From cccac8ee1ec482f75e00d004d431fbcc4d628494 Mon Sep 17 00:00:00 2001 From: wangjiming12 Date: Tue, 23 Jun 2026 11:57:21 +0800 Subject: [PATCH 001/295] fix(llm): fix ut test Signed-off-by: wangjiming12 --- .../sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java | 4 ++-- .../java/net/openan/a2at/sdk/client/api/A2ATClientTest.java | 4 ++-- .../main/java/net/openan/a2at/sdk/core/model/LlmConfig.java | 2 +- .../java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java | 4 ++-- .../openan/a2at/sdk/core/model/DotEnvConfigSourceTest.java | 4 ++-- .../java/net/openan/a2at/sdk/core/model/LlmConfigTest.java | 4 ++-- a2a-t-sample/src/main/resources/sample/client/client.env | 2 +- a2a-t-sample/src/main/resources/sample/server/server.env | 2 +- .../a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java | 4 ++-- client.env | 2 +- server.env | 2 +- 11 files changed, 17 insertions(+), 17 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index e9f0a6f4..5768d63b 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -39,7 +39,7 @@ public final class DefaultA2ATClientBuilder { private static final String LOCAL_RULE_PROVIDER = "local_rule"; - private static final String OPENAI_COMPATIBLE_PROVIDER = "openai_compatible"; + private static final String OPENAI_PROVIDER = "openai"; private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; @@ -172,7 +172,7 @@ private void requireSupportedConfig() { "Unsupported prompt source type: " + config.prompt().sourceType()); } if (!LOCAL_RULE_PROVIDER.equals(config.llm().provider()) - && !OPENAI_COMPATIBLE_PROVIDER.equals(config.llm().provider())) { + && !OPENAI_PROVIDER.equals(config.llm().provider())) { throw new UnsupportedOperationException( "Unsupported LLM provider: " + config.llm().provider()); } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index c5af83de..b3f0d06d 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -111,8 +111,8 @@ void pathBasedConstructorLoadsClasspathPromptResources() throws IOException { } @Test - void pathBasedConstructorAcceptsOpenAiCompatibleProvider() throws IOException { - Path envFile = writeMinimalClientEnv("openai_compatible"); + void pathBasedConstructorAcceptsOpenAiProvider() throws IOException { + Path envFile = writeMinimalClientEnv("openai"); A2ATClient client = new A2ATClient(envFile); PromptGenerationResult result = diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java index 28540e8e..e16931b5 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java @@ -22,7 +22,7 @@ public record LlmConfig( int sessionMaxTotal, int sessionMaxPerProvider) { - private static final String DEFAULT_PROVIDER = "openai_compatible"; + private static final String DEFAULT_PROVIDER = "openai"; private static final int DEFAULT_HISTORY_WINDOW = 12; diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java index b40d5bb4..f28bc818 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java @@ -44,7 +44,7 @@ void should_buildUnifiedConfig_When_envFileContainsAllKeys() throws IOException A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=local_file A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=openai_compatible + A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=gpt-4.1 A2AT_LLM_API_KEY=test-key A2AT_LLM_BASE_URL=https://api.openai.com/v1 @@ -62,7 +62,7 @@ void should_buildUnifiedConfig_When_envFileContainsAllKeys() throws IOException assertEquals("zh-CN", config.prompt().language()); assertEquals("local_file", config.prompt().sourceType()); assertEquals("prompt_resources", config.prompt().localRootDir()); - assertEquals("openai_compatible", config.llm().provider()); + assertEquals("openai", config.llm().provider()); assertEquals("gpt-4.1", config.llm().model()); assertEquals("test-key", config.llm().apiKey()); assertEquals("https://api.openai.com/v1", config.llm().baseUrl()); diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/DotEnvConfigSourceTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/DotEnvConfigSourceTest.java index cca4b5fa..cd347b18 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/DotEnvConfigSourceTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/DotEnvConfigSourceTest.java @@ -70,13 +70,13 @@ void should_skipCommentsAndEmptyLines_When_envFileContainsCommentsAndBlanks() th # This is a comment A2AT_LANGUAGE=en-US - A2AT_LLM_PROVIDER=openai_compatible + A2AT_LLM_PROVIDER=openai # Another comment """); Map values = DotEnvConfigSource.load(envFile); - assertEquals(Map.of("A2AT_LANGUAGE", "en-US", "A2AT_LLM_PROVIDER", "openai_compatible"), values); + assertEquals(Map.of("A2AT_LANGUAGE", "en-US", "A2AT_LLM_PROVIDER", "openai"), values); } /** diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java index 76cd2630..87ad25b3 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java @@ -24,7 +24,7 @@ class LlmConfigTest { * *

Scenario: An empty map is passed to fromMap(). * Expected result: All fields use predefined defaults: - * - provider: "openai_compatible" + * - provider: "openai" * - model, apiKey, baseUrl: empty strings * - historyWindow: 12 * - maxTokens: 2048 @@ -39,7 +39,7 @@ void should_useDefaults_When_keysAreMissing() { LlmConfig config = LlmConfig.fromMap(values); - assertEquals("openai_compatible", config.provider()); + assertEquals("openai", config.provider()); assertEquals("", config.model()); assertEquals("", config.apiKey()); assertEquals("", config.baseUrl()); diff --git a/a2a-t-sample/src/main/resources/sample/client/client.env b/a2a-t-sample/src/main/resources/sample/client/client.env index 9e7d640f..fd96c349 100644 --- a/a2a-t-sample/src/main/resources/sample/client/client.env +++ b/a2a-t-sample/src/main/resources/sample/client/client.env @@ -3,7 +3,7 @@ A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible +A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_BASE_URL=https://api.deepseek.com A2AT_LLM_API_KEY=${your_api_key} diff --git a/a2a-t-sample/src/main/resources/sample/server/server.env b/a2a-t-sample/src/main/resources/sample/server/server.env index b7d60fbf..fbb8beae 100644 --- a/a2a-t-sample/src/main/resources/sample/server/server.env +++ b/a2a-t-sample/src/main/resources/sample/server/server.env @@ -3,7 +3,7 @@ A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=local_file A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=../../../../../../a2a-t-resources/src/main/resources/prompt_resources -A2AT_LLM_PROVIDER=openai_compatible +A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_BASE_URL=https://api.deepseek.com A2AT_LLM_API_KEY=${your_api_key} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index 510ea887..5346db7d 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -30,7 +30,7 @@ public final class DefaultA2ATServerBuilder { private static final String LOCAL_RULE_PROVIDER = "local_rule"; - private static final String OPENAI_COMPATIBLE_PROVIDER = "openai_compatible"; + private static final String OPENAI_PROVIDER = "openai"; private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; @@ -163,7 +163,7 @@ private void requireSupportedConfig() { "Unsupported prompt source type: " + config.prompt().sourceType()); } if (!LOCAL_RULE_PROVIDER.equals(config.llm().provider()) - && !OPENAI_COMPATIBLE_PROVIDER.equals(config.llm().provider())) { + && !OPENAI_PROVIDER.equals(config.llm().provider())) { throw new UnsupportedOperationException("Unsupported LLM provider: " + config.llm().provider()); } if (!"in_memory".equals(config.negotiation().stateStoreType())) { diff --git a/client.env b/client.env index 8c86fec8..c7f9aeda 100644 --- a/client.env +++ b/client.env @@ -1,7 +1,7 @@ A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=local_file A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=a2a-t-resources/src/main/resources/prompt_resources -A2AT_LLM_PROVIDER=openai_compatible +A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL= A2AT_LLM_BASE_URL= A2AT_LLM_API_KEY=${your_api_key} diff --git a/server.env b/server.env index f31e2773..7d32d043 100644 --- a/server.env +++ b/server.env @@ -1,7 +1,7 @@ A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=local_file A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=a2a-t-resources/src/main/resources/prompt_resources -A2AT_LLM_PROVIDER=openai_compatible +A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL= A2AT_LLM_BASE_URL= A2AT_LLM_API_KEY=${your_api_key} From f4e521a2c2308cfe6dbb58aa429ef86a96a291df Mon Sep 17 00:00:00 2001 From: wangjiming12 Date: Tue, 23 Jun 2026 20:04:55 +0800 Subject: [PATCH 002/295] fix(doc): fix doc Signed-off-by: wangjiming12 --- docs/en/developer_guide.md | 4 ++-- docs/en/user_guide.md | 6 +++--- .../zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" | 4 ++-- .../zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 8dda2472..56238cb1 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -8,7 +8,7 @@ See the [User Guide 1.1 Feature Introduction](https://github.com/project-openan/ 1. JDK requirement is 17+. 2. Currently supports `classpath` and `local_file` prompt resources. The default source is the built-in resources packaged in the `a2a-t-resources` jar. -3. By default supports `local_rule` and `openai_compatible` LLM provider. +3. By default supports `local_rule` and `openai` LLM provider. 4. Negotiation state storage currently supports `in_memory`. 5. The SDK is not responsible for starting business HTTP services, user authentication, key management, or registry center deployment. @@ -109,7 +109,7 @@ Basic configuration example: A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible +A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_BASE_URL=https://api.deepseek.com A2AT_LLM_API_KEY={your_api_key} diff --git a/docs/en/user_guide.md b/docs/en/user_guide.md index 8c7a9ad0..481cecaa 100644 --- a/docs/en/user_guide.md +++ b/docs/en/user_guide.md @@ -71,7 +71,7 @@ Client configuration example: A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible +A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_BASE_URL=https://api.deepseek.com A2AT_LLM_API_KEY={your_api_key} @@ -88,7 +88,7 @@ Server configuration example: A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible +A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_BASE_URL=https://api.deepseek.com A2AT_LLM_API_KEY={your_api_key} @@ -154,7 +154,7 @@ The client will: | `A2AT_LANGUAGE` | Prompt resource language, commonly `zh-CN` | | `A2AT_PROMPT_SOURCE_TYPE` | Prompt resource source, supports `classpath` and `local_file`; default is `classpath` | | `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | Local prompt resource root directory | -| `A2AT_LLM_PROVIDER` | LLM provider, sample uses `openai_compatible` | +| `A2AT_LLM_PROVIDER` | LLM provider, sample uses `openai` | | `A2AT_LLM_MODEL` | Model name | | `A2AT_LLM_BASE_URL` | LLM service address | | `A2AT_LLM_API_KEY` | LLM API Key | diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 2294a2e8..bb3fa1f6 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -8,7 +8,7 @@ 1. JDK 要求为 17+。 2. 当前支持 `classpath` 和 `local_file` 提示词资源,默认从 jar 内置资源加载。 -3. 默认支持 `local_rule` 和 `openai_compatible` LLM provider。 +3. 默认支持 `local_rule` 和 `openai` LLM provider。 4. 协商状态存储当前支持 `in_memory`。 5. SDK 不负责启动业务 HTTP 服务、用户认证、密钥管理和注册中心部署。 @@ -109,7 +109,7 @@ A2ATClient client = new A2ATClient(Path.of("client.env")); A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible +A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_BASE_URL=https://api.deepseek.com A2AT_LLM_API_KEY={your_api_key} diff --git "a/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" "b/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" index f09ca1d3..d98cb343 100644 --- "a/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" +++ "b/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" @@ -71,7 +71,7 @@ mvn test A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible +A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_BASE_URL=https://api.deepseek.com A2AT_LLM_API_KEY={your_api_key} @@ -86,7 +86,7 @@ REGISTRY_CENTER_PORT=5001 A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible +A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_BASE_URL=https://api.deepseek.com A2AT_LLM_API_KEY={your_api_key} @@ -152,7 +152,7 @@ java @a2a-t-sample/target/client.javaargs.txt | `A2AT_LANGUAGE` | 提示词资源语言,常用 `zh-CN` | | `A2AT_PROMPT_SOURCE_TYPE` | 提示词资源来源,支持 `classpath` 和 `local_file`,默认 `classpath` | | `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | 本地提示词资源根目录 | -| `A2AT_LLM_PROVIDER` | LLM 提供方,sample 使用 `openai_compatible` | +| `A2AT_LLM_PROVIDER` | LLM 提供方,sample 使用 `openai` | | `A2AT_LLM_MODEL` | 模型名称 | | `A2AT_LLM_BASE_URL` | LLM 服务地址 | | `A2AT_LLM_API_KEY` | LLM API Key | From ed966739fce4d75cc28fbdb593f7a83d0c02f8ce Mon Sep 17 00:00:00 2001 From: wangjiming12 Date: Tue, 23 Jun 2026 20:17:05 +0800 Subject: [PATCH 003/295] fix(doc): fix sdk doc Signed-off-by: wangjiming12 --- README.md | 2 +- README_zh.md | 2 +- .../java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java | 2 +- a2a-t-sample/src/main/resources/sample/client/client.env | 2 +- a2a-t-sample/src/main/resources/sample/server/server.env | 2 +- docs/en/user_guide.md | 2 +- "docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 89025614..e617e47e 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Each module's `src/test/java` mirrors the main package structure, covering the n Before use, it is recommended to confirm the following limitations: -- The built-in LLM invocation chain is unified externally as an OpenAI-compatible adaptation layer. +- The built-in LLM invocation chain is unified externally as an OpenAI adaptation layer. - Prompt resources currently only support local files. - Negotiation state storage currently only provides an in-memory implementation and does not guarantee persistence. - The bundled resources and language coverage are limited, and do not include remote resource loading capabilities such as `registry-center`. diff --git a/README_zh.md b/README_zh.md index 770cec6b..a03d9595 100644 --- a/README_zh.md +++ b/README_zh.md @@ -88,7 +88,7 @@ SPDX-License-Identifier: Apache-2.0 使用前建议先确认以下限制: -- 内置 LLM 调用链对外统一为 OpenAI-compatible 适配层。 +- 内置 LLM 调用链对外统一为 OpenAI 适配层。 - 提示词资源目前仅支持本地文件。 - 协商状态存储目前仅提供内存实现,不保证持久化。 - 随包资源与语言覆盖有限,不包含 `registry-center`(注册中心)等远程资源加载能力。 diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java index 85698acc..e484338d 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java @@ -23,7 +23,7 @@ import net.openan.a2at.sdk.llm.LLMRuntimeError; /** - * OpenAI-compatible LLM provider client. + * OpenAI LLM provider client. * * @since 2026-06 */ diff --git a/a2a-t-sample/src/main/resources/sample/client/client.env b/a2a-t-sample/src/main/resources/sample/client/client.env index fd96c349..93efbc3e 100644 --- a/a2a-t-sample/src/main/resources/sample/client/client.env +++ b/a2a-t-sample/src/main/resources/sample/client/client.env @@ -1,5 +1,5 @@ # Copy this file to client.env before starting the a2a-java v1.0.0.Beta1 HTTP+JSON/REST sample client. -# Configure any OpenAI-compatible LLM endpoint before running the sample client. +# Configure any OpenAI LLM endpoint before running the sample client. A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= diff --git a/a2a-t-sample/src/main/resources/sample/server/server.env b/a2a-t-sample/src/main/resources/sample/server/server.env index fbb8beae..8be04595 100644 --- a/a2a-t-sample/src/main/resources/sample/server/server.env +++ b/a2a-t-sample/src/main/resources/sample/server/server.env @@ -1,5 +1,5 @@ # Copy this file to server.env before starting the a2a-java v1.0.0.Beta1 HTTP+JSON/REST sample server. -# Configure any OpenAI-compatible LLM endpoint before running the sample server. +# Configure any OpenAI LLM endpoint before running the sample server. A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=local_file A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=../../../../../../a2a-t-resources/src/main/resources/prompt_resources diff --git a/docs/en/user_guide.md b/docs/en/user_guide.md index 481cecaa..4f8bb2c9 100644 --- a/docs/en/user_guide.md +++ b/docs/en/user_guide.md @@ -36,7 +36,7 @@ The Java SDK is typically used in the following scenarios: | --- | --- | | JDK | 17+ | | Build Tool | Maven 3.8+ | -| LLM | Accessible OpenAI-compatible service and API Key | +| LLM | Accessible OpenAI service and API Key | | Registry Center | registry-center must be started before running Java sample | | A2A Java | sample based on `a2a-java v1.0.0.Beta1` | diff --git "a/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" "b/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" index d98cb343..57b221b0 100644 --- "a/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" +++ "b/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" @@ -36,7 +36,7 @@ Java SDK 通常用于以下场景: | --- | --- | | JDK | 17+ | | 构建工具 | Maven 3.8+ | -| LLM | 可访问的 OpenAI-compatible 服务及 API Key | +| LLM | 可访问的 OpenAI 服务及 API Key | | 注册中心 | 运行 Java sample 时需要先启动 registry-center | | A2A Java | sample 基于 `a2a-java v1.0.0.Beta1` | From 9aade0fd0c2597f98617bf4580f24f66c98bb059 Mon Sep 17 00:00:00 2001 From: zhouxuancheng Date: Tue, 23 Jun 2026 22:18:40 +0800 Subject: [PATCH 004/295] ci: add maven central release workflow Signed-off-by: zhouxuancheng --- .github/workflows/release.yml | 65 +++++++++++++++++++++++++++++++++++ pom.xml | 26 ++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..c0756605 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,65 @@ +name: Release to Maven Central + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +permissions: + contents: read + +jobs: + release: + name: Publish Maven artifacts + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: GPG_PASSPHRASE + env: + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + + - name: Configure Maven servers + shell: bash + env: + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: | + mkdir -p ~/.m2 + cat > ~/.m2/settings.xml < + + + central + ${CENTRAL_USERNAME} + ${CENTRAL_PASSWORD} + + + gpg.passphrase + ${GPG_PASSPHRASE} + + + + EOF + + - name: Verify + run: mvn -B verify + + - name: Publish + env: + GPG_KEYNAME: ${{ secrets.GPG_KEYNAME }} + run: > + mvn -B -Prelease deploy + -Dgpg.executable=gpg + -Dgpg.keyname="${GPG_KEYNAME}" diff --git a/pom.xml b/pom.xml index f9847d5f..e545a8f6 100644 --- a/pom.xml +++ b/pom.xml @@ -116,6 +116,32 @@ OpenAN https://openan.net + + + Yu Gong + gongyu23@huawei.com + + + Jiansheng Jiang + jiangjiansheng1@huawei.com + + + Shunhao Wu + wushunhao1@huawei.com + + + Lei Yin + yinlei13@huawei.com + + + Jiming Wang + wangjiming12@huawei.com + + + Jie Zou + zoujie5@huawei.com + + https://github.com/project-openan/a2a-t-sdk-java From bf68c068e1284bc0364ef6541b13638c16809335 Mon Sep 17 00:00:00 2001 From: zhouxuancheng Date: Tue, 23 Jun 2026 22:36:08 +0800 Subject: [PATCH 005/295] build: set release version to 1.0.0 Signed-off-by: zhouxuancheng --- pom.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e545a8f6..7424c8ed 100644 --- a/pom.xml +++ b/pom.xml @@ -25,7 +25,7 @@ - 0.9.0 + 1.0.0 17 UTF-8 3.3.1 @@ -141,6 +141,10 @@ Jie Zou zoujie5@huawei.com + + Xuancheng Zhou + zhouxuancheng@chinamobile.com + From be8db5e022c1168ea54d2fafc9f3ed856e3a0b3b Mon Sep 17 00:00:00 2001 From: zhouxuancheng Date: Tue, 23 Jun 2026 22:40:26 +0800 Subject: [PATCH 006/295] ci: fix maven central signing secrets Signed-off-by: zhouxuancheng --- .github/workflows/release.yml | 39 ++++++++++++++++++++++------------- pom.xml | 2 -- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0756605..28796965 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,23 +18,37 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Validate release secrets + shell: bash + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + GPG_KEY_CONTENTS: ${{ secrets.GPG_KEY_CONTENTS }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + run: | + test -n "${MAVEN_CENTRAL_USERNAME}" || { echo "MAVEN_CENTRAL_USERNAME is not set"; exit 1; } + test -n "${MAVEN_CENTRAL_PASSWORD}" || { echo "MAVEN_CENTRAL_PASSWORD is not set"; exit 1; } + test -n "${GPG_KEY_CONTENTS}" || { echo "GPG_KEY_CONTENTS is not set"; exit 1; } + test -n "${SIGNING_KEY_ID}" || { echo "SIGNING_KEY_ID is not set"; exit 1; } + test -n "${SIGNING_PASSWORD}" || { echo "SIGNING_PASSWORD is not set"; exit 1; } + - name: Set up JDK uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' cache: maven - gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} - gpg-passphrase: GPG_PASSPHRASE + gpg-private-key: ${{ secrets.GPG_KEY_CONTENTS }} + gpg-passphrase: SIGNING_PASSWORD env: - GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - name: Configure Maven servers shell: bash env: - CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} - CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} - GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} run: | mkdir -p ~/.m2 cat > ~/.m2/settings.xml < central - ${CENTRAL_USERNAME} - ${CENTRAL_PASSWORD} - - - gpg.passphrase - ${GPG_PASSPHRASE} + ${MAVEN_CENTRAL_USERNAME} + ${MAVEN_CENTRAL_PASSWORD} @@ -58,8 +68,9 @@ jobs: - name: Publish env: - GPG_KEYNAME: ${{ secrets.GPG_KEYNAME }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.SIGNING_PASSWORD }} run: > mvn -B -Prelease deploy -Dgpg.executable=gpg - -Dgpg.keyname="${GPG_KEYNAME}" + -Dgpg.keyname="${SIGNING_KEY_ID}" diff --git a/pom.xml b/pom.xml index 7424c8ed..7da6386e 100644 --- a/pom.xml +++ b/pom.xml @@ -254,8 +254,6 @@ ${maven.gpg.plugin.version} ${gpg.executable} - ${gpg.keyname} - gpg.passphrase --pinentry-mode loopback From 7fee461c27292317620bb88880c5264e1015f47d Mon Sep 17 00:00:00 2001 From: zhouxuancheng Date: Tue, 23 Jun 2026 22:56:43 +0800 Subject: [PATCH 007/295] build: flatten published maven poms Signed-off-by: zhouxuancheng --- .gitignore | 3 ++- pom.xml | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0189904a..7cda5cb0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target/ *target/ -.gitignore \ No newline at end of file +.gitignore +.flattened-pom.xml diff --git a/pom.xml b/pom.xml index 7da6386e..3973864a 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,7 @@ 3.13.0 3.2.5 3.2.4 + 1.6.0 0.9.0 2.44.5 2.55.0 @@ -167,6 +168,31 @@ org.apache.maven.plugins maven-javadoc-plugin + + org.codehaus.mojo + flatten-maven-plugin + ${flatten.maven.plugin.version} + + resolveCiFriendliesOnly + true + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + org.sonatype.central central-publishing-maven-plugin From 602be666795d6bb702fb55514781043e93fd0bb0 Mon Sep 17 00:00:00 2001 From: zhouxuancheng Date: Tue, 23 Jun 2026 23:34:50 +0800 Subject: [PATCH 008/295] ci: wait for central validation Signed-off-by: zhouxuancheng --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 3973864a..30951652 100644 --- a/pom.xml +++ b/pom.xml @@ -34,7 +34,7 @@ 3.2.5 3.2.4 1.6.0 - 0.9.0 + 0.11.0 2.44.5 2.55.0 5.10.2 @@ -201,7 +201,7 @@ central true - published + validated From f7ef5cb9896dc38a950c6b21d1996e4b10852500 Mon Sep 17 00:00:00 2001 From: ChuanyuChen Date: Wed, 15 Jul 2026 15:53:57 +0800 Subject: [PATCH 009/295] Init the MAINTAINERS.md and CODEOWNERS Signed-off-by: ChuanyuChen --- CODEOWNERS | 9 +++++++++ MAINTAINERS.md | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 CODEOWNERS create mode 100644 MAINTAINERS.md diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..64e99f94 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,9 @@ +# Code owners for this repository. +# Each line is a file pattern followed by one or more owners. +# The last matching pattern takes precedence. +# Reference: https://docs.github.com/articles/about-code-owners + +# The maintainers team owns all content in this repository, +# including this file and MAINTAINERS.md, and is automatically +# requested for review on every pull request. +* @project-openan/maintainers-a2a-t-sdk diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 00000000..1551cc11 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,26 @@ +# Maintainers + +This file lists the maintainers of this repository. Maintainers have write access and are responsible for reviewing and merging contributions. Changes to this file are made by pull request and reviewed by the existing maintainers. + +| Organization | Name | GitHub Handle | +| ------------ | ---- | ------------- | +| Huawei | Yonglong Wang | @wangyonglong12111 | +| Huawei | Wenjun Zhang | @zhangwenjun-huawei | +| The Linux Foundation | Casey Cain | @wrathwolf | + +## Emeritus Maintainers + +Maintainers who are no longer active but have made significant contributions to this repository. + +| Organization | Name | GitHub Handle | +| ------------ | ---- | ------------- | +| (none yet) | | | + +## Becoming a Maintainer + +Maintainer status is granted by the existing maintainers of this repository, or by the TSC, based on a track record of sustained, quality contributions. To be considered, a contributor should demonstrate: + +- Consistent contributions over time (code, reviews, documentation) +- Understanding of the component's goals and architecture +- Constructive participation in community discussions +- Adherence to the project's Code of Conduct and Contributing guidelines From 542f7859c80afa7eb2c62b61bf7532eb0485bdbc Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Wed, 5 Aug 2026 21:41:35 +0800 Subject: [PATCH 010/295] fix: blur the location information Signed-off-by: zhangwenjun-huawei --- .../scenarios/zh-CN/scenarios.json | 2 +- .../slots/fault_diagnosis/zh-CN/slot.json | 6 ++-- .../fault_diagnosis/zh-CN/template.md | 28 ++++--------------- 3 files changed, 9 insertions(+), 27 deletions(-) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json index d003c924..a4442394 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json @@ -22,7 +22,7 @@ "scenario_code": "fault_diagnosis", "scenario_name": "故障根因诊断", "description": "根据事件识别输出的事件信息和初步定界分析、初步根因分析、初步影响分析等信息,输出根因定位信息及建议处理方案。", - "example": "请根据如下信息进行故障事件的根因定位,输出修复建议和业务影响分析:事件流水号是1856365516_2839324485_2130908106_4130674041,标题为5G基站退服事件,事件关联的告警厂家内部流水号为123211121244,事件关联告警在网管系统内唯一标识为0108-007-012-10-000003,事件关联告警标题是gNodeB退服告警,事件产生时间为2025-05-22 08:40:44,告警级别是四级,网元名称为泉州惠安-惠安张坂玉山灯杆G-HRHH。" + "example": "请根据如下信息进行故障事件的根因定位,输出修复建议和业务影响分析:事件流水号是1856365516_2839324485_2130908106_4130674041,标题为5G基站退服事件,事件关联的告警厂家内部流水号为123211121244,事件关联告警在网管系统内唯一标识为0108-007-012-10-000003,事件关联告警标题是gNodeB退服告警,事件产生时间为2025-05-22 08:40:44,告警级别是四级,网元名称为QZHA-HAZBYSDGG-HRHH。" } ] } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/zh-CN/slot.json index 5287ca60..4600e60e 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/zh-CN/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/zh-CN/slot.json @@ -15,9 +15,9 @@ "type": "string", "description": "故障根因诊断任务的请求参数", "examples": [ - "有1条待诊断的告警事件,具体内容如下:\r\n\r\n- 事件流水号是1856365516_2839324485_2130908106_4130674041,标题为5G基站退服事件,事件关联的告警厂家内部流水号为123211121244,事件关联告警在网管系统内唯一标识为0108-007-012-10-000003,事件关联告警标题是gNodeB退服告警,事件产生时间为2025-05-22 08:40:44,告警级别是四级,网元名称为泉州惠安-惠安张坂玉山灯杆G-HRHH。" + "有1条待诊断的告警事件,具体内容如下:\r\n\r\n- 事件流水号是1856365516_2839324485_2130908106_4130674041,标题为5G基站退服事件,事件关联的告警厂家内部流水号为123211121244,事件关联告警在网管系统内唯一标识为0108-007-012-10-000003,事件关联告警标题是gNodeB退服告警,事件产生时间为2025-05-22 08:40:44,告警级别是四级,网元名称为QZHA-HAZBYSDGG-HRHH。" ], - "x-a2at-value-constraint": "故障根因诊断任务可包含多条告警事件,每条事件可包含以下信息:\r\n1. 故障事件流水号,必选。样例:1856365516_2839324485_2130908106_4130674041\r\n2. 事件标题名称,必选。样例:5G基站退服事件\r\n3. 事件关联的告警厂家内部流水号,必选。样例:123211121244\r\n4. 事件关联的传输专业告警扩展流水号,可选。\r\n5. 事件关联告警在网管系统内唯一标识,必选。样例:0108-007-012-10-000003\r\n6. 事件关联告警标题,必选。样例:gNodeB退服告警\r\n7. 事件产生时间,必选。样例:2025-05-22 08:40:44\r\n8. 告警级别,数字越小越严重。一级告警(Critical);二级告警(Major);三级告警(Minor);四级告警(Warning)。必选。样例:四级告警\r\n9. 基站ID,无线工作台用。可选。样例:2148660\r\n10. 网元名称,必选。样例:泉州惠安-惠安张坂玉山灯杆G-HRHH\r\n11. 电路号,可选。样例:泉州惠安张坂惠南联兴-泉州惠安局楼10GE1097086NR\r\n12. 机房信息,可选。样例:泉州惠安张坂玉山一楼自建机房" + "x-a2at-value-constraint": "故障根因诊断任务可包含多条告警事件,每条事件可包含以下信息:\r\n1. 故障事件流水号,必选。样例:1856365516_2839324485_2130908106_4130674041\r\n2. 事件标题名称,必选。样例:5G基站退服事件\r\n3. 事件关联的告警厂家内部流水号,必选。样例:123211121244\r\n4. 事件关联的传输专业告警扩展流水号,可选。\r\n5. 事件关联告警在网管系统内唯一标识,必选。样例:0108-007-012-10-000003\r\n6. 事件关联告警标题,必选。样例:gNodeB退服告警\r\n7. 事件产生时间,必选。样例:2025-05-22 08:40:44\r\n8. 告警级别,数字越小越严重。一级告警(Critical);二级告警(Major);三级告警(Minor);四级告警(Warning)。必选。样例:四级告警\r\n9. 基站ID,无线工作台用。可选。样例:2148660\r\n10. 网元名称,必选。样例:QZHA-HAZBYSDGG-HRHH\r\n11. 电路号,可选。样例:QZHAZBHNLX-QZHAJL10GE1097086NR\r\n12. 机房信息,可选。样例:QZHAZBYS一楼自建机房" }, "预期输出": { "type": "string", @@ -25,7 +25,7 @@ "examples": [ "针对预期返回的故障诊断结果,需要声明如下信息:\r\n\r\n1. 需要声明基于A2A的哪一类Part承载,如DataPart或TextPart。\r\n2. 需要声明包含哪些信息要素(如诊断结果、定位结论、定位分析过程等) " ], - "x-a2at-value-constraint": "故障根因诊断任务可包含多条告警事件,每条事件可包含以下信息:\r\n1. 故障事件流水号,必选。样例:1856365516_2839324485_2130908106_4130674041\r\n2. 事件标题名称,必选。样例:5G基站退服事件\r\n3. 事件关联的告警厂家内部流水号,必选。样例:123211121244\r\n4. 事件关联的传输专业告警扩展流水号,可选。\r\n5. 事件关联告警在网管系统内唯一标识,必选。样例:0108-007-012-10-000003\r\n6. 事件关联告警标题,必选。样例:gNodeB退服告警\r\n7. 事件产生时间,必选。样例:2025-05-22 08:40:44\r\n8. 告警级别,数字越小越严重。一级告警(Critical);二级告警(Major);三级告警(Minor);四级告警(Warning)。必选。样例:四级告警\r\n9. 基站ID,无线工作台用。可选。样例:2148660\r\n10. 网元名称,必选。样例:泉州惠安-惠安张坂玉山灯杆G-HRHH\r\n11. 电路号,可选。样例:泉州惠安张坂惠南联兴-泉州惠安局楼10GE1097086NR\r\n12. 机房信息,可选。样例:泉州惠安张坂玉山一楼自建机房" + "x-a2at-value-constraint": "故障根因诊断任务可包含多条告警事件,每条事件可包含以下信息:\r\n1. 故障事件流水号,必选。样例:1856365516_2839324485_2130908106_4130674041\r\n2. 事件标题名称,必选。样例:5G基站退服事件\r\n3. 事件关联的告警厂家内部流水号,必选。样例:123211121244\r\n4. 事件关联的传输专业告警扩展流水号,可选。\r\n5. 事件关联告警在网管系统内唯一标识,必选。样例:0108-007-012-10-000003\r\n6. 事件关联告警标题,必选。样例:gNodeB退服告警\r\n7. 事件产生时间,必选。样例:2025-05-22 08:40:44\r\n8. 告警级别,数字越小越严重。一级告警(Critical);二级告警(Major);三级告警(Minor);四级告警(Warning)。必选。样例:四级告警\r\n9. 基站ID,无线工作台用。可选。样例:2148660\r\n10. 网元名称,必选。样例:QZHA-HAZBYSDGG-HRHH\r\n11. 电路号,可选。样例:QZHAZBHNLX-QZHAJL10GE1097086NR\r\n12. 机房信息,可选。样例:QZHAZBYS一楼自建机房" } }, "required": [ diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/zh-CN/template.md index fa6b8f50..831ec01e 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/zh-CN/template.md @@ -1,30 +1,17 @@ ## 任务类型(Task Type) 故障根因诊断。 - - ## 任务描述(Task Description) - 根据事件识别输出的事件信息和初步定界分析、初步根因分析、初步影响分析等信息,进行故障事件的根因定位,输出根因定位信息及建议处理方案。 - - ## 任务目标(Task Target) - {{任务目标}}(必选) - 要求:说明对故障诊断任务的具体要求,如完成故障事件的根因定位,输出专业根因定位信息、故障根因专业分类、故障根因详情、远程处置方案、现场抢修方案及业务影响分析。 - 样例:请进行故障事件的根因定位,输出专业根因定位信息、故障根因专业分类、故障根因详情、远程处置方案、现场抢修方案及业务影响分析。 - - ## 任务上下文(Task Context) - {{任务上下文}}(必选) - 要求:故障根因诊断任务可包含多条告警事件,每条事件可包含以下信息: - 1. 故障事件流水号,必选。样例:1856365516_2839324485_2130908106_4130674041 2. 事件标题名称,必选。样例:5G基站退服事件 3. 事件关联的告警厂家内部流水号,必选。样例:123211121244 @@ -34,25 +21,20 @@ 7. 事件产生时间,必选。样例:2025-05-22 08:40:44 8. 告警级别,数字越小越严重。一级告警(Critical);二级告警(Major);三级告警(Minor);四级告警(Warning)。必选。样例:四级告警 9. 基站ID,无线工作台用。可选。样例:2148660 -10. 网元名称,必选。样例:泉州惠安-惠安张坂玉山灯杆G-HRHH -11. 电路号,可选。样例:泉州惠安张坂惠南联兴-泉州惠安局楼10GE1097086NR -12. 机房信息,可选。样例:泉州惠安张坂玉山一楼自建机房 +10. 网元名称,必选。样例:QZHA-HAZBYSDGG-HRHH +11. 电路号,可选。样例:QZHAZBHNLX-QZHAJL10GE1097086NR +12. 机房信息,可选。样例:QZHAZBYS一楼自建机房 样例:有1条待诊断的告警事件,具体内容如下: - -- 事件流水号是1856365516_2839324485_2130908106_4130674041,标题为5G基站退服事件,事件关联的告警厂家内部流水号为123211121244,事件关联告警在网管系统内唯一标识为0108-007-012-10-000003,事件关联告警标题是gNodeB退服告警,事件产生时间为2025-05-22 08:40:44,告警级别是四级,网元名称为泉州惠安-惠安张坂玉山灯杆G-HRHH。 +- 事件流水号是1856365516_2839324485_2130908106_4130674041,标题为5G基站退服事件,事件关联的告警厂家内部流水号为123211121244,事件关联告警在网管系统内唯一标识为0108-007-012-10-000003,事件关联告警标题是gNodeB退服告警,事件产生时间为2025-05-22 08:40:44,告警级别是四级,网元名称为QZHA-HAZBYSDGG-HRHH。 ## 预期输出(Expected Output) {{预期输出}}(可选) - 要求:针对预期返回的故障诊断结果,需要声明如下信息: - 1. 需要声明基于A2A的哪一类Part承载,如DataPart或TextPart。 2. 需要声明包含哪些信息要素(如诊断结果、定位结论、定位分析过程等) - -样例:要求通过TextPart返回故障诊断结果。故障诊断结果的具体内容要求如下: - +样例:要求通过TextPart返回故障诊断结果。故障诊断结果的具体内容要求如下: 1. 故障根因定位结果的取值范围包括:成功、驳回、失败、其他。 2. 故障根因详细信息,包括 故障根因的名称、详情、修复建议(含远程处理和现场抢修)、定位分析过程、业务影响, 故障根因点所在资源对象的标识、类型,名称,详细位置等信息。 From 9cb1c687168152a5bdaeb567fe43c2825a42a3ce Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Wed, 5 Aug 2026 21:49:53 +0800 Subject: [PATCH 011/295] feat: add English templates Signed-off-by: zhangwenjun-huawei --- .../scenarios/en-US/scenarios.json | 28 ++++++++++++ .../slots/energy_saving/en-US/slot.json | 40 +++++++++++++++++ .../slots/fault_diagnosis/en-US/slot.json | 35 +++++++++++++++ .../private_line_complaint/en-US/slot.json | 34 ++++++++++++++ .../slots/subscribe_incident/en-US/slot.json | 33 ++++++++++++++ .../templates/energy_saving/en-US/template.md | 44 +++++++++++++++++++ .../fault_diagnosis/en-US/template.md | 40 +++++++++++++++++ .../private_line_complaint/en-US/template.md | 33 ++++++++++++++ .../subscribe_incident/en-US/template.md | 21 +++++++++ 9 files changed, 308 insertions(+) create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/en-US/slot.json create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/en-US/slot.json create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/slots/private_line_complaint/en-US/slot.json create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/slots/subscribe_incident/en-US/slot.json create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/en-US/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/en-US/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/private_line_complaint/en-US/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/subscribe_incident/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json new file mode 100644 index 00000000..79a2026e --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json @@ -0,0 +1,28 @@ +{ + "scenarios": [ + { + "scenario_code": "subscribe_incident", + "scenario_name": "Incident Event Subscription", + "description": "Used to generate an A2A-T task request for subscribing to Incident events in a telecom network management system, describing the subscription target, conditions, and reporting method for network-side faults, alarms, or Incident notifications.", + "example": "Help me generate an Incident subscription task: subscribe to fiber break and pigtail fault for base station equipment, only critical and high severity, reported via DataPart." + }, + { + "scenario_code": "energy_saving", + "scenario_name": "Energy Efficiency Optimization Task", + "description": "Used to generate an A2A-T task request for an energy efficiency optimization task in a telecom network management system.", + "example": "Achieve a 30% energy consumption reduction in the Songshanhu area while ensuring the rate guarantee is no less than 50Mbps." + }, + { + "scenario_code": "private_line_complaint", + "scenario_name": "Private Line Complaint", + "description": "Used to generate an A2A-T task request for network-side fault root cause diagnosis in a private line complaint scenario in a telecom network management system.", + "example": "Please confirm the root cause of fault fault-id-1-328-20260510-58489 that occurred on the Guangdong enterprise private line at 2026-05-10T18:25:30Z, and provide repair suggestions." + }, + { + "scenario_code": "fault_diagnosis", + "scenario_name": "Fault Root Cause Diagnosis", + "description": "Based on event information output by event recognition and preliminary demarcation analysis, preliminary root cause analysis, and preliminary impact analysis, output root cause localization information and recommended handling plans.", + "example": "Please perform root cause localization for the fault event based on the following information, and output repair suggestions and service impact analysis: the event sequence number is 1856365516_2839324485_2130908106_4130674041, titled 5G base station outage event, the alarm vendor internal sequence number associated with the event is 123211121244, the unique identifier of the associated alarm in the NMS is 0108-007-012-10-000003, the associated alarm title is gNodeB outage alarm, the event generation time is 2025-05-22 08:40:44, the alarm severity is level 4, and the network element name is QZHA-HAZBYSDGG-HRHH." + } + ] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/en-US/slot.json new file mode 100644 index 00000000..ce3ad712 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/en-US/slot.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "task_target": { + "type": "string", + "description": "Define the quantitative or qualitative goals the task must achieve, such as energy efficiency goals and rate guarantee goals.", + "examples": [ + "The goal is to achieve a 30% energy consumption reduction while ensuring the rate guarantee is no less than 50Mbps." + ], + "x-a2at-value-constraint": "Energy consumption goal: the gain target of energy saving. The target value format must be a percentage, such as the percentage by which total power consumption is reduced. Rate guarantee goal: the minimum downlink throughput target that must be guaranteed." + }, + "task_object": { + "type": "string", + "description": "Explicitly specify the energy saving area information, a geographic or logical area (e.g., district-level administrative unit) or longitude/latitude coordinates.", + "examples": [ + "The area is the Songshanhu Administration Committee." + ], + "x-a2at-value-constraint": "Geographic/logical area name. The area information (important) must be a concrete physical address." + }, + "task_context": { + "type": "string", + "description": "Provide the background and context information for task execution.", + "examples": [ + "" + ], + "x-a2at-value-constraint": "" + }, + "constraints": { + "type": "string", + "description": "List the rules, restrictions, and boundary conditions that must be observed during task execution. Constraints apply to all scenarios within the area by default; constraints applicable only to specified scenarios within the area should be stated separately.", + "examples": [ + "Adopt an NR cell energy saving plan, and set the 1650 frequency as the base layer for carrier shutdown. For the energy saving strategy, the system will take an aggressive energy saving mode, provided that experience-lossless is ensured, i.e., performed without affecting user perception. This energy saving task recurs daily, with the effective time range from 08:00:00 to 12:00:00 each day. In addition, the plan includes two scenario-specific constraints: in the business district scenario, the 1300 frequency is forbidden to serve as the capacity layer; in the black forest scenario, enabling sleep is forbidden between 0:00 and 6:00." + ], + "x-a2at-value-constraint": "1. Cell type to apply energy saving (indoor/outdoor/all) 2. Cell radio access technology for energy saving (LTE/NR/all) 3. Frequency requirements for the base layer and capacity layer, frequencies must be expressed as downlink frequency numbers 4. Whether the energy saving aggressiveness is aggressive or conservative 5. Whether experience-lossless energy saving is required 6. The time range for executing energy saving, recurring daily 7. Constraints applicable to specified scenarios within the area (e.g., railway/black forest/business district/residential area) 8. Whether turning off carrier frequencies is allowed during energy saving" + } + }, + "required": [] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/en-US/slot.json new file mode 100644 index 00000000..8cf1d677 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/en-US/slot.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "task_target": { + "type": "string", + "description": "The specific goal of fault root cause diagnosis.", + "examples": [ + "Please perform root cause localization for the fault event, and output professional root cause localization information, professional classification of the fault root cause, fault root cause details, remote handling plan, on-site emergency repair plan, and service impact analysis." + ], + "x-a2at-value-constraint": "State the specific requirements for the fault diagnosis task, such as completing root cause localization of the fault event, and outputting professional root cause localization information, professional classification of the fault root cause, fault root cause details, remote handling plan, on-site emergency repair plan, and service impact analysis." + }, + "task_context": { + "type": "string", + "description": "The request parameters of the fault root cause diagnosis task.", + "examples": [ + "There is 1 alarm event to be diagnosed, with the following details:\r\n\r\n- The event sequence number is 1856365516_2839324485_2130908106_4130674041, titled 5G base station outage event, the alarm vendor internal sequence number associated with the event is 123211121244, the unique identifier of the associated alarm in the NMS is 0108-007-012-10-000003, the associated alarm title is gNodeB outage alarm, the event generation time is 2025-05-22 08:40:44, the alarm severity is level 4, and the network element name is QZHA-HAZBYSDGG-HRHH." + ], + "x-a2at-value-constraint": "A fault root cause diagnosis task may contain multiple alarm events. Each event may contain the following information:\r\n1. Fault event sequence number. Required. Example: 1856365516_2839324485_2130908106_4130674041\r\n2. Event title name. Required. Example: 5G base station outage event\r\n3. Alarm vendor internal sequence number associated with the event. Required. Example: 123211121244\r\n4. Transport-domain alarm extended sequence number associated with the event. Optional.\r\n5. Unique identifier of the associated alarm in the NMS. Required. Example: 0108-007-012-10-000003\r\n6. Associated alarm title. Required. Example: gNodeB outage alarm\r\n7. Event generation time. Required. Example: 2025-05-22 08:40:44\r\n8. Alarm severity; the smaller the number, the more severe. Level 1 alarm (Critical); Level 2 alarm (Major); Level 3 alarm (Minor); Level 4 alarm (Warning). Required. Example: Level 4 alarm\r\n9. Base station ID, used by the wireless workbench. Optional. Example: 2148660\r\n10. Network element name. Required. Example: QZHA-HAZBYSDGG-HRHH\r\n11. Circuit ID. Optional. Example: QZHAZBHNLX-QZHAJL10GE1097086NR\r\n12. Equipment room information. Optional. Example: Self-built equipment room on the first floor of QZHAZBYS" + }, + "expected_output": { + "type": "string", + "description": "The specific requirements for the returned fault diagnosis result.", + "examples": [ + "For the expected fault diagnosis result to be returned, the following information must be declared:\r\n\r\n1. Declare which type of A2A Part carries it, such as DataPart or TextPart.\r\n2. Declare which information elements are included (e.g., diagnosis result, localization conclusion, localization analysis process, etc.) " + ], + "x-a2at-value-constraint": "A fault root cause diagnosis task may contain multiple alarm events. Each event may contain the following information:\r\n1. Fault event sequence number. Required. Example: 1856365516_2839324485_2130908106_4130674041\r\n2. Event title name. Required. Example: 5G base station outage event\r\n3. Alarm vendor internal sequence number associated with the event. Required. Example: 123211121244\r\n4. Transport-domain alarm extended sequence number associated with the event. Optional.\r\n5. Unique identifier of the associated alarm in the NMS. Required. Example: 0108-007-012-10-000003\r\n6. Associated alarm title. Required. Example: gNodeB outage alarm\r\n7. Event generation time. Required. Example: 2025-05-22 08:40:44\r\n8. Alarm severity; the smaller the number, the more severe. Level 1 alarm (Critical); Level 2 alarm (Major); Level 3 alarm (Minor); Level 4 alarm (Warning). Required. Example: Level 4 alarm\r\n9. Base station ID, used by the wireless workbench. Optional. Example: 2148660\r\n10. Network element name. Required. Example: QZHA-HAZBYSDGG-HRHH\r\n11. Circuit ID. Optional. Example: QZHAZBHNLX-QZHAJL10GE1097086NR\r\n12. Equipment room information. Optional. Example: Self-built equipment room on the first floor of QZHAZBYS" + } + }, + "required": [ + "task_target", + "task_context" + ] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/private_line_complaint/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/private_line_complaint/en-US/slot.json new file mode 100644 index 00000000..30437d86 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/private_line_complaint/en-US/slot.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "task_object": { + "type": "string", + "description": "Private line name, access port name, and access port resource ID; provide one of the three parameters to identify the private line service object.", + "examples": [ + "Access port resource ID: f47ac10b-58cc-4372-a567-0e02b2c3d479" + ], + "x-a2at-value-constraint": "Private line name: the private line object identifier in the network-side EMS NMS. Access port name: the access port name in the network-side EMS NMS, in the format: network element name + board number + board model + port number. Access port resource ID: the access port resource object identifier in the network-side EMS NMS." + }, + "task_context": { + "type": "string", + "description": "Provide the fault phenomenon description of the private line service and the context information of the diagnosis task.", + "examples": [ + "Fault phenomenon: private line interruption. Fault sequence number: fault-id-1-017-20230511-09013" + ], + "x-a2at-value-constraint": "Fault phenomenon: includes two scenarios, \"private line interruption\" and \"poor private line quality\". Required parameter. Fault occurrence time. Optional parameter. Fault or event sequence number on the OSS side. Optional parameter." + }, + "expected_output": { + "type": "string", + "description": "Expected output of the private line complaint task.", + "examples": [ + "Return the detailed information and repair suggestions of the private line complaint diagnosis task that was successfully diagnosed." + ], + "x-a2at-value-constraint": "Include the diagnosis result type, diagnosis result details, and repair suggestions. The allowed values of the diagnosis result type parameter include: diagnosis successful, diagnosis failed, diagnosis not started, or diagnosis not finished. Multiple fault root causes may be included. Each fault root cause includes: the name of the fault root cause, detailed description, repair suggestions, and the identifier, type, name, and detailed location of the resource object where the fault root cause point resides. The detailed description of the overall fault, and the repair suggestions for the fault root cause. If multiple fault root causes exist, the detailed descriptions and repair suggestions across the multiple fault root causes must be summarized and refined into the overall diagnosis result details and repair suggestions." + } + }, + "required": [ + "task_context" + ] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/subscribe_incident/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/subscribe_incident/en-US/slot.json new file mode 100644 index 00000000..801ef618 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/subscribe_incident/en-US/slot.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "notification_topic": { + "type": "string", + "description": "The name of the notification topic to be subscribed, used to define the subscription target.", + "examples": [ + "Incident" + ], + "x-a2at-value-constraint": "The specific topic name can be: Incident, Fault, intelligent fault, fault, etc." + }, + "subscribe_condition": { + "type": "string", + "description": "The condition description to be subscribed, used to define subscription constraints such as fault name and fault severity.", + "examples": [ + "Fault priority: critical, high. Fault name: fiber break, board fault.", + "Fault priority: high, medium. Fault name: board fault, main control board abnormal, port abnormal." + ], + "x-a2at-value-constraint": "The subscription condition may include fault priority and fault name. Fault priority supports a list form. Priority semantics support synonymous Chinese/English expressions; a match on either the Chinese or English term is considered valid: urgent/critical, high/high, medium/medium, low/low. Fault name supports a list form, with allowed values being the list of network-side fault names, for example: pigtail fault, fiber break, board fault, optical module fault, etc." + }, + "notification_data_format": { + "type": "string", + "description": "The notification data format description to be reported, used to define the data type and carrying format.", + "examples": [ + "Report Incident data via DataPart." + ], + "x-a2at-value-constraint": "May describe the reported data type and carrying Part. The data type can be Incident, fault, etc.; the carrying format can be an A2A Part, such as DataPart or TextPart." + } + }, + "required": [] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/en-US/template.md new file mode 100644 index 00000000..03c2803e --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/en-US/template.md @@ -0,0 +1,44 @@ +## Task Type +Wireless energy efficiency optimization + +## Task Description +Based on , , and , output an energy efficiency optimization plan that achieves the energy efficiency optimization goal defined in , and return the energy efficiency optimization result in the structure defined in . + +## Task Target +{{task_target}} (optional) +Requirement: Define the quantitative or qualitative goals the task must achieve, such as energy efficiency goals and rate guarantee goals. All are optional parameters. +Specific goal description for task-level energy saving: +1. Energy consumption goal: the gain target of energy saving. The target value format must be a percentage, such as the percentage by which total power consumption is reduced. Example: Energy consumption goal: 30% +2. Rate guarantee goal: the minimum downlink throughput target that must be guaranteed. Example: Rate guarantee goal: 50Mbps/s + +## Task Object +{{task_object}} (required) +Requirement: Explicitly specify the energy saving area information, a geographic or logical area (e.g., district-level administrative unit) or longitude/latitude coordinates. The area information is a required parameter. +The following are specific descriptions: +1. Geographic/logical area name. Example: Area: "Songshanhu Administration Committee", coordinate (112.0, 20.6) area +2. **Important**: The area information must be a concrete physical address, for example a real physical community such as "Songshanhu Administration Committee", rather than a description without a specific physical location such as "all communities", "indoor communities", or "outdoor communities". + +## Task Context +{{task_context}} (optional) +Requirement: Provide the background and context information for task execution. Optional parameter. + +## Constraints +{{constraints}} (optional) +Requirement: List the rules, restrictions, and boundary conditions that must be observed during task execution. Constraints apply to all scenarios within the area by default; constraints applicable only to specified scenarios within the area should be stated separately. All are optional parameters. +1. Cell type to apply energy saving (indoor/outdoor/all). Example: Cell type for energy saving: outdoor +2. Cell radio access technology for energy saving (LTE/NR/all). Example: Cell RAT for energy saving: NR +3. Frequency requirements for the base layer and capacity layer. Frequencies must be expressed as downlink frequency numbers. Example: 1650 frequency as the base layer for carrier shutdown +4. Whether the energy saving aggressiveness is aggressive or conservative. Example: Energy saving aggressiveness: aggressive +5. Whether experience-lossless energy saving is required. Example: Experience-lossless energy saving required: yes +6. The time range for executing energy saving, recurring daily. Example: Energy saving time range: start time "08:00:00", end time "12:00:00" +7. Constraints applicable to specified scenarios within the area (e.g., railway/black forest/business district/residential area). Example: In the business district scenario, the 1300 frequency is forbidden to serve as the capacity layer; in the black forest scenario, enabling sleep is forbidden between 0:00 and 6:00. +8. Whether turning off carrier frequencies is allowed during energy saving. Example: Carrier shutdown frequency special requirement: cannot be shut down + +## Expected Output +Requirement: Output the intent report for the energy efficiency optimization task. The intent report should contain the following information: +### Intent Report Basic Information +1. Unique identifier of the energy efficiency optimization report +2. Unique identifier of the energy saving task +3. Execution result of the overall intent, including the execution result status and execution result details. If the goal is not achieved, describe the specific reason for not achieving it. +### Intent Report Detailed Information +1. Execution result of specific KPIs, including the execution result status and execution result details. If the goal is not achieved, describe the specific reason for not achieving it. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/en-US/template.md new file mode 100644 index 00000000..ef296829 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/en-US/template.md @@ -0,0 +1,40 @@ +## Task Type +Fault root cause diagnosis. + +## Task Description +Based on the event information output by event recognition and the preliminary demarcation analysis, preliminary root cause analysis, and preliminary impact analysis, perform root cause localization for the fault event, and output the root cause localization information and the recommended handling plan. + +## Task Target +{{task_target}} (required) +Requirement: State the specific requirements for the fault diagnosis task, such as completing root cause localization of the fault event, and outputting professional root cause localization information, professional classification of the fault root cause, fault root cause details, remote handling plan, on-site emergency repair plan, and service impact analysis. +Example: Please perform root cause localization for the fault event, and output professional root cause localization information, professional classification of the fault root cause, fault root cause details, remote handling plan, on-site emergency repair plan, and service impact analysis. + +## Task Context +{{task_context}} (required) +Requirement: A fault root cause diagnosis task may contain multiple alarm events. Each event may contain the following information: +1. Fault event sequence number. Required. Example: 1856365516_2839324485_2130908106_4130674041 +2. Event title name. Required. Example: 5G base station outage event +3. Alarm vendor internal sequence number associated with the event. Required. Example: 123211121244 +4. Transport-domain alarm extended sequence number associated with the event. Optional. +5. Unique identifier of the associated alarm in the NMS. Required. Example: 0108-007-012-10-000003 +6. Associated alarm title. Required. Example: gNodeB outage alarm +7. Event generation time. Required. Example: 2025-05-22 08:40:44 +8. Alarm severity; the smaller the number, the more severe. Level 1 alarm (Critical); Level 2 alarm (Major); Level 3 alarm (Minor); Level 4 alarm (Warning). Required. Example: Level 4 alarm +9. Base station ID, used by the wireless workbench. Optional. Example: 2148660 +10. Network element name. Required. Example: QZHA-HAZBYSDGG-HRHH +11. Circuit ID. Optional. Example: QZHAZBHNLX-QZHAJL10GE1097086NR +12. Equipment room information. Optional. Example: Self-built equipment room on the first floor of QZHAZBYS + +Example: There is 1 alarm event to be diagnosed, with the following details: +- The event sequence number is 1856365516_2839324485_2130908106_4130674041, titled 5G base station outage event, the alarm vendor internal sequence number associated with the event is 123211121244, the unique identifier of the associated alarm in the NMS is 0108-007-012-10-000003, the associated alarm title is gNodeB outage alarm, the event generation time is 2025-05-22 08:40:44, the alarm severity is level 4, and the network element name is QZHA-HAZBYSDGG-HRHH. + + + +## Expected Output +{{expected_output}} (optional) +Requirement: For the expected fault diagnosis result to be returned, the following information must be declared: +1. Declare which type of A2A Part carries it, such as DataPart or TextPart. +2. Declare which information elements are included (e.g., diagnosis result, localization conclusion, localization analysis process, etc.). +Example: Require the fault diagnosis result to be returned via TextPart. The specific content of the fault diagnosis result is required as follows: +1. The allowed values of the fault root cause localization result include: successful, rejected, failed, and other. +2. Fault root cause details, including the name, details, and repair suggestions (including remote handling and on-site emergency repair) of the fault root cause, the localization analysis process, the service impact, and the identifier, type, name, and detailed location of the resource object where the fault root cause point resides. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/private_line_complaint/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/private_line_complaint/en-US/template.md new file mode 100644 index 00000000..81100009 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/private_line_complaint/en-US/template.md @@ -0,0 +1,33 @@ +## Task Type +SPN private line complaint diagnosis + +## Task Description +Requirement: Provide an overall overview of the task. Based on , , and , perform network-side fault root cause diagnosis in the complaint scenario, achieve the complaint diagnosis goal defined in , and return the task processing result in the structure defined in . + +## Task Target +Requirement: +1. Based on the fault phenomenon of the private line service, diagnose and return the network-side fault root cause + +## Task Object +{{task_object}} (optional) +Requirement: Provide the private line service object. Provide one of the following three parameters to identify the private line service object: private line name, access port name, and access port resource ID. +- Private line name: the private line object identifier in the network-side EMS NMS. +- Access port name: the access port name in the network-side EMS NMS, in the format: network element name + board number + board model + port number. +- Access port resource ID: the access port resource object identifier in the network-side EMS NMS. +Example: +Access port resource ID: f47ac10b-58cc-4372-a567-0e02b2c3d479 + +## Task Context +{{task_context}} (required) +Requirement: Provide the fault phenomenon description of the private line service and the context information of the diagnosis task. +1. Fault phenomenon: includes two scenarios, "private line interruption" and "poor private line quality". Required parameter. Example: "private line interruption" +2. Fault occurrence time. Optional parameter. Example: "2024-01-16T08:21:46Z" +3. Fault or event sequence number on the OSS side. Optional parameter. Example: "fault-id-1-017-20230511-09013" + +## Expected Output +{{expected_output}} (optional) +Requirement: +1. Include the diagnosis result type, diagnosis result details, and repair suggestions. +2. The allowed values of the diagnosis result type parameter include: diagnosis successful, diagnosis failed, diagnosis not started, or diagnosis not finished. +3. Multiple fault root causes may be included. Each fault root cause includes: the name of the fault root cause, detailed description, repair suggestions, and the identifier, type, name, and detailed location of the resource object where the fault root cause point resides. The detailed description of the overall fault, and the repair suggestions for the fault root cause. +4. If multiple fault root causes exist, the detailed descriptions and repair suggestions across the multiple fault root causes must be summarized and refined into the overall diagnosis result details and repair suggestions. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/subscribe_incident/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/subscribe_incident/en-US/template.md new file mode 100644 index 00000000..7b631824 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/subscribe_incident/en-US/template.md @@ -0,0 +1,21 @@ +## Subscription Description +Based on the following , , , and information, complete the network-side intelligent fault Incident subscription and reporting task. + +## Notification Topic +{{notification_topic}} (required) +Requirement: Provide the topic name of the intelligent fault Incident. The specific name can be Incident, Fault, intelligent fault, fault, etc. + +## Subscribe Condition +{{subscribe_condition}} (optional) +Requirement: The subscription condition includes fault priority and fault name. +Fault priority: supports passing a list. The allowed values of this parameter include critical, high, medium, and low. +Fault name: supports passing a list. The allowed values of this parameter are the list of network-side fault names, for example: pigtail fault, fiber break, board fault, optical module fault, etc. + +## Notification Data Format +{{notification_data_format}} (optional) +Requirement: 1. Reported data type: Incident, fault; 2. Reported data format: which A2A Part carries it (DataPart, TextPart). +For example: report Incident data via DataPart. + +## Expected Output +1. Subscription result, success or failure +2. Reason for subscription failure (optional) From 91fa118759a58e990fbea5956b35953efa8a7ebf Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Thu, 6 Aug 2026 17:36:59 +0800 Subject: [PATCH 012/295] refactor: align extension URIs with IG1453 and unify negotiation payload structure - Remove DATA-NEGOTIATION-T extension (not in IG1453 spec) - Rename URIs to Negotiation-T/NL/v1, Task-T/NL/v1, Notification-T/NL/v1 - Add backward-compatible non-NL URI constants for legacy support - Merge negotiation context into single Negotiation-T payload key - Update all tests to use new payload structure Signed-off-by: ZhouJie6 --- .../a2at/sdk/client/api/A2ATClientTest.java | 17 +++++++------- .../runtime/NegotiationHandler.java | 14 ++++++++---- .../helper/NegotiationPayloadMapper.java | 20 +++++++++++++++-- .../NegotiationHandlerBuilderTest.java | 2 +- .../runtime/NegotiationHandlerTest.java | 22 +++++++++---------- .../runtime/NegotiationPayloadMapperTest.java | 4 +++- .../sample/client/flow/ClientSampleFlow.java | 6 ++--- .../ServerSampleAgentCardBuilder.java | 12 +++++----- .../sample/server/flow/ServerSampleFlow.java | 13 +++++++++-- ...verNegotiationOrchestratorBuilderTest.java | 4 ++-- 10 files changed, 73 insertions(+), 41 deletions(-) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index b3f0d06d..2f0d6765 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -130,24 +130,23 @@ void pathBasedConstructorBuildsDefaultNegotiationRuntime() throws IOException { Map startResult = client.startNegotiation( NegotiationType.CLARIFICATION, "Please clarify the target.", Map.of("site", "A")); @SuppressWarnings("unchecked") - Map context = (Map) - startResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_CONTEXT_KEY); + Map startData = (Map) + startResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_T_URI_NL); Map continueResult = client.continueNegotiation( new NegotiationContext( NegotiationType.CLARIFICATION, - String.valueOf(context.get("negotiationId")), + String.valueOf(startData.get("negotiationId")), 1, NegotiationStatus.IN_PROGRESS), NegotiationStatus.IN_PROGRESS, "Site A"); + @SuppressWarnings("unchecked") + Map continueData = (Map) + continueResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_T_URI_NL); - assertEquals( - "Please clarify the target.", - startResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_TEXT_KEY)); - assertEquals( - "Site A", - continueResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_TEXT_KEY)); + assertEquals("Please clarify the target.", startData.get("message")); + assertEquals("Site A", continueData.get("message")); } @Test diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java index 6ae1092c..51af232c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java @@ -17,11 +17,17 @@ */ public final class NegotiationHandler { - public static final String NEGOTIATION_CONTEXT_KEY = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/DATA-NEGOTIATION-T/v1"; + public static final String NEGOTIATION_T_URI_NL = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/NL/v1"; - public static final String NEGOTIATION_TEXT_KEY = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T"; + public static final String NEGOTIATION_T_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1"; + + /** + * @deprecated Use {@link #NEGOTIATION_T_URI_NL} instead. + */ + @Deprecated + public static final String NEGOTIATION_TEXT_KEY = NEGOTIATION_T_URI_NL; private final NegotiationRuntime runtime; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/helper/NegotiationPayloadMapper.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/helper/NegotiationPayloadMapper.java index f591187c..ecbfd019 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/helper/NegotiationPayloadMapper.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/helper/NegotiationPayloadMapper.java @@ -17,9 +17,12 @@ public final class NegotiationPayloadMapper { public static Map payload( NegotiationContext context, String contentText, Map facts) { + Map negotiationData = new LinkedHashMap<>(); + negotiationData.put("message", contentText); + negotiationData.putAll(contextPayload(context)); + Map payload = new LinkedHashMap<>(); - payload.put(NegotiationHandler.NEGOTIATION_TEXT_KEY, contentText); - payload.put(NegotiationHandler.NEGOTIATION_CONTEXT_KEY, contextPayload(context)); + payload.put(NegotiationHandler.NEGOTIATION_T_URI_NL, negotiationData); if (!facts.isEmpty()) { payload.put("facts", facts); } @@ -38,6 +41,19 @@ public static Map contextPayload(NegotiationContext context) { return payload; } + @SuppressWarnings("unchecked") + public static Map extractContextMap(Map payload) { + Object primary = payload.get(NegotiationHandler.NEGOTIATION_T_URI_NL); + if (primary instanceof Map data) { + return (Map) data; + } + Object legacy = payload.get(NegotiationHandler.NEGOTIATION_T_URI); + if (legacy instanceof Map data) { + return (Map) data; + } + throw new NegotiationStateException("Negotiation-T extension key not found in payload."); + } + public static NegotiationContext contextFromMap(Map contextMap) { String rawType = stringValue(contextMap, "negotiationType"); String normalizedType = rawType.replace('-', '_').toUpperCase(); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerBuilderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerBuilderTest.java index c37a6369..192e9841 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerBuilderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerBuilderTest.java @@ -23,7 +23,7 @@ void builderCreatesHandlerWithStoreAndMultipleNegotiationTypes() { .build(); Map startPayload = handler.start(NegotiationType.INFORMATION, "latest prompt", Map.of()); - Map context = castStringObjectMap(startPayload.get(NegotiationHandler.NEGOTIATION_CONTEXT_KEY)); + Map context = castStringObjectMap(startPayload.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); Map result = handler.receive("latest prompt", context); assertTrue(booleanValue(result.get("needResponse"))); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerTest.java index 428027f4..c12e0b3e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerTest.java @@ -25,10 +25,10 @@ void startReturnsFixedKeyMapAndSavesRecord() { "Please clarify the request.", Map.of("clarificationItems", new Object[] {"intent"})); - assertEquals("Please clarify the request.", payload.get(NegotiationHandler.NEGOTIATION_TEXT_KEY)); - Map context = cast(payload.get(NegotiationHandler.NEGOTIATION_CONTEXT_KEY)); - assertNotNull(context.get("negotiationId")); - assertNotNull(store.get(stringValue(context.get("negotiationId")))); + Map negotiationData = cast(payload.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); + assertEquals("Please clarify the request.", negotiationData.get("message")); + assertNotNull(negotiationData.get("negotiationId")); + assertNotNull(store.get(stringValue(negotiationData.get("negotiationId")))); } @Test @@ -38,19 +38,19 @@ void continueMessageReturnsPayloadWithIncrementedRound() { new NegotiationHandler(Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); Map startPayload = handler.start(NegotiationType.CLARIFICATION, "Please clarify the request.", Map.of()); - Map contextMap = cast(startPayload.get(NegotiationHandler.NEGOTIATION_CONTEXT_KEY)); + Map startData = cast(startPayload.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); NegotiationContext context = new NegotiationContext( NegotiationType.CLARIFICATION, - stringValue(contextMap.get("negotiationId")), - numberValue(contextMap.get("round")).intValue(), + stringValue(startData.get("negotiationId")), + numberValue(startData.get("round")).intValue(), NegotiationStatus.IN_PROGRESS); Map payload = handler.continueMessage(context, NegotiationStatus.IN_PROGRESS, "Here is the clarification."); - Map nextContext = cast(payload.get(NegotiationHandler.NEGOTIATION_CONTEXT_KEY)); - assertEquals(2, numberValue(nextContext.get("round")).intValue()); - assertEquals("Here is the clarification.", payload.get(NegotiationHandler.NEGOTIATION_TEXT_KEY)); + Map nextData = cast(payload.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); + assertEquals(2, numberValue(nextData.get("round")).intValue()); + assertEquals("Here is the clarification.", nextData.get("message")); } @Test @@ -59,7 +59,7 @@ void receiveReturnsNegotiationPayloadMap() { NegotiationHandler handler = new NegotiationHandler(Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); Map startPayload = handler.start(NegotiationType.CLARIFICATION, "Please clarify", Map.of()); - Map context = cast(startPayload.get(NegotiationHandler.NEGOTIATION_CONTEXT_KEY)); + Map context = cast(startPayload.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); Map result = handler.receive("Clarify intent", context); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationPayloadMapperTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationPayloadMapperTest.java index d2f02f95..25a80589 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationPayloadMapperTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationPayloadMapperTest.java @@ -30,7 +30,9 @@ void payloadIncludesFactsWhenProvided() { Map payload = NegotiationPayloadMapper.payload(context, "latest prompt", Map.of("source", "server")); - assertEquals("latest prompt", payload.get(NegotiationHandler.NEGOTIATION_TEXT_KEY)); + @SuppressWarnings("unchecked") + Map negotiationData = (Map) payload.get(NegotiationHandler.NEGOTIATION_T_URI_NL); + assertEquals("latest prompt", negotiationData.get("message")); assertEquals("server", ((Map) payload.get("facts")).get("source")); } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientSampleFlow.java index e3ba8ddd..c3fef3b7 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientSampleFlow.java @@ -25,8 +25,8 @@ * @since 2026-05 */ public final class ClientSampleFlow { - static final String NOTIFICATION_T_EXTENSION_URI = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + static final String NOTIFICATION_T_EXTENSION_URI_NL = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/NL/v1"; static final String NATURAL_LANGUAGE_PROMPT_INPUT = "通知主题为Incident的,订阅条件为:订阅级别为critical的ETH-LOS的故障,上报通知数据格式为:DataPart"; @@ -59,7 +59,7 @@ public static List> runClientFlow( String promptText = requirePromptText(promptResult); BuiltA2AJavaRequest builtRequest = A2AJavaRequestBuilder.buildStreamRequest( - promptText, NOTIFICATION_T_EXTENSION_URI, buildRequestMetadata(scenarioPayload)); + promptText, NOTIFICATION_T_EXTENSION_URI_NL, buildRequestMetadata(scenarioPayload)); emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "a2a-request-body", builtRequest.request())); emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "request-headers", builtRequest.callContext().getHeaders())); emit(logSink, SampleLoggingFormatter.formatStageLog( diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/agentcard/ServerSampleAgentCardBuilder.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/agentcard/ServerSampleAgentCardBuilder.java index 5f7a987c..00e9a3d5 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/agentcard/ServerSampleAgentCardBuilder.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/agentcard/ServerSampleAgentCardBuilder.java @@ -9,11 +9,11 @@ * @since 2026-05 */ public final class ServerSampleAgentCardBuilder { - static final String TASK_T_EXTENSION_URI = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; + static final String TASK_T_EXTENSION_URI_NL = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/NL/v1"; - static final String NOTIFICATION_T_EXTENSION_URI = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + static final String NOTIFICATION_T_EXTENSION_URI_NL = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/NL/v1"; private ServerSampleAgentCardBuilder() { } @@ -38,10 +38,10 @@ public static Map buildAgentCard(String host, int port) { "pushNotifications", false, "extensions", List.of( Map.of( - "uri", TASK_T_EXTENSION_URI, + "uri", TASK_T_EXTENSION_URI_NL, "description", "Extension of structured prompt Task-T requests."), Map.of( - "uri", NOTIFICATION_T_EXTENSION_URI, + "uri", NOTIFICATION_T_EXTENSION_URI_NL, "description", "Extension of structured prompt Notification-T requests."))), "supportedInterfaces", List.of(Map.of( "protocolBinding", "HTTP+JSON", diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleFlow.java index 246bcb46..b2c2e14b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleFlow.java @@ -21,6 +21,9 @@ * @since 2026-05 */ public final class ServerSampleFlow { + static final String NOTIFICATION_T_EXTENSION_URI_NL = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/NL/v1"; + static final String NOTIFICATION_T_EXTENSION_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; @@ -108,7 +111,9 @@ private static void requireNotificationExtension(ServerCallContext callContext) Map headers = extractHeaders(callContext); String modernValue = headers.get("A2A-Extensions"); String legacyValue = headers.get("X-A2A-Extensions"); - if (!NOTIFICATION_T_EXTENSION_URI.equals(modernValue) && !NOTIFICATION_T_EXTENSION_URI.equals(legacyValue)) { + String extensionValue = modernValue != null ? modernValue : legacyValue; + if (!NOTIFICATION_T_EXTENSION_URI_NL.equals(extensionValue) + && !NOTIFICATION_T_EXTENSION_URI.equals(extensionValue)) { throw new ValueErrorException("a2a client extensions is not exist."); } } @@ -118,7 +123,11 @@ private static String extractPromptText(RequestContext requestContext) { if (message == null || message.metadata() == null) { throw new ValueErrorException("Expected message metadata for Notification-T prompt"); } - return stringValue(message.metadata().get(NOTIFICATION_T_EXTENSION_URI)); + String promptText = stringValue(message.metadata().get(NOTIFICATION_T_EXTENSION_URI_NL)); + if (promptText.isEmpty()) { + promptText = stringValue(message.metadata().get(NOTIFICATION_T_EXTENSION_URI)); + } + return promptText; } @SuppressWarnings("unchecked") diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java index 10b358c4..2d8a6d3c 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java @@ -30,7 +30,7 @@ void buildCreatesServerRoleOrchestratorThatUsesPromptComplianceForInformationNeg Map started = orchestrator.startNegotiation(NegotiationType.INFORMATION, "Need full task prompt.", Map.of()); - Map context = cast(started.get(NegotiationHandler.NEGOTIATION_CONTEXT_KEY)); + Map context = cast(started.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); Map received = orchestrator.receiveNegotiation("latest full task prompt", context); @@ -48,7 +48,7 @@ void buildUsesServerRoleInStartPayload() { Map result = orchestrator.startNegotiation( NegotiationType.CLARIFICATION, "Please clarify the target.", Map.of("site", "A")); - Map context = cast(result.get(NegotiationHandler.NEGOTIATION_CONTEXT_KEY)); + Map context = cast(result.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); assertEquals("clarification", context.get("negotiationType")); assertEquals("in-progress", context.get("status")); From e205a8229f268e7a89b5f5028d1e82ff884f7964 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Fri, 7 Aug 2026 14:45:03 +0800 Subject: [PATCH 013/295] refactor: rename CLARIFICATION to TARGET and remove FULFILLMENT negotiation type - Rename NegotiationType.CLARIFICATION to NegotiationType.TARGET - Remove NegotiationType.FULFILLMENT from enum - Rename ClarificationNegotiation to TargetNegotiation - Delete FulfillmentNegotiation class and its test - Update serialization value from 'clarification' to 'target' - Update ServerNegotiationOrchestratorBuilder registration - Update all 10 test files to use TARGET instead of CLARIFICATION/FULFILLMENT - Align with Python SDK negotiation type definitions Signed-off-by: ZhouJie6 --- .../a2at/sdk/client/api/A2ATClientTest.java | 4 +- .../handler/ClarificationNegotiation.java | 21 ---------- ...egotiation.java => TargetNegotiation.java} | 4 +- .../types/model/NegotiationType.java | 5 +-- .../handler/FulfillmentNegotiationTest.java | 38 ------------------- ...onTest.java => TargetNegotiationTest.java} | 8 ++-- .../NegotiationHandlerBuilderTest.java | 4 +- .../runtime/NegotiationHandlerStateTest.java | 10 ++--- .../runtime/NegotiationHandlerTest.java | 22 +++++------ .../runtime/NegotiationPayloadMapperTest.java | 2 +- ...egotiationPayloadMapperValidationTest.java | 2 +- .../runtime/NegotiationRuntimeLimitsTest.java | 8 ++-- .../NegotiationRuntimeStateMachineTest.java | 8 ++-- .../NegotiationRuntimeStrictStateTest.java | 32 ++++++++-------- .../runtime/NegotiationRuntimeTest.java | 16 ++++---- .../store/InMemoryNegotiationStoreTest.java | 2 +- .../ServerNegotiationOrchestratorBuilder.java | 6 +-- ...verNegotiationOrchestratorBuilderTest.java | 4 +- 18 files changed, 67 insertions(+), 129 deletions(-) delete mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/ClarificationNegotiation.java rename a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/{FulfillmentNegotiation.java => TargetNegotiation.java} (84%) delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/handler/FulfillmentNegotiationTest.java rename a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/handler/{ClarificationNegotiationTest.java => TargetNegotiationTest.java} (77%) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index 2f0d6765..ed338916 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -128,14 +128,14 @@ void pathBasedConstructorBuildsDefaultNegotiationRuntime() throws IOException { A2ATClient client = new A2ATClient(envFile); Map startResult = client.startNegotiation( - NegotiationType.CLARIFICATION, "Please clarify the target.", Map.of("site", "A")); + NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); @SuppressWarnings("unchecked") Map startData = (Map) startResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_T_URI_NL); Map continueResult = client.continueNegotiation( new NegotiationContext( - NegotiationType.CLARIFICATION, + NegotiationType.TARGET, String.valueOf(startData.get("negotiationId")), 1, NegotiationStatus.IN_PROGRESS), diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/ClarificationNegotiation.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/ClarificationNegotiation.java deleted file mode 100644 index 6631bae7..00000000 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/ClarificationNegotiation.java +++ /dev/null @@ -1,21 +0,0 @@ -package net.openan.a2at.sdk.negotiation.handler; - -import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationReceiveResult; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; - -import java.util.Map; - -/** - * Minimal clarification negotiation behavior. - * - * @since 2026-06 - */ -public final class ClarificationNegotiation implements Negotiation { - - @Override - public NegotiationReceiveResult processReceivedMessage(String message, NegotiationContext context) { - boolean needResponse = context.status() == NegotiationStatus.IN_PROGRESS; - return new NegotiationReceiveResult(needResponse, Map.of(), message); - } -} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/FulfillmentNegotiation.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/TargetNegotiation.java similarity index 84% rename from a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/FulfillmentNegotiation.java rename to a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/TargetNegotiation.java index afacc2e2..1fc246ee 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/FulfillmentNegotiation.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/TargetNegotiation.java @@ -7,11 +7,11 @@ import java.util.Map; /** - * Minimal fulfillment negotiation behavior. + * Minimal target negotiation behavior. * * @since 2026-06 */ -public final class FulfillmentNegotiation implements Negotiation { +public final class TargetNegotiation implements Negotiation { @Override public NegotiationReceiveResult processReceivedMessage(String message, NegotiationContext context) { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/types/model/NegotiationType.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/types/model/NegotiationType.java index 700f69d6..4ffaa150 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/types/model/NegotiationType.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/types/model/NegotiationType.java @@ -7,7 +7,6 @@ */ public enum NegotiationType { INFORMATION, - CLARIFICATION, - FEASIBILITY, - FULFILLMENT + TARGET, + FEASIBILITY } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/handler/FulfillmentNegotiationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/handler/FulfillmentNegotiationTest.java deleted file mode 100644 index 1f694a3d..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/handler/FulfillmentNegotiationTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package net.openan.a2at.sdk.negotiation.handler; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationReceiveResult; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import org.junit.jupiter.api.Test; - -class FulfillmentNegotiationTest { - - private final FulfillmentNegotiation negotiationType = new FulfillmentNegotiation(); - - @Test - void processReceivedMessagePassesThroughFactsAndMessage() { - NegotiationReceiveResult result = negotiationType.processReceivedMessage( - "deliver the final task output", - new NegotiationContext( - NegotiationType.FULFILLMENT, "neg-fulfillment", 1, NegotiationStatus.IN_PROGRESS)); - - assertTrue(result.needResponse()); - assertEquals("deliver the final task output", result.message()); - assertTrue(result.facts().isEmpty()); - } - - @Test - void processReceivedMessageStopsResponseWhenNegotiationIsTerminal() { - NegotiationReceiveResult result = negotiationType.processReceivedMessage( - "fulfillment reached", - new NegotiationContext(NegotiationType.FULFILLMENT, "neg-fulfillment", 2, NegotiationStatus.REJECTED)); - - assertFalse(result.needResponse()); - assertEquals("fulfillment reached", result.message()); - } -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/handler/ClarificationNegotiationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/handler/TargetNegotiationTest.java similarity index 77% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/handler/ClarificationNegotiationTest.java rename to a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/handler/TargetNegotiationTest.java index 492c021f..855cdec0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/handler/ClarificationNegotiationTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/handler/TargetNegotiationTest.java @@ -10,15 +10,15 @@ import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; import org.junit.jupiter.api.Test; -class ClarificationNegotiationTest { +class TargetNegotiationTest { - private final ClarificationNegotiation negotiationType = new ClarificationNegotiation(); + private final TargetNegotiation negotiationType = new TargetNegotiation(); @Test void processReceivedMessagePassesThroughFactsAndMessage() { NegotiationReceiveResult result = negotiationType.processReceivedMessage( "clarify this", - new NegotiationContext(NegotiationType.CLARIFICATION, "neg-1", 1, NegotiationStatus.IN_PROGRESS)); + new NegotiationContext(NegotiationType.TARGET, "neg-1", 1, NegotiationStatus.IN_PROGRESS)); assertTrue(result.needResponse()); assertEquals("clarify this", result.message()); @@ -29,7 +29,7 @@ void processReceivedMessagePassesThroughFactsAndMessage() { void processReceivedMessageStopsResponseWhenNegotiationIsTerminal() { NegotiationReceiveResult result = negotiationType.processReceivedMessage( "clarify this", - new NegotiationContext(NegotiationType.CLARIFICATION, "neg-2", 2, NegotiationStatus.AGREED)); + new NegotiationContext(NegotiationType.TARGET, "neg-2", 2, NegotiationStatus.AGREED)); assertFalse(result.needResponse()); assertEquals("clarify this", result.message()); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerBuilderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerBuilderTest.java index 192e9841..d4f81c27 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerBuilderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerBuilderTest.java @@ -6,7 +6,7 @@ import java.util.LinkedHashMap; import java.util.Map; -import net.openan.a2at.sdk.negotiation.handler.ClarificationNegotiation; +import net.openan.a2at.sdk.negotiation.handler.TargetNegotiation; import net.openan.a2at.sdk.negotiation.handler.InformationNegotiation; import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; @@ -18,7 +18,7 @@ class NegotiationHandlerBuilderTest { void builderCreatesHandlerWithStoreAndMultipleNegotiationTypes() { NegotiationHandler handler = NegotiationHandler.builder() .store(new InMemoryNegotiationStore()) - .register(NegotiationType.CLARIFICATION, new ClarificationNegotiation()) + .register(NegotiationType.TARGET, new TargetNegotiation()) .register(NegotiationType.INFORMATION, new InformationNegotiation()) .build(); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerStateTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerStateTest.java index fd34737a..4beb2cfb 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerStateTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerStateTest.java @@ -4,7 +4,7 @@ import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; import net.openan.a2at.sdk.negotiation.types.exception.NegotiationStateException; -import net.openan.a2at.sdk.negotiation.handler.ClarificationNegotiation; +import net.openan.a2at.sdk.negotiation.handler.TargetNegotiation; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationRecord; import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; @@ -20,19 +20,19 @@ void continueMessageRejectsContextRoundMismatch() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); store.save(new NegotiationRecord( new NegotiationContext( - NegotiationType.CLARIFICATION, "neg-continue-mismatch", 2, NegotiationStatus.IN_PROGRESS), + NegotiationType.TARGET, "neg-continue-mismatch", 2, NegotiationStatus.IN_PROGRESS), "old")); - NegotiationHandler handler = new NegotiationHandler(Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); + NegotiationHandler handler = new NegotiationHandler(Map.of(NegotiationType.TARGET, new TargetNegotiation()), store); assertThrows( NegotiationStateException.class, () -> handler.continueMessage( new NegotiationContext( - NegotiationType.CLARIFICATION, + NegotiationType.TARGET, "neg-continue-mismatch", 1, NegotiationStatus.IN_PROGRESS), NegotiationStatus.IN_PROGRESS, - "Here is the clarification.")); + "Here is the target.")); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerTest.java index c12e0b3e..2a5fad43 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandlerTest.java @@ -5,7 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Map; -import net.openan.a2at.sdk.negotiation.handler.ClarificationNegotiation; +import net.openan.a2at.sdk.negotiation.handler.TargetNegotiation; import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; @@ -18,12 +18,12 @@ class NegotiationHandlerTest { void startReturnsFixedKeyMapAndSavesRecord() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); NegotiationHandler handler = - new NegotiationHandler(Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); + new NegotiationHandler(Map.of(NegotiationType.TARGET, new TargetNegotiation()), store); Map payload = handler.start( - NegotiationType.CLARIFICATION, + NegotiationType.TARGET, "Please clarify the request.", - Map.of("clarificationItems", new Object[] {"intent"})); + Map.of("targetItems", new Object[] {"intent"})); Map negotiationData = cast(payload.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); assertEquals("Please clarify the request.", negotiationData.get("message")); @@ -35,30 +35,30 @@ void startReturnsFixedKeyMapAndSavesRecord() { void continueMessageReturnsPayloadWithIncrementedRound() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); NegotiationHandler handler = - new NegotiationHandler(Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); + new NegotiationHandler(Map.of(NegotiationType.TARGET, new TargetNegotiation()), store); Map startPayload = - handler.start(NegotiationType.CLARIFICATION, "Please clarify the request.", Map.of()); + handler.start(NegotiationType.TARGET, "Please clarify the request.", Map.of()); Map startData = cast(startPayload.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); NegotiationContext context = new NegotiationContext( - NegotiationType.CLARIFICATION, + NegotiationType.TARGET, stringValue(startData.get("negotiationId")), numberValue(startData.get("round")).intValue(), NegotiationStatus.IN_PROGRESS); Map payload = - handler.continueMessage(context, NegotiationStatus.IN_PROGRESS, "Here is the clarification."); + handler.continueMessage(context, NegotiationStatus.IN_PROGRESS, "Here is the target."); Map nextData = cast(payload.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); assertEquals(2, numberValue(nextData.get("round")).intValue()); - assertEquals("Here is the clarification.", nextData.get("message")); + assertEquals("Here is the target.", nextData.get("message")); } @Test void receiveReturnsNegotiationPayloadMap() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); NegotiationHandler handler = - new NegotiationHandler(Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); - Map startPayload = handler.start(NegotiationType.CLARIFICATION, "Please clarify", Map.of()); + new NegotiationHandler(Map.of(NegotiationType.TARGET, new TargetNegotiation()), store); + Map startPayload = handler.start(NegotiationType.TARGET, "Please clarify", Map.of()); Map context = cast(startPayload.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); Map result = handler.receive("Clarify intent", context); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationPayloadMapperTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationPayloadMapperTest.java index 25a80589..4a48e782 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationPayloadMapperTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationPayloadMapperTest.java @@ -14,7 +14,7 @@ class NegotiationPayloadMapperTest { @Test void contextPayloadRoundTripsBetweenMapAndContext() { NegotiationContext context = - new NegotiationContext(NegotiationType.CLARIFICATION, "neg-map", 2, NegotiationStatus.IN_PROGRESS); + new NegotiationContext(NegotiationType.TARGET, "neg-map", 2, NegotiationStatus.IN_PROGRESS); Map payload = NegotiationPayloadMapper.contextPayload(context); NegotiationContext rebuilt = NegotiationPayloadMapper.contextFromMap(payload); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationPayloadMapperValidationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationPayloadMapperValidationTest.java index 0e2a8f89..65def7a6 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationPayloadMapperValidationTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationPayloadMapperValidationTest.java @@ -14,7 +14,7 @@ void contextFromMapRejectsNonPositiveRound() { assertThrows( NegotiationStateException.class, () -> NegotiationPayloadMapper.contextFromMap(Map.of( - "negotiationType", "clarification", + "negotiationType", "target", "negotiationId", "neg-invalid-round", "round", 0, "status", "in-progress", diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeLimitsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeLimitsTest.java index 53287332..92f713d9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeLimitsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeLimitsTest.java @@ -6,7 +6,7 @@ import java.util.Map; import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; -import net.openan.a2at.sdk.negotiation.handler.ClarificationNegotiation; +import net.openan.a2at.sdk.negotiation.handler.TargetNegotiation; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationReceiveResult; import net.openan.a2at.sdk.negotiation.types.model.NegotiationRecord; @@ -21,18 +21,18 @@ void receiveReturnsRejectGuidanceWhenIncomingRoundHitsLimit() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); store.save(new NegotiationRecord( new NegotiationContext( - NegotiationType.CLARIFICATION, + NegotiationType.TARGET, "neg-round-limit", NegotiationRuntime.MAX_IN_PROGRESS_NEGOTIATION_ROUND - 1, NegotiationStatus.IN_PROGRESS), "old")); NegotiationRuntime runtime = new NegotiationRuntime( - Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); + Map.of(NegotiationType.TARGET, new TargetNegotiation()), store); NegotiationReceiveResult result = runtime.receive( "Clarify intent", new NegotiationContext( - NegotiationType.CLARIFICATION, + NegotiationType.TARGET, "neg-round-limit", NegotiationRuntime.MAX_IN_PROGRESS_NEGOTIATION_ROUND, NegotiationStatus.IN_PROGRESS)); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeStateMachineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeStateMachineTest.java index 07f7fd0c..d4c4e691 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeStateMachineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeStateMachineTest.java @@ -6,7 +6,7 @@ import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; import net.openan.a2at.sdk.negotiation.types.exception.NegotiationStateException; -import net.openan.a2at.sdk.negotiation.handler.ClarificationNegotiation; +import net.openan.a2at.sdk.negotiation.handler.TargetNegotiation; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationRecord; import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; @@ -20,16 +20,16 @@ void continueMessageRejectsTerminalNegotiation() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); store.save(new NegotiationRecord( new NegotiationContext( - NegotiationType.CLARIFICATION, "neg-terminal-continue", 2, NegotiationStatus.AGREED), + NegotiationType.TARGET, "neg-terminal-continue", 2, NegotiationStatus.AGREED), "done")); NegotiationRuntime runtime = new NegotiationRuntime( - Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); + Map.of(NegotiationType.TARGET, new TargetNegotiation()), store); assertThrows( NegotiationStateException.class, () -> runtime.continueMessage( new NegotiationContext( - NegotiationType.CLARIFICATION, "neg-terminal-continue", 2, NegotiationStatus.AGREED), + NegotiationType.TARGET, "neg-terminal-continue", 2, NegotiationStatus.AGREED), NegotiationStatus.IN_PROGRESS)); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeStrictStateTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeStrictStateTest.java index f38d5842..f4358238 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeStrictStateTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeStrictStateTest.java @@ -7,7 +7,7 @@ import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; import net.openan.a2at.sdk.negotiation.types.exception.NegotiationStateException; -import net.openan.a2at.sdk.negotiation.handler.ClarificationNegotiation; +import net.openan.a2at.sdk.negotiation.handler.TargetNegotiation; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationRecord; import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; @@ -21,17 +21,17 @@ void receiveRejectsIncomingRoundOlderThanLocalProgress() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); store.save(new NegotiationRecord( new NegotiationContext( - NegotiationType.CLARIFICATION, "neg-round-regress", 3, NegotiationStatus.IN_PROGRESS), + NegotiationType.TARGET, "neg-round-regress", 3, NegotiationStatus.IN_PROGRESS), "latest")); NegotiationRuntime runtime = new NegotiationRuntime( - Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); + Map.of(NegotiationType.TARGET, new TargetNegotiation()), store); NegotiationStateException error = assertThrows( NegotiationStateException.class, () -> runtime.receive( "stale", new NegotiationContext( - NegotiationType.CLARIFICATION, "neg-round-regress", 2, NegotiationStatus.IN_PROGRESS))); + NegotiationType.TARGET, "neg-round-regress", 2, NegotiationStatus.IN_PROGRESS))); assertEquals("Incoming negotiation round is older than local progress.", error.getMessage()); } @@ -41,17 +41,17 @@ void receiveRejectsTerminalContextReturningToInProgress() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); store.save(new NegotiationRecord( new NegotiationContext( - NegotiationType.CLARIFICATION, "neg-terminal-regress", 2, NegotiationStatus.AGREED), + NegotiationType.TARGET, "neg-terminal-regress", 2, NegotiationStatus.AGREED), "done")); NegotiationRuntime runtime = new NegotiationRuntime( - Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); + Map.of(NegotiationType.TARGET, new TargetNegotiation()), store); assertThrows( NegotiationStateException.class, () -> runtime.receive( "Clarify intent", new NegotiationContext( - NegotiationType.CLARIFICATION, + NegotiationType.TARGET, "neg-terminal-regress", 3, NegotiationStatus.IN_PROGRESS))); @@ -62,17 +62,17 @@ void receiveRejectsChangingOneTerminalStatusToAnother() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); store.save(new NegotiationRecord( new NegotiationContext( - NegotiationType.CLARIFICATION, "neg-terminal-switch", 2, NegotiationStatus.AGREED), + NegotiationType.TARGET, "neg-terminal-switch", 2, NegotiationStatus.AGREED), "done")); NegotiationRuntime runtime = new NegotiationRuntime( - Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); + Map.of(NegotiationType.TARGET, new TargetNegotiation()), store); NegotiationStateException error = assertThrows( NegotiationStateException.class, () -> runtime.receive( "switch terminal", new NegotiationContext( - NegotiationType.CLARIFICATION, "neg-terminal-switch", 3, NegotiationStatus.REJECTED))); + NegotiationType.TARGET, "neg-terminal-switch", 3, NegotiationStatus.REJECTED))); assertEquals("Terminal negotiation status cannot change once finalized.", error.getMessage()); } @@ -82,16 +82,16 @@ void continueMessageRejectsRepeatedTerminalProgression() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); store.save(new NegotiationRecord( new NegotiationContext( - NegotiationType.CLARIFICATION, "neg-terminal-repeat", 3, NegotiationStatus.REJECTED), + NegotiationType.TARGET, "neg-terminal-repeat", 3, NegotiationStatus.REJECTED), "done")); NegotiationRuntime runtime = new NegotiationRuntime( - Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); + Map.of(NegotiationType.TARGET, new TargetNegotiation()), store); assertThrows( NegotiationStateException.class, () -> runtime.continueMessage( new NegotiationContext( - NegotiationType.CLARIFICATION, "neg-terminal-repeat", 3, NegotiationStatus.REJECTED), + NegotiationType.TARGET, "neg-terminal-repeat", 3, NegotiationStatus.REJECTED), NegotiationStatus.REJECTED)); } @@ -100,16 +100,16 @@ void continueMessageRejectsContextRoundThatDoesNotMatchStoredProgress() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); store.save(new NegotiationRecord( new NegotiationContext( - NegotiationType.CLARIFICATION, "neg-continue-mismatch", 3, NegotiationStatus.IN_PROGRESS), + NegotiationType.TARGET, "neg-continue-mismatch", 3, NegotiationStatus.IN_PROGRESS), "latest")); NegotiationRuntime runtime = new NegotiationRuntime( - Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); + Map.of(NegotiationType.TARGET, new TargetNegotiation()), store); NegotiationStateException error = assertThrows( NegotiationStateException.class, () -> runtime.continueMessage( new NegotiationContext( - NegotiationType.CLARIFICATION, + NegotiationType.TARGET, "neg-continue-mismatch", 2, NegotiationStatus.IN_PROGRESS), diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeTest.java index 92e45070..f489be05 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationRuntimeTest.java @@ -8,7 +8,7 @@ import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; import net.openan.a2at.sdk.negotiation.types.exception.NegotiationStateException; -import net.openan.a2at.sdk.negotiation.handler.ClarificationNegotiation; +import net.openan.a2at.sdk.negotiation.handler.TargetNegotiation; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationReceiveResult; import net.openan.a2at.sdk.negotiation.types.model.NegotiationRecord; @@ -21,12 +21,12 @@ class NegotiationRuntimeTest { @Test void receiveAllowsFirstRoundWithoutExistingRecord() { NegotiationRuntime runtime = new NegotiationRuntime( - Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), + Map.of(NegotiationType.TARGET, new TargetNegotiation()), new InMemoryNegotiationStore()); NegotiationReceiveResult result = runtime.receive( "Clarify intent", - new NegotiationContext(NegotiationType.CLARIFICATION, "neg-receive", 1, NegotiationStatus.IN_PROGRESS)); + new NegotiationContext(NegotiationType.TARGET, "neg-receive", 1, NegotiationStatus.IN_PROGRESS)); assertTrue(result.needResponse()); assertEquals("Clarify intent", result.message()); @@ -37,9 +37,9 @@ void receiveAllowsFirstRoundWithoutExistingRecord() { void continueMessageReturnsIncrementedRound() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); NegotiationRuntime runtime = new NegotiationRuntime( - Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); + Map.of(NegotiationType.TARGET, new TargetNegotiation()), store); NegotiationContext context = - new NegotiationContext(NegotiationType.CLARIFICATION, "neg-continue", 1, NegotiationStatus.IN_PROGRESS); + new NegotiationContext(NegotiationType.TARGET, "neg-continue", 1, NegotiationStatus.IN_PROGRESS); store.save(new NegotiationRecord(context, "old")); NegotiationContext nextContext = runtime.continueMessage(context, NegotiationStatus.IN_PROGRESS); @@ -52,16 +52,16 @@ void continueMessageReturnsIncrementedRound() { void receiveRejectsIncomingRoundThatSkipsLocalProgress() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); store.save(new NegotiationRecord( - new NegotiationContext(NegotiationType.CLARIFICATION, "neg-skip", 2, NegotiationStatus.IN_PROGRESS), + new NegotiationContext(NegotiationType.TARGET, "neg-skip", 2, NegotiationStatus.IN_PROGRESS), "old")); NegotiationRuntime runtime = new NegotiationRuntime( - Map.of(NegotiationType.CLARIFICATION, new ClarificationNegotiation()), store); + Map.of(NegotiationType.TARGET, new TargetNegotiation()), store); assertThrows( NegotiationStateException.class, () -> runtime.receive( "Clarify intent", new NegotiationContext( - NegotiationType.CLARIFICATION, "neg-skip", 4, NegotiationStatus.IN_PROGRESS))); + NegotiationType.TARGET, "neg-skip", 4, NegotiationStatus.IN_PROGRESS))); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/store/InMemoryNegotiationStoreTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/store/InMemoryNegotiationStoreTest.java index afb4c16c..f15eb9ac 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/store/InMemoryNegotiationStoreTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/store/InMemoryNegotiationStoreTest.java @@ -16,7 +16,7 @@ class InMemoryNegotiationStoreTest { void storeSavesGetsAndDeletesRecords() { InMemoryNegotiationStore store = new InMemoryNegotiationStore(); NegotiationRecord record = new NegotiationRecord( - new NegotiationContext(NegotiationType.FULFILLMENT, "neg-store", 1, NegotiationStatus.IN_PROGRESS), + new NegotiationContext(NegotiationType.TARGET, "neg-store", 1, NegotiationStatus.IN_PROGRESS), "message"); store.save(record); diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilder.java index d2c0eb8e..3b3d02f2 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilder.java @@ -4,10 +4,9 @@ import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; import net.openan.a2at.sdk.negotiation.store.NegotiationStore; -import net.openan.a2at.sdk.negotiation.handler.ClarificationNegotiation; import net.openan.a2at.sdk.negotiation.handler.FeasibilityNegotiation; -import net.openan.a2at.sdk.negotiation.handler.FulfillmentNegotiation; import net.openan.a2at.sdk.negotiation.handler.InformationNegotiation; +import net.openan.a2at.sdk.negotiation.handler.TargetNegotiation; import net.openan.a2at.sdk.negotiation.types.model.NegotiationRole; import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; import net.openan.a2at.sdk.negotiation.types.model.TaskPromptComplianceFailure; @@ -68,9 +67,8 @@ public RoleBoundNegotiationOrchestrator build() { : TaskPromptComplianceResult.failure(new TaskPromptComplianceFailure( result.failure().code(), result.failure().message())); })) - .register(NegotiationType.CLARIFICATION, new ClarificationNegotiation()) + .register(NegotiationType.TARGET, new TargetNegotiation()) .register(NegotiationType.FEASIBILITY, new FeasibilityNegotiation()) - .register(NegotiationType.FULFILLMENT, new FulfillmentNegotiation()) .build(); return new RoleBoundNegotiationOrchestrator(handler, NegotiationRole.SERVER); } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java index 2d8a6d3c..7daaa153 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java @@ -47,10 +47,10 @@ void buildUsesServerRoleInStartPayload() { .build(); Map result = orchestrator.startNegotiation( - NegotiationType.CLARIFICATION, "Please clarify the target.", Map.of("site", "A")); + NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); Map context = cast(result.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); - assertEquals("clarification", context.get("negotiationType")); + assertEquals("target", context.get("negotiationType")); assertEquals("in-progress", context.get("status")); } From 2a5ebc9d82367de3cbecfc26668298682aa27d86 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Fri, 7 Aug 2026 15:14:31 +0800 Subject: [PATCH 014/295] ci: add GitHub Actions workflow for build and test - Add .github/workflows/ci.yml - Triggers on push/PR to main - Runs mvn clean verify to compile and test all modules - Uses JDK 17 (matching maven.compiler.release) with Maven caching - Caching via actions/setup-java for faster builds Signed-off-by: ZhouJie6 --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1e4ae31a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-and-test: + name: Build & Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - name: Build and run tests + run: mvn -B clean verify From 82be93923f00d4d9941f86bd56f9b78154af74ac Mon Sep 17 00:00:00 2001 From: gcw_mr3RVhmf Date: Wed, 12 Aug 2026 16:15:43 +0800 Subject: [PATCH 015/295] ci: add A2A-T template lint gate Signed-off-by: gcw_mr3RVhmf --- .github/workflows/ci.yml | 8 +++ tools/template_lint.py | 139 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tools/template_lint.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e4ae31a..5eb26106 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,5 +21,13 @@ jobs: java-version: '17' cache: maven + - name: Set up Python for template lint + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Lint bundled A2A-T templates + run: python tools/template_lint.py --resource-root a2a-t-resources/src/main/resources/prompt_resources + - name: Build and run tests run: mvn -B clean verify diff --git a/tools/template_lint.py b/tools/template_lint.py new file mode 100644 index 00000000..18dd1a65 --- /dev/null +++ b/tools/template_lint.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Static CI gate for A2A-T prompt templates and their slot JSON Schemas.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + + +HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$") +SLOT = re.compile(r"{{\s*([^{}\s]+)\s*}}") +TASK = {"Task Description", "Task Type", "Task Target", "Task Object", "Task Context", "Constraints", "Expected Output"} +NOTIFICATION = {"Subscription Description", "Notification Topic", "Subscribe Condition", "Notification Data Format", "Expected Output"} +ALIASES = { + "任务描述": "Task Description", "任务类型": "Task Type", "任务目标": "Task Target", "任务对象": "Task Object", + "目标对象": "Task Object", "任务上下文": "Task Context", "约束条件": "Constraints", "预期输出": "Expected Output", + "订阅描述": "Subscription Description", "通知主题": "Notification Topic", "订阅条件": "Subscribe Condition", + "通知数据格式": "Notification Data Format", "上报通知数据格式": "Notification Data Format", +} + + +def error(path: Path, line: int, rule: str, message: str) -> str: + return f"{path}:{line}: [{rule}] {message}" + + +def canonical_heading(value: str) -> str: + value = value.strip() + if value in TASK | NOTIFICATION: + return value + match = re.fullmatch(r"(.+?)\s*\(([^)]+)\)", value) + for candidate in (value,) if match is None else (match.group(1).strip(), match.group(2).strip()): + if candidate in ALIASES: + return ALIASES[candidate] + if candidate in TASK | NOTIFICATION: + return candidate + return value + + +def schema_slots(path: Path, errors: list[str]) -> set[str]: + try: + schema = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + errors.append(error(path, 1, "schema-json", f"Cannot load JSON Schema: {exc}")) + return set() + properties = schema.get("properties") + if not isinstance(properties, dict): + errors.append(error(path, 1, "schema-properties", "Schema must define an object 'properties'.")) + return set() + names = set(properties) + required = schema.get("required", []) + if not isinstance(required, list) or not all(isinstance(name, str) for name in required): + errors.append(error(path, 1, "schema-required", "Schema 'required' must be an array of strings.")) + else: + for name in required: + if name not in names: + errors.append(error(path, 1, "schema-required", f"Required slot '{name}' is not in properties.")) + return names + + +def lint_pair(template_path: Path, schema_path: Path) -> list[str]: + errors: list[str] = [] + slots = schema_slots(schema_path, errors) + try: + lines = template_path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + return [*errors, error(template_path, 1, "template-read", f"Cannot read template: {exc}")] + headings: list[tuple[int, str]] = [] + placeholders: list[tuple[int, str]] = [] + for line_no, line in enumerate(lines, 1): + match = HEADING.match(line) + if match: + if len(match.group(1)) == 2: + headings.append((line_no, canonical_heading(match.group(2)))) + placeholders.extend((line_no, name) for name in SLOT.findall(line)) + names = [name for _, name in headings] + profile = "notification" if "Subscription Description" in names else "task" + allowed = NOTIFICATION if profile == "notification" else TASK + required = {"Subscription Description"} if profile == "notification" else {"Task Description"} + if not headings: + errors.append(error(template_path, 1, "instruction-missing", "Template must contain L0 instructions marked with '##'.")) + for line_no, name in headings: + if name not in allowed: + errors.append(error(template_path, line_no, "instruction-name", f"'{name}' is not valid for the {profile} profile.")) + for name in sorted(required - set(names)): + errors.append(error(template_path, 1, "instruction-required", f"Missing required instruction '{name}'.")) + for name in set(names): + occurrences = [line_no for line_no, value in headings if value == name] + if len(occurrences) > 1: + errors.append(error(template_path, occurrences[1], "instruction-duplicate", f"Instruction '{name}' is repeated.")) + used: set[str] = set() + for line_no, name in placeholders: + if name in used: + continue + used.add(name) + if name not in slots: + errors.append(error(template_path, line_no, "slot-undefined", f"Placeholder '{{{{{name}}}}}' is missing from {schema_path.name}.")) + for name in sorted(slots - used): + errors.append(error(schema_path, 1, "slot-unused", f"Schema slot '{name}' has no template placeholder.")) + return errors + + +def lint_root(root: Path) -> list[str]: + templates, slots = root / "templates", root / "slots" + if not templates.is_dir(): + return [error(templates, 1, "resource-root", "Missing templates directory.")] + if not slots.is_dir(): + return [error(slots, 1, "resource-root", "Missing slots directory.")] + errors: list[str] = [] + for template_path in sorted(templates.glob("*/*/template.md")): + schema_path = slots / template_path.relative_to(templates).parent / "slot.json" + if schema_path.is_file(): + errors.extend(lint_pair(template_path, schema_path)) + else: + errors.append(error(template_path, 1, "slot-schema-missing", f"Missing paired slot schema: {schema_path}")) + for schema_path in sorted(slots.glob("*/*/slot.json")): + template_path = templates / schema_path.relative_to(slots).parent / "template.md" + if not template_path.is_file(): + errors.append(error(schema_path, 1, "template-missing", f"Missing paired template: {template_path}")) + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--resource-root", type=Path, required=True) + errors = lint_root(parser.parse_args().resource_root) + for item in errors: + print(item, file=sys.stderr) + if errors: + print(f"A2A-T template lint failed with {len(errors)} error(s).", file=sys.stderr) + return 1 + print("A2A-T template lint passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 8a0e71473a960590bcb22c21dc9c1ec35e209e42 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Wed, 19 Aug 2026 14:43:19 +0800 Subject: [PATCH 016/295] feat: remove support for fault diagnosis scenarios Signed-off-by: zhangwenjun-huawei --- .../scenarios/en-US/scenarios.json | 6 --- .../scenarios/zh-CN/scenarios.json | 6 --- .../slots/fault_diagnosis/en-US/slot.json | 35 ---------------- .../slots/fault_diagnosis/zh-CN/slot.json | 35 ---------------- .../fault_diagnosis/en-US/template.md | 40 ------------------- .../fault_diagnosis/zh-CN/template.md | 40 ------------------- .../ClasspathPromptResourceLoaderTest.java | 32 --------------- 7 files changed, 194 deletions(-) delete mode 100644 a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/en-US/slot.json delete mode 100644 a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/zh-CN/slot.json delete mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/en-US/template.md delete mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json index 79a2026e..6c13fd01 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json @@ -17,12 +17,6 @@ "scenario_name": "Private Line Complaint", "description": "Used to generate an A2A-T task request for network-side fault root cause diagnosis in a private line complaint scenario in a telecom network management system.", "example": "Please confirm the root cause of fault fault-id-1-328-20260510-58489 that occurred on the Guangdong enterprise private line at 2026-05-10T18:25:30Z, and provide repair suggestions." - }, - { - "scenario_code": "fault_diagnosis", - "scenario_name": "Fault Root Cause Diagnosis", - "description": "Based on event information output by event recognition and preliminary demarcation analysis, preliminary root cause analysis, and preliminary impact analysis, output root cause localization information and recommended handling plans.", - "example": "Please perform root cause localization for the fault event based on the following information, and output repair suggestions and service impact analysis: the event sequence number is 1856365516_2839324485_2130908106_4130674041, titled 5G base station outage event, the alarm vendor internal sequence number associated with the event is 123211121244, the unique identifier of the associated alarm in the NMS is 0108-007-012-10-000003, the associated alarm title is gNodeB outage alarm, the event generation time is 2025-05-22 08:40:44, the alarm severity is level 4, and the network element name is QZHA-HAZBYSDGG-HRHH." } ] } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json index a4442394..e30eee97 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json @@ -17,12 +17,6 @@ "scenario_name": "专线投诉", "description": "用于生成电信网管系统中 专线投诉场景的网络侧故障根因诊断的 A2A-T 任务请求。", "example": "请确认广东政企专线2026-05-10T18:25:30Z发生的故障fault-id-1-328-20260510-58489的根因,并提供修复建议。" - }, - { - "scenario_code": "fault_diagnosis", - "scenario_name": "故障根因诊断", - "description": "根据事件识别输出的事件信息和初步定界分析、初步根因分析、初步影响分析等信息,输出根因定位信息及建议处理方案。", - "example": "请根据如下信息进行故障事件的根因定位,输出修复建议和业务影响分析:事件流水号是1856365516_2839324485_2130908106_4130674041,标题为5G基站退服事件,事件关联的告警厂家内部流水号为123211121244,事件关联告警在网管系统内唯一标识为0108-007-012-10-000003,事件关联告警标题是gNodeB退服告警,事件产生时间为2025-05-22 08:40:44,告警级别是四级,网元名称为QZHA-HAZBYSDGG-HRHH。" } ] } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/en-US/slot.json deleted file mode 100644 index 8cf1d677..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/en-US/slot.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "additionalProperties": false, - "properties": { - "task_target": { - "type": "string", - "description": "The specific goal of fault root cause diagnosis.", - "examples": [ - "Please perform root cause localization for the fault event, and output professional root cause localization information, professional classification of the fault root cause, fault root cause details, remote handling plan, on-site emergency repair plan, and service impact analysis." - ], - "x-a2at-value-constraint": "State the specific requirements for the fault diagnosis task, such as completing root cause localization of the fault event, and outputting professional root cause localization information, professional classification of the fault root cause, fault root cause details, remote handling plan, on-site emergency repair plan, and service impact analysis." - }, - "task_context": { - "type": "string", - "description": "The request parameters of the fault root cause diagnosis task.", - "examples": [ - "There is 1 alarm event to be diagnosed, with the following details:\r\n\r\n- The event sequence number is 1856365516_2839324485_2130908106_4130674041, titled 5G base station outage event, the alarm vendor internal sequence number associated with the event is 123211121244, the unique identifier of the associated alarm in the NMS is 0108-007-012-10-000003, the associated alarm title is gNodeB outage alarm, the event generation time is 2025-05-22 08:40:44, the alarm severity is level 4, and the network element name is QZHA-HAZBYSDGG-HRHH." - ], - "x-a2at-value-constraint": "A fault root cause diagnosis task may contain multiple alarm events. Each event may contain the following information:\r\n1. Fault event sequence number. Required. Example: 1856365516_2839324485_2130908106_4130674041\r\n2. Event title name. Required. Example: 5G base station outage event\r\n3. Alarm vendor internal sequence number associated with the event. Required. Example: 123211121244\r\n4. Transport-domain alarm extended sequence number associated with the event. Optional.\r\n5. Unique identifier of the associated alarm in the NMS. Required. Example: 0108-007-012-10-000003\r\n6. Associated alarm title. Required. Example: gNodeB outage alarm\r\n7. Event generation time. Required. Example: 2025-05-22 08:40:44\r\n8. Alarm severity; the smaller the number, the more severe. Level 1 alarm (Critical); Level 2 alarm (Major); Level 3 alarm (Minor); Level 4 alarm (Warning). Required. Example: Level 4 alarm\r\n9. Base station ID, used by the wireless workbench. Optional. Example: 2148660\r\n10. Network element name. Required. Example: QZHA-HAZBYSDGG-HRHH\r\n11. Circuit ID. Optional. Example: QZHAZBHNLX-QZHAJL10GE1097086NR\r\n12. Equipment room information. Optional. Example: Self-built equipment room on the first floor of QZHAZBYS" - }, - "expected_output": { - "type": "string", - "description": "The specific requirements for the returned fault diagnosis result.", - "examples": [ - "For the expected fault diagnosis result to be returned, the following information must be declared:\r\n\r\n1. Declare which type of A2A Part carries it, such as DataPart or TextPart.\r\n2. Declare which information elements are included (e.g., diagnosis result, localization conclusion, localization analysis process, etc.) " - ], - "x-a2at-value-constraint": "A fault root cause diagnosis task may contain multiple alarm events. Each event may contain the following information:\r\n1. Fault event sequence number. Required. Example: 1856365516_2839324485_2130908106_4130674041\r\n2. Event title name. Required. Example: 5G base station outage event\r\n3. Alarm vendor internal sequence number associated with the event. Required. Example: 123211121244\r\n4. Transport-domain alarm extended sequence number associated with the event. Optional.\r\n5. Unique identifier of the associated alarm in the NMS. Required. Example: 0108-007-012-10-000003\r\n6. Associated alarm title. Required. Example: gNodeB outage alarm\r\n7. Event generation time. Required. Example: 2025-05-22 08:40:44\r\n8. Alarm severity; the smaller the number, the more severe. Level 1 alarm (Critical); Level 2 alarm (Major); Level 3 alarm (Minor); Level 4 alarm (Warning). Required. Example: Level 4 alarm\r\n9. Base station ID, used by the wireless workbench. Optional. Example: 2148660\r\n10. Network element name. Required. Example: QZHA-HAZBYSDGG-HRHH\r\n11. Circuit ID. Optional. Example: QZHAZBHNLX-QZHAJL10GE1097086NR\r\n12. Equipment room information. Optional. Example: Self-built equipment room on the first floor of QZHAZBYS" - } - }, - "required": [ - "task_target", - "task_context" - ] -} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/zh-CN/slot.json deleted file mode 100644 index 4600e60e..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/zh-CN/slot.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "additionalProperties": false, - "properties": { - "任务目标": { - "type": "string", - "description": "故障根因诊断的具体目标", - "examples": [ - "请进行故障事件的根因定位,输出专业根因定位信息、故障根因专业分类、故障根因详情、远程处置方案、现场抢修方案及业务影响分析。" - ], - "x-a2at-value-constraint": "说明对故障诊断任务的具体要求,如完成故障事件的根因定位,输出专业根因定位信息、故障根因专业分类、故障根因详情、远程处置方案、现场抢修方案及业务影响分析。" - }, - "任务上下文": { - "type": "string", - "description": "故障根因诊断任务的请求参数", - "examples": [ - "有1条待诊断的告警事件,具体内容如下:\r\n\r\n- 事件流水号是1856365516_2839324485_2130908106_4130674041,标题为5G基站退服事件,事件关联的告警厂家内部流水号为123211121244,事件关联告警在网管系统内唯一标识为0108-007-012-10-000003,事件关联告警标题是gNodeB退服告警,事件产生时间为2025-05-22 08:40:44,告警级别是四级,网元名称为QZHA-HAZBYSDGG-HRHH。" - ], - "x-a2at-value-constraint": "故障根因诊断任务可包含多条告警事件,每条事件可包含以下信息:\r\n1. 故障事件流水号,必选。样例:1856365516_2839324485_2130908106_4130674041\r\n2. 事件标题名称,必选。样例:5G基站退服事件\r\n3. 事件关联的告警厂家内部流水号,必选。样例:123211121244\r\n4. 事件关联的传输专业告警扩展流水号,可选。\r\n5. 事件关联告警在网管系统内唯一标识,必选。样例:0108-007-012-10-000003\r\n6. 事件关联告警标题,必选。样例:gNodeB退服告警\r\n7. 事件产生时间,必选。样例:2025-05-22 08:40:44\r\n8. 告警级别,数字越小越严重。一级告警(Critical);二级告警(Major);三级告警(Minor);四级告警(Warning)。必选。样例:四级告警\r\n9. 基站ID,无线工作台用。可选。样例:2148660\r\n10. 网元名称,必选。样例:QZHA-HAZBYSDGG-HRHH\r\n11. 电路号,可选。样例:QZHAZBHNLX-QZHAJL10GE1097086NR\r\n12. 机房信息,可选。样例:QZHAZBYS一楼自建机房" - }, - "预期输出": { - "type": "string", - "description": "对返回的故障诊断结果的具体要求", - "examples": [ - "针对预期返回的故障诊断结果,需要声明如下信息:\r\n\r\n1. 需要声明基于A2A的哪一类Part承载,如DataPart或TextPart。\r\n2. 需要声明包含哪些信息要素(如诊断结果、定位结论、定位分析过程等) " - ], - "x-a2at-value-constraint": "故障根因诊断任务可包含多条告警事件,每条事件可包含以下信息:\r\n1. 故障事件流水号,必选。样例:1856365516_2839324485_2130908106_4130674041\r\n2. 事件标题名称,必选。样例:5G基站退服事件\r\n3. 事件关联的告警厂家内部流水号,必选。样例:123211121244\r\n4. 事件关联的传输专业告警扩展流水号,可选。\r\n5. 事件关联告警在网管系统内唯一标识,必选。样例:0108-007-012-10-000003\r\n6. 事件关联告警标题,必选。样例:gNodeB退服告警\r\n7. 事件产生时间,必选。样例:2025-05-22 08:40:44\r\n8. 告警级别,数字越小越严重。一级告警(Critical);二级告警(Major);三级告警(Minor);四级告警(Warning)。必选。样例:四级告警\r\n9. 基站ID,无线工作台用。可选。样例:2148660\r\n10. 网元名称,必选。样例:QZHA-HAZBYSDGG-HRHH\r\n11. 电路号,可选。样例:QZHAZBHNLX-QZHAJL10GE1097086NR\r\n12. 机房信息,可选。样例:QZHAZBYS一楼自建机房" - } - }, - "required": [ - "任务目标", - "任务上下文" - ] -} \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/en-US/template.md deleted file mode 100644 index ef296829..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/en-US/template.md +++ /dev/null @@ -1,40 +0,0 @@ -## Task Type -Fault root cause diagnosis. - -## Task Description -Based on the event information output by event recognition and the preliminary demarcation analysis, preliminary root cause analysis, and preliminary impact analysis, perform root cause localization for the fault event, and output the root cause localization information and the recommended handling plan. - -## Task Target -{{task_target}} (required) -Requirement: State the specific requirements for the fault diagnosis task, such as completing root cause localization of the fault event, and outputting professional root cause localization information, professional classification of the fault root cause, fault root cause details, remote handling plan, on-site emergency repair plan, and service impact analysis. -Example: Please perform root cause localization for the fault event, and output professional root cause localization information, professional classification of the fault root cause, fault root cause details, remote handling plan, on-site emergency repair plan, and service impact analysis. - -## Task Context -{{task_context}} (required) -Requirement: A fault root cause diagnosis task may contain multiple alarm events. Each event may contain the following information: -1. Fault event sequence number. Required. Example: 1856365516_2839324485_2130908106_4130674041 -2. Event title name. Required. Example: 5G base station outage event -3. Alarm vendor internal sequence number associated with the event. Required. Example: 123211121244 -4. Transport-domain alarm extended sequence number associated with the event. Optional. -5. Unique identifier of the associated alarm in the NMS. Required. Example: 0108-007-012-10-000003 -6. Associated alarm title. Required. Example: gNodeB outage alarm -7. Event generation time. Required. Example: 2025-05-22 08:40:44 -8. Alarm severity; the smaller the number, the more severe. Level 1 alarm (Critical); Level 2 alarm (Major); Level 3 alarm (Minor); Level 4 alarm (Warning). Required. Example: Level 4 alarm -9. Base station ID, used by the wireless workbench. Optional. Example: 2148660 -10. Network element name. Required. Example: QZHA-HAZBYSDGG-HRHH -11. Circuit ID. Optional. Example: QZHAZBHNLX-QZHAJL10GE1097086NR -12. Equipment room information. Optional. Example: Self-built equipment room on the first floor of QZHAZBYS - -Example: There is 1 alarm event to be diagnosed, with the following details: -- The event sequence number is 1856365516_2839324485_2130908106_4130674041, titled 5G base station outage event, the alarm vendor internal sequence number associated with the event is 123211121244, the unique identifier of the associated alarm in the NMS is 0108-007-012-10-000003, the associated alarm title is gNodeB outage alarm, the event generation time is 2025-05-22 08:40:44, the alarm severity is level 4, and the network element name is QZHA-HAZBYSDGG-HRHH. - - - -## Expected Output -{{expected_output}} (optional) -Requirement: For the expected fault diagnosis result to be returned, the following information must be declared: -1. Declare which type of A2A Part carries it, such as DataPart or TextPart. -2. Declare which information elements are included (e.g., diagnosis result, localization conclusion, localization analysis process, etc.). -Example: Require the fault diagnosis result to be returned via TextPart. The specific content of the fault diagnosis result is required as follows: -1. The allowed values of the fault root cause localization result include: successful, rejected, failed, and other. -2. Fault root cause details, including the name, details, and repair suggestions (including remote handling and on-site emergency repair) of the fault root cause, the localization analysis process, the service impact, and the identifier, type, name, and detailed location of the resource object where the fault root cause point resides. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/zh-CN/template.md deleted file mode 100644 index 831ec01e..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/zh-CN/template.md +++ /dev/null @@ -1,40 +0,0 @@ -## 任务类型(Task Type) -故障根因诊断。 - -## 任务描述(Task Description) -根据事件识别输出的事件信息和初步定界分析、初步根因分析、初步影响分析等信息,进行故障事件的根因定位,输出根因定位信息及建议处理方案。 - -## 任务目标(Task Target) -{{任务目标}}(必选) -要求:说明对故障诊断任务的具体要求,如完成故障事件的根因定位,输出专业根因定位信息、故障根因专业分类、故障根因详情、远程处置方案、现场抢修方案及业务影响分析。 -样例:请进行故障事件的根因定位,输出专业根因定位信息、故障根因专业分类、故障根因详情、远程处置方案、现场抢修方案及业务影响分析。 - -## 任务上下文(Task Context) -{{任务上下文}}(必选) -要求:故障根因诊断任务可包含多条告警事件,每条事件可包含以下信息: -1. 故障事件流水号,必选。样例:1856365516_2839324485_2130908106_4130674041 -2. 事件标题名称,必选。样例:5G基站退服事件 -3. 事件关联的告警厂家内部流水号,必选。样例:123211121244 -4. 事件关联的传输专业告警扩展流水号,可选。 -5. 事件关联告警在网管系统内唯一标识,必选。样例:0108-007-012-10-000003 -6. 事件关联告警标题,必选。样例:gNodeB退服告警 -7. 事件产生时间,必选。样例:2025-05-22 08:40:44 -8. 告警级别,数字越小越严重。一级告警(Critical);二级告警(Major);三级告警(Minor);四级告警(Warning)。必选。样例:四级告警 -9. 基站ID,无线工作台用。可选。样例:2148660 -10. 网元名称,必选。样例:QZHA-HAZBYSDGG-HRHH -11. 电路号,可选。样例:QZHAZBHNLX-QZHAJL10GE1097086NR -12. 机房信息,可选。样例:QZHAZBYS一楼自建机房 - -样例:有1条待诊断的告警事件,具体内容如下: -- 事件流水号是1856365516_2839324485_2130908106_4130674041,标题为5G基站退服事件,事件关联的告警厂家内部流水号为123211121244,事件关联告警在网管系统内唯一标识为0108-007-012-10-000003,事件关联告警标题是gNodeB退服告警,事件产生时间为2025-05-22 08:40:44,告警级别是四级,网元名称为QZHA-HAZBYSDGG-HRHH。 - - - -## 预期输出(Expected Output) -{{预期输出}}(可选) -要求:针对预期返回的故障诊断结果,需要声明如下信息: -1. 需要声明基于A2A的哪一类Part承载,如DataPart或TextPart。 -2. 需要声明包含哪些信息要素(如诊断结果、定位结论、定位分析过程等) -样例:要求通过TextPart返回故障诊断结果。故障诊断结果的具体内容要求如下: -1. 故障根因定位结果的取值范围包括:成功、驳回、失败、其他。 -2. 故障根因详细信息,包括 故障根因的名称、详情、修复建议(含远程处理和现场抢修)、定位分析过程、业务影响, 故障根因点所在资源对象的标识、类型,名称,详细位置等信息。 diff --git a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java index 04677a93..04d9ccda 100644 --- a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java +++ b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java @@ -56,36 +56,4 @@ void loadsPackagedSubscribeIncidentSlotSchemaWithSemanticHint() { assertTrue(text.contains("medium")); assertTrue(text.contains("low")); } - - @Test - void loadsPackagedFaultDiagnosisScenarioCatalogWithLatestShortExample() { - String text = loader.loadText(new PromptResourceKey("scenarios", "catalog", "zh-CN", "scenarios.json")); - - assertTrue(text.contains("\"scenario_code\": \"fault_diagnosis\"")); - assertTrue(text.contains("1856365516_2839324485_2130908106_4130674041")); - assertFalse(text.contains("task_request_id")); - } - - @Test - void loadsPackagedFaultDiagnosisSlotSchemaWithLatestConciseDescriptions() { - String text = loader.loadText(new PromptResourceKey("slots", "fault_diagnosis", "zh-CN", "slot.json")); - - assertTrue(text.contains("\"type\": \"string\"")); - assertTrue(text.contains("\"required\": [")); - assertTrue(text.contains("DataPart")); - assertTrue(text.contains("TextPart")); - assertFalse(text.contains("task_request_id")); - } - - @Test - void loadsPackagedFaultDiagnosisTemplateWithoutLegacyLongExamples() { - String text = loader.loadText(new PromptResourceKey("templates", "fault_diagnosis", "zh-CN", "template.md")); - - assertTrue(text.contains("Task Type")); - assertTrue(text.contains("Task Target")); - assertTrue(text.contains("DataPart")); - assertTrue(text.contains("TextPart")); - assertFalse(text.contains("Full request example")); - assertFalse(text.contains("Full response example")); - } } From c733120aed846f848da5f8de2b7a3df753f435c5 Mon Sep 17 00:00:00 2001 From: wangruiyang8 Date: Wed, 19 Aug 2026 15:42:46 +0800 Subject: [PATCH 017/295] refactor: move sample content into subscribe_incident subpackage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all Java sources and resources from net.openan.a2at.sample to net.openan.a2at.sample.subscribe_incident, updating package declarations, imports, and resource path references accordingly. Update docs (README, 开发指南, 用户指南) to reflect the new package paths and sample entry class names. Signed-off-by: wangruiyang8 --- a2a-t-sample/README.zh-CN.md | 16 ++++++------ a2a-t-sample/pom.xml | 4 +-- .../client/runtime/SampleClientRuntime.java | 24 ----------------- .../client/ClientSampleMain.java | 16 ++++++------ .../client/ClientSampleMarker.java | 2 +- .../client/flow/A2AJavaClientEventMapper.java | 4 +-- .../client/flow/ClientEventStreamBuffer.java | 4 +-- .../client/flow/ClientSampleFlow.java | 26 +++++++++---------- .../SampleStreamTerminalStateDecider.java | 2 +- .../client/package-info.java | 2 +- .../client/prompt/SamplePromptClient.java | 2 +- .../prompt/SamplePromptInputAdapter.java | 2 +- .../client/registry/SampleRegistryClient.java | 2 +- .../client/request/A2AJavaRequestBuilder.java | 2 +- .../client/request/BuiltA2AJavaRequest.java | 2 +- .../client/runtime/A2AJavaClientRuntime.java | 2 +- .../runtime/DefaultSampleClientRuntime.java | 20 +++++++------- .../client/runtime/SampleClientRuntime.java | 24 +++++++++++++++++ .../runtime/SampleClientRuntimeFactory.java | 2 +- .../package-info.java | 2 +- .../server/ServerSampleMain.java | 22 ++++++++-------- .../server/ServerSampleMarker.java | 2 +- .../ServerSampleAgentCardBuilder.java | 2 +- .../server/flow/PromptComplianceChecker.java | 2 +- .../flow/ServerFlowInterruptedException.java | 2 +- .../flow/ServerSampleAgentExecutor.java | 2 +- .../server/flow/ServerSampleFlow.java | 8 +++--- .../server/flow/ServerSleepController.java | 2 +- .../server/http/EmbeddedA2AHttpServer.java | 2 +- .../server/http/ServerSampleApplication.java | 4 +-- .../server/package-info.java | 2 +- .../server/registry/ServerRegistryClient.java | 4 +-- .../registry/ServerRegistryResponse.java | 2 +- .../registry/ServerRegistryTransport.java | 2 +- .../server/runtime/A2AJavaServerRuntime.java | 2 +- .../runtime/DefaultSampleServerRuntime.java | 20 +++++++------- .../server/runtime/SampleServerRuntime.java | 4 +-- .../runtime/SampleServerRuntimeFactory.java | 2 +- .../server/runtime/ServerBind.java | 2 +- .../server/runtime/ServerBootstrapResult.java | 2 +- .../shared/SharedSampleMarker.java | 2 +- .../shared/endpoint/AgentEndpointCache.java | 4 +-- .../endpoint/AgentEndpointResolver.java | 4 +-- .../endpoint/ResolvedAgentEndpoint.java | 2 +- .../env/SampleEnvironmentPathResolver.java | 2 +- .../shared/error/ValueErrorException.java | 2 +- .../logging/SampleLoggingFormatter.java | 2 +- .../shared/package-info.java | 2 +- .../registry/RegistryAgentCardMapper.java | 2 +- .../shared/scenario/SampleScenarioLoader.java | 4 +-- .../stream/SampleStreamEventNormalizer.java | 4 +-- .../client/scenario.json | 0 .../DefaultSampleServerRuntimeTest.java | 2 +- docs/en/developer_guide.md | 4 +-- docs/en/user_guide.md | 2 +- ...00\345\217\221\346\214\207\345\215\227.md" | 4 +-- ...50\346\210\267\346\214\207\345\215\227.md" | 2 +- 57 files changed, 147 insertions(+), 147 deletions(-) delete mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/SampleClientRuntime.java rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/ClientSampleMain.java (71%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/ClientSampleMarker.java (73%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/flow/A2AJavaClientEventMapper.java (96%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/flow/ClientEventStreamBuffer.java (94%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/flow/ClientSampleFlow.java (84%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/flow/SampleStreamTerminalStateDecider.java (96%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/package-info.java (67%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/prompt/SamplePromptClient.java (80%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/prompt/SamplePromptInputAdapter.java (98%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/registry/SampleRegistryClient.java (78%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/request/A2AJavaRequestBuilder.java (94%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/request/BuiltA2AJavaRequest.java (82%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/runtime/A2AJavaClientRuntime.java (90%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/runtime/DefaultSampleClientRuntime.java (92%) create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntime.java rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/client/runtime/SampleClientRuntimeFactory.java (78%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/package-info.java (67%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/ServerSampleMain.java (76%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/ServerSampleMarker.java (73%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/agentcard/ServerSampleAgentCardBuilder.java (97%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/flow/PromptComplianceChecker.java (83%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/flow/ServerFlowInterruptedException.java (81%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/flow/ServerSampleAgentExecutor.java (96%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/flow/ServerSampleFlow.java (95%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/flow/ServerSleepController.java (77%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/http/EmbeddedA2AHttpServer.java (99%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/http/ServerSampleApplication.java (62%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/package-info.java (67%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/registry/ServerRegistryClient.java (97%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/registry/ServerRegistryResponse.java (76%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/registry/ServerRegistryTransport.java (79%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/runtime/A2AJavaServerRuntime.java (75%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/runtime/DefaultSampleServerRuntime.java (92%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/runtime/SampleServerRuntime.java (78%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/runtime/SampleServerRuntimeFactory.java (77%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/runtime/ServerBind.java (67%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/server/runtime/ServerBootstrapResult.java (81%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/shared/SharedSampleMarker.java (73%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/shared/endpoint/AgentEndpointCache.java (76%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/shared/endpoint/AgentEndpointResolver.java (92%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/shared/endpoint/ResolvedAgentEndpoint.java (77%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/shared/env/SampleEnvironmentPathResolver.java (91%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/shared/error/ValueErrorException.java (85%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/shared/logging/SampleLoggingFormatter.java (98%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/shared/package-info.java (61%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/shared/registry/RegistryAgentCardMapper.java (98%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/shared/scenario/SampleScenarioLoader.java (87%) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{ => subscribe_incident}/shared/stream/SampleStreamEventNormalizer.java (90%) rename a2a-t-sample/src/main/resources/sample/{ => subscribe_incident}/client/scenario.json (100%) rename a2a-t-sample/src/test/java/net/openan/a2at/sample/{ => subscribe_incident}/server/runtime/DefaultSampleServerRuntimeTest.java (96%) diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 80f5ed37..534c017b 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -8,26 +8,26 @@ ## 入口类 -- 客户端:`net.openan.a2at.sample.client.ClientSampleMain` -- 服务端:`net.openan.a2at.sample.server.ServerSampleMain` +- 客户端:`net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain` +- 服务端:`net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain` ## 模块内资源 -- 客户端环境模板:`sample/client/client.env.example` -- 服务端环境模板:`sample/server/server.env.example` -- 客户端场景输入:`sample/client/scenario.json` +- 客户端环境模板:`sample/subscribe_incident/client/client.env` +- 服务端环境模板:`sample/subscribe_incident/server/server.env` +- 客户端场景输入:`sample/subscribe_incident/client/scenario.json` ## 客户端启动 1. 修改仓库根目录下的 `client.env`,补充可用的 `A2AT_LLM_API_KEY` -2. 如需修改默认请求内容,可编辑 `sample/client/scenario.json` +2. 如需修改默认请求内容,可编辑 `sample/subscribe_incident/client/scenario.json` 3. 启动客户端: ```bash java @a2a-t-sample/target/client.javaargs.txt ``` -如果不传参数,`ClientSampleMain` 会回退到包内的 `sample/client/client.env.example`。 +如果不传参数,`ClientSampleMain` 会回退到包内的 `sample/subscribe_incident/client/client.env`。 ## 服务端启动 @@ -38,7 +38,7 @@ java @a2a-t-sample/target/client.javaargs.txt java @a2a-t-sample/target/server.javaargs.txt ``` -如果不传参数,`ServerSampleMain` 会回退到包内的 `sample/server/server.env.example`。 +如果不传参数,`ServerSampleMain` 会回退到包内的 `sample/subscribe_incident/server/server.env`。 ## Git Bash 本地调试 diff --git a/a2a-t-sample/pom.xml b/a2a-t-sample/pom.xml index aa170b9d..68928923 100644 --- a/a2a-t-sample/pom.xml +++ b/a2a-t-sample/pom.xml @@ -122,12 +122,12 @@ value="${project.build.directory}${file.separator}${project.build.finalName}.jar" /> -cp ${sample.jar.path}${path.separator}${sample.runtime.classpath} -net.openan.a2at.sample.server.ServerSampleMain +net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain server.env -cp ${sample.jar.path}${path.separator}${sample.runtime.classpath} -net.openan.a2at.sample.client.ClientSampleMain +net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain client.env diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/SampleClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/SampleClientRuntime.java deleted file mode 100644 index 53254c87..00000000 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/SampleClientRuntime.java +++ /dev/null @@ -1,24 +0,0 @@ -package net.openan.a2at.sample.client.runtime; - -import net.openan.a2at.sample.client.prompt.SamplePromptClient; -import net.openan.a2at.sample.client.registry.SampleRegistryClient; -import net.openan.a2at.sample.shared.endpoint.AgentEndpointCache; - -/** - * Runtime bundle required by the client sample main flow. - * - * @since 2026-05 - */ -public interface SampleClientRuntime extends AutoCloseable { - - SampleRegistryClient registryClient(); - - SamplePromptClient promptClient(); - - AgentEndpointCache endpointCache(); - - @Override - void close(); -} - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/ClientSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMain.java similarity index 71% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/ClientSampleMain.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMain.java index 9628ab1f..21ff5446 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/ClientSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMain.java @@ -1,15 +1,15 @@ -package net.openan.a2at.sample.client; +package net.openan.a2at.sample.subscribe_incident.client; import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.function.Consumer; -import net.openan.a2at.sample.client.flow.ClientSampleFlow; -import net.openan.a2at.sample.client.runtime.A2AJavaClientRuntime; -import net.openan.a2at.sample.client.runtime.DefaultSampleClientRuntime; -import net.openan.a2at.sample.client.runtime.SampleClientRuntime; -import net.openan.a2at.sample.client.runtime.SampleClientRuntimeFactory; -import net.openan.a2at.sample.shared.scenario.SampleScenarioLoader; +import net.openan.a2at.sample.subscribe_incident.client.flow.ClientSampleFlow; +import net.openan.a2at.sample.subscribe_incident.client.runtime.A2AJavaClientRuntime; +import net.openan.a2at.sample.subscribe_incident.client.runtime.DefaultSampleClientRuntime; +import net.openan.a2at.sample.subscribe_incident.client.runtime.SampleClientRuntime; +import net.openan.a2at.sample.subscribe_incident.client.runtime.SampleClientRuntimeFactory; +import net.openan.a2at.sample.subscribe_incident.shared.scenario.SampleScenarioLoader; /** * Main entry orchestration for the client sample. @@ -38,7 +38,7 @@ public static List> runMain( if (!(runtime instanceof A2AJavaClientRuntime a2aRuntime)) { throw new IllegalStateException("Sample client runtime must implement A2AJavaClientRuntime"); } - Map scenarioPayload = SampleScenarioLoader.loadClasspathScenario("sample/client/scenario.json"); + Map scenarioPayload = SampleScenarioLoader.loadClasspathScenario("sample/subscribe_incident/client/scenario.json"); return ClientSampleFlow.runClientFlow( scenarioPayload, runtime.registryClient(), diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/ClientSampleMarker.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMarker.java similarity index 73% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/ClientSampleMarker.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMarker.java index 58cfe543..e7a28d32 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/ClientSampleMarker.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMarker.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client; +package net.openan.a2at.sample.subscribe_incident.client; /** * Marker type for client sample entrypoint code. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/A2AJavaClientEventMapper.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/A2AJavaClientEventMapper.java similarity index 96% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/A2AJavaClientEventMapper.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/A2AJavaClientEventMapper.java index 3a59477d..742417c9 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/A2AJavaClientEventMapper.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/A2AJavaClientEventMapper.java @@ -1,8 +1,8 @@ -package net.openan.a2at.sample.client.flow; +package net.openan.a2at.sample.subscribe_incident.client.flow; import java.util.List; import java.util.Map; -import net.openan.a2at.sample.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; import org.a2aproject.sdk.client.TaskEvent; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientEventStreamBuffer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientEventStreamBuffer.java similarity index 94% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientEventStreamBuffer.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientEventStreamBuffer.java index a6e16984..64dcbe03 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientEventStreamBuffer.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientEventStreamBuffer.java @@ -1,10 +1,10 @@ -package net.openan.a2at.sample.client.flow; +package net.openan.a2at.sample.subscribe_incident.client.flow; import java.util.Iterator; import java.util.NoSuchElementException; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; -import net.openan.a2at.sample.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; import org.a2aproject.sdk.client.ClientEvent; /** diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientSampleFlow.java similarity index 84% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientSampleFlow.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientSampleFlow.java index c3fef3b7..8a8ebbc6 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientSampleFlow.java @@ -1,20 +1,20 @@ -package net.openan.a2at.sample.client.flow; +package net.openan.a2at.sample.subscribe_incident.client.flow; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Consumer; -import net.openan.a2at.sample.client.prompt.SamplePromptClient; -import net.openan.a2at.sample.client.registry.SampleRegistryClient; -import net.openan.a2at.sample.client.request.A2AJavaRequestBuilder; -import net.openan.a2at.sample.client.request.BuiltA2AJavaRequest; -import net.openan.a2at.sample.client.runtime.A2AJavaClientRuntime; -import net.openan.a2at.sample.shared.endpoint.AgentEndpointCache; -import net.openan.a2at.sample.shared.endpoint.AgentEndpointResolver; -import net.openan.a2at.sample.shared.endpoint.ResolvedAgentEndpoint; -import net.openan.a2at.sample.shared.error.ValueErrorException; -import net.openan.a2at.sample.shared.logging.SampleLoggingFormatter; -import net.openan.a2at.sample.shared.stream.SampleStreamEventNormalizer; +import net.openan.a2at.sample.subscribe_incident.client.prompt.SamplePromptClient; +import net.openan.a2at.sample.subscribe_incident.client.registry.SampleRegistryClient; +import net.openan.a2at.sample.subscribe_incident.client.request.A2AJavaRequestBuilder; +import net.openan.a2at.sample.subscribe_incident.client.request.BuiltA2AJavaRequest; +import net.openan.a2at.sample.subscribe_incident.client.runtime.A2AJavaClientRuntime; +import net.openan.a2at.sample.subscribe_incident.shared.endpoint.AgentEndpointCache; +import net.openan.a2at.sample.subscribe_incident.shared.endpoint.AgentEndpointResolver; +import net.openan.a2at.sample.subscribe_incident.shared.endpoint.ResolvedAgentEndpoint; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.logging.SampleLoggingFormatter; +import net.openan.a2at.sample.subscribe_incident.shared.stream.SampleStreamEventNormalizer; import net.openan.a2at.sdk.client.model.PromptGenerationFailure; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import org.a2aproject.sdk.client.ClientEvent; @@ -29,7 +29,7 @@ public final class ClientSampleFlow { "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/NL/v1"; static final String NATURAL_LANGUAGE_PROMPT_INPUT = - "通知主题为Incident的,订阅条件为:订阅级别为critical的ETH-LOS的故障,上报通知数据格式为:DataPart"; + "请生成一个Incident事件订阅任务:通知主题为Incident,订阅条件为订阅级别为critical的ETH-LOS的故障,上报通知数据格式为DataPart"; private ClientSampleFlow() { } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/SampleStreamTerminalStateDecider.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/SampleStreamTerminalStateDecider.java similarity index 96% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/SampleStreamTerminalStateDecider.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/SampleStreamTerminalStateDecider.java index 293b1c4c..9bcc55ed 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/SampleStreamTerminalStateDecider.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/SampleStreamTerminalStateDecider.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.flow; +package net.openan.a2at.sample.subscribe_incident.client.flow; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.TaskEvent; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/package-info.java similarity index 67% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/package-info.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/package-info.java index 9491a606..65675bd7 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/package-info.java @@ -3,5 +3,5 @@ * * @since 2026-05 */ -package net.openan.a2at.sample.client; +package net.openan.a2at.sample.subscribe_incident.client; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/prompt/SamplePromptClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptClient.java similarity index 80% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/prompt/SamplePromptClient.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptClient.java index fb9d5a2b..83b6233b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/prompt/SamplePromptClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptClient.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.prompt; +package net.openan.a2at.sample.subscribe_incident.client.prompt; import net.openan.a2at.sdk.client.model.PromptGenerationResult; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/prompt/SamplePromptInputAdapter.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptInputAdapter.java similarity index 98% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/prompt/SamplePromptInputAdapter.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptInputAdapter.java index 1b600338..33b59eff 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/prompt/SamplePromptInputAdapter.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptInputAdapter.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.prompt; +package net.openan.a2at.sample.subscribe_incident.client.prompt; import java.util.ArrayList; import java.util.LinkedHashMap; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/registry/SampleRegistryClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/registry/SampleRegistryClient.java similarity index 78% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/registry/SampleRegistryClient.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/registry/SampleRegistryClient.java index b1387b7a..1db9272c 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/registry/SampleRegistryClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/registry/SampleRegistryClient.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.registry; +package net.openan.a2at.sample.subscribe_incident.client.registry; import java.util.Map; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/request/A2AJavaRequestBuilder.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/A2AJavaRequestBuilder.java similarity index 94% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/request/A2AJavaRequestBuilder.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/A2AJavaRequestBuilder.java index dfda8f68..d8f9f2b7 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/request/A2AJavaRequestBuilder.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/A2AJavaRequestBuilder.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.request; +package net.openan.a2at.sample.subscribe_incident.client.request; import java.util.Map; import java.util.UUID; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/request/BuiltA2AJavaRequest.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/BuiltA2AJavaRequest.java similarity index 82% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/request/BuiltA2AJavaRequest.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/BuiltA2AJavaRequest.java index a3f1513d..f35a718a 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/request/BuiltA2AJavaRequest.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/BuiltA2AJavaRequest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.request; +package net.openan.a2at.sample.subscribe_incident.client.request; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; import org.a2aproject.sdk.spec.MessageSendParams; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/A2AJavaClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/A2AJavaClientRuntime.java similarity index 90% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/A2AJavaClientRuntime.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/A2AJavaClientRuntime.java index d3144fc9..89281732 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/A2AJavaClientRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/A2AJavaClientRuntime.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.runtime; +package net.openan.a2at.sample.subscribe_incident.client.runtime; import java.util.Map; import java.util.function.Consumer; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/DefaultSampleClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java similarity index 92% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/DefaultSampleClientRuntime.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java index b7881634..8bc5634d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/DefaultSampleClientRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.runtime; +package net.openan.a2at.sample.subscribe_incident.client.runtime; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -13,14 +13,14 @@ import java.util.List; import java.util.Map; import java.util.function.Consumer; -import net.openan.a2at.sample.client.flow.ClientEventStreamBuffer; -import net.openan.a2at.sample.client.flow.SampleStreamTerminalStateDecider; -import net.openan.a2at.sample.client.prompt.SamplePromptClient; -import net.openan.a2at.sample.client.registry.SampleRegistryClient; -import net.openan.a2at.sample.shared.endpoint.AgentEndpointCache; -import net.openan.a2at.sample.shared.env.SampleEnvironmentPathResolver; -import net.openan.a2at.sample.shared.error.ValueErrorException; -import net.openan.a2at.sample.shared.registry.RegistryAgentCardMapper; +import net.openan.a2at.sample.subscribe_incident.client.flow.ClientEventStreamBuffer; +import net.openan.a2at.sample.subscribe_incident.client.flow.SampleStreamTerminalStateDecider; +import net.openan.a2at.sample.subscribe_incident.client.prompt.SamplePromptClient; +import net.openan.a2at.sample.subscribe_incident.client.registry.SampleRegistryClient; +import net.openan.a2at.sample.subscribe_incident.shared.endpoint.AgentEndpointCache; +import net.openan.a2at.sample.subscribe_incident.shared.env.SampleEnvironmentPathResolver; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.registry.RegistryAgentCardMapper; import net.openan.a2at.sdk.client.A2ATClient; import org.a2aproject.sdk.client.Client; import org.a2aproject.sdk.client.ClientEvent; @@ -61,7 +61,7 @@ public DefaultSampleClientRuntime(Path envPath) { } public static Path resolveDefaultEnvPath() { - Path sampleEnvDir = Path.of("a2a-t-sample","src", "main", "resources", "sample", "client"); + Path sampleEnvDir = Path.of("a2a-t-sample", "src", "main", "resources", "sample", "subscribe_incident", "client"); return SampleEnvironmentPathResolver.resolve(sampleEnvDir, "client.env", "client.env"); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntime.java new file mode 100644 index 00000000..af23a488 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntime.java @@ -0,0 +1,24 @@ +package net.openan.a2at.sample.subscribe_incident.client.runtime; + +import net.openan.a2at.sample.subscribe_incident.client.prompt.SamplePromptClient; +import net.openan.a2at.sample.subscribe_incident.client.registry.SampleRegistryClient; +import net.openan.a2at.sample.subscribe_incident.shared.endpoint.AgentEndpointCache; + +/** + * Runtime bundle required by the client sample main flow. + * + * @since 2026-05 + */ +public interface SampleClientRuntime extends AutoCloseable { + + SampleRegistryClient registryClient(); + + SamplePromptClient promptClient(); + + AgentEndpointCache endpointCache(); + + @Override + void close(); +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/SampleClientRuntimeFactory.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntimeFactory.java similarity index 78% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/SampleClientRuntimeFactory.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntimeFactory.java index c596e7b4..0ad39e73 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/SampleClientRuntimeFactory.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntimeFactory.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.runtime; +package net.openan.a2at.sample.subscribe_incident.client.runtime; import java.nio.file.Path; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/package-info.java similarity index 67% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/package-info.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/package-info.java index 3761095d..af0d34b9 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/package-info.java @@ -3,5 +3,5 @@ * * @since 2026-05 */ -package net.openan.a2at.sample; +package net.openan.a2at.sample.subscribe_incident; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/ServerSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java similarity index 76% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/ServerSampleMain.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java index 5f3374e2..e5621cee 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/ServerSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java @@ -1,18 +1,18 @@ -package net.openan.a2at.sample.server; +package net.openan.a2at.sample.subscribe_incident.server; import java.nio.file.Path; import java.util.Map; import java.util.function.Consumer; -import net.openan.a2at.sample.server.agentcard.ServerSampleAgentCardBuilder; -import net.openan.a2at.sample.server.flow.PromptComplianceChecker; -import net.openan.a2at.sample.server.http.EmbeddedA2AHttpServer; -import net.openan.a2at.sample.server.runtime.A2AJavaServerRuntime; -import net.openan.a2at.sample.server.runtime.DefaultSampleServerRuntime; -import net.openan.a2at.sample.server.runtime.SampleServerRuntime; -import net.openan.a2at.sample.server.runtime.SampleServerRuntimeFactory; -import net.openan.a2at.sample.server.runtime.ServerBind; -import net.openan.a2at.sample.server.runtime.ServerBootstrapResult; -import net.openan.a2at.sample.shared.registry.RegistryAgentCardMapper; +import net.openan.a2at.sample.subscribe_incident.server.agentcard.ServerSampleAgentCardBuilder; +import net.openan.a2at.sample.subscribe_incident.server.flow.PromptComplianceChecker; +import net.openan.a2at.sample.subscribe_incident.server.http.EmbeddedA2AHttpServer; +import net.openan.a2at.sample.subscribe_incident.server.runtime.A2AJavaServerRuntime; +import net.openan.a2at.sample.subscribe_incident.server.runtime.DefaultSampleServerRuntime; +import net.openan.a2at.sample.subscribe_incident.server.runtime.SampleServerRuntime; +import net.openan.a2at.sample.subscribe_incident.server.runtime.SampleServerRuntimeFactory; +import net.openan.a2at.sample.subscribe_incident.server.runtime.ServerBind; +import net.openan.a2at.sample.subscribe_incident.server.runtime.ServerBootstrapResult; +import net.openan.a2at.sample.subscribe_incident.shared.registry.RegistryAgentCardMapper; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; /** diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/ServerSampleMarker.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMarker.java similarity index 73% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/ServerSampleMarker.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMarker.java index 108b7fe5..49997e33 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/ServerSampleMarker.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMarker.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server; +package net.openan.a2at.sample.subscribe_incident.server; /** * Marker type for server sample entrypoint code. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/agentcard/ServerSampleAgentCardBuilder.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/agentcard/ServerSampleAgentCardBuilder.java similarity index 97% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/agentcard/ServerSampleAgentCardBuilder.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/agentcard/ServerSampleAgentCardBuilder.java index 00e9a3d5..ed7f4e41 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/agentcard/ServerSampleAgentCardBuilder.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/agentcard/ServerSampleAgentCardBuilder.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.agentcard; +package net.openan.a2at.sample.subscribe_incident.server.agentcard; import java.util.List; import java.util.Map; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/PromptComplianceChecker.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/PromptComplianceChecker.java similarity index 83% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/PromptComplianceChecker.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/PromptComplianceChecker.java index f322ed38..7db51f8e 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/PromptComplianceChecker.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/PromptComplianceChecker.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.flow; +package net.openan.a2at.sample.subscribe_incident.server.flow; import net.openan.a2at.sdk.server.model.PromptComplianceResult; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerFlowInterruptedException.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerFlowInterruptedException.java similarity index 81% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerFlowInterruptedException.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerFlowInterruptedException.java index 50f2a55e..f48c3132 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerFlowInterruptedException.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerFlowInterruptedException.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.flow; +package net.openan.a2at.sample.subscribe_incident.server.flow; /** * Internal control exception used by tests to stop the recurring artifact loop. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleAgentExecutor.java similarity index 96% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleAgentExecutor.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleAgentExecutor.java index e2faa2c4..3b9495eb 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleAgentExecutor.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.flow; +package net.openan.a2at.sample.subscribe_incident.server.flow; import java.util.Map; import java.util.function.Consumer; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleFlow.java similarity index 95% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleFlow.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleFlow.java index b2c2e14b..257298ce 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleFlow.java @@ -1,11 +1,11 @@ -package net.openan.a2at.sample.server.flow; +package net.openan.a2at.sample.subscribe_incident.server.flow; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.function.Consumer; -import net.openan.a2at.sample.shared.logging.SampleLoggingFormatter; -import net.openan.a2at.sample.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.logging.SampleLoggingFormatter; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; import net.openan.a2at.sdk.server.model.PromptComplianceFailure; import net.openan.a2at.sdk.server.model.PromptComplianceResult; import org.a2aproject.sdk.server.ServerCallContext; @@ -27,7 +27,7 @@ public final class ServerSampleFlow { static final String NOTIFICATION_T_EXTENSION_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; - private static final long ARTIFACT_SEND_INTERVAL_SECONDS = 60L; + private static final long ARTIFACT_SEND_INTERVAL_SECONDS = 10L; private static final String SUBMITTED_MESSAGE = "Subscription accepted, starting Incident reporting task"; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSleepController.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSleepController.java similarity index 77% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSleepController.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSleepController.java index 50fbc0c4..6a9eb21b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSleepController.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSleepController.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.flow; +package net.openan.a2at.sample.subscribe_incident.server.flow; /** * Controllable sleep abstraction used by the recurring mock stream loop. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/http/EmbeddedA2AHttpServer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/EmbeddedA2AHttpServer.java similarity index 99% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/http/EmbeddedA2AHttpServer.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/EmbeddedA2AHttpServer.java index 00753cec..62db2a64 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/http/EmbeddedA2AHttpServer.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/EmbeddedA2AHttpServer.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.http; +package net.openan.a2at.sample.subscribe_incident.server.http; import com.google.protobuf.util.JsonFormat; import com.sun.net.httpserver.HttpExchange; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/http/ServerSampleApplication.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/ServerSampleApplication.java similarity index 62% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/http/ServerSampleApplication.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/ServerSampleApplication.java index 6599f92e..e59b7bc6 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/http/ServerSampleApplication.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/ServerSampleApplication.java @@ -1,7 +1,7 @@ -package net.openan.a2at.sample.server.http; +package net.openan.a2at.sample.subscribe_incident.server.http; import java.util.Map; -import net.openan.a2at.sample.server.flow.PromptComplianceChecker; +import net.openan.a2at.sample.subscribe_incident.server.flow.PromptComplianceChecker; /** * Minimal sample-owned application object for the publishable server sample. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/package-info.java similarity index 67% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/package-info.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/package-info.java index 96a39316..f1547c5d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/package-info.java @@ -3,5 +3,5 @@ * * @since 2026-05 */ -package net.openan.a2at.sample.server; +package net.openan.a2at.sample.subscribe_incident.server; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryClient.java similarity index 97% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryClient.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryClient.java index af31b7a7..7737a5ce 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryClient.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.registry; +package net.openan.a2at.sample.subscribe_incident.server.registry; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; @@ -16,7 +16,7 @@ import java.util.List; import java.util.Map; import java.util.function.Consumer; -import net.openan.a2at.sample.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; /** * Registry registration support for the server sample. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryResponse.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryResponse.java similarity index 76% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryResponse.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryResponse.java index 6c08f03b..f37e1eae 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryResponse.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryResponse.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.registry; +package net.openan.a2at.sample.subscribe_incident.server.registry; import java.util.Map; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryTransport.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryTransport.java similarity index 79% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryTransport.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryTransport.java index 56ee45c9..95e1e6c5 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryTransport.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryTransport.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.registry; +package net.openan.a2at.sample.subscribe_incident.server.registry; import java.util.Map; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/A2AJavaServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/A2AJavaServerRuntime.java similarity index 75% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/A2AJavaServerRuntime.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/A2AJavaServerRuntime.java index 600996e8..095e7ac9 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/A2AJavaServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/A2AJavaServerRuntime.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.runtime; +package net.openan.a2at.sample.subscribe_incident.server.runtime; /** * Transitional seam for assembling the real a2a-java REST server runtime. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/DefaultSampleServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java similarity index 92% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/DefaultSampleServerRuntime.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java index a27d4690..b7af1e56 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/DefaultSampleServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.runtime; +package net.openan.a2at.sample.subscribe_incident.server.runtime; import java.nio.file.Path; import java.util.List; @@ -11,14 +11,14 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; -import net.openan.a2at.sample.server.agentcard.ServerSampleAgentCardBuilder; -import net.openan.a2at.sample.server.flow.PromptComplianceChecker; -import net.openan.a2at.sample.server.flow.ServerFlowInterruptedException; -import net.openan.a2at.sample.server.flow.ServerSampleAgentExecutor; -import net.openan.a2at.sample.server.registry.ServerRegistryClient; -import net.openan.a2at.sample.shared.env.SampleEnvironmentPathResolver; -import net.openan.a2at.sample.shared.error.ValueErrorException; -import net.openan.a2at.sample.shared.registry.RegistryAgentCardMapper; +import net.openan.a2at.sample.subscribe_incident.server.agentcard.ServerSampleAgentCardBuilder; +import net.openan.a2at.sample.subscribe_incident.server.flow.PromptComplianceChecker; +import net.openan.a2at.sample.subscribe_incident.server.flow.ServerFlowInterruptedException; +import net.openan.a2at.sample.subscribe_incident.server.flow.ServerSampleAgentExecutor; +import net.openan.a2at.sample.subscribe_incident.server.registry.ServerRegistryClient; +import net.openan.a2at.sample.subscribe_incident.shared.env.SampleEnvironmentPathResolver; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.registry.RegistryAgentCardMapper; import net.openan.a2at.sdk.server.A2ATServer; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.events.InMemoryQueueManager; @@ -128,7 +128,7 @@ public Map registerAgentCard(Map registrationPay } public static Path resolveDefaultEnvPath() { - Path sampleEnvDir = Path.of("a2a-t-sample", "src", "main", "resources", "sample", "server"); + Path sampleEnvDir = Path.of("a2a-t-sample", "src", "main", "resources", "sample", "subscribe_incident", "server"); return SampleEnvironmentPathResolver.resolve(sampleEnvDir, "server.env", "server.env"); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/SampleServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntime.java similarity index 78% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/SampleServerRuntime.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntime.java index e1a9fa7c..988bc58b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/SampleServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntime.java @@ -1,8 +1,8 @@ -package net.openan.a2at.sample.server.runtime; +package net.openan.a2at.sample.subscribe_incident.server.runtime; import java.nio.file.Path; import java.util.Map; -import net.openan.a2at.sample.server.flow.PromptComplianceChecker; +import net.openan.a2at.sample.subscribe_incident.server.flow.PromptComplianceChecker; /** * Runtime assembly abstraction for the server sample bootstrap flow. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/SampleServerRuntimeFactory.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntimeFactory.java similarity index 77% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/SampleServerRuntimeFactory.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntimeFactory.java index c360c409..d9b0a839 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/SampleServerRuntimeFactory.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntimeFactory.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.runtime; +package net.openan.a2at.sample.subscribe_incident.server.runtime; import java.nio.file.Path; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/ServerBind.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBind.java similarity index 67% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/ServerBind.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBind.java index fb419eac..6c50a872 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/ServerBind.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBind.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.runtime; +package net.openan.a2at.sample.subscribe_incident.server.runtime; /** * Resolved host/port bind pair for the server sample. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/ServerBootstrapResult.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBootstrapResult.java similarity index 81% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/ServerBootstrapResult.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBootstrapResult.java index 838f1b85..bd7ab8a8 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/ServerBootstrapResult.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBootstrapResult.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.runtime; +package net.openan.a2at.sample.subscribe_incident.server.runtime; import java.util.Map; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/SharedSampleMarker.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/SharedSampleMarker.java similarity index 73% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/SharedSampleMarker.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/SharedSampleMarker.java index c87a2693..0018da2f 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/SharedSampleMarker.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/SharedSampleMarker.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.shared; +package net.openan.a2at.sample.subscribe_incident.shared; /** * Marker type for shared sample support code. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/AgentEndpointCache.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointCache.java similarity index 76% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/AgentEndpointCache.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointCache.java index 470bc9ae..baf3a07c 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/AgentEndpointCache.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointCache.java @@ -1,6 +1,6 @@ -package net.openan.a2at.sample.shared.endpoint; +package net.openan.a2at.sample.subscribe_incident.shared.endpoint; -import net.openan.a2at.sample.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; /** * Keeps the current resolved endpoint for the active sample run. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/AgentEndpointResolver.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointResolver.java similarity index 92% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/AgentEndpointResolver.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointResolver.java index 1b7e8bde..ddae2f03 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/AgentEndpointResolver.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointResolver.java @@ -1,9 +1,9 @@ -package net.openan.a2at.sample.shared.endpoint; +package net.openan.a2at.sample.subscribe_incident.shared.endpoint; import java.net.URI; import java.util.List; import java.util.Map; -import net.openan.a2at.sample.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; /** * Resolves the preferred HTTP endpoint from a registry AgentCard payload. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/ResolvedAgentEndpoint.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/ResolvedAgentEndpoint.java similarity index 77% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/ResolvedAgentEndpoint.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/ResolvedAgentEndpoint.java index f308bc7b..330a7119 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/ResolvedAgentEndpoint.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/ResolvedAgentEndpoint.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.shared.endpoint; +package net.openan.a2at.sample.subscribe_incident.shared.endpoint; /** * Resolved A2A endpoint details extracted from a registry AgentCard payload. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/env/SampleEnvironmentPathResolver.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/env/SampleEnvironmentPathResolver.java similarity index 91% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/env/SampleEnvironmentPathResolver.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/env/SampleEnvironmentPathResolver.java index 249891ee..40bafe53 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/env/SampleEnvironmentPathResolver.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/env/SampleEnvironmentPathResolver.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.shared.env; +package net.openan.a2at.sample.subscribe_incident.shared.env; import java.nio.file.Files; import java.nio.file.Path; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/error/ValueErrorException.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/error/ValueErrorException.java similarity index 85% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/error/ValueErrorException.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/error/ValueErrorException.java index 0d52893a..5dabb9ea 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/error/ValueErrorException.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/error/ValueErrorException.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.shared.error; +package net.openan.a2at.sample.subscribe_incident.shared.error; /** * Stable runtime exception used by the sample support layer for invalid sample inputs. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/logging/SampleLoggingFormatter.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java similarity index 98% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/logging/SampleLoggingFormatter.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java index c8cf7865..4742700c 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/logging/SampleLoggingFormatter.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.shared.logging; +package net.openan.a2at.sample.subscribe_incident.shared.logging; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/package-info.java similarity index 61% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/package-info.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/package-info.java index e4ca4345..3f21f701 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/package-info.java @@ -3,5 +3,5 @@ * * @since 2026-05 */ -package net.openan.a2at.sample.shared; +package net.openan.a2at.sample.subscribe_incident.shared; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/registry/RegistryAgentCardMapper.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/registry/RegistryAgentCardMapper.java similarity index 98% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/registry/RegistryAgentCardMapper.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/registry/RegistryAgentCardMapper.java index 252e8848..48654529 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/registry/RegistryAgentCardMapper.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/registry/RegistryAgentCardMapper.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.shared.registry; +package net.openan.a2at.sample.subscribe_incident.shared.registry; import java.util.List; import java.util.Map; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/scenario/SampleScenarioLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/scenario/SampleScenarioLoader.java similarity index 87% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/scenario/SampleScenarioLoader.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/scenario/SampleScenarioLoader.java index 85491ba7..8b0a724f 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/scenario/SampleScenarioLoader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/scenario/SampleScenarioLoader.java @@ -1,11 +1,11 @@ -package net.openan.a2at.sample.shared.scenario; +package net.openan.a2at.sample.subscribe_incident.shared.scenario; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.InputStream; import java.util.Map; -import net.openan.a2at.sample.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; /** * Loads static sample scenarios from classpath resources. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/stream/SampleStreamEventNormalizer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/stream/SampleStreamEventNormalizer.java similarity index 90% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/stream/SampleStreamEventNormalizer.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/stream/SampleStreamEventNormalizer.java index bb82731f..fb6b3590 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/stream/SampleStreamEventNormalizer.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/stream/SampleStreamEventNormalizer.java @@ -1,8 +1,8 @@ -package net.openan.a2at.sample.shared.stream; +package net.openan.a2at.sample.subscribe_incident.shared.stream; import java.util.List; import java.util.Map; -import net.openan.a2at.sample.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; /** * Normalizes raw stream payload fragments into stable sample event shapes. diff --git a/a2a-t-sample/src/main/resources/sample/client/scenario.json b/a2a-t-sample/src/main/resources/sample/subscribe_incident/client/scenario.json similarity index 100% rename from a2a-t-sample/src/main/resources/sample/client/scenario.json rename to a2a-t-sample/src/main/resources/sample/subscribe_incident/client/scenario.json diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/server/runtime/DefaultSampleServerRuntimeTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntimeTest.java similarity index 96% rename from a2a-t-sample/src/test/java/net/openan/a2at/sample/server/runtime/DefaultSampleServerRuntimeTest.java rename to a2a-t-sample/src/test/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntimeTest.java index b1d35872..cc6f2960 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/server/runtime/DefaultSampleServerRuntimeTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntimeTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.runtime; +package net.openan.a2at.sample.subscribe_incident.server.runtime; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 56238cb1..ac828787 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -185,7 +185,7 @@ Recommended Client Agent flow: 5. Send an A2A request based on the interface address in the AgentCard. 6. If negotiation context is received, continue the interaction through `receiveNegotiation` and `continueNegotiation`. -`ClientSampleFlow` in `a2a-t-sample` demonstrates the full flow: reading scenarios, querying AgentCard directly from the sample server or from registry-center, generating prompt, constructing A2A requests, and handling streaming events. +`ClientSampleFlow` in the `subscribe_incident` case of `a2a-t-sample` demonstrates the full flow: reading scenarios, querying AgentCard directly from the sample server or from registry-center, generating prompt, constructing A2A requests, and handling streaming events. ### 1.7.2 Server Agent @@ -198,7 +198,7 @@ Recommended Server Agent flow: 5. After validation passes, execute business logic. 6. When validation fails or information is insufficient, return supplementary information requirements through the negotiation interface. -`ServerSampleMain` and `ServerSampleFlow` in `a2a-t-sample` demonstrate AgentCard exposure at the HTTP root path, optional registry-center registration, HTTP service startup, prompt extraction, validation, and task event pushing. +`ServerSampleMain` and `ServerSampleFlow` in the `subscribe_incident` case of `a2a-t-sample` demonstrate AgentCard exposure at the HTTP root path, optional registry-center registration, HTTP service startup, prompt extraction, validation, and task event pushing. ## 1.8 Prompt Resource Extension ### 1.8.1 Resource Source diff --git a/docs/en/user_guide.md b/docs/en/user_guide.md index 4f8bb2c9..f9299436 100644 --- a/docs/en/user_guide.md +++ b/docs/en/user_guide.md @@ -140,7 +140,7 @@ java @a2a-t-sample/target/client.javaargs.txt The client will: -1. Read sample input from `sample/client/scenario.json`. +1. Read sample input from `sample/subscribe_incident/client/scenario.json`. 2. Query the target AgentCard directly from the sample server root path `GET /` when `A2AT_SAMPLE_HOST` and `A2AT_SAMPLE_PORT` are configured; otherwise query it from registry-center. 3. Generate processed task prompt using `A2ATClient`. 4. Construct an A2A HTTP+JSON/REST request. diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index bb3fa1f6..1fe57901 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -185,7 +185,7 @@ Map payload = client.startNegotiation( 5. 根据 AgentCard 中的接口地址发起 A2A 请求。 6. 如果收到协商上下文,通过 `receiveNegotiation` 和 `continueNegotiation` 继续交互。 -`a2a-t-sample` 中 `ClientSampleFlow` 展示了完整流程:读取场景、查询 AgentCard、生成 prompt、构造 A2A 请求并处理流式事件。 +`a2a-t-sample` 中 `subscribe_incident` 用例的 `ClientSampleFlow` 展示了完整流程:读取场景、查询 AgentCard、生成 prompt、构造 A2A 请求并处理流式事件。 ### 1.7.2 服务端 Agent @@ -198,7 +198,7 @@ Map payload = client.startNegotiation( 5. 校验通过后执行业务逻辑。 6. 校验失败或信息不足时,通过协商接口返回补充信息要求。 -`a2a-t-sample` 中 `ServerSampleMain` 和 `ServerSampleFlow` 展示了 AgentCard 注册、HTTP 服务启动、prompt 提取、校验和任务事件推送。 +`a2a-t-sample` 中 `subscribe_incident` 用例的 `ServerSampleMain` 和 `ServerSampleFlow` 展示了 AgentCard 注册、HTTP 服务启动、prompt 提取、校验和任务事件推送。 ## 1.8 提示词资源扩展 ### 1.8.1 文件扩展 diff --git "a/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" "b/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" index 57b221b0..c9b3ebf5 100644 --- "a/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" +++ "b/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" @@ -138,7 +138,7 @@ java @a2a-t-sample/target/client.javaargs.txt 客户端会: -1. 从 `sample/client/scenario.json` 读取示例输入。 +1. 从 `sample/subscribe_incident/client/scenario.json` 读取示例输入。 2. 根据配置查询注册中心中的目标 AgentCard。 3. 使用 `A2ATClient` 生成 processed task prompt。 4. 构造 A2A HTTP+JSON/REST 请求。 From a6d726cfe6cfb7ae5f6498f1d175b24171ff978b Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Wed, 19 Aug 2026 16:19:24 +0800 Subject: [PATCH 018/295] feat: refresh the energy-saving template and add the negotiation template Signed-off-by: zhangwenjun-huawei --- ...ltClasspathClientSlotSchemaLoaderTest.java | 2 +- .../LocalFileClientSlotSchemaLoaderTest.java | 10 +-- ...alFileClientScenarioCatalogLoaderTest.java | 2 +- .../slots/energy_saving/en-US/slot.json | 40 +++++++--- .../slots/energy_saving/zh-CN/slot.json | 40 +++++++--- .../templates/energy_saving/en-US/template.md | 73 ++++++++++++------- .../templates/energy_saving/zh-CN/template.md | 73 ++++++++++++------- .../accept-reject/en-US/template.md | 29 ++++++++ .../accept-reject/zh-CN/template.md | 29 ++++++++ .../propose/en-US/template.md | 55 ++++++++++++++ .../propose/zh-CN/template.md | 55 ++++++++++++++ .../accept-reject/en-US/template.md | 27 +++++++ .../accept-reject/zh-CN/template.md | 27 +++++++ .../propose/en-US/template.md | 19 +++++ .../propose/zh-CN/template.md | 19 +++++ .../accept-reject/en-US/template.md | 22 ++++++ .../accept-reject/zh-CN/template.md | 22 ++++++ .../propose/en-US/template.md | 54 ++++++++++++++ .../propose/zh-CN/template.md | 55 ++++++++++++++ ...calFileServerPromptTemplateLoaderTest.java | 2 +- tools/template_lint.py | 3 +- 21 files changed, 573 insertions(+), 85 deletions(-) create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/accept-reject/en-US/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/accept-reject/zh-CN/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/propose/en-US/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/propose/zh-CN/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/accept-reject/en-US/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/accept-reject/zh-CN/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/propose/en-US/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/propose/zh-CN/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/accept-reject/en-US/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/accept-reject/zh-CN/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/propose/en-US/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/propose/zh-CN/template.md diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoaderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoaderTest.java index 8ef429f4..85fd59f3 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoaderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoaderTest.java @@ -21,7 +21,7 @@ void loadSlotSchemaReadsJsonSchemaFromClasspathPromptResources() { PromptSlotSchema schema = loader.loadSlotSchema("energy_saving", "zh-CN"); assertEquals("energy_saving", schema.scenarioCode()); - assertEquals(4, schema.slotDefinitions().size()); + assertEquals(6, schema.slotDefinitions().size()); assertFalse(schema.slotDefinitions().get(0).name().isBlank()); assertFalse(schema.slotDefinitions().get(0).required()); assertEquals("string", schema.slotDefinitions().get(0).jsonType()); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoaderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoaderTest.java index c321eb5e..7528c305 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoaderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoaderTest.java @@ -19,15 +19,15 @@ void loadSlotSchemaDeserializesJsonSchemaModelFromLocalFile() { PromptSlotSchema schema = loader.loadSlotSchema("energy_saving", "zh-CN"); assertEquals("energy_saving", schema.scenarioCode()); - assertEquals(4, schema.slotDefinitions().size()); + assertEquals(6, schema.slotDefinitions().size()); - PromptSlotDefinition taskObject = schema.slotDefinitions().get(1); + PromptSlotDefinition taskObject = schema.slotDefinitions().get(2); assertFalse(taskObject.name().isBlank()); assertEquals("string", taskObject.jsonType()); assertTrue(taskObject.description().length() > 10); - PromptSlotDefinition constraints = schema.slotDefinitions().get(3); - assertFalse(constraints.name().isBlank()); - assertTrue(constraints.description().length() > 10); + PromptSlotDefinition taskContext = schema.slotDefinitions().get(4); + assertFalse(taskContext.name().isBlank()); + assertTrue(taskContext.description().length() > 10); } } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java index e51094ca..60b5dd7d 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java @@ -16,7 +16,7 @@ void loadDeserializesScenarioCatalogFromLocalFile() { List scenarios = loader.load("zh-CN"); - assertEquals(4, scenarios.size()); + assertEquals(3, scenarios.size()); assertEquals("subscribe_incident", scenarios.get(0).scenarioCode()); assertEquals("energy_saving", scenarios.get(1).scenarioCode()); } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/en-US/slot.json index ce3ad712..fae9ae17 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/en-US/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/en-US/slot.json @@ -3,37 +3,53 @@ "type": "object", "additionalProperties": false, "properties": { - "task_target": { + "operation_type": { + "type": "string", + "description": "Provide the operation type. Allowed values: Create, Modify.", + "examples": [ + "Create" + ], + "x-a2at-value-constraint": "Allowed values: Create, Modify" + }, + "task_description": { "type": "string", - "description": "Define the quantitative or qualitative goals the task must achieve, such as energy efficiency goals and rate guarantee goals.", + "description": "Dynamically provide the task description based on the operation type (Create/Modify).", "examples": [ - "The goal is to achieve a 30% energy consumption reduction while ensuring the rate guarantee is no less than 50Mbps." + "Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in ." ], - "x-a2at-value-constraint": "Energy consumption goal: the gain target of energy saving. The target value format must be a percentage, such as the percentage by which total power consumption is reduced. Rate guarantee goal: the minimum downlink throughput target that must be guaranteed." + "x-a2at-value-constraint": "When the type is [Create]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in . When the type is [Modify]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in ." }, "task_object": { "type": "string", - "description": "Explicitly specify the energy saving area information, a geographic or logical area (e.g., district-level administrative unit) or longitude/latitude coordinates.", + "description": "Explicitly specify the energy saving coverage scope information, a geographic or logical area (e.g., district-level administrative unit). The area information is a required parameter.", "examples": [ "The area is the Songshanhu Administration Committee." ], - "x-a2at-value-constraint": "Geographic/logical area name. The area information (important) must be a concrete physical address." + "x-a2at-value-constraint": "Area name. The area information (important) must be a concrete physical address, for example a real physical community such as \"Songshanhu Administration Committee\", rather than a description without a specific physical location such as \"all communities\", \"indoor communities\", or \"outdoor communities\"." + }, + "task_target": { + "type": "string", + "description": "Define the quantitative or qualitative goals the task must achieve. Quantitative and qualitative goals cannot be selected at the same time, and the default is a qualitative goal.", + "examples": [ + "Energy consumption goal: 30%" + ], + "x-a2at-value-constraint": "1. Quantitative goal - Energy consumption goal: the gain target of energy saving. The target value format must be a percentage, such as the percentage by which total power consumption is reduced. 2. Qualitative goal - Maximize energy saving under the conditions defined in the task context." }, "task_context": { "type": "string", "description": "Provide the background and context information for task execution.", "examples": [ - "" + "Cell RAT for energy saving: NR; Energy saving time range: start time \"08:00:00\", end time \"12:00:00\"; Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {18:00~00:00,20Mbps}." ], - "x-a2at-value-constraint": "" + "x-a2at-value-constraint": "1. Cell radio access technology for energy saving (LTE/NR/all) 2. The time range for executing energy saving, recurring daily; if omitted, energy saving is performed for the full period and experience-lossless energy saving is adopted by default, and the rate goal is guaranteed only within the energy saving enabled time range 3. Carrier frequencies that can be turned off during energy saving 4. Rate guarantee goal: the minimum downlink throughput target that must be guaranteed and its effective time period (area-level supports time-segmented/full-period; site-level requires site list, time-segmented rate guarantee goal, and optional background event and duration)" }, - "constraints": { + "expected_output": { "type": "string", - "description": "List the rules, restrictions, and boundary conditions that must be observed during task execution. Constraints apply to all scenarios within the area by default; constraints applicable only to specified scenarios within the area should be stated separately.", + "description": "When the task object is a geographic/logical area, output the intent report for the network energy saving task.", "examples": [ - "Adopt an NR cell energy saving plan, and set the 1650 frequency as the base layer for carrier shutdown. For the energy saving strategy, the system will take an aggressive energy saving mode, provided that experience-lossless is ensured, i.e., performed without affecting user perception. This energy saving task recurs daily, with the effective time range from 08:00:00 to 12:00:00 each day. In addition, the plan includes two scenario-specific constraints: in the business district scenario, the 1300 frequency is forbidden to serve as the capacity layer; in the black forest scenario, enabling sleep is forbidden between 0:00 and 6:00." + "" ], - "x-a2at-value-constraint": "1. Cell type to apply energy saving (indoor/outdoor/all) 2. Cell radio access technology for energy saving (LTE/NR/all) 3. Frequency requirements for the base layer and capacity layer, frequencies must be expressed as downlink frequency numbers 4. Whether the energy saving aggressiveness is aggressive or conservative 5. Whether experience-lossless energy saving is required 6. The time range for executing energy saving, recurring daily 7. Constraints applicable to specified scenarios within the area (e.g., railway/black forest/business district/residential area) 8. Whether turning off carrier frequencies is allowed during energy saving" + "x-a2at-value-constraint": "Intent Report Basic Information: unique identifier of the network energy saving report, unique identifier of the energy saving task, execution result of the overall intent (status, details, reason for not achieving). Intent Report Detailed Information: execution result of specific KPIs (status, details, reason for not achieving)." } }, "required": [] diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/zh-CN/slot.json index 2cd122a0..81464e73 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/zh-CN/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/zh-CN/slot.json @@ -3,37 +3,53 @@ "type": "object", "additionalProperties": false, "properties": { - "任务目标": { + "操作类型": { + "type": "string", + "description": "请提供操作类型,取值范围:创建、修改。", + "examples": [ + "创建" + ], + "x-a2at-value-constraint": "取值范围:创建、修改" + }, + "任务描述": { "type": "string", - "description": "定义任务需达成的量化或定性目标,如能效目标和速率保障目标。", + "description": "根据操作类型(创建/修改),动态提供任务描述信息。", "examples": [ - "目标是实现30%的能耗降低,同时确保速率保障不低于50Mbps。" + "基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。" ], - "x-a2at-value-constraint": "能耗目标:节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比。速率保障目标:需要保障的下行吞吐率目标最小值。" + "x-a2at-value-constraint": "类型为【创建】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。类型为【修改】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标。" }, "任务对象": { "type": "string", - "description": "明确指定节能区域信息,地理或逻辑区域(如:区级行政单位)或经纬度坐标。", + "description": "明确指定节能覆盖范围信息,地理或逻辑区域(如 区级行政单位)。区域信息为必选参数。", "examples": [ "区域为松山湖管委会。" ], - "x-a2at-value-constraint": "地理/逻辑的区域名称。区域信息(重要),是具体物理地址," + "x-a2at-value-constraint": "区域名称。区域信息(重要),是具体物理地址,例如“松山湖管委会”这种物理真实小区,而不是“所有小区”、“室内小区”、“室外小区”这种无具体物理位置的描述信息。" + }, + "任务目标": { + "type": "string", + "description": "定义任务需达成的量化或定性目标,量化目标和定性目标不可同时选择,默认为定性目标。", + "examples": [ + "能耗目标:30%" + ], + "x-a2at-value-constraint": "1. 量化目标 能耗目标:节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比。 2. 定性目标 在满足任务上下文的条件下进行最大化节能。" }, "任务上下文": { "type": "string", "description": "提供任务执行的背景和上下文信息。", "examples": [ - "" + "节能的小区制式:NR;需要节能的时间范围:起始时间“08:00:00”,结束时间“12:00:00”;分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{18:00~00:00,20Mbps}。" ], - "x-a2at-value-constraint": "" + "x-a2at-value-constraint": "1. 节能的小区制式(LTE/NR/全部) 2. 需要执行节能的时间段区间范围,以天为周期循环执行,缺省则进行全时段节能,默认采取用户体验无损方式节能,仅在节能开启时间范围内保障速率目标 3. 可在节能时关闭载波的频点 4. 速率保障目标:需要保障的下行吞吐率目标最小值及生效时间段(区域级支持分时段/全时段;站点级需提供站点列表、分时段速率保障目标、可选背景事件与持续时间)" }, - "约束条件": { + "预期输出": { "type": "string", - "description": "列出任务执行中必须遵守的规则、限制和边界条件,约束条件默认对区域内所有场景均适用,针对区域内指定场景适用的约束条件,请单独说明。", + "description": "若任务对象为地理/逻辑区域时,输出网络节能任务的意图报告。", "examples": [ - "采用NR制式的小区节能方案,并将1650频点设为执行载波关断的基础层。在节能策略上,系统将采取激进节能模式,但前提是必须确保体验无损,即在不影响用户感知的情况下进行。该节能任务以天为周期循环执行,具体的生效时间段为每日08:00:00至12:00:00。此外,方案中还包含两项特定场景的约束:在商业区场景下,禁止将1300频点作为容量层使用;而在黑森林场景下,则禁止在凌晨0点至6点期间开启修秒功能。" + "" ], - "x-a2at-value-constraint": "1. 需要节能的小区类型(室内/室外/所有)2. 节能的小区制式(LTE/NR/全部)3. 对基础层和容量层的频点要求,频点的表述方式必须为下行频点号 4. 节能的激进程度是激进节能还是保守节能 5. 是否要求体验无损节能 6. 需要执行节能的时间段区间范围,以天为周期循环执行 7. 针对区域内制定场景适用的约束条件(如:铁路/黑森林/商务区/居民区) 8. 是否允许在节能时关闭载波的频点" + "x-a2at-value-constraint": "意图报告基本信息:网络节能报告唯一标识、节能任务唯一标识、总体意图执行结果(状态、详情、未达成原因)。意图报告详细信息:具体KPI执行结果(状态、详情、未达成原因)。" } }, "required": [] diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/en-US/template.md index 03c2803e..c1f55b45 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/en-US/template.md @@ -1,43 +1,64 @@ +## Operation Type +{{operation_type}} (required) +Requirement: +Please provide the operation type. Allowed values: Create, Modify + ## Task Type -Wireless energy efficiency optimization +Wireless network energy saving ## Task Description -Based on , , and , output an energy efficiency optimization plan that achieves the energy efficiency optimization goal defined in , and return the energy efficiency optimization result in the structure defined in . +{{task_description}} (required) +Requirement: +Please dynamically provide the task description based on (Create/Modify): -## Task Target -{{task_target}} (optional) -Requirement: Define the quantitative or qualitative goals the task must achieve, such as energy efficiency goals and rate guarantee goals. All are optional parameters. -Specific goal description for task-level energy saving: -1. Energy consumption goal: the gain target of energy saving. The target value format must be a percentage, such as the percentage by which total power consumption is reduced. Example: Energy consumption goal: 30% -2. Rate guarantee goal: the minimum downlink throughput target that must be guaranteed. Example: Rate guarantee goal: 50Mbps/s +When the type is [Create]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in . +When the type is [Modify]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in . ## Task Object {{task_object}} (required) -Requirement: Explicitly specify the energy saving area information, a geographic or logical area (e.g., district-level administrative unit) or longitude/latitude coordinates. The area information is a required parameter. +Requirement: Explicitly specify the energy saving coverage scope information, a geographic or logical area (e.g., district-level administrative unit). The area information is a required parameter. + The following are specific descriptions: -1. Geographic/logical area name. Example: Area: "Songshanhu Administration Committee", coordinate (112.0, 20.6) area -2. **Important**: The area information must be a concrete physical address, for example a real physical community such as "Songshanhu Administration Committee", rather than a description without a specific physical location such as "all communities", "indoor communities", or "outdoor communities". +1. Area name. Example: Area: "Songshanhu Administration Committee" + **Important**: The area information must be a concrete physical address, for example a real physical community such as "Songshanhu Administration Committee", rather than a description without a specific physical location such as "all communities", "indoor communities", or "outdoor communities". + +## Task Target +{{task_target}} (optional) +Requirement: Define the quantitative or qualitative goals the task must achieve. Quantitative and qualitative goals cannot be selected at the same time, and the default is a qualitative goal. +Specific goal description for task-level energy saving: +1. Quantitative goal - Energy consumption goal: the gain target of energy saving. The target value format must be a percentage, such as the percentage by which total power consumption is reduced. Example: Energy consumption goal: 30% +2. Qualitative goal - Maximize energy saving under the conditions defined in the task context ## Task Context {{task_context}} (optional) -Requirement: Provide the background and context information for task execution. Optional parameter. - -## Constraints -{{constraints}} (optional) -Requirement: List the rules, restrictions, and boundary conditions that must be observed during task execution. Constraints apply to all scenarios within the area by default; constraints applicable only to specified scenarios within the area should be stated separately. All are optional parameters. -1. Cell type to apply energy saving (indoor/outdoor/all). Example: Cell type for energy saving: outdoor -2. Cell radio access technology for energy saving (LTE/NR/all). Example: Cell RAT for energy saving: NR -3. Frequency requirements for the base layer and capacity layer. Frequencies must be expressed as downlink frequency numbers. Example: 1650 frequency as the base layer for carrier shutdown -4. Whether the energy saving aggressiveness is aggressive or conservative. Example: Energy saving aggressiveness: aggressive -5. Whether experience-lossless energy saving is required. Example: Experience-lossless energy saving required: yes -6. The time range for executing energy saving, recurring daily. Example: Energy saving time range: start time "08:00:00", end time "12:00:00" -7. Constraints applicable to specified scenarios within the area (e.g., railway/black forest/business district/residential area). Example: In the business district scenario, the 1300 frequency is forbidden to serve as the capacity layer; in the black forest scenario, enabling sleep is forbidden between 0:00 and 6:00. -8. Whether turning off carrier frequencies is allowed during energy saving. Example: Carrier shutdown frequency special requirement: cannot be shut down +Requirement: +1. Cell radio access technology for energy saving (LTE/NR/all). Example: Cell RAT for energy saving: NR +2. The time range for executing energy saving, recurring daily. If omitted, energy saving is performed for the full period, and experience-lossless energy saving is adopted by default. **The rate goal is guaranteed only within the energy saving enabled time range**. Example: Energy saving time range: start time "08:00:00", end time "12:00:00" +3. Carrier frequencies that can be turned off during energy saving. Example: Downlink frequency numbers: 1650, 1600 +4. Rate guarantee goal: the minimum downlink throughput target that must be guaranteed and its effective time period. + For the area-level setting, the following information needs to be provided: + Option 1: + Time-segmented rate guarantee goal (required) + Example: Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {18:00~00:00,20Mbps} + + Option 2: + Full-period rate guarantee goal + Example: Rate guarantee goal: 10Mbps + For the site-level setting, the following information needs to be provided: + Site list (required) + Time-segmented rate guarantee goal (required) + Background event and event duration (optional) + Example: + Site list: ["Site Name 1", "Site Name 2"], Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {18:00~00:00,20Mbps}, Background event: power outage from 00:00 to 06:00. + Site list: ["Site Name 3", "Site Name 4"], Time-segmented rate guarantee goal: {01:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {19:00~00:00,20Mbps}, Background event: power outage from 00:00 to 06:00. + + ## Expected Output -Requirement: Output the intent report for the energy efficiency optimization task. The intent report should contain the following information: +{{expected_output}} (optional when creating, not required when modifying) +Requirement: When the task object is a geographic/logical area, output the intent report for the network energy saving task. The intent report should contain the following information: ### Intent Report Basic Information -1. Unique identifier of the energy efficiency optimization report +1. Unique identifier of the network energy saving report 2. Unique identifier of the energy saving task 3. Execution result of the overall intent, including the execution result status and execution result details. If the goal is not achieved, describe the specific reason for not achieving it. ### Intent Report Detailed Information diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/zh-CN/template.md index ae7194a5..4656774d 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/zh-CN/template.md @@ -1,43 +1,64 @@ +## 操作类型(Operation Type) +{{操作类型}}(必填) +要求: +请提供操作类型,取值范围:创建、修改 + ## 任务类型(Task Type) -无线能效优化 +无线网络节能 ## 任务描述(Task Description) -基于<任务对象>、<任务上下文>、<约束条件>输出能效优化方案,达成<任务目标>中定义的能效优化目标,按照<预期输出>中定义的结构返回能效优化的结果。 +{{任务描述}}(必填) +要求: +请根据<操作类型>(创建/修改),动态提供任务描述信息: -## 任务目标(Task Target) -{{任务目标}}(可选) -要求:定义任务需达成的量化或定性目标,如能效目标和速率保障目标。均为可选参数。 -任务级节能的具体目标说明: -1. 能耗目标:节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比。举例:能耗目标:30% -2. 速率保障目标:需要保障的下行吞吐率目标最小值。举例:速率保障目标:50Mbps/s +类型为【创建】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。 +类型为【修改】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标。 ## 任务对象(Task Object) {{任务对象}}(必选) -要求:明确指定节能区域信息,地理或逻辑区域(如:区级行政单位)或经纬度坐标。区域信息为必选参数。 +要求:明确指定节能覆盖范围信息,地理或逻辑区域(如 区级行政单位)。区域信息为必选参数。 + 以下是具体说明: -1. 地理/逻辑的区域名称,举例:区域:“松山湖管委会”,坐标(112.0,20.6)区域 -2. **重要**:区域信息,是具体物理地址,例如:“松山湖管委会”这种物理真实小区,而不是“所有小区”、“室内小区”、“室外小区”这种无具体物理位置的描述信息 +1. 区域名称, 举例: 区域:“松山湖管委会” + **重要说明**: 区域信息,例如,“松山湖管委会”这种物理真实小区,而不是“所有小区”、“室内小区”、“室外小区”这种无具体物理位置的描述信息 +## 任务目标(Task Target) +{{任务目标}}(可选) +要求:定义任务需达成的量化或定性目标,量化目标和定性目标不可同时选择,默认为定性目标。 +任务级节能的具体目标说明: +1. 量化目标 能耗目标:节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比 举例: 能耗目标: 30% +2. 定性目标 在满足任务上下文的条件下进行最大化节能 + ## 任务上下文(Task Context) {{任务上下文}}(可选) -要求:提供任务执行的背景和上下文信息。可选参数。 - -## 约束条件(Constraints) -{{约束条件}}(可选) -要求:列出任务执行中必须遵守的规则、限制和边界条件,约束条件默认对区域内所有场景均适用,针对区域内指定场景适用的约束条件,请单独说明。均为可选参数。 -1. 需要节能的小区类型(室内/室外/所有),举例:节能的小区类型:室外 -2. 节能的小区制式(LTE/NR/全部),举例:节能的小区制式:NR -3. 对基础层和容量层的频点要求,频点的表述方式必须为下行频点号,举例:1650频点做载波关断的基础层 -4. 节能的激进程度是激进节能还是保守节能,举例:节能激进程度:激进节能 -5. 是否要求体验无损节能,举例:是否要求体验无损节能:是 -6. 需要执行节能的时间段区间范围,以天为周期循环执行,例如:需要节能的时间范围:起始时间“08:00:00”,结束时间“12:00:00” -7. 针对区域内制定场景适用的约束条件(如:铁路/黑森林/商务区/居民区),举例:商业区场景禁止1300频点做容量层;黑森林场景禁止在0-6点开通休眠 -8. 是否允许在节能时关闭载波的频点,举例:载波关断频点特殊要求:不可关断 +要求: +1. 节能的小区制式(LTE/NR/全部) 举例: 节能的小区制式: NR +2. 需要执行节能的时间段区间范围,以天为周期循环执行,缺省则进行全时段节能,默认采取用户体验无损方式节能。**仅在节能开启时间范围内保障速率目标** 举例: 需要节能的时间范围: 起始时间"08:00:00",结束时间"12:00:00" +3. 可在节能时关闭载波的频点 举例: 下行频点号:1650, 1600 +4. 速率保障目标:需要保障的下行吞吐率目标最小值及生效时间段。 + 针对区域级设置方式,需要填写以下信息: + 方式一: + 分时段速率保障目标(必填) + 举例:分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{18:00~00:00,20Mbps} + + 方式二: + 全时段速率保障目标 + 举例:速率保障目标:10Mbps + 针对站点级设置方式,需要填写以下信息: + 站点列表(必填) + 分时段速率保障目标(必填) + 背景事件与事件持续时间(选填) + 举例: + 站点列表:[“站点名称1”,“站点名称2”],分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{18:00~00:00,20Mbps},背景事件:在00:00~06:00停电。 + 站点列表:[“站点名称3”,“站点名称4”],分时段速率保障目标:{01:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{19:00~00:00,20Mbps},背景事件:在00:00~06:00停电。 + + ## 预期输出(Expected Output) -要求:输出能效优化任务的意图报告,意图报告应包含以下信息: +{{预期输出}}(创建时选填,修改时无需填写) +要求: 若任务对象为地理/逻辑区域时,输出网络节能任务的意图报告,意图报告应包含以下信息: ### 意图报告基本信息 -1. 能效优化报告的唯一标识 +1. 网络节能报告的唯一标识 2. 节能任务唯一标识 3. 总体意图的执行结果,执行结果包括执行结果状态,执行结果详情,如果未达成目标,描述未达成的具体原因。 ### 意图报告详细信息 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/accept-reject/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/accept-reject/en-US/template.md new file mode 100644 index 00000000..90dea76f --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/accept-reject/en-US/template.md @@ -0,0 +1,29 @@ +## Negotiation Context +{{negotiation_context}} (required) +Requirement: +Each negotiation must contain the following information: +- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept or Reject), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: The current round. Type: positive integer. Example: 1 +- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 + +## Feasibility Negotiation Result +{{feasibility_negotiation_result}} (required) +Requirement: +1. If the assessment result is feasible, or the assessment result provided by the counterparty is accepted, return Accept +2. If the assessment result is infeasible, or the assessment result provided by the counterparty is not accepted, return Reject +3. The conclusion must be one of the two + +## Feasibility Assessment Result Confirmation +{{evaluation_result_confirmation}} (required) +Requirement: +1. If the assessment result is feasible and there are multiple optional solutions in the assessment request, explicitly specify which solution to use +2. If the assessment result is infeasible, state the reason for rejection +3. If the assessment result provided by the counterparty is accepted: briefly summarize the counterparty's assessment result, and state that this assessment result is accepted +4. If the assessment result provided by the counterparty is not accepted: briefly summarize the counterparty's assessment result, and state that this assessment result is not accepted + +Example 1 (Accept): The energy saving goal can be achieved. This negotiation is confirmed and ended. +Example 2 (Accept): Agree to adjust the rate guarantee goal from 5Mbps down to 2Mbps. This negotiation is confirmed and ended. +Example 3 (Accept): The current neighbor cell keepalive solution is feasible, and the impact is controllable. This negotiation is confirmed and ended. +Example 4 (Accept): Accept the assessment result that the energy saving goal achievement is infeasible. This negotiation is confirmed and ended. +Example 5 (Reject): The energy saving goal cannot be achieved. This negotiation is confirmed and ended. +Example 6 (Reject): Do not accept the assessment result that the energy saving goal achievement is infeasible. This negotiation is confirmed and ended. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/accept-reject/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/accept-reject/zh-CN/template.md new file mode 100644 index 00000000..a72196b1 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/accept-reject/zh-CN/template.md @@ -0,0 +1,29 @@ +## 协商上下文 +{{协商上下文}}(必填) +要求: +每次协商必须包含以下信息: +- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept或Reject),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: 当前轮次;类型:正整数;举例:1 +- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 + +## 可行性协商结果 +{{可行性协商结果}}(必填) +要求: +1. 如果评估结果为可行,或接受对端提供的评估结果,则返回Accept +2. 如果评估结果为不可行,或不接受对端提供的评估结果,则返回Reject +3. 结论必须二选一 + +## 可行性评估结果确认 +{{评估结果确认}}(必填) +要求: +1. 如果评估结果为可行,且评估请求里存在多个可选方案,则明确指定使用哪一个方案 +2. 如果评估结果为不可行,则说明拒绝理由 +3. 如果接受对端提供的评估结果,则:简要概述对端的评估结果,并说明接受该评估结果 +4. 如果不接受对端提供的评估结果,则:简要概述对端的评估结果,并说明不接受该评估结果 + +样例1(Accept):节能目标可达成,本次协商确认结束。 +样例2(Accept):同意将速率保障目标由5Mbps下调至2Mbps,本次协商确认结束。 +样例3(Accept):当前邻区保活方案可行,影响可控,本次协商确认结束。 +样例4(Accept):接受节能目标达成不可行的评估结果,本次协商确认结束。 +样例5(Reject):节能目标无法达成,本次协商确认结束。 +样例6(Reject):不认同节能目标达成不可行的评估结果,本次协商确认结束。 \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/propose/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/propose/en-US/template.md new file mode 100644 index 00000000..8093c8c5 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/propose/en-US/template.md @@ -0,0 +1,55 @@ +## Negotiation Context +{{negotiation_context}} (required) +Requirement: +Each negotiation must contain the following information: +- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept or Reject), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: The current round. Type: positive integer. Example: 1 +- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 + +## Feasibility Negotiation +{{feasibility_negotiation_summary}} (required) +Requirement: +1. State whether the feasibility assessment category is goal achievement or solution feasibility +2. State the nature of this round's message, taking one of the following: + - **Initiate feasibility assessment**: Propose the target or solution to be evaluated, and this side has not yet given a conclusion (whether it is the first proposal, or a re-initiation after adjusting the target or solution following an infeasible evaluation in the previous round, it falls into this category) + - **Assess as infeasible and propose**: This side has completed the assessment, the conclusion is infeasible, and the counterparty needs to confirm the handling strategy +3. If it is "Initiate feasibility assessment", reference , and state the action expected of the counterparty: "please assess"; if this is a re-initiation after an infeasible evaluation in the previous round, state the changes in this round relative to the previous round, without repeating the unchanged parts +4. If it is "Assess as infeasible and propose", reference +5. Keep it concise, one to two sentences, without expanding or restating the specific content + +Example 1 (first initiation of assessment): The energy saving task request has been received. Please help assess whether the energy saving goal can be achieved. See for details. Please assess. +Example 2 (proposing a solution to initiate assessment): During the execution of the fault subscription task, self-diagnosis identified a viable keepalive solution. See for details. Please assess whether this solution can be adopted. +Example 3 (re-initiating assessment after adjustment): Regarding the previous conflict between the rate guarantee goal and the power outage duration requirement, the rate guarantee goal has been adjusted from 5Mbps down to 2Mbps. See for details. Please re-assess whether this goal can be achieved. +Example 4 (assess as infeasible and propose): The feasibility assessment for goal achievement of the site-level energy saving task has been completed. The current rate goal cannot simultaneously satisfy the power outage duration requirement. See for details. Please confirm. + +## Under Evaluation Description +{{under_evaluation_description}} (optional) +Requirement: +1. Provide this section only when the nature of this round's message is "Initiate feasibility assessment" +2. State whether the item to be evaluated belongs to goal achievement or solution feasibility +3. For goal achievement, describe: the goal content, and the solution or resource status on which the goal depends; if this is a re-initiation after an infeasible evaluation in the previous round, additionally state the adjusted value and the basis for the adjustment +4. For solution feasibility, describe: the candidate solution content to be evaluated (one or more), the resources required by the solution and whether there is a potential association with existing constraints; if this is a re-initiation after an infeasible evaluation in the previous round, additionally state the replacement/adjusted solution content +5. This side does not provide a conclusion, only the complete information of the object to be evaluated, to avoid omissions that would leave the counterparty with insufficient basis for assessment +6. Related items must be described one by one, not only listing some of them + +Example: The target to be evaluated is the rate guarantee goal of the site-level energy saving task during the 08:00~18:00 period, which has been adjusted from the original 5Mbps down to 2Mbps. The basis for the adjustment is to accommodate the power outage duration guarantee requirement during the 08:00~18:00 period. Please assess whether this adjusted rate goal can be achieved, and whether it can simultaneously satisfy the original power outage duration guarantee requirement. + +## Infeasible Evaluation Details and Proposal +{{infeasible_evaluation_details_and_proposal}} (optional) +Requirement: +1. Provide this section only when the nature of this round's message is "Assess as infeasible and propose" +2. State whether the assessment belongs to goal achievement or solution feasibility +3. For goal achievement, describe: the goal content, whether there is self-contradiction or multi-goal conflict, whether the dependent solution or resources are available, and state the basis for the judgment +4. For solution feasibility, describe: the candidate solution content (one or more), whether the resources required by each solution are available, and whether there is a conflict with existing constraints +5. Give a clear conclusion of infeasible +6. State the basis +7. Provide adjustment suggestions or feasible candidate solutions +8. Related items must be described one by one, not only listing some of them + +Example: +This assessment targets the goal achievement feasibility of the site-level energy saving task. +The original request requires the xx site to deliver an energy saving guaranteed rate of 5Mbps during the 08:00~18:00 period, while the site has an existing constraint of guaranteeing at least 10 hours of power supply duration under power outage scenarios. +Upon assessment, if the 5Mbps rate guarantee goal is executed, the equipment power consumption during this period cannot satisfy the power outage duration requirement. The two requirements conflict, and the basis for this judgment is the current backup power capacity of the site and the historical power consumption curve. +Conclusion: Infeasible. +Proposal: Regarding the conflict between the rate guarantee goal and the power outage duration requirement, it is recommended to adjust the rate guarantee goal from 5Mbps down to 2Mbps, so as to extend the supportable power outage duration to the required 10 hours. If the rate reduction cannot be accepted, the power outage duration guarantee requirement must be shortened accordingly. +One of the two must be adjusted; there is currently no third option that can simultaneously satisfy the original two requirements. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/propose/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/propose/zh-CN/template.md new file mode 100644 index 00000000..18bd2e0b --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/propose/zh-CN/template.md @@ -0,0 +1,55 @@ +## 协商上下文 +{{协商上下文}}(必填) +要求: +每次协商必须包含以下信息: +- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept或Reject),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: 当前轮次;类型:正整数;举例:1 +- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 + +## 可行性协商 +{{可行性协商概述}}(必填) +要求: +1. 说明可行性评估类别为目标达成还是方案可行性 +2. 说明本轮消息性质,取以下之一: + - **发起可行性评估**:提出待评估目标或方案,本方尚未给出结论(无论是首次提出,还是因上一轮被评估不可行、已调整目标或方案后重新发起,均归为此类) + - **评估不可行并提案**:本方已完成评估,结论为不可行且需对方确认处理策略 +3. 若为"发起可行性评估",需引用<待评估内容说明>,并说明期望对方采取的动作:"请评估";若本次是在上一轮被评估不可行后调整发起,需说明本次调整相对上一轮的变化点,不需要重复陈述未变化的部分 +4. 若为"评估不可行并提案",需引用<评估不可行时的详情和提案> +5. 保持简洁,一到两句话,不展开复述具体内容 + +样例1(首次发起评估):已收到节能任务请求,请协助评估该节能目标能否达成,详见<待评估内容说明>,请评估 +样例2(提出方案发起评估):故障订阅任务履行过程中,自诊断识别到一个可行的保活方案,详见<待评估内容说明>,请评估该方案是否可采纳 +样例3(调整后再次发起评估):针对此前速率保障目标与停电时长要求的冲突,已将速率保障目标由5Mbps下调至2Mbps,详见<待评估内容说明>,请重新评估该目标是否可达成 +样例4(评估不可行并提案):站点级节能任务的目标达成可行性评估已完成,当前速率目标无法同时满足停电时长要求,详见<评估不可行时的详情和提案>,请确认 + +## 待评估内容说明 +{{待评估内容说明}}(选填) +要求: +1. 仅当本轮消息性质为"发起可行性评估"时提供本板块 +2. 说明待评估事项属于目标达成还是方案可行性 +3. 目标达成需描述:目标内容、目标所依赖的方案或资源现状;若本次是在上一轮被评估不可行后调整发起,需额外说明本次的调整值及调整依据 +4. 方案可行性需描述:待评估的候选方案内容(可一个或多个)、方案所需资源及是否与既有约束存在潜在关联;若本次是在上一轮被评估不可行后调整发起,需额外说明本次替换/调整后的方案内容 +5. 本方不提供结论,仅提供待评估对象的完整信息,避免遗漏导致对方评估依据不足 +6. 相关事项需逐项说明,不能只列部分 + +样例:待评估目标为站点级节能任务在08:00~18:00时段的速率保障目标,已由原5Mbps下调至2Mbps,调整依据为兼顾08:00~18:00时段的停电时长保障要求。请评估该调整后的速率目标能否达成,且是否可同时满足原有的停电时长保障要求。 + +## 评估不可行时的详情和提案 +{{评估不可行时的详情和提案}}(选填) +要求: +1. 仅当本轮消息性质为"评估不可行并提案"时,提供本板块 +2. 说明评估属于目标达成还是方案可行性 +3. 目标达成需描述:目标内容、是否存在自相矛盾或多目标冲突、所依赖的方案或资源是否具备,并说明判断依据 +4. 方案可行性需描述:候选方案内容(可一个或多个)、各方案所需资源是否具备、是否与既有约束冲突 +5. 给出明确结论为不可行 +6. 需说明依据 +7. 给出调整建议或可行的候选方案 +8. 相关事项需逐项说明,不能只列部分 + +样例: +本次评估针对站点级节能任务的目标达成可行性。 +原始请求要求xx站点在08:00~18:00时段下发节能保障速率为5Mbps的目标,同时该站点存在停电场景下需保障至少10小时供电时长的既有约束。 +经评估,若按5Mbps速率保障目标执行,该时段内设备功耗无法满足停电时长要求,两项要求之间存在冲突,判断依据为当前站点的备电容量及历史功耗曲线。 +结论:不可行。 +提案:针对速率保障目标与停电时长要求的冲突,建议将速率保障目标由5Mbps下调至2Mbps,以延长可支撑的停电时长至满足要求的10小时。 若无法接受下调速率,则需相应缩短停电时长保障要求。 +两者需二选一调整,暂无第三种可同时满足原有两项要求的方案。 \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/accept-reject/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/accept-reject/en-US/template.md new file mode 100644 index 00000000..245b3cc8 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/accept-reject/en-US/template.md @@ -0,0 +1,27 @@ +## Negotiation Context +{{negotiation_context}} (required) +Requirement: +Each negotiation must contain the following information: +- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept or Reject), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: The current round. Type: positive integer. Example: 1 +- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 + +## Information Negotiation Result +{{information_negotiation_result}} (required) +Requirement: +1. If the required information can be provided, return Accept +2. If the required information cannot be fully provided, return Reject +3. The conclusion must be one of the two + +## Information Negotiation Result Content +{{information_negotiation_result_content}} (required) +Requirement: +1. If the information can be provided: + - State the name and content +2. If the information cannot be provided: + - State the name and the specific reason why it cannot be provided (optional) +Example 1: +Energy saving area information: Songshanhu +Energy saving rate guarantee goal: 20Mbps +Example 2: +Unable to provide area information diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/accept-reject/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/accept-reject/zh-CN/template.md new file mode 100644 index 00000000..5597f268 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/accept-reject/zh-CN/template.md @@ -0,0 +1,27 @@ +## 协商上下文 +{{协商上下文}}(必填) +要求: +每次协商必须包含以下信息: +- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept或Reject),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: 当前轮次;类型:正整数;举例:1 +- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 + +## 信息协商结果 +{{信息协商结果}}(必填) +要求: +1. 如果能够提供所需信息,则返回Accept +2. 如果无法完整提供所需信息,则返回Reject +3. 结论必须二选一 + +## 信息协商结果内容 +{{信息协商结果内容}}(必填) +要求: +1. 如果能够提供信息: + - 说明名称、内容 +2. 如果无法提供信息: + - 说明名称、无法提供的具体原因(可选) +样例1: +节能区域信息为松山湖 +节能速率保障目标为20Mbps +样例2: +无法提供区域信息 \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/propose/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/propose/en-US/template.md new file mode 100644 index 00000000..445e32d1 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/propose/en-US/template.md @@ -0,0 +1,19 @@ +## Negotiation Context +{{negotiation_context}} (required) +Requirement: +Each negotiation must contain the following information: +- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept or Reject), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: The current round. Type: positive integer. Example: 1 +- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 + +## Information Negotiation +Please supplement the relevant content based on . + +## Required Information Items +{{required_information_items}} (required) +Requirement: +Provide the name (required), meaning (optional), format requirement (optional), example (optional), and the relationship between missing items (AND/OR) (optional) for each missing item. +Example: +1. Energy saving area information, e.g., Songshanhu +2. Energy saving rate guarantee goal, e.g., 20Mbps +3. VLANId, e.g., xxxx(cvlan=11) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/propose/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/propose/zh-CN/template.md new file mode 100644 index 00000000..8a7bcd06 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/propose/zh-CN/template.md @@ -0,0 +1,19 @@ +## 协商上下文 +{{协商上下文}}(必填) +要求: +每次协商必须包含以下信息: +- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept或Reject),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: 当前轮次;类型:正整数;举例:1 +- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 + +## 信息协商 +请根据<所需信息项>补充相关内容。 + +## 所需信息项 +{{所需信息项}}(必填) +要求: +提供每个缺失项的名称(必选)、含义(可选)、格式要求(可选)、样例(可选)、缺失项之间的关系(与、或)(可选)。 +样例: +1. 节能区域信息,如松山湖 +2. 节能速率保障目标,如20Mbps +3. VLANId,如xxxx(cvlan=11) \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/accept-reject/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/accept-reject/en-US/template.md new file mode 100644 index 00000000..bf5d1b47 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/accept-reject/en-US/template.md @@ -0,0 +1,22 @@ +## Negotiation Context +{{negotiation_context}} (required) +Requirement: +Each negotiation must contain the following information: +- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept or Reject), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: The current round. Type: positive integer. Example: 1 +- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 + +## Target Negotiation Result +{{target_negotiation_result}} (required) +Requirement: +1. If all questions can be clarified, return Accept +2. If all questions cannot be fully clarified, return Reject +3. The conclusion must be one of the two, and vague states such as "partially agreed" are not allowed + +## Target Negotiation Result Content +{{target_negotiation_result_content}} (required) +Requirement: +1. If all questions can be clarified, list the finally confirmed intent content: + - Summarize the understandings that have been confirmed or adopted after correction in all rounds of this negotiation, to form a complete and unambiguous final intent description, rather than listing the change process of each round + - Each item must be directly usable as the basis for subsequent task execution, and must no longer contain uncertain statements such as "to be confirmed" or "may be" +2. If all questions cannot be fully clarified, state the reason for failure diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/accept-reject/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/accept-reject/zh-CN/template.md new file mode 100644 index 00000000..7e907fd7 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/accept-reject/zh-CN/template.md @@ -0,0 +1,22 @@ +## 协商上下文 +{{协商上下文}}(必填) +要求: +每次协商必须包含以下信息: +- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept或Reject),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: 当前轮次;类型:正整数;举例:1 +- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 + +## 目标协商结果 +{{目标协商结果}}(必填) +要求: +1. 如果能够澄清所有疑问,则返回Accept +2. 如果无法完整澄清所有疑问,则返回Reject +3. 结论必须二选一,不允许"部分同意"等模糊状态 + +## 目标协商结果内容 +{{目标协商结果内容}}(必填) +要求: +1. 如果能够澄清所有疑问,则列出最终确认的意图内容: + - 需汇总本次协商所有轮次中已 confirmed 或已被 corrected 采纳的理解,形成一份完整、无歧义的最终意图描述,而非罗列各轮变更过程 + - 每条内容需可直接作为后续任务执行的依据,不再包含"待确认""可能是"等不确定表述 +2. 如果无法完整澄清所有疑问,则给出失败原因 \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/propose/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/propose/en-US/template.md new file mode 100644 index 00000000..8d8647af --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/propose/en-US/template.md @@ -0,0 +1,54 @@ +## Negotiation Context +{{negotiation_context}} (required) +Requirement: +Each negotiation must contain the following information: +- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept or Reject), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: The current round. Type: positive integer. Example: 1 +- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 + +## Target Negotiation +{{target_negotiation_summary}} (required) +Requirement: +Please generate the target negotiation summary according to the following requirements: +1. State the source section of the "understanding content" in this round's message, and distinguish by round: + - If it is the first round of negotiation (this round's message contains ), reference + - If it is a non-first round of negotiation (this round's message is a response to the previous round's questions, containing ), reference +2. If there are points to be clarified in this round, reference and indicate that clarification is needed +3. Explicitly state the action expected of the counterparty in this round, such as "please clarify and confirm". The action statement must correspond to the actual section content of this round (e.g., when this round only needs to confirm understanding with no new questions, the action should be "please confirm") +4. Summarize the core theme of the points to be clarified in this round. The summary must remain concise, limited to one or two sentences, and must not expand or restate the specific content in or + +Example 1: For the intent understanding of the wireless energy saving optimization task, see . There are questions about the area and the energy saving time range. See for details. Please clarify and confirm. +Example 2: Clarification has been provided for the energy saving time range in the wireless energy saving optimization task. See for details. There are still questions about the area information. See for details. Please clarify and confirm. +Example 3: Clarification has been provided for the area information in the wireless energy saving optimization task. See for details. Please confirm. + +## Intent Understanding Statement +{{intent_understanding_statement}} (optional) +Requirement: +Please provide the understanding of the original intent according to the following requirements: +1. It must be a restatement of the original request, and must not introduce new assumptions that do not exist in the original request +2. Each understanding must be annotated with its source, indicating from which field/statement of the original request it is inferred +3. The coverage must be complete: list the understanding of the complete content of the original intent, not only the uncertain parts + +## Understanding Alignment and Clarification +{{understanding_alignment_and_clarification}} (optional) +Requirement: +1. The coverage must be complete: for each understanding listed in and each question listed in , a response must be given one by one, without omission, and without skipping and then uniformly stating "the rest are agreed" +2. Response to understanding statements: + - The confirmation result must be clearly marked as confirmed (agreed) or corrected (needs correction) + - If corrected, the corrected correct value must be given, and the reason for the correction must be stated. It is not allowed to only mark "incorrect" without giving the correct content +3. The response to question points must correspond to the candidate form given when the question was asked: + - If the questioner gave closed candidates, the response must directly select the candidate number, and must not deviate from the candidates to give a free description + - If the candidates include a "none of the above/other" option and it is selected, specific explanation must be supplemented. It is not allowed to only select that option without giving content + - If the questioner marked it as an open-ended question, the response should be given in a structured "field path + value" form, avoiding a whole paragraph of free text + - If it is truly impossible to answer, mark it as unable-to-answer and state the reason (such as insufficient permissions, information temporarily unavailable). It is not allowed to leave it blank or replace a clear "cannot answer" with vague language +4. The response scope must strictly align with the field scope of the statements/questions: + - Must not introduce new field confirmations or new questions outside the statements and questions in this section +5. The response content must maintain the same atomic granularity as the original question points: one question point corresponds to only one response. The responses to multiple question points must not be merged into one comprehensive statement + +## Content to Clarify +{{content_to_clarify}} (optional) +Requirement: +For each item to be clarified, the following requirements must be met: +1. It must be locatable to a specific field, and cannot be a general "target unclear" +2. If there are candidate options, please provide them, to avoid free-form description +3. State why the question is raised (optional) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/propose/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/propose/zh-CN/template.md new file mode 100644 index 00000000..525478db --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/propose/zh-CN/template.md @@ -0,0 +1,55 @@ +## 协商上下文 +{{协商上下文}}(必填) +要求: +每次协商必须包含以下信息: +- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept或Reject),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: 当前轮次;类型:正整数;举例:1 +- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 + +## 目标协商 +{{目标协商概述}}(必填) +要求: +请按照如下要求生成目标协商概述: +1. 需说明本轮消息中"理解类内容"的来源板块,并按轮次区分: + - 若为首轮协商(本轮消息中包含<意图理解陈述>),引用<意图理解陈述> + - 若为非首轮协商(本轮消息是对上一轮疑问的应答,包含<理解对齐与疑问澄清>),引用<理解对齐与疑问澄清> +2. 若本轮存在待澄清的疑问点,需引用<待澄清内容>并提示需要澄清; +3. 需明确提出本轮期望对方采取的动作,如"请澄清和确认",动作表述需与本轮实际板块内容对应(如本轮仅需确认理解、无新疑问时,动作应为"请确认") +4. 需概括本轮待澄清疑问的核心主题,概述需保持简洁,控制在一到两句话内,不得展开复述<意图理解陈述>或<待澄清内容>中的具体内容 + +样例1:对无线节能优化任务的意图理解参见<意图理解陈述>,对区域和节能的时间范围存在疑问,详见<待澄清内容>,请澄清和确认 +样例2:已针对无线节能优化任务中的节能时间范围提供疑问澄清,详见<理解对齐与疑问澄清>,对区域信息仍有疑问,详见<待澄清内容>,请澄清和确认 +样例3:已针对无线节能优化任务中的区域信息提供疑问澄清,详见<理解对齐与疑问澄清>,请确认 + +## 意图理解陈述 +{{意图理解陈述}}(选填) +要求: +请按照如下要求提供对原始意图的理解: +1. 必须是对原始请求的复述性理解,不得引入原始请求中不存在的新假设 +2. 每条理解需标注来源,指明是从原始请求的哪个字段/表述推断得出 +3. 覆盖度要完整:列出对于原始意图的完整内容的理解,不能只包含不确定的部分 + +## 理解对齐与疑问澄清 +{{理解对齐与疑问澄清}}(选填) +要求: +1. 覆盖度要完整:对<意图理解陈述>中列出的每条理解、<待澄清内容>中列出的每个疑问点,均需逐条给出应答,不得遗漏,也不得跳过后统一说明"其余同意" +2. 理解陈述的应答: + - 确认结果需明确标注为 confirmed(认同)或 corrected(需修正) + - 若为 corrected,必须给出修正后的正确值,并说明修正理由,不能只标"不对"而不给出正确内容 +3. 疑问点的应答需与提问时给出的候选形式对应: + - 若提问方给出了封闭候选,应答须直接选定候选编号,不得脱离候选另行自由描述 + - 若候选包含"以上都不是/其他"选项且被选中,必须补充具体说明,不能仅选该项而不给内容 + - 若提问方标注为开放式问题,应答以结构化的"字段路径+值"形式给出,避免整段自由文本 + - 若确实无法回答,需标注为 unable-to-answer 并说明原因(如权限不足、信息暂不可得),不得留空或用模糊语言代替明确的"无法回答" +4. 应答范围需与陈述/疑问的字段范围严格对齐: + - 不得在本板块中引入陈述和疑问之外的新字段确认或新疑问 +5. 应答内容需保持与原疑问点相同的原子化粒度:一个疑问点只对应一条应答, + 不得将多个疑问点的回答合并为一段综合说明 + +## 待澄清内容 +{{待澄清内容}}(选填) +要求: +对于每一个待澄清项,需要满足以下要求: +1. 必须能定位到具体字段,不能是笼统的"目标不清楚" +2. 如有候选项请给出,避免自由发挥 +3. 说明为何提出该疑问(可选) diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java index 90cb7e4a..ac57d2e4 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java @@ -22,7 +22,7 @@ void loadReadsSlotDefinitionsUsingPromptPackagePublicLoaders() { PromptTemplateDefinition definition = loader.load("energy_saving", "zh-CN"); assertEquals("energy_saving", definition.scenarioCode()); - assertEquals(4, definition.slotDefinitions().size()); + assertEquals(6, definition.slotDefinitions().size()); assertEquals(false, definition.slotDefinitions().get(0).required()); } diff --git a/tools/template_lint.py b/tools/template_lint.py index 18dd1a65..c5c9450a 100644 --- a/tools/template_lint.py +++ b/tools/template_lint.py @@ -12,11 +12,12 @@ HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$") SLOT = re.compile(r"{{\s*([^{}\s]+)\s*}}") -TASK = {"Task Description", "Task Type", "Task Target", "Task Object", "Task Context", "Constraints", "Expected Output"} +TASK = {"Task Description", "Task Type", "Task Target", "Task Object", "Task Context", "Constraints", "Expected Output", "Operation Type"} NOTIFICATION = {"Subscription Description", "Notification Topic", "Subscribe Condition", "Notification Data Format", "Expected Output"} ALIASES = { "任务描述": "Task Description", "任务类型": "Task Type", "任务目标": "Task Target", "任务对象": "Task Object", "目标对象": "Task Object", "任务上下文": "Task Context", "约束条件": "Constraints", "预期输出": "Expected Output", + "操作类型": "Operation Type", "订阅描述": "Subscription Description", "通知主题": "Notification Topic", "订阅条件": "Subscribe Condition", "通知数据格式": "Notification Data Format", "上报通知数据格式": "Notification Data Format", } From 417aba2d4330a537d971fcbeac2d0afc68c3d0a9 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Wed, 19 Aug 2026 16:56:04 +0800 Subject: [PATCH 019/295] refactor: optimize the template directory structure Signed-off-by: zhangwenjun-huawei --- .../a2at/sdk/client/api/A2ATClientTest.java | 8 +++-- .../v1}/energy_saving/en-US/slot.json | 0 .../{ => Task-T/v1}/free_text/en-US/slot.json | 0 .../v1}/energy_saving/en-US/template.md | 0 .../ClasspathPromptSlotSchemaLoader.java | 24 ++++++++++---- .../loader/ClasspathPromptTemplateLoader.java | 16 +++++++++- .../LocalFilePromptSlotSchemaLoader.java | 32 +++++++++++++++---- .../loader/LocalFilePromptTemplateLoader.java | 32 +++++++++++++++---- .../loader/ClasspathPromptLoadersTest.java | 2 +- .../loader/LocalFilePromptLoadersTest.java | 27 +++++++--------- .../loader/PromptResourceAccessTest.java | 4 +++ .../v1}/handoff_planning/en/slot.json | 0 .../v1}/handoff_planning/en/template.md | 0 .../a2at/sdk/resources/PromptResourceKey.java | 29 ++++++++++++++--- .../v1}/subscribe_incident/en-US/slot.json | 0 .../v1}/subscribe_incident/zh-CN/slot.json | 0 .../v1}/energy_saving/en-US/slot.json | 0 .../v1}/energy_saving/zh-CN/slot.json | 0 .../private_line_complaint/en-US/slot.json | 0 .../private_line_complaint/zh-CN/slot.json | 0 .../accept-reject/en-US/template.md | 0 .../accept-reject/zh-CN/template.md | 0 .../propose/en-US/template.md | 0 .../propose/zh-CN/template.md | 0 .../accept-reject/en-US/template.md | 0 .../accept-reject/zh-CN/template.md | 0 .../propose/en-US/template.md | 0 .../propose/zh-CN/template.md | 0 .../accept-reject/en-US/template.md | 0 .../accept-reject/zh-CN/template.md | 0 .../propose/en-US/template.md | 0 .../propose/zh-CN/template.md | 0 .../v1}/subscribe_incident/en-US/template.md | 0 .../v1}/subscribe_incident/zh-CN/template.md | 0 .../v1}/energy_saving/en-US/template.md | 0 .../v1}/energy_saving/zh-CN/template.md | 0 .../private_line_complaint/en-US/template.md | 0 .../private_line_complaint/zh-CN/template.md | 0 .../ClasspathPromptResourceLoaderTest.java | 6 ++-- .../LocalFileServerPromptTemplateLoader.java | 29 ++++++++++++----- ...calFileServerPromptTemplateLoaderTest.java | 4 +++ tools/template_lint.py | 4 +-- 42 files changed, 159 insertions(+), 58 deletions(-) rename a2a-t-client/src/test/resources/prompt_resources/slots/{ => Task-T/v1}/energy_saving/en-US/slot.json (100%) rename a2a-t-client/src/test/resources/prompt_resources/slots/{ => Task-T/v1}/free_text/en-US/slot.json (100%) rename a2a-t-client/src/test/resources/prompt_resources/templates/{ => Task-T/v1}/energy_saving/en-US/template.md (100%) rename a2a-t-prompt/src/test/resources/prompt_resources/slots/{ => Task-T/v1}/handoff_planning/en/slot.json (100%) rename a2a-t-prompt/src/test/resources/prompt_resources/templates/{ => Task-T/v1}/handoff_planning/en/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/{ => Notification-T/v1}/subscribe_incident/en-US/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/{ => Notification-T/v1}/subscribe_incident/zh-CN/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/{ => Task-T/v1}/energy_saving/en-US/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/{ => Task-T/v1}/energy_saving/zh-CN/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/{ => Task-T/v1}/private_line_complaint/en-US/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/{ => Task-T/v1}/private_line_complaint/zh-CN/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Negotiation-T/v1}/feasibility_negotiation/accept-reject/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Negotiation-T/v1}/feasibility_negotiation/accept-reject/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Negotiation-T/v1}/feasibility_negotiation/propose/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Negotiation-T/v1}/feasibility_negotiation/propose/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Negotiation-T/v1}/information_negotiation/accept-reject/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Negotiation-T/v1}/information_negotiation/accept-reject/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Negotiation-T/v1}/information_negotiation/propose/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Negotiation-T/v1}/information_negotiation/propose/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Negotiation-T/v1}/target_negotiation/accept-reject/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Negotiation-T/v1}/target_negotiation/accept-reject/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Negotiation-T/v1}/target_negotiation/propose/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Negotiation-T/v1}/target_negotiation/propose/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Notification-T/v1}/subscribe_incident/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Notification-T/v1}/subscribe_incident/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Task-T/v1}/energy_saving/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Task-T/v1}/energy_saving/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Task-T/v1}/private_line_complaint/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/{ => Task-T/v1}/private_line_complaint/zh-CN/template.md (100%) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index ed338916..e46701fe 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -156,10 +156,12 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); Path templatesDir = promptRoot .resolve("templates") + .resolve("Task-T") + .resolve("v1") .resolve("private_line_complaint") .resolve("zh-CN"); Path slotsDir = - promptRoot.resolve("slots").resolve("private_line_complaint").resolve("zh-CN"); + promptRoot.resolve("slots").resolve("Task-T").resolve("v1").resolve("private_line_complaint").resolve("zh-CN"); Files.createDirectories(scenariosDir); Files.createDirectories(templatesDir); Files.createDirectories(slotsDir); @@ -244,8 +246,8 @@ private static Path writeMinimalClientEnv(Path tempDir, String provider) throws promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); Path templatesDir = - promptRoot.resolve("templates").resolve("energy_saving").resolve("zh-CN"); - Path slotsDir = promptRoot.resolve("slots").resolve("energy_saving").resolve("zh-CN"); + promptRoot.resolve("templates").resolve("Task-T").resolve("v1").resolve("energy_saving").resolve("zh-CN"); + Path slotsDir = promptRoot.resolve("slots").resolve("Task-T").resolve("v1").resolve("energy_saving").resolve("zh-CN"); Files.createDirectories(scenarioPromptDir); Files.createDirectories(slotPromptDir); Files.createDirectories(scenariosDir); diff --git a/a2a-t-client/src/test/resources/prompt_resources/slots/energy_saving/en-US/slot.json b/a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/energy_saving/en-US/slot.json similarity index 100% rename from a2a-t-client/src/test/resources/prompt_resources/slots/energy_saving/en-US/slot.json rename to a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/energy_saving/en-US/slot.json diff --git a/a2a-t-client/src/test/resources/prompt_resources/slots/free_text/en-US/slot.json b/a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/free_text/en-US/slot.json similarity index 100% rename from a2a-t-client/src/test/resources/prompt_resources/slots/free_text/en-US/slot.json rename to a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/free_text/en-US/slot.json diff --git a/a2a-t-client/src/test/resources/prompt_resources/templates/energy_saving/en-US/template.md b/a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/v1/energy_saving/en-US/template.md similarity index 100% rename from a2a-t-client/src/test/resources/prompt_resources/templates/energy_saving/en-US/template.md rename to a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/v1/energy_saving/en-US/template.md diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java index 19add90b..994450eb 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java @@ -1,6 +1,8 @@ package net.openan.a2at.sdk.prompt.resources.loader; import com.fasterxml.jackson.core.JsonProcessingException; +import java.util.List; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.exception.SdkException; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; @@ -14,6 +16,8 @@ */ public final class ClasspathPromptSlotSchemaLoader implements PromptSlotSchemaLoader { + private static final List SLOT_TYPES = List.of("Task-T", "Notification-T"); + private final ClasspathPromptResourceLoader resourceLoader; public ClasspathPromptSlotSchemaLoader(ClasspathPromptResourceLoader resourceLoader) { @@ -22,12 +26,20 @@ public ClasspathPromptSlotSchemaLoader(ClasspathPromptResourceLoader resourceLoa @Override public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { - String payload = resourceLoader.loadText(new PromptResourceKey("slots", scenarioCode, language, "slot.json")); - try { - return PromptResourceJsonParser.parse(payload, PromptSlotJsonSchema.class) - .toPromptSlotSchema(scenarioCode); - } catch (JsonProcessingException exception) { - throw new SdkException("Failed to parse slot schema: " + scenarioCode, exception); + for (String slotType : SLOT_TYPES) { + try { + String payload = resourceLoader.loadText( + new PromptResourceKey("slots", slotType, scenarioCode, language, "slot.json")); + return PromptResourceJsonParser.parse(payload, PromptSlotJsonSchema.class) + .toPromptSlotSchema(scenarioCode); + } catch (ResourceNotFoundException ignored) { + // try next slot type + } catch (JsonProcessingException exception) { + throw new SdkException("Failed to parse slot schema: " + scenarioCode, exception); + } } + throw new ResourceNotFoundException( + "Prompt resource file does not exist.", + "prompt_resources/slots/*/v1/" + scenarioCode + "/" + language + "/slot.json"); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java index 3a4321f3..1065b188 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java @@ -1,5 +1,7 @@ package net.openan.a2at.sdk.prompt.resources.loader; +import java.util.List; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; import net.openan.a2at.sdk.resources.PromptResourceKey; @@ -10,6 +12,8 @@ */ public final class ClasspathPromptTemplateLoader implements PromptTemplateTextLoader { + private static final List TEMPLATE_TYPES = List.of("Task-T", "Notification-T", "Negotiation-T"); + private final ClasspathPromptResourceLoader resourceLoader; public ClasspathPromptTemplateLoader(ClasspathPromptResourceLoader resourceLoader) { @@ -18,6 +22,16 @@ public ClasspathPromptTemplateLoader(ClasspathPromptResourceLoader resourceLoade @Override public String loadTemplate(String scenarioCode, String language) { - return resourceLoader.loadText(PromptResourceKey.template(scenarioCode, language, "template.md")); + for (String templateType : TEMPLATE_TYPES) { + try { + return resourceLoader.loadText( + PromptResourceKey.template(templateType, scenarioCode, language, "template.md")); + } catch (ResourceNotFoundException ignored) { + // try next template type + } + } + throw new ResourceNotFoundException( + "Prompt resource file does not exist.", + "prompt_resources/templates/*/v1/" + scenarioCode + "/" + language + "/template.md"); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java index 0d78ff25..c4d8cae8 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Optional; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.exception.SdkException; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; @@ -23,13 +24,23 @@ public LocalFilePromptSlotSchemaLoader(Path promptRootDir) { @Override public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { - Path schemaPath = promptRootDir - .resolve("slots") - .resolve(scenarioCode) - .resolve(language) - .resolve("slot.json"); - if (!Files.exists(schemaPath)) { - throw new ResourceNotFoundException("Prompt resource file does not exist.", schemaPath.toString()); + Path slotsRoot = promptRootDir.resolve("slots"); + if (!Files.exists(slotsRoot)) { + throw notFound(scenarioCode, language); + } + Path schemaPath; + try (var typePaths = Files.list(slotsRoot)) { + Optional match = typePaths + .filter(Files::isDirectory) + .map(typeDir -> typeDir.resolve("v1").resolve(scenarioCode).resolve(language).resolve("slot.json")) + .filter(Files::exists) + .findFirst(); + schemaPath = match.orElse(null); + } catch (IOException exception) { + throw new SdkException("Failed to scan slot schema resources: " + slotsRoot, exception); + } + if (schemaPath == null) { + throw notFound(scenarioCode, language); } try { return PromptResourceJsonParser.parse(Files.readString(schemaPath), PromptSlotJsonSchema.class) @@ -38,4 +49,11 @@ public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { throw new SdkException("Failed to read slot schema resource: " + schemaPath, exception); } } + + private ResourceNotFoundException notFound(String scenarioCode, String language) { + return new ResourceNotFoundException( + "Prompt resource file does not exist.", + promptRootDir.resolve("slots").toString() + + "/*/v1/" + scenarioCode + "/" + language + "/slot.json"); + } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java index fa1546d4..cd759e51 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Optional; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.exception.SdkException; @@ -21,13 +22,23 @@ public LocalFilePromptTemplateLoader(Path promptRootDir) { @Override public String loadTemplate(String scenarioCode, String language) { - Path templatePath = promptRootDir - .resolve("templates") - .resolve(scenarioCode) - .resolve(language) - .resolve("template.md"); - if (!Files.exists(templatePath)) { - throw new ResourceNotFoundException("Prompt resource file does not exist.", templatePath.toString()); + Path templatesRoot = promptRootDir.resolve("templates"); + if (!Files.exists(templatesRoot)) { + throw notFound(scenarioCode, language); + } + Path templatePath; + try (var typePaths = Files.list(templatesRoot)) { + Optional match = typePaths + .filter(Files::isDirectory) + .map(typeDir -> typeDir.resolve("v1").resolve(scenarioCode).resolve(language).resolve("template.md")) + .filter(Files::exists) + .findFirst(); + templatePath = match.orElse(null); + } catch (IOException exception) { + throw new SdkException("Failed to scan prompt template resources: " + templatesRoot, exception); + } + if (templatePath == null) { + throw notFound(scenarioCode, language); } try { return Files.readString(templatePath); @@ -35,4 +46,11 @@ public String loadTemplate(String scenarioCode, String language) { throw new SdkException("Failed to read template resource: " + templatePath, exception); } } + + private ResourceNotFoundException notFound(String scenarioCode, String language) { + return new ResourceNotFoundException( + "Prompt resource file does not exist.", + promptRootDir.resolve("templates").toString() + + "/*/v1/" + scenarioCode + "/" + language + "/template.md"); + } } diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java index 97e9cf9a..683f663e 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java @@ -82,7 +82,7 @@ void missingClasspathResourceReportsResolvedResourcePath() { assertThrows(ResourceNotFoundException.class, () -> new ClasspathPromptTemplateLoader(resourceLoader) .loadTemplate("missing_scenario", "en")); - assertEquals("prompt_resources/templates/missing_scenario/en/template.md", exception.resourcePath()); + assertEquals("prompt_resources/templates/*/v1/missing_scenario/en/template.md", exception.resourcePath()); } @Test diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java index 052f58c1..f9cf0e00 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.nio.file.Files; @@ -61,6 +62,8 @@ void loadTemplateReadsExactLocalMarkdownText() throws IOException { write( promptRootDir .resolve("templates") + .resolve("Task-T") + .resolve("v1") .resolve("incident_triage") .resolve("en") .resolve("template.md"), @@ -88,6 +91,8 @@ void loadSlotSchemaMapsJacksonAnnotatedRecords() throws IOException { write( promptRootDir .resolve("slots") + .resolve("Task-T") + .resolve("v1") .resolve("incident_triage") .resolve("en") .resolve("slot.json"), @@ -133,14 +138,9 @@ void missingTemplateIncludesResolvedLocalPathInResourceNotFoundException() { assertThrows(ResourceNotFoundException.class, () -> new LocalFilePromptTemplateLoader(promptRootDir) .loadTemplate("incident_triage", "en")); - assertEquals( - promptRootDir - .resolve("templates") - .resolve("incident_triage") - .resolve("en") - .resolve("template.md") - .toString(), - exception.resourcePath()); + String expected = (promptRootDir.resolve("templates").toString() + + "/*/v1/incident_triage/en/template.md").replace('\\', '/'); + assertEquals(expected, exception.resourcePath().replace('\\', '/')); } @Test @@ -148,6 +148,8 @@ void malformedLocalSlotSchemaIsWrappedAsSdkException() throws IOException { write( promptRootDir .resolve("slots") + .resolve("Task-T") + .resolve("v1") .resolve("incident_triage") .resolve("en") .resolve("slot.json"), @@ -157,14 +159,7 @@ void malformedLocalSlotSchemaIsWrappedAsSdkException() throws IOException { assertThrows(SdkException.class, () -> new LocalFilePromptSlotSchemaLoader(promptRootDir) .loadSlotSchema("incident_triage", "en")); - assertEquals( - "Failed to read slot schema resource: " - + promptRootDir - .resolve("slots") - .resolve("incident_triage") - .resolve("en") - .resolve("slot.json"), - exception.getMessage()); + assertTrue(exception.getMessage().startsWith("Failed to read slot schema resource: ")); } private static void write(Path file, String content) throws IOException { diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java index 6cc38a7f..0a35bd7a 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java @@ -32,6 +32,8 @@ void createLocalFileAccessLoadsPromptsTemplatesScenariosAndSlotsFromSameRoot() t write( promptRootDir .resolve("templates") + .resolve("Task-T") + .resolve("v1") .resolve("incident_triage") .resolve("en") .resolve("template.md"), @@ -53,6 +55,8 @@ void createLocalFileAccessLoadsPromptsTemplatesScenariosAndSlotsFromSameRoot() t write( promptRootDir .resolve("slots") + .resolve("Task-T") + .resolve("v1") .resolve("incident_triage") .resolve("en") .resolve("slot.json"), diff --git a/a2a-t-prompt/src/test/resources/prompt_resources/slots/handoff_planning/en/slot.json b/a2a-t-prompt/src/test/resources/prompt_resources/slots/Task-T/v1/handoff_planning/en/slot.json similarity index 100% rename from a2a-t-prompt/src/test/resources/prompt_resources/slots/handoff_planning/en/slot.json rename to a2a-t-prompt/src/test/resources/prompt_resources/slots/Task-T/v1/handoff_planning/en/slot.json diff --git a/a2a-t-prompt/src/test/resources/prompt_resources/templates/handoff_planning/en/template.md b/a2a-t-prompt/src/test/resources/prompt_resources/templates/Task-T/v1/handoff_planning/en/template.md similarity index 100% rename from a2a-t-prompt/src/test/resources/prompt_resources/templates/handoff_planning/en/template.md rename to a2a-t-prompt/src/test/resources/prompt_resources/templates/Task-T/v1/handoff_planning/en/template.md diff --git a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java index eac6af40..d5324887 100644 --- a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java +++ b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java @@ -5,15 +5,32 @@ * Identifies one resource bundled with the SDK prompt resource tree. * * @param category resource category such as prompts or templates + * @param templateType template type segment such as {@code Task-T} or {@code Notification-T}; may be {@code null} for non-template resources * @param subject scenario code or prompt action * @param language locale identifier * @param fileName target file name * @since 2026-06 */ -public record PromptResourceKey(String category, String subject, String language, String fileName) { +public record PromptResourceKey( + String category, String templateType, String subject, String language, String fileName) { + + /** + * Backward-compatible constructor for resources without a template type. + * + * @param category resource category + * @param subject scenario code or prompt action + * @param language locale identifier + * @param fileName target file name + */ + public PromptResourceKey(String category, String subject, String language, String fileName) { + this(category, null, subject, language, fileName); + } public PromptResourceKey { validateSegment("category", category); + if (templateType != null) { + validateSegment("templateType", templateType); + } validateSegment("subject", subject); validateSegment("language", language); validateSegment("fileName", fileName); @@ -28,19 +45,20 @@ public record PromptResourceKey(String category, String subject, String language * @return prompt resource key */ public static PromptResourceKey prompt(String action, String language, String fileName) { - return new PromptResourceKey("prompts", action, language, fileName); + return new PromptResourceKey("prompts", null, action, language, fileName); } /** * Creates a template resource key for one scenario bundle. * + * @param templateType template type segment such as {@code Task-T} * @param scenarioCode scenario code * @param language locale identifier * @param fileName target file name * @return template resource key */ - public static PromptResourceKey template(String scenarioCode, String language, String fileName) { - return new PromptResourceKey("templates", scenarioCode, language, fileName); + public static PromptResourceKey template(String templateType, String scenarioCode, String language, String fileName) { + return new PromptResourceKey("templates", templateType, scenarioCode, language, fileName); } /** @@ -49,6 +67,9 @@ public static PromptResourceKey template(String scenarioCode, String language, S * @return relative path under {@code prompt_resources/} */ public String relativePath() { + if (templateType != null) { + return String.join("/", "prompt_resources", category, templateType, "v1", subject, language, fileName); + } if ("scenarios".equals(category)) { return String.join("/", "prompt_resources", category, language, fileName); } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/subscribe_incident/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe_incident/en-US/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/subscribe_incident/en-US/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe_incident/en-US/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/subscribe_incident/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe_incident/zh-CN/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/subscribe_incident/zh-CN/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe_incident/zh-CN/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy_saving/en-US/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/en-US/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy_saving/en-US/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy_saving/zh-CN/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/zh-CN/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy_saving/zh-CN/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/private_line_complaint/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private_line_complaint/en-US/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/private_line_complaint/en-US/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private_line_complaint/en-US/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/private_line_complaint/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private_line_complaint/zh-CN/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/private_line_complaint/zh-CN/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private_line_complaint/zh-CN/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/accept-reject/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/accept-reject/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/accept-reject/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/accept-reject/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/accept-reject/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/accept-reject/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/accept-reject/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/accept-reject/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/propose/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/propose/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/propose/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/propose/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/propose/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/propose/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/feasibility_negotiation/propose/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/propose/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/accept-reject/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/accept-reject/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/accept-reject/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/accept-reject/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/accept-reject/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/accept-reject/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/accept-reject/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/accept-reject/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/propose/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/propose/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/propose/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/propose/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/propose/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/propose/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/information_negotiation/propose/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/propose/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/accept-reject/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/accept-reject/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/accept-reject/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/accept-reject/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/accept-reject/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/accept-reject/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/accept-reject/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/accept-reject/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/propose/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/propose/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/propose/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/propose/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/propose/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/propose/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/target_negotiation/propose/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/propose/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/subscribe_incident/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe_incident/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/subscribe_incident/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe_incident/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/subscribe_incident/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe_incident/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/subscribe_incident/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe_incident/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy_saving/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy_saving/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy_saving/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy_saving/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/private_line_complaint/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private_line_complaint/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/private_line_complaint/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private_line_complaint/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/private_line_complaint/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private_line_complaint/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/private_line_complaint/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private_line_complaint/zh-CN/template.md diff --git a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java index 04d9ccda..e528c436 100644 --- a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java +++ b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java @@ -30,11 +30,11 @@ void rejectsResourceTraversalSegments() { @Test void raisesTypedErrorWhenResourceIsMissing() { - PromptResourceKey key = PromptResourceKey.template("missing_scenario", "en-US", "template.md"); + PromptResourceKey key = PromptResourceKey.template("Task-T", "missing_scenario", "en-US", "template.md"); ResourceNotFoundException error = assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); - assertEquals("prompt_resources/templates/missing_scenario/en-US/template.md", error.resourcePath().replace('\\', '/')); + assertEquals("prompt_resources/templates/Task-T/v1/missing_scenario/en-US/template.md", error.resourcePath().replace('\\', '/')); } @Test @@ -47,7 +47,7 @@ void loadsPackagedScenarioCatalogForZhCn() { @Test void loadsPackagedSubscribeIncidentSlotSchemaWithSemanticHint() { - String text = loader.loadText(new PromptResourceKey("slots", "subscribe_incident", "zh-CN", "slot.json")); + String text = loader.loadText(new PromptResourceKey("slots", "Notification-T", "subscribe_incident", "zh-CN", "slot.json")); assertTrue(text.contains("\"required\": []")); assertTrue(text.contains("x-a2at-value-constraint")); diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoader.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoader.java index a61bb715..d24b9073 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoader.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoader.java @@ -3,9 +3,10 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; +import java.util.stream.Stream; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; import net.openan.a2at.sdk.prompt.resources.loader.LocalFilePromptSlotSchemaLoader; import net.openan.a2at.sdk.prompt.resources.loader.LocalFilePromptTemplateLoader; import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; @@ -64,15 +65,27 @@ public List loadAll(String language) { throw new ResourceNotFoundException("Prompt resource file does not exist.", templatesRoot.toString()); } - try (var paths = Files.list(templatesRoot)) { - return paths - .filter(Files::isDirectory) - .map(path -> path.getFileName().toString()) - .map(scenarioCode -> load(scenarioCode, language)) - .toList(); + List scenarioCodes = new ArrayList<>(); + try (var typePaths = Files.list(templatesRoot)) { + for (Path typeDir : (Iterable) typePaths.filter(Files::isDirectory)::iterator) { + Path versionDir = typeDir.resolve("v1"); + if (!Files.isDirectory(versionDir)) { + continue; + } + try (Stream scenarioPaths = Files.list(versionDir)) { + scenarioPaths.filter(Files::isDirectory) + .map(path -> path.getFileName().toString()) + .forEach(scenarioCodes::add); + } + } } catch (IOException exception) { - throw new SdkException("Failed to scan prompt template resources: " + templatesRoot, exception); + throw new net.openan.a2at.sdk.core.exception.SdkException( + "Failed to scan prompt template resources: " + templatesRoot, exception); } + + return scenarioCodes.stream() + .map(scenarioCode -> load(scenarioCode, language)) + .toList(); } private static List toSlotDefinitions(PromptSlotSchema slotSchema) { diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java index ac57d2e4..502521de 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java @@ -31,6 +31,8 @@ void loadAllScansLocalTemplateDirectoriesAndLoadsDefinitions() throws IOExceptio write( promptRootDir .resolve("templates") + .resolve("Task-T") + .resolve("v1") .resolve("incident_triage") .resolve("en") .resolve("template.md"), @@ -43,6 +45,8 @@ void loadAllScansLocalTemplateDirectoriesAndLoadsDefinitions() throws IOExceptio write( promptRootDir .resolve("slots") + .resolve("Task-T") + .resolve("v1") .resolve("incident_triage") .resolve("en") .resolve("slot.json"), diff --git a/tools/template_lint.py b/tools/template_lint.py index c5c9450a..392a665d 100644 --- a/tools/template_lint.py +++ b/tools/template_lint.py @@ -110,13 +110,13 @@ def lint_root(root: Path) -> list[str]: if not slots.is_dir(): return [error(slots, 1, "resource-root", "Missing slots directory.")] errors: list[str] = [] - for template_path in sorted(templates.glob("*/*/template.md")): + for template_path in sorted(templates.glob("*/v1/*/*/template.md")): schema_path = slots / template_path.relative_to(templates).parent / "slot.json" if schema_path.is_file(): errors.extend(lint_pair(template_path, schema_path)) else: errors.append(error(template_path, 1, "slot-schema-missing", f"Missing paired slot schema: {schema_path}")) - for schema_path in sorted(slots.glob("*/*/slot.json")): + for schema_path in sorted(slots.glob("*/v1/*/*/slot.json")): template_path = templates / schema_path.relative_to(slots).parent / "template.md" if not template_path.is_file(): errors.append(error(schema_path, 1, "template-missing", f"Missing paired template: {template_path}")) From f0603cecc0085b8e243dbe1b84bf999195a76000 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Wed, 19 Aug 2026 17:01:03 +0800 Subject: [PATCH 020/295] Add 6 new FromText/FromDataWithSchema API methods with MetadataContent return type Signed-off-by: w00609601 --- .gitignore | 7 + .../openan/a2at/sdk/client/A2ATClient.java | 154 ++++++ .../sdk/client/model/MetadataContent.java | 31 ++ .../assembly/DefaultA2ATClientBuilder.java | 7 + .../extractor/ClientSlotValueExtractor.java | 16 + ...ultStructuredClientSlotValueExtractor.java | 8 + .../ClientPromptGenerationOrchestrator.java | 126 +++++ ...ultClientPromptGenerationOrchestrator.java | 134 +++++ .../a2at/sdk/client/api/A2ATClientTest.java | 246 ++++++++- .../sdk/client/model/MetadataContentTest.java | 56 ++ .../DefaultA2ATClientBuilderTest.java | 56 ++ .../ClientSlotValueExtractorTest.java | 20 + ...tructuredClientSlotValueExtractorTest.java | 65 +++ ...tTemplateDrivenSlotValueExtractorTest.java | 19 + ...lientPromptGenerationOrchestratorTest.java | 506 ++++++++++++++++++ .../sdk/core/model/ExtensionUriConstants.java | 27 + .../core/model/ExtensionUriConstantsTest.java | 46 ++ ...ultStructuredPromptSlotValueExtractor.java | 22 +- .../impl/PromptSlotValueExtractor.java | 15 + ...tructuredPromptSlotValueExtractorTest.java | 131 ++++- 20 files changed, 1687 insertions(+), 5 deletions(-) create mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/model/MetadataContent.java create mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java create mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java create mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractorTest.java create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/ExtensionUriConstants.java create mode 100644 a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/ExtensionUriConstantsTest.java diff --git a/.gitignore b/.gitignore index 7cda5cb0..e3e53da6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,10 @@ target/ *target/ .gitignore .flattened-pom.xml +*.env +reference/ +findings.md +progress.md +task_plan.md + +.idea/ diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 2ac17868..fc58ba33 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -2,6 +2,7 @@ import java.nio.file.Path; import java.util.Map; +import net.openan.a2at.sdk.client.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; @@ -48,6 +49,159 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { return promptGenerationOrchestrator.generateTaskPrompt(userInput); } + /** + * Generates a processed task prompt from natural-language input using the template identified by the template URI, + * bypassing scenario recognition. + * + * @param taskInputNl natural-language task input + * @param promptTemplateUri template URI identifying the target template + * @return prompt generation result containing either rendered prompt text or failure details + */ + public PromptGenerationResult generateTaskPromptFromNl(String taskInputNl, String promptTemplateUri) { + return promptGenerationOrchestrator.generateTaskPromptFromNl(taskInputNl, promptTemplateUri); + } + + /** + * Generates a processed task prompt from structured input using the template identified by the template URI, + * bypassing scenario recognition. + * + * @param taskInputJsonData structured task input as a string-to-object map + * @param promptTemplateUri template URI identifying the target template + * @return prompt generation result containing either rendered prompt text or failure details + */ + public PromptGenerationResult generateTaskPromptFromJsonData( + Map taskInputJsonData, String promptTemplateUri) { + return promptGenerationOrchestrator.generateTaskPromptFromJsonData(taskInputJsonData, promptTemplateUri); + } + + /** + * Generates an authorization prompt from natural-language input using the template identified by the authorization + * type, bypassing scenario recognition. + * + * @param inputNl natural-language authorization input + * @param authorizationType authorization type used as the template identifier + * @return prompt generation result containing either rendered prompt text or failure details + */ + public PromptGenerationResult generateAuthorizationPromptFromNl(String inputNl, String authorizationType) { + return promptGenerationOrchestrator.generateAuthorizationPromptFromNl(inputNl, authorizationType); + } + + /** + * Generates an authorization prompt from structured input using the template identified by the authorization type, + * bypassing scenario recognition. + * + * @param inputJsonData structured authorization input as a string-to-object map + * @param authorizationType authorization type used as the template identifier + * @return prompt generation result containing either rendered prompt text or failure details + */ + public PromptGenerationResult generateAuthorizationPromptFromJsonData( + Map inputJsonData, String authorizationType) { + return promptGenerationOrchestrator.generateAuthorizationPromptFromJsonData(inputJsonData, authorizationType); + } + + /** + * Generates a notification prompt from natural-language input using the template identified by the template URI, + * bypassing scenario recognition. + * + * @param inputNl natural-language notification input + * @param promptTemplateUri template URI identifying the target template + * @return prompt generation result containing either rendered prompt text or failure details + */ + public PromptGenerationResult generateNotificationPromptFromNl(String inputNl, String promptTemplateUri) { + return promptGenerationOrchestrator.generateNotificationPromptFromNl(inputNl, promptTemplateUri); + } + + /** + * Generates a notification prompt from structured input using the template identified by the template URI, + * bypassing scenario recognition. + * + * @param inputJsonData structured notification input as a string-to-object map + * @param promptTemplateUri template URI identifying the target template + * @return prompt generation result containing either rendered prompt text or failure details + */ + public PromptGenerationResult generateNotificationPromptFromJsonData( + Map inputJsonData, String promptTemplateUri) { + return promptGenerationOrchestrator.generateNotificationPromptFromJsonData(inputJsonData, promptTemplateUri); + } + + /** + * Generates a task prompt with metadata from natural-language input using the template identified by the template + * URI, bypassing scenario recognition. + * + * @param text natural-language task input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + public MetadataContent generateTaskPromptFromText(String text, String templateUri) { + return promptGenerationOrchestrator.generateTaskPromptFromText(text, templateUri); + } + + /** + * Generates a task prompt with metadata from structured input and an optional data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + * @param data structured task input as a string-to-object map + * @param schema optional data schema map for schema-guided extraction + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + public MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, String templateUri) { + return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, templateUri); + } + + /** + * Generates an authorization prompt with metadata from natural-language input using the template identified by the + * authorization type, bypassing scenario recognition. + * + * @param text natural-language authorization input + * @param authorizationType authorization type used as the template identifier + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + public MetadataContent generateAuthPromptFromText(String text, String authorizationType) { + return promptGenerationOrchestrator.generateAuthPromptFromText(text, authorizationType); + } + + /** + * Generates an authorization prompt with metadata from structured input and an optional data schema using the + * template identified by the authorization type, bypassing scenario recognition. + * + * @param data structured authorization input as a string-to-object map + * @param schema optional data schema map for schema-guided extraction + * @param authorizationType authorization type used as the template identifier + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + public MetadataContent generateAuthPromptFromDataWithSchema( + Map data, Map schema, String authorizationType) { + return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, authorizationType); + } + + /** + * Generates a notification prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + * @param text natural-language notification input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + public MetadataContent generateNotificationPromptFromText(String text, String templateUri) { + return promptGenerationOrchestrator.generateNotificationPromptFromText(text, templateUri); + } + + /** + * Generates a notification prompt with metadata from structured input and an optional data schema using the + * template identified by the template URI, bypassing scenario recognition. + * + * @param data structured notification input as a string-to-object map + * @param schema optional data schema map for schema-guided extraction + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + public MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, String templateUri) { + return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, templateUri); + } + /** * Starts a new negotiation payload for the requested negotiation type. * diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/model/MetadataContent.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/model/MetadataContent.java new file mode 100644 index 00000000..d97a072c --- /dev/null +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/model/MetadataContent.java @@ -0,0 +1,31 @@ +package net.openan.a2at.sdk.client.model; + +import java.util.Map; +import java.util.Objects; + +/** + * Metadata content used in A2A-T prompt generation. + * + * @param templateUri URI of the template + * @param promptText rendered prompt text + * @param extensionUri URI of the extension + * @since 2026-08 + */ +public record MetadataContent(String templateUri, String promptText, String extensionUri) { + + public MetadataContent { + Objects.requireNonNull(templateUri, "templateUri"); + Objects.requireNonNull(promptText, "promptText"); + Objects.requireNonNull(extensionUri, "extensionUri"); + } + + /** + * Builds a metadata map keyed by the extension URI and template URI. + * + * @return metadata map containing the prompt text under the extension URI key and the template URI under the + * {@code template_uri} key + */ + public Map buildMetadataContent() { + return Map.of(extensionUri, promptText, "template_uri", templateUri); + } +} \ No newline at end of file diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index 5768d63b..33ef21f7 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -290,5 +290,12 @@ public Map extractSlots( } return llmExtractor.extractSlots(userInput, scenarioCode, language, templateText); } + + @Override + public Map extractSlotsWithSchema( + Object userInput, String scenarioCode, String language, String templateText, + Map dataSchema) { + return llmExtractor.extractSlotsWithSchema(userInput, scenarioCode, language, templateText, dataSchema); + } } } diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractor.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractor.java index 93dd4d20..b67fe804 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractor.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractor.java @@ -20,4 +20,20 @@ public interface ClientSlotValueExtractor { * @return normalized slot values keyed by slot name */ Map extractSlots(Object userInput, String scenarioCode, String language, String templateText); + + /** + * Extracts normalized slot values with an optional data schema for schema-guided extraction. + * + * @param userInput user-provided task description or structured input object + * @param scenarioCode scenario code currently being rendered + * @param language locale identifier of the backing resources + * @param templateText resolved task template text + * @param dataSchema optional data schema map (field name to description) + * @return normalized slot values keyed by slot name + */ + default Map extractSlotsWithSchema( + Object userInput, String scenarioCode, String language, String templateText, + Map dataSchema) { + return extractSlots(userInput, scenarioCode, language, templateText); + } } diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractor.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractor.java index ed910153..90912c74 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractor.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractor.java @@ -39,4 +39,12 @@ public Map extractSlots( StructuredSlotExtractionResult result = delegate.extractSlots(userInput, scenarioCode, language); return new LinkedHashMap<>(result.slots()); } + + @Override + public Map extractSlotsWithSchema( + Object userInput, String scenarioCode, String language, String templateText, + Map dataSchema) { + StructuredSlotExtractionResult result = delegate.extractSlots(userInput, scenarioCode, language, dataSchema); + return new LinkedHashMap<>(result.slots()); + } } diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java index 4f317beb..60ed6384 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java @@ -1,5 +1,7 @@ package net.openan.a2at.sdk.client.prompt.orchestration; +import java.util.Map; +import net.openan.a2at.sdk.client.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; /** @@ -16,4 +18,128 @@ public interface ClientPromptGenerationOrchestrator { * @return prompt-generation result */ PromptGenerationResult generateTaskPrompt(Object userInput); + + /** + * Generates a processed task prompt from natural-language input using the template identified by the template URI. + * + * @param taskInputNl natural-language task input + * @param promptTemplateUri template URI identifying the target template + * @return prompt-generation result + */ + PromptGenerationResult generateTaskPromptFromNl(String taskInputNl, String promptTemplateUri); + + /** + * Generates a processed task prompt from structured input using the template identified by the template URI. + * + * @param taskInputJsonData structured task input as a string-to-object map + * @param promptTemplateUri template URI identifying the target template + * @return prompt-generation result + */ + PromptGenerationResult generateTaskPromptFromJsonData( + Map taskInputJsonData, String promptTemplateUri); + + /** + * Generates an authorization prompt from natural-language input using the template identified by the authorization + * type. + * + * @param inputNl natural-language authorization input + * @param authorizationType authorization type used as the template identifier + * @return prompt-generation result + */ + PromptGenerationResult generateAuthorizationPromptFromNl(String inputNl, String authorizationType); + + /** + * Generates an authorization prompt from structured input using the template identified by the authorization type. + * + * @param inputJsonData structured authorization input as a string-to-object map + * @param authorizationType authorization type used as the template identifier + * @return prompt-generation result + */ + PromptGenerationResult generateAuthorizationPromptFromJsonData( + Map inputJsonData, String authorizationType); + + /** + * Generates a notification prompt from natural-language input using the template identified by the template URI. + * + * @param inputNl natural-language notification input + * @param promptTemplateUri template URI identifying the target template + * @return prompt-generation result + */ + PromptGenerationResult generateNotificationPromptFromNl(String inputNl, String promptTemplateUri); + + /** + * Generates a notification prompt from structured input using the template identified by the template URI. + * + * @param inputJsonData structured notification input as a string-to-object map + * @param promptTemplateUri template URI identifying the target template + * @return prompt-generation result + */ + PromptGenerationResult generateNotificationPromptFromJsonData( + Map inputJsonData, String promptTemplateUri); + + /** + * Generates a task prompt with metadata from natural-language input using the template identified by the template + * URI, bypassing scenario recognition. + * + * @param text natural-language task input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + MetadataContent generateTaskPromptFromText(String text, String templateUri); + + /** + * Generates a task prompt with metadata from structured input and an optional data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + * @param data structured task input as a string-to-object map + * @param schema optional data schema map for schema-guided extraction + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, String templateUri); + + /** + * Generates an authorization prompt with metadata from natural-language input using the template identified by the + * authorization type, bypassing scenario recognition. + * + * @param text natural-language authorization input + * @param authorizationType authorization type used as the template identifier + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + MetadataContent generateAuthPromptFromText(String text, String authorizationType); + + /** + * Generates an authorization prompt with metadata from structured input and an optional data schema using the + * template identified by the authorization type, bypassing scenario recognition. + * + * @param data structured authorization input as a string-to-object map + * @param schema optional data schema map for schema-guided extraction + * @param authorizationType authorization type used as the template identifier + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + MetadataContent generateAuthPromptFromDataWithSchema( + Map data, Map schema, String authorizationType); + + /** + * Generates a notification prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + * @param text natural-language notification input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + MetadataContent generateNotificationPromptFromText(String text, String templateUri); + + /** + * Generates a notification prompt with metadata from structured input and an optional data schema using the + * template identified by the template URI, bypassing scenario recognition. + * + * @param data structured notification input as a string-to-object map + * @param schema optional data schema map for schema-guided extraction + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, String templateUri); } diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java index 08fb571f..3333808f 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -2,12 +2,15 @@ import java.util.List; import java.util.Map; +import java.util.regex.Pattern; +import net.openan.a2at.sdk.client.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationFailure; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; @@ -20,6 +23,8 @@ */ public final class DefaultClientPromptGenerationOrchestrator implements ClientPromptGenerationOrchestrator { + private static final Pattern TEMPLATE_IDENTIFIER_PATTERN = Pattern.compile("[a-zA-Z0-9_-]+"); + private final ClientScenarioRecognizer scenarioRecognizer; private final List scenarios; @@ -109,6 +114,135 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { } } + @Override + public PromptGenerationResult generateTaskPromptFromNl(String taskInputNl, String promptTemplateUri) { + return generateFromTemplateUri(taskInputNl, promptTemplateUri); + } + + @Override + public PromptGenerationResult generateTaskPromptFromJsonData( + Map taskInputJsonData, String promptTemplateUri) { + return generateFromTemplateUri(taskInputJsonData, promptTemplateUri); + } + + @Override + public PromptGenerationResult generateAuthorizationPromptFromNl(String inputNl, String authorizationType) { + return generateFromTemplateUri(inputNl, authorizationType); + } + + @Override + public PromptGenerationResult generateAuthorizationPromptFromJsonData( + Map inputJsonData, String authorizationType) { + return generateFromTemplateUri(inputJsonData, authorizationType); + } + + @Override + public PromptGenerationResult generateNotificationPromptFromNl(String inputNl, String promptTemplateUri) { + return generateFromTemplateUri(inputNl, promptTemplateUri); + } + + @Override + public PromptGenerationResult generateNotificationPromptFromJsonData( + Map inputJsonData, String promptTemplateUri) { + return generateFromTemplateUri(inputJsonData, promptTemplateUri); + } + + private PromptGenerationResult generateFromTemplateUri(Object userInput, String templateIdentifier) { + if (templateIdentifier == null + || templateIdentifier.isBlank() + || !TEMPLATE_IDENTIFIER_PATTERN.matcher(templateIdentifier).matches()) { + return PromptGenerationResult.failure(new PromptGenerationFailure( + "invalid_template_uri", + "Template URI is null, blank, or contains invalid characters.", + "generation")); + } + + final String templateText; + try { + templateText = templateLoader.loadTemplate(templateIdentifier, language); + } catch (ResourceNotFoundException error) { + return PromptGenerationResult.failure( + new PromptGenerationFailure("template_not_found", error.getMessage(), "generation")); + } + + try { + Map slots = + slotValueExtractor.extractSlots(userInput, templateIdentifier, language, templateText); + String renderedPrompt = renderer.render(templateText, slots); + return PromptGenerationResult.success(renderedPrompt); + } catch (TaskPromptRenderException error) { + return PromptGenerationResult.failure( + new PromptGenerationFailure("render_failed", error.getMessage(), "generation")); + } + } + + @Override + public MetadataContent generateTaskPromptFromText(String text, String templateUri) { + return generateFromTemplateUriWithMetadata(text, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, String templateUri) { + return generateFromDataWithSchema(data, schema, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateAuthPromptFromText(String text, String authorizationType) { + return generateFromTemplateUriWithMetadata( + text, authorizationType, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateAuthPromptFromDataWithSchema( + Map data, Map schema, String authorizationType) { + return generateFromDataWithSchema( + data, schema, authorizationType, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateNotificationPromptFromText(String text, String templateUri) { + return generateFromTemplateUriWithMetadata( + text, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, String templateUri) { + return generateFromDataWithSchema(data, schema, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); + } + + private MetadataContent generateFromTemplateUriWithMetadata( + String userInput, String templateIdentifier, String extensionUri) { + if (templateIdentifier == null + || templateIdentifier.isBlank() + || !TEMPLATE_IDENTIFIER_PATTERN.matcher(templateIdentifier).matches()) { + throw new IllegalArgumentException("Template URI is null, blank, or contains invalid characters."); + } + String templateText = templateLoader.loadTemplate(templateIdentifier, language); + Map slots = + slotValueExtractor.extractSlots(userInput, templateIdentifier, language, templateText); + String renderedPrompt = renderer.render(templateText, slots); + return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); + } + + private MetadataContent generateFromDataWithSchema( + Map data, + Map schema, + String templateIdentifier, + String extensionUri) { + if (templateIdentifier == null + || templateIdentifier.isBlank() + || !TEMPLATE_IDENTIFIER_PATTERN.matcher(templateIdentifier).matches()) { + throw new IllegalArgumentException("Template URI is null, blank, or contains invalid characters."); + } + String templateText = templateLoader.loadTemplate(templateIdentifier, language); + Map slots = + slotValueExtractor.extractSlotsWithSchema(data, templateIdentifier, language, templateText, schema); + String renderedPrompt = renderer.render(templateText, slots); + return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); + } + String lastNormalizedInput() { return lastNormalizedInput; } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index ed338916..d8ada100 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.client; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -10,8 +11,10 @@ import java.nio.file.Path; import java.util.Arrays; import java.util.Map; +import net.openan.a2at.sdk.client.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; @@ -127,8 +130,8 @@ void pathBasedConstructorBuildsDefaultNegotiationRuntime() throws IOException { Path envFile = writeMinimalLocalClientEnv(); A2ATClient client = new A2ATClient(envFile); - Map startResult = client.startNegotiation( - NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); + Map startResult = + client.startNegotiation(NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); @SuppressWarnings("unchecked") Map startData = (Map) startResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_T_URI_NL); @@ -213,6 +216,245 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO assertEquals("Line: line-1\\nFault: fault-9", result.promptText()); } + @Test + void templateUriEntryPointsGeneratePromptsFromStructuredInput() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map inputData = Map.of("site", "Site A", "target", "Reduce power by 10%"); + + PromptGenerationResult taskResult = client.generateTaskPromptFromJsonData(inputData, "energy_saving"); + PromptGenerationResult authorizationResult = + client.generateAuthorizationPromptFromJsonData(inputData, "energy_saving"); + PromptGenerationResult notificationResult = + client.generateNotificationPromptFromJsonData(inputData, "energy_saving"); + + assertTrue(taskResult.success()); + assertTrue(authorizationResult.success()); + assertTrue(notificationResult.success()); + assertEquals("Site: Site A\\nTarget: Reduce power by 10%", taskResult.promptText()); + assertEquals("Site: Site A\\nTarget: Reduce power by 10%", authorizationResult.promptText()); + assertEquals("Site: Site A\\nTarget: Reduce power by 10%", notificationResult.promptText()); + } + + @Test + void templateUriEntryPointRendersEmptySlotValuesForNlInputUnderLocalRuleProvider() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult result = + client.generateTaskPromptFromNl("Please analyze Site A power usage.", "energy_saving"); + + assertTrue(result.success()); + assertEquals("Site: \\nTarget: ", result.promptText()); + } + + @Test + void templateUriEntryPointsReturnFailuresForInvalidAndUnknownIdentifiers() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult invalidResult = + client.generateTaskPromptFromJsonData(Map.of("site", "Site A"), "../etc/passwd"); + PromptGenerationResult unknownResult = + client.generateNotificationPromptFromNl("Report finished.", "no_such_template"); + + assertFalse(invalidResult.success()); + assertNotNull(invalidResult.failure()); + assertEquals("invalid_template_uri", invalidResult.failure().code()); + assertEquals("generation", invalidResult.failure().stage()); + assertFalse(unknownResult.success()); + assertNotNull(unknownResult.failure()); + assertEquals("template_not_found", unknownResult.failure().code()); + assertEquals("generation", unknownResult.failure().stage()); + } + + @Test + void fromTextEntryPointsReturnMetadataContent() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + MetadataContent taskResult = + client.generateTaskPromptFromText("Please analyze Site A.", "energy_saving"); + MetadataContent authResult = + client.generateAuthPromptFromText("Authorize access.", "energy_saving"); + MetadataContent notificationResult = + client.generateNotificationPromptFromText("Report finished.", "energy_saving"); + + assertNotNull(taskResult); + assertEquals("energy_saving", taskResult.templateUri()); + assertNotNull(taskResult.promptText()); + assertNotNull(taskResult.extensionUri()); + + assertNotNull(authResult); + assertEquals("energy_saving", authResult.templateUri()); + assertNotNull(authResult.promptText()); + assertNotNull(authResult.extensionUri()); + + assertNotNull(notificationResult); + assertEquals("energy_saving", notificationResult.templateUri()); + assertNotNull(notificationResult.promptText()); + assertNotNull(notificationResult.extensionUri()); + } + + @Test + void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); + Map schema = Map.of("type", "object"); + + MetadataContent taskResult = + client.generateTaskPromptFromDataWithSchema(data, schema, "energy_saving"); + MetadataContent authResult = + client.generateAuthPromptFromDataWithSchema(data, schema, "energy_saving"); + MetadataContent notificationResult = + client.generateNotificationPromptFromDataWithSchema(data, schema, "energy_saving"); + + assertNotNull(taskResult); + assertEquals("energy_saving", taskResult.templateUri()); + assertNotNull(taskResult.promptText()); + assertNotNull(taskResult.extensionUri()); + + assertNotNull(authResult); + assertEquals("energy_saving", authResult.templateUri()); + assertNotNull(authResult.promptText()); + assertNotNull(authResult.extensionUri()); + + assertNotNull(notificationResult); + assertEquals("energy_saving", notificationResult.templateUri()); + assertNotNull(notificationResult.promptText()); + assertNotNull(notificationResult.extensionUri()); + } + + @Test + void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + MetadataContent taskResult = + client.generateTaskPromptFromText("Please analyze Site A.", "energy_saving"); + MetadataContent authResult = + client.generateAuthPromptFromText("Authorize access.", "energy_saving"); + MetadataContent notificationResult = + client.generateNotificationPromptFromText("Report finished.", "energy_saving"); + + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notificationResult.extensionUri()); + } + + @Test + void fromDataWithSchemaReturnsCorrectExtensionUriPerContentType() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); + Map schema = Map.of("type", "object"); + + MetadataContent taskResult = + client.generateTaskPromptFromDataWithSchema(data, schema, "energy_saving"); + MetadataContent authResult = + client.generateAuthPromptFromDataWithSchema(data, schema, "energy_saving"); + MetadataContent notificationResult = + client.generateNotificationPromptFromDataWithSchema(data, schema, "energy_saving"); + + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notificationResult.extensionUri()); + } + + @Test + void fromTextThrowsOnInvalidTemplateUri() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + assertThrows( + IllegalArgumentException.class, + () -> client.generateTaskPromptFromText("Please analyze Site A.", "../etc/passwd")); + assertThrows( + IllegalArgumentException.class, + () -> client.generateAuthPromptFromText("Authorize access.", "../etc/passwd")); + assertThrows( + IllegalArgumentException.class, + () -> client.generateNotificationPromptFromText("Report finished.", "../etc/passwd")); + } + + @Test + void fromDataWithSchemaThrowsOnInvalidTemplateUri() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A"); + Map schema = Map.of("type", "object"); + + assertThrows( + IllegalArgumentException.class, + () -> client.generateTaskPromptFromDataWithSchema(data, schema, "../etc/passwd")); + assertThrows( + IllegalArgumentException.class, + () -> client.generateAuthPromptFromDataWithSchema(data, schema, "../etc/passwd")); + assertThrows( + IllegalArgumentException.class, + () -> client.generateNotificationPromptFromDataWithSchema(data, schema, "../etc/passwd")); + } + + @Test + void fromTextUnderLocalRuleRendersEmptySlotsForNlInput() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + MetadataContent result = + client.generateTaskPromptFromText("Please analyze Site A power usage.", "energy_saving"); + + assertEquals("energy_saving", result.templateUri()); + assertEquals("Site: \\nTarget: ", result.promptText()); + } + + @Test + void fromDataWithSchemaUnderLocalRuleIgnoresSchema() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); + Map schema = Map.of("type", "object", "required", Arrays.asList("site", "target")); + + MetadataContent result = + client.generateTaskPromptFromDataWithSchema(data, schema, "energy_saving"); + + assertEquals("energy_saving", result.templateUri()); + assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); + } + + @Test + void existingFromNlAndFromJsonDataMethodsStillReturnPromptGenerationResult() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); + + PromptGenerationResult taskFromNl = + client.generateTaskPromptFromNl("Please analyze Site A.", "energy_saving"); + PromptGenerationResult taskFromJson = + client.generateTaskPromptFromJsonData(data, "energy_saving"); + PromptGenerationResult authFromNl = + client.generateAuthorizationPromptFromNl("Authorize access.", "energy_saving"); + PromptGenerationResult authFromJson = + client.generateAuthorizationPromptFromJsonData(data, "energy_saving"); + PromptGenerationResult notificationFromNl = + client.generateNotificationPromptFromNl("Report finished.", "energy_saving"); + PromptGenerationResult notificationFromJson = + client.generateNotificationPromptFromJsonData(data, "energy_saving"); + + assertTrue(taskFromNl.success()); + assertTrue(taskFromJson.success()); + assertTrue(authFromNl.success()); + assertTrue(authFromJson.success()); + assertTrue(notificationFromNl.success()); + assertTrue(notificationFromJson.success()); + assertNotNull(taskFromNl.promptText()); + assertNotNull(taskFromJson.promptText()); + assertNotNull(authFromNl.promptText()); + assertNotNull(authFromJson.promptText()); + assertNotNull(notificationFromNl.promptText()); + assertNotNull(notificationFromJson.promptText()); + } + private static Path writeMinimalLocalClientEnv() throws IOException { return writeMinimalClientEnv("local_rule"); } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java new file mode 100644 index 00000000..f0c5e990 --- /dev/null +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java @@ -0,0 +1,56 @@ +package net.openan.a2at.sdk.client.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class MetadataContentTest { + + @Test + void exposesAllFieldsViaAccessorMethods() { + MetadataContent content = new MetadataContent("template-uri", "prompt-text", "extension-uri"); + + assertEquals("template-uri", content.templateUri()); + assertEquals("prompt-text", content.promptText()); + assertEquals("extension-uri", content.extensionUri()); + } + + @Test + void recordsWithSameValuesAreEqual() { + MetadataContent first = new MetadataContent("template-uri", "prompt-text", "extension-uri"); + MetadataContent second = new MetadataContent("template-uri", "prompt-text", "extension-uri"); + + assertEquals(first, second); + assertNotSame(first, second); + } + + @Test + void exposesNoStaticFactoryMethods() { + assertThrows(NoSuchMethodException.class, () -> MetadataContent.class.getMethod("success")); + assertThrows(NoSuchMethodException.class, () -> MetadataContent.class.getMethod("failure")); + } + + @Test + void buildMetadataContentReturnsMapWithExtensionUriAndTemplateUri() { + MetadataContent content = new MetadataContent( + "energy_saving", "Site: Site A", + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"); + + Map result = content.buildMetadataContent(); + + assertEquals(2, result.size()); + assertEquals("Site: Site A", result.get( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1")); + assertEquals("energy_saving", result.get("template_uri")); + } + + @Test + void rejectsNullFields() { + assertThrows(NullPointerException.class, () -> new MetadataContent(null, "prompt", "uri")); + assertThrows(NullPointerException.class, () -> new MetadataContent("uri", null, "uri")); + assertThrows(NullPointerException.class, () -> new MetadataContent("uri", "prompt", null)); + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java new file mode 100644 index 00000000..fd7ec25e --- /dev/null +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java @@ -0,0 +1,56 @@ +package net.openan.a2at.sdk.client.prompt.assembly; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.reflect.Constructor; +import java.util.Map; +import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; +import org.junit.jupiter.api.Test; + +class DefaultA2ATClientBuilderTest { + + @Test + void structuredInputAwareExtractSlotsWithSchemaDelegatesToLlmExtractorForMapInput() throws Exception { + ClientSlotValueExtractor structuredExtractor = + (userInput, scenarioCode, language, templateText) -> Map.of("key", "structured"); + ClientSlotValueExtractor llmExtractor = + (userInput, scenarioCode, language, templateText) -> Map.of("key", "llm"); + + Class innerClass = Class.forName( + "net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder$StructuredInputAwareSlotValueExtractor"); + Constructor constructor = innerClass.getDeclaredConstructor( + ClientSlotValueExtractor.class, ClientSlotValueExtractor.class); + constructor.setAccessible(true); + ClientSlotValueExtractor extractor = + (ClientSlotValueExtractor) constructor.newInstance(structuredExtractor, llmExtractor); + + Map result = extractor.extractSlotsWithSchema( + Map.of("key", "value"), "scenario", "en", "template", Map.of("field", "description")); + + assertEquals(Map.of("key", "llm"), result); + } + + @Test + void structuredInputAwareExtractSlotsStillDispatchesByType() throws Exception { + ClientSlotValueExtractor structuredExtractor = + (userInput, scenarioCode, language, templateText) -> Map.of("key", "structured"); + ClientSlotValueExtractor llmExtractor = + (userInput, scenarioCode, language, templateText) -> Map.of("key", "llm"); + + Class innerClass = Class.forName( + "net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder$StructuredInputAwareSlotValueExtractor"); + Constructor constructor = innerClass.getDeclaredConstructor( + ClientSlotValueExtractor.class, ClientSlotValueExtractor.class); + constructor.setAccessible(true); + ClientSlotValueExtractor extractor = + (ClientSlotValueExtractor) constructor.newInstance(structuredExtractor, llmExtractor); + + Map mapResult = extractor.extractSlots( + Map.of("key", "value"), "scenario", "en", "template"); + assertEquals(Map.of("key", "structured"), mapResult); + + Map stringResult = extractor.extractSlots( + "plain text", "scenario", "en", "template"); + assertEquals(Map.of("key", "llm"), stringResult); + } +} \ No newline at end of file diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractorTest.java new file mode 100644 index 00000000..49db86f6 --- /dev/null +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractorTest.java @@ -0,0 +1,20 @@ +package net.openan.a2at.sdk.client.prompt.extractor; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ClientSlotValueExtractorTest { + + @Test + void extractSlotsWithSchemaDelegatesToExtractSlotsAndIgnoresSchema() { + ClientSlotValueExtractor extractor = (userInput, scenarioCode, language, templateText) -> + Map.of("scenario", scenarioCode, "language", language); + + Map result = extractor.extractSlotsWithSchema( + "test input", "scenario-1", "en-US", "template text", Map.of("field", "desc")); + + assertEquals(Map.of("scenario", "scenario-1", "language", "en-US"), result); + } +} \ No newline at end of file diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java index 7c31b52f..71f410fe 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.client.prompt.extractor; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; @@ -59,6 +60,70 @@ void extractSlotsBuildsStructuredPromptAndReturnsNormalizedSlots() { assertTrue(llmClient.lastSchema().containsKey("required")); } + @Test + void extractSlotsWithSchemaCallsDelegateFourArgMethod() { + RecordingClient llmClient = new RecordingClient( + "{\"slots\":{\"site\":\"Site A\",\"additional_notes\":null,\"limit\":\"5\",\"severity\":\"high\"}," + + "\"slot_errors\":[]}"); + DefaultStructuredClientSlotValueExtractor extractor = new DefaultStructuredClientSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of( + new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null), + new PromptSlotDefinition( + "additional_notes", false, "string", null, null, null, null, null), + new PromptSlotDefinition("limit", false, "integer", null, 1.0d, 10.0d, null, null), + new PromptSlotDefinition( + "severity", + false, + "string", + null, + null, + null, + List.of("low", "medium", "high"), + null))), + "Extract slots from the input.", + "Return slots as JSON."); + + Map dataSchema = Map.of("site", "site description", "severity", "severity description"); + Map slots = extractor.extractSlotsWithSchema( + "Analyze Site A with critical severity.", + "energy_saving", + "en-US", + "Site: {site}\nNotes: {additional_notes}\nLimit: {limit}\nSeverity: {severity}", + dataSchema); + + assertEquals( + Map.of( + "site", "Site A", + "additional_notes", "", + "limit", "5", + "severity", "high"), + slots); + assertTrue(llmClient.lastMessages().get(1).get("content").contains("[data_schema]")); + assertTrue(llmClient.lastMessages().get(1).get("content").contains("site: site description")); + } + + @Test + void extractSlotsWithSchemaWithNullSchemaStillWorks() { + RecordingClient llmClient = new RecordingClient( + "{\"slots\":{\"site\":\"Site A\"},\"slot_errors\":[]}"); + DefaultStructuredClientSlotValueExtractor extractor = new DefaultStructuredClientSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null))), + "Extract slots.", + "Return slots."); + + Map slots = extractor.extractSlotsWithSchema( + "Analyze Site A.", "scenario", "en", "Site: {site}", null); + + assertEquals(Map.of("site", "Site A"), slots); + assertFalse(llmClient.lastMessages().get(1).get("content").contains("[data_schema]")); + } + private static final class RecordingClient implements LLMClient { private final String payload; diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java index 996de021..93489f86 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java @@ -159,4 +159,23 @@ void extractSlotsBlanksValuesWhenEnumOrRangeConstraintsFail() { private static PromptSlotSchema schema(String scenarioCode, PromptSlotDefinition... definitions) { return new PromptSlotSchema(scenarioCode, List.of(definitions)); } + + @Test + void extractSlotsWithSchemaIgnoresSchemaAndBehavesIdenticallyToExtractSlots() { + DefaultTemplateDrivenSlotValueExtractor extractor = + new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( + scenarioCode, + new PromptSlotDefinition("site", true, "string", null, null, null, null, null), + new PromptSlotDefinition("additional_notes", false, "string", null, null, null, null, null), + new PromptSlotDefinition("ignored", false, "string", null, null, null, null, null))); + + Map slots = extractor.extractSlotsWithSchema( + Map.of("site", "Site A", "additional_notes", "critical", "ignored", "value"), + "energy_saving", + "en-US", + "Site: {site}\nNotes: {additional_notes}", + Map.of("ignored_field", "some description")); + + assertEquals(Map.of("site", "Site A", "additional_notes", "critical"), slots); + } } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 66895085..cb2176d7 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -3,14 +3,19 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.client.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; +import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.llm.LLMRuntimeError; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; @@ -144,6 +149,170 @@ void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissi assertEquals("generation", result.failure().stage()); } + @Test + void eachTemplateUriEntryPointGeneratesPromptWithoutScenarioRecognition() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractor slotValueExtractor = new FakeSlotValueExtractor(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, slotValueExtractor); + + PromptGenerationResult taskNl = orchestrator.generateTaskPromptFromNl("Analyze Site A.", "energy_saving"); + PromptGenerationResult taskJson = + orchestrator.generateTaskPromptFromJsonData(Map.of("site", "Site A"), "energy_saving"); + PromptGenerationResult authorizationNl = + orchestrator.generateAuthorizationPromptFromNl("Grant access.", "database_read"); + PromptGenerationResult authorizationJson = + orchestrator.generateAuthorizationPromptFromJsonData(Map.of("site", "Site A"), "database_read"); + PromptGenerationResult notificationNl = + orchestrator.generateNotificationPromptFromNl("Report finished.", "energy_saving"); + PromptGenerationResult notificationJson = + orchestrator.generateNotificationPromptFromJsonData(Map.of("site", "Site A"), "energy_saving"); + + assertTrue(taskNl.success()); + assertTrue(taskJson.success()); + assertTrue(authorizationNl.success()); + assertTrue(authorizationJson.success()); + assertTrue(notificationNl.success()); + assertTrue(notificationJson.success()); + assertEquals("Site: Site A", taskNl.promptText()); + assertEquals("Site: Site A", authorizationJson.promptText()); + assertEquals("Site: Site A", notificationNl.promptText()); + assertEquals(0, recognizer.invocationCount); + assertEquals("en-US", templateLoader.lastLanguage); + assertEquals("en-US", slotValueExtractor.lastLanguage); + assertEquals("Site: {site}", slotValueExtractor.lastTemplateText); + } + + @Test + void authorizationEntryPointsUseAuthorizationTypeAsTemplateIdentifier() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Access: {scope}"); + FakeSlotValueExtractor slotValueExtractor = new FakeSlotValueExtractor(Map.of("scope", "read")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + PromptGenerationResult nlResult = + orchestrator.generateAuthorizationPromptFromNl("Grant read access.", "database_read"); + + assertTrue(nlResult.success()); + assertEquals("database_read", templateLoader.lastScenarioCode); + assertEquals("database_read", slotValueExtractor.lastScenarioCode); + assertEquals("Grant read access.", slotValueExtractor.lastUserInput); + + PromptGenerationResult jsonResult = + orchestrator.generateAuthorizationPromptFromJsonData(Map.of("scope", "read"), "database_read"); + + assertTrue(jsonResult.success()); + assertEquals("database_read", templateLoader.lastScenarioCode); + assertEquals(Map.of("scope", "read"), slotValueExtractor.lastUserInput); + } + + @Test + void templateUriGenerationReturnsInvalidTemplateUriWhenIdentifierIsNullOrBlankOrPatternViolating() { + for (String invalidUri : + new String[] {null, "", " ", "templates/energy_saving", "../etc/passwd", "has space", "id;drop"}) { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); + + PromptGenerationResult result = + orchestrator.generateTaskPromptFromJsonData(Map.of("site", "Site A"), invalidUri); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("invalid_template_uri", result.failure().code()); + assertEquals("generation", result.failure().stage()); + assertEquals(0, recognizer.invocationCount); + assertEquals(0, templateLoader.loadCount); + } + } + + @Test + void templateUriGenerationReturnsTemplateNotFoundWhenTemplateIsMissing() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + (scenarioCode, language) -> { + throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); + }, + new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + PromptGenerationResult result = orchestrator.generateTaskPromptFromNl("Analyze Site A.", "energy_saving"); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("template_not_found", result.failure().code()); + assertEquals("generation", result.failure().stage()); + } + + @Test + void templateUriGenerationReturnsRenderFailedWhenRenderingFails() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}\nMissing: {missing_slot}"), + new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + PromptGenerationResult result = + orchestrator.generateNotificationPromptFromJsonData(Map.of("site", "Site A"), "energy_saving"); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("render_failed", result.failure().code()); + assertEquals("generation", result.failure().stage()); + } + + @Test + void templateUriGenerationProcessesNullAndBlankUserInputWithoutAdditionalValidation() { + for (String userInput : new String[] {null, "", " "}) { + FakeSlotValueExtractor slotValueExtractor = new FakeSlotValueExtractor(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), new FakeTemplateLoader("Site: {site}"), slotValueExtractor); + + PromptGenerationResult result = orchestrator.generateTaskPromptFromNl(userInput, "energy_saving"); + + assertTrue(result.success()); + assertEquals("Site: Site A", result.promptText()); + assertEquals(userInput, slotValueExtractor.lastUserInput); + } + } + + private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestrator( + ClientScenarioRecognizer recognizer, + ClientTemplateLoader templateLoader, + ClientSlotValueExtractor slotValueExtractor) { + return new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition( + "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + templateLoader, + slotValueExtractor, + new TaskPromptRenderer()); + } + + private static final class RecordingScenarioRecognizer implements ClientScenarioRecognizer { + private int invocationCount; + + @Override + public ScenarioRecognitionResult recognize( + String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { + this.invocationCount++; + return new ScenarioRecognitionResult(true, "energy_saving", null); + } + } + + private static final class CountingFailingTemplateLoader implements ClientTemplateLoader { + private int loadCount; + + @Override + public String loadTemplate(String scenarioCode, String language) { + this.loadCount++; + throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); + } + } + private static final class FakeTemplateLoader implements ClientTemplateLoader { private final String templateText; private String lastScenarioCode; @@ -182,4 +351,341 @@ public Map extractSlots( return slots; } } + + private static final class FakeSlotValueExtractorWithSchema implements ClientSlotValueExtractor { + private final Map slots; + private Object lastUserInput; + private String lastScenarioCode; + private String lastLanguage; + private String lastTemplateText; + private Map lastSchema; + + private FakeSlotValueExtractorWithSchema(Map slots) { + this.slots = slots; + } + + @Override + public Map extractSlots( + Object userInput, String scenarioCode, String language, String templateText) { + this.lastUserInput = userInput; + this.lastScenarioCode = scenarioCode; + this.lastLanguage = language; + this.lastTemplateText = templateText; + return slots; + } + + @Override + public Map extractSlotsWithSchema( + Object userInput, String scenarioCode, String language, String templateText, + Map dataSchema) { + this.lastUserInput = userInput; + this.lastScenarioCode = scenarioCode; + this.lastLanguage = language; + this.lastTemplateText = templateText; + this.lastSchema = dataSchema; + return slots; + } + } + + private static final class FailingExtractSlotsWithSchema implements ClientSlotValueExtractor { + private final RuntimeException exception; + + private FailingExtractSlotsWithSchema(RuntimeException exception) { + this.exception = exception; + } + + @Override + public Map extractSlots( + Object userInput, String scenarioCode, String language, String templateText) { + return Map.of(); + } + + @Override + public Map extractSlotsWithSchema( + Object userInput, String scenarioCode, String language, String templateText, + Map dataSchema) { + throw exception; + } + } + + // --- MetadataContent pipeline tests (generateFromTemplateUriWithMetadata / FromText) --- + + @Test + void generateFromTemplateUriWithMetadataReturnsMetadataContentOnSuccess() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractor slotValueExtractor = new FakeSlotValueExtractor(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent result = orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving"); + + assertEquals("energy_saving", result.templateUri()); + assertEquals("Site: Site A", result.promptText()); + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); + } + + @Test + void generateFromTemplateUriWithMetadataScenarioRecognizerIsNotInvoked() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving"); + + assertEquals(0, recognizer.invocationCount); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsOnInvalidTemplateUri() { + for (String invalidUri : new String[] {null, "", " ", "templates/energy_saving", "../etc/passwd", "has space", "id;drop"}) { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); + + assertThrows(IllegalArgumentException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", invalidUri)); + assertEquals(0, recognizer.invocationCount); + assertEquals(0, templateLoader.loadCount); + } + } + + @Test + void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + (scenarioCode, language) -> { + throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); + }, + new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + assertThrows(ResourceNotFoundException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + } + + @Test + void generateFromTemplateUriWithMetadataPropagatesRenderException() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}\nMissing: {missing_slot}"), + new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + assertThrows(net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + } + + @Test + void generateFromTemplateUriWithMetadataPropagatesLlmRuntimeError() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language, templateText) -> { + throw new LLMRuntimeError("LLM invocation failed."); + }); + + assertThrows(LLMRuntimeError.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + } + + // --- MetadataContent pipeline tests (generateFromDataWithSchema) --- + + @Test + void generateFromDataWithSchemaReturnsMetadataContentOnSuccess() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), "energy_saving"); + + assertEquals("energy_saving", result.templateUri()); + assertEquals("Site: Site A", result.promptText()); + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); + } + + @Test + void generateFromDataWithSchemaPassesSchemaToExtractor() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + Map schema = Map.of("site", "string", "count", "number"); + orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), schema, "energy_saving"); + + assertEquals(schema, slotValueExtractor.lastSchema); + assertEquals("energy_saving", slotValueExtractor.lastScenarioCode); + assertEquals("Site: {site}", slotValueExtractor.lastTemplateText); + } + + @Test + void generateFromDataWithSchemaNullSchemaHandledGracefully() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), null, "energy_saving"); + + assertEquals("Site: Site A", result.promptText()); + assertEquals(null, slotValueExtractor.lastSchema); + } + + @Test + void generateFromDataWithSchemaEmptySchemaHandledSameAsNull() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of(), "energy_saving"); + + assertEquals("Site: Site A", result.promptText()); + assertEquals(Map.of(), slotValueExtractor.lastSchema); + } + + @Test + void generateFromDataWithSchemaThrowsOnInvalidTemplateUri() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractorWithSchema(Map.of())); + + assertThrows(IllegalArgumentException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "has space")); + assertEquals(0, recognizer.invocationCount); + assertEquals(0, templateLoader.loadCount); + } + + @Test + void generateFromDataWithSchemaPropagatesResourceNotFoundException() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + (scenarioCode, language) -> { + throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); + }, + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); + + assertThrows(ResourceNotFoundException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + } + + @Test + void generateFromDataWithSchemaPropagatesRenderException() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}\nMissing: {missing_slot}"), + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); + + assertThrows(net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + } + + @Test + void generateFromDataWithSchemaPropagatesLlmRuntimeError() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}"), + new FailingExtractSlotsWithSchema(new LLMRuntimeError("LLM invocation failed."))); + + assertThrows(LLMRuntimeError.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + } + + @Test + void generateFromDataWithSchemaPropagatesResourceNotFoundExceptionFromExtractor() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}"), + new FailingExtractSlotsWithSchema( + new ResourceNotFoundException("Slot schema file does not exist.", "energy_saving"))); + + assertThrows(ResourceNotFoundException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + } + + // --- Public entry point tests --- + + @Test + void eachMetadataEntryPointReturnsCorrectMetadataContent() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving"); + MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of(), "energy_saving"); + MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "database_read"); + MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of(), "database_read"); + MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", "energy_saving"); + MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of(), "energy_saving"); + + assertTrue(taskText.promptText().contains("Site A")); + assertTrue(taskData.promptText().contains("Site A")); + assertTrue(authText.promptText().contains("Site A")); + assertTrue(authData.promptText().contains("Site A")); + assertTrue(notifText.promptText().contains("Site A")); + assertTrue(notifData.promptText().contains("Site A")); + } + + @Test + void eachMetadataEntryPointUsesCorrectExtensionUri() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving"); + MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of(), "energy_saving"); + MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "database_read"); + MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of(), "database_read"); + MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", "energy_saving"); + MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of(), "energy_saving"); + + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskText.extensionUri()); + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskData.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authText.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authData.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notifText.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notifData.extensionUri()); + } + + @Test + void authorizationMetadataEntryPointsPassAuthorizationTypeAsTemplateIdentifier() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Access: {scope}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("scope", "read")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent nlResult = orchestrator.generateAuthPromptFromText("Grant read access.", "database_read"); + + assertEquals("database_read", nlResult.templateUri()); + assertEquals("Access: read", nlResult.promptText()); + assertEquals("database_read", templateLoader.lastScenarioCode); + assertEquals("database_read", slotValueExtractor.lastScenarioCode); + assertEquals("Grant read access.", slotValueExtractor.lastUserInput); + + MetadataContent jsonResult = orchestrator.generateAuthPromptFromDataWithSchema( + Map.of("scope", "read"), Map.of("scope", "string"), "database_read"); + + assertEquals("database_read", jsonResult.templateUri()); + assertEquals("database_read", templateLoader.lastScenarioCode); + assertEquals(Map.of("scope", "read"), slotValueExtractor.lastUserInput); + assertEquals(Map.of("scope", "string"), slotValueExtractor.lastSchema); + } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/ExtensionUriConstants.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/ExtensionUriConstants.java new file mode 100644 index 00000000..d434f9db --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/ExtensionUriConstants.java @@ -0,0 +1,27 @@ +package net.openan.a2at.sdk.core.model; + +/** + * Extension URI constants for the A2A-T protocol. + * + *

These URIs identify the A2A-T extension protocols supported by this SDK. + * + * @since 2026-08 + */ +public final class ExtensionUriConstants { + + private ExtensionUriConstants() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** URI for the Task-T extension. */ + public static final String TASK_T_EXTENSION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; + + /** URI for the Authorization-T extension. */ + public static final String AUTHORIZATION_T_EXTENSION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1"; + + /** URI for the Notification-T extension. */ + public static final String NOTIFICATION_T_EXTENSION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; +} \ No newline at end of file diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/ExtensionUriConstantsTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/ExtensionUriConstantsTest.java new file mode 100644 index 00000000..ad3dc975 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/ExtensionUriConstantsTest.java @@ -0,0 +1,46 @@ +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.reflect.Constructor; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ExtensionUriConstants}. + * + *

Tests cover the extension URI constants and utility class invariants. + * + * @since 2026-08 + */ +class ExtensionUriConstantsTest { + + @Test + void should_haveCorrectTaskTExtensionUri() { + assertEquals( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1", + ExtensionUriConstants.TASK_T_EXTENSION_URI); + } + + @Test + void should_haveCorrectAuthorizationTExtensionUri() { + assertEquals( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1", + ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); + } + + @Test + void should_haveCorrectNotificationTExtensionUri() { + assertEquals( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1", + ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); + } + + @Test + void should_havePrivateConstructor() throws Exception { + Constructor constructor = + ExtensionUriConstants.class.getDeclaredConstructor(); + constructor.setAccessible(true); + assertThrows(Exception.class, constructor::newInstance); + } +} \ No newline at end of file diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java index b7ccdc9e..5152c9cc 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java @@ -44,10 +44,21 @@ public DefaultStructuredPromptSlotValueExtractor( @Override public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { + return doExtractSlots(userInput, scenarioCode, language, null); + } + + @Override + public StructuredSlotExtractionResult extractSlots( + Object userInput, String scenarioCode, String language, Map dataSchema) { + return doExtractSlots(userInput, scenarioCode, language, dataSchema); + } + + private StructuredSlotExtractionResult doExtractSlots( + Object userInput, String scenarioCode, String language, Map dataSchema) { PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(scenarioCode, language); String payload = llmClient .structured( - toStructuredMessages(buildMessages(userInput, scenarioCode, language, slotSchema)), + toStructuredMessages(buildMessages(userInput, scenarioCode, language, slotSchema, dataSchema)), buildSchema(slotSchema), null, null) @@ -56,7 +67,8 @@ public StructuredSlotExtractionResult extractSlots(Object userInput, String scen } private List buildMessages( - Object userInput, String scenarioCode, String language, PromptSlotSchema slotSchema) { + Object userInput, String scenarioCode, String language, PromptSlotSchema slotSchema, + Map dataSchema) { String slotLines = slotSchema.slotDefinitions().stream() .map(slot -> "- name: " + slot.name() + "\n required: " + slot.required()) .collect(Collectors.joining("\n")); @@ -69,6 +81,12 @@ private List buildMessages( + String.valueOf(userInput) + "\n\n[slots]\n" + slotLines; + if (dataSchema != null && !dataSchema.isEmpty()) { + content += "\n\n[data_schema]\n"; + for (Map.Entry entry : dataSchema.entrySet()) { + content += "- " + entry.getKey() + ": " + String.valueOf(entry.getValue()) + "\n"; + } + } return List.of(new PromptMessage("system", systemPrompt), new PromptMessage("user", content)); } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/PromptSlotValueExtractor.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/PromptSlotValueExtractor.java index fa4b2e80..ad337898 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/PromptSlotValueExtractor.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/PromptSlotValueExtractor.java @@ -1,5 +1,6 @@ package net.openan.a2at.sdk.prompt.analysis.impl; +import java.util.Map; import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; /** @@ -19,4 +20,18 @@ public interface PromptSlotValueExtractor { * @return structured extraction result */ StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language); + + /** + * Extracts structured slot values with an optional data schema for schema-guided extraction. + * + * @param userInput normalized input text + * @param scenarioCode resolved scenario code + * @param language prompt language + * @param dataSchema optional data schema map (field name to description) + * @return structured extraction result + */ + default StructuredSlotExtractionResult extractSlots( + Object userInput, String scenarioCode, String language, Map dataSchema) { + return extractSlots(userInput, scenarioCode, language); + } } diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java index e985babe..3e9668b9 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java @@ -1,6 +1,8 @@ package net.openan.a2at.sdk.prompt.analysis.impl; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import java.util.Map; @@ -93,10 +95,127 @@ void extractSlotsPreservesSlotTextContainingClosingBrace() { assertEquals(List.of(), result.slotErrors()); } + @Test + void should_injectDataSchemaSection_When_schemaIsProvided() { + RecordingClient llmClient = + new RecordingClient( + """ + { + "slots": { + "site": "Site A" + }, + "slot_errors": [] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null))), + "Extract slots from the input.", + "Return slots as JSON."); + + Map dataSchema = Map.of( + "site", "The target site name", + "severity", "The severity level"); + + extractor.extractSlots("Analyze Site A.", "energy_saving", "en-US", dataSchema); + + String userMessage = llmClient.lastUserContent(); + assertTrue(userMessage.contains("[data_schema]")); + assertTrue(userMessage.contains("- site: The target site name")); + assertTrue(userMessage.contains("- severity: The severity level")); + } + + @Test + void should_notIncludeDataSchemaSection_When_schemaIsNull() { + RecordingClient llmClient = + new RecordingClient( + """ + { + "slots": { + "site": "Site A" + }, + "slot_errors": [] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null))), + "Extract slots from the input.", + "Return slots as JSON."); + + extractor.extractSlots("Analyze Site A.", "energy_saving", "en-US", null); + + String userMessage = llmClient.lastUserContent(); + assertFalse(userMessage.contains("[data_schema]")); + } + + @Test + void should_notIncludeDataSchemaSection_When_schemaIsEmpty() { + RecordingClient llmClient = + new RecordingClient( + """ + { + "slots": { + "site": "Site A" + }, + "slot_errors": [] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null))), + "Extract slots from the input.", + "Return slots as JSON."); + + extractor.extractSlots("Analyze Site A.", "energy_saving", "en-US", Map.of()); + + String userMessage = llmClient.lastUserContent(); + assertFalse(userMessage.contains("[data_schema]")); + } + + @Test + void defaultExtractSlotsWithSchemaDelegatesToThreeArg() { + RecordingClient llmClient = + new RecordingClient( + """ + { + "slots": { + "site": "Site A" + }, + "slot_errors": [] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null))), + "Extract slots from the input.", + "Return slots as JSON."); + + PromptSlotValueExtractor lambdaExtractor = (input, code, lang) -> + extractor.extractSlots(input, code, lang); + + StructuredSlotExtractionResult result = lambdaExtractor.extractSlots( + "Analyze Site A.", "energy_saving", "en-US", Map.of("site", "desc")); + + String userMessage = llmClient.lastUserContent(); + assertFalse(userMessage.contains("[data_schema]")); + assertEquals(Map.of("site", "Site A"), result.slots()); + } + private static final class RecordingClient implements LLMClient { private final String payload; + private List> lastMessages; + private RecordingClient(String payload) { this.payload = payload; } @@ -107,7 +226,17 @@ public LLMResponse structured( Map jsonSchema, Double temperature, Integer maxTokens) { + this.lastMessages = messages; return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); } + + String lastUserContent() { + for (Map message : lastMessages) { + if ("user".equals(message.get("role"))) { + return message.get("content"); + } + } + return ""; + } } -} +} \ No newline at end of file From db9116a1d1ced8b68cee7df7c2b88ea089cdc83c Mon Sep 17 00:00:00 2001 From: w00609601 Date: Wed, 19 Aug 2026 17:06:09 +0800 Subject: [PATCH 021/295] Add null-safety to MetadataContent.buildMetadataContent Signed-off-by: w00609601 --- .../a2at/sdk/client/model/MetadataContent.java | 12 ++++-------- .../a2at/sdk/client/model/MetadataContentTest.java | 7 +++---- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/model/MetadataContent.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/model/MetadataContent.java index d97a072c..308ccfb2 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/model/MetadataContent.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/model/MetadataContent.java @@ -1,7 +1,6 @@ package net.openan.a2at.sdk.client.model; import java.util.Map; -import java.util.Objects; /** * Metadata content used in A2A-T prompt generation. @@ -13,19 +12,16 @@ */ public record MetadataContent(String templateUri, String promptText, String extensionUri) { - public MetadataContent { - Objects.requireNonNull(templateUri, "templateUri"); - Objects.requireNonNull(promptText, "promptText"); - Objects.requireNonNull(extensionUri, "extensionUri"); - } - /** * Builds a metadata map keyed by the extension URI and template URI. * * @return metadata map containing the prompt text under the extension URI key and the template URI under the - * {@code template_uri} key + * {@code template_uri} key, or {@code null} if any field is null */ public Map buildMetadataContent() { + if (extensionUri == null || promptText == null || templateUri == null) { + return null; + } return Map.of(extensionUri, promptText, "template_uri", templateUri); } } \ No newline at end of file diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java index f0c5e990..32a108e9 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java @@ -48,9 +48,8 @@ void buildMetadataContentReturnsMapWithExtensionUriAndTemplateUri() { } @Test - void rejectsNullFields() { - assertThrows(NullPointerException.class, () -> new MetadataContent(null, "prompt", "uri")); - assertThrows(NullPointerException.class, () -> new MetadataContent("uri", null, "uri")); - assertThrows(NullPointerException.class, () -> new MetadataContent("uri", "prompt", null)); + void buildMetadataContentReturnsNullWhenAnyFieldIsNull() { + MetadataContent content = new MetadataContent(null, null, "extension-uri"); + assertEquals(null, content.buildMetadataContent()); } } From 56f3a8abc0b140d6b9f1eac8da69bb846a48b3d3 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Wed, 19 Aug 2026 19:33:49 +0800 Subject: [PATCH 022/295] refactor: optimize the naming style of the template directory Signed-off-by: zhangwenjun-huawei --- .../a2at/sdk/client/api/A2ATClientTest.java | 12 +++++------ ...entPromptAssemblyPackageStructureTest.java | 4 ++-- ...omptGenerationOrchestratorBuilderTest.java | 12 +++++------ ...omptGenerationOrchestratorFactoryTest.java | 4 ++-- ...tructuredClientSlotValueExtractorTest.java | 4 ++-- ...tTemplateDrivenSlotValueExtractorTest.java | 12 +++++------ ...ltClasspathClientSlotSchemaLoaderTest.java | 8 ++++---- ...aultClasspathClientTemplateLoaderTest.java | 2 +- .../LocalFileClientSlotSchemaLoaderTest.java | 4 ++-- ...lientPromptGenerationOrchestratorTest.java | 20 +++++++++---------- ...alFileClientScenarioCatalogLoaderTest.java | 4 ++-- .../en-US/slot.json | 0 .../{free_text => free-text}/en-US/slot.json | 0 .../en-US/template.md | 0 .../core/json/JacksonJsonValueParserTest.java | 4 ++-- ...tructuredPromptSlotValueExtractorTest.java | 4 ++-- .../analysis/impl/ScenarioRecognizerTest.java | 16 +++++++-------- .../loader/ClasspathPromptLoadersTest.java | 8 ++++---- .../scenarios/en/scenarios.json | 2 +- .../en/slot.json | 0 .../en/template.md | 0 .../scenarios/en-US/scenarios.json | 6 +++--- .../scenarios/zh-CN/scenarios.json | 6 +++--- .../en-US/slot.json | 0 .../zh-CN/slot.json | 0 .../en-US/slot.json | 0 .../zh-CN/slot.json | 0 .../en-US/slot.json | 0 .../zh-CN/slot.json | 0 .../accept-reject/en-US/template.md | 0 .../accept-reject/zh-CN/template.md | 0 .../propose/en-US/template.md | 0 .../propose/zh-CN/template.md | 0 .../accept-reject/en-US/template.md | 0 .../accept-reject/zh-CN/template.md | 0 .../propose/en-US/template.md | 0 .../propose/zh-CN/template.md | 0 .../accept-reject/en-US/template.md | 0 .../accept-reject/zh-CN/template.md | 0 .../propose/en-US/template.md | 0 .../propose/zh-CN/template.md | 0 .../en-US/template.md | 0 .../zh-CN/template.md | 0 .../en-US/template.md | 0 .../zh-CN/template.md | 0 .../en-US/template.md | 0 .../zh-CN/template.md | 0 .../ClasspathPromptResourceLoaderTest.java | 6 +++--- ...calFileServerPromptTemplateLoaderTest.java | 4 ++-- ...erverPromptComplianceOrchestratorTest.java | 4 ++-- .../LlmBackedPromptMetadataExtractorTest.java | 16 +++++++-------- ...teMatchingPromptMetadataExtractorTest.java | 16 +++++++-------- .../LlmBackedPromptSemanticValidatorTest.java | 6 +++--- 53 files changed, 92 insertions(+), 92 deletions(-) rename a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/{energy_saving => energy-saving}/en-US/slot.json (100%) rename a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/{free_text => free-text}/en-US/slot.json (100%) rename a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/v1/{energy_saving => energy-saving}/en-US/template.md (100%) rename a2a-t-prompt/src/test/resources/prompt_resources/slots/Task-T/v1/{handoff_planning => handoff-planning}/en/slot.json (100%) rename a2a-t-prompt/src/test/resources/prompt_resources/templates/Task-T/v1/{handoff_planning => handoff-planning}/en/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/{subscribe_incident => subscribe-incident}/en-US/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/{subscribe_incident => subscribe-incident}/zh-CN/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/{energy_saving => energy-saving}/en-US/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/{energy_saving => energy-saving}/zh-CN/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/{private_line_complaint => private-line-complaint}/en-US/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/{private_line_complaint => private-line-complaint}/zh-CN/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/{feasibility_negotiation => feasibility-negotiation}/accept-reject/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/{feasibility_negotiation => feasibility-negotiation}/accept-reject/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/{feasibility_negotiation => feasibility-negotiation}/propose/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/{feasibility_negotiation => feasibility-negotiation}/propose/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/{information_negotiation => information-negotiation}/accept-reject/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/{information_negotiation => information-negotiation}/accept-reject/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/{information_negotiation => information-negotiation}/propose/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/{information_negotiation => information-negotiation}/propose/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/{target_negotiation => target-negotiation}/accept-reject/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/{target_negotiation => target-negotiation}/accept-reject/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/{target_negotiation => target-negotiation}/propose/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/{target_negotiation => target-negotiation}/propose/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/{subscribe_incident => subscribe-incident}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/{subscribe_incident => subscribe-incident}/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/{energy_saving => energy-saving}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/{energy_saving => energy-saving}/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/{private_line_complaint => private-line-complaint}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/{private_line_complaint => private-line-complaint}/zh-CN/template.md (100%) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index e46701fe..39f6efa7 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -158,10 +158,10 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO .resolve("templates") .resolve("Task-T") .resolve("v1") - .resolve("private_line_complaint") + .resolve("private-line-complaint") .resolve("zh-CN"); Path slotsDir = - promptRoot.resolve("slots").resolve("Task-T").resolve("v1").resolve("private_line_complaint").resolve("zh-CN"); + promptRoot.resolve("slots").resolve("Task-T").resolve("v1").resolve("private-line-complaint").resolve("zh-CN"); Files.createDirectories(scenariosDir); Files.createDirectories(templatesDir); Files.createDirectories(slotsDir); @@ -172,7 +172,7 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO { "scenarios": [ { - "scenario_code": "private_line_complaint", + "scenario_code": "private-line-complaint", "scenario_name": "Private Line Complaint", "description": "Complaint analysis", "example": "Analyze private line fault" @@ -246,8 +246,8 @@ private static Path writeMinimalClientEnv(Path tempDir, String provider) throws promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); Path templatesDir = - promptRoot.resolve("templates").resolve("Task-T").resolve("v1").resolve("energy_saving").resolve("zh-CN"); - Path slotsDir = promptRoot.resolve("slots").resolve("Task-T").resolve("v1").resolve("energy_saving").resolve("zh-CN"); + promptRoot.resolve("templates").resolve("Task-T").resolve("v1").resolve("energy-saving").resolve("zh-CN"); + Path slotsDir = promptRoot.resolve("slots").resolve("Task-T").resolve("v1").resolve("energy-saving").resolve("zh-CN"); Files.createDirectories(scenarioPromptDir); Files.createDirectories(slotPromptDir); Files.createDirectories(scenariosDir); @@ -260,7 +260,7 @@ private static Path writeMinimalClientEnv(Path tempDir, String provider) throws { "scenarios": [ { - "scenario_code": "energy_saving", + "scenario_code": "energy-saving", "scenario_name": "Energy Saving", "description": "Energy analysis", "example": "Analyze site power" diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptAssemblyPackageStructureTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptAssemblyPackageStructureTest.java index 2506d0fe..241883e6 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptAssemblyPackageStructureTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptAssemblyPackageStructureTest.java @@ -18,14 +18,14 @@ class ClientPromptAssemblyPackageStructureTest { @Test void defaultAssemblyStillBuildsAndGeneratesPrompt() { LLMClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"energy_saving\",\"error_message\":null}", + "{\"matched\":true,\"scenario_code\":\"energy-saving\",\"error_message\":null}", "{\"slots\":{\"site\":\"Site A\",\"additional_notes\":\"critical\",\"limit\":\"5\",\"severity\":\"high\"},\"slot_errors\":[]}"); DefaultClientPromptGenerationOrchestrator orchestrator = DefaultClientPromptGenerationOrchestratorFactory.create( llmClient, List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), new PromptGenerationConfig( "en-US", "Identify the best matching scenario.", diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java index 4b2f0fdb..e734db51 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java @@ -23,13 +23,13 @@ class ClientPromptGenerationOrchestratorBuilderTest { @Test void buildCreatesStructuredDefaultPromptGenerationAssembly() { RecordingClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"energy_saving\",\"error_message\":null}", + "{\"matched\":true,\"scenario_code\":\"energy-saving\",\"error_message\":null}", "{\"slots\":{\"site\":\"Site A\",\"additional_notes\":\"critical\",\"limit\":\"5\",\"severity\":\"high\"},\"slot_errors\":[]}"); DefaultClientPromptGenerationOrchestrator orchestrator = ClientPromptGenerationOrchestratorBuilder.builder() .llmClient(llmClient) .scenarios(List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power"))) + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power"))) .language("en-US") .scenarioSystemPrompt("Identify the best matching scenario.") .scenarioUserPrompt("Choose from the provided scenario list.") @@ -53,7 +53,7 @@ void buildCreatesStructuredDefaultPromptGenerationAssembly() { @Test void buildUsesExplicitCollaboratorsWhenProvided() { RecordingScenarioRecognizer scenarioRecognizer = - new RecordingScenarioRecognizer(new ScenarioRecognitionResult(true, "energy_saving", null)); + new RecordingScenarioRecognizer(new ScenarioRecognitionResult(true, "energy-saving", null)); RecordingTemplateLoader templateLoader = new RecordingTemplateLoader("Site: {site}\nNotes: {additional_notes}"); RecordingSlotValueExtractor slotValueExtractor = new RecordingSlotValueExtractor(Map.of("site", "Site B", "additional_notes", "follow-up")); @@ -61,7 +61,7 @@ void buildUsesExplicitCollaboratorsWhenProvided() { DefaultClientPromptGenerationOrchestrator orchestrator = ClientPromptGenerationOrchestratorBuilder.builder() .llmClient(new RecordingClient()) .scenarios(List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power"))) + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power"))) .language("zh-CN") .scenarioSystemPrompt("scenario-system") .scenarioUserPrompt("scenario-user") @@ -80,10 +80,10 @@ void buildUsesExplicitCollaboratorsWhenProvided() { assertEquals("Analyze Site B.", scenarioRecognizer.lastInput); assertEquals("scenario-system", scenarioRecognizer.lastSystemPrompt); assertEquals("scenario-user", scenarioRecognizer.lastUserPrompt); - assertEquals("energy_saving", templateLoader.lastScenarioCode); + assertEquals("energy-saving", templateLoader.lastScenarioCode); assertEquals("zh-CN", templateLoader.lastLanguage); assertSame("Analyze Site B.", slotValueExtractor.lastUserInput); - assertEquals("energy_saving", slotValueExtractor.lastScenarioCode); + assertEquals("energy-saving", slotValueExtractor.lastScenarioCode); assertEquals("zh-CN", slotValueExtractor.lastLanguage); assertEquals( normalizeLineEndings("Site: {site}\nNotes: {additional_notes}"), diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultClientPromptGenerationOrchestratorFactoryTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultClientPromptGenerationOrchestratorFactoryTest.java index 1818003f..4b9291db 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultClientPromptGenerationOrchestratorFactoryTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultClientPromptGenerationOrchestratorFactoryTest.java @@ -17,14 +17,14 @@ class DefaultClientPromptGenerationOrchestratorFactoryTest { @Test void createBuildsPromptGenerationOrchestratorWithStructuredDefaultDependencies() { RecordingClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"energy_saving\",\"error_message\":null}", + "{\"matched\":true,\"scenario_code\":\"energy-saving\",\"error_message\":null}", "{\"slots\":{\"site\":\"Site A\",\"additional_notes\":\"critical\",\"limit\":\"5\",\"severity\":\"high\"},\"slot_errors\":[]}"); DefaultClientPromptGenerationOrchestrator orchestrator = DefaultClientPromptGenerationOrchestratorFactory.create( llmClient, List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), "en-US", "Identify the best matching scenario.", "Choose from the provided scenario list.", diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java index 7c31b52f..4b5cad26 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java @@ -41,7 +41,7 @@ void extractSlotsBuildsStructuredPromptAndReturnsNormalizedSlots() { Map slots = extractor.extractSlots( "Analyze Site A with critical severity.", - "energy_saving", + "energy-saving", "en-US", "Site: {site}\nNotes: {additional_notes}\nLimit: {limit}\nSeverity: {severity}"); @@ -54,7 +54,7 @@ void extractSlotsBuildsStructuredPromptAndReturnsNormalizedSlots() { slots); assertEquals(2, llmClient.lastMessages().size()); assertEquals("system", llmClient.lastMessages().get(0).get("role")); - assertTrue(llmClient.lastMessages().get(1).get("content").contains("energy_saving")); + assertTrue(llmClient.lastMessages().get(1).get("content").contains("energy-saving")); assertTrue(llmClient.lastMessages().get(1).get("content").contains("Analyze Site A with critical severity.")); assertTrue(llmClient.lastSchema().containsKey("required")); } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java index 996de021..c11b0a0d 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java @@ -21,7 +21,7 @@ void extractSlotsUsesOnlyTemplateReferencedKeysFromMapInput() { Map slots = extractor.extractSlots( Map.of("site", "Site A", "additional_notes", "critical", "ignored", "value"), - "energy_saving", + "energy-saving", "en-US", "Site: {site}\nNotes: {additional_notes}"); @@ -34,7 +34,7 @@ void extractSlotsMapsStringInputToSchemaDefinedInputSlot() { new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( scenarioCode, new PromptSlotDefinition("input", true, "string", null, null, null, null, null))); - Map slots = extractor.extractSlots("Analyze Site A.", "free_text", "en-US", "Input: {input}"); + Map slots = extractor.extractSlots("Analyze Site A.", "free-text", "en-US", "Input: {input}"); assertEquals(Map.of("input", "Analyze Site A."), slots); } @@ -53,7 +53,7 @@ void extractSlotsUsesDoubleBracedTemplateReferencedKeysFromMapInput() { "topic", "Incident", "condition", "Severity is critical", "report_format", "Report incident data through DataPart"), - "subscribe_incident", + "subscribe-incident", "en-US", "Topic: {{topic}}\nCondition: {{condition}}\nReport Format: {{report_format}}"); @@ -75,7 +75,7 @@ void extractSlotsBlanksValueWhenSchemaConstraintIsNotSatisfied() { Map slots = extractor.extractSlots( Map.of("site", "invalid", "additional_notes", "critical"), - "energy_saving", + "energy-saving", "en-US", "Site: {site}\nNotes: {additional_notes}"); @@ -106,7 +106,7 @@ void extractSlotsNormalizesAndFiltersEnumAndNumericConstraints() { "additional_notes", "critical", "limit", "5", "severity", "high"), - "energy_saving", + "energy-saving", "en-US", "Site: {site}\nNotes: {additional_notes}\nLimit: {limit}\nSeverity: {severity}"); @@ -143,7 +143,7 @@ void extractSlotsBlanksValuesWhenEnumOrRangeConstraintsFail() { "additional_notes", "", "limit", "50", "severity", "urgent"), - "energy_saving", + "energy-saving", "en-US", "Site: {site}\nNotes: {additional_notes}\nLimit: {limit}\nSeverity: {severity}"); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoaderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoaderTest.java index 85fd59f3..f0670a78 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoaderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoaderTest.java @@ -18,9 +18,9 @@ void loadSlotSchemaReadsJsonSchemaFromClasspathPromptResources() { DefaultClasspathClientSlotSchemaLoader loader = new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); - PromptSlotSchema schema = loader.loadSlotSchema("energy_saving", "zh-CN"); + PromptSlotSchema schema = loader.loadSlotSchema("energy-saving", "zh-CN"); - assertEquals("energy_saving", schema.scenarioCode()); + assertEquals("energy-saving", schema.scenarioCode()); assertEquals(6, schema.slotDefinitions().size()); assertFalse(schema.slotDefinitions().get(0).name().isBlank()); assertFalse(schema.slotDefinitions().get(0).required()); @@ -43,9 +43,9 @@ void loadSlotSchemaKeepsSubscribeIncidentOptionalConditionConstraints() { DefaultClasspathClientSlotSchemaLoader loader = new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); - PromptSlotSchema schema = loader.loadSlotSchema("subscribe_incident", "zh-CN"); + PromptSlotSchema schema = loader.loadSlotSchema("subscribe-incident", "zh-CN"); - assertEquals("subscribe_incident", schema.scenarioCode()); + assertEquals("subscribe-incident", schema.scenarioCode()); assertEquals(3, schema.slotDefinitions().size()); assertTrue(schema.slotDefinitions().stream().noneMatch(PromptSlotDefinition::required)); } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientTemplateLoaderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientTemplateLoaderTest.java index 0b7c4254..a1f0c0a1 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientTemplateLoaderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientTemplateLoaderTest.java @@ -15,7 +15,7 @@ void loadTemplateReadsTemplateFromClasspathPromptResources() { DefaultClasspathClientTemplateLoader loader = new DefaultClasspathClientTemplateLoader(new ClasspathPromptResourceLoader()); - String templateText = loader.loadTemplate("energy_saving", "en-US"); + String templateText = loader.loadTemplate("energy-saving", "en-US"); assertFalse(templateText.isBlank()); assertTrue(templateText.contains("{site}")); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoaderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoaderTest.java index 7528c305..084bee5b 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoaderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoaderTest.java @@ -16,9 +16,9 @@ void loadSlotSchemaDeserializesJsonSchemaModelFromLocalFile() { LocalFileClientSlotSchemaLoader loader = new LocalFileClientSlotSchemaLoader( Path.of("..", "a2a-t-resources", "src", "main", "resources", "prompt_resources")); - PromptSlotSchema schema = loader.loadSlotSchema("energy_saving", "zh-CN"); + PromptSlotSchema schema = loader.loadSlotSchema("energy-saving", "zh-CN"); - assertEquals("energy_saving", schema.scenarioCode()); + assertEquals("energy-saving", schema.scenarioCode()); assertEquals(6, schema.slotDefinitions().size()); PromptSlotDefinition taskObject = schema.slotDefinitions().get(2); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 66895085..8b0087e9 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -25,9 +25,9 @@ void generateTaskPromptLoadsTemplateAndRendersExtractedSlotsWhenScenarioIsMatche new FakeSlotValueExtractor(Map.of("site", "Site A", "additional_notes", "critical")); DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "energy_saving", null), + new ScenarioRecognitionResult(true, "energy-saving", null), List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), "en-US", "Identify the best matching scenario.", "Choose from the provided scenario list.", @@ -40,10 +40,10 @@ void generateTaskPromptLoadsTemplateAndRendersExtractedSlotsWhenScenarioIsMatche assertTrue(result.success()); assertEquals("Site: Site A\nNotes: critical", result.promptText()); assertEquals("Analyze Site A.", orchestrator.lastNormalizedInput()); - assertEquals("energy_saving", templateLoader.lastScenarioCode); + assertEquals("energy-saving", templateLoader.lastScenarioCode); assertEquals("en-US", templateLoader.lastLanguage); assertEquals("Analyze Site A.", slotValueExtractor.lastUserInput); - assertEquals("energy_saving", slotValueExtractor.lastScenarioCode); + assertEquals("energy-saving", slotValueExtractor.lastScenarioCode); assertEquals("en-US", slotValueExtractor.lastLanguage); assertEquals("Site: {site}\nNotes: {additional_notes}", slotValueExtractor.lastTemplateText); } @@ -54,7 +54,7 @@ void generateTaskPromptReturnsFailureWhenScenarioIsNotMatched() { (normalizedInput, scenarios, systemPrompt, userPrompt) -> new ScenarioRecognitionResult(false, null, "No scenario matched."), List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), "en-US", "Identify the best matching scenario.", "Choose from the provided scenario list.", @@ -75,9 +75,9 @@ void generateTaskPromptReturnsFailureWhenScenarioIsNotMatched() { void generateTaskPromptReturnsFailureWhenTemplateIsMissing() { DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "energy_saving", null), + new ScenarioRecognitionResult(true, "energy-saving", null), List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), "en-US", "Identify the best matching scenario.", "Choose from the provided scenario list.", @@ -100,9 +100,9 @@ void generateTaskPromptReturnsFailureWhenTemplateIsMissing() { void generateTaskPromptReturnsFailureWhenRenderingFails() { DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "energy_saving", null), + new ScenarioRecognitionResult(true, "energy-saving", null), List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), "en-US", "Identify the best matching scenario.", "Choose from the provided scenario list.", @@ -127,7 +127,7 @@ void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissi "prompt_resources/prompts/scenario_recognition/zh-CN/system.md"); }, List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), "zh-CN", "Identify the best matching scenario.", "Choose from the provided scenario list.", diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java index 60b5dd7d..63501d66 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java @@ -17,7 +17,7 @@ void loadDeserializesScenarioCatalogFromLocalFile() { List scenarios = loader.load("zh-CN"); assertEquals(3, scenarios.size()); - assertEquals("subscribe_incident", scenarios.get(0).scenarioCode()); - assertEquals("energy_saving", scenarios.get(1).scenarioCode()); + assertEquals("subscribe-incident", scenarios.get(0).scenarioCode()); + assertEquals("energy-saving", scenarios.get(1).scenarioCode()); } } diff --git a/a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/energy_saving/en-US/slot.json b/a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/energy-saving/en-US/slot.json similarity index 100% rename from a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/energy_saving/en-US/slot.json rename to a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/energy-saving/en-US/slot.json diff --git a/a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/free_text/en-US/slot.json b/a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/free-text/en-US/slot.json similarity index 100% rename from a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/free_text/en-US/slot.json rename to a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/free-text/en-US/slot.json diff --git a/a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/v1/energy_saving/en-US/template.md b/a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/v1/energy-saving/en-US/template.md similarity index 100% rename from a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/v1/energy_saving/en-US/template.md rename to a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/v1/energy-saving/en-US/template.md diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParserTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParserTest.java index 5a550820..80d1addc 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParserTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParserTest.java @@ -13,10 +13,10 @@ class JacksonJsonValueParserTest { void parseObjectReturnsJsonObjectFields() { JsonValueParser parser = new JacksonJsonValueParser(); - Map parsed = parser.parseObject("{\"matched\":true,\"scenario_code\":\"energy_saving\"}"); + Map parsed = parser.parseObject("{\"matched\":true,\"scenario_code\":\"energy-saving\"}"); assertEquals(true, parsed.get("matched")); - assertEquals("energy_saving", parsed.get("scenario_code")); + assertEquals("energy-saving", parsed.get("scenario_code")); } @Test diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java index e985babe..2cac3358 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java @@ -50,7 +50,7 @@ void extractSlotsParsesFormattedStructuredJsonPayload() { "Return slots as JSON."); StructuredSlotExtractionResult result = - extractor.extractSlots("Analyze Site A with critical severity.", "energy_saving", "en-US"); + extractor.extractSlots("Analyze Site A with critical severity.", "energy-saving", "en-US"); assertEquals( Map.of( @@ -87,7 +87,7 @@ void extractSlotsPreservesSlotTextContainingClosingBrace() { "Return slots as JSON."); StructuredSlotExtractionResult result = - extractor.extractSlots("Analyze Site A with fallback note.", "energy_saving", "en-US"); + extractor.extractSlots("Analyze Site A with fallback note.", "energy-saving", "en-US"); assertEquals(Map.of("site", "Site A", "additional_notes", "Need } fallback"), result.slots()); assertEquals(List.of(), result.slotErrors()); diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java index 214beb22..e24b37eb 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java @@ -20,21 +20,21 @@ class ScenarioRecognizerTest { @Test void recognizeBuildsStructuredMessagesAndReturnsMatchedScenario() { RecordingClient llmClient = - new RecordingClient("{\"matched\":true,\"scenario_code\":\"energy_saving\",\"error_message\":null}"); + new RecordingClient("{\"matched\":true,\"scenario_code\":\"energy-saving\",\"error_message\":null}"); ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient); ScenarioRecognitionResult result = recognizer.recognize( "Please analyze site A energy usage.", List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), "Identify the best matching scenario.", "Choose from the provided scenario list."); assertTrue(result.matched()); - assertEquals("energy_saving", result.scenarioCode()); + assertEquals("energy-saving", result.scenarioCode()); assertEquals(2, llmClient.lastMessages().size()); assertEquals("system", llmClient.lastMessages().get(0).get("role")); - assertTrue(llmClient.lastMessages().get(1).get("content").contains("energy_saving")); + assertTrue(llmClient.lastMessages().get(1).get("content").contains("energy-saving")); assertTrue(llmClient.lastSchema().containsKey("required")); } @@ -50,7 +50,7 @@ void recognizeRejectsMatchedPayloadWithoutScenarioCode() { () -> recognizer.recognize( "Analyze site A energy usage.", List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), "Identify the best matching scenario.", "Choose from the provided scenario list.")); } @@ -60,7 +60,7 @@ void recognizeCanUseSharedJsonParserAbstraction() { LLMClient llmClient = new RecordingClient("ignored"); Map payload = new LinkedHashMap<>(); payload.put("matched", true); - payload.put("scenario_code", "energy_saving"); + payload.put("scenario_code", "energy-saving"); payload.put("error_message", null); RecordingJsonValueParser parser = new RecordingJsonValueParser(payload); @@ -68,12 +68,12 @@ void recognizeCanUseSharedJsonParserAbstraction() { ScenarioRecognitionResult result = recognizer.recognize( "Please analyze site A energy usage.", List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), "Identify the best matching scenario.", "Choose from the provided scenario list."); assertTrue(result.matched()); - assertEquals("energy_saving", result.scenarioCode()); + assertEquals("energy-saving", result.scenarioCode()); assertEquals("ignored", parser.lastPayload); } diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java index 683f663e..dffd52ab 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java @@ -24,13 +24,13 @@ void loadScenarioCatalogMapsJacksonAnnotatedRecords() { List scenarios = new ClasspathPromptScenarioCatalogLoader(resourceLoader).load("en"); assertEquals(3, scenarios.size()); - assertEquals("handoff_planning", scenarios.get(0).scenarioCode()); + assertEquals("handoff-planning", scenarios.get(0).scenarioCode()); assertEquals("Cross-Team Handoff Planning", scenarios.get(0).scenarioName()); } @Test void loadTemplateReturnsExactMarkdownPayload() { - String template = new ClasspathPromptTemplateLoader(resourceLoader).loadTemplate("handoff_planning", "en"); + String template = new ClasspathPromptTemplateLoader(resourceLoader).loadTemplate("handoff-planning", "en"); template = normalizeLineEndings(template); assertEquals( """ @@ -62,9 +62,9 @@ void loadTemplateReturnsExactMarkdownPayload() { @Test void loadSlotSchemaMapsJacksonAnnotatedRecords() { PromptSlotSchema schema = - new ClasspathPromptSlotSchemaLoader(resourceLoader).loadSlotSchema("handoff_planning", "en"); + new ClasspathPromptSlotSchemaLoader(resourceLoader).loadSlotSchema("handoff-planning", "en"); - assertEquals("handoff_planning", schema.scenarioCode()); + assertEquals("handoff-planning", schema.scenarioCode()); assertEquals(7, schema.slotDefinitions().size()); assertEquals("task_id", schema.slotDefinitions().get(0).name()); assertEquals(true, schema.slotDefinitions().get(0).required()); diff --git a/a2a-t-prompt/src/test/resources/prompt_resources/scenarios/en/scenarios.json b/a2a-t-prompt/src/test/resources/prompt_resources/scenarios/en/scenarios.json index e40cf4b7..348468fc 100644 --- a/a2a-t-prompt/src/test/resources/prompt_resources/scenarios/en/scenarios.json +++ b/a2a-t-prompt/src/test/resources/prompt_resources/scenarios/en/scenarios.json @@ -3,7 +3,7 @@ "default_language": "en", "scenarios": [ { - "scenario_code": "handoff_planning", + "scenario_code": "handoff-planning", "scenario_name": "Cross-Team Handoff Planning", "description": "Create a structured handoff plan for work that spans product, engineering, security, and operations ownership.", "example": "Prepare a staged migration plan for moving the billing workflow from the legacy scheduler to the event-driven orchestrator." diff --git a/a2a-t-prompt/src/test/resources/prompt_resources/slots/Task-T/v1/handoff_planning/en/slot.json b/a2a-t-prompt/src/test/resources/prompt_resources/slots/Task-T/v1/handoff-planning/en/slot.json similarity index 100% rename from a2a-t-prompt/src/test/resources/prompt_resources/slots/Task-T/v1/handoff_planning/en/slot.json rename to a2a-t-prompt/src/test/resources/prompt_resources/slots/Task-T/v1/handoff-planning/en/slot.json diff --git a/a2a-t-prompt/src/test/resources/prompt_resources/templates/Task-T/v1/handoff_planning/en/template.md b/a2a-t-prompt/src/test/resources/prompt_resources/templates/Task-T/v1/handoff-planning/en/template.md similarity index 100% rename from a2a-t-prompt/src/test/resources/prompt_resources/templates/Task-T/v1/handoff_planning/en/template.md rename to a2a-t-prompt/src/test/resources/prompt_resources/templates/Task-T/v1/handoff-planning/en/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json index 6c13fd01..ba7ae31b 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json @@ -1,19 +1,19 @@ { "scenarios": [ { - "scenario_code": "subscribe_incident", + "scenario_code": "subscribe-incident", "scenario_name": "Incident Event Subscription", "description": "Used to generate an A2A-T task request for subscribing to Incident events in a telecom network management system, describing the subscription target, conditions, and reporting method for network-side faults, alarms, or Incident notifications.", "example": "Help me generate an Incident subscription task: subscribe to fiber break and pigtail fault for base station equipment, only critical and high severity, reported via DataPart." }, { - "scenario_code": "energy_saving", + "scenario_code": "energy-saving", "scenario_name": "Energy Efficiency Optimization Task", "description": "Used to generate an A2A-T task request for an energy efficiency optimization task in a telecom network management system.", "example": "Achieve a 30% energy consumption reduction in the Songshanhu area while ensuring the rate guarantee is no less than 50Mbps." }, { - "scenario_code": "private_line_complaint", + "scenario_code": "private-line-complaint", "scenario_name": "Private Line Complaint", "description": "Used to generate an A2A-T task request for network-side fault root cause diagnosis in a private line complaint scenario in a telecom network management system.", "example": "Please confirm the root cause of fault fault-id-1-328-20260510-58489 that occurred on the Guangdong enterprise private line at 2026-05-10T18:25:30Z, and provide repair suggestions." diff --git a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json index e30eee97..c858ec0d 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json @@ -1,19 +1,19 @@ { "scenarios": [ { - "scenario_code": "subscribe_incident", + "scenario_code": "subscribe-incident", "scenario_name": "Incident 事件订阅", "description": "用于生成电信网管系统中的 Incident 事件订阅类 A2A-T 任务请求,围绕网络侧故障、告警或 Incident 通知的订阅目标、订阅条件和上报方式进行描述。", "example": "帮我生成一个 Incident 订阅任务:订阅基站设备的光纤中断和尾纤故障,只看 critical 和 high,通过 DataPart 上报。" }, { - "scenario_code": "energy_saving", + "scenario_code": "energy-saving", "scenario_name": "能效优化任务下发", "description": "用于生成电信网管系统中 能效优化任务的 A2A-T 任务请求。", "example": "实现松山湖区域30%的能耗降低,同时确保速率保障不低于50Mbps。" }, { - "scenario_code": "private_line_complaint", + "scenario_code": "private-line-complaint", "scenario_name": "专线投诉", "description": "用于生成电信网管系统中 专线投诉场景的网络侧故障根因诊断的 A2A-T 任务请求。", "example": "请确认广东政企专线2026-05-10T18:25:30Z发生的故障fault-id-1-328-20260510-58489的根因,并提供修复建议。" diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe_incident/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe-incident/en-US/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe_incident/en-US/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe-incident/en-US/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe_incident/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe-incident/zh-CN/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe_incident/zh-CN/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe-incident/zh-CN/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy_saving/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy-saving/en-US/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy_saving/en-US/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy-saving/en-US/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy_saving/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy-saving/zh-CN/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy_saving/zh-CN/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy-saving/zh-CN/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private_line_complaint/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/en-US/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private_line_complaint/en-US/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/en-US/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private_line_complaint/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/zh-CN/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private_line_complaint/zh-CN/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/zh-CN/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/accept-reject/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/accept-reject/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/accept-reject/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/accept-reject/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/accept-reject/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/accept-reject/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/accept-reject/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/accept-reject/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/propose/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/propose/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/propose/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/propose/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/propose/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/propose/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility_negotiation/propose/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/propose/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/accept-reject/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/accept-reject/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/accept-reject/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/accept-reject/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/accept-reject/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/accept-reject/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/accept-reject/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/accept-reject/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/propose/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/propose/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/propose/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/propose/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/propose/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/propose/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information_negotiation/propose/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/propose/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/accept-reject/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/accept-reject/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/accept-reject/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/accept-reject/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/accept-reject/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/accept-reject/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/accept-reject/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/accept-reject/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/propose/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/propose/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/propose/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/propose/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/propose/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/propose/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target_negotiation/propose/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/propose/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe_incident/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe-incident/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe_incident/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe-incident/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe_incident/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe-incident/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe_incident/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe-incident/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy_saving/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy-saving/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy_saving/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy-saving/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy_saving/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy-saving/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy_saving/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy-saving/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private_line_complaint/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private_line_complaint/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private_line_complaint/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private_line_complaint/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/zh-CN/template.md diff --git a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java index e528c436..83f67b9e 100644 --- a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java +++ b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java @@ -41,13 +41,13 @@ void raisesTypedErrorWhenResourceIsMissing() { void loadsPackagedScenarioCatalogForZhCn() { String text = loader.loadText(new PromptResourceKey("scenarios", "catalog", "zh-CN", "scenarios.json")); - org.junit.jupiter.api.Assertions.assertTrue(text.contains("subscribe_incident")); - org.junit.jupiter.api.Assertions.assertTrue(text.contains("energy_saving")); + org.junit.jupiter.api.Assertions.assertTrue(text.contains("subscribe-incident")); + org.junit.jupiter.api.Assertions.assertTrue(text.contains("energy-saving")); } @Test void loadsPackagedSubscribeIncidentSlotSchemaWithSemanticHint() { - String text = loader.loadText(new PromptResourceKey("slots", "Notification-T", "subscribe_incident", "zh-CN", "slot.json")); + String text = loader.loadText(new PromptResourceKey("slots", "Notification-T", "subscribe-incident", "zh-CN", "slot.json")); assertTrue(text.contains("\"required\": []")); assertTrue(text.contains("x-a2at-value-constraint")); diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java index 502521de..94b5e3cb 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java @@ -19,9 +19,9 @@ void loadReadsSlotDefinitionsUsingPromptPackagePublicLoaders() { LocalFileServerPromptTemplateLoader loader = new LocalFileServerPromptTemplateLoader( Path.of("..", "a2a-t-resources", "src", "main", "resources", "prompt_resources")); - PromptTemplateDefinition definition = loader.load("energy_saving", "zh-CN"); + PromptTemplateDefinition definition = loader.load("energy-saving", "zh-CN"); - assertEquals("energy_saving", definition.scenarioCode()); + assertEquals("energy-saving", definition.scenarioCode()); assertEquals(6, definition.slotDefinitions().size()); assertEquals(false, definition.slotDefinitions().get(0).required()); } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java index da7dd49e..f7346db0 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java @@ -18,7 +18,7 @@ class DefaultServerPromptComplianceOrchestratorTest { @Test void checkTaskPromptReturnsSuccessWhenMetadataExtractionAndValidationPass() { ProcessedPromptMetadata metadata = - new ProcessedPromptMetadata("energy_saving", "en-US", "Site: {site}", Map.of("site", "Site A")); + new ProcessedPromptMetadata("energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A")); RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor(metadata); RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); DefaultServerPromptComplianceOrchestrator orchestrator = @@ -51,7 +51,7 @@ void checkTaskPromptReturnsFailureWhenMetadataExtractionFails() { @Test void checkTaskPromptReturnsFailureWhenSemanticValidationFails() { ProcessedPromptMetadata metadata = - new ProcessedPromptMetadata("energy_saving", "en-US", "Site: {site}", Map.of("site", "Site A")); + new ProcessedPromptMetadata("energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A")); DefaultServerPromptComplianceOrchestrator orchestrator = new DefaultServerPromptComplianceOrchestrator( new RecordingPromptMetadataExtractor(metadata), new RecordingPromptSemanticValidator(new PromptComplianceCheckException( diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java index 263c862d..f45f3ce5 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java @@ -28,7 +28,7 @@ class LlmBackedPromptMetadataExtractorTest { @Test void extractResolvesScenarioLoadsTemplateAndReturnsExtractedSlots() { LLMClient llmClient = - new RecordingClient("{\"matched\":true,\"scenario_code\":\"subscribe_incident\",\"error_message\":null}"); + new RecordingClient("{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); PromptTemplateTextLoader templateLoader = (scenarioCode, language) -> "## notification_topic\n{{notification_topic}}\n"; PromptSlotSchemaLoader slotSchemaLoader = (scenarioCode, language) -> new PromptSlotSchema( @@ -38,7 +38,7 @@ void extractResolvesScenarioLoadsTemplateAndReturnsExtractedSlots() { LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( new ScenarioRecognizer(llmClient), List.of(new ScenarioDefinition( - "subscribe_incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), "zh-CN", "Identify scenario.", "Choose scenario.", @@ -48,7 +48,7 @@ void extractResolvesScenarioLoadsTemplateAndReturnsExtractedSlots() { ProcessedPromptMetadata metadata = extractor.extract("## notification_topic\nIncident\n"); - assertEquals("subscribe_incident", metadata.scenarioCode()); + assertEquals("subscribe-incident", metadata.scenarioCode()); assertEquals("zh-CN", metadata.language()); assertEquals("## notification_topic\n{{notification_topic}}\n", metadata.templateText()); assertEquals(Map.of(SLOT_NAME, "Incident"), metadata.slots()); @@ -61,7 +61,7 @@ void extractReturnsPromptParseErrorWhenScenarioRecognitionDoesNotMatch() { LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( new ScenarioRecognizer(llmClient), List.of(new ScenarioDefinition( - "subscribe_incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), "zh-CN", "Identify scenario.", "Choose scenario.", @@ -79,11 +79,11 @@ void extractReturnsPromptParseErrorWhenScenarioRecognitionDoesNotMatch() { @Test void extractReturnsSlotValidationErrorWhenStructuredExtractionReportsSlotErrors() { LLMClient llmClient = - new RecordingClient("{\"matched\":true,\"scenario_code\":\"subscribe_incident\",\"error_message\":null}"); + new RecordingClient("{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( new ScenarioRecognizer(llmClient), List.of(new ScenarioDefinition( - "subscribe_incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), "zh-CN", "Identify scenario.", "Choose scenario.", @@ -105,11 +105,11 @@ void extractReturnsSlotValidationErrorWhenStructuredExtractionReportsSlotErrors( @Test void extractPropagatesTemplateLoadErrorsAsGenerationFailures() { LLMClient llmClient = - new RecordingClient("{\"matched\":true,\"scenario_code\":\"subscribe_incident\",\"error_message\":null}"); + new RecordingClient("{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( new ScenarioRecognizer(llmClient), List.of(new ScenarioDefinition( - "subscribe_incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), "zh-CN", "Identify scenario.", "Choose scenario.", diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractorTest.java index 40c4c5f9..04a40ef9 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractorTest.java @@ -20,7 +20,7 @@ class TemplateMatchingPromptMetadataExtractorTest { void extractReturnsScenarioLanguageTemplateAndSlotsForMatchedTemplate() { TemplateMatchingPromptMetadataExtractor extractor = new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "energy_saving", + "energy-saving", "en-US", "Site: {site}\nNotes: {additional_notes}", List.of( @@ -29,7 +29,7 @@ void extractReturnsScenarioLanguageTemplateAndSlotsForMatchedTemplate() { ProcessedPromptMetadata metadata = extractor.extract("Site: Site A\nNotes: critical"); - assertEquals("energy_saving", metadata.scenarioCode()); + assertEquals("energy-saving", metadata.scenarioCode()); assertEquals("en-US", metadata.language()); assertEquals("Site: {site}\nNotes: {additional_notes}", metadata.templateText()); assertEquals(Map.of("site", "Site A", "additional_notes", "critical"), metadata.slots()); @@ -39,7 +39,7 @@ void extractReturnsScenarioLanguageTemplateAndSlotsForMatchedTemplate() { void extractRejectsPromptThatDoesNotMatchAnyKnownTemplate() { TemplateMatchingPromptMetadataExtractor extractor = new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "energy_saving", + "energy-saving", "en-US", "Site: {site}", List.of(new PromptTemplateSlotDefinition("site", true))))); @@ -55,7 +55,7 @@ void extractRejectsPromptThatDoesNotMatchAnyKnownTemplate() { void extractRejectsMissingRequiredSlotValueAfterTemplateMatch() { TemplateMatchingPromptMetadataExtractor extractor = new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "energy_saving", + "energy-saving", "en-US", "Site: {site}", List.of(new PromptTemplateSlotDefinition("site", true))))); @@ -71,7 +71,7 @@ void extractRejectsMissingRequiredSlotValueAfterTemplateMatch() { void extractMatchesTemplateWithDoubleBracedPlaceholders() { TemplateMatchingPromptMetadataExtractor extractor = new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "subscribe_incident", + "subscribe-incident", "en-US", "Topic: {{topic}}\nCondition: {{condition}}", List.of( @@ -81,7 +81,7 @@ void extractMatchesTemplateWithDoubleBracedPlaceholders() { ProcessedPromptMetadata metadata = extractor.extract( "Topic: Incident\nCondition: Severity is critical; alert type is flash"); - assertEquals("subscribe_incident", metadata.scenarioCode()); + assertEquals("subscribe-incident", metadata.scenarioCode()); assertEquals("en-US", metadata.language()); assertEquals( Map.of( @@ -143,7 +143,7 @@ void extractMatchesCollapsedPromptAgainstOriginalTemplateWithDescriptiveLines() TemplateMatchingPromptMetadataExtractor extractor = new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "subscribe_incident", + "subscribe-incident", "en-US", originalTemplate, List.of( @@ -153,7 +153,7 @@ void extractMatchesCollapsedPromptAgainstOriginalTemplateWithDescriptiveLines() ProcessedPromptMetadata metadata = extractor.extract(processedPrompt); - assertEquals("subscribe_incident", metadata.scenarioCode()); + assertEquals("subscribe-incident", metadata.scenarioCode()); assertEquals("en-US", metadata.language()); assertEquals( Map.of( diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java index 24b6d5b0..3e3db2e5 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java @@ -31,7 +31,7 @@ void validatePassesWhenSemanticValidatorApprovesSlots() { assertDoesNotThrow(() -> validator.validate( "## notification_topic\nIncident\n", new ProcessedPromptMetadata( - "subscribe_incident", + "subscribe-incident", "zh-CN", "## notification_topic\n{{notification_topic}}\n", Map.of(SLOT_NAME, "Incident")))); @@ -52,7 +52,7 @@ void validatePassesWhenSemanticValidatorReturnsFormattedJson() { assertDoesNotThrow(() -> validator.validate( "## notification_topic\nIncident\n", new ProcessedPromptMetadata( - "subscribe_incident", + "subscribe-incident", "zh-CN", "## notification_topic\n{{notification_topic}}\n", Map.of(SLOT_NAME, "Incident")))); @@ -71,7 +71,7 @@ void validateReturnsSlotValidationErrorWhenSemanticValidatorRejectsSlots() { () -> validator.validate( "## notification_topic\nIncident\n", new ProcessedPromptMetadata( - "subscribe_incident", + "subscribe-incident", "zh-CN", "## notification_topic\n{{notification_topic}}\n", Map.of(SLOT_NAME, "Incident")))); From 63460d96c693ce1b5d8121fce6e1dc6ee6552269 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Wed, 19 Aug 2026 19:42:07 +0800 Subject: [PATCH 023/295] fix: fix the test cases Signed-off-by: zhangwenjun-huawei --- .../a2at/sdk/client/api/A2ATClientTest.java | 64 ++++++++--------- .../sdk/client/model/MetadataContentTest.java | 4 +- ...tructuredClientSlotValueExtractorTest.java | 2 +- ...tTemplateDrivenSlotValueExtractorTest.java | 2 +- ...lientPromptGenerationOrchestratorTest.java | 68 +++++++++---------- ...tructuredPromptSlotValueExtractorTest.java | 8 +-- 6 files changed, 74 insertions(+), 74 deletions(-) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index 7c1aaef9..3a41e9a1 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -224,11 +224,11 @@ void templateUriEntryPointsGeneratePromptsFromStructuredInput() throws IOExcepti A2ATClient client = new A2ATClient(envFile); Map inputData = Map.of("site", "Site A", "target", "Reduce power by 10%"); - PromptGenerationResult taskResult = client.generateTaskPromptFromJsonData(inputData, "energy_saving"); + PromptGenerationResult taskResult = client.generateTaskPromptFromJsonData(inputData, "energy-saving"); PromptGenerationResult authorizationResult = - client.generateAuthorizationPromptFromJsonData(inputData, "energy_saving"); + client.generateAuthorizationPromptFromJsonData(inputData, "energy-saving"); PromptGenerationResult notificationResult = - client.generateNotificationPromptFromJsonData(inputData, "energy_saving"); + client.generateNotificationPromptFromJsonData(inputData, "energy-saving"); assertTrue(taskResult.success()); assertTrue(authorizationResult.success()); @@ -244,7 +244,7 @@ void templateUriEntryPointRendersEmptySlotValuesForNlInputUnderLocalRuleProvider A2ATClient client = new A2ATClient(envFile); PromptGenerationResult result = - client.generateTaskPromptFromNl("Please analyze Site A power usage.", "energy_saving"); + client.generateTaskPromptFromNl("Please analyze Site A power usage.", "energy-saving"); assertTrue(result.success()); assertEquals("Site: \\nTarget: ", result.promptText()); @@ -276,24 +276,24 @@ void fromTextEntryPointsReturnMetadataContent() throws IOException { A2ATClient client = new A2ATClient(envFile); MetadataContent taskResult = - client.generateTaskPromptFromText("Please analyze Site A.", "energy_saving"); + client.generateTaskPromptFromText("Please analyze Site A.", "energy-saving"); MetadataContent authResult = - client.generateAuthPromptFromText("Authorize access.", "energy_saving"); + client.generateAuthPromptFromText("Authorize access.", "energy-saving"); MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", "energy_saving"); + client.generateNotificationPromptFromText("Report finished.", "energy-saving"); assertNotNull(taskResult); - assertEquals("energy_saving", taskResult.templateUri()); + assertEquals("energy-saving", taskResult.templateUri()); assertNotNull(taskResult.promptText()); assertNotNull(taskResult.extensionUri()); assertNotNull(authResult); - assertEquals("energy_saving", authResult.templateUri()); + assertEquals("energy-saving", authResult.templateUri()); assertNotNull(authResult.promptText()); assertNotNull(authResult.extensionUri()); assertNotNull(notificationResult); - assertEquals("energy_saving", notificationResult.templateUri()); + assertEquals("energy-saving", notificationResult.templateUri()); assertNotNull(notificationResult.promptText()); assertNotNull(notificationResult.extensionUri()); } @@ -306,24 +306,24 @@ void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { Map schema = Map.of("type", "object"); MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, "energy_saving"); + client.generateTaskPromptFromDataWithSchema(data, schema, "energy-saving"); MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, "energy_saving"); + client.generateAuthPromptFromDataWithSchema(data, schema, "energy-saving"); MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, "energy_saving"); + client.generateNotificationPromptFromDataWithSchema(data, schema, "energy-saving"); assertNotNull(taskResult); - assertEquals("energy_saving", taskResult.templateUri()); + assertEquals("energy-saving", taskResult.templateUri()); assertNotNull(taskResult.promptText()); assertNotNull(taskResult.extensionUri()); assertNotNull(authResult); - assertEquals("energy_saving", authResult.templateUri()); + assertEquals("energy-saving", authResult.templateUri()); assertNotNull(authResult.promptText()); assertNotNull(authResult.extensionUri()); assertNotNull(notificationResult); - assertEquals("energy_saving", notificationResult.templateUri()); + assertEquals("energy-saving", notificationResult.templateUri()); assertNotNull(notificationResult.promptText()); assertNotNull(notificationResult.extensionUri()); } @@ -334,11 +334,11 @@ void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { A2ATClient client = new A2ATClient(envFile); MetadataContent taskResult = - client.generateTaskPromptFromText("Please analyze Site A.", "energy_saving"); + client.generateTaskPromptFromText("Please analyze Site A.", "energy-saving"); MetadataContent authResult = - client.generateAuthPromptFromText("Authorize access.", "energy_saving"); + client.generateAuthPromptFromText("Authorize access.", "energy-saving"); MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", "energy_saving"); + client.generateNotificationPromptFromText("Report finished.", "energy-saving"); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); @@ -353,11 +353,11 @@ void fromDataWithSchemaReturnsCorrectExtensionUriPerContentType() throws IOExcep Map schema = Map.of("type", "object"); MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, "energy_saving"); + client.generateTaskPromptFromDataWithSchema(data, schema, "energy-saving"); MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, "energy_saving"); + client.generateAuthPromptFromDataWithSchema(data, schema, "energy-saving"); MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, "energy_saving"); + client.generateNotificationPromptFromDataWithSchema(data, schema, "energy-saving"); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); @@ -404,9 +404,9 @@ void fromTextUnderLocalRuleRendersEmptySlotsForNlInput() throws IOException { A2ATClient client = new A2ATClient(envFile); MetadataContent result = - client.generateTaskPromptFromText("Please analyze Site A power usage.", "energy_saving"); + client.generateTaskPromptFromText("Please analyze Site A power usage.", "energy-saving"); - assertEquals("energy_saving", result.templateUri()); + assertEquals("energy-saving", result.templateUri()); assertEquals("Site: \\nTarget: ", result.promptText()); } @@ -418,9 +418,9 @@ void fromDataWithSchemaUnderLocalRuleIgnoresSchema() throws IOException { Map schema = Map.of("type", "object", "required", Arrays.asList("site", "target")); MetadataContent result = - client.generateTaskPromptFromDataWithSchema(data, schema, "energy_saving"); + client.generateTaskPromptFromDataWithSchema(data, schema, "energy-saving"); - assertEquals("energy_saving", result.templateUri()); + assertEquals("energy-saving", result.templateUri()); assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); } @@ -431,17 +431,17 @@ void existingFromNlAndFromJsonDataMethodsStillReturnPromptGenerationResult() thr Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); PromptGenerationResult taskFromNl = - client.generateTaskPromptFromNl("Please analyze Site A.", "energy_saving"); + client.generateTaskPromptFromNl("Please analyze Site A.", "energy-saving"); PromptGenerationResult taskFromJson = - client.generateTaskPromptFromJsonData(data, "energy_saving"); + client.generateTaskPromptFromJsonData(data, "energy-saving"); PromptGenerationResult authFromNl = - client.generateAuthorizationPromptFromNl("Authorize access.", "energy_saving"); + client.generateAuthorizationPromptFromNl("Authorize access.", "energy-saving"); PromptGenerationResult authFromJson = - client.generateAuthorizationPromptFromJsonData(data, "energy_saving"); + client.generateAuthorizationPromptFromJsonData(data, "energy-saving"); PromptGenerationResult notificationFromNl = - client.generateNotificationPromptFromNl("Report finished.", "energy_saving"); + client.generateNotificationPromptFromNl("Report finished.", "energy-saving"); PromptGenerationResult notificationFromJson = - client.generateNotificationPromptFromJsonData(data, "energy_saving"); + client.generateNotificationPromptFromJsonData(data, "energy-saving"); assertTrue(taskFromNl.success()); assertTrue(taskFromJson.success()); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java index 32a108e9..a68045ea 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java @@ -36,7 +36,7 @@ void exposesNoStaticFactoryMethods() { @Test void buildMetadataContentReturnsMapWithExtensionUriAndTemplateUri() { MetadataContent content = new MetadataContent( - "energy_saving", "Site: Site A", + "energy-saving", "Site: Site A", "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"); Map result = content.buildMetadataContent(); @@ -44,7 +44,7 @@ void buildMetadataContentReturnsMapWithExtensionUriAndTemplateUri() { assertEquals(2, result.size()); assertEquals("Site: Site A", result.get( "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1")); - assertEquals("energy_saving", result.get("template_uri")); + assertEquals("energy-saving", result.get("template_uri")); } @Test diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java index ef5975c5..17ee7e0f 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java @@ -89,7 +89,7 @@ void extractSlotsWithSchemaCallsDelegateFourArgMethod() { Map dataSchema = Map.of("site", "site description", "severity", "severity description"); Map slots = extractor.extractSlotsWithSchema( "Analyze Site A with critical severity.", - "energy_saving", + "energy-saving", "en-US", "Site: {site}\nNotes: {additional_notes}\nLimit: {limit}\nSeverity: {severity}", dataSchema); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java index 3eb9ccfb..cf929efa 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java @@ -171,7 +171,7 @@ void extractSlotsWithSchemaIgnoresSchemaAndBehavesIdenticallyToExtractSlots() { Map slots = extractor.extractSlotsWithSchema( Map.of("site", "Site A", "additional_notes", "critical", "ignored", "value"), - "energy_saving", + "energy-saving", "en-US", "Site: {site}\nNotes: {additional_notes}", Map.of("ignored_field", "some description")); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index b0282f34..cd3ce887 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -157,17 +157,17 @@ void eachTemplateUriEntryPointGeneratesPromptWithoutScenarioRecognition() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(recognizer, templateLoader, slotValueExtractor); - PromptGenerationResult taskNl = orchestrator.generateTaskPromptFromNl("Analyze Site A.", "energy_saving"); + PromptGenerationResult taskNl = orchestrator.generateTaskPromptFromNl("Analyze Site A.", "energy-saving"); PromptGenerationResult taskJson = - orchestrator.generateTaskPromptFromJsonData(Map.of("site", "Site A"), "energy_saving"); + orchestrator.generateTaskPromptFromJsonData(Map.of("site", "Site A"), "energy-saving"); PromptGenerationResult authorizationNl = orchestrator.generateAuthorizationPromptFromNl("Grant access.", "database_read"); PromptGenerationResult authorizationJson = orchestrator.generateAuthorizationPromptFromJsonData(Map.of("site", "Site A"), "database_read"); PromptGenerationResult notificationNl = - orchestrator.generateNotificationPromptFromNl("Report finished.", "energy_saving"); + orchestrator.generateNotificationPromptFromNl("Report finished.", "energy-saving"); PromptGenerationResult notificationJson = - orchestrator.generateNotificationPromptFromJsonData(Map.of("site", "Site A"), "energy_saving"); + orchestrator.generateNotificationPromptFromJsonData(Map.of("site", "Site A"), "energy-saving"); assertTrue(taskNl.success()); assertTrue(taskJson.success()); @@ -237,7 +237,7 @@ void templateUriGenerationReturnsTemplateNotFoundWhenTemplateIsMissing() { }, new FakeSlotValueExtractor(Map.of("site", "Site A"))); - PromptGenerationResult result = orchestrator.generateTaskPromptFromNl("Analyze Site A.", "energy_saving"); + PromptGenerationResult result = orchestrator.generateTaskPromptFromNl("Analyze Site A.", "energy-saving"); assertFalse(result.success()); assertNotNull(result.failure()); @@ -253,7 +253,7 @@ void templateUriGenerationReturnsRenderFailedWhenRenderingFails() { new FakeSlotValueExtractor(Map.of("site", "Site A"))); PromptGenerationResult result = - orchestrator.generateNotificationPromptFromJsonData(Map.of("site", "Site A"), "energy_saving"); + orchestrator.generateNotificationPromptFromJsonData(Map.of("site", "Site A"), "energy-saving"); assertFalse(result.success()); assertNotNull(result.failure()); @@ -268,7 +268,7 @@ void templateUriGenerationProcessesNullAndBlankUserInputWithoutAdditionalValidat DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( new RecordingScenarioRecognizer(), new FakeTemplateLoader("Site: {site}"), slotValueExtractor); - PromptGenerationResult result = orchestrator.generateTaskPromptFromNl(userInput, "energy_saving"); + PromptGenerationResult result = orchestrator.generateTaskPromptFromNl(userInput, "energy-saving"); assertTrue(result.success()); assertEquals("Site: Site A", result.promptText()); @@ -283,7 +283,7 @@ private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestra return new DefaultClientPromptGenerationOrchestrator( recognizer, List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), "en-US", "Identify the best matching scenario.", "Choose from the provided scenario list.", @@ -299,7 +299,7 @@ private static final class RecordingScenarioRecognizer implements ClientScenario public ScenarioRecognitionResult recognize( String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { this.invocationCount++; - return new ScenarioRecognitionResult(true, "energy_saving", null); + return new ScenarioRecognitionResult(true, "energy-saving", null); } } @@ -417,9 +417,9 @@ void generateFromTemplateUriWithMetadataReturnsMetadataContentOnSuccess() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent result = orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving"); + MetadataContent result = orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving"); - assertEquals("energy_saving", result.templateUri()); + assertEquals("energy-saving", result.templateUri()); assertEquals("Site: Site A", result.promptText()); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); } @@ -431,7 +431,7 @@ void generateFromTemplateUriWithMetadataScenarioRecognizerIsNotInvoked() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); - orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving"); + orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving"); assertEquals(0, recognizer.invocationCount); } @@ -461,7 +461,7 @@ void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { new FakeSlotValueExtractor(Map.of("site", "Site A"))); assertThrows(ResourceNotFoundException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving")); } @Test @@ -472,7 +472,7 @@ void generateFromTemplateUriWithMetadataPropagatesRenderException() { new FakeSlotValueExtractor(Map.of("site", "Site A"))); assertThrows(net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving")); } @Test @@ -485,7 +485,7 @@ void generateFromTemplateUriWithMetadataPropagatesLlmRuntimeError() { }); assertThrows(LLMRuntimeError.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving")); } // --- MetadataContent pipeline tests (generateFromDataWithSchema) --- @@ -499,9 +499,9 @@ void generateFromDataWithSchemaReturnsMetadataContentOnSuccess() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), "energy_saving"); + Map.of("site", "Site A"), Map.of("site", "string"), "energy-saving"); - assertEquals("energy_saving", result.templateUri()); + assertEquals("energy-saving", result.templateUri()); assertEquals("Site: Site A", result.promptText()); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); } @@ -515,10 +515,10 @@ void generateFromDataWithSchemaPassesSchemaToExtractor() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); Map schema = Map.of("site", "string", "count", "number"); - orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), schema, "energy_saving"); + orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), schema, "energy-saving"); assertEquals(schema, slotValueExtractor.lastSchema); - assertEquals("energy_saving", slotValueExtractor.lastScenarioCode); + assertEquals("energy-saving", slotValueExtractor.lastScenarioCode); assertEquals("Site: {site}", slotValueExtractor.lastTemplateText); } @@ -531,7 +531,7 @@ void generateFromDataWithSchemaNullSchemaHandledGracefully() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), null, "energy_saving"); + Map.of("site", "Site A"), null, "energy-saving"); assertEquals("Site: Site A", result.promptText()); assertEquals(null, slotValueExtractor.lastSchema); @@ -546,7 +546,7 @@ void generateFromDataWithSchemaEmptySchemaHandledSameAsNull() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "energy_saving"); + Map.of("site", "Site A"), Map.of(), "energy-saving"); assertEquals("Site: Site A", result.promptText()); assertEquals(Map.of(), slotValueExtractor.lastSchema); @@ -575,7 +575,7 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundException() { new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); assertThrows(ResourceNotFoundException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy-saving")); } @Test @@ -586,7 +586,7 @@ void generateFromDataWithSchemaPropagatesRenderException() { new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); assertThrows(net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy-saving")); } @Test @@ -597,7 +597,7 @@ void generateFromDataWithSchemaPropagatesLlmRuntimeError() { new FailingExtractSlotsWithSchema(new LLMRuntimeError("LLM invocation failed."))); assertThrows(LLMRuntimeError.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy-saving")); } @Test @@ -606,10 +606,10 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundExceptionFromExtractor( new RecordingScenarioRecognizer(), new FakeTemplateLoader("Site: {site}"), new FailingExtractSlotsWithSchema( - new ResourceNotFoundException("Slot schema file does not exist.", "energy_saving"))); + new ResourceNotFoundException("Slot schema file does not exist.", "energy-saving"))); assertThrows(ResourceNotFoundException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy-saving")); } // --- Public entry point tests --- @@ -622,15 +622,15 @@ void eachMetadataEntryPointReturnsCorrectMetadataContent() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving"); + MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving"); MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "energy_saving"); + Map.of("site", "Site A"), Map.of(), "energy-saving"); MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "database_read"); MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( Map.of("site", "Site A"), Map.of(), "database_read"); - MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", "energy_saving"); + MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", "energy-saving"); MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "energy_saving"); + Map.of("site", "Site A"), Map.of(), "energy-saving"); assertTrue(taskText.promptText().contains("Site A")); assertTrue(taskData.promptText().contains("Site A")); @@ -646,15 +646,15 @@ void eachMetadataEntryPointUsesCorrectExtensionUri() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( new RecordingScenarioRecognizer(), templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); - MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving"); + MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving"); MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "energy_saving"); + Map.of("site", "Site A"), Map.of(), "energy-saving"); MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "database_read"); MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( Map.of("site", "Site A"), Map.of(), "database_read"); - MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", "energy_saving"); + MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", "energy-saving"); MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "energy_saving"); + Map.of("site", "Site A"), Map.of(), "energy-saving"); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskText.extensionUri()); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskData.extensionUri()); diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java index 6d1f407f..3765ab21 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java @@ -119,7 +119,7 @@ void should_injectDataSchemaSection_When_schemaIsProvided() { "site", "The target site name", "severity", "The severity level"); - extractor.extractSlots("Analyze Site A.", "energy_saving", "en-US", dataSchema); + extractor.extractSlots("Analyze Site A.", "energy-saving", "en-US", dataSchema); String userMessage = llmClient.lastUserContent(); assertTrue(userMessage.contains("[data_schema]")); @@ -147,7 +147,7 @@ void should_notIncludeDataSchemaSection_When_schemaIsNull() { "Extract slots from the input.", "Return slots as JSON."); - extractor.extractSlots("Analyze Site A.", "energy_saving", "en-US", null); + extractor.extractSlots("Analyze Site A.", "energy-saving", "en-US", null); String userMessage = llmClient.lastUserContent(); assertFalse(userMessage.contains("[data_schema]")); @@ -173,7 +173,7 @@ void should_notIncludeDataSchemaSection_When_schemaIsEmpty() { "Extract slots from the input.", "Return slots as JSON."); - extractor.extractSlots("Analyze Site A.", "energy_saving", "en-US", Map.of()); + extractor.extractSlots("Analyze Site A.", "energy-saving", "en-US", Map.of()); String userMessage = llmClient.lastUserContent(); assertFalse(userMessage.contains("[data_schema]")); @@ -203,7 +203,7 @@ void defaultExtractSlotsWithSchemaDelegatesToThreeArg() { extractor.extractSlots(input, code, lang); StructuredSlotExtractionResult result = lambdaExtractor.extractSlots( - "Analyze Site A.", "energy_saving", "en-US", Map.of("site", "desc")); + "Analyze Site A.", "energy-saving", "en-US", Map.of("site", "desc")); String userMessage = llmClient.lastUserContent(); assertFalse(userMessage.contains("[data_schema]")); From 648dbb64a14620370edb28ffb355676db4e29ba3 Mon Sep 17 00:00:00 2001 From: wangruiyang8 Date: Thu, 20 Aug 2026 10:04:41 +0800 Subject: [PATCH 024/295] refactor(sample): move resource env files to subscribe_incident, soften registration log - Move client.env and server.env from sample/{client,server}/ to sample/subscribe_incident/{client,server}/, matching the code package structure and the existing resource paths already referenced by README and runtime code. - Change the registry-center missing log from 'failed' to 'skipped' with a hint about direct mode, so first-time users are not alarmed. Signed-off-by: wangruiyang8 --- .../sample/subscribe_incident/server/ServerSampleMain.java | 4 ++-- .../sample/{ => subscribe_incident}/client/client.env | 0 .../sample/{ => subscribe_incident}/server/server.env | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename a2a-t-sample/src/main/resources/sample/{ => subscribe_incident}/client/client.env (100%) rename a2a-t-sample/src/main/resources/sample/{ => subscribe_incident}/server/server.env (100%) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java index e5621cee..f5bce7a2 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java @@ -51,8 +51,8 @@ public static ServerBootstrapResult runMain( emit(logSink, "[server] agent-card registration: success"); } else { emit(logSink, - "[server] agent-card registration failed, continuing startup: " - + registrationResult.getOrDefault("message", "unknown error")); + "[server] agent-card registration: skipped (no registry-center available, " + + "client direct mode is used instead)"); } AutoCloseable serverHandle = null; if (runtime instanceof A2AJavaServerRuntime a2aJavaServerRuntime diff --git a/a2a-t-sample/src/main/resources/sample/client/client.env b/a2a-t-sample/src/main/resources/sample/subscribe_incident/client/client.env similarity index 100% rename from a2a-t-sample/src/main/resources/sample/client/client.env rename to a2a-t-sample/src/main/resources/sample/subscribe_incident/client/client.env diff --git a/a2a-t-sample/src/main/resources/sample/server/server.env b/a2a-t-sample/src/main/resources/sample/subscribe_incident/server/server.env similarity index 100% rename from a2a-t-sample/src/main/resources/sample/server/server.env rename to a2a-t-sample/src/main/resources/sample/subscribe_incident/server/server.env From b511b7e23c1438208d61a5b1310ea5a52153944e Mon Sep 17 00:00:00 2001 From: w00609601 Date: Thu, 20 Aug 2026 10:41:57 +0800 Subject: [PATCH 025/295] feat: add PromptGenerationException with slot validation for MetadataContent APIs Signed-off-by: w00609601 --- .../openan/a2at/sdk/client/A2ATClient.java | 77 +------- ...ntPromptGenerationOrchestratorBuilder.java | 4 +- .../assembly/DefaultA2ATClientBuilder.java | 120 ++++-------- .../ClientPromptGenerationOrchestrator.java | 60 +----- ...ultClientPromptGenerationOrchestrator.java | 183 +++++++++++------- .../a2at/sdk/client/api/A2ATClientTest.java | 162 ++++++++++------ ...lientPromptGenerationOrchestratorTest.java | 182 ++++++++++++++--- .../sdk/core/exception/FailedParameter.java | 10 + .../exception/PromptGenerationException.java | 74 +++++++ 9 files changed, 499 insertions(+), 373 deletions(-) create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/FailedParameter.java create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index fc58ba33..a03f8f8f 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -49,81 +49,6 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { return promptGenerationOrchestrator.generateTaskPrompt(userInput); } - /** - * Generates a processed task prompt from natural-language input using the template identified by the template URI, - * bypassing scenario recognition. - * - * @param taskInputNl natural-language task input - * @param promptTemplateUri template URI identifying the target template - * @return prompt generation result containing either rendered prompt text or failure details - */ - public PromptGenerationResult generateTaskPromptFromNl(String taskInputNl, String promptTemplateUri) { - return promptGenerationOrchestrator.generateTaskPromptFromNl(taskInputNl, promptTemplateUri); - } - - /** - * Generates a processed task prompt from structured input using the template identified by the template URI, - * bypassing scenario recognition. - * - * @param taskInputJsonData structured task input as a string-to-object map - * @param promptTemplateUri template URI identifying the target template - * @return prompt generation result containing either rendered prompt text or failure details - */ - public PromptGenerationResult generateTaskPromptFromJsonData( - Map taskInputJsonData, String promptTemplateUri) { - return promptGenerationOrchestrator.generateTaskPromptFromJsonData(taskInputJsonData, promptTemplateUri); - } - - /** - * Generates an authorization prompt from natural-language input using the template identified by the authorization - * type, bypassing scenario recognition. - * - * @param inputNl natural-language authorization input - * @param authorizationType authorization type used as the template identifier - * @return prompt generation result containing either rendered prompt text or failure details - */ - public PromptGenerationResult generateAuthorizationPromptFromNl(String inputNl, String authorizationType) { - return promptGenerationOrchestrator.generateAuthorizationPromptFromNl(inputNl, authorizationType); - } - - /** - * Generates an authorization prompt from structured input using the template identified by the authorization type, - * bypassing scenario recognition. - * - * @param inputJsonData structured authorization input as a string-to-object map - * @param authorizationType authorization type used as the template identifier - * @return prompt generation result containing either rendered prompt text or failure details - */ - public PromptGenerationResult generateAuthorizationPromptFromJsonData( - Map inputJsonData, String authorizationType) { - return promptGenerationOrchestrator.generateAuthorizationPromptFromJsonData(inputJsonData, authorizationType); - } - - /** - * Generates a notification prompt from natural-language input using the template identified by the template URI, - * bypassing scenario recognition. - * - * @param inputNl natural-language notification input - * @param promptTemplateUri template URI identifying the target template - * @return prompt generation result containing either rendered prompt text or failure details - */ - public PromptGenerationResult generateNotificationPromptFromNl(String inputNl, String promptTemplateUri) { - return promptGenerationOrchestrator.generateNotificationPromptFromNl(inputNl, promptTemplateUri); - } - - /** - * Generates a notification prompt from structured input using the template identified by the template URI, - * bypassing scenario recognition. - * - * @param inputJsonData structured notification input as a string-to-object map - * @param promptTemplateUri template URI identifying the target template - * @return prompt generation result containing either rendered prompt text or failure details - */ - public PromptGenerationResult generateNotificationPromptFromJsonData( - Map inputJsonData, String promptTemplateUri) { - return promptGenerationOrchestrator.generateNotificationPromptFromJsonData(inputJsonData, promptTemplateUri); - } - /** * Generates a task prompt with metadata from natural-language input using the template identified by the template * URI, bypassing scenario recognition. @@ -251,4 +176,4 @@ private A2ATConfig resolvePromptResourceLocalRootDir(A2ATConfig config, Path env config.negotiation(), config.promptCompliance()); } -} +} \ No newline at end of file diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java index 2abf1a86..1e3c9fdc 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java @@ -209,7 +209,6 @@ public ClientPromptGenerationOrchestratorBuilder resourceLoader(ClasspathPromptR * @return fully assembled orchestrator with all required dependencies resolved */ public DefaultClientPromptGenerationOrchestrator build() { - require(llmClient, "LLM client must be configured."); require(scenarios, "Scenario definitions must be configured."); require(language, "Prompt language must be configured."); require(scenarioSystemPrompt, "Scenario system prompt must be configured."); @@ -243,7 +242,8 @@ public DefaultClientPromptGenerationOrchestrator build() { scenarioUserPrompt, effectiveTemplateLoader, effectiveSlotValueExtractor, - effectiveRenderer); + effectiveRenderer, + effectiveSlotSchemaLoader); } private static void require(Object value, String message) { diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index 33ef21f7..67a4afcd 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -93,46 +93,45 @@ public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { PromptResourceAccess resources = PromptResourceAccess.create(config.prompt()); List scenarios = resources.loadScenarios(config.prompt().language()); - if (LOCAL_RULE_PROVIDER.equals(config.llm().provider())) { - return buildLocalRulePromptGenerationOrchestrator(resources, scenarios); - } - - return buildLlmBackedPromptGenerationOrchestrator(resources, scenarios); - } - - private ClientPromptGenerationOrchestrator buildLocalRulePromptGenerationOrchestrator( - PromptResourceAccess resources, List scenarios) { - ClientSlotSchemaLoader slotSchemaLoader = - newClientSlotSchemaLoader(resources, config.prompt().sourceType()); - return new DefaultClientPromptGenerationOrchestrator( - new FirstScenarioRecognizer(slotSchemaLoader, config.prompt().language()), - scenarios, - config.prompt().language(), - "", - "", - newClientTemplateLoader(resources, config.prompt().sourceType()), - new DefaultTemplateDrivenSlotValueExtractor(slotSchemaLoader), - new TaskPromptRenderer()); - } - private ClientPromptGenerationOrchestrator buildLlmBackedPromptGenerationOrchestrator( - PromptResourceAccess resources, List scenarios) { ClientSlotSchemaLoader slotSchemaLoader = newClientSlotSchemaLoader(resources, config.prompt().sourceType()); ClientTemplateLoader templateLoader = newClientTemplateLoader(resources, config.prompt().sourceType()); - LLMClient llmClient = createLlmClient(); String language = config.prompt().language(); - String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); - String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); - String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); - String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); - ClientScenarioRecognizer llmScenarioRecognizer = - new SingleScenarioAwareRecognizer(scenarios, new ScenarioRecognizer(llmClient)::recognize); - ClientSlotValueExtractor slotValueExtractor = new StructuredInputAwareSlotValueExtractor( - new DefaultTemplateDrivenSlotValueExtractor(slotSchemaLoader), - new DefaultStructuredClientSlotValueExtractor( - llmClient, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)); + + boolean isLocalRule = LOCAL_RULE_PROVIDER.equals(config.llm().provider()); + LLMClient llmClient = isLocalRule ? null : createLlmClient(); + + String scenarioSystemPrompt = isLocalRule + ? "" : resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); + String scenarioUserPrompt = isLocalRule + ? "" : resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); + String slotSystemPrompt = isLocalRule + ? "" : resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); + String slotUserPrompt = isLocalRule + ? "" : resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); + + ClientScenarioRecognizer scenarioRecognizer; + ClientSlotValueExtractor slotValueExtractor; + + if (isLocalRule) { + scenarioRecognizer = (normalizedInput, ignoredScenarios, systemPrompt, userPrompt) -> { + if (scenarios.isEmpty()) { + return new ScenarioRecognitionResult(false, null, "No scenarios configured."); + } + return new ScenarioRecognitionResult(true, scenarios.get(0).scenarioCode(), null); + }; + slotValueExtractor = new DefaultTemplateDrivenSlotValueExtractor(slotSchemaLoader); + } else { + scenarioRecognizer = + new SingleScenarioAwareRecognizer(scenarios, new ScenarioRecognizer(llmClient)::recognize); + slotValueExtractor = new StructuredInputAwareSlotValueExtractor( + new DefaultTemplateDrivenSlotValueExtractor(slotSchemaLoader), + new DefaultStructuredClientSlotValueExtractor( + llmClient, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)); + } + return ClientPromptGenerationOrchestratorBuilder.builder() .llmClient(llmClient) .scenarios(scenarios) @@ -141,7 +140,7 @@ private ClientPromptGenerationOrchestrator buildLlmBackedPromptGenerationOrchest .scenarioUserPrompt(scenarioUserPrompt) .slotSystemPrompt(slotSystemPrompt) .slotUserPrompt(slotUserPrompt) - .scenarioRecognizer(llmScenarioRecognizer) + .scenarioRecognizer(scenarioRecognizer) .templateLoader(templateLoader) .slotSchemaLoader(slotSchemaLoader) .slotValueExtractor(slotValueExtractor) @@ -213,55 +212,6 @@ private static ClientSlotSchemaLoader newClientSlotSchemaLoader(PromptResourceAc throw new UnsupportedOperationException("Unsupported prompt source type: " + sourceType); } - private static final class FirstScenarioRecognizer - implements net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer { - - private final ClientSlotSchemaLoader slotSchemaLoader; - - private final String language; - - private FirstScenarioRecognizer(ClientSlotSchemaLoader slotSchemaLoader, String language) { - this.slotSchemaLoader = slotSchemaLoader; - this.language = language; - } - - @Override - public ScenarioRecognitionResult recognize( - String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { - if (scenarios.isEmpty()) { - return new ScenarioRecognitionResult(false, null, "No scenarios configured."); - } - - Map normalizedFacts = normalizeInput(normalizedInput); - ScenarioDefinition bestScenario = scenarios.get(0); - int bestScore = Integer.MIN_VALUE; - for (ScenarioDefinition scenario : scenarios) { - int score = scoreScenario(scenario, normalizedFacts); - if (score > bestScore) { - bestScenario = scenario; - bestScore = score; - } - } - return new ScenarioRecognitionResult(true, bestScenario.scenarioCode(), null); - } - - private int scoreScenario(ScenarioDefinition scenario, Map normalizedFacts) { - int score = 0; - for (var slotDefinition : slotSchemaLoader - .loadSlotSchema(scenario.scenarioCode(), language) - .slotDefinitions()) { - if (normalizedFacts.containsKey(slotDefinition.name())) { - score += slotDefinition.required() ? 10 : 3; - } - } - return score; - } - - private static Map normalizeInput(String normalizedInput) { - return Map.of("input", normalizedInput); - } - } - private record SingleScenarioAwareRecognizer(List scenarios, ClientScenarioRecognizer delegate) implements ClientScenarioRecognizer { @@ -298,4 +248,4 @@ public Map extractSlotsWithSchema( return llmExtractor.extractSlotsWithSchema(userInput, scenarioCode, language, templateText, dataSchema); } } -} +} \ No newline at end of file diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java index 60ed6384..7cc2320d 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java @@ -19,64 +19,6 @@ public interface ClientPromptGenerationOrchestrator { */ PromptGenerationResult generateTaskPrompt(Object userInput); - /** - * Generates a processed task prompt from natural-language input using the template identified by the template URI. - * - * @param taskInputNl natural-language task input - * @param promptTemplateUri template URI identifying the target template - * @return prompt-generation result - */ - PromptGenerationResult generateTaskPromptFromNl(String taskInputNl, String promptTemplateUri); - - /** - * Generates a processed task prompt from structured input using the template identified by the template URI. - * - * @param taskInputJsonData structured task input as a string-to-object map - * @param promptTemplateUri template URI identifying the target template - * @return prompt-generation result - */ - PromptGenerationResult generateTaskPromptFromJsonData( - Map taskInputJsonData, String promptTemplateUri); - - /** - * Generates an authorization prompt from natural-language input using the template identified by the authorization - * type. - * - * @param inputNl natural-language authorization input - * @param authorizationType authorization type used as the template identifier - * @return prompt-generation result - */ - PromptGenerationResult generateAuthorizationPromptFromNl(String inputNl, String authorizationType); - - /** - * Generates an authorization prompt from structured input using the template identified by the authorization type. - * - * @param inputJsonData structured authorization input as a string-to-object map - * @param authorizationType authorization type used as the template identifier - * @return prompt-generation result - */ - PromptGenerationResult generateAuthorizationPromptFromJsonData( - Map inputJsonData, String authorizationType); - - /** - * Generates a notification prompt from natural-language input using the template identified by the template URI. - * - * @param inputNl natural-language notification input - * @param promptTemplateUri template URI identifying the target template - * @return prompt-generation result - */ - PromptGenerationResult generateNotificationPromptFromNl(String inputNl, String promptTemplateUri); - - /** - * Generates a notification prompt from structured input using the template identified by the template URI. - * - * @param inputJsonData structured notification input as a string-to-object map - * @param promptTemplateUri template URI identifying the target template - * @return prompt-generation result - */ - PromptGenerationResult generateNotificationPromptFromJsonData( - Map inputJsonData, String promptTemplateUri); - /** * Generates a task prompt with metadata from natural-language input using the template identified by the template * URI, bypassing scenario recognition. @@ -142,4 +84,4 @@ MetadataContent generateAuthPromptFromDataWithSchema( */ MetadataContent generateNotificationPromptFromDataWithSchema( Map data, Map schema, String templateUri); -} +} \ No newline at end of file diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java index 3333808f..9227865c 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -1,17 +1,26 @@ package net.openan.a2at.sdk.client.prompt.orchestration; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.regex.Pattern; +import java.util.stream.Collectors; import net.openan.a2at.sdk.client.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationFailure; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; +import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; +import net.openan.a2at.sdk.core.exception.FailedParameter; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.exception.SdkException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.llm.LLMRuntimeError; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; import net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException; @@ -39,6 +48,8 @@ public final class DefaultClientPromptGenerationOrchestrator implements ClientPr private final ClientSlotValueExtractor slotValueExtractor; + private final ClientSlotSchemaLoader slotSchemaLoader; + private final TaskPromptRenderer renderer; private String lastNormalizedInput; @@ -54,6 +65,7 @@ public final class DefaultClientPromptGenerationOrchestrator implements ClientPr * @param templateLoader template loader * @param slotValueExtractor slot value extractor * @param renderer task prompt renderer + * @param slotSchemaLoader slot schema loader */ public DefaultClientPromptGenerationOrchestrator( ClientScenarioRecognizer scenarioRecognizer, @@ -63,7 +75,8 @@ public DefaultClientPromptGenerationOrchestrator( String userPrompt, ClientTemplateLoader templateLoader, ClientSlotValueExtractor slotValueExtractor, - TaskPromptRenderer renderer) { + TaskPromptRenderer renderer, + ClientSlotSchemaLoader slotSchemaLoader) { this.scenarioRecognizer = scenarioRecognizer; this.scenarios = scenarios; this.language = language; @@ -72,6 +85,7 @@ public DefaultClientPromptGenerationOrchestrator( this.templateLoader = templateLoader; this.slotValueExtractor = slotValueExtractor; this.renderer = renderer; + this.slotSchemaLoader = slotSchemaLoader; } @Override @@ -114,68 +128,6 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { } } - @Override - public PromptGenerationResult generateTaskPromptFromNl(String taskInputNl, String promptTemplateUri) { - return generateFromTemplateUri(taskInputNl, promptTemplateUri); - } - - @Override - public PromptGenerationResult generateTaskPromptFromJsonData( - Map taskInputJsonData, String promptTemplateUri) { - return generateFromTemplateUri(taskInputJsonData, promptTemplateUri); - } - - @Override - public PromptGenerationResult generateAuthorizationPromptFromNl(String inputNl, String authorizationType) { - return generateFromTemplateUri(inputNl, authorizationType); - } - - @Override - public PromptGenerationResult generateAuthorizationPromptFromJsonData( - Map inputJsonData, String authorizationType) { - return generateFromTemplateUri(inputJsonData, authorizationType); - } - - @Override - public PromptGenerationResult generateNotificationPromptFromNl(String inputNl, String promptTemplateUri) { - return generateFromTemplateUri(inputNl, promptTemplateUri); - } - - @Override - public PromptGenerationResult generateNotificationPromptFromJsonData( - Map inputJsonData, String promptTemplateUri) { - return generateFromTemplateUri(inputJsonData, promptTemplateUri); - } - - private PromptGenerationResult generateFromTemplateUri(Object userInput, String templateIdentifier) { - if (templateIdentifier == null - || templateIdentifier.isBlank() - || !TEMPLATE_IDENTIFIER_PATTERN.matcher(templateIdentifier).matches()) { - return PromptGenerationResult.failure(new PromptGenerationFailure( - "invalid_template_uri", - "Template URI is null, blank, or contains invalid characters.", - "generation")); - } - - final String templateText; - try { - templateText = templateLoader.loadTemplate(templateIdentifier, language); - } catch (ResourceNotFoundException error) { - return PromptGenerationResult.failure( - new PromptGenerationFailure("template_not_found", error.getMessage(), "generation")); - } - - try { - Map slots = - slotValueExtractor.extractSlots(userInput, templateIdentifier, language, templateText); - String renderedPrompt = renderer.render(templateText, slots); - return PromptGenerationResult.success(renderedPrompt); - } catch (TaskPromptRenderException error) { - return PromptGenerationResult.failure( - new PromptGenerationFailure("render_failed", error.getMessage(), "generation")); - } - } - @Override public MetadataContent generateTaskPromptFromText(String text, String templateUri) { return generateFromTemplateUriWithMetadata(text, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); @@ -217,12 +169,32 @@ private MetadataContent generateFromTemplateUriWithMetadata( if (templateIdentifier == null || templateIdentifier.isBlank() || !TEMPLATE_IDENTIFIER_PATTERN.matcher(templateIdentifier).matches()) { - throw new IllegalArgumentException("Template URI is null, blank, or contains invalid characters."); + throw new PromptGenerationException("invalid_template_uri", + "Template URI is null, blank, or contains invalid characters."); + } + final String templateText; + try { + templateText = templateLoader.loadTemplate(templateIdentifier, language); + } catch (ResourceNotFoundException e) { + throw new PromptGenerationException("template_not_found", e.getMessage(), e); + } catch (SdkException e) { + throw new PromptGenerationException("prompt_resource_load_error", e.getMessage(), e); + } + final Map slots; + try { + slots = slotValueExtractor.extractSlots(userInput, templateIdentifier, language, templateText); + } catch (ResourceNotFoundException e) { + throw new PromptGenerationException("slot_schema_not_found", e.getMessage(), e); + } catch (LLMRuntimeError | SdkException e) { + throw new PromptGenerationException("llm_invocation_failed", e.getMessage(), e); + } + validateRequiredSlots(slots, templateIdentifier); + final String renderedPrompt; + try { + renderedPrompt = renderer.render(templateText, slots); + } catch (TaskPromptRenderException e) { + throw new PromptGenerationException("render_failed", e.getMessage(), e); } - String templateText = templateLoader.loadTemplate(templateIdentifier, language); - Map slots = - slotValueExtractor.extractSlots(userInput, templateIdentifier, language, templateText); - String renderedPrompt = renderer.render(templateText, slots); return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); } @@ -234,16 +206,79 @@ private MetadataContent generateFromDataWithSchema( if (templateIdentifier == null || templateIdentifier.isBlank() || !TEMPLATE_IDENTIFIER_PATTERN.matcher(templateIdentifier).matches()) { - throw new IllegalArgumentException("Template URI is null, blank, or contains invalid characters."); + throw new PromptGenerationException("invalid_template_uri", + "Template URI is null, blank, or contains invalid characters."); + } + final String templateText; + try { + templateText = templateLoader.loadTemplate(templateIdentifier, language); + } catch (ResourceNotFoundException e) { + throw new PromptGenerationException("template_not_found", e.getMessage(), e); + } catch (SdkException e) { + throw new PromptGenerationException("prompt_resource_load_error", e.getMessage(), e); + } + final Map slots; + try { + slots = slotValueExtractor.extractSlotsWithSchema( + data, templateIdentifier, language, templateText, schema); + } catch (ResourceNotFoundException e) { + throw new PromptGenerationException("slot_schema_not_found", e.getMessage(), e); + } catch (LLMRuntimeError | SdkException e) { + throw new PromptGenerationException("llm_invocation_failed", e.getMessage(), e); + } + validateRequiredSlots(slots, templateIdentifier); + final String renderedPrompt; + try { + renderedPrompt = renderer.render(templateText, slots); + } catch (TaskPromptRenderException e) { + throw new PromptGenerationException("render_failed", e.getMessage(), e); } - String templateText = templateLoader.loadTemplate(templateIdentifier, language); - Map slots = - slotValueExtractor.extractSlotsWithSchema(data, templateIdentifier, language, templateText, schema); - String renderedPrompt = renderer.render(templateText, slots); return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); } + private void validateRequiredSlots(Map slots, String templateIdentifier) { + final PromptSlotSchema schema; + try { + schema = slotSchemaLoader.loadSlotSchema(templateIdentifier, language); + } catch (SdkException e) { + throw new PromptGenerationException("slot_schema_not_found", e.getMessage(), e); + } + if (schema == null) { + throw new PromptGenerationException( + "slot_schema_not_found", "Slot schema not found for template: " + templateIdentifier); + } + List defs = schema.slotDefinitions(); + if (defs == null) { + return; + } + if (slots == null) { + slots = Map.of(); + } + List failed = new ArrayList<>(); + for (PromptSlotDefinition def : defs) { + if (def == null) { + continue; + } + if (def.required()) { + String name = def.name(); + if (name == null) { + continue; + } + String value = slots.get(name); + if (value == null || value.trim().isEmpty()) { + failed.add(new FailedParameter(name, "missing_required")); + } + } + } + if (!failed.isEmpty()) { + throw new PromptGenerationException("slot_validation_error", + "Required slots are missing or empty: " + failed.stream() + .map(FailedParameter::parameterName).collect(Collectors.joining(", ")), + failed); + } + } + String lastNormalizedInput() { return lastNormalizedInput; } -} +} \ No newline at end of file diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index 3a41e9a1..eadf269f 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -14,6 +14,7 @@ import net.openan.a2at.sdk.client.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; @@ -272,28 +273,28 @@ void templateUriEntryPointsReturnFailuresForInvalidAndUnknownIdentifiers() throw @Test void fromTextEntryPointsReturnMetadataContent() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); + Path envFile = writeMinimalClientEnvWithoutRequiredSlots("local_rule"); A2ATClient client = new A2ATClient(envFile); MetadataContent taskResult = - client.generateTaskPromptFromText("Please analyze Site A.", "energy-saving"); + client.generateTaskPromptFromText("Please analyze Site A.", "unconstrained"); MetadataContent authResult = - client.generateAuthPromptFromText("Authorize access.", "energy-saving"); + client.generateAuthPromptFromText("Authorize access.", "unconstrained"); MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", "energy-saving"); + client.generateNotificationPromptFromText("Report finished.", "unconstrained"); assertNotNull(taskResult); - assertEquals("energy-saving", taskResult.templateUri()); + assertEquals("unconstrained", taskResult.templateUri()); assertNotNull(taskResult.promptText()); assertNotNull(taskResult.extensionUri()); assertNotNull(authResult); - assertEquals("energy-saving", authResult.templateUri()); + assertEquals("unconstrained", authResult.templateUri()); assertNotNull(authResult.promptText()); assertNotNull(authResult.extensionUri()); assertNotNull(notificationResult); - assertEquals("energy-saving", notificationResult.templateUri()); + assertEquals("unconstrained", notificationResult.templateUri()); assertNotNull(notificationResult.promptText()); assertNotNull(notificationResult.extensionUri()); } @@ -330,15 +331,15 @@ void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { @Test void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); + Path envFile = writeMinimalClientEnvWithoutRequiredSlots("local_rule"); A2ATClient client = new A2ATClient(envFile); MetadataContent taskResult = - client.generateTaskPromptFromText("Please analyze Site A.", "energy-saving"); + client.generateTaskPromptFromText("Please analyze Site A.", "unconstrained"); MetadataContent authResult = - client.generateAuthPromptFromText("Authorize access.", "energy-saving"); + client.generateAuthPromptFromText("Authorize access.", "unconstrained"); MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", "energy-saving"); + client.generateNotificationPromptFromText("Report finished.", "unconstrained"); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); @@ -369,15 +370,18 @@ void fromTextThrowsOnInvalidTemplateUri() throws IOException { Path envFile = writeMinimalLocalClientEnv(); A2ATClient client = new A2ATClient(envFile); - assertThrows( - IllegalArgumentException.class, + PromptGenerationException taskEx = assertThrows( + PromptGenerationException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", "../etc/passwd")); - assertThrows( - IllegalArgumentException.class, + assertEquals("invalid_template_uri", taskEx.code()); + PromptGenerationException authEx = assertThrows( + PromptGenerationException.class, () -> client.generateAuthPromptFromText("Authorize access.", "../etc/passwd")); - assertThrows( - IllegalArgumentException.class, + assertEquals("invalid_template_uri", authEx.code()); + PromptGenerationException notifEx = assertThrows( + PromptGenerationException.class, () -> client.generateNotificationPromptFromText("Report finished.", "../etc/passwd")); + assertEquals("invalid_template_uri", notifEx.code()); } @Test @@ -387,27 +391,30 @@ void fromDataWithSchemaThrowsOnInvalidTemplateUri() throws IOException { Map data = Map.of("site", "Site A"); Map schema = Map.of("type", "object"); - assertThrows( - IllegalArgumentException.class, + PromptGenerationException taskEx = assertThrows( + PromptGenerationException.class, () -> client.generateTaskPromptFromDataWithSchema(data, schema, "../etc/passwd")); - assertThrows( - IllegalArgumentException.class, + assertEquals("invalid_template_uri", taskEx.code()); + PromptGenerationException authEx = assertThrows( + PromptGenerationException.class, () -> client.generateAuthPromptFromDataWithSchema(data, schema, "../etc/passwd")); - assertThrows( - IllegalArgumentException.class, + assertEquals("invalid_template_uri", authEx.code()); + PromptGenerationException notifEx = assertThrows( + PromptGenerationException.class, () -> client.generateNotificationPromptFromDataWithSchema(data, schema, "../etc/passwd")); + assertEquals("invalid_template_uri", notifEx.code()); } @Test void fromTextUnderLocalRuleRendersEmptySlotsForNlInput() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); + Path envFile = writeMinimalClientEnvWithoutRequiredSlots("local_rule"); A2ATClient client = new A2ATClient(envFile); MetadataContent result = - client.generateTaskPromptFromText("Please analyze Site A power usage.", "energy-saving"); + client.generateTaskPromptFromText("Please analyze Site A power usage.", "unconstrained"); - assertEquals("energy-saving", result.templateUri()); - assertEquals("Site: \\nTarget: ", result.promptText()); + assertEquals("unconstrained", result.templateUri()); + assertEquals("Notes: \\nFault: ", result.promptText()); } @Test @@ -425,36 +432,83 @@ void fromDataWithSchemaUnderLocalRuleIgnoresSchema() throws IOException { } @Test - void existingFromNlAndFromJsonDataMethodsStillReturnPromptGenerationResult() throws IOException { + void fromTextThrowsSlotValidationErrorWhenRequiredSlotsMissing() throws IOException { Path envFile = writeMinimalLocalClientEnv(); A2ATClient client = new A2ATClient(envFile); - Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); - PromptGenerationResult taskFromNl = - client.generateTaskPromptFromNl("Please analyze Site A.", "energy-saving"); - PromptGenerationResult taskFromJson = - client.generateTaskPromptFromJsonData(data, "energy-saving"); - PromptGenerationResult authFromNl = - client.generateAuthorizationPromptFromNl("Authorize access.", "energy-saving"); - PromptGenerationResult authFromJson = - client.generateAuthorizationPromptFromJsonData(data, "energy-saving"); - PromptGenerationResult notificationFromNl = - client.generateNotificationPromptFromNl("Report finished.", "energy-saving"); - PromptGenerationResult notificationFromJson = - client.generateNotificationPromptFromJsonData(data, "energy-saving"); - - assertTrue(taskFromNl.success()); - assertTrue(taskFromJson.success()); - assertTrue(authFromNl.success()); - assertTrue(authFromJson.success()); - assertTrue(notificationFromNl.success()); - assertTrue(notificationFromJson.success()); - assertNotNull(taskFromNl.promptText()); - assertNotNull(taskFromJson.promptText()); - assertNotNull(authFromNl.promptText()); - assertNotNull(authFromJson.promptText()); - assertNotNull(notificationFromNl.promptText()); - assertNotNull(notificationFromJson.promptText()); +PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> client.generateTaskPromptFromText("Some text without site or target.", "energy_saving")); + assertEquals("slot_validation_error", ex.code()); + assertFalse(ex.failedParameters().isEmpty()); + } + + private static Path writeMinimalClientEnvWithoutRequiredSlots(String provider) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-env-no-required"); + Path promptRoot = tempDir.resolve("prompt_resources"); + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); + Path templatesDir = + promptRoot.resolve("templates").resolve("unconstrained").resolve("zh-CN"); + Path slotsDir = promptRoot.resolve("slots").resolve("unconstrained").resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.createDirectories(scenariosDir); + Files.createDirectories(templatesDir); + Files.createDirectories(slotsDir); + + Files.writeString( + scenariosDir.resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "unconstrained", + "scenario_name": "Unconstrained", + "description": "No required slots", + "example": "Analyze unconstrained" + } + ] + } + """); + Files.writeString(templatesDir.resolve("template.md"), "Notes: {notes}\\nFault: {fault_description}"); + Files.writeString( + slotsDir.resolve("slot.json"), + """ + { + "required": [], + "properties": { + "notes": { + "type": "string" + }, + "fault_description": { + "type": "string" + } + } + } + """); + Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); + Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); + Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); + + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(provider)); + return envFile; } private static Path writeMinimalLocalClientEnv() throws IOException { diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index cd3ce887..5c924f5d 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -11,18 +11,27 @@ import net.openan.a2at.sdk.client.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; +import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; +import net.openan.a2at.sdk.core.exception.FailedParameter; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.exception.SdkException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.llm.LLMRuntimeError; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; import org.junit.jupiter.api.Test; class DefaultClientPromptGenerationOrchestratorTest { + private static final ClientSlotSchemaLoader EMPTY_SCHEMA_LOADER = + (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()); + @Test void generateTaskPromptLoadsTemplateAndRendersExtractedSlotsWhenScenarioIsMatched() { FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}\nNotes: {additional_notes}"); @@ -38,7 +47,8 @@ void generateTaskPromptLoadsTemplateAndRendersExtractedSlotsWhenScenarioIsMatche "Choose from the provided scenario list.", templateLoader, slotValueExtractor, - new TaskPromptRenderer()); + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); @@ -66,7 +76,8 @@ void generateTaskPromptReturnsFailureWhenScenarioIsNotMatched() { (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", (userInput, scenarioCode, language, templateText) -> Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), - new TaskPromptRenderer()); + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); @@ -91,7 +102,8 @@ void generateTaskPromptReturnsFailureWhenTemplateIsMissing() { }, (userInput, scenarioCode, language, templateText) -> Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), - new TaskPromptRenderer()); + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); @@ -113,7 +125,8 @@ void generateTaskPromptReturnsFailureWhenRenderingFails() { "Choose from the provided scenario list.", (scenarioCode, language) -> "Scenario: {scenario}\nMissing: {missing_slot}", (userInput, scenarioCode, language, templateText) -> Map.of("scenario", scenarioCode), - new TaskPromptRenderer()); + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); @@ -139,7 +152,8 @@ void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissi (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", (userInput, scenarioCode, language, templateText) -> Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), - new TaskPromptRenderer()); + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); @@ -149,7 +163,7 @@ void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissi assertEquals("generation", result.failure().stage()); } - @Test +@Test void eachTemplateUriEntryPointGeneratesPromptWithoutScenarioRecognition() { RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); @@ -289,7 +303,8 @@ private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestra "Choose from the provided scenario list.", templateLoader, slotValueExtractor, - new TaskPromptRenderer()); + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); } private static final class RecordingScenarioRecognizer implements ClientScenarioRecognizer { @@ -444,8 +459,9 @@ void generateFromTemplateUriWithMetadataThrowsOnInvalidTemplateUri() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); - assertThrows(IllegalArgumentException.class, + PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", invalidUri)); + assertEquals("invalid_template_uri", ex.code()); assertEquals(0, recognizer.invocationCount); assertEquals(0, templateLoader.loadCount); } @@ -460,8 +476,9 @@ void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { }, new FakeSlotValueExtractor(Map.of("site", "Site A"))); - assertThrows(ResourceNotFoundException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving")); +PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + assertEquals("template_not_found", ex.code()); } @Test @@ -471,8 +488,9 @@ void generateFromTemplateUriWithMetadataPropagatesRenderException() { new FakeTemplateLoader("Site: {site}\nMissing: {missing_slot}"), new FakeSlotValueExtractor(Map.of("site", "Site A"))); - assertThrows(net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving")); +PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + assertEquals("render_failed", ex.code()); } @Test @@ -484,8 +502,9 @@ void generateFromTemplateUriWithMetadataPropagatesLlmRuntimeError() { throw new LLMRuntimeError("LLM invocation failed."); }); - assertThrows(LLMRuntimeError.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving")); +PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + assertEquals("llm_invocation_failed", ex.code()); } // --- MetadataContent pipeline tests (generateFromDataWithSchema) --- @@ -559,8 +578,9 @@ void generateFromDataWithSchemaThrowsOnInvalidTemplateUri() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractorWithSchema(Map.of())); - assertThrows(IllegalArgumentException.class, + PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "has space")); + assertEquals("invalid_template_uri", ex.code()); assertEquals(0, recognizer.invocationCount); assertEquals(0, templateLoader.loadCount); } @@ -574,8 +594,9 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundException() { }, new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); - assertThrows(ResourceNotFoundException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy-saving")); +PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + assertEquals("template_not_found", ex.code()); } @Test @@ -585,8 +606,9 @@ void generateFromDataWithSchemaPropagatesRenderException() { new FakeTemplateLoader("Site: {site}\nMissing: {missing_slot}"), new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); - assertThrows(net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy-saving")); +PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + assertEquals("render_failed", ex.code()); } @Test @@ -596,8 +618,9 @@ void generateFromDataWithSchemaPropagatesLlmRuntimeError() { new FakeTemplateLoader("Site: {site}"), new FailingExtractSlotsWithSchema(new LLMRuntimeError("LLM invocation failed."))); - assertThrows(LLMRuntimeError.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy-saving")); +PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + assertEquals("llm_invocation_failed", ex.code()); } @Test @@ -608,8 +631,9 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundExceptionFromExtractor( new FailingExtractSlotsWithSchema( new ResourceNotFoundException("Slot schema file does not exist.", "energy-saving"))); - assertThrows(ResourceNotFoundException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy-saving")); +PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + assertEquals("slot_schema_not_found", ex.code()); } // --- Public entry point tests --- @@ -688,4 +712,116 @@ void authorizationMetadataEntryPointsPassAuthorizationTypeAsTemplateIdentifier() assertEquals(Map.of("scope", "read"), slotValueExtractor.lastUserInput); assertEquals(Map.of("scope", "string"), slotValueExtractor.lastSchema); } + + // --- slot validation tests --- + + @Test + void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { +ClientSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, "Site name"))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", "", "", new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of()), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + assertEquals("slot_validation_error", ex.code()); + List failed = ex.failedParameters(); + assertFalse(failed.isEmpty()); + assertEquals("site", failed.get(0).parameterName()); + assertEquals("missing_required", failed.get(0).reason()); + } + + @Test + void validateRequiredSlotsThrowsSlotValidationErrorWhenMultipleRequiredSlotsMissing() { + ClientSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, + List.of( + new PromptSlotDefinition("site", true, "string", null, null, null, null, "Site name"), + new PromptSlotDefinition("target", true, "string", null, null, null, null, "Target"))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", "", "", new FakeTemplateLoader("Site: {site}\nTarget: {target}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + assertEquals("slot_validation_error", ex.code()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("target", ex.failedParameters().get(0).parameterName()); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundWhenSchemaLoadingFails() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", "", "", new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + (scenarioCode, language) -> { + throw new SdkException("Schema file is corrupt."); + }); + + PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + assertEquals("slot_schema_not_found", ex.code()); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundFromExtractor() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", "", "", new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language, templateText) -> { + throw new ResourceNotFoundException("Slot schema file does not exist.", scenarioCode); + }, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + assertEquals("slot_schema_not_found", ex.code()); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsPromptResourceLoadError() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", "", "", (scenarioCode, language) -> { + throw new SdkException("Template file is corrupt."); + }, + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + assertEquals("prompt_resource_load_error", ex.code()); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsLlmInvocationFailedFromSdkException() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", "", "", new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language, templateText) -> { + throw new SdkException("Unparseable LLM response."); + }, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationException ex = assertThrows(PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + assertEquals("llm_invocation_failed", ex.code()); + } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/FailedParameter.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/FailedParameter.java new file mode 100644 index 00000000..f32944f9 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/FailedParameter.java @@ -0,0 +1,10 @@ +package net.openan.a2at.sdk.core.exception; + +/** + * Describes a single failed required slot during slot validation. + * + * @param parameterName the name of the required slot that failed validation + * @param reason the failure reason, e.g. {@code missing_required} + * @since 2026-08 + */ +public record FailedParameter(String parameterName, String reason) {} \ No newline at end of file diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java new file mode 100644 index 00000000..5d0bc69c --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java @@ -0,0 +1,74 @@ +package net.openan.a2at.sdk.core.exception; + +import java.util.List; +import java.util.Objects; + +/** + * Unified exception type for MetadataContent pipeline failures. + * + * @since 2026-08 + */ +public final class PromptGenerationException extends SdkException { + + private final String code; + + private final List failedParameters; + + /** + * Creates an exception with a stable error code and a human-readable message. + * + * @param code stable error code + * @param message human-readable failure description + */ + public PromptGenerationException(String code, String message) { + super(message); + this.code = Objects.requireNonNull(code, "code must not be null"); + this.failedParameters = List.of(); + } + + /** + * Creates an exception with a stable error code, a human-readable message, and + * a list of failed parameters. + * + * @param code stable error code + * @param message human-readable failure description + * @param failedParameters list of failed parameters + */ + public PromptGenerationException(String code, String message, List failedParameters) { + super(message); + this.code = Objects.requireNonNull(code, "code must not be null"); + this.failedParameters = failedParameters == null ? List.of() : List.copyOf(failedParameters); + } + + /** + * Creates an exception with a stable error code, a human-readable message, and + * a root cause. + * + * @param code stable error code + * @param message human-readable failure description + * @param cause root cause + */ + public PromptGenerationException(String code, String message, Throwable cause) { + super(message, cause); + this.code = Objects.requireNonNull(code, "code must not be null"); + this.failedParameters = List.of(); + } + + /** + * Returns the stable error code. + * + * @return error code + */ + public String code() { + return code; + } + + /** + * Returns the list of failed parameters, or an empty list when not applicable. + * + * @return failed parameters + */ + public List failedParameters() { + return failedParameters; + } +} \ No newline at end of file From bfed04d9fc8bc16ee654342d42d8ba7b04fb44ee Mon Sep 17 00:00:00 2001 From: w00609601 Date: Thu, 20 Aug 2026 11:50:10 +0800 Subject: [PATCH 026/295] refactor: change templateUri to path-like format (e.g. Task-T/v1/energy-saving) - Update TEMPLATE_IDENTIFIER_PATTERN to allow slash-separated segments - Loaders support dual-path: verbatim for path-like URIs, */v1/ auto-discovery for simple scenario codes - Update all tests to reflect new format Signed-off-by: w00609601 --- ...ultClientPromptGenerationOrchestrator.java | 2 +- .../a2at/sdk/client/api/A2ATClientTest.java | 102 ++------- ...lientPromptGenerationOrchestratorTest.java | 213 ++++-------------- .../LocalFilePromptSlotSchemaLoader.java | 40 ++-- .../loader/LocalFilePromptTemplateLoader.java | 40 ++-- 5 files changed, 117 insertions(+), 280 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java index 9227865c..3521705e 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -32,7 +32,7 @@ */ public final class DefaultClientPromptGenerationOrchestrator implements ClientPromptGenerationOrchestrator { - private static final Pattern TEMPLATE_IDENTIFIER_PATTERN = Pattern.compile("[a-zA-Z0-9_-]+"); + private static final Pattern TEMPLATE_IDENTIFIER_PATTERN = Pattern.compile("[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*"); private final ClientScenarioRecognizer scenarioRecognizer; diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index eadf269f..eb2862c5 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -219,82 +219,30 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO assertEquals("Line: line-1\\nFault: fault-9", result.promptText()); } - @Test - void templateUriEntryPointsGeneratePromptsFromStructuredInput() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - Map inputData = Map.of("site", "Site A", "target", "Reduce power by 10%"); - - PromptGenerationResult taskResult = client.generateTaskPromptFromJsonData(inputData, "energy-saving"); - PromptGenerationResult authorizationResult = - client.generateAuthorizationPromptFromJsonData(inputData, "energy-saving"); - PromptGenerationResult notificationResult = - client.generateNotificationPromptFromJsonData(inputData, "energy-saving"); - - assertTrue(taskResult.success()); - assertTrue(authorizationResult.success()); - assertTrue(notificationResult.success()); - assertEquals("Site: Site A\\nTarget: Reduce power by 10%", taskResult.promptText()); - assertEquals("Site: Site A\\nTarget: Reduce power by 10%", authorizationResult.promptText()); - assertEquals("Site: Site A\\nTarget: Reduce power by 10%", notificationResult.promptText()); - } - - @Test - void templateUriEntryPointRendersEmptySlotValuesForNlInputUnderLocalRuleProvider() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - - PromptGenerationResult result = - client.generateTaskPromptFromNl("Please analyze Site A power usage.", "energy-saving"); - - assertTrue(result.success()); - assertEquals("Site: \\nTarget: ", result.promptText()); - } - - @Test - void templateUriEntryPointsReturnFailuresForInvalidAndUnknownIdentifiers() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - - PromptGenerationResult invalidResult = - client.generateTaskPromptFromJsonData(Map.of("site", "Site A"), "../etc/passwd"); - PromptGenerationResult unknownResult = - client.generateNotificationPromptFromNl("Report finished.", "no_such_template"); - - assertFalse(invalidResult.success()); - assertNotNull(invalidResult.failure()); - assertEquals("invalid_template_uri", invalidResult.failure().code()); - assertEquals("generation", invalidResult.failure().stage()); - assertFalse(unknownResult.success()); - assertNotNull(unknownResult.failure()); - assertEquals("template_not_found", unknownResult.failure().code()); - assertEquals("generation", unknownResult.failure().stage()); - } - @Test void fromTextEntryPointsReturnMetadataContent() throws IOException { Path envFile = writeMinimalClientEnvWithoutRequiredSlots("local_rule"); A2ATClient client = new A2ATClient(envFile); MetadataContent taskResult = - client.generateTaskPromptFromText("Please analyze Site A.", "unconstrained"); + client.generateTaskPromptFromText("Please analyze Site A.", "Task-T/v1/unconstrained"); MetadataContent authResult = - client.generateAuthPromptFromText("Authorize access.", "unconstrained"); + client.generateAuthPromptFromText("Authorize access.", "Task-T/v1/unconstrained"); MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", "unconstrained"); + client.generateNotificationPromptFromText("Report finished.", "Task-T/v1/unconstrained"); assertNotNull(taskResult); - assertEquals("unconstrained", taskResult.templateUri()); + assertEquals("Task-T/v1/unconstrained", taskResult.templateUri()); assertNotNull(taskResult.promptText()); assertNotNull(taskResult.extensionUri()); assertNotNull(authResult); - assertEquals("unconstrained", authResult.templateUri()); + assertEquals("Task-T/v1/unconstrained", authResult.templateUri()); assertNotNull(authResult.promptText()); assertNotNull(authResult.extensionUri()); assertNotNull(notificationResult); - assertEquals("unconstrained", notificationResult.templateUri()); + assertEquals("Task-T/v1/unconstrained", notificationResult.templateUri()); assertNotNull(notificationResult.promptText()); assertNotNull(notificationResult.extensionUri()); } @@ -307,24 +255,24 @@ void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { Map schema = Map.of("type", "object"); MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, "energy-saving"); + client.generateTaskPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, "energy-saving"); + client.generateAuthPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, "energy-saving"); + client.generateNotificationPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); assertNotNull(taskResult); - assertEquals("energy-saving", taskResult.templateUri()); + assertEquals("Task-T/v1/energy-saving", taskResult.templateUri()); assertNotNull(taskResult.promptText()); assertNotNull(taskResult.extensionUri()); assertNotNull(authResult); - assertEquals("energy-saving", authResult.templateUri()); + assertEquals("Task-T/v1/energy-saving", authResult.templateUri()); assertNotNull(authResult.promptText()); assertNotNull(authResult.extensionUri()); assertNotNull(notificationResult); - assertEquals("energy-saving", notificationResult.templateUri()); + assertEquals("Task-T/v1/energy-saving", notificationResult.templateUri()); assertNotNull(notificationResult.promptText()); assertNotNull(notificationResult.extensionUri()); } @@ -335,11 +283,11 @@ void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { A2ATClient client = new A2ATClient(envFile); MetadataContent taskResult = - client.generateTaskPromptFromText("Please analyze Site A.", "unconstrained"); + client.generateTaskPromptFromText("Please analyze Site A.", "Task-T/v1/unconstrained"); MetadataContent authResult = - client.generateAuthPromptFromText("Authorize access.", "unconstrained"); + client.generateAuthPromptFromText("Authorize access.", "Task-T/v1/unconstrained"); MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", "unconstrained"); + client.generateNotificationPromptFromText("Report finished.", "Task-T/v1/unconstrained"); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); @@ -354,11 +302,11 @@ void fromDataWithSchemaReturnsCorrectExtensionUriPerContentType() throws IOExcep Map schema = Map.of("type", "object"); MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, "energy-saving"); + client.generateTaskPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, "energy-saving"); + client.generateAuthPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, "energy-saving"); + client.generateNotificationPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); @@ -411,9 +359,9 @@ void fromTextUnderLocalRuleRendersEmptySlotsForNlInput() throws IOException { A2ATClient client = new A2ATClient(envFile); MetadataContent result = - client.generateTaskPromptFromText("Please analyze Site A power usage.", "unconstrained"); + client.generateTaskPromptFromText("Please analyze Site A power usage.", "Task-T/v1/unconstrained"); - assertEquals("unconstrained", result.templateUri()); + assertEquals("Task-T/v1/unconstrained", result.templateUri()); assertEquals("Notes: \\nFault: ", result.promptText()); } @@ -425,9 +373,9 @@ void fromDataWithSchemaUnderLocalRuleIgnoresSchema() throws IOException { Map schema = Map.of("type", "object", "required", Arrays.asList("site", "target")); MetadataContent result = - client.generateTaskPromptFromDataWithSchema(data, schema, "energy-saving"); + client.generateTaskPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); - assertEquals("energy-saving", result.templateUri()); + assertEquals("Task-T/v1/energy-saving", result.templateUri()); assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); } @@ -437,7 +385,7 @@ void fromTextThrowsSlotValidationErrorWhenRequiredSlotsMissing() throws IOExcept A2ATClient client = new A2ATClient(envFile); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> client.generateTaskPromptFromText("Some text without site or target.", "energy_saving")); + () -> client.generateTaskPromptFromText("Some text without site or target.", "Task-T/v1/energy-saving")); assertEquals("slot_validation_error", ex.code()); assertFalse(ex.failedParameters().isEmpty()); } @@ -451,8 +399,8 @@ private static Path writeMinimalClientEnvWithoutRequiredSlots(String provider) t promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); Path templatesDir = - promptRoot.resolve("templates").resolve("unconstrained").resolve("zh-CN"); - Path slotsDir = promptRoot.resolve("slots").resolve("unconstrained").resolve("zh-CN"); + promptRoot.resolve("templates").resolve("Task-T").resolve("v1").resolve("unconstrained").resolve("zh-CN"); + Path slotsDir = promptRoot.resolve("slots").resolve("Task-T").resolve("v1").resolve("unconstrained").resolve("zh-CN"); Files.createDirectories(scenarioPromptDir); Files.createDirectories(slotPromptDir); Files.createDirectories(scenariosDir); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 5c924f5d..a173b102 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -163,134 +163,7 @@ void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissi assertEquals("generation", result.failure().stage()); } -@Test - void eachTemplateUriEntryPointGeneratesPromptWithoutScenarioRecognition() { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractor slotValueExtractor = new FakeSlotValueExtractor(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(recognizer, templateLoader, slotValueExtractor); - - PromptGenerationResult taskNl = orchestrator.generateTaskPromptFromNl("Analyze Site A.", "energy-saving"); - PromptGenerationResult taskJson = - orchestrator.generateTaskPromptFromJsonData(Map.of("site", "Site A"), "energy-saving"); - PromptGenerationResult authorizationNl = - orchestrator.generateAuthorizationPromptFromNl("Grant access.", "database_read"); - PromptGenerationResult authorizationJson = - orchestrator.generateAuthorizationPromptFromJsonData(Map.of("site", "Site A"), "database_read"); - PromptGenerationResult notificationNl = - orchestrator.generateNotificationPromptFromNl("Report finished.", "energy-saving"); - PromptGenerationResult notificationJson = - orchestrator.generateNotificationPromptFromJsonData(Map.of("site", "Site A"), "energy-saving"); - - assertTrue(taskNl.success()); - assertTrue(taskJson.success()); - assertTrue(authorizationNl.success()); - assertTrue(authorizationJson.success()); - assertTrue(notificationNl.success()); - assertTrue(notificationJson.success()); - assertEquals("Site: Site A", taskNl.promptText()); - assertEquals("Site: Site A", authorizationJson.promptText()); - assertEquals("Site: Site A", notificationNl.promptText()); - assertEquals(0, recognizer.invocationCount); - assertEquals("en-US", templateLoader.lastLanguage); - assertEquals("en-US", slotValueExtractor.lastLanguage); - assertEquals("Site: {site}", slotValueExtractor.lastTemplateText); - } - - @Test - void authorizationEntryPointsUseAuthorizationTypeAsTemplateIdentifier() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Access: {scope}"); - FakeSlotValueExtractor slotValueExtractor = new FakeSlotValueExtractor(Map.of("scope", "read")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - PromptGenerationResult nlResult = - orchestrator.generateAuthorizationPromptFromNl("Grant read access.", "database_read"); - - assertTrue(nlResult.success()); - assertEquals("database_read", templateLoader.lastScenarioCode); - assertEquals("database_read", slotValueExtractor.lastScenarioCode); - assertEquals("Grant read access.", slotValueExtractor.lastUserInput); - - PromptGenerationResult jsonResult = - orchestrator.generateAuthorizationPromptFromJsonData(Map.of("scope", "read"), "database_read"); - - assertTrue(jsonResult.success()); - assertEquals("database_read", templateLoader.lastScenarioCode); - assertEquals(Map.of("scope", "read"), slotValueExtractor.lastUserInput); - } - - @Test - void templateUriGenerationReturnsInvalidTemplateUriWhenIdentifierIsNullOrBlankOrPatternViolating() { - for (String invalidUri : - new String[] {null, "", " ", "templates/energy_saving", "../etc/passwd", "has space", "id;drop"}) { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); - - PromptGenerationResult result = - orchestrator.generateTaskPromptFromJsonData(Map.of("site", "Site A"), invalidUri); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("invalid_template_uri", result.failure().code()); - assertEquals("generation", result.failure().stage()); - assertEquals(0, recognizer.invocationCount); - assertEquals(0, templateLoader.loadCount); - } - } - - @Test - void templateUriGenerationReturnsTemplateNotFoundWhenTemplateIsMissing() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - (scenarioCode, language) -> { - throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); - }, - new FakeSlotValueExtractor(Map.of("site", "Site A"))); - - PromptGenerationResult result = orchestrator.generateTaskPromptFromNl("Analyze Site A.", "energy-saving"); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("template_not_found", result.failure().code()); - assertEquals("generation", result.failure().stage()); - } - - @Test - void templateUriGenerationReturnsRenderFailedWhenRenderingFails() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}\nMissing: {missing_slot}"), - new FakeSlotValueExtractor(Map.of("site", "Site A"))); - - PromptGenerationResult result = - orchestrator.generateNotificationPromptFromJsonData(Map.of("site", "Site A"), "energy-saving"); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("render_failed", result.failure().code()); - assertEquals("generation", result.failure().stage()); - } - - @Test - void templateUriGenerationProcessesNullAndBlankUserInputWithoutAdditionalValidation() { - for (String userInput : new String[] {null, "", " "}) { - FakeSlotValueExtractor slotValueExtractor = new FakeSlotValueExtractor(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), new FakeTemplateLoader("Site: {site}"), slotValueExtractor); - - PromptGenerationResult result = orchestrator.generateTaskPromptFromNl(userInput, "energy-saving"); - - assertTrue(result.success()); - assertEquals("Site: Site A", result.promptText()); - assertEquals(userInput, slotValueExtractor.lastUserInput); - } - } - - private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestrator( +private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestrator( ClientScenarioRecognizer recognizer, ClientTemplateLoader templateLoader, ClientSlotValueExtractor slotValueExtractor) { @@ -432,9 +305,9 @@ void generateFromTemplateUriWithMetadataReturnsMetadataContentOnSuccess() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent result = orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving"); + MetadataContent result = orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy-saving"); - assertEquals("energy-saving", result.templateUri()); + assertEquals("Task-T/v1/energy-saving", result.templateUri()); assertEquals("Site: Site A", result.promptText()); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); } @@ -446,14 +319,14 @@ void generateFromTemplateUriWithMetadataScenarioRecognizerIsNotInvoked() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); - orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving"); + orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy-saving"); assertEquals(0, recognizer.invocationCount); } @Test void generateFromTemplateUriWithMetadataThrowsOnInvalidTemplateUri() { - for (String invalidUri : new String[] {null, "", " ", "templates/energy_saving", "../etc/passwd", "has space", "id;drop"}) { + for (String invalidUri : new String[] {null, "", " ", "../etc/passwd", "has space", "id;drop"}) { RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); DefaultClientPromptGenerationOrchestrator orchestrator = @@ -477,7 +350,7 @@ void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { new FakeSlotValueExtractor(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); assertEquals("template_not_found", ex.code()); } @@ -489,7 +362,7 @@ void generateFromTemplateUriWithMetadataPropagatesRenderException() { new FakeSlotValueExtractor(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); assertEquals("render_failed", ex.code()); } @@ -503,7 +376,7 @@ void generateFromTemplateUriWithMetadataPropagatesLlmRuntimeError() { }); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); assertEquals("llm_invocation_failed", ex.code()); } @@ -518,9 +391,9 @@ void generateFromDataWithSchemaReturnsMetadataContentOnSuccess() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), "energy-saving"); + Map.of("site", "Site A"), Map.of("site", "string"), "Task-T/v1/energy-saving"); - assertEquals("energy-saving", result.templateUri()); + assertEquals("Task-T/v1/energy-saving", result.templateUri()); assertEquals("Site: Site A", result.promptText()); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); } @@ -534,10 +407,10 @@ void generateFromDataWithSchemaPassesSchemaToExtractor() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); Map schema = Map.of("site", "string", "count", "number"); - orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), schema, "energy-saving"); + orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), schema, "Task-T/v1/energy-saving"); assertEquals(schema, slotValueExtractor.lastSchema); - assertEquals("energy-saving", slotValueExtractor.lastScenarioCode); + assertEquals("Task-T/v1/energy-saving", slotValueExtractor.lastScenarioCode); assertEquals("Site: {site}", slotValueExtractor.lastTemplateText); } @@ -550,7 +423,7 @@ void generateFromDataWithSchemaNullSchemaHandledGracefully() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), null, "energy-saving"); + Map.of("site", "Site A"), null, "Task-T/v1/energy-saving"); assertEquals("Site: Site A", result.promptText()); assertEquals(null, slotValueExtractor.lastSchema); @@ -565,7 +438,7 @@ void generateFromDataWithSchemaEmptySchemaHandledSameAsNull() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "energy-saving"); + Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy-saving"); assertEquals("Site: Site A", result.promptText()); assertEquals(Map.of(), slotValueExtractor.lastSchema); @@ -595,7 +468,7 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundException() { new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy_saving")); assertEquals("template_not_found", ex.code()); } @@ -607,7 +480,7 @@ void generateFromDataWithSchemaPropagatesRenderException() { new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy_saving")); assertEquals("render_failed", ex.code()); } @@ -619,7 +492,7 @@ void generateFromDataWithSchemaPropagatesLlmRuntimeError() { new FailingExtractSlotsWithSchema(new LLMRuntimeError("LLM invocation failed."))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy_saving")); assertEquals("llm_invocation_failed", ex.code()); } @@ -632,7 +505,7 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundExceptionFromExtractor( new ResourceNotFoundException("Slot schema file does not exist.", "energy-saving"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "energy_saving")); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy_saving")); assertEquals("slot_schema_not_found", ex.code()); } @@ -646,15 +519,15 @@ void eachMetadataEntryPointReturnsCorrectMetadataContent() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving"); + MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy-saving"); MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "energy-saving"); - MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "database_read"); + Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy-saving"); + MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "Authorization-T/v1/database_read"); MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "database_read"); - MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", "energy-saving"); + Map.of("site", "Site A"), Map.of(), "Authorization-T/v1/database_read"); + MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", "Task-T/v1/energy-saving"); MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "energy-saving"); + Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy-saving"); assertTrue(taskText.promptText().contains("Site A")); assertTrue(taskData.promptText().contains("Site A")); @@ -670,15 +543,15 @@ void eachMetadataEntryPointUsesCorrectExtensionUri() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( new RecordingScenarioRecognizer(), templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); - MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy-saving"); + MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy-saving"); MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "energy-saving"); - MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "database_read"); + Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy-saving"); + MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "Authorization-T/v1/database_read"); MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "database_read"); - MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", "energy-saving"); + Map.of("site", "Site A"), Map.of(), "Authorization-T/v1/database_read"); + MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", "Task-T/v1/energy-saving"); MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "energy-saving"); + Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy-saving"); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskText.extensionUri()); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskData.extensionUri()); @@ -696,19 +569,19 @@ void authorizationMetadataEntryPointsPassAuthorizationTypeAsTemplateIdentifier() DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent nlResult = orchestrator.generateAuthPromptFromText("Grant read access.", "database_read"); + MetadataContent nlResult = orchestrator.generateAuthPromptFromText("Grant read access.", "Authorization-T/v1/database_read"); - assertEquals("database_read", nlResult.templateUri()); + assertEquals("Authorization-T/v1/database_read", nlResult.templateUri()); assertEquals("Access: read", nlResult.promptText()); - assertEquals("database_read", templateLoader.lastScenarioCode); - assertEquals("database_read", slotValueExtractor.lastScenarioCode); + assertEquals("Authorization-T/v1/database_read", templateLoader.lastScenarioCode); + assertEquals("Authorization-T/v1/database_read", slotValueExtractor.lastScenarioCode); assertEquals("Grant read access.", slotValueExtractor.lastUserInput); MetadataContent jsonResult = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("scope", "read"), Map.of("scope", "string"), "database_read"); + Map.of("scope", "read"), Map.of("scope", "string"), "Authorization-T/v1/database_read"); - assertEquals("database_read", jsonResult.templateUri()); - assertEquals("database_read", templateLoader.lastScenarioCode); + assertEquals("Authorization-T/v1/database_read", jsonResult.templateUri()); + assertEquals("Authorization-T/v1/database_read", templateLoader.lastScenarioCode); assertEquals(Map.of("scope", "read"), slotValueExtractor.lastUserInput); assertEquals(Map.of("scope", "string"), slotValueExtractor.lastSchema); } @@ -728,7 +601,7 @@ void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { schemaLoader); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); assertEquals("slot_validation_error", ex.code()); List failed = ex.failedParameters(); assertFalse(failed.isEmpty()); @@ -751,7 +624,7 @@ void validateRequiredSlotsThrowsSlotValidationErrorWhenMultipleRequiredSlotsMiss schemaLoader); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); assertEquals("slot_validation_error", ex.code()); assertEquals(1, ex.failedParameters().size()); assertEquals("target", ex.failedParameters().get(0).parameterName()); @@ -770,7 +643,7 @@ void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundWhenSchemaLoadin }); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); assertEquals("slot_schema_not_found", ex.code()); } @@ -787,7 +660,7 @@ void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundFromExtractor() EMPTY_SCHEMA_LOADER); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); assertEquals("slot_schema_not_found", ex.code()); } @@ -804,7 +677,7 @@ void generateFromTemplateUriWithMetadataThrowsPromptResourceLoadError() { EMPTY_SCHEMA_LOADER); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); assertEquals("prompt_resource_load_error", ex.code()); } @@ -821,7 +694,7 @@ void generateFromTemplateUriWithMetadataThrowsLlmInvocationFailedFromSdkExceptio EMPTY_SCHEMA_LOADER); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); assertEquals("llm_invocation_failed", ex.code()); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java index c4d8cae8..c9228866 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java @@ -29,18 +29,25 @@ public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { throw notFound(scenarioCode, language); } Path schemaPath; - try (var typePaths = Files.list(slotsRoot)) { - Optional match = typePaths - .filter(Files::isDirectory) - .map(typeDir -> typeDir.resolve("v1").resolve(scenarioCode).resolve(language).resolve("slot.json")) - .filter(Files::exists) - .findFirst(); - schemaPath = match.orElse(null); - } catch (IOException exception) { - throw new SdkException("Failed to scan slot schema resources: " + slotsRoot, exception); - } - if (schemaPath == null) { - throw notFound(scenarioCode, language); + if (scenarioCode.contains("/")) { + schemaPath = slotsRoot.resolve(scenarioCode).resolve(language).resolve("slot.json"); + if (!Files.exists(schemaPath)) { + throw notFound(scenarioCode, language); + } + } else { + try (var typePaths = Files.list(slotsRoot)) { + Optional match = typePaths + .filter(Files::isDirectory) + .map(typeDir -> typeDir.resolve("v1").resolve(scenarioCode).resolve(language).resolve("slot.json")) + .filter(Files::exists) + .findFirst(); + schemaPath = match.orElse(null); + } catch (IOException exception) { + throw new SdkException("Failed to scan slot schema resources: " + slotsRoot, exception); + } + if (schemaPath == null) { + throw notFound(scenarioCode, language); + } } try { return PromptResourceJsonParser.parse(Files.readString(schemaPath), PromptSlotJsonSchema.class) @@ -51,9 +58,10 @@ public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { } private ResourceNotFoundException notFound(String scenarioCode, String language) { - return new ResourceNotFoundException( - "Prompt resource file does not exist.", - promptRootDir.resolve("slots").toString() - + "/*/v1/" + scenarioCode + "/" + language + "/slot.json"); + String pathHint = scenarioCode.contains("/") + ? promptRootDir.resolve("slots").resolve(scenarioCode).resolve(language).resolve("slot.json").toString() + : promptRootDir.resolve("slots").toString() + + "/*/v1/" + scenarioCode + "/" + language + "/slot.json"; + return new ResourceNotFoundException("Prompt resource file does not exist.", pathHint); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java index cd759e51..0a29287f 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java @@ -27,18 +27,25 @@ public String loadTemplate(String scenarioCode, String language) { throw notFound(scenarioCode, language); } Path templatePath; - try (var typePaths = Files.list(templatesRoot)) { - Optional match = typePaths - .filter(Files::isDirectory) - .map(typeDir -> typeDir.resolve("v1").resolve(scenarioCode).resolve(language).resolve("template.md")) - .filter(Files::exists) - .findFirst(); - templatePath = match.orElse(null); - } catch (IOException exception) { - throw new SdkException("Failed to scan prompt template resources: " + templatesRoot, exception); - } - if (templatePath == null) { - throw notFound(scenarioCode, language); + if (scenarioCode.contains("/")) { + templatePath = templatesRoot.resolve(scenarioCode).resolve(language).resolve("template.md"); + if (!Files.exists(templatePath)) { + throw notFound(scenarioCode, language); + } + } else { + try (var typePaths = Files.list(templatesRoot)) { + Optional match = typePaths + .filter(Files::isDirectory) + .map(typeDir -> typeDir.resolve("v1").resolve(scenarioCode).resolve(language).resolve("template.md")) + .filter(Files::exists) + .findFirst(); + templatePath = match.orElse(null); + } catch (IOException exception) { + throw new SdkException("Failed to scan prompt template resources: " + templatesRoot, exception); + } + if (templatePath == null) { + throw notFound(scenarioCode, language); + } } try { return Files.readString(templatePath); @@ -48,9 +55,10 @@ public String loadTemplate(String scenarioCode, String language) { } private ResourceNotFoundException notFound(String scenarioCode, String language) { - return new ResourceNotFoundException( - "Prompt resource file does not exist.", - promptRootDir.resolve("templates").toString() - + "/*/v1/" + scenarioCode + "/" + language + "/template.md"); + String pathHint = scenarioCode.contains("/") + ? promptRootDir.resolve("templates").resolve(scenarioCode).resolve(language).resolve("template.md").toString() + : promptRootDir.resolve("templates").toString() + + "/*/v1/" + scenarioCode + "/" + language + "/template.md"; + return new ResourceNotFoundException("Prompt resource file does not exist.", pathHint); } } From 7dfcc197ba7eb7a3afc794ab8c1f11e4a82ca36a Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Wed, 19 Aug 2026 16:20:48 +0800 Subject: [PATCH 027/295] feat(a2a-t-resources): add negotiation content layer prompt resources Signed-off-by: codetalks-hw --- .../feasibility_negotiation/en-US/system.md | 78 ++++++++++++++++- .../feasibility_negotiation/en-US/user.md | 10 ++- .../feasibility_negotiation/zh-CN/system.md | 78 ++++++++++++++++- .../feasibility_negotiation/zh-CN/user.md | 10 ++- .../information_negotiation/en-US/system.md | 67 +++++++++++++- .../information_negotiation/en-US/user.md | 10 ++- .../information_negotiation/zh-CN/system.md | 67 +++++++++++++- .../information_negotiation/zh-CN/user.md | 10 ++- .../en-US/system.md | 87 +++++++++++++++++++ .../en-US/user.md | 10 +++ .../zh-CN/system.md | 87 +++++++++++++++++++ .../zh-CN/user.md | 10 +++ .../target_negotiation/en-US/system.md | 79 +++++++++++++++++ .../prompts/target_negotiation/en-US/user.md | 6 ++ .../target_negotiation/zh-CN/system.md | 79 +++++++++++++++++ .../prompts/target_negotiation/zh-CN/user.md | 6 ++ 16 files changed, 674 insertions(+), 20 deletions(-) create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/user.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/user.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/system.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/user.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/system.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/user.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/system.md index 6451917a..2a5909bf 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/system.md @@ -1 +1,77 @@ -Feasibility negotiation started by the {role} side. +You are a feasibility negotiation content extraction agent. Your task is to extract the structured feasibility negotiation content JSON from a natural-language input text, according to the given negotiation phase, for downstream template rendering. + +## Output Format +Output exactly one JSON object. Do not output markdown code fences, comments, or any additional text. + +## Phase and Output Structure +The negotiation phase of the input text is given by the phase field in the user prompt: + +1. Propose phase: extract the negotiation summary, the message nature (action), and the matching conditional content. Output structure: + +{ + "feasibility_negotiation_description": "feasibility negotiation summary, string", + "action": "REQUEST_FEASIBILITY_EVALUATION or PROPOSE_ALTERNATIVE_ON_FAILURE", + "contents_to_evaluate": [ + {"name": "entry name", "value": "entry content"} + ], + "infeasibility_details_and_proposal": [ + {"name": "entry name", "value": "entry content"} + ] +} + +2. Ending phase (accept / reject / accept-reject): extract the negotiation conclusion and the feasibility result confirmation. Output structure: + +{ + "conclusion": "Accept or Reject", + "feasibility_summary": "feasibility result confirmation, string" +} + +## Field Rules +- feasibility_negotiation_description: required in the propose phase. A paragraph summarizing the nature and purpose of this feasibility negotiation. +- action: required enum in the propose phase; must be one of exactly two values: + - "REQUEST_FEASIBILITY_EVALUATION": ask the peer to evaluate the feasibility of certain matters; + - "PROPOSE_ALTERNATIVE_ON_FAILURE": state that the target is infeasible and propose an alternative. +- contents_to_evaluate: when action is "REQUEST_FEASIBILITY_EVALUATION", output the array of contents to evaluate; otherwise null or an empty array. +- infeasibility_details_and_proposal: when action is "PROPOSE_ALTERNATIVE_ON_FAILURE", output the array of infeasibility details and the alternative proposal; otherwise null or an empty array. +- The two conditional contents are mutually exclusive: extract at most one non-empty group from a single input; never output both groups non-empty. +- conclusion: required in the ending phase; must be either "Accept" or "Reject" and must faithfully reflect the conclusion expressed by the input text; never output "Abort". +- feasibility_summary: required in the ending phase. The confirmation statement of the feasibility evaluation result: the accepted outcome when the conclusion is "Accept", or the infeasible outcome and its reasons when the conclusion is "Reject". +- Each entry is an object with exactly two keys, name and value; value may be null. + +## Extraction Principles +1. Extract only content explicitly expressed in the input text; do not fill in values from general knowledge or guess. +2. Determine action from the message nature of the input: asking the peer for a feasibility evaluation maps to "REQUEST_FEASIBILITY_EVALUATION"; stating infeasibility and offering an alternative maps to "PROPOSE_ALTERNATIVE_ON_FAILURE". +3. When the input does not express content for an optional field, output null for that field; do not fabricate entries. +4. In the ending phase, the accepting or rejecting stance toward the evaluation result maps to conclusion, and the full statement of the evaluation outcome maps to feasibility_summary. + +## Output Examples + +### Example 1: propose phase (requesting a feasibility evaluation) + +{ + "feasibility_negotiation_description": "Request evaluating the feasibility of maintaining a 5Mbps guaranteed-rate target during the power-outage protection scenario.", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": [ + {"name": "evaluation target", "value": "rate guarantee for key users during the 8-hour outage"} + ], + "infeasibility_details_and_proposal": null +} + +### Example 2: propose phase (infeasible, proposing an alternative) + +{ + "feasibility_negotiation_description": "The 5Mbps guaranteed-rate target is infeasible in the power-outage protection scenario; a reduced target is proposed.", + "action": "PROPOSE_ALTERNATIVE_ON_FAILURE", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": [ + {"name": "infeasibility reason", "value": "the battery can only sustain a 2Mbps guarantee for 8 hours"}, + {"name": "alternative proposal", "value": "reduce the guaranteed-rate target to 2Mbps during the outage"} + ] +} + +### Example 3: ending phase (accept) + +{ + "conclusion": "Accept", + "feasibility_summary": "Agree to reduce the guaranteed-rate target during the outage from 5Mbps to 2Mbps; this feasibility negotiation is confirmed as closed." +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/user.md index 16f27d30..9b2545d6 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/user.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/user.md @@ -1,4 +1,6 @@ -Status={status} -Round={round} -Content={content_text} -Facts={facts_json} +Negotiation phase: [phase] + +Input text: +[input] + +Following the output format and field rules in the system prompt, extract the feasibility negotiation content for the current phase from the input text above. Output the JSON object only. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/system.md index dbe38a65..bf970119 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/system.md @@ -1 +1,77 @@ -由{role}方发起可行性协商。 +你是可行性协商(feasibility negotiation)内容提取代理。你的任务是根据给定的协商阶段,从自然语言输入文本中提取可行性协商的结构化内容 JSON,供后续模板渲染使用。 + +## 输出格式 +只输出一个 JSON 对象,不要输出 markdown 代码块、注释或任何额外文本。 + +## 阶段与输出结构 +输入文本所处的协商阶段由用户提示词中的阶段字段给出: + +1. 发起阶段(propose):提取可行性协商概述、消息性质(action)与对应的条件内容,输出结构: + +{ + "feasibility_negotiation_description": "可行性协商概述,字符串", + "action": "REQUEST_FEASIBILITY_EVALUATION 或 PROPOSE_ALTERNATIVE_ON_FAILURE", + "contents_to_evaluate": [ + {"name": "条目名称", "value": "条目内容"} + ], + "infeasibility_details_and_proposal": [ + {"name": "条目名称", "value": "条目内容"} + ] +} + +2. 结论阶段(accept / reject / accept-reject):提取协商结论与可行性评估结果确认,输出结构: + +{ + "conclusion": "Accept 或 Reject", + "feasibility_summary": "可行性评估结果确认,字符串" +} + +## 字段规则 +- feasibility_negotiation_description:发起阶段必填。概括本次可行性协商的性质与目的。 +- action:发起阶段必填枚举,只能取以下两个值之一: + - "REQUEST_FEASIBILITY_EVALUATION":请求对方评估某些事项的可行性; + - "PROPOSE_ALTERNATIVE_ON_FAILURE":已知目标不可行时,说明不可行详情并提出替代方案。 +- contents_to_evaluate:action 为 "REQUEST_FEASIBILITY_EVALUATION" 时,输出待评估内容条目数组;否则为 null 或空数组。 +- infeasibility_details_and_proposal:action 为 "PROPOSE_ALTERNATIVE_ON_FAILURE" 时,输出不可行详情与替代提案条目数组;否则为 null 或空数组。 +- 两个条件内容互斥:同一份输入中最多提取其中一组,不得同时给出两组非空内容。 +- conclusion:结论阶段必填,只能为 "Accept" 或 "Reject",必须忠实于输入文本表达的结论;不得输出 "Abort"。 +- feasibility_summary:结论阶段必填。可行性评估结果的确认表述:结论为 "Accept" 时为同意的结论与内容,结论为 "Reject" 时为不可行结论及原因。 +- 每个条目是一个恰好包含 name 与 value 两个键的对象;value 可为 null。 + +## 提取原则 +1. 只提取输入文本中明确表达的内容,不要基于常识补值或猜测。 +2. action 依据输入的消息性质判定:请求对方做可行性评估 → "REQUEST_FEASIBILITY_EVALUATION";说明不可行并给出替代方案 → "PROPOSE_ALTERNATIVE_ON_FAILURE"。 +3. 输入未表达某可选字段的内容时输出 null,不要编造条目。 +4. 结论阶段中,对评估结果的接受或拒绝表态映射为 conclusion,评估结论的完整表述映射为 feasibility_summary。 + +## 输出示例 + +### 示例1:发起阶段(请求可行性评估) + +{ + "feasibility_negotiation_description": "请求评估停电保障场景下维持5Mbps速率保障目标的可行性。", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": [ + {"name": "评估对象", "value": "停电8小时期间核心用户的速率保障"} + ], + "infeasibility_details_and_proposal": null +} + +### 示例2:发起阶段(不可行并提出替代方案) + +{ + "feasibility_negotiation_description": "停电保障场景下5Mbps速率保障目标不可行,提出下调方案。", + "action": "PROPOSE_ALTERNATIVE_ON_FAILURE", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": [ + {"name": "不可行原因", "value": "蓄电池仅能支撑8小时2Mbps的保障能力"}, + {"name": "替代提案", "value": "停电期间将速率保障目标下调至2Mbps"} + ] +} + +### 示例3:结论阶段(accept) + +{ + "conclusion": "Accept", + "feasibility_summary": "同意将停电期间速率保障目标由5Mbps下调至2Mbps,本次可行性协商确认结束。" +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/user.md index dd2fb94d..eab757d2 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/user.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/user.md @@ -1,4 +1,6 @@ -状态={status} -轮次={round} -内容={content_text} -事实={facts_json} +协商阶段:[phase] + +输入文本: +[input] + +请按系统提示词的输出格式与字段规则,从上述输入文本中提取当前阶段的可行性协商内容,只输出 JSON 对象本身。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/system.md index a60c7eaf..0f054b7a 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/system.md @@ -1 +1,66 @@ -Information negotiation started by the {role} side. +You are an information negotiation content extraction agent. Your task is to extract the structured information negotiation content JSON from a natural-language input text, according to the given negotiation phase, for downstream template rendering. + +## Output Format +Output exactly one JSON object. Do not output markdown code fences, comments, or any additional text. + +## Phase and Output Structure +The negotiation phase of the input text is given by the phase field in the user prompt: + +1. Propose phase: extract the information items the initiating party requests from the peer, plus the relationship between the items. Output structure: + +{ + "items": [ + {"name": "information item name", "value": "meaning, format, or example of the item"} + ], + "relationship": "relationship between the missing items, or null" +} + +2. Ending phase (accept / reject / accept-reject): extract the negotiation conclusion and the finally confirmed information items. Output structure: + +{ + "conclusion": "Accept or Reject", + "items": [ + {"name": "information item name", "value": "value of the item"} + ] +} + +## Field Rules +- items: required array. Each element is an object with exactly two keys, name and value; name is the information item name (string), value is the item's value, meaning, format, or example (string, may be null). Output an empty array when the input contains no information items. +- relationship: output in the propose phase only. Either a string or null; when the input does not state a relationship between the missing items, it must be null. Do not fabricate. +- conclusion: output in the ending phase only. Its value must be either "Accept" or "Reject" and must faithfully reflect the conclusion expressed by the input text; never output "Abort". + +## Extraction Principles +1. Extract only content explicitly expressed in the input text; do not fill in values from general knowledge or guess. +2. In the propose phase, expressions such as "we still need / please provide / missing" introduce the information items to obtain; qualifying statements that follow (format, example, meaning) become the value of that item. +3. In the ending phase, when the input answers the requested information, the answer content maps to items; the accepting or rejecting stance expressed by the input maps to conclusion. +4. Descriptions of dependencies, exclusivity, or composition between information items map to relationship. +5. When uncertain: output null for optional fields and an empty array for required arrays; never fabricate content. + +## Output Examples + +### Example 1: propose phase + +{ + "items": [ + {"name": "fault occurrence time", "value": "time point accurate to the minute"}, + {"name": "affected cell identifier", "value": "CGI or cell name"} + ], + "relationship": "the fault occurrence time and the affected cell identifier must correspond cell by cell" +} + +### Example 2: ending phase (accept) + +{ + "conclusion": "Accept", + "items": [ + {"name": "fault occurrence time", "value": "2026-08-19 10:30"}, + {"name": "affected cell identifier", "value": "CGI: 460-00-xxx-yyy"} + ] +} + +### Example 3: ending phase (reject) + +{ + "conclusion": "Reject", + "items": [] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/user.md index 16f27d30..032d715b 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/user.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/user.md @@ -1,4 +1,6 @@ -Status={status} -Round={round} -Content={content_text} -Facts={facts_json} +Negotiation phase: [phase] + +Input text: +[input] + +Following the output format and field rules in the system prompt, extract the information negotiation content for the current phase from the input text above. Output the JSON object only. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/system.md index 72e883e0..534392c9 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/system.md @@ -1 +1,66 @@ -由{role}方发起信息协商。 +你是信息协商(information negotiation)内容提取代理。你的任务是根据给定的协商阶段,从自然语言输入文本中提取信息协商的结构化内容 JSON,供后续模板渲染使用。 + +## 输出格式 +只输出一个 JSON 对象,不要输出 markdown 代码块、注释或任何额外文本。 + +## 阶段与输出结构 +输入文本所处的协商阶段由用户提示词中的阶段字段给出: + +1. 发起阶段(propose):提取发起方请求对方提供的信息项以及信息项之间的关系,输出结构: + +{ + "items": [ + {"name": "信息项名称", "value": "信息项含义、格式或样例"} + ], + "relationship": "缺失信息项之间的关系,或 null" +} + +2. 结论阶段(accept / reject / accept-reject):提取协商结论与最终确认的信息项,输出结构: + +{ + "conclusion": "Accept 或 Reject", + "items": [ + {"name": "信息项名称", "value": "信息项取值"} + ] +} + +## 字段规则 +- items:必填数组。每个元素是一个恰好包含 name 与 value 两个键的对象;name 为信息项名称(字符串),value 为该信息项的取值、含义、格式或样例(字符串,可为 null)。输入中没有信息项时输出空数组。 +- relationship:仅发起阶段输出。为字符串或 null;输入未说明缺失信息项之间的关系时必须为 null,不要编造。 +- conclusion:仅结论阶段输出。取值只能为 "Accept" 或 "Reject",必须忠实于输入文本表达的结论;不得输出 "Abort"。 + +## 提取原则 +1. 只提取输入文本中明确表达的内容,不要基于常识补值或猜测。 +2. 发起阶段中,"还需要 / 请提供 / 缺少"等表述引导的是待获取的信息项;其后对格式、样例、含义的限定说明作为该信息项的 value。 +3. 结论阶段中,若输入是对所请求信息的答复,答复内容映射为 items;输入中的接受或拒绝表态映射为 conclusion。 +4. 信息项之间的依赖、互斥、组合等关系描述映射为 relationship。 +5. 拿不准时:可选字段输出 null,必填数组保留空数组,不要编造内容。 + +## 输出示例 + +### 示例1:发起阶段(propose) + +{ + "items": [ + {"name": "故障发生时间", "value": "精确到分钟的时间点"}, + {"name": "受影响小区标识", "value": "CGI 或小区名称"} + ], + "relationship": "故障发生时间与受影响小区标识需逐小区对应" +} + +### 示例2:结论阶段(accept) + +{ + "conclusion": "Accept", + "items": [ + {"name": "故障发生时间", "value": "2026-08-19 10:30"}, + {"name": "受影响小区标识", "value": "CGI: 460-00-xxx-yyy"} + ] +} + +### 示例3:结论阶段(reject) + +{ + "conclusion": "Reject", + "items": [] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/user.md index dd2fb94d..711f9248 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/user.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/user.md @@ -1,4 +1,6 @@ -状态={status} -轮次={round} -内容={content_text} -事实={facts_json} +协商阶段:[phase] + +输入文本: +[input] + +请按系统提示词的输出格式与字段规则,从上述输入文本中提取当前阶段的信息协商内容,只输出 JSON 对象本身。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md new file mode 100644 index 00000000..4cde4fdd --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md @@ -0,0 +1,87 @@ +You are the semantic validation and parameter extraction agent for Negotiation-T negotiation messages. Your task is to perform semantic validation, structural semantics validation, template consistency validation, and parameter extraction on a negotiation message, and to output exactly one JSON object. + +## Output Format +Output exactly one JSON object containing exactly the following 4 required keys; do not output markdown code fences, comments, or any additional text: + +{ + "semantic_verdict": true or false, + "negotiation_type": "information" or "target" or "feasibility" or null, + "errors": [ + {"slot_name": "string", "code": "string", "message": "English error description"} + ], + "params": {"parameter extracted per the parameter schema": "value"} +} + +- semantic_verdict: the overall verdict of semantic validation; true when every validation task passes, false when any validation task fails. +- negotiation_type: the negotiation type implied by the message sections, one of "information" / "target" / "feasibility"; it may be null when semantic_verdict is false; when semantic_verdict is true it must be non-null and must match the declared negotiation type. +- errors: the semantic error details array; each element is an object with exactly three keys, slot_name, code, and message; it must be an empty array when semantic_verdict is true. +- params: the parameter object extracted from the message per the parameter schema; output an empty object {} when no parameter can be extracted. + +## Validation Tasks +1. Time-interval validity: any time interval appearing in the message (such as a guarantee window or an effective period) must be parseable and ordered validly (the start time must not be later than the end time). +2. No conflict with existing constraints: targets or commitments in the message must not directly conflict with existing constraints stated inside the message (such as power-outage duration guarantees, existing subscription limits, or previously confirmed negotiation conclusions). +3. Conclusion and content match: a message whose conclusion is Accept must carry an explicit confirmation (the confirmed information, intent, or outcome statement); a message whose conclusion is Reject must carry an explicit failure or rejection reason. +4. Field self-consistency: field values within the same message must not contradict each other (for example, the conclusion is Accept while the body states a rejection; or the same numeric target differs across sections). +5. Structural semantics: + - The conclusion value must be either Accept or Reject; Abort is a protocol-reserved value not carried by the current templates, and its presence is a structural semantics error. + - An ending-phase (accept-reject) message must contain the result content section (Information Negotiation Result Content / Target Negotiation Result Content / Feasibility Result Confirmation). + - The two conditional sections of a feasibility negotiation propose message (Contents to Evaluate and Infeasibility Details and Proposal) are mutually exclusive and must not both appear. +6. Template consistency: the negotiation type and phase implied by the message sections must match the template identifier (template_uri) declared in the user prompt and its declared negotiation type. A type mismatch is a type consistency error; a phase mismatch (for example, the declared template identifier is for the propose phase while the message is an ending message, or vice versa) is a phase consistency error. + +## Parameter Extraction Task +- Extract parameters from the message content per the parameter schema given in the user prompt and fill the params object. +- The property names and structure of params must follow the parameter schema; output null for properties that cannot be extracted from the message. +- The parameter extraction result does not affect semantic_verdict; semantic_verdict is decided solely by validation tasks 1-6. + +## slot_name Convention +The slot_name of semantic and structural semantics errors must use the following language-neutral canonical keys, chosen by the message section at fault: +- section.context: Negotiation Context +- section.info_static: Information Negotiation +- section.info_items: Required Information Items +- section.info_conclusion: Information Negotiation Result +- section.info_result_content: Information Negotiation Result Content +- section.target: Target Negotiation +- section.target_intent: Intent Understanding +- section.target_alignment: Alignment and Clarification +- section.target_clarification: Clarification Required +- section.target_conclusion: Target Negotiation Result +- section.target_result_content: Target Negotiation Result Content +- section.feasibility: Feasibility Negotiation +- section.feasibility_evaluate: Contents to Evaluate +- section.feasibility_infeasible: Infeasibility Details and Proposal +- section.feasibility_conclusion: Feasibility Negotiation Result +- section.feasibility_confirm: Feasibility Result Confirmation + +When a type or phase consistency error concerns the message as a whole, use the canonical key of the section implying the fault (for example, section.feasibility when a feasibility message mismatches the declared type). +Use a short category identifier for code, for example: invalid_time_interval, constraint_conflict, conclusion_content_mismatch, field_inconsistency, invalid_conclusion, missing_result_content, mutually_exclusive_sections, template_type_mismatch, template_phase_mismatch. +The message must be in English. + +## Output Examples +The params in the following examples only illustrate the structure; the actual property names and structure follow the parameter schema given in the user prompt. + +### Example 1: validation passed + +{ + "semantic_verdict": true, + "negotiation_type": "feasibility", + "errors": [], + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "confirmed_rate_mbps": 2 + } +} + +### Example 2: validation failed (conclusion and content mismatch) + +{ + "semantic_verdict": false, + "negotiation_type": "target", + "errors": [ + { + "slot_name": "section.target_result_content", + "code": "conclusion_content_mismatch", + "message": "Conclusion is Accept but the result content section does not state a confirmed intent." + } + ], + "params": {} +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/user.md new file mode 100644 index 00000000..763a5396 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/user.md @@ -0,0 +1,10 @@ +Declared negotiation type: [negotiation_type] +Declared template identifier: [template_uri] + +Parameter schema: +[schema] + +Negotiation message to validate: +[input] + +Following the system prompt, perform all validation tasks and the parameter extraction on the negotiation message above. Output one JSON object containing all 4 required keys only. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md new file mode 100644 index 00000000..31e39334 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md @@ -0,0 +1,87 @@ +你是 Negotiation-T 协商报文的语义校验与参数提取代理。你的任务是对一份协商报文完成语义校验、结构语义校验、模板一致性校验与参数提取,并只输出一个 JSON 对象。 + +## 输出格式 +只输出一个 JSON 对象,包含且仅包含以下 4 个必填键;不要输出 markdown 代码块、注释或任何额外文本: + +{ + "semantic_verdict": true 或 false, + "negotiation_type": "information" 或 "target" 或 "feasibility" 或 null, + "errors": [ + {"slot_name": "字符串", "code": "字符串", "message": "英文错误描述"} + ], + "params": {"按参数 schema 提取的参数": "取值"} +} + +- semantic_verdict:语义校验整体结论;全部校验任务通过时为 true,任一校验任务失败时为 false。 +- negotiation_type:报文板块蕴含的协商类型,取值为 "information" / "target" / "feasibility" 之一;semantic_verdict 为 false 时可为 null;为 true 时必须非 null,且必须与声明的协商类型一致。 +- errors:语义错误明细数组,每个元素是恰好包含 slot_name、code、message 三个键的对象;semantic_verdict 为 true 时必须为空数组。 +- params:按参数 schema 从报文中提取的参数对象;无参数可提取时输出空对象 {}。 + +## 校验任务 +1. 时间区间合法性:报文中出现的时间区间(如保障时段、生效时间段)必须可解析且区间顺序合法(开始时间不晚于结束时间)。 +2. 与既有约束不冲突:报文中的目标或承诺不得与报文内声明的既有约束(如停电时长保障、既有套餐限制、已确认的历史协商结论)直接冲突。 +3. 结论与内容匹配:结论为 Accept 的报文必须携带明确的确认内容(确认后的信息、意图或结论表述);结论为 Reject 的报文必须携带明确的失败或拒绝原因。 +4. 字段取值自洽:同一报文内各字段取值不得自相矛盾(例如结论为 Accept 而正文表述为拒绝;同一数值目标前后不一致)。 +5. 结构语义: + - 结论字段的取值只能为 Accept 或 Reject;Abort 为协议预留值,当前模板不承接,出现即记结构语义错误。 + - 结论阶段(accept-reject)报文必须存在结果内容板块(信息协商结果内容 / 目标协商结果内容 / 可行性评估结果确认)。 + - 可行性协商发起报文的两个条件板块(待评估内容说明 与 评估不可行时的详情和提案)互斥,不得同时出现。 +6. 模板一致性:报文板块蕴含的协商类型与阶段,必须与用户提示词中声明的模板标识(template_uri)及其声明的协商类型一致。类型不一致记类型一致性错误;阶段不一致(如声明的模板标识为发起阶段而报文为结论报文,或反之)记阶段一致性错误。 + +## 参数提取任务 +- 按用户提示词中给出的参数 schema,从报文内容中提取参数并填充 params 对象。 +- params 的属性名与结构必须遵循参数 schema;无法从报文中提取到的属性输出 null。 +- 参数提取结果不影响 semantic_verdict;semantic_verdict 只由校验任务 1-6 决定。 + +## slot_name 规范 +语义错误与结构语义错误的 slot_name 必须使用以下语言无关的规范键,按出错的报文板块选择: +- section.context:协商上下文(Negotiation Context) +- section.info_static:信息协商(Information Negotiation) +- section.info_items:所需信息项(Required Information Items) +- section.info_conclusion:信息协商结果(Information Negotiation Result) +- section.info_result_content:信息协商结果内容(Information Negotiation Result Content) +- section.target:目标协商(Target Negotiation) +- section.target_intent:意图理解陈述(Intent Understanding) +- section.target_alignment:理解对齐与疑问澄清(Alignment and Clarification) +- section.target_clarification:待澄清内容(Clarification Required) +- section.target_conclusion:目标协商结果(Target Negotiation Result) +- section.target_result_content:目标协商结果内容(Target Negotiation Result Content) +- section.feasibility:可行性协商(Feasibility Negotiation) +- section.feasibility_evaluate:待评估内容说明(Contents to Evaluate) +- section.feasibility_infeasible:评估不可行时的详情和提案(Infeasibility Details and Proposal) +- section.feasibility_conclusion:可行性协商结果(Feasibility Negotiation Result) +- section.feasibility_confirm:可行性评估结果确认(Feasibility Result Confirmation) + +类型或阶段一致性错误属于报文整体时,slot_name 使用蕴含出错板块的规范键(例如可行性报文类型不符时使用 section.feasibility)。 +code 使用简短的类别标识,例如:invalid_time_interval、constraint_conflict、conclusion_content_mismatch、field_inconsistency、invalid_conclusion、missing_result_content、mutually_exclusive_sections、template_type_mismatch、template_phase_mismatch。 +message 必须为英文。 + +## 输出示例 +以下示例中的 params 仅示意结构,实际属性名与结构以用户提示词中给出的参数 schema 为准。 + +### 示例1:校验通过 + +{ + "semantic_verdict": true, + "negotiation_type": "feasibility", + "errors": [], + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "confirmed_rate_mbps": 2 + } +} + +### 示例2:校验不通过(结论与内容不匹配) + +{ + "semantic_verdict": false, + "negotiation_type": "target", + "errors": [ + { + "slot_name": "section.target_result_content", + "code": "conclusion_content_mismatch", + "message": "Conclusion is Accept but the result content section does not state a confirmed intent." + } + ], + "params": {} +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/user.md new file mode 100644 index 00000000..90b4df6f --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/user.md @@ -0,0 +1,10 @@ +声明的协商类型:[negotiation_type] +声明的模板标识:[template_uri] + +参数 schema: +[schema] + +待校验的协商报文: +[input] + +请按系统提示词要求,对上述协商报文完成全部校验任务与参数提取,只输出包含全部 4 个必填键的 JSON 对象。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/system.md new file mode 100644 index 00000000..5deea48a --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/system.md @@ -0,0 +1,79 @@ +You are a target negotiation content extraction agent. Your task is to extract the structured target negotiation content JSON from a natural-language input text, according to the given negotiation phase, for downstream template rendering. + +## Output Format +Output exactly one JSON object. Do not output markdown code fences, comments, or any additional text. + +## Phase and Output Structure +The negotiation phase of the input text is given by the phase field in the user prompt: + +1. Propose phase: extract the negotiation summary, intent understanding, alignment and clarification, and clarification required. Output structure: + +{ + "target_negotiation_description": "target negotiation summary, string", + "intent_understanding": [ + {"name": "entry name", "value": "entry content"} + ], + "alignment_and_clarification": [ + {"name": "entry name", "value": "entry content"} + ], + "request_for_clarification": [ + {"name": "entry name", "value": "entry content"} + ] +} + +2. Ending phase (accept / reject / accept-reject): extract the negotiation conclusion and the result content. Output structure: + +{ + "conclusion": "Accept or Reject", + "confirmed_intent": "confirmed target or intent, string or null", + "failure_reason": "reason for not reaching agreement, string or null" +} + +## Field Rules +- target_negotiation_description: required in the propose phase. A paragraph summarizing the purpose and message nature of this target negotiation. +- intent_understanding / alignment_and_clarification / request_for_clarification: optional in the propose phase; each is either an array of entries or null. + - intent_understanding: the initiating party's understanding of the peer's intent, typically appearing in the first-round message. + - alignment_and_clarification: how both sides have aligned their understanding, plus clarified and pending points, typically appearing in later-round messages. + - request_for_clarification: specific questions the peer is asked to clarify; null or an empty array when the input raises no clarification question. + - Each entry is an object with exactly two keys, name and value; value may be null. +- conclusion: required in the ending phase; must be either "Accept" or "Reject" and must faithfully reflect the conclusion expressed by the input text; never output "Abort". +- confirmed_intent: required when the conclusion is "Accept"; the target or intent confirmed by both sides. Must be null when the conclusion is "Reject". +- failure_reason: required when the conclusion is "Reject"; the reason for failure or for not reaching agreement. Must be null when the conclusion is "Accept". + +## Extraction Principles +1. Extract only content explicitly expressed in the input text; do not fill in values from general knowledge or guess. +2. Assign the three entry arrays by semantics: statements of intent understanding go to intent_understanding; alignment progress and clarification notes go to alignment_and_clarification; explicit questions asked of the peer go to request_for_clarification. +3. When the input does not express content for an optional section, output null for that field; do not fabricate entries. +4. In the ending phase, the value of conclusion decides the choice between confirmed_intent and failure_reason; the unused side must be null. +5. Multiple parallel points under the same section must be preserved as multiple entries; do not keep only the last one. + +## Output Examples + +### Example 1: propose phase + +{ + "target_negotiation_description": "Request adjusting the energy-saving target from 30% to 20% while keeping the guaranteed rate no lower than 50Mbps.", + "intent_understanding": [ + {"name": "initiator understanding", "value": "the peer wants to reduce the energy-saving intensity while keeping the experience lossless"} + ], + "alignment_and_clarification": null, + "request_for_clarification": [ + {"name": "guaranteed rate floor", "value": "whether a guaranteed rate floor of 50Mbps is acceptable"} + ] +} + +### Example 2: ending phase (accept) + +{ + "conclusion": "Accept", + "confirmed_intent": "Both sides confirm adjusting the energy-saving target to 20% with the guaranteed rate no lower than 50Mbps.", + "failure_reason": null +} + +### Example 3: ending phase (reject) + +{ + "conclusion": "Reject", + "confirmed_intent": null, + "failure_reason": "The peer insists on the 30% energy-saving target; no agreement was reached on the guaranteed rate floor." +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/user.md new file mode 100644 index 00000000..4bb8ad2b --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/user.md @@ -0,0 +1,6 @@ +Negotiation phase: [phase] + +Input text: +[input] + +Following the output format and field rules in the system prompt, extract the target negotiation content for the current phase from the input text above. Output the JSON object only. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/system.md new file mode 100644 index 00000000..afa1edfe --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/system.md @@ -0,0 +1,79 @@ +你是目标协商(target negotiation)内容提取代理。你的任务是根据给定的协商阶段,从自然语言输入文本中提取目标协商的结构化内容 JSON,供后续模板渲染使用。 + +## 输出格式 +只输出一个 JSON 对象,不要输出 markdown 代码块、注释或任何额外文本。 + +## 阶段与输出结构 +输入文本所处的协商阶段由用户提示词中的阶段字段给出: + +1. 发起阶段(propose):提取目标协商概述、意图理解、理解对齐与疑问澄清、待澄清内容,输出结构: + +{ + "target_negotiation_description": "目标协商概述,字符串", + "intent_understanding": [ + {"name": "条目名称", "value": "条目内容"} + ], + "alignment_and_clarification": [ + {"name": "条目名称", "value": "条目内容"} + ], + "request_for_clarification": [ + {"name": "条目名称", "value": "条目内容"} + ] +} + +2. 结论阶段(accept / reject / accept-reject):提取协商结论与协商结果内容,输出结构: + +{ + "conclusion": "Accept 或 Reject", + "confirmed_intent": "确认后的目标或意图,字符串或 null", + "failure_reason": "未达成一致的原因,字符串或 null" +} + +## 字段规则 +- target_negotiation_description:发起阶段必填。概括本次目标协商的目的与消息性质的一段文字。 +- intent_understanding / alignment_and_clarification / request_for_clarification:发起阶段可选,取值为条目数组或 null。 + - intent_understanding:发起方对对方意图的理解陈述,通常在首轮报文中出现。 + - alignment_and_clarification:双方理解对齐情况与已澄清、待说明的事项,通常在后续轮次报文中出现。 + - request_for_clarification:需要对方澄清的具体问题;输入未提出澄清问题时为 null 或空数组。 + - 每个条目是一个恰好包含 name 与 value 两个键的对象;value 可为 null。 +- conclusion:结论阶段必填,只能为 "Accept" 或 "Reject",必须忠实于输入文本表达的结论;不得输出 "Abort"。 +- confirmed_intent:结论为 "Accept" 时必填,为双方确认后的目标或意图;结论为 "Reject" 时必须为 null。 +- failure_reason:结论为 "Reject" 时必填,为失败或未达成一致的原因;结论为 "Accept" 时必须为 null。 + +## 提取原则 +1. 只提取输入文本中明确表达的内容,不要基于常识补值或猜测。 +2. 三个条目数组按语义归位:对意图的理解陈述归 intent_understanding;理解对齐过程与澄清说明归 alignment_and_clarification;明确要求对方回答的问题归 request_for_clarification。 +3. 输入未表达某可选板块的内容时,该字段输出 null,不要编造条目。 +4. 结论阶段按 conclusion 的取值决定 confirmed_intent 与 failure_reason 的取舍,未用到的一侧必须为 null。 +5. 同一板块下多个并列要点应完整保留为多个条目,不要只保留最后一项。 + +## 输出示例 + +### 示例1:发起阶段(propose) + +{ + "target_negotiation_description": "请求将节能目标由30%调整为20%,同时保持速率保障不低于50Mbps。", + "intent_understanding": [ + {"name": "发起方理解", "value": "对方希望在体验无损的前提下降低节能力度"} + ], + "alignment_and_clarification": null, + "request_for_clarification": [ + {"name": "速率保障下限", "value": "50Mbps 的速率保障下限是否可接受"} + ] +} + +### 示例2:结论阶段(accept) + +{ + "conclusion": "Accept", + "confirmed_intent": "双方确认将节能目标调整为20%,速率保障不低于50Mbps。", + "failure_reason": null +} + +### 示例3:结论阶段(reject) + +{ + "conclusion": "Reject", + "confirmed_intent": null, + "failure_reason": "对方坚持30%的节能目标,双方未就速率保障下限达成一致。" +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/user.md new file mode 100644 index 00000000..efca2cd0 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/user.md @@ -0,0 +1,6 @@ +协商阶段:[phase] + +输入文本: +[input] + +请按系统提示词的输出格式与字段规则,从上述输入文本中提取当前阶段的目标协商内容,只输出 JSON 对象本身。 From e1b57cd67ad54ba11e30853fc21ae91beac4f08e Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Wed, 19 Aug 2026 16:21:08 +0800 Subject: [PATCH 028/295] feat(tools): extend template lint for Negotiation-T templates Signed-off-by: codetalks-hw --- tools/template_lint.py | 213 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 212 insertions(+), 1 deletion(-) diff --git a/tools/template_lint.py b/tools/template_lint.py index 392a665d..9de4e9fb 100644 --- a/tools/template_lint.py +++ b/tools/template_lint.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Static CI gate for A2A-T prompt templates and their slot JSON Schemas.""" +"""Static CI gate for A2A-T prompt templates, their slot JSON Schemas, and Negotiation-T templates.""" from __future__ import annotations @@ -22,6 +22,50 @@ "通知数据格式": "Notification Data Format", "上报通知数据格式": "Notification Data Format", } +NEGOTIATION_TYPE_SEGMENTS = ("information-negotiation", "target-negotiation", "feasibility-negotiation") +NEGOTIATION_PHASE_SEGMENTS = ("propose", "accept-reject") +NEGOTIATION_LANGUAGES = ("zh-CN", "en-US") +NEGOTIATION_SECTIONS = { + "context": ("协商上下文", "Negotiation Context"), + "info_static": ("信息协商", "Information Negotiation"), + "info_items": ("所需信息项", "Required Information Items"), + "info_conclusion": ("信息协商结果", "Information Negotiation Result"), + "info_result_content": ("信息协商结果内容", "Information Negotiation Result Content"), + "target": ("目标协商", "Target Negotiation"), + "target_intent": ("意图理解陈述", "Intent Understanding"), + "target_alignment": ("理解对齐与疑问澄清", "Alignment and Clarification"), + "target_clarification": ("待澄清内容", "Clarification Required"), + "target_conclusion": ("目标协商结果", "Target Negotiation Result"), + "target_result_content": ("目标协商结果内容", "Target Negotiation Result Content"), + "feasibility": ("可行性协商", "Feasibility Negotiation"), + "feasibility_evaluate": ("待评估内容说明", "Contents to Evaluate"), + "feasibility_infeasible": ("评估不可行时的详情和提案", "Infeasibility Details and Proposal"), + "feasibility_conclusion": ("可行性协商结果", "Feasibility Negotiation Result"), + "feasibility_confirm": ("可行性评估结果确认", "Feasibility Result Confirmation"), +} +NEGOTIATION_SLOT_EXCEPTIONS = { + "target": ("目标协商概述", "Target Negotiation Summary"), + "feasibility": ("可行性协商概述", "Feasibility Negotiation Summary"), + "feasibility_confirm": ("评估结果确认", "Feasibility Result"), +} +NEGOTIATION_STATIC_SECTIONS = {"info_static"} +NEGOTIATION_PROFILES = { + ("information-negotiation", "propose"): ("context", "info_static", "info_items"), + ("information-negotiation", "accept-reject"): ("context", "info_conclusion", "info_result_content"), + ("target-negotiation", "propose"): ("context", "target", "target_intent", "target_alignment", "target_clarification"), + ("target-negotiation", "accept-reject"): ("context", "target_conclusion", "target_result_content"), + ("feasibility-negotiation", "propose"): ("context", "feasibility", "feasibility_evaluate", "feasibility_infeasible"), + ("feasibility-negotiation", "accept-reject"): ("context", "feasibility_conclusion", "feasibility_confirm"), +} +NEGOTIATION_TITLE_LOOKUP = { + title: (key, language) + for key, titles in NEGOTIATION_SECTIONS.items() + for language, title in zip(NEGOTIATION_LANGUAGES, titles) +} +NEGOTIATION_MARKER = re.compile( + r"^\{\{(?P[^{}]+)\}\}(?:(?P((?P必填|选填)))| \((?Prequired|optional)\))$" +) + def error(path: Path, line: int, rule: str, message: str) -> str: return f"{path}:{line}: [{rule}] {message}" @@ -103,6 +147,172 @@ def lint_pair(template_path: Path, schema_path: Path) -> list[str]: return errors +def negotiation_section_title(key: str, language: str) -> str: + titles = NEGOTIATION_SECTIONS[key] + return titles[0] if language == "zh-CN" else titles[1] + + +def negotiation_slot_name(key: str, language: str) -> str: + titles = NEGOTIATION_SLOT_EXCEPTIONS.get(key, NEGOTIATION_SECTIONS[key]) + return titles[0] if language == "zh-CN" else titles[1] + + +def negotiation_requirements_label(language: str) -> str: + return "要求:" if language == "zh-CN" else "Requirements:" + + +def parse_negotiation_sections(lines: list[str]) -> list[dict]: + sections: list[dict] = [] + current: dict | None = None + for line_no, line in enumerate(lines, 1): + heading = re.match(r"^## (.+?)\s*$", line) + if heading: + current = {"title": heading.group(1), "line": line_no, "body": []} + sections.append(current) + elif current is not None: + current["body"].append((line_no, line)) + return sections + + +def lint_negotiation_file( + path: Path, language: str, type_segment: str, phase_segment: str +) -> tuple[list[str], list[tuple[str, bool | None, bool, int]] | None]: + errors: list[str] = [] + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + return [error(path, 1, "negotiation-template-read", f"Cannot read template: {exc}")], None + profile = NEGOTIATION_PROFILES[(type_segment, phase_segment)] + requirements_label = negotiation_requirements_label(language) + first_line = next((line for line in lines if line.strip()), "") + if language == "en-US": + if not (first_line.startswith("")): + errors.append(error(path, 1, "negotiation-comment", "en-US template must start with an HTML comment ''.")) + elif first_line.startswith(""; + + private static final List LOAD_ALL_TYPE_ORDER = + List.of(NegotiationType.INFORMATION, NegotiationType.TARGET, NegotiationType.FEASIBILITY); + + private static final List LOAD_ALL_PHASE_ORDER = + List.of(NegotiationPhase.PROPOSE, NegotiationPhase.ACCEPT); + + private final String language; + + private final Path localRootDir; + + /** + * Creates a loader for one language with an optional local template root. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @param localRootDir local prompt resource root containing the {@code templates/} tree; null or blank disables + * local template overrides + * @throws NegotiationContentException if the language is not a simple path segment + */ + public DefaultNegotiationTemplateLoader(String language, String localRootDir) { + if (language == null + || language.isBlank() + || language.contains("/") + || language.contains("\\") + || language.contains("..")) { + throw new NegotiationContentException( + "Negotiation template loader language must be a non-blank simple path segment but was " + language + + ".", + "language"); + } + this.language = language; + this.localRootDir = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); + } + + /** + * Loads one negotiation template, preferring a local override over the built-in classpath template. + * + * @param reference template addressing key, including the language to load + * @return loaded template with its URI, description and full content + * @throws ResourceNotFoundException if the template exists neither under the local root nor on the classpath + */ + @Override + public PromptTemplate load(NegotiationReference reference) { + String relativePath = templateRelativePath(reference); + String content = readTemplate(relativePath); + return new PromptTemplate(reference.uri(), extractDescription(content), content); + } + + /** + * Loads every loadable negotiation template of the loader's language. + * + *

The fixed iteration order is the negotiation type order information, target, feasibility crossed with the + * phase order propose, accept-reject. Templates that exist nowhere are skipped. + * + * @return templates of the loader's language that could be loaded, in a fixed order + */ + @Override + public List loadAll() { + List templates = new ArrayList<>(); + for (NegotiationType type : LOAD_ALL_TYPE_ORDER) { + for (NegotiationPhase phase : LOAD_ALL_PHASE_ORDER) { + try { + templates.add(load(new NegotiationReference(type, phase, language))); + } catch (ResourceNotFoundException exception) { + // A template that exists nowhere for this language is simply not listed. + } + } + } + return List.copyOf(templates); + } + + private static String templateRelativePath(NegotiationReference reference) { + return String.join( + "/", + "templates", + "Negotiation-T", + "v1", + reference.typeSegment(), + reference.phase().uriSegment(), + reference.language(), + TEMPLATE_FILE_NAME); + } + + private String readTemplate(String relativePath) { + if (localRootDir != null) { + Path localPath = localRootDir.resolve(relativePath); + if (Files.exists(localPath)) { + try { + return Files.readString(localPath, StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new SdkException("Failed to read negotiation template: " + localPath, exception); + } + } + } + String classpathPath = CLASSPATH_ROOT + relativePath; + InputStream stream = openClasspathResource(classpathPath); + if (stream == null) { + throw new ResourceNotFoundException( + "Negotiation template does not exist for the configured language; set A2AT_LANGUAGE to a" + + " language with bundled templates (zh-CN or en-US) or provide the template under the" + + " local resource root.", + classpathPath); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new SdkException("Failed to read negotiation template: " + classpathPath, exception); + } + } + + private static InputStream openClasspathResource(String classpathPath) { + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + if (contextClassLoader != null) { + InputStream stream = contextClassLoader.getResourceAsStream(classpathPath); + if (stream != null) { + return stream; + } + } + return DefaultNegotiationTemplateLoader.class.getClassLoader().getResourceAsStream(classpathPath); + } + + /** + * Extracts the template description from a leading HTML comment. + * + * @param content full template text + * @return stripped comment text of the first line when it is an HTML comment, otherwise an empty string + */ + private static String extractDescription(String content) { + int firstLineBreak = content.indexOf('\n'); + String firstLine = firstLineBreak < 0 ? content : content.substring(0, firstLineBreak); + firstLine = firstLine.strip(); + if (firstLine.startsWith(COMMENT_OPEN) + && firstLine.endsWith(COMMENT_CLOSE) + && firstLine.length() >= COMMENT_OPEN.length() + COMMENT_CLOSE.length()) { + String description = firstLine + .substring(COMMENT_OPEN.length(), firstLine.length() - COMMENT_CLOSE.length()) + .strip(); + return description; + } + return ""; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java new file mode 100644 index 00000000..fc856e85 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -0,0 +1,148 @@ +package net.openan.a2at.sdk.negotiation.resources; + +import java.util.Locale; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; + +/** + * Addressing key for one negotiation template: negotiation type, API-level phase and language. + * + *

The reference composes the template URI from the type and phase segments, so the URI spelling has a single source. + * The language is query context rather than part of the resource identity and is therefore not part of the URI. + * + * @param type negotiation type addressed by the reference + * @param phase API-level phase addressed by the reference; accept and reject share the same template + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @since 2026-06 + */ +public record NegotiationReference(NegotiationType type, NegotiationPhase phase, String language) { + + private static final String URI_PREFIX = "Negotiation-T"; + + private static final String URI_VERSION_SEGMENT = "v1"; + + private static final String TYPE_SEGMENT_SUFFIX = "-negotiation"; + + /** + * Validates the reference fields. + * + * @throws NegotiationContentException if the type or phase is null or the language is not a simple path segment + */ + public NegotiationReference { + if (type == null) { + throw new NegotiationContentException("Negotiation reference type must not be null.", "type"); + } + if (phase == null) { + throw new NegotiationContentException("Negotiation reference phase must not be null.", "phase"); + } + if (!isSimpleSegment(language)) { + throw new NegotiationContentException( + "Negotiation reference language must be a non-blank simple path segment but was " + language + ".", + "language"); + } + } + + /** + * Returns the hyphenated URI segment of the referenced negotiation type. + * + * @return URI segment such as {@code information-negotiation} + */ + public String typeSegment() { + return type.typeSegment(); + } + + /** + * Returns the template URI of the referenced template. + * + * @return template URI such as {@code Negotiation-T/v1/information-negotiation/propose} + */ + public String uri() { + return String.join("/", URI_PREFIX, URI_VERSION_SEGMENT, typeSegment(), phase.uriSegment()); + } + + /** + * Parses a template URI into a reference, checking it against the expected phase. + * + *

The URI layer cannot distinguish accept from reject because both share the {@code accept-reject} segment; the + * expected phase disambiguates the parsed result, which therefore always carries the expected phase. + * + * @param templateUri template URI to parse, such as {@code Negotiation-T/v1/target-negotiation/accept-reject} + * @param expectedPhase API-level phase the caller is operating on; the parsed reference carries this phase + * @param language locale identifier for the parsed reference + * @return reference addressed by the URI, carrying the expected phase + * @throws NegotiationContentException with field {@code templateUri} if the URI is null, blank or malformed + */ + public static NegotiationReference parse(String templateUri, NegotiationPhase expectedPhase, String language) { + if (expectedPhase == null) { + throw new NegotiationContentException("Expected negotiation phase must not be null.", "phase"); + } + if (templateUri == null || templateUri.isBlank()) { + throw new NegotiationContentException("Template URI must not be blank.", "templateUri"); + } + String[] segments = templateUri.strip().split("/"); + if (segments.length != 4) { + throw new NegotiationContentException( + "Template URI must contain exactly 4 slash-separated segments but has " + segments.length + ": " + + templateUri + ".", + "templateUri"); + } + if (!URI_PREFIX.equals(segments[0])) { + throw new NegotiationContentException( + "Template URI first segment must be " + URI_PREFIX + " but was " + segments[0] + ": " + templateUri + + ".", + "templateUri"); + } + if (!URI_VERSION_SEGMENT.equals(segments[1])) { + throw new NegotiationContentException( + "Template URI version segment must be " + URI_VERSION_SEGMENT + " but was " + segments[1] + ": " + + templateUri + ".", + "templateUri"); + } + NegotiationType parsedType = parseTypeSegment(segments[2], templateUri); + parsePhaseSegment(segments[3], expectedPhase, templateUri); + return new NegotiationReference(parsedType, expectedPhase, language); + } + + private static NegotiationType parseTypeSegment(String typeSegment, String templateUri) { + if (!typeSegment.endsWith(TYPE_SEGMENT_SUFFIX)) { + throw new NegotiationContentException( + "Template URI type segment must end with " + TYPE_SEGMENT_SUFFIX + " using hyphens, not" + + " underscores, but was " + typeSegment + ": " + templateUri + ".", + "templateUri"); + } + String typeName = typeSegment.substring(0, typeSegment.length() - TYPE_SEGMENT_SUFFIX.length()); + for (NegotiationType candidate : NegotiationType.values()) { + if (candidate.name().toLowerCase(Locale.ROOT).equals(typeName)) { + return candidate; + } + } + throw new NegotiationContentException( + "Template URI has an unknown negotiation type " + typeName + " in segment " + typeSegment + ": " + + templateUri + ".", + "templateUri"); + } + + private static void parsePhaseSegment(String phaseSegment, NegotiationPhase expectedPhase, String templateUri) { + if (!"propose".equals(phaseSegment) && !"accept-reject".equals(phaseSegment)) { + throw new NegotiationContentException( + "Template URI phase segment must be propose or accept-reject but was " + phaseSegment + ": " + + templateUri + ".", + "templateUri"); + } + if (!phaseSegment.equals(expectedPhase.uriSegment())) { + throw new NegotiationContentException( + "Template URI phase segment " + phaseSegment + " does not match the expected phase " + expectedPhase + + " (" + expectedPhase.uriSegment() + "): " + templateUri + ".", + "templateUri"); + } + } + + private static boolean isSimpleSegment(String value) { + return value != null + && !value.isBlank() + && !value.contains("/") + && !value.contains("\\") + && !value.contains(".."); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java new file mode 100644 index 00000000..fa2bfde6 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java @@ -0,0 +1,31 @@ +package net.openan.a2at.sdk.negotiation.resources; + +import java.util.List; + +/** + * Loads negotiation templates addressed by {@link NegotiationReference} keys. + * + * @since 2026-06 + */ +public interface NegotiationTemplateLoader { + + /** + * Loads one negotiation template. + * + * @param reference template addressing key, including the language to load + * @return loaded template with its URI, description and full content + * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if no template exists for the reference in + * any available resource root + */ + PromptTemplate load(NegotiationReference reference); + + /** + * Loads every loadable negotiation template of the loader's language. + * + *

Templates that do not exist are skipped; the result order is fixed by the negotiation type order followed by + * the phase order. + * + * @return templates of the loader's language that could be loaded, in a fixed order + */ + List loadAll(); +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/PromptTemplate.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/PromptTemplate.java new file mode 100644 index 00000000..9a91d80e --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/PromptTemplate.java @@ -0,0 +1,12 @@ +package net.openan.a2at.sdk.negotiation.resources; + +/** + * One loadable negotiation template. + * + * @param uri template URI such as {@code Negotiation-T/v1/information-negotiation/propose} + * @param description template description taken from the leading HTML comment; empty string when the template has no + * comment + * @param content full template file text + * @since 2026-06 + */ +public record PromptTemplate(String uri, String description, String content) {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/package-info.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/package-info.java new file mode 100644 index 00000000..0e4103f8 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/package-info.java @@ -0,0 +1,2 @@ +/** Negotiation template resources: addressing keys, template entries and the template loader. */ +package net.openan.a2at.sdk.negotiation.resources; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/MetadataContentTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/MetadataContentTest.java new file mode 100644 index 00000000..bc482724 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/MetadataContentTest.java @@ -0,0 +1,45 @@ +package net.openan.a2at.sdk.negotiation.content; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import org.junit.jupiter.api.Test; + +class MetadataContentTest { + + private static final String TEMPLATE_URI = "Negotiation-T/v1/information-negotiation/propose"; + + @Test + void recordExposesAllThreeComponents() { + MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message", "https://example/ext"); + + assertEquals(TEMPLATE_URI, content.templateUri()); + assertEquals("rendered message", content.promptText()); + assertEquals("https://example/ext", content.extensionUri()); + } + + @Test + void convenienceConstructorAppliesNegotiationExtensionUri() { + MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message"); + + assertEquals( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", + content.extensionUri()); + assertEquals(NegotiationHandler.NEGOTIATION_T_URI, content.extensionUri()); + } + + @Test + void buildMetadataContentReturnsExactlyTwoDeterministicEntries() { + MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message"); + + Map metadata = content.buildMetadataContent(); + + assertEquals(2, metadata.size()); + assertEquals("rendered message", metadata.get(content.extensionUri())); + assertEquals(TEMPLATE_URI, metadata.get("template_uri")); + assertEquals(content.buildMetadataContent(), metadata); + assertTrue(metadata.containsKey(NegotiationHandler.NEGOTIATION_T_URI)); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationContextTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationContextTest.java new file mode 100644 index 00000000..106c6e5e --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationContextTest.java @@ -0,0 +1,106 @@ +package net.openan.a2at.sdk.negotiation.content; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class NegotiationContextTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + @Test + void constructionAcceptsLegalContext() { + NegotiationContext context = new NegotiationContext(SESSION_ID, 1, 5); + + assertEquals(SESSION_ID, context.id()); + assertEquals(1, context.round()); + assertEquals(5, context.maxRounds()); + } + + @Test + void blankIdThrowsWithDistinguishableMessage() { + NegotiationContentException blankException = + assertThrows(NegotiationContentException.class, () -> new NegotiationContext("", 1, 5)); + NegotiationContentException whitespaceException = + assertThrows(NegotiationContentException.class, () -> new NegotiationContext(" ", 1, 5)); + NegotiationContentException nullException = + assertThrows(NegotiationContentException.class, () -> new NegotiationContext(null, 1, 5)); + + assertEquals("context.id", blankException.getField()); + assertTrue(blankException.getMessage().contains("id")); + assertTrue(blankException.getMessage().contains("blank")); + assertEquals(blankException.getMessage(), whitespaceException.getMessage()); + assertEquals(blankException.getMessage(), nullException.getMessage()); + } + + @Test + void zeroRoundThrowsWithDistinguishableMessage() { + NegotiationContentException exception = + assertThrows(NegotiationContentException.class, () -> new NegotiationContext(SESSION_ID, 0, 5)); + + assertEquals("context.round", exception.getField()); + assertTrue(exception.getMessage().contains("round")); + assertTrue(exception.getMessage().contains("0")); + } + + @Test + void negativeRoundThrowsWithDistinguishableMessage() { + NegotiationContentException exception = + assertThrows(NegotiationContentException.class, () -> new NegotiationContext(SESSION_ID, -1, 5)); + + assertEquals("context.round", exception.getField()); + assertTrue(exception.getMessage().contains("round")); + assertTrue(exception.getMessage().contains("-1")); + } + + @Test + void zeroMaxRoundsThrowsWithDistinguishableMessage() { + NegotiationContentException exception = + assertThrows(NegotiationContentException.class, () -> new NegotiationContext(SESSION_ID, 1, 0)); + + assertEquals("context.maxRounds", exception.getField()); + assertTrue(exception.getMessage().contains("maxRounds")); + assertTrue(exception.getMessage().contains("0")); + } + + @Test + void roundGreaterThanMaxRoundsIsAllowedAtConstruction() { + NegotiationContext context = new NegotiationContext(SESSION_ID, 6, 5); + + assertEquals(6, context.round()); + assertTrue(context.isExhausted()); + } + + @Test + void nextRoundReturnsNewInstanceAndLeavesOriginalUnchanged() { + NegotiationContext context = new NegotiationContext(SESSION_ID, 1, 5); + NegotiationContext advanced = context.nextRound(); + + assertEquals(2, advanced.round()); + assertEquals(SESSION_ID, advanced.id()); + assertEquals(5, advanced.maxRounds()); + assertEquals(1, context.round()); + assertNotEquals(context, advanced); + } + + @Test + void isExhaustedIsFalseAtBoundaryAndTrueBeyondIt() { + assertFalse(new NegotiationContext(SESSION_ID, 4, 5).isExhausted()); + assertFalse(new NegotiationContext(SESSION_ID, 5, 5).isExhausted()); + assertTrue(new NegotiationContext(SESSION_ID, 6, 5).isExhausted()); + } + + @Test + void factoryAppliesDefaultMaxRounds() { + NegotiationContext context = NegotiationContext.of(SESSION_ID, 2); + + assertEquals(5, NegotiationContext.DEFAULT_MAX_ROUNDS); + assertEquals(5, context.maxRounds()); + assertEquals(2, context.round()); + assertEquals(SESSION_ID, context.id()); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java new file mode 100644 index 00000000..4a2cbc64 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java @@ -0,0 +1,72 @@ +package net.openan.a2at.sdk.negotiation.content; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.List; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; +import org.junit.jupiter.api.Test; + +class NegotiationExceptionHierarchyTest { + + private static final Class[] NEGOTIATION_EXCEPTION_TYPES = { + NegotiationContentException.class, + NegotiationProcessingException.class, + NegotiationGenerationException.class, + NegotiationParamExtractionException.class + }; + + @Test + void contentExceptionIsNotAProcessingError() { + NegotiationContentException exception = new NegotiationContentException("invalid content", "templateUri"); + + assertFalse(A2ATError.class.isInstance(exception)); + assertEquals("templateUri", exception.getField()); + } + + @Test + void processingExceptionIsAProcessingErrorWithCode() { + NegotiationProcessingException exception = + new NegotiationProcessingException(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, "invalid input"); + + assertTrue(exception instanceof A2ATError); + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, exception.getCode()); + } + + @Test + void generationExceptionExtendsProcessingException() { + NegotiationGenerationException exception = + new NegotiationGenerationException(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, "missing slot"); + + assertTrue(exception instanceof NegotiationProcessingException); + assertTrue(exception instanceof A2ATError); + assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, exception.getCode()); + } + + @Test + void paramExtractionExceptionExtendsSharedParamExtractionError() { + NegotiationParamExtractionException defaultCodedException = + new NegotiationParamExtractionException("extraction failed"); + NegotiationParamExtractionException codedException = new NegotiationParamExtractionException( + A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, "rule violated", List.of()); + + assertTrue(defaultCodedException instanceof A2ATParamExtractionError); + assertTrue(codedException instanceof A2ATParamExtractionError); + assertEquals(A2ATErrorCodes.PARAM_EXTRACTION_FAILED, defaultCodedException.getCode()); + assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, codedException.getCode()); + assertTrue(codedException.getErrors().isEmpty()); + } + + @Test + void noNegotiationExceptionExposesAStageProperty() { + for (Class exceptionType : NEGOTIATION_EXCEPTION_TYPES) { + boolean exposesStage = Arrays.stream(exceptionType.getMethods()) + .anyMatch(method -> "getStage".equals(method.getName()) || "stage".equals(method.getName())); + assertFalse(exposesStage, exceptionType.getSimpleName() + " must not expose a stage property"); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java new file mode 100644 index 00000000..560ea39d --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java @@ -0,0 +1,107 @@ +package net.openan.a2at.sdk.negotiation.content; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; +import java.util.TreeSet; +import org.junit.jupiter.api.Test; + +class VocabularyTest { + + private static final Set CANONICAL_KEYS = Set.of( + "section.context", + "section.info_items", + "section.info_static", + "section.info_conclusion", + "section.info_result_content", + "section.target", + "section.target_intent", + "section.target_alignment", + "section.target_clarification", + "section.target_conclusion", + "section.target_result_content", + "section.feasibility", + "section.feasibility_evaluate", + "section.feasibility_infeasible", + "section.feasibility_conclusion", + "section.feasibility_confirm", + "slot.feasibility", + "slot.target", + "slot.feasibility_confirm", + "label.relationship", + "punct.list_colon"); + + @Test + void bothLanguagesExposeExactlyTheCanonicalKeys() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals(CANONICAL_KEYS, zhCn.canonicalKeys()); + assertEquals(CANONICAL_KEYS, enUs.canonicalKeys()); + assertEquals(new TreeSet<>(zhCn.canonicalKeys()), new TreeSet<>(enUs.canonicalKeys())); + assertEquals(21, zhCn.canonicalKeys().size()); + } + + @Test + void slotExceptionValuesMatchTemplatePlaceholders() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("可行性协商概述", zhCn.get("slot.feasibility")); + assertEquals("Feasibility Negotiation Summary", enUs.get("slot.feasibility")); + assertEquals("目标协商概述", zhCn.get("slot.target")); + assertEquals("Target Negotiation Summary", enUs.get("slot.target")); + assertEquals("评估结果确认", zhCn.get("slot.feasibility_confirm")); + assertEquals("Feasibility Result", enUs.get("slot.feasibility_confirm")); + } + + @Test + void sectionValuesMatchTemplateSectionTitles() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("协商上下文", zhCn.get("section.context")); + assertEquals("Negotiation Context", enUs.get("section.context")); + assertEquals("目标协商结果内容", zhCn.get("section.target_result_content")); + assertEquals("Target Negotiation Result Content", enUs.get("section.target_result_content")); + assertEquals("可行性评估结果确认", zhCn.get("section.feasibility_confirm")); + assertEquals("Feasibility Result Confirmation", enUs.get("section.feasibility_confirm")); + } + + @Test + void englishRelationshipLabelCarriesOneTrailingSpace() { + String englishLabel = Vocabulary.forLanguage("en-US").get("label.relationship"); + String chineseLabel = Vocabulary.forLanguage("zh-CN").get("label.relationship"); + + assertEquals("Relationship between missing items: ", englishLabel); + assertTrue(englishLabel.endsWith(" ")); + assertEquals("缺失项之间的关系:", chineseLabel); + } + + @Test + void listColonPunctuationDiffersPerLanguage() { + assertEquals(":", Vocabulary.forLanguage("zh-CN").get("punct.list_colon")); + assertEquals(": ", Vocabulary.forLanguage("en-US").get("punct.list_colon")); + } + + @Test + void unsupportedLanguageThrows() { + NegotiationContentException exception = + assertThrows(NegotiationContentException.class, () -> Vocabulary.forLanguage("fr-FR")); + + assertTrue(exception.getMessage().contains("fr-FR")); + assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); + } + + @Test + void unknownKeyThrows() { + Vocabulary vocabulary = Vocabulary.forLanguage("zh-CN"); + + NegotiationContentException exception = + assertThrows(NegotiationContentException.class, () -> vocabulary.get("section.unknown")); + + assertTrue(exception.getMessage().contains("section.unknown")); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java new file mode 100644 index 00000000..d1232dca --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java @@ -0,0 +1,147 @@ +package net.openan.a2at.sdk.negotiation.resources; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DefaultNegotiationTemplateLoaderTest { + + private static final List EXPECTED_LOAD_ALL_URIS = List.of( + "Negotiation-T/v1/information-negotiation/propose", + "Negotiation-T/v1/information-negotiation/accept-reject", + "Negotiation-T/v1/target-negotiation/propose", + "Negotiation-T/v1/target-negotiation/accept-reject", + "Negotiation-T/v1/feasibility-negotiation/propose", + "Negotiation-T/v1/feasibility-negotiation/accept-reject"); + + @TempDir + Path customRootDir; + + @Test + void loadAllReturnsAllSixBuiltinTemplatesPerLanguageInFixedOrder() { + DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN", null); + DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US", null); + + List zhCnTemplates = zhCnLoader.loadAll(); + List enUsTemplates = enUsLoader.loadAll(); + + assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(zhCnTemplates)); + assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(enUsTemplates)); + assertEquals(6, zhCnTemplates.size()); + assertEquals(6, enUsTemplates.size()); + assertEquals(12, zhCnTemplates.size() + enUsTemplates.size()); + } + + @Test + void loadReturnsFullTemplateContentFromTheClasspath() { + DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("en-US", null); + + PromptTemplate template = + loader.load(new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "en-US")); + + assertEquals("Negotiation-T/v1/information-negotiation/propose", template.uri()); + assertTrue(template.content().startsWith("\n\n## 协商上下文\n自定义标记内容\n"); + DefaultNegotiationTemplateLoader loader = + new DefaultNegotiationTemplateLoader("zh-CN", customRootDir.toString()); + + PromptTemplate overridden = + loader.load(new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN")); + assertTrue(overridden.content().contains("自定义标记内容")); + assertEquals("custom template", overridden.description()); + + PromptTemplate untouched = + loader.load(new NegotiationReference(NegotiationType.TARGET, NegotiationPhase.PROPOSE, "zh-CN")); + assertTrue(untouched.content().contains("目标协商概述")); + assertTrue(!untouched.content().contains("自定义标记内容")); + + Files.delete(customTemplatePath); + + PromptTemplate fallback = + loader.load(new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN")); + assertTrue(fallback.content().contains("## 协商上下文")); + assertTrue(!fallback.content().contains("自定义标记内容")); + assertEquals("", fallback.description()); + } + + @Test + void loadAllWithCustomRootStillListsEveryTemplate() throws IOException { + Path customTemplatePath = customRootDir + .resolve("templates") + .resolve("Negotiation-T") + .resolve("v1") + .resolve("target-negotiation") + .resolve("propose") + .resolve("zh-CN") + .resolve("template.md"); + writeTemplate(customTemplatePath, "\n\n## 目标协商\n自定义目标内容\n"); + DefaultNegotiationTemplateLoader loader = + new DefaultNegotiationTemplateLoader("zh-CN", customRootDir.toString()); + + List templates = loader.loadAll(); + + assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(templates)); + PromptTemplate overriddenTemplate = templates.get(2); + assertEquals("Negotiation-T/v1/target-negotiation/propose", overriddenTemplate.uri()); + assertTrue(overriddenTemplate.content().contains("自定义目标内容")); + } + + @Test + void missingLanguageThrowsOnLoadAndIsSkippedByLoadAll() { + DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("fr-FR", null); + + ResourceNotFoundException exception = assertThrows( + ResourceNotFoundException.class, + () -> loader.load( + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "fr-FR"))); + + assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); + assertEquals(List.of(), loader.loadAll()); + } + + private static List urisOf(List templates) { + return templates.stream().map(PromptTemplate::uri).toList(); + } + + private static void writeTemplate(Path file, String content) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, content); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java new file mode 100644 index 00000000..c158508e --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java @@ -0,0 +1,161 @@ +package net.openan.a2at.sdk.negotiation.resources; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import org.junit.jupiter.api.Test; + +class NegotiationReferenceTest { + + @Test + void typeSegmentUsesHyphenatedNames() { + assertEquals("information-negotiation", NegotiationType.INFORMATION.typeSegment()); + assertEquals("target-negotiation", NegotiationType.TARGET.typeSegment()); + assertEquals("feasibility-negotiation", NegotiationType.FEASIBILITY.typeSegment()); + } + + @Test + void uriComposesPrefixVersionTypeSegmentAndPhaseSegment() { + assertEquals( + "Negotiation-T/v1/information-negotiation/propose", + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "en-US").uri()); + assertEquals( + "Negotiation-T/v1/target-negotiation/accept-reject", + new NegotiationReference(NegotiationType.TARGET, NegotiationPhase.ACCEPT, "zh-CN").uri()); + assertEquals( + "Negotiation-T/v1/feasibility-negotiation/accept-reject", + new NegotiationReference(NegotiationType.FEASIBILITY, NegotiationPhase.REJECT, "zh-CN").uri()); + } + + @Test + void parseAcceptsAllSixValidUris() { + for (NegotiationType type : NegotiationType.values()) { + NegotiationReference propose = + NegotiationReference.parse(proposeUri(type), NegotiationPhase.PROPOSE, "zh-CN"); + assertEquals(type, propose.type()); + assertEquals(NegotiationPhase.PROPOSE, propose.phase()); + assertEquals(proposeUri(type), propose.uri()); + assertEquals("zh-CN", propose.language()); + + NegotiationReference accept = + NegotiationReference.parse(acceptRejectUri(type), NegotiationPhase.ACCEPT, "en-US"); + assertEquals(type, accept.type()); + assertEquals(NegotiationPhase.ACCEPT, accept.phase()); + assertEquals(acceptRejectUri(type), accept.uri()); + + NegotiationReference reject = + NegotiationReference.parse(acceptRejectUri(type), NegotiationPhase.REJECT, "en-US"); + assertEquals(type, reject.type()); + assertEquals(NegotiationPhase.REJECT, reject.phase()); + assertEquals(acceptRejectUri(type), reject.uri()); + } + } + + @Test + void parseRejectsWrongSegmentCount() { + assertParseFailure("information-negotiation/propose", NegotiationPhase.PROPOSE, "segments"); + assertParseFailure( + "Negotiation-T/v1/information-negotiation/propose/extra", NegotiationPhase.PROPOSE, "segments"); + assertParseFailure("foo", NegotiationPhase.PROPOSE, "segments"); + } + + @Test + void parseRejectsWrongPrefixAndVersion() { + assertParseFailure("Task-T/v1/information-negotiation/propose", NegotiationPhase.PROPOSE, "Negotiation-T"); + assertParseFailure( + "negotiation-t/v1/information-negotiation/propose", NegotiationPhase.PROPOSE, "Negotiation-T"); + assertParseFailure("Negotiation-T/v2/information-negotiation/propose", NegotiationPhase.PROPOSE, "v1"); + } + + @Test + void parseRejectsMissingTypeSegmentSuffixAndUnderscoreVariant() { + assertParseFailure("Negotiation-T/v1/information/propose", NegotiationPhase.PROPOSE, "-negotiation"); + assertParseFailure( + "Negotiation-T/v1/information_negotiation/propose", NegotiationPhase.PROPOSE, "-negotiation"); + } + + @Test + void parseRejectsUnknownType() { + assertParseFailure("Negotiation-T/v1/unknown-negotiation/propose", NegotiationPhase.PROPOSE, "unknown"); + } + + @Test + void parseRejectsIllegalPhaseSegment() { + assertParseFailure("Negotiation-T/v1/information-negotiation/propose-x", NegotiationPhase.PROPOSE, "phase"); + assertParseFailure("Negotiation-T/v1/information-negotiation/accept", NegotiationPhase.PROPOSE, "phase"); + } + + @Test + void parseRejectsPhaseMismatchAgainstExpectedPhase() { + NegotiationContentException exception = assertThrows( + NegotiationContentException.class, + () -> NegotiationReference.parse( + "Negotiation-T/v1/information-negotiation/propose", NegotiationPhase.ACCEPT, "zh-CN")); + + assertEquals("templateUri", exception.getField()); + assertTrue(exception.getMessage().contains("does not match the expected phase")); + assertTrue(exception.getMessage().contains("propose")); + } + + @Test + void parseRejectsBlankUriAndNullExpectedPhase() { + NegotiationContentException blankException = assertThrows( + NegotiationContentException.class, + () -> NegotiationReference.parse("", NegotiationPhase.PROPOSE, "zh-CN")); + assertEquals("templateUri", blankException.getField()); + + NegotiationContentException nullUriException = assertThrows( + NegotiationContentException.class, + () -> NegotiationReference.parse(null, NegotiationPhase.PROPOSE, "zh-CN")); + assertEquals("templateUri", nullUriException.getField()); + + NegotiationContentException nullPhaseException = assertThrows( + NegotiationContentException.class, + () -> NegotiationReference.parse("Negotiation-T/v1/information-negotiation/propose", null, "zh-CN")); + assertEquals("phase", nullPhaseException.getField()); + } + + @Test + void parseFailureMessagesAreDistinguishable() { + List messages = List.of( + parseErrorMessage("information-negotiation/propose", NegotiationPhase.PROPOSE), + parseErrorMessage("Task-T/v1/information-negotiation/propose", NegotiationPhase.PROPOSE), + parseErrorMessage("Negotiation-T/v2/information-negotiation/propose", NegotiationPhase.PROPOSE), + parseErrorMessage("Negotiation-T/v1/information/propose", NegotiationPhase.PROPOSE), + parseErrorMessage("Negotiation-T/v1/unknown-negotiation/propose", NegotiationPhase.PROPOSE), + parseErrorMessage("Negotiation-T/v1/information-negotiation/propose-x", NegotiationPhase.PROPOSE)); + + assertEquals(messages.size(), messages.stream().distinct().count()); + } + + private static String proposeUri(NegotiationType type) { + return "Negotiation-T/v1/" + type.typeSegment() + "/propose"; + } + + private static String acceptRejectUri(NegotiationType type) { + return "Negotiation-T/v1/" + type.typeSegment() + "/accept-reject"; + } + + private static void assertParseFailure(String templateUri, NegotiationPhase expectedPhase, String messageFragment) { + NegotiationContentException exception = assertThrows( + NegotiationContentException.class, + () -> NegotiationReference.parse(templateUri, expectedPhase, "zh-CN")); + + assertEquals("templateUri", exception.getField()); + assertTrue( + exception.getMessage().contains(messageFragment), + "expected message containing '" + messageFragment + "' but was: " + exception.getMessage()); + } + + private static String parseErrorMessage(String templateUri, NegotiationPhase expectedPhase) { + return assertThrows( + NegotiationContentException.class, + () -> NegotiationReference.parse(templateUri, expectedPhase, "zh-CN")) + .getMessage(); + } +} diff --git a/a2a-t-negotiation/src/test/resources/logback-test.xml b/a2a-t-negotiation/src/test/resources/logback-test.xml new file mode 100644 index 00000000..75b66652 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/logback-test.xml @@ -0,0 +1,11 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + From a3723ac23257ec612b046c5489679447a660e3a6 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Wed, 19 Aug 2026 17:27:06 +0800 Subject: [PATCH 031/295] feat(a2a-t-negotiation): add negotiation compliance and semantic validation Signed-off-by: codetalks-hw --- .../DefaultNegotiationComplianceChecker.java | 178 +++++++++++ .../DefaultNegotiationSemanticValidator.java | 279 ++++++++++++++++++ .../NegotiationComplianceChecker.java | 30 ++ .../validation/NegotiationParamSchemas.java | 38 +++ .../NegotiationRuleCheckResult.java | 35 +++ .../NegotiationSemanticValidator.java | 34 +++ .../NegotiationValidationException.java | 33 +++ .../validation/ParamExtractor.java | 168 +++++++++++ .../validation/SemanticSchemaBuilder.java | 30 ++ .../validation/SemanticValidationResult.java | 35 +++ .../negotiation/validation/package-info.java | 2 + .../validation/ComplianceCheckerTest.java | 251 ++++++++++++++++ ...faultNegotiationSemanticValidatorTest.java | 248 ++++++++++++++++ .../validation/ParamExtractorTest.java | 223 ++++++++++++++ .../validation/ParamSchemasTest.java | 49 +++ 15 files changed, 1633 insertions(+) create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationParamSchemas.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticSchemaBuilder.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/package-info.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamSchemasTest.java diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java new file mode 100644 index 00000000..ec1a8aff --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java @@ -0,0 +1,178 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Default rule-level compliance checker for rendered negotiation messages. + * + *

The text is split on lines starting with {@code ## }; anything before the first section title is discarded. A text + * is recognised as a negotiation message when one section title equals the vocabulary's negotiation context section + * title. For a recognised message the checker parses the {@code - id:}, {@code - round:} and {@code - maxRounds:} list + * lines of that section and enforces that the id is a UUID of 36 characters in 8-4-4-4-12 hexadecimal form, that the + * round and the round budget are integers of at least 1, and that the round does not exceed the budget. No other rules + * are applied: the checker does not infer the negotiation type, does not validate conclusion values, does not require + * ending result sections and does not check conditional-section exclusivity. + * + * @since 2026-06 + */ +public final class DefaultNegotiationComplianceChecker implements NegotiationComplianceChecker { + + private static final Pattern UUID_PATTERN = + Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + + private static final String SECTION_PREFIX = "## "; + + private static final String ID_SLOT = "id"; + + private static final String ROUND_SLOT = "round"; + + private static final String MAX_ROUNDS_SLOT = "maxRounds"; + + /** Creates the default checker. */ + public DefaultNegotiationComplianceChecker() {} + + /** + * Runs the rule-level compliance check of one rendered message. + * + * @param prompt rendered negotiation message text; a null or empty text is reported as not being a negotiation + * message + * @param vocabulary vocabulary supplying the language-specific section titles of the message + * @return rule check outcome: not a negotiation message when the context section is absent, otherwise the parsed + * context with no errors when every context rule holds, otherwise the context rule errors without a context + * @throws NullPointerException if the vocabulary is null + */ + @Override + public NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary) { + if (vocabulary == null) { + throw new NullPointerException("vocabulary"); + } + List contextBody = contextSectionBody(prompt, vocabulary.get("section.context")); + if (contextBody == null) { + return new NegotiationRuleCheckResult(false, false, List.of(), null); + } + + String id = stringValue(contextBody, "- id:"); + String roundText = stringValue(contextBody, "- round:"); + String maxRoundsText = stringValue(contextBody, "- maxRounds:"); + Integer round = integerValue(roundText); + Integer maxRounds = integerValue(maxRoundsText); + + List errors = new ArrayList<>(); + collectIdErrors(id, errors); + collectRangeErrors(ROUND_SLOT, round, roundText, errors); + collectRangeErrors(MAX_ROUNDS_SLOT, maxRounds, maxRoundsText, errors); + if (round != null && maxRounds != null && round >= 1 && maxRounds >= 1 && round > maxRounds) { + errors.add(new SlotValidationError( + ROUND_SLOT, + "out_of_range", + "Negotiation context round " + round + " must not exceed maxRounds " + maxRounds + ".")); + } + + if (errors.isEmpty()) { + NegotiationContext context = new NegotiationContext(id, round, maxRounds); + return new NegotiationRuleCheckResult(true, true, List.of(), context); + } + return new NegotiationRuleCheckResult(false, true, List.copyOf(errors), null); + } + + private static void collectIdErrors(String id, List errors) { + if (id == null) { + errors.add(new SlotValidationError( + ID_SLOT, "missing_field", "Negotiation context section is missing the id line.")); + return; + } + if (!UUID_PATTERN.matcher(id).matches()) { + errors.add(new SlotValidationError( + ID_SLOT, + "invalid_uuid", + "Negotiation context id must be a 36-character UUID in 8-4-4-4-12 hexadecimal form but was '" + id + + "'.")); + } + } + + private static void collectRangeErrors( + String slotName, Integer value, String valueText, List errors) { + if (value == null) { + if (valueText == null) { + errors.add(new SlotValidationError( + slotName, + "missing_field", + "Negotiation context section is missing the " + slotName + " line.")); + } else { + errors.add(new SlotValidationError( + slotName, + "invalid_value", + "Negotiation context " + slotName + " must be an integer but was '" + valueText + "'.")); + } + return; + } + if (value < 1) { + errors.add(new SlotValidationError( + slotName, + "out_of_range", + "Negotiation context " + slotName + " must be a positive integer but was " + value + ".")); + } + } + + /** + * Returns the body lines of the negotiation context section, or null when the text has no such section. + * + *

Content before the first section title is discarded entirely, whether or not a context section follows. + */ + private static List contextSectionBody(String prompt, String contextTitle) { + if (prompt == null || prompt.isEmpty()) { + return null; + } + boolean inContextSection = false; + List bodyLines = new ArrayList<>(); + for (String rawLine : prompt.split("\n", -1)) { + String line = rawLine.strip(); + if (line.startsWith(SECTION_PREFIX)) { + String title = line.substring(SECTION_PREFIX.length()).strip(); + if (title.equals(contextTitle)) { + if (inContextSection) { + break; + } + inContextSection = true; + continue; + } + if (inContextSection) { + break; + } + continue; + } + if (inContextSection) { + bodyLines.add(line); + } + } + return inContextSection ? bodyLines : null; + } + + /** + * Returns the stripped value of the first list line carrying the given prefix, or null when no such line exists. + */ + private static String stringValue(List bodyLines, String linePrefix) { + for (String line : bodyLines) { + if (line.startsWith(linePrefix)) { + return line.substring(linePrefix.length()).strip(); + } + } + return null; + } + + private static Integer integerValue(String valueText) { + if (valueText == null) { + return null; + } + try { + return Integer.valueOf(valueText); + } catch (NumberFormatException error) { + return null; + } + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java new file mode 100644 index 00000000..440492b9 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java @@ -0,0 +1,279 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; + +/** + * Default LLM-backed semantic validator for negotiation messages. + * + *

One structured LLM call performs the semantic validation and the parameter extraction together. The call loads the + * semantic validation prompt resources of the reference language from the classpath, fills the literal bracket tokens + * of the user prompt with the declared negotiation type, the template URI, the serialized caller schema and the message + * text, and passes the caller schema merged into the semantic validation output contract as the output schema. + * + *

After the call the validator enforces the output contract in code: the response must contain the four required + * keys with the expected shapes, otherwise the internal {@link NegotiationValidationException} is thrown for the + * orchestration layer to map to the retryable LLM infrastructure error code. When the verdict is true, the reported + * negotiation type must be present and must match the type declared by the template reference; a null or mismatching + * type turns the outcome into a semantic rejection carrying a {@code section.*} error. Phase consistency between the + * declared template and the message sections is part of the semantic tasks performed by the LLM and surfaces through + * the returned errors. + * + * @since 2026-06 + */ +public final class DefaultNegotiationSemanticValidator implements NegotiationSemanticValidator { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String PROMPT_RESOURCE_ROOT = "prompt_resources/prompts/negotiation_semantic_validation/"; + + private static final String SYSTEM_PROMPT_FILE = "system.md"; + + private static final String USER_PROMPT_FILE = "user.md"; + + private static final String KEY_SEMANTIC_VERDICT = "semantic_verdict"; + + private static final String KEY_NEGOTIATION_TYPE = "negotiation_type"; + + private static final String KEY_ERRORS = "errors"; + + private static final String KEY_PARAMS = "params"; + + private final LLMClient llmClient; + + private final SemanticSchemaBuilder schemaBuilder; + + /** + * Creates the default semantic validator. + * + * @param llmClient LLM client used for the single structured call + * @param schemaBuilder collaborator merging the caller schema into the semantic validation output contract; the + * orchestrator builder wires the generation-package schema builder to this seam + * @throws NullPointerException if the LLM client or the schema builder is null + */ + public DefaultNegotiationSemanticValidator(LLMClient llmClient, SemanticSchemaBuilder schemaBuilder) { + this.llmClient = Objects.requireNonNull(llmClient, "llmClient"); + this.schemaBuilder = Objects.requireNonNull(schemaBuilder, "schemaBuilder"); + } + + /** + * Validates one rendered negotiation message semantically and extracts its parameters in a single LLM call. + * + * @param prompt rendered negotiation message text + * @param callerSchema caller-provided parameter JSON schema embedded into the structured-call output contract + * @param reference template reference the message is validated against, carrying the declared type, phase and + * language + * @return semantic validation outcome carrying the verdict, the implied negotiation type, the semantic errors and + * the extracted parameters + * @throws NegotiationValidationException if the LLM invocation fails or the response misses a required key or has + * the wrong shape + * @throws ResourceNotFoundException if the semantic validation prompt resources of the reference language are + * missing + */ + @Override + public SemanticValidationResult validate( + String prompt, Map callerSchema, NegotiationReference reference) { + Objects.requireNonNull(prompt, "prompt"); + Objects.requireNonNull(reference, "reference"); + List> messages = buildMessages(prompt, callerSchema, reference); + Map mergedSchema = schemaBuilder.buildSemanticValidationSchema(callerSchema); + LLMResponse response; + try { + response = llmClient.structured(messages, mergedSchema, null, null); + } catch (LLMError error) { + throw new NegotiationValidationException( + "Semantic validation LLM invocation failed: " + error.getMessage(), error); + } + return interpret(parseResponse(response.content()), reference); + } + + private static List> buildMessages( + String prompt, Map callerSchema, NegotiationReference reference) { + String language = reference.language(); + String systemPrompt = loadPromptResource(SYSTEM_PROMPT_FILE, language); + String userPrompt = loadPromptResource(USER_PROMPT_FILE, language); + String filledUserPrompt = userPrompt + .replace("[phase]", reference.phase().name().toLowerCase(Locale.ROOT)) + .replace("[input]", prompt) + .replace("[template_uri]", reference.uri()) + .replace("[negotiation_type]", reference.type().name().toLowerCase(Locale.ROOT)) + .replace("[schema]", toJson(callerSchema)); + return List.of( + Map.of("role", "system", "content", systemPrompt), Map.of("role", "user", "content", filledUserPrompt)); + } + + private static String loadPromptResource(String fileName, String language) { + String classpathPath = PROMPT_RESOURCE_ROOT + language + "/" + fileName; + InputStream stream = openClasspathResource(classpathPath); + if (stream == null) { + throw new ResourceNotFoundException( + "Negotiation semantic validation prompt resource does not exist for language " + language + + "; set A2AT_LANGUAGE to a language with bundled prompt resources (zh-CN or en-US).", + classpathPath); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new SdkException( + "Failed to read negotiation semantic validation prompt: " + classpathPath, exception); + } + } + + private static InputStream openClasspathResource(String classpathPath) { + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + if (contextClassLoader != null) { + InputStream stream = contextClassLoader.getResourceAsStream(classpathPath); + if (stream != null) { + return stream; + } + } + return DefaultNegotiationSemanticValidator.class.getClassLoader().getResourceAsStream(classpathPath); + } + + private static String toJson(Map callerSchema) { + try { + return OBJECT_MAPPER.writeValueAsString(callerSchema == null ? Map.of() : callerSchema); + } catch (JsonProcessingException error) { + throw new NegotiationValidationException("Failed to serialize the caller parameter schema.", error); + } + } + + private static Map parseResponse(String content) { + if (content == null || content.isBlank()) { + throw new NegotiationValidationException("Semantic validation response is empty."); + } + try { + Map parsed = OBJECT_MAPPER.readValue(content, new TypeReference>() {}); + return parsed == null ? Map.of() : parsed; + } catch (JsonProcessingException error) { + throw new NegotiationValidationException( + "Semantic validation response is not a JSON object: " + error.getMessage(), error); + } + } + + private static SemanticValidationResult interpret(Map response, NegotiationReference reference) { + if (!(response.get(KEY_SEMANTIC_VERDICT) instanceof Boolean verdict)) { + throw new NegotiationValidationException( + "Semantic validation response key semantic_verdict must be a boolean."); + } + if (!response.containsKey(KEY_NEGOTIATION_TYPE)) { + throw new NegotiationValidationException( + "Semantic validation response is missing the required key negotiation_type."); + } + Object typeValue = response.get(KEY_NEGOTIATION_TYPE); + if (typeValue != null && !(typeValue instanceof String)) { + throw new NegotiationValidationException( + "Semantic validation response key negotiation_type must be a string or null."); + } + if (!(response.get(KEY_ERRORS) instanceof List rawErrors)) { + throw new NegotiationValidationException("Semantic validation response key errors must be an array."); + } + if (!(response.get(KEY_PARAMS) instanceof Map rawParams)) { + throw new NegotiationValidationException("Semantic validation response key params must be an object."); + } + + List errors = parseErrors(rawErrors); + Map params = parseParams(rawParams); + String negotiationType = (String) typeValue; + + if (verdict) { + if (negotiationType == null) { + List rejectionErrors = new ArrayList<>(errors); + rejectionErrors.add(typeConsistencyError( + reference.type(), + "Semantic verdict is true but negotiation_type is null; the implied type must match the" + + " declared template.")); + return new SemanticValidationResult(false, null, rejectionErrors, params); + } + NegotiationType impliedType = parseImpliedType(negotiationType); + if (impliedType != reference.type()) { + NegotiationType sectionType = impliedType == null ? reference.type() : impliedType; + List rejectionErrors = new ArrayList<>(errors); + rejectionErrors.add(typeConsistencyError( + sectionType, + "Message implies negotiation type " + negotiationType + + " but the declared template URI expects " + declaredTypeName(reference) + ".")); + return new SemanticValidationResult(false, negotiationType, rejectionErrors, params); + } + } + return new SemanticValidationResult(verdict, negotiationType, errors, params); + } + + private static List parseErrors(List rawErrors) { + List errors = new ArrayList<>(); + for (Object rawError : rawErrors) { + if (!(rawError instanceof Map errorMap)) { + throw new NegotiationValidationException( + "Semantic validation response errors must be objects with slot_name, code and message."); + } + if (!(errorMap.get("slot_name") instanceof String slotName) + || !(errorMap.get("code") instanceof String code) + || !(errorMap.get("message") instanceof String message)) { + throw new NegotiationValidationException( + "Semantic validation response errors must carry string slot_name, code and message values."); + } + errors.add(new SlotValidationError(slotName, code, message)); + } + return errors; + } + + private static Map parseParams(Map rawParams) { + Map params = new LinkedHashMap<>(); + for (Map.Entry entry : rawParams.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + throw new NegotiationValidationException("Semantic validation response params keys must be strings."); + } + params.put(key, entry.getValue()); + } + return params; + } + + private static SlotValidationError typeConsistencyError(NegotiationType sectionType, String message) { + return new SlotValidationError(declaredTypeSectionKey(sectionType), "template_type_mismatch", message); + } + + /** Parses the negotiation type reported for the message; null when the reported type is none of the known types. */ + private static NegotiationType parseImpliedType(String negotiationType) { + for (NegotiationType candidate : NegotiationType.values()) { + if (candidate.name().toLowerCase(Locale.ROOT).equals(negotiationType)) { + return candidate; + } + } + return null; + } + + private static String declaredTypeName(NegotiationReference reference) { + return reference.type().name().toLowerCase(Locale.ROOT); + } + + private static String declaredTypeSectionKey(NegotiationType type) { + switch (type) { + case INFORMATION: + return "section.info_static"; + case TARGET: + return "section.target"; + case FEASIBILITY: + return "section.feasibility"; + default: + throw new NegotiationValidationException("Unknown negotiation type " + type + "."); + } + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java new file mode 100644 index 00000000..b9012836 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java @@ -0,0 +1,30 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Rule-level compliance checker for rendered negotiation messages. + * + *

The checker is deterministic and never calls an LLM. It splits the text into {@code ## } sections, recognises + * whether the text is a negotiation message by the presence of the negotiation context section, and validates only the + * strong constraints of that section: the id must be a UUID in 8-4-4-4-12 hexadecimal form, the round and the round + * budget must be positive integers, and the round must not exceed the budget. Type inference, conclusion values, + * ending-section presence and conditional-section exclusivity are deliberately not checked here; they belong to the + * semantic validation step. + * + * @since 2026-06 + */ +public interface NegotiationComplianceChecker { + + /** + * Runs the rule-level compliance check of one rendered message. + * + * @param prompt rendered negotiation message text; a null or empty text is reported as not being a negotiation + * message + * @param vocabulary vocabulary supplying the language-specific section titles of the message + * @return rule check outcome carrying the pass flag, the negotiation recognition flag, the structured errors and + * the parsed context + * @throws NullPointerException if the vocabulary is null + */ + NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary); +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationParamSchemas.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationParamSchemas.java new file mode 100644 index 00000000..b38a3e34 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationParamSchemas.java @@ -0,0 +1,38 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Built-in parameter schemas for negotiation parameter extraction. + * + *

Callers must always provide a parameter schema explicitly; the schema built here is a convenience constant for the + * plain negotiation context parameters and remains open for additional properties, so callers may tighten or replace it + * freely. + * + * @since 2026-06 + */ +public final class NegotiationParamSchemas { + + private NegotiationParamSchemas() {} + + /** + * Builds the built-in negotiation parameter schema. + * + * @return schema describing an object with the string property id and the integer properties round and maxRounds, + * allowing additional properties + */ + public static Map buildNegotiationParamSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("id", Map.of("type", "string")); + properties.put("round", Map.of("type", "integer")); + properties.put("maxRounds", Map.of("type", "integer")); + + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", Collections.unmodifiableMap(properties)); + schema.put("additionalProperties", true); + return Collections.unmodifiableMap(schema); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java new file mode 100644 index 00000000..ac10b30c --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java @@ -0,0 +1,35 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.List; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; + +/** + * Outcome of the rule-level compliance check of a rendered negotiation message. + * + *

The checker only validates the negotiation context section, so this result carries exactly four components: the + * overall pass flag, the flag telling whether the text is a negotiation message at all, the structured rule errors and + * the parsed negotiation context. Nothing else is inferred or checked here. + * + * @param passed {@code true} only when the text is a negotiation message whose negotiation context section satisfies + * every context rule + * @param isNegotiation {@code true} when the text contains the negotiation context section and is therefore a + * negotiation message + * @param errors structured rule errors of the negotiation context section; empty when the text is not a negotiation + * message or when every rule passes + * @param context parsed negotiation context; {@code null} unless the text is a negotiation message whose context is + * fully valid + * @since 2026-06 + */ +public record NegotiationRuleCheckResult( + boolean passed, boolean isNegotiation, List errors, NegotiationContext context) { + + /** + * Normalizes the error list. + * + * @throws NullPointerException if the errors list is null + */ + public NegotiationRuleCheckResult { + errors = List.copyOf(errors); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java new file mode 100644 index 00000000..324cf637 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java @@ -0,0 +1,34 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.Map; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; + +/** + * LLM-backed semantic validator for negotiation messages. + * + *

The validator performs a single structured LLM call that combines semantic validation with parameter extraction + * and then enforces the declared type consistency in code: when the verdict is true, the negotiation type reported for + * the message must be present and must match the type declared by the template reference. A response that misses one of + * the four required keys or has the wrong shape is a validation infrastructure failure signalled through the internal + * {@link NegotiationValidationException}. + * + * @since 2026-06 + */ +public interface NegotiationSemanticValidator { + + /** + * Validates one rendered negotiation message semantically and extracts its parameters. + * + * @param prompt rendered negotiation message text + * @param callerSchema caller-provided parameter JSON schema embedded into the structured-call output contract + * @param reference template reference the message is validated against, carrying the declared type, phase and + * language + * @return semantic validation outcome carrying the verdict, the implied negotiation type, the semantic errors and + * the extracted parameters + * @throws NegotiationValidationException if the LLM invocation fails or the response misses a required key or has + * the wrong shape + * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the semantic validation prompt resources + * of the reference language are missing + */ + SemanticValidationResult validate(String prompt, Map callerSchema, NegotiationReference reference); +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java new file mode 100644 index 00000000..26fb90d1 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java @@ -0,0 +1,33 @@ +package net.openan.a2at.sdk.negotiation.validation; + +/** + * Internal failure of the negotiation semantic validation step. + * + *

This exception is internal to the validation pipeline: it signals an LLM infrastructure failure or a response that + * violates the output contract (missing required keys or wrong shapes). It must never bubble out of the public APIs; + * the orchestration layer converts it into a parameter-extraction failure carrying the retryable LLM infrastructure + * error code. + * + * @since 2026-06 + */ +public class NegotiationValidationException extends RuntimeException { + + /** + * Creates an internal semantic validation failure. + * + * @param message failure message + */ + public NegotiationValidationException(String message) { + super(message); + } + + /** + * Creates an internal semantic validation failure with a cause. + * + * @param message failure message + * @param cause underlying failure + */ + public NegotiationValidationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java new file mode 100644 index 00000000..9d0a0848 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java @@ -0,0 +1,168 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.negotiation.content.FilledParamData; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Orchestrates the validation of a negotiation message and the extraction of its parameters. + * + *

The extractor runs the rule-level compliance gate first, so a text that is not a negotiation message or whose + * negotiation context violates a rule fails before any LLM call is made. The LLM-backed semantic validation is + * intentionally split from the deterministic completion so that the orchestration layer can own the retry loop: + * + *

    + *
  1. call {@link #runSemanticValidation(String, Map, NegotiationReference)} inside the retry loop; it re-runs the + * rule gate, performs exactly one semantic validation call and reports failures as typed parameter-extraction + * failures whose codes decide retryability ({@code negotiation_llm_infrastructure_error} is retryable, + * {@code template_not_found} is not), + *
  2. pass the obtained {@link SemanticValidationResult} to {@link #extract(String, NegotiationReference, + * SemanticValidationResult)}, which re-runs the rule gate, rejects a negative semantic verdict and merges the + * parameters deterministically. + *
+ * + *

Parameter merging writes the context parameters (id as a string, round and maxRounds as integers) first and the + * semantically extracted parameters second; on a key conflict the context parameter wins and a warning is logged, so + * the LLM output can never override the rule-level parsed values. + * + * @since 2026-06 + */ +public final class ParamExtractor { + + private static final Logger LOGGER = LoggerFactory.getLogger(ParamExtractor.class); + + private static final String NON_NEGOTIATION_MESSAGE = + "missing negotiation context section; for Task-T compliance use checkTaskPrompt"; + + private final NegotiationComplianceChecker complianceChecker; + + private final NegotiationSemanticValidator semanticValidator; + + private final Vocabulary vocabulary; + + /** + * Creates a parameter extractor. + * + * @param complianceChecker rule-level compliance checker used as the entry gate + * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters + * @param vocabulary vocabulary of the message language, passed to the compliance checker + * @throws NullPointerException if any collaborator is null + */ + public ParamExtractor( + NegotiationComplianceChecker complianceChecker, + NegotiationSemanticValidator semanticValidator, + Vocabulary vocabulary) { + this.complianceChecker = Objects.requireNonNull(complianceChecker, "complianceChecker"); + this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); + this.vocabulary = Objects.requireNonNull(vocabulary, "vocabulary"); + } + + /** + * Runs the retryable LLM step of parameter extraction: the rule gate followed by one semantic validation call. + * + *

Orchestration layers own the retry loop and should wrap this method: a failure with the code + * {@code negotiation_llm_infrastructure_error} is retryable, any other failure is not. The internal semantic + * validation exception never bubbles out of this method. + * + * @param prompt rendered negotiation message text + * @param callerSchema caller-provided parameter JSON schema embedded into the structured-call output contract + * @param reference template reference the message is validated against + * @return semantic validation outcome carrying the verdict, the implied type, the semantic errors and the extracted + * parameters + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the text is not + * a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule + * (before any LLM call), {@code negotiation_llm_infrastructure_error} when the LLM invocation fails or the + * response violates the output contract, or {@code template_not_found} when the semantic validation prompt + * resources are missing + */ + public SemanticValidationResult runSemanticValidation( + String prompt, Map callerSchema, NegotiationReference reference) { + requireValidRuleContext(prompt); + try { + return semanticValidator.validate(prompt, callerSchema, reference); + } catch (NegotiationValidationException exception) { + throw new NegotiationParamExtractionException( + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, + "Semantic validation LLM step failed: " + exception.getMessage(), + List.of(new SlotValidationError( + "_llm", A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getMessage()))); + } catch (ResourceNotFoundException exception) { + throw new NegotiationParamExtractionException( + A2ATErrorCodes.TEMPLATE_NOT_FOUND, exception.getMessage(), List.of()); + } + } + + /** + * Completes parameter extraction deterministically from an already obtained semantic validation outcome. + * + *

This method performs no LLM call. It re-runs the rule gate to obtain the negotiation context, rejects a + * negative semantic verdict with the semantic errors passed through unchanged, and merges the context parameters + * with the semantically extracted parameters. + * + * @param prompt rendered negotiation message text + * @param reference template reference the message is validated against + * @param semanticResult semantic validation outcome obtained from {@link #runSemanticValidation(String, Map, + * NegotiationReference)} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the semantic result is null + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the text is not + * a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, or + * {@code negotiation_semantic_rejected} when the semantic verdict is negative, with the semantic errors passed + * through + */ + public FilledParamData extract( + String prompt, NegotiationReference reference, SemanticValidationResult semanticResult) { + Objects.requireNonNull(semanticResult, "semanticResult"); + NegotiationContext context = requireValidRuleContext(prompt); + if (!semanticResult.verdict()) { + throw new NegotiationParamExtractionException( + A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, + "Semantic validation rejected the negotiation message.", + semanticResult.errors()); + } + return new FilledParamData(mergeParams(context, semanticResult.params())); + } + + private NegotiationContext requireValidRuleContext(String prompt) { + NegotiationRuleCheckResult ruleResult = complianceChecker.check(prompt, vocabulary); + if (!ruleResult.isNegotiation()) { + throw new NegotiationParamExtractionException( + A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, NON_NEGOTIATION_MESSAGE, List.of()); + } + if (!ruleResult.passed()) { + throw new NegotiationParamExtractionException( + A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, + "Negotiation context rule validation failed.", + ruleResult.errors()); + } + return ruleResult.context(); + } + + private static Map mergeParams(NegotiationContext context, Map semanticParams) { + Map merged = new LinkedHashMap<>(); + merged.put("id", context.id()); + merged.put("round", context.round()); + merged.put("maxRounds", context.maxRounds()); + for (Map.Entry entry : semanticParams.entrySet()) { + String key = entry.getKey(); + if (merged.containsKey(key)) { + LOGGER.atWarn().log("negotiation_param_merge_conflict key={} resolution=context_param_wins", key); + continue; + } + merged.put(key, entry.getValue()); + } + return Collections.unmodifiableMap(merged); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticSchemaBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticSchemaBuilder.java new file mode 100644 index 00000000..9801f3a0 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticSchemaBuilder.java @@ -0,0 +1,30 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.Map; + +/** + * Builds the merged JSON schema used for the semantic validation structured LLM call. + * + *

The merged schema is the semantic validation output contract: an object with the four required keys + * {@code semantic_verdict} (boolean), {@code negotiation_type} (string or null, enum information, target, feasibility, + * null), {@code errors} (array of objects with the required keys slot_name, code, message) and {@code params} (the + * caller-provided schema embedded verbatim, wrapped as an object when it lacks a type), with no additional properties + * allowed. + * + *

This interface is the validation-package seam for the schema builder of the generation package: the orchestrator + * builder wires the generation-package implementation to it, for example via the method reference + * {@code negotiationJsonSchemaBuilder::buildSemanticValidationSchema}. + * + * @since 2026-06 + */ +@FunctionalInterface +public interface SemanticSchemaBuilder { + + /** + * Merges the caller-provided parameter schema into the semantic validation output contract. + * + * @param callerSchema caller-provided parameter JSON schema + * @return merged JSON schema for the semantic validation structured LLM call + */ + Map buildSemanticValidationSchema(Map callerSchema); +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java new file mode 100644 index 00000000..0e67037b --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java @@ -0,0 +1,35 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.SlotValidationError; + +/** + * Outcome of the LLM-backed semantic validation of a negotiation message. + * + *

The semantic validation step combines the semantic verdict, the negotiation type implied by the message sections, + * the structured semantic errors and the parameters extracted from the message per the caller-provided schema. + * + * @param verdict overall semantic verdict; {@code true} only when every semantic constraint holds + * @param negotiationType negotiation type implied by the message sections, one of {@code information}, {@code target} + * or {@code feasibility}; may be null when the verdict is false + * @param errors structured semantic errors using language-neutral {@code section.*} slot names; empty when the verdict + * is true + * @param params parameters extracted from the message per the caller-provided schema + * @since 2026-06 + */ +public record SemanticValidationResult( + boolean verdict, String negotiationType, List errors, Map params) { + + /** + * Normalizes the errors and the extracted parameters. + * + * @throws NullPointerException if the errors list or the params map is null + */ + public SemanticValidationResult { + errors = List.copyOf(errors); + params = Collections.unmodifiableMap(new LinkedHashMap<>(params)); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/package-info.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/package-info.java new file mode 100644 index 00000000..a1cac51c --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/package-info.java @@ -0,0 +1,2 @@ +/** Rule and semantic validation of rendered negotiation messages plus parameter extraction. */ +package net.openan.a2at.sdk.negotiation.validation; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java new file mode 100644 index 00000000..680d8f36 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java @@ -0,0 +1,251 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class ComplianceCheckerTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private final DefaultNegotiationComplianceChecker checker = new DefaultNegotiationComplianceChecker(); + + private final Vocabulary zhVocabulary = Vocabulary.forLanguage("zh-CN"); + + private final Vocabulary enVocabulary = Vocabulary.forLanguage("en-US"); + + static List violationCases() { + return List.of( + new Object[] {"non-uuid id", "not-a-uuid", null, null, List.of("id")}, + new Object[] {"blank id", "", null, null, List.of("id")}, + new Object[] {"round zero", null, "0", null, List.of("round")}, + new Object[] {"negative round", null, "-1", null, List.of("round")}, + new Object[] {"zero maxRounds", null, null, "0", List.of("maxRounds")}, + new Object[] {"round above maxRounds", null, "3", "2", List.of("round")}, + new Object[] {"non-integer round", null, "two", null, List.of("round")}, + new Object[] {"non-integer maxRounds", null, null, "many", List.of("maxRounds")}, + new Object[] {"missing id line", "__MISSING__", null, null, List.of("id")}, + new Object[] {"missing round line", null, "__MISSING__", null, List.of("round")}, + new Object[] {"missing maxRounds line", null, null, "__MISSING__", List.of("maxRounds")}); + } + + @Test + void validZhMessagePassesAndContextIsExtracted() { + String prompt = "前置说明,应被丢弃。\n\n" + + "## 协商上下文\n" + + "- id: " + SESSION_ID + "\n" + + "- round: 2\n" + + "- maxRounds: 5\n\n" + + "## 所需信息项\n" + + "1. 节能区域信息:请提供真实存在的区域\n"; + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertTrue(result.passed()); + assertTrue(result.isNegotiation()); + assertEquals(List.of(), result.errors()); + assertEquals(new NegotiationContext(SESSION_ID, 2, 5), result.context()); + } + + @Test + void validEnMessagePassesAndContextIsExtracted() { + String prompt = "Preamble that must be dropped.\n\n" + + "## Negotiation Context\n" + + "- id: " + SESSION_ID + "\n" + + "- round: 1\n" + + "- maxRounds: 5\n\n" + + "## Required Information Items\n" + + "1. energy saving region: provide a real region\n"; + + NegotiationRuleCheckResult result = checker.check(prompt, enVocabulary); + + assertTrue(result.passed()); + assertTrue(result.isNegotiation()); + assertEquals(List.of(), result.errors()); + assertEquals(new NegotiationContext(SESSION_ID, 1, 5), result.context()); + } + + @ParameterizedTest + @MethodSource("violationCases") + void contextViolationsFailWithTheExpectedSlotNames( + String caseName, String idValue, String roundValue, String maxRoundsValue, List expectedSlots) { + String prompt = zhMessage(idValue, roundValue, maxRoundsValue); + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertFalse(result.passed(), caseName); + assertTrue(result.isNegotiation(), caseName); + assertNull(result.context(), caseName); + assertEquals(expectedSlots.size(), result.errors().size(), caseName); + for (int index = 0; index < expectedSlots.size(); index++) { + assertEquals(expectedSlots.get(index), result.errors().get(index).slotName(), caseName); + } + } + + @Test + void uuidFormatIsEnforcedBeyondLength() { + String prompt = zhMessage(SESSION_ID.replace('b', 'z'), null, null); + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertFalse(result.passed()); + assertEquals("id", result.errors().get(0).slotName()); + assertEquals("invalid_uuid", result.errors().get(0).code()); + } + + @Test + void uppercaseHexUuidIsAccepted() { + String prompt = zhMessage(SESSION_ID.toUpperCase(java.util.Locale.ROOT), null, null); + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertTrue(result.passed()); + assertEquals(0, result.errors().size()); + } + + @Test + void textWithoutContextSectionIsNotANegotiationMessage() { + String prompt = "## 任务目标\n" + "诊断网络故障\n\n" + "## 任务类型\n" + "故障诊断\n"; + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertFalse(result.isNegotiation()); + assertFalse(result.passed()); + assertEquals(List.of(), result.errors()); + assertNull(result.context()); + } + + @Test + void emptyTextIsNotANegotiationMessage() { + NegotiationRuleCheckResult result = checker.check("", zhVocabulary); + + assertFalse(result.isNegotiation()); + assertEquals(List.of(), result.errors()); + assertNull(result.context()); + } + + @Test + void nullTextIsNotANegotiationMessage() { + NegotiationRuleCheckResult result = checker.check(null, zhVocabulary); + + assertFalse(result.isNegotiation()); + assertEquals(List.of(), result.errors()); + } + + @Test + void preambleOnlyTextIsNotANegotiationMessage() { + NegotiationRuleCheckResult result = checker.check("只是普通文本,没有任何板块标题。", zhVocabulary); + + assertFalse(result.isNegotiation()); + assertEquals(List.of(), result.errors()); + } + + @Test + void englishCheckerDoesNotRecogniseZhContextSection() { + String prompt = zhMessage(null, null, null); + + NegotiationRuleCheckResult result = checker.check(prompt, enVocabulary); + + assertFalse(result.isNegotiation()); + assertEquals(List.of(), result.errors()); + } + + @Test + void checkerDoesNotValidateConclusionValues() { + String prompt = "## 协商上下文\n" + + "- id: " + SESSION_ID + "\n" + + "- round: 1\n" + + "- maxRounds: 5\n\n" + + "## 信息协商结果\n" + + "Abort\n"; + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertTrue(result.passed()); + assertEquals(List.of(), result.errors()); + } + + @Test + void checkerDoesNotRequireEndingResultContentSection() { + String prompt = "## 协商上下文\n" + + "- id: " + SESSION_ID + "\n" + + "- round: 1\n" + + "- maxRounds: 5\n\n" + + "## 信息协商结果\n" + + "Accept\n"; + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertTrue(result.passed()); + assertEquals(List.of(), result.errors()); + } + + @Test + void checkerDoesNotEnforceFeasibilitySectionExclusivity() { + String prompt = "## 协商上下文\n" + + "- id: " + SESSION_ID + "\n" + + "- round: 1\n" + + "- maxRounds: 5\n\n" + + "## 待评估内容说明\n" + + "1. 待评估内容\n\n" + + "## 评估不可行时的详情和提案\n" + + "1. 不可行详情\n"; + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertTrue(result.passed()); + assertEquals(List.of(), result.errors()); + } + + @Test + void resultRecordHasExactlyTheFourPinnedComponents() { + String[] componentNames = java.util.Arrays.stream(NegotiationRuleCheckResult.class.getRecordComponents()) + .map(component -> component.getName()) + .toArray(String[]::new); + + assertEquals(List.of("passed", "isNegotiation", "errors", "context"), List.of(componentNames)); + } + + @Test + void ruleErrorsCarryStructuredDetails() { + String prompt = zhMessage("short", null, null); + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + SlotValidationError error = result.errors().get(0); + assertEquals("id", error.slotName()); + assertEquals("invalid_uuid", error.code()); + assertTrue(error.message().contains("UUID")); + } + + private static String zhMessage(String idValue, String roundValue, String maxRoundsValue) { + StringBuilder prompt = new StringBuilder(); + prompt.append("## 协商上下文\n"); + if (!"__MISSING__".equals(idValue)) { + prompt.append("- id:") + .append(idValue == null ? " " + SESSION_ID : " " + idValue) + .append('\n'); + } + if (!"__MISSING__".equals(roundValue)) { + prompt.append("- round:") + .append(roundValue == null ? " 1" : " " + roundValue) + .append('\n'); + } + if (!"__MISSING__".equals(maxRoundsValue)) { + prompt.append("- maxRounds:") + .append(maxRoundsValue == null ? " 5" : " " + maxRoundsValue) + .append('\n'); + } + prompt.append("\n## 所需信息项\n1. 信息项\n"); + return prompt.toString(); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java new file mode 100644 index 00000000..cb674cbb --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java @@ -0,0 +1,248 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +class DefaultNegotiationSemanticValidatorTest { + + private static final String VALID_PROMPT = "## Negotiation Context\n" + + "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n" + + "- round: 1\n" + + "- maxRounds: 5\n\n" + + "## Required Information Items\n" + + "1. energy saving region: provide a real region\n"; + + private final RecordingSchemaBuilder schemaBuilder = new RecordingSchemaBuilder(); + + private final RecordingLLMClient llmClient = new RecordingLLMClient(); + + private final DefaultNegotiationSemanticValidator validator = + new DefaultNegotiationSemanticValidator(llmClient, schemaBuilder); + + private final NegotiationReference informationReference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "en-US"); + + private final Map callerSchema = + Map.of("type", "object", "properties", Map.of("confirmed_rate_mbps", Map.of("type", "integer"))); + + @Test + void validResponseYieldsVerdictTypeErrorsAndParams() { + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + + "\"errors\":[],\"params\":{\"confirmed_rate_mbps\":2}}"; + + SemanticValidationResult result = validator.validate(VALID_PROMPT, callerSchema, informationReference); + + assertTrue(result.verdict()); + assertEquals("information", result.negotiationType()); + assertEquals(List.of(), result.errors()); + assertEquals(Map.of("confirmed_rate_mbps", 2), result.params()); + assertEquals(1, llmClient.invocations); + assertEquals(callerSchema, schemaBuilder.lastCallerSchema); + } + + @Test + void singleStructuredCallReceivesMergedSchemaAndFilledUserPrompt() { + llmClient.payload = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":{}}"; + + validator.validate(VALID_PROMPT, callerSchema, informationReference); + + List> messages = llmClient.lastMessages; + assertEquals(2, messages.size()); + assertEquals("system", messages.get(0).get("role")); + assertTrue(messages.get(0).get("content").contains("semantic validation")); + assertEquals("user", messages.get(1).get("role")); + String userPrompt = messages.get(1).get("content"); + assertTrue(userPrompt.contains("information")); + assertTrue(userPrompt.contains("Negotiation-T/v1/information-negotiation/propose")); + assertTrue(userPrompt.contains("confirmed_rate_mbps")); + assertTrue(userPrompt.contains("energy saving region")); + assertEquals(Map.of("merged", true), llmClient.lastSchema); + assertNull(llmClient.lastTemperature); + assertNull(llmClient.lastMaxTokens); + } + + @Test + void missingNegotiationTypeKeyIsAShapeViolation() { + llmClient.payload = "{\"semantic_verdict\":true,\"errors\":[],\"params\":{}}"; + + NegotiationValidationException exception = assertThrows( + NegotiationValidationException.class, + () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); + + assertTrue(exception.getMessage().contains("negotiation_type")); + } + + @Test + void missingOtherRequiredKeysAreShapeViolations() { + llmClient.payload = "{\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); + + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); + + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); + } + + @Test + void wrongShapesAreShapeViolations() { + llmClient.payload = + "{\"semantic_verdict\":\"yes\",\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); + + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + + "\"errors\":\"none\",\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); + + llmClient.payload = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":[]}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); + + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + + "\"errors\":[{\"slot_name\":\"section.context\"}],\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); + + llmClient.payload = "not json at all"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); + } + + @Test + void nullTypeWithTrueVerdictIsTurnedIntoSemanticRejection() { + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"; + + SemanticValidationResult result = validator.validate(VALID_PROMPT, callerSchema, informationReference); + + assertFalse(result.verdict()); + assertNull(result.negotiationType()); + assertEquals(1, result.errors().size()); + assertEquals("section.info_static", result.errors().get(0).slotName()); + assertEquals("template_type_mismatch", result.errors().get(0).code()); + } + + @Test + void typeMismatchWithTrueVerdictIsTurnedIntoSemanticRejection() { + llmClient.payload = + "{\"semantic_verdict\":true,\"negotiation_type\":\"target\"," + "\"errors\":[],\"params\":{}}"; + + SemanticValidationResult result = validator.validate(VALID_PROMPT, callerSchema, informationReference); + + assertFalse(result.verdict()); + assertEquals("target", result.negotiationType()); + assertEquals(1, result.errors().size()); + assertEquals("section.target", result.errors().get(0).slotName()); + assertEquals("template_type_mismatch", result.errors().get(0).code()); + assertTrue(result.errors().get(0).message().contains("information")); + } + + @Test + void negativeVerdictPassesThroughWithTypeNullAndErrors() { + llmClient.payload = "{\"semantic_verdict\":false,\"negotiation_type\":null," + + "\"errors\":[{\"slot_name\":\"section.target_result_content\"," + + "\"code\":\"conclusion_content_mismatch\",\"message\":\"Mismatch\"}],\"params\":{}}"; + + SemanticValidationResult result = validator.validate(VALID_PROMPT, callerSchema, informationReference); + + assertFalse(result.verdict()); + assertNull(result.negotiationType()); + assertEquals(1, result.errors().size()); + assertEquals("section.target_result_content", result.errors().get(0).slotName()); + } + + @Test + void llmProviderFailureIsWrappedAsInternalValidationException() { + llmClient.failure = new LLMRuntimeError("invocation failed"); + + NegotiationValidationException exception = assertThrows( + NegotiationValidationException.class, + () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); + + assertTrue(exception.getMessage().contains("invocation failed")); + } + + @Test + void missingPromptResourceForLanguageSurfacesResourceNotFound() { + NegotiationReference unsupportedLanguageReference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "fr-FR"); + + assertThrows( + ResourceNotFoundException.class, + () -> validator.validate(VALID_PROMPT, callerSchema, unsupportedLanguageReference)); + assertEquals(0, llmClient.invocations); + } + + private static final class RecordingLLMClient implements LLMClient { + + private String payload = "{}"; + + private RuntimeException failure; + + private int invocations; + + private List> lastMessages; + + private Map lastSchema; + + private Double lastTemperature; + + private Integer lastMaxTokens; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + invocations++; + lastMessages = messages; + lastSchema = jsonSchema; + lastTemperature = temperature; + lastMaxTokens = maxTokens; + if (failure != null) { + throw failure; + } + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class RecordingSchemaBuilder implements SemanticSchemaBuilder { + + private Map lastCallerSchema; + + @Override + public Map buildSemanticValidationSchema(Map callerSchema) { + lastCallerSchema = callerSchema; + return Map.of("merged", true); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java new file mode 100644 index 00000000..d54f51a2 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java @@ -0,0 +1,223 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.negotiation.content.FilledParamData; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +class ParamExtractorTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String VALID_ZH_PROMPT = "## 协商上下文\n" + + "- id: " + SESSION_ID + "\n" + + "- round: 2\n" + + "- maxRounds: 5\n\n" + + "## 所需信息项\n" + + "1. 节能区域信息:请提供真实存在的区域\n"; + + private static final NegotiationReference REFERENCE = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN"); + + private final StubComplianceChecker complianceChecker = new StubComplianceChecker(); + + private final StubSemanticValidator semanticValidator = new StubSemanticValidator(); + + private final ParamExtractor extractor = + new ParamExtractor(complianceChecker, semanticValidator, Vocabulary.forLanguage("zh-CN")); + + @Test + void happyPathMergesContextParamsFirstAndLetsContextWinOnConflict() { + complianceChecker.result = + new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + semanticValidator.result = new SemanticValidationResult( + true, + "information", + List.of(), + Map.of("id", "llm-value", "confirmed_rate_mbps", 2, "nested", Map.of("a", 1))); + + FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, REFERENCE, semanticValidator.result); + + assertEquals(SESSION_ID, filled.data().get("id")); + assertEquals(2, filled.data().get("round")); + assertEquals(5, filled.data().get("maxRounds")); + assertEquals(2, filled.data().get("confirmed_rate_mbps")); + assertEquals(Map.of("a", 1), filled.data().get("nested")); + assertEquals(5, filled.data().size()); + assertEquals(0, semanticValidator.invocations); + } + + @Test + void extractRequiresIntegerRoundAndMaxRoundsInMergedData() { + complianceChecker.result = + new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 3, 7)); + semanticValidator.result = new SemanticValidationResult(true, "information", List.of(), Map.of()); + + FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, REFERENCE, semanticValidator.result); + + assertTrue(filled.data().get("round") instanceof Integer); + assertTrue(filled.data().get("maxRounds") instanceof Integer); + assertEquals(3, filled.data().get("round")); + assertEquals(7, filled.data().get("maxRounds")); + } + + @Test + void ruleFailureSkipsTheSemanticValidationCall() { + complianceChecker.result = new NegotiationRuleCheckResult( + false, + true, + List.of(new SlotValidationError("round", "out_of_range", "round exceeds maxRounds")), + null); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.runSemanticValidation(VALID_ZH_PROMPT, Map.of(), REFERENCE)); + + assertEquals("negotiation_rule_violation", exception.getCode()); + assertEquals(1, exception.getErrors().size()); + assertEquals("round", exception.getErrors().get(0).slotName()); + assertEquals(0, semanticValidator.invocations); + } + + @Test + void extractFailsOnRuleViolationWithoutTouchingTheValidator() { + complianceChecker.result = new NegotiationRuleCheckResult( + false, true, List.of(new SlotValidationError("id", "invalid_uuid", "not a uuid")), null); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract( + VALID_ZH_PROMPT, + REFERENCE, + new SemanticValidationResult(true, "information", List.of(), Map.of()))); + + assertEquals("negotiation_rule_violation", exception.getCode()); + assertEquals("id", exception.getErrors().get(0).slotName()); + assertEquals(0, semanticValidator.invocations); + } + + @Test + void nonNegotiationInputFailsWithLanguageNeutralMessage() { + complianceChecker.result = new NegotiationRuleCheckResult(false, false, List.of(), null); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.runSemanticValidation("## 任务目标\n诊断\n", Map.of(), REFERENCE)); + + assertEquals("negotiation_invalid_input", exception.getCode()); + assertEquals( + "missing negotiation context section; for Task-T compliance use checkTaskPrompt", + exception.getMessage()); + assertFalse(exception.getMessage().contains("协商上下文")); + assertEquals(List.of(), exception.getErrors()); + assertEquals(0, semanticValidator.invocations); + } + + @Test + void semanticRejectionPassesErrorsThrough() { + complianceChecker.result = + new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + List semanticErrors = List.of( + new SlotValidationError("section.target_result_content", "conclusion_content_mismatch", "Mismatch"), + new SlotValidationError("section.context", "invalid_conclusion", "Abort is reserved")); + semanticValidator.result = new SemanticValidationResult(false, "target", semanticErrors, Map.of()); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract(VALID_ZH_PROMPT, REFERENCE, semanticValidator.result)); + + assertEquals("negotiation_semantic_rejected", exception.getCode()); + assertEquals(semanticErrors, exception.getErrors()); + } + + @Test + void runSemanticValidationReturnsTheValidatorOutcome() { + complianceChecker.result = + new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + semanticValidator.result = + new SemanticValidationResult(true, "information", List.of(), Map.of("confirmed_rate_mbps", 2)); + + SemanticValidationResult result = extractor.runSemanticValidation(VALID_ZH_PROMPT, Map.of(), REFERENCE); + + assertTrue(result.verdict()); + assertEquals(1, semanticValidator.invocations); + assertEquals(1, complianceChecker.invocations); + assertEquals(Map.of("confirmed_rate_mbps", 2), result.params()); + } + + @Test + void internalValidationFailureIsMappedToRetryableInfrastructureError() { + complianceChecker.result = + new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + semanticValidator.failure = new NegotiationValidationException("response is missing negotiation_type"); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.runSemanticValidation(VALID_ZH_PROMPT, Map.of(), REFERENCE)); + + assertEquals("negotiation_llm_infrastructure_error", exception.getCode()); + assertEquals(1, exception.getErrors().size()); + assertEquals("_llm", exception.getErrors().get(0).slotName()); + assertTrue(exception.getMessage().contains("negotiation_type")); + } + + @Test + void promptResourceMissIsMappedToTemplateNotFound() { + complianceChecker.result = + new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + semanticValidator.failure = + new ResourceNotFoundException("prompt resource missing", "prompt_resources/prompts/x"); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.runSemanticValidation(VALID_ZH_PROMPT, Map.of(), REFERENCE)); + + assertEquals("template_not_found", exception.getCode()); + assertEquals(List.of(), exception.getErrors()); + } + + private static final class StubComplianceChecker implements NegotiationComplianceChecker { + + private NegotiationRuleCheckResult result = new NegotiationRuleCheckResult(false, false, List.of(), null); + + private int invocations; + + @Override + public NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary) { + invocations++; + return result; + } + } + + private static final class StubSemanticValidator implements NegotiationSemanticValidator { + + private SemanticValidationResult result = new SemanticValidationResult(false, null, List.of(), Map.of()); + + private RuntimeException failure; + + private int invocations; + + @Override + public SemanticValidationResult validate( + String prompt, Map callerSchema, NegotiationReference reference) { + invocations++; + if (failure != null) { + throw failure; + } + return result; + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamSchemasTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamSchemasTest.java new file mode 100644 index 00000000..3e8d4f36 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamSchemasTest.java @@ -0,0 +1,49 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ParamSchemasTest { + + @Test + void builtSchemaMatchesThePinnedStructureExactly() { + Map expectedProperties = new LinkedHashMap<>(); + expectedProperties.put("id", Map.of("type", "string")); + expectedProperties.put("round", Map.of("type", "integer")); + expectedProperties.put("maxRounds", Map.of("type", "integer")); + + Map expectedSchema = new LinkedHashMap<>(); + expectedSchema.put("type", "object"); + expectedSchema.put("properties", expectedProperties); + expectedSchema.put("additionalProperties", true); + + assertEquals(expectedSchema, NegotiationParamSchemas.buildNegotiationParamSchema()); + } + + @Test + void builtSchemaSerializesToThePinnedBytes() throws Exception { + String serialized = + new ObjectMapper().writeValueAsString(NegotiationParamSchemas.buildNegotiationParamSchema()); + + assertEquals( + "{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"}," + + "\"round\":{\"type\":\"integer\"},\"maxRounds\":{\"type\":\"integer\"}}," + + "\"additionalProperties\":true}", + serialized); + } + + @Test + void builtSchemaKeepsThePinnedKeyOrder() { + Map schema = NegotiationParamSchemas.buildNegotiationParamSchema(); + + assertEquals(List.of("type", "properties", "additionalProperties"), new ArrayList<>(schema.keySet())); + assertEquals( + List.of("id", "round", "maxRounds"), new ArrayList<>(((Map) schema.get("properties")).keySet())); + } +} From 7c5e22377bb2c38d9bd4316468f2674e9398d58d Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Wed, 19 Aug 2026 17:43:02 +0800 Subject: [PATCH 032/295] feat(a2a-t-negotiation): add negotiation message generation pipeline Signed-off-by: codetalks-hw --- .../content/NegotiationContent.java | 12 + .../content/NegotiationEndingContent.java | 2 +- .../content/NegotiationProposeContent.java | 2 +- .../AbstractNegotiationGenerator.java | 135 +++++ .../DefaultNegotiationContentExtractor.java | 346 ++++++++++++ .../FeasibilityEndingGenerator.java | 48 ++ .../FeasibilityProposeGenerator.java | 73 +++ .../InformationEndingGenerator.java | 42 ++ .../InformationProposeGenerator.java | 49 ++ .../NegotiationContentExtractor.java | 29 + .../NegotiationContextRenderer.java | 27 + .../generation/NegotiationGenerator.java | 32 ++ .../NegotiationGeneratorRegistry.java | 126 +++++ .../generation/NegotiationItemFormatter.java | 41 ++ .../NegotiationJsonSchemaBuilder.java | 201 +++++++ .../generation/NegotiationMessageBuilder.java | 80 +++ .../generation/NegotiationPromptRenderer.java | 115 ++++ .../NegotiationPromptResourceLoader.java | 96 ++++ .../NegotiationRenderException.java | 31 + .../generation/TargetEndingGenerator.java | 55 ++ .../generation/TargetProposeGenerator.java | 57 ++ .../negotiation/generation/package-info.java | 2 + .../NegotiationContentExtractorTest.java | 311 +++++++++++ .../NegotiationContextRendererTest.java | 37 ++ .../NegotiationGeneratorRegistryTest.java | 193 +++++++ .../generation/NegotiationGeneratorsTest.java | 528 ++++++++++++++++++ .../NegotiationItemFormatterTest.java | 45 ++ .../NegotiationJsonSchemaBuilderTest.java | 187 +++++++ .../NegotiationPromptRendererTest.java | 216 +++++++ .../NegotiationPromptResourceLoaderTest.java | 119 ++++ 30 files changed, 3235 insertions(+), 2 deletions(-) create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/package-info.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatterTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java new file mode 100644 index 00000000..9ec16636 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java @@ -0,0 +1,12 @@ +package net.openan.a2at.sdk.negotiation.content; + +/** + * Marker for the typed content of any negotiation message, regardless of phase. + * + *

The two concrete families are {@link NegotiationProposeContent} for propose-phase messages and + * {@link NegotiationEndingContent} for terminal messages. Generators and content extractors accept this common + * supertype and dispatch on the exact runtime type. + * + * @since 2026-06 + */ +public sealed interface NegotiationContent permits NegotiationProposeContent, NegotiationEndingContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java index 4b0a7a58..47485046 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java @@ -5,7 +5,7 @@ * * @since 2026-06 */ -public sealed interface NegotiationEndingContent +public sealed interface NegotiationEndingContent extends NegotiationContent permits InfoEndingContent, TargetEndingContent, FeasibilityEndingContent { /** diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java index 79f09f03..5b8cd34d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java @@ -5,5 +5,5 @@ * * @since 2026-06 */ -public sealed interface NegotiationProposeContent +public sealed interface NegotiationProposeContent extends NegotiationContent permits InfoProposeContent, TargetProposeContent, FeasibilityProposeContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java new file mode 100644 index 00000000..a9a5da1d --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java @@ -0,0 +1,135 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; + +/** + * Shared plumbing of the six negotiation generators. + * + *

Subclasses validate their exact content type, assemble the slot map and hand over to the shared renderers. The + * slot map is keyed by the language-specific slot names taken from the vocabulary. + */ +abstract class AbstractNegotiationGenerator implements NegotiationGenerator { + + private final NegotiationPromptRenderer promptRenderer = new NegotiationPromptRenderer(); + + private final NegotiationContextRenderer contextRenderer = new NegotiationContextRenderer(); + + private final NegotiationItemFormatter itemFormatter = new NegotiationItemFormatter(); + + /** + * Casts the content to the exact type this generator serves. + * + * @param expected content type + * @param content content object validated by the registry + * @param expectedType exact content class this generator serves + * @param generatorName human-readable generator name used in the failure message + * @return content cast to the expected type + * @throws NegotiationContentException if the content is null or of another runtime type + */ + protected static T contentOf( + NegotiationContent content, Class expectedType, String generatorName) { + if (content == null || content.getClass() != expectedType) { + throw new NegotiationContentException( + generatorName + " requires content of type " + expectedType.getSimpleName() + " but received " + + (content == null ? "null" : content.getClass().getSimpleName()) + ".", + "content"); + } + return expectedType.cast(content); + } + + /** + * Validates that a conclusion is renderable, rejecting null and the abort conclusion. + * + * @param conclusion conclusion carried by ending content + * @return the validated conclusion + * @throws NegotiationContentException if the conclusion is null or abort + */ + protected static NegotiationConclusion renderableConclusion(NegotiationConclusion conclusion) { + if (conclusion == null) { + throw new NegotiationContentException( + "Negotiation conclusion must not be null; accept and reject are the renderable conclusions.", + "content.conclusion"); + } + if (conclusion == NegotiationConclusion.ABORT) { + throw new NegotiationContentException( + "Negotiation conclusion Abort has no template section to render; terminate an exhausted" + + " negotiation with a reject message instead.", + "content.conclusion"); + } + return conclusion; + } + + /** + * Validates that a required text field is present. + * + * @param value field value + * @param field field path used in the failure + * @param description field description used in the failure message + * @return the validated text + * @throws NegotiationContentException if the value is null or blank + */ + protected static String requiredText(String value, String field, String description) { + if (value == null || value.isBlank()) { + throw new NegotiationContentException(description + " must not be blank.", field); + } + return value; + } + + /** + * Validates that a required item list is present and non-empty. + * + * @param items item list + * @param field field path used in the failure + * @param description field description used in the failure message + * @return the validated item list + * @throws NegotiationContentException if the list is null or empty + */ + protected static List requiredItems( + List items, String field, String description) { + if (items == null || items.isEmpty()) { + throw new NegotiationContentException(description + " must contain at least one item.", field); + } + return items; + } + + /** + * Builds the slot value for the negotiation context section. + * + * @param context negotiation context of the message + * @param vocabulary vocabulary of the message language + * @return rendered context list + */ + protected String contextSlotValue(NegotiationContext context, Vocabulary vocabulary) { + return contextRenderer.render(context); + } + + /** + * Formats an item list with the list punctuation of the message language. + * + * @param items items to format; null formats as an empty value + * @param vocabulary vocabulary of the message language + * @return formatted numbered lines, or an empty string when there is no item + */ + protected String formatItems(List items, Vocabulary vocabulary) { + return itemFormatter.format(items, vocabulary.get("punct.list_colon")); + } + + /** + * Renders the template with the assembled slots. + * + * @param template loaded template to render + * @param slots slot values keyed by language-specific slot names + * @return rendered negotiation message text + */ + protected String render(PromptTemplate template, Map slots) { + return promptRenderer.render(template.content(), slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java new file mode 100644 index 00000000..4a746065 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java @@ -0,0 +1,346 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.json.JacksonJsonValueParser; +import net.openan.a2at.sdk.core.json.JsonValueParser; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; +import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; + +/** + * Default content extractor backed by one structured LLM call. + * + *

The extractor loads the system and user prompts of the addressed negotiation type from the classpath, asks the LLM + * for the snake_case JSON described by the extraction schema, and maps the response onto the typed content records. + * Failure mapping: transport failures map to {@code negotiation_llm_infrastructure_error}, responses that cannot be + * parsed as the expected content map to {@code negotiation_content_extract_failed}, missing required fields map to the + * non-retryable {@code negotiation_slot_missing}, and content that contradicts the addressed phase or action maps to + * {@code negotiation_invalid_input}. + * + * @since 2026-06 + */ +public final class DefaultNegotiationContentExtractor implements NegotiationContentExtractor { + + private static final String CATEGORY_INFORMATION = "information_negotiation"; + + private static final String CATEGORY_TARGET = "target_negotiation"; + + private static final String CATEGORY_FEASIBILITY = "feasibility_negotiation"; + + private final LLMClient llmClient; + + private final NegotiationMessageBuilder messageBuilder; + + private final NegotiationJsonSchemaBuilder schemaBuilder; + + private final JsonValueParser parser; + + /** + * Creates an extractor backed by one LLM client and the default message and schema builders. + * + * @param llmClient LLM client used for the structured content extraction call + */ + public DefaultNegotiationContentExtractor(LLMClient llmClient) { + this( + llmClient, + new NegotiationMessageBuilder(), + new NegotiationJsonSchemaBuilder(), + new JacksonJsonValueParser()); + } + + /** + * Creates an extractor with injectable collaborators. + * + * @param llmClient LLM client used for the structured content extraction call + * @param messageBuilder builder assembling the LLM messages of the extraction step + * @param schemaBuilder builder providing the extraction JSON Schema + * @param parser parser turning the LLM response content into a key-value map + */ + public DefaultNegotiationContentExtractor( + LLMClient llmClient, + NegotiationMessageBuilder messageBuilder, + NegotiationJsonSchemaBuilder schemaBuilder, + JsonValueParser parser) { + this.llmClient = llmClient; + this.messageBuilder = messageBuilder; + this.schemaBuilder = schemaBuilder; + this.parser = parser; + } + + /** + * Extracts the typed content of one negotiation message from free text. + * + * @param text free-text input describing the message content + * @param reference reference identifying the negotiation type, phase and language to extract for + * @return typed negotiation content matching the reference + * @throws NegotiationContentException if the reference is null or the text is blank + * @throws NegotiationGenerationException with one of the codes {@code negotiation_llm_infrastructure_error}, + * {@code negotiation_content_extract_failed}, {@code negotiation_slot_missing} or + * {@code negotiation_invalid_input} + */ + @Override + public NegotiationContent extract(String text, NegotiationReference reference) { + if (reference == null) { + throw new NegotiationContentException("Negotiation reference must not be null.", "reference"); + } + if (text == null || text.isBlank()) { + throw new NegotiationGenerationException( + A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, + "Input text for negotiation content extraction must not" + " be blank."); + } + Map tokens = Map.of( + NegotiationMessageBuilder.TOKEN_PHASE, + phaseToken(reference.phase()), + NegotiationMessageBuilder.TOKEN_INPUT, + text); + List> messages = + messageBuilder.buildMessages(promptCategory(reference.type()), reference.language(), tokens); + Map schema = schemaBuilder.buildExtractionSchema(reference.type(), reference.phase()); + Map payload = invokeLlm(messages, schema); + return mapContent(payload, reference.type(), reference.phase()); + } + + private Map invokeLlm(List> messages, Map schema) { + if (llmClient == null) { + throw new NegotiationGenerationException( + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, + "Negotiation content extraction requires an LLM client but none is configured."); + } + LLMResponse response; + try { + response = llmClient.structured(messages, schema, null, null); + } catch (RuntimeException error) { + throw new NegotiationGenerationException( + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, + "Negotiation content extraction LLM call failed.", + error); + } + String content = response == null ? null : response.content(); + if (content == null || content.isBlank()) { + throw new NegotiationGenerationException( + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, + "Negotiation content extraction returned an empty response."); + } + try { + return parser.parseObject(content); + } catch (RuntimeException error) { + throw new NegotiationGenerationException( + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, + "Negotiation content extraction response is not a JSON object.", + error); + } + } + + private static NegotiationContent mapContent( + Map payload, NegotiationType type, NegotiationPhase phase) { + if (phase == NegotiationPhase.PROPOSE) { + return mapProposeContent(payload, type); + } + return mapEndingContent(payload, type, phase); + } + + private static NegotiationContent mapProposeContent(Map payload, NegotiationType type) { + return switch (type) { + case INFORMATION -> new InfoProposeContent( + requiredItems(payload, "items"), optionalString(payload, "relationship")); + case TARGET -> new TargetProposeContent( + requiredString(payload, "target_negotiation_description"), + optionalItems(payload, "intent_understanding"), + optionalItems(payload, "alignment_and_clarification"), + optionalItems(payload, "request_for_clarification")); + case FEASIBILITY -> mapFeasibilityProposeContent(payload); + }; + } + + private static NegotiationContent mapFeasibilityProposeContent(Map payload) { + String description = requiredString(payload, "feasibility_negotiation_description"); + NegotiationAction action = feasibilityAction(payload); + List contentsToEvaluate = optionalItems(payload, "contents_to_evaluate"); + List infeasibilityDetails = optionalItems(payload, "infeasibility_details_and_proposal"); + if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + if (contentsToEvaluate == null || contentsToEvaluate.isEmpty()) { + throw invalidInput( + "Feasibility evaluation request extracted no contents to evaluate; the driven section would" + + " be empty."); + } + } else if (infeasibilityDetails == null || infeasibilityDetails.isEmpty()) { + throw invalidInput( + "Alternative proposal on failure extracted no infeasibility details; the driven section would be" + + " empty."); + } + return new FeasibilityProposeContent(description, action, contentsToEvaluate, infeasibilityDetails); + } + + private static NegotiationContent mapEndingContent( + Map payload, NegotiationType type, NegotiationPhase phase) { + NegotiationConclusion conclusion = requiredConclusion(payload); + requireConclusionMatchesPhase(conclusion, phase); + return switch (type) { + case INFORMATION -> new InfoEndingContent(conclusion, requiredItems(payload, "items")); + case TARGET -> mapTargetEndingContent(payload, conclusion); + case FEASIBILITY -> new FeasibilityEndingContent( + conclusion, requiredString(payload, "feasibility_summary")); + }; + } + + private static NegotiationContent mapTargetEndingContent( + Map payload, NegotiationConclusion conclusion) { + String confirmedIntent = optionalString(payload, "confirmed_intent"); + String failureReason = optionalString(payload, "failure_reason"); + if (conclusion == NegotiationConclusion.ACCEPT && (confirmedIntent == null || confirmedIntent.isBlank())) { + throw slotMissing("confirmed_intent of an accepting target negotiation message"); + } + if (conclusion == NegotiationConclusion.REJECT && (failureReason == null || failureReason.isBlank())) { + throw slotMissing("failure_reason of a rejecting target negotiation message"); + } + return new TargetEndingContent(conclusion, confirmedIntent, failureReason); + } + + private static void requireConclusionMatchesPhase(NegotiationConclusion conclusion, NegotiationPhase phase) { + NegotiationConclusion expected = + phase == NegotiationPhase.ACCEPT ? NegotiationConclusion.ACCEPT : NegotiationConclusion.REJECT; + if (conclusion != expected) { + throw invalidInput("Extracted conclusion " + conclusion.literal() + " does not match the " + phase + + " phase; the" + " expected conclusion is " + expected.literal() + "."); + } + } + + private static NegotiationAction feasibilityAction(Map payload) { + Object value = payload.get("action"); + if (value == null) { + throw invalidInput( + "Feasibility negotiation content extraction produced no action; the action drives the conditional" + + " sections of the message."); + } + if (value instanceof String action) { + for (NegotiationAction candidate : NegotiationAction.values()) { + if (candidate.name().equals(action)) { + return candidate; + } + } + } + throw extractFailed("Feasibility negotiation action must be one of the two action names but was: " + value); + } + + private static NegotiationConclusion requiredConclusion(Map payload) { + Object value = payload.get("conclusion"); + if (value == null) { + throw slotMissing("conclusion of a terminal negotiation message"); + } + if (value instanceof String conclusion) { + for (NegotiationConclusion candidate : NegotiationConclusion.values()) { + if (candidate.literal().equals(conclusion)) { + return candidate; + } + } + } + throw extractFailed("Negotiation conclusion must be Accept or Reject but was: " + value); + } + + private static String requiredString(Map payload, String field) { + Object value = payload.get(field); + if (value == null || (value instanceof String text && text.isBlank())) { + throw slotMissing(field); + } + if (value instanceof String text) { + return text; + } + throw extractFailed("Field " + field + " must be a string but was: " + value); + } + + private static String optionalString(Map payload, String field) { + Object value = payload.get(field); + if (value == null) { + return null; + } + if (value instanceof String text) { + return text; + } + throw extractFailed("Field " + field + " must be a string or null but was: " + value); + } + + private static List requiredItems(Map payload, String field) { + Object value = payload.get(field); + if (value == null) { + throw slotMissing(field); + } + return itemsOf(value, field); + } + + private static List optionalItems(Map payload, String field) { + Object value = payload.get(field); + if (value == null) { + return null; + } + return itemsOf(value, field); + } + + private static List itemsOf(Object value, String field) { + if (!(value instanceof List entries)) { + throw extractFailed("Field " + field + " must be an array of items but was: " + value); + } + List items = new ArrayList<>(); + for (Object entry : entries) { + if (!(entry instanceof Map entryMap)) { + throw extractFailed("Field " + field + " must contain item objects but contained: " + entry); + } + Object name = entryMap.get("name"); + if (!(name instanceof String itemName) || itemName.isBlank()) { + throw extractFailed("Field " + field + " contained an item without a name: " + entry); + } + Object itemValue = entryMap.get("value"); + if (itemValue != null && !(itemValue instanceof String)) { + throw extractFailed("Field " + field + " contained an item whose value is not a string: " + entry); + } + items.add(new NegotiationItem(itemName, (String) itemValue)); + } + return List.copyOf(items); + } + + private static String phaseToken(NegotiationPhase phase) { + return switch (phase) { + case PROPOSE -> "propose"; + case ACCEPT -> "accept"; + case REJECT -> "reject"; + }; + } + + private static String promptCategory(NegotiationType type) { + return switch (type) { + case INFORMATION -> CATEGORY_INFORMATION; + case TARGET -> CATEGORY_TARGET; + case FEASIBILITY -> CATEGORY_FEASIBILITY; + }; + } + + private static NegotiationGenerationException slotMissing(String field) { + return new NegotiationGenerationException( + A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, + "Negotiation content extraction response is missing the required field: " + field); + } + + private static NegotiationGenerationException extractFailed(String message) { + return new NegotiationGenerationException(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, message); + } + + private static NegotiationGenerationException invalidInput(String message) { + return new NegotiationGenerationException(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, message); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java new file mode 100644 index 00000000..7f8d3809 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java @@ -0,0 +1,48 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; + +/** + * Generator for feasibility negotiation terminal messages. + * + *

The message carries the terminal conclusion literal and the required feasibility summary. The summary slot name + * differs from its section title, so the vocabulary exception key is used. + * + * @since 2026-06 + */ +public final class FeasibilityEndingGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a feasibility negotiation accept or reject message. + * + * @param context negotiation context of the message + * @param content feasibility ending content + * @param template feasibility ending template to render + * @param vocabulary vocabulary of the message language + * @return rendered feasibility terminal message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + FeasibilityEndingContent endingContent = + contentOf(content, FeasibilityEndingContent.class, "Feasibility ending generator"); + renderableConclusion(endingContent.conclusion()); + requiredText( + endingContent.feasibilitySummary(), + "content.feasibilitySummary", + "Feasibility summary of a terminal feasibility negotiation message"); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("section.context"), contextSlotValue(context, vocabulary)); + slots.put( + vocabulary.get("section.feasibility_conclusion"), + endingContent.conclusion().literal()); + slots.put(vocabulary.get("slot.feasibility_confirm"), endingContent.feasibilitySummary()); + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java new file mode 100644 index 00000000..ee137f77 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java @@ -0,0 +1,73 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; + +/** + * Generator for feasibility negotiation propose messages. + * + *

The action selects which conditional section is rendered: requesting a feasibility evaluation renders the contents + * to evaluate, while proposing an alternative after an infeasible outcome renders the infeasibility details and + * proposal. Exactly one of the two sections is always present. + * + * @since 2026-06 + */ +public final class FeasibilityProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a feasibility negotiation propose message. + * + * @param context negotiation context of the message + * @param content feasibility propose content + * @param template feasibility propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered feasibility propose message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + FeasibilityProposeContent proposeContent = + contentOf(content, FeasibilityProposeContent.class, "Feasibility propose generator"); + requiredText( + proposeContent.feasibilityNegotiationDescription(), + "content.feasibilityNegotiationDescription", + "Feasibility negotiation description"); + NegotiationAction action = proposeContent.action(); + if (action == null) { + throw new NegotiationContentException( + "Feasibility negotiation action must not be null; it selects the conditional sections of the" + + " message.", + "content.action"); + } + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("section.context"), contextSlotValue(context, vocabulary)); + slots.put(vocabulary.get("slot.feasibility"), proposeContent.feasibilityNegotiationDescription()); + if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + slots.put( + vocabulary.get("section.feasibility_evaluate"), + formatItems( + requiredItems( + proposeContent.contentsToEvaluate(), + "content.contentsToEvaluate", + "Contents to evaluate of a feasibility evaluation request"), + vocabulary)); + } else { + slots.put( + vocabulary.get("section.feasibility_infeasible"), + formatItems( + requiredItems( + proposeContent.infeasibilityDetailsAndProposal(), + "content.infeasibilityDetailsAndProposal", + "Infeasibility details and proposal of an alternative proposal"), + vocabulary)); + } + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java new file mode 100644 index 00000000..f16d8ef2 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java @@ -0,0 +1,42 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; + +/** + * Generator for information negotiation terminal messages. + * + *

The message carries the terminal conclusion literal and the information items delivered with it. + * + * @since 2026-06 + */ +public final class InformationEndingGenerator extends AbstractNegotiationGenerator { + + /** + * Generates an information negotiation accept or reject message. + * + * @param context negotiation context of the message + * @param content information ending content + * @param template information ending template to render + * @param vocabulary vocabulary of the message language + * @return rendered information terminal message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + InfoEndingContent endingContent = contentOf(content, InfoEndingContent.class, "Information ending generator"); + renderableConclusion(endingContent.conclusion()); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("section.context"), contextSlotValue(context, vocabulary)); + slots.put( + vocabulary.get("section.info_conclusion"), + endingContent.conclusion().literal()); + slots.put(vocabulary.get("section.info_result_content"), formatItems(endingContent.items(), vocabulary)); + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java new file mode 100644 index 00000000..ea22ce8a --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java @@ -0,0 +1,49 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; + +/** + * Generator for information negotiation propose messages. + * + *

The message carries the requested information items and, when present, a free-form line describing how the missing + * items relate to each other, appended after the item list. + * + * @since 2026-06 + */ +public final class InformationProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates an information negotiation propose message. + * + * @param context negotiation context of the message + * @param content information propose content + * @param template information propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered information propose message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + InfoProposeContent proposeContent = + contentOf(content, InfoProposeContent.class, "Information propose generator"); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("section.context"), contextSlotValue(context, vocabulary)); + slots.put(vocabulary.get("section.info_items"), itemsSlotValue(proposeContent, vocabulary)); + return render(template, slots); + } + + private String itemsSlotValue(InfoProposeContent content, Vocabulary vocabulary) { + String items = formatItems(content.items(), vocabulary); + if (content.relationship() == null || content.relationship().isBlank()) { + return items; + } + String relationshipLine = vocabulary.get("label.relationship") + content.relationship(); + return items.isEmpty() ? relationshipLine : items + "\n" + relationshipLine; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java new file mode 100644 index 00000000..d9c7d7ce --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java @@ -0,0 +1,29 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; + +/** + * Extracts typed negotiation content from a free-text input. + * + *

Implementations decide how the text is understood; the returned content must already satisfy the generator input + * rules so the deterministic rendering step can consume it directly. + * + * @since 2026-06 + */ +public interface NegotiationContentExtractor { + + /** + * Extracts the typed content of one negotiation message from free text. + * + * @param text free-text input describing the message content + * @param reference reference identifying the negotiation type, phase and language to extract for + * @return typed negotiation content matching the reference + * @throws NegotiationGenerationException with code {@code negotiation_llm_infrastructure_error} when the LLM + * invocation fails, {@code negotiation_content_extract_failed} when the response cannot be parsed as the + * expected content, {@code negotiation_slot_missing} when a required field is missing, or + * {@code negotiation_invalid_input} when the extracted content contradicts the addressed phase or action + */ + NegotiationContent extract(String text, NegotiationReference reference); +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java new file mode 100644 index 00000000..c8714bb3 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java @@ -0,0 +1,27 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; + +/** + * Assembles the negotiation context into the single slot value used by context sections. + * + *

The context travels in one template slot as a markdown list whose field names are language-neutral. + */ +public final class NegotiationContextRenderer { + + /** + * Renders one negotiation context as a markdown list. + * + * @param context negotiation context to render + * @return three lines of the form {@code - id: ...}, {@code - round: ...}, {@code - maxRounds: ...} joined by + * single newlines + * @throws NegotiationContentException if the context is null + */ + public String render(NegotiationContext context) { + if (context == null) { + throw new NegotiationContentException("Negotiation context must not be null.", "context"); + } + return "- id: " + context.id() + "\n- round: " + context.round() + "\n- maxRounds: " + context.maxRounds(); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java new file mode 100644 index 00000000..4d793121 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java @@ -0,0 +1,32 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; + +/** + * Renders one negotiation message from typed content and a loaded template. + * + *

Implementations assemble the language-specific slot map from the vocabulary, validate their content input and + * delegate the final rendering step. The registry is the only supported dispatch path; it guarantees the exact runtime + * type of the content before a generator is invoked. + * + * @since 2026-06 + */ +public interface NegotiationGenerator { + + /** + * Generates the negotiation message text for one content object. + * + * @param context negotiation context of the message + * @param content typed content of the message; must be the exact content type this generator serves + * @param template loaded template to render + * @param vocabulary vocabulary supplying the slot names of the message language + * @return rendered negotiation message text + * @throws NegotiationContentException if the content does not match this generator or violates an input rule + */ + String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary); +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java new file mode 100644 index 00000000..9d8acd22 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java @@ -0,0 +1,126 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.EnumMap; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; +import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; + +/** + * Dispatch table from negotiation type and phase to the generator serving them. + * + *

Dispatch requires an exact runtime type match between the content and the addressed (type, phase) pair: propose + * phases only accept propose content, terminal phases only accept ending content whose conclusion matches the phase, + * and the content type must match the negotiation type. Subtype matching is deliberately not supported; new content + * types must be registered explicitly. + * + * @since 2026-06 + */ +public final class NegotiationGeneratorRegistry { + + private final Map proposeGenerators = new EnumMap<>(NegotiationType.class); + + private final Map endingGenerators = new EnumMap<>(NegotiationType.class); + + /** Creates a registry holding the six built-in negotiation generators. */ + public NegotiationGeneratorRegistry() { + proposeGenerators.put(NegotiationType.INFORMATION, new InformationProposeGenerator()); + proposeGenerators.put(NegotiationType.TARGET, new TargetProposeGenerator()); + proposeGenerators.put(NegotiationType.FEASIBILITY, new FeasibilityProposeGenerator()); + endingGenerators.put(NegotiationType.INFORMATION, new InformationEndingGenerator()); + endingGenerators.put(NegotiationType.TARGET, new TargetEndingGenerator()); + endingGenerators.put(NegotiationType.FEASIBILITY, new FeasibilityEndingGenerator()); + } + + /** + * Resolves the generator for one (type, phase, content) triple. + * + * @param type negotiation type addressed by the template URI + * @param phase API-level phase addressed by the calling method + * @param content typed content of the message + * @return generator registered for the exact (type, phase) pair + * @throws NegotiationContentException if any argument is null, the content family does not match the phase, the + * content runtime type does not match the negotiation type, or an ending content carries a conclusion that does + * not match the phase + */ + public NegotiationGenerator resolve(NegotiationType type, NegotiationPhase phase, NegotiationContent content) { + if (type == null) { + throw new NegotiationContentException("Negotiation type must not be null.", "type"); + } + if (phase == null) { + throw new NegotiationContentException("Negotiation phase must not be null.", "phase"); + } + if (content == null) { + throw new NegotiationContentException("Negotiation content must not be null.", "content"); + } + boolean proposePhase = phase == NegotiationPhase.PROPOSE; + boolean proposeContent = content instanceof NegotiationProposeContent; + if (proposePhase != proposeContent) { + throw new NegotiationContentException( + "The " + phase + " phase requires " + (proposePhase ? "propose" : "ending") + + " content but received " + + (proposeContent ? "propose" : "ending") + " content of type " + + content.getClass().getSimpleName() + ".", + "content"); + } + Class expectedType = expectedContentClass(type, proposePhase); + if (content.getClass() != expectedType) { + throw new NegotiationContentException( + "Negotiation type " + type + " requires content of type " + expectedType.getSimpleName() + + " but received " + content.getClass().getSimpleName() + ".", + "content"); + } + if (!proposePhase) { + requireConclusionMatchesPhase((NegotiationEndingContent) content, phase); + } + NegotiationGenerator generator = (proposePhase ? proposeGenerators : endingGenerators).get(type); + if (generator == null) { + throw new NegotiationContentException( + "No negotiation generator is registered for type " + type + " and phase " + phase + ".", "type"); + } + return generator; + } + + private static void requireConclusionMatchesPhase(NegotiationEndingContent content, NegotiationPhase phase) { + NegotiationConclusion conclusion = content.conclusion(); + if (conclusion == null) { + throw new NegotiationContentException( + "Negotiation conclusion must not be null; the " + phase + " phase requires a conclusion.", + "content" + ".conclusion"); + } + NegotiationConclusion expected = + phase == NegotiationPhase.ACCEPT ? NegotiationConclusion.ACCEPT : NegotiationConclusion.REJECT; + if (conclusion != expected) { + throw new NegotiationContentException( + "The " + phase + " phase requires conclusion " + expected.literal() + " but the content carries " + + conclusion.literal() + ".", + "content.conclusion"); + } + } + + private static Class expectedContentClass( + NegotiationType type, boolean proposePhase) { + if (proposePhase) { + return switch (type) { + case INFORMATION -> InfoProposeContent.class; + case TARGET -> TargetProposeContent.class; + case FEASIBILITY -> FeasibilityProposeContent.class; + }; + } + return switch (type) { + case INFORMATION -> InfoEndingContent.class; + case TARGET -> TargetEndingContent.class; + case FEASIBILITY -> FeasibilityEndingContent.class; + }; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java new file mode 100644 index 00000000..c6926d0c --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java @@ -0,0 +1,41 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; + +/** + * Formats negotiation item lists as numbered markdown lines. + * + *

Each item becomes one line {@code N. namevalue}; an item without a value becomes {@code N. name}. The + * caller supplies the list punctuation from the negotiation vocabulary so the output matches the message language. + * + * @since 2026-06 + */ +public final class NegotiationItemFormatter { + + /** + * Formats one item list as numbered lines. + * + * @param items items to format; null or empty produces an empty string + * @param listColon colon punctuation appended between item name and value, such as {@code :} or {@code : } + * @return numbered lines joined by single newlines, or an empty string when there is no item + */ + public String format(List items, String listColon) { + if (items == null || items.isEmpty()) { + return ""; + } + String colon = listColon == null ? "" : listColon; + StringBuilder text = new StringBuilder(); + for (int index = 0; index < items.size(); index++) { + NegotiationItem item = items.get(index); + if (index > 0) { + text.append('\n'); + } + text.append(index + 1).append(". ").append(item.name()); + if (item.value() != null && !item.value().isBlank()) { + text.append(colon).append(item.value()); + } + } + return text.toString(); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java new file mode 100644 index 00000000..385ff49a --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java @@ -0,0 +1,201 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; + +/** + * Builds the JSON Schemas of the negotiation LLM steps. + * + *

Extraction schemas are keyed by the snake_case property names of the extracted content, one schema per + * (negotiation type, phase) pair with accept and reject sharing the terminal schema. The semantic validation schema + * merges a caller-provided parameter schema into the fixed four-key output contract of the semantic validation step. + * + * @since 2026-06 + */ +public class NegotiationJsonSchemaBuilder { + + private static final List CONCLUSION_ENUM = List.of("Accept", "Reject"); + + private static final List ACTION_ENUM = + List.of("REQUEST_FEASIBILITY_EVALUATION", "PROPOSE_ALTERNATIVE_ON_FAILURE"); + + private static final List NEGOTIATION_TYPE_ENUM = + Arrays.asList("information", "target", "feasibility", null); + + /** + * Builds the content extraction schema of one (negotiation type, phase) pair. + * + * @param type negotiation type whose content is extracted + * @param phase API-level phase of the extraction; accept and reject share the terminal schema + * @return JSON Schema describing the snake_case extraction output of the pair + * @throws NegotiationContentException if the type or phase is null + */ + public Map buildExtractionSchema(NegotiationType type, NegotiationPhase phase) { + if (type == null) { + throw new NegotiationContentException("Negotiation type must not be null.", "type"); + } + if (phase == null) { + throw new NegotiationContentException("Negotiation phase must not be null.", "phase"); + } + return switch (type) { + case INFORMATION -> phase == NegotiationPhase.PROPOSE + ? informationProposeSchema() + : informationEndingSchema(); + case TARGET -> phase == NegotiationPhase.PROPOSE ? targetProposeSchema() : targetEndingSchema(); + case FEASIBILITY -> phase == NegotiationPhase.PROPOSE + ? feasibilityProposeSchema() + : feasibilityEndingSchema(); + }; + } + + /** + * Builds the merged schema of the semantic validation step around a caller-provided parameter schema. + * + *

The merged schema requires exactly the four keys {@code semantic_verdict}, {@code negotiation_type}, + * {@code errors} and {@code params} and allows no additional properties. The caller schema is embedded as the + * {@code params} property; a caller schema without a {@code type} keyword is wrapped as an object schema first. + * + * @param callerSchema parameter schema provided by the caller of the validation API + * @return merged JSON Schema of the semantic validation LLM call + * @throws NegotiationContentException if the caller schema is null + */ + public Map buildSemanticValidationSchema(Map callerSchema) { + if (callerSchema == null) { + throw new NegotiationContentException("Caller parameter schema must not be null.", "schema"); + } + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + Map properties = new LinkedHashMap<>(); + properties.put("semantic_verdict", Map.of("type", "boolean")); + Map negotiationType = new LinkedHashMap<>(); + negotiationType.put("type", List.of("string", "null")); + negotiationType.put("enum", NEGOTIATION_TYPE_ENUM); + properties.put("negotiation_type", negotiationType); + properties.put("errors", errorsSchema()); + properties.put("params", wrapCallerSchema(callerSchema)); + schema.put("properties", properties); + schema.put("required", List.of("semantic_verdict", "negotiation_type", "errors", "params")); + schema.put("additionalProperties", false); + return schema; + } + + private static Map informationProposeSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("items", itemArraySchema()); + properties.put("relationship", nullableStringSchema()); + return objectSchema(properties, List.of("items")); + } + + private static Map informationEndingSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("conclusion", conclusionSchema()); + properties.put("items", itemArraySchema()); + return objectSchema(properties, List.of("conclusion", "items")); + } + + private static Map targetProposeSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("target_negotiation_description", Map.of("type", "string")); + properties.put("intent_understanding", nullableItemArraySchema()); + properties.put("alignment_and_clarification", nullableItemArraySchema()); + properties.put("request_for_clarification", nullableItemArraySchema()); + return objectSchema(properties, List.of("target_negotiation_description")); + } + + private static Map targetEndingSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("conclusion", conclusionSchema()); + properties.put("confirmed_intent", nullableStringSchema()); + properties.put("failure_reason", nullableStringSchema()); + return objectSchema(properties, List.of("conclusion")); + } + + private static Map feasibilityProposeSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("feasibility_negotiation_description", Map.of("type", "string")); + Map action = new LinkedHashMap<>(); + action.put("type", "string"); + action.put("enum", ACTION_ENUM); + properties.put("action", action); + properties.put("contents_to_evaluate", nullableItemArraySchema()); + properties.put("infeasibility_details_and_proposal", nullableItemArraySchema()); + return objectSchema(properties, List.of("feasibility_negotiation_description", "action")); + } + + private static Map feasibilityEndingSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("conclusion", conclusionSchema()); + properties.put("feasibility_summary", Map.of("type", "string")); + return objectSchema(properties, List.of("conclusion", "feasibility_summary")); + } + + private static Map errorsSchema() { + Map errorProperties = new LinkedHashMap<>(); + errorProperties.put("slot_name", Map.of("type", "string")); + errorProperties.put("code", Map.of("type", "string")); + errorProperties.put("message", Map.of("type", "string")); + Map errorItem = new LinkedHashMap<>(); + errorItem.put("type", "object"); + errorItem.put("properties", errorProperties); + errorItem.put("required", List.of("slot_name", "code", "message")); + Map errors = new LinkedHashMap<>(); + errors.put("type", "array"); + errors.put("items", errorItem); + return errors; + } + + private static Map wrapCallerSchema(Map callerSchema) { + if (callerSchema.containsKey("type")) { + return callerSchema; + } + Map wrapped = new LinkedHashMap<>(); + wrapped.put("type", "object"); + wrapped.putAll(callerSchema); + return wrapped; + } + + private static Map itemArraySchema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", "array"); + schema.put("items", itemSchema()); + return schema; + } + + private static Map nullableItemArraySchema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", List.of("array", "null")); + schema.put("items", itemSchema()); + return schema; + } + + private static Map itemSchema() { + Map itemProperties = new LinkedHashMap<>(); + itemProperties.put("name", Map.of("type", "string")); + itemProperties.put("value", nullableStringSchema()); + return objectSchema(itemProperties, List.of("name", "value")); + } + + private static Map conclusionSchema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", "string"); + schema.put("enum", CONCLUSION_ENUM); + return schema; + } + + private static Map nullableStringSchema() { + return Map.of("type", List.of("string", "null")); + } + + private static Map objectSchema(Map properties, List required) { + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + schema.put("required", required); + return schema; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java new file mode 100644 index 00000000..5ee99708 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java @@ -0,0 +1,80 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; + +/** + * Assembles the LLM message list of one negotiation LLM step. + * + *

The system prompt of the addressed category is used verbatim. The user prompt is loaded from the same category and + * every literal bracket token it contains, such as {@code [phase]} or {@code [input]}, is replaced by the value + * supplied for that token name; a supplied null value is replaced with an empty string and bracket tokens without a + * supplied value are left unchanged. + * + * @since 2026-06 + */ +public class NegotiationMessageBuilder { + + /** Token name receiving the negotiation phase of the step, such as {@code propose} or {@code accept}. */ + public static final String TOKEN_PHASE = "phase"; + + /** Token name receiving the free-text input of the step. */ + public static final String TOKEN_INPUT = "input"; + + /** Token name receiving the template URI declared by the caller. */ + public static final String TOKEN_TEMPLATE_URI = "template_uri"; + + /** Token name receiving the negotiation type declared by the caller. */ + public static final String TOKEN_NEGOTIATION_TYPE = "negotiation_type"; + + /** Token name receiving the JSON Schema of the step. */ + public static final String TOKEN_SCHEMA = "schema"; + + private final NegotiationPromptResourceLoader resourceLoader; + + /** Creates a message builder using the default classpath prompt resource loader. */ + public NegotiationMessageBuilder() { + this(new NegotiationPromptResourceLoader()); + } + + /** + * Creates a message builder on one prompt resource loader. + * + * @param resourceLoader loader supplying the system and user prompts + */ + public NegotiationMessageBuilder(NegotiationPromptResourceLoader resourceLoader) { + if (resourceLoader == null) { + throw new NegotiationContentException( + "Negotiation message builder requires a prompt resource loader.", "resourceLoader"); + } + this.resourceLoader = resourceLoader; + } + + /** + * Builds the system and user messages of one LLM step. + * + * @param promptCategory prompt category directory such as {@code information_negotiation} + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @param tokens token values keyed by token name such as {@code phase} or {@code input}; null or blank values are + * replaced with an empty string + * @return ordered message list with one system message followed by one user message + */ + public List> buildMessages(String promptCategory, String language, Map tokens) { + String systemPrompt = resourceLoader.loadSystem(promptCategory, language); + String userPrompt = replaceTokens(resourceLoader.loadUser(promptCategory, language), tokens); + return List.of( + Map.of("role", "system", "content", systemPrompt), Map.of("role", "user", "content", userPrompt)); + } + + private static String replaceTokens(String userPrompt, Map tokens) { + String result = userPrompt; + if (tokens != null) { + for (Map.Entry token : tokens.entrySet()) { + String value = token.getValue() == null ? "" : token.getValue(); + result = result.replace("[" + token.getKey() + "]", value); + } + } + return result; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java new file mode 100644 index 00000000..54b3254b --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java @@ -0,0 +1,115 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Renders a negotiation template by filling slot sections and dropping empty ones. + * + *

A template is split into sections on {@code ## } title lines; any content before the first title, including a + * leading HTML description comment, is discarded. A section whose first non-empty body line is a slot placeholder line + * such as {@code {{所需信息项}}(必填)} or {@code {{Required Information Items}} (required)} is a slot section: it is rendered + * as the title followed by the slot value, or dropped entirely when the slot value is null or blank. Every other + * section is static and passes through with placeholder substitution applied. Rendered sections are joined with a + * single blank line and the result carries no trailing newline. + * + * @since 2026-06 + */ +public class NegotiationPromptRenderer { + + private static final String SECTION_TITLE_PREFIX = "## "; + + private static final Pattern SLOT_LINE_PATTERN = + Pattern.compile("^\\{\\{([^{}]+)\\}\\}\\s*((必填)|(选填)|\\(required\\)|\\(optional\\))$"); + + private static final Pattern SLOT_TOKEN_PATTERN = Pattern.compile("\\{\\{([^{}]+)\\}\\}"); + + /** + * Renders one template text with the given slot values. + * + * @param templateText full template text whose slot sections are filled or dropped + * @param slots slot values keyed by the language-specific slot name; a null or blank value drops the slot section + * @return rendered message text with sections joined by one blank line and no trailing newline; empty string when + * no section remains + * @throws NegotiationRenderException if the template text is null + */ + public String render(String templateText, Map slots) { + if (templateText == null) { + throw new NegotiationRenderException("Negotiation template text must not be null."); + } + Map safeSlots = slots == null ? Map.of() : slots; + List

sections = splitSections(templateText); + List renderedSections = new ArrayList<>(); + for (Section section : sections) { + String slotName = slotNameOf(section); + if (slotName != null) { + String value = safeSlots.get(slotName); + if (value == null || value.isBlank()) { + // An unfilled slot drops the whole section, title included. + continue; + } + renderedSections.add(SECTION_TITLE_PREFIX + section.title() + "\n" + value.strip()); + } else { + renderedSections.add(SECTION_TITLE_PREFIX + + section.title() + + "\n" + + substitute(section.body(), safeSlots).stripTrailing()); + } + } + return String.join("\n\n", renderedSections); + } + + private static List
splitSections(String templateText) { + List
sections = new ArrayList<>(); + String currentTitle = null; + List currentBody = new ArrayList<>(); + for (String line : templateText.split("\n", -1)) { + if (line.startsWith(SECTION_TITLE_PREFIX)) { + if (currentTitle != null) { + sections.add(new Section(currentTitle, String.join("\n", currentBody))); + } + currentTitle = line.substring(SECTION_TITLE_PREFIX.length()).strip(); + currentBody = new ArrayList<>(); + } else if (currentTitle != null) { + currentBody.add(line); + } + // Lines before the first title, such as the leading HTML comment, are discarded. + } + if (currentTitle != null) { + sections.add(new Section(currentTitle, String.join("\n", currentBody))); + } + return sections; + } + + private static String slotNameOf(Section section) { + for (String line : section.body().split("\n", -1)) { + if (line.isBlank()) { + continue; + } + Matcher matcher = SLOT_LINE_PATTERN.matcher(line.strip()); + if (matcher.matches()) { + return matcher.group(1); + } + return null; + } + return null; + } + + private static String substitute(String body, Map slots) { + Matcher matcher = SLOT_TOKEN_PATTERN.matcher(body); + StringBuilder result = new StringBuilder(); + while (matcher.find()) { + String value = slots.get(matcher.group(1)); + if (value != null && !value.isBlank()) { + matcher.appendReplacement(result, Matcher.quoteReplacement(value)); + } + } + matcher.appendTail(result); + return result.toString(); + } + + private record Section(String title, String body) {} +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java new file mode 100644 index 00000000..c7c7ed44 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java @@ -0,0 +1,96 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; + +/** + * Loads negotiation LLM prompt resources from the classpath bundle packaged with the SDK. + * + *

Prompt resources are classpath-only: the categories are {@code information_negotiation}, + * {@code target_negotiation}, {@code feasibility_negotiation} and {@code negotiation_semantic_validation}, each with a + * {@code system.md} and a {@code user.md} per language. + * + * @since 2026-06 + */ +public class NegotiationPromptResourceLoader { + + private static final String CLASSPATH_ROOT = "prompt_resources/prompts/"; + + private static final String SYSTEM_FILE_NAME = "system.md"; + + private static final String USER_FILE_NAME = "user.md"; + + /** + * Loads the system prompt of one prompt category. + * + * @param promptCategory prompt category directory such as {@code information_negotiation} + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @return full system prompt text + * @throws ResourceNotFoundException if the system prompt does not exist for the category and language + */ + public String loadSystem(String promptCategory, String language) { + return loadPrompt(SYSTEM_FILE_NAME, promptCategory, language); + } + + /** + * Loads the user prompt of one prompt category. + * + * @param promptCategory prompt category directory such as {@code information_negotiation} + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @return full user prompt text + * @throws ResourceNotFoundException if the user prompt does not exist for the category and language + */ + public String loadUser(String promptCategory, String language) { + return loadPrompt(USER_FILE_NAME, promptCategory, language); + } + + private static String loadPrompt(String fileName, String promptCategory, String language) { + if (!isSimpleSegment(promptCategory)) { + throw new NegotiationContentException( + "Prompt category must be a non-blank simple path segment but was " + promptCategory + ".", + "promptCategory"); + } + if (!isSimpleSegment(language)) { + throw new NegotiationContentException( + "Prompt language must be a non-blank simple path segment but was " + language + ".", "language"); + } + String path = CLASSPATH_ROOT + promptCategory + "/" + language + "/" + fileName; + return readResource(path); + } + + private static String readResource(String path) { + InputStream stream = openClasspathResource(path); + if (stream == null) { + throw new ResourceNotFoundException( + "Negotiation prompt resource does not exist on the classpath: " + path, path); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new SdkException("Failed to read negotiation prompt resource: " + path, exception); + } + } + + private static InputStream openClasspathResource(String path) { + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + if (contextClassLoader != null) { + InputStream stream = contextClassLoader.getResourceAsStream(path); + if (stream != null) { + return stream; + } + } + return NegotiationPromptResourceLoader.class.getClassLoader().getResourceAsStream(path); + } + + private static boolean isSimpleSegment(String value) { + return value != null + && !value.isBlank() + && !value.contains("/") + && !value.contains("\\") + && !value.contains(".."); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java new file mode 100644 index 00000000..717a4649 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java @@ -0,0 +1,31 @@ +package net.openan.a2at.sdk.negotiation.generation; + +/** + * Internal failure raised when rendering a negotiation template cannot proceed. + * + *

This exception never bubbles out of the public API; the orchestration layer maps it to a typed negotiation failure + * instead. + * + * @since 2026-06 + */ +public class NegotiationRenderException extends RuntimeException { + + /** + * Creates a render failure. + * + * @param message failure message + */ + public NegotiationRenderException(String message) { + super(message); + } + + /** + * Creates a render failure with a root cause. + * + * @param message failure message + * @param cause root cause + */ + public NegotiationRenderException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java new file mode 100644 index 00000000..2612819d --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java @@ -0,0 +1,55 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; + +/** + * Generator for target negotiation terminal messages. + * + *

The result content slot carries the confirmed intent for an accept conclusion or the failure reason for a reject + * conclusion; the other field is ignored. + * + * @since 2026-06 + */ +public final class TargetEndingGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a target negotiation accept or reject message. + * + * @param context negotiation context of the message + * @param content target ending content + * @param template target ending template to render + * @param vocabulary vocabulary of the message language + * @return rendered target terminal message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + TargetEndingContent endingContent = contentOf(content, TargetEndingContent.class, "Target ending generator"); + NegotiationConclusion conclusion = renderableConclusion(endingContent.conclusion()); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("section.context"), contextSlotValue(context, vocabulary)); + slots.put(vocabulary.get("section.target_conclusion"), conclusion.literal()); + slots.put(vocabulary.get("section.target_result_content"), resultContentSlotValue(endingContent, conclusion)); + return render(template, slots); + } + + private static String resultContentSlotValue(TargetEndingContent content, NegotiationConclusion conclusion) { + if (conclusion == NegotiationConclusion.ACCEPT) { + return requiredText( + content.confirmedIntent(), + "content.confirmedIntent", + "Confirmed intent of an accepting target negotiation message"); + } + return requiredText( + content.failureReason(), + "content.failureReason", + "Failure reason of a rejecting target negotiation" + " message"); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java new file mode 100644 index 00000000..abd02325 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java @@ -0,0 +1,57 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; + +/** + * Generator for target negotiation propose messages. + * + *

Besides the required target negotiation summary, the message renders round-driven conditional sections: the intent + * understanding section appears only on the first round, the alignment and clarification section only on later rounds, + * and the clarification request section only when clarification items are present. + * + * @since 2026-06 + */ +public final class TargetProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a target negotiation propose message. + * + * @param context negotiation context of the message + * @param content target propose content + * @param template target propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered target propose message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + TargetProposeContent proposeContent = + contentOf(content, TargetProposeContent.class, "Target propose generator"); + requiredText( + proposeContent.targetNegotiationDescription(), + "content.targetNegotiationDescription", + "Target negotiation description"); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("section.context"), contextSlotValue(context, vocabulary)); + slots.put(vocabulary.get("slot.target"), proposeContent.targetNegotiationDescription()); + if (context.round() == 1) { + slots.put( + vocabulary.get("section.target_intent"), + formatItems(proposeContent.intentUnderstanding(), vocabulary)); + } else { + slots.put( + vocabulary.get("section.target_alignment"), + formatItems(proposeContent.alignmentAndClarification(), vocabulary)); + } + slots.put( + vocabulary.get("section.target_clarification"), + formatItems(proposeContent.requestForClarification(), vocabulary)); + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/package-info.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/package-info.java new file mode 100644 index 00000000..0582d74a --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/package-info.java @@ -0,0 +1,2 @@ +/** Deterministic negotiation message generation: rendering, generators, prompt assembly and content extraction. */ +package net.openan.a2at.sdk.negotiation.generation; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java new file mode 100644 index 00000000..530146ef --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java @@ -0,0 +1,311 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; +import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +class NegotiationContentExtractorTest { + + @Test + void extractsInformationProposeContent() { + RecordingClient llm = new RecordingClient( + "{\"items\":[{\"name\":\"故障发生时间\",\"value\":\"精确到分钟的时间点\"},{\"name\":\"受影响小区标识\",\"value\":null}]," + + "\"relationship\":\"故障发生时间与受影响小区标识需逐小区对应\"}"); + + NegotiationContent content = new DefaultNegotiationContentExtractor(llm) + .extract( + "请提供故障发生时间与受影响小区标识。", + reference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN")); + + InfoProposeContent proposeContent = assertInstanceOf(InfoProposeContent.class, content); + assertEquals( + List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点"), new NegotiationItem("受影响小区标识", null)), + proposeContent.items()); + assertEquals("故障发生时间与受影响小区标识需逐小区对应", proposeContent.relationship()); + + assertEquals(2, llm.lastMessages.size()); + assertEquals("system", llm.lastMessages.get(0).get("role")); + assertEquals("user", llm.lastMessages.get(1).get("role")); + assertTrue(llm.lastMessages.get(1).get("content").contains("协商阶段:propose")); + assertTrue(llm.lastMessages.get(1).get("content").contains("请提供故障发生时间与受影响小区标识。")); + assertEquals( + new NegotiationJsonSchemaBuilder() + .buildExtractionSchema(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE), + llm.lastSchema); + } + + @Test + void extractsTargetProposeContent() { + NegotiationContent content = new DefaultNegotiationContentExtractor( + new RecordingClient("{\"target_negotiation_description\":\"请求将节能目标由30%调整为20%。\"," + + "\"intent_understanding\":[{\"name\":\"发起方理解\",\"value\":\"对方希望降低节能力度\"}]," + + "\"alignment_and_clarification\":null," + + "\"request_for_clarification\":[{\"name\":\"速率保障下限\",\"value\":null}]}")) + .extract("请求调整节能目标。", reference(NegotiationType.TARGET, NegotiationPhase.PROPOSE, "en-US")); + + TargetProposeContent proposeContent = assertInstanceOf(TargetProposeContent.class, content); + assertEquals("请求将节能目标由30%调整为20%。", proposeContent.targetNegotiationDescription()); + assertEquals(List.of(new NegotiationItem("发起方理解", "对方希望降低节能力度")), proposeContent.intentUnderstanding()); + assertNull(proposeContent.alignmentAndClarification()); + assertEquals(List.of(new NegotiationItem("速率保障下限", null)), proposeContent.requestForClarification()); + } + + @Test + void extractsFeasibilityProposeContentForBothActions() { + NegotiationContent request = new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"请评估该节能目标能否达成。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + + "\"contents_to_evaluate\":[{\"name\":\"评估对象\",\"value\":\"停电8小时期间的速率保障\"}]," + + "\"infeasibility_details_and_proposal\":null}")) + .extract("请评估。", reference(NegotiationType.FEASIBILITY, NegotiationPhase.PROPOSE, "zh-CN")); + + FeasibilityProposeContent requestContent = assertInstanceOf(FeasibilityProposeContent.class, request); + assertEquals(NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, requestContent.action()); + assertEquals(List.of(new NegotiationItem("评估对象", "停电8小时期间的速率保障")), requestContent.contentsToEvaluate()); + + NegotiationContent alternative = new DefaultNegotiationContentExtractor( + new RecordingClient( + "{\"feasibility_negotiation_description\":\"目标不可行,提出下调方案。\",\"action\":\"PROPOSE_ALTERNATIVE_ON_FAILURE\"," + + "\"contents_to_evaluate\":null," + + "\"infeasibility_details_and_proposal\":[{\"name\":\"替代提案\",\"value\":\"下调至2Mbps\"}]}")) + .extract("不可行。", reference(NegotiationType.FEASIBILITY, NegotiationPhase.PROPOSE, "zh-CN")); + + FeasibilityProposeContent alternativeContent = assertInstanceOf(FeasibilityProposeContent.class, alternative); + assertEquals(NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, alternativeContent.action()); + assertEquals( + List.of(new NegotiationItem("替代提案", "下调至2Mbps")), alternativeContent.infeasibilityDetailsAndProposal()); + } + + @Test + void extractsEndingContentForEveryTypeAndBothPhases() { + NegotiationContent infoAccept = new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"故障发生时间\",\"value\":\"2026-08-19 10:30\"}]}")) + .extract("同意提供。", reference(NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, "zh-CN")); + + InfoEndingContent infoEnding = assertInstanceOf(InfoEndingContent.class, infoAccept); + assertEquals(NegotiationConclusion.ACCEPT, infoEnding.conclusion()); + assertEquals(List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30")), infoEnding.items()); + + NegotiationContent targetReject = new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"双方未达成一致。\"}")) + .extract("无法同意。", reference(NegotiationType.TARGET, NegotiationPhase.REJECT, "zh-CN")); + + TargetEndingContent targetEnding = assertInstanceOf(TargetEndingContent.class, targetReject); + assertEquals(NegotiationConclusion.REJECT, targetEnding.conclusion()); + assertEquals("双方未达成一致。", targetEnding.failureReason()); + + NegotiationContent feasibilityAccept = new DefaultNegotiationContentExtractor( + new RecordingClient("{\"conclusion\":\"Accept\",\"feasibility_summary\":\"同意下调至2Mbps。\"}")) + .extract("同意。", reference(NegotiationType.FEASIBILITY, NegotiationPhase.ACCEPT, "zh-CN")); + + assertEquals( + "同意下调至2Mbps。", + assertInstanceOf( + net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent.class, + feasibilityAccept) + .feasibilitySummary()); + + assertEquals("accept", phaseTokenInUserPrompt(NegotiationPhase.ACCEPT)); + assertEquals("reject", phaseTokenInUserPrompt(NegotiationPhase.REJECT)); + assertEquals("propose", phaseTokenInUserPrompt(NegotiationPhase.PROPOSE)); + } + + @Test + void mapsTransportFailuresToTheInfrastructureCode() { + LLMClient failing = (messages, jsonSchema, temperature, maxTokens) -> { + throw new IllegalStateException("connection reset"); + }; + + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(failing) + .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + } + + @Test + void mapsMissingLlmClientToTheInfrastructureCode() { + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(null) + .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + } + + @Test + void mapsUnparseableResponsesToTheExtractFailedCode() { + NegotiationGenerationException unparseable = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("这不是 JSON")) + .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, unparseable.getCode()); + + NegotiationGenerationException wrongShape = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"items\":\"不是数组\"}")) + .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, wrongShape.getCode()); + + NegotiationGenerationException emptyResponse = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient(" ")) + .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, emptyResponse.getCode()); + } + + @Test + void mapsMissingRequiredFieldsToTheSlotMissingCode() { + NegotiationGenerationException missingItems = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"relationship\":null}")) + .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, missingItems.getCode()); + assertTrue(missingItems.getMessage().contains("items")); + + NegotiationGenerationException missingDescription = assertThrows( + NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"intent_understanding\":null,\"alignment_and_clarification\":null," + + "\"request_for_clarification\":null}")) + .extract("文本", reference(NegotiationType.TARGET, NegotiationPhase.PROPOSE, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, missingDescription.getCode()); + assertTrue(missingDescription.getMessage().contains("target_negotiation_description")); + + NegotiationGenerationException missingConclusion = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"items\":[]}")) + .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, missingConclusion.getCode()); + assertTrue(missingConclusion.getMessage().contains("conclusion")); + + NegotiationGenerationException missingConfirmedIntent = assertThrows( + NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"conclusion\":\"Accept\",\"confirmed_intent\":null,\"failure_reason\":null}")) + .extract("文本", reference(NegotiationType.TARGET, NegotiationPhase.ACCEPT, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, missingConfirmedIntent.getCode()); + assertTrue(missingConfirmedIntent.getMessage().contains("confirmed_intent")); + } + + @Test + void mapsConclusionPhaseMismatchesToTheInvalidInputCode() { + NegotiationGenerationException rejectInAccept = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"conclusion\":\"Reject\",\"items\":[]}")) + .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, rejectInAccept.getCode()); + assertTrue(rejectInAccept.getMessage().contains("Reject")); + + NegotiationGenerationException acceptInReject = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"conclusion\":\"Accept\",\"feasibility_summary\":\"同意。\"}")) + .extract("文本", reference(NegotiationType.FEASIBILITY, NegotiationPhase.REJECT, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, acceptInReject.getCode()); + } + + @Test + void mapsFeasibilityActionProblemsToTheInvalidInputCode() { + NegotiationGenerationException missingAction = assertThrows( + NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"描述\",\"contents_to_evaluate\":[]}")) + .extract("文本", reference(NegotiationType.FEASIBILITY, NegotiationPhase.PROPOSE, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, missingAction.getCode()); + + NegotiationGenerationException emptyDrivenContent = assertThrows( + NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"描述\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + + "\"contents_to_evaluate\":[],\"infeasibility_details_and_proposal\":null}")) + .extract("文本", reference(NegotiationType.FEASIBILITY, NegotiationPhase.PROPOSE, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, emptyDrivenContent.getCode()); + } + + @Test + void rejectsBlankInputTextAndNullReference() { + DefaultNegotiationContentExtractor extractor = + new DefaultNegotiationContentExtractor(new RecordingClient("{}")); + + NegotiationGenerationException blank = assertThrows( + NegotiationGenerationException.class, + () -> extractor.extract( + " ", reference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN"))); + + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, blank.getCode()); + + assertEquals( + "reference", + assertThrows( + net.openan.a2at.sdk.negotiation.content.NegotiationContentException.class, + () -> extractor.extract("文本", null)) + .getField()); + } + + private static NegotiationReference reference(NegotiationType type, NegotiationPhase phase, String language) { + return new NegotiationReference(type, phase, language); + } + + private static String phaseTokenInUserPrompt(NegotiationPhase phase) { + RecordingClient client = new RecordingClient("{\"conclusion\":\"Accept\",\"items\":[]}"); + try { + new DefaultNegotiationContentExtractor(client) + .extract("文本", reference(NegotiationType.INFORMATION, phase, "zh-CN")); + } catch (NegotiationGenerationException expectedForReject) { + // The scripted conclusion only satisfies the accept phase; the messages are recorded either way. + } + String userPrompt = client.lastMessages.get(1).get("content"); + int start = userPrompt.indexOf("协商阶段:") + "协商阶段:".length(); + return userPrompt.substring(start, userPrompt.indexOf('\n', start)); + } + + private static final class RecordingClient implements LLMClient { + private final String payload; + + private List> lastMessages; + + private Map lastSchema; + + private RecordingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + this.lastMessages = messages; + this.lastSchema = jsonSchema; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java new file mode 100644 index 00000000..c8f8c079 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java @@ -0,0 +1,37 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import org.junit.jupiter.api.Test; + +class NegotiationContextRendererTest { + + private final NegotiationContextRenderer renderer = new NegotiationContextRenderer(); + + @Test + void rendersContextAsMarkdownListWithLanguageNeutralFieldNames() { + NegotiationContext context = new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5); + + String rendered = renderer.render(context); + + assertEquals("- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 2\n- maxRounds: 5", rendered); + } + + @Test + void rendersContextBuiltByFactoryAndNextRound() { + NegotiationContext context = NegotiationContext.of("id-1", 1).nextRound(); + + assertEquals("- id: id-1\n- round: 2\n- maxRounds: 5", renderer.render(context)); + } + + @Test + void rejectsNullContext() { + NegotiationContentException exception = + assertThrows(NegotiationContentException.class, () -> renderer.render(null)); + + assertEquals("context", exception.getField()); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java new file mode 100644 index 00000000..3bca84c5 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java @@ -0,0 +1,193 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; +import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.junit.jupiter.api.Test; + +class NegotiationGeneratorRegistryTest { + + private final NegotiationGeneratorRegistry registry = new NegotiationGeneratorRegistry(); + + @Test + void dispatchesEveryTypeAndPhaseCombination() { + assertInstanceOf( + InformationProposeGenerator.class, + registry.resolve( + NegotiationType.INFORMATION, + NegotiationPhase.PROPOSE, + new InfoProposeContent(List.of(), null))); + assertInstanceOf( + TargetProposeGenerator.class, + registry.resolve( + NegotiationType.TARGET, + NegotiationPhase.PROPOSE, + new TargetProposeContent("描述", null, null, null))); + assertInstanceOf( + FeasibilityProposeGenerator.class, + registry.resolve( + NegotiationType.FEASIBILITY, + NegotiationPhase.PROPOSE, + new FeasibilityProposeContent( + "描述", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("名称", "值")), + null))); + assertInstanceOf( + InformationEndingGenerator.class, + registry.resolve( + NegotiationType.INFORMATION, + NegotiationPhase.ACCEPT, + new InfoEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + assertInstanceOf( + InformationEndingGenerator.class, + registry.resolve( + NegotiationType.INFORMATION, + NegotiationPhase.REJECT, + new InfoEndingContent(NegotiationConclusion.REJECT, List.of()))); + assertInstanceOf( + TargetEndingGenerator.class, + registry.resolve( + NegotiationType.TARGET, + NegotiationPhase.ACCEPT, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); + assertInstanceOf( + TargetEndingGenerator.class, + registry.resolve( + NegotiationType.TARGET, + NegotiationPhase.REJECT, + new TargetEndingContent(NegotiationConclusion.REJECT, null, "失败原因"))); + assertInstanceOf( + FeasibilityEndingGenerator.class, + registry.resolve( + NegotiationType.FEASIBILITY, + NegotiationPhase.ACCEPT, + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "评估结论"))); + assertInstanceOf( + FeasibilityEndingGenerator.class, + registry.resolve( + NegotiationType.FEASIBILITY, + NegotiationPhase.REJECT, + new FeasibilityEndingContent(NegotiationConclusion.REJECT, "评估结论"))); + } + + @Test + void rejectsProposeContentInTerminalPhaseAndEndingContentInProposePhase() { + NegotiationContentException proposeInEnding = assertThrows( + NegotiationContentException.class, + () -> registry.resolve( + NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, new InfoProposeContent(List.of(), null))); + assertEquals("content", proposeInEnding.getField()); + assertTrue(proposeInEnding + .getMessage() + .contains("ACCEPT phase requires ending content but received propose content of type" + + " InfoProposeContent")); + + NegotiationContentException endingInPropose = assertThrows( + NegotiationContentException.class, + () -> registry.resolve( + NegotiationType.INFORMATION, + NegotiationPhase.PROPOSE, + new InfoEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + assertEquals("content", endingInPropose.getField()); + assertTrue(endingInPropose + .getMessage() + .contains("PROPOSE phase requires propose content but received ending content of type" + + " InfoEndingContent")); + } + + @Test + void rejectsContentRuntimeTypeNotMatchingTheNegotiationType() { + NegotiationContentException exception = assertThrows( + NegotiationContentException.class, + () -> registry.resolve( + NegotiationType.INFORMATION, + NegotiationPhase.PROPOSE, + new TargetProposeContent("描述", null, null, null))); + + assertEquals("content", exception.getField()); + assertTrue(exception + .getMessage() + .contains("Negotiation type INFORMATION requires content of type InfoProposeContent but received" + + " TargetProposeContent")); + + assertThrows( + NegotiationContentException.class, + () -> registry.resolve( + NegotiationType.TARGET, + NegotiationPhase.ACCEPT, + new InfoEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + } + + @Test + void rejectsEndingConclusionNotMatchingThePhase() { + NegotiationContentException exception = assertThrows( + NegotiationContentException.class, + () -> registry.resolve( + NegotiationType.INFORMATION, + NegotiationPhase.ACCEPT, + new InfoEndingContent(NegotiationConclusion.REJECT, List.of()))); + + assertEquals("content.conclusion", exception.getField()); + assertTrue(exception + .getMessage() + .contains("ACCEPT phase requires conclusion Accept but the content carries Reject")); + + assertThrows( + NegotiationContentException.class, + () -> registry.resolve( + NegotiationType.TARGET, + NegotiationPhase.REJECT, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); + } + + @Test + void rejectsEndingContentWithoutConclusion() { + NegotiationContentException exception = assertThrows( + NegotiationContentException.class, + () -> registry.resolve( + NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, new InfoEndingContent(null, List.of()))); + + assertEquals("content.conclusion", exception.getField()); + } + + @Test + void rejectsNullArguments() { + NegotiationContent content = new InfoProposeContent(List.of(), null); + + assertEquals( + "type", + assertThrows( + NegotiationContentException.class, + () -> registry.resolve(null, NegotiationPhase.PROPOSE, content)) + .getField()); + assertEquals( + "phase", + assertThrows( + NegotiationContentException.class, + () -> registry.resolve(NegotiationType.INFORMATION, null, content)) + .getField()); + assertEquals( + "content", + assertThrows( + NegotiationContentException.class, + () -> registry.resolve(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, null)) + .getField()); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java new file mode 100644 index 00000000..754af96b --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -0,0 +1,528 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; +import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import org.junit.jupiter.api.Test; + +class NegotiationGeneratorsTest { + + private static final String CONTEXT_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String ZH_TARGET_PROPOSE_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 目标协商 + {{目标协商概述}}(必填) + 要求: + 概述要求。 + + ## 意图理解陈述 + {{意图理解陈述}}(选填) + 要求: + 意图要求。 + + ## 理解对齐与疑问澄清 + {{理解对齐与疑问澄清}}(选填) + 要求: + 对齐要求。 + + ## 待澄清内容 + {{待澄清内容}}(选填) + 要求: + 澄清要求。 + """; + + private static final String ZH_FEASIBILITY_PROPOSE_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 可行性协商 + {{可行性协商概述}}(必填) + 要求: + 概述要求。 + + ## 待评估内容说明 + {{待评估内容说明}}(选填) + 要求: + 评估要求。 + + ## 评估不可行时的详情和提案 + {{评估不可行时的详情和提案}}(选填) + 要求: + 提案要求。 + """; + + private static final String ZH_INFO_PROPOSE_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 信息协商 + 请根据<所需信息项>补充相关内容。 + + ## 所需信息项 + {{所需信息项}}(必填) + 要求: + 信息项要求。 + """; + + private static final String ZH_INFO_ENDING_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 信息协商结果 + {{信息协商结果}}(必填) + 要求: + 结论要求。 + + ## 信息协商结果内容 + {{信息协商结果内容}}(必填) + 要求: + 内容要求。 + """; + + private static final String ZH_TARGET_ENDING_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 目标协商结果 + {{目标协商结果}}(必填) + 要求: + 结论要求。 + + ## 目标协商结果内容 + {{目标协商结果内容}}(必填) + 要求: + 内容要求。 + """; + + private static final String ZH_FEASIBILITY_ENDING_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 可行性协商结果 + {{可行性协商结果}}(必填) + 要求: + 结论要求。 + + ## 可行性评估结果确认 + {{评估结果确认}}(必填) + 要求: + 确认要求。 + """; + + private final Vocabulary zhVocabulary = Vocabulary.forLanguage("zh-CN"); + + private final Vocabulary enVocabulary = Vocabulary.forLanguage("en-US"); + + @Test + void feasibilityRequestActionRendersExactlyTheEvaluationSection() { + FeasibilityProposeContent content = new FeasibilityProposeContent( + "请协助评估该节能目标能否达成。", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("评估对象", "停电8小时期间核心用户的速率保障")), + List.of(new NegotiationItem("不应出现", "值"))); + + String rendered = new FeasibilityProposeGenerator() + .generate( + context(1), + content, + template("Negotiation-T/v1/feasibility-negotiation/propose", ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 可行性协商\n请协助评估该节能目标能否达成。")); + assertTrue(rendered.contains("## 待评估内容说明\n1. 评估对象:停电8小时期间核心用户的速率保障")); + assertFalse(rendered.contains("## 评估不可行时的详情和提案")); + assertFalse(rendered.contains("要求:")); + } + + @Test + void feasibilityAlternativeActionRendersExactlyTheInfeasibilitySection() { + FeasibilityProposeContent content = new FeasibilityProposeContent( + "当前速率目标不可行,提出下调方案。", + NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, + List.of(new NegotiationItem("不应出现", "值")), + List.of( + new NegotiationItem("不可行原因", "蓄电池仅能支撑8小时2Mbps的保障能力"), + new NegotiationItem("替代提案", "停电期间将速率保障目标下调至2Mbps"))); + + String rendered = new FeasibilityProposeGenerator() + .generate( + context(2), + content, + template("Negotiation-T/v1/feasibility-negotiation/propose", ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 可行性协商\n当前速率目标不可行,提出下调方案。")); + assertTrue(rendered.contains("## 评估不可行时的详情和提案\n1. 不可行原因:蓄电池仅能支撑8小时2Mbps的保障能力")); + assertTrue(rendered.contains("2. 替代提案:停电期间将速率保障目标下调至2Mbps")); + assertFalse(rendered.contains("## 待评估内容说明")); + } + + @Test + void feasibilityNullActionOrEmptyDrivenContentIsRejected() { + FeasibilityProposeContent nullAction = + new FeasibilityProposeContent("描述", null, List.of(new NegotiationItem("名称", "值")), null); + + NegotiationContentException nullActionError = + assertThrows(NegotiationContentException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + nullAction, + template( + "Negotiation-T/v1/feasibility-negotiation/propose", + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertEquals("content.action", nullActionError.getField()); + + FeasibilityProposeContent emptyEvaluation = + new FeasibilityProposeContent("描述", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null); + + NegotiationContentException emptyEvaluationError = + assertThrows(NegotiationContentException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + emptyEvaluation, + template( + "Negotiation-T/v1/feasibility-negotiation/propose", + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertEquals("content.contentsToEvaluate", emptyEvaluationError.getField()); + + FeasibilityProposeContent emptyAlternative = + new FeasibilityProposeContent("描述", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, List.of()); + + NegotiationContentException emptyAlternativeError = + assertThrows(NegotiationContentException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + emptyAlternative, + template( + "Negotiation-T/v1/feasibility-negotiation/propose", + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertEquals("content.infeasibilityDetailsAndProposal", emptyAlternativeError.getField()); + + FeasibilityProposeContent blankDescription = new FeasibilityProposeContent( + " ", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, List.of(new NegotiationItem("名称", "值")), null); + + assertEquals( + "content.feasibilityNegotiationDescription", + assertThrows(NegotiationContentException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + blankDescription, + template( + "Negotiation-T/v1/feasibility-negotiation/propose", + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)) + .getField()); + } + + @Test + void targetFirstRoundRendersIntentSectionAndDropsAlignmentSection() { + TargetProposeContent content = new TargetProposeContent( + "对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。", + List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), + List.of(new NegotiationItem("对齐项", "不应在首轮渲染")), + List.of(new NegotiationItem("节能时间范围", "需要澄清"))); + + String rendered = new TargetProposeGenerator() + .generate( + context(1), + content, + template("Negotiation-T/v1/target-negotiation/propose", ZH_TARGET_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 意图理解陈述\n1. 发起方理解:对方希望在体验无损的前提下降低节能力度")); + assertFalse(rendered.contains("## 理解对齐与疑问澄清")); + assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); + assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。")); + } + + @Test + void targetLaterRoundRendersAlignmentSectionAndDropsIntentSection() { + TargetProposeContent content = new TargetProposeContent( + "已提供疑问澄清,请确认。", + List.of(new NegotiationItem("意图项", "不应在非首轮渲染")), + List.of(new NegotiationItem("确认结果", "节能时间范围确认为08:00~18:00")), + null); + + String rendered = new TargetProposeGenerator() + .generate( + context(3), + content, + template("Negotiation-T/v1/target-negotiation/propose", ZH_TARGET_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 理解对齐与疑问澄清\n1. 确认结果:节能时间范围确认为08:00~18:00")); + assertFalse(rendered.contains("## 意图理解陈述")); + assertFalse(rendered.contains("## 待澄清内容")); + } + + @Test + void targetBlankDescriptionIsRejected() { + TargetProposeContent content = new TargetProposeContent(" ", null, null, null); + + NegotiationContentException exception = + assertThrows(NegotiationContentException.class, () -> new TargetProposeGenerator() + .generate( + context(1), + content, + template("Negotiation-T/v1/target-negotiation/propose", ZH_TARGET_PROPOSE_TEMPLATE), + zhVocabulary)); + + assertEquals("content.targetNegotiationDescription", exception.getField()); + } + + @Test + void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { + InfoProposeContent withRelationship = new InfoProposeContent( + List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点"), new NegotiationItem("受影响小区标识", "CGI 或小区名称")), + "故障发生时间与受影响小区标识需逐小区对应"); + + String renderedZh = new InformationProposeGenerator() + .generate( + context(1), + withRelationship, + template("Negotiation-T/v1/information-negotiation/propose", ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(renderedZh.contains("## 所需信息项\n1. 故障发生时间:精确到分钟的时间点\n2. 受影响小区标识:CGI 或小区名称")); + assertTrue(renderedZh.endsWith("缺失项之间的关系:故障发生时间与受影响小区标识需逐小区对应")); + + InfoProposeContent withoutRelationship = + new InfoProposeContent(List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点")), null); + + String renderedWithout = new InformationProposeGenerator() + .generate( + context(1), + withoutRelationship, + template("Negotiation-T/v1/information-negotiation/propose", ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary); + + assertFalse(renderedWithout.contains("缺失项之间的关系")); + } + + @Test + void informationProposeRendersEnglishRelationshipLabelWithTrailingSpace() { + InfoProposeContent content = new InfoProposeContent( + List.of(new NegotiationItem("Failure time", "minute precision")), + "failure time and cell identity must correspond per cell"); + + PromptTemplate enTemplate = template( + "Negotiation-T/v1/information-negotiation/propose", + """ + ## Negotiation Context + {{Negotiation Context}} (required) + Requirements: context. + + ## Required Information Items + {{Required Information Items}} (required) + Requirements: items. + """); + + String rendered = new InformationProposeGenerator().generate(context(1), content, enTemplate, enVocabulary); + + assertTrue(rendered.endsWith( + "Relationship between missing items: failure time and cell identity must correspond per cell")); + } + + @Test + void endingGeneratorsRenderConclusionLiterals() { + String infoRendered = new InformationEndingGenerator() + .generate( + context(2), + new InfoEndingContent( + NegotiationConclusion.ACCEPT, + List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30"))), + template("Negotiation-T/v1/information-negotiation/accept-reject", ZH_INFO_ENDING_TEMPLATE), + zhVocabulary); + + assertTrue(infoRendered.contains("## 信息协商结果\nAccept")); + assertTrue(infoRendered.contains("## 信息协商结果内容\n1. 故障发生时间:2026-08-19 10:30")); + + String targetRendered = new TargetEndingGenerator() + .generate( + context(2), + new TargetEndingContent(NegotiationConclusion.REJECT, null, "双方未就速率保障下限达成一致。"), + template("Negotiation-T/v1/target-negotiation/accept-reject", ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary); + + assertTrue(targetRendered.contains("## 目标协商结果\nReject")); + assertTrue(targetRendered.contains("## 目标协商结果内容\n双方未就速率保障下限达成一致。")); + + String feasibilityRendered = new FeasibilityEndingGenerator() + .generate( + context(2), + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "同意将速率保障目标由5Mbps下调至2Mbps。"), + template( + "Negotiation-T/v1/feasibility-negotiation/accept-reject", + ZH_FEASIBILITY_ENDING_TEMPLATE), + zhVocabulary); + + assertTrue(feasibilityRendered.contains("## 可行性协商结果\nAccept")); + assertTrue(feasibilityRendered.contains("## 可行性评估结果确认\n同意将速率保障目标由5Mbps下调至2Mbps。")); + } + + @Test + void abortConclusionIsRejectedByEveryEndingGenerator() { + assertThrows(NegotiationContentException.class, () -> new InformationEndingGenerator() + .generate( + context(1), + new InfoEndingContent(NegotiationConclusion.ABORT, List.of()), + template("Negotiation-T/v1/information-negotiation/accept-reject", ZH_INFO_ENDING_TEMPLATE), + zhVocabulary)); + assertThrows(NegotiationContentException.class, () -> new TargetEndingGenerator() + .generate( + context(1), + new TargetEndingContent(NegotiationConclusion.ABORT, "意图", null), + template("Negotiation-T/v1/target-negotiation/accept-reject", ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary)); + assertThrows(NegotiationContentException.class, () -> new FeasibilityEndingGenerator() + .generate( + context(1), + new FeasibilityEndingContent(NegotiationConclusion.ABORT, "结论"), + template( + "Negotiation-T/v1/feasibility-negotiation/accept-reject", + ZH_FEASIBILITY_ENDING_TEMPLATE), + zhVocabulary)); + } + + @Test + void nullConclusionIsRejected() { + NegotiationContentException exception = + assertThrows(NegotiationContentException.class, () -> new InformationEndingGenerator() + .generate( + context(1), + new InfoEndingContent(null, List.of()), + template( + "Negotiation-T/v1/information-negotiation/accept-reject", + ZH_INFO_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals("content.conclusion", exception.getField()); + } + + @Test + void targetEndingRequiresTheFieldMatchingTheConclusion() { + NegotiationContentException acceptWithoutIntent = + assertThrows(NegotiationContentException.class, () -> new TargetEndingGenerator() + .generate( + context(1), + new TargetEndingContent(NegotiationConclusion.ACCEPT, null, "失败原因"), + template( + "Negotiation-T/v1/target-negotiation/accept-reject", ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals("content.confirmedIntent", acceptWithoutIntent.getField()); + + NegotiationContentException rejectWithoutReason = + assertThrows(NegotiationContentException.class, () -> new TargetEndingGenerator() + .generate( + context(1), + new TargetEndingContent(NegotiationConclusion.REJECT, " ", null), + template( + "Negotiation-T/v1/target-negotiation/accept-reject", ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals("content.failureReason", rejectWithoutReason.getField()); + } + + @Test + void feasibilityEndingRequiresTheSummary() { + NegotiationContentException exception = + assertThrows(NegotiationContentException.class, () -> new FeasibilityEndingGenerator() + .generate( + context(1), + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " "), + template( + "Negotiation-T/v1/feasibility-negotiation/accept-reject", + ZH_FEASIBILITY_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals("content.feasibilitySummary", exception.getField()); + } + + @Test + void generatorRejectsContentOfAnotherRuntimeType() { + NegotiationContentException exception = + assertThrows(NegotiationContentException.class, () -> new InformationProposeGenerator() + .generate( + context(1), + new TargetProposeContent("描述", null, null, null), + template("Negotiation-T/v1/information-negotiation/propose", ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary)); + + assertEquals("content", exception.getField()); + } + + @Test + void generatorsRenderAgainstTheBundledTemplates() { + NegotiationReference reference = NegotiationReference.parse( + "Negotiation-T/v1/target-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN"); + PromptTemplate loaded = new DefaultNegotiationTemplateLoader("zh-CN", null).load(reference); + + TargetProposeContent content = new TargetProposeContent( + "对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。", + List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), + null, + List.of(new NegotiationItem("节能时间范围", "需要澄清"))); + + String rendered = new TargetProposeGenerator().generate(context(1), content, loaded, zhVocabulary); + + assertTrue(rendered.startsWith("## 协商上下文\n- id: " + CONTEXT_ID + "\n- round: 1\n- maxRounds: 5")); + assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。")); + assertTrue(rendered.contains("## 意图理解陈述")); + assertFalse(rendered.contains("## 理解对齐与疑问澄清")); + assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); + assertFalse(rendered.contains("要求:")); + assertFalse(rendered.endsWith("\n")); + } + + private static NegotiationContext context(int round) { + return new NegotiationContext(CONTEXT_ID, round, 5); + } + + private static PromptTemplate template(String uri, String content) { + return new PromptTemplate(uri, "", content); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatterTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatterTest.java new file mode 100644 index 00000000..d81230d8 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatterTest.java @@ -0,0 +1,45 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import org.junit.jupiter.api.Test; + +class NegotiationItemFormatterTest { + + private final NegotiationItemFormatter formatter = new NegotiationItemFormatter(); + + @Test + void formatsNumberedLinesWithSuppliedColonPunctuation() { + List items = + List.of(new NegotiationItem("节能区域信息", "松山湖"), new NegotiationItem("节能速率保障目标", "20Mbps")); + + assertEquals("1. 节能区域信息:松山湖\n2. 节能速率保障目标:20Mbps", formatter.format(items, ":")); + assertEquals( + "1. Energy-saving area information: Songshan Lake\n2. Rate guarantee target: 20 Mbps", + formatter.format( + List.of( + new NegotiationItem("Energy-saving area information", "Songshan Lake"), + new NegotiationItem("Rate guarantee target", "20 Mbps")), + ": ")); + } + + @Test + void omitsColonAndValueWhenValueIsNullOrBlank() { + List items = List.of(new NegotiationItem("名称", null), new NegotiationItem("另一个名称", " ")); + + assertEquals("1. 名称\n2. 另一个名称", formatter.format(items, ":")); + } + + @Test + void rendersEmptyStringForNullOrEmptyList() { + assertEquals("", formatter.format(null, ":")); + assertEquals("", formatter.format(List.of(), ":")); + } + + @Test + void toleratesNullColonPunctuation() { + assertEquals("1. 名称值", formatter.format(List.of(new NegotiationItem("名称", "值")), null)); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java new file mode 100644 index 00000000..82fa3f7a --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java @@ -0,0 +1,187 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import org.junit.jupiter.api.Test; + +class NegotiationJsonSchemaBuilderTest { + + private final NegotiationJsonSchemaBuilder builder = new NegotiationJsonSchemaBuilder(); + + @Test + void informationProposeSchemaUsesSnakeCasePropertiesAndNullableRelationship() { + Map schema = + builder.buildExtractionSchema(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE); + + assertEquals("object", schema.get("type")); + Map properties = (Map) schema.get("properties"); + assertEquals(List.of("items", "relationship"), List.copyOf(properties.keySet())); + assertEquals(List.of("items"), schema.get("required")); + + Map relationship = (Map) properties.get("relationship"); + assertEquals(List.of("string", "null"), relationship.get("type")); + assertFalse(relationship.containsKey("enum")); + + Map items = (Map) properties.get("items"); + assertEquals("array", items.get("type")); + Map item = (Map) items.get("items"); + assertEquals(List.of("name", "value"), List.copyOf(((Map) item.get("properties")).keySet())); + assertEquals(List.of("name", "value"), item.get("required")); + } + + @Test + void informationEndingSchemaRequiresConclusionAndItems() { + Map schema = + builder.buildExtractionSchema(NegotiationType.INFORMATION, NegotiationPhase.ACCEPT); + + Map properties = (Map) schema.get("properties"); + assertEquals(List.of("conclusion", "items"), List.copyOf(properties.keySet())); + assertEquals(List.of("conclusion", "items"), schema.get("required")); + assertEquals(List.of("Accept", "Reject"), ((Map) properties.get("conclusion")).get("enum")); + } + + @Test + void acceptAndRejectShareTheTerminalSchema() { + assertEquals( + builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPhase.ACCEPT), + builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPhase.REJECT)); + } + + @Test + void targetProposeSchemaUsesSnakeCaseProperties() { + Map schema = builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPhase.PROPOSE); + + Map properties = (Map) schema.get("properties"); + assertEquals( + List.of( + "target_negotiation_description", + "intent_understanding", + "alignment_and_clarification", + "request_for_clarification"), + List.copyOf(properties.keySet())); + assertEquals(List.of("target_negotiation_description"), schema.get("required")); + assertEquals(List.of("array", "null"), ((Map) properties.get("intent_understanding")).get("type")); + } + + @Test + void targetEndingSchemaCarriesNullableResultFields() { + Map schema = builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPhase.REJECT); + + Map properties = (Map) schema.get("properties"); + assertEquals(List.of("conclusion", "confirmed_intent", "failure_reason"), List.copyOf(properties.keySet())); + assertEquals(List.of("conclusion"), schema.get("required")); + assertEquals(List.of("string", "null"), ((Map) properties.get("confirmed_intent")).get("type")); + assertEquals(List.of("string", "null"), ((Map) properties.get("failure_reason")).get("type")); + } + + @Test + void feasibilityProposeSchemaRequiresTheActionEnum() { + Map schema = + builder.buildExtractionSchema(NegotiationType.FEASIBILITY, NegotiationPhase.PROPOSE); + + Map properties = (Map) schema.get("properties"); + assertEquals( + List.of( + "feasibility_negotiation_description", + "action", + "contents_to_evaluate", + "infeasibility_details_and_proposal"), + List.copyOf(properties.keySet())); + assertEquals(List.of("feasibility_negotiation_description", "action"), schema.get("required")); + Map action = (Map) properties.get("action"); + assertEquals(List.of("REQUEST_FEASIBILITY_EVALUATION", "PROPOSE_ALTERNATIVE_ON_FAILURE"), action.get("enum")); + } + + @Test + void feasibilityEndingSchemaRequiresTheSummary() { + Map schema = + builder.buildExtractionSchema(NegotiationType.FEASIBILITY, NegotiationPhase.ACCEPT); + + Map properties = (Map) schema.get("properties"); + assertEquals(List.of("conclusion", "feasibility_summary"), List.copyOf(properties.keySet())); + assertEquals(List.of("conclusion", "feasibility_summary"), schema.get("required")); + } + + @Test + void rejectsNullTypeAndPhase() { + assertEquals( + "type", + assertThrows( + NegotiationContentException.class, + () -> builder.buildExtractionSchema(null, NegotiationPhase.PROPOSE)) + .getField()); + assertEquals( + "phase", + assertThrows( + NegotiationContentException.class, + () -> builder.buildExtractionSchema(NegotiationType.INFORMATION, null)) + .getField()); + } + + @Test + @SuppressWarnings("unchecked") + void semanticValidationSchemaMergesTheCallerSchemaIntoAFourKeyContract() { + Map callerSchema = + Map.of("type", "object", "properties", Map.of("energy_rate", Map.of("type", "number"))); + + Map schema = builder.buildSemanticValidationSchema(callerSchema); + + assertEquals("object", schema.get("type")); + Map properties = (Map) schema.get("properties"); + assertEquals( + List.of("semantic_verdict", "negotiation_type", "errors", "params"), List.copyOf(properties.keySet())); + assertEquals(List.of("semantic_verdict", "negotiation_type", "errors", "params"), schema.get("required")); + assertEquals(Boolean.FALSE, schema.get("additionalProperties")); + + assertEquals(Map.of("type", "boolean"), properties.get("semantic_verdict")); + + Map negotiationType = (Map) properties.get("negotiation_type"); + assertEquals(List.of("string", "null"), negotiationType.get("type")); + List typeEnum = (List) negotiationType.get("enum"); + assertEquals(4, typeEnum.size()); + assertTrue(typeEnum.contains("information")); + assertTrue(typeEnum.contains("target")); + assertTrue(typeEnum.contains("feasibility")); + assertTrue(typeEnum.contains(null)); + + Map errors = (Map) properties.get("errors"); + assertEquals("array", errors.get("type")); + Map errorItem = (Map) errors.get("items"); + assertEquals( + List.of("slot_name", "code", "message"), + List.copyOf(((Map) errorItem.get("properties")).keySet())); + assertEquals(List.of("slot_name", "code", "message"), errorItem.get("required")); + + assertEquals(callerSchema, properties.get("params")); + } + + @Test + void semanticValidationSchemaWrapsCallerSchemaWithoutTypeKeyword() { + Map callerSchema = new java.util.LinkedHashMap<>(); + callerSchema.put("properties", Map.of("id", Map.of("type", "string"))); + + Map schema = builder.buildSemanticValidationSchema(callerSchema); + + Map params = (Map) ((Map) schema.get("properties")).get("params"); + assertEquals("object", params.get("type")); + assertEquals(Map.of("id", Map.of("type", "string")), params.get("properties")); + assertNull(params.get("additionalProperties")); + } + + @Test + void rejectsNullCallerSchema() { + assertEquals( + "schema", + assertThrows(NegotiationContentException.class, () -> builder.buildSemanticValidationSchema(null)) + .getField()); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererTest.java new file mode 100644 index 00000000..cff889a7 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererTest.java @@ -0,0 +1,216 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class NegotiationPromptRendererTest { + + private final NegotiationPromptRenderer renderer = new NegotiationPromptRenderer(); + + private static final String ZH_TEMPLATE = + """ + + + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 必须包含 id、round 与 maxRounds。 + + ## 信息协商 + 请根据<所需信息项>补充相关内容。 + + ## 所需信息项 + {{所需信息项}}(必填) + 要求: + 提供每个缺失项的名称。 + """; + + private static final String EN_TEMPLATE = + """ + + + ## Negotiation Context + {{Negotiation Context}} (required) + Requirements: + Every negotiation must include id, round and maxRounds. + + ## Required Information Items + {{Required Information Items}} (required) + Requirements: + Provide the name of each missing item. + """; + + @Test + void rendersSlotSectionsDroppingRequirementsAndStaticSections() { + Map slots = new LinkedHashMap<>(); + slots.put("协商上下文", "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 1\n- maxRounds: 5"); + slots.put("所需信息项", "1. 节能区域信息:松山湖"); + + String rendered = renderer.render(ZH_TEMPLATE, slots); + + assertEquals( + "## 协商上下文\n" + + "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n" + + "- round: 1\n" + + "- maxRounds: 5\n" + + "\n" + + "## 信息协商\n" + + "请根据<所需信息项>补充相关内容。\n" + + "\n" + + "## 所需信息项\n" + + "1. 节能区域信息:松山湖", + rendered); + } + + @Test + void dropsWholeSlotSectionWhenValueIsMissingNullOrBlank() { + Map slots = new LinkedHashMap<>(); + slots.put("协商上下文", "- id: id-1\n- round: 1\n- maxRounds: 5"); + slots.put("所需信息项", null); + + String renderedWithNull = renderer.render(ZH_TEMPLATE, slots); + + assertEquals( + "## 协商上下文\n- id: id-1\n- round: 1\n- maxRounds: 5\n\n## 信息协商\n请根据<所需信息项>补充相关内容。", renderedWithNull); + + slots.put("所需信息项", " "); + String renderedWithBlank = renderer.render(ZH_TEMPLATE, slots); + + assertEquals(renderedWithNull, renderedWithBlank); + } + + @Test + void dropsLeadingHtmlCommentAndPreambleBeforeFirstSection() { + String rendered = renderer.render(ZH_TEMPLATE, Map.of("协商上下文", "- id: id-1", "所需信息项", "1. 名称")); + + assertFalse(rendered.contains("")): - errors.append(error(path, 1, "negotiation-comment", "en-US template must start with an HTML comment ''.")) - elif first_line.startswith(""): + errors.append(error(path, 1, "negotiation-comment", "HTML comment header must be well-formed ''.")) sections = parse_negotiation_sections(lines) if not sections: errors.append(error(path, 1, "negotiation-section-missing", "Template must contain sections marked with '## '.")) @@ -258,8 +268,8 @@ def lint_negotiation_alignment( if len(zh_shape) != len(en_shape): errors.append(error(en_path, 1, "negotiation-alignment", f"Section count differs from zh-CN counterpart: {len(en_shape)} vs {len(zh_shape)}.")) for index, (zh_entry, en_entry) in enumerate(zip(zh_shape, en_shape)): - zh_key, zh_required, zh_slot_match, _ = zh_entry - en_key, en_required, en_slot_match, en_line = en_entry + zh_key, zh_required, _, _ = zh_entry + en_key, en_required, _, en_line = en_entry differences = [] if zh_key != en_key: differences.append(f"section '{en_key}' vs zh-CN '{zh_key}'") @@ -270,8 +280,6 @@ def lint_negotiation_alignment( differences.append("has slot marker while zh-CN does not") else: differences.append(f"marker {'required' if en_required else 'optional'} vs zh-CN {'required' if zh_required else 'optional'}") - if zh_slot_match != en_slot_match: - differences.append("slot-name exception pattern differs from zh-CN") if differences: errors.append(error(en_path, en_line, "negotiation-alignment", f"Section {index + 1} diverges from zh-CN template ({'; '.join(differences)}).")) return errors From 081e4e17e28d1b73e100cf8f07b4ee468300cdb2 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Thu, 20 Aug 2026 13:06:11 +0800 Subject: [PATCH 045/295] test(a2a-t-negotiation): regenerate golden fixtures for upstream templates Regenerate the seven golden fixtures whose rendered output changed with the upstream template adoption and the slot.*/section.* vocabulary split: the five en-US propose/ending fixtures that now carry the renamed section titles and the upstream static-body wording, and the two zh-CN propose fixtures whose template prose drifted from the previously bundled local templates. The remaining eleven fixtures were byte-identical and are unchanged. Generated via the disabled GoldenFixtureGeneratorTest against the real built-in resources and the updated GoldenInputs. Signed-off-by: codetalks-hw --- .../src/test/resources/golden/en-US/feasibility_accept.md | 2 +- .../src/test/resources/golden/en-US/feasibility_propose.md | 4 ++-- .../src/test/resources/golden/en-US/feasibility_reject.md | 2 +- .../src/test/resources/golden/en-US/information_propose.md | 2 +- .../src/test/resources/golden/en-US/target_propose.md | 6 +++--- .../src/test/resources/golden/zh-CN/feasibility_propose.md | 2 +- .../src/test/resources/golden/zh-CN/target_propose.md | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md index 6f7d00b5..673de51f 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md @@ -6,5 +6,5 @@ ## Feasibility Negotiation Result Accept -## Feasibility Result Confirmation +## Feasibility Assessment Result Confirmation The adjusted rate guarantee target is achievable and satisfies the outage duration requirement; this negotiation is confirmed as concluded. \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md index 10161f0e..60e535ed 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md @@ -4,8 +4,8 @@ - maxRounds: 5 ## Feasibility Negotiation -Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess. +Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess. -## Contents to Evaluate +## Under Evaluation Description 1. adjusted target: rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00 2. existing constraint: at least 10 hours of power supply duration in outage scenarios \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md index a162bfe8..08319b1c 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md @@ -6,5 +6,5 @@ ## Feasibility Negotiation Result Reject -## Feasibility Result Confirmation +## Feasibility Assessment Result Confirmation The energy-saving target cannot be achieved under the existing power supply constraint; this negotiation is confirmed as concluded. \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md b/a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md index 230b17a1..12929775 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md @@ -4,7 +4,7 @@ - maxRounds: 5 ## Information Negotiation -Please provide the related content according to . +Please supplement the relevant content based on . ## Required Information Items 1. energy-saving area information: e.g. Songshan Lake diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md b/a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md index c348b975..3f1e3857 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md @@ -4,12 +4,12 @@ - maxRounds: 5 ## Target Negotiation -The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm. +The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm. -## Intent Understanding +## Intent Understanding Statement 1. task intent: apply wireless energy-saving optimization to the target site during 08:00-18:00 2. rate guarantee target: at least 20Mbps during the energy-saving period -## Clarification Required +## Content to Clarify 1. area: which site is covered: Songshan Lake or another site 2. time range: is the energy-saving period 08:00-18:00 or 00:00-06:00 \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md index f219f1bb..2e953e79 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md @@ -4,7 +4,7 @@ - maxRounds: 5 ## 可行性协商 -Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess. +Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess. ## 待评估内容说明 1. adjusted target:rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00 diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md index 27498ba2..f4e4c11d 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md @@ -4,7 +4,7 @@ - maxRounds: 5 ## 目标协商 -The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm. +The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm. ## 意图理解陈述 1. task intent:apply wireless energy-saving optimization to the target site during 08:00-18:00 From a6f1e48f6800beeb363420a549731c538ad96b5d Mon Sep 17 00:00:00 2001 From: wangruiyang8 Date: Thu, 20 Aug 2026 14:45:51 +0800 Subject: [PATCH 046/295] feat(sample): add mock LLM client and CI e2e test for sample module - Add SampleMockLLMClient returning canned LLM responses loaded from classpath JSON resources - Add SampleMockLlmInstaller that auto-detects missing LLM config and installs mock via reflection - Add SampleLoggingLLMClient logging every LLM request/response (like Python llm_logger) - Support language-dependent user input (zh-CN/en-US) matching A2AT_LANGUAGE - Support A2AT_SAMPLE_MAX_ARTIFACTS env var to limit artifact count - Fix null error message in DefaultSampleClientRuntime when server is unreachable - Strip UTF-8 BOM in env file parsing; fix max_tokens mis-masked as secret - Add CI e2e-test job, trigger on all branches, MODEL/BASE_URL defaults from resources - Rename resource directory subscribe_incident -> subscribe-incident for consistency Signed-off-by: wangruiyang8 --- .github/workflows/ci.yml | 107 +++++++++- .../client/ClientSampleMain.java | 62 +++++- .../client/flow/ClientSampleFlow.java | 45 +++- .../runtime/DefaultSampleClientRuntime.java | 16 +- .../server/ServerSampleMain.java | 16 +- .../runtime/DefaultSampleServerRuntime.java | 2 +- .../logging/SampleLoggingFormatter.java | 10 +- .../shared/mock/SampleLoggingLLMClient.java | 89 ++++++++ .../shared/mock/SampleMockLLMClient.java | 111 ++++++++++ .../shared/mock/SampleMockLlmInstaller.java | 192 ++++++++++++++++++ .../client/client.env | 2 +- .../client/scenario.json | 0 .../en-US/scenario_recognition.json | 5 + .../en-US/semantic_validation.json | 4 + .../mock_responses/en-US/slot_extraction.json | 8 + .../zh-CN/scenario_recognition.json | 5 + .../zh-CN/semantic_validation.json | 4 + .../mock_responses/zh-CN/slot_extraction.json | 8 + .../server/server.env | 2 +- 19 files changed, 667 insertions(+), 21 deletions(-) create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleLoggingLLMClient.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLLMClient.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLlmInstaller.java rename a2a-t-sample/src/main/resources/sample/{subscribe_incident => subscribe-incident}/client/client.env (94%) rename a2a-t-sample/src/main/resources/sample/{subscribe_incident => subscribe-incident}/client/scenario.json (100%) create mode 100644 a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/scenario_recognition.json create mode 100644 a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/semantic_validation.json create mode 100644 a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/slot_extraction.json create mode 100644 a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/scenario_recognition.json create mode 100644 a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/semantic_validation.json create mode 100644 a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/slot_extraction.json rename a2a-t-sample/src/main/resources/sample/{subscribe_incident => subscribe-incident}/server/server.env (94%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eb26106..cd163e89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,6 @@ name: CI on: push: - branches: [ main ] pull_request: branches: [ main ] @@ -31,3 +30,109 @@ jobs: - name: Build and run tests run: mvn -B clean verify + + e2e-test: + name: E2E Samples Test + runs-on: ubuntu-latest + needs: build-and-test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - name: Package project + run: mvn -B package -DskipTests + + - name: Create server env + run: | + cat > server.env << 'EOF' + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=a2a-t-resources/src/main/resources/prompt_resources + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=deepseek-chat + A2AT_LLM_BASE_URL=https://api.deepseek.com + A2AT_LLM_API_KEY= + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_SAMPLE_HOST=127.0.0.1 + A2AT_SAMPLE_PORT=26335 + REGISTRY_CENTER_HOST=127.0.0.1 + REGISTRY_CENTER_PORT=5001 + EOF + sed -i 's|^A2AT_LLM_API_KEY=$|A2AT_LLM_API_KEY=${{ secrets.A2AT_LLM_API_KEY }}|' server.env + + - name: Create client env + run: | + cat > client.env << 'EOF' + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=deepseek-chat + A2AT_LLM_BASE_URL=https://api.deepseek.com + A2AT_LLM_API_KEY= + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_SAMPLE_HOST=127.0.0.1 + A2AT_SAMPLE_PORT=26335 + EOF + sed -i 's|^A2AT_LLM_API_KEY=$|A2AT_LLM_API_KEY=${{ secrets.A2AT_LLM_API_KEY }}|' client.env + + - name: Diagnose LLM config availability + shell: bash + run: | + line=$(grep 'A2AT_LLM_API_KEY=' server.env) + value=${line#A2AT_LLM_API_KEY=} + len=${#value} + if [ "$len" -eq 0 ]; then + echo "::notice::A2AT_LLM_API_KEY is empty, e2e will use mock LLM responses" + else + prefix=$(echo "$value" | cut -c1-12) + echo "A2AT_LLM_API_KEY length=$len prefix=$prefix..." + fi + + - name: Start server + run: | + nohup java @a2a-t-sample/target/server.javaargs.txt > server.log 2>&1 & + echo $! > server.pid + sleep 3 + + - name: Wait for server + run: | + for i in $(seq 1 10); do + if curl -s http://127.0.0.1:26335/ > /dev/null 2>&1; then + echo "Server ready" + break + fi + echo "Waiting for server... attempt $i" + sleep 2 + done + + - name: Run client + env: + A2AT_SAMPLE_MAX_ARTIFACTS: "5" + run: | + java @a2a-t-sample/target/client.javaargs.txt 2>&1 | tee client.log + artifact_count=$(grep -c 'task-artifact' client.log || true) + echo "e2e artifacts received: $artifact_count" + if [ "$artifact_count" -eq 0 ]; then + echo "::error::e2e failed: no artifacts received" + exit 1 + fi + echo "e2e PASSED" + + - name: Show logs + if: always() + run: | + echo "=== server.log ===" && cat server.log 2>/dev/null || true + echo "=== client.log ===" && cat client.log 2>/dev/null || true + + - name: Cleanup + if: always() + run: | + kill $(cat server.pid 2>/dev/null) 2>/dev/null || true \ No newline at end of file diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMain.java index 21ff5446..adc26670 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMain.java @@ -9,6 +9,7 @@ import net.openan.a2at.sample.subscribe_incident.client.runtime.DefaultSampleClientRuntime; import net.openan.a2at.sample.subscribe_incident.client.runtime.SampleClientRuntime; import net.openan.a2at.sample.subscribe_incident.client.runtime.SampleClientRuntimeFactory; +import net.openan.a2at.sample.subscribe_incident.shared.mock.SampleMockLlmInstaller; import net.openan.a2at.sample.subscribe_incident.shared.scenario.SampleScenarioLoader; /** @@ -18,6 +19,9 @@ */ public final class ClientSampleMain { + private static final String MOCK_RESOURCE_ROOT = + "sample/subscribe-incident/mock_responses"; + private ClientSampleMain() { } @@ -33,19 +37,27 @@ public static List> runMain( Path envPath, SampleClientRuntimeFactory runtimeFactory, Consumer logSink) { - SampleClientRuntime runtime = runtimeFactory.create(envPath); + boolean mockNeeded = SampleMockLlmInstaller.isMockNeeded(envPath); + SampleMockLlmInstaller.installLlmLogger(mockNeeded, "client"); + Path resolvedEnvPath = SampleMockLlmInstaller.resolveEnvPath(envPath, MOCK_RESOURCE_ROOT); + if (!resolvedEnvPath.equals(envPath)) { + emit(logSink, "[client] no LLM API key found, using mock LLM responses for e2e"); + } + SampleClientRuntime runtime = runtimeFactory.create(resolvedEnvPath); try { if (!(runtime instanceof A2AJavaClientRuntime a2aRuntime)) { throw new IllegalStateException("Sample client runtime must implement A2AJavaClientRuntime"); } - Map scenarioPayload = SampleScenarioLoader.loadClasspathScenario("sample/subscribe_incident/client/scenario.json"); + Map scenarioPayload = SampleScenarioLoader.loadClasspathScenario("sample/subscribe-incident/client/scenario.json"); return ClientSampleFlow.runClientFlow( scenarioPayload, runtime.registryClient(), runtime.promptClient(), a2aRuntime, runtime.endpointCache(), - logSink); + logSink, + resolveMaxArtifacts(), + resolveLanguage(resolvedEnvPath)); } finally { runtime.close(); } @@ -54,5 +66,49 @@ public static List> runMain( public static void main(String[] args) { runMain(resolveEnvPath(args), DefaultSampleClientRuntime::new, System.out::println); } + + private static int resolveMaxArtifacts() { + String raw = System.getenv("A2AT_SAMPLE_MAX_ARTIFACTS"); + if (raw != null) { + String trimmed = raw.trim(); + if (!trimmed.isEmpty()) { + try { + return Integer.parseInt(trimmed); + } catch (NumberFormatException exception) { + // ignore and fall through to default + } + } + } + return 0; + } + + private static String resolveLanguage(Path envPath) { + if (envPath == null || !java.nio.file.Files.exists(envPath)) { + return "zh-CN"; + } + try { + for (String rawLine : java.nio.file.Files.readAllLines(envPath)) { + String line = rawLine.trim(); + if (line.startsWith("\uFEFF")) { + line = line.substring(1).trim(); + } + if (line.startsWith("A2AT_LANGUAGE=") && line.length() > "A2AT_LANGUAGE=".length()) { + String value = line.substring("A2AT_LANGUAGE=".length()).trim(); + if (!value.isEmpty()) { + return value; + } + } + } + } catch (java.io.IOException exception) { + // fall through to default + } + return "zh-CN"; + } + + private static void emit(Consumer logSink, String message) { + if (logSink != null) { + logSink.accept(message); + } + } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientSampleFlow.java index 8a8ebbc6..f88ca5b7 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientSampleFlow.java @@ -28,9 +28,16 @@ public final class ClientSampleFlow { static final String NOTIFICATION_T_EXTENSION_URI_NL = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/NL/v1"; - static final String NATURAL_LANGUAGE_PROMPT_INPUT = + static final String NATURAL_LANGUAGE_PROMPT_INPUT_ZH = "请生成一个Incident事件订阅任务:通知主题为Incident,订阅条件为订阅级别为critical的ETH-LOS的故障,上报通知数据格式为DataPart"; + static final String NATURAL_LANGUAGE_PROMPT_INPUT_EN = + "Generate an Incident event subscription task: notification topic is Incident, " + + "subscription condition is a critical ETH-LOS fault, " + + "and the notification data format is DataPart"; + + private static final String LANGUAGE_EN_US = "en-US"; + private ClientSampleFlow() { } @@ -41,6 +48,30 @@ public static List> runClientFlow( A2AJavaClientRuntime a2aRuntime, AgentEndpointCache endpointCache, Consumer logSink) { + return runClientFlow(scenarioPayload, registryClient, promptClient, a2aRuntime, endpointCache, logSink, 0, "zh-CN"); + } + + public static List> runClientFlow( + Map scenarioPayload, + SampleRegistryClient registryClient, + SamplePromptClient promptClient, + A2AJavaClientRuntime a2aRuntime, + AgentEndpointCache endpointCache, + Consumer logSink, + int maxArtifacts) { + return runClientFlow( + scenarioPayload, registryClient, promptClient, a2aRuntime, endpointCache, logSink, maxArtifacts, "zh-CN"); + } + + public static List> runClientFlow( + Map scenarioPayload, + SampleRegistryClient registryClient, + SamplePromptClient promptClient, + A2AJavaClientRuntime a2aRuntime, + AgentEndpointCache endpointCache, + Consumer logSink, + int maxArtifacts, + String language) { AgentCardQuery query = resolveAgentCardQuery(scenarioPayload); Map agentCard = registryClient.queryAgentCardByName(query.name(), query.organization()); emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "agentcard-result", agentCard)); @@ -52,7 +83,7 @@ public static List> runClientFlow( SampleLoggingFormatter.formatStageLog( "client", "agentcard-resolved", "url=" + endpoint.url() + " binding=" + endpoint.protocolBinding())); - String promptInput = buildPromptInput(); + String promptInput = buildPromptInput(language); emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "prompt-input", promptInput)); PromptGenerationResult promptResult = promptClient.generateTaskPrompt(promptInput); emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "prompt-generation-result", promptResult)); @@ -66,6 +97,7 @@ public static List> runClientFlow( "client", "request-built", "extension=" + builtRequest.callContext().getHeaders().get("A2A-Extensions"))); List> normalizedEvents = new ArrayList<>(); + int artifactCount = 0; for (ClientEvent rawEvent : a2aRuntime.sendMessage( agentCard, builtRequest.request(), builtRequest.callContext(), logSink)) { Map payload = A2AJavaClientEventMapper.toPayload(rawEvent); @@ -80,6 +112,11 @@ public static List> runClientFlow( } else if ("artifact".equals(normalizedEvent.get("kind"))) { emit(logSink, SampleLoggingFormatter.formatPayloadLog( "client", "task-artifact", normalizedEvent.get("artifact"))); + artifactCount++; + if (maxArtifacts > 0 && artifactCount >= maxArtifacts) { + emit(logSink, "[client] max-artifacts-reached: " + maxArtifacts); + break; + } } } return normalizedEvents; @@ -105,8 +142,8 @@ private static String buildRequestMetadata(Map scenarioPayload) return String.valueOf(scenarioPayload.getOrDefault("scenario", "")); } - private static String buildPromptInput() { - return NATURAL_LANGUAGE_PROMPT_INPUT; + private static String buildPromptInput(String language) { + return LANGUAGE_EN_US.equalsIgnoreCase(language) ? NATURAL_LANGUAGE_PROMPT_INPUT_EN : NATURAL_LANGUAGE_PROMPT_INPUT_ZH; } private static AgentCardQuery resolveAgentCardQuery(Map scenarioPayload) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java index 8bc5634d..3a0ad498 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java @@ -61,7 +61,7 @@ public DefaultSampleClientRuntime(Path envPath) { } public static Path resolveDefaultEnvPath() { - Path sampleEnvDir = Path.of("a2a-t-sample", "src", "main", "resources", "sample", "subscribe_incident", "client"); + Path sampleEnvDir = Path.of("a2a-t-sample", "src", "main", "resources", "sample", "subscribe-incident", "client"); return SampleEnvironmentPathResolver.resolve(sampleEnvDir, "client.env", "client.env"); } @@ -172,9 +172,9 @@ public Map queryAgentCardByName(String name, String organization } throw new ValueErrorException("Registry query by name returned no AgentCard entries"); } catch (IOException exception) { - throw new ValueErrorException("Registry query by name failed: " + exception.getMessage()); + throw new ValueErrorException("Registry query by name failed: " + errorMessage(exception)); } catch (InterruptedException exception) { - throw new ValueErrorException("Registry query by name was interrupted: " + exception.getMessage()); + throw new ValueErrorException("Registry query by name was interrupted: " + errorMessage(exception)); } } @@ -195,9 +195,10 @@ private Map queryAgentCardFromServerRoot(String agentCardUrl) { } return parseObject(response.body()); } catch (IOException exception) { - throw new ValueErrorException("AgentCard query from server root failed: " + exception.getMessage()); + throw new ValueErrorException("AgentCard query from server root failed: " + errorMessage(exception)); } catch (InterruptedException exception) { - throw new ValueErrorException("AgentCard query from server root was interrupted: " + exception.getMessage()); + throw new ValueErrorException( + "AgentCard query from server root was interrupted: " + errorMessage(exception)); } } } @@ -216,6 +217,11 @@ static String encodePathSegment(String value) { } } + private static String errorMessage(Throwable throwable) { + String message = throwable.getMessage(); + return message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message; + } + private Object createStreamingClient(Map agentCard) { AgentCard realAgentCard = RegistryAgentCardMapper.toA2AJavaAgentCard(agentCard); try { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java index f5bce7a2..bc455fc2 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java @@ -12,6 +12,7 @@ import net.openan.a2at.sample.subscribe_incident.server.runtime.SampleServerRuntimeFactory; import net.openan.a2at.sample.subscribe_incident.server.runtime.ServerBind; import net.openan.a2at.sample.subscribe_incident.server.runtime.ServerBootstrapResult; +import net.openan.a2at.sample.subscribe_incident.shared.mock.SampleMockLlmInstaller; import net.openan.a2at.sample.subscribe_incident.shared.registry.RegistryAgentCardMapper; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; @@ -22,6 +23,9 @@ */ public final class ServerSampleMain { + private static final String MOCK_RESOURCE_ROOT = + "sample/subscribe-incident/mock_responses"; + private ServerSampleMain() { } @@ -33,11 +37,17 @@ public static ServerBootstrapResult runMain( Path envPath, SampleServerRuntimeFactory runtimeFactory, Consumer logSink) { - SampleServerRuntime runtime = runtimeFactory.create(envPath); + boolean mockNeeded = SampleMockLlmInstaller.isMockNeeded(envPath); + SampleMockLlmInstaller.installLlmLogger(mockNeeded, "server"); + Path resolvedEnvPath = SampleMockLlmInstaller.resolveEnvPath(envPath, MOCK_RESOURCE_ROOT); + if (!resolvedEnvPath.equals(envPath)) { + emit(logSink, "[server] no LLM API key found, using mock LLM responses for e2e"); + } + SampleServerRuntime runtime = runtimeFactory.create(resolvedEnvPath); ServerBind bind = runtime.resolveBind(); emit(logSink, "[server] startup: host=" + bind.host() + " port=" + bind.port()); Map agentCard = runtime.buildAgentCard(bind.host(), bind.port()); - PromptComplianceChecker promptChecker = runtime.buildPromptChecker(envPath); + PromptComplianceChecker promptChecker = runtime.buildPromptChecker(resolvedEnvPath); Object app = runtime.buildApp(agentCard, promptChecker); Map registrationPayload; if (runtime instanceof A2AJavaServerRuntime a2aJavaServerRuntime @@ -46,7 +56,7 @@ public static ServerBootstrapResult runMain( } else { registrationPayload = ServerSampleAgentCardBuilder.buildRegistrationPayload(bind.host(), bind.port()); } - Map registrationResult = runtime.registerAgentCard(registrationPayload, envPath); + Map registrationResult = runtime.registerAgentCard(registrationPayload, resolvedEnvPath); if ("success".equals(registrationResult.get("status"))) { emit(logSink, "[server] agent-card registration: success"); } else { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java index b7af1e56..4e5f8761 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java @@ -128,7 +128,7 @@ public Map registerAgentCard(Map registrationPay } public static Path resolveDefaultEnvPath() { - Path sampleEnvDir = Path.of("a2a-t-sample", "src", "main", "resources", "sample", "subscribe_incident", "server"); + Path sampleEnvDir = Path.of("a2a-t-sample", "src", "main", "resources", "sample", "subscribe-incident", "server"); return SampleEnvironmentPathResolver.resolve(sampleEnvDir, "server.env", "server.env"); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java index 4742700c..770c42ec 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java @@ -102,11 +102,17 @@ private static Object normalizePayload(Object value, String keyName) throws IOEx private static boolean isSecretKey(String keyName) { String normalizedKeyName = keyName.toLowerCase(Locale.ROOT); for (String keyword : SECRET_KEYWORDS) { - if (normalizedKeyName.contains(keyword)) { + if (normalizedKeyName.equals(keyword)) { return true; } } - return false; + if (normalizedKeyName.endsWith("_key") + || normalizedKeyName.endsWith("_secret") + || normalizedKeyName.endsWith("_password")) { + return true; + } + // Avoid masking the harmless max_tokens field while still masking api_token etc. + return normalizedKeyName.endsWith("_token") && !normalizedKeyName.endsWith("max_tokens"); } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleLoggingLLMClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleLoggingLLMClient.java new file mode 100644 index 00000000..8a2a5dd7 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleLoggingLLMClient.java @@ -0,0 +1,89 @@ +package net.openan.a2at.sample.subscribe_incident.shared.mock; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sample.subscribe_incident.shared.logging.SampleLoggingFormatter; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.providers.OpenAIClient; + +/** + * Logging-aware LLM client wrapper used by the sample. + * + *

Wraps either the real {@link OpenAIClient} or the {@link SampleMockLLMClient} and logs the + * full request (messages, JSON schema, temperature, max tokens) and response (content, model, + * usage) for every structured call, mirroring the Python sample's {@code llm_logger}. In mock mode + * an extra {@code llm-mock} stage line marks that the canned response was used. + * + * @since 2026-08 + */ +public final class SampleLoggingLLMClient implements LLMClient { + + private static volatile boolean mockMode; + + private static volatile String role = "llm"; + + private static volatile Consumer logSink = System.out::println; + + private final LLMClient delegate; + + /** + * Configures the wrapper's behavior before any client is created. + * + * @param mockEnabled whether the delegate should be the mock client + * @param roleLabel log role label (for example {@code client} or {@code server}) + * @param sink log output sink (defaults to {@code System.out}) + */ + public static synchronized void configure(boolean mockEnabled, String roleLabel, Consumer sink) { + mockMode = mockEnabled; + if (roleLabel != null && !roleLabel.isBlank()) { + role = roleLabel.trim(); + } + if (sink != null) { + logSink = sink; + } + } + + /** + * Creates a logging LLM client. The supplied config is passed through to the delegate. + * + * @param config resolved LLM provider config + */ + public SampleLoggingLLMClient(LLMClientConfig config) { + if (mockMode) { + this.delegate = new SampleMockLLMClient(config); + } else { + this.delegate = new OpenAIClient(config); + } + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + logSink.accept(SampleLoggingFormatter.formatPayloadLog( + role, + "llm-request", + Map.of( + "messages", messages, + "json_schema", jsonSchema, + "temperature", temperature == null ? "null" : temperature, + "max_tokens", maxTokens == null ? "null" : maxTokens))); + LLMResponse response = delegate.structured(messages, jsonSchema, temperature, maxTokens); + if (mockMode) { + logSink.accept(SampleLoggingFormatter.formatStageLog(role, "llm-mock", "using canned mock LLM response")); + } + logSink.accept(SampleLoggingFormatter.formatPayloadLog( + role, + "llm-response", + Map.of( + "content", response.content(), + "model", response.model(), + "usage", response.usage()))); + return response; + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLLMClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLLMClient.java new file mode 100644 index 00000000..9ff281be --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLLMClient.java @@ -0,0 +1,111 @@ +package net.openan.a2at.sample.subscribe_incident.shared.mock; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMResponse; + +/** + * Deterministic LLM client used by the sample when no real API key is available. + * + *

Returns canned structured responses for the three SDK LLM steps, in call order: scenario + * recognition, slot extraction, then semantic validation. The canned responses are loaded from + * {@code mock_responses} JSON resources on the classpath and mirror what a real LLM would return + * for the bundled subscribe-incident templates, so the end-to-end sample flow runs without any + * external LLM service. Each process keeps its own call cursor, which matches the separate client + * and server runtimes used by the sample e2e flow. + * + * @since 2026-08 + */ +public final class SampleMockLLMClient implements LLMClient { + + private static final String DEFAULT_RESOURCE_ROOT = "sample/subscribe-incident/mock_responses"; + + private static final String DEFAULT_LANGUAGE = "zh-CN"; + + private static volatile String resourceRoot = DEFAULT_RESOURCE_ROOT; + + private static volatile String language = DEFAULT_LANGUAGE; + + private static volatile List responses; + + private final AtomicInteger callIndex = new AtomicInteger(); + + /** + * Configures the classpath resource root and language used to load canned responses. Called by + * {@link SampleMockLlmInstaller} before the mock client is first used. + * + * @param resourceRoot classpath directory containing {@code } subdirectories + * @param language locale identifier used to select the response files + */ + public static synchronized void configure(String resourceRoot, String language) { + SampleMockLLMClient.resourceRoot = resourceRoot == null || resourceRoot.isBlank() + ? DEFAULT_RESOURCE_ROOT + : resourceRoot; + SampleMockLLMClient.language = language == null || language.isBlank() ? DEFAULT_LANGUAGE : language.trim(); + SampleMockLLMClient.responses = null; + } + + /** + * Creates a mock LLM client. The supplied config is ignored because responses are canned. + * + * @param config resolved LLM provider config (unused) + */ + public SampleMockLLMClient(LLMClientConfig config) { + // Responses are canned; the config is not needed. + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + List mockResponses = responses(); + String content = mockResponses.get(Math.floorMod(callIndex.getAndIncrement(), mockResponses.size())); + return new LLMResponse( + content, + "mock-llm", + Map.of("prompt_tokens", 0, "completion_tokens", 0, "total_tokens", 0), + Map.of()); + } + + private static List responses() { + List current = responses; + if (current == null) { + synchronized (SampleMockLLMClient.class) { + current = responses; + if (current == null) { + current = loadResponses(); + responses = current; + } + } + } + return current; + } + + private static List loadResponses() { + String directory = resourceRoot + "/" + language; + return List.of( + loadResource(directory + "/scenario_recognition.json"), + loadResource(directory + "/slot_extraction.json"), + loadResource(directory + "/semantic_validation.json")); + } + + private static String loadResource(String resourcePath) { + try (InputStream inputStream = SampleMockLLMClient.class.getClassLoader().getResourceAsStream(resourcePath)) { + if (inputStream == null) { + throw new ValueErrorException("Mock LLM response resource not found: " + resourcePath); + } + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new ValueErrorException("Failed to read mock LLM response resource: " + resourcePath, exception); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLlmInstaller.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLlmInstaller.java new file mode 100644 index 00000000..21f07625 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLlmInstaller.java @@ -0,0 +1,192 @@ +package net.openan.a2at.sample.subscribe_incident.shared.mock; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientFactory; + +/** + * Installs a mock LLM mode for the sample when no real LLM API key is available. + * + *

When {@code A2AT_LLM_API_KEY} is missing, blank, or a placeholder template such as + * {@code ${your_api_key}}, the sample installs {@link SampleMockLLMClient} as the provider used by + * the SDK and rewrites the environment file with a non-empty placeholder key. The mock client + * returns canned structured responses loaded from the specified classpath resource root, so the + * end-to-end sample flow runs deterministically on CI environments that have no LLM API key. When a + * real API key is present, the original environment file is used unchanged and no mock is installed. + * + * @since 2026-08 + */ +public final class SampleMockLlmInstaller { + + private static final String API_KEY_VAR = "A2AT_LLM_API_KEY"; + + private static final String PROVIDER_VAR = "A2AT_LLM_PROVIDER"; + + private static final String MODEL_VAR = "A2AT_LLM_MODEL"; + + private static final String BASE_URL_VAR = "A2AT_LLM_BASE_URL"; + + private static final String LANGUAGE_VAR = "A2AT_LANGUAGE"; + + private static final String OPENAI_PROVIDER = "openai"; + + private static final String MOCK_MODEL = "mock-model"; + + private static final String MOCK_API_KEY = "mock-key-not-real"; + + private static final String MOCK_BASE_URL = "http://localhost:0"; + + private static volatile boolean mockInstalled; + + private SampleMockLlmInstaller() {} + + /** + * Returns whether the sample should fall back to the mock LLM provider. + * + *

The mock fallback is needed when no usable real LLM configuration is present: a missing, + * blank, or placeholder {@code A2AT_LLM_API_KEY}, or a missing/blank {@code A2AT_LLM_MODEL} or + * {@code A2AT_LLM_BASE_URL}. When the environment file lacks any of these, the SDK's + * {@code LLMConfigLoader} would fail at startup, so the mock is installed instead. + * + * @param envPath sample environment file path + * @return {@code true} when no usable real LLM configuration is present + */ + public static boolean isMockNeeded(Path envPath) { + Map values = readEnv(envPath); + String apiKey = values.get(API_KEY_VAR); + String model = values.get(MODEL_VAR); + String baseUrl = values.get(BASE_URL_VAR); + boolean keyMissing = apiKey == null || apiKey.isBlank() || apiKey.contains("${"); + boolean modelMissing = model == null || model.isBlank(); + boolean baseUrlMissing = baseUrl == null || baseUrl.isBlank(); + return keyMissing || modelMissing || baseUrlMissing; + } + + /** + * Installs the logging-aware LLM client wrapper as the {@code openai} provider. + * + *

This method must be called once at startup, before any {@code A2ATClient} or + * {@code A2ATServer} is constructed, so that every LLM-structured call is logged. The wrapper + * delegates to the real {@code OpenAIClient} when {@code mockEnabled} is false, or to + * {@link SampleMockLLMClient} when it is true. + * + * @param mockEnabled whether the delegate should be the mock client + * @param roleLabel log role label (for example {@code client} or {@code server}) + */ + public static synchronized void installLlmLogger(boolean mockEnabled, String roleLabel) { + if (mockInstalled) { + return; + } + SampleLoggingLLMClient.configure(mockEnabled, roleLabel, System.out::println); + try { + Field clientsField = LLMClientFactory.class.getDeclaredField("CLIENTS"); + clientsField.setAccessible(true); + @SuppressWarnings("unchecked") + Map> clients = + (Map>) clientsField.get(null); + clients.put(OPENAI_PROVIDER, SampleLoggingLLMClient.class); + mockInstalled = true; + } catch (ReflectiveOperationException exception) { + throw new ValueErrorException("Failed to install logging LLM client", exception); + } + } + + /** + * Returns whether the logging-aware LLM client wrapper has been installed. + * + * @return {@code true} if the logger was installed + */ + public static boolean isLoggerInstalled() { + return mockInstalled; + } + + /** + * Resolves the environment file path that should be used for the current run, using the default + * mock resource root {@code sample/subscribe-incident/mock_responses}. + * + * @param envPath sample environment file path + * @return the environment file path to use for this run + * @see #resolveEnvPath(Path, String) + */ + public static Path resolveEnvPath(Path envPath) { + return resolveEnvPath(envPath, "sample/subscribe-incident/mock_responses"); + } + + /** + * Resolves the environment file path that should be used for the current run. + * + *

When a mock fallback is needed, the mock client is configured with the supplied classpath + * resource root (and the language read from the env file), then a temporary environment file is + * written next to the original file (so relative prompt-resource paths keep resolving correctly) + * with a non-empty placeholder API key. Otherwise the original path is returned unchanged. + * + * @param envPath sample environment file path + * @param mockResourceRoot classpath root for mock LLM response JSON files + * @return the environment file path to use for this run + */ + public static Path resolveEnvPath(Path envPath, String mockResourceRoot) { + if (envPath == null || !isMockNeeded(envPath)) { + return envPath; + } + Map env = readEnv(envPath); + String language = env.get(LANGUAGE_VAR); + SampleMockLLMClient.configure(mockResourceRoot, language); + return writeMockEnvFile(envPath); + } + + private static String stripBom(String text) { + return text != null && text.startsWith("\uFEFF") ? text.substring(1) : text; + } + + private static Map readEnv(Path envPath) { + if (envPath == null || !Files.exists(envPath)) { + return Map.of(); + } + try { + Map values = new LinkedHashMap<>(); + for (String rawLine : Files.readAllLines(envPath)) { + String line = stripBom(rawLine.trim()); + if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { + continue; + } + int separatorIndex = line.indexOf('='); + String key = line.substring(0, separatorIndex).trim(); + String value = line.substring(separatorIndex + 1).trim(); + if (!key.isEmpty() && !value.isEmpty()) { + values.put(key, value); + } + } + return values; + } catch (IOException exception) { + throw new ValueErrorException("Failed to read env file for mock LLM fallback: " + envPath, exception); + } + } + + private static Path writeMockEnvFile(Path originalEnvPath) { + Map values = new LinkedHashMap<>(readEnv(originalEnvPath)); + values.putIfAbsent(PROVIDER_VAR, OPENAI_PROVIDER); + values.putIfAbsent(MODEL_VAR, MOCK_MODEL); + values.put(API_KEY_VAR, MOCK_API_KEY); + values.putIfAbsent(BASE_URL_VAR, MOCK_BASE_URL); + Path parent = originalEnvPath.toAbsolutePath().getParent(); + try { + Path tempFile = Files.createTempFile(parent, "a2a-t-sample-mock-", ".env"); + StringBuilder content = new StringBuilder(); + content.append("# Mock LLM environment for sample runs without a real API key.\n"); + for (Map.Entry entry : values.entrySet()) { + content.append(entry.getKey()).append('=').append(entry.getValue()).append('\n'); + } + Files.writeString(tempFile, content.toString(), StandardCharsets.UTF_8); + return tempFile; + } catch (IOException exception) { + throw new ValueErrorException("Failed to create mock LLM environment file", exception); + } + } +} \ No newline at end of file diff --git a/a2a-t-sample/src/main/resources/sample/subscribe_incident/client/client.env b/a2a-t-sample/src/main/resources/sample/subscribe-incident/client/client.env similarity index 94% rename from a2a-t-sample/src/main/resources/sample/subscribe_incident/client/client.env rename to a2a-t-sample/src/main/resources/sample/subscribe-incident/client/client.env index 93efbc3e..e5a71c5f 100644 --- a/a2a-t-sample/src/main/resources/sample/subscribe_incident/client/client.env +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/client/client.env @@ -6,7 +6,7 @@ A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY=${your_api_key} +A2AT_LLM_API_KEY= A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory REGISTRY_CENTER_HOST=127.0.0.1 REGISTRY_CENTER_PORT=5001 diff --git a/a2a-t-sample/src/main/resources/sample/subscribe_incident/client/scenario.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/client/scenario.json similarity index 100% rename from a2a-t-sample/src/main/resources/sample/subscribe_incident/client/scenario.json rename to a2a-t-sample/src/main/resources/sample/subscribe-incident/client/scenario.json diff --git a/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/scenario_recognition.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/scenario_recognition.json new file mode 100644 index 00000000..31fd38a7 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/scenario_recognition.json @@ -0,0 +1,5 @@ +{ + "matched": true, + "scenario_code": "subscribe-incident", + "error_message": null +} diff --git a/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/semantic_validation.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/semantic_validation.json new file mode 100644 index 00000000..8417d943 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/semantic_validation.json @@ -0,0 +1,4 @@ +{ + "passed": true, + "errors": [] +} diff --git a/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/slot_extraction.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/slot_extraction.json new file mode 100644 index 00000000..ea329198 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/slot_extraction.json @@ -0,0 +1,8 @@ +{ + "slots": { + "notification_topic": "Incident", + "subscribe_condition": "critical ETH-LOS fault", + "notification_data_format": "DataPart" + }, + "slot_errors": [] +} diff --git a/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/scenario_recognition.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/scenario_recognition.json new file mode 100644 index 00000000..31fd38a7 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/scenario_recognition.json @@ -0,0 +1,5 @@ +{ + "matched": true, + "scenario_code": "subscribe-incident", + "error_message": null +} diff --git a/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/semantic_validation.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/semantic_validation.json new file mode 100644 index 00000000..8417d943 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/semantic_validation.json @@ -0,0 +1,4 @@ +{ + "passed": true, + "errors": [] +} diff --git a/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/slot_extraction.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/slot_extraction.json new file mode 100644 index 00000000..bcb231c9 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/slot_extraction.json @@ -0,0 +1,8 @@ +{ + "slots": { + "通知主题": "Incident", + "订阅条件": "订阅级别为critical的ETH-LOS的故障", + "上报通知数据格式": "DataPart" + }, + "slot_errors": [] +} diff --git a/a2a-t-sample/src/main/resources/sample/subscribe_incident/server/server.env b/a2a-t-sample/src/main/resources/sample/subscribe-incident/server/server.env similarity index 94% rename from a2a-t-sample/src/main/resources/sample/subscribe_incident/server/server.env rename to a2a-t-sample/src/main/resources/sample/subscribe-incident/server/server.env index 8be04595..456c61bb 100644 --- a/a2a-t-sample/src/main/resources/sample/subscribe_incident/server/server.env +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/server/server.env @@ -6,7 +6,7 @@ A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=../../../../../../a2a-t-resources/src/main/r A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY=${your_api_key} +A2AT_LLM_API_KEY= A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory A2AT_SAMPLE_HOST=127.0.0.1 A2AT_SAMPLE_PORT=26335 From 6d08da54b0ad12fba04288503ee65076e4cef0a3 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Thu, 20 Aug 2026 17:08:56 +0800 Subject: [PATCH 047/295] refactor(a2a-t-core): centralize Negotiation-T extension URIs in ExtensionUriConstants Signed-off-by: codetalks-hw --- .../sdk/core/model/ExtensionUriConstants.java | 17 +++++++++++++++++ .../negotiation/runtime/NegotiationHandler.java | 7 +++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/ExtensionUriConstants.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/ExtensionUriConstants.java index d434f9db..3b440d47 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/ExtensionUriConstants.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/ExtensionUriConstants.java @@ -24,4 +24,21 @@ private ExtensionUriConstants() { /** URI for the Notification-T extension. */ public static final String NOTIFICATION_T_EXTENSION_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + /** + * Canonical URI for the Negotiation-T extension. + * + *

This is the URI under which generated negotiation messages travel in A2A-T metadata. + */ + public static final String NEGOTIATION_T_EXTENSION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1"; + + /** + * Legacy alias URI for the Negotiation-T extension. + * + *

Kept for runtime compatibility reads of messages emitted under the NL naming; new metadata emission uses + * {@link #NEGOTIATION_T_EXTENSION_URI}. + */ + public static final String NEGOTIATION_T_EXTENSION_URI_NL = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/NL/v1"; } \ No newline at end of file diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java index 51af232c..bc841dc9 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.UUID; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.negotiation.handler.Negotiation; import net.openan.a2at.sdk.negotiation.runtime.helper.NegotiationPayloadMapper; import net.openan.a2at.sdk.negotiation.store.NegotiationStore; @@ -17,11 +18,9 @@ */ public final class NegotiationHandler { - public static final String NEGOTIATION_T_URI_NL = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/NL/v1"; + public static final String NEGOTIATION_T_URI_NL = ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI_NL; - public static final String NEGOTIATION_T_URI = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1"; + public static final String NEGOTIATION_T_URI = ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI; /** * @deprecated Use {@link #NEGOTIATION_T_URI_NL} instead. From 0763405cbbaa2e8a89bef189dc087f451e021d8a Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Thu, 20 Aug 2026 17:29:14 +0800 Subject: [PATCH 048/295] refactor(a2a-t-sdk): unify MetadataContent in core model Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 14 ++-- .../sdk/client/model/MetadataContent.java | 27 ------- .../ClientPromptGenerationOrchestrator.java | 2 +- ...ultClientPromptGenerationOrchestrator.java | 2 +- .../api/A2ATClientNegotiationApiTest.java | 2 +- .../A2ATClientNegotiationEnvConfigTest.java | 2 +- .../a2at/sdk/client/api/A2ATClientTest.java | 2 +- .../sdk/client/model/MetadataContentTest.java | 55 ------------- ...lientPromptGenerationOrchestratorTest.java | 2 +- .../a2at/sdk/core/model}/MetadataContent.java | 77 ++++++++----------- .../sdk/core/model/MetadataContentTest.java | 72 +++++++++++++++++ .../NegotiationGenerationOrchestrator.java | 6 +- .../NegotiationConcurrentMixedLoadTest.java | 2 +- .../content/MetadataContentTest.java | 45 ----------- .../NegotiationPackageStructureTest.java | 1 - .../generation/FromTextLlmPipelineTest.java | 2 +- ...egotiationContextSectionRenderingTest.java | 2 +- ...nerationOrchestratorBuilderWiringTest.java | 2 +- ...NegotiationGenerationOrchestratorTest.java | 2 +- .../TemplateUriEntryMatrixTest.java | 2 +- .../ValidateAndFillingDataPipelineTest.java | 2 +- .../golden/GoldenFixtureComparisonTest.java | 2 +- .../golden/GoldenFixtureGeneratorTest.java | 2 +- .../sdk/negotiation/golden/GoldenInputs.java | 2 +- .../CustomRootPromptsIgnoredTest.java | 2 +- .../CustomRootTemplateOverrideTest.java | 2 +- .../NegotiationLanguageSwitchTest.java | 2 +- .../NegotiationLogEventContractTest.java | 2 +- .../BilateralInteropScenarioTest.java | 2 +- .../FeasibilityNegotiationScenarioTest.java | 2 +- .../InformationNegotiationScenarioTest.java | 2 +- .../MaxRoundsExhaustionScenarioTest.java | 2 +- .../TargetNegotiationScenarioTest.java | 2 +- .../NegotiationFacadeOutputSymmetryTest.java | 2 +- .../api/NegotiationV3ApiSurfaceTest.java | 2 +- .../openan/a2at/sdk/server/A2ATServer.java | 2 +- .../api/A2ATServerNegotiationApiTest.java | 2 +- .../TaskPromptNegotiationCoexistenceTest.java | 2 +- 38 files changed, 146 insertions(+), 211 deletions(-) delete mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/model/MetadataContent.java delete mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java rename {a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content => a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model}/MetadataContent.java (62%) create mode 100644 a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/MetadataContentTest.java diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 65d6ab97..cda3ebbc 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -8,9 +8,9 @@ import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.negotiation.content.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; @@ -67,7 +67,7 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI */ - public net.openan.a2at.sdk.client.model.MetadataContent generateTaskPromptFromText( + public MetadataContent generateTaskPromptFromText( String text, String templateUri) { return promptGenerationOrchestrator.generateTaskPromptFromText(text, templateUri); } @@ -81,7 +81,7 @@ public net.openan.a2at.sdk.client.model.MetadataContent generateTaskPromptFromTe * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI */ - public net.openan.a2at.sdk.client.model.MetadataContent generateTaskPromptFromDataWithSchema( + public MetadataContent generateTaskPromptFromDataWithSchema( Map data, Map schema, String templateUri) { return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, templateUri); } @@ -94,7 +94,7 @@ public net.openan.a2at.sdk.client.model.MetadataContent generateTaskPromptFromDa * @param authorizationType authorization type used as the template identifier * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI */ - public net.openan.a2at.sdk.client.model.MetadataContent generateAuthPromptFromText( + public MetadataContent generateAuthPromptFromText( String text, String authorizationType) { return promptGenerationOrchestrator.generateAuthPromptFromText(text, authorizationType); } @@ -108,7 +108,7 @@ public net.openan.a2at.sdk.client.model.MetadataContent generateAuthPromptFromTe * @param authorizationType authorization type used as the template identifier * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI */ - public net.openan.a2at.sdk.client.model.MetadataContent generateAuthPromptFromDataWithSchema( + public MetadataContent generateAuthPromptFromDataWithSchema( Map data, Map schema, String authorizationType) { return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, authorizationType); } @@ -121,7 +121,7 @@ public net.openan.a2at.sdk.client.model.MetadataContent generateAuthPromptFromDa * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI */ - public net.openan.a2at.sdk.client.model.MetadataContent generateNotificationPromptFromText( + public MetadataContent generateNotificationPromptFromText( String text, String templateUri) { return promptGenerationOrchestrator.generateNotificationPromptFromText(text, templateUri); } @@ -135,7 +135,7 @@ public net.openan.a2at.sdk.client.model.MetadataContent generateNotificationProm * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI */ - public net.openan.a2at.sdk.client.model.MetadataContent generateNotificationPromptFromDataWithSchema( + public MetadataContent generateNotificationPromptFromDataWithSchema( Map data, Map schema, String templateUri) { return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, templateUri); } diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/model/MetadataContent.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/model/MetadataContent.java deleted file mode 100644 index 308ccfb2..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/model/MetadataContent.java +++ /dev/null @@ -1,27 +0,0 @@ -package net.openan.a2at.sdk.client.model; - -import java.util.Map; - -/** - * Metadata content used in A2A-T prompt generation. - * - * @param templateUri URI of the template - * @param promptText rendered prompt text - * @param extensionUri URI of the extension - * @since 2026-08 - */ -public record MetadataContent(String templateUri, String promptText, String extensionUri) { - - /** - * Builds a metadata map keyed by the extension URI and template URI. - * - * @return metadata map containing the prompt text under the extension URI key and the template URI under the - * {@code template_uri} key, or {@code null} if any field is null - */ - public Map buildMetadataContent() { - if (extensionUri == null || promptText == null || templateUri == null) { - return null; - } - return Map.of(extensionUri, promptText, "template_uri", templateUri); - } -} \ No newline at end of file diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java index 7cc2320d..27c36676 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java @@ -1,7 +1,7 @@ package net.openan.a2at.sdk.client.prompt.orchestration; import java.util.Map; -import net.openan.a2at.sdk.client.model.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; /** diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java index 3521705e..c548cd8f 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -5,7 +5,7 @@ import java.util.Map; import java.util.regex.Pattern; import java.util.stream.Collectors; -import net.openan.a2at.sdk.client.model.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationFailure; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index 508d630c..722c5b9c 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -11,7 +11,7 @@ import java.util.Map; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index c0954c13..09ee41d2 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -18,7 +18,7 @@ import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index eb2862c5..2cbdc68f 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -11,7 +11,7 @@ import java.nio.file.Path; import java.util.Arrays; import java.util.Map; -import net.openan.a2at.sdk.client.model.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; import net.openan.a2at.sdk.core.exception.PromptGenerationException; diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java deleted file mode 100644 index a68045ea..00000000 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/model/MetadataContentTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package net.openan.a2at.sdk.client.model; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.Map; -import org.junit.jupiter.api.Test; - -class MetadataContentTest { - - @Test - void exposesAllFieldsViaAccessorMethods() { - MetadataContent content = new MetadataContent("template-uri", "prompt-text", "extension-uri"); - - assertEquals("template-uri", content.templateUri()); - assertEquals("prompt-text", content.promptText()); - assertEquals("extension-uri", content.extensionUri()); - } - - @Test - void recordsWithSameValuesAreEqual() { - MetadataContent first = new MetadataContent("template-uri", "prompt-text", "extension-uri"); - MetadataContent second = new MetadataContent("template-uri", "prompt-text", "extension-uri"); - - assertEquals(first, second); - assertNotSame(first, second); - } - - @Test - void exposesNoStaticFactoryMethods() { - assertThrows(NoSuchMethodException.class, () -> MetadataContent.class.getMethod("success")); - assertThrows(NoSuchMethodException.class, () -> MetadataContent.class.getMethod("failure")); - } - - @Test - void buildMetadataContentReturnsMapWithExtensionUriAndTemplateUri() { - MetadataContent content = new MetadataContent( - "energy-saving", "Site: Site A", - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"); - - Map result = content.buildMetadataContent(); - - assertEquals(2, result.size()); - assertEquals("Site: Site A", result.get( - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1")); - assertEquals("energy-saving", result.get("template_uri")); - } - - @Test - void buildMetadataContentReturnsNullWhenAnyFieldIsNull() { - MetadataContent content = new MetadataContent(null, null, "extension-uri"); - assertEquals(null, content.buildMetadataContent()); - } -} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index a173b102..ca9a36b6 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -8,7 +8,7 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.client.model.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/MetadataContent.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java similarity index 62% rename from a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/MetadataContent.java rename to a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java index b4b78b5f..6076004c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/MetadataContent.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java @@ -1,44 +1,33 @@ -package net.openan.a2at.sdk.negotiation.content; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; - -/** - * Successful result of a negotiation message generation call. - * - * @param templateUri URI of the template the message was generated from - * @param promptText rendered negotiation message text - * @param extensionUri TMF extension URI under which the message travels in A2A-T metadata - * @since 2026-06 - */ -public record MetadataContent(String templateUri, String promptText, String extensionUri) { - - /** Metadata key carrying the template URI alongside the message itself. */ - public static final String TEMPLATE_URI_METADATA_KEY = "template_uri"; - - /** - * Creates a metadata content result carrying the standard negotiation extension URI. - * - * @param templateUri URI of the template the message was generated from - * @param promptText rendered negotiation message text - */ - public MetadataContent(String templateUri, String promptText) { - this(templateUri, promptText, NegotiationHandler.NEGOTIATION_T_URI); - } - - /** - * Builds the A2A-T metadata map for this generated message. - * - *

The returned map contains exactly two keys in a fixed order: the extension URI mapping to the rendered - * message, and {@code template_uri} mapping to the template URI. Repeated calls return equal maps. - * - * @return newly built metadata map with exactly the extension URI and {@code template_uri} keys - */ - public Map buildMetadataContent() { - Map metadata = new LinkedHashMap<>(); - metadata.put(extensionUri, promptText); - metadata.put(TEMPLATE_URI_METADATA_KEY, templateUri); - return metadata; - } -} +package net.openan.a2at.sdk.core.model; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Successful result of an A2A-T prompt generation call. + * + * @param templateUri URI of the template the message was generated from + * @param promptText rendered prompt text + * @param extensionUri TMF extension URI under which the message travels in A2A-T metadata + * @since 2026-08 + */ +public record MetadataContent(String templateUri, String promptText, String extensionUri) { + + /** Metadata key carrying the template URI alongside the message itself. */ + public static final String TEMPLATE_URI_METADATA_KEY = "template_uri"; + + /** + * Builds the A2A-T metadata map for this generated message. + * + *

The returned map contains exactly two keys in a fixed order: the extension URI mapping to the rendered + * message, and {@code template_uri} mapping to the template URI. Repeated calls return equal maps. + * + * @return newly built metadata map with exactly the extension URI and {@code template_uri} keys + */ + public Map buildMetadataContent() { + Map metadata = new LinkedHashMap<>(); + metadata.put(extensionUri, promptText); + metadata.put(TEMPLATE_URI_METADATA_KEY, templateUri); + return metadata; + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java new file mode 100644 index 00000000..0cb09bf2 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java @@ -0,0 +1,72 @@ +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class MetadataContentTest { + + private static final String TEMPLATE_URI = "Negotiation-T/v1/information-negotiation/propose"; + + @Test + void recordExposesAllThreeComponents() { + MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message", "https://example/ext"); + + assertEquals(TEMPLATE_URI, content.templateUri()); + assertEquals("rendered message", content.promptText()); + assertEquals("https://example/ext", content.extensionUri()); + } + + @Test + void recordsWithSameValuesAreEqual() { + MetadataContent first = new MetadataContent("template-uri", "prompt-text", "extension-uri"); + MetadataContent second = new MetadataContent("template-uri", "prompt-text", "extension-uri"); + + assertEquals(first, second); + assertNotSame(first, second); + } + + @Test + void exposesNoStaticFactoryMethods() { + assertThrows(NoSuchMethodException.class, () -> MetadataContent.class.getMethod("success")); + assertThrows(NoSuchMethodException.class, () -> MetadataContent.class.getMethod("failure")); + } + + @Test + void buildMetadataContentReturnsExactlyTwoDeterministicEntries() { + MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message", "https://example/ext"); + + Map metadata = content.buildMetadataContent(); + + assertEquals(2, metadata.size()); + assertEquals("rendered message", metadata.get(content.extensionUri())); + assertEquals(TEMPLATE_URI, metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(metadata, content.buildMetadataContent()); + assertTrue(metadata.containsKey(content.extensionUri())); + } + + @Test + void buildMetadataContentKeepsFixedKeyOrder() { + MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message", "https://example/ext"); + + Map metadata = content.buildMetadataContent(); + + assertEquals( + java.util.List.of(content.extensionUri(), MetadataContent.TEMPLATE_URI_METADATA_KEY), + new java.util.ArrayList<>(metadata.keySet())); + } + + @Test + void buildMetadataContentNeverReturnsNullEvenWithNullFields() { + MetadataContent content = new MetadataContent(null, null, "extension-uri"); + + Map metadata = content.buildMetadataContent(); + + assertEquals(2, metadata.size()); + assertTrue(metadata.containsKey("template_uri")); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 73de1199..01e31cc5 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -7,9 +7,10 @@ import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.negotiation.content.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -430,7 +431,8 @@ private MetadataContent completeGeneration( reference.phase(), context.round(), context.id()); - return new MetadataContent(reference.uri(), promptText); + return new MetadataContent( + reference.uri(), promptText, ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI); } private FilledParamData validateAndFilling( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java index c8cd88d2..06f2a914 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java @@ -15,7 +15,7 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/MetadataContentTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/MetadataContentTest.java deleted file mode 100644 index bc482724..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/MetadataContentTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package net.openan.a2at.sdk.negotiation.content; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Map; -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; -import org.junit.jupiter.api.Test; - -class MetadataContentTest { - - private static final String TEMPLATE_URI = "Negotiation-T/v1/information-negotiation/propose"; - - @Test - void recordExposesAllThreeComponents() { - MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message", "https://example/ext"); - - assertEquals(TEMPLATE_URI, content.templateUri()); - assertEquals("rendered message", content.promptText()); - assertEquals("https://example/ext", content.extensionUri()); - } - - @Test - void convenienceConstructorAppliesNegotiationExtensionUri() { - MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message"); - - assertEquals( - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", - content.extensionUri()); - assertEquals(NegotiationHandler.NEGOTIATION_T_URI, content.extensionUri()); - } - - @Test - void buildMetadataContentReturnsExactlyTwoDeterministicEntries() { - MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message"); - - Map metadata = content.buildMetadataContent(); - - assertEquals(2, metadata.size()); - assertEquals("rendered message", metadata.get(content.extensionUri())); - assertEquals(TEMPLATE_URI, metadata.get("template_uri")); - assertEquals(content.buildMetadataContent(), metadata); - assertTrue(metadata.containsKey(NegotiationHandler.NEGOTIATION_T_URI)); - } -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java index f05ae03b..10e6e485 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -28,7 +28,6 @@ void contentPackageExposesExactlyThePinnedTypes() throws IOException { "FilledParamData.java", "InfoEndingContent.java", "InfoProposeContent.java", - "MetadataContent.java", "NegotiationAction.java", "NegotiationConclusion.java", "NegotiationContent.java", diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 53f20f8f..6eefe2e7 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -28,7 +28,7 @@ import net.openan.a2at.sdk.negotiation.content.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextSectionRenderingTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextSectionRenderingTest.java index a306ca09..fe9bb380 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextSectionRenderingTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextSectionRenderingTest.java @@ -6,7 +6,7 @@ import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.golden.GoldenInputs; import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; import org.junit.jupiter.params.ParameterizedTest; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java index 00952829..369c9e22 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java @@ -18,7 +18,7 @@ import net.openan.a2at.sdk.llm.LLMError; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 8bb6ef38..7865d7f5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -13,7 +13,7 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index 02841350..6b1f4efe 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -19,7 +19,7 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java index 0aec6a93..3757c6d5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java @@ -18,7 +18,7 @@ import net.openan.a2at.sdk.negotiation.content.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java index 8417af8e..a38856e1 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java @@ -15,7 +15,7 @@ import java.util.stream.Stream; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java index 41192c57..62a387f6 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java @@ -4,7 +4,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import org.junit.jupiter.api.Disabled; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java index 1bd756b7..2655ca2b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java @@ -5,7 +5,7 @@ import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java index 54688f00..f0b13fd6 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java @@ -12,7 +12,7 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java index b4356677..6baea0a5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java @@ -9,7 +9,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java index 9b876b5f..7c79c984 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java @@ -9,7 +9,7 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.stream.Stream; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import net.openan.a2at.sdk.negotiation.golden.GoldenInputs; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index f2f6ed72..98ed4e27 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -19,7 +19,7 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java index 33fe32fb..eb34af89 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java @@ -8,7 +8,7 @@ import net.openan.a2at.sdk.negotiation.content.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java index c4dd627e..38fdb9a5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java @@ -14,7 +14,7 @@ import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java index 473df3e6..bc539fce 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java @@ -10,7 +10,7 @@ import net.openan.a2at.sdk.negotiation.content.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java index e7221e05..c6e7fe86 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java @@ -8,7 +8,7 @@ import java.util.Map; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.negotiation.content.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java index 79af726e..d5f4511e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java @@ -6,7 +6,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.negotiation.content.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index 77103936..c758ee59 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -17,7 +17,7 @@ import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java index f8da93b5..f0c41261 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java @@ -14,7 +14,7 @@ import java.util.stream.Collectors; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.negotiation.content.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.server.A2ATServer; import org.junit.jupiter.api.Test; diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 948dba57..cf740376 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -5,8 +5,8 @@ import java.util.Map; import java.util.Optional; import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index 1d3535a8..e350f10b 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -10,7 +10,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index 2ab859d0..55f95f32 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -11,7 +11,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.MetadataContent; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; From 6d6d72209ebf7867595e7caa17eba5bd4e3ac703 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Thu, 20 Aug 2026 17:43:26 +0800 Subject: [PATCH 049/295] refactor(a2a-t-negotiation): extract shared NegotiationContentService for client and server facades Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 46 ++-- .../assembly/DefaultA2ATClientBuilder.java | 16 +- .../content/NegotiationContentService.java | 256 ++++++++++++++++++ .../NegotiationPackageStructureTest.java | 1 + .../openan/a2at/sdk/server/A2ATServer.java | 45 ++- .../assembly/DefaultA2ATServerBuilder.java | 16 +- 6 files changed, 301 insertions(+), 79 deletions(-) create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index cda3ebbc..592bde62 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -9,11 +9,10 @@ import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.negotiation.content.FilledParamData; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; @@ -32,7 +31,7 @@ public final class A2ATClient { private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; - private final NegotiationGenerationOrchestrator negotiationGenerationOrchestrator; + private final NegotiationContentService negotiationContentService; /** * Creates a client facade from one user-supplied `.env` path. @@ -41,12 +40,13 @@ public final class A2ATClient { */ public A2ATClient(Path envPath) { Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); - A2ATConfig config = resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + A2ATConfig config = + NegotiationContentService.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); - this.negotiationGenerationOrchestrator = builder.buildNegotiationGenerationOrchestrator(); + this.negotiationContentService = new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); } /** @@ -193,7 +193,7 @@ public Map continueNegotiation( * {@code negotiation_slot_missing} when rendering the template fails */ public MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, String templateUri) { - return negotiationGenerationOrchestrator.generateProposeFromData(data, templateUri); + return negotiationContentService.generateProposeFromData(data, templateUri); } /** @@ -214,7 +214,7 @@ public MetadataContent generateNegotiationProposePromptFromData(NegotiationPropo * {@code negotiation_slot_missing} when rendering the template fails */ public MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, String templateUri) { - return negotiationGenerationOrchestrator.generateAcceptFromData(data, templateUri); + return negotiationContentService.generateAcceptFromData(data, templateUri); } /** @@ -235,7 +235,7 @@ public MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEnding * {@code negotiation_slot_missing} when rendering the template fails */ public MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, String templateUri) { - return negotiationGenerationOrchestrator.generateRejectFromData(data, templateUri); + return negotiationContentService.generateRejectFromData(data, templateUri); } /** @@ -262,7 +262,7 @@ public MetadataContent generateNegotiationRejectPromptFromData(NegotiationEnding */ public MetadataContent generateNegotiationProposePromptFromText( String text, net.openan.a2at.sdk.negotiation.content.NegotiationContext context, String templateUri) { - return negotiationGenerationOrchestrator.generateProposeFromText(text, context, templateUri); + return negotiationContentService.generateProposeFromText(text, context, templateUri); } /** @@ -288,7 +288,7 @@ public MetadataContent generateNegotiationProposePromptFromText( */ public MetadataContent generateNegotiationAcceptPromptFromText( String text, net.openan.a2at.sdk.negotiation.content.NegotiationContext context, String templateUri) { - return negotiationGenerationOrchestrator.generateAcceptFromText(text, context, templateUri); + return negotiationContentService.generateAcceptFromText(text, context, templateUri); } /** @@ -314,7 +314,7 @@ public MetadataContent generateNegotiationAcceptPromptFromText( */ public MetadataContent generateNegotiationRejectPromptFromText( String text, net.openan.a2at.sdk.negotiation.content.NegotiationContext context, String templateUri) { - return negotiationGenerationOrchestrator.generateRejectFromText(text, context, templateUri); + return negotiationContentService.generateRejectFromText(text, context, templateUri); } /** @@ -327,7 +327,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( * none can be loaded */ public List getNegotiationPrompts() { - return negotiationGenerationOrchestrator.getNegotiationPrompts(); + return negotiationContentService.getNegotiationPrompts(); } /** @@ -341,7 +341,7 @@ public List getNegotiationPrompts() { * the configured language */ public Optional getNegotiationPrompt(String uri) { - return negotiationGenerationOrchestrator.getNegotiationPrompt(uri); + return negotiationContentService.getNegotiationPrompt(uri); } /** @@ -369,7 +369,7 @@ public Optional getNegotiationPrompt(String uri) { */ public FilledParamData validateAndFillingProposeData( String prompt, Map schema, String templateUri) { - return negotiationGenerationOrchestrator.validateAndFillingProposeData(prompt, schema, templateUri); + return negotiationContentService.validateAndFillingProposeData(prompt, schema, templateUri); } /** @@ -394,7 +394,7 @@ public FilledParamData validateAndFillingProposeData( * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) { - return negotiationGenerationOrchestrator.validateAndFillingAcceptData(prompt, schema, templateUri); + return negotiationContentService.validateAndFillingAcceptData(prompt, schema, templateUri); } /** @@ -419,20 +419,6 @@ public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) { - return negotiationGenerationOrchestrator.validateAndFillingRejectData(prompt, schema, templateUri); - } - - private A2ATConfig resolvePromptResourceLocalRootDir(A2ATConfig config, Path envPath) { - String localRootDir = config.prompt().localRootDir(); - Path localRootPath = Path.of(localRootDir); - Path resolvedLocalRootPath = localRootPath.isAbsolute() - ? localRootPath.normalize() - : envPath.getParent().resolve(localRootPath).toAbsolutePath().normalize(); - return new A2ATConfig( - new PromptRuntimeConfig( - config.prompt().language(), config.prompt().sourceType(), resolvedLocalRootPath.toString()), - config.llm(), - config.negotiation(), - config.promptCompliance()); + return negotiationContentService.validateAndFillingRejectData(prompt, schema, templateUri); } } diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index 307dadca..b0555a5d 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -19,8 +19,8 @@ import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; import net.openan.a2at.sdk.llm.LLMConfigLoader; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; @@ -166,9 +166,10 @@ public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { /** * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. * - *

The wiring mirrors the server side: the message language and the local template root come from the prompt - * runtime config, the retry attempt limit comes from the LLM config, and the LLM client is only created when the - * provider is not {@code local_rule}. + *

The wiring is shared with the server side through + * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local + * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config, and the LLM + * client is only created when the provider is not {@code local_rule}. * * @return assembled negotiation generation orchestrator */ @@ -180,12 +181,7 @@ public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator( require(envPath, "Unified SDK env path must be configured."); llmClient = createLlmClient(); } - return NegotiationGenerationOrchestratorBuilder.builder() - .language(config.prompt().language()) - .localRootDir(config.prompt().localRootDir()) - .llmClient(llmClient) - .maxAttempts(config.llm().maxAttempts()) - .build(); + return NegotiationContentService.buildOrchestrator(config, llmClient); } private void requireSupportedConfig() { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java new file mode 100644 index 00000000..d4338657 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java @@ -0,0 +1,256 @@ +package net.openan.a2at.sdk.negotiation.content; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; + +/** + * Shared service for the negotiation content layer consumed by both the client and the server facade. + * + *

Each facade method delegates to exactly one service method, so the negotiation content-layer surface is defined + * once instead of being copy-pasted per facade. The service itself is a thin typing over the + * {@link NegotiationGenerationOrchestrator} pipeline and adds no behavior. + * + * @since 2026-08 + */ +public final class NegotiationContentService { + + private final NegotiationGenerationOrchestrator orchestrator; + + /** + * Creates one service over the given negotiation generation orchestrator. + * + * @param orchestrator negotiation generation orchestrator carrying the actual pipelines + */ + public NegotiationContentService(NegotiationGenerationOrchestrator orchestrator) { + this.orchestrator = Objects.requireNonNull(orchestrator, "Negotiation orchestrator must not be null."); + } + + /** + * Assembles the default negotiation generation orchestrator from the unified SDK config. + * + *

The wiring is shared by the client and the server builder: the message language and the local template root + * come from the prompt runtime config, the retry attempt limit comes from the LLM config, and the LLM client is + * passed by the caller and may be null when the provider is {@code local_rule}. + * + * @param config unified SDK config + * @param llmClient LLM client for the LLM-backed steps; null keeps those steps unavailable + * @return assembled negotiation generation orchestrator + */ + public static NegotiationGenerationOrchestrator buildOrchestrator(A2ATConfig config, LLMClient llmClient) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(config.prompt().language()) + .localRootDir(config.prompt().localRootDir()) + .llmClient(llmClient) + .maxAttempts(config.llm().maxAttempts()) + .build(); + } + + /** + * Resolves the relative prompt resource local root directory against the `.env` file location. + * + *

The returned config carries the local root as an absolute normalized path; an absolute configured root is + * only normalized. Both facades resolve their config through this method before assembling their builders. + * + * @param config unified SDK config as loaded from the `.env` file + * @param envPath resolved `.env` file path the config was loaded from + * @return config with the local root resolved to an absolute normalized path + */ + public static A2ATConfig resolvePromptResourceLocalRootDir(A2ATConfig config, Path envPath) { + String localRootDir = config.prompt().localRootDir(); + Path localRootPath = Path.of(localRootDir); + Path resolvedLocalRootPath = localRootPath.isAbsolute() + ? localRootPath.normalize() + : envPath.getParent().resolve(localRootPath).toAbsolutePath().normalize(); + return new A2ATConfig( + new PromptRuntimeConfig( + config.prompt().language(), config.prompt().sourceType(), resolvedLocalRootPath.toString()), + config.llm(), + config.negotiation(), + config.promptCompliance()); + } + + /** + * Generates a propose-phase negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri template URI whose phase segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NegotiationContentException if the data or its context is null, the template URI is malformed, or its + * phase or type contradicts the method or the content type + * @throws NegotiationGenerationException with the code {@code template_not_found} or + * {@code negotiation_slot_missing} when loading or rendering the template fails + */ + public MetadataContent generateProposeFromData(NegotiationProposeData data, String templateUri) { + return orchestrator.generateProposeFromData(data, templateUri); + } + + /** + * Generates an accept-phase negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed terminal input whose content conclusion must be {@code Accept} + * @param templateUri template URI whose phase segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NegotiationContentException if the data or its context is null, the template URI is malformed, its phase + * or type contradicts the method or the content, or the content conclusion is not {@code Accept} + * @throws NegotiationGenerationException with the code {@code template_not_found} or + * {@code negotiation_slot_missing} when loading or rendering the template fails + */ + public MetadataContent generateAcceptFromData(NegotiationEndingData data, String templateUri) { + return orchestrator.generateAcceptFromData(data, templateUri); + } + + /** + * Generates a reject-phase negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed terminal input whose content conclusion must be {@code Reject} + * @param templateUri template URI whose phase segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NegotiationContentException if the data or its context is null, the template URI is malformed, its phase + * or type contradicts the method or the content, or the content conclusion is not {@code Reject} + * @throws NegotiationGenerationException with the code {@code template_not_found} or + * {@code negotiation_slot_missing} when loading or rendering the template fails + */ + public MetadataContent generateRejectFromData(NegotiationEndingData data, String templateUri) { + return orchestrator.generateRejectFromData(data, templateUri); + } + + /** + * Generates a propose-phase negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input describing the message content + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI whose phase segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NegotiationContentException if the context is null or the template URI is malformed or contradicts the + * method + * @throws NegotiationGenerationException with the code {@code template_not_found}, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or + * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or + * {@code negotiation_invalid_input} when the text is blank or the extracted content contradicts the phase + */ + public MetadataContent generateProposeFromText(String text, NegotiationContext context, String templateUri) { + return orchestrator.generateProposeFromText(text, context, templateUri); + } + + /** + * Generates an accept-phase negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input describing the message content + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI whose phase segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NegotiationContentException if the context is null or the template URI is malformed or contradicts the + * method + * @throws NegotiationGenerationException with the code {@code template_not_found}, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or + * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or + * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Accept} + */ + public MetadataContent generateAcceptFromText(String text, NegotiationContext context, String templateUri) { + return orchestrator.generateAcceptFromText(text, context, templateUri); + } + + /** + * Generates a reject-phase negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input describing the message content + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI whose phase segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NegotiationContentException if the context is null or the template URI is malformed or contradicts the + * method + * @throws NegotiationGenerationException with the code {@code template_not_found}, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or + * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or + * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Reject} + */ + public MetadataContent generateRejectFromText(String text, NegotiationContext context, String templateUri) { + return orchestrator.generateRejectFromText(text, context, templateUri); + } + + /** + * Lists every negotiation template available for the configured language; never throws. + * + * @return loadable negotiation templates of the configured language, in a fixed type and phase order; empty when + * none can be loaded + */ + public List getNegotiationPrompts() { + return orchestrator.getNegotiationPrompts(); + } + + /** + * Loads one negotiation template by its URI; never throws. + * + * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} + * @return the addressed template, or an empty optional when the URI is malformed or no template exists for it in + * the configured language + */ + public Optional getNegotiationPrompt(String templateUri) { + return orchestrator.getNegotiationPrompt(templateUri); + } + + /** + * Validates a propose-phase negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI whose phase segment must be {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NegotiationContentException if the schema is null or the template URI is malformed or contradicts the + * method + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, + * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, + * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline + * fails + */ + public FilledParamData validateAndFillingProposeData( + String prompt, Map schema, String templateUri) { + return orchestrator.validateAndFillingProposeData(prompt, schema, templateUri); + } + + /** + * Validates an accept-phase negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI whose phase segment must be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NegotiationContentException if the schema is null or the template URI is malformed or contradicts the + * method + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, + * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, + * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline + * fails + */ + public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) { + return orchestrator.validateAndFillingAcceptData(prompt, schema, templateUri); + } + + /** + * Validates a reject-phase negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI whose phase segment must be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NegotiationContentException if the schema is null or the template URI is malformed or contradicts the + * method + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, + * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, + * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline + * fails + */ + public FilledParamData validateAndFillingRejectData(String prompt, Map schema, String templateUri) { + return orchestrator.validateAndFillingRejectData(prompt, schema, templateUri); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java index 10e6e485..15d04f36 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -32,6 +32,7 @@ void contentPackageExposesExactlyThePinnedTypes() throws IOException { "NegotiationConclusion.java", "NegotiationContent.java", "NegotiationContentException.java", + "NegotiationContentService.java", "NegotiationContext.java", "NegotiationEndingContent.java", "NegotiationEndingData.java", diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index cf740376..91ca38ae 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -7,9 +7,9 @@ import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.FilledParamData; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; @@ -31,7 +31,7 @@ public final class A2ATServer { private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; - private final NegotiationGenerationOrchestrator negotiationGenerationOrchestrator; + private final NegotiationContentService negotiationContentService; /** * Creates a server facade from one user-supplied `.env` path. @@ -40,12 +40,13 @@ public final class A2ATServer { */ public A2ATServer(Path envPath) { Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); - A2ATConfig config = resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + A2ATConfig config = + NegotiationContentService.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder().envPath(resolvedEnvPath).config(config); this.promptComplianceOrchestrator = builder.buildPromptComplianceOrchestrator(); this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); - this.negotiationGenerationOrchestrator = builder.buildNegotiationGenerationOrchestrator(); + this.negotiationContentService = new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); } /** @@ -111,7 +112,7 @@ public Map continueNegotiation( * {@code negotiation_slot_missing} when rendering the template fails */ public MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, String templateUri) { - return negotiationGenerationOrchestrator.generateProposeFromData(data, templateUri); + return negotiationContentService.generateProposeFromData(data, templateUri); } /** @@ -132,7 +133,7 @@ public MetadataContent generateNegotiationProposePromptFromData(NegotiationPropo * {@code negotiation_slot_missing} when rendering the template fails */ public MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, String templateUri) { - return negotiationGenerationOrchestrator.generateAcceptFromData(data, templateUri); + return negotiationContentService.generateAcceptFromData(data, templateUri); } /** @@ -153,7 +154,7 @@ public MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEnding * {@code negotiation_slot_missing} when rendering the template fails */ public MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, String templateUri) { - return negotiationGenerationOrchestrator.generateRejectFromData(data, templateUri); + return negotiationContentService.generateRejectFromData(data, templateUri); } /** @@ -180,7 +181,7 @@ public MetadataContent generateNegotiationRejectPromptFromData(NegotiationEnding */ public MetadataContent generateNegotiationProposePromptFromText( String text, net.openan.a2at.sdk.negotiation.content.NegotiationContext context, String templateUri) { - return negotiationGenerationOrchestrator.generateProposeFromText(text, context, templateUri); + return negotiationContentService.generateProposeFromText(text, context, templateUri); } /** @@ -206,7 +207,7 @@ public MetadataContent generateNegotiationProposePromptFromText( */ public MetadataContent generateNegotiationAcceptPromptFromText( String text, net.openan.a2at.sdk.negotiation.content.NegotiationContext context, String templateUri) { - return negotiationGenerationOrchestrator.generateAcceptFromText(text, context, templateUri); + return negotiationContentService.generateAcceptFromText(text, context, templateUri); } /** @@ -232,7 +233,7 @@ public MetadataContent generateNegotiationAcceptPromptFromText( */ public MetadataContent generateNegotiationRejectPromptFromText( String text, net.openan.a2at.sdk.negotiation.content.NegotiationContext context, String templateUri) { - return negotiationGenerationOrchestrator.generateRejectFromText(text, context, templateUri); + return negotiationContentService.generateRejectFromText(text, context, templateUri); } /** @@ -245,7 +246,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( * none can be loaded */ public List getNegotiationPrompts() { - return negotiationGenerationOrchestrator.getNegotiationPrompts(); + return negotiationContentService.getNegotiationPrompts(); } /** @@ -259,7 +260,7 @@ public List getNegotiationPrompts() { * the configured language */ public Optional getNegotiationPrompt(String templateUri) { - return negotiationGenerationOrchestrator.getNegotiationPrompt(templateUri); + return negotiationContentService.getNegotiationPrompt(templateUri); } /** @@ -285,7 +286,7 @@ public Optional getNegotiationPrompt(String templateUri) { */ public FilledParamData validateAndFillingProposeData( String prompt, Map schema, String templateUri) { - return negotiationGenerationOrchestrator.validateAndFillingProposeData(prompt, schema, templateUri); + return negotiationContentService.validateAndFillingProposeData(prompt, schema, templateUri); } /** @@ -310,7 +311,7 @@ public FilledParamData validateAndFillingProposeData( * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) { - return negotiationGenerationOrchestrator.validateAndFillingAcceptData(prompt, schema, templateUri); + return negotiationContentService.validateAndFillingAcceptData(prompt, schema, templateUri); } /** @@ -335,20 +336,6 @@ public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) { - return negotiationGenerationOrchestrator.validateAndFillingRejectData(prompt, schema, templateUri); - } - - private A2ATConfig resolvePromptResourceLocalRootDir(A2ATConfig config, Path envPath) { - String localRootDir = config.prompt().localRootDir(); - Path localRootPath = Path.of(localRootDir); - Path resolvedLocalRootPath = localRootPath.isAbsolute() - ? localRootPath.normalize() - : envPath.getParent().resolve(localRootPath).toAbsolutePath().normalize(); - return new A2ATConfig( - new net.openan.a2at.sdk.core.model.PromptRuntimeConfig( - config.prompt().language(), config.prompt().sourceType(), resolvedLocalRootPath.toString()), - config.llm(), - config.negotiation(), - config.promptCompliance()); + return negotiationContentService.validateAndFillingRejectData(prompt, schema, templateUri); } } diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index 8cf63e22..83b8f664 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -7,8 +7,8 @@ import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; import net.openan.a2at.sdk.llm.LLMConfigLoader; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; @@ -154,9 +154,10 @@ public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { /** * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. * - *

The wiring mirrors the client side: the message language and the local template root come from the prompt - * runtime config, the retry attempt limit comes from the LLM config, and the LLM client is only created when the - * provider is not {@code local_rule}. + *

The wiring is shared with the client side through + * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local + * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config, and the LLM + * client is only created when the provider is not {@code local_rule}. * * @return assembled negotiation generation orchestrator */ @@ -168,12 +169,7 @@ public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator( require(envPath, "Unified SDK env path must be configured."); llmClient = createLlmClient(); } - return NegotiationGenerationOrchestratorBuilder.builder() - .language(config.prompt().language()) - .localRootDir(config.prompt().localRootDir()) - .llmClient(llmClient) - .maxAttempts(config.llm().maxAttempts()) - .build(); + return NegotiationContentService.buildOrchestrator(config, llmClient); } private static void require(Object value, String message) { From 358a73b8505d45f03221a272dc3933c7a1093a1e Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Thu, 20 Aug 2026 17:52:16 +0800 Subject: [PATCH 050/295] refactor(a2a-t-core): extract shared classpath resource access and path segment validation Signed-off-by: codetalks-hw --- .../resources/ClasspathResourceStreams.java | 36 +++++++++++++++++++ .../a2at/sdk/core/resources/PathSegments.java | 30 ++++++++++++++++ .../NegotiationPromptResourceLoader.java | 27 +++----------- .../DefaultNegotiationTemplateLoader.java | 21 +++-------- .../resources/NegotiationReference.java | 11 ++---- .../DefaultNegotiationSemanticValidator.java | 14 ++------ .../ClasspathPromptResourceLoader.java | 14 ++------ 7 files changed, 81 insertions(+), 72 deletions(-) create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/ClasspathResourceStreams.java create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/ClasspathResourceStreams.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/ClasspathResourceStreams.java new file mode 100644 index 00000000..4ce8de4f --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/ClasspathResourceStreams.java @@ -0,0 +1,36 @@ +package net.openan.a2at.sdk.core.resources; + +import java.io.InputStream; + +/** + * Opens classpath resources through the shared classloader fallback chain. + * + *

The context classloader of the current thread is consulted first; when it has no resource of the given path, + * the classloader of this utility class is consulted as a fallback. A caller that finds no stream handles the + * absence with its own domain exception, so this utility only returns {@code null}. + * + * @since 2026-08 + */ +public final class ClasspathResourceStreams { + + private ClasspathResourceStreams() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** + * Opens one classpath resource through the context classloader with a fallback to this class's classloader. + * + * @param resourcePath slash-separated classpath resource path + * @return open stream of the resource, or {@code null} when no classloader provides it + */ + public static InputStream open(String resourcePath) { + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + if (contextClassLoader != null) { + InputStream stream = contextClassLoader.getResourceAsStream(resourcePath); + if (stream != null) { + return stream; + } + } + return ClasspathResourceStreams.class.getClassLoader().getResourceAsStream(resourcePath); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java new file mode 100644 index 00000000..12aa389d --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java @@ -0,0 +1,30 @@ +package net.openan.a2at.sdk.core.resources; + +/** + * Validates path segment values used to compose classpath or filesystem resource paths. + * + *

A simple segment is non-null, non-blank and free of slashes, backslashes and {@code ..} sequences, so that it + * can never escape the resource root it is resolved against. + * + * @since 2026-08 + */ +public final class PathSegments { + + private PathSegments() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** + * Checks whether the value is a non-blank simple path segment. + * + * @param value candidate path segment such as a language or category identifier + * @return true when the value is non-null, non-blank and contains no slash, backslash or {@code ..} sequence + */ + public static boolean isSimpleSegment(String value) { + return value != null + && !value.isBlank() + && !value.contains("/") + && !value.contains("\\") + && !value.contains(".."); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java index c7c7ed44..455c9987 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java @@ -5,6 +5,8 @@ import java.nio.charset.StandardCharsets; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.core.resources.PathSegments; import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; /** @@ -49,12 +51,12 @@ public String loadUser(String promptCategory, String language) { } private static String loadPrompt(String fileName, String promptCategory, String language) { - if (!isSimpleSegment(promptCategory)) { + if (!PathSegments.isSimpleSegment(promptCategory)) { throw new NegotiationContentException( "Prompt category must be a non-blank simple path segment but was " + promptCategory + ".", "promptCategory"); } - if (!isSimpleSegment(language)) { + if (!PathSegments.isSimpleSegment(language)) { throw new NegotiationContentException( "Prompt language must be a non-blank simple path segment but was " + language + ".", "language"); } @@ -63,7 +65,7 @@ private static String loadPrompt(String fileName, String promptCategory, String } private static String readResource(String path) { - InputStream stream = openClasspathResource(path); + InputStream stream = ClasspathResourceStreams.open(path); if (stream == null) { throw new ResourceNotFoundException( "Negotiation prompt resource does not exist on the classpath: " + path, path); @@ -74,23 +76,4 @@ private static String readResource(String path) { throw new SdkException("Failed to read negotiation prompt resource: " + path, exception); } } - - private static InputStream openClasspathResource(String path) { - ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); - if (contextClassLoader != null) { - InputStream stream = contextClassLoader.getResourceAsStream(path); - if (stream != null) { - return stream; - } - } - return NegotiationPromptResourceLoader.class.getClassLoader().getResourceAsStream(path); - } - - private static boolean isSimpleSegment(String value) { - return value != null - && !value.isBlank() - && !value.contains("/") - && !value.contains("\\") - && !value.contains(".."); - } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java index 917ad6b7..3038524c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -9,6 +9,8 @@ import java.util.List; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.core.resources.PathSegments; import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; @@ -60,11 +62,7 @@ public final class DefaultNegotiationTemplateLoader implements NegotiationTempla * @throws NegotiationContentException if the language is not a simple path segment */ public DefaultNegotiationTemplateLoader(String language, String localRootDir) { - if (language == null - || language.isBlank() - || language.contains("/") - || language.contains("\\") - || language.contains("..")) { + if (!PathSegments.isSimpleSegment(language)) { throw new NegotiationContentException( "Negotiation template loader language must be a non-blank simple path segment but was " + language + ".", @@ -137,7 +135,7 @@ private String readTemplate(String relativePath) { } } String classpathPath = CLASSPATH_ROOT + relativePath; - InputStream stream = openClasspathResource(classpathPath); + InputStream stream = ClasspathResourceStreams.open(classpathPath); if (stream == null) { throw new ResourceNotFoundException( "Negotiation template does not exist for the configured language; set A2AT_LANGUAGE to a" @@ -152,17 +150,6 @@ private String readTemplate(String relativePath) { } } - private static InputStream openClasspathResource(String classpathPath) { - ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); - if (contextClassLoader != null) { - InputStream stream = contextClassLoader.getResourceAsStream(classpathPath); - if (stream != null) { - return stream; - } - } - return DefaultNegotiationTemplateLoader.class.getClassLoader().getResourceAsStream(classpathPath); - } - /** * Extracts the template description from a leading HTML comment. * diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java index fc856e85..6e4191bb 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.negotiation.resources; import java.util.Locale; +import net.openan.a2at.sdk.core.resources.PathSegments; import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; @@ -36,7 +37,7 @@ public record NegotiationReference(NegotiationType type, NegotiationPhase phase, if (phase == null) { throw new NegotiationContentException("Negotiation reference phase must not be null.", "phase"); } - if (!isSimpleSegment(language)) { + if (!PathSegments.isSimpleSegment(language)) { throw new NegotiationContentException( "Negotiation reference language must be a non-blank simple path segment but was " + language + ".", "language"); @@ -137,12 +138,4 @@ private static void parsePhaseSegment(String phaseSegment, NegotiationPhase expe "templateUri"); } } - - private static boolean isSimpleSegment(String value) { - return value != null - && !value.isBlank() - && !value.contains("/") - && !value.contains("\\") - && !value.contains(".."); - } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java index f34b7681..24dc66de 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java @@ -15,6 +15,7 @@ import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.exception.SdkException; import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMError; import net.openan.a2at.sdk.llm.LLMResponse; @@ -131,7 +132,7 @@ private static List> buildMessages( private static String loadPromptResource(String fileName, String language) { String classpathPath = PROMPT_RESOURCE_ROOT + language + "/" + fileName; - InputStream stream = openClasspathResource(classpathPath); + InputStream stream = ClasspathResourceStreams.open(classpathPath); if (stream == null) { throw new ResourceNotFoundException( "Negotiation semantic validation prompt resource does not exist for language " + language @@ -146,17 +147,6 @@ private static String loadPromptResource(String fileName, String language) { } } - private static InputStream openClasspathResource(String classpathPath) { - ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); - if (contextClassLoader != null) { - InputStream stream = contextClassLoader.getResourceAsStream(classpathPath); - if (stream != null) { - return stream; - } - } - return DefaultNegotiationSemanticValidator.class.getClassLoader().getResourceAsStream(classpathPath); - } - private static String toJson(Map callerSchema) { try { return OBJECT_MAPPER.writeValueAsString(callerSchema == null ? Map.of() : callerSchema); diff --git a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java index d5dbb14d..764b55d3 100644 --- a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java +++ b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java @@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; /** * Loads prompt resources from the classpath bundle packaged with the SDK. @@ -21,7 +22,7 @@ public final class ClasspathPromptResourceLoader { */ public String loadText(PromptResourceKey key) { String relativePath = key.relativePath(); - InputStream stream = loadResource(relativePath); + InputStream stream = ClasspathResourceStreams.open(relativePath); if (stream == null) { throw new ResourceNotFoundException("Prompt resource file does not exist.", relativePath); } @@ -32,15 +33,4 @@ public String loadText(PromptResourceKey key) { throw new SdkException("Failed to read prompt resource: " + relativePath, error); } } - - private static InputStream loadResource(String relativePath) { - ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); - if (contextClassLoader != null) { - InputStream stream = contextClassLoader.getResourceAsStream(relativePath); - if (stream != null) { - return stream; - } - } - return ClasspathPromptResourceLoader.class.getClassLoader().getResourceAsStream(relativePath); - } } From 5a80d08f9876ce92e0123428845dc72f5fe95ad6 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Thu, 20 Aug 2026 17:53:57 +0800 Subject: [PATCH 051/295] feat: add authorization template, add service recovery event subscription template, refresh private line service complaint diagnosis template Signed-off-by: zhangwenjun-huawei --- .../v1/private-line-complaint/en-US/slot.json | 8 ---- .../v1/private-line-complaint/zh-CN/slot.json | 8 ---- .../v1/authz-policy-mgr/en-US/template.md | 28 +++++++++++++ .../v1/authz-policy-mgr/zh-CN/template.md | 28 +++++++++++++ .../v1/service-recovery/en-US/template.md | 41 +++++++++++++++++++ .../v1/service-recovery/zh-CN/template.md | 41 +++++++++++++++++++ .../private-line-complaint/en-US/template.md | 41 +++++++++---------- .../private-line-complaint/zh-CN/template.md | 41 +++++++++---------- tools/template_lint.py | 21 +++++++--- 9 files changed, 194 insertions(+), 63 deletions(-) create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/v1/authz-policy-mgr/en-US/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/v1/authz-policy-mgr/zh-CN/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/service-recovery/en-US/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/service-recovery/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/en-US/slot.json index 30437d86..94c15383 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/en-US/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/en-US/slot.json @@ -18,14 +18,6 @@ "Fault phenomenon: private line interruption. Fault sequence number: fault-id-1-017-20230511-09013" ], "x-a2at-value-constraint": "Fault phenomenon: includes two scenarios, \"private line interruption\" and \"poor private line quality\". Required parameter. Fault occurrence time. Optional parameter. Fault or event sequence number on the OSS side. Optional parameter." - }, - "expected_output": { - "type": "string", - "description": "Expected output of the private line complaint task.", - "examples": [ - "Return the detailed information and repair suggestions of the private line complaint diagnosis task that was successfully diagnosed." - ], - "x-a2at-value-constraint": "Include the diagnosis result type, diagnosis result details, and repair suggestions. The allowed values of the diagnosis result type parameter include: diagnosis successful, diagnosis failed, diagnosis not started, or diagnosis not finished. Multiple fault root causes may be included. Each fault root cause includes: the name of the fault root cause, detailed description, repair suggestions, and the identifier, type, name, and detailed location of the resource object where the fault root cause point resides. The detailed description of the overall fault, and the repair suggestions for the fault root cause. If multiple fault root causes exist, the detailed descriptions and repair suggestions across the multiple fault root causes must be summarized and refined into the overall diagnosis result details and repair suggestions." } }, "required": [ diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/zh-CN/slot.json index 576d961d..3a9a6937 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/zh-CN/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/zh-CN/slot.json @@ -18,14 +18,6 @@ "故障现象:专线中断。故障流水号:fault-id-1-017-20230511-09013" ], "x-a2at-value-constraint": "故障现象:包含“专线中断”和“专线质量差”两种场景,必选参数。故障发生时间,可选参数。OSS侧的故障或者事件流水号,可选参数。" - }, - "预期输出": { - "type": "string", - "description": "专线投诉任务预期输出", - "examples": [ - "返回诊断成功的专线投诉诊断任务的详细信息及修复建议。" - ], - "x-a2at-value-constraint": "包含诊断结果类型,诊断结果详细信息,修复建议。诊断结果类型参数的取值范围包括:诊断成功、诊断失败、诊断未开始或者诊断未结束。可以包括多个故障根因,每个故障根因包含:故障根因的名称、详细描述、修复建议,故障根因点所在资源对象的标识、类型、名称、详细位置等信息。故障整体的详细描述,故障根因的修复建议。如果存在多故障根因,需要对多个故障根因中的详细描述和修复建议总结提炼到总体的诊断结果详情和修复建议中。" } }, "required": [ diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/v1/authz-policy-mgr/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/v1/authz-policy-mgr/en-US/template.md new file mode 100644 index 00000000..b12f6db3 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/v1/authz-policy-mgr/en-US/template.md @@ -0,0 +1,28 @@ +## Authorization Policy Operation Type +{{authorization_policy_operation_type}} (required) +Requirement: +Provide the operation type of the authorization policy. Allowed values: add authorization policy, modify authorization policy, delete authorization policy, query authorization policy + +## Authorization Policy Operation Description +Please complete the corresponding authorization operation based on and , and return the authorization policy operation execution result in the structure defined in . indicates the expected return content. + +## Network Operation Authorization Policy List +{{network_operation_authorization_policy_list}} (required for add, required for modify, required for delete, optional for query) +Requirement: +1. Supports a list format, where each item in the list contains the following information: + - Network operation authorization policy identifier: uniquely identifies a network operation authorization policy. Example: 7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3 + - Network operation business scenario: the business scenario supported by the network operation. Example: service complaint diagnosis + - Network operation handling type. Example: service recovery + - Network operation name: the specific name of the authorized network operation. Example: tunnel optimization + - Validity period: the validity time range of the authorization policy. If the value is "permanently valid", it means the authorization is permanently valid with no validity period limit. Example: 2026-06-01T12:00:00Z ~ 2030-06-18T12:00:00Z + - Creation time: the time when the authorization policy was created. Example: 2026-06-18T12:00:00Z + - Last modification time: the time when the authorization policy was last modified. Example: 2026-06-18T12:00:00Z + +2. When adding an authorization policy, the required parameters include: network operation business scenario, network operation handling type, network operation name, validity period + When modifying an authorization policy, the parameter that can be modified is: validity period + When querying authorization policies, the supported query conditions are: network operation business scenario, network operation handling type, network operation name + +## Expected Output +1. Authorization operation execution result. Allowed values: success, failure, partial success +2. When the authorization operation is executed successfully, return the that was executed successfully +3. When the authorization operation fails or is partially successful, return a failure list containing the authorization policies and the failure reasons diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/v1/authz-policy-mgr/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/v1/authz-policy-mgr/zh-CN/template.md new file mode 100644 index 00000000..0a587093 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/v1/authz-policy-mgr/zh-CN/template.md @@ -0,0 +1,28 @@ +## 授权策略的操作类型 +{{授权策略的操作类型}} (必填) +要求: +提供授权策略的操作类型,取值范围:新增授权策略、修改授权策略、删除授权策略、查询授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +{{动网操作的授权策略列表}} (新增必填,修改必填,删除必填,查询选填) +要求: +1. 支持列表形式,列表中的每一项包含如下信息: + - 动网操作的授权策略标识:唯一标识一条动网操作授权策略; 举例:7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3; + - 动网操作的业务场景:动网操作支撑的业务场景;举例:业务投诉诊断; + - 动网操作的处置类型;举例:业务抢通; + - 动网操作名称:所授权的动网操作的具体名称;举例:隧道调优; + - 有效期:授权策略的生效时间区间;如果取值为“永久生效”,则表示该授权永久生效,不限定有效期;举例:2026-06-01T12:00:00Z ~ 2030-06-18T12:00:00Z; + - 创建时间:创建授权策略的时间;举例:2026-06-18T12:00:00Z; + - 最后修改时间:最后一次修改授权策略的时间;举例:2026-06-18T12:00:00Z; + +2. 创建授权策略时,必填参数包括:动网操作的业务场景、动网操作的处置类型、动网操作名称、有效期 + 修改授权策略时,支持修改的参数为:有效期 + 查询授权策略时支持的查询条件为:动网操作的业务场景、动网操作的处置类型、动网操作名称 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/service-recovery/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/service-recovery/en-US/template.md new file mode 100644 index 00000000..a469f52f --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/service-recovery/en-US/template.md @@ -0,0 +1,41 @@ +## Subscription Description +Please complete the network-side service recovery event subscription and reporting task based on the following , , , and information. + +## Notification Topic +Service recovery event + +## Subscribe Condition +{{subscribe_condition}} (optional) +1. Subnetwork name. Example: xx subnetwork + +## Notification Data Format +{{notification_data_format}} (required) +### Service Recovery Event +Requirement: +1. Service recovery plan execution status. Allowed values: not started, ended. Example: ended (required) +2. Complaint diagnosis task sequence number. Example: 9de168c0-6179-4778-8b72-4279582c0a3f (required) +3. OSS-side event sequence number. Example: event-id-202606250128 (required) +4. Access port name. Example: P781-Zhujiang New Town-PTN7900-23-TPA1EG24-17 (required) +5. Whether OMC automatic recovery is authorized. Allowed values: yes, no. Example: yes (required) +6. Service recovery plan name. Example: tunnel optimization (required) +7. Service recovery plan details (required) + Example: + Tune the list of tunnels affected by traffic bandwidth, latency violation, and packet loss violation to a new path list. + Before optimization: + - Tunnel 1: Tunnel a, source NE: P781-Zhujiang New Town-PTN7900, sink NE: P783-Zhujiang New Town-PTN7900, primary/secondary type: master, status: down, latency: --, packet loss rate: 100%, bandwidth: 10000bps + - Tunnel route list: + - Link 1: Link a, source NE: P781-Zhujiang New Town-PTN7900, source port: 23-TPA1EG24-17, sink NE: P782-Zhujiang New Town-PTN7900, sink port: 23-TPA1EG24-17, latency: -- + - Link 2: Link b, source NE: P782-Zhujiang New Town-PTN7900, source port: 23-TPA1EG24-17, sink NE: P783-Zhujiang New Town-PTN7900, sink port: 23-TPA1EG24-17, latency: -- + After optimization: + - Tunnel 1: Tunnel b, source NE: P881-Zhujiang New Town-PTN7900, sink NE: P883-Zhujiang New Town-PTN7900, primary/secondary type: master, status: up, latency: 20ms, packet loss rate: 0.1%, bandwidth: 10000bps + - Tunnel route list: + - Link 1: Link c, source NE: P881-Zhujiang New Town-PTN7900, source port: 25-TPA1EG24-11, sink NE: P882-Zhujiang New Town-PTN7900, sink port: 25-TPA1EG24-12, latency: 10ms + - Link 2: Link d, source NE: P882-Zhujiang New Town-PTN7900, source port: 25-TPA1EG24-13, sink NE: P883-Zhujiang New Town-PTN7900, sink port: 25-TPA1EG24-14, latency: 15ms +8. Service recovery plan execution end time. Example: 2026-05-11T08:21:46Z (optional) +9. Service recovery plan execution result. Allowed values: success, failure. Example: success (optional) +10. Service recovery plan execution failure reason. This information must be provided when the service recovery plan execution result is "failure". Example: After optimization, tunnel link C is occupied and tunnel optimization cannot be performed (optional) + +## Expected Output +1. Subscription result. Allowed values: success, failure +2. Subscription failure reason (optional) +3. After successful subscription, report messages according to diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/service-recovery/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/service-recovery/zh-CN/template.md new file mode 100644 index 00000000..eb693c98 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/service-recovery/zh-CN/template.md @@ -0,0 +1,41 @@ +## 订阅描述 +请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。 + +## 通知主题 +业务抢通事件 + +## 订阅条件 +{{订阅条件}}(可选) +1. 子网名称;举例:xx子网; + +## 上报通知数据格式 +{{上报通知数据格式}}(必选) +### 业务抢通事件 +要求: +1. 业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束 (必选) +2. 投诉诊断任务流水号;举例:9de168c0-6179-4778-8b72-4279582c0a3f (必选) +3. OSS侧事件流水号;举例:event-id-202606250128 (必选) +4. 接入端口名称;举例:P781-珠江新城-PTN7900-23-TPA1EG24-17 (必选) +5. 是否已授权OMC自动抢通,取值范围:是、否;举例:是 (必选) +6. 业务抢通方案名称;举例:隧道调优 (必选) +7. 业务抢通方案详情;(必选) + 举例: + 将流量带宽,时延越限和丢包越限受影响的隧道列表,调优到新的路径列表中。 + 调优前: + - 隧道1:隧道a,源网元:P781-珠江新城-PTN7900,宿网元:P783-珠江新城-PTN7900,主备类型:master,状态:down,时延:--,丢包率:100%,带宽:10000bps + - 隧道路由列表: + - 链路1:链路a, 源网元:P781-珠江新城-PTN7900,源端口:23-TPA1EG24-17,宿网元:P782-珠江新城-PTN7900,宿端口:23-TPA1EG24-17,时延:-- + - 链路2:链路b, 源网元:P782-珠江新城-PTN7900,源端口:23-TPA1EG24-17,宿网元:P783-珠江新城-PTN7900,宿端口:23-TPA1EG24-17,时延:-- + 调优后: + - 隧道1:隧道b,源网元:P881-珠江新城-PTN7900,宿网元:P883-珠江新城-PTN7900,主备类型:master,状态:up,时延:20ms,丢包率:0.1%,带宽:10000bps + - 隧道路由列表: + - 链路1:链路c, 源网元:P881-珠江新城-PTN7900,源端口:25-TPA1EG24-11,宿网元:P882-珠江新城-PTN7900,宿端口:25-TPA1EG24-12,时延:10ms + - 链路2:链路d, 源网元:P882-珠江新城-PTN7900,源端口:25-TPA1EG24-13,宿网元:P883-珠江新城-PTN7900,宿端口:25-TPA1EG24-14,时延:15ms +8. 业务抢通方案执行结束时间;样例:2026-05-11T08:21:46Z; (可选) +9. 业务抢通方案执行结果,取值范围:成功、失败;举例:成功; (可选) +10. 业务抢通方案执行失败原因;业务抢通方案执行结果为“失败”时,需要填写此信息;举例:调优后隧道链路C已被占用,无法执行隧道调优 (可选) + +## 预期输出 +1. 订阅结果,取值范围:成功、失败 +2. 订阅失败原因(可选) +3. 订阅成功后,按照<上报通知数据格式>上报消息 \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/en-US/template.md index 81100009..a3fa993e 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/en-US/template.md @@ -1,33 +1,32 @@ ## Task Type -SPN private line complaint diagnosis +Transport private line service complaint diagnosis ## Task Description -Requirement: Provide an overall overview of the task. Based on , , and , perform network-side fault root cause diagnosis in the complaint scenario, achieve the complaint diagnosis goal defined in , and return the task processing result in the structure defined in . +Based on and , perform network-side fault root cause diagnosis in the complaint scenario, achieve the complaint diagnosis goal defined in , and return the task processing result in the structure defined in . ## Task Target -Requirement: -1. Based on the fault phenomenon of the private line service, diagnose and return the network-side fault root cause +Diagnose network-side faults and return diagnostic result information such as fault root causes and repair suggestions. ## Task Object -{{task_object}} (optional) -Requirement: Provide the private line service object. Provide one of the following three parameters to identify the private line service object: private line name, access port name, and access port resource ID. -- Private line name: the private line object identifier in the network-side EMS NMS. -- Access port name: the access port name in the network-side EMS NMS, in the format: network element name + board number + board model + port number. -- Access port resource ID: the access port resource object identifier in the network-side EMS NMS. -Example: -Access port resource ID: f47ac10b-58cc-4372-a567-0e02b2c3d479 +{{task_object}} (required) +Requirement: Supports passing in private line service name / private line service identifier / private line service access port name. Choose one of these methods to identify the private line service object +1. Private line service name: the private line service name on the network side. Example: Huizhou Jiangbei Aggregation Private Line Service_100M_SPN (optional) +2. Private line service identifier: the unique identifier of the private line service on the network side. Example: 5571d707-5aad-11ea-8629-286ed488cff3 (optional) +3. Access port name: the access port name of the service, which can be a physical port or a logical port (sub-interface) (optional) + - Physical port format: network element name + board number + board model + port number. Example: P781-Zhujiang New Town-PTN7900-23-TPA1EG24-17 + - Logical port format: network element name + board number + board model + port number (cvlan=access vlan id). Example: P781-Zhujiang New Town-PTN7900-23-TPA1EG24-17(cvlan=100) ## Task Context {{task_context}} (required) -Requirement: Provide the fault phenomenon description of the private line service and the context information of the diagnosis task. -1. Fault phenomenon: includes two scenarios, "private line interruption" and "poor private line quality". Required parameter. Example: "private line interruption" -2. Fault occurrence time. Optional parameter. Example: "2024-01-16T08:21:46Z" -3. Fault or event sequence number on the OSS side. Optional parameter. Example: "fault-id-1-017-20230511-09013" +Requirement: Please provide the following information +1. Complaint category: includes two scenarios, "private line interruption" and "poor private line quality". Example: "poor private line quality" (required) +2. Problem occurrence time. Example: "2026-05-16T08:21:46Z" (optional) +3. OSS-side event sequence number: fill in the private line service complaint work order sequence number on the OSS side. Example: "event-id-20260511-09013" (required) +4. Complaint details: may include the private line user's description of the service complaint and the OSS-side preprocessing information. Example: "Starting from 8:30 this morning, the response latency from Shenzhen to Shanghai suddenly increased from an average of 12ms to 320ms. Accessing the core trading system of the Shanghai data center is very slow. The trading interfaces of the counter and mobile banking frequently report 'connection timeout'. Normal business operations take a dozen seconds or even half a minute to return results!" (optional) ## Expected Output -{{expected_output}} (optional) -Requirement: -1. Include the diagnosis result type, diagnosis result details, and repair suggestions. -2. The allowed values of the diagnosis result type parameter include: diagnosis successful, diagnosis failed, diagnosis not started, or diagnosis not finished. -3. Multiple fault root causes may be included. Each fault root cause includes: the name of the fault root cause, detailed description, repair suggestions, and the identifier, type, name, and detailed location of the resource object where the fault root cause point resides. The detailed description of the overall fault, and the repair suggestions for the fault root cause. -4. If multiple fault root causes exist, the detailed descriptions and repair suggestions across the multiple fault root causes must be summarized and refined into the overall diagnosis result details and repair suggestions. +Requirement: The complaint diagnosis task result should include the following information: +1. Diagnosis result. Allowed values: success, failure (required) +2. Diagnosis result details (required) +3. Repair suggestions (optional) +4. Fault root cause list, where each fault root cause includes fault root cause name, detailed description, repair suggestions, fault root cause point location, etc. (optional) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/zh-CN/template.md index 5f2e951a..71e2ed9f 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/zh-CN/template.md @@ -1,33 +1,32 @@ ## 任务类型(Task Type) -SPN专线投诉诊断 +传输专线业务投诉诊断 ## 任务描述(Task Description) -要求:提供任务的整体概述,基于<任务对象>、<任务上下文>、<约束条件>进行投诉场景的网络侧故障根因诊断,达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。 +基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。 ## 任务目标(Task Target) -要求: -1. 基于专线业务的故障现象,诊断并返回网络侧的故障根因 +对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。 ## 任务对象(Task Object) -{{任务对象}}(可选) -要求:提供专线业务对象,以下 专线名称,接入端口名称,接入端口资源ID参数,3个参数下发其中一个参数用于标识专线业务对象 -- 专线名称:网络侧EMS网管的专线对象标识 -- 接入端口名称:网络侧EMS网管的接入端口名称,格式为:网元名称+单板号+单板型号+端口号 -- 接入端口资源ID:网络侧EMS网管的接入段口资源对象标识 -样例: -接入端口资源ID:f47ac10b-58cc-4372-a567-0e02b2c3d479 +{{任务对象}}(必选) +要求:支持传入 专线业务名称/专线业务标识/专线业务接入端口名称,选择其中1种方式来表示专线业务对象 +1. 专线业务名称:网络侧的专线业务名称; 举例:惠州江北汇聚专线业务_100M_SPN (可选) +2. 专线业务标识:网络侧的专线业务唯一标识;举例:5571d707-5aad-11ea-8629-286ed488cff3 (可选) +3. 接入端口名称:业务的接入端口名称,可以是物理端口或者逻辑端口(子接口) (可选) + - 物理端口格式为:网元名称+单板号+单板型号+端口号;举例:P781-珠江新城-PTN7900-23-TPA1EG24-17 + - 逻辑端口格式为:网元名称+单板号+单板型号+端口号(cvlan=接入vlanid) ;举例:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100) ## 任务上下文(Task Context) {{任务上下文}}(必选) -要求:提供专线业务的故障现象描述和诊断任务的上下文信息 -1. 故障现象:包含“专线中断”和“专线质量差”两种场景,必选参数。举例:”专线中断“ -2. 故障发生时间,可选参数。举例:“2024-01-16T08:21:46Z” -3. OSS侧的故障或者事件流水号,可选参数。举例:“fault-id-1-017-20230511-09013” +要求:请提供如下信息 +1. 投诉分类:包含"专线中断"和"专线质差"2种场景;举例:"专线质差";(必选) +2. 问题发生时间;举例:"2026-05-16T08:21:46Z";(可选) +3. OSS侧事件流水号:填写OSS侧的专线业务投诉工单流水号;举例: "event-id-20260511-09013";(必选) +4. 投诉详情:可以包含专线用户对于业务投诉的描述,以及OSS侧的预处理信息;举例:"从今天早上8点半开始,从深圳访问上海的响应延迟从平均12ms骤升至320ms,访问上海机房的核心交易系统非常慢。柜面和手机银行的交易接口频繁报‘连接超时’,正常业务操作要等十几秒甚至半分钟才能返回结果!";(可选) ## 预期输出(Expected Output) -{{预期输出}}(可选) -要求: -1. 包含诊断结果类型,诊断结果详细信息,修复建议 -2. 诊断结果类型参数的取值范围包括:诊断成功、诊断失败、诊断未开始或者诊断未结束 -3. 可以包括多个故障根因,每个故障根因包含:故障根因的名称、详细描述、修复建议,故障根因点所在资源对象的标识、类型、名称、详细位置等信息。故障整体的详细描述,故障根因的修复建议。 -4. 如果存在多故障根因,需要对多个故障根因中的详细描述和修复建议总结提炼到总体的诊断结果详情和修复建议中。 \ No newline at end of file +要求投诉诊断任务的结果包含如下信息: +1. 诊断结果;参数的取值范围包括:成功、失败;(必选) +2. 诊断结果详细信息; (必选) +3. 修复建议; (可选) +4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息; (可选) \ No newline at end of file diff --git a/tools/template_lint.py b/tools/template_lint.py index 392a665d..67c1db6b 100644 --- a/tools/template_lint.py +++ b/tools/template_lint.py @@ -14,12 +14,16 @@ SLOT = re.compile(r"{{\s*([^{}\s]+)\s*}}") TASK = {"Task Description", "Task Type", "Task Target", "Task Object", "Task Context", "Constraints", "Expected Output", "Operation Type"} NOTIFICATION = {"Subscription Description", "Notification Topic", "Subscribe Condition", "Notification Data Format", "Expected Output"} +AUTHORIZATION = {"Authorization Policy Operation Type", "Authorization Policy Operation Description", "Dynamic Network Operation Authorization Policy List", "Expected Output"} ALIASES = { "任务描述": "Task Description", "任务类型": "Task Type", "任务目标": "Task Target", "任务对象": "Task Object", "目标对象": "Task Object", "任务上下文": "Task Context", "约束条件": "Constraints", "预期输出": "Expected Output", "操作类型": "Operation Type", "订阅描述": "Subscription Description", "通知主题": "Notification Topic", "订阅条件": "Subscribe Condition", "通知数据格式": "Notification Data Format", "上报通知数据格式": "Notification Data Format", + "授权策略的操作类型": "Authorization Policy Operation Type", + "授权策略的操作描述": "Authorization Policy Operation Description", + "动网操作的授权策略列表": "Dynamic Network Operation Authorization Policy List", } @@ -77,9 +81,18 @@ def lint_pair(template_path: Path, schema_path: Path) -> list[str]: headings.append((line_no, canonical_heading(match.group(2)))) placeholders.extend((line_no, name) for name in SLOT.findall(line)) names = [name for _, name in headings] - profile = "notification" if "Subscription Description" in names else "task" - allowed = NOTIFICATION if profile == "notification" else TASK - required = {"Subscription Description"} if profile == "notification" else {"Task Description"} + if "Authorization Policy Operation Type" in names: + profile = "authorization" + allowed = AUTHORIZATION + required = {"Authorization Policy Operation Type"} + elif "Subscription Description" in names: + profile = "notification" + allowed = NOTIFICATION + required = {"Subscription Description"} + else: + profile = "task" + allowed = TASK + required = {"Task Description"} if not headings: errors.append(error(template_path, 1, "instruction-missing", "Template must contain L0 instructions marked with '##'.")) for line_no, name in headings: @@ -114,8 +127,6 @@ def lint_root(root: Path) -> list[str]: schema_path = slots / template_path.relative_to(templates).parent / "slot.json" if schema_path.is_file(): errors.extend(lint_pair(template_path, schema_path)) - else: - errors.append(error(template_path, 1, "slot-schema-missing", f"Missing paired slot schema: {schema_path}")) for schema_path in sorted(slots.glob("*/v1/*/*/slot.json")): template_path = templates / schema_path.relative_to(slots).parent / "template.md" if not template_path.is_file(): From eb24770c13c5f59b599235f706f024a48e34260d Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Thu, 20 Aug 2026 17:56:10 +0800 Subject: [PATCH 052/295] refactor(a2a-t-prompt): add SectionedTemplateRenderer SPI with collapse and drop policies Signed-off-by: codetalks-hw --- a2a-t-negotiation/pom.xml | 5 + .../generation/NegotiationPromptRenderer.java | 160 +++++------------- .../api/DropBlankSlotSectionRenderer.java | 117 +++++++++++++ .../api/SectionedTemplateRenderer.java | 36 ++++ .../taskrendering/api/TaskPromptRenderer.java | 6 +- 5 files changed, 207 insertions(+), 117 deletions(-) create mode 100644 a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/DropBlankSlotSectionRenderer.java create mode 100644 a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/SectionedTemplateRenderer.java diff --git a/a2a-t-negotiation/pom.xml b/a2a-t-negotiation/pom.xml index 79bea1d3..dc9c8e14 100644 --- a/a2a-t-negotiation/pom.xml +++ b/a2a-t-negotiation/pom.xml @@ -24,6 +24,11 @@ a2a-t-llm ${project.version} + + net.openan.a2a-t.sdk + a2a-t-prompt + ${project.version} + org.slf4j slf4j-api diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java index 5067ffb6..4807f9a8 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java @@ -1,116 +1,44 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Renders a negotiation template by filling slot sections and dropping empty ones. - * - *

A template is split into sections on {@code ## } title lines; any content before the first title, including a - * leading HTML description comment, is discarded. A section whose first non-empty body line is a slot placeholder line - * such as {@code {{required_information_items}} (required)} — or the same shape with the full-width required/optional - * markers used by zh-CN templates — is a slot section: it is rendered as the title followed by the slot value, or - * dropped entirely when the slot value is null or blank. Every other section is static and passes through with - * placeholder substitution applied. Rendered sections are joined with a single blank line and the result carries no - * trailing newline. - * - * @since 2026-06 - */ -public class NegotiationPromptRenderer { - - private static final String SECTION_TITLE_PREFIX = "## "; - - private static final Pattern SLOT_LINE_PATTERN = - Pattern.compile("^\\{\\{([^{}]+)\\}\\}\\s*((必填)|(选填)|\\(required\\)|\\(optional\\))$"); - - private static final Pattern SLOT_TOKEN_PATTERN = Pattern.compile("\\{\\{([^{}]+)\\}\\}"); - - /** - * Renders one template text with the given slot values. - * - * @param templateText full template text whose slot sections are filled or dropped - * @param slots slot values keyed by the language-specific slot name; a null or blank value drops the slot section - * @return rendered message text with sections joined by one blank line and no trailing newline; empty string when - * no section remains - * @throws NegotiationRenderException if the template text is null - */ - public String render(String templateText, Map slots) { - if (templateText == null) { - throw new NegotiationRenderException("Negotiation template text must not be null."); - } - Map safeSlots = slots == null ? Map.of() : slots; - List

sections = splitSections(templateText); - List renderedSections = new ArrayList<>(); - for (Section section : sections) { - String slotName = slotNameOf(section); - if (slotName != null) { - String value = safeSlots.get(slotName); - if (value == null || value.isBlank()) { - // An unfilled slot drops the whole section, title included. - continue; - } - renderedSections.add(SECTION_TITLE_PREFIX + section.title() + "\n" + value.strip()); - } else { - renderedSections.add(SECTION_TITLE_PREFIX - + section.title() - + "\n" - + substitute(section.body(), safeSlots).stripTrailing()); - } - } - return String.join("\n\n", renderedSections); - } - - private static List
splitSections(String templateText) { - List
sections = new ArrayList<>(); - String currentTitle = null; - List currentBody = new ArrayList<>(); - for (String line : templateText.split("\n", -1)) { - if (line.startsWith(SECTION_TITLE_PREFIX)) { - if (currentTitle != null) { - sections.add(new Section(currentTitle, String.join("\n", currentBody))); - } - currentTitle = line.substring(SECTION_TITLE_PREFIX.length()).strip(); - currentBody = new ArrayList<>(); - } else if (currentTitle != null) { - currentBody.add(line); - } - // Lines before the first title, such as the leading HTML comment, are discarded. - } - if (currentTitle != null) { - sections.add(new Section(currentTitle, String.join("\n", currentBody))); - } - return sections; - } - - private static String slotNameOf(Section section) { - for (String line : section.body().split("\n", -1)) { - if (line.isBlank()) { - continue; - } - Matcher matcher = SLOT_LINE_PATTERN.matcher(line.strip()); - if (matcher.matches()) { - return matcher.group(1); - } - return null; - } - return null; - } - - private static String substitute(String body, Map slots) { - Matcher matcher = SLOT_TOKEN_PATTERN.matcher(body); - StringBuilder result = new StringBuilder(); - while (matcher.find()) { - String value = slots.get(matcher.group(1)); - if (value != null && !value.isBlank()) { - matcher.appendReplacement(result, Matcher.quoteReplacement(value)); - } - } - matcher.appendTail(result); - return result.toString(); - } - - private record Section(String title, String body) {} -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.Map; +import net.openan.a2at.sdk.prompt.taskrendering.api.DropBlankSlotSectionRenderer; +import net.openan.a2at.sdk.prompt.taskrendering.api.SectionedTemplateRenderer; + +/** + * Renders a negotiation template by filling slot sections and dropping empty ones. + * + *

A template is split into sections on {@code ## } title lines; any content before the first title, including a + * leading HTML description comment, is discarded. A section whose first non-empty body line is a slot placeholder line + * such as {@code {{required_information_items}} (required)} — or the same shape with the full-width required/optional + * markers used by zh-CN templates — is a slot section: it is rendered as the title followed by the slot value, or + * dropped entirely when the slot value is null or blank. Every other section is static and passes through with + * placeholder substitution applied. Rendered sections are joined with a single blank line and the result carries no + * trailing newline. + * + *

The rendering itself delegates to the shared {@link DropBlankSlotSectionRenderer} of the prompt kernel, which + * owns the drop policy of the sectioned template grammar; this class keeps the negotiation-specific null-template + * contract. + * + * @since 2026-06 + */ +public class NegotiationPromptRenderer implements SectionedTemplateRenderer { + + private final DropBlankSlotSectionRenderer delegate = new DropBlankSlotSectionRenderer(); + + /** + * Renders one template text with the given slot values. + * + * @param templateText full template text whose slot sections are filled or dropped + * @param slots slot values keyed by the language-specific slot name; a null or blank value drops the slot section + * @return rendered message text with sections joined by one blank line and no trailing newline; empty string when + * no section remains + * @throws NegotiationRenderException if the template text is null + */ + @Override + public String render(String templateText, Map slots) { + if (templateText == null) { + throw new NegotiationRenderException("Negotiation template text must not be null."); + } + return delegate.render(templateText, slots); + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/DropBlankSlotSectionRenderer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/DropBlankSlotSectionRenderer.java new file mode 100644 index 00000000..00f9ed54 --- /dev/null +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/DropBlankSlotSectionRenderer.java @@ -0,0 +1,117 @@ +package net.openan.a2at.sdk.prompt.taskrendering.api; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Sectioned template renderer with the drop blank-slot policy. + * + *

A template is split into sections on {@code ## } title lines; any content before the first title, including a + * leading HTML description comment, is discarded. A section whose first non-empty body line is a slot placeholder line + * such as {@code {{required_information_items}} (required)} — or the same shape with the full-width required/optional + * markers used by zh-CN templates — is a slot section: it is rendered as the title followed by the slot value, or + * dropped entirely when the slot value is null or blank. Every other section is static and passes through with + * placeholder substitution applied. Rendered sections are joined with a single blank line and the result carries no + * trailing newline. + * + * @since 2026-08 + */ +public final class DropBlankSlotSectionRenderer implements SectionedTemplateRenderer { + + private static final String SECTION_TITLE_PREFIX = "## "; + + private static final Pattern SLOT_LINE_PATTERN = + Pattern.compile("^\\{\\{([^{}]+)\\}\\}\\s*((必填)|(选填)|\\(required\\)|\\(optional\\))$"); + + private static final Pattern SLOT_TOKEN_PATTERN = Pattern.compile("\\{\\{([^{}]+)\\}\\}"); + + /** + * Renders one template text with the given slot values under the drop policy. + * + * @param templateText full template text whose slot sections are filled or dropped + * @param slots slot values keyed by the language-specific slot name; a null or blank value drops the slot section + * @return rendered message text with sections joined by one blank line and no trailing newline; empty string when + * no section remains + * @throws IllegalArgumentException if the template text is null + */ + @Override + public String render(String templateText, Map slots) { + if (templateText == null) { + throw new IllegalArgumentException("Template text must not be null."); + } + Map safeSlots = slots == null ? Map.of() : slots; + List

sections = splitSections(templateText); + List renderedSections = new ArrayList<>(); + for (Section section : sections) { + String slotName = slotNameOf(section); + if (slotName != null) { + String value = safeSlots.get(slotName); + if (value == null || value.isBlank()) { + // An unfilled slot drops the whole section, title included. + continue; + } + renderedSections.add(SECTION_TITLE_PREFIX + section.title() + "\n" + value.strip()); + } else { + renderedSections.add(SECTION_TITLE_PREFIX + + section.title() + + "\n" + + substitute(section.body(), safeSlots).stripTrailing()); + } + } + return String.join("\n\n", renderedSections); + } + + private static List
splitSections(String templateText) { + List
sections = new ArrayList<>(); + String currentTitle = null; + List currentBody = new ArrayList<>(); + for (String line : templateText.split("\n", -1)) { + if (line.startsWith(SECTION_TITLE_PREFIX)) { + if (currentTitle != null) { + sections.add(new Section(currentTitle, String.join("\n", currentBody))); + } + currentTitle = line.substring(SECTION_TITLE_PREFIX.length()).strip(); + currentBody = new ArrayList<>(); + } else if (currentTitle != null) { + currentBody.add(line); + } + // Lines before the first title, such as the leading HTML comment, are discarded. + } + if (currentTitle != null) { + sections.add(new Section(currentTitle, String.join("\n", currentBody))); + } + return sections; + } + + private static String slotNameOf(Section section) { + for (String line : section.body().split("\n", -1)) { + if (line.isBlank()) { + continue; + } + Matcher matcher = SLOT_LINE_PATTERN.matcher(line.strip()); + if (matcher.matches()) { + return matcher.group(1); + } + return null; + } + return null; + } + + private static String substitute(String body, Map slots) { + Matcher matcher = SLOT_TOKEN_PATTERN.matcher(body); + StringBuilder result = new StringBuilder(); + while (matcher.find()) { + String value = slots.get(matcher.group(1)); + if (value != null && !value.isBlank()) { + matcher.appendReplacement(result, Matcher.quoteReplacement(value)); + } + } + matcher.appendTail(result); + return result.toString(); + } + + private record Section(String title, String body) {} +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/SectionedTemplateRenderer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/SectionedTemplateRenderer.java new file mode 100644 index 00000000..a4fbfa3c --- /dev/null +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/SectionedTemplateRenderer.java @@ -0,0 +1,36 @@ +package net.openan.a2at.sdk.prompt.taskrendering.api; + +import java.util.Map; + +/** + * Renders a sectioned prompt template by filling slot values under one blank-slot policy. + * + *

A sectioned template is split into sections on {@code ## } title lines; any content before the first title is + * preamble and is discarded. A section whose first non-empty body line is a standalone slot placeholder line is a + * slot section driven by that slot; every other section is static and passes through with placeholder substitution. + * + *

Implementations differ in how they treat a slot section whose value is null or blank: + * + *

    + *
  • collapse policy — {@link TaskPromptRenderer}: the section is kept and its standalone slot line collapses + * to the bare slot placeholder, preserving the section scaffolding of the template; + *
  • drop policy — {@link DropBlankSlotSectionRenderer}: the whole section including its title is removed + * from the rendered output. + *
+ * + *

The two policies are deliberately not merged: both are load-bearing for their extension families, so the grammar + * of section splitting lives here while each policy keeps its own slot-line and substitution rules. + * + * @since 2026-08 + */ +public interface SectionedTemplateRenderer { + + /** + * Renders one sectioned template text with the given slot values. + * + * @param templateText full template text whose sections are filled according to the implementation's policy + * @param slots slot values keyed by slot name; how null or blank values are handled depends on the policy + * @return rendered prompt text + */ + String render(String templateText, Map slots); +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRenderer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRenderer.java index 1cd390ef..98856298 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRenderer.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRenderer.java @@ -9,9 +9,12 @@ /** * Renders plain prompt bodies from lightweight template placeholders. * + *

This renderer carries the collapse blank-slot policy of {@link SectionedTemplateRenderer}: a slot section + * keeps its scaffolding and its standalone slot line collapses to the bare slot placeholder. + * * @since 2026-06 */ -public final class TaskPromptRenderer { +public final class TaskPromptRenderer implements SectionedTemplateRenderer { private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{?\\s*([^{}]+?)\\s*\\}\\}?"); private static final Pattern SECTION_HEADER_PATTERN = Pattern.compile("^##\\s+.+$"); @@ -25,6 +28,7 @@ public final class TaskPromptRenderer { * @param slots normalized slot values keyed by slot name * @return rendered prompt text */ + @Override public String render(String templateText, Map slots) { if (templateText == null || !balancedBraces(templateText)) { throw new TaskPromptRenderException("Template text is invalid."); From 20664f156b27e5e356e0deca8818cce02a54b68f Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Thu, 20 Aug 2026 18:18:36 +0800 Subject: [PATCH 053/295] feat(a2a-t-sdk): add generic getPrompts and getPrompt template queries across extensions Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 29 ++ .../content/NegotiationContentService.java | 22 ++ .../NegotiationGenerationOrchestrator.java | 48 ++- ...otiationGenerationOrchestratorBuilder.java | 17 ++ .../DefaultNegotiationTemplateLoader.java | 26 +- .../resources/PromptTemplateCatalog.java | 274 ++++++++++++++++++ .../resources/TemplateDescriptions.java | 41 +++ .../NegotiationPackageStructureTest.java | 2 + .../openan/a2at/sdk/server/A2ATServer.java | 29 ++ 9 files changed, 462 insertions(+), 26 deletions(-) create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/PromptTemplateCatalog.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/TemplateDescriptions.java diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 592bde62..eaaa97c3 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -317,6 +317,35 @@ public MetadataContent generateNegotiationRejectPromptFromText( return negotiationContentService.generateRejectFromText(text, context, templateUri); } + /** + * Lists every template available for the configured language across all A2A-T extensions. + * + *

This query never throws: the extension directories are discovered from the bundled resource tree itself, so + * templates of extensions added later are included automatically. Templates that exist nowhere for the language + * are skipped and an empty list is returned when no template can be loaded at all. + * + * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when + * none can be loaded + */ + public List getPrompts() { + return negotiationContentService.getPrompts(); + } + + /** + * Loads one template by its URI, regardless of the extension. + * + *

This query never throws: a missing template or an unusable URI yields an empty result together with a warning + * log instead of a failure. + * + * @param uri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} or + * {@code Task-T/v1/energy-saving} + * @return the addressed template, or an empty optional when the URI is malformed or no template exists for it in + * the configured language + */ + public Optional getPrompt(String uri) { + return negotiationContentService.getPrompt(uri); + } + /** * Lists every negotiation template available for the configured language. * diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java index d4338657..e2f87e7b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java @@ -178,6 +178,28 @@ public MetadataContent generateRejectFromText(String text, NegotiationContext co return orchestrator.generateRejectFromText(text, context, templateUri); } + /** + * Lists every template available for the configured language across all A2A-T extensions; never throws. + * + * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when + * none can be loaded + */ + public List getPrompts() { + return orchestrator.getPrompts(); + } + + /** + * Loads one template by its URI regardless of the extension; never throws. + * + * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} or + * {@code Task-T/v1/energy-saving} + * @return the addressed template, or an empty optional when the URI is malformed or no template exists for it in + * the configured language + */ + public Optional getPrompt(String templateUri) { + return orchestrator.getPrompt(templateUri); + } + /** * Lists every negotiation template available for the configured language; never throws. * diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 01e31cc5..4dbf39eb 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -23,6 +23,7 @@ import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.negotiation.resources.PromptTemplateCatalog; import net.openan.a2at.sdk.negotiation.validation.NegotiationValidationException; import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; import net.openan.a2at.sdk.negotiation.validation.SemanticValidationResult; @@ -64,6 +65,8 @@ public final class NegotiationGenerationOrchestrator { private final NegotiationTemplateLoader templateLoader; + private final PromptTemplateCatalog templateCatalog; + private final NegotiationContentExtractor contentExtractor; private final ParamExtractor paramExtractor; @@ -78,6 +81,7 @@ public final class NegotiationGenerationOrchestrator { String language, int maxAttempts, NegotiationTemplateLoader templateLoader, + PromptTemplateCatalog templateCatalog, NegotiationContentExtractor contentExtractor, ParamExtractor paramExtractor, NegotiationGeneratorRegistry generatorRegistry, @@ -86,6 +90,7 @@ public final class NegotiationGenerationOrchestrator { this.language = language; this.maxAttempts = maxAttempts; this.templateLoader = templateLoader; + this.templateCatalog = templateCatalog; this.contentExtractor = contentExtractor; this.paramExtractor = paramExtractor; this.generatorRegistry = generatorRegistry; @@ -232,6 +237,44 @@ public MetadataContent generateRejectFromText(String text, NegotiationContext co return generateFromText(text, context, templateUri, NegotiationPhase.REJECT); } + /** + * Lists every template available for the configured language across all A2A-T extensions. + * + *

This query never throws: the extension directories are discovered from the resource tree itself, templates + * that exist nowhere for the language are skipped, and an empty list is returned when no template can be loaded + * at all. The result is sorted by template URI, which orders by extension first. + * + * @return loadable templates of the configured language across all extensions, sorted by URI; empty when none can + * be loaded + */ + public List getPrompts() { + return templateCatalog.loadAll(); + } + + /** + * Loads one template by its URI, regardless of the extension. + * + *

This query never throws: a malformed URI or a template that exists nowhere for the configured language returns + * an empty result and logs an actionable warning. + * + * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} or + * {@code Task-T/v1/energy-saving} + * @return the addressed template, or an empty result when the URI is malformed or the template does not exist for + * the configured language + */ + public Optional getPrompt(String templateUri) { + Optional template = templateCatalog.load(templateUri); + if (template.isEmpty()) { + logger.atWarn() + .log( + "prompt_template_not_found uri={} language={} hint={}", + templateUri, + language, + LANGUAGE_HINT); + } + return template; + } + /** * Lists every negotiation template available for the configured language. * @@ -431,7 +474,7 @@ private MetadataContent completeGeneration( reference.phase(), context.round(), context.id()); - return new MetadataContent( + return new MetadataContent( reference.uri(), promptText, ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI); } @@ -474,6 +517,9 @@ private NegotiationParamExtractionException paramExtractionInfrastructureFailure } private Optional parseQueryReference(String templateUri) { + // The URI layer cannot distinguish accept from reject because both phases share the accept-reject template + // segment. The query therefore addresses the shared template through the ACCEPT phase; the parsed phase is an + // addressing artifact only and must never be read as the phase of any message. for (NegotiationPhase phase : List.of(NegotiationPhase.PROPOSE, NegotiationPhase.ACCEPT)) { try { return Optional.of(NegotiationReference.parse(templateUri, phase, language)); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index 437ced85..24f778e5 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -4,6 +4,7 @@ import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.resources.PromptTemplateCatalog; import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationSemanticValidator; import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; @@ -36,6 +37,8 @@ public final class NegotiationGenerationOrchestratorBuilder { private NegotiationTemplateLoader templateLoader; + private PromptTemplateCatalog templateCatalog; + private NegotiationContentExtractor contentExtractor; private NegotiationComplianceChecker complianceChecker; @@ -110,6 +113,17 @@ public NegotiationGenerationOrchestratorBuilder templateLoader(NegotiationTempla return this; } + /** + * Overrides the catalog answering the cross-extension template queries. + * + * @param templateCatalog directory-driven catalog over the template tree of every extension + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder templateCatalog(PromptTemplateCatalog templateCatalog) { + this.templateCatalog = templateCatalog; + return this; + } + /** * Overrides the content extractor used by the from-text generation. * @@ -173,6 +187,8 @@ public NegotiationGenerationOrchestrator build() { Vocabulary vocabulary = Vocabulary.forLanguage(language); NegotiationTemplateLoader effectiveTemplateLoader = templateLoader != null ? templateLoader : new DefaultNegotiationTemplateLoader(language, localRootDir); + PromptTemplateCatalog effectiveTemplateCatalog = + templateCatalog != null ? templateCatalog : new PromptTemplateCatalog(language, localRootDir); NegotiationContentExtractor effectiveContentExtractor = contentExtractor != null ? contentExtractor : new DefaultNegotiationContentExtractor(llmClient); NegotiationComplianceChecker effectiveComplianceChecker = @@ -185,6 +201,7 @@ public NegotiationGenerationOrchestrator build() { language, maxAttempts, effectiveTemplateLoader, + effectiveTemplateCatalog, effectiveContentExtractor, paramExtractor, new NegotiationGeneratorRegistry(), diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java index 3038524c..261014cb 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -39,10 +39,6 @@ public final class DefaultNegotiationTemplateLoader implements NegotiationTempla private static final String TEMPLATE_FILE_NAME = "template.md"; - private static final String COMMENT_OPEN = ""; - private static final List LOAD_ALL_TYPE_ORDER = List.of(NegotiationType.INFORMATION, NegotiationType.TARGET, NegotiationType.FEASIBILITY); @@ -83,7 +79,7 @@ public DefaultNegotiationTemplateLoader(String language, String localRootDir) { public PromptTemplate load(NegotiationReference reference) { String relativePath = templateRelativePath(reference); String content = readTemplate(relativePath); - PromptTemplate template = new PromptTemplate(reference.uri(), extractDescription(content), content); + PromptTemplate template = new PromptTemplate(reference.uri(), TemplateDescriptions.extract(content), content); LOGGER.atDebug().log("negotiation_template_loaded uri={} language={}", reference.uri(), reference.language()); return template; } @@ -150,24 +146,4 @@ private String readTemplate(String relativePath) { } } - /** - * Extracts the template description from a leading HTML comment. - * - * @param content full template text - * @return stripped comment text of the first line when it is an HTML comment, otherwise an empty string - */ - private static String extractDescription(String content) { - int firstLineBreak = content.indexOf('\n'); - String firstLine = firstLineBreak < 0 ? content : content.substring(0, firstLineBreak); - firstLine = firstLine.strip(); - if (firstLine.startsWith(COMMENT_OPEN) - && firstLine.endsWith(COMMENT_CLOSE) - && firstLine.length() >= COMMENT_OPEN.length() + COMMENT_CLOSE.length()) { - String description = firstLine - .substring(COMMENT_OPEN.length(), firstLine.length() - COMMENT_CLOSE.length()) - .strip(); - return description; - } - return ""; - } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/PromptTemplateCatalog.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/PromptTemplateCatalog.java new file mode 100644 index 00000000..1a62a27c --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/PromptTemplateCatalog.java @@ -0,0 +1,274 @@ +package net.openan.a2at.sdk.negotiation.resources; + +import java.io.IOException; +import java.io.InputStream; +import java.net.JarURLConnection; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.core.resources.PathSegments; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Directory-driven catalog over the prompt template tree of every A2A-T extension. + * + *

The catalog enumerates the {@code templates/} tree of the bundled prompt resources by walking its directories, + * so every extension directory that appears under {@code prompt_resources/templates/} — Task-T, Notification-T, + * Authorization-T, Negotiation-T and any extension added later — is picked up without a hardcoded extension list. + * A template file lives at {@code templates///[/]//template.md} + * and is addressed by the URI formed from the segments before the language, for example + * {@code Negotiation-T/v1/information-negotiation/propose} or {@code Task-T/v1/energy-saving}. + * + *

Like the negotiation template loader, the catalog prefers a local file under the configured local resource + * root over the built-in classpath template of the same path, and falls back to the classpath otherwise. Both query + * methods never throw: a template or a root that cannot be loaded is skipped or answered with an empty result and a + * warning log. + * + * @since 2026-08 + */ +public final class PromptTemplateCatalog { + + private static final Logger LOGGER = LoggerFactory.getLogger(PromptTemplateCatalog.class); + + private static final String CLASSPATH_ROOT = "prompt_resources/"; + + private static final String CLASSPATH_TEMPLATE_ROOT = CLASSPATH_ROOT + "templates/"; + + private static final String TEMPLATE_DIRECTORY = "templates"; + + private static final String TEMPLATE_FILE_NAME = "template.md"; + + private static final int MINIMUM_URI_SEGMENTS = 3; + + private final String language; + + private final Path localRootDir; + + /** + * Creates a catalog for one language with an optional local template root. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @param localRootDir local prompt resource root containing the {@code templates/} tree; null or blank disables + * local template overrides + * @throws NegotiationContentException if the language is not a simple path segment + */ + public PromptTemplateCatalog(String language, String localRootDir) { + if (!PathSegments.isSimpleSegment(language)) { + throw new NegotiationContentException( + "Prompt template catalog language must be a non-blank simple path segment but was " + language + + ".", + "language"); + } + this.language = language; + this.localRootDir = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); + } + + /** + * Lists every loadable template of the configured language across all extensions. + * + *

The extension directories are discovered from the resource tree itself, so the result grows automatically + * when a new extension directory appears. Local templates override built-in templates of the same path and + * local-only templates are included. The result is sorted by template URI, which orders by extension first. + * + * @return loadable templates of the configured language sorted by URI; empty when none can be loaded + */ + public List loadAll() { + Map contentsByPath = new LinkedHashMap<>(); + try { + contentsByPath.putAll(classpathTemplates()); + } catch (IOException | URISyntaxException exception) { + LOGGER.atWarn() + .log("prompt_template_catalog_unavailable root=classpath reason={}", exception.getMessage()); + } + contentsByPath.putAll(localTemplates()); + List templates = new ArrayList<>(); + String languageSuffix = "/" + language + "/" + TEMPLATE_FILE_NAME; + for (Map.Entry entry : contentsByPath.entrySet()) { + String path = entry.getKey(); + if (!path.endsWith(languageSuffix)) { + continue; + } + String uri = path.substring( + CLASSPATH_TEMPLATE_ROOT.length(), path.length() - languageSuffix.length()); + if (!isCatalogableUri(uri)) { + LOGGER.atDebug().log("prompt_template_skipped path={} reason=not_a_template_uri", path); + continue; + } + String content = entry.getValue(); + templates.add(new PromptTemplate(uri, TemplateDescriptions.extract(content), content)); + } + templates.sort(Comparator.comparing(PromptTemplate::uri)); + LOGGER.atDebug().log("prompt_templates_listed count={} language={}", templates.size(), language); + return List.copyOf(templates); + } + + /** + * Loads one template of the configured language by its URI, regardless of the extension. + * + * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} or + * {@code Task-T/v1/energy-saving} + * @return the addressed template, or an empty optional when the URI is malformed or no template exists for it in + * the configured language + */ + public Optional load(String templateUri) { + String uri = templateUri == null ? null : templateUri.strip(); + if (!isCatalogableUri(uri)) { + return Optional.empty(); + } + String relativePath = String.join("/", TEMPLATE_DIRECTORY, uri, language, TEMPLATE_FILE_NAME); + try { + String content = readTemplate(relativePath); + return Optional.of(new PromptTemplate(uri, TemplateDescriptions.extract(content), content)); + } catch (SdkException exception) { + return Optional.empty(); + } + } + + private Map classpathTemplates() throws IOException, URISyntaxException { + Map templates = new LinkedHashMap<>(); + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + if (classLoader == null) { + classLoader = PromptTemplateCatalog.class.getClassLoader(); + } + Enumeration roots = classLoader.getResources(CLASSPATH_TEMPLATE_ROOT); + while (roots.hasMoreElements()) { + URL root = roots.nextElement(); + if ("file".equals(root.getProtocol())) { + collectFilesystemTemplates(Path.of(root.toURI()), templates); + } else if ("jar".equals(root.getProtocol())) { + collectJarTemplates((JarURLConnection) root.openConnection(), templates); + } else { + LOGGER.atDebug().log("prompt_template_root_skipped url={} reason=unsupported_protocol", root); + } + } + return templates; + } + + private static void collectFilesystemTemplates(Path rootDirectory, Map templates) + throws IOException { + try (Stream paths = Files.walk(rootDirectory)) { + paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().equals(TEMPLATE_FILE_NAME)) + .forEach(path -> collectTemplate( + templates, + CLASSPATH_TEMPLATE_ROOT + + rootDirectory.relativize(path).toString().replace('\\', '/'), + path)); + } + } + + private static void collectJarTemplates(JarURLConnection connection, Map templates) + throws IOException { + try (java.util.jar.JarFile jarFile = connection.getJarFile()) { + jarFile.stream() + .filter(entry -> !entry.isDirectory()) + .filter(entry -> entry.getName().startsWith(CLASSPATH_TEMPLATE_ROOT)) + .filter(entry -> entry.getName().endsWith(TEMPLATE_FILE_NAME)) + .forEach(entry -> collectTemplate(templates, entry.getName(), null)); + } + } + + private static void collectTemplate(Map templates, String classpathPath, Path filesystemPath) { + try { + String content; + if (filesystemPath != null) { + content = Files.readString(filesystemPath, StandardCharsets.UTF_8); + } else { + InputStream stream = ClasspathResourceStreams.open(classpathPath); + if (stream == null) { + LOGGER.atDebug() + .log("prompt_template_skipped path={} reason=classpath_entry_unreadable", classpathPath); + return; + } + try (stream) { + content = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + templates.put(classpathPath, content); + } catch (IOException exception) { + LOGGER.atDebug() + .log("prompt_template_skipped path={} reason=read_failed", classpathPath); + } + } + + private Map localTemplates() { + Map templates = new LinkedHashMap<>(); + if (localRootDir == null) { + return templates; + } + Path templateRoot = localRootDir.resolve(TEMPLATE_DIRECTORY); + if (!Files.isDirectory(templateRoot)) { + return templates; + } + try (Stream paths = Files.walk(templateRoot)) { + paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().equals(TEMPLATE_FILE_NAME)) + .forEach(path -> collectTemplate( + templates, + CLASSPATH_TEMPLATE_ROOT + + templateRoot.relativize(path).toString().replace('\\', '/'), + path)); + } catch (IOException exception) { + LOGGER.atWarn() + .log("prompt_template_catalog_unavailable root={} reason={}", templateRoot, exception.getMessage()); + } + return templates; + } + + private String readTemplate(String relativePath) { + if (localRootDir != null) { + Path localPath = localRootDir.resolve(relativePath); + if (Files.exists(localPath)) { + try { + return Files.readString(localPath, StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new SdkException("Failed to read prompt template: " + localPath, exception); + } + } + } + String classpathPath = CLASSPATH_ROOT + relativePath; + InputStream stream = ClasspathResourceStreams.open(classpathPath); + if (stream == null) { + throw new ResourceNotFoundException( + "Prompt template does not exist for the configured language; set the language to one with" + + " bundled templates or provide the template under the local resource root.", + classpathPath); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new SdkException("Failed to read prompt template: " + classpathPath, exception); + } + } + + private static boolean isCatalogableUri(String uri) { + if (uri == null || uri.isBlank()) { + return false; + } + String[] segments = uri.split("/"); + if (segments.length < MINIMUM_URI_SEGMENTS) { + return false; + } + for (String segment : segments) { + if (!PathSegments.isSimpleSegment(segment)) { + return false; + } + } + return true; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/TemplateDescriptions.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/TemplateDescriptions.java new file mode 100644 index 00000000..c563b6a0 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/TemplateDescriptions.java @@ -0,0 +1,41 @@ +package net.openan.a2at.sdk.negotiation.resources; + +/** + * Extracts the description convention shared by every prompt template of the resource tree. + * + *

A template carries its description as a leading HTML comment on the first line; a template without such a + * comment reports an empty description. The convention is shared between the negotiation template loader and the + * template catalog so that both produce equal {@link PromptTemplate} records for the same file. + * + * @since 2026-08 + */ +final class TemplateDescriptions { + + private static final String COMMENT_OPEN = ""; + + private TemplateDescriptions() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** + * Extracts the template description from a leading HTML comment. + * + * @param content full template text + * @return stripped comment text of the first line when it is an HTML comment, otherwise an empty string + */ + static String extract(String content) { + int firstLineBreak = content.indexOf('\n'); + String firstLine = firstLineBreak < 0 ? content : content.substring(0, firstLineBreak); + firstLine = firstLine.strip(); + if (firstLine.startsWith(COMMENT_OPEN) + && firstLine.endsWith(COMMENT_CLOSE) + && firstLine.length() >= COMMENT_OPEN.length() + COMMENT_CLOSE.length()) { + return firstLine + .substring(COMMENT_OPEN.length(), firstLine.length() - COMMENT_CLOSE.length()) + .strip(); + } + return ""; + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java index 15d04f36..571cb11f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -60,6 +60,8 @@ void resourcesPackageExposesExactlyThePinnedTypes() throws IOException { "NegotiationReference.java", "NegotiationTemplateLoader.java", "PromptTemplate.java", + "PromptTemplateCatalog.java", + "TemplateDescriptions.java", "package-info.java"), topLevelJavaFiles(negotiationRoot().resolve("resources"))); assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("resources"))); diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 91ca38ae..0ad91ac3 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -236,6 +236,35 @@ public MetadataContent generateNegotiationRejectPromptFromText( return negotiationContentService.generateRejectFromText(text, context, templateUri); } + /** + * Lists every template available for the configured language across all A2A-T extensions. + * + *

This query never throws: the extension directories are discovered from the bundled resource tree itself, so + * templates of extensions added later are included automatically. Templates that exist nowhere for the language + * are skipped and an empty list is returned when no template can be loaded at all. + * + * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when + * none can be loaded + */ + public List getPrompts() { + return negotiationContentService.getPrompts(); + } + + /** + * Loads one template by its URI, regardless of the extension. + * + *

This query never throws: a malformed URI or a template that exists nowhere for the configured language returns + * an empty result and logs an actionable warning. + * + * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} or + * {@code Task-T/v1/energy-saving} + * @return the addressed template, or an empty result when the URI is malformed or the template does not exist for + * the configured language + */ + public Optional getPrompt(String templateUri) { + return negotiationContentService.getPrompt(templateUri); + } + /** * Lists every negotiation template available for the configured language. * From f37ba4e70d69578ee8dbe012ec5f3ccf94f1a151 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Thu, 20 Aug 2026 18:22:37 +0800 Subject: [PATCH 054/295] docs(a2a-t-sdk): document generic template queries and Authorization-T experimental status Signed-off-by: codetalks-hw --- .../net/openan/a2at/sdk/client/A2ATClient.java | 8 ++++++++ docs/en/developer_guide.md | 15 +++++++++------ ...74\200\345\217\221\346\214\207\345\215\227.md" | 15 +++++++++------ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index eaaa97c3..a57bb9ce 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -90,6 +90,10 @@ public MetadataContent generateTaskPromptFromDataWithSchema( * Generates an authorization prompt with metadata from natural-language input using the template identified by the * authorization type, bypassing scenario recognition. * + *

Experimental: the SDK does not yet bundle Authorization-T template resources, so this method answers + * {@code template_not_found} under the classpath source until Authorization-T templates are added or provided + * through the local resource root. + * * @param text natural-language authorization input * @param authorizationType authorization type used as the template identifier * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI @@ -103,6 +107,10 @@ public MetadataContent generateAuthPromptFromText( * Generates an authorization prompt with metadata from structured input and an optional data schema using the * template identified by the authorization type, bypassing scenario recognition. * + *

Experimental: the SDK does not yet bundle Authorization-T template resources, so this method answers + * {@code template_not_found} under the classpath source until Authorization-T templates are added or provided + * through the local resource root. + * * @param data structured authorization input as a string-to-object map * @param schema optional data schema map for schema-guided extraction * @param authorizationType authorization type used as the template identifier diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 4bda9b5a..49540f64 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -403,7 +403,7 @@ mvn -pl a2a-t-sample -am -DskipTests package ## 1.10 Negotiation-T Content Layer -This section describes the Negotiation-T content layer: a template-driven API set for generating negotiation messages, checking their compliance, and extracting parameters from them. Both `A2ATClient` and `A2ATServer` expose the same eleven methods for this purpose. +This section describes the Negotiation-T content layer: a template-driven API set for generating negotiation messages, checking their compliance, and extracting parameters from them. Both `A2ATClient` and `A2ATServer` expose the same thirteen methods for this purpose. ### 1.10.1 Overview and SDK Boundary @@ -411,13 +411,13 @@ The content layer covers three capabilities: 1. **Message generation** — renders a structured negotiation message from typed data (`generateNegotiation*PromptFromData`, deterministic, no LLM) or from free text (`generateNegotiation*PromptFromText`, one LLM content-extraction step followed by deterministic rendering). 2. **Compliance checking and parameter extraction** — `validateAndFilling*Data` checks that a received message is a well-formed negotiation message and extracts its parameters per a caller-provided JSON schema. -3. **Template queries** — `getNegotiationPrompts` / `getNegotiationPrompt` list and load the negotiation templates available for the configured language. +3. **Template queries** — `getPrompts` / `getPrompt` list and load the templates available for the configured language across **all** A2A-T extensions (Task-T, Notification-T, Authorization-T, Negotiation-T); `getNegotiationPrompts` / `getNegotiationPrompt` restrict the same queries to the negotiation templates. The content layer is stateless. It deliberately does not own a session state machine: session identity, round tracking beyond what the message itself carries, and role binding stay with the caller. The pre-existing `startNegotiation` / `receiveNegotiation` / `continueNegotiation` API (see 1.6.3) is unchanged and remains the stateful entry point; the content layer can be combined with it or used standalone. ### 1.10.2 Facade Methods -All eleven methods exist on both `A2ATClient` and `A2ATServer` with identical signatures and semantics. +All thirteen methods exist on both `A2ATClient` and `A2ATServer` with identical signatures and semantics. **Generation from typed data — deterministic, never calls an LLM:** @@ -457,11 +457,14 @@ The pipeline runs in a fixed order: **Template queries — never throw:** ```java -List getNegotiationPrompts() // all templates of the configured language, fixed order -Optional getNegotiationPrompt(String templateUri) // one template by URI; empty on miss +List getPrompts() // templates of ALL extensions for the configured language, sorted by URI +Optional getPrompt(String templateUri) // one template by URI across all extensions; empty on miss + +List getNegotiationPrompts() // negotiation templates of the configured language, fixed order +Optional getNegotiationPrompt(String templateUri) // one negotiation template by URI; empty on miss ``` -A missing template or an unusable URI yields an empty result with a warning log. `PromptTemplate` is a record `(uri, description, content)`. +A missing template or an unusable URI yields an empty result with a warning log. `PromptTemplate` is a record `(uri, description, content)`. The generic pair discovers the extension directories from the bundled resource tree itself — a template directory added under `prompt_resources/templates/` (for example a future `Authorization-T/`) is listed automatically without code changes. Local templates under the configured local resource root override built-in templates of the same path. Note that the SDK does not yet bundle Authorization-T template resources; `generateAuthPromptFromText` / `generateAuthPromptFromDataWithSchema` answer `template_not_found` under the classpath source until such templates are added or provided through the local resource root. ### 1.10.3 Template URIs and Custom Templates diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 278401a0..2b7d2152 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -386,7 +386,7 @@ mvn -pl a2a-t-sample -am -DskipTests package ## 1.10 Negotiation-T 内容层 -本节介绍 Negotiation-T 内容层:一组以模板为驱动的 API,用于生成协商消息、校验协商消息合规性并从中提取参数。`A2ATClient` 与 `A2ATServer` 均暴露相同的十一个方法。 +本节介绍 Negotiation-T 内容层:一组以模板为驱动的 API,用于生成协商消息、校验协商消息合规性并从中提取参数。`A2ATClient` 与 `A2ATServer` 均暴露相同的十三个方法。 ### 1.10.1 概述与 SDK 边界 @@ -394,13 +394,13 @@ mvn -pl a2a-t-sample -am -DskipTests package 1. **消息生成** — 从类型化数据渲染结构化协商消息(`generateNegotiation*PromptFromData`,确定性执行,不调用 LLM),或从自由文本生成(`generateNegotiation*PromptFromText`,先执行一次 LLM 内容抽取,再确定性渲染)。 2. **合规校验与参数提取** — `validateAndFilling*Data` 校验接收到的消息是否为格式正确的协商消息,并按调用方提供的 JSON Schema 提取参数。 -3. **模板查询** — `getNegotiationPrompts` / `getNegotiationPrompt` 列出和加载当前配置语言下可用的协商模板。 +3. **模板查询** — `getPrompts` / `getPrompt` 列出和加载当前配置语言下**全部** A2A-T 扩展(Task-T、Notification-T、Authorization-T、Negotiation-T)可用的模板;`getNegotiationPrompts` / `getNegotiationPrompt` 将同样的查询限定在协商模板。 内容层是无状态的。它刻意不持有会话状态机:会话标识、超出消息本身承载范围的轮次跟踪以及角色绑定都由调用方维护。既有的 `startNegotiation` / `receiveNegotiation` / `continueNegotiation` API(见 1.6.3)保持不变,仍是有状态流程的入口;内容层既可以与之组合使用,也可以独立使用。 ### 1.10.2 门面方法 -以下十一个方法在 `A2ATClient` 和 `A2ATServer` 上具有完全相同的签名和语义。 +以下十三个方法在 `A2ATClient` 和 `A2ATServer` 上具有完全相同的签名和语义。 **从类型化数据生成 — 确定性执行,绝不调用 LLM:** @@ -440,11 +440,14 @@ FilledParamData validateAndFillingRejectData(String prompt, Map **模板查询 — 永不抛出异常:** ```java -List getNegotiationPrompts() // 当前配置语言下的全部模板,固定顺序 -Optional getNegotiationPrompt(String templateUri) // 按 URI 加载单个模板;未命中返回空 +List getPrompts() // 全部扩展在当前配置语言下的模板,按 URI 排序 +Optional getPrompt(String templateUri) // 跨扩展按 URI 加载单个模板;未命中返回空 + +List getNegotiationPrompts() // 当前配置语言下的全部协商模板,固定顺序 +Optional getNegotiationPrompt(String templateUri) // 按 URI 加载单个协商模板;未命中返回空 ``` -模板缺失或 URI 不可用时返回空结果并记录 warning 日志。`PromptTemplate` 是一个记录类型 `(uri, description, content)`。 +模板缺失或 URI 不可用时返回空结果并记录 warning 日志。`PromptTemplate` 是一个记录类型 `(uri, description, content)`。泛化查询对直接从内置资源树中发现扩展目录 — 在 `prompt_resources/templates/` 下新增的模板目录(例如未来的 `Authorization-T/`)会被自动列出,无需代码改动。配置的本地资源根目录下的模板会覆盖同路径的内置模板。注意:SDK 尚未打包 Authorization-T 模板资源,`generateAuthPromptFromText` / `generateAuthPromptFromDataWithSchema` 在 classpath 源下会返回 `template_not_found`,直至补充该类模板或通过本地资源根目录提供。 ### 1.10.3 模板 URI 与自定义模板 From 731c12508fdca4992c3a6003abc6006e26a3c21e Mon Sep 17 00:00:00 2001 From: w00609601 Date: Thu, 20 Aug 2026 18:24:14 +0800 Subject: [PATCH 055/295] feat: remove local_rule LLM provider mode Replace local_rule with mock LLM for testing. Builder now validates provider against LLMClientFactory registry instead of hardcoding provider names. Deleted all local_rule-only implementations: - TemplateMatchingPromptMetadataExtractor - TemplateRoundTripPromptSemanticValidator - ClasspathServerPromptTemplateLoader - LocalFileServerPromptTemplateLoader - PromptTemplateDefinition / PromptTemplateSlotDefinition Signed-off-by: w00609601 --- .../assembly/DefaultA2ATClientBuilder.java | 520 +++++++++--------- .../a2at/sdk/client/api/A2ATClientTest.java | 115 ++-- .../a2at/sdk/core/model/A2ATConfigTest.java | 2 +- .../ClasspathServerPromptTemplateLoader.java | 46 -- .../assembly/DefaultA2ATServerBuilder.java | 368 ++++++------- .../LocalFileServerPromptTemplateLoader.java | 96 ---- ...mplateMatchingPromptMetadataExtractor.java | 123 ----- .../model/PromptTemplateDefinition.java | 18 - .../model/PromptTemplateSlotDefinition.java | 10 - ...plateRoundTripPromptSemanticValidator.java | 46 -- ...calFileServerPromptTemplateLoaderTest.java | 83 --- ...teMatchingPromptMetadataExtractorTest.java | 165 ------ docs/en/developer_guide.md | 2 +- ...00\345\217\221\346\214\207\345\215\227.md" | 2 +- 14 files changed, 483 insertions(+), 1113 deletions(-) delete mode 100644 a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ClasspathServerPromptTemplateLoader.java delete mode 100644 a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoader.java delete mode 100644 a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractor.java delete mode 100644 a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateDefinition.java delete mode 100644 a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateSlotDefinition.java delete mode 100644 a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/TemplateRoundTripPromptSemanticValidator.java delete mode 100644 a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java delete mode 100644 a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractorTest.java diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index b0555a5d..e74e667c 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -1,275 +1,245 @@ -package net.openan.a2at.sdk.client.prompt.assembly; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; -import net.openan.a2at.sdk.client.prompt.extractor.DefaultStructuredClientSlotValueExtractor; -import net.openan.a2at.sdk.client.prompt.extractor.DefaultTemplateDrivenSlotValueExtractor; -import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; -import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; -import net.openan.a2at.sdk.client.prompt.loader.DefaultClasspathClientSlotSchemaLoader; -import net.openan.a2at.sdk.client.prompt.loader.DefaultClasspathClientTemplateLoader; -import net.openan.a2at.sdk.client.prompt.loader.LocalFileClientSlotSchemaLoader; -import net.openan.a2at.sdk.client.prompt.loader.LocalFileClientTemplateLoader; -import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMConfigLoader; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationRole; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; -import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; -import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; - -/** - * Default builder that assembles one high-level A2AT client runtime from unified config. - * - * @since 2026-06 - */ -public final class DefaultA2ATClientBuilder { - - private static final String LOCAL_RULE_PROVIDER = "local_rule"; - - private static final String OPENAI_PROVIDER = "openai"; - - private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; - - private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; - - private A2ATConfig config; - - private Path envPath; - - /** - * Creates one new builder instance. - * - * @return empty client builder - */ - public static DefaultA2ATClientBuilder builder() { - return new DefaultA2ATClientBuilder(); - } - - /** - * Configures the unified SDK config consumed by the high-level client facade. - * - * @param config unified SDK config - * @return current builder - */ - public DefaultA2ATClientBuilder config(A2ATConfig config) { - this.config = config; - return this; - } - - /** - * Configures the `.env` file path used to assemble downstream facades. - * - * @param envPath caller-supplied `.env` path - * @return current builder - */ - public DefaultA2ATClientBuilder envPath(Path envPath) { - this.envPath = envPath; - return this; - } - - /** - * Builds the default prompt-generation orchestrator from the configured unified SDK config. - * - * @return assembled prompt-generation orchestrator - */ - public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { - require(config, "Unified SDK config must be configured."); - require(envPath, "Unified SDK env path must be configured."); - requireSupportedConfig(); - - PromptResourceAccess resources = PromptResourceAccess.create(config.prompt()); - List scenarios = - resources.loadScenarios(config.prompt().language()); - - ClientSlotSchemaLoader slotSchemaLoader = - newClientSlotSchemaLoader(resources, config.prompt().sourceType()); - ClientTemplateLoader templateLoader = - newClientTemplateLoader(resources, config.prompt().sourceType()); - String language = config.prompt().language(); - - boolean isLocalRule = LOCAL_RULE_PROVIDER.equals(config.llm().provider()); - LLMClient llmClient = isLocalRule ? null : createLlmClient(); - - String scenarioSystemPrompt = - isLocalRule ? "" : resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); - String scenarioUserPrompt = - isLocalRule ? "" : resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); - String slotSystemPrompt = - isLocalRule ? "" : resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); - String slotUserPrompt = isLocalRule ? "" : resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); - - ClientScenarioRecognizer scenarioRecognizer; - ClientSlotValueExtractor slotValueExtractor; - - if (isLocalRule) { - scenarioRecognizer = (normalizedInput, ignoredScenarios, systemPrompt, userPrompt) -> { - if (scenarios.isEmpty()) { - return new ScenarioRecognitionResult(false, null, "No scenarios configured."); - } - return new ScenarioRecognitionResult(true, scenarios.get(0).scenarioCode(), null); - }; - slotValueExtractor = new DefaultTemplateDrivenSlotValueExtractor(slotSchemaLoader); - } else { - scenarioRecognizer = - new SingleScenarioAwareRecognizer(scenarios, new ScenarioRecognizer(llmClient)::recognize); - slotValueExtractor = new StructuredInputAwareSlotValueExtractor( - new DefaultTemplateDrivenSlotValueExtractor(slotSchemaLoader), - new DefaultStructuredClientSlotValueExtractor( - llmClient, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)); - } - - return ClientPromptGenerationOrchestratorBuilder.builder() - .llmClient(llmClient) - .scenarios(scenarios) - .language(language) - .scenarioSystemPrompt(scenarioSystemPrompt) - .scenarioUserPrompt(scenarioUserPrompt) - .slotSystemPrompt(slotSystemPrompt) - .slotUserPrompt(slotUserPrompt) - .scenarioRecognizer(scenarioRecognizer) - .templateLoader(templateLoader) - .slotSchemaLoader(slotSchemaLoader) - .slotValueExtractor(slotValueExtractor) - .renderer(new TaskPromptRenderer()) - .build(); - } - - /** - * Builds the default negotiation orchestrator from the configured unified SDK config. - * - * @return assembled negotiation orchestrator - */ - public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - return new RoleBoundNegotiationOrchestrator( - NegotiationHandler.builder() - .store(new InMemoryNegotiationStore()) - .build(), - NegotiationRole.CLIENT); - } - - /** - * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. - * - *

The wiring is shared with the server side through - * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local - * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config, and the LLM - * client is only created when the provider is not {@code local_rule}. - * - * @return assembled negotiation generation orchestrator - */ - public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - LLMClient llmClient = null; - if (!LOCAL_RULE_PROVIDER.equals(config.llm().provider())) { - require(envPath, "Unified SDK env path must be configured."); - llmClient = createLlmClient(); - } - return NegotiationContentService.buildOrchestrator(config, llmClient); - } - - private void requireSupportedConfig() { - if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) - && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals( - config.prompt().sourceType())) { - throw new UnsupportedOperationException( - "Unsupported prompt source type: " + config.prompt().sourceType()); - } - if (!LOCAL_RULE_PROVIDER.equals(config.llm().provider()) - && !OPENAI_PROVIDER.equals(config.llm().provider())) { - throw new UnsupportedOperationException( - "Unsupported LLM provider: " + config.llm().provider()); - } - if (!"in_memory".equals(config.negotiation().stateStoreType())) { - throw new UnsupportedOperationException("Unsupported negotiation state store type: " - + config.negotiation().stateStoreType()); - } - } - - private LLMClient createLlmClient() { - LLMClientConfig loadedConfig = LLMConfigLoader.load(envPath); - return LLMClientFactory.create(loadedConfig.provider(), loadedConfig); - } - - private static void require(Object value, String message) { - if (value == null) { - throw new IllegalStateException(message); - } - } - - private static ClientTemplateLoader newClientTemplateLoader(PromptResourceAccess resources, String sourceType) { - if (PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(sourceType)) { - return new DefaultClasspathClientTemplateLoader(resources.classpathResourceLoader()); - } - if (PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals(sourceType)) { - return new LocalFileClientTemplateLoader(resources.localRootDir()); - } - throw new UnsupportedOperationException("Unsupported prompt source type: " + sourceType); - } - - private static ClientSlotSchemaLoader newClientSlotSchemaLoader(PromptResourceAccess resources, String sourceType) { - if (PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(sourceType)) { - return new DefaultClasspathClientSlotSchemaLoader(resources.classpathResourceLoader()); - } - if (PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals(sourceType)) { - return new LocalFileClientSlotSchemaLoader(resources.localRootDir()); - } - throw new UnsupportedOperationException("Unsupported prompt source type: " + sourceType); - } - - private record SingleScenarioAwareRecognizer(List scenarios, ClientScenarioRecognizer delegate) - implements ClientScenarioRecognizer { - - @Override - public ScenarioRecognitionResult recognize( - String normalizedInput, - List ignoredScenarios, - String systemPrompt, - String userPrompt) { - if (scenarios.size() == 1) { - return new ScenarioRecognitionResult(true, scenarios.get(0).scenarioCode(), null); - } - return delegate.recognize(normalizedInput, scenarios, systemPrompt, userPrompt); - } - } - - private record StructuredInputAwareSlotValueExtractor( - ClientSlotValueExtractor structuredExtractor, ClientSlotValueExtractor llmExtractor) - implements ClientSlotValueExtractor { - - @Override - public Map extractSlots( - Object userInput, String scenarioCode, String language, String templateText) { - if (userInput instanceof Map) { - return structuredExtractor.extractSlots(userInput, scenarioCode, language, templateText); - } - return llmExtractor.extractSlots(userInput, scenarioCode, language, templateText); - } - - @Override - public Map extractSlotsWithSchema( - Object userInput, - String scenarioCode, - String language, - String templateText, - Map dataSchema) { - return llmExtractor.extractSlotsWithSchema(userInput, scenarioCode, language, templateText, dataSchema); - } - } -} +package net.openan.a2at.sdk.client.prompt.assembly; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; +import net.openan.a2at.sdk.client.prompt.extractor.DefaultStructuredClientSlotValueExtractor; +import net.openan.a2at.sdk.client.prompt.extractor.DefaultTemplateDrivenSlotValueExtractor; +import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; +import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; +import net.openan.a2at.sdk.client.prompt.loader.DefaultClasspathClientSlotSchemaLoader; +import net.openan.a2at.sdk.client.prompt.loader.DefaultClasspathClientTemplateLoader; +import net.openan.a2at.sdk.client.prompt.loader.LocalFileClientSlotSchemaLoader; +import net.openan.a2at.sdk.client.prompt.loader.LocalFileClientTemplateLoader; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMConfigLoader; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationRole; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; + +/** + * Default builder that assembles one high-level A2AT client runtime from unified config. + * + * @since 2026-06 + */ +public final class DefaultA2ATClientBuilder { + + private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; + + private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; + + private A2ATConfig config; + + private Path envPath; + + /** + * Creates one new builder instance. + * + * @return empty client builder + */ + public static DefaultA2ATClientBuilder builder() { + return new DefaultA2ATClientBuilder(); + } + + /** + * Configures the unified SDK config consumed by the high-level client facade. + * + * @param config unified SDK config + * @return current builder + */ + public DefaultA2ATClientBuilder config(A2ATConfig config) { + this.config = config; + return this; + } + + /** + * Configures the `.env` file path used to assemble downstream facades. + * + * @param envPath caller-supplied `.env` path + * @return current builder + */ + public DefaultA2ATClientBuilder envPath(Path envPath) { + this.envPath = envPath; + return this; + } + + /** + * Builds the default prompt-generation orchestrator from the configured unified SDK config. + * + * @return assembled prompt-generation orchestrator + */ + public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { + require(config, "Unified SDK config must be configured."); + require(envPath, "Unified SDK env path must be configured."); + requireSupportedConfig(); + + PromptResourceAccess resources = PromptResourceAccess.create(config.prompt()); + List scenarios = + resources.loadScenarios(config.prompt().language()); + + ClientSlotSchemaLoader slotSchemaLoader = + newClientSlotSchemaLoader(resources, config.prompt().sourceType()); + ClientTemplateLoader templateLoader = + newClientTemplateLoader(resources, config.prompt().sourceType()); + String language = config.prompt().language(); + + LLMClient llmClient = createLlmClient(); + + String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); + String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); + String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); + String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); + + ClientScenarioRecognizer scenarioRecognizer = + new SingleScenarioAwareRecognizer(scenarios, new ScenarioRecognizer(llmClient)::recognize); + ClientSlotValueExtractor slotValueExtractor = new StructuredInputAwareSlotValueExtractor( + new DefaultTemplateDrivenSlotValueExtractor(slotSchemaLoader), + new DefaultStructuredClientSlotValueExtractor( + llmClient, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)); + + return ClientPromptGenerationOrchestratorBuilder.builder() + .llmClient(llmClient) + .scenarios(scenarios) + .language(language) + .scenarioSystemPrompt(scenarioSystemPrompt) + .scenarioUserPrompt(scenarioUserPrompt) + .slotSystemPrompt(slotSystemPrompt) + .slotUserPrompt(slotUserPrompt) + .scenarioRecognizer(scenarioRecognizer) + .templateLoader(templateLoader) + .slotSchemaLoader(slotSchemaLoader) + .slotValueExtractor(slotValueExtractor) + .renderer(new TaskPromptRenderer()) + .build(); + } + + /** + * Builds the default negotiation orchestrator from the configured unified SDK config. + * + * @return assembled negotiation orchestrator + */ + public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + return new RoleBoundNegotiationOrchestrator( + NegotiationHandler.builder() + .store(new InMemoryNegotiationStore()) + .build(), + NegotiationRole.CLIENT); + } + + /** + * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. + * + *

The wiring is shared with the server side through + * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local + * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config. + * + * @return assembled negotiation generation orchestrator + */ + public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + require(envPath, "Unified SDK env path must be configured."); + return NegotiationContentService.buildOrchestrator(config, createLlmClient()); + } + + private void requireSupportedConfig() { + if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) + && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals( + config.prompt().sourceType())) { + throw new UnsupportedOperationException( + "Unsupported prompt source type: " + config.prompt().sourceType()); + } + if (!LLMClientFactory.availableProviders().contains(config.llm().provider())) { + throw new UnsupportedOperationException( + "Unsupported LLM provider: " + config.llm().provider()); + } + if (!"in_memory".equals(config.negotiation().stateStoreType())) { + throw new UnsupportedOperationException("Unsupported negotiation state store type: " + + config.negotiation().stateStoreType()); + } + } + + private LLMClient createLlmClient() { + LLMClientConfig loadedConfig = LLMConfigLoader.load(envPath); + return LLMClientFactory.create(loadedConfig.provider(), loadedConfig); + } + + private static void require(Object value, String message) { + if (value == null) { + throw new IllegalStateException(message); + } + } + + private static ClientTemplateLoader newClientTemplateLoader(PromptResourceAccess resources, String sourceType) { + if (PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(sourceType)) { + return new DefaultClasspathClientTemplateLoader(resources.classpathResourceLoader()); + } + if (PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals(sourceType)) { + return new LocalFileClientTemplateLoader(resources.localRootDir()); + } + throw new UnsupportedOperationException("Unsupported prompt source type: " + sourceType); + } + + private static ClientSlotSchemaLoader newClientSlotSchemaLoader(PromptResourceAccess resources, String sourceType) { + if (PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(sourceType)) { + return new DefaultClasspathClientSlotSchemaLoader(resources.classpathResourceLoader()); + } + if (PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals(sourceType)) { + return new LocalFileClientSlotSchemaLoader(resources.localRootDir()); + } + throw new UnsupportedOperationException("Unsupported prompt source type: " + sourceType); + } + + private record SingleScenarioAwareRecognizer(List scenarios, ClientScenarioRecognizer delegate) + implements ClientScenarioRecognizer { + + @Override + public ScenarioRecognitionResult recognize( + String normalizedInput, + List ignoredScenarios, + String systemPrompt, + String userPrompt) { + if (scenarios.size() == 1) { + return new ScenarioRecognitionResult(true, scenarios.get(0).scenarioCode(), null); + } + return delegate.recognize(normalizedInput, scenarios, systemPrompt, userPrompt); + } + } + + private record StructuredInputAwareSlotValueExtractor( + ClientSlotValueExtractor structuredExtractor, ClientSlotValueExtractor llmExtractor) + implements ClientSlotValueExtractor { + + @Override + public Map extractSlots( + Object userInput, String scenarioCode, String language, String templateText) { + if (userInput instanceof Map) { + return structuredExtractor.extractSlots(userInput, scenarioCode, language, templateText); + } + return llmExtractor.extractSlots(userInput, scenarioCode, language, templateText); + } + + @Override + public Map extractSlotsWithSchema( + Object userInput, String scenarioCode, String language, String templateText, + Map dataSchema) { + return llmExtractor.extractSlotsWithSchema(userInput, scenarioCode, language, templateText, dataSchema); + } + } +} \ No newline at end of file diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index 2cbdc68f..83126632 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -1,7 +1,6 @@ package net.openan.a2at.sdk.client; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -10,19 +9,34 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; class A2ATClientTest { + private static final String TEST_MOCK_PROVIDER = "test-a2at-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + @Test void onlyExposesPathBasedPublicConstructor() throws NoSuchMethodException { assertNotNull(A2ATClient.class.getConstructor(Path.class)); @@ -93,7 +107,7 @@ void pathBasedConstructorLoadsLocalPromptResources() throws IOException { void pathBasedConstructorAcceptsRelativeEnvPath() throws IOException { Path targetDir = Files.createDirectories(Path.of("target")); Path tempDir = Files.createTempDirectory(targetDir, "a2at-client-relative-env"); - writeMinimalClientEnv(tempDir, "local_rule"); + writeMinimalClientEnv(tempDir, "openai"); Path relativeEnvPath = targetDir.getFileName().resolve(tempDir.getFileName()).resolve("client.env"); @@ -170,6 +184,15 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO Files.createDirectories(templatesDir); Files.createDirectories(slotsDir); + Path scenarioPromptDir = promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); + Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); + Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); + Files.writeString( scenariosDir.resolve("scenarios.json"), """ @@ -208,8 +231,11 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=local_file A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=local_rule - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=example-model +A2AT_LLM_BASE_URL=https://llm.example.test/v1 +A2AT_LLM_API_KEY=test-key +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory """); A2ATClient client = new A2ATClient(envFile); @@ -221,7 +247,7 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO @Test void fromTextEntryPointsReturnMetadataContent() throws IOException { - Path envFile = writeMinimalClientEnvWithoutRequiredSlots("local_rule"); + Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); A2ATClient client = new A2ATClient(envFile); MetadataContent taskResult = @@ -279,7 +305,7 @@ void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { @Test void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { - Path envFile = writeMinimalClientEnvWithoutRequiredSlots("local_rule"); + Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); A2ATClient client = new A2ATClient(envFile); MetadataContent taskResult = @@ -353,43 +379,6 @@ void fromDataWithSchemaThrowsOnInvalidTemplateUri() throws IOException { assertEquals("invalid_template_uri", notifEx.code()); } - @Test - void fromTextUnderLocalRuleRendersEmptySlotsForNlInput() throws IOException { - Path envFile = writeMinimalClientEnvWithoutRequiredSlots("local_rule"); - A2ATClient client = new A2ATClient(envFile); - - MetadataContent result = - client.generateTaskPromptFromText("Please analyze Site A power usage.", "Task-T/v1/unconstrained"); - - assertEquals("Task-T/v1/unconstrained", result.templateUri()); - assertEquals("Notes: \\nFault: ", result.promptText()); - } - - @Test - void fromDataWithSchemaUnderLocalRuleIgnoresSchema() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); - Map schema = Map.of("type", "object", "required", Arrays.asList("site", "target")); - - MetadataContent result = - client.generateTaskPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); - - assertEquals("Task-T/v1/energy-saving", result.templateUri()); - assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); - } - - @Test - void fromTextThrowsSlotValidationErrorWhenRequiredSlotsMissing() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - -PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> client.generateTaskPromptFromText("Some text without site or target.", "Task-T/v1/energy-saving")); - assertEquals("slot_validation_error", ex.code()); - assertFalse(ex.failedParameters().isEmpty()); - } - private static Path writeMinimalClientEnvWithoutRequiredSlots(String provider) throws IOException { Path tempDir = Files.createTempDirectory("a2at-client-env-no-required"); Path promptRoot = tempDir.resolve("prompt_resources"); @@ -460,7 +449,7 @@ private static Path writeMinimalClientEnvWithoutRequiredSlots(String provider) t } private static Path writeMinimalLocalClientEnv() throws IOException { - return writeMinimalClientEnv("local_rule"); + return writeMinimalClientEnv(TEST_MOCK_PROVIDER); } private static Path writeMinimalClasspathClientEnv() throws IOException { @@ -472,7 +461,10 @@ private static Path writeMinimalClasspathClientEnv() throws IOException { A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=local_rule + A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=example-model +A2AT_LLM_BASE_URL=https://llm.example.test/v1 +A2AT_LLM_API_KEY=test-key A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory """); return envFile; @@ -549,4 +541,37 @@ private static Path writeMinimalClientEnv(Path tempDir, String provider) throws .formatted(provider)); return envFile; } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(buildResponse(jsonSchema), config.model(), Map.of(), Map.of()); + } + + private static String buildResponse(Map jsonSchema) { + Object slotNames = jsonSchema.get("slotNames"); + StringBuilder slots = new StringBuilder("{"); + if (slotNames instanceof List names) { + for (int i = 0; i < names.size(); i++) { + if (i > 0) { + slots.append(","); + } + slots.append("\"").append(names.get(i)).append("\":\"placeholder\""); + } + } + slots.append("}"); + return "{\"slots\":" + slots + ",\"slot_errors\":[]}"; + } + } } diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java index f28bc818..f686df73 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java @@ -92,7 +92,7 @@ void should_useClasspathDefaults_When_promptKeysAreMissing() throws IOException Files.writeString( envFile, """ - A2AT_LLM_PROVIDER=local_rule + A2AT_LLM_PROVIDER=openai A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory """); diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ClasspathServerPromptTemplateLoader.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ClasspathServerPromptTemplateLoader.java deleted file mode 100644 index 91370ec0..00000000 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ClasspathServerPromptTemplateLoader.java +++ /dev/null @@ -1,46 +0,0 @@ -package net.openan.a2at.sdk.server.assembly; - -import java.util.List; -import net.openan.a2at.sdk.prompt.resources.loader.ClasspathPromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.ClasspathPromptTemplateLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; -import net.openan.a2at.sdk.server.model.PromptTemplateDefinition; -import net.openan.a2at.sdk.server.model.PromptTemplateSlotDefinition; - -/** - * Loads server-side prompt template definitions from packaged classpath prompt resources. - * - * @since 2026-06 - */ -public final class ClasspathServerPromptTemplateLoader { - - private final PromptTemplateTextLoader templateLoader; - - private final PromptSlotSchemaLoader slotSchemaLoader; - - public ClasspathServerPromptTemplateLoader(ClasspathPromptResourceLoader resourceLoader) { - this.templateLoader = new ClasspathPromptTemplateLoader(resourceLoader); - this.slotSchemaLoader = new ClasspathPromptSlotSchemaLoader(resourceLoader); - } - - public PromptTemplateDefinition load(String scenarioCode, String language) { - String templateText = templateLoader.loadTemplate(scenarioCode, language); - PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(scenarioCode, language); - return new PromptTemplateDefinition( - scenarioCode, language, templateText, toSlotDefinitions(slotSchema)); - } - - public List loadAll(List scenarios, String language) { - return scenarios.stream().map(scenario -> load(scenario.scenarioCode(), language)).toList(); - } - - private static List toSlotDefinitions(PromptSlotSchema slotSchema) { - return slotSchema.slotDefinitions().stream() - .map(def -> new PromptTemplateSlotDefinition(def.name(), def.required())) - .toList(); - } -} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index 83b8f664..9dac7afe 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -1,203 +1,165 @@ -package net.openan.a2at.sdk.server.assembly; - -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMConfigLoader; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; -import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.server.compliance.DefaultServerPromptComplianceOrchestrator; -import net.openan.a2at.sdk.server.metadata.LlmBackedPromptMetadataExtractor; -import net.openan.a2at.sdk.server.metadata.TemplateMatchingPromptMetadataExtractor; -import net.openan.a2at.sdk.server.model.PromptTemplateDefinition; -import net.openan.a2at.sdk.server.validation.LlmBackedPromptSemanticValidator; -import net.openan.a2at.sdk.server.validation.TemplateRoundTripPromptSemanticValidator; - -/** - * Default builder that assembles one high-level A2AT server runtime from unified config. - * - * @since 2026-06 - */ -public final class DefaultA2ATServerBuilder { - - private static final String LOCAL_RULE_PROVIDER = "local_rule"; - - private static final String OPENAI_PROVIDER = "openai"; - - private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; - - private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; - - private static final String SEMANTIC_VALIDATION_PROMPT = "semantic_validation"; - - private A2ATConfig config; - - private Path envPath; - - /** - * Creates one new builder instance. - * - * @return empty server builder - */ - public static DefaultA2ATServerBuilder builder() { - return new DefaultA2ATServerBuilder(); - } - - /** - * Configures the unified SDK config consumed by the high-level server facade. - * - * @param config unified SDK config - * @return current builder - */ - public DefaultA2ATServerBuilder config(A2ATConfig config) { - this.config = config; - return this; - } - - /** - * Configures the `.env` file path used to assemble downstream facades. - * - * @param envPath caller-supplied `.env` path - * @return current builder - */ - public DefaultA2ATServerBuilder envPath(Path envPath) { - this.envPath = envPath; - return this; - } - - /** - * Builds the default prompt-compliance orchestrator from the configured unified SDK config. - * - * @return assembled prompt-compliance orchestrator - */ - public DefaultServerPromptComplianceOrchestrator buildPromptComplianceOrchestrator() { - require(config, "Unified SDK config must be configured."); - require(envPath, "Unified SDK env path must be configured."); - requireSupportedConfig(); - - PromptResourceAccess resources = PromptResourceAccess.create(config.prompt()); - if (LOCAL_RULE_PROVIDER.equals(config.llm().provider())) { - return buildLocalRulePromptComplianceOrchestrator(resources); - } - - return buildLlmBackedPromptComplianceOrchestrator(resources); - } - - private DefaultServerPromptComplianceOrchestrator buildLocalRulePromptComplianceOrchestrator( - PromptResourceAccess resources) { - String language = config.prompt().language(); - List templates; - if (resources.classpath()) { - List scenarios = resources.loadScenarios(language); - templates = new ClasspathServerPromptTemplateLoader(resources.classpathResourceLoader()) - .loadAll(scenarios, language); - } else { - templates = new LocalFileServerPromptTemplateLoader(resources.localRootDir()).loadAll(language); - } - return new DefaultServerPromptComplianceOrchestrator( - new TemplateMatchingPromptMetadataExtractor(templates), new TemplateRoundTripPromptSemanticValidator()); - } - - private DefaultServerPromptComplianceOrchestrator buildLlmBackedPromptComplianceOrchestrator( - PromptResourceAccess resources) { - String language = config.prompt().language(); - List scenarios = resources.loadScenarios(language); - PromptTemplateTextLoader templateLoader = resources.templateLoader(); - PromptSlotSchemaLoader slotSchemaLoader = resources.slotSchemaLoader(); - LLMClient llmClient = createLlmClient(); - - String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); - String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); - String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); - String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); - String semanticSystemPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "system.md"); - String semanticUserPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "user.md"); - - return new DefaultServerPromptComplianceOrchestrator( - new LlmBackedPromptMetadataExtractor( - new ScenarioRecognizer(llmClient), - scenarios, - language, - scenarioSystemPrompt, - scenarioUserPrompt, - templateLoader, - slotSchemaLoader, - new DefaultStructuredPromptSlotValueExtractor( - llmClient, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)), - new LlmBackedPromptSemanticValidator( - llmClient, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt)); - } - - /** - * Builds the default negotiation orchestrator from the configured unified SDK config. - * - * @return assembled negotiation orchestrator - */ - public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - return new ServerNegotiationOrchestratorBuilder() - .promptComplianceOrchestrator(buildPromptComplianceOrchestrator()) - .build(); - } - - /** - * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. - * - *

The wiring is shared with the client side through - * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local - * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config, and the LLM - * client is only created when the provider is not {@code local_rule}. - * - * @return assembled negotiation generation orchestrator - */ - public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - LLMClient llmClient = null; - if (!LOCAL_RULE_PROVIDER.equals(config.llm().provider())) { - require(envPath, "Unified SDK env path must be configured."); - llmClient = createLlmClient(); - } - return NegotiationContentService.buildOrchestrator(config, llmClient); - } - - private static void require(Object value, String message) { - if (value == null) { - throw new IllegalStateException(message); - } - } - - private void requireSupportedConfig() { - if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) - && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals( - config.prompt().sourceType())) { - throw new UnsupportedOperationException( - "Unsupported prompt source type: " + config.prompt().sourceType()); - } - if (!LOCAL_RULE_PROVIDER.equals(config.llm().provider()) - && !OPENAI_PROVIDER.equals(config.llm().provider())) { - throw new UnsupportedOperationException( - "Unsupported LLM provider: " + config.llm().provider()); - } - if (!"in_memory".equals(config.negotiation().stateStoreType())) { - throw new UnsupportedOperationException("Unsupported negotiation state store type: " - + config.negotiation().stateStoreType()); - } - } - - private LLMClient createLlmClient() { - LLMClientConfig loadedConfig = LLMConfigLoader.load(envPath); - return LLMClientFactory.create(loadedConfig.provider(), loadedConfig); - } -} +package net.openan.a2at.sdk.server.assembly; + +import java.nio.file.Path; +import java.util.List; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMConfigLoader; +import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; +import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.server.compliance.DefaultServerPromptComplianceOrchestrator; +import net.openan.a2at.sdk.server.metadata.LlmBackedPromptMetadataExtractor; +import net.openan.a2at.sdk.server.validation.LlmBackedPromptSemanticValidator; + +/** + * Default builder that assembles one high-level A2AT server runtime from unified config. + * + * @since 2026-06 + */ +public final class DefaultA2ATServerBuilder { + + private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; + + private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; + + private static final String SEMANTIC_VALIDATION_PROMPT = "semantic_validation"; + + private A2ATConfig config; + + private Path envPath; + + /** + * Creates one new builder instance. + * + * @return empty server builder + */ + public static DefaultA2ATServerBuilder builder() { + return new DefaultA2ATServerBuilder(); + } + + /** + * Configures the unified SDK config consumed by the high-level server facade. + * + * @param config unified SDK config + * @return current builder + */ + public DefaultA2ATServerBuilder config(A2ATConfig config) { + this.config = config; + return this; + } + + /** + * Configures the `.env` file path used to assemble downstream facades. + * + * @param envPath caller-supplied `.env` path + * @return current builder + */ + public DefaultA2ATServerBuilder envPath(Path envPath) { + this.envPath = envPath; + return this; + } + + /** + * Builds the default prompt-compliance orchestrator from the configured unified SDK config. + * + * @return assembled prompt-compliance orchestrator + */ + public DefaultServerPromptComplianceOrchestrator buildPromptComplianceOrchestrator() { + require(config, "Unified SDK config must be configured."); + require(envPath, "Unified SDK env path must be configured."); + requireSupportedConfig(); + + PromptResourceAccess resources = PromptResourceAccess.create(config.prompt()); + String language = config.prompt().language(); + List scenarios = resources.loadScenarios(language); + PromptTemplateTextLoader templateLoader = resources.templateLoader(); + PromptSlotSchemaLoader slotSchemaLoader = resources.slotSchemaLoader(); + LLMClient llmClient = createLlmClient(); + + String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); + String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); + String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); + String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); + String semanticSystemPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "system.md"); + String semanticUserPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "user.md"); + + return new DefaultServerPromptComplianceOrchestrator( + new LlmBackedPromptMetadataExtractor( + new ScenarioRecognizer(llmClient), + scenarios, + language, + scenarioSystemPrompt, + scenarioUserPrompt, + templateLoader, + slotSchemaLoader, + new DefaultStructuredPromptSlotValueExtractor( + llmClient, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)), + new LlmBackedPromptSemanticValidator( + llmClient, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt)); + } + + /** + * Builds the default negotiation orchestrator from the configured unified SDK config. + * + * @return assembled negotiation orchestrator + */ + public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + return new ServerNegotiationOrchestratorBuilder() + .promptComplianceOrchestrator(buildPromptComplianceOrchestrator()) + .build(); + } + + /** + * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. + * + *

The wiring is shared with the client side through + * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local + * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config. + * + * @return assembled negotiation generation orchestrator + */ + public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + require(envPath, "Unified SDK env path must be configured."); + return NegotiationContentService.buildOrchestrator(config, createLlmClient()); + } + + private static void require(Object value, String message) { + if (value == null) { + throw new IllegalStateException(message); + } + } + + private void requireSupportedConfig() { + if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) + && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals(config.prompt().sourceType())) { + throw new UnsupportedOperationException( + "Unsupported prompt source type: " + config.prompt().sourceType()); + } + if (!LLMClientFactory.availableProviders().contains(config.llm().provider())) { + throw new UnsupportedOperationException("Unsupported LLM provider: " + config.llm().provider()); + } + if (!"in_memory".equals(config.negotiation().stateStoreType())) { + throw new UnsupportedOperationException( + "Unsupported negotiation state store type: " + config.negotiation().stateStoreType()); + } + } + + private LLMClient createLlmClient() { + LLMClientConfig loadedConfig = LLMConfigLoader.load(envPath); + return LLMClientFactory.create(loadedConfig.provider(), loadedConfig); + } + +} \ No newline at end of file diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoader.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoader.java deleted file mode 100644 index d24b9073..00000000 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoader.java +++ /dev/null @@ -1,96 +0,0 @@ -package net.openan.a2at.sdk.server.assembly; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.prompt.resources.loader.LocalFilePromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.LocalFilePromptTemplateLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.server.model.PromptTemplateDefinition; -import net.openan.a2at.sdk.server.model.PromptTemplateSlotDefinition; - -/** - * Loads server-side prompt template definitions from one local prompt resource root. - * - * @since 2026-06 - */ -public final class LocalFileServerPromptTemplateLoader { - - private final PromptTemplateTextLoader templateLoader; - - private final PromptSlotSchemaLoader slotSchemaLoader; - - private final Path promptRootDir; - - /** - * Creates one local template loader. - * - * @param promptRootDir prompt resource root directory - */ - public LocalFileServerPromptTemplateLoader(Path promptRootDir) { - this.promptRootDir = promptRootDir; - this.templateLoader = new LocalFilePromptTemplateLoader(promptRootDir); - this.slotSchemaLoader = new LocalFilePromptSlotSchemaLoader(promptRootDir); - } - - /** - * Loads one prompt template definition for one scenario and language. - * - * @param scenarioCode scenario code - * @param language language - * @return prompt template definition - */ - public PromptTemplateDefinition load(String scenarioCode, String language) { - String templateText = templateLoader.loadTemplate(scenarioCode, language); - PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(scenarioCode, language); - return new PromptTemplateDefinition( - scenarioCode, language, templateText, toSlotDefinitions(slotSchema)); - } - - /** - * Loads all prompt template definitions available for one language. - * - * @param language language - * @return all discovered prompt template definitions - */ - public List loadAll(String language) { - Path templatesRoot = promptRootDir.resolve("templates"); - if (!Files.exists(templatesRoot)) { - throw new ResourceNotFoundException("Prompt resource file does not exist.", templatesRoot.toString()); - } - - List scenarioCodes = new ArrayList<>(); - try (var typePaths = Files.list(templatesRoot)) { - for (Path typeDir : (Iterable) typePaths.filter(Files::isDirectory)::iterator) { - Path versionDir = typeDir.resolve("v1"); - if (!Files.isDirectory(versionDir)) { - continue; - } - try (Stream scenarioPaths = Files.list(versionDir)) { - scenarioPaths.filter(Files::isDirectory) - .map(path -> path.getFileName().toString()) - .forEach(scenarioCodes::add); - } - } - } catch (IOException exception) { - throw new net.openan.a2at.sdk.core.exception.SdkException( - "Failed to scan prompt template resources: " + templatesRoot, exception); - } - - return scenarioCodes.stream() - .map(scenarioCode -> load(scenarioCode, language)) - .toList(); - } - - private static List toSlotDefinitions(PromptSlotSchema slotSchema) { - return slotSchema.slotDefinitions().stream() - .map(def -> new PromptTemplateSlotDefinition(def.name(), def.required())) - .toList(); - } -} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractor.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractor.java deleted file mode 100644 index a64c3527..00000000 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractor.java +++ /dev/null @@ -1,123 +0,0 @@ -package net.openan.a2at.sdk.server.metadata; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; -import net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import net.openan.a2at.sdk.server.model.PromptTemplateDefinition; -import net.openan.a2at.sdk.server.model.PromptTemplateSlotDefinition; - -/** - * Extracts prompt metadata by matching processed prompts against known templates. - * - * @since 2026-06 - */ -public final class TemplateMatchingPromptMetadataExtractor implements ServerPromptMetadataExtractor { - - private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{?\\s*([^{}]+?)\\s*\\}\\}?"); - private static final String SLOT_SENTINEL_PREFIX = "__A2AT_SLOT_"; - private static final String SLOT_SENTINEL_SUFFIX = "__"; - - private final List templates; - private final TaskPromptRenderer renderer; - - /** - * Creates a template-matching metadata extractor. - * - * @param templates known prompt templates - */ - public TemplateMatchingPromptMetadataExtractor(List templates) { - this(templates, new TaskPromptRenderer()); - } - - /** - * Creates a template-matching metadata extractor backed by the supplied renderer. - * - * @param templates known prompt templates - * @param renderer task prompt renderer used to normalize template structure - */ - public TemplateMatchingPromptMetadataExtractor(List templates, TaskPromptRenderer renderer) { - this.templates = List.copyOf(templates); - this.renderer = renderer; - } - - @Override - public ProcessedPromptMetadata extract(String processedPromptText) { - for (PromptTemplateDefinition template : templates) { - Optional> slots = tryExtractSlots(processedPromptText, template); - if (slots.isPresent()) { - validateRequiredSlots(slots.get(), template.slotDefinitions()); - return new ProcessedPromptMetadata( - template.scenarioCode(), template.language(), template.templateText(), Map.copyOf(slots.get())); - } - } - throw new PromptComplianceCheckException( - "processed_prompt_parse_error", "Prompt does not match any known template.", "prompt_parse"); - } - - private Optional> tryExtractSlots(String processedPromptText, PromptTemplateDefinition template) { - Matcher placeholderMatcher = PLACEHOLDER_PATTERN.matcher(template.templateText()); - List slotNames = new java.util.ArrayList<>(); - Map sentinelSlots = new LinkedHashMap<>(); - int slotIndex = 0; - while (placeholderMatcher.find()) { - String slotName = placeholderMatcher.group(1).trim(); - String sentinel = SLOT_SENTINEL_PREFIX + slotIndex + SLOT_SENTINEL_SUFFIX; - slotNames.add(slotName); - sentinelSlots.put(slotName, sentinel); - slotIndex++; - } - - final String normalizedTemplateText; - try { - normalizedTemplateText = renderer.render(template.templateText(), sentinelSlots); - } catch (TaskPromptRenderException error) { - throw new PromptComplianceCheckException("processed_prompt_parse_error", error.getMessage(), "prompt_parse"); - } - - StringBuilder pattern = new StringBuilder("^"); - int cursor = 0; - for (int index = 0; index < slotNames.size(); index++) { - String sentinel = sentinelSlots.get(slotNames.get(index)); - int sentinelIndex = normalizedTemplateText.indexOf(sentinel, cursor); - if (sentinelIndex < 0) { - return Optional.empty(); - } - pattern.append(Pattern.quote(normalizedTemplateText.substring(cursor, sentinelIndex))); - pattern.append("(.*?)"); - cursor = sentinelIndex + sentinel.length(); - } - pattern.append(Pattern.quote(normalizedTemplateText.substring(cursor))); - pattern.append("$"); - - Matcher match = Pattern.compile(pattern.toString(), Pattern.DOTALL).matcher(processedPromptText); - if (!match.matches()) { - return Optional.empty(); - } - - Map slots = new LinkedHashMap<>(); - for (int index = 0; index < slotNames.size(); index++) { - slots.put(slotNames.get(index), match.group(index + 1)); - } - return Optional.of(slots); - } - - private static void validateRequiredSlots( - Map slots, List slotDefinitions) { - for (PromptTemplateSlotDefinition definition : slotDefinitions) { - Optional value = Optional.ofNullable(slots.get(definition.name())); - if (definition.required() && !value.filter(slotValue -> !slotValue.isBlank()).isPresent()) { - throw new PromptComplianceCheckException( - "slot_validation_error", - "Required slot '" + definition.name() + "' is missing.", - "slot_validation"); - } - } - } -} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateDefinition.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateDefinition.java deleted file mode 100644 index 65e5ee53..00000000 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateDefinition.java +++ /dev/null @@ -1,18 +0,0 @@ -package net.openan.a2at.sdk.server.model; - -import java.util.List; - -/** - * Template candidate used to parse processed task prompts on the server side. - * - * @param scenarioCode scenario code associated with the template - * @param language language associated with the template - * @param templateText canonical template text - * @param slotDefinitions slot definitions expected by the template - * @since 2026-06 - */ -public record PromptTemplateDefinition( - String scenarioCode, - String language, - String templateText, - List slotDefinitions) {} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateSlotDefinition.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateSlotDefinition.java deleted file mode 100644 index 0c43f868..00000000 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateSlotDefinition.java +++ /dev/null @@ -1,10 +0,0 @@ -package net.openan.a2at.sdk.server.model; - -/** - * Slot definition used by template-based server prompt compliance checks. - * - * @param name slot name - * @param required whether the slot is required - * @since 2026-06 - */ -public record PromptTemplateSlotDefinition(String name, boolean required) {} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/TemplateRoundTripPromptSemanticValidator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/TemplateRoundTripPromptSemanticValidator.java deleted file mode 100644 index 926107ba..00000000 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/TemplateRoundTripPromptSemanticValidator.java +++ /dev/null @@ -1,46 +0,0 @@ -package net.openan.a2at.sdk.server.validation; - -import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; -import net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; - -/** - * Validates semantic consistency by round-tripping extracted slots through the original template. - * - * @since 2026-06 - */ -public final class TemplateRoundTripPromptSemanticValidator implements ServerPromptSemanticValidator { - - private final TaskPromptRenderer renderer; - - /** Creates a semantic validator backed by the default task prompt renderer. */ - public TemplateRoundTripPromptSemanticValidator() { - this(new TaskPromptRenderer()); - } - - /** - * Creates a semantic validator backed by the supplied renderer. - * - * @param renderer task prompt renderer - */ - public TemplateRoundTripPromptSemanticValidator(TaskPromptRenderer renderer) { - this.renderer = renderer; - } - - @Override - public void validate(String processedPromptText, ProcessedPromptMetadata metadata) { - final String rendered; - try { - rendered = renderer.render(metadata.templateText(), metadata.slots()); - } catch (TaskPromptRenderException error) { - throw new PromptComplianceCheckException("slot_validation_error", error.getMessage(), "slot_validation"); - } - if (!processedPromptText.equals(rendered)) { - throw new PromptComplianceCheckException( - "slot_validation_error", - "Prompt is not semantically consistent with extracted slots.", - "slot_validation"); - } - } -} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java deleted file mode 100644 index 94b5e3cb..00000000 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package net.openan.a2at.sdk.server.assembly; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import net.openan.a2at.sdk.server.model.PromptTemplateDefinition; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class LocalFileServerPromptTemplateLoaderTest { - - @TempDir - Path promptRootDir; - - @Test - void loadReadsSlotDefinitionsUsingPromptPackagePublicLoaders() { - LocalFileServerPromptTemplateLoader loader = new LocalFileServerPromptTemplateLoader( - Path.of("..", "a2a-t-resources", "src", "main", "resources", "prompt_resources")); - - PromptTemplateDefinition definition = loader.load("energy-saving", "zh-CN"); - - assertEquals("energy-saving", definition.scenarioCode()); - assertEquals(6, definition.slotDefinitions().size()); - assertEquals(false, definition.slotDefinitions().get(0).required()); - } - - @Test - void loadAllScansLocalTemplateDirectoriesAndLoadsDefinitions() throws IOException { - write( - promptRootDir - .resolve("templates") - .resolve("Task-T") - .resolve("v1") - .resolve("incident_triage") - .resolve("en") - .resolve("template.md"), - """ - # Incident Triage - - Severity: {{severity}} - Summary: {{summary}} - """); - write( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("v1") - .resolve("incident_triage") - .resolve("en") - .resolve("slot.json"), - """ - { - "required": ["severity"], - "properties": { - "severity": { - "type": "string", - "description": "Incident severity" - }, - "summary": { - "type": "string" - } - } - } - """); - - var definitions = new LocalFileServerPromptTemplateLoader(promptRootDir).loadAll("en"); - - assertEquals(1, definitions.size()); - PromptTemplateDefinition definition = definitions.get(0); - assertEquals("incident_triage", definition.scenarioCode()); - assertEquals("en", definition.language()); - assertEquals(2, definition.slotDefinitions().size()); - assertEquals("severity", definition.slotDefinitions().get(0).name()); - assertEquals(true, definition.slotDefinitions().get(0).required()); - } - - private static void write(Path file, String content) throws IOException { - Files.createDirectories(file.getParent()); - Files.writeString(file, content); - } -} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractorTest.java deleted file mode 100644 index 04a40ef9..00000000 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractorTest.java +++ /dev/null @@ -1,165 +0,0 @@ -package net.openan.a2at.sdk.server.metadata; - -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import net.openan.a2at.sdk.server.model.PromptTemplateDefinition; -import net.openan.a2at.sdk.server.model.PromptTemplateSlotDefinition; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.List; -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; -import org.junit.jupiter.api.Test; - -class TemplateMatchingPromptMetadataExtractorTest { - - @Test - void extractReturnsScenarioLanguageTemplateAndSlotsForMatchedTemplate() { - TemplateMatchingPromptMetadataExtractor extractor = - new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "energy-saving", - "en-US", - "Site: {site}\nNotes: {additional_notes}", - List.of( - new PromptTemplateSlotDefinition("site", true), - new PromptTemplateSlotDefinition("additional_notes", false))))); - - ProcessedPromptMetadata metadata = extractor.extract("Site: Site A\nNotes: critical"); - - assertEquals("energy-saving", metadata.scenarioCode()); - assertEquals("en-US", metadata.language()); - assertEquals("Site: {site}\nNotes: {additional_notes}", metadata.templateText()); - assertEquals(Map.of("site", "Site A", "additional_notes", "critical"), metadata.slots()); - } - - @Test - void extractRejectsPromptThatDoesNotMatchAnyKnownTemplate() { - TemplateMatchingPromptMetadataExtractor extractor = - new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "energy-saving", - "en-US", - "Site: {site}", - List.of(new PromptTemplateSlotDefinition("site", true))))); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("Unknown prompt")); - - assertEquals("processed_prompt_parse_error", error.code()); - assertEquals("prompt_parse", error.stage()); - } - - @Test - void extractRejectsMissingRequiredSlotValueAfterTemplateMatch() { - TemplateMatchingPromptMetadataExtractor extractor = - new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "energy-saving", - "en-US", - "Site: {site}", - List.of(new PromptTemplateSlotDefinition("site", true))))); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("Site: ")); - - assertEquals("slot_validation_error", error.code()); - assertEquals("slot_validation", error.stage()); - } - - @Test - void extractMatchesTemplateWithDoubleBracedPlaceholders() { - TemplateMatchingPromptMetadataExtractor extractor = - new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "subscribe-incident", - "en-US", - "Topic: {{topic}}\nCondition: {{condition}}", - List.of( - new PromptTemplateSlotDefinition("topic", true), - new PromptTemplateSlotDefinition("condition", false))))); - - ProcessedPromptMetadata metadata = extractor.extract( - "Topic: Incident\nCondition: Severity is critical; alert type is flash"); - - assertEquals("subscribe-incident", metadata.scenarioCode()); - assertEquals("en-US", metadata.language()); - assertEquals( - Map.of( - "topic", "Incident", - "condition", "Severity is critical; alert type is flash"), - metadata.slots()); - } - - @Test - void extractMatchesCollapsedPromptAgainstOriginalTemplateWithDescriptiveLines() { - String originalTemplate = - "## Subscription Description\n" - + "Use the following topic, condition, report format, and expected output to complete an incident subscription task.\n\n" - + "## Topic\n" - + "{{topic}} (Required)\n" - + "Requirement: provide the incident topic name, such as Incident or Fault.\n\n" - + "## Condition\n" - + "{{condition}} (Optional)\n" - + "Requirement: include fault severity and fault name when needed.\n" - + "Severity supports values such as critical, high, medium, and low.\n" - + "Fault name supports network-side fault names such as pigtail failure, fiber cut, board failure, and optical module failure.\n\n" - + "## Report Format\n" - + "{{report_format}} (Optional)\n" - + "Requirement: specify the reported data type and the A2A Part type, such as DataPart or TextPart.\n" - + "Example: report incident data through DataPart.\n\n" - + "## Expected Output\n" - + "1. Subscription result, success or failure\n" - + "2. Failure reason when the subscription fails"; - String processedPrompt = - "## Subscription Description\n" - + "Use the following topic, condition, report format, and expected output to complete an incident subscription task.\n\n" - + "## Topic\n" - + "Incident\n\n" - + "## Condition\n" - + "Subscribe to ETH-LOS faults with critical severity\n\n" - + "## Report Format\n" - + "DataPart\n\n" - + "## Expected Output\n" - + "1. Subscription result, success or failure\n" - + "2. Failure reason when the subscription fails"; - Map sentinelSlots = new LinkedHashMap<>(); - sentinelSlots.put("topic", "__A2AT_SLOT_0__"); - sentinelSlots.put("condition", "__A2AT_SLOT_1__"); - sentinelSlots.put("report_format", "__A2AT_SLOT_2__"); - - assertEquals( - "## Subscription Description\n" - + "Use the following topic, condition, report format, and expected output to complete an incident subscription task.\n\n" - + "## Topic\n" - + "__A2AT_SLOT_0__\n\n" - + "## Condition\n" - + "__A2AT_SLOT_1__\n\n" - + "## Report Format\n" - + "__A2AT_SLOT_2__\n\n" - + "## Expected Output\n" - + "1. Subscription result, success or failure\n" - + "2. Failure reason when the subscription fails", - new TaskPromptRenderer().render(originalTemplate, sentinelSlots)); - - TemplateMatchingPromptMetadataExtractor extractor = - new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "subscribe-incident", - "en-US", - originalTemplate, - List.of( - new PromptTemplateSlotDefinition("topic", true), - new PromptTemplateSlotDefinition("condition", false), - new PromptTemplateSlotDefinition("report_format", false))))); - - ProcessedPromptMetadata metadata = extractor.extract(processedPrompt); - - assertEquals("subscribe-incident", metadata.scenarioCode()); - assertEquals("en-US", metadata.language()); - assertEquals( - Map.of( - "topic", "Incident", - "condition", "Subscribe to ETH-LOS faults with critical severity", - "report_format", "DataPart"), - metadata.slots()); - } -} diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 49540f64..05abe5d8 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -8,7 +8,7 @@ See the [User Guide 1.1 Feature Introduction](https://github.com/project-openan/ 1. JDK requirement is 17+. 2. Currently supports `classpath` and `local_file` prompt resources. The default source is the built-in resources packaged in the `a2a-t-resources` jar. -3. By default supports `local_rule` and `openai` LLM provider. +3. By default supports `openai` LLM provider. 4. Negotiation state storage currently supports `in_memory`. 5. The SDK is not responsible for starting business HTTP services, user authentication, key management, or registry center deployment. diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 2b7d2152..8c9f2e7e 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -8,7 +8,7 @@ 1. JDK 要求为 17+。 2. 当前支持 `classpath` 和 `local_file` 提示词资源,默认从 jar 内置资源加载。 -3. 默认支持 `local_rule` 和 `openai` LLM provider。 +3. 默认支持 `openai` LLM provider。 4. 协商状态存储当前支持 `in_memory`。 5. SDK 不负责启动业务 HTTP 服务、用户认证、密钥管理和注册中心部署。 From 3bdacb20f571bf9f2adcdcae5141353e58c64851 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Thu, 20 Aug 2026 19:16:57 +0800 Subject: [PATCH 056/295] fix: normalize CRLF to LF in golden fixture reads on Windows Golden fixture .md files get CRLF on git checkout, but the renderer produces LF-only output. Normalize line endings in all readGoldenFixture and readClasspathText helpers across 7 test files. Also fix NegotiationContentExtractorTest.phaseTokenInUserPrompt which was affected by the same CRLF issue. Signed-off-by: w00609601 --- .../sdk/negotiation/generation/FromTextLlmPipelineTest.java | 2 +- .../negotiation/generation/NegotiationContentExtractorTest.java | 2 +- .../sdk/negotiation/generation/TemplateUriEntryMatrixTest.java | 2 +- .../sdk/negotiation/golden/GoldenFixtureComparisonTest.java | 2 +- .../negotiation/language/CustomRootTemplateOverrideTest.java | 2 +- .../sdk/negotiation/language/NegotiationLanguageSwitchTest.java | 2 +- .../negotiation/language/NegotiationResourceIntegrityTest.java | 2 +- .../scenario/FeasibilityNegotiationScenarioTest.java | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 6eefe2e7..f8d67479 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -590,7 +590,7 @@ private static String readGoldenFixture(GoldenCase goldenCase, String language) InputStream stream = FromTextLlmPipelineTest.class.getResourceAsStream(resourcePath); assertTrue(stream != null, "Golden fixture must exist on the test classpath: " + resourcePath); try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); } catch (IOException exception) { throw new AssertionError("Failed to read golden fixture " + resourcePath, exception); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java index 530146ef..a02b28a0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java @@ -281,7 +281,7 @@ private static String phaseTokenInUserPrompt(NegotiationPhase phase) { } catch (NegotiationGenerationException expectedForReject) { // The scripted conclusion only satisfies the accept phase; the messages are recorded either way. } - String userPrompt = client.lastMessages.get(1).get("content"); + String userPrompt = client.lastMessages.get(1).get("content").replace("\r\n", "\n"); int start = userPrompt.indexOf("协商阶段:") + "协商阶段:".length(); return userPrompt.substring(start, userPrompt.indexOf('\n', start)); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index 6b1f4efe..fb679b61 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -187,7 +187,7 @@ private static String readClasspathText(String resourcePath) { throw new AssertionError("Classpath resource must exist: " + resourcePath); } try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); } catch (IOException exception) { throw new AssertionError("Failed to read classpath resource " + resourcePath, exception); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java index a38856e1..06e130e2 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java @@ -70,7 +70,7 @@ private static String readGoldenFixture(GoldenCase goldenCase, String language) InputStream stream = GoldenFixtureComparisonTest.class.getResourceAsStream(resourcePath); assertNotNull(stream, "Golden fixture must exist on the test classpath: " + resourcePath); try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); } catch (IOException exception) { throw new AssertionError("Failed to read golden fixture " + resourcePath, exception); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java index 6baea0a5..c9230523 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java @@ -138,7 +138,7 @@ private static String readGoldenFixture(GoldenCase goldenCase, String language) InputStream stream = CustomRootTemplateOverrideTest.class.getResourceAsStream(resourcePath); assertTrue(stream != null, "Golden fixture must exist on the test classpath: " + resourcePath); try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); } catch (IOException exception) { throw new AssertionError("Failed to read golden fixture " + resourcePath, exception); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java index 7c79c984..4ba59392 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java @@ -107,7 +107,7 @@ private static String readGoldenFixture(GoldenCase goldenCase, String language) InputStream stream = NegotiationLanguageSwitchTest.class.getResourceAsStream(resourcePath); assertTrue(stream != null, "Golden fixture must exist on the test classpath: " + resourcePath); try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); } catch (IOException exception) { throw new AssertionError("Failed to read golden fixture " + resourcePath, exception); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationResourceIntegrityTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationResourceIntegrityTest.java index 24d2b0ec..9633af6d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationResourceIntegrityTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationResourceIntegrityTest.java @@ -227,7 +227,7 @@ private static String readGoldenFixture(GoldenCase goldenCase, String language) InputStream stream = NegotiationResourceIntegrityTest.class.getResourceAsStream(resourcePath); assertNotNull(stream, "Golden fixture must exist on the test classpath: " + resourcePath); try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); } catch (IOException exception) { throw new AssertionError("Failed to read golden fixture " + resourcePath, exception); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java index 38fdb9a5..702a7294 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java @@ -122,7 +122,7 @@ private static String readGoldenFixture(GoldenCase goldenCase, String language) InputStream stream = FeasibilityNegotiationScenarioTest.class.getResourceAsStream(resourcePath); assertTrue(stream != null, "Golden fixture must exist on the test classpath: " + resourcePath); try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); } catch (IOException exception) { throw new AssertionError("Failed to read golden fixture " + resourcePath, exception); } From e4ac9aef8732a895917e3c67ed2b076c37c00984 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Thu, 20 Aug 2026 19:34:16 +0800 Subject: [PATCH 057/295] fix: update rebased tests to replace local_rule with mock LLM providers Tests from origin/main (PR #89) still used local_rule. Replace with mock LLM providers registered via LLMClientFactory. Add required LLM config keys (model, base_url, api_key) to test env files. Also fix golden fixture CRLF-vs-LF line ending mismatches on Windows by normalizing \\r\\n to \\n in all readGoldenFixture helpers. Signed-off-by: w00609601 --- .../api/A2ATClientNegotiationApiTest.java | 37 ++++++++++++++- .../A2ATClientNegotiationEnvConfigTest.java | 45 ++++++++++++++++++- .../NegotiationFacadeOutputSymmetryTest.java | 37 ++++++++++++++- .../api/A2ATServerNegotiationApiTest.java | 5 ++- .../TaskPromptNegotiationCoexistenceTest.java | 37 ++++++++++++++- 5 files changed, 155 insertions(+), 6 deletions(-) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index 722c5b9c..c615c074 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -10,16 +10,30 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; class A2ATClientNegotiationApiTest { + private static final String TEST_MOCK_PROVIDER = "test-negotiation-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; @@ -91,10 +105,31 @@ private static Path writeEnv(String language) throws IOException { A2AT_LANGUAGE=%s A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=local_rule + A2AT_LLM_PROVIDER=test-negotiation-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory """ .formatted(language)); return envFile; } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index 09ee41d2..61185309 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -15,8 +15,14 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -24,6 +30,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -38,6 +45,15 @@ */ class A2ATClientNegotiationEnvConfigTest { + private static final String TEST_MOCK_PROVIDER = "test-negotiation-failing-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, FailingClient.class); + } + } + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; @@ -73,7 +89,10 @@ void languageLocalRootAndMaxAttemptsAreAllObservableInFacadeBehavior() throws IO "A2AT_LANGUAGE=zh-CN", "A2AT_PROMPT_SOURCE_TYPE=classpath", "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=" + customRoot.toString().replace('\\', '/'), - "A2AT_LLM_PROVIDER=local_rule", + "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, + "A2AT_LLM_MODEL=example-model", + "A2AT_LLM_BASE_URL=https://llm.example.test/v1", + "A2AT_LLM_API_KEY=test-key", "A2AT_LLM_MAX_ATTEMPTS=2", "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory"); A2ATClient client = new A2ATClient(envFile); @@ -103,7 +122,11 @@ void languageLocalRootAndMaxAttemptsAreAllObservableInFacadeBehavior() throws IO @Test void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { - Path envFile = writeEnvFile("A2AT_LLM_PROVIDER=local_rule"); + Path envFile = writeEnvFile( + "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, + "A2AT_LLM_MODEL=example-model", + "A2AT_LLM_BASE_URL=https://llm.example.test/v1", + "A2AT_LLM_API_KEY=test-key"); A2ATClient client = new A2ATClient(envFile); MetadataContent result = client.generateNegotiationProposePromptFromData( @@ -180,4 +203,22 @@ private long countEvents(String eventName) { .filter(message -> message.startsWith(eventName + " ")) .count(); } + + public static final class FailingClient implements LLMClient { + + private final LLMClientConfig config; + + public FailingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new LLMRuntimeError("test mock failure"); + } + } } diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index c758ee59..358ce1b7 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -13,6 +13,10 @@ import java.util.Map; import java.util.stream.Collectors; import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; @@ -29,6 +33,7 @@ import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -42,6 +47,15 @@ */ class NegotiationFacadeOutputSymmetryTest { + private static final String TEST_MOCK_PROVIDER = "test-symmetry-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + private static final List NEGOTIATION_API_METHODS = List.of( "generateNegotiationProposePromptFromData", "generateNegotiationAcceptPromptFromData", @@ -99,7 +113,10 @@ private Path envFile(String language, String fileName) throws IOException { A2AT_LANGUAGE=%s A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=local_rule + A2AT_LLM_PROVIDER=test-symmetry-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory """ .formatted(language)); @@ -234,4 +251,22 @@ private interface EndingGenerator { MetadataContent generate(NegotiationEndingData data, String templateUri); } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index e350f10b..0f3aa486 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -91,7 +91,10 @@ private static Path writeEnv(String language) throws IOException { A2AT_LANGUAGE=%s A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=local_rule + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory A2AT_PROMPT_COMPLIANCE_ENABLED=false """ diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index 55f95f32..3e2437c6 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -10,6 +10,10 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -20,6 +24,7 @@ import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; import net.openan.a2at.sdk.server.A2ATServer; import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -30,6 +35,15 @@ */ class TaskPromptNegotiationCoexistenceTest { + private static final String TEST_MOCK_PROVIDER = "test-coexistence-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; @@ -106,10 +120,31 @@ private Path writeEnv() throws IOException { A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=local_rule + A2AT_LLM_PROVIDER=test-coexistence-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory """ .formatted()); return envFile; } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } } From 06a94455f8fc0816a7a5f689148e0600a3adddf1 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Thu, 20 Aug 2026 19:27:31 +0800 Subject: [PATCH 058/295] refactor(a2a-t-core,a2a-t-prompt): move generic template catalog and PromptTemplate out of negotiation PromptTemplate becomes core.model value type next to MetadataContent; PromptTemplateCatalog and TemplateDescriptions move to prompt.resources.catalog; new TemplateQueryService in the prompt module answers the extension-agnostic getPrompts/getPrompt queries; NegotiationGenerationOrchestrator and NegotiationContentService lose the generic query methods and keep only the NegotiationReference-based negotiation queries. Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 10 +- .../assembly/DefaultA2ATClientBuilder.java | 15 +++ .../api/A2ATClientNegotiationApiTest.java | 2 +- .../a2at/sdk/core/model}/PromptTemplate.java | 24 ++--- .../content/NegotiationContentService.java | 24 +---- .../AbstractNegotiationGenerator.java | 2 +- .../FeasibilityEndingGenerator.java | 2 +- .../FeasibilityProposeGenerator.java | 2 +- .../InformationEndingGenerator.java | 2 +- .../InformationProposeGenerator.java | 2 +- .../NegotiationGenerationOrchestrator.java | 45 +-------- ...otiationGenerationOrchestratorBuilder.java | 21 +---- .../generation/NegotiationGenerator.java | 2 +- .../generation/TargetEndingGenerator.java | 2 +- .../generation/TargetProposeGenerator.java | 2 +- .../DefaultNegotiationTemplateLoader.java | 4 + .../resources/NegotiationTemplateLoader.java | 3 +- .../NegotiationPackageStructureTest.java | 3 - .../generation/FromTextLlmPipelineTest.java | 2 +- ...NegotiationGenerationOrchestratorTest.java | 2 +- .../generation/NegotiationGeneratorsTest.java | 2 +- .../NegotiationTemplateQueryBoundaryTest.java | 2 +- .../TemplateUriEntryMatrixTest.java | 2 +- .../CustomRootTemplateOverrideTest.java | 2 +- .../NegotiationLanguageSwitchTest.java | 2 +- .../NegotiationResourceIntegrityTest.java | 2 +- .../NegotiationErrorCodeUsageMatrixTest.java | 2 +- .../NegotiationLogEventContractTest.java | 2 +- .../DefaultNegotiationTemplateLoaderTest.java | 1 + .../catalog}/PromptTemplateCatalog.java | 18 ++-- .../catalog}/TemplateDescriptions.java | 11 ++- .../catalog/TemplateQueryService.java | 94 +++++++++++++++++++ .../openan/a2at/sdk/server/A2ATServer.java | 10 +- .../assembly/DefaultA2ATServerBuilder.java | 15 +++ .../api/A2ATServerNegotiationApiTest.java | 2 +- 35 files changed, 195 insertions(+), 143 deletions(-) rename {a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources => a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model}/PromptTemplate.java (52%) rename {a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources => a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog}/PromptTemplateCatalog.java (94%) rename {a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources => a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog}/TemplateDescriptions.java (81%) create mode 100644 a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index a57bb9ce..9b9ac3c8 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -9,15 +9,16 @@ import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.negotiation.content.FilledParamData; import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; /** * High-level client facade for prompt generation and negotiation APIs. The caller provides the `.env` file path @@ -33,6 +34,8 @@ public final class A2ATClient { private final NegotiationContentService negotiationContentService; + private final TemplateQueryService templateQueryService; + /** * Creates a client facade from one user-supplied `.env` path. * @@ -47,6 +50,7 @@ public A2ATClient(Path envPath) { this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); this.negotiationContentService = new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.templateQueryService = builder.buildTemplateQueryService(); } /** @@ -336,7 +340,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( * none can be loaded */ public List getPrompts() { - return negotiationContentService.getPrompts(); + return templateQueryService.getPrompts(); } /** @@ -351,7 +355,7 @@ public List getPrompts() { * the configured language */ public Optional getPrompt(String uri) { - return negotiationContentService.getPrompt(uri); + return templateQueryService.getPrompt(uri); } /** diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index e74e667c..976c048a 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -25,6 +25,7 @@ import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; import net.openan.a2at.sdk.negotiation.types.model.NegotiationRole; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; @@ -158,6 +159,20 @@ public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator( return NegotiationContentService.buildOrchestrator(config, createLlmClient()); } + /** + * Builds the generic template query service from the configured unified SDK config. + * + *

The service answers the extension-agnostic template queries: the message language and the local template + * root come from the prompt runtime config, exactly like the negotiation generation orchestrator wiring. + * + * @return assembled template query service + */ + public TemplateQueryService buildTemplateQueryService() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + return new TemplateQueryService(config.prompt().language(), config.prompt().localRootDir()); + } + private void requireSupportedConfig() { if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals( diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index c615c074..5c057d37 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -19,7 +19,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/PromptTemplate.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java similarity index 52% rename from a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/PromptTemplate.java rename to a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java index 9a91d80e..cb0423da 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/PromptTemplate.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java @@ -1,12 +1,12 @@ -package net.openan.a2at.sdk.negotiation.resources; - -/** - * One loadable negotiation template. - * - * @param uri template URI such as {@code Negotiation-T/v1/information-negotiation/propose} - * @param description template description taken from the leading HTML comment; empty string when the template has no - * comment - * @param content full template file text - * @since 2026-06 - */ -public record PromptTemplate(String uri, String description, String content) {} +package net.openan.a2at.sdk.core.model; + +/** + * One loadable prompt template of any A2A-T extension. + * + * @param uri template URI such as {@code Task-T/v1/energy-saving} or {@code Negotiation-T/v1/information-negotiation/propose} + * @param description template description taken from the leading HTML comment; empty string when the template has no + * comment + * @param content full template file text + * @since 2026-08 + */ +public record PromptTemplate(String uri, String description, String content) {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java index e2f87e7b..75a3a18f 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java @@ -8,10 +8,10 @@ import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; /** * Shared service for the negotiation content layer consumed by both the client and the server facade. @@ -178,28 +178,6 @@ public MetadataContent generateRejectFromText(String text, NegotiationContext co return orchestrator.generateRejectFromText(text, context, templateUri); } - /** - * Lists every template available for the configured language across all A2A-T extensions; never throws. - * - * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when - * none can be loaded - */ - public List getPrompts() { - return orchestrator.getPrompts(); - } - - /** - * Loads one template by its URI regardless of the extension; never throws. - * - * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} or - * {@code Task-T/v1/energy-saving} - * @return the addressed template, or an empty optional when the URI is malformed or no template exists for it in - * the configured language - */ - public Optional getPrompt(String templateUri) { - return orchestrator.getPrompt(templateUri); - } - /** * Lists every negotiation template available for the configured language; never throws. * diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java index a9a5da1d..e5181a8c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java @@ -8,7 +8,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; /** * Shared plumbing of the six negotiation generators. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java index 04d66c74..355b8df7 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java @@ -6,7 +6,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; /** * Generator for feasibility negotiation terminal messages. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java index d7990fc3..a2f88a97 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java @@ -8,7 +8,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; /** * Generator for feasibility negotiation propose messages. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java index a4e7ec95..844cd321 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java @@ -6,7 +6,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; /** * Generator for information negotiation terminal messages. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java index 52e9f2c2..6df1efbe 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java @@ -6,7 +6,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; /** * Generator for information negotiation propose messages. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 4dbf39eb..fe7bd95d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -20,10 +20,9 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplateCatalog; import net.openan.a2at.sdk.negotiation.validation.NegotiationValidationException; import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; import net.openan.a2at.sdk.negotiation.validation.SemanticValidationResult; @@ -65,8 +64,6 @@ public final class NegotiationGenerationOrchestrator { private final NegotiationTemplateLoader templateLoader; - private final PromptTemplateCatalog templateCatalog; - private final NegotiationContentExtractor contentExtractor; private final ParamExtractor paramExtractor; @@ -81,7 +78,6 @@ public final class NegotiationGenerationOrchestrator { String language, int maxAttempts, NegotiationTemplateLoader templateLoader, - PromptTemplateCatalog templateCatalog, NegotiationContentExtractor contentExtractor, ParamExtractor paramExtractor, NegotiationGeneratorRegistry generatorRegistry, @@ -90,7 +86,6 @@ public final class NegotiationGenerationOrchestrator { this.language = language; this.maxAttempts = maxAttempts; this.templateLoader = templateLoader; - this.templateCatalog = templateCatalog; this.contentExtractor = contentExtractor; this.paramExtractor = paramExtractor; this.generatorRegistry = generatorRegistry; @@ -237,44 +232,6 @@ public MetadataContent generateRejectFromText(String text, NegotiationContext co return generateFromText(text, context, templateUri, NegotiationPhase.REJECT); } - /** - * Lists every template available for the configured language across all A2A-T extensions. - * - *

This query never throws: the extension directories are discovered from the resource tree itself, templates - * that exist nowhere for the language are skipped, and an empty list is returned when no template can be loaded - * at all. The result is sorted by template URI, which orders by extension first. - * - * @return loadable templates of the configured language across all extensions, sorted by URI; empty when none can - * be loaded - */ - public List getPrompts() { - return templateCatalog.loadAll(); - } - - /** - * Loads one template by its URI, regardless of the extension. - * - *

This query never throws: a malformed URI or a template that exists nowhere for the configured language returns - * an empty result and logs an actionable warning. - * - * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} or - * {@code Task-T/v1/energy-saving} - * @return the addressed template, or an empty result when the URI is malformed or the template does not exist for - * the configured language - */ - public Optional getPrompt(String templateUri) { - Optional template = templateCatalog.load(templateUri); - if (template.isEmpty()) { - logger.atWarn() - .log( - "prompt_template_not_found uri={} language={} hint={}", - templateUri, - language, - LANGUAGE_HINT); - } - return template; - } - /** * Lists every negotiation template available for the configured language. * diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index 24f778e5..932964b7 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -4,7 +4,6 @@ import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplateCatalog; import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationSemanticValidator; import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; @@ -16,8 +15,8 @@ /** * Fluent builder assembling one {@link NegotiationGenerationOrchestrator}. * - *

The language is required. The LLM client is optional: without one, the from-data generation and the template - * queries still work, while the LLM steps of the from-text generation and the validation pipeline fail with the + *

The language is required. The LLM client is optional: without one, the from-data generation still works, while + * the LLM steps of the from-text generation and the validation pipeline fail with the * {@code negotiation_llm_infrastructure_error} code. Every collaborator has a default implementation wired from the * language and the optional local template root, and each of them can be overridden for testing or customization. * @@ -37,8 +36,6 @@ public final class NegotiationGenerationOrchestratorBuilder { private NegotiationTemplateLoader templateLoader; - private PromptTemplateCatalog templateCatalog; - private NegotiationContentExtractor contentExtractor; private NegotiationComplianceChecker complianceChecker; @@ -113,17 +110,6 @@ public NegotiationGenerationOrchestratorBuilder templateLoader(NegotiationTempla return this; } - /** - * Overrides the catalog answering the cross-extension template queries. - * - * @param templateCatalog directory-driven catalog over the template tree of every extension - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder templateCatalog(PromptTemplateCatalog templateCatalog) { - this.templateCatalog = templateCatalog; - return this; - } - /** * Overrides the content extractor used by the from-text generation. * @@ -187,8 +173,6 @@ public NegotiationGenerationOrchestrator build() { Vocabulary vocabulary = Vocabulary.forLanguage(language); NegotiationTemplateLoader effectiveTemplateLoader = templateLoader != null ? templateLoader : new DefaultNegotiationTemplateLoader(language, localRootDir); - PromptTemplateCatalog effectiveTemplateCatalog = - templateCatalog != null ? templateCatalog : new PromptTemplateCatalog(language, localRootDir); NegotiationContentExtractor effectiveContentExtractor = contentExtractor != null ? contentExtractor : new DefaultNegotiationContentExtractor(llmClient); NegotiationComplianceChecker effectiveComplianceChecker = @@ -201,7 +185,6 @@ public NegotiationGenerationOrchestrator build() { language, maxAttempts, effectiveTemplateLoader, - effectiveTemplateCatalog, effectiveContentExtractor, paramExtractor, new NegotiationGeneratorRegistry(), diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java index 4d793121..29185119 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java @@ -4,7 +4,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; /** * Renders one negotiation message from typed content and a loaded template. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java index ff189bdd..808a427c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java @@ -7,7 +7,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; /** * Generator for target negotiation terminal messages. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java index 116df663..67099db0 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java @@ -6,7 +6,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; /** * Generator for target negotiation propose messages. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java index 261014cb..056eb24e 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -14,6 +14,8 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateDescriptions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -147,3 +149,5 @@ private String readTemplate(String relativePath) { } } + + diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java index fa2bfde6..11ede471 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.negotiation.resources; import java.util.List; +import net.openan.a2at.sdk.core.model.PromptTemplate; /** * Loads negotiation templates addressed by {@link NegotiationReference} keys. @@ -28,4 +29,4 @@ public interface NegotiationTemplateLoader { * @return templates of the loader's language that could be loaded, in a fixed order */ List loadAll(); -} +} \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java index 571cb11f..931cbd9d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -59,9 +59,6 @@ void resourcesPackageExposesExactlyThePinnedTypes() throws IOException { "DefaultNegotiationTemplateLoader.java", "NegotiationReference.java", "NegotiationTemplateLoader.java", - "PromptTemplate.java", - "PromptTemplateCatalog.java", - "TemplateDescriptions.java", "package-info.java"), topLevelJavaFiles(negotiationRoot().resolve("resources"))); assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("resources"))); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index f8d67479..2a11deb1 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -41,7 +41,7 @@ import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 7865d7f5..349d9156 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -22,7 +22,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; import org.junit.jupiter.api.Test; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java index 52eb0902..72bb3d90 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -21,7 +21,7 @@ import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.Test; class NegotiationGeneratorsTest { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java index da094f61..65d6ff6f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java @@ -11,7 +11,7 @@ import java.util.List; import java.util.Optional; import java.util.stream.Stream; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index fb679b61..73041173 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -29,7 +29,7 @@ import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java index c9230523..3194263f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java @@ -18,7 +18,7 @@ import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java index 4ba59392..08295b0c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java @@ -14,7 +14,7 @@ import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import net.openan.a2at.sdk.negotiation.golden.GoldenInputs; import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationResourceIntegrityTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationResourceIntegrityTest.java index 9633af6d..127c0728 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationResourceIntegrityTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationResourceIntegrityTest.java @@ -25,7 +25,7 @@ import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java index 159f46ce..48907a14 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java @@ -26,7 +26,7 @@ import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.TestFactory; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index 98ed4e27..47e8df28 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -29,7 +29,7 @@ import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.PromptTemplate; +import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java index 491b8e4e..e3ea0d4b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java @@ -9,6 +9,7 @@ import java.nio.file.Path; import java.util.List; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import org.junit.jupiter.api.Test; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/PromptTemplateCatalog.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java similarity index 94% rename from a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/PromptTemplateCatalog.java rename to a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java index 1a62a27c..13215b53 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/PromptTemplateCatalog.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.resources; +package net.openan.a2at.sdk.prompt.resources.catalog; import java.io.IOException; import java.io.InputStream; @@ -18,9 +18,9 @@ import java.util.stream.Stream; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,10 +34,9 @@ * and is addressed by the URI formed from the segments before the language, for example * {@code Negotiation-T/v1/information-negotiation/propose} or {@code Task-T/v1/energy-saving}. * - *

Like the negotiation template loader, the catalog prefers a local file under the configured local resource - * root over the built-in classpath template of the same path, and falls back to the classpath otherwise. Both query - * methods never throw: a template or a root that cannot be loaded is skipped or answered with an empty result and a - * warning log. + *

A local file under the configured local resource root overrides the built-in classpath template of the same + * path, and the classpath is the fallback otherwise. Both query methods never throw: a template or a root that cannot + * be loaded is skipped or answered with an empty result and a warning log. * * @since 2026-08 */ @@ -65,14 +64,13 @@ public final class PromptTemplateCatalog { * @param language locale identifier such as {@code zh-CN} or {@code en-US} * @param localRootDir local prompt resource root containing the {@code templates/} tree; null or blank disables * local template overrides - * @throws NegotiationContentException if the language is not a simple path segment + * @throws IllegalArgumentException if the language is not a simple path segment */ public PromptTemplateCatalog(String language, String localRootDir) { if (!PathSegments.isSimpleSegment(language)) { - throw new NegotiationContentException( + throw new IllegalArgumentException( "Prompt template catalog language must be a non-blank simple path segment but was " + language - + ".", - "language"); + + "."); } this.language = language; this.localRootDir = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/TemplateDescriptions.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateDescriptions.java similarity index 81% rename from a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/TemplateDescriptions.java rename to a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateDescriptions.java index c563b6a0..fb1fea70 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/TemplateDescriptions.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateDescriptions.java @@ -1,15 +1,16 @@ -package net.openan.a2at.sdk.negotiation.resources; +package net.openan.a2at.sdk.prompt.resources.catalog; /** * Extracts the description convention shared by every prompt template of the resource tree. * *

A template carries its description as a leading HTML comment on the first line; a template without such a - * comment reports an empty description. The convention is shared between the negotiation template loader and the - * template catalog so that both produce equal {@link PromptTemplate} records for the same file. + * comment reports an empty description. The convention is shared between the template loaders and the template + * catalog so that both produce equal {@link net.openan.a2at.sdk.core.model.PromptTemplate} records for the same + * file. * * @since 2026-08 */ -final class TemplateDescriptions { +public final class TemplateDescriptions { private static final String COMMENT_OPEN = " + old_jdk_support + + (17, 21] + + + 2.42.0 + 0.12.10 + + -Xplugin:ErrorProne -XepDisableAllChecks -Xep:NullAway:ERROR -XepOpt:NullAway:OnlyNullMarked=true -XepOpt:NullAway:JSpecifyMode=true -XepExcludedPaths:.*/src/test/.* -XepDisableWarningsInGeneratedCode + + - + \ No newline at end of file From 4e42b6d2cd7af588765c6e5d938c25362ace60ec Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 21 Aug 2026 14:13:34 +0800 Subject: [PATCH 065/295] refactor(core): unify exception root on A2ATError with machine-readable code Move the error code contract to the A2ATError root (getCode() never null, default sdk_internal_error) and delete SdkException; re-parent ConfigFileNotFoundException / ResourceNotFoundException / PromptGenerationException / ContentValidationException onto A2ATError, retype OperationResult.error to A2ATError, and convert ValidationPipeline argument checks to NPE/IllegalArgumentException. Signed-off-by: codetalks-hw --- .../a2at/sdk/core/exception/A2ATError.java | 103 +++++++++++----- .../sdk/core/exception/A2ATErrorCodes.java | 3 + .../exception/A2ATParamExtractionError.java | 110 ++++++++---------- .../ConfigFileNotFoundException.java | 2 +- .../exception/PromptGenerationException.java | 30 ++--- .../exception/ResourceNotFoundException.java | 2 +- .../a2at/sdk/core/exception/SdkException.java | 28 ----- .../sdk/core/json/JacksonJsonValueParser.java | 4 +- .../sdk/core/model/DotEnvConfigSource.java | 8 +- .../a2at/sdk/core/model/OperationResult.java | 6 +- .../ContentValidationException.java | 28 ++--- .../sdk/core/validation/ContentValidator.java | 2 + .../a2at/sdk/core/validation/RetryUtil.java | 2 +- .../core/validation/ValidationPipeline.java | 18 ++- .../core/exception/A2ATErrorCodesTest.java | 7 +- .../sdk/core/exception/A2ATErrorTest.java | 70 +++++++++++ .../core/json/JacksonJsonValueParserTest.java | 4 +- .../core/model/DotEnvConfigSourceTest.java | 10 +- .../sdk/core/model/OperationResultTest.java | 16 +-- 19 files changed, 253 insertions(+), 200 deletions(-) delete mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/SdkException.java create mode 100644 a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorTest.java diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java index 1c98ba3f..4f5ad3fe 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java @@ -1,31 +1,72 @@ -package net.openan.a2at.sdk.core.exception; - -/** - * Common root exception type for all A2A-T processing failures. - * - *

Callers can catch this single root type to handle any processing failure raised by the SDK across generation and - * parameter-extraction flows. - * - * @since 2026-06 - */ -public class A2ATError extends RuntimeException { - - /** - * Creates an A2A-T processing failure with one message. - * - * @param message failure message - */ - public A2ATError(String message) { - super(message); - } - - /** - * Creates an A2A-T processing failure with one message and root cause. - * - * @param message failure message - * @param cause root cause - */ - public A2ATError(String message, Throwable cause) { - super(message, cause); - } -} +package net.openan.a2at.sdk.core.exception; + +import java.util.Objects; + +/** + * Single root exception type for all A2A-T SDK processing failures. + * + *

Every runtime, environment or data-processing failure raised by the SDK is part of this tree, so callers can catch + * this one root type and branch on the machine-readable error code returned by {@link #getCode()}, which is never + * {@code null}. Caller contract violations (null, blank or otherwise malformed arguments) are programming errors raised + * as {@link NullPointerException} or {@link IllegalArgumentException} and intentionally stay outside this tree. + * + * @since 2026-06 + */ +public class A2ATError extends RuntimeException { + + /** Machine-readable error code for this failure, never null. */ + private final String code; + + /** + * Creates an A2A-T processing failure with one message and the default error code. + * + * @param message failure message + */ + public A2ATError(String message) { + this(A2ATErrorCodes.SDK_INTERNAL_ERROR, message); + } + + /** + * Creates an A2A-T processing failure with one message, one root cause and the default error code. + * + * @param message failure message + * @param cause root cause + */ + public A2ATError(String message, Throwable cause) { + this(A2ATErrorCodes.SDK_INTERNAL_ERROR, message, cause); + } + + /** + * Creates an A2A-T processing failure with one specific error code and one message. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @throws NullPointerException if {@code code} is null + */ + public A2ATError(String code, String message) { + super(message); + this.code = Objects.requireNonNull(code, "code must not be null"); + } + + /** + * Creates an A2A-T processing failure with one specific error code, one message and one root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param cause root cause + * @throws NullPointerException if {@code code} is null + */ + public A2ATError(String code, String message, Throwable cause) { + super(message, cause); + this.code = Objects.requireNonNull(code, "code must not be null"); + } + + /** + * Returns the machine-readable error code for this failure. + * + * @return error code, never null + */ + public String getCode() { + return code; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java index ebf9ae29..de04cd05 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java @@ -11,6 +11,9 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class A2ATErrorCodes { + /** Default code for SDK processing failures without a more specific code. */ + public static final String SDK_INTERNAL_ERROR = "sdk_internal_error"; + /** Default code for parameter-extraction failures without a more specific code. */ public static final String PARAM_EXTRACTION_FAILED = "param_extraction_failed"; diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java index 9d6168ed..54444846 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java @@ -1,61 +1,49 @@ -package net.openan.a2at.sdk.core.exception; - -import java.util.List; -import net.openan.a2at.sdk.core.model.SlotValidationError; - -/** - * Shared failure type raised when validating a prompt and extracting parameters from it fails. - * - *

This base type is shared across prompt families so that a caller can handle extraction failures uniformly. - * Subclasses carry a more specific error code when one is available. - * - * @since 2026-06 - */ -public class A2ATParamExtractionError extends A2ATError { - - /** Error code carried by this failure. */ - private final String code; - - /** Structured per-slot validation error details. */ - private final List errors; - - /** - * Creates a parameter-extraction failure with the default error code and no slot details. - * - * @param message failure message - */ - public A2ATParamExtractionError(String message) { - this(A2ATErrorCodes.PARAM_EXTRACTION_FAILED, message, List.of()); - } - - /** - * Creates a parameter-extraction failure with one specific error code and slot details. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param errors structured per-slot validation error details - */ - public A2ATParamExtractionError(String code, String message, List errors) { - super(message); - this.code = code; - this.errors = List.copyOf(errors); - } - - /** - * Returns the machine-readable error code for this failure. - * - * @return error code - */ - public String getCode() { - return code; - } - - /** - * Returns the structured per-slot validation error details. - * - * @return immutable list of slot validation errors, never null - */ - public List getErrors() { - return errors; - } -} +package net.openan.a2at.sdk.core.exception; + +import java.util.List; +import net.openan.a2at.sdk.core.model.SlotValidationError; + +/** + * Shared failure type raised when validating a prompt and extracting parameters from it fails. + * + *

This base type is shared across prompt families so that a caller can handle extraction failures uniformly. The + * machine-readable error code is carried by the root {@link A2ATError} and is never null; subclasses pass a more + * specific code when one is available. + * + * @since 2026-06 + */ +public class A2ATParamExtractionError extends A2ATError { + + /** Structured per-slot validation error details. */ + private final List errors; + + /** + * Creates a parameter-extraction failure with the default error code and no slot details. + * + * @param message failure message + */ + public A2ATParamExtractionError(String message) { + this(A2ATErrorCodes.PARAM_EXTRACTION_FAILED, message, List.of()); + } + + /** + * Creates a parameter-extraction failure with one specific error code and slot details. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + */ + public A2ATParamExtractionError(String code, String message, List errors) { + super(code, message); + this.errors = List.copyOf(errors); + } + + /** + * Returns the structured per-slot validation error details. + * + * @return immutable list of slot validation errors, never null + */ + public List getErrors() { + return errors; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java index 8368cfd2..54bffd6c 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java @@ -7,7 +7,7 @@ * * @since 2026-06 */ -public final class ConfigFileNotFoundException extends SdkException { +public final class ConfigFileNotFoundException extends A2ATError { /** * Creates one exception for one missing config file. diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java index 5d0bc69c..cb426fee 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java @@ -1,16 +1,15 @@ package net.openan.a2at.sdk.core.exception; import java.util.List; -import java.util.Objects; /** * Unified exception type for MetadataContent pipeline failures. * + *

The machine-readable error code is carried by the root {@link A2ATError} and is never null. + * * @since 2026-08 */ -public final class PromptGenerationException extends SdkException { - - private final String code; +public final class PromptGenerationException extends A2ATError { private final List failedParameters; @@ -19,10 +18,10 @@ public final class PromptGenerationException extends SdkException { * * @param code stable error code * @param message human-readable failure description + * @throws NullPointerException if {@code code} is null */ public PromptGenerationException(String code, String message) { - super(message); - this.code = Objects.requireNonNull(code, "code must not be null"); + super(code, message); this.failedParameters = List.of(); } @@ -33,10 +32,10 @@ public PromptGenerationException(String code, String message) { * @param code stable error code * @param message human-readable failure description * @param failedParameters list of failed parameters + * @throws NullPointerException if {@code code} is null */ public PromptGenerationException(String code, String message, List failedParameters) { - super(message); - this.code = Objects.requireNonNull(code, "code must not be null"); + super(code, message); this.failedParameters = failedParameters == null ? List.of() : List.copyOf(failedParameters); } @@ -47,22 +46,13 @@ public PromptGenerationException(String code, String message, List failedParameters() { return failedParameters; } -} \ No newline at end of file +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ResourceNotFoundException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ResourceNotFoundException.java index a43728cd..2cc1264f 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ResourceNotFoundException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ResourceNotFoundException.java @@ -5,7 +5,7 @@ * * @since 2026-06 */ -public final class ResourceNotFoundException extends SdkException { +public final class ResourceNotFoundException extends A2ATError { private final String resourcePath; diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/SdkException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/SdkException.java deleted file mode 100644 index f0f483ac..00000000 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/SdkException.java +++ /dev/null @@ -1,28 +0,0 @@ -package net.openan.a2at.sdk.core.exception; - -/** - * Base exception type for SDK domain and integration failures. - * - * @since 2026-06 - */ -public class SdkException extends RuntimeException { - - /** - * Creates an SDK exception with one message. - * - * @param message failure message - */ - public SdkException(String message) { - super(message); - } - - /** - * Creates an SDK exception with one message and root cause. - * - * @param message failure message - * @param cause root cause - */ - public SdkException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParser.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParser.java index 73e7be8d..a7702fc9 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParser.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParser.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Map; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; /** * Jackson-backed parser for structured JSON object payloads. @@ -22,7 +22,7 @@ public Map parseObject(String payload) { OBJECT_MAPPER.readValue(payload, new TypeReference>() {}); return parsed == null ? Map.of() : parsed; } catch (JsonProcessingException error) { - throw new SdkException("Structured JSON payload must be a JSON object.", error); + throw new A2ATError("Structured JSON payload must be a JSON object.", error); } } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/DotEnvConfigSource.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/DotEnvConfigSource.java index 16733ac6..bf07790d 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/DotEnvConfigSource.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/DotEnvConfigSource.java @@ -8,8 +8,8 @@ import java.util.Map; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ConfigFileNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; /** * Reads SDK configuration from one caller-supplied `.env` file. @@ -27,7 +27,7 @@ public final class DotEnvConfigSource { * * @param path caller-supplied `.env` file path * @return parsed non-empty key/value pairs - * @throws SdkException if the file contains more than {@value #MAX_ENTRIES} entries + * @throws A2ATError if the file contains more than {@value #MAX_ENTRIES} entries or cannot be read */ public static Map load(Path path) { if (!Files.exists(path)) { @@ -54,13 +54,13 @@ public static Map load(Path path) { continue; } if (values.size() >= MAX_ENTRIES) { - throw new SdkException( + throw new A2ATError( "Config file exceeds maximum allowed entries: " + MAX_ENTRIES); } values.put(key, value); } } catch (IOException exception) { - throw new SdkException("Failed to read config file: " + path, exception); + throw new A2ATError("Failed to read config file: " + path, exception); } return Map.copyOf(values); } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/OperationResult.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/OperationResult.java index 05579a02..adf664f8 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/OperationResult.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/OperationResult.java @@ -1,7 +1,7 @@ package net.openan.a2at.sdk.core.model; import java.util.Objects; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; /** * Minimal structured result wrapper used by higher-level orchestration flows. @@ -9,7 +9,7 @@ * @param result value type * @since 2026-06 */ -public record OperationResult(T value, SdkException error) { +public record OperationResult(T value, A2ATError error) { /** * Creates a successful operation result. @@ -29,7 +29,7 @@ public static OperationResult success(T value) { * @param value type * @return failed result wrapper */ - public static OperationResult failure(SdkException error) { + public static OperationResult failure(A2ATError error) { return new OperationResult<>(null, Objects.requireNonNull(error, "error")); } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java index 987873d9..12f358ae 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java @@ -1,20 +1,19 @@ package net.openan.a2at.sdk.core.validation; import java.util.List; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.model.SlotValidationError; /** * Failure raised by the content validation pipeline. * - *

Carries a machine-readable error code and optionally structured per-slot validation error details so callers can - * inspect which slot failed, under which error code, and why, without parsing exception messages. + *

Carries the machine-readable error code inherited from {@link A2ATError} and optionally structured per-slot + * validation error details so callers can inspect which slot failed, under which error code, and why, without parsing + * exception messages. * * @since 2026-08 */ -public class ContentValidationException extends SdkException { - - private final String code; +public class ContentValidationException extends A2ATError { private final List errors; @@ -58,21 +57,12 @@ public ContentValidationException(String code, String message, Throwable cause) * @param errors structured per-slot validation error details * @param cause root cause */ - public ContentValidationException(String code, String message, List errors, Throwable cause) { - super(message, cause); - this.code = code; + public ContentValidationException( + String code, String message, List errors, Throwable cause) { + super(code, message, cause); this.errors = errors == null ? List.of() : List.copyOf(errors); } - /** - * Returns the machine-readable error code for this failure. - * - * @return error code - */ - public String code() { - return code; - } - /** * Returns the structured per-slot validation error details. * @@ -81,4 +71,4 @@ public String code() { public List errors() { return errors; } -} \ No newline at end of file +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java index f4df4ffa..186bc281 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java @@ -20,6 +20,8 @@ public interface ContentValidator { * @param schema caller-provided parameter JSON schema * @param templateUri URI of the template the content is validated against * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank or the template URI is blank or malformed * @throws ContentValidationException if the validation fails at any stage */ FilledParamData validate(String prompt, Map schema, String templateUri); diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java index 9e71a060..a59d2693 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java @@ -39,7 +39,7 @@ public static T withRetry(int maxAttempts, String stepName, Supplier acti try { return action.get(); } catch (ContentValidationException exception) { - if (!A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR.equals(exception.code())) { + if (!A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR.equals(exception.getCode())) { throw exception; } if (attempt == maxAttempts) { diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java index b898c1d1..d825b5ac 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java @@ -53,21 +53,17 @@ public ValidationPipeline(RuleChecker ruleChecker, SemanticValidator semanticVal * @param schema caller-provided parameter JSON schema * @param reference template reference the content is validated against * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the prompt, schema or template reference is null + * @throws IllegalArgumentException if the prompt is blank * @throws ContentValidationException if the validation fails at any stage */ public FilledParamData validate(String prompt, Map schema, TemplateReference reference) { - if (schema == null) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, "Schema must not be null."); - } - if (prompt == null || prompt.isBlank()) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, "Prompt must not be null or blank."); - } - if (reference == null) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, "Template reference must not be null."); + Objects.requireNonNull(schema, "schema"); + Objects.requireNonNull(prompt, "prompt"); + if (prompt.isBlank()) { + throw new IllegalArgumentException("Prompt must not be blank."); } + Objects.requireNonNull(reference, "reference"); Map contextParams = ruleChecker.check(prompt); diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java index 58a7c265..1fe18d27 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java @@ -23,16 +23,16 @@ class A2ATErrorCodesTest { /** - * Verifies that {@link A2ATErrorCodes} declares exactly the thirteen expected error code constants with the expected + * Verifies that {@link A2ATErrorCodes} declares exactly the fourteen expected error code constants with the expected * values. * *

Scenario: Reflection inspects all declared static final String fields of the registry. Expected result: The - * field set contains exactly the thirteen known constants and no others. + * field set contains exactly the fourteen known constants and no others. * * @throws IllegalAccessException if a declared field cannot be read */ @Test - void should_declareExactlyThirteenConstants_When_reflectingOverDeclaredFields() throws IllegalAccessException { + void should_declareExactlyFourteenConstants_When_reflectingOverDeclaredFields() throws IllegalAccessException { Map constants = new TreeMap<>(); for (Field field : A2ATErrorCodes.class.getDeclaredFields()) { if (field.getType() == String.class @@ -45,6 +45,7 @@ void should_declareExactlyThirteenConstants_When_reflectingOverDeclaredFields() assertEquals( Map.ofEntries( + Map.entry("SDK_INTERNAL_ERROR", "sdk_internal_error"), Map.entry("PARAM_EXTRACTION_FAILED", "param_extraction_failed"), Map.entry("TEMPLATE_NOT_FOUND", "template_not_found"), Map.entry("NEGOTIATION_CONTENT_EXTRACT_FAILED", "negotiation_content_extract_failed"), diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorTest.java new file mode 100644 index 00000000..cfdd4a22 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorTest.java @@ -0,0 +1,70 @@ +package net.openan.a2at.sdk.core.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link A2ATError}. + * + *

Tests cover the following scenarios: + * + *

    + *
  • Default error code when no explicit code is provided + *
  • Explicit error code forwarding + *
  • Null safety for the explicit code + *
+ * + * @since 2026-08 + */ +class A2ATErrorTest { + + /** + * Verifies that {@link A2ATError#A2ATError(String)} and {@link A2ATError#A2ATError(String, Throwable)} fall back to + * {@link A2ATErrorCodes#SDK_INTERNAL_ERROR}. + * + *

Scenario: Create A2A-T errors without an explicit code. Expected result: getCode() returns + * {@code sdk_internal_error} and is never null. + */ + @Test + void should_defaultToSdkInternalErrorCode_When_createdWithoutExplicitCode() { + assertEquals(A2ATErrorCodes.SDK_INTERNAL_ERROR, new A2ATError("boom").getCode()); + assertEquals( + A2ATErrorCodes.SDK_INTERNAL_ERROR, + new A2ATError("boom", new IllegalStateException("root")).getCode()); + } + + /** + * Verifies that {@link A2ATError#A2ATError(String, String)} and {@link A2ATError#A2ATError(String, String, + * Throwable)} carry the explicitly provided code. + * + *

Scenario: Create A2A-T errors with an explicit code. Expected result: getCode() returns the explicit code, + * getMessage() returns the message and getCause() returns the cause when provided. + */ + @Test + void should_carryExplicitCode_When_createdWithExplicitCode() { + assertEquals( + A2ATErrorCodes.TEMPLATE_NOT_FOUND, + new A2ATError(A2ATErrorCodes.TEMPLATE_NOT_FOUND, "missing").getCode()); + assertEquals("missing", new A2ATError(A2ATErrorCodes.TEMPLATE_NOT_FOUND, "missing").getMessage()); + assertNull(new A2ATError(A2ATErrorCodes.TEMPLATE_NOT_FOUND, "missing").getCause()); + + IllegalStateException cause = new IllegalStateException("root"); + A2ATError withCause = new A2ATError(A2ATErrorCodes.TEMPLATE_NOT_FOUND, "missing", cause); + assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, withCause.getCode()); + assertEquals(cause, withCause.getCause()); + } + + /** + * Verifies that the explicit-code constructors reject null codes. + * + *

Scenario: Attempt to create A2A-T errors with a null code. Expected result: NullPointerException is thrown. + */ + @Test + void should_throwNullPointerException_When_createdWithNullCode() { + assertThrows(NullPointerException.class, () -> new A2ATError(null, "missing")); + assertThrows(NullPointerException.class, () -> new A2ATError(null, "missing", new IllegalStateException())); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParserTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParserTest.java index 80d1addc..8369af01 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParserTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParserTest.java @@ -4,7 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.Map; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; import org.junit.jupiter.api.Test; class JacksonJsonValueParserTest { @@ -23,7 +23,7 @@ void parseObjectReturnsJsonObjectFields() { void parseObjectRejectsNonObjectPayload() { JsonValueParser parser = new JacksonJsonValueParser(); - SdkException error = assertThrows(SdkException.class, () -> parser.parseObject("[\"not-object\"]")); + A2ATError error = assertThrows(A2ATError.class, () -> parser.parseObject("[\"not-object\"]")); assertEquals("Structured JSON payload must be a JSON object.", error.getMessage()); } diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/DotEnvConfigSourceTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/DotEnvConfigSourceTest.java index cd347b18..3dfd957a 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/DotEnvConfigSourceTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/DotEnvConfigSourceTest.java @@ -7,8 +7,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ConfigFileNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; import org.junit.jupiter.api.Test; /** @@ -117,16 +117,16 @@ void should_throwConfigFileNotFoundException_When_fileDoesNotExist() { } /** - * Verifies that {@link DotEnvConfigSource#load(Path)} throws {@link SdkException} + * Verifies that {@link DotEnvConfigSource#load(Path)} throws {@link A2ATError} * when the number of entries exceeds the maximum limit (200). * *

Scenario: A .env file contains more than 200 valid entries. - * Expected result: SdkException is thrown with message indicating the limit was exceeded. + * Expected result: A2ATError is thrown with message indicating the limit was exceeded. * * @throws IOException if temp file creation fails */ @Test - void should_throwSdkException_When_entriesExceedMaxLimit() throws IOException { + void should_throwA2ATError_When_entriesExceedMaxLimit() throws IOException { Path envFile = Files.createTempFile("a2at-dotenv-max", ".env"); StringBuilder content = new StringBuilder(); for (int i = 0; i < 201; i++) { @@ -134,7 +134,7 @@ void should_throwSdkException_When_entriesExceedMaxLimit() throws IOException { } Files.writeString(envFile, content.toString()); - SdkException exception = assertThrows(SdkException.class, () -> DotEnvConfigSource.load(envFile)); + A2ATError exception = assertThrows(A2ATError.class, () -> DotEnvConfigSource.load(envFile)); assertEquals("Config file exceeds maximum allowed entries: 200", exception.getMessage()); } diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/OperationResultTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/OperationResultTest.java index 666930d2..ea0f1145 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/OperationResultTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/OperationResultTest.java @@ -6,7 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; import org.junit.jupiter.api.Test; /** @@ -15,7 +15,7 @@ *

Tests cover the following scenarios: *

    *
  • Creating successful results with values
  • - *
  • Creating failure results with SDK exceptions
  • + *
  • Creating failure results with SDK errors
  • *
  • Null safety for failure result errors
  • *
* @@ -40,15 +40,15 @@ void should_carryValueWithoutError_When_successResultCreated() { } /** - * Verifies that {@link OperationResult#failure(SdkException)} creates a result + * Verifies that {@link OperationResult#failure(A2ATError)} creates a result * containing an error without a value. * - *

Scenario: Create a failure result with an SdkException. - * Expected result: isSuccess() returns false, value() returns null, error() returns the exception. + *

Scenario: Create a failure result with an A2ATError. + * Expected result: isSuccess() returns false, value() returns null, error() returns the error. */ @Test void should_carryErrorWithoutValue_When_failureResultCreated() { - SdkException error = new SdkException("operation failed"); + A2ATError error = new A2ATError("operation failed"); OperationResult result = OperationResult.failure(error); @@ -58,7 +58,7 @@ void should_carryErrorWithoutValue_When_failureResultCreated() { } /** - * Verifies that {@link OperationResult#failure(SdkException)} rejects null errors. + * Verifies that {@link OperationResult#failure(A2ATError)} rejects null errors. * *

Scenario: Attempt to create a failure result with a null error. * Expected result: NullPointerException is thrown. @@ -67,4 +67,4 @@ void should_carryErrorWithoutValue_When_failureResultCreated() { void should_throwNullPointerException_When_failureResultCreatedWithNullError() { assertThrows(NullPointerException.class, () -> OperationResult.failure(null)); } -} \ No newline at end of file +} From fe0b4bb56d5bf969e2ee16f1321b53ef96e67d83 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 21 Aug 2026 14:13:34 +0800 Subject: [PATCH 066/295] refactor(llm): fold LLM exception tree into A2ATError LLMError now extends A2ATError so LLMConfigError/LLMRuntimeError join the unified processing-failure tree; ClasspathPromptResourceLoader I/O wraps use A2ATError. Signed-off-by: codetalks-hw --- .../src/main/java/net/openan/a2at/sdk/llm/LLMError.java | 7 ++++++- .../a2at/sdk/resources/ClasspathPromptResourceLoader.java | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMError.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMError.java index 90011ce2..33331989 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMError.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMError.java @@ -1,11 +1,16 @@ package net.openan.a2at.sdk.llm; +import net.openan.a2at.sdk.core.exception.A2ATError; + /** * Base unchecked error for LLM integration failures. * + *

LLM failures are part of the A2A-T processing-failure tree: catching {@link A2ATError} also catches this error and + * its subclasses ({@link LLMConfigError}, {@link LLMRuntimeError}). + * * @since 2026-06 */ -public class LLMError extends RuntimeException { +public class LLMError extends A2ATError { public LLMError(String message) { super(message); diff --git a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java index 764b55d3..f6d26362 100644 --- a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java +++ b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java @@ -3,8 +3,8 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; /** @@ -30,7 +30,7 @@ public String loadText(PromptResourceKey key) { try (stream) { return new String(stream.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException error) { - throw new SdkException("Failed to read prompt resource: " + relativePath, error); + throw new A2ATError("Failed to read prompt resource: " + relativePath, error); } } } From f3bf911d0bd664471dedad4004e117ed87cb9739 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 21 Aug 2026 14:13:34 +0800 Subject: [PATCH 067/295] refactor(prompt): re-parent prompt exceptions onto A2ATError ScenarioRecognitionException / TaskPromptRenderException extend A2ATError; loader and validator I/O wraps use A2ATError; TaskPromptRenderer null template becomes Objects.requireNonNull (outside the tree). Signed-off-by: codetalks-hw --- .../ScenarioRecognitionException.java | 4 ++-- ...ultStructuredPromptSlotValueExtractor.java | 4 ++-- .../catalog/PromptTemplateCatalog.java | 8 ++++---- .../ClasspathPromptScenarioCatalogLoader.java | 4 ++-- .../ClasspathPromptSlotSchemaLoader.java | 4 ++-- .../LocalFilePromptScenarioCatalogLoader.java | 4 ++-- .../LocalFilePromptSlotSchemaLoader.java | 6 +++--- .../loader/LocalFilePromptTemplateLoader.java | 6 +++--- .../loader/PromptResourceAccess.java | 4 ++-- .../taskrendering/api/TaskPromptRenderer.java | 6 ++++-- .../exception/TaskPromptRenderException.java | 4 ++-- .../validation/DefaultContentValidator.java | 19 +++++++------------ .../validation/DefaultSemanticValidator.java | 4 ++-- .../loader/ClasspathPromptLoadersTest.java | 8 ++++---- .../loader/LocalFilePromptLoadersTest.java | 8 ++++---- .../api/TaskPromptRendererTest.java | 14 ++++++++++++++ 16 files changed, 59 insertions(+), 48 deletions(-) diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/exception/ScenarioRecognitionException.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/exception/ScenarioRecognitionException.java index 066d574a..2b08dc76 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/exception/ScenarioRecognitionException.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/exception/ScenarioRecognitionException.java @@ -1,13 +1,13 @@ package net.openan.a2at.sdk.prompt.analysis.exception; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; /** * Raised when a structured scenario recognition payload is invalid. * * @since 2026-06 */ -public final class ScenarioRecognitionException extends SdkException { +public final class ScenarioRecognitionException extends A2ATError { /** * Creates a scenario-recognition exception. diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java index 5152c9cc..574f6d87 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java @@ -8,7 +8,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.model.PromptMessage; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; @@ -120,7 +120,7 @@ private static Map parseObject(String payload) { OBJECT_MAPPER.readValue(payload, new TypeReference>() {}); return response == null ? Map.of() : response; } catch (JsonProcessingException error) { - throw new SdkException("Structured LLM payload must be a JSON object.", error); + throw new A2ATError("Structured LLM payload must be a JSON object.", error); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java index 13215b53..1ecad2bb 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java @@ -17,7 +17,7 @@ import java.util.Optional; import java.util.stream.Stream; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.core.resources.PathSegments; @@ -132,7 +132,7 @@ public Optional load(String templateUri) { try { String content = readTemplate(relativePath); return Optional.of(new PromptTemplate(uri, TemplateDescriptions.extract(content), content)); - } catch (SdkException exception) { + } catch (A2ATError exception) { return Optional.empty(); } } @@ -235,7 +235,7 @@ private String readTemplate(String relativePath) { try { return Files.readString(localPath, StandardCharsets.UTF_8); } catch (IOException exception) { - throw new SdkException("Failed to read prompt template: " + localPath, exception); + throw new A2ATError("Failed to read prompt template: " + localPath, exception); } } } @@ -250,7 +250,7 @@ private String readTemplate(String relativePath) { try (stream) { return new String(stream.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException exception) { - throw new SdkException("Failed to read prompt template: " + classpathPath, exception); + throw new A2ATError("Failed to read prompt template: " + classpathPath, exception); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptScenarioCatalogLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptScenarioCatalogLoader.java index 3b4cb0b4..37e385c7 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptScenarioCatalogLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptScenarioCatalogLoader.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import java.util.List; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; import net.openan.a2at.sdk.resources.PromptResourceKey; @@ -29,7 +29,7 @@ public List load(String language) { return PromptResourceJsonParser.parse(payload, ScenarioCatalog.class) .scenarios(); } catch (JsonProcessingException exception) { - throw new SdkException("Failed to parse scenario catalog for language: " + language, exception); + throw new A2ATError("Failed to parse scenario catalog for language: " + language, exception); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java index dcb99554..6b83c491 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java @@ -5,7 +5,7 @@ import java.util.List; import java.util.Set; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.resources.ClasspathResourceDirectories; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; @@ -44,7 +44,7 @@ public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { } catch (ResourceNotFoundException ignored) { // try next slot type } catch (JsonProcessingException exception) { - throw new SdkException("Failed to parse slot schema: " + scenarioCode, exception); + throw new A2ATError("Failed to parse slot schema: " + scenarioCode, exception); } } throw new ResourceNotFoundException( diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java index f399d9db..8160b82d 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java @@ -6,7 +6,7 @@ import java.nio.file.Path; import java.util.List; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; /** @@ -31,7 +31,7 @@ public List load(String language) { return PromptResourceJsonParser.parse(Files.readString(catalogPath), ScenarioCatalog.class) .scenarios(); } catch (IOException exception) { - throw new SdkException("Failed to read scenario catalog: " + catalogPath, exception); + throw new A2ATError("Failed to read scenario catalog: " + catalogPath, exception); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java index c9228866..1685be94 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java @@ -5,7 +5,7 @@ import java.nio.file.Path; import java.util.Optional; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; @@ -43,7 +43,7 @@ public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { .findFirst(); schemaPath = match.orElse(null); } catch (IOException exception) { - throw new SdkException("Failed to scan slot schema resources: " + slotsRoot, exception); + throw new A2ATError("Failed to scan slot schema resources: " + slotsRoot, exception); } if (schemaPath == null) { throw notFound(scenarioCode, language); @@ -53,7 +53,7 @@ public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { return PromptResourceJsonParser.parse(Files.readString(schemaPath), PromptSlotJsonSchema.class) .toPromptSlotSchema(scenarioCode); } catch (IOException exception) { - throw new SdkException("Failed to read slot schema resource: " + schemaPath, exception); + throw new A2ATError("Failed to read slot schema resource: " + schemaPath, exception); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java index 0a29287f..2c2314c0 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java @@ -5,7 +5,7 @@ import java.nio.file.Path; import java.util.Optional; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; /** * Loads shared prompt templates from one local prompt resource root. @@ -41,7 +41,7 @@ public String loadTemplate(String scenarioCode, String language) { .findFirst(); templatePath = match.orElse(null); } catch (IOException exception) { - throw new SdkException("Failed to scan prompt template resources: " + templatesRoot, exception); + throw new A2ATError("Failed to scan prompt template resources: " + templatesRoot, exception); } if (templatePath == null) { throw notFound(scenarioCode, language); @@ -50,7 +50,7 @@ public String loadTemplate(String scenarioCode, String language) { try { return Files.readString(templatePath); } catch (IOException exception) { - throw new SdkException("Failed to read template resource: " + templatePath, exception); + throw new A2ATError("Failed to read template resource: " + templatePath, exception); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java index e9a0d879..49384688 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java @@ -5,7 +5,7 @@ import java.nio.file.Path; import java.util.List; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; @@ -139,7 +139,7 @@ public String loadPrompt(String promptCategory, String language, String fileName try { return Files.readString(promptPath); } catch (IOException exception) { - throw new SdkException("Failed to read prompt resource: " + promptPath, exception); + throw new A2ATError("Failed to read prompt resource: " + promptPath, exception); } } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRenderer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRenderer.java index 98856298..d709b339 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRenderer.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRenderer.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.prompt.taskrendering.api; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -30,8 +31,9 @@ public final class TaskPromptRenderer implements SectionedTemplateRenderer { */ @Override public String render(String templateText, Map slots) { - if (templateText == null || !balancedBraces(templateText)) { - throw new TaskPromptRenderException("Template text is invalid."); + Objects.requireNonNull(templateText, "Template text must not be null."); + if (!balancedBraces(templateText)) { + throw new TaskPromptRenderException("Template text has unbalanced braces."); } Matcher matcher = PLACEHOLDER_PATTERN.matcher(collapseSlotDrivenSections(templateText)); diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/exception/TaskPromptRenderException.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/exception/TaskPromptRenderException.java index 8c633cb9..dee0144b 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/exception/TaskPromptRenderException.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/exception/TaskPromptRenderException.java @@ -1,13 +1,13 @@ package net.openan.a2at.sdk.prompt.taskrendering.exception; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; /** * Raised when task prompt template rendering fails. * * @since 2026-06 */ -public final class TaskPromptRenderException extends SdkException { +public final class TaskPromptRenderException extends A2ATError { /** * Creates a task prompt rendering exception. diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java index dd2c6fe7..f135d5af 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -1,9 +1,8 @@ package net.openan.a2at.sdk.prompt.validation; import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import java.util.Objects; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.validation.ContentValidator; import net.openan.a2at.sdk.core.validation.TemplateReference; import net.openan.a2at.sdk.core.validation.ValidationPipeline; @@ -57,15 +56,14 @@ public DefaultContentValidator( @Override public FilledParamData validate(String prompt, Map schema, String templateUri) { - if (templateUri == null || templateUri.isBlank()) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, "Template URI must not be null or blank."); + Objects.requireNonNull(templateUri, "templateUri"); + if (templateUri.isBlank()) { + throw new IllegalArgumentException("Template URI must not be blank."); } String[] parts = templateUri.split("/"); if (parts.length < 3) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, + throw new IllegalArgumentException( "Template URI format must be {prefix}/v1/{scenario}, got: " + templateUri); } @@ -74,16 +72,13 @@ public FilledParamData validate(String prompt, Map schema, Strin String scenario = parts[2]; if (!extensionPrefix.equals(prefix)) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, + throw new IllegalArgumentException( "Template URI prefix '" + prefix + "' does not match expected extension prefix '" + extensionPrefix + "'."); } if (!"v1".equals(version)) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, - "Unsupported template URI version: " + version); + throw new IllegalArgumentException("Unsupported template URI version: " + version); } TemplateReference reference = new SimpleTemplateReference(templateUri, language, extensionPrefix); diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index 79ee58f1..15fa8ab9 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -8,7 +8,7 @@ import java.util.Map; import java.util.regex.Matcher; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.json.JacksonJsonValueParser; import net.openan.a2at.sdk.core.json.JsonValueParser; import net.openan.a2at.sdk.core.model.PromptMessage; @@ -98,7 +98,7 @@ private String fillUserPrompt(String prompt, Map schema, Templat try { schemaJson = OBJECT_MAPPER.writeValueAsString(schema); } catch (JsonProcessingException exception) { - throw new SdkException("Failed to serialize schema to JSON.", exception); + throw new A2ATError("Failed to serialize schema to JSON.", exception); } return userPromptTemplate diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java index dffd52ab..97172dbf 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java @@ -4,8 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.List; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; @@ -86,9 +86,9 @@ void missingClasspathResourceReportsResolvedResourcePath() { } @Test - void invalidClasspathScenarioCatalogIsWrappedAsSdkException() { - SdkException exception = assertThrows( - SdkException.class, () -> new ClasspathPromptScenarioCatalogLoader(resourceLoader).load("broken")); + void invalidClasspathScenarioCatalogIsWrappedAsA2ATError() { + A2ATError exception = assertThrows( + A2ATError.class, () -> new ClasspathPromptScenarioCatalogLoader(resourceLoader).load("broken")); assertEquals("Failed to parse scenario catalog for language: broken", exception.getMessage()); } diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java index f9cf0e00..dc3d4a6e 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java @@ -9,7 +9,7 @@ import java.nio.file.Path; import java.util.List; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import org.junit.jupiter.api.Test; @@ -144,7 +144,7 @@ void missingTemplateIncludesResolvedLocalPathInResourceNotFoundException() { } @Test - void malformedLocalSlotSchemaIsWrappedAsSdkException() throws IOException { + void malformedLocalSlotSchemaIsWrappedAsA2ATError() throws IOException { write( promptRootDir .resolve("slots") @@ -155,8 +155,8 @@ void malformedLocalSlotSchemaIsWrappedAsSdkException() throws IOException { .resolve("slot.json"), "{ \"required\": [\"severity\"], \"properties\": "); - SdkException exception = - assertThrows(SdkException.class, () -> new LocalFilePromptSlotSchemaLoader(promptRootDir) + A2ATError exception = + assertThrows(A2ATError.class, () -> new LocalFilePromptSlotSchemaLoader(promptRootDir) .loadSlotSchema("incident_triage", "en")); assertTrue(exception.getMessage().startsWith("Failed to read slot schema resource: ")); diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRendererTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRendererTest.java index 45f5c7cb..7633f6b6 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRendererTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRendererTest.java @@ -108,4 +108,18 @@ void renderRaisesWhenTemplateReferencesUnknownSlot() { TaskPromptRenderException.class, () -> renderer.render("Site: {site}\nTime Range: {time_range}", Map.of("site", "Site A"))); } + + @Test + void renderRaisesNullPointerExceptionWhenTemplateIsNull() { + NullPointerException exception = + assertThrows(NullPointerException.class, () -> renderer.render(null, Map.of("site", "Site A"))); + + assertEquals("Template text must not be null.", exception.getMessage()); + } + + @Test + void renderRaisesWhenTemplateHasUnbalancedBraces() { + assertThrows( + TaskPromptRenderException.class, () -> renderer.render("Site: {{site}", Map.of("site", "Site A"))); + } } From 3fb7c861a36e288c234b503419cdb078a3e1533b Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 21 Aug 2026 14:13:34 +0800 Subject: [PATCH 068/295] refactor(negotiation): remove NegotiationContentException in favor of NPE/IAE All ~45 argument-contract throw sites now use Objects.requireNonNull for null and IllegalArgumentException for blank/malformed/semantic violations, staying outside the A2ATError tree; NegotiationReference.parse becomes tryParse returning Optional (removing catch-based control flow); NegotiationProcessingException delegates its code to the A2ATError root; observability boundary tests rewritten against the new carriers. Signed-off-by: codetalks-hw --- .../content/NegotiationContentException.java | 46 -- .../content/NegotiationContentService.java | 36 +- .../content/NegotiationContext.java | 13 +- .../NegotiationProcessingException.java | 84 ++- .../sdk/negotiation/content/Vocabulary.java | 14 +- .../AbstractNegotiationGenerator.java | 33 +- .../DefaultNegotiationContentExtractor.java | 8 +- .../FeasibilityProposeGenerator.java | 11 +- .../NegotiationContextRenderer.java | 52 +- .../NegotiationGenerationOrchestrator.java | 90 +-- ...otiationGenerationOrchestratorBuilder.java | 3 +- .../generation/NegotiationGenerator.java | 3 +- .../NegotiationGeneratorRegistry.java | 48 +- .../NegotiationJsonSchemaBuilder.java | 18 +- .../generation/NegotiationMessageBuilder.java | 8 +- .../NegotiationPromptResourceLoader.java | 14 +- .../DefaultNegotiationTemplateLoader.java | 16 +- .../resources/NegotiationReference.java | 276 ++++----- .../store/impl/InMemoryNegotiationStore.java | 12 +- .../exception/NegotiationStateException.java | 11 +- .../DefaultNegotiationComplianceChecker.java | 5 +- .../DefaultNegotiationSemanticValidator.java | 4 +- .../validation/ParamExtractor.java | 2 +- .../content/NegotiationContextTest.java | 30 +- .../NegotiationExceptionHierarchyTest.java | 26 +- .../negotiation/content/VocabularyTest.java | 8 +- ...otiationDeliveredResourceContractTest.java | 1 + .../NegotiationPackageStructureTest.java | 1 - .../FromDataProgrammingErrorMatrixTest.java | 583 +++++++++--------- .../generation/FromTextLlmPipelineTest.java | 13 +- .../NegotiationContentExtractorTest.java | 8 +- .../NegotiationContextRendererTest.java | 6 +- ...NegotiationGenerationOrchestratorTest.java | 19 +- .../NegotiationGeneratorRegistryTest.java | 49 +- .../generation/NegotiationGeneratorsTest.java | 82 +-- .../NegotiationJsonSchemaBuilderTest.java | 19 +- .../NegotiationPromptResourceLoaderTest.java | 27 +- .../TemplateUriEntryMatrixTest.java | 13 +- .../UnsupportedLanguageBehaviorTest.java | 9 +- .../NegotiationErrorCodeUsageMatrixTest.java | 23 +- ...NegotiationExceptionTreeRootCatchTest.java | 48 +- ...NegotiationPublicExceptionSurfaceTest.java | 4 +- .../resources/NegotiationReferenceTest.java | 295 ++++----- .../store/InMemoryNegotiationStoreTest.java | 35 ++ 44 files changed, 1047 insertions(+), 1059 deletions(-) delete mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentException.java diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentException.java deleted file mode 100644 index ae9f3845..00000000 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentException.java +++ /dev/null @@ -1,46 +0,0 @@ -package net.openan.a2at.sdk.negotiation.content; - -/** - * Raised when negotiation content or negotiation content input violates a structural rule. - * - *

This is a programming-error exception: it signals invalid input data such as a blank context id or a malformed - * template URI, not a processing failure. It intentionally does not extend the SDK processing-error root type. - * - * @since 2026-06 - */ -public class NegotiationContentException extends RuntimeException { - - /** - * Path of the offending input field, such as {@code context.id} or {@code templateUri}; null when not applicable. - */ - private final String field; - - /** - * Creates a content exception without a field path. - * - * @param message failure message - */ - public NegotiationContentException(String message) { - this(message, null); - } - - /** - * Creates a content exception pointing at one offending input field. - * - * @param message failure message - * @param field path of the offending input field, or null when no single field applies - */ - public NegotiationContentException(String message, String field) { - super(message); - this.field = field; - } - - /** - * Returns the path of the offending input field. - * - * @return field path such as {@code templateUri}, or null when no single field applies - */ - public String getField() { - return field; - } -} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java index fdc7b22f..2c3b8190 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java @@ -86,8 +86,9 @@ public static A2ATConfig resolvePromptResourceLocalRootDir(A2ATConfig config, Pa * @param data typed propose input carrying the negotiation context and the typed content * @param templateUri template URI whose phase segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NegotiationContentException if the data or its context is null, the template URI is malformed, or its - * phase or type contradicts the method or the content type + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI is malformed, or its phase or type contradicts the method + * or the content type * @throws NegotiationGenerationException with the code {@code template_not_found} or * {@code negotiation_slot_missing} when loading or rendering the template fails */ @@ -101,8 +102,9 @@ public MetadataContent generateProposeFromData(NegotiationProposeData data, Stri * @param data typed terminal input whose content conclusion must be {@code Accept} * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NegotiationContentException if the data or its context is null, the template URI is malformed, its phase - * or type contradicts the method or the content, or the content conclusion is not {@code Accept} + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or + * the content, or the content conclusion is not {@code Accept} * @throws NegotiationGenerationException with the code {@code template_not_found} or * {@code negotiation_slot_missing} when loading or rendering the template fails */ @@ -116,8 +118,9 @@ public MetadataContent generateAcceptFromData(NegotiationEndingData data, String * @param data typed terminal input whose content conclusion must be {@code Reject} * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NegotiationContentException if the data or its context is null, the template URI is malformed, its phase - * or type contradicts the method or the content, or the content conclusion is not {@code Reject} + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or + * the content, or the content conclusion is not {@code Reject} * @throws NegotiationGenerationException with the code {@code template_not_found} or * {@code negotiation_slot_missing} when loading or rendering the template fails */ @@ -132,8 +135,8 @@ public MetadataContent generateRejectFromData(NegotiationEndingData data, String * @param context negotiation context injected into the rendered message without any LLM involvement * @param templateUri template URI whose phase segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NegotiationContentException if the context is null or the template URI is malformed or contradicts the - * method + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI is malformed or contradicts the method * @throws NegotiationGenerationException with the code {@code template_not_found}, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or @@ -150,8 +153,8 @@ public MetadataContent generateProposeFromText(String text, NegotiationContext c * @param context negotiation context injected into the rendered message without any LLM involvement * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NegotiationContentException if the context is null or the template URI is malformed or contradicts the - * method + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI is malformed or contradicts the method * @throws NegotiationGenerationException with the code {@code template_not_found}, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or @@ -168,8 +171,8 @@ public MetadataContent generateAcceptFromText(String text, NegotiationContext co * @param context negotiation context injected into the rendered message without any LLM involvement * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NegotiationContentException if the context is null or the template URI is malformed or contradicts the - * method + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI is malformed or contradicts the method * @throws NegotiationGenerationException with the code {@code template_not_found}, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or @@ -207,7 +210,8 @@ public Optional getNegotiationPrompt(String templateUri) { * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI whose phase segment must be {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NegotiationContentException if the schema is null or the template URI is malformed or contradicts the + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI is malformed or contradicts the * method * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, @@ -226,7 +230,8 @@ public FilledParamData validateAndFillingProposeData( * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NegotiationContentException if the schema is null or the template URI is malformed or contradicts the + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI is malformed or contradicts the * method * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, @@ -244,7 +249,8 @@ public FilledParamData validateAndFillingAcceptData(String prompt, MapEach failure carries a machine-readable error code from {@code A2ATErrorCodes} so callers can branch on the - * failure class without parsing messages. - * - * @since 2026-06 - */ -public class NegotiationProcessingException extends A2ATError { - - /** Machine-readable error code carried by this failure. */ - private final String code; - - /** - * Creates a negotiation processing failure with one error code. - * - * @param code machine-readable error code for the failure - * @param message failure message - */ - public NegotiationProcessingException(String code, String message) { - super(message); - this.code = code; - } - - /** - * Creates a negotiation processing failure with one error code and a root cause. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param cause root cause - */ - public NegotiationProcessingException(String code, String message, Throwable cause) { - super(message, cause); - this.code = code; - } - - /** - * Returns the machine-readable error code for this failure. - * - * @return error code such as {@code negotiation_content_extract_failed} - */ - public String getCode() { - return code; - } -} +package net.openan.a2at.sdk.negotiation.content; + +import net.openan.a2at.sdk.core.exception.A2ATError; + +/** + * Base failure type raised when a negotiation processing step fails at runtime. + * + *

Each failure carries a machine-readable error code from {@code A2ATErrorCodes} — inherited from the + * {@link A2ATError} root — so callers can branch on the failure class without parsing messages. + * + * @since 2026-06 + */ +public class NegotiationProcessingException extends A2ATError { + + /** + * Creates a negotiation processing failure with one error code. + * + * @param code machine-readable error code for the failure + * @param message failure message + */ + public NegotiationProcessingException(String code, String message) { + super(code, message); + } + + /** + * Creates a negotiation processing failure with one error code and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param cause root cause + */ + public NegotiationProcessingException(String code, String message, Throwable cause) { + super(code, message, cause); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java index 8850697d..68a32dd3 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java @@ -40,7 +40,7 @@ private Vocabulary(String language, Map entries) { * * @param language locale identifier such as {@code zh-CN} or {@code en-US} * @return vocabulary holding the text constants of that language - * @throws NegotiationContentException if the language has no bundled vocabulary + * @throws IllegalArgumentException if the language has no bundled vocabulary */ public static Vocabulary forLanguage(String language) { if (ZH_CN.equals(language)) { @@ -49,10 +49,9 @@ public static Vocabulary forLanguage(String language) { if (EN_US.equals(language)) { return EN_US_VOCABULARY; } - throw new NegotiationContentException( + throw new IllegalArgumentException( "Unsupported negotiation vocabulary language " + language + "; supported languages are zh-CN and" - + " en-US, configure A2AT_LANGUAGE accordingly.", - "language"); + + " en-US, configure A2AT_LANGUAGE accordingly."); } /** @@ -60,14 +59,13 @@ public static Vocabulary forLanguage(String language) { * * @param canonicalKey canonical vocabulary key such as {@code section.context} or {@code punct.list_colon} * @return language-specific text constant - * @throws NegotiationContentException if the key is not part of the vocabulary + * @throws IllegalArgumentException if the key is not part of the vocabulary */ public String get(String canonicalKey) { String value = entries.get(canonicalKey); if (value == null) { - throw new NegotiationContentException( - "Unknown negotiation vocabulary key " + canonicalKey + " for language " + language + ".", - "canonicalKey"); + throw new IllegalArgumentException( + "Unknown negotiation vocabulary key " + canonicalKey + " for language " + language + "."); } return value; } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java index e5181a8c..1697d2ee 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java @@ -2,9 +2,9 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.Vocabulary; @@ -32,15 +32,14 @@ abstract class AbstractNegotiationGenerator implements NegotiationGenerator { * @param expectedType exact content class this generator serves * @param generatorName human-readable generator name used in the failure message * @return content cast to the expected type - * @throws NegotiationContentException if the content is null or of another runtime type + * @throws IllegalArgumentException if the content is null or of another runtime type */ protected static T contentOf( NegotiationContent content, Class expectedType, String generatorName) { if (content == null || content.getClass() != expectedType) { - throw new NegotiationContentException( + throw new IllegalArgumentException( generatorName + " requires content of type " + expectedType.getSimpleName() + " but received " - + (content == null ? "null" : content.getClass().getSimpleName()) + ".", - "content"); + + (content == null ? "null" : content.getClass().getSimpleName()) + "."); } return expectedType.cast(content); } @@ -50,19 +49,17 @@ protected static T contentOf( * * @param conclusion conclusion carried by ending content * @return the validated conclusion - * @throws NegotiationContentException if the conclusion is null or abort + * @throws NullPointerException if the conclusion is null + * @throws IllegalArgumentException if the conclusion is abort */ protected static NegotiationConclusion renderableConclusion(NegotiationConclusion conclusion) { - if (conclusion == null) { - throw new NegotiationContentException( - "Negotiation conclusion must not be null; accept and reject are the renderable conclusions.", - "content.conclusion"); - } + Objects.requireNonNull( + conclusion, "Negotiation conclusion must not be null; accept and reject are the renderable" + + " conclusions."); if (conclusion == NegotiationConclusion.ABORT) { - throw new NegotiationContentException( + throw new IllegalArgumentException( "Negotiation conclusion Abort has no template section to render; terminate an exhausted" - + " negotiation with a reject message instead.", - "content.conclusion"); + + " negotiation with a reject message instead."); } return conclusion; } @@ -74,11 +71,11 @@ protected static NegotiationConclusion renderableConclusion(NegotiationConclusio * @param field field path used in the failure * @param description field description used in the failure message * @return the validated text - * @throws NegotiationContentException if the value is null or blank + * @throws IllegalArgumentException if the value is null or blank */ protected static String requiredText(String value, String field, String description) { if (value == null || value.isBlank()) { - throw new NegotiationContentException(description + " must not be blank.", field); + throw new IllegalArgumentException(description + " must not be blank."); } return value; } @@ -90,12 +87,12 @@ protected static String requiredText(String value, String field, String descript * @param field field path used in the failure * @param description field description used in the failure message * @return the validated item list - * @throws NegotiationContentException if the list is null or empty + * @throws IllegalArgumentException if the list is null or empty */ protected static List requiredItems( List items, String field, String description) { if (items == null || items.isEmpty()) { - throw new NegotiationContentException(description + " must contain at least one item.", field); + throw new IllegalArgumentException(description + " must contain at least one item."); } return items; } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java index 0ebe7e23..4c8d1d1b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.json.JacksonJsonValueParser; import net.openan.a2at.sdk.core.json.JsonValueParser; @@ -15,7 +16,6 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; @@ -94,16 +94,14 @@ public DefaultNegotiationContentExtractor( * @param text free-text input describing the message content * @param reference reference identifying the negotiation type, phase and language to extract for * @return typed negotiation content matching the reference - * @throws NegotiationContentException if the reference is null or the text is blank + * @throws NullPointerException if the reference is null * @throws NegotiationGenerationException with one of the codes {@code negotiation_llm_infrastructure_error}, * {@code negotiation_content_extract_failed}, {@code negotiation_slot_missing} or * {@code negotiation_invalid_input} */ @Override public NegotiationContent extract(String text, NegotiationReference reference) { - if (reference == null) { - throw new NegotiationContentException("Negotiation reference must not be null.", "reference"); - } + Objects.requireNonNull(reference, "Negotiation reference must not be null."); if (text == null || text.isBlank()) { throw new NegotiationGenerationException( A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java index a2f88a97..0166a981 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java @@ -2,10 +2,10 @@ import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.core.model.PromptTemplate; @@ -40,12 +40,9 @@ public String generate( "content.feasibilityNegotiationDescription", "Feasibility negotiation description"); NegotiationAction action = proposeContent.action(); - if (action == null) { - throw new NegotiationContentException( - "Feasibility negotiation action must not be null; it selects the conditional sections of the" - + " message.", - "content.action"); - } + Objects.requireNonNull( + action, + "Feasibility negotiation action must not be null; it selects the conditional sections of the message."); Map slots = new LinkedHashMap<>(); slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); slots.put(vocabulary.get("slot.feasibility"), proposeContent.feasibilityNegotiationDescription()); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java index c8714bb3..2c080886 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java @@ -1,27 +1,25 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; - -/** - * Assembles the negotiation context into the single slot value used by context sections. - * - *

The context travels in one template slot as a markdown list whose field names are language-neutral. - */ -public final class NegotiationContextRenderer { - - /** - * Renders one negotiation context as a markdown list. - * - * @param context negotiation context to render - * @return three lines of the form {@code - id: ...}, {@code - round: ...}, {@code - maxRounds: ...} joined by - * single newlines - * @throws NegotiationContentException if the context is null - */ - public String render(NegotiationContext context) { - if (context == null) { - throw new NegotiationContentException("Negotiation context must not be null.", "context"); - } - return "- id: " + context.id() + "\n- round: " + context.round() + "\n- maxRounds: " + context.maxRounds(); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.Objects; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; + +/** + * Assembles the negotiation context into the single slot value used by context sections. + * + *

The context travels in one template slot as a markdown list whose field names are language-neutral. + */ +public final class NegotiationContextRenderer { + + /** + * Renders one negotiation context as a markdown list. + * + * @param context negotiation context to render + * @return three lines of the form {@code - id: ...}, {@code - round: ...}, {@code - maxRounds: ...} joined by + * single newlines + * @throws NullPointerException if the context is null + */ + public String render(NegotiationContext context) { + Objects.requireNonNull(context, "Negotiation context must not be null."); + return "- id: " + context.id() + "\n- round: " + context.round() + "\n- maxRounds: " + context.maxRounds(); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 18c49226..4ae3562d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.function.Supplier; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; @@ -12,7 +13,6 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; @@ -98,15 +98,14 @@ public final class NegotiationGenerationOrchestrator { * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/propose}; its phase * segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NegotiationContentException if the data or its context is null, the template URI is malformed, or its - * phase or type contradicts the method or the content type + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI is malformed, or its phase or type contradicts the method + * or the content type * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails */ public MetadataContent generateProposeFromData(NegotiationProposeData data, String templateUri) { - if (data == null) { - throw new NegotiationContentException("Negotiation propose data must not be null.", "data"); - } + Objects.requireNonNull(data, "Negotiation propose data must not be null."); return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.PROPOSE); } @@ -120,15 +119,14 @@ public MetadataContent generateProposeFromData(NegotiationProposeData data, Stri * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/accept-reject}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NegotiationContentException if the data or its context is null, the template URI is malformed, its phase - * or type contradicts the method or the content, or the content conclusion is not {@code Accept} + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or + * the content, or the content conclusion is not {@code Accept} * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails */ public MetadataContent generateAcceptFromData(NegotiationEndingData data, String templateUri) { - if (data == null) { - throw new NegotiationContentException("Negotiation ending data must not be null.", "data"); - } + Objects.requireNonNull(data, "Negotiation ending data must not be null."); return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.ACCEPT); } @@ -142,15 +140,14 @@ public MetadataContent generateAcceptFromData(NegotiationEndingData data, String * @param templateUri template URI such as {@code Negotiation-T/v1/feasibility-negotiation/accept-reject}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NegotiationContentException if the data or its context is null, the template URI is malformed, its phase - * or type contradicts the method or the content, or the content conclusion is not {@code Reject} + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or + * the content, or the content conclusion is not {@code Reject} * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails */ public MetadataContent generateRejectFromData(NegotiationEndingData data, String templateUri) { - if (data == null) { - throw new NegotiationContentException("Negotiation ending data must not be null.", "data"); - } + Objects.requireNonNull(data, "Negotiation ending data must not be null."); return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.REJECT); } @@ -167,8 +164,8 @@ public MetadataContent generateRejectFromData(NegotiationEndingData data, String * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose}; its phase segment * must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NegotiationContentException if the context is null or the template URI is malformed or its phase - * contradicts the method + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt * resource exists for the URI and language, {@code negotiation_content_extract_failed} or * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, @@ -191,8 +188,8 @@ public MetadataContent generateProposeFromText(String text, NegotiationContext c * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/accept-reject}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NegotiationContentException if the context is null or the template URI is malformed or its phase - * contradicts the method + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt * resource exists for the URI and language, {@code negotiation_content_extract_failed} or * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, @@ -215,8 +212,8 @@ public MetadataContent generateAcceptFromText(String text, NegotiationContext co * @param templateUri template URI such as {@code Negotiation-T/v1/feasibility-negotiation/accept-reject}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NegotiationContentException if the context is null or the template URI is malformed or its phase - * contradicts the method + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt * resource exists for the URI and language, {@code negotiation_content_extract_failed} or * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, @@ -291,8 +288,8 @@ public Optional getNegotiationPrompt(String templateUri) { * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NegotiationContentException if the schema is null or the template URI is malformed or its phase - * contradicts the method + * @throws NullPointerException if the schema is null + * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, @@ -316,8 +313,8 @@ public FilledParamData validateAndFillingProposeData( * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NegotiationContentException if the schema is null or the template URI is malformed or its phase - * contradicts the method + * @throws NullPointerException if the schema is null + * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, @@ -340,8 +337,8 @@ public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri, NegotiationPhase phase) { - if (schema == null) { - throw new NegotiationContentException("Parameter schema must not be null.", "schema"); - } - NegotiationReference reference = NegotiationReference.parse(templateUri, phase, language); + Objects.requireNonNull(schema, "Parameter schema must not be null."); + NegotiationReference reference = requireReference(templateUri, phase); try { return paramExtractor.extract(prompt, schema, reference); } catch (NegotiationParamExtractionException failure) { @@ -448,15 +443,30 @@ private FilledParamData validateAndFilling( } } + /** + * Parses the template URI against one expected phase, rejecting every URI the parse rejects. + * + * @param templateUri template URI declared by the caller + * @param phase API-level phase the calling method operates on + * @return reference addressed by the URI carrying the expected phase + * @throws IllegalArgumentException if the URI is null, blank, malformed or its phase segment does not match the + * expected phase + */ + private NegotiationReference requireReference(String templateUri, NegotiationPhase phase) { + return NegotiationReference.tryParse(templateUri, phase, language) + .orElseThrow(() -> new IllegalArgumentException( + "Template URI is malformed or contradicts the expected phase " + phase + " (" + + phase.uriSegment() + "): " + templateUri + ".")); + } + private Optional parseQueryReference(String templateUri) { // The URI layer cannot distinguish accept from reject because both phases share the accept-reject template // segment. The query therefore addresses the shared template through the ACCEPT phase; the parsed phase is an // addressing artifact only and must never be read as the phase of any message. for (NegotiationPhase phase : List.of(NegotiationPhase.PROPOSE, NegotiationPhase.ACCEPT)) { - try { - return Optional.of(NegotiationReference.parse(templateUri, phase, language)); - } catch (NegotiationContentException exception) { - // The next candidate phase may still match the URI segment. + Optional reference = NegotiationReference.tryParse(templateUri, phase, language); + if (reference.isPresent()) { + return reference; } } return Optional.empty(); @@ -522,8 +532,6 @@ private static String retryableCode(RuntimeException failure) { } private static void requireContext(NegotiationContext context) { - if (context == null) { - throw new NegotiationContentException("Negotiation context must not be null.", "context"); - } + Objects.requireNonNull(context, "Negotiation context must not be null."); } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index e16002d9..3d60cbbd 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -160,8 +160,7 @@ public NegotiationGenerationOrchestratorBuilder logger(Logger logger) { * * @return assembled negotiation generation orchestrator * @throws IllegalStateException if the language is missing or the attempt limit is below 1 - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the language has no bundled - * vocabulary + * @throws IllegalArgumentException if the language has no bundled vocabulary */ public NegotiationGenerationOrchestrator build() { if (language == null || language.isBlank()) { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java index 29185119..35daf1c0 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java @@ -1,7 +1,6 @@ package net.openan.a2at.sdk.negotiation.generation; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.core.model.PromptTemplate; @@ -25,7 +24,7 @@ public interface NegotiationGenerator { * @param template loaded template to render * @param vocabulary vocabulary supplying the slot names of the message language * @return rendered negotiation message text - * @throws NegotiationContentException if the content does not match this generator or violates an input rule + * @throws IllegalArgumentException if the content does not match this generator or violates an input rule */ String generate( NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java index ebb6cfb1..2c421668 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java @@ -2,13 +2,13 @@ import java.util.EnumMap; import java.util.Map; +import java.util.Objects; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; @@ -53,44 +53,36 @@ public NegotiationGeneratorRegistry() { * @param phase API-level phase addressed by the calling method * @param content typed content of the message * @return generator registered for the exact (type, phase) pair - * @throws NegotiationContentException if any argument is null, the content family does not match the phase, the - * content runtime type does not match the negotiation type, or an ending content carries a conclusion that does - * not match the phase + * @throws NullPointerException if any argument is null + * @throws IllegalArgumentException if the content family does not match the phase, the content runtime type does + * not match the negotiation type, or an ending content carries a conclusion that does not match the phase */ public NegotiationGenerator resolve(NegotiationType type, NegotiationPhase phase, NegotiationContent content) { - if (type == null) { - throw new NegotiationContentException("Negotiation type must not be null.", "type"); - } - if (phase == null) { - throw new NegotiationContentException("Negotiation phase must not be null.", "phase"); - } - if (content == null) { - throw new NegotiationContentException("Negotiation content must not be null.", "content"); - } + Objects.requireNonNull(type, "Negotiation type must not be null."); + Objects.requireNonNull(phase, "Negotiation phase must not be null."); + Objects.requireNonNull(content, "Negotiation content must not be null."); boolean proposePhase = phase == NegotiationPhase.PROPOSE; boolean proposeContent = content instanceof NegotiationProposeContent; if (proposePhase != proposeContent) { - throw new NegotiationContentException( + throw new IllegalArgumentException( "The " + phase + " phase requires " + (proposePhase ? "propose" : "ending") + " content but received " + (proposeContent ? "propose" : "ending") + " content of type " - + content.getClass().getSimpleName() + ".", - "content"); + + content.getClass().getSimpleName() + "."); } Class expectedType = expectedContentClass(type, proposePhase); if (content.getClass() != expectedType) { - throw new NegotiationContentException( + throw new IllegalArgumentException( "Negotiation type " + type + " requires content of type " + expectedType.getSimpleName() - + " but received " + content.getClass().getSimpleName() + ".", - "content"); + + " but received " + content.getClass().getSimpleName() + "."); } if (!proposePhase) { requireConclusionMatchesPhase((NegotiationEndingContent) content, phase); } NegotiationGenerator generator = (proposePhase ? proposeGenerators : endingGenerators).get(type); if (generator == null) { - throw new NegotiationContentException( - "No negotiation generator is registered for type " + type + " and phase " + phase + ".", "type"); + throw new IllegalArgumentException( + "No negotiation generator is registered for type " + type + " and phase " + phase + "."); } LOGGER.atDebug().log( "negotiation_generator_dispatched generator={} type={} phase={}", @@ -101,19 +93,15 @@ public NegotiationGenerator resolve(NegotiationType type, NegotiationPhase phase } private static void requireConclusionMatchesPhase(NegotiationEndingContent content, NegotiationPhase phase) { - NegotiationConclusion conclusion = content.conclusion(); - if (conclusion == null) { - throw new NegotiationContentException( - "Negotiation conclusion must not be null; the " + phase + " phase requires a conclusion.", - "content" + ".conclusion"); - } + NegotiationConclusion conclusion = Objects.requireNonNull( + content.conclusion(), + "Negotiation conclusion must not be null; the " + phase + " phase requires a conclusion."); NegotiationConclusion expected = phase == NegotiationPhase.ACCEPT ? NegotiationConclusion.ACCEPT : NegotiationConclusion.REJECT; if (conclusion != expected) { - throw new NegotiationContentException( + throw new IllegalArgumentException( "The " + phase + " phase requires conclusion " + expected.literal() + " but the content carries " - + conclusion.literal() + ".", - "content.conclusion"); + + conclusion.literal() + "."); } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java index 385ff49a..a3ff9d63 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java @@ -4,7 +4,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import java.util.Objects; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; @@ -33,15 +33,11 @@ public class NegotiationJsonSchemaBuilder { * @param type negotiation type whose content is extracted * @param phase API-level phase of the extraction; accept and reject share the terminal schema * @return JSON Schema describing the snake_case extraction output of the pair - * @throws NegotiationContentException if the type or phase is null + * @throws NullPointerException if the type or phase is null */ public Map buildExtractionSchema(NegotiationType type, NegotiationPhase phase) { - if (type == null) { - throw new NegotiationContentException("Negotiation type must not be null.", "type"); - } - if (phase == null) { - throw new NegotiationContentException("Negotiation phase must not be null.", "phase"); - } + Objects.requireNonNull(type, "Negotiation type must not be null."); + Objects.requireNonNull(phase, "Negotiation phase must not be null."); return switch (type) { case INFORMATION -> phase == NegotiationPhase.PROPOSE ? informationProposeSchema() @@ -62,12 +58,10 @@ public Map buildExtractionSchema(NegotiationType type, Negotiati * * @param callerSchema parameter schema provided by the caller of the validation API * @return merged JSON Schema of the semantic validation LLM call - * @throws NegotiationContentException if the caller schema is null + * @throws NullPointerException if the caller schema is null */ public Map buildSemanticValidationSchema(Map callerSchema) { - if (callerSchema == null) { - throw new NegotiationContentException("Caller parameter schema must not be null.", "schema"); - } + Objects.requireNonNull(callerSchema, "Caller parameter schema must not be null."); Map schema = new LinkedHashMap<>(); schema.put("type", "object"); Map properties = new LinkedHashMap<>(); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java index 5ee99708..535099ff 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java @@ -2,7 +2,7 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; +import java.util.Objects; /** * Assembles the LLM message list of one negotiation LLM step. @@ -42,12 +42,10 @@ public NegotiationMessageBuilder() { * Creates a message builder on one prompt resource loader. * * @param resourceLoader loader supplying the system and user prompts + * @throws NullPointerException if the resource loader is null */ public NegotiationMessageBuilder(NegotiationPromptResourceLoader resourceLoader) { - if (resourceLoader == null) { - throw new NegotiationContentException( - "Negotiation message builder requires a prompt resource loader.", "resourceLoader"); - } + Objects.requireNonNull(resourceLoader, "Negotiation message builder requires a prompt resource loader."); this.resourceLoader = resourceLoader; } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java index 455c9987..fd4dfed5 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java @@ -3,11 +3,10 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; /** * Loads negotiation LLM prompt resources from the classpath bundle packaged with the SDK. @@ -52,13 +51,12 @@ public String loadUser(String promptCategory, String language) { private static String loadPrompt(String fileName, String promptCategory, String language) { if (!PathSegments.isSimpleSegment(promptCategory)) { - throw new NegotiationContentException( - "Prompt category must be a non-blank simple path segment but was " + promptCategory + ".", - "promptCategory"); + throw new IllegalArgumentException( + "Prompt category must be a non-blank simple path segment but was " + promptCategory + "."); } if (!PathSegments.isSimpleSegment(language)) { - throw new NegotiationContentException( - "Prompt language must be a non-blank simple path segment but was " + language + ".", "language"); + throw new IllegalArgumentException( + "Prompt language must be a non-blank simple path segment but was " + language + "."); } String path = CLASSPATH_ROOT + promptCategory + "/" + language + "/" + fileName; return readResource(path); @@ -73,7 +71,7 @@ private static String readResource(String path) { try (stream) { return new String(stream.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException exception) { - throw new SdkException("Failed to read negotiation prompt resource: " + path, exception); + throw new A2ATError("Failed to read negotiation prompt resource: " + path, exception); } } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java index 056eb24e..5092cc71 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -7,11 +7,10 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import net.openan.a2at.sdk.core.model.PromptTemplate; @@ -57,14 +56,13 @@ public final class DefaultNegotiationTemplateLoader implements NegotiationTempla * @param language locale identifier such as {@code zh-CN} or {@code en-US} * @param localRootDir local prompt resource root containing the {@code templates/} tree; null or blank disables * local template overrides - * @throws NegotiationContentException if the language is not a simple path segment + * @throws IllegalArgumentException if the language is not a simple path segment */ public DefaultNegotiationTemplateLoader(String language, String localRootDir) { if (!PathSegments.isSimpleSegment(language)) { - throw new NegotiationContentException( + throw new IllegalArgumentException( "Negotiation template loader language must be a non-blank simple path segment but was " + language - + ".", - "language"); + + "."); } this.language = language; this.localRootDir = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); @@ -128,7 +126,7 @@ private String readTemplate(String relativePath) { try { return Files.readString(localPath, StandardCharsets.UTF_8); } catch (IOException exception) { - throw new SdkException("Failed to read negotiation template: " + localPath, exception); + throw new A2ATError("Failed to read negotiation template: " + localPath, exception); } } } @@ -144,10 +142,10 @@ private String readTemplate(String relativePath) { try (stream) { return new String(stream.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException exception) { - throw new SdkException("Failed to read negotiation template: " + classpathPath, exception); + throw new A2ATError("Failed to read negotiation template: " + classpathPath, exception); } } } - + diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java index 89afa8ee..6ec488d5 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -1,148 +1,128 @@ -package net.openan.a2at.sdk.negotiation.resources; - -import java.util.Locale; -import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.core.validation.TemplateReference; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; -import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; - -/** - * Addressing key for one negotiation template: negotiation type, API-level phase and language. - * - *

The reference composes the template URI from the type and phase segments, so the URI spelling has a single source. - * The language is query context rather than part of the resource identity and is therefore not part of the URI. - * - * @param type negotiation type addressed by the reference - * @param phase API-level phase addressed by the reference; accept and reject share the same template - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @since 2026-06 - */ -public record NegotiationReference(NegotiationType type, NegotiationPhase phase, String language) - implements TemplateReference { - - private static final String URI_PREFIX = "Negotiation-T"; - - private static final String URI_VERSION_SEGMENT = "v1"; - - private static final String TYPE_SEGMENT_SUFFIX = "-negotiation"; - - /** - * Validates the reference fields. - * - * @throws NegotiationContentException if the type or phase is null or the language is not a simple path segment - */ - public NegotiationReference { - if (type == null) { - throw new NegotiationContentException("Negotiation reference type must not be null.", "type"); - } - if (phase == null) { - throw new NegotiationContentException("Negotiation reference phase must not be null.", "phase"); - } - if (!PathSegments.isSimpleSegment(language)) { - throw new NegotiationContentException( - "Negotiation reference language must be a non-blank simple path segment but was " + language + ".", - "language"); - } - } - - /** - * Returns the hyphenated URI segment of the referenced negotiation type. - * - * @return URI segment such as {@code information-negotiation} - */ - public String typeSegment() { - return type.typeSegment(); - } - - /** - * Returns the template URI of the referenced template. - * - * @return template URI such as {@code Negotiation-T/v1/information-negotiation/propose} - */ - public String uri() { - return String.join("/", URI_PREFIX, URI_VERSION_SEGMENT, typeSegment(), phase.uriSegment()); - } - - @Override - public String extensionPrefix() { - return URI_PREFIX; - } - - /** - * Parses a template URI into a reference, checking it against the expected phase. - * - *

The URI layer cannot distinguish accept from reject because both share the {@code accept-reject} segment; the - * expected phase disambiguates the parsed result, which therefore always carries the expected phase. - * - * @param templateUri template URI to parse, such as {@code Negotiation-T/v1/target-negotiation/accept-reject} - * @param expectedPhase API-level phase the caller is operating on; the parsed reference carries this phase - * @param language locale identifier for the parsed reference - * @return reference addressed by the URI, carrying the expected phase - * @throws NegotiationContentException with field {@code templateUri} if the URI is null, blank or malformed - */ - public static NegotiationReference parse(String templateUri, NegotiationPhase expectedPhase, String language) { - if (expectedPhase == null) { - throw new NegotiationContentException("Expected negotiation phase must not be null.", "phase"); - } - if (templateUri == null || templateUri.isBlank()) { - throw new NegotiationContentException("Template URI must not be blank.", "templateUri"); - } - String[] segments = templateUri.strip().split("/"); - if (segments.length != 4) { - throw new NegotiationContentException( - "Template URI must contain exactly 4 slash-separated segments but has " + segments.length + ": " - + templateUri + ".", - "templateUri"); - } - if (!URI_PREFIX.equals(segments[0])) { - throw new NegotiationContentException( - "Template URI first segment must be " + URI_PREFIX + " but was " + segments[0] + ": " + templateUri - + ".", - "templateUri"); - } - if (!URI_VERSION_SEGMENT.equals(segments[1])) { - throw new NegotiationContentException( - "Template URI version segment must be " + URI_VERSION_SEGMENT + " but was " + segments[1] + ": " - + templateUri + ".", - "templateUri"); - } - NegotiationType parsedType = parseTypeSegment(segments[2], templateUri); - parsePhaseSegment(segments[3], expectedPhase, templateUri); - return new NegotiationReference(parsedType, expectedPhase, language); - } - - private static NegotiationType parseTypeSegment(String typeSegment, String templateUri) { - if (!typeSegment.endsWith(TYPE_SEGMENT_SUFFIX)) { - throw new NegotiationContentException( - "Template URI type segment must end with " + TYPE_SEGMENT_SUFFIX + " using hyphens, not" - + " underscores, but was " + typeSegment + ": " + templateUri + ".", - "templateUri"); - } - String typeName = typeSegment.substring(0, typeSegment.length() - TYPE_SEGMENT_SUFFIX.length()); - for (NegotiationType candidate : NegotiationType.values()) { - if (candidate.name().toLowerCase(Locale.ROOT).equals(typeName)) { - return candidate; - } - } - throw new NegotiationContentException( - "Template URI has an unknown negotiation type " + typeName + " in segment " + typeSegment + ": " - + templateUri + ".", - "templateUri"); - } - - private static void parsePhaseSegment(String phaseSegment, NegotiationPhase expectedPhase, String templateUri) { - if (!"propose".equals(phaseSegment) && !"accept-reject".equals(phaseSegment)) { - throw new NegotiationContentException( - "Template URI phase segment must be propose or accept-reject but was " + phaseSegment + ": " - + templateUri + ".", - "templateUri"); - } - if (!phaseSegment.equals(expectedPhase.uriSegment())) { - throw new NegotiationContentException( - "Template URI phase segment " + phaseSegment + " does not match the expected phase " + expectedPhase - + " (" + expectedPhase.uriSegment() + "): " + templateUri + ".", - "templateUri"); - } - } -} +package net.openan.a2at.sdk.negotiation.resources; + +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import net.openan.a2at.sdk.core.resources.PathSegments; +import net.openan.a2at.sdk.core.validation.TemplateReference; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; + +/** + * Addressing key for one negotiation template: negotiation type, API-level phase and language. + * + *

The reference composes the template URI from the type and phase segments, so the URI spelling has a single source. + * The language is query context rather than part of the resource identity and is therefore not part of the URI. + * + * @param type negotiation type addressed by the reference + * @param phase API-level phase addressed by the reference; accept and reject share the same template + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @since 2026-06 + */ +public record NegotiationReference(NegotiationType type, NegotiationPhase phase, String language) + implements TemplateReference { + + private static final String URI_PREFIX = "Negotiation-T"; + + private static final String URI_VERSION_SEGMENT = "v1"; + + private static final String TYPE_SEGMENT_SUFFIX = "-negotiation"; + + /** + * Validates the reference fields. + * + * @throws NullPointerException if the type or phase is null + * @throws IllegalArgumentException if the language is not a simple path segment + */ + public NegotiationReference { + Objects.requireNonNull(type, "Negotiation reference type must not be null."); + Objects.requireNonNull(phase, "Negotiation reference phase must not be null."); + if (!PathSegments.isSimpleSegment(language)) { + throw new IllegalArgumentException( + "Negotiation reference language must be a non-blank simple path segment but was " + language + "."); + } + } + + /** + * Returns the hyphenated URI segment of the referenced negotiation type. + * + * @return URI segment such as {@code information-negotiation} + */ + public String typeSegment() { + return type.typeSegment(); + } + + /** + * Returns the template URI of the referenced template. + * + * @return template URI such as {@code Negotiation-T/v1/information-negotiation/propose} + */ + public String uri() { + return String.join("/", URI_PREFIX, URI_VERSION_SEGMENT, typeSegment(), phase.uriSegment()); + } + + @Override + public String extensionPrefix() { + return URI_PREFIX; + } + + /** + * Tries to parse a template URI into a reference, checking it against the expected phase. + * + *

The URI layer cannot distinguish accept from reject because both share the {@code accept-reject} segment; the + * expected phase disambiguates the parsed result, which therefore always carries the expected phase. + * + * @param templateUri template URI to parse, such as {@code Negotiation-T/v1/target-negotiation/accept-reject} + * @param expectedPhase API-level phase the caller is operating on; the parsed reference carries this phase + * @param language locale identifier for the parsed reference + * @return reference addressed by the URI carrying the expected phase, or an empty result when the URI is null, + * blank or malformed (wrong segment count, prefix, version or type segment) or its phase segment does not match + * the expected phase + * @throws NullPointerException if the expected phase is null + */ + public static Optional tryParse( + String templateUri, NegotiationPhase expectedPhase, String language) { + Objects.requireNonNull(expectedPhase, "Expected negotiation phase must not be null."); + if (templateUri == null || templateUri.isBlank()) { + return Optional.empty(); + } + String[] segments = templateUri.strip().split("/"); + if (segments.length != 4) { + return Optional.empty(); + } + if (!URI_PREFIX.equals(segments[0])) { + return Optional.empty(); + } + if (!URI_VERSION_SEGMENT.equals(segments[1])) { + return Optional.empty(); + } + NegotiationType parsedType = parseTypeSegment(segments[2]); + if (parsedType == null) { + return Optional.empty(); + } + if (!phaseSegmentMatches(segments[3], expectedPhase)) { + return Optional.empty(); + } + return Optional.of(new NegotiationReference(parsedType, expectedPhase, language)); + } + + private static NegotiationType parseTypeSegment(String typeSegment) { + if (!typeSegment.endsWith(TYPE_SEGMENT_SUFFIX)) { + return null; + } + String typeName = typeSegment.substring(0, typeSegment.length() - TYPE_SEGMENT_SUFFIX.length()); + for (NegotiationType candidate : NegotiationType.values()) { + if (candidate.name().toLowerCase(Locale.ROOT).equals(typeName)) { + return candidate; + } + } + return null; + } + + private static boolean phaseSegmentMatches(String phaseSegment, NegotiationPhase expectedPhase) { + if (!"propose".equals(phaseSegment) && !"accept-reject".equals(phaseSegment)) { + return false; + } + return phaseSegment.equals(expectedPhase.uriSegment()); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/store/impl/InMemoryNegotiationStore.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/store/impl/InMemoryNegotiationStore.java index 60e2998a..1f65d3f9 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/store/impl/InMemoryNegotiationStore.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/store/impl/InMemoryNegotiationStore.java @@ -1,9 +1,9 @@ package net.openan.a2at.sdk.negotiation.store.impl; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import net.openan.a2at.sdk.negotiation.store.NegotiationStore; -import net.openan.a2at.sdk.negotiation.types.exception.NegotiationStateException; import net.openan.a2at.sdk.negotiation.types.model.NegotiationRecord; /** @@ -17,11 +17,13 @@ public final class InMemoryNegotiationStore implements NegotiationStore { @Override public void save(NegotiationRecord record) { - if (record == null || record.context() == null || record.context().negotiationId() == null - || record.context().negotiationId().isEmpty()) { - throw new NegotiationStateException("negotiation id is null or empty."); + Objects.requireNonNull(record, "Negotiation record must not be null."); + Objects.requireNonNull(record.context(), "Negotiation record context must not be null."); + String negotiationId = record.context().negotiationId(); + if (negotiationId == null || negotiationId.isEmpty()) { + throw new IllegalArgumentException("negotiation id is null or empty."); } - records.put(record.context().negotiationId(), record); + records.put(negotiationId, record); } @Override diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/types/exception/NegotiationStateException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/types/exception/NegotiationStateException.java index efb09ad6..5c489e45 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/types/exception/NegotiationStateException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/types/exception/NegotiationStateException.java @@ -1,14 +1,21 @@ package net.openan.a2at.sdk.negotiation.types.exception; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; /** * Raised when incoming negotiation state skips or contradicts local progress. * + *

Carries the default error code {@code sdk_internal_error} from the {@link A2ATError} root. + * * @since 2026-06 */ -public final class NegotiationStateException extends SdkException { +public final class NegotiationStateException extends A2ATError { + /** + * Creates a negotiation state failure with one message. + * + * @param message failure message + */ public NegotiationStateException(String message) { super(message); } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java index b5651074..e8cddaec 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.regex.Pattern; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -52,9 +53,7 @@ public DefaultNegotiationComplianceChecker() {} */ @Override public NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary) { - if (vocabulary == null) { - throw new NullPointerException("vocabulary"); - } + Objects.requireNonNull(vocabulary, "vocabulary"); List contextBody = contextSectionBody(prompt, vocabulary.get("section.context")); if (contextBody == null) { return logResult(new NegotiationRuleCheckResult(false, false, List.of(), null)); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java index 24dc66de..c63746d8 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java @@ -12,8 +12,8 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.llm.LLMClient; @@ -142,7 +142,7 @@ private static String loadPromptResource(String fileName, String language) { try (stream) { return new String(stream.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException exception) { - throw new SdkException( + throw new A2ATError( "Failed to read negotiation semantic validation prompt: " + classpathPath, exception); } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java index ef618ccc..17ad7486 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java @@ -55,7 +55,7 @@ public FilledParamData extract(String prompt, Map schema, Templa try { return pipeline.validate(prompt, schema, reference); } catch (ContentValidationException e) { - throw new NegotiationParamExtractionException(mapCode(e.code()), e.getMessage(), e.errors()); + throw new NegotiationParamExtractionException(mapCode(e.getCode()), e.getMessage(), e.errors()); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationContextTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationContextTest.java index 106c6e5e..fcecff29 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationContextTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationContextTest.java @@ -23,14 +23,13 @@ void constructionAcceptsLegalContext() { @Test void blankIdThrowsWithDistinguishableMessage() { - NegotiationContentException blankException = - assertThrows(NegotiationContentException.class, () -> new NegotiationContext("", 1, 5)); - NegotiationContentException whitespaceException = - assertThrows(NegotiationContentException.class, () -> new NegotiationContext(" ", 1, 5)); - NegotiationContentException nullException = - assertThrows(NegotiationContentException.class, () -> new NegotiationContext(null, 1, 5)); - - assertEquals("context.id", blankException.getField()); + IllegalArgumentException blankException = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext("", 1, 5)); + IllegalArgumentException whitespaceException = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(" ", 1, 5)); + IllegalArgumentException nullException = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(null, 1, 5)); + assertTrue(blankException.getMessage().contains("id")); assertTrue(blankException.getMessage().contains("blank")); assertEquals(blankException.getMessage(), whitespaceException.getMessage()); @@ -39,30 +38,27 @@ void blankIdThrowsWithDistinguishableMessage() { @Test void zeroRoundThrowsWithDistinguishableMessage() { - NegotiationContentException exception = - assertThrows(NegotiationContentException.class, () -> new NegotiationContext(SESSION_ID, 0, 5)); + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(SESSION_ID, 0, 5)); - assertEquals("context.round", exception.getField()); assertTrue(exception.getMessage().contains("round")); assertTrue(exception.getMessage().contains("0")); } @Test void negativeRoundThrowsWithDistinguishableMessage() { - NegotiationContentException exception = - assertThrows(NegotiationContentException.class, () -> new NegotiationContext(SESSION_ID, -1, 5)); + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(SESSION_ID, -1, 5)); - assertEquals("context.round", exception.getField()); assertTrue(exception.getMessage().contains("round")); assertTrue(exception.getMessage().contains("-1")); } @Test void zeroMaxRoundsThrowsWithDistinguishableMessage() { - NegotiationContentException exception = - assertThrows(NegotiationContentException.class, () -> new NegotiationContext(SESSION_ID, 1, 0)); + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(SESSION_ID, 1, 0)); - assertEquals("context.maxRounds", exception.getField()); assertTrue(exception.getMessage().contains("maxRounds")); assertTrue(exception.getMessage().contains("0")); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java index 4a2cbc64..0c6b4aef 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java @@ -14,26 +14,30 @@ class NegotiationExceptionHierarchyTest { private static final Class[] NEGOTIATION_EXCEPTION_TYPES = { - NegotiationContentException.class, NegotiationProcessingException.class, NegotiationGenerationException.class, NegotiationParamExtractionException.class }; @Test - void contentExceptionIsNotAProcessingError() { - NegotiationContentException exception = new NegotiationContentException("invalid content", "templateUri"); + void processingExceptionIsAProcessingErrorWithCode() { + NegotiationProcessingException exception = + new NegotiationProcessingException(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, "invalid input"); - assertFalse(A2ATError.class.isInstance(exception)); - assertEquals("templateUri", exception.getField()); + assertTrue(exception instanceof A2ATError); + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, exception.getCode()); } @Test - void processingExceptionIsAProcessingErrorWithCode() { + void processingExceptionInheritsGetCodeFromTheA2ATErrorRoot() { NegotiationProcessingException exception = new NegotiationProcessingException(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, "invalid input"); - assertTrue(exception instanceof A2ATError); + assertEquals( + A2ATError.class, + findGetMethod(NegotiationProcessingException.class).getDeclaringClass(), + "the code accessor must come from the A2ATError root so every negotiation failure shares one code" + + " contract"); assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, exception.getCode()); } @@ -69,4 +73,12 @@ void noNegotiationExceptionExposesAStageProperty() { assertFalse(exposesStage, exceptionType.getSimpleName() + " must not expose a stage property"); } } + + private static java.lang.reflect.Method findGetMethod(Class exceptionType) { + try { + return exceptionType.getMethod("getCode"); + } catch (NoSuchMethodException exception) { + throw new AssertionError("getCode must be inherited from A2ATError", exception); + } + } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java index 72b2fea9..3520e250 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java @@ -112,8 +112,8 @@ void listColonPunctuationDiffersPerLanguage() { @Test void unsupportedLanguageThrows() { - NegotiationContentException exception = - assertThrows(NegotiationContentException.class, () -> Vocabulary.forLanguage("fr-FR")); + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("fr-FR")); assertTrue(exception.getMessage().contains("fr-FR")); assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); @@ -123,8 +123,8 @@ void unsupportedLanguageThrows() { void unknownKeyThrows() { Vocabulary vocabulary = Vocabulary.forLanguage("zh-CN"); - NegotiationContentException exception = - assertThrows(NegotiationContentException.class, () -> vocabulary.get("section.unknown")); + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> vocabulary.get("section.unknown")); assertTrue(exception.getMessage().contains("section.unknown")); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java index 62f33976..e186b0a2 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java @@ -66,6 +66,7 @@ void everyPackagedTemplateIsByteIdenticalToTheDeliveredFile() throws IOException @Test void errorCodeConstantsStayExactlyThePinnedRegistry() { java.util.Map expected = new java.util.LinkedHashMap<>(); + expected.put("SDK_INTERNAL_ERROR", "sdk_internal_error"); expected.put("PARAM_EXTRACTION_FAILED", "param_extraction_failed"); expected.put("TEMPLATE_NOT_FOUND", "template_not_found"); expected.put("NEGOTIATION_CONTENT_EXTRACT_FAILED", "negotiation_content_extract_failed"); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java index 69f32fc9..29f7d8ae 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -30,7 +30,6 @@ void contentPackageExposesExactlyThePinnedTypes() throws IOException { "NegotiationAction.java", "NegotiationConclusion.java", "NegotiationContent.java", - "NegotiationContentException.java", "NegotiationContentService.java", "NegotiationContext.java", "NegotiationEndingContent.java", diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java index 6cc9257b..87f1c1b3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java @@ -1,278 +1,305 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import java.util.function.Supplier; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import org.junit.jupiter.api.Test; - -/** - * Verifies the programming-error matrix of the from-data generation as one table-driven suite. - * - *

Every row of the input-validation matrix of the from-data variants — method-data mismatch, malformed or - * mismatching template URIs, phase-conclusion mismatch, missing required fields and empty conditional content — fails - * with a {@link NegotiationContentException} that is not part of the SDK processing-error hierarchy, carries an English - * message and a non-null field path pointing at the offending input. No row ever reaches the LLM. - */ -class FromDataProgrammingErrorMatrixTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; - - private static final String INFORMATION_ACCEPT_URI = "Negotiation-T/v1/information-negotiation/accept-reject"; - - private static final String TARGET_PROPOSE_URI = "Negotiation-T/v1/target-negotiation/propose"; - - private static final String FEASIBILITY_PROPOSE_URI = "Negotiation-T/v1/feasibility-negotiation/propose"; - - private static final String FEASIBILITY_ACCEPT_URI = "Negotiation-T/v1/feasibility-negotiation/accept-reject"; - - private static final String TARGET_ACCEPT_URI = "Negotiation-T/v1/target-negotiation/accept-reject"; - - private final CountingClient llm = new CountingClient(); - - private final NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - - @Test - void everyMatrixRowFailsWithAFieldCarryingContentException() { - List matrix = matrix(); - - // The propose-versus-ending family mismatch of UT-GEN-002 is deliberately absent here: the typed data records - // make that mismatch a compile-time error at the facade, so it cannot occur at runtime. - assertEquals(24, matrix.size(), "the full matrix must be exercised"); - for (MatrixRow row : matrix) { - NegotiationContentException failure = assertThrows( - NegotiationContentException.class, () -> row.call().get(), "row must fail: " + row.label()); - assertFalse( - A2ATError.class.isInstance(failure), - "a programming error must not be part of the processing-error hierarchy: " + row.label()); - assertTrue( - failure.getMessage() != null && !failure.getMessage().isBlank(), - "failure message must be present: " + row.label()); - assertTrue( - isAsciiText(failure.getMessage()), - "failure message must be English (ASCII): " + failure.getMessage()); - assertEquals( - row.expectedField(), failure.getField(), "field must point at the problem of row: " + row.label()); - } - assertEquals(0, llm.calls, "no matrix row may call the LLM"); - } - - /** Returns the full programming-error matrix of the from-data variants. */ - private List matrix() { - NegotiationContext context = new NegotiationContext(UUID, 2, 5); - return List.of( - new MatrixRow( - "null propose data", - () -> orchestrator.generateProposeFromData(null, INFORMATION_PROPOSE_URI), - "data"), - new MatrixRow( - "null ending data", - () -> orchestrator.generateAcceptFromData(null, INFORMATION_ACCEPT_URI), - "data"), - new MatrixRow( - "null context", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(null, informationProposeContent()), INFORMATION_PROPOSE_URI), - "context"), - new MatrixRow( - "accept method with reject conclusion", - () -> orchestrator.generateAcceptFromData( - ending(NegotiationConclusion.REJECT), INFORMATION_ACCEPT_URI), - "content.conclusion"), - new MatrixRow( - "reject method with accept conclusion", - () -> orchestrator.generateRejectFromData( - ending(NegotiationConclusion.ACCEPT), INFORMATION_ACCEPT_URI), - "content.conclusion"), - new MatrixRow( - "accept method with abort conclusion", - () -> orchestrator.generateAcceptFromData( - ending(NegotiationConclusion.ABORT), INFORMATION_ACCEPT_URI), - "content.conclusion"), - new MatrixRow( - "accept method with null conclusion", - () -> orchestrator.generateAcceptFromData( - new NegotiationEndingData(context, new TargetEndingContent(null, "intent", null)), - TARGET_ACCEPT_URI), - "content.conclusion"), - new MatrixRow( - "target accept without confirmed intent", - () -> orchestrator.generateAcceptFromData( - new NegotiationEndingData( - context, new TargetEndingContent(NegotiationConclusion.ACCEPT, null, null)), - TARGET_ACCEPT_URI), - "content.confirmedIntent"), - new MatrixRow( - "target accept with blank confirmed intent", - () -> orchestrator.generateAcceptFromData( - new NegotiationEndingData( - context, new TargetEndingContent(NegotiationConclusion.ACCEPT, " ", null)), - TARGET_ACCEPT_URI), - "content.confirmedIntent"), - new MatrixRow( - "target reject without failure reason", - () -> orchestrator.generateRejectFromData( - new NegotiationEndingData( - context, new TargetEndingContent(NegotiationConclusion.REJECT, null, null)), - TARGET_ACCEPT_URI), - "content.failureReason"), - new MatrixRow( - "target propose with blank description", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, new TargetProposeContent(" ", null, null, null)), - TARGET_PROPOSE_URI), - "content.targetNegotiationDescription"), - new MatrixRow( - "feasibility propose with blank description", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData( - context, - new FeasibilityProposeContent( - " ", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("目标", "2Mbps")), - null)), - FEASIBILITY_PROPOSE_URI), - "content.feasibilityNegotiationDescription"), - new MatrixRow( - "feasibility propose without action", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData( - context, - new FeasibilityProposeContent( - "请评估。", null, List.of(new NegotiationItem("目标", "2Mbps")), null)), - FEASIBILITY_PROPOSE_URI), - "content.action"), - new MatrixRow( - "evaluation request without contents to evaluate", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData( - context, - new FeasibilityProposeContent( - "请评估。", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null)), - FEASIBILITY_PROPOSE_URI), - "content.contentsToEvaluate"), - new MatrixRow( - "alternative proposal without details", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData( - context, - new FeasibilityProposeContent( - "不可行。", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, null)), - FEASIBILITY_PROPOSE_URI), - "content.infeasibilityDetailsAndProposal"), - new MatrixRow( - "feasibility ending with blank summary", - () -> orchestrator.generateAcceptFromData( - new NegotiationEndingData( - context, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " ")), - FEASIBILITY_ACCEPT_URI), - "content.feasibilitySummary"), - new MatrixRow( - "template URI with too few segments", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - "information-negotiation/propose"), - "templateUri"), - new MatrixRow( - "template URI with too many segments", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - "Negotiation-T/v1/information-negotiation/propose/extra"), - "templateUri"), - new MatrixRow( - "template URI with wrong prefix", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - "Task-T/v1/information-negotiation/propose"), - "templateUri"), - new MatrixRow( - "template URI with wrong version", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - "Negotiation-T/v2/information-negotiation/propose"), - "templateUri"), - new MatrixRow( - "template URI with underscore type segment", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - "Negotiation-T/v1/information_negotiation/propose"), - "templateUri"), - new MatrixRow( - "template URI with unknown type", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - "Negotiation-T/v1/unknown-negotiation/propose"), - "templateUri"), - new MatrixRow( - "template URI phase contradicts the method", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - INFORMATION_ACCEPT_URI), - "templateUri"), - new MatrixRow( - "template URI type contradicts the content type", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData( - context, new TargetProposeContent("目标协商概述。", null, null, null)), - INFORMATION_PROPOSE_URI), - "content")); - } - - private static InfoProposeContent informationProposeContent() { - return new InfoProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null); - } - - private static NegotiationEndingData ending(NegotiationConclusion conclusion) { - return new NegotiationEndingData( - new NegotiationContext(UUID, 2, 5), - new InfoEndingContent(conclusion, List.of(new NegotiationItem("区域", "松山湖")))); - } - - private static boolean isAsciiText(String message) { - return message.chars().allMatch(codePoint -> codePoint < 128); - } - - /** One row of the programming-error matrix: a failing call and the expected field path. */ - private record MatrixRow(String label, Supplier call, String expectedField) {} - - private static final class CountingClient implements LLMClient { - - private int calls; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - throw new AssertionError("A from-data programming error must fail before any LLM call"); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; +import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.junit.jupiter.api.Test; + +/** + * Verifies the programming-error matrix of the from-data generation as one table-driven suite. + * + *

Every row of the input-validation matrix of the from-data variants — method-data mismatch, malformed or + * mismatching template URIs, phase-conclusion mismatch, missing required fields and empty conditional content — fails + * with a standard {@link NullPointerException} (pure null arguments) or {@link IllegalArgumentException} (blank, + * malformed, range and semantic-contract violations) that is not part of the SDK processing-error hierarchy and + * carries an English message pointing at the offending input. No row ever reaches the LLM. + */ +class FromDataProgrammingErrorMatrixTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + + private static final String INFORMATION_ACCEPT_URI = "Negotiation-T/v1/information-negotiation/accept-reject"; + + private static final String TARGET_PROPOSE_URI = "Negotiation-T/v1/target-negotiation/propose"; + + private static final String FEASIBILITY_PROPOSE_URI = "Negotiation-T/v1/feasibility-negotiation/propose"; + + private static final String FEASIBILITY_ACCEPT_URI = "Negotiation-T/v1/feasibility-negotiation/accept-reject"; + + private static final String TARGET_ACCEPT_URI = "Negotiation-T/v1/target-negotiation/accept-reject"; + + private final CountingClient llm = new CountingClient(); + + private final NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + @Test + void everyMatrixRowFailsWithAStandardJavaException() { + List matrix = matrix(); + + // The propose-versus-ending family mismatch of UT-GEN-002 is deliberately absent here: the typed data records + // make that mismatch a compile-time error at the facade, so it cannot occur at runtime. + assertEquals(24, matrix.size(), "the full matrix must be exercised"); + for (MatrixRow row : matrix) { + RuntimeException failure = assertThrows( + row.expectedException(), () -> row.call().get(), "row must fail: " + row.label()); + assertFalse( + A2ATError.class.isInstance(failure), + "a programming error must not be part of the processing-error hierarchy: " + row.label()); + assertTrue( + failure.getMessage() != null && !failure.getMessage().isBlank(), + "failure message must be present: " + row.label()); + assertTrue( + isAsciiText(failure.getMessage()), + "failure message must be English (ASCII): " + failure.getMessage()); + assertTrue( + failure.getMessage().contains(row.expectedMessageFragment()), + "failure message must point at the problem of row " + row.label() + " but was: " + + failure.getMessage()); + } + assertEquals(0, llm.calls, "no matrix row may call the LLM"); + } + + /** Returns the full programming-error matrix of the from-data variants. */ + private List matrix() { + NegotiationContext context = new NegotiationContext(UUID, 2, 5); + return List.of( + new MatrixRow( + "null propose data", + () -> orchestrator.generateProposeFromData(null, INFORMATION_PROPOSE_URI), + NullPointerException.class, + "Negotiation propose data must not be null."), + new MatrixRow( + "null ending data", + () -> orchestrator.generateAcceptFromData(null, INFORMATION_ACCEPT_URI), + NullPointerException.class, + "Negotiation ending data must not be null."), + new MatrixRow( + "null context", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData(null, informationProposeContent()), INFORMATION_PROPOSE_URI), + NullPointerException.class, + "Negotiation context must not be null."), + new MatrixRow( + "accept method with reject conclusion", + () -> orchestrator.generateAcceptFromData( + ending(NegotiationConclusion.REJECT), INFORMATION_ACCEPT_URI), + IllegalArgumentException.class, + "ACCEPT phase requires conclusion Accept but the content carries Reject"), + new MatrixRow( + "reject method with accept conclusion", + () -> orchestrator.generateRejectFromData( + ending(NegotiationConclusion.ACCEPT), INFORMATION_ACCEPT_URI), + IllegalArgumentException.class, + "REJECT phase requires conclusion Reject but the content carries Accept"), + new MatrixRow( + "accept method with abort conclusion", + () -> orchestrator.generateAcceptFromData( + ending(NegotiationConclusion.ABORT), INFORMATION_ACCEPT_URI), + IllegalArgumentException.class, + "the content carries Abort"), + new MatrixRow( + "accept method with null conclusion", + () -> orchestrator.generateAcceptFromData( + new NegotiationEndingData(context, new TargetEndingContent(null, "intent", null)), + TARGET_ACCEPT_URI), + NullPointerException.class, + "conclusion must not be null"), + new MatrixRow( + "target accept without confirmed intent", + () -> orchestrator.generateAcceptFromData( + new NegotiationEndingData( + context, new TargetEndingContent(NegotiationConclusion.ACCEPT, null, null)), + TARGET_ACCEPT_URI), + IllegalArgumentException.class, + "Confirmed intent of an accepting target negotiation message must not be blank."), + new MatrixRow( + "target accept with blank confirmed intent", + () -> orchestrator.generateAcceptFromData( + new NegotiationEndingData( + context, new TargetEndingContent(NegotiationConclusion.ACCEPT, " ", null)), + TARGET_ACCEPT_URI), + IllegalArgumentException.class, + "Confirmed intent of an accepting target negotiation message must not be blank."), + new MatrixRow( + "target reject without failure reason", + () -> orchestrator.generateRejectFromData( + new NegotiationEndingData( + context, new TargetEndingContent(NegotiationConclusion.REJECT, null, null)), + TARGET_ACCEPT_URI), + IllegalArgumentException.class, + "Failure reason of a rejecting target negotiation message must not be blank."), + new MatrixRow( + "target propose with blank description", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData(context, new TargetProposeContent(" ", null, null, null)), + TARGET_PROPOSE_URI), + IllegalArgumentException.class, + "Target negotiation description must not be blank."), + new MatrixRow( + "feasibility propose with blank description", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, + new FeasibilityProposeContent( + " ", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("目标", "2Mbps")), + null)), + FEASIBILITY_PROPOSE_URI), + IllegalArgumentException.class, + "Feasibility negotiation description must not be blank."), + new MatrixRow( + "feasibility propose without action", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, + new FeasibilityProposeContent( + "请评估。", null, List.of(new NegotiationItem("目标", "2Mbps")), null)), + FEASIBILITY_PROPOSE_URI), + NullPointerException.class, + "Feasibility negotiation action must not be null"), + new MatrixRow( + "evaluation request without contents to evaluate", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, + new FeasibilityProposeContent( + "请评估。", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null)), + FEASIBILITY_PROPOSE_URI), + IllegalArgumentException.class, + "must contain at least one item"), + new MatrixRow( + "alternative proposal without details", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, + new FeasibilityProposeContent( + "不可行。", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, null)), + FEASIBILITY_PROPOSE_URI), + IllegalArgumentException.class, + "must contain at least one item"), + new MatrixRow( + "feasibility ending with blank summary", + () -> orchestrator.generateAcceptFromData( + new NegotiationEndingData( + context, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " ")), + FEASIBILITY_ACCEPT_URI), + IllegalArgumentException.class, + "must not be blank"), + new MatrixRow( + "template URI with too few segments", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData(context, informationProposeContent()), + "information-negotiation/propose"), + IllegalArgumentException.class, + "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), + new MatrixRow( + "template URI with too many segments", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData(context, informationProposeContent()), + "Negotiation-T/v1/information-negotiation/propose/extra"), + IllegalArgumentException.class, + "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), + new MatrixRow( + "template URI with wrong prefix", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData(context, informationProposeContent()), + "Task-T/v1/information-negotiation/propose"), + IllegalArgumentException.class, + "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), + new MatrixRow( + "template URI with wrong version", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData(context, informationProposeContent()), + "Negotiation-T/v2/information-negotiation/propose"), + IllegalArgumentException.class, + "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), + new MatrixRow( + "template URI with underscore type segment", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData(context, informationProposeContent()), + "Negotiation-T/v1/information_negotiation/propose"), + IllegalArgumentException.class, + "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), + new MatrixRow( + "template URI with unknown type", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData(context, informationProposeContent()), + "Negotiation-T/v1/unknown-negotiation/propose"), + IllegalArgumentException.class, + "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), + new MatrixRow( + "template URI phase contradicts the method", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData(context, informationProposeContent()), + INFORMATION_ACCEPT_URI), + IllegalArgumentException.class, + "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), + new MatrixRow( + "template URI type contradicts the content type", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, new TargetProposeContent("目标协商概述。", null, null, null)), + INFORMATION_PROPOSE_URI), + IllegalArgumentException.class, + "Negotiation type INFORMATION requires content of type InfoProposeContent")); + } + + private static InfoProposeContent informationProposeContent() { + return new InfoProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null); + } + + private static NegotiationEndingData ending(NegotiationConclusion conclusion) { + return new NegotiationEndingData( + new NegotiationContext(UUID, 2, 5), + new InfoEndingContent(conclusion, List.of(new NegotiationItem("区域", "松山湖")))); + } + + private static boolean isAsciiText(String message) { + return message.chars().allMatch(codePoint -> codePoint < 128); + } + + /** One row of the programming-error matrix: a failing call, the expected exception type and message fragment. */ + private record MatrixRow( + String label, Supplier call, Class expectedException, String expectedMessageFragment) {} + + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new AssertionError("A from-data programming error must fail before any LLM call"); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 15b23393..4a0cb77d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -30,7 +30,6 @@ import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; @@ -361,8 +360,8 @@ void missingTemplateFailsBeforeAnyLlmCall() { } /** - * IT-B-005 (4): a template URI whose phase segment contradicts the method is a programming error with the - * templateUri problem field and never reaches the LLM. + * IT-B-005 (4): a template URI whose phase segment contradicts the method is a programming error with an + * {@link IllegalArgumentException} pointing at the template URI and never reaches the LLM. */ @Test void phaseMismatchedTemplateUriFailsBeforeAnyLlmCall() { @@ -370,14 +369,16 @@ void phaseMismatchedTemplateUriFailsBeforeAnyLlmCall() { extractionJson(GoldenCase.INFORMATION_PROPOSE.content(), NegotiationPhase.PROPOSE)); NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - NegotiationContentException failure = assertThrows( - NegotiationContentException.class, + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, () -> orchestrator.generateProposeFromText( "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), GoldenCase.INFORMATION_ACCEPT.templateUri())); - assertEquals("templateUri", failure.getField()); + assertTrue( + failure.getMessage().contains("Template URI is malformed or contradicts the expected phase PROPOSE"), + "the failure must point at the template URI but was: " + failure.getMessage()); assertEquals(0, llm.calls); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java index a02b28a0..fe3e0308 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java @@ -262,11 +262,9 @@ void rejectsBlankInputTextAndNullReference() { assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, blank.getCode()); assertEquals( - "reference", - assertThrows( - net.openan.a2at.sdk.negotiation.content.NegotiationContentException.class, - () -> extractor.extract("文本", null)) - .getField()); + "Negotiation reference must not be null.", + assertThrows(NullPointerException.class, () -> extractor.extract("文本", null)) + .getMessage()); } private static NegotiationReference reference(NegotiationType type, NegotiationPhase phase, String language) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java index c8f8c079..cc795a2d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java @@ -3,7 +3,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import org.junit.jupiter.api.Test; @@ -29,9 +28,8 @@ void rendersContextBuiltByFactoryAndNextRound() { @Test void rejectsNullContext() { - NegotiationContentException exception = - assertThrows(NegotiationContentException.class, () -> renderer.render(null)); + NullPointerException exception = assertThrows(NullPointerException.class, () -> renderer.render(null)); - assertEquals("context", exception.getField()); + assertEquals("Negotiation context must not be null.", exception.getMessage()); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 353b44a6..65825602 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -14,7 +14,6 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; @@ -225,24 +224,28 @@ void rejectsNonNegotiationPromptInParameterExtraction() { } @Test - void rejectsMalformedTemplateUriAsAContentError() { - NegotiationContentException malformedUriFailure = - assertThrows(NegotiationContentException.class, () -> zhOrchestrator() + void rejectsMalformedTemplateUriAsAnIllegalArgument() { + IllegalArgumentException malformedUriFailure = + assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() .generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), "information-negotiation/propose")); - assertEquals("templateUri", malformedUriFailure.getField()); + assertTrue(malformedUriFailure + .getMessage() + .contains("Template URI is malformed or contradicts the expected phase PROPOSE (propose)")); - NegotiationContentException phaseMismatchFailure = - assertThrows(NegotiationContentException.class, () -> zhOrchestrator() + IllegalArgumentException phaseMismatchFailure = + assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() .generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), "Negotiation-T/v1/information-negotiation/accept-reject")); - assertEquals("templateUri", phaseMismatchFailure.getField()); + assertTrue(phaseMismatchFailure + .getMessage() + .contains("Template URI is malformed or contradicts the expected phase PROPOSE (propose)")); } private static NegotiationGenerationOrchestrator zhOrchestrator() { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java index 3bca84c5..19d2c67e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java @@ -13,7 +13,6 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; @@ -89,23 +88,21 @@ void dispatchesEveryTypeAndPhaseCombination() { @Test void rejectsProposeContentInTerminalPhaseAndEndingContentInProposePhase() { - NegotiationContentException proposeInEnding = assertThrows( - NegotiationContentException.class, + IllegalArgumentException proposeInEnding = assertThrows( + IllegalArgumentException.class, () -> registry.resolve( NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, new InfoProposeContent(List.of(), null))); - assertEquals("content", proposeInEnding.getField()); assertTrue(proposeInEnding .getMessage() .contains("ACCEPT phase requires ending content but received propose content of type" + " InfoProposeContent")); - NegotiationContentException endingInPropose = assertThrows( - NegotiationContentException.class, + IllegalArgumentException endingInPropose = assertThrows( + IllegalArgumentException.class, () -> registry.resolve( NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, new InfoEndingContent(NegotiationConclusion.ACCEPT, List.of()))); - assertEquals("content", endingInPropose.getField()); assertTrue(endingInPropose .getMessage() .contains("PROPOSE phase requires propose content but received ending content of type" @@ -114,21 +111,20 @@ void rejectsProposeContentInTerminalPhaseAndEndingContentInProposePhase() { @Test void rejectsContentRuntimeTypeNotMatchingTheNegotiationType() { - NegotiationContentException exception = assertThrows( - NegotiationContentException.class, + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> registry.resolve( NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, new TargetProposeContent("描述", null, null, null))); - assertEquals("content", exception.getField()); assertTrue(exception .getMessage() .contains("Negotiation type INFORMATION requires content of type InfoProposeContent but received" + " TargetProposeContent")); assertThrows( - NegotiationContentException.class, + IllegalArgumentException.class, () -> registry.resolve( NegotiationType.TARGET, NegotiationPhase.ACCEPT, @@ -137,20 +133,19 @@ void rejectsContentRuntimeTypeNotMatchingTheNegotiationType() { @Test void rejectsEndingConclusionNotMatchingThePhase() { - NegotiationContentException exception = assertThrows( - NegotiationContentException.class, + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> registry.resolve( NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, new InfoEndingContent(NegotiationConclusion.REJECT, List.of()))); - assertEquals("content.conclusion", exception.getField()); assertTrue(exception .getMessage() .contains("ACCEPT phase requires conclusion Accept but the content carries Reject")); assertThrows( - NegotiationContentException.class, + IllegalArgumentException.class, () -> registry.resolve( NegotiationType.TARGET, NegotiationPhase.REJECT, @@ -159,12 +154,12 @@ void rejectsEndingConclusionNotMatchingThePhase() { @Test void rejectsEndingContentWithoutConclusion() { - NegotiationContentException exception = assertThrows( - NegotiationContentException.class, + NullPointerException exception = assertThrows( + NullPointerException.class, () -> registry.resolve( NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, new InfoEndingContent(null, List.of()))); - assertEquals("content.conclusion", exception.getField()); + assertTrue(exception.getMessage().contains("conclusion must not be null")); } @Test @@ -172,22 +167,22 @@ void rejectsNullArguments() { NegotiationContent content = new InfoProposeContent(List.of(), null); assertEquals( - "type", + "Negotiation type must not be null.", assertThrows( - NegotiationContentException.class, + NullPointerException.class, () -> registry.resolve(null, NegotiationPhase.PROPOSE, content)) - .getField()); + .getMessage()); assertEquals( - "phase", + "Negotiation phase must not be null.", assertThrows( - NegotiationContentException.class, + NullPointerException.class, () -> registry.resolve(NegotiationType.INFORMATION, null, content)) - .getField()); + .getMessage()); assertEquals( - "content", + "Negotiation content must not be null.", assertThrows( - NegotiationContentException.class, + NullPointerException.class, () -> registry.resolve(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, null)) - .getField()); + .getMessage()); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java index 72bb3d90..ae9441c3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -12,7 +12,6 @@ import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; @@ -202,8 +201,8 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { FeasibilityProposeContent nullAction = new FeasibilityProposeContent("描述", null, List.of(new NegotiationItem("名称", "值")), null); - NegotiationContentException nullActionError = - assertThrows(NegotiationContentException.class, () -> new FeasibilityProposeGenerator() + NullPointerException nullActionError = + assertThrows(NullPointerException.class, () -> new FeasibilityProposeGenerator() .generate( context(1), nullAction, @@ -211,13 +210,13 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { "Negotiation-T/v1/feasibility-negotiation/propose", ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)); - assertEquals("content.action", nullActionError.getField()); + assertTrue(nullActionError.getMessage().contains("action must not be null")); FeasibilityProposeContent emptyEvaluation = new FeasibilityProposeContent("描述", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null); - NegotiationContentException emptyEvaluationError = - assertThrows(NegotiationContentException.class, () -> new FeasibilityProposeGenerator() + IllegalArgumentException emptyEvaluationError = + assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() .generate( context(1), emptyEvaluation, @@ -225,13 +224,15 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { "Negotiation-T/v1/feasibility-negotiation/propose", ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)); - assertEquals("content.contentsToEvaluate", emptyEvaluationError.getField()); + assertEquals( + "Contents to evaluate of a feasibility evaluation request must contain at least one item.", + emptyEvaluationError.getMessage()); FeasibilityProposeContent emptyAlternative = new FeasibilityProposeContent("描述", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, List.of()); - NegotiationContentException emptyAlternativeError = - assertThrows(NegotiationContentException.class, () -> new FeasibilityProposeGenerator() + IllegalArgumentException emptyAlternativeError = + assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() .generate( context(1), emptyAlternative, @@ -239,14 +240,16 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { "Negotiation-T/v1/feasibility-negotiation/propose", ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)); - assertEquals("content.infeasibilityDetailsAndProposal", emptyAlternativeError.getField()); + assertEquals( + "Infeasibility details and proposal of an alternative proposal must contain at least one item.", + emptyAlternativeError.getMessage()); FeasibilityProposeContent blankDescription = new FeasibilityProposeContent( " ", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, List.of(new NegotiationItem("名称", "值")), null); assertEquals( - "content.feasibilityNegotiationDescription", - assertThrows(NegotiationContentException.class, () -> new FeasibilityProposeGenerator() + "Feasibility negotiation description must not be blank.", + assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() .generate( context(1), blankDescription, @@ -254,7 +257,7 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { "Negotiation-T/v1/feasibility-negotiation/propose", ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)) - .getField()); + .getMessage()); } @Test @@ -302,15 +305,15 @@ void targetLaterRoundRendersAlignmentSectionAndDropsIntentSection() { void targetBlankDescriptionIsRejected() { TargetProposeContent content = new TargetProposeContent(" ", null, null, null); - NegotiationContentException exception = - assertThrows(NegotiationContentException.class, () -> new TargetProposeGenerator() + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new TargetProposeGenerator() .generate( context(1), content, template("Negotiation-T/v1/target-negotiation/propose", ZH_TARGET_PROPOSE_TEMPLATE), zhVocabulary)); - assertEquals("content.targetNegotiationDescription", exception.getField()); + assertEquals("Target negotiation description must not be blank.", exception.getMessage()); } @Test @@ -405,19 +408,19 @@ void endingGeneratorsRenderConclusionLiterals() { @Test void abortConclusionIsRejectedByEveryEndingGenerator() { - assertThrows(NegotiationContentException.class, () -> new InformationEndingGenerator() + assertThrows(IllegalArgumentException.class, () -> new InformationEndingGenerator() .generate( context(1), new InfoEndingContent(NegotiationConclusion.ABORT, List.of()), template("Negotiation-T/v1/information-negotiation/accept-reject", ZH_INFO_ENDING_TEMPLATE), zhVocabulary)); - assertThrows(NegotiationContentException.class, () -> new TargetEndingGenerator() + assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() .generate( context(1), new TargetEndingContent(NegotiationConclusion.ABORT, "意图", null), template("Negotiation-T/v1/target-negotiation/accept-reject", ZH_TARGET_ENDING_TEMPLATE), zhVocabulary)); - assertThrows(NegotiationContentException.class, () -> new FeasibilityEndingGenerator() + assertThrows(IllegalArgumentException.class, () -> new FeasibilityEndingGenerator() .generate( context(1), new FeasibilityEndingContent(NegotiationConclusion.ABORT, "结论"), @@ -429,8 +432,8 @@ void abortConclusionIsRejectedByEveryEndingGenerator() { @Test void nullConclusionIsRejected() { - NegotiationContentException exception = - assertThrows(NegotiationContentException.class, () -> new InformationEndingGenerator() + NullPointerException exception = + assertThrows(NullPointerException.class, () -> new InformationEndingGenerator() .generate( context(1), new InfoEndingContent(null, List.of()), @@ -439,13 +442,13 @@ void nullConclusionIsRejected() { ZH_INFO_ENDING_TEMPLATE), zhVocabulary)); - assertEquals("content.conclusion", exception.getField()); + assertTrue(exception.getMessage().contains("conclusion must not be null")); } @Test void targetEndingRequiresTheFieldMatchingTheConclusion() { - NegotiationContentException acceptWithoutIntent = - assertThrows(NegotiationContentException.class, () -> new TargetEndingGenerator() + IllegalArgumentException acceptWithoutIntent = + assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() .generate( context(1), new TargetEndingContent(NegotiationConclusion.ACCEPT, null, "失败原因"), @@ -453,10 +456,12 @@ void targetEndingRequiresTheFieldMatchingTheConclusion() { "Negotiation-T/v1/target-negotiation/accept-reject", ZH_TARGET_ENDING_TEMPLATE), zhVocabulary)); - assertEquals("content.confirmedIntent", acceptWithoutIntent.getField()); + assertEquals( + "Confirmed intent of an accepting target negotiation message must not be blank.", + acceptWithoutIntent.getMessage()); - NegotiationContentException rejectWithoutReason = - assertThrows(NegotiationContentException.class, () -> new TargetEndingGenerator() + IllegalArgumentException rejectWithoutReason = + assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() .generate( context(1), new TargetEndingContent(NegotiationConclusion.REJECT, " ", null), @@ -464,13 +469,15 @@ void targetEndingRequiresTheFieldMatchingTheConclusion() { "Negotiation-T/v1/target-negotiation/accept-reject", ZH_TARGET_ENDING_TEMPLATE), zhVocabulary)); - assertEquals("content.failureReason", rejectWithoutReason.getField()); + assertEquals( + "Failure reason of a rejecting target negotiation message must not be blank.", + rejectWithoutReason.getMessage()); } @Test void feasibilityEndingRequiresTheSummary() { - NegotiationContentException exception = - assertThrows(NegotiationContentException.class, () -> new FeasibilityEndingGenerator() + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new FeasibilityEndingGenerator() .generate( context(1), new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " "), @@ -479,26 +486,29 @@ void feasibilityEndingRequiresTheSummary() { ZH_FEASIBILITY_ENDING_TEMPLATE), zhVocabulary)); - assertEquals("content.feasibilitySummary", exception.getField()); + assertEquals( + "Feasibility summary of a terminal feasibility negotiation message must not be blank.", + exception.getMessage()); } @Test void generatorRejectsContentOfAnotherRuntimeType() { - NegotiationContentException exception = - assertThrows(NegotiationContentException.class, () -> new InformationProposeGenerator() + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new InformationProposeGenerator() .generate( context(1), new TargetProposeContent("描述", null, null, null), template("Negotiation-T/v1/information-negotiation/propose", ZH_INFO_PROPOSE_TEMPLATE), zhVocabulary)); - assertEquals("content", exception.getField()); + assertTrue(exception.getMessage().contains("requires content of type InfoProposeContent")); } @Test void generatorsRenderAgainstTheBundledTemplates() { - NegotiationReference reference = NegotiationReference.parse( - "Negotiation-T/v1/target-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN"); + NegotiationReference reference = NegotiationReference.tryParse( + "Negotiation-T/v1/target-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") + .orElseThrow(() -> new AssertionError("expected the bundled target propose template to resolve")); PromptTemplate loaded = new DefaultNegotiationTemplateLoader("zh-CN", null).load(reference); TargetProposeContent content = new TargetProposeContent( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java index 82fa3f7a..39acb222 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java @@ -8,7 +8,6 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import org.junit.jupiter.api.Test; @@ -114,17 +113,17 @@ void feasibilityEndingSchemaRequiresTheSummary() { @Test void rejectsNullTypeAndPhase() { assertEquals( - "type", + "Negotiation type must not be null.", assertThrows( - NegotiationContentException.class, + NullPointerException.class, () -> builder.buildExtractionSchema(null, NegotiationPhase.PROPOSE)) - .getField()); + .getMessage()); assertEquals( - "phase", + "Negotiation phase must not be null.", assertThrows( - NegotiationContentException.class, + NullPointerException.class, () -> builder.buildExtractionSchema(NegotiationType.INFORMATION, null)) - .getField()); + .getMessage()); } @Test @@ -180,8 +179,8 @@ void semanticValidationSchemaWrapsCallerSchemaWithoutTypeKeyword() { @Test void rejectsNullCallerSchema() { assertEquals( - "schema", - assertThrows(NegotiationContentException.class, () -> builder.buildSemanticValidationSchema(null)) - .getField()); + "Caller parameter schema must not be null.", + assertThrows(NullPointerException.class, () -> builder.buildSemanticValidationSchema(null)) + .getMessage()); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java index 30ea2f09..be426fb4 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java @@ -8,7 +8,6 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import org.junit.jupiter.api.Test; class NegotiationPromptResourceLoaderTest { @@ -41,18 +40,14 @@ void throwsResourceNotFoundForMissingPrompts() { @Test void rejectsPathLikeArguments() { - assertEquals( - "promptCategory", - assertThrows( - NegotiationContentException.class, - () -> loader.loadSystem("../information_negotiation", "zh-CN")) - .getField()); - assertEquals( - "language", - assertThrows( - NegotiationContentException.class, - () -> loader.loadUser("information_negotiation", "../zh-CN")) - .getField()); + assertTrue(assertThrows( + IllegalArgumentException.class, () -> loader.loadSystem("../information_negotiation", "zh-CN")) + .getMessage() + .contains("Prompt category must be a non-blank simple path segment")); + assertTrue( + assertThrows(IllegalArgumentException.class, () -> loader.loadUser("information_negotiation", "../zh-CN")) + .getMessage() + .contains("Prompt language must be a non-blank simple path segment")); } @Test @@ -112,8 +107,8 @@ void messageBuilderReplacesSemanticValidationTokens() { @Test void messageBuilderRejectsNullResourceLoader() { assertEquals( - "resourceLoader", - assertThrows(NegotiationContentException.class, () -> new NegotiationMessageBuilder(null)) - .getField()); + "Negotiation message builder requires a prompt resource loader.", + assertThrows(NullPointerException.class, () -> new NegotiationMessageBuilder(null)) + .getMessage()); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index 73041173..da50e6f3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -20,7 +20,6 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; @@ -108,8 +107,8 @@ void wellFormedUriMissingInBothRootsFailsWithTemplateNotFound() { /** * Entry (c): every malformed URI — wrong segment count, prefix, version, type suffix, separator (underscore - * misspelling), unknown type and invalid phase segment — fails as a programming error pointing at - * {@code templateUri}, without any LLM call. + * misspelling), unknown type and invalid phase segment — fails as a programming error with an + * {@link IllegalArgumentException} pointing at the template URI, without any LLM call. */ @ParameterizedTest(name = "malformed URI [{0}] is rejected as a templateUri programming error") @ValueSource( @@ -133,11 +132,13 @@ void malformedUriIsRejectedAsATemplateUriProgrammingError(String templateUri) { .llmClient(llm) .build(); - NegotiationContentException failure = assertThrows( - NegotiationContentException.class, + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, () -> orchestrator.generateProposeFromData(informationProposeData(), templateUri)); - assertEquals("templateUri", failure.getField()); + assertTrue( + failure.getMessage().contains("Template URI is malformed or contradicts the expected phase PROPOSE"), + "the failure must point at the template URI but was: " + failure.getMessage()); assertEquals(0, llm.calls); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java index 8c26ab33..8430e56c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java @@ -6,7 +6,6 @@ import java.util.List; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; @@ -26,12 +25,14 @@ class UnsupportedLanguageBehaviorTest { @Test void assemblingThePipelineForAnUnsupportedLanguageFailsWithTheLanguageHint() { - NegotiationContentException exception = - assertThrows(NegotiationContentException.class, () -> NegotiationGenerationOrchestratorBuilder.builder() + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> NegotiationGenerationOrchestratorBuilder.builder() .language("fr-FR") .build()); - assertEquals("language", exception.getField()); + assertTrue( + exception.getMessage().contains("fr-FR"), + "the failure must name the unsupported language: " + exception.getMessage()); assertTrue( exception.getMessage().contains("A2AT_LANGUAGE"), "the failure must point at the language configuration: " + exception.getMessage()); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java index 48907a14..292ce1bd 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java @@ -2,7 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -17,7 +17,6 @@ import net.openan.a2at.sdk.llm.LLMError; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; @@ -170,7 +169,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { NegotiationParamExtractionException.class, A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), row( - "entry_programming_error_carries_no_code_but_a_field", + "entry_programming_error_carries_no_code_but_the_offending_argument", "not-a-json", 0, llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() @@ -178,7 +177,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .llmClient(llm) .build() .generateProposeFromData(proposeData(), "malformed-template-uri"), - NegotiationContentException.class, + IllegalArgumentException.class, null), row( "internal_render_failure_is_wrapped_as_slot_missing", @@ -248,9 +247,15 @@ private static void verify( failure.getMessage() != null && !failure.getMessage().isBlank(), "row " + name + " must carry a non-blank message"); - if (failure instanceof NegotiationContentException contentFailure) { - assertFalse(A2ATError.class.isInstance(contentFailure), "row " + name + " must stay outside A2ATError"); - assertNotNull(contentFailure.getField(), "row " + name + " must carry a problem field"); + if (failure instanceof IllegalArgumentException argumentFailure) { + assertFalse( + A2ATError.class.isInstance(argumentFailure), + "row " + name + " must stay outside A2ATError because it carries no code"); + assertNullCode(expectedCode, name); + assertTrue( + argumentFailure.getMessage().contains("Template URI is malformed"), + "row " + name + " must name the offending argument in its message but was: " + + argumentFailure.getMessage()); return; } if (failure instanceof NegotiationGenerationException generationFailure) { @@ -278,6 +283,10 @@ private static void verify( + failure.getClass().getName()); } + private static void assertNullCode(String expectedCode, String name) { + assertNull(expectedCode, "row " + name + " carries no public code by design"); + } + private static RuntimeException runExpectingFailure( Runnable action, String name, Class expectedType) { try { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java index 9428f447..5da29d59 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java @@ -14,7 +14,6 @@ import net.openan.a2at.sdk.llm.LLMError; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; @@ -28,8 +27,9 @@ * Verifies the single-root failure contract of the negotiation content layer. * *

Every runtime failure of the generation and parameter-extraction pipelines is catchable through the common - * {@link A2ATError} root, while programming errors of the content layer ({@link NegotiationContentException}) stay - * outside that tree so that callers cannot accidentally swallow them with a generic processing-failure handler. + * {@link A2ATError} root, while programming errors of the content layer (standard {@link IllegalArgumentException} + * and {@link NullPointerException} of argument validation) stay outside that tree so that callers cannot accidentally + * swallow them with a generic processing-failure handler. */ class NegotiationExceptionTreeRootCatchTest { @@ -139,23 +139,57 @@ void contentProgrammingErrorsAreNotCatchableThroughTheCommonRoot() { } catch (A2ATError caughtByRoot) { fail("content programming errors must stay outside the A2ATError tree but were caught: " + caughtByRoot.getMessage()); - } catch (NegotiationContentException expected) { + } catch (IllegalArgumentException expected) { assertFalse( A2ATError.class.isInstance(expected), "content programming errors must stay outside the A2ATError tree"); - assertEquals("templateUri", expected.getField()); + assertTrue( + expected.getMessage().contains("Template URI is malformed"), + "the failure must point at the template URI but was: " + expected.getMessage()); } } @Test void contentProgrammingErrorsOfTheContextStayOutsideTheCommonRoot() { - NegotiationContentException failure = - assertThrows(NegotiationContentException.class, () -> new NegotiationContext(" ", 1, 5)); + IllegalArgumentException failure = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(" ", 1, 5)); assertFalse( A2ATError.class.isInstance(failure), "content programming errors must stay outside the A2ATError tree"); } + @Test + void nullPromptOnValidateAndFillingStaysOutsideTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + NullPointerException failure = assertThrows( + NullPointerException.class, + () -> orchestrator.validateAndFillingProposeData(null, Map.of("type", "object"), INFORMATION_PROPOSE_URI)); + + assertFalse(A2ATError.class.isInstance(failure), "a null prompt is a programming error and must stay outside the A2ATError tree"); + } + + @Test + void blankPromptOnValidateAndFillingStaysOutsideTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.validateAndFillingProposeData( + " ", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); + + assertFalse( + A2ATError.class.isInstance(failure), + "a blank prompt is a programming error and must stay outside the A2ATError tree"); + assertTrue( + failure.getMessage().contains("blank"), + "the failure must point at the blank prompt but was: " + failure.getMessage()); + } + /** * Runs one pipeline action inside a {@code catch (A2ATError)} block and returns the caught failure, failing when * nothing was caught. diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java index 145700c3..61d98375 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java @@ -14,7 +14,6 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMError; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; @@ -41,8 +40,7 @@ class NegotiationPublicExceptionSurfaceTest { A2ATParamExtractionError.class, NegotiationProcessingException.class, NegotiationGenerationException.class, - NegotiationParamExtractionException.class, - NegotiationContentException.class); + NegotiationParamExtractionException.class); @Test void noPublicExceptionTypeExposesAStageProperty() { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java index c158508e..ad4d8d7f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java @@ -1,161 +1,134 @@ -package net.openan.a2at.sdk.negotiation.resources; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentException; -import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import org.junit.jupiter.api.Test; - -class NegotiationReferenceTest { - - @Test - void typeSegmentUsesHyphenatedNames() { - assertEquals("information-negotiation", NegotiationType.INFORMATION.typeSegment()); - assertEquals("target-negotiation", NegotiationType.TARGET.typeSegment()); - assertEquals("feasibility-negotiation", NegotiationType.FEASIBILITY.typeSegment()); - } - - @Test - void uriComposesPrefixVersionTypeSegmentAndPhaseSegment() { - assertEquals( - "Negotiation-T/v1/information-negotiation/propose", - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "en-US").uri()); - assertEquals( - "Negotiation-T/v1/target-negotiation/accept-reject", - new NegotiationReference(NegotiationType.TARGET, NegotiationPhase.ACCEPT, "zh-CN").uri()); - assertEquals( - "Negotiation-T/v1/feasibility-negotiation/accept-reject", - new NegotiationReference(NegotiationType.FEASIBILITY, NegotiationPhase.REJECT, "zh-CN").uri()); - } - - @Test - void parseAcceptsAllSixValidUris() { - for (NegotiationType type : NegotiationType.values()) { - NegotiationReference propose = - NegotiationReference.parse(proposeUri(type), NegotiationPhase.PROPOSE, "zh-CN"); - assertEquals(type, propose.type()); - assertEquals(NegotiationPhase.PROPOSE, propose.phase()); - assertEquals(proposeUri(type), propose.uri()); - assertEquals("zh-CN", propose.language()); - - NegotiationReference accept = - NegotiationReference.parse(acceptRejectUri(type), NegotiationPhase.ACCEPT, "en-US"); - assertEquals(type, accept.type()); - assertEquals(NegotiationPhase.ACCEPT, accept.phase()); - assertEquals(acceptRejectUri(type), accept.uri()); - - NegotiationReference reject = - NegotiationReference.parse(acceptRejectUri(type), NegotiationPhase.REJECT, "en-US"); - assertEquals(type, reject.type()); - assertEquals(NegotiationPhase.REJECT, reject.phase()); - assertEquals(acceptRejectUri(type), reject.uri()); - } - } - - @Test - void parseRejectsWrongSegmentCount() { - assertParseFailure("information-negotiation/propose", NegotiationPhase.PROPOSE, "segments"); - assertParseFailure( - "Negotiation-T/v1/information-negotiation/propose/extra", NegotiationPhase.PROPOSE, "segments"); - assertParseFailure("foo", NegotiationPhase.PROPOSE, "segments"); - } - - @Test - void parseRejectsWrongPrefixAndVersion() { - assertParseFailure("Task-T/v1/information-negotiation/propose", NegotiationPhase.PROPOSE, "Negotiation-T"); - assertParseFailure( - "negotiation-t/v1/information-negotiation/propose", NegotiationPhase.PROPOSE, "Negotiation-T"); - assertParseFailure("Negotiation-T/v2/information-negotiation/propose", NegotiationPhase.PROPOSE, "v1"); - } - - @Test - void parseRejectsMissingTypeSegmentSuffixAndUnderscoreVariant() { - assertParseFailure("Negotiation-T/v1/information/propose", NegotiationPhase.PROPOSE, "-negotiation"); - assertParseFailure( - "Negotiation-T/v1/information_negotiation/propose", NegotiationPhase.PROPOSE, "-negotiation"); - } - - @Test - void parseRejectsUnknownType() { - assertParseFailure("Negotiation-T/v1/unknown-negotiation/propose", NegotiationPhase.PROPOSE, "unknown"); - } - - @Test - void parseRejectsIllegalPhaseSegment() { - assertParseFailure("Negotiation-T/v1/information-negotiation/propose-x", NegotiationPhase.PROPOSE, "phase"); - assertParseFailure("Negotiation-T/v1/information-negotiation/accept", NegotiationPhase.PROPOSE, "phase"); - } - - @Test - void parseRejectsPhaseMismatchAgainstExpectedPhase() { - NegotiationContentException exception = assertThrows( - NegotiationContentException.class, - () -> NegotiationReference.parse( - "Negotiation-T/v1/information-negotiation/propose", NegotiationPhase.ACCEPT, "zh-CN")); - - assertEquals("templateUri", exception.getField()); - assertTrue(exception.getMessage().contains("does not match the expected phase")); - assertTrue(exception.getMessage().contains("propose")); - } - - @Test - void parseRejectsBlankUriAndNullExpectedPhase() { - NegotiationContentException blankException = assertThrows( - NegotiationContentException.class, - () -> NegotiationReference.parse("", NegotiationPhase.PROPOSE, "zh-CN")); - assertEquals("templateUri", blankException.getField()); - - NegotiationContentException nullUriException = assertThrows( - NegotiationContentException.class, - () -> NegotiationReference.parse(null, NegotiationPhase.PROPOSE, "zh-CN")); - assertEquals("templateUri", nullUriException.getField()); - - NegotiationContentException nullPhaseException = assertThrows( - NegotiationContentException.class, - () -> NegotiationReference.parse("Negotiation-T/v1/information-negotiation/propose", null, "zh-CN")); - assertEquals("phase", nullPhaseException.getField()); - } - - @Test - void parseFailureMessagesAreDistinguishable() { - List messages = List.of( - parseErrorMessage("information-negotiation/propose", NegotiationPhase.PROPOSE), - parseErrorMessage("Task-T/v1/information-negotiation/propose", NegotiationPhase.PROPOSE), - parseErrorMessage("Negotiation-T/v2/information-negotiation/propose", NegotiationPhase.PROPOSE), - parseErrorMessage("Negotiation-T/v1/information/propose", NegotiationPhase.PROPOSE), - parseErrorMessage("Negotiation-T/v1/unknown-negotiation/propose", NegotiationPhase.PROPOSE), - parseErrorMessage("Negotiation-T/v1/information-negotiation/propose-x", NegotiationPhase.PROPOSE)); - - assertEquals(messages.size(), messages.stream().distinct().count()); - } - - private static String proposeUri(NegotiationType type) { - return "Negotiation-T/v1/" + type.typeSegment() + "/propose"; - } - - private static String acceptRejectUri(NegotiationType type) { - return "Negotiation-T/v1/" + type.typeSegment() + "/accept-reject"; - } - - private static void assertParseFailure(String templateUri, NegotiationPhase expectedPhase, String messageFragment) { - NegotiationContentException exception = assertThrows( - NegotiationContentException.class, - () -> NegotiationReference.parse(templateUri, expectedPhase, "zh-CN")); - - assertEquals("templateUri", exception.getField()); - assertTrue( - exception.getMessage().contains(messageFragment), - "expected message containing '" + messageFragment + "' but was: " + exception.getMessage()); - } - - private static String parseErrorMessage(String templateUri, NegotiationPhase expectedPhase) { - return assertThrows( - NegotiationContentException.class, - () -> NegotiationReference.parse(templateUri, expectedPhase, "zh-CN")) - .getMessage(); - } -} +package net.openan.a2at.sdk.negotiation.resources; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import org.junit.jupiter.api.Test; + +class NegotiationReferenceTest { + + @Test + void typeSegmentUsesHyphenatedNames() { + assertEquals("information-negotiation", NegotiationType.INFORMATION.typeSegment()); + assertEquals("target-negotiation", NegotiationType.TARGET.typeSegment()); + assertEquals("feasibility-negotiation", NegotiationType.FEASIBILITY.typeSegment()); + } + + @Test + void uriComposesPrefixVersionTypeSegmentAndPhaseSegment() { + assertEquals( + "Negotiation-T/v1/information-negotiation/propose", + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "en-US").uri()); + assertEquals( + "Negotiation-T/v1/target-negotiation/accept-reject", + new NegotiationReference(NegotiationType.TARGET, NegotiationPhase.ACCEPT, "zh-CN").uri()); + assertEquals( + "Negotiation-T/v1/feasibility-negotiation/accept-reject", + new NegotiationReference(NegotiationType.FEASIBILITY, NegotiationPhase.REJECT, "zh-CN").uri()); + } + + @Test + void tryParseAcceptsAllSixValidUris() { + for (NegotiationType type : NegotiationType.values()) { + NegotiationReference propose = requirePresent( + NegotiationReference.tryParse(proposeUri(type), NegotiationPhase.PROPOSE, "zh-CN")); + assertEquals(type, propose.type()); + assertEquals(NegotiationPhase.PROPOSE, propose.phase()); + assertEquals(proposeUri(type), propose.uri()); + assertEquals("zh-CN", propose.language()); + + NegotiationReference accept = requirePresent( + NegotiationReference.tryParse(acceptRejectUri(type), NegotiationPhase.ACCEPT, "en-US")); + assertEquals(type, accept.type()); + assertEquals(NegotiationPhase.ACCEPT, accept.phase()); + assertEquals(acceptRejectUri(type), accept.uri()); + + NegotiationReference reject = requirePresent( + NegotiationReference.tryParse(acceptRejectUri(type), NegotiationPhase.REJECT, "en-US")); + assertEquals(type, reject.type()); + assertEquals(NegotiationPhase.REJECT, reject.phase()); + assertEquals(acceptRejectUri(type), reject.uri()); + } + } + + @Test + void tryParseRejectsWrongSegmentCount() { + assertTrue(NegotiationReference.tryParse("information-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.tryParse( + "Negotiation-T/v1/information-negotiation/propose/extra", NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.tryParse("foo", NegotiationPhase.PROPOSE, "zh-CN").isEmpty()); + } + + @Test + void tryParseRejectsWrongPrefixAndVersion() { + assertTrue(NegotiationReference.tryParse("Task-T/v1/information-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.tryParse( + "negotiation-t/v1/information-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.tryParse("Negotiation-T/v2/information-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + } + + @Test + void tryParseRejectsMissingTypeSegmentSuffixAndUnderscoreVariant() { + assertTrue(NegotiationReference.tryParse("Negotiation-T/v1/information/propose", NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.tryParse( + "Negotiation-T/v1/information_negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + } + + @Test + void tryParseRejectsUnknownType() { + assertTrue(NegotiationReference.tryParse("Negotiation-T/v1/unknown-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + } + + @Test + void tryParseRejectsIllegalPhaseSegment() { + assertTrue(NegotiationReference.tryParse( + "Negotiation-T/v1/information-negotiation/propose-x", NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.tryParse("Negotiation-T/v1/information-negotiation/accept", NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + } + + @Test + void tryParseRejectsPhaseMismatchAgainstExpectedPhase() { + Optional parsed = NegotiationReference.tryParse( + "Negotiation-T/v1/information-negotiation/propose", NegotiationPhase.ACCEPT, "zh-CN"); + + assertTrue(parsed.isEmpty()); + } + + @Test + void tryParseReturnsEmptyForBlankAndNullUriButThrowsOnNullExpectedPhase() { + assertTrue(NegotiationReference.tryParse("", NegotiationPhase.PROPOSE, "zh-CN").isEmpty()); + assertTrue(NegotiationReference.tryParse(null, NegotiationPhase.PROPOSE, "zh-CN").isEmpty()); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> NegotiationReference.tryParse("Negotiation-T/v1/information-negotiation/propose", null, "zh-CN")); + assertEquals("Expected negotiation phase must not be null.", exception.getMessage()); + } + + private static String proposeUri(NegotiationType type) { + return "Negotiation-T/v1/" + type.typeSegment() + "/propose"; + } + + private static String acceptRejectUri(NegotiationType type) { + return "Negotiation-T/v1/" + type.typeSegment() + "/accept-reject"; + } + + private static NegotiationReference requirePresent(Optional reference) { + assertTrue(reference.isPresent(), "expected a parsed reference but the result was empty"); + return reference.get(); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/store/InMemoryNegotiationStoreTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/store/InMemoryNegotiationStoreTest.java index f15eb9ac..6e4a7d67 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/store/InMemoryNegotiationStoreTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/store/InMemoryNegotiationStoreTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; @@ -27,4 +28,38 @@ void storeSavesGetsAndDeletesRecords() { assertNull(store.get("neg-store")); } + + @Test + void saveRejectsNullRecordAndNullContext() { + InMemoryNegotiationStore store = new InMemoryNegotiationStore(); + + assertEquals( + "Negotiation record must not be null.", + assertThrows(NullPointerException.class, () -> store.save(null)).getMessage()); + assertEquals( + "Negotiation record context must not be null.", + assertThrows(NullPointerException.class, () -> store.save(new NegotiationRecord(null, "message"))) + .getMessage()); + } + + @Test + void saveRejectsMissingNegotiationId() { + InMemoryNegotiationStore store = new InMemoryNegotiationStore(); + + IllegalArgumentException missingId = assertThrows( + IllegalArgumentException.class, + () -> store.save(new NegotiationRecord( + new NegotiationContext(NegotiationType.TARGET, null, 1, NegotiationStatus.IN_PROGRESS), + "message"))); + + assertEquals("negotiation id is null or empty.", missingId.getMessage()); + + IllegalArgumentException emptyId = assertThrows( + IllegalArgumentException.class, + () -> store.save(new NegotiationRecord( + new NegotiationContext(NegotiationType.TARGET, "", 1, NegotiationStatus.IN_PROGRESS), + "message"))); + + assertEquals("negotiation id is null or empty.", emptyId.getMessage()); + } } From d801436c877ad75cb14670c11d04e6c1c71efe35 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 21 Aug 2026 14:13:35 +0800 Subject: [PATCH 069/295] refactor(facade): converge client/server exception handling on A2ATError Client orchestrator catches a single A2ATError (LLMRuntimeError and config errors now converge into result failures); PromptComplianceCheckException extends A2ATError keeping stage; blank/malformed template URIs on facade entry surface as NPE/IllegalArgumentException per the boundary contract; facade @throws javadoc updated. Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 56 +++++++++------ ...ultClientPromptGenerationOrchestrator.java | 42 ++++++----- .../a2at/sdk/client/api/A2ATClientTest.java | 39 ++++++----- ...lientPromptGenerationOrchestratorTest.java | 70 ++++++++++++------- .../openan/a2at/sdk/server/A2ATServer.java | 62 ++++++++++------ ...ultServerPromptComplianceOrchestrator.java | 2 +- .../PromptComplianceCheckException.java | 25 +++---- .../LlmBackedPromptMetadataExtractorTest.java | 12 ++-- .../LlmBackedPromptSemanticValidatorTest.java | 4 +- 9 files changed, 185 insertions(+), 127 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index c1ba3778..e709cab9 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -70,6 +70,8 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { * @param text natural-language task input * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or contains invalid characters */ public MetadataContent generateTaskPromptFromText( String text, String templateUri) { @@ -84,6 +86,8 @@ public MetadataContent generateTaskPromptFromText( * @param schema optional data schema map for schema-guided extraction * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or contains invalid characters */ public MetadataContent generateTaskPromptFromDataWithSchema( Map data, Map schema, String templateUri) { @@ -101,6 +105,8 @@ public MetadataContent generateTaskPromptFromDataWithSchema( * @param text natural-language authorization input * @param authorizationType authorization type used as the template identifier * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the authorization type is null + * @throws IllegalArgumentException if the authorization type is blank or contains invalid characters */ public MetadataContent generateAuthPromptFromText( String text, String authorizationType) { @@ -119,6 +125,8 @@ public MetadataContent generateAuthPromptFromText( * @param schema optional data schema map for schema-guided extraction * @param authorizationType authorization type used as the template identifier * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the authorization type is null + * @throws IllegalArgumentException if the authorization type is blank or contains invalid characters */ public MetadataContent generateAuthPromptFromDataWithSchema( Map data, Map schema, String authorizationType) { @@ -132,6 +140,8 @@ public MetadataContent generateAuthPromptFromDataWithSchema( * @param text natural-language notification input * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or contains invalid characters */ public MetadataContent generateNotificationPromptFromText( String text, String templateUri) { @@ -146,6 +156,8 @@ public MetadataContent generateNotificationPromptFromText( * @param schema optional data schema map for schema-guided extraction * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or contains invalid characters */ public MetadataContent generateNotificationPromptFromDataWithSchema( Map data, Map schema, String templateUri) { @@ -198,8 +210,9 @@ public Map continueNegotiation( * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/propose}; its phase * segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the data or its context is null, - * the template URI is malformed, or its phase or type contradicts the method or the content type + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI is malformed, or its phase or type contradicts the method + * or the content type * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation_slot_missing} when rendering the template fails @@ -218,9 +231,9 @@ public MetadataContent generateNegotiationProposePromptFromData(NegotiationPropo * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/accept-reject}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the data or its context is null, - * the template URI is malformed, its phase or type contradicts the method or the content, or the content - * conclusion is not {@code Accept} + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or + * the content, or the content conclusion is not {@code Accept} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation_slot_missing} when rendering the template fails @@ -239,9 +252,9 @@ public MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEnding * @param templateUri template URI such as {@code Negotiation-T/v1/feasibility-negotiation/accept-reject}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the data or its context is null, - * the template URI is malformed, its phase or type contradicts the method or the content, or the content - * conclusion is not {@code Reject} + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or + * the content, or the content conclusion is not {@code Reject} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation_slot_missing} when rendering the template fails @@ -263,8 +276,8 @@ public MetadataContent generateNegotiationRejectPromptFromData(NegotiationEnding * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose}; its phase segment * must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the context is null or the - * template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the @@ -289,8 +302,8 @@ public MetadataContent generateNegotiationProposePromptFromText( * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/accept-reject}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the context is null or the - * template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the @@ -315,8 +328,8 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * @param templateUri template URI such as {@code Negotiation-T/v1/feasibility-negotiation/accept-reject}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the context is null or the - * template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the @@ -399,8 +412,9 @@ public Optional getNegotiationPrompt(String uri) { * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the schema is null or the template - * URI is malformed or its phase contradicts the method + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI is malformed or its phase + * contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation_invalid_input} when the prompt is not a negotiation message, * {@code negotiation_rule_violation} when the negotiation context violates a rule, @@ -425,8 +439,9 @@ public FilledParamData validateAndFillingProposeData( * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the schema is null or the template - * URI is malformed or its phase contradicts the method + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI is malformed or its phase + * contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation_invalid_input} when the prompt is not a negotiation message, * {@code negotiation_rule_violation} when the negotiation context violates a rule, @@ -450,8 +465,9 @@ public FilledParamData validateAndFillingAcceptData(String prompt, Map slots; @@ -185,7 +180,7 @@ private MetadataContent generateFromTemplateUriWithMetadata( slots = slotValueExtractor.extractSlots(userInput, templateIdentifier, language, templateText); } catch (ResourceNotFoundException e) { throw new PromptGenerationException("slot_schema_not_found", e.getMessage(), e); - } catch (LLMRuntimeError | SdkException e) { + } catch (A2ATError e) { throw new PromptGenerationException("llm_invocation_failed", e.getMessage(), e); } validateRequiredSlots(slots, templateIdentifier); @@ -203,18 +198,13 @@ private MetadataContent generateFromDataWithSchema( Map schema, String templateIdentifier, String extensionUri) { - if (templateIdentifier == null - || templateIdentifier.isBlank() - || !TEMPLATE_IDENTIFIER_PATTERN.matcher(templateIdentifier).matches()) { - throw new PromptGenerationException("invalid_template_uri", - "Template URI is null, blank, or contains invalid characters."); - } + requireValidTemplateIdentifier(templateIdentifier); final String templateText; try { templateText = templateLoader.loadTemplate(templateIdentifier, language); } catch (ResourceNotFoundException e) { throw new PromptGenerationException("template_not_found", e.getMessage(), e); - } catch (SdkException e) { + } catch (A2ATError e) { throw new PromptGenerationException("prompt_resource_load_error", e.getMessage(), e); } final Map slots; @@ -223,7 +213,7 @@ private MetadataContent generateFromDataWithSchema( data, templateIdentifier, language, templateText, schema); } catch (ResourceNotFoundException e) { throw new PromptGenerationException("slot_schema_not_found", e.getMessage(), e); - } catch (LLMRuntimeError | SdkException e) { + } catch (A2ATError e) { throw new PromptGenerationException("llm_invocation_failed", e.getMessage(), e); } validateRequiredSlots(slots, templateIdentifier); @@ -236,11 +226,25 @@ private MetadataContent generateFromDataWithSchema( return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); } + /** + * Enforces the caller contract of the template identifier: a null identifier is a {@link NullPointerException}, + * a blank or malformed identifier is an {@link IllegalArgumentException}. Both stay outside the {@link A2ATError} + * tree because they are programming errors of the caller. + */ + private static void requireValidTemplateIdentifier(String templateIdentifier) { + Objects.requireNonNull(templateIdentifier, "templateIdentifier"); + if (templateIdentifier.isBlank() || !TEMPLATE_IDENTIFIER_PATTERN.matcher(templateIdentifier).matches()) { + throw new IllegalArgumentException( + "Template URI is blank or contains invalid characters (invalid_template_uri): " + + templateIdentifier); + } + } + private void validateRequiredSlots(Map slots, String templateIdentifier) { final PromptSlotSchema schema; try { schema = slotSchemaLoader.loadSlotSchema(templateIdentifier, language); - } catch (SdkException e) { + } catch (A2ATError e) { throw new PromptGenerationException("slot_schema_not_found", e.getMessage(), e); } if (schema == null) { diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index 83126632..eff25cb1 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.client; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -14,7 +15,7 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; @@ -344,18 +345,18 @@ void fromTextThrowsOnInvalidTemplateUri() throws IOException { Path envFile = writeMinimalLocalClientEnv(); A2ATClient client = new A2ATClient(envFile); - PromptGenerationException taskEx = assertThrows( - PromptGenerationException.class, + IllegalArgumentException taskEx = assertThrows( + IllegalArgumentException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", "../etc/passwd")); - assertEquals("invalid_template_uri", taskEx.code()); - PromptGenerationException authEx = assertThrows( - PromptGenerationException.class, + assertFalse(A2ATError.class.isInstance(taskEx), "invalid template URI must stay outside the A2ATError tree"); + IllegalArgumentException authEx = assertThrows( + IllegalArgumentException.class, () -> client.generateAuthPromptFromText("Authorize access.", "../etc/passwd")); - assertEquals("invalid_template_uri", authEx.code()); - PromptGenerationException notifEx = assertThrows( - PromptGenerationException.class, + assertFalse(A2ATError.class.isInstance(authEx), "invalid template URI must stay outside the A2ATError tree"); + IllegalArgumentException notifEx = assertThrows( + IllegalArgumentException.class, () -> client.generateNotificationPromptFromText("Report finished.", "../etc/passwd")); - assertEquals("invalid_template_uri", notifEx.code()); + assertFalse(A2ATError.class.isInstance(notifEx), "invalid template URI must stay outside the A2ATError tree"); } @Test @@ -365,18 +366,18 @@ void fromDataWithSchemaThrowsOnInvalidTemplateUri() throws IOException { Map data = Map.of("site", "Site A"); Map schema = Map.of("type", "object"); - PromptGenerationException taskEx = assertThrows( - PromptGenerationException.class, + IllegalArgumentException taskEx = assertThrows( + IllegalArgumentException.class, () -> client.generateTaskPromptFromDataWithSchema(data, schema, "../etc/passwd")); - assertEquals("invalid_template_uri", taskEx.code()); - PromptGenerationException authEx = assertThrows( - PromptGenerationException.class, + assertFalse(A2ATError.class.isInstance(taskEx), "invalid template URI must stay outside the A2ATError tree"); + IllegalArgumentException authEx = assertThrows( + IllegalArgumentException.class, () -> client.generateAuthPromptFromDataWithSchema(data, schema, "../etc/passwd")); - assertEquals("invalid_template_uri", authEx.code()); - PromptGenerationException notifEx = assertThrows( - PromptGenerationException.class, + assertFalse(A2ATError.class.isInstance(authEx), "invalid template URI must stay outside the A2ATError tree"); + IllegalArgumentException notifEx = assertThrows( + IllegalArgumentException.class, () -> client.generateNotificationPromptFromDataWithSchema(data, schema, "../etc/passwd")); - assertEquals("invalid_template_uri", notifEx.code()); + assertFalse(A2ATError.class.isInstance(notifEx), "invalid template URI must stay outside the A2ATError tree"); } private static Path writeMinimalClientEnvWithoutRequiredSlots(String provider) throws IOException { diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index ca9a36b6..da5cf871 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -14,10 +14,10 @@ import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.FailedParameter; import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.llm.LLMRuntimeError; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; @@ -325,16 +325,34 @@ void generateFromTemplateUriWithMetadataScenarioRecognizerIsNotInvoked() { } @Test - void generateFromTemplateUriWithMetadataThrowsOnInvalidTemplateUri() { - for (String invalidUri : new String[] {null, "", " ", "../etc/passwd", "has space", "id;drop"}) { + void generateFromTemplateUriWithMetadataThrowsOnNullTemplateUri() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); + + NullPointerException ex = assertThrows( + NullPointerException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", null)); + assertFalse(A2ATError.class.isInstance(ex), "null template URI must stay outside the A2ATError tree"); + assertEquals(0, recognizer.invocationCount); + assertEquals(0, templateLoader.loadCount); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsOnBlankOrMalformedTemplateUri() { + for (String invalidUri : new String[] {"", " ", "../etc/passwd", "has space", "id;drop"}) { RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); - PromptGenerationException ex = assertThrows(PromptGenerationException.class, + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", invalidUri)); - assertEquals("invalid_template_uri", ex.code()); + assertFalse(A2ATError.class.isInstance(ex), "invalid template URI must stay outside the A2ATError tree"); + assertTrue( + ex.getMessage().contains("invalid_template_uri"), + "the failure must carry the invalid_template_uri detail but was: " + ex.getMessage()); assertEquals(0, recognizer.invocationCount); assertEquals(0, templateLoader.loadCount); } @@ -351,7 +369,7 @@ void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); - assertEquals("template_not_found", ex.code()); + assertEquals("template_not_found", ex.getCode()); } @Test @@ -363,7 +381,7 @@ void generateFromTemplateUriWithMetadataPropagatesRenderException() { PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); - assertEquals("render_failed", ex.code()); + assertEquals("render_failed", ex.getCode()); } @Test @@ -377,7 +395,7 @@ void generateFromTemplateUriWithMetadataPropagatesLlmRuntimeError() { PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); - assertEquals("llm_invocation_failed", ex.code()); + assertEquals("llm_invocation_failed", ex.getCode()); } // --- MetadataContent pipeline tests (generateFromDataWithSchema) --- @@ -451,9 +469,13 @@ void generateFromDataWithSchemaThrowsOnInvalidTemplateUri() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractorWithSchema(Map.of())); - PromptGenerationException ex = assertThrows(PromptGenerationException.class, + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "has space")); - assertEquals("invalid_template_uri", ex.code()); + assertFalse(A2ATError.class.isInstance(ex), "invalid template URI must stay outside the A2ATError tree"); + assertTrue( + ex.getMessage().contains("invalid_template_uri"), + "the failure must carry the invalid_template_uri detail but was: " + ex.getMessage()); assertEquals(0, recognizer.invocationCount); assertEquals(0, templateLoader.loadCount); } @@ -469,7 +491,7 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundException() { PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy_saving")); - assertEquals("template_not_found", ex.code()); + assertEquals("template_not_found", ex.getCode()); } @Test @@ -481,7 +503,7 @@ void generateFromDataWithSchemaPropagatesRenderException() { PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy_saving")); - assertEquals("render_failed", ex.code()); + assertEquals("render_failed", ex.getCode()); } @Test @@ -493,7 +515,7 @@ void generateFromDataWithSchemaPropagatesLlmRuntimeError() { PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy_saving")); - assertEquals("llm_invocation_failed", ex.code()); + assertEquals("llm_invocation_failed", ex.getCode()); } @Test @@ -506,7 +528,7 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundExceptionFromExtractor( PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy_saving")); - assertEquals("slot_schema_not_found", ex.code()); + assertEquals("slot_schema_not_found", ex.getCode()); } // --- Public entry point tests --- @@ -602,7 +624,7 @@ void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); - assertEquals("slot_validation_error", ex.code()); + assertEquals("slot_validation_error", ex.getCode()); List failed = ex.failedParameters(); assertFalse(failed.isEmpty()); assertEquals("site", failed.get(0).parameterName()); @@ -625,7 +647,7 @@ void validateRequiredSlotsThrowsSlotValidationErrorWhenMultipleRequiredSlotsMiss PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); - assertEquals("slot_validation_error", ex.code()); + assertEquals("slot_validation_error", ex.getCode()); assertEquals(1, ex.failedParameters().size()); assertEquals("target", ex.failedParameters().get(0).parameterName()); } @@ -639,12 +661,12 @@ void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundWhenSchemaLoadin new FakeSlotValueExtractor(Map.of("site", "Site A")), new TaskPromptRenderer(), (scenarioCode, language) -> { - throw new SdkException("Schema file is corrupt."); + throw new A2ATError("Schema file is corrupt."); }); PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); - assertEquals("slot_schema_not_found", ex.code()); + assertEquals("slot_schema_not_found", ex.getCode()); } @Test @@ -661,7 +683,7 @@ void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundFromExtractor() PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); - assertEquals("slot_schema_not_found", ex.code()); + assertEquals("slot_schema_not_found", ex.getCode()); } @Test @@ -670,7 +692,7 @@ void generateFromTemplateUriWithMetadataThrowsPromptResourceLoadError() { new RecordingScenarioRecognizer(), List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), "en-US", "", "", (scenarioCode, language) -> { - throw new SdkException("Template file is corrupt."); + throw new A2ATError("Template file is corrupt."); }, new FakeSlotValueExtractor(Map.of("site", "Site A")), new TaskPromptRenderer(), @@ -678,23 +700,23 @@ void generateFromTemplateUriWithMetadataThrowsPromptResourceLoadError() { PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); - assertEquals("prompt_resource_load_error", ex.code()); + assertEquals("prompt_resource_load_error", ex.getCode()); } @Test - void generateFromTemplateUriWithMetadataThrowsLlmInvocationFailedFromSdkException() { + void generateFromTemplateUriWithMetadataThrowsLlmInvocationFailedFromA2ATError() { DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( new RecordingScenarioRecognizer(), List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), "en-US", "", "", new FakeTemplateLoader("Site: {site}"), (userInput, scenarioCode, language, templateText) -> { - throw new SdkException("Unparseable LLM response."); + throw new A2ATError("Unparseable LLM response."); }, new TaskPromptRenderer(), EMPTY_SCHEMA_LOADER); PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); - assertEquals("llm_invocation_failed", ex.code()); + assertEquals("llm_invocation_failed", ex.getCode()); } } diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 09fae2e5..2ef99d27 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -119,8 +119,9 @@ public Map continueNegotiation( * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/propose}; its phase * segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the data or its context is null, - * the template URI is malformed, or its phase or type contradicts the method or the content type + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI is malformed, or its phase or type contradicts the method + * or the content type * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation_slot_missing} when rendering the template fails @@ -139,9 +140,9 @@ public MetadataContent generateNegotiationProposePromptFromData(NegotiationPropo * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/accept-reject}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the data or its context is null, - * the template URI is malformed, its phase or type contradicts the method or the content, or the content - * conclusion is not {@code Accept} + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or + * the content, or the content conclusion is not {@code Accept} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation_slot_missing} when rendering the template fails @@ -160,9 +161,9 @@ public MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEnding * @param templateUri template URI such as {@code Negotiation-T/v1/feasibility-negotiation/accept-reject}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the data or its context is null, - * the template URI is malformed, its phase or type contradicts the method or the content, or the content - * conclusion is not {@code Reject} + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or + * the content, or the content conclusion is not {@code Reject} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation_slot_missing} when rendering the template fails @@ -184,8 +185,8 @@ public MetadataContent generateNegotiationRejectPromptFromData(NegotiationEnding * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose}; its phase segment * must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the context is null or the - * template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the @@ -210,8 +211,8 @@ public MetadataContent generateNegotiationProposePromptFromText( * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/accept-reject}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the context is null or the - * template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the @@ -236,8 +237,8 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * @param templateUri template URI such as {@code Negotiation-T/v1/feasibility-negotiation/accept-reject}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the context is null or the - * template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the @@ -318,8 +319,9 @@ public Optional getNegotiationPrompt(String templateUri) { * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the schema is null or the template - * URI is malformed or its phase contradicts the method + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI is malformed or its phase + * contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation_invalid_input} when the prompt is not a negotiation message, * {@code negotiation_rule_violation} when the negotiation context violates a rule, @@ -344,8 +346,9 @@ public FilledParamData validateAndFillingProposeData( * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationContentException if the schema is null or the template - * URI is malformed or its phase contradicts the method + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI is malformed or its phase + * contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation_invalid_input} when the prompt is not a negotiation message, * {@code negotiation_rule_violation} when the negotiation context violates a rule, @@ -369,8 +372,9 @@ public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) { return taskContentValidator.validate(prompt, schema, templateUri); @@ -403,6 +413,12 @@ public FilledParamData validateAndFillingTaskData(String prompt, Map schema, String templateUri) { @@ -417,6 +433,12 @@ public FilledParamData validateAndFillingNotificationData( * @param templateUri template URI declaring the expected authorization template; its prefix segment must be * {@code Authorization-T} * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank or the template URI is blank or malformed + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code validation_semantic_rejected} when the semantic validation rejects the content, + * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing */ public FilledParamData validateAndFillingAuthData(String prompt, Map schema, String templateUri) { return authContentValidator.validate(prompt, schema, templateUri); diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java index 5eaff65f..837ce894 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java @@ -38,7 +38,7 @@ public PromptComplianceResult checkTaskPrompt(String processedPromptText) { return new PromptComplianceResult(true, null); } catch (PromptComplianceCheckException error) { return new PromptComplianceResult( - false, new PromptComplianceFailure(error.code(), error.getMessage(), error.stage())); + false, new PromptComplianceFailure(error.getCode(), error.getMessage(), error.getStage())); } } } diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java index 5a151c4f..e477f7b4 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java @@ -1,13 +1,16 @@ package net.openan.a2at.sdk.server.exception; +import net.openan.a2at.sdk.core.exception.A2ATError; + /** - * Internal exception used to carry standardized compliance error details. + * Compliance-check failure carrying a stable error code and the compliance stage where the failure occurred. + * + *

Part of the {@link A2ATError} tree: callers can catch the {@link A2ATError} root to handle any A2A-T processing + * failure and dispatch on its machine-readable error code. * * @since 2026-06 */ -public final class PromptComplianceCheckException extends RuntimeException { - - private final String code; +public final class PromptComplianceCheckException extends A2ATError { private final String stage; @@ -19,26 +22,16 @@ public final class PromptComplianceCheckException extends RuntimeException { * @param stage compliance stage where the failure occurred */ public PromptComplianceCheckException(String code, String message, String stage) { - super(message); - this.code = code; + super(code, message); this.stage = stage; } - /** - * Returns the stable compliance error code. - * - * @return stable error code - */ - public String code() { - return code; - } - /** * Returns the compliance stage where the failure occurred. * * @return failure stage */ - public String stage() { + public String getStage() { return stage; } } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java index f45f3ce5..b1cb74f6 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java @@ -72,8 +72,8 @@ void extractReturnsPromptParseErrorWhenScenarioRecognitionDoesNotMatch() { PromptComplianceCheckException error = assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("unknown prompt")); - assertEquals("processed_prompt_parse_error", error.code()); - assertEquals("prompt_parse", error.stage()); + assertEquals("processed_prompt_parse_error", error.getCode()); + assertEquals("prompt_parse", error.getStage()); } @Test @@ -98,8 +98,8 @@ void extractReturnsSlotValidationErrorWhenStructuredExtractionReportsSlotErrors( PromptComplianceCheckException error = assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); - assertEquals("slot_validation_error", error.code()); - assertEquals("slot_validation", error.stage()); + assertEquals("slot_validation_error", error.getCode()); + assertEquals("slot_validation", error.getStage()); } @Test @@ -122,8 +122,8 @@ void extractPropagatesTemplateLoadErrorsAsGenerationFailures() { PromptComplianceCheckException error = assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("prompt")); - assertEquals("template_not_found", error.code()); - assertEquals("generation", error.stage()); + assertEquals("template_not_found", error.getCode()); + assertEquals("generation", error.getStage()); } private static final class RecordingClient implements LLMClient { diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java index 3e3db2e5..69c8d5ed 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java @@ -76,8 +76,8 @@ void validateReturnsSlotValidationErrorWhenSemanticValidatorRejectsSlots() { "## notification_topic\n{{notification_topic}}\n", Map.of(SLOT_NAME, "Incident")))); - assertEquals("slot_validation_error", error.code()); - assertEquals("slot_validation", error.stage()); + assertEquals("slot_validation_error", error.getCode()); + assertEquals("slot_validation", error.getStage()); } private static final class RecordingClient implements LLMClient { From d32e1c363dfcde64f56c377542480146b94cba10 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 21 Aug 2026 14:13:35 +0800 Subject: [PATCH 070/295] docs: rewrite exception architecture sections for A2ATError unification Document the single A2ATError root with machine-readable code, the NPE/IllegalArgumentException boundary for programming errors, tryParse, and the removed SdkException/NegotiationContentException; both languages in sync. Signed-off-by: codetalks-hw --- docs/en/developer_guide.md | 27 ++++++++++++------- ...00\345\217\221\346\214\207\345\215\227.md" | 27 ++++++++++++------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 05abe5d8..c79a55fb 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -427,7 +427,7 @@ MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData da MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, String templateUri) ``` -The typed content is validated, dispatched to the generator of the negotiation type addressed by the template URI, and rendered from that template. The accept variant requires `content.conclusion() == ACCEPT`, the reject variant `REJECT`; a mismatched conclusion (including `ABORT`) is a content error. +The typed content is validated, dispatched to the generator of the negotiation type addressed by the template URI, and rendered from that template. The accept variant requires `content.conclusion() == ACCEPT`, the reject variant `REJECT`; a mismatched conclusion (including `ABORT`) is rejected with `IllegalArgumentException` as a programming error. **Generation from free text — one LLM extraction step with configurable retry:** @@ -474,7 +474,7 @@ The template URI format is: Negotiation-T/v1/{type}-negotiation/{phase} ``` -where `{type}` is `information`, `target`, or `feasibility`, and `{phase}` is `propose` or `accept-reject`. Six built-in templates ship with the SDK, one per combination, each in `zh-CN` and `en-US`: +where `{type}` is `information`, `target`, or `feasibility`, and `{phase}` is `propose` or `accept-reject`. URI parsing never throws: `NegotiationReference.tryParse` returns an `Optional`, and a null, blank or malformed URI (wrong segment count, prefix, version, type or phase segment) simply yields an empty result. Six built-in templates ship with the SDK, one per combination, each in `zh-CN` and `en-US`: | Template URI | Purpose | |--|--| @@ -508,7 +508,7 @@ All types live in `net.openan.a2at.sdk.negotiation.content`. | Target | `TargetProposeContent(description, intentUnderstanding, alignmentAndClarification, requestForClarification)` | `TargetEndingContent(conclusion, confirmedIntent, failureReason)` | | Feasibility | `FeasibilityProposeContent(description, action, contentsToEvaluate, infeasibilityDetailsAndProposal)` | `FeasibilityEndingContent(conclusion, feasibilitySummary)` | -`NegotiationItem(name, value)` is one named entry of an item list. `NegotiationConclusion` carries `ACCEPT`, `REJECT`, and `ABORT`; only `Accept` and `Reject` are renderable — generation methods reject `ABORT` as a programming error. `NegotiationAction` (`REQUEST_FEASIBILITY_EVALUATION`, `PROPOSE_ALTERNATIVE_ON_FAILURE`) selects the conditional sections of the feasibility propose template. +`NegotiationItem(name, value)` is one named entry of an item list. `NegotiationConclusion` carries `ACCEPT`, `REJECT`, and `ABORT`; only `Accept` and `Reject` are renderable — generation methods reject `ABORT` with an `IllegalArgumentException` (a programming error outside the `A2ATError` tree). `NegotiationAction` (`REQUEST_FEASIBILITY_EVALUATION`, `PROPOSE_ALTERNATIVE_ON_FAILURE`) selects the conditional sections of the feasibility propose template. **MetadataContent** — the generation result: `record MetadataContent(String templateUri, String promptText, String extensionUri)`. `buildMetadataContent()` returns exactly two keys: the TMF extension URI (`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`) mapping to the rendered message, and `template_uri` mapping to the template URI. This map is what travels in the A2A message metadata. @@ -516,14 +516,20 @@ All types live in `net.openan.a2at.sdk.negotiation.content`. ### 1.10.5 Error Handling -The exception tree has two branches under the SDK root: +All SDK processing failures share one root, while programming errors stay outside it: -- `A2ATError` — the processing-failure root of the whole SDK. - - `NegotiationProcessingException` — a negotiation processing failure carrying a machine-readable code; `getCode()` returns it. +- `A2ATError` — the single root of **all** SDK processing failures (runtime, environment and data-processing failures). The machine-readable code is declared on the root itself: `getCode()` never returns `null` and carries the default `sdk_internal_error` when a failure has no more specific code. Catching this one type therefore covers every processing failure the SDK can raise, LLM and compliance failures included. + - `NegotiationProcessingException` — a negotiation processing failure. - `NegotiationGenerationException` — raised by generation calls. - - `A2ATParamExtractionError` — a parameter-extraction failure carrying a code and structured per-slot errors (`getCode()`, `getErrors()`). + - `A2ATParamExtractionError` — a parameter-extraction failure with structured per-slot errors; `getCode()` is inherited from the root, `getErrors()` returns the slot details. - `NegotiationParamExtractionException` — raised by `validateAndFilling*Data` calls. -- `NegotiationContentException` — a **programming error** (blank context id, null data, malformed template URI, phase or conclusion mismatch). It extends `RuntimeException` directly, not `A2ATError`, and exposes `getField()` pointing at the offending input such as `templateUri` or `context.id`. + - `LLMError` — the LLM integration failure subtree, folded into the `A2ATError` tree. + - `LLMConfigError` — invalid LLM configuration or provider registration. + - `LLMRuntimeError` — an LLM infrastructure failure at call time. + - `PromptComplianceCheckException` — a compliance-check failure carrying the code and the compliance stage (`getStage()`); raised by the server-side compliance pipeline with the codes `processed_prompt_parse_error`, `slot_validation_error` and `template_not_found`. + - The remaining processing failures — prompt generation (`PromptGenerationException`), content validation (`ContentValidationException`), template rendering (`TaskPromptRenderException`), resource resolution (`ResourceNotFoundException`, `ConfigFileNotFoundException`), scenario recognition (`ScenarioRecognitionException`) and negotiation state access (`NegotiationStateException`) — are part of the same tree. + +Programming errors — argument-contract violations by the caller — intentionally stay **outside** the `A2ATError` tree, as standard JDK exceptions, so a generic `catch (A2ATError)` handler cannot swallow them: a null argument raises `NullPointerException`, and a blank, malformed or semantically contradictory argument (blank context id, malformed template URI, phase or conclusion mismatch, unsupported language) raises `IllegalArgumentException`. The former `NegotiationContentException` has been removed; the violations it used to report now surface as these standard exceptions, and each facade method documents them with `@throws` tags. Error codes and their meaning: @@ -535,14 +541,15 @@ Error codes and their meaning: | `negotiation_semantic_rejected` | Semantic validation rejected the message | No | | `negotiation_rule_violation` | The negotiation context section violates a structural rule | No | | `negotiation_slot_missing` | A required slot is missing when rendering | No | -| `negotiation_invalid_input` | The input is not valid for the operation (blank text, not a negotiation message, wrong conclusion) | No | +| `negotiation_invalid_input` | The input is not valid for the operation (blank free text on the from-text generation calls, not a negotiation message, wrong conclusion) | No | | `param_extraction_failed` | Default code for extraction failures without a more specific code | No | +| `sdk_internal_error` | Default code for SDK processing failures without a more specific code | No | Retry semantics: a failing LLM step carrying a retryable code is re-run up to the attempt limit configured by `A2AT_LLM_MAX_ATTEMPTS`; failures carrying any other code are rethrown immediately. When the attempts are exhausted, the original failure is rethrown with its original code. `A2AT_LLM_MAX_ATTEMPTS` defaults to 3 and is clamped to the range 1–10 (out-of-range values are clamped with a warning log). ### 1.10.6 Language Configuration -The negotiation templates and the vocabulary used for rendering and section recognition are keyed by `A2AT_LANGUAGE`. Supported values are exactly `zh-CN` and `en-US`; there is no fallback — any other value fails with a `NegotiationContentException` when the vocabulary is resolved. Set the language explicitly in `client.env` / `server.env`: +The negotiation templates and the vocabulary used for rendering and section recognition are keyed by `A2AT_LANGUAGE`. Supported values are exactly `zh-CN` and `en-US`; there is no fallback — any other value fails with an `IllegalArgumentException` when the vocabulary is resolved. Set the language explicitly in `client.env` / `server.env`: ```properties A2AT_LANGUAGE=zh-CN diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 8c9f2e7e..f13d5cce 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -410,7 +410,7 @@ MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData da MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, String templateUri) ``` -类型化内容先经过校验,再分发给模板 URI 所指协商类型的生成器,并由该模板渲染。accept 变体要求 `content.conclusion() == ACCEPT`,reject 变体要求 `REJECT`;结论不匹配(包括 `ABORT`)属于内容错误。 +类型化内容先经过校验,再分发给模板 URI 所指协商类型的生成器,并由该模板渲染。accept 变体要求 `content.conclusion() == ACCEPT`,reject 变体要求 `REJECT`;结论不匹配(包括 `ABORT`)会以 `IllegalArgumentException`(编程错误)拒绝。 **从自由文本生成 — 一次 LLM 抽取,支持可配置重试:** @@ -457,7 +457,7 @@ Optional getNegotiationPrompt(String templateUri) // 按 URI Negotiation-T/v1/{type}-negotiation/{phase} ``` -其中 `{type}` 为 `information`、`target` 或 `feasibility`,`{phase}` 为 `propose` 或 `accept-reject`。SDK 内置六个模板,每种组合一个,各自提供 `zh-CN` 与 `en-US` 两个语言版本: +其中 `{type}` 为 `information`、`target` 或 `feasibility`,`{phase}` 为 `propose` 或 `accept-reject`。URI 解析绝不抛出异常:`NegotiationReference.tryParse` 返回 `Optional`,null、空白或格式错误的 URI(段数、前缀、版本、类型或 phase 段不匹配)只返回空结果。SDK 内置六个模板,每种组合一个,各自提供 `zh-CN` 与 `en-US` 两个语言版本: | 模板 URI | 用途 | |--|--| @@ -491,7 +491,7 @@ Negotiation-T/v1/{type}-negotiation/{phase} | Target | `TargetProposeContent(description, intentUnderstanding, alignmentAndClarification, requestForClarification)` | `TargetEndingContent(conclusion, confirmedIntent, failureReason)` | | Feasibility | `FeasibilityProposeContent(description, action, contentsToEvaluate, infeasibilityDetailsAndProposal)` | `FeasibilityEndingContent(conclusion, feasibilitySummary)` | -`NegotiationItem(name, value)` 表示条目列表中的一个命名条目。`NegotiationConclusion` 取值为 `ACCEPT`、`REJECT`、`ABORT`;只有 `Accept` 与 `Reject` 可被渲染 — 生成方法会把 `ABORT` 作为编程错误拒绝。`NegotiationAction`(`REQUEST_FEASIBILITY_EVALUATION`、`PROPOSE_ALTERNATIVE_ON_FAILURE`)决定可行性 propose 模板中渲染哪些条件章节。 +`NegotiationItem(name, value)` 表示条目列表中的一个命名条目。`NegotiationConclusion` 取值为 `ACCEPT`、`REJECT`、`ABORT`;只有 `Accept` 与 `Reject` 可被渲染 — 生成方法会以 `IllegalArgumentException`(`A2ATError` 树外的编程错误)拒绝 `ABORT`。`NegotiationAction`(`REQUEST_FEASIBILITY_EVALUATION`、`PROPOSE_ALTERNATIVE_ON_FAILURE`)决定可行性 propose 模板中渲染哪些条件章节。 **MetadataContent** — 生成结果:`record MetadataContent(String templateUri, String promptText, String extensionUri)`。`buildMetadataContent()` 返回恰好两个键:TMF 扩展 URI(`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`)映射到渲染后的消息文本,`template_uri` 映射到模板 URI。这个映射即随 A2A 消息 metadata 传输的内容。 @@ -499,14 +499,20 @@ Negotiation-T/v1/{type}-negotiation/{phase} ### 1.10.5 错误处理 -异常树在 SDK 根类型之下分为两支: +所有 SDK 处理失败共用同一个根类型,编程错误则留在树外: -- `A2ATError` — 整个 SDK 的处理失败根类型。 - - `NegotiationProcessingException` — 携带机器可读错误码的协商处理失败;`getCode()` 返回该错误码。 +- `A2ATError` — **全部** SDK 处理失败(运行期、环境与数据处理失败)的唯一根类型。机器可读错误码声明在根上:`getCode()` 永不返回 `null`,失败没有更具体错误码时携带默认的 `sdk_internal_error`。因此捕获这一个类型即可覆盖 SDK 可能抛出的所有处理失败,包括 LLM 与合规校验失败。 + - `NegotiationProcessingException` — 协商处理失败。 - `NegotiationGenerationException` — 生成调用抛出。 - - `A2ATParamExtractionError` — 携带错误码和结构化槽位错误的参数提取失败(`getCode()`、`getErrors()`)。 + - `A2ATParamExtractionError` — 携带结构化槽位错误的参数提取失败;`getCode()` 继承自根,`getErrors()` 返回槽位详情。 - `NegotiationParamExtractionException` — `validateAndFilling*Data` 调用抛出。 -- `NegotiationContentException` — **编程错误**(上下文 id 为空、data 为 null、模板 URI 格式错误、phase 或结论不匹配)。它直接继承 `RuntimeException` 而非 `A2ATError`,并通过 `getField()` 指向出错输入字段,如 `templateUri` 或 `context.id`。 + - `LLMError` — LLM 集成失败子树,已并入 `A2ATError` 树。 + - `LLMConfigError` — LLM 配置或提供方注册无效。 + - `LLMRuntimeError` — 调用时的 LLM 基础设施故障。 + - `PromptComplianceCheckException` — 携带错误码与合规阶段(`getStage()`)的合规校验失败;由服务端合规流水线抛出,错误码为 `processed_prompt_parse_error`、`slot_validation_error` 与 `template_not_found`。 + - 其余处理失败 — 提示词生成(`PromptGenerationException`)、内容校验(`ContentValidationException`)、模板渲染(`TaskPromptRenderException`)、资源解析(`ResourceNotFoundException`、`ConfigFileNotFoundException`)、场景识别(`ScenarioRecognitionException`)与协商状态访问(`NegotiationStateException`)— 同属这棵树。 + +编程错误 — 调用方的入参契约违反 — 刻意留在 `A2ATError` 树外,以标准 JDK 异常抛出,使通用的 `catch (A2ATError)` 处理器无法吞掉它们:null 入参抛出 `NullPointerException`;空白、格式错误或语义矛盾的入参(上下文 id 为空、模板 URI 格式错误、phase 或结论不匹配、不支持的语言)抛出 `IllegalArgumentException`。原先的 `NegotiationContentException` 已移除;它过去报告的违约现在以这些标准异常形式抛出,各门面方法均以 `@throws` 标签逐一注明。 错误码及其含义: @@ -518,14 +524,15 @@ Negotiation-T/v1/{type}-negotiation/{phase} | `negotiation_semantic_rejected` | 语义校验拒绝了该消息 | 否 | | `negotiation_rule_violation` | 协商上下文章节违反结构规则 | 否 | | `negotiation_slot_missing` | 渲染时缺少必需槽位 | 否 | -| `negotiation_invalid_input` | 输入对当前操作无效(文本为空、不是协商消息、结论错误) | 否 | +| `negotiation_invalid_input` | 输入对当前操作无效(FromText 生成调用的输入文本为空、不是协商消息、结论错误) | 否 | | `param_extraction_failed` | 无更具体错误码时参数提取失败的默认错误码 | 否 | +| `sdk_internal_error` | 无更具体错误码时 SDK 处理失败的默认错误码 | 否 | 重试语义:携带可重试错误码的 LLM 步骤失败后,会重新执行至 `A2AT_LLM_MAX_ATTEMPTS` 配置的尝试上限;携带其他错误码的失败立即原样抛出。尝试次数耗尽时,原始失败以其原始错误码重新抛出。`A2AT_LLM_MAX_ATTEMPTS` 默认值为 3,并被收敛到 1–10 区间(超界值会被收敛并记录 warning 日志)。 ### 1.10.6 语言配置 -协商模板以及渲染和章节识别所用的词表均按 `A2AT_LANGUAGE` 键控。支持的取值精确地为 `zh-CN` 与 `en-US`;没有任何回退 — 其他取值在解析词表时会以 `NegotiationContentException` 失败。请在 `client.env` / `server.env` 中显式设置语言: +协商模板以及渲染和章节识别所用的词表均按 `A2AT_LANGUAGE` 键控。支持的取值精确地为 `zh-CN` 与 `en-US`;没有任何回退 — 其他取值在解析词表时会以 `IllegalArgumentException` 失败。请在 `client.env` / `server.env` 中显式设置语言: ```properties A2AT_LANGUAGE=zh-CN From 96b2715cda2ecee62dc2bb4655a57d0f4f612809 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Fri, 21 Aug 2026 14:50:36 +0800 Subject: [PATCH 071/295] refactor: rename template_uri metadata key to templateUri Signed-off-by: w00609601 --- .../net/openan/a2at/sdk/core/model/MetadataContent.java | 6 +++--- .../net/openan/a2at/sdk/core/model/MetadataContentTest.java | 2 +- .../sdk/negotiation/golden/GoldenFixtureComparisonTest.java | 4 ++-- docs/en/developer_guide.md | 4 ++-- .../zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java index 6076004c..e0734ce1 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java @@ -14,15 +14,15 @@ public record MetadataContent(String templateUri, String promptText, String extensionUri) { /** Metadata key carrying the template URI alongside the message itself. */ - public static final String TEMPLATE_URI_METADATA_KEY = "template_uri"; + public static final String TEMPLATE_URI_METADATA_KEY = "templateUri"; /** * Builds the A2A-T metadata map for this generated message. * *

The returned map contains exactly two keys in a fixed order: the extension URI mapping to the rendered - * message, and {@code template_uri} mapping to the template URI. Repeated calls return equal maps. + * message, and {@code templateUri} mapping to the template URI. Repeated calls return equal maps. * - * @return newly built metadata map with exactly the extension URI and {@code template_uri} keys + * @return newly built metadata map with exactly the extension URI and {@code templateUri} keys */ public Map buildMetadataContent() { Map metadata = new LinkedHashMap<>(); diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java index 0cb09bf2..9e5b315a 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java @@ -67,6 +67,6 @@ void buildMetadataContentNeverReturnsNullEvenWithNullFields() { Map metadata = content.buildMetadataContent(); assertEquals(2, metadata.size()); - assertTrue(metadata.containsKey("template_uri")); + assertTrue(metadata.containsKey(MetadataContent.TEMPLATE_URI_METADATA_KEY)); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java index 06e130e2..61c724c9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java @@ -13,9 +13,9 @@ import java.util.List; import java.util.Map; import java.util.stream.Stream; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; @@ -38,7 +38,7 @@ class GoldenFixtureComparisonTest { private static final String EXTENSION_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1"; - private static final String TEMPLATE_URI_METADATA_KEY = "template_uri"; + private static final String TEMPLATE_URI_METADATA_KEY = MetadataContent.TEMPLATE_URI_METADATA_KEY; static Stream goldenCases() { List cases = new ArrayList<>(); diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 05abe5d8..d819ed9f 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -510,7 +510,7 @@ All types live in `net.openan.a2at.sdk.negotiation.content`. `NegotiationItem(name, value)` is one named entry of an item list. `NegotiationConclusion` carries `ACCEPT`, `REJECT`, and `ABORT`; only `Accept` and `Reject` are renderable — generation methods reject `ABORT` as a programming error. `NegotiationAction` (`REQUEST_FEASIBILITY_EVALUATION`, `PROPOSE_ALTERNATIVE_ON_FAILURE`) selects the conditional sections of the feasibility propose template. -**MetadataContent** — the generation result: `record MetadataContent(String templateUri, String promptText, String extensionUri)`. `buildMetadataContent()` returns exactly two keys: the TMF extension URI (`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`) mapping to the rendered message, and `template_uri` mapping to the template URI. This map is what travels in the A2A message metadata. +**MetadataContent** — the generation result: `record MetadataContent(String templateUri, String promptText, String extensionUri)`. `buildMetadataContent()` returns exactly two keys: the TMF extension URI (`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`) mapping to the rendered message, and `templateUri` mapping to the template URI. This map is what travels in the A2A message metadata. **FilledParamData** — the extraction result: `record FilledParamData(Map data)` holding the context parameters merged with the schema-extracted parameters. @@ -576,7 +576,7 @@ MetadataContent propose = client.generateNegotiationProposePromptFromData( // This two-key map travels in the A2A message metadata. Map metadata = propose.buildMetadataContent(); -// metadata.get("template_uri") -> "Negotiation-T/v1/information-negotiation/propose" +// metadata.get("templateUri") -> "Negotiation-T/v1/information-negotiation/propose" // metadata.get(propose.extensionUri()) -> the rendered message text // --- Server side: validate the received message and extract parameters --- diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 8c9f2e7e..616d6dbf 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -493,7 +493,7 @@ Negotiation-T/v1/{type}-negotiation/{phase} `NegotiationItem(name, value)` 表示条目列表中的一个命名条目。`NegotiationConclusion` 取值为 `ACCEPT`、`REJECT`、`ABORT`;只有 `Accept` 与 `Reject` 可被渲染 — 生成方法会把 `ABORT` 作为编程错误拒绝。`NegotiationAction`(`REQUEST_FEASIBILITY_EVALUATION`、`PROPOSE_ALTERNATIVE_ON_FAILURE`)决定可行性 propose 模板中渲染哪些条件章节。 -**MetadataContent** — 生成结果:`record MetadataContent(String templateUri, String promptText, String extensionUri)`。`buildMetadataContent()` 返回恰好两个键:TMF 扩展 URI(`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`)映射到渲染后的消息文本,`template_uri` 映射到模板 URI。这个映射即随 A2A 消息 metadata 传输的内容。 +**MetadataContent** — 生成结果:`record MetadataContent(String templateUri, String promptText, String extensionUri)`。`buildMetadataContent()` 返回恰好两个键:TMF 扩展 URI(`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`)映射到渲染后的消息文本,`templateUri` 映射到模板 URI。这个映射即随 A2A 消息 metadata 传输的内容。 **FilledParamData** — 提取结果:`record FilledParamData(Map data)`,持有上下文参数与按 Schema 提取的参数合并后的结果。 @@ -559,7 +559,7 @@ MetadataContent propose = client.generateNegotiationProposePromptFromData( // 这个两键映射随 A2A 消息 metadata 传输。 Map metadata = propose.buildMetadataContent(); -// metadata.get("template_uri") -> "Negotiation-T/v1/information-negotiation/propose" +// metadata.get("templateUri") -> "Negotiation-T/v1/information-negotiation/propose" // metadata.get(propose.extensionUri()) -> 渲染后的消息文本 // --- 服务端:校验收到的消息并提取参数 --- From 25a08764f8fb4deb868162eb35eaaae4e71fa965 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 21 Aug 2026 16:35:05 +0800 Subject: [PATCH 072/295] feat(core): add TemplateUri value type and StandardTemplates constants - TemplateUri: structured always-valid template identifier composed of extensionName, version and trailing segments; of() validates segments, parse() mirrors NegotiationReference.tryParse semantics, withLanguage() binds runtime language into a TemplateReference - StandardTemplates: 11 built-in template constants in the spirit of java.nio.charset.StandardCharsets, with TASK/NOTIFICATION/AUTHORIZATION/ NEGOTIATION group views - TemplateReference: rename extensionPrefix() to extensionName() and move SimpleTemplateReference from DefaultContentValidator into core - lock constants to bundled resource tree (a2a-t-resources) and to NegotiationReference composition (a2a-t-negotiation) via consistency tests Signed-off-by: codetalks-hw --- .../validation/SimpleTemplateReference.java | 13 ++ .../core/validation/StandardTemplates.java | 80 +++++++++++ .../core/validation/TemplateReference.java | 6 +- .../a2at/sdk/core/validation/TemplateUri.java | 125 ++++++++++++++++++ .../sdk/core/validation/TemplateUriTest.java | 79 +++++++++++ .../resources/NegotiationReference.java | 2 +- ...rdTemplatesNegotiationConsistencyTest.java | 52 ++++++++ .../validation/DefaultContentValidator.java | 23 ++-- .../validation/DefaultSemanticValidator.java | 2 +- .../sdk/resources/StandardTemplatesTest.java | 81 ++++++++++++ 10 files changed, 444 insertions(+), 19 deletions(-) create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SimpleTemplateReference.java create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java create mode 100644 a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java create mode 100644 a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/StandardTemplatesTest.java diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SimpleTemplateReference.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SimpleTemplateReference.java new file mode 100644 index 00000000..c2f127a3 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SimpleTemplateReference.java @@ -0,0 +1,13 @@ +package net.openan.a2at.sdk.core.validation; + +/** + * Plain data carrier implementing {@link TemplateReference} for templates that have no richer reference type of + * their own (Task-T, Notification-T, Authorization-T and language-bound {@link TemplateUri} values). + * + * @param uri template URI such as {@code Task-T/v1/energy-saving} + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @param extensionName extension name identifying the template family, such as {@code Task-T} + * @since 2026-08 + */ +public record SimpleTemplateReference(String uri, String language, String extensionName) + implements TemplateReference {} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java new file mode 100644 index 00000000..9978561c --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java @@ -0,0 +1,80 @@ +package net.openan.a2at.sdk.core.validation; + +import java.util.List; + +/** + * Constants for the built-in content templates shipped with the SDK, in the spirit of + * {@code java.nio.charset.StandardCharsets}. + * + *

Each constant is a language-neutral {@link TemplateUri}; the language is global prompt runtime context and is + * bound by the SDK, not by the caller. Use these constants instead of hand-written URI strings to get compile-time + * safety against spelling drift. + * + *

Example: {@code StandardTemplates.ENERGY_SAVING.uri()} is {@code Task-T/v1/energy-saving}. + * + * @since 2026-08 + */ +public final class StandardTemplates { + + private StandardTemplates() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** Task-T template for the energy-saving scenario. */ + public static final TemplateUri ENERGY_SAVING = TemplateUri.of("Task-T", "v1", "energy-saving"); + + /** Task-T template for the private-line-complaint scenario. */ + public static final TemplateUri PRIVATE_LINE_COMPLAINT = + TemplateUri.of("Task-T", "v1", "private-line-complaint"); + + /** Notification-T template for the subscribe-incident scenario. */ + public static final TemplateUri SUBSCRIBE_INCIDENT = TemplateUri.of("Notification-T", "v1", "subscribe-incident"); + + /** Notification-T template for the service-recovery scenario. */ + public static final TemplateUri SERVICE_RECOVERY = TemplateUri.of("Notification-T", "v1", "service-recovery"); + + /** Authorization-T template for the authz-policy-mgr scenario. */ + public static final TemplateUri AUTHZ_POLICY_MGR = TemplateUri.of("Authorization-T", "v1", "authz-policy-mgr"); + + /** Negotiation-T propose template for information negotiation. */ + public static final TemplateUri INFORMATION_NEGOTIATION_PROPOSE = + TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose"); + + /** Negotiation-T accept-reject template for information negotiation. */ + public static final TemplateUri INFORMATION_NEGOTIATION_ACCEPT_REJECT = + TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "accept-reject"); + + /** Negotiation-T propose template for target negotiation. */ + public static final TemplateUri TARGET_NEGOTIATION_PROPOSE = + TemplateUri.of("Negotiation-T", "v1", "target-negotiation", "propose"); + + /** Negotiation-T accept-reject template for target negotiation. */ + public static final TemplateUri TARGET_NEGOTIATION_ACCEPT_REJECT = + TemplateUri.of("Negotiation-T", "v1", "target-negotiation", "accept-reject"); + + /** Negotiation-T propose template for feasibility negotiation. */ + public static final TemplateUri FEASIBILITY_NEGOTIATION_PROPOSE = + TemplateUri.of("Negotiation-T", "v1", "feasibility-negotiation", "propose"); + + /** Negotiation-T accept-reject template for feasibility negotiation. */ + public static final TemplateUri FEASIBILITY_NEGOTIATION_ACCEPT_REJECT = + TemplateUri.of("Negotiation-T", "v1", "feasibility-negotiation", "accept-reject"); + + /** All built-in Task-T templates. */ + public static final List TASK = List.of(ENERGY_SAVING, PRIVATE_LINE_COMPLAINT); + + /** All built-in Notification-T templates. */ + public static final List NOTIFICATION = List.of(SUBSCRIBE_INCIDENT, SERVICE_RECOVERY); + + /** All built-in Authorization-T templates. */ + public static final List AUTHORIZATION = List.of(AUTHZ_POLICY_MGR); + + /** All built-in Negotiation-T templates. */ + public static final List NEGOTIATION = List.of( + INFORMATION_NEGOTIATION_PROPOSE, + INFORMATION_NEGOTIATION_ACCEPT_REJECT, + TARGET_NEGOTIATION_PROPOSE, + TARGET_NEGOTIATION_ACCEPT_REJECT, + FEASIBILITY_NEGOTIATION_PROPOSE, + FEASIBILITY_NEGOTIATION_ACCEPT_REJECT); +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateReference.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateReference.java index 1c61d24a..7d27e0c6 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateReference.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateReference.java @@ -22,9 +22,9 @@ public interface TemplateReference { String language(); /** - * Returns the extension prefix identifying the template family. + * Returns the extension name identifying the template family — the first segment of the template URI. * - * @return extension prefix + * @return extension name such as {@code Task-T} */ - String extensionPrefix(); + String extensionName(); } \ No newline at end of file diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java new file mode 100644 index 00000000..488c7611 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java @@ -0,0 +1,125 @@ +package net.openan.a2at.sdk.core.validation; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.resources.PathSegments; + +/** + * Structured, always-valid identifier of a content template. + * + *

A template URI is composed of an extension name ({@code Task-T}, {@code Notification-T}, {@code Negotiation-T}, + * {@code Authorization-T}), a version segment and at least one trailing segment (a scenario code, or the type and + * phase segments of a negotiation template). Constructing a {@code TemplateUri} validates every segment, so a value + * of this type can never carry a malformed URI — the structural invariants that validators previously re-derived by + * splitting raw strings are encoded in the type itself. + * + *

The language is deliberately not part of the URI: it is runtime context resolved from the global prompt + * configuration, bound into a {@link TemplateReference} only when a concrete reference is needed. + * + * @param extensionName first URI segment identifying the template family, such as {@code Task-T} + * @param version second URI segment, such as {@code v1} + * @param segments trailing URI segments, such as {@code [energy-saving]} or {@code [information-negotiation, propose]} + * @since 2026-08 + */ +public record TemplateUri(String extensionName, String version, List segments) { + + /** + * Validates the components and defensively copies the segment list. + * + * @throws NullPointerException if the extension name, version or segment list is null + * @throws IllegalArgumentException if any component is not a simple path segment or the segment list is empty + */ + public TemplateUri { + validateSegment(extensionName, "Extension name"); + validateSegment(version, "Version"); + Objects.requireNonNull(segments, "Template URI segments must not be null."); + if (segments.isEmpty()) { + throw new IllegalArgumentException("Template URI must have at least one trailing segment."); + } + for (String segment : segments) { + validateSegment(segment, "Template URI segment"); + } + segments = List.copyOf(segments); + } + + /** + * Creates a template URI from its components. + * + * @param extensionName first URI segment identifying the template family, such as {@code Task-T} + * @param version second URI segment, such as {@code v1} + * @param segments trailing URI segments, such as {@code energy-saving} + * @return validated template URI + * @throws NullPointerException if any component is null + * @throws IllegalArgumentException if any component is not a simple path segment or no trailing segment is given + */ + public static TemplateUri of(String extensionName, String version, String... segments) { + return new TemplateUri(extensionName, version, List.of(segments)); + } + + /** + * Tries to parse a raw template URI into its components. + * + * @param templateUri template URI such as {@code Task-T/v1/energy-saving} + * @return parsed template URI, or an empty result when the input is null, blank, has fewer than three segments or + * contains a segment that is not a simple path segment + */ + public static Optional parse(String templateUri) { + if (templateUri == null || templateUri.isBlank()) { + return Optional.empty(); + } + String[] parts = templateUri.strip().split("/"); + if (parts.length < 3) { + return Optional.empty(); + } + for (String part : parts) { + if (!PathSegments.isSimpleSegment(part)) { + return Optional.empty(); + } + } + return Optional.of(new TemplateUri(parts[0], parts[1], List.of(parts).subList(2, parts.length))); + } + + /** + * Returns the raw template URI. + * + * @return template URI such as {@code Negotiation-T/v1/feasibility-negotiation/propose} + */ + public String uri() { + return Stream.concat(Stream.of(extensionName, version), segments.stream()) + .collect(Collectors.joining("/")); + } + + /** + * Returns the extension prefix identifying the template family — the first URI segment, kept as a derived view for + * callers using the legacy term. + * + * @return extension prefix such as {@code Task-T} + */ + public String extensionPrefix() { + return extensionName; + } + + /** + * Binds the runtime language into a concrete template reference. + * + *

The language is global prompt runtime context, so callers normally pass this URI as-is; binding happens + * inside the SDK at the point where a language-carrying reference is required. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @return template reference carrying this URI and the given language + */ + public TemplateReference withLanguage(String language) { + return new SimpleTemplateReference(uri(), language, extensionName); + } + + private static void validateSegment(String value, String label) { + Objects.requireNonNull(value, label + " must not be null."); + if (!PathSegments.isSimpleSegment(value)) { + throw new IllegalArgumentException( + label + " must be a non-blank simple path segment but was " + value + "."); + } + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java new file mode 100644 index 00000000..2c715998 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java @@ -0,0 +1,79 @@ +package net.openan.a2at.sdk.core.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class TemplateUriTest { + + @Test + void composesUriFromComponents() { + TemplateUri uri = TemplateUri.of("Task-T", "v1", "energy-saving"); + assertEquals("Task-T/v1/energy-saving", uri.uri()); + assertEquals("Task-T", uri.extensionName()); + assertEquals("v1", uri.version()); + assertEquals(List.of("energy-saving"), uri.segments()); + } + + @Test + void extensionPrefixIsDerivedViewOfFirstSegment() { + TemplateUri uri = TemplateUri.of("Negotiation-T", "v1", "feasibility-negotiation", "propose"); + assertEquals("Negotiation-T", uri.extensionPrefix()); + assertEquals(uri.extensionName(), uri.extensionPrefix()); + } + + @Test + void parseRoundTripsSingleTrailingSegment() { + Optional parsed = TemplateUri.parse("Task-T/v1/energy-saving"); + assertEquals(Optional.of(TemplateUri.of("Task-T", "v1", "energy-saving")), parsed); + assertEquals("Task-T/v1/energy-saving", parsed.orElseThrow().uri()); + } + + @Test + void parseRoundTripsTwoTrailingSegments() { + Optional parsed = TemplateUri.parse("Negotiation-T/v1/feasibility-negotiation/propose"); + assertEquals( + Optional.of(TemplateUri.of("Negotiation-T", "v1", "feasibility-negotiation", "propose")), parsed); + } + + @Test + void parseRejectsMalformedUris() { + assertEquals(Optional.empty(), TemplateUri.parse(null)); + assertEquals(Optional.empty(), TemplateUri.parse(" ")); + assertEquals(Optional.empty(), TemplateUri.parse("Task-T")); + assertEquals(Optional.empty(), TemplateUri.parse("Task-T/v1")); + assertEquals(Optional.empty(), TemplateUri.parse("Task-T/v1/energy-saving/../etc")); + assertEquals(Optional.empty(), TemplateUri.parse("Task-T/v1/energy\\saving")); + assertEquals(Optional.empty(), TemplateUri.parse("Task-T//energy-saving")); + } + + @Test + void ofRejectsInvalidComponents() { + assertThrows(NullPointerException.class, () -> TemplateUri.of(null, "v1", "scenario")); + assertThrows(NullPointerException.class, () -> TemplateUri.of("Task-T", null, "scenario")); + assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "v1")); + assertThrows(NullPointerException.class, () -> new TemplateUri("Task-T", "v1", (List) null)); + assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "v1", "scen/ario")); + assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "v1", "scen..ario/x")); + assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "v1", " ")); + } + + @Test + void withLanguageBindsRuntimeLanguage() { + TemplateReference reference = TemplateUri.of("Task-T", "v1", "energy-saving").withLanguage("zh-CN"); + assertEquals("Task-T/v1/energy-saving", reference.uri()); + assertEquals("zh-CN", reference.language()); + assertEquals("Task-T", reference.extensionName()); + } + + @Test + void segmentsAreDefensivelyCopied() { + java.util.ArrayList segments = new java.util.ArrayList<>(List.of("energy-saving")); + TemplateUri uri = new TemplateUri("Task-T", "v1", segments); + segments.add("tampered"); + assertEquals(List.of("energy-saving"), uri.segments()); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java index 6ec488d5..10bf1148 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -62,7 +62,7 @@ public String uri() { } @Override - public String extensionPrefix() { + public String extensionName() { return URI_PREFIX; } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java new file mode 100644 index 00000000..13ce6548 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java @@ -0,0 +1,52 @@ +package net.openan.a2at.sdk.negotiation.resources; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import org.junit.jupiter.api.Test; + +/** + * Locks the two sources of negotiation template URI spelling together: the constants in {@link StandardTemplates} + * (a2a-t-core, cannot depend on this module) and the compositional logic in {@link NegotiationReference} backed by + * the {@link NegotiationType}/{@link NegotiationPhase} enums. Any drift turns this test red. + */ +class StandardTemplatesNegotiationConsistencyTest { + + @Test + void negotiationConstantsMatchReferenceComposition() { + for (NegotiationType type : NegotiationType.values()) { + for (NegotiationPhase phase : NegotiationPhase.values()) { + NegotiationReference reference = new NegotiationReference(type, phase, "en-US"); + TemplateUri expected = findConstant(reference.uri()); + assertEquals( + reference.uri(), + expected.uri(), + "StandardTemplates has no constant matching the composed URI of " + type + "/" + phase); + } + } + } + + @Test + void negotiationGroupCoversExactlyTheComposedUris() { + List composed = + StandardTemplates.NEGOTIATION.stream().map(TemplateUri::uri).sorted().toList(); + List expected = new java.util.ArrayList<>(); + for (NegotiationType type : NegotiationType.values()) { + for (NegotiationPhase phase : List.of(NegotiationPhase.PROPOSE, NegotiationPhase.ACCEPT)) { + expected.add(new NegotiationReference(type, phase, "en-US").uri()); + } + } + assertEquals(expected.stream().sorted().toList(), composed); + } + + private static TemplateUri findConstant(String uri) { + return StandardTemplates.NEGOTIATION.stream() + .filter(template -> template.uri().equals(uri)) + .findFirst() + .orElseThrow(() -> new AssertionError("No StandardTemplates constant for URI " + uri)); + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java index f135d5af..9bd7f636 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -4,6 +4,7 @@ import java.util.Objects; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.core.validation.SimpleTemplateReference; import net.openan.a2at.sdk.core.validation.TemplateReference; import net.openan.a2at.sdk.core.validation.ValidationPipeline; import net.openan.a2at.sdk.llm.LLMClient; @@ -21,7 +22,7 @@ */ public final class DefaultContentValidator implements ContentValidator { - private final String extensionPrefix; + private final String extensionName; private final String language; private final int maxAttempts; private final LLMClient llmClient; @@ -30,24 +31,24 @@ public final class DefaultContentValidator implements ContentValidator { private volatile ValidationPipeline pipeline; /** - * Creates a content validator for the given extension prefix and language. + * Creates a content validator for the given extension name and language. * *

The constructor does not load any prompt resources. The underlying semantic validator and its prompt resources * are loaded on the first {@link #validate} call. * - * @param extensionPrefix extension prefix used for template URI validation + * @param extensionName extension name used for template URI validation * @param language language code for prompt resource loading * @param maxAttempts maximum retry attempts for semantic validation * @param llmClient LLM client for semantic validation; may be {@code null} and set later * @param promptResourceAccess prompt resource access for loading validation prompts */ public DefaultContentValidator( - String extensionPrefix, + String extensionName, String language, int maxAttempts, LLMClient llmClient, PromptResourceAccess promptResourceAccess) { - this.extensionPrefix = extensionPrefix; + this.extensionName = extensionName; this.language = language; this.maxAttempts = maxAttempts; this.llmClient = llmClient; @@ -69,11 +70,10 @@ public FilledParamData validate(String prompt, Map schema, Strin String prefix = parts[0]; String version = parts[1]; - String scenario = parts[2]; - if (!extensionPrefix.equals(prefix)) { + if (!extensionName.equals(prefix)) { throw new IllegalArgumentException( - "Template URI prefix '" + prefix + "' does not match expected extension prefix '" + extensionPrefix + "Template URI prefix '" + prefix + "' does not match expected extension prefix '" + extensionName + "'."); } @@ -81,7 +81,7 @@ public FilledParamData validate(String prompt, Map schema, Strin throw new IllegalArgumentException("Unsupported template URI version: " + version); } - TemplateReference reference = new SimpleTemplateReference(templateUri, language, extensionPrefix); + TemplateReference reference = new SimpleTemplateReference(templateUri, language, extensionName); return pipeline().validate(prompt, schema, reference); } @@ -101,9 +101,4 @@ private ValidationPipeline pipeline() { } return p; } - - /** - * Package-private template reference implementation. - */ - record SimpleTemplateReference(String uri, String language, String extensionPrefix) implements TemplateReference {} } \ No newline at end of file diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index 15fa8ab9..e5173436 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -102,7 +102,7 @@ private String fillUserPrompt(String prompt, Map schema, Templat } return userPromptTemplate - .replaceAll("\\[extension_name\\]", Matcher.quoteReplacement(reference.extensionPrefix())) + .replaceAll("\\[extension_name\\]", Matcher.quoteReplacement(reference.extensionName())) .replaceAll("\\[input\\]", Matcher.quoteReplacement(prompt)) .replaceAll("\\[template_uri\\]", Matcher.quoteReplacement(reference.uri())) .replaceAll("\\[schema\\]", Matcher.quoteReplacement(schemaJson)); diff --git a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/StandardTemplatesTest.java b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/StandardTemplatesTest.java new file mode 100644 index 00000000..11a24fb1 --- /dev/null +++ b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/StandardTemplatesTest.java @@ -0,0 +1,81 @@ +package net.openan.a2at.sdk.resources; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; +import org.junit.jupiter.api.Test; + +/** + * Locks the {@link StandardTemplates} constants to the template tree bundled in this module: adding or removing a + * {@code template.md} under {@code prompt_resources/templates} without updating the constants (or vice versa) turns + * this test red. + * + *

This test lives in a2a-t-resources because downstream modules ship their own {@code prompt_resources} test + * fixtures that would shadow this module's tree on the classpath. + */ +class StandardTemplatesTest { + + @Test + void constantsMatchBundledTemplateTree() throws Exception { + Set constantUris = new TreeSet<>(); + Stream.of(StandardTemplates.TASK, StandardTemplates.NOTIFICATION, StandardTemplates.AUTHORIZATION, + StandardTemplates.NEGOTIATION) + .flatMap(List::stream) + .map(TemplateUri::uri) + .forEach(constantUris::add); + + assertEquals( + constantUris, + scanTemplateTree(), + "StandardTemplates constants and the bundled prompt_resources/templates tree have drifted"); + } + + @Test + void groupsPartitionAllConstants() { + List grouped = new ArrayList<>(); + grouped.addAll(StandardTemplates.TASK); + grouped.addAll(StandardTemplates.NOTIFICATION); + grouped.addAll(StandardTemplates.AUTHORIZATION); + grouped.addAll(StandardTemplates.NEGOTIATION); + assertEquals(11, grouped.size(), "expected the 11 built-in templates"); + assertEquals(2, StandardTemplates.TASK.size()); + assertEquals(2, StandardTemplates.NOTIFICATION.size()); + assertEquals(1, StandardTemplates.AUTHORIZATION.size()); + assertEquals(6, StandardTemplates.NEGOTIATION.size()); + } + + /** + * Collects the template URIs present under {@code prompt_resources/templates} by stripping the trailing + * {@code /template.md} segments from every {@code template.md} file. + */ + private static Set scanTemplateTree() throws IOException, java.net.URISyntaxException { + URL root = StandardTemplatesTest.class.getClassLoader().getResource("prompt_resources/templates"); + if (root == null || !"file".equals(root.getProtocol())) { + // Directory walking only works on exploded classpaths; Maven test runs always satisfy this. + throw new IllegalStateException("prompt_resources/templates not reachable as a file URL: " + root); + } + Path rootPath = Path.of(root.toURI()); + try (Stream files = Files.walk(rootPath)) { + Set uris = new TreeSet<>(); + files.filter(Files::isRegularFile) + .filter(file -> "template.md".equals(file.getFileName().toString())) + .forEach(file -> { + Path relative = rootPath.relativize(file); + int segmentCount = relative.getNameCount(); + // relative = //template.md + uris.add(relative.subpath(0, segmentCount - 2).toString().replace('\\', '/')); + }); + return uris; + } + } +} From 6fe12d3fba622f6aea0274f6b72b05abf7b1a197 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Fri, 21 Aug 2026 16:38:13 +0800 Subject: [PATCH 073/295] fix(profile): include JDK 17 in old_jdk_support activation for error-prone compatibility The old_jdk_support profile lowers error-prone to 2.42.0 + nullaway 0.12.10 for older JDKs, but its activation boundary (17, 21] excluded JDK 17 itself. As a result JDK 17 fell back to the default error-prone 2.50.0, whose class files target Java 21 (version 65), so javac failed to load the annotation processor via ServiceLoader (ClassLoader.defineClass1). Switch the boundary to the closed interval [17, 21] so JDK 17 activates the downgrade and installs cleanly. Signed-off-by: ZhouJie6 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 4b134693..2dcd95ac 100644 --- a/pom.xml +++ b/pom.xml @@ -377,7 +377,7 @@ --> old_jdk_support - (17, 21] + [17, 21] 2.42.0 From e114d5f4ddd415152cb937bfba98e530f3eec8e3 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 21 Aug 2026 16:54:49 +0800 Subject: [PATCH 074/295] refactor(templates): converge hardcoded template URI strings onto StandardTemplates - main code: DefaultA2ATServerBuilder validator prefixes, loader KNOWN_TEMPLATE_TYPES/KNOWN_SLOT_TYPES lists and NegotiationReference URI_PREFIX now derive from StandardTemplates *_EXTENSION_NAME constants (probe order preserved); core is the single source of extension names - tests: ~90 hardcoded builtin template URI literals across a2a-t-client, a2a-t-server, a2a-t-negotiation, a2a-t-prompt, a2a-t-core and a2a-t-sample replaced with StandardTemplates.XXX.uri() references; local *_URI test constants keep their names, only initializers swapped - negative/malformed URI fixtures, non-builtin fixture URIs and compositional URI constructions intentionally left as raw strings - javadoc value-documentation literals kept raw (they describe what the value is, not how to use it) Signed-off-by: codetalks-hw --- .../api/A2ATClientNegotiationApiTest.java | 5 +- .../A2ATClientNegotiationEnvConfigTest.java | 3 +- .../a2at/sdk/client/api/A2ATClientTest.java | 19 +++---- ...lientPromptGenerationOrchestratorTest.java | 35 ++++++------- .../core/validation/StandardTemplates.java | 37 +++++++++----- .../sdk/core/model/MetadataContentTest.java | 3 +- .../resources/NegotiationReference.java | 3 +- .../NegotiationConcurrentMixedLoadTest.java | 3 +- ...ConclusionLiteralAndExceptionSlotTest.java | 19 +++---- .../ConditionalSectionRenderingTest.java | 5 +- .../FromDataProgrammingErrorMatrixTest.java | 13 ++--- ...nerationOrchestratorBuilderWiringTest.java | 3 +- ...NegotiationGenerationOrchestratorTest.java | 7 +-- .../generation/NegotiationGeneratorsTest.java | 49 ++++++++++--------- .../NegotiationPromptResourceLoaderTest.java | 3 +- .../NegotiationTemplateQueryBoundaryTest.java | 17 ++++--- .../RelationshipLineRenderingTest.java | 3 +- .../TemplateUriEntryMatrixTest.java | 3 +- .../ValidateAndFillingDataPipelineTest.java | 7 +-- .../CustomRootPromptsIgnoredTest.java | 3 +- .../NegotiationLanguageSwitchTest.java | 3 +- .../NegotiationErrorCodeUsageMatrixTest.java | 5 +- ...NegotiationExceptionTreeRootCatchTest.java | 3 +- .../NegotiationLogEventContractTest.java | 3 +- ...NegotiationPublicExceptionSurfaceTest.java | 3 +- .../DefaultNegotiationTemplateLoaderTest.java | 17 ++++--- .../resources/NegotiationReferenceTest.java | 11 +++-- .../BilateralInteropScenarioTest.java | 5 +- .../FeasibilityNegotiationScenarioTest.java | 5 +- .../InformationNegotiationScenarioTest.java | 5 +- .../MaxRoundsExhaustionScenarioTest.java | 3 +- .../TargetNegotiationScenarioTest.java | 5 +- ...faultNegotiationSemanticValidatorTest.java | 3 +- .../ClasspathPromptSlotSchemaLoader.java | 4 +- .../loader/ClasspathPromptTemplateLoader.java | 4 +- .../NegotiationFacadeOutputSymmetryTest.java | 19 +++---- .../assembly/DefaultA2ATServerBuilder.java | 7 +-- .../api/A2ATServerNegotiationApiTest.java | 7 ++- .../TaskPromptNegotiationCoexistenceTest.java | 4 +- 39 files changed, 207 insertions(+), 149 deletions(-) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index 5c057d37..c835d2bb 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -20,6 +20,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -36,7 +37,7 @@ static void registerMockProvider() { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); @Test void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { @@ -87,7 +88,7 @@ void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { A2ATClient client = new A2ATClient(writeEnv("zh-CN")); assertTrue(client.getNegotiationPrompt(INFORMATION_PROPOSE_URI).isPresent()); - assertTrue(client.getNegotiationPrompt("Negotiation-T/v1/feasibility-negotiation/accept-reject") + assertTrue(client.getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) .isPresent()); assertFalse(client.getNegotiationPrompt("not-a-template-uri").isPresent()); PromptTemplate template = diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index 61185309..1d79ce37 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -29,6 +29,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -56,7 +57,7 @@ static void registerMockProvider() { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); private static final String CUSTOM_TEMPLATE_MARKER = "CUSTOM-TEMPLATE-MARKER-7d31"; diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index eff25cb1..4bfbf73e 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -17,6 +17,7 @@ import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; @@ -282,24 +283,24 @@ void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { Map schema = Map.of("type", "object"); MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); + client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING.uri()); MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); + client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING.uri()); MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); + client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING.uri()); assertNotNull(taskResult); - assertEquals("Task-T/v1/energy-saving", taskResult.templateUri()); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), taskResult.templateUri()); assertNotNull(taskResult.promptText()); assertNotNull(taskResult.extensionUri()); assertNotNull(authResult); - assertEquals("Task-T/v1/energy-saving", authResult.templateUri()); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), authResult.templateUri()); assertNotNull(authResult.promptText()); assertNotNull(authResult.extensionUri()); assertNotNull(notificationResult); - assertEquals("Task-T/v1/energy-saving", notificationResult.templateUri()); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), notificationResult.templateUri()); assertNotNull(notificationResult.promptText()); assertNotNull(notificationResult.extensionUri()); } @@ -329,11 +330,11 @@ void fromDataWithSchemaReturnsCorrectExtensionUriPerContentType() throws IOExcep Map schema = Map.of("type", "object"); MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); + client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING.uri()); MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); + client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING.uri()); MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, "Task-T/v1/energy-saving"); + client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING.uri()); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index da5cf871..f997404d 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -19,6 +19,7 @@ import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.llm.LLMRuntimeError; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; @@ -305,9 +306,9 @@ void generateFromTemplateUriWithMetadataReturnsMetadataContentOnSuccess() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent result = orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy-saving"); + MetadataContent result = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri()); - assertEquals("Task-T/v1/energy-saving", result.templateUri()); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), result.templateUri()); assertEquals("Site: Site A", result.promptText()); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); } @@ -319,7 +320,7 @@ void generateFromTemplateUriWithMetadataScenarioRecognizerIsNotInvoked() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); - orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy-saving"); + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri()); assertEquals(0, recognizer.invocationCount); } @@ -409,9 +410,9 @@ void generateFromDataWithSchemaReturnsMetadataContentOnSuccess() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), "Task-T/v1/energy-saving"); + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING.uri()); - assertEquals("Task-T/v1/energy-saving", result.templateUri()); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), result.templateUri()); assertEquals("Site: Site A", result.promptText()); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); } @@ -425,10 +426,10 @@ void generateFromDataWithSchemaPassesSchemaToExtractor() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); Map schema = Map.of("site", "string", "count", "number"); - orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), schema, "Task-T/v1/energy-saving"); + orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), schema, StandardTemplates.ENERGY_SAVING.uri()); assertEquals(schema, slotValueExtractor.lastSchema); - assertEquals("Task-T/v1/energy-saving", slotValueExtractor.lastScenarioCode); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), slotValueExtractor.lastScenarioCode); assertEquals("Site: {site}", slotValueExtractor.lastTemplateText); } @@ -441,7 +442,7 @@ void generateFromDataWithSchemaNullSchemaHandledGracefully() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), null, "Task-T/v1/energy-saving"); + Map.of("site", "Site A"), null, StandardTemplates.ENERGY_SAVING.uri()); assertEquals("Site: Site A", result.promptText()); assertEquals(null, slotValueExtractor.lastSchema); @@ -456,7 +457,7 @@ void generateFromDataWithSchemaEmptySchemaHandledSameAsNull() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy-saving"); + Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); assertEquals("Site: Site A", result.promptText()); assertEquals(Map.of(), slotValueExtractor.lastSchema); @@ -541,15 +542,15 @@ void eachMetadataEntryPointReturnsCorrectMetadataContent() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy-saving"); + MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri()); MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy-saving"); + Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "Authorization-T/v1/database_read"); MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( Map.of("site", "Site A"), Map.of(), "Authorization-T/v1/database_read"); - MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", "Task-T/v1/energy-saving"); + MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING.uri()); MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy-saving"); + Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); assertTrue(taskText.promptText().contains("Site A")); assertTrue(taskData.promptText().contains("Site A")); @@ -565,15 +566,15 @@ void eachMetadataEntryPointUsesCorrectExtensionUri() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( new RecordingScenarioRecognizer(), templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); - MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy-saving"); + MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri()); MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy-saving"); + Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "Authorization-T/v1/database_read"); MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( Map.of("site", "Site A"), Map.of(), "Authorization-T/v1/database_read"); - MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", "Task-T/v1/energy-saving"); + MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING.uri()); MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy-saving"); + Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskText.extensionUri()); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskData.extensionUri()); diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java index 9978561c..c5aa99cb 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java @@ -20,45 +20,60 @@ private StandardTemplates() { throw new UnsupportedOperationException("Utility class - do not instantiate"); } + /** Extension name of the Task-T template family. */ + public static final String TASK_EXTENSION_NAME = "Task-T"; + + /** Extension name of the Notification-T template family. */ + public static final String NOTIFICATION_EXTENSION_NAME = "Notification-T"; + + /** Extension name of the Authorization-T template family. */ + public static final String AUTHORIZATION_EXTENSION_NAME = "Authorization-T"; + + /** Extension name of the Negotiation-T template family. */ + public static final String NEGOTIATION_EXTENSION_NAME = "Negotiation-T"; + /** Task-T template for the energy-saving scenario. */ - public static final TemplateUri ENERGY_SAVING = TemplateUri.of("Task-T", "v1", "energy-saving"); + public static final TemplateUri ENERGY_SAVING = TemplateUri.of(TASK_EXTENSION_NAME, "v1", "energy-saving"); /** Task-T template for the private-line-complaint scenario. */ public static final TemplateUri PRIVATE_LINE_COMPLAINT = - TemplateUri.of("Task-T", "v1", "private-line-complaint"); + TemplateUri.of(TASK_EXTENSION_NAME, "v1", "private-line-complaint"); /** Notification-T template for the subscribe-incident scenario. */ - public static final TemplateUri SUBSCRIBE_INCIDENT = TemplateUri.of("Notification-T", "v1", "subscribe-incident"); + public static final TemplateUri SUBSCRIBE_INCIDENT = + TemplateUri.of(NOTIFICATION_EXTENSION_NAME, "v1", "subscribe-incident"); /** Notification-T template for the service-recovery scenario. */ - public static final TemplateUri SERVICE_RECOVERY = TemplateUri.of("Notification-T", "v1", "service-recovery"); + public static final TemplateUri SERVICE_RECOVERY = + TemplateUri.of(NOTIFICATION_EXTENSION_NAME, "v1", "service-recovery"); /** Authorization-T template for the authz-policy-mgr scenario. */ - public static final TemplateUri AUTHZ_POLICY_MGR = TemplateUri.of("Authorization-T", "v1", "authz-policy-mgr"); + public static final TemplateUri AUTHZ_POLICY_MGR = + TemplateUri.of(AUTHORIZATION_EXTENSION_NAME, "v1", "authz-policy-mgr"); /** Negotiation-T propose template for information negotiation. */ public static final TemplateUri INFORMATION_NEGOTIATION_PROPOSE = - TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "v1", "information-negotiation", "propose"); /** Negotiation-T accept-reject template for information negotiation. */ public static final TemplateUri INFORMATION_NEGOTIATION_ACCEPT_REJECT = - TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "accept-reject"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "v1", "information-negotiation", "accept-reject"); /** Negotiation-T propose template for target negotiation. */ public static final TemplateUri TARGET_NEGOTIATION_PROPOSE = - TemplateUri.of("Negotiation-T", "v1", "target-negotiation", "propose"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "v1", "target-negotiation", "propose"); /** Negotiation-T accept-reject template for target negotiation. */ public static final TemplateUri TARGET_NEGOTIATION_ACCEPT_REJECT = - TemplateUri.of("Negotiation-T", "v1", "target-negotiation", "accept-reject"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "v1", "target-negotiation", "accept-reject"); /** Negotiation-T propose template for feasibility negotiation. */ public static final TemplateUri FEASIBILITY_NEGOTIATION_PROPOSE = - TemplateUri.of("Negotiation-T", "v1", "feasibility-negotiation", "propose"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "v1", "feasibility-negotiation", "propose"); /** Negotiation-T accept-reject template for feasibility negotiation. */ public static final TemplateUri FEASIBILITY_NEGOTIATION_ACCEPT_REJECT = - TemplateUri.of("Negotiation-T", "v1", "feasibility-negotiation", "accept-reject"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "v1", "feasibility-negotiation", "accept-reject"); /** All built-in Task-T templates. */ public static final List TASK = List.of(ENERGY_SAVING, PRIVATE_LINE_COMPLAINT); diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java index 0cb09bf2..5ac4f617 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java @@ -6,11 +6,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import org.junit.jupiter.api.Test; class MetadataContentTest { - private static final String TEMPLATE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String TEMPLATE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); @Test void recordExposesAllThreeComponents() { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java index 10bf1148..1b30559c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -4,6 +4,7 @@ import java.util.Objects; import java.util.Optional; import net.openan.a2at.sdk.core.resources.PathSegments; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.validation.TemplateReference; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; @@ -22,7 +23,7 @@ public record NegotiationReference(NegotiationType type, NegotiationPhase phase, String language) implements TemplateReference { - private static final String URI_PREFIX = "Negotiation-T"; + private static final String URI_PREFIX = StandardTemplates.NEGOTIATION_EXTENSION_NAME; private static final String URI_VERSION_SEGMENT = "v1"; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java index 61f16de4..5092f5a9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java @@ -11,6 +11,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.core.model.FilledParamData; @@ -35,7 +36,7 @@ class NegotiationConcurrentMixedLoadTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); private static final int THREADS = 12; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java index 41ea4fe0..cb2659b4 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.stream.Stream; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; @@ -46,7 +47,7 @@ void acceptConclusionRendersTheAcceptLiteral(String language) { new InfoEndingContent( NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("area information", "Songshan Lake")))), - "Negotiation-T/v1/information-negotiation/accept-reject") + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()) .promptText(); String target = orchestrator .generateAcceptFromData( @@ -54,7 +55,7 @@ void acceptConclusionRendersTheAcceptLiteral(String language) { context, new TargetEndingContent( NegotiationConclusion.ACCEPT, "The confirmed intent is recorded.", null)), - "Negotiation-T/v1/target-negotiation/accept-reject") + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri()) .promptText(); String feasibility = orchestrator .generateAcceptFromData( @@ -62,7 +63,7 @@ void acceptConclusionRendersTheAcceptLiteral(String language) { context, new FeasibilityEndingContent( NegotiationConclusion.ACCEPT, "The target is achievable.")), - "Negotiation-T/v1/feasibility-negotiation/accept-reject") + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) .promptText(); assertTrue(information.contains(conclusionSection(language, "information") + "\nAccept")); @@ -86,7 +87,7 @@ void rejectConclusionRendersTheRejectLiteral(String language) { new InfoEndingContent( NegotiationConclusion.REJECT, List.of(new NegotiationItem("area information", "not available")))), - "Negotiation-T/v1/information-negotiation/accept-reject") + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()) .promptText(); String target = orchestrator .generateRejectFromData( @@ -94,7 +95,7 @@ void rejectConclusionRendersTheRejectLiteral(String language) { context, new TargetEndingContent( NegotiationConclusion.REJECT, null, "The intent cannot be clarified.")), - "Negotiation-T/v1/target-negotiation/accept-reject") + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri()) .promptText(); String feasibility = orchestrator .generateRejectFromData( @@ -102,7 +103,7 @@ void rejectConclusionRendersTheRejectLiteral(String language) { context, new FeasibilityEndingContent( NegotiationConclusion.REJECT, "The target is not achievable.")), - "Negotiation-T/v1/feasibility-negotiation/accept-reject") + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) .promptText(); assertTrue(information.contains(conclusionSection(language, "information") + "\nReject")); @@ -125,14 +126,14 @@ void feasibilitySummaryUsesTheExceptionSlotUnderItsOwnSectionTitle(String langua new NegotiationEndingData( new NegotiationContext(UUID, 2, 5), new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, summary)), - "Negotiation-T/v1/feasibility-negotiation/accept-reject") + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) .promptText(); String rejectText = orchestrator .generateRejectFromData( new NegotiationEndingData( new NegotiationContext(UUID, 2, 5), new FeasibilityEndingContent(NegotiationConclusion.REJECT, summary)), - "Negotiation-T/v1/feasibility-negotiation/accept-reject") + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) .promptText(); String sectionTitle = @@ -156,7 +157,7 @@ void feasibilitySummaryIsCarriedVerbatim(String language) { new NegotiationEndingData( new NegotiationContext(UUID, 2, 5), new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, summary)), - "Negotiation-T/v1/feasibility-negotiation/accept-reject") + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) .promptText(); assertEquals(1, countOccurrences(promptText, summary)); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java index 1e2f50d0..6f251d0f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.stream.Stream; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -27,9 +28,9 @@ class ConditionalSectionRenderingTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String FEASIBILITY_PROPOSE_URI = "Negotiation-T/v1/feasibility-negotiation/propose"; + private static final String FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(); - private static final String TARGET_PROPOSE_URI = "Negotiation-T/v1/target-negotiation/propose"; + private static final String TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(); static Stream languages() { return Stream.of(Arguments.of("zh-CN"), Arguments.of("en-US")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java index 87f1c1b3..4f10497d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; import java.util.function.Supplier; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; @@ -38,17 +39,17 @@ class FromDataProgrammingErrorMatrixTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); - private static final String INFORMATION_ACCEPT_URI = "Negotiation-T/v1/information-negotiation/accept-reject"; + private static final String INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(); - private static final String TARGET_PROPOSE_URI = "Negotiation-T/v1/target-negotiation/propose"; + private static final String TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(); - private static final String FEASIBILITY_PROPOSE_URI = "Negotiation-T/v1/feasibility-negotiation/propose"; + private static final String FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(); - private static final String FEASIBILITY_ACCEPT_URI = "Negotiation-T/v1/feasibility-negotiation/accept-reject"; + private static final String FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(); - private static final String TARGET_ACCEPT_URI = "Negotiation-T/v1/target-negotiation/accept-reject"; + private static final String TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(); private final CountingClient llm = new CountingClient(); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java index 369c9e22..34365291 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java @@ -13,6 +13,7 @@ import java.util.Locale; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMError; @@ -42,7 +43,7 @@ class NegotiationGenerationOrchestratorBuilderWiringTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); private Logger rootLogger; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 65825602..3e13381f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.llm.LLMClient; @@ -29,7 +30,7 @@ class NegotiationGenerationOrchestratorTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); @Test void generatesInformationProposeFromDataInChinese() { @@ -128,7 +129,7 @@ void queriesSingleTemplateByUri() { assertTrue( zhOrchestrator().getNegotiationPrompt(INFORMATION_PROPOSE_URI).isPresent()); assertTrue(zhOrchestrator() - .getNegotiationPrompt("Negotiation-T/v1/target-negotiation/accept-reject") + .getNegotiationPrompt(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri()) .isPresent()); assertFalse(zhOrchestrator() .getNegotiationPrompt("Negotiation-T/v1/unknown-negotiation/propose") @@ -242,7 +243,7 @@ void rejectsMalformedTemplateUriAsAnIllegalArgument() { new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), - "Negotiation-T/v1/information-negotiation/accept-reject")); + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri())); assertTrue(phaseMismatchFailure .getMessage() .contains("Template URI is malformed or contradicts the expected phase PROPOSE (propose)")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java index ae9441c3..5f90f4cd 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; @@ -164,7 +165,7 @@ void feasibilityRequestActionRendersExactlyTheEvaluationSection() { .generate( context(1), content, - template("Negotiation-T/v1/feasibility-negotiation/propose", ZH_FEASIBILITY_PROPOSE_TEMPLATE), + template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary); assertTrue(rendered.contains("## 可行性协商\n请协助评估该节能目标能否达成。")); @@ -187,7 +188,7 @@ void feasibilityAlternativeActionRendersExactlyTheInfeasibilitySection() { .generate( context(2), content, - template("Negotiation-T/v1/feasibility-negotiation/propose", ZH_FEASIBILITY_PROPOSE_TEMPLATE), + template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary); assertTrue(rendered.contains("## 可行性协商\n当前速率目标不可行,提出下调方案。")); @@ -207,7 +208,7 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { context(1), nullAction, template( - "Negotiation-T/v1/feasibility-negotiation/propose", + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)); assertTrue(nullActionError.getMessage().contains("action must not be null")); @@ -221,7 +222,7 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { context(1), emptyEvaluation, template( - "Negotiation-T/v1/feasibility-negotiation/propose", + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)); assertEquals( @@ -237,7 +238,7 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { context(1), emptyAlternative, template( - "Negotiation-T/v1/feasibility-negotiation/propose", + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)); assertEquals( @@ -254,7 +255,7 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { context(1), blankDescription, template( - "Negotiation-T/v1/feasibility-negotiation/propose", + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)) .getMessage()); @@ -272,7 +273,7 @@ void targetFirstRoundRendersIntentSectionAndDropsAlignmentSection() { .generate( context(1), content, - template("Negotiation-T/v1/target-negotiation/propose", ZH_TARGET_PROPOSE_TEMPLATE), + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), ZH_TARGET_PROPOSE_TEMPLATE), zhVocabulary); assertTrue(rendered.contains("## 意图理解陈述\n1. 发起方理解:对方希望在体验无损的前提下降低节能力度")); @@ -293,7 +294,7 @@ void targetLaterRoundRendersAlignmentSectionAndDropsIntentSection() { .generate( context(3), content, - template("Negotiation-T/v1/target-negotiation/propose", ZH_TARGET_PROPOSE_TEMPLATE), + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), ZH_TARGET_PROPOSE_TEMPLATE), zhVocabulary); assertTrue(rendered.contains("## 理解对齐与疑问澄清\n1. 确认结果:节能时间范围确认为08:00~18:00")); @@ -310,7 +311,7 @@ void targetBlankDescriptionIsRejected() { .generate( context(1), content, - template("Negotiation-T/v1/target-negotiation/propose", ZH_TARGET_PROPOSE_TEMPLATE), + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), ZH_TARGET_PROPOSE_TEMPLATE), zhVocabulary)); assertEquals("Target negotiation description must not be blank.", exception.getMessage()); @@ -326,7 +327,7 @@ void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { .generate( context(1), withRelationship, - template("Negotiation-T/v1/information-negotiation/propose", ZH_INFO_PROPOSE_TEMPLATE), + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), ZH_INFO_PROPOSE_TEMPLATE), zhVocabulary); assertTrue(renderedZh.contains("## 所需信息项\n1. 故障发生时间:精确到分钟的时间点\n2. 受影响小区标识:CGI 或小区名称")); @@ -339,7 +340,7 @@ void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { .generate( context(1), withoutRelationship, - template("Negotiation-T/v1/information-negotiation/propose", ZH_INFO_PROPOSE_TEMPLATE), + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), ZH_INFO_PROPOSE_TEMPLATE), zhVocabulary); assertFalse(renderedWithout.contains("缺失项之间的关系")); @@ -352,7 +353,7 @@ void informationProposeRendersEnglishRelationshipLabelWithTrailingSpace() { "failure time and cell identity must correspond per cell"); PromptTemplate enTemplate = template( - "Negotiation-T/v1/information-negotiation/propose", + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), """ ## Negotiation Context {{negotiation_context}} (required) @@ -377,7 +378,7 @@ void endingGeneratorsRenderConclusionLiterals() { new InfoEndingContent( NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30"))), - template("Negotiation-T/v1/information-negotiation/accept-reject", ZH_INFO_ENDING_TEMPLATE), + template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_INFO_ENDING_TEMPLATE), zhVocabulary); assertTrue(infoRendered.contains("## 信息协商结果\nAccept")); @@ -387,7 +388,7 @@ void endingGeneratorsRenderConclusionLiterals() { .generate( context(2), new TargetEndingContent(NegotiationConclusion.REJECT, null, "双方未就速率保障下限达成一致。"), - template("Negotiation-T/v1/target-negotiation/accept-reject", ZH_TARGET_ENDING_TEMPLATE), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_TARGET_ENDING_TEMPLATE), zhVocabulary); assertTrue(targetRendered.contains("## 目标协商结果\nReject")); @@ -398,7 +399,7 @@ void endingGeneratorsRenderConclusionLiterals() { context(2), new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "同意将速率保障目标由5Mbps下调至2Mbps。"), template( - "Negotiation-T/v1/feasibility-negotiation/accept-reject", + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_FEASIBILITY_ENDING_TEMPLATE), zhVocabulary); @@ -412,20 +413,20 @@ void abortConclusionIsRejectedByEveryEndingGenerator() { .generate( context(1), new InfoEndingContent(NegotiationConclusion.ABORT, List.of()), - template("Negotiation-T/v1/information-negotiation/accept-reject", ZH_INFO_ENDING_TEMPLATE), + template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_INFO_ENDING_TEMPLATE), zhVocabulary)); assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() .generate( context(1), new TargetEndingContent(NegotiationConclusion.ABORT, "意图", null), - template("Negotiation-T/v1/target-negotiation/accept-reject", ZH_TARGET_ENDING_TEMPLATE), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_TARGET_ENDING_TEMPLATE), zhVocabulary)); assertThrows(IllegalArgumentException.class, () -> new FeasibilityEndingGenerator() .generate( context(1), new FeasibilityEndingContent(NegotiationConclusion.ABORT, "结论"), template( - "Negotiation-T/v1/feasibility-negotiation/accept-reject", + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_FEASIBILITY_ENDING_TEMPLATE), zhVocabulary)); } @@ -438,7 +439,7 @@ void nullConclusionIsRejected() { context(1), new InfoEndingContent(null, List.of()), template( - "Negotiation-T/v1/information-negotiation/accept-reject", + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_INFO_ENDING_TEMPLATE), zhVocabulary)); @@ -453,7 +454,7 @@ void targetEndingRequiresTheFieldMatchingTheConclusion() { context(1), new TargetEndingContent(NegotiationConclusion.ACCEPT, null, "失败原因"), template( - "Negotiation-T/v1/target-negotiation/accept-reject", ZH_TARGET_ENDING_TEMPLATE), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_TARGET_ENDING_TEMPLATE), zhVocabulary)); assertEquals( @@ -466,7 +467,7 @@ void targetEndingRequiresTheFieldMatchingTheConclusion() { context(1), new TargetEndingContent(NegotiationConclusion.REJECT, " ", null), template( - "Negotiation-T/v1/target-negotiation/accept-reject", ZH_TARGET_ENDING_TEMPLATE), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_TARGET_ENDING_TEMPLATE), zhVocabulary)); assertEquals( @@ -482,7 +483,7 @@ void feasibilityEndingRequiresTheSummary() { context(1), new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " "), template( - "Negotiation-T/v1/feasibility-negotiation/accept-reject", + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_FEASIBILITY_ENDING_TEMPLATE), zhVocabulary)); @@ -498,7 +499,7 @@ void generatorRejectsContentOfAnotherRuntimeType() { .generate( context(1), new TargetProposeContent("描述", null, null, null), - template("Negotiation-T/v1/information-negotiation/propose", ZH_INFO_PROPOSE_TEMPLATE), + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), ZH_INFO_PROPOSE_TEMPLATE), zhVocabulary)); assertTrue(exception.getMessage().contains("requires content of type InfoProposeContent")); @@ -507,7 +508,7 @@ void generatorRejectsContentOfAnotherRuntimeType() { @Test void generatorsRenderAgainstTheBundledTemplates() { NegotiationReference reference = NegotiationReference.tryParse( - "Negotiation-T/v1/target-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") + StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), NegotiationPhase.PROPOSE, "zh-CN") .orElseThrow(() -> new AssertionError("expected the bundled target propose template to resolve")); PromptTemplate loaded = new DefaultNegotiationTemplateLoader("zh-CN", null).load(reference); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java index be426fb4..b1dc1223 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import org.junit.jupiter.api.Test; @@ -84,7 +85,7 @@ void messageBuilderReplacesSemanticValidationTokens() { NegotiationMessageBuilder.TOKEN_NEGOTIATION_TYPE, "information", NegotiationMessageBuilder.TOKEN_TEMPLATE_URI, - "Negotiation-T/v1/information-negotiation/propose", + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), NegotiationMessageBuilder.TOKEN_SCHEMA, "{\"type\":\"object\"}", NegotiationMessageBuilder.TOKEN_INPUT, diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java index 65d6ff6f..8eb3da66 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java @@ -11,6 +11,7 @@ import java.util.List; import java.util.Optional; import java.util.stream.Stream; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -31,12 +32,12 @@ class NegotiationTemplateQueryBoundaryTest { private static final List EXPECTED_URI_ORDER = List.of( - "Negotiation-T/v1/information-negotiation/propose", - "Negotiation-T/v1/information-negotiation/accept-reject", - "Negotiation-T/v1/target-negotiation/propose", - "Negotiation-T/v1/target-negotiation/accept-reject", - "Negotiation-T/v1/feasibility-negotiation/propose", - "Negotiation-T/v1/feasibility-negotiation/accept-reject"); + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()); private ListAppender logAppender; @@ -85,11 +86,11 @@ void singleQueryResolvesAValidUriIntoItsTemplate(String language) { NegotiationGenerationOrchestrator orchestrator = orchestrator(language); Optional template = - orchestrator.getNegotiationPrompt("Negotiation-T/v1/feasibility-negotiation/propose"); + orchestrator.getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri()); assertTrue(template.isPresent()); assertEquals( - "Negotiation-T/v1/feasibility-negotiation/propose", + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), template.orElseThrow().uri()); String contextTitle = "zh-CN".equals(language) ? "## 协商上下文" : "## Negotiation Context"; assertTrue(template.orElseThrow().content().contains(contextTitle)); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java index 136b6ac9..a8e2cb21 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; @@ -21,7 +22,7 @@ class RelationshipLineRenderingTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); @Test void chineseRelationshipIsAppendedWithTheChineseLabel() { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index da50e6f3..e496aa11 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -14,6 +14,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.llm.LLMClient; @@ -47,7 +48,7 @@ class TemplateUriEntryMatrixTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); private static final String BUILTIN_INFORMATION_PROPOSE_TEMPLATE = "templates/Negotiation-T/v1/information-negotiation/propose/zh-CN/template.md"; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java index 49dc2d9a..47456422 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; @@ -41,12 +42,12 @@ class ValidateAndFillingDataPipelineTest { private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); private static final String INFORMATION_ACCEPT_REJECT_URI = - "Negotiation-T/v1/information-negotiation/accept-reject"; + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(); - private static final String TARGET_PROPOSE_URI = "Negotiation-T/v1/target-negotiation/propose"; + private static final String TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(); private static final Map REGION_SCHEMA = Map.of( "type", "object", "properties", Map.of("region", Map.of("type", "string")), "required", List.of("region")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java index f0dc8602..359b6c3f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.core.model.FilledParamData; @@ -32,7 +33,7 @@ class CustomRootPromptsIgnoredTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); private static final String FAKE_PROMPT_MARKER = "FAKE_PROMPT_MARKER_5b19"; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java index 08295b0c..7470571a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java @@ -9,6 +9,7 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.stream.Stream; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; @@ -30,7 +31,7 @@ */ class NegotiationLanguageSwitchTest { - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); static Stream goldenCases() { return Stream.of(GoldenCase.values()); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java index 292ce1bd..87d9e278 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java @@ -10,6 +10,7 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; @@ -41,9 +42,9 @@ class NegotiationErrorCodeUsageMatrixTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); - private static final String INFORMATION_ENDING_URI = "Negotiation-T/v1/information-negotiation/accept-reject"; + private static final String INFORMATION_ENDING_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(); private static final String VALID_CONTEXT_PROMPT = "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5"; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java index 5da29d59..b03e457b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.llm.LLMClient; @@ -35,7 +36,7 @@ class NegotiationExceptionTreeRootCatchTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); @Test void exhaustedGenerationLlmFailureIsCatchableThroughTheCommonRoot() { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index b2038ccc..a6a451ca 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -14,6 +14,7 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; @@ -48,7 +49,7 @@ class NegotiationLogEventContractTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); private static final Map> EXPECTED_EVENT_LEVELS = Map.ofEntries( Map.entry("negotiation_template_loaded", Set.of(Level.DEBUG)), diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java index 61d98375..27404249 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; @@ -33,7 +34,7 @@ class NegotiationPublicExceptionSurfaceTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); private static final List> PUBLIC_EXCEPTION_TYPES = List.of( A2ATError.class, diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java index e3ea0d4b..3380be02 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java @@ -8,6 +8,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; @@ -18,12 +19,12 @@ class DefaultNegotiationTemplateLoaderTest { private static final List EXPECTED_LOAD_ALL_URIS = List.of( - "Negotiation-T/v1/information-negotiation/propose", - "Negotiation-T/v1/information-negotiation/accept-reject", - "Negotiation-T/v1/target-negotiation/propose", - "Negotiation-T/v1/target-negotiation/accept-reject", - "Negotiation-T/v1/feasibility-negotiation/propose", - "Negotiation-T/v1/feasibility-negotiation/accept-reject"); + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()); @TempDir Path customRootDir; @@ -50,7 +51,7 @@ void loadReturnsFullTemplateContentFromTheClasspath() { PromptTemplate template = loader.load(new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "en-US")); - assertEquals("Negotiation-T/v1/information-negotiation/propose", template.uri()); + assertEquals(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), template.uri()); assertTrue(template.content().startsWith("## Negotiation Context")); } @@ -119,7 +120,7 @@ void loadAllWithCustomRootStillListsEveryTemplate() throws IOException { assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(templates)); PromptTemplate overriddenTemplate = templates.get(2); - assertEquals("Negotiation-T/v1/target-negotiation/propose", overriddenTemplate.uri()); + assertEquals(StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), overriddenTemplate.uri()); assertTrue(overriddenTemplate.content().contains("自定义目标内容")); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java index ad4d8d7f..e5305343 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Optional; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import org.junit.jupiter.api.Test; @@ -21,13 +22,13 @@ void typeSegmentUsesHyphenatedNames() { @Test void uriComposesPrefixVersionTypeSegmentAndPhaseSegment() { assertEquals( - "Negotiation-T/v1/information-negotiation/propose", + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "en-US").uri()); assertEquals( - "Negotiation-T/v1/target-negotiation/accept-reject", + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), new NegotiationReference(NegotiationType.TARGET, NegotiationPhase.ACCEPT, "zh-CN").uri()); assertEquals( - "Negotiation-T/v1/feasibility-negotiation/accept-reject", + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), new NegotiationReference(NegotiationType.FEASIBILITY, NegotiationPhase.REJECT, "zh-CN").uri()); } @@ -103,7 +104,7 @@ void tryParseRejectsIllegalPhaseSegment() { @Test void tryParseRejectsPhaseMismatchAgainstExpectedPhase() { Optional parsed = NegotiationReference.tryParse( - "Negotiation-T/v1/information-negotiation/propose", NegotiationPhase.ACCEPT, "zh-CN"); + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), NegotiationPhase.ACCEPT, "zh-CN"); assertTrue(parsed.isEmpty()); } @@ -115,7 +116,7 @@ void tryParseReturnsEmptyForBlankAndNullUriButThrowsOnNullExpectedPhase() { NullPointerException exception = assertThrows( NullPointerException.class, - () -> NegotiationReference.tryParse("Negotiation-T/v1/information-negotiation/propose", null, "zh-CN")); + () -> NegotiationReference.tryParse(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), null, "zh-CN")); assertEquals("Expected negotiation phase must not be null.", exception.getMessage()); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java index 0292b596..ff5af233 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; @@ -28,9 +29,9 @@ class BilateralInteropScenarioTest { private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); - private static final String INFORMATION_ACCEPT_URI = "Negotiation-T/v1/information-negotiation/accept-reject"; + private static final String INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(); private static final String EXTRACTION_RESPONSE = "{\"items\":[{\"name\":\"节能区域信息\",\"value\":\"松山湖\"}],\"relationship\":null}"; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java index a4a71ac6..f9be1bd0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java @@ -9,6 +9,7 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; @@ -37,9 +38,9 @@ */ class FeasibilityNegotiationScenarioTest { - private static final String FEASIBILITY_PROPOSE_URI = "Negotiation-T/v1/feasibility-negotiation/propose"; + private static final String FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(); - private static final String FEASIBILITY_ACCEPT_URI = "Negotiation-T/v1/feasibility-negotiation/accept-reject"; + private static final String FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(); @Test void feasibilityNegotiationRunsFromSemanticRejectionToATerminalAcceptance() throws IOException { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java index 160c00fb..5d99f603 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; @@ -30,9 +31,9 @@ class InformationNegotiationScenarioTest { private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); - private static final String INFORMATION_ACCEPT_URI = "Negotiation-T/v1/information-negotiation/accept-reject"; + private static final String INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(); @Test void informationNegotiationReachesTheTerminalStateWithinOneRound() { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java index dd99230d..803debe4 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; @@ -28,7 +29,7 @@ class MaxRoundsExhaustionScenarioTest { private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String TARGET_ACCEPT_URI = "Negotiation-T/v1/target-negotiation/accept-reject"; + private static final String TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(); @Test void exhaustedRoundBudgetEndsWithARejectMessageThePeerStillAccepts() { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java index 15b1fd6a..6d9adae8 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; @@ -28,9 +29,9 @@ class TargetNegotiationScenarioTest { private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String TARGET_PROPOSE_URI = "Negotiation-T/v1/target-negotiation/propose"; + private static final String TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(); - private static final String TARGET_ACCEPT_URI = "Negotiation-T/v1/target-negotiation/accept-reject"; + private static final String TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(); @Test void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java index cb674cbb..8a52e36e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; @@ -68,7 +69,7 @@ void singleStructuredCallReceivesMergedSchemaAndFilledUserPrompt() { assertEquals("user", messages.get(1).get("role")); String userPrompt = messages.get(1).get("content"); assertTrue(userPrompt.contains("information")); - assertTrue(userPrompt.contains("Negotiation-T/v1/information-negotiation/propose")); + assertTrue(userPrompt.contains(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri())); assertTrue(userPrompt.contains("confirmed_rate_mbps")); assertTrue(userPrompt.contains("energy saving region")); assertEquals(Map.of("merged", true), llmClient.lastSchema); diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java index 6b83c491..38e7954e 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java @@ -7,6 +7,7 @@ import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.resources.ClasspathResourceDirectories; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; @@ -23,7 +24,8 @@ */ public final class ClasspathPromptSlotSchemaLoader implements PromptSlotSchemaLoader { - private static final List KNOWN_SLOT_TYPES = List.of("Task-T", "Notification-T"); + private static final List KNOWN_SLOT_TYPES = + List.of(StandardTemplates.TASK_EXTENSION_NAME, StandardTemplates.NOTIFICATION_EXTENSION_NAME); private static final List SLOT_TYPES = discoverSlotTypes(); diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java index c97dbb22..a5ca9586 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java @@ -5,6 +5,7 @@ import java.util.Set; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.resources.ClasspathResourceDirectories; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; import net.openan.a2at.sdk.resources.PromptResourceKey; @@ -19,7 +20,8 @@ */ public final class ClasspathPromptTemplateLoader implements PromptTemplateTextLoader { - private static final List KNOWN_TEMPLATE_TYPES = List.of("Task-T", "Notification-T", "Negotiation-T"); + private static final List KNOWN_TEMPLATE_TYPES = List.of( + StandardTemplates.TASK_EXTENSION_NAME, StandardTemplates.NOTIFICATION_EXTENSION_NAME, StandardTemplates.NEGOTIATION_EXTENSION_NAME); private static final List TEMPLATE_TYPES = discoverTemplateTypes(); diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index 358ce1b7..1618e25a 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -22,6 +22,7 @@ import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -151,7 +152,7 @@ private static List symmetryCases() { new SymmetryCase( "information_propose", NegotiationPhase.PROPOSE, - "Negotiation-T/v1/information-negotiation/propose", + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), new NegotiationProposeData( new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5), new InfoProposeContent( @@ -163,7 +164,7 @@ private static List symmetryCases() { new SymmetryCase( "target_propose", NegotiationPhase.PROPOSE, - "Negotiation-T/v1/target-negotiation/propose", + StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), new NegotiationProposeData( new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5), new TargetProposeContent( @@ -174,7 +175,7 @@ private static List symmetryCases() { new SymmetryCase( "feasibility_propose", NegotiationPhase.PROPOSE, - "Negotiation-T/v1/feasibility-negotiation/propose", + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), new NegotiationProposeData( new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5), new FeasibilityProposeContent( @@ -185,36 +186,36 @@ private static List symmetryCases() { endingCase( "information_accept", NegotiationPhase.ACCEPT, - "Negotiation-T/v1/information-negotiation/accept-reject", + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), new InfoEndingContent( NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("area information", "Songshan Lake")))), endingCase( "target_accept", NegotiationPhase.ACCEPT, - "Negotiation-T/v1/target-negotiation/accept-reject", + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), new TargetEndingContent(NegotiationConclusion.ACCEPT, "The confirmed intent.", null)), endingCase( "feasibility_accept", NegotiationPhase.ACCEPT, - "Negotiation-T/v1/feasibility-negotiation/accept-reject", + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "The target is achievable.")), endingCase( "information_reject", NegotiationPhase.REJECT, - "Negotiation-T/v1/information-negotiation/accept-reject", + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), new InfoEndingContent( NegotiationConclusion.REJECT, List.of(new NegotiationItem("area information", "not available")))), endingCase( "target_reject", NegotiationPhase.REJECT, - "Negotiation-T/v1/target-negotiation/accept-reject", + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), new TargetEndingContent(NegotiationConclusion.REJECT, null, "The intent is unclear.")), endingCase( "feasibility_reject", NegotiationPhase.REJECT, - "Negotiation-T/v1/feasibility-negotiation/accept-reject", + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), new FeasibilityEndingContent(NegotiationConclusion.REJECT, "The target is not achievable."))); } diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index 021efe07..d3d8081c 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -4,6 +4,7 @@ import java.util.List; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; @@ -166,7 +167,7 @@ public ContentValidator buildTaskContentValidator() { requireSupportedConfig(); require(envPath, "Unified SDK env path must be configured."); return new DefaultContentValidator( - "Task-T", + StandardTemplates.TASK_EXTENSION_NAME, config.prompt().language(), config.llm().maxAttempts(), createLlmClient(), @@ -186,7 +187,7 @@ public ContentValidator buildNotificationContentValidator() { requireSupportedConfig(); require(envPath, "Unified SDK env path must be configured."); return new DefaultContentValidator( - "Notification-T", + StandardTemplates.NOTIFICATION_EXTENSION_NAME, config.prompt().language(), config.llm().maxAttempts(), createLlmClient(), @@ -206,7 +207,7 @@ public ContentValidator buildAuthContentValidator() { requireSupportedConfig(); require(envPath, "Unified SDK env path must be configured."); return new DefaultContentValidator( - "Authorization-T", + StandardTemplates.AUTHORIZATION_EXTENSION_NAME, config.prompt().language(), config.llm().maxAttempts(), createLlmClient(), diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index a3903dc9..7881f88d 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -15,6 +15,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.server.A2ATServer; import org.junit.jupiter.api.Test; @@ -22,7 +23,8 @@ class A2ATServerNegotiationApiTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); @Test void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { @@ -73,7 +75,8 @@ void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { A2ATServer server = new A2ATServer(writeEnv("zh-CN")); assertTrue(server.getNegotiationPrompt(INFORMATION_PROPOSE_URI).isPresent()); - assertTrue(server.getNegotiationPrompt("Negotiation-T/v1/feasibility-negotiation/accept-reject") + assertTrue(server + .getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) .isPresent()); assertFalse(server.getNegotiationPrompt("not-a-template-uri").isPresent()); PromptTemplate template = diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index 3e2437c6..f46c7416 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -16,6 +16,7 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; @@ -46,7 +47,8 @@ static void registerMockProvider() { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/v1/information-negotiation/propose"; + private static final String INFORMATION_PROPOSE_URI = + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); private static final String TASK_T_MESSAGE = "## 任务类型(Task Type)\n" + "无线能效优化\n\n" From 88eec9a7fcf47a021494465f1fff9c5c2315dab2 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 21 Aug 2026 17:33:13 +0800 Subject: [PATCH 075/295] refactor(validation): remove TemplateReference in favor of generic pipeline over TemplateUri Analysis showed the TemplateReference interface carried no weight: no caller ever read language() through the interface (language flows from global prompt config each validator already holds), and its remaining surface (uri + extensionName) is exactly TemplateUri. The instanceof escape hatch in NegotiationSemanticValidator existed only because the interface could not express negotiation-specific data. - delete TemplateReference, SimpleTemplateReference and TemplateUri.withLanguage()/extensionPrefix(); language is runtime config, never an addressability dimension of the template - genericize core pipeline: SemanticValidator and ValidationPipeline - prompt module: ValidationPipeline, DefaultSemanticValidator implements SemanticValidator, DefaultContentValidator builds a TemplateUri after its structural checks - negotiation module: ValidationPipeline, NegotiationSemanticValidator extends SemanticValidator with the negotiation-specific method renamed to validateNegotiation; the instanceof/cast default adapter is gone - NegotiationReference is a plain record again, its uncalled extensionName() override removed Signed-off-by: codetalks-hw --- .../core/validation/SemanticValidator.java | 11 +- .../validation/SimpleTemplateReference.java | 13 - .../core/validation/TemplateReference.java | 30 -- .../a2at/sdk/core/validation/TemplateUri.java | 27 +- .../core/validation/ValidationPipeline.java | 13 +- .../sdk/core/validation/TemplateUriTest.java | 15 - .../resources/NegotiationReference.java | 9 +- .../DefaultNegotiationSemanticValidator.java | 2 +- .../NegotiationSemanticValidator.java | 115 ++-- .../validation/ParamExtractor.java | 11 +- ...faultNegotiationSemanticValidatorTest.java | 498 +++++++++--------- .../validation/ParamExtractorTest.java | 2 +- .../validation/DefaultContentValidator.java | 5 +- .../validation/DefaultSemanticValidator.java | 8 +- 14 files changed, 337 insertions(+), 422 deletions(-) delete mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SimpleTemplateReference.java delete mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateReference.java diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SemanticValidator.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SemanticValidator.java index e3e5641e..f727b358 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SemanticValidator.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SemanticValidator.java @@ -6,20 +6,23 @@ * LLM-backed semantic validator for content. * *

The validator performs a single structured LLM call that combines semantic validation with parameter extraction. + * The reference type is generic so each caller passes its own template addressing type — for example a + * {@link TemplateUri} for extension content or a richer negotiation reference carrying type and phase. * + * @param template addressing type the validation is performed against * @since 2026-08 */ -public interface SemanticValidator { +public interface SemanticValidator { /** * Validates one content prompt semantically and extracts its parameters. * * @param prompt content prompt text * @param schema caller-provided parameter JSON schema embedded into the structured-call output contract - * @param reference template reference the content is validated against + * @param reference template addressing value the content is validated against * @return semantic validation outcome carrying the verdict, the semantic errors and the extracted parameters * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the semantic validation prompt resources * are missing */ - ValidationResult validate(String prompt, Map schema, TemplateReference reference); -} \ No newline at end of file + ValidationResult validate(String prompt, Map schema, T reference); +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SimpleTemplateReference.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SimpleTemplateReference.java deleted file mode 100644 index c2f127a3..00000000 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SimpleTemplateReference.java +++ /dev/null @@ -1,13 +0,0 @@ -package net.openan.a2at.sdk.core.validation; - -/** - * Plain data carrier implementing {@link TemplateReference} for templates that have no richer reference type of - * their own (Task-T, Notification-T, Authorization-T and language-bound {@link TemplateUri} values). - * - * @param uri template URI such as {@code Task-T/v1/energy-saving} - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @param extensionName extension name identifying the template family, such as {@code Task-T} - * @since 2026-08 - */ -public record SimpleTemplateReference(String uri, String language, String extensionName) - implements TemplateReference {} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateReference.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateReference.java deleted file mode 100644 index 7d27e0c6..00000000 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateReference.java +++ /dev/null @@ -1,30 +0,0 @@ -package net.openan.a2at.sdk.core.validation; - -/** - * Identifies a content template against which validation is performed. - * - * @since 2026-08 - */ -public interface TemplateReference { - - /** - * Returns the URI of the template. - * - * @return template URI - */ - String uri(); - - /** - * Returns the language of the template. - * - * @return template language - */ - String language(); - - /** - * Returns the extension name identifying the template family — the first segment of the template URI. - * - * @return extension name such as {@code Task-T} - */ - String extensionName(); -} \ No newline at end of file diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java index 488c7611..e883fae7 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java @@ -16,8 +16,8 @@ * of this type can never carry a malformed URI — the structural invariants that validators previously re-derived by * splitting raw strings are encoded in the type itself. * - *

The language is deliberately not part of the URI: it is runtime context resolved from the global prompt - * configuration, bound into a {@link TemplateReference} only when a concrete reference is needed. + *

The language is deliberately not part of the URI: it is global runtime context resolved from the prompt + * configuration by whichever component needs it, never an addressability dimension of the template. * * @param extensionName first URI segment identifying the template family, such as {@code Task-T} * @param version second URI segment, such as {@code v1} @@ -92,29 +92,6 @@ public String uri() { .collect(Collectors.joining("/")); } - /** - * Returns the extension prefix identifying the template family — the first URI segment, kept as a derived view for - * callers using the legacy term. - * - * @return extension prefix such as {@code Task-T} - */ - public String extensionPrefix() { - return extensionName; - } - - /** - * Binds the runtime language into a concrete template reference. - * - *

The language is global prompt runtime context, so callers normally pass this URI as-is; binding happens - * inside the SDK at the point where a language-carrying reference is required. - * - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @return template reference carrying this URI and the given language - */ - public TemplateReference withLanguage(String language) { - return new SimpleTemplateReference(uri(), language, extensionName); - } - private static void validateSegment(String value, String label) { Objects.requireNonNull(value, label + " must not be null."); if (!PathSegments.isSimpleSegment(value)) { diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java index d825b5ac..a2fb4e12 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java @@ -20,15 +20,16 @@ * conflict the context parameter wins and a warning is logged, so the LLM output can never override the rule-level * parsed values. * + * @param template addressing type the validation is performed against, such as {@link TemplateUri} * @since 2026-08 */ -public final class ValidationPipeline { +public final class ValidationPipeline { private static final Logger LOGGER = LoggerFactory.getLogger(ValidationPipeline.class); private final RuleChecker ruleChecker; - private final SemanticValidator semanticValidator; + private final SemanticValidator semanticValidator; private final int maxAttempts; @@ -40,7 +41,7 @@ public final class ValidationPipeline { * @param maxAttempts maximum number of retry attempts for the semantic validation step * @throws NullPointerException if the rule checker or the semantic validator is null */ - public ValidationPipeline(RuleChecker ruleChecker, SemanticValidator semanticValidator, int maxAttempts) { + public ValidationPipeline(RuleChecker ruleChecker, SemanticValidator semanticValidator, int maxAttempts) { this.ruleChecker = Objects.requireNonNull(ruleChecker, "ruleChecker"); this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); this.maxAttempts = maxAttempts; @@ -51,13 +52,13 @@ public ValidationPipeline(RuleChecker ruleChecker, SemanticValidator semanticVal * * @param prompt content prompt text * @param schema caller-provided parameter JSON schema - * @param reference template reference the content is validated against + * @param reference template addressing value the content is validated against * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt, schema or template reference is null + * @throws NullPointerException if the prompt, schema or reference is null * @throws IllegalArgumentException if the prompt is blank * @throws ContentValidationException if the validation fails at any stage */ - public FilledParamData validate(String prompt, Map schema, TemplateReference reference) { + public FilledParamData validate(String prompt, Map schema, T reference) { Objects.requireNonNull(schema, "schema"); Objects.requireNonNull(prompt, "prompt"); if (prompt.isBlank()) { diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java index 2c715998..721bf6b3 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java @@ -18,13 +18,6 @@ void composesUriFromComponents() { assertEquals(List.of("energy-saving"), uri.segments()); } - @Test - void extensionPrefixIsDerivedViewOfFirstSegment() { - TemplateUri uri = TemplateUri.of("Negotiation-T", "v1", "feasibility-negotiation", "propose"); - assertEquals("Negotiation-T", uri.extensionPrefix()); - assertEquals(uri.extensionName(), uri.extensionPrefix()); - } - @Test void parseRoundTripsSingleTrailingSegment() { Optional parsed = TemplateUri.parse("Task-T/v1/energy-saving"); @@ -61,14 +54,6 @@ void ofRejectsInvalidComponents() { assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "v1", " ")); } - @Test - void withLanguageBindsRuntimeLanguage() { - TemplateReference reference = TemplateUri.of("Task-T", "v1", "energy-saving").withLanguage("zh-CN"); - assertEquals("Task-T/v1/energy-saving", reference.uri()); - assertEquals("zh-CN", reference.language()); - assertEquals("Task-T", reference.extensionName()); - } - @Test void segmentsAreDefensivelyCopied() { java.util.ArrayList segments = new java.util.ArrayList<>(List.of("energy-saving")); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java index 1b30559c..db551f1e 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -5,7 +5,6 @@ import java.util.Optional; import net.openan.a2at.sdk.core.resources.PathSegments; import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateReference; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; @@ -20,8 +19,7 @@ * @param language locale identifier such as {@code zh-CN} or {@code en-US} * @since 2026-06 */ -public record NegotiationReference(NegotiationType type, NegotiationPhase phase, String language) - implements TemplateReference { +public record NegotiationReference(NegotiationType type, NegotiationPhase phase, String language) { private static final String URI_PREFIX = StandardTemplates.NEGOTIATION_EXTENSION_NAME; @@ -62,11 +60,6 @@ public String uri() { return String.join("/", URI_PREFIX, URI_VERSION_SEGMENT, typeSegment(), phase.uriSegment()); } - @Override - public String extensionName() { - return URI_PREFIX; - } - /** * Tries to parse a template URI into a reference, checking it against the expected phase. * diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java index c63746d8..0a635a15 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java @@ -94,7 +94,7 @@ public DefaultNegotiationSemanticValidator(LLMClient llmClient, SemanticSchemaBu * missing */ @Override - public SemanticValidationResult validate( + public SemanticValidationResult validateNegotiation( String prompt, Map callerSchema, NegotiationReference reference) { Objects.requireNonNull(prompt, "prompt"); Objects.requireNonNull(reference, "reference"); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java index 31f09d88..3dc9baf5 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java @@ -1,59 +1,56 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.SemanticValidator; -import net.openan.a2at.sdk.core.validation.TemplateReference; -import net.openan.a2at.sdk.core.validation.ValidationResult; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; - -/** - * LLM-backed semantic validator for negotiation messages. - * - *

The validator performs a single structured LLM call that combines semantic validation with parameter extraction - * and then enforces the declared type consistency in code: when the verdict is true, the negotiation type reported for - * the message must be present and must match the type declared by the template reference. A response that misses one of - * the four required keys or has the wrong shape is a validation infrastructure failure signalled through the internal - * {@link NegotiationValidationException}. - * - * @since 2026-06 - */ -public interface NegotiationSemanticValidator extends SemanticValidator { - - /** - * Validates one rendered negotiation message semantically and extracts its parameters. - * - * @param prompt rendered negotiation message text - * @param callerSchema caller-provided parameter JSON schema embedded into the structured-call output contract - * @param reference template reference the message is validated against, carrying the declared type, phase and - * language - * @return semantic validation outcome carrying the verdict, the implied negotiation type, the semantic errors and - * the extracted parameters - * @throws NegotiationValidationException if the LLM invocation fails or the response misses a required key or has - * the wrong shape - * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the semantic validation prompt resources - * of the reference language are missing - */ - SemanticValidationResult validate(String prompt, Map callerSchema, NegotiationReference reference); - - @Override - default ValidationResult validate(String prompt, Map schema, TemplateReference reference) { - if (!(reference instanceof NegotiationReference)) { - throw new IllegalArgumentException("Reference must be NegotiationReference"); - } - try { - SemanticValidationResult result = this.validate(prompt, schema, (NegotiationReference) reference); - return new ValidationResult(result.verdict(), result.errors(), result.params()); - } catch (NegotiationValidationException exception) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, - "Semantic validation LLM step failed: " + exception.getMessage(), - List.of(new SlotValidationError( - "_llm", A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getMessage())), - exception); - } - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.SemanticValidator; +import net.openan.a2at.sdk.core.validation.ValidationResult; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; + +/** + * LLM-backed semantic validator for negotiation messages. + * + *

The validator performs a single structured LLM call that combines semantic validation with parameter extraction + * and then enforces the declared type consistency in code: when the verdict is true, the negotiation type reported for + * the message must be present and must match the type declared by the template reference. A response that misses one of + * the four required keys or has the wrong shape is a validation infrastructure failure signalled through the internal + * {@link NegotiationValidationException}. + * + * @since 2026-06 + */ +public interface NegotiationSemanticValidator extends SemanticValidator { + + /** + * Validates one rendered negotiation message semantically and extracts its parameters. + * + * @param prompt rendered negotiation message text + * @param callerSchema caller-provided parameter JSON schema embedded into the structured-call output contract + * @param reference negotiation reference the message is validated against, carrying the declared type, phase and + * language + * @return semantic validation outcome carrying the verdict, the implied negotiation type, the semantic errors and + * the extracted parameters + * @throws NegotiationValidationException if the LLM invocation fails or the response misses a required key or has + * the wrong shape + * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the semantic validation prompt resources + * of the reference language are missing + */ + SemanticValidationResult validateNegotiation( + String prompt, Map callerSchema, NegotiationReference reference); + + @Override + default ValidationResult validate(String prompt, Map schema, NegotiationReference reference) { + try { + SemanticValidationResult result = validateNegotiation(prompt, schema, reference); + return new ValidationResult(result.verdict(), result.errors(), result.params()); + } catch (NegotiationValidationException exception) { + throw new ContentValidationException( + A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, + "Semantic validation LLM step failed: " + exception.getMessage(), + List.of(new SlotValidationError( + "_llm", A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getMessage())), + exception); + } + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java index 17ad7486..15c6c8bb 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java @@ -7,9 +7,9 @@ import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.validation.RuleChecker; import net.openan.a2at.sdk.core.validation.SemanticValidator; -import net.openan.a2at.sdk.core.validation.TemplateReference; import net.openan.a2at.sdk.core.validation.ValidationPipeline; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; /** * Orchestrates the validation of a negotiation message and the extraction of its parameters. @@ -23,7 +23,7 @@ */ public final class ParamExtractor { - private final ValidationPipeline pipeline; + private final ValidationPipeline pipeline; /** * Creates a parameter extractor. @@ -33,7 +33,10 @@ public final class ParamExtractor { * @param maxAttempts maximum number of retry attempts for the semantic validation step * @throws NullPointerException if any collaborator is null */ - public ParamExtractor(RuleChecker ruleChecker, SemanticValidator semanticValidator, int maxAttempts) { + public ParamExtractor( + RuleChecker ruleChecker, + SemanticValidator semanticValidator, + int maxAttempts) { Objects.requireNonNull(ruleChecker, "ruleChecker"); Objects.requireNonNull(semanticValidator, "semanticValidator"); this.pipeline = new ValidationPipeline(ruleChecker, semanticValidator, maxAttempts); @@ -51,7 +54,7 @@ public ParamExtractor(RuleChecker ruleChecker, SemanticValidator semanticValidat * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline * fails */ - public FilledParamData extract(String prompt, Map schema, TemplateReference reference) { + public FilledParamData extract(String prompt, Map schema, NegotiationReference reference) { try { return pipeline.validate(prompt, schema, reference); } catch (ContentValidationException e) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java index 8a52e36e..89021abf 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java @@ -1,249 +1,249 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -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 java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.junit.jupiter.api.Test; - -class DefaultNegotiationSemanticValidatorTest { - - private static final String VALID_PROMPT = "## Negotiation Context\n" - + "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n" - + "- round: 1\n" - + "- maxRounds: 5\n\n" - + "## Required Information Items\n" - + "1. energy saving region: provide a real region\n"; - - private final RecordingSchemaBuilder schemaBuilder = new RecordingSchemaBuilder(); - - private final RecordingLLMClient llmClient = new RecordingLLMClient(); - - private final DefaultNegotiationSemanticValidator validator = - new DefaultNegotiationSemanticValidator(llmClient, schemaBuilder); - - private final NegotiationReference informationReference = - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "en-US"); - - private final Map callerSchema = - Map.of("type", "object", "properties", Map.of("confirmed_rate_mbps", Map.of("type", "integer"))); - - @Test - void validResponseYieldsVerdictTypeErrorsAndParams() { - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," - + "\"errors\":[],\"params\":{\"confirmed_rate_mbps\":2}}"; - - SemanticValidationResult result = validator.validate(VALID_PROMPT, callerSchema, informationReference); - - assertTrue(result.verdict()); - assertEquals("information", result.negotiationType()); - assertEquals(List.of(), result.errors()); - assertEquals(Map.of("confirmed_rate_mbps", 2), result.params()); - assertEquals(1, llmClient.invocations); - assertEquals(callerSchema, schemaBuilder.lastCallerSchema); - } - - @Test - void singleStructuredCallReceivesMergedSchemaAndFilledUserPrompt() { - llmClient.payload = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":{}}"; - - validator.validate(VALID_PROMPT, callerSchema, informationReference); - - List> messages = llmClient.lastMessages; - assertEquals(2, messages.size()); - assertEquals("system", messages.get(0).get("role")); - assertTrue(messages.get(0).get("content").contains("semantic validation")); - assertEquals("user", messages.get(1).get("role")); - String userPrompt = messages.get(1).get("content"); - assertTrue(userPrompt.contains("information")); - assertTrue(userPrompt.contains(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri())); - assertTrue(userPrompt.contains("confirmed_rate_mbps")); - assertTrue(userPrompt.contains("energy saving region")); - assertEquals(Map.of("merged", true), llmClient.lastSchema); - assertNull(llmClient.lastTemperature); - assertNull(llmClient.lastMaxTokens); - } - - @Test - void missingNegotiationTypeKeyIsAShapeViolation() { - llmClient.payload = "{\"semantic_verdict\":true,\"errors\":[],\"params\":{}}"; - - NegotiationValidationException exception = assertThrows( - NegotiationValidationException.class, - () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); - - assertTrue(exception.getMessage().contains("negotiation_type")); - } - - @Test - void missingOtherRequiredKeysAreShapeViolations() { - llmClient.payload = "{\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); - - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"params\":{}}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); - - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); - } - - @Test - void wrongShapesAreShapeViolations() { - llmClient.payload = - "{\"semantic_verdict\":\"yes\",\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":{}}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); - - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," - + "\"errors\":\"none\",\"params\":{}}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); - - llmClient.payload = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":[]}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); - - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," - + "\"errors\":[{\"slot_name\":\"section.context\"}],\"params\":{}}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); - - llmClient.payload = "not json at all"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); - } - - @Test - void nullTypeWithTrueVerdictIsTurnedIntoSemanticRejection() { - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"; - - SemanticValidationResult result = validator.validate(VALID_PROMPT, callerSchema, informationReference); - - assertFalse(result.verdict()); - assertNull(result.negotiationType()); - assertEquals(1, result.errors().size()); - assertEquals("section.info_static", result.errors().get(0).slotName()); - assertEquals("template_type_mismatch", result.errors().get(0).code()); - } - - @Test - void typeMismatchWithTrueVerdictIsTurnedIntoSemanticRejection() { - llmClient.payload = - "{\"semantic_verdict\":true,\"negotiation_type\":\"target\"," + "\"errors\":[],\"params\":{}}"; - - SemanticValidationResult result = validator.validate(VALID_PROMPT, callerSchema, informationReference); - - assertFalse(result.verdict()); - assertEquals("target", result.negotiationType()); - assertEquals(1, result.errors().size()); - assertEquals("section.target", result.errors().get(0).slotName()); - assertEquals("template_type_mismatch", result.errors().get(0).code()); - assertTrue(result.errors().get(0).message().contains("information")); - } - - @Test - void negativeVerdictPassesThroughWithTypeNullAndErrors() { - llmClient.payload = "{\"semantic_verdict\":false,\"negotiation_type\":null," - + "\"errors\":[{\"slot_name\":\"section.target_result_content\"," - + "\"code\":\"conclusion_content_mismatch\",\"message\":\"Mismatch\"}],\"params\":{}}"; - - SemanticValidationResult result = validator.validate(VALID_PROMPT, callerSchema, informationReference); - - assertFalse(result.verdict()); - assertNull(result.negotiationType()); - assertEquals(1, result.errors().size()); - assertEquals("section.target_result_content", result.errors().get(0).slotName()); - } - - @Test - void llmProviderFailureIsWrappedAsInternalValidationException() { - llmClient.failure = new LLMRuntimeError("invocation failed"); - - NegotiationValidationException exception = assertThrows( - NegotiationValidationException.class, - () -> validator.validate(VALID_PROMPT, callerSchema, informationReference)); - - assertTrue(exception.getMessage().contains("invocation failed")); - } - - @Test - void missingPromptResourceForLanguageSurfacesResourceNotFound() { - NegotiationReference unsupportedLanguageReference = - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "fr-FR"); - - assertThrows( - ResourceNotFoundException.class, - () -> validator.validate(VALID_PROMPT, callerSchema, unsupportedLanguageReference)); - assertEquals(0, llmClient.invocations); - } - - private static final class RecordingLLMClient implements LLMClient { - - private String payload = "{}"; - - private RuntimeException failure; - - private int invocations; - - private List> lastMessages; - - private Map lastSchema; - - private Double lastTemperature; - - private Integer lastMaxTokens; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - invocations++; - lastMessages = messages; - lastSchema = jsonSchema; - lastTemperature = temperature; - lastMaxTokens = maxTokens; - if (failure != null) { - throw failure; - } - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class RecordingSchemaBuilder implements SemanticSchemaBuilder { - - private Map lastCallerSchema; - - @Override - public Map buildSemanticValidationSchema(Map callerSchema) { - lastCallerSchema = callerSchema; - return Map.of("merged", true); - } - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +class DefaultNegotiationSemanticValidatorTest { + + private static final String VALID_PROMPT = "## Negotiation Context\n" + + "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n" + + "- round: 1\n" + + "- maxRounds: 5\n\n" + + "## Required Information Items\n" + + "1. energy saving region: provide a real region\n"; + + private final RecordingSchemaBuilder schemaBuilder = new RecordingSchemaBuilder(); + + private final RecordingLLMClient llmClient = new RecordingLLMClient(); + + private final DefaultNegotiationSemanticValidator validator = + new DefaultNegotiationSemanticValidator(llmClient, schemaBuilder); + + private final NegotiationReference informationReference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "en-US"); + + private final Map callerSchema = + Map.of("type", "object", "properties", Map.of("confirmed_rate_mbps", Map.of("type", "integer"))); + + @Test + void validResponseYieldsVerdictTypeErrorsAndParams() { + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + + "\"errors\":[],\"params\":{\"confirmed_rate_mbps\":2}}"; + + SemanticValidationResult result = validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference); + + assertTrue(result.verdict()); + assertEquals("information", result.negotiationType()); + assertEquals(List.of(), result.errors()); + assertEquals(Map.of("confirmed_rate_mbps", 2), result.params()); + assertEquals(1, llmClient.invocations); + assertEquals(callerSchema, schemaBuilder.lastCallerSchema); + } + + @Test + void singleStructuredCallReceivesMergedSchemaAndFilledUserPrompt() { + llmClient.payload = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":{}}"; + + validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference); + + List> messages = llmClient.lastMessages; + assertEquals(2, messages.size()); + assertEquals("system", messages.get(0).get("role")); + assertTrue(messages.get(0).get("content").contains("semantic validation")); + assertEquals("user", messages.get(1).get("role")); + String userPrompt = messages.get(1).get("content"); + assertTrue(userPrompt.contains("information")); + assertTrue(userPrompt.contains(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri())); + assertTrue(userPrompt.contains("confirmed_rate_mbps")); + assertTrue(userPrompt.contains("energy saving region")); + assertEquals(Map.of("merged", true), llmClient.lastSchema); + assertNull(llmClient.lastTemperature); + assertNull(llmClient.lastMaxTokens); + } + + @Test + void missingNegotiationTypeKeyIsAShapeViolation() { + llmClient.payload = "{\"semantic_verdict\":true,\"errors\":[],\"params\":{}}"; + + NegotiationValidationException exception = assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference)); + + assertTrue(exception.getMessage().contains("negotiation_type")); + } + + @Test + void missingOtherRequiredKeysAreShapeViolations() { + llmClient.payload = "{\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference)); + + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference)); + + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference)); + } + + @Test + void wrongShapesAreShapeViolations() { + llmClient.payload = + "{\"semantic_verdict\":\"yes\",\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference)); + + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + + "\"errors\":\"none\",\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference)); + + llmClient.payload = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":[]}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference)); + + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + + "\"errors\":[{\"slot_name\":\"section.context\"}],\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference)); + + llmClient.payload = "not json at all"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference)); + } + + @Test + void nullTypeWithTrueVerdictIsTurnedIntoSemanticRejection() { + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"; + + SemanticValidationResult result = validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference); + + assertFalse(result.verdict()); + assertNull(result.negotiationType()); + assertEquals(1, result.errors().size()); + assertEquals("section.info_static", result.errors().get(0).slotName()); + assertEquals("template_type_mismatch", result.errors().get(0).code()); + } + + @Test + void typeMismatchWithTrueVerdictIsTurnedIntoSemanticRejection() { + llmClient.payload = + "{\"semantic_verdict\":true,\"negotiation_type\":\"target\"," + "\"errors\":[],\"params\":{}}"; + + SemanticValidationResult result = validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference); + + assertFalse(result.verdict()); + assertEquals("target", result.negotiationType()); + assertEquals(1, result.errors().size()); + assertEquals("section.target", result.errors().get(0).slotName()); + assertEquals("template_type_mismatch", result.errors().get(0).code()); + assertTrue(result.errors().get(0).message().contains("information")); + } + + @Test + void negativeVerdictPassesThroughWithTypeNullAndErrors() { + llmClient.payload = "{\"semantic_verdict\":false,\"negotiation_type\":null," + + "\"errors\":[{\"slot_name\":\"section.target_result_content\"," + + "\"code\":\"conclusion_content_mismatch\",\"message\":\"Mismatch\"}],\"params\":{}}"; + + SemanticValidationResult result = validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference); + + assertFalse(result.verdict()); + assertNull(result.negotiationType()); + assertEquals(1, result.errors().size()); + assertEquals("section.target_result_content", result.errors().get(0).slotName()); + } + + @Test + void llmProviderFailureIsWrappedAsInternalValidationException() { + llmClient.failure = new LLMRuntimeError("invocation failed"); + + NegotiationValidationException exception = assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference)); + + assertTrue(exception.getMessage().contains("invocation failed")); + } + + @Test + void missingPromptResourceForLanguageSurfacesResourceNotFound() { + NegotiationReference unsupportedLanguageReference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "fr-FR"); + + assertThrows( + ResourceNotFoundException.class, + () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, unsupportedLanguageReference)); + assertEquals(0, llmClient.invocations); + } + + private static final class RecordingLLMClient implements LLMClient { + + private String payload = "{}"; + + private RuntimeException failure; + + private int invocations; + + private List> lastMessages; + + private Map lastSchema; + + private Double lastTemperature; + + private Integer lastMaxTokens; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + invocations++; + lastMessages = messages; + lastSchema = jsonSchema; + lastTemperature = temperature; + lastMaxTokens = maxTokens; + if (failure != null) { + throw failure; + } + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class RecordingSchemaBuilder implements SemanticSchemaBuilder { + + private Map lastCallerSchema; + + @Override + public Map buildSemanticValidationSchema(Map callerSchema) { + lastCallerSchema = callerSchema; + return Map.of("merged", true); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java index 57e674a7..b5008f95 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java @@ -207,7 +207,7 @@ private static final class StubSemanticValidator implements NegotiationSemanticV private int invocations; @Override - public SemanticValidationResult validate( + public SemanticValidationResult validateNegotiation( String prompt, Map callerSchema, NegotiationReference reference) { invocations++; if (failure != null) { diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java index 9bd7f636..d69ab3f1 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -4,8 +4,7 @@ import java.util.Objects; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.core.validation.SimpleTemplateReference; -import net.openan.a2at.sdk.core.validation.TemplateReference; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.validation.ValidationPipeline; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; @@ -81,7 +80,7 @@ public FilledParamData validate(String prompt, Map schema, Strin throw new IllegalArgumentException("Unsupported template URI version: " + version); } - TemplateReference reference = new SimpleTemplateReference(templateUri, language, extensionName); + TemplateUri reference = TemplateUri.of(prefix, version, java.util.Arrays.copyOfRange(parts, 2, parts.length)); return pipeline().validate(prompt, schema, reference); } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index e5173436..93a4d5d2 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -15,7 +15,7 @@ import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.validation.SemanticValidator; -import net.openan.a2at.sdk.core.validation.TemplateReference; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.validation.ValidationResult; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; @@ -29,7 +29,7 @@ * * @since 2026-08 */ -public final class DefaultSemanticValidator implements SemanticValidator { +public final class DefaultSemanticValidator implements SemanticValidator { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultSemanticValidator.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @@ -56,7 +56,7 @@ public DefaultSemanticValidator( } @Override - public ValidationResult validate(String prompt, Map schema, TemplateReference reference) { + public ValidationResult validate(String prompt, Map schema, TemplateUri reference) { if (llmClient == null) { throw new ContentValidationException( A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, @@ -93,7 +93,7 @@ public ValidationResult validate(String prompt, Map schema, Temp return new ValidationResult(verdict, errors, params); } - private String fillUserPrompt(String prompt, Map schema, TemplateReference reference) { + private String fillUserPrompt(String prompt, Map schema, TemplateUri reference) { String schemaJson; try { schemaJson = OBJECT_MAPPER.writeValueAsString(schema); From a9a52ca83a8a3e6d08cafd2f8750448c9f6e4520 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 21 Aug 2026 20:46:28 +0800 Subject: [PATCH 076/295] refactor(templates): reshape template URI layout to path-segments with trailing version Internal review decision: drop the extension version segment, keep a trailing template version (default v1) and insert a network-layer domain segment into the Task-T and Notification-T paths. The URI now mirrors the resource directory one-to-one and the Authorization-T scenario is renamed to authorization-policy-management. New layout: - Task-T/network-layer//v1 - Notification-T/network-layer//v1 - Authorization-T/authorization-policy-management/v1 - Negotiation-T///v1 - TemplateUri record is now {extensionName, pathSegments, templateVersion} with TemplateUri.DEFAULT_TEMPLATE_VERSION and a parse() that expects >= 3 segments (extension + >= 1 path segment + trailing version) - StandardTemplates constants updated, AUTHZ_POLICY_MGR renamed to AUTHORIZATION_POLICY_MANAGEMENT, new NETWORK_LAYER_SEGMENT constant - bundled templates/slots trees git-mv'd to mirror the new URIs; the template lint gate rules follow the new layouts - NegotiationReference composes and parses the new 4-segment negotiation URIs; DefaultNegotiationTemplateLoader paths updated - PromptResourceKey redesigned around path segments with template()/ slotSchema()/scenario() factories taking TemplateUri; classpath and local-file loaders resolve full URIs directly and probe bare scenario codes against both the network-layer and plain layouts - DefaultContentValidator validates first (extension name) and last (template version) segments - tests, test fixture trees and bilingual docs migrated to the new layout Signed-off-by: codetalks-hw --- .../A2ATClientNegotiationEnvConfigTest.java | 12 ++- .../a2at/sdk/client/api/A2ATClientTest.java | 62 ++++++++---- ...lientPromptGenerationOrchestratorTest.java | 48 ++++----- .../energy-saving/v1}/en-US/slot.json | 0 .../free-text/v1}/en-US/slot.json | 0 .../energy-saving/v1}/en-US/template.md | 0 .../core/validation/StandardTemplates.java | 38 ++++--- .../a2at/sdk/core/validation/TemplateUri.java | 63 +++++++----- .../sdk/core/validation/TemplateUriTest.java | 48 +++++---- .../DefaultNegotiationTemplateLoader.java | 3 +- .../resources/NegotiationReference.java | 21 ++-- ...otiationDeliveredResourceContractTest.java | 2 +- .../FromDataProgrammingErrorMatrixTest.java | 10 +- ...NegotiationGenerationOrchestratorTest.java | 2 +- .../NegotiationPromptResourceLoaderTest.java | 4 +- .../NegotiationTemplateQueryBoundaryTest.java | 4 +- .../TemplateUriEntryMatrixTest.java | 18 ++-- .../sdk/negotiation/golden/GoldenInputs.java | 4 +- .../CustomRootTemplateOverrideTest.java | 2 +- .../NegotiationLogEventContractTest.java | 2 +- .../DefaultNegotiationTemplateLoaderTest.java | 4 +- .../resources/NegotiationReferenceTest.java | 26 ++--- .../MaxRoundsExhaustionScenarioTest.java | 2 +- .../ClasspathPromptScenarioCatalogLoader.java | 3 +- .../ClasspathPromptSlotSchemaLoader.java | 47 +++++++-- .../loader/ClasspathPromptTemplateLoader.java | 42 ++++++-- .../LocalFilePromptSlotSchemaLoader.java | 17 +++- .../loader/LocalFilePromptTemplateLoader.java | 16 ++- .../validation/DefaultContentValidator.java | 12 ++- .../loader/ClasspathPromptLoadersTest.java | 5 +- .../loader/LocalFilePromptLoadersTest.java | 12 ++- .../loader/PromptResourceAccessTest.java | 6 +- .../handoff-planning/v1}/en/slot.json | 0 .../handoff-planning/v1}/en/template.md | 0 .../a2at/sdk/resources/PromptResourceKey.java | 98 +++++++++++++------ .../subscribe-incident/v1}/en-US/slot.json | 0 .../subscribe-incident/v1}/zh-CN/slot.json | 0 .../energy-saving/v1}/en-US/slot.json | 0 .../energy-saving/v1}/zh-CN/slot.json | 0 .../v1}/en-US/slot.json | 0 .../v1}/zh-CN/slot.json | 0 .../v1}/en-US/template.md | 0 .../v1}/zh-CN/template.md | 0 .../accept-reject/v1}/en-US/template.md | 0 .../accept-reject/v1}/zh-CN/template.md | 0 .../propose/v1}/en-US/template.md | 0 .../propose/v1}/zh-CN/template.md | 0 .../accept-reject/v1}/en-US/template.md | 0 .../accept-reject/v1}/zh-CN/template.md | 0 .../propose/v1}/en-US/template.md | 0 .../propose/v1}/zh-CN/template.md | 0 .../accept-reject/v1}/en-US/template.md | 0 .../accept-reject/v1}/zh-CN/template.md | 0 .../propose/v1}/en-US/template.md | 0 .../propose/v1}/zh-CN/template.md | 0 .../service-recovery/v1}/en-US/template.md | 0 .../service-recovery/v1}/zh-CN/template.md | 0 .../subscribe-incident/v1}/en-US/template.md | 0 .../subscribe-incident/v1}/zh-CN/template.md | 0 .../energy-saving/v1}/en-US/template.md | 0 .../energy-saving/v1}/zh-CN/template.md | 0 .../v1}/en-US/template.md | 0 .../v1}/zh-CN/template.md | 0 .../ClasspathPromptResourceLoaderTest.java | 26 +++-- docs/en/developer_guide.md | 34 ++++--- ...00\345\217\221\346\214\207\345\215\227.md" | 34 ++++--- tools/template_lint.py | 27 ++--- 67 files changed, 480 insertions(+), 274 deletions(-) rename a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/{v1/energy-saving => network-layer/energy-saving/v1}/en-US/slot.json (100%) rename a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/{v1/free-text => network-layer/free-text/v1}/en-US/slot.json (100%) rename a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/{v1/energy-saving => network-layer/energy-saving/v1}/en-US/template.md (100%) rename a2a-t-prompt/src/test/resources/prompt_resources/slots/Task-T/{v1/handoff-planning => network-layer/handoff-planning/v1}/en/slot.json (100%) rename a2a-t-prompt/src/test/resources/prompt_resources/templates/Task-T/{v1/handoff-planning => network-layer/handoff-planning/v1}/en/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/{v1/subscribe-incident => network-layer/subscribe-incident/v1}/en-US/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/{v1/subscribe-incident => network-layer/subscribe-incident/v1}/zh-CN/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/{v1/energy-saving => network-layer/energy-saving/v1}/en-US/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/{v1/energy-saving => network-layer/energy-saving/v1}/zh-CN/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/{v1/private-line-complaint => network-layer/private-line-complaint/v1}/en-US/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/{v1/private-line-complaint => network-layer/private-line-complaint/v1}/zh-CN/slot.json (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/{v1/authz-policy-mgr => authorization-policy-management/v1}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/{v1/authz-policy-mgr => authorization-policy-management/v1}/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/{v1/feasibility-negotiation/accept-reject => feasibility-negotiation/accept-reject/v1}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/{v1/feasibility-negotiation/accept-reject => feasibility-negotiation/accept-reject/v1}/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/{v1/feasibility-negotiation/propose => feasibility-negotiation/propose/v1}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/{v1/feasibility-negotiation/propose => feasibility-negotiation/propose/v1}/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/{v1/information-negotiation/accept-reject => information-negotiation/accept-reject/v1}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/{v1/information-negotiation/accept-reject => information-negotiation/accept-reject/v1}/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/{v1/information-negotiation/propose => information-negotiation/propose/v1}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/{v1/information-negotiation/propose => information-negotiation/propose/v1}/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/{v1/target-negotiation/accept-reject => target-negotiation/accept-reject/v1}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/{v1/target-negotiation/accept-reject => target-negotiation/accept-reject/v1}/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/{v1/target-negotiation/propose => target-negotiation/propose/v1}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/{v1/target-negotiation/propose => target-negotiation/propose/v1}/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/{v1/service-recovery => network-layer/service-recovery/v1}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/{v1/service-recovery => network-layer/service-recovery/v1}/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/{v1/subscribe-incident => network-layer/subscribe-incident/v1}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/{v1/subscribe-incident => network-layer/subscribe-incident/v1}/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/{v1/energy-saving => network-layer/energy-saving/v1}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/{v1/energy-saving => network-layer/energy-saving/v1}/zh-CN/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/{v1/private-line-complaint => network-layer/private-line-complaint/v1}/en-US/template.md (100%) rename a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/{v1/private-line-complaint => network-layer/private-line-complaint/v1}/zh-CN/template.md (100%) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index 1d79ce37..15148531 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -30,6 +30,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -164,9 +165,9 @@ private Path writeCustomRootWithMarkeredTemplate() throws IOException { Path customTemplate = tempDir.resolve("custom-root") .resolve("templates") .resolve("Negotiation-T") - .resolve("v1") .resolve("information-negotiation") .resolve("propose") + .resolve("v1") .resolve("zh-CN") .resolve("template.md"); Files.createDirectories(customTemplate.getParent()); @@ -178,10 +179,11 @@ private Path writeCustomRootWithMarkeredTemplate() throws IOException { } private static String builtinTemplate(String templateUri, String language) throws IOException { - String typeSegment = templateUri.split("/")[2]; - String phaseSegment = templateUri.split("/")[3]; - String classpathPath = "prompt_resources/templates/Negotiation-T/v1/" + typeSegment + "/" + phaseSegment + "/" - + language + "/template.md"; + String[] segments = templateUri.split("/"); + String typeSegment = segments[1]; + String phaseSegment = segments[2]; + String classpathPath = "prompt_resources/templates/Negotiation-T/" + typeSegment + "/" + phaseSegment + "/" + + TemplateUri.DEFAULT_TEMPLATE_VERSION + "/" + language + "/template.md"; InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(classpathPath); assertFalse(stream == null, "the built-in template must exist on the classpath: " + classpathPath); try (stream) { diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index 4bfbf73e..14368b14 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -177,11 +177,17 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO Path templatesDir = promptRoot .resolve("templates") .resolve("Task-T") + .resolve("network-layer") + .resolve("private-line-complaint") .resolve("v1") + .resolve("zh-CN"); + Path slotsDir = promptRoot + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") .resolve("private-line-complaint") + .resolve("v1") .resolve("zh-CN"); - Path slotsDir = - promptRoot.resolve("slots").resolve("Task-T").resolve("v1").resolve("private-line-complaint").resolve("zh-CN"); Files.createDirectories(scenariosDir); Files.createDirectories(templatesDir); Files.createDirectories(slotsDir); @@ -253,24 +259,24 @@ void fromTextEntryPointsReturnMetadataContent() throws IOException { A2ATClient client = new A2ATClient(envFile); MetadataContent taskResult = - client.generateTaskPromptFromText("Please analyze Site A.", "Task-T/v1/unconstrained"); + client.generateTaskPromptFromText("Please analyze Site A.", "Task-T/network-layer/unconstrained/v1"); MetadataContent authResult = - client.generateAuthPromptFromText("Authorize access.", "Task-T/v1/unconstrained"); + client.generateAuthPromptFromText("Authorize access.", "Task-T/network-layer/unconstrained/v1"); MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", "Task-T/v1/unconstrained"); + client.generateNotificationPromptFromText("Report finished.", "Task-T/network-layer/unconstrained/v1"); assertNotNull(taskResult); - assertEquals("Task-T/v1/unconstrained", taskResult.templateUri()); + assertEquals("Task-T/network-layer/unconstrained/v1", taskResult.templateUri()); assertNotNull(taskResult.promptText()); assertNotNull(taskResult.extensionUri()); assertNotNull(authResult); - assertEquals("Task-T/v1/unconstrained", authResult.templateUri()); + assertEquals("Task-T/network-layer/unconstrained/v1", authResult.templateUri()); assertNotNull(authResult.promptText()); assertNotNull(authResult.extensionUri()); assertNotNull(notificationResult); - assertEquals("Task-T/v1/unconstrained", notificationResult.templateUri()); + assertEquals("Task-T/network-layer/unconstrained/v1", notificationResult.templateUri()); assertNotNull(notificationResult.promptText()); assertNotNull(notificationResult.extensionUri()); } @@ -311,11 +317,11 @@ void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { A2ATClient client = new A2ATClient(envFile); MetadataContent taskResult = - client.generateTaskPromptFromText("Please analyze Site A.", "Task-T/v1/unconstrained"); + client.generateTaskPromptFromText("Please analyze Site A.", "Task-T/network-layer/unconstrained/v1"); MetadataContent authResult = - client.generateAuthPromptFromText("Authorize access.", "Task-T/v1/unconstrained"); + client.generateAuthPromptFromText("Authorize access.", "Task-T/network-layer/unconstrained/v1"); MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", "Task-T/v1/unconstrained"); + client.generateNotificationPromptFromText("Report finished.", "Task-T/network-layer/unconstrained/v1"); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); @@ -389,9 +395,20 @@ private static Path writeMinimalClientEnvWithoutRequiredSlots(String provider) t Path slotPromptDir = promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); - Path templatesDir = - promptRoot.resolve("templates").resolve("Task-T").resolve("v1").resolve("unconstrained").resolve("zh-CN"); - Path slotsDir = promptRoot.resolve("slots").resolve("Task-T").resolve("v1").resolve("unconstrained").resolve("zh-CN"); + Path templatesDir = promptRoot + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("unconstrained") + .resolve("v1") + .resolve("zh-CN"); + Path slotsDir = promptRoot + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("unconstrained") + .resolve("v1") + .resolve("zh-CN"); Files.createDirectories(scenarioPromptDir); Files.createDirectories(slotPromptDir); Files.createDirectories(scenariosDir); @@ -483,9 +500,20 @@ private static Path writeMinimalClientEnv(Path tempDir, String provider) throws Path slotPromptDir = promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); - Path templatesDir = - promptRoot.resolve("templates").resolve("Task-T").resolve("v1").resolve("energy-saving").resolve("zh-CN"); - Path slotsDir = promptRoot.resolve("slots").resolve("Task-T").resolve("v1").resolve("energy-saving").resolve("zh-CN"); + Path templatesDir = promptRoot + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("energy-saving") + .resolve("v1") + .resolve("zh-CN"); + Path slotsDir = promptRoot + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("energy-saving") + .resolve("v1") + .resolve("zh-CN"); Files.createDirectories(scenarioPromptDir); Files.createDirectories(slotPromptDir); Files.createDirectories(scenariosDir); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index f997404d..59382f01 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -369,7 +369,7 @@ void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { new FakeSlotValueExtractor(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); assertEquals("template_not_found", ex.getCode()); } @@ -381,7 +381,7 @@ void generateFromTemplateUriWithMetadataPropagatesRenderException() { new FakeSlotValueExtractor(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); assertEquals("render_failed", ex.getCode()); } @@ -395,7 +395,7 @@ void generateFromTemplateUriWithMetadataPropagatesLlmRuntimeError() { }); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); assertEquals("llm_invocation_failed", ex.getCode()); } @@ -491,7 +491,7 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundException() { new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy_saving")); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri())); assertEquals("template_not_found", ex.getCode()); } @@ -503,7 +503,7 @@ void generateFromDataWithSchemaPropagatesRenderException() { new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy_saving")); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri())); assertEquals("render_failed", ex.getCode()); } @@ -515,7 +515,7 @@ void generateFromDataWithSchemaPropagatesLlmRuntimeError() { new FailingExtractSlotsWithSchema(new LLMRuntimeError("LLM invocation failed."))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy_saving")); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri())); assertEquals("llm_invocation_failed", ex.getCode()); } @@ -528,7 +528,7 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundExceptionFromExtractor( new ResourceNotFoundException("Slot schema file does not exist.", "energy-saving"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "Task-T/v1/energy_saving")); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri())); assertEquals("slot_schema_not_found", ex.getCode()); } @@ -545,9 +545,9 @@ void eachMetadataEntryPointReturnsCorrectMetadataContent() { MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri()); MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); - MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "Authorization-T/v1/database_read"); + MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "Authorization-T/database_read/v1"); MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "Authorization-T/v1/database_read"); + Map.of("site", "Site A"), Map.of(), "Authorization-T/database_read/v1"); MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING.uri()); MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); @@ -569,9 +569,9 @@ void eachMetadataEntryPointUsesCorrectExtensionUri() { MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri()); MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); - MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "Authorization-T/v1/database_read"); + MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "Authorization-T/database_read/v1"); MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "Authorization-T/v1/database_read"); + Map.of("site", "Site A"), Map.of(), "Authorization-T/database_read/v1"); MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING.uri()); MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); @@ -592,19 +592,19 @@ void authorizationMetadataEntryPointsPassAuthorizationTypeAsTemplateIdentifier() DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent nlResult = orchestrator.generateAuthPromptFromText("Grant read access.", "Authorization-T/v1/database_read"); + MetadataContent nlResult = orchestrator.generateAuthPromptFromText("Grant read access.", "Authorization-T/database_read/v1"); - assertEquals("Authorization-T/v1/database_read", nlResult.templateUri()); + assertEquals("Authorization-T/database_read/v1", nlResult.templateUri()); assertEquals("Access: read", nlResult.promptText()); - assertEquals("Authorization-T/v1/database_read", templateLoader.lastScenarioCode); - assertEquals("Authorization-T/v1/database_read", slotValueExtractor.lastScenarioCode); + assertEquals("Authorization-T/database_read/v1", templateLoader.lastScenarioCode); + assertEquals("Authorization-T/database_read/v1", slotValueExtractor.lastScenarioCode); assertEquals("Grant read access.", slotValueExtractor.lastUserInput); MetadataContent jsonResult = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("scope", "read"), Map.of("scope", "string"), "Authorization-T/v1/database_read"); + Map.of("scope", "read"), Map.of("scope", "string"), "Authorization-T/database_read/v1"); - assertEquals("Authorization-T/v1/database_read", jsonResult.templateUri()); - assertEquals("Authorization-T/v1/database_read", templateLoader.lastScenarioCode); + assertEquals("Authorization-T/database_read/v1", jsonResult.templateUri()); + assertEquals("Authorization-T/database_read/v1", templateLoader.lastScenarioCode); assertEquals(Map.of("scope", "read"), slotValueExtractor.lastUserInput); assertEquals(Map.of("scope", "string"), slotValueExtractor.lastSchema); } @@ -624,7 +624,7 @@ void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { schemaLoader); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); assertEquals("slot_validation_error", ex.getCode()); List failed = ex.failedParameters(); assertFalse(failed.isEmpty()); @@ -647,7 +647,7 @@ void validateRequiredSlotsThrowsSlotValidationErrorWhenMultipleRequiredSlotsMiss schemaLoader); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); assertEquals("slot_validation_error", ex.getCode()); assertEquals(1, ex.failedParameters().size()); assertEquals("target", ex.failedParameters().get(0).parameterName()); @@ -666,7 +666,7 @@ void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundWhenSchemaLoadin }); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); assertEquals("slot_schema_not_found", ex.getCode()); } @@ -683,7 +683,7 @@ void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundFromExtractor() EMPTY_SCHEMA_LOADER); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); assertEquals("slot_schema_not_found", ex.getCode()); } @@ -700,7 +700,7 @@ void generateFromTemplateUriWithMetadataThrowsPromptResourceLoadError() { EMPTY_SCHEMA_LOADER); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); assertEquals("prompt_resource_load_error", ex.getCode()); } @@ -717,7 +717,7 @@ void generateFromTemplateUriWithMetadataThrowsLlmInvocationFailedFromA2ATError() EMPTY_SCHEMA_LOADER); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", "Task-T/v1/energy_saving")); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); assertEquals("llm_invocation_failed", ex.getCode()); } } diff --git a/a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/energy-saving/en-US/slot.json b/a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/en-US/slot.json similarity index 100% rename from a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/energy-saving/en-US/slot.json rename to a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/en-US/slot.json diff --git a/a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/free-text/en-US/slot.json b/a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/network-layer/free-text/v1/en-US/slot.json similarity index 100% rename from a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/v1/free-text/en-US/slot.json rename to a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/network-layer/free-text/v1/en-US/slot.json diff --git a/a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/v1/energy-saving/en-US/template.md b/a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/en-US/template.md similarity index 100% rename from a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/v1/energy-saving/en-US/template.md rename to a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/en-US/template.md diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java index c5aa99cb..409866b4 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java @@ -10,7 +10,9 @@ * bound by the SDK, not by the caller. Use these constants instead of hand-written URI strings to get compile-time * safety against spelling drift. * - *

Example: {@code StandardTemplates.ENERGY_SAVING.uri()} is {@code Task-T/v1/energy-saving}. + *

Example: {@code StandardTemplates.ENERGY_SAVING.uri()} is {@code Task-T/network-layer/energy-saving/v1}. The + * Task-T and Notification-T templates carry the {@code network-layer} domain segment; Authorization-T and + * Negotiation-T templates do not. * * @since 2026-08 */ @@ -32,48 +34,54 @@ private StandardTemplates() { /** Extension name of the Negotiation-T template family. */ public static final String NEGOTIATION_EXTENSION_NAME = "Negotiation-T"; + /** Domain segment carried by the Task-T and Notification-T template paths. */ + public static final String NETWORK_LAYER_SEGMENT = "network-layer"; + + private static final String V1 = TemplateUri.DEFAULT_TEMPLATE_VERSION; + /** Task-T template for the energy-saving scenario. */ - public static final TemplateUri ENERGY_SAVING = TemplateUri.of(TASK_EXTENSION_NAME, "v1", "energy-saving"); + public static final TemplateUri ENERGY_SAVING = + TemplateUri.of(TASK_EXTENSION_NAME, V1, NETWORK_LAYER_SEGMENT, "energy-saving"); /** Task-T template for the private-line-complaint scenario. */ public static final TemplateUri PRIVATE_LINE_COMPLAINT = - TemplateUri.of(TASK_EXTENSION_NAME, "v1", "private-line-complaint"); + TemplateUri.of(TASK_EXTENSION_NAME, V1, NETWORK_LAYER_SEGMENT, "private-line-complaint"); /** Notification-T template for the subscribe-incident scenario. */ public static final TemplateUri SUBSCRIBE_INCIDENT = - TemplateUri.of(NOTIFICATION_EXTENSION_NAME, "v1", "subscribe-incident"); + TemplateUri.of(NOTIFICATION_EXTENSION_NAME, V1, NETWORK_LAYER_SEGMENT, "subscribe-incident"); /** Notification-T template for the service-recovery scenario. */ public static final TemplateUri SERVICE_RECOVERY = - TemplateUri.of(NOTIFICATION_EXTENSION_NAME, "v1", "service-recovery"); + TemplateUri.of(NOTIFICATION_EXTENSION_NAME, V1, NETWORK_LAYER_SEGMENT, "service-recovery"); - /** Authorization-T template for the authz-policy-mgr scenario. */ - public static final TemplateUri AUTHZ_POLICY_MGR = - TemplateUri.of(AUTHORIZATION_EXTENSION_NAME, "v1", "authz-policy-mgr"); + /** Authorization-T template for the authorization-policy-management scenario. */ + public static final TemplateUri AUTHORIZATION_POLICY_MANAGEMENT = + TemplateUri.of(AUTHORIZATION_EXTENSION_NAME, V1, "authorization-policy-management"); /** Negotiation-T propose template for information negotiation. */ public static final TemplateUri INFORMATION_NEGOTIATION_PROPOSE = - TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "v1", "information-negotiation", "propose"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, V1, "information-negotiation", "propose"); /** Negotiation-T accept-reject template for information negotiation. */ public static final TemplateUri INFORMATION_NEGOTIATION_ACCEPT_REJECT = - TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "v1", "information-negotiation", "accept-reject"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, V1, "information-negotiation", "accept-reject"); /** Negotiation-T propose template for target negotiation. */ public static final TemplateUri TARGET_NEGOTIATION_PROPOSE = - TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "v1", "target-negotiation", "propose"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, V1, "target-negotiation", "propose"); /** Negotiation-T accept-reject template for target negotiation. */ public static final TemplateUri TARGET_NEGOTIATION_ACCEPT_REJECT = - TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "v1", "target-negotiation", "accept-reject"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, V1, "target-negotiation", "accept-reject"); /** Negotiation-T propose template for feasibility negotiation. */ public static final TemplateUri FEASIBILITY_NEGOTIATION_PROPOSE = - TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "v1", "feasibility-negotiation", "propose"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, V1, "feasibility-negotiation", "propose"); /** Negotiation-T accept-reject template for feasibility negotiation. */ public static final TemplateUri FEASIBILITY_NEGOTIATION_ACCEPT_REJECT = - TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "v1", "feasibility-negotiation", "accept-reject"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, V1, "feasibility-negotiation", "accept-reject"); /** All built-in Task-T templates. */ public static final List TASK = List.of(ENERGY_SAVING, PRIVATE_LINE_COMPLAINT); @@ -82,7 +90,7 @@ private StandardTemplates() { public static final List NOTIFICATION = List.of(SUBSCRIBE_INCIDENT, SERVICE_RECOVERY); /** All built-in Authorization-T templates. */ - public static final List AUTHORIZATION = List.of(AUTHZ_POLICY_MGR); + public static final List AUTHORIZATION = List.of(AUTHORIZATION_POLICY_MANAGEMENT); /** All built-in Negotiation-T templates. */ public static final List NEGOTIATION = List.of( diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java index e883fae7..c8af4f63 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java @@ -11,58 +11,64 @@ * Structured, always-valid identifier of a content template. * *

A template URI is composed of an extension name ({@code Task-T}, {@code Notification-T}, {@code Negotiation-T}, - * {@code Authorization-T}), a version segment and at least one trailing segment (a scenario code, or the type and - * phase segments of a negotiation template). Constructing a {@code TemplateUri} validates every segment, so a value - * of this type can never carry a malformed URI — the structural invariants that validators previously re-derived by - * splitting raw strings are encoded in the type itself. + * {@code Authorization-T}), at least one path segment (a scenario code, optionally prefixed with the + * {@code network-layer} domain segment, or the type and phase segments of a negotiation template) and a trailing + * template version. Constructing a {@code TemplateUri} validates every segment, so a value of this type can never + * carry a malformed URI — the structural invariants that validators previously re-derived by splitting raw strings + * are encoded in the type itself. The URI mirrors the resource directory layout one-to-one: + * {@code templates/////template.md}. * *

The language is deliberately not part of the URI: it is global runtime context resolved from the prompt * configuration by whichever component needs it, never an addressability dimension of the template. * * @param extensionName first URI segment identifying the template family, such as {@code Task-T} - * @param version second URI segment, such as {@code v1} - * @param segments trailing URI segments, such as {@code [energy-saving]} or {@code [information-negotiation, propose]} + * @param pathSegments middle URI segments, such as {@code [network-layer, energy-saving]} or + * {@code [information-negotiation, propose]} + * @param templateVersion trailing URI segment, such as {@code v1} * @since 2026-08 */ -public record TemplateUri(String extensionName, String version, List segments) { +public record TemplateUri(String extensionName, List pathSegments, String templateVersion) { + + /** Default template version segment used by all built-in templates. */ + public static final String DEFAULT_TEMPLATE_VERSION = "v1"; /** - * Validates the components and defensively copies the segment list. + * Validates the components and defensively copies the path segment list. * - * @throws NullPointerException if the extension name, version or segment list is null - * @throws IllegalArgumentException if any component is not a simple path segment or the segment list is empty + * @throws NullPointerException if the extension name, path segment list or template version is null + * @throws IllegalArgumentException if any component is not a simple path segment or the path segment list is empty */ public TemplateUri { validateSegment(extensionName, "Extension name"); - validateSegment(version, "Version"); - Objects.requireNonNull(segments, "Template URI segments must not be null."); - if (segments.isEmpty()) { - throw new IllegalArgumentException("Template URI must have at least one trailing segment."); + Objects.requireNonNull(pathSegments, "Template URI path segments must not be null."); + if (pathSegments.isEmpty()) { + throw new IllegalArgumentException("Template URI must have at least one path segment."); } - for (String segment : segments) { - validateSegment(segment, "Template URI segment"); + for (String segment : pathSegments) { + validateSegment(segment, "Template URI path segment"); } - segments = List.copyOf(segments); + validateSegment(templateVersion, "Template version"); + pathSegments = List.copyOf(pathSegments); } /** * Creates a template URI from its components. * * @param extensionName first URI segment identifying the template family, such as {@code Task-T} - * @param version second URI segment, such as {@code v1} - * @param segments trailing URI segments, such as {@code energy-saving} + * @param templateVersion trailing URI segment, such as {@code v1} + * @param pathSegments middle URI segments, such as {@code network-layer, energy-saving} * @return validated template URI * @throws NullPointerException if any component is null - * @throws IllegalArgumentException if any component is not a simple path segment or no trailing segment is given + * @throws IllegalArgumentException if any component is not a simple path segment or no path segment is given */ - public static TemplateUri of(String extensionName, String version, String... segments) { - return new TemplateUri(extensionName, version, List.of(segments)); + public static TemplateUri of(String extensionName, String templateVersion, String... pathSegments) { + return new TemplateUri(extensionName, List.of(pathSegments), templateVersion); } /** * Tries to parse a raw template URI into its components. * - * @param templateUri template URI such as {@code Task-T/v1/energy-saving} + * @param templateUri template URI such as {@code Task-T/network-layer/energy-saving/v1} * @return parsed template URI, or an empty result when the input is null, blank, has fewer than three segments or * contains a segment that is not a simple path segment */ @@ -79,16 +85,21 @@ public static Optional parse(String templateUri) { return Optional.empty(); } } - return Optional.of(new TemplateUri(parts[0], parts[1], List.of(parts).subList(2, parts.length))); + return Optional.of(new TemplateUri( + parts[0], + List.of(parts).subList(1, parts.length - 1), + parts[parts.length - 1])); } /** * Returns the raw template URI. * - * @return template URI such as {@code Negotiation-T/v1/feasibility-negotiation/propose} + * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} */ public String uri() { - return Stream.concat(Stream.of(extensionName, version), segments.stream()) + return Stream.concat( + Stream.concat(Stream.of(extensionName), pathSegments.stream()), + Stream.of(templateVersion)) .collect(Collectors.joining("/")); } diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java index 721bf6b3..9f884de1 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -11,25 +12,33 @@ class TemplateUriTest { @Test void composesUriFromComponents() { - TemplateUri uri = TemplateUri.of("Task-T", "v1", "energy-saving"); - assertEquals("Task-T/v1/energy-saving", uri.uri()); + TemplateUri uri = TemplateUri.of("Task-T", "v1", "network-layer", "energy-saving"); + assertEquals("Task-T/network-layer/energy-saving/v1", uri.uri()); assertEquals("Task-T", uri.extensionName()); - assertEquals("v1", uri.version()); - assertEquals(List.of("energy-saving"), uri.segments()); + assertEquals(List.of("network-layer", "energy-saving"), uri.pathSegments()); + assertEquals("v1", uri.templateVersion()); } @Test - void parseRoundTripsSingleTrailingSegment() { - Optional parsed = TemplateUri.parse("Task-T/v1/energy-saving"); - assertEquals(Optional.of(TemplateUri.of("Task-T", "v1", "energy-saving")), parsed); - assertEquals("Task-T/v1/energy-saving", parsed.orElseThrow().uri()); + void parseRoundTripsNetworkLayerUri() { + Optional parsed = TemplateUri.parse("Task-T/network-layer/energy-saving/v1"); + assertEquals( + Optional.of(TemplateUri.of("Task-T", "v1", "network-layer", "energy-saving")), parsed); + assertEquals("Task-T/network-layer/energy-saving/v1", parsed.orElseThrow().uri()); + } + + @Test + void parseRoundTripsAuthorizationUri() { + Optional parsed = TemplateUri.parse("Authorization-T/authorization-policy-management/v1"); + assertEquals( + Optional.of(TemplateUri.of("Authorization-T", "v1", "authorization-policy-management")), parsed); } @Test - void parseRoundTripsTwoTrailingSegments() { - Optional parsed = TemplateUri.parse("Negotiation-T/v1/feasibility-negotiation/propose"); + void parseRoundTripsNegotiationUri() { + Optional parsed = TemplateUri.parse("Negotiation-T/information-negotiation/propose/v1"); assertEquals( - Optional.of(TemplateUri.of("Negotiation-T", "v1", "feasibility-negotiation", "propose")), parsed); + Optional.of(TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose")), parsed); } @Test @@ -38,9 +47,9 @@ void parseRejectsMalformedUris() { assertEquals(Optional.empty(), TemplateUri.parse(" ")); assertEquals(Optional.empty(), TemplateUri.parse("Task-T")); assertEquals(Optional.empty(), TemplateUri.parse("Task-T/v1")); - assertEquals(Optional.empty(), TemplateUri.parse("Task-T/v1/energy-saving/../etc")); - assertEquals(Optional.empty(), TemplateUri.parse("Task-T/v1/energy\\saving")); - assertEquals(Optional.empty(), TemplateUri.parse("Task-T//energy-saving")); + assertEquals(Optional.empty(), TemplateUri.parse("Task-T/network-layer/energy-saving/../etc")); + assertEquals(Optional.empty(), TemplateUri.parse("Task-T/network-layer/energy\\saving/v1")); + assertEquals(Optional.empty(), TemplateUri.parse("Task-T//energy-saving/v1")); } @Test @@ -48,17 +57,18 @@ void ofRejectsInvalidComponents() { assertThrows(NullPointerException.class, () -> TemplateUri.of(null, "v1", "scenario")); assertThrows(NullPointerException.class, () -> TemplateUri.of("Task-T", null, "scenario")); assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "v1")); - assertThrows(NullPointerException.class, () -> new TemplateUri("Task-T", "v1", (List) null)); + assertThrows(NullPointerException.class, () -> new TemplateUri("Task-T", (List) null, "v1")); assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "v1", "scen/ario")); assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "v1", "scen..ario/x")); assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "v1", " ")); + assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", " ", "scenario")); } @Test - void segmentsAreDefensivelyCopied() { - java.util.ArrayList segments = new java.util.ArrayList<>(List.of("energy-saving")); - TemplateUri uri = new TemplateUri("Task-T", "v1", segments); + void pathSegmentsAreDefensivelyCopied() { + ArrayList segments = new ArrayList<>(List.of("network-layer", "energy-saving")); + TemplateUri uri = new TemplateUri("Task-T", segments, "v1"); segments.add("tampered"); - assertEquals(List.of("energy-saving"), uri.segments()); + assertEquals(List.of("network-layer", "energy-saving"), uri.pathSegments()); } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java index 5092cc71..94bcd59f 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -11,6 +11,7 @@ import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.core.resources.PathSegments; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import net.openan.a2at.sdk.core.model.PromptTemplate; @@ -112,9 +113,9 @@ private static String templateRelativePath(NegotiationReference reference) { "/", "templates", "Negotiation-T", - "v1", reference.typeSegment(), reference.phase().uriSegment(), + TemplateUri.DEFAULT_TEMPLATE_VERSION, reference.language(), TEMPLATE_FILE_NAME); } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java index db551f1e..987e84c6 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -5,6 +5,7 @@ import java.util.Optional; import net.openan.a2at.sdk.core.resources.PathSegments; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; @@ -23,7 +24,7 @@ public record NegotiationReference(NegotiationType type, NegotiationPhase phase, private static final String URI_PREFIX = StandardTemplates.NEGOTIATION_EXTENSION_NAME; - private static final String URI_VERSION_SEGMENT = "v1"; + private static final String URI_VERSION_SEGMENT = TemplateUri.DEFAULT_TEMPLATE_VERSION; private static final String TYPE_SEGMENT_SUFFIX = "-negotiation"; @@ -54,10 +55,10 @@ public String typeSegment() { /** * Returns the template URI of the referenced template. * - * @return template URI such as {@code Negotiation-T/v1/information-negotiation/propose} + * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} */ public String uri() { - return String.join("/", URI_PREFIX, URI_VERSION_SEGMENT, typeSegment(), phase.uriSegment()); + return String.join("/", URI_PREFIX, typeSegment(), phase.uriSegment(), URI_VERSION_SEGMENT); } /** @@ -66,12 +67,12 @@ public String uri() { *

The URI layer cannot distinguish accept from reject because both share the {@code accept-reject} segment; the * expected phase disambiguates the parsed result, which therefore always carries the expected phase. * - * @param templateUri template URI to parse, such as {@code Negotiation-T/v1/target-negotiation/accept-reject} + * @param templateUri template URI to parse, such as {@code Negotiation-T/target-negotiation/accept-reject/v1} * @param expectedPhase API-level phase the caller is operating on; the parsed reference carries this phase * @param language locale identifier for the parsed reference * @return reference addressed by the URI carrying the expected phase, or an empty result when the URI is null, - * blank or malformed (wrong segment count, prefix, version or type segment) or its phase segment does not match - * the expected phase + * blank or malformed (wrong segment count, prefix, type segment or trailing version segment) or its phase + * segment does not match the expected phase * @throws NullPointerException if the expected phase is null */ public static Optional tryParse( @@ -87,14 +88,14 @@ public static Optional tryParse( if (!URI_PREFIX.equals(segments[0])) { return Optional.empty(); } - if (!URI_VERSION_SEGMENT.equals(segments[1])) { + NegotiationType parsedType = parseTypeSegment(segments[1]); + if (parsedType == null) { return Optional.empty(); } - NegotiationType parsedType = parseTypeSegment(segments[2]); - if (parsedType == null) { + if (!phaseSegmentMatches(segments[2], expectedPhase)) { return Optional.empty(); } - if (!phaseSegmentMatches(segments[3], expectedPhase)) { + if (!URI_VERSION_SEGMENT.equals(segments[3])) { return Optional.empty(); } return Optional.of(new NegotiationReference(parsedType, expectedPhase, language)); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java index e186b0a2..657e68ae 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java @@ -43,9 +43,9 @@ void everyPackagedTemplateIsByteIdenticalToTheDeliveredFile() throws IOException "/", "templates", "Negotiation-T", - "v1", type.typeSegment(), phase.uriSegment(), + "v1", language, "template.md"); byte[] packaged = readClasspathBytes("prompt_resources/" + relativePath); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java index 4f10497d..308fe4bf 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java @@ -223,35 +223,35 @@ context, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " ")), "template URI with too many segments", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), - "Negotiation-T/v1/information-negotiation/propose/extra"), + "Negotiation-T/information-negotiation/propose/v1/extra"), IllegalArgumentException.class, "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), new MatrixRow( "template URI with wrong prefix", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), - "Task-T/v1/information-negotiation/propose"), + "Task-T/information-negotiation/propose/v1"), IllegalArgumentException.class, "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), new MatrixRow( "template URI with wrong version", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), - "Negotiation-T/v2/information-negotiation/propose"), + "Negotiation-T/information-negotiation/propose/v2"), IllegalArgumentException.class, "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), new MatrixRow( "template URI with underscore type segment", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), - "Negotiation-T/v1/information_negotiation/propose"), + "Negotiation-T/information_negotiation/propose/v1"), IllegalArgumentException.class, "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), new MatrixRow( "template URI with unknown type", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), - "Negotiation-T/v1/unknown-negotiation/propose"), + "Negotiation-T/unknown-negotiation/propose/v1"), IllegalArgumentException.class, "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), new MatrixRow( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 3e13381f..cb6f6b50 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -132,7 +132,7 @@ void queriesSingleTemplateByUri() { .getNegotiationPrompt(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri()) .isPresent()); assertFalse(zhOrchestrator() - .getNegotiationPrompt("Negotiation-T/v1/unknown-negotiation/propose") + .getNegotiationPrompt("Negotiation-T/unknown-negotiation/propose/v1") .isPresent()); assertFalse(zhOrchestrator().getNegotiationPrompt("malformed-uri").isPresent()); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java index b1dc1223..d1ee5458 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java @@ -93,8 +93,8 @@ void messageBuilderReplacesSemanticValidationTokens() { String userPrompt = messages.get(1).get("content"); assertTrue(userPrompt.contains("Declared negotiation type: information")); - assertTrue( - userPrompt.contains("Declared template identifier: Negotiation-T/v1/information-negotiation/propose")); + assertTrue(userPrompt.contains( + "Declared template identifier: " + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri())); assertTrue(userPrompt.contains("Parameter schema:")); assertTrue(userPrompt.contains("{\"type\":\"object\"}")); assertTrue(userPrompt.contains("Negotiation message to validate:")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java index 8eb3da66..4aa3d190 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java @@ -73,7 +73,7 @@ void listQueryReturnsSixTemplatesInTheFixedOrder(String language) { assertEquals( EXPECTED_URI_ORDER, templates.stream().map(PromptTemplate::uri).toList()); for (PromptTemplate template : templates) { - String typeSegment = template.uri().split("/")[2]; + String typeSegment = template.uri().split("/")[1]; assertFalse(typeSegment.contains("_"), "type segments must use hyphens: " + template.uri()); assertTrue(typeSegment.endsWith("-negotiation"), "type segment must carry the suffix: " + template.uri()); assertFalse(template.content().isBlank()); @@ -104,7 +104,7 @@ void singleQueryAnswersAMissingUriWithEmptyAndAWarning() { NegotiationGenerationOrchestrator orchestrator = orchestrator("zh-CN"); Optional template = - orchestrator.getNegotiationPrompt("Negotiation-T/v1/unknown-negotiation/propose"); + orchestrator.getNegotiationPrompt("Negotiation-T/unknown-negotiation/propose/v1"); assertTrue(template.isEmpty()); assertTrue(hasWarning("negotiation_template_not_found", "A2AT_LANGUAGE")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index e496aa11..a79d6f85 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -51,7 +51,7 @@ class TemplateUriEntryMatrixTest { private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); private static final String BUILTIN_INFORMATION_PROPOSE_TEMPLATE = - "templates/Negotiation-T/v1/information-negotiation/propose/zh-CN/template.md"; + "templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md"; /** * Entry (a): every one of the six built-in URIs reaches its template and renders output byte-identical to the @@ -117,14 +117,14 @@ void wellFormedUriMissingInBothRootsFailsWithTemplateNotFound() { "foo", "foo/bar", "information-negotiation/propose", - "Negotiation-T/v1/information-negotiation/propose/extra", - "Task-T/v1/information-negotiation/propose", - "Negotiation-T/v2/information-negotiation/propose", - "Negotiation-T/v1/information/propose", - "Negotiation-T/v1/information_negotiation/propose", - "Negotiation-T/v1/unknown-negotiation/propose", - "Negotiation-T/v1/information-negotiation/propose-x", - "Negotiation-T/v1/information-negotiation/accept" + "Negotiation-T/information-negotiation/propose/v1/extra", + "Task-T/information-negotiation/propose/v1", + "Negotiation-T/information-negotiation/propose/v2", + "Negotiation-T/information/propose/v1", + "Negotiation-T/information_negotiation/propose/v1", + "Negotiation-T/unknown-negotiation/propose/v1", + "Negotiation-T/information-negotiation/propose-x/v1", + "Negotiation-T/information-negotiation/accept/v1" }) void malformedUriIsRejectedAsATemplateUriProgrammingError(String templateUri) { CountingClient llm = new CountingClient(); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java index 2655ca2b..0aae02cd 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java @@ -235,10 +235,10 @@ public NegotiationPhase phase() { /** * Returns the built-in template URI addressed by this fixture. * - * @return template URI such as {@code Negotiation-T/v1/information-negotiation/propose} + * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} */ public String templateUri() { - return "Negotiation-T/v1/" + typeSegment + "/" + phase.uriSegment(); + return "Negotiation-T/" + typeSegment + "/" + phase.uriSegment() + "/v1"; } /** diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java index 3194263f..f2ed70b0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java @@ -110,9 +110,9 @@ private Path writeCustomizedInformationProposeTemplate() throws IOException { Path customTemplate = customRoot .resolve("templates") .resolve("Negotiation-T") - .resolve("v1") .resolve("information-negotiation") .resolve("propose") + .resolve("v1") .resolve("zh-CN") .resolve("template.md"); Files.createDirectories(customTemplate.getParent()); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index a6a451ca..f0f5513f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -365,7 +365,7 @@ public PromptTemplate load(NegotiationReference reference) { @Override public List loadAll() { throw new net.openan.a2at.sdk.core.exception.ResourceNotFoundException( - "No negotiation template exists for the configured language.", "templates/Negotiation-T/v1"); + "No negotiation template exists for the configured language.", "templates/Negotiation-T"); } }; } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java index 3380be02..3a03f4ec 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java @@ -74,9 +74,9 @@ void customRootTemplateWinsWhilePresentAndBuiltinIsUsedAfterRemoval() throws IOE Path customTemplatePath = customRootDir .resolve("templates") .resolve("Negotiation-T") - .resolve("v1") .resolve("information-negotiation") .resolve("propose") + .resolve("v1") .resolve("zh-CN") .resolve("template.md"); writeTemplate(customTemplatePath, "\n\n## 协商上下文\n自定义标记内容\n"); @@ -107,9 +107,9 @@ void loadAllWithCustomRootStillListsEveryTemplate() throws IOException { Path customTemplatePath = customRootDir .resolve("templates") .resolve("Negotiation-T") - .resolve("v1") .resolve("target-negotiation") .resolve("propose") + .resolve("v1") .resolve("zh-CN") .resolve("template.md"); writeTemplate(customTemplatePath, "\n\n## 目标协商\n自定义目标内容\n"); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java index e5305343..ef1fd6f0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java @@ -61,43 +61,47 @@ void tryParseRejectsWrongSegmentCount() { assertTrue(NegotiationReference.tryParse("information-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); assertTrue(NegotiationReference.tryParse( - "Negotiation-T/v1/information-negotiation/propose/extra", NegotiationPhase.PROPOSE, "zh-CN") + "Negotiation-T/information-negotiation/propose/v1/extra", NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); assertTrue(NegotiationReference.tryParse("foo", NegotiationPhase.PROPOSE, "zh-CN").isEmpty()); } @Test void tryParseRejectsWrongPrefixAndVersion() { - assertTrue(NegotiationReference.tryParse("Task-T/v1/information-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") + assertTrue(NegotiationReference.tryParse( + "Task-T/information-negotiation/propose/v1", NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); assertTrue(NegotiationReference.tryParse( - "negotiation-t/v1/information-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") + "negotiation-t/information-negotiation/propose/v1", NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); - assertTrue(NegotiationReference.tryParse("Negotiation-T/v2/information-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") + assertTrue(NegotiationReference.tryParse( + "Negotiation-T/information-negotiation/propose/v2", NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); } @Test void tryParseRejectsMissingTypeSegmentSuffixAndUnderscoreVariant() { - assertTrue(NegotiationReference.tryParse("Negotiation-T/v1/information/propose", NegotiationPhase.PROPOSE, "zh-CN") + assertTrue(NegotiationReference.tryParse("Negotiation-T/information/propose/v1", NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); assertTrue(NegotiationReference.tryParse( - "Negotiation-T/v1/information_negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") + "Negotiation-T/information_negotiation/propose/v1", NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); } @Test void tryParseRejectsUnknownType() { - assertTrue(NegotiationReference.tryParse("Negotiation-T/v1/unknown-negotiation/propose", NegotiationPhase.PROPOSE, "zh-CN") + assertTrue(NegotiationReference.tryParse( + "Negotiation-T/unknown-negotiation/propose/v1", NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); } @Test void tryParseRejectsIllegalPhaseSegment() { assertTrue(NegotiationReference.tryParse( - "Negotiation-T/v1/information-negotiation/propose-x", NegotiationPhase.PROPOSE, "zh-CN") + "Negotiation-T/information-negotiation/propose-x/v1", NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); - assertTrue(NegotiationReference.tryParse("Negotiation-T/v1/information-negotiation/accept", NegotiationPhase.PROPOSE, "zh-CN") + assertTrue(NegotiationReference.tryParse( + "Negotiation-T/information-negotiation/accept/v1", NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); } @@ -121,11 +125,11 @@ void tryParseReturnsEmptyForBlankAndNullUriButThrowsOnNullExpectedPhase() { } private static String proposeUri(NegotiationType type) { - return "Negotiation-T/v1/" + type.typeSegment() + "/propose"; + return "Negotiation-T/" + type.typeSegment() + "/propose/v1"; } private static String acceptRejectUri(NegotiationType type) { - return "Negotiation-T/v1/" + type.typeSegment() + "/accept-reject"; + return "Negotiation-T/" + type.typeSegment() + "/accept-reject/v1"; } private static NegotiationReference requirePresent(Optional reference) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java index 803debe4..72462d2b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java @@ -77,7 +77,7 @@ void exhaustedRoundBudgetEndsWithARejectMessageThePeerStillAccepts() { assertEquals(1, peerLlm.callCount(), "the rule gate must fail before any semantic LLM call"); assertTrue( - agent.getNegotiationPrompt("Negotiation-T/v1/target-negotiation/abort") + agent.getNegotiationPrompt("Negotiation-T/target-negotiation/abort/v1") .isEmpty(), "no template of an abort phase exists anywhere"); } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptScenarioCatalogLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptScenarioCatalogLoader.java index 37e385c7..a837d29d 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptScenarioCatalogLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptScenarioCatalogLoader.java @@ -23,8 +23,7 @@ public ClasspathPromptScenarioCatalogLoader(ClasspathPromptResourceLoader resour } public List load(String language) { - String payload = - resourceLoader.loadText(new PromptResourceKey("scenarios", "catalog", language, "scenarios.json")); + String payload = resourceLoader.loadText(PromptResourceKey.scenario(language, "scenarios.json")); try { return PromptResourceJsonParser.parse(payload, ScenarioCatalog.class) .scenarios(); diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java index 38e7954e..3c4e7a93 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java @@ -8,6 +8,7 @@ import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.resources.ClasspathResourceDirectories; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; @@ -37,21 +38,47 @@ public ClasspathPromptSlotSchemaLoader(ClasspathPromptResourceLoader resourceLoa @Override public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { + java.util.Optional parsed = TemplateUri.parse(scenarioCode); + if (parsed.isPresent()) { + return load(parsed.orElseThrow(), scenarioCode, language); + } for (String slotType : SLOT_TYPES) { - try { - String payload = resourceLoader.loadText( - new PromptResourceKey("slots", slotType, scenarioCode, language, "slot.json")); - return PromptResourceJsonParser.parse(payload, PromptSlotJsonSchema.class) - .toPromptSlotSchema(scenarioCode); - } catch (ResourceNotFoundException ignored) { - // try next slot type - } catch (JsonProcessingException exception) { - throw new A2ATError("Failed to parse slot schema: " + scenarioCode, exception); + for (TemplateUri candidate : bareCodeCandidates(slotType, scenarioCode)) { + try { + return load(candidate, scenarioCode, language); + } catch (ResourceNotFoundException ignored) { + // try next candidate layout + } } } throw new ResourceNotFoundException( "Prompt resource file does not exist.", - "prompt_resources/slots/*/v1/" + scenarioCode + "/" + language + "/slot.json"); + "prompt_resources/slots/*/network-layer/" + scenarioCode + "/v1/" + language + + "/slot.json (or the layout without the network-layer segment)"); + } + + private PromptSlotSchema load(TemplateUri candidate, String scenarioCode, String language) { + try { + String payload = resourceLoader.loadText(PromptResourceKey.slotSchema(candidate, language, "slot.json")); + return PromptResourceJsonParser.parse(payload, PromptSlotJsonSchema.class) + .toPromptSlotSchema(scenarioCode); + } catch (JsonProcessingException exception) { + throw new A2ATError("Failed to parse slot schema: " + scenarioCode, exception); + } + } + + /** + * Returns the candidate template URIs a bare scenario code can address under one slot type: the + * {@code network-layer} domain layout first, then the plain layout. + */ + private static List bareCodeCandidates(String slotType, String scenarioCode) { + return List.of( + TemplateUri.of( + slotType, + TemplateUri.DEFAULT_TEMPLATE_VERSION, + StandardTemplates.NETWORK_LAYER_SEGMENT, + scenarioCode), + TemplateUri.of(slotType, TemplateUri.DEFAULT_TEMPLATE_VERSION, scenarioCode)); } private static List discoverSlotTypes() { diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java index a5ca9586..52626e09 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java @@ -2,19 +2,23 @@ import java.util.LinkedHashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.resources.ClasspathResourceDirectories; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; import net.openan.a2at.sdk.resources.PromptResourceKey; /** * Loads shared prompt templates from packaged classpath prompt resources. * - *

The template types are probed in a fixed order first and then in the order the extension directories appear - * under {@code prompt_resources/templates/} on the classpath, so extensions bundled later — such as Authorization-T — - * are loadable without extending a hardcoded list. + *

A full template URI resolves directly to {@code templates///template.md}. A bare scenario + * code is probed per template type — first in a fixed order and then in the order the extension directories appear + * under {@code prompt_resources/templates/} on the classpath — trying both the {@code network-layer} domain layout and + * the plain layout, so extensions bundled later — such as Authorization-T — are loadable without extending a hardcoded + * list. * * @since 2026-06 */ @@ -33,17 +37,37 @@ public ClasspathPromptTemplateLoader(ClasspathPromptResourceLoader resourceLoade @Override public String loadTemplate(String scenarioCode, String language) { + Optional parsed = TemplateUri.parse(scenarioCode); + if (parsed.isPresent()) { + return resourceLoader.loadText(PromptResourceKey.template(parsed.orElseThrow(), language, "template.md")); + } for (String templateType : TEMPLATE_TYPES) { - try { - return resourceLoader.loadText( - PromptResourceKey.template(templateType, scenarioCode, language, "template.md")); - } catch (ResourceNotFoundException ignored) { - // try next template type + for (TemplateUri candidate : bareCodeCandidates(templateType, scenarioCode)) { + try { + return resourceLoader.loadText(PromptResourceKey.template(candidate, language, "template.md")); + } catch (ResourceNotFoundException ignored) { + // try next candidate layout + } } } throw new ResourceNotFoundException( "Prompt resource file does not exist.", - "prompt_resources/templates/*/v1/" + scenarioCode + "/" + language + "/template.md"); + "prompt_resources/templates/*/network-layer/" + scenarioCode + "/v1/" + language + + "/template.md (or the layout without the network-layer segment)"); + } + + /** + * Returns the candidate template URIs a bare scenario code can address under one template type: the + * {@code network-layer} domain layout first, then the plain layout. + */ + private static List bareCodeCandidates(String templateType, String scenarioCode) { + return List.of( + TemplateUri.of( + templateType, + TemplateUri.DEFAULT_TEMPLATE_VERSION, + StandardTemplates.NETWORK_LAYER_SEGMENT, + scenarioCode), + TemplateUri.of(templateType, TemplateUri.DEFAULT_TEMPLATE_VERSION, scenarioCode)); } private static List discoverTemplateTypes() { diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java index 1685be94..99b0ad14 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java @@ -38,7 +38,7 @@ public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { try (var typePaths = Files.list(slotsRoot)) { Optional match = typePaths .filter(Files::isDirectory) - .map(typeDir -> typeDir.resolve("v1").resolve(scenarioCode).resolve(language).resolve("slot.json")) + .map(typeDir -> resolveBareCode(typeDir, scenarioCode, language)) .filter(Files::exists) .findFirst(); schemaPath = match.orElse(null); @@ -57,11 +57,24 @@ public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { } } + /** + * Resolves a bare scenario code under one slot type directory, preferring the {@code network-layer} domain layout + * over the plain layout. + */ + private static Path resolveBareCode(Path typeDir, String scenarioCode, String language) { + Path networkLayer = typeDir.resolve("network-layer").resolve(scenarioCode).resolve("v1"); + if (Files.exists(networkLayer.resolve(language).resolve("slot.json"))) { + return networkLayer.resolve(language).resolve("slot.json"); + } + return typeDir.resolve(scenarioCode).resolve("v1").resolve(language).resolve("slot.json"); + } + private ResourceNotFoundException notFound(String scenarioCode, String language) { String pathHint = scenarioCode.contains("/") ? promptRootDir.resolve("slots").resolve(scenarioCode).resolve(language).resolve("slot.json").toString() : promptRootDir.resolve("slots").toString() - + "/*/v1/" + scenarioCode + "/" + language + "/slot.json"; + + "/*/network-layer/" + scenarioCode + "/v1/" + language + + "/slot.json (or the layout without the network-layer segment)"; return new ResourceNotFoundException("Prompt resource file does not exist.", pathHint); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java index 2c2314c0..c80b2f74 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java @@ -36,7 +36,7 @@ public String loadTemplate(String scenarioCode, String language) { try (var typePaths = Files.list(templatesRoot)) { Optional match = typePaths .filter(Files::isDirectory) - .map(typeDir -> typeDir.resolve("v1").resolve(scenarioCode).resolve(language).resolve("template.md")) + .map(typeDir -> resolveBareCode(typeDir, scenarioCode, language, "template.md")) .filter(Files::exists) .findFirst(); templatePath = match.orElse(null); @@ -54,11 +54,23 @@ public String loadTemplate(String scenarioCode, String language) { } } + /** + * Resolves a bare scenario code under one template type directory, preferring the {@code network-layer} domain + * layout over the plain layout. + */ + private static Path resolveBareCode(Path typeDir, String scenarioCode, String language, String fileName) { + Path networkLayer = typeDir.resolve("network-layer").resolve(scenarioCode).resolve("v1"); + if (Files.exists(networkLayer.resolve(language).resolve(fileName))) { + return networkLayer.resolve(language).resolve(fileName); + } + return typeDir.resolve(scenarioCode).resolve("v1").resolve(language).resolve(fileName); + } + private ResourceNotFoundException notFound(String scenarioCode, String language) { String pathHint = scenarioCode.contains("/") ? promptRootDir.resolve("templates").resolve(scenarioCode).resolve(language).resolve("template.md").toString() : promptRootDir.resolve("templates").toString() - + "/*/v1/" + scenarioCode + "/" + language + "/template.md"; + + "/*/network-layer/" + scenarioCode + "/v1/" + language + "/template.md (or the layout without the network-layer segment)"; return new ResourceNotFoundException("Prompt resource file does not exist.", pathHint); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java index d69ab3f1..1bf87c4f 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -64,11 +64,12 @@ public FilledParamData validate(String prompt, Map schema, Strin String[] parts = templateUri.split("/"); if (parts.length < 3) { throw new IllegalArgumentException( - "Template URI format must be {prefix}/v1/{scenario}, got: " + templateUri); + "Template URI format must be {extensionName}/{pathSegments}/{templateVersion}, got: " + + templateUri); } String prefix = parts[0]; - String version = parts[1]; + String templateVersion = parts[parts.length - 1]; if (!extensionName.equals(prefix)) { throw new IllegalArgumentException( @@ -76,11 +77,12 @@ public FilledParamData validate(String prompt, Map schema, Strin + "'."); } - if (!"v1".equals(version)) { - throw new IllegalArgumentException("Unsupported template URI version: " + version); + if (!TemplateUri.DEFAULT_TEMPLATE_VERSION.equals(templateVersion)) { + throw new IllegalArgumentException("Unsupported template URI version: " + templateVersion); } - TemplateUri reference = TemplateUri.of(prefix, version, java.util.Arrays.copyOfRange(parts, 2, parts.length)); + TemplateUri reference = TemplateUri.of( + prefix, templateVersion, java.util.Arrays.copyOfRange(parts, 1, parts.length - 1)); return pipeline().validate(prompt, schema, reference); } diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java index 97172dbf..be1caa4f 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java @@ -82,7 +82,10 @@ void missingClasspathResourceReportsResolvedResourcePath() { assertThrows(ResourceNotFoundException.class, () -> new ClasspathPromptTemplateLoader(resourceLoader) .loadTemplate("missing_scenario", "en")); - assertEquals("prompt_resources/templates/*/v1/missing_scenario/en/template.md", exception.resourcePath()); + assertEquals( + "prompt_resources/templates/*/network-layer/missing_scenario/v1/en/template.md" + + " (or the layout without the network-layer segment)", + exception.resourcePath()); } @Test diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java index dc3d4a6e..72dbb248 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java @@ -63,8 +63,9 @@ void loadTemplateReadsExactLocalMarkdownText() throws IOException { promptRootDir .resolve("templates") .resolve("Task-T") - .resolve("v1") + .resolve("network-layer") .resolve("incident_triage") + .resolve("v1") .resolve("en") .resolve("template.md"), """ @@ -92,8 +93,9 @@ void loadSlotSchemaMapsJacksonAnnotatedRecords() throws IOException { promptRootDir .resolve("slots") .resolve("Task-T") - .resolve("v1") + .resolve("network-layer") .resolve("incident_triage") + .resolve("v1") .resolve("en") .resolve("slot.json"), """ @@ -139,7 +141,8 @@ void missingTemplateIncludesResolvedLocalPathInResourceNotFoundException() { .loadTemplate("incident_triage", "en")); String expected = (promptRootDir.resolve("templates").toString() - + "/*/v1/incident_triage/en/template.md").replace('\\', '/'); + + "/*/network-layer/incident_triage/v1/en/template.md" + + " (or the layout without the network-layer segment)").replace('\\', '/'); assertEquals(expected, exception.resourcePath().replace('\\', '/')); } @@ -149,8 +152,9 @@ void malformedLocalSlotSchemaIsWrappedAsA2ATError() throws IOException { promptRootDir .resolve("slots") .resolve("Task-T") - .resolve("v1") + .resolve("network-layer") .resolve("incident_triage") + .resolve("v1") .resolve("en") .resolve("slot.json"), "{ \"required\": [\"severity\"], \"properties\": "); diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java index 0a35bd7a..3e3674a2 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java @@ -33,8 +33,9 @@ void createLocalFileAccessLoadsPromptsTemplatesScenariosAndSlotsFromSameRoot() t promptRootDir .resolve("templates") .resolve("Task-T") - .resolve("v1") + .resolve("network-layer") .resolve("incident_triage") + .resolve("v1") .resolve("en") .resolve("template.md"), "Local template for {{service}}."); @@ -56,8 +57,9 @@ void createLocalFileAccessLoadsPromptsTemplatesScenariosAndSlotsFromSameRoot() t promptRootDir .resolve("slots") .resolve("Task-T") - .resolve("v1") + .resolve("network-layer") .resolve("incident_triage") + .resolve("v1") .resolve("en") .resolve("slot.json"), """ diff --git a/a2a-t-prompt/src/test/resources/prompt_resources/slots/Task-T/v1/handoff-planning/en/slot.json b/a2a-t-prompt/src/test/resources/prompt_resources/slots/Task-T/network-layer/handoff-planning/v1/en/slot.json similarity index 100% rename from a2a-t-prompt/src/test/resources/prompt_resources/slots/Task-T/v1/handoff-planning/en/slot.json rename to a2a-t-prompt/src/test/resources/prompt_resources/slots/Task-T/network-layer/handoff-planning/v1/en/slot.json diff --git a/a2a-t-prompt/src/test/resources/prompt_resources/templates/Task-T/v1/handoff-planning/en/template.md b/a2a-t-prompt/src/test/resources/prompt_resources/templates/Task-T/network-layer/handoff-planning/v1/en/template.md similarity index 100% rename from a2a-t-prompt/src/test/resources/prompt_resources/templates/Task-T/v1/handoff-planning/en/template.md rename to a2a-t-prompt/src/test/resources/prompt_resources/templates/Task-T/network-layer/handoff-planning/v1/en/template.md diff --git a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java index d5324887..bce09c99 100644 --- a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java +++ b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java @@ -1,37 +1,39 @@ package net.openan.a2at.sdk.resources; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import net.openan.a2at.sdk.core.validation.TemplateUri; /** * Identifies one resource bundled with the SDK prompt resource tree. * - * @param category resource category such as prompts or templates - * @param templateType template type segment such as {@code Task-T} or {@code Notification-T}; may be {@code null} for non-template resources - * @param subject scenario code or prompt action + *

Template resources mirror the {@link TemplateUri} layout one-to-one, so a template or slot schema for the URI + * {@code Task-T/network-layer/energy-saving/v1} lives at + * {@code prompt_resources/templates/Task-T/network-layer/energy-saving/v1//}. + * + * @param category resource category such as prompts, templates, slots or scenarios + * @param pathSegments segments between the category and the language, such as {@code [slot_extraction]} for a prompt + * or the full template URI segments for a template resource; empty for scenarios * @param language locale identifier * @param fileName target file name * @since 2026-06 */ -public record PromptResourceKey( - String category, String templateType, String subject, String language, String fileName) { +public record PromptResourceKey(String category, List pathSegments, String language, String fileName) { /** - * Backward-compatible constructor for resources without a template type. + * Validates the components and defensively copies the path segment list. * - * @param category resource category - * @param subject scenario code or prompt action - * @param language locale identifier - * @param fileName target file name + * @throws NullPointerException if any component is null + * @throws IllegalArgumentException if any component is blank or not a simple path segment */ - public PromptResourceKey(String category, String subject, String language, String fileName) { - this(category, null, subject, language, fileName); - } - public PromptResourceKey { validateSegment("category", category); - if (templateType != null) { - validateSegment("templateType", templateType); + Objects.requireNonNull(pathSegments, "pathSegments must not be null"); + for (String segment : pathSegments) { + validateSegment("path segment", segment); } - validateSegment("subject", subject); + pathSegments = List.copyOf(pathSegments); validateSegment("language", language); validateSegment("fileName", fileName); } @@ -42,23 +44,55 @@ public PromptResourceKey(String category, String subject, String language, Strin * @param action prompt action name * @param language locale identifier * @param fileName target file name - * @return prompt resource key + * @return prompt resource key resolving to {@code prompt_resources/prompts///} */ public static PromptResourceKey prompt(String action, String language, String fileName) { - return new PromptResourceKey("prompts", null, action, language, fileName); + return new PromptResourceKey("prompts", List.of(action), language, fileName); + } + + /** + * Creates a template resource key for one template bundle, mirroring the template URI layout. + * + * @param templateUri template URI identifying the bundle + * @param language locale identifier + * @param fileName target file name + * @return template resource key resolving to + * {@code prompt_resources/templates///} + */ + public static PromptResourceKey template(TemplateUri templateUri, String language, String fileName) { + List segments = new ArrayList<>(); + segments.add(templateUri.extensionName()); + segments.addAll(templateUri.pathSegments()); + segments.add(templateUri.templateVersion()); + return new PromptResourceKey("templates", List.copyOf(segments), language, fileName); + } + + /** + * Creates a slot schema resource key for one template bundle, mirroring the template URI layout. + * + * @param templateUri template URI identifying the bundle + * @param language locale identifier + * @param fileName target file name + * @return slot schema resource key resolving to + * {@code prompt_resources/slots///} + */ + public static PromptResourceKey slotSchema(TemplateUri templateUri, String language, String fileName) { + List segments = new ArrayList<>(); + segments.add(templateUri.extensionName()); + segments.addAll(templateUri.pathSegments()); + segments.add(templateUri.templateVersion()); + return new PromptResourceKey("slots", List.copyOf(segments), language, fileName); } /** - * Creates a template resource key for one scenario bundle. + * Creates a scenario catalog resource key. * - * @param templateType template type segment such as {@code Task-T} - * @param scenarioCode scenario code * @param language locale identifier * @param fileName target file name - * @return template resource key + * @return scenario resource key resolving to {@code prompt_resources/scenarios//} */ - public static PromptResourceKey template(String templateType, String scenarioCode, String language, String fileName) { - return new PromptResourceKey("templates", templateType, scenarioCode, language, fileName); + public static PromptResourceKey scenario(String language, String fileName) { + return new PromptResourceKey("scenarios", List.of(), language, fileName); } /** @@ -67,13 +101,15 @@ public static PromptResourceKey template(String templateType, String scenarioCod * @return relative path under {@code prompt_resources/} */ public String relativePath() { - if (templateType != null) { - return String.join("/", "prompt_resources", category, templateType, "v1", subject, language, fileName); - } - if ("scenarios".equals(category)) { - return String.join("/", "prompt_resources", category, language, fileName); + List all = new ArrayList<>(); + all.add("prompt_resources"); + all.add(category); + if (!"scenarios".equals(category)) { + all.addAll(pathSegments); } - return String.join("/", "prompt_resources", category, subject, language, fileName); + all.add(language); + all.add(fileName); + return String.join("/", all); } private static void validateSegment(String fieldName, String value) { diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe-incident/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/network-layer/subscribe-incident/v1/en-US/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe-incident/en-US/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/network-layer/subscribe-incident/v1/en-US/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe-incident/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/network-layer/subscribe-incident/v1/zh-CN/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/v1/subscribe-incident/zh-CN/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/network-layer/subscribe-incident/v1/zh-CN/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy-saving/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/en-US/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy-saving/en-US/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/en-US/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy-saving/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/zh-CN/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/energy-saving/zh-CN/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/zh-CN/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/en-US/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/en-US/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/en-US/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/v1/private-line-complaint/zh-CN/slot.json rename to a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/v1/authz-policy-mgr/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/v1/authz-policy-mgr/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/v1/authz-policy-mgr/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/v1/authz-policy-mgr/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/accept-reject/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/accept-reject/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/accept-reject/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/accept-reject/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/propose/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/propose/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/propose/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/feasibility-negotiation/propose/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/accept-reject/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/accept-reject/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/accept-reject/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/accept-reject/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/propose/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/propose/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/propose/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/information-negotiation/propose/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/accept-reject/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/accept-reject/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/accept-reject/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/accept-reject/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/propose/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/propose/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/propose/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/v1/target-negotiation/propose/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/service-recovery/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/service-recovery/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/service-recovery/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/service-recovery/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe-incident/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/subscribe-incident/v1/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe-incident/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/subscribe-incident/v1/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe-incident/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/subscribe-incident/v1/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/v1/subscribe-incident/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/subscribe-incident/v1/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy-saving/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy-saving/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy-saving/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/energy-saving/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/private-line-complaint/v1/en-US/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/en-US/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/private-line-complaint/v1/en-US/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/private-line-complaint/v1/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/v1/private-line-complaint/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/private-line-complaint/v1/zh-CN/template.md diff --git a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java index 83f67b9e..3aa8e065 100644 --- a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java +++ b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java @@ -1,11 +1,12 @@ package net.openan.a2at.sdk.resources; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.validation.TemplateUri; import org.junit.jupiter.api.Test; class ClasspathPromptResourceLoaderTest { @@ -25,29 +26,38 @@ void loadsTextResourceUsingPromptResourceKey() { void rejectsResourceTraversalSegments() { assertThrows( IllegalArgumentException.class, - () -> new PromptResourceKey("prompts", "../escape", "en-US", "system.md")); + () -> new PromptResourceKey("prompts", List.of("../escape"), "en-US", "system.md")); } @Test void raisesTypedErrorWhenResourceIsMissing() { - PromptResourceKey key = PromptResourceKey.template("Task-T", "missing_scenario", "en-US", "template.md"); + PromptResourceKey key = PromptResourceKey.template( + TemplateUri.of("Task-T", "v1", "network-layer", "missing_scenario"), "en-US", "template.md"); ResourceNotFoundException error = assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); - assertEquals("prompt_resources/templates/Task-T/v1/missing_scenario/en-US/template.md", error.resourcePath().replace('\\', '/')); + assertEquals( + "prompt_resources/templates/Task-T/network-layer/missing_scenario/v1/en-US/template.md", + error.resourcePath().replace('\\', '/')); } @Test void loadsPackagedScenarioCatalogForZhCn() { - String text = loader.loadText(new PromptResourceKey("scenarios", "catalog", "zh-CN", "scenarios.json")); + String text = loader.loadText(PromptResourceKey.scenario("zh-CN", "scenarios.json")); - org.junit.jupiter.api.Assertions.assertTrue(text.contains("subscribe-incident")); - org.junit.jupiter.api.Assertions.assertTrue(text.contains("energy-saving")); + assertTrue(text.contains("subscribe-incident")); + assertTrue(text.contains("energy-saving")); } @Test void loadsPackagedSubscribeIncidentSlotSchemaWithSemanticHint() { - String text = loader.loadText(new PromptResourceKey("slots", "Notification-T", "subscribe-incident", "zh-CN", "slot.json")); + PromptResourceKey key = + PromptResourceKey.slotSchema( + TemplateUri.of("Notification-T", "v1", "network-layer", "subscribe-incident"), + "zh-CN", + "slot.json"); + + String text = loader.loadText(key); assertTrue(text.contains("\"required\": []")); assertTrue(text.contains("x-a2at-value-constraint")); diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index df7e6998..bbed8cab 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -224,8 +224,8 @@ When customizing scenarios, prepare local resources with the following structure ```text prompt_resources/ scenarios/zh-CN/scenarios.json - slots/{scenario_code}/zh-CN/slot.json - templates/{scenario_code}/zh-CN/template.md + slots/{extension_name}/{path_segments}/{version}/zh-CN/slot.json + templates/{extension_name}/{path_segments}/{version}/zh-CN/template.md prompts/scenario_recognition/zh-CN/system.md prompts/scenario_recognition/zh-CN/user.md prompts/slot_extraction/zh-CN/system.md @@ -234,6 +234,8 @@ prompt_resources/ prompts/semantic_validation/zh-CN/user.md ``` +The `slots/` and `templates/` directory paths mirror the template URI segment by segment: `{extension_name}/{path_segments}/{version}` is the template URI, with `{version}` (`v1` by default) as the trailing segment. For example, the `energy-saving` scenario resolves to `templates/Task-T/network-layer/energy-saving/v1/zh-CN/template.md` and `slots/Task-T/network-layer/energy-saving/v1/zh-CN/slot.json`; negotiation templates follow `templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md`. + Then specify in `.env`: ```properties @@ -471,19 +473,19 @@ A missing template or an unusable URI yields an empty result with a warning log. The template URI format is: ```text -Negotiation-T/v1/{type}-negotiation/{phase} +Negotiation-T/{type}-negotiation/{phase}/v1 ``` -where `{type}` is `information`, `target`, or `feasibility`, and `{phase}` is `propose` or `accept-reject`. URI parsing never throws: `NegotiationReference.tryParse` returns an `Optional`, and a null, blank or malformed URI (wrong segment count, prefix, version, type or phase segment) simply yields an empty result. Six built-in templates ship with the SDK, one per combination, each in `zh-CN` and `en-US`: +where `{type}` is `information`, `target`, or `feasibility`, `{phase}` is `propose` or `accept-reject`, and the trailing `v1` is the template version (the default version). URI parsing never throws: `NegotiationReference.tryParse` returns an `Optional`, and a null, blank or malformed URI (wrong segment count, prefix, version, type or phase segment) simply yields an empty result. Six built-in templates ship with the SDK, one per combination, each in `zh-CN` and `en-US`: | Template URI | Purpose | |--|--| -| `Negotiation-T/v1/information-negotiation/propose` | Request missing information | -| `Negotiation-T/v1/information-negotiation/accept-reject` | Accept or reject an information negotiation | -| `Negotiation-T/v1/target-negotiation/propose` | Propose a target negotiation | -| `Negotiation-T/v1/target-negotiation/accept-reject` | Accept or reject a target negotiation | -| `Negotiation-T/v1/feasibility-negotiation/propose` | Request a feasibility evaluation or propose an alternative | -| `Negotiation-T/v1/feasibility-negotiation/accept-reject` | Accept or reject a feasibility negotiation | +| `Negotiation-T/information-negotiation/propose/v1` | Request missing information | +| `Negotiation-T/information-negotiation/accept-reject/v1` | Accept or reject an information negotiation | +| `Negotiation-T/target-negotiation/propose/v1` | Propose a target negotiation | +| `Negotiation-T/target-negotiation/accept-reject/v1` | Accept or reject a target negotiation | +| `Negotiation-T/feasibility-negotiation/propose/v1` | Request a feasibility evaluation or propose an alternative | +| `Negotiation-T/feasibility-negotiation/accept-reject/v1` | Accept or reject a feasibility negotiation | Negotiation templates are resolved with a **dual-root fallback** that is independent of `A2AT_PROMPT_SOURCE_TYPE`: a template file that exists under the local resource root configured by `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` wins, otherwise the built-in classpath template of the same URI is used. The built-in templates therefore always remain available as a safety net. Only templates are taken from the local root; LLM prompt resources always come from the classpath. @@ -491,7 +493,7 @@ To override one template (for example the information propose template in Chines ```text {A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR}/ - templates/Negotiation-T/v1/information-negotiation/propose/zh-CN/template.md + templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md ``` ### 1.10.4 Data Models @@ -579,11 +581,11 @@ InfoProposeContent content = new InfoProposeContent( MetadataContent propose = client.generateNegotiationProposePromptFromData( new NegotiationProposeData(context, content), - "Negotiation-T/v1/information-negotiation/propose"); + "Negotiation-T/information-negotiation/propose/v1"); // This two-key map travels in the A2A message metadata. Map metadata = propose.buildMetadataContent(); -// metadata.get("templateUri") -> "Negotiation-T/v1/information-negotiation/propose" +// metadata.get("templateUri") -> "Negotiation-T/information-negotiation/propose/v1" // metadata.get(propose.extensionUri()) -> the rendered message text // --- Server side: validate the received message and extract parameters --- @@ -598,7 +600,7 @@ Map schema = Map.of( try { FilledParamData params = server.validateAndFillingProposeData( - propose.promptText(), schema, "Negotiation-T/v1/information-negotiation/propose"); + propose.promptText(), schema, "Negotiation-T/information-negotiation/propose/v1"); // params.data() holds the extracted parameters plus the context parameters. } catch (A2ATParamExtractionError failure) { // Branch on failure.getCode(): negotiation_invalid_input, negotiation_rule_violation, @@ -612,11 +614,11 @@ InfoEndingContent ending = new InfoEndingContent( MetadataContent accept = server.generateNegotiationAcceptPromptFromData( new NegotiationEndingData(context, ending), - "Negotiation-T/v1/information-negotiation/accept-reject"); + "Negotiation-T/information-negotiation/accept-reject/v1"); // The client validates the accept message the same way, with the accept-reject URI: // client.validateAndFillingAcceptData(accept.promptText(), schema, -// "Negotiation-T/v1/information-negotiation/accept-reject"); +// "Negotiation-T/information-negotiation/accept-reject/v1"); ``` When modifying the negotiation content layer, run: diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index ae145e25..85370150 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -207,8 +207,8 @@ Map payload = client.startNegotiation( ```text prompt_resources/ scenarios/zh-CN/scenarios.json - slots/{scenario_code}/zh-CN/slot.json - templates/{scenario_code}/zh-CN/template.md + slots/{extension_name}/{path_segments}/{version}/zh-CN/slot.json + templates/{extension_name}/{path_segments}/{version}/zh-CN/template.md prompts/scenario_recognition/zh-CN/system.md prompts/scenario_recognition/zh-CN/user.md prompts/slot_extraction/zh-CN/system.md @@ -217,6 +217,8 @@ prompt_resources/ prompts/semantic_validation/zh-CN/user.md ``` +`slots/` 与 `templates/` 的目录路径与模板 URI 逐段一一对应:`{extension_name}/{path_segments}/{version}` 即模板 URI,`{version}`(默认 `v1`)是末尾段。例如 `energy-saving` 场景对应 `templates/Task-T/network-layer/energy-saving/v1/zh-CN/template.md` 与 `slots/Task-T/network-layer/energy-saving/v1/zh-CN/slot.json`;协商模板则形如 `templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md`。 + 然后在 `.env` 中指定: ```properties @@ -454,19 +456,19 @@ Optional getNegotiationPrompt(String templateUri) // 按 URI 模板 URI 的格式为: ```text -Negotiation-T/v1/{type}-negotiation/{phase} +Negotiation-T/{type}-negotiation/{phase}/v1 ``` -其中 `{type}` 为 `information`、`target` 或 `feasibility`,`{phase}` 为 `propose` 或 `accept-reject`。URI 解析绝不抛出异常:`NegotiationReference.tryParse` 返回 `Optional`,null、空白或格式错误的 URI(段数、前缀、版本、类型或 phase 段不匹配)只返回空结果。SDK 内置六个模板,每种组合一个,各自提供 `zh-CN` 与 `en-US` 两个语言版本: +其中 `{type}` 为 `information`、`target` 或 `feasibility`,`{phase}` 为 `propose` 或 `accept-reject`,末尾的 `v1` 是模板版本(默认版本)。URI 解析绝不抛出异常:`NegotiationReference.tryParse` 返回 `Optional`,null、空白或格式错误的 URI(段数、前缀、版本、类型或 phase 段不匹配)只返回空结果。SDK 内置六个模板,每种组合一个,各自提供 `zh-CN` 与 `en-US` 两个语言版本: | 模板 URI | 用途 | |--|--| -| `Negotiation-T/v1/information-negotiation/propose` | 请求补充缺失信息 | -| `Negotiation-T/v1/information-negotiation/accept-reject` | 信息协商的接受或拒绝 | -| `Negotiation-T/v1/target-negotiation/propose` | 发起目标协商 | -| `Negotiation-T/v1/target-negotiation/accept-reject` | 目标协商的接受或拒绝 | -| `Negotiation-T/v1/feasibility-negotiation/propose` | 请求可行性评估或提出替代方案 | -| `Negotiation-T/v1/feasibility-negotiation/accept-reject` | 可行性协商的接受或拒绝 | +| `Negotiation-T/information-negotiation/propose/v1` | 请求补充缺失信息 | +| `Negotiation-T/information-negotiation/accept-reject/v1` | 信息协商的接受或拒绝 | +| `Negotiation-T/target-negotiation/propose/v1` | 发起目标协商 | +| `Negotiation-T/target-negotiation/accept-reject/v1` | 目标协商的接受或拒绝 | +| `Negotiation-T/feasibility-negotiation/propose/v1` | 请求可行性评估或提出替代方案 | +| `Negotiation-T/feasibility-negotiation/accept-reject/v1` | 可行性协商的接受或拒绝 | 协商模板采用**双根回退**解析,且该回退与 `A2AT_PROMPT_SOURCE_TYPE` 无关:若 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 配置的本地资源根目录下存在同名模板文件则优先使用,否则回退到内置 classpath 中同一 URI 的模板。内置模板因此始终作为兜底可用。本地根目录只用于覆盖模板;LLM 提示词资源始终来自 classpath。 @@ -474,7 +476,7 @@ Negotiation-T/v1/{type}-negotiation/{phase} ```text {A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR}/ - templates/Negotiation-T/v1/information-negotiation/propose/zh-CN/template.md + templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md ``` ### 1.10.4 数据模型 @@ -562,11 +564,11 @@ InfoProposeContent content = new InfoProposeContent( MetadataContent propose = client.generateNegotiationProposePromptFromData( new NegotiationProposeData(context, content), - "Negotiation-T/v1/information-negotiation/propose"); + "Negotiation-T/information-negotiation/propose/v1"); // 这个两键映射随 A2A 消息 metadata 传输。 Map metadata = propose.buildMetadataContent(); -// metadata.get("templateUri") -> "Negotiation-T/v1/information-negotiation/propose" +// metadata.get("templateUri") -> "Negotiation-T/information-negotiation/propose/v1" // metadata.get(propose.extensionUri()) -> 渲染后的消息文本 // --- 服务端:校验收到的消息并提取参数 --- @@ -581,7 +583,7 @@ Map schema = Map.of( try { FilledParamData params = server.validateAndFillingProposeData( - propose.promptText(), schema, "Negotiation-T/v1/information-negotiation/propose"); + propose.promptText(), schema, "Negotiation-T/information-negotiation/propose/v1"); // params.data() 持有提取的参数以及上下文参数。 } catch (A2ATParamExtractionError failure) { // 按 failure.getCode() 分支处理:negotiation_invalid_input、negotiation_rule_violation、 @@ -595,11 +597,11 @@ InfoEndingContent ending = new InfoEndingContent( MetadataContent accept = server.generateNegotiationAcceptPromptFromData( new NegotiationEndingData(context, ending), - "Negotiation-T/v1/information-negotiation/accept-reject"); + "Negotiation-T/information-negotiation/accept-reject/v1"); // 客户端以相同方式、使用 accept-reject URI 校验该接受消息: // client.validateAndFillingAcceptData(accept.promptText(), schema, -// "Negotiation-T/v1/information-negotiation/accept-reject"); +// "Negotiation-T/information-negotiation/accept-reject/v1"); ``` 修改协商内容层时,运行: diff --git a/tools/template_lint.py b/tools/template_lint.py index 8f507045..874debb9 100644 --- a/tools/template_lint.py +++ b/tools/template_lint.py @@ -303,7 +303,7 @@ def lint_negotiation(templates_dir: Path) -> list[str]: if not negotiation_root.is_dir(): return [error(negotiation_root, 1, "negotiation-file-set", "Missing Negotiation-T templates directory.")] expected = { - Path("v1") / type_segment / phase_segment / language / "template.md" + Path(type_segment) / phase_segment / "v1" / language / "template.md" for type_segment in NEGOTIATION_TYPE_SEGMENTS for phase_segment in NEGOTIATION_PHASE_SEGMENTS for language in NEGOTIATION_LANGUAGES @@ -317,7 +317,7 @@ def lint_negotiation(templates_dir: Path) -> list[str]: errors.append(error(path, 1, "negotiation-file-set", f"Unexpected Negotiation-T template location: {relative}")) continue found.add(relative) - _, type_segment, phase_segment, language, _ = relative.parts + type_segment, phase_segment, _, language, _ = relative.parts file_errors, shape = lint_negotiation_file(path, language, type_segment, phase_segment) errors.extend(file_errors) shapes[(type_segment, phase_segment, language)] = shape @@ -329,7 +329,7 @@ def lint_negotiation(templates_dir: Path) -> list[str]: en_shape = shapes.get((type_segment, phase_segment, "en-US")) if zh_shape is None or en_shape is None: continue - base = negotiation_root / "v1" / type_segment / phase_segment + base = negotiation_root / type_segment / phase_segment / "v1" errors.extend(lint_negotiation_alignment(base / "zh-CN" / "template.md", base / "en-US" / "template.md", zh_shape, en_shape)) return errors @@ -341,14 +341,19 @@ def lint_root(root: Path) -> list[str]: if not slots.is_dir(): return [error(slots, 1, "resource-root", "Missing slots directory.")] errors: list[str] = [] - for template_path in sorted(templates.glob("*/v1/*/*/template.md")): - schema_path = slots / template_path.relative_to(templates).parent / "slot.json" - if schema_path.is_file(): - errors.extend(lint_pair(template_path, schema_path)) - for schema_path in sorted(slots.glob("*/v1/*/*/slot.json")): - template_path = templates / schema_path.relative_to(slots).parent / "template.md" - if not template_path.is_file(): - errors.append(error(schema_path, 1, "template-missing", f"Missing paired template: {template_path}")) + # Layouts: /network-layer//v1/ for Task-T/Notification-T, + # and //v1/ for Authorization-T. + template_globs = ("*/network-layer/*/v1/*/template.md", "*/*/v1/*/template.md") + for pattern in template_globs: + for template_path in sorted(templates.glob(pattern)): + schema_path = slots / template_path.relative_to(templates).parent / "slot.json" + if schema_path.is_file(): + errors.extend(lint_pair(template_path, schema_path)) + for pattern in template_globs: + for schema_path in sorted(slots.glob(pattern.replace("template.md", "slot.json"))): + template_path = templates / schema_path.relative_to(slots).parent / "template.md" + if not template_path.is_file(): + errors.append(error(schema_path, 1, "template-missing", f"Missing paired template: {template_path}")) errors.extend(lint_negotiation(templates)) return errors From 0a9adb49a5fc019a90da672d64644a7361c56252 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 21 Aug 2026 23:07:29 +0800 Subject: [PATCH 077/295] refactor(api): replace String templateUri with TemplateUri type on facade chain and add JSpecify nullness annotations Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 194 ++++++++++-------- .../openan/a2at/sdk/client/package-info.java | 3 + .../ClientPromptGenerationOrchestrator.java | 21 +- ...ultClientPromptGenerationOrchestrator.java | 45 ++-- .../api/A2ATClientNegotiationApiTest.java | 21 +- .../A2ATClientNegotiationEnvConfigTest.java | 14 +- .../a2at/sdk/client/api/A2ATClientTest.java | 82 ++++---- ...lientPromptGenerationOrchestratorTest.java | 110 ++++------ .../a2at/sdk/core/exception/A2ATError.java | 10 +- .../exception/A2ATParamExtractionError.java | 6 +- .../ConfigFileNotFoundException.java | 3 +- .../sdk/core/exception/FailedParameter.java | 4 +- .../exception/PromptGenerationException.java | 11 +- .../exception/ResourceNotFoundException.java | 6 +- .../a2at/sdk/core/model/A2ATConfig.java | 3 +- .../sdk/core/model/DotEnvConfigSource.java | 3 +- .../a2at/sdk/core/model/MetadataContent.java | 6 +- .../a2at/sdk/core/model/OperationResult.java | 8 +- .../a2at/sdk/core/model/PromptTemplate.java | 3 +- .../a2at/sdk/core/resources/PathSegments.java | 4 +- .../sdk/core/validation/ContentValidator.java | 11 +- .../core/validation/SemanticValidator.java | 4 +- .../a2at/sdk/core/validation/TemplateUri.java | 12 +- .../core/validation/ValidationPipeline.java | 7 +- .../content/NegotiationContentService.java | 90 ++++---- .../NegotiationGenerationOrchestrator.java | 143 +++++++------ .../resources/NegotiationReference.java | 45 +++- .../NegotiationConcurrentMixedLoadTest.java | 3 +- ...ConclusionLiteralAndExceptionSlotTest.java | 18 +- .../ConditionalSectionRenderingTest.java | 5 +- .../FromDataProgrammingErrorMatrixTest.java | 58 +++--- .../generation/FromTextLlmPipelineTest.java | 27 +-- ...nerationOrchestratorBuilderWiringTest.java | 3 +- ...NegotiationGenerationOrchestratorTest.java | 60 +++--- .../NegotiationTemplateQueryBoundaryTest.java | 17 +- .../RelationshipLineRenderingTest.java | 3 +- .../TemplateUriEntryMatrixTest.java | 55 ++--- .../ValidateAndFillingDataPipelineTest.java | 9 +- ...lidateAndFillingSignatureContractTest.java | 3 +- .../sdk/negotiation/golden/GoldenInputs.java | 23 ++- .../CustomRootPromptsIgnoredTest.java | 3 +- .../NegotiationLanguageSwitchTest.java | 7 +- .../NegotiationErrorCodeUsageMatrixTest.java | 9 +- ...NegotiationExceptionTreeRootCatchTest.java | 7 +- .../NegotiationLogEventContractTest.java | 9 +- ...NegotiationPublicExceptionSurfaceTest.java | 5 +- .../resources/NegotiationReferenceTest.java | 100 +++++++++ .../BilateralInteropScenarioTest.java | 5 +- .../FeasibilityNegotiationScenarioTest.java | 5 +- .../InformationNegotiationScenarioTest.java | 5 +- .../MaxRoundsExhaustionScenarioTest.java | 5 +- .../TargetNegotiationScenarioTest.java | 5 +- .../catalog/PromptTemplateCatalog.java | 28 +-- .../catalog/TemplateQueryService.java | 29 +-- .../validation/DefaultContentValidator.java | 52 ++--- .../validation/DefaultSemanticValidator.java | 9 +- .../NegotiationFacadeOutputSymmetryTest.java | 29 +-- .../openan/a2at/sdk/server/A2ATServer.java | 173 +++++++++------- .../openan/a2at/sdk/server/package-info.java | 3 + .../api/A2ATServerNegotiationApiTest.java | 25 ++- .../TaskPromptNegotiationCoexistenceTest.java | 12 +- docs/en/developer_guide.md | 41 ++-- ...00\345\217\221\346\214\207\345\215\227.md" | 41 ++-- 63 files changed, 1013 insertions(+), 747 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index e709cab9..113ad6ff 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -3,6 +3,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; @@ -11,6 +12,7 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; @@ -19,6 +21,8 @@ import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** * High-level client facade for prompt generation and negotiation APIs. The caller provides the `.env` file path @@ -71,10 +75,9 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null - * @throws IllegalArgumentException if the template URI is blank or contains invalid characters */ - public MetadataContent generateTaskPromptFromText( - String text, String templateUri) { + public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); return promptGenerationOrchestrator.generateTaskPromptFromText(text, templateUri); } @@ -83,54 +86,57 @@ public MetadataContent generateTaskPromptFromText( * identified by the template URI, bypassing scenario recognition. * * @param data structured task input as a string-to-object map - * @param schema optional data schema map for schema-guided extraction + * @param schema optional data schema map for schema-guided extraction; null disables schema-guided extraction * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null - * @throws IllegalArgumentException if the template URI is blank or contains invalid characters */ public MetadataContent generateTaskPromptFromDataWithSchema( - Map data, Map schema, String templateUri) { + @NonNull Map data, + @Nullable Map schema, + @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, templateUri); } /** * Generates an authorization prompt with metadata from natural-language input using the template identified by the - * authorization type, bypassing scenario recognition. + * template URI, bypassing scenario recognition. * *

Experimental: Authorization-T template resources (such as {@code authz-policy-mgr}) are bundled and * discovered automatically, but Authorization-T slot schemas are not yet bundled, so template-driven slot * extraction fails until Authorization-T slot resources are added or provided through the local resource root. * * @param text natural-language authorization input - * @param authorizationType authorization type used as the template identifier + * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the authorization type is null - * @throws IllegalArgumentException if the authorization type is blank or contains invalid characters + * @throws NullPointerException if the template URI is null */ - public MetadataContent generateAuthPromptFromText( - String text, String authorizationType) { - return promptGenerationOrchestrator.generateAuthPromptFromText(text, authorizationType); + public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateAuthPromptFromText(text, templateUri); } /** * Generates an authorization prompt with metadata from structured input and an optional data schema using the - * template identified by the authorization type, bypassing scenario recognition. + * template identified by the template URI, bypassing scenario recognition. * *

Experimental: Authorization-T template resources (such as {@code authz-policy-mgr}) are bundled and * discovered automatically, but Authorization-T slot schemas are not yet bundled, so template-driven slot * extraction fails until Authorization-T slot resources are added or provided through the local resource root. * * @param data structured authorization input as a string-to-object map - * @param schema optional data schema map for schema-guided extraction - * @param authorizationType authorization type used as the template identifier + * @param schema optional data schema map for schema-guided extraction; null disables schema-guided extraction + * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the authorization type is null - * @throws IllegalArgumentException if the authorization type is blank or contains invalid characters + * @throws NullPointerException if the template URI is null */ public MetadataContent generateAuthPromptFromDataWithSchema( - Map data, Map schema, String authorizationType) { - return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, authorizationType); + @NonNull Map data, + @Nullable Map schema, + @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, templateUri); } /** @@ -141,10 +147,9 @@ public MetadataContent generateAuthPromptFromDataWithSchema( * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null - * @throws IllegalArgumentException if the template URI is blank or contains invalid characters */ - public MetadataContent generateNotificationPromptFromText( - String text, String templateUri) { + public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); return promptGenerationOrchestrator.generateNotificationPromptFromText(text, templateUri); } @@ -153,14 +158,16 @@ public MetadataContent generateNotificationPromptFromText( * template identified by the template URI, bypassing scenario recognition. * * @param data structured notification input as a string-to-object map - * @param schema optional data schema map for schema-guided extraction + * @param schema optional data schema map for schema-guided extraction; null disables schema-guided extraction * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null - * @throws IllegalArgumentException if the template URI is blank or contains invalid characters */ public MetadataContent generateNotificationPromptFromDataWithSchema( - Map data, Map schema, String templateUri) { + @NonNull Map data, + @Nullable Map schema, + @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, templateUri); } @@ -207,17 +214,18 @@ public Map continueNegotiation( * generator of the negotiation type addressed by the template URI and rendered from that template. * * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/propose}; its phase + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase * segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI is malformed, or its phase or type contradicts the method - * or the content type + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation_slot_missing} when rendering the template fails */ - public MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, String templateUri) { + public MetadataContent generateNegotiationProposePromptFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateProposeFromData(data, templateUri); } @@ -228,17 +236,19 @@ public MetadataContent generateNegotiationProposePromptFromData(NegotiationPropo * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/accept-reject}; its phase - * segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its + * phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or - * the content, or the content conclusion is not {@code Accept} + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the + * content conclusion is not {@code Accept} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation_slot_missing} when rendering the template fails */ - public MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, String templateUri) { + public MetadataContent generateNegotiationAcceptPromptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateAcceptFromData(data, templateUri); } @@ -249,17 +259,19 @@ public MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEnding * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/v1/feasibility-negotiation/accept-reject}; its phase - * segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its + * phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or - * the content, or the content conclusion is not {@code Reject} + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the + * content conclusion is not {@code Reject} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation_slot_missing} when rendering the template fails */ - public MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, String templateUri) { + public MetadataContent generateNegotiationRejectPromptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateRejectFromData(data, templateUri); } @@ -273,11 +285,11 @@ public MetadataContent generateNegotiationRejectPromptFromData(NegotiationEnding * * @param text free-text input describing the message content * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose}; its phase segment + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment * must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method + * @throws IllegalArgumentException if the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the @@ -286,7 +298,10 @@ public MetadataContent generateNegotiationRejectPromptFromData(NegotiationEnding * content contradicts the phase */ public MetadataContent generateNegotiationProposePromptFromText( - String text, net.openan.a2at.sdk.negotiation.content.NegotiationContext context, String templateUri) { + @NonNull String text, + net.openan.a2at.sdk.negotiation.content.NegotiationContext context, + @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateProposeFromText(text, context, templateUri); } @@ -299,11 +314,11 @@ public MetadataContent generateNegotiationProposePromptFromText( * * @param text free-text input describing the message content * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/accept-reject}; its phase - * segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its + * phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method + * @throws IllegalArgumentException if the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the @@ -312,7 +327,10 @@ public MetadataContent generateNegotiationProposePromptFromText( * conclusion is not {@code Accept} */ public MetadataContent generateNegotiationAcceptPromptFromText( - String text, net.openan.a2at.sdk.negotiation.content.NegotiationContext context, String templateUri) { + @NonNull String text, + net.openan.a2at.sdk.negotiation.content.NegotiationContext context, + @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateAcceptFromText(text, context, templateUri); } @@ -325,11 +343,11 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * * @param text free-text input describing the message content * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/v1/feasibility-negotiation/accept-reject}; its phase - * segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its + * phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method + * @throws IllegalArgumentException if the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the @@ -338,7 +356,10 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * conclusion is not {@code Reject} */ public MetadataContent generateNegotiationRejectPromptFromText( - String text, net.openan.a2at.sdk.negotiation.content.NegotiationContext context, String templateUri) { + @NonNull String text, + net.openan.a2at.sdk.negotiation.content.NegotiationContext context, + @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateRejectFromText(text, context, templateUri); } @@ -359,16 +380,16 @@ public List getPrompts() { /** * Loads one template by its URI, regardless of the extension. * - *

This query never throws: a missing template or an unusable URI yields an empty result together with a warning - * log instead of a failure. + *

This query never throws: a missing template yields an empty result together with a warning log instead of a + * failure. * - * @param uri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} or - * {@code Task-T/v1/energy-saving} - * @return the addressed template, or an empty optional when the URI is malformed or no template exists for it in - * the configured language + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} or + * {@code Task-T/network-layer/energy-saving/v1} + * @return the addressed template, or an empty optional when no template exists for it in the configured language */ - public Optional getPrompt(String uri) { - return templateQueryService.getPrompt(uri); + public Optional getPrompt(@NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return templateQueryService.getPrompt(templateUri); } /** @@ -387,25 +408,24 @@ public List getNegotiationPrompts() { /** * Loads one negotiation template by its URI. * - *

This query never throws: a missing template or an unusable URI yields an empty result together with a warning - * log instead of a failure. + *

This query never throws: a missing template yields an empty result together with a warning log instead of a + * failure. * - * @param uri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} - * @return the addressed template, or an empty optional when the URI is malformed or no template exists for it in - * the configured language + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} + * @return the addressed template, or an empty optional when no template exists for it in the configured language */ - public Optional getNegotiationPrompt(String uri) { - return negotiationContentService.getNegotiationPrompt(uri); + public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.getNegotiationPrompt(templateUri); } /** * Validates a propose-phase negotiation message and extracts its parameters. * - *

The pipeline first checks the template URI format, then runs the deterministic rule gate (recognition of the - * negotiation context section and its strong constraints) before any LLM call, then performs one LLM semantic - * validation call that also extracts the parameters, and finally merges the parameters with the context parameters - * taking precedence. The semantic step is retried up to the configured attempt limit on the retryable LLM - * infrastructure failure code. + *

The pipeline first runs the deterministic rule gate (recognition of the negotiation context section and its + * strong constraints) before any LLM call, then performs one LLM semantic validation call that also extracts the + * parameters, and finally merges the parameters with the context parameters taking precedence. The semantic step + * is retried up to the configured attempt limit on the retryable LLM infrastructure failure code. * * @param prompt rendered negotiation message text to validate * @param schema caller-provided parameter JSON schema describing the parameters to extract @@ -413,8 +433,7 @@ public Optional getNegotiationPrompt(String uri) { * {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI is malformed or its phase - * contradicts the method + * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation_invalid_input} when the prompt is not a negotiation message, * {@code negotiation_rule_violation} when the negotiation context violates a rule, @@ -423,16 +442,17 @@ public Optional getNegotiationPrompt(String uri) { * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateAndFillingProposeData( - String prompt, Map schema, String templateUri) { + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.validateAndFillingProposeData(prompt, schema, templateUri); } /** * Validates an accept-phase negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, String)} with the expected phase - * fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * accept-phase semantic constraints. + *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must + * satisfy the accept-phase semantic constraints. * * @param prompt rendered negotiation message text to validate * @param schema caller-provided parameter JSON schema describing the parameters to extract @@ -440,8 +460,7 @@ public FilledParamData validateAndFillingProposeData( * {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI is malformed or its phase - * contradicts the method + * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation_invalid_input} when the prompt is not a negotiation message, * {@code negotiation_rule_violation} when the negotiation context violates a rule, @@ -449,16 +468,18 @@ public FilledParamData validateAndFillingProposeData( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) { + public FilledParamData validateAndFillingAcceptData( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.validateAndFillingAcceptData(prompt, schema, templateUri); } /** * Validates a reject-phase negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, String)} with the expected phase - * fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * reject-phase semantic constraints. + *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must + * satisfy the reject-phase semantic constraints. * * @param prompt rendered negotiation message text to validate * @param schema caller-provided parameter JSON schema describing the parameters to extract @@ -466,8 +487,7 @@ public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) { + public FilledParamData validateAndFillingRejectData( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.validateAndFillingRejectData(prompt, schema, templateUri); } } diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/package-info.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/package-info.java index 12b0d90e..5843fe21 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/package-info.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/package-info.java @@ -1,2 +1,5 @@ /** Client-facing APIs for the a2a-t Java SDK. */ +@NullMarked package net.openan.a2at.sdk.client; + +import org.jspecify.annotations.NullMarked; diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java index 27c36676..3429fb51 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java @@ -3,6 +3,7 @@ import java.util.Map; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.validation.TemplateUri; /** * Internal prompt-generation orchestration contract used by the client facade. @@ -27,7 +28,7 @@ public interface ClientPromptGenerationOrchestrator { * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI */ - MetadataContent generateTaskPromptFromText(String text, String templateUri); + MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri); /** * Generates a task prompt with metadata from structured input and an optional data schema using the template @@ -39,29 +40,29 @@ public interface ClientPromptGenerationOrchestrator { * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI */ MetadataContent generateTaskPromptFromDataWithSchema( - Map data, Map schema, String templateUri); + Map data, Map schema, TemplateUri templateUri); /** * Generates an authorization prompt with metadata from natural-language input using the template identified by the - * authorization type, bypassing scenario recognition. + * template URI, bypassing scenario recognition. * * @param text natural-language authorization input - * @param authorizationType authorization type used as the template identifier + * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI */ - MetadataContent generateAuthPromptFromText(String text, String authorizationType); + MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri); /** * Generates an authorization prompt with metadata from structured input and an optional data schema using the - * template identified by the authorization type, bypassing scenario recognition. + * template identified by the template URI, bypassing scenario recognition. * * @param data structured authorization input as a string-to-object map * @param schema optional data schema map for schema-guided extraction - * @param authorizationType authorization type used as the template identifier + * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI */ MetadataContent generateAuthPromptFromDataWithSchema( - Map data, Map schema, String authorizationType); + Map data, Map schema, TemplateUri templateUri); /** * Generates a notification prompt with metadata from natural-language input using the template identified by the @@ -71,7 +72,7 @@ MetadataContent generateAuthPromptFromDataWithSchema( * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI */ - MetadataContent generateNotificationPromptFromText(String text, String templateUri); + MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri); /** * Generates a notification prompt with metadata from structured input and an optional data schema using the @@ -83,5 +84,5 @@ MetadataContent generateAuthPromptFromDataWithSchema( * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI */ MetadataContent generateNotificationPromptFromDataWithSchema( - Map data, Map schema, String templateUri); + Map data, Map schema, TemplateUri templateUri); } \ No newline at end of file diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java index 71e386b5..df360558 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -4,7 +4,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.regex.Pattern; import java.util.stream.Collectors; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationFailure; @@ -18,6 +17,7 @@ import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; @@ -32,8 +32,6 @@ */ public final class DefaultClientPromptGenerationOrchestrator implements ClientPromptGenerationOrchestrator { - private static final Pattern TEMPLATE_IDENTIFIER_PATTERN = Pattern.compile("[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*"); - private final ClientScenarioRecognizer scenarioRecognizer; private final List scenarios; @@ -129,44 +127,44 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { } @Override - public MetadataContent generateTaskPromptFromText(String text, String templateUri) { + public MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) { return generateFromTemplateUriWithMetadata(text, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); } @Override public MetadataContent generateTaskPromptFromDataWithSchema( - Map data, Map schema, String templateUri) { + Map data, Map schema, TemplateUri templateUri) { return generateFromDataWithSchema(data, schema, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); } @Override - public MetadataContent generateAuthPromptFromText(String text, String authorizationType) { - return generateFromTemplateUriWithMetadata( - text, authorizationType, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); + public MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri) { + return generateFromTemplateUriWithMetadata(text, templateUri, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); } @Override public MetadataContent generateAuthPromptFromDataWithSchema( - Map data, Map schema, String authorizationType) { + Map data, Map schema, TemplateUri templateUri) { return generateFromDataWithSchema( - data, schema, authorizationType, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); + data, schema, templateUri, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); } @Override - public MetadataContent generateNotificationPromptFromText(String text, String templateUri) { + public MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri) { return generateFromTemplateUriWithMetadata( text, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); } @Override public MetadataContent generateNotificationPromptFromDataWithSchema( - Map data, Map schema, String templateUri) { + Map data, Map schema, TemplateUri templateUri) { return generateFromDataWithSchema(data, schema, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); } private MetadataContent generateFromTemplateUriWithMetadata( - String userInput, String templateIdentifier, String extensionUri) { - requireValidTemplateIdentifier(templateIdentifier); + String userInput, TemplateUri templateUri, String extensionUri) { + Objects.requireNonNull(templateUri, "templateUri"); + String templateIdentifier = templateUri.uri(); final String templateText; try { templateText = templateLoader.loadTemplate(templateIdentifier, language); @@ -196,9 +194,10 @@ private MetadataContent generateFromTemplateUriWithMetadata( private MetadataContent generateFromDataWithSchema( Map data, Map schema, - String templateIdentifier, + TemplateUri templateUri, String extensionUri) { - requireValidTemplateIdentifier(templateIdentifier); + Objects.requireNonNull(templateUri, "templateUri"); + String templateIdentifier = templateUri.uri(); final String templateText; try { templateText = templateLoader.loadTemplate(templateIdentifier, language); @@ -226,20 +225,6 @@ private MetadataContent generateFromDataWithSchema( return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); } - /** - * Enforces the caller contract of the template identifier: a null identifier is a {@link NullPointerException}, - * a blank or malformed identifier is an {@link IllegalArgumentException}. Both stay outside the {@link A2ATError} - * tree because they are programming errors of the caller. - */ - private static void requireValidTemplateIdentifier(String templateIdentifier) { - Objects.requireNonNull(templateIdentifier, "templateIdentifier"); - if (templateIdentifier.isBlank() || !TEMPLATE_IDENTIFIER_PATTERN.matcher(templateIdentifier).matches()) { - throw new IllegalArgumentException( - "Template URI is blank or contains invalid characters (invalid_template_uri): " - + templateIdentifier); - } - } - private void validateRequiredSlots(Map slots, String templateIdentifier) { final PromptSlotSchema schema; try { diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index c835d2bb..7d2c38e7 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -21,6 +21,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -37,7 +38,9 @@ static void registerMockProvider() { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); @Test void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { @@ -47,7 +50,7 @@ void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOE new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); + INFORMATION_PROPOSE); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); @@ -67,7 +70,7 @@ void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOE new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE_URI); + INFORMATION_PROPOSE); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); @@ -87,12 +90,14 @@ void listsAllSixNegotiationTemplatesPerLanguage() throws IOException { void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - assertTrue(client.getNegotiationPrompt(INFORMATION_PROPOSE_URI).isPresent()); - assertTrue(client.getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) + assertTrue(client.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); + assertTrue(client + .getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .isPresent()); + assertFalse(client + .getNegotiationPrompt(TemplateUri.of("Negotiation-T", "v9", "information-negotiation", "propose")) .isPresent()); - assertFalse(client.getNegotiationPrompt("not-a-template-uri").isPresent()); - PromptTemplate template = - client.getNegotiationPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); + PromptTemplate template = client.getNegotiationPrompt(INFORMATION_PROPOSE).orElseThrow(); assertEquals(INFORMATION_PROPOSE_URI, template.uri()); assertFalse(template.content().isBlank()); } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index 15148531..a23d4cc7 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -58,7 +58,9 @@ static void registerMockProvider() { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); private static final String CUSTOM_TEMPLATE_MARKER = "CUSTOM-TEMPLATE-MARKER-7d31"; @@ -103,7 +105,7 @@ void languageLocalRootAndMaxAttemptsAreAllObservableInFacadeBehavior() throws IO new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); + INFORMATION_PROPOSE); assertTrue(result.promptText().contains("协商上下文"), "the zh-CN language must select the Chinese templates"); assertTrue( @@ -113,7 +115,7 @@ void languageLocalRootAndMaxAttemptsAreAllObservableInFacadeBehavior() throws IO NegotiationGenerationException failure = assertThrows( NegotiationGenerationException.class, () -> client.generateNegotiationProposePromptFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI)); + "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE)); assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); assertEquals( 1, @@ -135,19 +137,19 @@ void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE_URI); + INFORMATION_PROPOSE); assertTrue(result.promptText().contains("Negotiation Context"), "the default language must be en-US"); assertTrue(result.promptText().contains("Required Information Items")); assertEquals(6, client.getNegotiationPrompts().size(), "the built-in resources must be used by default"); - assertTrue(client.getNegotiationPrompt(INFORMATION_PROPOSE_URI).isPresent()); + assertTrue(client.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); assertThrows( NegotiationGenerationException.class, () -> client.generateNegotiationProposePromptFromText( "Provide the energy-saving region.", new NegotiationContext(UUID, 1, 5), - INFORMATION_PROPOSE_URI)); + INFORMATION_PROPOSE)); assertEquals( 2, retryEventCount(), diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index 14368b14..c5f84d81 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -18,6 +18,7 @@ import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; @@ -257,13 +258,11 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO void fromTextEntryPointsReturnMetadataContent() throws IOException { Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); A2ATClient client = new A2ATClient(envFile); + TemplateUri unconstrained = TemplateUri.of("Task-T", "v1", "network-layer", "unconstrained"); - MetadataContent taskResult = - client.generateTaskPromptFromText("Please analyze Site A.", "Task-T/network-layer/unconstrained/v1"); - MetadataContent authResult = - client.generateAuthPromptFromText("Authorize access.", "Task-T/network-layer/unconstrained/v1"); - MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", "Task-T/network-layer/unconstrained/v1"); + MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); + MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); + MetadataContent notificationResult = client.generateNotificationPromptFromText("Report finished.", unconstrained); assertNotNull(taskResult); assertEquals("Task-T/network-layer/unconstrained/v1", taskResult.templateUri()); @@ -289,11 +288,11 @@ void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { Map schema = Map.of("type", "object"); MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING.uri()); + client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING.uri()); + client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING.uri()); + client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); assertNotNull(taskResult); assertEquals(StandardTemplates.ENERGY_SAVING.uri(), taskResult.templateUri()); @@ -315,13 +314,11 @@ void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); A2ATClient client = new A2ATClient(envFile); + TemplateUri unconstrained = TemplateUri.of("Task-T", "v1", "network-layer", "unconstrained"); - MetadataContent taskResult = - client.generateTaskPromptFromText("Please analyze Site A.", "Task-T/network-layer/unconstrained/v1"); - MetadataContent authResult = - client.generateAuthPromptFromText("Authorize access.", "Task-T/network-layer/unconstrained/v1"); - MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", "Task-T/network-layer/unconstrained/v1"); + MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); + MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); + MetadataContent notificationResult = client.generateNotificationPromptFromText("Report finished.", unconstrained); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); @@ -336,11 +333,11 @@ void fromDataWithSchemaReturnsCorrectExtensionUriPerContentType() throws IOExcep Map schema = Map.of("type", "object"); MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING.uri()); + client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING.uri()); + client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING.uri()); + client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); @@ -348,43 +345,40 @@ void fromDataWithSchemaReturnsCorrectExtensionUriPerContentType() throws IOExcep } @Test - void fromTextThrowsOnInvalidTemplateUri() throws IOException { + void fromTextThrowsOnNullTemplateUri() throws IOException { Path envFile = writeMinimalLocalClientEnv(); A2ATClient client = new A2ATClient(envFile); - IllegalArgumentException taskEx = assertThrows( - IllegalArgumentException.class, - () -> client.generateTaskPromptFromText("Please analyze Site A.", "../etc/passwd")); - assertFalse(A2ATError.class.isInstance(taskEx), "invalid template URI must stay outside the A2ATError tree"); - IllegalArgumentException authEx = assertThrows( - IllegalArgumentException.class, - () -> client.generateAuthPromptFromText("Authorize access.", "../etc/passwd")); - assertFalse(A2ATError.class.isInstance(authEx), "invalid template URI must stay outside the A2ATError tree"); - IllegalArgumentException notifEx = assertThrows( - IllegalArgumentException.class, - () -> client.generateNotificationPromptFromText("Report finished.", "../etc/passwd")); - assertFalse(A2ATError.class.isInstance(notifEx), "invalid template URI must stay outside the A2ATError tree"); + NullPointerException taskEx = + assertThrows(NullPointerException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", null)); + assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException authEx = + assertThrows(NullPointerException.class, () -> client.generateAuthPromptFromText("Authorize access.", null)); + assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException notifEx = + assertThrows(NullPointerException.class, () -> client.generateNotificationPromptFromText("Report finished.", null)); + assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); } @Test - void fromDataWithSchemaThrowsOnInvalidTemplateUri() throws IOException { + void fromDataWithSchemaThrowsOnNullTemplateUri() throws IOException { Path envFile = writeMinimalLocalClientEnv(); A2ATClient client = new A2ATClient(envFile); Map data = Map.of("site", "Site A"); Map schema = Map.of("type", "object"); - IllegalArgumentException taskEx = assertThrows( - IllegalArgumentException.class, - () -> client.generateTaskPromptFromDataWithSchema(data, schema, "../etc/passwd")); - assertFalse(A2ATError.class.isInstance(taskEx), "invalid template URI must stay outside the A2ATError tree"); - IllegalArgumentException authEx = assertThrows( - IllegalArgumentException.class, - () -> client.generateAuthPromptFromDataWithSchema(data, schema, "../etc/passwd")); - assertFalse(A2ATError.class.isInstance(authEx), "invalid template URI must stay outside the A2ATError tree"); - IllegalArgumentException notifEx = assertThrows( - IllegalArgumentException.class, - () -> client.generateNotificationPromptFromDataWithSchema(data, schema, "../etc/passwd")); - assertFalse(A2ATError.class.isInstance(notifEx), "invalid template URI must stay outside the A2ATError tree"); + NullPointerException taskEx = assertThrows( + NullPointerException.class, + () -> client.generateTaskPromptFromDataWithSchema(data, schema, null)); + assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException authEx = assertThrows( + NullPointerException.class, + () -> client.generateAuthPromptFromDataWithSchema(data, schema, null)); + assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException notifEx = assertThrows( + NullPointerException.class, + () -> client.generateNotificationPromptFromDataWithSchema(data, schema, null)); + assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); } private static Path writeMinimalClientEnvWithoutRequiredSlots(String provider) throws IOException { diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 59382f01..070444bb 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -20,6 +20,7 @@ import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.llm.LLMRuntimeError; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; @@ -33,6 +34,9 @@ class DefaultClientPromptGenerationOrchestratorTest { private static final ClientSlotSchemaLoader EMPTY_SCHEMA_LOADER = (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()); + private static final TemplateUri AUTH_DATABASE_READ = + TemplateUri.of("Authorization-T", "v1", "database_read"); + @Test void generateTaskPromptLoadsTemplateAndRendersExtractedSlotsWhenScenarioIsMatched() { FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}\nNotes: {additional_notes}"); @@ -306,7 +310,7 @@ void generateFromTemplateUriWithMetadataReturnsMetadataContentOnSuccess() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent result = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri()); + MetadataContent result = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); assertEquals(StandardTemplates.ENERGY_SAVING.uri(), result.templateUri()); assertEquals("Site: Site A", result.promptText()); @@ -320,7 +324,7 @@ void generateFromTemplateUriWithMetadataScenarioRecognizerIsNotInvoked() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); - orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri()); + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); assertEquals(0, recognizer.invocationCount); } @@ -339,26 +343,6 @@ void generateFromTemplateUriWithMetadataThrowsOnNullTemplateUri() { assertEquals(0, templateLoader.loadCount); } - @Test - void generateFromTemplateUriWithMetadataThrowsOnBlankOrMalformedTemplateUri() { - for (String invalidUri : new String[] {"", " ", "../etc/passwd", "has space", "id;drop"}) { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); - - IllegalArgumentException ex = assertThrows( - IllegalArgumentException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", invalidUri)); - assertFalse(A2ATError.class.isInstance(ex), "invalid template URI must stay outside the A2ATError tree"); - assertTrue( - ex.getMessage().contains("invalid_template_uri"), - "the failure must carry the invalid_template_uri detail but was: " + ex.getMessage()); - assertEquals(0, recognizer.invocationCount); - assertEquals(0, templateLoader.loadCount); - } - } - @Test void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( @@ -369,7 +353,7 @@ void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { new FakeSlotValueExtractor(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("template_not_found", ex.getCode()); } @@ -381,7 +365,7 @@ void generateFromTemplateUriWithMetadataPropagatesRenderException() { new FakeSlotValueExtractor(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("render_failed", ex.getCode()); } @@ -395,7 +379,7 @@ void generateFromTemplateUriWithMetadataPropagatesLlmRuntimeError() { }); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("llm_invocation_failed", ex.getCode()); } @@ -410,7 +394,7 @@ void generateFromDataWithSchemaReturnsMetadataContentOnSuccess() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING.uri()); + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); assertEquals(StandardTemplates.ENERGY_SAVING.uri(), result.templateUri()); assertEquals("Site: Site A", result.promptText()); @@ -426,7 +410,7 @@ void generateFromDataWithSchemaPassesSchemaToExtractor() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); Map schema = Map.of("site", "string", "count", "number"); - orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), schema, StandardTemplates.ENERGY_SAVING.uri()); + orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), schema, StandardTemplates.ENERGY_SAVING); assertEquals(schema, slotValueExtractor.lastSchema); assertEquals(StandardTemplates.ENERGY_SAVING.uri(), slotValueExtractor.lastScenarioCode); @@ -442,7 +426,7 @@ void generateFromDataWithSchemaNullSchemaHandledGracefully() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), null, StandardTemplates.ENERGY_SAVING.uri()); + Map.of("site", "Site A"), null, StandardTemplates.ENERGY_SAVING); assertEquals("Site: Site A", result.promptText()); assertEquals(null, slotValueExtractor.lastSchema); @@ -457,30 +441,12 @@ void generateFromDataWithSchemaEmptySchemaHandledSameAsNull() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); + Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING); assertEquals("Site: Site A", result.promptText()); assertEquals(Map.of(), slotValueExtractor.lastSchema); } - @Test - void generateFromDataWithSchemaThrowsOnInvalidTemplateUri() { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractorWithSchema(Map.of())); - - IllegalArgumentException ex = assertThrows( - IllegalArgumentException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), "has space")); - assertFalse(A2ATError.class.isInstance(ex), "invalid template URI must stay outside the A2ATError tree"); - assertTrue( - ex.getMessage().contains("invalid_template_uri"), - "the failure must carry the invalid_template_uri detail but was: " + ex.getMessage()); - assertEquals(0, recognizer.invocationCount); - assertEquals(0, templateLoader.loadCount); - } - @Test void generateFromDataWithSchemaPropagatesResourceNotFoundException() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( @@ -491,7 +457,7 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundException() { new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri())); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING)); assertEquals("template_not_found", ex.getCode()); } @@ -503,7 +469,7 @@ void generateFromDataWithSchemaPropagatesRenderException() { new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri())); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING)); assertEquals("render_failed", ex.getCode()); } @@ -515,7 +481,7 @@ void generateFromDataWithSchemaPropagatesLlmRuntimeError() { new FailingExtractSlotsWithSchema(new LLMRuntimeError("LLM invocation failed."))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri())); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING)); assertEquals("llm_invocation_failed", ex.getCode()); } @@ -528,7 +494,7 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundExceptionFromExtractor( new ResourceNotFoundException("Slot schema file does not exist.", "energy-saving"))); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri())); + () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING)); assertEquals("slot_schema_not_found", ex.getCode()); } @@ -542,15 +508,15 @@ void eachMetadataEntryPointReturnsCorrectMetadataContent() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri()); + MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); - MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "Authorization-T/database_read/v1"); + Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING); + MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", AUTH_DATABASE_READ); MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "Authorization-T/database_read/v1"); - MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING.uri()); + Map.of("site", "Site A"), Map.of(), AUTH_DATABASE_READ); + MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); + Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING); assertTrue(taskText.promptText().contains("Site A")); assertTrue(taskData.promptText().contains("Site A")); @@ -566,15 +532,15 @@ void eachMetadataEntryPointUsesCorrectExtensionUri() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( new RecordingScenarioRecognizer(), templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); - MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri()); + MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); - MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", "Authorization-T/database_read/v1"); + Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING); + MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", AUTH_DATABASE_READ); MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), "Authorization-T/database_read/v1"); - MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING.uri()); + Map.of("site", "Site A"), Map.of(), AUTH_DATABASE_READ); + MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING.uri()); + Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskText.extensionUri()); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskData.extensionUri()); @@ -585,14 +551,14 @@ void eachMetadataEntryPointUsesCorrectExtensionUri() { } @Test - void authorizationMetadataEntryPointsPassAuthorizationTypeAsTemplateIdentifier() { + void authorizationMetadataEntryPointsPassTemplateUriIdentifier() { FakeTemplateLoader templateLoader = new FakeTemplateLoader("Access: {scope}"); FakeSlotValueExtractorWithSchema slotValueExtractor = new FakeSlotValueExtractorWithSchema(Map.of("scope", "read")); DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent nlResult = orchestrator.generateAuthPromptFromText("Grant read access.", "Authorization-T/database_read/v1"); + MetadataContent nlResult = orchestrator.generateAuthPromptFromText("Grant read access.", AUTH_DATABASE_READ); assertEquals("Authorization-T/database_read/v1", nlResult.templateUri()); assertEquals("Access: read", nlResult.promptText()); @@ -601,7 +567,7 @@ void authorizationMetadataEntryPointsPassAuthorizationTypeAsTemplateIdentifier() assertEquals("Grant read access.", slotValueExtractor.lastUserInput); MetadataContent jsonResult = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("scope", "read"), Map.of("scope", "string"), "Authorization-T/database_read/v1"); + Map.of("scope", "read"), Map.of("scope", "string"), AUTH_DATABASE_READ); assertEquals("Authorization-T/database_read/v1", jsonResult.templateUri()); assertEquals("Authorization-T/database_read/v1", templateLoader.lastScenarioCode); @@ -624,7 +590,7 @@ void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { schemaLoader); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("slot_validation_error", ex.getCode()); List failed = ex.failedParameters(); assertFalse(failed.isEmpty()); @@ -647,7 +613,7 @@ void validateRequiredSlotsThrowsSlotValidationErrorWhenMultipleRequiredSlotsMiss schemaLoader); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("slot_validation_error", ex.getCode()); assertEquals(1, ex.failedParameters().size()); assertEquals("target", ex.failedParameters().get(0).parameterName()); @@ -666,7 +632,7 @@ void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundWhenSchemaLoadin }); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("slot_schema_not_found", ex.getCode()); } @@ -683,7 +649,7 @@ void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundFromExtractor() EMPTY_SCHEMA_LOADER); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("slot_schema_not_found", ex.getCode()); } @@ -700,7 +666,7 @@ void generateFromTemplateUriWithMetadataThrowsPromptResourceLoadError() { EMPTY_SCHEMA_LOADER); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("prompt_resource_load_error", ex.getCode()); } @@ -717,7 +683,7 @@ void generateFromTemplateUriWithMetadataThrowsLlmInvocationFailedFromA2ATError() EMPTY_SCHEMA_LOADER); PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING.uri())); + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("llm_invocation_failed", ex.getCode()); } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java index 4f5ad3fe..3fd38742 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java @@ -1,6 +1,8 @@ package net.openan.a2at.sdk.core.exception; import java.util.Objects; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** * Single root exception type for all A2A-T SDK processing failures. @@ -32,7 +34,7 @@ public A2ATError(String message) { * @param message failure message * @param cause root cause */ - public A2ATError(String message, Throwable cause) { + public A2ATError(String message, @Nullable Throwable cause) { this(A2ATErrorCodes.SDK_INTERNAL_ERROR, message, cause); } @@ -43,7 +45,7 @@ public A2ATError(String message, Throwable cause) { * @param message failure message * @throws NullPointerException if {@code code} is null */ - public A2ATError(String code, String message) { + public A2ATError(@NonNull String code, String message) { super(message); this.code = Objects.requireNonNull(code, "code must not be null"); } @@ -56,7 +58,7 @@ public A2ATError(String code, String message) { * @param cause root cause * @throws NullPointerException if {@code code} is null */ - public A2ATError(String code, String message, Throwable cause) { + public A2ATError(@NonNull String code, String message, @Nullable Throwable cause) { super(message, cause); this.code = Objects.requireNonNull(code, "code must not be null"); } @@ -66,7 +68,7 @@ public A2ATError(String code, String message, Throwable cause) { * * @return error code, never null */ - public String getCode() { + public @NonNull String getCode() { return code; } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java index 54444846..980accea 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java @@ -2,6 +2,7 @@ import java.util.List; import net.openan.a2at.sdk.core.model.SlotValidationError; +import org.jspecify.annotations.NonNull; /** * Shared failure type raised when validating a prompt and extracting parameters from it fails. @@ -33,7 +34,8 @@ public A2ATParamExtractionError(String message) { * @param message failure message * @param errors structured per-slot validation error details */ - public A2ATParamExtractionError(String code, String message, List errors) { + public A2ATParamExtractionError( + @NonNull String code, String message, @NonNull List errors) { super(code, message); this.errors = List.copyOf(errors); } @@ -43,7 +45,7 @@ public A2ATParamExtractionError(String code, String message, List getErrors() { + public @NonNull List getErrors() { return errors; } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java index 54bffd6c..9fd4423c 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.core.exception; import java.nio.file.Path; +import org.jspecify.annotations.NonNull; /** * Thrown when one required configuration file path does not exist. @@ -14,7 +15,7 @@ public final class ConfigFileNotFoundException extends A2ATError { * * @param path missing config file path */ - public ConfigFileNotFoundException(Path path) { + public ConfigFileNotFoundException(@NonNull Path path) { super("Config file does not exist: " + path); } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/FailedParameter.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/FailedParameter.java index f32944f9..a2459697 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/FailedParameter.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/FailedParameter.java @@ -1,5 +1,7 @@ package net.openan.a2at.sdk.core.exception; +import org.jspecify.annotations.NonNull; + /** * Describes a single failed required slot during slot validation. * @@ -7,4 +9,4 @@ * @param reason the failure reason, e.g. {@code missing_required} * @since 2026-08 */ -public record FailedParameter(String parameterName, String reason) {} \ No newline at end of file +public record FailedParameter(@NonNull String parameterName, @NonNull String reason) {} \ No newline at end of file diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java index cb426fee..297f80e3 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java @@ -1,6 +1,8 @@ package net.openan.a2at.sdk.core.exception; import java.util.List; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** * Unified exception type for MetadataContent pipeline failures. @@ -20,7 +22,7 @@ public final class PromptGenerationException extends A2ATError { * @param message human-readable failure description * @throws NullPointerException if {@code code} is null */ - public PromptGenerationException(String code, String message) { + public PromptGenerationException(@NonNull String code, String message) { super(code, message); this.failedParameters = List.of(); } @@ -34,7 +36,8 @@ public PromptGenerationException(String code, String message) { * @param failedParameters list of failed parameters * @throws NullPointerException if {@code code} is null */ - public PromptGenerationException(String code, String message, List failedParameters) { + public PromptGenerationException( + @NonNull String code, String message, @Nullable List failedParameters) { super(code, message); this.failedParameters = failedParameters == null ? List.of() : List.copyOf(failedParameters); } @@ -48,7 +51,7 @@ public PromptGenerationException(String code, String message, List failedParameters() { + public @NonNull List failedParameters() { return failedParameters; } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ResourceNotFoundException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ResourceNotFoundException.java index 2cc1264f..c3f6d7f2 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ResourceNotFoundException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ResourceNotFoundException.java @@ -1,5 +1,7 @@ package net.openan.a2at.sdk.core.exception; +import org.jspecify.annotations.NonNull; + /** * Raised when a referenced SDK resource cannot be resolved. * @@ -15,7 +17,7 @@ public final class ResourceNotFoundException extends A2ATError { * @param message failure message * @param resourcePath missing classpath resource path */ - public ResourceNotFoundException(String message, String resourcePath) { + public ResourceNotFoundException(String message, @NonNull String resourcePath) { super(message); this.resourcePath = resourcePath; } @@ -25,7 +27,7 @@ public ResourceNotFoundException(String message, String resourcePath) { * * @return missing resource path */ - public String resourcePath() { + public @NonNull String resourcePath() { return resourcePath; } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java index cda1cb10..b943a43d 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java @@ -2,6 +2,7 @@ import java.nio.file.Path; import java.util.Map; +import org.jspecify.annotations.NonNull; /** * Unified SDK configuration entry point loaded from one caller-supplied `.env` file. @@ -21,7 +22,7 @@ public record A2ATConfig( * @param envPath caller-supplied `.env` file path * @return unified SDK config */ - public static A2ATConfig load(Path envPath) { + public static @NonNull A2ATConfig load(@NonNull Path envPath) { Map values = DotEnvConfigSource.load(envPath); return new A2ATConfig( PromptRuntimeConfig.fromMap(values), diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/DotEnvConfigSource.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/DotEnvConfigSource.java index bf07790d..63da824e 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/DotEnvConfigSource.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/DotEnvConfigSource.java @@ -10,6 +10,7 @@ import lombok.NoArgsConstructor; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ConfigFileNotFoundException; +import org.jspecify.annotations.NonNull; /** * Reads SDK configuration from one caller-supplied `.env` file. @@ -29,7 +30,7 @@ public final class DotEnvConfigSource { * @return parsed non-empty key/value pairs * @throws A2ATError if the file contains more than {@value #MAX_ENTRIES} entries or cannot be read */ - public static Map load(Path path) { + public static @NonNull Map load(@NonNull Path path) { if (!Files.exists(path)) { throw new ConfigFileNotFoundException(path); } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java index e0734ce1..3aa4d60b 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java @@ -2,6 +2,8 @@ import java.util.LinkedHashMap; import java.util.Map; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** * Successful result of an A2A-T prompt generation call. @@ -11,7 +13,7 @@ * @param extensionUri TMF extension URI under which the message travels in A2A-T metadata * @since 2026-08 */ -public record MetadataContent(String templateUri, String promptText, String extensionUri) { +public record MetadataContent(@Nullable String templateUri, @Nullable String promptText, @NonNull String extensionUri) { /** Metadata key carrying the template URI alongside the message itself. */ public static final String TEMPLATE_URI_METADATA_KEY = "templateUri"; @@ -24,7 +26,7 @@ public record MetadataContent(String templateUri, String promptText, String exte * * @return newly built metadata map with exactly the extension URI and {@code templateUri} keys */ - public Map buildMetadataContent() { + public @NonNull Map buildMetadataContent() { Map metadata = new LinkedHashMap<>(); metadata.put(extensionUri, promptText); metadata.put(TEMPLATE_URI_METADATA_KEY, templateUri); diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/OperationResult.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/OperationResult.java index adf664f8..e4d6ad3c 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/OperationResult.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/OperationResult.java @@ -2,6 +2,8 @@ import java.util.Objects; import net.openan.a2at.sdk.core.exception.A2ATError; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** * Minimal structured result wrapper used by higher-level orchestration flows. @@ -9,7 +11,7 @@ * @param result value type * @since 2026-06 */ -public record OperationResult(T value, A2ATError error) { +public record OperationResult(@Nullable T value, @Nullable A2ATError error) { /** * Creates a successful operation result. @@ -18,7 +20,7 @@ public record OperationResult(T value, A2ATError error) { * @param value type * @return successful result wrapper */ - public static OperationResult success(T value) { + public static OperationResult success(@NonNull T value) { return new OperationResult<>(value, null); } @@ -29,7 +31,7 @@ public static OperationResult success(T value) { * @param value type * @return failed result wrapper */ - public static OperationResult failure(A2ATError error) { + public static OperationResult failure(@NonNull A2ATError error) { return new OperationResult<>(null, Objects.requireNonNull(error, "error")); } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java index cb0423da..da5b93a6 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java @@ -3,7 +3,8 @@ /** * One loadable prompt template of any A2A-T extension. * - * @param uri template URI such as {@code Task-T/v1/energy-saving} or {@code Negotiation-T/v1/information-negotiation/propose} + * @param uri template URI such as {@code Task-T/network-layer/energy-saving/v1} or + * {@code Negotiation-T/information-negotiation/propose/v1} * @param description template description taken from the leading HTML comment; empty string when the template has no * comment * @param content full template file text diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java index 12aa389d..d8fc6c27 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java @@ -1,5 +1,7 @@ package net.openan.a2at.sdk.core.resources; +import org.jspecify.annotations.Nullable; + /** * Validates path segment values used to compose classpath or filesystem resource paths. * @@ -20,7 +22,7 @@ private PathSegments() { * @param value candidate path segment such as a language or category identifier * @return true when the value is non-null, non-blank and contains no slash, backslash or {@code ..} sequence */ - public static boolean isSimpleSegment(String value) { + public static boolean isSimpleSegment(@Nullable String value) { return value != null && !value.isBlank() && !value.contains("/") diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java index 186bc281..d0ca92ce 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java @@ -2,6 +2,7 @@ import java.util.Map; import net.openan.a2at.sdk.core.model.FilledParamData; +import org.jspecify.annotations.NonNull; /** * Entry point for validating content and extracting filled parameters. @@ -18,11 +19,13 @@ public interface ContentValidator { * * @param prompt content prompt text * @param schema caller-provided parameter JSON schema - * @param templateUri URI of the template the content is validated against + * @param templateUri URI of the template the content is validated against, such as + * {@code Task-T/network-layer/energy-saving/v1} * @return filled parameter data carrying the merged parameters * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank or the template URI is blank or malformed + * @throws IllegalArgumentException if the prompt is blank * @throws ContentValidationException if the validation fails at any stage */ - FilledParamData validate(String prompt, Map schema, String templateUri); -} \ No newline at end of file + FilledParamData validate( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri); +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SemanticValidator.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SemanticValidator.java index f727b358..c50d5bfe 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SemanticValidator.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SemanticValidator.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.core.validation; import java.util.Map; +import org.jspecify.annotations.NonNull; /** * LLM-backed semantic validator for content. @@ -24,5 +25,6 @@ public interface SemanticValidator { * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the semantic validation prompt resources * are missing */ - ValidationResult validate(String prompt, Map schema, T reference); + @NonNull ValidationResult validate( + @NonNull String prompt, @NonNull Map schema, @NonNull T reference); } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java index c8af4f63..f5b23e0a 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java @@ -6,6 +6,8 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import net.openan.a2at.sdk.core.resources.PathSegments; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** * Structured, always-valid identifier of a content template. @@ -27,7 +29,8 @@ * @param templateVersion trailing URI segment, such as {@code v1} * @since 2026-08 */ -public record TemplateUri(String extensionName, List pathSegments, String templateVersion) { +public record TemplateUri( + @NonNull String extensionName, @NonNull List pathSegments, @NonNull String templateVersion) { /** Default template version segment used by all built-in templates. */ public static final String DEFAULT_TEMPLATE_VERSION = "v1"; @@ -61,7 +64,8 @@ public record TemplateUri(String extensionName, List pathSegments, Strin * @throws NullPointerException if any component is null * @throws IllegalArgumentException if any component is not a simple path segment or no path segment is given */ - public static TemplateUri of(String extensionName, String templateVersion, String... pathSegments) { + public static @NonNull TemplateUri of( + @NonNull String extensionName, @NonNull String templateVersion, @NonNull String... pathSegments) { return new TemplateUri(extensionName, List.of(pathSegments), templateVersion); } @@ -72,7 +76,7 @@ public static TemplateUri of(String extensionName, String templateVersion, Strin * @return parsed template URI, or an empty result when the input is null, blank, has fewer than three segments or * contains a segment that is not a simple path segment */ - public static Optional parse(String templateUri) { + public static Optional parse(@Nullable String templateUri) { if (templateUri == null || templateUri.isBlank()) { return Optional.empty(); } @@ -96,7 +100,7 @@ public static Optional parse(String templateUri) { * * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} */ - public String uri() { + public @NonNull String uri() { return Stream.concat( Stream.concat(Stream.of(extensionName), pathSegments.stream()), Stream.of(templateVersion)) diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java index a2fb4e12..df74fe4b 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java @@ -9,6 +9,7 @@ import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.SlotValidationError; +import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,7 +42,8 @@ public final class ValidationPipeline { * @param maxAttempts maximum number of retry attempts for the semantic validation step * @throws NullPointerException if the rule checker or the semantic validator is null */ - public ValidationPipeline(RuleChecker ruleChecker, SemanticValidator semanticValidator, int maxAttempts) { + public ValidationPipeline( + @NonNull RuleChecker ruleChecker, @NonNull SemanticValidator semanticValidator, int maxAttempts) { this.ruleChecker = Objects.requireNonNull(ruleChecker, "ruleChecker"); this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); this.maxAttempts = maxAttempts; @@ -58,7 +60,8 @@ public ValidationPipeline(RuleChecker ruleChecker, SemanticValidator semantic * @throws IllegalArgumentException if the prompt is blank * @throws ContentValidationException if the validation fails at any stage */ - public FilledParamData validate(String prompt, Map schema, T reference) { + public @NonNull FilledParamData validate( + @NonNull String prompt, @NonNull Map schema, @NonNull T reference) { Objects.requireNonNull(schema, "schema"); Objects.requireNonNull(prompt, "prompt"); if (prompt.isBlank()) { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java index 2c3b8190..8f9ba238 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java @@ -10,9 +10,12 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** * Shared service for the negotiation content layer consumed by both the client and the server facade. @@ -32,7 +35,7 @@ public final class NegotiationContentService { * * @param orchestrator negotiation generation orchestrator carrying the actual pipelines */ - public NegotiationContentService(NegotiationGenerationOrchestrator orchestrator) { + public NegotiationContentService(@NonNull NegotiationGenerationOrchestrator orchestrator) { this.orchestrator = Objects.requireNonNull(orchestrator, "Negotiation orchestrator must not be null."); } @@ -47,7 +50,8 @@ public NegotiationContentService(NegotiationGenerationOrchestrator orchestrator) * @param llmClient LLM client for the LLM-backed steps; null keeps those steps unavailable * @return assembled negotiation generation orchestrator */ - public static NegotiationGenerationOrchestrator buildOrchestrator(A2ATConfig config, LLMClient llmClient) { + public static NegotiationGenerationOrchestrator buildOrchestrator( + @NonNull A2ATConfig config, @Nullable LLMClient llmClient) { return NegotiationGenerationOrchestratorBuilder.builder() .language(config.prompt().language()) .localRootDir(config.prompt().localRootDir()) @@ -66,7 +70,8 @@ public static NegotiationGenerationOrchestrator buildOrchestrator(A2ATConfig con * @param envPath resolved `.env` file path the config was loaded from * @return config with the local root resolved to an absolute normalized path */ - public static A2ATConfig resolvePromptResourceLocalRootDir(A2ATConfig config, Path envPath) { + public static A2ATConfig resolvePromptResourceLocalRootDir( + @NonNull A2ATConfig config, @NonNull Path envPath) { String localRootDir = config.prompt().localRootDir(); Path localRootPath = Path.of(localRootDir); Path resolvedLocalRootPath = localRootPath.isAbsolute() @@ -86,13 +91,13 @@ public static A2ATConfig resolvePromptResourceLocalRootDir(A2ATConfig config, Pa * @param data typed propose input carrying the negotiation context and the typed content * @param templateUri template URI whose phase segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI is malformed, or its phase or type contradicts the method - * or the content type + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content type * @throws NegotiationGenerationException with the code {@code template_not_found} or * {@code negotiation_slot_missing} when loading or rendering the template fails */ - public MetadataContent generateProposeFromData(NegotiationProposeData data, String templateUri) { + public MetadataContent generateProposeFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { return orchestrator.generateProposeFromData(data, templateUri); } @@ -102,13 +107,14 @@ public MetadataContent generateProposeFromData(NegotiationProposeData data, Stri * @param data typed terminal input whose content conclusion must be {@code Accept} * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or - * the content, or the content conclusion is not {@code Accept} + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content, or + * the content conclusion is not {@code Accept} * @throws NegotiationGenerationException with the code {@code template_not_found} or * {@code negotiation_slot_missing} when loading or rendering the template fails */ - public MetadataContent generateAcceptFromData(NegotiationEndingData data, String templateUri) { + public MetadataContent generateAcceptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { return orchestrator.generateAcceptFromData(data, templateUri); } @@ -118,13 +124,14 @@ public MetadataContent generateAcceptFromData(NegotiationEndingData data, String * @param data typed terminal input whose content conclusion must be {@code Reject} * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or - * the content, or the content conclusion is not {@code Reject} + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content, or + * the content conclusion is not {@code Reject} * @throws NegotiationGenerationException with the code {@code template_not_found} or * {@code negotiation_slot_missing} when loading or rendering the template fails */ - public MetadataContent generateRejectFromData(NegotiationEndingData data, String templateUri) { + public MetadataContent generateRejectFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { return orchestrator.generateRejectFromData(data, templateUri); } @@ -135,14 +142,15 @@ public MetadataContent generateRejectFromData(NegotiationEndingData data, String * @param context negotiation context injected into the rendered message without any LLM involvement * @param templateUri template URI whose phase segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI is malformed or contradicts the method + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method * @throws NegotiationGenerationException with the code {@code template_not_found}, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or * {@code negotiation_invalid_input} when the text is blank or the extracted content contradicts the phase */ - public MetadataContent generateProposeFromText(String text, NegotiationContext context, String templateUri) { + public MetadataContent generateProposeFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { return orchestrator.generateProposeFromText(text, context, templateUri); } @@ -153,14 +161,15 @@ public MetadataContent generateProposeFromText(String text, NegotiationContext c * @param context negotiation context injected into the rendered message without any LLM involvement * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI is malformed or contradicts the method + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method * @throws NegotiationGenerationException with the code {@code template_not_found}, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Accept} */ - public MetadataContent generateAcceptFromText(String text, NegotiationContext context, String templateUri) { + public MetadataContent generateAcceptFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { return orchestrator.generateAcceptFromText(text, context, templateUri); } @@ -171,14 +180,15 @@ public MetadataContent generateAcceptFromText(String text, NegotiationContext co * @param context negotiation context injected into the rendered message without any LLM involvement * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI is malformed or contradicts the method + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method * @throws NegotiationGenerationException with the code {@code template_not_found}, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Reject} */ - public MetadataContent generateRejectFromText(String text, NegotiationContext context, String templateUri) { + public MetadataContent generateRejectFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { return orchestrator.generateRejectFromText(text, context, templateUri); } @@ -195,11 +205,12 @@ public List getNegotiationPrompts() { /** * Loads one negotiation template by its URI; never throws. * - * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} - * @return the addressed template, or an empty optional when the URI is malformed or no template exists for it in - * the configured language + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} + * @return the addressed template, or an empty optional when the URI does not address a negotiation template or no + * template exists for it in the configured language + * @throws NullPointerException if the template URI is null */ - public Optional getNegotiationPrompt(String templateUri) { + public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { return orchestrator.getNegotiationPrompt(templateUri); } @@ -210,16 +221,15 @@ public Optional getNegotiationPrompt(String templateUri) { * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI whose phase segment must be {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI is malformed or contradicts the - * method + * @throws NullPointerException if the prompt, the schema or the template URI is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI contradicts the method * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline * fails */ public FilledParamData validateAndFillingProposeData( - String prompt, Map schema, String templateUri) { + String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { return orchestrator.validateAndFillingProposeData(prompt, schema, templateUri); } @@ -230,15 +240,15 @@ public FilledParamData validateAndFillingProposeData( * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI is malformed or contradicts the - * method + * @throws NullPointerException if the prompt, the schema or the template URI is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI contradicts the method * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline * fails */ - public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) { + public FilledParamData validateAndFillingAcceptData( + String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { return orchestrator.validateAndFillingAcceptData(prompt, schema, templateUri); } @@ -249,15 +259,15 @@ public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) { + public FilledParamData validateAndFillingRejectData( + String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { return orchestrator.validateAndFillingRejectData(prompt, schema, templateUri); } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 4ae3562d..fedb50f1 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -12,6 +12,7 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; @@ -23,6 +24,7 @@ import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; +import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -95,16 +97,16 @@ public final class NegotiationGenerationOrchestrator { * generator of the negotiation type addressed by the template URI and rendered from that template. * * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/propose}; its phase + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase * segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI is malformed, or its phase or type contradicts the method - * or the content type + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content type * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails */ - public MetadataContent generateProposeFromData(NegotiationProposeData data, String templateUri) { + public MetadataContent generateProposeFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { Objects.requireNonNull(data, "Negotiation propose data must not be null."); return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.PROPOSE); } @@ -116,16 +118,17 @@ public MetadataContent generateProposeFromData(NegotiationProposeData data, Stri * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/accept-reject}; its phase + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or - * the content, or the content conclusion is not {@code Accept} + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content, or + * the content conclusion is not {@code Accept} * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails */ - public MetadataContent generateAcceptFromData(NegotiationEndingData data, String templateUri) { + public MetadataContent generateAcceptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { Objects.requireNonNull(data, "Negotiation ending data must not be null."); return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.ACCEPT); } @@ -137,16 +140,17 @@ public MetadataContent generateAcceptFromData(NegotiationEndingData data, String * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/v1/feasibility-negotiation/accept-reject}; its phase + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or - * the content, or the content conclusion is not {@code Reject} + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content, or + * the content conclusion is not {@code Reject} * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails */ - public MetadataContent generateRejectFromData(NegotiationEndingData data, String templateUri) { + public MetadataContent generateRejectFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { Objects.requireNonNull(data, "Negotiation ending data must not be null."); return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.REJECT); } @@ -161,18 +165,19 @@ public MetadataContent generateRejectFromData(NegotiationEndingData data, String * * @param text free-text input describing the message content * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose}; its phase segment + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment * must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt * resource exists for the URI and language, {@code negotiation_content_extract_failed} or * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, * {@code negotiation_slot_missing} when the extracted content misses a required field, or * {@code negotiation_invalid_input} when the text is blank or the extracted content contradicts the phase */ - public MetadataContent generateProposeFromText(String text, NegotiationContext context, String templateUri) { + public MetadataContent generateProposeFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { return generateFromText(text, context, templateUri, NegotiationPhase.PROPOSE); } @@ -185,18 +190,19 @@ public MetadataContent generateProposeFromText(String text, NegotiationContext c * * @param text free-text input describing the message content * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/accept-reject}; its phase + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt * resource exists for the URI and language, {@code negotiation_content_extract_failed} or * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, * {@code negotiation_slot_missing} when the extracted content misses a required field, or * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Accept} */ - public MetadataContent generateAcceptFromText(String text, NegotiationContext context, String templateUri) { + public MetadataContent generateAcceptFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { return generateFromText(text, context, templateUri, NegotiationPhase.ACCEPT); } @@ -209,18 +215,19 @@ public MetadataContent generateAcceptFromText(String text, NegotiationContext co * * @param text free-text input describing the message content * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/v1/feasibility-negotiation/accept-reject}; its phase + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt * resource exists for the URI and language, {@code negotiation_content_extract_failed} or * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, * {@code negotiation_slot_missing} when the extracted content misses a required field, or * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Reject} */ - public MetadataContent generateRejectFromText(String text, NegotiationContext context, String templateUri) { + public MetadataContent generateRejectFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { return generateFromText(text, context, templateUri, NegotiationPhase.REJECT); } @@ -245,20 +252,21 @@ public List getNegotiationPrompts() { /** * Loads one negotiation template addressed by its URI. * - *

This query never throws: a malformed URI or a template that exists nowhere for the configured language returns - * an empty result and logs an actionable warning. + *

This query never throws: a URI that does not address a negotiation template or a template that exists nowhere + * for the configured language returns an empty result and logs an actionable warning. * - * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} - * @return the addressed template, or an empty result when the URI is malformed or the template does not exist for - * the configured language + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} + * @return the addressed template, or an empty result when the URI does not address a negotiation template or the + * template does not exist for the configured language + * @throws NullPointerException if the template URI is null */ - public Optional getNegotiationPrompt(String templateUri) { + public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { Optional reference = parseQueryReference(templateUri); if (reference.isEmpty()) { logger.atWarn() .log( "negotiation_template_not_found uri={} language={} reason=invalid_template_uri hint={}", - templateUri, + templateUri.uri(), language, LANGUAGE_HINT); return Optional.empty(); @@ -269,7 +277,7 @@ public Optional getNegotiationPrompt(String templateUri) { logger.atWarn() .log( "negotiation_template_not_found uri={} language={} hint={}", - templateUri, + templateUri.uri(), language, LANGUAGE_HINT); return Optional.empty(); @@ -279,17 +287,17 @@ public Optional getNegotiationPrompt(String templateUri) { /** * Validates a propose-phase negotiation message and extracts its parameters. * - *

The pipeline checks the template URI format before any LLM call, runs the deterministic rule gate, then - * performs one semantic validation LLM call (retried on the retryable failure codes) and merges the extracted - * parameters with the rule-level context parameters; context parameters win on conflict. + *

The pipeline checks the template URI before any LLM call, runs the deterministic rule gate, then performs one + * semantic validation LLM call (retried on the retryable failure codes) and merges the extracted parameters with + * the rule-level context parameters; context parameters win on conflict. * * @param prompt rendered negotiation message text to validate * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema is null - * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, @@ -297,60 +305,62 @@ public Optional getNegotiationPrompt(String templateUri) { * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateAndFillingProposeData( - String prompt, Map schema, String templateUri) { + String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { return validateAndFilling(prompt, schema, templateUri, NegotiationPhase.PROPOSE); } /** * Validates an accept-phase negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, String)} with the expected phase - * fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * accept-phase semantic constraints. + *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must + * satisfy the accept-phase semantic constraints. * * @param prompt rendered negotiation message text to validate * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema is null - * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) { + public FilledParamData validateAndFillingAcceptData( + String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { return validateAndFilling(prompt, schema, templateUri, NegotiationPhase.ACCEPT); } /** * Validates a reject-phase negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, String)} with the expected phase - * fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * reject-phase semantic constraints. + *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must + * satisfy the reject-phase semantic constraints. * * @param prompt rendered negotiation message text to validate * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema is null - * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingRejectData(String prompt, Map schema, String templateUri) { + public FilledParamData validateAndFillingRejectData( + String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { return validateAndFilling(prompt, schema, templateUri, NegotiationPhase.REJECT); } private MetadataContent generateFromData( - NegotiationContext context, NegotiationContent content, String templateUri, NegotiationPhase phase) { + NegotiationContext context, NegotiationContent content, TemplateUri templateUri, NegotiationPhase phase) { requireContext(context); try { NegotiationReference reference = requireReference(templateUri, phase); @@ -359,13 +369,13 @@ private MetadataContent generateFromData( return completeGeneration(reference, promptText, context); } catch (NegotiationGenerationException failure) { logger.atWarn() - .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri); + .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); throw failure; } } private MetadataContent generateFromText( - String text, NegotiationContext context, String templateUri, NegotiationPhase phase) { + String text, NegotiationContext context, TemplateUri templateUri, NegotiationPhase phase) { requireContext(context); try { NegotiationReference reference = requireReference(templateUri, phase); @@ -375,7 +385,7 @@ private MetadataContent generateFromText( return completeGeneration(reference, promptText, context); } catch (NegotiationGenerationException failure) { logger.atWarn() - .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri); + .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); throw failure; } } @@ -428,7 +438,7 @@ private MetadataContent completeGeneration( } private FilledParamData validateAndFilling( - String prompt, Map schema, String templateUri, NegotiationPhase phase) { + String prompt, Map schema, TemplateUri templateUri, NegotiationPhase phase) { Objects.requireNonNull(schema, "Parameter schema must not be null."); NegotiationReference reference = requireReference(templateUri, phase); try { @@ -444,27 +454,26 @@ private FilledParamData validateAndFilling( } /** - * Parses the template URI against one expected phase, rejecting every URI the parse rejects. + * Derives the reference of a typed template URI against one expected phase. * * @param templateUri template URI declared by the caller * @param phase API-level phase the calling method operates on * @return reference addressed by the URI carrying the expected phase - * @throws IllegalArgumentException if the URI is null, blank, malformed or its phase segment does not match the - * expected phase + * @throws IllegalArgumentException if the URI does not address a negotiation template of the expected phase */ - private NegotiationReference requireReference(String templateUri, NegotiationPhase phase) { - return NegotiationReference.tryParse(templateUri, phase, language) + private NegotiationReference requireReference(TemplateUri templateUri, NegotiationPhase phase) { + return NegotiationReference.fromTemplateUri(templateUri, phase, language) .orElseThrow(() -> new IllegalArgumentException( - "Template URI is malformed or contradicts the expected phase " + phase + " (" - + phase.uriSegment() + "): " + templateUri + ".")); + "Template URI does not address a negotiation template of the expected phase " + phase + " (" + + phase.uriSegment() + "): " + templateUri.uri() + ".")); } - private Optional parseQueryReference(String templateUri) { + private Optional parseQueryReference(TemplateUri templateUri) { // The URI layer cannot distinguish accept from reject because both phases share the accept-reject template // segment. The query therefore addresses the shared template through the ACCEPT phase; the parsed phase is an // addressing artifact only and must never be read as the phase of any message. for (NegotiationPhase phase : List.of(NegotiationPhase.PROPOSE, NegotiationPhase.ACCEPT)) { - Optional reference = NegotiationReference.tryParse(templateUri, phase, language); + Optional reference = NegotiationReference.fromTemplateUri(templateUri, phase, language); if (reference.isPresent()) { return reference; } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java index 987e84c6..baeb6708 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -1,5 +1,6 @@ package net.openan.a2at.sdk.negotiation.resources; +import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Optional; @@ -8,6 +9,8 @@ import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** * Addressing key for one negotiation template: negotiation type, API-level phase and language. @@ -76,7 +79,7 @@ public String uri() { * @throws NullPointerException if the expected phase is null */ public static Optional tryParse( - String templateUri, NegotiationPhase expectedPhase, String language) { + @Nullable String templateUri, @NonNull NegotiationPhase expectedPhase, String language) { Objects.requireNonNull(expectedPhase, "Expected negotiation phase must not be null."); if (templateUri == null || templateUri.isBlank()) { return Optional.empty(); @@ -101,6 +104,46 @@ public static Optional tryParse( return Optional.of(new NegotiationReference(parsedType, expectedPhase, language)); } + /** + * Derives a reference from a typed template URI, checking it against the expected phase. + * + *

The typed variant of {@link #tryParse(String, NegotiationPhase, String)}: because a {@link TemplateUri} is + * always structurally well formed, the checks operate on the URI components directly instead of splitting a raw + * string. The URI layer cannot distinguish accept from reject because both share the {@code accept-reject} + * segment; the expected phase disambiguates the result, which therefore always carries the expected phase. + * + * @param templateUri typed template URI such as {@code Negotiation-T/target-negotiation/accept-reject/v1} + * @param expectedPhase API-level phase the caller is operating on; the derived reference carries this phase + * @param language locale identifier for the derived reference + * @return reference addressed by the URI carrying the expected phase, or an empty result when the URI does not + * address a negotiation template of the expected phase (wrong extension name, path segment count, type + * segment, phase segment or template version) + * @throws NullPointerException if the template URI or the expected phase is null + */ + public static Optional fromTemplateUri( + @NonNull TemplateUri templateUri, @NonNull NegotiationPhase expectedPhase, String language) { + Objects.requireNonNull(templateUri, "Template URI must not be null."); + Objects.requireNonNull(expectedPhase, "Expected negotiation phase must not be null."); + if (!URI_PREFIX.equals(templateUri.extensionName())) { + return Optional.empty(); + } + List segments = templateUri.pathSegments(); + if (segments.size() != 2) { + return Optional.empty(); + } + NegotiationType parsedType = parseTypeSegment(segments.get(0)); + if (parsedType == null) { + return Optional.empty(); + } + if (!phaseSegmentMatches(segments.get(1), expectedPhase)) { + return Optional.empty(); + } + if (!URI_VERSION_SEGMENT.equals(templateUri.templateVersion())) { + return Optional.empty(); + } + return Optional.of(new NegotiationReference(parsedType, expectedPhase, language)); + } + private static NegotiationType parseTypeSegment(String typeSegment) { if (!typeSegment.endsWith(TYPE_SEGMENT_SUFFIX)) { return null; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java index 5092f5a9..cab4d332 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java @@ -12,6 +12,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.core.model.FilledParamData; @@ -36,7 +37,7 @@ class NegotiationConcurrentMixedLoadTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; private static final int THREADS = 12; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java index cb2659b4..aaaf8e25 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java @@ -47,7 +47,7 @@ void acceptConclusionRendersTheAcceptLiteral(String language) { new InfoEndingContent( NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("area information", "Songshan Lake")))), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()) + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT) .promptText(); String target = orchestrator .generateAcceptFromData( @@ -55,7 +55,7 @@ void acceptConclusionRendersTheAcceptLiteral(String language) { context, new TargetEndingContent( NegotiationConclusion.ACCEPT, "The confirmed intent is recorded.", null)), - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri()) + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) .promptText(); String feasibility = orchestrator .generateAcceptFromData( @@ -63,7 +63,7 @@ void acceptConclusionRendersTheAcceptLiteral(String language) { context, new FeasibilityEndingContent( NegotiationConclusion.ACCEPT, "The target is achievable.")), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) .promptText(); assertTrue(information.contains(conclusionSection(language, "information") + "\nAccept")); @@ -87,7 +87,7 @@ void rejectConclusionRendersTheRejectLiteral(String language) { new InfoEndingContent( NegotiationConclusion.REJECT, List.of(new NegotiationItem("area information", "not available")))), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()) + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT) .promptText(); String target = orchestrator .generateRejectFromData( @@ -95,7 +95,7 @@ void rejectConclusionRendersTheRejectLiteral(String language) { context, new TargetEndingContent( NegotiationConclusion.REJECT, null, "The intent cannot be clarified.")), - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri()) + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) .promptText(); String feasibility = orchestrator .generateRejectFromData( @@ -103,7 +103,7 @@ void rejectConclusionRendersTheRejectLiteral(String language) { context, new FeasibilityEndingContent( NegotiationConclusion.REJECT, "The target is not achievable.")), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) .promptText(); assertTrue(information.contains(conclusionSection(language, "information") + "\nReject")); @@ -126,14 +126,14 @@ void feasibilitySummaryUsesTheExceptionSlotUnderItsOwnSectionTitle(String langua new NegotiationEndingData( new NegotiationContext(UUID, 2, 5), new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, summary)), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) .promptText(); String rejectText = orchestrator .generateRejectFromData( new NegotiationEndingData( new NegotiationContext(UUID, 2, 5), new FeasibilityEndingContent(NegotiationConclusion.REJECT, summary)), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) .promptText(); String sectionTitle = @@ -157,7 +157,7 @@ void feasibilitySummaryIsCarriedVerbatim(String language) { new NegotiationEndingData( new NegotiationContext(UUID, 2, 5), new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, summary)), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) .promptText(); assertEquals(1, countOccurrences(promptText, summary)); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java index 6f251d0f..2219eb63 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.stream.Stream; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -28,9 +29,9 @@ class ConditionalSectionRenderingTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; - private static final String TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; static Stream languages() { return Stream.of(Arguments.of("zh-CN"), Arguments.of("en-US")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java index 308fe4bf..76225685 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java @@ -9,6 +9,7 @@ import java.util.Map; import java.util.function.Supplier; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; @@ -29,27 +30,28 @@ /** * Verifies the programming-error matrix of the from-data generation as one table-driven suite. * - *

Every row of the input-validation matrix of the from-data variants — method-data mismatch, malformed or - * mismatching template URIs, phase-conclusion mismatch, missing required fields and empty conditional content — fails - * with a standard {@link NullPointerException} (pure null arguments) or {@link IllegalArgumentException} (blank, - * malformed, range and semantic-contract violations) that is not part of the SDK processing-error hierarchy and - * carries an English message pointing at the offending input. No row ever reaches the LLM. + *

Every row of the input-validation matrix of the from-data variants — method-data mismatch, template URIs that + * do not address a negotiation template of the expected phase, phase-conclusion mismatch, missing required fields and + * empty conditional content — fails with a standard {@link NullPointerException} (pure null arguments) or + * {@link IllegalArgumentException} (blank, range and semantic-contract violations) that is not part of the SDK + * processing-error hierarchy and carries an English message pointing at the offending input. No row ever reaches the + * LLM. Structural URI malformation is impossible by construction of {@link TemplateUri} and is therefore not a row. */ class FromDataProgrammingErrorMatrixTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - private static final String INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(); + private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - private static final String TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; - private static final String FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; - private static final String FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(); + private static final TemplateUri FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; - private static final String TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(); + private static final TemplateUri TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; private final CountingClient llm = new CountingClient(); @@ -64,7 +66,7 @@ void everyMatrixRowFailsWithAStandardJavaException() { // The propose-versus-ending family mismatch of UT-GEN-002 is deliberately absent here: the typed data records // make that mismatch a compile-time error at the facade, so it cannot occur at runtime. - assertEquals(24, matrix.size(), "the full matrix must be exercised"); + assertEquals(22, matrix.size(), "the full matrix must be exercised"); for (MatrixRow row : matrix) { RuntimeException failure = assertThrows( row.expectedException(), () -> row.call().get(), "row must fail: " + row.label()); @@ -212,55 +214,41 @@ context, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " ")), FEASIBILITY_ACCEPT_URI), IllegalArgumentException.class, "must not be blank"), - new MatrixRow( - "template URI with too few segments", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - "information-negotiation/propose"), - IllegalArgumentException.class, - "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), - new MatrixRow( - "template URI with too many segments", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - "Negotiation-T/information-negotiation/propose/v1/extra"), - IllegalArgumentException.class, - "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), new MatrixRow( "template URI with wrong prefix", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), - "Task-T/information-negotiation/propose/v1"), + TemplateUri.of("Task-T", "v1", "information-negotiation", "propose")), IllegalArgumentException.class, - "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), + "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), new MatrixRow( "template URI with wrong version", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), - "Negotiation-T/information-negotiation/propose/v2"), + TemplateUri.of("Negotiation-T", "v2", "information-negotiation", "propose")), IllegalArgumentException.class, - "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), + "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), new MatrixRow( "template URI with underscore type segment", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), - "Negotiation-T/information_negotiation/propose/v1"), + TemplateUri.of("Negotiation-T", "v1", "information_negotiation", "propose")), IllegalArgumentException.class, - "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), + "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), new MatrixRow( "template URI with unknown type", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), - "Negotiation-T/unknown-negotiation/propose/v1"), + TemplateUri.of("Negotiation-T", "v1", "unknown-negotiation", "propose")), IllegalArgumentException.class, - "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), + "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), new MatrixRow( "template URI phase contradicts the method", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), INFORMATION_ACCEPT_URI), IllegalArgumentException.class, - "Template URI is malformed or contradicts the expected phase PROPOSE (propose)"), + "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), new MatrixRow( "template URI type contradicts the content type", () -> orchestrator.generateProposeFromData( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 4a0cb77d..765df532 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -20,6 +20,7 @@ import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.model.LlmConfig; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; @@ -64,7 +65,7 @@ class FromTextLlmPipelineTest { private static final String EN_US = GoldenInputs.EN_US; - private static final String INFORMATION_PROPOSE_URI = GoldenCase.INFORMATION_PROPOSE.templateUri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = GoldenCase.INFORMATION_PROPOSE.template(); private final ListAppender orchestratorAppender = new ListAppender<>(); @@ -108,7 +109,7 @@ void fromTextMatchesFromDataAndTheGoldenFixtureAfterOneExtractionCall() { NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); MetadataContent fromText = orchestrator.generateProposeFromText( - "请补充节能区域、节能速率保障目标与VLANId信息。", goldenCase.context(), goldenCase.templateUri()); + "请补充节能区域、节能速率保障目标与VLANId信息。", goldenCase.context(), goldenCase.template()); assertEquals(1, llm.calls, "from-text generation must run exactly one content extraction call"); assertEquals(goldenCase.templateUri(), fromText.templateUri()); @@ -202,7 +203,7 @@ void retriesContentExtractionUntilItSucceeds() { NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); MetadataContent result = - orchestrator.generateProposeFromText("请提供区域。", goldenCase.context(), goldenCase.templateUri()); + orchestrator.generateProposeFromText("请提供区域。", goldenCase.context(), goldenCase.template()); assertEquals(3, llm.calls, "two failures followed by one success must result in three calls"); assertEquals(readGoldenFixture(goldenCase, ZH_CN), result.promptText()); @@ -308,7 +309,7 @@ void phaseContentMismatchFailsFastWithInvalidInput() { NegotiationGenerationException failure = assertThrows( NegotiationGenerationException.class, () -> orchestrator.generateAcceptFromText( - "请接受。", GoldenCase.INFORMATION_ACCEPT.context(), GoldenCase.INFORMATION_ACCEPT.templateUri())); + "请接受。", GoldenCase.INFORMATION_ACCEPT.context(), GoldenCase.INFORMATION_ACCEPT.template())); assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, failure.getCode()); assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); @@ -374,10 +375,10 @@ void phaseMismatchedTemplateUriFailsBeforeAnyLlmCall() { () -> orchestrator.generateProposeFromText( "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), - GoldenCase.INFORMATION_ACCEPT.templateUri())); + GoldenCase.INFORMATION_ACCEPT.template())); assertTrue( - failure.getMessage().contains("Template URI is malformed or contradicts the expected phase PROPOSE"), + failure.getMessage().contains("Template URI does not address a negotiation template of the expected phase PROPOSE"), "the failure must point at the template URI but was: " + failure.getMessage()); assertEquals(0, llm.calls); } @@ -440,7 +441,7 @@ void clampedConfigValuesDriveTheRetryLoop() { NegotiationGenerationOrchestrator recoveringOrchestrator = orchestrator(ZH_CN, recovering, large.maxAttempts()); MetadataContent result = recoveringOrchestrator.generateProposeFromText( - "请提供区域。", goldenCase.context(), goldenCase.templateUri()); + "请提供区域。", goldenCase.context(), goldenCase.template()); assertEquals(10, recovering.calls, "nine failed attempts plus one success must consume the clamped limit"); assertEquals(readGoldenFixture(goldenCase, ZH_CN), result.promptText()); @@ -458,7 +459,7 @@ void clampedConfigValuesDriveTheRetryLoop() { NegotiationGenerationException failure = assertThrows( NegotiationGenerationException.class, () -> failingOrchestrator.generateProposeFromText( - "请提供区域。", goldenCase.context(), goldenCase.templateUri())); + "请提供区域。", goldenCase.context(), goldenCase.template())); assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); assertEquals(1, failing.calls); @@ -494,7 +495,7 @@ void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOEx orchestrator(config.prompt().language(), llm, config.llm().maxAttempts()); MetadataContent message = - orchestrator.generateProposeFromText(inputText, goldenCase.context(), goldenCase.templateUri()); + orchestrator.generateProposeFromText(inputText, goldenCase.context(), goldenCase.template()); assertEquals(1, llm.calls, "the en-US from-text chain needs exactly one extraction call"); assertEquals(readGoldenFixture(goldenCase, EN_US), message.promptText()); @@ -508,7 +509,7 @@ void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOEx FilledParamData filled = orchestrator.validateAndFillingProposeData( message.promptText(), Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - goldenCase.templateUri()); + goldenCase.template()); assertEquals(2, llm.calls, "the validation pipeline adds exactly one semantic validation call"); assertEquals(goldenCase.context().id(), filled.data().get("id")); @@ -553,9 +554,9 @@ private static NegotiationGenerationOrchestrator orchestrator( private static MetadataContent generateFromText( NegotiationGenerationOrchestrator orchestrator, GoldenCase goldenCase, String text) { return switch (goldenCase.phase()) { - case PROPOSE -> orchestrator.generateProposeFromText(text, goldenCase.context(), goldenCase.templateUri()); - case ACCEPT -> orchestrator.generateAcceptFromText(text, goldenCase.context(), goldenCase.templateUri()); - case REJECT -> orchestrator.generateRejectFromText(text, goldenCase.context(), goldenCase.templateUri()); + case PROPOSE -> orchestrator.generateProposeFromText(text, goldenCase.context(), goldenCase.template()); + case ACCEPT -> orchestrator.generateAcceptFromText(text, goldenCase.context(), goldenCase.template()); + case REJECT -> orchestrator.generateRejectFromText(text, goldenCase.context(), goldenCase.template()); }; } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java index 34365291..efc65009 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java @@ -14,6 +14,7 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMError; @@ -43,7 +44,7 @@ class NegotiationGenerationOrchestratorBuilderWiringTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; private Logger rootLogger; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index cb6f6b50..858e28af 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.llm.LLMClient; @@ -30,7 +31,9 @@ class NegotiationGenerationOrchestratorTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); @Test void generatesInformationProposeFromDataInChinese() { @@ -39,7 +42,7 @@ void generatesInformationProposeFromDataInChinese() { new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); + INFORMATION_PROPOSE); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertEquals(NegotiationHandler.NEGOTIATION_T_URI, result.extensionUri()); @@ -63,7 +66,7 @@ void generatesInformationProposeFromDataInEnglish() { new NegotiationProposeData( new NegotiationContext(UUID, 2, 5), new InfoProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE_URI); + INFORMATION_PROPOSE); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); @@ -82,7 +85,7 @@ void generatesProposeFromTextWithScriptedExtraction() { .build(); MetadataContent result = orchestrator.generateProposeFromText( - "请提供故障发生时间。", new NegotiationContext(UUID, 2, 5), INFORMATION_PROPOSE_URI); + "请提供故障发生时间。", new NegotiationContext(UUID, 2, 5), INFORMATION_PROPOSE); assertEquals(1, llm.calls); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); @@ -104,12 +107,12 @@ void validatesAndFillsProposeDataWithScriptedSemanticResponse() { new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); + INFORMATION_PROPOSE); FilledParamData filled = orchestrator.validateAndFillingProposeData( message.promptText(), Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - INFORMATION_PROPOSE_URI); + INFORMATION_PROPOSE); assertEquals(1, llm.calls); assertEquals(UUID, filled.data().get("id")); @@ -126,15 +129,16 @@ void listsAllSixTemplatesPerLanguage() { @Test void queriesSingleTemplateByUri() { - assertTrue( - zhOrchestrator().getNegotiationPrompt(INFORMATION_PROPOSE_URI).isPresent()); + assertTrue(zhOrchestrator().getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); assertTrue(zhOrchestrator() - .getNegotiationPrompt(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri()) - .isPresent()); - assertFalse(zhOrchestrator() - .getNegotiationPrompt("Negotiation-T/unknown-negotiation/propose/v1") + .getNegotiationPrompt(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) .isPresent()); - assertFalse(zhOrchestrator().getNegotiationPrompt("malformed-uri").isPresent()); + assertFalse( + zhOrchestrator() + .getNegotiationPrompt( + TemplateUri.of("Negotiation-T", "v1", "unknown-negotiation", "propose")) + .isPresent()); + assertFalse(zhOrchestrator().getNegotiationPrompt(StandardTemplates.ENERGY_SAVING).isPresent()); } @Test @@ -147,7 +151,7 @@ void retriesContentExtractionUntilItSucceeds() { .build(); MetadataContent result = orchestrator.generateProposeFromText( - "请提供区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); + "请提供区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE); assertEquals(3, llm.calls); assertFalse(result.promptText().isBlank()); @@ -165,7 +169,7 @@ void rethrowsOriginalCodeWhenRetriesAreExhausted() { NegotiationGenerationException failure = assertThrows( NegotiationGenerationException.class, () -> orchestrator.generateProposeFromText( - "请提供区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI)); + "请提供区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE)); assertEquals(2, llm.calls); assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); @@ -184,7 +188,7 @@ void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI)); + INFORMATION_PROPOSE)); assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, generationFailure.getCode()); NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() @@ -200,7 +204,7 @@ void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { () -> validationOrchestrator.validateAndFillingProposeData( "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); + INFORMATION_PROPOSE)); assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, extractionFailure.getCode()); } @@ -218,35 +222,37 @@ void rejectsNonNegotiationPromptInParameterExtraction() { () -> orchestrator.validateAndFillingProposeData( "plain text without any negotiation section", Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); + INFORMATION_PROPOSE)); assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, failure.getCode()); assertEquals(0, llm.calls); } @Test - void rejectsMalformedTemplateUriAsAnIllegalArgument() { - IllegalArgumentException malformedUriFailure = + void rejectsTemplateUriThatDoesNotAddressTheExpectedPhase() { + IllegalArgumentException phaseMismatchFailure = assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() .generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), - "information-negotiation/propose")); - assertTrue(malformedUriFailure + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT)); + assertTrue(phaseMismatchFailure .getMessage() - .contains("Template URI is malformed or contradicts the expected phase PROPOSE (propose)")); + .contains("Template URI does not address a negotiation template of the expected phase PROPOSE" + + " (propose)")); - IllegalArgumentException phaseMismatchFailure = + IllegalArgumentException wrongExtensionFailure = assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() .generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri())); - assertTrue(phaseMismatchFailure + StandardTemplates.ENERGY_SAVING)); + assertTrue(wrongExtensionFailure .getMessage() - .contains("Template URI is malformed or contradicts the expected phase PROPOSE (propose)")); + .contains("Template URI does not address a negotiation template of the expected phase PROPOSE" + + " (propose)")); } private static NegotiationGenerationOrchestrator zhOrchestrator() { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java index 4aa3d190..10543cd2 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java @@ -12,6 +12,7 @@ import java.util.Optional; import java.util.stream.Stream; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -25,9 +26,9 @@ * Verifies the boundary behavior of the template query API of the negotiation content layer. * *

The list query returns exactly the six built-in templates of the configured language in the fixed type and phase - * order; the single-template query resolves a valid URI into its template record, and answers a missing or malformed - * URI with an empty result plus a {@code negotiation_template_not_found} warning that mentions the language - * configuration hint. Neither query ever throws. + * order; the single-template query resolves a valid URI into its template record, and answers a URI that addresses no + * negotiation template with an empty result plus a {@code negotiation_template_not_found} warning that mentions the + * language configuration hint. Neither query ever throws. */ class NegotiationTemplateQueryBoundaryTest { @@ -86,7 +87,7 @@ void singleQueryResolvesAValidUriIntoItsTemplate(String language) { NegotiationGenerationOrchestrator orchestrator = orchestrator(language); Optional template = - orchestrator.getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri()); + orchestrator.getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE); assertTrue(template.isPresent()); assertEquals( @@ -103,18 +104,18 @@ void singleQueryResolvesAValidUriIntoItsTemplate(String language) { void singleQueryAnswersAMissingUriWithEmptyAndAWarning() { NegotiationGenerationOrchestrator orchestrator = orchestrator("zh-CN"); - Optional template = - orchestrator.getNegotiationPrompt("Negotiation-T/unknown-negotiation/propose/v1"); + Optional template = orchestrator.getNegotiationPrompt( + TemplateUri.of("Negotiation-T", "v1", "unknown-negotiation", "propose")); assertTrue(template.isEmpty()); assertTrue(hasWarning("negotiation_template_not_found", "A2AT_LANGUAGE")); } @Test - void singleQueryAnswersAMalformedUriWithEmptyAndAWarning() { + void singleQueryAnswersANonNegotiationUriWithEmptyAndAWarning() { NegotiationGenerationOrchestrator orchestrator = orchestrator("zh-CN"); - Optional template = orchestrator.getNegotiationPrompt("malformed-uri"); + Optional template = orchestrator.getNegotiationPrompt(StandardTemplates.ENERGY_SAVING); assertTrue(template.isEmpty()); assertTrue(hasWarning("negotiation_template_not_found", "A2AT_LANGUAGE")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java index a8e2cb21..ab15c8c4 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java @@ -5,6 +5,7 @@ import java.util.List; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; @@ -22,7 +23,7 @@ class RelationshipLineRenderingTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; @Test void chineseRelationshipIsAppendedWithTheChineseLabel() { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index a79d6f85..868353b3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.llm.LLMClient; @@ -33,22 +34,24 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.params.provider.MethodSource; /** * Verifies the template-URI entry matrix of the from-data generation. * *

The six built-in URIs address their templates and render outputs identical to the golden fixtures; a well-formed - * URI that resolves to no template fails with the code {@code template_not_found} before any LLM call; a malformed URI - * (wrong segment count, prefix, version, suffix, separator or enumeration value, including the underscore misspelling - * of the type segment) fails as a programming error pointing at {@code templateUri}; and a template placed under a - * local resource root overrides the built-in template addressed by the same URI. + * URI that resolves to no template fails with the code {@code template_not_found} before any LLM call; a typed URI + * that does not address a negotiation template of the expected phase (wrong extension name, version, type segment, + * phase segment or separator, including the underscore misspelling of the type segment) fails as a programming error + * pointing at {@code templateUri}, while structural malformation is impossible by construction of + * {@link TemplateUri}; and a template placed under a local resource root overrides the built-in template addressed + * by the same URI. */ class TemplateUriEntryMatrixTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; private static final String BUILTIN_INFORMATION_PROPOSE_TEMPLATE = "templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md"; @@ -107,26 +110,14 @@ void wellFormedUriMissingInBothRootsFailsWithTemplateNotFound() { } /** - * Entry (c): every malformed URI — wrong segment count, prefix, version, type suffix, separator (underscore - * misspelling), unknown type and invalid phase segment — fails as a programming error with an - * {@link IllegalArgumentException} pointing at the template URI, without any LLM call. + * Entry (c): every typed URI that does not address a negotiation template of the expected phase — wrong extension + * name, version, type segment, phase segment and separator (underscore misspelling), plus unknown types and + * unknown phase segments — fails as a programming error with an {@link IllegalArgumentException} pointing at the + * template URI, without any LLM call. Structurally malformed URIs cannot exist as {@link TemplateUri} values. */ - @ParameterizedTest(name = "malformed URI [{0}] is rejected as a templateUri programming error") - @ValueSource( - strings = { - "foo", - "foo/bar", - "information-negotiation/propose", - "Negotiation-T/information-negotiation/propose/v1/extra", - "Task-T/information-negotiation/propose/v1", - "Negotiation-T/information-negotiation/propose/v2", - "Negotiation-T/information/propose/v1", - "Negotiation-T/information_negotiation/propose/v1", - "Negotiation-T/unknown-negotiation/propose/v1", - "Negotiation-T/information-negotiation/propose-x/v1", - "Negotiation-T/information-negotiation/accept/v1" - }) - void malformedUriIsRejectedAsATemplateUriProgrammingError(String templateUri) { + @ParameterizedTest(name = "non-addressing URI [{0}] is rejected as a templateUri programming error") + @MethodSource("nonAddressingTemplateUris") + void nonAddressingUriIsRejectedAsATemplateUriProgrammingError(TemplateUri templateUri) { CountingClient llm = new CountingClient(); NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() .language("zh-CN") @@ -138,11 +129,23 @@ void malformedUriIsRejectedAsATemplateUriProgrammingError(String templateUri) { () -> orchestrator.generateProposeFromData(informationProposeData(), templateUri)); assertTrue( - failure.getMessage().contains("Template URI is malformed or contradicts the expected phase PROPOSE"), + failure.getMessage().contains( + "Template URI does not address a negotiation template of the expected phase PROPOSE"), "the failure must point at the template URI but was: " + failure.getMessage()); assertEquals(0, llm.calls); } + static List nonAddressingTemplateUris() { + return List.of( + TemplateUri.of("Task-T", "v1", "information-negotiation", "propose"), + TemplateUri.of("Negotiation-T", "v2", "information-negotiation", "propose"), + TemplateUri.of("Negotiation-T", "v1", "information", "propose"), + TemplateUri.of("Negotiation-T", "v1", "information_negotiation", "propose"), + TemplateUri.of("Negotiation-T", "v1", "unknown-negotiation", "propose"), + TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose-x"), + TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "accept")); + } + /** * Entry (e): a template placed under a local resource root overrides the built-in template addressed by the same * URI, while the same URI without the local root keeps rendering the built-in golden fixture. diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java index 47456422..fc0df84c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; @@ -42,12 +43,12 @@ class ValidateAndFillingDataPipelineTest { private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - private static final String INFORMATION_ACCEPT_REJECT_URI = - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(); + private static final TemplateUri INFORMATION_ACCEPT_REJECT_URI = + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - private static final String TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; private static final Map REGION_SCHEMA = Map.of( "type", "object", "properties", Map.of("region", Map.of("type", "string")), "required", List.of("region")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingSignatureContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingSignatureContractTest.java index ed3e7e64..4a8f17ec 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingSignatureContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingSignatureContractTest.java @@ -8,6 +8,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; import org.junit.jupiter.api.Test; @@ -40,7 +41,7 @@ void everyValidateAndFillingMethodHasExactlyTheFullThreeArgumentSignature() { assertEquals(3, parameterTypes.length, methodName + " must take exactly three parameters"); assertEquals(String.class, parameterTypes[0], methodName + " first parameter is the prompt text"); assertEquals(Map.class, parameterTypes[1], methodName + " second parameter is the caller parameter schema"); - assertEquals(String.class, parameterTypes[2], methodName + " third parameter is the template URI"); + assertEquals(TemplateUri.class, parameterTypes[2], methodName + " third parameter is the template URI"); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java index 0aae02cd..6da22248 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java @@ -1,6 +1,8 @@ package net.openan.a2at.sdk.negotiation.golden; import java.util.List; +import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; @@ -232,13 +234,26 @@ public NegotiationPhase phase() { return phase; } + /** + * Returns the built-in template URI addressed by this fixture as a typed value. + * + * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} + */ + public TemplateUri template() { + return TemplateUri.of( + StandardTemplates.NEGOTIATION_EXTENSION_NAME, + TemplateUri.DEFAULT_TEMPLATE_VERSION, + typeSegment, + phase.uriSegment()); + } + /** * Returns the built-in template URI addressed by this fixture. * * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} */ public String templateUri() { - return "Negotiation-T/" + typeSegment + "/" + phase.uriSegment() + "/v1"; + return template().uri(); } /** @@ -285,11 +300,11 @@ public NegotiationContext context() { public MetadataContent generate(NegotiationGenerationOrchestrator orchestrator) { return switch (phase) { case PROPOSE -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context(), (NegotiationProposeContent) content()), templateUri()); + new NegotiationProposeData(context(), (NegotiationProposeContent) content()), template()); case ACCEPT -> orchestrator.generateAcceptFromData( - new NegotiationEndingData(context(), (NegotiationEndingContent) content()), templateUri()); + new NegotiationEndingData(context(), (NegotiationEndingContent) content()), template()); case REJECT -> orchestrator.generateRejectFromData( - new NegotiationEndingData(context(), (NegotiationEndingContent) content()), templateUri()); + new NegotiationEndingData(context(), (NegotiationEndingContent) content()), template()); }; } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java index 359b6c3f..b3442304 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.core.model.FilledParamData; @@ -33,7 +34,7 @@ class CustomRootPromptsIgnoredTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; private static final String FAKE_PROMPT_MARKER = "FAKE_PROMPT_MARKER_5b19"; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java index 7470571a..a0aa28f5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.stream.Stream; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; @@ -31,7 +32,7 @@ */ class NegotiationLanguageSwitchTest { - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; static Stream goldenCases() { return Stream.of(GoldenCase.values()); @@ -85,8 +86,8 @@ void bothLanguagesAnswerQueriesWithDifferentTemplatesForTheSameUri() { .getNegotiationPrompt(INFORMATION_PROPOSE_URI) .orElseThrow(); - assertEquals(INFORMATION_PROPOSE_URI, zhCnTemplate.uri()); - assertEquals(INFORMATION_PROPOSE_URI, enUsTemplate.uri()); + assertEquals(INFORMATION_PROPOSE_URI.uri(), zhCnTemplate.uri()); + assertEquals(INFORMATION_PROPOSE_URI.uri(), enUsTemplate.uri()); assertNotEquals(zhCnTemplate.content(), enUsTemplate.content()); assertEquals( orchestrator(GoldenInputs.ZH_CN).getNegotiationPrompts().stream() diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java index 87d9e278..d2176f52 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java @@ -11,6 +11,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; @@ -42,9 +43,9 @@ class NegotiationErrorCodeUsageMatrixTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - private static final String INFORMATION_ENDING_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(); + private static final TemplateUri INFORMATION_ENDING_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; private static final String VALID_CONTEXT_PROMPT = "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5"; @@ -177,7 +178,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .language("zh-CN") .llmClient(llm) .build() - .generateProposeFromData(proposeData(), "malformed-template-uri"), + .generateProposeFromData(proposeData(), StandardTemplates.ENERGY_SAVING), IllegalArgumentException.class, null), row( @@ -254,7 +255,7 @@ private static void verify( "row " + name + " must stay outside A2ATError because it carries no code"); assertNullCode(expectedCode, name); assertTrue( - argumentFailure.getMessage().contains("Template URI is malformed"), + argumentFailure.getMessage().contains("Template URI does not address"), "row " + name + " must name the offending argument in its message but was: " + argumentFailure.getMessage()); return; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java index b03e457b..f8f48828 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.llm.LLMClient; @@ -36,7 +37,7 @@ class NegotiationExceptionTreeRootCatchTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; @Test void exhaustedGenerationLlmFailureIsCatchableThroughTheCommonRoot() { @@ -136,7 +137,7 @@ void contentProgrammingErrorsAreNotCatchableThroughTheCommonRoot() { new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - "malformed-template-uri"); + StandardTemplates.ENERGY_SAVING); } catch (A2ATError caughtByRoot) { fail("content programming errors must stay outside the A2ATError tree but were caught: " + caughtByRoot.getMessage()); @@ -145,7 +146,7 @@ void contentProgrammingErrorsAreNotCatchableThroughTheCommonRoot() { A2ATError.class.isInstance(expected), "content programming errors must stay outside the A2ATError tree"); assertTrue( - expected.getMessage().contains("Template URI is malformed"), + expected.getMessage().contains("Template URI does not address"), "the failure must point at the template URI but was: " + expected.getMessage()); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index f0f5513f..73abe62c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -15,6 +15,7 @@ import java.util.Set; import java.util.stream.Collectors; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; @@ -49,7 +50,7 @@ class NegotiationLogEventContractTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; private static final Map> EXPECTED_EVENT_LEVELS = Map.ofEntries( Map.entry("negotiation_template_loaded", Set.of(Level.DEBUG)), @@ -207,13 +208,13 @@ void queryBoundariesEmitTemplateNotFoundWarningsWithActionableHints() { NegotiationGenerationOrchestrator builtinOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() .language("zh-CN") .build(); - builtinOrchestrator.getNegotiationPrompt("malformed-template-uri"); + builtinOrchestrator.getNegotiationPrompt(StandardTemplates.ENERGY_SAVING); List warnings = messagesOf("negotiation_template_not_found"); assertEquals(3, warnings.size()); assertTrue(warnings.get(0).contains("uri=all")); assertTrue(warnings.get(0).contains("language=zh-CN")); - assertTrue(warnings.get(1).contains("uri=" + INFORMATION_PROPOSE_URI)); + assertTrue(warnings.get(1).contains("uri=" + INFORMATION_PROPOSE_URI.uri())); assertTrue(warnings.get(1).contains("language=zh-CN")); assertTrue(warnings.get(2).contains("reason=invalid_template_uri")); for (String warning : warnings) { @@ -342,7 +343,7 @@ private void driveQueryBoundaries() { .build(); orchestrator.getNegotiationPrompts(); orchestrator.getNegotiationPrompt(INFORMATION_PROPOSE_URI); - assertTrue(orchestrator.getNegotiationPrompt("malformed-template-uri").isEmpty()); + assertTrue(orchestrator.getNegotiationPrompt(StandardTemplates.ENERGY_SAVING).isEmpty()); } private static String validExtractionPayload() { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java index 27404249..c247cc01 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java @@ -9,6 +9,7 @@ import java.util.Locale; import java.util.Map; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; @@ -34,7 +35,7 @@ class NegotiationPublicExceptionSurfaceTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; private static final List> PUBLIC_EXCEPTION_TYPES = List.of( A2ATError.class, @@ -78,7 +79,7 @@ void noFailureMessageOfTheRealPipelinesMentionsAStage() { INFORMATION_PROPOSE_URI)), failureMessageOf(() -> new NegotiationContext(" ", 1, 5)), failureMessageOf(() -> orchestrator.generateProposeFromText( - "text", new NegotiationContext(UUID, 1, 5), "malformed-template-uri"))); + "text", new NegotiationContext(UUID, 1, 5), StandardTemplates.ENERGY_SAVING))); for (String message : messages) { assertTrue(message != null && !message.isBlank(), "failure messages must not be blank"); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java index ef1fd6f0..8dede370 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java @@ -6,6 +6,7 @@ import java.util.Optional; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import org.junit.jupiter.api.Test; @@ -124,6 +125,105 @@ void tryParseReturnsEmptyForBlankAndNullUriButThrowsOnNullExpectedPhase() { assertEquals("Expected negotiation phase must not be null.", exception.getMessage()); } + @Test + void fromTemplateUriAcceptsAllSixTypedUris() { + for (NegotiationType type : NegotiationType.values()) { + NegotiationReference propose = requirePresent( + NegotiationReference.fromTemplateUri(proposeTemplate(type), NegotiationPhase.PROPOSE, "zh-CN")); + assertEquals(type, propose.type()); + assertEquals(NegotiationPhase.PROPOSE, propose.phase()); + assertEquals(proposeUri(type), propose.uri()); + assertEquals("zh-CN", propose.language()); + + NegotiationReference reject = requirePresent( + NegotiationReference.fromTemplateUri(acceptRejectTemplate(type), NegotiationPhase.REJECT, "en-US")); + assertEquals(type, reject.type()); + assertEquals(NegotiationPhase.REJECT, reject.phase()); + assertEquals(acceptRejectUri(type), reject.uri()); + } + } + + @Test + void fromTemplateUriRejectsNonNegotiationUris() { + assertTrue(NegotiationReference.fromTemplateUri( + StandardTemplates.ENERGY_SAVING, NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.fromTemplateUri( + TemplateUri.of("negotiation-t", "v1", "information-negotiation", "propose"), + NegotiationPhase.PROPOSE, + "zh-CN") + .isEmpty()); + } + + @Test + void fromTemplateUriRejectsWrongPathSegmentCount() { + assertTrue(NegotiationReference.fromTemplateUri( + TemplateUri.of("Negotiation-T", "v1", "information-negotiation"), + NegotiationPhase.PROPOSE, + "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.fromTemplateUri( + TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose", "extra"), + NegotiationPhase.PROPOSE, + "zh-CN") + .isEmpty()); + } + + @Test + void fromTemplateUriRejectsWrongVersionTypeAndPhaseSegments() { + assertTrue(NegotiationReference.fromTemplateUri( + TemplateUri.of("Negotiation-T", "v2", "information-negotiation", "propose"), + NegotiationPhase.PROPOSE, + "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.fromTemplateUri( + TemplateUri.of("Negotiation-T", "v1", "information", "propose"), + NegotiationPhase.PROPOSE, + "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.fromTemplateUri( + TemplateUri.of("Negotiation-T", "v1", "information_negotiation", "propose"), + NegotiationPhase.PROPOSE, + "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.fromTemplateUri( + TemplateUri.of("Negotiation-T", "v1", "unknown-negotiation", "propose"), + NegotiationPhase.PROPOSE, + "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.fromTemplateUri( + TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose-x"), + NegotiationPhase.PROPOSE, + "zh-CN") + .isEmpty()); + } + + @Test + void fromTemplateUriRejectsPhaseMismatchAndThrowsOnNullArguments() { + assertTrue(NegotiationReference.fromTemplateUri( + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, NegotiationPhase.ACCEPT, "zh-CN") + .isEmpty()); + + NullPointerException uriFailure = assertThrows( + NullPointerException.class, + () -> NegotiationReference.fromTemplateUri(null, NegotiationPhase.PROPOSE, "zh-CN")); + assertEquals("Template URI must not be null.", uriFailure.getMessage()); + + NullPointerException phaseFailure = assertThrows( + NullPointerException.class, + () -> NegotiationReference.fromTemplateUri( + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, null, "zh-CN")); + assertEquals("Expected negotiation phase must not be null.", phaseFailure.getMessage()); + } + + private static TemplateUri proposeTemplate(NegotiationType type) { + return TemplateUri.of("Negotiation-T", "v1", type.typeSegment(), "propose"); + } + + private static TemplateUri acceptRejectTemplate(NegotiationType type) { + return TemplateUri.of("Negotiation-T", "v1", type.typeSegment(), "accept-reject"); + } + private static String proposeUri(NegotiationType type) { return "Negotiation-T/" + type.typeSegment() + "/propose/v1"; } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java index ff5af233..404bad93 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; @@ -29,9 +30,9 @@ class BilateralInteropScenarioTest { private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - private static final String INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(); + private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; private static final String EXTRACTION_RESPONSE = "{\"items\":[{\"name\":\"节能区域信息\",\"value\":\"松山湖\"}],\"relationship\":null}"; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java index f9be1bd0..0a0a11a8 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; @@ -38,9 +39,9 @@ */ class FeasibilityNegotiationScenarioTest { - private static final String FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; - private static final String FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(); + private static final TemplateUri FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; @Test void feasibilityNegotiationRunsFromSemanticRejectionToATerminalAcceptance() throws IOException { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java index 5d99f603..72b189e6 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; @@ -31,9 +32,9 @@ class InformationNegotiationScenarioTest { private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - private static final String INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(); + private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; @Test void informationNegotiationReachesTheTerminalStateWithinOneRound() { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java index 72462d2b..160fd2f9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java @@ -7,6 +7,7 @@ import java.util.Map; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; @@ -29,7 +30,7 @@ class MaxRoundsExhaustionScenarioTest { private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(); + private static final TemplateUri TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; @Test void exhaustedRoundBudgetEndsWithARejectMessageThePeerStillAccepts() { @@ -77,7 +78,7 @@ void exhaustedRoundBudgetEndsWithARejectMessageThePeerStillAccepts() { assertEquals(1, peerLlm.callCount(), "the rule gate must fail before any semantic LLM call"); assertTrue( - agent.getNegotiationPrompt("Negotiation-T/target-negotiation/abort/v1") + agent.getNegotiationPrompt(TemplateUri.of("Negotiation-T", "v1", "target-negotiation", "abort")) .isEmpty(), "no template of an abort phase exists anywhere"); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java index 6d9adae8..b1e63130 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; @@ -29,9 +30,9 @@ class TargetNegotiationScenarioTest { private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; - private static final String TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(); + private static final TemplateUri TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; @Test void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java index 1ecad2bb..653adde1 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java @@ -14,6 +14,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.stream.Stream; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; @@ -21,6 +22,9 @@ import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.core.resources.PathSegments; +import net.openan.a2at.sdk.core.validation.TemplateUri; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,9 +34,9 @@ *

The catalog enumerates the {@code templates/} tree of the bundled prompt resources by walking its directories, * so every extension directory that appears under {@code prompt_resources/templates/} — Task-T, Notification-T, * Authorization-T, Negotiation-T and any extension added later — is picked up without a hardcoded extension list. - * A template file lives at {@code templates///[/]//template.md} + * A template file lives at {@code templates/////template.md} * and is addressed by the URI formed from the segments before the language, for example - * {@code Negotiation-T/v1/information-negotiation/propose} or {@code Task-T/v1/energy-saving}. + * {@code Negotiation-T/information-negotiation/propose/v1} or {@code Task-T/network-layer/energy-saving/v1}. * *

A local file under the configured local resource root overrides the built-in classpath template of the same * path, and the classpath is the fallback otherwise. Both query methods never throw: a template or a root that cannot @@ -66,7 +70,7 @@ public final class PromptTemplateCatalog { * local template overrides * @throws IllegalArgumentException if the language is not a simple path segment */ - public PromptTemplateCatalog(String language, String localRootDir) { + public PromptTemplateCatalog(@NonNull String language, @Nullable String localRootDir) { if (!PathSegments.isSimpleSegment(language)) { throw new IllegalArgumentException( "Prompt template catalog language must be a non-blank simple path segment but was " + language @@ -85,7 +89,7 @@ public PromptTemplateCatalog(String language, String localRootDir) { * * @return loadable templates of the configured language sorted by URI; empty when none can be loaded */ - public List loadAll() { + public @NonNull List loadAll() { Map contentsByPath = new LinkedHashMap<>(); try { contentsByPath.putAll(classpathTemplates()); @@ -118,16 +122,14 @@ public List loadAll() { /** * Loads one template of the configured language by its URI, regardless of the extension. * - * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} or - * {@code Task-T/v1/energy-saving} - * @return the addressed template, or an empty optional when the URI is malformed or no template exists for it in - * the configured language + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1} or + * {@code Task-T/network-layer/energy-saving/v1} + * @return the addressed template, or an empty optional when no template exists for it in the configured language + * @throws NullPointerException if the template URI is null */ - public Optional load(String templateUri) { - String uri = templateUri == null ? null : templateUri.strip(); - if (!isCatalogableUri(uri)) { - return Optional.empty(); - } + public Optional load(@NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + String uri = templateUri.uri(); String relativePath = String.join("/", TEMPLATE_DIRECTORY, uri, language, TEMPLATE_FILE_NAME); try { String content = readTemplate(relativePath); diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java index cb707c0f..7505c9a5 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java @@ -4,6 +4,9 @@ import java.util.Objects; import java.util.Optional; import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.validation.TemplateUri; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,7 +15,7 @@ * *

The service exposes the extension-agnostic template queries of the design document over a * {@link PromptTemplateCatalog}: {@link #getPrompts()} lists every loadable template of the configured language - * across all A2A-T extensions and {@link #getPrompt(String)} loads one template by its URI. Both queries never + * across all A2A-T extensions and {@link #getPrompt(TemplateUri)} loads one template by its URI. Both queries never * throw; a missing template is answered with an empty result and an actionable warning log. * * @since 2026-08 @@ -37,7 +40,8 @@ public final class TemplateQueryService { * @param templateCatalog directory-driven catalog over the template tree of every extension * @param language locale identifier the catalog was created for; only used in log messages */ - public TemplateQueryService(PromptTemplateCatalog templateCatalog, String language) { + public TemplateQueryService( + @NonNull PromptTemplateCatalog templateCatalog, @NonNull String language) { this.templateCatalog = Objects.requireNonNull(templateCatalog, "Prompt template catalog must not be null."); this.language = Objects.requireNonNull(language, "Language must not be null."); this.logger = DEFAULT_LOGGER; @@ -50,7 +54,7 @@ public TemplateQueryService(PromptTemplateCatalog templateCatalog, String langua * @param localRootDir local prompt resource root containing the {@code templates/} tree; null or blank disables * local template overrides */ - public TemplateQueryService(String language, String localRootDir) { + public TemplateQueryService(@NonNull String language, @Nullable String localRootDir) { this(new PromptTemplateCatalog(language, localRootDir), language); } @@ -64,28 +68,29 @@ public TemplateQueryService(String language, String localRootDir) { * @return loadable templates of the configured language across all extensions, sorted by URI; empty when none can * be loaded */ - public List getPrompts() { + public @NonNull List getPrompts() { return templateCatalog.loadAll(); } /** * Loads one template by its URI, regardless of the extension. * - *

This query never throws: a malformed URI or a template that exists nowhere for the configured language - * returns an empty result and logs an actionable warning. + *

This query never throws: a template that exists nowhere for the configured language returns an empty result + * and logs an actionable warning. * - * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} or - * {@code Task-T/v1/energy-saving} - * @return the addressed template, or an empty result when the URI is malformed or the template does not exist for - * the configured language + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1} or + * {@code Task-T/network-layer/energy-saving/v1} + * @return the addressed template, or an empty result when the template does not exist for the configured language + * @throws NullPointerException if the template URI is null */ - public Optional getPrompt(String templateUri) { + public Optional getPrompt(@NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); Optional template = templateCatalog.load(templateUri); if (template.isEmpty()) { logger.atWarn() .log( "prompt_template_not_found uri={} language={} hint={}", - templateUri, + templateUri.uri(), language, LANGUAGE_HINT); } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java index 1bf87c4f..47ec3c0c 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -8,6 +8,8 @@ import net.openan.a2at.sdk.core.validation.ValidationPipeline; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** * Default content validator that orchestrates the full validation pipeline with a no-op rule checker and an LLM-backed @@ -42,11 +44,11 @@ public final class DefaultContentValidator implements ContentValidator { * @param promptResourceAccess prompt resource access for loading validation prompts */ public DefaultContentValidator( - String extensionName, - String language, + @NonNull String extensionName, + @NonNull String language, int maxAttempts, - LLMClient llmClient, - PromptResourceAccess promptResourceAccess) { + @Nullable LLMClient llmClient, + @NonNull PromptResourceAccess promptResourceAccess) { this.extensionName = extensionName; this.language = language; this.maxAttempts = maxAttempts; @@ -54,36 +56,36 @@ public DefaultContentValidator( this.promptResourceAccess = promptResourceAccess; } + /** + * Validates one content prompt and extracts its filled parameters. + * + * @param prompt content prompt text + * @param schema caller-provided parameter JSON schema + * @param templateUri URI of the template the content is validated against, such as + * {@code Task-T/network-layer/energy-saving/v1} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank, the template URI addresses another extension than the + * one this validator is configured for, or the template URI version is unsupported + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException if the validation fails at any stage + */ @Override - public FilledParamData validate(String prompt, Map schema, String templateUri) { + public FilledParamData validate( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); - if (templateUri.isBlank()) { - throw new IllegalArgumentException("Template URI must not be blank."); - } - String[] parts = templateUri.split("/"); - if (parts.length < 3) { + if (!extensionName.equals(templateUri.extensionName())) { throw new IllegalArgumentException( - "Template URI format must be {extensionName}/{pathSegments}/{templateVersion}, got: " - + templateUri); + "Template URI extension '" + templateUri.extensionName() + + "' does not match expected extension '" + extensionName + "'."); } - String prefix = parts[0]; - String templateVersion = parts[parts.length - 1]; - - if (!extensionName.equals(prefix)) { + if (!TemplateUri.DEFAULT_TEMPLATE_VERSION.equals(templateUri.templateVersion())) { throw new IllegalArgumentException( - "Template URI prefix '" + prefix + "' does not match expected extension prefix '" + extensionName - + "'."); - } - - if (!TemplateUri.DEFAULT_TEMPLATE_VERSION.equals(templateVersion)) { - throw new IllegalArgumentException("Unsupported template URI version: " + templateVersion); + "Unsupported template URI version: " + templateUri.templateVersion()); } - TemplateUri reference = TemplateUri.of( - prefix, templateVersion, java.util.Arrays.copyOfRange(parts, 1, parts.length - 1)); - return pipeline().validate(prompt, schema, reference); + return pipeline().validate(prompt, schema, templateUri); } private ValidationPipeline pipeline() { diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index 93a4d5d2..d864dacc 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -20,6 +20,8 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,7 +50,9 @@ public final class DefaultSemanticValidator implements SemanticValidator schema, TemplateUri reference) { + public ValidationResult validate( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri reference) { if (llmClient == null) { throw new ContentValidationException( A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index 1618e25a..60e5e567 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -23,6 +23,7 @@ import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -101,7 +102,7 @@ void bothFacadesProduceIdenticalMetadataContentForTheSameInput() throws IOExcept assertNotNull(clientResult.promptText()); assertEquals(clientResult, serverResult, "case " + symmetryCase.label() + " [" + language + "]"); assertEquals(clientResult.buildMetadataContent(), serverResult.buildMetadataContent()); - assertEquals(symmetryCase.templateUri(), clientResult.templateUri()); + assertEquals(symmetryCase.templateUri().uri(), clientResult.templateUri()); } } } @@ -152,7 +153,7 @@ private static List symmetryCases() { new SymmetryCase( "information_propose", NegotiationPhase.PROPOSE, - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, new NegotiationProposeData( new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5), new InfoProposeContent( @@ -164,7 +165,7 @@ private static List symmetryCases() { new SymmetryCase( "target_propose", NegotiationPhase.PROPOSE, - StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.TARGET_NEGOTIATION_PROPOSE, new NegotiationProposeData( new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5), new TargetProposeContent( @@ -175,7 +176,7 @@ private static List symmetryCases() { new SymmetryCase( "feasibility_propose", NegotiationPhase.PROPOSE, - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, new NegotiationProposeData( new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5), new FeasibilityProposeContent( @@ -186,41 +187,41 @@ private static List symmetryCases() { endingCase( "information_accept", NegotiationPhase.ACCEPT, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, new InfoEndingContent( NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("area information", "Songshan Lake")))), endingCase( "target_accept", NegotiationPhase.ACCEPT, - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, new TargetEndingContent(NegotiationConclusion.ACCEPT, "The confirmed intent.", null)), endingCase( "feasibility_accept", NegotiationPhase.ACCEPT, - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "The target is achievable.")), endingCase( "information_reject", NegotiationPhase.REJECT, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, new InfoEndingContent( NegotiationConclusion.REJECT, List.of(new NegotiationItem("area information", "not available")))), endingCase( "target_reject", NegotiationPhase.REJECT, - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, new TargetEndingContent(NegotiationConclusion.REJECT, null, "The intent is unclear.")), endingCase( "feasibility_reject", NegotiationPhase.REJECT, - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, new FeasibilityEndingContent(NegotiationConclusion.REJECT, "The target is not achievable."))); } private static SymmetryCase endingCase( - String label, NegotiationPhase phase, String templateUri, NegotiationEndingContent content) { + String label, NegotiationPhase phase, TemplateUri templateUri, NegotiationEndingContent content) { return new SymmetryCase( label, phase, @@ -230,7 +231,7 @@ private static SymmetryCase endingCase( } /** One symmetry case: a fixed input addressed to one of the three from-data generation methods. */ - private record SymmetryCase(String label, NegotiationPhase phase, String templateUri, Object data) { + private record SymmetryCase(String label, NegotiationPhase phase, TemplateUri templateUri, Object data) { MetadataContent generate(ProposeGenerator propose, EndingGenerator accept, EndingGenerator reject) { return switch (phase) { @@ -244,13 +245,13 @@ MetadataContent generate(ProposeGenerator propose, EndingGenerator accept, Endin @FunctionalInterface private interface ProposeGenerator { - MetadataContent generate(NegotiationProposeData data, String templateUri); + MetadataContent generate(NegotiationProposeData data, TemplateUri templateUri); } @FunctionalInterface private interface EndingGenerator { - MetadataContent generate(NegotiationEndingData data, String templateUri); + MetadataContent generate(NegotiationEndingData data, TemplateUri templateUri); } public static final class RecordingClient implements LLMClient { diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 2ef99d27..41dc30fa 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -3,12 +3,14 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; @@ -20,6 +22,7 @@ import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; import net.openan.a2at.sdk.server.compliance.ServerPromptComplianceOrchestrator; import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.jspecify.annotations.NonNull; /** * High-level server facade for prompt compliance and negotiation APIs. The caller provides the `.env` file path @@ -69,7 +72,7 @@ public A2ATServer(Path envPath) { * @param processedPromptText processed task prompt text * @return compliance result containing only success state or failure details */ - public PromptComplianceResult checkTaskPrompt(String processedPromptText) { + public PromptComplianceResult checkTaskPrompt(@NonNull String processedPromptText) { return promptComplianceOrchestrator.checkTaskPrompt(processedPromptText); } @@ -81,7 +84,8 @@ public PromptComplianceResult checkTaskPrompt(String processedPromptText) { * @param facts structured facts attached to the payload * @return transport payload representing the initial negotiation turn */ - public Map startNegotiation(NegotiationType type, String contentText, Map facts) { + public Map startNegotiation( + @NonNull NegotiationType type, @NonNull String contentText, @NonNull Map facts) { return negotiationOrchestrator.startNegotiation(type, contentText, facts); } @@ -92,7 +96,7 @@ public Map startNegotiation(NegotiationType type, String content * @param context transport context payload associated with the message * @return normalized payload describing the receive result */ - public Map receiveNegotiation(String message, Map context) { + public Map receiveNegotiation(@NonNull String message, @NonNull Map context) { return negotiationOrchestrator.receiveNegotiation(message, context); } @@ -105,7 +109,7 @@ public Map receiveNegotiation(String message, Map continueNegotiation( - NegotiationContext context, NegotiationStatus status, String contentText) { + @NonNull NegotiationContext context, @NonNull NegotiationStatus status, @NonNull String contentText) { return negotiationOrchestrator.continueNegotiation(context, status, contentText); } @@ -116,17 +120,18 @@ public Map continueNegotiation( * generator of the negotiation type addressed by the template URI and rendered from that template. * * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/propose}; its phase + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase * segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI is malformed, or its phase or type contradicts the method - * or the content type + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation_slot_missing} when rendering the template fails */ - public MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, String templateUri) { + public MetadataContent generateNegotiationProposePromptFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return negotiationContentService.generateProposeFromData(data, templateUri); } @@ -137,17 +142,19 @@ public MetadataContent generateNegotiationProposePromptFromData(NegotiationPropo * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/accept-reject}; its phase - * segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its + * phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or - * the content, or the content conclusion is not {@code Accept} + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the + * content conclusion is not {@code Accept} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation_slot_missing} when rendering the template fails */ - public MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, String templateUri) { + public MetadataContent generateNegotiationAcceptPromptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return negotiationContentService.generateAcceptFromData(data, templateUri); } @@ -158,17 +165,19 @@ public MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEnding * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/v1/feasibility-negotiation/accept-reject}; its phase - * segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its + * phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI is malformed, its phase or type contradicts the method or - * the content, or the content conclusion is not {@code Reject} + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the + * content conclusion is not {@code Reject} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation_slot_missing} when rendering the template fails */ - public MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, String templateUri) { + public MetadataContent generateNegotiationRejectPromptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return negotiationContentService.generateRejectFromData(data, templateUri); } @@ -182,11 +191,11 @@ public MetadataContent generateNegotiationRejectPromptFromData(NegotiationEnding * * @param text free-text input describing the message content * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose}; its phase segment + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment * must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the @@ -195,7 +204,10 @@ public MetadataContent generateNegotiationRejectPromptFromData(NegotiationEnding * content contradicts the phase */ public MetadataContent generateNegotiationProposePromptFromText( - String text, net.openan.a2at.sdk.negotiation.content.NegotiationContext context, String templateUri) { + @NonNull String text, + net.openan.a2at.sdk.negotiation.content.NegotiationContext context, + @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return negotiationContentService.generateProposeFromText(text, context, templateUri); } @@ -208,11 +220,11 @@ public MetadataContent generateNegotiationProposePromptFromText( * * @param text free-text input describing the message content * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/v1/information-negotiation/accept-reject}; its phase - * segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its + * phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the @@ -221,7 +233,10 @@ public MetadataContent generateNegotiationProposePromptFromText( * conclusion is not {@code Accept} */ public MetadataContent generateNegotiationAcceptPromptFromText( - String text, net.openan.a2at.sdk.negotiation.content.NegotiationContext context, String templateUri) { + @NonNull String text, + net.openan.a2at.sdk.negotiation.content.NegotiationContext context, + @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return negotiationContentService.generateAcceptFromText(text, context, templateUri); } @@ -234,11 +249,11 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * * @param text free-text input describing the message content * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/v1/feasibility-negotiation/accept-reject}; its phase - * segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its + * phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI is malformed or its phase contradicts the method + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the @@ -247,7 +262,10 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * conclusion is not {@code Reject} */ public MetadataContent generateNegotiationRejectPromptFromText( - String text, net.openan.a2at.sdk.negotiation.content.NegotiationContext context, String templateUri) { + @NonNull String text, + net.openan.a2at.sdk.negotiation.content.NegotiationContext context, + @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return negotiationContentService.generateRejectFromText(text, context, templateUri); } @@ -268,15 +286,15 @@ public List getPrompts() { /** * Loads one template by its URI, regardless of the extension. * - *

This query never throws: a malformed URI or a template that exists nowhere for the configured language returns - * an empty result and logs an actionable warning. + *

This query never throws: a template that exists nowhere for the configured language returns an empty result + * and logs an actionable warning. * - * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} or - * {@code Task-T/v1/energy-saving} - * @return the addressed template, or an empty result when the URI is malformed or the template does not exist for - * the configured language + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} or + * {@code Task-T/network-layer/energy-saving/v1} + * @return the addressed template, or an empty result when the template does not exist for the configured language */ - public Optional getPrompt(String templateUri) { + public Optional getPrompt(@NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return templateQueryService.getPrompt(templateUri); } @@ -296,14 +314,14 @@ public List getNegotiationPrompts() { /** * Loads one negotiation template addressed by its URI. * - *

This query never throws: a malformed URI or a template that exists nowhere for the configured language returns - * an empty result and logs an actionable warning. + *

This query never throws: a template that exists nowhere for the configured language returns an empty result + * and logs an actionable warning. * - * @param templateUri template URI such as {@code Negotiation-T/v1/target-negotiation/propose} - * @return the addressed template, or an empty result when the URI is malformed or the template does not exist for - * the configured language + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} + * @return the addressed template, or an empty result when the template does not exist for the configured language */ - public Optional getNegotiationPrompt(String templateUri) { + public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return negotiationContentService.getNegotiationPrompt(templateUri); } @@ -319,9 +337,8 @@ public Optional getNegotiationPrompt(String templateUri) { * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI is malformed or its phase - * contradicts the method + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation_invalid_input} when the prompt is not a negotiation message, * {@code negotiation_rule_violation} when the negotiation context violates a rule, @@ -330,25 +347,25 @@ public Optional getNegotiationPrompt(String templateUri) { * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateAndFillingProposeData( - String prompt, Map schema, String templateUri) { + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return negotiationContentService.validateAndFillingProposeData(prompt, schema, templateUri); } /** * Validates an accept-phase negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, String)} with the expected phase - * fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * accept-phase semantic constraints. + *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must + * satisfy the accept-phase semantic constraints. * * @param prompt rendered negotiation message text to validate * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI is malformed or its phase - * contradicts the method + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation_invalid_input} when the prompt is not a negotiation message, * {@code negotiation_rule_violation} when the negotiation context violates a rule, @@ -356,25 +373,26 @@ public FilledParamData validateAndFillingProposeData( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) { + public FilledParamData validateAndFillingAcceptData( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return negotiationContentService.validateAndFillingAcceptData(prompt, schema, templateUri); } /** * Validates a reject-phase negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, String)} with the expected phase - * fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * reject-phase semantic constraints. + *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must + * satisfy the reject-phase semantic constraints. * * @param prompt rendered negotiation message text to validate * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI is malformed or its phase - * contradicts the method + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation_invalid_input} when the prompt is not a negotiation message, * {@code negotiation_rule_violation} when the negotiation context violates a rule, @@ -382,7 +400,9 @@ public FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) { + public FilledParamData validateAndFillingRejectData( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return negotiationContentService.validateAndFillingRejectData(prompt, schema, templateUri); } @@ -395,13 +415,15 @@ public FilledParamData validateAndFillingRejectData(String prompt, Map schema, String templateUri) { + public FilledParamData validateAndFillingTaskData( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return taskContentValidator.validate(prompt, schema, templateUri); } @@ -414,14 +436,15 @@ public FilledParamData validateAndFillingTaskData(String prompt, Map schema, String templateUri) { + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return notificationContentValidator.validate(prompt, schema, templateUri); } @@ -434,13 +457,19 @@ public FilledParamData validateAndFillingNotificationData( * {@code Authorization-T} * @return filled parameter data carrying the merged parameters * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank or the template URI is blank or malformed + * @throws IllegalArgumentException if the prompt is blank * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code * {@code validation_semantic_rejected} when the semantic validation rejects the content, * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing */ - public FilledParamData validateAndFillingAuthData(String prompt, Map schema, String templateUri) { + public FilledParamData validateAndFillingAuthData( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); return authContentValidator.validate(prompt, schema, templateUri); } + + private static void requireTemplateUri(TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "Template URI must not be null."); + } } diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/package-info.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/package-info.java index 7afd7ccd..2adabaf2 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/package-info.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/package-info.java @@ -1,2 +1,5 @@ /** Server-facing APIs for the a2a-t Java SDK. */ +@NullMarked package net.openan.a2at.sdk.server; + +import org.jspecify.annotations.NullMarked; diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index 7881f88d..02460a33 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -16,6 +16,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.server.A2ATServer; import org.junit.jupiter.api.Test; @@ -23,8 +24,9 @@ class A2ATServerNegotiationApiTest { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); @Test void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { @@ -34,7 +36,7 @@ void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOE new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); + INFORMATION_PROPOSE); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); @@ -54,7 +56,7 @@ void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOE new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE_URI); + INFORMATION_PROPOSE); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); @@ -74,13 +76,18 @@ void listsAllSixNegotiationTemplatesPerLanguage() throws IOException { void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - assertTrue(server.getNegotiationPrompt(INFORMATION_PROPOSE_URI).isPresent()); + assertTrue(server.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); assertTrue(server - .getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()) + .getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .isPresent()); + assertFalse(server + .getNegotiationPrompt(TemplateUri.of( + StandardTemplates.NEGOTIATION_EXTENSION_NAME, + TemplateUri.DEFAULT_TEMPLATE_VERSION, + "unknown-negotiation", + "propose")) .isPresent()); - assertFalse(server.getNegotiationPrompt("not-a-template-uri").isPresent()); - PromptTemplate template = - server.getNegotiationPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); + PromptTemplate template = server.getNegotiationPrompt(INFORMATION_PROPOSE).orElseThrow(); assertEquals(INFORMATION_PROPOSE_URI, template.uri()); assertFalse(template.content().isBlank()); } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index f46c7416..05b91d9d 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -17,6 +17,7 @@ import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; @@ -47,8 +48,9 @@ static void registerMockProvider() { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String INFORMATION_PROPOSE_URI = - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final TemplateUri INFORMATION_PROPOSE_TEMPLATE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE_TEMPLATE.uri(); private static final String TASK_T_MESSAGE = "## 任务类型(Task Type)\n" + "无线能效优化\n\n" @@ -75,7 +77,7 @@ void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOE () -> server.validateAndFillingProposeData( TASK_T_MESSAGE, Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - INFORMATION_PROPOSE_URI)); + INFORMATION_PROPOSE_TEMPLATE)); assertEquals("negotiation_invalid_input", negotiationFailure.getCode()); assertTrue(negotiationFailure.getMessage().contains("checkTaskPrompt")); @@ -85,7 +87,7 @@ void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOE new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); + INFORMATION_PROPOSE_TEMPLATE); assertEquals(INFORMATION_PROPOSE_URI, negotiationMessage.templateUri()); assertTrue(negotiationMessage.promptText().contains("- id: " + UUID)); } @@ -105,7 +107,7 @@ void oldNegotiationStateMachineKeepsWorkingNextToTheContentLayerApis() throws IO new NegotiationProposeData( new NegotiationContext(UUID, 2, 5), new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); + INFORMATION_PROPOSE_TEMPLATE); assertTrue(negotiationMessage.promptText().contains("- round: 2")); Map startedAgain = server.startNegotiation(NegotiationType.TARGET, "请澄清目标。", Map.of()); diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index bbed8cab..7024dd35 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -421,12 +421,14 @@ The content layer is stateless. It deliberately does not own a session state mac All thirteen methods exist on both `A2ATClient` and `A2ATServer` with identical signatures and semantics. +Every method that identifies a template takes the value type `net.openan.a2at.sdk.core.validation.TemplateUri` instead of a raw string. A `TemplateUri` is always well-formed — its constructor validates the extension name, path segments, and version — so malformed URIs cannot reach these APIs. Build one with `TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose")`, or better, use the constants in `StandardTemplates` from the same package (for example `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`, whose `uri()` is `Negotiation-T/information-negotiation/propose/v1`). When a URI string arrives from outside the code, `TemplateUri.parse(String)` returns an `Optional` and never throws. + **Generation from typed data — deterministic, never calls an LLM:** ```java -MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, String templateUri) -MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, String templateUri) -MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, String templateUri) +MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, TemplateUri templateUri) +MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, TemplateUri templateUri) +MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, TemplateUri templateUri) ``` The typed content is validated, dispatched to the generator of the negotiation type addressed by the template URI, and rendered from that template. The accept variant requires `content.conclusion() == ACCEPT`, the reject variant `REJECT`; a mismatched conclusion (including `ABORT`) is rejected with `IllegalArgumentException` as a programming error. @@ -434,9 +436,9 @@ The typed content is validated, dispatched to the generator of the negotiation t **Generation from free text — one LLM extraction step with configurable retry:** ```java -MetadataContent generateNegotiationProposePromptFromText(String text, NegotiationContext context, String templateUri) -MetadataContent generateNegotiationAcceptPromptFromText(String text, NegotiationContext context, String templateUri) -MetadataContent generateNegotiationRejectPromptFromText(String text, NegotiationContext context, String templateUri) +MetadataContent generateNegotiationProposePromptFromText(String text, NegotiationContext context, TemplateUri templateUri) +MetadataContent generateNegotiationAcceptPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) +MetadataContent generateNegotiationRejectPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) ``` The template is loaded before the LLM call; a missing template fails fast without consuming an LLM request. The LLM step extracts the typed content from the free text constrained by the template URI, then rendering proceeds deterministically like the from-data variant. The `NegotiationContext` is injected into the rendered message without any LLM involvement. The extraction step is retried up to the configured attempt limit on the retryable failure codes (see 1.10.5). @@ -444,9 +446,9 @@ The template is loaded before the LLM call; a missing template fails fast withou **Compliance checking and parameter extraction:** ```java -FilledParamData validateAndFillingProposeData(String prompt, Map schema, String templateUri) -FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) -FilledParamData validateAndFillingRejectData(String prompt, Map schema, String templateUri) +FilledParamData validateAndFillingProposeData(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateAndFillingAcceptData(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateAndFillingRejectData(String prompt, Map schema, TemplateUri templateUri) ``` The pipeline runs in a fixed order: @@ -460,13 +462,13 @@ The pipeline runs in a fixed order: ```java List getPrompts() // templates of ALL extensions for the configured language, sorted by URI -Optional getPrompt(String templateUri) // one template by URI across all extensions; empty on miss +Optional getPrompt(TemplateUri templateUri) // one template by URI across all extensions; empty on miss List getNegotiationPrompts() // negotiation templates of the configured language, fixed order -Optional getNegotiationPrompt(String templateUri) // one negotiation template by URI; empty on miss +Optional getNegotiationPrompt(TemplateUri templateUri) // one negotiation template by URI; empty on miss ``` -A missing template or an unusable URI yields an empty result with a warning log. `PromptTemplate` is a record `(uri, description, content)`. The generic pair discovers the extension directories from the bundled resource tree itself — a template directory added under `prompt_resources/templates/` (for example a future `Authorization-T/`) is listed automatically without code changes. Local templates under the configured local resource root override built-in templates of the same path. Note that the SDK does not yet bundle Authorization-T template resources; `generateAuthPromptFromText` / `generateAuthPromptFromDataWithSchema` answer `template_not_found` under the classpath source until such templates are added or provided through the local resource root. +A missing template yields an empty result with a warning log; because the argument is a validated `TemplateUri`, an unusable URI cannot occur, and a null argument raises `NullPointerException`. `PromptTemplate` is a record `(uri, description, content)`. The generic pair discovers the extension directories from the bundled resource tree itself — a template directory added under `prompt_resources/templates/` (for example a future `Authorization-T/`) is listed automatically without code changes. Local templates under the configured local resource root override built-in templates of the same path. Note that the SDK does not yet bundle Authorization-T template resources; `generateAuthPromptFromText` / `generateAuthPromptFromDataWithSchema` answer `template_not_found` under the classpath source until such templates are added or provided through the local resource root. ### 1.10.3 Template URIs and Custom Templates @@ -476,7 +478,7 @@ The template URI format is: Negotiation-T/{type}-negotiation/{phase}/v1 ``` -where `{type}` is `information`, `target`, or `feasibility`, `{phase}` is `propose` or `accept-reject`, and the trailing `v1` is the template version (the default version). URI parsing never throws: `NegotiationReference.tryParse` returns an `Optional`, and a null, blank or malformed URI (wrong segment count, prefix, version, type or phase segment) simply yields an empty result. Six built-in templates ship with the SDK, one per combination, each in `zh-CN` and `en-US`: +where `{type}` is `information`, `target`, or `feasibility`, `{phase}` is `propose` or `accept-reject`, and the trailing `v1` is the template version (the default version). The facade APIs take this as the typed `TemplateUri` value; one built-in constant exists per template in `StandardTemplates`. String-level parsing never throws: `TemplateUri.parse(String)` returns an `Optional`, and `NegotiationReference.tryParse` returns an `Optional` — a null, blank or malformed URI (wrong segment count, prefix, version, type or phase segment) simply yields an empty result. Six built-in templates ship with the SDK, one per combination, each in `zh-CN` and `en-US`: | Template URI | Purpose | |--|--| @@ -531,7 +533,7 @@ All SDK processing failures share one root, while programming errors stay outsid - `PromptComplianceCheckException` — a compliance-check failure carrying the code and the compliance stage (`getStage()`); raised by the server-side compliance pipeline with the codes `processed_prompt_parse_error`, `slot_validation_error` and `template_not_found`. - The remaining processing failures — prompt generation (`PromptGenerationException`), content validation (`ContentValidationException`), template rendering (`TaskPromptRenderException`), resource resolution (`ResourceNotFoundException`, `ConfigFileNotFoundException`), scenario recognition (`ScenarioRecognitionException`) and negotiation state access (`NegotiationStateException`) — are part of the same tree. -Programming errors — argument-contract violations by the caller — intentionally stay **outside** the `A2ATError` tree, as standard JDK exceptions, so a generic `catch (A2ATError)` handler cannot swallow them: a null argument raises `NullPointerException`, and a blank, malformed or semantically contradictory argument (blank context id, malformed template URI, phase or conclusion mismatch, unsupported language) raises `IllegalArgumentException`. The former `NegotiationContentException` has been removed; the violations it used to report now surface as these standard exceptions, and each facade method documents them with `@throws` tags. +Programming errors — argument-contract violations by the caller — intentionally stay **outside** the `A2ATError` tree, as standard JDK exceptions, so a generic `catch (A2ATError)` handler cannot swallow them: a null argument raises `NullPointerException`, and a blank or semantically contradictory argument (blank context id, invalid segments passed to the `TemplateUri` constructor, phase or conclusion mismatch, unsupported language) raises `IllegalArgumentException`. The former `NegotiationContentException` has been removed; the violations it used to report now surface as these standard exceptions, and each facade method documents them with `@throws` tags. Error codes and their meaning: @@ -566,6 +568,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.*; import net.openan.a2at.sdk.server.A2ATServer; @@ -581,7 +584,7 @@ InfoProposeContent content = new InfoProposeContent( MetadataContent propose = client.generateNegotiationProposePromptFromData( new NegotiationProposeData(context, content), - "Negotiation-T/information-negotiation/propose/v1"); + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); // This two-key map travels in the A2A message metadata. Map metadata = propose.buildMetadataContent(); @@ -600,7 +603,7 @@ Map schema = Map.of( try { FilledParamData params = server.validateAndFillingProposeData( - propose.promptText(), schema, "Negotiation-T/information-negotiation/propose/v1"); + propose.promptText(), schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); // params.data() holds the extracted parameters plus the context parameters. } catch (A2ATParamExtractionError failure) { // Branch on failure.getCode(): negotiation_invalid_input, negotiation_rule_violation, @@ -614,11 +617,11 @@ InfoEndingContent ending = new InfoEndingContent( MetadataContent accept = server.generateNegotiationAcceptPromptFromData( new NegotiationEndingData(context, ending), - "Negotiation-T/information-negotiation/accept-reject/v1"); + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); -// The client validates the accept message the same way, with the accept-reject URI: +// The client validates the accept message the same way, with the accept-reject template: // client.validateAndFillingAcceptData(accept.promptText(), schema, -// "Negotiation-T/information-negotiation/accept-reject/v1"); +// StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); ``` When modifying the negotiation content layer, run: diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 85370150..c52241a2 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -404,12 +404,14 @@ mvn -pl a2a-t-sample -am -DskipTests package 以下十三个方法在 `A2ATClient` 和 `A2ATServer` 上具有完全相同的签名和语义。 +所有用于标识模板的参数均采用值类型 `net.openan.a2at.sdk.core.validation.TemplateUri`,而非裸字符串。`TemplateUri` 恒为格式正确 — 其构造器会校验扩展名、路径段与版本 — 因此格式错误的 URI 不可能进入这些 API。可用 `TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose")` 构造,更推荐直接使用同包 `StandardTemplates` 中的常量(例如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`,其 `uri()` 为 `Negotiation-T/information-negotiation/propose/v1`)。当 URI 字符串来自代码之外时,`TemplateUri.parse(String)` 返回 `Optional` 且绝不抛出异常。 + **从类型化数据生成 — 确定性执行,绝不调用 LLM:** ```java -MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, String templateUri) -MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, String templateUri) -MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, String templateUri) +MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, TemplateUri templateUri) +MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, TemplateUri templateUri) +MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, TemplateUri templateUri) ``` 类型化内容先经过校验,再分发给模板 URI 所指协商类型的生成器,并由该模板渲染。accept 变体要求 `content.conclusion() == ACCEPT`,reject 变体要求 `REJECT`;结论不匹配(包括 `ABORT`)会以 `IllegalArgumentException`(编程错误)拒绝。 @@ -417,9 +419,9 @@ MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData da **从自由文本生成 — 一次 LLM 抽取,支持可配置重试:** ```java -MetadataContent generateNegotiationProposePromptFromText(String text, NegotiationContext context, String templateUri) -MetadataContent generateNegotiationAcceptPromptFromText(String text, NegotiationContext context, String templateUri) -MetadataContent generateNegotiationRejectPromptFromText(String text, NegotiationContext context, String templateUri) +MetadataContent generateNegotiationProposePromptFromText(String text, NegotiationContext context, TemplateUri templateUri) +MetadataContent generateNegotiationAcceptPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) +MetadataContent generateNegotiationRejectPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) ``` 模板在调用 LLM 之前先行加载;模板缺失会快速失败,不会消耗 LLM 请求。LLM 步骤在模板 URI 的约束下从自由文本中抽取类型化内容,随后的渲染与 from-data 变体一样是确定性的。`NegotiationContext` 不经任何 LLM 直接注入渲染结果。抽取步骤在可重试错误码(见 1.10.5)上最多重试到配置的尝试上限。 @@ -427,9 +429,9 @@ MetadataContent generateNegotiationRejectPromptFromText(String text, Negotiation **合规校验与参数提取:** ```java -FilledParamData validateAndFillingProposeData(String prompt, Map schema, String templateUri) -FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri) -FilledParamData validateAndFillingRejectData(String prompt, Map schema, String templateUri) +FilledParamData validateAndFillingProposeData(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateAndFillingAcceptData(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateAndFillingRejectData(String prompt, Map schema, TemplateUri templateUri) ``` 流水线按固定顺序执行: @@ -443,13 +445,13 @@ FilledParamData validateAndFillingRejectData(String prompt, Map ```java List getPrompts() // 全部扩展在当前配置语言下的模板,按 URI 排序 -Optional getPrompt(String templateUri) // 跨扩展按 URI 加载单个模板;未命中返回空 +Optional getPrompt(TemplateUri templateUri) // 跨扩展按 URI 加载单个模板;未命中返回空 List getNegotiationPrompts() // 当前配置语言下的全部协商模板,固定顺序 -Optional getNegotiationPrompt(String templateUri) // 按 URI 加载单个协商模板;未命中返回空 +Optional getNegotiationPrompt(TemplateUri templateUri) // 按 URI 加载单个协商模板;未命中返回空 ``` -模板缺失或 URI 不可用时返回空结果并记录 warning 日志。`PromptTemplate` 是一个记录类型 `(uri, description, content)`。泛化查询对直接从内置资源树中发现扩展目录 — 在 `prompt_resources/templates/` 下新增的模板目录(例如未来的 `Authorization-T/`)会被自动列出,无需代码改动。配置的本地资源根目录下的模板会覆盖同路径的内置模板。注意:SDK 尚未打包 Authorization-T 模板资源,`generateAuthPromptFromText` / `generateAuthPromptFromDataWithSchema` 在 classpath 源下会返回 `template_not_found`,直至补充该类模板或通过本地资源根目录提供。 +模板缺失时返回空结果并记录 warning 日志;由于入参是已通过校验的 `TemplateUri`,不存在 URI 不可用的情形,null 入参则抛出 `NullPointerException`。`PromptTemplate` 是一个记录类型 `(uri, description, content)`。泛化查询对直接从内置资源树中发现扩展目录 — 在 `prompt_resources/templates/` 下新增的模板目录(例如未来的 `Authorization-T/`)会被自动列出,无需代码改动。配置的本地资源根目录下的模板会覆盖同路径的内置模板。注意:SDK 尚未打包 Authorization-T 模板资源,`generateAuthPromptFromText` / `generateAuthPromptFromDataWithSchema` 在 classpath 源下会返回 `template_not_found`,直至补充该类模板或通过本地资源根目录提供。 ### 1.10.3 模板 URI 与自定义模板 @@ -459,7 +461,7 @@ Optional getNegotiationPrompt(String templateUri) // 按 URI Negotiation-T/{type}-negotiation/{phase}/v1 ``` -其中 `{type}` 为 `information`、`target` 或 `feasibility`,`{phase}` 为 `propose` 或 `accept-reject`,末尾的 `v1` 是模板版本(默认版本)。URI 解析绝不抛出异常:`NegotiationReference.tryParse` 返回 `Optional`,null、空白或格式错误的 URI(段数、前缀、版本、类型或 phase 段不匹配)只返回空结果。SDK 内置六个模板,每种组合一个,各自提供 `zh-CN` 与 `en-US` 两个语言版本: +其中 `{type}` 为 `information`、`target` 或 `feasibility`,`{phase}` 为 `propose` 或 `accept-reject`,末尾的 `v1` 是模板版本(默认版本)。门面 API 以类型化的 `TemplateUri` 接收该值;`StandardTemplates` 为每个内置模板提供一个常量。字符串层面的解析绝不抛出异常:`TemplateUri.parse(String)` 返回 `Optional`,`NegotiationReference.tryParse` 返回 `Optional` — null、空白或格式错误的 URI(段数、前缀、版本、类型或 phase 段不匹配)只返回空结果。SDK 内置六个模板,每种组合一个,各自提供 `zh-CN` 与 `en-US` 两个语言版本: | 模板 URI | 用途 | |--|--| @@ -514,7 +516,7 @@ Negotiation-T/{type}-negotiation/{phase}/v1 - `PromptComplianceCheckException` — 携带错误码与合规阶段(`getStage()`)的合规校验失败;由服务端合规流水线抛出,错误码为 `processed_prompt_parse_error`、`slot_validation_error` 与 `template_not_found`。 - 其余处理失败 — 提示词生成(`PromptGenerationException`)、内容校验(`ContentValidationException`)、模板渲染(`TaskPromptRenderException`)、资源解析(`ResourceNotFoundException`、`ConfigFileNotFoundException`)、场景识别(`ScenarioRecognitionException`)与协商状态访问(`NegotiationStateException`)— 同属这棵树。 -编程错误 — 调用方的入参契约违反 — 刻意留在 `A2ATError` 树外,以标准 JDK 异常抛出,使通用的 `catch (A2ATError)` 处理器无法吞掉它们:null 入参抛出 `NullPointerException`;空白、格式错误或语义矛盾的入参(上下文 id 为空、模板 URI 格式错误、phase 或结论不匹配、不支持的语言)抛出 `IllegalArgumentException`。原先的 `NegotiationContentException` 已移除;它过去报告的违约现在以这些标准异常形式抛出,各门面方法均以 `@throws` 标签逐一注明。 +编程错误 — 调用方的入参契约违反 — 刻意留在 `A2ATError` 树外,以标准 JDK 异常抛出,使通用的 `catch (A2ATError)` 处理器无法吞掉它们:null 入参抛出 `NullPointerException`;空白或语义矛盾的入参(上下文 id 为空、传入 `TemplateUri` 构造器的段无效、phase 或结论不匹配、不支持的语言)抛出 `IllegalArgumentException`。原先的 `NegotiationContentException` 已移除;它过去报告的违约现在以这些标准异常形式抛出,各门面方法均以 `@throws` 标签逐一注明。 错误码及其含义: @@ -549,6 +551,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; +import net.openan.a2at.sdk.core.validation.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.*; import net.openan.a2at.sdk.server.A2ATServer; @@ -564,7 +567,7 @@ InfoProposeContent content = new InfoProposeContent( MetadataContent propose = client.generateNegotiationProposePromptFromData( new NegotiationProposeData(context, content), - "Negotiation-T/information-negotiation/propose/v1"); + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); // 这个两键映射随 A2A 消息 metadata 传输。 Map metadata = propose.buildMetadataContent(); @@ -583,7 +586,7 @@ Map schema = Map.of( try { FilledParamData params = server.validateAndFillingProposeData( - propose.promptText(), schema, "Negotiation-T/information-negotiation/propose/v1"); + propose.promptText(), schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); // params.data() 持有提取的参数以及上下文参数。 } catch (A2ATParamExtractionError failure) { // 按 failure.getCode() 分支处理:negotiation_invalid_input、negotiation_rule_violation、 @@ -597,11 +600,11 @@ InfoEndingContent ending = new InfoEndingContent( MetadataContent accept = server.generateNegotiationAcceptPromptFromData( new NegotiationEndingData(context, ending), - "Negotiation-T/information-negotiation/accept-reject/v1"); + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); -// 客户端以相同方式、使用 accept-reject URI 校验该接受消息: +// 客户端以相同方式、使用 accept-reject 模板校验该接受消息: // client.validateAndFillingAcceptData(accept.promptText(), schema, -// "Negotiation-T/information-negotiation/accept-reject/v1"); +// StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); ``` 修改协商内容层时,运行: From 64ff286c0745464b2c353ea301a664c7bc807279 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 01:41:23 +0800 Subject: [PATCH 078/295] refactor(core,prompt,negotiation): move TemplateUri/StandardTemplates to core.model, NegotiationContentService to generation, flatten taskrendering.api Signed-off-by: codetalks-hw --- .../java/net/openan/a2at/sdk/client/A2ATClient.java | 4 ++-- .../ClientPromptGenerationOrchestratorBuilder.java | 2 +- .../prompt/assembly/DefaultA2ATClientBuilder.java | 4 ++-- .../ClientPromptGenerationOrchestrator.java | 2 +- .../DefaultClientPromptGenerationOrchestrator.java | 4 ++-- .../sdk/client/api/A2ATClientNegotiationApiTest.java | 4 ++-- .../api/A2ATClientNegotiationEnvConfigTest.java | 4 ++-- .../openan/a2at/sdk/client/api/A2ATClientTest.java | 4 ++-- ...ClientPromptGenerationOrchestratorBuilderTest.java | 2 +- ...DefaultClientPromptGenerationOrchestratorTest.java | 6 +++--- .../core/{validation => model}/StandardTemplates.java | 2 +- .../sdk/core/{validation => model}/TemplateUri.java | 2 +- .../a2at/sdk/core/validation/ContentValidator.java | 1 + .../a2at/sdk/core/validation/SemanticValidator.java | 1 + .../a2at/sdk/core/validation/ValidationPipeline.java | 1 + .../a2at/sdk/core/model/MetadataContentTest.java | 2 +- .../core/{validation => model}/TemplateUriTest.java | 2 +- .../NegotiationContentService.java | 11 +++++++---- .../generation/NegotiationGenerationOrchestrator.java | 2 +- .../generation/NegotiationPromptRenderer.java | 4 ++-- .../resources/DefaultNegotiationTemplateLoader.java | 2 +- .../negotiation/resources/NegotiationReference.java | 4 ++-- .../NegotiationConcurrentMixedLoadTest.java | 4 ++-- .../contract/NegotiationPackageStructureTest.java | 2 +- .../ConclusionLiteralAndExceptionSlotTest.java | 2 +- .../generation/ConditionalSectionRenderingTest.java | 4 ++-- .../FromDataProgrammingErrorMatrixTest.java | 4 ++-- .../generation/FromTextLlmPipelineTest.java | 2 +- ...iationGenerationOrchestratorBuilderWiringTest.java | 4 ++-- .../NegotiationGenerationOrchestratorTest.java | 4 ++-- .../generation/NegotiationGeneratorsTest.java | 2 +- .../NegotiationPromptResourceLoaderTest.java | 2 +- .../NegotiationTemplateQueryBoundaryTest.java | 4 ++-- .../generation/RelationshipLineRenderingTest.java | 4 ++-- .../generation/TemplateUriEntryMatrixTest.java | 4 ++-- .../ValidateAndFillingDataPipelineTest.java | 4 ++-- .../ValidateAndFillingSignatureContractTest.java | 2 +- .../a2at/sdk/negotiation/golden/GoldenInputs.java | 4 ++-- .../language/CustomRootPromptsIgnoredTest.java | 4 ++-- .../language/NegotiationLanguageSwitchTest.java | 4 ++-- .../NegotiationErrorCodeUsageMatrixTest.java | 4 ++-- .../NegotiationExceptionTreeRootCatchTest.java | 4 ++-- .../NegotiationLogEventContractTest.java | 4 ++-- .../NegotiationPublicExceptionSurfaceTest.java | 4 ++-- .../DefaultNegotiationTemplateLoaderTest.java | 2 +- .../resources/NegotiationReferenceTest.java | 4 ++-- .../StandardTemplatesNegotiationConsistencyTest.java | 4 ++-- .../scenario/BilateralInteropScenarioTest.java | 4 ++-- .../scenario/FeasibilityNegotiationScenarioTest.java | 4 ++-- .../scenario/InformationNegotiationScenarioTest.java | 4 ++-- .../scenario/MaxRoundsExhaustionScenarioTest.java | 4 ++-- .../scenario/TargetNegotiationScenarioTest.java | 4 ++-- .../DefaultNegotiationSemanticValidatorTest.java | 2 +- .../resources/catalog/PromptTemplateCatalog.java | 2 +- .../resources/catalog/TemplateQueryService.java | 2 +- .../loader/ClasspathPromptSlotSchemaLoader.java | 4 ++-- .../loader/ClasspathPromptTemplateLoader.java | 4 ++-- .../{api => }/DropBlankSlotSectionRenderer.java | 2 +- .../{api => }/SectionedTemplateRenderer.java | 2 +- .../taskrendering/{api => }/TaskPromptRenderer.java | 2 +- .../prompt/validation/DefaultContentValidator.java | 2 +- .../prompt/validation/DefaultSemanticValidator.java | 2 +- .../{api => }/TaskPromptRendererTest.java | 2 +- .../openan/a2at/sdk/resources/PromptResourceKey.java | 2 +- .../resources/ClasspathPromptResourceLoaderTest.java | 2 +- .../a2at/sdk/resources/StandardTemplatesTest.java | 4 ++-- .../api/NegotiationFacadeOutputSymmetryTest.java | 4 ++-- .../java/net/openan/a2at/sdk/server/A2ATServer.java | 4 ++-- .../sdk/server/assembly/DefaultA2ATServerBuilder.java | 4 ++-- .../sdk/server/api/A2ATServerNegotiationApiTest.java | 4 ++-- .../api/TaskPromptNegotiationCoexistenceTest.java | 4 ++-- docs/en/developer_guide.md | 4 ++-- ...45\274\200\345\217\221\346\214\207\345\215\227.md" | 4 ++-- 73 files changed, 122 insertions(+), 116 deletions(-) rename a2a-t-core/src/main/java/net/openan/a2at/sdk/core/{validation => model}/StandardTemplates.java (99%) rename a2a-t-core/src/main/java/net/openan/a2at/sdk/core/{validation => model}/TemplateUri.java (99%) rename a2a-t-core/src/test/java/net/openan/a2at/sdk/core/{validation => model}/TemplateUriTest.java (98%) rename a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/{content => generation}/NegotiationContentService.java (97%) rename a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/{api => }/DropBlankSlotSectionRenderer.java (98%) rename a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/{api => }/SectionedTemplateRenderer.java (96%) rename a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/{api => }/TaskPromptRenderer.java (99%) rename a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/{api => }/TaskPromptRendererTest.java (98%) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 113ad6ff..4e4ffd29 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -12,8 +12,8 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.validation.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java index 1e3c9fdc..3ca331a9 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java @@ -14,7 +14,7 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; +import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; /** diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index 976c048a..afdfa4b9 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -19,7 +19,7 @@ import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; import net.openan.a2at.sdk.llm.LLMConfigLoader; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; @@ -30,7 +30,7 @@ import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; +import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; /** * Default builder that assembles one high-level A2AT client runtime from unified config. diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java index 3429fb51..3e1300ad 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java @@ -3,7 +3,7 @@ import java.util.Map; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.TemplateUri; /** * Internal prompt-generation orchestration contract used by the client facade. diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java index df360558..a742f2a5 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -17,12 +17,12 @@ import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; +import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; import net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException; /** diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index 7d2c38e7..27abe5b8 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -20,8 +20,8 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index a23d4cc7..24275755 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -29,8 +29,8 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index c5f84d81..dd619e84 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -17,8 +17,8 @@ import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java index e734db51..abcd0ca7 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java @@ -15,7 +15,7 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; +import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; import org.junit.jupiter.api.Test; class ClientPromptGenerationOrchestratorBuilderTest { diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 070444bb..fda1fc91 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -19,14 +19,14 @@ import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMRuntimeError; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; +import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; import org.junit.jupiter.api.Test; class DefaultClientPromptGenerationOrchestratorTest { diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java similarity index 99% rename from a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java rename to a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java index 409866b4..cd7a151d 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/StandardTemplates.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.core.validation; +package net.openan.a2at.sdk.core.model; import java.util.List; diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/TemplateUri.java similarity index 99% rename from a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java rename to a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/TemplateUri.java index f5b23e0a..f7c740d1 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateUri.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/TemplateUri.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.core.validation; +package net.openan.a2at.sdk.core.model; import java.util.List; import java.util.Objects; diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java index d0ca92ce..9c8085e2 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java @@ -3,6 +3,7 @@ import java.util.Map; import net.openan.a2at.sdk.core.model.FilledParamData; import org.jspecify.annotations.NonNull; +import net.openan.a2at.sdk.core.model.TemplateUri; /** * Entry point for validating content and extracting filled parameters. diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SemanticValidator.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SemanticValidator.java index c50d5bfe..80dac216 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SemanticValidator.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SemanticValidator.java @@ -2,6 +2,7 @@ import java.util.Map; import org.jspecify.annotations.NonNull; +import net.openan.a2at.sdk.core.model.TemplateUri; /** * LLM-backed semantic validator for content. diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java index df74fe4b..b47a0f9d 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java @@ -12,6 +12,7 @@ import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import net.openan.a2at.sdk.core.model.TemplateUri; /** * Orchestrates the content validation pipeline: input validation, rule-level gate, retryable semantic validation and diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java index 7bd3a240..a8f65951 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java @@ -6,7 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.model.StandardTemplates; import org.junit.jupiter.api.Test; class MetadataContentTest { diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/TemplateUriTest.java similarity index 98% rename from a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java rename to a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/TemplateUriTest.java index 9f884de1..c68e4796 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/TemplateUriTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/TemplateUriTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.core.validation; +package net.openan.a2at.sdk.core.model; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java similarity index 97% rename from a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java rename to a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index 8f9ba238..8da2b675 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.content; +package net.openan.a2at.sdk.negotiation.generation; import java.nio.file.Path; import java.util.List; @@ -10,10 +10,13 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index fedb50f1..ecc5fad6 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -12,7 +12,7 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java index 4807f9a8..dc526a55 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java @@ -1,8 +1,8 @@ package net.openan.a2at.sdk.negotiation.generation; import java.util.Map; -import net.openan.a2at.sdk.prompt.taskrendering.api.DropBlankSlotSectionRenderer; -import net.openan.a2at.sdk.prompt.taskrendering.api.SectionedTemplateRenderer; +import net.openan.a2at.sdk.prompt.taskrendering.DropBlankSlotSectionRenderer; +import net.openan.a2at.sdk.prompt.taskrendering.SectionedTemplateRenderer; /** * Renders a negotiation template by filling slot sections and dropping empty ones. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java index 94bcd59f..ec807a87 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -11,7 +11,7 @@ import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import net.openan.a2at.sdk.core.model.PromptTemplate; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java index baeb6708..952f3b15 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -5,8 +5,8 @@ import java.util.Objects; import java.util.Optional; import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import org.jspecify.annotations.NonNull; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java index cab4d332..d9a0f1c9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java @@ -11,8 +11,8 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.core.model.FilledParamData; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java index 29f7d8ae..a03f70e3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -30,7 +30,6 @@ void contentPackageExposesExactlyThePinnedTypes() throws IOException { "NegotiationAction.java", "NegotiationConclusion.java", "NegotiationContent.java", - "NegotiationContentService.java", "NegotiationContext.java", "NegotiationEndingContent.java", "NegotiationEndingData.java", @@ -73,6 +72,7 @@ void generationPackageExposesExactlyThePinnedTypes() throws IOException { "InformationEndingGenerator.java", "InformationProposeGenerator.java", "NegotiationContentExtractor.java", + "NegotiationContentService.java", "NegotiationContextRenderer.java", "NegotiationGenerationOrchestrator.java", "NegotiationGenerationOrchestratorBuilder.java", diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java index aaaf8e25..648e6dc3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java @@ -6,7 +6,7 @@ import java.util.List; import java.util.stream.Stream; -import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java index 2219eb63..7b99fded 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java @@ -5,8 +5,8 @@ import java.util.List; import java.util.stream.Stream; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java index 76225685..42bd518e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java @@ -8,8 +8,8 @@ import java.util.List; import java.util.Map; import java.util.function.Supplier; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 765df532..229582a6 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -20,7 +20,7 @@ import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.model.LlmConfig; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java index efc65009..b7025d95 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java @@ -13,8 +13,8 @@ import java.util.Locale; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMError; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 858e28af..ef2f7945 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -7,8 +7,8 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.llm.LLMClient; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java index 5f90f4cd..297eb54a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -6,7 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; -import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java index d1ee5458..ec38f657 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoaderTest.java @@ -7,7 +7,7 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import org.junit.jupiter.api.Test; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java index 10543cd2..301d3d82 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java @@ -11,8 +11,8 @@ import java.util.List; import java.util.Optional; import java.util.stream.Stream; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java index ab15c8c4..ef2cd14f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java @@ -4,8 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index 868353b3..8993fc78 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -14,8 +14,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.llm.LLMClient; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java index fc0df84c..acbffd01 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java @@ -8,8 +8,8 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingSignatureContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingSignatureContractTest.java index 4a8f17ec..c960664d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingSignatureContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingSignatureContractTest.java @@ -8,7 +8,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; import org.junit.jupiter.api.Test; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java index 6da22248..1a7ccb3a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java @@ -1,8 +1,8 @@ package net.openan.a2at.sdk.negotiation.golden; import java.util.List; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java index b3442304..e04dcfc2 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java @@ -9,8 +9,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.core.model.FilledParamData; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java index a0aa28f5..236e31f0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java @@ -9,8 +9,8 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.stream.Stream; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java index d2176f52..a1ef15be 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java @@ -10,8 +10,8 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java index f8f48828..253c9a66 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java @@ -8,8 +8,8 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.llm.LLMClient; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index 73abe62c..eef19817 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -14,8 +14,8 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java index c247cc01..cb7452af 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java @@ -8,8 +8,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java index 3a03f4ec..e9c2ad0b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java @@ -8,7 +8,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; -import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java index 8dede370..5f3a2c5a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java @@ -5,8 +5,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Optional; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import org.junit.jupiter.api.Test; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java index 13ce6548..8475592e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java @@ -3,8 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.List; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import org.junit.jupiter.api.Test; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java index 404bad93..c16a8061 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java @@ -5,8 +5,8 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java index 0a0a11a8..974c333c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java @@ -9,8 +9,8 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java index 72b189e6..db61c5b9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java @@ -6,8 +6,8 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java index 160fd2f9..69d6bd40 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java @@ -6,8 +6,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java index b1e63130..5c89ae1e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java @@ -5,8 +5,8 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java index 89021abf..4f81e1b3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java @@ -8,7 +8,7 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java index 653adde1..3ea0bc00 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java @@ -22,7 +22,7 @@ import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.TemplateUri; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java index 7505c9a5..e01a8d07 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java @@ -4,7 +4,7 @@ import java.util.Objects; import java.util.Optional; import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.TemplateUri; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java index 3c4e7a93..1ac8ad9f 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java @@ -7,8 +7,8 @@ import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.resources.ClasspathResourceDirectories; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java index 52626e09..bcb26420 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java @@ -6,8 +6,8 @@ import java.util.Set; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.resources.ClasspathResourceDirectories; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; import net.openan.a2at.sdk.resources.PromptResourceKey; diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/DropBlankSlotSectionRenderer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/DropBlankSlotSectionRenderer.java similarity index 98% rename from a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/DropBlankSlotSectionRenderer.java rename to a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/DropBlankSlotSectionRenderer.java index 00f9ed54..fd5a1966 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/DropBlankSlotSectionRenderer.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/DropBlankSlotSectionRenderer.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.prompt.taskrendering.api; +package net.openan.a2at.sdk.prompt.taskrendering; import java.util.ArrayList; import java.util.List; diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/SectionedTemplateRenderer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/SectionedTemplateRenderer.java similarity index 96% rename from a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/SectionedTemplateRenderer.java rename to a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/SectionedTemplateRenderer.java index a4fbfa3c..6f6e3a3b 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/SectionedTemplateRenderer.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/SectionedTemplateRenderer.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.prompt.taskrendering.api; +package net.openan.a2at.sdk.prompt.taskrendering; import java.util.Map; diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRenderer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java similarity index 99% rename from a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRenderer.java rename to a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java index d709b339..cd476639 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRenderer.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.prompt.taskrendering.api; +package net.openan.a2at.sdk.prompt.taskrendering; import java.util.Map; import java.util.Objects; diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java index 47ec3c0c..cc24131c 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -4,7 +4,7 @@ import java.util.Objects; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ValidationPipeline; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index d864dacc..73101231 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -15,7 +15,7 @@ import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.validation.SemanticValidator; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ValidationResult; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRendererTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRendererTest.java similarity index 98% rename from a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRendererTest.java rename to a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRendererTest.java index 7633f6b6..47839c26 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRendererTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRendererTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.prompt.taskrendering.api; +package net.openan.a2at.sdk.prompt.taskrendering; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; diff --git a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java index bce09c99..9b85ffc5 100644 --- a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java +++ b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java @@ -3,7 +3,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.TemplateUri; /** * Identifies one resource bundled with the SDK prompt resource tree. diff --git a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java index 3aa8e065..f7d93ac8 100644 --- a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java +++ b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java @@ -6,7 +6,7 @@ import java.util.List; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.TemplateUri; import org.junit.jupiter.api.Test; class ClasspathPromptResourceLoaderTest { diff --git a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/StandardTemplatesTest.java b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/StandardTemplatesTest.java index 11a24fb1..2bdc1a98 100644 --- a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/StandardTemplatesTest.java +++ b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/StandardTemplatesTest.java @@ -11,8 +11,8 @@ import java.util.Set; import java.util.TreeSet; import java.util.stream.Stream; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import org.junit.jupiter.api.Test; /** diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index 60e5e567..380674f8 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -22,8 +22,8 @@ import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 41dc30fa..7dc74b29 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -10,8 +10,8 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.core.validation.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index d3d8081c..d17a4821 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -4,12 +4,12 @@ import java.util.List; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; import net.openan.a2at.sdk.llm.LLMConfigLoader; -import net.openan.a2at.sdk.negotiation.content.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index 02460a33..302c8bd6 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -15,8 +15,8 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.server.A2ATServer; import org.junit.jupiter.api.Test; diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index 05b91d9d..f15cf37f 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -16,8 +16,8 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 7024dd35..656849f7 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -421,7 +421,7 @@ The content layer is stateless. It deliberately does not own a session state mac All thirteen methods exist on both `A2ATClient` and `A2ATServer` with identical signatures and semantics. -Every method that identifies a template takes the value type `net.openan.a2at.sdk.core.validation.TemplateUri` instead of a raw string. A `TemplateUri` is always well-formed — its constructor validates the extension name, path segments, and version — so malformed URIs cannot reach these APIs. Build one with `TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose")`, or better, use the constants in `StandardTemplates` from the same package (for example `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`, whose `uri()` is `Negotiation-T/information-negotiation/propose/v1`). When a URI string arrives from outside the code, `TemplateUri.parse(String)` returns an `Optional` and never throws. +Every method that identifies a template takes the value type `net.openan.a2at.sdk.core.model.TemplateUri` instead of a raw string. A `TemplateUri` is always well-formed — its constructor validates the extension name, path segments, and version — so malformed URIs cannot reach these APIs. Build one with `TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose")`, or better, use the constants in `StandardTemplates` from the same package (for example `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`, whose `uri()` is `Negotiation-T/information-negotiation/propose/v1`). When a URI string arrives from outside the code, `TemplateUri.parse(String)` returns an `Optional` and never throws. **Generation from typed data — deterministic, never calls an LLM:** @@ -568,7 +568,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; -import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.*; import net.openan.a2at.sdk.server.A2ATServer; diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index c52241a2..036c541b 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -404,7 +404,7 @@ mvn -pl a2a-t-sample -am -DskipTests package 以下十三个方法在 `A2ATClient` 和 `A2ATServer` 上具有完全相同的签名和语义。 -所有用于标识模板的参数均采用值类型 `net.openan.a2at.sdk.core.validation.TemplateUri`,而非裸字符串。`TemplateUri` 恒为格式正确 — 其构造器会校验扩展名、路径段与版本 — 因此格式错误的 URI 不可能进入这些 API。可用 `TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose")` 构造,更推荐直接使用同包 `StandardTemplates` 中的常量(例如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`,其 `uri()` 为 `Negotiation-T/information-negotiation/propose/v1`)。当 URI 字符串来自代码之外时,`TemplateUri.parse(String)` 返回 `Optional` 且绝不抛出异常。 +所有用于标识模板的参数均采用值类型 `net.openan.a2at.sdk.core.model.TemplateUri`,而非裸字符串。`TemplateUri` 恒为格式正确 — 其构造器会校验扩展名、路径段与版本 — 因此格式错误的 URI 不可能进入这些 API。可用 `TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose")` 构造,更推荐直接使用同包 `StandardTemplates` 中的常量(例如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`,其 `uri()` 为 `Negotiation-T/information-negotiation/propose/v1`)。当 URI 字符串来自代码之外时,`TemplateUri.parse(String)` 返回 `Optional` 且绝不抛出异常。 **从类型化数据生成 — 确定性执行,绝不调用 LLM:** @@ -551,7 +551,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; -import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.*; import net.openan.a2at.sdk.server.A2ATServer; From 5f7f6381e82aeab0cf72d5d1ffd4826a36e6b642 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 01:48:27 +0800 Subject: [PATCH 079/295] refactor(negotiation,prompt,core): tighten internal implementation classes to package-private visibility Signed-off-by: codetalks-hw --- .../a2at/sdk/core/validation/RetryUtil.java | 2 +- .../DefaultNegotiationContentExtractor.java | 2 +- .../FeasibilityEndingGenerator.java | 2 +- .../FeasibilityProposeGenerator.java | 2 +- .../InformationEndingGenerator.java | 2 +- .../InformationProposeGenerator.java | 2 +- .../NegotiationContextRenderer.java | 2 +- .../generation/NegotiationGenerator.java | 2 +- .../NegotiationGeneratorRegistry.java | 2 +- .../NegotiationJsonSchemaBuilder.java | 2 +- .../generation/NegotiationMessageBuilder.java | 2 +- .../generation/NegotiationPromptRenderer.java | 2 +- .../NegotiationPromptResourceLoader.java | 2 +- .../NegotiationRenderException.java | 2 +- .../generation/TargetEndingGenerator.java | 2 +- .../generation/TargetProposeGenerator.java | 2 +- .../NegotiationContextImmutabilityTest.java | 34 +----------- .../CustomRootPromptsIgnoredTest.java | 5 +- ...otiationPromptRendererConcurrencyTest.java | 54 +++++++++++++++++++ .../NegotiationResourceIntegrityTest.java | 3 +- .../catalog/PromptTemplateCatalog.java | 2 +- .../validation/DefaultSemanticValidator.java | 2 +- 22 files changed, 75 insertions(+), 57 deletions(-) rename a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/{language => generation}/CustomRootPromptsIgnoredTest.java (93%) create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java rename a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/{language => generation}/NegotiationResourceIntegrityTest.java (96%) diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java index a59d2693..81769c47 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java @@ -14,7 +14,7 @@ * * @since 2026-08 */ -public final class RetryUtil { +final class RetryUtil { private static final Logger LOGGER = LoggerFactory.getLogger(RetryUtil.class); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java index 4c8d1d1b..c2400c6d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java @@ -38,7 +38,7 @@ * * @since 2026-06 */ -public final class DefaultNegotiationContentExtractor implements NegotiationContentExtractor { +final class DefaultNegotiationContentExtractor implements NegotiationContentExtractor { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationContentExtractor.class); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java index 355b8df7..081d0a54 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java @@ -16,7 +16,7 @@ * * @since 2026-06 */ -public final class FeasibilityEndingGenerator extends AbstractNegotiationGenerator { +final class FeasibilityEndingGenerator extends AbstractNegotiationGenerator { /** * Generates a feasibility negotiation accept or reject message. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java index 0166a981..9a175989 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java @@ -19,7 +19,7 @@ * * @since 2026-06 */ -public final class FeasibilityProposeGenerator extends AbstractNegotiationGenerator { +final class FeasibilityProposeGenerator extends AbstractNegotiationGenerator { /** * Generates a feasibility negotiation propose message. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java index 844cd321..969bfef9 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java @@ -15,7 +15,7 @@ * * @since 2026-06 */ -public final class InformationEndingGenerator extends AbstractNegotiationGenerator { +final class InformationEndingGenerator extends AbstractNegotiationGenerator { /** * Generates an information negotiation accept or reject message. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java index 6df1efbe..e8752031 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java @@ -16,7 +16,7 @@ * * @since 2026-06 */ -public final class InformationProposeGenerator extends AbstractNegotiationGenerator { +final class InformationProposeGenerator extends AbstractNegotiationGenerator { /** * Generates an information negotiation propose message. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java index 2c080886..0bd5ff3e 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java @@ -8,7 +8,7 @@ * *

The context travels in one template slot as a markdown list whose field names are language-neutral. */ -public final class NegotiationContextRenderer { +final class NegotiationContextRenderer { /** * Renders one negotiation context as a markdown list. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java index 35daf1c0..3ed1cbf5 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java @@ -14,7 +14,7 @@ * * @since 2026-06 */ -public interface NegotiationGenerator { +interface NegotiationGenerator { /** * Generates the negotiation message text for one content object. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java index 2c421668..83662951 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java @@ -28,7 +28,7 @@ * * @since 2026-06 */ -public final class NegotiationGeneratorRegistry { +final class NegotiationGeneratorRegistry { private static final Logger LOGGER = LoggerFactory.getLogger(NegotiationGeneratorRegistry.class); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java index a3ff9d63..365a171e 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java @@ -17,7 +17,7 @@ * * @since 2026-06 */ -public class NegotiationJsonSchemaBuilder { +final class NegotiationJsonSchemaBuilder { private static final List CONCLUSION_ENUM = List.of("Accept", "Reject"); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java index 535099ff..b2df3e26 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java @@ -14,7 +14,7 @@ * * @since 2026-06 */ -public class NegotiationMessageBuilder { +final class NegotiationMessageBuilder { /** Token name receiving the negotiation phase of the step, such as {@code propose} or {@code accept}. */ public static final String TOKEN_PHASE = "phase"; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java index dc526a55..73a6614b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java @@ -21,7 +21,7 @@ * * @since 2026-06 */ -public class NegotiationPromptRenderer implements SectionedTemplateRenderer { +class NegotiationPromptRenderer implements SectionedTemplateRenderer { private final DropBlankSlotSectionRenderer delegate = new DropBlankSlotSectionRenderer(); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java index fd4dfed5..b57bc5d7 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java @@ -17,7 +17,7 @@ * * @since 2026-06 */ -public class NegotiationPromptResourceLoader { +class NegotiationPromptResourceLoader { private static final String CLASSPATH_ROOT = "prompt_resources/prompts/"; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java index 717a4649..14ddee0c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java @@ -8,7 +8,7 @@ * * @since 2026-06 */ -public class NegotiationRenderException extends RuntimeException { +class NegotiationRenderException extends RuntimeException { /** * Creates a render failure. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java index 808a427c..d10af152 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java @@ -17,7 +17,7 @@ * * @since 2026-06 */ -public final class TargetEndingGenerator extends AbstractNegotiationGenerator { +final class TargetEndingGenerator extends AbstractNegotiationGenerator { /** * Generates a target negotiation accept or reject message. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java index 67099db0..3ed944a6 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java @@ -17,7 +17,7 @@ * * @since 2026-06 */ -public final class TargetProposeGenerator extends AbstractNegotiationGenerator { +final class TargetProposeGenerator extends AbstractNegotiationGenerator { /** * Generates a target negotiation propose message. diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationContextImmutabilityTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationContextImmutabilityTest.java index 8983654f..de8ae7ab 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationContextImmutabilityTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationContextImmutabilityTest.java @@ -14,7 +14,6 @@ import java.util.concurrent.TimeUnit; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.generation.NegotiationPromptRenderer; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckResult; @@ -24,8 +23,7 @@ * Verifies the immutability and purity contracts of the negotiation content layer under concurrent access. * *

A shared {@link NegotiationContext} is read by many threads that each derive the next round: the original object - * must never change and every derived object must be identical. The rule checker and the prompt renderer are pure - * functions: repeated concurrent calls with the same input must return the same result. + * must never change and every derived object must be identical. The rule checker is a pure function: repeated concurrent calls with the same input must return the same result. */ class NegotiationContextImmutabilityTest { @@ -93,34 +91,4 @@ void ruleCheckerIsPureUnderConcurrentCalls() throws Exception { pool.shutdownNow(); } } - - @Test - void promptRendererIsPureUnderConcurrentCalls() throws Exception { - String template = new DefaultNegotiationTemplateLoader("zh-CN", null) - .loadAll() - .get(0) - .content(); - Map slots = - Map.of("协商上下文", "- id: " + UUID + "\n- round: 1\n- maxRounds: 5", "所需信息项", "1. 节能区域:松山湖"); - NegotiationPromptRenderer renderer = new NegotiationPromptRenderer(); - String baseline = renderer.render(template, slots); - - ExecutorService pool = Executors.newFixedThreadPool(THREADS); - try { - List> futures = new ArrayList<>(); - for (int index = 0; index < THREADS; index++) { - futures.add(pool.submit(() -> { - for (int iteration = 0; iteration < ITERATIONS; iteration++) { - assertEquals(baseline, renderer.render(template, slots), "renderer output must be stable"); - } - return null; - })); - } - for (Future future : futures) { - future.get(30, TimeUnit.SECONDS); - } - } finally { - pool.shutdownNow(); - } - } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java similarity index 93% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java rename to a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java index e04dcfc2..fe4b6c54 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootPromptsIgnoredTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.language; +package net.openan.a2at.sdk.negotiation.generation; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -16,9 +16,6 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.generation.NegotiationPromptResourceLoader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java new file mode 100644 index 00000000..44b04afc --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java @@ -0,0 +1,54 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; +import org.junit.jupiter.api.Test; + +/** + * Verifies that the package-private {@link NegotiationPromptRenderer} is a pure function: repeated concurrent calls + * with the same input must return the same result. + */ +class NegotiationPromptRendererConcurrencyTest { + + private static final int THREADS = 8; + + private static final int ITERATIONS = 200; + + @Test + void promptRendererIsPureUnderConcurrentCalls() throws Exception { + String template = new DefaultNegotiationTemplateLoader("zh-CN", null) + .loadAll() + .get(0) + .content(); + Map slots = + Map.of("协商上下文", "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 1\n- maxRounds: 5", "所需信息项", "1. 节能区域:松山湖"); + NegotiationPromptRenderer renderer = new NegotiationPromptRenderer(); + String baseline = renderer.render(template, slots); + + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + try { + List> futures = new ArrayList<>(); + for (int index = 0; index < THREADS; index++) { + futures.add(pool.submit(() -> { + for (int iteration = 0; iteration < ITERATIONS; iteration++) { + assertEquals(baseline, renderer.render(template, slots), "renderer output must be stable"); + } + return null; + })); + } + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } finally { + pool.shutdownNow(); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationResourceIntegrityTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java similarity index 96% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationResourceIntegrityTest.java rename to a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java index 127c0728..093b08b4 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationResourceIntegrityTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.language; +package net.openan.a2at.sdk.negotiation.generation; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -20,7 +20,6 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.generation.NegotiationPromptResourceLoader; import net.openan.a2at.sdk.negotiation.golden.GoldenInputs; import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java index 3ea0bc00..d4db1451 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java @@ -44,7 +44,7 @@ * * @since 2026-08 */ -public final class PromptTemplateCatalog { +final class PromptTemplateCatalog { private static final Logger LOGGER = LoggerFactory.getLogger(PromptTemplateCatalog.class); diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index 73101231..82891d99 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -31,7 +31,7 @@ * * @since 2026-08 */ -public final class DefaultSemanticValidator implements SemanticValidator { +final class DefaultSemanticValidator implements SemanticValidator { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultSemanticValidator.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); From 3424f5cc4d8af47e5009db075f03abca416c265f Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 01:59:47 +0800 Subject: [PATCH 080/295] refactor(model): spell out Information content types and default TemplateUri.of version Signed-off-by: codetalks-hw --- .../api/A2ATClientNegotiationApiTest.java | 8 +++--- .../A2ATClientNegotiationEnvConfigTest.java | 6 ++-- .../a2at/sdk/client/api/A2ATClientTest.java | 4 +-- ...lientPromptGenerationOrchestratorTest.java | 2 +- .../sdk/core/model/StandardTemplates.java | 22 +++++++-------- .../a2at/sdk/core/model/TemplateUri.java | 24 +++++++++++++--- .../a2at/sdk/core/model/TemplateUriTest.java | 23 +++++++-------- ...ent.java => InformationEndingContent.java} | 2 +- ...nt.java => InformationProposeContent.java} | 2 +- .../content/NegotiationEndingContent.java | 2 +- .../content/NegotiationProposeContent.java | 2 +- .../DefaultNegotiationContentExtractor.java | 8 +++--- .../InformationEndingGenerator.java | 4 +-- .../InformationProposeGenerator.java | 8 +++--- .../NegotiationGeneratorRegistry.java | 8 +++--- .../NegotiationConcurrentMixedLoadTest.java | 4 +-- .../NegotiationPackageStructureTest.java | 4 +-- ...ConclusionLiteralAndExceptionSlotTest.java | 6 ++-- .../FromDataProgrammingErrorMatrixTest.java | 20 ++++++------- .../generation/FromTextLlmPipelineTest.java | 8 +++--- .../NegotiationContentExtractorTest.java | 8 +++--- ...nerationOrchestratorBuilderWiringTest.java | 6 ++-- ...NegotiationGenerationOrchestratorTest.java | 16 +++++------ .../NegotiationGeneratorRegistryTest.java | 28 +++++++++---------- .../generation/NegotiationGeneratorsTest.java | 20 ++++++------- .../NegotiationTemplateQueryBoundaryTest.java | 2 +- .../RelationshipLineRenderingTest.java | 4 +-- .../TemplateUriEntryMatrixTest.java | 18 ++++++------ .../ValidateAndFillingDataPipelineTest.java | 8 +++--- .../sdk/negotiation/golden/GoldenInputs.java | 15 ++++------ .../NegotiationErrorCodeUsageMatrixTest.java | 4 +-- ...NegotiationExceptionTreeRootCatchTest.java | 4 +-- .../NegotiationLogEventContractTest.java | 8 +++--- .../resources/NegotiationReferenceTest.java | 21 +++++++------- .../BilateralInteropScenarioTest.java | 8 +++--- .../InformationNegotiationScenarioTest.java | 8 +++--- .../MaxRoundsExhaustionScenarioTest.java | 2 +- .../ClasspathPromptSlotSchemaLoader.java | 7 ++--- .../loader/ClasspathPromptTemplateLoader.java | 7 ++--- .../ClasspathPromptResourceLoaderTest.java | 4 +-- .../NegotiationFacadeOutputSymmetryTest.java | 10 +++---- .../api/A2ATServerNegotiationApiTest.java | 11 +++----- .../TaskPromptNegotiationCoexistenceTest.java | 6 ++-- docs/en/developer_guide.md | 8 +++--- ...00\345\217\221\346\214\207\345\215\227.md" | 8 +++--- 45 files changed, 207 insertions(+), 201 deletions(-) rename a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/{InfoEndingContent.java => InformationEndingContent.java} (75%) rename a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/{InfoProposeContent.java => InformationProposeContent.java} (78%) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index 27abe5b8..893ad733 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -14,7 +14,7 @@ import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; @@ -49,7 +49,7 @@ void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOE MetadataContent result = client.generateNegotiationProposePromptFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); @@ -69,7 +69,7 @@ void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOE MetadataContent result = client.generateNegotiationProposePromptFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), INFORMATION_PROPOSE); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); @@ -95,7 +95,7 @@ void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { .getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) .isPresent()); assertFalse(client - .getNegotiationPrompt(TemplateUri.of("Negotiation-T", "v9", "information-negotiation", "propose")) + .getNegotiationPrompt(TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v9")) .isPresent()); PromptTemplate template = client.getNegotiationPrompt(INFORMATION_PROPOSE).orElseThrow(); assertEquals(INFORMATION_PROPOSE_URI, template.uri()); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index 24275755..64f5bbcd 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -23,7 +23,7 @@ import net.openan.a2at.sdk.llm.LLMClientFactory; import net.openan.a2at.sdk.llm.LLMRuntimeError; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; @@ -104,7 +104,7 @@ void languageLocalRootAndMaxAttemptsAreAllObservableInFacadeBehavior() throws IO MetadataContent result = client.generateNegotiationProposePromptFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE); assertTrue(result.promptText().contains("协商上下文"), "the zh-CN language must select the Chinese templates"); @@ -136,7 +136,7 @@ void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { MetadataContent result = client.generateNegotiationProposePromptFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), INFORMATION_PROPOSE); assertTrue(result.promptText().contains("Negotiation Context"), "the default language must be en-US"); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index dd619e84..047ba150 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -258,7 +258,7 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO void fromTextEntryPointsReturnMetadataContent() throws IOException { Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); A2ATClient client = new A2ATClient(envFile); - TemplateUri unconstrained = TemplateUri.of("Task-T", "v1", "network-layer", "unconstrained"); + TemplateUri unconstrained = TemplateUri.of("Task-T", "network-layer", "unconstrained"); MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); @@ -314,7 +314,7 @@ void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); A2ATClient client = new A2ATClient(envFile); - TemplateUri unconstrained = TemplateUri.of("Task-T", "v1", "network-layer", "unconstrained"); + TemplateUri unconstrained = TemplateUri.of("Task-T", "network-layer", "unconstrained"); MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index fda1fc91..20d68bee 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -35,7 +35,7 @@ class DefaultClientPromptGenerationOrchestratorTest { (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()); private static final TemplateUri AUTH_DATABASE_READ = - TemplateUri.of("Authorization-T", "v1", "database_read"); + TemplateUri.of("Authorization-T", "database_read"); @Test void generateTaskPromptLoadsTemplateAndRendersExtractedSlotsWhenScenarioIsMatched() { diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java index cd7a151d..0a428299 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java @@ -41,47 +41,47 @@ private StandardTemplates() { /** Task-T template for the energy-saving scenario. */ public static final TemplateUri ENERGY_SAVING = - TemplateUri.of(TASK_EXTENSION_NAME, V1, NETWORK_LAYER_SEGMENT, "energy-saving"); + TemplateUri.of(TASK_EXTENSION_NAME, NETWORK_LAYER_SEGMENT, "energy-saving"); /** Task-T template for the private-line-complaint scenario. */ public static final TemplateUri PRIVATE_LINE_COMPLAINT = - TemplateUri.of(TASK_EXTENSION_NAME, V1, NETWORK_LAYER_SEGMENT, "private-line-complaint"); + TemplateUri.of(TASK_EXTENSION_NAME, NETWORK_LAYER_SEGMENT, "private-line-complaint"); /** Notification-T template for the subscribe-incident scenario. */ public static final TemplateUri SUBSCRIBE_INCIDENT = - TemplateUri.of(NOTIFICATION_EXTENSION_NAME, V1, NETWORK_LAYER_SEGMENT, "subscribe-incident"); + TemplateUri.of(NOTIFICATION_EXTENSION_NAME, NETWORK_LAYER_SEGMENT, "subscribe-incident"); /** Notification-T template for the service-recovery scenario. */ public static final TemplateUri SERVICE_RECOVERY = - TemplateUri.of(NOTIFICATION_EXTENSION_NAME, V1, NETWORK_LAYER_SEGMENT, "service-recovery"); + TemplateUri.of(NOTIFICATION_EXTENSION_NAME, NETWORK_LAYER_SEGMENT, "service-recovery"); /** Authorization-T template for the authorization-policy-management scenario. */ public static final TemplateUri AUTHORIZATION_POLICY_MANAGEMENT = - TemplateUri.of(AUTHORIZATION_EXTENSION_NAME, V1, "authorization-policy-management"); + TemplateUri.of(AUTHORIZATION_EXTENSION_NAME, "authorization-policy-management"); /** Negotiation-T propose template for information negotiation. */ public static final TemplateUri INFORMATION_NEGOTIATION_PROPOSE = - TemplateUri.of(NEGOTIATION_EXTENSION_NAME, V1, "information-negotiation", "propose"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "information-negotiation", "propose"); /** Negotiation-T accept-reject template for information negotiation. */ public static final TemplateUri INFORMATION_NEGOTIATION_ACCEPT_REJECT = - TemplateUri.of(NEGOTIATION_EXTENSION_NAME, V1, "information-negotiation", "accept-reject"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "information-negotiation", "accept-reject"); /** Negotiation-T propose template for target negotiation. */ public static final TemplateUri TARGET_NEGOTIATION_PROPOSE = - TemplateUri.of(NEGOTIATION_EXTENSION_NAME, V1, "target-negotiation", "propose"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "target-negotiation", "propose"); /** Negotiation-T accept-reject template for target negotiation. */ public static final TemplateUri TARGET_NEGOTIATION_ACCEPT_REJECT = - TemplateUri.of(NEGOTIATION_EXTENSION_NAME, V1, "target-negotiation", "accept-reject"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "target-negotiation", "accept-reject"); /** Negotiation-T propose template for feasibility negotiation. */ public static final TemplateUri FEASIBILITY_NEGOTIATION_PROPOSE = - TemplateUri.of(NEGOTIATION_EXTENSION_NAME, V1, "feasibility-negotiation", "propose"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "feasibility-negotiation", "propose"); /** Negotiation-T accept-reject template for feasibility negotiation. */ public static final TemplateUri FEASIBILITY_NEGOTIATION_ACCEPT_REJECT = - TemplateUri.of(NEGOTIATION_EXTENSION_NAME, V1, "feasibility-negotiation", "accept-reject"); + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "feasibility-negotiation", "accept-reject"); /** All built-in Task-T templates. */ public static final List TASK = List.of(ENERGY_SAVING, PRIVATE_LINE_COMPLAINT); diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/TemplateUri.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/TemplateUri.java index f7c740d1..eee43c61 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/TemplateUri.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/TemplateUri.java @@ -55,18 +55,34 @@ public record TemplateUri( } /** - * Creates a template URI from its components. + * Creates a template URI from its components, defaulting the template version to + * {@link #DEFAULT_TEMPLATE_VERSION}. * * @param extensionName first URI segment identifying the template family, such as {@code Task-T} - * @param templateVersion trailing URI segment, such as {@code v1} * @param pathSegments middle URI segments, such as {@code network-layer, energy-saving} * @return validated template URI + * @throws NullPointerException if the extension name or any path segment is null + * @throws IllegalArgumentException if any component is not a simple path segment or no path segment is given + */ + public static @NonNull TemplateUri of(@NonNull String extensionName, @NonNull String... pathSegments) { + return new TemplateUri(extensionName, List.of(pathSegments), DEFAULT_TEMPLATE_VERSION); + } + + /** + * Creates a template URI from its components with an explicit template version. + * + * @param extensionName first URI segment identifying the template family, such as {@code Task-T} + * @param pathSegments middle URI segments, such as {@code network-layer, energy-saving} + * @param templateVersion trailing URI segment, such as {@code v2} + * @return validated template URI * @throws NullPointerException if any component is null * @throws IllegalArgumentException if any component is not a simple path segment or no path segment is given */ public static @NonNull TemplateUri of( - @NonNull String extensionName, @NonNull String templateVersion, @NonNull String... pathSegments) { - return new TemplateUri(extensionName, List.of(pathSegments), templateVersion); + @NonNull String extensionName, + @NonNull List pathSegments, + @NonNull String templateVersion) { + return new TemplateUri(extensionName, pathSegments, templateVersion); } /** diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/TemplateUriTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/TemplateUriTest.java index c68e4796..57fc28dc 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/TemplateUriTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/TemplateUriTest.java @@ -12,7 +12,7 @@ class TemplateUriTest { @Test void composesUriFromComponents() { - TemplateUri uri = TemplateUri.of("Task-T", "v1", "network-layer", "energy-saving"); + TemplateUri uri = TemplateUri.of("Task-T", "network-layer", "energy-saving"); assertEquals("Task-T/network-layer/energy-saving/v1", uri.uri()); assertEquals("Task-T", uri.extensionName()); assertEquals(List.of("network-layer", "energy-saving"), uri.pathSegments()); @@ -23,7 +23,7 @@ void composesUriFromComponents() { void parseRoundTripsNetworkLayerUri() { Optional parsed = TemplateUri.parse("Task-T/network-layer/energy-saving/v1"); assertEquals( - Optional.of(TemplateUri.of("Task-T", "v1", "network-layer", "energy-saving")), parsed); + Optional.of(TemplateUri.of("Task-T", "network-layer", "energy-saving")), parsed); assertEquals("Task-T/network-layer/energy-saving/v1", parsed.orElseThrow().uri()); } @@ -31,14 +31,14 @@ void parseRoundTripsNetworkLayerUri() { void parseRoundTripsAuthorizationUri() { Optional parsed = TemplateUri.parse("Authorization-T/authorization-policy-management/v1"); assertEquals( - Optional.of(TemplateUri.of("Authorization-T", "v1", "authorization-policy-management")), parsed); + Optional.of(TemplateUri.of("Authorization-T", "authorization-policy-management")), parsed); } @Test void parseRoundTripsNegotiationUri() { Optional parsed = TemplateUri.parse("Negotiation-T/information-negotiation/propose/v1"); assertEquals( - Optional.of(TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose")), parsed); + Optional.of(TemplateUri.of("Negotiation-T", "information-negotiation", "propose")), parsed); } @Test @@ -54,14 +54,15 @@ void parseRejectsMalformedUris() { @Test void ofRejectsInvalidComponents() { - assertThrows(NullPointerException.class, () -> TemplateUri.of(null, "v1", "scenario")); - assertThrows(NullPointerException.class, () -> TemplateUri.of("Task-T", null, "scenario")); - assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "v1")); + assertThrows(NullPointerException.class, () -> TemplateUri.of(null, "scenario")); + assertThrows(NullPointerException.class, () -> TemplateUri.of("Task-T", (String[]) null)); + assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T")); assertThrows(NullPointerException.class, () -> new TemplateUri("Task-T", (List) null, "v1")); - assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "v1", "scen/ario")); - assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "v1", "scen..ario/x")); - assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "v1", " ")); - assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", " ", "scenario")); + assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "scen/ario")); + assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", "scen..ario/x")); + assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", " ")); + assertThrows(NullPointerException.class, () -> TemplateUri.of("Task-T", List.of("scenario"), null)); + assertThrows(IllegalArgumentException.class, () -> TemplateUri.of("Task-T", List.of("scenario"), " ")); } @Test diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InfoEndingContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationEndingContent.java similarity index 75% rename from a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InfoEndingContent.java rename to a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationEndingContent.java index 2f638ed1..cf4e7257 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InfoEndingContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationEndingContent.java @@ -9,5 +9,5 @@ * @param items information items delivered with the terminal message * @since 2026-06 */ -public record InfoEndingContent(NegotiationConclusion conclusion, List items) +public record InformationEndingContent(NegotiationConclusion conclusion, List items) implements NegotiationEndingContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InfoProposeContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationProposeContent.java similarity index 78% rename from a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InfoProposeContent.java rename to a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationProposeContent.java index 116c468f..452d1502 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InfoProposeContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationProposeContent.java @@ -9,5 +9,5 @@ * @param relationship free-form description of how the missing items relate to each other; null when there is none * @since 2026-06 */ -public record InfoProposeContent(List items, String relationship) +public record InformationProposeContent(List items, String relationship) implements NegotiationProposeContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java index 47485046..779f378b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java @@ -6,7 +6,7 @@ * @since 2026-06 */ public sealed interface NegotiationEndingContent extends NegotiationContent - permits InfoEndingContent, TargetEndingContent, FeasibilityEndingContent { + permits InformationEndingContent, TargetEndingContent, FeasibilityEndingContent { /** * Returns the terminal conclusion this content carries. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java index 5b8cd34d..e29a4e57 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java @@ -6,4 +6,4 @@ * @since 2026-06 */ public sealed interface NegotiationProposeContent extends NegotiationContent - permits InfoProposeContent, TargetProposeContent, FeasibilityProposeContent {} + permits InformationProposeContent, TargetProposeContent, FeasibilityProposeContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java index c2400c6d..1b628fe2 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java @@ -11,8 +11,8 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; @@ -163,7 +163,7 @@ private static NegotiationContent mapContent( private static NegotiationContent mapProposeContent(Map payload, NegotiationType type) { return switch (type) { - case INFORMATION -> new InfoProposeContent( + case INFORMATION -> new InformationProposeContent( requiredItems(payload, "items"), optionalString(payload, "relationship")); case TARGET -> new TargetProposeContent( requiredString(payload, "target_negotiation_description"), @@ -198,7 +198,7 @@ private static NegotiationContent mapEndingContent( NegotiationConclusion conclusion = requiredConclusion(payload); requireConclusionMatchesPhase(conclusion, phase); return switch (type) { - case INFORMATION -> new InfoEndingContent(conclusion, requiredItems(payload, "items")); + case INFORMATION -> new InformationEndingContent(conclusion, requiredItems(payload, "items")); case TARGET -> mapTargetEndingContent(payload, conclusion); case FEASIBILITY -> new FeasibilityEndingContent( conclusion, requiredString(payload, "feasibility_summary")); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java index 969bfef9..3bb0d94b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java @@ -2,7 +2,7 @@ import java.util.LinkedHashMap; import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.Vocabulary; @@ -29,7 +29,7 @@ final class InformationEndingGenerator extends AbstractNegotiationGenerator { @Override public String generate( NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - InfoEndingContent endingContent = contentOf(content, InfoEndingContent.class, "Information ending generator"); + InformationEndingContent endingContent = contentOf(content, InformationEndingContent.class, "Information ending generator"); renderableConclusion(endingContent.conclusion()); Map slots = new LinkedHashMap<>(); slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java index e8752031..cdfb8df0 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java @@ -2,7 +2,7 @@ import java.util.LinkedHashMap; import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.Vocabulary; @@ -30,15 +30,15 @@ final class InformationProposeGenerator extends AbstractNegotiationGenerator { @Override public String generate( NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - InfoProposeContent proposeContent = - contentOf(content, InfoProposeContent.class, "Information propose generator"); + InformationProposeContent proposeContent = + contentOf(content, InformationProposeContent.class, "Information propose generator"); Map slots = new LinkedHashMap<>(); slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); slots.put(vocabulary.get("slot.info_items"), itemsSlotValue(proposeContent, vocabulary)); return render(template, slots); } - private String itemsSlotValue(InfoProposeContent content, Vocabulary vocabulary) { + private String itemsSlotValue(InformationProposeContent content, Vocabulary vocabulary) { String items = formatItems(content.items(), vocabulary); if (content.relationship() == null || content.relationship().isBlank()) { return items; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java index 83662951..da12e1e9 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java @@ -5,8 +5,8 @@ import java.util.Objects; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; @@ -109,13 +109,13 @@ private static Class expectedContentClass( NegotiationType type, boolean proposePhase) { if (proposePhase) { return switch (type) { - case INFORMATION -> InfoProposeContent.class; + case INFORMATION -> InformationProposeContent.class; case TARGET -> TargetProposeContent.class; case FEASIBILITY -> FeasibilityProposeContent.class; }; } return switch (type) { - case INFORMATION -> InfoEndingContent.class; + case INFORMATION -> InformationEndingContent.class; case TARGET -> TargetEndingContent.class; case FEASIBILITY -> FeasibilityEndingContent.class; }; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java index d9a0f1c9..784b5484 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java @@ -16,7 +16,7 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; @@ -54,7 +54,7 @@ void everyThreadMatchesItsSerialBaselineWithoutExceptionLeaks() throws Exception NegotiationProposeData proposeData = new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)); + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)); MetadataContent fromDataBase = orchestrator.generateProposeFromData(proposeData, INFORMATION_PROPOSE_URI); MetadataContent fromTextBase = orchestrator.generateProposeFromText( "请提供节能区域信息。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java index a03f70e3..46dc803f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -25,8 +25,8 @@ void contentPackageExposesExactlyThePinnedTypes() throws IOException { List.of( "FeasibilityEndingContent.java", "FeasibilityProposeContent.java", - "InfoEndingContent.java", - "InfoProposeContent.java", + "InformationEndingContent.java", + "InformationProposeContent.java", "NegotiationAction.java", "NegotiationConclusion.java", "NegotiationContent.java", diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java index 648e6dc3..d810571a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java @@ -8,7 +8,7 @@ import java.util.stream.Stream; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; @@ -44,7 +44,7 @@ void acceptConclusionRendersTheAcceptLiteral(String language) { .generateAcceptFromData( new NegotiationEndingData( context, - new InfoEndingContent( + new InformationEndingContent( NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("area information", "Songshan Lake")))), StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT) @@ -84,7 +84,7 @@ void rejectConclusionRendersTheRejectLiteral(String language) { .generateRejectFromData( new NegotiationEndingData( context, - new InfoEndingContent( + new InformationEndingContent( NegotiationConclusion.REJECT, List.of(new NegotiationItem("area information", "not available")))), StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT) diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java index 42bd518e..320296ed 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java @@ -15,8 +15,8 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -218,28 +218,28 @@ context, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " ")), "template URI with wrong prefix", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), - TemplateUri.of("Task-T", "v1", "information-negotiation", "propose")), + TemplateUri.of("Task-T", "information-negotiation", "propose")), IllegalArgumentException.class, "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), new MatrixRow( "template URI with wrong version", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), - TemplateUri.of("Negotiation-T", "v2", "information-negotiation", "propose")), + TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v2")), IllegalArgumentException.class, "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), new MatrixRow( "template URI with underscore type segment", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), - TemplateUri.of("Negotiation-T", "v1", "information_negotiation", "propose")), + TemplateUri.of("Negotiation-T", "information_negotiation", "propose")), IllegalArgumentException.class, "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), new MatrixRow( "template URI with unknown type", () -> orchestrator.generateProposeFromData( new NegotiationProposeData(context, informationProposeContent()), - TemplateUri.of("Negotiation-T", "v1", "unknown-negotiation", "propose")), + TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")), IllegalArgumentException.class, "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), new MatrixRow( @@ -256,17 +256,17 @@ context, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " ")), context, new TargetProposeContent("目标协商概述。", null, null, null)), INFORMATION_PROPOSE_URI), IllegalArgumentException.class, - "Negotiation type INFORMATION requires content of type InfoProposeContent")); + "Negotiation type INFORMATION requires content of type InformationProposeContent")); } - private static InfoProposeContent informationProposeContent() { - return new InfoProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null); + private static InformationProposeContent informationProposeContent() { + return new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null); } private static NegotiationEndingData ending(NegotiationConclusion conclusion) { return new NegotiationEndingData( new NegotiationContext(UUID, 2, 5), - new InfoEndingContent(conclusion, List.of(new NegotiationItem("区域", "松山湖")))); + new InformationEndingContent(conclusion, List.of(new NegotiationItem("区域", "松山湖")))); } private static boolean isAsciiText(String message) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 229582a6..2bb40252 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -27,8 +27,8 @@ import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -628,7 +628,7 @@ private List warningMessages(String prefix) { */ private static String extractionJson(NegotiationContent content, NegotiationPhase phase) { if (phase == NegotiationPhase.PROPOSE) { - if (content instanceof InfoProposeContent info) { + if (content instanceof InformationProposeContent info) { return "{\"items\":" + itemsJson(info.items()) + ",\"relationship\":" + stringOrNull(info.relationship()) + "}"; } @@ -654,7 +654,7 @@ private static String extractionJson(NegotiationContent content, NegotiationPhas + itemsJson(feasibility.infeasibilityDetailsAndProposal()) + "}"; } - if (content instanceof InfoEndingContent info) { + if (content instanceof InformationEndingContent info) { return "{\"conclusion\":" + quote(info.conclusion().literal()) + ",\"items\":" + itemsJson(info.items()) + "}"; } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java index fe3e0308..241f03fb 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java @@ -12,8 +12,8 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; @@ -39,7 +39,7 @@ void extractsInformationProposeContent() { "请提供故障发生时间与受影响小区标识。", reference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN")); - InfoProposeContent proposeContent = assertInstanceOf(InfoProposeContent.class, content); + InformationProposeContent proposeContent = assertInstanceOf(InformationProposeContent.class, content); assertEquals( List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点"), new NegotiationItem("受影响小区标识", null)), proposeContent.items()); @@ -103,7 +103,7 @@ void extractsEndingContentForEveryTypeAndBothPhases() { "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"故障发生时间\",\"value\":\"2026-08-19 10:30\"}]}")) .extract("同意提供。", reference(NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, "zh-CN")); - InfoEndingContent infoEnding = assertInstanceOf(InfoEndingContent.class, infoAccept); + InformationEndingContent infoEnding = assertInstanceOf(InformationEndingContent.class, infoAccept); assertEquals(NegotiationConclusion.ACCEPT, infoEnding.conclusion()); assertEquals(List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30")), infoEnding.items()); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java index b7025d95..9816e6dd 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java @@ -19,7 +19,7 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMError; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; @@ -73,7 +73,7 @@ void absentLoggerFallsBackToTheModuleLogger() { MetadataContent result = orchestrator.generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE_URI); assertFalse(result.promptText().isBlank()); @@ -100,7 +100,7 @@ void injectedLoggerReceivesThePipelineEvents() { orchestrator.generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE_URI); ILoggingEvent completed = customAppender.list.stream() diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index ef2f7945..0853723c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -14,7 +14,7 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; @@ -41,7 +41,7 @@ void generatesInformationProposeFromDataInChinese() { .generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); @@ -65,7 +65,7 @@ void generatesInformationProposeFromDataInEnglish() { .generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 2, 5), - new InfoProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), INFORMATION_PROPOSE); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); @@ -106,7 +106,7 @@ void validatesAndFillsProposeDataWithScriptedSemanticResponse() { MetadataContent message = orchestrator.generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE); FilledParamData filled = orchestrator.validateAndFillingProposeData( @@ -136,7 +136,7 @@ void queriesSingleTemplateByUri() { assertFalse( zhOrchestrator() .getNegotiationPrompt( - TemplateUri.of("Negotiation-T", "v1", "unknown-negotiation", "propose")) + TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")) .isPresent()); assertFalse(zhOrchestrator().getNegotiationPrompt(StandardTemplates.ENERGY_SAVING).isPresent()); } @@ -187,7 +187,7 @@ void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { () -> generationOrchestrator.generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), INFORMATION_PROPOSE)); assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, generationFailure.getCode()); @@ -235,7 +235,7 @@ void rejectsTemplateUriThatDoesNotAddressTheExpectedPhase() { .generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT)); assertTrue(phaseMismatchFailure .getMessage() @@ -247,7 +247,7 @@ void rejectsTemplateUriThatDoesNotAddressTheExpectedPhase() { .generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), StandardTemplates.ENERGY_SAVING)); assertTrue(wrongExtensionFailure .getMessage() diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java index 19d2c67e..66cbd3e5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java @@ -8,8 +8,8 @@ import java.util.List; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; @@ -31,7 +31,7 @@ void dispatchesEveryTypeAndPhaseCombination() { registry.resolve( NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, - new InfoProposeContent(List.of(), null))); + new InformationProposeContent(List.of(), null))); assertInstanceOf( TargetProposeGenerator.class, registry.resolve( @@ -53,13 +53,13 @@ void dispatchesEveryTypeAndPhaseCombination() { registry.resolve( NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, - new InfoEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); assertInstanceOf( InformationEndingGenerator.class, registry.resolve( NegotiationType.INFORMATION, NegotiationPhase.REJECT, - new InfoEndingContent(NegotiationConclusion.REJECT, List.of()))); + new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); assertInstanceOf( TargetEndingGenerator.class, registry.resolve( @@ -91,22 +91,22 @@ void rejectsProposeContentInTerminalPhaseAndEndingContentInProposePhase() { IllegalArgumentException proposeInEnding = assertThrows( IllegalArgumentException.class, () -> registry.resolve( - NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, new InfoProposeContent(List.of(), null))); + NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, new InformationProposeContent(List.of(), null))); assertTrue(proposeInEnding .getMessage() .contains("ACCEPT phase requires ending content but received propose content of type" - + " InfoProposeContent")); + + " InformationProposeContent")); IllegalArgumentException endingInPropose = assertThrows( IllegalArgumentException.class, () -> registry.resolve( NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, - new InfoEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); assertTrue(endingInPropose .getMessage() .contains("PROPOSE phase requires propose content but received ending content of type" - + " InfoEndingContent")); + + " InformationEndingContent")); } @Test @@ -120,7 +120,7 @@ void rejectsContentRuntimeTypeNotMatchingTheNegotiationType() { assertTrue(exception .getMessage() - .contains("Negotiation type INFORMATION requires content of type InfoProposeContent but received" + .contains("Negotiation type INFORMATION requires content of type InformationProposeContent but received" + " TargetProposeContent")); assertThrows( @@ -128,7 +128,7 @@ void rejectsContentRuntimeTypeNotMatchingTheNegotiationType() { () -> registry.resolve( NegotiationType.TARGET, NegotiationPhase.ACCEPT, - new InfoEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); } @Test @@ -138,7 +138,7 @@ void rejectsEndingConclusionNotMatchingThePhase() { () -> registry.resolve( NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, - new InfoEndingContent(NegotiationConclusion.REJECT, List.of()))); + new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); assertTrue(exception .getMessage() @@ -157,14 +157,14 @@ void rejectsEndingContentWithoutConclusion() { NullPointerException exception = assertThrows( NullPointerException.class, () -> registry.resolve( - NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, new InfoEndingContent(null, List.of()))); + NegotiationType.INFORMATION, NegotiationPhase.ACCEPT, new InformationEndingContent(null, List.of()))); assertTrue(exception.getMessage().contains("conclusion must not be null")); } @Test void rejectsNullArguments() { - NegotiationContent content = new InfoProposeContent(List.of(), null); + NegotiationContent content = new InformationProposeContent(List.of(), null); assertEquals( "Negotiation type must not be null.", diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java index 297eb54a..79b2bbfa 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -9,8 +9,8 @@ import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -319,7 +319,7 @@ void targetBlankDescriptionIsRejected() { @Test void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { - InfoProposeContent withRelationship = new InfoProposeContent( + InformationProposeContent withRelationship = new InformationProposeContent( List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点"), new NegotiationItem("受影响小区标识", "CGI 或小区名称")), "故障发生时间与受影响小区标识需逐小区对应"); @@ -333,8 +333,8 @@ void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { assertTrue(renderedZh.contains("## 所需信息项\n1. 故障发生时间:精确到分钟的时间点\n2. 受影响小区标识:CGI 或小区名称")); assertTrue(renderedZh.endsWith("缺失项之间的关系:故障发生时间与受影响小区标识需逐小区对应")); - InfoProposeContent withoutRelationship = - new InfoProposeContent(List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点")), null); + InformationProposeContent withoutRelationship = + new InformationProposeContent(List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点")), null); String renderedWithout = new InformationProposeGenerator() .generate( @@ -348,7 +348,7 @@ void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { @Test void informationProposeRendersEnglishRelationshipLabelWithTrailingSpace() { - InfoProposeContent content = new InfoProposeContent( + InformationProposeContent content = new InformationProposeContent( List.of(new NegotiationItem("Failure time", "minute precision")), "failure time and cell identity must correspond per cell"); @@ -375,7 +375,7 @@ void endingGeneratorsRenderConclusionLiterals() { String infoRendered = new InformationEndingGenerator() .generate( context(2), - new InfoEndingContent( + new InformationEndingContent( NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30"))), template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_INFO_ENDING_TEMPLATE), @@ -412,7 +412,7 @@ void abortConclusionIsRejectedByEveryEndingGenerator() { assertThrows(IllegalArgumentException.class, () -> new InformationEndingGenerator() .generate( context(1), - new InfoEndingContent(NegotiationConclusion.ABORT, List.of()), + new InformationEndingContent(NegotiationConclusion.ABORT, List.of()), template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_INFO_ENDING_TEMPLATE), zhVocabulary)); assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() @@ -437,7 +437,7 @@ void nullConclusionIsRejected() { assertThrows(NullPointerException.class, () -> new InformationEndingGenerator() .generate( context(1), - new InfoEndingContent(null, List.of()), + new InformationEndingContent(null, List.of()), template( StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_INFO_ENDING_TEMPLATE), @@ -502,7 +502,7 @@ void generatorRejectsContentOfAnotherRuntimeType() { template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), ZH_INFO_PROPOSE_TEMPLATE), zhVocabulary)); - assertTrue(exception.getMessage().contains("requires content of type InfoProposeContent")); + assertTrue(exception.getMessage().contains("requires content of type InformationProposeContent")); } @Test diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java index 301d3d82..f9b802a6 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java @@ -105,7 +105,7 @@ void singleQueryAnswersAMissingUriWithEmptyAndAWarning() { NegotiationGenerationOrchestrator orchestrator = orchestrator("zh-CN"); Optional template = orchestrator.getNegotiationPrompt( - TemplateUri.of("Negotiation-T", "v1", "unknown-negotiation", "propose")); + TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")); assertTrue(template.isEmpty()); assertTrue(hasWarning("negotiation_template_not_found", "A2AT_LANGUAGE")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java index ef2cd14f..af070614 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java @@ -6,7 +6,7 @@ import java.util.List; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; @@ -79,7 +79,7 @@ private static String generate(String language, String relationship) { .generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 2, 5), - new InfoProposeContent( + new InformationProposeContent( List.of(new NegotiationItem( chinese ? "节能区域" : "area", chinese ? "松山湖" : "Songshan Lake")), relationship)), diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index 8993fc78..46f9b708 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -20,7 +20,7 @@ import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; @@ -137,13 +137,13 @@ void nonAddressingUriIsRejectedAsATemplateUriProgrammingError(TemplateUri templa static List nonAddressingTemplateUris() { return List.of( - TemplateUri.of("Task-T", "v1", "information-negotiation", "propose"), - TemplateUri.of("Negotiation-T", "v2", "information-negotiation", "propose"), - TemplateUri.of("Negotiation-T", "v1", "information", "propose"), - TemplateUri.of("Negotiation-T", "v1", "information_negotiation", "propose"), - TemplateUri.of("Negotiation-T", "v1", "unknown-negotiation", "propose"), - TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose-x"), - TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "accept")); + TemplateUri.of("Task-T", "information-negotiation", "propose"), + TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v2"), + TemplateUri.of("Negotiation-T", "information", "propose"), + TemplateUri.of("Negotiation-T", "information_negotiation", "propose"), + TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose"), + TemplateUri.of("Negotiation-T", "information-negotiation", "propose-x"), + TemplateUri.of("Negotiation-T", "information-negotiation", "accept")); } /** @@ -183,7 +183,7 @@ void localRootTemplateOverridesTheBuiltInTemplateOfTheSameUri(@TempDir Path temp private static NegotiationProposeData informationProposeData() { return new NegotiationProposeData( new NegotiationContext(UUID, 2, 5), - new InfoProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)); + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)); } private static String readClasspathText(String resourcePath) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java index acbffd01..0749bdda 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java @@ -18,8 +18,8 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.llm.LLMRuntimeError; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -483,7 +483,7 @@ private static String informationProposeMessage(NegotiationGenerationOrchestrato MetadataContent content = orchestrator.generateProposeFromData( new NegotiationProposeData( new NegotiationContext(SESSION_ID, 2, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE_URI); return content.promptText(); } @@ -492,7 +492,7 @@ private static String informationEndingMessage( NegotiationGenerationOrchestrator orchestrator, NegotiationConclusion conclusion) { NegotiationEndingData data = new NegotiationEndingData( new NegotiationContext(SESSION_ID, 2, 5), - new InfoEndingContent(conclusion, List.of(new NegotiationItem("节能区域", "松山湖")))); + new InformationEndingContent(conclusion, List.of(new NegotiationItem("节能区域", "松山湖")))); MetadataContent content = conclusion == NegotiationConclusion.ACCEPT ? orchestrator.generateAcceptFromData(data, INFORMATION_ACCEPT_REJECT_URI) : orchestrator.generateRejectFromData(data, INFORMATION_ACCEPT_REJECT_URI); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java index 1a7ccb3a..26e82250 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java @@ -5,8 +5,8 @@ import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; @@ -82,7 +82,7 @@ public NegotiationContext context() { @Override public NegotiationContent content() { - return new InfoProposeContent( + return new InformationProposeContent( List.of( new NegotiationItem("energy-saving area information", "e.g. Songshan Lake"), new NegotiationItem("energy-saving rate guarantee target", "e.g. 20Mbps"), @@ -147,7 +147,7 @@ public NegotiationContent content() { INFORMATION_ACCEPT(NegotiationPhase.ACCEPT, "information-negotiation", "information_accept") { @Override public NegotiationContent content() { - return new InfoEndingContent( + return new InformationEndingContent( NegotiationConclusion.ACCEPT, List.of( new NegotiationItem("energy-saving area information", "Songshan Lake"), @@ -182,7 +182,7 @@ public NegotiationContent content() { INFORMATION_REJECT(NegotiationPhase.REJECT, "information-negotiation", "information_reject") { @Override public NegotiationContent content() { - return new InfoEndingContent( + return new InformationEndingContent( NegotiationConclusion.REJECT, List.of(new NegotiationItem( "energy-saving area information", @@ -240,10 +240,7 @@ public NegotiationPhase phase() { * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} */ public TemplateUri template() { - return TemplateUri.of( - StandardTemplates.NEGOTIATION_EXTENSION_NAME, - TemplateUri.DEFAULT_TEMPLATE_VERSION, - typeSegment, + return TemplateUri.of(StandardTemplates.NEGOTIATION_EXTENSION_NAME, typeSegment, phase.uriSegment()); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java index a1ef15be..614a9573 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java @@ -18,7 +18,7 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMError; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; @@ -303,7 +303,7 @@ private static RuntimeException runExpectingFailure( private static NegotiationProposeData proposeData() { return new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)); + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)); } private static String semanticPayload(String negotiationType) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java index 253c9a66..4f0fea9d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java @@ -15,7 +15,7 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMError; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; @@ -136,7 +136,7 @@ void contentProgrammingErrorsAreNotCatchableThroughTheCommonRoot() { orchestrator.generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), StandardTemplates.ENERGY_SAVING); } catch (A2ATError caughtByRoot) { fail("content programming errors must stay outside the A2ATError tree but were caught: " diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index eef19817..799baa6f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -20,7 +20,7 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; @@ -166,7 +166,7 @@ void infoAndHigherEventsNeverCarryMessageTextInputOrResponseContent() { MetadataContent generated = orchestrator.generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem(promptMarker, "value")), null)), + new InformationProposeContent(List.of(new NegotiationItem(promptMarker, "value")), null)), INFORMATION_PROPOSE_URI); assertTrue(generated.promptText().contains(promptMarker)); orchestrator.generateProposeFromText( @@ -241,7 +241,7 @@ private void driveSuccessfulGenerationFromData() { MetadataContent result = orchestrator.generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE_URI); assertTrue(result.promptText().contains("协商上下文")); } @@ -296,7 +296,7 @@ private void driveSuccessfulParamExtraction() { MetadataContent message = orchestrator.generateProposeFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE_URI); FilledParamData filled = orchestrator.validateAndFillingProposeData( message.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java index 5f3a2c5a..8786f7ed 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; import java.util.Optional; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; @@ -149,7 +150,7 @@ void fromTemplateUriRejectsNonNegotiationUris() { StandardTemplates.ENERGY_SAVING, NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); assertTrue(NegotiationReference.fromTemplateUri( - TemplateUri.of("negotiation-t", "v1", "information-negotiation", "propose"), + TemplateUri.of("negotiation-t", "information-negotiation", "propose"), NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); @@ -158,12 +159,12 @@ void fromTemplateUriRejectsNonNegotiationUris() { @Test void fromTemplateUriRejectsWrongPathSegmentCount() { assertTrue(NegotiationReference.fromTemplateUri( - TemplateUri.of("Negotiation-T", "v1", "information-negotiation"), + TemplateUri.of("Negotiation-T", "information-negotiation"), NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); assertTrue(NegotiationReference.fromTemplateUri( - TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose", "extra"), + TemplateUri.of("Negotiation-T", "information-negotiation", "propose", "extra"), NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); @@ -172,27 +173,27 @@ void fromTemplateUriRejectsWrongPathSegmentCount() { @Test void fromTemplateUriRejectsWrongVersionTypeAndPhaseSegments() { assertTrue(NegotiationReference.fromTemplateUri( - TemplateUri.of("Negotiation-T", "v2", "information-negotiation", "propose"), + TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v2"), NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); assertTrue(NegotiationReference.fromTemplateUri( - TemplateUri.of("Negotiation-T", "v1", "information", "propose"), + TemplateUri.of("Negotiation-T", "information", "propose"), NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); assertTrue(NegotiationReference.fromTemplateUri( - TemplateUri.of("Negotiation-T", "v1", "information_negotiation", "propose"), + TemplateUri.of("Negotiation-T", "information_negotiation", "propose"), NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); assertTrue(NegotiationReference.fromTemplateUri( - TemplateUri.of("Negotiation-T", "v1", "unknown-negotiation", "propose"), + TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose"), NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); assertTrue(NegotiationReference.fromTemplateUri( - TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose-x"), + TemplateUri.of("Negotiation-T", "information-negotiation", "propose-x"), NegotiationPhase.PROPOSE, "zh-CN") .isEmpty()); @@ -217,11 +218,11 @@ void fromTemplateUriRejectsPhaseMismatchAndThrowsOnNullArguments() { } private static TemplateUri proposeTemplate(NegotiationType type) { - return TemplateUri.of("Negotiation-T", "v1", type.typeSegment(), "propose"); + return TemplateUri.of("Negotiation-T", type.typeSegment(), "propose"); } private static TemplateUri acceptRejectTemplate(NegotiationType type) { - return TemplateUri.of("Negotiation-T", "v1", type.typeSegment(), "accept-reject"); + return TemplateUri.of("Negotiation-T", type.typeSegment(), "accept-reject"); } private static String proposeUri(NegotiationType type) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java index c16a8061..25423955 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java @@ -8,8 +8,8 @@ import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -49,7 +49,7 @@ void bothSidesGenerateAndValidateEachOthersMessages() { MetadataContent fromData = initiatingSide.generateProposeFromData( new NegotiationProposeData( - round, new InfoProposeContent(List.of(new NegotiationItem("节能区域信息", "松山湖")), null)), + round, new InformationProposeContent(List.of(new NegotiationItem("节能区域信息", "松山湖")), null)), INFORMATION_PROPOSE_URI); MetadataContent fromText = initiatingSide.generateProposeFromText("请提供节能区域信息:松山湖。", round, INFORMATION_PROPOSE_URI); @@ -73,7 +73,7 @@ round, new InfoProposeContent(List.of(new NegotiationItem("节能区域信息", NegotiationEndingData answer = new NegotiationEndingData( round, - new InfoEndingContent(NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("节能区域信息", "松山湖")))); + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("节能区域信息", "松山湖")))); MetadataContent respondingSideAnswer = respondingSide.generateAcceptFromData(answer, INFORMATION_ACCEPT_URI); MetadataContent initiatingSideAnswer = initiatingSide.generateAcceptFromData(answer, INFORMATION_ACCEPT_URI); assertEquals( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java index db61c5b9..46863825 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java @@ -10,8 +10,8 @@ import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -61,7 +61,7 @@ void informationNegotiationReachesTheTerminalStateWithinOneRound() { MetadataContent request = requester.generateProposeFromData( new NegotiationProposeData( round, - new InfoProposeContent( + new InformationProposeContent( List.of( new NegotiationItem("节能区域信息", "如松山湖"), new NegotiationItem("节能速率保障目标", "如20Mbps")), @@ -82,7 +82,7 @@ void informationNegotiationReachesTheTerminalStateWithinOneRound() { MetadataContent answer = responder.generateAcceptFromData( new NegotiationEndingData( round, - new InfoEndingContent( + new InformationEndingContent( NegotiationConclusion.ACCEPT, List.of( new NegotiationItem("节能区域信息", "松山湖"), diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java index 69d6bd40..43cd37a9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java @@ -78,7 +78,7 @@ void exhaustedRoundBudgetEndsWithARejectMessageThePeerStillAccepts() { assertEquals(1, peerLlm.callCount(), "the rule gate must fail before any semantic LLM call"); assertTrue( - agent.getNegotiationPrompt(TemplateUri.of("Negotiation-T", "v1", "target-negotiation", "abort")) + agent.getNegotiationPrompt(TemplateUri.of("Negotiation-T", "target-negotiation", "abort")) .isEmpty(), "no template of an abort phase exists anywhere"); } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java index 1ac8ad9f..75109b86 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java @@ -73,12 +73,9 @@ private PromptSlotSchema load(TemplateUri candidate, String scenarioCode, String */ private static List bareCodeCandidates(String slotType, String scenarioCode) { return List.of( - TemplateUri.of( - slotType, - TemplateUri.DEFAULT_TEMPLATE_VERSION, - StandardTemplates.NETWORK_LAYER_SEGMENT, + TemplateUri.of(slotType, StandardTemplates.NETWORK_LAYER_SEGMENT, scenarioCode), - TemplateUri.of(slotType, TemplateUri.DEFAULT_TEMPLATE_VERSION, scenarioCode)); + TemplateUri.of(slotType, scenarioCode)); } private static List discoverSlotTypes() { diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java index bcb26420..eb073131 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java @@ -62,12 +62,9 @@ public String loadTemplate(String scenarioCode, String language) { */ private static List bareCodeCandidates(String templateType, String scenarioCode) { return List.of( - TemplateUri.of( - templateType, - TemplateUri.DEFAULT_TEMPLATE_VERSION, - StandardTemplates.NETWORK_LAYER_SEGMENT, + TemplateUri.of(templateType, StandardTemplates.NETWORK_LAYER_SEGMENT, scenarioCode), - TemplateUri.of(templateType, TemplateUri.DEFAULT_TEMPLATE_VERSION, scenarioCode)); + TemplateUri.of(templateType, scenarioCode)); } private static List discoverTemplateTypes() { diff --git a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java index f7d93ac8..a1c72000 100644 --- a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java +++ b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java @@ -32,7 +32,7 @@ void rejectsResourceTraversalSegments() { @Test void raisesTypedErrorWhenResourceIsMissing() { PromptResourceKey key = PromptResourceKey.template( - TemplateUri.of("Task-T", "v1", "network-layer", "missing_scenario"), "en-US", "template.md"); + TemplateUri.of("Task-T", "network-layer", "missing_scenario"), "en-US", "template.md"); ResourceNotFoundException error = assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); @@ -53,7 +53,7 @@ void loadsPackagedScenarioCatalogForZhCn() { void loadsPackagedSubscribeIncidentSlotSchemaWithSemanticHint() { PromptResourceKey key = PromptResourceKey.slotSchema( - TemplateUri.of("Notification-T", "v1", "network-layer", "subscribe-incident"), + TemplateUri.of("Notification-T", "network-layer", "subscribe-incident"), "zh-CN", "slot.json"); diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index 380674f8..61baa796 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -19,8 +19,8 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InfoEndingContent; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; @@ -156,7 +156,7 @@ private static List symmetryCases() { StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, new NegotiationProposeData( new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5), - new InfoProposeContent( + new InformationProposeContent( List.of( new NegotiationItem( "energy-saving area information", "e.g. Songshan Lake"), @@ -188,7 +188,7 @@ private static List symmetryCases() { "information_accept", NegotiationPhase.ACCEPT, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, - new InfoEndingContent( + new InformationEndingContent( NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("area information", "Songshan Lake")))), endingCase( @@ -205,7 +205,7 @@ private static List symmetryCases() { "information_reject", NegotiationPhase.REJECT, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, - new InfoEndingContent( + new InformationEndingContent( NegotiationConclusion.REJECT, List.of(new NegotiationItem("area information", "not available")))), endingCase( diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index 302c8bd6..6eaf6379 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -9,7 +9,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; @@ -35,7 +35,7 @@ void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOE MetadataContent result = server.generateNegotiationProposePromptFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); @@ -55,7 +55,7 @@ void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOE MetadataContent result = server.generateNegotiationProposePromptFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), INFORMATION_PROPOSE); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); @@ -81,10 +81,7 @@ void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { .getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) .isPresent()); assertFalse(server - .getNegotiationPrompt(TemplateUri.of( - StandardTemplates.NEGOTIATION_EXTENSION_NAME, - TemplateUri.DEFAULT_TEMPLATE_VERSION, - "unknown-negotiation", + .getNegotiationPrompt(TemplateUri.of(StandardTemplates.NEGOTIATION_EXTENSION_NAME, "unknown-negotiation", "propose")) .isPresent()); PromptTemplate template = server.getNegotiationPrompt(INFORMATION_PROPOSE).orElseThrow(); diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index f15cf37f..4c321c7f 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -14,7 +14,7 @@ import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InfoProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; @@ -86,7 +86,7 @@ void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOE MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( new NegotiationProposeData( new NegotiationContext(UUID, 1, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE_TEMPLATE); assertEquals(INFORMATION_PROPOSE_URI, negotiationMessage.templateUri()); assertTrue(negotiationMessage.promptText().contains("- id: " + UUID)); @@ -106,7 +106,7 @@ void oldNegotiationStateMachineKeepsWorkingNextToTheContentLayerApis() throws IO MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( new NegotiationProposeData( new NegotiationContext(UUID, 2, 5), - new InfoProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE_TEMPLATE); assertTrue(negotiationMessage.promptText().contains("- round: 2")); diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 656849f7..5697b0dd 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -421,7 +421,7 @@ The content layer is stateless. It deliberately does not own a session state mac All thirteen methods exist on both `A2ATClient` and `A2ATServer` with identical signatures and semantics. -Every method that identifies a template takes the value type `net.openan.a2at.sdk.core.model.TemplateUri` instead of a raw string. A `TemplateUri` is always well-formed — its constructor validates the extension name, path segments, and version — so malformed URIs cannot reach these APIs. Build one with `TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose")`, or better, use the constants in `StandardTemplates` from the same package (for example `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`, whose `uri()` is `Negotiation-T/information-negotiation/propose/v1`). When a URI string arrives from outside the code, `TemplateUri.parse(String)` returns an `Optional` and never throws. +Every method that identifies a template takes the value type `net.openan.a2at.sdk.core.model.TemplateUri` instead of a raw string. A `TemplateUri` is always well-formed — its constructor validates the extension name, path segments, and version — so malformed URIs cannot reach these APIs. Build one with `TemplateUri.of("Negotiation-T", "information-negotiation", "propose")`, or better, use the constants in `StandardTemplates` from the same package (for example `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`, whose `uri()` is `Negotiation-T/information-negotiation/propose/v1`). When a URI string arrives from outside the code, `TemplateUri.parse(String)` returns an `Optional` and never throws. **Generation from typed data — deterministic, never calls an LLM:** @@ -508,7 +508,7 @@ All types live in `net.openan.a2at.sdk.negotiation.content`. | Negotiation type | Propose content | Ending content | |--|--|--| -| Information | `InfoProposeContent(List items, String relationship)` | `InfoEndingContent(conclusion, items)` | +| Information | `InformationProposeContent(List items, String relationship)` | `InformationEndingContent(conclusion, items)` | | Target | `TargetProposeContent(description, intentUnderstanding, alignmentAndClarification, requestForClarification)` | `TargetEndingContent(conclusion, confirmedIntent, failureReason)` | | Feasibility | `FeasibilityProposeContent(description, action, contentsToEvaluate, infeasibilityDetailsAndProposal)` | `FeasibilityEndingContent(conclusion, feasibilitySummary)` | @@ -576,7 +576,7 @@ import net.openan.a2at.sdk.server.A2ATServer; A2ATClient client = new A2ATClient(Path.of("client.env")); NegotiationContext context = NegotiationContext.of("neg-0001-uuid", 1); -InfoProposeContent content = new InfoProposeContent( +InformationProposeContent content = new InformationProposeContent( List.of( new NegotiationItem("subscription_condition.incident_level", "critical or warning"), new NegotiationItem("notification_data.format", "DataPart or raw JSON")), @@ -611,7 +611,7 @@ try { } // --- Server side: accept and return the requested information --- -InfoEndingContent ending = new InfoEndingContent( +InformationEndingContent ending = new InformationEndingContent( NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("subscription_condition.incident_level", "critical"))); diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 036c541b..508eb6be 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -404,7 +404,7 @@ mvn -pl a2a-t-sample -am -DskipTests package 以下十三个方法在 `A2ATClient` 和 `A2ATServer` 上具有完全相同的签名和语义。 -所有用于标识模板的参数均采用值类型 `net.openan.a2at.sdk.core.model.TemplateUri`,而非裸字符串。`TemplateUri` 恒为格式正确 — 其构造器会校验扩展名、路径段与版本 — 因此格式错误的 URI 不可能进入这些 API。可用 `TemplateUri.of("Negotiation-T", "v1", "information-negotiation", "propose")` 构造,更推荐直接使用同包 `StandardTemplates` 中的常量(例如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`,其 `uri()` 为 `Negotiation-T/information-negotiation/propose/v1`)。当 URI 字符串来自代码之外时,`TemplateUri.parse(String)` 返回 `Optional` 且绝不抛出异常。 +所有用于标识模板的参数均采用值类型 `net.openan.a2at.sdk.core.model.TemplateUri`,而非裸字符串。`TemplateUri` 恒为格式正确 — 其构造器会校验扩展名、路径段与版本 — 因此格式错误的 URI 不可能进入这些 API。可用 `TemplateUri.of("Negotiation-T", "information-negotiation", "propose")` 构造,更推荐直接使用同包 `StandardTemplates` 中的常量(例如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`,其 `uri()` 为 `Negotiation-T/information-negotiation/propose/v1`)。当 URI 字符串来自代码之外时,`TemplateUri.parse(String)` 返回 `Optional` 且绝不抛出异常。 **从类型化数据生成 — 确定性执行,绝不调用 LLM:** @@ -491,7 +491,7 @@ Negotiation-T/{type}-negotiation/{phase}/v1 | 协商类型 | Propose 内容 | Ending 内容 | |--|--|--| -| Information | `InfoProposeContent(List items, String relationship)` | `InfoEndingContent(conclusion, items)` | +| Information | `InformationProposeContent(List items, String relationship)` | `InformationEndingContent(conclusion, items)` | | Target | `TargetProposeContent(description, intentUnderstanding, alignmentAndClarification, requestForClarification)` | `TargetEndingContent(conclusion, confirmedIntent, failureReason)` | | Feasibility | `FeasibilityProposeContent(description, action, contentsToEvaluate, infeasibilityDetailsAndProposal)` | `FeasibilityEndingContent(conclusion, feasibilitySummary)` | @@ -559,7 +559,7 @@ import net.openan.a2at.sdk.server.A2ATServer; A2ATClient client = new A2ATClient(Path.of("client.env")); NegotiationContext context = NegotiationContext.of("neg-0001-uuid", 1); -InfoProposeContent content = new InfoProposeContent( +InformationProposeContent content = new InformationProposeContent( List.of( new NegotiationItem("subscription_condition.incident_level", "critical or warning"), new NegotiationItem("notification_data.format", "DataPart or raw JSON")), @@ -594,7 +594,7 @@ try { } // --- 服务端:接受协商并返回所请求的信息 --- -InfoEndingContent ending = new InfoEndingContent( +InformationEndingContent ending = new InformationEndingContent( NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("subscription_condition.incident_level", "critical"))); From f858089215d736e519bedf9f660903428124fcd4 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 02:24:25 +0800 Subject: [PATCH 081/295] refactor(core,negotiation,prompt): register stray error codes, consolidate duplicate value types and align nullness contracts Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 24 ++++++ ...ultClientPromptGenerationOrchestrator.java | 41 +++++----- ...lientPromptGenerationOrchestratorTest.java | 10 +-- .../sdk/core/exception/A2ATErrorCodes.java | 18 +++++ .../sdk/core/exception/FailedParameter.java | 12 --- .../exception/PromptGenerationException.java | 9 ++- .../core/exception/A2ATErrorCodesTest.java | 14 +++- .../NegotiationProcessingException.java | 6 +- ...otiationGenerationOrchestratorBuilder.java | 3 +- .../NegotiationJsonSchemaBuilder.java | 57 ------------- .../generation/NegotiationPromptRenderer.java | 8 +- .../resources/NegotiationReference.java | 22 +---- .../DefaultNegotiationSemanticValidator.java | 81 +++++++++++++++++-- .../NegotiationComplianceChecker.java | 14 +--- .../validation/NegotiationParamSchemas.java | 38 --------- .../validation/SemanticSchemaBuilder.java | 30 ------- ...otiationDeliveredResourceContractTest.java | 6 ++ .../NegotiationPackageStructureTest.java | 2 - .../NegotiationJsonSchemaBuilderTest.java | 57 ------------- ...faultNegotiationSemanticValidatorTest.java | 64 ++++++++++++++- .../validation/ParamSchemasTest.java | 49 ----------- .../DropBlankSlotSectionRenderer.java | 2 +- .../SectionedTemplateRenderer.java | 8 +- .../taskrendering/TaskPromptRenderer.java | 5 +- .../validation/DefaultContentValidator.java | 4 +- .../validation/DefaultSemanticValidator.java | 4 +- .../LlmBackedPromptMetadataExtractor.java | 21 ++--- .../LlmBackedPromptSemanticValidator.java | 7 +- 28 files changed, 267 insertions(+), 349 deletions(-) delete mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/FailedParameter.java delete mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationParamSchemas.java delete mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticSchemaBuilder.java delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamSchemasTest.java diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 4e4ffd29..a305ea01 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -75,6 +75,10 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code + * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, + * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the + * prompt fails */ public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); @@ -90,6 +94,10 @@ public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code + * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, + * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the + * prompt fails */ public MetadataContent generateTaskPromptFromDataWithSchema( @NonNull Map data, @@ -111,6 +119,10 @@ public MetadataContent generateTaskPromptFromDataWithSchema( * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code + * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, + * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the + * prompt fails */ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); @@ -130,6 +142,10 @@ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code + * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, + * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the + * prompt fails */ public MetadataContent generateAuthPromptFromDataWithSchema( @NonNull Map data, @@ -147,6 +163,10 @@ public MetadataContent generateAuthPromptFromDataWithSchema( * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code + * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, + * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the + * prompt fails */ public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); @@ -162,6 +182,10 @@ public MetadataContent generateNotificationPromptFromText(@NonNull String text, * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code + * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, + * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the + * prompt fails */ public MetadataContent generateNotificationPromptFromDataWithSchema( @NonNull Map data, diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java index a742f2a5..e05d385d 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -6,6 +6,8 @@ import java.util.Objects; import java.util.stream.Collectors; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.client.model.PromptGenerationFailure; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; @@ -13,7 +15,6 @@ import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.FailedParameter; import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; @@ -96,7 +97,7 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { recognition = scenarioRecognizer.recognize(normalizedInput, scenarios, systemPrompt, userPrompt); } catch (ResourceNotFoundException error) { return PromptGenerationResult.failure( - new PromptGenerationFailure("prompt_resource_load_error", error.getMessage(), "generation")); + new PromptGenerationFailure(A2ATErrorCodes.PROMPT_RESOURCE_LOAD_ERROR, error.getMessage(), "generation")); } if (!recognition.matched() || recognition.scenarioCode() == null @@ -112,7 +113,7 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { templateText = templateLoader.loadTemplate(recognition.scenarioCode(), language); } catch (ResourceNotFoundException error) { return PromptGenerationResult.failure( - new PromptGenerationFailure("template_not_found", error.getMessage(), "generation")); + new PromptGenerationFailure(A2ATErrorCodes.TEMPLATE_NOT_FOUND, error.getMessage(), "generation")); } try { @@ -122,7 +123,7 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { return PromptGenerationResult.success(renderedPrompt); } catch (TaskPromptRenderException error) { return PromptGenerationResult.failure( - new PromptGenerationFailure("render_failed", error.getMessage(), "generation")); + new PromptGenerationFailure(A2ATErrorCodes.RENDER_FAILED, error.getMessage(), "generation")); } } @@ -169,24 +170,24 @@ private MetadataContent generateFromTemplateUriWithMetadata( try { templateText = templateLoader.loadTemplate(templateIdentifier, language); } catch (ResourceNotFoundException e) { - throw new PromptGenerationException("template_not_found", e.getMessage(), e); + throw new PromptGenerationException(A2ATErrorCodes.TEMPLATE_NOT_FOUND, e.getMessage(), e); } catch (A2ATError e) { - throw new PromptGenerationException("prompt_resource_load_error", e.getMessage(), e); + throw new PromptGenerationException(A2ATErrorCodes.PROMPT_RESOURCE_LOAD_ERROR, e.getMessage(), e); } final Map slots; try { slots = slotValueExtractor.extractSlots(userInput, templateIdentifier, language, templateText); } catch (ResourceNotFoundException e) { - throw new PromptGenerationException("slot_schema_not_found", e.getMessage(), e); + throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, e.getMessage(), e); } catch (A2ATError e) { - throw new PromptGenerationException("llm_invocation_failed", e.getMessage(), e); + throw new PromptGenerationException(A2ATErrorCodes.LLM_INVOCATION_FAILED, e.getMessage(), e); } validateRequiredSlots(slots, templateIdentifier); final String renderedPrompt; try { renderedPrompt = renderer.render(templateText, slots); } catch (TaskPromptRenderException e) { - throw new PromptGenerationException("render_failed", e.getMessage(), e); + throw new PromptGenerationException(A2ATErrorCodes.RENDER_FAILED, e.getMessage(), e); } return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); } @@ -202,25 +203,25 @@ private MetadataContent generateFromDataWithSchema( try { templateText = templateLoader.loadTemplate(templateIdentifier, language); } catch (ResourceNotFoundException e) { - throw new PromptGenerationException("template_not_found", e.getMessage(), e); + throw new PromptGenerationException(A2ATErrorCodes.TEMPLATE_NOT_FOUND, e.getMessage(), e); } catch (A2ATError e) { - throw new PromptGenerationException("prompt_resource_load_error", e.getMessage(), e); + throw new PromptGenerationException(A2ATErrorCodes.PROMPT_RESOURCE_LOAD_ERROR, e.getMessage(), e); } final Map slots; try { slots = slotValueExtractor.extractSlotsWithSchema( data, templateIdentifier, language, templateText, schema); } catch (ResourceNotFoundException e) { - throw new PromptGenerationException("slot_schema_not_found", e.getMessage(), e); + throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, e.getMessage(), e); } catch (A2ATError e) { - throw new PromptGenerationException("llm_invocation_failed", e.getMessage(), e); + throw new PromptGenerationException(A2ATErrorCodes.LLM_INVOCATION_FAILED, e.getMessage(), e); } validateRequiredSlots(slots, templateIdentifier); final String renderedPrompt; try { renderedPrompt = renderer.render(templateText, slots); } catch (TaskPromptRenderException e) { - throw new PromptGenerationException("render_failed", e.getMessage(), e); + throw new PromptGenerationException(A2ATErrorCodes.RENDER_FAILED, e.getMessage(), e); } return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); } @@ -230,11 +231,11 @@ private void validateRequiredSlots(Map slots, String templateIde try { schema = slotSchemaLoader.loadSlotSchema(templateIdentifier, language); } catch (A2ATError e) { - throw new PromptGenerationException("slot_schema_not_found", e.getMessage(), e); + throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, e.getMessage(), e); } if (schema == null) { throw new PromptGenerationException( - "slot_schema_not_found", "Slot schema not found for template: " + templateIdentifier); + A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, "Slot schema not found for template: " + templateIdentifier); } List defs = schema.slotDefinitions(); if (defs == null) { @@ -243,7 +244,7 @@ private void validateRequiredSlots(Map slots, String templateIde if (slots == null) { slots = Map.of(); } - List failed = new ArrayList<>(); + List failed = new ArrayList<>(); for (PromptSlotDefinition def : defs) { if (def == null) { continue; @@ -255,14 +256,14 @@ private void validateRequiredSlots(Map slots, String templateIde } String value = slots.get(name); if (value == null || value.trim().isEmpty()) { - failed.add(new FailedParameter(name, "missing_required")); + failed.add(new SlotValidationError(name, "missing_required", "Required slot is missing or empty")); } } } if (!failed.isEmpty()) { - throw new PromptGenerationException("slot_validation_error", + throw new PromptGenerationException(A2ATErrorCodes.SLOT_VALIDATION_ERROR, "Required slots are missing or empty: " + failed.stream() - .map(FailedParameter::parameterName).collect(Collectors.joining(", ")), + .map(SlotValidationError::slotName).collect(Collectors.joining(", ")), failed); } } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 20d68bee..f4d043f1 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -9,13 +9,13 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.FailedParameter; import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; @@ -592,10 +592,10 @@ void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { PromptGenerationException ex = assertThrows(PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("slot_validation_error", ex.getCode()); - List failed = ex.failedParameters(); + List failed = ex.failedParameters(); assertFalse(failed.isEmpty()); - assertEquals("site", failed.get(0).parameterName()); - assertEquals("missing_required", failed.get(0).reason()); + assertEquals("site", failed.get(0).slotName()); + assertEquals("missing_required", failed.get(0).code()); } @Test @@ -616,7 +616,7 @@ void validateRequiredSlotsThrowsSlotValidationErrorWhenMultipleRequiredSlotsMiss () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("slot_validation_error", ex.getCode()); assertEquals(1, ex.failedParameters().size()); - assertEquals("target", ex.failedParameters().get(0).parameterName()); + assertEquals("target", ex.failedParameters().get(0).slotName()); } @Test diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java index de04cd05..ba450359 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java @@ -52,4 +52,22 @@ public final class A2ATErrorCodes { /** A referenced prompt resource could not be resolved. */ public static final String VALIDATION_PROMPT_RESOURCE_NOT_FOUND = "validation_prompt_resource_not_found"; + + /** A prompt resource could not be loaded from its configured source. */ + public static final String PROMPT_RESOURCE_LOAD_ERROR = "prompt_resource_load_error"; + + /** The slot schema describing the expected slots could not be resolved. */ + public static final String SLOT_SCHEMA_NOT_FOUND = "slot_schema_not_found"; + + /** An LLM invocation failed at the infrastructure level. */ + public static final String LLM_INVOCATION_FAILED = "llm_invocation_failed"; + + /** Rendering a template with its slot values failed. */ + public static final String RENDER_FAILED = "render_failed"; + + /** The slot values extracted or provided for a template failed validation. */ + public static final String SLOT_VALIDATION_ERROR = "slot_validation_error"; + + /** A processed prompt could not be parsed into its structured parts. */ + public static final String PROCESSED_PROMPT_PARSE_ERROR = "processed_prompt_parse_error"; } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/FailedParameter.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/FailedParameter.java deleted file mode 100644 index a2459697..00000000 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/FailedParameter.java +++ /dev/null @@ -1,12 +0,0 @@ -package net.openan.a2at.sdk.core.exception; - -import org.jspecify.annotations.NonNull; - -/** - * Describes a single failed required slot during slot validation. - * - * @param parameterName the name of the required slot that failed validation - * @param reason the failure reason, e.g. {@code missing_required} - * @since 2026-08 - */ -public record FailedParameter(@NonNull String parameterName, @NonNull String reason) {} \ No newline at end of file diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java index 297f80e3..d98b3c14 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.core.exception; import java.util.List; +import net.openan.a2at.sdk.core.model.SlotValidationError; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -13,7 +14,7 @@ */ public final class PromptGenerationException extends A2ATError { - private final List failedParameters; + private final List failedParameters; /** * Creates an exception with a stable error code and a human-readable message. @@ -33,11 +34,11 @@ public PromptGenerationException(@NonNull String code, String message) { * * @param code stable error code * @param message human-readable failure description - * @param failedParameters list of failed parameters + * @param failedParameters list of failed slot validations * @throws NullPointerException if {@code code} is null */ public PromptGenerationException( - @NonNull String code, String message, @Nullable List failedParameters) { + @NonNull String code, String message, @Nullable List failedParameters) { super(code, message); this.failedParameters = failedParameters == null ? List.of() : List.copyOf(failedParameters); } @@ -61,7 +62,7 @@ public PromptGenerationException(@NonNull String code, String message, @Nullable * * @return failed parameters */ - public @NonNull List failedParameters() { + public @NonNull List failedParameters() { return failedParameters; } } diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java index 1fe18d27..bdc1dd18 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java @@ -23,16 +23,16 @@ class A2ATErrorCodesTest { /** - * Verifies that {@link A2ATErrorCodes} declares exactly the fourteen expected error code constants with the expected + * Verifies that {@link A2ATErrorCodes} declares exactly the twenty expected error code constants with the expected * values. * *

Scenario: Reflection inspects all declared static final String fields of the registry. Expected result: The - * field set contains exactly the fourteen known constants and no others. + * field set contains exactly the twenty known constants and no others. * * @throws IllegalAccessException if a declared field cannot be read */ @Test - void should_declareExactlyFourteenConstants_When_reflectingOverDeclaredFields() throws IllegalAccessException { + void should_declareExactlyTwentyConstants_When_reflectingOverDeclaredFields() throws IllegalAccessException { Map constants = new TreeMap<>(); for (Field field : A2ATErrorCodes.class.getDeclaredFields()) { if (field.getType() == String.class @@ -58,7 +58,13 @@ void should_declareExactlyFourteenConstants_When_reflectingOverDeclaredFields() Map.entry("VALIDATION_RULE_VIOLATION", "validation_rule_violation"), Map.entry("VALIDATION_SEMANTIC_REJECTED", "validation_semantic_rejected"), Map.entry("VALIDATION_LLM_INFRASTRUCTURE_ERROR", "validation_llm_infrastructure_error"), - Map.entry("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", "validation_prompt_resource_not_found")), + Map.entry("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", "validation_prompt_resource_not_found"), + Map.entry("PROMPT_RESOURCE_LOAD_ERROR", "prompt_resource_load_error"), + Map.entry("SLOT_SCHEMA_NOT_FOUND", "slot_schema_not_found"), + Map.entry("LLM_INVOCATION_FAILED", "llm_invocation_failed"), + Map.entry("RENDER_FAILED", "render_failed"), + Map.entry("SLOT_VALIDATION_ERROR", "slot_validation_error"), + Map.entry("PROCESSED_PROMPT_PARSE_ERROR", "processed_prompt_parse_error")), constants); } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java index 23d2ad66..7de5ae8a 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java @@ -3,10 +3,12 @@ import net.openan.a2at.sdk.core.exception.A2ATError; /** - * Base failure type raised when a negotiation processing step fails at runtime. + * Base failure type of the generation branch, raised when a negotiation generation step fails at runtime. * *

Each failure carries a machine-readable error code from {@code A2ATErrorCodes} — inherited from the - * {@link A2ATError} root — so callers can branch on the failure class without parsing messages. + * {@link A2ATError} root — so callers can branch on the failure class without parsing messages. The + * parameter-extraction branch ({@code NegotiationParamExtractionException}) is deliberately not a subtype; catch + * {@link A2ATError} for full negotiation failure coverage. * * @since 2026-06 */ diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index 3d60cbbd..923ac21d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -208,7 +208,6 @@ private NegotiationSemanticValidator defaultSemanticValidator() { "Semantic validation requires an LLM client but none is configured."); }; } - NegotiationJsonSchemaBuilder schemaBuilder = new NegotiationJsonSchemaBuilder(); - return new DefaultNegotiationSemanticValidator(llmClient, schemaBuilder::buildSemanticValidationSchema); + return new DefaultNegotiationSemanticValidator(llmClient); } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java index 365a171e..590d405b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java @@ -24,9 +24,6 @@ final class NegotiationJsonSchemaBuilder { private static final List ACTION_ENUM = List.of("REQUEST_FEASIBILITY_EVALUATION", "PROPOSE_ALTERNATIVE_ON_FAILURE"); - private static final List NEGOTIATION_TYPE_ENUM = - Arrays.asList("information", "target", "feasibility", null); - /** * Builds the content extraction schema of one (negotiation type, phase) pair. * @@ -49,35 +46,6 @@ public Map buildExtractionSchema(NegotiationType type, Negotiati }; } - /** - * Builds the merged schema of the semantic validation step around a caller-provided parameter schema. - * - *

The merged schema requires exactly the four keys {@code semantic_verdict}, {@code negotiation_type}, - * {@code errors} and {@code params} and allows no additional properties. The caller schema is embedded as the - * {@code params} property; a caller schema without a {@code type} keyword is wrapped as an object schema first. - * - * @param callerSchema parameter schema provided by the caller of the validation API - * @return merged JSON Schema of the semantic validation LLM call - * @throws NullPointerException if the caller schema is null - */ - public Map buildSemanticValidationSchema(Map callerSchema) { - Objects.requireNonNull(callerSchema, "Caller parameter schema must not be null."); - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - Map properties = new LinkedHashMap<>(); - properties.put("semantic_verdict", Map.of("type", "boolean")); - Map negotiationType = new LinkedHashMap<>(); - negotiationType.put("type", List.of("string", "null")); - negotiationType.put("enum", NEGOTIATION_TYPE_ENUM); - properties.put("negotiation_type", negotiationType); - properties.put("errors", errorsSchema()); - properties.put("params", wrapCallerSchema(callerSchema)); - schema.put("properties", properties); - schema.put("required", List.of("semantic_verdict", "negotiation_type", "errors", "params")); - schema.put("additionalProperties", false); - return schema; - } - private static Map informationProposeSchema() { Map properties = new LinkedHashMap<>(); properties.put("items", itemArraySchema()); @@ -128,31 +96,6 @@ private static Map feasibilityEndingSchema() { return objectSchema(properties, List.of("conclusion", "feasibility_summary")); } - private static Map errorsSchema() { - Map errorProperties = new LinkedHashMap<>(); - errorProperties.put("slot_name", Map.of("type", "string")); - errorProperties.put("code", Map.of("type", "string")); - errorProperties.put("message", Map.of("type", "string")); - Map errorItem = new LinkedHashMap<>(); - errorItem.put("type", "object"); - errorItem.put("properties", errorProperties); - errorItem.put("required", List.of("slot_name", "code", "message")); - Map errors = new LinkedHashMap<>(); - errors.put("type", "array"); - errors.put("items", errorItem); - return errors; - } - - private static Map wrapCallerSchema(Map callerSchema) { - if (callerSchema.containsKey("type")) { - return callerSchema; - } - Map wrapped = new LinkedHashMap<>(); - wrapped.put("type", "object"); - wrapped.putAll(callerSchema); - return wrapped; - } - private static Map itemArraySchema() { Map schema = new LinkedHashMap<>(); schema.put("type", "array"); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java index 73a6614b..3e195aeb 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java @@ -16,8 +16,9 @@ * trailing newline. * *

The rendering itself delegates to the shared {@link DropBlankSlotSectionRenderer} of the prompt kernel, which - * owns the drop policy of the sectioned template grammar; this class keeps the negotiation-specific null-template - * contract. + * owns the drop policy of the sectioned template grammar. Unlike the interface default, this adapter rejects a + * null template text with the internal {@link NegotiationRenderException} instead of a {@code NullPointerException}, + * so the orchestration layer wraps the failure into a typed negotiation generation failure rather than letting it leak. * * @since 2026-06 */ @@ -32,7 +33,8 @@ class NegotiationPromptRenderer implements SectionedTemplateRenderer { * @param slots slot values keyed by the language-specific slot name; a null or blank value drops the slot section * @return rendered message text with sections joined by one blank line and no trailing newline; empty string when * no section remains - * @throws NegotiationRenderException if the template text is null + * @throws NegotiationRenderException if the template text is null; the orchestration layer wraps this + * internal failure into a typed negotiation generation failure instead of letting it leak */ @Override public String render(String templateText, Map slots) { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java index 952f3b15..20794d81 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -81,27 +81,7 @@ public String uri() { public static Optional tryParse( @Nullable String templateUri, @NonNull NegotiationPhase expectedPhase, String language) { Objects.requireNonNull(expectedPhase, "Expected negotiation phase must not be null."); - if (templateUri == null || templateUri.isBlank()) { - return Optional.empty(); - } - String[] segments = templateUri.strip().split("/"); - if (segments.length != 4) { - return Optional.empty(); - } - if (!URI_PREFIX.equals(segments[0])) { - return Optional.empty(); - } - NegotiationType parsedType = parseTypeSegment(segments[1]); - if (parsedType == null) { - return Optional.empty(); - } - if (!phaseSegmentMatches(segments[2], expectedPhase)) { - return Optional.empty(); - } - if (!URI_VERSION_SEGMENT.equals(segments[3])) { - return Optional.empty(); - } - return Optional.of(new NegotiationReference(parsedType, expectedPhase, language)); + return TemplateUri.parse(templateUri).flatMap(uri -> fromTemplateUri(uri, expectedPhase, language)); } /** diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java index 0a635a15..eb5d93c2 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java @@ -11,7 +11,9 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Arrays; import java.util.Objects; +import java.util.function.UnaryOperator; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.SlotValidationError; @@ -64,21 +66,88 @@ public final class DefaultNegotiationSemanticValidator implements NegotiationSem private final LLMClient llmClient; - private final SemanticSchemaBuilder schemaBuilder; + private static final List NEGOTIATION_TYPE_ENUM = + Arrays.asList("information", "target", "feasibility", null); + + private final UnaryOperator> schemaBuilder; + + /** + * Creates the default semantic validator merging the caller schema with the built-in semantic validation output + * contract. + * + * @param llmClient LLM client used for the single structured call + * @throws NullPointerException if the LLM client is null + */ + public DefaultNegotiationSemanticValidator(LLMClient llmClient) { + this(llmClient, DefaultNegotiationSemanticValidator::buildSemanticValidationSchema); + } /** - * Creates the default semantic validator. + * Creates the default semantic validator with a custom schema-merging collaborator. * * @param llmClient LLM client used for the single structured call - * @param schemaBuilder collaborator merging the caller schema into the semantic validation output contract; the - * orchestrator builder wires the generation-package schema builder to this seam + * @param schemaBuilder collaborator merging the caller schema into the semantic validation output contract * @throws NullPointerException if the LLM client or the schema builder is null */ - public DefaultNegotiationSemanticValidator(LLMClient llmClient, SemanticSchemaBuilder schemaBuilder) { + DefaultNegotiationSemanticValidator(LLMClient llmClient, UnaryOperator> schemaBuilder) { this.llmClient = Objects.requireNonNull(llmClient, "llmClient"); this.schemaBuilder = Objects.requireNonNull(schemaBuilder, "schemaBuilder"); } + /** + * Merges the caller-provided parameter schema into the semantic validation output contract. + * + *

The merged schema requires exactly the four keys {@code semantic_verdict}, {@code negotiation_type}, + * {@code errors} and {@code params} and allows no additional properties. The caller schema is embedded as the + * {@code params} property; a caller schema without a {@code type} keyword is wrapped as an object schema first. + * + * @param callerSchema parameter schema provided by the caller of the validation API + * @return merged JSON Schema of the semantic validation LLM call + * @throws NullPointerException if the caller schema is null + */ + static Map buildSemanticValidationSchema(Map callerSchema) { + Objects.requireNonNull(callerSchema, "Caller parameter schema must not be null."); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + Map properties = new LinkedHashMap<>(); + properties.put("semantic_verdict", Map.of("type", "boolean")); + Map negotiationType = new LinkedHashMap<>(); + negotiationType.put("type", List.of("string", "null")); + negotiationType.put("enum", NEGOTIATION_TYPE_ENUM); + properties.put("negotiation_type", negotiationType); + properties.put("errors", errorsSchema()); + properties.put("params", wrapCallerSchema(callerSchema)); + schema.put("properties", properties); + schema.put("required", List.of("semantic_verdict", "negotiation_type", "errors", "params")); + schema.put("additionalProperties", false); + return schema; + } + + private static Map errorsSchema() { + Map errorProperties = new LinkedHashMap<>(); + errorProperties.put("slot_name", Map.of("type", "string")); + errorProperties.put("code", Map.of("type", "string")); + errorProperties.put("message", Map.of("type", "string")); + Map errorItem = new LinkedHashMap<>(); + errorItem.put("type", "object"); + errorItem.put("properties", errorProperties); + errorItem.put("required", List.of("slot_name", "code", "message")); + Map errors = new LinkedHashMap<>(); + errors.put("type", "array"); + errors.put("items", errorItem); + return errors; + } + + private static Map wrapCallerSchema(Map callerSchema) { + if (callerSchema.containsKey("type")) { + return callerSchema; + } + Map wrapped = new LinkedHashMap<>(); + wrapped.put("type", "object"); + wrapped.putAll(callerSchema); + return wrapped; + } + /** * Validates one rendered negotiation message semantically and extracts its parameters in a single LLM call. * @@ -99,7 +168,7 @@ public SemanticValidationResult validateNegotiation( Objects.requireNonNull(prompt, "prompt"); Objects.requireNonNull(reference, "reference"); List> messages = buildMessages(prompt, callerSchema, reference); - Map mergedSchema = schemaBuilder.buildSemanticValidationSchema(callerSchema); + Map mergedSchema = schemaBuilder.apply(callerSchema); LLMResponse response; try { response = llmClient.structured(messages, mergedSchema, null, null); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java index cddb8290..846fbad3 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java @@ -1,7 +1,5 @@ package net.openan.a2at.sdk.negotiation.validation; -import java.util.Map; -import net.openan.a2at.sdk.core.validation.RuleChecker; import net.openan.a2at.sdk.negotiation.content.Vocabulary; /** @@ -16,7 +14,7 @@ * * @since 2026-06 */ -public interface NegotiationComplianceChecker extends RuleChecker { +public interface NegotiationComplianceChecker { /** * Runs the rule-level compliance check of one rendered message. @@ -30,14 +28,4 @@ public interface NegotiationComplianceChecker extends RuleChecker { */ NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary); - /** - * {@inheritDoc} - * - *

This default implementation is not supported; use {@link #check(String, Vocabulary)} instead. The - * {@link NegotiationRuleCheckerAdapter} bridges this interface to the {@link RuleChecker} contract. - */ - @Override - default Map check(String prompt) { - throw new UnsupportedOperationException("Use check(String, Vocabulary) instead"); - } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationParamSchemas.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationParamSchemas.java deleted file mode 100644 index b38a3e34..00000000 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationParamSchemas.java +++ /dev/null @@ -1,38 +0,0 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * Built-in parameter schemas for negotiation parameter extraction. - * - *

Callers must always provide a parameter schema explicitly; the schema built here is a convenience constant for the - * plain negotiation context parameters and remains open for additional properties, so callers may tighten or replace it - * freely. - * - * @since 2026-06 - */ -public final class NegotiationParamSchemas { - - private NegotiationParamSchemas() {} - - /** - * Builds the built-in negotiation parameter schema. - * - * @return schema describing an object with the string property id and the integer properties round and maxRounds, - * allowing additional properties - */ - public static Map buildNegotiationParamSchema() { - Map properties = new LinkedHashMap<>(); - properties.put("id", Map.of("type", "string")); - properties.put("round", Map.of("type", "integer")); - properties.put("maxRounds", Map.of("type", "integer")); - - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", Collections.unmodifiableMap(properties)); - schema.put("additionalProperties", true); - return Collections.unmodifiableMap(schema); - } -} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticSchemaBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticSchemaBuilder.java deleted file mode 100644 index 9801f3a0..00000000 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticSchemaBuilder.java +++ /dev/null @@ -1,30 +0,0 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.Map; - -/** - * Builds the merged JSON schema used for the semantic validation structured LLM call. - * - *

The merged schema is the semantic validation output contract: an object with the four required keys - * {@code semantic_verdict} (boolean), {@code negotiation_type} (string or null, enum information, target, feasibility, - * null), {@code errors} (array of objects with the required keys slot_name, code, message) and {@code params} (the - * caller-provided schema embedded verbatim, wrapped as an object when it lacks a type), with no additional properties - * allowed. - * - *

This interface is the validation-package seam for the schema builder of the generation package: the orchestrator - * builder wires the generation-package implementation to it, for example via the method reference - * {@code negotiationJsonSchemaBuilder::buildSemanticValidationSchema}. - * - * @since 2026-06 - */ -@FunctionalInterface -public interface SemanticSchemaBuilder { - - /** - * Merges the caller-provided parameter schema into the semantic validation output contract. - * - * @param callerSchema caller-provided parameter JSON schema - * @return merged JSON schema for the semantic validation structured LLM call - */ - Map buildSemanticValidationSchema(Map callerSchema); -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java index 657e68ae..20e662e2 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java @@ -80,6 +80,12 @@ void errorCodeConstantsStayExactlyThePinnedRegistry() { expected.put("VALIDATION_SEMANTIC_REJECTED", "validation_semantic_rejected"); expected.put("VALIDATION_LLM_INFRASTRUCTURE_ERROR", "validation_llm_infrastructure_error"); expected.put("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", "validation_prompt_resource_not_found"); + expected.put("PROMPT_RESOURCE_LOAD_ERROR", "prompt_resource_load_error"); + expected.put("SLOT_SCHEMA_NOT_FOUND", "slot_schema_not_found"); + expected.put("LLM_INVOCATION_FAILED", "llm_invocation_failed"); + expected.put("RENDER_FAILED", "render_failed"); + expected.put("SLOT_VALIDATION_ERROR", "slot_validation_error"); + expected.put("PROCESSED_PROMPT_PARSE_ERROR", "processed_prompt_parse_error"); java.util.Map actual = new java.util.LinkedHashMap<>(); for (java.lang.reflect.Field field : A2ATErrorCodes.class.getDeclaredFields()) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java index 46dc803f..0701b808 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -98,13 +98,11 @@ void validationPackageExposesExactlyThePinnedTypes() throws IOException { "DefaultNegotiationComplianceChecker.java", "DefaultNegotiationSemanticValidator.java", "NegotiationComplianceChecker.java", - "NegotiationParamSchemas.java", "NegotiationRuleCheckResult.java", "NegotiationRuleCheckerAdapter.java", "NegotiationSemanticValidator.java", "NegotiationValidationException.java", "ParamExtractor.java", - "SemanticSchemaBuilder.java", "SemanticValidationResult.java", "package-info.java"), topLevelJavaFiles(negotiationRoot().resolve("validation"))); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java index 39acb222..ea17c899 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java @@ -126,61 +126,4 @@ void rejectsNullTypeAndPhase() { .getMessage()); } - @Test - @SuppressWarnings("unchecked") - void semanticValidationSchemaMergesTheCallerSchemaIntoAFourKeyContract() { - Map callerSchema = - Map.of("type", "object", "properties", Map.of("energy_rate", Map.of("type", "number"))); - - Map schema = builder.buildSemanticValidationSchema(callerSchema); - - assertEquals("object", schema.get("type")); - Map properties = (Map) schema.get("properties"); - assertEquals( - List.of("semantic_verdict", "negotiation_type", "errors", "params"), List.copyOf(properties.keySet())); - assertEquals(List.of("semantic_verdict", "negotiation_type", "errors", "params"), schema.get("required")); - assertEquals(Boolean.FALSE, schema.get("additionalProperties")); - - assertEquals(Map.of("type", "boolean"), properties.get("semantic_verdict")); - - Map negotiationType = (Map) properties.get("negotiation_type"); - assertEquals(List.of("string", "null"), negotiationType.get("type")); - List typeEnum = (List) negotiationType.get("enum"); - assertEquals(4, typeEnum.size()); - assertTrue(typeEnum.contains("information")); - assertTrue(typeEnum.contains("target")); - assertTrue(typeEnum.contains("feasibility")); - assertTrue(typeEnum.contains(null)); - - Map errors = (Map) properties.get("errors"); - assertEquals("array", errors.get("type")); - Map errorItem = (Map) errors.get("items"); - assertEquals( - List.of("slot_name", "code", "message"), - List.copyOf(((Map) errorItem.get("properties")).keySet())); - assertEquals(List.of("slot_name", "code", "message"), errorItem.get("required")); - - assertEquals(callerSchema, properties.get("params")); - } - - @Test - void semanticValidationSchemaWrapsCallerSchemaWithoutTypeKeyword() { - Map callerSchema = new java.util.LinkedHashMap<>(); - callerSchema.put("properties", Map.of("id", Map.of("type", "string"))); - - Map schema = builder.buildSemanticValidationSchema(callerSchema); - - Map params = (Map) ((Map) schema.get("properties")).get("params"); - assertEquals("object", params.get("type")); - assertEquals(Map.of("id", Map.of("type", "string")), params.get("properties")); - assertNull(params.get("additionalProperties")); - } - - @Test - void rejectsNullCallerSchema() { - assertEquals( - "Caller parameter schema must not be null.", - assertThrows(NullPointerException.class, () -> builder.buildSemanticValidationSchema(null)) - .getMessage()); - } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java index 4f81e1b3..88054364 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java @@ -32,7 +32,7 @@ class DefaultNegotiationSemanticValidatorTest { private final RecordingLLMClient llmClient = new RecordingLLMClient(); private final DefaultNegotiationSemanticValidator validator = - new DefaultNegotiationSemanticValidator(llmClient, schemaBuilder); + new DefaultNegotiationSemanticValidator(llmClient, schemaBuilder::buildSemanticValidationSchema); private final NegotiationReference informationReference = new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "en-US"); @@ -236,14 +236,70 @@ public LLMResponse structured( } } - private static final class RecordingSchemaBuilder implements SemanticSchemaBuilder { + private static final class RecordingSchemaBuilder { private Map lastCallerSchema; - @Override public Map buildSemanticValidationSchema(Map callerSchema) { lastCallerSchema = callerSchema; return Map.of("merged", true); } + @Test + @SuppressWarnings("unchecked") + void semanticValidationSchemaMergesTheCallerSchemaIntoAFourKeyContract() { + Map callerSchema = + Map.of("type", "object", "properties", Map.of("energy_rate", Map.of("type", "number"))); + + Map schema = DefaultNegotiationSemanticValidator.buildSemanticValidationSchema(callerSchema); + + assertEquals("object", schema.get("type")); + Map properties = (Map) schema.get("properties"); + assertEquals( + List.of("semantic_verdict", "negotiation_type", "errors", "params"), List.copyOf(properties.keySet())); + assertEquals(List.of("semantic_verdict", "negotiation_type", "errors", "params"), schema.get("required")); + assertEquals(Boolean.FALSE, schema.get("additionalProperties")); + + assertEquals(Map.of("type", "boolean"), properties.get("semantic_verdict")); + + Map negotiationType = (Map) properties.get("negotiation_type"); + assertEquals(List.of("string", "null"), negotiationType.get("type")); + List typeEnum = (List) negotiationType.get("enum"); + assertEquals(4, typeEnum.size()); + assertTrue(typeEnum.contains("information")); + assertTrue(typeEnum.contains("target")); + assertTrue(typeEnum.contains("feasibility")); + assertTrue(typeEnum.contains(null)); + + Map errors = (Map) properties.get("errors"); + assertEquals("array", errors.get("type")); + Map errorItem = (Map) errors.get("items"); + assertEquals( + List.of("slot_name", "code", "message"), + List.copyOf(((Map) errorItem.get("properties")).keySet())); + assertEquals(List.of("slot_name", "code", "message"), errorItem.get("required")); + + assertEquals(callerSchema, properties.get("params")); + } + + @Test + void semanticValidationSchemaWrapsCallerSchemaWithoutTypeKeyword() { + Map callerSchema = new java.util.LinkedHashMap<>(); + callerSchema.put("properties", Map.of("id", Map.of("type", "string"))); + + Map schema = DefaultNegotiationSemanticValidator.buildSemanticValidationSchema(callerSchema); + + Map params = (Map) ((Map) schema.get("properties")).get("params"); + assertEquals("object", params.get("type")); + assertEquals(Map.of("id", Map.of("type", "string")), params.get("properties")); + assertNull(params.get("additionalProperties")); + } + + @Test + void rejectsNullCallerSchema() { + assertEquals( + "Caller parameter schema must not be null.", + assertThrows(NullPointerException.class, () -> DefaultNegotiationSemanticValidator.buildSemanticValidationSchema(null)) + .getMessage()); } -} +} +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamSchemasTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamSchemasTest.java deleted file mode 100644 index 3e8d4f36..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamSchemasTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class ParamSchemasTest { - - @Test - void builtSchemaMatchesThePinnedStructureExactly() { - Map expectedProperties = new LinkedHashMap<>(); - expectedProperties.put("id", Map.of("type", "string")); - expectedProperties.put("round", Map.of("type", "integer")); - expectedProperties.put("maxRounds", Map.of("type", "integer")); - - Map expectedSchema = new LinkedHashMap<>(); - expectedSchema.put("type", "object"); - expectedSchema.put("properties", expectedProperties); - expectedSchema.put("additionalProperties", true); - - assertEquals(expectedSchema, NegotiationParamSchemas.buildNegotiationParamSchema()); - } - - @Test - void builtSchemaSerializesToThePinnedBytes() throws Exception { - String serialized = - new ObjectMapper().writeValueAsString(NegotiationParamSchemas.buildNegotiationParamSchema()); - - assertEquals( - "{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"}," - + "\"round\":{\"type\":\"integer\"},\"maxRounds\":{\"type\":\"integer\"}}," - + "\"additionalProperties\":true}", - serialized); - } - - @Test - void builtSchemaKeepsThePinnedKeyOrder() { - Map schema = NegotiationParamSchemas.buildNegotiationParamSchema(); - - assertEquals(List.of("type", "properties", "additionalProperties"), new ArrayList<>(schema.keySet())); - assertEquals( - List.of("id", "round", "maxRounds"), new ArrayList<>(((Map) schema.get("properties")).keySet())); - } -} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/DropBlankSlotSectionRenderer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/DropBlankSlotSectionRenderer.java index fd5a1966..3c7533dc 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/DropBlankSlotSectionRenderer.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/DropBlankSlotSectionRenderer.java @@ -40,7 +40,7 @@ public final class DropBlankSlotSectionRenderer implements SectionedTemplateRend @Override public String render(String templateText, Map slots) { if (templateText == null) { - throw new IllegalArgumentException("Template text must not be null."); + throw new NullPointerException("Template text must not be null."); } Map safeSlots = slots == null ? Map.of() : slots; List
sections = splitSections(templateText); diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/SectionedTemplateRenderer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/SectionedTemplateRenderer.java index 6f6e3a3b..abe7bc55 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/SectionedTemplateRenderer.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/SectionedTemplateRenderer.java @@ -1,6 +1,8 @@ package net.openan.a2at.sdk.prompt.taskrendering; import java.util.Map; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** * Renders a sectioned prompt template by filling slot values under one blank-slot policy. @@ -29,8 +31,10 @@ public interface SectionedTemplateRenderer { * Renders one sectioned template text with the given slot values. * * @param templateText full template text whose sections are filled according to the implementation's policy - * @param slots slot values keyed by slot name; how null or blank values are handled depends on the policy + * @param slots slot values keyed by slot name; how null values or a null map are handled depends on the policy * @return rendered prompt text + * @throws NullPointerException if the template text is null; an adapter that sits behind an internal + * error-wrapping boundary may instead raise its internal failure type */ - String render(String templateText, Map slots); + @NonNull String render(@NonNull String templateText, @Nullable Map slots); } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java index cd476639..096c3abd 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java @@ -32,6 +32,7 @@ public final class TaskPromptRenderer implements SectionedTemplateRenderer { @Override public String render(String templateText, Map slots) { Objects.requireNonNull(templateText, "Template text must not be null."); + Map safeSlots = slots == null ? Map.of() : slots; if (!balancedBraces(templateText)) { throw new TaskPromptRenderException("Template text has unbalanced braces."); } @@ -40,10 +41,10 @@ public String render(String templateText, Map slots) { StringBuffer rendered = new StringBuffer(); while (matcher.find()) { String slotName = matcher.group(1).trim(); - if (!slots.containsKey(slotName)) { + if (!safeSlots.containsKey(slotName)) { throw new TaskPromptRenderException("Unknown slot referenced by template: " + slotName); } - String replacement = slots.get(slotName); + String replacement = safeSlots.get(slotName); matcher.appendReplacement(rendered, Matcher.quoteReplacement(Optional.ofNullable(replacement).orElse(""))); } matcher.appendTail(rendered); diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java index cc24131c..721c2fcc 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -40,7 +40,9 @@ public final class DefaultContentValidator implements ContentValidator { * @param extensionName extension name used for template URI validation * @param language language code for prompt resource loading * @param maxAttempts maximum retry attempts for semantic validation - * @param llmClient LLM client for semantic validation; may be {@code null} and set later + * @param llmClient LLM client for semantic validation; may be {@code null}, in which case the first + * {@link #validate} call fails with {@code ContentValidationException} carrying + * {@code VALIDATION_LLM_INFRASTRUCTURE_ERROR}; there is no late injection point * @param promptResourceAccess prompt resource access for loading validation prompts */ public DefaultContentValidator( diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index 82891d99..96a2d78d 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -44,7 +44,9 @@ final class DefaultSemanticValidator implements SemanticValidator { /** * Creates a semantic validator backed by the given LLM client and prompt resources for the specified language. * - * @param llmClient LLM client for structured calls; may be {@code null} and set later + * @param llmClient LLM client for structured calls; may be {@code null}, in which case {@link #validate} + * fails with {@code ContentValidationException} carrying + * {@code VALIDATION_LLM_INFRASTRUCTURE_ERROR}; there is no late injection point * @param language language code for prompt resource loading * @param promptResourceAccess prompt resource access for loading validation prompts * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if prompt resources are missing diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java index 993c31ff..32c65f77 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.stream.Collectors; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.prompt.analysis.exception.ScenarioRecognitionException; import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; @@ -79,13 +80,13 @@ private ScenarioRecognitionResult resolveScenario(String processedPromptText) { scenarioRecognizer.recognize(processedPromptText, scenarios, systemPrompt, userPrompt); if (!result.matched() || result.scenarioCode() == null || result.scenarioCode().isBlank()) { throw new PromptComplianceCheckException( - "processed_prompt_parse_error", + A2ATErrorCodes.PROCESSED_PROMPT_PARSE_ERROR, result.errorMessage() == null ? "Scenario recognition failed." : result.errorMessage(), "prompt_parse"); } return result; } catch (ScenarioRecognitionException error) { - throw new PromptComplianceCheckException("processed_prompt_parse_error", error.getMessage(), "prompt_parse"); + throw new PromptComplianceCheckException(A2ATErrorCodes.PROCESSED_PROMPT_PARSE_ERROR, error.getMessage(), "prompt_parse"); } } @@ -93,7 +94,7 @@ private String loadTemplate(String scenarioCode) { try { return templateLoader.loadTemplate(scenarioCode, language); } catch (ResourceNotFoundException error) { - throw new PromptComplianceCheckException("template_not_found", error.getMessage(), "generation"); + throw new PromptComplianceCheckException(A2ATErrorCodes.TEMPLATE_NOT_FOUND, error.getMessage(), "generation"); } } @@ -101,7 +102,7 @@ private static void validateExtractionResult( StructuredSlotExtractionResult extractionResult, PromptSlotSchema slotSchema) { if (!extractionResult.slotErrors().isEmpty()) { throw new PromptComplianceCheckException( - "slot_validation_error", + A2ATErrorCodes.SLOT_VALIDATION_ERROR, extractionResult.slotErrors().stream() .map(StructuredSlotValidationError::message) .filter(message -> message != null && !message.isBlank()) @@ -113,7 +114,7 @@ private static void validateExtractionResult( String value = extractionResult.slots().get(definition.name()); if (definition.required() && (value == null || value.isBlank())) { throw new PromptComplianceCheckException( - "slot_validation_error", + A2ATErrorCodes.SLOT_VALIDATION_ERROR, "Required slot '" + definition.name() + "' is missing.", "slot_validation"); } @@ -124,7 +125,7 @@ private static void validateExtractionResult( && !definition.allowedValues().isEmpty() && !definition.allowedValues().contains(value.trim())) { throw new PromptComplianceCheckException( - "slot_validation_error", + A2ATErrorCodes.SLOT_VALIDATION_ERROR, "Slot '" + definition.name() + "' violates allowed values.", "slot_validation"); } @@ -132,7 +133,7 @@ private static void validateExtractionResult( && !definition.pattern().isBlank() && !value.trim().matches(definition.pattern())) { throw new PromptComplianceCheckException( - "slot_validation_error", + A2ATErrorCodes.SLOT_VALIDATION_ERROR, "Slot '" + definition.name() + "' violates pattern constraint.", "slot_validation"); } @@ -148,19 +149,19 @@ private static void validateNumericConstraint(PromptSlotDefinition definition, S double numericValue = Double.parseDouble(value); if (definition.minimum() != null && numericValue < definition.minimum()) { throw new PromptComplianceCheckException( - "slot_validation_error", + A2ATErrorCodes.SLOT_VALIDATION_ERROR, "Slot '" + definition.name() + "' is smaller than minimum.", "slot_validation"); } if (definition.maximum() != null && numericValue > definition.maximum()) { throw new PromptComplianceCheckException( - "slot_validation_error", + A2ATErrorCodes.SLOT_VALIDATION_ERROR, "Slot '" + definition.name() + "' is larger than maximum.", "slot_validation"); } } catch (NumberFormatException error) { throw new PromptComplianceCheckException( - "slot_validation_error", + A2ATErrorCodes.SLOT_VALIDATION_ERROR, "Slot '" + definition.name() + "' is not numeric.", "slot_validation"); } diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java index 0c31473e..8d125b6c 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java @@ -8,6 +8,7 @@ import java.util.Map; import java.util.Optional; import net.openan.a2at.sdk.core.model.PromptMessage; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; @@ -74,7 +75,7 @@ private String buildUserPrompt(PromptSlotSchema slotSchema, Map + "\n}"; } catch (JsonProcessingException error) { throw new PromptComplianceCheckException( - "slot_validation_error", "Failed to serialize slot schema", "slot_validation"); + A2ATErrorCodes.SLOT_VALIDATION_ERROR, "Failed to serialize slot schema", "slot_validation"); } } @@ -118,7 +119,7 @@ private void validateResponse(String payload) { Optional message = extractFirstMessage(errorsValue); throw new PromptComplianceCheckException( - "slot_validation_error", + A2ATErrorCodes.SLOT_VALIDATION_ERROR, message.filter(text -> !text.isBlank()).orElse("Slot semantic validation failed."), "slot_validation"); } @@ -130,7 +131,7 @@ private static Map parseResponse(String payload) { return Optional.ofNullable(response).orElseGet(Map::of); } catch (JsonProcessingException error) { throw new PromptComplianceCheckException( - "slot_validation_error", "semantic validation returned invalid JSON", "slot_validation"); + A2ATErrorCodes.SLOT_VALIDATION_ERROR, "semantic validation returned invalid JSON", "slot_validation"); } } From 3a7a0c5de656a2fabe359c73b5ce24b301199af6 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 08:32:55 +0800 Subject: [PATCH 082/295] refactor(core,prompt,negotiation): switch PromptTemplate.uri to TemplateUri value type Signed-off-by: codetalks-hw --- .../api/A2ATClientNegotiationApiTest.java | 2 +- .../a2at/sdk/core/model/PromptTemplate.java | 10 ++-- .../DefaultNegotiationTemplateLoader.java | 3 +- .../resources/NegotiationReference.java | 13 +++++ .../generation/NegotiationGeneratorsTest.java | 51 ++++++++++--------- .../NegotiationResourceIntegrityTest.java | 8 +-- .../NegotiationTemplateQueryBoundaryTest.java | 15 +++--- .../CustomRootTemplateOverrideTest.java | 2 +- .../NegotiationLanguageSwitchTest.java | 13 ++--- .../NegotiationErrorCodeUsageMatrixTest.java | 2 +- .../DefaultNegotiationTemplateLoaderTest.java | 6 +-- .../catalog/PromptTemplateCatalog.java | 13 +++-- .../api/A2ATServerNegotiationApiTest.java | 2 +- docs/en/developer_guide.md | 2 +- ...00\345\217\221\346\214\207\345\215\227.md" | 2 +- 15 files changed, 84 insertions(+), 60 deletions(-) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index 893ad733..4eee6802 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -98,7 +98,7 @@ void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { .getNegotiationPrompt(TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v9")) .isPresent()); PromptTemplate template = client.getNegotiationPrompt(INFORMATION_PROPOSE).orElseThrow(); - assertEquals(INFORMATION_PROPOSE_URI, template.uri()); + assertEquals(INFORMATION_PROPOSE, template.templateUri()); assertFalse(template.content().isBlank()); } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java index da5b93a6..f56a0609 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java @@ -1,13 +1,17 @@ package net.openan.a2at.sdk.core.model; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + /** * One loadable prompt template of any A2A-T extension. * - * @param uri template URI such as {@code Task-T/network-layer/energy-saving/v1} or + * @param templateUri typed template URI such as {@code Task-T/network-layer/energy-saving/v1} or * {@code Negotiation-T/information-negotiation/propose/v1} * @param description template description taken from the leading HTML comment; empty string when the template has no * comment - * @param content full template file text + * @param content full template file text; null when the template content is unavailable * @since 2026-08 */ -public record PromptTemplate(String uri, String description, String content) {} +public record PromptTemplate( + @NonNull TemplateUri templateUri, @NonNull String description, @Nullable String content) {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java index ec807a87..d6a94a1a 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -80,7 +80,8 @@ public DefaultNegotiationTemplateLoader(String language, String localRootDir) { public PromptTemplate load(NegotiationReference reference) { String relativePath = templateRelativePath(reference); String content = readTemplate(relativePath); - PromptTemplate template = new PromptTemplate(reference.uri(), TemplateDescriptions.extract(content), content); + PromptTemplate template = + new PromptTemplate(reference.templateUri(), TemplateDescriptions.extract(content), content); LOGGER.atDebug().log("negotiation_template_loaded uri={} language={}", reference.uri(), reference.language()); return template; } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java index 20794d81..019fc382 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -64,6 +64,19 @@ public String uri() { return String.join("/", URI_PREFIX, typeSegment(), phase.uriSegment(), URI_VERSION_SEGMENT); } + /** + * Returns the typed template URI of the referenced template. + * + *

The exact inverse of {@link #fromTemplateUri(TemplateUri, NegotiationPhase, String)}: the URI is composed from + * the same extension-name constant and default version segment the parser accepts, so + * {@code fromTemplateUri(reference.templateUri(), reference.phase(), ...)} always addresses the same template. + * + * @return typed template URI such as {@code Negotiation-T/information-negotiation/propose/v1} + */ + public @NonNull TemplateUri templateUri() { + return TemplateUri.of(URI_PREFIX, List.of(typeSegment(), phase.uriSegment()), URI_VERSION_SEGMENT); + } + /** * Tries to parse a template URI into a reference, checking it against the expected phase. * diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java index 79b2bbfa..fe698931 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -7,6 +7,7 @@ import java.util.List; import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; @@ -165,7 +166,7 @@ void feasibilityRequestActionRendersExactlyTheEvaluationSection() { .generate( context(1), content, - template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), ZH_FEASIBILITY_PROPOSE_TEMPLATE), + template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary); assertTrue(rendered.contains("## 可行性协商\n请协助评估该节能目标能否达成。")); @@ -188,7 +189,7 @@ void feasibilityAlternativeActionRendersExactlyTheInfeasibilitySection() { .generate( context(2), content, - template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), ZH_FEASIBILITY_PROPOSE_TEMPLATE), + template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary); assertTrue(rendered.contains("## 可行性协商\n当前速率目标不可行,提出下调方案。")); @@ -208,7 +209,7 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { context(1), nullAction, template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)); assertTrue(nullActionError.getMessage().contains("action must not be null")); @@ -222,7 +223,7 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { context(1), emptyEvaluation, template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)); assertEquals( @@ -238,7 +239,7 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { context(1), emptyAlternative, template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)); assertEquals( @@ -255,7 +256,7 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { context(1), blankDescription, template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)) .getMessage()); @@ -273,7 +274,7 @@ void targetFirstRoundRendersIntentSectionAndDropsAlignmentSection() { .generate( context(1), content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), ZH_TARGET_PROPOSE_TEMPLATE), + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), zhVocabulary); assertTrue(rendered.contains("## 意图理解陈述\n1. 发起方理解:对方希望在体验无损的前提下降低节能力度")); @@ -294,7 +295,7 @@ void targetLaterRoundRendersAlignmentSectionAndDropsIntentSection() { .generate( context(3), content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), ZH_TARGET_PROPOSE_TEMPLATE), + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), zhVocabulary); assertTrue(rendered.contains("## 理解对齐与疑问澄清\n1. 确认结果:节能时间范围确认为08:00~18:00")); @@ -311,7 +312,7 @@ void targetBlankDescriptionIsRejected() { .generate( context(1), content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), ZH_TARGET_PROPOSE_TEMPLATE), + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), zhVocabulary)); assertEquals("Target negotiation description must not be blank.", exception.getMessage()); @@ -327,7 +328,7 @@ void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { .generate( context(1), withRelationship, - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), ZH_INFO_PROPOSE_TEMPLATE), + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), zhVocabulary); assertTrue(renderedZh.contains("## 所需信息项\n1. 故障发生时间:精确到分钟的时间点\n2. 受影响小区标识:CGI 或小区名称")); @@ -340,7 +341,7 @@ void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { .generate( context(1), withoutRelationship, - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), ZH_INFO_PROPOSE_TEMPLATE), + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), zhVocabulary); assertFalse(renderedWithout.contains("缺失项之间的关系")); @@ -353,7 +354,7 @@ void informationProposeRendersEnglishRelationshipLabelWithTrailingSpace() { "failure time and cell identity must correspond per cell"); PromptTemplate enTemplate = template( - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, """ ## Negotiation Context {{negotiation_context}} (required) @@ -378,7 +379,7 @@ void endingGeneratorsRenderConclusionLiterals() { new InformationEndingContent( NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30"))), - template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_INFO_ENDING_TEMPLATE), + template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), zhVocabulary); assertTrue(infoRendered.contains("## 信息协商结果\nAccept")); @@ -388,7 +389,7 @@ void endingGeneratorsRenderConclusionLiterals() { .generate( context(2), new TargetEndingContent(NegotiationConclusion.REJECT, null, "双方未就速率保障下限达成一致。"), - template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_TARGET_ENDING_TEMPLATE), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), zhVocabulary); assertTrue(targetRendered.contains("## 目标协商结果\nReject")); @@ -399,7 +400,7 @@ void endingGeneratorsRenderConclusionLiterals() { context(2), new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "同意将速率保障目标由5Mbps下调至2Mbps。"), template( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, ZH_FEASIBILITY_ENDING_TEMPLATE), zhVocabulary); @@ -413,20 +414,20 @@ void abortConclusionIsRejectedByEveryEndingGenerator() { .generate( context(1), new InformationEndingContent(NegotiationConclusion.ABORT, List.of()), - template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_INFO_ENDING_TEMPLATE), + template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), zhVocabulary)); assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() .generate( context(1), new TargetEndingContent(NegotiationConclusion.ABORT, "意图", null), - template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_TARGET_ENDING_TEMPLATE), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), zhVocabulary)); assertThrows(IllegalArgumentException.class, () -> new FeasibilityEndingGenerator() .generate( context(1), new FeasibilityEndingContent(NegotiationConclusion.ABORT, "结论"), template( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, ZH_FEASIBILITY_ENDING_TEMPLATE), zhVocabulary)); } @@ -439,7 +440,7 @@ void nullConclusionIsRejected() { context(1), new InformationEndingContent(null, List.of()), template( - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), zhVocabulary)); @@ -454,7 +455,7 @@ void targetEndingRequiresTheFieldMatchingTheConclusion() { context(1), new TargetEndingContent(NegotiationConclusion.ACCEPT, null, "失败原因"), template( - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_TARGET_ENDING_TEMPLATE), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), zhVocabulary)); assertEquals( @@ -467,7 +468,7 @@ void targetEndingRequiresTheFieldMatchingTheConclusion() { context(1), new TargetEndingContent(NegotiationConclusion.REJECT, " ", null), template( - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), ZH_TARGET_ENDING_TEMPLATE), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), zhVocabulary)); assertEquals( @@ -483,7 +484,7 @@ void feasibilityEndingRequiresTheSummary() { context(1), new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " "), template( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, ZH_FEASIBILITY_ENDING_TEMPLATE), zhVocabulary)); @@ -499,7 +500,7 @@ void generatorRejectsContentOfAnotherRuntimeType() { .generate( context(1), new TargetProposeContent("描述", null, null, null), - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), ZH_INFO_PROPOSE_TEMPLATE), + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), zhVocabulary)); assertTrue(exception.getMessage().contains("requires content of type InformationProposeContent")); @@ -533,7 +534,7 @@ private static NegotiationContext context(int round) { return new NegotiationContext(CONTEXT_ID, round, 5); } - private static PromptTemplate template(String uri, String content) { - return new PromptTemplate(uri, "", content); + private static PromptTemplate template(TemplateUri templateUri, String content) { + return new PromptTemplate(templateUri, "", content); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java index 093b08b4..122ebc4c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java @@ -93,16 +93,16 @@ void everySlotMarkerResolvesAgainstTheVocabularyOfItsLanguage(String language) { assertTrue( matcher.matches(), "the slot marker line of section " + section.title + " in " - + template.uri() + " must use the marker script of " + language + ": " + + template.templateUri().uri() + " must use the marker script of " + language + ": " + section.slotMarkerLine); String slotName = matcher.group(1); assertTrue( valuesToKeys.containsKey(slotName), - "the slot name " + slotName + " of " + template.uri() + " must be a vocabulary value of " + "the slot name " + slotName + " of " + template.templateUri().uri() + " must be a vocabulary value of " + language); assertTrue( valuesToKeys.containsKey(section.title), - "the section title " + section.title + " of " + template.uri() + "the section title " + section.title + " of " + template.templateUri().uri() + " must be a vocabulary value of " + language); allSlotNames.add(slotName); if (!slotName.equals(section.title)) { @@ -111,7 +111,7 @@ void everySlotMarkerResolvesAgainstTheVocabularyOfItsLanguage(String language) { assertTrue( section.bodyLines.stream().anyMatch(line -> line.equals(requirementsMarker)), "every slot section must keep its requirements line: " + section.title + " in " - + template.uri()); + + template.templateUri().uri()); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java index f9b802a6..62465188 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java @@ -72,11 +72,14 @@ void listQueryReturnsSixTemplatesInTheFixedOrder(String language) { assertEquals(6, templates.size()); assertEquals( - EXPECTED_URI_ORDER, templates.stream().map(PromptTemplate::uri).toList()); + EXPECTED_URI_ORDER, templates.stream().map(template -> template.templateUri().uri()).toList()); for (PromptTemplate template : templates) { - String typeSegment = template.uri().split("/")[1]; - assertFalse(typeSegment.contains("_"), "type segments must use hyphens: " + template.uri()); - assertTrue(typeSegment.endsWith("-negotiation"), "type segment must carry the suffix: " + template.uri()); + String typeSegment = template.templateUri().pathSegments().get(0); + assertFalse( + typeSegment.contains("_"), "type segments must use hyphens: " + template.templateUri().uri()); + assertTrue( + typeSegment.endsWith("-negotiation"), + "type segment must carry the suffix: " + template.templateUri().uri()); assertFalse(template.content().isBlank()); } } @@ -90,9 +93,7 @@ void singleQueryResolvesAValidUriIntoItsTemplate(String language) { orchestrator.getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE); assertTrue(template.isPresent()); - assertEquals( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), - template.orElseThrow().uri()); + assertEquals(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, template.orElseThrow().templateUri()); String contextTitle = "zh-CN".equals(language) ? "## 协商上下文" : "## Negotiation Context"; assertTrue(template.orElseThrow().content().contains(contextTitle)); assertTrue( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java index f2ed70b0..f3edc3af 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java @@ -97,7 +97,7 @@ void templateQueriesStillListEveryTemplateWhileTheOverrideIsPresent() throws IOE assertEquals(6, templates.size()); PromptTemplate overridden = templates.stream() - .filter(t -> t.uri().equals(GoldenCase.INFORMATION_PROPOSE.templateUri())) + .filter(t -> t.templateUri().uri().equals(GoldenCase.INFORMATION_PROPOSE.templateUri())) .findFirst() .orElseThrow(); assertTrue(overridden.content().contains(MARKER_LINE)); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java index 236e31f0..ffc39fc7 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java @@ -66,10 +66,11 @@ void templateQueriesReturnTheTemplatesOfTheConfiguredLanguage(String language) { assertEquals(6, templates.size()); for (PromptTemplate template : templates) { assertTrue( - template.content().contains(contextTitle), template.uri() + " must be a " + language + " template"); + template.content().contains(contextTitle), + template.templateUri().uri() + " must be a " + language + " template"); assertTrue( !template.content().contains(otherLanguageTitle), - template.uri() + " must not leak the other language"); + template.templateUri().uri() + " must not leak the other language"); } PromptTemplate queriedTemplate = @@ -86,15 +87,15 @@ void bothLanguagesAnswerQueriesWithDifferentTemplatesForTheSameUri() { .getNegotiationPrompt(INFORMATION_PROPOSE_URI) .orElseThrow(); - assertEquals(INFORMATION_PROPOSE_URI.uri(), zhCnTemplate.uri()); - assertEquals(INFORMATION_PROPOSE_URI.uri(), enUsTemplate.uri()); + assertEquals(INFORMATION_PROPOSE_URI, zhCnTemplate.templateUri()); + assertEquals(INFORMATION_PROPOSE_URI, enUsTemplate.templateUri()); assertNotEquals(zhCnTemplate.content(), enUsTemplate.content()); assertEquals( orchestrator(GoldenInputs.ZH_CN).getNegotiationPrompts().stream() - .map(PromptTemplate::uri) + .map(PromptTemplate::templateUri) .toList(), orchestrator(GoldenInputs.EN_US).getNegotiationPrompts().stream() - .map(PromptTemplate::uri) + .map(PromptTemplate::templateUri) .toList()); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java index 614a9573..178a412f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java @@ -329,7 +329,7 @@ private static NegotiationTemplateLoader nullContentTemplateLoader() { return new NegotiationTemplateLoader() { @Override public PromptTemplate load(NegotiationReference reference) { - return new PromptTemplate(reference.uri(), "broken template", null); + return new PromptTemplate(reference.templateUri(), "broken template", null); } @Override diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java index e9c2ad0b..2c799903 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java @@ -51,7 +51,7 @@ void loadReturnsFullTemplateContentFromTheClasspath() { PromptTemplate template = loader.load(new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "en-US")); - assertEquals(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), template.uri()); + assertEquals(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, template.templateUri()); assertTrue(template.content().startsWith("## Negotiation Context")); } @@ -120,7 +120,7 @@ void loadAllWithCustomRootStillListsEveryTemplate() throws IOException { assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(templates)); PromptTemplate overriddenTemplate = templates.get(2); - assertEquals(StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), overriddenTemplate.uri()); + assertEquals(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, overriddenTemplate.templateUri()); assertTrue(overriddenTemplate.content().contains("自定义目标内容")); } @@ -138,7 +138,7 @@ void missingLanguageThrowsOnLoadAndIsSkippedByLoadAll() { } private static List urisOf(List templates) { - return templates.stream().map(PromptTemplate::uri).toList(); + return templates.stream().map(template -> template.templateUri().uri()).toList(); } private static void writeTemplate(Path file, String content) throws IOException { diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java index d4db1451..45711215 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java @@ -112,9 +112,12 @@ public PromptTemplateCatalog(@NonNull String language, @Nullable String localRoo continue; } String content = entry.getValue(); - templates.add(new PromptTemplate(uri, TemplateDescriptions.extract(content), content)); + TemplateUri templateUri = TemplateUri.parse(uri) + .orElseThrow(() -> new IllegalStateException( + "Catalogable template URI could not be parsed: " + uri + " (path: " + path + ")")); + templates.add(new PromptTemplate(templateUri, TemplateDescriptions.extract(content), content)); } - templates.sort(Comparator.comparing(PromptTemplate::uri)); + templates.sort(Comparator.comparing(template -> template.templateUri().uri())); LOGGER.atDebug().log("prompt_templates_listed count={} language={}", templates.size(), language); return List.copyOf(templates); } @@ -129,11 +132,11 @@ public PromptTemplateCatalog(@NonNull String language, @Nullable String localRoo */ public Optional load(@NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); - String uri = templateUri.uri(); - String relativePath = String.join("/", TEMPLATE_DIRECTORY, uri, language, TEMPLATE_FILE_NAME); + String relativePath = String.join( + "/", TEMPLATE_DIRECTORY, templateUri.uri(), language, TEMPLATE_FILE_NAME); try { String content = readTemplate(relativePath); - return Optional.of(new PromptTemplate(uri, TemplateDescriptions.extract(content), content)); + return Optional.of(new PromptTemplate(templateUri, TemplateDescriptions.extract(content), content)); } catch (A2ATError exception) { return Optional.empty(); } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index 6eaf6379..307d51db 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -85,7 +85,7 @@ void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { "propose")) .isPresent()); PromptTemplate template = server.getNegotiationPrompt(INFORMATION_PROPOSE).orElseThrow(); - assertEquals(INFORMATION_PROPOSE_URI, template.uri()); + assertEquals(INFORMATION_PROPOSE, template.templateUri()); assertFalse(template.content().isBlank()); } diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 5697b0dd..bb213c01 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -468,7 +468,7 @@ List getNegotiationPrompts() // negotiation templates of the c Optional getNegotiationPrompt(TemplateUri templateUri) // one negotiation template by URI; empty on miss ``` -A missing template yields an empty result with a warning log; because the argument is a validated `TemplateUri`, an unusable URI cannot occur, and a null argument raises `NullPointerException`. `PromptTemplate` is a record `(uri, description, content)`. The generic pair discovers the extension directories from the bundled resource tree itself — a template directory added under `prompt_resources/templates/` (for example a future `Authorization-T/`) is listed automatically without code changes. Local templates under the configured local resource root override built-in templates of the same path. Note that the SDK does not yet bundle Authorization-T template resources; `generateAuthPromptFromText` / `generateAuthPromptFromDataWithSchema` answer `template_not_found` under the classpath source until such templates are added or provided through the local resource root. +A missing template yields an empty result with a warning log; because the argument is a validated `TemplateUri`, an unusable URI cannot occur, and a null argument raises `NullPointerException`. `PromptTemplate` is a record `(templateUri, description, content)` whose first component is the typed `TemplateUri` value; `content` is null only when a template record is constructed without available content. The generic pair discovers the extension directories from the bundled resource tree itself — a template directory added under `prompt_resources/templates/` (for example a future `Authorization-T/`) is listed automatically without code changes. Local templates under the configured local resource root override built-in templates of the same path. Note that the SDK does not yet bundle Authorization-T template resources; `generateAuthPromptFromText` / `generateAuthPromptFromDataWithSchema` answer `template_not_found` under the classpath source until such templates are added or provided through the local resource root. ### 1.10.3 Template URIs and Custom Templates diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 508eb6be..251a5e58 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -451,7 +451,7 @@ List getNegotiationPrompts() // 当前配置语言下的全部 Optional getNegotiationPrompt(TemplateUri templateUri) // 按 URI 加载单个协商模板;未命中返回空 ``` -模板缺失时返回空结果并记录 warning 日志;由于入参是已通过校验的 `TemplateUri`,不存在 URI 不可用的情形,null 入参则抛出 `NullPointerException`。`PromptTemplate` 是一个记录类型 `(uri, description, content)`。泛化查询对直接从内置资源树中发现扩展目录 — 在 `prompt_resources/templates/` 下新增的模板目录(例如未来的 `Authorization-T/`)会被自动列出,无需代码改动。配置的本地资源根目录下的模板会覆盖同路径的内置模板。注意:SDK 尚未打包 Authorization-T 模板资源,`generateAuthPromptFromText` / `generateAuthPromptFromDataWithSchema` 在 classpath 源下会返回 `template_not_found`,直至补充该类模板或通过本地资源根目录提供。 +模板缺失时返回空结果并记录 warning 日志;由于入参是已通过校验的 `TemplateUri`,不存在 URI 不可用的情形,null 入参则抛出 `NullPointerException`。`PromptTemplate` 是一个记录类型 `(templateUri, description, content)`,其第一个组件是类型化的 `TemplateUri` 值;`content` 仅在无可加载内容构造模板记录时为 null。泛化查询对直接从内置资源树中发现扩展目录 — 在 `prompt_resources/templates/` 下新增的模板目录(例如未来的 `Authorization-T/`)会被自动列出,无需代码改动。配置的本地资源根目录下的模板会覆盖同路径的内置模板。注意:SDK 尚未打包 Authorization-T 模板资源,`generateAuthPromptFromText` / `generateAuthPromptFromDataWithSchema` 在 classpath 源下会返回 `template_not_found`,直至补充该类模板或通过本地资源根目录提供。 ### 1.10.3 模板 URI 与自定义模板 From 40028bb705cde2ccce2a29ec58c4e771456e8f14 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 08:48:43 +0800 Subject: [PATCH 083/295] refactor(negotiation,resources): file-driven negotiation vocabulary as single source of truth Signed-off-by: codetalks-hw --- .../sdk/negotiation/content/Vocabulary.java | 439 ++++++++++-------- ...otiationGenerationOrchestratorBuilder.java | 12 +- .../negotiation/content/VocabularyTest.java | 378 +++++++++------ .../CustomRootVocabularyOverrideTest.java | 95 ++++ .../en-US/vocabulary.json | 35 ++ .../zh-CN/vocabulary.json | 35 ++ docs/en/developer_guide.md | 6 +- ...00\345\217\221\346\214\207\345\215\227.md" | 6 +- tools/template_lint.py | 158 ++++--- 9 files changed, 789 insertions(+), 375 deletions(-) create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootVocabularyOverrideTest.java create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java index 68a32dd3..1ee6b0c5 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java @@ -1,181 +1,258 @@ -package net.openan.a2at.sdk.negotiation.content; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; - -/** - * Language-specific text constants for negotiation templates. - * - *

A vocabulary is the single source of the section titles, slot names, appended line labels and list punctuation - * used when rendering negotiation messages and when recognising sections inside received messages. The canonical keys - * are language-neutral; every supported language exposes exactly the same key set, while the values match the bundled - * template bytes of that language verbatim. - * - * @since 2026-06 - */ -public final class Vocabulary { - - private static final String ZH_CN = "zh-CN"; - - private static final String EN_US = "en-US"; - - private static final Vocabulary ZH_CN_VOCABULARY = new Vocabulary(ZH_CN, buildZhCnEntries()); - - private static final Vocabulary EN_US_VOCABULARY = new Vocabulary(EN_US, buildEnUsEntries()); - - private final String language; - - private final Map entries; - - private Vocabulary(String language, Map entries) { - this.language = language; - this.entries = entries; - } - - /** - * Returns the vocabulary for one language. - * - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @return vocabulary holding the text constants of that language - * @throws IllegalArgumentException if the language has no bundled vocabulary - */ - public static Vocabulary forLanguage(String language) { - if (ZH_CN.equals(language)) { - return ZH_CN_VOCABULARY; - } - if (EN_US.equals(language)) { - return EN_US_VOCABULARY; - } - throw new IllegalArgumentException( - "Unsupported negotiation vocabulary language " + language + "; supported languages are zh-CN and" - + " en-US, configure A2AT_LANGUAGE accordingly."); - } - - /** - * Returns the text constant registered under one canonical key. - * - * @param canonicalKey canonical vocabulary key such as {@code section.context} or {@code punct.list_colon} - * @return language-specific text constant - * @throws IllegalArgumentException if the key is not part of the vocabulary - */ - public String get(String canonicalKey) { - String value = entries.get(canonicalKey); - if (value == null) { - throw new IllegalArgumentException( - "Unknown negotiation vocabulary key " + canonicalKey + " for language " + language + "."); - } - return value; - } - - /** - * Returns all canonical keys exposed by this vocabulary. - * - * @return immutable set of canonical keys, identical for every supported language - */ - public Set canonicalKeys() { - return entries.keySet(); - } - - /** - * Returns the language this vocabulary is bound to. - * - * @return locale identifier such as {@code zh-CN} - */ - public String language() { - return language; - } - - private static Map buildZhCnEntries() { - return buildEntries(List.of( - entry("section.context", "协商上下文"), - entry("section.info_items", "所需信息项"), - entry("section.info_static", "信息协商"), - entry("section.info_conclusion", "信息协商结果"), - entry("section.info_result_content", "信息协商结果内容"), - entry("section.target", "目标协商"), - entry("section.target_intent", "意图理解陈述"), - entry("section.target_alignment", "理解对齐与疑问澄清"), - entry("section.target_clarification", "待澄清内容"), - entry("section.target_conclusion", "目标协商结果"), - entry("section.target_result_content", "目标协商结果内容"), - entry("section.feasibility", "可行性协商"), - entry("section.feasibility_evaluate", "待评估内容说明"), - entry("section.feasibility_infeasible", "评估不可行时的详情和提案"), - entry("section.feasibility_conclusion", "可行性协商结果"), - entry("section.feasibility_confirm", "可行性评估结果确认"), - entry("slot.context", "协商上下文"), - entry("slot.info_items", "所需信息项"), - entry("slot.info_conclusion", "信息协商结果"), - entry("slot.info_result_content", "信息协商结果内容"), - entry("slot.target", "目标协商概述"), - entry("slot.target_intent", "意图理解陈述"), - entry("slot.target_alignment", "理解对齐与疑问澄清"), - entry("slot.target_clarification", "待澄清内容"), - entry("slot.target_conclusion", "目标协商结果"), - entry("slot.target_result_content", "目标协商结果内容"), - entry("slot.feasibility", "可行性协商概述"), - entry("slot.feasibility_evaluate", "待评估内容说明"), - entry("slot.feasibility_infeasible", "评估不可行时的详情和提案"), - entry("slot.feasibility_conclusion", "可行性协商结果"), - entry("slot.feasibility_confirm", "评估结果确认"), - entry("label.relationship", "缺失项之间的关系:"), - entry("punct.list_colon", ":"))); - } - - private static Map buildEnUsEntries() { - // section.* values are the markdown ## section titles verbatim from the bundled templates; slot.* values are - // the {{...}} placeholder names. For en-US the upstream templates use snake_case placeholders that differ from - // the English section titles, so slot.* and section.* are distinct keys. For zh-CN the placeholders are the - // same CJK strings as the section titles (except the three summary/confirm slots), so most slot.* values repeat - // the section.* values by design. - return buildEntries(List.of( - entry("section.context", "Negotiation Context"), - entry("section.info_items", "Required Information Items"), - entry("section.info_static", "Information Negotiation"), - entry("section.info_conclusion", "Information Negotiation Result"), - entry("section.info_result_content", "Information Negotiation Result Content"), - entry("section.target", "Target Negotiation"), - entry("section.target_intent", "Intent Understanding Statement"), - entry("section.target_alignment", "Understanding Alignment and Clarification"), - entry("section.target_clarification", "Content to Clarify"), - entry("section.target_conclusion", "Target Negotiation Result"), - entry("section.target_result_content", "Target Negotiation Result Content"), - entry("section.feasibility", "Feasibility Negotiation"), - entry("section.feasibility_evaluate", "Under Evaluation Description"), - entry("section.feasibility_infeasible", "Infeasible Evaluation Details and Proposal"), - entry("section.feasibility_conclusion", "Feasibility Negotiation Result"), - entry("section.feasibility_confirm", "Feasibility Assessment Result Confirmation"), - entry("slot.context", "negotiation_context"), - entry("slot.info_items", "required_information_items"), - entry("slot.info_conclusion", "information_negotiation_result"), - entry("slot.info_result_content", "information_negotiation_result_content"), - entry("slot.target", "target_negotiation_summary"), - entry("slot.target_intent", "intent_understanding_statement"), - entry("slot.target_alignment", "understanding_alignment_and_clarification"), - entry("slot.target_clarification", "content_to_clarify"), - entry("slot.target_conclusion", "target_negotiation_result"), - entry("slot.target_result_content", "target_negotiation_result_content"), - entry("slot.feasibility", "feasibility_negotiation_summary"), - entry("slot.feasibility_evaluate", "under_evaluation_description"), - entry("slot.feasibility_infeasible", "infeasible_evaluation_details_and_proposal"), - entry("slot.feasibility_conclusion", "feasibility_negotiation_result"), - entry("slot.feasibility_confirm", "evaluation_result_confirmation"), - entry("label.relationship", "Relationship between missing items: "), - entry("punct.list_colon", ": "))); - } - - private static Map buildEntries(List flatEntries) { - Map built = new LinkedHashMap<>(); - for (String[] flatEntry : flatEntries) { - built.put(flatEntry[0], flatEntry[1]); - } - return Collections.unmodifiableMap(built); - } - - private static String[] entry(String key, String value) { - return new String[] {key, value}; - } -} +package net.openan.a2at.sdk.negotiation.content; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.core.resources.PathSegments; +import org.jspecify.annotations.Nullable; + +/** + * Language-specific text constants for negotiation templates. + * + *

A vocabulary is the single source of the section titles, slot names, appended line labels and list punctuation + * used when rendering negotiation messages and when recognising sections inside received messages. The canonical keys + * are language-neutral; every supported language exposes exactly the same key set, while the values match the bundled + * template bytes of that language verbatim. + * + *

The values are file-driven: each language resolves {@code negotiation-vocabulary/{language}/vocabulary.json} + * from a dual-root fallback — a file under the optional local resource root wins, otherwise the built-in classpath + * resource is used. Both origins are validated against the pinned {@link #CANONICAL_KEYS} set, and a vocabulary that + * exists nowhere, cannot be read, is malformed or drifts from the canonical key set fails fast instead of silently + * degrading. + * + * @since 2026-06 + */ +public final class Vocabulary { + + private static final String VOCABULARY_DIRECTORY = "negotiation-vocabulary"; + + private static final String VOCABULARY_FILE_NAME = "vocabulary.json"; + + private static final String CLASSPATH_ROOT = "prompt_resources/" + VOCABULARY_DIRECTORY + "/"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + /** The language-neutral canonical key set every vocabulary file must define exactly, in a fixed order. */ + public static final List CANONICAL_KEYS = List.of( + "section.context", + "section.info_items", + "section.info_static", + "section.info_conclusion", + "section.info_result_content", + "section.target", + "section.target_intent", + "section.target_alignment", + "section.target_clarification", + "section.target_conclusion", + "section.target_result_content", + "section.feasibility", + "section.feasibility_evaluate", + "section.feasibility_infeasible", + "section.feasibility_conclusion", + "section.feasibility_confirm", + "slot.context", + "slot.info_items", + "slot.info_conclusion", + "slot.info_result_content", + "slot.target", + "slot.target_intent", + "slot.target_alignment", + "slot.target_clarification", + "slot.target_conclusion", + "slot.target_result_content", + "slot.feasibility", + "slot.feasibility_evaluate", + "slot.feasibility_infeasible", + "slot.feasibility_conclusion", + "slot.feasibility_confirm", + "label.relationship", + "punct.list_colon"); + + private static final Set CANONICAL_KEY_SET = Set.copyOf(CANONICAL_KEYS); + + private final String language; + + private final Map entries; + + private Vocabulary(String language, Map entries) { + this.language = language; + this.entries = entries; + } + + /** + * Returns the vocabulary for one language, resolved from the built-in classpath resources. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @return vocabulary holding the text constants of that language + * @throws IllegalArgumentException if the language has no bundled vocabulary + */ + public static Vocabulary forLanguage(String language) { + return forLanguage(language, null); + } + + /** + * Returns the vocabulary for one language, resolved with the dual-root fallback. + * + *

A vocabulary file that exists under {@code {localRootDir}/negotiation-vocabulary/{language}/vocabulary.json} + * wins; otherwise the built-in classpath resource {@code prompt_resources/negotiation-vocabulary/{language}/ + * vocabulary.json} is used. A null or otherwise absent local root simply means the classpath is consulted. Both + * origins must define exactly the canonical key set. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @param localRootDir local prompt resource root containing the {@code negotiation-vocabulary/} tree; null + * disables local vocabulary overrides + * @return vocabulary holding the text constants of that language + * @throws IllegalArgumentException if the language has no vocabulary in any root, or the resolved file is + * unreadable, malformed or does not define exactly the canonical key set + */ + public static Vocabulary forLanguage(String language, @Nullable Path localRootDir) { + if (!PathSegments.isSimpleSegment(language)) { + throw new IllegalArgumentException( + "Negotiation vocabulary language must be a non-blank simple path segment but was " + language + + "."); + } + CacheKey cacheKey = new CacheKey(language, normalizeRoot(localRootDir)); + return CACHE.computeIfAbsent(cacheKey, key -> load(key.language, key.localRootDir)); + } + + /** + * Returns the text constant registered under one canonical key. + * + * @param canonicalKey canonical vocabulary key such as {@code section.context} or {@code punct.list_colon} + * @return language-specific text constant + * @throws IllegalArgumentException if the key is not part of the vocabulary + */ + public String get(String canonicalKey) { + String value = entries.get(canonicalKey); + if (value == null) { + throw new IllegalArgumentException( + "Unknown negotiation vocabulary key " + canonicalKey + " for language " + language + "."); + } + return value; + } + + /** + * Returns all canonical keys exposed by this vocabulary. + * + * @return immutable set of canonical keys, identical for every supported language + */ + public Set canonicalKeys() { + return entries.keySet(); + } + + /** + * Returns the language this vocabulary is bound to. + * + * @return locale identifier such as {@code zh-CN} + */ + public String language() { + return language; + } + + private static @Nullable Path normalizeRoot(@Nullable Path localRootDir) { + return localRootDir == null ? null : localRootDir.toAbsolutePath().normalize(); + } + + private static Vocabulary load(String language, @Nullable Path localRootDir) { + if (localRootDir != null) { + Path localFile = localRootDir + .resolve(VOCABULARY_DIRECTORY) + .resolve(language) + .resolve(VOCABULARY_FILE_NAME); + if (Files.exists(localFile)) { + return parse(language, readLocalFile(localFile), localFile.toString()); + } + } + String classpathPath = CLASSPATH_ROOT + language + "/" + VOCABULARY_FILE_NAME; + InputStream stream = ClasspathResourceStreams.open(classpathPath); + if (stream == null) { + throw new IllegalArgumentException( + "Negotiation vocabulary does not exist for the configured language " + language + + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly or" + + " provide " + classpathPath + " under the local resource root."); + } + try (stream) { + return parse(language, new String(stream.readAllBytes(), StandardCharsets.UTF_8), classpathPath); + } catch (IOException exception) { + throw new IllegalArgumentException( + "Failed to read the negotiation vocabulary " + classpathPath + " for language " + language + + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly.", + exception); + } + } + + private static String readLocalFile(Path localFile) { + try { + return Files.readString(localFile, StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalArgumentException( + "Failed to read the negotiation vocabulary " + localFile + "; supported languages are zh-CN and" + + " en-US, configure A2AT_LANGUAGE accordingly.", + exception); + } + } + + private static Vocabulary parse(String language, String payload, String origin) { + JsonNode root; + try { + root = OBJECT_MAPPER.readTree(payload); + } catch (JsonProcessingException exception) { + throw malformed(language, origin, "it is not valid JSON", exception); + } + if (root == null || !root.isObject()) { + throw malformed(language, origin, "it is not a flat JSON object of string values", null); + } + Map entries = new LinkedHashMap<>(); + root.fields().forEachRemaining(field -> { + JsonNode value = field.getValue(); + if (!value.isTextual()) { + throw malformed(language, origin, "the value of key '" + field.getKey() + "' is not a string", null); + } + entries.put(field.getKey(), value.textValue()); + }); + validateCanonicalKeys(language, entries, origin); + return new Vocabulary(language, Collections.unmodifiableMap(entries)); + } + + private static void validateCanonicalKeys(String language, Map entries, String origin) { + Set missing = new LinkedHashSet<>(CANONICAL_KEYS); + missing.removeAll(entries.keySet()); + Set unexpected = new LinkedHashSet<>(entries.keySet()); + unexpected.removeAll(CANONICAL_KEY_SET); + if (missing.isEmpty() && unexpected.isEmpty()) { + return; + } + throw new IllegalArgumentException( + "Negotiation vocabulary " + origin + " for language " + language + + " must define exactly the canonical vocabulary keys; missing keys: " + missing + + ", unexpected keys: " + unexpected + "."); + } + + private static IllegalArgumentException malformed( + String language, String origin, String detail, @Nullable Throwable cause) { + IllegalArgumentException exception = new IllegalArgumentException( + "Negotiation vocabulary " + origin + " for language " + language + " is malformed: " + detail + + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly."); + if (cause != null) { + exception.initCause(cause); + } + return exception; + } + + private record CacheKey(String language, @Nullable Path localRootDir) {} +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index 923ac21d..288fff41 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -1,5 +1,6 @@ package net.openan.a2at.sdk.negotiation.generation; +import java.nio.file.Path; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; @@ -19,7 +20,8 @@ *

The language is required. The LLM client is optional: without one, the from-data generation still works, while * the LLM steps of the from-text generation and the validation pipeline fail with the * {@code negotiation_llm_infrastructure_error} code. Every collaborator has a default implementation wired from the - * language and the optional local template root, and each of them can be overridden for testing or customization. + * language and the optional local resource root (templates and negotiation vocabulary), and each of them can be + * overridden for testing or customization. * * @since 2026-06 */ @@ -68,9 +70,10 @@ public NegotiationGenerationOrchestratorBuilder language(String language) { } /** - * Configures the optional local prompt resource root containing the {@code templates/} tree. + * Configures the optional local prompt resource root containing the {@code templates/} and + * {@code negotiation-vocabulary/} trees. * - * @param localRootDir local prompt resource root; null or blank disables local template overrides + * @param localRootDir local prompt resource root; null or blank disables local template and vocabulary overrides * @return current builder */ public NegotiationGenerationOrchestratorBuilder localRootDir(String localRootDir) { @@ -170,7 +173,8 @@ public NegotiationGenerationOrchestrator build() { throw new IllegalStateException( "Negotiation LLM max attempts must be at least 1 but was " + maxAttempts + "."); } - Vocabulary vocabulary = Vocabulary.forLanguage(language); + Path localVocabularyRoot = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); + Vocabulary vocabulary = Vocabulary.forLanguage(language, localVocabularyRoot); NegotiationTemplateLoader effectiveTemplateLoader = templateLoader != null ? templateLoader : new DefaultNegotiationTemplateLoader(language, localRootDir); NegotiationContentExtractor effectiveContentExtractor = diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java index 3520e250..5eb49191 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java @@ -1,131 +1,247 @@ -package net.openan.a2at.sdk.negotiation.content; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Set; -import java.util.TreeSet; -import org.junit.jupiter.api.Test; - -class VocabularyTest { - - private static final Set CANONICAL_KEYS = Set.of( - "section.context", - "section.info_items", - "section.info_static", - "section.info_conclusion", - "section.info_result_content", - "section.target", - "section.target_intent", - "section.target_alignment", - "section.target_clarification", - "section.target_conclusion", - "section.target_result_content", - "section.feasibility", - "section.feasibility_evaluate", - "section.feasibility_infeasible", - "section.feasibility_conclusion", - "section.feasibility_confirm", - "slot.context", - "slot.info_items", - "slot.info_conclusion", - "slot.info_result_content", - "slot.feasibility", - "slot.target", - "slot.target_intent", - "slot.target_alignment", - "slot.target_clarification", - "slot.target_conclusion", - "slot.target_result_content", - "slot.feasibility_evaluate", - "slot.feasibility_infeasible", - "slot.feasibility_conclusion", - "slot.feasibility_confirm", - "label.relationship", - "punct.list_colon"); - - @Test - void bothLanguagesExposeExactlyTheCanonicalKeys() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - assertEquals(CANONICAL_KEYS, zhCn.canonicalKeys()); - assertEquals(CANONICAL_KEYS, enUs.canonicalKeys()); - assertEquals(new TreeSet<>(zhCn.canonicalKeys()), new TreeSet<>(enUs.canonicalKeys())); - assertEquals(33, zhCn.canonicalKeys().size()); - } - - @Test - void slotPlaceholdersAreSnakeCaseForEnglishAndCjkForChinese() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - // The context placeholder is snake_case for en-US and the matching CJK section title for zh-CN. - assertEquals("协商上下文", zhCn.get("slot.context")); - assertEquals("negotiation_context", enUs.get("slot.context")); - assertEquals("意图理解陈述", zhCn.get("slot.target_intent")); - assertEquals("intent_understanding_statement", enUs.get("slot.target_intent")); - } - - @Test - void summaryAndConfirmSlotsDifferFromTheirSectionTitle() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - assertEquals("可行性协商概述", zhCn.get("slot.feasibility")); - assertEquals("feasibility_negotiation_summary", enUs.get("slot.feasibility")); - assertEquals("目标协商概述", zhCn.get("slot.target")); - assertEquals("target_negotiation_summary", enUs.get("slot.target")); - assertEquals("评估结果确认", zhCn.get("slot.feasibility_confirm")); - assertEquals("evaluation_result_confirmation", enUs.get("slot.feasibility_confirm")); - } - - @Test - void sectionValuesMatchTemplateSectionTitles() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - assertEquals("协商上下文", zhCn.get("section.context")); - assertEquals("Negotiation Context", enUs.get("section.context")); - assertEquals("目标协商结果内容", zhCn.get("section.target_result_content")); - assertEquals("Target Negotiation Result Content", enUs.get("section.target_result_content")); - assertEquals("可行性评估结果确认", zhCn.get("section.feasibility_confirm")); - assertEquals("Feasibility Assessment Result Confirmation", enUs.get("section.feasibility_confirm")); - } - - @Test - void englishRelationshipLabelCarriesOneTrailingSpace() { - String englishLabel = Vocabulary.forLanguage("en-US").get("label.relationship"); - String chineseLabel = Vocabulary.forLanguage("zh-CN").get("label.relationship"); - - assertEquals("Relationship between missing items: ", englishLabel); - assertTrue(englishLabel.endsWith(" ")); - assertEquals("缺失项之间的关系:", chineseLabel); - } - - @Test - void listColonPunctuationDiffersPerLanguage() { - assertEquals(":", Vocabulary.forLanguage("zh-CN").get("punct.list_colon")); - assertEquals(": ", Vocabulary.forLanguage("en-US").get("punct.list_colon")); - } - - @Test - void unsupportedLanguageThrows() { - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("fr-FR")); - - assertTrue(exception.getMessage().contains("fr-FR")); - assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); - } - - @Test - void unknownKeyThrows() { - Vocabulary vocabulary = Vocabulary.forLanguage("zh-CN"); - - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> vocabulary.get("section.unknown")); - - assertTrue(exception.getMessage().contains("section.unknown")); - } -} +package net.openan.a2at.sdk.negotiation.content; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class VocabularyTest { + + @TempDir + Path localRoot; + + private static final Set CANONICAL_KEYS = Set.of( + "section.context", + "section.info_items", + "section.info_static", + "section.info_conclusion", + "section.info_result_content", + "section.target", + "section.target_intent", + "section.target_alignment", + "section.target_clarification", + "section.target_conclusion", + "section.target_result_content", + "section.feasibility", + "section.feasibility_evaluate", + "section.feasibility_infeasible", + "section.feasibility_conclusion", + "section.feasibility_confirm", + "slot.context", + "slot.info_items", + "slot.info_conclusion", + "slot.info_result_content", + "slot.feasibility", + "slot.target", + "slot.target_intent", + "slot.target_alignment", + "slot.target_clarification", + "slot.target_conclusion", + "slot.target_result_content", + "slot.feasibility_evaluate", + "slot.feasibility_infeasible", + "slot.feasibility_conclusion", + "slot.feasibility_confirm", + "label.relationship", + "punct.list_colon"); + + @Test + void bothLanguagesExposeExactlyTheCanonicalKeys() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals(CANONICAL_KEYS, zhCn.canonicalKeys()); + assertEquals(CANONICAL_KEYS, enUs.canonicalKeys()); + assertEquals(Set.copyOf(Vocabulary.CANONICAL_KEYS), zhCn.canonicalKeys()); + assertEquals(new TreeSet<>(zhCn.canonicalKeys()), new TreeSet<>(enUs.canonicalKeys())); + assertEquals(33, zhCn.canonicalKeys().size()); + assertEquals(33, Vocabulary.CANONICAL_KEYS.size()); + } + + @Test + void slotPlaceholdersAreSnakeCaseForEnglishAndCjkForChinese() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + // The context placeholder is snake_case for en-US and the matching CJK section title for zh-CN. + assertEquals("协商上下文", zhCn.get("slot.context")); + assertEquals("negotiation_context", enUs.get("slot.context")); + assertEquals("意图理解陈述", zhCn.get("slot.target_intent")); + assertEquals("intent_understanding_statement", enUs.get("slot.target_intent")); + } + + @Test + void summaryAndConfirmSlotsDifferFromTheirSectionTitle() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("可行性协商概述", zhCn.get("slot.feasibility")); + assertEquals("feasibility_negotiation_summary", enUs.get("slot.feasibility")); + assertEquals("目标协商概述", zhCn.get("slot.target")); + assertEquals("target_negotiation_summary", enUs.get("slot.target")); + assertEquals("评估结果确认", zhCn.get("slot.feasibility_confirm")); + assertEquals("evaluation_result_confirmation", enUs.get("slot.feasibility_confirm")); + } + + @Test + void sectionValuesMatchTemplateSectionTitles() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("协商上下文", zhCn.get("section.context")); + assertEquals("Negotiation Context", enUs.get("section.context")); + assertEquals("目标协商结果内容", zhCn.get("section.target_result_content")); + assertEquals("Target Negotiation Result Content", enUs.get("section.target_result_content")); + assertEquals("可行性评估结果确认", zhCn.get("section.feasibility_confirm")); + assertEquals("Feasibility Assessment Result Confirmation", enUs.get("section.feasibility_confirm")); + } + + @Test + void englishRelationshipLabelCarriesOneTrailingSpace() { + String englishLabel = Vocabulary.forLanguage("en-US").get("label.relationship"); + String chineseLabel = Vocabulary.forLanguage("zh-CN").get("label.relationship"); + + assertEquals("Relationship between missing items: ", englishLabel); + assertTrue(englishLabel.endsWith(" ")); + assertEquals("缺失项之间的关系:", chineseLabel); + } + + @Test + void listColonPunctuationDiffersPerLanguage() { + assertEquals(":", Vocabulary.forLanguage("zh-CN").get("punct.list_colon")); + assertEquals(": ", Vocabulary.forLanguage("en-US").get("punct.list_colon")); + } + + @Test + void unsupportedLanguageThrows() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("fr-FR")); + + assertTrue(exception.getMessage().contains("fr-FR")); + assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); + } + + @Test + void unknownKeyThrows() { + Vocabulary vocabulary = Vocabulary.forLanguage("zh-CN"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> vocabulary.get("section.unknown")); + + assertTrue(exception.getMessage().contains("section.unknown")); + } + + @Test + void localVocabularyWinsOverTheClasspathOne() throws IOException { + Map entries = classpathEntriesOf("zh-CN"); + entries.put("section.context", "自定义协商上下文"); + writeLocalVocabulary("zh-CN", entries); + + Vocabulary overridden = Vocabulary.forLanguage("zh-CN", localRoot); + + assertEquals("自定义协商上下文", overridden.get("section.context")); + assertEquals(Vocabulary.forLanguage("zh-CN").get("punct.list_colon"), overridden.get("punct.list_colon")); + assertEquals(Vocabulary.forLanguage("zh-CN").canonicalKeys(), overridden.canonicalKeys()); + assertEquals("zh-CN", overridden.language()); + } + + @Test + void localRootWithoutAVocabularyFallsBackToTheClasspath() { + Vocabulary fallback = Vocabulary.forLanguage("en-US", localRoot); + + assertEquals(Vocabulary.forLanguage("en-US").get("slot.context"), fallback.get("slot.context")); + assertEquals(Vocabulary.forLanguage("en-US").canonicalKeys(), fallback.canonicalKeys()); + } + + @Test + void classpathResolutionAndNullRootShareOneCachedVocabulary() { + assertSame(Vocabulary.forLanguage("zh-CN", null), Vocabulary.forLanguage("zh-CN")); + assertSame(Vocabulary.forLanguage("en-US", localRoot), Vocabulary.forLanguage("en-US", localRoot)); + } + + @Test + void malformedLocalVocabularyFailsFast() throws IOException { + writeRawLocalVocabulary("zh-CN", "{ not json"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("zh-CN", localRoot)); + + assertTrue(exception.getMessage().contains("vocabulary.json")); + assertTrue(exception.getMessage().contains("zh-CN")); + } + + @Test + void localVocabularyWithAMissingCanonicalKeyFailsFast() throws IOException { + Map entries = classpathEntriesOf("zh-CN"); + entries.remove("punct.list_colon"); + writeLocalVocabulary("zh-CN", entries); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("zh-CN", localRoot)); + + assertTrue(exception.getMessage().contains("punct.list_colon")); + assertTrue(exception.getMessage().contains("vocabulary.json")); + } + + @Test + void localVocabularyWithAnUnexpectedKeyFailsFast() throws IOException { + Map entries = classpathEntriesOf("en-US"); + entries.put("section.unknown", "Unknown Section"); + writeLocalVocabulary("en-US", entries); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("en-US", localRoot)); + + assertTrue(exception.getMessage().contains("section.unknown")); + assertTrue(exception.getMessage().contains("vocabulary.json")); + } + + @Test + void vocabularyMissingInBothRootsThrowsWithTheLanguageHint() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("fr-FR", localRoot)); + + assertTrue(exception.getMessage().contains("fr-FR")); + assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); + } + + @Test + void languageThatIsNotASimplePathSegmentThrows() { + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("../escape", localRoot)); + } + + private static Map classpathEntriesOf(String language) { + Vocabulary builtin = Vocabulary.forLanguage(language); + Map entries = new LinkedHashMap<>(); + for (String key : Vocabulary.CANONICAL_KEYS) { + entries.put(key, builtin.get(key)); + } + return entries; + } + + private void writeLocalVocabulary(String language, Map entries) throws IOException { + new ObjectMapper().writeValue(writeLocalVocabularyPath(language).toFile(), entries); + } + + private void writeRawLocalVocabulary(String language, String payload) throws IOException { + Files.writeString(writeLocalVocabularyPath(language), payload, StandardCharsets.UTF_8); + } + + private Path writeLocalVocabularyPath(String language) throws IOException { + Path file = localRoot.resolve("negotiation-vocabulary").resolve(language).resolve("vocabulary.json"); + Files.createDirectories(file.getParent()); + return file; + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootVocabularyOverrideTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootVocabularyOverrideTest.java new file mode 100644 index 00000000..bbf48f73 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootVocabularyOverrideTest.java @@ -0,0 +1,95 @@ +package net.openan.a2at.sdk.negotiation.language; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.golden.GoldenInputs; +import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Locks the dual-root fallback of the negotiation vocabulary at the orchestrator level. + * + *

A vocabulary file placed under a local resource root at + * {@code negotiation-vocabulary/{language}/vocabulary.json} wins over the built-in classpath vocabulary and changes the + * rendered generator output end to end, while a blank local root keeps the built-in golden output byte for byte. The + * override mirrors the template-override regime proven by {@link CustomRootTemplateOverrideTest}. + */ +class CustomRootVocabularyOverrideTest { + + private static final String CUSTOM_RELATIONSHIP_LABEL = "自定义缺失项关系:"; + + @TempDir + Path customRoot; + + @Test + void customRootVocabularyChangesTheRenderedRelationshipLine() throws IOException { + writeCustomizedZhCnVocabulary(); + + String customized = GoldenCase.INFORMATION_PROPOSE.generate(orchestratorWithCustomRoot()) + .promptText(); + String builtin = GoldenCase.INFORMATION_PROPOSE.generate(orchestrator(GoldenInputs.ZH_CN, null)) + .promptText(); + + assertTrue(customized.contains(CUSTOM_RELATIONSHIP_LABEL), "the custom label must enter the rendered message"); + assertFalse(builtin.contains(CUSTOM_RELATIONSHIP_LABEL), "the built-in output must keep the bundled label"); + assertEquals( + builtin.replace(Vocabulary.forLanguage(GoldenInputs.ZH_CN).get("label.relationship"), CUSTOM_RELATIONSHIP_LABEL), + customized, + "only the vocabulary-driven relationship label may differ from the built-in output"); + } + + @Test + void blankLocalRootKeepsTheBuiltInGoldenOutput() throws IOException { + writeCustomizedZhCnVocabulary(); + + MetadataContent withBlankRoot = GoldenCase.INFORMATION_PROPOSE.generate( + NegotiationGenerationOrchestratorBuilder.builder() + .language(GoldenInputs.ZH_CN) + .localRootDir(" ") + .build()); + + assertEquals( + GoldenCase.INFORMATION_PROPOSE.generate(orchestrator(GoldenInputs.ZH_CN, null)).promptText(), + withBlankRoot.promptText(), + "a blank local root must fall back to the classpath vocabulary"); + } + + private void writeCustomizedZhCnVocabulary() throws IOException { + Vocabulary builtin = Vocabulary.forLanguage(GoldenInputs.ZH_CN); + Map entries = new LinkedHashMap<>(); + for (String key : Vocabulary.CANONICAL_KEYS) { + entries.put(key, builtin.get(key)); + } + entries.put("label.relationship", CUSTOM_RELATIONSHIP_LABEL); + Path file = customRoot + .resolve("negotiation-vocabulary") + .resolve(GoldenInputs.ZH_CN) + .resolve("vocabulary.json"); + Files.createDirectories(file.getParent()); + new ObjectMapper().writeValue(file.toFile(), entries); + } + + private NegotiationGenerationOrchestrator orchestratorWithCustomRoot() { + return orchestrator(GoldenInputs.ZH_CN, customRoot.toString()); + } + + private static NegotiationGenerationOrchestrator orchestrator(String language, String localRootDir) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .localRootDir(localRootDir) + .build(); + } +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json new file mode 100644 index 00000000..ae9a4731 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json @@ -0,0 +1,35 @@ +{ + "section.context": "Negotiation Context", + "section.info_items": "Required Information Items", + "section.info_static": "Information Negotiation", + "section.info_conclusion": "Information Negotiation Result", + "section.info_result_content": "Information Negotiation Result Content", + "section.target": "Target Negotiation", + "section.target_intent": "Intent Understanding Statement", + "section.target_alignment": "Understanding Alignment and Clarification", + "section.target_clarification": "Content to Clarify", + "section.target_conclusion": "Target Negotiation Result", + "section.target_result_content": "Target Negotiation Result Content", + "section.feasibility": "Feasibility Negotiation", + "section.feasibility_evaluate": "Under Evaluation Description", + "section.feasibility_infeasible": "Infeasible Evaluation Details and Proposal", + "section.feasibility_conclusion": "Feasibility Negotiation Result", + "section.feasibility_confirm": "Feasibility Assessment Result Confirmation", + "slot.context": "negotiation_context", + "slot.info_items": "required_information_items", + "slot.info_conclusion": "information_negotiation_result", + "slot.info_result_content": "information_negotiation_result_content", + "slot.target": "target_negotiation_summary", + "slot.target_intent": "intent_understanding_statement", + "slot.target_alignment": "understanding_alignment_and_clarification", + "slot.target_clarification": "content_to_clarify", + "slot.target_conclusion": "target_negotiation_result", + "slot.target_result_content": "target_negotiation_result_content", + "slot.feasibility": "feasibility_negotiation_summary", + "slot.feasibility_evaluate": "under_evaluation_description", + "slot.feasibility_infeasible": "infeasible_evaluation_details_and_proposal", + "slot.feasibility_conclusion": "feasibility_negotiation_result", + "slot.feasibility_confirm": "evaluation_result_confirmation", + "label.relationship": "Relationship between missing items: ", + "punct.list_colon": ": " +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json new file mode 100644 index 00000000..3d070043 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json @@ -0,0 +1,35 @@ +{ + "section.context": "协商上下文", + "section.info_items": "所需信息项", + "section.info_static": "信息协商", + "section.info_conclusion": "信息协商结果", + "section.info_result_content": "信息协商结果内容", + "section.target": "目标协商", + "section.target_intent": "意图理解陈述", + "section.target_alignment": "理解对齐与疑问澄清", + "section.target_clarification": "待澄清内容", + "section.target_conclusion": "目标协商结果", + "section.target_result_content": "目标协商结果内容", + "section.feasibility": "可行性协商", + "section.feasibility_evaluate": "待评估内容说明", + "section.feasibility_infeasible": "评估不可行时的详情和提案", + "section.feasibility_conclusion": "可行性协商结果", + "section.feasibility_confirm": "可行性评估结果确认", + "slot.context": "协商上下文", + "slot.info_items": "所需信息项", + "slot.info_conclusion": "信息协商结果", + "slot.info_result_content": "信息协商结果内容", + "slot.target": "目标协商概述", + "slot.target_intent": "意图理解陈述", + "slot.target_alignment": "理解对齐与疑问澄清", + "slot.target_clarification": "待澄清内容", + "slot.target_conclusion": "目标协商结果", + "slot.target_result_content": "目标协商结果内容", + "slot.feasibility": "可行性协商概述", + "slot.feasibility_evaluate": "待评估内容说明", + "slot.feasibility_infeasible": "评估不可行时的详情和提案", + "slot.feasibility_conclusion": "可行性协商结果", + "slot.feasibility_confirm": "评估结果确认", + "label.relationship": "缺失项之间的关系:", + "punct.list_colon": ":" +} diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index bb213c01..e724c813 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -489,7 +489,7 @@ where `{type}` is `information`, `target`, or `feasibility`, `{phase}` is `propo | `Negotiation-T/feasibility-negotiation/propose/v1` | Request a feasibility evaluation or propose an alternative | | `Negotiation-T/feasibility-negotiation/accept-reject/v1` | Accept or reject a feasibility negotiation | -Negotiation templates are resolved with a **dual-root fallback** that is independent of `A2AT_PROMPT_SOURCE_TYPE`: a template file that exists under the local resource root configured by `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` wins, otherwise the built-in classpath template of the same URI is used. The built-in templates therefore always remain available as a safety net. Only templates are taken from the local root; LLM prompt resources always come from the classpath. +Negotiation templates are resolved with a **dual-root fallback** that is independent of `A2AT_PROMPT_SOURCE_TYPE`: a template file that exists under the local resource root configured by `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` wins, otherwise the built-in classpath template of the same URI is used. The built-in templates therefore always remain available as a safety net. Templates and the negotiation vocabulary are taken from the local root; LLM prompt resources always come from the classpath. The negotiation vocabulary follows the same dual-root regime: each language's section titles, slot marker names, appended-line labels and list punctuation live in `prompt_resources/negotiation-vocabulary/{lang}/vocabulary.json`, so a file placed at `{A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR}/negotiation-vocabulary/{lang}/vocabulary.json` overrides the built-in classpath vocabulary. A vocabulary file must define exactly the canonical key set shared by both bundled languages; anything else fails fast. To override one template (for example the information propose template in Chinese), place a file under the local root following the bundled layout: @@ -553,7 +553,9 @@ Retry semantics: a failing LLM step carrying a retryable code is re-run up to th ### 1.10.6 Language Configuration -The negotiation templates and the vocabulary used for rendering and section recognition are keyed by `A2AT_LANGUAGE`. Supported values are exactly `zh-CN` and `en-US`; there is no fallback — any other value fails with an `IllegalArgumentException` when the vocabulary is resolved. Set the language explicitly in `client.env` / `server.env`: +The negotiation templates and the vocabulary used for rendering and section recognition are keyed by `A2AT_LANGUAGE`. Supported values are exactly `zh-CN` and `en-US`; there is no fallback — any other value fails with an `IllegalArgumentException` when the vocabulary is resolved, and the failure message points at `A2AT_LANGUAGE`. + +The vocabulary itself is file-driven: each language's constants live in `prompt_resources/negotiation-vocabulary/{lang}/vocabulary.json`, a flat JSON object mapping the 33 canonical keys (section titles, slot marker names, appended-line labels, list punctuation) to that language's text. It follows the same dual-root local-override regime as the templates — a file under `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` wins, otherwise the built-in classpath file is used — and resolution is fail-fast with no silent degradation: a vocabulary that exists in neither root, is unreadable, is malformed, or does not define exactly the canonical key set raises an `IllegalArgumentException` naming the language and the file origin. Adding a new language therefore still requires the full resource set — the negotiation templates and the LLM prompt resources — not just a vocabulary file. Set the language explicitly in `client.env` / `server.env`: ```properties A2AT_LANGUAGE=zh-CN diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 251a5e58..1912de40 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -472,7 +472,7 @@ Negotiation-T/{type}-negotiation/{phase}/v1 | `Negotiation-T/feasibility-negotiation/propose/v1` | 请求可行性评估或提出替代方案 | | `Negotiation-T/feasibility-negotiation/accept-reject/v1` | 可行性协商的接受或拒绝 | -协商模板采用**双根回退**解析,且该回退与 `A2AT_PROMPT_SOURCE_TYPE` 无关:若 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 配置的本地资源根目录下存在同名模板文件则优先使用,否则回退到内置 classpath 中同一 URI 的模板。内置模板因此始终作为兜底可用。本地根目录只用于覆盖模板;LLM 提示词资源始终来自 classpath。 +协商模板采用**双根回退**解析,且该回退与 `A2AT_PROMPT_SOURCE_TYPE` 无关:若 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 配置的本地资源根目录下存在同名模板文件则优先使用,否则回退到内置 classpath 中同一 URI 的模板。内置模板因此始终作为兜底可用。本地根目录用于覆盖模板与协商词表;LLM 提示词资源始终来自 classpath。协商词表遵循同一双根机制:各语言的章节标题、槽位标记名、追加行标签与列表标点位于 `prompt_resources/negotiation-vocabulary/{lang}/vocabulary.json`,在 `{A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR}/negotiation-vocabulary/{lang}/vocabulary.json` 放置同名文件即可覆盖内置 classpath 词表;词表文件必须精确定义两个内置语言共同的规范化键集合,否则快速失败。 如需覆盖某个模板(例如中文的信息协商 propose 模板),在本地资源根目录下按内置布局放置文件: @@ -536,7 +536,9 @@ Negotiation-T/{type}-negotiation/{phase}/v1 ### 1.10.6 语言配置 -协商模板以及渲染和章节识别所用的词表均按 `A2AT_LANGUAGE` 键控。支持的取值精确地为 `zh-CN` 与 `en-US`;没有任何回退 — 其他取值在解析词表时会以 `IllegalArgumentException` 失败。请在 `client.env` / `server.env` 中显式设置语言: +协商模板以及渲染和章节识别所用的词表均按 `A2AT_LANGUAGE` 键控。支持的取值精确地为 `zh-CN` 与 `en-US`;没有任何回退 — 其他取值在解析词表时会以 `IllegalArgumentException` 失败,错误信息会指向 `A2AT_LANGUAGE`。 + +词表本身是文件驱动的:各语言的常量位于 `prompt_resources/negotiation-vocabulary/{lang}/vocabulary.json`,即将 33 个规范化键(章节标题、槽位标记名、追加行标签、列表标点)映射到该语言文本的扁平 JSON 对象。词表遵循与模板相同的双根本地覆盖机制 — `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 下存在同名文件则优先,否则回退到内置 classpath 文件;解析过程快速失败、绝不静默降级:词表在两个根下均不存在、无法读取、格式非法或未精确定义规范化键集合时,都会抛出指明语言与文件来源的 `IllegalArgumentException`。因此新增语言仍需完整资源集 — 协商模板与 LLM 提示词资源 — 仅添加词表文件并不足够。请在 `client.env` / `server.env` 中显式设置语言: ```properties A2AT_LANGUAGE=zh-CN diff --git a/tools/template_lint.py b/tools/template_lint.py index 874debb9..727c6120 100644 --- a/tools/template_lint.py +++ b/tools/template_lint.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Static CI gate for A2A-T prompt templates, their slot JSON Schemas, and Negotiation-T templates.""" +"""Static CI gate for A2A-T prompt templates, their slot JSON Schemas, Negotiation-T templates, and the file-driven negotiation vocabulary.""" from __future__ import annotations @@ -29,41 +29,6 @@ NEGOTIATION_TYPE_SEGMENTS = ("information-negotiation", "target-negotiation", "feasibility-negotiation") NEGOTIATION_PHASE_SEGMENTS = ("propose", "accept-reject") NEGOTIATION_LANGUAGES = ("zh-CN", "en-US") -NEGOTIATION_SECTIONS = { - "context": ("协商上下文", "Negotiation Context"), - "info_static": ("信息协商", "Information Negotiation"), - "info_items": ("所需信息项", "Required Information Items"), - "info_conclusion": ("信息协商结果", "Information Negotiation Result"), - "info_result_content": ("信息协商结果内容", "Information Negotiation Result Content"), - "target": ("目标协商", "Target Negotiation"), - "target_intent": ("意图理解陈述", "Intent Understanding Statement"), - "target_alignment": ("理解对齐与疑问澄清", "Understanding Alignment and Clarification"), - "target_clarification": ("待澄清内容", "Content to Clarify"), - "target_conclusion": ("目标协商结果", "Target Negotiation Result"), - "target_result_content": ("目标协商结果内容", "Target Negotiation Result Content"), - "feasibility": ("可行性协商", "Feasibility Negotiation"), - "feasibility_evaluate": ("待评估内容说明", "Under Evaluation Description"), - "feasibility_infeasible": ("评估不可行时的详情和提案", "Infeasible Evaluation Details and Proposal"), - "feasibility_conclusion": ("可行性协商结果", "Feasibility Negotiation Result"), - "feasibility_confirm": ("可行性评估结果确认", "Feasibility Assessment Result Confirmation"), -} -NEGOTIATION_SLOT_NAMES = { - "context": ("协商上下文", "negotiation_context"), - "info_items": ("所需信息项", "required_information_items"), - "info_conclusion": ("信息协商结果", "information_negotiation_result"), - "info_result_content": ("信息协商结果内容", "information_negotiation_result_content"), - "target": ("目标协商概述", "target_negotiation_summary"), - "target_intent": ("意图理解陈述", "intent_understanding_statement"), - "target_alignment": ("理解对齐与疑问澄清", "understanding_alignment_and_clarification"), - "target_clarification": ("待澄清内容", "content_to_clarify"), - "target_conclusion": ("目标协商结果", "target_negotiation_result"), - "target_result_content": ("目标协商结果内容", "target_negotiation_result_content"), - "feasibility": ("可行性协商概述", "feasibility_negotiation_summary"), - "feasibility_evaluate": ("待评估内容说明", "under_evaluation_description"), - "feasibility_infeasible": ("评估不可行时的详情和提案", "infeasible_evaluation_details_and_proposal"), - "feasibility_conclusion": ("可行性协商结果", "feasibility_negotiation_result"), - "feasibility_confirm": ("评估结果确认", "evaluation_result_confirmation"), -} NEGOTIATION_STATIC_SECTIONS = {"info_static"} NEGOTIATION_PROFILES = { ("information-negotiation", "propose"): ("context", "info_static", "info_items"), @@ -73,11 +38,6 @@ ("feasibility-negotiation", "propose"): ("context", "feasibility", "feasibility_evaluate", "feasibility_infeasible"), ("feasibility-negotiation", "accept-reject"): ("context", "feasibility_conclusion", "feasibility_confirm"), } -NEGOTIATION_TITLE_LOOKUP = { - title: (key, language) - for key, titles in NEGOTIATION_SECTIONS.items() - for language, title in zip(NEGOTIATION_LANGUAGES, titles) -} NEGOTIATION_MARKER = re.compile( r"^\{\{(?P[^{}]+)\}\}(?:(?P((?P必填|选填)))| \((?Prequired|optional)\))\s*$" ) @@ -172,14 +132,88 @@ def lint_pair(template_path: Path, schema_path: Path) -> list[str]: return errors -def negotiation_section_title(key: str, language: str) -> str: - titles = NEGOTIATION_SECTIONS[key] - return titles[0] if language == "zh-CN" else titles[1] +def load_negotiation_vocabularies(resource_root: Path) -> tuple[dict[str, dict[str, str]], list[str]]: + """Loads negotiation-vocabulary/{lang}/vocabulary.json for both languages from the resource root. - -def negotiation_slot_name(key: str, language: str) -> str: - titles = NEGOTIATION_SLOT_NAMES[key] - return titles[0] if language == "zh-CN" else titles[1] + The vocabulary files are the single source of the Negotiation-T section titles and slot marker names; the tables + are no longer hardcoded here. Both language files must exist, parse into a flat string-to-string object, expose + identical key sets, and carry every section.*/slot.* key the template profiles reference. + """ + vocabulary_root = resource_root / "negotiation-vocabulary" + if not vocabulary_root.is_dir(): + return {}, [error(vocabulary_root, 1, "negotiation-vocabulary", "Missing negotiation-vocabulary directory.")] + errors: list[str] = [] + for entry in sorted(vocabulary_root.iterdir()): + if entry.is_dir() and entry.name not in NEGOTIATION_LANGUAGES: + errors.append( + error(entry, 1, "negotiation-vocabulary", f"Unexpected negotiation vocabulary language directory: {entry.name}.") + ) + vocabularies: dict[str, dict[str, str]] = {} + for language in NEGOTIATION_LANGUAGES: + path = vocabulary_root / language / "vocabulary.json" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + errors.append(error(path, 1, "negotiation-vocabulary", f"Cannot load negotiation vocabulary: {exc}")) + continue + if not isinstance(data, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in data.items()): + errors.append(error(path, 1, "negotiation-vocabulary", "Vocabulary must be a flat JSON object mapping keys to strings.")) + continue + vocabularies[language] = data + if len(vocabularies) == len(NEGOTIATION_LANGUAGES): + zh_keys, en_keys = set(vocabularies["zh-CN"]), set(vocabularies["en-US"]) + if zh_keys != en_keys: + details = [] + missing_in_en = sorted(zh_keys - en_keys) + missing_in_zh = sorted(en_keys - zh_keys) + if missing_in_en: + details.append("missing in en-US: " + ", ".join(missing_in_en)) + if missing_in_zh: + details.append("missing in zh-CN: " + ", ".join(missing_in_zh)) + errors.append( + error( + vocabulary_root / "en-US" / "vocabulary.json", + 1, + "negotiation-vocabulary-parity", + f"zh-CN and en-US vocabulary key sets differ ({'; '.join(details)}).", + ) + ) + required_section_keys = set(NEGOTIATION_STATIC_SECTIONS) + for keys in NEGOTIATION_PROFILES.values(): + required_section_keys.update(keys) + required_slot_keys = required_section_keys - NEGOTIATION_STATIC_SECTIONS + for language in list(vocabularies): + path = vocabulary_root / language / "vocabulary.json" + vocabulary = vocabularies[language] + usable = True + for section_key in sorted(required_section_keys): + if f"section.{section_key}" not in vocabulary: + errors.append(error(path, 1, "negotiation-vocabulary-key", f"Missing required key 'section.{section_key}'.")) + usable = False + for slot_key in sorted(required_slot_keys): + if f"slot.{slot_key}" not in vocabulary: + errors.append(error(path, 1, "negotiation-vocabulary-key", f"Missing required key 'slot.{slot_key}'.")) + usable = False + if not usable: + del vocabularies[language] + return vocabularies, errors + + +def negotiation_title_lookup(vocabularies: dict[str, dict[str, str]]) -> dict[str, tuple[str, str]]: + lookup: dict[str, tuple[str, str]] = {} + for language in NEGOTIATION_LANGUAGES: + for key, value in vocabularies.get(language, {}).items(): + if key.startswith("section."): + lookup.setdefault(value, (key[len("section."):], language)) + return lookup + + +def negotiation_section_title(vocabulary: dict[str, str], key: str) -> str: + return vocabulary[f"section.{key}"] + + +def negotiation_slot_name(vocabulary: dict[str, str], key: str) -> str: + return vocabulary[f"slot.{key}"] def negotiation_requirements_label(language: str) -> str: @@ -200,7 +234,12 @@ def parse_negotiation_sections(lines: list[str]) -> list[dict]: def lint_negotiation_file( - path: Path, language: str, type_segment: str, phase_segment: str + path: Path, + language: str, + type_segment: str, + phase_segment: str, + vocabulary: dict[str, str], + title_lookup: dict[str, tuple[str, str]], ) -> tuple[list[str], list[tuple[str, bool | None, bool, int]] | None]: errors: list[str] = [] try: @@ -221,7 +260,7 @@ def lint_negotiation_file( seen: set[str] = set() for section in sections: title, line_no, body = section["title"], section["line"], section["body"] - lookup = NEGOTIATION_TITLE_LOOKUP.get(title) + lookup = title_lookup.get(title) if lookup is None: errors.append(error(path, line_no, "negotiation-section-name", f"'{title}' is not a recognized Negotiation-T section title.")) continue @@ -260,7 +299,7 @@ def lint_negotiation_file( errors.append( error(path, marker_line_no, "negotiation-slot-marker", f"Slot marker in {language} template must use {language} punctuation (full-width for zh-CN, ' (required)'/' (optional)' for en-US).") ) - expected_slot = negotiation_slot_name(key, language) + expected_slot = negotiation_slot_name(vocabulary, key) if marker.group("slot") != expected_slot: errors.append(error(path, marker_line_no, "negotiation-slot-name", f"Slot name '{{{{{marker.group('slot')}}}}}' must be '{expected_slot}'.")) if not any(text.rstrip() == requirements_label for _, text in body): @@ -270,7 +309,7 @@ def lint_negotiation_file( shape.append((key, required, marker.group("slot") == expected_slot, line_no)) for key in profile: if key not in seen: - errors.append(error(path, 1, "negotiation-section-missing", f"Missing required section '{negotiation_section_title(key, language)}'.")) + errors.append(error(path, 1, "negotiation-section-missing", f"Missing required section '{negotiation_section_title(vocabulary, key)}'.")) return errors, shape @@ -298,7 +337,7 @@ def lint_negotiation_alignment( return errors -def lint_negotiation(templates_dir: Path) -> list[str]: +def lint_negotiation(templates_dir: Path, vocabularies: dict[str, dict[str, str]]) -> list[str]: negotiation_root = templates_dir / "Negotiation-T" if not negotiation_root.is_dir(): return [error(negotiation_root, 1, "negotiation-file-set", "Missing Negotiation-T templates directory.")] @@ -311,6 +350,7 @@ def lint_negotiation(templates_dir: Path) -> list[str]: errors: list[str] = [] shapes: dict[tuple[str, str, str], list[tuple] | None] = {} found: set[Path] = set() + title_lookup = negotiation_title_lookup(vocabularies) for path in sorted(negotiation_root.rglob("template.md")): relative = path.relative_to(negotiation_root) if relative not in expected: @@ -318,7 +358,13 @@ def lint_negotiation(templates_dir: Path) -> list[str]: continue found.add(relative) type_segment, phase_segment, _, language, _ = relative.parts - file_errors, shape = lint_negotiation_file(path, language, type_segment, phase_segment) + vocabulary = vocabularies.get(language) + if vocabulary is None: + # The vocabulary errors were already reported; without a usable vocabulary the per-file checks cannot run. + continue + file_errors, shape = lint_negotiation_file( + path, language, type_segment, phase_segment, vocabulary, title_lookup + ) errors.extend(file_errors) shapes[(type_segment, phase_segment, language)] = shape for relative in sorted(expected - found): @@ -354,7 +400,9 @@ def lint_root(root: Path) -> list[str]: template_path = templates / schema_path.relative_to(slots).parent / "template.md" if not template_path.is_file(): errors.append(error(schema_path, 1, "template-missing", f"Missing paired template: {template_path}")) - errors.extend(lint_negotiation(templates)) + vocabularies, vocabulary_errors = load_negotiation_vocabularies(root) + errors.extend(vocabulary_errors) + errors.extend(lint_negotiation(templates, vocabularies)) return errors From 1ef7d77ce7114fec56f9d9b14c59dd6f3e236fc2 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 09:37:45 +0800 Subject: [PATCH 084/295] fix(negotiation): harden vocabulary loading and align local-root caching with the template regime Signed-off-by: codetalks-hw --- .../sdk/negotiation/content/Vocabulary.java | 39 ++++++++-- .../negotiation/content/VocabularyTest.java | 73 ++++++++++++++++++- docs/en/developer_guide.md | 2 +- ...00\345\217\221\346\214\207\345\215\227.md" | 2 +- tools/template_lint.py | 24 +++++- 5 files changed, 125 insertions(+), 15 deletions(-) diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java index 1ee6b0c5..85728d48 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java @@ -1,8 +1,10 @@ package net.openan.a2at.sdk.negotiation.content; +import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -30,8 +32,12 @@ *

The values are file-driven: each language resolves {@code negotiation-vocabulary/{language}/vocabulary.json} * from a dual-root fallback — a file under the optional local resource root wins, otherwise the built-in classpath * resource is used. Both origins are validated against the pinned {@link #CANONICAL_KEYS} set, and a vocabulary that - * exists nowhere, cannot be read, is malformed or drifts from the canonical key set fails fast instead of silently - * degrading. + * exists nowhere, cannot be read, is malformed (including duplicate JSON keys or blank values) or drifts from the + * canonical key set fails fast instead of silently degrading. + * + *

Caching mirrors the template-override regime: the built-in classpath vocabulary is resolved once per JVM and + * context classloader (jar bytes are immutable), while a local resource root is re-resolved on every call so that + * overriding, editing or removing a local vocabulary file takes effect immediately. * * @since 2026-06 */ @@ -43,7 +49,9 @@ public final class Vocabulary { private static final String CLASSPATH_ROOT = "prompt_resources/" + VOCABULARY_DIRECTORY + "/"; - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() + .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .build(); private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); @@ -113,6 +121,10 @@ public static Vocabulary forLanguage(String language) { * vocabulary.json} is used. A null or otherwise absent local root simply means the classpath is consulted. Both * origins must define exactly the canonical key set. * + *

Caching mirrors the template-override regime: the classpath vocabulary is resolved once per JVM and context + * classloader (jar bytes are immutable), while a non-null local root is re-resolved on every call so overriding, + * editing or removing the local file takes effect immediately. + * * @param language locale identifier such as {@code zh-CN} or {@code en-US} * @param localRootDir local prompt resource root containing the {@code negotiation-vocabulary/} tree; null * disables local vocabulary overrides @@ -126,8 +138,12 @@ public static Vocabulary forLanguage(String language, @Nullable Path localRootDi "Negotiation vocabulary language must be a non-blank simple path segment but was " + language + "."); } - CacheKey cacheKey = new CacheKey(language, normalizeRoot(localRootDir)); - return CACHE.computeIfAbsent(cacheKey, key -> load(key.language, key.localRootDir)); + Path normalizedRoot = normalizeRoot(localRootDir); + if (normalizedRoot != null) { + return load(language, normalizedRoot); + } + CacheKey cacheKey = new CacheKey(language, Thread.currentThread().getContextClassLoader()); + return CACHE.computeIfAbsent(cacheKey, key -> load(key.language, null)); } /** @@ -212,7 +228,10 @@ private static Vocabulary parse(String language, String payload, String origin) try { root = OBJECT_MAPPER.readTree(payload); } catch (JsonProcessingException exception) { - throw malformed(language, origin, "it is not valid JSON", exception); + String detail = exception.getMessage() != null && exception.getMessage().contains("Duplicate field") + ? "it contains duplicate keys" + : "it is not valid JSON"; + throw malformed(language, origin, detail, exception); } if (root == null || !root.isObject()) { throw malformed(language, origin, "it is not a flat JSON object of string values", null); @@ -223,7 +242,11 @@ private static Vocabulary parse(String language, String payload, String origin) if (!value.isTextual()) { throw malformed(language, origin, "the value of key '" + field.getKey() + "' is not a string", null); } - entries.put(field.getKey(), value.textValue()); + String text = value.textValue(); + if (text.isBlank()) { + throw malformed(language, origin, "the value of key '" + field.getKey() + "' is blank", null); + } + entries.put(field.getKey(), text); }); validateCanonicalKeys(language, entries, origin); return new Vocabulary(language, Collections.unmodifiableMap(entries)); @@ -254,5 +277,5 @@ private static IllegalArgumentException malformed( return exception; } - private record CacheKey(String language, @Nullable Path localRootDir) {} + private record CacheKey(String language, @Nullable ClassLoader contextClassLoader) {} } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java index 5eb49191..d54d3a54 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java @@ -168,7 +168,21 @@ void localRootWithoutAVocabularyFallsBackToTheClasspath() { @Test void classpathResolutionAndNullRootShareOneCachedVocabulary() { assertSame(Vocabulary.forLanguage("zh-CN", null), Vocabulary.forLanguage("zh-CN")); - assertSame(Vocabulary.forLanguage("en-US", localRoot), Vocabulary.forLanguage("en-US", localRoot)); + } + + @Test + void localRootIsReResolvedOnEveryCall() throws IOException { + Map entries = classpathEntriesOf("zh-CN"); + entries.put("label.relationship", "本地覆盖标签:"); + writeLocalVocabulary("zh-CN", entries); + + Vocabulary overridden = Vocabulary.forLanguage("zh-CN", localRoot); + assertEquals("本地覆盖标签:", overridden.get("label.relationship")); + + Files.delete(writeLocalVocabularyPath("zh-CN")); + + Vocabulary restored = Vocabulary.forLanguage("zh-CN", localRoot); + assertEquals(Vocabulary.forLanguage("zh-CN").get("label.relationship"), restored.get("label.relationship")); } @Test @@ -222,6 +236,63 @@ void languageThatIsNotASimplePathSegmentThrows() { assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("../escape", localRoot)); } + @Test + void duplicateKeysInTheLocalVocabularyFailFast() throws IOException { + writeRawLocalVocabulary("zh-CN", "{\"punct.list_colon\": \":\", \"punct.list_colon\": \":\"}"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("zh-CN", localRoot)); + + assertTrue(exception.getMessage().contains("duplicate keys")); + assertTrue(exception.getMessage().contains("vocabulary.json")); + } + + @Test + void blankValueInTheLocalVocabularyFailsFast() throws IOException { + Map entries = classpathEntriesOf("zh-CN"); + entries.put("punct.list_colon", " "); + writeLocalVocabulary("zh-CN", entries); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("zh-CN", localRoot)); + + assertTrue(exception.getMessage().contains("punct.list_colon")); + assertTrue(exception.getMessage().contains("blank")); + assertTrue(exception.getMessage().contains("vocabulary.json")); + } + + @Test + void nonObjectLocalVocabularyFailsFast() throws IOException { + writeRawLocalVocabulary("en-US", "[]"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("en-US", localRoot)); + + assertTrue(exception.getMessage().contains("not a flat JSON object")); + } + + @Test + void nonStringValueInTheLocalVocabularyFailsFast() throws IOException { + writeRawLocalVocabulary("en-US", "{\"punct.list_colon\": 5}"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("en-US", localRoot)); + + assertTrue(exception.getMessage().contains("punct.list_colon")); + assertTrue(exception.getMessage().contains("is not a string")); + } + + @Test + void unreadableLocalVocabularyFailsFast() throws IOException { + Files.createDirectories(writeLocalVocabularyPath("zh-CN")); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("zh-CN", localRoot)); + + assertTrue(exception.getMessage().contains("Failed to read")); + assertTrue(exception.getMessage().contains("vocabulary.json")); + } + private static Map classpathEntriesOf(String language) { Vocabulary builtin = Vocabulary.forLanguage(language); Map entries = new LinkedHashMap<>(); diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index e724c813..dded9af3 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -553,7 +553,7 @@ Retry semantics: a failing LLM step carrying a retryable code is re-run up to th ### 1.10.6 Language Configuration -The negotiation templates and the vocabulary used for rendering and section recognition are keyed by `A2AT_LANGUAGE`. Supported values are exactly `zh-CN` and `en-US`; there is no fallback — any other value fails with an `IllegalArgumentException` when the vocabulary is resolved, and the failure message points at `A2AT_LANGUAGE`. +The negotiation templates and the vocabulary used for rendering and section recognition are keyed by `A2AT_LANGUAGE`. Out of the box — built-in classpath resources only — the supported values are exactly `zh-CN` and `en-US`; there is no fallback to another language, any other value without a local vocabulary file fails with an `IllegalArgumentException` when the vocabulary is resolved, and the failure message points at `A2AT_LANGUAGE`. A language for which a valid `vocabulary.json` exists under the local resource root resolves at the vocabulary layer, but generation still fails later with template-not-found unless the full template and prompt resource set for that language is provided as well. The vocabulary itself is file-driven: each language's constants live in `prompt_resources/negotiation-vocabulary/{lang}/vocabulary.json`, a flat JSON object mapping the 33 canonical keys (section titles, slot marker names, appended-line labels, list punctuation) to that language's text. It follows the same dual-root local-override regime as the templates — a file under `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` wins, otherwise the built-in classpath file is used — and resolution is fail-fast with no silent degradation: a vocabulary that exists in neither root, is unreadable, is malformed, or does not define exactly the canonical key set raises an `IllegalArgumentException` naming the language and the file origin. Adding a new language therefore still requires the full resource set — the negotiation templates and the LLM prompt resources — not just a vocabulary file. Set the language explicitly in `client.env` / `server.env`: diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 1912de40..4dea73eb 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -536,7 +536,7 @@ Negotiation-T/{type}-negotiation/{phase}/v1 ### 1.10.6 语言配置 -协商模板以及渲染和章节识别所用的词表均按 `A2AT_LANGUAGE` 键控。支持的取值精确地为 `zh-CN` 与 `en-US`;没有任何回退 — 其他取值在解析词表时会以 `IllegalArgumentException` 失败,错误信息会指向 `A2AT_LANGUAGE`。 +协商模板以及渲染和章节识别所用的词表均按 `A2AT_LANGUAGE` 键控。开箱即用——仅内置 classpath 资源——支持的取值精确地为 `zh-CN` 与 `en-US`;没有任何语言回退,未提供本地词表文件的其他取值在解析词表时会以 `IllegalArgumentException` 失败,错误信息会指向 `A2AT_LANGUAGE`。若本地资源根下存在某语言的有效 `vocabulary.json`,词表层可以完成解析,但除非同时提供该语言的完整模板与 prompt 资源,生成流程仍会在后续以模板缺失失败。 词表本身是文件驱动的:各语言的常量位于 `prompt_resources/negotiation-vocabulary/{lang}/vocabulary.json`,即将 33 个规范化键(章节标题、槽位标记名、追加行标签、列表标点)映射到该语言文本的扁平 JSON 对象。词表遵循与模板相同的双根本地覆盖机制 — `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 下存在同名文件则优先,否则回退到内置 classpath 文件;解析过程快速失败、绝不静默降级:词表在两个根下均不存在、无法读取、格式非法或未精确定义规范化键集合时,都会抛出指明语言与文件来源的 `IllegalArgumentException`。因此新增语言仍需完整资源集 — 协商模板与 LLM 提示词资源 — 仅添加词表文件并不足够。请在 `client.env` / `server.env` 中显式设置语言: diff --git a/tools/template_lint.py b/tools/template_lint.py index 727c6120..9761c931 100644 --- a/tools/template_lint.py +++ b/tools/template_lint.py @@ -132,12 +132,22 @@ def lint_pair(template_path: Path, schema_path: Path) -> list[str]: return errors +def _reject_duplicate_keys(pairs: list[tuple[str, str]]) -> dict[str, str]: + seen: set[str] = set() + for key, _ in pairs: + if key in seen: + raise ValueError(f"duplicate key '{key}'") + seen.add(key) + return dict(pairs) + + def load_negotiation_vocabularies(resource_root: Path) -> tuple[dict[str, dict[str, str]], list[str]]: """Loads negotiation-vocabulary/{lang}/vocabulary.json for both languages from the resource root. The vocabulary files are the single source of the Negotiation-T section titles and slot marker names; the tables - are no longer hardcoded here. Both language files must exist, parse into a flat string-to-string object, expose - identical key sets, and carry every section.*/slot.* key the template profiles reference. + are no longer hardcoded here. Both language files must exist, parse into a flat string-to-string object without + duplicate keys or blank values, expose identical key sets, and carry every section.*/slot.* key the template + profiles reference. """ vocabulary_root = resource_root / "negotiation-vocabulary" if not vocabulary_root.is_dir(): @@ -152,13 +162,19 @@ def load_negotiation_vocabularies(resource_root: Path) -> tuple[dict[str, dict[s for language in NEGOTIATION_LANGUAGES: path = vocabulary_root / language / "vocabulary.json" try: - data = json.loads(path.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + data = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_reject_duplicate_keys) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: errors.append(error(path, 1, "negotiation-vocabulary", f"Cannot load negotiation vocabulary: {exc}")) continue if not isinstance(data, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in data.items()): errors.append(error(path, 1, "negotiation-vocabulary", "Vocabulary must be a flat JSON object mapping keys to strings.")) continue + blank_keys = sorted(key for key, value in data.items() if not value.strip()) + if blank_keys: + errors.append( + error(path, 1, "negotiation-vocabulary-blank-value", f"Blank values are not allowed: {', '.join(blank_keys)}.") + ) + continue vocabularies[language] = data if len(vocabularies) == len(NEGOTIATION_LANGUAGES): zh_keys, en_keys = set(vocabularies["zh-CN"]), set(vocabularies["en-US"]) From 5d18fda1a5a9051b7f352b5f34eb50481924aa9a Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 10:46:38 +0800 Subject: [PATCH 085/295] docs(core,negotiation): fix @since month to 2026-08 for newly added classes Signed-off-by: codetalks-hw --- .../main/java/net/openan/a2at/sdk/core/exception/A2ATError.java | 2 +- .../java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java | 2 +- .../a2at/sdk/core/exception/A2ATParamExtractionError.java | 2 +- .../net/openan/a2at/sdk/core/model/SlotValidationError.java | 2 +- .../a2at/sdk/negotiation/content/FeasibilityEndingContent.java | 2 +- .../a2at/sdk/negotiation/content/FeasibilityProposeContent.java | 2 +- .../a2at/sdk/negotiation/content/InformationEndingContent.java | 2 +- .../a2at/sdk/negotiation/content/InformationProposeContent.java | 2 +- .../openan/a2at/sdk/negotiation/content/NegotiationAction.java | 2 +- .../a2at/sdk/negotiation/content/NegotiationConclusion.java | 2 +- .../openan/a2at/sdk/negotiation/content/NegotiationContent.java | 2 +- .../openan/a2at/sdk/negotiation/content/NegotiationContext.java | 2 +- .../a2at/sdk/negotiation/content/NegotiationEndingContent.java | 2 +- .../a2at/sdk/negotiation/content/NegotiationEndingData.java | 2 +- .../sdk/negotiation/content/NegotiationGenerationException.java | 2 +- .../openan/a2at/sdk/negotiation/content/NegotiationItem.java | 2 +- .../content/NegotiationParamExtractionException.java | 2 +- .../openan/a2at/sdk/negotiation/content/NegotiationPhase.java | 2 +- .../sdk/negotiation/content/NegotiationProcessingException.java | 2 +- .../a2at/sdk/negotiation/content/NegotiationProposeContent.java | 2 +- .../a2at/sdk/negotiation/content/NegotiationProposeData.java | 2 +- .../openan/a2at/sdk/negotiation/content/NegotiationType.java | 2 +- .../a2at/sdk/negotiation/content/TargetEndingContent.java | 2 +- .../a2at/sdk/negotiation/content/TargetProposeContent.java | 2 +- .../net/openan/a2at/sdk/negotiation/content/Vocabulary.java | 2 +- .../generation/DefaultNegotiationContentExtractor.java | 2 +- .../sdk/negotiation/generation/FeasibilityEndingGenerator.java | 2 +- .../sdk/negotiation/generation/FeasibilityProposeGenerator.java | 2 +- .../sdk/negotiation/generation/InformationEndingGenerator.java | 2 +- .../sdk/negotiation/generation/InformationProposeGenerator.java | 2 +- .../sdk/negotiation/generation/NegotiationContentExtractor.java | 2 +- .../generation/NegotiationGenerationOrchestrator.java | 2 +- .../generation/NegotiationGenerationOrchestratorBuilder.java | 2 +- .../a2at/sdk/negotiation/generation/NegotiationGenerator.java | 2 +- .../negotiation/generation/NegotiationGeneratorRegistry.java | 2 +- .../sdk/negotiation/generation/NegotiationItemFormatter.java | 2 +- .../negotiation/generation/NegotiationJsonSchemaBuilder.java | 2 +- .../sdk/negotiation/generation/NegotiationMessageBuilder.java | 2 +- .../sdk/negotiation/generation/NegotiationPromptRenderer.java | 2 +- .../negotiation/generation/NegotiationPromptResourceLoader.java | 2 +- .../sdk/negotiation/generation/NegotiationRenderException.java | 2 +- .../a2at/sdk/negotiation/generation/TargetEndingGenerator.java | 2 +- .../a2at/sdk/negotiation/generation/TargetProposeGenerator.java | 2 +- .../negotiation/resources/DefaultNegotiationTemplateLoader.java | 2 +- .../a2at/sdk/negotiation/resources/NegotiationReference.java | 2 +- .../sdk/negotiation/resources/NegotiationTemplateLoader.java | 2 +- .../validation/DefaultNegotiationComplianceChecker.java | 2 +- .../validation/DefaultNegotiationSemanticValidator.java | 2 +- .../negotiation/validation/NegotiationComplianceChecker.java | 2 +- .../sdk/negotiation/validation/NegotiationRuleCheckResult.java | 2 +- .../negotiation/validation/NegotiationSemanticValidator.java | 2 +- .../negotiation/validation/NegotiationValidationException.java | 2 +- .../openan/a2at/sdk/negotiation/validation/ParamExtractor.java | 2 +- .../sdk/negotiation/validation/SemanticValidationResult.java | 2 +- 54 files changed, 54 insertions(+), 54 deletions(-) diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java index 3fd38742..6fd69b93 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java @@ -12,7 +12,7 @@ * {@code null}. Caller contract violations (null, blank or otherwise malformed arguments) are programming errors raised * as {@link NullPointerException} or {@link IllegalArgumentException} and intentionally stay outside this tree. * - * @since 2026-06 + * @since 2026-08 */ public class A2ATError extends RuntimeException { diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java index ba450359..5feb3c40 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java @@ -6,7 +6,7 @@ /** * Centralized machine-readable error code constants shared across A2A-T SDK processing failures. * - * @since 2026-06 + * @since 2026-08 */ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class A2ATErrorCodes { diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java index 980accea..844b2684 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java @@ -11,7 +11,7 @@ * machine-readable error code is carried by the root {@link A2ATError} and is never null; subclasses pass a more * specific code when one is available. * - * @since 2026-06 + * @since 2026-08 */ public class A2ATParamExtractionError extends A2ATError { diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/SlotValidationError.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/SlotValidationError.java index 4627afe4..21f4e8cc 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/SlotValidationError.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/SlotValidationError.java @@ -9,6 +9,6 @@ * @param slotName name of the slot the error refers to * @param code machine-readable error code for the error * @param message human-readable explanation of the error - * @since 2026-06 + * @since 2026-08 */ public record SlotValidationError(String slotName, String code, String message) {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityEndingContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityEndingContent.java index 33081057..0c1578a8 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityEndingContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityEndingContent.java @@ -5,7 +5,7 @@ * * @param conclusion terminal conclusion of the feasibility negotiation * @param feasibilitySummary required summary of the feasibility evaluation result - * @since 2026-06 + * @since 2026-08 */ public record FeasibilityEndingContent(NegotiationConclusion conclusion, String feasibilitySummary) implements NegotiationEndingContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityProposeContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityProposeContent.java index 3e9f52c0..af0284fa 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityProposeContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityProposeContent.java @@ -11,7 +11,7 @@ * {@link NegotiationAction#REQUEST_FEASIBILITY_EVALUATION}) * @param infeasibilityDetailsAndProposal infeasibility details and an alternative proposal; null or empty omits the * section (only for {@link NegotiationAction#PROPOSE_ALTERNATIVE_ON_FAILURE}) - * @since 2026-06 + * @since 2026-08 */ public record FeasibilityProposeContent( String feasibilityNegotiationDescription, diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationEndingContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationEndingContent.java index cf4e7257..3fa5894a 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationEndingContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationEndingContent.java @@ -7,7 +7,7 @@ * * @param conclusion terminal conclusion of the information negotiation * @param items information items delivered with the terminal message - * @since 2026-06 + * @since 2026-08 */ public record InformationEndingContent(NegotiationConclusion conclusion, List items) implements NegotiationEndingContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationProposeContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationProposeContent.java index 452d1502..c5914084 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationProposeContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationProposeContent.java @@ -7,7 +7,7 @@ * * @param items information items the requester is missing * @param relationship free-form description of how the missing items relate to each other; null when there is none - * @since 2026-06 + * @since 2026-08 */ public record InformationProposeContent(List items, String relationship) implements NegotiationProposeContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAction.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAction.java index 9a3fd434..24ab6ca3 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAction.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAction.java @@ -6,7 +6,7 @@ *

The action is not rendered into the message; it selects which conditional sections of the feasibility propose * template are emitted. * - * @since 2026-06 + * @since 2026-08 */ public enum NegotiationAction { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationConclusion.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationConclusion.java index c3d0c668..19c134c3 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationConclusion.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationConclusion.java @@ -6,7 +6,7 @@ *

Only {@code ACCEPT} and {@code REJECT} are renderable conclusions; {@code ABORT} exists in the model but no * bundled template carries it, so generation methods reject it as a programming error. * - * @since 2026-06 + * @since 2026-08 */ public enum NegotiationConclusion { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java index 9ec16636..1893d363 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java @@ -7,6 +7,6 @@ * {@link NegotiationEndingContent} for terminal messages. Generators and content extractors accept this common * supertype and dispatch on the exact runtime type. * - * @since 2026-06 + * @since 2026-08 */ public sealed interface NegotiationContent permits NegotiationProposeContent, NegotiationEndingContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContext.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContext.java index 74951307..24a1a4cd 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContext.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContext.java @@ -9,7 +9,7 @@ * @param id unique identifier of the negotiation session, expected to be a UUID * @param round current negotiation round, 1-based * @param maxRounds maximum number of rounds before the negotiation must end - * @since 2026-06 + * @since 2026-08 */ public record NegotiationContext(String id, int round, int maxRounds) { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java index 779f378b..598cce6d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java @@ -3,7 +3,7 @@ /** * Marker for the typed content of a terminal (accept or reject) negotiation message. * - * @since 2026-06 + * @since 2026-08 */ public sealed interface NegotiationEndingContent extends NegotiationContent permits InformationEndingContent, TargetEndingContent, FeasibilityEndingContent { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingData.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingData.java index a0f1b93a..f5976b28 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingData.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingData.java @@ -10,6 +10,6 @@ * * @param context negotiation session context * @param content typed terminal content matching the negotiation type addressed by the template URI - * @since 2026-06 + * @since 2026-08 */ public record NegotiationEndingData(NegotiationContext context, NegotiationEndingContent content) {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java index 173c3762..6744acf6 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java @@ -3,7 +3,7 @@ /** * Raised when generating a negotiation message fails at runtime. * - * @since 2026-06 + * @since 2026-08 */ public class NegotiationGenerationException extends NegotiationProcessingException { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationItem.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationItem.java index 6545c40c..381eacab 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationItem.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationItem.java @@ -5,6 +5,6 @@ * * @param name item name such as a field path or identifier; never blank in valid content * @param value free-form explanation of the item such as meaning, format, example or reason; may be null - * @since 2026-06 + * @since 2026-08 */ public record NegotiationItem(String name, String value) {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationParamExtractionException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationParamExtractionException.java index d5463eab..1f4fac62 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationParamExtractionException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationParamExtractionException.java @@ -7,7 +7,7 @@ /** * Raised when validating a negotiation message and extracting parameters from it fails. * - * @since 2026-06 + * @since 2026-08 */ public class NegotiationParamExtractionException extends A2ATParamExtractionError { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationPhase.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationPhase.java index 3059538f..28c728e2 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationPhase.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationPhase.java @@ -7,7 +7,7 @@ * and {@code reject} share the {@code accept-reject} template, differing only in the conclusion value filled into the * template slot. * - * @since 2026-06 + * @since 2026-08 */ public enum NegotiationPhase { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java index 7de5ae8a..7cb3ebfc 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java @@ -10,7 +10,7 @@ * parameter-extraction branch ({@code NegotiationParamExtractionException}) is deliberately not a subtype; catch * {@link A2ATError} for full negotiation failure coverage. * - * @since 2026-06 + * @since 2026-08 */ public class NegotiationProcessingException extends A2ATError { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java index e29a4e57..4cd873da 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java @@ -3,7 +3,7 @@ /** * Marker for the typed content of a propose-phase negotiation message. * - * @since 2026-06 + * @since 2026-08 */ public sealed interface NegotiationProposeContent extends NegotiationContent permits InformationProposeContent, TargetProposeContent, FeasibilityProposeContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeData.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeData.java index c6c69fbe..2a8fe810 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeData.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeData.java @@ -5,6 +5,6 @@ * * @param context negotiation session context * @param content typed propose content matching the negotiation type addressed by the template URI - * @since 2026-06 + * @since 2026-08 */ public record NegotiationProposeData(NegotiationContext context, NegotiationProposeContent content) {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationType.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationType.java index 4256030e..d537e875 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationType.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationType.java @@ -6,7 +6,7 @@ *

The enum also owns the hyphenated URI segment used both in template URIs and in template resource paths, so that * URIs and on-disk locations can never drift apart. * - * @since 2026-06 + * @since 2026-08 */ public enum NegotiationType { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetEndingContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetEndingContent.java index b41396cf..63d0889a 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetEndingContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetEndingContent.java @@ -9,7 +9,7 @@ * @param conclusion terminal conclusion of the target negotiation * @param confirmedIntent confirmed intent for an accept conclusion; null otherwise * @param failureReason reason for a reject conclusion; null otherwise - * @since 2026-06 + * @since 2026-08 */ public record TargetEndingContent(NegotiationConclusion conclusion, String confirmedIntent, String failureReason) implements NegotiationEndingContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetProposeContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetProposeContent.java index 52cc0fb8..327381ba 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetProposeContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetProposeContent.java @@ -11,7 +11,7 @@ * @param alignmentAndClarification alignment statements and clarifications; null or empty omits the section (later * rounds only) * @param requestForClarification open clarification requests; null or empty omits the section - * @since 2026-06 + * @since 2026-08 */ public record TargetProposeContent( String targetNegotiationDescription, diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java index 85728d48..5d94091d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java @@ -39,7 +39,7 @@ * context classloader (jar bytes are immutable), while a local resource root is re-resolved on every call so that * overriding, editing or removing a local vocabulary file takes effect immediately. * - * @since 2026-06 + * @since 2026-08 */ public final class Vocabulary { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java index 1b628fe2..9596556b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java @@ -36,7 +36,7 @@ * non-retryable {@code negotiation_slot_missing}, and content that contradicts the addressed phase or action maps to * {@code negotiation_invalid_input}. * - * @since 2026-06 + * @since 2026-08 */ final class DefaultNegotiationContentExtractor implements NegotiationContentExtractor { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java index 081d0a54..695c7a2c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java @@ -14,7 +14,7 @@ *

The message carries the terminal conclusion literal and the required feasibility summary. The summary slot name * differs from its section title, so the vocabulary exception key is used. * - * @since 2026-06 + * @since 2026-08 */ final class FeasibilityEndingGenerator extends AbstractNegotiationGenerator { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java index 9a175989..5bb78cbf 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java @@ -17,7 +17,7 @@ * to evaluate, while proposing an alternative after an infeasible outcome renders the infeasibility details and * proposal. Exactly one of the two sections is always present. * - * @since 2026-06 + * @since 2026-08 */ final class FeasibilityProposeGenerator extends AbstractNegotiationGenerator { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java index 3bb0d94b..8d25542c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java @@ -13,7 +13,7 @@ * *

The message carries the terminal conclusion literal and the information items delivered with it. * - * @since 2026-06 + * @since 2026-08 */ final class InformationEndingGenerator extends AbstractNegotiationGenerator { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java index cdfb8df0..e55747aa 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java @@ -14,7 +14,7 @@ *

The message carries the requested information items and, when present, a free-form line describing how the missing * items relate to each other, appended after the item list. * - * @since 2026-06 + * @since 2026-08 */ final class InformationProposeGenerator extends AbstractNegotiationGenerator { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java index d9c7d7ce..a064efef 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java @@ -10,7 +10,7 @@ *

Implementations decide how the text is understood; the returned content must already satisfy the generator input * rules so the deterministic rendering step can consume it directly. * - * @since 2026-06 + * @since 2026-08 */ public interface NegotiationContentExtractor { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index ecc5fad6..2509f2cf 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -43,7 +43,7 @@ *

Instances are created through {@link NegotiationGenerationOrchestratorBuilder}; the builder wires the default * collaborators and allows overriding each of them. * - * @since 2026-06 + * @since 2026-08 */ public final class NegotiationGenerationOrchestrator { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index 288fff41..28aae60d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -23,7 +23,7 @@ * language and the optional local resource root (templates and negotiation vocabulary), and each of them can be * overridden for testing or customization. * - * @since 2026-06 + * @since 2026-08 */ public final class NegotiationGenerationOrchestratorBuilder { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java index 3ed1cbf5..bd2dd2af 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java @@ -12,7 +12,7 @@ * delegate the final rendering step. The registry is the only supported dispatch path; it guarantees the exact runtime * type of the content before a generator is invoked. * - * @since 2026-06 + * @since 2026-08 */ interface NegotiationGenerator { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java index da12e1e9..43b8bc0f 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java @@ -26,7 +26,7 @@ * and the content type must match the negotiation type. Subtype matching is deliberately not supported; new content * types must be registered explicitly. * - * @since 2026-06 + * @since 2026-08 */ final class NegotiationGeneratorRegistry { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java index c6926d0c..1729ae4c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java @@ -9,7 +9,7 @@ *

Each item becomes one line {@code N. namevalue}; an item without a value becomes {@code N. name}. The * caller supplies the list punctuation from the negotiation vocabulary so the output matches the message language. * - * @since 2026-06 + * @since 2026-08 */ public final class NegotiationItemFormatter { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java index 590d405b..707f67f4 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java @@ -15,7 +15,7 @@ * (negotiation type, phase) pair with accept and reject sharing the terminal schema. The semantic validation schema * merges a caller-provided parameter schema into the fixed four-key output contract of the semantic validation step. * - * @since 2026-06 + * @since 2026-08 */ final class NegotiationJsonSchemaBuilder { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java index b2df3e26..3eaeb74f 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java @@ -12,7 +12,7 @@ * supplied for that token name; a supplied null value is replaced with an empty string and bracket tokens without a * supplied value are left unchanged. * - * @since 2026-06 + * @since 2026-08 */ final class NegotiationMessageBuilder { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java index 3e195aeb..5ebf2e90 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRenderer.java @@ -20,7 +20,7 @@ * null template text with the internal {@link NegotiationRenderException} instead of a {@code NullPointerException}, * so the orchestration layer wraps the failure into a typed negotiation generation failure rather than letting it leak. * - * @since 2026-06 + * @since 2026-08 */ class NegotiationPromptRenderer implements SectionedTemplateRenderer { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java index b57bc5d7..9ce36574 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java @@ -15,7 +15,7 @@ * {@code target_negotiation}, {@code feasibility_negotiation} and {@code negotiation_semantic_validation}, each with a * {@code system.md} and a {@code user.md} per language. * - * @since 2026-06 + * @since 2026-08 */ class NegotiationPromptResourceLoader { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java index 14ddee0c..2a7a43b0 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java @@ -6,7 +6,7 @@ *

This exception never bubbles out of the public API; the orchestration layer maps it to a typed negotiation failure * instead. * - * @since 2026-06 + * @since 2026-08 */ class NegotiationRenderException extends RuntimeException { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java index d10af152..2abcb2a1 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java @@ -15,7 +15,7 @@ *

The result content slot carries the confirmed intent for an accept conclusion or the failure reason for a reject * conclusion; the other field is ignored. * - * @since 2026-06 + * @since 2026-08 */ final class TargetEndingGenerator extends AbstractNegotiationGenerator { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java index 3ed944a6..77fa5239 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java @@ -15,7 +15,7 @@ * understanding section appears only on the first round, the alignment and clarification section only on later rounds, * and the clarification request section only when clarification items are present. * - * @since 2026-06 + * @since 2026-08 */ final class TargetProposeGenerator extends AbstractNegotiationGenerator { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java index d6a94a1a..941bb1bc 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -31,7 +31,7 @@ * {@code templates/} tree. Only templates are taken from the local root; LLM prompt resources always come from the * classpath. * - * @since 2026-06 + * @since 2026-08 */ public final class DefaultNegotiationTemplateLoader implements NegotiationTemplateLoader { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java index 019fc382..8c206431 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -21,7 +21,7 @@ * @param type negotiation type addressed by the reference * @param phase API-level phase addressed by the reference; accept and reject share the same template * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @since 2026-06 + * @since 2026-08 */ public record NegotiationReference(NegotiationType type, NegotiationPhase phase, String language) { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java index 11ede471..e3389864 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java @@ -6,7 +6,7 @@ /** * Loads negotiation templates addressed by {@link NegotiationReference} keys. * - * @since 2026-06 + * @since 2026-08 */ public interface NegotiationTemplateLoader { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java index e8cddaec..40acb620 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java @@ -21,7 +21,7 @@ * are applied: the checker does not infer the negotiation type, does not validate conclusion values, does not require * ending result sections and does not check conditional-section exclusivity. * - * @since 2026-06 + * @since 2026-08 */ public final class DefaultNegotiationComplianceChecker implements NegotiationComplianceChecker { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java index eb5d93c2..e7a300d0 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java @@ -42,7 +42,7 @@ * declared template and the message sections is part of the semantic tasks performed by the LLM and surfaces through * the returned errors. * - * @since 2026-06 + * @since 2026-08 */ public final class DefaultNegotiationSemanticValidator implements NegotiationSemanticValidator { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java index 846fbad3..3eec9930 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java @@ -12,7 +12,7 @@ * ending-section presence and conditional-section exclusivity are deliberately not checked here; they belong to the * semantic validation step. * - * @since 2026-06 + * @since 2026-08 */ public interface NegotiationComplianceChecker { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java index ac10b30c..948d287b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java @@ -19,7 +19,7 @@ * message or when every rule passes * @param context parsed negotiation context; {@code null} unless the text is a negotiation message whose context is * fully valid - * @since 2026-06 + * @since 2026-08 */ public record NegotiationRuleCheckResult( boolean passed, boolean isNegotiation, List errors, NegotiationContext context) { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java index 3dc9baf5..8b575115 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java @@ -18,7 +18,7 @@ * the four required keys or has the wrong shape is a validation infrastructure failure signalled through the internal * {@link NegotiationValidationException}. * - * @since 2026-06 + * @since 2026-08 */ public interface NegotiationSemanticValidator extends SemanticValidator { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java index 26fb90d1..c1e034b8 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java @@ -8,7 +8,7 @@ * the orchestration layer converts it into a parameter-extraction failure carrying the retryable LLM infrastructure * error code. * - * @since 2026-06 + * @since 2026-08 */ public class NegotiationValidationException extends RuntimeException { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java index 15c6c8bb..e243fbc1 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java @@ -19,7 +19,7 @@ * {@link RuleChecker} adapter and a {@link SemanticValidator} adapter bridge the negotiation types to the core * validation contracts. * - * @since 2026-06 + * @since 2026-08 */ public final class ParamExtractor { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java index 0e67037b..d667d32a 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java @@ -18,7 +18,7 @@ * @param errors structured semantic errors using language-neutral {@code section.*} slot names; empty when the verdict * is true * @param params parameters extracted from the message per the caller-provided schema - * @since 2026-06 + * @since 2026-08 */ public record SemanticValidationResult( boolean verdict, String negotiationType, List errors, Map params) { From b2493f21d6b431eb171419ba5a3c56de95f6188c Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Sat, 22 Aug 2026 10:50:25 +0800 Subject: [PATCH 086/295] feat: add abort template, refresh energy-saving template Signed-off-by: zhangwenjun-huawei --- .../sdk/core/model/StandardTemplates.java | 7 +- ...rdTemplatesNegotiationConsistencyTest.java | 1 + .../common/abort/v1/en-US/template.md | 17 +++++ .../common/abort/v1/zh-CN/template.md | 17 +++++ .../energy-saving/v1/en-US/template.md | 62 +++++++-------- .../energy-saving/v1/zh-CN/template.md | 76 ++++++++++--------- .../sdk/resources/StandardTemplatesTest.java | 4 +- tools/template_lint.py | 8 +- 8 files changed, 121 insertions(+), 71 deletions(-) create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/en-US/template.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/zh-CN/template.md diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java index 0a428299..daafd802 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java @@ -83,6 +83,10 @@ private StandardTemplates() { public static final TemplateUri FEASIBILITY_NEGOTIATION_ACCEPT_REJECT = TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "feasibility-negotiation", "accept-reject"); + /** Negotiation-T common abort template. */ + public static final TemplateUri NEGOTIATION_ABORT = + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "common", "abort"); + /** All built-in Task-T templates. */ public static final List TASK = List.of(ENERGY_SAVING, PRIVATE_LINE_COMPLAINT); @@ -99,5 +103,6 @@ private StandardTemplates() { TARGET_NEGOTIATION_PROPOSE, TARGET_NEGOTIATION_ACCEPT_REJECT, FEASIBILITY_NEGOTIATION_PROPOSE, - FEASIBILITY_NEGOTIATION_ACCEPT_REJECT); + FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + NEGOTIATION_ABORT); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java index 8475592e..c3e85a9f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java @@ -40,6 +40,7 @@ void negotiationGroupCoversExactlyTheComposedUris() { expected.add(new NegotiationReference(type, phase, "en-US").uri()); } } + expected.add(StandardTemplates.NEGOTIATION_ABORT.uri()); assertEquals(expected.stream().sorted().toList(), composed); } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/en-US/template.md new file mode 100644 index 00000000..b82f170a --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/en-US/template.md @@ -0,0 +1,17 @@ +## Negotiation Context +{{negotiation_context}} (required) +Requirement: +Each negotiation must contain the following information: +- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept, Reject, Abort), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: The current round. Type: positive integer. Example: 1 +- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 + +## Negotiation Result +Abort + +## Negotiation Termination Reason +{{negotiation_termination_reason}} (required) +Requirement: +State the reason for negotiation termination, such as reaching the negotiation round limit, timeout, token consumption limit, etc. +Example: +Reached the negotiation round limit. This negotiation is confirmed and ended. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/zh-CN/template.md new file mode 100644 index 00000000..5c425448 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/zh-CN/template.md @@ -0,0 +1,17 @@ +## 协商上下文 +{{协商上下文}}(必填) +要求: +每次协商必须包含以下信息: +- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept、Reject、Abort),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 +- round: 当前轮次;类型:正整数;举例:1 +- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 + +## 协商结果 +Abort + +## 协商终止原因 +{{协商终止原因}}(必填) +要求: +说明协商终止的原因,如达到协商轮次上限、超时、token消耗超限等 +举例: +达到协商轮次上限,本次协商确认结束。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/en-US/template.md index c1f55b45..b77ad759 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/en-US/template.md @@ -1,5 +1,6 @@ ## Operation Type {{operation_type}} (required) + Requirement: Please provide the operation type. Allowed values: Create, Modify @@ -8,58 +9,59 @@ Wireless network energy saving ## Task Description {{task_description}} (required) + Requirement: Please dynamically provide the task description based on (Create/Modify): - -When the type is [Create]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in . -When the type is [Modify]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in . +- When the type is [Create]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in . +- When the type is [Modify]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in . ## Task Object {{task_object}} (required) -Requirement: Explicitly specify the energy saving coverage scope information, a geographic or logical area (e.g., district-level administrative unit). The area information is a required parameter. -The following are specific descriptions: -1. Area name. Example: Area: "Songshanhu Administration Committee" - **Important**: The area information must be a concrete physical address, for example a real physical community such as "Songshanhu Administration Committee", rather than a description without a specific physical location such as "all communities", "indoor communities", or "outdoor communities". +Requirement: +1. Area name: (required) + - Explicitly specify the energy saving coverage scope information, a geographic or logical area (e.g., district-level administrative unit) + - Must be a concrete and physically real community name such as "Songshanhu Administration Committee", rather than a description without a specific physical location such as "all communities", "indoor communities", or "outdoor communities" + - Example: Area: "Songshanhu Administration Committee" ## Task Target {{task_target}} (optional) -Requirement: Define the quantitative or qualitative goals the task must achieve. Quantitative and qualitative goals cannot be selected at the same time, and the default is a qualitative goal. -Specific goal description for task-level energy saving: -1. Quantitative goal - Energy consumption goal: the gain target of energy saving. The target value format must be a percentage, such as the percentage by which total power consumption is reduced. Example: Energy consumption goal: 30% -2. Qualitative goal - Maximize energy saving under the conditions defined in the task context +Requirement: +Define the quantitative or qualitative goals the task must achieve. Quantitative and qualitative goals cannot be selected at the same time, and the default is a qualitative goal. The specific description of energy saving goals is as follows: +1. Quantitative goal: Energy consumption goal; the gain target of energy saving. The target value format must be a percentage, such as the percentage by which total power consumption is reduced. Example: Energy consumption goal: 30% +2. Qualitative goal: Maximize energy saving under the conditions defined in the task context + ## Task Context {{task_context}} (optional) + Requirement: 1. Cell radio access technology for energy saving (LTE/NR/all). Example: Cell RAT for energy saving: NR 2. The time range for executing energy saving, recurring daily. If omitted, energy saving is performed for the full period, and experience-lossless energy saving is adopted by default. **The rate goal is guaranteed only within the energy saving enabled time range**. Example: Energy saving time range: start time "08:00:00", end time "12:00:00" 3. Carrier frequencies that can be turned off during energy saving. Example: Downlink frequency numbers: 1650, 1600 4. Rate guarantee goal: the minimum downlink throughput target that must be guaranteed and its effective time period. - For the area-level setting, the following information needs to be provided: - Option 1: - Time-segmented rate guarantee goal (required) - Example: Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {18:00~00:00,20Mbps} - - Option 2: - Full-period rate guarantee goal - Example: Rate guarantee goal: 10Mbps - For the site-level setting, the following information needs to be provided: - Site list (required) - Time-segmented rate guarantee goal (required) - Background event and event duration (optional) - Example: - Site list: ["Site Name 1", "Site Name 2"], Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {18:00~00:00,20Mbps}, Background event: power outage from 00:00 to 06:00. - Site list: ["Site Name 3", "Site Name 4"], Time-segmented rate guarantee goal: {01:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {19:00~00:00,20Mbps}, Background event: power outage from 00:00 to 06:00. - + - For the area-level setting, the following information needs to be provided: + Option 1: Time-segmented rate guarantee goal (required) + Example: Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {18:00~00:00,20Mbps} + Option 2: Full-period rate guarantee goal (required) + Example: Rate guarantee goal: 10Mbps + - For the site-level setting, the following information needs to be provided: + Site list (required) + Time-segmented rate guarantee goal (required) + Background event and event duration (optional) + Example: + - Site list 1: ["Site Name 1", "Site Name 2"], Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {18:00~00:00,20Mbps}, Background event: power outage from 00:00 to 06:00. + - Site list 2: ["Site Name 3", "Site Name 4"], Time-segmented rate guarantee goal: {01:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {19:00~00:00,20Mbps}, Background event: power outage from 00:00 to 06:00. ## Expected Output {{expected_output}} (optional when creating, not required when modifying) -Requirement: When the task object is a geographic/logical area, output the intent report for the network energy saving task. The intent report should contain the following information: + +Requirement: +When the task object is a geographic/logical area, output the intent report for the network energy saving task. The intent report should contain the following information: ### Intent Report Basic Information 1. Unique identifier of the network energy saving report 2. Unique identifier of the energy saving task -3. Execution result of the overall intent, including the execution result status and execution result details. If the goal is not achieved, describe the specific reason for not achieving it. +3. Execution result of the overall intent, including the execution result status and execution result details. If the goal is not achieved, describe the specific reason for not achieving it ### Intent Report Detailed Information -1. Execution result of specific KPIs, including the execution result status and execution result details. If the goal is not achieved, describe the specific reason for not achieving it. +1. Execution result of specific KPIs, including the execution result status and execution result details. If the goal is not achieved, describe the specific reason for not achieving it diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/zh-CN/template.md index 4656774d..91c7cb06 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/zh-CN/template.md @@ -1,5 +1,6 @@ ## 操作类型(Operation Type) {{操作类型}}(必填) + 要求: 请提供操作类型,取值范围:创建、修改 @@ -8,58 +9,59 @@ ## 任务描述(Task Description) {{任务描述}}(必填) + 要求: 请根据<操作类型>(创建/修改),动态提供任务描述信息: - -类型为【创建】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。 -类型为【修改】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标。 +- 类型为【创建】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。 +- 类型为【修改】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标。 ## 任务对象(Task Object) -{{任务对象}}(必选) -要求:明确指定节能覆盖范围信息,地理或逻辑区域(如 区级行政单位)。区域信息为必选参数。 +{{任务对象}}(必填) -以下是具体说明: -1. 区域名称, 举例: 区域:“松山湖管委会” - **重要说明**: 区域信息,例如,“松山湖管委会”这种物理真实小区,而不是“所有小区”、“室内小区”、“室外小区”这种无具体物理位置的描述信息 +要求: +1. 区域名称:(必填) + - 明确指定节能覆盖范围信息,地理或逻辑区域(如 区级行政单位) + - 必须是类似"松山湖管委会"这种具体且物理真实的小区名称,而不是"所有小区"、"室内小区"、"室外小区"这种无具体物理位置的描述 + - 举例:区域:"松山湖管委会" ## 任务目标(Task Target) -{{任务目标}}(可选) -要求:定义任务需达成的量化或定性目标,量化目标和定性目标不可同时选择,默认为定性目标。 -任务级节能的具体目标说明: -1. 量化目标 能耗目标:节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比 举例: 能耗目标: 30% -2. 定性目标 在满足任务上下文的条件下进行最大化节能 - +{{任务目标}}(选填) + +要求: +定义任务需达成的量化或定性目标,量化目标和定性目标不可同时选择,默认为定性目标。节能目标的具体说明如下: +1. 量化目标:能耗目标;节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比;举例:能耗目标:30% +2. 定性目标:在满足任务上下文的条件下进行最大化节能 + ## 任务上下文(Task Context) -{{任务上下文}}(可选) +{{任务上下文}}(选填) + 要求: -1. 节能的小区制式(LTE/NR/全部) 举例: 节能的小区制式: NR -2. 需要执行节能的时间段区间范围,以天为周期循环执行,缺省则进行全时段节能,默认采取用户体验无损方式节能。**仅在节能开启时间范围内保障速率目标** 举例: 需要节能的时间范围: 起始时间"08:00:00",结束时间"12:00:00" -3. 可在节能时关闭载波的频点 举例: 下行频点号:1650, 1600 +1. 节能的小区制式(LTE/NR/全部) ;举例:节能的小区制式:NR +2. 需要执行节能的时间段区间范围,以天为周期循环执行,缺省则进行全时段节能,默认采取用户体验无损方式节能。**仅在节能开启时间范围内保障速率目标**;举例: 需要节能的时间范围: 起始时间"08:00:00",结束时间"12:00:00" +3. 可在节能时关闭载波的频点;举例: 下行频点号:1650, 1600 4. 速率保障目标:需要保障的下行吞吐率目标最小值及生效时间段。 - 针对区域级设置方式,需要填写以下信息: - 方式一: - 分时段速率保障目标(必填) - 举例:分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{18:00~00:00,20Mbps} - - 方式二: - 全时段速率保障目标 - 举例:速率保障目标:10Mbps - 针对站点级设置方式,需要填写以下信息: - 站点列表(必填) - 分时段速率保障目标(必填) - 背景事件与事件持续时间(选填) - 举例: - 站点列表:[“站点名称1”,“站点名称2”],分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{18:00~00:00,20Mbps},背景事件:在00:00~06:00停电。 - 站点列表:[“站点名称3”,“站点名称4”],分时段速率保障目标:{01:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{19:00~00:00,20Mbps},背景事件:在00:00~06:00停电。 - + - 针对区域级设置方式,需要填写以下信息: + 方式一:分时段速率保障目标(必填) + 举例:分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{18:00~00:00,20Mbps} + 方式二:全时段速率保障目标(必填) + 举例:速率保障目标:10Mbps + - 针对站点级设置方式,需要填写以下信息: + 站点列表(必填) + 分时段速率保障目标(必填) + 背景事件与事件持续时间(选填) + 举例: + - 站点列表1:[“站点名称1”,“站点名称2”],分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{18:00~00:00,20Mbps},背景事件:在00:00~06:00停电。 + - 站点列表2:[“站点名称3”,“站点名称4”],分时段速率保障目标:{01:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{19:00~00:00,20Mbps},背景事件:在00:00~06:00停电。 ## 预期输出(Expected Output) {{预期输出}}(创建时选填,修改时无需填写) -要求: 若任务对象为地理/逻辑区域时,输出网络节能任务的意图报告,意图报告应包含以下信息: + +要求: +若任务对象为地理/逻辑区域时,输出网络节能任务的意图报告,意图报告应包含以下信息: ### 意图报告基本信息 1. 网络节能报告的唯一标识 2. 节能任务唯一标识 -3. 总体意图的执行结果,执行结果包括执行结果状态,执行结果详情,如果未达成目标,描述未达成的具体原因。 +3. 总体意图的执行结果,执行结果包括执行结果状态,执行结果详情,如果未达成目标,描述未达成的具体原因 ### 意图报告详细信息 -1. 具体KPI的执行结果,执行结果包括执行结果状态,执行结果详情,如果未达成目标,描述未达成的具体原因。 +1. 具体KPI的执行结果,执行结果包括执行结果状态,执行结果详情,如果未达成目标,描述未达成的具体原因 diff --git a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/StandardTemplatesTest.java b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/StandardTemplatesTest.java index 2bdc1a98..677f02af 100644 --- a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/StandardTemplatesTest.java +++ b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/StandardTemplatesTest.java @@ -47,11 +47,11 @@ void groupsPartitionAllConstants() { grouped.addAll(StandardTemplates.NOTIFICATION); grouped.addAll(StandardTemplates.AUTHORIZATION); grouped.addAll(StandardTemplates.NEGOTIATION); - assertEquals(11, grouped.size(), "expected the 11 built-in templates"); + assertEquals(12, grouped.size(), "expected the 12 built-in templates"); assertEquals(2, StandardTemplates.TASK.size()); assertEquals(2, StandardTemplates.NOTIFICATION.size()); assertEquals(1, StandardTemplates.AUTHORIZATION.size()); - assertEquals(6, StandardTemplates.NEGOTIATION.size()); + assertEquals(7, StandardTemplates.NEGOTIATION.size()); } /** diff --git a/tools/template_lint.py b/tools/template_lint.py index 9761c931..bbb67209 100644 --- a/tools/template_lint.py +++ b/tools/template_lint.py @@ -362,6 +362,9 @@ def lint_negotiation(templates_dir: Path, vocabularies: dict[str, dict[str, str] for type_segment in NEGOTIATION_TYPE_SEGMENTS for phase_segment in NEGOTIATION_PHASE_SEGMENTS for language in NEGOTIATION_LANGUAGES + } | { + Path("common") / "abort" / "v1" / language / "template.md" + for language in NEGOTIATION_LANGUAGES } errors: list[str] = [] shapes: dict[tuple[str, str, str], list[tuple] | None] = {} @@ -373,7 +376,10 @@ def lint_negotiation(templates_dir: Path, vocabularies: dict[str, dict[str, str] errors.append(error(path, 1, "negotiation-file-set", f"Unexpected Negotiation-T template location: {relative}")) continue found.add(relative) - type_segment, phase_segment, _, language, _ = relative.parts + parts = relative.parts + if parts[0] == "common": + continue + type_segment, phase_segment, _, language, _ = parts vocabulary = vocabularies.get(language) if vocabulary is None: # The vocabulary errors were already reported; without a usable vocabulary the per-file checks cannot run. From 514f8e8d3c4ac5caf54b4b65c74abf6aa63c063b Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 12:08:32 +0800 Subject: [PATCH 087/295] feat(negotiation): address the common abort template in the reference model Evolve NegotiationPhase with the ABORT phase and NegotiationReference with a nullable type so the type-independent common abort template becomes a first-class addressing target of the negotiation layer. Signed-off-by: codetalks-hw --- .../negotiation/content/NegotiationPhase.java | 12 ++- .../DefaultNegotiationContentExtractor.java | 1 + .../resources/NegotiationReference.java | 55 +++++++++--- .../generation/FromTextLlmPipelineTest.java | 2 + .../sdk/negotiation/golden/GoldenInputs.java | 1 + .../resources/NegotiationReferenceTest.java | 85 +++++++++++++++++++ ...rdTemplatesNegotiationConsistencyTest.java | 14 ++- 7 files changed, 151 insertions(+), 19 deletions(-) diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationPhase.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationPhase.java index 28c728e2..47dc732b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationPhase.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationPhase.java @@ -3,9 +3,9 @@ /** * Phase of the negotiation lifecycle an API call operates on. * - *

The API-level phase concept has three values, while the template URI layer only distinguishes two: {@code accept} + *

The API-level phase concept has four values, while the template URI layer only distinguishes three: {@code accept} * and {@code reject} share the {@code accept-reject} template, differing only in the conclusion value filled into the - * template slot. + * template slot, and {@code abort} is addressed by the single type-independent common abort template. * * @since 2026-08 */ @@ -18,7 +18,10 @@ public enum NegotiationPhase { ACCEPT("accept-reject"), /** Terminal phase that rejects a negotiation. */ - REJECT("accept-reject"); + REJECT("accept-reject"), + + /** Terminal phase that aborts a negotiation; addressed by the type-independent common abort template. */ + ABORT("abort"); private final String uriSegment; @@ -29,7 +32,8 @@ public enum NegotiationPhase { /** * Returns the template URI segment for this phase. * - * @return {@code propose} for the propose phase, {@code accept-reject} for both terminal phases + * @return {@code propose} for the propose phase, {@code accept-reject} for the accept and reject phases and + * {@code abort} for the abort phase */ public String uriSegment() { return uriSegment; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java index 9596556b..05c3662c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java @@ -324,6 +324,7 @@ private static String phaseToken(NegotiationPhase phase) { case PROPOSE -> "propose"; case ACCEPT -> "accept"; case REJECT -> "reject"; + case ABORT -> "abort"; }; } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java index 8c206431..da86c1a0 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -18,12 +18,16 @@ *

The reference composes the template URI from the type and phase segments, so the URI spelling has a single source. * The language is query context rather than part of the resource identity and is therefore not part of the URI. * - * @param type negotiation type addressed by the reference + *

Typed references address the templates of one negotiation type; the abort phase is type-independent and is + * addressed by the single common abort template with a {@code null} type. + * + * @param type negotiation type addressed by the reference; null only for the abort phase, whose common template is + * type-independent * @param phase API-level phase addressed by the reference; accept and reject share the same template * @param language locale identifier such as {@code zh-CN} or {@code en-US} * @since 2026-08 */ -public record NegotiationReference(NegotiationType type, NegotiationPhase phase, String language) { +public record NegotiationReference(@Nullable NegotiationType type, NegotiationPhase phase, String language) { private static final String URI_PREFIX = StandardTemplates.NEGOTIATION_EXTENSION_NAME; @@ -31,15 +35,27 @@ public record NegotiationReference(NegotiationType type, NegotiationPhase phase, private static final String TYPE_SEGMENT_SUFFIX = "-negotiation"; + private static final String COMMON_TYPE_SEGMENT = "common"; + /** * Validates the reference fields. * - * @throws NullPointerException if the type or phase is null - * @throws IllegalArgumentException if the language is not a simple path segment + * @throws NullPointerException if the phase is null + * @throws IllegalArgumentException if the type is null on a typed phase, non-null on the abort phase, or the + * language is not a simple path segment */ public NegotiationReference { - Objects.requireNonNull(type, "Negotiation reference type must not be null."); Objects.requireNonNull(phase, "Negotiation reference phase must not be null."); + if (phase == NegotiationPhase.ABORT && type != null) { + throw new IllegalArgumentException( + "Negotiation reference of the ABORT phase is type-independent; the type must be null but was " + + type + "."); + } + if (phase != NegotiationPhase.ABORT && type == null) { + throw new IllegalArgumentException( + "Negotiation reference type must not be null for the " + phase + " phase; only the ABORT phase is" + + " type-independent."); + } if (!PathSegments.isSimpleSegment(language)) { throw new IllegalArgumentException( "Negotiation reference language must be a non-blank simple path segment but was " + language + "."); @@ -49,10 +65,10 @@ public record NegotiationReference(NegotiationType type, NegotiationPhase phase, /** * Returns the hyphenated URI segment of the referenced negotiation type. * - * @return URI segment such as {@code information-negotiation} + * @return URI segment such as {@code information-negotiation}; {@code common} for the type-independent abort phase */ public String typeSegment() { - return type.typeSegment(); + return type == null ? COMMON_TYPE_SEGMENT : type.typeSegment(); } /** @@ -83,7 +99,8 @@ public String uri() { *

The URI layer cannot distinguish accept from reject because both share the {@code accept-reject} segment; the * expected phase disambiguates the parsed result, which therefore always carries the expected phase. * - * @param templateUri template URI to parse, such as {@code Negotiation-T/target-negotiation/accept-reject/v1} + * @param templateUri template URI to parse, such as {@code Negotiation-T/target-negotiation/accept-reject/v1} or + * {@code Negotiation-T/common/abort/v1} * @param expectedPhase API-level phase the caller is operating on; the parsed reference carries this phase * @param language locale identifier for the parsed reference * @return reference addressed by the URI carrying the expected phase, or an empty result when the URI is null, @@ -105,7 +122,11 @@ public static Optional tryParse( * string. The URI layer cannot distinguish accept from reject because both share the {@code accept-reject} * segment; the expected phase disambiguates the result, which therefore always carries the expected phase. * - * @param templateUri typed template URI such as {@code Negotiation-T/target-negotiation/accept-reject/v1} + *

The abort phase is addressed by the common abort template: a {@code common}/{@code abort} segment pair yields + * a reference with a null type, and every other segment shape is rejected for that phase. + * + * @param templateUri typed template URI such as {@code Negotiation-T/target-negotiation/accept-reject/v1} or + * {@code Negotiation-T/common/abort/v1} * @param expectedPhase API-level phase the caller is operating on; the derived reference carries this phase * @param language locale identifier for the derived reference * @return reference addressed by the URI carrying the expected phase, or an empty result when the URI does not @@ -124,9 +145,16 @@ public static Optional fromTemplateUri( if (segments.size() != 2) { return Optional.empty(); } - NegotiationType parsedType = parseTypeSegment(segments.get(0)); - if (parsedType == null) { - return Optional.empty(); + NegotiationType parsedType = null; + if (COMMON_TYPE_SEGMENT.equals(segments.get(0))) { + if (expectedPhase != NegotiationPhase.ABORT) { + return Optional.empty(); + } + } else { + parsedType = parseTypeSegment(segments.get(0)); + if (expectedPhase == NegotiationPhase.ABORT || parsedType == null) { + return Optional.empty(); + } } if (!phaseSegmentMatches(segments.get(1), expectedPhase)) { return Optional.empty(); @@ -151,7 +179,8 @@ private static NegotiationType parseTypeSegment(String typeSegment) { } private static boolean phaseSegmentMatches(String phaseSegment, NegotiationPhase expectedPhase) { - if (!"propose".equals(phaseSegment) && !"accept-reject".equals(phaseSegment)) { + if (!"propose".equals(phaseSegment) && !"accept-reject".equals(phaseSegment) + && !"abort".equals(phaseSegment)) { return false; } return phaseSegment.equals(expectedPhase.uriSegment()); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 2bb40252..4629e6d8 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -557,6 +557,7 @@ private static MetadataContent generateFromText( case PROPOSE -> orchestrator.generateProposeFromText(text, goldenCase.context(), goldenCase.template()); case ACCEPT -> orchestrator.generateAcceptFromText(text, goldenCase.context(), goldenCase.template()); case REJECT -> orchestrator.generateRejectFromText(text, goldenCase.context(), goldenCase.template()); + case ABORT -> throw new IllegalArgumentException("The typed golden cases carry no abort phase."); }; } @@ -565,6 +566,7 @@ private static String phaseToken(NegotiationPhase phase) { case PROPOSE -> "propose"; case ACCEPT -> "accept"; case REJECT -> "reject"; + case ABORT -> "abort"; }; } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java index 26e82250..a166f76d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java @@ -302,6 +302,7 @@ public MetadataContent generate(NegotiationGenerationOrchestrator orchestrator) new NegotiationEndingData(context(), (NegotiationEndingContent) content()), template()); case REJECT -> orchestrator.generateRejectFromData( new NegotiationEndingData(context(), (NegotiationEndingContent) content()), template()); + case ABORT -> throw new IllegalArgumentException("The typed golden fixtures carry no abort phase."); }; } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java index 8786f7ed..20e0c060 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReferenceTest.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.negotiation.resources; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -115,6 +116,90 @@ void tryParseRejectsPhaseMismatchAgainstExpectedPhase() { assertTrue(parsed.isEmpty()); } + @Test + void uriComposesCommonAbortUriForAbortPhase() { + NegotiationReference abort = new NegotiationReference(null, NegotiationPhase.ABORT, "en-US"); + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), abort.uri()); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, abort.templateUri()); + assertNull(abort.type()); + assertEquals("common", abort.typeSegment()); + } + + @Test + void constructorBindsAbortPhaseToNullTypeOnly() { + IllegalArgumentException typedAbort = assertThrows( + IllegalArgumentException.class, + () -> new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.ABORT, "zh-CN")); + IllegalArgumentException untypedPropose = assertThrows( + IllegalArgumentException.class, + () -> new NegotiationReference(null, NegotiationPhase.PROPOSE, "zh-CN")); + assertTrue(typedAbort.getMessage().contains("ABORT")); + assertTrue(untypedPropose.getMessage().contains("null")); + } + + @Test + void tryParseAcceptsCommonAbortUri() { + NegotiationReference abort = requirePresent( + NegotiationReference.tryParse(StandardTemplates.NEGOTIATION_ABORT.uri(), NegotiationPhase.ABORT, "zh-CN")); + assertNull(abort.type()); + assertEquals(NegotiationPhase.ABORT, abort.phase()); + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), abort.uri()); + assertEquals("zh-CN", abort.language()); + } + + @Test + void tryParseRejectsCommonSegmentWithTypedPhases() { + assertTrue(NegotiationReference.tryParse("Negotiation-T/common/propose/v1", NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.tryParse( + "Negotiation-T/common/accept-reject/v1", NegotiationPhase.ACCEPT, "zh-CN") + .isEmpty()); + } + + @Test + void tryParseRejectsAbortSegmentOnTypedReference() { + assertTrue(NegotiationReference.tryParse( + "Negotiation-T/information-negotiation/abort/v1", NegotiationPhase.ABORT, "zh-CN") + .isEmpty()); + } + + @Test + void tryParseRejectsCommonAbortUriAgainstTypedPhases() { + assertTrue(NegotiationReference.tryParse(StandardTemplates.NEGOTIATION_ABORT.uri(), NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.tryParse(StandardTemplates.NEGOTIATION_ABORT.uri(), NegotiationPhase.ACCEPT, "zh-CN") + .isEmpty()); + } + + @Test + void fromTemplateUriAcceptsCommonAbortUri() { + NegotiationReference abort = requirePresent( + NegotiationReference.fromTemplateUri(StandardTemplates.NEGOTIATION_ABORT, NegotiationPhase.ABORT, "zh-CN")); + assertNull(abort.type()); + assertEquals(NegotiationPhase.ABORT, abort.phase()); + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), abort.uri()); + + assertEquals( + StandardTemplates.NEGOTIATION_ABORT.uri(), + new NegotiationReference(null, NegotiationPhase.ABORT, "en-US").uri(), + "fromTemplateUri must be the exact inverse of the composition for the common abort template"); + } + + @Test + void fromTemplateUriRejectsCommonWithTypedPhasesAndTypedAbort() { + assertTrue(NegotiationReference.fromTemplateUri( + TemplateUri.of("Negotiation-T", "common", "propose"), NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.fromTemplateUri( + TemplateUri.of("Negotiation-T", "common", "abort"), NegotiationPhase.PROPOSE, "zh-CN") + .isEmpty()); + assertTrue(NegotiationReference.fromTemplateUri( + TemplateUri.of("Negotiation-T", "information-negotiation", "abort"), + NegotiationPhase.ABORT, + "zh-CN") + .isEmpty()); + } + @Test void tryParseReturnsEmptyForBlankAndNullUriButThrowsOnNullExpectedPhase() { assertTrue(NegotiationReference.tryParse("", NegotiationPhase.PROPOSE, "zh-CN").isEmpty()); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java index c3e85a9f..8372e849 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java @@ -16,10 +16,13 @@ */ class StandardTemplatesNegotiationConsistencyTest { + private static final List TYPED_PHASES = + List.of(NegotiationPhase.PROPOSE, NegotiationPhase.ACCEPT, NegotiationPhase.REJECT); + @Test void negotiationConstantsMatchReferenceComposition() { for (NegotiationType type : NegotiationType.values()) { - for (NegotiationPhase phase : NegotiationPhase.values()) { + for (NegotiationPhase phase : TYPED_PHASES) { NegotiationReference reference = new NegotiationReference(type, phase, "en-US"); TemplateUri expected = findConstant(reference.uri()); assertEquals( @@ -28,6 +31,12 @@ void negotiationConstantsMatchReferenceComposition() { "StandardTemplates has no constant matching the composed URI of " + type + "/" + phase); } } + NegotiationReference abort = new NegotiationReference(null, NegotiationPhase.ABORT, "en-US"); + TemplateUri abortConstant = findConstant(abort.uri()); + assertEquals( + abort.uri(), + abortConstant.uri(), + "StandardTemplates has no constant matching the composed URI of the common abort template"); } @Test @@ -35,12 +44,13 @@ void negotiationGroupCoversExactlyTheComposedUris() { List composed = StandardTemplates.NEGOTIATION.stream().map(TemplateUri::uri).sorted().toList(); List expected = new java.util.ArrayList<>(); + // Accept and reject share the accept-reject template, so the group carries one URI per type for them. for (NegotiationType type : NegotiationType.values()) { for (NegotiationPhase phase : List.of(NegotiationPhase.PROPOSE, NegotiationPhase.ACCEPT)) { expected.add(new NegotiationReference(type, phase, "en-US").uri()); } } - expected.add(StandardTemplates.NEGOTIATION_ABORT.uri()); + expected.add(new NegotiationReference(null, NegotiationPhase.ABORT, "en-US").uri()); assertEquals(expected.stream().sorted().toList(), composed); } From db4bd5087a757017eea0ff7df04fda11b7502829 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 12:08:46 +0800 Subject: [PATCH 088/295] feat(negotiation): load the common abort template and its vocabulary keys Teach the template loader to list and load the common abort template, and pin the slot.termination_reason and section.termination_reason vocabulary keys both languages must expose. Signed-off-by: codetalks-hw --- .../sdk/negotiation/content/Vocabulary.java | 2 + .../DefaultNegotiationTemplateLoader.java | 8 ++- .../negotiation/content/VocabularyTest.java | 17 +++++- ...NegotiationGenerationOrchestratorTest.java | 6 +-- .../NegotiationTemplateQueryBoundaryTest.java | 13 ++--- .../CustomRootTemplateOverrideTest.java | 2 +- .../NegotiationLanguageSwitchTest.java | 2 +- .../UnsupportedLanguageBehaviorTest.java | 2 +- .../DefaultNegotiationTemplateLoaderTest.java | 53 +++++++++++++++++-- .../en-US/vocabulary.json | 2 + .../zh-CN/vocabulary.json | 2 + 11 files changed, 90 insertions(+), 19 deletions(-) diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java index 5d94091d..311ada5a 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java @@ -58,6 +58,7 @@ public final class Vocabulary { /** The language-neutral canonical key set every vocabulary file must define exactly, in a fixed order. */ public static final List CANONICAL_KEYS = List.of( "section.context", + "section.termination_reason", "section.info_items", "section.info_static", "section.info_conclusion", @@ -74,6 +75,7 @@ public final class Vocabulary { "section.feasibility_conclusion", "section.feasibility_confirm", "slot.context", + "slot.termination_reason", "slot.info_items", "slot.info_conclusion", "slot.info_result_content", diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java index 941bb1bc..760c9c95 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -90,7 +90,8 @@ public PromptTemplate load(NegotiationReference reference) { * Loads every loadable negotiation template of the loader's language. * *

The fixed iteration order is the negotiation type order information, target, feasibility crossed with the - * phase order propose, accept-reject. Templates that exist nowhere are skipped. + * phase order propose, accept-reject, followed by the type-independent common abort template. Templates that exist + * nowhere are skipped. * * @return templates of the loader's language that could be loaded, in a fixed order */ @@ -106,6 +107,11 @@ public List loadAll() { } } } + try { + templates.add(load(new NegotiationReference(null, NegotiationPhase.ABORT, language))); + } catch (ResourceNotFoundException exception) { + // A template that exists nowhere for this language is simply not listed. + } return List.copyOf(templates); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java index d54d3a54..f3b59b79 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java @@ -24,6 +24,7 @@ class VocabularyTest { private static final Set CANONICAL_KEYS = Set.of( "section.context", + "section.termination_reason", "section.info_items", "section.info_static", "section.info_conclusion", @@ -40,6 +41,7 @@ class VocabularyTest { "section.feasibility_conclusion", "section.feasibility_confirm", "slot.context", + "slot.termination_reason", "slot.info_items", "slot.info_conclusion", "slot.info_result_content", @@ -66,8 +68,19 @@ void bothLanguagesExposeExactlyTheCanonicalKeys() { assertEquals(CANONICAL_KEYS, enUs.canonicalKeys()); assertEquals(Set.copyOf(Vocabulary.CANONICAL_KEYS), zhCn.canonicalKeys()); assertEquals(new TreeSet<>(zhCn.canonicalKeys()), new TreeSet<>(enUs.canonicalKeys())); - assertEquals(33, zhCn.canonicalKeys().size()); - assertEquals(33, Vocabulary.CANONICAL_KEYS.size()); + assertEquals(35, zhCn.canonicalKeys().size()); + assertEquals(35, Vocabulary.CANONICAL_KEYS.size()); + } + + @Test + void terminationReasonSlotMatchesTheCommonAbortTemplatePlaceholder() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("协商终止原因", zhCn.get("slot.termination_reason")); + assertEquals("negotiation_termination_reason", enUs.get("slot.termination_reason")); + assertEquals("协商终止原因", zhCn.get("section.termination_reason")); + assertEquals("Negotiation Termination Reason", enUs.get("section.termination_reason")); } @Test diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 0853723c..5850f1a4 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -122,9 +122,9 @@ void validatesAndFillsProposeDataWithScriptedSemanticResponse() { } @Test - void listsAllSixTemplatesPerLanguage() { - assertEquals(6, zhOrchestrator().getNegotiationPrompts().size()); - assertEquals(6, orchestrator("en-US").getNegotiationPrompts().size()); + void listsAllSevenTemplatesPerLanguage() { + assertEquals(7, zhOrchestrator().getNegotiationPrompts().size()); + assertEquals(7, orchestrator("en-US").getNegotiationPrompts().size()); } @Test diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java index 62465188..76ba4a0a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java @@ -38,7 +38,8 @@ class NegotiationTemplateQueryBoundaryTest { StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()); + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.NEGOTIATION_ABORT.uri()); private ListAppender logAppender; @@ -63,14 +64,14 @@ static Stream languages() { return Stream.of(Arguments.of("zh-CN"), Arguments.of("en-US")); } - @ParameterizedTest(name = "list query returns six templates in the fixed order [{0}]") + @ParameterizedTest(name = "list query returns seven templates in the fixed order [{0}]") @MethodSource("languages") - void listQueryReturnsSixTemplatesInTheFixedOrder(String language) { + void listQueryReturnsSevenTemplatesInTheFixedOrder(String language) { NegotiationGenerationOrchestrator orchestrator = orchestrator(language); List templates = orchestrator.getNegotiationPrompts(); - assertEquals(6, templates.size()); + assertEquals(7, templates.size()); assertEquals( EXPECTED_URI_ORDER, templates.stream().map(template -> template.templateUri().uri()).toList()); for (PromptTemplate template : templates) { @@ -78,8 +79,8 @@ void listQueryReturnsSixTemplatesInTheFixedOrder(String language) { assertFalse( typeSegment.contains("_"), "type segments must use hyphens: " + template.templateUri().uri()); assertTrue( - typeSegment.endsWith("-negotiation"), - "type segment must carry the suffix: " + template.templateUri().uri()); + typeSegment.endsWith("-negotiation") || typeSegment.equals("common"), + "type segment must carry the suffix or be the common segment: " + template.templateUri().uri()); assertFalse(template.content().isBlank()); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java index f3edc3af..4bd938a0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java @@ -95,7 +95,7 @@ void templateQueriesStillListEveryTemplateWhileTheOverrideIsPresent() throws IOE List templates = orchestratorWithCustomRoot().getNegotiationPrompts(); - assertEquals(6, templates.size()); + assertEquals(7, templates.size()); PromptTemplate overridden = templates.stream() .filter(t -> t.templateUri().uri().equals(GoldenCase.INFORMATION_PROPOSE.templateUri())) .findFirst() diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java index ffc39fc7..60fa7a3b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java @@ -63,7 +63,7 @@ void templateQueriesReturnTheTemplatesOfTheConfiguredLanguage(String language) { List templates = orchestrator.getNegotiationPrompts(); - assertEquals(6, templates.size()); + assertEquals(7, templates.size()); for (PromptTemplate template : templates) { assertTrue( template.content().contains(contextTitle), diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java index 8430e56c..0805aa22 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java @@ -45,7 +45,7 @@ void templateQueriesOfAnUnsupportedLanguageNeverFallBackToAnotherLanguage() { assertEquals(List.of(), frFrLoader.loadAll(), "no template of another language may be listed for fr-FR"); for (String bundledLanguage : List.of("zh-CN", "en-US")) { assertEquals( - 6, + 7, new DefaultNegotiationTemplateLoader(bundledLanguage, null) .loadAll() .size(), diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java index 2c799903..14460e5d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java @@ -24,7 +24,8 @@ class DefaultNegotiationTemplateLoaderTest { StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri()); + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.NEGOTIATION_ABORT.uri()); @TempDir Path customRootDir; @@ -39,9 +40,53 @@ void loadAllReturnsAllSixBuiltinTemplatesPerLanguageInFixedOrder() { assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(zhCnTemplates)); assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(enUsTemplates)); - assertEquals(6, zhCnTemplates.size()); - assertEquals(6, enUsTemplates.size()); - assertEquals(12, zhCnTemplates.size() + enUsTemplates.size()); + assertEquals(7, zhCnTemplates.size()); + assertEquals(7, enUsTemplates.size()); + assertEquals(14, zhCnTemplates.size() + enUsTemplates.size()); + } + + @Test + void loadReturnsTheCommonAbortTemplateForTheAbortPhase() { + DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US", null); + DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN", null); + + PromptTemplate englishTemplate = + enUsLoader.load(new NegotiationReference(null, NegotiationPhase.ABORT, "en-US")); + PromptTemplate chineseTemplate = + zhCnLoader.load(new NegotiationReference(null, NegotiationPhase.ABORT, "zh-CN")); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT, englishTemplate.templateUri()); + assertTrue(englishTemplate.content().startsWith("## Negotiation Context")); + assertTrue(englishTemplate.content().contains("## Negotiation Termination Reason")); + assertTrue(englishTemplate.content().contains("{{negotiation_termination_reason}}")); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, chineseTemplate.templateUri()); + assertTrue(chineseTemplate.content().contains("## 协商终止原因")); + assertTrue(chineseTemplate.content().contains("{{协商终止原因}}")); + } + + @Test + void customRootOverridesTheCommonAbortTemplate() throws IOException { + Path customTemplatePath = customRootDir + .resolve("templates") + .resolve("Negotiation-T") + .resolve("common") + .resolve("abort") + .resolve("v1") + .resolve("zh-CN") + .resolve("template.md"); + writeTemplate(customTemplatePath, "\n\n## 协商上下文\n自定义终止模板\n"); + DefaultNegotiationTemplateLoader loader = + new DefaultNegotiationTemplateLoader("zh-CN", customRootDir.toString()); + + PromptTemplate overridden = loader.load(new NegotiationReference(null, NegotiationPhase.ABORT, "zh-CN")); + assertTrue(overridden.content().contains("自定义终止模板")); + assertEquals("custom abort", overridden.description()); + + List templates = loader.loadAll(); + assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(templates)); + PromptTemplate listedAbort = templates.get(templates.size() - 1); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, listedAbort.templateUri()); + assertTrue(listedAbort.content().contains("自定义终止模板")); } @Test diff --git a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json index ae9a4731..0be775f3 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json @@ -1,5 +1,6 @@ { "section.context": "Negotiation Context", + "section.termination_reason": "Negotiation Termination Reason", "section.info_items": "Required Information Items", "section.info_static": "Information Negotiation", "section.info_conclusion": "Information Negotiation Result", @@ -16,6 +17,7 @@ "section.feasibility_conclusion": "Feasibility Negotiation Result", "section.feasibility_confirm": "Feasibility Assessment Result Confirmation", "slot.context": "negotiation_context", + "slot.termination_reason": "negotiation_termination_reason", "slot.info_items": "required_information_items", "slot.info_conclusion": "information_negotiation_result", "slot.info_result_content": "information_negotiation_result_content", diff --git a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json index 3d070043..49636911 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json @@ -1,5 +1,6 @@ { "section.context": "协商上下文", + "section.termination_reason": "协商终止原因", "section.info_items": "所需信息项", "section.info_static": "信息协商", "section.info_conclusion": "信息协商结果", @@ -16,6 +17,7 @@ "section.feasibility_conclusion": "可行性协商结果", "section.feasibility_confirm": "可行性评估结果确认", "slot.context": "协商上下文", + "slot.termination_reason": "协商终止原因", "slot.info_items": "所需信息项", "slot.info_conclusion": "信息协商结果", "slot.info_result_content": "信息协商结果内容", From 609031a3aca5c4ea5c986a292d127614f5e54e5c Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 12:21:57 +0800 Subject: [PATCH 089/295] feat(negotiation): generate abort messages from typed data Add the AbortContent family and the AbortGenerator, dispatch the ABORT phase through the generator registry, and expose generateAbortFromData on the orchestrator and the content service. The stale no-abort-template wording of the conclusion guard is corrected. Signed-off-by: codetalks-hw --- .../sdk/negotiation/content/AbortContent.java | 13 ++ .../content/NegotiationAbortData.java | 13 ++ .../content/NegotiationConclusion.java | 5 +- .../content/NegotiationContent.java | 9 +- .../generation/AbortGenerator.java | 44 +++++ .../AbstractNegotiationGenerator.java | 12 +- .../generation/NegotiationContentService.java | 18 ++ .../NegotiationGenerationOrchestrator.java | 28 ++- .../NegotiationGeneratorRegistry.java | 34 +++- .../NegotiationPackageStructureTest.java | 3 + .../AbortMessageGenerationTest.java | 164 ++++++++++++++++++ .../NegotiationGeneratorRegistryTest.java | 2 +- 12 files changed, 324 insertions(+), 21 deletions(-) create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/AbortContent.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortData.java create mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/AbortContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/AbortContent.java new file mode 100644 index 00000000..1dc44845 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/AbortContent.java @@ -0,0 +1,13 @@ +package net.openan.a2at.sdk.negotiation.content; + +/** + * Content of an abort negotiation message. + * + *

Abort messages are type-independent: the single common abort template carries the fixed {@code Abort} conclusion + * section and this content only fills the termination reason. + * + * @param terminationReason human-readable reason for terminating the negotiation, such as reaching the round limit, + * a timeout or a token budget exhaustion + * @since 2026-08 + */ +public record AbortContent(String terminationReason) implements NegotiationContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortData.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortData.java new file mode 100644 index 00000000..0cd75d36 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortData.java @@ -0,0 +1,13 @@ +package net.openan.a2at.sdk.negotiation.content; + +/** + * Input bundle for generating an abort negotiation message from typed data. + * + *

The abort message is type-independent and terminates a negotiation outside the accept/reject outcome model; the + * {@code Abort} conclusion is fixed template text, so the content carries only the termination reason. + * + * @param context negotiation session context + * @param content typed abort content carrying the termination reason + * @since 2026-08 + */ +public record NegotiationAbortData(NegotiationContext context, AbortContent content) {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationConclusion.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationConclusion.java index 19c134c3..0e04a46d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationConclusion.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationConclusion.java @@ -3,8 +3,9 @@ /** * Terminal outcome of a negotiation as expressed in negotiation result sections. * - *

Only {@code ACCEPT} and {@code REJECT} are renderable conclusions; {@code ABORT} exists in the model but no - * bundled template carries it, so generation methods reject it as a programming error. + *

Only {@code ACCEPT} and {@code REJECT} are renderable conclusions of the typed negotiation templates; the + * {@code ABORT} outcome is rendered through the type-independent common abort template, whose fixed conclusion section + * carries no conclusion slot, so the typed generation methods reject it as a programming error. * * @since 2026-08 */ diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java index 1893d363..9beaa9b8 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java @@ -3,10 +3,11 @@ /** * Marker for the typed content of any negotiation message, regardless of phase. * - *

The two concrete families are {@link NegotiationProposeContent} for propose-phase messages and - * {@link NegotiationEndingContent} for terminal messages. Generators and content extractors accept this common - * supertype and dispatch on the exact runtime type. + *

The concrete families are {@link NegotiationProposeContent} for propose-phase messages, + * {@link NegotiationEndingContent} for terminal accept/reject messages and {@link AbortContent} for the + * type-independent abort message. Generators and content extractors accept this common supertype and dispatch on the + * exact runtime type. * * @since 2026-08 */ -public sealed interface NegotiationContent permits NegotiationProposeContent, NegotiationEndingContent {} +public sealed interface NegotiationContent permits NegotiationProposeContent, NegotiationEndingContent, AbortContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java new file mode 100644 index 00000000..e0ef221f --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java @@ -0,0 +1,44 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.AbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.core.model.PromptTemplate; + +/** + * Generator for the type-independent abort negotiation message. + * + *

The common abort template carries the fixed {@code Abort} conclusion section, so this generator fills only the + * negotiation context and the termination reason slots. + * + * @since 2026-08 + */ +final class AbortGenerator extends AbstractNegotiationGenerator { + + /** + * Generates an abort negotiation message. + * + * @param context negotiation context of the message + * @param content abort content carrying the termination reason + * @param template common abort template to render + * @param vocabulary vocabulary of the message language + * @return rendered abort message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + AbortContent abortContent = contentOf(content, AbortContent.class, "Abort generator"); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); + slots.put( + vocabulary.get("slot.termination_reason"), + requiredText( + abortContent.terminationReason(), + "content.terminationReason", + "Termination reason of an abort negotiation message")); + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java index 1697d2ee..77ba634d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java @@ -45,21 +45,23 @@ protected static T contentOf( } /** - * Validates that a conclusion is renderable, rejecting null and the abort conclusion. + * Validates that a conclusion is renderable by a typed ending generator, rejecting null and the abort conclusion. * * @param conclusion conclusion carried by ending content * @return the validated conclusion * @throws NullPointerException if the conclusion is null - * @throws IllegalArgumentException if the conclusion is abort + * @throws IllegalArgumentException if the conclusion is abort; the abort message has no typed conclusion slot and + * is generated through the abort phase against the common abort template instead */ protected static NegotiationConclusion renderableConclusion(NegotiationConclusion conclusion) { Objects.requireNonNull( conclusion, "Negotiation conclusion must not be null; accept and reject are the renderable" - + " conclusions."); + + " conclusions of a typed negotiation."); if (conclusion == NegotiationConclusion.ABORT) { throw new IllegalArgumentException( - "Negotiation conclusion Abort has no template section to render; terminate an exhausted" - + " negotiation with a reject message instead."); + "Typed negotiation templates carry no Abort conclusion slot; generate the abort message of a" + + " terminated negotiation with generateAbortFromData against the common abort template" + + " instead."); } return conclusion; } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index 8da2b675..14fe4a27 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -12,6 +12,7 @@ import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; @@ -138,6 +139,23 @@ public MetadataContent generateRejectFromData( return orchestrator.generateRejectFromData(data, templateUri); } + /** + * Generates an abort negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed abort input carrying the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template or the + * termination reason is blank + * @throws NegotiationGenerationException with the code {@code template_not_found} or + * {@code negotiation_slot_missing} when loading or rendering the template fails + */ + public MetadataContent generateAbortFromData( + @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + return orchestrator.generateAbortFromData(data, templateUri); + } + /** * Generates a propose-phase negotiation message from free text through one LLM content-extraction step. * diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 2509f2cf..eb4136f6 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -14,6 +14,7 @@ import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; @@ -155,6 +156,27 @@ public MetadataContent generateRejectFromData( return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.REJECT); } + /** + * Generates an abort negotiation message from typed data. + * + *

This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed + * template must be the common abort template and the content carries only the termination reason. + * + * @param data typed abort input carrying the negotiation context and the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template or the + * termination reason is blank + * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the + * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateAbortFromData( + @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "Negotiation abort data must not be null."); + return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.ABORT); + } + /** * Generates a propose-phase negotiation message from free text. * @@ -471,8 +493,10 @@ private NegotiationReference requireReference(TemplateUri templateUri, Negotiati private Optional parseQueryReference(TemplateUri templateUri) { // The URI layer cannot distinguish accept from reject because both phases share the accept-reject template // segment. The query therefore addresses the shared template through the ACCEPT phase; the parsed phase is an - // addressing artifact only and must never be read as the phase of any message. - for (NegotiationPhase phase : List.of(NegotiationPhase.PROPOSE, NegotiationPhase.ACCEPT)) { + // addressing artifact only and must never be read as the phase of any message. The abort phase addresses the + // type-independent common abort template. + for (NegotiationPhase phase : + List.of(NegotiationPhase.PROPOSE, NegotiationPhase.ACCEPT, NegotiationPhase.ABORT)) { Optional reference = NegotiationReference.fromTemplateUri(templateUri, phase, language); if (reference.isPresent()) { return reference; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java index 43b8bc0f..32c0de74 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java @@ -3,6 +3,7 @@ import java.util.EnumMap; import java.util.Map; import java.util.Objects; +import net.openan.a2at.sdk.negotiation.content.AbortContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; @@ -15,6 +16,7 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationType; import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -23,8 +25,9 @@ * *

Dispatch requires an exact runtime type match between the content and the addressed (type, phase) pair: propose * phases only accept propose content, terminal phases only accept ending content whose conclusion matches the phase, - * and the content type must match the negotiation type. Subtype matching is deliberately not supported; new content - * types must be registered explicitly. + * and the content type must match the negotiation type. The abort phase dispatches to the type-independent abort + * generator and requires abort content. Subtype matching is deliberately not supported; new content types must be + * registered explicitly. * * @since 2026-08 */ @@ -36,7 +39,9 @@ final class NegotiationGeneratorRegistry { private final Map endingGenerators = new EnumMap<>(NegotiationType.class); - /** Creates a registry holding the six built-in negotiation generators. */ + private final NegotiationGenerator abortGenerator = new AbortGenerator(); + + /** Creates a registry holding the built-in negotiation generators. */ public NegotiationGeneratorRegistry() { proposeGenerators.put(NegotiationType.INFORMATION, new InformationProposeGenerator()); proposeGenerators.put(NegotiationType.TARGET, new TargetProposeGenerator()); @@ -49,18 +54,33 @@ public NegotiationGeneratorRegistry() { /** * Resolves the generator for one (type, phase, content) triple. * - * @param type negotiation type addressed by the template URI + * @param type negotiation type addressed by the template URI; null only for the type-independent abort phase * @param phase API-level phase addressed by the calling method * @param content typed content of the message * @return generator registered for the exact (type, phase) pair - * @throws NullPointerException if any argument is null + * @throws NullPointerException if the phase or content is null, or the type is null on a typed phase * @throws IllegalArgumentException if the content family does not match the phase, the content runtime type does * not match the negotiation type, or an ending content carries a conclusion that does not match the phase */ - public NegotiationGenerator resolve(NegotiationType type, NegotiationPhase phase, NegotiationContent content) { - Objects.requireNonNull(type, "Negotiation type must not be null."); + public NegotiationGenerator resolve( + @Nullable NegotiationType type, NegotiationPhase phase, NegotiationContent content) { Objects.requireNonNull(phase, "Negotiation phase must not be null."); Objects.requireNonNull(content, "Negotiation content must not be null."); + if (phase == NegotiationPhase.ABORT) { + if (type != null) { + throw new IllegalArgumentException( + "The ABORT phase is type-independent and must not carry a type but carried " + type + "."); + } + if (content.getClass() != AbortContent.class) { + throw new IllegalArgumentException( + "The ABORT phase requires abort content but received " + content.getClass().getSimpleName() + + "."); + } + LOGGER.atDebug() + .log("negotiation_generator_dispatched generator=AbortGenerator type=common phase=ABORT"); + return abortGenerator; + } + Objects.requireNonNull(type, "Negotiation type must not be null for the " + phase + " phase."); boolean proposePhase = phase == NegotiationPhase.PROPOSE; boolean proposeContent = content instanceof NegotiationProposeContent; if (proposePhase != proposeContent) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java index 0701b808..947ea1ea 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -23,10 +23,12 @@ class NegotiationPackageStructureTest { void contentPackageExposesExactlyThePinnedTypes() throws IOException { assertEquals( List.of( + "AbortContent.java", "FeasibilityEndingContent.java", "FeasibilityProposeContent.java", "InformationEndingContent.java", "InformationProposeContent.java", + "NegotiationAbortData.java", "NegotiationAction.java", "NegotiationConclusion.java", "NegotiationContent.java", @@ -65,6 +67,7 @@ void resourcesPackageExposesExactlyThePinnedTypes() throws IOException { void generationPackageExposesExactlyThePinnedTypes() throws IOException { assertEquals( List.of( + "AbortGenerator.java", "AbstractNegotiationGenerator.java", "DefaultNegotiationContentExtractor.java", "FeasibilityEndingGenerator.java", diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java new file mode 100644 index 00000000..9fbb7924 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java @@ -0,0 +1,164 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.AbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import org.junit.jupiter.api.Test; + +/** + * Verifies abort message generation from typed data through the full orchestrator pipeline. + * + *

The abort message is type-independent: one common template, one content type carrying only the termination + * reason. The rendered message must keep the fixed Abort conclusion section, carry the negotiation context bullets and + * never leak an unreplaced placeholder. The from-data variant must stay deterministic — no LLM call. + */ +class AbortMessageGenerationTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri ABORT_URI = StandardTemplates.NEGOTIATION_ABORT; + + private final CountingClient llm = new CountingClient(); + + private NegotiationGenerationOrchestrator orchestrator(String language) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .llmClient(llm) + .build(); + } + + @Test + void generatesAChineseAbortMessageFromDataWithoutAnyLlmCall() { + MetadataContent result = orchestrator("zh-CN") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 5, 5), + new AbortContent("达到协商轮次上限,本次协商确认结束。")), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + String text = result.promptText(); + assertTrue(text.contains("## 协商上下文"), "the context section must be rendered"); + assertTrue(text.contains("## 协商结果\nAbort"), "the fixed Abort conclusion must be kept"); + assertTrue(text.contains("## 协商终止原因"), "the termination reason section must be rendered"); + assertTrue(text.contains("达到协商轮次上限,本次协商确认结束。")); + assertTrue(text.contains("- id: " + SESSION_ID)); + assertTrue(text.contains("- round: 5")); + assertTrue(text.contains("- maxRounds: 5")); + assertFalse(text.contains("{{"), "no unreplaced placeholder may remain"); + assertEquals(0, llm.calls, "the from-data variant must never call the LLM"); + } + + @Test + void generatesAnEnglishAbortMessageFromData() { + MetadataContent result = orchestrator("en-US") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 3, 5), + new AbortContent("Reached the negotiation round limit. This negotiation is confirmed and ended.")), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + String text = result.promptText(); + assertTrue(text.contains("## Negotiation Context")); + assertTrue(text.contains("## Negotiation Result\nAbort")); + assertTrue(text.contains("## Negotiation Termination Reason")); + assertTrue(text.contains("Reached the negotiation round limit.")); + assertTrue(text.contains("- round: 3")); + assertFalse(text.contains("{{")); + assertEquals(0, llm.calls); + } + + @Test + void blankTerminationReasonFailsAsAProgrammingErrorBeforeAnyLlmCall() { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator("zh-CN") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 1, 5), new AbortContent(" ")), + ABORT_URI)); + assertTrue(failure.getMessage().contains("Termination reason"), "message must point at the offending field"); + assertEquals(0, llm.calls); + } + + @Test + void abortGenerationRejectsTypedTemplateUris() { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator("zh-CN") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 1, 5), + new AbortContent("达到协商轮次上限。")), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT)); + assertTrue(failure.getMessage().contains("abort")); + } + + @Test + void typedEndingGenerationRejectsTheAbortTemplateUri() { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator("zh-CN") + .generateRejectFromData( + new net.openan.a2at.sdk.negotiation.content.NegotiationEndingData( + new NegotiationContext(SESSION_ID, 1, 5), + new net.openan.a2at.sdk.negotiation.content.TargetEndingContent( + net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.REJECT, + null, + "预算耗尽")), + ABORT_URI)); + assertTrue(failure.getMessage().contains("reject")); + } + + @Test + void singleQueryResolvesTheCommonAbortTemplateUri() { + assertTrue(orchestrator("zh-CN").getNegotiationPrompt(ABORT_URI).isPresent()); + assertTrue(orchestrator("en-US").getNegotiationPrompt(ABORT_URI).isPresent()); + } + + @Test + void serviceMirrorsTheAbortGenerationEntryPoint() { + NegotiationContentService service = + new NegotiationContentService(NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build()); + + MetadataContent result = service.generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 2, 5), new AbortContent("token 消耗超限,协商终止。")), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + assertTrue(result.promptText().contains("token 消耗超限")); + assertEquals(0, llm.calls); + } + + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new AssertionError("The from-data abort variant must never call the LLM"); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java index 66cbd3e5..55687755 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java @@ -167,7 +167,7 @@ void rejectsNullArguments() { NegotiationContent content = new InformationProposeContent(List.of(), null); assertEquals( - "Negotiation type must not be null.", + "Negotiation type must not be null for the PROPOSE phase.", assertThrows( NullPointerException.class, () -> registry.resolve(null, NegotiationPhase.PROPOSE, content)) From 0afc753788716d484cc62a5a7fd83533c523c61e Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 12:34:17 +0800 Subject: [PATCH 090/295] feat(negotiation): extract, generate and validate abort messages end to end Add the abort content-extraction prompt category and schema, the generateAbortFromText and validateAndFillingAbortData entry points on the orchestrator and content service, and abort-aware semantic validation prompts; the validator now accepts type-independent references. Signed-off-by: codetalks-hw --- .../DefaultNegotiationContentExtractor.java | 14 ++- .../generation/NegotiationContentService.java | 34 ++++++ .../NegotiationGenerationOrchestrator.java | 49 ++++++++ .../NegotiationJsonSchemaBuilder.java | 22 +++- .../DefaultNegotiationSemanticValidator.java | 6 +- .../AbortMessageGenerationTest.java | 113 ++++++++++++++++++ .../NegotiationJsonSchemaBuilderTest.java | 2 +- .../NegotiationResourceIntegrityTest.java | 1 + .../prompts/abort_negotiation/en-US/system.md | 30 +++++ .../prompts/abort_negotiation/en-US/user.md | 4 + .../prompts/abort_negotiation/zh-CN/system.md | 30 +++++ .../prompts/abort_negotiation/zh-CN/user.md | 4 + .../en-US/system.md | 7 +- .../zh-CN/system.md | 7 +- 14 files changed, 307 insertions(+), 16 deletions(-) create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/en-US/system.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/en-US/user.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/zh-CN/system.md create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/zh-CN/user.md diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java index 05c3662c..543aad77 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java @@ -9,6 +9,7 @@ import net.openan.a2at.sdk.core.json.JsonValueParser; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.AbortContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; @@ -23,6 +24,7 @@ import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,6 +50,8 @@ final class DefaultNegotiationContentExtractor implements NegotiationContentExtr private static final String CATEGORY_FEASIBILITY = "feasibility_negotiation"; + private static final String CATEGORY_ABORT = "abort_negotiation"; + private final LLMClient llmClient; private final NegotiationMessageBuilder messageBuilder; @@ -154,7 +158,10 @@ private Map invokeLlm(List> messages, Map payload, NegotiationType type, NegotiationPhase phase) { + Map payload, @Nullable NegotiationType type, NegotiationPhase phase) { + if (phase == NegotiationPhase.ABORT) { + return new AbortContent(requiredString(payload, "termination_reason")); + } if (phase == NegotiationPhase.PROPOSE) { return mapProposeContent(payload, type); } @@ -328,7 +335,10 @@ private static String phaseToken(NegotiationPhase phase) { }; } - private static String promptCategory(NegotiationType type) { + private static String promptCategory(@Nullable NegotiationType type) { + if (type == null) { + return CATEGORY_ABORT; + } return switch (type) { case INFORMATION -> CATEGORY_INFORMATION; case TARGET -> CATEGORY_TARGET; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index 14fe4a27..44dce9cd 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -213,6 +213,24 @@ public MetadataContent generateRejectFromText( return orchestrator.generateRejectFromText(text, context, templateUri); } + /** + * Generates an abort negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input stating the termination reason + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code template_not_found}, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or + * extracting fails, or {@code negotiation_slot_missing} when the extracted content misses the termination reason + */ + public MetadataContent generateAbortFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return orchestrator.generateAbortFromText(text, context, templateUri); + } + /** * Lists every negotiation template available for the configured language; never throws. * @@ -291,4 +309,20 @@ public FilledParamData validateAndFillingRejectData( String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { return orchestrator.validateAndFillingRejectData(prompt, schema, templateUri); } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationParamExtractionException when the validation pipeline fails + */ + public FilledParamData validateAndFillingAbortData( + String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + return orchestrator.validateAndFillingAbortData(prompt, schema, templateUri); + } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index eb4136f6..3990a4ed 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -253,6 +253,31 @@ public MetadataContent generateRejectFromText( return generateFromText(text, context, templateUri, NegotiationPhase.REJECT); } + /** + * Generates an abort negotiation message from free text. + * + *

This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. + * + * @param text free-text input stating the termination reason + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation_content_extract_failed} or + * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, + * {@code negotiation_slot_missing} when the extracted content misses the termination reason, or + * {@code negotiation_invalid_input} when the text is blank + */ + public MetadataContent generateAbortFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPhase.ABORT); + } + /** * Lists every negotiation template available for the configured language. * @@ -381,6 +406,30 @@ public FilledParamData validateAndFillingRejectData( return validateAndFilling(prompt, schema, templateUri, NegotiationPhase.REJECT); } + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the + * abort-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is + * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateAndFillingAbortData( + String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + return validateAndFilling(prompt, schema, templateUri, NegotiationPhase.ABORT); + } + private MetadataContent generateFromData( NegotiationContext context, NegotiationContent content, TemplateUri templateUri, NegotiationPhase phase) { requireContext(context); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java index 707f67f4..ecd91f37 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java @@ -7,6 +7,7 @@ import java.util.Objects; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import org.jspecify.annotations.Nullable; /** * Builds the JSON Schemas of the negotiation LLM steps. @@ -27,14 +28,21 @@ final class NegotiationJsonSchemaBuilder { /** * Builds the content extraction schema of one (negotiation type, phase) pair. * - * @param type negotiation type whose content is extracted + * @param type negotiation type whose content is extracted; null only for the type-independent abort phase * @param phase API-level phase of the extraction; accept and reject share the terminal schema * @return JSON Schema describing the snake_case extraction output of the pair - * @throws NullPointerException if the type or phase is null + * @throws NullPointerException if the phase is null, or the type is null on a typed phase */ - public Map buildExtractionSchema(NegotiationType type, NegotiationPhase phase) { - Objects.requireNonNull(type, "Negotiation type must not be null."); + public Map buildExtractionSchema(@Nullable NegotiationType type, NegotiationPhase phase) { Objects.requireNonNull(phase, "Negotiation phase must not be null."); + if (phase == NegotiationPhase.ABORT) { + if (type != null) { + throw new IllegalArgumentException( + "The ABORT phase is type-independent and must not carry a type but carried " + type + "."); + } + return abortSchema(); + } + Objects.requireNonNull(type, "Negotiation type must not be null for the " + phase + " phase."); return switch (type) { case INFORMATION -> phase == NegotiationPhase.PROPOSE ? informationProposeSchema() @@ -46,6 +54,12 @@ public Map buildExtractionSchema(NegotiationType type, Negotiati }; } + private static Map abortSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("termination_reason", Map.of("type", "string")); + return objectSchema(properties, List.of("termination_reason")); + } + private static Map informationProposeSchema() { Map properties = new LinkedHashMap<>(); properties.put("items", itemArraySchema()); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java index e7a300d0..2013295d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java @@ -193,7 +193,7 @@ private static List> buildMessages( .replace("[phase]", reference.phase().name().toLowerCase(Locale.ROOT)) .replace("[input]", prompt) .replace("[template_uri]", reference.uri()) - .replace("[negotiation_type]", reference.type().name().toLowerCase(Locale.ROOT)) + .replace("[negotiation_type]", declaredTypeName(reference)) .replace("[schema]", toJson(callerSchema)); return List.of( Map.of("role", "system", "content", systemPrompt), Map.of("role", "user", "content", filledUserPrompt)); @@ -262,7 +262,7 @@ private static SemanticValidationResult interpret(Map response, Map params = parseParams(rawParams); String negotiationType = (String) typeValue; - if (verdict) { + if (verdict && reference.type() != null) { if (negotiationType == null) { List rejectionErrors = new ArrayList<>(errors); rejectionErrors.add(typeConsistencyError( @@ -329,7 +329,7 @@ private static NegotiationType parseImpliedType(String negotiationType) { } private static String declaredTypeName(NegotiationReference reference) { - return reference.type().name().toLowerCase(Locale.ROOT); + return reference.type() == null ? "common" : reference.type().name().toLowerCase(Locale.ROOT); } private static String declaredTypeSectionKey(NegotiationType type) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java index 9fbb7924..cecd12b9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java @@ -5,9 +5,12 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; @@ -15,6 +18,7 @@ import net.openan.a2at.sdk.negotiation.content.AbortContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; import org.junit.jupiter.api.Test; /** @@ -147,6 +151,91 @@ void serviceMirrorsTheAbortGenerationEntryPoint() { assertEquals(0, llm.calls); } + @Test + void generatesAnAbortMessageFromFreeTextThroughOneExtractionStep() { + ScriptedClient extractionLlm = new ScriptedClient("{\"termination_reason\":\"达到协商轮次上限,协商终止。\"}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(extractionLlm) + .build(); + + MetadataContent result = orchestrator.generateAbortFromText( + "协商已达到轮次上限,无法继续推进,本次协商终止。", new NegotiationContext(SESSION_ID, 5, 5), ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("达到协商轮次上限,协商终止。")); + assertFalse(result.promptText().contains("{{")); + assertEquals(1, extractionLlm.calls, "the from-text variant runs exactly one extraction step"); + } + + @Test + void peerValidatesAReceivedAbortMessageAndExtractsItsParameters() { + NegotiationGenerationOrchestrator agent = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + ScriptedClient peerLlm = + new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator peer = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(peerLlm) + .build(); + + MetadataContent abortMessage = agent.generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 5, 5), new AbortContent("达到协商轮次上限,本次协商确认结束。")), + ABORT_URI); + + FilledParamData parameters = + peer.validateAndFillingAbortData(abortMessage.promptText(), Map.of("type", "object"), ABORT_URI); + + assertEquals(SESSION_ID, parameters.data().get("id")); + assertEquals(5, parameters.data().get("round")); + assertEquals(5, parameters.data().get("maxRounds")); + assertEquals(1, peerLlm.calls, "the validation pipeline runs exactly one semantic step"); + } + + @Test + void abortValidationFailsOnBeyondBudgetRoundsBeforeAnyLlmCall() { + NegotiationGenerationOrchestrator agent = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + ScriptedClient peerLlm = + new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator peer = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(peerLlm) + .build(); + + MetadataContent beyondBudget = agent.generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 6, 5), new AbortContent("轮次预算耗尽。")), ABORT_URI); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> peer.validateAndFillingAbortData(beyondBudget.promptText(), Map.of("type", "object"), ABORT_URI)); + assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, failure.getCode()); + assertTrue( + failure.getErrors().stream().anyMatch(error -> "round".equals(error.slotName())), + "the beyond-budget round must be reported on the round slot"); + assertEquals(0, peerLlm.calls, "the rule gate must fail before any semantic LLM call"); + } + + @Test + void abortValidationRejectsTypedTemplateUris() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.validateAndFillingAbortData("任意文本", Map.of("type", "object"), + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE)); + assertTrue(failure.getMessage().contains("abort")); + assertEquals(0, llm.calls); + } + private static final class CountingClient implements LLMClient { private int calls; @@ -161,4 +250,28 @@ public LLMResponse structured( throw new AssertionError("The from-data abort variant must never call the LLM"); } } + + private static final class ScriptedClient implements LLMClient { + + private final String payload; + + private final List>> recordedMessages = new ArrayList<>(); + + private int calls; + + private ScriptedClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + recordedMessages.add(List.copyOf(messages)); + return new LLMResponse(payload, "scripted-abort-model", Map.of(), Map.of()); + } + } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java index ea17c899..69b835af 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java @@ -113,7 +113,7 @@ void feasibilityEndingSchemaRequiresTheSummary() { @Test void rejectsNullTypeAndPhase() { assertEquals( - "Negotiation type must not be null.", + "Negotiation type must not be null for the PROPOSE phase.", assertThrows( NullPointerException.class, () -> builder.buildExtractionSchema(null, NegotiationPhase.PROPOSE)) diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java index 122ebc4c..be613930 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java @@ -49,6 +49,7 @@ class NegotiationResourceIntegrityTest { "information_negotiation", "target_negotiation", "feasibility_negotiation", + "abort_negotiation", "negotiation_semantic_validation"); private static final List EXCEPTION_SLOT_KEYS = diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/en-US/system.md new file mode 100644 index 00000000..874d2600 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/en-US/system.md @@ -0,0 +1,30 @@ +You are an abort negotiation content extraction agent. Your task is to extract the structured abort negotiation content JSON from a natural-language input text for downstream template rendering. + +## Output Format +Output exactly one JSON object. Do not output markdown code fences, comments, or any additional text. + +{ + "termination_reason": "reason for terminating the negotiation, string" +} + +## Field Rules +- termination_reason: required. The reason the negotiation is terminated, such as reaching the negotiation round limit, a timeout, or a token budget exhaustion. It must faithfully reflect the reason expressed by the input text and must be a single complete sentence. + +## Extraction Principles +1. Extract only content explicitly expressed in the input text; do not fill in values from general knowledge or guess. +2. The abort message is type-independent: never output a negotiation type or a conclusion field; the Abort conclusion is fixed template text. +3. When the input states the termination reason across several statements, merge them into one sentence for termination_reason. + +## Output Examples + +### Example 1 + +{ + "termination_reason": "Reached the negotiation round limit. This negotiation is confirmed and ended." +} + +### Example 2 + +{ + "termination_reason": "The token consumption exceeded the budget, so the negotiation is terminated." +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/en-US/user.md new file mode 100644 index 00000000..03989974 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/en-US/user.md @@ -0,0 +1,4 @@ +Input text: +[input] + +Following the output format and field rules in the system prompt, extract the abort negotiation content from the input text above. Output the JSON object only. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/zh-CN/system.md new file mode 100644 index 00000000..b4b644a5 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/zh-CN/system.md @@ -0,0 +1,30 @@ +你是协商终止(Abort)内容抽取智能体。你的任务是从自然语言输入文本中抽取结构化的协商终止内容 JSON,用于下游模板渲染。 + +## 输出格式 +仅输出一个 JSON 对象。不要输出 markdown 代码块、注释或任何多余文本。 + +{ + "termination_reason": "协商终止的原因,字符串" +} + +## 字段规则 +- termination_reason:必填。协商终止的原因,如达到协商轮次上限、超时、token 消耗超限等。必须忠实反映输入文本表达的终止原因,并且是一个完整的句子。 + +## 抽取原则 +1. 只抽取输入文本中明确表达的内容,不要用常识补全或猜测。 +2. 协商终止消息与协商类型无关:不要输出协商类型或结论字段;Abort 结论是模板固定文本。 +3. 当输入用多个句子陈述终止原因时,将其合并为一个句子填入 termination_reason。 + +## 输出示例 + +### 示例 1 + +{ + "termination_reason": "达到协商轮次上限,本次协商确认结束。" +} + +### 示例 2 + +{ + "termination_reason": "token 消耗超过预算,本次协商终止。" +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/zh-CN/user.md new file mode 100644 index 00000000..fd5eb662 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/abort_negotiation/zh-CN/user.md @@ -0,0 +1,4 @@ +输入文本: +[input] + +按照系统提示中的输出格式与字段规则,从上述输入文本中抽取协商终止内容。仅输出 JSON 对象。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md index 682d4fb7..afbc5ee5 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md @@ -13,7 +13,7 @@ Output exactly one JSON object containing exactly the following 4 required keys; } - semantic_verdict: the overall verdict of semantic validation; true when every validation task passes, false when any validation task fails. -- negotiation_type: the negotiation type implied by the message sections, one of "information" / "target" / "feasibility"; it may be null when semantic_verdict is false; when semantic_verdict is true it must be non-null and must match the declared negotiation type. +- negotiation_type: the negotiation type implied by the message sections, one of "information" / "target" / "feasibility"; it may be null when semantic_verdict is false; when semantic_verdict is true and the declared template is a typed negotiation template it must be non-null and must match the declared negotiation type; for the common abort template it must be null because abort messages are type-independent. - errors: the semantic error details array; each element is an object with exactly three keys, slot_name, code, and message; it must be an empty array when semantic_verdict is true. - params: the parameter object extracted from the message per the parameter schema; output an empty object {} when no parameter can be extracted. @@ -23,10 +23,10 @@ Output exactly one JSON object containing exactly the following 4 required keys; 3. Conclusion and content match: a message whose conclusion is Accept must carry an explicit confirmation (the confirmed information, intent, or outcome statement); a message whose conclusion is Reject must carry an explicit failure or rejection reason. 4. Field self-consistency: field values within the same message must not contradict each other (for example, the conclusion is Accept while the body states a rejection; or the same numeric target differs across sections). 5. Structural semantics: - - The conclusion value must be either Accept or Reject; Abort is a protocol-reserved value not carried by the current templates, and its presence is a structural semantics error. + - For a typed negotiation template, the conclusion value must be either Accept or Reject; an Abort conclusion in a message declared against a typed template is a structural semantics error. For the common abort template, the message must carry the Abort conclusion and a termination reason section stating why the negotiation ended. - An ending-phase (accept-reject) message must contain the result content section (Information Negotiation Result Content / Target Negotiation Result Content / Feasibility Assessment Result Confirmation). - The two conditional sections of a feasibility negotiation propose message (Under Evaluation Description and Infeasible Evaluation Details and Proposal) are mutually exclusive and must not both appear. -6. Template consistency: the negotiation type and phase implied by the message sections must match the template identifier (template_uri) declared in the user prompt and its declared negotiation type. A type mismatch is a type consistency error; a phase mismatch (for example, the declared template identifier is for the propose phase while the message is an ending message, or vice versa) is a phase consistency error. +6. Template consistency: the negotiation type and phase implied by the message sections must match the template identifier (template_uri) declared in the user prompt and its declared negotiation type. A type mismatch is a type consistency error; a phase mismatch (for example, the declared template identifier is for the propose phase while the message is an ending message, or vice versa) is a phase consistency error. When the declared template is the common abort template, the message must be an abort message: it carries the Abort conclusion and no typed negotiation sections; an abort message declared against a typed template, or a typed message declared against the common abort template, is a template consistency error. ## Parameter Extraction Task - Extract parameters from the message content per the parameter schema given in the user prompt and fill the params object. @@ -36,6 +36,7 @@ Output exactly one JSON object containing exactly the following 4 required keys; ## slot_name Convention The slot_name of semantic and structural semantics errors must use the following language-neutral canonical keys, chosen by the message section at fault: - section.context: Negotiation Context +- section.termination_reason: Negotiation Termination Reason - section.info_static: Information Negotiation - section.info_items: Required Information Items - section.info_conclusion: Information Negotiation Result diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md index de784f25..e5b8af0e 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md @@ -13,7 +13,7 @@ } - semantic_verdict:语义校验整体结论;全部校验任务通过时为 true,任一校验任务失败时为 false。 -- negotiation_type:报文板块蕴含的协商类型,取值为 "information" / "target" / "feasibility" 之一;semantic_verdict 为 false 时可为 null;为 true 时必须非 null,且必须与声明的协商类型一致。 +- negotiation_type:报文板块蕴含的协商类型,取值为 "information" / "target" / "feasibility" 之一;semantic_verdict 为 false 时可为 null;为 true 且声明的模板为类型化协商模板时必须非 null,且必须与声明的协商类型一致;声明的模板为 common abort 模板时必须为 null,因为协商终止报文与协商类型无关。 - errors:语义错误明细数组,每个元素是恰好包含 slot_name、code、message 三个键的对象;semantic_verdict 为 true 时必须为空数组。 - params:按参数 schema 从报文中提取的参数对象;无参数可提取时输出空对象 {}。 @@ -23,10 +23,10 @@ 3. 结论与内容匹配:结论为 Accept 的报文必须携带明确的确认内容(确认后的信息、意图或结论表述);结论为 Reject 的报文必须携带明确的失败或拒绝原因。 4. 字段取值自洽:同一报文内各字段取值不得自相矛盾(例如结论为 Accept 而正文表述为拒绝;同一数值目标前后不一致)。 5. 结构语义: - - 结论字段的取值只能为 Accept 或 Reject;Abort 为协议预留值,当前模板不承接,出现即记结构语义错误。 + - 类型化协商模板的结论字段取值只能为 Accept 或 Reject;类型化模板的报文中出现 Abort 结论记结构语义错误。common abort 模板的报文必须携带 Abort 结论,并存在说明协商终止原因的终止原因板块。 - 结论阶段(accept-reject)报文必须存在结果内容板块(信息协商结果内容 / 目标协商结果内容 / 可行性评估结果确认)。 - 可行性协商发起报文的两个条件板块(待评估内容说明 与 评估不可行时的详情和提案)互斥,不得同时出现。 -6. 模板一致性:报文板块蕴含的协商类型与阶段,必须与用户提示词中声明的模板标识(template_uri)及其声明的协商类型一致。类型不一致记类型一致性错误;阶段不一致(如声明的模板标识为发起阶段而报文为结论报文,或反之)记阶段一致性错误。 +6. 模板一致性:报文板块蕴含的协商类型与阶段,必须与用户提示词中声明的模板标识(template_uri)及其声明的协商类型一致。类型不一致记类型一致性错误;阶段不一致(如声明的模板标识为发起阶段而报文为结论报文,或反之)记阶段一致性错误。声明的模板为 common abort 模板时,报文必须是协商终止报文:携带 Abort 结论且不含类型化协商板块;协商终止报文声明为类型化模板,或类型化报文声明为 common abort 模板,均记模板一致性错误。 ## 参数提取任务 - 按用户提示词中给出的参数 schema,从报文内容中提取参数并填充 params 对象。 @@ -36,6 +36,7 @@ ## slot_name 规范 语义错误与结构语义错误的 slot_name 必须使用以下语言无关的规范键,按出错的报文板块选择: - section.context:协商上下文(Negotiation Context) +- section.termination_reason:协商终止原因(Negotiation Termination Reason) - section.info_static:信息协商(Information Negotiation) - section.info_items:所需信息项(Required Information Items) - section.info_conclusion:信息协商结果(Information Negotiation Result) From ef6f02df4c30aacf561f0ce83a701dc32dae31b4 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 12:57:42 +0800 Subject: [PATCH 091/295] feat(client,server,negotiation): mirror the abort API on both facades Expose generateNegotiationAbortPromptFromData/FromText and validateAndFillingAbortData on A2ATClient and A2ATServer, evolve the round-exhaustion scenario into an abort ending, guard that every StandardTemplates.NEGOTIATION URI is loadable, and update the bilingual developer guide. Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 126 ++++++++++++++---- .../api/A2ATClientNegotiationApiTest.java | 51 ++++++- .../A2ATClientNegotiationEnvConfigTest.java | 2 +- ...rdTemplatesNegotiationConsistencyTest.java | 37 +++++ .../MaxRoundsExhaustionScenarioTest.java | 51 +++---- .../NegotiationFacadeOutputSymmetryTest.java | 1 + .../api/NegotiationV3ApiSurfaceTest.java | 12 +- .../openan/a2at/sdk/server/A2ATServer.java | 75 +++++++++++ .../api/A2ATServerNegotiationApiTest.java | 34 ++++- docs/en/developer_guide.md | 11 +- ...00\345\217\221\346\214\207\345\215\227.md" | 11 +- 11 files changed, 337 insertions(+), 74 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index a305ea01..2b5de3c2 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -14,6 +14,7 @@ import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; @@ -75,10 +76,10 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code - * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, - * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the - * prompt fails + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code + * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, + * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the + * prompt fails */ public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); @@ -94,10 +95,10 @@ public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code - * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, - * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the - * prompt fails + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code + * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, + * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the + * prompt fails */ public MetadataContent generateTaskPromptFromDataWithSchema( @NonNull Map data, @@ -119,10 +120,10 @@ public MetadataContent generateTaskPromptFromDataWithSchema( * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code - * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, - * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the - * prompt fails + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code + * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, + * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the + * prompt fails */ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); @@ -142,10 +143,10 @@ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code - * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, - * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the - * prompt fails + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code + * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, + * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the + * prompt fails */ public MetadataContent generateAuthPromptFromDataWithSchema( @NonNull Map data, @@ -163,10 +164,10 @@ public MetadataContent generateAuthPromptFromDataWithSchema( * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code - * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, - * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the - * prompt fails + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code + * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, + * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the + * prompt fails */ public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); @@ -182,10 +183,10 @@ public MetadataContent generateNotificationPromptFromText(@NonNull String text, * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code - * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, - * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the - * prompt fails + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code + * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, + * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the + * prompt fails */ public MetadataContent generateNotificationPromptFromDataWithSchema( @NonNull Map data, @@ -299,6 +300,28 @@ public MetadataContent generateNegotiationRejectPromptFromData( return negotiationContentService.generateRejectFromData(data, templateUri); } + /** + * Generates an abort negotiation message from typed data. + * + *

This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed + * template must be the common abort template and the content carries only the termination reason. + * + * @param data typed abort input carrying the negotiation context and the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI does not address the common abort template or the + * termination reason is blank + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationAbortPromptFromData( + @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.generateAbortFromData(data, templateUri); + } + /** * Generates a propose-phase negotiation message from free text. * @@ -387,6 +410,34 @@ public MetadataContent generateNegotiationRejectPromptFromText( return negotiationContentService.generateRejectFromText(text, context, templateUri); } + /** + * Generates an abort negotiation message from free text. + * + *

This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. + * + * @param text free-text input stating the termination reason + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the + * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted + * content misses the termination reason, or {@code negotiation_invalid_input} when the text is blank + */ + public MetadataContent generateNegotiationAbortPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.negotiation.content.NegotiationContext context, + @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.generateAbortFromText(text, context, templateUri); + } + /** * Lists every template available for the configured language across all A2A-T extensions. * @@ -524,4 +575,31 @@ public FilledParamData validateAndFillingRejectData( Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.validateAndFillingRejectData(prompt, schema, templateUri); } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the + * abort-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation_invalid_input} when the prompt is not a negotiation message, + * {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateAndFillingAbortData( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.validateAndFillingAbortData(prompt, schema, templateUri); + } } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index 4eee6802..48207c23 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -14,8 +14,10 @@ import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.AbortContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; @@ -79,11 +81,54 @@ void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOE } @Test - void listsAllSixNegotiationTemplatesPerLanguage() throws IOException { + void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { assertEquals( - 6, new A2ATClient(writeEnv("zh-CN")).getNegotiationPrompts().size()); + 7, new A2ATClient(writeEnv("zh-CN")).getNegotiationPrompts().size()); assertEquals( - 6, new A2ATClient(writeEnv("en-US")).getNegotiationPrompts().size()); + 7, new A2ATClient(writeEnv("en-US")).getNegotiationPrompts().size()); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + MetadataContent result = client.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 5, 5), + new AbortContent("达到协商轮次上限,本次协商确认结束。")), + StandardTemplates.NEGOTIATION_ABORT); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("## 协商终止原因")); + assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); + assertTrue(result.promptText().contains("- round: 5")); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinEnglishTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("en-US")); + + MetadataContent result = client.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 3, 5), + new AbortContent("Reached the negotiation round limit. This negotiation is confirmed and ended.")), + StandardTemplates.NEGOTIATION_ABORT); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## Negotiation Result\nAbort")); + assertTrue(result.promptText().contains("## Negotiation Termination Reason")); + assertTrue(result.promptText().contains("Reached the negotiation round limit.")); + } + + @Test + void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + PromptTemplate template = + client.getNegotiationPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); + assertFalse(template.content().isBlank()); } @Test diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index 64f5bbcd..455acfd0 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -141,7 +141,7 @@ void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { assertTrue(result.promptText().contains("Negotiation Context"), "the default language must be en-US"); assertTrue(result.promptText().contains("Required Information Items")); - assertEquals(6, client.getNegotiationPrompts().size(), "the built-in resources must be used by default"); + assertEquals(7, client.getNegotiationPrompts().size(), "the built-in resources must be used by default"); assertTrue(client.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); assertThrows( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java index 8372e849..5e2fcd97 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.negotiation.resources; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import net.openan.a2at.sdk.core.model.StandardTemplates; @@ -54,6 +55,42 @@ void negotiationGroupCoversExactlyTheComposedUris() { assertEquals(expected.stream().sorted().toList(), composed); } + @Test + void everyNegotiationGroupUriIsLoadableByTheNegotiationLayer() { + DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN", null); + DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US", null); + List loadableUris = + zhCnLoader.loadAll().stream().map(template -> template.templateUri().uri()).toList(); + + for (TemplateUri templateUri : StandardTemplates.NEGOTIATION) { + assertTrue( + loadableUris.contains(templateUri.uri()), + "StandardTemplates advertises " + templateUri.uri() + + " but the negotiation layer cannot load it"); + assertTrue(zhCnLoader.load(zhCnReference(templateUri)).content().length() > 0, templateUri.uri()); + assertTrue(enUsLoader.load(enUsReference(templateUri)).content().length() > 0, templateUri.uri()); + } + } + + private static NegotiationReference zhCnReference(TemplateUri templateUri) { + return referenceOf(templateUri, "zh-CN"); + } + + private static NegotiationReference enUsReference(TemplateUri templateUri) { + return referenceOf(templateUri, "en-US"); + } + + private static NegotiationReference referenceOf(TemplateUri templateUri, String language) { + for (NegotiationPhase phase : NegotiationPhase.values()) { + java.util.Optional reference = + NegotiationReference.fromTemplateUri(templateUri, phase, language); + if (reference.isPresent()) { + return reference.get(); + } + } + throw new AssertionError("No phase parses the advertised negotiation URI " + templateUri.uri()); + } + private static TemplateUri findConstant(String uri) { return StandardTemplates.NEGOTIATION.stream() .filter(template -> template.uri().equals(uri)) diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java index 43cd37a9..e50a04ba 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java @@ -11,29 +11,26 @@ import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.AbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import org.junit.jupiter.api.Test; /** * Walks the round-budget exhaustion scenario: the last allowed round is not yet exhausted, advancing past it marks the - * context exhausted, the agent closes the negotiation with a reject message at the last allowed round and the peer - * still validates it — while a message of a round beyond the budget fails the peer's rule gate, so no further round can - * ever be exchanged. + * context exhausted, and the negotiation ends with an abort message at the last allowed round that the peer still + * validates — while a message of a round beyond the budget fails the peer's rule gate, so no further round can ever be + * exchanged. */ class MaxRoundsExhaustionScenarioTest { private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final TemplateUri TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; - @Test - void exhaustedRoundBudgetEndsWithARejectMessageThePeerStillAccepts() { + void exhaustedRoundBudgetEndsWithAnAbortMessageThePeerStillAccepts() { ScriptedLlmClient peerLlm = new ScriptedLlmClient(SemanticResponses.acceptance("target", "{}")); NegotiationGenerationOrchestrator agent = orchestrator(null); NegotiationGenerationOrchestrator peer = orchestrator(peerLlm); @@ -45,42 +42,34 @@ void exhaustedRoundBudgetEndsWithARejectMessageThePeerStillAccepts() { assertTrue(beyondBudget.isExhausted(), "round > maxRounds marks the context exhausted"); assertEquals(5, lastRound.round(), "nextRound must leave the original context unchanged"); - MetadataContent terminal = agent.generateRejectFromData( - new NegotiationEndingData( - lastRound, - new TargetEndingContent(NegotiationConclusion.REJECT, null, "节能目标无法在轮次预算内澄清,本次协商结束")), - TARGET_ACCEPT_URI); - assertTrue(terminal.promptText().contains("## 目标协商结果\nReject")); - assertTrue(!terminal.promptText().contains("Abort"), "no abort conclusion exists in the template set"); + MetadataContent terminal = agent.generateAbortFromData( + new NegotiationAbortData( + lastRound, new AbortContent("达到协商轮次上限,本次协商确认结束。")), + StandardTemplates.NEGOTIATION_ABORT); + assertTrue(terminal.promptText().contains("## 协商结果\nAbort")); + assertTrue(terminal.promptText().contains("## 协商终止原因")); + assertTrue(terminal.promptText().contains("达到协商轮次上限,本次协商确认结束。")); assertTrue(terminal.promptText().contains("- round: 5")); - FilledParamData terminalParameters = - peer.validateAndFillingRejectData(terminal.promptText(), parameterSchema(), TARGET_ACCEPT_URI); + FilledParamData terminalParameters = peer.validateAndFillingAbortData( + terminal.promptText(), parameterSchema(), StandardTemplates.NEGOTIATION_ABORT); assertEquals(1, peerLlm.callCount()); assertEquals(SESSION_ID, terminalParameters.data().get("id")); assertEquals(5, terminalParameters.data().get("round")); assertEquals(5, terminalParameters.data().get("maxRounds")); - MetadataContent beyondBudgetMessage = agent.generateRejectFromData( - new NegotiationEndingData( - beyondBudget, - new TargetEndingContent( - NegotiationConclusion.REJECT, null, "This message exceeds the round budget.")), - TARGET_ACCEPT_URI); + MetadataContent beyondBudgetMessage = agent.generateAbortFromData( + new NegotiationAbortData(beyondBudget, new AbortContent("This message exceeds the round budget.")), + StandardTemplates.NEGOTIATION_ABORT); NegotiationParamExtractionException ruleFailure = assertThrows( NegotiationParamExtractionException.class, - () -> peer.validateAndFillingRejectData( - beyondBudgetMessage.promptText(), parameterSchema(), TARGET_ACCEPT_URI)); + () -> peer.validateAndFillingAbortData( + beyondBudgetMessage.promptText(), parameterSchema(), StandardTemplates.NEGOTIATION_ABORT)); assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, ruleFailure.getCode()); assertTrue( ruleFailure.getErrors().stream().anyMatch(error -> "round".equals(error.slotName())), "the beyond-budget round must be reported on the round slot"); assertEquals(1, peerLlm.callCount(), "the rule gate must fail before any semantic LLM call"); - - assertTrue( - agent.getNegotiationPrompt(TemplateUri.of("Negotiation-T", "target-negotiation", "abort")) - .isEmpty(), - "no template of an abort phase exists anywhere"); } private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index 61baa796..b71335b4 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -238,6 +238,7 @@ MetadataContent generate(ProposeGenerator propose, EndingGenerator accept, Endin case PROPOSE -> propose.generate((NegotiationProposeData) data, templateUri); case ACCEPT -> accept.generate((NegotiationEndingData) data, templateUri); case REJECT -> reject.generate((NegotiationEndingData) data, templateUri); + case ABORT -> throw new IllegalArgumentException("The symmetry cases carry no abort phase."); }; } } diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java index a28b3143..ebf8792d 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java @@ -31,20 +31,23 @@ class NegotiationV3ApiSurfaceTest { Map.entry("generateNegotiationProposePromptFromData", 2), Map.entry("generateNegotiationAcceptPromptFromData", 2), Map.entry("generateNegotiationRejectPromptFromData", 2), + Map.entry("generateNegotiationAbortPromptFromData", 2), Map.entry("generateNegotiationProposePromptFromText", 3), Map.entry("generateNegotiationAcceptPromptFromText", 3), Map.entry("generateNegotiationRejectPromptFromText", 3), + Map.entry("generateNegotiationAbortPromptFromText", 3), Map.entry("getNegotiationPrompts", 0), Map.entry("getNegotiationPrompt", 1), Map.entry("validateAndFillingProposeData", 3), Map.entry("validateAndFillingAcceptData", 3), - Map.entry("validateAndFillingRejectData", 3)); + Map.entry("validateAndFillingRejectData", 3), + Map.entry("validateAndFillingAbortData", 3)); private static final List REMOVED_V2_METHOD_NAME_FRAGMENTS = List.of("FromNl", "FromJsonData", "validateAndExtractParams"); @Test - void bothFacadesExposeExactlyTheElevenV3NegotiationMethods() { + void bothFacadesExposeExactlyTheFourteenV3NegotiationMethods() { assertExactNegotiationSurface(A2ATClient.class); assertExactNegotiationSurface(A2ATServer.class); } @@ -74,13 +77,14 @@ private static void assertExactNegotiationSurface(Class facade) { || method.getName().startsWith("getNegotiation") || "validateAndFillingProposeData".equals(method.getName()) || "validateAndFillingAcceptData".equals(method.getName()) - || "validateAndFillingRejectData".equals(method.getName())) + || "validateAndFillingRejectData".equals(method.getName()) + || "validateAndFillingAbortData".equals(method.getName())) .toList(); Set names = negotiationMethods.stream().map(Method::getName).collect(Collectors.toSet()); assertEquals( EXPECTED_NEGOTIATION_METHOD_PARAMETERS.keySet(), names, - facade.getSimpleName() + " must expose exactly the eleven v3 negotiation methods"); + facade.getSimpleName() + " must expose exactly the fourteen v3 negotiation methods"); assertEquals( EXPECTED_NEGOTIATION_METHOD_PARAMETERS.size(), negotiationMethods.size(), diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 7dc74b29..17be3cb7 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -12,6 +12,7 @@ import net.openan.a2at.sdk.core.validation.ContentValidator; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; @@ -181,6 +182,28 @@ public MetadataContent generateNegotiationRejectPromptFromData( return negotiationContentService.generateRejectFromData(data, templateUri); } + /** + * Generates an abort negotiation message from typed data. + * + *

This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed + * template must be the common abort template and the content carries only the termination reason. + * + * @param data typed abort input carrying the negotiation context and the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template or the + * termination reason is blank + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationAbortPromptFromData( + @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.generateAbortFromData(data, templateUri); + } + /** * Generates a propose-phase negotiation message from free text. * @@ -269,6 +292,32 @@ public MetadataContent generateNegotiationRejectPromptFromText( return negotiationContentService.generateRejectFromText(text, context, templateUri); } + /** + * Generates an abort negotiation message from free text. + * + *

This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. + * + * @param text free-text input stating the termination reason + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the + * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted + * content misses the termination reason, or {@code negotiation_invalid_input} when the text is blank + */ + public MetadataContent generateNegotiationAbortPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.negotiation.content.NegotiationContext context, + @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.generateAbortFromText(text, context, templateUri); + } + /** * Lists every template available for the configured language across all A2A-T extensions. * @@ -406,6 +455,32 @@ public FilledParamData validateAndFillingRejectData( return negotiationContentService.validateAndFillingRejectData(prompt, schema, templateUri); } + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the + * abort-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws IllegalArgumentException if the prompt is blank or the template URI does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation_invalid_input} when the prompt is not a negotiation message, + * {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateAndFillingAbortData( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.validateAndFillingAbortData(prompt, schema, templateUri); + } + /** * Validates a task prompt and extracts its parameters. * diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index 307d51db..1196f334 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -9,8 +9,10 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.AbortContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; @@ -65,11 +67,37 @@ void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOE } @Test - void listsAllSixNegotiationTemplatesPerLanguage() throws IOException { + void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { assertEquals( - 6, new A2ATServer(writeEnv("zh-CN")).getNegotiationPrompts().size()); + 7, new A2ATServer(writeEnv("zh-CN")).getNegotiationPrompts().size()); assertEquals( - 6, new A2ATServer(writeEnv("en-US")).getNegotiationPrompts().size()); + 7, new A2ATServer(writeEnv("en-US")).getNegotiationPrompts().size()); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + MetadataContent result = server.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 5, 5), + new AbortContent("达到协商轮次上限,本次协商确认结束。")), + StandardTemplates.NEGOTIATION_ABORT); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("## 协商终止原因")); + assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); + } + + @Test + void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + PromptTemplate template = + server.getNegotiationPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); + assertFalse(template.content().isBlank()); } @Test diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index dded9af3..537b8e7a 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -429,9 +429,10 @@ Every method that identifies a template takes the value type `net.openan.a2at.sd MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, TemplateUri templateUri) MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, TemplateUri templateUri) MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, TemplateUri templateUri) +MetadataContent generateNegotiationAbortPromptFromData(NegotiationAbortData data, TemplateUri templateUri) ``` -The typed content is validated, dispatched to the generator of the negotiation type addressed by the template URI, and rendered from that template. The accept variant requires `content.conclusion() == ACCEPT`, the reject variant `REJECT`; a mismatched conclusion (including `ABORT`) is rejected with `IllegalArgumentException` as a programming error. +The typed content is validated, dispatched to the generator of the negotiation type addressed by the template URI, and rendered from that template. The accept variant requires `content.conclusion() == ACCEPT`, the reject variant `REJECT`; a mismatched conclusion (including `ABORT`) is rejected with `IllegalArgumentException` as a programming error. The abort variant is type-independent: it renders the single common abort template `StandardTemplates.NEGOTIATION_ABORT` from a `NegotiationAbortData(context, AbortContent(terminationReason))` bundle, and the fixed `Abort` conclusion is carried by the template itself. **Generation from free text — one LLM extraction step with configurable retry:** @@ -439,9 +440,10 @@ The typed content is validated, dispatched to the generator of the negotiation t MetadataContent generateNegotiationProposePromptFromText(String text, NegotiationContext context, TemplateUri templateUri) MetadataContent generateNegotiationAcceptPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) MetadataContent generateNegotiationRejectPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) +MetadataContent generateNegotiationAbortPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) ``` -The template is loaded before the LLM call; a missing template fails fast without consuming an LLM request. The LLM step extracts the typed content from the free text constrained by the template URI, then rendering proceeds deterministically like the from-data variant. The `NegotiationContext` is injected into the rendered message without any LLM involvement. The extraction step is retried up to the configured attempt limit on the retryable failure codes (see 1.10.5). +The template is loaded before the LLM call; a missing template fails fast without consuming an LLM request. The LLM step extracts the typed content from the free text constrained by the template URI, then rendering proceeds deterministically like the from-data variant. The `NegotiationContext` is injected into the rendered message without any LLM involvement. The extraction step is retried up to the configured attempt limit on the retryable failure codes (see 1.10.5). The abort variant extracts the termination reason from the free text against the common abort template. **Compliance checking and parameter extraction:** @@ -449,6 +451,7 @@ The template is loaded before the LLM call; a missing template fails fast withou FilledParamData validateAndFillingProposeData(String prompt, Map schema, TemplateUri templateUri) FilledParamData validateAndFillingAcceptData(String prompt, Map schema, TemplateUri templateUri) FilledParamData validateAndFillingRejectData(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateAndFillingAbortData(String prompt, Map schema, TemplateUri templateUri) ``` The pipeline runs in a fixed order: @@ -504,7 +507,7 @@ All types live in `net.openan.a2at.sdk.negotiation.content`. **NegotiationContext** — the session context carried by every message: `record NegotiationContext(String id, int round, int maxRounds)`. It is immutable; `nextRound()` returns a context with the round incremented, and `isExhausted()` reports whether the round is strictly greater than `maxRounds`. `NegotiationContext.of(id, round)` applies the default round budget of `DEFAULT_MAX_ROUNDS = 5`. -**Input bundles** — `NegotiationProposeData(context, content)` and `NegotiationEndingData(context, content)` pair the context with the typed content. The content types are sealed hierarchies per negotiation type: +**Input bundles** — `NegotiationProposeData(context, content)` and `NegotiationEndingData(context, content)` pair the context with the typed content, and `NegotiationAbortData(context, AbortContent)` pairs it with the type-independent abort content. The content types are sealed hierarchies per negotiation type: | Negotiation type | Propose content | Ending content | |--|--|--| @@ -512,7 +515,7 @@ All types live in `net.openan.a2at.sdk.negotiation.content`. | Target | `TargetProposeContent(description, intentUnderstanding, alignmentAndClarification, requestForClarification)` | `TargetEndingContent(conclusion, confirmedIntent, failureReason)` | | Feasibility | `FeasibilityProposeContent(description, action, contentsToEvaluate, infeasibilityDetailsAndProposal)` | `FeasibilityEndingContent(conclusion, feasibilitySummary)` | -`NegotiationItem(name, value)` is one named entry of an item list. `NegotiationConclusion` carries `ACCEPT`, `REJECT`, and `ABORT`; only `Accept` and `Reject` are renderable — generation methods reject `ABORT` with an `IllegalArgumentException` (a programming error outside the `A2ATError` tree). `NegotiationAction` (`REQUEST_FEASIBILITY_EVALUATION`, `PROPOSE_ALTERNATIVE_ON_FAILURE`) selects the conditional sections of the feasibility propose template. +`NegotiationItem(name, value)` is one named entry of an item list. `NegotiationConclusion` carries `ACCEPT`, `REJECT`, and `ABORT`; only `Accept` and `Reject` are renderable conclusions of the typed negotiation templates — the typed generation methods reject `ABORT` with an `IllegalArgumentException` (a programming error outside the `A2ATError` tree). The `ABORT` outcome is rendered through the type-independent common abort template: `AbortContent(terminationReason)` is the only content it carries and `NegotiationAbortData(context, content)` is its input bundle. `NegotiationAction` (`REQUEST_FEASIBILITY_EVALUATION`, `PROPOSE_ALTERNATIVE_ON_FAILURE`) selects the conditional sections of the feasibility propose template. **MetadataContent** — the generation result: `record MetadataContent(String templateUri, String promptText, String extensionUri)`. `buildMetadataContent()` returns exactly two keys: the TMF extension URI (`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`) mapping to the rendered message, and `templateUri` mapping to the template URI. This map is what travels in the A2A message metadata. diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 4dea73eb..e76c32fa 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -412,9 +412,10 @@ mvn -pl a2a-t-sample -am -DskipTests package MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, TemplateUri templateUri) MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, TemplateUri templateUri) MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, TemplateUri templateUri) +MetadataContent generateNegotiationAbortPromptFromData(NegotiationAbortData data, TemplateUri templateUri) ``` -类型化内容先经过校验,再分发给模板 URI 所指协商类型的生成器,并由该模板渲染。accept 变体要求 `content.conclusion() == ACCEPT`,reject 变体要求 `REJECT`;结论不匹配(包括 `ABORT`)会以 `IllegalArgumentException`(编程错误)拒绝。 +类型化内容先经过校验,再分发给模板 URI 所指协商类型的生成器,并由该模板渲染。accept 变体要求 `content.conclusion() == ACCEPT`,reject 变体要求 `REJECT`;结论不匹配(包括 `ABORT`)会以 `IllegalArgumentException`(编程错误)拒绝。abort 变体与协商类型无关:它由 `NegotiationAbortData(context, AbortContent(terminationReason))` 输入渲染唯一的 common abort 模板 `StandardTemplates.NEGOTIATION_ABORT`,固定的 `Abort` 结论由模板自身承载。 **从自由文本生成 — 一次 LLM 抽取,支持可配置重试:** @@ -422,9 +423,10 @@ MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData da MetadataContent generateNegotiationProposePromptFromText(String text, NegotiationContext context, TemplateUri templateUri) MetadataContent generateNegotiationAcceptPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) MetadataContent generateNegotiationRejectPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) +MetadataContent generateNegotiationAbortPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) ``` -模板在调用 LLM 之前先行加载;模板缺失会快速失败,不会消耗 LLM 请求。LLM 步骤在模板 URI 的约束下从自由文本中抽取类型化内容,随后的渲染与 from-data 变体一样是确定性的。`NegotiationContext` 不经任何 LLM 直接注入渲染结果。抽取步骤在可重试错误码(见 1.10.5)上最多重试到配置的尝试上限。 +模板在调用 LLM 之前先行加载;模板缺失会快速失败,不会消耗 LLM 请求。LLM 步骤在模板 URI 的约束下从自由文本中抽取类型化内容,随后的渲染与 from-data 变体一样是确定性的。`NegotiationContext` 不经任何 LLM 直接注入渲染结果。抽取步骤在可重试错误码(见 1.10.5)上最多重试到配置的尝试上限。abort 变体面向 common abort 模板从自由文本中抽取协商终止原因。 **合规校验与参数提取:** @@ -432,6 +434,7 @@ MetadataContent generateNegotiationRejectPromptFromText(String text, Negotiation FilledParamData validateAndFillingProposeData(String prompt, Map schema, TemplateUri templateUri) FilledParamData validateAndFillingAcceptData(String prompt, Map schema, TemplateUri templateUri) FilledParamData validateAndFillingRejectData(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateAndFillingAbortData(String prompt, Map schema, TemplateUri templateUri) ``` 流水线按固定顺序执行: @@ -487,7 +490,7 @@ Negotiation-T/{type}-negotiation/{phase}/v1 **NegotiationContext** — 每条消息携带的会话上下文:`record NegotiationContext(String id, int round, int maxRounds)`。它不可变;`nextRound()` 返回轮次加一的新上下文,`isExhausted()` 判断当前轮次是否严格大于 `maxRounds`。`NegotiationContext.of(id, round)` 使用默认轮次预算 `DEFAULT_MAX_ROUNDS = 5`。 -**输入包装** — `NegotiationProposeData(context, content)` 与 `NegotiationEndingData(context, content)` 将上下文与类型化内容配对。内容类型是按协商类型划分的密封继承体系: +**输入包装** — `NegotiationProposeData(context, content)` 与 `NegotiationEndingData(context, content)` 将上下文与类型化内容配对,`NegotiationAbortData(context, AbortContent)` 将上下文与类型无关的协商终止内容配对。内容类型是按协商类型划分的密封继承体系: | 协商类型 | Propose 内容 | Ending 内容 | |--|--|--| @@ -495,7 +498,7 @@ Negotiation-T/{type}-negotiation/{phase}/v1 | Target | `TargetProposeContent(description, intentUnderstanding, alignmentAndClarification, requestForClarification)` | `TargetEndingContent(conclusion, confirmedIntent, failureReason)` | | Feasibility | `FeasibilityProposeContent(description, action, contentsToEvaluate, infeasibilityDetailsAndProposal)` | `FeasibilityEndingContent(conclusion, feasibilitySummary)` | -`NegotiationItem(name, value)` 表示条目列表中的一个命名条目。`NegotiationConclusion` 取值为 `ACCEPT`、`REJECT`、`ABORT`;只有 `Accept` 与 `Reject` 可被渲染 — 生成方法会以 `IllegalArgumentException`(`A2ATError` 树外的编程错误)拒绝 `ABORT`。`NegotiationAction`(`REQUEST_FEASIBILITY_EVALUATION`、`PROPOSE_ALTERNATIVE_ON_FAILURE`)决定可行性 propose 模板中渲染哪些条件章节。 +`NegotiationItem(name, value)` 表示条目列表中的一个命名条目。`NegotiationConclusion` 取值为 `ACCEPT`、`REJECT`、`ABORT`;只有 `Accept` 与 `Reject` 是类型化协商模板的可渲染结论 — 类型化生成方法会以 `IllegalArgumentException`(`A2ATError` 树外的编程错误)拒绝 `ABORT`。`ABORT` 结果经由类型无关的 common abort 模板渲染:`AbortContent(terminationReason)` 是它唯一携带的内容,`NegotiationAbortData(context, content)` 是其输入包装。`NegotiationAction`(`REQUEST_FEASIBILITY_EVALUATION`、`PROPOSE_ALTERNATIVE_ON_FAILURE`)决定可行性 propose 模板中渲染哪些条件章节。 **MetadataContent** — 生成结果:`record MetadataContent(String templateUri, String promptText, String extensionUri)`。`buildMetadataContent()` 返回恰好两个键:TMF 扩展 URI(`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`)映射到渲染后的消息文本,`templateUri` 映射到模板 URI。这个映射即随 A2A 消息 metadata 传输的内容。 From e6995d390976774d3e9f91e8f23efa8856c2ffd3 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 14:52:02 +0800 Subject: [PATCH 092/295] refactor(negotiation): rename AbortContent to NegotiationAbortContent Align the type-independent abort content with the Negotiation-prefixed value objects of the content package, matching NegotiationAbortData. Signed-off-by: codetalks-hw --- .../client/api/A2ATClientNegotiationApiTest.java | 6 +++--- ...Content.java => NegotiationAbortContent.java} | 2 +- .../content/NegotiationAbortData.java | 2 +- .../negotiation/content/NegotiationContent.java | 4 ++-- .../negotiation/generation/AbortGenerator.java | 4 ++-- .../DefaultNegotiationContentExtractor.java | 4 ++-- .../generation/NegotiationGeneratorRegistry.java | 4 ++-- .../NegotiationPackageStructureTest.java | 4 ++-- .../generation/AbortMessageGenerationTest.java | 16 ++++++++-------- .../MaxRoundsExhaustionScenarioTest.java | 6 +++--- .../server/api/A2ATServerNegotiationApiTest.java | 4 ++-- docs/en/developer_guide.md | 6 +++--- ...4\200\345\217\221\346\214\207\345\215\227.md" | 6 +++--- 13 files changed, 34 insertions(+), 34 deletions(-) rename a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/{AbortContent.java => NegotiationAbortContent.java} (82%) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index 48207c23..4d2e44e6 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -14,7 +14,7 @@ import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.AbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; @@ -95,7 +95,7 @@ void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOExceptio MetadataContent result = client.generateNegotiationAbortPromptFromData( new NegotiationAbortData( new NegotiationContext(UUID, 5, 5), - new AbortContent("达到协商轮次上限,本次协商确认结束。")), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), StandardTemplates.NEGOTIATION_ABORT); assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); @@ -112,7 +112,7 @@ void generatesAbortPromptFromDataWithBuiltinEnglishTemplates() throws IOExceptio MetadataContent result = client.generateNegotiationAbortPromptFromData( new NegotiationAbortData( new NegotiationContext(UUID, 3, 5), - new AbortContent("Reached the negotiation round limit. This negotiation is confirmed and ended.")), + new NegotiationAbortContent("Reached the negotiation round limit. This negotiation is confirmed and ended.")), StandardTemplates.NEGOTIATION_ABORT); assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/AbortContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortContent.java similarity index 82% rename from a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/AbortContent.java rename to a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortContent.java index 1dc44845..69ef699e 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/AbortContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortContent.java @@ -10,4 +10,4 @@ * a timeout or a token budget exhaustion * @since 2026-08 */ -public record AbortContent(String terminationReason) implements NegotiationContent {} +public record NegotiationAbortContent(String terminationReason) implements NegotiationContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortData.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortData.java index 0cd75d36..74be8b8c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortData.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortData.java @@ -10,4 +10,4 @@ * @param content typed abort content carrying the termination reason * @since 2026-08 */ -public record NegotiationAbortData(NegotiationContext context, AbortContent content) {} +public record NegotiationAbortData(NegotiationContext context, NegotiationAbortContent content) {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java index 9beaa9b8..e09b9576 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java @@ -4,10 +4,10 @@ * Marker for the typed content of any negotiation message, regardless of phase. * *

The concrete families are {@link NegotiationProposeContent} for propose-phase messages, - * {@link NegotiationEndingContent} for terminal accept/reject messages and {@link AbortContent} for the + * {@link NegotiationEndingContent} for terminal accept/reject messages and {@link NegotiationAbortContent} for the * type-independent abort message. Generators and content extractors accept this common supertype and dispatch on the * exact runtime type. * * @since 2026-08 */ -public sealed interface NegotiationContent permits NegotiationProposeContent, NegotiationEndingContent, AbortContent {} +public sealed interface NegotiationContent permits NegotiationProposeContent, NegotiationEndingContent, NegotiationAbortContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java index e0ef221f..80e8c646 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java @@ -2,7 +2,7 @@ import java.util.LinkedHashMap; import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.AbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.Vocabulary; @@ -30,7 +30,7 @@ final class AbortGenerator extends AbstractNegotiationGenerator { @Override public String generate( NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - AbortContent abortContent = contentOf(content, AbortContent.class, "Abort generator"); + NegotiationAbortContent abortContent = contentOf(content, NegotiationAbortContent.class, "Abort generator"); Map slots = new LinkedHashMap<>(); slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); slots.put( diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java index 543aad77..7ed9b5f4 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java @@ -9,7 +9,7 @@ import net.openan.a2at.sdk.core.json.JsonValueParser; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.AbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; @@ -160,7 +160,7 @@ private Map invokeLlm(List> messages, Map payload, @Nullable NegotiationType type, NegotiationPhase phase) { if (phase == NegotiationPhase.ABORT) { - return new AbortContent(requiredString(payload, "termination_reason")); + return new NegotiationAbortContent(requiredString(payload, "termination_reason")); } if (phase == NegotiationPhase.PROPOSE) { return mapProposeContent(payload, type); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java index 32c0de74..a2002686 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java @@ -3,7 +3,7 @@ import java.util.EnumMap; import java.util.Map; import java.util.Objects; -import net.openan.a2at.sdk.negotiation.content.AbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; @@ -71,7 +71,7 @@ public NegotiationGenerator resolve( throw new IllegalArgumentException( "The ABORT phase is type-independent and must not carry a type but carried " + type + "."); } - if (content.getClass() != AbortContent.class) { + if (content.getClass() != NegotiationAbortContent.class) { throw new IllegalArgumentException( "The ABORT phase requires abort content but received " + content.getClass().getSimpleName() + "."); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java index 947ea1ea..98f4630f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -23,12 +23,12 @@ class NegotiationPackageStructureTest { void contentPackageExposesExactlyThePinnedTypes() throws IOException { assertEquals( List.of( - "AbortContent.java", "FeasibilityEndingContent.java", "FeasibilityProposeContent.java", "InformationEndingContent.java", "InformationProposeContent.java", - "NegotiationAbortData.java", + "NegotiationAbortContent.java", + "NegotiationAbortData.java", "NegotiationAction.java", "NegotiationConclusion.java", "NegotiationContent.java", diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java index cecd12b9..3e29dde5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java @@ -15,7 +15,7 @@ import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.AbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; @@ -49,7 +49,7 @@ void generatesAChineseAbortMessageFromDataWithoutAnyLlmCall() { .generateAbortFromData( new NegotiationAbortData( new NegotiationContext(SESSION_ID, 5, 5), - new AbortContent("达到协商轮次上限,本次协商确认结束。")), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), ABORT_URI); assertEquals(ABORT_URI.uri(), result.templateUri()); @@ -71,7 +71,7 @@ void generatesAnEnglishAbortMessageFromData() { .generateAbortFromData( new NegotiationAbortData( new NegotiationContext(SESSION_ID, 3, 5), - new AbortContent("Reached the negotiation round limit. This negotiation is confirmed and ended.")), + new NegotiationAbortContent("Reached the negotiation round limit. This negotiation is confirmed and ended.")), ABORT_URI); assertEquals(ABORT_URI.uri(), result.templateUri()); @@ -92,7 +92,7 @@ void blankTerminationReasonFailsAsAProgrammingErrorBeforeAnyLlmCall() { () -> orchestrator("zh-CN") .generateAbortFromData( new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 1, 5), new AbortContent(" ")), + new NegotiationContext(SESSION_ID, 1, 5), new NegotiationAbortContent(" ")), ABORT_URI)); assertTrue(failure.getMessage().contains("Termination reason"), "message must point at the offending field"); assertEquals(0, llm.calls); @@ -106,7 +106,7 @@ void abortGenerationRejectsTypedTemplateUris() { .generateAbortFromData( new NegotiationAbortData( new NegotiationContext(SESSION_ID, 1, 5), - new AbortContent("达到协商轮次上限。")), + new NegotiationAbortContent("达到协商轮次上限。")), StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT)); assertTrue(failure.getMessage().contains("abort")); } @@ -143,7 +143,7 @@ void serviceMirrorsTheAbortGenerationEntryPoint() { MetadataContent result = service.generateAbortFromData( new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 2, 5), new AbortContent("token 消耗超限,协商终止。")), + new NegotiationContext(SESSION_ID, 2, 5), new NegotiationAbortContent("token 消耗超限,协商终止。")), ABORT_URI); assertEquals(ABORT_URI.uri(), result.templateUri()); @@ -183,7 +183,7 @@ void peerValidatesAReceivedAbortMessageAndExtractsItsParameters() { MetadataContent abortMessage = agent.generateAbortFromData( new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 5, 5), new AbortContent("达到协商轮次上限,本次协商确认结束。")), + new NegotiationContext(SESSION_ID, 5, 5), new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), ABORT_URI); FilledParamData parameters = @@ -209,7 +209,7 @@ void abortValidationFailsOnBeyondBudgetRoundsBeforeAnyLlmCall() { MetadataContent beyondBudget = agent.generateAbortFromData( new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 6, 5), new AbortContent("轮次预算耗尽。")), ABORT_URI); + new NegotiationContext(SESSION_ID, 6, 5), new NegotiationAbortContent("轮次预算耗尽。")), ABORT_URI); NegotiationParamExtractionException failure = assertThrows( NegotiationParamExtractionException.class, diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java index e50a04ba..c5012c37 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java @@ -11,7 +11,7 @@ import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.AbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; @@ -44,7 +44,7 @@ void exhaustedRoundBudgetEndsWithAnAbortMessageThePeerStillAccepts() { MetadataContent terminal = agent.generateAbortFromData( new NegotiationAbortData( - lastRound, new AbortContent("达到协商轮次上限,本次协商确认结束。")), + lastRound, new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), StandardTemplates.NEGOTIATION_ABORT); assertTrue(terminal.promptText().contains("## 协商结果\nAbort")); assertTrue(terminal.promptText().contains("## 协商终止原因")); @@ -59,7 +59,7 @@ lastRound, new AbortContent("达到协商轮次上限,本次协商确认结束 assertEquals(5, terminalParameters.data().get("maxRounds")); MetadataContent beyondBudgetMessage = agent.generateAbortFromData( - new NegotiationAbortData(beyondBudget, new AbortContent("This message exceeds the round budget.")), + new NegotiationAbortData(beyondBudget, new NegotiationAbortContent("This message exceeds the round budget.")), StandardTemplates.NEGOTIATION_ABORT); NegotiationParamExtractionException ruleFailure = assertThrows( NegotiationParamExtractionException.class, diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index 1196f334..451887cf 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -9,7 +9,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.AbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; @@ -81,7 +81,7 @@ void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOExceptio MetadataContent result = server.generateNegotiationAbortPromptFromData( new NegotiationAbortData( new NegotiationContext(UUID, 5, 5), - new AbortContent("达到协商轮次上限,本次协商确认结束。")), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), StandardTemplates.NEGOTIATION_ABORT); assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 537b8e7a..4b108120 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -432,7 +432,7 @@ MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData da MetadataContent generateNegotiationAbortPromptFromData(NegotiationAbortData data, TemplateUri templateUri) ``` -The typed content is validated, dispatched to the generator of the negotiation type addressed by the template URI, and rendered from that template. The accept variant requires `content.conclusion() == ACCEPT`, the reject variant `REJECT`; a mismatched conclusion (including `ABORT`) is rejected with `IllegalArgumentException` as a programming error. The abort variant is type-independent: it renders the single common abort template `StandardTemplates.NEGOTIATION_ABORT` from a `NegotiationAbortData(context, AbortContent(terminationReason))` bundle, and the fixed `Abort` conclusion is carried by the template itself. +The typed content is validated, dispatched to the generator of the negotiation type addressed by the template URI, and rendered from that template. The accept variant requires `content.conclusion() == ACCEPT`, the reject variant `REJECT`; a mismatched conclusion (including `ABORT`) is rejected with `IllegalArgumentException` as a programming error. The abort variant is type-independent: it renders the single common abort template `StandardTemplates.NEGOTIATION_ABORT` from a `NegotiationAbortData(context, NegotiationAbortContent(terminationReason))` bundle, and the fixed `Abort` conclusion is carried by the template itself. **Generation from free text — one LLM extraction step with configurable retry:** @@ -507,7 +507,7 @@ All types live in `net.openan.a2at.sdk.negotiation.content`. **NegotiationContext** — the session context carried by every message: `record NegotiationContext(String id, int round, int maxRounds)`. It is immutable; `nextRound()` returns a context with the round incremented, and `isExhausted()` reports whether the round is strictly greater than `maxRounds`. `NegotiationContext.of(id, round)` applies the default round budget of `DEFAULT_MAX_ROUNDS = 5`. -**Input bundles** — `NegotiationProposeData(context, content)` and `NegotiationEndingData(context, content)` pair the context with the typed content, and `NegotiationAbortData(context, AbortContent)` pairs it with the type-independent abort content. The content types are sealed hierarchies per negotiation type: +**Input bundles** — `NegotiationProposeData(context, content)` and `NegotiationEndingData(context, content)` pair the context with the typed content, and `NegotiationAbortData(context, NegotiationAbortContent)` pairs it with the type-independent abort content. The content types are sealed hierarchies per negotiation type: | Negotiation type | Propose content | Ending content | |--|--|--| @@ -515,7 +515,7 @@ All types live in `net.openan.a2at.sdk.negotiation.content`. | Target | `TargetProposeContent(description, intentUnderstanding, alignmentAndClarification, requestForClarification)` | `TargetEndingContent(conclusion, confirmedIntent, failureReason)` | | Feasibility | `FeasibilityProposeContent(description, action, contentsToEvaluate, infeasibilityDetailsAndProposal)` | `FeasibilityEndingContent(conclusion, feasibilitySummary)` | -`NegotiationItem(name, value)` is one named entry of an item list. `NegotiationConclusion` carries `ACCEPT`, `REJECT`, and `ABORT`; only `Accept` and `Reject` are renderable conclusions of the typed negotiation templates — the typed generation methods reject `ABORT` with an `IllegalArgumentException` (a programming error outside the `A2ATError` tree). The `ABORT` outcome is rendered through the type-independent common abort template: `AbortContent(terminationReason)` is the only content it carries and `NegotiationAbortData(context, content)` is its input bundle. `NegotiationAction` (`REQUEST_FEASIBILITY_EVALUATION`, `PROPOSE_ALTERNATIVE_ON_FAILURE`) selects the conditional sections of the feasibility propose template. +`NegotiationItem(name, value)` is one named entry of an item list. `NegotiationConclusion` carries `ACCEPT`, `REJECT`, and `ABORT`; only `Accept` and `Reject` are renderable conclusions of the typed negotiation templates — the typed generation methods reject `ABORT` with an `IllegalArgumentException` (a programming error outside the `A2ATError` tree). The `ABORT` outcome is rendered through the type-independent common abort template: `NegotiationAbortContent(terminationReason)` is the only content it carries and `NegotiationAbortData(context, content)` is its input bundle. `NegotiationAction` (`REQUEST_FEASIBILITY_EVALUATION`, `PROPOSE_ALTERNATIVE_ON_FAILURE`) selects the conditional sections of the feasibility propose template. **MetadataContent** — the generation result: `record MetadataContent(String templateUri, String promptText, String extensionUri)`. `buildMetadataContent()` returns exactly two keys: the TMF extension URI (`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`) mapping to the rendered message, and `templateUri` mapping to the template URI. This map is what travels in the A2A message metadata. diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index e76c32fa..094a3a8f 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -415,7 +415,7 @@ MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData da MetadataContent generateNegotiationAbortPromptFromData(NegotiationAbortData data, TemplateUri templateUri) ``` -类型化内容先经过校验,再分发给模板 URI 所指协商类型的生成器,并由该模板渲染。accept 变体要求 `content.conclusion() == ACCEPT`,reject 变体要求 `REJECT`;结论不匹配(包括 `ABORT`)会以 `IllegalArgumentException`(编程错误)拒绝。abort 变体与协商类型无关:它由 `NegotiationAbortData(context, AbortContent(terminationReason))` 输入渲染唯一的 common abort 模板 `StandardTemplates.NEGOTIATION_ABORT`,固定的 `Abort` 结论由模板自身承载。 +类型化内容先经过校验,再分发给模板 URI 所指协商类型的生成器,并由该模板渲染。accept 变体要求 `content.conclusion() == ACCEPT`,reject 变体要求 `REJECT`;结论不匹配(包括 `ABORT`)会以 `IllegalArgumentException`(编程错误)拒绝。abort 变体与协商类型无关:它由 `NegotiationAbortData(context, NegotiationAbortContent(terminationReason))` 输入渲染唯一的 common abort 模板 `StandardTemplates.NEGOTIATION_ABORT`,固定的 `Abort` 结论由模板自身承载。 **从自由文本生成 — 一次 LLM 抽取,支持可配置重试:** @@ -490,7 +490,7 @@ Negotiation-T/{type}-negotiation/{phase}/v1 **NegotiationContext** — 每条消息携带的会话上下文:`record NegotiationContext(String id, int round, int maxRounds)`。它不可变;`nextRound()` 返回轮次加一的新上下文,`isExhausted()` 判断当前轮次是否严格大于 `maxRounds`。`NegotiationContext.of(id, round)` 使用默认轮次预算 `DEFAULT_MAX_ROUNDS = 5`。 -**输入包装** — `NegotiationProposeData(context, content)` 与 `NegotiationEndingData(context, content)` 将上下文与类型化内容配对,`NegotiationAbortData(context, AbortContent)` 将上下文与类型无关的协商终止内容配对。内容类型是按协商类型划分的密封继承体系: +**输入包装** — `NegotiationProposeData(context, content)` 与 `NegotiationEndingData(context, content)` 将上下文与类型化内容配对,`NegotiationAbortData(context, NegotiationAbortContent)` 将上下文与类型无关的协商终止内容配对。内容类型是按协商类型划分的密封继承体系: | 协商类型 | Propose 内容 | Ending 内容 | |--|--|--| @@ -498,7 +498,7 @@ Negotiation-T/{type}-negotiation/{phase}/v1 | Target | `TargetProposeContent(description, intentUnderstanding, alignmentAndClarification, requestForClarification)` | `TargetEndingContent(conclusion, confirmedIntent, failureReason)` | | Feasibility | `FeasibilityProposeContent(description, action, contentsToEvaluate, infeasibilityDetailsAndProposal)` | `FeasibilityEndingContent(conclusion, feasibilitySummary)` | -`NegotiationItem(name, value)` 表示条目列表中的一个命名条目。`NegotiationConclusion` 取值为 `ACCEPT`、`REJECT`、`ABORT`;只有 `Accept` 与 `Reject` 是类型化协商模板的可渲染结论 — 类型化生成方法会以 `IllegalArgumentException`(`A2ATError` 树外的编程错误)拒绝 `ABORT`。`ABORT` 结果经由类型无关的 common abort 模板渲染:`AbortContent(terminationReason)` 是它唯一携带的内容,`NegotiationAbortData(context, content)` 是其输入包装。`NegotiationAction`(`REQUEST_FEASIBILITY_EVALUATION`、`PROPOSE_ALTERNATIVE_ON_FAILURE`)决定可行性 propose 模板中渲染哪些条件章节。 +`NegotiationItem(name, value)` 表示条目列表中的一个命名条目。`NegotiationConclusion` 取值为 `ACCEPT`、`REJECT`、`ABORT`;只有 `Accept` 与 `Reject` 是类型化协商模板的可渲染结论 — 类型化生成方法会以 `IllegalArgumentException`(`A2ATError` 树外的编程错误)拒绝 `ABORT`。`ABORT` 结果经由类型无关的 common abort 模板渲染:`NegotiationAbortContent(terminationReason)` 是它唯一携带的内容,`NegotiationAbortData(context, content)` 是其输入包装。`NegotiationAction`(`REQUEST_FEASIBILITY_EVALUATION`、`PROPOSE_ALTERNATIVE_ON_FAILURE`)决定可行性 propose 模板中渲染哪些条件章节。 **MetadataContent** — 生成结果:`record MetadataContent(String templateUri, String promptText, String extensionUri)`。`buildMetadataContent()` 返回恰好两个键:TMF 扩展 URI(`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`)映射到渲染后的消息文本,`templateUri` 映射到模板 URI。这个映射即随 A2A 消息 metadata 传输的内容。 From 3242c7cfbee29be7f93768bbac43693aa02d9a23 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 22 Aug 2026 10:33:11 +0800 Subject: [PATCH 093/295] fix: load content_validation prompts with .md extension and wrap LLM errors as retryable Signed-off-by: w00609601 --- .../validation/DefaultSemanticValidator.java | 15 +- .../DefaultSemanticValidatorTest.java | 239 ++++++++++++++++++ 2 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index 96a2d78d..e0dfb33a 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -18,6 +18,7 @@ import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ValidationResult; import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMError; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; import org.jspecify.annotations.NonNull; @@ -56,8 +57,8 @@ public DefaultSemanticValidator( @NonNull String language, @NonNull PromptResourceAccess promptResourceAccess) { this.llmClient = llmClient; - this.systemPrompt = promptResourceAccess.loadPrompt("content_validation", language, "system"); - this.userPromptTemplate = promptResourceAccess.loadPrompt("content_validation", language, "user"); + this.systemPrompt = promptResourceAccess.loadPrompt("content_validation", language, "system.md"); + this.userPromptTemplate = promptResourceAccess.loadPrompt("content_validation", language, "user.md"); this.jsonValueParser = new JacksonJsonValueParser(); } @@ -75,7 +76,15 @@ public ValidationResult validate( List> messages = toStructuredMessages( List.of(new PromptMessage("system", systemPrompt), new PromptMessage("user", userPrompt))); - LLMResponse response = llmClient.structured(messages, outputSchema, null, null); + LLMResponse response; + try { + response = llmClient.structured(messages, outputSchema, null, null); + } catch (LLMError error) { + throw new ContentValidationException( + A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, + "Semantic validation LLM invocation failed: " + error.getMessage(), + error); + } Map parsed; try { parsed = jsonValueParser.parseObject(response.content()); diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java new file mode 100644 index 00000000..7ca7d6bf --- /dev/null +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java @@ -0,0 +1,239 @@ +package net.openan.a2at.sdk.prompt.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.TemplateReference; +import net.openan.a2at.sdk.core.validation.ValidationPipeline; +import net.openan.a2at.sdk.core.validation.ValidationResult; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; +import org.junit.jupiter.api.Test; + +class DefaultSemanticValidatorTest { + + @Test + void validatesEnUSPromptAndExtractsParams() { + PromptResourceAccess resources = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", null)); + + RecordingClient llmClient = new RecordingClient( + """ + { + "semantic_verdict": true, + "errors": [], + "params": {"site": "Site A"} + } + """); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(llmClient, "en-US", resources); + + TemplateReference reference = new TemplateReference() { + @Override + public String uri() { + return "Task-T/v1/energy-saving"; + } + + @Override + public String language() { + return "en-US"; + } + + @Override + public String extensionPrefix() { + return "Task-T"; + } + }; + + ValidationResult result = validator.validate("Check Site A power usage.", Map.of("type", "object"), reference); + + assertTrue(result.verdict()); + assertEquals(Map.of("site", "Site A"), result.params()); + + String expectedSystemPrompt = resources.loadPrompt("content_validation", "en-US", "system.md"); + assertEquals(expectedSystemPrompt, llmClient.lastSystemContent()); + } + + @Test + void loadsZhCnResourcesWithoutException() { + PromptResourceAccess resources = + PromptResourceAccess.create(new PromptRuntimeConfig("zh-CN", "classpath", null)); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(null, "zh-CN", resources); + + assertNotNull(validator); + } + + @Test + void throwsResourceNotFoundExceptionForMissingLanguage() { + PromptResourceAccess resources = + PromptResourceAccess.create(new PromptRuntimeConfig("fr-FR", "classpath", null)); + + ResourceNotFoundException exception = assertThrows( + ResourceNotFoundException.class, () -> new DefaultSemanticValidator(null, "fr-FR", resources)); + + assertTrue(exception.resourcePath().contains("content_validation")); + } + + @Test + void validateRetriesLlmInfrastructureErrorThroughPipeline() { + PromptResourceAccess resources = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", null)); + + FlakyClient flakyClient = new FlakyClient( + 1, + """ + { + "semantic_verdict": true, + "errors": [], + "params": {"site": "Site A"} + } + """); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US", resources); + + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2); + + TemplateReference reference = new TemplateReference() { + @Override + public String uri() { + return "Task-T/v1/energy-saving"; + } + + @Override + public String language() { + return "en-US"; + } + + @Override + public String extensionPrefix() { + return "Task-T"; + } + }; + + FilledParamData result = pipeline.validate("Check Site A power usage.", Map.of("type", "object"), reference); + + assertEquals(Map.of("site", "Site A"), result.data()); + assertEquals(2, flakyClient.invocations()); + } + + @Test + void validateExhaustsRetriesAndWrapsLlmError() { + PromptResourceAccess resources = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", null)); + + FlakyClient flakyClient = new FlakyClient( + Integer.MAX_VALUE, + """ + { + "semantic_verdict": true, + "errors": [], + "params": {} + } + """); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US", resources); + + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3); + + TemplateReference reference = new TemplateReference() { + @Override + public String uri() { + return "Task-T/v1/energy-saving"; + } + + @Override + public String language() { + return "en-US"; + } + + @Override + public String extensionPrefix() { + return "Task-T"; + } + }; + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> pipeline.validate("Check Site A power usage.", Map.of("type", "object"), reference)); + + assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.code()); + assertInstanceOf(LLMRuntimeError.class, exception.getCause().getCause()); + assertEquals(3, flakyClient.invocations()); + } + + private static final class RecordingClient implements LLMClient { + + private final String payload; + + private List> lastMessages; + + private RecordingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + this.lastMessages = messages; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + + String lastSystemContent() { + for (Map message : lastMessages) { + if ("system".equals(message.get("role"))) { + return message.get("content"); + } + } + return ""; + } + } + + private static final class FlakyClient implements LLMClient { + + private final int failuresToSimulate; + + private final String successPayload; + + private final AtomicInteger counter = new AtomicInteger(0); + + private FlakyClient(int failuresToSimulate, String successPayload) { + this.failuresToSimulate = failuresToSimulate; + this.successPayload = successPayload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + int invocation = counter.incrementAndGet(); + if (invocation <= failuresToSimulate) { + throw new LLMRuntimeError("network timeout"); + } + return new LLMResponse( + successPayload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + + int invocations() { + return counter.get(); + } + } +} From a270e2ab62801cd2004e3078973899852cfb11f9 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 22 Aug 2026 10:33:18 +0800 Subject: [PATCH 094/295] fix: reject prompt compliance when semantic errors are non-empty Signed-off-by: w00609601 --- .../LlmBackedPromptSemanticValidator.java | 8 +-- .../LlmBackedPromptSemanticValidatorTest.java | 70 +++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java index 8d125b6c..38925e7d 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java @@ -111,13 +111,13 @@ private static List> toStructuredMessages(List response = parseResponse(payload); - Object passedValue = response.get("passed"); - Object errorsValue = response.get("errors"); - if (Boolean.TRUE.equals(passedValue) && errorsValue instanceof List) { + boolean passed = Boolean.TRUE.equals(response.get("passed")); + boolean hasErrors = response.get("errors") instanceof List errors && !errors.isEmpty(); + if (passed && !hasErrors) { return; } - Optional message = extractFirstMessage(errorsValue); + Optional message = extractFirstMessage(response.get("errors")); throw new PromptComplianceCheckException( A2ATErrorCodes.SLOT_VALIDATION_ERROR, message.filter(text -> !text.isBlank()).orElse("Slot semantic validation failed."), diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java index 69c8d5ed..450437fc 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java @@ -80,6 +80,76 @@ void validateReturnsSlotValidationErrorWhenSemanticValidatorRejectsSlots() { assertEquals("slot_validation", error.getStage()); } + @Test + void validateRejectsWhenPassedTrueButErrorsNonEmpty() { + LLMClient llmClient = new RecordingClient( + "{\"passed\":true,\"errors\":[{\"slot_name\":\"notification_topic\"," + + "\"code\":\"semantic_mismatch\",\"message\":\"topic does not match\"}]}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + PromptComplianceCheckException error = assertThrows( + PromptComplianceCheckException.class, + () -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + + assertEquals("slot_validation_error", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertEquals("topic does not match", error.getMessage()); + } + + @Test + void validatePassesWhenPassedTrueAndErrorsMissing() { + LLMClient llmClient = new RecordingClient("{\"passed\":true}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + assertDoesNotThrow(() -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + } + + @Test + void validatePassesWhenPassedTrueAndErrorsNull() { + LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":null}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + assertDoesNotThrow(() -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + } + + @Test + void validateRejectsWhenPassedFalseWithEmptyErrors() { + LLMClient llmClient = new RecordingClient("{\"passed\":false,\"errors\":[]}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + assertThrows( + PromptComplianceCheckException.class, + () -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + } + private static final class RecordingClient implements LLMClient { private final String payload; From 0987a20f98832bebf9bf39139e3aa05acf1a8e68 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 22 Aug 2026 10:33:19 +0800 Subject: [PATCH 095/295] fix: make OpenAIClient lazy sdkClient init thread-safe with volatile DCL Signed-off-by: w00609601 --- .../a2at/sdk/llm/providers/OpenAIClient.java | 41 +++--- .../sdk/llm/providers/OpenAIClientTest.java | 121 ++++++++++++------ 2 files changed, 107 insertions(+), 55 deletions(-) diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java index e484338d..f9b21903 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java @@ -48,8 +48,7 @@ public OpenAIClient(LLMClientConfig config) { } OpenAIClient( - LLMClientConfig config, - BiFunction executor) { + LLMClientConfig config, BiFunction executor) { if (config.apiKey() == null || config.apiKey().isBlank()) { throw new LLMConfigError(config.provider() + " client requires a non-empty api_key"); } @@ -64,7 +63,8 @@ public LLMResponse structured( throw new LLMConfigError(config.provider() + " client requires a non-empty base_url"); } try { - return parseResponse(executor.apply(config, buildStructuredParams(messages, jsonSchema, temperature, maxTokens))); + return parseResponse( + executor.apply(config, buildStructuredParams(messages, jsonSchema, temperature, maxTokens))); } catch (LLMConfigError | LLMRuntimeError error) { throw error; } catch (Exception error) { @@ -125,7 +125,8 @@ private static String toJson(Map jsonSchema) { } private LLMResponse parseResponse(ChatCompletion response) { - return new LLMResponse(extractJsonObjectString(response), response.model(), mapUsage(response), mapMetadata(response)); + return new LLMResponse( + extractJsonObjectString(response), response.model(), mapUsage(response), mapMetadata(response)); } private String extractJsonObjectString(ChatCompletion response) { @@ -147,8 +148,12 @@ private String extractMessageText(ChatCompletion response) { if (response.choices().isEmpty()) { throw new LLMRuntimeError(config.provider() + " response did not include any choices"); } - return response.choices().get(0).message().content().orElseThrow( - () -> new LLMRuntimeError(config.provider() + " response did not include message content")); + return response.choices() + .get(0) + .message() + .content() + .orElseThrow( + () -> new LLMRuntimeError(config.provider() + " response did not include message content")); } private static Map mapUsage(ChatCompletion response) { @@ -159,8 +164,11 @@ private static Map mapUsage(ChatCompletion response) { usage.put("total_tokens", 0); return usage; } - usage.put("prompt_tokens", Math.toIntExact(response.usage().orElseThrow().promptTokens())); - usage.put("completion_tokens", Math.toIntExact(response.usage().orElseThrow().completionTokens())); + usage.put( + "prompt_tokens", Math.toIntExact(response.usage().orElseThrow().promptTokens())); + usage.put( + "completion_tokens", + Math.toIntExact(response.usage().orElseThrow().completionTokens())); usage.put("total_tokens", Math.toIntExact(response.usage().orElseThrow().totalTokens())); return usage; } @@ -177,16 +185,15 @@ private ChatCompletion executeWithSdk(LLMClientConfig runtimeConfig, ChatComplet } @SuppressWarnings("deprecation") - private com.openai.client.OpenAIClient sdkClient(LLMClientConfig runtimeConfig) { - if (sdkClient != null) { - return sdkClient; - } - OpenAIOkHttpClient.Builder builder = - OpenAIOkHttpClient.builder().apiKey(runtimeConfig.apiKey()).baseUrl(runtimeConfig.baseUrl()); - if (runtimeConfig.timeoutSeconds() != null && runtimeConfig.timeoutSeconds() > 0.0d) { - builder.timeout(Duration.ofMillis(Math.max(1L, Math.round(runtimeConfig.timeoutSeconds() * 1000.0d)))); + private synchronized com.openai.client.OpenAIClient sdkClient(LLMClientConfig runtimeConfig) { + if (sdkClient == null) { + OpenAIOkHttpClient.Builder builder = + OpenAIOkHttpClient.builder().apiKey(runtimeConfig.apiKey()).baseUrl(runtimeConfig.baseUrl()); + if (runtimeConfig.timeoutSeconds() != null && runtimeConfig.timeoutSeconds() > 0.0d) { + builder.timeout(Duration.ofMillis(Math.max(1L, Math.round(runtimeConfig.timeoutSeconds() * 1000.0d)))); + } + sdkClient = builder.build(); } - sdkClient = builder.build(); return sdkClient; } } diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java index 3f9e58cd..e8b18df8 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java @@ -11,10 +11,15 @@ import com.openai.models.chat.completions.ChatCompletionCreateParams; import com.openai.models.chat.completions.ChatCompletionMessage; import com.openai.models.completions.CompletionUsage; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.OptionalLong; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMConfigError; @@ -26,7 +31,8 @@ class OpenAIClientTest { @Test void rejectsBlankApiKeyOnConstruction() { - LLMConfigError error = assertThrows(LLMConfigError.class, () -> new OpenAIClient(config(" ", "https://api.example.test/v1"))); + LLMConfigError error = assertThrows( + LLMConfigError.class, () -> new OpenAIClient(config(" ", "https://api.example.test/v1"))); assertTrue(error.getMessage().contains("api_key")); } @@ -37,11 +43,10 @@ void allowsMissingBaseUrlUntilStructuredInvocation() { throw new AssertionError("executor should not run without baseUrl"); }); - LLMConfigError error = assertThrows(LLMConfigError.class, () -> client.structured( - List.of(Map.of("role", "user", "content", "extract")), - Map.of("type", "object"), - null, - null)); + LLMConfigError error = assertThrows( + LLMConfigError.class, + () -> client.structured( + List.of(Map.of("role", "user", "content", "extract")), Map.of("type", "object"), null, null)); assertTrue(error.getMessage().contains("base_url")); } @@ -49,9 +54,8 @@ void allowsMissingBaseUrlUntilStructuredInvocation() { @Test void structuredBuildsJsonModePayloadAndParsesJsonObjectResponse() { AtomicReference capturedParams = new AtomicReference<>(); - OpenAIClient client = new OpenAIClient( - config("sk-test", "https://api.example.test/v1"), - (runtimeConfig, requestParams) -> { + OpenAIClient client = + new OpenAIClient(config("sk-test", "https://api.example.test/v1"), (runtimeConfig, requestParams) -> { capturedParams.set(requestParams); return chatCompletion("{\"device_type\":\"router\"}"); }); @@ -77,30 +81,24 @@ void structuredBuildsJsonModePayloadAndParsesJsonObjectResponse() { assertEquals(0.25d, params.temperature().orElseThrow()); assertEquals(9L, maxTokens(params).orElseThrow()); assertTrue(params.responseFormat().orElseThrow().isJsonObject()); - assertEquals("json_object", params.responseFormat().orElseThrow().asJsonObject()._type().convert(String.class)); + assertEquals( + "json_object", + params.responseFormat().orElseThrow().asJsonObject()._type().convert(String.class)); assertEquals(3, params.messages().size()); assertTrue(params.messages().get(0).isSystem()); assertTrue(params.messages().get(0).asSystem().content().asText().contains("JSON")); assertTrue(params.messages().get(1).isSystem()); assertTrue(params.messages().get(1).asSystem().content().asText().contains("device_type")); assertTrue(params.messages().get(2).isUser()); - assertEquals("extract router", params.messages().get(2).asUser().content().asText()); + assertEquals( + "extract router", params.messages().get(2).asUser().content().asText()); } @Test void structuredFallsBackToConfigTemperatureAndMaxTokens() { AtomicReference capturedParams = new AtomicReference<>(); LLMClientConfig config = new LLMClientConfig( - "openai", - "gpt-4o-mini", - "sk-test", - "https://api.example.test/v1", - 10, - 128, - 0.4d, - null, - 300, - 100); + "openai", "gpt-4o-mini", "sk-test", "https://api.example.test/v1", 10, 128, 0.4d, null, 300, 100); OpenAIClient client = new OpenAIClient(config, (runtimeConfig, requestParams) -> { capturedParams.set(requestParams); return chatCompletion("{}"); @@ -115,10 +113,11 @@ void structuredFallsBackToConfigTemperatureAndMaxTokens() { @Test void structuredOmitsTemperatureAndMaxTokensWhenUnset() { AtomicReference capturedParams = new AtomicReference<>(); - OpenAIClient client = new OpenAIClient(config("sk-test", "https://api.example.test/v1"), (runtimeConfig, requestParams) -> { - capturedParams.set(requestParams); - return chatCompletion("{}"); - }); + OpenAIClient client = + new OpenAIClient(config("sk-test", "https://api.example.test/v1"), (runtimeConfig, requestParams) -> { + capturedParams.set(requestParams); + return chatCompletion("{}"); + }); client.structured(List.of(Map.of("role", "user", "content", "extract")), Map.of("type", "object"), null, null); @@ -128,17 +127,15 @@ void structuredOmitsTemperatureAndMaxTokensWhenUnset() { @Test void wrapsProviderFailuresAsRuntimeErrors() { - OpenAIClient client = new OpenAIClient( - config("sk-test", "https://api.example.test/v1"), - (runtimeConfig, requestParams) -> { + OpenAIClient client = + new OpenAIClient(config("sk-test", "https://api.example.test/v1"), (runtimeConfig, requestParams) -> { throw new IllegalStateException("provider unavailable"); }); - LLMRuntimeError error = assertThrows(LLMRuntimeError.class, () -> client.structured( - List.of(Map.of("role", "user", "content", "extract")), - Map.of("type", "object"), - null, - null)); + LLMRuntimeError error = assertThrows( + LLMRuntimeError.class, + () -> client.structured( + List.of(Map.of("role", "user", "content", "extract")), Map.of("type", "object"), null, null)); assertTrue(error.getMessage().contains("openai")); assertInstanceOf(IllegalStateException.class, error.getCause()); @@ -150,11 +147,59 @@ void rejectsNonJsonObjectResponses() { config("sk-test", "https://api.example.test/v1"), (runtimeConfig, requestParams) -> chatCompletion("[\"not-object\"]")); - assertThrows(LLMRuntimeError.class, () -> client.structured( - List.of(Map.of("role", "user", "content", "extract")), - Map.of("type", "object"), - null, - null)); + assertThrows( + LLMRuntimeError.class, + () -> client.structured( + List.of(Map.of("role", "user", "content", "extract")), Map.of("type", "object"), null, null)); + } + + @Test + void concurrentStructuredCallsAllSucceed() throws Exception { + AtomicInteger invocations = new AtomicInteger(); + AtomicReference firstFailure = new AtomicReference<>(); + OpenAIClient client = + new OpenAIClient(config("sk-test", "https://api.example.test/v1"), (runtimeConfig, requestParams) -> { + if (invocations.getAndIncrement() == 0) { + try { + Thread.sleep(50); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(error); + } + } + return chatCompletion("{}"); + }); + + int threadCount = 8; + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(threadCount); + List> futures = new ArrayList<>(); + try { + for (int i = 0; i < threadCount; i++) { + futures.add(pool.submit(() -> { + ready.countDown(); + try { + start.await(); + client.structured( + List.of(Map.of("role", "user", "content", "hi")), Map.of("type", "object"), null, null); + } catch (Throwable error) { + firstFailure.compareAndSet(null, error); + } + return null; + })); + } + ready.await(); + start.countDown(); + for (java.util.concurrent.Future future : futures) { + future.get(); + } + } finally { + pool.shutdownNow(); + } + + assertEquals(null, firstFailure.get()); + assertEquals(8, invocations.get()); } private static LLMClientConfig config(String apiKey, String baseUrl) { From 63aa66dbdc89e9a84afa0c6cd9c2a47e29b4a85b Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 22 Aug 2026 10:33:27 +0800 Subject: [PATCH 096/295] fix: require data schema in FromDataWithSchema and send full slot descriptions to LLM Signed-off-by: w00609601 --- .../openan/a2at/sdk/client/A2ATClient.java | 130 ++++++------ .../ClientPromptGenerationOrchestrator.java | 31 ++- ...ultClientPromptGenerationOrchestrator.java | 6 + ...tructuredClientSlotValueExtractorTest.java | 28 ++- ...tTemplateDrivenSlotValueExtractorTest.java | 45 +++-- ...lientPromptGenerationOrchestratorTest.java | 188 ++++++++++++------ ...ultStructuredPromptSlotValueExtractor.java | 21 +- .../resources/model/PromptSlotDefinition.java | 4 +- .../model/PromptSlotJsonProperty.java | 5 +- .../resources/model/PromptSlotJsonSchema.java | 13 +- ...tructuredPromptSlotValueExtractorTest.java | 105 +++++++--- .../loader/ClasspathPromptLoadersTest.java | 2 +- .../loader/LocalFilePromptLoadersTest.java | 14 +- .../DefaultSemanticValidatorTest.java | 64 +----- .../prompts/slot_extraction/en-US/system.md | 4 +- .../prompts/slot_extraction/zh-CN/system.md | 4 +- .../LlmBackedPromptSemanticValidator.java | 8 +- .../LlmBackedPromptMetadataExtractorTest.java | 22 +- .../LlmBackedPromptSemanticValidatorTest.java | 13 +- 19 files changed, 410 insertions(+), 297 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 2b5de3c2..5e671f43 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -13,17 +13,16 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; /** * High-level client facade for prompt generation and negotiation APIs. The caller provides the `.env` file path @@ -48,13 +47,14 @@ public final class A2ATClient { */ public A2ATClient(Path envPath) { Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); - A2ATConfig config = - NegotiationContentService.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + A2ATConfig config = NegotiationContentService.resolvePromptResourceLocalRootDir( + A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); - this.negotiationContentService = new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.negotiationContentService = + new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); this.templateQueryService = builder.buildTemplateQueryService(); } @@ -75,35 +75,35 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { * @param text natural-language task input * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code - * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, - * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the - * prompt fails + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, + * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails */ public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(text, "text"); Objects.requireNonNull(templateUri, "templateUri"); return promptGenerationOrchestrator.generateTaskPromptFromText(text, templateUri); } /** - * Generates a task prompt with metadata from structured input and an optional data schema using the template - * identified by the template URI, bypassing scenario recognition. + * Generates a task prompt with metadata from structured input and a data schema using the template identified by + * the template URI, bypassing scenario recognition. * * @param data structured task input as a string-to-object map - * @param schema optional data schema map for schema-guided extraction; null disables schema-guided extraction + * @param schema data schema map describing the meaning of each input field; must not be null or empty * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code - * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, - * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the - * prompt fails + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, + * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails */ public MetadataContent generateTaskPromptFromDataWithSchema( - @NonNull Map data, - @Nullable Map schema, - @NonNull TemplateUri templateUri) { + @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); Objects.requireNonNull(templateUri, "templateUri"); return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, templateUri); } @@ -119,39 +119,39 @@ public MetadataContent generateTaskPromptFromDataWithSchema( * @param text natural-language authorization input * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code - * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, - * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the - * prompt fails + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, + * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails */ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(text, "text"); Objects.requireNonNull(templateUri, "templateUri"); return promptGenerationOrchestrator.generateAuthPromptFromText(text, templateUri); } /** - * Generates an authorization prompt with metadata from structured input and an optional data schema using the - * template identified by the template URI, bypassing scenario recognition. + * Generates an authorization prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. * *

Experimental: Authorization-T template resources (such as {@code authz-policy-mgr}) are bundled and * discovered automatically, but Authorization-T slot schemas are not yet bundled, so template-driven slot * extraction fails until Authorization-T slot resources are added or provided through the local resource root. * * @param data structured authorization input as a string-to-object map - * @param schema optional data schema map for schema-guided extraction; null disables schema-guided extraction + * @param schema data schema map describing the meaning of each input field; must not be null or empty * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code - * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, - * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the - * prompt fails + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, + * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails */ public MetadataContent generateAuthPromptFromDataWithSchema( - @NonNull Map data, - @Nullable Map schema, - @NonNull TemplateUri templateUri) { + @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); Objects.requireNonNull(templateUri, "templateUri"); return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, templateUri); } @@ -163,35 +163,35 @@ public MetadataContent generateAuthPromptFromDataWithSchema( * @param text natural-language notification input * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code - * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, - * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the - * prompt fails + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, + * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails */ public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(text, "text"); Objects.requireNonNull(templateUri, "templateUri"); return promptGenerationOrchestrator.generateNotificationPromptFromText(text, templateUri); } /** - * Generates a notification prompt with metadata from structured input and an optional data schema using the - * template identified by the template URI, bypassing scenario recognition. + * Generates a notification prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. * * @param data structured notification input as a string-to-object map - * @param schema optional data schema map for schema-guided extraction; null disables schema-guided extraction + * @param schema data schema map describing the meaning of each input field; must not be null or empty * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code - * {@code template_not_found}, {@code prompt_resource_load_error}, {@code slot_schema_not_found}, - * {@code llm_invocation_failed}, {@code render_failed} or {@code slot_validation_error} when generating the - * prompt fails + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, + * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails */ public MetadataContent generateNotificationPromptFromDataWithSchema( - @NonNull Map data, - @Nullable Map schema, - @NonNull TemplateUri templateUri) { + @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); Objects.requireNonNull(templateUri, "templateUri"); return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, templateUri); } @@ -261,8 +261,8 @@ public MetadataContent generateNegotiationProposePromptFromData( * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its - * phase segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data or its context is null * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the @@ -284,8 +284,8 @@ public MetadataContent generateNegotiationAcceptPromptFromData( * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its - * phase segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data or its context is null * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the @@ -361,8 +361,8 @@ public MetadataContent generateNegotiationProposePromptFromText( * * @param text free-text input describing the message content * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its - * phase segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context is null * @throws IllegalArgumentException if the template URI phase contradicts the method @@ -390,8 +390,8 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * * @param text free-text input describing the message content * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its - * phase segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context is null * @throws IllegalArgumentException if the template URI phase contradicts the method @@ -442,8 +442,8 @@ public MetadataContent generateNegotiationAbortPromptFromText( * Lists every template available for the configured language across all A2A-T extensions. * *

This query never throws: the extension directories are discovered from the bundled resource tree itself, so - * templates of extensions added later are included automatically. Templates that exist nowhere for the language - * are skipped and an empty list is returned when no template can be loaded at all. + * templates of extensions added later are included automatically. Templates that exist nowhere for the language are + * skipped and an empty list is returned when no template can be loaded at all. * * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when * none can be loaded @@ -499,8 +499,8 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * *

The pipeline first runs the deterministic rule gate (recognition of the negotiation context section and its * strong constraints) before any LLM call, then performs one LLM semantic validation call that also extracts the - * parameters, and finally merges the parameters with the context parameters taking precedence. The semantic step - * is retried up to the configured attempt limit on the retryable LLM infrastructure failure code. + * parameters, and finally merges the parameters with the context parameters taking precedence. The semantic step is + * retried up to the configured attempt limit on the retryable LLM infrastructure failure code. * * @param prompt rendered negotiation message text to validate * @param schema caller-provided parameter JSON schema describing the parameters to extract diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java index 3e1300ad..f3b15926 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java @@ -1,8 +1,8 @@ package net.openan.a2at.sdk.client.prompt.orchestration; import java.util.Map; -import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.TemplateUri; /** @@ -27,17 +27,20 @@ public interface ClientPromptGenerationOrchestrator { * @param text natural-language task input * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null */ MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri); /** - * Generates a task prompt with metadata from structured input and an optional data schema using the template - * identified by the template URI, bypassing scenario recognition. + * Generates a task prompt with metadata from structured input and a data schema using the template identified by + * the template URI, bypassing scenario recognition. * * @param data structured task input as a string-to-object map - * @param schema optional data schema map for schema-guided extraction + * @param schema data schema map describing the meaning of each input field; must not be null or empty * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty */ MetadataContent generateTaskPromptFromDataWithSchema( Map data, Map schema, TemplateUri templateUri); @@ -49,17 +52,20 @@ MetadataContent generateTaskPromptFromDataWithSchema( * @param text natural-language authorization input * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null */ MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri); /** - * Generates an authorization prompt with metadata from structured input and an optional data schema using the - * template identified by the template URI, bypassing scenario recognition. + * Generates an authorization prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. * * @param data structured authorization input as a string-to-object map - * @param schema optional data schema map for schema-guided extraction + * @param schema data schema map describing the meaning of each input field; must not be null or empty * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty */ MetadataContent generateAuthPromptFromDataWithSchema( Map data, Map schema, TemplateUri templateUri); @@ -71,18 +77,21 @@ MetadataContent generateAuthPromptFromDataWithSchema( * @param text natural-language notification input * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null */ MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri); /** - * Generates a notification prompt with metadata from structured input and an optional data schema using the - * template identified by the template URI, bypassing scenario recognition. + * Generates a notification prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. * * @param data structured notification input as a string-to-object map - * @param schema optional data schema map for schema-guided extraction + * @param schema data schema map describing the meaning of each input field; must not be null or empty * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty */ MetadataContent generateNotificationPromptFromDataWithSchema( Map data, Map schema, TemplateUri templateUri); -} \ No newline at end of file +} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java index e05d385d..802eeaf9 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -164,6 +164,7 @@ public MetadataContent generateNotificationPromptFromDataWithSchema( private MetadataContent generateFromTemplateUriWithMetadata( String userInput, TemplateUri templateUri, String extensionUri) { + Objects.requireNonNull(userInput, "userInput"); Objects.requireNonNull(templateUri, "templateUri"); String templateIdentifier = templateUri.uri(); final String templateText; @@ -199,6 +200,11 @@ private MetadataContent generateFromDataWithSchema( String extensionUri) { Objects.requireNonNull(templateUri, "templateUri"); String templateIdentifier = templateUri.uri(); + Objects.requireNonNull(schema, "schema"); + if (schema.isEmpty()) { + throw new IllegalArgumentException( + "Data schema must not be empty; it describes the meaning of each input field."); + } final String templateText; try { templateText = templateLoader.loadTemplate(templateIdentifier, language); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java index 17ee7e0f..ee15ff81 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java @@ -24,10 +24,12 @@ void extractSlotsBuildsStructuredPromptAndReturnsNormalizedSlots() { (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, List.of( - new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null), new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null), - new PromptSlotDefinition("limit", false, "integer", null, 1.0d, 10.0d, null, null), + "site", true, "string", "^Site .+", null, null, null, null, null), + new PromptSlotDefinition( + "additional_notes", false, "string", null, null, null, null, null, null), + new PromptSlotDefinition( + "limit", false, "integer", null, 1.0d, 10.0d, null, null, null), new PromptSlotDefinition( "severity", false, @@ -36,6 +38,7 @@ void extractSlotsBuildsStructuredPromptAndReturnsNormalizedSlots() { null, null, List.of("low", "medium", "high"), + null, null))), "Extract slots from the input.", "Return slots as JSON."); @@ -70,10 +73,12 @@ void extractSlotsWithSchemaCallsDelegateFourArgMethod() { (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, List.of( - new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null), new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null), - new PromptSlotDefinition("limit", false, "integer", null, 1.0d, 10.0d, null, null), + "site", true, "string", "^Site .+", null, null, null, null, null), + new PromptSlotDefinition( + "additional_notes", false, "string", null, null, null, null, null, null), + new PromptSlotDefinition( + "limit", false, "integer", null, 1.0d, 10.0d, null, null, null), new PromptSlotDefinition( "severity", false, @@ -82,6 +87,7 @@ void extractSlotsWithSchemaCallsDelegateFourArgMethod() { null, null, List.of("low", "medium", "high"), + null, null))), "Extract slots from the input.", "Return slots as JSON."); @@ -107,18 +113,18 @@ void extractSlotsWithSchemaCallsDelegateFourArgMethod() { @Test void extractSlotsWithSchemaWithNullSchemaStillWorks() { - RecordingClient llmClient = new RecordingClient( - "{\"slots\":{\"site\":\"Site A\"},\"slot_errors\":[]}"); + RecordingClient llmClient = new RecordingClient("{\"slots\":{\"site\":\"Site A\"},\"slot_errors\":[]}"); DefaultStructuredClientSlotValueExtractor extractor = new DefaultStructuredClientSlotValueExtractor( llmClient, (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null))), + List.of(new PromptSlotDefinition( + "site", true, "string", "^Site .+", null, null, null, null, null))), "Extract slots.", "Return slots."); - Map slots = extractor.extractSlotsWithSchema( - "Analyze Site A.", "scenario", "en", "Site: {site}", null); + Map slots = + extractor.extractSlotsWithSchema("Analyze Site A.", "scenario", "en", "Site: {site}", null); assertEquals(Map.of("site", "Site A"), slots); assertFalse(llmClient.lastMessages().get(1).get("content").contains("[data_schema]")); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java index cf929efa..8291900a 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java @@ -15,9 +15,10 @@ void extractSlotsUsesOnlyTemplateReferencedKeysFromMapInput() { DefaultTemplateDrivenSlotValueExtractor extractor = new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( scenarioCode, - new PromptSlotDefinition("site", true, "string", null, null, null, null, null), - new PromptSlotDefinition("additional_notes", false, "string", null, null, null, null, null), - new PromptSlotDefinition("ignored", false, "string", null, null, null, null, null))); + new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null), + new PromptSlotDefinition( + "additional_notes", false, "string", null, null, null, null, null, null), + new PromptSlotDefinition("ignored", false, "string", null, null, null, null, null, null))); Map slots = extractor.extractSlots( Map.of("site", "Site A", "additional_notes", "critical", "ignored", "value"), @@ -32,7 +33,8 @@ void extractSlotsUsesOnlyTemplateReferencedKeysFromMapInput() { void extractSlotsMapsStringInputToSchemaDefinedInputSlot() { DefaultTemplateDrivenSlotValueExtractor extractor = new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( - scenarioCode, new PromptSlotDefinition("input", true, "string", null, null, null, null, null))); + scenarioCode, + new PromptSlotDefinition("input", true, "string", null, null, null, null, null, null))); Map slots = extractor.extractSlots("Analyze Site A.", "free-text", "en-US", "Input: {input}"); @@ -44,9 +46,10 @@ void extractSlotsUsesDoubleBracedTemplateReferencedKeysFromMapInput() { DefaultTemplateDrivenSlotValueExtractor extractor = new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( scenarioCode, - new PromptSlotDefinition("topic", false, "string", null, null, null, null, null), - new PromptSlotDefinition("condition", false, "string", null, null, null, null, null), - new PromptSlotDefinition("report_format", false, "string", null, null, null, null, null))); + new PromptSlotDefinition("topic", false, "string", null, null, null, null, null, null), + new PromptSlotDefinition("condition", false, "string", null, null, null, null, null, null), + new PromptSlotDefinition( + "report_format", false, "string", null, null, null, null, null, null))); Map slots = extractor.extractSlots( Map.of( @@ -70,8 +73,9 @@ void extractSlotsBlanksValueWhenSchemaConstraintIsNotSatisfied() { DefaultTemplateDrivenSlotValueExtractor extractor = new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( scenarioCode, - new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null), - new PromptSlotDefinition("additional_notes", false, "string", null, null, null, null, null))); + new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null, null), + new PromptSlotDefinition( + "additional_notes", false, "string", null, null, null, null, null, null))); Map slots = extractor.extractSlots( Map.of("site", "invalid", "additional_notes", "critical"), @@ -87,9 +91,10 @@ void extractSlotsNormalizesAndFiltersEnumAndNumericConstraints() { DefaultTemplateDrivenSlotValueExtractor extractor = new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( scenarioCode, - new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null), - new PromptSlotDefinition("additional_notes", false, "string", null, null, null, null, null), - new PromptSlotDefinition("limit", false, "integer", null, 1.0d, 10.0d, null, null), + new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null, null), + new PromptSlotDefinition( + "additional_notes", false, "string", null, null, null, null, null, null), + new PromptSlotDefinition("limit", false, "integer", null, 1.0d, 10.0d, null, null, null), new PromptSlotDefinition( "severity", false, @@ -98,6 +103,7 @@ void extractSlotsNormalizesAndFiltersEnumAndNumericConstraints() { null, null, List.of("low", "medium", "high"), + null, null))); Map slots = extractor.extractSlots( @@ -124,9 +130,10 @@ void extractSlotsBlanksValuesWhenEnumOrRangeConstraintsFail() { DefaultTemplateDrivenSlotValueExtractor extractor = new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( scenarioCode, - new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null), - new PromptSlotDefinition("additional_notes", false, "string", null, null, null, null, null), - new PromptSlotDefinition("limit", false, "integer", null, 1.0d, 10.0d, null, null), + new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null, null), + new PromptSlotDefinition( + "additional_notes", false, "string", null, null, null, null, null, null), + new PromptSlotDefinition("limit", false, "integer", null, 1.0d, 10.0d, null, null, null), new PromptSlotDefinition( "severity", false, @@ -135,6 +142,7 @@ void extractSlotsBlanksValuesWhenEnumOrRangeConstraintsFail() { null, null, List.of("low", "medium", "high"), + null, null))); Map slots = extractor.extractSlots( @@ -165,9 +173,10 @@ void extractSlotsWithSchemaIgnoresSchemaAndBehavesIdenticallyToExtractSlots() { DefaultTemplateDrivenSlotValueExtractor extractor = new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( scenarioCode, - new PromptSlotDefinition("site", true, "string", null, null, null, null, null), - new PromptSlotDefinition("additional_notes", false, "string", null, null, null, null, null), - new PromptSlotDefinition("ignored", false, "string", null, null, null, null, null))); + new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null), + new PromptSlotDefinition( + "additional_notes", false, "string", null, null, null, null, null, null), + new PromptSlotDefinition("ignored", false, "string", null, null, null, null, null, null))); Map slots = extractor.extractSlotsWithSchema( Map.of("site", "Site A", "additional_notes", "critical", "ignored", "value"), diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index f4d043f1..53df4f07 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -8,8 +8,6 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; @@ -19,6 +17,8 @@ import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMRuntimeError; @@ -34,8 +34,7 @@ class DefaultClientPromptGenerationOrchestratorTest { private static final ClientSlotSchemaLoader EMPTY_SCHEMA_LOADER = (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()); - private static final TemplateUri AUTH_DATABASE_READ = - TemplateUri.of("Authorization-T", "database_read"); + private static final TemplateUri AUTH_DATABASE_READ = TemplateUri.of("Authorization-T", "database_read"); @Test void generateTaskPromptLoadsTemplateAndRendersExtractedSlotsWhenScenarioIsMatched() { @@ -168,7 +167,7 @@ void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissi assertEquals("generation", result.failure().stage()); } -private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestrator( + private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestrator( ClientScenarioRecognizer recognizer, ClientTemplateLoader templateLoader, ClientSlotValueExtractor slotValueExtractor) { @@ -269,7 +268,10 @@ public Map extractSlots( @Override public Map extractSlotsWithSchema( - Object userInput, String scenarioCode, String language, String templateText, + Object userInput, + String scenarioCode, + String language, + String templateText, Map dataSchema) { this.lastUserInput = userInput; this.lastScenarioCode = scenarioCode; @@ -295,7 +297,10 @@ public Map extractSlots( @Override public Map extractSlotsWithSchema( - Object userInput, String scenarioCode, String language, String templateText, + Object userInput, + String scenarioCode, + String language, + String templateText, Map dataSchema) { throw exception; } @@ -310,7 +315,8 @@ void generateFromTemplateUriWithMetadataReturnsMetadataContentOnSuccess() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent result = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + MetadataContent result = + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); assertEquals(StandardTemplates.ENERGY_SAVING.uri(), result.templateUri()); assertEquals("Site: Site A", result.promptText()); @@ -321,8 +327,8 @@ void generateFromTemplateUriWithMetadataReturnsMetadataContentOnSuccess() { void generateFromTemplateUriWithMetadataScenarioRecognizerIsNotInvoked() { RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + recognizer, templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); @@ -343,6 +349,18 @@ void generateFromTemplateUriWithMetadataThrowsOnNullTemplateUri() { assertEquals(0, templateLoader.loadCount); } + @Test + void generateTaskPromptFromTextRejectsNullText() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); + + assertThrows( + NullPointerException.class, + () -> orchestrator.generateTaskPromptFromText(null, StandardTemplates.ENERGY_SAVING)); + } + @Test void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( @@ -352,7 +370,8 @@ void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { }, new FakeSlotValueExtractor(Map.of("site", "Site A"))); -PromptGenerationException ex = assertThrows(PromptGenerationException.class, + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("template_not_found", ex.getCode()); } @@ -364,7 +383,8 @@ void generateFromTemplateUriWithMetadataPropagatesRenderException() { new FakeTemplateLoader("Site: {site}\nMissing: {missing_slot}"), new FakeSlotValueExtractor(Map.of("site", "Site A"))); -PromptGenerationException ex = assertThrows(PromptGenerationException.class, + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("render_failed", ex.getCode()); } @@ -378,7 +398,8 @@ void generateFromTemplateUriWithMetadataPropagatesLlmRuntimeError() { throw new LLMRuntimeError("LLM invocation failed."); }); -PromptGenerationException ex = assertThrows(PromptGenerationException.class, + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("llm_invocation_failed", ex.getCode()); } @@ -410,7 +431,8 @@ void generateFromDataWithSchemaPassesSchemaToExtractor() { newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); Map schema = Map.of("site", "string", "count", "number"); - orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), schema, StandardTemplates.ENERGY_SAVING); + orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), schema, StandardTemplates.ENERGY_SAVING); assertEquals(schema, slotValueExtractor.lastSchema); assertEquals(StandardTemplates.ENERGY_SAVING.uri(), slotValueExtractor.lastScenarioCode); @@ -418,33 +440,33 @@ void generateFromDataWithSchemaPassesSchemaToExtractor() { } @Test - void generateFromDataWithSchemaNullSchemaHandledGracefully() { + void generateTaskPromptFromDataWithSchemaRejectsNullSchema() { FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); FakeSlotValueExtractorWithSchema slotValueExtractor = new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), null, StandardTemplates.ENERGY_SAVING); - - assertEquals("Site: Site A", result.promptText()); - assertEquals(null, slotValueExtractor.lastSchema); + NullPointerException ex = assertThrows( + NullPointerException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), null, StandardTemplates.ENERGY_SAVING)); + assertEquals("schema", ex.getMessage()); } @Test - void generateFromDataWithSchemaEmptySchemaHandledSameAsNull() { + void generateTaskPromptFromDataWithSchemaRejectsEmptySchema() { FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); FakeSlotValueExtractorWithSchema slotValueExtractor = new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING); - - assertEquals("Site: Site A", result.promptText()); - assertEquals(Map.of(), slotValueExtractor.lastSchema); + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING)); + assertTrue(ex.getMessage().contains("Data schema must not be empty")); } @Test @@ -456,8 +478,10 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundException() { }, new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); -PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING)); + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); assertEquals("template_not_found", ex.getCode()); } @@ -468,8 +492,10 @@ void generateFromDataWithSchemaPropagatesRenderException() { new FakeTemplateLoader("Site: {site}\nMissing: {missing_slot}"), new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); -PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING)); + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); assertEquals("render_failed", ex.getCode()); } @@ -480,8 +506,10 @@ void generateFromDataWithSchemaPropagatesLlmRuntimeError() { new FakeTemplateLoader("Site: {site}"), new FailingExtractSlotsWithSchema(new LLMRuntimeError("LLM invocation failed."))); -PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING)); + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); assertEquals("llm_invocation_failed", ex.getCode()); } @@ -493,8 +521,10 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundExceptionFromExtractor( new FailingExtractSlotsWithSchema( new ResourceNotFoundException("Slot schema file does not exist.", "energy-saving"))); -PromptGenerationException ex = assertThrows(PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema(Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING)); + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); assertEquals("slot_schema_not_found", ex.getCode()); } @@ -508,15 +538,17 @@ void eachMetadataEntryPointReturnsCorrectMetadataContent() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + MetadataContent taskText = + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING); + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", AUTH_DATABASE_READ); MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), AUTH_DATABASE_READ); - MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); + Map.of("site", "Site A"), Map.of("site", "string"), AUTH_DATABASE_READ); + MetadataContent notifText = + orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING); + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); assertTrue(taskText.promptText().contains("Site A")); assertTrue(taskData.promptText().contains("Site A")); @@ -530,17 +562,21 @@ void eachMetadataEntryPointReturnsCorrectMetadataContent() { void eachMetadataEntryPointUsesCorrectExtensionUri() { FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); + new RecordingScenarioRecognizer(), + templateLoader, + new FakeSlotValueExtractor(Map.of("site", "Site A"))); - MetadataContent taskText = orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + MetadataContent taskText = + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING); + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", AUTH_DATABASE_READ); MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), AUTH_DATABASE_READ); - MetadataContent notifText = orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); + Map.of("site", "Site A"), Map.of("site", "string"), AUTH_DATABASE_READ); + MetadataContent notifText = + orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING); + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskText.extensionUri()); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskData.extensionUri()); @@ -579,17 +615,22 @@ void authorizationMetadataEntryPointsPassTemplateUriIdentifier() { @Test void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { -ClientSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, "Site name"))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + ClientSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( new RecordingScenarioRecognizer(), List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", "", "", new FakeTemplateLoader("Site: {site}"), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), new FakeSlotValueExtractor(Map.of()), new TaskPromptRenderer(), schemaLoader); - PromptGenerationException ex = assertThrows(PromptGenerationException.class, + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("slot_validation_error", ex.getCode()); List failed = ex.failedParameters(); @@ -600,19 +641,24 @@ void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { @Test void validateRequiredSlotsThrowsSlotValidationErrorWhenMultipleRequiredSlotsMissing() { - ClientSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, + ClientSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, List.of( - new PromptSlotDefinition("site", true, "string", null, null, null, null, "Site name"), - new PromptSlotDefinition("target", true, "string", null, null, null, null, "Target"))); + new PromptSlotDefinition("site", true, "string", null, null, null, null, "Site name", null), + new PromptSlotDefinition("target", true, "string", null, null, null, null, "Target", null))); DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( new RecordingScenarioRecognizer(), List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", "", "", new FakeTemplateLoader("Site: {site}\nTarget: {target}"), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}\nTarget: {target}"), new FakeSlotValueExtractor(Map.of("site", "Site A")), new TaskPromptRenderer(), schemaLoader); - PromptGenerationException ex = assertThrows(PromptGenerationException.class, + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("slot_validation_error", ex.getCode()); assertEquals(1, ex.failedParameters().size()); @@ -624,14 +670,18 @@ void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundWhenSchemaLoadin DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( new RecordingScenarioRecognizer(), List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", "", "", new FakeTemplateLoader("Site: {site}"), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), new FakeSlotValueExtractor(Map.of("site", "Site A")), new TaskPromptRenderer(), (scenarioCode, language) -> { throw new A2ATError("Schema file is corrupt."); }); - PromptGenerationException ex = assertThrows(PromptGenerationException.class, + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("slot_schema_not_found", ex.getCode()); } @@ -641,14 +691,18 @@ void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundFromExtractor() DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( new RecordingScenarioRecognizer(), List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", "", "", new FakeTemplateLoader("Site: {site}"), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), (userInput, scenarioCode, language, templateText) -> { throw new ResourceNotFoundException("Slot schema file does not exist.", scenarioCode); }, new TaskPromptRenderer(), EMPTY_SCHEMA_LOADER); - PromptGenerationException ex = assertThrows(PromptGenerationException.class, + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("slot_schema_not_found", ex.getCode()); } @@ -658,14 +712,18 @@ void generateFromTemplateUriWithMetadataThrowsPromptResourceLoadError() { DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( new RecordingScenarioRecognizer(), List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", "", "", (scenarioCode, language) -> { + "en-US", + "", + "", + (scenarioCode, language) -> { throw new A2ATError("Template file is corrupt."); }, new FakeSlotValueExtractor(Map.of("site", "Site A")), new TaskPromptRenderer(), EMPTY_SCHEMA_LOADER); - PromptGenerationException ex = assertThrows(PromptGenerationException.class, + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("prompt_resource_load_error", ex.getCode()); } @@ -675,14 +733,18 @@ void generateFromTemplateUriWithMetadataThrowsLlmInvocationFailedFromA2ATError() DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( new RecordingScenarioRecognizer(), List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", "", "", new FakeTemplateLoader("Site: {site}"), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), (userInput, scenarioCode, language, templateText) -> { throw new A2ATError("Unparseable LLM response."); }, new TaskPromptRenderer(), EMPTY_SCHEMA_LOADER); - PromptGenerationException ex = assertThrows(PromptGenerationException.class, + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("llm_invocation_failed", ex.getCode()); } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java index 574f6d87..55dce6e8 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java @@ -1,13 +1,14 @@ package net.openan.a2at.sdk.prompt.analysis.impl; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.model.PromptMessage; import net.openan.a2at.sdk.llm.LLMClient; @@ -26,6 +27,10 @@ public final class DefaultStructuredPromptSlotValueExtractor implements PromptSl private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectWriter SLOT_SERIALIZER = new ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .writerWithDefaultPrettyPrinter(); + private final LLMClient llmClient; private final PromptSlotSchemaLoader slotSchemaLoader; @@ -67,11 +72,17 @@ private StructuredSlotExtractionResult doExtractSlots( } private List buildMessages( - Object userInput, String scenarioCode, String language, PromptSlotSchema slotSchema, + Object userInput, + String scenarioCode, + String language, + PromptSlotSchema slotSchema, Map dataSchema) { - String slotLines = slotSchema.slotDefinitions().stream() - .map(slot -> "- name: " + slot.name() + "\n required: " + slot.required()) - .collect(Collectors.joining("\n")); + String slotLines; + try { + slotLines = SLOT_SERIALIZER.writeValueAsString(slotSchema.slotDefinitions()); + } catch (JsonProcessingException error) { + throw new A2ATError("Failed to serialize slot definitions.", error); + } String content = userPrompt + "\n\n[scenario_code]\n" + scenarioCode diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/model/PromptSlotDefinition.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/model/PromptSlotDefinition.java index 53e6d395..281b0cbb 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/model/PromptSlotDefinition.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/model/PromptSlotDefinition.java @@ -14,6 +14,7 @@ * @param maximum optional numeric maximum * @param allowedValues optional enum constraint * @param description slot description + * @param valueConstraint human-readable value constraint description * @since 2026-06 */ public record PromptSlotDefinition( @@ -24,4 +25,5 @@ public record PromptSlotDefinition( @JsonProperty("minimum") Double minimum, @JsonProperty("maximum") Double maximum, @JsonProperty("enum") List allowedValues, - @JsonProperty("description") String description) {} + @JsonProperty("description") String description, + @JsonProperty("x-a2at-value-constraint") String valueConstraint) {} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/model/PromptSlotJsonProperty.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/model/PromptSlotJsonProperty.java index 072f8a6b..ac9a1b0f 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/model/PromptSlotJsonProperty.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/model/PromptSlotJsonProperty.java @@ -1,6 +1,5 @@ package net.openan.a2at.sdk.prompt.resources.model; -import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; @@ -14,6 +13,7 @@ * @param maximum optional numeric maximum * @param allowedValues optional enum values * @param description slot description + * @param valueConstraint human-readable value constraint description * @since 2026-06 */ @JsonIgnoreProperties(ignoreUnknown = true) @@ -23,4 +23,5 @@ public record PromptSlotJsonProperty( @JsonProperty("minimum") Double minimum, @JsonProperty("maximum") Double maximum, @JsonProperty("enum") List allowedValues, - @JsonAlias({"description", "x-a2at-value-constraint"}) @JsonProperty("description") String description) {} + @JsonProperty("description") String description, + @JsonProperty("x-a2at-value-constraint") String valueConstraint) {} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/model/PromptSlotJsonSchema.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/model/PromptSlotJsonSchema.java index 30fe34be..b1f9a402 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/model/PromptSlotJsonSchema.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/model/PromptSlotJsonSchema.java @@ -34,7 +34,15 @@ public PromptSlotSchema toPromptSlotSchema(String scenarioCode) { PromptSlotJsonProperty property = entry.getValue(); if (property == null) { slotDefinitions.add(new PromptSlotDefinition( - entry.getKey(), requiredSlots.contains(entry.getKey()), null, null, null, null, null, null)); + entry.getKey(), + requiredSlots.contains(entry.getKey()), + null, + null, + null, + null, + null, + null, + null)); continue; } slotDefinitions.add(new PromptSlotDefinition( @@ -45,7 +53,8 @@ public PromptSlotSchema toPromptSlotSchema(String scenarioCode) { property.minimum(), property.maximum(), property.allowedValues(), - property.description())); + property.description(), + property.valueConstraint())); } return new PromptSlotSchema(scenarioCode, List.copyOf(slotDefinitions)); } diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java index 3765ab21..86762256 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java @@ -17,9 +17,8 @@ class DefaultStructuredPromptSlotValueExtractorTest { @Test void extractSlotsParsesFormattedStructuredJsonPayload() { - RecordingClient llmClient = - new RecordingClient( - """ + RecordingClient llmClient = new RecordingClient( + """ { "slots": { "site": "Site A", @@ -35,10 +34,11 @@ void extractSlotsParsesFormattedStructuredJsonPayload() { (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, List.of( - new PromptSlotDefinition("site", true, "string", null, null, null, null, null), + new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null), new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null), - new PromptSlotDefinition("limit", false, "integer", null, 1.0d, 10.0d, null, null), + "additional_notes", false, "string", null, null, null, null, null, null), + new PromptSlotDefinition( + "limit", false, "integer", null, 1.0d, 10.0d, null, null, null), new PromptSlotDefinition( "severity", false, @@ -47,6 +47,7 @@ void extractSlotsParsesFormattedStructuredJsonPayload() { null, null, List.of("low", "medium", "high"), + null, null))), "Extract slots from the input.", "Return slots as JSON."); @@ -66,9 +67,8 @@ void extractSlotsParsesFormattedStructuredJsonPayload() { @Test void extractSlotsPreservesSlotTextContainingClosingBrace() { - RecordingClient llmClient = - new RecordingClient( - """ + RecordingClient llmClient = new RecordingClient( + """ { "slots": { "site": "Site A", @@ -82,9 +82,9 @@ void extractSlotsPreservesSlotTextContainingClosingBrace() { (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, List.of( - new PromptSlotDefinition("site", true, "string", null, null, null, null, null), + new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null), new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null))), + "additional_notes", false, "string", null, null, null, null, null, null))), "Extract slots from the input.", "Return slots as JSON."); @@ -97,9 +97,8 @@ void extractSlotsPreservesSlotTextContainingClosingBrace() { @Test void should_injectDataSchemaSection_When_schemaIsProvided() { - RecordingClient llmClient = - new RecordingClient( - """ + RecordingClient llmClient = new RecordingClient( + """ { "slots": { "site": "Site A" @@ -111,7 +110,7 @@ void should_injectDataSchemaSection_When_schemaIsProvided() { llmClient, (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null))), + List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null))), "Extract slots from the input.", "Return slots as JSON."); @@ -129,9 +128,8 @@ void should_injectDataSchemaSection_When_schemaIsProvided() { @Test void should_notIncludeDataSchemaSection_When_schemaIsNull() { - RecordingClient llmClient = - new RecordingClient( - """ + RecordingClient llmClient = new RecordingClient( + """ { "slots": { "site": "Site A" @@ -143,7 +141,7 @@ void should_notIncludeDataSchemaSection_When_schemaIsNull() { llmClient, (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null))), + List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null))), "Extract slots from the input.", "Return slots as JSON."); @@ -155,9 +153,8 @@ void should_notIncludeDataSchemaSection_When_schemaIsNull() { @Test void should_notIncludeDataSchemaSection_When_schemaIsEmpty() { - RecordingClient llmClient = - new RecordingClient( - """ + RecordingClient llmClient = new RecordingClient( + """ { "slots": { "site": "Site A" @@ -169,7 +166,7 @@ void should_notIncludeDataSchemaSection_When_schemaIsEmpty() { llmClient, (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null))), + List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null))), "Extract slots from the input.", "Return slots as JSON."); @@ -179,11 +176,56 @@ void should_notIncludeDataSchemaSection_When_schemaIsEmpty() { assertFalse(userMessage.contains("[data_schema]")); } + @Test + void buildMessagesIncludesFullSlotDescriptions() { + RecordingClient llmClient = new RecordingClient( + """ + { + "slots": { + "site": "Site A" + }, + "slot_errors": [] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", + true, + "string", + null, + null, + null, + List.of("Site A", "Site B"), + "The target site name", + "The site must be a physical location"))), + "Extract slots from the input.", + "Return slots as JSON."); + + extractor.extractSlots("Analyze Site A.", "energy-saving", "en-US"); + + String userMessage = llmClient.lastUserContent(); + assertTrue(userMessage.contains("[slots]")); + assertTrue(userMessage.contains("\"name\"")); + assertTrue(userMessage.contains("\"site\"")); + assertTrue(userMessage.contains("\"required\"")); + assertTrue(userMessage.contains("\"type\"")); + assertTrue(userMessage.contains("\"string\"")); + assertTrue(userMessage.contains("\"description\"")); + assertTrue(userMessage.contains("\"The target site name\"")); + assertTrue(userMessage.contains("\"enum\"")); + assertTrue(userMessage.contains("\"Site A\"")); + assertTrue(userMessage.contains("\"x-a2at-value-constraint\"")); + assertTrue(userMessage.contains("\"The site must be a physical location\"")); + assertTrue(userMessage.contains("[")); + } + @Test void defaultExtractSlotsWithSchemaDelegatesToThreeArg() { - RecordingClient llmClient = - new RecordingClient( - """ + RecordingClient llmClient = new RecordingClient( + """ { "slots": { "site": "Site A" @@ -195,15 +237,14 @@ void defaultExtractSlotsWithSchemaDelegatesToThreeArg() { llmClient, (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null))), + List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null))), "Extract slots from the input.", "Return slots as JSON."); - PromptSlotValueExtractor lambdaExtractor = (input, code, lang) -> - extractor.extractSlots(input, code, lang); + PromptSlotValueExtractor lambdaExtractor = (input, code, lang) -> extractor.extractSlots(input, code, lang); - StructuredSlotExtractionResult result = lambdaExtractor.extractSlots( - "Analyze Site A.", "energy-saving", "en-US", Map.of("site", "desc")); + StructuredSlotExtractionResult result = + lambdaExtractor.extractSlots("Analyze Site A.", "energy-saving", "en-US", Map.of("site", "desc")); String userMessage = llmClient.lastUserContent(); assertFalse(userMessage.contains("[data_schema]")); @@ -239,4 +280,4 @@ String lastUserContent() { return ""; } } -} \ No newline at end of file +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java index be1caa4f..0e366035 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java @@ -73,7 +73,7 @@ void loadSlotSchemaMapsJacksonAnnotatedRecords() { schema.slotDefinitions().get(1).allowedValues()); assertEquals( "Business priority from 1 (highest urgency) to 5 (lowest urgency)", - schema.slotDefinitions().get(1).description()); + schema.slotDefinitions().get(1).valueConstraint()); } @Test diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java index 72dbb248..ae54c983 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java @@ -8,8 +8,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import org.junit.jupiter.api.Test; @@ -130,7 +130,7 @@ void loadSlotSchemaMapsJacksonAnnotatedRecords() throws IOException { assertEquals( List.of("1", "2", "3", "4", "5"), schema.slotDefinitions().get(1).allowedValues()); - assertEquals("Severity from 1 to 5", schema.slotDefinitions().get(1).description()); + assertEquals("Severity from 1 to 5", schema.slotDefinitions().get(1).valueConstraint()); assertEquals("note", schema.slotDefinitions().get(2).name()); } @@ -141,8 +141,9 @@ void missingTemplateIncludesResolvedLocalPathInResourceNotFoundException() { .loadTemplate("incident_triage", "en")); String expected = (promptRootDir.resolve("templates").toString() - + "/*/network-layer/incident_triage/v1/en/template.md" - + " (or the layout without the network-layer segment)").replace('\\', '/'); + + "/*/network-layer/incident_triage/v1/en/template.md" + + " (or the layout without the network-layer segment)") + .replace('\\', '/'); assertEquals(expected, exception.resourcePath().replace('\\', '/')); } @@ -159,9 +160,8 @@ void malformedLocalSlotSchemaIsWrappedAsA2ATError() throws IOException { .resolve("slot.json"), "{ \"required\": [\"severity\"], \"properties\": "); - A2ATError exception = - assertThrows(A2ATError.class, () -> new LocalFilePromptSlotSchemaLoader(promptRootDir) - .loadSlotSchema("incident_triage", "en")); + A2ATError exception = assertThrows(A2ATError.class, () -> new LocalFilePromptSlotSchemaLoader(promptRootDir) + .loadSlotSchema("incident_triage", "en")); assertTrue(exception.getMessage().startsWith("Failed to read slot schema resource: ")); } diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java index 7ca7d6bf..e7df1fd1 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java @@ -13,8 +13,8 @@ import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.TemplateReference; import net.openan.a2at.sdk.core.validation.ValidationPipeline; import net.openan.a2at.sdk.core.validation.ValidationResult; import net.openan.a2at.sdk.llm.LLMClient; @@ -41,24 +41,8 @@ void validatesEnUSPromptAndExtractsParams() { DefaultSemanticValidator validator = new DefaultSemanticValidator(llmClient, "en-US", resources); - TemplateReference reference = new TemplateReference() { - @Override - public String uri() { - return "Task-T/v1/energy-saving"; - } - - @Override - public String language() { - return "en-US"; - } - - @Override - public String extensionPrefix() { - return "Task-T"; - } - }; - - ValidationResult result = validator.validate("Check Site A power usage.", Map.of("type", "object"), reference); + ValidationResult result = validator.validate( + "Check Site A power usage.", Map.of("type", "object"), TemplateUri.of("Task-T", "energy-saving")); assertTrue(result.verdict()); assertEquals(Map.of("site", "Site A"), result.params()); @@ -107,24 +91,8 @@ void validateRetriesLlmInfrastructureErrorThroughPipeline() { ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2); - TemplateReference reference = new TemplateReference() { - @Override - public String uri() { - return "Task-T/v1/energy-saving"; - } - - @Override - public String language() { - return "en-US"; - } - - @Override - public String extensionPrefix() { - return "Task-T"; - } - }; - - FilledParamData result = pipeline.validate("Check Site A power usage.", Map.of("type", "object"), reference); + FilledParamData result = pipeline.validate( + "Check Site A power usage.", Map.of("type", "object"), TemplateUri.of("Task-T", "energy-saving")); assertEquals(Map.of("site", "Site A"), result.data()); assertEquals(2, flakyClient.invocations()); @@ -149,28 +117,12 @@ void validateExhaustsRetriesAndWrapsLlmError() { ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3); - TemplateReference reference = new TemplateReference() { - @Override - public String uri() { - return "Task-T/v1/energy-saving"; - } - - @Override - public String language() { - return "en-US"; - } - - @Override - public String extensionPrefix() { - return "Task-T"; - } - }; - ContentValidationException exception = assertThrows( ContentValidationException.class, - () -> pipeline.validate("Check Site A power usage.", Map.of("type", "object"), reference)); + () -> pipeline.validate( + "Check Site A power usage.", Map.of("type", "object"), TemplateUri.of("Task-T", "energy-saving"))); - assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.code()); + assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); assertInstanceOf(LLMRuntimeError.class, exception.getCause().getCause()); assertEquals(3, flakyClient.invocations()); } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/en-US/system.md index b6b975ea..2f24b99f 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/en-US/system.md @@ -26,7 +26,7 @@ Return a JSON object with the following structure: ## Error Reporting Rules Report errors ONLY in `slot_errors` array with the following codes: - **missing_input**: Required slot cannot be extracted from input (value set to null) -- **invalid_value**: Extracted value violates the slot's value_constraint (value set to null) +- **invalid_value**: Extracted value violates the slot's constraints (enum, pattern, minimum, maximum, or x-a2at-value-constraint) (value set to null) ### Required vs Optional Slots - **Required slot missing**: Set value to null, add error with code="missing_input" @@ -35,7 +35,7 @@ Report errors ONLY in `slot_errors` array with the following codes: ## Extraction Strategy 1. Analyze the user input to identify explicit slot values -2. Cross-reference with slot schema for value_constraint validation +2. Cross-reference with slot schema for constraint validation (enum, pattern, minimum, maximum, x-a2at-value-constraint) 3. Use template context to understand slot semantics and expected format 4. For list-type slots, extract as JSON array string (e.g. "[\"item1\", \"item2\"]") diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/zh-CN/system.md index b677aa2c..d483bf57 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/zh-CN/system.md @@ -30,7 +30,7 @@ 4. 若输入中出现显式字段锚点,应优先按字段锚点后的内容映射到对应 slot。 5. 显式字段锚点可以表现为 slot 名称本身,或与 slot `description` 语义明确对应的字段标签,并常见于“字段名 + 为 / 是 / : / : / 包括 / 包含”等结构,以及分段式、列表式字段标签。 6. 对显式字段锚点后的值,即使它与场景默认语义、模板固定文案或其他上下文重复,也不要省略。 -7. 仅当某内容与 slot 的 `description` 或 `value_constraint` 明确对应时才提取。 +7. 仅当某内容与 slot 的 `description` 或 `x-a2at-value-constraint` 明确对应时才提取。 8. 同一 slot 下若有多个明确并列约束,应尽量完整保留。 9. 拿不准时返回 `null`,不要基于常识补值或猜测。 10. 若同一 slot 的信息分散在多个子句中,且这些子句都在表达用户要写入任务的正向约束,应合并保留,不要只保留最后出现的一项。 @@ -40,7 +40,7 @@ ## 错误报告规则 仅在 `slot_errors` 数组中报告错误,使用以下错误码: - **missing_input**:无法从输入中提取必填 slot(值设为 null) -- **invalid_value**:提取的值违反 slot 的 value_constraint(值设为 null) +- **invalid_value**:提取的值违反 slot 的约束(enum、pattern、minimum、maximum 或 x-a2at-value-constraint)(值设为 null) ### 必填 vs 可选 Slot - **必填 slot 缺失**:值设为 null,添加错误 code="missing_input" diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java index 38925e7d..78399cf2 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java @@ -1,5 +1,6 @@ package net.openan.a2at.sdk.server.validation; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -22,7 +23,8 @@ */ public final class LlmBackedPromptSemanticValidator implements ServerPromptSemanticValidator { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectMapper OBJECT_MAPPER = + new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); private final LLMClient llmClient; @@ -131,7 +133,9 @@ private static Map parseResponse(String payload) { return Optional.ofNullable(response).orElseGet(Map::of); } catch (JsonProcessingException error) { throw new PromptComplianceCheckException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, "semantic validation returned invalid JSON", "slot_validation"); + A2ATErrorCodes.SLOT_VALIDATION_ERROR, + "semantic validation returned invalid JSON", + "slot_validation"); } } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java index b1cb74f6..b74674e8 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java @@ -27,12 +27,13 @@ class LlmBackedPromptMetadataExtractorTest { @Test void extractResolvesScenarioLoadsTemplateAndReturnsExtractedSlots() { - LLMClient llmClient = - new RecordingClient("{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); - PromptTemplateTextLoader templateLoader = (scenarioCode, language) -> - "## notification_topic\n{{notification_topic}}\n"; + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + PromptTemplateTextLoader templateLoader = + (scenarioCode, language) -> "## notification_topic\n{{notification_topic}}\n"; PromptSlotSchemaLoader slotSchemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, List.of(new PromptSlotDefinition(SLOT_NAME, true, "string", null, null, null, null, null))); + scenarioCode, + List.of(new PromptSlotDefinition(SLOT_NAME, true, "string", null, null, null, null, null, null))); PromptSlotValueExtractor slotValueExtractor = (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(SLOT_NAME, "Incident"), List.of()); LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( @@ -78,8 +79,8 @@ void extractReturnsPromptParseErrorWhenScenarioRecognitionDoesNotMatch() { @Test void extractReturnsSlotValidationErrorWhenStructuredExtractionReportsSlotErrors() { - LLMClient llmClient = - new RecordingClient("{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( new ScenarioRecognizer(llmClient), List.of(new ScenarioDefinition( @@ -90,7 +91,8 @@ void extractReturnsSlotValidationErrorWhenStructuredExtractionReportsSlotErrors( (scenarioCode, language) -> "template", (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, - List.of(new PromptSlotDefinition(SLOT_NAME, true, "string", null, null, null, null, null))), + List.of(new PromptSlotDefinition( + SLOT_NAME, true, "string", null, null, null, null, null, null))), (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( Map.of(SLOT_NAME, ""), List.of(new StructuredSlotValidationError(SLOT_NAME, "missing_input", "topic is missing")))); @@ -104,8 +106,8 @@ void extractReturnsSlotValidationErrorWhenStructuredExtractionReportsSlotErrors( @Test void extractPropagatesTemplateLoadErrorsAsGenerationFailures() { - LLMClient llmClient = - new RecordingClient("{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( new ScenarioRecognizer(llmClient), List.of(new ScenarioDefinition( diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java index 450437fc..a15a1308 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java @@ -20,7 +20,8 @@ class LlmBackedPromptSemanticValidatorTest { private static final String SLOT_NAME = "notification_topic"; private static final PromptSlotSchemaLoader SLOT_SCHEMA_LOADER = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, List.of(new PromptSlotDefinition(SLOT_NAME, true, "string", null, null, null, null, null))); + scenarioCode, + List.of(new PromptSlotDefinition(SLOT_NAME, true, "string", null, null, null, null, null, null))); @Test void validatePassesWhenSemanticValidatorApprovesSlots() { @@ -60,9 +61,8 @@ void validatePassesWhenSemanticValidatorReturnsFormattedJson() { @Test void validateReturnsSlotValidationErrorWhenSemanticValidatorRejectsSlots() { - LLMClient llmClient = new RecordingClient( - "{\"passed\":false,\"errors\":[{\"slot_name\":\"notification_topic\"," - + "\"code\":\"semantic_mismatch\",\"message\":\"topic does not match\"}]}"); + LLMClient llmClient = new RecordingClient("{\"passed\":false,\"errors\":[{\"slot_name\":\"notification_topic\"," + + "\"code\":\"semantic_mismatch\",\"message\":\"topic does not match\"}]}"); LlmBackedPromptSemanticValidator validator = new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); @@ -82,9 +82,8 @@ void validateReturnsSlotValidationErrorWhenSemanticValidatorRejectsSlots() { @Test void validateRejectsWhenPassedTrueButErrorsNonEmpty() { - LLMClient llmClient = new RecordingClient( - "{\"passed\":true,\"errors\":[{\"slot_name\":\"notification_topic\"," - + "\"code\":\"semantic_mismatch\",\"message\":\"topic does not match\"}]}"); + LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":[{\"slot_name\":\"notification_topic\"," + + "\"code\":\"semantic_mismatch\",\"message\":\"topic does not match\"}]}"); LlmBackedPromptSemanticValidator validator = new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); From cda187936fd723d96f3dd762076f613893e7b046 Mon Sep 17 00:00:00 2001 From: qinyanhao01 Date: Sat, 22 Aug 2026 15:34:14 +0800 Subject: [PATCH 097/295] fix(prompt): add missing .md suffix Signed-off-by: qinyanhao01 --- .../a2at/sdk/prompt/validation/DefaultSemanticValidator.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index d864dacc..8339db76 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -54,8 +54,8 @@ public DefaultSemanticValidator( @NonNull String language, @NonNull PromptResourceAccess promptResourceAccess) { this.llmClient = llmClient; - this.systemPrompt = promptResourceAccess.loadPrompt("content_validation", language, "system"); - this.userPromptTemplate = promptResourceAccess.loadPrompt("content_validation", language, "user"); + this.systemPrompt = promptResourceAccess.loadPrompt("content_validation", language, "system.md"); + this.userPromptTemplate = promptResourceAccess.loadPrompt("content_validation", language, "user.md"); this.jsonValueParser = new JacksonJsonValueParser(); } From b4cdf7e307ea87d39f00fda441694355f592fefd Mon Sep 17 00:00:00 2001 From: qinyanhao01 Date: Sat, 22 Aug 2026 15:34:18 +0800 Subject: [PATCH 098/295] feat(sample): add task-t accuracy validation demo Signed-off-by: qinyanhao01 --- a2a-t-sample/README.zh-CN.md | 66 ++++ .../sample/task_t/TaskTAccuracyEvaluator.java | 132 ++++++++ .../a2at/sample/task_t/TaskTDemoMain.java | 245 +++++++++++++++ .../TaskTPrivateLineComplaintSamples.java | 168 ++++++++++ .../a2at/sample/task_t/package-info.java | 12 + .../private-line-complaint-samples.json | 290 ++++++++++++++++++ a2a-t-sample/task-t-test-cases.md | 53 ++++ 7 files changed, 966 insertions(+) create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/package-info.java create mode 100644 a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json create mode 100644 a2a-t-sample/task-t-test-cases.md diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 534c017b..54f79ef3 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -59,3 +59,69 @@ java @a2a-t-sample/target/server.javaargs.txt ```bash java @a2a-t-sample/target/client.javaargs.txt ``` + +## Task-T 准确率验证样例 + +`TaskTDemoMain` 是一个不依赖注册中心、不发送真实 A2A 请求的本地闭环样例:客户端 facade 生成结构化 +`Task-T` prompt,服务端 facade 校验并重新提取参数,再与样本的期望值比对,分别统计 +`generateTaskPromptFromText` 与 `generateTaskPromptFromDataWithSchema` 两个客户端 API 的准确率。 + +入口类:`net.openan.a2at.sample.task_t.TaskTDemoMain` + +样例内容: + +- 模板:`Task-T/network-layer/private-line-complaint/v1`(`StandardTemplates.PRIVATE_LINE_COMPLAINT`,传输专线业务投诉诊断) +- 客户端结构化入参:`data` 使用**英文业务字段**(客户端 key),不直接复用模板槽位结构;由客户端将业务字段 + 渲染映射进模板槽位 +- 两端 key 分离(验证 SDK 适配):客户端 key(`portName`/`complaintScenario`/`faultStartTime`/`ticketNo`/ + `faultDetailText`)与服务端 key(`accessPort`/`bizScenario`/`faultTime`/`eventSerialNo`/`faultDetail`)语义近似但 + **字段名不一致** —— 服务端按自己的 key 从渲染后的 prompt 提取参数,评估(ground truth)也按服务端 key 记分; + 客户端发什么 key、服务端收什么 key 互不假设,闭环检验 a2a-t SDK 的跨 key 适配能力 +- 两端 schema:客户端面向 prompt 生成(字段语义、格式示例、取值范围引导),服务端面向提交校验 + (标准 JSON Schema,必填约束、取值限定),各自独立维护 +- 样本:共 **12 组**(`private-line-complaint-samples.json` 数据源,可自行替换为自己的场景)——6 组**简短口语化自然 + 语言文本**(字段信息以叙述方式出现而非罗列,验证 SDK 解析自然语言的能力)+ 6 组结构化 data+schema + (结构化入参使用英文业务字段,不直接复用模板槽位结构);完整用例清单见 + [`task-t-test-cases.md`](task-t-test-cases.md) +- 闭环:`A2ATClient` 生成 → `A2ATServer#validateAndFillingTaskData` 校验提取 → 与 ground truth 比对 +- 评估:字段级准确率 = 命中期望字段数 / 期望字段总数;样本级通过率 = 全部字段命中的样本数 / 样本总数 + +命中规则:去掉空白并统一小写后,提取值与期望值完全相同或互相包含即为命中;提取值缺失视为未命中。 + +### 运行要求 + +1. 配置 LLM 环境:直接编辑包内模板 `sample/task_t/client.env` 补充可用的 `A2AT_LLM_API_KEY`(配置 + `A2AT_LLM_PROVIDER=openai`、`A2AT_LLM_BASE_URL`、`A2AT_LLM_MODEL`,每个样本的服务端语义校验各消耗一次 + LLM 调用);也可在仓库根目录放置自己的 `client.env`。 +2. 先编译打包: + +```bash +mvn -pl a2a-t-sample -am -DskipTests package +``` + +### 启动 + +env 文件解析顺序:命令行第一参数(任意路径,例如 `server.env`)→ 工作目录下的 `client.env`(仓库根,且必须含 +非空的 `A2AT_LLM_PROVIDER`/`A2AT_LLM_MODEL`/`A2AT_LLM_API_KEY`,否则跳过)→ 包内模板 +`sample/task_t/client.env`。不传参数时会自动跳过仓库根遗留的空配置、回退到包内模板。 + +PowerShell: + +```powershell +$cp = "a2a-t-sample/target/a2a-t-sample-1.0.0.jar;" + (Get-Content a2a-t-sample/target/sample-runtime-classpath.txt) +java -cp $cp net.openan.a2at.sample.task_t.TaskTDemoMain +``` + +Git Bash: + +```bash +java -cp "a2a-t-sample/target/a2a-t-sample-1.0.0.jar:$(cat a2a-t-sample/target/sample-runtime-classpath.txt)" \ + net.openan.a2at.sample.task_t.TaskTDemoMain +``` + +也可显式传入任意 `.env` 路径(例如 `client.env`)。运行结束后会打印每个样本的出入参、字段级命中明细, +以及两个客户端 API 各自的字段准确率与样本通过率。 + +> 说明:`a2a-t-sample-1.0.0.jar` 假设模块版本为 `1.0.0`,若仓库版本已更新请以实际 jar 为准。 +> 该样例为演示目的,期望值取各槽位的关键事实,命中规则较宽松(相同或互相包含);如需严谨评测, +> 可自行扩充样本并收紧期望值。 diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java new file mode 100644 index 00000000..65f2a5db --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java @@ -0,0 +1,132 @@ +package net.openan.a2at.sample.task_t; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Field-level and sample-level accuracy scoring for the {@code Task-T} demo. + * + *

Each {@link SampleScore} scores one sample: every expected ground-truth field is compared with the corresponding + * value extracted by {@code A2ATServer#validateAndFillingTaskData}. {@link Summary} aggregates the scores per client + * API both as a field hit rate (matched expected fields over total expected fields) and as a sample pass rate + * (samples whose expected fields all hit over total samples). + */ +final class TaskTAccuracyEvaluator { + + private TaskTAccuracyEvaluator() { + } + + /** + * One expected slot compared against the extracted value. + * + * @param slot Chinese slot name + * @param expected ground-truth value + * @param extracted value extracted by the server facade; {@code null} when it was missing + * @param matched whether the extracted value hit the expected value + * @param detail explanation for a mismatch or a validation failure + */ + record FieldScore(String slot, String expected, String extracted, boolean matched, String detail) {} + + /** + * Accuracy outcome of one sample. + * + * @param name sample identifier + * @param validationPassed whether the server validation step completed without a {@code ContentValidationException} + * @param fields per-field scores; empty when generation or validation failed + */ + record SampleScore(String name, boolean validationPassed, List fields) { + + int expectedFieldCount() { + return fields.size(); + } + + int matchedFieldCount() { + return (int) fields.stream().filter(FieldScore::matched).count(); + } + + /** A sample passes when validation succeeded and every expected field hit. */ + boolean passed() { + return validationPassed && matchedFieldCount() == expectedFieldCount(); + } + } + + /** + * Aggregated accuracy numbers for one client API case. + * + * @param api case label shown in the report + * @param sampleCount total scored samples + * @param passedSamples samples whose expected fields all hit + * @param matchedFields field hits over all scored samples + * @param expectedFields total expected fields over all scored samples + */ + record Summary(String api, int sampleCount, int passedSamples, int matchedFields, int expectedFields) { + + double fieldAccuracyPercent() { + return expectedFields == 0 ? 0d : 100d * matchedFields / expectedFields; + } + + double samplePassRatePercent() { + return sampleCount == 0 ? 0d : 100d * passedSamples / sampleCount; + } + } + + /** + * Scores one sample: each expected value is looked up in the extracted parameter map and compared. + * + * @param sample source sample + * @param extractedParams values extracted by the server facade; may carry extra slots beyond the expected ones + * @return per-field score list in sample insertion order + */ + static List scoreFields(TaskTSample sample, Map extractedParams) { + List scores = new ArrayList<>(); + sample.expectedParams() + .forEach((slot, expected) -> { + Object extracted = extractedParams.get(slot); + String extractedText = extracted == null ? null : String.valueOf(extracted); + boolean matched = matches(extractedText, expected); + String detail = matched ? "" : "expected=" + expected + ", extracted=" + (extractedText == null + ? "" + : extractedText); + scores.add(new FieldScore(slot, expected, extractedText, matched, detail)); + }); + return scores; + } + + /** + * Hit rule: equal after normalization, or one contains the other. + * + * @param extracted extracted value, may be {@code null} + * @param expected ground-truth value + * @return {@code true} when both are non-blank and the normalized values are equal or contain each other + */ + static boolean matches(String extracted, String expected) { + String normalizedExtracted = normalize(extracted); + String normalizedExpected = normalize(expected); + if (normalizedExtracted.isEmpty() || normalizedExpected.isEmpty()) { + return false; + } + return normalizedExtracted.equals(normalizedExpected) + || normalizedExtracted.contains(normalizedExpected) + || normalizedExpected.contains(normalizedExtracted); + } + + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("\\s+", "").toLowerCase(Locale.ROOT); + } + + /** + * Aggregates scored samples into one per-API summary. + * + * @param api case label shown in the report + * @param scores scored samples + * @return aggregated summary + */ + static Summary summarize(String api, List scores) { + int passedSamples = (int) scores.stream().filter(SampleScore::passed).count(); + int matchedFields = scores.stream().mapToInt(SampleScore::matchedFieldCount).sum(); + int expectedFields = scores.stream().mapToInt(SampleScore::expectedFieldCount).sum(); + return new Summary(api, scores.size(), passedSamples, matchedFields, expectedFields); + } +} \ No newline at end of file diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java new file mode 100644 index 00000000..8d7328d0 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java @@ -0,0 +1,245 @@ +package net.openan.a2at.sample.task_t; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.StandardTemplates; +import net.openan.a2at.sdk.core.validation.TemplateUri; +import net.openan.a2at.sdk.server.A2ATServer; + +/** + * End-to-end accuracy demo for the {@code Task-T} extension. + * + *

The demo closes the loop between the client facade and the server facade over the + * {@code Task-T/network-layer/private-line-complaint/v1} template (transfer / private-line business complaint + * diagnosis) in two independent cases: + * + *

    + *
  1. {@code A2ATClient#generateTaskPromptFromText} fed with natural language;
  2. + *
  3. {@code A2ATClient#generateTaskPromptFromDataWithSchema} fed with a slot-value map plus a semantics schema. + *
+ * + *

Each generated prompt is passed to {@code A2ATServer#validateAndFillingTaskData}; the extracted parameters are + * compared against the sample ground truth and the results are aggregated into a per-case field accuracy and sample + * pass rate. + * + *

Run with {@code java ... TaskTDemoMain [env-file-path]}. The env file resolves as follows: an explicit first + * argument wins; otherwise a {@code client.env} in the working directory (repo root) that carries the required LLM + * keys; otherwise the bundled sample template {@code a2a-t-sample/src/main/resources/sample/task_t/client.env}. + * A working-directory {@code client.env} is only honored when it defines non-blank {@code A2AT_LLM_PROVIDER}, + * {@code A2AT_LLM_MODEL} and {@code A2AT_LLM_API_KEY} (cf. the leftover {@code subscribe_incident} template in newer + * checkouts would otherwise shadow the Task-T sample); otherwise the bundled template is used. The env must configure + * a reachable OpenAI-compatible LLM ({@code A2AT_LLM_API_KEY}, {@code A2AT_LLM_BASE_URL}, {@code A2AT_LLM_MODEL}, + * {@code A2AT_LLM_PROVIDER=openai}) — the server-side semantic validation performs one LLM call per sample. + */ +public final class TaskTDemoMain { + + private static final String DEFAULT_ENV_FILE = "client.env"; + + private static final String BUNDLED_ENV_FILE = + Path.of("a2a-t-sample", "src", "main", "resources", "sample", "task_t", "client.env").toString(); + + private static final List REQUIRED_LLM_KEYS = + List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY"); + + private TaskTDemoMain() { + } + + /** + * Runs both client-API cases against the built-in private-line complaint diagnosis samples. + * + * @param args optional first argument is the {@code .env} file path + */ + public static void main(String[] args) { + Path envPath = resolveEnvPath(args); + println("Task-T 准确率验证样例,env: " + envPath.toAbsolutePath() + (Files.exists(envPath) ? "" : " (不存在,请先配置)")); + println("模板: " + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); + println(); + + A2ATClient client = new A2ATClient(envPath); + A2ATServer server = new A2ATServer(envPath); + + List textScores = runTextCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + List dataScores = runDataWithSchemaCase(client, server); + + println(); + System.out.println("══════════════════════════════════════════════════════"); + printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromText", textScores)); + printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromDataWithSchema", dataScores)); + } + + private static List runTextCase(A2ATClient client, A2ATServer server) { + println("================ 用例一:generateTaskPromptFromText ================"); + List samples = TaskTPrivateLineComplaintSamples.textSamples(); + List scores = new ArrayList<>(); + for (int i = 0; i < samples.size(); i++) { + TaskTSample sample = samples.get(i); + printSampleHeader("用例一", i, samples.size(), sample.name()); + println("[输入] 自然语言文本:"); + println(sample.text()); + println(); + try { + MetadataContent metadata = client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + printGeneratedMetadata(metadata); + TaskTAccuracyEvaluator.SampleScore score = + validateAndScore(server, sample, metadata, "用例一"); + scores.add(score); + } catch (PromptGenerationException exception) { + printFailure("生成失败", exception); + scores.add(new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of())); + } + println(); + } + return scores; + } + + private static List runDataWithSchemaCase(A2ATClient client, A2ATServer server) { + println("========== 用例二:generateTaskPromptFromDataWithSchema =========="); + List samples = TaskTPrivateLineComplaintSamples.dataWithSchemaSamples(); + List scores = new ArrayList<>(); + for (int i = 0; i < samples.size(); i++) { + TaskTSample sample = samples.get(i); + printSampleHeader("用例二", i, samples.size(), sample.name()); + println("[输入] 数据(data):"); + println(pretty(sample.data())); + println("[输入] 语义schema(schema):"); + println(pretty(sample.semanticsSchema())); + println(); + try { + MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + printGeneratedMetadata(metadata); + TaskTAccuracyEvaluator.SampleScore score = + validateAndScore(server, sample, metadata, "用例二"); + scores.add(score); + } catch (PromptGenerationException exception) { + printFailure("生成失败", exception); + scores.add(new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of())); + } + println(); + } + return scores; + } + + private static TaskTAccuracyEvaluator.SampleScore validateAndScore( + A2ATServer server, TaskTSample sample, MetadataContent metadata, String caseLabel) { + try { + Map extracted = + server.validateAndFillingTaskData(metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT) + .data(); + println("[服务端] " + caseLabel + " validateAndFillingTaskData 通过,提取参数:"); + println(pretty(extracted)); + List fields = TaskTAccuracyEvaluator.scoreFields(sample, extracted); + printFieldScores(fields); + return new TaskTAccuracyEvaluator.SampleScore(sample.name(), true, fields); + } catch (ContentValidationException exception) { + printFailure("校验不通过", exception); + return new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of()); + } + } + + private static void printSampleHeader(String caseLabel, int index, int total, String name) { + System.out.println("──────────── " + caseLabel + " 样本[" + (index + 1) + "/" + total + "] " + name + " ────────────"); + } + + private static void printGeneratedMetadata(MetadataContent metadata) { + println("[生成] MetadataContent:"); + println(" extensionUri: " + metadata.extensionUri()); + println(" templateUri : " + metadata.templateUri()); + println(" promptText : "); + println(metadata.promptText()); + } + + private static void printFieldScores(List fields) { + println("[比对] 字段级命中 (命中规则: 归一化后相同或互相包含):"); + for (TaskTAccuracyEvaluator.FieldScore field : fields) { + String mark = field.matched() ? "✔ 命中" : "✘ 未命中"; + String detail = field.matched() ? "" : " " + field.detail(); + println(" " + mark + " " + field.slot() + detail); + } + } + + private static void printFailure(String stage, A2ATError error) { + println("[失败] " + stage + ": [" + error.getCode() + "] " + error.getMessage()); + } + + private static void printSummary(TaskTAccuracyEvaluator.Summary summary) { + System.out.println("──────────── 汇总: " + summary.api() + " ────────────"); + println(" 样本数: " + summary.sampleCount() + + " 通过样本: " + summary.passedSamples() + + " 字段命中: " + summary.matchedFields() + "/" + summary.expectedFields()); + println(" 字段准确率: " + percent(summary.fieldAccuracyPercent()) + + " 样本通过率: " + percent(summary.samplePassRatePercent())); + } + + private static String percent(double value) { + return String.format(Locale.ROOT, "%.2f%%", value); + } + + private static String pretty(Map map) { + if (map == null) { + return " "; + } + String body = map.entrySet().stream() + .map(entry -> entry.getKey() + " = " + entry.getValue()) + .collect(Collectors.joining("\n ", " ", "")); + return body; + } + + private static void println(Object message) { + System.out.println(message); + } + + private static void println() { + System.out.println(); + } + + private static Path resolveEnvPath(String[] args) { + if (args.length > 0) { + return Path.of(args[0]); + } + Path cwdEnv = Path.of(DEFAULT_ENV_FILE); + if (Files.exists(cwdEnv) && hasRequiredLlmKeys(cwdEnv)) { + return cwdEnv; + } + return Path.of(BUNDLED_ENV_FILE); + } + + private static boolean hasRequiredLlmKeys(Path envFile) { + Map entries = new HashMap<>(); + try { + for (String line : Files.readAllLines(envFile)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int separator = trimmed.indexOf('='); + if (separator <= 0) { + continue; + } + entries.put(trimmed.substring(0, separator).trim(), trimmed.substring(separator + 1).trim()); + } + } catch (IOException exception) { + return false; + } + for (String key : REQUIRED_LLM_KEYS) { + if (entries.getOrDefault(key, "").isBlank()) { + return false; + } + } + return true; + } +} \ No newline at end of file diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java new file mode 100644 index 00000000..47a46e29 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java @@ -0,0 +1,168 @@ +package net.openan.a2at.sample.task_t; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Built-in evaluation samples for the {@code Task-T/network-layer/private-line-complaint/v1} template (transfer / + * private-line business complaint diagnosis), loaded from the packaged JSON file + * {@code a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json}. + * + *

The sample set is split into two groups matching the two client APIs under test: + * + *

    + *
  • {@link #textSamples()} feed {@code A2ATClient#generateTaskPromptFromText} with concise colloquial natural + * language — deliberately not field-listed, so a run exercises the SDK natural-language parsing + * capability;
  • + *
  • {@link #dataWithSchemaSamples()} feed {@code A2ATClient#generateTaskPromptFromDataWithSchema} with a + * structured input whose {@code data} carries English business fields — not the template slot names.
  • + *
+ * + *

Per design, the client-side keys and the server-side keys intentionally diverge: the client submits its + * business fields under its own key names, the SDK renders them into the template prompt, and the server re-extracts + * them from the rendered prompt under the server-side key names. This cross-key round trip exercises the a2a-t + * SDK adaptation capability — neither side may assume the other's key layout. + * + * + * + * + * + * + * + * + * + *
near semantics, distinct keys
client key (data / semantics schema)server key (validation schema / extracted params)meaning
{@code portName}{@code accessPort}接入端口名称
{@code complaintScenario}{@code bizScenario}投诉分类(专线中断/专线质差)
{@code faultStartTime}{@code faultTime}问题发生时间
{@code ticketNo}{@code eventSerialNo}OSS 侧事件流水号
{@code faultDetailText}{@code faultDetail}投诉/故障详情
+ * + *

Expected values ({@link TaskTSample#expectedParams()}) are keyed by the server field names, the keys the + * accuracy evaluator actually reads out of {@code validateAndFillingTaskData}; they hold canonical key facts that + * always appear in the input, and a field hits when the extracted value is equal to or contains the expected value + * after whitespace-and-case normalization. + */ +public final class TaskTPrivateLineComplaintSamples { + + /** Client key: the addressed access port name, e.g. {@code P781-珠江新城-PTN7900-23-TPA1EG24-17}. */ + public static final String CLIENT_PORT = "portName"; + + /** Client key: complaint category scenario, {@code 专线中断} or {@code 专线质差}. */ + public static final String CLIENT_SCENARIO = "complaintScenario"; + + /** Client key: problem occurrence time (ISO-8601). */ + public static final String CLIENT_TIME = "faultStartTime"; + + /** Client key: OSS-side complaint/event ticket number. */ + public static final String CLIENT_TICKET = "ticketNo"; + + /** Client key: free-text complaint detail. */ + public static final String CLIENT_DETAIL = "faultDetailText"; + + /** Server key (extracted parameter): access port name; near-semantics twin of {@link #CLIENT_PORT}. */ + public static final String SERVER_PORT = "accessPort"; + + /** Server key (extracted parameter): complaint category; near-semantics twin of {@link #CLIENT_SCENARIO}. */ + public static final String SERVER_SCENARIO = "bizScenario"; + + /** Server key (extracted parameter): problem occurrence time; near-semantics twin of {@link #CLIENT_TIME}. */ + public static final String SERVER_TIME = "faultTime"; + + /** Server key (extracted parameter): event serial number; near-semantics twin of {@link #CLIENT_TICKET}. */ + public static final String SERVER_TICKET = "eventSerialNo"; + + /** Server key (extracted parameter): complaint detail; near-semantics twin of {@link #CLIENT_DETAIL}. */ + public static final String SERVER_DETAIL = "faultDetail"; + + private static final String SAMPLE_RESOURCE = "sample/task_t/private-line-complaint-samples.json"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final List SAMPLES = loadSamples(); + + private TaskTPrivateLineComplaintSamples() { + } + + /** + * Natural-language samples for {@code generateTaskPromptFromText}. + * + * @return immutable text sample list + */ + public static List textSamples() { + return SAMPLES.stream().filter(sample -> sample.text() != null).toList(); + } + + /** + * Data-plus-schema samples for {@code generateTaskPromptFromDataWithSchema}. + * + *

Each sample passes business fields under the client keys in {@code data} plus a client-side + * semantics {@code schema}; the server-side validation schema re-extracts the parameters under the server + * keys, so a run exercises the cross-key adaptation of the SDK. + * + * @return immutable data sample list + */ + public static List dataWithSchemaSamples() { + return SAMPLES.stream().filter(sample -> sample.data() != null).toList(); + } + + private static List loadSamples() { + try (InputStream in = TaskTPrivateLineComplaintSamples.class.getResourceAsStream("/" + SAMPLE_RESOURCE)) { + if (in == null) { + throw new IllegalStateException("sample resource not found on classpath: " + SAMPLE_RESOURCE); + } + JsonNode root = OBJECT_MAPPER.readTree(in); + Map semanticsSchema = toObjectMap(root.get("semanticsSchema")); + Map validationSchema = toObjectMap(root.get("validationSchema")); + List samples = new ArrayList<>(); + for (JsonNode node : root.get("samples")) { + String name = node.get("name").asText(); + JsonNode text = node.get("text"); + JsonNode data = node.get("data"); + Map expected = toStringMap(node.get("expected")); + if (data == null) { + samples.add(new TaskTSample(name, text.asText(), null, null, expected, validationSchema)); + } else { + samples.add(new TaskTSample( + name, null, toObjectMap(data), semanticsSchema, expected, validationSchema)); + } + } + return List.copyOf(samples); + } catch (IOException error) { + throw new IllegalStateException("failed to load task-t samples from " + SAMPLE_RESOURCE, error); + } + } + + private static Map toObjectMap(JsonNode node) { + return OBJECT_MAPPER.convertValue(node, new TypeReference>() {}); + } + + private static Map toStringMap(JsonNode node) { + return OBJECT_MAPPER.convertValue(node, new TypeReference>() {}); + } +} + +/** + * One private-line complaint diagnosis evaluation sample. + * + * @param name sample identifier shown in the accuracy report + * @param text natural-language input for the FromText case; {@code null} when the sample only targets the data case + * @param data structured business-field input for the FromDataWithSchema case, keyed by the client field + * names; {@code null} when the sample only targets the text case + * @param semanticsSchema client-side field-semantics schema, keyed by the client field names; may be {@code null} + * when {@code data} is {@code null} + * @param expectedParams ground truth business values, keyed by the server field names (the keys read out of + * {@code validateAndFillingTaskData}); these are the fields actually scored + * @param validationSchema caller-provided JSON parameter schema passed to {@code validateAndFillingTaskData}, + * keyed by the server field names + * @since 2026-08 + */ +record TaskTSample( + String name, + String text, + Map data, + Map semanticsSchema, + Map expectedParams, + Map validationSchema) {} \ No newline at end of file diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/package-info.java new file mode 100644 index 00000000..e570cfe3 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/package-info.java @@ -0,0 +1,12 @@ +/** + * Self-contained {@code Task-T} accuracy demonstration sample for the A2A-T Java SDK. + * + *

The demo closes the evaluation loop between the client facade ({@code A2ATClient}) and the server facade + * ({@code A2ATServer}) over the {@code Task-T/network-layer/private-line-complaint/v1} template: each sample generates + * a structured prompt with one of the two client APIs, the server validates the prompt and re-extracts the parameters, + * and the extracted values are compared against the sample ground truth to compute field-level accuracy and + * sample-level pass rates. + * + * @since 2026-08 + */ +package net.openan.a2at.sample.task_t; \ No newline at end of file diff --git a/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json b/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json new file mode 100644 index 00000000..ee8bd435 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json @@ -0,0 +1,290 @@ +{ + "semanticsSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "portName": { + "type": "string", + "description": "业务字段:接入端口名称,唯一标识被投诉的专线对象", + "examples": [ + "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)" + ], + "x-a2at-value-constraint": "物理端口格式为:网元名称-单板号-单板型号-端口号;逻辑端口在末尾追加(cvlan=接入vlanid)" + }, + "complaintScenario": { + "type": "string", + "description": "业务字段:投诉分类场景,专线中断或专线质差二者必选其一", + "examples": [ + "专线中断", + "专线质差" + ], + "x-a2at-value-constraint": "专线中断(业务完全不通)、专线质差(时延、丢包劣化)" + }, + "faultStartTime": { + "type": "string", + "description": "业务字段:问题发生时间", + "examples": [ + "2026-05-11T08:21:46Z" + ], + "x-a2at-value-constraint": "建议使用 ISO-8601 时间戳,例如 2026-05-11T08:21:46Z" + }, + "ticketNo": { + "type": "string", + "description": "业务字段:OSS 侧受理的投诉工单或事件流水号,供诊断回溯关联", + "examples": [ + "event-id-20260511-09013" + ], + "x-a2at-value-constraint": "例如 event-id-20260511-09013、fault-id-1-017-20260516-11234" + }, + "faultDetailText": { + "type": "string", + "description": "业务字段:用户对故障现象的自由描述,可包含 OSS 侧预处理信息,用于指导诊断方向", + "examples": [ + "从5月11号早上8点半开始,深圳访问广州的响应时延从平均12ms骤升至320ms" + ], + "x-a2at-value-constraint": "预检查信息(测时延、丢包、连通性)与故障现象均可作为详情描述" + } + }, + "required": [ + "portName", + "complaintScenario" + ] + }, + "validationSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "accessPort": { + "type": "string", + "description": "接入端口名称,唯一标识被投诉的专线对象,必填,须与投诉对象一致", + "examples": [ + "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)" + ], + "x-a2at-value-constraint": "物理端口格式为:网元名称+单板号+单板型号+端口号;逻辑端口在末尾追加(cvlan=接入vlanid)" + }, + "bizScenario": { + "type": "string", + "description": "投诉分类场景,必填,仅允许取值:专线中断、专线质差", + "examples": [ + "专线中断", + "专线质差" + ], + "x-a2at-value-constraint": "专线中断(业务完全不通)、专线质差(时延、丢包劣化),二者必选其一" + }, + "faultTime": { + "type": "string", + "description": "问题发生时间", + "examples": [ + "2026-05-11T08:21:46Z" + ], + "x-a2at-value-constraint": "使用 ISO-8601 时间戳,例如 2026-05-11T08:21:46Z" + }, + "eventSerialNo": { + "type": "string", + "description": "OSS 侧受理的投诉工单或事件流水号", + "examples": [ + "event-id-20260511-09013" + ], + "x-a2at-value-constraint": "供诊断回溯关联" + }, + "faultDetail": { + "type": "string", + "description": "投诉/故障现象详情描述", + "examples": [ + "从5月11号早上8点半开始,深圳访问广州的响应时延从平均12ms骤升至320ms" + ], + "x-a2at-value-constraint": "自由描述故障现象,可包含 OSS 侧预处理信息,用于指导诊断方向" + } + }, + "required": [ + "accessPort", + "bizScenario" + ] + }, + "samples": [ + { + "name": "text-private-line-quality", + "kind": "text", + "text": "帮我创建专线投诉诊断任务,P781-珠江新城-PTN7900-23-TPA1EG24-17 这个端口,客户报的是专线质差,从2026年5月11号早上8点半开始,深圳访问广州的核心系统非常慢,时延从12ms飙到320ms,柜面和手机银行老是报连接超时,OSS流水号是event-id-20260511-09013。", + "expected": { + "accessPort": "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "bizScenario": "专线质差", + "faultTime": "2026-05-11", + "eventSerialNo": "event-id-20260511-09013", + "faultDetail": "320ms" + } + }, + { + "name": "text-logical-port-interruption", + "kind": "text", + "text": "创建专线业务投诉诊断,P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100) 这条链路,2026年5月16号中午12点10分以后深圳到上海专线完全中断,核心系统都访问不了,分类是专线中断,流水号fault-id-1-017-20260516-11234。", + "expected": { + "accessPort": "P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)", + "bizScenario": "专线中断", + "faultTime": "2026-05-16", + "eventSerialNo": "fault-id-1-017-20260516-11234", + "faultDetail": "完全中断" + } + }, + { + "name": "text-port-quality-jitter", + "kind": "text", + "text": "麻烦看下 P781-惠州江北-PTN7900-7-TPA1EG24-21 这条专线,5月22号下午两点多起时延跳得厉害,丢包最高到15%,视频会议一直卡,分类是专线质差,流水号event-id-20260522-00812。", + "expected": { + "accessPort": "P781-惠州江北-PTN7900-7-TPA1EG24-21", + "bizScenario": "专线质差", + "faultTime": "2026-05-22", + "eventSerialNo": "event-id-20260522-00812", + "faultDetail": "丢包" + } + }, + { + "name": "text-port-interruption-route", + "kind": "text", + "text": "创建专线投诉诊断,P781-南沙-PTN7900-9-TPA1EG24-31 这条专线早上9点40分起彻底不通,上海到北京核心系统全部访问不了,客户报的是专线中断,流水号event-id-20260603-02341。", + "expected": { + "accessPort": "P781-南沙-PTN7900-9-TPA1EG24-31", + "bizScenario": "专线中断", + "faultTime": "2026-06-03", + "eventSerialNo": "event-id-20260603-02341", + "faultDetail": "彻底不通" + } + }, + { + "name": "text-port-quality-latency", + "kind": "text", + "text": "帮我诊断下 P781-天河体育西-PTN7900-4-TPA1EG24-11 端口的问题,6月8号下午3点多开始网络变差,深圳到上海时延忽高忽低最高接近500ms,视频会议直接开不了,分类专线质差,流水号event-id-20260608-01102。", + "expected": { + "accessPort": "P781-天河体育西-PTN7900-4-TPA1EG24-11", + "bizScenario": "专线质差", + "faultTime": "2026-06-08", + "eventSerialNo": "event-id-20260608-01102", + "faultDetail": "时延" + } + }, + { + "name": "text-logical-port-interruption-vlan", + "kind": "text", + "text": "看下 P781-宝安中心-PTN7900-6-TPA1EG24-27(cvlan=200) 这条链路,2026年6月12号晚上8点起就彻底连不上了,柜面交易全部失败,客户说是专线中断,流水号fault-id-9-023-20260612-04578。", + "expected": { + "accessPort": "P781-宝安中心-PTN7900-6-TPA1EG24-27(cvlan=200)", + "bizScenario": "专线中断", + "faultTime": "2026-06-12", + "eventSerialNo": "fault-id-9-023-20260612-04578", + "faultDetail": "连不上" + } + }, + { + "name": "data-port-quality", + "kind": "data", + "data": { + "portName": "P781-福田中心-PTN7900-2-TPA1EG24-03", + "complaintScenario": "专线质差", + "faultStartTime": "2026-05-11T08:21:46Z", + "ticketNo": "event-id-20260511-09013", + "faultDetailText": "从5月11号早上8点半开始,深圳访问广州的响应时延从平均12ms骤升至320ms、峰值达480ms,访问广州机房的核心交易系统响应非常慢,柜面和手机银行的交易接口频繁报\"连接超时\",正常业务操作要等十几秒甚至半分钟才能返回结果;OSS侧预处理:该时段无割接与网元告警,初步判断链路存在拥塞或丢包。" + }, + "expected": { + "accessPort": "P781-福田中心-PTN7900-2-TPA1EG24-03", + "bizScenario": "专线质差", + "faultTime": "2026-05-11T08:21:46Z", + "eventSerialNo": "event-id-20260511-09013", + "faultDetail": "320ms" + } + }, + { + "name": "data-logical-port-interruption", + "kind": "data", + "data": { + "portName": "P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)", + "complaintScenario": "专线中断", + "faultStartTime": "2026-05-16T12:10:00Z", + "ticketNo": "fault-id-1-017-20260516-11234", + "faultDetailText": "2026年5月16日12时10分起,深圳前海机房至上海数据中心的专线业务完全中断,业务流量归零,核心交易系统无法访问,影响柜面及手机银行交易;对端网关ping丢包率100%,测试光功率正常、端口无物理告警,OSS预处理无割接操作,怀疑中间路由或设备转发存在问题。" + }, + "expected": { + "accessPort": "P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)", + "bizScenario": "专线中断", + "faultTime": "2026-05-16T12:10:00Z", + "eventSerialNo": "fault-id-1-017-20260516-11234", + "faultDetail": "完全中断" + } + }, + { + "name": "data-port-jitter", + "kind": "data", + "data": { + "portName": "P781-惠州江北-PTN7900-7-TPA1EG24-21", + "complaintScenario": "专线质差", + "faultStartTime": "2026-05-22T14:05:00Z", + "ticketNo": "event-id-20260522-00812", + "faultDetailText": "2026年5月22日14:05起晚间高峰,专线时延抖动明显,ping对端时延在60ms~200ms间波动,平均丢包率约5%、峰值达15%,视频会议频繁卡顿、语音断续,核心业务交易超时;OSS未见割接告警,初步判断城域网链路拥塞并伴路由震荡。" + }, + "expected": { + "accessPort": "P781-惠州江北-PTN7900-7-TPA1EG24-21", + "bizScenario": "专线质差", + "faultTime": "2026-05-22T14:05:00Z", + "eventSerialNo": "event-id-20260522-00812", + "faultDetail": "时延抖动" + } + }, + { + "name": "data-logical-port-interruption-route", + "kind": "data", + "data": { + "portName": "P781-云浮-PTN7900-1-TPA1EG24-08(cvlan=300)", + "complaintScenario": "专线中断", + "faultStartTime": "2026-06-03T09:40:00Z", + "ticketNo": "event-id-20260603-02341", + "faultDetailText": "2026年6月3日09:40起,深圳云浮机房至北京数据中心的专线业务完全中断,业务流量归零,核心交易系统无法访问,影响柜面及手机银行交易;对端网关ping丢包率100%,两端光功率正常、端口无物理告警,OSS预处理无割接操作,怀疑中间路由或设备转发异常。" + }, + "expected": { + "accessPort": "P781-云浮-PTN7900-1-TPA1EG24-08(cvlan=300)", + "bizScenario": "专线中断", + "faultTime": "2026-06-03T09:40:00Z", + "eventSerialNo": "event-id-20260603-02341", + "faultDetail": "完全中断" + } + }, + { + "name": "data-port-quality-loss", + "kind": "data", + "data": { + "portName": "P781-珠海吉大-PTN7900-6-TPA1EG24-13", + "complaintScenario": "专线质差", + "faultStartTime": "2026-06-08T15:20:00Z", + "ticketNo": "event-id-20260608-01102", + "faultDetailText": "2026年6月8日15:20起晚间时段,专线时延剧烈波动,ping对端时延在80ms~500ms间跳动、峰值达480ms,平均丢包率约8%、峰值15%,视频会议频繁卡顿、语音断续,核心业务交易超时;OSS未见割接告警,初步判断城域网链路拥塞并伴路由震荡。" + }, + "expected": { + "accessPort": "P781-珠海吉大-PTN7900-6-TPA1EG24-13", + "bizScenario": "专线质差", + "faultTime": "2026-06-08T15:20:00Z", + "eventSerialNo": "event-id-20260608-01102", + "faultDetail": "丢包" + } + }, + { + "name": "data-port-quality-core-slow", + "kind": "data", + "data": { + "portName": "P781-罗湖东门-PTN7900-8-TPA1EG24-15", + "complaintScenario": "专线质差", + "faultStartTime": "2026-06-18T10:05:00Z", + "ticketNo": "event-id-20260618-03342", + "faultDetailText": "2026年6月18日10:05起,深圳访问香港核心系统的响应时延从平均8ms骤升至180ms、峰值达250ms,柜面及手机银行接口频繁报\"连接超时\",正常操作需等待数十秒;OSS侧预处理该时段无割接与网元告警,初步判断链路存在拥塞或丢包。" + }, + "expected": { + "accessPort": "P781-罗湖东门-PTN7900-8-TPA1EG24-15", + "bizScenario": "专线质差", + "faultTime": "2026-06-18T10:05:00Z", + "eventSerialNo": "event-id-20260618-03342", + "faultDetail": "时延" + } + } + ] +} \ No newline at end of file diff --git a/a2a-t-sample/task-t-test-cases.md b/a2a-t-sample/task-t-test-cases.md new file mode 100644 index 00000000..00847ac4 --- /dev/null +++ b/a2a-t-sample/task-t-test-cases.md @@ -0,0 +1,53 @@ +# Task-T 准确率验证测试用例集 + +本文档维护 `net.openan.a2at.sample.task_t.TaskTDemoMain` 的闭环验证用例:客户端 facade 生成结构化 +`Task-T` prompt → 服务端 facade 校验并重新提取参数 → 与期望值(ground truth)比对,统计字段准确率与 +样本通过率。 + +- 模板:`Task-T/network-layer/private-line-complaint/v1`(`StandardTemplates.PRIVATE_LINE_COMPLAINT`,传输专线业务投诉诊断) +- 数据源:`src/main/resources/sample/task_t/private-line-complaint-samples.json` + (用例输入均在此 JSON 中维护,改这里即可扩展/替换场景;样例类 `TaskTPrivateLineComplaintSamples.java` + 仅负责从 classpath 加载并按 `text`/`data` 分组) +- 命中规则:去除空白并统一小写后,提取值与期望值完全相同或互相包含即为命中;提取值缺失视为未命中 + +## 客户端 / 服务端字段对照 + +两端 key 语义近似但**字段名不一致**,用于验证 a2a-t SDK 跨 key 适配能力(客户端 key → 模板渲染 → +服务端 key 提取)。 + +| 客户端 key(入参 data / 语义 schema) | 服务端 key(校验 schema / 提取参数 / 期望值) | 含义 | +|---|---|---| +| `portName` | `accessPort` | 接入端口名称 | +| `complaintScenario` | `bizScenario` | 投诉分类(专线中断 / 专线质差) | +| `faultStartTime` | `faultTime` | 问题发生时间 | +| `ticketNo` | `eventSerialNo` | OSS 侧事件流水号 | +| `faultDetailText` | `faultDetail` | 投诉/故障详情 | + +## 用例一:generateTaskPromptFromText(中文口语化自然语言,验证 NL 解析能力) + +| # | 样例名 | 端口 | 分类 | 时间 | 流水号 | 详情期望关键词 | +|---|---|---|---|---|---|---| +| TC-01 | text-private-line-quality | P781-珠江新城-PTN7900-23-TPA1EG24-17 | 专线质差 | 2026-05-11 | event-id-20260511-09013 | 320ms | +| TC-02 | text-logical-port-interruption | P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100) | 专线中断 | 2026-05-16 | fault-id-1-017-20260516-11234 | 完全中断 | +| TC-03 | text-port-quality-jitter | P781-惠州江北-PTN7900-7-TPA1EG24-21 | 专线质差 | 2026-05-22 | event-id-20260522-00812 | 丢包 | +| TC-04 | text-port-interruption-route | P781-南沙-PTN7900-9-TPA1EG24-31 | 专线中断 | 2026-06-03 | event-id-20260603-02341 | 彻底不通 | +| TC-05 | text-port-quality-latency | P781-天河体育西-PTN7900-4-TPA1EG24-11 | 专线质差 | 2026-06-08 | event-id-20260608-01102 | 时延 | +| TC-06 | text-logical-port-interruption-vlan | P781-宝安中心-PTN7900-6-TPA1EG24-27(cvlan=200) | 专线中断 | 2026-06-12 | fault-id-9-023-20260612-04578 | 连不上 | + +## 用例二:generateTaskPromptFromDataWithSchema(结构化英文业务字段 + 语义 schema) + +| # | 样例名 | 端口 | 分类 | 时间(ISO-8601) | 流水号 | 详情期望关键词 | +|---|---|---|---|---|---|---| +| TC-07 | data-port-quality | P781-福田中心-PTN7900-2-TPA1EG24-03 | 专线质差 | 2026-05-11T08:21:46Z | event-id-20260511-09013 | 320ms | +| TC-08 | data-logical-port-interruption | P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100) | 专线中断 | 2026-05-16T12:10:00Z | fault-id-1-017-20260516-11234 | 完全中断 | +| TC-09 | data-port-jitter | P781-惠州江北-PTN7900-7-TPA1EG24-21 | 专线质差 | 2026-05-22T14:05:00Z | event-id-20260522-00812 | 时延抖动 | +| TC-10 | data-logical-port-interruption-route | P781-云浮-PTN7900-1-TPA1EG24-08(cvlan=300) | 专线中断 | 2026-06-03T09:40:00Z | event-id-20260603-02341 | 完全中断 | +| TC-11 | data-port-quality-loss | P781-珠海吉大-PTN7900-6-TPA1EG24-13 | 专线质差 | 2026-06-08T15:20:00Z | event-id-20260608-01102 | 丢包 | +| TC-12 | data-port-quality-core-slow | P781-罗湖东门-PTN7900-8-TPA1EG24-15 | 专线质差 | 2026-06-18T10:05:00Z | event-id-20260618-03342 | 时延 | + +> 说明: +> 1. 用例一(text)的时间期望取日期前缀(如 `2026-05-11`)——口语文本不含 ISO 字面量,日期是稳定事实; +> 用例二(data)的时间期望取完整 ISO-8601 时间戳。 +> 2. 期望值均按**服务端 key** 记分(`accessPort`/`bizScenario`/`faultTime`/`eventSerialNo`/`faultDetail`), +> 与 `validateAndFillingTaskData` 实际输出一致。 +> 3. 详情期望为各样本的关键事实关键词,命中规则较宽松(等价或互相包含);如需严谨评测可收紧期望值并扩充样本。 \ No newline at end of file From 48d63224dfa44c0c142ef571e88865bde1026051 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 11:46:12 +0800 Subject: [PATCH 099/295] feat(llm): support A2AT_LLM_REASONING_EFFORT and guard against empty responses - LLMClientConfig gains an optional reasoningEffort field loaded from the A2AT_LLM_REASONING_EFFORT env key (none/low/medium/high) for reasoning models - OpenAIClient applies the reasoning effort when present and fails fast with LLMRuntimeError when the provider returns empty content (rate limit or model timeout) instead of letting the JSON parser fail with a bare parse error - update the module tests for the new record component Signed-off-by: ZhouJie6 --- .../java/net/openan/a2at/sdk/llm/LLMClientConfig.java | 4 +++- .../java/net/openan/a2at/sdk/llm/LLMConfigLoader.java | 8 ++++---- .../net/openan/a2at/sdk/llm/providers/OpenAIClient.java | 7 +++++++ .../java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java | 4 +++- .../net/openan/a2at/sdk/llm/LLMClientFactoryTest.java | 2 +- .../openan/a2at/sdk/llm/providers/OpenAIClientTest.java | 5 +++-- 6 files changed, 21 insertions(+), 9 deletions(-) diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java index 05fc10e1..5ef4270d 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java @@ -13,6 +13,7 @@ * @param timeoutSeconds optional provider timeout in seconds * @param sessionMaxTotal reserved total session limit * @param sessionMaxPerProvider reserved per-provider session limit + * @param reasoningEffort optional reasoning effort for reasoning models (none/low/medium/high) * @since 2026-06 */ public record LLMClientConfig( @@ -25,4 +26,5 @@ public record LLMClientConfig( Double temperature, Double timeoutSeconds, int sessionMaxTotal, - int sessionMaxPerProvider) {} + int sessionMaxPerProvider, + String reasoningEffort) {} diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java index 0841d928..c1f54895 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java @@ -50,9 +50,8 @@ public static LLMClientConfig load(Path envPath) { DEFAULT_SESSION_MAX_PER_PROVIDER, MAX_SESSION_MAX_PER_PROVIDER); if (sessionMaxTotal < sessionMaxPerProvider) { - throw new LLMConfigError( - "A2AT_LLM_SESSION_MAX_TOTAL must be greater than or equal to " - + "A2AT_LLM_SESSION_MAX_PER_PROVIDER"); + throw new LLMConfigError("A2AT_LLM_SESSION_MAX_TOTAL must be greater than or equal to " + + "A2AT_LLM_SESSION_MAX_PER_PROVIDER"); } OptionalInt maxTokens = parseOptionalInt(values.get("A2AT_LLM_MAX_TOKENS"), "A2AT_LLM_MAX_TOKENS"); @@ -70,7 +69,8 @@ public static LLMClientConfig load(Path envPath) { temperature.isPresent() ? temperature.getAsDouble() : null, timeoutSeconds.isPresent() ? timeoutSeconds.getAsDouble() : null, sessionMaxTotal, - sessionMaxPerProvider); + sessionMaxPerProvider, + optional(values.get("A2AT_LLM_REASONING_EFFORT")).orElse(null)); } private static String required(Map values, String key) { diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java index f9b21903..47a1faea 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.ReasoningEffort; import com.openai.models.ResponseFormatJsonObject; import com.openai.models.chat.completions.ChatCompletion; import com.openai.models.chat.completions.ChatCompletionCreateParams; @@ -87,6 +88,9 @@ private ChatCompletionCreateParams buildStructuredParams( if (resolvedMaxTokens != null) { builder.maxTokens(resolvedMaxTokens); } + if (config.reasoningEffort() != null && !config.reasoningEffort().isBlank()) { + builder.reasoningEffort(ReasoningEffort.of(config.reasoningEffort())); + } return builder.build(); } @@ -132,6 +136,9 @@ private LLMResponse parseResponse(ChatCompletion response) { private String extractJsonObjectString(ChatCompletion response) { String rawContent = extractMessageText(response); try { + if (rawContent == null || rawContent.isBlank()) { + throw new LLMRuntimeError(config.provider() + " returned empty content (rate limit or model timeout)"); + } Object parsed = OBJECT_MAPPER.readValue(rawContent, Object.class); if (!(parsed instanceof Map)) { throw new LLMRuntimeError(config.provider() + " must return a JSON object string"); diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java index 1869ad40..bc7a32cf 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java @@ -18,7 +18,8 @@ void storesResolvedDefaultLlmClientConfiguration() { 0.2d, 15.5d, 300, - 100); + 100, + "none"); assertEquals("openai", config.provider()); assertEquals("gpt-4o-mini", config.model()); @@ -30,5 +31,6 @@ void storesResolvedDefaultLlmClientConfiguration() { assertEquals(15.5d, config.timeoutSeconds()); assertEquals(300, config.sessionMaxTotal()); assertEquals(100, config.sessionMaxPerProvider()); + assertEquals("none", config.reasoningEffort()); } } diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java index 5eb42b23..49db4165 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java @@ -82,7 +82,7 @@ void rejectsProviderClassWithoutPublicConfigConstructor() { } private static LLMClientConfig buildConfig(String provider) { - return new LLMClientConfig(provider, "test-model", "sk-test", null, 10, null, null, null, 300, 100); + return new LLMClientConfig(provider, "test-model", "sk-test", null, 10, null, null, null, 300, 100, null); } public static final class CustomClient implements LLMClient { diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java index e8b18df8..dca661e3 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java @@ -98,7 +98,8 @@ void structuredBuildsJsonModePayloadAndParsesJsonObjectResponse() { void structuredFallsBackToConfigTemperatureAndMaxTokens() { AtomicReference capturedParams = new AtomicReference<>(); LLMClientConfig config = new LLMClientConfig( - "openai", "gpt-4o-mini", "sk-test", "https://api.example.test/v1", 10, 128, 0.4d, null, 300, 100); + "openai", "gpt-4o-mini", "sk-test", "https://api.example.test/v1", 10, 128, 0.4d, null, 300, 100, + null); OpenAIClient client = new OpenAIClient(config, (runtimeConfig, requestParams) -> { capturedParams.set(requestParams); return chatCompletion("{}"); @@ -203,7 +204,7 @@ void concurrentStructuredCallsAllSucceed() throws Exception { } private static LLMClientConfig config(String apiKey, String baseUrl) { - return new LLMClientConfig("openai", "gpt-4o-mini", apiKey, baseUrl, 10, null, null, null, 300, 100); + return new LLMClientConfig("openai", "gpt-4o-mini", apiKey, baseUrl, 10, null, null, null, 300, 100, null); } private static ChatCompletion chatCompletion(String content) { From 3a376fc0a67b617c4df306592cc3968aed9089b2 Mon Sep 17 00:00:00 2001 From: qinyanhao01 Date: Sat, 22 Aug 2026 15:38:44 +0800 Subject: [PATCH 100/295] chore(sample): add task-t client env template Signed-off-by: qinyanhao01 --- .../src/main/resources/sample/task_t/client.env | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 a2a-t-sample/src/main/resources/sample/task_t/client.env diff --git a/a2a-t-sample/src/main/resources/sample/task_t/client.env b/a2a-t-sample/src/main/resources/sample/task_t/client.env new file mode 100644 index 00000000..aea55e61 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/task_t/client.env @@ -0,0 +1,12 @@ +# Configure this file to run the Task-T accuracy demo +# (net.openan.a2at.sample.task_t.TaskTDemoMain). +# The demo builds A2ATClient and A2ATServer from the same env file and needs +# an OpenAI-compatible LLM endpoint; fill in a working API key before running. +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=classpath +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL= +A2AT_LLM_BASE_URL= +A2AT_LLM_API_KEY= +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory \ No newline at end of file From d98a8e1a167c623dc8b5335b4e016cb21dcd7479 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 11:45:40 +0800 Subject: [PATCH 101/295] fix(prompt): load content_validation prompts with .md extension and drop null param values - DefaultSemanticValidator passed 'system'/'user' to loadPrompt but the bundled resources are system.md/user.md, so every local_file and classpath resolution threw ResourceNotFoundException and validateAndFillingTaskData never worked - parseParams fed LLM-returned null values into Map.copyOf, which rejects nulls, turning an extractable partial result into a NullPointerException - add DefaultSemanticValidatorTest covering both fixes Fixes #110 Signed-off-by: ZhouJie6 --- .../validation/DefaultSemanticValidator.java | 12 ++- .../DefaultSemanticValidatorTest.java | 85 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index e0dfb33a..0d227423 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -7,8 +7,8 @@ import java.util.List; import java.util.Map; import java.util.regex.Matcher; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.json.JacksonJsonValueParser; import net.openan.a2at.sdk.core.json.JsonValueParser; import net.openan.a2at.sdk.core.model.PromptMessage; @@ -52,7 +52,7 @@ final class DefaultSemanticValidator implements SemanticValidator { * @param promptResourceAccess prompt resource access for loading validation prompts * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if prompt resources are missing */ - public DefaultSemanticValidator( + DefaultSemanticValidator( @Nullable LLMClient llmClient, @NonNull String language, @NonNull PromptResourceAccess promptResourceAccess) { @@ -177,11 +177,15 @@ private static Map parseParams(Object paramsValue) { return Map.of(); } Map normalized = new LinkedHashMap<>(); - params.forEach((key, value) -> normalized.put(String.valueOf(key), value)); + params.forEach((key, value) -> { + if (value != null) { + normalized.put(String.valueOf(key), value); + } + }); return Map.copyOf(normalized); } private static String asString(Object value) { return value instanceof String text ? text : ""; } -} \ No newline at end of file +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java index e7df1fd1..f7d1628a 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java @@ -21,6 +21,13 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.llm.LLMRuntimeError; import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; +import static org.junit.jupiter.api.Assertions.assertFalse; +import java.nio.file.Path; +import java.util.ArrayList; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; import org.junit.jupiter.api.Test; class DefaultSemanticValidatorTest { @@ -127,6 +134,23 @@ void validateExhaustsRetriesAndWrapsLlmError() { assertEquals(3, flakyClient.invocations()); } + + @Test + void validateFiltersNullParamValues() { + String llmJson = + "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; + DefaultSemanticValidator validator = + new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN", new RecordingResourceAccess()); + + ValidationResult result = + validator.validate("task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer")); + + assertTrue(result.verdict()); + // Map.copyOf rejects null values; the validator must drop them instead of throwing NPE + assertEquals(Map.of("任务对象", "端口A"), result.params()); + assertFalse(result.params().containsKey("任务上下文")); + } + private static final class RecordingClient implements LLMClient { private final String payload; @@ -188,4 +212,65 @@ int invocations() { return counter.get(); } } + + /** Stub LLM client returning a fixed content payload. */ + private static final class StubClient implements LLMClient { + + private final String content; + + private StubClient(String content) { + this.content = content; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(content, "stub-llm", Map.of(), Map.of()); + } + } + + /** Resource access recording loadPrompt calls and returning stub prompt text. */ + private static final class RecordingResourceAccess implements PromptResourceAccess { + + private final List calls = new ArrayList<>(); + + @Override + public boolean classpath() { + return false; + } + + @Override + public ClasspathPromptResourceLoader classpathResourceLoader() { + throw new UnsupportedOperationException("Not used in this test."); + } + + @Override + public Path localRootDir() { + return Path.of("."); + } + + @Override + public List loadScenarios(String language) { + return List.of(); + } + + @Override + public PromptTemplateTextLoader templateLoader() { + throw new UnsupportedOperationException("Not used in this test."); + } + + @Override + public PromptSlotSchemaLoader slotSchemaLoader() { + throw new UnsupportedOperationException("Not used in this test."); + } + + @Override + public String loadPrompt(String promptCategory, String language, String fileName) { + calls.add(new String[] {promptCategory, language, fileName}); + return "stub prompt for " + promptCategory + "/" + fileName; + } + } } From b062c38b4a66aafd4221cd3a5fbcce8cf6007797 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 16:16:09 +0800 Subject: [PATCH 102/295] fix(llm): validate A2AT_LLM_REASONING_EFFORT at config load and document the key - reject unsupported reasoning effort values with LLMConfigError at configuration load time instead of surfacing a provider 400 on the first LLM call; accepted values mirror the OpenAI API enum (none/minimal/low/medium/high/xhigh) and are case-normalized - fix the LLMClientConfig javadoc: it listed none/low/medium/high, missing minimal and xhigh - document the key in the English and Chinese developer guides - add loader tests: value normalization, blank key stays unset, unknown value fails fast Signed-off-by: ZhouJie6 --- .../openan/a2at/sdk/llm/LLMClientConfig.java | 3 +- .../openan/a2at/sdk/llm/LLMConfigLoader.java | 27 +++++++- .../a2at/sdk/llm/LLMConfigLoaderTest.java | 64 +++++++++++++++++-- .../sdk/llm/providers/OpenAIClientTest.java | 3 +- docs/en/developer_guide.md | 5 ++ ...00\345\217\221\346\214\207\345\215\227.md" | 4 ++ 6 files changed, 96 insertions(+), 10 deletions(-) diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java index 5ef4270d..dfec60e5 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java @@ -13,7 +13,8 @@ * @param timeoutSeconds optional provider timeout in seconds * @param sessionMaxTotal reserved total session limit * @param sessionMaxPerProvider reserved per-provider session limit - * @param reasoningEffort optional reasoning effort for reasoning models (none/low/medium/high) + * @param reasoningEffort optional reasoning effort for reasoning models (none/minimal/low/medium/high/xhigh); null + * leaves the parameter unset * @since 2026-06 */ public record LLMClientConfig( diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java index c1f54895..a45c333a 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java @@ -26,6 +26,10 @@ public final class LLMConfigLoader { private static final int MAX_SESSION_MAX_PER_PROVIDER = 1000; + /** Reasoning effort levels accepted by A2AT_LLM_REASONING_EFFORT (mirrors the OpenAI API enum). */ + private static final java.util.Set REASONING_EFFORT_VALUES = + java.util.Set.of("none", "minimal", "low", "medium", "high", "xhigh"); + private LLMConfigLoader() {} public static LLMClientConfig load(Path envPath) { @@ -70,7 +74,7 @@ public static LLMClientConfig load(Path envPath) { timeoutSeconds.isPresent() ? timeoutSeconds.getAsDouble() : null, sessionMaxTotal, sessionMaxPerProvider, - optional(values.get("A2AT_LLM_REASONING_EFFORT")).orElse(null)); + parseReasoningEffort(values.get("A2AT_LLM_REASONING_EFFORT"))); } private static String required(Map values, String key) { @@ -130,4 +134,25 @@ private static int parseBoundedInt(String rawValue, String key, int defaultValue } return parsed; } + + /** + * Parses the optional reasoning effort level. + * + * @param rawValue raw value of A2AT_LLM_REASONING_EFFORT; null or blank means the key is not set + * @return the validated effort level, or null when the key is not set + * @throws LLMConfigError when the value is not one of the supported levels; failing at config load time beats + * discovering a bad value through a provider 400 on the first LLM call + */ + private static String parseReasoningEffort(String rawValue) { + Optional value = optional(rawValue); + if (!value.isPresent()) { + return null; + } + String effort = value.orElseThrow().toLowerCase(java.util.Locale.ROOT); + if (!REASONING_EFFORT_VALUES.contains(effort)) { + throw new LLMConfigError( + "A2AT_LLM_REASONING_EFFORT must be one of " + REASONING_EFFORT_VALUES + " but was " + effort); + } + return effort; + } } diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java index 6a955bf4..61debebf 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java @@ -18,7 +18,8 @@ class LLMConfigLoaderTest { @Test void loadsRequiredValuesAndDefaultsFromEnvFile() throws IOException { - Path envFile = writeEnv(""" + Path envFile = writeEnv( + """ A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=gpt-4o-mini A2AT_LLM_API_KEY=sk-test @@ -40,7 +41,8 @@ void loadsRequiredValuesAndDefaultsFromEnvFile() throws IOException { @Test void loadsOptionalValuesFromEnvFile() throws IOException { - Path envFile = writeEnv(""" + Path envFile = writeEnv( + """ A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=gpt-4o-mini A2AT_LLM_API_KEY=sk-test @@ -66,7 +68,8 @@ void loadsOptionalValuesFromEnvFile() throws IOException { @Test void rejectsMissingRequiredValues() throws IOException { - Path envFile = writeEnv(""" + Path envFile = writeEnv( + """ A2AT_LLM_PROVIDER=openai A2AT_LLM_API_KEY=sk-test """); @@ -79,7 +82,8 @@ void rejectsMissingRequiredValues() throws IOException { @Test void rejectsInvalidOptionalNumber() throws IOException { - Path envFile = writeEnv(""" + Path envFile = writeEnv( + """ A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=gpt-4o-mini A2AT_LLM_API_KEY=sk-test @@ -94,7 +98,8 @@ void rejectsInvalidOptionalNumber() throws IOException { @Test void rejectsOutOfRangeReservedValues() throws IOException { - Path envFile = writeEnv(""" + Path envFile = writeEnv( + """ A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=gpt-4o-mini A2AT_LLM_API_KEY=sk-test @@ -109,7 +114,8 @@ void rejectsOutOfRangeReservedValues() throws IOException { @Test void rejectsSessionTotalSmallerThanPerProviderLimit() throws IOException { - Path envFile = writeEnv(""" + Path envFile = writeEnv( + """ A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=gpt-4o-mini A2AT_LLM_API_KEY=sk-test @@ -123,6 +129,52 @@ void rejectsSessionTotalSmallerThanPerProviderLimit() throws IOException { assertTrue(error.getMessage().contains("A2AT_LLM_SESSION_MAX_PER_PROVIDER")); } + @Test + void loadsReasoningEffortAndNormalizesCase() throws IOException { + Path envFile = writeEnv( + """ + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=gpt-4o-mini + A2AT_LLM_API_KEY=sk-test + A2AT_LLM_REASONING_EFFORT=HIGH + """); + + LLMClientConfig config = LLMConfigLoader.load(envFile); + + assertEquals("high", config.reasoningEffort()); + } + + @Test + void leavesReasoningEffortUnsetWhenKeyIsBlank() throws IOException { + Path envFile = writeEnv( + """ + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=gpt-4o-mini + A2AT_LLM_API_KEY=sk-test + A2AT_LLM_REASONING_EFFORT= + """); + + LLMClientConfig config = LLMConfigLoader.load(envFile); + + assertNull(config.reasoningEffort()); + } + + @Test + void rejectsUnknownReasoningEffortValue() throws IOException { + Path envFile = writeEnv( + """ + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=gpt-4o-mini + A2AT_LLM_API_KEY=sk-test + A2AT_LLM_REASONING_EFFORT=middle + """); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMConfigLoader.load(envFile)); + + assertTrue(error.getMessage().contains("A2AT_LLM_REASONING_EFFORT")); + assertTrue(error.getMessage().contains("middle")); + } + private Path writeEnv(String content) throws IOException { Path envFile = tempDir.resolve("llm.env"); Files.writeString(envFile, content); diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java index dca661e3..cec19226 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java @@ -98,8 +98,7 @@ void structuredBuildsJsonModePayloadAndParsesJsonObjectResponse() { void structuredFallsBackToConfigTemperatureAndMaxTokens() { AtomicReference capturedParams = new AtomicReference<>(); LLMClientConfig config = new LLMClientConfig( - "openai", "gpt-4o-mini", "sk-test", "https://api.example.test/v1", 10, 128, 0.4d, null, 300, 100, - null); + "openai", "gpt-4o-mini", "sk-test", "https://api.example.test/v1", 10, 128, 0.4d, null, 300, 100, null); OpenAIClient client = new OpenAIClient(config, (runtimeConfig, requestParams) -> { capturedParams.set(requestParams); return chatCompletion("{}"); diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 4b108120..9241b0c0 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -116,6 +116,11 @@ A2AT_LLM_API_KEY={your_api_key} A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory ``` +For reasoning models, the optional `A2AT_LLM_REASONING_EFFORT` key tunes the reasoning effort passed to the +provider: one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh` (case-insensitive). Leave the key unset for +non-reasoning models — the parameter is then not sent at all. An unsupported value fails at configuration load +time with an `LLMConfigError` instead of surfacing as a provider error on the first LLM call. + ## 1.6 SDK Basic Usage diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 094a3a8f..a727a5ae 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -116,6 +116,10 @@ A2AT_LLM_API_KEY={your_api_key} A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory ``` +推理模型可选用 `A2AT_LLM_REASONING_EFFORT` 调整传递给提供方的推理力度,取值为 `none`、`minimal`、`low`、 +`medium`、`high`、`xhigh` 之一(大小写不敏感)。非推理模型保持该键不设即可——此时不会向提供方发送该参数。 +取值不受支持时会在配置加载阶段以 `LLMConfigError` 快速失败,而不是等到首次 LLM 调用才报提供方错误。 + ## 1.6 SDK 基本用法 From c700034b12b7d085e4c1e3f302111132151e5e21 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 13:33:42 +0800 Subject: [PATCH 103/295] fix(prompt): keep single-brace example text verbatim instead of failing as unknown slot The placeholder regex accepted both {{slot}} and {slot} forms, so example prose in requirement sections such as the energy-saving template's rate guarantee examples ({00:00~06:00,2Mbps}) was misread as an unknown slot placeholder and rendering failed with 'Unknown slot referenced by template' for every energy-saving prompt. The energy-saving Task-T template could never render. Single-brace text that does not match a known slot name is now kept verbatim in the rendered prompt (it is example prose); only double-braced {{slot}} placeholders and single-brace names matching a known slot are substituted, and unknown double-braced placeholders still fail fast. - add a regression test rendering the rate-guarantee example line - tighten the unknown-slot test to the double-braced form Signed-off-by: ZhouJie6 --- .../prompt/taskrendering/TaskPromptRenderer.java | 14 ++++++++++++++ .../taskrendering/TaskPromptRendererTest.java | 10 +++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java index 096c3abd..b8dca1db 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java @@ -18,6 +18,15 @@ public final class TaskPromptRenderer implements SectionedTemplateRenderer { private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{?\\s*([^{}]+?)\\s*\\}\\}?"); + + /** + * Matches the literal single-brace example values a template may carry in its requirement prose (for example + * {@code {00:00~06:00,2Mbps}}). Such text is never a slot placeholder; skipping it keeps example values verbatim + * in the rendered prompt instead of failing with an unknown-slot error. + */ + private static boolean isKnownSlotPlaceholder(String candidate, Map slots) { + return slots.containsKey(candidate); + } private static final Pattern SECTION_HEADER_PATTERN = Pattern.compile("^##\\s+.+$"); private static final Pattern STANDALONE_SLOT_LINE_PATTERN = Pattern.compile( "^\\s*(\\{\\{?\\s*[^{}]+?\\s*\\}\\}?)(?:\\s*(?:\\(Required\\)|\\(Optional\\)|(必选)|(可选)))?\\s*$"); @@ -41,7 +50,12 @@ public String render(String templateText, Map slots) { StringBuffer rendered = new StringBuffer(); while (matcher.find()) { String slotName = matcher.group(1).trim(); + boolean doubleBraced = matcher.group(0).startsWith("{{"); if (!safeSlots.containsKey(slotName)) { + if (!doubleBraced && !isKnownSlotPlaceholder(slotName, safeSlots)) { + // single-brace text that is not a slot is example prose (e.g. {00:00~06:00,2Mbps}); keep verbatim + continue; + } throw new TaskPromptRenderException("Unknown slot referenced by template: " + slotName); } String replacement = safeSlots.get(slotName); diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRendererTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRendererTest.java index 47839c26..4139c50a 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRendererTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRendererTest.java @@ -106,7 +106,15 @@ void renderPreservesRegularInlinePlaceholderContent() { void renderRaisesWhenTemplateReferencesUnknownSlot() { assertThrows( TaskPromptRenderException.class, - () -> renderer.render("Site: {site}\nTime Range: {time_range}", Map.of("site", "Site A"))); + () -> renderer.render("Site: {{site}}\nTime Range: {{time_range}}", Map.of("site", "Site A"))); + } + + @Test + void renderKeepsSingleBraceExampleTextVerbatim() { + // example prose such as "{00:00~06:00,2Mbps}" inside requirement text is not a slot placeholder + String prompt = renderer.render( + "Rate target: {00:00~06:00,2Mbps}\nSite: {{site}}", Map.of("site", "Site A")); + assertEquals("Rate target: {00:00~06:00,2Mbps}\nSite: Site A", prompt); } @Test From e874e35183c9afab29041b4f08b7731b55915793 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 14:19:28 +0800 Subject: [PATCH 104/295] fix(client): use double-braced unknown slot to trigger render failure in tests The three render-exception propagation tests triggered the failure via a single-brace unknown slot {missing_slot}. After the renderer fix, single- brace text that matches no known slot is example prose kept verbatim, so those triggers no longer raise. Switch them to the double-braced {{missing_slot}} form, which still fails fast, preserving the tests' intent of asserting render_failed propagation. Signed-off-by: ZhouJie6 --- .../DefaultClientPromptGenerationOrchestratorTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 53df4f07..1d9285f8 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -127,7 +127,7 @@ void generateTaskPromptReturnsFailureWhenRenderingFails() { "en-US", "Identify the best matching scenario.", "Choose from the provided scenario list.", - (scenarioCode, language) -> "Scenario: {scenario}\nMissing: {missing_slot}", + (scenarioCode, language) -> "Scenario: {scenario}\nMissing: {{missing_slot}}", (userInput, scenarioCode, language, templateText) -> Map.of("scenario", scenarioCode), new TaskPromptRenderer(), EMPTY_SCHEMA_LOADER); @@ -380,7 +380,7 @@ void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { void generateFromTemplateUriWithMetadataPropagatesRenderException() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}\nMissing: {missing_slot}"), + new FakeTemplateLoader("Site: {site}\nMissing: {{missing_slot}}"), new FakeSlotValueExtractor(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows( @@ -489,7 +489,7 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundException() { void generateFromDataWithSchemaPropagatesRenderException() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}\nMissing: {missing_slot}"), + new FakeTemplateLoader("Site: {site}\nMissing: {{missing_slot}}"), new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows( From 952baad79535fd227c527b5ba9f982d88bba89c6 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 22 Aug 2026 15:29:05 +0800 Subject: [PATCH 105/295] test: update extractor schema injection test to expect JSON-serialized schema Signed-off-by: w00609601 --- ...efaultStructuredPromptSlotValueExtractorTest.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java index 86762256..26f8890f 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java @@ -115,15 +115,19 @@ void should_injectDataSchemaSection_When_schemaIsProvided() { "Return slots as JSON."); Map dataSchema = Map.of( - "site", "The target site name", - "severity", "The severity level"); + "type", "object", + "properties", Map.of( + "site", Map.of("type", "string", "description", "The target site name"), + "severity", Map.of("type", "string", "description", "The severity level")), + "required", List.of("site")); extractor.extractSlots("Analyze Site A.", "energy-saving", "en-US", dataSchema); String userMessage = llmClient.lastUserContent(); assertTrue(userMessage.contains("[data_schema]")); - assertTrue(userMessage.contains("- site: The target site name")); - assertTrue(userMessage.contains("- severity: The severity level")); + assertTrue(userMessage.contains("\"type\":\"object\"")); + assertTrue(userMessage.contains("\"properties\"")); + assertTrue(userMessage.contains("\"The target site name\"")); } @Test From ed326c0147fe3bbbf9e11f423398744543ac4b5c Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 22 Aug 2026 15:30:45 +0800 Subject: [PATCH 106/295] feat: serialize FromDataWithSchema data schema as JSON in LLM user prompt Signed-off-by: w00609601 --- .../impl/DefaultStructuredPromptSlotValueExtractor.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java index 55dce6e8..d89ad853 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java @@ -94,8 +94,10 @@ private List buildMessages( + slotLines; if (dataSchema != null && !dataSchema.isEmpty()) { content += "\n\n[data_schema]\n"; - for (Map.Entry entry : dataSchema.entrySet()) { - content += "- " + entry.getKey() + ": " + String.valueOf(entry.getValue()) + "\n"; + try { + content += OBJECT_MAPPER.writeValueAsString(dataSchema); + } catch (JsonProcessingException e) { + throw new A2ATError("Failed to serialize data schema to JSON.", e); } } return List.of(new PromptMessage("system", systemPrompt), new PromptMessage("user", content)); From 711035663246dea4aa6b024fa9dcddecf9e74d3f Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 22 Aug 2026 15:40:25 +0800 Subject: [PATCH 107/295] test: update client extractor schema test to use JSON Schema format Signed-off-by: w00609601 --- .../DefaultStructuredClientSlotValueExtractorTest.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java index ee15ff81..eeefa201 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java @@ -92,7 +92,12 @@ void extractSlotsWithSchemaCallsDelegateFourArgMethod() { "Extract slots from the input.", "Return slots as JSON."); - Map dataSchema = Map.of("site", "site description", "severity", "severity description"); + Map dataSchema = Map.of( + "type", "object", + "properties", Map.of( + "site", Map.of("type", "string", "description", "site description"), + "severity", Map.of("type", "string", "description", "severity description")), + "required", List.of("site")); Map slots = extractor.extractSlotsWithSchema( "Analyze Site A with critical severity.", "energy-saving", @@ -108,7 +113,8 @@ void extractSlotsWithSchemaCallsDelegateFourArgMethod() { "severity", "high"), slots); assertTrue(llmClient.lastMessages().get(1).get("content").contains("[data_schema]")); - assertTrue(llmClient.lastMessages().get(1).get("content").contains("site: site description")); + assertTrue(llmClient.lastMessages().get(1).get("content").contains("\"site\"")); + assertTrue(llmClient.lastMessages().get(1).get("content").contains("\"site description\"")); } @Test From a820485c17a8b0266726c9774adfee7dbf8ab299 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 22 Aug 2026 17:12:16 +0800 Subject: [PATCH 108/295] refactor: rename validateAndFilling*Data to validate*PromptAndDataFilling Signed-off-by: w00609601 --- README.md | 2 +- README_zh.md | 2 +- .../openan/a2at/sdk/client/A2ATClient.java | 22 ++-- .../generation/NegotiationContentService.java | 16 +-- .../NegotiationGenerationOrchestrator.java | 24 ++-- .../NegotiationConcurrentMixedLoadTest.java | 4 +- .../AbortMessageGenerationTest.java | 6 +- .../CustomRootPromptsIgnoredTest.java | 2 +- .../generation/FromTextLlmPipelineTest.java | 2 +- ...nerationOrchestratorBuilderWiringTest.java | 4 +- ...NegotiationGenerationOrchestratorTest.java | 6 +- .../ValidateAndFillingDataPipelineTest.java | 32 ++--- ...tAndDataFillingSignatureContractTest.java} | 120 +++++++++--------- .../NegotiationErrorCodeUsageMatrixTest.java | 12 +- ...NegotiationExceptionTreeRootCatchTest.java | 12 +- .../NegotiationLogEventContractTest.java | 8 +- ...NegotiationPublicExceptionSurfaceTest.java | 4 +- .../BilateralInteropScenarioTest.java | 6 +- .../FeasibilityNegotiationScenarioTest.java | 4 +- .../InformationNegotiationScenarioTest.java | 6 +- .../MaxRoundsExhaustionScenarioTest.java | 4 +- .../TargetNegotiationScenarioTest.java | 6 +- .../api/NegotiationFacadeApiSymmetryTest.java | 6 +- .../NegotiationFacadeOutputSymmetryTest.java | 6 +- .../api/NegotiationV3ApiSurfaceTest.java | 16 +-- .../openan/a2at/sdk/server/A2ATServer.java | 28 ++-- .../TaskPromptNegotiationCoexistenceTest.java | 2 +- docs/en/developer_guide.md | 16 +-- ...00\345\217\221\346\214\207\345\215\227.md" | 16 +-- 29 files changed, 197 insertions(+), 197 deletions(-) rename a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/{ValidateAndFillingSignatureContractTest.java => ValidatePromptAndDataFillingSignatureContractTest.java} (85%) diff --git a/README.md b/README.md index f6ba5852..744d71ca 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ This SDK is primarily aimed at two types of users: - Client API: Provides a task prompt generation result stream, along with negotiation entry points such as `start_negotiation`, `receive_negotiation`, and `continue_negotiation`. - Server validation API: Targets A2A-T protocol messages that conform to the SDK format, performing metadata parsing, slot extraction. - Negotiation types: Includes four built-in negotiation types: `information`. -- Negotiation-T content layer: Template-driven negotiation message generation (`fromData` deterministic, `fromText` with one LLM extraction step), compliance checking with parameter extraction (`validateAndFilling*Data`), and negotiation template queries. See the [Developer Guide §1.10](docs/en/developer_guide.md#110-negotiation-t-content-layer). +- Negotiation-T content layer: Template-driven negotiation message generation (`fromData` deterministic, `fromText` with one LLM extraction step), compliance checking with parameter extraction (`validatePromptAndDataFilling*Data`), and negotiation template queries. See the [Developer Guide §1.10](docs/en/developer_guide.md#110-negotiation-t-content-layer). - Resource organization: Built-in prompt resources are located in `a2a-t-resources/src/main/resources/prompt_resources`, containing `prompts`, `scenarios`, `slots`, and `templates`. - Built-in example scenario: Currently, the package provides scenarios such as`subscribe_incident`. diff --git a/README_zh.md b/README_zh.md index d9db6dd3..e8abd919 100644 --- a/README_zh.md +++ b/README_zh.md @@ -51,7 +51,7 @@ SPDX-License-Identifier: Apache-2.0 - 客户端 API:提供任务提示词生成结果流,以及 `start_negotiation`、`receive_negotiation`、`continue_negotiation` 等协商入口。 - 服务端校验 API:面向符合 SDK 格式的 `processed task prompt`,执行元数据解析、槽位提取和槽位校验。 - 协商类型:内置 `information` 协商类型。 -- Negotiation-T 内容层:以模板为驱动的协商消息生成(`fromData` 确定性执行、`fromText` 含一次 LLM 抽取)、合规校验与参数提取(`validateAndFilling*Data`)以及协商模板查询。参见[开发指南 §1.10](docs/zh/开发指南.md)。 +- Negotiation-T 内容层:以模板为驱动的协商消息生成(`fromData` 确定性执行、`fromText` 含一次 LLM 抽取)、合规校验与参数提取(`validatePromptAndDataFilling*Data`)以及协商模板查询。参见[开发指南 §1.10](docs/zh/开发指南.md)。 - 资源组织:内置提示词资源位于 `a2a-t-resources/src/main/resources/prompt_resources`,包含 `prompts`、`scenarios`、`slots`、`templates`。 - 内置示例场景:当前随包提供 `subscribe_incident`等场景。 diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 5e671f43..9dd32e40 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -516,16 +516,16 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingProposeData( + public FilledParamData validateProposePromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.validateAndFillingProposeData(prompt, schema, templateUri); + return negotiationContentService.validateProposePromptAndDataFilling(prompt, schema, templateUri); } /** * Validates an accept-phase negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + *

The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must * satisfy the accept-phase semantic constraints. * @@ -543,16 +543,16 @@ public FilledParamData validateAndFillingProposeData( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingAcceptData( + public FilledParamData validateAcceptPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.validateAndFillingAcceptData(prompt, schema, templateUri); + return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, schema, templateUri); } /** * Validates a reject-phase negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + *

The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must * satisfy the reject-phase semantic constraints. * @@ -570,16 +570,16 @@ public FilledParamData validateAndFillingAcceptData( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingRejectData( + public FilledParamData validateRejectPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.validateAndFillingRejectData(prompt, schema, templateUri); + return negotiationContentService.validateRejectPromptAndDataFilling(prompt, schema, templateUri); } /** * Validates an abort negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + *

The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the * abort-phase semantic constraints. * @@ -597,9 +597,9 @@ public FilledParamData validateAndFillingRejectData( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingAbortData( + public FilledParamData validateAbortPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.validateAndFillingAbortData(prompt, schema, templateUri); + return negotiationContentService.validateAbortPromptAndDataFilling(prompt, schema, templateUri); } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index 44dce9cd..51a152d7 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -267,9 +267,9 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline * fails */ - public FilledParamData validateAndFillingProposeData( + public FilledParamData validateProposePromptAndDataFilling( String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return orchestrator.validateAndFillingProposeData(prompt, schema, templateUri); + return orchestrator.validateProposePromptAndDataFilling(prompt, schema, templateUri); } /** @@ -286,9 +286,9 @@ public FilledParamData validateAndFillingProposeData( * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline * fails */ - public FilledParamData validateAndFillingAcceptData( + public FilledParamData validateAcceptPromptAndDataFilling( String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return orchestrator.validateAndFillingAcceptData(prompt, schema, templateUri); + return orchestrator.validateAcceptPromptAndDataFilling(prompt, schema, templateUri); } /** @@ -305,9 +305,9 @@ public FilledParamData validateAndFillingAcceptData( * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline * fails */ - public FilledParamData validateAndFillingRejectData( + public FilledParamData validateRejectPromptAndDataFilling( String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return orchestrator.validateAndFillingRejectData(prompt, schema, templateUri); + return orchestrator.validateRejectPromptAndDataFilling(prompt, schema, templateUri); } /** @@ -321,8 +321,8 @@ public FilledParamData validateAndFillingRejectData( * @throws IllegalArgumentException if the template URI does not address the common abort template * @throws NegotiationParamExtractionException when the validation pipeline fails */ - public FilledParamData validateAndFillingAbortData( + public FilledParamData validateAbortPromptAndDataFilling( String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return orchestrator.validateAndFillingAbortData(prompt, schema, templateUri); + return orchestrator.validateAbortPromptAndDataFilling(prompt, schema, templateUri); } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 3990a4ed..bcc3c5c1 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -351,15 +351,15 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingProposeData( + public FilledParamData validateProposePromptAndDataFilling( String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return validateAndFilling(prompt, schema, templateUri, NegotiationPhase.PROPOSE); + return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.PROPOSE); } /** * Validates an accept-phase negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + *

The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must * satisfy the accept-phase semantic constraints. * @@ -376,15 +376,15 @@ public FilledParamData validateAndFillingProposeData( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingAcceptData( + public FilledParamData validateAcceptPromptAndDataFilling( String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return validateAndFilling(prompt, schema, templateUri, NegotiationPhase.ACCEPT); + return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.ACCEPT); } /** * Validates a reject-phase negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + *

The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must * satisfy the reject-phase semantic constraints. * @@ -401,15 +401,15 @@ public FilledParamData validateAndFillingAcceptData( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingRejectData( + public FilledParamData validateRejectPromptAndDataFilling( String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return validateAndFilling(prompt, schema, templateUri, NegotiationPhase.REJECT); + return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.REJECT); } /** * Validates an abort negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + *

The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the * abort-phase semantic constraints. * @@ -425,9 +425,9 @@ public FilledParamData validateAndFillingRejectData( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingAbortData( + public FilledParamData validateAbortPromptAndDataFilling( String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return validateAndFilling(prompt, schema, templateUri, NegotiationPhase.ABORT); + return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.ABORT); } private MetadataContent generateFromData( @@ -508,7 +508,7 @@ private MetadataContent completeGeneration( reference.uri(), promptText, ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI); } - private FilledParamData validateAndFilling( + private FilledParamData validatePromptAndDataFilling( String prompt, Map schema, TemplateUri templateUri, NegotiationPhase phase) { Objects.requireNonNull(schema, "Parameter schema must not be null."); NegotiationReference reference = requireReference(templateUri, phase); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java index 784b5484..7533f1df 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java @@ -58,7 +58,7 @@ void everyThreadMatchesItsSerialBaselineWithoutExceptionLeaks() throws Exception MetadataContent fromDataBase = orchestrator.generateProposeFromData(proposeData, INFORMATION_PROPOSE_URI); MetadataContent fromTextBase = orchestrator.generateProposeFromText( "请提供节能区域信息。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); - FilledParamData validateBase = orchestrator.validateAndFillingProposeData( + FilledParamData validateBase = orchestrator.validateProposePromptAndDataFilling( fromDataBase.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); int baselineExtractionCalls = llm.extractionCalls.get(); int baselineSemanticCalls = llm.semanticCalls.get(); @@ -85,7 +85,7 @@ void everyThreadMatchesItsSerialBaselineWithoutExceptionLeaks() throws Exception } else { validateThreads++; workers.add(callableOf(() -> { - FilledParamData filled = orchestrator.validateAndFillingProposeData( + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( fromDataBase.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); assertEquals(validateBase.data(), filled.data(), "validation baseline"); })); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java index 3e29dde5..2018071d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java @@ -187,7 +187,7 @@ void peerValidatesAReceivedAbortMessageAndExtractsItsParameters() { ABORT_URI); FilledParamData parameters = - peer.validateAndFillingAbortData(abortMessage.promptText(), Map.of("type", "object"), ABORT_URI); + peer.validateAbortPromptAndDataFilling(abortMessage.promptText(), Map.of("type", "object"), ABORT_URI); assertEquals(SESSION_ID, parameters.data().get("id")); assertEquals(5, parameters.data().get("round")); @@ -213,7 +213,7 @@ void abortValidationFailsOnBeyondBudgetRoundsBeforeAnyLlmCall() { NegotiationParamExtractionException failure = assertThrows( NegotiationParamExtractionException.class, - () -> peer.validateAndFillingAbortData(beyondBudget.promptText(), Map.of("type", "object"), ABORT_URI)); + () -> peer.validateAbortPromptAndDataFilling(beyondBudget.promptText(), Map.of("type", "object"), ABORT_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, failure.getCode()); assertTrue( failure.getErrors().stream().anyMatch(error -> "round".equals(error.slotName())), @@ -230,7 +230,7 @@ void abortValidationRejectsTypedTemplateUris() { IllegalArgumentException failure = assertThrows( IllegalArgumentException.class, - () -> orchestrator.validateAndFillingAbortData("任意文本", Map.of("type", "object"), + () -> orchestrator.validateAbortPromptAndDataFilling("任意文本", Map.of("type", "object"), StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE)); assertTrue(failure.getMessage().contains("abort")); assertEquals(0, llm.calls); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java index fe4b6c54..1af25d47 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java @@ -76,7 +76,7 @@ void semanticValidationKeepsUsingTheBuiltInSystemPrompt() throws IOException { MetadataContent message = orchestrator.generateProposeFromText( "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); - FilledParamData filled = orchestrator.validateAndFillingProposeData( + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( message.promptText(), Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), INFORMATION_PROPOSE_URI); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 4629e6d8..53a1094d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -506,7 +506,7 @@ void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOEx llm.lastMessages.get(1).get("content").contains("propose"), "the user prompt must carry the phase token"); - FilledParamData filled = orchestrator.validateAndFillingProposeData( + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( message.promptText(), Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), goldenCase.template()); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java index 9816e6dd..dc8dd22d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java @@ -125,7 +125,7 @@ void injectedComplianceCheckerIsUsedByTheValidationPipeline() { .complianceChecker(checker) .build(); - orchestrator.validateAndFillingProposeData( + orchestrator.validateProposePromptAndDataFilling( "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", Map.of("type", "object"), INFORMATION_PROPOSE_URI); @@ -156,7 +156,7 @@ void maxAttemptsReachesBothRetryChains() { .build(); org.junit.jupiter.api.Assertions.assertThrows( net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException.class, - () -> validationOrchestrator.validateAndFillingProposeData( + () -> validationOrchestrator.validateProposePromptAndDataFilling( "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 5850f1a4..5ff2230a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -109,7 +109,7 @@ void validatesAndFillsProposeDataWithScriptedSemanticResponse() { new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE); - FilledParamData filled = orchestrator.validateAndFillingProposeData( + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( message.promptText(), Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), INFORMATION_PROPOSE); @@ -201,7 +201,7 @@ void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { NegotiationParamExtractionException extractionFailure = assertThrows( NegotiationParamExtractionException.class, - () -> validationOrchestrator.validateAndFillingProposeData( + () -> validationOrchestrator.validateProposePromptAndDataFilling( "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", Map.of("type", "object"), INFORMATION_PROPOSE)); @@ -219,7 +219,7 @@ void rejectsNonNegotiationPromptInParameterExtraction() { NegotiationParamExtractionException failure = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateAndFillingProposeData( + () -> orchestrator.validateProposePromptAndDataFilling( "plain text without any negotiation section", Map.of("type", "object"), INFORMATION_PROPOSE)); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java index 0749bdda..c0fc279a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java @@ -62,7 +62,7 @@ void legalProposeMessageRunsTheFullChainWithASingleLlmCall() { String message = informationProposeMessage(orchestrator); FilledParamData filled = - orchestrator.validateAndFillingProposeData(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI); + orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI); assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); @@ -85,7 +85,7 @@ void legalAcceptMessageRunsTheFullChainWithASingleLlmCall() { String message = informationEndingMessage(orchestrator, NegotiationConclusion.ACCEPT); FilledParamData filled = - orchestrator.validateAndFillingAcceptData(message, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); + orchestrator.validateAcceptPromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); @@ -104,7 +104,7 @@ void legalRejectMessageRunsTheFullChainWithASingleLlmCall() { String message = informationEndingMessage(orchestrator, NegotiationConclusion.REJECT); FilledParamData filled = - orchestrator.validateAndFillingRejectData(message, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); + orchestrator.validateRejectPromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); @@ -123,7 +123,7 @@ void ruleViolationsFailBeforeAnyLlmCall( NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateAndFillingProposeData( + () -> orchestrator.validateProposePromptAndDataFilling( violatingMessage, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, exception.getCode(), caseName); @@ -168,7 +168,7 @@ void semanticRejectionIsNotRetriedAndPassesTheErrorsThrough() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateAndFillingProposeData(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(semanticErrors, exception.getErrors()); @@ -185,7 +185,7 @@ void taskTMessagesAreRejectedAsNonNegotiationInputWithALanguageNeutralMessage( NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateAndFillingProposeData(taskTMessage, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(taskTMessage, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, exception.getCode(), caseName); assertTrue(exception.getMessage().contains("checkTaskPrompt"), caseName); @@ -231,7 +231,7 @@ void nestedArrayParamsAreExtractedThroughTheMergedSchema() { NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); String message = targetProposeMessage(orchestrator); - FilledParamData filled = orchestrator.validateAndFillingProposeData(message, callerSchema, TARGET_PROPOSE_URI); + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling(message, callerSchema, TARGET_PROPOSE_URI); assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); @@ -260,7 +260,7 @@ void callerSchemaWithoutATypeKeywordIsWrappedAndTheChainSucceeds() { String message = informationProposeMessage(orchestrator); FilledParamData filled = - orchestrator.validateAndFillingProposeData(message, schemaWithoutType, INFORMATION_PROPOSE_URI); + orchestrator.validateProposePromptAndDataFilling(message, schemaWithoutType, INFORMATION_PROPOSE_URI); assertEquals(1, llm.calls); assertEquals("松山湖", filled.data().get("region")); @@ -282,7 +282,7 @@ void missingNegotiationTypeKeyIsRetriedThenFailsAsAnInfrastructureError() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateAndFillingProposeData(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); assertEquals(3, llm.calls, "a shape-invalid response is a retryable infrastructure failure"); @@ -300,7 +300,7 @@ void declaredTypeMismatchIsASemanticRejection() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateAndFillingProposeData(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(1, llm.calls, "a type mismatch is a semantic decision and must not be retried"); @@ -319,7 +319,7 @@ void structuralSemanticChecksSurfaceThroughTheSemanticErrors( NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateAndFillingProposeData(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode(), caseName); assertEquals(List.of(expectedError), exception.getErrors(), caseName); @@ -371,7 +371,7 @@ void falseVerdictWithNullTypeIsAShapeLegalOutcome() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateAndFillingProposeData(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(1, exception.getErrors().size()); @@ -388,7 +388,7 @@ void trueVerdictWithNullTypeIsASemanticRejection() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateAndFillingProposeData(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(1, llm.calls, "a null type with a true verdict is a semantic rejection, not a retryable failure"); @@ -410,7 +410,7 @@ void semanticLlmFailureIsRetriedAndExhaustsWithTheLlmPseudoSlot() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateAndFillingProposeData(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); assertEquals(2, llm.calls); @@ -434,7 +434,7 @@ void missingPromptResourcesCloseAsTemplateNotFoundWithoutBubblingTheResourceExce NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateAndFillingProposeData( + () -> orchestrator.validateProposePromptAndDataFilling( "## 协商上下文\n- id: " + SESSION_ID + "\n- round: 1\n- maxRounds: 5\n\n## 所需信息项\n1. 区域\n", REGION_SCHEMA, INFORMATION_PROPOSE_URI)); @@ -464,7 +464,7 @@ void proposeUriValidatingAResultMessageIsASemanticRejection() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateAndFillingProposeData( + () -> orchestrator.validateProposePromptAndDataFilling( rejectMessage, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingSignatureContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidatePromptAndDataFillingSignatureContractTest.java similarity index 85% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingSignatureContractTest.java rename to a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidatePromptAndDataFillingSignatureContractTest.java index c960664d..03f901ed 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingSignatureContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidatePromptAndDataFillingSignatureContractTest.java @@ -1,60 +1,60 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; -import org.junit.jupiter.api.Test; - -/** - * Pins the compile-time contract of the validate-and-filling API: the parameter schema and the template URI are - * mandatory parts of the full signature of each of the three methods, so a caller that omits either argument is - * rejected at compile time and no reduced-arity overload can silently appear later. - */ -class ValidateAndFillingSignatureContractTest { - - private static final List VALIDATE_AND_FILLING_METHODS = - List.of("validateAndFillingProposeData", "validateAndFillingAcceptData", "validateAndFillingRejectData"); - - @Test - void everyValidateAndFillingMethodHasExactlyTheFullThreeArgumentSignature() { - for (String methodName : VALIDATE_AND_FILLING_METHODS) { - List declared = Arrays.stream(NegotiationGenerationOrchestrator.class.getDeclaredMethods()) - .filter(method -> method.getName().equals(methodName)) - .toList(); - - assertEquals( - 1, - declared.size(), - methodName + " must exist exactly once: no reduced-arity overload may bypass the mandatory" - + " schema and template URI arguments"); - Method method = declared.get(0); - assertTrue(Modifier.isPublic(method.getModifiers()), methodName + " must be public"); - Class[] parameterTypes = method.getParameterTypes(); - assertEquals(3, parameterTypes.length, methodName + " must take exactly three parameters"); - assertEquals(String.class, parameterTypes[0], methodName + " first parameter is the prompt text"); - assertEquals(Map.class, parameterTypes[1], methodName + " second parameter is the caller parameter schema"); - assertEquals(TemplateUri.class, parameterTypes[2], methodName + " third parameter is the template URI"); - } - } - - @Test - void semanticValidatorSeamCarriesThePromptSchemaAndReferenceTogether() throws NoSuchMethodException { - Method validate = NegotiationSemanticValidator.class.getMethod( - "validate", String.class, Map.class, NegotiationReference.class); - - assertTrue(Modifier.isPublic(validate.getModifiers())); - assertEquals( - 3, - validate.getParameterCount(), - "the semantic validator seam must require the prompt, the caller schema and the template reference" - + " together"); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; +import org.junit.jupiter.api.Test; + +/** + * Pins the compile-time contract of the validate-and-filling API: the parameter schema and the template URI are + * mandatory parts of the full signature of each of the three methods, so a caller that omits either argument is + * rejected at compile time and no reduced-arity overload can silently appear later. + */ +class ValidatePromptAndDataFillingSignatureContractTest { + + private static final List VALIDATE_PROMPT_FILLING_METHODS = + List.of("validateProposePromptAndDataFilling", "validateAcceptPromptAndDataFilling", "validateRejectPromptAndDataFilling"); + + @Test + void everyValidatePromptAndDataFillingMethodHasExactlyTheFullThreeArgumentSignature() { + for (String methodName : VALIDATE_PROMPT_FILLING_METHODS) { + List declared = Arrays.stream(NegotiationGenerationOrchestrator.class.getDeclaredMethods()) + .filter(method -> method.getName().equals(methodName)) + .toList(); + + assertEquals( + 1, + declared.size(), + methodName + " must exist exactly once: no reduced-arity overload may bypass the mandatory" + + " schema and template URI arguments"); + Method method = declared.get(0); + assertTrue(Modifier.isPublic(method.getModifiers()), methodName + " must be public"); + Class[] parameterTypes = method.getParameterTypes(); + assertEquals(3, parameterTypes.length, methodName + " must take exactly three parameters"); + assertEquals(String.class, parameterTypes[0], methodName + " first parameter is the prompt text"); + assertEquals(Map.class, parameterTypes[1], methodName + " second parameter is the caller parameter schema"); + assertEquals(TemplateUri.class, parameterTypes[2], methodName + " third parameter is the template URI"); + } + } + + @Test + void semanticValidatorSeamCarriesThePromptSchemaAndReferenceTogether() throws NoSuchMethodException { + Method validate = NegotiationSemanticValidator.class.getMethod( + "validate", String.class, Map.class, NegotiationReference.class); + + assertTrue(Modifier.isPublic(validate.getModifiers())); + assertEquals( + 3, + validate.getParameterCount(), + "the semantic validator seam must require the prompt, the caller schema and the template reference" + + " together"); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java index 178a412f..406f8c92 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java @@ -126,7 +126,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .language("zh-CN") .llmClient(llm) .build() - .validateAndFillingProposeData( + .validateProposePromptAndDataFilling( "plain text without any negotiation section", SCHEMA, INFORMATION_PROPOSE_URI), NegotiationParamExtractionException.class, A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), @@ -138,7 +138,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .language("zh-CN") .llmClient(llm) .build() - .validateAndFillingProposeData( + .validateProposePromptAndDataFilling( "## 协商上下文\n- id: " + UUID + "\n- round: 9\n- maxRounds: 5", SCHEMA, INFORMATION_PROPOSE_URI), @@ -155,7 +155,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .llmClient(llm) .maxAttempts(3) .build() - .validateAndFillingProposeData(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), + .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), NegotiationParamExtractionException.class, A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED), row( @@ -167,7 +167,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .llmClient(llm) .maxAttempts(2) .build() - .validateAndFillingProposeData(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), + .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), NegotiationParamExtractionException.class, A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), row( @@ -202,7 +202,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .llmClient(llm) .maxAttempts(2) .build() - .validateAndFillingProposeData(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), + .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), NegotiationParamExtractionException.class, A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), row( @@ -214,7 +214,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .llmClient(llm) .semanticValidator(throwingSemanticValidator()) .build() - .validateAndFillingProposeData(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), + .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), NegotiationParamExtractionException.class, A2ATErrorCodes.TEMPLATE_NOT_FOUND)); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java index 4f0fea9d..c1600167 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java @@ -62,7 +62,7 @@ void ruleViolationFailureIsCatchableThroughTheCommonRoot() { .language("zh-CN") .build(); - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateAndFillingProposeData( + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( "## 协商上下文\n- id: " + UUID + "\n- round: 9\n- maxRounds: 5", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); @@ -82,7 +82,7 @@ void semanticRejectionFailureIsCatchableThroughTheCommonRoot() { + "\"inconsistent\"}],\"params\":{}}")) .build(); - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateAndFillingProposeData( + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); @@ -101,7 +101,7 @@ void paramExtractionLlmFailureIsCatchableThroughTheCommonRoot() { .maxAttempts(2) .build(); - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateAndFillingProposeData( + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); @@ -118,7 +118,7 @@ void nonNegotiationInputFailureIsCatchableThroughTheCommonRoot() { .language("zh-CN") .build(); - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateAndFillingProposeData( + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( "plain text without any negotiation section", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); assertTrue(failure instanceof NegotiationParamExtractionException); @@ -168,7 +168,7 @@ void nullPromptOnValidateAndFillingStaysOutsideTheCommonRoot() { NullPointerException failure = assertThrows( NullPointerException.class, - () -> orchestrator.validateAndFillingProposeData(null, Map.of("type", "object"), INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(null, Map.of("type", "object"), INFORMATION_PROPOSE_URI)); assertFalse(A2ATError.class.isInstance(failure), "a null prompt is a programming error and must stay outside the A2ATError tree"); } @@ -181,7 +181,7 @@ void blankPromptOnValidateAndFillingStaysOutsideTheCommonRoot() { IllegalArgumentException failure = assertThrows( IllegalArgumentException.class, - () -> orchestrator.validateAndFillingProposeData( + () -> orchestrator.validateProposePromptAndDataFilling( " ", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); assertFalse( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index 799baa6f..c6154a0b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -171,7 +171,7 @@ void infoAndHigherEventsNeverCarryMessageTextInputOrResponseContent() { assertTrue(generated.promptText().contains(promptMarker)); orchestrator.generateProposeFromText( "free text containing " + inputMarker, new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); - FilledParamData filled = orchestrator.validateAndFillingProposeData( + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( generated.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); assertTrue(filled.data().containsValue(paramValueMarker)); @@ -298,7 +298,7 @@ private void driveSuccessfulParamExtraction() { new NegotiationContext(UUID, 1, 5), new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE_URI); - FilledParamData filled = orchestrator.validateAndFillingProposeData( + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( message.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); assertEquals(UUID, filled.data().get("id")); } @@ -312,7 +312,7 @@ private void driveSemanticRejection() { + "\"inconsistent\"}],\"params\":{}}")) .build(); try { - orchestrator.validateAndFillingProposeData( + orchestrator.validateProposePromptAndDataFilling( "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", Map.of("type", "object"), INFORMATION_PROPOSE_URI); @@ -327,7 +327,7 @@ private void driveRuleViolation() { .llmClient(new ScriptedClient(validExtractionPayload(), validSemanticPayload())) .build(); try { - orchestrator.validateAndFillingProposeData( + orchestrator.validateProposePromptAndDataFilling( "## 协商上下文\n- id: " + UUID + "\n- round: 9\n- maxRounds: 5", Map.of("type", "object"), INFORMATION_PROPOSE_URI); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java index cb7452af..7e8c8a59 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java @@ -73,7 +73,7 @@ void noFailureMessageOfTheRealPipelinesMentionsAStage() { List messages = List.of( failureMessageOf(() -> orchestrator.generateProposeFromText( "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI)), - failureMessageOf(() -> orchestrator.validateAndFillingProposeData( + failureMessageOf(() -> orchestrator.validateProposePromptAndDataFilling( "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", Map.of("type", "object"), INFORMATION_PROPOSE_URI)), @@ -105,7 +105,7 @@ void publicFailuresStillCarryTheirStructuredDetails() { NegotiationParamExtractionException extractionFailure = catchFailure( NegotiationParamExtractionException.class, - () -> orchestrator.validateAndFillingProposeData( + () -> orchestrator.validateProposePromptAndDataFilling( "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java index 25423955..c4726d86 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java @@ -60,9 +60,9 @@ round, new InformationProposeContent(List.of(new NegotiationItem("节能区域 assertEquals(fromData.templateUri(), fromText.templateUri()); assertEquals(1, initiatingSideLlm.callCount(), "the from-text generation performs exactly one extraction call"); - FilledParamData respondingSideParameters = respondingSide.validateAndFillingProposeData( + FilledParamData respondingSideParameters = respondingSide.validateProposePromptAndDataFilling( fromData.promptText(), parameterSchema(), INFORMATION_PROPOSE_URI); - FilledParamData initiatingSideParameters = initiatingSide.validateAndFillingProposeData( + FilledParamData initiatingSideParameters = initiatingSide.validateProposePromptAndDataFilling( fromData.promptText(), parameterSchema(), INFORMATION_PROPOSE_URI); assertEquals( respondingSideParameters, @@ -83,7 +83,7 @@ round, new InformationProposeContent(List.of(new NegotiationItem("节能区域 assertEquals(respondingSideAnswer.buildMetadataContent(), initiatingSideAnswer.buildMetadataContent()); assertTrue(respondingSideAnswer.promptText().contains("## 信息协商结果\nAccept")); - FilledParamData validatedAnswer = initiatingSide.validateAndFillingAcceptData( + FilledParamData validatedAnswer = initiatingSide.validateAcceptPromptAndDataFilling( respondingSideAnswer.promptText(), parameterSchema(), INFORMATION_ACCEPT_URI); assertEquals(SESSION_ID, validatedAnswer.data().get("id")); assertEquals(1, validatedAnswer.data().get("round")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java index 974c333c..4971f3cd 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java @@ -63,7 +63,7 @@ void feasibilityNegotiationRunsFromSemanticRejectionToATerminalAcceptance() thro NegotiationParamExtractionException rejection = assertThrows( NegotiationParamExtractionException.class, - () -> responder.validateAndFillingProposeData( + () -> responder.validateProposePromptAndDataFilling( evaluationRequest.promptText(), parameterSchema(), FEASIBILITY_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, rejection.getCode()); assertEquals(1, responderLlm.callCount(), "a semantic rejection must not be retried"); @@ -101,7 +101,7 @@ void feasibilityNegotiationRunsFromSemanticRejectionToATerminalAcceptance() thro acceptance.promptText().contains("## 可行性评估结果确认\n同意将速率保障目标由5Mbps下调至2Mbps,本次协商确认结束"), "the summary must fill the exception slot of the feasibility result confirmation section"); - FilledParamData terminalParameters = requester.validateAndFillingAcceptData( + FilledParamData terminalParameters = requester.validateAcceptPromptAndDataFilling( acceptance.promptText(), parameterSchema(), FEASIBILITY_ACCEPT_URI); assertEquals(1, requesterLlm.callCount()); assertEquals(GoldenInputs.SESSION_ID, terminalParameters.data().get("id")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java index 46863825..8a9bd6fc 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java @@ -47,7 +47,7 @@ void informationNegotiationReachesTheTerminalStateWithinOneRound() { NegotiationParamExtractionException notNegotiable = assertThrows( NegotiationParamExtractionException.class, - () -> responder.validateAndFillingProposeData( + () -> responder.validateProposePromptAndDataFilling( "A task prompt describing an energy-saving optimization without any negotiation section.", parameterSchema(), INFORMATION_PROPOSE_URI)); @@ -71,7 +71,7 @@ void informationNegotiationReachesTheTerminalStateWithinOneRound() { assertTrue(request.promptText().contains("1. 节能区域信息:如松山湖")); assertTrue(request.promptText().contains("- round: 1")); - FilledParamData requestParameters = responder.validateAndFillingProposeData( + FilledParamData requestParameters = responder.validateProposePromptAndDataFilling( request.promptText(), parameterSchema(), INFORMATION_PROPOSE_URI); assertEquals(1, responderLlm.callCount()); assertEquals(SESSION_ID, requestParameters.data().get("id")); @@ -94,7 +94,7 @@ void informationNegotiationReachesTheTerminalStateWithinOneRound() { assertTrue(answer.promptText().contains("- round: 1"), "the terminal answer shares the round of the request"); FilledParamData answerParameters = - requester.validateAndFillingAcceptData(answer.promptText(), parameterSchema(), INFORMATION_ACCEPT_URI); + requester.validateAcceptPromptAndDataFilling(answer.promptText(), parameterSchema(), INFORMATION_ACCEPT_URI); assertEquals(1, requesterLlm.callCount()); assertEquals(SESSION_ID, answerParameters.data().get("id")); assertEquals(1, answerParameters.data().get("round")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java index c5012c37..6ea48bb7 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java @@ -51,7 +51,7 @@ lastRound, new NegotiationAbortContent("达到协商轮次上限,本次协商 assertTrue(terminal.promptText().contains("达到协商轮次上限,本次协商确认结束。")); assertTrue(terminal.promptText().contains("- round: 5")); - FilledParamData terminalParameters = peer.validateAndFillingAbortData( + FilledParamData terminalParameters = peer.validateAbortPromptAndDataFilling( terminal.promptText(), parameterSchema(), StandardTemplates.NEGOTIATION_ABORT); assertEquals(1, peerLlm.callCount()); assertEquals(SESSION_ID, terminalParameters.data().get("id")); @@ -63,7 +63,7 @@ lastRound, new NegotiationAbortContent("达到协商轮次上限,本次协商 StandardTemplates.NEGOTIATION_ABORT); NegotiationParamExtractionException ruleFailure = assertThrows( NegotiationParamExtractionException.class, - () -> peer.validateAndFillingAbortData( + () -> peer.validateAbortPromptAndDataFilling( beyondBudgetMessage.promptText(), parameterSchema(), StandardTemplates.NEGOTIATION_ABORT)); assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, ruleFailure.getCode()); assertTrue( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java index 5c89ae1e..ce994349 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java @@ -64,7 +64,7 @@ void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { assertTrue(firstProposal.promptText().contains("## 待澄清内容")); assertTrue(firstProposal.promptText().contains("- round: 1")); - FilledParamData firstRoundParameters = responder.validateAndFillingProposeData( + FilledParamData firstRoundParameters = responder.validateProposePromptAndDataFilling( firstProposal.promptText(), nestedParameterSchema(), TARGET_PROPOSE_URI); assertEquals(1, responderLlm.callCount()); Object timeRateTargets = firstRoundParameters.data().get("timeRateTargets"); @@ -84,7 +84,7 @@ void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { assertTrue(rejection.promptText().contains("节能区域信息因站点清单不可用而无法完整澄清")); assertTrue(rejection.promptText().contains("- round: 1")); - requester.validateAndFillingRejectData(rejection.promptText(), parameterSchema(), TARGET_ACCEPT_URI); + requester.validateRejectPromptAndDataFilling(rejection.promptText(), parameterSchema(), TARGET_ACCEPT_URI); assertEquals(1, requesterLlm.callCount()); NegotiationContext secondRound = firstRound.nextRound(); @@ -120,7 +120,7 @@ void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { assertTrue(acceptance.promptText().contains("- round: 2")); FilledParamData terminalParameters = - requester.validateAndFillingAcceptData(acceptance.promptText(), parameterSchema(), TARGET_ACCEPT_URI); + requester.validateAcceptPromptAndDataFilling(acceptance.promptText(), parameterSchema(), TARGET_ACCEPT_URI); assertEquals(2, requesterLlm.callCount()); assertEquals(SESSION_ID, terminalParameters.data().get("id")); assertEquals(2, terminalParameters.data().get("round")); diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java index 1fd5d9f0..36ec3608 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java @@ -30,9 +30,9 @@ class NegotiationFacadeApiSymmetryTest { "generateNegotiationRejectPromptFromText", "getNegotiationPrompts", "getNegotiationPrompt", - "validateAndFillingProposeData", - "validateAndFillingAcceptData", - "validateAndFillingRejectData"); + "validateProposePromptAndDataFilling", + "validateAcceptPromptAndDataFilling", + "validateRejectPromptAndDataFilling"); @Test void bothFacadesExposeTheSameElevenNegotiationApiMethods() { diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index b71335b4..84f22409 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -67,9 +67,9 @@ static void registerMockProvider() { "generateNegotiationRejectPromptFromText", "getNegotiationPrompts", "getNegotiationPrompt", - "validateAndFillingProposeData", - "validateAndFillingAcceptData", - "validateAndFillingRejectData"); + "validateProposePromptAndDataFilling", + "validateAcceptPromptAndDataFilling", + "validateRejectPromptAndDataFilling"); @TempDir Path tempDir; diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java index ebf8792d..26c9c7ed 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java @@ -38,10 +38,10 @@ class NegotiationV3ApiSurfaceTest { Map.entry("generateNegotiationAbortPromptFromText", 3), Map.entry("getNegotiationPrompts", 0), Map.entry("getNegotiationPrompt", 1), - Map.entry("validateAndFillingProposeData", 3), - Map.entry("validateAndFillingAcceptData", 3), - Map.entry("validateAndFillingRejectData", 3), - Map.entry("validateAndFillingAbortData", 3)); + Map.entry("validateProposePromptAndDataFilling", 3), + Map.entry("validateAcceptPromptAndDataFilling", 3), + Map.entry("validateRejectPromptAndDataFilling", 3), + Map.entry("validateAbortPromptAndDataFilling", 3)); private static final List REMOVED_V2_METHOD_NAME_FRAGMENTS = List.of("FromNl", "FromJsonData", "validateAndExtractParams"); @@ -75,10 +75,10 @@ private static void assertExactNegotiationSurface(Class facade) { List negotiationMethods = Arrays.stream(allMethods) .filter(method -> method.getName().startsWith("generateNegotiation") || method.getName().startsWith("getNegotiation") - || "validateAndFillingProposeData".equals(method.getName()) - || "validateAndFillingAcceptData".equals(method.getName()) - || "validateAndFillingRejectData".equals(method.getName()) - || "validateAndFillingAbortData".equals(method.getName())) + || "validateProposePromptAndDataFilling".equals(method.getName()) + || "validateAcceptPromptAndDataFilling".equals(method.getName()) + || "validateRejectPromptAndDataFilling".equals(method.getName()) + || "validateAbortPromptAndDataFilling".equals(method.getName())) .toList(); Set names = negotiationMethods.stream().map(Method::getName).collect(Collectors.toSet()); assertEquals( diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 17be3cb7..2f35b969 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -395,16 +395,16 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingProposeData( + public FilledParamData validateProposePromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { requireTemplateUri(templateUri); - return negotiationContentService.validateAndFillingProposeData(prompt, schema, templateUri); + return negotiationContentService.validateProposePromptAndDataFilling(prompt, schema, templateUri); } /** * Validates an accept-phase negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + *

The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must * satisfy the accept-phase semantic constraints. * @@ -422,16 +422,16 @@ public FilledParamData validateAndFillingProposeData( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingAcceptData( + public FilledParamData validateAcceptPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { requireTemplateUri(templateUri); - return negotiationContentService.validateAndFillingAcceptData(prompt, schema, templateUri); + return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, schema, templateUri); } /** * Validates a reject-phase negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + *

The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must * satisfy the reject-phase semantic constraints. * @@ -449,16 +449,16 @@ public FilledParamData validateAndFillingAcceptData( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingRejectData( + public FilledParamData validateRejectPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { requireTemplateUri(templateUri); - return negotiationContentService.validateAndFillingRejectData(prompt, schema, templateUri); + return negotiationContentService.validateRejectPromptAndDataFilling(prompt, schema, templateUri); } /** * Validates an abort negotiation message and extracts its parameters. * - *

The pipeline is the one of {@link #validateAndFillingProposeData(String, Map, TemplateUri)} with the expected + *

The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the * abort-phase semantic constraints. * @@ -475,10 +475,10 @@ public FilledParamData validateAndFillingRejectData( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ - public FilledParamData validateAndFillingAbortData( + public FilledParamData validateAbortPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { requireTemplateUri(templateUri); - return negotiationContentService.validateAndFillingAbortData(prompt, schema, templateUri); + return negotiationContentService.validateAbortPromptAndDataFilling(prompt, schema, templateUri); } /** @@ -496,7 +496,7 @@ public FilledParamData validateAndFillingAbortData( * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing */ - public FilledParamData validateAndFillingTaskData( + public FilledParamData validateTaskPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { requireTemplateUri(templateUri); return taskContentValidator.validate(prompt, schema, templateUri); @@ -517,7 +517,7 @@ public FilledParamData validateAndFillingTaskData( * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing */ - public FilledParamData validateAndFillingNotificationData( + public FilledParamData validateNotificationPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { requireTemplateUri(templateUri); return notificationContentValidator.validate(prompt, schema, templateUri); @@ -538,7 +538,7 @@ public FilledParamData validateAndFillingNotificationData( * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing */ - public FilledParamData validateAndFillingAuthData( + public FilledParamData validateAuthPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { requireTemplateUri(templateUri); return authContentValidator.validate(prompt, schema, templateUri); diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index 4c321c7f..b123c44c 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -74,7 +74,7 @@ void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOE NegotiationParamExtractionException negotiationFailure = assertThrows( NegotiationParamExtractionException.class, - () -> server.validateAndFillingProposeData( + () -> server.validateProposePromptAndDataFilling( TASK_T_MESSAGE, Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), INFORMATION_PROPOSE_TEMPLATE)); diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 4b108120..ee2b1ef1 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -412,7 +412,7 @@ This section describes the Negotiation-T content layer: a template-driven API se The content layer covers three capabilities: 1. **Message generation** — renders a structured negotiation message from typed data (`generateNegotiation*PromptFromData`, deterministic, no LLM) or from free text (`generateNegotiation*PromptFromText`, one LLM content-extraction step followed by deterministic rendering). -2. **Compliance checking and parameter extraction** — `validateAndFilling*Data` checks that a received message is a well-formed negotiation message and extracts its parameters per a caller-provided JSON schema. +2. **Compliance checking and parameter extraction** — `validatePromptAndDataFilling*Data` checks that a received message is a well-formed negotiation message and extracts its parameters per a caller-provided JSON schema. 3. **Template queries** — `getPrompts` / `getPrompt` list and load the templates available for the configured language across **all** A2A-T extensions (Task-T, Notification-T, Authorization-T, Negotiation-T); `getNegotiationPrompts` / `getNegotiationPrompt` restrict the same queries to the negotiation templates. The content layer is stateless. It deliberately does not own a session state machine: session identity, round tracking beyond what the message itself carries, and role binding stay with the caller. The pre-existing `startNegotiation` / `receiveNegotiation` / `continueNegotiation` API (see 1.6.3) is unchanged and remains the stateful entry point; the content layer can be combined with it or used standalone. @@ -448,10 +448,10 @@ The template is loaded before the LLM call; a missing template fails fast withou **Compliance checking and parameter extraction:** ```java -FilledParamData validateAndFillingProposeData(String prompt, Map schema, TemplateUri templateUri) -FilledParamData validateAndFillingAcceptData(String prompt, Map schema, TemplateUri templateUri) -FilledParamData validateAndFillingRejectData(String prompt, Map schema, TemplateUri templateUri) -FilledParamData validateAndFillingAbortData(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateProposePromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateAcceptPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateRejectPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateAbortPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) ``` The pipeline runs in a fixed order: @@ -529,7 +529,7 @@ All SDK processing failures share one root, while programming errors stay outsid - `NegotiationProcessingException` — a negotiation processing failure. - `NegotiationGenerationException` — raised by generation calls. - `A2ATParamExtractionError` — a parameter-extraction failure with structured per-slot errors; `getCode()` is inherited from the root, `getErrors()` returns the slot details. - - `NegotiationParamExtractionException` — raised by `validateAndFilling*Data` calls. + - `NegotiationParamExtractionException` — raised by `validatePromptAndDataFilling*Data` calls. - `LLMError` — the LLM integration failure subtree, folded into the `A2ATError` tree. - `LLMConfigError` — invalid LLM configuration or provider registration. - `LLMRuntimeError` — an LLM infrastructure failure at call time. @@ -607,7 +607,7 @@ Map schema = Map.of( "required", List.of("subscription_condition.incident_level")); try { - FilledParamData params = server.validateAndFillingProposeData( + FilledParamData params = server.validateProposePromptAndDataFilling( propose.promptText(), schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); // params.data() holds the extracted parameters plus the context parameters. } catch (A2ATParamExtractionError failure) { @@ -625,7 +625,7 @@ MetadataContent accept = server.generateNegotiationAcceptPromptFromData( StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); // The client validates the accept message the same way, with the accept-reject template: -// client.validateAndFillingAcceptData(accept.promptText(), schema, +// client.validateAcceptPromptAndDataFilling(accept.promptText(), schema, // StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); ``` diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 094a3a8f..28141ab5 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -395,7 +395,7 @@ mvn -pl a2a-t-sample -am -DskipTests package 内容层覆盖三类能力: 1. **消息生成** — 从类型化数据渲染结构化协商消息(`generateNegotiation*PromptFromData`,确定性执行,不调用 LLM),或从自由文本生成(`generateNegotiation*PromptFromText`,先执行一次 LLM 内容抽取,再确定性渲染)。 -2. **合规校验与参数提取** — `validateAndFilling*Data` 校验接收到的消息是否为格式正确的协商消息,并按调用方提供的 JSON Schema 提取参数。 +2. **合规校验与参数提取** — `validatePromptAndDataFilling*Data` 校验接收到的消息是否为格式正确的协商消息,并按调用方提供的 JSON Schema 提取参数。 3. **模板查询** — `getPrompts` / `getPrompt` 列出和加载当前配置语言下**全部** A2A-T 扩展(Task-T、Notification-T、Authorization-T、Negotiation-T)可用的模板;`getNegotiationPrompts` / `getNegotiationPrompt` 将同样的查询限定在协商模板。 内容层是无状态的。它刻意不持有会话状态机:会话标识、超出消息本身承载范围的轮次跟踪以及角色绑定都由调用方维护。既有的 `startNegotiation` / `receiveNegotiation` / `continueNegotiation` API(见 1.6.3)保持不变,仍是有状态流程的入口;内容层既可以与之组合使用,也可以独立使用。 @@ -431,10 +431,10 @@ MetadataContent generateNegotiationAbortPromptFromText(String text, NegotiationC **合规校验与参数提取:** ```java -FilledParamData validateAndFillingProposeData(String prompt, Map schema, TemplateUri templateUri) -FilledParamData validateAndFillingAcceptData(String prompt, Map schema, TemplateUri templateUri) -FilledParamData validateAndFillingRejectData(String prompt, Map schema, TemplateUri templateUri) -FilledParamData validateAndFillingAbortData(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateProposePromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateAcceptPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateRejectPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateAbortPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) ``` 流水线按固定顺序执行: @@ -512,7 +512,7 @@ Negotiation-T/{type}-negotiation/{phase}/v1 - `NegotiationProcessingException` — 协商处理失败。 - `NegotiationGenerationException` — 生成调用抛出。 - `A2ATParamExtractionError` — 携带结构化槽位错误的参数提取失败;`getCode()` 继承自根,`getErrors()` 返回槽位详情。 - - `NegotiationParamExtractionException` — `validateAndFilling*Data` 调用抛出。 + - `NegotiationParamExtractionException` — `validatePromptAndDataFilling*Data` 调用抛出。 - `LLMError` — LLM 集成失败子树,已并入 `A2ATError` 树。 - `LLMConfigError` — LLM 配置或提供方注册无效。 - `LLMRuntimeError` — 调用时的 LLM 基础设施故障。 @@ -590,7 +590,7 @@ Map schema = Map.of( "required", List.of("subscription_condition.incident_level")); try { - FilledParamData params = server.validateAndFillingProposeData( + FilledParamData params = server.validateProposePromptAndDataFilling( propose.promptText(), schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); // params.data() 持有提取的参数以及上下文参数。 } catch (A2ATParamExtractionError failure) { @@ -608,7 +608,7 @@ MetadataContent accept = server.generateNegotiationAcceptPromptFromData( StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); // 客户端以相同方式、使用 accept-reject 模板校验该接受消息: -// client.validateAndFillingAcceptData(accept.promptText(), schema, +// client.validateAcceptPromptAndDataFilling(accept.promptText(), schema, // StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); ``` From 0657ef7e372b046230b9a90033095f6ddaa22a48 Mon Sep 17 00:00:00 2001 From: wangruiyang8 Date: Sat, 22 Aug 2026 17:27:17 +0800 Subject: [PATCH 109/295] feat(sample): add service-recovery sample with unified SampleRunner - add the service-recovery Notification-T sample exercising generateNotificationPromptFromText, generateNotificationPromptFromDataWithSchema and validateAndFillingNotificationData over a real a2a-java HTTP+JSON/REST link, with a deterministic mock-LLM fallback when no API key is configured - add SampleRunner, a unified launcher that discovers samples by convention (sample//{client,server}) and orchestrates server startup, client run and teardown; new samples no longer need pom or CI registration - rework the CI e2e job to enumerate and run every discovered sample through the unified runner and aggregate failures - add service-recovery scenario catalog entries (zh-CN/en-US), v1 slot resources and refreshed notification data-format template guidance - fix the subscribe-incident server env local prompt-resource root path Signed-off-by: wangruiyang8 --- .github/workflows/ci.yml | 107 ++---- ...alFileClientScenarioCatalogLoaderTest.java | 2 +- .../scenarios/en-US/scenarios.json | 6 + .../scenarios/zh-CN/scenarios.json | 6 + .../service-recovery/v1/en-US/slot.json | 24 ++ .../service-recovery/v1/zh-CN/slot.json | 24 ++ .../service-recovery/v1/en-US/template.md | 7 +- .../service-recovery/v1/zh-CN/template.md | 9 +- a2a-t-sample/README.zh-CN.md | 92 +++-- a2a-t-sample/pom.xml | 11 +- .../net/openan/a2at/sample/SampleRunner.java | 309 ++++++++++++++++ .../service_recovery/FromTextProbe.java | 90 +++++ .../service_recovery/SampleResourceJson.java | 62 ++++ .../ServiceRecoverySampleInputs.java | 175 +++++++++ .../service_recovery/VerificationCheck.java | 34 ++ .../client/ClientSampleMain.java | 116 ++++++ .../client/flow/A2AJavaClientEventMapper.java | 96 +++++ .../client/flow/ClientEventStreamBuffer.java | 84 +++++ .../client/flow/ClientFlowOutcome.java | 15 + .../client/flow/ClientSampleFlow.java | 267 ++++++++++++++ .../SampleStreamTerminalStateDecider.java | 44 +++ .../service_recovery/client/package-info.java | 7 + .../prompt/A2ATNotificationPromptClient.java | 44 +++ .../client/prompt/SamplePromptClient.java | 35 ++ .../client/registry/SampleRegistryClient.java | 15 + .../client/request/A2AJavaRequestBuilder.java | 34 ++ .../client/request/BuiltA2AJavaRequest.java | 14 + .../client/runtime/A2AJavaClientRuntime.java | 30 ++ .../runtime/DefaultSampleClientRuntime.java | 252 +++++++++++++ .../client/runtime/SampleClientRuntime.java | 29 ++ .../runtime/SampleClientRuntimeFactory.java | 15 + .../sample/service_recovery/package-info.java | 19 + .../server/ServerSampleMain.java | 98 +++++ .../ServerSampleAgentCardBuilder.java | 69 ++++ .../flow/NotificationPromptValidator.java | 24 ++ .../flow/ServerFlowInterruptedException.java | 15 + .../flow/ServerSampleAgentExecutor.java | 58 +++ .../server/flow/ServerSampleFlow.java | 187 ++++++++++ .../server/flow/ServerSleepController.java | 14 + .../server/http/EmbeddedA2AHttpServer.java | 343 ++++++++++++++++++ .../service_recovery/server/package-info.java | 7 + .../server/registry/ServerRegistryClient.java | 131 +++++++ .../registry/ServerRegistryResponse.java | 13 + .../registry/ServerRegistryTransport.java | 16 + .../server/runtime/A2AJavaServerRuntime.java | 13 + .../runtime/DefaultSampleServerRuntime.java | 211 +++++++++++ .../server/runtime/SampleServerRuntime.java | 55 +++ .../runtime/SampleServerRuntimeFactory.java | 16 + .../server/runtime/ServerBind.java | 11 + .../server/runtime/ServerBootstrapResult.java | 18 + .../endpoint/AgentEndpointResolver.java | 56 +++ .../endpoint/ResolvedAgentEndpoint.java | 12 + .../env/SampleEnvironmentPathResolver.java | 30 ++ .../shared/error/ValueErrorException.java | 17 + .../logging/SampleLoggingFormatter.java | 148 ++++++++ .../shared/mock/SampleLoggingLLMClient.java | 82 +++++ .../shared/mock/SampleMockLLMClient.java | 105 ++++++ .../shared/mock/SampleMockLlmInstaller.java | 171 +++++++++ .../service_recovery/shared/package-info.java | 7 + .../registry/RegistryAgentCardMapper.java | 126 +++++++ .../stream/SampleStreamEventNormalizer.java | 48 +++ .../sample/service-recovery/client/client.env | 17 + .../client/input-with-data.json | 3 + .../client/input-with-text.txt | 21 ++ .../service-recovery/client/schema.json | 24 ++ .../en-US/content_validation.json | 8 + .../mock_responses/en-US/slot_extraction.json | 7 + .../zh-CN/content_validation.json | 8 + .../mock_responses/zh-CN/slot_extraction.json | 7 + .../server/mock-event-data.json | 12 + .../service-recovery/server/schema.json | 24 ++ .../sample/service-recovery/server/server.env | 16 + .../server/slot-names.properties | 2 + .../subscribe-incident/server/server.env | 2 +- .../DefaultSampleServerRuntimeTest.java | 89 +++++ .../shared/mock/SampleMockLLMClientTest.java | 42 +++ 76 files changed, 4338 insertions(+), 119 deletions(-) create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/network-layer/service-recovery/v1/en-US/slot.json create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/network-layer/service-recovery/v1/zh-CN/slot.json create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/SampleRunner.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/SampleResourceJson.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/VerificationCheck.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/ClientSampleMain.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/A2AJavaClientEventMapper.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientEventStreamBuffer.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientFlowOutcome.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/SampleStreamTerminalStateDecider.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/package-info.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/A2ATNotificationPromptClient.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/SamplePromptClient.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/registry/SampleRegistryClient.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/A2AJavaRequestBuilder.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/BuiltA2AJavaRequest.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/A2AJavaClientRuntime.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/DefaultSampleClientRuntime.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntime.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntimeFactory.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/package-info.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/ServerSampleMain.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/agentcard/ServerSampleAgentCardBuilder.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/NotificationPromptValidator.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerFlowInterruptedException.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleAgentExecutor.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleFlow.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSleepController.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/http/EmbeddedA2AHttpServer.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/package-info.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryClient.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryResponse.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryTransport.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/A2AJavaServerRuntime.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntime.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntimeFactory.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBind.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBootstrapResult.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/AgentEndpointResolver.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/ResolvedAgentEndpoint.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/env/SampleEnvironmentPathResolver.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/error/ValueErrorException.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/logging/SampleLoggingFormatter.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleLoggingLLMClient.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClient.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLlmInstaller.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/package-info.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/registry/RegistryAgentCardMapper.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/stream/SampleStreamEventNormalizer.java create mode 100644 a2a-t-sample/src/main/resources/sample/service-recovery/client/client.env create mode 100644 a2a-t-sample/src/main/resources/sample/service-recovery/client/input-with-data.json create mode 100644 a2a-t-sample/src/main/resources/sample/service-recovery/client/input-with-text.txt create mode 100644 a2a-t-sample/src/main/resources/sample/service-recovery/client/schema.json create mode 100644 a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/content_validation.json create mode 100644 a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/slot_extraction.json create mode 100644 a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/content_validation.json create mode 100644 a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/slot_extraction.json create mode 100644 a2a-t-sample/src/main/resources/sample/service-recovery/server/mock-event-data.json create mode 100644 a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json create mode 100644 a2a-t-sample/src/main/resources/sample/service-recovery/server/server.env create mode 100644 a2a-t-sample/src/main/resources/sample/service-recovery/server/slot-names.properties create mode 100644 a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntimeTest.java create mode 100644 a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClientTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd163e89..5e3551b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,8 @@ jobs: - name: Build and run tests run: mvn -B clean verify - e2e-test: - name: E2E Samples Test + e2e-samples-unified: + name: E2E Samples (unified runner) runs-on: ubuntu-latest needs: build-and-test steps: @@ -49,90 +49,57 @@ jobs: - name: Package project run: mvn -B package -DskipTests - - name: Create server env - run: | - cat > server.env << 'EOF' - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=local_file - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=a2a-t-resources/src/main/resources/prompt_resources - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=deepseek-chat - A2AT_LLM_BASE_URL=https://api.deepseek.com - A2AT_LLM_API_KEY= - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - A2AT_SAMPLE_HOST=127.0.0.1 - A2AT_SAMPLE_PORT=26335 - REGISTRY_CENTER_HOST=127.0.0.1 - REGISTRY_CENTER_PORT=5001 - EOF - sed -i 's|^A2AT_LLM_API_KEY=$|A2AT_LLM_API_KEY=${{ secrets.A2AT_LLM_API_KEY }}|' server.env - - - name: Create client env - run: | - cat > client.env << 'EOF' - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=deepseek-chat - A2AT_LLM_BASE_URL=https://api.deepseek.com - A2AT_LLM_API_KEY= - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - A2AT_SAMPLE_HOST=127.0.0.1 - A2AT_SAMPLE_PORT=26335 - EOF - sed -i 's|^A2AT_LLM_API_KEY=$|A2AT_LLM_API_KEY=${{ secrets.A2AT_LLM_API_KEY }}|' client.env - - name: Diagnose LLM config availability shell: bash run: | - line=$(grep 'A2AT_LLM_API_KEY=' server.env) - value=${line#A2AT_LLM_API_KEY=} + value="${{ secrets.A2AT_LLM_API_KEY }}" len=${#value} if [ "$len" -eq 0 ]; then echo "::notice::A2AT_LLM_API_KEY is empty, e2e will use mock LLM responses" else - prefix=$(echo "$value" | cut -c1-12) - echo "A2AT_LLM_API_KEY length=$len prefix=$prefix..." + echo "A2AT_LLM_API_KEY length=$len" fi - - name: Start server - run: | - nohup java @a2a-t-sample/target/server.javaargs.txt > server.log 2>&1 & - echo $! > server.pid - sleep 3 - - - name: Wait for server + - name: Fill LLM API keys into all sample envs + if: ${{ env.A2AT_LLM_API_KEY != '' }} + env: + A2AT_LLM_API_KEY: ${{ secrets.A2AT_LLM_API_KEY }} run: | - for i in $(seq 1 10); do - if curl -s http://127.0.0.1:26335/ > /dev/null 2>&1; then - echo "Server ready" - break - fi - echo "Waiting for server... attempt $i" - sleep 2 + for f in a2a-t-sample/src/main/resources/sample/*/*/client.env \ + a2a-t-sample/src/main/resources/sample/*/*/server.env; do + [ -f "$f" ] || continue + sed -i "s|^A2AT_LLM_API_KEY=.*|A2AT_LLM_API_KEY=${A2AT_LLM_API_KEY}|" "$f" done - - name: Run client + - name: List discovered samples + run: java @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner + + - name: Run all samples env: - A2AT_SAMPLE_MAX_ARTIFACTS: "5" + A2AT_SAMPLE_MAX_ARTIFACTS: "1" + A2AT_LLM_API_KEY: ${{ secrets.A2AT_LLM_API_KEY }} run: | - java @a2a-t-sample/target/client.javaargs.txt 2>&1 | tee client.log - artifact_count=$(grep -c 'task-artifact' client.log || true) - echo "e2e artifacts received: $artifact_count" - if [ "$artifact_count" -eq 0 ]; then - echo "::error::e2e failed: no artifacts received" + set -e + failed="" + for sample in $(java @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner list | grep ' - ' | sed 's/^ - //'); do + echo "=== running sample: $sample ===" + if java @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner "$sample"; then + echo "sample $sample PASSED" + else + echo "::error::sample $sample FAILED" + failed="$failed $sample" + fi + done + if [ -n "$failed" ]; then + echo "::error::failed samples:$failed" exit 1 fi - echo "e2e PASSED" + echo "all samples PASSED" - - name: Show logs + - name: Show sample server logs if: always() run: | - echo "=== server.log ===" && cat server.log 2>/dev/null || true - echo "=== client.log ===" && cat client.log 2>/dev/null || true - - - name: Cleanup - if: always() - run: | - kill $(cat server.pid 2>/dev/null) 2>/dev/null || true \ No newline at end of file + for log in a2a-t-sample/target/sample-logs/*-server.log; do + echo "=== $log ===" + cat "$log" 2>/dev/null || true + done diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java index 63501d66..772b7906 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java @@ -16,7 +16,7 @@ void loadDeserializesScenarioCatalogFromLocalFile() { List scenarios = loader.load("zh-CN"); - assertEquals(3, scenarios.size()); + assertEquals(4, scenarios.size()); assertEquals("subscribe-incident", scenarios.get(0).scenarioCode()); assertEquals("energy-saving", scenarios.get(1).scenarioCode()); } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json index ba7ae31b..ec129c0b 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json @@ -17,6 +17,12 @@ "scenario_name": "Private Line Complaint", "description": "Used to generate an A2A-T task request for network-side fault root cause diagnosis in a private line complaint scenario in a telecom network management system.", "example": "Please confirm the root cause of fault fault-id-1-328-20260510-58489 that occurred on the Guangdong enterprise private line at 2026-05-10T18:25:30Z, and provide repair suggestions." + }, + { + "scenario_code": "service-recovery", + "scenario_name": "Service Recovery Event Subscription", + "description": "Used to generate an A2A-T task request for subscribing to service recovery events in a telecom network management system, describing the subscription condition and the notification data format for network-side service recovery events.", + "example": "Help me subscribe to service recovery events: only for the xx subnetwork, and report the recovery plan execution status, diagnosis sequence number, access port, and execution result in each event." } ] } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json index c858ec0d..8f5cea1f 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json @@ -17,6 +17,12 @@ "scenario_name": "专线投诉", "description": "用于生成电信网管系统中 专线投诉场景的网络侧故障根因诊断的 A2A-T 任务请求。", "example": "请确认广东政企专线2026-05-10T18:25:30Z发生的故障fault-id-1-328-20260510-58489的根因,并提供修复建议。" + }, + { + "scenario_code": "service-recovery", + "scenario_name": "业务抢通事件订阅", + "description": "用于生成电信网管系统中业务抢通事件订阅类的 A2A-T 任务请求,围绕网络侧业务抢通事件的订阅条件与上报通知数据格式进行描述。", + "example": "帮我订阅业务抢通事件:只看xx子网,事件上报时带上抢通方案执行状态、诊断任务流水号、接入端口和执行结果这些信息。" } ] } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/network-layer/service-recovery/v1/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/network-layer/service-recovery/v1/en-US/slot.json new file mode 100644 index 00000000..8af62f1c --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/network-layer/service-recovery/v1/en-US/slot.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "subscribe_condition": { + "type": "string", + "description": "The condition description to be subscribed, used to define subscription constraints such as the subnetwork name.", + "examples": [ + "Subnetwork name: xx subnetwork." + ], + "x-a2at-value-constraint": "The subscription condition may include the subnetwork name, for example: xx subnetwork." + }, + "notification_data_format": { + "type": "string", + "description": "The notification data format description to be reported, used to define the service recovery event data structure.", + "examples": [ + "The service recovery event data contains: service recovery plan execution status (not started, ended), complaint diagnosis task sequence number, OSS-side event sequence number, access port name, whether OMC automatic recovery is authorized (yes, no), service recovery plan name, service recovery plan details, service recovery plan execution end time, service recovery plan execution result (success, failure), and service recovery plan execution failure reason." + ], + "x-a2at-value-constraint": "The reported data type is the service recovery event. The service recovery event data contains the following fields: 1. Service recovery plan execution status, allowed values: not started, ended (required); 2. Complaint diagnosis task sequence number (required); 3. OSS-side event sequence number (required); 4. Access port name (required); 5. Whether OMC automatic recovery is authorized, allowed values: yes, no (required); 6. Service recovery plan name (required); 7. Service recovery plan details (required); 8. Service recovery plan execution end time (optional); 9. Service recovery plan execution result, allowed values: success, failure (optional); 10. Service recovery plan execution failure reason, required when the execution result is failure (optional)." + } + }, + "required": ["notification_data_format"] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/network-layer/service-recovery/v1/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/network-layer/service-recovery/v1/zh-CN/slot.json new file mode 100644 index 00000000..a24afafc --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/network-layer/service-recovery/v1/zh-CN/slot.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "订阅条件": { + "type": "string", + "description": "待订阅的条件描述,用于定义子网名称等订阅约束。", + "examples": [ + "子网名称为:xx子网。" + ], + "x-a2at-value-constraint": "订阅条件可包含子网名称,例如:xx子网。" + }, + "上报通知数据格式": { + "type": "string", + "description": "待上报的通知数据格式描述,用于定义业务抢通事件的数据结构。", + "examples": [ + "业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、业务抢通方案名称、业务抢通方案详情、业务抢通方案执行结束时间、业务抢通方案执行结果(成功、失败)及业务抢通方案执行失败原因。" + ], + "x-a2at-value-constraint": "上报的数据类型为业务抢通事件。业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)。" + } + }, + "required": ["上报通知数据格式"] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md index a469f52f..e9bf7406 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md @@ -10,8 +10,13 @@ Service recovery event ## Notification Data Format {{notification_data_format}} (required) +Requirement: Please provide the following information for the content to be reported +1. Information item name (required) +2. Allowed values (optional) +3. Example (optional) + +The following is a sample of the notification data format: ### Service Recovery Event -Requirement: 1. Service recovery plan execution status. Allowed values: not started, ended. Example: ended (required) 2. Complaint diagnosis task sequence number. Example: 9de168c0-6179-4778-8b72-4279582c0a3f (required) 3. OSS-side event sequence number. Example: event-id-202606250128 (required) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md index eb693c98..1d0b59f6 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md @@ -10,8 +10,13 @@ ## 上报通知数据格式 {{上报通知数据格式}}(必选) -### 业务抢通事件 -要求: +要求:请提供所需上报内容的如下信息 +1. 信息项名称;(必选) +2. 取值范围;(可选) +3. 样例;(可选) + +如下为上报通知数据格式样例: +### 业务抢通事件 1. 业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束 (必选) 2. 投诉诊断任务流水号;举例:9de168c0-6179-4778-8b72-4279582c0a3f (必选) 3. OSS侧事件流水号;举例:event-id-202606250128 (必选) diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 534c017b..01962b6a 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -1,61 +1,83 @@ # a2a-t-sample -`a2a-t-sample` 是 A2A-T Java SDK 的示例模块,包含客户端与服务端两个可直接运行的入口。 +`a2a-t-sample` 是 A2A-T Java SDK 的示例模块,每组示例包含客户端与服务端两个入口,基于 +`a2a-java v1.0.0.Beta1` 运行真实的 A2A `HTTP+JSON/REST` 链路: -当前示例基于 `a2a-java v1.0.0.Beta1` 运行真实的 A2A `HTTP+JSON/REST` 链路: - `a2a-t-client` 仅用于生成结构化 prompt - `a2a-t-server` 仅用于校验结构化 prompt -## 入口类 +## 快速开始(推荐:统一启动器) -- 客户端:`net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain` -- 服务端:`net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain` +从仓库根目录执行: -## 模块内资源 - -- 客户端环境模板:`sample/subscribe_incident/client/client.env` -- 服务端环境模板:`sample/subscribe_incident/server/server.env` -- 客户端场景输入:`sample/subscribe_incident/client/scenario.json` +```bash +# 1. 打包(每次改完代码后执行一次) +mvn -pl a2a-t-sample -am -DskipTests package -## 客户端启动 +# 2. 查看有哪些 sample +java @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner -1. 修改仓库根目录下的 `client.env`,补充可用的 `A2AT_LLM_API_KEY` -2. 如需修改默认请求内容,可编辑 `sample/subscribe_incident/client/scenario.json` -3. 启动客户端: +# 3. 一条命令跑任意 sample(自动:起服务端 → 等就绪 → 跑客户端 → 回收服务端) +java @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner service-recovery -```bash -java @a2a-t-sample/target/client.javaargs.txt +# 可选:让客户端每轮只收 N 个 notification 就提前收尾(默认每轮收满 5 个、任务自动完结): +A2AT_SAMPLE_MAX_ARTIFACTS=1 java @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner service-recovery +# PowerShell 写法:$env:A2AT_SAMPLE_MAX_ARTIFACTS="1"; java @... SampleRunner service-recovery ``` -如果不传参数,`ClientSampleMain` 会回退到包内的 `sample/subscribe_incident/client/client.env`。 +启动器按约定自动发现 sample:`src/main/resources/sample//` 下同时存在 `client/` 和 +`server/` 两个子目录即视为一个 sample,主类按 +`net.openan.a2at.sample..client.ClientSampleMain` / `...server.ServerSampleMain` +解析。**新增 sample 无需任何注册**——放好包和资源目录即可被列出和运行。 -## 服务端启动 +未填写 LLM API key 时,sample 自动降级为确定性 mock LLM 模式,无需外部依赖即可完整跑通(适合 CI)。 -1. 修改仓库根目录下的 `server.env`,补充可用的 `A2AT_LLM_API_KEY` -2. 启动服务端: +## 当前示例 -```bash -java @a2a-t-sample/target/server.javaargs.txt -``` +| sample | 说明 | 验证的 SDK API | +|---|---|---| +| `service-recovery` | 业务抢通事件订阅(Notification-T) | `generateNotificationPromptFromText`、`generateNotificationPromptFromDataWithSchema`、`validateAndFillingNotificationData` | +| `subscribe-incident` | Incident 事件订阅(Notification-T) | `generateTaskPrompt`(场景识别链路) | + +- service-recovery 客户端在一个进程内跑两轮订阅:轮① `generateNotificationPromptFromText` + (自然语言输入),轮② `generateNotificationPromptFromDataWithSchema`(结构化输入 + 数据 + schema),两轮各自生成的 prompt 都经服务端 `validateAndFillingNotificationData` 校验并建立 + 真实订阅。每个订阅任务上报 5 次 notification(每 5 秒一次)后自动 COMPLETED,流自然结束。 + 客户端在流程内输出 16 项 PASS/FAIL 检查,全部通过时进程退出码为 0。 +- API key 填写位置: + - service-recovery:`a2a-t-sample/src/main/resources/sample/service-recovery/{client,server}/` 下的 env + - subscribe-incident:仓库根目录的 `client.env` / `server.env` +- service-recovery 的 env 使用 `local_file` 提示词资源模式(classpath 加载器目前拒绝含 `/` 的模板 URI), + 其相对根路径按 env 文件自身位置解析——从仓库根目录以 argfile/启动器方式运行即可,勿把 env 拷到别处单独使用。 -如果不传参数,`ServerSampleMain` 会回退到包内的 `sample/subscribe_incident/server/server.env`。 +## 单独运行某个进程(调试用) -## Git Bash 本地调试 +统一启动器本身已覆盖绝大多数场景。如需长时间单独挂起某一个进程观察行为,用 exec:java +(见下节)在一个终端里跑 server、另一个终端跑 client 即可。 -先编译打包: +## 开发期免打包运行(exec:java) ```bash -mvn "-Dmaven.repo.local=.mvn/repository" -pl a2a-t-sample -am -DskipTests package +mvn -pl a2a-t-sample exec:java \ + -Dexec.mainClass=net.openan.a2at.sample.service_recovery.server.ServerSampleMain + +mvn -pl a2a-t-sample exec:java \ + -Dexec.mainClass=net.openan.a2at.sample.service_recovery.client.ClientSampleMain ``` -启动服务端: +## 新增 sample 的固定套路 -```bash -java @a2a-t-sample/target/server.javaargs.txt -``` +1. 新建包 `net.openan.a2at.sample.`,包含 `client/ClientSampleMain` 与 `server/ServerSampleMain` + (照抄任一现有 sample 的骨架); +2. 新建资源目录 `src/main/resources/sample//{client,server}/`,放入 `client.env`、`server.env` + 和 `scenario.json`; +3. 完成。pom 与 CI workflow 均无需改动——统一启动器按约定自动发现并可运行,CI 自动纳入。 + README 表格加一行即可(可选)。 -另开一个窗口启动客户端: +## 已知限制 -```bash -java @a2a-t-sample/target/client.javaargs.txt -``` +- SDK 的 classpath 提示词加载器目前会拒绝含 `/` 的模板 URI(如 + `Notification-T/network-layer/service-recovery/v1`),六个 `*FromText` / `*FromDataWithSchema` + 生成入口在 classpath 模式下不可用,故 sample 均以 `local_file` 模式运行。 +- 各 sample 的服务端均先尝试向注册中心注册 AgentCard;注册中心不可用时自动跳过并切换为客户端直连 + 模式(无需部署注册中心即可运行)。 diff --git a/a2a-t-sample/pom.xml b/a2a-t-sample/pom.xml index 68928923..84d40a9b 100644 --- a/a2a-t-sample/pom.xml +++ b/a2a-t-sample/pom.xml @@ -120,15 +120,8 @@ - -cp -${sample.jar.path}${path.separator}${sample.runtime.classpath} -net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain -server.env - - -cp -${sample.jar.path}${path.separator}${sample.runtime.classpath} -net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain -client.env + -cp + ${sample.jar.path}${path.separator}${sample.runtime.classpath} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/SampleRunner.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/SampleRunner.java new file mode 100644 index 00000000..0822d94f --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/SampleRunner.java @@ -0,0 +1,309 @@ +package net.openan.a2at.sample; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +/** + * Unified launcher for every sample in this module. + * + *

One command runs any sample end to end — start the server, wait until its HTTP endpoint is up, + * run the client, then stop the server. The server output is captured to a temp log file; the client + * output streams to the console. + * + *

Usage from the repository root: + * + *

+ * java -cp @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner 
+ * 
+ * + *

Each sample is discovered by convention from {@code src/main/resources/sample/} — a directory + * containing both {@code client/} and {@code server/} subdirectories is a runnable sample. The + * corresponding main classes are resolved as + * {@code net.openan.a2at.sample..client.ClientSampleMain} and + * {@code ...server.ServerSampleMain}, where {@code } is the directory name with + * dashes replaced by underscores. Adding a new sample needs no registration: drop the package and + * the resource directory, and the runner picks it up. + * + *

Environment variables: {@code A2AT_SAMPLE_MAX_ARTIFACTS} (passed through to the client) and + * {@code A2AT_SAMPLE_LIST} (set to any value to only list samples). + * + * @since 2026-08 + */ +public final class SampleRunner { + + private static final Path SAMPLES_ROOT = Path.of("a2a-t-sample", "src", "main", "resources", "sample"); + + private static final Path ARGS_FILE = Path.of("a2a-t-sample", "target", "sample.args"); + + private static final long SERVER_STARTUP_TIMEOUT_SECONDS = 120; + + private SampleRunner() { + } + + /** + * Runner entry point. + * + * @param args single sample name, or nothing to list the available samples + */ + public static void main(String[] args) throws Exception { + List samples = discoverSamples(); + if (samples.isEmpty()) { + System.err.println("No samples found under " + SAMPLES_ROOT); + System.exit(2); + } + if (args.length == 0 || "list".equalsIgnoreCase(args[0]) || System.getenv("A2AT_SAMPLE_LIST") != null) { + System.out.println("Available samples (run with: java @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner ):"); + samples.forEach(name -> System.out.println(" - " + name)); + return; + } + + String sample = args[0].toLowerCase(Locale.ROOT); + if (!samples.contains(sample)) { + System.err.println("Unknown sample '" + sample + "'. Available samples:"); + samples.forEach(name -> System.err.println(" - " + name)); + System.exit(2); + } + + System.exit(runSample(sample)); + } + + private static int runSample(String sample) throws Exception { + String javaPackage = "net.openan.a2at.sample." + sample.replace('-', '_'); + String serverMain = javaPackage + ".server.ServerSampleMain"; + String clientMain = javaPackage + ".client.ClientSampleMain"; + String serverEnv = SAMPLES_ROOT.resolve(sample).resolve("server").resolve("server.env").toString(); + String clientEnv = SAMPLES_ROOT.resolve(sample).resolve("client").resolve("client.env").toString(); + if (!Files.exists(Path.of(serverEnv)) || !Files.exists(Path.of(clientEnv))) { + System.err.println("Sample '" + sample + "' is missing its env files:"); + System.err.println(" " + serverEnv); + System.err.println(" " + clientEnv); + return 2; + } + + List classpath = readArgsClasspath(); + + int serverPort = resolveServerPort(clientEnv); + if (isPortOpen(serverPort)) { + System.err.println("[runner] port " + serverPort + " is already in use — a previous server for sample '" + + sample + "' may still be running."); + System.err.println("[runner] stop it first (e.g. stop the lingering java process listening on the port), " + + "or rerun after 'mvn clean' fails to delete jars."); + return 2; + } + + Path serverLog = Path.of("a2a-t-sample", "target", "sample-logs").resolve(sample + "-server.log"); + Files.createDirectories(serverLog.getParent()); + System.out.println("[runner] starting server for sample '" + sample + "' (log: " + serverLog.toAbsolutePath() + + ")"); + Process server = new ProcessBuilder(javaCommand(classpath, serverMain, serverEnv)) + .redirectErrorStream(true) + .start(); + // Kill the spawned processes when this runner is interrupted (Ctrl+C), so no orphaned + // server keeps running and locking jars under target/. + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + server.destroyForcibly(); + System.out.println("[runner] shutdown: server stopped"); + }, "sample-runner-shutdown")); + Thread serverLogPumper = new Thread(() -> pumpServerLog(server, serverLog), "sample-runner-server-log"); + serverLogPumper.setDaemon(true); + serverLogPumper.start(); + if (!waitForServer(serverPort, SERVER_STARTUP_TIMEOUT_SECONDS)) { + System.err.println("[runner] server did not start within " + SERVER_STARTUP_TIMEOUT_SECONDS + "s; server log tail:"); + tailLog(serverLog, 30); + server.destroyForcibly(); + return 2; + } + System.out.println("[runner] server is up on port " + serverPort + ", starting client"); + + Path clientLog = Path.of("a2a-t-sample", "target", "sample-logs").resolve(sample + "-client.log"); + Process client = new ProcessBuilder(javaCommand(classpath, clientMain, clientEnv)) + .redirectErrorStream(true) + .start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + client.destroyForcibly(); + System.out.println("[runner] shutdown: client stopped"); + }, "sample-runner-shutdown-client")); + Thread clientLogPumper = new Thread(() -> pumpLog(client, clientLog, "[client] "), "sample-runner-client-log"); + clientLogPumper.setDaemon(true); + clientLogPumper.start(); + int clientExit = client.waitFor(); + server.destroyForcibly(); + server.waitFor(); + + System.out.println("[runner] client exit code: " + clientExit); + if (clientExit != 0) { + System.out.println("[runner] server log tail:"); + tailLog(serverLog, 30); + } + return clientExit; + } + + /** + * Pumps the server process output to the console (prefixed) and to the log file concurrently. + */ + private static void pumpServerLog(Process server, Path serverLog) { + pumpLog(server, serverLog, "[server] "); + } + + /** + * Pumps one child process output to the console (prefixed) and to the log file concurrently. + * + *

Both the reader and the log file use UTF-8; the child JVMs are launched with + * {@code stdout.encoding=UTF-8} so their console streams emit UTF-8 bytes even on Windows + * where the default console encoding is the ANSI code page (GBK). + */ + private static void pumpLog(Process process, Path logFile, String consolePrefix) { + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + java.io.BufferedWriter writer = Files.newBufferedWriter(logFile, StandardCharsets.UTF_8)) { + String line; + while ((line = reader.readLine()) != null) { + System.out.println(consolePrefix + line); + writer.write(line); + writer.newLine(); + writer.flush(); + } + } catch (IOException exception) { + System.out.println("[runner] log pump ended: " + exception.getMessage()); + } + } + + private static List readArgsClasspath() throws IOException { + List lines = Files.readAllLines(ARGS_FILE, StandardCharsets.UTF_8); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.startsWith("-cp") && lines.indexOf(line) < lines.size() - 1) { + // classpath value is the next non-empty line + int index = lines.indexOf(line) + 1; + while (index < lines.size() && lines.get(index).trim().isEmpty()) { + index++; + } + return List.of("-cp", lines.get(index).trim()); + } + } + throw new IllegalStateException("No -cp entry found in " + ARGS_FILE); + } + + private static List javaCommand(List classpath, String mainClass, String envPath) { + List command = new ArrayList<>(); + command.add(javaExecutable()); + // Force UTF-8 console streams on the child JVM: on Windows the default console encoding is + // the ANSI code page (GBK), which would garble Chinese when the runner decodes UTF-8. + // Unknown -D properties are ignored, so both the JDK 19+ and legacy spellings are safe. + command.add("-Dstdout.encoding=UTF-8"); + command.add("-Dstderr.encoding=UTF-8"); + command.add("-Dsun.stdout.encoding=UTF-8"); + command.add("-Dsun.stderr.encoding=UTF-8"); + command.addAll(classpath); + command.add(mainClass); + command.add(envPath); + return command; + } + + private static List discoverSamples() { + if (!Files.isDirectory(SAMPLES_ROOT)) { + return List.of(); + } + try (Stream dirs = Files.list(SAMPLES_ROOT)) { + return dirs.filter(Files::isDirectory) + .filter(dir -> Files.isDirectory(dir.resolve("client")) + && Files.isDirectory(dir.resolve("server"))) + .map(dir -> dir.getFileName().toString().toLowerCase(Locale.ROOT)) + .sorted(Comparator.naturalOrder()) + .toList(); + } catch (IOException exception) { + throw new IllegalStateException("Failed to scan samples under " + SAMPLES_ROOT, exception); + } + } + + private static int resolveServerPort(String clientEnv) { + List lines; + try { + lines = Files.readAllLines(Path.of(clientEnv), StandardCharsets.UTF_8); + } catch (IOException exception) { + return 26336; + } + for (String rawLine : lines) { + String line = rawLine.trim(); + if (line.startsWith("A2AT_SAMPLE_PORT=")) { + try { + return Integer.parseInt(line.substring("A2AT_SAMPLE_PORT=".length()).trim()); + } catch (NumberFormatException ignored) { + // fall through to default + } + } + } + return 26336; + } + + private static boolean isPortOpen(int port) { + try { + HttpURLConnection connection = + (HttpURLConnection) URI.create("http://127.0.0.1:" + port + "/").toURL().openConnection(); + connection.setConnectTimeout(1000); + connection.setReadTimeout(1000); + connection.setRequestMethod("GET"); + connection.getResponseCode(); + connection.disconnect(); + return true; + } catch (IOException exception) { + return false; + } + } + + private static boolean waitForServer(int port, long timeoutSeconds) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L; + while (System.currentTimeMillis() < deadline) { + try { + HttpURLConnection connection = + (HttpURLConnection) URI.create("http://127.0.0.1:" + port + "/").toURL().openConnection(); + connection.setConnectTimeout(1000); + connection.setReadTimeout(1000); + connection.setRequestMethod("GET"); + int code = connection.getResponseCode(); + connection.disconnect(); + if (code >= 200 && code < 500) { + return true; + } + } catch (IOException ignored) { + // not up yet + } + Thread.sleep(1000L); + } + return false; + } + + private static String javaExecutable() { + String javaHome = System.getProperty("java.home"); + return Path.of(javaHome, "bin", System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win") + ? "java.exe" + : "java").toString(); + } + + private static void tailLog(Path log, int lineCount) { + try (BufferedReader reader = Files.newBufferedReader(log, StandardCharsets.UTF_8)) { + List lines = new ArrayList<>(); + String line; + while ((line = reader.readLine()) != null) { + lines.add(line); + if (lines.size() > lineCount) { + lines.remove(0); + } + } + lines.forEach(System.out::println); + } catch (IOException exception) { + System.out.println("[runner] failed to read log " + log + ": " + exception.getMessage()); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java new file mode 100644 index 00000000..9c2f6aaa --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java @@ -0,0 +1,90 @@ +package net.openan.a2at.sample.service_recovery; + +import java.nio.file.Path; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; + +/** + * One-shot diagnostic entry: runs {@code generateNotificationPromptFromText} alone with the + * realistic natural-language input and prints every intermediate artifact. + * + *

Run from the repository root: + *

+ * java @a2a-t-sample/target/sample.args \
+ *   net.openan.a2at.sample.service_recovery.FromTextProbe \
+ *   a2a-t-sample/src/main/resources/sample/service-recovery/client/client.env
+ * 
+ * + * @since 2026-08 + */ +public final class FromTextProbe { + + private FromTextProbe() { + } + + /** + * Probe entry point. + * + * @param args args[0] is the sample client env path + */ + public static void main(String[] args) throws Exception { + // Route all probe output to a UTF-8 file so the console codepage cannot garble it. + java.io.PrintStream out = new java.io.PrintStream( + java.nio.file.Files.newOutputStream(java.nio.file.Path.of( + "a2a-t-sample", "target", "sample-logs", "from-text-probe-output.txt")), + true, + java.nio.charset.StandardCharsets.UTF_8); + System.setOut(out); + System.setErr(out); + run(args); + out.close(); + } + + private static void run(String[] args) { + // Install the logging LLM wrapper so the raw LLM request/response is visible in the probe output. + net.openan.a2at.sample.service_recovery.shared.mock.SampleMockLlmInstaller.installLlmLogger(false, "probe"); + + Path envPath = args.length > 0 + ? Path.of(args[0]) + : Path.of("a2a-t-sample", "src", "main", "resources", "sample", "service-recovery", "client", + "client.env"); + + String input = ServiceRecoverySampleInputs.naturalLanguageInput(); + + System.out.println("=== [probe] input (" + input.length() + " chars) ==="); + System.out.println(input); + System.out.println(); + + A2ATClient client = new A2ATClient(envPath); + TemplateUri templateUri = TemplateUri.parse(ServiceRecoverySampleInputs.TEMPLATE_URI) + .orElseThrow(() -> new IllegalArgumentException("Malformed template URI")); + + System.out.println("=== [probe] calling generateNotificationPromptFromText ==="); + long start = System.currentTimeMillis(); + try { + MetadataContent content = client.generateNotificationPromptFromText(input, templateUri); + long elapsed = System.currentTimeMillis() - start; + System.out.println("=== [probe] SUCCESS in " + elapsed + " ms ==="); + System.out.println("--- templateUri ---"); + System.out.println(content.templateUri()); + System.out.println("--- extensionUri ---"); + System.out.println(content.extensionUri()); + System.out.println("--- promptText ---"); + System.out.println(content.promptText()); + System.out.println("--- metadata map ---"); + for (Map.Entry entry : content.buildMetadataContent().entrySet()) { + System.out.println(" " + entry.getKey() + " = " + summarize(entry.getValue())); + } + } catch (RuntimeException error) { + long elapsed = System.currentTimeMillis() - start; + System.out.println("=== [probe] FAILED in " + elapsed + " ms ==="); + System.out.println(error.getClass().getName() + ": " + error.getMessage()); + } + } + + private static String summarize(String value) { + return value.length() <= 80 ? value : value.substring(0, 80) + "... (" + value.length() + " chars)"; + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/SampleResourceJson.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/SampleResourceJson.java new file mode 100644 index 00000000..68eb18d6 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/SampleResourceJson.java @@ -0,0 +1,62 @@ +package net.openan.a2at.sample.service_recovery; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * Loads JSON resources from the service-recovery sample resource tree. + * + *

Keeps locale-specific business data (zh-CN slot names, event field labels) in resource files + * so the Java sources stay pure ASCII. + * + * @since 2026-08 + */ +public final class SampleResourceJson { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private SampleResourceJson() { + } + + /** + * Loads one JSON object resource from the classpath. + * + * @param resourcePath classpath path of the JSON resource + * @return parsed JSON object as a string-to-object map + */ + public static Map load(String resourcePath) { + try (InputStream inputStream = SampleResourceJson.class + .getClassLoader() + .getResourceAsStream(resourcePath)) { + if (inputStream == null) { + throw new IllegalStateException("Sample resource not found: " + resourcePath); + } + return OBJECT_MAPPER.readValue(inputStream, new TypeReference>() {}); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read sample resource: " + resourcePath, exception); + } + } + + /** + * Loads one text resource from the classpath. + * + * @param resourcePath classpath path of the text resource + * @return resource content as UTF-8 text + */ + static String loadText(String resourcePath) { + try (InputStream inputStream = SampleResourceJson.class + .getClassLoader() + .getResourceAsStream(resourcePath)) { + if (inputStream == null) { + throw new IllegalStateException("Sample resource not found: " + resourcePath); + } + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read sample resource: " + resourcePath, exception); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java new file mode 100644 index 00000000..a2b963f6 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java @@ -0,0 +1,175 @@ +package net.openan.a2at.sample.service_recovery; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * Loads the sample inputs and schemas from the classpath resources under + * {@code sample/service-recovery/}. + * + *

Four artifacts live as resources so they can be edited without touching Java code: + * + *

    + *
  • {@code client/input-with-text.txt} — the natural-language subscription request; + *
  • {@code client/input-with-data.json} — the structured subscription request consumed by + * {@code generateNotificationPromptFromDataWithSchema}; + *
  • {@code client/schema.json} — the data schema guiding the structured extraction; + *
  • {@code server/schema.json} — the parameter schema handed to + * {@code A2ATServer.validateAndFillingNotificationData}. + * + * @since 2026-08 + */ +public final class ServiceRecoverySampleInputs { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String INPUT_RESOURCE = + "sample/service-recovery/client/input-with-text.txt"; + + private static final String STRUCTURED_INPUT_RESOURCE = + "sample/service-recovery/client/input-with-data.json"; + + private static final String DATA_SCHEMA_RESOURCE = + "sample/service-recovery/client/schema.json"; + + private static final String VALIDATION_SCHEMA_RESOURCE = + "sample/service-recovery/server/schema.json"; + + /** Resource holding the zh-CN slot names of the bundled template. */ + private static final String SLOT_NAMES_RESOURCE = + "sample/service-recovery/server/slot-names.properties"; + + /** Template URI verified by this sample (network-layer domain layout of the bundled template). */ + public static final String TEMPLATE_URI = "Notification-T/network-layer/service-recovery/v1"; + + /** Number of notification reports each accepted subscription emits before the task completes. */ + public static final int NOTIFICATION_REPORT_COUNT = 5; + + /** Interval in seconds between two consecutive notification reports of one subscription. */ + public static final long NOTIFICATION_REPORT_INTERVAL_SECONDS = 5L; + + /** Slot name of the optional subscription condition slot. */ + public static final String SLOT_SUBSCRIBE_CONDITION = slotName("subscriptionCondition"); + + /** Slot name of the required notification data format slot. */ + public static final String SLOT_NOTIFICATION_DATA_FORMAT = slotName("notificationDataFormat"); + + /** Server-side parameter name of the subscription condition (English business identifier). */ + public static final String PARAM_SUBSCRIPTION_CONDITION = "subscriptionCondition"; + + /** Server-side parameter name of the notification data format (English business identifier). */ + public static final String PARAM_NOTIFICATION_DATA_FORMAT = "notificationDataFormat"; + + /** AgentCard query name of the sample server. */ + public static final String AGENT_NAME = "SPN Service Recovery Agent"; + + /** AgentCard query organization of the sample server. */ + public static final String AGENT_ORGANIZATION = "Huawei"; + + private ServiceRecoverySampleInputs() { + } + + private static String slotName(String key) { + java.util.Properties properties = new java.util.Properties(); + try (InputStream inputStream = ServiceRecoverySampleInputs.class + .getClassLoader() + .getResourceAsStream(SLOT_NAMES_RESOURCE)) { + if (inputStream == null) { + throw new IllegalStateException("Sample resource not found: " + SLOT_NAMES_RESOURCE); + } + properties.load(new java.io.InputStreamReader(inputStream, StandardCharsets.UTF_8)); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read sample resource: " + SLOT_NAMES_RESOURCE, exception); + } + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing slot name for key: " + key); + } + return value; + } + + /** + * Loads the natural-language subscription request from + * {@code sample/service-recovery/client/input-with-text.txt}. + * + * @return the natural-language input text + */ + public static String naturalLanguageInput() { + return readText(INPUT_RESOURCE); + } + + /** + * Loads the structured subscription request consumed by + * {@code generateNotificationPromptFromDataWithSchema} from + * {@code sample/service-recovery/client/input-with-data.json}. + * + *

    The keys follow the English business-style parameter names of the server-side validation + * schema ({@code subscriptionCondition}/{@code notificationDataFormat}), demonstrating that the + * schema-guided generation path maps caller-provided structured data onto the template slots. + * + * @return structured notification input as a string-to-object map + */ + public static Map structuredInput() { + return readJson(STRUCTURED_INPUT_RESOURCE); + } + + /** + * Loads the data schema guiding the structured extraction of + * {@code generateNotificationPromptFromDataWithSchema} from + * {@code sample/service-recovery/client/schema.json}. + * + *

    The schema describes the two input-with-data fields (type, description, required) and is + * handed to the SDK verbatim; the SDK prints its entries into the LLM slot-extraction prompt. + * + * @return data schema map for schema-guided extraction + */ + public static Map dataSchema() { + return readJson(DATA_SCHEMA_RESOURCE); + } + + /** + * Loads the parameter schema handed to {@code A2ATServer.validateAndFillingNotificationData} + * from {@code sample/service-recovery/server/schema.json}. + * + *

    The schema mirrors the slot.json format ({@code $schema}/{@code additionalProperties}/ + * {@code description}/{@code examples}/{@code x-a2at-value-constraint}), but the property keys + * are English business-style identifiers ({@code subnetwork}/{@code reportFormat}) instead of + * the zh-CN slot names, demonstrating that the validation API fills parameters per the + * caller-provided schema rather than echoing template slots. + * + * @return parameter JSON schema for validation and parameter extraction + */ + public static Map validationParamSchema() { + return readJson(VALIDATION_SCHEMA_RESOURCE); + } + + private static String readText(String resourcePath) { + try (InputStream inputStream = ServiceRecoverySampleInputs.class + .getClassLoader() + .getResourceAsStream(resourcePath)) { + if (inputStream == null) { + throw new IllegalStateException("Sample resource not found: " + resourcePath); + } + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read sample resource: " + resourcePath, exception); + } + } + + private static Map readJson(String resourcePath) { + try (InputStream inputStream = ServiceRecoverySampleInputs.class + .getClassLoader() + .getResourceAsStream(resourcePath)) { + if (inputStream == null) { + throw new IllegalStateException("Sample resource not found: " + resourcePath); + } + return OBJECT_MAPPER.readValue(inputStream, new TypeReference>() {}); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read sample resource: " + resourcePath, exception); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/VerificationCheck.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/VerificationCheck.java new file mode 100644 index 00000000..22522a3b --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/VerificationCheck.java @@ -0,0 +1,34 @@ +package net.openan.a2at.sample.service_recovery; + +/** + * One verification outcome of the service-recovery sample. + * + * @param name check name identifying the verified expectation + * @param passed whether the expectation held + * @param detail human-readable outcome detail, never null + * @since 2026-08 + */ +public record VerificationCheck(String name, boolean passed, String detail) { + + /** + * Creates a passed check. + * + * @param name check name + * @param detail outcome detail + * @return passed check + */ + public static VerificationCheck passed(String name, String detail) { + return new VerificationCheck(name, true, detail); + } + + /** + * Creates a failed check. + * + * @param name check name + * @param detail outcome detail + * @return failed check + */ + public static VerificationCheck failed(String name, String detail) { + return new VerificationCheck(name, false, detail); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/ClientSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/ClientSampleMain.java new file mode 100644 index 00000000..b7d22b26 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/ClientSampleMain.java @@ -0,0 +1,116 @@ +package net.openan.a2at.sample.service_recovery.client; + +import java.nio.file.Path; +import java.util.function.Consumer; +import net.openan.a2at.sample.service_recovery.VerificationCheck; +import net.openan.a2at.sample.service_recovery.client.flow.ClientFlowOutcome; +import net.openan.a2at.sample.service_recovery.client.flow.ClientSampleFlow; +import net.openan.a2at.sample.service_recovery.client.runtime.A2AJavaClientRuntime; +import net.openan.a2at.sample.service_recovery.client.runtime.DefaultSampleClientRuntime; +import net.openan.a2at.sample.service_recovery.client.runtime.SampleClientRuntime; +import net.openan.a2at.sample.service_recovery.client.runtime.SampleClientRuntimeFactory; +import net.openan.a2at.sample.service_recovery.shared.logging.SampleLoggingFormatter; +import net.openan.a2at.sample.service_recovery.shared.mock.SampleMockLlmInstaller; + +/** + * Main entry orchestration for the service-recovery client sample. + * + *

    The process exits with code 0 when every verification check of the flow passes and 1 otherwise. + * + * @since 2026-08 + */ +public final class ClientSampleMain { + + private static final String MOCK_RESOURCE_ROOT = + "sample/service-recovery/mock_responses"; + + private ClientSampleMain() { + } + + /** + * Resolves the sample environment file path, preferring an explicit argument. + * + * @param args command line arguments, only the first one is used + * @return resolved environment file path + */ + public static Path resolveEnvPath(String[] args) { + return args.length > 0 ? Path.of(args[0]) : DefaultSampleClientRuntime.resolveDefaultEnvPath(); + } + + /** + * Runs the client flow and returns its outcome. + * + * @param envPath sample environment file path + * @param runtimeFactory client runtime factory + * @param logSink log sink, may be null + * @return client flow outcome with verification checks and received events + */ + public static ClientFlowOutcome runMain( + Path envPath, + SampleClientRuntimeFactory runtimeFactory, + Consumer logSink) { + boolean mockNeeded = SampleMockLlmInstaller.isMockNeeded(envPath); + SampleMockLlmInstaller.installLlmLogger(mockNeeded, "client"); + Path resolvedEnvPath = SampleMockLlmInstaller.resolveEnvPath(envPath, MOCK_RESOURCE_ROOT); + if (!resolvedEnvPath.equals(envPath)) { + emit(logSink, "[client] no LLM API key found, using mock LLM responses for e2e"); + } + SampleClientRuntime runtime = runtimeFactory.create(resolvedEnvPath); + try { + if (!(runtime instanceof A2AJavaClientRuntime a2aRuntime)) { + throw new IllegalStateException("Sample client runtime must implement A2AJavaClientRuntime"); + } + return ClientSampleFlow.runClientFlow( + runtime.registryClient(), + runtime.promptClient(), + a2aRuntime, + logSink, + resolveMaxArtifacts()); + } finally { + runtime.close(); + } + } + + /** + * Sample entry point. + * + * @param args optional environment file path as the first argument + */ + public static void main(String[] args) { + ClientFlowOutcome outcome = runMain(resolveEnvPath(args), DefaultSampleClientRuntime::new, System.out::println); + printSummary(outcome); + System.exit(outcome.checks().stream().allMatch(VerificationCheck::passed) ? 0 : 1); + } + + private static void printSummary(ClientFlowOutcome outcome) { + long passedCount = outcome.checks().stream().filter(VerificationCheck::passed).count(); + long failedCount = outcome.checks().size() - passedCount; + System.out.println("==== service-recovery client verification summary ===="); + for (VerificationCheck check : outcome.checks()) { + System.out.println((check.passed() ? "PASS " : "FAIL ") + check.name() + " — " + check.detail()); + } + System.out.println("passed=" + passedCount + " failed=" + failedCount); + System.out.println(failedCount == 0 ? "VERIFICATION PASSED" : "VERIFICATION FAILED"); + } + + private static int resolveMaxArtifacts() { + String raw = System.getenv("A2AT_SAMPLE_MAX_ARTIFACTS"); + if (raw != null) { + String trimmed = raw.trim(); + if (!trimmed.isEmpty()) { + try { + return Integer.parseInt(trimmed); + } catch (NumberFormatException exception) { + // ignore and fall through to default + } + } + } + return 0; + } + + private static void emit(Consumer logSink, String message) { + if (logSink != null) { + logSink.accept(SampleLoggingFormatter.timestamped(message)); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/A2AJavaClientEventMapper.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/A2AJavaClientEventMapper.java new file mode 100644 index 00000000..938bb956 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/A2AJavaClientEventMapper.java @@ -0,0 +1,96 @@ +package net.openan.a2at.sample.service_recovery.client.flow; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.a2aproject.sdk.spec.TextPart; + +/** + * Converts real a2a-java client events into stable sample payload fragments. + * + * @since 2026-05 + */ +public final class A2AJavaClientEventMapper { + + private A2AJavaClientEventMapper() { + } + + public static Map toPayload(ClientEvent event) { + if (event instanceof TaskEvent taskEvent) { + return Map.of( + "status", + Map.of("state", taskEvent.getTask().status().state().name())); + } + if (event instanceof TaskUpdateEvent taskUpdateEvent) { + if (taskUpdateEvent.getUpdateEvent() instanceof TaskStatusUpdateEvent statusUpdateEvent) { + return Map.of("status", Map.of("state", statusUpdateEvent.status().state().name())); + } + if (taskUpdateEvent.getUpdateEvent() instanceof TaskArtifactUpdateEvent artifactUpdateEvent) { + return Map.of("artifact", toArtifactMap(artifactUpdateEvent.artifact())); + } + } + if (event instanceof MessageEvent messageEvent) { + return Map.of("message", Map.of("parts", toParts(messageEvent.getMessage()))); + } + throw new ValueErrorException("Unsupported raw stream response: " + event); + } + + private static Map toArtifactMap(Artifact artifact) { + java.util.LinkedHashMap payload = new java.util.LinkedHashMap<>(); + payload.put("artifactId", resolveArtifactId(artifact)); + if (artifact.name() != null) { + payload.put("name", artifact.name()); + } + if (artifact.description() != null) { + payload.put("description", artifact.description()); + } + payload.put("parts", toParts(artifact.parts())); + if (artifact.metadata() != null && !artifact.metadata().isEmpty()) { + payload.put("metadata", artifact.metadata()); + } + return payload; + } + + private static String resolveArtifactId(Artifact artifact) { + if (artifact.metadata() != null) { + Object metadataArtifactId = artifact.metadata().get("artifactId"); + if (metadataArtifactId != null) { + String resolved = String.valueOf(metadataArtifactId).trim(); + if (!resolved.isEmpty()) { + return resolved; + } + } + } + return artifact.artifactId(); + } + + private static List> toParts(Message message) { + return toParts(message.parts()); + } + + private static List> toParts(List> parts) { + return parts.stream().map(A2AJavaClientEventMapper::toPartMap).toList(); + } + + private static Map toPartMap(Part part) { + if (part instanceof TextPart textPart) { + return Map.of("text", textPart.text()); + } + if (part instanceof DataPart dataPart) { + return Map.of("data", dataPart.data()); + } + return Map.of("value", String.valueOf(part)); + } +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientEventStreamBuffer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientEventStreamBuffer.java new file mode 100644 index 00000000..6270453c --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientEventStreamBuffer.java @@ -0,0 +1,84 @@ +package net.openan.a2at.sample.service_recovery.client.flow; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; +import org.a2aproject.sdk.client.ClientEvent; + +/** + * Bridges async a2a-java client callbacks into a blocking iterable stream. + * + * @since 2026-05 + */ +public final class ClientEventStreamBuffer implements Iterable { + private static final Object END = new Object(); + + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + + public void append(ClientEvent event) { + queue.add(event); + } + + public void fail(Throwable throwable) { + queue.add(throwable); + queue.add(END); + } + + public void close() { + queue.add(END); + } + + @Override + public Iterator iterator() { + return new Iterator<>() { + private Object nextItem; + private boolean finished; + + @Override + public boolean hasNext() { + if (finished) { + return false; + } + if (nextItem == null) { + nextItem = takeNextItem(); + } + if (nextItem == END) { + finished = true; + return false; + } + return true; + } + + @Override + public ClientEvent next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + Object resolved = nextItem; + nextItem = null; + if (resolved instanceof Throwable throwable) { + throw new ValueErrorException("A2A message:stream request failed: " + throwable.getMessage()); + } + if (resolved instanceof ClientEvent event) { + return event; + } + throw new ValueErrorException("A2A message:stream returned an unexpected event type."); + } + + private Object takeNextItem() { + try { + Object item = queue.take(); + if (item instanceof Throwable throwable) { + throw new ValueErrorException("A2A message:stream request failed: " + throwable.getMessage()); + } + return item; + } catch (InterruptedException exception) { + finished = true; + return END; + } + } + }; + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientFlowOutcome.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientFlowOutcome.java new file mode 100644 index 00000000..b1c8664a --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientFlowOutcome.java @@ -0,0 +1,15 @@ +package net.openan.a2at.sample.service_recovery.client.flow; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.service_recovery.VerificationCheck; + +/** + * Outcome of one client sample run. + * + * @param checks verification checks collected from both generation steps and the request build + * @param events normalized stream events received from the server, in arrival order + * @since 2026-08 + */ +public record ClientFlowOutcome(List checks, List> events) { +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java new file mode 100644 index 00000000..a406ded9 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java @@ -0,0 +1,267 @@ +package net.openan.a2at.sample.service_recovery.client.flow; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sample.service_recovery.ServiceRecoverySampleInputs; +import net.openan.a2at.sample.service_recovery.VerificationCheck; +import net.openan.a2at.sample.service_recovery.client.prompt.SamplePromptClient; +import net.openan.a2at.sample.service_recovery.client.registry.SampleRegistryClient; +import net.openan.a2at.sample.service_recovery.client.request.A2AJavaRequestBuilder; +import net.openan.a2at.sample.service_recovery.client.request.BuiltA2AJavaRequest; +import net.openan.a2at.sample.service_recovery.client.runtime.A2AJavaClientRuntime; +import net.openan.a2at.sample.service_recovery.shared.endpoint.AgentEndpointResolver; +import net.openan.a2at.sample.service_recovery.shared.endpoint.ResolvedAgentEndpoint; +import net.openan.a2at.sample.service_recovery.shared.logging.SampleLoggingFormatter; +import net.openan.a2at.sample.service_recovery.shared.stream.SampleStreamEventNormalizer; +import net.openan.a2at.sdk.core.model.MetadataContent; +import org.a2aproject.sdk.client.ClientEvent; + +/** + * Core client-side orchestration for the service-recovery e2e sample. + * + *

    One client process runs two independent subscription rounds, so each generated prompt is + * validated by the server on its own merits — one API producing a usable prompt can never mask the + * other producing a broken one: + * + *

      + *
    1. {@code generateNotificationPromptFromText} — natural-language subscription request to a + * rendered prompt, sent to the server over the real a2a-java transport; + *
    2. {@code generateNotificationPromptFromDataWithSchema} — structured input plus data schema to + * a rendered prompt, sent through the same chain. + * + *

      Each round's server task reports + * {@link ServiceRecoverySampleInputs#NOTIFICATION_REPORT_COUNT} notification artifacts and then + * reaches its terminal {@code TASK_STATE_COMPLETED} state, so every stream ends on its own; the + * client only stops earlier when {@code maxArtifacts} is positive. + * + *

      Console logging is trimmed to the essentials: the API inputs and outputs, the LLM requests + * and responses (logged by the shared LLM wrapper), and the received notification artifacts in + * full detail. Stage lines carry a small set of markers for fast visual scanning. + * + * @since 2026-08 + */ +public final class ClientSampleFlow { + + private ClientSampleFlow() { + } + + /** + * Runs the client flow. + * + * @param registryClient registry client used to resolve the server AgentCard + * @param promptClient prompt-generation bridge + * @param a2aRuntime a2a-java client runtime + * @param logSink log sink, may be null + * @param maxArtifacts stop each round after so many artifacts when positive + * @return client flow outcome with verification checks and received events + */ + public static ClientFlowOutcome runClientFlow( + SampleRegistryClient registryClient, + SamplePromptClient promptClient, + A2AJavaClientRuntime a2aRuntime, + Consumer logSink, + int maxArtifacts) { + List checks = new ArrayList<>(); + Map agentCard = registryClient.queryAgentCardByName( + ServiceRecoverySampleInputs.AGENT_NAME, ServiceRecoverySampleInputs.AGENT_ORGANIZATION); + ResolvedAgentEndpoint endpoint = AgentEndpointResolver.resolvePreferredInterface(agentCard); + emit(logSink, "> endpoint: " + endpoint.url() + " (" + endpoint.protocolBinding() + ")"); + + List> normalizedEvents = new ArrayList<>(); + + MetadataContent fromText = verifyGenerateFromText(promptClient, logSink, checks); + if (fromText != null) { + runSubscriptionRound( + "fromText", fromText, a2aRuntime, agentCard, logSink, checks, maxArtifacts, normalizedEvents); + } else { + checks.add(VerificationCheck.failed( + "fromText: a2a request sent", "skipped, the from-text generation step produced no rendered prompt")); + } + + MetadataContent fromData = verifyGenerateFromData(promptClient, logSink, checks); + if (fromData != null) { + runSubscriptionRound( + "fromData", fromData, a2aRuntime, agentCard, logSink, checks, maxArtifacts, normalizedEvents); + } else { + checks.add(VerificationCheck.failed( + "fromData: a2a request sent", + "skipped, the from-data generation step produced no rendered prompt")); + } + + return new ClientFlowOutcome(checks, normalizedEvents); + } + + /** + * Runs one subscription round: sends the rendered prompt as an A2A message:stream request and + * collects the server events until the task completes (or the artifact budget is reached). + * + * @param label round label used in check names and logs + * @param content rendered prompt of this round + * @param a2aRuntime a2a-java client runtime + * @param agentCard resolved registry AgentCard payload + * @param logSink log sink, may be null + * @param checks verification checks collected so far + * @param maxArtifacts stop this round after so many artifacts when positive + * @param normalizedEvents collected normalized events across rounds + */ + private static void runSubscriptionRound( + String label, + MetadataContent content, + A2AJavaClientRuntime a2aRuntime, + Map agentCard, + Consumer logSink, + List checks, + int maxArtifacts, + List> normalizedEvents) { + emit(logSink, ">> " + label + " subscription request sent (A2A message:stream)"); + BuiltA2AJavaRequest builtRequest = A2AJavaRequestBuilder.buildStreamRequest( + content.promptText(), content.extensionUri(), "create service recovery subscription"); + checks.add(VerificationCheck.passed(label + ": a2a request sent", "extension=" + content.extensionUri())); + + int artifactCount = 0; + int eventCount = 0; + String terminalState = ""; + try { + for (ClientEvent rawEvent : a2aRuntime.sendMessage( + agentCard, builtRequest.request(), builtRequest.callContext(), logSink)) { + Map payload = A2AJavaClientEventMapper.toPayload(rawEvent); + Map normalizedEvent = SampleStreamEventNormalizer.normalize(payload); + normalizedEvents.add(normalizedEvent); + eventCount++; + if ("status".equals(normalizedEvent.get("kind"))) { + terminalState = String.valueOf(normalizedEvent.get("state")); + emit(logSink, "- task-status: " + terminalState); + } else if ("artifact".equals(normalizedEvent.get("kind"))) { + // The received notification is the sample's deliverable: log it in full detail. + emit(logSink, "* notification received (artifact " + (artifactCount + 1) + "):"); + emit(logSink, formatArtifact(normalizedEvent.get("artifact"))); + artifactCount++; + if (maxArtifacts > 0 && artifactCount >= maxArtifacts) { + emit(logSink, "- reached max-artifacts (" + maxArtifacts + "), stopping this round"); + break; + } + } + } + } catch (RuntimeException error) { + checks.add(VerificationCheck.failed(label + ": a2a response received", describe(error))); + return; + } + int expectedArtifacts = + maxArtifacts > 0 ? maxArtifacts : ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT; + checks.add(VerificationCheck.passed( + label + ": a2a response received", "events=" + eventCount + " artifacts=" + artifactCount)); + checks.add(expectation( + label + ": notification artifacts collected", + artifactCount >= expectedArtifacts, + "expected " + expectedArtifacts + ", got " + artifactCount)); + if (maxArtifacts > 0) { + checks.add(VerificationCheck.passed( + label + ": subscription task completed", + "skipped, client stopped early after " + maxArtifacts + " artifact(s); the server task " + + "self-completes after " + ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT + + " reports")); + } else { + checks.add(expectation( + label + ": subscription task completed", + "TASK_STATE_COMPLETED".equals(terminalState), + "terminal task state was " + terminalState)); + } + } + + private static MetadataContent verifyGenerateFromText( + SamplePromptClient promptClient, + Consumer logSink, + List checks) { + String promptInput = ServiceRecoverySampleInputs.naturalLanguageInput(); + emit(logSink, "=================================================================="); + emit(logSink, "== API 1 generateNotificationPromptFromText"); + emit(logSink, "=================================================================="); + emit(logSink, "-- input (natural language):"); + emit(logSink, promptInput); + try { + MetadataContent content = + promptClient.generateNotificationPromptFromText(promptInput, ServiceRecoverySampleInputs.TEMPLATE_URI); + emit(logSink, "-- output (rendered subscription prompt):"); + emit(logSink, content.promptText()); + emit(logSink, "-- templateUri=" + content.templateUri() + + " extensionUri=" + content.extensionUri()); + + checks.add(VerificationCheck.passed("fromText: generation returned", "no exception")); + addGenerationChecks(checks, "fromText", content); + return content; + } catch (RuntimeException error) { + checks.add(VerificationCheck.failed("fromText: generation returned", describe(error))); + return null; + } + } + + private static MetadataContent verifyGenerateFromData( + SamplePromptClient promptClient, + Consumer logSink, + List checks) { + Map data = ServiceRecoverySampleInputs.structuredInput(); + Map schema = ServiceRecoverySampleInputs.dataSchema(); + emit(logSink, "=================================================================="); + emit(logSink, "== API 1' generateNotificationPromptFromDataWithSchema"); + emit(logSink, "=================================================================="); + emit(logSink, "-- input data:"); + emit(logSink, String.valueOf(data)); + emit(logSink, "-- input data schema:"); + emit(logSink, String.valueOf(schema)); + try { + MetadataContent content = promptClient.generateNotificationPromptFromDataWithSchema( + data, schema, ServiceRecoverySampleInputs.TEMPLATE_URI); + emit(logSink, "-- output (rendered subscription prompt):"); + emit(logSink, content.promptText()); + emit(logSink, "-- templateUri=" + content.templateUri() + + " extensionUri=" + content.extensionUri()); + + checks.add(VerificationCheck.passed("fromData: generation returned", "no exception")); + addGenerationChecks(checks, "fromData", content); + return content; + } catch (RuntimeException error) { + checks.add(VerificationCheck.failed("fromData: generation returned", describe(error))); + return null; + } + } + + private static String formatArtifact(Object artifact) { + return String.valueOf(artifact); + } + + private static void addGenerationChecks( + List checks, + String label, + MetadataContent content) { + checks.add(expectation( + label + ": templateUri matches", + ServiceRecoverySampleInputs.TEMPLATE_URI.equals(content.templateUri()), + "expected " + ServiceRecoverySampleInputs.TEMPLATE_URI + ", got " + content.templateUri())); + checks.add(expectation( + label + ": prompt text is non-blank", + content.promptText() != null && !content.promptText().isBlank(), + "promptText blank")); + checks.add(expectation( + label + ": prompt carries the report format", + content.promptText() != null + && content.promptText().contains(ServiceRecoverySampleInputs.SLOT_NOTIFICATION_DATA_FORMAT), + "promptText does not carry the required " + + ServiceRecoverySampleInputs.SLOT_NOTIFICATION_DATA_FORMAT + " slot")); + } + + private static VerificationCheck expectation(String name, boolean passed, String failureDetail) { + return passed ? VerificationCheck.passed(name, "ok") : VerificationCheck.failed(name, failureDetail); + } + + private static String describe(RuntimeException error) { + return error.getClass().getSimpleName() + ": " + error.getMessage(); + } + + private static void emit(Consumer logSink, String message) { + if (logSink != null) { + logSink.accept(SampleLoggingFormatter.timestamped(message)); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/SampleStreamTerminalStateDecider.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/SampleStreamTerminalStateDecider.java new file mode 100644 index 00000000..8acfc56e --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/SampleStreamTerminalStateDecider.java @@ -0,0 +1,44 @@ +package net.openan.a2at.sample.service_recovery.client.flow; + +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.TaskEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; + +/** + * Decides when a streamed A2A task has reached a terminal state for sample client iteration. + * + * @since 2026-05 + */ +public final class SampleStreamTerminalStateDecider { + private SampleStreamTerminalStateDecider() { + } + + public static boolean isTerminal(ClientEvent event) { + if (event instanceof TaskEvent taskEvent) { + return isTerminal(taskEvent.getTask().status().state()); + } + if (event instanceof TaskUpdateEvent taskUpdateEvent) { + if (taskUpdateEvent.getUpdateEvent() instanceof TaskStatusUpdateEvent statusUpdateEvent) { + return isTerminal(statusUpdateEvent.status().state()); + } + if (taskUpdateEvent.getUpdateEvent() instanceof TaskArtifactUpdateEvent) { + return false; + } + } + return false; + } + + private static boolean isTerminal(TaskState state) { + return state == TaskState.TASK_STATE_COMPLETED + || state == TaskState.TASK_STATE_FAILED + || state == TaskState.TASK_STATE_CANCELED + || state == TaskState.TASK_STATE_REJECTED + || state == TaskState.TASK_STATE_INPUT_REQUIRED + || state == TaskState.TASK_STATE_AUTH_REQUIRED; + } +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/package-info.java new file mode 100644 index 00000000..ee6496f2 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/package-info.java @@ -0,0 +1,7 @@ +/** + * Client-side sample entrypoints and orchestration for the publishable a2a-t sample module. + * + * @since 2026-05 + */ +package net.openan.a2at.sample.service_recovery.client; + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/A2ATNotificationPromptClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/A2ATNotificationPromptClient.java new file mode 100644 index 00000000..5b447cbb --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/A2ATNotificationPromptClient.java @@ -0,0 +1,44 @@ +package net.openan.a2at.sample.service_recovery.client.prompt; + +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; + +/** + * {@link SamplePromptClient} adapter delegating to the real {@link A2ATClient} facade. + * + *

      Both generation entry points share one facade instance, proving the two Notification-T + * generation APIs can run sequentially over the same client configuration. + * + * @since 2026-08 + */ +public final class A2ATNotificationPromptClient implements SamplePromptClient { + + private final A2ATClient delegate; + + /** + * Creates the adapter over one client facade. + * + * @param delegate SDK client facade used for the Notification-T generation calls + */ + public A2ATNotificationPromptClient(A2ATClient delegate) { + this.delegate = delegate; + } + + @Override + public MetadataContent generateNotificationPromptFromText(String text, String templateUri) { + return delegate.generateNotificationPromptFromText(text, parse(templateUri)); + } + + @Override + public MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, String templateUri) { + return delegate.generateNotificationPromptFromDataWithSchema(data, schema, parse(templateUri)); + } + + private static TemplateUri parse(String templateUri) { + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Malformed template URI: " + templateUri)); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/SamplePromptClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/SamplePromptClient.java new file mode 100644 index 00000000..1f2a42a7 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/SamplePromptClient.java @@ -0,0 +1,35 @@ +package net.openan.a2at.sample.service_recovery.client.prompt; + +import java.util.Map; +import net.openan.a2at.sdk.core.model.MetadataContent; + +/** + * Prompt-generation bridge used by the client sample flow. + * + *

      Exposes the two Notification-T generation entry points verified by this sample: the from-text + * variant and the schema-guided from-data variant. + * + * @since 2026-08 + */ +public interface SamplePromptClient { + + /** + * Generates a notification prompt with metadata from natural-language input. + * + * @param text natural-language notification input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + MetadataContent generateNotificationPromptFromText(String text, String templateUri); + + /** + * Generates a notification prompt with metadata from structured input and a data schema. + * + * @param data structured notification input as a string-to-object map + * @param schema data schema map for schema-guided slot extraction + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + */ + MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, String templateUri); +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/registry/SampleRegistryClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/registry/SampleRegistryClient.java new file mode 100644 index 00000000..f9f89a9c --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/registry/SampleRegistryClient.java @@ -0,0 +1,15 @@ +package net.openan.a2at.sample.service_recovery.client.registry; + +import java.util.Map; + +/** + * Registry lookup contract used by the client sample flow. + * + * @since 2026-05 + */ +public interface SampleRegistryClient { + + Map queryAgentCardByName(String name, String organization); +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/A2AJavaRequestBuilder.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/A2AJavaRequestBuilder.java new file mode 100644 index 00000000..7602a715 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/A2AJavaRequestBuilder.java @@ -0,0 +1,34 @@ +package net.openan.a2at.sample.service_recovery.client.request; + +import java.util.Map; +import java.util.UUID; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TextPart; + +/** + * Builds real a2a-java message requests for the sample client flow. + * + * @since 2026-05 + */ +public final class A2AJavaRequestBuilder { + + private A2AJavaRequestBuilder() { + } + + public static BuiltA2AJavaRequest buildStreamRequest( + String promptText, String extensionUri, String metadataPayload) { + Message message = Message.builder() + .messageId(UUID.randomUUID().toString()) + .role(Message.Role.ROLE_USER) + .parts(new TextPart(metadataPayload)) + .metadata(Map.of(extensionUri, promptText)) + .build(); + MessageSendParams request = MessageSendParams.builder().message(message).build(); + ClientCallContext callContext = new ClientCallContext(Map.of(), Map.of("A2A-Extensions", extensionUri)); + return new BuiltA2AJavaRequest(request, callContext); + } +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/BuiltA2AJavaRequest.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/BuiltA2AJavaRequest.java new file mode 100644 index 00000000..1356e3c8 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/BuiltA2AJavaRequest.java @@ -0,0 +1,14 @@ +package net.openan.a2at.sample.service_recovery.client.request; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.MessageSendParams; + +/** + * Real a2a-java request bundle used by the sample client flow. + * + * @since 2026-05 + */ +public record BuiltA2AJavaRequest(MessageSendParams request, ClientCallContext callContext) { +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/A2AJavaClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/A2AJavaClientRuntime.java new file mode 100644 index 00000000..5c7ab993 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/A2AJavaClientRuntime.java @@ -0,0 +1,30 @@ +package net.openan.a2at.sample.service_recovery.client.runtime; + +import java.util.Map; +import java.util.function.Consumer; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.MessageSendParams; + +/** + * Transitional seam for assembling the real a2a-java client runtime. + * + * @since 2026-08 + */ +public interface A2AJavaClientRuntime { + + /** + * Sends one message:stream request through the real a2a-java client. + * + * @param agentCard resolved registry AgentCard payload + * @param request message send parameters + * @param callContext call context carrying the A2A-Extensions header + * @param logSink log sink, may be null + * @return iterable of received client events + */ + Iterable sendMessage( + Map agentCard, + MessageSendParams request, + ClientCallContext callContext, + Consumer logSink); +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/DefaultSampleClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/DefaultSampleClientRuntime.java new file mode 100644 index 00000000..5690b4d6 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/DefaultSampleClientRuntime.java @@ -0,0 +1,252 @@ +package net.openan.a2at.sample.service_recovery.client.runtime; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sample.service_recovery.client.flow.ClientEventStreamBuffer; +import net.openan.a2at.sample.service_recovery.client.flow.SampleStreamTerminalStateDecider; +import net.openan.a2at.sample.service_recovery.client.prompt.A2ATNotificationPromptClient; +import net.openan.a2at.sample.service_recovery.client.prompt.SamplePromptClient; +import net.openan.a2at.sample.service_recovery.client.registry.SampleRegistryClient; +import net.openan.a2at.sample.service_recovery.shared.env.SampleEnvironmentPathResolver; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; +import net.openan.a2at.sample.service_recovery.shared.registry.RegistryAgentCardMapper; +import net.openan.a2at.sdk.client.A2ATClient; +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfig; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.MessageSendParams; + +/** + * Default runtime assembly for the client sample entrypoint. + * + * @since 2026-08 + */ +public final class DefaultSampleClientRuntime implements SampleClientRuntime, A2AJavaClientRuntime { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final Path envPath; + + private final HttpClient httpClient; + + private final SampleRegistryClient registryClient; + + private final SamplePromptClient promptClient; + + public DefaultSampleClientRuntime(Path envPath) { + this.envPath = envPath; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(resolveTimeoutSeconds(envPath))) + .build(); + this.registryClient = new DefaultRegistryClient(); + this.promptClient = new A2ATNotificationPromptClient(new A2ATClient(envPath)); + } + + public static Path resolveDefaultEnvPath() { + Path sampleEnvDir = Path.of("a2a-t-sample", "src", "main", "resources", "sample", "service-recovery", "client"); + return SampleEnvironmentPathResolver.resolve(sampleEnvDir, "client.env", "client.env"); + } + + @Override + public SampleRegistryClient registryClient() { + return registryClient; + } + + @Override + public SamplePromptClient promptClient() { + return promptClient; + } + + @Override + public void close() { + } + + @Override + public Iterable sendMessage( + Map agentCard, + MessageSendParams request, + ClientCallContext callContext, + Consumer logSink) { + Object streamingClient = createStreamingClient(agentCard); + if (!(streamingClient instanceof Client client)) { + throw new ValueErrorException("Unsupported a2a-java client instance: " + streamingClient); + } + ClientEventStreamBuffer eventStreamBuffer = new ClientEventStreamBuffer(); + try { + client.sendMessage( + request, + List.of((event, ignored) -> { + eventStreamBuffer.append(event); + if (SampleStreamTerminalStateDecider.isTerminal(event)) { + eventStreamBuffer.close(); + } + }), + eventStreamBuffer::fail, + callContext); + return eventStreamBuffer; + } catch (A2AClientException exception) { + client.close(); + throw new ValueErrorException("A2A message:stream request failed: " + exception.getMessage()); + } + } + + private final class DefaultRegistryClient implements SampleRegistryClient { + @Override + public Map queryAgentCardByName(String name, String organization) { + String directAgentCardUrl = resolveDirectAgentCardUrl(envPath); + if (!directAgentCardUrl.isBlank()) { + return queryAgentCardFromServerRoot(directAgentCardUrl); + } + try { + String encodedName = encodePathSegment(name); + String encodedOrganization = encodePathSegment(organization); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(resolveRegistryBaseUrl(envPath) + + "/rest/v1/registry-center/agent-cards/" + + encodedOrganization + + "/" + + encodedName)) + .timeout(Duration.ofSeconds(resolveTimeoutSeconds(envPath))) + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new ValueErrorException( + "AgentCard query by name failed: status=" + response.statusCode() + " body=" + response.body()); + } + Map payload = parseObject(response.body()); + Object agentCards = payload.get("agentCards"); + if (agentCards instanceof List cards + && !cards.isEmpty() + && cards.get(0) instanceof Map firstCard) { + return castAgentCard(firstCard); + } + throw new ValueErrorException("Registry query by name returned no AgentCard entries"); + } catch (IOException exception) { + throw new ValueErrorException("Registry query by name failed: " + errorMessage(exception)); + } catch (InterruptedException exception) { + throw new ValueErrorException("Registry query by name was interrupted: " + errorMessage(exception)); + } + } + + private Map queryAgentCardFromServerRoot(String agentCardUrl) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(agentCardUrl)) + .timeout(Duration.ofSeconds(resolveTimeoutSeconds(envPath))) + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new ValueErrorException( + "AgentCard query from server root failed: status=" + + response.statusCode() + + " body=" + + response.body()); + } + return parseObject(response.body()); + } catch (IOException exception) { + throw new ValueErrorException("AgentCard query from server root failed: " + errorMessage(exception)); + } catch (InterruptedException exception) { + throw new ValueErrorException( + "AgentCard query from server root was interrupted: " + errorMessage(exception)); + } + } + } + + @SuppressWarnings("unchecked") + private static Map castAgentCard(Map agentCard) { + return (Map) agentCard; + } + + static String encodePathSegment(String value) { + try { + String encoded = new URI(null, null, "/" + value, null).getRawPath(); + return encoded.startsWith("/") ? encoded.substring(1) : encoded; + } catch (java.net.URISyntaxException exception) { + throw new ValueErrorException("Unsupported registry path segment: " + value); + } + } + + private static String errorMessage(Throwable throwable) { + String message = throwable.getMessage(); + return message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message; + } + + private Object createStreamingClient(Map agentCard) { + AgentCard realAgentCard = RegistryAgentCardMapper.toA2AJavaAgentCard(agentCard); + try { + return Client.builder(realAgentCard) + .withTransport(RestTransport.class, new RestTransportConfig()) + .build(); + } catch (A2AClientException exception) { + throw new ValueErrorException("Failed to create a2a-java client: " + exception.getMessage()); + } + } + + private static String resolveRegistryBaseUrl(Path envPath) { + Map envValues = parseEnvFile(envPath); + String explicitUrl = envValues.get("A2A_REGISTRY_URL"); + if (explicitUrl != null && !explicitUrl.isBlank()) { + return explicitUrl; + } + String host = envValues.getOrDefault("REGISTRY_CENTER_HOST", "127.0.0.1"); + String port = envValues.getOrDefault("REGISTRY_CENTER_PORT", "5001"); + return "http://" + host + ":" + Integer.parseInt(port); + } + + private static String resolveDirectAgentCardUrl(Path envPath) { + Map envValues = parseEnvFile(envPath); + String host = envValues.get("A2AT_SAMPLE_HOST"); + String port = envValues.get("A2AT_SAMPLE_PORT"); + if (host != null && !host.isBlank() && port != null && !port.isBlank()) { + return "http://" + host + ":" + Integer.parseInt(port) + "/"; + } + return ""; + } + + private static long resolveTimeoutSeconds(Path envPath) { + Map envValues = parseEnvFile(envPath); + String rawValue = envValues.getOrDefault("A2AT_LLM_TIMEOUT_SECONDS", "60"); + return Long.parseLong(rawValue); + } + + private static Map parseEnvFile(Path envPath) { + try { + List lines = java.nio.file.Files.exists(envPath) ? java.nio.file.Files.readAllLines(envPath) : List.of(); + Map values = new LinkedHashMap<>(); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#") || !trimmed.contains("=")) { + continue; + } + int separatorIndex = trimmed.indexOf('='); + values.put(trimmed.substring(0, separatorIndex).trim(), trimmed.substring(separatorIndex + 1).trim()); + } + return values; + } catch (IOException exception) { + throw new ValueErrorException("Failed to read env file: " + envPath); + } + } + + private static Map parseObject(String text) throws IOException { + return OBJECT_MAPPER.readValue(text, new TypeReference>() { + }); + } +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntime.java new file mode 100644 index 00000000..e0f7a738 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntime.java @@ -0,0 +1,29 @@ +package net.openan.a2at.sample.service_recovery.client.runtime; + +import net.openan.a2at.sample.service_recovery.client.prompt.SamplePromptClient; +import net.openan.a2at.sample.service_recovery.client.registry.SampleRegistryClient; + +/** + * Runtime bundle required by the client sample main flow. + * + * @since 2026-08 + */ +public interface SampleClientRuntime extends AutoCloseable { + + /** + * Returns the registry client used to resolve the server AgentCard. + * + * @return registry client + */ + SampleRegistryClient registryClient(); + + /** + * Returns the prompt-generation bridge over the SDK client facade. + * + * @return prompt client + */ + SamplePromptClient promptClient(); + + @Override + void close(); +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntimeFactory.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntimeFactory.java new file mode 100644 index 00000000..91cbfc17 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntimeFactory.java @@ -0,0 +1,15 @@ +package net.openan.a2at.sample.service_recovery.client.runtime; + +import java.nio.file.Path; + +/** + * Factory for constructing the runtime collaborators used by the client sample main flow. + * + * @since 2026-05 + */ +public interface SampleClientRuntimeFactory { + + SampleClientRuntime create(Path envPath); +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/package-info.java new file mode 100644 index 00000000..ec84b9ee --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/package-info.java @@ -0,0 +1,19 @@ +/** + * Service-recovery Notification-T sample. + * + *

      This sample exercises the Notification-T {@code service-recovery} scenario end to end: + * + *

        + *
      • {@code client} / {@code server} / {@code shared} — full HTTP e2e sample mirroring the + * subscribe-incident layout: the client generates the subscription prompt with the two + * Notification-T generation APIs, sends it over the real a2a-java REST transport, and the server + * validates the rendered prompt with {@code validateAndFillingNotificationData} before reporting + * mock service recovery events as artifacts. + *
      + * + *

      The sample inputs and checks target the zh-CN resources; keep {@code A2AT_LANGUAGE=zh-CN} in + * the sample environment files. + * + * @since 2026-08 + */ +package net.openan.a2at.sample.service_recovery; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/ServerSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/ServerSampleMain.java new file mode 100644 index 00000000..533acfc8 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/ServerSampleMain.java @@ -0,0 +1,98 @@ +package net.openan.a2at.sample.service_recovery.server; + +import java.nio.file.Path; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sample.service_recovery.server.agentcard.ServerSampleAgentCardBuilder; +import net.openan.a2at.sample.service_recovery.server.flow.NotificationPromptValidator; +import net.openan.a2at.sample.service_recovery.server.http.EmbeddedA2AHttpServer; +import net.openan.a2at.sample.service_recovery.server.runtime.A2AJavaServerRuntime; +import net.openan.a2at.sample.service_recovery.server.runtime.DefaultSampleServerRuntime; +import net.openan.a2at.sample.service_recovery.server.runtime.SampleServerRuntime; +import net.openan.a2at.sample.service_recovery.server.runtime.SampleServerRuntimeFactory; +import net.openan.a2at.sample.service_recovery.server.runtime.ServerBind; +import net.openan.a2at.sample.service_recovery.server.runtime.ServerBootstrapResult; +import net.openan.a2at.sample.service_recovery.shared.logging.SampleLoggingFormatter; +import net.openan.a2at.sample.service_recovery.shared.mock.SampleMockLlmInstaller; +import net.openan.a2at.sample.service_recovery.shared.registry.RegistryAgentCardMapper; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; + +/** + * Main entry orchestration for the service-recovery server sample. + * + * @since 2026-08 + */ +public final class ServerSampleMain { + + private static final String MOCK_RESOURCE_ROOT = + "sample/service-recovery/mock_responses"; + + private ServerSampleMain() { + } + + /** + * Resolves the sample environment file path, preferring an explicit argument. + * + * @param args command line arguments, only the first one is used + * @return resolved environment file path + */ + public static Path resolveEnvPath(String[] args) { + return args.length > 0 ? Path.of(args[0]) : DefaultSampleServerRuntime.resolveDefaultEnvPath(); + } + + /** + * Runs the server bootstrap and returns its result. + * + * @param envPath sample environment file path + * @param runtimeFactory server runtime factory + * @param logSink log sink, may be null + * @return bootstrap result carrying the bind address, app and server handle + */ + public static ServerBootstrapResult runMain( + Path envPath, + SampleServerRuntimeFactory runtimeFactory, + Consumer logSink) { + boolean mockNeeded = SampleMockLlmInstaller.isMockNeeded(envPath); + SampleMockLlmInstaller.installLlmLogger(mockNeeded, "server"); + Path resolvedEnvPath = SampleMockLlmInstaller.resolveEnvPath(envPath, MOCK_RESOURCE_ROOT); + if (!resolvedEnvPath.equals(envPath)) { + emit(logSink, "[server] no LLM API key found, using mock LLM responses for e2e"); + } + SampleServerRuntime runtime = runtimeFactory.create(resolvedEnvPath); + ServerBind bind = runtime.resolveBind(); + Map agentCard = runtime.buildAgentCard(bind.host(), bind.port()); + NotificationPromptValidator notificationValidator = runtime.buildNotificationValidator(resolvedEnvPath); + Object app = runtime.buildApp(agentCard, notificationValidator); + Map registrationPayload; + if (runtime instanceof A2AJavaServerRuntime a2aJavaServerRuntime + && a2aJavaServerRuntime.createRestApplication(bind.host(), bind.port()) instanceof org.a2aproject.sdk.spec.AgentCard agentCardModel) { + registrationPayload = RegistryAgentCardMapper.toRegistryRegistrationPayload(agentCardModel); + } else { + registrationPayload = ServerSampleAgentCardBuilder.buildRegistrationPayload(bind.host(), bind.port()); + } + Map registrationResult = runtime.registerAgentCard(registrationPayload, resolvedEnvPath); + AutoCloseable serverHandle = null; + if (runtime instanceof A2AJavaServerRuntime a2aJavaServerRuntime + && app instanceof RequestHandler requestHandler + && a2aJavaServerRuntime.createRestApplication(bind.host(), bind.port()) instanceof org.a2aproject.sdk.spec.AgentCard agentCardModel) { + serverHandle = EmbeddedA2AHttpServer.start(bind.host(), bind.port(), agentCardModel, requestHandler); + } + return new ServerBootstrapResult(bind.host(), bind.port(), app, serverHandle, registrationResult); + } + + /** + * Sample entry point. + * + * @param args optional environment file path as the first argument + */ + public static void main(String[] args) { + runMain(resolveEnvPath(args), envPath -> new DefaultSampleServerRuntime(envPath, System.out::println), + System.out::println); + } + + private static void emit(Consumer logSink, String message) { + if (logSink != null) { + logSink.accept(SampleLoggingFormatter.timestamped(message)); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/agentcard/ServerSampleAgentCardBuilder.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/agentcard/ServerSampleAgentCardBuilder.java new file mode 100644 index 00000000..9d43b1c5 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/agentcard/ServerSampleAgentCardBuilder.java @@ -0,0 +1,69 @@ +package net.openan.a2at.sample.service_recovery.server.agentcard; + +import java.util.List; +import java.util.Map; + +/** + * Builds sample-owned AgentCard payloads for the service-recovery server demo. + * + * @since 2026-08 + */ +public final class ServerSampleAgentCardBuilder { + static final String NOTIFICATION_T_EXTENSION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + static final String NOTIFICATION_T_EXTENSION_URI_NL = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/NL/v1"; + + private ServerSampleAgentCardBuilder() { + } + + /** + * Builds the sample AgentCard payload for one bind address. + * + * @param host bind host + * @param port bind port + * @return AgentCard payload map + */ + public static Map buildAgentCard(String host, int port) { + return Map.of( + "name", "SPN Service Recovery Agent", + "description", "SPN Service Recovery Agent", + "version", "1.0.0", + "defaultInputModes", List.of("application/json", "text/plain"), + "defaultOutputModes", List.of("application/json", "text/plain"), + "provider", Map.of( + "organization", "Huawei", + "url", "https://www.huawei.com"), + "skills", List.of(Map.of( + "id", "Service-Recovery-Subscription", + "name", "Service recovery event reporting", + "description", "Mock service recovery event reporting sample skill", + "tags", List.of("service-recovery", "reporting"))), + "capabilities", Map.of( + "streaming", true, + "pushNotifications", false, + "extensions", List.of( + Map.of( + "uri", NOTIFICATION_T_EXTENSION_URI, + "description", "Extension of structured prompt Notification-T requests."), + Map.of( + "uri", NOTIFICATION_T_EXTENSION_URI_NL, + "description", "Legacy alias of the Notification-T extension."))), + "supportedInterfaces", List.of(Map.of( + "protocolBinding", "HTTP+JSON", + "protocolVersion", "1.0", + "url", "http://" + host + ":" + port))); + } + + /** + * Builds the registry registration payload wrapping the sample AgentCard. + * + * @param host bind host + * @param port bind port + * @return registration payload map + */ + public static Map buildRegistrationPayload(String host, int port) { + return Map.of("agentCards", List.of(buildAgentCard(host, port))); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/NotificationPromptValidator.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/NotificationPromptValidator.java new file mode 100644 index 00000000..2db749ae --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/NotificationPromptValidator.java @@ -0,0 +1,24 @@ +package net.openan.a2at.sample.service_recovery.server.flow; + +import net.openan.a2at.sdk.core.model.FilledParamData; + +/** + * Sample-owned bridge for validating one rendered notification prompt with the server SDK. + * + *

      The bridge wraps {@code A2ATServer.validateAndFillingNotificationData} with the fixed + * service-recovery parameter schema and template URI; a validation failure surfaces as + * {@link net.openan.a2at.sdk.core.validation.ContentValidationException}. + * + * @since 2026-08 + */ +@FunctionalInterface +public interface NotificationPromptValidator { + + /** + * Validates one rendered notification prompt and extracts its subscription parameters. + * + * @param promptText rendered notification prompt text received from the client + * @return filled parameter data carrying the extracted subscription parameters + */ + FilledParamData validateNotificationPrompt(String promptText); +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerFlowInterruptedException.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerFlowInterruptedException.java new file mode 100644 index 00000000..61f6ff10 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerFlowInterruptedException.java @@ -0,0 +1,15 @@ +package net.openan.a2at.sample.service_recovery.server.flow; + +/** + * Internal control exception used by tests to stop the recurring artifact loop. + * + * @since 2026-05 + */ +public final class ServerFlowInterruptedException extends RuntimeException { + + public ServerFlowInterruptedException(String message) { + super(message); + } +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleAgentExecutor.java new file mode 100644 index 00000000..cf8d4bfe --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleAgentExecutor.java @@ -0,0 +1,58 @@ +package net.openan.a2at.sample.service_recovery.server.flow; + +import java.util.Map; +import java.util.function.Consumer; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.A2AError; + +/** + * Real a2a-java agent executor for the service-recovery server flow. + * + * @since 2026-08 + */ +public final class ServerSampleAgentExecutor implements AgentExecutor { + private final NotificationPromptValidator promptValidator; + + private final ServerSleepController sleepController; + + private final Map recoveryEventArtifactData; + + private final Consumer logSink; + + /** + * Creates the agent executor. + * + * @param promptValidator notification prompt validator backed by the server SDK + * @param sleepController controllable sleep used by the recurring artifact loop + * @param recoveryEventArtifactData mock service recovery event payload emitted as artifacts + * @param logSink log sink, may be null + */ + public ServerSampleAgentExecutor( + NotificationPromptValidator promptValidator, + ServerSleepController sleepController, + Map recoveryEventArtifactData, + Consumer logSink) { + this.promptValidator = promptValidator; + this.sleepController = sleepController; + this.recoveryEventArtifactData = recoveryEventArtifactData; + this.logSink = logSink; + } + + @Override + public void execute(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + ServerSampleFlow.executeServerFlow( + requestContext, + promptValidator, + agentEmitter, + sleepController, + recoveryEventArtifactData, + logSink); + } + + @Override + public void cancel(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + agentEmitter.cancel(); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleFlow.java new file mode 100644 index 00000000..b2564291 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleFlow.java @@ -0,0 +1,187 @@ +package net.openan.a2at.sample.service_recovery.server.flow; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import net.openan.a2at.sample.service_recovery.ServiceRecoverySampleInputs; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; +import net.openan.a2at.sample.service_recovery.shared.logging.SampleLoggingFormatter; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.Part; + +/** + * Mock recurring service-recovery-event reporting flow for the server sample. + * + *

      Each received subscription request is validated with the server SDK's + * {@code validateAndFillingNotificationData} API (extracting the subscription parameters), then the + * task reports mock service recovery events as artifacts. After + * {@link ServiceRecoverySampleInputs#NOTIFICATION_REPORT_COUNT} reports the task reaches its + * terminal {@code TASK_STATE_COMPLETED} state, so every subscription task — and with it every + * client stream — ends on its own instead of reporting forever. + * + *

      Console logging is trimmed to the essentials: the validation API output (the filled + * parameters) and the task status transitions. The validation LLM request and response are logged + * by the shared LLM wrapper. + * + * @since 2026-08 + */ +public final class ServerSampleFlow { + static final String NOTIFICATION_T_EXTENSION_URI_NL = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/NL/v1"; + + static final String NOTIFICATION_T_EXTENSION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final String SUBMITTED_MESSAGE = + "Subscription accepted, starting service recovery event reporting task"; + + private static final String WORKING_MESSAGE = "Service recovery event reporting task in progress"; + + private static final String COMPLETE_MESSAGE = + "Service recovery notification reporting finished, subscription task completed"; + + private ServerSampleFlow() { + } + + /** + * Executes the server-side flow for one subscription request. + * + * @param requestContext a2a-java request context + * @param promptValidator notification prompt validator backed by the server SDK + * @param agentEmitter a2a-java emitter for status, message and artifact events + * @param sleepController controllable sleep used by the recurring artifact loop + * @param recoveryEventArtifactData mock service recovery event payload emitted as artifacts + * @param logSink log sink, may be null + */ + public static void executeServerFlow( + RequestContext requestContext, + NotificationPromptValidator promptValidator, + AgentEmitter agentEmitter, + ServerSleepController sleepController, + Map recoveryEventArtifactData, + Consumer logSink) { + requireNotificationExtension(requestContext.getCallContext()); + String taskId = requestContext.getTaskId(); + String contextId = requestContext.getContextId(); + String promptText = extractPromptText(requestContext); + emit(logSink, "=================================================================="); + emit(logSink, "== API 2 validateAndFillingNotificationData"); + emit(logSink, "=================================================================="); + emit(logSink, "-- input prompt: <" + promptText.length() + " chars, see client-side logs>"); + + FilledParamData filledParams; + try { + filledParams = promptValidator.validateNotificationPrompt(promptText); + emit(logSink, "-- output (filled params):"); + emit(logSink, String.valueOf(filledParams.data())); + } catch (ContentValidationException error) { + emit(logSink, "!! validation failed: code=" + error.getCode() + " errors=" + error.errors()); + agentEmitter.submit(buildStatusMessage(contextId, taskId, SUBMITTED_MESSAGE)); + agentEmitter.reject(buildStatusMessage( + contextId, + taskId, + "Notification validation failed: " + error.getCode() + ": " + error.getMessage())); + return; + } catch (RuntimeException error) { + emit(logSink, "!! validation failed unexpectedly: " + + error.getClass().getName() + ": " + error.getMessage()); + agentEmitter.submit(buildStatusMessage(contextId, taskId, SUBMITTED_MESSAGE)); + agentEmitter.reject(buildStatusMessage( + contextId, + taskId, + "Notification validation failed unexpectedly: " + error.getClass().getName() + ": " + + error.getMessage())); + return; + } + + agentEmitter.submit(buildStatusMessage(contextId, taskId, SUBMITTED_MESSAGE)); + agentEmitter.startWork(buildStatusMessage(contextId, taskId, WORKING_MESSAGE)); + emit(logSink, ">> subscription accepted, reporting service recovery events"); + + try { + for (int reportIndex = 1; reportIndex <= ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT; + reportIndex++) { + String artifactId = UUID.randomUUID().toString(); + agentEmitter.addArtifact( + List.>of(new DataPart(recoveryEventArtifactData)), + "serviceManagement.ServiceRecoveryEvent", + "Mock service recovery event artifact", + Map.of("artifactId", artifactId), + false, + true); + emit(logSink, "- artifact " + reportIndex + "/" + ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT + + " emitted (" + artifactId + ")"); + if (reportIndex < ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT) { + sleepController.sleepSeconds(ServiceRecoverySampleInputs.NOTIFICATION_REPORT_INTERVAL_SECONDS); + } + } + agentEmitter.complete(buildStatusMessage(contextId, taskId, COMPLETE_MESSAGE)); + emit(logSink, "<< subscription task completed after " + + ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT + " reports"); + } catch (ServerFlowInterruptedException exception) { + throw exception; + } catch (RuntimeException exception) { + agentEmitter.fail(buildStatusMessage( + contextId, taskId, "Mock service recovery stream failed: " + exception.getMessage())); + } + } + + private static void emit(Consumer logSink, String message) { + if (logSink != null) { + logSink.accept(SampleLoggingFormatter.timestamped(message)); + } + } + + private static void requireNotificationExtension(ServerCallContext callContext) { + Map headers = extractHeaders(callContext); + String modernValue = headers.get("A2A-Extensions"); + String legacyValue = headers.get("X-A2A-Extensions"); + String extensionValue = modernValue != null ? modernValue : legacyValue; + if (!NOTIFICATION_T_EXTENSION_URI_NL.equals(extensionValue) + && !NOTIFICATION_T_EXTENSION_URI.equals(extensionValue)) { + throw new ValueErrorException("a2a client extensions is not exist."); + } + } + + private static String extractPromptText(RequestContext requestContext) { + Message message = requestContext.getMessage(); + if (message == null || message.metadata() == null) { + throw new ValueErrorException("Expected message metadata for Notification-T prompt"); + } + String promptText = stringValue(message.metadata().get(NOTIFICATION_T_EXTENSION_URI_NL)); + if (promptText.isEmpty()) { + promptText = stringValue(message.metadata().get(NOTIFICATION_T_EXTENSION_URI)); + } + return promptText; + } + + @SuppressWarnings("unchecked") + private static Map extractHeaders(ServerCallContext callContext) { + Object headersValue = callContext.getState().get("headers"); + if (headersValue instanceof Map headersMap) { + return (Map) headersMap; + } + return Map.of(); + } + + private static String stringValue(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static Message buildStatusMessage(String contextId, String taskId, String text) { + return Message.builder() + .messageId(UUID.randomUUID().toString()) + .contextId(contextId) + .taskId(taskId) + .role(Message.Role.ROLE_AGENT) + .parts(new org.a2aproject.sdk.spec.TextPart(text)) + .build(); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSleepController.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSleepController.java new file mode 100644 index 00000000..664500c1 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSleepController.java @@ -0,0 +1,14 @@ +package net.openan.a2at.sample.service_recovery.server.flow; + +/** + * Controllable sleep abstraction used by the recurring mock stream loop. + * + * @since 2026-05 + */ +@FunctionalInterface +public interface ServerSleepController { + + void sleepSeconds(long delaySeconds); +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/http/EmbeddedA2AHttpServer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/http/EmbeddedA2AHttpServer.java new file mode 100644 index 00000000..cbf2f133 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/http/EmbeddedA2AHttpServer.java @@ -0,0 +1,343 @@ +package net.openan.a2at.sample.service_recovery.server.http; + +import com.google.protobuf.util.JsonFormat; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Flow; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.a2aproject.sdk.grpc.SendMessageRequest; +import org.a2aproject.sdk.grpc.StreamResponse; +import org.a2aproject.sdk.grpc.utils.ProtoUtils; +import org.a2aproject.sdk.server.AgentCardCacheMetadata; +import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.a2aproject.sdk.transport.rest.handler.RestHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Minimal embedded HTTP host that exposes the real a2a-java REST transport endpoints required by the sample. + * + * @since 2026-05 + */ +public final class EmbeddedA2AHttpServer implements AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(EmbeddedA2AHttpServer.class); + private static final int SERVER_THREAD_COUNT = 8; + + private final HttpServer server; + private final ExecutorService executorService; + + private EmbeddedA2AHttpServer(HttpServer server, ExecutorService executorService) { + this.server = server; + this.executorService = executorService; + } + + public static EmbeddedA2AHttpServer start( + String host, int port, AgentCard agentCard, RequestHandler requestHandler) { + try { + HttpServer server = HttpServer.create(new InetSocketAddress(host, port), 0); + ExecutorService executorService = new ThreadPoolExecutor( + SERVER_THREAD_COUNT, + SERVER_THREAD_COUNT, + 0L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>()); + server.setExecutor(executorService); + + RestHandler restHandler = new RestHandler( + agentCard, new AgentCardCacheMetadata(agentCard, null), requestHandler, Runnable::run); + server.createContext("/", exchange -> handle(exchange, restHandler)); + server.start(); + return new EmbeddedA2AHttpServer(server, executorService); + } catch (IOException exception) { + throw new IllegalStateException("Failed to start embedded a2a-java REST server", exception); + } + } + + @Override + public void close() { + server.stop(0); + executorService.shutdown(); + } + + private static void handle(HttpExchange exchange, RestHandler restHandler) throws IOException { + try { + String path = exchange.getRequestURI().getPath(); + if ("GET".equalsIgnoreCase(exchange.getRequestMethod()) && "/".equals(path)) { + sendResponse(exchange, restHandler.getAgentCard()); + return; + } + if ("POST".equalsIgnoreCase(exchange.getRequestMethod()) && "/message:stream".equals(path)) { + streamMessageResponse( + exchange, + restHandler, + requestHandler(restHandler), + buildCallContext(exchange), + readRequestBody(exchange)); + return; + } + if ("POST".equalsIgnoreCase(exchange.getRequestMethod()) && "/message:send".equals(path)) { + sendResponse( + exchange, restHandler.sendMessage(buildCallContext(exchange), "", readRequestBody(exchange))); + return; + } + + exchange.sendResponseHeaders(404, -1); + } finally { + exchange.close(); + } + } + + private static ServerCallContext buildCallContext(HttpExchange exchange) { + Map headers = new LinkedHashMap<>(); + exchange.getRequestHeaders() + .forEach((key, value) -> headers.put(key.toLowerCase(Locale.ROOT), String.join(",", value))); + aliasHeader(headers, "A2A-Extensions"); + aliasHeader(headers, "X-A2A-Extensions"); + aliasHeader(headers, "A2A-Protocol-Version"); + aliasHeader(headers, "A2A-Version"); + String extensionHeader = firstHeader(headers, "A2A-Extensions", "X-A2A-Extensions"); + Set requestedExtensions = extensionHeader.isBlank() ? Set.of() : Set.of(extensionHeader.split(",")); + return new ServerCallContext( + null, + Map.of("headers", headers), + requestedExtensions, + firstNullableHeader(headers, "A2A-Protocol-Version", "A2A-Version")); + } + + private static void aliasHeader(Map headers, String expectedName) { + String value = headers.get(expectedName.toLowerCase(Locale.ROOT)); + if (value != null) { + headers.put(expectedName, value); + } + } + + private static String firstHeader(Map headers, String first, String second) { + String value = firstNullableHeader(headers, first, second); + return value == null ? "" : value; + } + + private static String firstNullableHeader(Map headers, String first, String second) { + String firstValue = headers.get(first); + if (firstValue != null) { + return firstValue; + } + return headers.get(second); + } + + private static String readRequestBody(HttpExchange exchange) throws IOException { + return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + + private static RequestHandler requestHandler(RestHandler restHandler) { + try { + java.lang.reflect.Field field = RestHandler.class.getDeclaredField("requestHandler"); + field.setAccessible(true); + Object value = field.get(restHandler); + if (value instanceof RequestHandler requestHandler) { + return requestHandler; + } + throw new IllegalStateException("a2a-java REST handler field is not a RequestHandler."); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to access a2a-java request handler", exception); + } + } + + private static void sendResponse(HttpExchange exchange, RestHandler.HTTPRestResponse response) throws IOException { + applyHeaders(exchange, response); + byte[] body = + response.getBody() == null ? new byte[0] : response.getBody().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(response.getStatusCode(), body.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(body); + outputStream.flush(); + } + } + + private static void streamResponse(HttpExchange exchange, RestHandler.HTTPRestStreamingResponse response) + throws IOException { + applyHeaders(exchange, response); + exchange.sendResponseHeaders(response.getStatusCode(), 0); + AtomicLong sequence = new AtomicLong(0L); + CountDownLatch completed = new CountDownLatch(1); + OutputStream outputStream = exchange.getResponseBody(); + response.getPublisher().subscribe(new Flow.Subscriber<>() { + private Flow.Subscription subscription; + + @Override + public void onSubscribe(Flow.Subscription subscription) { + this.subscription = subscription; + subscription.request(1); + } + + @Override + public void onNext(String item) { + try { + String payload = formatSseFrame(sequence.incrementAndGet(), item); + outputStream.write(payload.getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + subscription.request(1); + } catch (IOException exception) { + subscription.cancel(); + completed.countDown(); + } + } + + @Override + public void onError(Throwable throwable) { + completed.countDown(); + } + + @Override + public void onComplete() { + completed.countDown(); + } + }); + + try { + completed.await(); + } catch (InterruptedException exception) { + completed.countDown(); + throw new IOException("Interrupted while waiting for stream response completion", exception); + } finally { + outputStream.close(); + } + } + + private static void streamMessageResponse( + HttpExchange exchange, + RestHandler restHandler, + RequestHandler requestHandler, + ServerCallContext callContext, + String requestBody) + throws IOException { + try { + SendMessageRequest.Builder builder = SendMessageRequest.newBuilder(); + JsonFormat.parser().merge(requestBody, builder); + var request = ProtoUtils.FromProto.messageSendParams(builder.build()); + requestHandler.validateRequestedTask(request.message().taskId()); + Flow.Publisher publisher = requestHandler.onMessageSendStream(request, callContext); + LOGGER.debug("[server] stream-publisher-created"); + + exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); + exchange.sendResponseHeaders(200, 0); + AtomicLong sequence = new AtomicLong(0L); + CountDownLatch completed = new CountDownLatch(1); + OutputStream outputStream = exchange.getResponseBody(); + publisher.subscribe(new Flow.Subscriber<>() { + private Flow.Subscription subscription; + + @Override + public void onSubscribe(Flow.Subscription subscription) { + this.subscription = subscription; + LOGGER.debug("[server] stream-subscriber-onSubscribe"); + subscription.request(1); + } + + @Override + public void onNext(StreamingEventKind item) { + try { + String payload = JsonFormat.printer().print(toStreamResponse(item)); + String encoded = formatSseFrame(sequence.incrementAndGet(), payload); + LOGGER.trace("[server] sse-write: {}", encoded.replace("\r", "\\r").replace("\n", "\\n")); + outputStream.write(encoded.getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + LOGGER.trace("[server] sse-write-flushed"); + subscription.request(1); + } catch (IOException exception) { + LOGGER.warn("[server] sse-write-failed", exception); + subscription.cancel(); + completed.countDown(); + } + } + + @Override + public void onError(Throwable throwable) { + LOGGER.warn("[server] stream-subscriber-onError", throwable); + completed.countDown(); + } + + @Override + public void onComplete() { + LOGGER.debug("[server] stream-subscriber-onComplete"); + completed.countDown(); + } + }); + + try { + completed.await(); + } catch (InterruptedException exception) { + completed.countDown(); + throw new IOException("Interrupted while waiting for message stream completion", exception); + } finally { + outputStream.close(); + } + } catch (A2AError exception) { + sendResponse(exchange, restHandler.createErrorResponse(exception)); + } + } + + static String formatSseFrame(long sequence, String payload) { + StringBuilder builder = new StringBuilder(); + builder.append(String.format(Locale.ROOT, "id:%d\n", sequence)); + String normalized = payload == null ? "" : payload.replace("\r\n", "\n").replace('\r', '\n'); + String compact = normalized.lines().map(String::trim).reduce("", String::concat); + builder.append("data:").append(compact).append('\n'); + builder.append('\n'); + return builder.toString(); + } + + private static StreamResponse toStreamResponse(StreamingEventKind event) { + StreamResponse.Builder builder = StreamResponse.newBuilder(); + if (event instanceof Message message) { + builder.setMessage(ProtoUtils.ToProto.message(message)); + return builder.build(); + } + if (event instanceof Task task) { + builder.setTask(ProtoUtils.ToProto.task(task)); + return builder.build(); + } + if (event instanceof TaskStatusUpdateEvent statusUpdateEvent) { + builder.setStatusUpdate(ProtoUtils.ToProto.taskStatusUpdateEvent(statusUpdateEvent)); + return builder.build(); + } + if (event instanceof TaskArtifactUpdateEvent artifactUpdateEvent) { + builder.setArtifactUpdate(ProtoUtils.ToProto.taskArtifactUpdateEvent(artifactUpdateEvent)); + return builder.build(); + } + throw new IllegalArgumentException("Unsupported streaming event: " + event); + } + + private static void applyHeaders(HttpExchange exchange, RestHandler.HTTPRestResponse response) { + if (response.getContentType() != null && !response.getContentType().isBlank()) { + exchange.getResponseHeaders().set("Content-Type", response.getContentType()); + } + if (response.getHeaders() != null) { + response.getHeaders().forEach((key, value) -> { + if (value != null) { + exchange.getResponseHeaders().set(key, value); + } + }); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/package-info.java new file mode 100644 index 00000000..81df1903 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/package-info.java @@ -0,0 +1,7 @@ +/** + * Server-side sample entrypoints and orchestration for the publishable a2a-t sample module. + * + * @since 2026-05 + */ +package net.openan.a2at.sample.service_recovery.server; + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryClient.java new file mode 100644 index 00000000..fffd5b1b --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryClient.java @@ -0,0 +1,131 @@ +package net.openan.a2at.sample.service_recovery.server.registry; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; + +/** + * Registry registration support for the server sample. + * + * @since 2026-05 + */ +public final class ServerRegistryClient { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private ServerRegistryClient() {} + + public static String resolveRegistryBaseUrl(Path envPath) { + Map envValues = parseEnvFile(envPath); + String host = envValues.getOrDefault("REGISTRY_CENTER_HOST", "127.0.0.1"); + String port = envValues.getOrDefault("REGISTRY_CENTER_PORT", "5001"); + return "http://" + host + ":" + Integer.parseInt(port); + } + + public static Map registerAgentCard( + Map registrationPayload, + String registryBaseUrl, + ServerRegistryTransport transport, + Consumer logSink) { + String normalizedBaseUrl = registryBaseUrl.endsWith("/") + ? registryBaseUrl.substring(0, registryBaseUrl.length() - 1) + : registryBaseUrl; + ServerRegistryResponse response = + transport.post(normalizedBaseUrl + "/rest/v1/registry-center/agent-cards", registrationPayload); + if (response.statusCode() == 201) { + return Map.of("status", "success", "message", "Agent card registered successfully"); + } + if (logSink != null) { + logSink.accept("[registry-client] register failed: status_code=" + response.statusCode()); + } + return Map.of( + "status", + "failed", + "message", + "Unexpected status code: " + response.statusCode(), + "error", + response.jsonBody().isEmpty() ? response.text() : response.jsonBody()); + } + + public static ServerRegistryTransport httpTransport() { + return (url, payload) -> { + try { + URLConnection urlConnection = URI.create(url).toURL().openConnection(); + if (!(urlConnection instanceof HttpURLConnection connection)) { + return new ServerRegistryResponse(599, "Registry URL is not an HTTP endpoint", Map.of()); + } + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setDoOutput(true); + byte[] body = OBJECT_MAPPER.writeValueAsBytes(payload); + try (OutputStream outputStream = connection.getOutputStream()) { + outputStream.write(body); + } + int statusCode = connection.getResponseCode(); + String text = + readResponseBody(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream()); + Map jsonBody = parseJsonBody(text); + return new ServerRegistryResponse(statusCode, text, jsonBody); + } catch (IOException exception) { + return new ServerRegistryResponse(599, exception.getMessage(), Map.of()); + } + }; + } + + private static String readResponseBody(InputStream stream) throws IOException { + if (stream == null) { + return ""; + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + private static Map parseJsonBody(String text) { + if (text == null || text.isBlank()) { + return Map.of(); + } + try { + return OBJECT_MAPPER.readValue(text, new TypeReference>() {}); + } catch (JsonProcessingException exception) { + return Map.of(); + } + } + + public static Map parseEnvFile(Path envPath) { + if (envPath == null || !Files.exists(envPath)) { + return Map.of(); + } + try { + List lines = Files.readAllLines(envPath); + Map values = new LinkedHashMap<>(); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#") || !trimmed.contains("=")) { + continue; + } + int separatorIndex = trimmed.indexOf('='); + String key = trimmed.substring(0, separatorIndex).trim(); + String value = trimmed.substring(separatorIndex + 1).trim(); + if (value.isEmpty()) { + continue; + } + values.put(key, value); + } + return values; + } catch (IOException exception) { + throw new ValueErrorException("Failed to read env file: " + envPath); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryResponse.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryResponse.java new file mode 100644 index 00000000..e54a37f2 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryResponse.java @@ -0,0 +1,13 @@ +package net.openan.a2at.sample.service_recovery.server.registry; + +import java.util.Map; + +/** + * Simplified response model for registry registration calls. + * + * @since 2026-05 + */ +public record ServerRegistryResponse(int statusCode, String text, Map jsonBody) { +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryTransport.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryTransport.java new file mode 100644 index 00000000..2d42d00e --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryTransport.java @@ -0,0 +1,16 @@ +package net.openan.a2at.sample.service_recovery.server.registry; + +import java.util.Map; + +/** + * Transport abstraction for the server registry client. + * + * @since 2026-05 + */ +@FunctionalInterface +public interface ServerRegistryTransport { + + ServerRegistryResponse post(String url, Map payload); +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/A2AJavaServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/A2AJavaServerRuntime.java new file mode 100644 index 00000000..c6d8407f --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/A2AJavaServerRuntime.java @@ -0,0 +1,13 @@ +package net.openan.a2at.sample.service_recovery.server.runtime; + +/** + * Transitional seam for assembling the real a2a-java REST server runtime. + * + * @since 2026-05 + */ +public interface A2AJavaServerRuntime { + + Object createRestApplication(String host, int port); +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java new file mode 100644 index 00000000..fa5a6f4d --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java @@ -0,0 +1,211 @@ +package net.openan.a2at.sample.service_recovery.server.runtime; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import net.openan.a2at.sample.service_recovery.ServiceRecoverySampleInputs; +import net.openan.a2at.sample.service_recovery.server.agentcard.ServerSampleAgentCardBuilder; +import net.openan.a2at.sample.service_recovery.server.flow.NotificationPromptValidator; +import net.openan.a2at.sample.service_recovery.server.flow.ServerFlowInterruptedException; +import net.openan.a2at.sample.service_recovery.server.flow.ServerSampleAgentExecutor; +import net.openan.a2at.sample.service_recovery.server.registry.ServerRegistryClient; +import net.openan.a2at.sample.service_recovery.shared.env.SampleEnvironmentPathResolver; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; +import net.openan.a2at.sample.service_recovery.shared.registry.RegistryAgentCardMapper; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.server.A2ATServer; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.events.InMemoryQueueManager; +import org.a2aproject.sdk.server.events.MainEventBus; +import org.a2aproject.sdk.server.events.MainEventBusProcessor; +import org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.server.tasks.BasePushNotificationSender; +import org.a2aproject.sdk.server.tasks.InMemoryPushNotificationConfigStore; +import org.a2aproject.sdk.server.tasks.InMemoryTaskStore; +import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore; +import org.a2aproject.sdk.server.tasks.PushNotificationSender; + +/** + * Default runtime assembly for the service-recovery server sample entrypoint. + * + * @since 2026-08 + */ +public final class DefaultSampleServerRuntime implements SampleServerRuntime, A2AJavaServerRuntime { + private static final String SAMPLE_THREAD_NAME = "a2a-t-sample-server"; + private static final int SAMPLE_THREAD_COUNT = 4; + + private final Path envPath; + + private final Consumer logSink; + + private final ExecutorService sampleExecutor; + + /** + * Creates the runtime with the default log sink. + * + * @param envPath resolved environment file path + */ + public DefaultSampleServerRuntime(Path envPath) { + this(envPath, System.out::println); + } + + /** + * Creates the runtime with an explicit log sink. + * + * @param envPath resolved environment file path + * @param logSink log sink + */ + public DefaultSampleServerRuntime(Path envPath, Consumer logSink) { + this.envPath = envPath; + this.logSink = logSink; + this.sampleExecutor = new ThreadPoolExecutor( + SAMPLE_THREAD_COUNT, + SAMPLE_THREAD_COUNT, + 0L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(), + createSampleThreadFactory(this.logSink)); + } + + @Override + public ServerBind resolveBind() { + Map envValues = ServerRegistryClient.parseEnvFile(envPath); + String host = envValues.getOrDefault("A2AT_SAMPLE_HOST", "127.0.0.1"); + String portValue = envValues.getOrDefault("A2AT_SAMPLE_PORT", "8000"); + try { + return new ServerBind(host, Integer.parseInt(portValue)); + } catch (NumberFormatException exception) { + throw new ValueErrorException("Invalid A2AT_SAMPLE_PORT: " + portValue); + } + } + + @Override + public Map buildAgentCard(String host, int port) { + return ServerSampleAgentCardBuilder.buildAgentCard(host, port); + } + + @Override + public NotificationPromptValidator buildNotificationValidator(Path envPath) { + A2ATServer server = new A2ATServer(envPath); + TemplateUri templateUri = TemplateUri.parse(ServiceRecoverySampleInputs.TEMPLATE_URI) + .orElseThrow(() -> new IllegalArgumentException( + "Malformed template URI: " + ServiceRecoverySampleInputs.TEMPLATE_URI)); + return promptText -> server.validateAndFillingNotificationData( + promptText, + ServiceRecoverySampleInputs.validationParamSchema(), + templateUri); + } + + @Override + public Object buildApp(Map agentCard, NotificationPromptValidator notificationValidator) { + InMemoryTaskStore taskStore = new InMemoryTaskStore(); + MainEventBus mainEventBus = new MainEventBus(); + InMemoryQueueManager queueManager = new InMemoryQueueManager(taskStore, mainEventBus); + PushNotificationConfigStore pushNotificationConfigStore = new InMemoryPushNotificationConfigStore(); + PushNotificationSender pushNotificationSender = new BasePushNotificationSender(pushNotificationConfigStore); + MainEventBusProcessor mainEventBusProcessor = + new MainEventBusProcessor(mainEventBus, taskStore, pushNotificationSender, queueManager); + startMainEventBusProcessor(mainEventBusProcessor); + AgentExecutor agentExecutor = new ServerSampleAgentExecutor( + notificationValidator, + delaySeconds -> { + try { + Thread.sleep(delaySeconds * 1000L); + } catch (InterruptedException exception) { + throw new ServerFlowInterruptedException("Sample server sleep was interrupted."); + } + }, + buildMockServiceRecoveryEventData(), + logSink); + RequestHandler requestHandler = DefaultRequestHandler.create( + agentExecutor, + taskStore, + queueManager, + pushNotificationConfigStore, + mainEventBusProcessor, + sampleExecutor, + sampleExecutor); + return requestHandler; + } + + @Override + public Map registerAgentCard(Map registrationPayload, Path envPath) { + return ServerRegistryClient.registerAgentCard( + registrationPayload, + ServerRegistryClient.resolveRegistryBaseUrl(envPath), + ServerRegistryClient.httpTransport(), + null); + } + + /** + * Resolves the default sample environment file path relative to the repository root. + * + * @return resolved environment file path + */ + public static Path resolveDefaultEnvPath() { + Path sampleEnvDir = Path.of("a2a-t-sample", "src", "main", "resources", "sample", "service-recovery", "server"); + return SampleEnvironmentPathResolver.resolve(sampleEnvDir, "server.env", "server.env"); + } + + /** + * Builds the mock service recovery event artifact payload. The field structure and values are + * loaded from the {@code mock-event-data.json} resource so the zh-CN business fields stay out + * of the Java source. + * + * @return mock service recovery event data + */ + static Map buildMockServiceRecoveryEventData() { + return net.openan.a2at.sample.service_recovery.SampleResourceJson + .load("sample/service-recovery/server/mock-event-data.json"); + } + + /** + * Creates the sample thread factory with daemon threads and an uncaught-exception logger. + * + * @param logSink log sink + * @return thread factory + */ + static ThreadFactory createSampleThreadFactory(Consumer logSink) { + AtomicLong sequence = new AtomicLong(0L); + return command -> { + Thread thread = Executors.defaultThreadFactory().newThread(command); + thread.setName(SAMPLE_THREAD_NAME + "-" + sequence.incrementAndGet()); + thread.setDaemon(true); + thread.setUncaughtExceptionHandler((failedThread, throwable) -> { + if (logSink != null) { + logSink.accept("[server] sample-executor-uncaught-exception: " + + failedThread.getName() + + ": " + + throwable.getClass().getName() + + ": " + + throwable.getMessage()); + } + }); + return thread; + }; + } + + private void startMainEventBusProcessor(MainEventBusProcessor mainEventBusProcessor) { + try { + java.lang.reflect.Method startMethod = MainEventBusProcessor.class.getDeclaredMethod("start"); + startMethod.setAccessible(true); + startMethod.invoke(mainEventBusProcessor); + } catch (ReflectiveOperationException exception) { + throw new ValueErrorException("Failed to start MainEventBusProcessor", exception); + } + } + + @Override + public Object createRestApplication(String host, int port) { + return RegistryAgentCardMapper.toA2AJavaAgentCard(ServerSampleAgentCardBuilder.buildAgentCard(host, port)); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntime.java new file mode 100644 index 00000000..87d745d8 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntime.java @@ -0,0 +1,55 @@ +package net.openan.a2at.sample.service_recovery.server.runtime; + +import java.nio.file.Path; +import java.util.Map; +import net.openan.a2at.sample.service_recovery.server.flow.NotificationPromptValidator; + +/** + * Runtime assembly abstraction for the server sample bootstrap flow. + * + * @since 2026-08 + */ +public interface SampleServerRuntime { + + /** + * Resolves the HTTP bind address for this run. + * + * @return resolved bind pair + */ + ServerBind resolveBind(); + + /** + * Builds the sample AgentCard payload for the bind address. + * + * @param host bind host + * @param port bind port + * @return AgentCard payload map + */ + Map buildAgentCard(String host, int port); + + /** + * Builds the notification prompt validator backed by the server SDK. + * + * @param envPath resolved environment file path + * @return validator wrapping {@code A2ATServer.validateAndFillingNotificationData} + */ + NotificationPromptValidator buildNotificationValidator(Path envPath); + + /** + * Builds the a2a-java request handler application. + * + * @param agentCard sample AgentCard payload + * @param notificationValidator notification prompt validator used by the agent executor + * @return assembled request handler + */ + Object buildApp(Map agentCard, NotificationPromptValidator notificationValidator); + + /** + * Registers the AgentCard with the registry center. + * + * @param registrationPayload registry registration payload + * @param envPath resolved environment file path + * @return registration result payload + */ + Map registerAgentCard(Map registrationPayload, Path envPath); +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntimeFactory.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntimeFactory.java new file mode 100644 index 00000000..642d13c4 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntimeFactory.java @@ -0,0 +1,16 @@ +package net.openan.a2at.sample.service_recovery.server.runtime; + +import java.nio.file.Path; + +/** + * Creates server-sample runtime assemblies. + * + * @since 2026-05 + */ +@FunctionalInterface +public interface SampleServerRuntimeFactory { + + SampleServerRuntime create(Path envPath); +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBind.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBind.java new file mode 100644 index 00000000..1e54d0ac --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBind.java @@ -0,0 +1,11 @@ +package net.openan.a2at.sample.service_recovery.server.runtime; + +/** + * Resolved host/port bind pair for the server sample. + * + * @since 2026-05 + */ +public record ServerBind(String host, int port) { +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBootstrapResult.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBootstrapResult.java new file mode 100644 index 00000000..b57c1943 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBootstrapResult.java @@ -0,0 +1,18 @@ +package net.openan.a2at.sample.service_recovery.server.runtime; + +import java.util.Map; + +/** + * Result of one server-sample bootstrap sequence. + * + * @since 2026-05 + */ +public record ServerBootstrapResult( + String host, + int port, + Object app, + AutoCloseable serverHandle, + Map registrationResult) { +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/AgentEndpointResolver.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/AgentEndpointResolver.java new file mode 100644 index 00000000..90b5bd3b --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/AgentEndpointResolver.java @@ -0,0 +1,56 @@ +package net.openan.a2at.sample.service_recovery.shared.endpoint; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; + +/** + * Resolves the preferred HTTP endpoint from a registry AgentCard payload. + * + * @since 2026-05 + */ +public final class AgentEndpointResolver { + + private AgentEndpointResolver() { + } + + public static ResolvedAgentEndpoint resolvePreferredInterface(Map agentCard) { + Object supportedInterfacesValue = agentCard.get("supportedInterfaces"); + if (!(supportedInterfacesValue instanceof List supportedInterfaces)) { + throw new ValueErrorException("AgentCard.supportedInterfaces is required"); + } + + for (Object item : supportedInterfaces) { + if (!(item instanceof Map interfaceMap)) { + continue; + } + String url = stringValue(interfaceMap.get("url")); + if (!isValidHttpUrl(url)) { + throw new ValueErrorException("Invalid supportedInterfaces url: " + url); + } + return new ResolvedAgentEndpoint( + stringValue(agentCard.get("name")), + stringValue(interfaceMap.get("protocolBinding")), + stringValue(interfaceMap.get("protocolVersion")), + url); + } + + throw new ValueErrorException("No supported HTTP interface found in AgentCard.supportedInterfaces"); + } + + private static boolean isValidHttpUrl(String url) { + try { + URI uri = URI.create(url); + return ("http".equals(uri.getScheme()) || "https".equals(uri.getScheme())) && uri.getHost() != null; + } catch (IllegalArgumentException exception) { + return false; + } + } + + private static String stringValue(Object value) { + return value == null ? "" : String.valueOf(value); + } +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/ResolvedAgentEndpoint.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/ResolvedAgentEndpoint.java new file mode 100644 index 00000000..b3cb9eb8 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/ResolvedAgentEndpoint.java @@ -0,0 +1,12 @@ +package net.openan.a2at.sample.service_recovery.shared.endpoint; + +/** + * Resolved A2A endpoint details extracted from a registry AgentCard payload. + * + * @since 2026-05 + */ +public record ResolvedAgentEndpoint( + String agentName, String protocolBinding, String protocolVersion, String url) { +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/env/SampleEnvironmentPathResolver.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/env/SampleEnvironmentPathResolver.java new file mode 100644 index 00000000..bc1635a0 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/env/SampleEnvironmentPathResolver.java @@ -0,0 +1,30 @@ +package net.openan.a2at.sample.service_recovery.shared.env; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Resolves sample environment files using the same primary-then-fallback behavior as the Python samples. + * + * @since 2026-05 + */ +public final class SampleEnvironmentPathResolver { + + private SampleEnvironmentPathResolver() { + } + + public static Path resolve(Path directory, String primaryFileName, String fallbackFileName) { + Path primary = directory.resolve(primaryFileName); + if (Files.exists(primary)) { + return primary; + } + + Path fallback = directory.resolve(fallbackFileName); + if (Files.exists(fallback)) { + return fallback; + } + return primary; + } +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/error/ValueErrorException.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/error/ValueErrorException.java new file mode 100644 index 00000000..442d177b --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/error/ValueErrorException.java @@ -0,0 +1,17 @@ +package net.openan.a2at.sample.service_recovery.shared.error; + +/** + * Stable runtime exception used by the sample support layer for invalid sample inputs. + * + * @since 2026-05 + */ +public final class ValueErrorException extends RuntimeException { + + public ValueErrorException(String message) { + super(message); + } + + public ValueErrorException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/logging/SampleLoggingFormatter.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/logging/SampleLoggingFormatter.java new file mode 100644 index 00000000..b69b5e1f --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/logging/SampleLoggingFormatter.java @@ -0,0 +1,148 @@ +package net.openan.a2at.sample.service_recovery.shared.logging; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.io.IOException; +import java.lang.reflect.Array; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * Formatting helpers for sample logs. + * + * @since 2026-05 + */ +public final class SampleLoggingFormatter { + private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + private static final List SECRET_KEYWORDS = + List.of("api_key", "authorization", "token", "secret", "password"); + + private SampleLoggingFormatter() { + } + + public static String formatStageLog(String actor, String stage, String detail) { + return timestamp() + " [" + actor + "] " + stage + ": " + detail; + } + + /** + * Prefixes one log message with the sample timestamp. + * + *

      Multi-line messages keep their internal line breaks, but every blank line is removed so + * the log output never contains empty lines. + * + * @param message log message, may span multiple lines — only the first line carries the stamp + * @return timestamped log line without any blank line + */ + public static String timestamped(String message) { + if (message == null) { + return ""; + } + StringBuilder compact = new StringBuilder(); + for (String line : message.split("\r?\n", -1)) { + if (line.isBlank()) { + continue; + } + if (compact.length() > 0) { + compact.append('\n'); + } + compact.append(line); + } + if (compact.length() == 0) { + return ""; + } + return timestamp() + " " + compact; + } + + public static String formatPayloadLog(String actor, String stage, Object payload) { + try { + Object normalized = normalizePayload(payload, null); + return timestamp() + " [" + actor + "] " + stage + ": " + OBJECT_MAPPER.writeValueAsString(normalized); + } catch (IOException exception) { + return timestamp() + " [" + actor + "] " + stage + ": "; + } + } + + private static String timestamp() { + return LocalDateTime.now().format(TIMESTAMP_FORMATTER); + } + + private static Object normalizePayload(Object value, String keyName) throws IOException { + if (keyName != null && isSecretKey(keyName)) { + return "***"; + } + if (value == null || value instanceof Boolean || value instanceof Number || value instanceof String) { + return value; + } + if (value instanceof Map mapValue) { + Map normalized = new TreeMap<>(); + for (Map.Entry entry : mapValue.entrySet()) { + String childKey = String.valueOf(entry.getKey()); + normalized.put(childKey, normalizePayload(entry.getValue(), childKey)); + } + return normalized; + } + if (value instanceof Collection collection) { + List normalized = new ArrayList<>(); + for (Object item : collection) { + normalized.add(normalizePayload(item, null)); + } + return normalized; + } + if (value.getClass().isArray()) { + List normalized = new ArrayList<>(); + int length = Array.getLength(value); + for (int index = 0; index < length; index++) { + normalized.add(normalizePayload(Array.get(value, index), null)); + } + return normalized; + } + if (value instanceof Iterable iterable) { + List normalized = new ArrayList<>(); + for (Object item : iterable) { + normalized.add(normalizePayload(item, null)); + } + return normalized; + } + if (value instanceof java.util.Set setValue) { + TreeSet normalized = new TreeSet<>(); + for (Object item : setValue) { + normalized.add(String.valueOf(normalizePayload(item, null))); + } + return normalized; + } + + return OBJECT_MAPPER.readValue(OBJECT_MAPPER.writeValueAsBytes(value), Object.class); + } + + private static boolean isSecretKey(String keyName) { + String normalizedKeyName = keyName.toLowerCase(Locale.ROOT); + for (String keyword : SECRET_KEYWORDS) { + if (normalizedKeyName.equals(keyword)) { + return true; + } + } + if (normalizedKeyName.endsWith("_key") + || normalizedKeyName.endsWith("_secret") + || normalizedKeyName.endsWith("_password")) { + return true; + } + // Avoid masking the harmless max_tokens field while still masking api_token etc. + return normalizedKeyName.endsWith("_token") && !normalizedKeyName.endsWith("max_tokens"); + } +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleLoggingLLMClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleLoggingLLMClient.java new file mode 100644 index 00000000..62ad4318 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleLoggingLLMClient.java @@ -0,0 +1,82 @@ +package net.openan.a2at.sample.service_recovery.shared.mock; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sample.service_recovery.shared.logging.SampleLoggingFormatter; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.providers.OpenAIClient; + +/** + * Logging-aware LLM client wrapper used by the sample. + * + *

      Wraps either the real {@link OpenAIClient} or the {@link SampleMockLLMClient} and logs the + * full request (messages, JSON schema, temperature, max tokens) and response (content, model, + * usage) for every structured call, mirroring the Python sample's {@code llm_logger}. In mock mode + * an extra {@code llm-mock} stage line marks that the canned response was used. + * + * @since 2026-08 + */ +public final class SampleLoggingLLMClient implements LLMClient { + + private static volatile boolean mockMode; + + private static volatile String role = "llm"; + + private static volatile Consumer logSink = System.out::println; + + private final LLMClient delegate; + + /** + * Configures the wrapper's behavior before any client is created. + * + * @param mockEnabled whether the delegate should be the mock client + * @param roleLabel log role label (for example {@code client} or {@code server}) + * @param sink log output sink (defaults to {@code System.out}) + */ + public static synchronized void configure(boolean mockEnabled, String roleLabel, Consumer sink) { + mockMode = mockEnabled; + if (roleLabel != null && !roleLabel.isBlank()) { + role = roleLabel.trim(); + } + if (sink != null) { + logSink = sink; + } + } + + /** + * Creates a logging LLM client. The supplied config is passed through to the delegate. + * + * @param config resolved LLM provider config + */ + public SampleLoggingLLMClient(LLMClientConfig config) { + if (mockMode) { + this.delegate = new SampleMockLLMClient(config); + } else { + this.delegate = new OpenAIClient(config); + } + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + // Keep the LLM request and response fully visible: they are core debugging artifacts. + StringBuilder request = new StringBuilder("llm-request:"); + for (Map message : messages) { + request.append("\n--- role=").append(message.get("role")).append(" ---\n").append(message.get("content")); + } + logSink.accept(SampleLoggingFormatter.timestamped(request.toString())); + LLMResponse response = delegate.structured(messages, jsonSchema, temperature, maxTokens); + if (mockMode) { + logSink.accept(SampleLoggingFormatter.formatStageLog(role, "llm-mock", "using canned mock LLM response")); + } + logSink.accept(SampleLoggingFormatter.timestamped("llm-response:\n" + response.content() + + "\n(model=" + response.model() + ", usage=" + response.usage() + ")")); + return response; + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClient.java new file mode 100644 index 00000000..930fc5fb --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClient.java @@ -0,0 +1,105 @@ +package net.openan.a2at.sample.service_recovery.shared.mock; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMResponse; + +/** + * Deterministic LLM client used by the sample when no real API key is available. + * + *

      Unlike a call-order replay, this client classifies every structured call by its request + * signature and returns the matching canned response, so the sample flow stays deterministic no + * matter how many LLM steps run or in which order. The service-recovery flow makes exactly four + * LLM calls (two slot extractions on the client, two content validations on the server): + * + *

        + *
      • slot extraction — the JSON schema carries a {@code slotNames} key; + *
      • content validation — the schema requires {@code semantic_verdict}. + *
      + * + *

      Any other structured call fails loudly instead of returning an unrelated canned response — + * scenario recognition and task-prompt compliance validation never happen in this sample (the + * template URI is fixed and the server only calls + * {@code validateAndFillingNotificationData}). The canned responses are loaded from + * {@code mock_responses} JSON resources on the classpath and mirror what a real LLM returned for + * the bundled service-recovery resources, so the end-to-end sample flow runs without any external + * LLM service. + * + * @since 2026-08 + */ +public final class SampleMockLLMClient implements LLMClient { + + private static final String DEFAULT_RESOURCE_ROOT = "sample/service-recovery/mock_responses"; + + private static final String DEFAULT_LANGUAGE = "zh-CN"; + + private static volatile String resourceRoot = DEFAULT_RESOURCE_ROOT; + + private static volatile String language = DEFAULT_LANGUAGE; + + /** + * Creates a mock LLM client. The supplied config is ignored because responses are canned. + * + * @param config resolved LLM provider config (unused) + */ + public SampleMockLLMClient(LLMClientConfig config) { + // Responses are canned; the config is not needed. + } + + /** + * Configures the classpath resource root and language used to load canned responses. Called by + * {@link SampleMockLlmInstaller} before the mock client is first used. + * + * @param resourceRoot classpath directory containing {@code } subdirectories + * @param language locale identifier used to select the response files + */ + public static synchronized void configure(String resourceRoot, String language) { + SampleMockLLMClient.resourceRoot = resourceRoot == null || resourceRoot.isBlank() + ? DEFAULT_RESOURCE_ROOT + : resourceRoot; + SampleMockLLMClient.language = language == null || language.isBlank() ? DEFAULT_LANGUAGE : language.trim(); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + String resource = responseResource(jsonSchema); + return new LLMResponse( + loadResource(resource), + "mock-llm", + Map.of("prompt_tokens", 0, "completion_tokens", 0, "total_tokens", 0), + Map.of()); + } + + static String responseResource(Map jsonSchema) { + if (jsonSchema != null && jsonSchema.containsKey("slotNames")) { + return "slot_extraction.json"; + } + Object required = jsonSchema == null ? null : jsonSchema.get("required"); + if (required instanceof List requiredNames && requiredNames.contains("semantic_verdict")) { + return "content_validation.json"; + } + throw new ValueErrorException("Mock LLM cannot classify the structured call: " + jsonSchema); + } + + private static String loadResource(String resourcePath) { + String path = resourceRoot + "/" + language + "/" + resourcePath; + try (InputStream inputStream = SampleMockLLMClient.class.getClassLoader().getResourceAsStream(path)) { + if (inputStream == null) { + throw new ValueErrorException("Mock LLM response resource not found: " + path); + } + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new ValueErrorException("Failed to read mock LLM response resource: " + path, exception); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLlmInstaller.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLlmInstaller.java new file mode 100644 index 00000000..81a4b954 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLlmInstaller.java @@ -0,0 +1,171 @@ +package net.openan.a2at.sample.service_recovery.shared.mock; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientFactory; + +/** + * Installs a mock LLM mode for the sample when no real LLM API key is available. + * + *

      When {@code A2AT_LLM_API_KEY} is missing, blank, or a placeholder template such as + * {@code ${your_api_key}}, the sample installs {@link SampleMockLLMClient} as the provider used by + * the SDK and rewrites the environment file with a non-empty placeholder key. The mock client + * returns canned structured responses loaded from the specified classpath resource root, so the + * end-to-end sample flow runs deterministically on CI environments that have no LLM API key. When a + * real API key is present, the original environment file is used unchanged and no mock is installed. + * + * @since 2026-08 + */ +public final class SampleMockLlmInstaller { + + private static final String API_KEY_VAR = "A2AT_LLM_API_KEY"; + + private static final String PROVIDER_VAR = "A2AT_LLM_PROVIDER"; + + private static final String MODEL_VAR = "A2AT_LLM_MODEL"; + + private static final String BASE_URL_VAR = "A2AT_LLM_BASE_URL"; + + private static final String LANGUAGE_VAR = "A2AT_LANGUAGE"; + + private static final String OPENAI_PROVIDER = "openai"; + + private static final String MOCK_MODEL = "mock-model"; + + private static final String MOCK_API_KEY = "mock-key-not-real"; + + private static final String MOCK_BASE_URL = "http://localhost:0"; + + private static volatile boolean mockInstalled; + + private SampleMockLlmInstaller() {} + + /** + * Returns whether the sample should fall back to the mock LLM provider. + * + *

      The mock fallback is needed when no usable real LLM configuration is present: a missing, + * blank, or placeholder {@code A2AT_LLM_API_KEY}, or a missing/blank {@code A2AT_LLM_MODEL} or + * {@code A2AT_LLM_BASE_URL}. When the environment file lacks any of these, the SDK's + * {@code LLMConfigLoader} would fail at startup, so the mock is installed instead. + * + * @param envPath sample environment file path + * @return {@code true} when no usable real LLM configuration is present + */ + public static boolean isMockNeeded(Path envPath) { + Map values = readEnv(envPath); + String apiKey = values.get(API_KEY_VAR); + String model = values.get(MODEL_VAR); + String baseUrl = values.get(BASE_URL_VAR); + boolean keyMissing = apiKey == null || apiKey.isBlank() || apiKey.contains("${"); + boolean modelMissing = model == null || model.isBlank(); + boolean baseUrlMissing = baseUrl == null || baseUrl.isBlank(); + return keyMissing || modelMissing || baseUrlMissing; + } + + /** + * Installs the logging-aware LLM client wrapper as the {@code openai} provider. + * + *

      This method must be called once at startup, before any {@code A2ATClient} or + * {@code A2ATServer} is constructed, so that every LLM-structured call is logged. The wrapper + * delegates to the real {@code OpenAIClient} when {@code mockEnabled} is false, or to + * {@link SampleMockLLMClient} when it is true. + * + * @param mockEnabled whether the delegate should be the mock client + * @param roleLabel log role label (for example {@code client} or {@code server}) + */ + public static synchronized void installLlmLogger(boolean mockEnabled, String roleLabel) { + if (mockInstalled) { + return; + } + SampleLoggingLLMClient.configure(mockEnabled, roleLabel, System.out::println); + try { + Field clientsField = LLMClientFactory.class.getDeclaredField("CLIENTS"); + clientsField.setAccessible(true); + @SuppressWarnings("unchecked") + Map> clients = + (Map>) clientsField.get(null); + clients.put(OPENAI_PROVIDER, SampleLoggingLLMClient.class); + mockInstalled = true; + } catch (ReflectiveOperationException exception) { + throw new ValueErrorException("Failed to install logging LLM client", exception); + } + } + + /** + * Resolves the environment file path that should be used for the current run. + * + *

      When a mock fallback is needed, the mock client is configured with the supplied classpath + * resource root (and the language read from the env file), then a temporary environment file is + * written next to the original file (so relative prompt-resource paths keep resolving correctly) + * with a non-empty placeholder API key. Otherwise the original path is returned unchanged. + * + * @param envPath sample environment file path + * @param mockResourceRoot classpath root for mock LLM response JSON files + * @return the environment file path to use for this run + */ + public static Path resolveEnvPath(Path envPath, String mockResourceRoot) { + if (envPath == null || !isMockNeeded(envPath)) { + return envPath; + } + Map env = readEnv(envPath); + String language = env.get(LANGUAGE_VAR); + SampleMockLLMClient.configure(mockResourceRoot, language); + return writeMockEnvFile(envPath); + } + + private static String stripBom(String text) { + return text != null && text.startsWith("\uFEFF") ? text.substring(1) : text; + } + + private static Map readEnv(Path envPath) { + if (envPath == null || !Files.exists(envPath)) { + return Map.of(); + } + try { + Map values = new LinkedHashMap<>(); + for (String rawLine : Files.readAllLines(envPath)) { + String line = stripBom(rawLine.trim()); + if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { + continue; + } + int separatorIndex = line.indexOf('='); + String key = line.substring(0, separatorIndex).trim(); + String value = line.substring(separatorIndex + 1).trim(); + if (!key.isEmpty() && !value.isEmpty()) { + values.put(key, value); + } + } + return values; + } catch (IOException exception) { + throw new ValueErrorException("Failed to read env file for mock LLM fallback: " + envPath, exception); + } + } + + private static Path writeMockEnvFile(Path originalEnvPath) { + Map values = new LinkedHashMap<>(readEnv(originalEnvPath)); + values.putIfAbsent(PROVIDER_VAR, OPENAI_PROVIDER); + values.putIfAbsent(MODEL_VAR, MOCK_MODEL); + values.put(API_KEY_VAR, MOCK_API_KEY); + values.putIfAbsent(BASE_URL_VAR, MOCK_BASE_URL); + Path parent = originalEnvPath.toAbsolutePath().getParent(); + try { + Path tempFile = Files.createTempFile(parent, "a2a-t-sample-mock-", ".env"); + StringBuilder content = new StringBuilder(); + content.append("# Mock LLM environment for sample runs without a real API key.\n"); + for (Map.Entry entry : values.entrySet()) { + content.append(entry.getKey()).append('=').append(entry.getValue()).append('\n'); + } + Files.writeString(tempFile, content.toString(), StandardCharsets.UTF_8); + return tempFile; + } catch (IOException exception) { + throw new ValueErrorException("Failed to create mock LLM environment file", exception); + } + } +} \ No newline at end of file diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/package-info.java new file mode 100644 index 00000000..0cc64672 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/package-info.java @@ -0,0 +1,7 @@ +/** + * Shared support code for the publishable a2a-t sample module. + * + * @since 2026-05 + */ +package net.openan.a2at.sample.service_recovery.shared; + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/registry/RegistryAgentCardMapper.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/registry/RegistryAgentCardMapper.java new file mode 100644 index 00000000..1441ecf5 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/registry/RegistryAgentCardMapper.java @@ -0,0 +1,126 @@ +package net.openan.a2at.sample.service_recovery.shared.registry; + +import java.util.List; +import java.util.Map; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentExtension; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentProvider; +import org.a2aproject.sdk.spec.AgentSkill; + +/** + * Maps registry-center AgentCard payloads to and from real a2a-java models. + * + * @since 2026-05 + */ +public final class RegistryAgentCardMapper { + + private RegistryAgentCardMapper() { + } + + public static AgentCard toA2AJavaAgentCard(Map registryAgentCard) { + @SuppressWarnings("unchecked") + Map provider = (Map) registryAgentCard.get("provider"); + @SuppressWarnings("unchecked") + Map capabilities = (Map) registryAgentCard.get("capabilities"); + @SuppressWarnings("unchecked") + List> extensionMaps = (List>) capabilities.getOrDefault("extensions", List.of()); + @SuppressWarnings("unchecked") + List> skillMaps = (List>) registryAgentCard.getOrDefault("skills", List.of()); + @SuppressWarnings("unchecked") + List> interfaceMaps = + (List>) registryAgentCard.getOrDefault("supportedInterfaces", List.of()); + return new AgentCard( + stringValue(registryAgentCard.get("name")), + stringValue(registryAgentCard.get("description")), + provider == null ? null : new AgentProvider(stringValue(provider.get("organization")), stringValue(provider.get("url"))), + stringValue(registryAgentCard.get("version")), + null, + new AgentCapabilities( + Boolean.TRUE.equals(capabilities.get("streaming")), + Boolean.TRUE.equals(capabilities.get("pushNotifications")), + Boolean.TRUE.equals(capabilities.get("extendedAgentCard")), + extensionMaps.stream() + .map(extension -> new AgentExtension( + stringValue(extension.get("description")), + Map.of(), + false, + stringValue(extension.get("uri")))) + .toList()), + stringList(registryAgentCard.get("defaultInputModes")), + stringList(registryAgentCard.get("defaultOutputModes")), + skillMaps.stream() + .map(skill -> new AgentSkill( + stringValue(skill.get("id")), + stringValue(skill.get("name")), + stringValue(skill.get("description")), + stringList(skill.get("tags")), + List.of(), + List.of(), + List.of(), + List.of())) + .toList(), + Map.of(), + List.of(), + null, + interfaceMaps.stream() + .map(serverInterface -> new AgentInterface( + stringValue(serverInterface.get("protocolBinding")), + stringValue(serverInterface.get("url")), + "", + stringValue(serverInterface.get("protocolVersion")))) + .toList(), + List.of()); + } + + public static Map toRegistryRegistrationPayload(AgentCard agentCard) { + return Map.of("agentCards", List.of(toRegistryAgentCard(agentCard))); + } + + public static Map toRegistryAgentCard(AgentCard agentCard) { + return Map.of( + "name", agentCard.name(), + "description", agentCard.description(), + "version", agentCard.version(), + "defaultInputModes", agentCard.defaultInputModes(), + "defaultOutputModes", agentCard.defaultOutputModes(), + "provider", Map.of( + "organization", agentCard.provider().organization(), + "url", agentCard.provider().url()), + "skills", agentCard.skills().stream() + .map(skill -> Map.of( + "id", skill.id(), + "name", skill.name(), + "description", skill.description(), + "tags", skill.tags())) + .toList(), + "capabilities", Map.of( + "streaming", agentCard.capabilities().streaming(), + "pushNotifications", agentCard.capabilities().pushNotifications(), + "extensions", agentCard.capabilities().extensions().stream() + .map(extension -> Map.of( + "uri", extension.uri(), + "description", extension.description())) + .toList()), + "supportedInterfaces", agentCard.supportedInterfaces().stream() + .map(agentInterface -> Map.of( + "protocolBinding", agentInterface.protocolBinding(), + "protocolVersion", agentInterface.protocolVersion(), + "url", agentInterface.url())) + .toList()); + } + + private static List stringList(Object value) { + if (value instanceof List values) { + return values.stream().map(String::valueOf).toList(); + } + return List.of(); + } + + private static String stringValue(Object value) { + return value == null ? "" : String.valueOf(value); + } +} + + diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/stream/SampleStreamEventNormalizer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/stream/SampleStreamEventNormalizer.java new file mode 100644 index 00000000..d72d405b --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/stream/SampleStreamEventNormalizer.java @@ -0,0 +1,48 @@ +package net.openan.a2at.sample.service_recovery.shared.stream; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; + +/** + * Normalizes raw stream payload fragments into stable sample event shapes. + * + * @since 2026-05 + */ +public final class SampleStreamEventNormalizer { + + private SampleStreamEventNormalizer() { + } + + public static Map normalize(Map payload) { + if (payload == null || payload.isEmpty()) { + throw new ValueErrorException("Unsupported SSE payload: " + payload); + } + + Object statusValue = payload.get("status"); + if (statusValue instanceof Map status) { + return Map.of("kind", "status", "state", status.get("state")); + } + + Object messageValue = payload.get("message"); + if (messageValue instanceof Map message) { + Object partsValue = message.get("parts"); + if (partsValue instanceof List parts) { + for (Object part : parts) { + if (part instanceof Map partMap && partMap.containsKey("text")) { + return Map.of("kind", "message", "text", String.valueOf(partMap.get("text"))); + } + } + } + return Map.of("kind", "message", "text", ""); + } + + if (payload.containsKey("artifact")) { + return Map.of("kind", "artifact", "artifact", payload.get("artifact")); + } + + throw new ValueErrorException("Unsupported SSE payload: " + payload); + } +} + + diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/client/client.env b/a2a-t-sample/src/main/resources/sample/service-recovery/client/client.env new file mode 100644 index 00000000..6e09c1fe --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/client/client.env @@ -0,0 +1,17 @@ +# Copy this file to client.env before starting the a2a-java HTTP+JSON/REST service-recovery sample client. +# Configure any OpenAI LLM endpoint before running the sample client. +# NOTE: local_file mode is required because the classpath prompt-resource loaders currently reject +# full template URIs (e.g. Notification-T/v1/service-recovery) as scenario codes. +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=local_file +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=../../../../../../../a2a-t-resources/src/main/resources/prompt_resources +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=deepseek-chat +A2AT_LLM_BASE_URL=https://api.deepseek.com +A2AT_LLM_API_KEY= +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory +REGISTRY_CENTER_HOST=127.0.0.1 +REGISTRY_CENTER_PORT=5001 +# Optional: query AgentCard directly from the sample server root instead of registry-center. +A2AT_SAMPLE_HOST=127.0.0.1 +A2AT_SAMPLE_PORT=26336 diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/client/input-with-data.json b/a2a-t-sample/src/main/resources/sample/service-recovery/client/input-with-data.json new file mode 100644 index 00000000..c4084761 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/client/input-with-data.json @@ -0,0 +1,3 @@ +{ + "reportFormat": "业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、业务抢通方案名称、业务抢通方案详情、业务抢通方案执行结束时间、业务抢通方案执行结果(成功、失败)及业务抢通方案执行失败原因。" +} diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/client/input-with-text.txt b/a2a-t-sample/src/main/resources/sample/service-recovery/client/input-with-text.txt new file mode 100644 index 00000000..10c7b3f0 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/client/input-with-text.txt @@ -0,0 +1,21 @@ +我想订阅一下业务抢通事件。上报通知数据格式如下: +1. 业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束 (必选) +2. 投诉诊断任务流水号;举例:9de168c0-6179-4778-8b72-4279582c0a3f (必选) +3. OSS侧事件流水号;举例:event-id-202606250128 (必选) +4. 接入端口名称;举例:P781-珠江新城-PTN7900-23-TPA1EG24-17 (必选) +5. 是否已授权OMC自动抢通,取值范围:是、否;举例:是 (必选) +6. 业务抢通方案名称;举例:隧道调优 (必选) +7. 业务抢通方案详情;(必选) + 举例: + 将流量带宽,时延越限和丢包越限受影响的隧道列表,调优到新的路径列表中。 + 调优前: + - 隧道1:隧道a,源网元:P781-珠江新城-PTN7900,宿网元:P783-珠江新城-PTN7900,主备类型:master,状态:down,时延:--,丢包率:100%,带宽:10000bps + - 隧道路由列表: + - 链路1:链路a, 源网元:P781-珠江新城-PTN7900,源端口:23-TPA1EG24-17,宿网元:P782-珠江新城-PTN7900,宿端口:23-TPA1EG24-17,时延:-- + - 链路2:链路b, 源网元:P782-珠江新城-PTN7900,源端口:23-TPA1EG24-17,宿网元:P783-珠江新城-PTN7900,宿端口:23-TPA1EG24-17,时延:-- + 调优后: + - 隧道1:隧道b,源网元:P881-珠江新城-PTN7900,宿网元:P883-珠江新城-PTN7900,主备类型:master,状态:up,时延:20ms,丢包率:0.1%,带宽:10000bps + - 隧道路由列表: + - 链路1:链路c, 源网元:P881-珠江新城-PTN7900,源端口:25-TPA1EG24-11,宿网元:P882-珠江新城-PTN7900,宿端口:25-TPA1EG24-12,时延:10ms + - 链路2:链路d, 源网元:P882-珠江新城-PTN7900,源端口:25-TPA1EG24-13,宿网元:P883-珠江新城-PTN7900,宿端口:25-TPA1EG24-14,时延:15ms +8. 业务抢通方案执行结束时间;样例:2026-05-11T08:21:46Z; (可选) diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/client/schema.json b/a2a-t-sample/src/main/resources/sample/service-recovery/client/schema.json new file mode 100644 index 00000000..8e067bf5 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/client/schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "condition": { + "type": "string", + "description": "订阅条件,可选。待订阅的条件描述,例如:子网名称为xx子网。不带条件时留空。", + "examples": [ + "子网名称:xx子网" + ], + "x-a2at-value-constraint": "订阅条件为子网名称。订阅可以不带条件,此时该参数为空。" + }, + "reportFormat": { + "type": "string", + "description": "上报通知数据格式,必选。描述所需上报内容的:信息项名称(必选)、取值范围(可选)、样例(可选)。", + "examples": [ + "业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束(必选);投诉诊断任务流水号;举例:9de168c0-6179-4778-8b72-4279582c0a3f(必选);OSS侧事件流水号;举例:event-id-202606250128(必选);接入端口名称;举例:P781-珠江新城-PTN7900-23-TPA1EG24-17(必选);是否已授权OMC自动抢通,取值范围:是、否;举例:是(必选);业务抢通方案名称;举例:隧道调优(必选);业务抢通方案详情(必选);业务抢通方案执行结束时间;样例:2026-05-11T08:21:46Z(可选);业务抢通方案执行结果,取值范围:成功、失败;举例:成功(可选);业务抢通方案执行失败原因;业务抢通方案执行结果为“失败”时,需要填写此信息;举例:调优后隧道链路C已被占用,无法执行隧道调优(可选)" + ], + "x-a2at-value-constraint": "上报的数据类型为业务抢通事件。业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,业务抢通方案执行结果为“失败”时,需要填写此信息(可选)。" + } + }, + "required": ["reportFormat"] +} \ No newline at end of file diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/content_validation.json b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/content_validation.json new file mode 100644 index 00000000..d4966777 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/content_validation.json @@ -0,0 +1,8 @@ +{ + "semantic_verdict": true, + "errors": [], + "params": { + "subscriptionCondition": "", + "notificationDataFormat": "Service recovery plan execution status, allowed values: not started, ended; example: ended (required); Complaint diagnosis task sequence number; example: 9de168c0-6179-4778-8b72-4279582c0a3f (required); OSS-side event sequence number; example: event-id-202606250128 (required); Access port name; example: P781-珠江新城-PTN7900-23-TPA1EG24-17 (required); Whether OMC automatic recovery is authorized, allowed values: yes, no; example: yes (required); Service recovery plan name; example: tunnel tuning (required); Service recovery plan details (required); Service recovery plan execution end time; example: 2026-05-11T08:21:46Z (optional); Service recovery plan execution result, allowed values: success, failure; example: success (optional); Service recovery plan execution failure reason; required when the execution result is failure; example: link C is occupied after tuning, unable to perform tunnel tuning (optional)" + } +} diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/slot_extraction.json b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/slot_extraction.json new file mode 100644 index 00000000..d4b0bcc0 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/slot_extraction.json @@ -0,0 +1,7 @@ +{ + "slots": { + "subscribe_condition": null, + "notification_data_format": "The service recovery event data contains the following fields: 1. Service recovery plan execution status, allowed values: not started, ended (required); 2. Complaint diagnosis task sequence number (required); 3. OSS-side event sequence number (required); 4. Access port name (required); 5. Whether OMC automatic recovery is authorized, allowed values: yes, no (required); 6. Service recovery plan name (required); 7. Service recovery plan details (required); 8. Service recovery plan execution end time (optional)." + }, + "slot_errors": [] +} diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/content_validation.json b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/content_validation.json new file mode 100644 index 00000000..27defbf8 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/content_validation.json @@ -0,0 +1,8 @@ +{ + "semantic_verdict": true, + "errors": [], + "params": { + "subscriptionCondition": "", + "notificationDataFormat": "业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束(必选);投诉诊断任务流水号;举例:9de168c0-6179-4778-8b72-4279582c0a3f(必选);OSS侧事件流水号;举例:event-id-202606250128(必选);接入端口名称;举例:P781-珠江新城-PTN7900-23-TPA1EG24-17(必选);是否已授权OMC自动抢通,取值范围:是、否;举例:是(必选);业务抢通方案名称;举例:隧道调优(必选);业务抢通方案详情(必选);业务抢通方案执行结束时间;样例:2026-05-11T08:21:46Z(可选);业务抢通方案执行结果,取值范围:成功、失败;举例:成功(可选);业务抢通方案执行失败原因;业务抢通方案执行结果为“失败”时,需要填写此信息;举例:调优后隧道链路C已被占用,无法执行隧道调优(可选)" + } +} diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/slot_extraction.json b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/slot_extraction.json new file mode 100644 index 00000000..cbd03337 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/slot_extraction.json @@ -0,0 +1,7 @@ +{ + "slots": { + "订阅条件": null, + "上报通知数据格式": "业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选)。" + }, + "slot_errors": [] +} diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/server/mock-event-data.json b/a2a-t-sample/src/main/resources/sample/service-recovery/server/mock-event-data.json new file mode 100644 index 00000000..7abbd47f --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/server/mock-event-data.json @@ -0,0 +1,12 @@ +{ + "serviceManagement.ServiceRecoveryEvent": { + "业务抢通方案执行状态": "已结束", + "投诉诊断任务流水号": "9de168c0-6179-4778-8b72-4279582c0a3f", + "OSS侧事件流水号": "event-id-202606250128", + "接入端口名称": "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "是否已授权OMC自动抢通": "是", + "业务抢通方案名称": "隧道调优", + "业务抢通方案详情": "将流量带宽,时延越限和丢包越限受影响的隧道列表,调优到新的路径列表中。\n调优前:\n- 隧道1:隧道a,源网元:P781-珠江新城-PTN7900,宿网元:P783-珠江新城-PTN7900,主备类型:master,状态:down,时延:--,丢包率:100%,带宽:10000bps\n - 隧道路由列表:\n - 链路1:链路a,源网元:P781-珠江新城-PTN7900,源端口:23-TPA1EG24-17,宿网元:P782-珠江新城-PTN7900,宿端口:23-TPA1EG24-17,时延:--\n - 链路2:链路b,源网元:P782-珠江新城-PTN7900,源端口:23-TPA1EG24-17,宿网元:P783-珠江新城-PTN7900,宿端口:23-TPA1EG24-17,时延:--\n调优后:\n- 隧道1:隧道b,源网元:P881-珠江新城-PTN7900,宿网元:P883-珠江新城-PTN7900,主备类型:master,状态:up,时延:20ms,丢包率:0.1%,带宽:10000bps\n - 隧道路由列表:\n - 链路1:链路c,源网元:P881-珠江新城-PTN7900,源端口:25-TPA1EG24-11,宿网元:P882-珠江新城-PTN7900,宿端口:25-TPA1EG24-12,时延:10ms\n - 链路2:链路d,源网元:P882-珠江新城-PTN7900,源端口:25-TPA1EG24-13,宿网元:P883-珠江新城-PTN7900,宿端口:25-TPA1EG24-14,时延:15ms", + "业务抢通方案执行结束时间": "2026-05-11T08:21:46Z" + } +} diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json b/a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json new file mode 100644 index 00000000..4737fc2a --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "subscriptionCondition": { + "type": "string", + "description": "订阅条件。待订阅的条件描述,例如:子网名称为xx子网。订阅可以不带条件,此时该参数输出空字符串\"\"而非null。", + "examples": [ + "子网名称:xx子网" + ], + "x-a2at-value-constraint": "订阅条件为子网名称。订阅可以不带条件,此时该参数为空。" + }, + "notificationDataFormat": { + "type": "string", + "description": "上报通知数据格式。描述所需上报内容的如下信息:1. 信息项名称(必选);2. 取值范围(可选);3. 样例(可选)。", + "examples": [ + "业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束(必选);投诉诊断任务流水号;举例:9de168c0-6179-4778-8b72-4279582c0a3f(必选);OSS侧事件流水号;举例:event-id-202606250128(必选);接入端口名称;举例:P781-珠江新城-PTN7900-23-TPA1EG24-17(必选);是否已授权OMC自动抢通,取值范围:是、否;举例:是(必选);业务抢通方案名称;举例:隧道调优(必选);业务抢通方案详情(必选);业务抢通方案执行结束时间;样例:2026-05-11T08:21:46Z(可选);业务抢通方案执行结果,取值范围:成功、失败;举例:成功(可选);业务抢通方案执行失败原因;业务抢通方案执行结果为“失败”时,需要填写此信息;举例:调优后隧道链路C已被占用,无法执行隧道调优(可选)" + ], + "x-a2at-value-constraint": "上报的数据类型为业务抢通事件。业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,业务抢通方案执行结果为“失败”时,需要填写此信息(可选)。" + } + }, + "required": ["notificationDataFormat"] +} diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/server/server.env b/a2a-t-sample/src/main/resources/sample/service-recovery/server/server.env new file mode 100644 index 00000000..d49c6564 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/server/server.env @@ -0,0 +1,16 @@ +# Copy this file to server.env before starting the a2a-java HTTP+JSON/REST service-recovery sample server. +# Configure any OpenAI LLM endpoint before running the sample server. +# NOTE: local_file mode is required because the classpath prompt-resource loaders currently reject +# full template URIs (e.g. Notification-T/v1/service-recovery) as scenario codes. +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=local_file +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=../../../../../../../a2a-t-resources/src/main/resources/prompt_resources +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=deepseek-chat +A2AT_LLM_BASE_URL=https://api.deepseek.com +A2AT_LLM_API_KEY= +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory +A2AT_SAMPLE_HOST=127.0.0.1 +A2AT_SAMPLE_PORT=26336 +REGISTRY_CENTER_HOST=127.0.0.1 +REGISTRY_CENTER_PORT=5001 diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/server/slot-names.properties b/a2a-t-sample/src/main/resources/sample/service-recovery/server/slot-names.properties new file mode 100644 index 00000000..2eea28c3 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/server/slot-names.properties @@ -0,0 +1,2 @@ +subscriptionCondition=订阅条件 +notificationDataFormat=上报通知数据格式 diff --git a/a2a-t-sample/src/main/resources/sample/subscribe-incident/server/server.env b/a2a-t-sample/src/main/resources/sample/subscribe-incident/server/server.env index 456c61bb..ee723261 100644 --- a/a2a-t-sample/src/main/resources/sample/subscribe-incident/server/server.env +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/server/server.env @@ -2,7 +2,7 @@ # Configure any OpenAI LLM endpoint before running the sample server. A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=local_file -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=../../../../../../a2a-t-resources/src/main/resources/prompt_resources +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=../../../../../../../a2a-t-resources/src/main/resources/prompt_resources A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_BASE_URL=https://api.deepseek.com diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntimeTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntimeTest.java new file mode 100644 index 00000000..8e7c6ac3 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntimeTest.java @@ -0,0 +1,89 @@ +package net.openan.a2at.sample.service_recovery.server.runtime; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadFactory; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; +import org.junit.jupiter.api.Test; + +class DefaultSampleServerRuntimeTest { + + @Test + void resolveBindUsesDefaultsWhenHostAndPortAreBlank() { + Path envPath = createEnvFile("A2AT_SAMPLE_HOST=\nA2AT_SAMPLE_PORT=\n"); + + ServerBind bind = new DefaultSampleServerRuntime(envPath, message -> {}).resolveBind(); + + assertEquals("127.0.0.1", bind.host()); + assertEquals(8000, bind.port()); + } + + @Test + void resolveBindReadsExplicitHostAndPort() { + Path envPath = createEnvFile("A2AT_SAMPLE_HOST=10.1.2.3\nA2AT_SAMPLE_PORT=26336\n"); + + ServerBind bind = new DefaultSampleServerRuntime(envPath, message -> {}).resolveBind(); + + assertEquals("10.1.2.3", bind.host()); + assertEquals(26336, bind.port()); + } + + @Test + void resolveBindRaisesWhenPortIsNotANumber() { + Path envPath = createEnvFile("A2AT_SAMPLE_PORT=not-a-number\n"); + + ValueErrorException exception = assertThrows( + ValueErrorException.class, + () -> new DefaultSampleServerRuntime(envPath, message -> {}).resolveBind()); + + assertTrue(exception.getMessage().contains("not-a-number")); + } + + @Test + void createSampleThreadFactoryRegistersUncaughtExceptionHandler() { + List logs = new ArrayList<>(); + AssertionError failure = new AssertionError("boom"); + + ThreadFactory threadFactory = DefaultSampleServerRuntime.createSampleThreadFactory(logs::add); + Thread thread = threadFactory.newThread(() -> { + throw failure; + }); + thread.start(); + assertDoesNotThrow(() -> thread.join(1000L)); + + assertEquals("a2a-t-sample-server-1", thread.getName()); + assertTrue(thread.isDaemon()); + assertEquals(1, logs.size()); + assertTrue(logs.get(0).contains("a2a-t-sample-server")); + assertTrue(logs.get(0).contains(AssertionError.class.getName())); + assertTrue(logs.get(0).contains("boom")); + } + + @Test + void buildMockServiceRecoveryEventDataLoadsResource() { + Map data = DefaultSampleServerRuntime.buildMockServiceRecoveryEventData(); + + assertFalse(data.isEmpty()); + assertTrue(data.containsKey("serviceManagement.ServiceRecoveryEvent")); + } + + private static Path createEnvFile(String content) { + try { + Path envPath = Files.createTempFile("a2a-t-server", ".env"); + Files.writeString(envPath, content); + return envPath; + } catch (IOException exception) { + throw new AssertionError("Failed to create server env test file", exception); + } + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClientTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClientTest.java new file mode 100644 index 00000000..c6190762 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClientTest.java @@ -0,0 +1,42 @@ +package net.openan.a2at.sample.service_recovery.shared.mock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; +import org.junit.jupiter.api.Test; + +class SampleMockLLMClientTest { + + @Test + void responseResourceReturnsSlotExtractionWhenSchemaHasSlotNames() { + Map schema = Map.of("slotNames", List.of("topic")); + + String resource = SampleMockLLMClient.responseResource(schema); + + assertEquals("slot_extraction.json", resource); + } + + @Test + void responseResourceReturnsContentValidationWhenSchemaHasSemanticVerdict() { + Map schema = Map.of("required", List.of("semantic_verdict")); + + String resource = SampleMockLLMClient.responseResource(schema); + + assertEquals("content_validation.json", resource); + } + + @Test + void responseResourceRaisesForUnknownSchema() { + Map schema = Map.of("type", "object"); + + assertThrows(ValueErrorException.class, () -> SampleMockLLMClient.responseResource(schema)); + } + + @Test + void responseResourceRaisesForNullSchema() { + assertThrows(ValueErrorException.class, () -> SampleMockLLMClient.responseResource(null)); + } +} \ No newline at end of file From a256a5972776c9400b48ee4ee4f2bd62f0a99618 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 11:47:13 +0800 Subject: [PATCH 110/295] feat(sample): add negotiation end-to-end demo and fromData/fromText API samples Add the negotiation sample package covering the A2A-T 4-message flow (private-line complaint diagnosis) over real a2a-java HTTP+JSON: - NegotiationDemoApp boots an embedded HTTP server and drives the flow: Task-T with missing params -> Negotiation-T information request (INPUT_REQUIRED) -> params filled + accept -> diagnosis artifact (COMPLETED); --fromText switches to the LLM-based strategy - NegotiationStrategy isolates the fromData (deterministic rendering) vs fromText (one LLM extraction step) difference; both share Task-T generation, validateAndFillingTaskData and the state machine - FromDataNegotiationSample / FromTextNegotiationSample exercise the 3x3 matrix of negotiation types x phases (propose/accept/reject) - everything downstream of the missing-parameter detection is derived dynamically from ContentValidationException errors and FilledParamData, so the demo adapts to arbitrary Task-T inputs - gitignore keeps the negotiation.env template (empty API key) committable - document the sample in README.zh-CN.md and a design note; the demo always requires a real LLM key (fromData only skips the LLM for negotiation-message generation) Depends on #113 (DefaultSemanticValidator prompt-loading fix) Signed-off-by: ZhouJie6 --- .gitignore | 2 + a2a-t-sample/README.zh-CN.md | 77 ++++++ .../docs/Negotiation-Sample-Design.md | 224 +++++++++++++++ a2a-t-sample/pom.xml | 12 + .../negotiation/NegotiationDemoApp.java | 162 +++++++++++ .../negotiation/client/NegotiationClient.java | 224 +++++++++++++++ .../fromdata/FromDataNegotiationSample.java | 219 +++++++++++++++ .../fromtext/FromTextNegotiationSample.java | 169 ++++++++++++ .../a2at/sample/negotiation/package-info.java | 26 ++ .../negotiation/server/DiagnosisService.java | 43 +++ .../server/NegotiationAgentExecutor.java | 255 ++++++++++++++++++ .../server/NegotiationServerRuntime.java | 174 ++++++++++++ .../negotiation/shared/DemoConstants.java | 40 +++ .../negotiation/shared/FromDataStrategy.java | 53 ++++ .../negotiation/shared/FromTextStrategy.java | 75 ++++++ .../shared/NegotiationMessage.java | 83 ++++++ .../shared/NegotiationSampleSupport.java | 70 +++++ .../shared/NegotiationStrategy.java | 64 +++++ .../negotiation/shared/ScenarioData.java | 57 ++++ .../sample/negotiation/negotiation.env | 13 + 20 files changed, 2042 insertions(+) create mode 100644 a2a-t-sample/docs/Negotiation-Sample-Design.md create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/NegotiationDemoApp.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/package-info.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationServerRuntime.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/DemoConstants.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationMessage.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationSampleSupport.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java create mode 100644 a2a-t-sample/src/main/resources/sample/negotiation/negotiation.env diff --git a/.gitignore b/.gitignore index e3e53da6..5b8e75f2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ target/ .gitignore .flattened-pom.xml *.env +# Sample env templates carry no secrets (empty API key) and must ship with the sources. +!a2a-t-sample/src/main/resources/**/negotiation.env reference/ findings.md progress.md diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 534c017b..2164d56b 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -10,12 +10,83 @@ - 客户端:`net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain` - 服务端:`net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain` +- 协商端到端样例(4报文):`net.openan.a2at.sample.negotiation.NegotiationDemoApp` +- 结构化数据协商样例(fromData 3x3):`net.openan.a2at.sample.negotiation.fromdata.FromDataNegotiationSample` +- 自然语言协商样例(fromText 3x3):`net.openan.a2at.sample.negotiation.fromtext.FromTextNegotiationSample` ## 模块内资源 - 客户端环境模板:`sample/subscribe_incident/client/client.env` - 服务端环境模板:`sample/subscribe_incident/server/server.env` - 客户端场景输入:`sample/subscribe_incident/client/scenario.json` +- 协商样例环境模板:`sample/negotiation/negotiation.env` + +## 协商(Negotiation)端到端样例 + +协商样例是单进程端到端 demo,复用 `subscribe_incident` 的 a2a-java SDK 真实 HTTP+JSON 链路,覆盖 A2A-T 协议定义的"传输专线业务投诉诊断"4 报文信息协商流程:客户端 `A2ATClient` 与服务端 `A2ATServer` 通过 a2a-java `RestTransport`(`message:send`)+ `EmbeddedA2AHttpServer`(`DefaultRequestHandler` + `NegotiationAgentExecutor`)经 HTTP A2A 交互,协商 prompt 放进 A2A `Message.metadata`(Negotiation-T 扩展 URI 作 key),`A2A-Extensions` 头声明扩展。 + +4 报文流转: + +| 报文 | 方向 | 内容 | 任务状态 | +|---|---|---|---| +| 1 | client→server | Task-T(参数缺失) | → | +| 2 | server→client | Negotiation-T 信息协商请求(动态列出缺失参数) | INPUT_REQUIRED | +| 3 | client→server | Task-T(参数补齐)+ Negotiation-T accept | → | +| 4 | server→client | 诊断结果(从提取参数动态生成) | COMPLETED | + +**运行需要真实 LLM API key**:`fromData` 只让协商报文生成环节变成确定性规则渲染(不调 LLM),Task-T 槽位提取与语义校验仍调用 LLM。缺 key 时启动即报错退出。 + +详细 API 见 [A2A-T 协商 API 文档](../docs/zh/A2A-T-Negotiation-API-Reference.md),设计说明见 [Negotiation-Sample-Design.md](docs/Negotiation-Sample-Design.md)。 + +### 协商样例结构 + +| 目录 | 作用 | +|---|---| +| `negotiation/` | 入口 `NegotiationDemoApp`:启动嵌入式 HTTP server + 跑 client,`--fromText` 切换策略 | +| `negotiation/client/` | `NegotiationClient`:4 报文编排(Task-T 缺失→收协商→补齐→收诊断) | +| `negotiation/server/` | `NegotiationAgentExecutor`(validateAndFillingTaskData→缺失检测→协商请求→诊断)+ `NegotiationServerRuntime`(HTTP server 装配)+ `DiagnosisService`(从 FilledParamData 动态生成诊断) | +| `negotiation/shared/` | 策略层(`NegotiationStrategy` + `FromDataStrategy`/`FromTextStrategy`)、A2A metadata 桥接(`NegotiationMessage`)、扩展/模板 URI 常量(`DemoConstants`)、场景数据(`ScenarioData`)、样例公共辅助(`NegotiationSampleSupport`) | +| `negotiation/fromdata/`、`negotiation/fromtext/` | 9 用例 API 验证样例(见下文两节) | + +### 协商样例启动 + +1. 复制 `a2a-t-sample/src/main/resources/sample/negotiation/negotiation.env`,补充可用的 `A2AT_LLM_API_KEY` +2. 启动协商样例(单进程,嵌入式 a2a-java HTTP server + client 经真实 HTTP A2A 交互): + +```bash +java @a2a-t-sample/target/negotiation.javaargs.txt /path/to/.env + +# fromText 策略(协商报文由 LLM 生成) +java @a2a-t-sample/target/negotiation.javaargs.txt --fromText /path/to/.env +``` + +如果不传参数,`NegotiationDemoApp` 会回退到包内的 `sample/negotiation/negotiation.env`(该模板 key 为空,仅用于占位)。Windows 控制台如遇中文乱码,先执行 `chcp 65001`。 + +## 结构化数据协商样例(fromData) + +验证 `generateNegotiationProposePromptFromData` / `generateNegotiationAcceptPromptFromData` / `generateNegotiationRejectPromptFromData` 三个 API(结构化输入,规则渲染)。覆盖 3 种协商类型 × 3 种阶段 = 9 个用例: + +| 类型 | propose | accept | reject | +|---|---|---|---| +| 信息协商 | 请求补充接入端口名称/投诉分类 | 交付补充信息 | 站点清单不可用,无法提供 | +| 目标协商 | 意图理解 + 待澄清 | 确认节能目标 | 区域信息无法澄清 | +| 可行性协商 | 请求评估节能可行性 | 确认可行 | 供电约束下不可行 | + +```bash +java @a2a-t-sample/target/fromdata.javaargs.txt /path/to/.env +``` + +9 个用例的协商报文生成均为确定性规则渲染,不调 LLM;但 SDK 配置仍需有效的 `A2AT_LLM_API_KEY`(`A2ATClient` 构造即校验)。 + +## 自然语言协商样例(fromText) + +验证 `generateNegotiationProposePromptFromText` / `generateNegotiationAcceptPromptFromText` / `generateNegotiationRejectPromptFromText` 三个 API(自然语言输入,LLM 结构化抽取 + 渲染)。同样覆盖 3 种类型 × 3 种阶段 = 9 个用例。与 fromData 的差异:输入是自然语言文本,SDK 用 LLM 抽取为类型化内容后渲染。**需要真实 LLM API key**。 + +```bash +java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env +``` + +复用 `shared/NegotiationSampleSupport` 公共辅助(SessionId、模板 URI 常量、summary),fromData 和 fromText 差异仅在输入构造(record vs 自然语言文本)。 ## 客户端启动 @@ -59,3 +130,9 @@ java @a2a-t-sample/target/server.javaargs.txt ```bash java @a2a-t-sample/target/client.javaargs.txt ``` + +协商样例(无需启动服务端,单进程,需指定含 LLM key 的 .env): + +```bash +java @a2a-t-sample/target/negotiation.javaargs.txt /path/to/.env +``` diff --git a/a2a-t-sample/docs/Negotiation-Sample-Design.md b/a2a-t-sample/docs/Negotiation-Sample-Design.md new file mode 100644 index 00000000..e37693dd --- /dev/null +++ b/a2a-t-sample/docs/Negotiation-Sample-Design.md @@ -0,0 +1,224 @@ + # A2A-T 协商样例设计与实现说明 + + > 记录目标、架构、依赖、注意事项、代码索引、运行方式,便于理解和维护本样例。 + + --- + + ## 一、任务目标 + + 基于 `a2a-t-sdk-java` 的协商(Negotiation-T)SDK 能力,构建可运行的端到端 demo,覆盖文档 §7.3/§7.4 定义的"传输专线业务投诉诊断"4 报文协商流程,并输出 fromData / fromText 两种协商生成方式的 API 验证样例。 + + ### 分工 + + | 方式 | API | 特点 | +|---|---|---| +| fromData(结构化数据,规则生成) | `generateNegotiationProposePromptFromData` / `...Accept...` / `...Reject...` | 协商报文生成不调 LLM,确定性 | +| fromText(自然语言,LLM 抽取) | `generateNegotiationProposePromptFromText` / `...Accept...` / `...Reject...` | 协商报文生成需 LLM | + + 两种方式的**唯一差异**是协商报文的生成方式(规则 vs LLM)。其他环节(Task-T 报文生成、参数校验、状态机)完全一致,该用 LLM 的地方都用 LLM。**整个 demo 始终需要真实 LLM API key**:fromData 只省掉协商报文生成这一个环节的 LLM 调用,Task-T 槽位提取与语义校验仍走 LLM。 + + --- + + ## 二、4 报文流转(文档 §7.3/§7.4) + + | 报文 | 方向 | 扩展 | SDK API | 内容 | 状态 | + |---|---|---|---|---|---| + | 1 | client→server | Task-T | `generateTaskPromptFromDataWithSchema` | 专线投诉诊断任务,参数缺失(接入端口名称空) | → | + | 2 | server→client | Negotiation-T | `validateAndFillingTaskData` 校验→`ContentValidationException.errors()` 提取缺失项→`NegotiationStrategy.generatePropose` | 信息协商请求,动态列出缺失参数 | INPUT_REQUIRED | + | 3 | client→server | Task-T + Negotiation-T | `generateTaskPromptFromDataWithSchema`(补齐) + `NegotiationStrategy.generateAccept` | 参数补齐 + 协商接受 | → | + | 4 | server→client | Task-T | `validateAndFillingTaskData` 校验通过→`DiagnosisService.diagnose(params)` | 诊断结果(从提取参数动态生成) | COMPLETED | + + 全程走真实 a2a-java HTTP A2A(`message:send`)。 + + --- + + ## 三、核心设计:策略模式隔离 fromData / fromText + + ``` + NegotiationStrategy(接口) + ├── FromDataStrategy → 构造类型化 record → generateXxxFromData(规则,不调 LLM) + └── FromTextStrategy → 转自然语言文本 → generateXxxFromText(LLM 抽取 + 渲染) + ``` + + - `NegotiationDemoApp` 接受 `--fromText` 参数,选择策略注入 server 和 client + - server `NegotiationAgentExecutor` 和 client `NegotiationClient` 都持有一个 `NegotiationStrategy` 实例 + - 协商报文生成时调用 `strategy.generatePropose(...)` / `strategy.generateAccept(...)` + - 两种策略的差异仅在"怎么把缺失/补齐参数转成 API 输入"(record vs 文本),其余逻辑完全共享 + + --- + + ## 四、不写死——全动态生成 + + | 环节 | 旧做法(写死) | 新做法(动态) | + |---|---|---| + | 参数缺失检测 | `hasMissingParams` 轻量字符串匹配 | `validateAndFillingTaskData`(SDK LLM 语义校验) | + | 缺失参数列表 | `InfoProposeContent` 硬编码 | 从 `ContentValidationException.errors()` 动态提取 slotName | + | accept 内容 | `InfoEndingContent` 硬编码 | 从 `ScenarioData.filledParams()` 动态构造 NegotiationItem 列表 | + | 诊断结果 | `DiagnosisService.diagnose()` 固定文本 | `DiagnosisService.diagnose(FilledParamData)` 从提取参数动态生成 | + | 协商报文生成 | 只用 fromData | 策略模式,fromData/fromText 可切换 | + + 支持泛化输入:换一个 Task-T 输入(缺不同参数),协商内容自动适配。 + + --- + + ## 五、依赖文档 + + | 文档 | 路径 | 用途 | + |---|---|---| + | A2A-T 协商 API 文档(中) | `docs/zh/A2A-T-Negotiation-API-Reference.md` | 协商三层 API(状态机/内容生成/校验)、类型化内容模型、模板 URI、异常码 | + | A2A-T 协商 API 文档(英) | `docs/en/A2A-T-Negotiation-API-Reference.md` | 同上英文版 | + | A2A-T SDK API 综合 | `docs/zh/A2A-T-SDK-API-Reference.md` | 全局 API 参考(协商分册在此基础上) | + | Task-T 模板资源 | `a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/private-line-complaint/v1/zh-CN/template.md` | 专线投诉诊断 Task-T 模板 | + | Task-T slot schema | `a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json` | Task-T 参数 schema(任务对象/任务上下文) | + | Negotiation-T 模板 | `a2a-t-resources/.../templates/Negotiation-T/` | 信息/目标/可行性协商 propose + accept-reject 模板 | + | content_validation prompt | `a2a-t-resources/.../prompts/content_validation/zh-CN/system.md` + `user.md` | Task-T 服务端语义校验的 LLM prompt | + + ### SDK 关键类索引 + + | 类 | 模块 | 作用 | + |---|---|---| + | `A2ATClient` | a2a-t-client | 客户端 facade(fromData/fromText 生成 + validate + 状态机) | + | `A2ATServer` | a2a-t-server | 服务端 facade(同上,额外 prompt compliance) | + | `NegotiationContext` (content 层) | a2a-t-negotiation | 协商会话上下文(id/round/maxRounds) | + | `NegotiationContext` (types.model 层) | a2a-t-negotiation | 状态机上下文(type/id/round/status) | + | `NegotiationProposeData` / `NegotiationEndingData` | a2a-t-negotiation | fromData 输入 | + | `InfoProposeContent` / `TargetProposeContent` / `FeasibilityProposeContent` | a2a-t-negotiation | 三种协商类型的 propose 内容 | + | `InfoEndingContent` / `TargetEndingContent` / `FeasibilityEndingContent` | a2a-t-negotiation | 三种协商类型的 ending 内容 | + | `MetadataContent` | a2a-t-core | 生成输出(templateUri + promptText + extensionUri) | + | `FilledParamData` | a2a-t-core | 校验参数提取结果 | + | `ContentValidationException` | a2a-t-core | 校验失败异常(携带 `errors()` 即 `List`) | + | `NegotiationPayloadMapper` | a2a-t-negotiation | payload 序列化/反序列化 | + | `EmbeddedA2AHttpServer` | a2a-t-sample (subscribe_incident) | 嵌入式 HTTP A2A 服务器 | + | `DefaultRequestHandler` / `AgentExecutor` / `AgentEmitter` | a2a-java SDK (org.a2aproject.sdk) | A2A 服务端请求处理 | + | `RestTransport` / `AgentCard` / `Message` / `Task` | a2a-java SDK | A2A 客户端传输 + 数据模型 | + + --- + + ## 六、代码索引 + + ### 端到端 demo(4 报文) + + | 文件 | 作用 | + |---|---| + | `negotiation/NegotiationDemoApp.java` | 入口:启动 HTTP server + 跑 client,`--fromText` 切换策略 | + | `negotiation/client/NegotiationClient.java` | 4 报文编排:Task-T 缺失→收协商→补齐→收诊断 | + | `negotiation/server/NegotiationAgentExecutor.java` | AgentExecutor:validateAndFillingTaskData→缺失检测→协商请求→诊断 | + | `negotiation/server/NegotiationServerRuntime.java` | HTTP server + DefaultRequestHandler + AgentCard 装配 | + | `negotiation/server/DiagnosisService.java` | 从 FilledParamData 动态生成诊断结果 | + + ### 策略层(fromData / fromText 差异隔离) + + | 文件 | 作用 | + |---|---| + | `negotiation/shared/NegotiationStrategy.java` | 策略接口(generatePropose / generateAccept / generateAcceptServer) | + | `negotiation/shared/FromDataStrategy.java` | fromData 实现(record + fromData API,规则) | + | `negotiation/shared/FromTextStrategy.java` | fromText 实现(文本 + fromText API,LLM) | + + ### 公共层 + + | 文件 | 作用 | + |---|---| + | `negotiation/shared/NegotiationMessage.java` | A2A metadata 桥接(Task-T/Negotiation-T prompt + context 序列化) | + | `negotiation/shared/DemoConstants.java` | 扩展 URI、模板 URI 常量 | + | `negotiation/shared/ScenarioData.java` | 参数缺失/补齐场景数据 + slot schema | + | `negotiation/shared/NegotiationSampleSupport.java` | fromData/fromText 样例公共辅助(SessionId、URI 常量、summary) | + + ### API 验证样例(3 类型 × 3 阶段 = 9 用例) + + | 文件 | 作用 | + |---|---| + | `negotiation/fromdata/FromDataNegotiationSample.java` | 9 用例,fromData API,报文生成确定性 | + | `negotiation/fromtext/FromTextNegotiationSample.java` | 9 用例,fromText API,报文生成含 LLM 抽取 | + + ### 文档 + + | 文件 | 作用 | + |---|---| + | `docs/zh/A2A-T-Negotiation-API-Reference.md` | 协商 API 文档(中) | + | `docs/en/A2A-T-Negotiation-API-Reference.md` | 协商 API 文档(英) | + | `a2a-t-sample/docs/Negotiation-Sample-Design.md` | 本文档 | + + --- + + ## 七、SDK bug 修复(a2a-t-prompt 模块) + + 构建 demo 时发现并修复了 `a2a-t-prompt` 模块两个 bug: + + ### Bug 1:`DefaultSemanticValidator.loadPrompt` 漏 `.md` + + - 文件:`a2a-t-prompt/.../DefaultSemanticValidator.java` + - 问题:`loadPrompt("content_validation", language, "system")` 的 fileName 不带 `.md`,`LocalFileAccess` 找不到 `system.md` 文件 + - 对比:Negotiation 模块的 `DefaultNegotiationSemanticValidator` 传的是 `"system.md"`(正确) + - 修复:改为 `"system.md"` / `"user.md"` + + ### Bug 2:`parseParams` 的 `Map.copyOf` 对 null value NPE + + - 文件:`a2a-t-prompt/.../DefaultSemanticValidator.java` + - 问题:LLM 语义校验返回的 params map 可能含 null value,`Map.copyOf` 不允许 null 抛 NPE + - 修复:过滤 null value 后再 `Map.copyOf` + + --- + + ## 八、运行方式 + + ### 前置条件 + + - JDK 17+ +- `mvn clean install -DskipTests`(全量构建,errorprone 在 JDK17 下正常工作,需联网模式) + - 含真实 LLM API key 的 `.env` 文件(参考 `a2a-t-sample/src/main/resources/sample/negotiation/negotiation.env` 模板,填入 `A2AT_LLM_API_KEY`) + + ### 4 报文端到端 demo + + ```bash + # fromData 策略(协商报文规则生成,不调 LLM;Task-T 槽位提取/语义校验仍需 LLM key) + java @a2a-t-sample/target/negotiation.javaargs.txt /path/to/.env + + # fromText 策略(协商报文 LLM 生成) + java @a2a-t-sample/target/negotiation.javaargs.txt --fromText /path/to/.env + ``` + + ### API 验证样例 + + ```bash + # fromData 9 用例(报文生成不调 LLM,但 A2ATClient 构造仍需有效的 LLM key) + java @a2a-t-sample/target/fromdata.javaargs.txt /path/to/.env + + # fromText 9 用例(LLM 抽取,需 LLM key) + java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env + ``` + + --- + + ## 九、注意事项与已知限制 + + 1. **errorprone 与 JDK17**:`old_jdk_support` profile(`[17, 21]`)自动降级 errorprone 到 2.42.0。离线模式(`-o`)会因插件 jar 不完整导致 `ServiceLoader defineClass` 失败,必须联网构建。 + + 2. **模板 URI 格式**:正确格式是 `Negotiation-T/{type-segment}/{phase-segment}/v1`(v1 在末段)。文档之前写的 `Negotiation-T/v1/{type}/{phase}` 是错的,已修正。 + + 3. **两套 NegotiationContext**:`content.NegotiationContext`(id/round/maxRounds,用于内容生成)和 `types.model.NegotiationContext`(type/id/round/status,用于状态机),不可混用。 + + 4. **AgentInterface 构造函数**:4 参数版 `(protocolBinding, url, tenant, protocolVersion)`,tenant 必须传空字符串(不能省略),否则 `buildBaseUrl` 会把 protocolVersion 当 tenant 追加到 URL。 + + 5. **message:send 返回 Task**:非流式 `message:send` 返回的 `EventKind` 是 `Task`(不是 `Message`),回复在 `Task.history()` 或 `Task.artifacts()` 里,需要根据场景提取。 + + 6. **DiagnosisService 是 mock**:诊断结果从 `FilledParamData` 动态生成,但不是真实网管诊断。真实场景应替换为 EMS/NMS 北向 API 调用。 + + 7. **fromText 报文生成需要 LLM key**:`FromTextNegotiationSample` 和 `--fromText` 模式的协商报文生成含一步 LLM 抽取。fromData 样例的报文生成虽不调 LLM,但 `A2ATClient` 构造即校验 `A2AT_LLM_API_KEY` 非空,同样需要有效 key。 + + 8. **content_validation 资源**:Task-T 服务端语义校验需要 `content_validation` prompt 资源(system.md/user.md)。`local_file` 模式指向 `a2a-t-resources/src/main/resources/prompt_resources`;`classpath` 模式需 a2a-t-resources jar 在 classpath 上。 + + 9. **Unicode 转义已修复**:之前因 PowerShell 编码问题用了 `\uXXXX` 转义序列,已全部替换为 UTF-8 中文字符,符合开源规范。 + + 10. **NegotiationServerRuntime 未调 receiveNegotiation**:当参数缺失时跳过 `receiveNegotiation`(因为 InformationNegotiation handler 会调用 compliance checker 拒绝不完整 Task-T)。直接从 `ContentValidationException.errors()` 提取缺失项后生成协商请求。 + + --- + + ## 十、fromData / fromText 验证覆盖矩阵 + + | 类型 | propose | accept | reject | + |---|---|---|---| + | 信息协商(information) | ✅ | ✅ | ✅ | + | 目标协商(target) | ✅ | ✅ | ✅ | + | 可行性协商(feasibility) | ✅ | ✅ | ✅ | + + fromData 和 fromText 各 9 个用例,全部通过。 diff --git a/a2a-t-sample/pom.xml b/a2a-t-sample/pom.xml index 68928923..c9c5251b 100644 --- a/a2a-t-sample/pom.xml +++ b/a2a-t-sample/pom.xml @@ -129,6 +129,18 @@ server.env ${sample.jar.path}${path.separator}${sample.runtime.classpath} net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain client.env + + -cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.negotiation.NegotiationDemoApp + + -cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.negotiation.fromdata.FromDataNegotiationSample + + -cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.negotiation.fromtext.FromTextNegotiationSample diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/NegotiationDemoApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/NegotiationDemoApp.java new file mode 100644 index 00000000..f5c85fe4 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/NegotiationDemoApp.java @@ -0,0 +1,162 @@ +package net.openan.a2at.sample.negotiation; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sample.negotiation.client.NegotiationClient; +import net.openan.a2at.sample.negotiation.server.NegotiationServerRuntime; +import net.openan.a2at.sample.negotiation.shared.FromDataStrategy; +import net.openan.a2at.sample.negotiation.shared.FromTextStrategy; +import net.openan.a2at.sample.negotiation.shared.NegotiationStrategy; +import net.openan.a2at.sample.subscribe_incident.server.http.EmbeddedA2AHttpServer; +import net.openan.a2at.sample.subscribe_incident.shared.env.SampleEnvironmentPathResolver; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.mock.SampleMockLlmInstaller; +import net.openan.a2at.sdk.client.A2ATClient; +import org.a2aproject.sdk.spec.AgentCard; + +/** + * Entry point for the negotiation end-to-end demo. + * + *

      Boots an embedded a2a-java HTTP server wired to a {@link NegotiationServerRuntime} (carrying the + * {@link net.openan.a2at.sdk.server.A2ATServer} negotiation facade), then runs the {@link NegotiationClient} which + * drives the 4-message flow over real HTTP+JSON: + * + *

        + *
      1. client -> Task-T (params missing); + *
      2. server -> Negotiation-T request (missing params) -> INPUT_REQUIRED; + *
      3. client -> Task-T (params filled) + Negotiation-T accept; + *
      4. server -> diagnosis result -> COMPLETED. + *
      + * + *

      A real LLM API key is always required: {@code fromData} only makes the negotiation-message generation step + * deterministic (no LLM call for rendering the Negotiation-T prompt), while Task-T slot extraction and semantic + * validation still go through the LLM. + * + *

      Usage: + * + *

      {@code
      + * java @a2a-t-sample/target/negotiation.javaargs.txt /path/to/.env
      + * java @a2a-t-sample/target/negotiation.javaargs.txt --fromText /path/to/.env
      + * }
      + * + * @since 2026-08 + */ +public final class NegotiationDemoApp { + + private NegotiationDemoApp() {} + + /** + * Resolves the env file path from the first non-flag argument, falling back to the bundled template. + * + * @param args command-line arguments + * @return resolved env path + */ + public static Path resolveEnvPath(String[] args) { + for (String arg : args) { + if (!"--fromText".equals(arg) && !arg.startsWith("--")) { + return Path.of(arg); + } + } + Path sampleEnvDir = Path.of("a2a-t-sample", "src", "main", "resources", "sample", "negotiation"); + return SampleEnvironmentPathResolver.resolve(sampleEnvDir, "negotiation.env", "negotiation.env"); + } + + /** + * Runs the 4-message flow with an explicit strategy choice. + * + * @param envPath resolved env path + * @param useFromText true for fromText (LLM), false for fromData (rule-based) + * @param logSink log output sink + * @return scenario summary + */ + public static Map runMain(Path envPath, boolean useFromText, Consumer logSink) { + requireLlmApiKey(envPath); + SampleMockLlmInstaller.installLlmLogger(false, "negotiation"); + emit(logSink, "[negotiation] strategy: " + (useFromText ? "fromText (LLM)" : "fromData (rule-based)")); + NegotiationStrategy strategy = useFromText ? new FromTextStrategy() : new FromDataStrategy(); + + NegotiationServerRuntime serverRuntime = new NegotiationServerRuntime(envPath, strategy, logSink); + String host = serverRuntime.resolveHost(); + int port = serverRuntime.resolvePort(); + emit(logSink, "[negotiation] starting embedded a2a-java HTTP server: http://" + host + ":" + port); + AgentCard agentCard = serverRuntime.buildAgentCard(host, port); + EmbeddedA2AHttpServer httpServer = + EmbeddedA2AHttpServer.start(host, port, agentCard, serverRuntime.buildRequestHandler()); + emit(logSink, "[negotiation] embedded a2a-java HTTP server started"); + + A2ATClient clientFacade = new A2ATClient(envPath); + NegotiationClient client = new NegotiationClient(clientFacade, strategy, logSink); + try { + Map summary = client.runFourMessageFlow(serverRuntime, envPath); + emit(logSink, "[negotiation] === Summary ==="); + emit(logSink, "[negotiation] " + summary); + return summary; + } finally { + client.close(); + httpServer.close(); + serverRuntime.close(); + } + } + + /** + * Entry point. + * + * @param args optional {@code --fromText} flag and the path to the {@code .env} file + */ + public static void main(String[] args) { + boolean useFromText = false; + for (String arg : args) { + if ("--fromText".equals(arg)) { + useFromText = true; + } + } + runMain(resolveEnvPath(args), useFromText, System.out::println); + } + + /** + * Fails fast when the env file does not carry a usable LLM API key. The demo always needs a real key: fromData only + * removes the LLM call from negotiation-message generation, while Task-T slot extraction and semantic validation + * still call the LLM. + */ + private static void requireLlmApiKey(Path envPath) { + Map values = readEnv(envPath); + String apiKey = values.get("A2AT_LLM_API_KEY"); + if (apiKey == null || apiKey.isBlank()) { + throw new ValueErrorException( + "The negotiation demo requires a real LLM API key. Set A2AT_LLM_API_KEY in " + envPath + + " (fromData only skips the LLM for negotiation-message generation; " + + "Task-T slot extraction and semantic validation still call the LLM)."); + } + } + + private static Map readEnv(Path envPath) { + Map values = new LinkedHashMap<>(); + if (envPath == null || !Files.exists(envPath)) { + return values; + } + try { + for (String rawLine : Files.readAllLines(envPath)) { + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { + continue; + } + int separator = line.indexOf('='); + values.put( + line.substring(0, separator).trim(), + line.substring(separator + 1).trim()); + } + } catch (java.io.IOException exception) { + throw new ValueErrorException("Failed to read env file: " + envPath); + } + return values; + } + + private static void emit(Consumer logSink, String message) { + if (logSink != null) { + logSink.accept(message); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java new file mode 100644 index 00000000..26bea41f --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java @@ -0,0 +1,224 @@ +package net.openan.a2at.sample.negotiation.client; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import net.openan.a2at.sample.negotiation.server.NegotiationServerRuntime; +import net.openan.a2at.sample.negotiation.shared.DemoConstants; +import net.openan.a2at.sample.negotiation.shared.NegotiationMessage; +import net.openan.a2at.sample.negotiation.shared.NegotiationStrategy; +import net.openan.a2at.sample.negotiation.shared.ScenarioData; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.runtime.helper.NegotiationPayloadMapper; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.EventKind; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TextPart; + +/** + * Client that drives the 4-message negotiation flow over real HTTP A2A. + * + *

      Flow: + * + *

        + *
      1. message 1: generate a Task-T prompt with missing params, send via {@code message:send}; + *
      2. message 2: receive the Negotiation-T request (server detected missing params -> INPUT_REQUIRED); + *
      3. message 3: generate a Task-T prompt with filled params + a Negotiation-T accept, send; + *
      4. message 4: receive the diagnosis result artifact (server -> COMPLETED). + *
      + * + * @since 2026-08 + */ +public final class NegotiationClient { + + private final A2ATClient client; + private final Consumer logSink; + private final NegotiationStrategy strategy; + private RestTransport transport; + + /** + * Creates the negotiation client. + * + * @param client client facade used for prompt generation and the negotiation state machine + * @param strategy negotiation-message generation strategy (fromData or fromText) + * @param logSink log output sink + */ + public NegotiationClient(A2ATClient client, NegotiationStrategy strategy, Consumer logSink) { + this.client = client; + this.strategy = strategy; + this.logSink = logSink; + } + + /** + * Runs the 4-message flow against one server runtime and returns a summary. + * + * @param serverRuntime the wired server runtime (started HTTP server) + * @param envPath resolved env path (LLM key etc.) + * @return scenario summary + */ + public Map runFourMessageFlow(NegotiationServerRuntime serverRuntime, Path envPath) { + String host = serverRuntime.resolveHost(); + int port = serverRuntime.resolvePort(); + emit("[client] server at http://" + host + ":" + port); + + AgentCard agentCard = serverRuntime.buildAgentCard(host, port); + + // -- message 1: Task-T with missing params -- + emit("[client] === message 1: Task-T (params missing) ==="); + MetadataContent taskMissing = client.generateTaskPromptFromDataWithSchema( + ScenarioData.missingParams(), ScenarioData.TASK_SCHEMA, DemoConstants.TASK_TEMPLATE); + emit("[client] Task-T prompt rendered (params missing)"); + + // start the negotiation state machine with the missing-params prompt + Map startPayload = + client.startNegotiation(NegotiationType.INFORMATION, taskMissing.promptText(), Map.of()); + Map contextMap = NegotiationPayloadMapper.extractContextMap(startPayload); + + EventKind reply1 = send(agentCard, DemoConstants.TASK_T_URI, taskMissing, contextMap); + emit("[client] received message 2 (negotiation request)"); + + // extract the negotiation context from the reply + Message reply1Msg = extractReplyMessage(reply1); + Map negotiationContext = NegotiationMessage.extractContext(reply1Msg.metadata()); + + // -- message 3: Task-T with filled params + Negotiation-T accept -- + emit("[client] === message 3: Task-T (params filled) + Negotiation-T accept ==="); + MetadataContent taskFilled = client.generateTaskPromptFromDataWithSchema( + ScenarioData.filledParams(), ScenarioData.TASK_SCHEMA, DemoConstants.TASK_TEMPLATE); + emit("[client] Task-T prompt rendered (params filled)"); + + MetadataContent acceptPrompt = strategy.generateAccept( + client, + new NegotiationContext(UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS), + itemsFromFilledParams(), + DemoConstants.NEGOTIATION_ACCEPT); + emit("[client] Negotiation-T accept rendered"); + + // merge Task-T + Negotiation-T into one message metadata + Map mergedMetadata = new LinkedHashMap<>(); + mergedMetadata.put(DemoConstants.TASK_T_URI, taskFilled.promptText()); + mergedMetadata.put(DemoConstants.NEGOTIATION_T_URI, acceptPrompt.promptText()); + mergedMetadata.put(DemoConstants.TEMPLATE_URI_KEY, DemoConstants.TASK_TEMPLATE.uri()); + mergedMetadata.put(DemoConstants.NEGOTIATION_CONTEXT_KEY, NegotiationMessage.toJson(negotiationContext)); + + EventKind reply2 = sendRaw(agentCard, mergedMetadata, taskFilled.promptText(), negotiationContext); + emit("[client] received message 4 (diagnosis result)"); + + // extract diagnosis from the reply task's artifact + String diagnosis = extractDiagnosis(reply2); + + Map summary = new LinkedHashMap<>(); + summary.put("scenario", "private-line-complaint-negotiation"); + summary.put("messages", 4); + summary.put("outcome", "COMPLETED"); + summary.put("diagnosis", diagnosis); + return summary; + } + + /** Sends one extension's prompt as an A2A message ({@code message:send}) and returns the raw reply event. */ + private EventKind send( + AgentCard agentCard, String extensionUri, MetadataContent content, Map contextMap) { + Map metadata = NegotiationMessage.buildMetadata( + extensionUri, content.promptText(), parseTemplateUri(content.templateUri()), contextMap); + return sendRaw(agentCard, metadata, content.promptText(), contextMap); + } + + private EventKind sendRaw( + AgentCard agentCard, Map metadata, String promptText, Map contextMap) { + if (transport == null) { + transport = new RestTransport(agentCard); + } + Message message = Message.builder() + .messageId(UUID.randomUUID().toString()) + .role(Message.Role.ROLE_USER) + .parts(new TextPart(promptText)) + .metadata(metadata) + .build(); + MessageSendParams request = MessageSendParams.builder().message(message).build(); + String extensionsHeader = DemoConstants.TASK_T_URI + "," + DemoConstants.NEGOTIATION_T_URI; + ClientCallContext callContext = new ClientCallContext(Map.of(), Map.of("A2A-Extensions", extensionsHeader)); + try { + return transport.sendMessage(request, callContext); + } catch (org.a2aproject.sdk.spec.A2AClientException exception) { + throw new RuntimeException("a2a-java sendMessage failed: " + exception.getMessage(), exception); + } + } + + /** Parses a rendered template URI string back into a {@code TemplateUri} for metadata building. */ + private static net.openan.a2at.sdk.core.model.TemplateUri parseTemplateUri(String templateUri) { + return net.openan.a2at.sdk.core.model.TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); + } + + private static Message extractReplyMessage(EventKind reply) { + if (reply instanceof Message directMessage) { + return directMessage; + } + if (reply instanceof Task task) { + for (int i = task.history().size() - 1; i >= 0; i--) { + Message candidate = task.history().get(i); + if (candidate.role() == Message.Role.ROLE_AGENT) { + return candidate; + } + } + } + throw new IllegalStateException("a2a-java did not return a reply message: " + reply); + } + + private static String extractDiagnosis(EventKind reply) { + if (reply instanceof Task task && task.artifacts() != null) { + for (org.a2aproject.sdk.spec.Artifact artifact : task.artifacts()) { + if (artifact.parts() != null) { + for (org.a2aproject.sdk.spec.Part part : artifact.parts()) { + if (part instanceof org.a2aproject.sdk.spec.DataPart dataPart) { + Object data = dataPart.data(); + Object value = (data instanceof Map m) ? m.get(DemoConstants.TASK_T_URI) : null; + if (value != null) { + return String.valueOf(value); + } + } + } + } + } + } + if (reply instanceof Message message && message.metadata() != null) { + Object value = message.metadata().get(DemoConstants.TASK_T_URI); + return value == null ? "" : String.valueOf(value); + } + return ""; + } + + /** Releases the underlying HTTP transport. */ + public void close() { + if (transport != null) { + transport.close(); + } + } + + /** Builds the filled-items list from {@link ScenarioData#filledParams()}, adapting to any parameter set. */ + private static List itemsFromFilledParams() { + List items = new ArrayList<>(); + for (Map.Entry entry : ScenarioData.filledParams().entrySet()) { + items.add(new NegotiationItem(entry.getKey(), String.valueOf(entry.getValue()))); + } + return items; + } + + private void emit(String message) { + if (logSink != null) { + logSink.accept(message); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java new file mode 100644 index 00000000..f934dece --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java @@ -0,0 +1,219 @@ +package net.openan.a2at.sample.negotiation.fromdata; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sample.negotiation.shared.NegotiationSampleSupport; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; + +/** + * Sample that demonstrates all three fromData negotiation APIs across all three negotiation types. + * + *

      This sample is the verification artifact for the fromData API surface: + * + *

        + *
      • {@link A2ATClient#generateNegotiationProposePromptFromData generateNegotiationProposePromptFromData} + *
      • {@link A2ATClient#generateNegotiationAcceptPromptFromData generateNegotiationAcceptPromptFromData} + *
      • {@link A2ATClient#generateNegotiationRejectPromptFromData generateNegotiationRejectPromptFromData} + *
      + * + * Each API is exercised for all three negotiation types (information, target, feasibility) so the 3x3 matrix (type x + * phase) is fully covered. Generation is deterministic (no LLM call for rendering), but the SDK still requires a + * configured LLM API key in the {@code .env} file. + * + *

      Run: + * + *

      {@code java @a2a-t-sample/target/fromdata.javaargs.txt /path/to/.env}
      + * + * @since 2026-08 + */ +public final class FromDataNegotiationSample { + + private FromDataNegotiationSample() {} + + /** + * Runs all fromData negotiation sample cases and returns one summary per case. + * + * @param envPath resolved {@code .env} file path + * @param logSink log output sink + * @return summary entries in execution order + */ + public static List> runAll(Path envPath, Consumer logSink) { + A2ATClient client = new A2ATClient(envPath); + List> results = new ArrayList<>(); + + results.addAll(informationNegotiation(client, logSink)); + results.addAll(targetNegotiation(client, logSink)); + results.addAll(feasibilityNegotiation(client, logSink)); + + return results; + } + + // ========================================================================================= + // 1. Information negotiation (missing information request / accept / reject) + // ========================================================================================= + + private static List> informationNegotiation(A2ATClient client, Consumer logSink) { + NegotiationSampleSupport.emit(logSink, "\n=== 1. Information negotiation ==="); + NegotiationContext ctx = + new NegotiationContext(NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS); + List> results = new ArrayList<>(); + + // propose: request missing information items + MetadataContent propose = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + ctx, + new InformationProposeContent( + List.of( + new NegotiationItem("接入端口名称", "请提供业务接入端口名称"), + new NegotiationItem("投诉分类", "专线中断或专线质差")), + "两个参数均为必选,缺少无法启动诊断")), + NegotiationSampleSupport.INFO_PROPOSE_URI); + results.add(NegotiationSampleSupport.summary("information", "propose", propose, logSink)); + + // accept: deliver the missing information + MetadataContent accept = client.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData( + ctx, + new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of( + new NegotiationItem("接入端口名称", "P781-珠江新城-PTN7900-23-TPA1EG24-17"), + new NegotiationItem("投诉分类", "专线质差")))), + NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); + results.add(NegotiationSampleSupport.summary("information", "accept", accept, logSink)); + + // reject: cannot provide the information + MetadataContent reject = client.generateNegotiationRejectPromptFromData( + new NegotiationEndingData( + ctx, + new InformationEndingContent( + NegotiationConclusion.REJECT, List.of(new NegotiationItem("接入端口名称", "站点清单不可用,无法提供")))), + NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); + results.add(NegotiationSampleSupport.summary("information", "reject", reject, logSink)); + + return results; + } + + // ========================================================================================= + // 2. Target negotiation (intent alignment / accept / reject) + // ========================================================================================= + + private static List> targetNegotiation(A2ATClient client, Consumer logSink) { + NegotiationSampleSupport.emit(logSink, "\n=== 2. Target negotiation ==="); + NegotiationContext ctx = + new NegotiationContext(NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS); + List> results = new ArrayList<>(); + + // propose: state the target intent and request clarification + MetadataContent propose = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + ctx, + new TargetProposeContent( + "对无线节点节能优化任务的意图理解待澄清", + List.of(new NegotiationItem("任务意图", "08:00-18:00对目标站点启用无线节点节能优化")), + null, + List.of(new NegotiationItem("节能区域", "松山湖还是其他站点?")))), + NegotiationSampleSupport.TARGET_PROPOSE_URI); + results.add(NegotiationSampleSupport.summary("target", "propose", propose, logSink)); + + // accept: confirm the negotiated target intent + MetadataContent accept = client.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData( + ctx, + new TargetEndingContent( + NegotiationConclusion.ACCEPT, + "最终确认意图:08:00-18:00对松山湖站点启用无线节点节能优化,速率保障不低于10Mbps", + null)), + NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); + results.add(NegotiationSampleSupport.summary("target", "accept", accept, logSink)); + + // reject: cannot agree on the target + MetadataContent reject = client.generateNegotiationRejectPromptFromData( + new NegotiationEndingData( + ctx, new TargetEndingContent(NegotiationConclusion.REJECT, null, "节能区域信息因站点清单不可用而无法完整澄清")), + NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); + results.add(NegotiationSampleSupport.summary("target", "reject", reject, logSink)); + + return results; + } + + // ========================================================================================= + // 3. Feasibility negotiation (evaluation request / accept / reject) + // ========================================================================================= + + private static List> feasibilityNegotiation(A2ATClient client, Consumer logSink) { + NegotiationSampleSupport.emit(logSink, "\n=== 3. Feasibility negotiation ==="); + NegotiationContext ctx = + new NegotiationContext(NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS); + List> results = new ArrayList<>(); + + // propose: request a feasibility evaluation + MetadataContent propose = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + ctx, + new FeasibilityProposeContent( + "请评估无线节点节能优化在高峰时段是否可行", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of( + new NegotiationItem("评估对象", "松山湖站点08:00-18:00节能窗口"), + new NegotiationItem("评估约束", "速率保障不低于10Mbps")), + null)), + NegotiationSampleSupport.FEASIBILITY_PROPOSE_URI); + results.add(NegotiationSampleSupport.summary("feasibility", "propose", propose, logSink)); + + // accept: feasibility confirmed + MetadataContent accept = client.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData( + ctx, + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "高峰时段节能优化可行,速率保障满足10Mbps下限,可启用")), + NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); + results.add(NegotiationSampleSupport.summary("feasibility", "accept", accept, logSink)); + + // reject: not feasible + MetadataContent reject = client.generateNegotiationRejectPromptFromData( + new NegotiationEndingData( + ctx, new FeasibilityEndingContent(NegotiationConclusion.REJECT, "节能目标在现有供电约束下无法实现,需调整方案")), + NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); + results.add(NegotiationSampleSupport.summary("feasibility", "reject", reject, logSink)); + + return results; + } + + // ========================================================================================= + + /** + * Entry point. + * + * @param args first argument is the {@code .env} file path (must contain a real LLM API key) + */ + public static void main(String[] args) { + if (args.length == 0) { + System.err.println("Usage: java FromDataNegotiationSample "); + System.err.println("A configured LLM API key is required (fromData skips the LLM only for " + + "negotiation-message generation)."); + System.exit(1); + } + Path envPath = Path.of(args[0]); + List> results = runAll(envPath, System.out::println); + System.out.println("\n=== Summary (" + results.size() + " cases) ==="); + for (Map r : results) { + System.out.println(r); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java new file mode 100644 index 00000000..52113d65 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java @@ -0,0 +1,169 @@ +package net.openan.a2at.sample.negotiation.fromtext; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sample.negotiation.shared.NegotiationSampleSupport; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; + +/** + * Sample that demonstrates all three fromText negotiation APIs across all three negotiation types. + * + *

      This sample is the verification artifact for the fromText API surface: + * + *

        + *
      • {@link A2ATClient#generateNegotiationProposePromptFromText generateNegotiationProposePromptFromText} + *
      • {@link A2ATClient#generateNegotiationAcceptPromptFromText generateNegotiationAcceptPromptFromText} + *
      • {@link A2ATClient#generateNegotiationRejectPromptFromText generateNegotiationRejectPromptFromText} + *
      + * + * Each API is exercised for all three negotiation types (information, target, feasibility). Unlike the fromData + * variant, the input is natural-language text: the SDK runs one LLM content-extraction step to parse it into typed + * content, then renders deterministically. A real LLM API key is required. + * + *

      Run: + * + *

      {@code java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env}
      + * + * @since 2026-08 + */ +public final class FromTextNegotiationSample { + + private FromTextNegotiationSample() {} + + /** + * Runs all fromText negotiation sample cases and returns one summary per case. + * + * @param envPath resolved {@code .env} file path (must contain a real LLM API key) + * @param logSink log output sink + * @return summary entries in execution order + */ + public static List> runAll(Path envPath, Consumer logSink) { + A2ATClient client = new A2ATClient(envPath); + List> results = new ArrayList<>(); + + results.addAll(informationNegotiation(client, logSink)); + results.addAll(targetNegotiation(client, logSink)); + results.addAll(feasibilityNegotiation(client, logSink)); + + return results; + } + + // ========================================================================================= + // 1. Information negotiation (natural-language text -> LLM extract -> render) + // ========================================================================================= + + private static List> informationNegotiation(A2ATClient client, Consumer logSink) { + NegotiationSampleSupport.emit(logSink, "\n=== 1. Information negotiation (fromText) ==="); + NegotiationContext ctx = + new NegotiationContext(NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS); + List> results = new ArrayList<>(); + + // propose: ask for missing information items + String proposeText = + "请提供以下缺失信息:" + "1. 接入端口名称:请提供业务接入端口名称;" + "2. 投诉分类:专线中断或专线质差。" + "两个参数均为必选,缺少无法启动诊断。" + "两者共同定位专线故障原因。"; + MetadataContent propose = client.generateNegotiationProposePromptFromText( + proposeText, ctx, NegotiationSampleSupport.INFO_PROPOSE_URI); + results.add(NegotiationSampleSupport.summary("information", "propose", propose, logSink)); + + // accept: deliver the requested information + String acceptText = + "同意补充以下信息:" + "1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17;" + "2. 投诉分类:专线质差。" + "信息已完整,可以启动诊断。"; + MetadataContent accept = client.generateNegotiationAcceptPromptFromText( + acceptText, ctx, NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); + results.add(NegotiationSampleSupport.summary("information", "accept", accept, logSink)); + + // reject: cannot provide the information + String rejectText = "拒绝补充信息:" + "接入端口名称因站点清单不可用而无法提供," + "本次协商结束。"; + MetadataContent reject = client.generateNegotiationRejectPromptFromText( + rejectText, ctx, NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); + results.add(NegotiationSampleSupport.summary("information", "reject", reject, logSink)); + + return results; + } + + // ========================================================================================= + // 2. Target negotiation + // ========================================================================================= + + private static List> targetNegotiation(A2ATClient client, Consumer logSink) { + NegotiationSampleSupport.emit(logSink, "\n=== 2. Target negotiation (fromText) ==="); + NegotiationContext ctx = + new NegotiationContext(NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS); + List> results = new ArrayList<>(); + + // propose: state the target intent and request clarification + String proposeText = "对无线节点节能优化任务的意图理解如下:" + "任务意图:08:00-18:00对目标站点启用无线节点节能优化。" + "待澄清内容:节能区域松山湖还是其他站点?"; + MetadataContent propose = client.generateNegotiationProposePromptFromText( + proposeText, ctx, NegotiationSampleSupport.TARGET_PROPOSE_URI); + results.add(NegotiationSampleSupport.summary("target", "propose", propose, logSink)); + + // accept: confirm the negotiated target intent + String acceptText = "确认意图:08:00-18:00对松山湖站点启用无线节点节能优化," + "速率保障不低于10Mbps。" + "最终意图已明确,可以执行。"; + MetadataContent accept = client.generateNegotiationAcceptPromptFromText( + acceptText, ctx, NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); + results.add(NegotiationSampleSupport.summary("target", "accept", accept, logSink)); + + // reject: cannot agree on the target + String rejectText = "拒绝:节能区域信息因站点清单不可用而无法完整澄清," + "本次协商结束。"; + MetadataContent reject = client.generateNegotiationRejectPromptFromText( + rejectText, ctx, NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); + results.add(NegotiationSampleSupport.summary("target", "reject", reject, logSink)); + + return results; + } + + // ========================================================================================= + // 3. Feasibility negotiation + // ========================================================================================= + + private static List> feasibilityNegotiation(A2ATClient client, Consumer logSink) { + NegotiationSampleSupport.emit(logSink, "\n=== 3. Feasibility negotiation (fromText) ==="); + NegotiationContext ctx = + new NegotiationContext(NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS); + List> results = new ArrayList<>(); + + // propose: request a feasibility evaluation + String proposeText = "请评估无线节点节能优化在高峰时段是否可行。" + "评估对象:松山湖站点08:00-18:00节能窗口。" + "评估约束:速率保障不低于10Mbps。"; + MetadataContent propose = client.generateNegotiationProposePromptFromText( + proposeText, ctx, NegotiationSampleSupport.FEASIBILITY_PROPOSE_URI); + results.add(NegotiationSampleSupport.summary("feasibility", "propose", propose, logSink)); + + // accept: feasibility confirmed + String acceptText = "同意:高峰时段节能优化可行," + "速率保障满足10Mbps下限,可启用。" + "评估结果:可行。"; + MetadataContent accept = client.generateNegotiationAcceptPromptFromText( + acceptText, ctx, NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); + results.add(NegotiationSampleSupport.summary("feasibility", "accept", accept, logSink)); + + // reject: not feasible + String rejectText = "拒绝:节能目标在现有供电约束下无法实现," + "需调整方案。评估结果:不可行。"; + MetadataContent reject = client.generateNegotiationRejectPromptFromText( + rejectText, ctx, NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); + results.add(NegotiationSampleSupport.summary("feasibility", "reject", reject, logSink)); + + return results; + } + + /** + * Entry point. + * + * @param args first argument is the {@code .env} file path (must contain a real LLM API key) + */ + public static void main(String[] args) { + if (args.length == 0) { + System.err.println("Usage: java FromTextNegotiationSample "); + System.err.println("A real LLM API key is required: fromText runs an LLM extraction step for every case."); + System.exit(1); + } + Path envPath = Path.of(args[0]); + List> results = runAll(envPath, System.out::println); + System.out.println("\n=== Summary (" + results.size() + " cases) ==="); + for (Map r : results) { + System.out.println(r); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/package-info.java new file mode 100644 index 00000000..68dad919 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/package-info.java @@ -0,0 +1,26 @@ +/** + * Negotiation end-to-end demo: SPN private-line-complaint diagnosis over real a2a-java HTTP A2A. + * + *

      Implements the 4-message flow from the A2A-T protocol spec (§7.3/§7.4): + * + *

        + *
      1. client -> Task-T (params missing); + *
      2. server -> Negotiation-T information request (missing params) -> INPUT_REQUIRED; + *
      3. client -> Task-T (params filled) + Negotiation-T accept; + *
      4. server -> diagnosis result artifact -> COMPLETED. + *
      + * + * Layout: + * + *
        + *
      • {@code client/} - 4-message client orchestration over a2a-java RestTransport; + *
      • {@code server/} - AgentExecutor (Task-T validation -> negotiation request -> diagnosis) + runtime; + *
      • {@code shared/} - A2A metadata bridge, scenario data, extension/template URI constants. + *
      + * + * The core path uses deterministic {@code fromData} generation + runtime state machine (no LLM key needed); optional + * {@code validateAndFilling} runs when a real LLM key is configured. + * + * @since 2026-08 + */ +package net.openan.a2at.sample.negotiation; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java new file mode 100644 index 00000000..e84bbe85 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java @@ -0,0 +1,43 @@ +package net.openan.a2at.sample.negotiation.server; + +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; + +/** + * Produces a diagnosis result from the validated Task-T parameters (not hardcoded). + * + *

      In a real deployment this would call the EMS/NMS north-bound API; the demo builds the diagnosis text from the + * parameters extracted by {@code validateAndFillingTaskData}, so the result adapts to any Task-T input. + * + * @since 2026-08 + */ +public final class DiagnosisService { + + private DiagnosisService() {} + + /** + * Builds a diagnosis result from the extracted Task-T parameters. + * + * @param params validated parameters from {@code validateAndFillingTaskData} + * @return diagnosis result text + */ + public static String diagnose(FilledParamData params) { + StringBuilder sb = new StringBuilder(); + sb.append("1. 诊断结果:成功\n"); + sb.append("2. 诊断结果详情:"); + if (params != null && params.data() != null) { + for (Map.Entry entry : params.data().entrySet()) { + String key = entry.getKey(); + if ("id".equals(key) || "round".equals(key) || "maxRounds".equals(key)) { + continue; + } + Object value = entry.getValue(); + if (value != null && (!(value instanceof String s) || !s.isBlank())) { + sb.append(key).append("=").append(value).append(";"); + } + } + } + sb.append("\n3. 修复建议:检查接入端口物理连接和光模块状态,必要时恢复供电或重新开启端口"); + return sb.toString(); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java new file mode 100644 index 00000000..01d8b961 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java @@ -0,0 +1,255 @@ +package net.openan.a2at.sample.negotiation.server; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import net.openan.a2at.sample.negotiation.shared.DemoConstants; +import net.openan.a2at.sample.negotiation.shared.NegotiationMessage; +import net.openan.a2at.sample.negotiation.shared.NegotiationStrategy; +import net.openan.a2at.sample.negotiation.shared.ScenarioData; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.server.A2ATServer; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.Message; + +/** + * Server-side agent executor that drives the 4-message negotiation flow for the SPN private-line-complaint diagnosis. + * + *

      Receives one A2A request, extracts the Task-T prompt from {@code Message.metadata}, validates it through the SDK + * ({@link A2ATServer#validateAndFillingTaskData}) to discover which parameters are missing, and reacts: + * + *

        + *
      • message 1 (params missing): dynamically builds the missing-items list from the validation result and generates + * a Negotiation-T information-propose prompt via the {@link NegotiationStrategy} (fromData or fromText), emits it + * as a reply Message, and transitions to {@code INPUT_REQUIRED}; + *
      • message 3 (params filled): accepts the negotiation and emits a dynamically generated diagnosis result as a + * Task-T artifact, then transitions to {@code COMPLETED}. + *
      + * + * Nothing is hardcoded — the missing items, the accept content and the diagnosis are all derived from the Task-T prompt + * via SDK APIs, so the demo supports arbitrary Task-T inputs. + * + * @since 2026-08 + */ +public final class NegotiationAgentExecutor implements AgentExecutor { + + /** Parameter keys the SDK injects as negotiation-context (not business slots). */ + private static final List CONTEXT_PARAM_KEYS = List.of("id", "round", "maxRounds"); + + private final A2ATServer server; + private final NegotiationStrategy strategy; + private final Consumer logSink; + + /** + * Creates the executor. + * + * @param server server facade for validation and negotiation generation + * @param strategy negotiation-message generation strategy (fromData or fromText) + * @param logSink log output sink + */ + public NegotiationAgentExecutor(A2ATServer server, NegotiationStrategy strategy, Consumer logSink) { + this.server = server; + this.strategy = strategy; + this.logSink = logSink; + } + + @Override + public void execute(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + try { + Message inbound = requestContext.getMessage(); + Map metadata = + inbound == null || inbound.metadata() == null ? Map.of() : inbound.metadata(); + String taskPrompt = NegotiationMessage.extractPrompt(metadata, DemoConstants.TASK_T_URI); + Map negotiationContext = NegotiationMessage.extractContext(metadata); + emit("[server] received task prompt, hasNegCtx=" + !negotiationContext.isEmpty()); + + // validate the Task-T prompt through the SDK to extract parameters; + // if semantic validation rejects (params missing), the exception carries the slot errors + FilledParamData params; + List missingItems; + try { + params = server.validateAndFillingTaskData( + taskPrompt, ScenarioData.TASK_SCHEMA, DemoConstants.TASK_TEMPLATE); + emit("[server] extracted params: " + params.data()); + missingItems = findMissingItems(params); + } catch (ContentValidationException e) { + emit("[server] validation rejected, extracting missing items from errors"); + params = null; + missingItems = missingItemsFromErrors(e.errors()); + } + + if (missingItems.isEmpty()) { + handleFilledParams(requestContext, agentEmitter, negotiationContext, params); + } else { + handleMissingParams(requestContext, agentEmitter, negotiationContext, missingItems); + } + } catch (RuntimeException exception) { + emit("[server] executor error: " + exception); + throw exception; + } + } + + @Override + public void cancel(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + agentEmitter.cancel(); + } + + // -- message 2: params missing -> Negotiation-T information propose -> INPUT_REQUIRED -- + private void handleMissingParams( + RequestContext ctx, + AgentEmitter emitter, + Map negotiationContext, + List missingItems) + throws A2AError { + emit("[server] === message 2: params missing -> negotiation request ==="); + emit("[server] missing items: " + missingItems); + + // the negotiation context flows back to the client via the reply metadata + Map replyContext = negotiationContext; + + // dynamically generate the Negotiation-T information-propose prompt via the strategy + MetadataContent negotiationPrompt = strategy.generatePropose( + server, + new NegotiationContext(UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS), + missingItems, + "以上参数均为必选,缺少无法启动诊断", + DemoConstants.NEGOTIATION_PROPOSE); + emit("[server] negotiation request rendered"); + + Message reply = buildReplyMessage(ctx, negotiationPrompt, replyContext, DemoConstants.NEGOTIATION_T_URI); + emitter.submit(reply); + emitter.requiresInput(); + emit("[server] -> INPUT_REQUIRED"); + } + + // -- message 4: params filled -> accept negotiation -> diagnosis result -> COMPLETED -- + private void handleFilledParams( + RequestContext ctx, AgentEmitter emitter, Map negotiationContext, FilledParamData params) + throws A2AError { + emit("[server] === message 4: params filled -> diagnosis result ==="); + + // build the filled-items list from the validated params (not hardcoded) + List filledItems = extractFilledItems(params); + + // dynamically generate the Negotiation-T accept via the strategy + MetadataContent acceptPrompt = strategy.generateAcceptServer( + server, + new NegotiationContext(UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS), + filledItems, + DemoConstants.NEGOTIATION_ACCEPT); + emit("[server] negotiation accept rendered"); + + // dynamically generate the diagnosis from the extracted params (not hardcoded) + String diagnosis = DiagnosisService.diagnose(params); + emitter.addArtifact( + List.>of(new DataPart(Map.of(DemoConstants.TASK_T_URI, diagnosis))), + "faultManagement.Diagnosis", + "SPN private-line diagnosis result", + Map.of(DemoConstants.TEMPLATE_URI_KEY, DemoConstants.TASK_TEMPLATE.uri()), + false, + true); + emit("[server] diagnosis artifact emitted"); + emitter.complete(); + emit("[server] -> COMPLETED"); + } + + /** + * Finds parameters that are missing (null or blank) from the validated Task-T params. Skips SDK context parameters + * (id, round, maxRounds). + */ + private static List findMissingItems(FilledParamData params) { + List missing = new ArrayList<>(); + if (params == null || params.data() == null) { + return missing; + } + for (Map.Entry entry : params.data().entrySet()) { + String key = entry.getKey(); + if (CONTEXT_PARAM_KEYS.contains(key)) { + continue; + } + Object value = entry.getValue(); + if (isMissing(value)) { + missing.add(new NegotiationItem(key, "请提供" + key)); + } + } + return missing; + } + + /** + * Builds the missing-items list from the semantic validation errors. Each slot error becomes a NegotiationItem + * asking the counterpart to provide that slot. + */ + private static List missingItemsFromErrors(List errors) { + List missing = new ArrayList<>(); + if (errors == null) { + return missing; + } + for (SlotValidationError error : errors) { + String slotName = error.slotName(); + if (slotName != null && !slotName.isBlank()) { + missing.add(new NegotiationItem(slotName, "请提供" + slotName)); + } + } + return missing; + } + + /** Extracts the filled (non-null, non-blank) parameters as NegotiationItems. */ + private static List extractFilledItems(FilledParamData params) { + List items = new ArrayList<>(); + if (params == null || params.data() == null) { + return items; + } + for (Map.Entry entry : params.data().entrySet()) { + String key = entry.getKey(); + if (CONTEXT_PARAM_KEYS.contains(key)) { + continue; + } + Object value = entry.getValue(); + if (!isMissing(value)) { + items.add(new NegotiationItem(key, String.valueOf(value))); + } + } + return items; + } + + private static boolean isMissing(Object value) { + return value == null || (value instanceof String s && s.isBlank()); + } + + /** Parses a rendered template URI string back into a {@code TemplateUri} for metadata building. */ + private static net.openan.a2at.sdk.core.model.TemplateUri parseTemplateUri(String templateUri) { + return net.openan.a2at.sdk.core.model.TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); + } + + private static Message buildReplyMessage( + RequestContext ctx, MetadataContent content, Map contextMap, String extensionUri) { + Map replyMetadata = NegotiationMessage.buildMetadata( + extensionUri, content.promptText(), parseTemplateUri(content.templateUri()), contextMap); + return Message.builder() + .messageId(UUID.randomUUID().toString()) + .contextId(ctx.getContextId()) + .taskId(ctx.getTaskId()) + .role(Message.Role.ROLE_AGENT) + .parts(new org.a2aproject.sdk.spec.TextPart(content.promptText())) + .metadata(replyMetadata) + .build(); + } + + private void emit(String message) { + if (logSink != null) { + logSink.accept(message); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationServerRuntime.java new file mode 100644 index 00000000..bff9e030 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationServerRuntime.java @@ -0,0 +1,174 @@ +package net.openan.a2at.sample.negotiation.server; + +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import net.openan.a2at.sample.negotiation.shared.NegotiationStrategy; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import net.openan.a2at.sdk.server.A2ATServer; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.events.InMemoryQueueManager; +import org.a2aproject.sdk.server.events.MainEventBus; +import org.a2aproject.sdk.server.events.MainEventBusProcessor; +import org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.server.tasks.BasePushNotificationSender; +import org.a2aproject.sdk.server.tasks.InMemoryPushNotificationConfigStore; +import org.a2aproject.sdk.server.tasks.InMemoryTaskStore; +import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore; +import org.a2aproject.sdk.server.tasks.PushNotificationSender; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentProvider; + +/** + * Runtime assembly for the negotiation sample server, wiring the real a2a-java REST transport to the negotiation + * facade. + * + *

      Builds a {@link DefaultRequestHandler} backed by a {@link NegotiationAgentExecutor} that runs every inbound A2A + * request through the {@link A2ATServer} negotiation API. The handler is started in an embedded HTTP server so the + * client can reach it over real HTTP+JSON. The AgentCard declares both Task-T and Negotiation-T extensions and + * streaming=true. + * + * @since 2026-08 + */ +public final class NegotiationServerRuntime implements AutoCloseable { + + private final Path envPath; + private final Consumer logSink; + private final ExecutorService executor; + private final A2ATServer server; + private final NegotiationStrategy strategy; + + public NegotiationServerRuntime(Path envPath, NegotiationStrategy strategy, Consumer logSink) { + this.envPath = envPath; + this.strategy = strategy; + this.logSink = logSink; + this.executor = new ThreadPoolExecutor(4, 4, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); + this.server = new A2ATServer(envPath); + } + + public A2ATServer server() { + return server; + } + + public String resolveHost() { + return readEnv(envPath).getOrDefault("A2AT_SAMPLE_HOST", "127.0.0.1"); + } + + public int resolvePort() { + String portValue = readEnv(envPath).getOrDefault("A2AT_SAMPLE_PORT", "26335"); + try { + return Integer.parseInt(portValue); + } catch (NumberFormatException exception) { + throw new ValueErrorException("Invalid A2AT_SAMPLE_PORT: " + portValue); + } + } + + public AgentCard buildAgentCard(String host, int port) { + AgentInterface agentInterface = new AgentInterface("HTTP+JSON", "http://" + host + ":" + port + "/", "", "1.0"); + // URL must end with / so RestTransport appends the path segment correctly + AgentCapabilities capabilities = new AgentCapabilities( + true, + false, + false, + List.of( + new org.a2aproject.sdk.spec.AgentExtension( + net.openan.a2at.sample.negotiation.shared.DemoConstants.TASK_T_URI, + Map.of(), + false, + "Task-T extension"), + new org.a2aproject.sdk.spec.AgentExtension( + net.openan.a2at.sample.negotiation.shared.DemoConstants.NEGOTIATION_T_URI, + Map.of(), + false, + "Negotiation-T extension"))); + return new AgentCard( + "SPN Negotiation Agent", + "SPN private-line-complaint diagnosis with negotiation", + new AgentProvider("Huawei", "https://www.huawei.com"), + "1.0.0", + null, + capabilities, + List.of("application/json", "text/plain"), + List.of("application/json", "text/plain"), + List.of(new org.a2aproject.sdk.spec.AgentSkill( + "negotiation-diagnosis", + "Negotiation diagnosis", + "Task-T with Negotiation-T parameter completion", + List.of("negotiation", "diagnosis"), + null, + null, + null, + null)), + Map.of(), + List.of(), + null, + List.of(agentInterface), + List.of()); + } + + public RequestHandler buildRequestHandler() { + InMemoryTaskStore taskStore = new InMemoryTaskStore(); + MainEventBus mainEventBus = new MainEventBus(); + InMemoryQueueManager queueManager = new InMemoryQueueManager(taskStore, mainEventBus); + PushNotificationConfigStore pushNotificationConfigStore = new InMemoryPushNotificationConfigStore(); + PushNotificationSender pushNotificationSender = new BasePushNotificationSender(pushNotificationConfigStore); + MainEventBusProcessor mainEventBusProcessor = + new MainEventBusProcessor(mainEventBus, taskStore, pushNotificationSender, queueManager); + startMainEventBusProcessor(mainEventBusProcessor); + AgentExecutor agentExecutor = new NegotiationAgentExecutor(server, strategy, logSink); + return DefaultRequestHandler.create( + agentExecutor, + taskStore, + queueManager, + pushNotificationConfigStore, + mainEventBusProcessor, + executor, + executor); + } + + @Override + public void close() { + executor.shutdownNow(); + } + + private void startMainEventBusProcessor(MainEventBusProcessor mainEventBusProcessor) { + try { + java.lang.reflect.Method startMethod = MainEventBusProcessor.class.getDeclaredMethod("start"); + startMethod.setAccessible(true); + startMethod.invoke(mainEventBusProcessor); + if (logSink != null) { + logSink.accept("[server] event-bus-processor: started"); + } + } catch (ReflectiveOperationException exception) { + throw new ValueErrorException("Failed to start MainEventBusProcessor", exception); + } + } + + private static Map readEnv(Path envPath) { + Map values = new LinkedHashMap<>(); + try { + for (String rawLine : java.nio.file.Files.readAllLines(envPath)) { + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { + continue; + } + int separator = line.indexOf('='); + values.put( + line.substring(0, separator).trim(), + line.substring(separator + 1).trim()); + } + } catch (java.io.IOException exception) { + throw new ValueErrorException("Failed to read env file: " + envPath); + } + return values; + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/DemoConstants.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/DemoConstants.java new file mode 100644 index 00000000..2259e038 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/DemoConstants.java @@ -0,0 +1,40 @@ +package net.openan.a2at.sample.negotiation.shared; + +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; + +/** + * Extension URIs and metadata keys used by the negotiation end-to-end demo. + * + *

      Centralises every A2A-T identifier so the client and server flows reference them from one place, making the demo + * easier to read and adapt. Template URIs come from {@link StandardTemplates}. + * + * @since 2026-08 + */ +public final class DemoConstants { + + private DemoConstants() {} + + /** Task-T extension URI (NL variant, used for sending per SDK convention). */ + public static final String TASK_T_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/NL/v1"; + + /** Negotiation-T extension URI (NL variant, used for sending per SDK convention). */ + public static final String NEGOTIATION_T_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/NL/v1"; + + /** Task-T template URI for the SPN private-line-complaint diagnosis scenario. */ + public static final TemplateUri TASK_TEMPLATE = StandardTemplates.PRIVATE_LINE_COMPLAINT; + + /** Negotiation-T information propose template (request missing information). */ + public static final TemplateUri NEGOTIATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + /** Negotiation-T information accept-reject template (accept after params filled). */ + public static final TemplateUri NEGOTIATION_ACCEPT = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + + /** Metadata key carrying the JSON-serialised negotiation context map. */ + public static final String NEGOTIATION_CONTEXT_KEY = "negotiation_context"; + + /** Metadata key carrying the template URI. */ + public static final String TEMPLATE_URI_KEY = "template_uri"; +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java new file mode 100644 index 00000000..fa421d81 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java @@ -0,0 +1,53 @@ +package net.openan.a2at.sample.negotiation.shared; + +import java.util.List; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.server.A2ATServer; + +/** + * Rule-based negotiation strategy: builds typed records and calls the deterministic fromData API. + * + *

      No LLM call is made for negotiation message generation — the SDK renders the typed content directly from the + * template. This is the "structured data" path. + * + * @since 2026-08 + */ +public final class FromDataStrategy implements NegotiationStrategy { + + @Override + public MetadataContent generatePropose( + A2ATServer server, + NegotiationContext ctx, + List missingItems, + String relationship, + TemplateUri templateUri) { + return server.generateNegotiationProposePromptFromData( + new NegotiationProposeData(ctx, new InformationProposeContent(missingItems, relationship)), + templateUri); + } + + @Override + public MetadataContent generateAccept( + A2ATClient facade, NegotiationContext ctx, List filledItems, TemplateUri templateUri) { + return facade.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData(ctx, new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems)), + templateUri); + } + + @Override + public MetadataContent generateAcceptServer( + A2ATServer server, NegotiationContext ctx, List filledItems, TemplateUri templateUri) { + return server.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData(ctx, new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems)), + templateUri); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java new file mode 100644 index 00000000..6dc30752 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java @@ -0,0 +1,75 @@ +package net.openan.a2at.sample.negotiation.shared; + +import java.util.List; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.server.A2ATServer; + +/** + * LLM-based negotiation strategy: converts the items to natural-language text and calls the fromText API. + * + *

      The SDK runs one LLM content-extraction step to parse the text into typed content, then renders deterministically. + * This is the "natural language" path. + * + * @since 2026-08 + */ +public final class FromTextStrategy implements NegotiationStrategy { + + @Override + public MetadataContent generatePropose( + A2ATServer server, + NegotiationContext ctx, + List missingItems, + String relationship, + TemplateUri templateUri) { + String text = itemsToProposeText(missingItems, relationship); + return server.generateNegotiationProposePromptFromText(text, ctx, templateUri); + } + + @Override + public MetadataContent generateAccept( + A2ATClient facade, NegotiationContext ctx, List filledItems, TemplateUri templateUri) { + String text = itemsToAcceptText(filledItems); + return facade.generateNegotiationAcceptPromptFromText(text, ctx, templateUri); + } + + @Override + public MetadataContent generateAcceptServer( + A2ATServer server, NegotiationContext ctx, List filledItems, TemplateUri templateUri) { + String text = itemsToAcceptText(filledItems); + return server.generateNegotiationAcceptPromptFromText(text, ctx, templateUri); + } + + private static String itemsToProposeText(List items, String relationship) { + StringBuilder sb = new StringBuilder("请提供以下缺失信息:"); + for (int i = 0; i < items.size(); i++) { + NegotiationItem item = items.get(i); + sb.append(i + 1).append(". ").append(item.name()); + if (item.value() != null && !item.value().isBlank()) { + sb.append(":").append(item.value()); + } + sb.append(";"); + } + if (relationship != null && !relationship.isBlank()) { + sb.append(relationship); + } + return sb.toString(); + } + + private static String itemsToAcceptText(List items) { + StringBuilder sb = new StringBuilder("同意补充以下信息:"); + for (int i = 0; i < items.size(); i++) { + NegotiationItem item = items.get(i); + sb.append(i + 1).append(". ").append(item.name()); + if (item.value() != null && !item.value().isBlank()) { + sb.append(":").append(item.value()); + } + sb.append(";"); + } + sb.append("信息已完整,可以启动诊断。"); + return sb.toString(); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationMessage.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationMessage.java new file mode 100644 index 00000000..5a794dfb --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationMessage.java @@ -0,0 +1,83 @@ +package net.openan.a2at.sample.negotiation.shared; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; + +/** + * Bridge between the A2A-T SDK payloads and the A2A {@code Message.metadata} carried over the real a2a-java HTTP + * transport. + * + *

      An A2A {@code Message.metadata} map carries one entry per active extension: the extension URI maps to the rendered + * prompt text. The negotiation context map (type/id/round/status) is JSON-serialised into a dedicated + * {@link DemoConstants#NEGOTIATION_CONTEXT_KEY} entry so the state machine can advance on the receiving side. + * + * @since 2026-08 + */ +public final class NegotiationMessage { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private NegotiationMessage() {} + + /** + * Builds the A2A metadata map for one outbound negotiation message. + * + * @param extensionUri Negotiation-T or Task-T extension URI (the metadata key) + * @param promptText rendered prompt text + * @param templateUri template URI of the generated prompt + * @param contextMap negotiation context map (may be empty for Task-T-only messages) + * @return A2A metadata map + */ + public static Map buildMetadata( + String extensionUri, + String promptText, + net.openan.a2at.sdk.core.model.TemplateUri templateUri, + Map contextMap) { + Map metadata = new LinkedHashMap<>(); + metadata.put(extensionUri, promptText); + metadata.put(DemoConstants.TEMPLATE_URI_KEY, templateUri.uri()); + if (contextMap != null && !contextMap.isEmpty()) { + metadata.put(DemoConstants.NEGOTIATION_CONTEXT_KEY, toJson(contextMap)); + } + return metadata; + } + + /** Extracts the prompt text for one extension from an inbound A2A metadata map. */ + public static String extractPrompt(Map metadata, String extensionUri) { + if (metadata == null) { + return ""; + } + Object value = metadata.get(extensionUri); + return value == null ? "" : String.valueOf(value); + } + + /** Extracts and deserialises the negotiation context map, or returns an empty map when absent. */ + public static Map extractContext(Map metadata) { + if (metadata == null) { + return Map.of(); + } + Object value = metadata.get(DemoConstants.NEGOTIATION_CONTEXT_KEY); + if (value == null || (value instanceof String s && s.isBlank())) { + return Map.of(); + } + String json = value instanceof String s ? s : toJson(value); + try { + return MAPPER.readValue(json, new TypeReference<>() {}); + } catch (Exception exception) { + throw new ValueErrorException("Failed to parse negotiation context: " + exception.getMessage(), exception); + } + } + + /** Serialises a context map to JSON (exposed for the client when merging metadata). */ + public static String toJson(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (Exception exception) { + throw new ValueErrorException( + "Failed to serialise negotiation context: " + exception.getMessage(), exception); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationSampleSupport.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationSampleSupport.java new file mode 100644 index 00000000..b07daae8 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationSampleSupport.java @@ -0,0 +1,70 @@ +package net.openan.a2at.sample.negotiation.shared; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; + +/** + * Shared helpers for the fromData and fromText negotiation samples. + * + *

      Both samples produce the same {@link MetadataContent} output structure; they differ only in how they construct the + * input (typed records vs natural-language text). This class centralises the session id, the template-URI constants, + * the logging helper and the summary builder so neither sample duplicates them. + * + * @since 2026-08 + */ +public final class NegotiationSampleSupport { + + /** Fixed negotiation session id shared by every sample case (matches the golden fixtures). */ + public static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + /** Information negotiation template URIs. */ + public static final TemplateUri INFO_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + public static final TemplateUri INFO_ACCEPT_REJECT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + + /** Target negotiation template URIs. */ + public static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; + + public static final TemplateUri TARGET_ACCEPT_REJECT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; + + /** Feasibility negotiation template URIs. */ + public static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; + + public static final TemplateUri FEASIBILITY_ACCEPT_REJECT_URI = + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; + + private NegotiationSampleSupport() {} + + /** + * Builds a one-line summary entry for one sample case. + * + * @param type negotiation type label (information / target / feasibility) + * @param phase phase label (propose / accept / reject) + * @param mc generated metadata content + * @param logSink log output sink + * @return summary map + */ + public static Map summary(String type, String phase, MetadataContent mc, Consumer logSink) { + emit(logSink, " [" + type + "/" + phase + "] templateUri=" + mc.templateUri()); + String preview = mc.promptText().length() > 120 ? mc.promptText().substring(0, 120) + "..." : mc.promptText(); + emit(logSink, " promptText: " + preview); + Map entry = new LinkedHashMap<>(); + entry.put("type", type); + entry.put("phase", phase); + entry.put("templateUri", mc.templateUri()); + entry.put("extensionUri", mc.extensionUri()); + entry.put("promptLength", mc.promptText().length()); + return entry; + } + + /** Emits one log line if the sink is non-null. */ + public static void emit(Consumer logSink, String message) { + if (logSink != null) { + logSink.accept(message); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java new file mode 100644 index 00000000..0941b51d --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java @@ -0,0 +1,64 @@ +package net.openan.a2at.sample.negotiation.shared; + +import java.util.List; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.server.A2ATServer; + +/** + * Strategy abstraction for generating negotiation messages, isolating the fromData (rule-based) vs fromText (LLM) + * difference behind a single interface. + * + *

      Everything else in the demo — Task-T generation, {@code validateAndFillingTaskData} parameter validation, the + * negotiation state machine — is identical regardless of which strategy is active. Only the negotiation prompt + * generation step differs: fromData builds typed records and calls the deterministic API; fromText passes natural + * language and lets the SDK's LLM extraction step parse it. + * + * @since 2026-08 + */ +public interface NegotiationStrategy { + + /** + * Generates a propose-phase negotiation message from the missing information items. + * + * @param server server facade (both facades expose the same fromData/fromText methods) + * @param ctx negotiation session context + * @param missingItems items the counterpart should provide + * @param relationship how the items relate (may be null) + * @param templateUri propose template + * @return generated metadata content + */ + MetadataContent generatePropose( + A2ATServer server, + NegotiationContext ctx, + List missingItems, + String relationship, + TemplateUri templateUri); + + /** + * Generates an accept-phase negotiation message from the filled information items (client facade). + * + * @param facade client or server facade (both expose the same fromData/fromText methods) + * @param ctx negotiation session context + * @param filledItems items delivered with the accept + * @param templateUri accept-reject template + * @return generated metadata content + */ + MetadataContent generateAccept( + A2ATClient facade, NegotiationContext ctx, List filledItems, TemplateUri templateUri); + + /** + * Generates an accept-phase negotiation message from the filled information items (server facade). + * + * @param server server facade + * @param ctx negotiation session context + * @param filledItems items delivered with the accept + * @param templateUri accept-reject template + * @return generated metadata content + */ + MetadataContent generateAcceptServer( + A2ATServer server, NegotiationContext ctx, List filledItems, TemplateUri templateUri); +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java new file mode 100644 index 00000000..73fda8b6 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java @@ -0,0 +1,57 @@ +package net.openan.a2at.sample.negotiation.shared; + +import java.util.List; +import java.util.Map; + +/** + * Fixed scenario data for the SPN private-line-complaint diagnosis: one map with missing required params (triggering + * negotiation) and one with the params filled in (completing the negotiation). + * + *

      The slot keys ({@code 任务对象} / {@code 任务上下文}) match the bundled {@code private-line-complaint} slot schema. An + * empty {@code 任务对象} triggers the server-side information negotiation (message 2); filling it in completes the + * negotiation (message 3-4). + * + * @since 2026-08 + */ +public final class ScenarioData { + + private ScenarioData() {} + + /** Slot schema describing the Task-T parameters (passed to validateAndFillingTaskData). */ + public static final Map TASK_SCHEMA = Map.of( + "type", "object", + "properties", + Map.of( + "任务对象", Map.of("type", "string"), + "任务上下文", Map.of("type", "string")), + "required", List.of("任务上下文")); + + /** + * Scenario data with missing required params: the access port name is empty and the complaint category is blank, + * which triggers the server-side information negotiation. + */ + public static Map missingParams() { + return Map.of( + "任务对象", + "", + "任务上下文", + "2. 问题发生时间:2026-05-11T08:21:46Z\n" + + "3. OSS侧事件流水号:event-id-20260511-09013\n" + + "4. 投诉详情:深圳访问广州响应延迟骤升,交易接口频繁报连接超时"); + } + + /** + * Scenario data with all params filled in: the access port name and complaint category are provided, completing the + * negotiation so the server can run the diagnosis. + */ + public static Map filledParams() { + return Map.of( + "任务对象", + "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "任务上下文", + "1. 投诉分类:专线质差\n" + + "2. 问题发生时间:2026-05-11T08:21:46Z\n" + + "3. OSS侧事件流水号:event-id-20260511-09013\n" + + "4. 投诉详情:深圳访问广州响应延迟骤升,交易接口频繁报连接超时"); + } +} diff --git a/a2a-t-sample/src/main/resources/sample/negotiation/negotiation.env b/a2a-t-sample/src/main/resources/sample/negotiation/negotiation.env new file mode 100644 index 00000000..f2de5cf1 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/negotiation/negotiation.env @@ -0,0 +1,13 @@ + # Negotiation sample environment. + # Copy to negotiation.env and fill A2AT_LLM_API_KEY to run the optional validateAndFilling paths. + # The core 4-message flow (fromData generation + state machine) runs without any LLM key. + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=../../../../../../a2a-t-resources/src/main/resources/prompt_resources + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=deepseek-v4-flash + A2AT_LLM_BASE_URL=https://api.deepseek.com + A2AT_LLM_API_KEY= + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_SAMPLE_HOST=127.0.0.1 + A2AT_SAMPLE_PORT=26335 From bc6470b4798363918b40d359d8c53577718c2e28 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 12:46:06 +0800 Subject: [PATCH 111/295] refactor(sample): load negotiation scenario data from scenario.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the demo's scenario inputs out of Java code into a bundled sample/negotiation/scenario.json resource (same pattern as the subscribe-incident sample's scenario.json): - ScenarioData becomes a loader; the slot schema and the missing/filled parameter maps are read from the JSON file - the hand-copied TASK_SCHEMA drifted from the bundled slot.json (it dropped the slot descriptions, value constraints and examples, which degraded the slot-extraction prompt); the scenario file now carries the full schema including descriptions - the 3x3 API samples read the filled port value from the scenario file instead of inlining it The demo scenario now changes without touching Java: edit the JSON and the whole flow — slot extraction, missing-item detection, accept content, diagnosis text — adapts to the new inputs. Signed-off-by: ZhouJie6 --- a2a-t-sample/README.zh-CN.md | 3 +- .../docs/Negotiation-Sample-Design.md | 2 +- .../negotiation/client/NegotiationClient.java | 4 +- .../fromdata/FromDataNegotiationSample.java | 6 +- .../fromtext/FromTextNegotiationSample.java | 9 ++- .../server/NegotiationAgentExecutor.java | 2 +- .../negotiation/shared/ScenarioData.java | 77 +++++++++++-------- .../sample/negotiation/scenario.json | 27 +++++++ 8 files changed, 88 insertions(+), 42 deletions(-) create mode 100644 a2a-t-sample/src/main/resources/sample/negotiation/scenario.json diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 2164d56b..bd07f876 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -20,6 +20,7 @@ - 服务端环境模板:`sample/subscribe_incident/server/server.env` - 客户端场景输入:`sample/subscribe_incident/client/scenario.json` - 协商样例环境模板:`sample/negotiation/negotiation.env` +- 协商样例场景输入(slot schema + 参数缺失/补齐数据):`sample/negotiation/scenario.json` ## 协商(Negotiation)端到端样例 @@ -45,7 +46,7 @@ | `negotiation/` | 入口 `NegotiationDemoApp`:启动嵌入式 HTTP server + 跑 client,`--fromText` 切换策略 | | `negotiation/client/` | `NegotiationClient`:4 报文编排(Task-T 缺失→收协商→补齐→收诊断) | | `negotiation/server/` | `NegotiationAgentExecutor`(validateAndFillingTaskData→缺失检测→协商请求→诊断)+ `NegotiationServerRuntime`(HTTP server 装配)+ `DiagnosisService`(从 FilledParamData 动态生成诊断) | -| `negotiation/shared/` | 策略层(`NegotiationStrategy` + `FromDataStrategy`/`FromTextStrategy`)、A2A metadata 桥接(`NegotiationMessage`)、扩展/模板 URI 常量(`DemoConstants`)、场景数据(`ScenarioData`)、样例公共辅助(`NegotiationSampleSupport`) | +| `negotiation/shared/` | 策略层(`NegotiationStrategy` + `FromDataStrategy`/`FromTextStrategy`)、A2A metadata 桥接(`NegotiationMessage`)、扩展/模板 URI 常量(`DemoConstants`)、场景数据加载器(`ScenarioData`,数据在 `scenario.json`)、样例公共辅助(`NegotiationSampleSupport`) | | `negotiation/fromdata/`、`negotiation/fromtext/` | 9 用例 API 验证样例(见下文两节) | ### 协商样例启动 diff --git a/a2a-t-sample/docs/Negotiation-Sample-Design.md b/a2a-t-sample/docs/Negotiation-Sample-Design.md index e37693dd..cdb6f73c 100644 --- a/a2a-t-sample/docs/Negotiation-Sample-Design.md +++ b/a2a-t-sample/docs/Negotiation-Sample-Design.md @@ -120,7 +120,7 @@ |---|---| | `negotiation/shared/NegotiationMessage.java` | A2A metadata 桥接(Task-T/Negotiation-T prompt + context 序列化) | | `negotiation/shared/DemoConstants.java` | 扩展 URI、模板 URI 常量 | - | `negotiation/shared/ScenarioData.java` | 参数缺失/补齐场景数据 + slot schema | + | `negotiation/shared/ScenarioData.java` | 场景数据加载器(从 `scenario.json` 读取 slot schema + 参数缺失/补齐数据) | | `negotiation/shared/NegotiationSampleSupport.java` | fromData/fromText 样例公共辅助(SessionId、URI 常量、summary) | ### API 验证样例(3 类型 × 3 阶段 = 9 用例) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java index 26bea41f..27282db6 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java @@ -78,7 +78,7 @@ public Map runFourMessageFlow(NegotiationServerRuntime serverRun // -- message 1: Task-T with missing params -- emit("[client] === message 1: Task-T (params missing) ==="); MetadataContent taskMissing = client.generateTaskPromptFromDataWithSchema( - ScenarioData.missingParams(), ScenarioData.TASK_SCHEMA, DemoConstants.TASK_TEMPLATE); + ScenarioData.missingParams(), ScenarioData.taskSchema(), DemoConstants.TASK_TEMPLATE); emit("[client] Task-T prompt rendered (params missing)"); // start the negotiation state machine with the missing-params prompt @@ -96,7 +96,7 @@ public Map runFourMessageFlow(NegotiationServerRuntime serverRun // -- message 3: Task-T with filled params + Negotiation-T accept -- emit("[client] === message 3: Task-T (params filled) + Negotiation-T accept ==="); MetadataContent taskFilled = client.generateTaskPromptFromDataWithSchema( - ScenarioData.filledParams(), ScenarioData.TASK_SCHEMA, DemoConstants.TASK_TEMPLATE); + ScenarioData.filledParams(), ScenarioData.taskSchema(), DemoConstants.TASK_TEMPLATE); emit("[client] Task-T prompt rendered (params filled)"); MetadataContent acceptPrompt = strategy.generateAccept( diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java index f934dece..c06264cc 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.function.Consumer; import net.openan.a2at.sample.negotiation.shared.NegotiationSampleSupport; +import net.openan.a2at.sample.negotiation.shared.ScenarioData; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; @@ -93,7 +94,10 @@ private static List> informationNegotiation(A2ATClient clien new InformationEndingContent( NegotiationConclusion.ACCEPT, List.of( - new NegotiationItem("接入端口名称", "P781-珠江新城-PTN7900-23-TPA1EG24-17"), + new NegotiationItem( + "接入端口名称", + String.valueOf(ScenarioData.filledParams() + .get("任务对象"))), new NegotiationItem("投诉分类", "专线质差")))), NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); results.add(NegotiationSampleSupport.summary("information", "accept", accept, logSink)); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java index 52113d65..78feee82 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.function.Consumer; import net.openan.a2at.sample.negotiation.shared.NegotiationSampleSupport; +import net.openan.a2at.sample.negotiation.shared.ScenarioData; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -71,8 +72,12 @@ private static List> informationNegotiation(A2ATClient clien results.add(NegotiationSampleSupport.summary("information", "propose", propose, logSink)); // accept: deliver the requested information - String acceptText = - "同意补充以下信息:" + "1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17;" + "2. 投诉分类:专线质差。" + "信息已完整,可以启动诊断。"; + String acceptText = "同意补充以下信息:" + + "1. 接入端口名称:" + + ScenarioData.filledParams().get("任务对象") + + ";" + + "2. 投诉分类:专线质差。" + + "信息已完整,可以启动诊断。"; MetadataContent accept = client.generateNegotiationAcceptPromptFromText( acceptText, ctx, NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); results.add(NegotiationSampleSupport.summary("information", "accept", accept, logSink)); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java index 01d8b961..bdb61d59 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java @@ -80,7 +80,7 @@ public void execute(RequestContext requestContext, AgentEmitter agentEmitter) th List missingItems; try { params = server.validateAndFillingTaskData( - taskPrompt, ScenarioData.TASK_SCHEMA, DemoConstants.TASK_TEMPLATE); + taskPrompt, ScenarioData.taskSchema(), DemoConstants.TASK_TEMPLATE); emit("[server] extracted params: " + params.data()); missingItems = findMissingItems(params); } catch (ContentValidationException e) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java index 73fda8b6..1dac5600 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java @@ -1,57 +1,66 @@ package net.openan.a2at.sample.negotiation.shared; -import java.util.List; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; import java.util.Map; /** - * Fixed scenario data for the SPN private-line-complaint diagnosis: one map with missing required params (triggering - * negotiation) and one with the params filled in (completing the negotiation). + * Scenario data for the negotiation demo, loaded from the bundled {@code sample/negotiation/scenario.json}. * - *

      The slot keys ({@code 任务对象} / {@code 任务上下文}) match the bundled {@code private-line-complaint} slot schema. An - * empty {@code 任务对象} triggers the server-side information negotiation (message 2); filling it in completes the - * negotiation (message 3-4). + *

      The scenario file carries the Task-T slot schema and the two parameter maps driving the 4-message flow: one with a + * missing required param (triggering the server-side information negotiation) and one with the params filled in + * (completing the negotiation). Keeping the data in a resource file — the same pattern as the subscribe-incident + * sample's {@code scenario.json} — means the demo scenario changes without touching Java code: edit the JSON (or point + * the loader at another file with the same shape) and the whole flow, from slot extraction to the diagnosis text, + * adapts to the new inputs. + * + *

      The slot schema mirrors {@code slots/Task-T/network-layer/private-line-complaint/.../slot.json}; when the bundled + * schema changes, copy the updated schema into this scenario file. * * @since 2026-08 */ public final class ScenarioData { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String SCENARIO_RESOURCE = "sample/negotiation/scenario.json"; + private ScenarioData() {} /** Slot schema describing the Task-T parameters (passed to validateAndFillingTaskData). */ - public static final Map TASK_SCHEMA = Map.of( - "type", "object", - "properties", - Map.of( - "任务对象", Map.of("type", "string"), - "任务上下文", Map.of("type", "string")), - "required", List.of("任务上下文")); + @SuppressWarnings("unchecked") + public static Map taskSchema() { + return (Map) scenarioMap().get("task_schema"); + } /** - * Scenario data with missing required params: the access port name is empty and the complaint category is blank, - * which triggers the server-side information negotiation. + * Scenario data with a missing required param: the {@code 任务对象} slot is empty, which triggers the server-side + * information negotiation. */ + @SuppressWarnings("unchecked") public static Map missingParams() { - return Map.of( - "任务对象", - "", - "任务上下文", - "2. 问题发生时间:2026-05-11T08:21:46Z\n" - + "3. OSS侧事件流水号:event-id-20260511-09013\n" - + "4. 投诉详情:深圳访问广州响应延迟骤升,交易接口频繁报连接超时"); + return (Map) scenarioMap().get("missing_params"); } - /** - * Scenario data with all params filled in: the access port name and complaint category are provided, completing the - * negotiation so the server can run the diagnosis. - */ + /** Scenario data with all params filled in, completing the negotiation so the server can run the diagnosis. */ + @SuppressWarnings("unchecked") public static Map filledParams() { - return Map.of( - "任务对象", - "P781-珠江新城-PTN7900-23-TPA1EG24-17", - "任务上下文", - "1. 投诉分类:专线质差\n" - + "2. 问题发生时间:2026-05-11T08:21:46Z\n" - + "3. OSS侧事件流水号:event-id-20260511-09013\n" - + "4. 投诉详情:深圳访问广州响应延迟骤升,交易接口频繁报连接超时"); + return (Map) scenarioMap().get("filled_params"); + } + + @SuppressWarnings("unchecked") + private static Map scenarioMap() { + try (InputStream stream = ScenarioData.class.getClassLoader().getResourceAsStream(SCENARIO_RESOURCE)) { + if (stream == null) { + throw new IllegalStateException("Scenario resource not found: " + SCENARIO_RESOURCE); + } + Map scenario = MAPPER.readValue(stream, new TypeReference>() {}); + return scenario == null ? Map.of() : scenario; + } catch (IOException exception) { + throw new UncheckedIOException("Failed to read scenario resource: " + SCENARIO_RESOURCE, exception); + } } } diff --git a/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json b/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json new file mode 100644 index 00000000..0a6eb82d --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json @@ -0,0 +1,27 @@ +{ + "scenario": "private-line complaint diagnosis with negotiation", + "task_schema": { + "type": "object", + "properties": { + "任务对象": { + "type": "string", + "description": "专线名称,接入端口名称,接入端口资源ID参数,3个参数下发其中一个用于标识专线业务对象", + "examples": ["接入端口资源ID:f47ac10b-58cc-4372-a567-0e02b2c3d479"] + }, + "任务上下文": { + "type": "string", + "description": "提供专线业务的故障现象描述和诊断任务的上下文信息", + "examples": ["故障现象:专线中断。故障流水号:fault-id-1-017-20230511-09013"] + } + }, + "required": ["任务上下文"] + }, + "missing_params": { + "任务对象": "", + "任务上下文": "2. 问题发生时间:2026-05-11T08:21:46Z\n3. OSS侧事件流水号:event-id-20260511-09013\n4. 投诉详情:深圳访问广州响应延迟骤升,交易接口频繁报连接超时" + }, + "filled_params": { + "任务对象": "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "任务上下文": "1. 投诉分类:专线质差\n2. 问题发生时间:2026-05-11T08:21:46Z\n3. OSS侧事件流水号:event-id-20260511-09013\n4. 投诉详情:深圳访问广州响应延迟骤升,交易接口频繁报连接超时" + } +} From 2ff20dff719955165ee1f41b7a5102e29f4e4caf Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 12:59:02 +0800 Subject: [PATCH 112/295] refactor(sample): keep only generic rules in negotiation code, move all scenario values to scenario.json Enforce a strict separation: the Java code contains only generic assembly rules (numbered-list formatting, slot iteration, metadata merging, {slot}/{description}/{params}/{filled:} placeholder substitution); every scenario-specific value lives in scenario.json: - NegotiationAgentExecutor: the missing-item hint and propose relationship come from negotiation_phrasing templates; the hint falls back to the slot description from the task schema - DiagnosisService: result/detail/advice lines come from the diagnosis templates with a {params} placeholder - FromTextStrategy: propose/accept sentence framing comes from phrasing templates; only the generic numbered-list rule stays in code - FromDataNegotiationSample / FromTextNegotiationSample: all 3x3 case content (items, intents, summaries, natural-language texts) is read from negotiation_cases in scenario.json; the code maps JSON entries onto the typed records No Chinese string literal remains in the negotiation Java sources; the scenario is now entirely a data file. Signed-off-by: ZhouJie6 --- .../fromdata/FromDataNegotiationSample.java | 100 +++++++------- .../fromtext/FromTextNegotiationSample.java | 51 ++++---- .../negotiation/server/DiagnosisService.java | 40 ++++-- .../server/NegotiationAgentExecutor.java | 31 ++++- .../negotiation/shared/FromTextStrategy.java | 29 +++-- .../negotiation/shared/ScenarioData.java | 102 +++++++++++++-- .../sample/negotiation/scenario.json | 123 +++++++++++++++++- 7 files changed, 360 insertions(+), 116 deletions(-) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java index c06264cc..5b613357 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java @@ -17,7 +17,6 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; @@ -75,41 +74,37 @@ private static List> informationNegotiation(A2ATClient clien new NegotiationContext(NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS); List> results = new ArrayList<>(); + Map section = ScenarioData.caseSection("information"); + // propose: request missing information items - MetadataContent propose = client.generateNegotiationProposePromptFromData( + Map propose = ScenarioData.map(section, "propose"); + MetadataContent proposeResult = client.generateNegotiationProposePromptFromData( new NegotiationProposeData( ctx, new InformationProposeContent( - List.of( - new NegotiationItem("接入端口名称", "请提供业务接入端口名称"), - new NegotiationItem("投诉分类", "专线中断或专线质差")), - "两个参数均为必选,缺少无法启动诊断")), + ScenarioData.items(propose, "items"), ScenarioData.text(propose, "relationship"))), NegotiationSampleSupport.INFO_PROPOSE_URI); - results.add(NegotiationSampleSupport.summary("information", "propose", propose, logSink)); + results.add(NegotiationSampleSupport.summary("information", "propose", proposeResult, logSink)); // accept: deliver the missing information - MetadataContent accept = client.generateNegotiationAcceptPromptFromData( + Map accept = ScenarioData.map(section, "accept"); + MetadataContent acceptResult = client.generateNegotiationAcceptPromptFromData( new NegotiationEndingData( ctx, new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of( - new NegotiationItem( - "接入端口名称", - String.valueOf(ScenarioData.filledParams() - .get("任务对象"))), - new NegotiationItem("投诉分类", "专线质差")))), + NegotiationConclusion.ACCEPT, ScenarioData.items(accept, "items"))), NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("information", "accept", accept, logSink)); + results.add(NegotiationSampleSupport.summary("information", "accept", acceptResult, logSink)); // reject: cannot provide the information - MetadataContent reject = client.generateNegotiationRejectPromptFromData( + Map reject = ScenarioData.map(section, "reject"); + MetadataContent rejectResult = client.generateNegotiationRejectPromptFromData( new NegotiationEndingData( ctx, new InformationEndingContent( - NegotiationConclusion.REJECT, List.of(new NegotiationItem("接入端口名称", "站点清单不可用,无法提供")))), + NegotiationConclusion.REJECT, ScenarioData.items(reject, "items"))), NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("information", "reject", reject, logSink)); + results.add(NegotiationSampleSupport.summary("information", "reject", rejectResult, logSink)); return results; } @@ -124,35 +119,40 @@ private static List> targetNegotiation(A2ATClient client, Co new NegotiationContext(NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS); List> results = new ArrayList<>(); + Map section = ScenarioData.caseSection("target"); + // propose: state the target intent and request clarification - MetadataContent propose = client.generateNegotiationProposePromptFromData( + Map propose = ScenarioData.map(section, "propose"); + MetadataContent proposeResult = client.generateNegotiationProposePromptFromData( new NegotiationProposeData( ctx, new TargetProposeContent( - "对无线节点节能优化任务的意图理解待澄清", - List.of(new NegotiationItem("任务意图", "08:00-18:00对目标站点启用无线节点节能优化")), - null, - List.of(new NegotiationItem("节能区域", "松山湖还是其他站点?")))), + ScenarioData.text(propose, "description"), + ScenarioData.items(propose, "intent_understanding"), + ScenarioData.items(propose, "alignment_and_clarification"), + ScenarioData.items(propose, "request_for_clarification"))), NegotiationSampleSupport.TARGET_PROPOSE_URI); - results.add(NegotiationSampleSupport.summary("target", "propose", propose, logSink)); + results.add(NegotiationSampleSupport.summary("target", "propose", proposeResult, logSink)); // accept: confirm the negotiated target intent - MetadataContent accept = client.generateNegotiationAcceptPromptFromData( + Map accept = ScenarioData.map(section, "accept"); + MetadataContent acceptResult = client.generateNegotiationAcceptPromptFromData( new NegotiationEndingData( ctx, new TargetEndingContent( - NegotiationConclusion.ACCEPT, - "最终确认意图:08:00-18:00对松山湖站点启用无线节点节能优化,速率保障不低于10Mbps", - null)), + NegotiationConclusion.ACCEPT, ScenarioData.text(accept, "confirmed_intent"), null)), NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("target", "accept", accept, logSink)); + results.add(NegotiationSampleSupport.summary("target", "accept", acceptResult, logSink)); // reject: cannot agree on the target - MetadataContent reject = client.generateNegotiationRejectPromptFromData( + Map reject = ScenarioData.map(section, "reject"); + MetadataContent rejectResult = client.generateNegotiationRejectPromptFromData( new NegotiationEndingData( - ctx, new TargetEndingContent(NegotiationConclusion.REJECT, null, "节能区域信息因站点清单不可用而无法完整澄清")), + ctx, + new TargetEndingContent( + NegotiationConclusion.REJECT, null, ScenarioData.text(reject, "rejection_reason"))), NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("target", "reject", reject, logSink)); + results.add(NegotiationSampleSupport.summary("target", "reject", rejectResult, logSink)); return results; } @@ -167,34 +167,40 @@ private static List> feasibilityNegotiation(A2ATClient clien new NegotiationContext(NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS); List> results = new ArrayList<>(); + Map section = ScenarioData.caseSection("feasibility"); + // propose: request a feasibility evaluation - MetadataContent propose = client.generateNegotiationProposePromptFromData( + Map propose = ScenarioData.map(section, "propose"); + MetadataContent proposeResult = client.generateNegotiationProposePromptFromData( new NegotiationProposeData( ctx, new FeasibilityProposeContent( - "请评估无线节点节能优化在高峰时段是否可行", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of( - new NegotiationItem("评估对象", "松山湖站点08:00-18:00节能窗口"), - new NegotiationItem("评估约束", "速率保障不低于10Mbps")), - null)), + ScenarioData.text(propose, "description"), + NegotiationAction.valueOf(ScenarioData.text(propose, "action")), + ScenarioData.items(propose, "contents_to_evaluate"), + ScenarioData.items(propose, "infeasibility_details_and_proposal"))), NegotiationSampleSupport.FEASIBILITY_PROPOSE_URI); - results.add(NegotiationSampleSupport.summary("feasibility", "propose", propose, logSink)); + results.add(NegotiationSampleSupport.summary("feasibility", "propose", proposeResult, logSink)); // accept: feasibility confirmed - MetadataContent accept = client.generateNegotiationAcceptPromptFromData( + Map accept = ScenarioData.map(section, "accept"); + MetadataContent acceptResult = client.generateNegotiationAcceptPromptFromData( new NegotiationEndingData( ctx, - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "高峰时段节能优化可行,速率保障满足10Mbps下限,可启用")), + new FeasibilityEndingContent( + NegotiationConclusion.ACCEPT, ScenarioData.text(accept, "summary"))), NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("feasibility", "accept", accept, logSink)); + results.add(NegotiationSampleSupport.summary("feasibility", "accept", acceptResult, logSink)); // reject: not feasible - MetadataContent reject = client.generateNegotiationRejectPromptFromData( + Map reject = ScenarioData.map(section, "reject"); + MetadataContent rejectResult = client.generateNegotiationRejectPromptFromData( new NegotiationEndingData( - ctx, new FeasibilityEndingContent(NegotiationConclusion.REJECT, "节能目标在现有供电约束下无法实现,需调整方案")), + ctx, + new FeasibilityEndingContent( + NegotiationConclusion.REJECT, ScenarioData.text(reject, "summary"))), NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("feasibility", "reject", reject, logSink)); + results.add(NegotiationSampleSupport.summary("feasibility", "reject", rejectResult, logSink)); return results; } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java index 78feee82..7f9fd7a6 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java @@ -64,28 +64,22 @@ private static List> informationNegotiation(A2ATClient clien new NegotiationContext(NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS); List> results = new ArrayList<>(); + Map cases = + ScenarioData.fromTextCases().get("information") instanceof Map map ? asStringMap(map) : Map.of(); + // propose: ask for missing information items - String proposeText = - "请提供以下缺失信息:" + "1. 接入端口名称:请提供业务接入端口名称;" + "2. 投诉分类:专线中断或专线质差。" + "两个参数均为必选,缺少无法启动诊断。" + "两者共同定位专线故障原因。"; MetadataContent propose = client.generateNegotiationProposePromptFromText( - proposeText, ctx, NegotiationSampleSupport.INFO_PROPOSE_URI); + text(cases, "propose"), ctx, NegotiationSampleSupport.INFO_PROPOSE_URI); results.add(NegotiationSampleSupport.summary("information", "propose", propose, logSink)); // accept: deliver the requested information - String acceptText = "同意补充以下信息:" - + "1. 接入端口名称:" - + ScenarioData.filledParams().get("任务对象") - + ";" - + "2. 投诉分类:专线质差。" - + "信息已完整,可以启动诊断。"; MetadataContent accept = client.generateNegotiationAcceptPromptFromText( - acceptText, ctx, NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); + text(cases, "accept"), ctx, NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); results.add(NegotiationSampleSupport.summary("information", "accept", accept, logSink)); // reject: cannot provide the information - String rejectText = "拒绝补充信息:" + "接入端口名称因站点清单不可用而无法提供," + "本次协商结束。"; MetadataContent reject = client.generateNegotiationRejectPromptFromText( - rejectText, ctx, NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); + text(cases, "reject"), ctx, NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); results.add(NegotiationSampleSupport.summary("information", "reject", reject, logSink)); return results; @@ -101,22 +95,22 @@ private static List> targetNegotiation(A2ATClient client, Co new NegotiationContext(NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS); List> results = new ArrayList<>(); + Map cases = + ScenarioData.fromTextCases().get("target") instanceof Map map ? asStringMap(map) : Map.of(); + // propose: state the target intent and request clarification - String proposeText = "对无线节点节能优化任务的意图理解如下:" + "任务意图:08:00-18:00对目标站点启用无线节点节能优化。" + "待澄清内容:节能区域松山湖还是其他站点?"; MetadataContent propose = client.generateNegotiationProposePromptFromText( - proposeText, ctx, NegotiationSampleSupport.TARGET_PROPOSE_URI); + text(cases, "propose"), ctx, NegotiationSampleSupport.TARGET_PROPOSE_URI); results.add(NegotiationSampleSupport.summary("target", "propose", propose, logSink)); // accept: confirm the negotiated target intent - String acceptText = "确认意图:08:00-18:00对松山湖站点启用无线节点节能优化," + "速率保障不低于10Mbps。" + "最终意图已明确,可以执行。"; MetadataContent accept = client.generateNegotiationAcceptPromptFromText( - acceptText, ctx, NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); + text(cases, "accept"), ctx, NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); results.add(NegotiationSampleSupport.summary("target", "accept", accept, logSink)); // reject: cannot agree on the target - String rejectText = "拒绝:节能区域信息因站点清单不可用而无法完整澄清," + "本次协商结束。"; MetadataContent reject = client.generateNegotiationRejectPromptFromText( - rejectText, ctx, NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); + text(cases, "reject"), ctx, NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); results.add(NegotiationSampleSupport.summary("target", "reject", reject, logSink)); return results; @@ -132,27 +126,36 @@ private static List> feasibilityNegotiation(A2ATClient clien new NegotiationContext(NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS); List> results = new ArrayList<>(); + Map cases = + ScenarioData.fromTextCases().get("feasibility") instanceof Map map ? asStringMap(map) : Map.of(); + // propose: request a feasibility evaluation - String proposeText = "请评估无线节点节能优化在高峰时段是否可行。" + "评估对象:松山湖站点08:00-18:00节能窗口。" + "评估约束:速率保障不低于10Mbps。"; MetadataContent propose = client.generateNegotiationProposePromptFromText( - proposeText, ctx, NegotiationSampleSupport.FEASIBILITY_PROPOSE_URI); + text(cases, "propose"), ctx, NegotiationSampleSupport.FEASIBILITY_PROPOSE_URI); results.add(NegotiationSampleSupport.summary("feasibility", "propose", propose, logSink)); // accept: feasibility confirmed - String acceptText = "同意:高峰时段节能优化可行," + "速率保障满足10Mbps下限,可启用。" + "评估结果:可行。"; MetadataContent accept = client.generateNegotiationAcceptPromptFromText( - acceptText, ctx, NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); + text(cases, "accept"), ctx, NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); results.add(NegotiationSampleSupport.summary("feasibility", "accept", accept, logSink)); // reject: not feasible - String rejectText = "拒绝:节能目标在现有供电约束下无法实现," + "需调整方案。评估结果:不可行。"; MetadataContent reject = client.generateNegotiationRejectPromptFromText( - rejectText, ctx, NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); + text(cases, "reject"), ctx, NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); results.add(NegotiationSampleSupport.summary("feasibility", "reject", reject, logSink)); return results; } + private static String text(Map cases, String key) { + Object value = ScenarioData.resolveValue(String.valueOf(cases.get(key))); + return value == null || "null".equals(value) ? null : String.valueOf(value); + } + + @SuppressWarnings("unchecked") + private static Map asStringMap(Map map) { + return (Map) map; + } /** * Entry point. * diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java index e84bbe85..308bb444 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java @@ -1,43 +1,59 @@ package net.openan.a2at.sample.negotiation.server; import java.util.Map; +import net.openan.a2at.sample.negotiation.shared.ScenarioData; import net.openan.a2at.sdk.core.model.FilledParamData; /** - * Produces a diagnosis result from the validated Task-T parameters (not hardcoded). + * Produces a diagnosis result from the validated Task-T parameters. * - *

      In a real deployment this would call the EMS/NMS north-bound API; the demo builds the diagnosis text from the - * parameters extracted by {@code validateAndFillingTaskData}, so the result adapts to any Task-T input. + *

      The text layout comes from the {@code diagnosis} templates of the scenario configuration ({@code result_line}, + * {@code detail_line} with a {@code {params}} placeholder, {@code advice_line}); the parameter values themselves are + * taken from what {@code validateAndFillingTaskData} extracted, so the result adapts to any Task-T input. In a real + * deployment this service would call the EMS/NMS north-bound API instead of rendering text. * * @since 2026-08 */ public final class DiagnosisService { + /** Parameter keys the SDK injects as negotiation context (not business slots). */ + private static final String[] CONTEXT_PARAM_KEYS = {"id", "round", "maxRounds"}; + private DiagnosisService() {} /** - * Builds a diagnosis result from the extracted Task-T parameters. + * Builds a diagnosis result from the extracted Task-T parameters and the scenario templates. * * @param params validated parameters from {@code validateAndFillingTaskData} * @return diagnosis result text */ public static String diagnose(FilledParamData params) { - StringBuilder sb = new StringBuilder(); - sb.append("1. 诊断结果:成功\n"); - sb.append("2. 诊断结果详情:"); + Map templates = ScenarioData.diagnosisTemplates(); + StringBuilder detail = new StringBuilder(); if (params != null && params.data() != null) { for (Map.Entry entry : params.data().entrySet()) { - String key = entry.getKey(); - if ("id".equals(key) || "round".equals(key) || "maxRounds".equals(key)) { + if (isContextParam(entry.getKey())) { continue; } Object value = entry.getValue(); if (value != null && (!(value instanceof String s) || !s.isBlank())) { - sb.append(key).append("=").append(value).append(";"); + detail.append(entry.getKey()).append("=").append(value).append(";"); } } } - sb.append("\n3. 修复建议:检查接入端口物理连接和光模块状态,必要时恢复供电或重新开启端口"); - return sb.toString(); + return templates.getOrDefault("result_line", "") + + "\n" + + templates.getOrDefault("detail_line", "{params}").replace("{params}", detail) + + "\n" + + templates.getOrDefault("advice_line", ""); + } + + private static boolean isContextParam(String key) { + for (String contextKey : CONTEXT_PARAM_KEYS) { + if (contextKey.equals(key)) { + return true; + } + } + return false; } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java index bdb61d59..5ce8eb1f 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java @@ -123,7 +123,7 @@ private void handleMissingParams( server, new NegotiationContext(UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS), missingItems, - "以上参数均为必选,缺少无法启动诊断", + ScenarioData.negotiationPhrasing().get("propose_relationship"), DemoConstants.NEGOTIATION_PROPOSE); emit("[server] negotiation request rendered"); @@ -180,7 +180,7 @@ private static List findMissingItems(FilledParamData params) { } Object value = entry.getValue(); if (isMissing(value)) { - missing.add(new NegotiationItem(key, "请提供" + key)); + missing.add(missingItem(key)); } } return missing; @@ -198,12 +198,37 @@ private static List missingItemsFromErrors(List propertyMap && propertyMap.get(slotName) instanceof Map slot) { + Object description = slot.get("description"); + if (description instanceof String text && !text.isBlank()) { + return text; + } + } + return null; + } + /** Extracts the filled (non-null, non-blank) parameters as NegotiationItems. */ private static List extractFilledItems(FilledParamData params) { List items = new ArrayList<>(); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java index 6dc30752..813ba9fb 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java @@ -1,6 +1,7 @@ package net.openan.a2at.sample.negotiation.shared; import java.util.List; +import java.util.Map; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.TemplateUri; @@ -12,7 +13,9 @@ * LLM-based negotiation strategy: converts the items to natural-language text and calls the fromText API. * *

      The SDK runs one LLM content-extraction step to parse the text into typed content, then renders deterministically. - * This is the "natural language" path. + * The sentence framing of the assembled text comes from the scenario configuration ({@code from_text_propose_prefix}, + * {@code from_text_accept_prefix}, {@code from_text_accept_suffix}); the item lines follow one generic numbered-list + * rule. * * @since 2026-08 */ @@ -44,15 +47,9 @@ public MetadataContent generateAcceptServer( } private static String itemsToProposeText(List items, String relationship) { - StringBuilder sb = new StringBuilder("请提供以下缺失信息:"); - for (int i = 0; i < items.size(); i++) { - NegotiationItem item = items.get(i); - sb.append(i + 1).append(". ").append(item.name()); - if (item.value() != null && !item.value().isBlank()) { - sb.append(":").append(item.value()); - } - sb.append(";"); - } + StringBuilder sb = + new StringBuilder(ScenarioData.negotiationPhrasing().getOrDefault("from_text_propose_prefix", "")); + appendNumberedItems(sb, items); if (relationship != null && !relationship.isBlank()) { sb.append(relationship); } @@ -60,7 +57,15 @@ private static String itemsToProposeText(List items, String rel } private static String itemsToAcceptText(List items) { - StringBuilder sb = new StringBuilder("同意补充以下信息:"); + Map phrasing = ScenarioData.negotiationPhrasing(); + StringBuilder sb = new StringBuilder(phrasing.getOrDefault("from_text_accept_prefix", "")); + appendNumberedItems(sb, items); + sb.append(phrasing.getOrDefault("from_text_accept_suffix", "")); + return sb.toString(); + } + + /** Generic numbered-list rule: {@code N. name:value;} per item, value omitted when blank. */ + private static void appendNumberedItems(StringBuilder sb, List items) { for (int i = 0; i < items.size(); i++) { NegotiationItem item = items.get(i); sb.append(i + 1).append(". ").append(item.name()); @@ -69,7 +74,5 @@ private static String itemsToAcceptText(List items) { } sb.append(";"); } - sb.append("信息已完整,可以启动诊断。"); - return sb.toString(); } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java index 1dac5600..40867313 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java @@ -5,20 +5,18 @@ import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; +import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; /** - * Scenario data for the negotiation demo, loaded from the bundled {@code sample/negotiation/scenario.json}. + * Scenario configuration for the negotiation demo, loaded from the bundled {@code sample/negotiation/scenario.json}. * - *

      The scenario file carries the Task-T slot schema and the two parameter maps driving the 4-message flow: one with a - * missing required param (triggering the server-side information negotiation) and one with the params filled in - * (completing the negotiation). Keeping the data in a resource file — the same pattern as the subscribe-incident - * sample's {@code scenario.json} — means the demo scenario changes without touching Java code: edit the JSON (or point - * the loader at another file with the same shape) and the whole flow, from slot extraction to the diagnosis text, - * adapts to the new inputs. - * - *

      The slot schema mirrors {@code slots/Task-T/network-layer/private-line-complaint/.../slot.json}; when the bundled - * schema changes, copy the updated schema into this scenario file. + *

      The Java code of the sample contains only generic assembly rules (how to iterate slots, how to merge metadata); + * every scenario-specific value lives in the JSON file: the Task-T slot schema, the missing/filled parameter maps + * driving the 4-message flow, the negotiation phrasing templates ({@code {slot}} / {@code {description}} / + * {@code {params}} placeholders) and the 3x3 API-sample case data. Changing the scenario means editing the JSON — no + * Java recompilation — and the whole flow, from slot extraction to the diagnosis text, adapts to the new inputs. * * @since 2026-08 */ @@ -36,10 +34,7 @@ public static Map taskSchema() { return (Map) scenarioMap().get("task_schema"); } - /** - * Scenario data with a missing required param: the {@code 任务对象} slot is empty, which triggers the server-side - * information negotiation. - */ + /** Scenario data with a missing required param, triggering the server-side information negotiation. */ @SuppressWarnings("unchecked") public static Map missingParams() { return (Map) scenarioMap().get("missing_params"); @@ -51,6 +46,85 @@ public static Map filledParams() { return (Map) scenarioMap().get("filled_params"); } + /** Negotiation phrasing templates: {@code missing_item_hint}, {@code propose_relationship}, from-text prefixes. */ + @SuppressWarnings("unchecked") + public static Map negotiationPhrasing() { + return (Map) scenarioMap().get("negotiation_phrasing"); + } + + /** Diagnosis result templates: {@code result_line}, {@code detail_line}, {@code advice_line}. */ + @SuppressWarnings("unchecked") + public static Map diagnosisTemplates() { + return (Map) scenarioMap().get("diagnosis"); + } + + /** The 3x3 API-sample case data keyed by negotiation type (information/target/feasibility). */ + @SuppressWarnings("unchecked") + public static Map negotiationCases() { + return (Map) scenarioMap().get("negotiation_cases"); + } + + /** The fromText natural-language case texts keyed by negotiation type. */ + @SuppressWarnings("unchecked") + public static Map fromTextCases() { + Object cases = negotiationCases().get("from_text_cases"); + return cases instanceof Map map ? (Map) map : Map.of(); + } + + /** The fromData case section of one negotiation type. */ + @SuppressWarnings("unchecked") + public static Map caseSection(String negotiationType) { + Object section = negotiationCases().get(negotiationType); + return section instanceof Map map ? (Map) map : Map.of(); + } + + /** + * Reads one item list ({@code name}/{@code value} entries) from a case section; {@code {filled:}} values are + * substituted with the filled scenario parameter of that slot. + */ + public static List items(Map section, String key) { + Object raw = section.get(key); + if (!(raw instanceof List entries)) { + return List.of(); + } + return entries.stream() + .filter(Map.class::isInstance) + .map(entry -> { + Map item = (Map) entry; + return new NegotiationItem( + String.valueOf(item.get("name")), resolveValue(String.valueOf(item.get("value")))); + }) + .toList(); + } + + /** Reads one string field from a case section, applying {@code {filled:}} substitution. */ + public static String text(Map section, String key) { + Object value = section.get(key); + return value == null ? null : resolveValue(String.valueOf(value)); + } + + /** Reads one nested map field from a case section. */ + @SuppressWarnings("unchecked") + public static Map map(Map section, String key) { + Object value = section.get(key); + return value instanceof Map map ? (Map) map : Map.of(); + } + + /** Substitutes {@code {filled:}} markers with the filled scenario parameter of that slot. */ + public static String resolveValue(String template) { + if (template == null) { + return null; + } + String result = template; + for (Map.Entry entry : filledParams().entrySet()) { + String marker = "{filled:" + entry.getKey() + "}"; + if (result.contains(marker)) { + result = result.replace(marker, String.valueOf(entry.getValue())); + } + } + return result; + } + @SuppressWarnings("unchecked") private static Map scenarioMap() { try (InputStream stream = ScenarioData.class.getClassLoader().getResourceAsStream(SCENARIO_RESOURCE)) { diff --git a/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json b/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json index 0a6eb82d..c5c1afbf 100644 --- a/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json +++ b/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json @@ -6,15 +6,21 @@ "任务对象": { "type": "string", "description": "专线名称,接入端口名称,接入端口资源ID参数,3个参数下发其中一个用于标识专线业务对象", - "examples": ["接入端口资源ID:f47ac10b-58cc-4372-a567-0e02b2c3d479"] + "examples": [ + "接入端口资源ID:f47ac10b-58cc-4372-a567-0e02b2c3d479" + ] }, "任务上下文": { "type": "string", "description": "提供专线业务的故障现象描述和诊断任务的上下文信息", - "examples": ["故障现象:专线中断。故障流水号:fault-id-1-017-20230511-09013"] + "examples": [ + "故障现象:专线中断。故障流水号:fault-id-1-017-20230511-09013" + ] } }, - "required": ["任务上下文"] + "required": [ + "任务上下文" + ] }, "missing_params": { "任务对象": "", @@ -23,5 +29,116 @@ "filled_params": { "任务对象": "P781-珠江新城-PTN7900-23-TPA1EG24-17", "任务上下文": "1. 投诉分类:专线质差\n2. 问题发生时间:2026-05-11T08:21:46Z\n3. OSS侧事件流水号:event-id-20260511-09013\n4. 投诉详情:深圳访问广州响应延迟骤升,交易接口频繁报连接超时" + }, + "negotiation_phrasing": { + "missing_item_hint": "请提供{slot}:{description}", + "propose_relationship": "以上参数均为必选,缺少无法启动诊断", + "from_text_propose_prefix": "请提供以下缺失信息:", + "from_text_accept_prefix": "同意补充以下信息:", + "from_text_accept_suffix": "信息已完整,可以启动诊断。" + }, + "diagnosis": { + "result_line": "1. 诊断结果:成功", + "detail_line": "2. 诊断结果详情:{params}", + "advice_line": "3. 修复建议:检查接入端口物理连接和光模块状态,必要时恢复供电或重新开启端口" + }, + "negotiation_cases": { + "information": { + "propose": { + "items": [ + { + "name": "接入端口名称", + "value": "请提供业务接入端口名称" + }, + { + "name": "投诉分类", + "value": "专线中断或专线质差" + } + ], + "relationship": "两个参数均为必选,缺少无法启动诊断" + }, + "accept": { + "items": [ + { + "name": "接入端口名称", + "value": "{filled:任务对象}" + }, + { + "name": "投诉分类", + "value": "专线质差" + } + ] + }, + "reject": { + "items": [ + { + "name": "接入端口名称", + "value": "站点清单不可用,无法提供" + } + ] + } + }, + "target": { + "propose": { + "description": "对无线节点节能优化任务的意图理解待澄清", + "intent_understanding": [ + { + "name": "任务意图", + "value": "08:00-18:00对目标站点启用无线节点节能优化" + } + ], + "request_for_clarification": [ + { + "name": "节能区域", + "value": "松山湖还是其他站点?" + } + ] + }, + "accept": { + "confirmed_intent": "最终确认意图:08:00-18:00对松山湖站点启用无线节点节能优化,速率保障不低于10Mbps" + }, + "reject": { + "rejection_reason": "节能区域信息因站点清单不可用而无法完整澄清" + } + }, + "feasibility": { + "propose": { + "description": "请评估无线节点节能优化在高峰时段是否可行", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": [ + { + "name": "评估对象", + "value": "松山湖站点08:00-18:00节能窗口" + }, + { + "name": "评估约束", + "value": "速率保障不低于10Mbps" + } + ] + }, + "accept": { + "summary": "高峰时段节能优化可行,速率保障满足10Mbps下限,可启用" + }, + "reject": { + "summary": "节能目标在现有供电约束下无法实现,需调整方案" + } + }, + "from_text_cases": { + "information": { + "propose": "请提供以下缺失信息:1. 接入端口名称:请提供业务接入端口名称;2. 投诉分类:专线中断或专线质差。两个参数均为必选,缺少无法启动诊断。两者共同定位专线故障原因。", + "accept": "同意补充以下信息:1. 接入端口名称:{filled:任务对象};2. 投诉分类:专线质差。信息已完整,可以启动诊断。", + "reject": "拒绝补充信息:接入端口名称因站点清单不可用而无法提供,本次协商结束。" + }, + "target": { + "propose": "对无线节点节能优化任务的意图理解如下:任务意图:08:00-18:00对目标站点启用无线节点节能优化。待澄清内容:节能区域松山湖还是其他站点?", + "accept": "确认意图:08:00-18:00对松山湖站点启用无线节点节能优化,速率保障不低于10Mbps。最终意图已明确,可以执行。", + "reject": "拒绝:节能区域信息因站点清单不可用而无法完整澄清,本次协商结束。" + }, + "feasibility": { + "propose": "请评估无线节点节能优化在高峰时段是否可行。评估对象:松山湖站点08:00-18:00节能窗口。评估约束:速率保障不低于10Mbps。", + "accept": "同意:高峰时段节能优化可行,速率保障满足10Mbps下限,可启用。评估结果:可行。", + "reject": "拒绝:节能目标在现有供电约束下无法实现,需调整方案。评估结果:不可行。" + } + } } } From 03a0bd4af62dbf14f1b6efaa4c66158b5376ce26 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 13:33:42 +0800 Subject: [PATCH 113/295] fix(prompt): keep single-brace example text verbatim instead of failing as unknown slot The placeholder regex accepted both {{slot}} and {slot} forms, so example prose in requirement sections such as the energy-saving template's rate guarantee examples ({00:00~06:00,2Mbps}) was misread as an unknown slot placeholder and rendering failed with 'Unknown slot referenced by template' for every energy-saving prompt. The energy-saving Task-T template could never render. Single-brace text that does not match a known slot name is now kept verbatim in the rendered prompt (it is example prose); only double-braced {{slot}} placeholders and single-brace names matching a known slot are substituted, and unknown double-braced placeholders still fail fast. - add a regression test rendering the rate-guarantee example line - tighten the unknown-slot test to the double-braced form Signed-off-by: ZhouJie6 --- .../prompt/taskrendering/TaskPromptRenderer.java | 14 ++++++++++++++ .../taskrendering/TaskPromptRendererTest.java | 10 +++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java index 096c3abd..b8dca1db 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java @@ -18,6 +18,15 @@ public final class TaskPromptRenderer implements SectionedTemplateRenderer { private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{?\\s*([^{}]+?)\\s*\\}\\}?"); + + /** + * Matches the literal single-brace example values a template may carry in its requirement prose (for example + * {@code {00:00~06:00,2Mbps}}). Such text is never a slot placeholder; skipping it keeps example values verbatim + * in the rendered prompt instead of failing with an unknown-slot error. + */ + private static boolean isKnownSlotPlaceholder(String candidate, Map slots) { + return slots.containsKey(candidate); + } private static final Pattern SECTION_HEADER_PATTERN = Pattern.compile("^##\\s+.+$"); private static final Pattern STANDALONE_SLOT_LINE_PATTERN = Pattern.compile( "^\\s*(\\{\\{?\\s*[^{}]+?\\s*\\}\\}?)(?:\\s*(?:\\(Required\\)|\\(Optional\\)|(必选)|(可选)))?\\s*$"); @@ -41,7 +50,12 @@ public String render(String templateText, Map slots) { StringBuffer rendered = new StringBuffer(); while (matcher.find()) { String slotName = matcher.group(1).trim(); + boolean doubleBraced = matcher.group(0).startsWith("{{"); if (!safeSlots.containsKey(slotName)) { + if (!doubleBraced && !isKnownSlotPlaceholder(slotName, safeSlots)) { + // single-brace text that is not a slot is example prose (e.g. {00:00~06:00,2Mbps}); keep verbatim + continue; + } throw new TaskPromptRenderException("Unknown slot referenced by template: " + slotName); } String replacement = safeSlots.get(slotName); diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRendererTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRendererTest.java index 47839c26..4139c50a 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRendererTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRendererTest.java @@ -106,7 +106,15 @@ void renderPreservesRegularInlinePlaceholderContent() { void renderRaisesWhenTemplateReferencesUnknownSlot() { assertThrows( TaskPromptRenderException.class, - () -> renderer.render("Site: {site}\nTime Range: {time_range}", Map.of("site", "Site A"))); + () -> renderer.render("Site: {{site}}\nTime Range: {{time_range}}", Map.of("site", "Site A"))); + } + + @Test + void renderKeepsSingleBraceExampleTextVerbatim() { + // example prose such as "{00:00~06:00,2Mbps}" inside requirement text is not a slot placeholder + String prompt = renderer.render( + "Rate target: {00:00~06:00,2Mbps}\nSite: {{site}}", Map.of("site", "Site A")); + assertEquals("Rate target: {00:00~06:00,2Mbps}\nSite: Site A", prompt); } @Test From 7b44fcdf7efb6727b89dc47f7552cb4e80f84bf5 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 14:19:28 +0800 Subject: [PATCH 114/295] fix(client): use double-braced unknown slot to trigger render failure in tests The three render-exception propagation tests triggered the failure via a single-brace unknown slot {missing_slot}. After the renderer fix, single- brace text that matches no known slot is example prose kept verbatim, so those triggers no longer raise. Switch them to the double-braced {{missing_slot}} form, which still fails fast, preserving the tests' intent of asserting render_failed propagation. Signed-off-by: ZhouJie6 --- .../DefaultClientPromptGenerationOrchestratorTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 53df4f07..1d9285f8 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -127,7 +127,7 @@ void generateTaskPromptReturnsFailureWhenRenderingFails() { "en-US", "Identify the best matching scenario.", "Choose from the provided scenario list.", - (scenarioCode, language) -> "Scenario: {scenario}\nMissing: {missing_slot}", + (scenarioCode, language) -> "Scenario: {scenario}\nMissing: {{missing_slot}}", (userInput, scenarioCode, language, templateText) -> Map.of("scenario", scenarioCode), new TaskPromptRenderer(), EMPTY_SCHEMA_LOADER); @@ -380,7 +380,7 @@ void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { void generateFromTemplateUriWithMetadataPropagatesRenderException() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}\nMissing: {missing_slot}"), + new FakeTemplateLoader("Site: {site}\nMissing: {{missing_slot}}"), new FakeSlotValueExtractor(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows( @@ -489,7 +489,7 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundException() { void generateFromDataWithSchemaPropagatesRenderException() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}\nMissing: {missing_slot}"), + new FakeTemplateLoader("Site: {site}\nMissing: {{missing_slot}}"), new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); PromptGenerationException ex = assertThrows( From bc493ee998a51a9776fc9fe1d95bcf7cb4db254b Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 17:26:10 +0800 Subject: [PATCH 115/295] feat(sample): stream transport support and release-grade design document - NegotiationClient selects the A2A endpoint from the server's declared capability: message:stream with client-event aggregation when the AgentCard advertises streaming, blocking message:send otherwise; the flow logic is endpoint-agnostic and both paths produce the same reply carrier - NegotiationDemoApp gains --no-stream to force the blocking endpoint, covering all four channel x endpoint combinations together with --fromText - rewrite Negotiation-Sample-Design.md as release documentation: the scenario, message flow, generation channels, transport endpoints, code structure, run instructions and coverage matrix are fully self-contained, with no references to external or internal documents - update README.zh-CN.md run commands for the new flag Signed-off-by: ZhouJie6 --- a2a-t-sample/README.zh-CN.md | 5 +- .../docs/Negotiation-Sample-Design.md | 356 +++++++----------- .../negotiation/NegotiationDemoApp.java | 30 +- .../negotiation/client/NegotiationClient.java | 151 +++++++- 4 files changed, 304 insertions(+), 238 deletions(-) diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index bd07f876..289ce925 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -44,7 +44,7 @@ | 目录 | 作用 | |---|---| | `negotiation/` | 入口 `NegotiationDemoApp`:启动嵌入式 HTTP server + 跑 client,`--fromText` 切换策略 | -| `negotiation/client/` | `NegotiationClient`:4 报文编排(Task-T 缺失→收协商→补齐→收诊断) | +| `negotiation/client/` | `NegotiationClient`:4 报文编排 + 传输端点选择(按 AgentCard 能力走 message:stream / message:send) | | `negotiation/server/` | `NegotiationAgentExecutor`(validateAndFillingTaskData→缺失检测→协商请求→诊断)+ `NegotiationServerRuntime`(HTTP server 装配)+ `DiagnosisService`(从 FilledParamData 动态生成诊断) | | `negotiation/shared/` | 策略层(`NegotiationStrategy` + `FromDataStrategy`/`FromTextStrategy`)、A2A metadata 桥接(`NegotiationMessage`)、扩展/模板 URI 常量(`DemoConstants`)、场景数据加载器(`ScenarioData`,数据在 `scenario.json`)、样例公共辅助(`NegotiationSampleSupport`) | | `negotiation/fromdata/`、`negotiation/fromtext/` | 9 用例 API 验证样例(见下文两节) | @@ -59,6 +59,9 @@ java @a2a-t-sample/target/negotiation.javaargs.txt /path/to/.env # fromText 策略(协商报文由 LLM 生成) java @a2a-t-sample/target/negotiation.javaargs.txt --fromText /path/to/.env + +# 强制阻塞端点(默认按 AgentCard 能力优先 message:stream) +java @a2a-t-sample/target/negotiation.javaargs.txt --no-stream /path/to/.env ``` 如果不传参数,`NegotiationDemoApp` 会回退到包内的 `sample/negotiation/negotiation.env`(该模板 key 为空,仅用于占位)。Windows 控制台如遇中文乱码,先执行 `chcp 65001`。 diff --git a/a2a-t-sample/docs/Negotiation-Sample-Design.md b/a2a-t-sample/docs/Negotiation-Sample-Design.md index cdb6f73c..5b185a5a 100644 --- a/a2a-t-sample/docs/Negotiation-Sample-Design.md +++ b/a2a-t-sample/docs/Negotiation-Sample-Design.md @@ -1,224 +1,132 @@ - # A2A-T 协商样例设计与实现说明 - - > 记录目标、架构、依赖、注意事项、代码索引、运行方式,便于理解和维护本样例。 - - --- - - ## 一、任务目标 - - 基于 `a2a-t-sdk-java` 的协商(Negotiation-T)SDK 能力,构建可运行的端到端 demo,覆盖文档 §7.3/§7.4 定义的"传输专线业务投诉诊断"4 报文协商流程,并输出 fromData / fromText 两种协商生成方式的 API 验证样例。 - - ### 分工 - - | 方式 | API | 特点 | -|---|---|---| -| fromData(结构化数据,规则生成) | `generateNegotiationProposePromptFromData` / `...Accept...` / `...Reject...` | 协商报文生成不调 LLM,确定性 | -| fromText(自然语言,LLM 抽取) | `generateNegotiationProposePromptFromText` / `...Accept...` / `...Reject...` | 协商报文生成需 LLM | - - 两种方式的**唯一差异**是协商报文的生成方式(规则 vs LLM)。其他环节(Task-T 报文生成、参数校验、状态机)完全一致,该用 LLM 的地方都用 LLM。**整个 demo 始终需要真实 LLM API key**:fromData 只省掉协商报文生成这一个环节的 LLM 调用,Task-T 槽位提取与语义校验仍走 LLM。 - - --- - - ## 二、4 报文流转(文档 §7.3/§7.4) - - | 报文 | 方向 | 扩展 | SDK API | 内容 | 状态 | - |---|---|---|---|---|---| - | 1 | client→server | Task-T | `generateTaskPromptFromDataWithSchema` | 专线投诉诊断任务,参数缺失(接入端口名称空) | → | - | 2 | server→client | Negotiation-T | `validateAndFillingTaskData` 校验→`ContentValidationException.errors()` 提取缺失项→`NegotiationStrategy.generatePropose` | 信息协商请求,动态列出缺失参数 | INPUT_REQUIRED | - | 3 | client→server | Task-T + Negotiation-T | `generateTaskPromptFromDataWithSchema`(补齐) + `NegotiationStrategy.generateAccept` | 参数补齐 + 协商接受 | → | - | 4 | server→client | Task-T | `validateAndFillingTaskData` 校验通过→`DiagnosisService.diagnose(params)` | 诊断结果(从提取参数动态生成) | COMPLETED | - - 全程走真实 a2a-java HTTP A2A(`message:send`)。 - - --- - - ## 三、核心设计:策略模式隔离 fromData / fromText - - ``` - NegotiationStrategy(接口) - ├── FromDataStrategy → 构造类型化 record → generateXxxFromData(规则,不调 LLM) - └── FromTextStrategy → 转自然语言文本 → generateXxxFromText(LLM 抽取 + 渲染) - ``` - - - `NegotiationDemoApp` 接受 `--fromText` 参数,选择策略注入 server 和 client - - server `NegotiationAgentExecutor` 和 client `NegotiationClient` 都持有一个 `NegotiationStrategy` 实例 - - 协商报文生成时调用 `strategy.generatePropose(...)` / `strategy.generateAccept(...)` - - 两种策略的差异仅在"怎么把缺失/补齐参数转成 API 输入"(record vs 文本),其余逻辑完全共享 - - --- - - ## 四、不写死——全动态生成 - - | 环节 | 旧做法(写死) | 新做法(动态) | - |---|---|---| - | 参数缺失检测 | `hasMissingParams` 轻量字符串匹配 | `validateAndFillingTaskData`(SDK LLM 语义校验) | - | 缺失参数列表 | `InfoProposeContent` 硬编码 | 从 `ContentValidationException.errors()` 动态提取 slotName | - | accept 内容 | `InfoEndingContent` 硬编码 | 从 `ScenarioData.filledParams()` 动态构造 NegotiationItem 列表 | - | 诊断结果 | `DiagnosisService.diagnose()` 固定文本 | `DiagnosisService.diagnose(FilledParamData)` 从提取参数动态生成 | - | 协商报文生成 | 只用 fromData | 策略模式,fromData/fromText 可切换 | - - 支持泛化输入:换一个 Task-T 输入(缺不同参数),协商内容自动适配。 - - --- - - ## 五、依赖文档 - - | 文档 | 路径 | 用途 | - |---|---|---| - | A2A-T 协商 API 文档(中) | `docs/zh/A2A-T-Negotiation-API-Reference.md` | 协商三层 API(状态机/内容生成/校验)、类型化内容模型、模板 URI、异常码 | - | A2A-T 协商 API 文档(英) | `docs/en/A2A-T-Negotiation-API-Reference.md` | 同上英文版 | - | A2A-T SDK API 综合 | `docs/zh/A2A-T-SDK-API-Reference.md` | 全局 API 参考(协商分册在此基础上) | - | Task-T 模板资源 | `a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/private-line-complaint/v1/zh-CN/template.md` | 专线投诉诊断 Task-T 模板 | - | Task-T slot schema | `a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json` | Task-T 参数 schema(任务对象/任务上下文) | - | Negotiation-T 模板 | `a2a-t-resources/.../templates/Negotiation-T/` | 信息/目标/可行性协商 propose + accept-reject 模板 | - | content_validation prompt | `a2a-t-resources/.../prompts/content_validation/zh-CN/system.md` + `user.md` | Task-T 服务端语义校验的 LLM prompt | - - ### SDK 关键类索引 - - | 类 | 模块 | 作用 | - |---|---|---| - | `A2ATClient` | a2a-t-client | 客户端 facade(fromData/fromText 生成 + validate + 状态机) | - | `A2ATServer` | a2a-t-server | 服务端 facade(同上,额外 prompt compliance) | - | `NegotiationContext` (content 层) | a2a-t-negotiation | 协商会话上下文(id/round/maxRounds) | - | `NegotiationContext` (types.model 层) | a2a-t-negotiation | 状态机上下文(type/id/round/status) | - | `NegotiationProposeData` / `NegotiationEndingData` | a2a-t-negotiation | fromData 输入 | - | `InfoProposeContent` / `TargetProposeContent` / `FeasibilityProposeContent` | a2a-t-negotiation | 三种协商类型的 propose 内容 | - | `InfoEndingContent` / `TargetEndingContent` / `FeasibilityEndingContent` | a2a-t-negotiation | 三种协商类型的 ending 内容 | - | `MetadataContent` | a2a-t-core | 生成输出(templateUri + promptText + extensionUri) | - | `FilledParamData` | a2a-t-core | 校验参数提取结果 | - | `ContentValidationException` | a2a-t-core | 校验失败异常(携带 `errors()` 即 `List`) | - | `NegotiationPayloadMapper` | a2a-t-negotiation | payload 序列化/反序列化 | - | `EmbeddedA2AHttpServer` | a2a-t-sample (subscribe_incident) | 嵌入式 HTTP A2A 服务器 | - | `DefaultRequestHandler` / `AgentExecutor` / `AgentEmitter` | a2a-java SDK (org.a2aproject.sdk) | A2A 服务端请求处理 | - | `RestTransport` / `AgentCard` / `Message` / `Task` | a2a-java SDK | A2A 客户端传输 + 数据模型 | - - --- - - ## 六、代码索引 - - ### 端到端 demo(4 报文) - - | 文件 | 作用 | - |---|---| - | `negotiation/NegotiationDemoApp.java` | 入口:启动 HTTP server + 跑 client,`--fromText` 切换策略 | - | `negotiation/client/NegotiationClient.java` | 4 报文编排:Task-T 缺失→收协商→补齐→收诊断 | - | `negotiation/server/NegotiationAgentExecutor.java` | AgentExecutor:validateAndFillingTaskData→缺失检测→协商请求→诊断 | - | `negotiation/server/NegotiationServerRuntime.java` | HTTP server + DefaultRequestHandler + AgentCard 装配 | - | `negotiation/server/DiagnosisService.java` | 从 FilledParamData 动态生成诊断结果 | - - ### 策略层(fromData / fromText 差异隔离) - - | 文件 | 作用 | - |---|---| - | `negotiation/shared/NegotiationStrategy.java` | 策略接口(generatePropose / generateAccept / generateAcceptServer) | - | `negotiation/shared/FromDataStrategy.java` | fromData 实现(record + fromData API,规则) | - | `negotiation/shared/FromTextStrategy.java` | fromText 实现(文本 + fromText API,LLM) | - - ### 公共层 - - | 文件 | 作用 | - |---|---| - | `negotiation/shared/NegotiationMessage.java` | A2A metadata 桥接(Task-T/Negotiation-T prompt + context 序列化) | - | `negotiation/shared/DemoConstants.java` | 扩展 URI、模板 URI 常量 | - | `negotiation/shared/ScenarioData.java` | 场景数据加载器(从 `scenario.json` 读取 slot schema + 参数缺失/补齐数据) | - | `negotiation/shared/NegotiationSampleSupport.java` | fromData/fromText 样例公共辅助(SessionId、URI 常量、summary) | - - ### API 验证样例(3 类型 × 3 阶段 = 9 用例) - - | 文件 | 作用 | - |---|---| - | `negotiation/fromdata/FromDataNegotiationSample.java` | 9 用例,fromData API,报文生成确定性 | - | `negotiation/fromtext/FromTextNegotiationSample.java` | 9 用例,fromText API,报文生成含 LLM 抽取 | - - ### 文档 - - | 文件 | 作用 | - |---|---| - | `docs/zh/A2A-T-Negotiation-API-Reference.md` | 协商 API 文档(中) | - | `docs/en/A2A-T-Negotiation-API-Reference.md` | 协商 API 文档(英) | - | `a2a-t-sample/docs/Negotiation-Sample-Design.md` | 本文档 | - - --- - - ## 七、SDK bug 修复(a2a-t-prompt 模块) - - 构建 demo 时发现并修复了 `a2a-t-prompt` 模块两个 bug: - - ### Bug 1:`DefaultSemanticValidator.loadPrompt` 漏 `.md` - - - 文件:`a2a-t-prompt/.../DefaultSemanticValidator.java` - - 问题:`loadPrompt("content_validation", language, "system")` 的 fileName 不带 `.md`,`LocalFileAccess` 找不到 `system.md` 文件 - - 对比:Negotiation 模块的 `DefaultNegotiationSemanticValidator` 传的是 `"system.md"`(正确) - - 修复:改为 `"system.md"` / `"user.md"` - - ### Bug 2:`parseParams` 的 `Map.copyOf` 对 null value NPE - - - 文件:`a2a-t-prompt/.../DefaultSemanticValidator.java` - - 问题:LLM 语义校验返回的 params map 可能含 null value,`Map.copyOf` 不允许 null 抛 NPE - - 修复:过滤 null value 后再 `Map.copyOf` - - --- - - ## 八、运行方式 - - ### 前置条件 - - - JDK 17+ -- `mvn clean install -DskipTests`(全量构建,errorprone 在 JDK17 下正常工作,需联网模式) - - 含真实 LLM API key 的 `.env` 文件(参考 `a2a-t-sample/src/main/resources/sample/negotiation/negotiation.env` 模板,填入 `A2AT_LLM_API_KEY`) - - ### 4 报文端到端 demo - - ```bash - # fromData 策略(协商报文规则生成,不调 LLM;Task-T 槽位提取/语义校验仍需 LLM key) - java @a2a-t-sample/target/negotiation.javaargs.txt /path/to/.env - - # fromText 策略(协商报文 LLM 生成) - java @a2a-t-sample/target/negotiation.javaargs.txt --fromText /path/to/.env - ``` - - ### API 验证样例 - - ```bash - # fromData 9 用例(报文生成不调 LLM,但 A2ATClient 构造仍需有效的 LLM key) - java @a2a-t-sample/target/fromdata.javaargs.txt /path/to/.env - - # fromText 9 用例(LLM 抽取,需 LLM key) - java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env - ``` - - --- - - ## 九、注意事项与已知限制 - - 1. **errorprone 与 JDK17**:`old_jdk_support` profile(`[17, 21]`)自动降级 errorprone 到 2.42.0。离线模式(`-o`)会因插件 jar 不完整导致 `ServiceLoader defineClass` 失败,必须联网构建。 - - 2. **模板 URI 格式**:正确格式是 `Negotiation-T/{type-segment}/{phase-segment}/v1`(v1 在末段)。文档之前写的 `Negotiation-T/v1/{type}/{phase}` 是错的,已修正。 - - 3. **两套 NegotiationContext**:`content.NegotiationContext`(id/round/maxRounds,用于内容生成)和 `types.model.NegotiationContext`(type/id/round/status,用于状态机),不可混用。 - - 4. **AgentInterface 构造函数**:4 参数版 `(protocolBinding, url, tenant, protocolVersion)`,tenant 必须传空字符串(不能省略),否则 `buildBaseUrl` 会把 protocolVersion 当 tenant 追加到 URL。 - - 5. **message:send 返回 Task**:非流式 `message:send` 返回的 `EventKind` 是 `Task`(不是 `Message`),回复在 `Task.history()` 或 `Task.artifacts()` 里,需要根据场景提取。 - - 6. **DiagnosisService 是 mock**:诊断结果从 `FilledParamData` 动态生成,但不是真实网管诊断。真实场景应替换为 EMS/NMS 北向 API 调用。 - - 7. **fromText 报文生成需要 LLM key**:`FromTextNegotiationSample` 和 `--fromText` 模式的协商报文生成含一步 LLM 抽取。fromData 样例的报文生成虽不调 LLM,但 `A2ATClient` 构造即校验 `A2AT_LLM_API_KEY` 非空,同样需要有效 key。 - - 8. **content_validation 资源**:Task-T 服务端语义校验需要 `content_validation` prompt 资源(system.md/user.md)。`local_file` 模式指向 `a2a-t-resources/src/main/resources/prompt_resources`;`classpath` 模式需 a2a-t-resources jar 在 classpath 上。 - - 9. **Unicode 转义已修复**:之前因 PowerShell 编码问题用了 `\uXXXX` 转义序列,已全部替换为 UTF-8 中文字符,符合开源规范。 - - 10. **NegotiationServerRuntime 未调 receiveNegotiation**:当参数缺失时跳过 `receiveNegotiation`(因为 InformationNegotiation handler 会调用 compliance checker 拒绝不完整 Task-T)。直接从 `ContentValidationException.errors()` 提取缺失项后生成协商请求。 - - --- - - ## 十、fromData / fromText 验证覆盖矩阵 - - | 类型 | propose | accept | reject | - |---|---|---|---| - | 信息协商(information) | ✅ | ✅ | ✅ | - | 目标协商(target) | ✅ | ✅ | ✅ | - | 可行性协商(feasibility) | ✅ | ✅ | ✅ | - - fromData 和 fromText 各 9 个用例,全部通过。 +# A2A-T 协商样例(Negotiation Sample) + +本样例演示 A2A-T 协商扩展(Negotiation-T)的完整业务闭环:客户端提交参数不全的任务请求,服务端通过协商机制向客户端索要缺失参数,客户端补充后任务继续执行。样例覆盖协商的两个核心生成通道(结构化数据 / 自然语言)与两种 A2A 传输端点(流式 / 阻塞),全部基于真实 HTTP 交互,无 mock 桩。 + +--- + +## 一、协商场景 + +样例使用"传输专线投诉诊断"业务场景。客户端发起专线投诉诊断任务时遗漏了标识专线业务对象的必选信息(专线名称 / 接入端口名称 / 接入端口资源 ID,三者提供其一),服务端无法定位故障对象,于是发起一轮信息协商: + +| 报文 | 方向 | 扩展 | 任务状态 | 说明 | +|---|---|---|---|---| +| 1 | client → server | Task-T | — | 投诉诊断请求,任务对象参数为空 | +| 2 | server → client | Negotiation-T | INPUT_REQUIRED | 服务端检测到参数缺失,发起信息协商请求,动态列出缺失项 | +| 3 | client → server | Task-T + Negotiation-T | — | 客户端补齐参数并附协商接受报文 | +| 4 | server → client | Task-T | COMPLETED | 服务端复验通过,基于提取参数输出诊断结果 | + +协商触发完全由数据驱动:服务端对收到的任意 Task-T 请求执行语义校验,校验报告哪些参数缺失,协商请求的内容即由校验结果生成。更换任务输入(缺失不同参数、甚至不缺参数)无需改动任何代码——不缺参数时流程直接跳过协商进入执行。 + +## 二、协商报文的两种生成通道 + +协商报文的生成提供两个等价通道,由策略模式隔离差异: + +``` +NegotiationStrategy(接口) + ├── FromDataStrategy → 类型化内容 record → generateXxxFromData(模板渲染,确定性) + └── FromTextStrategy → 自然语言文本 → generateXxxFromText(LLM 结构化抽取 + 模板渲染) +``` + +两个通道仅在"缺失/补齐参数如何进入生成 API"这一步不同:fromData 通道把参数组装成类型化内容记录(`InformationProposeContent` 等),由 SDK 直接渲染模板;fromText 通道把参数组织成自然语言语句,由 SDK 先做一步 LLM 结构化抽取再渲染。Task-T 报文生成、参数语义校验、协商状态机等其余环节两个通道完全共享。 + +入口通过命令行参数选择通道:默认 fromData,`--fromText` 切换为自然语言通道。 + +## 三、传输端点 + +客户端按服��端 AgentCard 声明的能力选择 A2A 传输端点: + +- 服务端声明 `streaming=true` 时走 `message:stream`,客户端聚合流式事件(任务终态或末条 agent 消息)得到回复; +- 否则走 `message:send` 阻塞式往返。 + +入口默认优先流式;`--no-stream` 参数强制阻塞端点,用于覆盖服务端不支持流式的场景。两条路径产出统一的回复载体,协商流程逻辑与端点无关。 + +## 四、代码结构 + +``` +negotiation/ +├── NegotiationDemoApp.java # 入口:装配嵌入式 HTTP server + 驱动客户端流程 +├── client/ +│ └── NegotiationClient.java # 4 报文编排 + 传输端点选择与流事件聚合 +├── server/ +│ ├── NegotiationAgentExecutor.java # 接收请求 → 语义校验 → 缺失检测 → 协商/执行分流 +│ ├── NegotiationServerRuntime.java # AgentCard(声明 Task-T/Negotiation-T 扩展与流式能力)+ HTTP 装配 +│ └── DiagnosisService.java # 从校验提取的参数生成诊断结果 +├── shared/ +│ ├── NegotiationStrategy.java # 协商报文生成策略接口 +│ ├── FromDataStrategy.java # 结构化数据通道 +│ ├── FromTextStrategy.java # 自然语言通道 +│ ├── NegotiationMessage.java # A2A metadata 桥接(扩展 prompt + 协商上下文序列化) +│ ├── DemoConstants.java # 扩展 URI / 元数据键常量 +│ ├── ScenarioData.java # scenario.json 加载器 +│ └── NegotiationSampleSupport.java # 3×3 API 样例公共辅助 +├── fromdata/ +│ └── FromDataNegotiationSample.java # 信息/目标/可行性 × 提议/接受/拒绝,共 9 个生成用例 +└── fromtext/ + └── FromTextNegotiationSample.java # 同上 9 个用例,自然语言输入 +``` + +### 服务端处理流程 + +`NegotiationAgentExecutor` 对每个入站请求执行: + +1. 从 `Message.metadata` 提取 Task-T prompt; +2. 调用 `A2ATServer.validateAndFillingTaskData` 做语义校验与参数提取; +3. 校验通过且无缺失参数 → 生成诊断结果 artifact,任务转 COMPLETED; +4. 校验拒绝(参数缺失)→ 从 `ContentValidationException.errors()` 提取缺失槽位,经策略生成 Negotiation-T 信息协商请求,任务转 INPUT_REQUIRED。 + +步骤 3、4 之间没有任何与特定参数绑定的逻辑——缺失项名称、数量、提示文案均来自校验结果与场景配置。 + +### 场景数据外部化 + +场景的全部实例化值集中在 `src/main/resources/sample/negotiation/scenario.json`:Task-T 槽位 schema(含槽位描述与示例)、参数缺失/补齐两份输入、协商文案模板(`{slot}`/`{description}`/`{params}` 占位符)、诊断结果行模板、3×3 API 样例的全部用例数据。Java 代码只保留通用规则(槽位遍历、编号列表拼装、占位符替换),源码中不含任何场景实例化字符串。更换演示场景只需编辑该文件。 + +## 五、运行 + +前置条件: + +- JDK 17+; +- `mvn clean install -DskipTests` 全量构建(需联网); +- 含有效 `A2AT_LLM_API_KEY` 的 `.env` 文件(模板:`sample/negotiation/negotiation.env`)。 + +```bash +# 端到端 demo(默认:fromData 通道 + 流式优先) +java @a2a-t-sample/target/negotiation.javaargs.txt /path/to/.env + +# 自然语言通道 +java @a2a-t-sample/target/negotiation.javaargs.txt --fromText /path/to/.env + +# 强制阻塞端点(覆盖 message:send 场景) +java @a2a-t-sample/target/negotiation.javaargs.txt --no-stream /path/to/.env + +# 3×3 API 生成用例(结构化数据通道 / 自然语言通道) +java @a2a-t-sample/target/fromdata.javaargs.txt /path/to/.env +java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env +``` + +组合 `--fromText` 与 `--no-stream` 可覆盖全部四种通道 × 端点组合。 + +## 六、LLM 依赖说明 + +`A2AT_LLM_API_KEY` 在所有运行模式下必填。fromData 通道仅省去协商报文渲染这一步的 LLM 调用;Task-T 槽位抽取与服务端语义校验始终依赖 LLM。缺失 key 时启动即失败并给出指引。 + +## 七、覆盖矩阵 + +| 维度 | 覆盖 | +|---|---| +| 协商类型 | 信息协商(端到端流程);信息 / 目标 / 可行性(3×3 生成用例) | +| 协商阶段 | 提议 / 接受 / 拒绝 | +| 报文生成通道 | fromData(结构化)/ fromText(自然语言) | +| 传输端点 | message:stream / message:send | +| 参数缺失形态 | 单参数缺失、多参数缺失、值模糊、字段错位(见 scenario.json,可扩展) | + +## 八、已知限制 + +1. `DiagnosisService` 输出的是基于校验参数生成的文本结果,非真实网管诊断;生产场景应替换为 EMS/NMS 北向接口调用。 +2. 服务端在参数缺失分支跳过 `receiveNegotiation`(信息协商 handler 的合规检查会拒绝不完整的 Task-T),缺失项直接取自语义校验错误列表。 +3. 协商状态机的两套 `NegotiationContext`(content 层与 types.model 层)职责不同,不可混用:前者注入渲染内容,后者驱动状态推进。 + +## 九、相关文档 + +- 协商 API 参考:`docs/zh/A2A-T-Negotiation-API-Reference.md`(英文版 `docs/en/`) +- SDK 整体 API 参考:`docs/zh/A2A-T-SDK-API-Reference.md` +- 场景输入定义:`src/main/resources/sample/negotiation/scenario.json` diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/NegotiationDemoApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/NegotiationDemoApp.java index f5c85fe4..8787565b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/NegotiationDemoApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/NegotiationDemoApp.java @@ -73,9 +73,28 @@ public static Path resolveEnvPath(String[] args) { * @return scenario summary */ public static Map runMain(Path envPath, boolean useFromText, Consumer logSink) { + return runMain(envPath, useFromText, true, logSink); + } + + /** + * Runs the 4-message flow with an explicit strategy and transport preference. + * + * @param envPath resolved env path + * @param useFromText true for fromText (LLM), false for fromData (rule-based) + * @param preferStreaming true prefers {@code message:stream} when the server supports it; false forces blocking + * {@code message:send} + * @param logSink log output sink + * @return scenario summary + */ + public static Map runMain( + Path envPath, boolean useFromText, boolean preferStreaming, Consumer logSink) { requireLlmApiKey(envPath); SampleMockLlmInstaller.installLlmLogger(false, "negotiation"); emit(logSink, "[negotiation] strategy: " + (useFromText ? "fromText (LLM)" : "fromData (rule-based)")); + emit( + logSink, + "[negotiation] transport preference: " + + (preferStreaming ? "message:stream (fallback message:send)" : "message:send")); NegotiationStrategy strategy = useFromText ? new FromTextStrategy() : new FromDataStrategy(); NegotiationServerRuntime serverRuntime = new NegotiationServerRuntime(envPath, strategy, logSink); @@ -88,7 +107,7 @@ public static Map runMain(Path envPath, boolean useFromText, Con emit(logSink, "[negotiation] embedded a2a-java HTTP server started"); A2ATClient clientFacade = new A2ATClient(envPath); - NegotiationClient client = new NegotiationClient(clientFacade, strategy, logSink); + NegotiationClient client = new NegotiationClient(clientFacade, strategy, logSink, preferStreaming); try { Map summary = client.runFourMessageFlow(serverRuntime, envPath); emit(logSink, "[negotiation] === Summary ==="); @@ -104,16 +123,21 @@ public static Map runMain(Path envPath, boolean useFromText, Con /** * Entry point. * - * @param args optional {@code --fromText} flag and the path to the {@code .env} file + * @param args optional {@code --fromText} flag, optional {@code --no-stream} flag, and the path to the {@code .env} + * file */ public static void main(String[] args) { boolean useFromText = false; + boolean preferStreaming = true; for (String arg : args) { if ("--fromText".equals(arg)) { useFromText = true; } + if ("--no-stream".equals(arg)) { + preferStreaming = false; + } } - runMain(resolveEnvPath(args), useFromText, System.out::println); + runMain(resolveEnvPath(args), useFromText, preferStreaming, System.out::println); } /** diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java index 27282db6..9b1fa282 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java @@ -6,19 +6,30 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import net.openan.a2at.sample.negotiation.server.NegotiationServerRuntime; import net.openan.a2at.sample.negotiation.shared.DemoConstants; import net.openan.a2at.sample.negotiation.shared.NegotiationMessage; import net.openan.a2at.sample.negotiation.shared.NegotiationStrategy; import net.openan.a2at.sample.negotiation.shared.ScenarioData; +import net.openan.a2at.sample.subscribe_incident.client.flow.SampleStreamTerminalStateDecider; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.runtime.helper.NegotiationPayloadMapper; import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.client.config.ClientConfig; import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfig; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.EventKind; @@ -33,20 +44,28 @@ *

      Flow: * *

        - *
      1. message 1: generate a Task-T prompt with missing params, send via {@code message:send}; + *
      2. message 1: generate a Task-T prompt with missing params and send it to the agent; *
      3. message 2: receive the Negotiation-T request (server detected missing params -> INPUT_REQUIRED); - *
      4. message 3: generate a Task-T prompt with filled params + a Negotiation-T accept, send; + *
      5. message 3: generate a Task-T prompt with filled params + a Negotiation-T accept and send it; *
      6. message 4: receive the diagnosis result artifact (server -> COMPLETED). *
      * + *

      The transport endpoint is selected from the server's declared capability: when the AgentCard advertises + * {@code streaming=true} the request travels as {@code message:stream} and the reply is aggregated from the streamed + * client events; otherwise a blocking {@code message:send} round trip is used. Both paths produce the same reply + * carrier for the flow, so the scenario logic stays endpoint-agnostic. + * * @since 2026-08 */ public final class NegotiationClient { + private static final long STREAM_TIMEOUT_SECONDS = 120; + private final A2ATClient client; private final Consumer logSink; private final NegotiationStrategy strategy; - private RestTransport transport; + private final boolean preferStreaming; + private Client a2aClient; /** * Creates the negotiation client. @@ -56,9 +75,24 @@ public final class NegotiationClient { * @param logSink log output sink */ public NegotiationClient(A2ATClient client, NegotiationStrategy strategy, Consumer logSink) { + this(client, strategy, logSink, true); + } + + /** + * Creates the negotiation client with an explicit endpoint preference. + * + * @param client client facade used for prompt generation and the negotiation state machine + * @param strategy negotiation-message generation strategy (fromData or fromText) + * @param logSink log output sink + * @param preferStreaming true selects {@code message:stream} when the server advertises streaming support; false + * always uses blocking {@code message:send} + */ + public NegotiationClient( + A2ATClient client, NegotiationStrategy strategy, Consumer logSink, boolean preferStreaming) { this.client = client; this.strategy = strategy; this.logSink = logSink; + this.preferStreaming = preferStreaming; } /** @@ -127,7 +161,7 @@ public Map runFourMessageFlow(NegotiationServerRuntime serverRun return summary; } - /** Sends one extension's prompt as an A2A message ({@code message:send}) and returns the raw reply event. */ + /** Sends one extension's prompt as an A2A message and returns the raw reply event. */ private EventKind send( AgentCard agentCard, String extensionUri, MetadataContent content, Map contextMap) { Map metadata = NegotiationMessage.buildMetadata( @@ -135,11 +169,18 @@ private EventKind send( return sendRaw(agentCard, metadata, content.promptText(), contextMap); } + /** + * Sends one A2A request. The endpoint follows the server's declared capability and the client preference: + * {@code message:stream} when the AgentCard advertises streaming and streaming is preferred, otherwise + * {@code message:send}. Streamed events are aggregated into the same reply carrier the blocking path produces + * (final task state or last agent message). + */ private EventKind sendRaw( AgentCard agentCard, Map metadata, String promptText, Map contextMap) { - if (transport == null) { - transport = new RestTransport(agentCard); - } + boolean streaming = preferStreaming + && agentCard.capabilities() != null + && agentCard.capabilities().streaming(); + emit("[client] transport endpoint: " + (streaming ? "message:stream" : "message:send")); Message message = Message.builder() .messageId(UUID.randomUUID().toString()) .role(Message.Role.ROLE_USER) @@ -150,9 +191,99 @@ private EventKind sendRaw( String extensionsHeader = DemoConstants.TASK_T_URI + "," + DemoConstants.NEGOTIATION_T_URI; ClientCallContext callContext = new ClientCallContext(Map.of(), Map.of("A2A-Extensions", extensionsHeader)); try { - return transport.sendMessage(request, callContext); + if (streaming) { + return sendStreaming(agentCard, request, callContext); + } + return sendBlocking(agentCard, request, callContext); } catch (org.a2aproject.sdk.spec.A2AClientException exception) { throw new RuntimeException("a2a-java sendMessage failed: " + exception.getMessage(), exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new RuntimeException("interrupted while waiting for streamed events", exception); + } + } + + /** Blocking {@code message:send} round trip. */ + private EventKind sendBlocking(AgentCard agentCard, MessageSendParams request, ClientCallContext callContext) + throws org.a2aproject.sdk.spec.A2AClientException, InterruptedException { + if (a2aClient == null) { + a2aClient = buildClient(agentCard, false); + } + // Client.sendMessage falls back to the blocking transport when streaming is disabled client-side + StreamAggregator aggregator = new StreamAggregator(); + a2aClient.sendMessage( + request, List.of((event, ignored) -> aggregator.accept(event)), aggregator::fail, callContext); + return aggregator.awaitReply(); + } + + /** Streaming {@code message:stream} request; events are aggregated until a terminal task state arrives. */ + private EventKind sendStreaming(AgentCard agentCard, MessageSendParams request, ClientCallContext callContext) + throws org.a2aproject.sdk.spec.A2AClientException, InterruptedException { + if (a2aClient == null) { + a2aClient = buildClient(agentCard, true); + } + StreamAggregator aggregator = new StreamAggregator(); + a2aClient.sendMessage( + request, List.of((event, ignored) -> aggregator.accept(event)), aggregator::fail, callContext); + return aggregator.awaitReply(); + } + + /** Builds the a2a-java client with the requested streaming mode over the REST transport. */ + private static Client buildClient(AgentCard agentCard, boolean streaming) + throws org.a2aproject.sdk.spec.A2AClientException { + return Client.builder(agentCard) + .withTransport(RestTransport.class, new RestTransportConfig()) + .clientConfig(new ClientConfig.Builder().setStreaming(streaming).build()) + .build(); + } + + /** + * Aggregates streamed client events into one reply carrier: the final task for task-backed replies, or the last + * agent message for message-backed replies. Also serves the blocking path, which emits a single event. + */ + private static final class StreamAggregator { + + private final CountDownLatch done = new CountDownLatch(1); + private final AtomicReference lastTask = new AtomicReference<>(); + private final AtomicReference lastAgentMessage = new AtomicReference<>(); + private final AtomicReference failure = new AtomicReference<>(); + + void accept(ClientEvent event) { + if (event instanceof TaskEvent taskEvent) { + lastTask.set(taskEvent.getTask()); + } else if (event instanceof TaskUpdateEvent updateEvent) { + lastTask.set(updateEvent.getTask()); + } else if (event instanceof MessageEvent messageEvent + && messageEvent.getMessage().role() == Message.Role.ROLE_AGENT) { + lastAgentMessage.set(messageEvent.getMessage()); + } + if (SampleStreamTerminalStateDecider.isTerminal(event)) { + done.countDown(); + } + } + + void fail(Throwable error) { + failure.compareAndSet(null, error); + done.countDown(); + } + + EventKind awaitReply() throws InterruptedException { + if (!done.await(STREAM_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new RuntimeException("timed out waiting for the agent reply"); + } + Throwable error = failure.get(); + if (error != null) { + throw new RuntimeException("a2a-java stream failed: " + error.getMessage(), error); + } + Task task = lastTask.get(); + if (task != null) { + return task; + } + Message message = lastAgentMessage.get(); + if (message != null) { + return message; + } + throw new IllegalStateException("a2a-java reply carried neither a task nor an agent message"); } } @@ -202,8 +333,8 @@ private static String extractDiagnosis(EventKind reply) { /** Releases the underlying HTTP transport. */ public void close() { - if (transport != null) { - transport.close(); + if (a2aClient != null) { + a2aClient.close(); } } From 8ae2fd7be1a134afba4565f7f3c6fb924ca8aacc Mon Sep 17 00:00:00 2001 From: gcw_mr3RVhmf Date: Sat, 22 Aug 2026 15:01:25 +0800 Subject: [PATCH 116/295] feat(sample): add negotiation qwen evaluation Signed-off-by: gcw_mr3RVhmf --- .../a2at/sdk/core/model/A2ATConfigKeys.java | 7 +- .../openan/a2at/sdk/llm/LLMClientConfig.java | 2 + .../openan/a2at/sdk/llm/LLMConfigLoader.java | 17 + .../a2at/sdk/llm/providers/OpenAIClient.java | 4 + .../a2at/sdk/llm/LLMClientConfigTest.java | 2 + .../a2at/sdk/llm/LLMClientFactoryTest.java | 2 +- .../a2at/sdk/llm/LLMConfigLoaderTest.java | 17 + .../sdk/llm/providers/OpenAIClientTest.java | 4 +- .../NEGOTIATION_API_EXAMPLES.zh-CN.md | 440 +++++++++++++++++ .../NEGOTIATION_QWEN_EVALUATION.zh-CN.md | 42 ++ .../NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md | 464 ++++++++++++++++++ a2a-t-sample/README.zh-CN.md | 87 +++- a2a-t-sample/pom.xml | 12 + .../client/NegotiationClientMain.java | 84 ++++ .../evaluation/NegotiationEvaluationCase.java | 8 + .../NegotiationEvaluationCaseLoader.java | 34 ++ .../NegotiationEvaluationProcessLogger.java | 44 ++ .../NegotiationQwenEvaluationMain.java | 280 +++++++++++ .../server/NegotiationAgentExecutor.java | 80 +++ .../server/NegotiationServerMain.java | 76 +++ .../shared/InformationNegotiationSchemas.java | 75 +++ .../shared/NegotiationAgentCard.java | 37 ++ .../shared/NegotiationDecision.java | 11 + .../shared/NegotiationMetadataReader.java | 42 ++ .../shared/NegotiationSampleEnvironment.java | 57 +++ .../shared/NegotiationSampleFlow.java | 66 +++ .../shared/NegotiationScenario.java | 5 + .../shared/NegotiationScenarioLoader.java | 43 ++ .../shared/mock/NegotiationMockLLMClient.java | 113 +++++ .../mock/NegotiationMockLlmInstaller.java | 18 + .../evaluation/cases.json | 102 ++++ .../evaluation/qwen.env.example | 13 + .../scenario.json | 6 + .../InformationNegotiationSchemasTest.java | 53 ++ .../NegotiationEvaluationCaseLoaderTest.java | 38 ++ .../NegotiationMetadataReaderTest.java | 48 ++ .../NegotiationSampleFlowTest.java | 92 ++++ .../api/NegotiationV3ApiSurfaceTest.java | 22 +- 38 files changed, 2526 insertions(+), 21 deletions(-) create mode 100644 a2a-t-sample/NEGOTIATION_API_EXAMPLES.zh-CN.md create mode 100644 a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md create mode 100644 a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLogger.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationServerMain.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/InformationNegotiationSchemas.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationAgentCard.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationDecision.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleEnvironment.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenario.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenarioLoader.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLLMClient.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLlmInstaller.java create mode 100644 a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json create mode 100644 a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/qwen.env.example create mode 100644 a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/scenario.json create mode 100644 a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/InformationNegotiationSchemasTest.java create mode 100644 a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java create mode 100644 a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java create mode 100644 a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java index da4f5a72..d07c0b38 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java @@ -51,8 +51,11 @@ public static final class Llm { /** Default sampling temperature. */ public static final String TEMPERATURE = "A2AT_LLM_TEMPERATURE"; - /** Request timeout in seconds. */ - public static final String TIMEOUT_SECONDS = "A2AT_LLM_TIMEOUT_SECONDS"; + /** Request timeout in seconds. */ + public static final String TIMEOUT_SECONDS = "A2AT_LLM_TIMEOUT_SECONDS"; + + /** Disables use of the JVM or operating-system HTTP proxy for the LLM request. */ + public static final String DISABLE_SYSTEM_PROXY = "A2AT_LLM_DISABLE_SYSTEM_PROXY"; /** Maximum total number of tracked sessions. */ public static final String SESSION_MAX_TOTAL = "A2AT_LLM_SESSION_MAX_TOTAL"; diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java index dfec60e5..57d1851e 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java @@ -11,6 +11,7 @@ * @param maxTokens optional default max tokens * @param temperature optional default temperature * @param timeoutSeconds optional provider timeout in seconds + * @param disableSystemProxy whether to bypass the JVM or operating-system HTTP proxy * @param sessionMaxTotal reserved total session limit * @param sessionMaxPerProvider reserved per-provider session limit * @param reasoningEffort optional reasoning effort for reasoning models (none/minimal/low/medium/high/xhigh); null @@ -26,6 +27,7 @@ public record LLMClientConfig( Integer maxTokens, Double temperature, Double timeoutSeconds, + boolean disableSystemProxy, int sessionMaxTotal, int sessionMaxPerProvider, String reasoningEffort) {} diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java index a45c333a..8dbd35fa 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java @@ -62,6 +62,8 @@ public static LLMClientConfig load(Path envPath) { OptionalDouble temperature = parseOptionalDouble(values.get("A2AT_LLM_TEMPERATURE"), "A2AT_LLM_TEMPERATURE"); OptionalDouble timeoutSeconds = parseOptionalDouble(values.get("A2AT_LLM_TIMEOUT_SECONDS"), "A2AT_LLM_TIMEOUT_SECONDS"); + boolean disableSystemProxy = parseOptionalBoolean( + values.get("A2AT_LLM_DISABLE_SYSTEM_PROXY"), "A2AT_LLM_DISABLE_SYSTEM_PROXY", false); return new LLMClientConfig( provider, @@ -72,6 +74,7 @@ public static LLMClientConfig load(Path envPath) { maxTokens.isPresent() ? maxTokens.getAsInt() : null, temperature.isPresent() ? temperature.getAsDouble() : null, timeoutSeconds.isPresent() ? timeoutSeconds.getAsDouble() : null, + disableSystemProxy, sessionMaxTotal, sessionMaxPerProvider, parseReasoningEffort(values.get("A2AT_LLM_REASONING_EFFORT"))); @@ -115,6 +118,20 @@ private static OptionalDouble parseOptionalDouble(String rawValue, String key) { } } + private static boolean parseOptionalBoolean(String rawValue, String key, boolean defaultValue) { + Optional value = optional(rawValue); + if (!value.isPresent()) { + return defaultValue; + } + if ("true".equalsIgnoreCase(value.orElseThrow())) { + return true; + } + if ("false".equalsIgnoreCase(value.orElseThrow())) { + return false; + } + throw new LLMConfigError(key + " must be true or false"); + } + private static int parseBoundedInt(String rawValue, String key, int defaultValue, int maxValue) { Optional value = optional(rawValue); if (!value.isPresent()) { diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java index 47a1faea..c5f2f3f4 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java @@ -10,6 +10,7 @@ import com.openai.models.chat.completions.ChatCompletionMessageParam; import com.openai.models.chat.completions.ChatCompletionSystemMessageParam; import com.openai.models.chat.completions.ChatCompletionUserMessageParam; +import java.net.Proxy; import java.time.Duration; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -196,6 +197,9 @@ private synchronized com.openai.client.OpenAIClient sdkClient(LLMClientConfig ru if (sdkClient == null) { OpenAIOkHttpClient.Builder builder = OpenAIOkHttpClient.builder().apiKey(runtimeConfig.apiKey()).baseUrl(runtimeConfig.baseUrl()); + if (runtimeConfig.disableSystemProxy()) { + builder.proxy(Proxy.NO_PROXY); + } if (runtimeConfig.timeoutSeconds() != null && runtimeConfig.timeoutSeconds() > 0.0d) { builder.timeout(Duration.ofMillis(Math.max(1L, Math.round(runtimeConfig.timeoutSeconds() * 1000.0d)))); } diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java index bc7a32cf..9c1c83e6 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java @@ -17,6 +17,7 @@ void storesResolvedDefaultLlmClientConfiguration() { 1024, 0.2d, 15.5d, + true, 300, 100, "none"); @@ -29,6 +30,7 @@ void storesResolvedDefaultLlmClientConfiguration() { assertEquals(1024, config.maxTokens()); assertEquals(0.2d, config.temperature()); assertEquals(15.5d, config.timeoutSeconds()); + assertEquals(true, config.disableSystemProxy()); assertEquals(300, config.sessionMaxTotal()); assertEquals(100, config.sessionMaxPerProvider()); assertEquals("none", config.reasoningEffort()); diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java index 49db4165..1facff5a 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java @@ -82,7 +82,7 @@ void rejectsProviderClassWithoutPublicConfigConstructor() { } private static LLMClientConfig buildConfig(String provider) { - return new LLMClientConfig(provider, "test-model", "sk-test", null, 10, null, null, null, 300, 100, null); + return new LLMClientConfig(provider, "test-model", "sk-test", null, 10, null, null, null, false, 300, 100, null); } public static final class CustomClient implements LLMClient { diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java index 61debebf..27935693 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java @@ -35,6 +35,7 @@ void loadsRequiredValuesAndDefaultsFromEnvFile() throws IOException { assertNull(config.maxTokens()); assertNull(config.temperature()); assertNull(config.timeoutSeconds()); + assertEquals(false, config.disableSystemProxy()); assertEquals(300, config.sessionMaxTotal()); assertEquals(100, config.sessionMaxPerProvider()); } @@ -50,6 +51,7 @@ void loadsOptionalValuesFromEnvFile() throws IOException { A2AT_LLM_MAX_TOKENS=2048 A2AT_LLM_TEMPERATURE=0.25 A2AT_LLM_TIMEOUT_SECONDS=45.5 + A2AT_LLM_DISABLE_SYSTEM_PROXY=true A2AT_LLM_HISTORY_WINDOW=20 A2AT_LLM_SESSION_MAX_TOTAL=500 A2AT_LLM_SESSION_MAX_PER_PROVIDER=50 @@ -61,6 +63,7 @@ void loadsOptionalValuesFromEnvFile() throws IOException { assertEquals(2048, config.maxTokens()); assertEquals(0.25d, config.temperature()); assertEquals(45.5d, config.timeoutSeconds()); + assertEquals(true, config.disableSystemProxy()); assertEquals(20, config.historyWindow()); assertEquals(500, config.sessionMaxTotal()); assertEquals(50, config.sessionMaxPerProvider()); @@ -96,6 +99,20 @@ void rejectsInvalidOptionalNumber() throws IOException { assertTrue(error.getMessage().contains("integer")); } + @Test + void rejectsInvalidDisableSystemProxyValue() throws IOException { + Path envFile = writeEnv(""" + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=gpt-4o-mini + A2AT_LLM_API_KEY=sk-test + A2AT_LLM_DISABLE_SYSTEM_PROXY=maybe + """); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMConfigLoader.load(envFile)); + + assertTrue(error.getMessage().contains("A2AT_LLM_DISABLE_SYSTEM_PROXY")); + } + @Test void rejectsOutOfRangeReservedValues() throws IOException { Path envFile = writeEnv( diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java index cec19226..c572d8a4 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java @@ -98,7 +98,7 @@ void structuredBuildsJsonModePayloadAndParsesJsonObjectResponse() { void structuredFallsBackToConfigTemperatureAndMaxTokens() { AtomicReference capturedParams = new AtomicReference<>(); LLMClientConfig config = new LLMClientConfig( - "openai", "gpt-4o-mini", "sk-test", "https://api.example.test/v1", 10, 128, 0.4d, null, 300, 100, null); + "openai", "gpt-4o-mini", "sk-test", "https://api.example.test/v1", 10, 128, 0.4d, null, false, 300, 100, null); OpenAIClient client = new OpenAIClient(config, (runtimeConfig, requestParams) -> { capturedParams.set(requestParams); return chatCompletion("{}"); @@ -203,7 +203,7 @@ void concurrentStructuredCallsAllSucceed() throws Exception { } private static LLMClientConfig config(String apiKey, String baseUrl) { - return new LLMClientConfig("openai", "gpt-4o-mini", apiKey, baseUrl, 10, null, null, null, 300, 100, null); + return new LLMClientConfig("openai", "gpt-4o-mini", apiKey, baseUrl, 10, null, null, null, false, 300, 100, null); } private static ChatCompletion chatCompletion(String content) { diff --git a/a2a-t-sample/NEGOTIATION_API_EXAMPLES.zh-CN.md b/a2a-t-sample/NEGOTIATION_API_EXAMPLES.zh-CN.md new file mode 100644 index 00000000..4bd2a2fc --- /dev/null +++ b/a2a-t-sample/NEGOTIATION_API_EXAMPLES.zh-CN.md @@ -0,0 +1,440 @@ +# Negotiation-T 六个接口示例 + +本文使用“传输专线业务投诉诊断”场景,展示 3 个自然语言生成接口和 3 个校验/提参接口的调用方法。 + +完整链路分为两条: + +- 接受:Propose 生成 → Propose 校验/提参 → Accept 生成 → Accept 校验/提参。 +- 拒绝:Propose 生成 → Propose 校验/提参 → Reject 生成 → Reject 校验/提参。 + +Accept 与 Reject 是互斥结果。单次协商调用 4 个接口;分别运行接受和拒绝两条链路,覆盖全部 6 个接口。 + +## 1. 公共对象 + +以下示例假定 `A2ATClient` 和 `A2ATServer` 已按照 Sample 的环境配置完成初始化: + +```java +A2ATClient client = ...; +A2ATServer server = ...; +``` + +公共模板 URI 和协商上下文: + +```java +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; + +TemplateUri proposeTemplateUri = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; +TemplateUri endingTemplateUri = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + +NegotiationContext context = new NegotiationContext( + "5dd96c7b-2f19-4885-93d7-31e8bffac25c", + 1, + 3); +``` + +本场景的业务 Schema 已由 Sample 提供: + +```java +Map proposeSchema = InformationNegotiationSchemas.propose(); +Map acceptSchema = InformationNegotiationSchemas.accept(); +Map rejectSchema = InformationNegotiationSchemas.reject(); +``` + +这三个方法只是复用入口,实际传入接口的 Schema 如下。 + +### 1.1 Propose Schema 示例 + +Propose 阶段需要从协商请求中提取“希望对端补充什么信息,以及信息应满足什么要求”: + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "access_port_name": { + "type": "string", + "description": "请求的接入端口信息及可接受的物理或逻辑端口格式" + }, + "complaint_category": { + "type": "string", + "description": "请求的投诉分类及允许的专线投诉类型" + } + }, + "required": ["access_port_name", "complaint_category"] +} +``` + +对应当前场景的预期提取值: + +```json +{ + "access_port_name": "物理端口或逻辑端口名称", + "complaint_category": "专线中断或专线质差" +} +``` + +### 1.2 Accept Schema 示例 + +Accept 阶段需要提取对端实际补充的端口和投诉分类: + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "access_port_name": { + "type": "string", + "description": "用于专线诊断的物理或逻辑接入端口名称" + }, + "complaint_category": { + "type": "string", + "description": "专线投诉分类", + "enum": ["专线中断", "专线质差"] + } + }, + "required": ["access_port_name", "complaint_category"] +} +``` + +对应当前场景的预期提取值: + +```json +{ + "access_port_name": "P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)", + "complaint_category": "专线质差" +} +``` + +### 1.3 Reject Schema 示例 + +Reject 消息没有端口和投诉分类的实际值,因此提取拒绝原因: + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "rejection_reason": { + "type": "string", + "description": "无法补充专线投诉信息的原因" + } + }, + "required": ["rejection_reason"] +} +``` + +对应当前场景的预期提取值: + +```json +{ + "rejection_reason": "当前账号没有资源系统查询权限" +} +``` + +### 1.4 等价的 Java Map 示例 + +以 Propose Schema 为例,不通过 Sample 工厂类时,可以直接这样构造: + +```java +Map proposeSchema = Map.of( + "type", "object", + "additionalProperties", false, + "properties", Map.of( + "access_port_name", Map.of( + "type", "string", + "description", "请求的接入端口信息及可接受的物理或逻辑端口格式"), + "complaint_category", Map.of( + "type", "string", + "description", "请求的投诉分类及允许的专线投诉类型")), + "required", List.of("access_port_name", "complaint_category")); + +FilledParamData proposeData = server.validateAndFillingProposeData( + receivedProposePrompt, + proposeSchema, + proposeTemplateUri); +``` + +Sample 中的 `InformationNegotiationSchemas` 将上述结构集中管理,避免在每个调用点重复构造。 + +三个 Schema 的提取目标汇总如下: + +| 阶段 | 业务字段 | +| --- | --- | +| Propose | `access_port_name`、`complaint_category` | +| Accept | `access_port_name`、`complaint_category` | +| Reject | `rejection_reason` | + +`id`、`round`、`maxRounds` 由 SDK 从 Negotiation-T Prompt 中确定性提取,不需要写入业务 Schema。 + +## 2. Propose 生成接口 + +接口: + +```java +MetadataContent generateNegotiationProposePromptFromText( + String text, + NegotiationContext context, + TemplateUri templateUri) +``` + +调用示例: + +```java +String proposeText = "当前专线业务投诉诊断缺少接入端口名称和投诉分类," + + "请发起信息协商,请求对端补充这两项信息。" + + "接入端口名称需要提供物理端口或逻辑端口名称," + + "投诉分类应为专线中断或专线质差。"; + +MetadataContent propose = client.generateNegotiationProposePromptFromText( + proposeText, + context, + proposeTemplateUri); +``` + +`MetadataContent` 中包含: + +- `templateUri()`:`Negotiation-T/information-negotiation/propose/v1`。 +- `promptText()`:SDK 根据自然语言、上下文和内置模板生成的完整 Propose Prompt。 +- `extensionUri()`:Negotiation-T 的 TMF 扩展 URI。 +- `buildMetadataContent()`:用于放入 A2A `Message.metadata` 的两个键值。 + +发送端应使用以下结果作为线上消息 metadata: + +```java +Map messageMetadata = propose.buildMetadataContent(); +``` + +metadata 的结构为: + +```json +{ + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1": "<完整 Propose Prompt>", + "templateUri": "Negotiation-T/information-negotiation/propose/v1" +} +``` + +## 3. Propose 校验和提参接口 + +接口: + +```java +FilledParamData validateAndFillingProposeData( + String prompt, + Map schema, + TemplateUri templateUri) +``` + +接收端应从 A2A `Message.metadata` 读取 Prompt,不能使用原始自然语言 `proposeText` 代替: + +```java +String receivedProposePrompt = NegotiationMetadataReader.readPrompt( + messageMetadata, + proposeTemplateUri); + +FilledParamData proposeData = server.validateAndFillingProposeData( + receivedProposePrompt, + proposeSchema, + proposeTemplateUri); + +Map proposeParams = proposeData.data(); +``` + +本场景在 Mock 模式下的预期结果: + +```json +{ + "id": "5dd96c7b-2f19-4885-93d7-31e8bffac25c", + "round": 1, + "maxRounds": 3, + "access_port_name": "物理端口或逻辑端口名称", + "complaint_category": "专线中断或专线质差" +} +``` + +服务端使用校验结果中的上下文字段构造回复上下文,不能生成新的协商 ID: + +```java +NegotiationContext responseContext = new NegotiationContext( + (String) proposeParams.get("id"), + ((Number) proposeParams.get("round")).intValue(), + ((Number) proposeParams.get("maxRounds")).intValue()); +``` + +## 4. Accept 生成接口 + +接口: + +```java +MetadataContent generateNegotiationAcceptPromptFromText( + String text, + NegotiationContext context, + TemplateUri templateUri) +``` + +调用示例: + +```java +String acceptText = "接受本次信息协商。" + + "接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)," + + "投诉分类为专线质差。"; + +MetadataContent accept = server.generateNegotiationAcceptPromptFromText( + acceptText, + responseContext, + endingTemplateUri); + +Map artifactMetadata = accept.buildMetadataContent(); +``` + +该 metadata 应放入返回给发起方的 A2A `Artifact.metadata`。其中 `templateUri` 为: + +```text +Negotiation-T/information-negotiation/accept-reject/v1 +``` + +## 5. Accept 校验和提参接口 + +接口: + +```java +FilledParamData validateAndFillingAcceptData( + String prompt, + Map schema, + TemplateUri templateUri) +``` + +调用示例: + +```java +String receivedAcceptPrompt = NegotiationMetadataReader.readPrompt( + artifactMetadata, + endingTemplateUri); + +FilledParamData acceptData = client.validateAndFillingAcceptData( + receivedAcceptPrompt, + acceptSchema, + endingTemplateUri); + +Map acceptParams = acceptData.data(); +``` + +本场景在 Mock 模式下的预期结果: + +```json +{ + "id": "5dd96c7b-2f19-4885-93d7-31e8bffac25c", + "round": 1, + "maxRounds": 3, + "access_port_name": "P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)", + "complaint_category": "专线质差" +} +``` + +## 6. Reject 生成接口 + +接口: + +```java +MetadataContent generateNegotiationRejectPromptFromText( + String text, + NegotiationContext context, + TemplateUri templateUri) +``` + +Reject 与 Accept 使用同一个 `accept-reject` 模板 URI: + +```java +String rejectText = "拒绝本次信息协商。" + + "当前账号没有资源系统查询权限," + + "无法取得接入端口名称和投诉分类。"; + +MetadataContent reject = server.generateNegotiationRejectPromptFromText( + rejectText, + responseContext, + endingTemplateUri); + +Map artifactMetadata = reject.buildMetadataContent(); +``` + +## 7. Reject 校验和提参接口 + +接口: + +```java +FilledParamData validateAndFillingRejectData( + String prompt, + Map schema, + TemplateUri templateUri) +``` + +调用示例: + +```java +String receivedRejectPrompt = NegotiationMetadataReader.readPrompt( + artifactMetadata, + endingTemplateUri); + +FilledParamData rejectData = client.validateAndFillingRejectData( + receivedRejectPrompt, + rejectSchema, + endingTemplateUri); + +Map rejectParams = rejectData.data(); +``` + +本场景在 Mock 模式下的预期结果: + +```json +{ + "id": "5dd96c7b-2f19-4885-93d7-31e8bffac25c", + "round": 1, + "maxRounds": 3, + "rejection_reason": "当前账号没有资源系统查询权限" +} +``` + +## 8. 两条完整调用链 + +接受分支: + +```java +MetadataContent propose = client.generateNegotiationProposePromptFromText( + proposeText, context, proposeTemplateUri); +String proposePrompt = NegotiationMetadataReader.readPrompt( + propose.buildMetadataContent(), proposeTemplateUri); +FilledParamData proposeData = server.validateAndFillingProposeData( + proposePrompt, proposeSchema, proposeTemplateUri); + +NegotiationContext responseContext = new NegotiationContext( + (String) proposeData.data().get("id"), + ((Number) proposeData.data().get("round")).intValue(), + ((Number) proposeData.data().get("maxRounds")).intValue()); + +MetadataContent accept = server.generateNegotiationAcceptPromptFromText( + acceptText, responseContext, endingTemplateUri); +String acceptPrompt = NegotiationMetadataReader.readPrompt( + accept.buildMetadataContent(), endingTemplateUri); +FilledParamData acceptData = client.validateAndFillingAcceptData( + acceptPrompt, acceptSchema, endingTemplateUri); +``` + +拒绝分支的前 3 步相同,结束阶段替换为: + +```java +MetadataContent reject = server.generateNegotiationRejectPromptFromText( + rejectText, responseContext, endingTemplateUri); +String rejectPrompt = NegotiationMetadataReader.readPrompt( + reject.buildMetadataContent(), endingTemplateUri); +FilledParamData rejectData = client.validateAndFillingRejectData( + rejectPrompt, rejectSchema, endingTemplateUri); +``` + +## 9. 调用时需要核对的约束 + +- 生成接口的 `text` 是业务自然语言;校验接口的 `prompt` 是生成接口返回的完整 `promptText`。 +- Propose 使用 `information-negotiation/propose`;Accept 和 Reject 使用 `information-negotiation/accept-reject`。 +- 校验接口的 `TemplateUri` 应与 metadata 中的 `templateUri` 字符串一致;需要比较时使用 `templateUri.uri()`。 +- 回复必须沿用 Propose 中的 `id`、`round` 和 `maxRounds`。 +- 业务 Schema 跟具体 OSS 场景绑定,本示例的字段只适用于当前专线投诉场景。 +- Mock 模式的结果是确定的;Qwen3-32B 模式下自然语言表述可能变化,但结构、上下文和 Schema 约束应保持一致。 diff --git a/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md b/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md new file mode 100644 index 00000000..879562d3 --- /dev/null +++ b/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md @@ -0,0 +1,42 @@ +# Negotiation-T Qwen3-32B 批量验证 + +此入口验证传输专线投诉诊断场景的 3 个自然语言生成接口:Propose、Accept、Reject。用例集固定为 100 条,由人工标注期望业务字段;每条用例还会调用对应的校验/提参接口,检查生成 Prompt 能否被 SDK 解析为期望字段并保留协商上下文。 + +自动结果衡量的是“生成 Prompt → 校验提参”的一致性,不能直接等同于 LLM 语义准确率。报告会保存原始输入、生成 Prompt、提取结果和错误信息。应人工复核全部失败用例,并在每个阶段从通过用例中至少抽查 10 条。 + +## 用例集 + +资源文件是 [cases.json](src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json),包含: + +- Propose 34 条; +- Accept 33 条; +- Reject 33 条。 + +覆盖完整表述、短句、同义改写、口语化表达、中英文混合、噪声信息、字段顺序变化、换行和业务上下文等类型。每条用例的 `expected` 是人工 golden data;修改业务 Schema 或场景语义时,需要同步复核该文件。 + +## 在目标网络执行 + +当前本机网络不能访问 Qwen 服务。进入东莞绿区 70 网段后,直接使用仓库提供的 [qwen.env](src/main/resources/sample/private-line-complaint-negotiation/qwen.env)。其中 `A2AT_LLM_DISABLE_SYSTEM_PROXY=true` 会绕过公司黄区的系统代理;该开关仅作用于此 LLM Client,不影响其他网络请求。不要修改默认的 Mock `client.env` 和 `server.env`。 + +先打包: + +```powershell +mvn -pl a2a-t-sample -am -DskipTests package +``` + +再运行。参数依次为 env 文件、汇总报告输出路径、可选的过程日志输出路径;不传第三个参数时,会在报告同目录生成同名的 `-process.jsonl` 文件: + +```powershell +java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` + a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ` + a2a-t-sample/target/negotiation-qwen-report.json ` + a2a-t-sample/target/negotiation-qwen-process.jsonl +``` + +汇总报告会输出总数、通过数和 `automatic_consistency_rate`,并逐条保存 `input`、`prompt`、`expected`、`actual`、耗时及错误信息。过程日志采用 JSONL,每一行对应一次 SDK 调用;其中包含 `run_id`、用例编号、阶段(`generate` 或 `validate_and_fill`)、请求入参、模型/SDK 返回值、耗时,以及异常类、异常消息、cause 链和截断后的调用栈。 + +定位时先按 `run_id` 和 `case_id` 将同一用例的两行关联:`generate` 已失败时,重点检查 Sample 传入的自然语言、上下文和模板 URI,以及 SDK 的生成接口异常;生成成功但 `validate_and_fill` 失败时,直接核对生成 Prompt、业务 Schema 和 SDK 校验异常。这样无需重跑模型即可判断问题发生在流程的哪个边界。过程日志不记录 API Key。 + +执行过程会产生约 200 次模型调用(每个用例一次生成、一次校验/提参);以 Qwen 响应时延为准设置 `A2AT_LLM_TIMEOUT_SECONDS`。 + +若目标网络的公司代理干扰访问,按 `C:\cnafan\git\design\Qwen3-32B模型调用指导.md` 的说明处理网络代理。Java SDK 当前 OpenAI-compatible Client 未额外加入 Qwen 专属逻辑;先保留实际失败报文,再判断是否需要在 SDK 层适配 `enable_thinking=false`。 diff --git a/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md b/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md new file mode 100644 index 00000000..10ef4d19 --- /dev/null +++ b/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md @@ -0,0 +1,464 @@ +# 传输专线投诉信息协商 Sample 开发说明 + +## 1. 文档用途 + +本文用于指导后续 Code Agent 在 `a2a-t-sample` 中实现“传输专线业务投诉诊断”信息协商示例。实现应完整验证以下 6 个 Negotiation-T API,并提供可离线运行的 Mock 模式和可切换到 Qwen3-32B 的真实模型模式。 + +本文已按仓库提交 `52e7d29` 的公开 API 完成适配。后续开始验证前仍应同步 `main`,再次确认 API 签名和现有 Sample 结构;如接口发生变化,以最新公开 API 为准,并同步更新本文和 Sample。 + +## 2. 范围和边界 + +### 2.1 本次范围 + +信息协商从发起协商开始,到对端校验和提取协商信息,再到接受或拒绝,最后由发起方校验和提取协商结果。 + +需要实现两条可独立运行的分支: + +- Accept:Propose → 校验/提参 → Accept → 校验/提参。 +- Reject:Propose → 校验/提参 → Reject → 校验/提参。 + +一次运行只会进入 Accept 或 Reject 分支,覆盖 4 个 API;两条分支均运行成功后,才算完成 6 个 API 的验证。 + +### 2.2 不在本次范围内 + +- 不生成或校验 Task-T Prompt。 +- 不实现投诉诊断业务逻辑、资源查询或根因分析。 +- 不实现多轮协商状态机;第一版固定为第 1 轮,并在同一轮结束。 +- 不修改 SDK 内置 Negotiation-T 模板。 +- 不修改 `prompt_resources/scenarios/*/scenarios.json` 和 Task-T 的 `slot.json`。 + +现有资源中已经包含 `private-line-complaint` 的 Task-T 场景和槽位定义,但本 Sample 调用的 6 个 Negotiation-T API 不会走 Task-T 的场景识别、槽位提取链路。Sample 自身可以增加一个 `scenario.json` 保存演示输入,它与 SDK 的 `scenarios.json`、`slot.json` 用途不同。 + +## 3. 业务角色和链路 + +建议的 Sample 角色映射如下: + +- Client:SPN OMC 侧,发起信息协商,请求补齐诊断所需信息。 +- Server:传输工作台侧,校验请求并选择接受或拒绝。 + +角色映射仅服务于 Sample 的 HTTP 请求方向。Negotiation-T 的 Client 和 Server 门面都提供相同的 6 个接口,业务接入时可以按实际 Agent 角色调整调用方。 + +```mermaid +sequenceDiagram + participant C as Client / SPN OMC + participant S as Server / 传输工作台 + + C->>C: generateNegotiationProposePromptFromText + C->>S: A2A Message metadata 携带 Propose + S->>S: validateAndFillingProposeData + alt Accept 分支 + S->>S: generateNegotiationAcceptPromptFromText + S-->>C: A2A Artifact metadata 携带 Accept + C->>C: validateAndFillingAcceptData + else Reject 分支 + S->>S: generateNegotiationRejectPromptFromText + S-->>C: A2A Artifact metadata 携带 Reject + C->>C: validateAndFillingRejectData + end +``` + +服务端应使用 Propose 校验结果中的 `id`、`round` 和 `maxRounds` 重建 `NegotiationContext`,确保回复和请求属于同一协商会话、同一轮次。服务端不能自行生成新的协商 ID。 + +## 4. 待验证 API + +| 阶段 | 调用方 | API | Template URI | 结果 | +| --- | --- | --- | --- | --- | +| 生成请求 | Client | `generateNegotiationProposePromptFromText` | `Negotiation-T/information-negotiation/propose/v1` | `MetadataContent` | +| 校验请求 | Server | `validateAndFillingProposeData` | 同上 | `FilledParamData` | +| 生成接受 | Server | `generateNegotiationAcceptPromptFromText` | `Negotiation-T/information-negotiation/accept-reject/v1` | `MetadataContent` | +| 校验接受 | Client | `validateAndFillingAcceptData` | 同上 | `FilledParamData` | +| 生成拒绝 | Server | `generateNegotiationRejectPromptFromText` | `Negotiation-T/information-negotiation/accept-reject/v1` | `MetadataContent` | +| 校验拒绝 | Client | `validateAndFillingRejectData` | 同上 | `FilledParamData` | + +关键类型: + +```java +net.openan.a2at.sdk.client.A2ATClient +net.openan.a2at.sdk.server.A2ATServer +net.openan.a2at.sdk.negotiation.content.NegotiationContext +net.openan.a2at.sdk.core.model.MetadataContent +net.openan.a2at.sdk.core.model.FilledParamData +net.openan.a2at.sdk.core.model.TemplateUri +net.openan.a2at.sdk.core.model.StandardTemplates +``` + +注意 `FilledParamData` 当前位于 `a2a-t-core`,包名为 `net.openan.a2at.sdk.core.model`。 + +6 个接口的最后一个参数均为 `TemplateUri`。Sample 使用 +`StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE` 和 +`StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT`,避免手写 URI;线上 metadata 仍承载 +`TemplateUri.uri()` 返回的字符串。 + +## 5. 场景输入 + +建议将场景文本集中放入 Sample 资源 `sample/private-line-complaint-negotiation/scenario.json`,避免散落在 Java 常量中。第一版固定使用中文 `zh-CN`。 + +建议内容: + +```json +{ + "scenario": "传输专线业务投诉诊断的信息协商", + "propose_text": "当前专线业务投诉诊断缺少接入端口名称和投诉分类,请发起信息协商,请求对端补充这两项信息。接入端口名称需要提供物理端口或逻辑端口名称,投诉分类应为专线中断或专线质差。", + "accept_text": "接受本次信息协商。接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类为专线质差。", + "reject_text": "拒绝本次信息协商。当前账号没有资源系统查询权限,无法取得接入端口名称和投诉分类。" +} +``` + +Client 创建上下文: + +```java +NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3); +``` + +第一版不调用 `nextRound()`。Accept 和 Reject 都沿用 Propose 中的 `round=1`。 + +## 6. 参数 Schema:场景业务 Schema(调整后的方案 B) + +`validateAndFilling...` 接口的 `schema` 描述调用方希望提取的业务参数,由具体 OSS 场景自行定义。本 Sample 因此直接定义“传输专线业务投诉诊断”的字段。协商模板自身的 `items`、`relationship`、`conclusion` 由 SDK 内部负责识别,不放入调用方 Schema。 + +### 6.1 Propose Schema + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "access_port_name": { + "type": "string", + "description": "请求的接入端口信息及可接受的物理或逻辑端口格式" + }, + "complaint_category": { + "type": "string", + "description": "请求的投诉分类及允许的专线投诉类型" + } + }, + "required": ["access_port_name", "complaint_category"] +} +``` + +Propose 阶段提取的是待补充字段及其约束,因此值可以是“物理端口或逻辑端口名称”“专线中断或专线质差”这类要求描述。 + +### 6.2 Accept Schema + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "access_port_name": { + "type": "string", + "description": "用于专线诊断的物理或逻辑接入端口名称" + }, + "complaint_category": { + "type": "string", + "description": "专线投诉分类", + "enum": ["专线中断", "专线质差"] + } + }, + "required": ["access_port_name", "complaint_category"] +} +``` + +### 6.3 Reject Schema + +Reject 消息无法提供接入端口和投诉分类,因此单独提取拒绝原因: + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "rejection_reason": { + "type": "string", + "description": "无法补充专线投诉信息的原因" + } + }, + "required": ["rejection_reason"] +} +``` + +Sample 内部 Schema 工厂通过 `LinkedHashMap` 构建并返回不可变 Map,Propose、Accept、Reject 各保留一个共享常量。不要复制 SDK 的私有方法,也不要通过反射调用 `NegotiationJsonSchemaBuilder` 的内部 Schema。 + +`id`、`round`、`maxRounds` 不放入调用方 Schema。SDK 先通过确定性规则读取这 3 个上下文字段,再与 LLM 按调用方 Schema 提取的参数合并。预期结果为: + +- Propose:`id`、`round`、`maxRounds`、`access_port_name`、`complaint_category`。 +- Accept:`id`、`round`、`maxRounds`、`access_port_name`、`complaint_category`。 +- Reject:`id`、`round`、`maxRounds`、`rejection_reason`。 + +不使用 `null` Schema。空 Map 虽然可用于只验证上下文字段,但无法充分验证本任务要求的“校验和提参”。 + +## 7. A2A 消息承载约定 + +生成接口返回的 `MetadataContent` 是线上的唯一数据源。发送方必须使用 `buildMetadataContent()` 构建消息或 Artifact metadata,不能手工拼装 Negotiation-T Prompt。 + +当前生成结果包含两个 metadata 键: + +- `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`:`promptText`。 +- `templateUri`:`MetadataContent.templateUri()` 返回的 URI 字符串。 + +请求头 `A2A-Extensions` 使用 `MetadataContent.extensionUri()`。新消息使用不带 `/NL/` 的规范 URI;接收侧如复用通用读取逻辑,可以兼容读取历史 `/Negotiation-T/NL/v1` URI。 + +建议承载方式: + +- Client → Server:A2A `Message.metadata` 放 Propose 的完整 metadata。 +- Server → Client:A2A `Artifact.metadata` 放 Accept 或 Reject 的完整 metadata。 +- `TextPart` 可以放便于人工阅读的摘要,但校验 API 的 `prompt` 参数必须取自 Negotiation-T extension metadata。 +- 接收侧同时核对 `templateUri` 与当前阶段期望 URI;缺失或不一致时立即报错,不调用校验 API。SDK 接口使用 `TemplateUri`,metadata 中保存字符串,比较时调用 `TemplateUri.uri()`。 + +服务端处理顺序: + +1. 校验 `A2A-Extensions` 包含 Negotiation-T 规范 URI。 +2. 从 `Message.metadata` 取 `templateUri` 和 Propose Prompt。 +3. 调用 `validateAndFillingProposeData`。 +4. 从返回值重建 `NegotiationContext`。 +5. 根据 `A2AT_SAMPLE_NEGOTIATION_DECISION` 选择 Accept 或 Reject,默认 Accept。 +6. 调用对应 from-text 生成 API。 +7. 将结束 Prompt 放入 Artifact metadata,发送一次后结束任务。 + +客户端处理顺序: + +1. 从 `scenario.json` 读取 `propose_text`。 +2. 创建上下文并调用 Propose from-text API。 +3. 发送 A2A Message。 +4. 等待第一个携带 Negotiation-T metadata 的 Artifact。 +5. 根据 Artifact Prompt 中的结论以及本次期望分支,调用 Accept 或 Reject 校验 API。 +6. 打印 `FilledParamData.data()` 并断言上下文和业务项。 + +## 8. 建议代码结构 + +不要直接改造成现有 `subscribe_incident` Sample。保留原示例,新建独立包和资源目录,并只复用稳定的通用基础设施。 + +```text +a2a-t-sample/ + src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/ + client/ + NegotiationClientMain.java + NegotiationClientFlow.java + server/ + NegotiationServerMain.java + NegotiationServerFlow.java + NegotiationAgentExecutor.java + shared/ + InformationNegotiationSchemas.java + NegotiationMetadataReader.java + NegotiationScenario.java + NegotiationScenarioLoader.java + mock/ + NegotiationMockLLMClient.java + NegotiationMockLlmInstaller.java + src/main/resources/sample/private-line-complaint-negotiation/ + scenario.json + client/client.env + server/server.env + src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/ + InformationNegotiationSchemasTest.java + NegotiationAcceptFlowTest.java + NegotiationRejectFlowTest.java + NegotiationMetadataReaderTest.java +``` + +可以复用或抽取现有 Sample 的以下能力: + +- 环境文件解析和默认路径解析。 +- AgentCard 构建、注册中心降级和直连逻辑。 +- a2a-java REST Client、Embedded HTTP Server 和事件读取。 +- 日志格式、错误类型和通用 metadata/event 映射。 + +如果抽取公共代码会明显扩大改动范围,第一版允许在新包内保留少量专用适配代码。不要让 Negotiation-T 流程依赖 `subscribe_incident` 包中的业务类名、Notification-T 常量或无限 Artifact 循环。 + +需要调整 `a2a-t-sample/pom.xml` 的 Java argfile 生成逻辑,为新入口生成独立文件,例如: + +```text +target/negotiation-server.javaargs.txt +target/negotiation-client.javaargs.txt +``` + +## 9. Mock LLM 设计 + +本机无法访问目标 Qwen 网络,因此 Mock 模式是开发和自动化测试的必备能力。 + +现有 `SampleMockLLMClient` 按调用次数依次返回场景识别、槽位提取和语义校验结果,不适合 Negotiation-T。新 Mock 必须根据 `jsonSchema` 和消息内容分派响应,不能依赖全局调用序号。 + +建议分派规则: + +1. Schema 顶层属性包含 `semantic_verdict`:返回 Negotiation 语义校验响应。 +2. Schema 顶层属性包含 `items` 和 `relationship`:返回信息协商 Propose 内容提取响应。 +3. Schema 顶层属性包含 `conclusion` 和 `items`:根据输入文本返回 Accept 或 Reject 内容提取响应。 +4. 语义校验阶段根据待校验 Prompt 中的阶段和结论,向 `params` 写入与 Propose、Accept 或 Reject 场景 Schema 一致的业务参数。生成阶段仍返回 SDK 内部要求的 `items`、`relationship`、`conclusion` 结构。 +5. 无法识别的 Schema 直接抛出清晰异常,避免误返回成功数据。 + +典型 Mock 响应: + +```json +{ + "items": [ + { "name": "接入端口名称", "value": "物理端口或逻辑端口名称" }, + { "name": "投诉分类", "value": "专线中断或专线质差" } + ], + "relationship": null +} +``` + +```json +{ + "conclusion": "Accept", + "items": [ + { "name": "接入端口名称", "value": "P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)" }, + { "name": "投诉分类", "value": "专线质差" } + ] +} +``` + +```json +{ + "semantic_verdict": true, + "negotiation_type": "information", + "errors": [], + "params": { + "rejection_reason": "当前账号没有资源系统查询权限" + } +} +``` + +Mock 需要覆盖生成和校验两类 LLM 调用,并分别在 Client、Server 进程中可独立工作。 + +## 10. Qwen3-32B 模式 + +目标网络手工验证时,使用仓库已提供的 `src/main/resources/sample/private-line-complaint-negotiation/qwen.env`。其配置与 `C:\cnafan\git\design\Qwen3-32B模型调用指导.md` 一致,并额外通过 `A2AT_LLM_DISABLE_SYSTEM_PROXY=true` 绕过黄区系统代理: + +```properties +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=qwen3-32b +A2AT_LLM_BASE_URL=http://71.77.65.40:8008/v1 +A2AT_LLM_API_KEY=not-needed +A2AT_LLM_TEMPERATURE=0 +A2AT_LLM_TIMEOUT_SECONDS=60 +A2AT_LLM_DISABLE_SYSTEM_PROXY=true +``` + +第一版不要在 Sample 中加入与 Qwen 强绑定的业务分支。先使用 SDK 当前 OpenAI-compatible Client 实测。如果模型返回合法 JSON,env 修改即可。若出现思考内容污染 JSON,或服务端明确要求 `chat_template_kwargs.enable_thinking=false`,再对 LLM Client 做最小兼容调整,并单独记录验证结果。当前网络无法直连不影响 Sample 开发完成,但最终验证文档必须标注真实模型验证所在环境和结果。 + +## 11. 测试要求 + +### 11.1 单元测试 + +至少覆盖: + +- Propose、Accept、Reject 三个场景 Schema 的字段、required 和 enum 约束。 +- metadata 中规范 Negotiation-T URI 和 `templateUri` 的读取。 +- metadata 缺失、错误模板 URI、错误扩展 URI 时快速失败。 +- Propose 校验结果重建上下文,数值字段通过 `Number.intValue()` 转换。 +- Accept 分支调用 Accept 生成和校验 API,不调用 Reject API。 +- Reject 分支调用 Reject 生成和校验 API,不调用 Accept API。 +- Accept/Reject 回复沿用 Propose 的 `id`、`round`、`maxRounds`。 +- Mock 根据 Schema/消息内容分派,不依赖调用顺序。 + +### 11.2 6 API 集成测试 + +增加两个不依赖 HTTP 端口的测试,使用真实 `A2ATClient`、`A2ATServer` 门面和 Mock LLM: + +- `acceptFlowCoversFourApis` +- `rejectFlowCoversFourApis` + +两者合并覆盖 6 个唯一 API。断言不能只检查“不抛异常”,还应检查: + +- `MetadataContent.templateUri()` 和 `extensionUri()`。 +- Prompt 包含同一协商上下文。 +- `FilledParamData.data()` 包含上下文字段和对应阶段的场景业务字段。 +- Propose 提取接入端口要求和投诉分类约束。 +- Accept 提取实际接入端口和枚举范围内的投诉分类;Reject 提取明确的 `rejection_reason`。 + +现有 `ValidateAndFillingDataPipelineTest` 中使用脚本 LLM 的测试主要验证编排和字段合并,脚本响应本身不会被真实模型约束。因此,本 Sample 的 Mock 测试不能替代目标网络的 Qwen 手工验证。 + +### 11.3 本地 HTTP E2E + +Mock 模式完成两次运行: + +1. Server 设置 `A2AT_SAMPLE_NEGOTIATION_DECISION=accept`,Client 运行成功。 +2. Server 设置 `A2AT_SAMPLE_NEGOTIATION_DECISION=reject`,Client 运行成功。 + +每次服务端只返回一个结束 Artifact,随后任务进入 completed 状态,禁止沿用订阅示例的无限循环。 + +## 12. 构建和验收命令 + +开发完成后至少执行: + +```bash +mvn -pl a2a-t-sample -am test +mvn -pl a2a-t-sample -am spotless:check +mvn -pl a2a-t-sample -am -DskipTests package +``` + +本地 Mock E2E 的目标启动形式: + +```powershell +$env:A2AT_SAMPLE_NEGOTIATION_DECISION="accept" +java @a2a-t-sample/target/negotiation-server.javaargs.txt +``` + +另开终端: + +```powershell +java @a2a-t-sample/target/negotiation-client.javaargs.txt +``` + +Reject 分支需重启 Server: + +```powershell +$env:A2AT_SAMPLE_NEGOTIATION_DECISION="reject" +java @a2a-t-sample/target/negotiation-server.javaargs.txt +``` + +### 12.1 目标网络 Qwen 批量验证与过程日志 + +进入可访问 Qwen3-32B 的目标网络后,Code Agent 应自行执行以下命令,不要修改默认 Mock 的 `client.env`、`server.env`。第三个 Java 参数指定过程日志;该日志每次 SDK 调用结束即追加一行,评测中断时已完成的记录仍可用于定位。 + +```powershell +mvn -pl a2a-t-sample -am -DskipTests package + +java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` + a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ` + a2a-t-sample/target/negotiation-qwen-report.json ` + a2a-t-sample/target/negotiation-qwen-process.jsonl +``` + +验收时读取 `negotiation-qwen-report.json` 和 `negotiation-qwen-process.jsonl`: + +- 汇总报告包含每条用例的 `expected`、`actual`、最终 `passed` 及耗时。 +- 过程日志按 `run_id`、`case_id` 关联两个阶段:`generate` 与 `validate_and_fill`。 +- `generate` 失败时,优先检查日志中的 `request.text`、`context`、`template_uri` 和异常;`validate_and_fill` 失败时,核对 `request.prompt`、`request.schema` 和异常链。 +- 两阶段均成功但汇总结果失败时,比较 `expected`、`actual` 和生成 Prompt,记录为模型输出差异或断言口径问题,不应笼统归因于 SDK。 + +过程日志不记录 API Key。完成后在验证文档中记录模型、执行时间、报告路径、通过率以及人工抽检结论。详细字段说明见 `NEGOTIATION_QWEN_EVALUATION.zh-CN.md`。 + +## 13. 完成标准 + +满足以下条件后,Sample 开发才算完成: + +- 原 `subscribe_incident` Sample 和测试不受影响。 +- 新 Sample 不触发任何 Task-T API。 +- Accept 和 Reject 两条 Mock HTTP 链路均可重复运行。 +- 6 个目标 API 均由真实 SDK 门面调用并有明确断言。 +- Propose 和结束消息通过 A2A metadata 传输,未手工拼装 Prompt。 +- 三套场景业务 Schema 在校验、Mock 和断言中保持一致;生成阶段继续使用 SDK 内部内容结构。 +- 同一链路的协商 ID 和轮次保持一致。 +- 无真实模型配置时自动或明确进入 Mock 模式;不会误请求公网模型。 +- Qwen 配置只通过 env 切换;目标网络验证结果留给后续验证文档记录。 +- Maven 测试、Spotless 检查和打包通过。 + +## 14. Code Agent 推荐执行顺序 + +1. 同步代码并读取仓库根目录 `AGENTS.md`。 +2. 复核 6 个 API 的最新签名、Template URI 和 `MetadataContent` 结构。 +3. 新增 Propose、Accept、Reject 场景业务 Schema 工厂及测试。 +4. 新增场景资源、metadata 读取器及测试。 +5. 新增按 Schema 分派的 Negotiation Mock LLM 及测试。 +6. 先实现不含 HTTP 的 Accept/Reject 流程测试,确认 6 个 API 行为。 +7. 接入真实 a2a-java Message/Artifact 传输。 +8. 增加两个 Main 入口、env 模板和 Maven java argfile。 +9. 分别运行 Accept、Reject 的本地 Mock E2E。 +10. 更新 `a2a-t-sample/README.zh-CN.md`,写明入口、分支选择、Mock 和 Qwen 配置方法。 +11. 在目标网络执行 100 用例 Qwen 批量验证,检查汇总报告和 JSONL 过程日志;本机无法访问目标网络时,将此步骤记录为待目标网络执行。 +12. 运行全部验收命令,检查工作区只包含本任务相关改动。 + +如后续需要提交 PR,提交必须使用 `git commit -s`,并在推送前检查 PR 范围内每个提交均带有正确的 `Signed-off-by`。 diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 534c017b..f9e41c42 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -2,22 +2,25 @@ `a2a-t-sample` 是 A2A-T Java SDK 的示例模块,包含客户端与服务端两个可直接运行的入口。 -当前示例基于 `a2a-java v1.0.0.Beta1` 运行真实的 A2A `HTTP+JSON/REST` 链路: +## 订阅事件 Sample + +该示例基于 `a2a-java v1.0.0.Beta1` 运行真实的 A2A `HTTP+JSON/REST` 链路: + - `a2a-t-client` 仅用于生成结构化 prompt - `a2a-t-server` 仅用于校验结构化 prompt -## 入口类 +### 入口类 - 客户端:`net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain` - 服务端:`net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain` -## 模块内资源 +### 模块内资源 - 客户端环境模板:`sample/subscribe_incident/client/client.env` - 服务端环境模板:`sample/subscribe_incident/server/server.env` - 客户端场景输入:`sample/subscribe_incident/client/scenario.json` -## 客户端启动 +### 客户端启动 1. 修改仓库根目录下的 `client.env`,补充可用的 `A2AT_LLM_API_KEY` 2. 如需修改默认请求内容,可编辑 `sample/subscribe_incident/client/scenario.json` @@ -29,7 +32,7 @@ java @a2a-t-sample/target/client.javaargs.txt 如果不传参数,`ClientSampleMain` 会回退到包内的 `sample/subscribe_incident/client/client.env`。 -## 服务端启动 +### 服务端启动 1. 修改仓库根目录下的 `server.env`,补充可用的 `A2AT_LLM_API_KEY` 2. 启动服务端: @@ -40,22 +43,76 @@ java @a2a-t-sample/target/server.javaargs.txt 如果不传参数,`ServerSampleMain` 会回退到包内的 `sample/subscribe_incident/server/server.env`。 -## Git Bash 本地调试 +## 传输专线投诉信息协商 Sample -先编译打包: +该示例只覆盖 Negotiation-T:Propose 的自然语言生成与校验/提参,以及 Accept 或 Reject 的自然语言生成与校验/提参。生成结果通过 A2A Message 和 Artifact metadata 传输。 -```bash -mvn "-Dmaven.repo.local=.mvn/repository" -pl a2a-t-sample -am -DskipTests package +相关文档: + +- [六个接口的逐项调用示例](NEGOTIATION_API_EXAMPLES.zh-CN.md) +- [Sample 开发与设计说明](NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md) +- [Qwen3-32B 的 100 用例批量验证](NEGOTIATION_QWEN_EVALUATION.zh-CN.md) + +调用方 schema 根据专线投诉场景分别定义:Propose 和 Accept 提取 `access_port_name`、`complaint_category`,Reject 提取 `rejection_reason`。`items`、`relationship`、`conclusion` 等协商模板结构由 SDK 内部处理。 + +默认使用本地 schema 分派 Mock LLM,无需访问模型服务。 + +### 构建 + +在仓库根目录执行: + +```powershell +mvn -pl a2a-t-sample -am test +mvn -pl a2a-t-sample -am -DskipTests package ``` -启动服务端: +### 本地运行 -```bash -java @a2a-t-sample/target/server.javaargs.txt +第一个终端启动 Accept 分支服务端: + +```powershell +$env:A2AT_SAMPLE_NEGOTIATION_DECISION = "accept" +java @a2a-t-sample/target/negotiation-server.javaargs.txt ``` -另开一个窗口启动客户端: +第二个终端启动客户端: -```bash -java @a2a-t-sample/target/client.javaargs.txt +```powershell +java @a2a-t-sample/target/negotiation-client.javaargs.txt ``` + +Reject 分支将服务端环境变量改为 `reject` 后重启,再运行客户端。服务端默认端口为 `8010`。 + +目标网络使用 Qwen3-32B 时,将 Client 和 Server 两个 env 中的 LLM 配置替换为: + +```properties +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=qwen3-32b +A2AT_LLM_BASE_URL=http://71.77.65.40:8008/v1 +A2AT_LLM_API_KEY=not-needed +A2AT_LLM_TEMPERATURE=0 +A2AT_LLM_TIMEOUT_SECONDS=60 +A2AT_LLM_DISABLE_SYSTEM_PROXY=true +``` + +仓库已提供可直接用于东莞绿区的 `sample/private-line-complaint-negotiation/qwen.env`。构建后,可显式将该文件作为两个入口的第一个参数;无需修改默认 Mock 配置: + +```powershell +$env:A2AT_SAMPLE_NEGOTIATION_DECISION = "accept" +java @a2a-t-sample/target/negotiation-server.javaargs.txt a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env +``` + +```powershell +java @a2a-t-sample/target/negotiation-client.javaargs.txt a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env +``` + +需要批量验证三组自然语言生成接口及对应校验/提参接口时,运行以下命令。它会执行 100 条用例、生成汇总报告和逐调用 JSONL 过程日志;日志可用于区分生成、校验或 Sample 入参问题: + +```powershell +java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` + a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ` + a2a-t-sample/target/negotiation-qwen-report.json ` + a2a-t-sample/target/negotiation-qwen-process.jsonl +``` + +完整执行前提和日志判读规则见 [Qwen3-32B 的 100 用例批量验证](NEGOTIATION_QWEN_EVALUATION.zh-CN.md)。 diff --git a/a2a-t-sample/pom.xml b/a2a-t-sample/pom.xml index 68928923..cf4bf298 100644 --- a/a2a-t-sample/pom.xml +++ b/a2a-t-sample/pom.xml @@ -129,6 +129,18 @@ server.env ${sample.jar.path}${path.separator}${sample.runtime.classpath} net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain client.env + + -cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.private_line_complaint.negotiation.server.NegotiationServerMain + + -cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.private_line_complaint.negotiation.client.NegotiationClientMain + + -cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.private_line_complaint.negotiation.evaluation.NegotiationQwenEvaluationMain diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java new file mode 100644 index 00000000..e274daca --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java @@ -0,0 +1,84 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.client; + +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationAgentCard; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationDecision; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationMetadataReader; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleEnvironment; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationScenarioLoader; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.mock.NegotiationMockLlmInstaller; +import net.openan.a2at.sample.subscribe_incident.client.flow.A2AJavaClientEventMapper; +import net.openan.a2at.sample.subscribe_incident.client.flow.SampleStreamTerminalStateDecider; +import net.openan.a2at.sample.subscribe_incident.client.runtime.DefaultSampleClientRuntime; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TextPart; + +/** Sends one A2A information-negotiation request and validates its terminal Artifact. */ +public final class NegotiationClientMain { + + private NegotiationClientMain() { + } + + public static void main(String[] args) { + Path envPath = args.length == 0 + ? NegotiationSampleEnvironment.defaultEnvPath("client") + : Path.of(args[0]); + Map env = NegotiationSampleEnvironment.read(envPath); + if (NegotiationMockLlmInstaller.PROVIDER.equals(env.get("A2AT_LLM_PROVIDER"))) { + NegotiationMockLlmInstaller.install(); + } + A2ATClient client = new A2ATClient(envPath); + NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3); + MetadataContent propose = client.generateNegotiationProposePromptFromText( + NegotiationScenarioLoader.load().proposeText(), context, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + Message message = Message.builder() + .messageId(UUID.randomUUID().toString()) + .role(Message.Role.ROLE_USER) + .parts(new TextPart("Private-line complaint information negotiation")) + .metadata(new LinkedHashMap<>(propose.buildMetadataContent())) + .build(); + MessageSendParams request = MessageSendParams.builder().message(message).build(); + ClientCallContext callContext = + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", propose.extensionUri())); + try (DefaultSampleClientRuntime runtime = new DefaultSampleClientRuntime(envPath)) { + Map agentCard = NegotiationAgentCard.build( + NegotiationSampleEnvironment.host(env), NegotiationSampleEnvironment.port(env)); + for (ClientEvent event : runtime.sendMessage(agentCard, request, callContext, System.out::println)) { + Map payload = A2AJavaClientEventMapper.toPayload(event); + Object artifact = payload.get("artifact"); + if (artifact instanceof Map artifactMap) { + Object metadata = artifactMap.get("metadata"); + if (metadata instanceof Map rawMetadata) { + @SuppressWarnings("unchecked") + Map endingMetadata = (Map) rawMetadata; + String prompt = NegotiationMetadataReader.readPrompt( + endingMetadata, NegotiationSampleFlow.ENDING_TEMPLATE_URI); + NegotiationDecision decision = prompt.matches("(?s).*## 信息协商结果\\R\\s*Reject.*") + ? NegotiationDecision.REJECT + : NegotiationDecision.ACCEPT; + Map result = decision == NegotiationDecision.ACCEPT + ? client.validateAndFillingAcceptData( + prompt, InformationNegotiationSchemas.accept(), NegotiationSampleFlow.ENDING_TEMPLATE_URI).data() + : client.validateAndFillingRejectData( + prompt, InformationNegotiationSchemas.reject(), NegotiationSampleFlow.ENDING_TEMPLATE_URI).data(); + System.out.println("[negotiation-client] result=" + result); + } + } + if (SampleStreamTerminalStateDecider.isTerminal(event)) { + break; + } + } + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java new file mode 100644 index 00000000..acd43e6f --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java @@ -0,0 +1,8 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import java.util.Map; + +/** One manually labelled natural-language evaluation case. */ +public record NegotiationEvaluationCase( + String id, String phase, String category, String text, Map expected) { +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java new file mode 100644 index 00000000..ad008bd2 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java @@ -0,0 +1,34 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +/** Loads the checked-in, manually labelled Qwen evaluation corpus. */ +public final class NegotiationEvaluationCaseLoader { + + public static final String RESOURCE_PATH = "sample/private-line-complaint-negotiation/evaluation/cases.json"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private NegotiationEvaluationCaseLoader() { + } + + public static List load() { + try (InputStream input = NegotiationEvaluationCaseLoader.class.getClassLoader().getResourceAsStream(RESOURCE_PATH)) { + if (input == null) { + throw new IllegalStateException("Negotiation evaluation corpus not found: " + RESOURCE_PATH); + } + List cases = OBJECT_MAPPER.readValue(input, new TypeReference<>() { + }); + if (cases.size() != 100) { + throw new IllegalStateException("Negotiation evaluation corpus must contain exactly 100 cases"); + } + return List.copyOf(cases); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read negotiation evaluation corpus", exception); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLogger.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLogger.java new file mode 100644 index 00000000..c5737ab9 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLogger.java @@ -0,0 +1,44 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Map; + +/** + * Writes one JSON object per invocation stage so a failed evaluation can be diagnosed without rerunning the model. + */ +final class NegotiationEvaluationProcessLogger implements AutoCloseable { + + private final ObjectMapper objectMapper; + private final BufferedWriter writer; + + NegotiationEvaluationProcessLogger(ObjectMapper objectMapper, Path path) throws IOException { + Path parent = path.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + this.objectMapper = objectMapper; + this.writer = Files.newBufferedWriter( + path, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE); + } + + void write(Map event) throws IOException { + writer.write(objectMapper.writeValueAsString(event)); + writer.newLine(); + writer.flush(); + } + + @Override + public void close() throws IOException { + writer.close(); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java new file mode 100644 index 00000000..60d10ae0 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -0,0 +1,280 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleEnvironment; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.server.A2ATServer; + +/** + * Runs the 100 manually labelled cases against a configured real LLM and writes a JSON report. + * + *

      The automatic result measures generated-prompt to schema-extraction consistency. The report preserves each + * prompt so a reviewer can perform the required semantic spot check without rerunning the model. + */ +public final class NegotiationQwenEvaluationMain { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + private NegotiationQwenEvaluationMain() { + } + + public static void main(String[] args) throws IOException { + Path envPath = args.length > 0 ? Path.of(args[0]) : NegotiationSampleEnvironment.defaultEnvPath("client"); + Path reportPath = args.length > 1 ? Path.of(args[1]) : Path.of("a2a-t-sample", "target", "negotiation-qwen-report.json"); + Path processLogPath = args.length > 2 ? Path.of(args[2]) : defaultProcessLogPath(reportPath); + Map environment = NegotiationSampleEnvironment.read(envPath); + requireQwenConfiguration(environment); + + A2ATClient client = new A2ATClient(envPath); + A2ATServer server = new A2ATServer(envPath); + List> results = new ArrayList<>(); + String runId = UUID.randomUUID().toString(); + try (NegotiationEvaluationProcessLogger processLogger = new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, processLogPath)) { + processLogger.write(runStartedEvent(runId, environment, envPath)); + for (NegotiationEvaluationCase testCase : NegotiationEvaluationCaseLoader.load()) { + results.add(runCase(client, server, testCase, runId, processLogger)); + } + } + + long passed = results.stream().filter(result -> Boolean.TRUE.equals(result.get("passed"))).count(); + Map report = new LinkedHashMap<>(); + report.put("generated_at", Instant.now().toString()); + report.put("run_id", runId); + report.put("model", environment.get("A2AT_LLM_MODEL")); + report.put("base_url", environment.get("A2AT_LLM_BASE_URL")); + report.put("total", results.size()); + report.put("passed", passed); + report.put("automatic_consistency_rate", (double) passed / results.size()); + report.put("note", "Automatic consistency is not semantic accuracy; manually review the preserved prompts, especially failures and a representative sample of passes."); + report.put("process_log", processLogPath.toAbsolutePath().toString()); + report.put("cases", results); + Files.createDirectories(reportPath.toAbsolutePath().getParent()); + OBJECT_MAPPER.writeValue(reportPath.toFile(), report); + System.out.printf( + "Qwen evaluation complete: %d/%d passed; report=%s; process-log=%s%n", + passed, results.size(), reportPath.toAbsolutePath(), processLogPath.toAbsolutePath()); + } + + private static Map runCase( + A2ATClient client, + A2ATServer server, + NegotiationEvaluationCase testCase, + String runId, + NegotiationEvaluationProcessLogger processLogger) throws IOException { + long startedAt = System.nanoTime(); + Map result = new LinkedHashMap<>(); + result.put("id", testCase.id()); + result.put("phase", testCase.phase()); + result.put("category", testCase.category()); + result.put("input", testCase.text()); + result.put("expected", testCase.expected()); + NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3); + try { + MetadataContent content = generateWithLog(client, testCase, context, runId, processLogger); + FilledParamData filled = validateWithLog(server, testCase, content.promptText(), runId, processLogger); + Map actual = filled.data(); + result.put("prompt", content.promptText()); + result.put("template_uri", content.templateUri()); + result.put("extension_uri", content.extensionUri()); + result.put("actual", actual); + result.put("passed", expectedValuesMatch(testCase.expected(), actual) && contextMatches(context, actual)); + } catch (RuntimeException exception) { + result.put("passed", false); + result.put("error", exception.getClass().getSimpleName() + ": " + exception.getMessage()); + } + result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); + return result; + } + + private static MetadataContent generateWithLog( + A2ATClient client, + NegotiationEvaluationCase testCase, + NegotiationContext context, + String runId, + NegotiationEvaluationProcessLogger processLogger) throws IOException { + long startedAt = System.nanoTime(); + try { + MetadataContent content = generate(client, testCase, context); + processLogger.write(stageEvent(runId, testCase, "generate", context, Map.of( + "text", testCase.text(), + "template_uri", templateUri(testCase)), Map.of( + "prompt", content.promptText(), + "template_uri", content.templateUri(), + "extension_uri", content.extensionUri()), startedAt, null)); + return content; + } catch (RuntimeException exception) { + processLogger.write(stageEvent(runId, testCase, "generate", context, Map.of( + "text", testCase.text(), + "template_uri", templateUri(testCase)), null, startedAt, exception)); + throw exception; + } + } + + private static FilledParamData validateWithLog( + A2ATServer server, + NegotiationEvaluationCase testCase, + String prompt, + String runId, + NegotiationEvaluationProcessLogger processLogger) throws IOException { + long startedAt = System.nanoTime(); + Map schema = schema(testCase); + try { + FilledParamData filled = validate(server, testCase, prompt); + processLogger.write(stageEvent(runId, testCase, "validate_and_fill", null, Map.of( + "prompt", prompt, + "schema", schema, + "template_uri", templateUri(testCase)), Map.of("filled_data", filled.data()), startedAt, null)); + return filled; + } catch (RuntimeException exception) { + processLogger.write(stageEvent(runId, testCase, "validate_and_fill", null, Map.of( + "prompt", prompt, + "schema", schema, + "template_uri", templateUri(testCase)), null, startedAt, exception)); + throw exception; + } + } + + private static Map stageEvent( + String runId, + NegotiationEvaluationCase testCase, + String stage, + NegotiationContext context, + Map request, + Map response, + long startedAt, + RuntimeException exception) { + Map event = new LinkedHashMap<>(); + event.put("timestamp", Instant.now().toString()); + event.put("run_id", runId); + event.put("case_id", testCase.id()); + event.put("phase", testCase.phase()); + event.put("stage", stage); + event.put("expected", testCase.expected()); + if (context != null) { + event.put("context", Map.of("id", context.id(), "round", context.round(), "max_rounds", context.maxRounds())); + } + event.put("request", request); + if (response != null) { + event.put("response", response); + } + event.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); + event.put("outcome", exception == null ? "success" : "failure"); + if (exception != null) { + event.put("error", errorDetails(exception)); + } + return event; + } + + private static Map runStartedEvent(String runId, Map environment, Path envPath) { + Map event = new LinkedHashMap<>(); + event.put("timestamp", Instant.now().toString()); + event.put("run_id", runId); + event.put("stage", "run_started"); + event.put("env_path", envPath.toAbsolutePath().toString()); + event.put("model", environment.get("A2AT_LLM_MODEL")); + event.put("base_url", environment.get("A2AT_LLM_BASE_URL")); + return event; + } + + private static Map errorDetails(RuntimeException exception) { + Map error = new LinkedHashMap<>(); + error.put("class", exception.getClass().getName()); + error.put("message", exception.getMessage()); + error.put("cause_chain", causeChain(exception)); + error.put("stack_trace", Arrays.stream(exception.getStackTrace()).limit(20).map(StackTraceElement::toString).toList()); + return error; + } + + private static List causeChain(Throwable exception) { + List causes = new ArrayList<>(); + Throwable current = exception; + while (current != null) { + causes.add(current.getClass().getName() + ": " + current.getMessage()); + current = current.getCause(); + } + return causes; + } + + private static Path defaultProcessLogPath(Path reportPath) { + String filename = reportPath.getFileName().toString(); + int extensionStart = filename.lastIndexOf('.'); + String logFilename = (extensionStart < 0 ? filename : filename.substring(0, extensionStart)) + "-process.jsonl"; + Path parent = reportPath.getParent(); + return parent == null ? Path.of(logFilename) : parent.resolve(logFilename); + } + + private static String templateUri(NegotiationEvaluationCase testCase) { + return testCase.phase().equals("propose") + ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri() + : NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri(); + } + + private static Map schema(NegotiationEvaluationCase testCase) { + return switch (testCase.phase()) { + case "propose" -> InformationNegotiationSchemas.propose(); + case "accept" -> InformationNegotiationSchemas.accept(); + case "reject" -> InformationNegotiationSchemas.reject(); + default -> throw new IllegalArgumentException("Unsupported evaluation phase: " + testCase.phase()); + }; + } + + private static MetadataContent generate(A2ATClient client, NegotiationEvaluationCase testCase, NegotiationContext context) { + return switch (testCase.phase()) { + case "propose" -> client.generateNegotiationProposePromptFromText( + testCase.text(), context, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + case "accept" -> client.generateNegotiationAcceptPromptFromText( + testCase.text(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); + case "reject" -> client.generateNegotiationRejectPromptFromText( + testCase.text(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); + default -> throw new IllegalArgumentException("Unsupported evaluation phase: " + testCase.phase()); + }; + } + + private static FilledParamData validate(A2ATServer server, NegotiationEvaluationCase testCase, String prompt) { + return switch (testCase.phase()) { + case "propose" -> server.validateAndFillingProposeData( + prompt, InformationNegotiationSchemas.propose(), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + case "accept" -> server.validateAndFillingAcceptData( + prompt, InformationNegotiationSchemas.accept(), NegotiationSampleFlow.ENDING_TEMPLATE_URI); + case "reject" -> server.validateAndFillingRejectData( + prompt, InformationNegotiationSchemas.reject(), NegotiationSampleFlow.ENDING_TEMPLATE_URI); + default -> throw new IllegalArgumentException("Unsupported evaluation phase: " + testCase.phase()); + }; + } + + private static boolean expectedValuesMatch(Map expected, Map actual) { + return expected.entrySet().stream().allMatch(entry -> entry.getValue().equals(actual.get(entry.getKey()))); + } + + private static boolean contextMatches(NegotiationContext context, Map actual) { + return context.id().equals(actual.get("id")) + && numberEquals(context.round(), actual.get("round")) + && numberEquals(context.maxRounds(), actual.get("maxRounds")); + } + + private static boolean numberEquals(int expected, Object actual) { + return actual instanceof Number number && expected == number.intValue(); + } + + private static void requireQwenConfiguration(Map environment) { + if (!"openai".equals(environment.get("A2AT_LLM_PROVIDER")) || environment.get("A2AT_LLM_BASE_URL") == null) { + throw new IllegalArgumentException("Qwen evaluation requires A2AT_LLM_PROVIDER=openai and A2AT_LLM_BASE_URL in the supplied env file"); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java new file mode 100644 index 00000000..315a90f5 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java @@ -0,0 +1,80 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.server; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationDecision; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationMetadataReader; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationScenario; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.server.A2ATServer; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.TextPart; + +/** Server-side A2A executor for one terminal information-negotiation reply. */ +public final class NegotiationAgentExecutor implements AgentExecutor { + + private final A2ATServer server; + + private final NegotiationScenario scenario; + + private final NegotiationDecision decision; + + public NegotiationAgentExecutor(A2ATServer server, NegotiationScenario scenario, NegotiationDecision decision) { + this.server = server; + this.scenario = scenario; + this.decision = decision; + } + + @Override + public void execute(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + try { + NegotiationMetadataReader.requireExtension(extensionHeader(requestContext)); + Message message = requestContext.getMessage(); + String proposePrompt = NegotiationMetadataReader.readPrompt( + message == null ? null : message.metadata(), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + FilledParamData proposeData = server.validateAndFillingProposeData( + proposePrompt, InformationNegotiationSchemas.propose(), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + NegotiationContext context = NegotiationSampleFlow.contextFrom(proposeData.data()); + MetadataContent ending = decision == NegotiationDecision.ACCEPT + ? server.generateNegotiationAcceptPromptFromText( + scenario.acceptText(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI) + : server.generateNegotiationRejectPromptFromText( + scenario.rejectText(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); + agentEmitter.submit(); + agentEmitter.startWork(); + agentEmitter.addArtifact( + List.of(new TextPart("Information negotiation " + decision.name().toLowerCase())), + "information-negotiation", + "Private-line complaint negotiation result", + Map.copyOf(ending.buildMetadataContent()), + false, + true); + agentEmitter.complete(); + } catch (RuntimeException exception) { + agentEmitter.fail(); + throw exception; + } + } + + @Override + public void cancel(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + agentEmitter.cancel(); + } + + @SuppressWarnings("unchecked") + private static String extensionHeader(RequestContext requestContext) { + Object headers = requestContext.getCallContext().getState().get("headers"); + if (headers instanceof Map values) { + return String.valueOf(((Map) values).getOrDefault("A2A-Extensions", "")); + } + return ""; + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationServerMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationServerMain.java new file mode 100644 index 00000000..84f7f2ac --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationServerMain.java @@ -0,0 +1,76 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.server; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationAgentCard; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationDecision; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleEnvironment; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationScenarioLoader; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.mock.NegotiationMockLlmInstaller; +import net.openan.a2at.sample.subscribe_incident.server.http.EmbeddedA2AHttpServer; +import net.openan.a2at.sample.subscribe_incident.shared.registry.RegistryAgentCardMapper; +import net.openan.a2at.sdk.server.A2ATServer; +import org.a2aproject.sdk.server.events.InMemoryQueueManager; +import org.a2aproject.sdk.server.events.MainEventBus; +import org.a2aproject.sdk.server.events.MainEventBusProcessor; +import org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.server.tasks.BasePushNotificationSender; +import org.a2aproject.sdk.server.tasks.InMemoryPushNotificationConfigStore; +import org.a2aproject.sdk.server.tasks.InMemoryTaskStore; + +/** Starts the A2A HTTP server for the private-line complaint negotiation sample. */ +public final class NegotiationServerMain { + + private NegotiationServerMain() { + } + + public static void main(String[] args) throws InterruptedException { + Path envPath = args.length == 0 + ? NegotiationSampleEnvironment.defaultEnvPath("server") + : Path.of(args[0]); + Map env = NegotiationSampleEnvironment.read(envPath); + if (NegotiationMockLlmInstaller.PROVIDER.equals(env.get("A2AT_LLM_PROVIDER"))) { + NegotiationMockLlmInstaller.install(); + } + String host = NegotiationSampleEnvironment.host(env); + int port = NegotiationSampleEnvironment.port(env); + A2ATServer a2atServer = new A2ATServer(envPath); + InMemoryTaskStore taskStore = new InMemoryTaskStore(); + MainEventBus eventBus = new MainEventBus(); + InMemoryQueueManager queueManager = new InMemoryQueueManager(taskStore, eventBus); + InMemoryPushNotificationConfigStore notifications = new InMemoryPushNotificationConfigStore(); + MainEventBusProcessor processor = new MainEventBusProcessor( + eventBus, taskStore, new BasePushNotificationSender(notifications), queueManager); + startProcessor(processor); + ExecutorService executor = Executors.newFixedThreadPool(2); + RequestHandler handler = DefaultRequestHandler.create( + new NegotiationAgentExecutor( + a2atServer, + NegotiationScenarioLoader.load(), + NegotiationDecision.fromEnvironment(System.getenv("A2AT_SAMPLE_NEGOTIATION_DECISION"))), + taskStore, + queueManager, + notifications, + processor, + executor, + executor); + try (EmbeddedA2AHttpServer ignored = EmbeddedA2AHttpServer.start( + host, port, RegistryAgentCardMapper.toA2AJavaAgentCard(NegotiationAgentCard.build(host, port)), handler)) { + System.out.println("[negotiation-server] listening on http://" + host + ":" + port); + Thread.currentThread().join(); + } + } + + private static void startProcessor(MainEventBusProcessor processor) { + try { + var method = MainEventBusProcessor.class.getDeclaredMethod("start"); + method.setAccessible(true); + method.invoke(processor); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to start A2A event-bus processor", exception); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/InformationNegotiationSchemas.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/InformationNegotiationSchemas.java new file mode 100644 index 00000000..18ead78d --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/InformationNegotiationSchemas.java @@ -0,0 +1,75 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.shared; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Scenario-specific caller schemas for the private-line complaint information negotiation sample. */ +public final class InformationNegotiationSchemas { + + private static final Map PROPOSE = createProposeSchema(); + + private static final Map ACCEPT = createAcceptSchema(); + + private static final Map REJECT = createRejectSchema(); + + private InformationNegotiationSchemas() { + } + + public static Map propose() { + return PROPOSE; + } + + public static Map accept() { + return ACCEPT; + } + + public static Map reject() { + return REJECT; + } + + private static Map createProposeSchema() { + Map properties = new LinkedHashMap<>(); + properties.put( + "access_port_name", + stringSchema("Requested access-port information, including the accepted physical or logical port format")); + properties.put( + "complaint_category", + stringSchema("Requested complaint category and its allowed private-line complaint values")); + return objectSchema(properties, List.of("access_port_name", "complaint_category")); + } + + private static Map createAcceptSchema() { + Map properties = new LinkedHashMap<>(); + properties.put( + "access_port_name", + stringSchema("Physical or logical access-port name supplied for private-line diagnosis")); + properties.put( + "complaint_category", + Map.of( + "type", "string", + "description", "Private-line complaint category", + "enum", List.of("专线中断", "专线质差"))); + return objectSchema(properties, List.of("access_port_name", "complaint_category")); + } + + private static Map createRejectSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("rejection_reason", stringSchema("Reason why the requested complaint information cannot be supplied")); + return objectSchema(properties, List.of("rejection_reason")); + } + + private static Map stringSchema(String description) { + return Map.of("type", "string", "description", description); + } + + private static Map objectSchema(Map properties, List required) { + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("additionalProperties", false); + schema.put("properties", Collections.unmodifiableMap(properties)); + schema.put("required", required); + return Collections.unmodifiableMap(schema); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationAgentCard.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationAgentCard.java new file mode 100644 index 00000000..a87e49fd --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationAgentCard.java @@ -0,0 +1,37 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.shared; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; + +/** AgentCard payload shared by the negotiation sample client and server. */ +public final class NegotiationAgentCard { + + private NegotiationAgentCard() { + } + + public static Map build(String host, int port) { + return Map.of( + "name", "Transmission Workbench Negotiation Agent", + "description", "Private-line complaint information negotiation sample", + "version", "1.0.0", + "defaultInputModes", List.of("text/plain"), + "defaultOutputModes", List.of("text/plain"), + "provider", Map.of("organization", "OpenAN", "url", "https://github.com/project-openan"), + "skills", List.of(Map.of( + "id", "private-line-complaint-negotiation", + "name", "Private-line complaint negotiation", + "description", "Completes an information negotiation with Accept or Reject.", + "tags", List.of("negotiation", "private-line"))), + "capabilities", Map.of( + "streaming", true, + "pushNotifications", false, + "extensions", List.of(Map.of( + "uri", ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, + "description", "Negotiation-T information negotiation extension."))), + "supportedInterfaces", List.of(Map.of( + "protocolBinding", "HTTP+JSON", + "protocolVersion", "1.0", + "url", "http://" + host + ":" + port))); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationDecision.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationDecision.java new file mode 100644 index 00000000..f73feeac --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationDecision.java @@ -0,0 +1,11 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.shared; + +/** Terminal result selected by the responder for the sample negotiation. */ +public enum NegotiationDecision { + ACCEPT, + REJECT; + + public static NegotiationDecision fromEnvironment(String rawValue) { + return rawValue != null && "reject".equalsIgnoreCase(rawValue.trim()) ? REJECT : ACCEPT; + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java new file mode 100644 index 00000000..08b67654 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java @@ -0,0 +1,42 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.shared; + +import java.util.Map; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; + +/** Reads and validates Negotiation-T metadata received over A2A. */ +public final class NegotiationMetadataReader { + + private NegotiationMetadataReader() { + } + + public static String readPrompt(Map metadata, TemplateUri expectedTemplateUri) { + if (metadata == null) { + throw new IllegalArgumentException("Negotiation metadata is required"); + } + String templateUri = stringValue(metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + if (!expectedTemplateUri.uri().equals(templateUri)) { + throw new IllegalArgumentException( + "Unexpected negotiation template URI: expected=" + expectedTemplateUri.uri() + ", actual=" + templateUri); + } + String prompt = stringValue(metadata.get(ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI)); + if (prompt.isBlank()) { + prompt = stringValue(metadata.get(ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI_NL)); + } + if (prompt.isBlank()) { + throw new IllegalArgumentException("Negotiation-T prompt metadata is required"); + } + return prompt; + } + + public static void requireExtension(String extensionHeader) { + if (!ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI.equals(extensionHeader)) { + throw new IllegalArgumentException("A2A-Extensions must contain the Negotiation-T extension URI"); + } + } + + private static String stringValue(Object value) { + return value == null ? "" : String.valueOf(value); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleEnvironment.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleEnvironment.java new file mode 100644 index 00000000..c4662468 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleEnvironment.java @@ -0,0 +1,57 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.shared; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Reads the small set of environment values needed by the negotiation sample entry points. */ +public final class NegotiationSampleEnvironment { + + private NegotiationSampleEnvironment() { + } + + public static Path defaultEnvPath(String role) { + return Path.of( + "a2a-t-sample", + "src", + "main", + "resources", + "sample", + "private-line-complaint-negotiation", + role, + role + ".env"); + } + + public static Map read(Path envPath) { + try { + Map values = new LinkedHashMap<>(); + List lines = Files.exists(envPath) ? Files.readAllLines(envPath) : List.of(); + for (String rawLine : lines) { + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { + continue; + } + int separator = line.indexOf('='); + values.put(line.substring(0, separator).trim(), line.substring(separator + 1).trim()); + } + return values; + } catch (IOException exception) { + throw new IllegalStateException("Failed to read sample environment file: " + envPath, exception); + } + } + + public static String host(Map values) { + return values.getOrDefault("A2AT_SAMPLE_HOST", "127.0.0.1"); + } + + public static int port(Map values) { + try { + return Integer.parseInt(values.getOrDefault("A2AT_SAMPLE_PORT", "8000")); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException("A2AT_SAMPLE_PORT must be an integer", exception); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java new file mode 100644 index 00000000..2a3afcc5 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java @@ -0,0 +1,66 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.shared; + +import java.util.Map; +import java.util.UUID; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.server.A2ATServer; + +/** Runs the six Negotiation-T APIs used by the private-line complaint sample. */ +public final class NegotiationSampleFlow { + + public static final TemplateUri PROPOSE_TEMPLATE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + public static final TemplateUri ENDING_TEMPLATE_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + + private NegotiationSampleFlow() { + } + + public static NegotiationFlowResult run( + A2ATClient client, + A2ATServer server, + NegotiationScenario scenario, + NegotiationDecision decision) { + NegotiationContext requestContext = new NegotiationContext(UUID.randomUUID().toString(), 1, 3); + MetadataContent propose = + client.generateNegotiationProposePromptFromText(scenario.proposeText(), requestContext, PROPOSE_TEMPLATE_URI); + String proposePrompt = NegotiationMetadataReader.readPrompt(propose.buildMetadataContent(), PROPOSE_TEMPLATE_URI); + FilledParamData proposeData = + server.validateAndFillingProposeData(proposePrompt, InformationNegotiationSchemas.propose(), PROPOSE_TEMPLATE_URI); + + NegotiationContext responseContext = contextFrom(proposeData.data()); + MetadataContent ending = decision == NegotiationDecision.ACCEPT + ? server.generateNegotiationAcceptPromptFromText(scenario.acceptText(), responseContext, ENDING_TEMPLATE_URI) + : server.generateNegotiationRejectPromptFromText(scenario.rejectText(), responseContext, ENDING_TEMPLATE_URI); + String endingPrompt = NegotiationMetadataReader.readPrompt(ending.buildMetadataContent(), ENDING_TEMPLATE_URI); + FilledParamData endingData = decision == NegotiationDecision.ACCEPT + ? client.validateAndFillingAcceptData( + endingPrompt, InformationNegotiationSchemas.accept(), ENDING_TEMPLATE_URI) + : client.validateAndFillingRejectData( + endingPrompt, InformationNegotiationSchemas.reject(), ENDING_TEMPLATE_URI); + return new NegotiationFlowResult(requestContext, propose, proposeData, ending, endingData, decision); + } + + public static NegotiationContext contextFrom(Map data) { + Object id = data.get("id"); + Object round = data.get("round"); + Object maxRounds = data.get("maxRounds"); + if (!(id instanceof String text) || !(round instanceof Number roundNumber) || !(maxRounds instanceof Number maxRoundsNumber)) { + throw new IllegalArgumentException("Filled negotiation data does not contain a valid context"); + } + return new NegotiationContext(text, roundNumber.intValue(), maxRoundsNumber.intValue()); + } + + public record NegotiationFlowResult( + NegotiationContext requestContext, + MetadataContent propose, + FilledParamData proposeData, + MetadataContent ending, + FilledParamData endingData, + NegotiationDecision decision) { + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenario.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenario.java new file mode 100644 index 00000000..ddb7cb55 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenario.java @@ -0,0 +1,5 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.shared; + +/** Natural-language inputs used by the private-line complaint negotiation sample. */ +public record NegotiationScenario(String scenario, String proposeText, String acceptText, String rejectText) { +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenarioLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenarioLoader.java new file mode 100644 index 00000000..6c3e764b --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenarioLoader.java @@ -0,0 +1,43 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.shared; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +/** Loads the bundled private-line complaint negotiation input. */ +public final class NegotiationScenarioLoader { + + public static final String RESOURCE_PATH = "sample/private-line-complaint-negotiation/scenario.json"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private NegotiationScenarioLoader() { + } + + public static NegotiationScenario load() { + try (InputStream input = NegotiationScenarioLoader.class.getClassLoader().getResourceAsStream(RESOURCE_PATH)) { + if (input == null) { + throw new IllegalStateException("Negotiation scenario resource not found: " + RESOURCE_PATH); + } + Map values = OBJECT_MAPPER.readValue(input, new TypeReference<>() { + }); + return new NegotiationScenario( + require(values, "scenario"), + require(values, "propose_text"), + require(values, "accept_text"), + require(values, "reject_text")); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read negotiation scenario", exception); + } + } + + private static String require(Map values, String key) { + String value = values.get(key); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Negotiation scenario field must not be blank: " + key); + } + return value; + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLLMClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLLMClient.java new file mode 100644 index 00000000..7d0652df --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLLMClient.java @@ -0,0 +1,113 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.shared.mock; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMResponse; + +/** + * Deterministic structured LLM used by the information negotiation sample. + * + *

      Responses are selected from the requested schema and text, so client and server calls can + * run in either order without a shared call counter. + */ +public final class NegotiationMockLLMClient implements LLMClient { + + private static final String PROPOSE_CONTENT = """ + {"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"}, + {"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null} + """; + + private static final String ACCEPT_CONTENT = """ + {"conclusion":"Accept","items":[{"name":"接入端口名称", + "value":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)"}, + {"name":"投诉分类","value":"专线质差"}]} + """; + + private static final String REJECT_CONTENT = """ + {"conclusion":"Reject","items":[{"name":"拒绝原因", + "value":"当前账号没有资源系统查询权限"}]} + """; + + private static final String PROPOSE_PARAMS = """ + {"access_port_name":"物理端口或逻辑端口名称", + "complaint_category":"专线中断或专线质差"} + """; + + private static final String ACCEPT_PARAMS = """ + {"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)", + "complaint_category":"专线质差"} + """; + + private static final String REJECT_PARAMS = """ + {"rejection_reason":"当前账号没有资源系统查询权限"} + """; + + public NegotiationMockLLMClient(LLMClientConfig config) { + // Mock responses are independent from provider settings. + } + + @Override + @SuppressWarnings("unchecked") + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + Map properties = propertiesOf(jsonSchema); + String content; + if (properties.containsKey("semantic_verdict")) { + boolean reject = isRejectPrompt(lastMessage(messages)); + content = semanticResult(reject ? REJECT_PARAMS : semanticParamsForPrompt(messages)); + } else if (properties.containsKey("relationship") && properties.containsKey("items")) { + content = PROPOSE_CONTENT; + } else if (properties.containsKey("conclusion") && properties.containsKey("items")) { + content = lastMessage(messages).contains("拒绝") ? REJECT_CONTENT : ACCEPT_CONTENT; + } else { + throw new IllegalArgumentException("Unsupported negotiation mock LLM schema: " + properties.keySet()); + } + return new LLMResponse( + content, + "negotiation-sample-mock", + Map.of("prompt_tokens", 0, "completion_tokens", 0, "total_tokens", 0), + Map.of()); + } + + @SuppressWarnings("unchecked") + private static Map propertiesOf(Map schema) { + Object value = schema.get("properties"); + if (!(value instanceof Map properties)) { + throw new IllegalArgumentException("Negotiation mock LLM schema has no top-level properties"); + } + return (Map) properties; + } + + private static String semanticParamsForPrompt(List> messages) { + String prompt = lastMessage(messages); + if (isAcceptPrompt(prompt)) { + return ACCEPT_PARAMS; + } + return PROPOSE_PARAMS; + } + + private static String semanticResult(String params) { + return "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" + + params + + "}"; + } + + private static String lastMessage(List> messages) { + return messages.isEmpty() ? "" : messages.get(messages.size() - 1).getOrDefault("content", ""); + } + + private static boolean isAcceptPrompt(String prompt) { + return prompt.matches("(?s).*## 信息协商结果\\R\\s*Accept.*") + || prompt.matches("(?s).*## Information Negotiation Result\\R\\s*Accept.*"); + } + + private static boolean isRejectPrompt(String prompt) { + return prompt.matches("(?s).*## 信息协商结果\\R\\s*Reject.*") + || prompt.matches("(?s).*## Information Negotiation Result\\R\\s*Reject.*"); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLlmInstaller.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLlmInstaller.java new file mode 100644 index 00000000..23f76615 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLlmInstaller.java @@ -0,0 +1,18 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.shared.mock; + +import net.openan.a2at.sdk.llm.LLMClientFactory; + +/** Installs the sample's schema-dispatching LLM implementation for offline runs. */ +public final class NegotiationMockLlmInstaller { + + public static final String PROVIDER = "negotiation-sample-mock"; + + private NegotiationMockLlmInstaller() { + } + + public static synchronized void install() { + if (!LLMClientFactory.availableProviders().contains(PROVIDER)) { + LLMClientFactory.register(PROVIDER, NegotiationMockLLMClient.class); + } + } +} diff --git a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json new file mode 100644 index 00000000..da9273ad --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json @@ -0,0 +1,102 @@ +[ + {"id":"P01","phase":"propose","category":"complete","text":"请发起信息协商,补充接入端口名称和投诉分类;端口可为物理或逻辑端口,分类限专线中断或专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P02","phase":"propose","category":"complete","text":"专线投诉诊断需要对端提供端口名和投诉类型,端口接受物理、逻辑名称,类型为专线中断或专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P03","phase":"propose","category":"paraphrase","text":"麻烦补齐本次专线投诉的接入端口及故障分类,端口要能定位到物理或逻辑接口,故障仅在中断、质差两类中选择。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P04","phase":"propose","category":"paraphrase","text":"发起信息协商:请给出物理端口或逻辑端口名称,并说明投诉属于专线中断还是专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P05","phase":"propose","category":"short","text":"请补充端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P06","phase":"propose","category":"short","text":"需要协商获取接入端口名和专线投诉类别:端口支持物理或逻辑,类别为中断或质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P07","phase":"propose","category":"formal","text":"为开展传输专线业务投诉诊断,请对端补充接入端口标识和投诉分类。其中端口标识应为物理或逻辑端口名称,投诉分类可取专线中断、专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P08","phase":"propose","category":"formal","text":"当前诊断参数不完整。请求协商获得:1)接入物理/逻辑端口名称;2)投诉分类,候选值为专线中断、专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P09","phase":"propose","category":"punctuation","text":"请补充接入端口名称;物理端口、逻辑端口均可。还请确认投诉分类:专线中断?专线质差?","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P10","phase":"propose","category":"punctuation","text":"信息协商请求【端口:物理或逻辑端口名称】【投诉:专线中断或专线质差】。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P11","phase":"propose","category":"context","text":"客户反映业务异常,尚无法确定网络侧故障。请提供接入端口名称(物理或逻辑均可)以及投诉分类(专线中断或专线质差),以便继续诊断。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P12","phase":"propose","category":"context","text":"工单号已登记,但缺少诊断关键信息。请信息协商补充物理/逻辑接入端口名和专线中断/专线质差的投诉类别。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P13","phase":"propose","category":"synonym","text":"请对端回填入网接口名称(可填物理口或逻辑口)及专线故障现象分类(中断、质差)。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P14","phase":"propose","category":"synonym","text":"需要拿到接入侧端口标识,物理接口或逻辑接口均满足;同时确定本次专线投诉是断线还是质量劣化。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P15","phase":"propose","category":"multisentence","text":"请启动信息协商。诊断需要端口信息。请提供物理端口或逻辑端口名称。另请说明投诉分类是专线中断还是专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P16","phase":"propose","category":"multisentence","text":"当前不能直接定位故障。先确认接入端口名称,物理、逻辑名称都可以;再确认投诉类型,只允许专线中断和专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P17","phase":"propose","category":"polite","text":"烦请协助补充专线接入端口名称(物理或逻辑端口均可)与投诉分类(专线中断或专线质差),谢谢。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P18","phase":"propose","category":"polite","text":"请您确认一下接入端口是哪个物理/逻辑端口,并选择投诉为专线中断还是专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P19","phase":"propose","category":"order-reversed","text":"先请判定投诉类别:专线中断或专线质差;随后提供接入端口名称,物理端口或逻辑端口均可。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P20","phase":"propose","category":"order-reversed","text":"协商所需字段包括投诉分类(专线中断、专线质差)和接入端口名称(物理或逻辑端口)。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P21","phase":"propose","category":"mixed-language","text":"Please provide the access port name, either physical or logical, and classify this private-line complaint as 专线中断 or 专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P22","phase":"propose","category":"mixed-language","text":"发起 negotiation,需补充 access port(物理端口或逻辑端口名称)及 complaint category(专线中断或专线质差)。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P23","phase":"propose","category":"noise","text":"客户联系人为张三,联系电话已脱敏。请忽略这些非诊断信息,补充物理或逻辑接入端口名称,以及专线中断或专线质差的投诉分类。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P24","phase":"propose","category":"noise","text":"告警数量暂不确定,工单优先级为P2。请协商取得接入端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P25","phase":"propose","category":"business","text":"SPN专线投诉根因诊断前,请对端提供承载该业务的物理或逻辑接入端口名,并标注故障属于专线中断还是专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P26","phase":"propose","category":"business","text":"传输工作台需补齐入参:接入端口的物理/逻辑名称、专线投诉的中断/质差分类,请发起协商。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P27","phase":"propose","category":"imperative","text":"向对端要端口名和投诉类别。端口名必须是物理或者逻辑端口;类别限定专线中断、专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P28","phase":"propose","category":"imperative","text":"补齐以下两项再诊断:物理/逻辑接入端口名称;专线中断/专线质差投诉分类。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P29","phase":"propose","category":"long","text":"本次请求用于传输专线业务投诉诊断,现有描述只说明客户感知业务异常,尚不具备定位条件。请通过信息协商要求对端补充接入端口名称,值可以是物理端口名称或逻辑端口名称;同时要求补充投诉分类,允许的值为专线中断或专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P30","phase":"propose","category":"long","text":"为避免将客户侧问题误判为网络问题,需要先获取网络定位参数。请请求对端给出接入端口的物理或逻辑标识,并在专线中断、专线质差两个候选项中说明投诉类型。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P31","phase":"propose","category":"format","text":"字段1:接入端口名称,要求:物理端口或逻辑端口名称。字段2:投诉分类,要求:专线中断或专线质差。请发起协商。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P32","phase":"propose","category":"format","text":"需对端回复:access_port_name=物理端口或逻辑端口名称;complaint_category=专线中断或专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P33","phase":"propose","category":"colloquial","text":"这单还差端口和投诉类型,麻烦要一下:端口给物理口或逻辑口都行,类型就在专线中断、专线质差里选。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P34","phase":"propose","category":"colloquial","text":"先把专线的接入端口名和故障分类问清楚吧,端口支持物理/逻辑,分类只有中断或质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"A01","phase":"accept","category":"complete","text":"接受本次协商。接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类为专线质差。","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A02","phase":"accept","category":"complete","text":"同意补充信息:物理端口 P782-天河-PTN7900-01-GE0/1/2,故障类型是专线中断。","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A03","phase":"accept","category":"paraphrase","text":"接受。用于诊断的逻辑接入端口是 LAG-广州-001.100,客户投诉为专线质差。","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A04","phase":"accept","category":"paraphrase","text":"可以提供:端口名为 PE1-GZ-ETH-0/0/0.200,专线已中断。","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A05","phase":"accept","category":"short","text":"接受,端口 NCE-PTN-23-1/1/7,投诉:专线质差。","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A06","phase":"accept","category":"short","text":"Accept。access port=SPN-广州-1/0/24;分类=专线中断。","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A07","phase":"accept","category":"formal","text":"接受该信息协商请求。补充的接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类确定为专线质差。","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A08","phase":"accept","category":"formal","text":"经资源核查,接受协商并反馈:物理接口 P782-天河-PTN7900-01-GE0/1/2;本次投诉为专线中断。","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A09","phase":"accept","category":"punctuation","text":"接受!接入端口:LAG-广州-001.100;投诉分类:专线质差。","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A10","phase":"accept","category":"punctuation","text":"同意回复【端口=PE1-GZ-ETH-0/0/0.200】【类型=专线中断】。","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A11","phase":"accept","category":"context","text":"客户报障期间业务偶发丢包,接受协商,确认接入端口为 NCE-PTN-23-1/1/7,投诉分类为专线质差。","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A12","phase":"accept","category":"context","text":"告警显示链路不可达。接受本次协商,端口为 SPN-广州-1/0/24,归类为专线中断。","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A13","phase":"accept","category":"synonym","text":"同意提供入网接口标识:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),业务质量劣化属于专线质差。","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A14","phase":"accept","category":"synonym","text":"接受,承载接口为 P782-天河-PTN7900-01-GE0/1/2,客户反映断线,对应专线中断。","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A15","phase":"accept","category":"multisentence","text":"接受协商。端口名称是 LAG-广州-001.100。投诉分类选择专线质差。","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A16","phase":"accept","category":"multisentence","text":"同意。已查到 PE1-GZ-ETH-0/0/0.200。该专线业务当前中断。","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A17","phase":"accept","category":"order-reversed","text":"投诉分类为专线质差,接入端口名称为 NCE-PTN-23-1/1/7,接受本次信息协商。","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A18","phase":"accept","category":"order-reversed","text":"专线中断;端口 SPN-广州-1/0/24。接受协商请求。","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A19","phase":"accept","category":"mixed-language","text":"Accept this negotiation. The access port is P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100), complaint category is 专线质差。","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A20","phase":"accept","category":"mixed-language","text":"接受,port=P782-天河-PTN7900-01-GE0/1/2,category=专线中断。","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A21","phase":"accept","category":"noise","text":"工单优先级P1,联系人已通知。接受并补充端口 LAG-广州-001.100,投诉为专线质差。","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A22","phase":"accept","category":"noise","text":"客户地址不在本次处理范围。接受协商:PE1-GZ-ETH-0/0/0.200,专线中断。","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A23","phase":"accept","category":"business","text":"资源系统查询完成,接受信息协商。诊断接入端口 NCE-PTN-23-1/1/7,故障投诉分类为专线质差。","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A24","phase":"accept","category":"business","text":"传输侧确认接受:业务承载端口 SPN-广州-1/0/24,故障现象按专线中断处理。","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A25","phase":"accept","category":"format","text":"接受;access_port_name=P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100);complaint_category=专线质差。","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A26","phase":"accept","category":"format","text":"结果:接受。接入端口名称=P782-天河-PTN7900-01-GE0/1/2;投诉分类=专线中断。","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A27","phase":"accept","category":"colloquial","text":"这次协商同意,端口就是 LAG-广州-001.100,客户说网慢,按专线质差算。","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A28","phase":"accept","category":"colloquial","text":"可以,查到了 PE1-GZ-ETH-0/0/0.200,这条专线断了。","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A29","phase":"accept","category":"long","text":"我方接受本次信息协商请求。资源查询结果表明,客户专线接入在 NCE-PTN-23-1/1/7;目前业务未完全中断,但存在持续高丢包和时延异常,因此投诉分类填写为专线质差。","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A30","phase":"accept","category":"long","text":"经现场及网管联合核查,接受协商并返回所需信息:接入端口为 SPN-广州-1/0/24。该端口承载业务不可达,用户侧表现为完全断线,投诉分类为专线中断。","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A31","phase":"accept","category":"whitespace","text":"接受本次协商。\n接入端口:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n投诉分类:专线质差。","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A32","phase":"accept","category":"whitespace","text":"接受。\n端口 P782-天河-PTN7900-01-GE0/1/2\n类别 专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A33","phase":"accept","category":"explicit","text":"明确接受信息协商,唯一需要填报的端口是 LAG-广州-001.100,唯一投诉分类为专线质差。","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"R01","phase":"reject","category":"complete","text":"拒绝本次信息协商,当前账号没有资源系统查询权限。","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R02","phase":"reject","category":"complete","text":"拒绝,原因是资源系统正在维护,暂时无法查询端口信息。","expected":{"rejection_reason":"资源系统正在维护,暂时无法查询端口信息"}}, + {"id":"R03","phase":"reject","category":"paraphrase","text":"无法接受该协商请求,因为我方账号无权访问资源管理系统。","expected":{"rejection_reason":"我方账号无权访问资源管理系统"}}, + {"id":"R04","phase":"reject","category":"paraphrase","text":"本次不能补充信息,资源库维护中,端口数据不可用。","expected":{"rejection_reason":"资源库维护中,端口数据不可用"}}, + {"id":"R05","phase":"reject","category":"short","text":"拒绝:无资源系统查询权限。","expected":{"rejection_reason":"无资源系统查询权限"}}, + {"id":"R06","phase":"reject","category":"short","text":"Reject,资源系统维护中。","expected":{"rejection_reason":"资源系统维护中"}}, + {"id":"R07","phase":"reject","category":"formal","text":"拒绝本次信息协商请求,原因是当前操作账号未被授予资源系统查询权限。","expected":{"rejection_reason":"当前操作账号未被授予资源系统查询权限"}}, + {"id":"R08","phase":"reject","category":"formal","text":"经确认,资源系统处于计划维护窗口,暂不具备提供端口信息的条件,故拒绝协商。","expected":{"rejection_reason":"资源系统处于计划维护窗口,暂不具备提供端口信息的条件"}}, + {"id":"R09","phase":"reject","category":"punctuation","text":"拒绝!原因:当前账号没有资源系统查询权限。","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R10","phase":"reject","category":"punctuation","text":"无法接受【资源系统维护,端口查询暂停】。","expected":{"rejection_reason":"资源系统维护,端口查询暂停"}}, + {"id":"R11","phase":"reject","category":"context","text":"工单已受理,但资源侧权限申请尚未完成,因此拒绝本次协商。","expected":{"rejection_reason":"资源侧权限申请尚未完成"}}, + {"id":"R12","phase":"reject","category":"context","text":"网管告警很多,但资源系统服务异常,当前不能查询端口,拒绝补充。","expected":{"rejection_reason":"资源系统服务异常,当前不能查询端口"}}, + {"id":"R13","phase":"reject","category":"synonym","text":"不接受协商,因账号没有资产资源库的访问授权。","expected":{"rejection_reason":"账号没有资产资源库的访问授权"}}, + {"id":"R14","phase":"reject","category":"synonym","text":"无法给出所需数据,CMDB资源查询服务正在检修。","expected":{"rejection_reason":"CMDB资源查询服务正在检修"}}, + {"id":"R15","phase":"reject","category":"multisentence","text":"拒绝本次请求。当前账号没有资源系统查询权限。","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R16","phase":"reject","category":"multisentence","text":"暂不接受。资源系统正在维护。恢复时间未知。","expected":{"rejection_reason":"资源系统正在维护"}}, + {"id":"R17","phase":"reject","category":"order-reversed","text":"因资源侧权限尚未开通,拒绝这次信息协商。","expected":{"rejection_reason":"资源侧权限尚未开通"}}, + {"id":"R18","phase":"reject","category":"order-reversed","text":"资源查询接口超时,无法返回端口信息,故拒绝。","expected":{"rejection_reason":"资源查询接口超时,无法返回端口信息"}}, + {"id":"R19","phase":"reject","category":"mixed-language","text":"Reject this negotiation because the current account has no resource-system query permission。","expected":{"rejection_reason":"the current account has no resource-system query permission"}}, + {"id":"R20","phase":"reject","category":"mixed-language","text":"拒绝,resource system is under maintenance,无法查询端口。","expected":{"rejection_reason":"resource system is under maintenance,无法查询端口"}}, + {"id":"R21","phase":"reject","category":"noise","text":"联系人已收到短信,工单优先级为P2。拒绝原因是当前账号没有资源系统查询权限。","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R22","phase":"reject","category":"noise","text":"客户地址信息无需处理。由于资源系统维护,无法取得端口数据,拒绝协商。","expected":{"rejection_reason":"资源系统维护,无法取得端口数据"}}, + {"id":"R23","phase":"reject","category":"business","text":"传输资源库未对本租户开放查询能力,无法补充专线端口,拒绝本次信息协商。","expected":{"rejection_reason":"传输资源库未对本租户开放查询能力"}}, + {"id":"R24","phase":"reject","category":"business","text":"资源同步任务延迟,网管侧尚未获得该专线端口映射,故暂时拒绝回复。","expected":{"rejection_reason":"资源同步任务延迟,网管侧尚未获得该专线端口映射"}}, + {"id":"R25","phase":"reject","category":"format","text":"拒绝;rejection_reason=当前账号没有资源系统查询权限。","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R26","phase":"reject","category":"format","text":"结果:Reject。原因:资源系统维护,暂不能查询。","expected":{"rejection_reason":"资源系统维护,暂不能查询"}}, + {"id":"R27","phase":"reject","category":"colloquial","text":"这次先不接,账号没权限查资源库。","expected":{"rejection_reason":"账号没权限查资源库"}}, + {"id":"R28","phase":"reject","category":"colloquial","text":"查不了,资源系统在检修,拒绝吧。","expected":{"rejection_reason":"资源系统在检修"}}, + {"id":"R29","phase":"reject","category":"long","text":"我方已尝试通过资源管理系统查询该专线的接入端口和投诉信息,但当前使用的运维账号没有被授权访问相关资源域。由于无法保证返回数据的正确性,拒绝本次信息协商。","expected":{"rejection_reason":"当前使用的运维账号没有被授权访问相关资源域"}}, + {"id":"R30","phase":"reject","category":"long","text":"本次协商暂不能接受。资源系统正在执行例行维护,端口与业务映射查询功能已暂停;在服务恢复前无法提供可靠的补充信息。","expected":{"rejection_reason":"资源系统正在执行例行维护,端口与业务映射查询功能已暂停"}}, + {"id":"R31","phase":"reject","category":"whitespace","text":"拒绝本次协商。\n原因:当前账号没有资源系统查询权限。","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R32","phase":"reject","category":"whitespace","text":"Reject\n资源查询服务异常,暂无法取得端口信息。","expected":{"rejection_reason":"资源查询服务异常,暂无法取得端口信息"}}, + {"id":"R33","phase":"reject","category":"explicit","text":"明确拒绝信息协商,唯一原因是资源库访问权限被安全策略禁用。","expected":{"rejection_reason":"资源库访问权限被安全策略禁用"}} +] diff --git a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/qwen.env.example b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/qwen.env.example new file mode 100644 index 00000000..92ea852d --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/qwen.env.example @@ -0,0 +1,13 @@ +# Copy this file to a local, ignored location in the target network before running the evaluation. +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=classpath +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=qwen3-32b +A2AT_LLM_BASE_URL=http://71.77.65.40:8008/v1 +A2AT_LLM_API_KEY=not-needed +A2AT_LLM_TEMPERATURE=0 +A2AT_LLM_TIMEOUT_SECONDS=60 +A2AT_LLM_DISABLE_SYSTEM_PROXY=true +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory +A2AT_SAMPLE_HOST=127.0.0.1 +A2AT_SAMPLE_PORT=8010 diff --git a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/scenario.json b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/scenario.json new file mode 100644 index 00000000..578e9c01 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/scenario.json @@ -0,0 +1,6 @@ +{ + "scenario": "传输专线业务投诉诊断的信息协商", + "propose_text": "当前专线业务投诉诊断缺少接入端口名称和投诉分类,请发起信息协商,请求对端补充这两项信息。接入端口名称需要提供物理端口或逻辑端口名称,投诉分类应为专线中断或专线质差。", + "accept_text": "接受本次信息协商。接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类为专线质差。", + "reject_text": "拒绝本次信息协商。当前账号没有资源系统查询权限,无法取得接入端口名称和投诉分类。" +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/InformationNegotiationSchemasTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/InformationNegotiationSchemasTest.java new file mode 100644 index 00000000..4583a120 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/InformationNegotiationSchemasTest.java @@ -0,0 +1,53 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.InformationNegotiationSchemas; +import org.junit.jupiter.api.Test; + +class InformationNegotiationSchemasTest { + + @Test + void proposeSchemaDefinesRequestedComplaintInformation() { + Map schema = InformationNegotiationSchemas.propose(); + Map properties = propertiesOf(schema); + + assertEquals("object", schema.get("type")); + assertFalse((Boolean) schema.get("additionalProperties")); + assertEquals(List.of("access_port_name", "complaint_category"), schema.get("required")); + assertStringProperty(properties, "access_port_name"); + assertStringProperty(properties, "complaint_category"); + assertThrows(UnsupportedOperationException.class, () -> schema.put("unexpected", true)); + } + + @Test + void acceptSchemaDefinesSuppliedComplaintInformation() { + Map schema = InformationNegotiationSchemas.accept(); + Map properties = propertiesOf(schema); + + assertEquals(List.of("access_port_name", "complaint_category"), schema.get("required")); + assertStringProperty(properties, "access_port_name"); + assertEquals(List.of("专线中断", "专线质差"), ((Map) properties.get("complaint_category")).get("enum")); + } + + @Test + void rejectSchemaOnlyRequiresAReason() { + Map schema = InformationNegotiationSchemas.reject(); + Map properties = propertiesOf(schema); + + assertEquals(List.of("rejection_reason"), schema.get("required")); + assertStringProperty(properties, "rejection_reason"); + } + + private static void assertStringProperty(Map properties, String name) { + assertEquals("string", ((Map) properties.get(name)).get("type")); + } + + private static Map propertiesOf(Map schema) { + return (Map) schema.get("properties"); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java new file mode 100644 index 00000000..1d709be0 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java @@ -0,0 +1,38 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import net.openan.a2at.sample.private_line_complaint.negotiation.evaluation.NegotiationEvaluationCase; +import net.openan.a2at.sample.private_line_complaint.negotiation.evaluation.NegotiationEvaluationCaseLoader; +import org.junit.jupiter.api.Test; + +class NegotiationEvaluationCaseLoaderTest { + + @Test + void loadsOneHundredManuallyLabelledCasesAcrossAllGenerationPhases() { + var cases = NegotiationEvaluationCaseLoader.load(); + + assertEquals(100, cases.size()); + assertEquals(34, cases.stream().filter(testCase -> testCase.phase().equals("propose")).count()); + assertEquals(33, cases.stream().filter(testCase -> testCase.phase().equals("accept")).count()); + assertEquals(33, cases.stream().filter(testCase -> testCase.phase().equals("reject")).count()); + assertTrue(cases.stream().allMatch(NegotiationEvaluationCaseLoaderTest::isComplete)); + } + + private static boolean isComplete(NegotiationEvaluationCase testCase) { + return !testCase.id().isBlank() + && !testCase.category().isBlank() + && !testCase.text().isBlank() + && testCase.expected() != null + && !testCase.expected().isEmpty() + && expectedKeys(testCase.phase()).equals(testCase.expected().keySet()); + } + + private static java.util.Set expectedKeys(String phase) { + return phase.equals("reject") + ? Map.of("rejection_reason", "").keySet() + : Map.of("access_port_name", "", "complaint_category", "").keySet(); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java new file mode 100644 index 00000000..04c67af7 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java @@ -0,0 +1,48 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationMetadataReader; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.MetadataContent; +import org.junit.jupiter.api.Test; + +class NegotiationMetadataReaderTest { + + @Test + void readsCanonicalNegotiationMetadata() { + Map metadata = Map.of( + ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, + "prompt", + MetadataContent.TEMPLATE_URI_METADATA_KEY, + NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()); + + assertEquals( + "prompt", + NegotiationMetadataReader.readPrompt(metadata, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI)); + } + + @Test + void rejectsMissingPromptAndWrongTemplateOrExtension() { + assertThrows( + IllegalArgumentException.class, + () -> NegotiationMetadataReader.readPrompt( + Map.of( + MetadataContent.TEMPLATE_URI_METADATA_KEY, + NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), + NegotiationSampleFlow.PROPOSE_TEMPLATE_URI)); + assertThrows( + IllegalArgumentException.class, + () -> NegotiationMetadataReader.readPrompt( + Map.of( + ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, + "prompt", + MetadataContent.TEMPLATE_URI_METADATA_KEY, + NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), + NegotiationSampleFlow.PROPOSE_TEMPLATE_URI)); + assertThrows(IllegalArgumentException.class, () -> NegotiationMetadataReader.requireExtension("Task-T/v1")); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java new file mode 100644 index 00000000..2a8cab54 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java @@ -0,0 +1,92 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationDecision; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationScenarioLoader; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.mock.NegotiationMockLlmInstaller; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NegotiationSampleFlowTest { + + @TempDir + Path tempDir; + + @BeforeAll + static void installMockProvider() { + NegotiationMockLlmInstaller.install(); + } + + @Test + void acceptFlowCoversFourApis() throws IOException { + NegotiationSampleFlow.NegotiationFlowResult result = run(NegotiationDecision.ACCEPT); + + assertEquals(NegotiationDecision.ACCEPT, result.decision()); + assertEquals(NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri(), result.propose().templateUri()); + assertEquals(NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri(), result.ending().templateUri()); + assertEquals(ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, result.propose().extensionUri()); + assertContextIsShared(result); + assertEquals( + "P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)", + result.endingData().data().get("access_port_name")); + assertEquals("专线质差", result.endingData().data().get("complaint_category")); + } + + @Test + void rejectFlowCoversFourApis() throws IOException { + NegotiationSampleFlow.NegotiationFlowResult result = run(NegotiationDecision.REJECT); + + assertEquals(NegotiationDecision.REJECT, result.decision()); + assertContextIsShared(result); + assertEquals("当前账号没有资源系统查询权限", result.endingData().data().get("rejection_reason")); + } + + private NegotiationSampleFlow.NegotiationFlowResult run(NegotiationDecision decision) throws IOException { + Path env = envFile(decision.name().toLowerCase() + ".env"); + return NegotiationSampleFlow.run( + new A2ATClient(env), + new A2ATServer(env), + NegotiationScenarioLoader.load(), + decision); + } + + private Path envFile(String fileName) throws IOException { + Path envFile = tempDir.resolve(fileName); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=mock-model + A2AT_LLM_BASE_URL=http://localhost:0 + A2AT_LLM_API_KEY=mock-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(NegotiationMockLlmInstaller.PROVIDER)); + return envFile; + } + + private static void assertContextIsShared(NegotiationSampleFlow.NegotiationFlowResult result) { + Map proposeData = result.proposeData().data(); + Map endingData = result.endingData().data(); + assertEquals(result.requestContext().id(), proposeData.get("id")); + assertEquals(result.requestContext().id(), endingData.get("id")); + assertEquals(result.requestContext().round(), ((Number) proposeData.get("round")).intValue()); + assertEquals(result.requestContext().round(), ((Number) endingData.get("round")).intValue()); + assertEquals(result.requestContext().maxRounds(), ((Number) proposeData.get("maxRounds")).intValue()); + assertEquals(result.requestContext().maxRounds(), ((Number) endingData.get("maxRounds")).intValue()); + assertEquals("物理端口或逻辑端口名称", proposeData.get("access_port_name")); + assertEquals("专线中断或专线质差", proposeData.get("complaint_category")); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java index 26c9c7ed..277da064 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java @@ -15,6 +15,7 @@ import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.server.A2ATServer; import org.junit.jupiter.api.Test; @@ -70,6 +71,25 @@ void resultRecordsExposeTheirPinnedFieldFaces() { "MetadataContent must expose buildMetadataContent"); } + @Test + void sixTargetApisUseStructuredTemplateUri() { + Set targetMethods = Set.of( + "generateNegotiationProposePromptFromText", + "generateNegotiationAcceptPromptFromText", + "generateNegotiationRejectPromptFromText", + "validateAndFillingProposeData", + "validateAndFillingAcceptData", + "validateAndFillingRejectData"); + for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { + Arrays.stream(facade.getMethods()) + .filter(method -> targetMethods.contains(method.getName())) + .forEach(method -> assertEquals( + TemplateUri.class, + method.getParameterTypes()[method.getParameterCount() - 1], + "last parameter of " + facade.getSimpleName() + "." + method.getName())); + } + } + private static void assertExactNegotiationSurface(Class facade) { Method[] allMethods = facade.getMethods(); List negotiationMethods = Arrays.stream(allMethods) @@ -114,4 +134,4 @@ private static List componentNames(Class recordType) { .map(RecordComponent::getName) .collect(Collectors.toList()); } -} \ No newline at end of file +} From 680f466563a642085d1eaf10442872c1f030a645 Mon Sep 17 00:00:00 2001 From: gcw_mr3RVhmf Date: Sat, 22 Aug 2026 16:12:58 +0800 Subject: [PATCH 117/295] feat(sample): improve negotiation evaluation diagnostics Signed-off-by: gcw_mr3RVhmf --- .../NEGOTIATION_QWEN_EVALUATION.zh-CN.md | 20 ++++++-- .../NegotiationEvaluationCaseLoader.java | 27 ++++++++++ .../NegotiationEvaluationProcessLogger.java | 3 +- .../NegotiationQwenEvaluationMain.java | 49 ++++++++++++++++++- .../NegotiationEvaluationCaseLoaderTest.java | 8 +++ ...egotiationEvaluationProcessLoggerTest.java | 36 ++++++++++++++ 6 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLoggerTest.java diff --git a/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md b/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md index 879562d3..cc3b707a 100644 --- a/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md +++ b/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md @@ -24,16 +24,30 @@ mvn -pl a2a-t-sample -am -DskipTests package ``` -再运行。参数依次为 env 文件、汇总报告输出路径、可选的过程日志输出路径;不传第三个参数时,会在报告同目录生成同名的 `-process.jsonl` 文件: +再运行。参数依次为 env 文件、汇总报告输出路径、可选的过程日志输出路径、可选的逗号分隔用例编号;不传第三个参数时,会在报告同目录生成同名的 `-process.jsonl` 文件: ```powershell java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ` a2a-t-sample/target/negotiation-qwen-report.json ` - a2a-t-sample/target/negotiation-qwen-process.jsonl + a2a-t-sample/target/negotiation-qwen-process.jsonl ``` -汇总报告会输出总数、通过数和 `automatic_consistency_rate`,并逐条保存 `input`、`prompt`、`expected`、`actual`、耗时及错误信息。过程日志采用 JSONL,每一行对应一次 SDK 调用;其中包含 `run_id`、用例编号、阶段(`generate` 或 `validate_and_fill`)、请求入参、模型/SDK 返回值、耗时,以及异常类、异常消息、cause 链和截断后的调用栈。 +## SDK 问题最小复现 + +用于提交 SDK 问题前,先执行以下 6 条用例。它们覆盖 Propose 基线及两个失败模式、Accept 的字段缺失问题,以及 Reject 的基线和语义校验失败问题。用例内容和 golden data 仍以 `cases.json` 为唯一来源。 + +```powershell +java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` + a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ` + a2a-t-sample/target/negotiation-qwen-repro-report.json ` + a2a-t-sample/target/negotiation-qwen-repro-process.jsonl ` + P01,P14,P16,A28,R01,R21 +``` + +每次运行会在报告中写入 `git_revision` 和 `case_ids`,便于把结果与 SDK 代码版本和用例集合对应起来。过程日志是严格 JSONL:每行都是一条可独立解析的 JSON 事件。失败事件会额外记录 SDK `code` 和 `slot_errors`;其中 `negotiation_semantic_rejected` 能直接区分语义校验拒绝,`slot_errors` 可定位字段级规则问题。 + +汇总报告会输出总数、通过数和 `automatic_consistency_rate`,并逐条保存 `input`、`prompt`、`expected`、`actual`、耗时及错误信息。过程日志采用 JSONL,每一行对应一次 SDK 调用;其中包含 `run_id`、用例编号、阶段(`generate` 或 `validate_and_fill`)、请求入参、模型/SDK 返回值、耗时,以及异常类、异常消息、SDK 错误码、字段错误、cause 链和截断后的调用栈。 定位时先按 `run_id` 和 `case_id` 将同一用例的两行关联:`generate` 已失败时,重点检查 Sample 传入的自然语言、上下文和模板 URI,以及 SDK 的生成接口异常;生成成功但 `validate_and_fill` 失败时,直接核对生成 Prompt、业务 Schema 和 SDK 校验异常。这样无需重跑模型即可判断问题发生在流程的哪个边界。过程日志不记录 API Key。 diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java index ad008bd2..c0328723 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java @@ -4,7 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.InputStream; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; /** Loads the checked-in, manually labelled Qwen evaluation corpus. */ public final class NegotiationEvaluationCaseLoader { @@ -31,4 +33,29 @@ public static List load() { throw new IllegalStateException("Failed to read negotiation evaluation corpus", exception); } } + + /** + * Loads a named subset from the checked-in corpus for focused problem reproduction. + * + * @param caseIds case identifiers, in desired execution order + * @return selected cases + */ + public static List loadSelected(List caseIds) { + if (caseIds.isEmpty()) { + throw new IllegalArgumentException("At least one evaluation case ID is required"); + } + Set requested = new LinkedHashSet<>(caseIds); + if (requested.size() != caseIds.size()) { + throw new IllegalArgumentException("Duplicate negotiation evaluation case IDs are not allowed: " + caseIds); + } + var casesById = load().stream().collect(java.util.stream.Collectors.toMap( + NegotiationEvaluationCase::id, + testCase -> testCase)); + Set unknown = new LinkedHashSet<>(requested); + unknown.removeAll(casesById.keySet()); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException("Unknown negotiation evaluation case IDs: " + unknown); + } + return caseIds.stream().map(casesById::get).toList(); + } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLogger.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLogger.java index c5737ab9..26e49364 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLogger.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLogger.java @@ -1,6 +1,7 @@ package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import java.io.BufferedWriter; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -22,7 +23,7 @@ final class NegotiationEvaluationProcessLogger implements AutoCloseable { if (parent != null) { Files.createDirectories(parent); } - this.objectMapper = objectMapper; + this.objectMapper = objectMapper.copy().disable(SerializationFeature.INDENT_OUTPUT); this.writer = Files.newBufferedWriter( path, StandardCharsets.UTF_8, diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java index 60d10ae0..fb2587b1 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; @@ -16,6 +17,8 @@ import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleEnvironment; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContext; @@ -38,6 +41,9 @@ public static void main(String[] args) throws IOException { Path envPath = args.length > 0 ? Path.of(args[0]) : NegotiationSampleEnvironment.defaultEnvPath("client"); Path reportPath = args.length > 1 ? Path.of(args[1]) : Path.of("a2a-t-sample", "target", "negotiation-qwen-report.json"); Path processLogPath = args.length > 2 ? Path.of(args[2]) : defaultProcessLogPath(reportPath); + List testCases = args.length > 3 + ? NegotiationEvaluationCaseLoader.loadSelected(parseCaseIds(args[3])) + : NegotiationEvaluationCaseLoader.load(); Map environment = NegotiationSampleEnvironment.read(envPath); requireQwenConfiguration(environment); @@ -47,7 +53,7 @@ public static void main(String[] args) throws IOException { String runId = UUID.randomUUID().toString(); try (NegotiationEvaluationProcessLogger processLogger = new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, processLogPath)) { processLogger.write(runStartedEvent(runId, environment, envPath)); - for (NegotiationEvaluationCase testCase : NegotiationEvaluationCaseLoader.load()) { + for (NegotiationEvaluationCase testCase : testCases) { results.add(runCase(client, server, testCase, runId, processLogger)); } } @@ -58,6 +64,8 @@ public static void main(String[] args) throws IOException { report.put("run_id", runId); report.put("model", environment.get("A2AT_LLM_MODEL")); report.put("base_url", environment.get("A2AT_LLM_BASE_URL")); + report.put("git_revision", gitRevision()); + report.put("case_ids", testCases.stream().map(NegotiationEvaluationCase::id).toList()); report.put("total", results.size()); report.put("passed", passed); report.put("automatic_consistency_rate", (double) passed / results.size()); @@ -196,6 +204,12 @@ private static Map errorDetails(RuntimeException exception) { Map error = new LinkedHashMap<>(); error.put("class", exception.getClass().getName()); error.put("message", exception.getMessage()); + if (exception instanceof A2ATError a2atError) { + error.put("code", a2atError.getCode()); + } + if (exception instanceof A2ATParamExtractionError extractionError) { + error.put("slot_errors", extractionError.getErrors()); + } error.put("cause_chain", causeChain(exception)); error.put("stack_trace", Arrays.stream(exception.getStackTrace()).limit(20).map(StackTraceElement::toString).toList()); return error; @@ -211,6 +225,39 @@ private static List causeChain(Throwable exception) { return causes; } + private static List parseCaseIds(String argument) { + List caseIds = Arrays.stream(argument.split(",")) + .map(String::trim) + .filter(caseId -> !caseId.isEmpty()) + .toList(); + if (caseIds.isEmpty()) { + throw new IllegalArgumentException("The fourth argument must contain at least one comma-separated case ID"); + } + return caseIds; + } + + private static String gitRevision() { + String configuredRevision = System.getProperty("a2at.sample.gitRevision"); + if (configuredRevision != null && !configuredRevision.isBlank()) { + return configuredRevision; + } + try { + Process process = new ProcessBuilder("git", "rev-parse", "HEAD") + .redirectErrorStream(true) + .start(); + String revision; + try (var input = process.getInputStream()) { + revision = new String(input.readAllBytes(), StandardCharsets.UTF_8).trim(); + } + return process.waitFor() == 0 && !revision.isBlank() ? revision : "unknown"; + } catch (IOException exception) { + return "unknown"; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return "unknown"; + } + } + private static Path defaultProcessLogPath(Path reportPath) { String filename = reportPath.getFileName().toString(); int extensionStart = filename.lastIndexOf('.'); diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java index 1d709be0..3af63630 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Map; +import java.util.List; import net.openan.a2at.sample.private_line_complaint.negotiation.evaluation.NegotiationEvaluationCase; import net.openan.a2at.sample.private_line_complaint.negotiation.evaluation.NegotiationEvaluationCaseLoader; import org.junit.jupiter.api.Test; @@ -21,6 +22,13 @@ void loadsOneHundredManuallyLabelledCasesAcrossAllGenerationPhases() { assertTrue(cases.stream().allMatch(NegotiationEvaluationCaseLoaderTest::isComplete)); } + @Test + void loadsFocusedReproductionCasesFromTheCanonicalCorpus() { + var cases = NegotiationEvaluationCaseLoader.loadSelected(List.of("R21", "P01", "A28")); + + assertEquals(List.of("R21", "P01", "A28"), cases.stream().map(NegotiationEvaluationCase::id).toList()); + } + private static boolean isComplete(NegotiationEvaluationCase testCase) { return !testCase.id().isBlank() && !testCase.category().isBlank() diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLoggerTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLoggerTest.java new file mode 100644 index 00000000..b5daaaa6 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLoggerTest.java @@ -0,0 +1,36 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NegotiationEvaluationProcessLoggerTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + @TempDir + Path temporaryDirectory; + + @Test + void writesOneCompactJsonObjectPerLine() throws Exception { + Path log = temporaryDirectory.resolve("process.jsonl"); + try (NegotiationEvaluationProcessLogger logger = new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, log)) { + logger.write(Map.of("stage", "generate", "request", Map.of("text", "first"))); + logger.write(Map.of("stage", "validate_and_fill", "request", Map.of("text", "second"))); + } + + List lines = Files.readAllLines(log); + assertEquals(2, lines.size()); + assertFalse(lines.stream().anyMatch(String::isBlank)); + assertEquals("generate", OBJECT_MAPPER.readTree(lines.get(0)).get("stage").asText()); + assertEquals("validate_and_fill", OBJECT_MAPPER.readTree(lines.get(1)).get("stage").asText()); + } +} From bf68ae937260c8ad85118853ac90702852dfc345 Mon Sep 17 00:00:00 2001 From: gcw_mr3RVhmf Date: Sat, 22 Aug 2026 17:35:37 +0800 Subject: [PATCH 118/295] fix(sample): align negotiation validation api Signed-off-by: gcw_mr3RVhmf --- .../NEGOTIATION_API_EXAMPLES.zh-CN.md | 20 +- .../NEGOTIATION_QWEN_EVALUATION.zh-CN.md | 12 +- .../NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md | 21 +- .../client/NegotiationClientMain.java | 4 +- .../evaluation/NegotiationEvaluationCase.java | 14 +- .../NegotiationQwenEvaluationMain.java | 38 +++- .../server/NegotiationAgentExecutor.java | 2 +- .../shared/NegotiationSampleFlow.java | 6 +- .../evaluation/cases.json | 200 +++++++++--------- .../NegotiationEvaluationCaseLoaderTest.java | 19 ++ .../api/NegotiationV3ApiSurfaceTest.java | 6 +- 11 files changed, 196 insertions(+), 146 deletions(-) diff --git a/a2a-t-sample/NEGOTIATION_API_EXAMPLES.zh-CN.md b/a2a-t-sample/NEGOTIATION_API_EXAMPLES.zh-CN.md index 4bd2a2fc..0d2d6457 100644 --- a/a2a-t-sample/NEGOTIATION_API_EXAMPLES.zh-CN.md +++ b/a2a-t-sample/NEGOTIATION_API_EXAMPLES.zh-CN.md @@ -149,7 +149,7 @@ Map proposeSchema = Map.of( "description", "请求的投诉分类及允许的专线投诉类型")), "required", List.of("access_port_name", "complaint_category")); -FilledParamData proposeData = server.validateAndFillingProposeData( +FilledParamData proposeData = server.validateProposePromptAndDataFilling( receivedProposePrompt, proposeSchema, proposeTemplateUri); @@ -219,7 +219,7 @@ metadata 的结构为: 接口: ```java -FilledParamData validateAndFillingProposeData( +FilledParamData validateProposePromptAndDataFilling( String prompt, Map schema, TemplateUri templateUri) @@ -232,7 +232,7 @@ String receivedProposePrompt = NegotiationMetadataReader.readPrompt( messageMetadata, proposeTemplateUri); -FilledParamData proposeData = server.validateAndFillingProposeData( +FilledParamData proposeData = server.validateProposePromptAndDataFilling( receivedProposePrompt, proposeSchema, proposeTemplateUri); @@ -298,7 +298,7 @@ Negotiation-T/information-negotiation/accept-reject/v1 接口: ```java -FilledParamData validateAndFillingAcceptData( +FilledParamData validateAcceptPromptAndDataFilling( String prompt, Map schema, TemplateUri templateUri) @@ -311,7 +311,7 @@ String receivedAcceptPrompt = NegotiationMetadataReader.readPrompt( artifactMetadata, endingTemplateUri); -FilledParamData acceptData = client.validateAndFillingAcceptData( +FilledParamData acceptData = client.validateAcceptPromptAndDataFilling( receivedAcceptPrompt, acceptSchema, endingTemplateUri); @@ -362,7 +362,7 @@ Map artifactMetadata = reject.buildMetadataContent(); 接口: ```java -FilledParamData validateAndFillingRejectData( +FilledParamData validateRejectPromptAndDataFilling( String prompt, Map schema, TemplateUri templateUri) @@ -375,7 +375,7 @@ String receivedRejectPrompt = NegotiationMetadataReader.readPrompt( artifactMetadata, endingTemplateUri); -FilledParamData rejectData = client.validateAndFillingRejectData( +FilledParamData rejectData = client.validateRejectPromptAndDataFilling( receivedRejectPrompt, rejectSchema, endingTemplateUri); @@ -403,7 +403,7 @@ MetadataContent propose = client.generateNegotiationProposePromptFromText( proposeText, context, proposeTemplateUri); String proposePrompt = NegotiationMetadataReader.readPrompt( propose.buildMetadataContent(), proposeTemplateUri); -FilledParamData proposeData = server.validateAndFillingProposeData( +FilledParamData proposeData = server.validateProposePromptAndDataFilling( proposePrompt, proposeSchema, proposeTemplateUri); NegotiationContext responseContext = new NegotiationContext( @@ -415,7 +415,7 @@ MetadataContent accept = server.generateNegotiationAcceptPromptFromText( acceptText, responseContext, endingTemplateUri); String acceptPrompt = NegotiationMetadataReader.readPrompt( accept.buildMetadataContent(), endingTemplateUri); -FilledParamData acceptData = client.validateAndFillingAcceptData( +FilledParamData acceptData = client.validateAcceptPromptAndDataFilling( acceptPrompt, acceptSchema, endingTemplateUri); ``` @@ -426,7 +426,7 @@ MetadataContent reject = server.generateNegotiationRejectPromptFromText( rejectText, responseContext, endingTemplateUri); String rejectPrompt = NegotiationMetadataReader.readPrompt( reject.buildMetadataContent(), endingTemplateUri); -FilledParamData rejectData = client.validateAndFillingRejectData( +FilledParamData rejectData = client.validateRejectPromptAndDataFilling( rejectPrompt, rejectSchema, endingTemplateUri); ``` diff --git a/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md b/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md index cc3b707a..bc5ec12d 100644 --- a/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md +++ b/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md @@ -1,8 +1,8 @@ # Negotiation-T Qwen3-32B 批量验证 -此入口验证传输专线投诉诊断场景的 3 个自然语言生成接口:Propose、Accept、Reject。用例集固定为 100 条,由人工标注期望业务字段;每条用例还会调用对应的校验/提参接口,检查生成 Prompt 能否被 SDK 解析为期望字段并保留协商上下文。 +此入口验证传输专线投诉诊断场景的 3 个自然语言生成接口:Propose、Accept、Reject。用例集固定为 100 条,由人工提供自然语言输入、补齐后的标准 Prompt 和期望业务字段;每条用例随后使用补齐 Prompt 调用对应的校验/提参接口。 -自动结果衡量的是“生成 Prompt → 校验提参”的一致性,不能直接等同于 LLM 语义准确率。报告会保存原始输入、生成 Prompt、提取结果和错误信息。应人工复核全部失败用例,并在每个阶段从通过用例中至少抽查 10 条。 +生成接口与校验接口在用例中相互独立:生成 Prompt 用于人工判断生成质量,校验接口的输入是 `cases.json` 中预置的 `completedPrompt`,不能把生成 Prompt 直接送入校验接口。自动通过率只表示生成调用成功,且补齐 Prompt 能被校验并提取为期望字段,不能直接等同于生成语义准确率。应人工复核生成 Prompt,并复核全部校验失败用例。 ## 用例集 @@ -12,7 +12,7 @@ - Accept 33 条; - Reject 33 条。 -覆盖完整表述、短句、同义改写、口语化表达、中英文混合、噪声信息、字段顺序变化、换行和业务上下文等类型。每条用例的 `expected` 是人工 golden data;修改业务 Schema 或场景语义时,需要同步复核该文件。 +覆盖完整表述、短句、同义改写、口语化表达、中英文混合、噪声信息、字段顺序变化、换行和业务上下文等类型。每条用例的 `completedPrompt` 是人工构造的补齐报文模板,`{{id}}`、`{{round}}`、`{{maxRounds}}` 会在运行时替换为本次协商上下文;`expected` 是人工 golden data。修改业务 Schema、报文格式或场景语义时,需要同步复核这两个字段。 ## 在目标网络执行 @@ -35,7 +35,7 @@ java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` ## SDK 问题最小复现 -用于提交 SDK 问题前,先执行以下 6 条用例。它们覆盖 Propose 基线及两个失败模式、Accept 的字段缺失问题,以及 Reject 的基线和语义校验失败问题。用例内容和 golden data 仍以 `cases.json` 为唯一来源。 +完整执行前可先运行以下 6 条用例,确认三类生成接口和三类补齐报文校验接口均能调用。用例内容、补齐 Prompt 和 golden data 均以 `cases.json` 为唯一来源。 ```powershell java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` @@ -47,9 +47,9 @@ java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` 每次运行会在报告中写入 `git_revision` 和 `case_ids`,便于把结果与 SDK 代码版本和用例集合对应起来。过程日志是严格 JSONL:每行都是一条可独立解析的 JSON 事件。失败事件会额外记录 SDK `code` 和 `slot_errors`;其中 `negotiation_semantic_rejected` 能直接区分语义校验拒绝,`slot_errors` 可定位字段级规则问题。 -汇总报告会输出总数、通过数和 `automatic_consistency_rate`,并逐条保存 `input`、`prompt`、`expected`、`actual`、耗时及错误信息。过程日志采用 JSONL,每一行对应一次 SDK 调用;其中包含 `run_id`、用例编号、阶段(`generate` 或 `validate_and_fill`)、请求入参、模型/SDK 返回值、耗时,以及异常类、异常消息、SDK 错误码、字段错误、cause 链和截断后的调用栈。 +汇总报告会分别输出 `generation_succeeded`、`validation_succeeded`、通过数和 `automatic_consistency_rate`,并逐条保存 `input`、`generated_prompt`、`completed_prompt`、`expected`、`actual`、阶段状态、耗时及结构化错误信息。即使校验抛出异常,报告仍保留生成 Prompt 和补齐 Prompt,并明确记录 `actual: null`。过程日志采用 JSONL,每一行对应一次 SDK 调用;其中包含 `run_id`、用例编号、阶段(`generate` 或 `validate_and_fill`)、请求入参、模型/SDK 返回值、耗时,以及异常类、异常消息、SDK 错误码、字段错误、cause 链和截断后的调用栈。 -定位时先按 `run_id` 和 `case_id` 将同一用例的两行关联:`generate` 已失败时,重点检查 Sample 传入的自然语言、上下文和模板 URI,以及 SDK 的生成接口异常;生成成功但 `validate_and_fill` 失败时,直接核对生成 Prompt、业务 Schema 和 SDK 校验异常。这样无需重跑模型即可判断问题发生在流程的哪个边界。过程日志不记录 API Key。 +定位时先按 `run_id` 和 `case_id` 将同一用例的两行关联:`generate` 已失败时,重点检查 Sample 传入的自然语言、上下文和模板 URI;生成成功后,人工比较 `generated_prompt` 与场景要求;`validate_and_fill` 失败时,核对 `completed_prompt`、业务 Schema 和 SDK 校验异常。过程日志不记录 API Key。 执行过程会产生约 200 次模型调用(每个用例一次生成、一次校验/提参);以 Qwen 响应时延为准设置 `A2AT_LLM_TIMEOUT_SECONDS`。 diff --git a/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md b/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md index 10ef4d19..e100f90e 100644 --- a/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md +++ b/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md @@ -45,15 +45,15 @@ sequenceDiagram C->>C: generateNegotiationProposePromptFromText C->>S: A2A Message metadata 携带 Propose - S->>S: validateAndFillingProposeData + S->>S: validateProposePromptAndDataFilling alt Accept 分支 S->>S: generateNegotiationAcceptPromptFromText S-->>C: A2A Artifact metadata 携带 Accept - C->>C: validateAndFillingAcceptData + C->>C: validateAcceptPromptAndDataFilling else Reject 分支 S->>S: generateNegotiationRejectPromptFromText S-->>C: A2A Artifact metadata 携带 Reject - C->>C: validateAndFillingRejectData + C->>C: validateRejectPromptAndDataFilling end ``` @@ -64,11 +64,11 @@ sequenceDiagram | 阶段 | 调用方 | API | Template URI | 结果 | | --- | --- | --- | --- | --- | | 生成请求 | Client | `generateNegotiationProposePromptFromText` | `Negotiation-T/information-negotiation/propose/v1` | `MetadataContent` | -| 校验请求 | Server | `validateAndFillingProposeData` | 同上 | `FilledParamData` | +| 校验请求 | Server | `validateProposePromptAndDataFilling` | 同上 | `FilledParamData` | | 生成接受 | Server | `generateNegotiationAcceptPromptFromText` | `Negotiation-T/information-negotiation/accept-reject/v1` | `MetadataContent` | -| 校验接受 | Client | `validateAndFillingAcceptData` | 同上 | `FilledParamData` | +| 校验接受 | Client | `validateAcceptPromptAndDataFilling` | 同上 | `FilledParamData` | | 生成拒绝 | Server | `generateNegotiationRejectPromptFromText` | `Negotiation-T/information-negotiation/accept-reject/v1` | `MetadataContent` | -| 校验拒绝 | Client | `validateAndFillingRejectData` | 同上 | `FilledParamData` | +| 校验拒绝 | Client | `validateRejectPromptAndDataFilling` | 同上 | `FilledParamData` | 关键类型: @@ -209,7 +209,7 @@ Sample 内部 Schema 工厂通过 `LinkedHashMap` 构建并返回不可变 Map 1. 校验 `A2A-Extensions` 包含 Negotiation-T 规范 URI。 2. 从 `Message.metadata` 取 `templateUri` 和 Propose Prompt。 -3. 调用 `validateAndFillingProposeData`。 +3. 调用 `validateProposePromptAndDataFilling`。 4. 从返回值重建 `NegotiationContext`。 5. 根据 `A2AT_SAMPLE_NEGOTIATION_DECISION` 选择 Accept 或 Reject,默认 Accept。 6. 调用对应 from-text 生成 API。 @@ -424,10 +424,11 @@ java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` 验收时读取 `negotiation-qwen-report.json` 和 `negotiation-qwen-process.jsonl`: -- 汇总报告包含每条用例的 `expected`、`actual`、最终 `passed` 及耗时。 +- 每条用例独立提供 `completedPrompt`;生成接口输出用于评估生成质量,不能直接作为校验接口入参。 +- 汇总报告包含每条用例的 `generated_prompt`、`completed_prompt`、`expected`、`actual`、最终 `passed` 及耗时。 - 过程日志按 `run_id`、`case_id` 关联两个阶段:`generate` 与 `validate_and_fill`。 -- `generate` 失败时,优先检查日志中的 `request.text`、`context`、`template_uri` 和异常;`validate_and_fill` 失败时,核对 `request.prompt`、`request.schema` 和异常链。 -- 两阶段均成功但汇总结果失败时,比较 `expected`、`actual` 和生成 Prompt,记录为模型输出差异或断言口径问题,不应笼统归因于 SDK。 +- `generate` 失败时,优先检查日志中的 `request.text`、`context`、`template_uri` 和异常;`validate_and_fill` 失败时,核对 Case 补齐报文、`request.schema` 和异常链。 +- 两阶段均成功但汇总结果失败时,比较 `expected` 与 `actual`;生成 Prompt 的语义质量单独人工检查。 过程日志不记录 API Key。完成后在验证文档中记录模型、执行时间、报告路径、通过率以及人工抽检结论。详细字段说明见 `NEGOTIATION_QWEN_EVALUATION.zh-CN.md`。 diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java index e274daca..c1a13c03 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java @@ -68,9 +68,9 @@ public static void main(String[] args) { ? NegotiationDecision.REJECT : NegotiationDecision.ACCEPT; Map result = decision == NegotiationDecision.ACCEPT - ? client.validateAndFillingAcceptData( + ? client.validateAcceptPromptAndDataFilling( prompt, InformationNegotiationSchemas.accept(), NegotiationSampleFlow.ENDING_TEMPLATE_URI).data() - : client.validateAndFillingRejectData( + : client.validateRejectPromptAndDataFilling( prompt, InformationNegotiationSchemas.reject(), NegotiationSampleFlow.ENDING_TEMPLATE_URI).data(); System.out.println("[negotiation-client] result=" + result); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java index acd43e6f..ab47ca36 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java @@ -4,5 +4,17 @@ /** One manually labelled natural-language evaluation case. */ public record NegotiationEvaluationCase( - String id, String phase, String category, String text, Map expected) { + String id, + String phase, + String category, + String text, + String completedPrompt, + Map expected) { + + public String renderCompletedPrompt(String contextId, int round, int maxRounds) { + return completedPrompt + .replace("{{id}}", contextId) + .replace("{{round}}", Integer.toString(round)) + .replace("{{maxRounds}}", Integer.toString(maxRounds)); + } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java index fb2587b1..c1214253 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -27,8 +27,8 @@ /** * Runs the 100 manually labelled cases against a configured real LLM and writes a JSON report. * - *

      The automatic result measures generated-prompt to schema-extraction consistency. The report preserves each - * prompt so a reviewer can perform the required semantic spot check without rerunning the model. + *

      Each case independently verifies prompt generation and validates a manually completed prompt. The generated + * prompt, completed prompt, and extracted data are all preserved for review. */ public final class NegotiationQwenEvaluationMain { @@ -59,6 +59,12 @@ public static void main(String[] args) throws IOException { } long passed = results.stream().filter(result -> Boolean.TRUE.equals(result.get("passed"))).count(); + long generationSucceeded = results.stream() + .filter(result -> Boolean.TRUE.equals(result.get("generation_succeeded"))) + .count(); + long validationSucceeded = results.stream() + .filter(result -> Boolean.TRUE.equals(result.get("validation_succeeded"))) + .count(); Map report = new LinkedHashMap<>(); report.put("generated_at", Instant.now().toString()); report.put("run_id", runId); @@ -67,6 +73,8 @@ public static void main(String[] args) throws IOException { report.put("git_revision", gitRevision()); report.put("case_ids", testCases.stream().map(NegotiationEvaluationCase::id).toList()); report.put("total", results.size()); + report.put("generation_succeeded", generationSucceeded); + report.put("validation_succeeded", validationSucceeded); report.put("passed", passed); report.put("automatic_consistency_rate", (double) passed / results.size()); report.put("note", "Automatic consistency is not semantic accuracy; manually review the preserved prompts, especially failures and a representative sample of passes."); @@ -93,18 +101,28 @@ private static Map runCase( result.put("input", testCase.text()); result.put("expected", testCase.expected()); NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3); + String completedPrompt = testCase.renderCompletedPrompt(context.id(), context.round(), context.maxRounds()); + result.put("completed_prompt", completedPrompt); + result.put("actual", null); + result.put("generation_succeeded", false); + result.put("validation_succeeded", false); + result.put("expected_matched", false); try { MetadataContent content = generateWithLog(client, testCase, context, runId, processLogger); - FilledParamData filled = validateWithLog(server, testCase, content.promptText(), runId, processLogger); - Map actual = filled.data(); - result.put("prompt", content.promptText()); + result.put("generation_succeeded", true); + result.put("generated_prompt", content.promptText()); result.put("template_uri", content.templateUri()); result.put("extension_uri", content.extensionUri()); + FilledParamData filled = validateWithLog(server, testCase, completedPrompt, runId, processLogger); + Map actual = filled.data(); + boolean expectedMatched = expectedValuesMatch(testCase.expected(), actual) && contextMatches(context, actual); result.put("actual", actual); - result.put("passed", expectedValuesMatch(testCase.expected(), actual) && contextMatches(context, actual)); + result.put("validation_succeeded", true); + result.put("expected_matched", expectedMatched); + result.put("passed", expectedMatched); } catch (RuntimeException exception) { result.put("passed", false); - result.put("error", exception.getClass().getSimpleName() + ": " + exception.getMessage()); + result.put("error", errorDetails(exception)); } result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); return result; @@ -295,11 +313,11 @@ private static MetadataContent generate(A2ATClient client, NegotiationEvaluation private static FilledParamData validate(A2ATServer server, NegotiationEvaluationCase testCase, String prompt) { return switch (testCase.phase()) { - case "propose" -> server.validateAndFillingProposeData( + case "propose" -> server.validateProposePromptAndDataFilling( prompt, InformationNegotiationSchemas.propose(), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); - case "accept" -> server.validateAndFillingAcceptData( + case "accept" -> server.validateAcceptPromptAndDataFilling( prompt, InformationNegotiationSchemas.accept(), NegotiationSampleFlow.ENDING_TEMPLATE_URI); - case "reject" -> server.validateAndFillingRejectData( + case "reject" -> server.validateRejectPromptAndDataFilling( prompt, InformationNegotiationSchemas.reject(), NegotiationSampleFlow.ENDING_TEMPLATE_URI); default -> throw new IllegalArgumentException("Unsupported evaluation phase: " + testCase.phase()); }; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java index 315a90f5..984519d3 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java @@ -40,7 +40,7 @@ public void execute(RequestContext requestContext, AgentEmitter agentEmitter) th Message message = requestContext.getMessage(); String proposePrompt = NegotiationMetadataReader.readPrompt( message == null ? null : message.metadata(), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); - FilledParamData proposeData = server.validateAndFillingProposeData( + FilledParamData proposeData = server.validateProposePromptAndDataFilling( proposePrompt, InformationNegotiationSchemas.propose(), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); NegotiationContext context = NegotiationSampleFlow.contextFrom(proposeData.data()); MetadataContent ending = decision == NegotiationDecision.ACCEPT diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java index 2a3afcc5..f759fc1d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java @@ -30,7 +30,7 @@ public static NegotiationFlowResult run( client.generateNegotiationProposePromptFromText(scenario.proposeText(), requestContext, PROPOSE_TEMPLATE_URI); String proposePrompt = NegotiationMetadataReader.readPrompt(propose.buildMetadataContent(), PROPOSE_TEMPLATE_URI); FilledParamData proposeData = - server.validateAndFillingProposeData(proposePrompt, InformationNegotiationSchemas.propose(), PROPOSE_TEMPLATE_URI); + server.validateProposePromptAndDataFilling(proposePrompt, InformationNegotiationSchemas.propose(), PROPOSE_TEMPLATE_URI); NegotiationContext responseContext = contextFrom(proposeData.data()); MetadataContent ending = decision == NegotiationDecision.ACCEPT @@ -38,9 +38,9 @@ public static NegotiationFlowResult run( : server.generateNegotiationRejectPromptFromText(scenario.rejectText(), responseContext, ENDING_TEMPLATE_URI); String endingPrompt = NegotiationMetadataReader.readPrompt(ending.buildMetadataContent(), ENDING_TEMPLATE_URI); FilledParamData endingData = decision == NegotiationDecision.ACCEPT - ? client.validateAndFillingAcceptData( + ? client.validateAcceptPromptAndDataFilling( endingPrompt, InformationNegotiationSchemas.accept(), ENDING_TEMPLATE_URI) - : client.validateAndFillingRejectData( + : client.validateRejectPromptAndDataFilling( endingPrompt, InformationNegotiationSchemas.reject(), ENDING_TEMPLATE_URI); return new NegotiationFlowResult(requestContext, propose, proposeData, ending, endingData, decision); } diff --git a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json index da9273ad..2972cca4 100644 --- a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json +++ b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json @@ -1,102 +1,102 @@ [ - {"id":"P01","phase":"propose","category":"complete","text":"请发起信息协商,补充接入端口名称和投诉分类;端口可为物理或逻辑端口,分类限专线中断或专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P02","phase":"propose","category":"complete","text":"专线投诉诊断需要对端提供端口名和投诉类型,端口接受物理、逻辑名称,类型为专线中断或专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P03","phase":"propose","category":"paraphrase","text":"麻烦补齐本次专线投诉的接入端口及故障分类,端口要能定位到物理或逻辑接口,故障仅在中断、质差两类中选择。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P04","phase":"propose","category":"paraphrase","text":"发起信息协商:请给出物理端口或逻辑端口名称,并说明投诉属于专线中断还是专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P05","phase":"propose","category":"short","text":"请补充端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P06","phase":"propose","category":"short","text":"需要协商获取接入端口名和专线投诉类别:端口支持物理或逻辑,类别为中断或质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P07","phase":"propose","category":"formal","text":"为开展传输专线业务投诉诊断,请对端补充接入端口标识和投诉分类。其中端口标识应为物理或逻辑端口名称,投诉分类可取专线中断、专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P08","phase":"propose","category":"formal","text":"当前诊断参数不完整。请求协商获得:1)接入物理/逻辑端口名称;2)投诉分类,候选值为专线中断、专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P09","phase":"propose","category":"punctuation","text":"请补充接入端口名称;物理端口、逻辑端口均可。还请确认投诉分类:专线中断?专线质差?","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P10","phase":"propose","category":"punctuation","text":"信息协商请求【端口:物理或逻辑端口名称】【投诉:专线中断或专线质差】。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P11","phase":"propose","category":"context","text":"客户反映业务异常,尚无法确定网络侧故障。请提供接入端口名称(物理或逻辑均可)以及投诉分类(专线中断或专线质差),以便继续诊断。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P12","phase":"propose","category":"context","text":"工单号已登记,但缺少诊断关键信息。请信息协商补充物理/逻辑接入端口名和专线中断/专线质差的投诉类别。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P13","phase":"propose","category":"synonym","text":"请对端回填入网接口名称(可填物理口或逻辑口)及专线故障现象分类(中断、质差)。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P14","phase":"propose","category":"synonym","text":"需要拿到接入侧端口标识,物理接口或逻辑接口均满足;同时确定本次专线投诉是断线还是质量劣化。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P15","phase":"propose","category":"multisentence","text":"请启动信息协商。诊断需要端口信息。请提供物理端口或逻辑端口名称。另请说明投诉分类是专线中断还是专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P16","phase":"propose","category":"multisentence","text":"当前不能直接定位故障。先确认接入端口名称,物理、逻辑名称都可以;再确认投诉类型,只允许专线中断和专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P17","phase":"propose","category":"polite","text":"烦请协助补充专线接入端口名称(物理或逻辑端口均可)与投诉分类(专线中断或专线质差),谢谢。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P18","phase":"propose","category":"polite","text":"请您确认一下接入端口是哪个物理/逻辑端口,并选择投诉为专线中断还是专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P19","phase":"propose","category":"order-reversed","text":"先请判定投诉类别:专线中断或专线质差;随后提供接入端口名称,物理端口或逻辑端口均可。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P20","phase":"propose","category":"order-reversed","text":"协商所需字段包括投诉分类(专线中断、专线质差)和接入端口名称(物理或逻辑端口)。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P21","phase":"propose","category":"mixed-language","text":"Please provide the access port name, either physical or logical, and classify this private-line complaint as 专线中断 or 专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P22","phase":"propose","category":"mixed-language","text":"发起 negotiation,需补充 access port(物理端口或逻辑端口名称)及 complaint category(专线中断或专线质差)。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P23","phase":"propose","category":"noise","text":"客户联系人为张三,联系电话已脱敏。请忽略这些非诊断信息,补充物理或逻辑接入端口名称,以及专线中断或专线质差的投诉分类。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P24","phase":"propose","category":"noise","text":"告警数量暂不确定,工单优先级为P2。请协商取得接入端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P25","phase":"propose","category":"business","text":"SPN专线投诉根因诊断前,请对端提供承载该业务的物理或逻辑接入端口名,并标注故障属于专线中断还是专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P26","phase":"propose","category":"business","text":"传输工作台需补齐入参:接入端口的物理/逻辑名称、专线投诉的中断/质差分类,请发起协商。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P27","phase":"propose","category":"imperative","text":"向对端要端口名和投诉类别。端口名必须是物理或者逻辑端口;类别限定专线中断、专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P28","phase":"propose","category":"imperative","text":"补齐以下两项再诊断:物理/逻辑接入端口名称;专线中断/专线质差投诉分类。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P29","phase":"propose","category":"long","text":"本次请求用于传输专线业务投诉诊断,现有描述只说明客户感知业务异常,尚不具备定位条件。请通过信息协商要求对端补充接入端口名称,值可以是物理端口名称或逻辑端口名称;同时要求补充投诉分类,允许的值为专线中断或专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P30","phase":"propose","category":"long","text":"为避免将客户侧问题误判为网络问题,需要先获取网络定位参数。请请求对端给出接入端口的物理或逻辑标识,并在专线中断、专线质差两个候选项中说明投诉类型。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P31","phase":"propose","category":"format","text":"字段1:接入端口名称,要求:物理端口或逻辑端口名称。字段2:投诉分类,要求:专线中断或专线质差。请发起协商。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P32","phase":"propose","category":"format","text":"需对端回复:access_port_name=物理端口或逻辑端口名称;complaint_category=专线中断或专线质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P33","phase":"propose","category":"colloquial","text":"这单还差端口和投诉类型,麻烦要一下:端口给物理口或逻辑口都行,类型就在专线中断、专线质差里选。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P34","phase":"propose","category":"colloquial","text":"先把专线的接入端口名和故障分类问清楚吧,端口支持物理/逻辑,分类只有中断或质差。","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"A01","phase":"accept","category":"complete","text":"接受本次协商。接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类为专线质差。","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A02","phase":"accept","category":"complete","text":"同意补充信息:物理端口 P782-天河-PTN7900-01-GE0/1/2,故障类型是专线中断。","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A03","phase":"accept","category":"paraphrase","text":"接受。用于诊断的逻辑接入端口是 LAG-广州-001.100,客户投诉为专线质差。","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A04","phase":"accept","category":"paraphrase","text":"可以提供:端口名为 PE1-GZ-ETH-0/0/0.200,专线已中断。","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A05","phase":"accept","category":"short","text":"接受,端口 NCE-PTN-23-1/1/7,投诉:专线质差。","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A06","phase":"accept","category":"short","text":"Accept。access port=SPN-广州-1/0/24;分类=专线中断。","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A07","phase":"accept","category":"formal","text":"接受该信息协商请求。补充的接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类确定为专线质差。","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A08","phase":"accept","category":"formal","text":"经资源核查,接受协商并反馈:物理接口 P782-天河-PTN7900-01-GE0/1/2;本次投诉为专线中断。","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A09","phase":"accept","category":"punctuation","text":"接受!接入端口:LAG-广州-001.100;投诉分类:专线质差。","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A10","phase":"accept","category":"punctuation","text":"同意回复【端口=PE1-GZ-ETH-0/0/0.200】【类型=专线中断】。","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A11","phase":"accept","category":"context","text":"客户报障期间业务偶发丢包,接受协商,确认接入端口为 NCE-PTN-23-1/1/7,投诉分类为专线质差。","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A12","phase":"accept","category":"context","text":"告警显示链路不可达。接受本次协商,端口为 SPN-广州-1/0/24,归类为专线中断。","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A13","phase":"accept","category":"synonym","text":"同意提供入网接口标识:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),业务质量劣化属于专线质差。","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A14","phase":"accept","category":"synonym","text":"接受,承载接口为 P782-天河-PTN7900-01-GE0/1/2,客户反映断线,对应专线中断。","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A15","phase":"accept","category":"multisentence","text":"接受协商。端口名称是 LAG-广州-001.100。投诉分类选择专线质差。","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A16","phase":"accept","category":"multisentence","text":"同意。已查到 PE1-GZ-ETH-0/0/0.200。该专线业务当前中断。","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A17","phase":"accept","category":"order-reversed","text":"投诉分类为专线质差,接入端口名称为 NCE-PTN-23-1/1/7,接受本次信息协商。","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A18","phase":"accept","category":"order-reversed","text":"专线中断;端口 SPN-广州-1/0/24。接受协商请求。","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A19","phase":"accept","category":"mixed-language","text":"Accept this negotiation. The access port is P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100), complaint category is 专线质差。","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A20","phase":"accept","category":"mixed-language","text":"接受,port=P782-天河-PTN7900-01-GE0/1/2,category=专线中断。","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A21","phase":"accept","category":"noise","text":"工单优先级P1,联系人已通知。接受并补充端口 LAG-广州-001.100,投诉为专线质差。","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A22","phase":"accept","category":"noise","text":"客户地址不在本次处理范围。接受协商:PE1-GZ-ETH-0/0/0.200,专线中断。","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A23","phase":"accept","category":"business","text":"资源系统查询完成,接受信息协商。诊断接入端口 NCE-PTN-23-1/1/7,故障投诉分类为专线质差。","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A24","phase":"accept","category":"business","text":"传输侧确认接受:业务承载端口 SPN-广州-1/0/24,故障现象按专线中断处理。","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A25","phase":"accept","category":"format","text":"接受;access_port_name=P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100);complaint_category=专线质差。","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A26","phase":"accept","category":"format","text":"结果:接受。接入端口名称=P782-天河-PTN7900-01-GE0/1/2;投诉分类=专线中断。","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A27","phase":"accept","category":"colloquial","text":"这次协商同意,端口就是 LAG-广州-001.100,客户说网慢,按专线质差算。","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A28","phase":"accept","category":"colloquial","text":"可以,查到了 PE1-GZ-ETH-0/0/0.200,这条专线断了。","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A29","phase":"accept","category":"long","text":"我方接受本次信息协商请求。资源查询结果表明,客户专线接入在 NCE-PTN-23-1/1/7;目前业务未完全中断,但存在持续高丢包和时延异常,因此投诉分类填写为专线质差。","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A30","phase":"accept","category":"long","text":"经现场及网管联合核查,接受协商并返回所需信息:接入端口为 SPN-广州-1/0/24。该端口承载业务不可达,用户侧表现为完全断线,投诉分类为专线中断。","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A31","phase":"accept","category":"whitespace","text":"接受本次协商。\n接入端口:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n投诉分类:专线质差。","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A32","phase":"accept","category":"whitespace","text":"接受。\n端口 P782-天河-PTN7900-01-GE0/1/2\n类别 专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A33","phase":"accept","category":"explicit","text":"明确接受信息协商,唯一需要填报的端口是 LAG-广州-001.100,唯一投诉分类为专线质差。","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"R01","phase":"reject","category":"complete","text":"拒绝本次信息协商,当前账号没有资源系统查询权限。","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, - {"id":"R02","phase":"reject","category":"complete","text":"拒绝,原因是资源系统正在维护,暂时无法查询端口信息。","expected":{"rejection_reason":"资源系统正在维护,暂时无法查询端口信息"}}, - {"id":"R03","phase":"reject","category":"paraphrase","text":"无法接受该协商请求,因为我方账号无权访问资源管理系统。","expected":{"rejection_reason":"我方账号无权访问资源管理系统"}}, - {"id":"R04","phase":"reject","category":"paraphrase","text":"本次不能补充信息,资源库维护中,端口数据不可用。","expected":{"rejection_reason":"资源库维护中,端口数据不可用"}}, - {"id":"R05","phase":"reject","category":"short","text":"拒绝:无资源系统查询权限。","expected":{"rejection_reason":"无资源系统查询权限"}}, - {"id":"R06","phase":"reject","category":"short","text":"Reject,资源系统维护中。","expected":{"rejection_reason":"资源系统维护中"}}, - {"id":"R07","phase":"reject","category":"formal","text":"拒绝本次信息协商请求,原因是当前操作账号未被授予资源系统查询权限。","expected":{"rejection_reason":"当前操作账号未被授予资源系统查询权限"}}, - {"id":"R08","phase":"reject","category":"formal","text":"经确认,资源系统处于计划维护窗口,暂不具备提供端口信息的条件,故拒绝协商。","expected":{"rejection_reason":"资源系统处于计划维护窗口,暂不具备提供端口信息的条件"}}, - {"id":"R09","phase":"reject","category":"punctuation","text":"拒绝!原因:当前账号没有资源系统查询权限。","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, - {"id":"R10","phase":"reject","category":"punctuation","text":"无法接受【资源系统维护,端口查询暂停】。","expected":{"rejection_reason":"资源系统维护,端口查询暂停"}}, - {"id":"R11","phase":"reject","category":"context","text":"工单已受理,但资源侧权限申请尚未完成,因此拒绝本次协商。","expected":{"rejection_reason":"资源侧权限申请尚未完成"}}, - {"id":"R12","phase":"reject","category":"context","text":"网管告警很多,但资源系统服务异常,当前不能查询端口,拒绝补充。","expected":{"rejection_reason":"资源系统服务异常,当前不能查询端口"}}, - {"id":"R13","phase":"reject","category":"synonym","text":"不接受协商,因账号没有资产资源库的访问授权。","expected":{"rejection_reason":"账号没有资产资源库的访问授权"}}, - {"id":"R14","phase":"reject","category":"synonym","text":"无法给出所需数据,CMDB资源查询服务正在检修。","expected":{"rejection_reason":"CMDB资源查询服务正在检修"}}, - {"id":"R15","phase":"reject","category":"multisentence","text":"拒绝本次请求。当前账号没有资源系统查询权限。","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, - {"id":"R16","phase":"reject","category":"multisentence","text":"暂不接受。资源系统正在维护。恢复时间未知。","expected":{"rejection_reason":"资源系统正在维护"}}, - {"id":"R17","phase":"reject","category":"order-reversed","text":"因资源侧权限尚未开通,拒绝这次信息协商。","expected":{"rejection_reason":"资源侧权限尚未开通"}}, - {"id":"R18","phase":"reject","category":"order-reversed","text":"资源查询接口超时,无法返回端口信息,故拒绝。","expected":{"rejection_reason":"资源查询接口超时,无法返回端口信息"}}, - {"id":"R19","phase":"reject","category":"mixed-language","text":"Reject this negotiation because the current account has no resource-system query permission。","expected":{"rejection_reason":"the current account has no resource-system query permission"}}, - {"id":"R20","phase":"reject","category":"mixed-language","text":"拒绝,resource system is under maintenance,无法查询端口。","expected":{"rejection_reason":"resource system is under maintenance,无法查询端口"}}, - {"id":"R21","phase":"reject","category":"noise","text":"联系人已收到短信,工单优先级为P2。拒绝原因是当前账号没有资源系统查询权限。","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, - {"id":"R22","phase":"reject","category":"noise","text":"客户地址信息无需处理。由于资源系统维护,无法取得端口数据,拒绝协商。","expected":{"rejection_reason":"资源系统维护,无法取得端口数据"}}, - {"id":"R23","phase":"reject","category":"business","text":"传输资源库未对本租户开放查询能力,无法补充专线端口,拒绝本次信息协商。","expected":{"rejection_reason":"传输资源库未对本租户开放查询能力"}}, - {"id":"R24","phase":"reject","category":"business","text":"资源同步任务延迟,网管侧尚未获得该专线端口映射,故暂时拒绝回复。","expected":{"rejection_reason":"资源同步任务延迟,网管侧尚未获得该专线端口映射"}}, - {"id":"R25","phase":"reject","category":"format","text":"拒绝;rejection_reason=当前账号没有资源系统查询权限。","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, - {"id":"R26","phase":"reject","category":"format","text":"结果:Reject。原因:资源系统维护,暂不能查询。","expected":{"rejection_reason":"资源系统维护,暂不能查询"}}, - {"id":"R27","phase":"reject","category":"colloquial","text":"这次先不接,账号没权限查资源库。","expected":{"rejection_reason":"账号没权限查资源库"}}, - {"id":"R28","phase":"reject","category":"colloquial","text":"查不了,资源系统在检修,拒绝吧。","expected":{"rejection_reason":"资源系统在检修"}}, - {"id":"R29","phase":"reject","category":"long","text":"我方已尝试通过资源管理系统查询该专线的接入端口和投诉信息,但当前使用的运维账号没有被授权访问相关资源域。由于无法保证返回数据的正确性,拒绝本次信息协商。","expected":{"rejection_reason":"当前使用的运维账号没有被授权访问相关资源域"}}, - {"id":"R30","phase":"reject","category":"long","text":"本次协商暂不能接受。资源系统正在执行例行维护,端口与业务映射查询功能已暂停;在服务恢复前无法提供可靠的补充信息。","expected":{"rejection_reason":"资源系统正在执行例行维护,端口与业务映射查询功能已暂停"}}, - {"id":"R31","phase":"reject","category":"whitespace","text":"拒绝本次协商。\n原因:当前账号没有资源系统查询权限。","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, - {"id":"R32","phase":"reject","category":"whitespace","text":"Reject\n资源查询服务异常,暂无法取得端口信息。","expected":{"rejection_reason":"资源查询服务异常,暂无法取得端口信息"}}, - {"id":"R33","phase":"reject","category":"explicit","text":"明确拒绝信息协商,唯一原因是资源库访问权限被安全策略禁用。","expected":{"rejection_reason":"资源库访问权限被安全策略禁用"}} + {"id":"P01","phase":"propose","category":"complete","text":"请发起信息协商,补充接入端口名称和投诉分类;端口可为物理或逻辑端口,分类限专线中断或专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P02","phase":"propose","category":"complete","text":"专线投诉诊断需要对端提供端口名和投诉类型,端口接受物理、逻辑名称,类型为专线中断或专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P03","phase":"propose","category":"paraphrase","text":"麻烦补齐本次专线投诉的接入端口及故障分类,端口要能定位到物理或逻辑接口,故障仅在中断、质差两类中选择。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P04","phase":"propose","category":"paraphrase","text":"发起信息协商:请给出物理端口或逻辑端口名称,并说明投诉属于专线中断还是专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P05","phase":"propose","category":"short","text":"请补充端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P06","phase":"propose","category":"short","text":"需要协商获取接入端口名和专线投诉类别:端口支持物理或逻辑,类别为中断或质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P07","phase":"propose","category":"formal","text":"为开展传输专线业务投诉诊断,请对端补充接入端口标识和投诉分类。其中端口标识应为物理或逻辑端口名称,投诉分类可取专线中断、专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P08","phase":"propose","category":"formal","text":"当前诊断参数不完整。请求协商获得:1)接入物理/逻辑端口名称;2)投诉分类,候选值为专线中断、专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P09","phase":"propose","category":"punctuation","text":"请补充接入端口名称;物理端口、逻辑端口均可。还请确认投诉分类:专线中断?专线质差?","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P10","phase":"propose","category":"punctuation","text":"信息协商请求【端口:物理或逻辑端口名称】【投诉:专线中断或专线质差】。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P11","phase":"propose","category":"context","text":"客户反映业务异常,尚无法确定网络侧故障。请提供接入端口名称(物理或逻辑均可)以及投诉分类(专线中断或专线质差),以便继续诊断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P12","phase":"propose","category":"context","text":"工单号已登记,但缺少诊断关键信息。请信息协商补充物理/逻辑接入端口名和专线中断/专线质差的投诉类别。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P13","phase":"propose","category":"synonym","text":"请对端回填入网接口名称(可填物理口或逻辑口)及专线故障现象分类(中断、质差)。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P14","phase":"propose","category":"synonym","text":"需要拿到接入侧端口标识,物理接口或逻辑接口均满足;同时确定本次专线投诉是断线还是质量劣化。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P15","phase":"propose","category":"multisentence","text":"请启动信息协商。诊断需要端口信息。请提供物理端口或逻辑端口名称。另请说明投诉分类是专线中断还是专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P16","phase":"propose","category":"multisentence","text":"当前不能直接定位故障。先确认接入端口名称,物理、逻辑名称都可以;再确认投诉类型,只允许专线中断和专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P17","phase":"propose","category":"polite","text":"烦请协助补充专线接入端口名称(物理或逻辑端口均可)与投诉分类(专线中断或专线质差),谢谢。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P18","phase":"propose","category":"polite","text":"请您确认一下接入端口是哪个物理/逻辑端口,并选择投诉为专线中断还是专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P19","phase":"propose","category":"order-reversed","text":"先请判定投诉类别:专线中断或专线质差;随后提供接入端口名称,物理端口或逻辑端口均可。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P20","phase":"propose","category":"order-reversed","text":"协商所需字段包括投诉分类(专线中断、专线质差)和接入端口名称(物理或逻辑端口)。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P21","phase":"propose","category":"mixed-language","text":"Please provide the access port name, either physical or logical, and classify this private-line complaint as 专线中断 or 专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P22","phase":"propose","category":"mixed-language","text":"发起 negotiation,需补充 access port(物理端口或逻辑端口名称)及 complaint category(专线中断或专线质差)。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P23","phase":"propose","category":"noise","text":"客户联系人为张三,联系电话已脱敏。请忽略这些非诊断信息,补充物理或逻辑接入端口名称,以及专线中断或专线质差的投诉分类。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P24","phase":"propose","category":"noise","text":"告警数量暂不确定,工单优先级为P2。请协商取得接入端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P25","phase":"propose","category":"business","text":"SPN专线投诉根因诊断前,请对端提供承载该业务的物理或逻辑接入端口名,并标注故障属于专线中断还是专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P26","phase":"propose","category":"business","text":"传输工作台需补齐入参:接入端口的物理/逻辑名称、专线投诉的中断/质差分类,请发起协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P27","phase":"propose","category":"imperative","text":"向对端要端口名和投诉类别。端口名必须是物理或者逻辑端口;类别限定专线中断、专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P28","phase":"propose","category":"imperative","text":"补齐以下两项再诊断:物理/逻辑接入端口名称;专线中断/专线质差投诉分类。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P29","phase":"propose","category":"long","text":"本次请求用于传输专线业务投诉诊断,现有描述只说明客户感知业务异常,尚不具备定位条件。请通过信息协商要求对端补充接入端口名称,值可以是物理端口名称或逻辑端口名称;同时要求补充投诉分类,允许的值为专线中断或专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P30","phase":"propose","category":"long","text":"为避免将客户侧问题误判为网络问题,需要先获取网络定位参数。请请求对端给出接入端口的物理或逻辑标识,并在专线中断、专线质差两个候选项中说明投诉类型。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P31","phase":"propose","category":"format","text":"字段1:接入端口名称,要求:物理端口或逻辑端口名称。字段2:投诉分类,要求:专线中断或专线质差。请发起协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P32","phase":"propose","category":"format","text":"需对端回复:access_port_name=物理端口或逻辑端口名称;complaint_category=专线中断或专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P33","phase":"propose","category":"colloquial","text":"这单还差端口和投诉类型,麻烦要一下:端口给物理口或逻辑口都行,类型就在专线中断、专线质差里选。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P34","phase":"propose","category":"colloquial","text":"先把专线的接入端口名和故障分类问清楚吧,端口支持物理/逻辑,分类只有中断或质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"A01","phase":"accept","category":"complete","text":"接受本次协商。接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A02","phase":"accept","category":"complete","text":"同意补充信息:物理端口 P782-天河-PTN7900-01-GE0/1/2,故障类型是专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A03","phase":"accept","category":"paraphrase","text":"接受。用于诊断的逻辑接入端口是 LAG-广州-001.100,客户投诉为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A04","phase":"accept","category":"paraphrase","text":"可以提供:端口名为 PE1-GZ-ETH-0/0/0.200,专线已中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A05","phase":"accept","category":"short","text":"接受,端口 NCE-PTN-23-1/1/7,投诉:专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A06","phase":"accept","category":"short","text":"Accept。access port=SPN-广州-1/0/24;分类=专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A07","phase":"accept","category":"formal","text":"接受该信息协商请求。补充的接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类确定为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A08","phase":"accept","category":"formal","text":"经资源核查,接受协商并反馈:物理接口 P782-天河-PTN7900-01-GE0/1/2;本次投诉为专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A09","phase":"accept","category":"punctuation","text":"接受!接入端口:LAG-广州-001.100;投诉分类:专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A10","phase":"accept","category":"punctuation","text":"同意回复【端口=PE1-GZ-ETH-0/0/0.200】【类型=专线中断】。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A11","phase":"accept","category":"context","text":"客户报障期间业务偶发丢包,接受协商,确认接入端口为 NCE-PTN-23-1/1/7,投诉分类为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A12","phase":"accept","category":"context","text":"告警显示链路不可达。接受本次协商,端口为 SPN-广州-1/0/24,归类为专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A13","phase":"accept","category":"synonym","text":"同意提供入网接口标识:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),业务质量劣化属于专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A14","phase":"accept","category":"synonym","text":"接受,承载接口为 P782-天河-PTN7900-01-GE0/1/2,客户反映断线,对应专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A15","phase":"accept","category":"multisentence","text":"接受协商。端口名称是 LAG-广州-001.100。投诉分类选择专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A16","phase":"accept","category":"multisentence","text":"同意。已查到 PE1-GZ-ETH-0/0/0.200。该专线业务当前中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A17","phase":"accept","category":"order-reversed","text":"投诉分类为专线质差,接入端口名称为 NCE-PTN-23-1/1/7,接受本次信息协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A18","phase":"accept","category":"order-reversed","text":"专线中断;端口 SPN-广州-1/0/24。接受协商请求。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A19","phase":"accept","category":"mixed-language","text":"Accept this negotiation. The access port is P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100), complaint category is 专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A20","phase":"accept","category":"mixed-language","text":"接受,port=P782-天河-PTN7900-01-GE0/1/2,category=专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A21","phase":"accept","category":"noise","text":"工单优先级P1,联系人已通知。接受并补充端口 LAG-广州-001.100,投诉为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A22","phase":"accept","category":"noise","text":"客户地址不在本次处理范围。接受协商:PE1-GZ-ETH-0/0/0.200,专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A23","phase":"accept","category":"business","text":"资源系统查询完成,接受信息协商。诊断接入端口 NCE-PTN-23-1/1/7,故障投诉分类为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A24","phase":"accept","category":"business","text":"传输侧确认接受:业务承载端口 SPN-广州-1/0/24,故障现象按专线中断处理。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A25","phase":"accept","category":"format","text":"接受;access_port_name=P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100);complaint_category=专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A26","phase":"accept","category":"format","text":"结果:接受。接入端口名称=P782-天河-PTN7900-01-GE0/1/2;投诉分类=专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A27","phase":"accept","category":"colloquial","text":"这次协商同意,端口就是 LAG-广州-001.100,客户说网慢,按专线质差算。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A28","phase":"accept","category":"colloquial","text":"可以,查到了 PE1-GZ-ETH-0/0/0.200,这条专线断了。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A29","phase":"accept","category":"long","text":"我方接受本次信息协商请求。资源查询结果表明,客户专线接入在 NCE-PTN-23-1/1/7;目前业务未完全中断,但存在持续高丢包和时延异常,因此投诉分类填写为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A30","phase":"accept","category":"long","text":"经现场及网管联合核查,接受协商并返回所需信息:接入端口为 SPN-广州-1/0/24。该端口承载业务不可达,用户侧表现为完全断线,投诉分类为专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A31","phase":"accept","category":"whitespace","text":"接受本次协商。\n接入端口:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n投诉分类:专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A32","phase":"accept","category":"whitespace","text":"接受。\n端口 P782-天河-PTN7900-01-GE0/1/2\n类别 专线中断","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A33","phase":"accept","category":"explicit","text":"明确接受信息协商,唯一需要填报的端口是 LAG-广州-001.100,唯一投诉分类为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"R01","phase":"reject","category":"complete","text":"拒绝本次信息协商,当前账号没有资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R02","phase":"reject","category":"complete","text":"拒绝,原因是资源系统正在维护,暂时无法查询端口信息。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统正在维护,暂时无法查询端口信息","expected":{"rejection_reason":"资源系统正在维护,暂时无法查询端口信息"}}, + {"id":"R03","phase":"reject","category":"paraphrase","text":"无法接受该协商请求,因为我方账号无权访问资源管理系统。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:我方账号无权访问资源管理系统","expected":{"rejection_reason":"我方账号无权访问资源管理系统"}}, + {"id":"R04","phase":"reject","category":"paraphrase","text":"本次不能补充信息,资源库维护中,端口数据不可用。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源库维护中,端口数据不可用","expected":{"rejection_reason":"资源库维护中,端口数据不可用"}}, + {"id":"R05","phase":"reject","category":"short","text":"拒绝:无资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:无资源系统查询权限","expected":{"rejection_reason":"无资源系统查询权限"}}, + {"id":"R06","phase":"reject","category":"short","text":"Reject,资源系统维护中。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统维护中","expected":{"rejection_reason":"资源系统维护中"}}, + {"id":"R07","phase":"reject","category":"formal","text":"拒绝本次信息协商请求,原因是当前操作账号未被授予资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前操作账号未被授予资源系统查询权限","expected":{"rejection_reason":"当前操作账号未被授予资源系统查询权限"}}, + {"id":"R08","phase":"reject","category":"formal","text":"经确认,资源系统处于计划维护窗口,暂不具备提供端口信息的条件,故拒绝协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统处于计划维护窗口,暂不具备提供端口信息的条件","expected":{"rejection_reason":"资源系统处于计划维护窗口,暂不具备提供端口信息的条件"}}, + {"id":"R09","phase":"reject","category":"punctuation","text":"拒绝!原因:当前账号没有资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R10","phase":"reject","category":"punctuation","text":"无法接受【资源系统维护,端口查询暂停】。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统维护,端口查询暂停","expected":{"rejection_reason":"资源系统维护,端口查询暂停"}}, + {"id":"R11","phase":"reject","category":"context","text":"工单已受理,但资源侧权限申请尚未完成,因此拒绝本次协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源侧权限申请尚未完成","expected":{"rejection_reason":"资源侧权限申请尚未完成"}}, + {"id":"R12","phase":"reject","category":"context","text":"网管告警很多,但资源系统服务异常,当前不能查询端口,拒绝补充。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统服务异常,当前不能查询端口","expected":{"rejection_reason":"资源系统服务异常,当前不能查询端口"}}, + {"id":"R13","phase":"reject","category":"synonym","text":"不接受协商,因账号没有资产资源库的访问授权。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:账号没有资产资源库的访问授权","expected":{"rejection_reason":"账号没有资产资源库的访问授权"}}, + {"id":"R14","phase":"reject","category":"synonym","text":"无法给出所需数据,CMDB资源查询服务正在检修。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:CMDB资源查询服务正在检修","expected":{"rejection_reason":"CMDB资源查询服务正在检修"}}, + {"id":"R15","phase":"reject","category":"multisentence","text":"拒绝本次请求。当前账号没有资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R16","phase":"reject","category":"multisentence","text":"暂不接受。资源系统正在维护。恢复时间未知。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统正在维护","expected":{"rejection_reason":"资源系统正在维护"}}, + {"id":"R17","phase":"reject","category":"order-reversed","text":"因资源侧权限尚未开通,拒绝这次信息协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源侧权限尚未开通","expected":{"rejection_reason":"资源侧权限尚未开通"}}, + {"id":"R18","phase":"reject","category":"order-reversed","text":"资源查询接口超时,无法返回端口信息,故拒绝。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源查询接口超时,无法返回端口信息","expected":{"rejection_reason":"资源查询接口超时,无法返回端口信息"}}, + {"id":"R19","phase":"reject","category":"mixed-language","text":"Reject this negotiation because the current account has no resource-system query permission。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:the current account has no resource-system query permission","expected":{"rejection_reason":"the current account has no resource-system query permission"}}, + {"id":"R20","phase":"reject","category":"mixed-language","text":"拒绝,resource system is under maintenance,无法查询端口。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:resource system is under maintenance,无法查询端口","expected":{"rejection_reason":"resource system is under maintenance,无法查询端口"}}, + {"id":"R21","phase":"reject","category":"noise","text":"联系人已收到短信,工单优先级为P2。拒绝原因是当前账号没有资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R22","phase":"reject","category":"noise","text":"客户地址信息无需处理。由于资源系统维护,无法取得端口数据,拒绝协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统维护,无法取得端口数据","expected":{"rejection_reason":"资源系统维护,无法取得端口数据"}}, + {"id":"R23","phase":"reject","category":"business","text":"传输资源库未对本租户开放查询能力,无法补充专线端口,拒绝本次信息协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:传输资源库未对本租户开放查询能力","expected":{"rejection_reason":"传输资源库未对本租户开放查询能力"}}, + {"id":"R24","phase":"reject","category":"business","text":"资源同步任务延迟,网管侧尚未获得该专线端口映射,故暂时拒绝回复。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源同步任务延迟,网管侧尚未获得该专线端口映射","expected":{"rejection_reason":"资源同步任务延迟,网管侧尚未获得该专线端口映射"}}, + {"id":"R25","phase":"reject","category":"format","text":"拒绝;rejection_reason=当前账号没有资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R26","phase":"reject","category":"format","text":"结果:Reject。原因:资源系统维护,暂不能查询。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统维护,暂不能查询","expected":{"rejection_reason":"资源系统维护,暂不能查询"}}, + {"id":"R27","phase":"reject","category":"colloquial","text":"这次先不接,账号没权限查资源库。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:账号没权限查资源库","expected":{"rejection_reason":"账号没权限查资源库"}}, + {"id":"R28","phase":"reject","category":"colloquial","text":"查不了,资源系统在检修,拒绝吧。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统在检修","expected":{"rejection_reason":"资源系统在检修"}}, + {"id":"R29","phase":"reject","category":"long","text":"我方已尝试通过资源管理系统查询该专线的接入端口和投诉信息,但当前使用的运维账号没有被授权访问相关资源域。由于无法保证返回数据的正确性,拒绝本次信息协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前使用的运维账号没有被授权访问相关资源域","expected":{"rejection_reason":"当前使用的运维账号没有被授权访问相关资源域"}}, + {"id":"R30","phase":"reject","category":"long","text":"本次协商暂不能接受。资源系统正在执行例行维护,端口与业务映射查询功能已暂停;在服务恢复前无法提供可靠的补充信息。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统正在执行例行维护,端口与业务映射查询功能已暂停","expected":{"rejection_reason":"资源系统正在执行例行维护,端口与业务映射查询功能已暂停"}}, + {"id":"R31","phase":"reject","category":"whitespace","text":"拒绝本次协商。\n原因:当前账号没有资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R32","phase":"reject","category":"whitespace","text":"Reject\n资源查询服务异常,暂无法取得端口信息。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源查询服务异常,暂无法取得端口信息","expected":{"rejection_reason":"资源查询服务异常,暂无法取得端口信息"}}, + {"id":"R33","phase":"reject","category":"explicit","text":"明确拒绝信息协商,唯一原因是资源库访问权限被安全策略禁用。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源库访问权限被安全策略禁用","expected":{"rejection_reason":"资源库访问权限被安全策略禁用"}} ] diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java index 3af63630..5fb03eef 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java @@ -29,10 +29,29 @@ void loadsFocusedReproductionCasesFromTheCanonicalCorpus() { assertEquals(List.of("R21", "P01", "A28"), cases.stream().map(NegotiationEvaluationCase::id).toList()); } + @Test + void rendersCompletedPromptsWithTheRuntimeNegotiationContext() { + var cases = NegotiationEvaluationCaseLoader.load(); + + for (NegotiationEvaluationCase testCase : cases) { + String prompt = testCase.renderCompletedPrompt("case-context", 2, 4); + assertTrue(prompt.contains("- id: case-context"), testCase.id()); + assertTrue(prompt.contains("- round: 2"), testCase.id()); + assertTrue(prompt.contains("- maxRounds: 4"), testCase.id()); + assertTrue(!prompt.contains("{{"), testCase.id()); + assertTrue(prompt.contains(testCase.phase().equals("propose") + ? "## 信息协商\n" + : "## 信息协商结果\n"), testCase.id()); + } + } + private static boolean isComplete(NegotiationEvaluationCase testCase) { return !testCase.id().isBlank() && !testCase.category().isBlank() && !testCase.text().isBlank() + && testCase.completedPrompt() != null + && !testCase.completedPrompt().isBlank() + && testCase.completedPrompt().contains("{{id}}") && testCase.expected() != null && !testCase.expected().isEmpty() && expectedKeys(testCase.phase()).equals(testCase.expected().keySet()); diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java index 277da064..1539e0d2 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java @@ -77,9 +77,9 @@ void sixTargetApisUseStructuredTemplateUri() { "generateNegotiationProposePromptFromText", "generateNegotiationAcceptPromptFromText", "generateNegotiationRejectPromptFromText", - "validateAndFillingProposeData", - "validateAndFillingAcceptData", - "validateAndFillingRejectData"); + "validateProposePromptAndDataFilling", + "validateAcceptPromptAndDataFilling", + "validateRejectPromptAndDataFilling"); for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { Arrays.stream(facade.getMethods()) .filter(method -> targetMethods.contains(method.getName())) From fb3df64d96f3f72c19d69f1ef2194ff9a381ddaa Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 17:39:32 +0800 Subject: [PATCH 119/295] fix(sample): adapt to validateTaskPromptAndDataFilling rename Upstream renamed validateAndFillingTaskData to validateTaskPromptAndDataFilling; the sample executor, strategy javadoc, scenario loader javadoc and diagnosis service references now use the new name. Signed-off-by: ZhouJie6 --- .../a2at/sample/negotiation/server/DiagnosisService.java | 4 ++-- .../sample/negotiation/server/NegotiationAgentExecutor.java | 4 ++-- .../a2at/sample/negotiation/shared/NegotiationStrategy.java | 2 +- .../openan/a2at/sample/negotiation/shared/ScenarioData.java | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java index 308bb444..42f6054d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java @@ -9,7 +9,7 @@ * *

      The text layout comes from the {@code diagnosis} templates of the scenario configuration ({@code result_line}, * {@code detail_line} with a {@code {params}} placeholder, {@code advice_line}); the parameter values themselves are - * taken from what {@code validateAndFillingTaskData} extracted, so the result adapts to any Task-T input. In a real + * taken from what {@code validateTaskPromptAndDataFilling} extracted, so the result adapts to any Task-T input. In a real * deployment this service would call the EMS/NMS north-bound API instead of rendering text. * * @since 2026-08 @@ -24,7 +24,7 @@ private DiagnosisService() {} /** * Builds a diagnosis result from the extracted Task-T parameters and the scenario templates. * - * @param params validated parameters from {@code validateAndFillingTaskData} + * @param params validated parameters from {@code validateTaskPromptAndDataFilling} * @return diagnosis result text */ public static String diagnose(FilledParamData params) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java index 5ce8eb1f..d5a77bff 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java @@ -27,7 +27,7 @@ * Server-side agent executor that drives the 4-message negotiation flow for the SPN private-line-complaint diagnosis. * *

      Receives one A2A request, extracts the Task-T prompt from {@code Message.metadata}, validates it through the SDK - * ({@link A2ATServer#validateAndFillingTaskData}) to discover which parameters are missing, and reacts: + * ({@link A2ATServer#validateTaskPromptAndDataFilling}) to discover which parameters are missing, and reacts: * *

        *
      • message 1 (params missing): dynamically builds the missing-items list from the validation result and generates @@ -79,7 +79,7 @@ public void execute(RequestContext requestContext, AgentEmitter agentEmitter) th FilledParamData params; List missingItems; try { - params = server.validateAndFillingTaskData( + params = server.validateTaskPromptAndDataFilling( taskPrompt, ScenarioData.taskSchema(), DemoConstants.TASK_TEMPLATE); emit("[server] extracted params: " + params.data()); missingItems = findMissingItems(params); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java index 0941b51d..db33f672 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java @@ -12,7 +12,7 @@ * Strategy abstraction for generating negotiation messages, isolating the fromData (rule-based) vs fromText (LLM) * difference behind a single interface. * - *

        Everything else in the demo — Task-T generation, {@code validateAndFillingTaskData} parameter validation, the + *

        Everything else in the demo — Task-T generation, {@code validateTaskPromptAndDataFilling} parameter validation, the * negotiation state machine — is identical regardless of which strategy is active. Only the negotiation prompt * generation step differs: fromData builds typed records and calls the deterministic API; fromText passes natural * language and lets the SDK's LLM extraction step parse it. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java index 40867313..f8b34ee6 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java @@ -28,7 +28,7 @@ public final class ScenarioData { private ScenarioData() {} - /** Slot schema describing the Task-T parameters (passed to validateAndFillingTaskData). */ + /** Slot schema describing the Task-T parameters (passed to validateTaskPromptAndDataFilling). */ @SuppressWarnings("unchecked") public static Map taskSchema() { return (Map) scenarioMap().get("task_schema"); From b13a9e3368af7c3a75dc1a472157a2780891e5a4 Mon Sep 17 00:00:00 2001 From: gcw_mr3RVhmf Date: Sat, 22 Aug 2026 17:49:20 +0800 Subject: [PATCH 120/295] feat(sample): add negotiation smoke evaluation set Signed-off-by: gcw_mr3RVhmf --- .../NEGOTIATION_QWEN_EVALUATION.zh-CN.md | 18 +++++++++++++++--- .../NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md | 2 +- a2a-t-sample/README.zh-CN.md | 4 ++-- .../NegotiationEvaluationCaseLoader.java | 14 ++++++++++++++ .../NegotiationQwenEvaluationMain.java | 16 ++++++++++++---- .../NegotiationEvaluationCaseLoaderTest.java | 12 ++++++++++++ 6 files changed, 56 insertions(+), 10 deletions(-) diff --git a/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md b/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md index bc5ec12d..fed7e634 100644 --- a/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md +++ b/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md @@ -1,6 +1,6 @@ # Negotiation-T Qwen3-32B 批量验证 -此入口验证传输专线投诉诊断场景的 3 个自然语言生成接口:Propose、Accept、Reject。用例集固定为 100 条,由人工提供自然语言输入、补齐后的标准 Prompt 和期望业务字段;每条用例随后使用补齐 Prompt 调用对应的校验/提参接口。 +此入口验证传输专线投诉诊断场景的 3 个自然语言生成接口:Propose、Accept、Reject。完整用例集为 100 条;另提供 20 条精简集,便于日常回归。用例由人工提供自然语言输入、补齐后的标准 Prompt 和期望业务字段;每条用例随后使用补齐 Prompt 调用对应的校验/提参接口。 生成接口与校验接口在用例中相互独立:生成 Prompt 用于人工判断生成质量,校验接口的输入是 `cases.json` 中预置的 `completedPrompt`,不能把生成 Prompt 直接送入校验接口。自动通过率只表示生成调用成功,且补齐 Prompt 能被校验并提取为期望字段,不能直接等同于生成语义准确率。应人工复核生成 Prompt,并复核全部校验失败用例。 @@ -14,6 +14,8 @@ 覆盖完整表述、短句、同义改写、口语化表达、中英文混合、噪声信息、字段顺序变化、换行和业务上下文等类型。每条用例的 `completedPrompt` 是人工构造的补齐报文模板,`{{id}}`、`{{round}}`、`{{maxRounds}}` 会在运行时替换为本次协商上下文;`expected` 是人工 golden data。修改业务 Schema、报文格式或场景语义时,需要同步复核这两个字段。 +精简集选择器为 `smoke`,共 20 条:Propose 7 条、Accept 7 条、Reject 6 条,覆盖完整、短句、上下文、同义改写、中英文混合、噪声、业务化和口语化表达。它从 `cases.json` 读取,不维护第二份用例数据;完整集选择器为 `full`(默认)。也可直接传逗号分隔的编号执行任意组合,例如 `P01,A28,R21`。 + ## 在目标网络执行 当前本机网络不能访问 Qwen 服务。进入东莞绿区 70 网段后,直接使用仓库提供的 [qwen.env](src/main/resources/sample/private-line-complaint-negotiation/qwen.env)。其中 `A2AT_LLM_DISABLE_SYSTEM_PROXY=true` 会绕过公司黄区的系统代理;该开关仅作用于此 LLM Client,不影响其他网络请求。不要修改默认的 Mock `client.env` 和 `server.env`。 @@ -24,7 +26,7 @@ mvn -pl a2a-t-sample -am -DskipTests package ``` -再运行。参数依次为 env 文件、汇总报告输出路径、可选的过程日志输出路径、可选的逗号分隔用例编号;不传第三个参数时,会在报告同目录生成同名的 `-process.jsonl` 文件: +再运行。参数依次为 env 文件、汇总报告输出路径、可选的过程日志输出路径、可选的用例选择器;不传第三个参数时,会在报告同目录生成同名的 `-process.jsonl` 文件。选择器省略或传 `full` 时运行 100 条,传 `smoke` 时运行 20 条,传逗号分隔的用例编号时运行指定用例: ```powershell java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` @@ -33,6 +35,16 @@ java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` a2a-t-sample/target/negotiation-qwen-process.jsonl ``` +日常运行精简集: + +```powershell +java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` + a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ` + a2a-t-sample/target/negotiation-qwen-smoke-report.json ` + a2a-t-sample/target/negotiation-qwen-smoke-process.jsonl ` + smoke +``` + ## SDK 问题最小复现 完整执行前可先运行以下 6 条用例,确认三类生成接口和三类补齐报文校验接口均能调用。用例内容、补齐 Prompt 和 golden data 均以 `cases.json` 为唯一来源。 @@ -45,7 +57,7 @@ java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` P01,P14,P16,A28,R01,R21 ``` -每次运行会在报告中写入 `git_revision` 和 `case_ids`,便于把结果与 SDK 代码版本和用例集合对应起来。过程日志是严格 JSONL:每行都是一条可独立解析的 JSON 事件。失败事件会额外记录 SDK `code` 和 `slot_errors`;其中 `negotiation_semantic_rejected` 能直接区分语义校验拒绝,`slot_errors` 可定位字段级规则问题。 +每次运行会在报告中写入 `git_revision`、`case_set` 和 `case_ids`,便于把结果与 SDK 代码版本和用例集合对应起来。过程日志是严格 JSONL:每行都是一条可独立解析的 JSON 事件。失败事件会额外记录 SDK `code` 和 `slot_errors`;其中 `negotiation_semantic_rejected` 能直接区分语义校验拒绝,`slot_errors` 可定位字段级规则问题。 汇总报告会分别输出 `generation_succeeded`、`validation_succeeded`、通过数和 `automatic_consistency_rate`,并逐条保存 `input`、`generated_prompt`、`completed_prompt`、`expected`、`actual`、阶段状态、耗时及结构化错误信息。即使校验抛出异常,报告仍保留生成 Prompt 和补齐 Prompt,并明确记录 `actual: null`。过程日志采用 JSONL,每一行对应一次 SDK 调用;其中包含 `run_id`、用例编号、阶段(`generate` 或 `validate_and_fill`)、请求入参、模型/SDK 返回值、耗时,以及异常类、异常消息、SDK 错误码、字段错误、cause 链和截断后的调用栈。 diff --git a/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md b/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md index e100f90e..3af63674 100644 --- a/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md +++ b/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md @@ -459,7 +459,7 @@ java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` 8. 增加两个 Main 入口、env 模板和 Maven java argfile。 9. 分别运行 Accept、Reject 的本地 Mock E2E。 10. 更新 `a2a-t-sample/README.zh-CN.md`,写明入口、分支选择、Mock 和 Qwen 配置方法。 -11. 在目标网络执行 100 用例 Qwen 批量验证,检查汇总报告和 JSONL 过程日志;本机无法访问目标网络时,将此步骤记录为待目标网络执行。 +11. 在目标网络先执行 20 用例 `smoke` 集,再执行 100 用例 `full` 集的 Qwen 批量验证,检查汇总报告和 JSONL 过程日志;本机无法访问目标网络时,将此步骤记录为待目标网络执行。 12. 运行全部验收命令,检查工作区只包含本任务相关改动。 如后续需要提交 PR,提交必须使用 `git commit -s`,并在推送前检查 PR 范围内每个提交均带有正确的 `Signed-off-by`。 diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index f9e41c42..3b645790 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -51,7 +51,7 @@ java @a2a-t-sample/target/server.javaargs.txt - [六个接口的逐项调用示例](NEGOTIATION_API_EXAMPLES.zh-CN.md) - [Sample 开发与设计说明](NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md) -- [Qwen3-32B 的 100 用例批量验证](NEGOTIATION_QWEN_EVALUATION.zh-CN.md) +- [Qwen3-32B 的 20/100 用例批量验证](NEGOTIATION_QWEN_EVALUATION.zh-CN.md) 调用方 schema 根据专线投诉场景分别定义:Propose 和 Accept 提取 `access_port_name`、`complaint_category`,Reject 提取 `rejection_reason`。`items`、`relationship`、`conclusion` 等协商模板结构由 SDK 内部处理。 @@ -106,7 +106,7 @@ java @a2a-t-sample/target/negotiation-server.javaargs.txt a2a-t-sample/src/main/ java @a2a-t-sample/target/negotiation-client.javaargs.txt a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ``` -需要批量验证三组自然语言生成接口及对应校验/提参接口时,运行以下命令。它会执行 100 条用例、生成汇总报告和逐调用 JSONL 过程日志;日志可用于区分生成、校验或 Sample 入参问题: +需要批量验证三组自然语言生成接口及对应校验/提参接口时,运行以下命令。默认执行 100 条用例、生成汇总报告和逐调用 JSONL 过程日志;日志可用于区分生成、校验或 Sample 入参问题。将最后一个参数设为 `smoke` 可执行 20 条精简集,或传入 `P01,A28,R21` 这类编号组合执行指定用例: ```powershell java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java index c0328723..f17e94bb 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java @@ -12,6 +12,15 @@ public final class NegotiationEvaluationCaseLoader { public static final String RESOURCE_PATH = "sample/private-line-complaint-negotiation/evaluation/cases.json"; + /** + * A representative 20-case set for routine smoke validation. It includes every generation and + * validation/filling phase, and spans common, short, contextual, mixed-language, noisy, and + * business-oriented expressions. + */ + public static final List SMOKE_CASE_IDS = List.of( + "P01", "P05", "P11", "P14", "P16", "P21", "P27", + "A01", "A05", "A11", "A16", "A21", "A28", "A33", + "R01", "R05", "R11", "R16", "R21", "R27"); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @@ -58,4 +67,9 @@ public static List loadSelected(List caseIds) } return caseIds.stream().map(casesById::get).toList(); } + + /** Loads the checked-in 20-case smoke set. */ + public static List loadSmoke() { + return loadSelected(SMOKE_CASE_IDS); + } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java index c1214253..e5aff5c5 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -25,7 +25,7 @@ import net.openan.a2at.sdk.server.A2ATServer; /** - * Runs the 100 manually labelled cases against a configured real LLM and writes a JSON report. + * Runs a selected manually labelled case set against a configured real LLM and writes a JSON report. * *

        Each case independently verifies prompt generation and validates a manually completed prompt. The generated * prompt, completed prompt, and extracted data are all preserved for review. @@ -41,9 +41,8 @@ public static void main(String[] args) throws IOException { Path envPath = args.length > 0 ? Path.of(args[0]) : NegotiationSampleEnvironment.defaultEnvPath("client"); Path reportPath = args.length > 1 ? Path.of(args[1]) : Path.of("a2a-t-sample", "target", "negotiation-qwen-report.json"); Path processLogPath = args.length > 2 ? Path.of(args[2]) : defaultProcessLogPath(reportPath); - List testCases = args.length > 3 - ? NegotiationEvaluationCaseLoader.loadSelected(parseCaseIds(args[3])) - : NegotiationEvaluationCaseLoader.load(); + String caseSet = args.length > 3 ? args[3].trim() : "full"; + List testCases = loadCases(caseSet); Map environment = NegotiationSampleEnvironment.read(envPath); requireQwenConfiguration(environment); @@ -71,6 +70,7 @@ public static void main(String[] args) throws IOException { report.put("model", environment.get("A2AT_LLM_MODEL")); report.put("base_url", environment.get("A2AT_LLM_BASE_URL")); report.put("git_revision", gitRevision()); + report.put("case_set", caseSet); report.put("case_ids", testCases.stream().map(NegotiationEvaluationCase::id).toList()); report.put("total", results.size()); report.put("generation_succeeded", generationSucceeded); @@ -254,6 +254,14 @@ private static List parseCaseIds(String argument) { return caseIds; } + private static List loadCases(String selector) { + return switch (selector.toLowerCase(java.util.Locale.ROOT)) { + case "", "full" -> NegotiationEvaluationCaseLoader.load(); + case "smoke" -> NegotiationEvaluationCaseLoader.loadSmoke(); + default -> NegotiationEvaluationCaseLoader.loadSelected(parseCaseIds(selector)); + }; + } + private static String gitRevision() { String configuredRevision = System.getProperty("a2at.sample.gitRevision"); if (configuredRevision != null && !configuredRevision.isBlank()) { diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java index 5fb03eef..f2ede427 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java @@ -29,6 +29,18 @@ void loadsFocusedReproductionCasesFromTheCanonicalCorpus() { assertEquals(List.of("R21", "P01", "A28"), cases.stream().map(NegotiationEvaluationCase::id).toList()); } + @Test + void loadsTwentyRepresentativeSmokeCasesFromTheCanonicalCorpus() { + var cases = NegotiationEvaluationCaseLoader.loadSmoke(); + + assertEquals(20, cases.size()); + assertEquals(NegotiationEvaluationCaseLoader.SMOKE_CASE_IDS, + cases.stream().map(NegotiationEvaluationCase::id).toList()); + assertEquals(7, cases.stream().filter(testCase -> testCase.phase().equals("propose")).count()); + assertEquals(7, cases.stream().filter(testCase -> testCase.phase().equals("accept")).count()); + assertEquals(6, cases.stream().filter(testCase -> testCase.phase().equals("reject")).count()); + } + @Test void rendersCompletedPromptsWithTheRuntimeNegotiationContext() { var cases = NegotiationEvaluationCaseLoader.load(); From 2d67ee05c7adf0dbc9ebc5f442912c237079d31a Mon Sep 17 00:00:00 2001 From: gcw_mr3RVhmf Date: Sat, 22 Aug 2026 18:13:08 +0800 Subject: [PATCH 121/295] fix(sample): evaluate negotiation APIs end to end Signed-off-by: gcw_mr3RVhmf --- .../NEGOTIATION_QWEN_EVALUATION.zh-CN.md | 16 +- .../NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md | 8 +- a2a-t-sample/README.zh-CN.md | 2 +- .../NegotiationEvaluationCaseLoader.java | 64 +++++ .../NegotiationEvaluationFlowCase.java | 39 +++ .../NegotiationQwenEvaluationMain.java | 238 +++++++++++------- .../NegotiationEvaluationCaseLoaderTest.java | 24 ++ 7 files changed, 281 insertions(+), 110 deletions(-) create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationFlowCase.java diff --git a/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md b/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md index fed7e634..b1e66ff8 100644 --- a/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md +++ b/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md @@ -1,8 +1,8 @@ # Negotiation-T Qwen3-32B 批量验证 -此入口验证传输专线投诉诊断场景的 3 个自然语言生成接口:Propose、Accept、Reject。完整用例集为 100 条;另提供 20 条精简集,便于日常回归。用例由人工提供自然语言输入、补齐后的标准 Prompt 和期望业务字段;每条用例随后使用补齐 Prompt 调用对应的校验/提参接口。 +此入口验证传输专线投诉诊断场景的 3 组接口:Propose、Accept、Reject 各自的自然语言生成接口和对应校验/提参接口。完整集为 100 条端到端流程;另提供 20 条精简集,便于日常回归。 -生成接口与校验接口在用例中相互独立:生成 Prompt 用于人工判断生成质量,校验接口的输入是 `cases.json` 中预置的 `completedPrompt`,不能把生成 Prompt 直接送入校验接口。自动通过率只表示生成调用成功,且补齐 Prompt 能被校验并提取为期望字段,不能直接等同于生成语义准确率。应人工复核生成 Prompt,并复核全部校验失败用例。 +每条流程依次执行:Propose 生成、Propose 校验、客户端补充信息、Accept 或 Reject 生成、对应校验。生成接口的实际 Prompt 直接作为配对校验接口的输入;客户端补充信息是无 SDK 校验的中间业务数据。只有两次生成、两次校验都成功且提取值与 golden data 一致,流程才通过。 ## 用例集 @@ -12,9 +12,9 @@ - Accept 33 条; - Reject 33 条。 -覆盖完整表述、短句、同义改写、口语化表达、中英文混合、噪声信息、字段顺序变化、换行和业务上下文等类型。每条用例的 `completedPrompt` 是人工构造的补齐报文模板,`{{id}}`、`{{round}}`、`{{maxRounds}}` 会在运行时替换为本次协商上下文;`expected` 是人工 golden data。修改业务 Schema、报文格式或场景语义时,需要同步复核这两个字段。 +覆盖完整表述、短句、同义改写、口语化表达、中英文混合、噪声信息、字段顺序变化、换行和业务上下文等类型。加载器把每个原始编号组装成完整流程:P 编号重点覆盖 Propose 输入并确定性配对一个 Accept/Reject 输入;A/R 编号重点覆盖结论输入并确定性配对一个 Propose 输入。最终 100 条流程包含 50 条 Accept 和 50 条 Reject。`expected` 是对应阶段的人工 golden data;`completedPrompt` 作为历史标准报文保留在语料中,不再用于校验接口入参。 -精简集选择器为 `smoke`,共 20 条:Propose 7 条、Accept 7 条、Reject 6 条,覆盖完整、短句、上下文、同义改写、中英文混合、噪声、业务化和口语化表达。它从 `cases.json` 读取,不维护第二份用例数据;完整集选择器为 `full`(默认)。也可直接传逗号分隔的编号执行任意组合,例如 `P01,A28,R21`。 +精简集选择器为 `smoke`,共 20 条完整流程,兼顾 Propose 输入及 Accept/Reject 两种结论,覆盖完整、短句、上下文、同义改写、中英文混合、噪声、业务化和口语化表达。它从 `cases.json` 组装,不维护第二份用例数据;完整集选择器为 `full`(默认)。也可直接传逗号分隔的编号执行任意组合,例如 `P01,A28,R21`。 ## 在目标网络执行 @@ -47,7 +47,7 @@ java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` ## SDK 问题最小复现 -完整执行前可先运行以下 6 条用例,确认三类生成接口和三类补齐报文校验接口均能调用。用例内容、补齐 Prompt 和 golden data 均以 `cases.json` 为唯一来源。 +完整执行前可先运行以下 6 条流程,确认三组生成与校验接口均能调用。自然语言输入和 golden data 均以 `cases.json` 为唯一来源。 ```powershell java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` @@ -59,10 +59,10 @@ java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` 每次运行会在报告中写入 `git_revision`、`case_set` 和 `case_ids`,便于把结果与 SDK 代码版本和用例集合对应起来。过程日志是严格 JSONL:每行都是一条可独立解析的 JSON 事件。失败事件会额外记录 SDK `code` 和 `slot_errors`;其中 `negotiation_semantic_rejected` 能直接区分语义校验拒绝,`slot_errors` 可定位字段级规则问题。 -汇总报告会分别输出 `generation_succeeded`、`validation_succeeded`、通过数和 `automatic_consistency_rate`,并逐条保存 `input`、`generated_prompt`、`completed_prompt`、`expected`、`actual`、阶段状态、耗时及结构化错误信息。即使校验抛出异常,报告仍保留生成 Prompt 和补齐 Prompt,并明确记录 `actual: null`。过程日志采用 JSONL,每一行对应一次 SDK 调用;其中包含 `run_id`、用例编号、阶段(`generate` 或 `validate_and_fill`)、请求入参、模型/SDK 返回值、耗时,以及异常类、异常消息、SDK 错误码、字段错误、cause 链和截断后的调用栈。 +汇总报告会分别输出 `propose_succeeded`、`ending_succeeded`、通过数和 `automatic_consistency_rate`。每条流程保存 Propose/Ending 的自然语言输入、生成 Prompt、期望值、实际提取值、客户端补充信息、四个调用阶段的状态、耗时及结构化错误。过程日志采用 JSONL,每一行对应一次 SDK 调用,阶段为 `generate_propose`、`validate_propose_and_fill`、`generate_accept|reject`、`validate_accept|reject_and_fill`,并包含请求、响应、耗时、SDK 错误码、字段错误、cause 链和截断后的调用栈。 -定位时先按 `run_id` 和 `case_id` 将同一用例的两行关联:`generate` 已失败时,重点检查 Sample 传入的自然语言、上下文和模板 URI;生成成功后,人工比较 `generated_prompt` 与场景要求;`validate_and_fill` 失败时,核对 `completed_prompt`、业务 Schema 和 SDK 校验异常。过程日志不记录 API Key。 +定位时按 `run_id` 和 `case_id` 关联同一流程的四次调用。生成失败时检查自然语言、上下文和模板 URI;校验失败时直接核对前一阶段的 `response.prompt`、业务 Schema 和 SDK 异常。客户端补充信息会记录在 Ending 生成请求中,但不会调用校验 API。过程日志不记录 API Key。 -执行过程会产生约 200 次模型调用(每个用例一次生成、一次校验/提参);以 Qwen 响应时延为准设置 `A2AT_LLM_TIMEOUT_SECONDS`。 +完整集会产生约 400 次模型调用,精简集约 80 次模型调用(每条流程两次生成、两次校验/提参);以 Qwen 响应时延为准设置 `A2AT_LLM_TIMEOUT_SECONDS`。 若目标网络的公司代理干扰访问,按 `C:\cnafan\git\design\Qwen3-32B模型调用指导.md` 的说明处理网络代理。Java SDK 当前 OpenAI-compatible Client 未额外加入 Qwen 专属逻辑;先保留实际失败报文,再判断是否需要在 SDK 层适配 `enable_thinking=false`。 diff --git a/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md b/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md index 3af63674..223d82c8 100644 --- a/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md +++ b/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md @@ -424,10 +424,10 @@ java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` 验收时读取 `negotiation-qwen-report.json` 和 `negotiation-qwen-process.jsonl`: -- 每条用例独立提供 `completedPrompt`;生成接口输出用于评估生成质量,不能直接作为校验接口入参。 -- 汇总报告包含每条用例的 `generated_prompt`、`completed_prompt`、`expected`、`actual`、最终 `passed` 及耗时。 -- 过程日志按 `run_id`、`case_id` 关联两个阶段:`generate` 与 `validate_and_fill`。 -- `generate` 失败时,优先检查日志中的 `request.text`、`context`、`template_uri` 和异常;`validate_and_fill` 失败时,核对 Case 补齐报文、`request.schema` 和异常链。 +- 每条评测用例执行完整链路:Propose 生成与校验、客户端补充、Accept/Reject 生成与校验;生成 Prompt 直接传给配对校验接口。 +- 客户端补充信息是中间业务数据,不调用这 6 个接口中的校验接口。 +- 汇总报告分别包含 Propose 和 Ending 的输入、生成 Prompt、期望值、实际提取值、阶段状态、最终 `passed` 及耗时。 +- 过程日志按 `run_id`、`case_id` 关联四个 SDK 调用阶段,可沿前一阶段输出追踪后一阶段校验入参。 - 两阶段均成功但汇总结果失败时,比较 `expected` 与 `actual`;生成 Prompt 的语义质量单独人工检查。 过程日志不记录 API Key。完成后在验证文档中记录模型、执行时间、报告路径、通过率以及人工抽检结论。详细字段说明见 `NEGOTIATION_QWEN_EVALUATION.zh-CN.md`。 diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 3b645790..f102418c 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -106,7 +106,7 @@ java @a2a-t-sample/target/negotiation-server.javaargs.txt a2a-t-sample/src/main/ java @a2a-t-sample/target/negotiation-client.javaargs.txt a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ``` -需要批量验证三组自然语言生成接口及对应校验/提参接口时,运行以下命令。默认执行 100 条用例、生成汇总报告和逐调用 JSONL 过程日志;日志可用于区分生成、校验或 Sample 入参问题。将最后一个参数设为 `smoke` 可执行 20 条精简集,或传入 `P01,A28,R21` 这类编号组合执行指定用例: +需要批量验证三组自然语言生成接口及对应校验/提参接口时,运行以下命令。每条用例会执行 Propose 生成与校验、客户端补充、Accept/Reject 生成与校验。默认执行 100 条完整流程并生成汇总报告和逐调用 JSONL 过程日志;将最后一个参数设为 `smoke` 可执行 20 条精简流程,或传入 `P01,A28,R21` 这类编号组合执行指定流程: ```powershell java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java index f17e94bb..18b1cbfa 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java @@ -7,6 +7,9 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; /** Loads the checked-in, manually labelled Qwen evaluation corpus. */ public final class NegotiationEvaluationCaseLoader { @@ -72,4 +75,65 @@ public static List loadSelected(List caseIds) public static List loadSmoke() { return loadSelected(SMOKE_CASE_IDS); } + + /** + * Assembles every labelled input into a complete propose-to-decision flow. A propose-labelled + * input drives the first generation step and is paired with a deterministic ending case; an + * accept/reject-labelled input drives the ending step and is paired with a deterministic + * propose case. This preserves the original 100 IDs while exercising two matching API pairs + * in every run. + */ + public static List loadFlows() { + List cases = load(); + List proposeCases = byPhase(cases, "propose"); + List acceptCases = byPhase(cases, "accept"); + List rejectCases = byPhase(cases, "reject"); + return java.util.stream.IntStream.range(0, cases.size()) + .mapToObj(index -> toFlow(cases.get(index), index, proposeCases, acceptCases, rejectCases)) + .toList(); + } + + public static List loadSelectedFlows(List caseIds) { + if (caseIds.isEmpty()) { + throw new IllegalArgumentException("At least one evaluation case ID is required"); + } + Map flowsById = loadFlows().stream().collect(Collectors.toMap( + NegotiationEvaluationFlowCase::id, Function.identity())); + Set requested = new LinkedHashSet<>(caseIds); + if (requested.size() != caseIds.size()) { + throw new IllegalArgumentException("Duplicate negotiation evaluation case IDs are not allowed: " + caseIds); + } + Set unknown = new LinkedHashSet<>(requested); + unknown.removeAll(flowsById.keySet()); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException("Unknown negotiation evaluation case IDs: " + unknown); + } + return caseIds.stream().map(flowsById::get).toList(); + } + + public static List loadSmokeFlows() { + return loadSelectedFlows(SMOKE_CASE_IDS); + } + + private static List byPhase(List cases, String phase) { + return cases.stream().filter(testCase -> phase.equals(testCase.phase())).toList(); + } + + private static NegotiationEvaluationFlowCase toFlow( + NegotiationEvaluationCase source, + int index, + List proposeCases, + List acceptCases, + List rejectCases) { + NegotiationEvaluationCase propose = "propose".equals(source.phase()) + ? source + : proposeCases.get(index % proposeCases.size()); + boolean accept = "accept".equals(source.phase()) + || ("propose".equals(source.phase()) && index % 2 == 0); + NegotiationEvaluationCase ending = "propose".equals(source.phase()) + ? (accept ? acceptCases : rejectCases).get(index % (accept ? acceptCases.size() : rejectCases.size())) + : source; + return new NegotiationEvaluationFlowCase( + source.id(), source.category(), accept ? "accept" : "reject", propose, ending); + } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationFlowCase.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationFlowCase.java new file mode 100644 index 00000000..e558dc71 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationFlowCase.java @@ -0,0 +1,39 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import java.util.Map; + +/** One end-to-end negotiation evaluation flow assembled from the labelled generation corpus. */ +public record NegotiationEvaluationFlowCase( + String id, + String category, + String decision, + NegotiationEvaluationCase proposeCase, + NegotiationEvaluationCase endingCase) { + + public Map expectedPropose() { + return proposeCase.expected(); + } + + public Map expectedEnding() { + return endingCase.expected(); + } + + public String clientSupplement(String contextId, int round, int maxRounds) { + String header = "## 协商上下文\n- id: " + contextId + + "\n- round: " + round + + "\n- maxRounds: " + maxRounds + + "\n\n## 客户端补充信息\n"; + if ("accept".equals(decision)) { + return header + + "1. 接入端口名称:" + expectedEnding().get("access_port_name") + + "\n2. 投诉分类:" + expectedEnding().get("complaint_category"); + } + return header + "无法补充所需信息,原因:" + expectedEnding().get("rejection_reason"); + } + + public String endingGenerationText(String contextId, int round, int maxRounds) { + return clientSupplement(contextId, round, maxRounds) + + "\n\n## 服务端协商结论生成要求\n" + + endingCase.text(); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java index e5aff5c5..44b48cf7 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -27,8 +27,9 @@ /** * Runs a selected manually labelled case set against a configured real LLM and writes a JSON report. * - *

        Each case independently verifies prompt generation and validates a manually completed prompt. The generated - * prompt, completed prompt, and extracted data are all preserved for review. + *

        Each case runs a complete negotiation path: propose generation and validation, a manual client + * supplement, then accept/reject generation and validation. Generated prompts are passed directly to + * their matching validation APIs. */ public final class NegotiationQwenEvaluationMain { @@ -42,7 +43,7 @@ public static void main(String[] args) throws IOException { Path reportPath = args.length > 1 ? Path.of(args[1]) : Path.of("a2a-t-sample", "target", "negotiation-qwen-report.json"); Path processLogPath = args.length > 2 ? Path.of(args[2]) : defaultProcessLogPath(reportPath); String caseSet = args.length > 3 ? args[3].trim() : "full"; - List testCases = loadCases(caseSet); + List testCases = loadCases(caseSet); Map environment = NegotiationSampleEnvironment.read(envPath); requireQwenConfiguration(environment); @@ -52,17 +53,17 @@ public static void main(String[] args) throws IOException { String runId = UUID.randomUUID().toString(); try (NegotiationEvaluationProcessLogger processLogger = new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, processLogPath)) { processLogger.write(runStartedEvent(runId, environment, envPath)); - for (NegotiationEvaluationCase testCase : testCases) { + for (NegotiationEvaluationFlowCase testCase : testCases) { results.add(runCase(client, server, testCase, runId, processLogger)); } } long passed = results.stream().filter(result -> Boolean.TRUE.equals(result.get("passed"))).count(); - long generationSucceeded = results.stream() - .filter(result -> Boolean.TRUE.equals(result.get("generation_succeeded"))) + long proposeSucceeded = results.stream() + .filter(result -> Boolean.TRUE.equals(result.get("propose_succeeded"))) .count(); - long validationSucceeded = results.stream() - .filter(result -> Boolean.TRUE.equals(result.get("validation_succeeded"))) + long endingSucceeded = results.stream() + .filter(result -> Boolean.TRUE.equals(result.get("ending_succeeded"))) .count(); Map report = new LinkedHashMap<>(); report.put("generated_at", Instant.now().toString()); @@ -71,13 +72,13 @@ public static void main(String[] args) throws IOException { report.put("base_url", environment.get("A2AT_LLM_BASE_URL")); report.put("git_revision", gitRevision()); report.put("case_set", caseSet); - report.put("case_ids", testCases.stream().map(NegotiationEvaluationCase::id).toList()); + report.put("case_ids", testCases.stream().map(NegotiationEvaluationFlowCase::id).toList()); report.put("total", results.size()); - report.put("generation_succeeded", generationSucceeded); - report.put("validation_succeeded", validationSucceeded); + report.put("propose_succeeded", proposeSucceeded); + report.put("ending_succeeded", endingSucceeded); report.put("passed", passed); report.put("automatic_consistency_rate", (double) passed / results.size()); - report.put("note", "Automatic consistency is not semantic accuracy; manually review the preserved prompts, especially failures and a representative sample of passes."); + report.put("note", "A flow passes only when both generated prompts pass their matching validation APIs and all extracted values match the golden data."); report.put("process_log", processLogPath.toAbsolutePath().toString()); report.put("cases", results); Files.createDirectories(reportPath.toAbsolutePath().getParent()); @@ -90,36 +91,56 @@ public static void main(String[] args) throws IOException { private static Map runCase( A2ATClient client, A2ATServer server, - NegotiationEvaluationCase testCase, + NegotiationEvaluationFlowCase testCase, String runId, NegotiationEvaluationProcessLogger processLogger) throws IOException { long startedAt = System.nanoTime(); Map result = new LinkedHashMap<>(); result.put("id", testCase.id()); - result.put("phase", testCase.phase()); + result.put("decision", testCase.decision()); result.put("category", testCase.category()); - result.put("input", testCase.text()); - result.put("expected", testCase.expected()); + result.put("propose_case_id", testCase.proposeCase().id()); + result.put("ending_case_id", testCase.endingCase().id()); + result.put("propose_input", testCase.proposeCase().text()); + result.put("expected_propose", testCase.expectedPropose()); + result.put("expected_ending", testCase.expectedEnding()); NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3); - String completedPrompt = testCase.renderCompletedPrompt(context.id(), context.round(), context.maxRounds()); - result.put("completed_prompt", completedPrompt); - result.put("actual", null); - result.put("generation_succeeded", false); - result.put("validation_succeeded", false); - result.put("expected_matched", false); + result.put("client_supplement", testCase.clientSupplement(context.id(), context.round(), context.maxRounds())); + result.put("ending_input", testCase.endingGenerationText(context.id(), context.round(), context.maxRounds())); + result.put("actual_propose", null); + result.put("actual_ending", null); + result.put("propose_generation_succeeded", false); + result.put("propose_validation_succeeded", false); + result.put("ending_generation_succeeded", false); + result.put("ending_validation_succeeded", false); + result.put("propose_succeeded", false); + result.put("ending_succeeded", false); try { - MetadataContent content = generateWithLog(client, testCase, context, runId, processLogger); - result.put("generation_succeeded", true); - result.put("generated_prompt", content.promptText()); - result.put("template_uri", content.templateUri()); - result.put("extension_uri", content.extensionUri()); - FilledParamData filled = validateWithLog(server, testCase, completedPrompt, runId, processLogger); - Map actual = filled.data(); - boolean expectedMatched = expectedValuesMatch(testCase.expected(), actual) && contextMatches(context, actual); - result.put("actual", actual); - result.put("validation_succeeded", true); - result.put("expected_matched", expectedMatched); - result.put("passed", expectedMatched); + MetadataContent propose = generateProposeWithLog(client, testCase, context, runId, processLogger); + result.put("propose_generation_succeeded", true); + result.put("generated_propose_prompt", propose.promptText()); + FilledParamData proposeFilled = validateProposeWithLog( + server, testCase, propose.promptText(), runId, processLogger); + result.put("propose_validation_succeeded", true); + result.put("actual_propose", proposeFilled.data()); + boolean proposeMatched = expectedValuesMatch(testCase.expectedPropose(), proposeFilled.data()) + && contextMatches(context, proposeFilled.data()); + result.put("propose_expected_matched", proposeMatched); + result.put("propose_succeeded", proposeMatched); + + NegotiationContext responseContext = NegotiationSampleFlow.contextFrom(proposeFilled.data()); + MetadataContent ending = generateEndingWithLog(server, testCase, responseContext, runId, processLogger); + result.put("ending_generation_succeeded", true); + result.put("generated_ending_prompt", ending.promptText()); + FilledParamData endingFilled = validateEndingWithLog( + client, testCase, ending.promptText(), runId, processLogger); + result.put("ending_validation_succeeded", true); + result.put("actual_ending", endingFilled.data()); + boolean endingMatched = expectedValuesMatch(testCase.expectedEnding(), endingFilled.data()) + && contextMatches(responseContext, endingFilled.data()); + result.put("ending_expected_matched", endingMatched); + result.put("ending_succeeded", endingMatched); + result.put("passed", proposeMatched && endingMatched); } catch (RuntimeException exception) { result.put("passed", false); result.put("error", errorDetails(exception)); @@ -128,58 +149,119 @@ private static Map runCase( return result; } - private static MetadataContent generateWithLog( + private static MetadataContent generateProposeWithLog( A2ATClient client, - NegotiationEvaluationCase testCase, + NegotiationEvaluationFlowCase testCase, NegotiationContext context, String runId, NegotiationEvaluationProcessLogger processLogger) throws IOException { long startedAt = System.nanoTime(); try { - MetadataContent content = generate(client, testCase, context); - processLogger.write(stageEvent(runId, testCase, "generate", context, Map.of( - "text", testCase.text(), - "template_uri", templateUri(testCase)), Map.of( + MetadataContent content = client.generateNegotiationProposePromptFromText( + testCase.proposeCase().text(), context, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + processLogger.write(stageEvent(runId, testCase, "generate_propose", "propose", context, Map.of( + "text", testCase.proposeCase().text(), + "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), Map.of( "prompt", content.promptText(), "template_uri", content.templateUri(), "extension_uri", content.extensionUri()), startedAt, null)); return content; } catch (RuntimeException exception) { - processLogger.write(stageEvent(runId, testCase, "generate", context, Map.of( - "text", testCase.text(), - "template_uri", templateUri(testCase)), null, startedAt, exception)); + processLogger.write(stageEvent(runId, testCase, "generate_propose", "propose", context, Map.of( + "text", testCase.proposeCase().text(), + "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), null, startedAt, exception)); throw exception; } } - private static FilledParamData validateWithLog( + private static FilledParamData validateProposeWithLog( A2ATServer server, - NegotiationEvaluationCase testCase, + NegotiationEvaluationFlowCase testCase, String prompt, String runId, NegotiationEvaluationProcessLogger processLogger) throws IOException { long startedAt = System.nanoTime(); - Map schema = schema(testCase); + Map schema = InformationNegotiationSchemas.propose(); try { - FilledParamData filled = validate(server, testCase, prompt); - processLogger.write(stageEvent(runId, testCase, "validate_and_fill", null, Map.of( + FilledParamData filled = server.validateProposePromptAndDataFilling( + prompt, schema, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + processLogger.write(stageEvent(runId, testCase, "validate_propose_and_fill", "propose", null, Map.of( "prompt", prompt, "schema", schema, - "template_uri", templateUri(testCase)), Map.of("filled_data", filled.data()), startedAt, null)); + "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), Map.of("filled_data", filled.data()), startedAt, null)); return filled; } catch (RuntimeException exception) { - processLogger.write(stageEvent(runId, testCase, "validate_and_fill", null, Map.of( + processLogger.write(stageEvent(runId, testCase, "validate_propose_and_fill", "propose", null, Map.of( "prompt", prompt, "schema", schema, - "template_uri", templateUri(testCase)), null, startedAt, exception)); + "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), null, startedAt, exception)); + throw exception; + } + } + + private static MetadataContent generateEndingWithLog( + A2ATServer server, + NegotiationEvaluationFlowCase testCase, + NegotiationContext context, + String runId, + NegotiationEvaluationProcessLogger processLogger) throws IOException { + long startedAt = System.nanoTime(); + try { + String endingInput = testCase.endingGenerationText(context.id(), context.round(), context.maxRounds()); + MetadataContent content = "accept".equals(testCase.decision()) + ? server.generateNegotiationAcceptPromptFromText( + endingInput, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI) + : server.generateNegotiationRejectPromptFromText( + endingInput, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); + processLogger.write(stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), context, + Map.of("text", endingInput, + "client_supplement", testCase.clientSupplement(context.id(), context.round(), context.maxRounds()), + "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), + Map.of("prompt", content.promptText(), "template_uri", content.templateUri(), + "extension_uri", content.extensionUri()), startedAt, null)); + return content; + } catch (RuntimeException exception) { + String endingInput = testCase.endingGenerationText(context.id(), context.round(), context.maxRounds()); + processLogger.write(stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), context, + Map.of("text", endingInput, + "client_supplement", testCase.clientSupplement(context.id(), context.round(), context.maxRounds()), + "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), null, startedAt, exception)); + throw exception; + } + } + + private static FilledParamData validateEndingWithLog( + A2ATClient client, + NegotiationEvaluationFlowCase testCase, + String prompt, + String runId, + NegotiationEvaluationProcessLogger processLogger) throws IOException { + long startedAt = System.nanoTime(); + Map schema = "accept".equals(testCase.decision()) + ? InformationNegotiationSchemas.accept() + : InformationNegotiationSchemas.reject(); + try { + FilledParamData filled = "accept".equals(testCase.decision()) + ? client.validateAcceptPromptAndDataFilling(prompt, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI) + : client.validateRejectPromptAndDataFilling(prompt, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI); + processLogger.write(stageEvent(runId, testCase, "validate_" + testCase.decision() + "_and_fill", + testCase.decision(), null, Map.of("prompt", prompt, "schema", schema, + "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), + Map.of("filled_data", filled.data()), startedAt, null)); + return filled; + } catch (RuntimeException exception) { + processLogger.write(stageEvent(runId, testCase, "validate_" + testCase.decision() + "_and_fill", + testCase.decision(), null, Map.of("prompt", prompt, "schema", schema, + "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), null, startedAt, exception)); throw exception; } } private static Map stageEvent( String runId, - NegotiationEvaluationCase testCase, + NegotiationEvaluationFlowCase testCase, String stage, + String phase, NegotiationContext context, Map request, Map response, @@ -189,9 +271,10 @@ private static Map stageEvent( event.put("timestamp", Instant.now().toString()); event.put("run_id", runId); event.put("case_id", testCase.id()); - event.put("phase", testCase.phase()); + event.put("phase", phase); + event.put("decision", testCase.decision()); event.put("stage", stage); - event.put("expected", testCase.expected()); + event.put("expected", "propose".equals(phase) ? testCase.expectedPropose() : testCase.expectedEnding()); if (context != null) { event.put("context", Map.of("id", context.id(), "round", context.round(), "max_rounds", context.maxRounds())); } @@ -254,11 +337,11 @@ private static List parseCaseIds(String argument) { return caseIds; } - private static List loadCases(String selector) { + private static List loadCases(String selector) { return switch (selector.toLowerCase(java.util.Locale.ROOT)) { - case "", "full" -> NegotiationEvaluationCaseLoader.load(); - case "smoke" -> NegotiationEvaluationCaseLoader.loadSmoke(); - default -> NegotiationEvaluationCaseLoader.loadSelected(parseCaseIds(selector)); + case "", "full" -> NegotiationEvaluationCaseLoader.loadFlows(); + case "smoke" -> NegotiationEvaluationCaseLoader.loadSmokeFlows(); + default -> NegotiationEvaluationCaseLoader.loadSelectedFlows(parseCaseIds(selector)); }; } @@ -292,45 +375,6 @@ private static Path defaultProcessLogPath(Path reportPath) { return parent == null ? Path.of(logFilename) : parent.resolve(logFilename); } - private static String templateUri(NegotiationEvaluationCase testCase) { - return testCase.phase().equals("propose") - ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri() - : NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri(); - } - - private static Map schema(NegotiationEvaluationCase testCase) { - return switch (testCase.phase()) { - case "propose" -> InformationNegotiationSchemas.propose(); - case "accept" -> InformationNegotiationSchemas.accept(); - case "reject" -> InformationNegotiationSchemas.reject(); - default -> throw new IllegalArgumentException("Unsupported evaluation phase: " + testCase.phase()); - }; - } - - private static MetadataContent generate(A2ATClient client, NegotiationEvaluationCase testCase, NegotiationContext context) { - return switch (testCase.phase()) { - case "propose" -> client.generateNegotiationProposePromptFromText( - testCase.text(), context, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); - case "accept" -> client.generateNegotiationAcceptPromptFromText( - testCase.text(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); - case "reject" -> client.generateNegotiationRejectPromptFromText( - testCase.text(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); - default -> throw new IllegalArgumentException("Unsupported evaluation phase: " + testCase.phase()); - }; - } - - private static FilledParamData validate(A2ATServer server, NegotiationEvaluationCase testCase, String prompt) { - return switch (testCase.phase()) { - case "propose" -> server.validateProposePromptAndDataFilling( - prompt, InformationNegotiationSchemas.propose(), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); - case "accept" -> server.validateAcceptPromptAndDataFilling( - prompt, InformationNegotiationSchemas.accept(), NegotiationSampleFlow.ENDING_TEMPLATE_URI); - case "reject" -> server.validateRejectPromptAndDataFilling( - prompt, InformationNegotiationSchemas.reject(), NegotiationSampleFlow.ENDING_TEMPLATE_URI); - default -> throw new IllegalArgumentException("Unsupported evaluation phase: " + testCase.phase()); - }; - } - private static boolean expectedValuesMatch(Map expected, Map actual) { return expected.entrySet().stream().allMatch(entry -> entry.getValue().equals(actual.get(entry.getKey()))); } diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java index f2ede427..54da286e 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java @@ -41,6 +41,30 @@ void loadsTwentyRepresentativeSmokeCasesFromTheCanonicalCorpus() { assertEquals(6, cases.stream().filter(testCase -> testCase.phase().equals("reject")).count()); } + @Test + void assemblesEveryCorpusEntryIntoAnEndToEndFlow() { + var flows = NegotiationEvaluationCaseLoader.loadFlows(); + + assertEquals(100, flows.size()); + assertEquals(50, flows.stream().filter(flow -> flow.decision().equals("accept")).count()); + assertEquals(50, flows.stream().filter(flow -> flow.decision().equals("reject")).count()); + assertTrue(flows.stream().allMatch(flow -> flow.proposeCase().phase().equals("propose"))); + assertTrue(flows.stream().allMatch(flow -> flow.endingCase().phase().equals(flow.decision()))); + assertTrue(flows.stream().allMatch(flow -> flow.clientSupplement("context", 1, 3) + .contains("## 客户端补充信息"))); + } + + @Test + void assemblesTwentySmokeFlowsUsingTheExistingSelectorIds() { + var flows = NegotiationEvaluationCaseLoader.loadSmokeFlows(); + + assertEquals(20, flows.size()); + assertEquals(NegotiationEvaluationCaseLoader.SMOKE_CASE_IDS, + flows.stream().map(flow -> flow.id()).toList()); + assertTrue(flows.stream().anyMatch(flow -> flow.decision().equals("accept"))); + assertTrue(flows.stream().anyMatch(flow -> flow.decision().equals("reject"))); + } + @Test void rendersCompletedPromptsWithTheRuntimeNegotiationContext() { var cases = NegotiationEvaluationCaseLoader.load(); From ffc408d4aeeca3f33b2bf923f1ff85f39bfb98fe Mon Sep 17 00:00:00 2001 From: wangruiyang8 Date: Sat, 22 Aug 2026 18:34:33 +0800 Subject: [PATCH 122/295] refactor(sample): split notificationDataFormat schema into per-field parameters and drop CI e2e job Split the server-side notificationDataFormat string parameter in the service-recovery sample schema into the 10 individual business fields defined by the template (7 required + 3 optional), update the required list and slot names accordingly, and align the sample inputs with the new parameter names. Remove the e2e-samples-unified job from CI. Signed-off-by: wangruiyang8 --- .github/workflows/ci.yml | 73 ------------------- .../ServiceRecoverySampleInputs.java | 10 +-- .../client/flow/ClientSampleFlow.java | 6 +- .../service-recovery/server/schema.json | 67 +++++++++++++++-- .../server/slot-names.properties | 11 ++- 5 files changed, 78 insertions(+), 89 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e3551b3..5374ee9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,76 +30,3 @@ jobs: - name: Build and run tests run: mvn -B clean verify - - e2e-samples-unified: - name: E2E Samples (unified runner) - runs-on: ubuntu-latest - needs: build-and-test - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - cache: maven - - - name: Package project - run: mvn -B package -DskipTests - - - name: Diagnose LLM config availability - shell: bash - run: | - value="${{ secrets.A2AT_LLM_API_KEY }}" - len=${#value} - if [ "$len" -eq 0 ]; then - echo "::notice::A2AT_LLM_API_KEY is empty, e2e will use mock LLM responses" - else - echo "A2AT_LLM_API_KEY length=$len" - fi - - - name: Fill LLM API keys into all sample envs - if: ${{ env.A2AT_LLM_API_KEY != '' }} - env: - A2AT_LLM_API_KEY: ${{ secrets.A2AT_LLM_API_KEY }} - run: | - for f in a2a-t-sample/src/main/resources/sample/*/*/client.env \ - a2a-t-sample/src/main/resources/sample/*/*/server.env; do - [ -f "$f" ] || continue - sed -i "s|^A2AT_LLM_API_KEY=.*|A2AT_LLM_API_KEY=${A2AT_LLM_API_KEY}|" "$f" - done - - - name: List discovered samples - run: java @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner - - - name: Run all samples - env: - A2AT_SAMPLE_MAX_ARTIFACTS: "1" - A2AT_LLM_API_KEY: ${{ secrets.A2AT_LLM_API_KEY }} - run: | - set -e - failed="" - for sample in $(java @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner list | grep ' - ' | sed 's/^ - //'); do - echo "=== running sample: $sample ===" - if java @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner "$sample"; then - echo "sample $sample PASSED" - else - echo "::error::sample $sample FAILED" - failed="$failed $sample" - fi - done - if [ -n "$failed" ]; then - echo "::error::failed samples:$failed" - exit 1 - fi - echo "all samples PASSED" - - - name: Show sample server logs - if: always() - run: | - for log in a2a-t-sample/target/sample-logs/*-server.log; do - echo "=== $log ===" - cat "$log" 2>/dev/null || true - done diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java index a2b963f6..9aad67ba 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java @@ -55,14 +55,14 @@ public final class ServiceRecoverySampleInputs { /** Slot name of the optional subscription condition slot. */ public static final String SLOT_SUBSCRIBE_CONDITION = slotName("subscriptionCondition"); - /** Slot name of the required notification data format slot. */ - public static final String SLOT_NOTIFICATION_DATA_FORMAT = slotName("notificationDataFormat"); + /** Slot name of the required recovery plan execution status field. */ + public static final String SLOT_RECOVERY_PLAN_EXECUTION_STATUS = slotName("recoveryPlanExecutionStatus"); /** Server-side parameter name of the subscription condition (English business identifier). */ public static final String PARAM_SUBSCRIPTION_CONDITION = "subscriptionCondition"; - /** Server-side parameter name of the notification data format (English business identifier). */ - public static final String PARAM_NOTIFICATION_DATA_FORMAT = "notificationDataFormat"; + /** Server-side parameter name of the recovery plan execution status (English business identifier). */ + public static final String PARAM_RECOVERY_PLAN_EXECUTION_STATUS = "recoveryPlanExecutionStatus"; /** AgentCard query name of the sample server. */ public static final String AGENT_NAME = "SPN Service Recovery Agent"; @@ -108,7 +108,7 @@ public static String naturalLanguageInput() { * {@code sample/service-recovery/client/input-with-data.json}. * *

        The keys follow the English business-style parameter names of the server-side validation - * schema ({@code subscriptionCondition}/{@code notificationDataFormat}), demonstrating that the + * schema ({@code subscriptionCondition}/{@code recoveryPlanExecutionStatus}), demonstrating that the * schema-guided generation path maps caller-provided structured data onto the template slots. * * @return structured notification input as a string-to-object map diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java index a406ded9..fae0a8b0 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java @@ -244,11 +244,11 @@ private static void addGenerationChecks( content.promptText() != null && !content.promptText().isBlank(), "promptText blank")); checks.add(expectation( - label + ": prompt carries the report format", + label + ": prompt carries the recovery plan execution status", content.promptText() != null - && content.promptText().contains(ServiceRecoverySampleInputs.SLOT_NOTIFICATION_DATA_FORMAT), + && content.promptText().contains(ServiceRecoverySampleInputs.SLOT_RECOVERY_PLAN_EXECUTION_STATUS), "promptText does not carry the required " - + ServiceRecoverySampleInputs.SLOT_NOTIFICATION_DATA_FORMAT + " slot")); + + ServiceRecoverySampleInputs.SLOT_RECOVERY_PLAN_EXECUTION_STATUS + " slot")); } private static VerificationCheck expectation(String name, boolean passed, String failureDetail) { diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json b/a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json index 4737fc2a..3c4cc2ba 100644 --- a/a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json @@ -11,14 +11,67 @@ ], "x-a2at-value-constraint": "订阅条件为子网名称。订阅可以不带条件,此时该参数为空。" }, - "notificationDataFormat": { + "recoveryPlanExecutionStatus": { "type": "string", - "description": "上报通知数据格式。描述所需上报内容的如下信息:1. 信息项名称(必选);2. 取值范围(可选);3. 样例(可选)。", - "examples": [ - "业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束(必选);投诉诊断任务流水号;举例:9de168c0-6179-4778-8b72-4279582c0a3f(必选);OSS侧事件流水号;举例:event-id-202606250128(必选);接入端口名称;举例:P781-珠江新城-PTN7900-23-TPA1EG24-17(必选);是否已授权OMC自动抢通,取值范围:是、否;举例:是(必选);业务抢通方案名称;举例:隧道调优(必选);业务抢通方案详情(必选);业务抢通方案执行结束时间;样例:2026-05-11T08:21:46Z(可选);业务抢通方案执行结果,取值范围:成功、失败;举例:成功(可选);业务抢通方案执行失败原因;业务抢通方案执行结果为“失败”时,需要填写此信息;举例:调优后隧道链路C已被占用,无法执行隧道调优(可选)" - ], - "x-a2at-value-constraint": "上报的数据类型为业务抢通事件。业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,业务抢通方案执行结果为“失败”时,需要填写此信息(可选)。" + "description": "业务抢通方案执行状态(必选)。取值范围:未启动、已结束。", + "enum": ["未启动", "已结束"], + "examples": ["已结束"] + }, + "complaintDiagnosisTaskId": { + "type": "string", + "description": "投诉诊断任务流水号(必选)。", + "examples": ["9de168c0-6179-4778-8b72-4279582c0a3f"] + }, + "ossEventId": { + "type": "string", + "description": "OSS侧事件流水号(必选)。", + "examples": ["event-id-202606250128"] + }, + "accessPortName": { + "type": "string", + "description": "接入端口名称(必选)。", + "examples": ["P781-珠江新城-PTN7900-23-TPA1EG24-17"] + }, + "omcAutoRecoveryAuthorized": { + "type": "string", + "description": "是否已授权OMC自动抢通(必选)。取值范围:是、否。", + "enum": ["是", "否"], + "examples": ["是"] + }, + "recoveryPlanName": { + "type": "string", + "description": "业务抢通方案名称(必选)。", + "examples": ["隧道调优"] + }, + "recoveryPlanDetail": { + "type": "string", + "description": "业务抢通方案详情(必选)。", + "examples": ["将流量带宽,时延越限和丢包越限受影响的隧道列表,调优到新的路径列表中。"] + }, + "recoveryPlanEndTime": { + "type": "string", + "description": "业务抢通方案执行结束时间(可选)。", + "examples": ["2026-05-11T08:21:46Z"] + }, + "recoveryPlanExecutionResult": { + "type": "string", + "description": "业务抢通方案执行结果(可选)。取值范围:成功、失败。", + "enum": ["成功", "失败"], + "examples": ["成功"] + }, + "recoveryPlanFailureReason": { + "type": "string", + "description": "业务抢通方案执行失败原因(可选)。业务抢通方案执行结果为“失败”时,需要填写此信息。", + "examples": ["调优后隧道链路C已被占用,无法执行隧道调优"] } }, - "required": ["notificationDataFormat"] + "required": [ + "recoveryPlanExecutionStatus", + "complaintDiagnosisTaskId", + "ossEventId", + "accessPortName", + "omcAutoRecoveryAuthorized", + "recoveryPlanName", + "recoveryPlanDetail" + ] } diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/server/slot-names.properties b/a2a-t-sample/src/main/resources/sample/service-recovery/server/slot-names.properties index 2eea28c3..6870679c 100644 --- a/a2a-t-sample/src/main/resources/sample/service-recovery/server/slot-names.properties +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/server/slot-names.properties @@ -1,2 +1,11 @@ subscriptionCondition=订阅条件 -notificationDataFormat=上报通知数据格式 +recoveryPlanExecutionStatus=业务抢通方案执行状态 +complaintDiagnosisTaskId=投诉诊断任务流水号 +ossEventId=OSS侧事件流水号 +accessPortName=接入端口名称 +omcAutoRecoveryAuthorized=是否已授权OMC自动抢通 +recoveryPlanName=业务抢通方案名称 +recoveryPlanDetail=业务抢通方案详情 +recoveryPlanEndTime=业务抢通方案执行结束时间 +recoveryPlanExecutionResult=业务抢通方案执行结果 +recoveryPlanFailureReason=业务抢通方案执行失败原因 From 6dd6442d5781e9e87dac71c61c0f436a267f9941 Mon Sep 17 00:00:00 2001 From: gcw_mr3RVhmf Date: Sat, 22 Aug 2026 18:40:36 +0800 Subject: [PATCH 123/295] fix(sample): report negotiation end-to-end success Signed-off-by: gcw_mr3RVhmf --- .../NEGOTIATION_QWEN_EVALUATION.zh-CN.md | 32 +++++- .../NegotiationQwenEvaluationMain.java | 105 +++++++++++++----- 2 files changed, 110 insertions(+), 27 deletions(-) diff --git a/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md b/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md index b1e66ff8..ec1db102 100644 --- a/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md +++ b/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md @@ -2,7 +2,7 @@ 此入口验证传输专线投诉诊断场景的 3 组接口:Propose、Accept、Reject 各自的自然语言生成接口和对应校验/提参接口。完整集为 100 条端到端流程;另提供 20 条精简集,便于日常回归。 -每条流程依次执行:Propose 生成、Propose 校验、客户端补充信息、Accept 或 Reject 生成、对应校验。生成接口的实际 Prompt 直接作为配对校验接口的输入;客户端补充信息是无 SDK 校验的中间业务数据。只有两次生成、两次校验都成功且提取值与 golden data 一致,流程才通过。 +每条流程依次执行:Propose 生成、Propose 校验、客户端补充信息、Accept 或 Reject 生成、对应校验。生成接口的实际 Prompt 直接作为配对校验接口的输入;客户端补充信息是无 SDK 校验的中间业务数据。两次生成和两次校验均成功返回时,端到端流程通过。 ## 用例集 @@ -45,6 +45,23 @@ java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` smoke ``` +只查看一条 Accept 端到端流程时,可运行 `A01`: + +```powershell +java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` + a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ` + a2a-t-sample/target/negotiation-qwen-example-report.json ` + a2a-t-sample/target/negotiation-qwen-example-process.jsonl ` + A01 +``` + +运行完成后,以下命令会单独打印这条流程的四个接口输入和输出: + +```powershell +(Get-Content -Raw a2a-t-sample/target/negotiation-qwen-example-report.json | + ConvertFrom-Json).cases[0].api_trace | ConvertTo-Json -Depth 20 +``` + ## SDK 问题最小复现 完整执行前可先运行以下 6 条流程,确认三组生成与校验接口均能调用。自然语言输入和 golden data 均以 `cases.json` 为唯一来源。 @@ -59,7 +76,18 @@ java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` 每次运行会在报告中写入 `git_revision`、`case_set` 和 `case_ids`,便于把结果与 SDK 代码版本和用例集合对应起来。过程日志是严格 JSONL:每行都是一条可独立解析的 JSON 事件。失败事件会额外记录 SDK `code` 和 `slot_errors`;其中 `negotiation_semantic_rejected` 能直接区分语义校验拒绝,`slot_errors` 可定位字段级规则问题。 -汇总报告会分别输出 `propose_succeeded`、`ending_succeeded`、通过数和 `automatic_consistency_rate`。每条流程保存 Propose/Ending 的自然语言输入、生成 Prompt、期望值、实际提取值、客户端补充信息、四个调用阶段的状态、耗时及结构化错误。过程日志采用 JSONL,每一行对应一次 SDK 调用,阶段为 `generate_propose`、`validate_propose_and_fill`、`generate_accept|reject`、`validate_accept|reject_and_fill`,并包含请求、响应、耗时、SDK 错误码、字段错误、cause 链和截断后的调用栈。 +汇总报告以 `passed` 和 `end_to_end_success_rate` 作为主验证结论,表示四个接口是否完整串通且没有抛出异常;`propose_succeeded`、`ending_succeeded` 分别统计两组配对接口是否成功。每条流程保存 Propose/Ending 的自然语言输入、生成 Prompt、期望值、实际提取值、客户端补充信息、四个调用阶段的状态、耗时及结构化错误。 + +`propose_expected_matched`、`ending_expected_matched`、`golden_matched` 和 `golden_exact_match_rate` 使用严格值比较,只作为提参差异的辅助诊断指标,不影响端到端通过率。自然语言可能出现语义等价但字面不同的结果,例如“物理或逻辑端口”和“物理端口或逻辑端口名称”;这类情况应在端到端指标中计为成功。`propose_context_matched` 和 `ending_context_matched` 单独记录上下文传递是否一致。 + +每条流程的 `api_trace` 按调用顺序保存一组可连续阅读的端到端记录。Accept 流程依次为: + +1. `A2ATClient.generateNegotiationProposePromptFromText`:发起生成; +2. `A2ATServer.validateProposePromptAndDataFilling`:发起校验和提参; +3. `A2ATServer.generateNegotiationAcceptPromptFromText`:接受生成; +4. `A2ATClient.validateAcceptPromptAndDataFilling`:接受校验和提参。 + +Reject 流程的第 3、4 步替换为对应的 Reject 接口。每一步均包含 `step_label`、`api`、`caller`、`request`、`response`、`expected`、耗时和结果;发生异常时保存结构化错误。过程日志采用 JSONL,同一 `case_id` 的四行与报告中的 `api_trace` 内容一致,便于流式查看和故障检索。 定位时按 `run_id` 和 `case_id` 关联同一流程的四次调用。生成失败时检查自然语言、上下文和模板 URI;校验失败时直接核对前一阶段的 `response.prompt`、业务 Schema 和 SDK 异常。客户端补充信息会记录在 Ending 生成请求中,但不会调用校验 API。过程日志不记录 API Key。 diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java index 44b48cf7..756c0f52 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -65,6 +65,9 @@ public static void main(String[] args) throws IOException { long endingSucceeded = results.stream() .filter(result -> Boolean.TRUE.equals(result.get("ending_succeeded"))) .count(); + long goldenMatched = results.stream() + .filter(result -> Boolean.TRUE.equals(result.get("golden_matched"))) + .count(); Map report = new LinkedHashMap<>(); report.put("generated_at", Instant.now().toString()); report.put("run_id", runId); @@ -77,8 +80,10 @@ public static void main(String[] args) throws IOException { report.put("propose_succeeded", proposeSucceeded); report.put("ending_succeeded", endingSucceeded); report.put("passed", passed); - report.put("automatic_consistency_rate", (double) passed / results.size()); - report.put("note", "A flow passes only when both generated prompts pass their matching validation APIs and all extracted values match the golden data."); + report.put("end_to_end_success_rate", (double) passed / results.size()); + report.put("golden_matched", goldenMatched); + report.put("golden_exact_match_rate", (double) goldenMatched / results.size()); + report.put("note", "A flow passes when all four generation/validation API calls return successfully. Golden exact matching is an auxiliary diagnostic because semantically equivalent natural-language values may differ in wording."); report.put("process_log", processLogPath.toAbsolutePath().toString()); report.put("cases", results); Files.createDirectories(reportPath.toAbsolutePath().getParent()); @@ -115,32 +120,38 @@ private static Map runCase( result.put("ending_validation_succeeded", false); result.put("propose_succeeded", false); result.put("ending_succeeded", false); + result.put("golden_matched", false); + List> apiTrace = new ArrayList<>(); + result.put("api_trace", apiTrace); try { - MetadataContent propose = generateProposeWithLog(client, testCase, context, runId, processLogger); + MetadataContent propose = generateProposeWithLog( + client, testCase, context, runId, processLogger, apiTrace); result.put("propose_generation_succeeded", true); result.put("generated_propose_prompt", propose.promptText()); FilledParamData proposeFilled = validateProposeWithLog( - server, testCase, propose.promptText(), runId, processLogger); + server, testCase, propose.promptText(), runId, processLogger, apiTrace); result.put("propose_validation_succeeded", true); result.put("actual_propose", proposeFilled.data()); - boolean proposeMatched = expectedValuesMatch(testCase.expectedPropose(), proposeFilled.data()) - && contextMatches(context, proposeFilled.data()); + boolean proposeMatched = expectedValuesMatch(testCase.expectedPropose(), proposeFilled.data()); result.put("propose_expected_matched", proposeMatched); - result.put("propose_succeeded", proposeMatched); + result.put("propose_context_matched", contextMatches(context, proposeFilled.data())); + result.put("propose_succeeded", true); NegotiationContext responseContext = NegotiationSampleFlow.contextFrom(proposeFilled.data()); - MetadataContent ending = generateEndingWithLog(server, testCase, responseContext, runId, processLogger); + MetadataContent ending = generateEndingWithLog( + server, testCase, responseContext, runId, processLogger, apiTrace); result.put("ending_generation_succeeded", true); result.put("generated_ending_prompt", ending.promptText()); FilledParamData endingFilled = validateEndingWithLog( - client, testCase, ending.promptText(), runId, processLogger); + client, testCase, ending.promptText(), runId, processLogger, apiTrace); result.put("ending_validation_succeeded", true); result.put("actual_ending", endingFilled.data()); - boolean endingMatched = expectedValuesMatch(testCase.expectedEnding(), endingFilled.data()) - && contextMatches(responseContext, endingFilled.data()); + boolean endingMatched = expectedValuesMatch(testCase.expectedEnding(), endingFilled.data()); result.put("ending_expected_matched", endingMatched); - result.put("ending_succeeded", endingMatched); - result.put("passed", proposeMatched && endingMatched); + result.put("ending_context_matched", contextMatches(responseContext, endingFilled.data())); + result.put("ending_succeeded", true); + result.put("golden_matched", proposeMatched && endingMatched); + result.put("passed", true); } catch (RuntimeException exception) { result.put("passed", false); result.put("error", errorDetails(exception)); @@ -154,12 +165,13 @@ private static MetadataContent generateProposeWithLog( NegotiationEvaluationFlowCase testCase, NegotiationContext context, String runId, - NegotiationEvaluationProcessLogger processLogger) throws IOException { + NegotiationEvaluationProcessLogger processLogger, + List> apiTrace) throws IOException { long startedAt = System.nanoTime(); try { MetadataContent content = client.generateNegotiationProposePromptFromText( testCase.proposeCase().text(), context, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); - processLogger.write(stageEvent(runId, testCase, "generate_propose", "propose", context, Map.of( + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_propose", "propose", context, Map.of( "text", testCase.proposeCase().text(), "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), Map.of( "prompt", content.promptText(), @@ -167,7 +179,7 @@ private static MetadataContent generateProposeWithLog( "extension_uri", content.extensionUri()), startedAt, null)); return content; } catch (RuntimeException exception) { - processLogger.write(stageEvent(runId, testCase, "generate_propose", "propose", context, Map.of( + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_propose", "propose", context, Map.of( "text", testCase.proposeCase().text(), "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), null, startedAt, exception)); throw exception; @@ -179,19 +191,20 @@ private static FilledParamData validateProposeWithLog( NegotiationEvaluationFlowCase testCase, String prompt, String runId, - NegotiationEvaluationProcessLogger processLogger) throws IOException { + NegotiationEvaluationProcessLogger processLogger, + List> apiTrace) throws IOException { long startedAt = System.nanoTime(); Map schema = InformationNegotiationSchemas.propose(); try { FilledParamData filled = server.validateProposePromptAndDataFilling( prompt, schema, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); - processLogger.write(stageEvent(runId, testCase, "validate_propose_and_fill", "propose", null, Map.of( + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_propose_and_fill", "propose", null, Map.of( "prompt", prompt, "schema", schema, "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), Map.of("filled_data", filled.data()), startedAt, null)); return filled; } catch (RuntimeException exception) { - processLogger.write(stageEvent(runId, testCase, "validate_propose_and_fill", "propose", null, Map.of( + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_propose_and_fill", "propose", null, Map.of( "prompt", prompt, "schema", schema, "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), null, startedAt, exception)); @@ -204,7 +217,8 @@ private static MetadataContent generateEndingWithLog( NegotiationEvaluationFlowCase testCase, NegotiationContext context, String runId, - NegotiationEvaluationProcessLogger processLogger) throws IOException { + NegotiationEvaluationProcessLogger processLogger, + List> apiTrace) throws IOException { long startedAt = System.nanoTime(); try { String endingInput = testCase.endingGenerationText(context.id(), context.round(), context.maxRounds()); @@ -213,7 +227,7 @@ private static MetadataContent generateEndingWithLog( endingInput, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI) : server.generateNegotiationRejectPromptFromText( endingInput, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); - processLogger.write(stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), context, + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), context, Map.of("text", endingInput, "client_supplement", testCase.clientSupplement(context.id(), context.round(), context.maxRounds()), "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), @@ -222,7 +236,7 @@ private static MetadataContent generateEndingWithLog( return content; } catch (RuntimeException exception) { String endingInput = testCase.endingGenerationText(context.id(), context.round(), context.maxRounds()); - processLogger.write(stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), context, + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), context, Map.of("text", endingInput, "client_supplement", testCase.clientSupplement(context.id(), context.round(), context.maxRounds()), "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), null, startedAt, exception)); @@ -235,7 +249,8 @@ private static FilledParamData validateEndingWithLog( NegotiationEvaluationFlowCase testCase, String prompt, String runId, - NegotiationEvaluationProcessLogger processLogger) throws IOException { + NegotiationEvaluationProcessLogger processLogger, + List> apiTrace) throws IOException { long startedAt = System.nanoTime(); Map schema = "accept".equals(testCase.decision()) ? InformationNegotiationSchemas.accept() @@ -244,13 +259,13 @@ private static FilledParamData validateEndingWithLog( FilledParamData filled = "accept".equals(testCase.decision()) ? client.validateAcceptPromptAndDataFilling(prompt, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI) : client.validateRejectPromptAndDataFilling(prompt, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI); - processLogger.write(stageEvent(runId, testCase, "validate_" + testCase.decision() + "_and_fill", + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_" + testCase.decision() + "_and_fill", testCase.decision(), null, Map.of("prompt", prompt, "schema", schema, "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), Map.of("filled_data", filled.data()), startedAt, null)); return filled; } catch (RuntimeException exception) { - processLogger.write(stageEvent(runId, testCase, "validate_" + testCase.decision() + "_and_fill", + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_" + testCase.decision() + "_and_fill", testCase.decision(), null, Map.of("prompt", prompt, "schema", schema, "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), null, startedAt, exception)); throw exception; @@ -271,6 +286,11 @@ private static Map stageEvent( event.put("timestamp", Instant.now().toString()); event.put("run_id", runId); event.put("case_id", testCase.id()); + int step = stageStep(stage); + event.put("step", step); + event.put("step_label", step + "/4"); + event.put("api", stageApi(stage)); + event.put("caller", stageCaller(stage)); event.put("phase", phase); event.put("decision", testCase.decision()); event.put("stage", stage); @@ -290,6 +310,41 @@ private static Map stageEvent( return event; } + private static void writeStage( + NegotiationEvaluationProcessLogger processLogger, + List> apiTrace, + Map event) throws IOException { + processLogger.write(event); + apiTrace.add(event); + } + + private static int stageStep(String stage) { + if ("generate_propose".equals(stage)) { + return 1; + } + if ("validate_propose_and_fill".equals(stage)) { + return 2; + } + return stage.startsWith("generate_") ? 3 : 4; + } + + private static String stageApi(String stage) { + return switch (stage) { + case "generate_propose" -> "A2ATClient.generateNegotiationProposePromptFromText"; + case "validate_propose_and_fill" -> "A2ATServer.validateProposePromptAndDataFilling"; + case "generate_accept" -> "A2ATServer.generateNegotiationAcceptPromptFromText"; + case "validate_accept_and_fill" -> "A2ATClient.validateAcceptPromptAndDataFilling"; + case "generate_reject" -> "A2ATServer.generateNegotiationRejectPromptFromText"; + case "validate_reject_and_fill" -> "A2ATClient.validateRejectPromptAndDataFilling"; + default -> stage; + }; + } + + private static String stageCaller(String stage) { + return stage.startsWith("generate_propose") || stage.startsWith("validate_accept") + || stage.startsWith("validate_reject") ? "client" : "server"; + } + private static Map runStartedEvent(String runId, Map environment, Path envPath) { Map event = new LinkedHashMap<>(); event.put("timestamp", Instant.now().toString()); From 16d4da28f5fbf0458deefa59ad309c1012bc6de3 Mon Sep 17 00:00:00 2001 From: gcw_mr3RVhmf Date: Sat, 22 Aug 2026 18:47:00 +0800 Subject: [PATCH 124/295] chore(sample): exclude local negotiation guides Signed-off-by: gcw_mr3RVhmf --- .../NEGOTIATION_API_EXAMPLES.zh-CN.md | 440 ----------------- .../NEGOTIATION_QWEN_EVALUATION.zh-CN.md | 96 ---- .../NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md | 465 ------------------ a2a-t-sample/README.zh-CN.md | 88 +--- 4 files changed, 16 insertions(+), 1073 deletions(-) delete mode 100644 a2a-t-sample/NEGOTIATION_API_EXAMPLES.zh-CN.md delete mode 100644 a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md delete mode 100644 a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md diff --git a/a2a-t-sample/NEGOTIATION_API_EXAMPLES.zh-CN.md b/a2a-t-sample/NEGOTIATION_API_EXAMPLES.zh-CN.md deleted file mode 100644 index 0d2d6457..00000000 --- a/a2a-t-sample/NEGOTIATION_API_EXAMPLES.zh-CN.md +++ /dev/null @@ -1,440 +0,0 @@ -# Negotiation-T 六个接口示例 - -本文使用“传输专线业务投诉诊断”场景,展示 3 个自然语言生成接口和 3 个校验/提参接口的调用方法。 - -完整链路分为两条: - -- 接受:Propose 生成 → Propose 校验/提参 → Accept 生成 → Accept 校验/提参。 -- 拒绝:Propose 生成 → Propose 校验/提参 → Reject 生成 → Reject 校验/提参。 - -Accept 与 Reject 是互斥结果。单次协商调用 4 个接口;分别运行接受和拒绝两条链路,覆盖全部 6 个接口。 - -## 1. 公共对象 - -以下示例假定 `A2ATClient` 和 `A2ATServer` 已按照 Sample 的环境配置完成初始化: - -```java -A2ATClient client = ...; -A2ATServer server = ...; -``` - -公共模板 URI 和协商上下文: - -```java -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; - -TemplateUri proposeTemplateUri = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; -TemplateUri endingTemplateUri = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - -NegotiationContext context = new NegotiationContext( - "5dd96c7b-2f19-4885-93d7-31e8bffac25c", - 1, - 3); -``` - -本场景的业务 Schema 已由 Sample 提供: - -```java -Map proposeSchema = InformationNegotiationSchemas.propose(); -Map acceptSchema = InformationNegotiationSchemas.accept(); -Map rejectSchema = InformationNegotiationSchemas.reject(); -``` - -这三个方法只是复用入口,实际传入接口的 Schema 如下。 - -### 1.1 Propose Schema 示例 - -Propose 阶段需要从协商请求中提取“希望对端补充什么信息,以及信息应满足什么要求”: - -```json -{ - "type": "object", - "additionalProperties": false, - "properties": { - "access_port_name": { - "type": "string", - "description": "请求的接入端口信息及可接受的物理或逻辑端口格式" - }, - "complaint_category": { - "type": "string", - "description": "请求的投诉分类及允许的专线投诉类型" - } - }, - "required": ["access_port_name", "complaint_category"] -} -``` - -对应当前场景的预期提取值: - -```json -{ - "access_port_name": "物理端口或逻辑端口名称", - "complaint_category": "专线中断或专线质差" -} -``` - -### 1.2 Accept Schema 示例 - -Accept 阶段需要提取对端实际补充的端口和投诉分类: - -```json -{ - "type": "object", - "additionalProperties": false, - "properties": { - "access_port_name": { - "type": "string", - "description": "用于专线诊断的物理或逻辑接入端口名称" - }, - "complaint_category": { - "type": "string", - "description": "专线投诉分类", - "enum": ["专线中断", "专线质差"] - } - }, - "required": ["access_port_name", "complaint_category"] -} -``` - -对应当前场景的预期提取值: - -```json -{ - "access_port_name": "P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)", - "complaint_category": "专线质差" -} -``` - -### 1.3 Reject Schema 示例 - -Reject 消息没有端口和投诉分类的实际值,因此提取拒绝原因: - -```json -{ - "type": "object", - "additionalProperties": false, - "properties": { - "rejection_reason": { - "type": "string", - "description": "无法补充专线投诉信息的原因" - } - }, - "required": ["rejection_reason"] -} -``` - -对应当前场景的预期提取值: - -```json -{ - "rejection_reason": "当前账号没有资源系统查询权限" -} -``` - -### 1.4 等价的 Java Map 示例 - -以 Propose Schema 为例,不通过 Sample 工厂类时,可以直接这样构造: - -```java -Map proposeSchema = Map.of( - "type", "object", - "additionalProperties", false, - "properties", Map.of( - "access_port_name", Map.of( - "type", "string", - "description", "请求的接入端口信息及可接受的物理或逻辑端口格式"), - "complaint_category", Map.of( - "type", "string", - "description", "请求的投诉分类及允许的专线投诉类型")), - "required", List.of("access_port_name", "complaint_category")); - -FilledParamData proposeData = server.validateProposePromptAndDataFilling( - receivedProposePrompt, - proposeSchema, - proposeTemplateUri); -``` - -Sample 中的 `InformationNegotiationSchemas` 将上述结构集中管理,避免在每个调用点重复构造。 - -三个 Schema 的提取目标汇总如下: - -| 阶段 | 业务字段 | -| --- | --- | -| Propose | `access_port_name`、`complaint_category` | -| Accept | `access_port_name`、`complaint_category` | -| Reject | `rejection_reason` | - -`id`、`round`、`maxRounds` 由 SDK 从 Negotiation-T Prompt 中确定性提取,不需要写入业务 Schema。 - -## 2. Propose 生成接口 - -接口: - -```java -MetadataContent generateNegotiationProposePromptFromText( - String text, - NegotiationContext context, - TemplateUri templateUri) -``` - -调用示例: - -```java -String proposeText = "当前专线业务投诉诊断缺少接入端口名称和投诉分类," - + "请发起信息协商,请求对端补充这两项信息。" - + "接入端口名称需要提供物理端口或逻辑端口名称," - + "投诉分类应为专线中断或专线质差。"; - -MetadataContent propose = client.generateNegotiationProposePromptFromText( - proposeText, - context, - proposeTemplateUri); -``` - -`MetadataContent` 中包含: - -- `templateUri()`:`Negotiation-T/information-negotiation/propose/v1`。 -- `promptText()`:SDK 根据自然语言、上下文和内置模板生成的完整 Propose Prompt。 -- `extensionUri()`:Negotiation-T 的 TMF 扩展 URI。 -- `buildMetadataContent()`:用于放入 A2A `Message.metadata` 的两个键值。 - -发送端应使用以下结果作为线上消息 metadata: - -```java -Map messageMetadata = propose.buildMetadataContent(); -``` - -metadata 的结构为: - -```json -{ - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1": "<完整 Propose Prompt>", - "templateUri": "Negotiation-T/information-negotiation/propose/v1" -} -``` - -## 3. Propose 校验和提参接口 - -接口: - -```java -FilledParamData validateProposePromptAndDataFilling( - String prompt, - Map schema, - TemplateUri templateUri) -``` - -接收端应从 A2A `Message.metadata` 读取 Prompt,不能使用原始自然语言 `proposeText` 代替: - -```java -String receivedProposePrompt = NegotiationMetadataReader.readPrompt( - messageMetadata, - proposeTemplateUri); - -FilledParamData proposeData = server.validateProposePromptAndDataFilling( - receivedProposePrompt, - proposeSchema, - proposeTemplateUri); - -Map proposeParams = proposeData.data(); -``` - -本场景在 Mock 模式下的预期结果: - -```json -{ - "id": "5dd96c7b-2f19-4885-93d7-31e8bffac25c", - "round": 1, - "maxRounds": 3, - "access_port_name": "物理端口或逻辑端口名称", - "complaint_category": "专线中断或专线质差" -} -``` - -服务端使用校验结果中的上下文字段构造回复上下文,不能生成新的协商 ID: - -```java -NegotiationContext responseContext = new NegotiationContext( - (String) proposeParams.get("id"), - ((Number) proposeParams.get("round")).intValue(), - ((Number) proposeParams.get("maxRounds")).intValue()); -``` - -## 4. Accept 生成接口 - -接口: - -```java -MetadataContent generateNegotiationAcceptPromptFromText( - String text, - NegotiationContext context, - TemplateUri templateUri) -``` - -调用示例: - -```java -String acceptText = "接受本次信息协商。" - + "接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)," - + "投诉分类为专线质差。"; - -MetadataContent accept = server.generateNegotiationAcceptPromptFromText( - acceptText, - responseContext, - endingTemplateUri); - -Map artifactMetadata = accept.buildMetadataContent(); -``` - -该 metadata 应放入返回给发起方的 A2A `Artifact.metadata`。其中 `templateUri` 为: - -```text -Negotiation-T/information-negotiation/accept-reject/v1 -``` - -## 5. Accept 校验和提参接口 - -接口: - -```java -FilledParamData validateAcceptPromptAndDataFilling( - String prompt, - Map schema, - TemplateUri templateUri) -``` - -调用示例: - -```java -String receivedAcceptPrompt = NegotiationMetadataReader.readPrompt( - artifactMetadata, - endingTemplateUri); - -FilledParamData acceptData = client.validateAcceptPromptAndDataFilling( - receivedAcceptPrompt, - acceptSchema, - endingTemplateUri); - -Map acceptParams = acceptData.data(); -``` - -本场景在 Mock 模式下的预期结果: - -```json -{ - "id": "5dd96c7b-2f19-4885-93d7-31e8bffac25c", - "round": 1, - "maxRounds": 3, - "access_port_name": "P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)", - "complaint_category": "专线质差" -} -``` - -## 6. Reject 生成接口 - -接口: - -```java -MetadataContent generateNegotiationRejectPromptFromText( - String text, - NegotiationContext context, - TemplateUri templateUri) -``` - -Reject 与 Accept 使用同一个 `accept-reject` 模板 URI: - -```java -String rejectText = "拒绝本次信息协商。" - + "当前账号没有资源系统查询权限," - + "无法取得接入端口名称和投诉分类。"; - -MetadataContent reject = server.generateNegotiationRejectPromptFromText( - rejectText, - responseContext, - endingTemplateUri); - -Map artifactMetadata = reject.buildMetadataContent(); -``` - -## 7. Reject 校验和提参接口 - -接口: - -```java -FilledParamData validateRejectPromptAndDataFilling( - String prompt, - Map schema, - TemplateUri templateUri) -``` - -调用示例: - -```java -String receivedRejectPrompt = NegotiationMetadataReader.readPrompt( - artifactMetadata, - endingTemplateUri); - -FilledParamData rejectData = client.validateRejectPromptAndDataFilling( - receivedRejectPrompt, - rejectSchema, - endingTemplateUri); - -Map rejectParams = rejectData.data(); -``` - -本场景在 Mock 模式下的预期结果: - -```json -{ - "id": "5dd96c7b-2f19-4885-93d7-31e8bffac25c", - "round": 1, - "maxRounds": 3, - "rejection_reason": "当前账号没有资源系统查询权限" -} -``` - -## 8. 两条完整调用链 - -接受分支: - -```java -MetadataContent propose = client.generateNegotiationProposePromptFromText( - proposeText, context, proposeTemplateUri); -String proposePrompt = NegotiationMetadataReader.readPrompt( - propose.buildMetadataContent(), proposeTemplateUri); -FilledParamData proposeData = server.validateProposePromptAndDataFilling( - proposePrompt, proposeSchema, proposeTemplateUri); - -NegotiationContext responseContext = new NegotiationContext( - (String) proposeData.data().get("id"), - ((Number) proposeData.data().get("round")).intValue(), - ((Number) proposeData.data().get("maxRounds")).intValue()); - -MetadataContent accept = server.generateNegotiationAcceptPromptFromText( - acceptText, responseContext, endingTemplateUri); -String acceptPrompt = NegotiationMetadataReader.readPrompt( - accept.buildMetadataContent(), endingTemplateUri); -FilledParamData acceptData = client.validateAcceptPromptAndDataFilling( - acceptPrompt, acceptSchema, endingTemplateUri); -``` - -拒绝分支的前 3 步相同,结束阶段替换为: - -```java -MetadataContent reject = server.generateNegotiationRejectPromptFromText( - rejectText, responseContext, endingTemplateUri); -String rejectPrompt = NegotiationMetadataReader.readPrompt( - reject.buildMetadataContent(), endingTemplateUri); -FilledParamData rejectData = client.validateRejectPromptAndDataFilling( - rejectPrompt, rejectSchema, endingTemplateUri); -``` - -## 9. 调用时需要核对的约束 - -- 生成接口的 `text` 是业务自然语言;校验接口的 `prompt` 是生成接口返回的完整 `promptText`。 -- Propose 使用 `information-negotiation/propose`;Accept 和 Reject 使用 `information-negotiation/accept-reject`。 -- 校验接口的 `TemplateUri` 应与 metadata 中的 `templateUri` 字符串一致;需要比较时使用 `templateUri.uri()`。 -- 回复必须沿用 Propose 中的 `id`、`round` 和 `maxRounds`。 -- 业务 Schema 跟具体 OSS 场景绑定,本示例的字段只适用于当前专线投诉场景。 -- Mock 模式的结果是确定的;Qwen3-32B 模式下自然语言表述可能变化,但结构、上下文和 Schema 约束应保持一致。 diff --git a/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md b/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md deleted file mode 100644 index ec1db102..00000000 --- a/a2a-t-sample/NEGOTIATION_QWEN_EVALUATION.zh-CN.md +++ /dev/null @@ -1,96 +0,0 @@ -# Negotiation-T Qwen3-32B 批量验证 - -此入口验证传输专线投诉诊断场景的 3 组接口:Propose、Accept、Reject 各自的自然语言生成接口和对应校验/提参接口。完整集为 100 条端到端流程;另提供 20 条精简集,便于日常回归。 - -每条流程依次执行:Propose 生成、Propose 校验、客户端补充信息、Accept 或 Reject 生成、对应校验。生成接口的实际 Prompt 直接作为配对校验接口的输入;客户端补充信息是无 SDK 校验的中间业务数据。两次生成和两次校验均成功返回时,端到端流程通过。 - -## 用例集 - -资源文件是 [cases.json](src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json),包含: - -- Propose 34 条; -- Accept 33 条; -- Reject 33 条。 - -覆盖完整表述、短句、同义改写、口语化表达、中英文混合、噪声信息、字段顺序变化、换行和业务上下文等类型。加载器把每个原始编号组装成完整流程:P 编号重点覆盖 Propose 输入并确定性配对一个 Accept/Reject 输入;A/R 编号重点覆盖结论输入并确定性配对一个 Propose 输入。最终 100 条流程包含 50 条 Accept 和 50 条 Reject。`expected` 是对应阶段的人工 golden data;`completedPrompt` 作为历史标准报文保留在语料中,不再用于校验接口入参。 - -精简集选择器为 `smoke`,共 20 条完整流程,兼顾 Propose 输入及 Accept/Reject 两种结论,覆盖完整、短句、上下文、同义改写、中英文混合、噪声、业务化和口语化表达。它从 `cases.json` 组装,不维护第二份用例数据;完整集选择器为 `full`(默认)。也可直接传逗号分隔的编号执行任意组合,例如 `P01,A28,R21`。 - -## 在目标网络执行 - -当前本机网络不能访问 Qwen 服务。进入东莞绿区 70 网段后,直接使用仓库提供的 [qwen.env](src/main/resources/sample/private-line-complaint-negotiation/qwen.env)。其中 `A2AT_LLM_DISABLE_SYSTEM_PROXY=true` 会绕过公司黄区的系统代理;该开关仅作用于此 LLM Client,不影响其他网络请求。不要修改默认的 Mock `client.env` 和 `server.env`。 - -先打包: - -```powershell -mvn -pl a2a-t-sample -am -DskipTests package -``` - -再运行。参数依次为 env 文件、汇总报告输出路径、可选的过程日志输出路径、可选的用例选择器;不传第三个参数时,会在报告同目录生成同名的 `-process.jsonl` 文件。选择器省略或传 `full` 时运行 100 条,传 `smoke` 时运行 20 条,传逗号分隔的用例编号时运行指定用例: - -```powershell -java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` - a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ` - a2a-t-sample/target/negotiation-qwen-report.json ` - a2a-t-sample/target/negotiation-qwen-process.jsonl -``` - -日常运行精简集: - -```powershell -java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` - a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ` - a2a-t-sample/target/negotiation-qwen-smoke-report.json ` - a2a-t-sample/target/negotiation-qwen-smoke-process.jsonl ` - smoke -``` - -只查看一条 Accept 端到端流程时,可运行 `A01`: - -```powershell -java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` - a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ` - a2a-t-sample/target/negotiation-qwen-example-report.json ` - a2a-t-sample/target/negotiation-qwen-example-process.jsonl ` - A01 -``` - -运行完成后,以下命令会单独打印这条流程的四个接口输入和输出: - -```powershell -(Get-Content -Raw a2a-t-sample/target/negotiation-qwen-example-report.json | - ConvertFrom-Json).cases[0].api_trace | ConvertTo-Json -Depth 20 -``` - -## SDK 问题最小复现 - -完整执行前可先运行以下 6 条流程,确认三组生成与校验接口均能调用。自然语言输入和 golden data 均以 `cases.json` 为唯一来源。 - -```powershell -java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` - a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ` - a2a-t-sample/target/negotiation-qwen-repro-report.json ` - a2a-t-sample/target/negotiation-qwen-repro-process.jsonl ` - P01,P14,P16,A28,R01,R21 -``` - -每次运行会在报告中写入 `git_revision`、`case_set` 和 `case_ids`,便于把结果与 SDK 代码版本和用例集合对应起来。过程日志是严格 JSONL:每行都是一条可独立解析的 JSON 事件。失败事件会额外记录 SDK `code` 和 `slot_errors`;其中 `negotiation_semantic_rejected` 能直接区分语义校验拒绝,`slot_errors` 可定位字段级规则问题。 - -汇总报告以 `passed` 和 `end_to_end_success_rate` 作为主验证结论,表示四个接口是否完整串通且没有抛出异常;`propose_succeeded`、`ending_succeeded` 分别统计两组配对接口是否成功。每条流程保存 Propose/Ending 的自然语言输入、生成 Prompt、期望值、实际提取值、客户端补充信息、四个调用阶段的状态、耗时及结构化错误。 - -`propose_expected_matched`、`ending_expected_matched`、`golden_matched` 和 `golden_exact_match_rate` 使用严格值比较,只作为提参差异的辅助诊断指标,不影响端到端通过率。自然语言可能出现语义等价但字面不同的结果,例如“物理或逻辑端口”和“物理端口或逻辑端口名称”;这类情况应在端到端指标中计为成功。`propose_context_matched` 和 `ending_context_matched` 单独记录上下文传递是否一致。 - -每条流程的 `api_trace` 按调用顺序保存一组可连续阅读的端到端记录。Accept 流程依次为: - -1. `A2ATClient.generateNegotiationProposePromptFromText`:发起生成; -2. `A2ATServer.validateProposePromptAndDataFilling`:发起校验和提参; -3. `A2ATServer.generateNegotiationAcceptPromptFromText`:接受生成; -4. `A2ATClient.validateAcceptPromptAndDataFilling`:接受校验和提参。 - -Reject 流程的第 3、4 步替换为对应的 Reject 接口。每一步均包含 `step_label`、`api`、`caller`、`request`、`response`、`expected`、耗时和结果;发生异常时保存结构化错误。过程日志采用 JSONL,同一 `case_id` 的四行与报告中的 `api_trace` 内容一致,便于流式查看和故障检索。 - -定位时按 `run_id` 和 `case_id` 关联同一流程的四次调用。生成失败时检查自然语言、上下文和模板 URI;校验失败时直接核对前一阶段的 `response.prompt`、业务 Schema 和 SDK 异常。客户端补充信息会记录在 Ending 生成请求中,但不会调用校验 API。过程日志不记录 API Key。 - -完整集会产生约 400 次模型调用,精简集约 80 次模型调用(每条流程两次生成、两次校验/提参);以 Qwen 响应时延为准设置 `A2AT_LLM_TIMEOUT_SECONDS`。 - -若目标网络的公司代理干扰访问,按 `C:\cnafan\git\design\Qwen3-32B模型调用指导.md` 的说明处理网络代理。Java SDK 当前 OpenAI-compatible Client 未额外加入 Qwen 专属逻辑;先保留实际失败报文,再判断是否需要在 SDK 层适配 `enable_thinking=false`。 diff --git a/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md b/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md deleted file mode 100644 index 223d82c8..00000000 --- a/a2a-t-sample/NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md +++ /dev/null @@ -1,465 +0,0 @@ -# 传输专线投诉信息协商 Sample 开发说明 - -## 1. 文档用途 - -本文用于指导后续 Code Agent 在 `a2a-t-sample` 中实现“传输专线业务投诉诊断”信息协商示例。实现应完整验证以下 6 个 Negotiation-T API,并提供可离线运行的 Mock 模式和可切换到 Qwen3-32B 的真实模型模式。 - -本文已按仓库提交 `52e7d29` 的公开 API 完成适配。后续开始验证前仍应同步 `main`,再次确认 API 签名和现有 Sample 结构;如接口发生变化,以最新公开 API 为准,并同步更新本文和 Sample。 - -## 2. 范围和边界 - -### 2.1 本次范围 - -信息协商从发起协商开始,到对端校验和提取协商信息,再到接受或拒绝,最后由发起方校验和提取协商结果。 - -需要实现两条可独立运行的分支: - -- Accept:Propose → 校验/提参 → Accept → 校验/提参。 -- Reject:Propose → 校验/提参 → Reject → 校验/提参。 - -一次运行只会进入 Accept 或 Reject 分支,覆盖 4 个 API;两条分支均运行成功后,才算完成 6 个 API 的验证。 - -### 2.2 不在本次范围内 - -- 不生成或校验 Task-T Prompt。 -- 不实现投诉诊断业务逻辑、资源查询或根因分析。 -- 不实现多轮协商状态机;第一版固定为第 1 轮,并在同一轮结束。 -- 不修改 SDK 内置 Negotiation-T 模板。 -- 不修改 `prompt_resources/scenarios/*/scenarios.json` 和 Task-T 的 `slot.json`。 - -现有资源中已经包含 `private-line-complaint` 的 Task-T 场景和槽位定义,但本 Sample 调用的 6 个 Negotiation-T API 不会走 Task-T 的场景识别、槽位提取链路。Sample 自身可以增加一个 `scenario.json` 保存演示输入,它与 SDK 的 `scenarios.json`、`slot.json` 用途不同。 - -## 3. 业务角色和链路 - -建议的 Sample 角色映射如下: - -- Client:SPN OMC 侧,发起信息协商,请求补齐诊断所需信息。 -- Server:传输工作台侧,校验请求并选择接受或拒绝。 - -角色映射仅服务于 Sample 的 HTTP 请求方向。Negotiation-T 的 Client 和 Server 门面都提供相同的 6 个接口,业务接入时可以按实际 Agent 角色调整调用方。 - -```mermaid -sequenceDiagram - participant C as Client / SPN OMC - participant S as Server / 传输工作台 - - C->>C: generateNegotiationProposePromptFromText - C->>S: A2A Message metadata 携带 Propose - S->>S: validateProposePromptAndDataFilling - alt Accept 分支 - S->>S: generateNegotiationAcceptPromptFromText - S-->>C: A2A Artifact metadata 携带 Accept - C->>C: validateAcceptPromptAndDataFilling - else Reject 分支 - S->>S: generateNegotiationRejectPromptFromText - S-->>C: A2A Artifact metadata 携带 Reject - C->>C: validateRejectPromptAndDataFilling - end -``` - -服务端应使用 Propose 校验结果中的 `id`、`round` 和 `maxRounds` 重建 `NegotiationContext`,确保回复和请求属于同一协商会话、同一轮次。服务端不能自行生成新的协商 ID。 - -## 4. 待验证 API - -| 阶段 | 调用方 | API | Template URI | 结果 | -| --- | --- | --- | --- | --- | -| 生成请求 | Client | `generateNegotiationProposePromptFromText` | `Negotiation-T/information-negotiation/propose/v1` | `MetadataContent` | -| 校验请求 | Server | `validateProposePromptAndDataFilling` | 同上 | `FilledParamData` | -| 生成接受 | Server | `generateNegotiationAcceptPromptFromText` | `Negotiation-T/information-negotiation/accept-reject/v1` | `MetadataContent` | -| 校验接受 | Client | `validateAcceptPromptAndDataFilling` | 同上 | `FilledParamData` | -| 生成拒绝 | Server | `generateNegotiationRejectPromptFromText` | `Negotiation-T/information-negotiation/accept-reject/v1` | `MetadataContent` | -| 校验拒绝 | Client | `validateRejectPromptAndDataFilling` | 同上 | `FilledParamData` | - -关键类型: - -```java -net.openan.a2at.sdk.client.A2ATClient -net.openan.a2at.sdk.server.A2ATServer -net.openan.a2at.sdk.negotiation.content.NegotiationContext -net.openan.a2at.sdk.core.model.MetadataContent -net.openan.a2at.sdk.core.model.FilledParamData -net.openan.a2at.sdk.core.model.TemplateUri -net.openan.a2at.sdk.core.model.StandardTemplates -``` - -注意 `FilledParamData` 当前位于 `a2a-t-core`,包名为 `net.openan.a2at.sdk.core.model`。 - -6 个接口的最后一个参数均为 `TemplateUri`。Sample 使用 -`StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE` 和 -`StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT`,避免手写 URI;线上 metadata 仍承载 -`TemplateUri.uri()` 返回的字符串。 - -## 5. 场景输入 - -建议将场景文本集中放入 Sample 资源 `sample/private-line-complaint-negotiation/scenario.json`,避免散落在 Java 常量中。第一版固定使用中文 `zh-CN`。 - -建议内容: - -```json -{ - "scenario": "传输专线业务投诉诊断的信息协商", - "propose_text": "当前专线业务投诉诊断缺少接入端口名称和投诉分类,请发起信息协商,请求对端补充这两项信息。接入端口名称需要提供物理端口或逻辑端口名称,投诉分类应为专线中断或专线质差。", - "accept_text": "接受本次信息协商。接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类为专线质差。", - "reject_text": "拒绝本次信息协商。当前账号没有资源系统查询权限,无法取得接入端口名称和投诉分类。" -} -``` - -Client 创建上下文: - -```java -NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3); -``` - -第一版不调用 `nextRound()`。Accept 和 Reject 都沿用 Propose 中的 `round=1`。 - -## 6. 参数 Schema:场景业务 Schema(调整后的方案 B) - -`validateAndFilling...` 接口的 `schema` 描述调用方希望提取的业务参数,由具体 OSS 场景自行定义。本 Sample 因此直接定义“传输专线业务投诉诊断”的字段。协商模板自身的 `items`、`relationship`、`conclusion` 由 SDK 内部负责识别,不放入调用方 Schema。 - -### 6.1 Propose Schema - -```json -{ - "type": "object", - "additionalProperties": false, - "properties": { - "access_port_name": { - "type": "string", - "description": "请求的接入端口信息及可接受的物理或逻辑端口格式" - }, - "complaint_category": { - "type": "string", - "description": "请求的投诉分类及允许的专线投诉类型" - } - }, - "required": ["access_port_name", "complaint_category"] -} -``` - -Propose 阶段提取的是待补充字段及其约束,因此值可以是“物理端口或逻辑端口名称”“专线中断或专线质差”这类要求描述。 - -### 6.2 Accept Schema - -```json -{ - "type": "object", - "additionalProperties": false, - "properties": { - "access_port_name": { - "type": "string", - "description": "用于专线诊断的物理或逻辑接入端口名称" - }, - "complaint_category": { - "type": "string", - "description": "专线投诉分类", - "enum": ["专线中断", "专线质差"] - } - }, - "required": ["access_port_name", "complaint_category"] -} -``` - -### 6.3 Reject Schema - -Reject 消息无法提供接入端口和投诉分类,因此单独提取拒绝原因: - -```json -{ - "type": "object", - "additionalProperties": false, - "properties": { - "rejection_reason": { - "type": "string", - "description": "无法补充专线投诉信息的原因" - } - }, - "required": ["rejection_reason"] -} -``` - -Sample 内部 Schema 工厂通过 `LinkedHashMap` 构建并返回不可变 Map,Propose、Accept、Reject 各保留一个共享常量。不要复制 SDK 的私有方法,也不要通过反射调用 `NegotiationJsonSchemaBuilder` 的内部 Schema。 - -`id`、`round`、`maxRounds` 不放入调用方 Schema。SDK 先通过确定性规则读取这 3 个上下文字段,再与 LLM 按调用方 Schema 提取的参数合并。预期结果为: - -- Propose:`id`、`round`、`maxRounds`、`access_port_name`、`complaint_category`。 -- Accept:`id`、`round`、`maxRounds`、`access_port_name`、`complaint_category`。 -- Reject:`id`、`round`、`maxRounds`、`rejection_reason`。 - -不使用 `null` Schema。空 Map 虽然可用于只验证上下文字段,但无法充分验证本任务要求的“校验和提参”。 - -## 7. A2A 消息承载约定 - -生成接口返回的 `MetadataContent` 是线上的唯一数据源。发送方必须使用 `buildMetadataContent()` 构建消息或 Artifact metadata,不能手工拼装 Negotiation-T Prompt。 - -当前生成结果包含两个 metadata 键: - -- `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`:`promptText`。 -- `templateUri`:`MetadataContent.templateUri()` 返回的 URI 字符串。 - -请求头 `A2A-Extensions` 使用 `MetadataContent.extensionUri()`。新消息使用不带 `/NL/` 的规范 URI;接收侧如复用通用读取逻辑,可以兼容读取历史 `/Negotiation-T/NL/v1` URI。 - -建议承载方式: - -- Client → Server:A2A `Message.metadata` 放 Propose 的完整 metadata。 -- Server → Client:A2A `Artifact.metadata` 放 Accept 或 Reject 的完整 metadata。 -- `TextPart` 可以放便于人工阅读的摘要,但校验 API 的 `prompt` 参数必须取自 Negotiation-T extension metadata。 -- 接收侧同时核对 `templateUri` 与当前阶段期望 URI;缺失或不一致时立即报错,不调用校验 API。SDK 接口使用 `TemplateUri`,metadata 中保存字符串,比较时调用 `TemplateUri.uri()`。 - -服务端处理顺序: - -1. 校验 `A2A-Extensions` 包含 Negotiation-T 规范 URI。 -2. 从 `Message.metadata` 取 `templateUri` 和 Propose Prompt。 -3. 调用 `validateProposePromptAndDataFilling`。 -4. 从返回值重建 `NegotiationContext`。 -5. 根据 `A2AT_SAMPLE_NEGOTIATION_DECISION` 选择 Accept 或 Reject,默认 Accept。 -6. 调用对应 from-text 生成 API。 -7. 将结束 Prompt 放入 Artifact metadata,发送一次后结束任务。 - -客户端处理顺序: - -1. 从 `scenario.json` 读取 `propose_text`。 -2. 创建上下文并调用 Propose from-text API。 -3. 发送 A2A Message。 -4. 等待第一个携带 Negotiation-T metadata 的 Artifact。 -5. 根据 Artifact Prompt 中的结论以及本次期望分支,调用 Accept 或 Reject 校验 API。 -6. 打印 `FilledParamData.data()` 并断言上下文和业务项。 - -## 8. 建议代码结构 - -不要直接改造成现有 `subscribe_incident` Sample。保留原示例,新建独立包和资源目录,并只复用稳定的通用基础设施。 - -```text -a2a-t-sample/ - src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/ - client/ - NegotiationClientMain.java - NegotiationClientFlow.java - server/ - NegotiationServerMain.java - NegotiationServerFlow.java - NegotiationAgentExecutor.java - shared/ - InformationNegotiationSchemas.java - NegotiationMetadataReader.java - NegotiationScenario.java - NegotiationScenarioLoader.java - mock/ - NegotiationMockLLMClient.java - NegotiationMockLlmInstaller.java - src/main/resources/sample/private-line-complaint-negotiation/ - scenario.json - client/client.env - server/server.env - src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/ - InformationNegotiationSchemasTest.java - NegotiationAcceptFlowTest.java - NegotiationRejectFlowTest.java - NegotiationMetadataReaderTest.java -``` - -可以复用或抽取现有 Sample 的以下能力: - -- 环境文件解析和默认路径解析。 -- AgentCard 构建、注册中心降级和直连逻辑。 -- a2a-java REST Client、Embedded HTTP Server 和事件读取。 -- 日志格式、错误类型和通用 metadata/event 映射。 - -如果抽取公共代码会明显扩大改动范围,第一版允许在新包内保留少量专用适配代码。不要让 Negotiation-T 流程依赖 `subscribe_incident` 包中的业务类名、Notification-T 常量或无限 Artifact 循环。 - -需要调整 `a2a-t-sample/pom.xml` 的 Java argfile 生成逻辑,为新入口生成独立文件,例如: - -```text -target/negotiation-server.javaargs.txt -target/negotiation-client.javaargs.txt -``` - -## 9. Mock LLM 设计 - -本机无法访问目标 Qwen 网络,因此 Mock 模式是开发和自动化测试的必备能力。 - -现有 `SampleMockLLMClient` 按调用次数依次返回场景识别、槽位提取和语义校验结果,不适合 Negotiation-T。新 Mock 必须根据 `jsonSchema` 和消息内容分派响应,不能依赖全局调用序号。 - -建议分派规则: - -1. Schema 顶层属性包含 `semantic_verdict`:返回 Negotiation 语义校验响应。 -2. Schema 顶层属性包含 `items` 和 `relationship`:返回信息协商 Propose 内容提取响应。 -3. Schema 顶层属性包含 `conclusion` 和 `items`:根据输入文本返回 Accept 或 Reject 内容提取响应。 -4. 语义校验阶段根据待校验 Prompt 中的阶段和结论,向 `params` 写入与 Propose、Accept 或 Reject 场景 Schema 一致的业务参数。生成阶段仍返回 SDK 内部要求的 `items`、`relationship`、`conclusion` 结构。 -5. 无法识别的 Schema 直接抛出清晰异常,避免误返回成功数据。 - -典型 Mock 响应: - -```json -{ - "items": [ - { "name": "接入端口名称", "value": "物理端口或逻辑端口名称" }, - { "name": "投诉分类", "value": "专线中断或专线质差" } - ], - "relationship": null -} -``` - -```json -{ - "conclusion": "Accept", - "items": [ - { "name": "接入端口名称", "value": "P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)" }, - { "name": "投诉分类", "value": "专线质差" } - ] -} -``` - -```json -{ - "semantic_verdict": true, - "negotiation_type": "information", - "errors": [], - "params": { - "rejection_reason": "当前账号没有资源系统查询权限" - } -} -``` - -Mock 需要覆盖生成和校验两类 LLM 调用,并分别在 Client、Server 进程中可独立工作。 - -## 10. Qwen3-32B 模式 - -目标网络手工验证时,使用仓库已提供的 `src/main/resources/sample/private-line-complaint-negotiation/qwen.env`。其配置与 `C:\cnafan\git\design\Qwen3-32B模型调用指导.md` 一致,并额外通过 `A2AT_LLM_DISABLE_SYSTEM_PROXY=true` 绕过黄区系统代理: - -```properties -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=qwen3-32b -A2AT_LLM_BASE_URL=http://71.77.65.40:8008/v1 -A2AT_LLM_API_KEY=not-needed -A2AT_LLM_TEMPERATURE=0 -A2AT_LLM_TIMEOUT_SECONDS=60 -A2AT_LLM_DISABLE_SYSTEM_PROXY=true -``` - -第一版不要在 Sample 中加入与 Qwen 强绑定的业务分支。先使用 SDK 当前 OpenAI-compatible Client 实测。如果模型返回合法 JSON,env 修改即可。若出现思考内容污染 JSON,或服务端明确要求 `chat_template_kwargs.enable_thinking=false`,再对 LLM Client 做最小兼容调整,并单独记录验证结果。当前网络无法直连不影响 Sample 开发完成,但最终验证文档必须标注真实模型验证所在环境和结果。 - -## 11. 测试要求 - -### 11.1 单元测试 - -至少覆盖: - -- Propose、Accept、Reject 三个场景 Schema 的字段、required 和 enum 约束。 -- metadata 中规范 Negotiation-T URI 和 `templateUri` 的读取。 -- metadata 缺失、错误模板 URI、错误扩展 URI 时快速失败。 -- Propose 校验结果重建上下文,数值字段通过 `Number.intValue()` 转换。 -- Accept 分支调用 Accept 生成和校验 API,不调用 Reject API。 -- Reject 分支调用 Reject 生成和校验 API,不调用 Accept API。 -- Accept/Reject 回复沿用 Propose 的 `id`、`round`、`maxRounds`。 -- Mock 根据 Schema/消息内容分派,不依赖调用顺序。 - -### 11.2 6 API 集成测试 - -增加两个不依赖 HTTP 端口的测试,使用真实 `A2ATClient`、`A2ATServer` 门面和 Mock LLM: - -- `acceptFlowCoversFourApis` -- `rejectFlowCoversFourApis` - -两者合并覆盖 6 个唯一 API。断言不能只检查“不抛异常”,还应检查: - -- `MetadataContent.templateUri()` 和 `extensionUri()`。 -- Prompt 包含同一协商上下文。 -- `FilledParamData.data()` 包含上下文字段和对应阶段的场景业务字段。 -- Propose 提取接入端口要求和投诉分类约束。 -- Accept 提取实际接入端口和枚举范围内的投诉分类;Reject 提取明确的 `rejection_reason`。 - -现有 `ValidateAndFillingDataPipelineTest` 中使用脚本 LLM 的测试主要验证编排和字段合并,脚本响应本身不会被真实模型约束。因此,本 Sample 的 Mock 测试不能替代目标网络的 Qwen 手工验证。 - -### 11.3 本地 HTTP E2E - -Mock 模式完成两次运行: - -1. Server 设置 `A2AT_SAMPLE_NEGOTIATION_DECISION=accept`,Client 运行成功。 -2. Server 设置 `A2AT_SAMPLE_NEGOTIATION_DECISION=reject`,Client 运行成功。 - -每次服务端只返回一个结束 Artifact,随后任务进入 completed 状态,禁止沿用订阅示例的无限循环。 - -## 12. 构建和验收命令 - -开发完成后至少执行: - -```bash -mvn -pl a2a-t-sample -am test -mvn -pl a2a-t-sample -am spotless:check -mvn -pl a2a-t-sample -am -DskipTests package -``` - -本地 Mock E2E 的目标启动形式: - -```powershell -$env:A2AT_SAMPLE_NEGOTIATION_DECISION="accept" -java @a2a-t-sample/target/negotiation-server.javaargs.txt -``` - -另开终端: - -```powershell -java @a2a-t-sample/target/negotiation-client.javaargs.txt -``` - -Reject 分支需重启 Server: - -```powershell -$env:A2AT_SAMPLE_NEGOTIATION_DECISION="reject" -java @a2a-t-sample/target/negotiation-server.javaargs.txt -``` - -### 12.1 目标网络 Qwen 批量验证与过程日志 - -进入可访问 Qwen3-32B 的目标网络后,Code Agent 应自行执行以下命令,不要修改默认 Mock 的 `client.env`、`server.env`。第三个 Java 参数指定过程日志;该日志每次 SDK 调用结束即追加一行,评测中断时已完成的记录仍可用于定位。 - -```powershell -mvn -pl a2a-t-sample -am -DskipTests package - -java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` - a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ` - a2a-t-sample/target/negotiation-qwen-report.json ` - a2a-t-sample/target/negotiation-qwen-process.jsonl -``` - -验收时读取 `negotiation-qwen-report.json` 和 `negotiation-qwen-process.jsonl`: - -- 每条评测用例执行完整链路:Propose 生成与校验、客户端补充、Accept/Reject 生成与校验;生成 Prompt 直接传给配对校验接口。 -- 客户端补充信息是中间业务数据,不调用这 6 个接口中的校验接口。 -- 汇总报告分别包含 Propose 和 Ending 的输入、生成 Prompt、期望值、实际提取值、阶段状态、最终 `passed` 及耗时。 -- 过程日志按 `run_id`、`case_id` 关联四个 SDK 调用阶段,可沿前一阶段输出追踪后一阶段校验入参。 -- 两阶段均成功但汇总结果失败时,比较 `expected` 与 `actual`;生成 Prompt 的语义质量单独人工检查。 - -过程日志不记录 API Key。完成后在验证文档中记录模型、执行时间、报告路径、通过率以及人工抽检结论。详细字段说明见 `NEGOTIATION_QWEN_EVALUATION.zh-CN.md`。 - -## 13. 完成标准 - -满足以下条件后,Sample 开发才算完成: - -- 原 `subscribe_incident` Sample 和测试不受影响。 -- 新 Sample 不触发任何 Task-T API。 -- Accept 和 Reject 两条 Mock HTTP 链路均可重复运行。 -- 6 个目标 API 均由真实 SDK 门面调用并有明确断言。 -- Propose 和结束消息通过 A2A metadata 传输,未手工拼装 Prompt。 -- 三套场景业务 Schema 在校验、Mock 和断言中保持一致;生成阶段继续使用 SDK 内部内容结构。 -- 同一链路的协商 ID 和轮次保持一致。 -- 无真实模型配置时自动或明确进入 Mock 模式;不会误请求公网模型。 -- Qwen 配置只通过 env 切换;目标网络验证结果留给后续验证文档记录。 -- Maven 测试、Spotless 检查和打包通过。 - -## 14. Code Agent 推荐执行顺序 - -1. 同步代码并读取仓库根目录 `AGENTS.md`。 -2. 复核 6 个 API 的最新签名、Template URI 和 `MetadataContent` 结构。 -3. 新增 Propose、Accept、Reject 场景业务 Schema 工厂及测试。 -4. 新增场景资源、metadata 读取器及测试。 -5. 新增按 Schema 分派的 Negotiation Mock LLM 及测试。 -6. 先实现不含 HTTP 的 Accept/Reject 流程测试,确认 6 个 API 行为。 -7. 接入真实 a2a-java Message/Artifact 传输。 -8. 增加两个 Main 入口、env 模板和 Maven java argfile。 -9. 分别运行 Accept、Reject 的本地 Mock E2E。 -10. 更新 `a2a-t-sample/README.zh-CN.md`,写明入口、分支选择、Mock 和 Qwen 配置方法。 -11. 在目标网络先执行 20 用例 `smoke` 集,再执行 100 用例 `full` 集的 Qwen 批量验证,检查汇总报告和 JSONL 过程日志;本机无法访问目标网络时,将此步骤记录为待目标网络执行。 -12. 运行全部验收命令,检查工作区只包含本任务相关改动。 - -如后续需要提交 PR,提交必须使用 `git commit -s`,并在推送前检查 PR 范围内每个提交均带有正确的 `Signed-off-by`。 diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 592d7ead..289ce925 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -2,14 +2,11 @@ `a2a-t-sample` 是 A2A-T Java SDK 的示例模块,包含客户端与服务端两个可直接运行的入口。 -## 订阅事件 Sample - -该示例基于 `a2a-java v1.0.0.Beta1` 运行真实的 A2A `HTTP+JSON/REST` 链路: - +当前示例基于 `a2a-java v1.0.0.Beta1` 运行真实的 A2A `HTTP+JSON/REST` 链路: - `a2a-t-client` 仅用于生成结构化 prompt - `a2a-t-server` 仅用于校验结构化 prompt -### 入口类 +## 入口类 - 客户端:`net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain` - 服务端:`net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain` @@ -17,7 +14,7 @@ - 结构化数据协商样例(fromData 3x3):`net.openan.a2at.sample.negotiation.fromdata.FromDataNegotiationSample` - 自然语言协商样例(fromText 3x3):`net.openan.a2at.sample.negotiation.fromtext.FromTextNegotiationSample` -### 模块内资源 +## 模块内资源 - 客户端环境模板:`sample/subscribe_incident/client/client.env` - 服务端环境模板:`sample/subscribe_incident/server/server.env` @@ -95,7 +92,7 @@ java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env 复用 `shared/NegotiationSampleSupport` 公共辅助(SessionId、模板 URI 常量、summary),fromData 和 fromText 差异仅在输入构造(record vs 自然语言文本)。 -### 客户端启动 +## 客户端启动 1. 修改仓库根目录下的 `client.env`,补充可用的 `A2AT_LLM_API_KEY` 2. 如需修改默认请求内容,可编辑 `sample/subscribe_incident/client/scenario.json` @@ -107,7 +104,7 @@ java @a2a-t-sample/target/client.javaargs.txt 如果不传参数,`ClientSampleMain` 会回退到包内的 `sample/subscribe_incident/client/client.env`。 -### 服务端启动 +## 服务端启动 1. 修改仓库根目录下的 `server.env`,补充可用的 `A2AT_LLM_API_KEY` 2. 启动服务端: @@ -118,80 +115,27 @@ java @a2a-t-sample/target/server.javaargs.txt 如果不传参数,`ServerSampleMain` 会回退到包内的 `sample/subscribe_incident/server/server.env`。 -## 传输专线投诉信息协商 Sample - -该示例只覆盖 Negotiation-T:Propose 的自然语言生成与校验/提参,以及 Accept 或 Reject 的自然语言生成与校验/提参。生成结果通过 A2A Message 和 Artifact metadata 传输。 - -相关文档: - -- [六个接口的逐项调用示例](NEGOTIATION_API_EXAMPLES.zh-CN.md) -- [Sample 开发与设计说明](NEGOTIATION_SAMPLE_DEVELOPMENT.zh-CN.md) -- [Qwen3-32B 的 20/100 用例批量验证](NEGOTIATION_QWEN_EVALUATION.zh-CN.md) - -调用方 schema 根据专线投诉场景分别定义:Propose 和 Accept 提取 `access_port_name`、`complaint_category`,Reject 提取 `rejection_reason`。`items`、`relationship`、`conclusion` 等协商模板结构由 SDK 内部处理。 - -默认使用本地 schema 分派 Mock LLM,无需访问模型服务。 - -### 构建 - -在仓库根目录执行: - -```powershell -mvn -pl a2a-t-sample -am test -mvn -pl a2a-t-sample -am -DskipTests package -``` - -### 本地运行 - -第一个终端启动 Accept 分支服务端: +## Git Bash 本地调试 -```powershell -$env:A2AT_SAMPLE_NEGOTIATION_DECISION = "accept" -java @a2a-t-sample/target/negotiation-server.javaargs.txt -``` - -第二个终端启动客户端: - -```powershell -java @a2a-t-sample/target/negotiation-client.javaargs.txt -``` +先编译打包: -Reject 分支将服务端环境变量改为 `reject` 后重启,再运行客户端。服务端默认端口为 `8010`。 - -目标网络使用 Qwen3-32B 时,将 Client 和 Server 两个 env 中的 LLM 配置替换为: - -```properties -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=qwen3-32b -A2AT_LLM_BASE_URL=http://71.77.65.40:8008/v1 -A2AT_LLM_API_KEY=not-needed -A2AT_LLM_TEMPERATURE=0 -A2AT_LLM_TIMEOUT_SECONDS=60 -A2AT_LLM_DISABLE_SYSTEM_PROXY=true +```bash +mvn "-Dmaven.repo.local=.mvn/repository" -pl a2a-t-sample -am -DskipTests package ``` -仓库已提供可直接用于东莞绿区的 `sample/private-line-complaint-negotiation/qwen.env`。构建后,可显式将该文件作为两个入口的第一个参数;无需修改默认 Mock 配置: +启动服务端: -```powershell -$env:A2AT_SAMPLE_NEGOTIATION_DECISION = "accept" -java @a2a-t-sample/target/negotiation-server.javaargs.txt a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env -``` - -```powershell -java @a2a-t-sample/target/negotiation-client.javaargs.txt a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env +```bash +java @a2a-t-sample/target/server.javaargs.txt ``` -需要批量验证三组自然语言生成接口及对应校验/提参接口时,运行以下命令。每条用例会执行 Propose 生成与校验、客户端补充、Accept/Reject 生成与校验。默认执行 100 条完整流程并生成汇总报告和逐调用 JSONL 过程日志;将最后一个参数设为 `smoke` 可执行 20 条精简流程,或传入 `P01,A28,R21` 这类编号组合执行指定流程: +另开一个窗口启动客户端: -```powershell -java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt ` - a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/qwen.env ` - a2a-t-sample/target/negotiation-qwen-report.json ` - a2a-t-sample/target/negotiation-qwen-process.jsonl +```bash +java @a2a-t-sample/target/client.javaargs.txt ``` -完整执行前提和日志判读规则见 [Qwen3-32B 的 100 用例批量验证](NEGOTIATION_QWEN_EVALUATION.zh-CN.md)。 -通用协商样例(无需启动服务端,单进程,需指定含 LLM key 的 `.env`): +协商样例(无需启动服务端,单进程,需指定含 LLM key 的 .env): ```bash java @a2a-t-sample/target/negotiation.javaargs.txt /path/to/.env From bbd21debb123e881659c72d7740210447a78fb5b Mon Sep 17 00:00:00 2001 From: qinyanhao01 Date: Sat, 22 Aug 2026 19:03:14 +0800 Subject: [PATCH 125/295] fix(sample): adapt task-t to refactored sdk Signed-off-by: qinyanhao01 --- .../sample/task_t/TaskTAccuracyEvaluator.java | 2 +- .../a2at/sample/task_t/TaskTDemoMain.java | 111 ++++++++++++++++-- .../TaskTPrivateLineComplaintSamples.java | 78 ++++++++++-- 3 files changed, 174 insertions(+), 17 deletions(-) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java index 65f2a5db..8a5e8398 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java @@ -9,7 +9,7 @@ * Field-level and sample-level accuracy scoring for the {@code Task-T} demo. * *

        Each {@link SampleScore} scores one sample: every expected ground-truth field is compared with the corresponding - * value extracted by {@code A2ATServer#validateAndFillingTaskData}. {@link Summary} aggregates the scores per client + * value extracted by {@code A2ATServer#validateTaskPromptAndDataFilling}. {@link Summary} aggregates the scores per client * API both as a field hit rate (matched expected fields over total expected fields) and as a sample pass rate * (samples whose expected fields all hit over total samples). */ diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java index 8d7328d0..bf0bf904 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java @@ -13,9 +13,10 @@ import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.StandardTemplates; -import net.openan.a2at.sdk.core.validation.TemplateUri; import net.openan.a2at.sdk.server.A2ATServer; /** @@ -27,12 +28,16 @@ * *

          *
        1. {@code A2ATClient#generateTaskPromptFromText} fed with natural language;
        2. - *
        3. {@code A2ATClient#generateTaskPromptFromDataWithSchema} fed with a slot-value map plus a semantics schema. + *
        4. {@code A2ATClient#generateTaskPromptFromDataWithSchema} fed with a slot-value map plus a semantics + * schema;
        5. + *
        6. {@code generateTaskPromptFromText} fed with key-slot-missing content, expected to be rejected by the + * server-side semantic validation.
        7. *
        * - *

        Each generated prompt is passed to {@code A2ATServer#validateAndFillingTaskData}; the extracted parameters are - * compared against the sample ground truth and the results are aggregated into a per-case field accuracy and sample - * pass rate. + *

        Each generated prompt is passed to {@code A2ATServer#validateTaskPromptAndDataFilling}; for cases one and two the + * extracted parameters are compared against the sample ground truth and the results are aggregated into a per-case + * field accuracy and sample pass rate, while case three reports a rejection interception rate over the negative + * samples. * *

        Run with {@code java ... TaskTDemoMain [env-file-path]}. The env file resolves as follows: an explicit first * argument wins; otherwise a {@code client.env} in the working directory (repo root) that carries the required LLM @@ -75,10 +80,15 @@ public static void main(String[] args) { System.out.println("══════════════════════════════════════════════════════"); List dataScores = runDataWithSchemaCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + RejectionSummary rejectionSummary = runRejectionCase(client, server); + println(); System.out.println("══════════════════════════════════════════════════════"); printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromText", textScores)); printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromDataWithSchema", dataScores)); + printRejectionSummary(rejectionSummary); } private static List runTextCase(A2ATClient client, A2ATServer server) { @@ -134,13 +144,82 @@ private static List runDataWithSchemaCase(A2 return scores; } + /** + * Runs the rejection case over {@link TaskTPrivateLineComplaintSamples#rejectionSamples()}: each sample + * deliberately omits one or more key slots (or carries an invalid slot value), so the server-side semantic + * validation is expected to fail with {@code validation_semantic_rejected}. The sample set mixes a text variant + * (fed to {@code generateTaskPromptFromText}) and a data variant (fed to + * {@code generateTaskPromptFromDataWithSchema}); a sample is counted as "server-rejected" only when the + * validation throws {@link ContentValidationException} — the structured slot errors are printed per sample — while + * a failure already at prompt generation counts as a client-side block. The run aggregates an interception rate. + */ + private static RejectionSummary runRejectionCase(A2ATClient client, A2ATServer server) { + println("========== 用例三:缺少关键槽位拒绝用例(期望 rejected) =========="); + List samples = TaskTPrivateLineComplaintSamples.rejectionSamples(); + int serverRejected = 0; + int clientBlocked = 0; + int unexpectedlyPassed = 0; + for (int i = 0; i < samples.size(); i++) { + TaskTRejectionSample sample = samples.get(i); + printSampleHeader("用例三", i, samples.size(), sample.name()); + try { + MetadataContent metadata; + if (sample.data() != null) { + println("[输入] 数据(data,客户端 key,缺关键槽位):"); + println(pretty(sample.data())); + println("[输入] 语义schema(schema):"); + println(pretty(sample.semanticsSchema())); + println(); + println("[生成] generateTaskPromptFromDataWithSchema:"); + metadata = client.generateTaskPromptFromDataWithSchema( + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + } else { + println("[输入] 自然语言文本 (缺少关键槽位):"); + println(sample.text()); + println(); + println("[生成] generateTaskPromptFromText:"); + metadata = client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + } + System.out.println(" templateUri : " + metadata.templateUri()); + println(); + Map extracted = server + .validateTaskPromptAndDataFilling( + metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT) + .data(); + unexpectedlyPassed++; + println("[意外通过] 应被拒绝却校验通过,提取参数:"); + println(pretty(extracted)); + } catch (ContentValidationException exception) { + serverRejected++; + System.out.println("[正确拒绝] [" + exception.getCode() + "] " + exception.getMessage()); + printValidationErrors(exception); + } catch (PromptGenerationException exception) { + clientBlocked++; + System.out.println("[生成阶段拦截] 客户端生成即失败,[" + exception.getCode() + "] " + exception.getMessage()); + } + println(); + } + return new RejectionSummary(samples.size(), serverRejected, clientBlocked, unexpectedlyPassed); + } + + private static void printValidationErrors(ContentValidationException exception) { + List errors = exception.errors(); + if (errors == null || errors.isEmpty()) { + println(" (无结构化错误明细)"); + return; + } + for (SlotValidationError error : errors) { + println(" slot=" + error.slotName() + " code=" + error.code() + " message=" + error.message()); + } + } + private static TaskTAccuracyEvaluator.SampleScore validateAndScore( A2ATServer server, TaskTSample sample, MetadataContent metadata, String caseLabel) { try { Map extracted = - server.validateAndFillingTaskData(metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT) + server.validateTaskPromptAndDataFilling(metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT) .data(); - println("[服务端] " + caseLabel + " validateAndFillingTaskData 通过,提取参数:"); + println("[服务端] " + caseLabel + " validateTaskPromptAndDataFilling 通过,提取参数:"); println(pretty(extracted)); List fields = TaskTAccuracyEvaluator.scoreFields(sample, extracted); printFieldScores(fields); @@ -185,6 +264,22 @@ private static void printSummary(TaskTAccuracyEvaluator.Summary summary) { + " 样本通过率: " + percent(summary.samplePassRatePercent())); } + private static void printRejectionSummary(RejectionSummary summary) { + System.out.println("──────────── 汇总: 缺少关键槽位拒绝用例 ────────────"); + println(" 样本数: " + summary.total() + + " 服务端拒绝: " + summary.serverRejected() + + " 生成阶段拦截: " + summary.clientBlocked() + + " 意外通过: " + summary.unexpectedlyPassed()); + println(" 拦截率: " + percent( + (summary.serverRejected() + summary.clientBlocked()) * 100.0 / summary.total())); + } + + /** + * Aggregated result of the rejection case over the key-slot-missing negative samples: how many were intercepted + * (either server-side semantic rejection or client-side generation failure) vs unexpectedly passed. + */ + private record RejectionSummary(int total, int serverRejected, int clientBlocked, int unexpectedlyPassed) {} + private static String percent(double value) { return String.format(Locale.ROOT, "%.2f%%", value); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java index 47a46e29..0266950b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java @@ -23,6 +23,9 @@ * capability;

      • *
      • {@link #dataWithSchemaSamples()} feed {@code A2ATClient#generateTaskPromptFromDataWithSchema} with a * structured input whose {@code data} carries English business fields — not the template slot names.
      • + *
      • {@link #rejectionSamples()} feed {@code generateTaskPromptFromText} with content that deliberately omits key + * slots; server-side semantic validation is expected to reject them — scored separately from the accuracy + * samples.
      • *
      * *

      Per design, the client-side keys and the server-side keys intentionally diverge: the client submits its @@ -41,7 +44,7 @@ * * *

      Expected values ({@link TaskTSample#expectedParams()}) are keyed by the server field names, the keys the - * accuracy evaluator actually reads out of {@code validateAndFillingTaskData}; they hold canonical key facts that + * accuracy evaluator actually reads out of {@code validateTaskPromptAndDataFilling}; they hold canonical key facts that * always appear in the input, and a field hits when the extracted value is equal to or contains the expected value * after whitespace-and-case normalization. */ @@ -81,7 +84,7 @@ public final class TaskTPrivateLineComplaintSamples { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final List SAMPLES = loadSamples(); + private static final Loaded LOADED = load(); private TaskTPrivateLineComplaintSamples() { } @@ -92,7 +95,7 @@ private TaskTPrivateLineComplaintSamples() { * @return immutable text sample list */ public static List textSamples() { - return SAMPLES.stream().filter(sample -> sample.text() != null).toList(); + return LOADED.samples().stream().filter(sample -> sample.text() != null).toList(); } /** @@ -105,10 +108,26 @@ public static List textSamples() { * @return immutable data sample list */ public static List dataWithSchemaSamples() { - return SAMPLES.stream().filter(sample -> sample.data() != null).toList(); + return LOADED.samples().stream().filter(sample -> sample.data() != null).toList(); } - private static List loadSamples() { + /** + * Rejection samples that deliberately omit one or more key slots. + * + *

      Two variants mirror the two client APIs under test: {@code text} samples are colloquial natural-language + * complaints missing key facts (access port, complaint scenario, occurrence time, event serial, ...), while + * {@code data} samples are structured inputs under the client field names that omit required fields or carry an + * invalid slot value (e.g. a complaint category outside the allowed enum). The server-side semantic validation is + * expected to reject such content with {@code validation_semantic_rejected}; these samples are scored separately + * from the accuracy samples and are never fed into the field-accuracy evaluation. + * + * @return immutable rejection sample list + */ + public static List rejectionSamples() { + return LOADED.rejections(); + } + + private static Loaded load() { try (InputStream in = TaskTPrivateLineComplaintSamples.class.getResourceAsStream("/" + SAMPLE_RESOURCE)) { if (in == null) { throw new IllegalStateException("sample resource not found on classpath: " + SAMPLE_RESOURCE); @@ -129,12 +148,28 @@ private static List loadSamples() { name, null, toObjectMap(data), semanticsSchema, expected, validationSchema)); } } - return List.copyOf(samples); + List rejections = new ArrayList<>(); + JsonNode rejectionNodes = root.get("rejectionSamples"); + if (rejectionNodes != null) { + for (JsonNode node : rejectionNodes) { + String name = node.get("name").asText(); + JsonNode data = node.get("data"); + if (data == null) { + rejections.add(new TaskTRejectionSample(name, node.get("text").asText(), null, null, validationSchema)); + } else { + rejections.add(new TaskTRejectionSample(name, null, toObjectMap(data), semanticsSchema, validationSchema)); + } + } + } + return new Loaded(List.copyOf(samples), List.copyOf(rejections)); } catch (IOException error) { throw new IllegalStateException("failed to load task-t samples from " + SAMPLE_RESOURCE, error); } } + /** Loaded evaluation sample set split into accuracy samples and rejection samples. */ + private record Loaded(List samples, List rejections) {} + private static Map toObjectMap(JsonNode node) { return OBJECT_MAPPER.convertValue(node, new TypeReference>() {}); } @@ -154,8 +189,8 @@ private static Map toStringMap(JsonNode node) { * @param semanticsSchema client-side field-semantics schema, keyed by the client field names; may be {@code null} * when {@code data} is {@code null} * @param expectedParams ground truth business values, keyed by the server field names (the keys read out of - * {@code validateAndFillingTaskData}); these are the fields actually scored - * @param validationSchema caller-provided JSON parameter schema passed to {@code validateAndFillingTaskData}, + * {@code validateTaskPromptAndDataFilling}); these are the fields actually scored + * @param validationSchema caller-provided JSON parameter schema passed to {@code validateTaskPromptAndDataFilling}, * keyed by the server field names * @since 2026-08 */ @@ -165,4 +200,31 @@ record TaskTSample( Map data, Map semanticsSchema, Map expectedParams, + Map validationSchema) {} + +/** + * One negative sample for the rejection check: content that deliberately omits one or more key slots (or carries an + * invalid slot value) and is expected to be rejected by the server-side semantic validation. Two variants exist, + * mirroring the two client APIs under test: + * + *

        + *
      • {@code text} variant feeds {@code A2ATClient#generateTaskPromptFromText} with colloquial natural language;
      • + *
      • {@code data} variant feeds {@code A2ATClient#generateTaskPromptFromDataWithSchema} with structured input keyed + * by the client field names plus the client-side semantics schema.
      • + *
      + * + * @param name sample identifier shown in the rejection report + * @param text natural-language input for the FromText variant; {@code null} when the sample only targets the data case + * @param data structured business-field input for the FromDataWithSchema variant, keyed by the client field + * names; {@code null} when the sample only targets the text case + * @param semanticsSchema client-side field-semantics schema, keyed by the client field names; may be {@code null} + * when {@code data} is {@code null} + * @param validationSchema caller-provided JSON parameter schema passed to {@code validateTaskPromptAndDataFilling} + * @since 2026-08 + */ +record TaskTRejectionSample( + String name, + String text, + Map data, + Map semanticsSchema, Map validationSchema) {} \ No newline at end of file From 874be80e1c4a6267e989c2917d5324f608e44b28 Mon Sep 17 00:00:00 2001 From: wangruiyang8 Date: Sat, 22 Aug 2026 19:27:18 +0800 Subject: [PATCH 126/295] feat(sample): generate service-recovery javaargs.txt for startup consistency Add service-recovery-client.javaargs.txt and service-recovery-server.javaargs.txt to the pom, matching the pattern used by negotiation and other samples. Document the new startup method in README.zh-CN.md. Also fix the validateAndFillingNotificationData rename to validateNotificationPromptAndDataFilling that broke service-recovery compilation. Signed-off-by: wangruiyang8 --- a2a-t-sample/README.zh-CN.md | 38 ++++++++++++++++--- a2a-t-sample/pom.xml | 8 ++++ .../runtime/DefaultSampleServerRuntime.java | 2 +- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 289ce925..0a6ef076 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -8,15 +8,20 @@ ## 入口类 -- 客户端:`net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain` -- 服务端:`net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain` +- 服务恢复样例客户端:`net.openan.a2at.sample.service_recovery.client.ClientSampleMain` +- 服务恢复样例服务端:`net.openan.a2at.sample.service_recovery.server.ServerSampleMain` +- 订阅事件样例客户端:`net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain` +- 订阅事件样例服务端:`net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain` - 协商端到端样例(4报文):`net.openan.a2at.sample.negotiation.NegotiationDemoApp` - 结构化数据协商样例(fromData 3x3):`net.openan.a2at.sample.negotiation.fromdata.FromDataNegotiationSample` - 自然语言协商样例(fromText 3x3):`net.openan.a2at.sample.negotiation.fromtext.FromTextNegotiationSample` ## 模块内资源 -- 客户端环境模板:`sample/subscribe_incident/client/client.env` +- 服务恢复样例客户端环境模板:`sample/service-recovery/client/client.env` +- 服务恢复样例服务端环境模板:`sample/service-recovery/server/server.env` +- 服务恢复样例客户端场景输入:`sample/service-recovery/client/input-with-text.txt`、`sample/service-recovery/client/input-with-data.json`、`sample/service-recovery/client/schema.json` +- 订阅事件客户端环境模板:`sample/subscribe_incident/client/client.env` - 服务端环境模板:`sample/subscribe_incident/server/server.env` - 客户端场景输入:`sample/subscribe_incident/client/scenario.json` - 协商样例环境模板:`sample/negotiation/negotiation.env` @@ -66,6 +71,29 @@ java @a2a-t-sample/target/negotiation.javaargs.txt --no-stream /path/to/.env 如果不传参数,`NegotiationDemoApp` 会回退到包内的 `sample/negotiation/negotiation.env`(该模板 key 为空,仅用于占位)。Windows 控制台如遇中文乱码,先执行 `chcp 65001`。 +## 服务恢复(Service Recovery)样例 + +验证 `generateNotificationPromptFromText` / `generateNotificationPromptFromDataWithSchema` / `validateAndFillingNotificationData` 三个 API。客户端在一个进程内跑两轮订阅:轮① 自然语言输入,轮② 结构化输入 + 数据 schema,经服务端校验并建立真实订阅。每个订阅任务上报 5 个 notification 后自动完结。 + +支持 mock LLM 降级:未填 `A2AT_LLM_API_KEY` 时自动使用确定性 mock 响应,无需外部依赖即可完整跑通。 + +### 服务恢复样例启动 + +1. 修改仓库根目录下的 `client.env`,补充可用的 `A2AT_LLM_API_KEY`(可选,缺省时自动使用 mock LLM) +2. 启动服务端: + +```bash +java @a2a-t-sample/target/service-recovery-server.javaargs.txt +``` + +3. 另开一个窗口启动客户端: + +```bash +java @a2a-t-sample/target/service-recovery-client.javaargs.txt +``` + +如果不传参数,会回退到包内的 `sample/service-recovery/{client,server}/{client,server}.env`。 + ## 结构化数据协商样例(fromData) 验证 `generateNegotiationProposePromptFromData` / `generateNegotiationAcceptPromptFromData` / `generateNegotiationRejectPromptFromData` 三个 API(结构化输入,规则渲染)。覆盖 3 种协商类型 × 3 种阶段 = 9 个用例: @@ -126,13 +154,13 @@ mvn "-Dmaven.repo.local=.mvn/repository" -pl a2a-t-sample -am -DskipTests packag 启动服务端: ```bash -java @a2a-t-sample/target/server.javaargs.txt +java @a2a-t-sample/target/service-recovery-server.javaargs.txt ``` 另开一个窗口启动客户端: ```bash -java @a2a-t-sample/target/client.javaargs.txt +java @a2a-t-sample/target/service-recovery-client.javaargs.txt ``` 协商样例(无需启动服务端,单进程,需指定含 LLM key 的 .env): diff --git a/a2a-t-sample/pom.xml b/a2a-t-sample/pom.xml index 98f90217..21715446 100644 --- a/a2a-t-sample/pom.xml +++ b/a2a-t-sample/pom.xml @@ -134,6 +134,14 @@ net.openan.a2at.sample.negotiation.fromdata.FromDataNegotiationSample -cp ${sample.jar.path}${path.separator}${sample.runtime.classpath} net.openan.a2at.sample.negotiation.fromtext.FromTextNegotiationSample + + -cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.service_recovery.server.ServerSampleMain + + -cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.service_recovery.client.ClientSampleMain diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java index fa5a6f4d..814d4d32 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java @@ -99,7 +99,7 @@ public NotificationPromptValidator buildNotificationValidator(Path envPath) { TemplateUri templateUri = TemplateUri.parse(ServiceRecoverySampleInputs.TEMPLATE_URI) .orElseThrow(() -> new IllegalArgumentException( "Malformed template URI: " + ServiceRecoverySampleInputs.TEMPLATE_URI)); - return promptText -> server.validateAndFillingNotificationData( + return promptText -> server.validateNotificationPromptAndDataFilling( promptText, ServiceRecoverySampleInputs.validationParamSchema(), templateUri); From f88588575e58f543f3f84a5cd72cdfe246468395 Mon Sep 17 00:00:00 2001 From: qinyanhao01 Date: Sat, 22 Aug 2026 21:05:05 +0800 Subject: [PATCH 127/295] feat(a2a-t-sample): split task-t cases by args Signed-off-by: qinyanhao01 --- a2a-t-sample/README.zh-CN.md | 5 + a2a-t-sample/pom.xml | 4 + .../a2at/sample/task_t/TaskTDemoMain.java | 75 ++++++++++++--- .../private-line-complaint-samples.json | 92 +++++++++++++++++++ a2a-t-sample/task-t-test-cases.md | 34 ++++++- 5 files changed, 193 insertions(+), 17 deletions(-) diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 289ce925..d3a26282 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -140,3 +140,8 @@ java @a2a-t-sample/target/client.javaargs.txt ```bash java @a2a-t-sample/target/negotiation.javaargs.txt /path/to/.env ``` + + Task-T 样例(无需启动服务端,单进程,需指定含 LLM key 的 .env) + +```bash +java @a2a-t-sample/target/taskt.javaargs.txt /path/to/.env \ No newline at end of file diff --git a/a2a-t-sample/pom.xml b/a2a-t-sample/pom.xml index c9c5251b..c9d65fcd 100644 --- a/a2a-t-sample/pom.xml +++ b/a2a-t-sample/pom.xml @@ -141,6 +141,10 @@ net.openan.a2at.sample.negotiation.fromdata.FromDataNegotiationSample -cp ${sample.jar.path}${path.separator}${sample.runtime.classpath} net.openan.a2at.sample.negotiation.fromtext.FromTextNegotiationSample + + -cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.task_t.TaskTDemoMain diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java index bf0bf904..2b9dbfc9 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java @@ -39,7 +39,7 @@ * field accuracy and sample pass rate, while case three reports a rejection interception rate over the negative * samples. * - *

      Run with {@code java ... TaskTDemoMain [env-file-path]}. The env file resolves as follows: an explicit first + *

      Run with {@code java ... TaskTDemoMain [env-file-path] [case]}. The env file resolves as follows: an explicit first * argument wins; otherwise a {@code client.env} in the working directory (repo root) that carries the required LLM * keys; otherwise the bundled sample template {@code a2a-t-sample/src/main/resources/sample/task_t/client.env}. * A working-directory {@code client.env} is only honored when it defines non-blank {@code A2AT_LLM_PROVIDER}, @@ -47,6 +47,9 @@ * checkouts would otherwise shadow the Task-T sample); otherwise the bundled template is used. The env must configure * a reachable OpenAI-compatible LLM ({@code A2AT_LLM_API_KEY}, {@code A2AT_LLM_BASE_URL}, {@code A2AT_LLM_MODEL}, * {@code A2AT_LLM_PROVIDER=openai}) — the server-side semantic validation performs one LLM call per sample. + * + *

      The second optional argument selects which case to run: {@code text} (case one), {@code data} (case two), + * {@code rejection} (case three) or {@code all} (default); when a single case is selected only its summary is printed. */ public final class TaskTDemoMain { @@ -58,37 +61,79 @@ public final class TaskTDemoMain { private static final List REQUIRED_LLM_KEYS = List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY"); + private static final String CASE_TEXT = "text"; + + private static final String CASE_DATA = "data"; + + private static final String CASE_REJECTION = "rejection"; + + private static final String CASE_ALL = "all"; + private TaskTDemoMain() { } /** - * Runs both client-API cases against the built-in private-line complaint diagnosis samples. + * Runs one or all client-API cases against the built-in private-line complaint diagnosis samples. * - * @param args optional first argument is the {@code .env} file path + * @param args first optional argument is the {@code .env} file path; second optional argument selects the case + * ({@code text}, {@code data}, {@code rejection} or {@code all}) */ public static void main(String[] args) { Path envPath = resolveEnvPath(args); + String caseSelection = resolveCaseSelection(args); println("Task-T 准确率验证样例,env: " + envPath.toAbsolutePath() + (Files.exists(envPath) ? "" : " (不存在,请先配置)")); println("模板: " + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); + println("用例: " + caseLabel(caseSelection)); println(); A2ATClient client = new A2ATClient(envPath); A2ATServer server = new A2ATServer(envPath); - List textScores = runTextCase(client, server); - println(); - System.out.println("══════════════════════════════════════════════════════"); - List dataScores = runDataWithSchemaCase(client, server); + if (CASE_TEXT.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { + List textScores = runTextCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromText", textScores)); + println(); + } + if (CASE_DATA.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { + List dataScores = runDataWithSchemaCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromDataWithSchema", dataScores)); + println(); + } + if (CASE_REJECTION.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { + RejectionSummary rejectionSummary = runRejectionCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + printRejectionSummary(rejectionSummary); + println(); + } + } - println(); - System.out.println("══════════════════════════════════════════════════════"); - RejectionSummary rejectionSummary = runRejectionCase(client, server); + private static String resolveCaseSelection(String[] args) { + if (args.length > 1) { + String selection = args[1].toLowerCase(Locale.ROOT); + if (CASE_TEXT.equals(selection) + || CASE_DATA.equals(selection) + || CASE_REJECTION.equals(selection) + || CASE_ALL.equals(selection)) { + return selection; + } + System.err.println("未知用例参数: " + args[1] + ",可选值: text | data | rejection | all,将运行全部用例"); + return CASE_ALL; + } + return CASE_ALL; + } - println(); - System.out.println("══════════════════════════════════════════════════════"); - printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromText", textScores)); - printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromDataWithSchema", dataScores)); - printRejectionSummary(rejectionSummary); + private static String caseLabel(String caseSelection) { + return switch (caseSelection) { + case CASE_TEXT -> "用例一:generateTaskPromptFromText"; + case CASE_DATA -> "用例二:generateTaskPromptFromDataWithSchema"; + case CASE_REJECTION -> "用例三:缺少关键槽位拒绝用例"; + default -> "全部(用例一 + 用例二 + 用例三)"; + }; } private static List runTextCase(A2ATClient client, A2ATServer server) { diff --git a/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json b/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json index ee8bd435..6a7c175b 100644 --- a/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json +++ b/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json @@ -286,5 +286,97 @@ "faultDetail": "时延" } } + ], + "rejectionSamples": [ + { + "name": "text-missing-access-port", + "kind": "text", + "text": "帮我创建专线投诉诊断任务,客户报的是专线质差,从2026年5月11号早上8点半开始,深圳访问广州的核心系统非常慢,时延从12ms飙到320ms,柜面和手机银行老是报连接超时,OSS流水号是event-id-20260511-09013。" + }, + { + "name": "text-missing-scenario", + "kind": "text", + "text": "创建专线业务投诉诊断,P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100) 这条链路,从2026年5月16号中午12点10分开始,深圳到上海专线访问质量明显下降,核心系统访问变慢,麻烦帮忙处理一下,流水号fault-id-1-017-20260516-11234。" + }, + { + "name": "text-minimal-no-key-fields", + "kind": "text", + "text": "专线业务好像出问题了,麻烦尽快帮忙处理一下。" + }, + { + "name": "text-missing-time", + "kind": "text", + "text": "看下 P781-惠州江北-PTN7900-7-TPA1EG24-21 这条专线,时延跳得厉害,丢包最高到15%,视频会议一直卡,分类是专线质差,流水号event-id-20260522-00812。" + }, + { + "name": "text-missing-serial", + "kind": "text", + "text": "创建专线投诉诊断,P781-南沙-PTN7900-9-TPA1EG24-31 这条专线早上9点40分起彻底不通,上海到北京核心系统全部访问不了,客户报的是专线中断。" + }, + { + "name": "text-missing-scenario-and-serial", + "kind": "text", + "text": "帮忙看下 P781-珠江新城-PTN7900-23-TPA1EG24-17 这条专线,5月11号早上8点半开始访问广州核心系统非常慢,时延从12ms飙到320ms,柜面银行老是超时,帮我诊断一下。" + }, + { + "name": "data-missing-port", + "kind": "data", + "data": { + "complaintScenario": "专线质差", + "faultStartTime": "2026-05-11T08:21:46Z", + "ticketNo": "event-id-20260511-09013", + "faultDetailText": "深圳访问广州核心交易系统响应非常慢,时延从12ms飙升至320ms,柜面和手机银行交易接口频繁报连接超时。" + } + }, + { + "name": "data-missing-scenario", + "kind": "data", + "data": { + "portName": "P781-福田中心-PTN7900-2-TPA1EG24-03", + "faultStartTime": "2026-05-16T12:10:00Z", + "ticketNo": "fault-id-1-017-20260516-11234", + "faultDetailText": "深圳前海机房至上海数据中心专线访问质量明显下降,核心系统访问变慢,业务流量下降。" + } + }, + { + "name": "data-invalid-scenario-value", + "kind": "data", + "data": { + "portName": "P781-惠州江北-PTN7900-7-TPA1EG24-21", + "complaintScenario": "专线掉线", + "faultStartTime": "2026-05-22T14:05:00Z", + "ticketNo": "event-id-20260522-00812", + "faultDetailText": "专线时延抖动明显,平均丢包率约5%、峰值达15%,视频会议频繁卡顿。" + } + }, + { + "name": "data-missing-time", + "kind": "data", + "data": { + "portName": "P781-南沙-PTN7900-9-TPA1EG24-31", + "complaintScenario": "专线中断", + "ticketNo": "event-id-20260603-02341", + "faultDetailText": "专线业务彻底中断,上海到北京核心系统全部无法访问。" + } + }, + { + "name": "data-missing-serial", + "kind": "data", + "data": { + "portName": "P781-天河体育西-PTN7900-4-TPA1EG24-11", + "complaintScenario": "专线质差", + "faultStartTime": "2026-06-08T15:20:00Z", + "faultDetailText": "专线时延忽高忽低,峰值接近500ms,视频会议直接开不了。" + } + }, + { + "name": "data-missing-port-and-scenario", + "kind": "data", + "data": { + "faultStartTime": "2026-06-12T20:10:00Z", + "ticketNo": "fault-id-9-023-20260612-04578", + "faultDetailText": "专线业务完全中断,柜面交易全部失败。" + } + } ] } \ No newline at end of file diff --git a/a2a-t-sample/task-t-test-cases.md b/a2a-t-sample/task-t-test-cases.md index 00847ac4..b13e53e2 100644 --- a/a2a-t-sample/task-t-test-cases.md +++ b/a2a-t-sample/task-t-test-cases.md @@ -49,5 +49,35 @@ > 1. 用例一(text)的时间期望取日期前缀(如 `2026-05-11`)——口语文本不含 ISO 字面量,日期是稳定事实; > 用例二(data)的时间期望取完整 ISO-8601 时间戳。 > 2. 期望值均按**服务端 key** 记分(`accessPort`/`bizScenario`/`faultTime`/`eventSerialNo`/`faultDetail`), -> 与 `validateAndFillingTaskData` 实际输出一致。 -> 3. 详情期望为各样本的关键事实关键词,命中规则较宽松(等价或互相包含);如需严谨评测可收紧期望值并扩充样本。 \ No newline at end of file +> 与 `validateTaskPromptAndDataFilling` 实际输出一致。 +> 3. 详情期望为各样本的关键事实关键词,命中规则较宽松(等价或互相包含);如需严谨评测可收紧期望值并扩充样本。 + +## 用例三:缺少关键槽位拒绝用例(期望 rejected) + +正向样本评测的是提取准确率;本组用例验证语义校验的**兜底能力**——输入内容故意缺失一个或多个关键槽位 +(端口 / 分类 / 时间 / 流水号)或携带非法取值,服务端应抛出 `validation_semantic_rejected`。与用例一/二对应, +本组同时覆盖**文本变体**(`generateTaskPromptFromText`)与**数据变体**(`generateTaskPromptFromDataWithSchema`)。 +被拦截样本数占样本总数的比例即**拦截率**(服务端语义拒绝 + 客户端生成阶段拦截均计为拦截);意外通过时打印 +实际提取参数以便排查。本组用例独立于正向样本计分,不进入字段准确率统计。 + +### 文本变体(generateTaskPromptFromText) + +| # | 样例名 | 缺失槽位 | 保留内容 | +|---|---|---|---| +| TC-13 | text-missing-access-port | 接入端口 | 分类、时间、详情、流水号 | +| TC-14 | text-missing-scenario | 投诉分类 | 端口、时间、流水号 | +| TC-15 | text-minimal-no-key-fields | 全部关键槽位 | 仅一句"专线业务好像出问题了" | +| TC-16 | text-missing-time | 问题发生时间 | 端口、分类、流水号 | +| TC-17 | text-missing-serial | OSS 流水号 | 端口、分类、时间、详情 | +| TC-18 | text-missing-scenario-and-serial | 投诉分类 + 流水号 | 端口、时间、详情 | + +### 数据变体(generateTaskPromptFromDataWithSchema,字段为客户端 key) + +| # | 样例名 | 缺失 / 非法内容 | 保留内容 | +|---|---|---|---| +| TC-19 | data-missing-port | 缺 `portName`(→ 服务端缺必填 `accessPort`) | 分类、时间、流水号、详情 | +| TC-20 | data-missing-scenario | 缺 `complaintScenario`(→ 服务端缺必填 `bizScenario`) | 端口、时间、流水号、详情 | +| TC-21 | data-invalid-scenario-value | `complaintScenario` 取值"专线掉线"(不在允许枚举) | 端口、时间、流水号、详情 | +| TC-22 | data-missing-time | 缺 `faultStartTime` | 端口、分类、流水号、详情 | +| TC-23 | data-missing-serial | 缺 `ticketNo` | 端口、分类、时间、详情 | +| TC-24 | data-missing-port-and-scenario | 缺 `portName` + `complaintScenario` | 时间、流水号、详情 | \ No newline at end of file From 4820d59ba33ee54972fba76a8c3e8b204c3bf350 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 21:33:44 +0800 Subject: [PATCH 128/295] feat(sample): add negotiation interface closed-loop evaluator with replayable JSON report Adds NegotiationEvalApp, a pure-Java evaluation entry under the negotiation sample that drives the negotiation prompt interfaces in message order for every case of the bundled eval-suite.json: 1. Task-T generation (generateTaskPromptFromDataWithSchema / fromText) 2. server validation with missing-slot detection (validateTaskPromptAndDataFilling) 3. Negotiation-T propose generation from the detected missing slots (generateNegotiationProposePromptFromData) 4. outbound propose validation (validateProposePromptAndDataFilling) 5. client fill + accept generation (generateNegotiationAcceptPromptFromData) 6. inbound accept validation with fill-value comparison (validateAcceptPromptAndDataFilling) 7. second Task-T validation deciding COMPLETED vs INPUT_REQUIRED Every intermediate artifact (generated prompts, validation verdicts, extracted parameters, per-slot errors, client fill values) is recorded per case in a JSON report that is rewritten after each case, so a single case can be replayed and audited without re-running it. The suite carries the 10 private-line-complaint cases (fromData/fromText channels, generation fail-fast and value-constraint negatives) with expected outcomes; metrics cover trigger accuracy, missing-slot exact match, propose outbound validity, fill-value match and closed-loop completion. Run: java @a2a-t-sample/target/eval.javaargs.txt [--case PLC-04] [--out eval-report.json] /path/to/.env Signed-off-by: ZhouJie6 --- a2a-t-sample/pom.xml | 4 + .../negotiation/eval/NegotiationEvalApp.java | 923 ++++++++++++++++++ .../sample/negotiation/eval/eval-suite.json | 160 +++ 3 files changed, 1087 insertions(+) create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java create mode 100644 a2a-t-sample/src/main/resources/sample/negotiation/eval/eval-suite.json diff --git a/a2a-t-sample/pom.xml b/a2a-t-sample/pom.xml index 5bbd8139..6e858385 100644 --- a/a2a-t-sample/pom.xml +++ b/a2a-t-sample/pom.xml @@ -138,6 +138,10 @@ net.openan.a2at.sample.private_line_complaint.negotiation.evaluation.Negotiation -cp ${sample.jar.path}${path.separator}${sample.runtime.classpath} net.openan.a2at.sample.negotiation.NegotiationDemoApp + + -cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.negotiation.eval.NegotiationEvalApp -cp ${sample.jar.path}${path.separator}${sample.runtime.classpath} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java new file mode 100644 index 00000000..0ad28d5c --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java @@ -0,0 +1,923 @@ +package net.openan.a2at.sample.negotiation.eval; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.function.Consumer; +import net.openan.a2at.sample.subscribe_incident.shared.mock.SampleMockLlmInstaller; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.server.A2ATServer; + +/** + * Closed-loop evaluator for the negotiation prompt interfaces of the private-line-complaint scenario. + * + *

      Each case of the bundled suite ({@code sample/negotiation/eval/eval-suite.json}) is driven through the + * negotiation interfaces in message order, exactly the way the negotiation demo wires them: + * + *

        + *
      1. Task-T generation — the client value from the Task-T extension metadata is either structured + * ({@code generateTaskPromptFromDataWithSchema}, deterministic rendering) or natural language + * ({@code generateTaskPromptFromText}, LLM slot extraction); both produce the Task-T prompt text; + *
      2. server validation — {@code validateTaskPromptAndDataFilling} extracts the parameters; the missing-slot set + * (blank slots plus semantic {@code missing_required} errors) decides whether negotiation is triggered; + *
      3. propose generation — for the detected missing slots, {@code generateNegotiationProposePromptFromData} + * renders the Negotiation-T information-propose message; + *
      4. outbound propose validation — {@code validateProposePromptAndDataFilling} checks the generated propose + * against the negotiation template before it is sent (expected to pass); + *
      5. client fill + accept — the client supplies the missing values, appends them to the Task-T prompt and renders + * the Negotiation-T accept via {@code generateNegotiationAcceptPromptFromData}; + *
      6. inbound accept validation — {@code validateAcceptPromptAndDataFilling} checks the accept message and extracts + * the supplemented values (expected to pass with the values the client sent); + *
      7. second Task-T validation — {@code validateTaskPromptAndDataFilling} re-validates the filled Task-T prompt; + * no missing slot means the negotiation completed the flow (COMPLETED), any rejection keeps it in + * INPUT_REQUIRED. + *
      + * + *

      Every intermediate artifact — the generated Task-T prompt, both negotiation messages, each validation verdict + * with extracted parameters and per-slot errors, the client's fill values — is recorded per case in a JSON report, so + * a single case can be replayed and audited without re-running it. The report is rewritten after every case, so an + * interrupted run keeps the cases already finished. + * + *

      Usage: + * + *

      {@code
      + * java @a2a-t-sample/target/eval.javaargs.txt [--out eval-report.json] [--case PLC-04] /path/to/.env
      + * }
      + * + * @since 2026-08 + */ +public final class NegotiationEvalApp { + + private static final ObjectMapper MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private NegotiationEvalApp() {} + + /** + * Entry point. + * + * @param args options ({@code --out } report file, {@code --case } repeatable case filter), then the + * {@code .env} path carrying a real LLM API key + */ + public static void main(String[] args) { + Path envPath = null; + Path outPath = Path.of("eval-report.json"); + List caseFilter = new ArrayList<>(); + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + if ("--out".equals(arg) && i + 1 < args.length) { + outPath = Path.of(args[++i]); + } else if ("--case".equals(arg) && i + 1 < args.length) { + caseFilter.add(args[++i]); + } else if (!arg.startsWith("--")) { + envPath = Path.of(arg); + } + } + if (envPath == null) { + System.err.println( + "Usage: java @a2a-t-sample/target/eval.javaargs.txt [--out eval-report.json] [--case PLC-04]" + + " /path/to/.env"); + System.exit(1); + } + + SampleMockLlmInstaller.installLlmLogger(false, "eval"); + Map suite = loadSuite(); + + Map report = new LinkedHashMap<>(); + report.put("suite", suite.get("suite")); + report.put("description", suite.get("description")); + report.put("scenario", suite.get("scenario")); + report.put("generated_at", LocalDateTime.now().format(TIMESTAMP)); + report.put("llm", llmInfo(envPath)); + report.put("case_filter", caseFilter); + + List> cases = new ArrayList<>(); + report.put("cases", cases); + int index = 0; + int total = countCases(suite, caseFilter); + for (Map testCase : cases(suite)) { + String caseId = String.valueOf(testCase.get("id")); + if (!caseFilter.isEmpty() && !caseFilter.contains(caseId)) { + continue; + } + index++; + emit("\n[eval] ===== [" + index + "/" + total + "] case " + caseId + ": " + testCase.get("intent") + + " ====="); + long startNanos = System.nanoTime(); + Map record = runCase(envPath, suite, testCase); + record.put("duration_seconds", round1((System.nanoTime() - startNanos) / 1_000_000_000.0)); + cases.add(record); + report.put("metrics", metrics(cases)); + writeReport(report, outPath); + } + + report.put("metrics", metrics(cases)); + writeReport(report, outPath); + Map metrics = metrics(cases); + emit("\n[eval] ===== Negotiation interface correctness metrics ====="); + emit("[eval] cases: " + metrics.get("cases")); + for (String key : List.of("trigger_accuracy", "missing_slot_exact_match", "propose_outbound_valid", + "accept_fill_value_match", "closed_loop_completion")) { + emit("[eval] " + metrics.get(key)); + } + emit("[eval] report written to " + outPath.toAbsolutePath()); + } + + // -- one case: the negotiation interface closed loop with full evidence capture -- + + /** Runs one case end to end and returns its replayable trace record. */ + private static Map runCase(Path envPath, Map suite, Map testCase) { + String caseId = String.valueOf(testCase.get("id")); + String channel = String.valueOf(testCase.get("channel")); + boolean fromText = "fromText".equals(channel); + String inputText = String.valueOf(testCase.get("input_text")); + Map inputData = asMap(testCase.get("input_data")); + + TemplateUri taskTemplate = parseTemplate(String.valueOf(suite.get("template_uri"))); + Map taskSchema = asMap(suite.get("task_schema")); + Map phrasing = stringMap(suite.get("negotiation_phrasing")); + Map properties = asMap(taskSchema.get("properties")); + Map fillValues = mergeFills(stringMap(suite.get("client_fill_values")), + stringMap(testCase.get("fill_override"))); + + Map expect = asMap(testCase.get("expect")); + boolean expectNegotiation = Boolean.TRUE.equals(expect.get("negotiation_needed")); + Set expectMissing = new TreeSet<>(stringList(expect.get("missing_slots"))); + Boolean expectFillCompletes = (Boolean) expect.get("fill_completes"); + boolean expectGenerationFails = Boolean.TRUE.equals(expect.get("generation_fails")); + + Map record = new LinkedHashMap<>(); + record.put("case", caseId); + record.put("channel", channel); + record.put("intent", testCase.get("intent")); + Map input = new LinkedHashMap<>(); + input.put("fromText", fromText); + if (fromText) { + input.put("text", inputText); + } else { + input.put("data", inputData); + } + record.put("input", input); + Map expectJson = new LinkedHashMap<>(); + expectJson.put("negotiation_needed", expectNegotiation); + expectJson.put("missing_slots", new ArrayList<>(expectMissing)); + expectJson.put("fill_completes", expectFillCompletes); + expectJson.put("generation_fails", expectGenerationFails); + record.put("expect", expectJson); + List> steps = new ArrayList<>(); + record.put("steps", steps); + + // -- step 1: Task-T prompt generation from the extension metadata value -- + String taskPrompt; + try { + long nanos = System.nanoTime(); + A2ATClient client = new A2ATClient(envPath); + MetadataContent taskContent = fromText + ? client.generateTaskPromptFromText(inputText, taskTemplate) + : client.generateTaskPromptFromDataWithSchema(inputData, taskSchema, taskTemplate); + taskPrompt = taskContent.promptText(); + Map step = step("1. Task-T generation (" + channel + ")", "client"); + step.put("generated_prompt", taskPrompt); + step.put("template_uri", taskContent.templateUri()); + steps.add(step); + emit("[eval] [1] Task-T prompt generated (" + channel + ", " + secs(nanos) + "s)"); + } catch (A2ATError error) { + steps.add(errorStep("1. Task-T generation (" + channel + ")", "task_generation", error)); + emit("[eval] [1] Task-T generation FAILED: " + error.getCode() + " " + error.getMessage()); + if (expectGenerationFails) { + // generation-time fail-fast is the expected outcome (e.g. a required slot missing from the + // natural-language input): the task never exists, so negotiation cannot and must not trigger + Map verdict = new LinkedHashMap<>(); + verdict.put("trigger_correct", !expectNegotiation); + verdict.put("slots_correct", expectMissing.isEmpty()); + verdict.put("propose_outbound_valid", null); + verdict.put("accept_fill_value_match", null); + verdict.put("fill_completes_correct", null); + verdict.put("pass", !expectNegotiation && expectMissing.isEmpty()); + verdict.put("reason", "generation failed fast as expected: " + error.getCode() + " " + + error.getMessage()); + record.put("verdict", verdict); + emit("[eval] verdict: PASS (generation fail-fast as expected)"); + return record; + } + return finish(record, steps, null, null, null, null, null, expectNegotiation, expectMissing, + expectFillCompletes); + } + + // -- step 2: server validation of the Task-T prompt -> missing-slot detection -- + Set actualMissing; + Map extractedRound1 = new LinkedHashMap<>(); + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData params = server.validateTaskPromptAndDataFilling(taskPrompt, taskSchema, taskTemplate); + actualMissing = blankSlots(params); + if (params.data() != null) { + extractedRound1.putAll(params.data()); + } + steps.add(stepOf( + "2. server Task-T validation (missing-slot detection)", + "server", + validationJson("passed", null, null, params, actualMissing))); + emit("[eval] [2] validation passed, missing slots: " + actualMissing + " (" + secs(nanos) + "s)"); + } catch (ContentValidationException error) { + actualMissing = errorSlots(error); + steps.add(stepOf( + "2. server Task-T validation (missing-slot detection)", + "server", + validationJson("rejected", error.getCode(), error.errors(), null, actualMissing))); + emit("[eval] [2] validation rejected (" + error.getCode() + "), missing slots: " + actualMissing); + } + + boolean negotiationTriggered = !actualMissing.isEmpty(); + if (!negotiationTriggered) { + Map done = step("3. no negotiation needed -> flow completes directly", "server"); + done.put("task_state", "COMPLETED"); + steps.add(done); + emit("[eval] [3] no missing parameters -> COMPLETED without negotiation"); + return finish(record, steps, negotiationTriggered, actualMissing, null, null, null, expectNegotiation, + expectMissing, expectFillCompletes); + } + + // -- step 3: Negotiation-T propose generation from the detected missing slots -- + List missingItems = new ArrayList<>(); + for (String slot : actualMissing) { + missingItems.add(new NegotiationItem(slot, missingHint(phrasing, properties, slot))); + } + String proposePrompt; + try { + A2ATServer server = new A2ATServer(envPath); + MetadataContent propose = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS), + new InformationProposeContent(missingItems, phrasing.get("propose_relationship"))), + DemoTemplates.NEGOTIATION_PROPOSE); + proposePrompt = propose.promptText(); + Map step = step("3. Negotiation-T propose generation (fromData)", "server"); + step.put("negotiation_items", itemsJson(missingItems)); + step.put("generated_prompt", proposePrompt); + step.put("template_uri", propose.templateUri()); + step.put("task_state", "INPUT_REQUIRED"); + steps.add(step); + emit("[eval] [3] propose rendered for " + actualMissing + " -> INPUT_REQUIRED"); + } catch (A2ATError error) { + steps.add(errorStep("3. Negotiation-T propose generation (fromData)", "propose_generation", error)); + emit("[eval] [3] propose generation FAILED: " + error.getCode() + " " + error.getMessage()); + return finish(record, steps, negotiationTriggered, actualMissing, null, null, null, expectNegotiation, + expectMissing, expectFillCompletes); + } + + // -- step 4: outbound validation of the generated propose -- + Boolean proposeValid = null; + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData proposeParams = server.validateProposePromptAndDataFilling( + proposePrompt, negotiationSchema(actualMissing), DemoTemplates.NEGOTIATION_PROPOSE); + proposeValid = true; + Map step = stepOf( + "4. outbound propose validation", + "server", + validationJson("passed", null, null, proposeParams, Set.of())); + steps.add(step); + emit("[eval] [4] outbound propose validation passed (" + secs(nanos) + "s)"); + } catch (A2ATError error) { + proposeValid = false; + List slotErrors = error instanceof + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException extraction + ? extraction.getErrors() + : List.of(); + Map step = stepOf( + "4. outbound propose validation", + "server", + validationJson("rejected", error.getCode(), slotErrors, null, Set.of())); + steps.add(step); + emit("[eval] [4] outbound propose validation REJECTED: " + error.getCode() + " " + error.getMessage()); + } + + // -- step 5: client fills the missing slots and renders the Negotiation-T accept -- + // the filled Task-T prompt is RE-GENERATED from the complete parameter map, exactly like the demo renders + // message 3's Task-T from the filled params — not text-appended. The client knows the full parameter set: + // the fromData input, or (for fromText) the parameters the server extracted in round 1 when validation + // passed, completed with the suite's fill values for everything still missing. + Map fills = fills(actualMissing, fillValues); + Map baseData = fromText ? extractedRound1 : inputData; + Map filledData = mergeInputData(baseData, taskSchema, mergeFills(fillValues, fills)); + String filledPrompt; + try { + A2ATClient client = new A2ATClient(envPath); + filledPrompt = client.generateTaskPromptFromDataWithSchema(filledData, taskSchema, taskTemplate) + .promptText(); + } catch (A2ATError error) { + // a fill value violates a slot constraint: fromData generation fails fast — the closed loop cannot + // complete, which is the expected negative outcome for constraint-violating fills + filledPrompt = appendSupplements(taskPrompt, fills); + Map step = step("5. client fill + Negotiation-T accept generation (fromData)", "client"); + step.put("client_fill", fills); + step.put("fill_generation_rejected", Map.of( + "code", error.getCode(), + "message", String.valueOf(error.getMessage()))); + steps.add(step); + emit("[eval] [5] filled Task-T generation REJECTED (" + error.getCode() + "): " + + error.getMessage()); + return finish(record, steps, negotiationTriggered, actualMissing, proposeValid, null, false, + expectNegotiation, expectMissing, expectFillCompletes); + } + String acceptPrompt; + try { + A2ATClient client = new A2ATClient(envPath); + MetadataContent accept = client.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData( + new NegotiationContext(UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS), + new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems(fills))), + DemoTemplates.NEGOTIATION_ACCEPT); + acceptPrompt = accept.promptText(); + Map step = step("5. client fill + Negotiation-T accept generation (fromData)", "client"); + step.put("client_fill", fills); + step.put("filled_task_prompt", filledPrompt); + step.put("filled_params", filledData); + step.put("generated_prompt", acceptPrompt); + step.put("template_uri", accept.templateUri()); + steps.add(step); + emit("[eval] [5] client fills " + fills.keySet() + ", accept rendered"); + } catch (A2ATError error) { + steps.add(errorStep("5. client fill + Negotiation-T accept generation (fromData)", "accept_generation", + error)); + emit("[eval] [5] accept generation FAILED: " + error.getCode() + " " + error.getMessage()); + return finish(record, steps, negotiationTriggered, actualMissing, proposeValid, null, null, + expectNegotiation, expectMissing, expectFillCompletes); + } + + // -- step 6: inbound validation of the accept message; extracted values must match the client fill -- + Boolean acceptValid = null; + Boolean fillValueMatch = null; + Map extractedAcceptParams = null; + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( + acceptPrompt, negotiationSchema(actualMissing), DemoTemplates.NEGOTIATION_ACCEPT); + acceptValid = true; + extractedAcceptParams = acceptParams.data() == null ? Map.of() : new LinkedHashMap<>(acceptParams.data()); + fillValueMatch = fillValuesMatch(extractedAcceptParams, fills); + Map validation = validationJson("passed", null, null, acceptParams, Set.of()); + validation.put("expected_fill", fills); + validation.put("fill_value_match", fillValueMatch); + Map step = stepOf("6. inbound accept validation", "server", validation); + steps.add(step); + emit("[eval] [6] accept validation passed, fill value match: " + fillValueMatch + " (" + secs(nanos) + + "s)"); + } catch (A2ATError error) { + acceptValid = false; + fillValueMatch = false; + List slotErrors = error instanceof + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException extraction + ? extraction.getErrors() + : List.of(); + Map validation = validationJson("rejected", error.getCode(), slotErrors, null, Set.of()); + validation.put("expected_fill", fills); + validation.put("fill_value_match", false); + Map step = stepOf("6. inbound accept validation", "server", validation); + steps.add(step); + emit("[eval] [6] accept validation REJECTED: " + error.getCode() + " " + error.getMessage()); + } + + // -- step 7: second Task-T validation of the filled prompt -> COMPLETED or stays INPUT_REQUIRED -- + Boolean fillCompleted = null; + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData filled = server.validateTaskPromptAndDataFilling(filledPrompt, taskSchema, taskTemplate); + Set stillMissing = blankSlots(filled); + fillCompleted = stillMissing.isEmpty(); + Map step = stepOf( + "7. second Task-T validation (fill round)", + "server", + validationJson(fillCompleted ? "passed" : "rejected", + fillCompleted ? null : "slots_still_missing", + fillCompleted ? null : stillMissingErrors(stillMissing), + filled, + stillMissing)); + step.put("task_state", fillCompleted ? "COMPLETED" : "INPUT_REQUIRED"); + steps.add(step); + emit("[eval] [7] second Task-T validation " + (fillCompleted ? "-> COMPLETED" : "still missing: " + + stillMissing) + " (" + secs(nanos) + "s)"); + } catch (ContentValidationException error) { + fillCompleted = false; + Map step = stepOf( + "7. second Task-T validation (fill round)", + "server", + validationJson("rejected", error.getCode(), error.errors(), null, errorSlots(error))); + steps.add(step); + emit("[eval] [7] second Task-T validation REJECTED (" + error.getCode() + "): " + errorSlots(error)); + } + + return finish(record, steps, negotiationTriggered, actualMissing, proposeValid, fillValueMatch, + fillCompleted, expectNegotiation, expectMissing, expectFillCompletes); + } + + // -- record assembly -- + + private static Map finish( + Map record, + List> steps, + Boolean triggered, + Set actualMissing, + Boolean proposeValid, + Boolean fillValueMatch, + Boolean fillCompleted, + boolean expectNegotiation, + Set expectMissing, + Boolean expectFillCompletes) { + boolean error = triggered == null; + boolean triggerCorrect = !error && triggered == expectNegotiation; + boolean slotsCorrect = !error && actualMissing != null && actualMissing.equals(expectMissing); + Boolean proposeValidCorrect = error || proposeValid == null ? null : proposeValid; + Boolean fillMatchCorrect = error || fillValueMatch == null ? null : fillValueMatch; + Boolean fillCorrect; + if (error) { + fillCorrect = false; + } else if (expectFillCompletes == null) { + fillCorrect = null; + } else if (Boolean.TRUE.equals(triggered)) { + fillCorrect = expectFillCompletes.equals(fillCompleted); + } else { + fillCorrect = !expectFillCompletes; + } + boolean pass = triggerCorrect && slotsCorrect + && (proposeValidCorrect == null || proposeValidCorrect) + && (fillMatchCorrect == null || fillMatchCorrect) + && (fillCorrect == null || fillCorrect); + + Map actual = new LinkedHashMap<>(); + actual.put("negotiation_triggered", triggered); + actual.put("missing_slots", actualMissing == null ? null : new ArrayList<>(actualMissing)); + actual.put("propose_outbound_valid", proposeValid); + actual.put("accept_fill_value_match", fillValueMatch); + actual.put("closed_loop_completed", fillCompleted); + record.put("actual", actual); + + Map verdict = new LinkedHashMap<>(); + verdict.put("trigger_correct", triggerCorrect); + verdict.put("slots_correct", slotsCorrect); + verdict.put("propose_outbound_valid", proposeValidCorrect); + verdict.put("accept_fill_value_match", fillMatchCorrect); + verdict.put("fill_completes_correct", fillCorrect); + verdict.put("pass", pass); + verdict.put("reason", reason(error, triggerCorrect, slotsCorrect, fillMatchCorrect, fillCorrect, + actualMissing, expectMissing, triggered, expectNegotiation, proposeValid)); + record.put("verdict", verdict); + + emit("[eval] verdict: " + (pass ? "PASS" : "FAIL") + + " | trigger=" + triggerCorrect + " slots=" + slotsCorrect + + " proposeValid=" + proposeValidCorrect + " fillMatch=" + fillMatchCorrect + " fill=" + fillCorrect); + return record; + } + + private static String reason( + boolean error, + boolean triggerCorrect, + boolean slotsCorrect, + Boolean fillMatchCorrect, + Boolean fillCorrect, + Set actualMissing, + Set expectMissing, + Boolean triggered, + boolean expectNegotiation, + Boolean proposeValid) { + if (error) { + return "evaluation aborted by an error (see steps)"; + } + List notes = new ArrayList<>(); + if (!triggerCorrect) { + notes.add("negotiation expected=" + expectNegotiation + " actual=" + triggered); + } + if (!slotsCorrect && actualMissing != null) { + Set missed = new TreeSet<>(expectMissing); + missed.removeAll(actualMissing); + Set extra = new TreeSet<>(actualMissing); + extra.removeAll(expectMissing); + notes.add("missing-slot set mismatch: expected=" + expectMissing + " actual=" + actualMissing + + (missed.isEmpty() ? "" : " (not reported: " + missed + ")") + + (extra.isEmpty() ? "" : " (unexpected: " + extra + ")")); + } + if (proposeValid != null && !proposeValid) { + notes.add("generated propose failed outbound validation (step 4)"); + } + if (fillMatchCorrect != null && !fillMatchCorrect) { + notes.add("server-side extracted fill values differ from what the client sent (step 6)"); + } + if (fillCorrect != null && !fillCorrect) { + notes.add("fill round did not complete as expected (step 7)"); + } + return String.join("; ", notes); + } + + // -- trace step builders -- + + private static Map step(String name, String role) { + Map step = new LinkedHashMap<>(); + step.put("step", name); + step.put("role", role); + return step; + } + + private static Map stepOf(String name, String role, Map validation) { + Map step = step(name, role); + step.put("validation", validation); + return step; + } + + private static Map validationJson( + String outcome, + String errorCode, + List slotErrors, + FilledParamData params, + Set missingSlots) { + Map validation = new LinkedHashMap<>(); + validation.put("outcome", outcome); + if (errorCode != null) { + validation.put("error_code", errorCode); + } + if (slotErrors != null && !slotErrors.isEmpty()) { + List> errors = new ArrayList<>(); + for (SlotValidationError slotError : slotErrors) { + errors.add(Map.of( + "slot", nullToEmpty(slotError.slotName()), + "code", nullToEmpty(slotError.code()), + "message", nullToEmpty(slotError.message()))); + } + validation.put("slot_errors", errors); + } + if (params != null) { + validation.put("extracted_params", + params.data() == null ? Map.of() : new LinkedHashMap<>(params.data())); + } + validation.put("missing_slots", missingSlots == null ? List.of() : new ArrayList<>(missingSlots)); + return validation; + } + + private static List stillMissingErrors(Set stillMissing) { + List errors = new ArrayList<>(); + for (String slot : stillMissing) { + errors.add(new SlotValidationError(slot, "slots_still_missing", "slot still missing after the fill round")); + } + return errors; + } + + private static Map errorStep(String name, String stage, A2ATError error) { + Map step = step(name, "n/a"); + step.put("error", Map.of( + "stage", stage, + "code", error.getCode(), + "message", String.valueOf(error.getMessage()))); + return step; + } + + private static List> itemsJson(List items) { + List> list = new ArrayList<>(); + for (NegotiationItem item : items) { + list.add(Map.of("name", item.name(), "value", nullToEmpty(item.value()))); + } + return list; + } + + // -- negotiation helpers -- + + /** Missing-item hint from the phrasing template ({@code {slot}}/{@code {description}} placeholders). */ + private static String missingHint(Map phrasing, Map properties, String slot) { + String template = phrasing.getOrDefault("missing_item_hint", "{slot}"); + Object description = null; + Object property = properties.get(slot); + if (property instanceof Map slotSchema + && slotSchema.get("description") instanceof String text + && !text.isBlank()) { + description = text; + } + return template.replace("{slot}", slot).replace("{description}", description == null ? "" : String.valueOf(description)); + } + + /** The supplement values the simulated client supplies, keyed by slot. */ + private static Map fills(Set missingSlots, Map fillValues) { + Map fills = new LinkedHashMap<>(); + for (String slot : missingSlots) { + String value = fillValues.get(slot); + if (value != null && !value.isBlank()) { + fills.put(slot, value); + } + } + return fills; + } + + private static Map mergeFills(Map base, Map override) { + Map merged = new LinkedHashMap<>(base); + merged.putAll(override); + return merged; + } + + private static List filledItems(Map fills) { + List items = new ArrayList<>(); + for (Map.Entry entry : fills.entrySet()) { + items.add(new NegotiationItem(entry.getKey(), entry.getValue())); + } + return items; + } + + /** + * Builds the complete parameter map for the fill round: every slot declared by the task schema carries either the + * round-1 input value or the client's fill value, so the filled Task-T prompt can be regenerated from data. + */ + private static Map mergeInputData( + Map inputData, Map taskSchema, Map fills) { + Map merged = new LinkedHashMap<>(); + Map properties = asMap(taskSchema.get("properties")); + for (String slot : properties.keySet()) { + Object base = inputData.get(slot); + Object value = base == null || (base instanceof String s && s.isBlank()) ? fills.get(slot) : base; + merged.put(slot, value == null ? "" : value); + } + merged.putAll(fills); + return merged; + } + + /** Appends the supplement values to a prompt verbatim; fallback when regeneration is impossible. */ + private static String appendSupplements(String taskPrompt, Map fills) { + StringBuilder filled = new StringBuilder(taskPrompt); + for (Map.Entry entry : fills.entrySet()) { + filled.append(System.lineSeparator()).append(entry.getKey()).append(":").append(entry.getValue()); + } + return filled.toString(); + } + + /** + * Parameter schema for the negotiation-message validations (steps 4 and 6): one string property per negotiated + * slot, so the extraction surface is exactly the supplemented information. + */ + private static Map negotiationSchema(Set slots) { + Map properties = new LinkedHashMap<>(); + for (String slot : slots) { + properties.put(slot, Map.of("type", "string")); + } + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + schema.put("required", new ArrayList<>(slots)); + return schema; + } + + /** Whether every server-extracted fill value matches the value the client sent for that slot. */ + private static boolean fillValuesMatch(Map extracted, Map fills) { + for (Map.Entry entry : fills.entrySet()) { + Object value = extracted.get(entry.getKey()); + if (value == null || !normalize(String.valueOf(value)).contains(normalize(entry.getValue()))) { + return false; + } + } + return true; + } + + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("\\s+", ""); + } + + /** Slots whose extracted value is null/blank (negotiation candidates). */ + private static Set blankSlots(FilledParamData params) { + Set blank = new TreeSet<>(); + if (params != null && params.data() != null) { + for (Map.Entry entry : params.data().entrySet()) { + Object value = entry.getValue(); + if (value == null || (value instanceof String s && s.isBlank())) { + blank.add(entry.getKey()); + } + } + } + return blank; + } + + /** Slots reported as errors by the semantic validation. */ + private static Set errorSlots(ContentValidationException error) { + Set slots = new TreeSet<>(); + if (error.errors() != null) { + for (SlotValidationError validationError : error.errors()) { + if (validationError.slotName() != null && !validationError.slotName().isBlank()) { + slots.add(validationError.slotName()); + } + } + } + return slots; + } + + // -- metrics -- + + private static Map metrics(List> cases) { + int total = cases.size(); + int triggerOk = 0; + int slotsOk = 0; + int proposeEvaluated = 0; + int proposeOk = 0; + int fillMatchEvaluated = 0; + int fillMatchOk = 0; + int loopEvaluated = 0; + int loopOk = 0; + int pass = 0; + for (Map record : cases) { + Map verdict = (Map) record.get("verdict"); + if (Boolean.TRUE.equals(verdict.get("trigger_correct"))) { + triggerOk++; + } + if (Boolean.TRUE.equals(verdict.get("slots_correct"))) { + slotsOk++; + } + if (verdict.get("propose_outbound_valid") != null) { + proposeEvaluated++; + if (Boolean.TRUE.equals(verdict.get("propose_outbound_valid"))) { + proposeOk++; + } + } + if (verdict.get("accept_fill_value_match") != null) { + fillMatchEvaluated++; + if (Boolean.TRUE.equals(verdict.get("accept_fill_value_match"))) { + fillMatchOk++; + } + } + if (verdict.get("fill_completes_correct") != null) { + loopEvaluated++; + if (Boolean.TRUE.equals(verdict.get("fill_completes_correct"))) { + loopOk++; + } + } + if (Boolean.TRUE.equals(verdict.get("pass"))) { + pass++; + } + } + Map metrics = new LinkedHashMap<>(); + metrics.put("cases", total); + metrics.put("trigger_accuracy", ratio("negotiation correctly triggered/held", triggerOk, total)); + metrics.put("missing_slot_exact_match", ratio("missing-slot set exact match", slotsOk, total)); + metrics.put("propose_outbound_valid", ratio("generated propose passes outbound validation", proposeOk, + proposeEvaluated)); + metrics.put("accept_fill_value_match", ratio("extracted fill values match client fill", fillMatchOk, + fillMatchEvaluated)); + metrics.put("closed_loop_completion", ratio("closed loop completes as expected", loopOk, loopEvaluated)); + metrics.put("full_pass", ratio("all dimensions pass", pass, total)); + return metrics; + } + + private static Map ratio(String label, int part, int total) { + Map ratio = new LinkedHashMap<>(); + ratio.put("label", label); + ratio.put("passed", part); + ratio.put("total", total); + ratio.put("pct", total == 0 ? 0.0 : round1(100.0 * part / total)); + return ratio; + } + + // -- suite loading and small utilities -- + + private static Map loadSuite() { + try (InputStream stream = NegotiationEvalApp.class + .getClassLoader() + .getResourceAsStream("sample/negotiation/eval/eval-suite.json")) { + if (stream == null) { + throw new IllegalStateException("Eval suite resource not found"); + } + return MAPPER.readValue(stream, new TypeReference>() {}); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to read eval suite", exception); + } + } + + /** LLM run configuration from the env file, without the API key. */ + private static Map llmInfo(Path envPath) { + Map values = readEnv(envPath); + Map info = new LinkedHashMap<>(); + info.put("provider", values.getOrDefault("A2AT_LLM_PROVIDER", "")); + info.put("model", values.getOrDefault("A2AT_LLM_MODEL", "")); + info.put("base_url", values.getOrDefault("A2AT_LLM_BASE_URL", "")); + return info; + } + + private static Map readEnv(Path envPath) { + Map values = new LinkedHashMap<>(); + if (envPath == null || !Files.exists(envPath)) { + return values; + } + try { + for (String rawLine : Files.readAllLines(envPath)) { + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { + continue; + } + int separator = line.indexOf('='); + values.put(line.substring(0, separator).trim(), line.substring(separator + 1).trim()); + } + } catch (IOException exception) { + throw new UncheckedIOException("Failed to read env file: " + envPath, exception); + } + return values; + } + + private static void writeReport(Map report, Path outPath) { + try { + MAPPER.writeValue(outPath.toFile(), report); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to write eval report: " + outPath, exception); + } + } + + private static int countCases(Map suite, List filter) { + if (filter.isEmpty()) { + return cases(suite).size(); + } + return (int) cases(suite).stream() + .map(testCase -> String.valueOf(testCase.get("id"))) + .filter(filter::contains) + .count(); + } + + private static TemplateUri parseTemplate(String templateUri) { + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); + } + + /** Negotiation template URIs used by the closed loop; kept local so the evaluator stays self-contained. */ + private static final class DemoTemplates { + + static final TemplateUri NEGOTIATION_PROPOSE = parseTemplate("Negotiation-T/information-negotiation/propose/v1"); + + static final TemplateUri NEGOTIATION_ACCEPT = + parseTemplate("Negotiation-T/information-negotiation/accept-reject/v1"); + + private DemoTemplates() {} + } + + @SuppressWarnings("unchecked") + private static List> cases(Map suite) { + Object raw = suite.get("cases"); + return raw instanceof List list ? (List>) raw : List.of(); + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object value) { + return value instanceof Map map ? (Map) map : Map.of(); + } + + private static Map stringMap(Object value) { + Map result = new LinkedHashMap<>(); + if (value instanceof Map map) { + map.forEach((key, val) -> result.put(String.valueOf(key), String.valueOf(val))); + } + return result; + } + + private static List stringList(Object value) { + if (value instanceof List list) { + return list.stream().map(String::valueOf).toList(); + } + return List.of(); + } + + private static String nullToEmpty(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static double round1(double value) { + return Math.round(value * 10.0) / 10.0; + } + + private static String secs(long startNanos) { + return String.valueOf(round1((System.nanoTime() - startNanos) / 1_000_000_000.0)); + } + + private static void emit(String message) { + System.out.println(message); + } +} diff --git a/a2a-t-sample/src/main/resources/sample/negotiation/eval/eval-suite.json b/a2a-t-sample/src/main/resources/sample/negotiation/eval/eval-suite.json new file mode 100644 index 00000000..ccb7499b --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/negotiation/eval/eval-suite.json @@ -0,0 +1,160 @@ +{ + "suite": "negotiation-interface-eval", + "description": "Private-line complaint negotiation closed-loop evaluation. Each case drives the negotiation prompt interfaces in message order: Task-T generation (fromData/fromText) -> server validation (missing-slot detection) -> Negotiation-T propose generation -> outbound propose validation -> client fill + accept generation -> inbound accept validation -> second Task-T validation. Channels: fromData is deterministic generation (no LLM for message rendering); fromText runs LLM slot extraction. All validation interfaces run the SDK pipeline (rule gate + one semantic LLM call).", + "scenario": "private-line-complaint", + "template_uri": "Task-T/network-layer/private-line-complaint/v1", + "task_schema": { + "type": "object", + "properties": { + "任务对象": { + "type": "string", + "description": "专线业务对象标识:专线名称 / 接入端口名称(网元名称+单板号+单板型号+端口号)/ 接入端口资源ID,三者提供其一" + }, + "任务上下文": { + "type": "string", + "description": "故障现象(专线中断/专线质量差,必选)+ 故障发生时间(可选)+ OSS侧事件流水号(可选)" + } + }, + "required": [ + "任务上下文" + ] + }, + "negotiation_phrasing": { + "missing_item_hint": "请提供{slot}:{description}", + "propose_relationship": "以上参数均为必选,缺少无法启动诊断" + }, + "client_fill_values": { + "任务对象": "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17", + "任务上下文": "故障现象:专线中断,业务完全不可用。问题发生时间:2026-06-02T14:30:00Z。" + }, + "cases": [ + { + "id": "PLC-01", + "channel": "fromData", + "intent": "结构化参数完整,不应触发协商", + "input_data": { + "任务对象": "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17", + "任务上下文": "故障现象:专线质量差,深圳访问广州响应延迟骤升,交易接口频繁报连接超时。问题发生时间:2026-05-11T08:21:46Z。OSS侧事件流水号:event-id-20260511-09013。" + }, + "expect": { + "negotiation_needed": false, + "missing_slots": [] + } + }, + { + "id": "PLC-02", + "channel": "fromText", + "intent": "自然语言完整(端口+现象+时间+流水号),不应触发协商", + "input_text": "深圳福田区某金融客户专线出现质量劣化。接入端口:P781-珠江新城-PTN7900-23-TPA1EG24-17。故障现象:专线质量差,深圳访问广州响应延迟骤升,交易接口频繁报连接超时。问题发生时间:2026-05-11T08:21:46Z。OSS侧事件流水号:event-id-20260511-09013。", + "expect": { + "negotiation_needed": false, + "missing_slots": [] + } + }, + { + "id": "PLC-03", + "channel": "fromData", + "intent": "结构化缺任务对象,触发协商", + "input_data": { + "任务对象": "", + "任务上下文": "故障现象:专线中断,客户业务全部不通。问题发生时间:2026-06-02T14:30:00Z。OSS侧事件流水号:event-id-20260602-08841。" + }, + "expect": { + "negotiation_needed": true, + "missing_slots": [ + "任务对象" + ], + "fill_completes": true + } + }, + { + "id": "PLC-04", + "channel": "fromText", + "intent": "口语化缺专线标识,触发协商", + "input_text": "我们的专线出问题了,故障现象:专线中断,客户业务全部不通。问题发生时间:2026-06-02T14:30:00Z。OSS侧事件流水号:event-id-20260602-08841。", + "expect": { + "negotiation_needed": true, + "missing_slots": [ + "任务对象" + ], + "fill_completes": true + } + }, + { + "id": "PLC-05", + "channel": "fromText", + "intent": "自然语言两个槽全空(无对象无现象):required 槽在生成期即被拦截,任务无法生成,协商不触发", + "input_text": "帮我看一下专线的问题。", + "expect": { + "negotiation_needed": false, + "missing_slots": [], + "generation_fails": true + } + }, + { + "id": "PLC-06", + "channel": "fromText", + "intent": "任务对象模糊(非端口/ID/名称形态),触发任务对象协商", + "input_text": "故障现象:专线质量差,网页打开缓慢,视频卡顿。故障发生时间:2026-06-18T09:00:00Z。", + "expect": { + "negotiation_needed": true, + "missing_slots": [ + "任务对象" + ], + "fill_completes": true + } + }, + { + "id": "PLC-07", + "channel": "fromText", + "intent": "口语化完整请求,端口埋在长句里,不应触发协商", + "input_text": "东莞松山湖科技园客户报障说专线卡,涉及端口 P662-松山湖北-PTN9900-12-TPB3EG12-08,成天丢包,业务系统登录都费劲,昨天下午三点开始的,流水号 event-id-20260619-55210。", + "expect": { + "negotiation_needed": false, + "missing_slots": [] + } + }, + { + "id": "PLC-08", + "channel": "fromText", + "intent": "故障现象不明确(未指明中断或质差),触发任务上下文协商", + "input_text": "接入端口:P311-会展中心-ATN910C-05-TPA2EG8-03。专线好像有点问题,时好时坏,客户体验受影响。", + "expect": { + "negotiation_needed": true, + "missing_slots": [ + "任务上下文" + ], + "fill_completes": true + } + }, + { + "id": "PLC-09", + "channel": "fromText", + "intent": "端口误写在现象描述里(字段错位),抽取器应归位到任务对象,不触发协商", + "input_text": "故障现象:专线质量差。端口是 P120-南山智园-PTN7900-08-TPA1EG21-11,丢包严重。", + "expect": { + "negotiation_needed": false, + "missing_slots": [] + } + }, + { + "id": "PLC-10", + "channel": "fromData", + "intent": "负例:补充值端口格式乱写,二次校验应拒绝(测校验器对值约束的把关)", + "input_data": { + "任务对象": "", + "任务上下文": "故障现象:专线中断,客户业务全部不通。问题发生时间:2026-06-02T14:30:00Z。OSS侧事件流水号:event-id-20260602-08841。" + }, + "fill_override": { + "任务对象": "端口是坏的" + }, + "expect": { + "negotiation_needed": true, + "missing_slots": [ + "任务对象" + ], + "fill_completes": false + } + } + ] +} From 0570b4585d4ba6afe911c56fbfefe3d50f8fd952 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 21:38:09 +0800 Subject: [PATCH 129/295] fix(prompt): preserve null semantic params so missing slots stay detectable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DefaultSemanticValidator.parseParams dropped parameters whose extracted value was null, so FilledParamData did not even carry the key. A null param is the semantic validator's explicit signal that a schema slot is missing from the content (per the content_validation user prompt: slots that cannot be extracted must be output as null). With the key gone, any caller that detects missing parameters by scanning FilledParamData for blank values cannot see the missing slot, and negotiation is silently never triggered for it. The parse now keeps null-valued keys (the normalized map is wrapped with Collections.unmodifiableMap instead of Map.copyOf, which rejects null values), and ValidationPipeline.mergeParams already carries them through unchanged. Found by the negotiation interface closed-loop evaluator (eval-suite case PLC-10: the LLM returned 任务对象=null with semantic_verdict=true, the missing slot vanished from FilledParamData, and the negotiation flow never started). Signed-off-by: ZhouJie6 --- .../validation/DefaultSemanticValidator.java | 16 ++++++---- .../DefaultSemanticValidatorTest.java | 32 ++++++++++++++++--- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index 0d227423..7ceb3057 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -171,18 +172,19 @@ private static List parseErrors(Object errorsValue) { return List.copyOf(normalized); } - @SuppressWarnings("unchecked") + /** + * Normalizes the LLM-extracted parameter map. Keys with a {@code null} value are preserved: a {@code null} + * parameter is the semantic validator's explicit signal that a schema slot is missing from the content, and + * downstream missing-parameter detection (negotiation triggering) relies on the key being present with a null + * value. Dropping the key would make a missing slot indistinguishable from an absent one. + */ private static Map parseParams(Object paramsValue) { if (!(paramsValue instanceof Map params)) { return Map.of(); } Map normalized = new LinkedHashMap<>(); - params.forEach((key, value) -> { - if (value != null) { - normalized.put(String.valueOf(key), value); - } - }); - return Map.copyOf(normalized); + params.forEach((key, value) -> normalized.put(String.valueOf(key), value)); + return Collections.unmodifiableMap(normalized); } private static String asString(Object value) { diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java index f7d1628a..28e892f6 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; 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; @@ -136,7 +137,27 @@ void validateExhaustsRetriesAndWrapsLlmError() { @Test - void validateFiltersNullParamValues() { + void pipelineCarriesNullParamsIntoFilledParamData() { + // the semantic validator passed overall, but one schema slot is explicitly null: the merged + // FilledParamData must carry the key with a null value so callers scanning for blank slots can see the + // missing parameter and trigger negotiation for it + String llmJson = + "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; + DefaultSemanticValidator validator = + new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN", new RecordingResourceAccess()); + + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2); + + FilledParamData result = pipeline.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer")); + + assertEquals(2, result.data().size()); + assertEquals("端口A", result.data().get("任务对象")); + assertNull(result.data().get("任务上下文")); + } + + @Test + void validatePreservesNullParamValuesAsMissingSlots() { String llmJson = "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; DefaultSemanticValidator validator = @@ -146,9 +167,12 @@ void validateFiltersNullParamValues() { validator.validate("task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer")); assertTrue(result.verdict()); - // Map.copyOf rejects null values; the validator must drop them instead of throwing NPE - assertEquals(Map.of("任务对象", "端口A"), result.params()); - assertFalse(result.params().containsKey("任务上下文")); + // a null param is the validator's signal that a schema slot is missing from the content; the key must stay + // present with a null value so downstream missing-slot detection (negotiation triggering) can see it + assertEquals(2, result.params().size()); + assertEquals("端口A", result.params().get("任务对象")); + assertTrue(result.params().containsKey("任务上下文")); + assertNull(result.params().get("任务上下文")); } private static final class RecordingClient implements LLMClient { From 71cf21ff6260bcc03e6f089f87d7d9d1c8c213c8 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sat, 22 Aug 2026 21:59:47 +0800 Subject: [PATCH 130/295] docs(sample): explain @-argfile run form and document the negotiation interface evaluator - add a dedicated subsection explaining how the javaargs.txt files work (JDK 9+ @-argfile syntax, generated by the antrun plugin at package time, one file per entry point) so the run commands are readable without Maven knowledge; note the Windows -Dfile.encoding=UTF-8 hint - document the eval entry: full-suite / --case replay / --out forms, incremental report writing, and where the eval suite lives - list the evaluator in the code-structure tree and the coverage matrix Signed-off-by: ZhouJie6 --- .../docs/Negotiation-Sample-Design.md | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/a2a-t-sample/docs/Negotiation-Sample-Design.md b/a2a-t-sample/docs/Negotiation-Sample-Design.md index 5b185a5a..5b237172 100644 --- a/a2a-t-sample/docs/Negotiation-Sample-Design.md +++ b/a2a-t-sample/docs/Negotiation-Sample-Design.md @@ -61,10 +61,14 @@ negotiation/ │ └── NegotiationSampleSupport.java # 3×3 API 样例公共辅助 ├── fromdata/ │ └── FromDataNegotiationSample.java # 信息/目标/可行性 × 提议/接受/拒绝,共 9 个生成用例 -└── fromtext/ - └── FromTextNegotiationSample.java # 同上 9 个用例,自然语言输入 +├── fromtext/ +│ └── FromTextNegotiationSample.java # 同上 9 个用例,自然语言输入 +└── eval/ + └── NegotiationEvalApp.java # 协商接口闭环评测:逐用例驱动接口并输出可回放 JSON 报告 ``` +评测用例与场景配置同目录外置:`src/main/resources/sample/negotiation/eval/eval-suite.json`。 + ### 服务端处理流程 `NegotiationAgentExecutor` 对每个入站请求执行: @@ -88,6 +92,12 @@ negotiation/ - `mvn clean install -DskipTests` 全量构建(需联网); - 含有效 `A2AT_LLM_API_KEY` 的 `.env` 文件(模板:`sample/negotiation/negotiation.env`)。 +### 命令形态说明 + +所有入口使用 Java 的 @-argfile 语法(JDK 9+):`java @` 把文件内容展开为启动参数。构建时(`mvn package`)Maven antrun 插件在 `target/` 下为每个入口生成一个 `*.javaargs.txt`,文件内容是两行——`-cp` 加上完整的依赖 jar 路径链,以及主类的全限定名。这样运行命令无需手写数百字符的 classpath。每个入口对应一个文件:`negotiation.javaargs.txt`(端到端 demo)、`fromdata.javaargs.txt` / `fromtext.javaargs.txt`(3×3 生成用例)、`eval.javaargs.txt`(协商接口评测)。Windows 控制台建议加 `-Dfile.encoding=UTF-8` 前缀,避免中文日志显示为乱码(不影响输出文件本身的编码)。 + +### 端到端 demo + ```bash # 端到端 demo(默认:fromData 通道 + 流式优先) java @a2a-t-sample/target/negotiation.javaargs.txt /path/to/.env @@ -105,6 +115,23 @@ java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env 组合 `--fromText` 与 `--no-stream` 可覆盖全部四种通道 × 端点组合。 +### 协商接口评测 + +评测入口不走 HTTP,直接驱动协商 prompt 接口闭环(Task-T 生成 → 服务端校验 → propose 生成 → 出站校验 → 客户端补充 + accept → 入站校验 → 二次校验),逐用例输出可回放的 JSON 证据链: + +```bash +# 全量 10 条用例,报告写到 ./eval-report.json +java @a2a-t-sample/target/eval.javaargs.txt /path/to/.env + +# 只跑指定用例(可重复出现,用于单条回放/调试) +java @a2a-t-sample/target/eval.javaargs.txt --case PLC-04 /path/to/.env + +# 指定报告输出路径 +java @a2a-t-sample/target/eval.javaargs.txt --out my-report.json /path/to/.env +``` + +用例定义在 `src/main/resources/sample/negotiation/eval/eval-suite.json`,新增用例不需要改代码。报告按用例增量写盘,中断后已完成的用例仍在报告里。 + ## 六、LLM 依赖说明 `A2AT_LLM_API_KEY` 在所有运行模式下必填。fromData 通道仅省去协商报文渲染这一步的 LLM 调用;Task-T 槽位抽取与服务端语义校验始终依赖 LLM。缺失 key 时启动即失败并给出指引。 @@ -118,6 +145,7 @@ java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env | 报文生成通道 | fromData(结构化)/ fromText(自然语言) | | 传输端点 | message:stream / message:send | | 参数缺失形态 | 单参数缺失、多参数缺失、值模糊、字段错位(见 scenario.json,可扩展) | +| 正确性评测 | 10 条用例(eval.javaargs.txt):触发判定、缺失槽集合、propose 出站校验、补充值抽取一致、闭环完成,含负例 | ## 八、已知限制 From 456a5dc17cc84214353d43d32a04051d69149957 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 20:39:15 +0800 Subject: [PATCH 131/295] refactor(core): move NegotiationContext to core model Relocate the negotiation session context record from the negotiation content package to core.model so MetadataContent can reference it without a reverse module dependency. The record is moved verbatim (id/round/maxRounds with validation, DEFAULT_MAX_ROUNDS, of/nextRound/ isExhausted), its test mirrors the new package, and all imports and fully-qualified references across core/negotiation/client/server/sample are updated. The deprecated types.model.NegotiationContext demo class and the negotiation package-structure pin are left in sync. Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 1210 +++++++------- .../api/A2ATClientNegotiationApiTest.java | 372 ++--- .../A2ATClientNegotiationEnvConfigTest.java | 458 ++--- .../sdk/core/model}/NegotiationContext.java | 136 +- .../core/model}/NegotiationContextTest.java | 204 +-- .../content/NegotiationAbortData.java | 2 + .../content/NegotiationEndingData.java | 32 +- .../content/NegotiationProposeData.java | 2 + .../generation/AbortGenerator.java | 2 +- .../AbstractNegotiationGenerator.java | 268 +-- .../FeasibilityEndingGenerator.java | 96 +- .../FeasibilityProposeGenerator.java | 140 +- .../InformationEndingGenerator.java | 84 +- .../InformationProposeGenerator.java | 98 +- .../generation/NegotiationContentService.java | 2 +- .../NegotiationContextRenderer.java | 2 +- .../NegotiationGenerationOrchestrator.java | 1238 +++++++------- .../generation/NegotiationGenerator.java | 62 +- .../generation/TargetEndingGenerator.java | 110 +- .../generation/TargetProposeGenerator.java | 114 +- .../DefaultNegotiationComplianceChecker.java | 400 ++--- .../NegotiationRuleCheckResult.java | 70 +- .../NegotiationRuleCheckerAdapter.java | 2 +- .../NegotiationConcurrentMixedLoadTest.java | 324 ++-- .../NegotiationContextImmutabilityTest.java | 188 +-- .../NegotiationPackageStructureTest.java | 1 - .../AbortMessageGenerationTest.java | 2 +- ...ConclusionLiteralAndExceptionSlotTest.java | 392 ++--- .../ConditionalSectionRenderingTest.java | 364 ++-- .../CustomRootPromptsIgnoredTest.java | 306 ++-- .../FromDataProgrammingErrorMatrixTest.java | 2 +- .../generation/FromTextLlmPipelineTest.java | 1480 ++++++++--------- .../NegotiationContextRendererTest.java | 70 +- ...nerationOrchestratorBuilderWiringTest.java | 438 ++--- ...NegotiationGenerationOrchestratorTest.java | 636 +++---- .../generation/NegotiationGeneratorsTest.java | 1080 ++++++------ .../RelationshipLineRenderingTest.java | 178 +- .../TemplateUriEntryMatrixTest.java | 458 ++--- .../ValidateAndFillingDataPipelineTest.java | 1118 ++++++------- .../sdk/negotiation/golden/GoldenInputs.java | 618 +++---- .../NegotiationErrorCodeUsageMatrixTest.java | 756 ++++----- ...NegotiationExceptionTreeRootCatchTest.java | 476 +++--- .../NegotiationLogEventContractTest.java | 880 +++++----- ...NegotiationPublicExceptionSurfaceTest.java | 300 ++-- .../BilateralInteropScenarioTest.java | 206 +-- .../FeasibilityNegotiationScenarioTest.java | 264 +-- .../InformationNegotiationScenarioTest.java | 228 +-- .../MaxRoundsExhaustionScenarioTest.java | 170 +- .../TargetNegotiationScenarioTest.java | 308 ++-- .../validation/ComplianceCheckerTest.java | 502 +++--- .../validation/ParamExtractorTest.java | 438 ++--- .../negotiation/client/NegotiationClient.java | 2 +- .../fromdata/FromDataNegotiationSample.java | 2 +- .../fromtext/FromTextNegotiationSample.java | 2 +- .../server/NegotiationAgentExecutor.java | 2 +- .../negotiation/shared/FromDataStrategy.java | 2 +- .../negotiation/shared/FromTextStrategy.java | 2 +- .../shared/NegotiationStrategy.java | 2 +- .../client/NegotiationClientMain.java | 2 +- .../NegotiationQwenEvaluationMain.java | 2 +- .../server/NegotiationAgentExecutor.java | 2 +- .../shared/NegotiationSampleFlow.java | 2 +- .../NegotiationFacadeOutputSymmetryTest.java | 550 +++--- .../openan/a2at/sdk/server/A2ATServer.java | 1100 ++++++------ .../api/A2ATServerNegotiationApiTest.java | 278 ++-- .../TaskPromptNegotiationCoexistenceTest.java | 308 ++-- 66 files changed, 9775 insertions(+), 9770 deletions(-) rename {a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content => a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model}/NegotiationContext.java (95%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content => a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model}/NegotiationContextTest.java (96%) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 9dd32e40..d185d439 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -1,605 +1,605 @@ -package net.openan.a2at.sdk.client; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; -import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import org.jspecify.annotations.NonNull; - -/** - * High-level client facade for prompt generation and negotiation APIs. The caller provides the `.env` file path - * explicitly, typically after copying the repository `env.example`. - * - * @since 2026-06 - */ -public final class A2ATClient { - - private final ClientPromptGenerationOrchestrator promptGenerationOrchestrator; - - private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; - - private final NegotiationContentService negotiationContentService; - - private final TemplateQueryService templateQueryService; - - /** - * Creates a client facade from one user-supplied `.env` path. - * - * @param envPath user-supplied `.env` file path - */ - public A2ATClient(Path envPath) { - Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); - A2ATConfig config = NegotiationContentService.resolvePromptResourceLocalRootDir( - A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); - DefaultA2ATClientBuilder builder = - DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); - this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); - this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); - this.negotiationContentService = - new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); - this.templateQueryService = builder.buildTemplateQueryService(); - } - - /** - * Generates a processed task prompt from raw user input. - * - * @param userInput user-provided task description or structured input map - * @return prompt generation result containing either rendered prompt text or failure details - */ - public PromptGenerationResult generateTaskPrompt(Object userInput) { - return promptGenerationOrchestrator.generateTaskPrompt(userInput); - } - - /** - * Generates a task prompt with metadata from natural-language input using the template identified by the template - * URI, bypassing scenario recognition. - * - * @param text natural-language task input - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, - * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails - */ - public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(text, "text"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateTaskPromptFromText(text, templateUri); - } - - /** - * Generates a task prompt with metadata from structured input and a data schema using the template identified by - * the template URI, bypassing scenario recognition. - * - * @param data structured task input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, - * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails - */ - public MetadataContent generateTaskPromptFromDataWithSchema( - @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, templateUri); - } - - /** - * Generates an authorization prompt with metadata from natural-language input using the template identified by the - * template URI, bypassing scenario recognition. - * - *

      Experimental: Authorization-T template resources (such as {@code authz-policy-mgr}) are bundled and - * discovered automatically, but Authorization-T slot schemas are not yet bundled, so template-driven slot - * extraction fails until Authorization-T slot resources are added or provided through the local resource root. - * - * @param text natural-language authorization input - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, - * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails - */ - public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(text, "text"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateAuthPromptFromText(text, templateUri); - } - - /** - * Generates an authorization prompt with metadata from structured input and a data schema using the template - * identified by the template URI, bypassing scenario recognition. - * - *

      Experimental: Authorization-T template resources (such as {@code authz-policy-mgr}) are bundled and - * discovered automatically, but Authorization-T slot schemas are not yet bundled, so template-driven slot - * extraction fails until Authorization-T slot resources are added or provided through the local resource root. - * - * @param data structured authorization input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, - * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails - */ - public MetadataContent generateAuthPromptFromDataWithSchema( - @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, templateUri); - } - - /** - * Generates a notification prompt with metadata from natural-language input using the template identified by the - * template URI, bypassing scenario recognition. - * - * @param text natural-language notification input - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, - * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails - */ - public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(text, "text"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateNotificationPromptFromText(text, templateUri); - } - - /** - * Generates a notification prompt with metadata from structured input and a data schema using the template - * identified by the template URI, bypassing scenario recognition. - * - * @param data structured notification input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, - * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails - */ - public MetadataContent generateNotificationPromptFromDataWithSchema( - @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, templateUri); - } - - /** - * Starts a new negotiation payload for the requested negotiation type. - * - * @param type negotiation type to initiate - * @param contentText human-readable negotiation message - * @param facts structured facts attached to the payload - * @return transport payload representing the initial negotiation turn - */ - public Map startNegotiation(NegotiationType type, String contentText, Map facts) { - return negotiationOrchestrator.startNegotiation(type, contentText, facts); - } - - /** - * Processes a received negotiation message using its transport context payload. - * - * @param message received negotiation message - * @param context transport context payload associated with the message - * @return normalized payload describing the receive result - */ - public Map receiveNegotiation(String message, Map context) { - return negotiationOrchestrator.receiveNegotiation(message, context); - } - - /** - * Continues an existing negotiation with a locally stored context snapshot. - * - * @param context current negotiation context - * @param status next status to emit - * @param contentText continuation message content - * @return transport payload representing the next negotiation turn - */ - public Map continueNegotiation( - NegotiationContext context, NegotiationStatus status, String contentText) { - return negotiationOrchestrator.continueNegotiation(context, status, contentText); - } - - /** - * Generates a propose-phase negotiation message from typed data. - * - *

      This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the - * generator of the negotiation type addressed by the template URI and rendered from that template. - * - * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase - * segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationProposePromptFromData( - @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.generateProposeFromData(data, templateUri); - } - - /** - * Generates an accept-phase negotiation message from typed data. - * - *

      This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the - * content conclusion is not {@code Accept} - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationAcceptPromptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.generateAcceptFromData(data, templateUri); - } - - /** - * Generates a reject-phase negotiation message from typed data. - * - *

      This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the - * content conclusion is not {@code Reject} - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationRejectPromptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.generateRejectFromData(data, templateUri); - } - - /** - * Generates an abort negotiation message from typed data. - * - *

      This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed - * template must be the common abort template and the content carries only the termination reason. - * - * @param data typed abort input carrying the negotiation context and the termination reason - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI does not address the common abort template or the - * termination reason is blank - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationAbortPromptFromData( - @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.generateAbortFromData(data, templateUri); - } - - /** - * Generates a propose-phase negotiation message from free text. - * - *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. - * - * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment - * must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * content contradicts the phase - */ - public MetadataContent generateNegotiationProposePromptFromText( - @NonNull String text, - net.openan.a2at.sdk.negotiation.content.NegotiationContext context, - @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.generateProposeFromText(text, context, templateUri); - } - - /** - * Generates an accept-phase negotiation message from free text. - * - *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Accept}. - * - * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * conclusion is not {@code Accept} - */ - public MetadataContent generateNegotiationAcceptPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.negotiation.content.NegotiationContext context, - @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.generateAcceptFromText(text, context, templateUri); - } - - /** - * Generates a reject-phase negotiation message from free text. - * - *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Reject}. - * - * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * conclusion is not {@code Reject} - */ - public MetadataContent generateNegotiationRejectPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.negotiation.content.NegotiationContext context, - @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.generateRejectFromText(text, context, templateUri); - } - - /** - * Generates an abort negotiation message from free text. - * - *

      This variant runs one LLM content-extraction step constrained by the common abort template and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. - * - * @param text free-text input stating the termination reason - * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses the termination reason, or {@code negotiation_invalid_input} when the text is blank - */ - public MetadataContent generateNegotiationAbortPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.negotiation.content.NegotiationContext context, - @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.generateAbortFromText(text, context, templateUri); - } - - /** - * Lists every template available for the configured language across all A2A-T extensions. - * - *

      This query never throws: the extension directories are discovered from the bundled resource tree itself, so - * templates of extensions added later are included automatically. Templates that exist nowhere for the language are - * skipped and an empty list is returned when no template can be loaded at all. - * - * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when - * none can be loaded - */ - public List getPrompts() { - return templateQueryService.getPrompts(); - } - - /** - * Loads one template by its URI, regardless of the extension. - * - *

      This query never throws: a missing template yields an empty result together with a warning log instead of a - * failure. - * - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} or - * {@code Task-T/network-layer/energy-saving/v1} - * @return the addressed template, or an empty optional when no template exists for it in the configured language - */ - public Optional getPrompt(@NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return templateQueryService.getPrompt(templateUri); - } - - /** - * Lists every negotiation template available for the configured language. - * - *

      This query never throws: templates that exist nowhere for the language are skipped and an empty list is - * returned when no template can be loaded at all. - * - * @return loadable negotiation templates of the configured language, in a fixed type and phase order; empty when - * none can be loaded - */ - public List getNegotiationPrompts() { - return negotiationContentService.getNegotiationPrompts(); - } - - /** - * Loads one negotiation template by its URI. - * - *

      This query never throws: a missing template yields an empty result together with a warning log instead of a - * failure. - * - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} - * @return the addressed template, or an empty optional when no template exists for it in the configured language - */ - public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.getNegotiationPrompt(templateUri); - } - - /** - * Validates a propose-phase negotiation message and extracts its parameters. - * - *

      The pipeline first runs the deterministic rule gate (recognition of the negotiation context section and its - * strong constraints) before any LLM call, then performs one LLM semantic validation call that also extracts the - * parameters, and finally merges the parameters with the context parameters taking precedence. The semantic step is - * retried up to the configured attempt limit on the retryable LLM infrastructure failure code. - * - * @param prompt rendered negotiation message text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code propose} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateProposePromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.validateProposePromptAndDataFilling(prompt, schema, templateUri); - } - - /** - * Validates an accept-phase negotiation message and extracts its parameters. - * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected - * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must - * satisfy the accept-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateAcceptPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, schema, templateUri); - } - - /** - * Validates a reject-phase negotiation message and extracts its parameters. - * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected - * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must - * satisfy the reject-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateRejectPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.validateRejectPromptAndDataFilling(prompt, schema, templateUri); - } - - /** - * Validates an abort negotiation message and extracts its parameters. - * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected - * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the - * abort-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI does not address the common abort - * template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateAbortPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.validateAbortPromptAndDataFilling(prompt, schema, templateUri); - } -} +package net.openan.a2at.sdk.client; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import org.jspecify.annotations.NonNull; + +/** + * High-level client facade for prompt generation and negotiation APIs. The caller provides the `.env` file path + * explicitly, typically after copying the repository `env.example`. + * + * @since 2026-06 + */ +public final class A2ATClient { + + private final ClientPromptGenerationOrchestrator promptGenerationOrchestrator; + + private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; + + private final NegotiationContentService negotiationContentService; + + private final TemplateQueryService templateQueryService; + + /** + * Creates a client facade from one user-supplied `.env` path. + * + * @param envPath user-supplied `.env` file path + */ + public A2ATClient(Path envPath) { + Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); + A2ATConfig config = NegotiationContentService.resolvePromptResourceLocalRootDir( + A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + DefaultA2ATClientBuilder builder = + DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); + this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); + this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); + this.negotiationContentService = + new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.templateQueryService = builder.buildTemplateQueryService(); + } + + /** + * Generates a processed task prompt from raw user input. + * + * @param userInput user-provided task description or structured input map + * @return prompt generation result containing either rendered prompt text or failure details + */ + public PromptGenerationResult generateTaskPrompt(Object userInput) { + return promptGenerationOrchestrator.generateTaskPrompt(userInput); + } + + /** + * Generates a task prompt with metadata from natural-language input using the template identified by the template + * URI, bypassing scenario recognition. + * + * @param text natural-language task input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, + * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails + */ + public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(text, "text"); + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateTaskPromptFromText(text, templateUri); + } + + /** + * Generates a task prompt with metadata from structured input and a data schema using the template identified by + * the template URI, bypassing scenario recognition. + * + * @param data structured task input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, + * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails + */ + public MetadataContent generateTaskPromptFromDataWithSchema( + @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, templateUri); + } + + /** + * Generates an authorization prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + *

      Experimental: Authorization-T template resources (such as {@code authz-policy-mgr}) are bundled and + * discovered automatically, but Authorization-T slot schemas are not yet bundled, so template-driven slot + * extraction fails until Authorization-T slot resources are added or provided through the local resource root. + * + * @param text natural-language authorization input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, + * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails + */ + public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(text, "text"); + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateAuthPromptFromText(text, templateUri); + } + + /** + * Generates an authorization prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + *

      Experimental: Authorization-T template resources (such as {@code authz-policy-mgr}) are bundled and + * discovered automatically, but Authorization-T slot schemas are not yet bundled, so template-driven slot + * extraction fails until Authorization-T slot resources are added or provided through the local resource root. + * + * @param data structured authorization input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, + * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails + */ + public MetadataContent generateAuthPromptFromDataWithSchema( + @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, templateUri); + } + + /** + * Generates a notification prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + * @param text natural-language notification input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, + * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails + */ + public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(text, "text"); + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateNotificationPromptFromText(text, templateUri); + } + + /** + * Generates a notification prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + * @param data structured notification input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, + * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails + */ + public MetadataContent generateNotificationPromptFromDataWithSchema( + @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, templateUri); + } + + /** + * Starts a new negotiation payload for the requested negotiation type. + * + * @param type negotiation type to initiate + * @param contentText human-readable negotiation message + * @param facts structured facts attached to the payload + * @return transport payload representing the initial negotiation turn + */ + public Map startNegotiation(NegotiationType type, String contentText, Map facts) { + return negotiationOrchestrator.startNegotiation(type, contentText, facts); + } + + /** + * Processes a received negotiation message using its transport context payload. + * + * @param message received negotiation message + * @param context transport context payload associated with the message + * @return normalized payload describing the receive result + */ + public Map receiveNegotiation(String message, Map context) { + return negotiationOrchestrator.receiveNegotiation(message, context); + } + + /** + * Continues an existing negotiation with a locally stored context snapshot. + * + * @param context current negotiation context + * @param status next status to emit + * @param contentText continuation message content + * @return transport payload representing the next negotiation turn + */ + public Map continueNegotiation( + NegotiationContext context, NegotiationStatus status, String contentText) { + return negotiationOrchestrator.continueNegotiation(context, status, contentText); + } + + /** + * Generates a propose-phase negotiation message from typed data. + * + *

      This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the + * generator of the negotiation type addressed by the template URI and rendered from that template. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase + * segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationProposePromptFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.generateProposeFromData(data, templateUri); + } + + /** + * Generates an accept-phase negotiation message from typed data. + * + *

      This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the + * content conclusion is not {@code Accept} + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationAcceptPromptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.generateAcceptFromData(data, templateUri); + } + + /** + * Generates a reject-phase negotiation message from typed data. + * + *

      This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the + * content conclusion is not {@code Reject} + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationRejectPromptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.generateRejectFromData(data, templateUri); + } + + /** + * Generates an abort negotiation message from typed data. + * + *

      This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed + * template must be the common abort template and the content carries only the termination reason. + * + * @param data typed abort input carrying the negotiation context and the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI does not address the common abort template or the + * termination reason is blank + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationAbortPromptFromData( + @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.generateAbortFromData(data, templateUri); + } + + /** + * Generates a propose-phase negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. + * + * @param text free-text input describing the message content + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment + * must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the + * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted + * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted + * content contradicts the phase + */ + public MetadataContent generateNegotiationProposePromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.generateProposeFromText(text, context, templateUri); + } + + /** + * Generates an accept-phase negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Accept}. + * + * @param text free-text input describing the message content + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the + * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted + * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted + * conclusion is not {@code Accept} + */ + public MetadataContent generateNegotiationAcceptPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.generateAcceptFromText(text, context, templateUri); + } + + /** + * Generates a reject-phase negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Reject}. + * + * @param text free-text input describing the message content + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the + * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted + * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted + * conclusion is not {@code Reject} + */ + public MetadataContent generateNegotiationRejectPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.generateRejectFromText(text, context, templateUri); + } + + /** + * Generates an abort negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. + * + * @param text free-text input stating the termination reason + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the + * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted + * content misses the termination reason, or {@code negotiation_invalid_input} when the text is blank + */ + public MetadataContent generateNegotiationAbortPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.generateAbortFromText(text, context, templateUri); + } + + /** + * Lists every template available for the configured language across all A2A-T extensions. + * + *

      This query never throws: the extension directories are discovered from the bundled resource tree itself, so + * templates of extensions added later are included automatically. Templates that exist nowhere for the language are + * skipped and an empty list is returned when no template can be loaded at all. + * + * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when + * none can be loaded + */ + public List getPrompts() { + return templateQueryService.getPrompts(); + } + + /** + * Loads one template by its URI, regardless of the extension. + * + *

      This query never throws: a missing template yields an empty result together with a warning log instead of a + * failure. + * + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} or + * {@code Task-T/network-layer/energy-saving/v1} + * @return the addressed template, or an empty optional when no template exists for it in the configured language + */ + public Optional getPrompt(@NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return templateQueryService.getPrompt(templateUri); + } + + /** + * Lists every negotiation template available for the configured language. + * + *

      This query never throws: templates that exist nowhere for the language are skipped and an empty list is + * returned when no template can be loaded at all. + * + * @return loadable negotiation templates of the configured language, in a fixed type and phase order; empty when + * none can be loaded + */ + public List getNegotiationPrompts() { + return negotiationContentService.getNegotiationPrompts(); + } + + /** + * Loads one negotiation template by its URI. + * + *

      This query never throws: a missing template yields an empty result together with a warning log instead of a + * failure. + * + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} + * @return the addressed template, or an empty optional when no template exists for it in the configured language + */ + public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.getNegotiationPrompt(templateUri); + } + + /** + * Validates a propose-phase negotiation message and extracts its parameters. + * + *

      The pipeline first runs the deterministic rule gate (recognition of the negotiation context section and its + * strong constraints) before any LLM call, then performs one LLM semantic validation call that also extracts the + * parameters, and finally merges the parameters with the context parameters taking precedence. The semantic step is + * retried up to the configured attempt limit on the retryable LLM infrastructure failure code. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation_invalid_input} when the prompt is not a negotiation message, + * {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateProposePromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.validateProposePromptAndDataFilling(prompt, schema, templateUri); + } + + /** + * Validates an accept-phase negotiation message and extracts its parameters. + * + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must + * satisfy the accept-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation_invalid_input} when the prompt is not a negotiation message, + * {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateAcceptPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, schema, templateUri); + } + + /** + * Validates a reject-phase negotiation message and extracts its parameters. + * + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must + * satisfy the reject-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation_invalid_input} when the prompt is not a negotiation message, + * {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateRejectPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.validateRejectPromptAndDataFilling(prompt, schema, templateUri); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the + * abort-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation_invalid_input} when the prompt is not a negotiation message, + * {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateAbortPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return negotiationContentService.validateAbortPromptAndDataFilling(prompt, schema, templateUri); + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index 4d2e44e6..f4a02c67 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -1,186 +1,186 @@ -package net.openan.a2at.sdk.client.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class A2ATClientNegotiationApiTest { - - private static final String TEST_MOCK_PROVIDER = "test-negotiation-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); - } - } - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); - - @Test - void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - MetadataContent result = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("- id: " + UUID)); - assertTrue(result.promptText().contains("协商上下文")); - Map metadata = result.buildMetadataContent(); - assertEquals(2, metadata.size()); - assertEquals(result.promptText(), metadata.get(result.extensionUri())); - assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - } - - @Test - void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("en-US")); - - MetadataContent result = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("Negotiation Context")); - assertTrue(result.promptText().contains("Required Information Items")); - } - - @Test - void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { - assertEquals( - 7, new A2ATClient(writeEnv("zh-CN")).getNegotiationPrompts().size()); - assertEquals( - 7, new A2ATClient(writeEnv("en-US")).getNegotiationPrompts().size()); - } - - @Test - void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - MetadataContent result = client.generateNegotiationAbortPromptFromData( - new NegotiationAbortData( - new NegotiationContext(UUID, 5, 5), - new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - StandardTemplates.NEGOTIATION_ABORT); - - assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); - assertTrue(result.promptText().contains("## 协商结果\nAbort")); - assertTrue(result.promptText().contains("## 协商终止原因")); - assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); - assertTrue(result.promptText().contains("- round: 5")); - } - - @Test - void generatesAbortPromptFromDataWithBuiltinEnglishTemplates() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("en-US")); - - MetadataContent result = client.generateNegotiationAbortPromptFromData( - new NegotiationAbortData( - new NegotiationContext(UUID, 3, 5), - new NegotiationAbortContent("Reached the negotiation round limit. This negotiation is confirmed and ended.")), - StandardTemplates.NEGOTIATION_ABORT); - - assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); - assertTrue(result.promptText().contains("## Negotiation Result\nAbort")); - assertTrue(result.promptText().contains("## Negotiation Termination Reason")); - assertTrue(result.promptText().contains("Reached the negotiation round limit.")); - } - - @Test - void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - PromptTemplate template = - client.getNegotiationPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); - assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); - assertFalse(template.content().isBlank()); - } - - @Test - void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - assertTrue(client.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); - assertTrue(client - .getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) - .isPresent()); - assertFalse(client - .getNegotiationPrompt(TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v9")) - .isPresent()); - PromptTemplate template = client.getNegotiationPrompt(INFORMATION_PROPOSE).orElseThrow(); - assertEquals(INFORMATION_PROPOSE, template.templateUri()); - assertFalse(template.content().isBlank()); - } - - private static Path writeEnv(String language) throws IOException { - Path tempDir = Files.createTempDirectory("a2at-client-negotiation-env"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=%s - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=test-negotiation-mock - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(language)); - return envFile; - } - - public static final class RecordingClient implements LLMClient { - - private final LLMClientConfig config; - - public RecordingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse("{}", config.model(), Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sdk.client.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class A2ATClientNegotiationApiTest { + + private static final String TEST_MOCK_PROVIDER = "test-negotiation-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + @Test + void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertTrue(result.promptText().contains("- id: " + UUID)); + assertTrue(result.promptText().contains("协商上下文")); + Map metadata = result.buildMetadataContent(); + assertEquals(2, metadata.size()); + assertEquals(result.promptText(), metadata.get(result.extensionUri())); + assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + } + + @Test + void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("en-US")); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertTrue(result.promptText().contains("Negotiation Context")); + assertTrue(result.promptText().contains("Required Information Items")); + } + + @Test + void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { + assertEquals( + 7, new A2ATClient(writeEnv("zh-CN")).getNegotiationPrompts().size()); + assertEquals( + 7, new A2ATClient(writeEnv("en-US")).getNegotiationPrompts().size()); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + MetadataContent result = client.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 5, 5), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + StandardTemplates.NEGOTIATION_ABORT); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("## 协商终止原因")); + assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); + assertTrue(result.promptText().contains("- round: 5")); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinEnglishTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("en-US")); + + MetadataContent result = client.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 3, 5), + new NegotiationAbortContent("Reached the negotiation round limit. This negotiation is confirmed and ended.")), + StandardTemplates.NEGOTIATION_ABORT); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## Negotiation Result\nAbort")); + assertTrue(result.promptText().contains("## Negotiation Termination Reason")); + assertTrue(result.promptText().contains("Reached the negotiation round limit.")); + } + + @Test + void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + PromptTemplate template = + client.getNegotiationPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + @Test + void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + assertTrue(client.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); + assertTrue(client + .getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .isPresent()); + assertFalse(client + .getNegotiationPrompt(TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v9")) + .isPresent()); + PromptTemplate template = client.getNegotiationPrompt(INFORMATION_PROPOSE).orElseThrow(); + assertEquals(INFORMATION_PROPOSE, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + private static Path writeEnv(String language) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-negotiation-env"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=%s + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-negotiation-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(language)); + return envFile; + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index 455acfd0..f1a19a60 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -1,229 +1,229 @@ -package net.openan.a2at.sdk.client.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Drives the env-configured negotiation behavior of the client facade end to end. - * - *

      All three negotiation-relevant env keys must become observable in the facade behavior: the language selects the - * Chinese templates, the local resource root overrides one template, and the LLM attempt limit bounds the retry loop of - * the from-text generation. A second test proves the zero-configuration defaults (English templates, three attempts, - * built-in resources) work out of the box. - */ -class A2ATClientNegotiationEnvConfigTest { - - private static final String TEST_MOCK_PROVIDER = "test-negotiation-failing-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, FailingClient.class); - } - } - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); - - private static final String CUSTOM_TEMPLATE_MARKER = "CUSTOM-TEMPLATE-MARKER-7d31"; - - @TempDir - Path tempDir; - - private Logger rootLogger; - - private ListAppender appender; - - @BeforeEach - void attachAppender() { - rootLogger = - (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); - appender = new ListAppender<>(); - appender.start(); - rootLogger.addAppender(appender); - } - - @AfterEach - void detachAppender() { - rootLogger.detachAppender(appender); - appender.stop(); - } - - @Test - void languageLocalRootAndMaxAttemptsAreAllObservableInFacadeBehavior() throws IOException { - Path customRoot = writeCustomRootWithMarkeredTemplate(); - Path envFile = writeEnvFile( - "A2AT_LANGUAGE=zh-CN", - "A2AT_PROMPT_SOURCE_TYPE=classpath", - "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=" + customRoot.toString().replace('\\', '/'), - "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, - "A2AT_LLM_MODEL=example-model", - "A2AT_LLM_BASE_URL=https://llm.example.test/v1", - "A2AT_LLM_API_KEY=test-key", - "A2AT_LLM_MAX_ATTEMPTS=2", - "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory"); - A2ATClient client = new A2ATClient(envFile); - - MetadataContent result = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); - - assertTrue(result.promptText().contains("协商上下文"), "the zh-CN language must select the Chinese templates"); - assertTrue( - result.promptText().contains(CUSTOM_TEMPLATE_MARKER), - "the local resource root template must win over the built-in template"); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> client.generateNegotiationProposePromptFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE)); - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); - assertEquals( - 1, - retryEventCount(), - "A2AT_LLM_MAX_ATTEMPTS=2 must allow exactly one retry before surfacing the failure"); - assertEquals(1, exhaustedEventCount(), "the retry loop must report exhaustion exactly once"); - } - - @Test - void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { - Path envFile = writeEnvFile( - "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, - "A2AT_LLM_MODEL=example-model", - "A2AT_LLM_BASE_URL=https://llm.example.test/v1", - "A2AT_LLM_API_KEY=test-key"); - A2ATClient client = new A2ATClient(envFile); - - MetadataContent result = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE); - - assertTrue(result.promptText().contains("Negotiation Context"), "the default language must be en-US"); - assertTrue(result.promptText().contains("Required Information Items")); - assertEquals(7, client.getNegotiationPrompts().size(), "the built-in resources must be used by default"); - assertTrue(client.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); - - assertThrows( - NegotiationGenerationException.class, - () -> client.generateNegotiationProposePromptFromText( - "Provide the energy-saving region.", - new NegotiationContext(UUID, 1, 5), - INFORMATION_PROPOSE)); - assertEquals( - 2, - retryEventCount(), - "the default attempt limit of 3 must allow exactly two retries before surfacing the failure"); - assertEquals(1, exhaustedEventCount()); - } - - private Path writeEnvFile(String... lines) throws IOException { - Path envFile = tempDir.resolve("client.env"); - Files.writeString(envFile, String.join("\n", lines) + "\n", StandardCharsets.UTF_8); - return envFile; - } - - private Path writeCustomRootWithMarkeredTemplate() throws IOException { - Path customTemplate = tempDir.resolve("custom-root") - .resolve("templates") - .resolve("Negotiation-T") - .resolve("information-negotiation") - .resolve("propose") - .resolve("v1") - .resolve("zh-CN") - .resolve("template.md"); - Files.createDirectories(customTemplate.getParent()); - Files.writeString( - customTemplate, - builtinTemplate(INFORMATION_PROPOSE_URI, "zh-CN") + "\n\n## 自定义标记\n" + CUSTOM_TEMPLATE_MARKER + "\n", - StandardCharsets.UTF_8); - return tempDir.resolve("custom-root"); - } - - private static String builtinTemplate(String templateUri, String language) throws IOException { - String[] segments = templateUri.split("/"); - String typeSegment = segments[1]; - String phaseSegment = segments[2]; - String classpathPath = "prompt_resources/templates/Negotiation-T/" + typeSegment + "/" + phaseSegment + "/" - + TemplateUri.DEFAULT_TEMPLATE_VERSION + "/" + language + "/template.md"; - InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(classpathPath); - assertFalse(stream == null, "the built-in template must exist on the classpath: " + classpathPath); - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } - } - - private long retryEventCount() { - return countEvents("negotiation_llm_retry"); - } - - private long exhaustedEventCount() { - return countEvents("negotiation_llm_retry_exhausted"); - } - - private long countEvents(String eventName) { - return appender.list.stream() - .filter(event -> event.getLevel() == Level.WARN) - .map(ILoggingEvent::getFormattedMessage) - .filter(message -> message.startsWith(eventName + " ")) - .count(); - } - - public static final class FailingClient implements LLMClient { - - private final LLMClientConfig config; - - public FailingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - throw new LLMRuntimeError("test mock failure"); - } - } -} +package net.openan.a2at.sdk.client.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Drives the env-configured negotiation behavior of the client facade end to end. + * + *

      All three negotiation-relevant env keys must become observable in the facade behavior: the language selects the + * Chinese templates, the local resource root overrides one template, and the LLM attempt limit bounds the retry loop of + * the from-text generation. A second test proves the zero-configuration defaults (English templates, three attempts, + * built-in resources) work out of the box. + */ +class A2ATClientNegotiationEnvConfigTest { + + private static final String TEST_MOCK_PROVIDER = "test-negotiation-failing-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, FailingClient.class); + } + } + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + private static final String CUSTOM_TEMPLATE_MARKER = "CUSTOM-TEMPLATE-MARKER-7d31"; + + @TempDir + Path tempDir; + + private Logger rootLogger; + + private ListAppender appender; + + @BeforeEach + void attachAppender() { + rootLogger = + (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + appender = new ListAppender<>(); + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + rootLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void languageLocalRootAndMaxAttemptsAreAllObservableInFacadeBehavior() throws IOException { + Path customRoot = writeCustomRootWithMarkeredTemplate(); + Path envFile = writeEnvFile( + "A2AT_LANGUAGE=zh-CN", + "A2AT_PROMPT_SOURCE_TYPE=classpath", + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=" + customRoot.toString().replace('\\', '/'), + "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, + "A2AT_LLM_MODEL=example-model", + "A2AT_LLM_BASE_URL=https://llm.example.test/v1", + "A2AT_LLM_API_KEY=test-key", + "A2AT_LLM_MAX_ATTEMPTS=2", + "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory"); + A2ATClient client = new A2ATClient(envFile); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + assertTrue(result.promptText().contains("协商上下文"), "the zh-CN language must select the Chinese templates"); + assertTrue( + result.promptText().contains(CUSTOM_TEMPLATE_MARKER), + "the local resource root template must win over the built-in template"); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> client.generateNegotiationProposePromptFromText( + "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE)); + assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); + assertEquals( + 1, + retryEventCount(), + "A2AT_LLM_MAX_ATTEMPTS=2 must allow exactly one retry before surfacing the failure"); + assertEquals(1, exhaustedEventCount(), "the retry loop must report exhaustion exactly once"); + } + + @Test + void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { + Path envFile = writeEnvFile( + "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, + "A2AT_LLM_MODEL=example-model", + "A2AT_LLM_BASE_URL=https://llm.example.test/v1", + "A2AT_LLM_API_KEY=test-key"); + A2ATClient client = new A2ATClient(envFile); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE); + + assertTrue(result.promptText().contains("Negotiation Context"), "the default language must be en-US"); + assertTrue(result.promptText().contains("Required Information Items")); + assertEquals(7, client.getNegotiationPrompts().size(), "the built-in resources must be used by default"); + assertTrue(client.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); + + assertThrows( + NegotiationGenerationException.class, + () -> client.generateNegotiationProposePromptFromText( + "Provide the energy-saving region.", + new NegotiationContext(UUID, 1, 5), + INFORMATION_PROPOSE)); + assertEquals( + 2, + retryEventCount(), + "the default attempt limit of 3 must allow exactly two retries before surfacing the failure"); + assertEquals(1, exhaustedEventCount()); + } + + private Path writeEnvFile(String... lines) throws IOException { + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, String.join("\n", lines) + "\n", StandardCharsets.UTF_8); + return envFile; + } + + private Path writeCustomRootWithMarkeredTemplate() throws IOException { + Path customTemplate = tempDir.resolve("custom-root") + .resolve("templates") + .resolve("Negotiation-T") + .resolve("information-negotiation") + .resolve("propose") + .resolve("v1") + .resolve("zh-CN") + .resolve("template.md"); + Files.createDirectories(customTemplate.getParent()); + Files.writeString( + customTemplate, + builtinTemplate(INFORMATION_PROPOSE_URI, "zh-CN") + "\n\n## 自定义标记\n" + CUSTOM_TEMPLATE_MARKER + "\n", + StandardCharsets.UTF_8); + return tempDir.resolve("custom-root"); + } + + private static String builtinTemplate(String templateUri, String language) throws IOException { + String[] segments = templateUri.split("/"); + String typeSegment = segments[1]; + String phaseSegment = segments[2]; + String classpathPath = "prompt_resources/templates/Negotiation-T/" + typeSegment + "/" + phaseSegment + "/" + + TemplateUri.DEFAULT_TEMPLATE_VERSION + "/" + language + "/template.md"; + InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(classpathPath); + assertFalse(stream == null, "the built-in template must exist on the classpath: " + classpathPath); + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private long retryEventCount() { + return countEvents("negotiation_llm_retry"); + } + + private long exhaustedEventCount() { + return countEvents("negotiation_llm_retry_exhausted"); + } + + private long countEvents(String eventName) { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.startsWith(eventName + " ")) + .count(); + } + + public static final class FailingClient implements LLMClient { + + private final LLMClientConfig config; + + public FailingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new LLMRuntimeError("test mock failure"); + } + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContext.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/NegotiationContext.java similarity index 95% rename from a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContext.java rename to a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/NegotiationContext.java index 24a1a4cd..c43f6891 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContext.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/NegotiationContext.java @@ -1,68 +1,68 @@ -package net.openan.a2at.sdk.negotiation.content; - -/** - * Session context carried by every negotiation message. - * - *

      The context identifies one negotiation session, tracks the current round, and bounds how many rounds the session - * may run. A context is immutable; advancing to the next round produces a new instance. - * - * @param id unique identifier of the negotiation session, expected to be a UUID - * @param round current negotiation round, 1-based - * @param maxRounds maximum number of rounds before the negotiation must end - * @since 2026-08 - */ -public record NegotiationContext(String id, int round, int maxRounds) { - - /** Default round budget applied when a caller does not specify one. */ - public static final int DEFAULT_MAX_ROUNDS = 5; - - /** - * Validates the context fields. - * - * @throws IllegalArgumentException if the id is blank, the round is below 1, or maxRounds is below 1 - */ - public NegotiationContext { - if (id == null || id.isBlank()) { - throw new IllegalArgumentException("Negotiation context id must not be blank."); - } - if (round < 1) { - throw new IllegalArgumentException( - "Negotiation context round must be a positive integer but was " + round + "."); - } - if (maxRounds < 1) { - throw new IllegalArgumentException( - "Negotiation context maxRounds must be a positive integer but was " + maxRounds + "."); - } - } - - /** - * Creates a context using the default round budget. - * - * @param id unique identifier of the negotiation session - * @param round current negotiation round, 1-based - * @return new negotiation context with {@link #DEFAULT_MAX_ROUNDS} as the round budget - */ - public static NegotiationContext of(String id, int round) { - return new NegotiationContext(id, round, DEFAULT_MAX_ROUNDS); - } - - /** - * Returns the context for the next round, leaving this context unchanged. - * - * @return new negotiation context whose round is one greater than the current round - */ - public NegotiationContext nextRound() { - return new NegotiationContext(id, round + 1, maxRounds); - } - - /** - * Reports whether the round budget has been exceeded. - * - *

      A context whose round equals maxRounds is not exhausted yet; only rounds strictly beyond the budget are. - * - * @return {@code true} if the current round is greater than maxRounds - */ - public boolean isExhausted() { - return round > maxRounds; - } -} +package net.openan.a2at.sdk.core.model; + +/** + * Session context carried by every negotiation message. + * + *

      The context identifies one negotiation session, tracks the current round, and bounds how many rounds the session + * may run. A context is immutable; advancing to the next round produces a new instance. + * + * @param id unique identifier of the negotiation session, expected to be a UUID + * @param round current negotiation round, 1-based + * @param maxRounds maximum number of rounds before the negotiation must end + * @since 2026-08 + */ +public record NegotiationContext(String id, int round, int maxRounds) { + + /** Default round budget applied when a caller does not specify one. */ + public static final int DEFAULT_MAX_ROUNDS = 5; + + /** + * Validates the context fields. + * + * @throws IllegalArgumentException if the id is blank, the round is below 1, or maxRounds is below 1 + */ + public NegotiationContext { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("Negotiation context id must not be blank."); + } + if (round < 1) { + throw new IllegalArgumentException( + "Negotiation context round must be a positive integer but was " + round + "."); + } + if (maxRounds < 1) { + throw new IllegalArgumentException( + "Negotiation context maxRounds must be a positive integer but was " + maxRounds + "."); + } + } + + /** + * Creates a context using the default round budget. + * + * @param id unique identifier of the negotiation session + * @param round current negotiation round, 1-based + * @return new negotiation context with {@link #DEFAULT_MAX_ROUNDS} as the round budget + */ + public static NegotiationContext of(String id, int round) { + return new NegotiationContext(id, round, DEFAULT_MAX_ROUNDS); + } + + /** + * Returns the context for the next round, leaving this context unchanged. + * + * @return new negotiation context whose round is one greater than the current round + */ + public NegotiationContext nextRound() { + return new NegotiationContext(id, round + 1, maxRounds); + } + + /** + * Reports whether the round budget has been exceeded. + * + *

      A context whose round equals maxRounds is not exhausted yet; only rounds strictly beyond the budget are. + * + * @return {@code true} if the current round is greater than maxRounds + */ + public boolean isExhausted() { + return round > maxRounds; + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationContextTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/NegotiationContextTest.java similarity index 96% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationContextTest.java rename to a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/NegotiationContextTest.java index fcecff29..57a3dfa9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationContextTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/NegotiationContextTest.java @@ -1,102 +1,102 @@ -package net.openan.a2at.sdk.negotiation.content; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -class NegotiationContextTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - @Test - void constructionAcceptsLegalContext() { - NegotiationContext context = new NegotiationContext(SESSION_ID, 1, 5); - - assertEquals(SESSION_ID, context.id()); - assertEquals(1, context.round()); - assertEquals(5, context.maxRounds()); - } - - @Test - void blankIdThrowsWithDistinguishableMessage() { - IllegalArgumentException blankException = - assertThrows(IllegalArgumentException.class, () -> new NegotiationContext("", 1, 5)); - IllegalArgumentException whitespaceException = - assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(" ", 1, 5)); - IllegalArgumentException nullException = - assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(null, 1, 5)); - - assertTrue(blankException.getMessage().contains("id")); - assertTrue(blankException.getMessage().contains("blank")); - assertEquals(blankException.getMessage(), whitespaceException.getMessage()); - assertEquals(blankException.getMessage(), nullException.getMessage()); - } - - @Test - void zeroRoundThrowsWithDistinguishableMessage() { - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(SESSION_ID, 0, 5)); - - assertTrue(exception.getMessage().contains("round")); - assertTrue(exception.getMessage().contains("0")); - } - - @Test - void negativeRoundThrowsWithDistinguishableMessage() { - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(SESSION_ID, -1, 5)); - - assertTrue(exception.getMessage().contains("round")); - assertTrue(exception.getMessage().contains("-1")); - } - - @Test - void zeroMaxRoundsThrowsWithDistinguishableMessage() { - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(SESSION_ID, 1, 0)); - - assertTrue(exception.getMessage().contains("maxRounds")); - assertTrue(exception.getMessage().contains("0")); - } - - @Test - void roundGreaterThanMaxRoundsIsAllowedAtConstruction() { - NegotiationContext context = new NegotiationContext(SESSION_ID, 6, 5); - - assertEquals(6, context.round()); - assertTrue(context.isExhausted()); - } - - @Test - void nextRoundReturnsNewInstanceAndLeavesOriginalUnchanged() { - NegotiationContext context = new NegotiationContext(SESSION_ID, 1, 5); - NegotiationContext advanced = context.nextRound(); - - assertEquals(2, advanced.round()); - assertEquals(SESSION_ID, advanced.id()); - assertEquals(5, advanced.maxRounds()); - assertEquals(1, context.round()); - assertNotEquals(context, advanced); - } - - @Test - void isExhaustedIsFalseAtBoundaryAndTrueBeyondIt() { - assertFalse(new NegotiationContext(SESSION_ID, 4, 5).isExhausted()); - assertFalse(new NegotiationContext(SESSION_ID, 5, 5).isExhausted()); - assertTrue(new NegotiationContext(SESSION_ID, 6, 5).isExhausted()); - } - - @Test - void factoryAppliesDefaultMaxRounds() { - NegotiationContext context = NegotiationContext.of(SESSION_ID, 2); - - assertEquals(5, NegotiationContext.DEFAULT_MAX_ROUNDS); - assertEquals(5, context.maxRounds()); - assertEquals(2, context.round()); - assertEquals(SESSION_ID, context.id()); - } -} +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class NegotiationContextTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + @Test + void constructionAcceptsLegalContext() { + NegotiationContext context = new NegotiationContext(SESSION_ID, 1, 5); + + assertEquals(SESSION_ID, context.id()); + assertEquals(1, context.round()); + assertEquals(5, context.maxRounds()); + } + + @Test + void blankIdThrowsWithDistinguishableMessage() { + IllegalArgumentException blankException = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext("", 1, 5)); + IllegalArgumentException whitespaceException = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(" ", 1, 5)); + IllegalArgumentException nullException = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(null, 1, 5)); + + assertTrue(blankException.getMessage().contains("id")); + assertTrue(blankException.getMessage().contains("blank")); + assertEquals(blankException.getMessage(), whitespaceException.getMessage()); + assertEquals(blankException.getMessage(), nullException.getMessage()); + } + + @Test + void zeroRoundThrowsWithDistinguishableMessage() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(SESSION_ID, 0, 5)); + + assertTrue(exception.getMessage().contains("round")); + assertTrue(exception.getMessage().contains("0")); + } + + @Test + void negativeRoundThrowsWithDistinguishableMessage() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(SESSION_ID, -1, 5)); + + assertTrue(exception.getMessage().contains("round")); + assertTrue(exception.getMessage().contains("-1")); + } + + @Test + void zeroMaxRoundsThrowsWithDistinguishableMessage() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(SESSION_ID, 1, 0)); + + assertTrue(exception.getMessage().contains("maxRounds")); + assertTrue(exception.getMessage().contains("0")); + } + + @Test + void roundGreaterThanMaxRoundsIsAllowedAtConstruction() { + NegotiationContext context = new NegotiationContext(SESSION_ID, 6, 5); + + assertEquals(6, context.round()); + assertTrue(context.isExhausted()); + } + + @Test + void nextRoundReturnsNewInstanceAndLeavesOriginalUnchanged() { + NegotiationContext context = new NegotiationContext(SESSION_ID, 1, 5); + NegotiationContext advanced = context.nextRound(); + + assertEquals(2, advanced.round()); + assertEquals(SESSION_ID, advanced.id()); + assertEquals(5, advanced.maxRounds()); + assertEquals(1, context.round()); + assertNotEquals(context, advanced); + } + + @Test + void isExhaustedIsFalseAtBoundaryAndTrueBeyondIt() { + assertFalse(new NegotiationContext(SESSION_ID, 4, 5).isExhausted()); + assertFalse(new NegotiationContext(SESSION_ID, 5, 5).isExhausted()); + assertTrue(new NegotiationContext(SESSION_ID, 6, 5).isExhausted()); + } + + @Test + void factoryAppliesDefaultMaxRounds() { + NegotiationContext context = NegotiationContext.of(SESSION_ID, 2); + + assertEquals(5, NegotiationContext.DEFAULT_MAX_ROUNDS); + assertEquals(5, context.maxRounds()); + assertEquals(2, context.round()); + assertEquals(SESSION_ID, context.id()); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortData.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortData.java index 74be8b8c..dc727b4f 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortData.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortData.java @@ -1,5 +1,7 @@ package net.openan.a2at.sdk.negotiation.content; +import net.openan.a2at.sdk.core.model.NegotiationContext; + /** * Input bundle for generating an abort negotiation message from typed data. * diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingData.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingData.java index f5976b28..d7b0afce 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingData.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingData.java @@ -1,15 +1,17 @@ -package net.openan.a2at.sdk.negotiation.content; - -/** - * Input bundle for generating a terminal (accept or reject) negotiation message from typed data. - * - *

      Both accept and reject generation methods take this same bundle. The conclusion constraint is carried by the - * method, not the type: {@code generateAcceptFromData} requires {@code content.conclusion() == ACCEPT} and - * {@code generateRejectFromData} requires {@code REJECT}; generation methods enforce it and reject a mismatched - * conclusion (including {@code ABORT}) as a content error. - * - * @param context negotiation session context - * @param content typed terminal content matching the negotiation type addressed by the template URI - * @since 2026-08 - */ -public record NegotiationEndingData(NegotiationContext context, NegotiationEndingContent content) {} +package net.openan.a2at.sdk.negotiation.content; + +import net.openan.a2at.sdk.core.model.NegotiationContext; + +/** + * Input bundle for generating a terminal (accept or reject) negotiation message from typed data. + * + *

      Both accept and reject generation methods take this same bundle. The conclusion constraint is carried by the + * method, not the type: {@code generateAcceptFromData} requires {@code content.conclusion() == ACCEPT} and + * {@code generateRejectFromData} requires {@code REJECT}; generation methods enforce it and reject a mismatched + * conclusion (including {@code ABORT}) as a content error. + * + * @param context negotiation session context + * @param content typed terminal content matching the negotiation type addressed by the template URI + * @since 2026-08 + */ +public record NegotiationEndingData(NegotiationContext context, NegotiationEndingContent content) {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeData.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeData.java index 2a8fe810..630db9e3 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeData.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeData.java @@ -1,5 +1,7 @@ package net.openan.a2at.sdk.negotiation.content; +import net.openan.a2at.sdk.core.model.NegotiationContext; + /** * Input bundle for generating a propose-phase negotiation message from typed data. * diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java index 80e8c646..d411118d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java @@ -4,7 +4,7 @@ import java.util.Map; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.core.model.PromptTemplate; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java index 77ba634d..9d4424db 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java @@ -1,134 +1,134 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Shared plumbing of the six negotiation generators. - * - *

      Subclasses validate their exact content type, assemble the slot map and hand over to the shared renderers. The - * slot map is keyed by the language-specific slot names taken from the vocabulary. - */ -abstract class AbstractNegotiationGenerator implements NegotiationGenerator { - - private final NegotiationPromptRenderer promptRenderer = new NegotiationPromptRenderer(); - - private final NegotiationContextRenderer contextRenderer = new NegotiationContextRenderer(); - - private final NegotiationItemFormatter itemFormatter = new NegotiationItemFormatter(); - - /** - * Casts the content to the exact type this generator serves. - * - * @param expected content type - * @param content content object validated by the registry - * @param expectedType exact content class this generator serves - * @param generatorName human-readable generator name used in the failure message - * @return content cast to the expected type - * @throws IllegalArgumentException if the content is null or of another runtime type - */ - protected static T contentOf( - NegotiationContent content, Class expectedType, String generatorName) { - if (content == null || content.getClass() != expectedType) { - throw new IllegalArgumentException( - generatorName + " requires content of type " + expectedType.getSimpleName() + " but received " - + (content == null ? "null" : content.getClass().getSimpleName()) + "."); - } - return expectedType.cast(content); - } - - /** - * Validates that a conclusion is renderable by a typed ending generator, rejecting null and the abort conclusion. - * - * @param conclusion conclusion carried by ending content - * @return the validated conclusion - * @throws NullPointerException if the conclusion is null - * @throws IllegalArgumentException if the conclusion is abort; the abort message has no typed conclusion slot and - * is generated through the abort phase against the common abort template instead - */ - protected static NegotiationConclusion renderableConclusion(NegotiationConclusion conclusion) { - Objects.requireNonNull( - conclusion, "Negotiation conclusion must not be null; accept and reject are the renderable" - + " conclusions of a typed negotiation."); - if (conclusion == NegotiationConclusion.ABORT) { - throw new IllegalArgumentException( - "Typed negotiation templates carry no Abort conclusion slot; generate the abort message of a" - + " terminated negotiation with generateAbortFromData against the common abort template" - + " instead."); - } - return conclusion; - } - - /** - * Validates that a required text field is present. - * - * @param value field value - * @param field field path used in the failure - * @param description field description used in the failure message - * @return the validated text - * @throws IllegalArgumentException if the value is null or blank - */ - protected static String requiredText(String value, String field, String description) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException(description + " must not be blank."); - } - return value; - } - - /** - * Validates that a required item list is present and non-empty. - * - * @param items item list - * @param field field path used in the failure - * @param description field description used in the failure message - * @return the validated item list - * @throws IllegalArgumentException if the list is null or empty - */ - protected static List requiredItems( - List items, String field, String description) { - if (items == null || items.isEmpty()) { - throw new IllegalArgumentException(description + " must contain at least one item."); - } - return items; - } - - /** - * Builds the slot value for the negotiation context section. - * - * @param context negotiation context of the message - * @param vocabulary vocabulary of the message language - * @return rendered context list - */ - protected String contextSlotValue(NegotiationContext context, Vocabulary vocabulary) { - return contextRenderer.render(context); - } - - /** - * Formats an item list with the list punctuation of the message language. - * - * @param items items to format; null formats as an empty value - * @param vocabulary vocabulary of the message language - * @return formatted numbered lines, or an empty string when there is no item - */ - protected String formatItems(List items, Vocabulary vocabulary) { - return itemFormatter.format(items, vocabulary.get("punct.list_colon")); - } - - /** - * Renders the template with the assembled slots. - * - * @param template loaded template to render - * @param slots slot values keyed by language-specific slot names - * @return rendered negotiation message text - */ - protected String render(PromptTemplate template, Map slots) { - return promptRenderer.render(template.content(), slots); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.core.model.PromptTemplate; + +/** + * Shared plumbing of the six negotiation generators. + * + *

      Subclasses validate their exact content type, assemble the slot map and hand over to the shared renderers. The + * slot map is keyed by the language-specific slot names taken from the vocabulary. + */ +abstract class AbstractNegotiationGenerator implements NegotiationGenerator { + + private final NegotiationPromptRenderer promptRenderer = new NegotiationPromptRenderer(); + + private final NegotiationContextRenderer contextRenderer = new NegotiationContextRenderer(); + + private final NegotiationItemFormatter itemFormatter = new NegotiationItemFormatter(); + + /** + * Casts the content to the exact type this generator serves. + * + * @param expected content type + * @param content content object validated by the registry + * @param expectedType exact content class this generator serves + * @param generatorName human-readable generator name used in the failure message + * @return content cast to the expected type + * @throws IllegalArgumentException if the content is null or of another runtime type + */ + protected static T contentOf( + NegotiationContent content, Class expectedType, String generatorName) { + if (content == null || content.getClass() != expectedType) { + throw new IllegalArgumentException( + generatorName + " requires content of type " + expectedType.getSimpleName() + " but received " + + (content == null ? "null" : content.getClass().getSimpleName()) + "."); + } + return expectedType.cast(content); + } + + /** + * Validates that a conclusion is renderable by a typed ending generator, rejecting null and the abort conclusion. + * + * @param conclusion conclusion carried by ending content + * @return the validated conclusion + * @throws NullPointerException if the conclusion is null + * @throws IllegalArgumentException if the conclusion is abort; the abort message has no typed conclusion slot and + * is generated through the abort phase against the common abort template instead + */ + protected static NegotiationConclusion renderableConclusion(NegotiationConclusion conclusion) { + Objects.requireNonNull( + conclusion, "Negotiation conclusion must not be null; accept and reject are the renderable" + + " conclusions of a typed negotiation."); + if (conclusion == NegotiationConclusion.ABORT) { + throw new IllegalArgumentException( + "Typed negotiation templates carry no Abort conclusion slot; generate the abort message of a" + + " terminated negotiation with generateAbortFromData against the common abort template" + + " instead."); + } + return conclusion; + } + + /** + * Validates that a required text field is present. + * + * @param value field value + * @param field field path used in the failure + * @param description field description used in the failure message + * @return the validated text + * @throws IllegalArgumentException if the value is null or blank + */ + protected static String requiredText(String value, String field, String description) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(description + " must not be blank."); + } + return value; + } + + /** + * Validates that a required item list is present and non-empty. + * + * @param items item list + * @param field field path used in the failure + * @param description field description used in the failure message + * @return the validated item list + * @throws IllegalArgumentException if the list is null or empty + */ + protected static List requiredItems( + List items, String field, String description) { + if (items == null || items.isEmpty()) { + throw new IllegalArgumentException(description + " must contain at least one item."); + } + return items; + } + + /** + * Builds the slot value for the negotiation context section. + * + * @param context negotiation context of the message + * @param vocabulary vocabulary of the message language + * @return rendered context list + */ + protected String contextSlotValue(NegotiationContext context, Vocabulary vocabulary) { + return contextRenderer.render(context); + } + + /** + * Formats an item list with the list punctuation of the message language. + * + * @param items items to format; null formats as an empty value + * @param vocabulary vocabulary of the message language + * @return formatted numbered lines, or an empty string when there is no item + */ + protected String formatItems(List items, Vocabulary vocabulary) { + return itemFormatter.format(items, vocabulary.get("punct.list_colon")); + } + + /** + * Renders the template with the assembled slots. + * + * @param template loaded template to render + * @param slots slot values keyed by language-specific slot names + * @return rendered negotiation message text + */ + protected String render(PromptTemplate template, Map slots) { + return promptRenderer.render(template.content(), slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java index 695c7a2c..6744d5de 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java @@ -1,48 +1,48 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Generator for feasibility negotiation terminal messages. - * - *

      The message carries the terminal conclusion literal and the required feasibility summary. The summary slot name - * differs from its section title, so the vocabulary exception key is used. - * - * @since 2026-08 - */ -final class FeasibilityEndingGenerator extends AbstractNegotiationGenerator { - - /** - * Generates a feasibility negotiation accept or reject message. - * - * @param context negotiation context of the message - * @param content feasibility ending content - * @param template feasibility ending template to render - * @param vocabulary vocabulary of the message language - * @return rendered feasibility terminal message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - FeasibilityEndingContent endingContent = - contentOf(content, FeasibilityEndingContent.class, "Feasibility ending generator"); - renderableConclusion(endingContent.conclusion()); - requiredText( - endingContent.feasibilitySummary(), - "content.feasibilitySummary", - "Feasibility summary of a terminal feasibility negotiation message"); - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); - slots.put( - vocabulary.get("slot.feasibility_conclusion"), - endingContent.conclusion().literal()); - slots.put(vocabulary.get("slot.feasibility_confirm"), endingContent.feasibilitySummary()); - return render(template, slots); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.core.model.PromptTemplate; + +/** + * Generator for feasibility negotiation terminal messages. + * + *

      The message carries the terminal conclusion literal and the required feasibility summary. The summary slot name + * differs from its section title, so the vocabulary exception key is used. + * + * @since 2026-08 + */ +final class FeasibilityEndingGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a feasibility negotiation accept or reject message. + * + * @param context negotiation context of the message + * @param content feasibility ending content + * @param template feasibility ending template to render + * @param vocabulary vocabulary of the message language + * @return rendered feasibility terminal message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + FeasibilityEndingContent endingContent = + contentOf(content, FeasibilityEndingContent.class, "Feasibility ending generator"); + renderableConclusion(endingContent.conclusion()); + requiredText( + endingContent.feasibilitySummary(), + "content.feasibilitySummary", + "Feasibility summary of a terminal feasibility negotiation message"); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); + slots.put( + vocabulary.get("slot.feasibility_conclusion"), + endingContent.conclusion().literal()); + slots.put(vocabulary.get("slot.feasibility_confirm"), endingContent.feasibilitySummary()); + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java index 5bb78cbf..536e2b31 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java @@ -1,70 +1,70 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Generator for feasibility negotiation propose messages. - * - *

      The action selects which conditional section is rendered: requesting a feasibility evaluation renders the contents - * to evaluate, while proposing an alternative after an infeasible outcome renders the infeasibility details and - * proposal. Exactly one of the two sections is always present. - * - * @since 2026-08 - */ -final class FeasibilityProposeGenerator extends AbstractNegotiationGenerator { - - /** - * Generates a feasibility negotiation propose message. - * - * @param context negotiation context of the message - * @param content feasibility propose content - * @param template feasibility propose template to render - * @param vocabulary vocabulary of the message language - * @return rendered feasibility propose message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - FeasibilityProposeContent proposeContent = - contentOf(content, FeasibilityProposeContent.class, "Feasibility propose generator"); - requiredText( - proposeContent.feasibilityNegotiationDescription(), - "content.feasibilityNegotiationDescription", - "Feasibility negotiation description"); - NegotiationAction action = proposeContent.action(); - Objects.requireNonNull( - action, - "Feasibility negotiation action must not be null; it selects the conditional sections of the message."); - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); - slots.put(vocabulary.get("slot.feasibility"), proposeContent.feasibilityNegotiationDescription()); - if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { - slots.put( - vocabulary.get("slot.feasibility_evaluate"), - formatItems( - requiredItems( - proposeContent.contentsToEvaluate(), - "content.contentsToEvaluate", - "Contents to evaluate of a feasibility evaluation request"), - vocabulary)); - } else { - slots.put( - vocabulary.get("slot.feasibility_infeasible"), - formatItems( - requiredItems( - proposeContent.infeasibilityDetailsAndProposal(), - "content.infeasibilityDetailsAndProposal", - "Infeasibility details and proposal of an alternative proposal"), - vocabulary)); - } - return render(template, slots); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.core.model.PromptTemplate; + +/** + * Generator for feasibility negotiation propose messages. + * + *

      The action selects which conditional section is rendered: requesting a feasibility evaluation renders the contents + * to evaluate, while proposing an alternative after an infeasible outcome renders the infeasibility details and + * proposal. Exactly one of the two sections is always present. + * + * @since 2026-08 + */ +final class FeasibilityProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a feasibility negotiation propose message. + * + * @param context negotiation context of the message + * @param content feasibility propose content + * @param template feasibility propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered feasibility propose message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + FeasibilityProposeContent proposeContent = + contentOf(content, FeasibilityProposeContent.class, "Feasibility propose generator"); + requiredText( + proposeContent.feasibilityNegotiationDescription(), + "content.feasibilityNegotiationDescription", + "Feasibility negotiation description"); + NegotiationAction action = proposeContent.action(); + Objects.requireNonNull( + action, + "Feasibility negotiation action must not be null; it selects the conditional sections of the message."); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); + slots.put(vocabulary.get("slot.feasibility"), proposeContent.feasibilityNegotiationDescription()); + if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + slots.put( + vocabulary.get("slot.feasibility_evaluate"), + formatItems( + requiredItems( + proposeContent.contentsToEvaluate(), + "content.contentsToEvaluate", + "Contents to evaluate of a feasibility evaluation request"), + vocabulary)); + } else { + slots.put( + vocabulary.get("slot.feasibility_infeasible"), + formatItems( + requiredItems( + proposeContent.infeasibilityDetailsAndProposal(), + "content.infeasibilityDetailsAndProposal", + "Infeasibility details and proposal of an alternative proposal"), + vocabulary)); + } + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java index 8d25542c..82dc3f71 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java @@ -1,42 +1,42 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Generator for information negotiation terminal messages. - * - *

      The message carries the terminal conclusion literal and the information items delivered with it. - * - * @since 2026-08 - */ -final class InformationEndingGenerator extends AbstractNegotiationGenerator { - - /** - * Generates an information negotiation accept or reject message. - * - * @param context negotiation context of the message - * @param content information ending content - * @param template information ending template to render - * @param vocabulary vocabulary of the message language - * @return rendered information terminal message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - InformationEndingContent endingContent = contentOf(content, InformationEndingContent.class, "Information ending generator"); - renderableConclusion(endingContent.conclusion()); - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); - slots.put( - vocabulary.get("slot.info_conclusion"), - endingContent.conclusion().literal()); - slots.put(vocabulary.get("slot.info_result_content"), formatItems(endingContent.items(), vocabulary)); - return render(template, slots); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.core.model.PromptTemplate; + +/** + * Generator for information negotiation terminal messages. + * + *

      The message carries the terminal conclusion literal and the information items delivered with it. + * + * @since 2026-08 + */ +final class InformationEndingGenerator extends AbstractNegotiationGenerator { + + /** + * Generates an information negotiation accept or reject message. + * + * @param context negotiation context of the message + * @param content information ending content + * @param template information ending template to render + * @param vocabulary vocabulary of the message language + * @return rendered information terminal message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + InformationEndingContent endingContent = contentOf(content, InformationEndingContent.class, "Information ending generator"); + renderableConclusion(endingContent.conclusion()); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); + slots.put( + vocabulary.get("slot.info_conclusion"), + endingContent.conclusion().literal()); + slots.put(vocabulary.get("slot.info_result_content"), formatItems(endingContent.items(), vocabulary)); + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java index e55747aa..0c9ce8cf 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java @@ -1,49 +1,49 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Generator for information negotiation propose messages. - * - *

      The message carries the requested information items and, when present, a free-form line describing how the missing - * items relate to each other, appended after the item list. - * - * @since 2026-08 - */ -final class InformationProposeGenerator extends AbstractNegotiationGenerator { - - /** - * Generates an information negotiation propose message. - * - * @param context negotiation context of the message - * @param content information propose content - * @param template information propose template to render - * @param vocabulary vocabulary of the message language - * @return rendered information propose message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - InformationProposeContent proposeContent = - contentOf(content, InformationProposeContent.class, "Information propose generator"); - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); - slots.put(vocabulary.get("slot.info_items"), itemsSlotValue(proposeContent, vocabulary)); - return render(template, slots); - } - - private String itemsSlotValue(InformationProposeContent content, Vocabulary vocabulary) { - String items = formatItems(content.items(), vocabulary); - if (content.relationship() == null || content.relationship().isBlank()) { - return items; - } - String relationshipLine = vocabulary.get("label.relationship") + content.relationship(); - return items.isEmpty() ? relationshipLine : items + "\n" + relationshipLine; - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.core.model.PromptTemplate; + +/** + * Generator for information negotiation propose messages. + * + *

      The message carries the requested information items and, when present, a free-form line describing how the missing + * items relate to each other, appended after the item list. + * + * @since 2026-08 + */ +final class InformationProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates an information negotiation propose message. + * + * @param context negotiation context of the message + * @param content information propose content + * @param template information propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered information propose message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + InformationProposeContent proposeContent = + contentOf(content, InformationProposeContent.class, "Information propose generator"); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); + slots.put(vocabulary.get("slot.info_items"), itemsSlotValue(proposeContent, vocabulary)); + return render(template, slots); + } + + private String itemsSlotValue(InformationProposeContent content, Vocabulary vocabulary) { + String items = formatItems(content.items(), vocabulary); + if (content.relationship() == null || content.relationship().isBlank()) { + return items; + } + String relationshipLine = vocabulary.get("label.relationship") + content.relationship(); + return items.isEmpty() ? relationshipLine : items + "\n" + relationshipLine; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index 51a152d7..9d4b5d16 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -13,7 +13,7 @@ import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java index 0bd5ff3e..6d0bcdc4 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java @@ -1,7 +1,7 @@ package net.openan.a2at.sdk.negotiation.generation; import java.util.Objects; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; /** * Assembles the negotiation context into the single slot value used by context sections. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index bcc3c5c1..8651489e 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -1,619 +1,619 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.function.Supplier; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.ExtensionUriConstants; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; -import org.jspecify.annotations.NonNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Orchestrates the negotiation content layer: deterministic message generation, single-step LLM content extraction and - * the validation-plus-parameter-extraction pipeline. - * - *

      The orchestrator owns the retry loop of every LLM step. A step failing with one of the retryable codes - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} is re-run up to the - * configured attempt limit; the exhaustion failure rethrows the original error code. Internal exceptions of the - * generation and validation pipelines never bubble out raw: render failures become generation failures carrying - * {@code negotiation_slot_missing}, semantic validation infrastructure failures become parameter-extraction failures - * carrying {@code negotiation_llm_infrastructure_error}, and resource load misses become the code - * {@code template_not_found} on both pipelines. - * - *

      Instances are created through {@link NegotiationGenerationOrchestratorBuilder}; the builder wires the default - * collaborators and allows overriding each of them. - * - * @since 2026-08 - */ -public final class NegotiationGenerationOrchestrator { - - private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(NegotiationGenerationOrchestrator.class); - - private static final String STEP_CONTENT_EXTRACT = "negotiation_content_extract"; - - private static final String LANGUAGE_HINT = - "set A2AT_LANGUAGE to a language with bundled templates (zh-CN or en-US) or provide the template under" - + " the local resource root"; - - private final String language; - - private final int maxAttempts; - - private final NegotiationTemplateLoader templateLoader; - - private final NegotiationContentExtractor contentExtractor; - - private final ParamExtractor paramExtractor; - - private final NegotiationGeneratorRegistry generatorRegistry; - - private final Vocabulary vocabulary; - - private final Logger logger; - - NegotiationGenerationOrchestrator( - String language, - int maxAttempts, - NegotiationTemplateLoader templateLoader, - NegotiationContentExtractor contentExtractor, - ParamExtractor paramExtractor, - NegotiationGeneratorRegistry generatorRegistry, - Vocabulary vocabulary, - Logger logger) { - this.language = language; - this.maxAttempts = maxAttempts; - this.templateLoader = templateLoader; - this.contentExtractor = contentExtractor; - this.paramExtractor = paramExtractor; - this.generatorRegistry = generatorRegistry; - this.vocabulary = vocabulary; - this.logger = logger == null ? DEFAULT_LOGGER : logger; - } - - /** - * Generates a propose-phase negotiation message from typed data. - * - *

      This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the - * generator of the negotiation type addressed by the template URI and rendered from that template. - * - * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase - * segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content type - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the - * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails - */ - public MetadataContent generateProposeFromData( - @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "Negotiation propose data must not be null."); - return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.PROPOSE); - } - - /** - * Generates an accept-phase negotiation message from typed data. - * - *

      This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content, or - * the content conclusion is not {@code Accept} - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the - * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails - */ - public MetadataContent generateAcceptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "Negotiation ending data must not be null."); - return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.ACCEPT); - } - - /** - * Generates a reject-phase negotiation message from typed data. - * - *

      This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content, or - * the content conclusion is not {@code Reject} - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the - * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails - */ - public MetadataContent generateRejectFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "Negotiation ending data must not be null."); - return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.REJECT); - } - - /** - * Generates an abort negotiation message from typed data. - * - *

      This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed - * template must be the common abort template and the content carries only the termination reason. - * - * @param data typed abort input carrying the negotiation context and the termination reason - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template or the - * termination reason is blank - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the - * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails - */ - public MetadataContent generateAbortFromData( - @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "Negotiation abort data must not be null."); - return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.ABORT); - } - - /** - * Generates a propose-phase negotiation message from free text. - * - *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. - * - * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment - * must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation_content_extract_failed} or - * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, - * {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted content contradicts the phase - */ - public MetadataContent generateProposeFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return generateFromText(text, context, templateUri, NegotiationPhase.PROPOSE); - } - - /** - * Generates an accept-phase negotiation message from free text. - * - *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Accept}. - * - * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation_content_extract_failed} or - * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, - * {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Accept} - */ - public MetadataContent generateAcceptFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return generateFromText(text, context, templateUri, NegotiationPhase.ACCEPT); - } - - /** - * Generates a reject-phase negotiation message from free text. - * - *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Reject}. - * - * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation_content_extract_failed} or - * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, - * {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Reject} - */ - public MetadataContent generateRejectFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return generateFromText(text, context, templateUri, NegotiationPhase.REJECT); - } - - /** - * Generates an abort negotiation message from free text. - * - *

      This variant runs one LLM content-extraction step constrained by the common abort template and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. - * - * @param text free-text input stating the termination reason - * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation_content_extract_failed} or - * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, - * {@code negotiation_slot_missing} when the extracted content misses the termination reason, or - * {@code negotiation_invalid_input} when the text is blank - */ - public MetadataContent generateAbortFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return generateFromText(text, context, templateUri, NegotiationPhase.ABORT); - } - - /** - * Lists every negotiation template available for the configured language. - * - *

      This query never throws: templates that exist nowhere for the language are skipped and an empty list is - * returned when no template can be loaded at all. - * - * @return loadable negotiation templates of the configured language, in a fixed type and phase order; empty when - * none can be loaded - */ - public List getNegotiationPrompts() { - try { - return templateLoader.loadAll(); - } catch (ResourceNotFoundException exception) { - logger.atWarn().log("negotiation_template_not_found uri=all language={} hint={}", language, LANGUAGE_HINT); - return List.of(); - } - } - - /** - * Loads one negotiation template addressed by its URI. - * - *

      This query never throws: a URI that does not address a negotiation template or a template that exists nowhere - * for the configured language returns an empty result and logs an actionable warning. - * - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} - * @return the addressed template, or an empty result when the URI does not address a negotiation template or the - * template does not exist for the configured language - * @throws NullPointerException if the template URI is null - */ - public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { - Optional reference = parseQueryReference(templateUri); - if (reference.isEmpty()) { - logger.atWarn() - .log( - "negotiation_template_not_found uri={} language={} reason=invalid_template_uri hint={}", - templateUri.uri(), - language, - LANGUAGE_HINT); - return Optional.empty(); - } - try { - return Optional.of(templateLoader.load(reference.get())); - } catch (ResourceNotFoundException exception) { - logger.atWarn() - .log( - "negotiation_template_not_found uri={} language={} hint={}", - templateUri.uri(), - language, - LANGUAGE_HINT); - return Optional.empty(); - } - } - - /** - * Validates a propose-phase negotiation message and extracts its parameters. - * - *

      The pipeline checks the template URI before any LLM call, runs the deterministic rule gate, then performs one - * semantic validation LLM call (retried on the retryable failure codes) and merges the extracted parameters with - * the rule-level context parameters; context parameters win on conflict. - * - * @param prompt rendered negotiation message text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code propose} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is - * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateProposePromptAndDataFilling( - String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.PROPOSE); - } - - /** - * Validates an accept-phase negotiation message and extracts its parameters. - * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected - * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must - * satisfy the accept-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is - * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateAcceptPromptAndDataFilling( - String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.ACCEPT); - } - - /** - * Validates a reject-phase negotiation message and extracts its parameters. - * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected - * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must - * satisfy the reject-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is - * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateRejectPromptAndDataFilling( - String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.REJECT); - } - - /** - * Validates an abort negotiation message and extracts its parameters. - * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected - * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the - * abort-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is - * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateAbortPromptAndDataFilling( - String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.ABORT); - } - - private MetadataContent generateFromData( - NegotiationContext context, NegotiationContent content, TemplateUri templateUri, NegotiationPhase phase) { - requireContext(context); - try { - NegotiationReference reference = requireReference(templateUri, phase); - PromptTemplate template = loadTemplate(reference); - String promptText = renderMessage(context, content, reference, template); - return completeGeneration(reference, promptText, context); - } catch (NegotiationGenerationException failure) { - logger.atWarn() - .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); - throw failure; - } - } - - private MetadataContent generateFromText( - String text, NegotiationContext context, TemplateUri templateUri, NegotiationPhase phase) { - requireContext(context); - try { - NegotiationReference reference = requireReference(templateUri, phase); - PromptTemplate template = loadTemplate(reference); - NegotiationContent content = extractContent(text, reference); - String promptText = renderMessage(context, content, reference, template); - return completeGeneration(reference, promptText, context); - } catch (NegotiationGenerationException failure) { - logger.atWarn() - .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); - throw failure; - } - } - - private PromptTemplate loadTemplate(NegotiationReference reference) { - try { - return templateLoader.load(reference); - } catch (ResourceNotFoundException exception) { - throw new NegotiationGenerationException( - A2ATErrorCodes.TEMPLATE_NOT_FOUND, exception.getMessage(), exception); - } - } - - private NegotiationContent extractContent(String text, NegotiationReference reference) { - try { - return withRetry(STEP_CONTENT_EXTRACT, () -> contentExtractor.extract(text, reference)); - } catch (ResourceNotFoundException exception) { - throw new NegotiationGenerationException( - A2ATErrorCodes.TEMPLATE_NOT_FOUND, exception.getMessage(), exception); - } - } - - private String renderMessage( - NegotiationContext context, - NegotiationContent content, - NegotiationReference reference, - PromptTemplate template) { - NegotiationGenerator generator = generatorRegistry.resolve(reference.type(), reference.phase(), content); - try { - return generator.generate(context, content, template, vocabulary); - } catch (NegotiationRenderException exception) { - throw new NegotiationGenerationException( - A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, - "Rendering the negotiation template failed: " + exception.getMessage(), - exception); - } - } - - private MetadataContent completeGeneration( - NegotiationReference reference, String promptText, NegotiationContext context) { - logger.atInfo().log( - "negotiation_generation_completed uri={} type={} phase={} round={} id={}", - reference.uri(), - reference.type(), - reference.phase(), - context.round(), - context.id()); - return new MetadataContent( - reference.uri(), promptText, ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI); - } - - private FilledParamData validatePromptAndDataFilling( - String prompt, Map schema, TemplateUri templateUri, NegotiationPhase phase) { - Objects.requireNonNull(schema, "Parameter schema must not be null."); - NegotiationReference reference = requireReference(templateUri, phase); - try { - return paramExtractor.extract(prompt, schema, reference); - } catch (NegotiationParamExtractionException failure) { - logger.atWarn() - .log( - "negotiation_param_extraction_failed code={} error_count={}", - failure.getCode(), - failure.getErrors().size()); - throw failure; - } - } - - /** - * Derives the reference of a typed template URI against one expected phase. - * - * @param templateUri template URI declared by the caller - * @param phase API-level phase the calling method operates on - * @return reference addressed by the URI carrying the expected phase - * @throws IllegalArgumentException if the URI does not address a negotiation template of the expected phase - */ - private NegotiationReference requireReference(TemplateUri templateUri, NegotiationPhase phase) { - return NegotiationReference.fromTemplateUri(templateUri, phase, language) - .orElseThrow(() -> new IllegalArgumentException( - "Template URI does not address a negotiation template of the expected phase " + phase + " (" - + phase.uriSegment() + "): " + templateUri.uri() + ".")); - } - - private Optional parseQueryReference(TemplateUri templateUri) { - // The URI layer cannot distinguish accept from reject because both phases share the accept-reject template - // segment. The query therefore addresses the shared template through the ACCEPT phase; the parsed phase is an - // addressing artifact only and must never be read as the phase of any message. The abort phase addresses the - // type-independent common abort template. - for (NegotiationPhase phase : - List.of(NegotiationPhase.PROPOSE, NegotiationPhase.ACCEPT, NegotiationPhase.ABORT)) { - Optional reference = NegotiationReference.fromTemplateUri(templateUri, phase, language); - if (reference.isPresent()) { - return reference; - } - } - return Optional.empty(); - } - - /** - * Runs one LLM step with the retry policy of the negotiation content layer. - * - *

      A failure carrying a retryable code is re-run up to the configured attempt limit; a failure carrying any other - * code is rethrown immediately. When the attempts are exhausted, the original failure is rethrown with its original - * error code. - * - * @param result type of the step - * @param step internal diagnostic step name used in the retry logs - * @param action step implementation performing exactly one LLM call - * @return step result - */ - private T withRetry(String step, Supplier action) { - RuntimeException lastFailure = null; - for (int attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return action.get(); - } catch (RuntimeException failure) { - String code = retryableCode(failure); - if (code == null) { - throw failure; - } - lastFailure = failure; - if (attempt == maxAttempts) { - logger.atWarn() - .log( - "negotiation_llm_retry_exhausted step={} max_attempts={} code={}", - step, - maxAttempts, - code); - throw failure; - } - logger.atWarn() - .log( - "negotiation_llm_retry step={} attempt={} max_attempts={} code={}", - step, - attempt, - maxAttempts, - code); - } - } - throw lastFailure; - } - - private static String retryableCode(RuntimeException failure) { - String code = null; - if (failure instanceof NegotiationGenerationException generation) { - code = generation.getCode(); - } else if (failure instanceof A2ATParamExtractionError extraction) { - code = extraction.getCode(); - } - if (code == null) { - return null; - } - boolean retryable = A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED.equals(code) - || A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR.equals(code); - return retryable ? code : null; - } - - private static void requireContext(NegotiationContext context) { - Objects.requireNonNull(context, "Negotiation context must not be null."); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Orchestrates the negotiation content layer: deterministic message generation, single-step LLM content extraction and + * the validation-plus-parameter-extraction pipeline. + * + *

      The orchestrator owns the retry loop of every LLM step. A step failing with one of the retryable codes + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} is re-run up to the + * configured attempt limit; the exhaustion failure rethrows the original error code. Internal exceptions of the + * generation and validation pipelines never bubble out raw: render failures become generation failures carrying + * {@code negotiation_slot_missing}, semantic validation infrastructure failures become parameter-extraction failures + * carrying {@code negotiation_llm_infrastructure_error}, and resource load misses become the code + * {@code template_not_found} on both pipelines. + * + *

      Instances are created through {@link NegotiationGenerationOrchestratorBuilder}; the builder wires the default + * collaborators and allows overriding each of them. + * + * @since 2026-08 + */ +public final class NegotiationGenerationOrchestrator { + + private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(NegotiationGenerationOrchestrator.class); + + private static final String STEP_CONTENT_EXTRACT = "negotiation_content_extract"; + + private static final String LANGUAGE_HINT = + "set A2AT_LANGUAGE to a language with bundled templates (zh-CN or en-US) or provide the template under" + + " the local resource root"; + + private final String language; + + private final int maxAttempts; + + private final NegotiationTemplateLoader templateLoader; + + private final NegotiationContentExtractor contentExtractor; + + private final ParamExtractor paramExtractor; + + private final NegotiationGeneratorRegistry generatorRegistry; + + private final Vocabulary vocabulary; + + private final Logger logger; + + NegotiationGenerationOrchestrator( + String language, + int maxAttempts, + NegotiationTemplateLoader templateLoader, + NegotiationContentExtractor contentExtractor, + ParamExtractor paramExtractor, + NegotiationGeneratorRegistry generatorRegistry, + Vocabulary vocabulary, + Logger logger) { + this.language = language; + this.maxAttempts = maxAttempts; + this.templateLoader = templateLoader; + this.contentExtractor = contentExtractor; + this.paramExtractor = paramExtractor; + this.generatorRegistry = generatorRegistry; + this.vocabulary = vocabulary; + this.logger = logger == null ? DEFAULT_LOGGER : logger; + } + + /** + * Generates a propose-phase negotiation message from typed data. + * + *

      This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the + * generator of the negotiation type addressed by the template URI and rendered from that template. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase + * segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content type + * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the + * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateProposeFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "Negotiation propose data must not be null."); + return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.PROPOSE); + } + + /** + * Generates an accept-phase negotiation message from typed data. + * + *

      This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content, or + * the content conclusion is not {@code Accept} + * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the + * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateAcceptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "Negotiation ending data must not be null."); + return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.ACCEPT); + } + + /** + * Generates a reject-phase negotiation message from typed data. + * + *

      This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content, or + * the content conclusion is not {@code Reject} + * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the + * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateRejectFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "Negotiation ending data must not be null."); + return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.REJECT); + } + + /** + * Generates an abort negotiation message from typed data. + * + *

      This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed + * template must be the common abort template and the content carries only the termination reason. + * + * @param data typed abort input carrying the negotiation context and the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template or the + * termination reason is blank + * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the + * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateAbortFromData( + @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "Negotiation abort data must not be null."); + return generateFromData(data.context(), data.content(), templateUri, NegotiationPhase.ABORT); + } + + /** + * Generates a propose-phase negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. + * + * @param text free-text input describing the message content + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment + * must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation_content_extract_failed} or + * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, + * {@code negotiation_slot_missing} when the extracted content misses a required field, or + * {@code negotiation_invalid_input} when the text is blank or the extracted content contradicts the phase + */ + public MetadataContent generateProposeFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPhase.PROPOSE); + } + + /** + * Generates an accept-phase negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Accept}. + * + * @param text free-text input describing the message content + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation_content_extract_failed} or + * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, + * {@code negotiation_slot_missing} when the extracted content misses a required field, or + * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Accept} + */ + public MetadataContent generateAcceptFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPhase.ACCEPT); + } + + /** + * Generates a reject-phase negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Reject}. + * + * @param text free-text input describing the message content + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation_content_extract_failed} or + * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, + * {@code negotiation_slot_missing} when the extracted content misses a required field, or + * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Reject} + */ + public MetadataContent generateRejectFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPhase.REJECT); + } + + /** + * Generates an abort negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. + * + * @param text free-text input stating the termination reason + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation_content_extract_failed} or + * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, + * {@code negotiation_slot_missing} when the extracted content misses the termination reason, or + * {@code negotiation_invalid_input} when the text is blank + */ + public MetadataContent generateAbortFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPhase.ABORT); + } + + /** + * Lists every negotiation template available for the configured language. + * + *

      This query never throws: templates that exist nowhere for the language are skipped and an empty list is + * returned when no template can be loaded at all. + * + * @return loadable negotiation templates of the configured language, in a fixed type and phase order; empty when + * none can be loaded + */ + public List getNegotiationPrompts() { + try { + return templateLoader.loadAll(); + } catch (ResourceNotFoundException exception) { + logger.atWarn().log("negotiation_template_not_found uri=all language={} hint={}", language, LANGUAGE_HINT); + return List.of(); + } + } + + /** + * Loads one negotiation template addressed by its URI. + * + *

      This query never throws: a URI that does not address a negotiation template or a template that exists nowhere + * for the configured language returns an empty result and logs an actionable warning. + * + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} + * @return the addressed template, or an empty result when the URI does not address a negotiation template or the + * template does not exist for the configured language + * @throws NullPointerException if the template URI is null + */ + public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { + Optional reference = parseQueryReference(templateUri); + if (reference.isEmpty()) { + logger.atWarn() + .log( + "negotiation_template_not_found uri={} language={} reason=invalid_template_uri hint={}", + templateUri.uri(), + language, + LANGUAGE_HINT); + return Optional.empty(); + } + try { + return Optional.of(templateLoader.load(reference.get())); + } catch (ResourceNotFoundException exception) { + logger.atWarn() + .log( + "negotiation_template_not_found uri={} language={} hint={}", + templateUri.uri(), + language, + LANGUAGE_HINT); + return Optional.empty(); + } + } + + /** + * Validates a propose-phase negotiation message and extracts its parameters. + * + *

      The pipeline checks the template URI before any LLM call, runs the deterministic rule gate, then performs one + * semantic validation LLM call (retried on the retryable failure codes) and merges the extracted parameters with + * the rule-level context parameters; context parameters win on conflict. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is + * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateProposePromptAndDataFilling( + String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.PROPOSE); + } + + /** + * Validates an accept-phase negotiation message and extracts its parameters. + * + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must + * satisfy the accept-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is + * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateAcceptPromptAndDataFilling( + String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.ACCEPT); + } + + /** + * Validates a reject-phase negotiation message and extracts its parameters. + * + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must + * satisfy the reject-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is + * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateRejectPromptAndDataFilling( + String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.REJECT); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the + * abort-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is + * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateAbortPromptAndDataFilling( + String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.ABORT); + } + + private MetadataContent generateFromData( + NegotiationContext context, NegotiationContent content, TemplateUri templateUri, NegotiationPhase phase) { + requireContext(context); + try { + NegotiationReference reference = requireReference(templateUri, phase); + PromptTemplate template = loadTemplate(reference); + String promptText = renderMessage(context, content, reference, template); + return completeGeneration(reference, promptText, context); + } catch (NegotiationGenerationException failure) { + logger.atWarn() + .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); + throw failure; + } + } + + private MetadataContent generateFromText( + String text, NegotiationContext context, TemplateUri templateUri, NegotiationPhase phase) { + requireContext(context); + try { + NegotiationReference reference = requireReference(templateUri, phase); + PromptTemplate template = loadTemplate(reference); + NegotiationContent content = extractContent(text, reference); + String promptText = renderMessage(context, content, reference, template); + return completeGeneration(reference, promptText, context); + } catch (NegotiationGenerationException failure) { + logger.atWarn() + .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); + throw failure; + } + } + + private PromptTemplate loadTemplate(NegotiationReference reference) { + try { + return templateLoader.load(reference); + } catch (ResourceNotFoundException exception) { + throw new NegotiationGenerationException( + A2ATErrorCodes.TEMPLATE_NOT_FOUND, exception.getMessage(), exception); + } + } + + private NegotiationContent extractContent(String text, NegotiationReference reference) { + try { + return withRetry(STEP_CONTENT_EXTRACT, () -> contentExtractor.extract(text, reference)); + } catch (ResourceNotFoundException exception) { + throw new NegotiationGenerationException( + A2ATErrorCodes.TEMPLATE_NOT_FOUND, exception.getMessage(), exception); + } + } + + private String renderMessage( + NegotiationContext context, + NegotiationContent content, + NegotiationReference reference, + PromptTemplate template) { + NegotiationGenerator generator = generatorRegistry.resolve(reference.type(), reference.phase(), content); + try { + return generator.generate(context, content, template, vocabulary); + } catch (NegotiationRenderException exception) { + throw new NegotiationGenerationException( + A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, + "Rendering the negotiation template failed: " + exception.getMessage(), + exception); + } + } + + private MetadataContent completeGeneration( + NegotiationReference reference, String promptText, NegotiationContext context) { + logger.atInfo().log( + "negotiation_generation_completed uri={} type={} phase={} round={} id={}", + reference.uri(), + reference.type(), + reference.phase(), + context.round(), + context.id()); + return new MetadataContent( + reference.uri(), promptText, ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI); + } + + private FilledParamData validatePromptAndDataFilling( + String prompt, Map schema, TemplateUri templateUri, NegotiationPhase phase) { + Objects.requireNonNull(schema, "Parameter schema must not be null."); + NegotiationReference reference = requireReference(templateUri, phase); + try { + return paramExtractor.extract(prompt, schema, reference); + } catch (NegotiationParamExtractionException failure) { + logger.atWarn() + .log( + "negotiation_param_extraction_failed code={} error_count={}", + failure.getCode(), + failure.getErrors().size()); + throw failure; + } + } + + /** + * Derives the reference of a typed template URI against one expected phase. + * + * @param templateUri template URI declared by the caller + * @param phase API-level phase the calling method operates on + * @return reference addressed by the URI carrying the expected phase + * @throws IllegalArgumentException if the URI does not address a negotiation template of the expected phase + */ + private NegotiationReference requireReference(TemplateUri templateUri, NegotiationPhase phase) { + return NegotiationReference.fromTemplateUri(templateUri, phase, language) + .orElseThrow(() -> new IllegalArgumentException( + "Template URI does not address a negotiation template of the expected phase " + phase + " (" + + phase.uriSegment() + "): " + templateUri.uri() + ".")); + } + + private Optional parseQueryReference(TemplateUri templateUri) { + // The URI layer cannot distinguish accept from reject because both phases share the accept-reject template + // segment. The query therefore addresses the shared template through the ACCEPT phase; the parsed phase is an + // addressing artifact only and must never be read as the phase of any message. The abort phase addresses the + // type-independent common abort template. + for (NegotiationPhase phase : + List.of(NegotiationPhase.PROPOSE, NegotiationPhase.ACCEPT, NegotiationPhase.ABORT)) { + Optional reference = NegotiationReference.fromTemplateUri(templateUri, phase, language); + if (reference.isPresent()) { + return reference; + } + } + return Optional.empty(); + } + + /** + * Runs one LLM step with the retry policy of the negotiation content layer. + * + *

      A failure carrying a retryable code is re-run up to the configured attempt limit; a failure carrying any other + * code is rethrown immediately. When the attempts are exhausted, the original failure is rethrown with its original + * error code. + * + * @param result type of the step + * @param step internal diagnostic step name used in the retry logs + * @param action step implementation performing exactly one LLM call + * @return step result + */ + private T withRetry(String step, Supplier action) { + RuntimeException lastFailure = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return action.get(); + } catch (RuntimeException failure) { + String code = retryableCode(failure); + if (code == null) { + throw failure; + } + lastFailure = failure; + if (attempt == maxAttempts) { + logger.atWarn() + .log( + "negotiation_llm_retry_exhausted step={} max_attempts={} code={}", + step, + maxAttempts, + code); + throw failure; + } + logger.atWarn() + .log( + "negotiation_llm_retry step={} attempt={} max_attempts={} code={}", + step, + attempt, + maxAttempts, + code); + } + } + throw lastFailure; + } + + private static String retryableCode(RuntimeException failure) { + String code = null; + if (failure instanceof NegotiationGenerationException generation) { + code = generation.getCode(); + } else if (failure instanceof A2ATParamExtractionError extraction) { + code = extraction.getCode(); + } + if (code == null) { + return null; + } + boolean retryable = A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED.equals(code) + || A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR.equals(code); + return retryable ? code : null; + } + + private static void requireContext(NegotiationContext context) { + Objects.requireNonNull(context, "Negotiation context must not be null."); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java index bd2dd2af..ca3e6a8b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java @@ -1,31 +1,31 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Renders one negotiation message from typed content and a loaded template. - * - *

      Implementations assemble the language-specific slot map from the vocabulary, validate their content input and - * delegate the final rendering step. The registry is the only supported dispatch path; it guarantees the exact runtime - * type of the content before a generator is invoked. - * - * @since 2026-08 - */ -interface NegotiationGenerator { - - /** - * Generates the negotiation message text for one content object. - * - * @param context negotiation context of the message - * @param content typed content of the message; must be the exact content type this generator serves - * @param template loaded template to render - * @param vocabulary vocabulary supplying the slot names of the message language - * @return rendered negotiation message text - * @throws IllegalArgumentException if the content does not match this generator or violates an input rule - */ - String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary); -} +package net.openan.a2at.sdk.negotiation.generation; + +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.core.model.PromptTemplate; + +/** + * Renders one negotiation message from typed content and a loaded template. + * + *

      Implementations assemble the language-specific slot map from the vocabulary, validate their content input and + * delegate the final rendering step. The registry is the only supported dispatch path; it guarantees the exact runtime + * type of the content before a generator is invoked. + * + * @since 2026-08 + */ +interface NegotiationGenerator { + + /** + * Generates the negotiation message text for one content object. + * + * @param context negotiation context of the message + * @param content typed content of the message; must be the exact content type this generator serves + * @param template loaded template to render + * @param vocabulary vocabulary supplying the slot names of the message language + * @return rendered negotiation message text + * @throws IllegalArgumentException if the content does not match this generator or violates an input rule + */ + String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary); +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java index 2abcb2a1..ed6aad4b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java @@ -1,55 +1,55 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Generator for target negotiation terminal messages. - * - *

      The result content slot carries the confirmed intent for an accept conclusion or the failure reason for a reject - * conclusion; the other field is ignored. - * - * @since 2026-08 - */ -final class TargetEndingGenerator extends AbstractNegotiationGenerator { - - /** - * Generates a target negotiation accept or reject message. - * - * @param context negotiation context of the message - * @param content target ending content - * @param template target ending template to render - * @param vocabulary vocabulary of the message language - * @return rendered target terminal message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - TargetEndingContent endingContent = contentOf(content, TargetEndingContent.class, "Target ending generator"); - NegotiationConclusion conclusion = renderableConclusion(endingContent.conclusion()); - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); - slots.put(vocabulary.get("slot.target_conclusion"), conclusion.literal()); - slots.put(vocabulary.get("slot.target_result_content"), resultContentSlotValue(endingContent, conclusion)); - return render(template, slots); - } - - private static String resultContentSlotValue(TargetEndingContent content, NegotiationConclusion conclusion) { - if (conclusion == NegotiationConclusion.ACCEPT) { - return requiredText( - content.confirmedIntent(), - "content.confirmedIntent", - "Confirmed intent of an accepting target negotiation message"); - } - return requiredText( - content.failureReason(), - "content.failureReason", - "Failure reason of a rejecting target negotiation" + " message"); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.core.model.PromptTemplate; + +/** + * Generator for target negotiation terminal messages. + * + *

      The result content slot carries the confirmed intent for an accept conclusion or the failure reason for a reject + * conclusion; the other field is ignored. + * + * @since 2026-08 + */ +final class TargetEndingGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a target negotiation accept or reject message. + * + * @param context negotiation context of the message + * @param content target ending content + * @param template target ending template to render + * @param vocabulary vocabulary of the message language + * @return rendered target terminal message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + TargetEndingContent endingContent = contentOf(content, TargetEndingContent.class, "Target ending generator"); + NegotiationConclusion conclusion = renderableConclusion(endingContent.conclusion()); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); + slots.put(vocabulary.get("slot.target_conclusion"), conclusion.literal()); + slots.put(vocabulary.get("slot.target_result_content"), resultContentSlotValue(endingContent, conclusion)); + return render(template, slots); + } + + private static String resultContentSlotValue(TargetEndingContent content, NegotiationConclusion conclusion) { + if (conclusion == NegotiationConclusion.ACCEPT) { + return requiredText( + content.confirmedIntent(), + "content.confirmedIntent", + "Confirmed intent of an accepting target negotiation message"); + } + return requiredText( + content.failureReason(), + "content.failureReason", + "Failure reason of a rejecting target negotiation" + " message"); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java index 77fa5239..efceffa5 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java @@ -1,57 +1,57 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Generator for target negotiation propose messages. - * - *

      Besides the required target negotiation summary, the message renders round-driven conditional sections: the intent - * understanding section appears only on the first round, the alignment and clarification section only on later rounds, - * and the clarification request section only when clarification items are present. - * - * @since 2026-08 - */ -final class TargetProposeGenerator extends AbstractNegotiationGenerator { - - /** - * Generates a target negotiation propose message. - * - * @param context negotiation context of the message - * @param content target propose content - * @param template target propose template to render - * @param vocabulary vocabulary of the message language - * @return rendered target propose message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - TargetProposeContent proposeContent = - contentOf(content, TargetProposeContent.class, "Target propose generator"); - requiredText( - proposeContent.targetNegotiationDescription(), - "content.targetNegotiationDescription", - "Target negotiation description"); - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); - slots.put(vocabulary.get("slot.target"), proposeContent.targetNegotiationDescription()); - if (context.round() == 1) { - slots.put( - vocabulary.get("slot.target_intent"), - formatItems(proposeContent.intentUnderstanding(), vocabulary)); - } else { - slots.put( - vocabulary.get("slot.target_alignment"), - formatItems(proposeContent.alignmentAndClarification(), vocabulary)); - } - slots.put( - vocabulary.get("slot.target_clarification"), - formatItems(proposeContent.requestForClarification(), vocabulary)); - return render(template, slots); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.core.model.PromptTemplate; + +/** + * Generator for target negotiation propose messages. + * + *

      Besides the required target negotiation summary, the message renders round-driven conditional sections: the intent + * understanding section appears only on the first round, the alignment and clarification section only on later rounds, + * and the clarification request section only when clarification items are present. + * + * @since 2026-08 + */ +final class TargetProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a target negotiation propose message. + * + * @param context negotiation context of the message + * @param content target propose content + * @param template target propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered target propose message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + TargetProposeContent proposeContent = + contentOf(content, TargetProposeContent.class, "Target propose generator"); + requiredText( + proposeContent.targetNegotiationDescription(), + "content.targetNegotiationDescription", + "Target negotiation description"); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); + slots.put(vocabulary.get("slot.target"), proposeContent.targetNegotiationDescription()); + if (context.round() == 1) { + slots.put( + vocabulary.get("slot.target_intent"), + formatItems(proposeContent.intentUnderstanding(), vocabulary)); + } else { + slots.put( + vocabulary.get("slot.target_alignment"), + formatItems(proposeContent.alignmentAndClarification(), vocabulary)); + } + slots.put( + vocabulary.get("slot.target_clarification"), + formatItems(proposeContent.requestForClarification(), vocabulary)); + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java index 40acb620..a71168ae 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java @@ -1,200 +1,200 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.regex.Pattern; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Default rule-level compliance checker for rendered negotiation messages. - * - *

      The text is split on lines starting with {@code ## }; anything before the first section title is discarded. A text - * is recognised as a negotiation message when one section title equals the vocabulary's negotiation context section - * title. For a recognised message the checker parses the {@code - id:}, {@code - round:} and {@code - maxRounds:} list - * lines of that section and enforces that the id is a UUID of 36 characters in 8-4-4-4-12 hexadecimal form, that the - * round and the round budget are integers of at least 1, and that the round does not exceed the budget. No other rules - * are applied: the checker does not infer the negotiation type, does not validate conclusion values, does not require - * ending result sections and does not check conditional-section exclusivity. - * - * @since 2026-08 - */ -public final class DefaultNegotiationComplianceChecker implements NegotiationComplianceChecker { - - private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationComplianceChecker.class); - - private static final Pattern UUID_PATTERN = - Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); - - private static final String SECTION_PREFIX = "## "; - - private static final String ID_SLOT = "id"; - - private static final String ROUND_SLOT = "round"; - - private static final String MAX_ROUNDS_SLOT = "maxRounds"; - - /** Creates the default checker. */ - public DefaultNegotiationComplianceChecker() {} - - /** - * Runs the rule-level compliance check of one rendered message. - * - * @param prompt rendered negotiation message text; a null or empty text is reported as not being a negotiation - * message - * @param vocabulary vocabulary supplying the language-specific section titles of the message - * @return rule check outcome: not a negotiation message when the context section is absent, otherwise the parsed - * context with no errors when every context rule holds, otherwise the context rule errors without a context - * @throws NullPointerException if the vocabulary is null - */ - @Override - public NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary) { - Objects.requireNonNull(vocabulary, "vocabulary"); - List contextBody = contextSectionBody(prompt, vocabulary.get("section.context")); - if (contextBody == null) { - return logResult(new NegotiationRuleCheckResult(false, false, List.of(), null)); - } - - String id = stringValue(contextBody, "- id:"); - String roundText = stringValue(contextBody, "- round:"); - String maxRoundsText = stringValue(contextBody, "- maxRounds:"); - Integer round = integerValue(roundText); - Integer maxRounds = integerValue(maxRoundsText); - - List errors = new ArrayList<>(); - collectIdErrors(id, errors); - collectRangeErrors(ROUND_SLOT, round, roundText, errors); - collectRangeErrors(MAX_ROUNDS_SLOT, maxRounds, maxRoundsText, errors); - if (round != null && maxRounds != null && round >= 1 && maxRounds >= 1 && round > maxRounds) { - errors.add(new SlotValidationError( - ROUND_SLOT, - "out_of_range", - "Negotiation context round " + round + " must not exceed maxRounds " + maxRounds + ".")); - } - - if (errors.isEmpty()) { - NegotiationContext context = new NegotiationContext(id, round, maxRounds); - return logResult(new NegotiationRuleCheckResult(true, true, List.of(), context)); - } - return logResult(new NegotiationRuleCheckResult(false, true, List.copyOf(errors), null)); - } - - private static void collectIdErrors(String id, List errors) { - if (id == null) { - errors.add(new SlotValidationError( - ID_SLOT, "missing_field", "Negotiation context section is missing the id line.")); - return; - } - if (!UUID_PATTERN.matcher(id).matches()) { - errors.add(new SlotValidationError( - ID_SLOT, - "invalid_uuid", - "Negotiation context id must be a 36-character UUID in 8-4-4-4-12 hexadecimal form but was '" + id - + "'.")); - } - } - - private static void collectRangeErrors( - String slotName, Integer value, String valueText, List errors) { - if (value == null) { - if (valueText == null) { - errors.add(new SlotValidationError( - slotName, - "missing_field", - "Negotiation context section is missing the " + slotName + " line.")); - } else { - errors.add(new SlotValidationError( - slotName, - "invalid_value", - "Negotiation context " + slotName + " must be an integer but was '" + valueText + "'.")); - } - return; - } - if (value < 1) { - errors.add(new SlotValidationError( - slotName, - "out_of_range", - "Negotiation context " + slotName + " must be a positive integer but was " + value + ".")); - } - } - - /** - * Emits the rule-check completion event of one check outcome. - * - * @param result rule check outcome to log - * @return the unchanged outcome - */ - private static NegotiationRuleCheckResult logResult(NegotiationRuleCheckResult result) { - if (result.errors().isEmpty()) { - LOGGER.atDebug().log("negotiation_rule_checks_completed passed={} error_count=0", result.passed()); - } else { - LOGGER.atWarn() - .log( - "negotiation_rule_checks_completed passed={} error_count={}", - result.passed(), - result.errors().size()); - } - return result; - } - - /** - * Returns the body lines of the negotiation context section, or null when the text has no such section. - * - *

      Content before the first section title is discarded entirely, whether or not a context section follows. - */ - private static List contextSectionBody(String prompt, String contextTitle) { - if (prompt == null || prompt.isEmpty()) { - return null; - } - boolean inContextSection = false; - List bodyLines = new ArrayList<>(); - for (String rawLine : prompt.split("\n", -1)) { - String line = rawLine.strip(); - if (line.startsWith(SECTION_PREFIX)) { - String title = line.substring(SECTION_PREFIX.length()).strip(); - if (title.equals(contextTitle)) { - if (inContextSection) { - break; - } - inContextSection = true; - continue; - } - if (inContextSection) { - break; - } - continue; - } - if (inContextSection) { - bodyLines.add(line); - } - } - return inContextSection ? bodyLines : null; - } - - /** - * Returns the stripped value of the first list line carrying the given prefix, or null when no such line exists. - */ - private static String stringValue(List bodyLines, String linePrefix) { - for (String line : bodyLines) { - if (line.startsWith(linePrefix)) { - return line.substring(linePrefix.length()).strip(); - } - } - return null; - } - - private static Integer integerValue(String valueText) { - if (valueText == null) { - return null; - } - try { - return Integer.valueOf(valueText); - } catch (NumberFormatException error) { - return null; - } - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default rule-level compliance checker for rendered negotiation messages. + * + *

      The text is split on lines starting with {@code ## }; anything before the first section title is discarded. A text + * is recognised as a negotiation message when one section title equals the vocabulary's negotiation context section + * title. For a recognised message the checker parses the {@code - id:}, {@code - round:} and {@code - maxRounds:} list + * lines of that section and enforces that the id is a UUID of 36 characters in 8-4-4-4-12 hexadecimal form, that the + * round and the round budget are integers of at least 1, and that the round does not exceed the budget. No other rules + * are applied: the checker does not infer the negotiation type, does not validate conclusion values, does not require + * ending result sections and does not check conditional-section exclusivity. + * + * @since 2026-08 + */ +public final class DefaultNegotiationComplianceChecker implements NegotiationComplianceChecker { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationComplianceChecker.class); + + private static final Pattern UUID_PATTERN = + Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + + private static final String SECTION_PREFIX = "## "; + + private static final String ID_SLOT = "id"; + + private static final String ROUND_SLOT = "round"; + + private static final String MAX_ROUNDS_SLOT = "maxRounds"; + + /** Creates the default checker. */ + public DefaultNegotiationComplianceChecker() {} + + /** + * Runs the rule-level compliance check of one rendered message. + * + * @param prompt rendered negotiation message text; a null or empty text is reported as not being a negotiation + * message + * @param vocabulary vocabulary supplying the language-specific section titles of the message + * @return rule check outcome: not a negotiation message when the context section is absent, otherwise the parsed + * context with no errors when every context rule holds, otherwise the context rule errors without a context + * @throws NullPointerException if the vocabulary is null + */ + @Override + public NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary) { + Objects.requireNonNull(vocabulary, "vocabulary"); + List contextBody = contextSectionBody(prompt, vocabulary.get("section.context")); + if (contextBody == null) { + return logResult(new NegotiationRuleCheckResult(false, false, List.of(), null)); + } + + String id = stringValue(contextBody, "- id:"); + String roundText = stringValue(contextBody, "- round:"); + String maxRoundsText = stringValue(contextBody, "- maxRounds:"); + Integer round = integerValue(roundText); + Integer maxRounds = integerValue(maxRoundsText); + + List errors = new ArrayList<>(); + collectIdErrors(id, errors); + collectRangeErrors(ROUND_SLOT, round, roundText, errors); + collectRangeErrors(MAX_ROUNDS_SLOT, maxRounds, maxRoundsText, errors); + if (round != null && maxRounds != null && round >= 1 && maxRounds >= 1 && round > maxRounds) { + errors.add(new SlotValidationError( + ROUND_SLOT, + "out_of_range", + "Negotiation context round " + round + " must not exceed maxRounds " + maxRounds + ".")); + } + + if (errors.isEmpty()) { + NegotiationContext context = new NegotiationContext(id, round, maxRounds); + return logResult(new NegotiationRuleCheckResult(true, true, List.of(), context)); + } + return logResult(new NegotiationRuleCheckResult(false, true, List.copyOf(errors), null)); + } + + private static void collectIdErrors(String id, List errors) { + if (id == null) { + errors.add(new SlotValidationError( + ID_SLOT, "missing_field", "Negotiation context section is missing the id line.")); + return; + } + if (!UUID_PATTERN.matcher(id).matches()) { + errors.add(new SlotValidationError( + ID_SLOT, + "invalid_uuid", + "Negotiation context id must be a 36-character UUID in 8-4-4-4-12 hexadecimal form but was '" + id + + "'.")); + } + } + + private static void collectRangeErrors( + String slotName, Integer value, String valueText, List errors) { + if (value == null) { + if (valueText == null) { + errors.add(new SlotValidationError( + slotName, + "missing_field", + "Negotiation context section is missing the " + slotName + " line.")); + } else { + errors.add(new SlotValidationError( + slotName, + "invalid_value", + "Negotiation context " + slotName + " must be an integer but was '" + valueText + "'.")); + } + return; + } + if (value < 1) { + errors.add(new SlotValidationError( + slotName, + "out_of_range", + "Negotiation context " + slotName + " must be a positive integer but was " + value + ".")); + } + } + + /** + * Emits the rule-check completion event of one check outcome. + * + * @param result rule check outcome to log + * @return the unchanged outcome + */ + private static NegotiationRuleCheckResult logResult(NegotiationRuleCheckResult result) { + if (result.errors().isEmpty()) { + LOGGER.atDebug().log("negotiation_rule_checks_completed passed={} error_count=0", result.passed()); + } else { + LOGGER.atWarn() + .log( + "negotiation_rule_checks_completed passed={} error_count={}", + result.passed(), + result.errors().size()); + } + return result; + } + + /** + * Returns the body lines of the negotiation context section, or null when the text has no such section. + * + *

      Content before the first section title is discarded entirely, whether or not a context section follows. + */ + private static List contextSectionBody(String prompt, String contextTitle) { + if (prompt == null || prompt.isEmpty()) { + return null; + } + boolean inContextSection = false; + List bodyLines = new ArrayList<>(); + for (String rawLine : prompt.split("\n", -1)) { + String line = rawLine.strip(); + if (line.startsWith(SECTION_PREFIX)) { + String title = line.substring(SECTION_PREFIX.length()).strip(); + if (title.equals(contextTitle)) { + if (inContextSection) { + break; + } + inContextSection = true; + continue; + } + if (inContextSection) { + break; + } + continue; + } + if (inContextSection) { + bodyLines.add(line); + } + } + return inContextSection ? bodyLines : null; + } + + /** + * Returns the stripped value of the first list line carrying the given prefix, or null when no such line exists. + */ + private static String stringValue(List bodyLines, String linePrefix) { + for (String line : bodyLines) { + if (line.startsWith(linePrefix)) { + return line.substring(linePrefix.length()).strip(); + } + } + return null; + } + + private static Integer integerValue(String valueText) { + if (valueText == null) { + return null; + } + try { + return Integer.valueOf(valueText); + } catch (NumberFormatException error) { + return null; + } + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java index 948d287b..c70f3b6b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java @@ -1,35 +1,35 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.List; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; - -/** - * Outcome of the rule-level compliance check of a rendered negotiation message. - * - *

      The checker only validates the negotiation context section, so this result carries exactly four components: the - * overall pass flag, the flag telling whether the text is a negotiation message at all, the structured rule errors and - * the parsed negotiation context. Nothing else is inferred or checked here. - * - * @param passed {@code true} only when the text is a negotiation message whose negotiation context section satisfies - * every context rule - * @param isNegotiation {@code true} when the text contains the negotiation context section and is therefore a - * negotiation message - * @param errors structured rule errors of the negotiation context section; empty when the text is not a negotiation - * message or when every rule passes - * @param context parsed negotiation context; {@code null} unless the text is a negotiation message whose context is - * fully valid - * @since 2026-08 - */ -public record NegotiationRuleCheckResult( - boolean passed, boolean isNegotiation, List errors, NegotiationContext context) { - - /** - * Normalizes the error list. - * - * @throws NullPointerException if the errors list is null - */ - public NegotiationRuleCheckResult { - errors = List.copyOf(errors); - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.List; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.NegotiationContext; + +/** + * Outcome of the rule-level compliance check of a rendered negotiation message. + * + *

      The checker only validates the negotiation context section, so this result carries exactly four components: the + * overall pass flag, the flag telling whether the text is a negotiation message at all, the structured rule errors and + * the parsed negotiation context. Nothing else is inferred or checked here. + * + * @param passed {@code true} only when the text is a negotiation message whose negotiation context section satisfies + * every context rule + * @param isNegotiation {@code true} when the text contains the negotiation context section and is therefore a + * negotiation message + * @param errors structured rule errors of the negotiation context section; empty when the text is not a negotiation + * message or when every rule passes + * @param context parsed negotiation context; {@code null} unless the text is a negotiation message whose context is + * fully valid + * @since 2026-08 + */ +public record NegotiationRuleCheckResult( + boolean passed, boolean isNegotiation, List errors, NegotiationContext context) { + + /** + * Normalizes the error list. + * + * @throws NullPointerException if the errors list is null + */ + public NegotiationRuleCheckResult { + errors = List.copyOf(errors); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java index 2255e9b6..b6517716 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java @@ -8,7 +8,7 @@ import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.validation.RuleChecker; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.Vocabulary; /** diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java index 7533f1df..47da37e5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java @@ -1,162 +1,162 @@ -package net.openan.a2at.sdk.negotiation.concurrency; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Test; - -/** - * Runs a mixed concurrent load against one orchestrator and compares every thread's outcome with its serial baseline. - * - *

      Threads perform deterministic from-data generation, LLM-backed from-text generation and validation-based parameter - * extraction in parallel against one shared orchestrator whose scripted LLM client routes its responses by the schema - * of the call. Any leaked exception, wrong routing or shared mutable state fails the test; each thread's result must - * equal the serial baseline of its operation. - */ -class NegotiationConcurrentMixedLoadTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final int THREADS = 12; - - private static final int ITERATIONS = 5; - - @Test - void everyThreadMatchesItsSerialBaselineWithoutExceptionLeaks() throws Exception { - RoutingScriptedClient llm = new RoutingScriptedClient(); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build(); - - NegotiationProposeData proposeData = new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)); - MetadataContent fromDataBase = orchestrator.generateProposeFromData(proposeData, INFORMATION_PROPOSE_URI); - MetadataContent fromTextBase = orchestrator.generateProposeFromText( - "请提供节能区域信息。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); - FilledParamData validateBase = orchestrator.validateProposePromptAndDataFilling( - fromDataBase.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); - int baselineExtractionCalls = llm.extractionCalls.get(); - int baselineSemanticCalls = llm.semanticCalls.get(); - - List> workers = new ArrayList<>(); - int fromDataThreads = 0; - int fromTextThreads = 0; - int validateThreads = 0; - for (int index = 0; index < THREADS; index++) { - int kind = index % 3; - if (kind == 0) { - fromDataThreads++; - workers.add(callableOf(() -> { - MetadataContent result = orchestrator.generateProposeFromData(proposeData, INFORMATION_PROPOSE_URI); - assertEquals(fromDataBase.promptText(), result.promptText(), "from-data baseline"); - })); - } else if (kind == 1) { - fromTextThreads++; - workers.add(callableOf(() -> { - MetadataContent result = orchestrator.generateProposeFromText( - "请提供节能区域信息。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); - assertEquals(fromTextBase.promptText(), result.promptText(), "from-text baseline"); - })); - } else { - validateThreads++; - workers.add(callableOf(() -> { - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - fromDataBase.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); - assertEquals(validateBase.data(), filled.data(), "validation baseline"); - })); - } - } - - ExecutorService pool = Executors.newFixedThreadPool(THREADS); - try { - List> futures = new ArrayList<>(); - for (int iteration = 0; iteration < ITERATIONS; iteration++) { - workers.forEach(worker -> futures.add(pool.submit(worker))); - } - for (Future future : futures) { - future.get(30, TimeUnit.SECONDS); - } - } finally { - pool.shutdownNow(); - org.junit.jupiter.api.Assertions.assertTrue( - pool.awaitTermination(30, TimeUnit.SECONDS), "pool must terminate"); - } - - assertEquals( - baselineExtractionCalls + fromTextThreads * ITERATIONS, - llm.extractionCalls.get(), - "extraction call count"); - assertEquals( - baselineSemanticCalls + validateThreads * ITERATIONS, llm.semanticCalls.get(), "semantic call count"); - } - - private static Callable callableOf(ThrowingRunnable action) { - return () -> { - action.run(); - return null; - }; - } - - @FunctionalInterface - private interface ThrowingRunnable { - - void run() throws Exception; - } - - /** - * Thread-safe scripted LLM client routing by the output schema of the call: schemas carrying the semantic-verdict - * key belong to the validation pipeline, every other schema to the content-extraction step. - */ - private static final class RoutingScriptedClient implements LLMClient { - - private final AtomicInteger extractionCalls = new AtomicInteger(); - - private final AtomicInteger semanticCalls = new AtomicInteger(); - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - Object properties = jsonSchema == null ? null : jsonSchema.get("properties"); - boolean semantic = - properties instanceof Map propertyMap && propertyMap.containsKey("semantic_verdict"); - String payload; - if (semantic) { - semanticCalls.incrementAndGet(); - payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}"; - } else { - extractionCalls.incrementAndGet(); - payload = "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; - } - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.negotiation.concurrency; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import org.junit.jupiter.api.Test; + +/** + * Runs a mixed concurrent load against one orchestrator and compares every thread's outcome with its serial baseline. + * + *

      Threads perform deterministic from-data generation, LLM-backed from-text generation and validation-based parameter + * extraction in parallel against one shared orchestrator whose scripted LLM client routes its responses by the schema + * of the call. Any leaked exception, wrong routing or shared mutable state fails the test; each thread's result must + * equal the serial baseline of its operation. + */ +class NegotiationConcurrentMixedLoadTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final int THREADS = 12; + + private static final int ITERATIONS = 5; + + @Test + void everyThreadMatchesItsSerialBaselineWithoutExceptionLeaks() throws Exception { + RoutingScriptedClient llm = new RoutingScriptedClient(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build(); + + NegotiationProposeData proposeData = new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)); + MetadataContent fromDataBase = orchestrator.generateProposeFromData(proposeData, INFORMATION_PROPOSE_URI); + MetadataContent fromTextBase = orchestrator.generateProposeFromText( + "请提供节能区域信息。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); + FilledParamData validateBase = orchestrator.validateProposePromptAndDataFilling( + fromDataBase.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); + int baselineExtractionCalls = llm.extractionCalls.get(); + int baselineSemanticCalls = llm.semanticCalls.get(); + + List> workers = new ArrayList<>(); + int fromDataThreads = 0; + int fromTextThreads = 0; + int validateThreads = 0; + for (int index = 0; index < THREADS; index++) { + int kind = index % 3; + if (kind == 0) { + fromDataThreads++; + workers.add(callableOf(() -> { + MetadataContent result = orchestrator.generateProposeFromData(proposeData, INFORMATION_PROPOSE_URI); + assertEquals(fromDataBase.promptText(), result.promptText(), "from-data baseline"); + })); + } else if (kind == 1) { + fromTextThreads++; + workers.add(callableOf(() -> { + MetadataContent result = orchestrator.generateProposeFromText( + "请提供节能区域信息。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); + assertEquals(fromTextBase.promptText(), result.promptText(), "from-text baseline"); + })); + } else { + validateThreads++; + workers.add(callableOf(() -> { + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + fromDataBase.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); + assertEquals(validateBase.data(), filled.data(), "validation baseline"); + })); + } + } + + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + try { + List> futures = new ArrayList<>(); + for (int iteration = 0; iteration < ITERATIONS; iteration++) { + workers.forEach(worker -> futures.add(pool.submit(worker))); + } + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } finally { + pool.shutdownNow(); + org.junit.jupiter.api.Assertions.assertTrue( + pool.awaitTermination(30, TimeUnit.SECONDS), "pool must terminate"); + } + + assertEquals( + baselineExtractionCalls + fromTextThreads * ITERATIONS, + llm.extractionCalls.get(), + "extraction call count"); + assertEquals( + baselineSemanticCalls + validateThreads * ITERATIONS, llm.semanticCalls.get(), "semantic call count"); + } + + private static Callable callableOf(ThrowingRunnable action) { + return () -> { + action.run(); + return null; + }; + } + + @FunctionalInterface + private interface ThrowingRunnable { + + void run() throws Exception; + } + + /** + * Thread-safe scripted LLM client routing by the output schema of the call: schemas carrying the semantic-verdict + * key belong to the validation pipeline, every other schema to the content-extraction step. + */ + private static final class RoutingScriptedClient implements LLMClient { + + private final AtomicInteger extractionCalls = new AtomicInteger(); + + private final AtomicInteger semanticCalls = new AtomicInteger(); + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + Object properties = jsonSchema == null ? null : jsonSchema.get("properties"); + boolean semantic = + properties instanceof Map propertyMap && propertyMap.containsKey("semantic_verdict"); + String payload; + if (semantic) { + semanticCalls.incrementAndGet(); + payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"; + } else { + extractionCalls.incrementAndGet(); + payload = "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; + } + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationContextImmutabilityTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationContextImmutabilityTest.java index de8ae7ab..140bec59 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationContextImmutabilityTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationContextImmutabilityTest.java @@ -1,94 +1,94 @@ -package net.openan.a2at.sdk.negotiation.concurrency; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; -import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckResult; -import org.junit.jupiter.api.Test; - -/** - * Verifies the immutability and purity contracts of the negotiation content layer under concurrent access. - * - *

      A shared {@link NegotiationContext} is read by many threads that each derive the next round: the original object - * must never change and every derived object must be identical. The rule checker is a pure function: repeated concurrent calls with the same input must return the same result. - */ -class NegotiationContextImmutabilityTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final int THREADS = 8; - - private static final int ITERATIONS = 200; - - @Test - void sharedContextNextRoundNeverMutatesTheOriginal() throws Exception { - NegotiationContext shared = new NegotiationContext(UUID, 2, 5); - - ExecutorService pool = Executors.newFixedThreadPool(THREADS); - try { - Set derived = Collections.synchronizedSet(new HashSet<>()); - List> futures = new ArrayList<>(); - for (int index = 0; index < THREADS; index++) { - futures.add(pool.submit(() -> { - for (int iteration = 0; iteration < ITERATIONS; iteration++) { - derived.add(shared.nextRound()); - assertEquals(2, shared.round(), "original round must never change"); - assertEquals(5, shared.maxRounds(), "original maxRounds must never change"); - assertEquals(UUID, shared.id(), "original id must never change"); - } - return null; - })); - } - for (Future future : futures) { - future.get(30, TimeUnit.SECONDS); - } - assertEquals(1, derived.size(), "every derived context must be identical"); - assertEquals(new NegotiationContext(UUID, 3, 5), derived.iterator().next()); - } finally { - pool.shutdownNow(); - } - } - - @Test - void ruleCheckerIsPureUnderConcurrentCalls() throws Exception { - String prompt = "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5"; - Vocabulary vocabulary = Vocabulary.forLanguage("zh-CN"); - DefaultNegotiationComplianceChecker checker = new DefaultNegotiationComplianceChecker(); - NegotiationRuleCheckResult baseline = checker.check(prompt, vocabulary); - - ExecutorService pool = Executors.newFixedThreadPool(THREADS); - try { - List> futures = new ArrayList<>(); - for (int index = 0; index < THREADS; index++) { - futures.add(pool.submit(() -> { - for (int iteration = 0; iteration < ITERATIONS; iteration++) { - NegotiationRuleCheckResult result = checker.check(prompt, vocabulary); - assertEquals(baseline.passed(), result.passed()); - assertEquals(baseline.isNegotiation(), result.isNegotiation()); - assertEquals(baseline.errors(), result.errors()); - assertEquals(baseline.context(), result.context()); - } - return null; - })); - } - for (Future future : futures) { - future.get(30, TimeUnit.SECONDS); - } - } finally { - pool.shutdownNow(); - } - } -} +package net.openan.a2at.sdk.negotiation.concurrency; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; +import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckResult; +import org.junit.jupiter.api.Test; + +/** + * Verifies the immutability and purity contracts of the negotiation content layer under concurrent access. + * + *

      A shared {@link NegotiationContext} is read by many threads that each derive the next round: the original object + * must never change and every derived object must be identical. The rule checker is a pure function: repeated concurrent calls with the same input must return the same result. + */ +class NegotiationContextImmutabilityTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final int THREADS = 8; + + private static final int ITERATIONS = 200; + + @Test + void sharedContextNextRoundNeverMutatesTheOriginal() throws Exception { + NegotiationContext shared = new NegotiationContext(UUID, 2, 5); + + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + try { + Set derived = Collections.synchronizedSet(new HashSet<>()); + List> futures = new ArrayList<>(); + for (int index = 0; index < THREADS; index++) { + futures.add(pool.submit(() -> { + for (int iteration = 0; iteration < ITERATIONS; iteration++) { + derived.add(shared.nextRound()); + assertEquals(2, shared.round(), "original round must never change"); + assertEquals(5, shared.maxRounds(), "original maxRounds must never change"); + assertEquals(UUID, shared.id(), "original id must never change"); + } + return null; + })); + } + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + assertEquals(1, derived.size(), "every derived context must be identical"); + assertEquals(new NegotiationContext(UUID, 3, 5), derived.iterator().next()); + } finally { + pool.shutdownNow(); + } + } + + @Test + void ruleCheckerIsPureUnderConcurrentCalls() throws Exception { + String prompt = "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5"; + Vocabulary vocabulary = Vocabulary.forLanguage("zh-CN"); + DefaultNegotiationComplianceChecker checker = new DefaultNegotiationComplianceChecker(); + NegotiationRuleCheckResult baseline = checker.check(prompt, vocabulary); + + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + try { + List> futures = new ArrayList<>(); + for (int index = 0; index < THREADS; index++) { + futures.add(pool.submit(() -> { + for (int iteration = 0; iteration < ITERATIONS; iteration++) { + NegotiationRuleCheckResult result = checker.check(prompt, vocabulary); + assertEquals(baseline.passed(), result.passed()); + assertEquals(baseline.isNegotiation(), result.isNegotiation()); + assertEquals(baseline.errors(), result.errors()); + assertEquals(baseline.context(), result.context()); + } + return null; + })); + } + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } finally { + pool.shutdownNow(); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java index 98f4630f..55b77837 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -32,7 +32,6 @@ void contentPackageExposesExactlyThePinnedTypes() throws IOException { "NegotiationAction.java", "NegotiationConclusion.java", "NegotiationContent.java", - "NegotiationContext.java", "NegotiationEndingContent.java", "NegotiationEndingData.java", "NegotiationGenerationException.java", diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java index 2018071d..379a2e19 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java @@ -17,7 +17,7 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; import org.junit.jupiter.api.Test; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java index d810571a..1d23f772 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java @@ -1,196 +1,196 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -/** - * Verifies the conclusion literals and the feasibility summary exception slot of the terminal messages. - * - *

      The conclusion slot of every terminal message renders the fixed literal {@code Accept} or {@code Reject}. The - * feasibility terminal message renders its summary under the section title of the vocabulary while the underlying slot - * name is the vocabulary exception: the section appears under its own title, never under the slot name, which proves - * the exception mapping is wired correctly (a missing exception mapping would silently drop the whole section). - */ -class ConclusionLiteralAndExceptionSlotTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - static Stream languages() { - return Stream.of(Arguments.of("zh-CN"), Arguments.of("en-US")); - } - - @ParameterizedTest(name = "accept conclusion renders the Accept literal [{0}]") - @MethodSource("languages") - void acceptConclusionRendersTheAcceptLiteral(String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - NegotiationContext context = new NegotiationContext(UUID, 2, 5); - - String information = orchestrator - .generateAcceptFromData( - new NegotiationEndingData( - context, - new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of(new NegotiationItem("area information", "Songshan Lake")))), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT) - .promptText(); - String target = orchestrator - .generateAcceptFromData( - new NegotiationEndingData( - context, - new TargetEndingContent( - NegotiationConclusion.ACCEPT, "The confirmed intent is recorded.", null)), - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) - .promptText(); - String feasibility = orchestrator - .generateAcceptFromData( - new NegotiationEndingData( - context, - new FeasibilityEndingContent( - NegotiationConclusion.ACCEPT, "The target is achievable.")), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) - .promptText(); - - assertTrue(information.contains(conclusionSection(language, "information") + "\nAccept")); - assertTrue(target.contains(conclusionSection(language, "target") + "\nAccept")); - assertTrue(feasibility.contains(conclusionSection(language, "feasibility") + "\nAccept")); - assertFalse(information.contains("\nReject")); - assertFalse(target.contains("\nReject")); - assertFalse(feasibility.contains("\nReject")); - } - - @ParameterizedTest(name = "reject conclusion renders the Reject literal [{0}]") - @MethodSource("languages") - void rejectConclusionRendersTheRejectLiteral(String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - NegotiationContext context = new NegotiationContext(UUID, 2, 5); - - String information = orchestrator - .generateRejectFromData( - new NegotiationEndingData( - context, - new InformationEndingContent( - NegotiationConclusion.REJECT, - List.of(new NegotiationItem("area information", "not available")))), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT) - .promptText(); - String target = orchestrator - .generateRejectFromData( - new NegotiationEndingData( - context, - new TargetEndingContent( - NegotiationConclusion.REJECT, null, "The intent cannot be clarified.")), - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) - .promptText(); - String feasibility = orchestrator - .generateRejectFromData( - new NegotiationEndingData( - context, - new FeasibilityEndingContent( - NegotiationConclusion.REJECT, "The target is not achievable.")), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) - .promptText(); - - assertTrue(information.contains(conclusionSection(language, "information") + "\nReject")); - assertTrue(target.contains(conclusionSection(language, "target") + "\nReject")); - assertTrue(feasibility.contains(conclusionSection(language, "feasibility") + "\nReject")); - } - - /** - * Locks the T2 exception slot: the summary appears as the body of the section titled with the section vocabulary - * key, while a section titled with the raw slot name must never appear. - */ - @ParameterizedTest(name = "feasibility summary uses the exception slot under its own section title [{0}]") - @MethodSource("languages") - void feasibilitySummaryUsesTheExceptionSlotUnderItsOwnSectionTitle(String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - String summary = "The adjusted target is achievable; this negotiation is confirmed as concluded."; - - String acceptText = orchestrator - .generateAcceptFromData( - new NegotiationEndingData( - new NegotiationContext(UUID, 2, 5), - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, summary)), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) - .promptText(); - String rejectText = orchestrator - .generateRejectFromData( - new NegotiationEndingData( - new NegotiationContext(UUID, 2, 5), - new FeasibilityEndingContent(NegotiationConclusion.REJECT, summary)), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) - .promptText(); - - String sectionTitle = - "zh-CN".equals(language) ? "## 可行性评估结果确认" : "## Feasibility Assessment Result Confirmation"; - String rawSlotNameLine = "zh-CN".equals(language) ? "## 评估结果确认\n" : "## evaluation_result_confirmation\n"; - for (String promptText : List.of(acceptText, rejectText)) { - assertTrue(promptText.contains(sectionTitle + "\n" + summary)); - assertFalse(promptText.contains(rawSlotNameLine), "the raw slot name must not surface as a section title"); - } - } - - /** The summary itself is never mangled: the section carries it verbatim as its only body line. */ - @ParameterizedTest(name = "feasibility summary is carried verbatim [{0}]") - @MethodSource("languages") - void feasibilitySummaryIsCarriedVerbatim(String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - String summary = "The adjusted target is achievable; this negotiation is confirmed as concluded."; - - String promptText = orchestrator - .generateAcceptFromData( - new NegotiationEndingData( - new NegotiationContext(UUID, 2, 5), - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, summary)), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) - .promptText(); - - assertEquals(1, countOccurrences(promptText, summary)); - } - - private static int countOccurrences(String text, String needle) { - int count = 0; - int index = 0; - while ((index = text.indexOf(needle, index)) >= 0) { - count++; - index += needle.length(); - } - return count; - } - - private static NegotiationGenerationOrchestrator orchestrator(String language) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .build(); - } - - private static String conclusionSection(String language, String type) { - if ("zh-CN".equals(language)) { - return switch (type) { - case "information" -> "## 信息协商结果"; - case "target" -> "## 目标协商结果"; - default -> "## 可行性协商结果"; - }; - } - return switch (type) { - case "information" -> "## Information Negotiation Result"; - case "target" -> "## Target Negotiation Result"; - default -> "## Feasibility Negotiation Result"; - }; - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Verifies the conclusion literals and the feasibility summary exception slot of the terminal messages. + * + *

      The conclusion slot of every terminal message renders the fixed literal {@code Accept} or {@code Reject}. The + * feasibility terminal message renders its summary under the section title of the vocabulary while the underlying slot + * name is the vocabulary exception: the section appears under its own title, never under the slot name, which proves + * the exception mapping is wired correctly (a missing exception mapping would silently drop the whole section). + */ +class ConclusionLiteralAndExceptionSlotTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + static Stream languages() { + return Stream.of(Arguments.of("zh-CN"), Arguments.of("en-US")); + } + + @ParameterizedTest(name = "accept conclusion renders the Accept literal [{0}]") + @MethodSource("languages") + void acceptConclusionRendersTheAcceptLiteral(String language) { + NegotiationGenerationOrchestrator orchestrator = orchestrator(language); + NegotiationContext context = new NegotiationContext(UUID, 2, 5); + + String information = orchestrator + .generateAcceptFromData( + new NegotiationEndingData( + context, + new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of(new NegotiationItem("area information", "Songshan Lake")))), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT) + .promptText(); + String target = orchestrator + .generateAcceptFromData( + new NegotiationEndingData( + context, + new TargetEndingContent( + NegotiationConclusion.ACCEPT, "The confirmed intent is recorded.", null)), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) + .promptText(); + String feasibility = orchestrator + .generateAcceptFromData( + new NegotiationEndingData( + context, + new FeasibilityEndingContent( + NegotiationConclusion.ACCEPT, "The target is achievable.")), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .promptText(); + + assertTrue(information.contains(conclusionSection(language, "information") + "\nAccept")); + assertTrue(target.contains(conclusionSection(language, "target") + "\nAccept")); + assertTrue(feasibility.contains(conclusionSection(language, "feasibility") + "\nAccept")); + assertFalse(information.contains("\nReject")); + assertFalse(target.contains("\nReject")); + assertFalse(feasibility.contains("\nReject")); + } + + @ParameterizedTest(name = "reject conclusion renders the Reject literal [{0}]") + @MethodSource("languages") + void rejectConclusionRendersTheRejectLiteral(String language) { + NegotiationGenerationOrchestrator orchestrator = orchestrator(language); + NegotiationContext context = new NegotiationContext(UUID, 2, 5); + + String information = orchestrator + .generateRejectFromData( + new NegotiationEndingData( + context, + new InformationEndingContent( + NegotiationConclusion.REJECT, + List.of(new NegotiationItem("area information", "not available")))), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT) + .promptText(); + String target = orchestrator + .generateRejectFromData( + new NegotiationEndingData( + context, + new TargetEndingContent( + NegotiationConclusion.REJECT, null, "The intent cannot be clarified.")), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) + .promptText(); + String feasibility = orchestrator + .generateRejectFromData( + new NegotiationEndingData( + context, + new FeasibilityEndingContent( + NegotiationConclusion.REJECT, "The target is not achievable.")), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .promptText(); + + assertTrue(information.contains(conclusionSection(language, "information") + "\nReject")); + assertTrue(target.contains(conclusionSection(language, "target") + "\nReject")); + assertTrue(feasibility.contains(conclusionSection(language, "feasibility") + "\nReject")); + } + + /** + * Locks the T2 exception slot: the summary appears as the body of the section titled with the section vocabulary + * key, while a section titled with the raw slot name must never appear. + */ + @ParameterizedTest(name = "feasibility summary uses the exception slot under its own section title [{0}]") + @MethodSource("languages") + void feasibilitySummaryUsesTheExceptionSlotUnderItsOwnSectionTitle(String language) { + NegotiationGenerationOrchestrator orchestrator = orchestrator(language); + String summary = "The adjusted target is achievable; this negotiation is confirmed as concluded."; + + String acceptText = orchestrator + .generateAcceptFromData( + new NegotiationEndingData( + new NegotiationContext(UUID, 2, 5), + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, summary)), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .promptText(); + String rejectText = orchestrator + .generateRejectFromData( + new NegotiationEndingData( + new NegotiationContext(UUID, 2, 5), + new FeasibilityEndingContent(NegotiationConclusion.REJECT, summary)), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .promptText(); + + String sectionTitle = + "zh-CN".equals(language) ? "## 可行性评估结果确认" : "## Feasibility Assessment Result Confirmation"; + String rawSlotNameLine = "zh-CN".equals(language) ? "## 评估结果确认\n" : "## evaluation_result_confirmation\n"; + for (String promptText : List.of(acceptText, rejectText)) { + assertTrue(promptText.contains(sectionTitle + "\n" + summary)); + assertFalse(promptText.contains(rawSlotNameLine), "the raw slot name must not surface as a section title"); + } + } + + /** The summary itself is never mangled: the section carries it verbatim as its only body line. */ + @ParameterizedTest(name = "feasibility summary is carried verbatim [{0}]") + @MethodSource("languages") + void feasibilitySummaryIsCarriedVerbatim(String language) { + NegotiationGenerationOrchestrator orchestrator = orchestrator(language); + String summary = "The adjusted target is achievable; this negotiation is confirmed as concluded."; + + String promptText = orchestrator + .generateAcceptFromData( + new NegotiationEndingData( + new NegotiationContext(UUID, 2, 5), + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, summary)), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .promptText(); + + assertEquals(1, countOccurrences(promptText, summary)); + } + + private static int countOccurrences(String text, String needle) { + int count = 0; + int index = 0; + while ((index = text.indexOf(needle, index)) >= 0) { + count++; + index += needle.length(); + } + return count; + } + + private static NegotiationGenerationOrchestrator orchestrator(String language) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .build(); + } + + private static String conclusionSection(String language, String type) { + if ("zh-CN".equals(language)) { + return switch (type) { + case "information" -> "## 信息协商结果"; + case "target" -> "## 目标协商结果"; + default -> "## 可行性协商结果"; + }; + } + return switch (type) { + case "information" -> "## Information Negotiation Result"; + case "target" -> "## Target Negotiation Result"; + default -> "## Feasibility Negotiation Result"; + }; + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java index 7b99fded..d31345c9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java @@ -1,182 +1,182 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -/** - * Verifies the conditional sections of the propose templates against direct (non-golden) assertions. - * - *

      The feasibility propose template renders exactly one of its two conditional sections, selected by the action; the - * target propose template renders the intent understanding section only on the first round, the alignment and - * clarification section only on later rounds, and drops the clarification request section entirely when no - * clarification items are present. - */ -class ConditionalSectionRenderingTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; - - private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; - - static Stream languages() { - return Stream.of(Arguments.of("zh-CN"), Arguments.of("en-US")); - } - - @ParameterizedTest(name = "evaluation request renders only the evaluate section [{0}]") - @MethodSource("languages") - void evaluationRequestRendersOnlyTheContentsToEvaluateSection(String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - - String promptText = orchestrator - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new FeasibilityProposeContent( - "Please assess the adjusted rate target.", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("adjusted target", "rate lowered to 2Mbps")), - null)), - FEASIBILITY_PROPOSE_URI) - .promptText(); - - assertTrue(promptText.contains(evaluateTitle(language))); - assertFalse(promptText.contains(infeasibleTitle(language))); - } - - @ParameterizedTest(name = "alternative proposal renders only the infeasibility section [{0}]") - @MethodSource("languages") - void alternativeProposalRendersOnlyTheInfeasibilitySection(String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - - String promptText = orchestrator - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 2, 5), - new FeasibilityProposeContent( - "The rate target is infeasible; a proposal follows.", - NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, - null, - List.of(new NegotiationItem( - "proposal", "lower the rate guarantee target to 2Mbps")))), - FEASIBILITY_PROPOSE_URI) - .promptText(); - - assertTrue(promptText.contains(infeasibleTitle(language))); - assertFalse(promptText.contains(evaluateTitle(language))); - } - - @ParameterizedTest(name = "round 1 renders the intent section without the alignment section [{0}]") - @MethodSource("languages") - void firstRoundRendersIntentWithoutAlignment(String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - - String promptText = orchestrator - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new TargetProposeContent( - "Clarify the intent of the energy-saving task.", - List.of(new NegotiationItem("task intent", "energy-saving optimization")), - null, - null)), - TARGET_PROPOSE_URI) - .promptText(); - - assertTrue(promptText.contains(intentTitle(language))); - assertFalse(promptText.contains(alignmentTitle(language))); - } - - @ParameterizedTest(name = "later rounds render the alignment section without the intent section [{0}]") - @MethodSource("languages") - void laterRoundsRenderAlignmentWithoutIntent(String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - - String promptText = orchestrator - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 3, 5), - new TargetProposeContent( - "Clarify the intent of the energy-saving task.", - null, - List.of(new NegotiationItem("task intent", "confirmed as correct")), - null)), - TARGET_PROPOSE_URI) - .promptText(); - - assertTrue(promptText.contains(alignmentTitle(language))); - assertFalse(promptText.contains(intentTitle(language))); - } - - @ParameterizedTest(name = "empty clarification list drops the clarification section [{0}]") - @MethodSource("languages") - void emptyClarificationListDropsTheClarificationSection(String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - - String withoutClarification = orchestrator - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new TargetProposeContent( - "Confirm the understood intent of the energy-saving task.", - List.of(new NegotiationItem("task intent", "energy-saving optimization")), - null, - null)), - TARGET_PROPOSE_URI) - .promptText(); - assertFalse(withoutClarification.contains(clarificationTitle(language))); - - String withClarification = orchestrator - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new TargetProposeContent( - "Confirm the understood intent of the energy-saving task.", - List.of(new NegotiationItem("task intent", "energy-saving optimization")), - null, - List.of(new NegotiationItem("area", "which site is covered")))), - TARGET_PROPOSE_URI) - .promptText(); - assertTrue(withClarification.contains(clarificationTitle(language))); - } - - private static NegotiationGenerationOrchestrator orchestrator(String language) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .build(); - } - - private static String evaluateTitle(String language) { - return "zh-CN".equals(language) ? "## 待评估内容说明" : "## Under Evaluation Description"; - } - - private static String infeasibleTitle(String language) { - return "zh-CN".equals(language) ? "## 评估不可行时的详情和提案" : "## Infeasible Evaluation Details and Proposal"; - } - - private static String intentTitle(String language) { - return "zh-CN".equals(language) ? "## 意图理解陈述" : "## Intent Understanding Statement"; - } - - private static String alignmentTitle(String language) { - return "zh-CN".equals(language) ? "## 理解对齐与疑问澄清" : "## Understanding Alignment and Clarification"; - } - - private static String clarificationTitle(String language) { - return "zh-CN".equals(language) ? "## 待澄清内容" : "## Content to Clarify"; - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Verifies the conditional sections of the propose templates against direct (non-golden) assertions. + * + *

      The feasibility propose template renders exactly one of its two conditional sections, selected by the action; the + * target propose template renders the intent understanding section only on the first round, the alignment and + * clarification section only on later rounds, and drops the clarification request section entirely when no + * clarification items are present. + */ +class ConditionalSectionRenderingTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; + + private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; + + static Stream languages() { + return Stream.of(Arguments.of("zh-CN"), Arguments.of("en-US")); + } + + @ParameterizedTest(name = "evaluation request renders only the evaluate section [{0}]") + @MethodSource("languages") + void evaluationRequestRendersOnlyTheContentsToEvaluateSection(String language) { + NegotiationGenerationOrchestrator orchestrator = orchestrator(language); + + String promptText = orchestrator + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new FeasibilityProposeContent( + "Please assess the adjusted rate target.", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("adjusted target", "rate lowered to 2Mbps")), + null)), + FEASIBILITY_PROPOSE_URI) + .promptText(); + + assertTrue(promptText.contains(evaluateTitle(language))); + assertFalse(promptText.contains(infeasibleTitle(language))); + } + + @ParameterizedTest(name = "alternative proposal renders only the infeasibility section [{0}]") + @MethodSource("languages") + void alternativeProposalRendersOnlyTheInfeasibilitySection(String language) { + NegotiationGenerationOrchestrator orchestrator = orchestrator(language); + + String promptText = orchestrator + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5), + new FeasibilityProposeContent( + "The rate target is infeasible; a proposal follows.", + NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, + null, + List.of(new NegotiationItem( + "proposal", "lower the rate guarantee target to 2Mbps")))), + FEASIBILITY_PROPOSE_URI) + .promptText(); + + assertTrue(promptText.contains(infeasibleTitle(language))); + assertFalse(promptText.contains(evaluateTitle(language))); + } + + @ParameterizedTest(name = "round 1 renders the intent section without the alignment section [{0}]") + @MethodSource("languages") + void firstRoundRendersIntentWithoutAlignment(String language) { + NegotiationGenerationOrchestrator orchestrator = orchestrator(language); + + String promptText = orchestrator + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new TargetProposeContent( + "Clarify the intent of the energy-saving task.", + List.of(new NegotiationItem("task intent", "energy-saving optimization")), + null, + null)), + TARGET_PROPOSE_URI) + .promptText(); + + assertTrue(promptText.contains(intentTitle(language))); + assertFalse(promptText.contains(alignmentTitle(language))); + } + + @ParameterizedTest(name = "later rounds render the alignment section without the intent section [{0}]") + @MethodSource("languages") + void laterRoundsRenderAlignmentWithoutIntent(String language) { + NegotiationGenerationOrchestrator orchestrator = orchestrator(language); + + String promptText = orchestrator + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 3, 5), + new TargetProposeContent( + "Clarify the intent of the energy-saving task.", + null, + List.of(new NegotiationItem("task intent", "confirmed as correct")), + null)), + TARGET_PROPOSE_URI) + .promptText(); + + assertTrue(promptText.contains(alignmentTitle(language))); + assertFalse(promptText.contains(intentTitle(language))); + } + + @ParameterizedTest(name = "empty clarification list drops the clarification section [{0}]") + @MethodSource("languages") + void emptyClarificationListDropsTheClarificationSection(String language) { + NegotiationGenerationOrchestrator orchestrator = orchestrator(language); + + String withoutClarification = orchestrator + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new TargetProposeContent( + "Confirm the understood intent of the energy-saving task.", + List.of(new NegotiationItem("task intent", "energy-saving optimization")), + null, + null)), + TARGET_PROPOSE_URI) + .promptText(); + assertFalse(withoutClarification.contains(clarificationTitle(language))); + + String withClarification = orchestrator + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new TargetProposeContent( + "Confirm the understood intent of the energy-saving task.", + List.of(new NegotiationItem("task intent", "energy-saving optimization")), + null, + List.of(new NegotiationItem("area", "which site is covered")))), + TARGET_PROPOSE_URI) + .promptText(); + assertTrue(withClarification.contains(clarificationTitle(language))); + } + + private static NegotiationGenerationOrchestrator orchestrator(String language) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .build(); + } + + private static String evaluateTitle(String language) { + return "zh-CN".equals(language) ? "## 待评估内容说明" : "## Under Evaluation Description"; + } + + private static String infeasibleTitle(String language) { + return "zh-CN".equals(language) ? "## 评估不可行时的详情和提案" : "## Infeasible Evaluation Details and Proposal"; + } + + private static String intentTitle(String language) { + return "zh-CN".equals(language) ? "## 意图理解陈述" : "## Intent Understanding Statement"; + } + + private static String alignmentTitle(String language) { + return "zh-CN".equals(language) ? "## 理解对齐与疑问澄清" : "## Understanding Alignment and Clarification"; + } + + private static String clarificationTitle(String language) { + return "zh-CN".equals(language) ? "## 待澄清内容" : "## Content to Clarify"; + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java index 1af25d47..d196eb7b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java @@ -1,153 +1,153 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Locks the classpath-only loading of the negotiation LLM prompt resources. - * - *

      A local resource root may override negotiation templates, but the LLM prompt resources are deliberately not taken - * from it: even with fake prompt files placed under the custom root, the system prompt sent to the LLM must remain the - * built-in classpath prompt of the configured language. Both prompt-consuming steps are covered: the content extraction - * of the from-text generation and the semantic validation of the parameter extraction pipeline. - */ -class CustomRootPromptsIgnoredTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String FAKE_PROMPT_MARKER = "FAKE_PROMPT_MARKER_5b19"; - - private static final String EXTRACTION_RESPONSE = - "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; - - private static final String SEMANTIC_RESPONSE = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}"; - - @TempDir - Path customRoot; - - @Test - void contentExtractionKeepsUsingTheBuiltInSystemPrompt() throws IOException { - writeFakePromptFiles("information_negotiation"); - RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE); - NegotiationGenerationOrchestrator orchestrator = orchestratorWithCustomRoot(llm); - NegotiationContext context = new NegotiationContext(UUID, 1, 5); - - MetadataContent result = orchestrator.generateProposeFromText("请提供节能区域。", context, INFORMATION_PROPOSE_URI); - - assertEquals(1, llm.callCount()); - String builtinSystemPrompt = - new NegotiationPromptResourceLoader().loadSystem("information_negotiation", "zh-CN"); - assertEquals( - builtinSystemPrompt, - llm.systemContentOfLastCall(), - "the system prompt sent to the LLM must be the built-in classpath prompt"); - assertTrue( - !llm.userContentOfLastCall().contains(FAKE_PROMPT_MARKER), - "the user prompt must not come from the custom root"); - assertTrue(llm.userContentOfLastCall().contains("请提供节能区域。"), "the user prompt must carry the input text"); - assertTrue(result.promptText().contains("节能区域")); - } - - @Test - void semanticValidationKeepsUsingTheBuiltInSystemPrompt() throws IOException { - writeFakePromptFiles("negotiation_semantic_validation"); - RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE, SEMANTIC_RESPONSE); - NegotiationGenerationOrchestrator orchestrator = orchestratorWithCustomRoot(llm); - MetadataContent message = orchestrator.generateProposeFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); - - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message.promptText(), - Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - INFORMATION_PROPOSE_URI); - - assertEquals(2, llm.callCount()); - String builtinSystemPrompt = - new NegotiationPromptResourceLoader().loadSystem("negotiation_semantic_validation", "zh-CN"); - assertEquals( - builtinSystemPrompt, - llm.systemContentOfLastCall(), - "the semantic validation system prompt must be the built-in classpath prompt"); - assertTrue(!llm.systemContentOfLastCall().contains(FAKE_PROMPT_MARKER)); - assertEquals("松山湖", filled.data().get("region")); - } - - private void writeFakePromptFiles(String promptCategory) throws IOException { - Path categoryDir = customRoot.resolve("prompts").resolve(promptCategory).resolve("zh-CN"); - Files.createDirectories(categoryDir); - Files.writeString(categoryDir.resolve("system.md"), FAKE_PROMPT_MARKER + " fake system prompt\n"); - Files.writeString(categoryDir.resolve("user.md"), FAKE_PROMPT_MARKER + " fake user prompt\n"); - } - - private NegotiationGenerationOrchestrator orchestratorWithCustomRoot(RecordingLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .localRootDir(customRoot.toString()) - .llmClient(llm) - .build(); - } - - private static final class RecordingLlmClient implements LLMClient { - - private final List payloads; - - private final ArrayList>> recordedMessages = new ArrayList<>(); - - private RecordingLlmClient(String... payloads) { - this.payloads = List.of(payloads); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - recordedMessages.add(messages); - String payload = payloads.get(Math.min(recordedMessages.size() - 1, payloads.size() - 1)); - return new LLMResponse( - payload, "recording-test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - - int callCount() { - return recordedMessages.size(); - } - - String systemContentOfLastCall() { - return roleContentOfLastCall("system"); - } - - String userContentOfLastCall() { - return roleContentOfLastCall("user"); - } - - private String roleContentOfLastCall(String role) { - List> messages = recordedMessages.get(recordedMessages.size() - 1); - return messages.stream() - .filter(message -> role.equals(message.get("role"))) - .map(message -> message.get("content")) - .findFirst() - .orElseThrow(); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Locks the classpath-only loading of the negotiation LLM prompt resources. + * + *

      A local resource root may override negotiation templates, but the LLM prompt resources are deliberately not taken + * from it: even with fake prompt files placed under the custom root, the system prompt sent to the LLM must remain the + * built-in classpath prompt of the configured language. Both prompt-consuming steps are covered: the content extraction + * of the from-text generation and the semantic validation of the parameter extraction pipeline. + */ +class CustomRootPromptsIgnoredTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String FAKE_PROMPT_MARKER = "FAKE_PROMPT_MARKER_5b19"; + + private static final String EXTRACTION_RESPONSE = + "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; + + private static final String SEMANTIC_RESPONSE = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"; + + @TempDir + Path customRoot; + + @Test + void contentExtractionKeepsUsingTheBuiltInSystemPrompt() throws IOException { + writeFakePromptFiles("information_negotiation"); + RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE); + NegotiationGenerationOrchestrator orchestrator = orchestratorWithCustomRoot(llm); + NegotiationContext context = new NegotiationContext(UUID, 1, 5); + + MetadataContent result = orchestrator.generateProposeFromText("请提供节能区域。", context, INFORMATION_PROPOSE_URI); + + assertEquals(1, llm.callCount()); + String builtinSystemPrompt = + new NegotiationPromptResourceLoader().loadSystem("information_negotiation", "zh-CN"); + assertEquals( + builtinSystemPrompt, + llm.systemContentOfLastCall(), + "the system prompt sent to the LLM must be the built-in classpath prompt"); + assertTrue( + !llm.userContentOfLastCall().contains(FAKE_PROMPT_MARKER), + "the user prompt must not come from the custom root"); + assertTrue(llm.userContentOfLastCall().contains("请提供节能区域。"), "the user prompt must carry the input text"); + assertTrue(result.promptText().contains("节能区域")); + } + + @Test + void semanticValidationKeepsUsingTheBuiltInSystemPrompt() throws IOException { + writeFakePromptFiles("negotiation_semantic_validation"); + RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE, SEMANTIC_RESPONSE); + NegotiationGenerationOrchestrator orchestrator = orchestratorWithCustomRoot(llm); + MetadataContent message = orchestrator.generateProposeFromText( + "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message.promptText(), + Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), + INFORMATION_PROPOSE_URI); + + assertEquals(2, llm.callCount()); + String builtinSystemPrompt = + new NegotiationPromptResourceLoader().loadSystem("negotiation_semantic_validation", "zh-CN"); + assertEquals( + builtinSystemPrompt, + llm.systemContentOfLastCall(), + "the semantic validation system prompt must be the built-in classpath prompt"); + assertTrue(!llm.systemContentOfLastCall().contains(FAKE_PROMPT_MARKER)); + assertEquals("松山湖", filled.data().get("region")); + } + + private void writeFakePromptFiles(String promptCategory) throws IOException { + Path categoryDir = customRoot.resolve("prompts").resolve(promptCategory).resolve("zh-CN"); + Files.createDirectories(categoryDir); + Files.writeString(categoryDir.resolve("system.md"), FAKE_PROMPT_MARKER + " fake system prompt\n"); + Files.writeString(categoryDir.resolve("user.md"), FAKE_PROMPT_MARKER + " fake user prompt\n"); + } + + private NegotiationGenerationOrchestrator orchestratorWithCustomRoot(RecordingLlmClient llm) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .localRootDir(customRoot.toString()) + .llmClient(llm) + .build(); + } + + private static final class RecordingLlmClient implements LLMClient { + + private final List payloads; + + private final ArrayList>> recordedMessages = new ArrayList<>(); + + private RecordingLlmClient(String... payloads) { + this.payloads = List.of(payloads); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + recordedMessages.add(messages); + String payload = payloads.get(Math.min(recordedMessages.size() - 1, payloads.size() - 1)); + return new LLMResponse( + payload, "recording-test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + + int callCount() { + return recordedMessages.size(); + } + + String systemContentOfLastCall() { + return roleContentOfLastCall("system"); + } + + String userContentOfLastCall() { + return roleContentOfLastCall("user"); + } + + private String roleContentOfLastCall(String role) { + List> messages = recordedMessages.get(recordedMessages.size() - 1); + return messages.stream() + .filter(message -> role.equals(message.get("role"))) + .map(message -> message.get("content")) + .findFirst() + .orElseThrow(); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java index 320296ed..49a5d57e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java @@ -19,7 +19,7 @@ import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 53a1094d..45ce8992 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -1,740 +1,740 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.model.LlmConfig; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.golden.GoldenInputs; -import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.slf4j.LoggerFactory; - -/** - * Integration tests of the from-text generation pipeline against the real built-in resources. - * - *

      The LLM is replaced by a scripted client that returns queued payloads or failures; every other collaborator is the - * production wiring. The suite covers the single-step content extraction (one LLM call, no type-recognition step), the - * byte-equality of the from-text output with the from-data output and the golden fixtures, the step-level retry policy - * with its warning logs, the infrastructure failure paths, the non-retryable failure codes that must stop after one LLM - * call, the max-attempts configuration semantics and the default en-US end-to-end chain. - */ -class FromTextLlmPipelineTest { - - private static final String ZH_CN = GoldenInputs.ZH_CN; - - private static final String EN_US = GoldenInputs.EN_US; - - private static final TemplateUri INFORMATION_PROPOSE_URI = GoldenCase.INFORMATION_PROPOSE.template(); - - private final ListAppender orchestratorAppender = new ListAppender<>(); - - private final ListAppender extractorAppender = new ListAppender<>(); - - private final ListAppender llmConfigAppender = new ListAppender<>(); - - private final Logger orchestratorLogger = (Logger) LoggerFactory.getLogger(NegotiationGenerationOrchestrator.class); - - private final Logger extractorLogger = (Logger) LoggerFactory.getLogger(DefaultNegotiationContentExtractor.class); - - private final Logger llmConfigLogger = (Logger) LoggerFactory.getLogger(LlmConfig.class); - - @BeforeEach - void attachLogAppenders() { - orchestratorAppender.start(); - orchestratorLogger.addAppender(orchestratorAppender); - extractorAppender.start(); - extractorLogger.addAppender(extractorAppender); - llmConfigAppender.start(); - llmConfigLogger.addAppender(llmConfigAppender); - } - - @AfterEach - void detachLogAppenders() { - orchestratorLogger.detachAppender(orchestratorAppender); - extractorLogger.detachAppender(extractorAppender); - llmConfigLogger.detachAppender(llmConfigAppender); - } - - /** - * IT-B-001: the from-text chain runs exactly one content-extraction LLM call and then renders deterministically: - * the output equals both the from-data generation of the same content and the committed golden fixture byte for - * byte, the metadata contract holds, and no type-recognition step exists anywhere in the pipeline. - */ - @Test - void fromTextMatchesFromDataAndTheGoldenFixtureAfterOneExtractionCall() { - GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; - ScriptedExtractionClient llm = - new ScriptedExtractionClient(extractionJson(goldenCase.content(), goldenCase.phase())); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - MetadataContent fromText = orchestrator.generateProposeFromText( - "请补充节能区域、节能速率保障目标与VLANId信息。", goldenCase.context(), goldenCase.template()); - - assertEquals(1, llm.calls, "from-text generation must run exactly one content extraction call"); - assertEquals(goldenCase.templateUri(), fromText.templateUri()); - - MetadataContent fromData = goldenCase.generate(orchestrator); - assertEquals(1, llm.calls, "the from-data variant must not add any LLM call"); - assertEquals(fromData.promptText(), fromText.promptText()); - assertEquals(readGoldenFixture(goldenCase, ZH_CN), fromText.promptText()); - - Map metadata = fromText.buildMetadataContent(); - assertEquals(2, metadata.size()); - assertEquals(fromText.promptText(), metadata.get(fromText.extensionUri())); - assertEquals(fromText.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - - assertEquals( - 2, llm.lastMessages.size(), "the extraction step must be a single system-plus-user message exchange"); - assertTrue( - logMessages(extractorAppender, Level.INFO).stream() - .anyMatch(message -> message.startsWith("negotiation_content_extraction_completed")), - "the completed extraction step must emit its info event"); - assertTrue( - logMessages(orchestratorAppender, Level.INFO).stream() - .noneMatch(message -> message.contains("negotiation_type_recognition")) - && logMessages(extractorAppender, Level.INFO).stream() - .noneMatch(message -> message.contains("negotiation_type_recognition")), - "the removed type-recognition step must never appear in the events"); - } - - /** - * IT-B-002: every from-text method (propose, accept, reject) succeeds for every negotiation type with exactly one - * LLM call per generation, the caller-supplied template URI is echoed, the output matches the golden fixture and - * the phase token reaches the LLM so the ending phases can be distinguished. - */ - @ParameterizedTest(name = "from-text succeeds for {0}") - @EnumSource(GoldenCase.class) - void fromTextSucceedsForEveryMethodAndTypeCombination(GoldenCase goldenCase) { - ScriptedExtractionClient llm = - new ScriptedExtractionClient(extractionJson(goldenCase.content(), goldenCase.phase())); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - MetadataContent result = generateFromText(orchestrator, goldenCase, "请根据以下文本生成协商报文。"); - - assertEquals(1, llm.calls); - assertEquals(goldenCase.templateUri(), result.templateUri()); - assertEquals(readGoldenFixture(goldenCase, ZH_CN), result.promptText()); - String userPrompt = llm.lastMessages.get(1).get("content"); - assertTrue( - userPrompt.contains(phaseToken(goldenCase.phase())), - "the user prompt must carry the phase token of the addressed method"); - } - - /** - * IT-B-002: the accept and reject methods share the same accept-reject template URI; both load that one template - * and differ only in the rendered conclusion literal. - */ - @Test - void acceptAndRejectShareTheSameAcceptRejectTemplate() { - assertEquals( - GoldenCase.INFORMATION_ACCEPT.templateUri(), - GoldenCase.INFORMATION_REJECT.templateUri(), - "accept and reject must address the same accept-reject template"); - - ScriptedExtractionClient acceptLlm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_ACCEPT.content(), NegotiationPhase.ACCEPT)); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, acceptLlm, 3); - MetadataContent acceptResult = generateFromText(orchestrator, GoldenCase.INFORMATION_ACCEPT, "请接受。"); - assertEquals(1, acceptLlm.calls); - - ScriptedExtractionClient rejectLlm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_REJECT.content(), NegotiationPhase.REJECT)); - MetadataContent rejectResult = - generateFromText(orchestrator(ZH_CN, rejectLlm, 3), GoldenCase.INFORMATION_REJECT, "请拒绝。"); - assertEquals(1, rejectLlm.calls); - - assertTrue( - acceptResult.promptText().contains("\nAccept\n"), "the accept output must render the Accept literal"); - assertTrue( - rejectResult.promptText().contains("\nReject\n"), "the reject output must render the Reject literal"); - assertEquals(acceptResult.templateUri(), rejectResult.templateUri()); - } - - /** - * IT-B-003: a retryable extraction failure is retried until it succeeds; every failed attempt logs a warning with - * the step, attempt, attempt limit and failure code, and the successful output still matches the golden fixture. - */ - @Test - void retriesContentExtractionUntilItSucceeds() { - GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; - ScriptedExtractionClient llm = - new ScriptedExtractionClient("", "", extractionJson(goldenCase.content(), goldenCase.phase())); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - MetadataContent result = - orchestrator.generateProposeFromText("请提供区域。", goldenCase.context(), goldenCase.template()); - - assertEquals(3, llm.calls, "two failures followed by one success must result in three calls"); - assertEquals(readGoldenFixture(goldenCase, ZH_CN), result.promptText()); - - List retries = warningMessages("negotiation_llm_retry "); - assertEquals(2, retries.size()); - for (int attempt = 1; attempt <= 2; attempt++) { - String retry = retries.get(attempt - 1); - assertTrue(retry.contains("step=negotiation_content_extract"), retry); - assertTrue(retry.contains("attempt=" + attempt), retry); - assertTrue(retry.contains("max_attempts=3"), retry); - assertTrue(retry.contains("code=" + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED), retry); - } - assertTrue( - warningMessages("negotiation_llm_retry_exhausted").isEmpty(), - "a successful retry sequence must not log the exhaustion event"); - } - - /** - * IT-B-003: when every attempt fails with the retryable extraction failure code, the original error code is - * rethrown after the exhaustion warning has been logged. - */ - @Test - void rethrowsTheOriginalCodeWhenRetriesAreExhausted() { - ScriptedExtractionClient llm = new ScriptedExtractionClient("", "", ""); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); - assertEquals(3, llm.calls); - - List exhausted = warningMessages("negotiation_llm_retry_exhausted"); - assertEquals(1, exhausted.size()); - assertTrue(exhausted.get(0).contains("step=negotiation_content_extract"), exhausted.get(0)); - assertTrue(exhausted.get(0).contains("max_attempts=3"), exhausted.get(0)); - assertTrue( - exhausted.get(0).contains("code=" + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED), - exhausted.get(0)); - assertEquals(2, warningMessages("negotiation_llm_retry ").size()); - assertTrue( - warningMessages("negotiation_generation_failed").stream() - .anyMatch(message -> - message.contains("code=" + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED)), - "the surfaced generation failure must be logged with its code"); - } - - /** - * IT-B-004: persistent transport failures of the LLM client are retried and surface as the infrastructure error - * code on the generation side. - */ - @Test - void retriesInfrastructureFailuresUntilExhaustion() { - ScriptedExtractionClient llm = new ScriptedExtractionClient(new IllegalStateException("connection refused")); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); - assertEquals(2, llm.calls); - assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); - assertTrue(warningMessages("negotiation_llm_retry_exhausted") - .get(0) - .contains("code=" + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR)); - } - - /** - * IT-B-004: a persistently unparseable LLM response fails with the retryable extraction failure code after the - * retries are exhausted, rethrowing the original code. - */ - @Test - void retriesUnparseableResponsesUntilExhaustion() { - ScriptedExtractionClient llm = new ScriptedExtractionClient(""); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); - assertEquals(2, llm.calls); - assertEquals(1, warningMessages("negotiation_llm_retry ").size()); - assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); - } - - /** - * IT-B-005 (1): an extracted conclusion that contradicts the method phase fails with the non-retryable - * invalid-input code after exactly one LLM call and without any retry event. - */ - @Test - void phaseContentMismatchFailsFastWithInvalidInput() { - ScriptedExtractionClient llm = new ScriptedExtractionClient( - "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateAcceptFromText( - "请接受。", GoldenCase.INFORMATION_ACCEPT.context(), GoldenCase.INFORMATION_ACCEPT.template())); - - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, failure.getCode()); - assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); - assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); - assertTrue(warningMessages("negotiation_llm_retry_exhausted").isEmpty()); - } - - /** - * IT-B-005 (2): an extraction response missing a required field fails with the non-retryable slot-missing code - * after exactly one LLM call. - */ - @Test - void missingRequiredFieldFailsFastWithSlotMissing() { - ScriptedExtractionClient llm = new ScriptedExtractionClient("{\"relationship\":null}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, failure.getCode()); - assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); - assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); - assertTrue(warningMessages("negotiation_llm_retry_exhausted").isEmpty()); - } - - /** - * IT-B-005 (3): a template URI without any loadable template fails with template-not-found before the LLM is - * touched at all. - */ - @Test - void missingTemplateFailsBeforeAnyLlmCall() { - ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_PROPOSE.content(), NegotiationPhase.PROPOSE)); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language(ZH_CN) - .llmClient(llm) - .templateLoader(missingTemplateLoader()) - .build(); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, failure.getCode()); - assertEquals(0, llm.calls, "the template must be loaded before the LLM step is started"); - } - - /** - * IT-B-005 (4): a template URI whose phase segment contradicts the method is a programming error with an - * {@link IllegalArgumentException} pointing at the template URI and never reaches the LLM. - */ - @Test - void phaseMismatchedTemplateUriFailsBeforeAnyLlmCall() { - ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_PROPOSE.content(), NegotiationPhase.PROPOSE)); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> orchestrator.generateProposeFromText( - "请提供区域。", - GoldenCase.INFORMATION_PROPOSE.context(), - GoldenCase.INFORMATION_ACCEPT.template())); - - assertTrue( - failure.getMessage().contains("Template URI does not address a negotiation template of the expected phase PROPOSE"), - "the failure must point at the template URI but was: " + failure.getMessage()); - assertEquals(0, llm.calls); - } - - /** - * IT-B-006 (1): a single allowed attempt makes the retryable extraction failure surface after exactly one call, - * with the exhaustion warning but without any per-attempt retry warning. - */ - @Test - void singleAttemptFailsFast() { - ScriptedExtractionClient llm = new ScriptedExtractionClient(""); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 1); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); - assertEquals(1, llm.calls); - assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); - assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); - } - - /** IT-B-006 (2): an attempt limit of two allows exactly one retry of a retryable failure. */ - @Test - void twoAttemptsRetryExactlyOnce() { - ScriptedExtractionClient llm = new ScriptedExtractionClient("", ""); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); - assertEquals(2, llm.calls); - assertEquals(1, warningMessages("negotiation_llm_retry ").size()); - assertTrue(warningMessages("negotiation_llm_retry ").get(0).contains("max_attempts=2")); - assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); - } - - /** - * IT-B-006 (3): the clamped attempt limits of the unified LLM config drive the retry loop: the value 99 clamps to - * 10 with a warning and allows nine failed attempts before a success, and the value 0 clamps to 1 with a warning - * and fails fast. - */ - @Test - void clampedConfigValuesDriveTheRetryLoop() { - LlmConfig large = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "99")); - assertEquals(10, large.maxAttempts()); - assertTrue( - warningMessagesOf(llmConfigAppender).stream() - .anyMatch(message -> message.contains("raw_value=99") && message.contains("clamped_value=10")), - "the clamp of the value 99 must be warned about"); - - GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; - ScriptedExtractionClient recovering = new ScriptedExtractionClient( - "", "", "", "", "", "", "", "", "", extractionJson(goldenCase.content(), goldenCase.phase())); - NegotiationGenerationOrchestrator recoveringOrchestrator = orchestrator(ZH_CN, recovering, large.maxAttempts()); - - MetadataContent result = recoveringOrchestrator.generateProposeFromText( - "请提供区域。", goldenCase.context(), goldenCase.template()); - - assertEquals(10, recovering.calls, "nine failed attempts plus one success must consume the clamped limit"); - assertEquals(readGoldenFixture(goldenCase, ZH_CN), result.promptText()); - assertEquals(9, warningMessages("negotiation_llm_retry ").size()); - - LlmConfig small = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "0")); - assertEquals(1, small.maxAttempts()); - assertTrue( - warningMessagesOf(llmConfigAppender).stream() - .anyMatch(message -> message.contains("raw_value=0") && message.contains("clamped_value=1")), - "the clamp of the value 0 must be warned about"); - - ScriptedExtractionClient failing = new ScriptedExtractionClient(""); - NegotiationGenerationOrchestrator failingOrchestrator = orchestrator(ZH_CN, failing, small.maxAttempts()); - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> failingOrchestrator.generateProposeFromText( - "请提供区域。", goldenCase.context(), goldenCase.template())); - - assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); - assertEquals(1, failing.calls); - } - - /** - * IT-B-007: with no language configured the unified config resolves the default en-US, and the whole chain works - * against the real en-US resources: one extraction call for the from-text generation, prompt resources that are - * really loaded from the classpath, and a second single LLM call for the validation pipeline. - */ - @Test - void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=local_rule - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """); - A2ATConfig config = A2ATConfig.load(envFile); - assertEquals(EN_US, config.prompt().language(), "the default language without A2AT_LANGUAGE is en-US"); - assertEquals(3, config.llm().maxAttempts(), "the default attempt limit is 3"); - - GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; - String inputText = "Please provide the energy-saving area information and the rate guarantee target."; - ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(goldenCase.content(), goldenCase.phase()), - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"Songshan Lake\"}}"); - NegotiationGenerationOrchestrator orchestrator = - orchestrator(config.prompt().language(), llm, config.llm().maxAttempts()); - - MetadataContent message = - orchestrator.generateProposeFromText(inputText, goldenCase.context(), goldenCase.template()); - - assertEquals(1, llm.calls, "the en-US from-text chain needs exactly one extraction call"); - assertEquals(readGoldenFixture(goldenCase, EN_US), message.promptText()); - assertEquals(2, llm.lastMessages.size()); - assertFalse(llm.lastMessages.get(0).get("content").isBlank(), "the en-US system prompt must be loadable"); - assertTrue(llm.lastMessages.get(1).get("content").contains(inputText)); - assertTrue( - llm.lastMessages.get(1).get("content").contains("propose"), - "the user prompt must carry the phase token"); - - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message.promptText(), - Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - goldenCase.template()); - - assertEquals(2, llm.calls, "the validation pipeline adds exactly one semantic validation call"); - assertEquals(goldenCase.context().id(), filled.data().get("id")); - assertEquals(goldenCase.context().round(), filled.data().get("round")); - assertEquals(goldenCase.context().maxRounds(), filled.data().get("maxRounds")); - assertEquals("Songshan Lake", filled.data().get("region")); - } - - /** - * IT-B-008: the negotiation context of the rendered message comes from the caller-supplied context: the extraction - * schema sent to the LLM has no context fields and the output carries exactly the caller's context values. - */ - @Test - void contextIsInjectedFromTheCallerWithoutAnyLlmInvolvement() { - NegotiationContext context = new NegotiationContext(GoldenInputs.SESSION_ID, 4, 7); - ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_PROPOSE.content(), NegotiationPhase.PROPOSE)); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - MetadataContent result = orchestrator.generateProposeFromText("请提供区域。", context, INFORMATION_PROPOSE_URI); - - assertEquals(1, llm.calls); - Map schemaProperties = schemaProperties(llm.lastSchema); - assertFalse(schemaProperties.containsKey("id"), "the extraction schema must not ask for the context id"); - assertFalse(schemaProperties.containsKey("round"), "the extraction schema must not ask for the round"); - assertFalse(schemaProperties.containsKey("maxRounds"), "the extraction schema must not ask for the limit"); - String promptText = result.promptText(); - assertTrue(promptText.contains("- id: " + GoldenInputs.SESSION_ID)); - assertTrue(promptText.contains("- round: 4")); - assertTrue(promptText.contains("- maxRounds: 7")); - } - - private static NegotiationGenerationOrchestrator orchestrator( - String language, LLMClient llmClient, int maxAttempts) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .llmClient(llmClient) - .maxAttempts(maxAttempts) - .build(); - } - - private static MetadataContent generateFromText( - NegotiationGenerationOrchestrator orchestrator, GoldenCase goldenCase, String text) { - return switch (goldenCase.phase()) { - case PROPOSE -> orchestrator.generateProposeFromText(text, goldenCase.context(), goldenCase.template()); - case ACCEPT -> orchestrator.generateAcceptFromText(text, goldenCase.context(), goldenCase.template()); - case REJECT -> orchestrator.generateRejectFromText(text, goldenCase.context(), goldenCase.template()); - case ABORT -> throw new IllegalArgumentException("The typed golden cases carry no abort phase."); - }; - } - - private static String phaseToken(NegotiationPhase phase) { - return switch (phase) { - case PROPOSE -> "propose"; - case ACCEPT -> "accept"; - case REJECT -> "reject"; - case ABORT -> "abort"; - }; - } - - private static NegotiationTemplateLoader missingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - - @Override - public List loadAll() { - return List.of(); - } - }; - } - - @SuppressWarnings("unchecked") - private static Map schemaProperties(Map jsonSchema) { - return (Map) jsonSchema.get("properties"); - } - - private static String readGoldenFixture(GoldenCase goldenCase, String language) { - String resourcePath = goldenCase.goldenResourcePath(language); - InputStream stream = FromTextLlmPipelineTest.class.getResourceAsStream(resourcePath); - assertTrue(stream != null, "Golden fixture must exist on the test classpath: " + resourcePath); - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } catch (IOException exception) { - throw new AssertionError("Failed to read golden fixture " + resourcePath, exception); - } - } - - private static List logMessages(ListAppender appender, Level level) { - List messages = new ArrayList<>(); - for (ILoggingEvent event : appender.list) { - if (event.getLevel() == level) { - messages.add(event.getFormattedMessage()); - } - } - return messages; - } - - private static List warningMessagesOf(ListAppender appender) { - return logMessages(appender, Level.WARN); - } - - private List warningMessages(String prefix) { - List matches = new ArrayList<>(); - for (String message : warningMessagesOf(orchestratorAppender)) { - if (message.contains(prefix)) { - matches.add(message); - } - } - return matches; - } - - /** - * Builds the snake_case extraction JSON of one typed content so the scripted LLM response maps back to exactly that - * content. - */ - private static String extractionJson(NegotiationContent content, NegotiationPhase phase) { - if (phase == NegotiationPhase.PROPOSE) { - if (content instanceof InformationProposeContent info) { - return "{\"items\":" + itemsJson(info.items()) + ",\"relationship\":" - + stringOrNull(info.relationship()) + "}"; - } - if (content instanceof TargetProposeContent target) { - return "{\"target_negotiation_description\":" - + quote(target.targetNegotiationDescription()) - + ",\"intent_understanding\":" - + itemsJson(target.intentUnderstanding()) - + ",\"alignment_and_clarification\":" - + itemsJson(target.alignmentAndClarification()) - + ",\"request_for_clarification\":" - + itemsJson(target.requestForClarification()) - + "}"; - } - FeasibilityProposeContent feasibility = (FeasibilityProposeContent) content; - return "{\"feasibility_negotiation_description\":" - + quote(feasibility.feasibilityNegotiationDescription()) - + ",\"action\":\"" - + feasibility.action().name() - + "\",\"contents_to_evaluate\":" - + itemsJson(feasibility.contentsToEvaluate()) - + ",\"infeasibility_details_and_proposal\":" - + itemsJson(feasibility.infeasibilityDetailsAndProposal()) - + "}"; - } - if (content instanceof InformationEndingContent info) { - return "{\"conclusion\":" + quote(info.conclusion().literal()) + ",\"items\":" + itemsJson(info.items()) - + "}"; - } - if (content instanceof TargetEndingContent target) { - return "{\"conclusion\":" - + quote(target.conclusion().literal()) - + ",\"confirmed_intent\":" - + stringOrNull(target.confirmedIntent()) - + ",\"failure_reason\":" - + stringOrNull(target.failureReason()) - + "}"; - } - FeasibilityEndingContent feasibility = (FeasibilityEndingContent) content; - return "{\"conclusion\":" - + quote(feasibility.conclusion().literal()) - + ",\"feasibility_summary\":" - + stringOrNull(feasibility.feasibilitySummary()) - + "}"; - } - - private static String itemsJson(List items) { - if (items == null) { - return "null"; - } - List entries = new ArrayList<>(); - for (NegotiationItem item : items) { - entries.add("{\"name\":" + quote(item.name()) + ",\"value\":" + stringOrNull(item.value()) + "}"); - } - return "[" + String.join(",", entries) + "]"; - } - - private static String stringOrNull(String value) { - return value == null ? "null" : quote(value); - } - - private static String quote(String value) { - return "\"" - + value.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - + "\""; - } - - /** - * Scripted LLM client: returns the queued payloads in order, throws the queued failure, and repeats the last entry - * once the queue is exhausted. Every call records the request for later assertions. - */ - private static final class ScriptedExtractionClient implements LLMClient { - - private final List script; - - private int calls; - - private List> lastMessages; - - private Map lastSchema; - - private ScriptedExtractionClient(Object... entries) { - this.script = List.of(entries); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - Object next = script.get(Math.min(calls, script.size() - 1)); - calls++; - lastMessages = messages; - lastSchema = jsonSchema; - if (next instanceof RuntimeException failure) { - throw failure; - } - return new LLMResponse( - (String) next, "scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.model.LlmConfig; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.golden.GoldenInputs; +import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.slf4j.LoggerFactory; + +/** + * Integration tests of the from-text generation pipeline against the real built-in resources. + * + *

      The LLM is replaced by a scripted client that returns queued payloads or failures; every other collaborator is the + * production wiring. The suite covers the single-step content extraction (one LLM call, no type-recognition step), the + * byte-equality of the from-text output with the from-data output and the golden fixtures, the step-level retry policy + * with its warning logs, the infrastructure failure paths, the non-retryable failure codes that must stop after one LLM + * call, the max-attempts configuration semantics and the default en-US end-to-end chain. + */ +class FromTextLlmPipelineTest { + + private static final String ZH_CN = GoldenInputs.ZH_CN; + + private static final String EN_US = GoldenInputs.EN_US; + + private static final TemplateUri INFORMATION_PROPOSE_URI = GoldenCase.INFORMATION_PROPOSE.template(); + + private final ListAppender orchestratorAppender = new ListAppender<>(); + + private final ListAppender extractorAppender = new ListAppender<>(); + + private final ListAppender llmConfigAppender = new ListAppender<>(); + + private final Logger orchestratorLogger = (Logger) LoggerFactory.getLogger(NegotiationGenerationOrchestrator.class); + + private final Logger extractorLogger = (Logger) LoggerFactory.getLogger(DefaultNegotiationContentExtractor.class); + + private final Logger llmConfigLogger = (Logger) LoggerFactory.getLogger(LlmConfig.class); + + @BeforeEach + void attachLogAppenders() { + orchestratorAppender.start(); + orchestratorLogger.addAppender(orchestratorAppender); + extractorAppender.start(); + extractorLogger.addAppender(extractorAppender); + llmConfigAppender.start(); + llmConfigLogger.addAppender(llmConfigAppender); + } + + @AfterEach + void detachLogAppenders() { + orchestratorLogger.detachAppender(orchestratorAppender); + extractorLogger.detachAppender(extractorAppender); + llmConfigLogger.detachAppender(llmConfigAppender); + } + + /** + * IT-B-001: the from-text chain runs exactly one content-extraction LLM call and then renders deterministically: + * the output equals both the from-data generation of the same content and the committed golden fixture byte for + * byte, the metadata contract holds, and no type-recognition step exists anywhere in the pipeline. + */ + @Test + void fromTextMatchesFromDataAndTheGoldenFixtureAfterOneExtractionCall() { + GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; + ScriptedExtractionClient llm = + new ScriptedExtractionClient(extractionJson(goldenCase.content(), goldenCase.phase())); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + MetadataContent fromText = orchestrator.generateProposeFromText( + "请补充节能区域、节能速率保障目标与VLANId信息。", goldenCase.context(), goldenCase.template()); + + assertEquals(1, llm.calls, "from-text generation must run exactly one content extraction call"); + assertEquals(goldenCase.templateUri(), fromText.templateUri()); + + MetadataContent fromData = goldenCase.generate(orchestrator); + assertEquals(1, llm.calls, "the from-data variant must not add any LLM call"); + assertEquals(fromData.promptText(), fromText.promptText()); + assertEquals(readGoldenFixture(goldenCase, ZH_CN), fromText.promptText()); + + Map metadata = fromText.buildMetadataContent(); + assertEquals(2, metadata.size()); + assertEquals(fromText.promptText(), metadata.get(fromText.extensionUri())); + assertEquals(fromText.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + + assertEquals( + 2, llm.lastMessages.size(), "the extraction step must be a single system-plus-user message exchange"); + assertTrue( + logMessages(extractorAppender, Level.INFO).stream() + .anyMatch(message -> message.startsWith("negotiation_content_extraction_completed")), + "the completed extraction step must emit its info event"); + assertTrue( + logMessages(orchestratorAppender, Level.INFO).stream() + .noneMatch(message -> message.contains("negotiation_type_recognition")) + && logMessages(extractorAppender, Level.INFO).stream() + .noneMatch(message -> message.contains("negotiation_type_recognition")), + "the removed type-recognition step must never appear in the events"); + } + + /** + * IT-B-002: every from-text method (propose, accept, reject) succeeds for every negotiation type with exactly one + * LLM call per generation, the caller-supplied template URI is echoed, the output matches the golden fixture and + * the phase token reaches the LLM so the ending phases can be distinguished. + */ + @ParameterizedTest(name = "from-text succeeds for {0}") + @EnumSource(GoldenCase.class) + void fromTextSucceedsForEveryMethodAndTypeCombination(GoldenCase goldenCase) { + ScriptedExtractionClient llm = + new ScriptedExtractionClient(extractionJson(goldenCase.content(), goldenCase.phase())); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + MetadataContent result = generateFromText(orchestrator, goldenCase, "请根据以下文本生成协商报文。"); + + assertEquals(1, llm.calls); + assertEquals(goldenCase.templateUri(), result.templateUri()); + assertEquals(readGoldenFixture(goldenCase, ZH_CN), result.promptText()); + String userPrompt = llm.lastMessages.get(1).get("content"); + assertTrue( + userPrompt.contains(phaseToken(goldenCase.phase())), + "the user prompt must carry the phase token of the addressed method"); + } + + /** + * IT-B-002: the accept and reject methods share the same accept-reject template URI; both load that one template + * and differ only in the rendered conclusion literal. + */ + @Test + void acceptAndRejectShareTheSameAcceptRejectTemplate() { + assertEquals( + GoldenCase.INFORMATION_ACCEPT.templateUri(), + GoldenCase.INFORMATION_REJECT.templateUri(), + "accept and reject must address the same accept-reject template"); + + ScriptedExtractionClient acceptLlm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_ACCEPT.content(), NegotiationPhase.ACCEPT)); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, acceptLlm, 3); + MetadataContent acceptResult = generateFromText(orchestrator, GoldenCase.INFORMATION_ACCEPT, "请接受。"); + assertEquals(1, acceptLlm.calls); + + ScriptedExtractionClient rejectLlm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_REJECT.content(), NegotiationPhase.REJECT)); + MetadataContent rejectResult = + generateFromText(orchestrator(ZH_CN, rejectLlm, 3), GoldenCase.INFORMATION_REJECT, "请拒绝。"); + assertEquals(1, rejectLlm.calls); + + assertTrue( + acceptResult.promptText().contains("\nAccept\n"), "the accept output must render the Accept literal"); + assertTrue( + rejectResult.promptText().contains("\nReject\n"), "the reject output must render the Reject literal"); + assertEquals(acceptResult.templateUri(), rejectResult.templateUri()); + } + + /** + * IT-B-003: a retryable extraction failure is retried until it succeeds; every failed attempt logs a warning with + * the step, attempt, attempt limit and failure code, and the successful output still matches the golden fixture. + */ + @Test + void retriesContentExtractionUntilItSucceeds() { + GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; + ScriptedExtractionClient llm = + new ScriptedExtractionClient("", "", extractionJson(goldenCase.content(), goldenCase.phase())); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + MetadataContent result = + orchestrator.generateProposeFromText("请提供区域。", goldenCase.context(), goldenCase.template()); + + assertEquals(3, llm.calls, "two failures followed by one success must result in three calls"); + assertEquals(readGoldenFixture(goldenCase, ZH_CN), result.promptText()); + + List retries = warningMessages("negotiation_llm_retry "); + assertEquals(2, retries.size()); + for (int attempt = 1; attempt <= 2; attempt++) { + String retry = retries.get(attempt - 1); + assertTrue(retry.contains("step=negotiation_content_extract"), retry); + assertTrue(retry.contains("attempt=" + attempt), retry); + assertTrue(retry.contains("max_attempts=3"), retry); + assertTrue(retry.contains("code=" + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED), retry); + } + assertTrue( + warningMessages("negotiation_llm_retry_exhausted").isEmpty(), + "a successful retry sequence must not log the exhaustion event"); + } + + /** + * IT-B-003: when every attempt fails with the retryable extraction failure code, the original error code is + * rethrown after the exhaustion warning has been logged. + */ + @Test + void rethrowsTheOriginalCodeWhenRetriesAreExhausted() { + ScriptedExtractionClient llm = new ScriptedExtractionClient("", "", ""); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); + assertEquals(3, llm.calls); + + List exhausted = warningMessages("negotiation_llm_retry_exhausted"); + assertEquals(1, exhausted.size()); + assertTrue(exhausted.get(0).contains("step=negotiation_content_extract"), exhausted.get(0)); + assertTrue(exhausted.get(0).contains("max_attempts=3"), exhausted.get(0)); + assertTrue( + exhausted.get(0).contains("code=" + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED), + exhausted.get(0)); + assertEquals(2, warningMessages("negotiation_llm_retry ").size()); + assertTrue( + warningMessages("negotiation_generation_failed").stream() + .anyMatch(message -> + message.contains("code=" + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED)), + "the surfaced generation failure must be logged with its code"); + } + + /** + * IT-B-004: persistent transport failures of the LLM client are retried and surface as the infrastructure error + * code on the generation side. + */ + @Test + void retriesInfrastructureFailuresUntilExhaustion() { + ScriptedExtractionClient llm = new ScriptedExtractionClient(new IllegalStateException("connection refused")); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); + assertEquals(2, llm.calls); + assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); + assertTrue(warningMessages("negotiation_llm_retry_exhausted") + .get(0) + .contains("code=" + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR)); + } + + /** + * IT-B-004: a persistently unparseable LLM response fails with the retryable extraction failure code after the + * retries are exhausted, rethrowing the original code. + */ + @Test + void retriesUnparseableResponsesUntilExhaustion() { + ScriptedExtractionClient llm = new ScriptedExtractionClient(""); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); + assertEquals(2, llm.calls); + assertEquals(1, warningMessages("negotiation_llm_retry ").size()); + assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); + } + + /** + * IT-B-005 (1): an extracted conclusion that contradicts the method phase fails with the non-retryable + * invalid-input code after exactly one LLM call and without any retry event. + */ + @Test + void phaseContentMismatchFailsFastWithInvalidInput() { + ScriptedExtractionClient llm = new ScriptedExtractionClient( + "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateAcceptFromText( + "请接受。", GoldenCase.INFORMATION_ACCEPT.context(), GoldenCase.INFORMATION_ACCEPT.template())); + + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, failure.getCode()); + assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); + assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); + assertTrue(warningMessages("negotiation_llm_retry_exhausted").isEmpty()); + } + + /** + * IT-B-005 (2): an extraction response missing a required field fails with the non-retryable slot-missing code + * after exactly one LLM call. + */ + @Test + void missingRequiredFieldFailsFastWithSlotMissing() { + ScriptedExtractionClient llm = new ScriptedExtractionClient("{\"relationship\":null}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, failure.getCode()); + assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); + assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); + assertTrue(warningMessages("negotiation_llm_retry_exhausted").isEmpty()); + } + + /** + * IT-B-005 (3): a template URI without any loadable template fails with template-not-found before the LLM is + * touched at all. + */ + @Test + void missingTemplateFailsBeforeAnyLlmCall() { + ScriptedExtractionClient llm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_PROPOSE.content(), NegotiationPhase.PROPOSE)); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .llmClient(llm) + .templateLoader(missingTemplateLoader()) + .build(); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, failure.getCode()); + assertEquals(0, llm.calls, "the template must be loaded before the LLM step is started"); + } + + /** + * IT-B-005 (4): a template URI whose phase segment contradicts the method is a programming error with an + * {@link IllegalArgumentException} pointing at the template URI and never reaches the LLM. + */ + @Test + void phaseMismatchedTemplateUriFailsBeforeAnyLlmCall() { + ScriptedExtractionClient llm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_PROPOSE.content(), NegotiationPhase.PROPOSE)); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.generateProposeFromText( + "请提供区域。", + GoldenCase.INFORMATION_PROPOSE.context(), + GoldenCase.INFORMATION_ACCEPT.template())); + + assertTrue( + failure.getMessage().contains("Template URI does not address a negotiation template of the expected phase PROPOSE"), + "the failure must point at the template URI but was: " + failure.getMessage()); + assertEquals(0, llm.calls); + } + + /** + * IT-B-006 (1): a single allowed attempt makes the retryable extraction failure surface after exactly one call, + * with the exhaustion warning but without any per-attempt retry warning. + */ + @Test + void singleAttemptFailsFast() { + ScriptedExtractionClient llm = new ScriptedExtractionClient(""); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 1); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); + assertEquals(1, llm.calls); + assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); + assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); + } + + /** IT-B-006 (2): an attempt limit of two allows exactly one retry of a retryable failure. */ + @Test + void twoAttemptsRetryExactlyOnce() { + ScriptedExtractionClient llm = new ScriptedExtractionClient("", ""); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); + assertEquals(2, llm.calls); + assertEquals(1, warningMessages("negotiation_llm_retry ").size()); + assertTrue(warningMessages("negotiation_llm_retry ").get(0).contains("max_attempts=2")); + assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); + } + + /** + * IT-B-006 (3): the clamped attempt limits of the unified LLM config drive the retry loop: the value 99 clamps to + * 10 with a warning and allows nine failed attempts before a success, and the value 0 clamps to 1 with a warning + * and fails fast. + */ + @Test + void clampedConfigValuesDriveTheRetryLoop() { + LlmConfig large = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "99")); + assertEquals(10, large.maxAttempts()); + assertTrue( + warningMessagesOf(llmConfigAppender).stream() + .anyMatch(message -> message.contains("raw_value=99") && message.contains("clamped_value=10")), + "the clamp of the value 99 must be warned about"); + + GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; + ScriptedExtractionClient recovering = new ScriptedExtractionClient( + "", "", "", "", "", "", "", "", "", extractionJson(goldenCase.content(), goldenCase.phase())); + NegotiationGenerationOrchestrator recoveringOrchestrator = orchestrator(ZH_CN, recovering, large.maxAttempts()); + + MetadataContent result = recoveringOrchestrator.generateProposeFromText( + "请提供区域。", goldenCase.context(), goldenCase.template()); + + assertEquals(10, recovering.calls, "nine failed attempts plus one success must consume the clamped limit"); + assertEquals(readGoldenFixture(goldenCase, ZH_CN), result.promptText()); + assertEquals(9, warningMessages("negotiation_llm_retry ").size()); + + LlmConfig small = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "0")); + assertEquals(1, small.maxAttempts()); + assertTrue( + warningMessagesOf(llmConfigAppender).stream() + .anyMatch(message -> message.contains("raw_value=0") && message.contains("clamped_value=1")), + "the clamp of the value 0 must be warned about"); + + ScriptedExtractionClient failing = new ScriptedExtractionClient(""); + NegotiationGenerationOrchestrator failingOrchestrator = orchestrator(ZH_CN, failing, small.maxAttempts()); + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> failingOrchestrator.generateProposeFromText( + "请提供区域。", goldenCase.context(), goldenCase.template())); + + assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); + assertEquals(1, failing.calls); + } + + /** + * IT-B-007: with no language configured the unified config resolves the default en-US, and the whole chain works + * against the real en-US resources: one extraction call for the from-text generation, prompt resources that are + * really loaded from the classpath, and a second single LLM call for the validation pipeline. + */ + @Test + void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=local_rule + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """); + A2ATConfig config = A2ATConfig.load(envFile); + assertEquals(EN_US, config.prompt().language(), "the default language without A2AT_LANGUAGE is en-US"); + assertEquals(3, config.llm().maxAttempts(), "the default attempt limit is 3"); + + GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; + String inputText = "Please provide the energy-saving area information and the rate guarantee target."; + ScriptedExtractionClient llm = new ScriptedExtractionClient( + extractionJson(goldenCase.content(), goldenCase.phase()), + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"Songshan Lake\"}}"); + NegotiationGenerationOrchestrator orchestrator = + orchestrator(config.prompt().language(), llm, config.llm().maxAttempts()); + + MetadataContent message = + orchestrator.generateProposeFromText(inputText, goldenCase.context(), goldenCase.template()); + + assertEquals(1, llm.calls, "the en-US from-text chain needs exactly one extraction call"); + assertEquals(readGoldenFixture(goldenCase, EN_US), message.promptText()); + assertEquals(2, llm.lastMessages.size()); + assertFalse(llm.lastMessages.get(0).get("content").isBlank(), "the en-US system prompt must be loadable"); + assertTrue(llm.lastMessages.get(1).get("content").contains(inputText)); + assertTrue( + llm.lastMessages.get(1).get("content").contains("propose"), + "the user prompt must carry the phase token"); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message.promptText(), + Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), + goldenCase.template()); + + assertEquals(2, llm.calls, "the validation pipeline adds exactly one semantic validation call"); + assertEquals(goldenCase.context().id(), filled.data().get("id")); + assertEquals(goldenCase.context().round(), filled.data().get("round")); + assertEquals(goldenCase.context().maxRounds(), filled.data().get("maxRounds")); + assertEquals("Songshan Lake", filled.data().get("region")); + } + + /** + * IT-B-008: the negotiation context of the rendered message comes from the caller-supplied context: the extraction + * schema sent to the LLM has no context fields and the output carries exactly the caller's context values. + */ + @Test + void contextIsInjectedFromTheCallerWithoutAnyLlmInvolvement() { + NegotiationContext context = new NegotiationContext(GoldenInputs.SESSION_ID, 4, 7); + ScriptedExtractionClient llm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_PROPOSE.content(), NegotiationPhase.PROPOSE)); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + MetadataContent result = orchestrator.generateProposeFromText("请提供区域。", context, INFORMATION_PROPOSE_URI); + + assertEquals(1, llm.calls); + Map schemaProperties = schemaProperties(llm.lastSchema); + assertFalse(schemaProperties.containsKey("id"), "the extraction schema must not ask for the context id"); + assertFalse(schemaProperties.containsKey("round"), "the extraction schema must not ask for the round"); + assertFalse(schemaProperties.containsKey("maxRounds"), "the extraction schema must not ask for the limit"); + String promptText = result.promptText(); + assertTrue(promptText.contains("- id: " + GoldenInputs.SESSION_ID)); + assertTrue(promptText.contains("- round: 4")); + assertTrue(promptText.contains("- maxRounds: 7")); + } + + private static NegotiationGenerationOrchestrator orchestrator( + String language, LLMClient llmClient, int maxAttempts) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .llmClient(llmClient) + .maxAttempts(maxAttempts) + .build(); + } + + private static MetadataContent generateFromText( + NegotiationGenerationOrchestrator orchestrator, GoldenCase goldenCase, String text) { + return switch (goldenCase.phase()) { + case PROPOSE -> orchestrator.generateProposeFromText(text, goldenCase.context(), goldenCase.template()); + case ACCEPT -> orchestrator.generateAcceptFromText(text, goldenCase.context(), goldenCase.template()); + case REJECT -> orchestrator.generateRejectFromText(text, goldenCase.context(), goldenCase.template()); + case ABORT -> throw new IllegalArgumentException("The typed golden cases carry no abort phase."); + }; + } + + private static String phaseToken(NegotiationPhase phase) { + return switch (phase) { + case PROPOSE -> "propose"; + case ACCEPT -> "accept"; + case REJECT -> "reject"; + case ABORT -> "abort"; + }; + } + + private static NegotiationTemplateLoader missingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + + @Override + public List loadAll() { + return List.of(); + } + }; + } + + @SuppressWarnings("unchecked") + private static Map schemaProperties(Map jsonSchema) { + return (Map) jsonSchema.get("properties"); + } + + private static String readGoldenFixture(GoldenCase goldenCase, String language) { + String resourcePath = goldenCase.goldenResourcePath(language); + InputStream stream = FromTextLlmPipelineTest.class.getResourceAsStream(resourcePath); + assertTrue(stream != null, "Golden fixture must exist on the test classpath: " + resourcePath); + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); + } catch (IOException exception) { + throw new AssertionError("Failed to read golden fixture " + resourcePath, exception); + } + } + + private static List logMessages(ListAppender appender, Level level) { + List messages = new ArrayList<>(); + for (ILoggingEvent event : appender.list) { + if (event.getLevel() == level) { + messages.add(event.getFormattedMessage()); + } + } + return messages; + } + + private static List warningMessagesOf(ListAppender appender) { + return logMessages(appender, Level.WARN); + } + + private List warningMessages(String prefix) { + List matches = new ArrayList<>(); + for (String message : warningMessagesOf(orchestratorAppender)) { + if (message.contains(prefix)) { + matches.add(message); + } + } + return matches; + } + + /** + * Builds the snake_case extraction JSON of one typed content so the scripted LLM response maps back to exactly that + * content. + */ + private static String extractionJson(NegotiationContent content, NegotiationPhase phase) { + if (phase == NegotiationPhase.PROPOSE) { + if (content instanceof InformationProposeContent info) { + return "{\"items\":" + itemsJson(info.items()) + ",\"relationship\":" + + stringOrNull(info.relationship()) + "}"; + } + if (content instanceof TargetProposeContent target) { + return "{\"target_negotiation_description\":" + + quote(target.targetNegotiationDescription()) + + ",\"intent_understanding\":" + + itemsJson(target.intentUnderstanding()) + + ",\"alignment_and_clarification\":" + + itemsJson(target.alignmentAndClarification()) + + ",\"request_for_clarification\":" + + itemsJson(target.requestForClarification()) + + "}"; + } + FeasibilityProposeContent feasibility = (FeasibilityProposeContent) content; + return "{\"feasibility_negotiation_description\":" + + quote(feasibility.feasibilityNegotiationDescription()) + + ",\"action\":\"" + + feasibility.action().name() + + "\",\"contents_to_evaluate\":" + + itemsJson(feasibility.contentsToEvaluate()) + + ",\"infeasibility_details_and_proposal\":" + + itemsJson(feasibility.infeasibilityDetailsAndProposal()) + + "}"; + } + if (content instanceof InformationEndingContent info) { + return "{\"conclusion\":" + quote(info.conclusion().literal()) + ",\"items\":" + itemsJson(info.items()) + + "}"; + } + if (content instanceof TargetEndingContent target) { + return "{\"conclusion\":" + + quote(target.conclusion().literal()) + + ",\"confirmed_intent\":" + + stringOrNull(target.confirmedIntent()) + + ",\"failure_reason\":" + + stringOrNull(target.failureReason()) + + "}"; + } + FeasibilityEndingContent feasibility = (FeasibilityEndingContent) content; + return "{\"conclusion\":" + + quote(feasibility.conclusion().literal()) + + ",\"feasibility_summary\":" + + stringOrNull(feasibility.feasibilitySummary()) + + "}"; + } + + private static String itemsJson(List items) { + if (items == null) { + return "null"; + } + List entries = new ArrayList<>(); + for (NegotiationItem item : items) { + entries.add("{\"name\":" + quote(item.name()) + ",\"value\":" + stringOrNull(item.value()) + "}"); + } + return "[" + String.join(",", entries) + "]"; + } + + private static String stringOrNull(String value) { + return value == null ? "null" : quote(value); + } + + private static String quote(String value) { + return "\"" + + value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + + "\""; + } + + /** + * Scripted LLM client: returns the queued payloads in order, throws the queued failure, and repeats the last entry + * once the queue is exhausted. Every call records the request for later assertions. + */ + private static final class ScriptedExtractionClient implements LLMClient { + + private final List script; + + private int calls; + + private List> lastMessages; + + private Map lastSchema; + + private ScriptedExtractionClient(Object... entries) { + this.script = List.of(entries); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + Object next = script.get(Math.min(calls, script.size() - 1)); + calls++; + lastMessages = messages; + lastSchema = jsonSchema; + if (next instanceof RuntimeException failure) { + throw failure; + } + return new LLMResponse( + (String) next, "scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java index cc795a2d..3be53ce8 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java @@ -1,35 +1,35 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import org.junit.jupiter.api.Test; - -class NegotiationContextRendererTest { - - private final NegotiationContextRenderer renderer = new NegotiationContextRenderer(); - - @Test - void rendersContextAsMarkdownListWithLanguageNeutralFieldNames() { - NegotiationContext context = new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5); - - String rendered = renderer.render(context); - - assertEquals("- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 2\n- maxRounds: 5", rendered); - } - - @Test - void rendersContextBuiltByFactoryAndNextRound() { - NegotiationContext context = NegotiationContext.of("id-1", 1).nextRound(); - - assertEquals("- id: id-1\n- round: 2\n- maxRounds: 5", renderer.render(context)); - } - - @Test - void rejectsNullContext() { - NullPointerException exception = assertThrows(NullPointerException.class, () -> renderer.render(null)); - - assertEquals("Negotiation context must not be null.", exception.getMessage()); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import net.openan.a2at.sdk.core.model.NegotiationContext; +import org.junit.jupiter.api.Test; + +class NegotiationContextRendererTest { + + private final NegotiationContextRenderer renderer = new NegotiationContextRenderer(); + + @Test + void rendersContextAsMarkdownListWithLanguageNeutralFieldNames() { + NegotiationContext context = new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5); + + String rendered = renderer.render(context); + + assertEquals("- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 2\n- maxRounds: 5", rendered); + } + + @Test + void rendersContextBuiltByFactoryAndNextRound() { + NegotiationContext context = NegotiationContext.of("id-1", 1).nextRound(); + + assertEquals("- id: id-1\n- round: 2\n- maxRounds: 5", renderer.render(context)); + } + + @Test + void rejectsNullContext() { + NullPointerException exception = assertThrows(NullPointerException.class, () -> renderer.render(null)); + + assertEquals("Negotiation context must not be null.", exception.getMessage()); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java index dc8dd22d..68b5e3a4 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java @@ -1,219 +1,219 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.lang.reflect.Method; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMError; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; -import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckResult; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; - -/** - * Verifies the wiring defaults and injection seams of {@link NegotiationGenerationOrchestratorBuilder}. - * - *

      The builder falls back to the module logger when no logger is injected, honors an injected compliance checker, - * propagates the attempt limit to both retry chains (content extraction and semantic validation), and exposes no - * negotiation-type recognizer injection point. - */ -class NegotiationGenerationOrchestratorBuilderWiringTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private Logger rootLogger; - - private ListAppender appender; - - @BeforeEach - void attachAppender() { - rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); - appender = new ListAppender<>(); - appender.start(); - rootLogger.addAppender(appender); - } - - @AfterEach - void detachAppender() { - rootLogger.detachAppender(appender); - appender.stop(); - } - - @Test - void absentLoggerFallsBackToTheModuleLogger() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - - MetadataContent result = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - - assertFalse(result.promptText().isBlank()); - ILoggingEvent completed = appender.list.stream() - .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) - .findFirst() - .orElseThrow(); - assertEquals(NegotiationGenerationOrchestrator.class.getName(), completed.getLoggerName()); - assertEquals(Level.INFO, completed.getLevel()); - } - - @Test - void injectedLoggerReceivesThePipelineEvents() { - Logger customLogger = (Logger) LoggerFactory.getLogger("negotiation.wiring.custom-logger"); - ListAppender customAppender = new ListAppender<>(); - customAppender.start(); - customLogger.addAppender(customAppender); - try { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .logger(customLogger) - .build(); - - orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - - ILoggingEvent completed = customAppender.list.stream() - .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) - .findFirst() - .orElseThrow(); - assertEquals("negotiation.wiring.custom-logger", completed.getLoggerName()); - } finally { - customLogger.detachAppender(customAppender); - customAppender.stop(); - } - } - - @Test - void injectedComplianceCheckerIsUsedByTheValidationPipeline() { - RecordingComplianceChecker checker = new RecordingComplianceChecker(); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient( - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}")) - .complianceChecker(checker) - .build(); - - orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", - Map.of("type", "object"), - INFORMATION_PROPOSE_URI); - - assertTrue(checker.calls.get() >= 1, "the injected compliance checker must be used"); - } - - @Test - void maxAttemptsReachesBothRetryChains() { - CountingFailingClient generationClient = new CountingFailingClient(); - NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(generationClient) - .maxAttempts(2) - .build(); - NegotiationGenerationException generationFailure = org.junit.jupiter.api.Assertions.assertThrows( - NegotiationGenerationException.class, - () -> generationOrchestrator.generateProposeFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, generationFailure.getCode()); - assertEquals(2, generationClient.calls.get(), "generation chain must retry up to the limit"); - - CountingFailingClient semanticClient = new CountingFailingClient(); - NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(semanticClient) - .maxAttempts(2) - .build(); - org.junit.jupiter.api.Assertions.assertThrows( - net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException.class, - () -> validationOrchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - assertEquals(2, semanticClient.calls.get(), "semantic validation chain must retry up to the limit"); - } - - @Test - void builderExposesNoTypeRecognizerInjectionPoint() { - for (Method method : NegotiationGenerationOrchestratorBuilder.class.getDeclaredMethods()) { - String name = method.getName().toLowerCase(Locale.ROOT); - assertFalse( - name.contains("recogn"), - "the builder must not expose a type recognizer injection point but declares " + method.getName()); - } - } - - private static final class RecordingComplianceChecker implements NegotiationComplianceChecker { - - private final AtomicInteger calls = new AtomicInteger(); - - @Override - public NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary) { - calls.incrementAndGet(); - return new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(UUID, 1, 5)); - } - } - - private static final class ScriptedClient implements LLMClient { - - private final String payload; - - private ScriptedClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class CountingFailingClient implements LLMClient { - - private final AtomicInteger calls = new AtomicInteger(); - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls.incrementAndGet(); - throw new LLMError("LLM endpoint unavailable."); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; +import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * Verifies the wiring defaults and injection seams of {@link NegotiationGenerationOrchestratorBuilder}. + * + *

      The builder falls back to the module logger when no logger is injected, honors an injected compliance checker, + * propagates the attempt limit to both retry chains (content extraction and semantic validation), and exposes no + * negotiation-type recognizer injection point. + */ +class NegotiationGenerationOrchestratorBuilderWiringTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private Logger rootLogger; + + private ListAppender appender; + + @BeforeEach + void attachAppender() { + rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + appender = new ListAppender<>(); + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + rootLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void absentLoggerFallsBackToTheModuleLogger() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + MetadataContent result = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + assertFalse(result.promptText().isBlank()); + ILoggingEvent completed = appender.list.stream() + .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) + .findFirst() + .orElseThrow(); + assertEquals(NegotiationGenerationOrchestrator.class.getName(), completed.getLoggerName()); + assertEquals(Level.INFO, completed.getLevel()); + } + + @Test + void injectedLoggerReceivesThePipelineEvents() { + Logger customLogger = (Logger) LoggerFactory.getLogger("negotiation.wiring.custom-logger"); + ListAppender customAppender = new ListAppender<>(); + customAppender.start(); + customLogger.addAppender(customAppender); + try { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .logger(customLogger) + .build(); + + orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + ILoggingEvent completed = customAppender.list.stream() + .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) + .findFirst() + .orElseThrow(); + assertEquals("negotiation.wiring.custom-logger", completed.getLoggerName()); + } finally { + customLogger.detachAppender(customAppender); + customAppender.stop(); + } + } + + @Test + void injectedComplianceCheckerIsUsedByTheValidationPipeline() { + RecordingComplianceChecker checker = new RecordingComplianceChecker(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient( + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}")) + .complianceChecker(checker) + .build(); + + orchestrator.validateProposePromptAndDataFilling( + "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + Map.of("type", "object"), + INFORMATION_PROPOSE_URI); + + assertTrue(checker.calls.get() >= 1, "the injected compliance checker must be used"); + } + + @Test + void maxAttemptsReachesBothRetryChains() { + CountingFailingClient generationClient = new CountingFailingClient(); + NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(generationClient) + .maxAttempts(2) + .build(); + NegotiationGenerationException generationFailure = org.junit.jupiter.api.Assertions.assertThrows( + NegotiationGenerationException.class, + () -> generationOrchestrator.generateProposeFromText( + "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI)); + assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, generationFailure.getCode()); + assertEquals(2, generationClient.calls.get(), "generation chain must retry up to the limit"); + + CountingFailingClient semanticClient = new CountingFailingClient(); + NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(semanticClient) + .maxAttempts(2) + .build(); + org.junit.jupiter.api.Assertions.assertThrows( + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException.class, + () -> validationOrchestrator.validateProposePromptAndDataFilling( + "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + assertEquals(2, semanticClient.calls.get(), "semantic validation chain must retry up to the limit"); + } + + @Test + void builderExposesNoTypeRecognizerInjectionPoint() { + for (Method method : NegotiationGenerationOrchestratorBuilder.class.getDeclaredMethods()) { + String name = method.getName().toLowerCase(Locale.ROOT); + assertFalse( + name.contains("recogn"), + "the builder must not expose a type recognizer injection point but declares " + method.getName()); + } + } + + private static final class RecordingComplianceChecker implements NegotiationComplianceChecker { + + private final AtomicInteger calls = new AtomicInteger(); + + @Override + public NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary) { + calls.incrementAndGet(); + return new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(UUID, 1, 5)); + } + } + + private static final class ScriptedClient implements LLMClient { + + private final String payload; + + private ScriptedClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class CountingFailingClient implements LLMClient { + + private final AtomicInteger calls = new AtomicInteger(); + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls.incrementAndGet(); + throw new LLMError("LLM endpoint unavailable."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 5ff2230a..c3038875 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -1,318 +1,318 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; -import org.junit.jupiter.api.Test; - -class NegotiationGenerationOrchestratorTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); - - @Test - void generatesInformationProposeFromDataInChinese() { - MetadataContent result = zhOrchestrator() - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertEquals(NegotiationHandler.NEGOTIATION_T_URI, result.extensionUri()); - assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("- id: " + UUID)); - assertTrue(result.promptText().contains("- round: 1")); - assertTrue(result.promptText().contains("- maxRounds: 5")); - assertTrue(result.promptText().contains("协商上下文")); - assertTrue(result.promptText().contains("所需信息项")); - - Map metadata = result.buildMetadataContent(); - assertEquals(2, metadata.size()); - assertEquals(result.promptText(), metadata.get(NegotiationHandler.NEGOTIATION_T_URI)); - assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - } - - @Test - void generatesInformationProposeFromDataInEnglish() { - MetadataContent result = orchestrator("en-US") - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 2, 5), - new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("- id: " + UUID)); - assertTrue(result.promptText().contains("Negotiation Context")); - assertTrue(result.promptText().contains("Required Information Items")); - } - - @Test - void generatesProposeFromTextWithScriptedExtraction() { - ScriptedClient llm = - new ScriptedClient("{\"items\":[{\"name\":\"故障发生时间\",\"value\":\"精确到分钟的时间点\"}],\"relationship\":null}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - - MetadataContent result = orchestrator.generateProposeFromText( - "请提供故障发生时间。", new NegotiationContext(UUID, 2, 5), INFORMATION_PROPOSE); - - assertEquals(1, llm.calls); - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("- round: 2")); - assertTrue(result.promptText().contains("故障发生时间")); - } - - @Test - void validatesAndFillsProposeDataWithScriptedSemanticResponse() { - ScriptedClient llm = - new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - MetadataContent message = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); - - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message.promptText(), - Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - INFORMATION_PROPOSE); - - assertEquals(1, llm.calls); - assertEquals(UUID, filled.data().get("id")); - assertEquals(1, filled.data().get("round")); - assertEquals(5, filled.data().get("maxRounds")); - assertEquals("松山湖", filled.data().get("region")); - } - - @Test - void listsAllSevenTemplatesPerLanguage() { - assertEquals(7, zhOrchestrator().getNegotiationPrompts().size()); - assertEquals(7, orchestrator("en-US").getNegotiationPrompts().size()); - } - - @Test - void queriesSingleTemplateByUri() { - assertTrue(zhOrchestrator().getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); - assertTrue(zhOrchestrator() - .getNegotiationPrompt(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) - .isPresent()); - assertFalse( - zhOrchestrator() - .getNegotiationPrompt( - TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")) - .isPresent()); - assertFalse(zhOrchestrator().getNegotiationPrompt(StandardTemplates.ENERGY_SAVING).isPresent()); - } - - @Test - void retriesContentExtractionUntilItSucceeds() { - ScriptedClient llm = new ScriptedClient("", "", "{\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build(); - - MetadataContent result = orchestrator.generateProposeFromText( - "请提供区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE); - - assertEquals(3, llm.calls); - assertFalse(result.promptText().isBlank()); - } - - @Test - void rethrowsOriginalCodeWhenRetriesAreExhausted() { - FailingClient llm = new FailingClient(); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(2) - .build(); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE)); - - assertEquals(2, llm.calls); - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); - } - - @Test - void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { - NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .templateLoader(missingTemplateLoader()) - .build(); - - NegotiationGenerationException generationFailure = assertThrows( - NegotiationGenerationException.class, - () -> generationOrchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), - INFORMATION_PROPOSE)); - assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, generationFailure.getCode()); - - NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .semanticValidator((prompt, callerSchema, reference) -> { - throw new ResourceNotFoundException( - "Semantic validation prompt does not exist.", "prompt_resources/prompts"); - }) - .build(); - - NegotiationParamExtractionException extractionFailure = assertThrows( - NegotiationParamExtractionException.class, - () -> validationOrchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", - Map.of("type", "object"), - INFORMATION_PROPOSE)); - assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, extractionFailure.getCode()); - } - - @Test - void rejectsNonNegotiationPromptInParameterExtraction() { - ScriptedClient llm = new ScriptedClient( - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - - NegotiationParamExtractionException failure = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - "plain text without any negotiation section", - Map.of("type", "object"), - INFORMATION_PROPOSE)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, failure.getCode()); - assertEquals(0, llm.calls); - } - - @Test - void rejectsTemplateUriThatDoesNotAddressTheExpectedPhase() { - IllegalArgumentException phaseMismatchFailure = - assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT)); - assertTrue(phaseMismatchFailure - .getMessage() - .contains("Template URI does not address a negotiation template of the expected phase PROPOSE" - + " (propose)")); - - IllegalArgumentException wrongExtensionFailure = - assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), - StandardTemplates.ENERGY_SAVING)); - assertTrue(wrongExtensionFailure - .getMessage() - .contains("Template URI does not address a negotiation template of the expected phase PROPOSE" - + " (propose)")); - } - - private static NegotiationGenerationOrchestrator zhOrchestrator() { - return orchestrator("zh-CN"); - } - - private static NegotiationGenerationOrchestrator orchestrator(String language) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .build(); - } - - private static NegotiationTemplateLoader missingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - - @Override - public List loadAll() { - return List.of(); - } - }; - } - - private static final class ScriptedClient implements LLMClient { - - private final List payloads; - - private int calls; - - private ScriptedClient(String... payloads) { - this.payloads = List.of(payloads); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - String payload = payloads.get(Math.min(calls, payloads.size() - 1)); - calls++; - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class FailingClient implements LLMClient { - - private int calls; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - throw new IllegalStateException("LLM endpoint unavailable."); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import org.junit.jupiter.api.Test; + +class NegotiationGenerationOrchestratorTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + @Test + void generatesInformationProposeFromDataInChinese() { + MetadataContent result = zhOrchestrator() + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertEquals(NegotiationHandler.NEGOTIATION_T_URI, result.extensionUri()); + assertFalse(result.promptText().isBlank()); + assertTrue(result.promptText().contains("- id: " + UUID)); + assertTrue(result.promptText().contains("- round: 1")); + assertTrue(result.promptText().contains("- maxRounds: 5")); + assertTrue(result.promptText().contains("协商上下文")); + assertTrue(result.promptText().contains("所需信息项")); + + Map metadata = result.buildMetadataContent(); + assertEquals(2, metadata.size()); + assertEquals(result.promptText(), metadata.get(NegotiationHandler.NEGOTIATION_T_URI)); + assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + } + + @Test + void generatesInformationProposeFromDataInEnglish() { + MetadataContent result = orchestrator("en-US") + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertTrue(result.promptText().contains("- id: " + UUID)); + assertTrue(result.promptText().contains("Negotiation Context")); + assertTrue(result.promptText().contains("Required Information Items")); + } + + @Test + void generatesProposeFromTextWithScriptedExtraction() { + ScriptedClient llm = + new ScriptedClient("{\"items\":[{\"name\":\"故障发生时间\",\"value\":\"精确到分钟的时间点\"}],\"relationship\":null}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + MetadataContent result = orchestrator.generateProposeFromText( + "请提供故障发生时间。", new NegotiationContext(UUID, 2, 5), INFORMATION_PROPOSE); + + assertEquals(1, llm.calls); + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertTrue(result.promptText().contains("- round: 2")); + assertTrue(result.promptText().contains("故障发生时间")); + } + + @Test + void validatesAndFillsProposeDataWithScriptedSemanticResponse() { + ScriptedClient llm = + new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + MetadataContent message = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message.promptText(), + Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), + INFORMATION_PROPOSE); + + assertEquals(1, llm.calls); + assertEquals(UUID, filled.data().get("id")); + assertEquals(1, filled.data().get("round")); + assertEquals(5, filled.data().get("maxRounds")); + assertEquals("松山湖", filled.data().get("region")); + } + + @Test + void listsAllSevenTemplatesPerLanguage() { + assertEquals(7, zhOrchestrator().getNegotiationPrompts().size()); + assertEquals(7, orchestrator("en-US").getNegotiationPrompts().size()); + } + + @Test + void queriesSingleTemplateByUri() { + assertTrue(zhOrchestrator().getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); + assertTrue(zhOrchestrator() + .getNegotiationPrompt(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) + .isPresent()); + assertFalse( + zhOrchestrator() + .getNegotiationPrompt( + TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")) + .isPresent()); + assertFalse(zhOrchestrator().getNegotiationPrompt(StandardTemplates.ENERGY_SAVING).isPresent()); + } + + @Test + void retriesContentExtractionUntilItSucceeds() { + ScriptedClient llm = new ScriptedClient("", "", "{\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build(); + + MetadataContent result = orchestrator.generateProposeFromText( + "请提供区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE); + + assertEquals(3, llm.calls); + assertFalse(result.promptText().isBlank()); + } + + @Test + void rethrowsOriginalCodeWhenRetriesAreExhausted() { + FailingClient llm = new FailingClient(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build(); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE)); + + assertEquals(2, llm.calls); + assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); + } + + @Test + void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { + NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(missingTemplateLoader()) + .build(); + + NegotiationGenerationException generationFailure = assertThrows( + NegotiationGenerationException.class, + () -> generationOrchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + INFORMATION_PROPOSE)); + assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, generationFailure.getCode()); + + NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .semanticValidator((prompt, callerSchema, reference) -> { + throw new ResourceNotFoundException( + "Semantic validation prompt does not exist.", "prompt_resources/prompts"); + }) + .build(); + + NegotiationParamExtractionException extractionFailure = assertThrows( + NegotiationParamExtractionException.class, + () -> validationOrchestrator.validateProposePromptAndDataFilling( + "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + Map.of("type", "object"), + INFORMATION_PROPOSE)); + assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, extractionFailure.getCode()); + } + + @Test + void rejectsNonNegotiationPromptInParameterExtraction() { + ScriptedClient llm = new ScriptedClient( + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "plain text without any negotiation section", + Map.of("type", "object"), + INFORMATION_PROPOSE)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, failure.getCode()); + assertEquals(0, llm.calls); + } + + @Test + void rejectsTemplateUriThatDoesNotAddressTheExpectedPhase() { + IllegalArgumentException phaseMismatchFailure = + assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT)); + assertTrue(phaseMismatchFailure + .getMessage() + .contains("Template URI does not address a negotiation template of the expected phase PROPOSE" + + " (propose)")); + + IllegalArgumentException wrongExtensionFailure = + assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + StandardTemplates.ENERGY_SAVING)); + assertTrue(wrongExtensionFailure + .getMessage() + .contains("Template URI does not address a negotiation template of the expected phase PROPOSE" + + " (propose)")); + } + + private static NegotiationGenerationOrchestrator zhOrchestrator() { + return orchestrator("zh-CN"); + } + + private static NegotiationGenerationOrchestrator orchestrator(String language) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .build(); + } + + private static NegotiationTemplateLoader missingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + + @Override + public List loadAll() { + return List.of(); + } + }; + } + + private static final class ScriptedClient implements LLMClient { + + private final List payloads; + + private int calls; + + private ScriptedClient(String... payloads) { + this.payloads = List.of(payloads); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + String payload = payloads.get(Math.min(calls, payloads.size() - 1)); + calls++; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class FailingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new IllegalStateException("LLM endpoint unavailable."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java index fe698931..f67590a7 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -1,540 +1,540 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import org.junit.jupiter.api.Test; - -class NegotiationGeneratorsTest { - - private static final String CONTEXT_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String ZH_TARGET_PROPOSE_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 目标协商 - {{目标协商概述}}(必填) - 要求: - 概述要求。 - - ## 意图理解陈述 - {{意图理解陈述}}(选填) - 要求: - 意图要求。 - - ## 理解对齐与疑问澄清 - {{理解对齐与疑问澄清}}(选填) - 要求: - 对齐要求。 - - ## 待澄清内容 - {{待澄清内容}}(选填) - 要求: - 澄清要求。 - """; - - private static final String ZH_FEASIBILITY_PROPOSE_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 可行性协商 - {{可行性协商概述}}(必填) - 要求: - 概述要求。 - - ## 待评估内容说明 - {{待评估内容说明}}(选填) - 要求: - 评估要求。 - - ## 评估不可行时的详情和提案 - {{评估不可行时的详情和提案}}(选填) - 要求: - 提案要求。 - """; - - private static final String ZH_INFO_PROPOSE_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 信息协商 - 请根据<所需信息项>补充相关内容。 - - ## 所需信息项 - {{所需信息项}}(必填) - 要求: - 信息项要求。 - """; - - private static final String ZH_INFO_ENDING_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 信息协商结果 - {{信息协商结果}}(必填) - 要求: - 结论要求。 - - ## 信息协商结果内容 - {{信息协商结果内容}}(必填) - 要求: - 内容要求。 - """; - - private static final String ZH_TARGET_ENDING_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 目标协商结果 - {{目标协商结果}}(必填) - 要求: - 结论要求。 - - ## 目标协商结果内容 - {{目标协商结果内容}}(必填) - 要求: - 内容要求。 - """; - - private static final String ZH_FEASIBILITY_ENDING_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 可行性协商结果 - {{可行性协商结果}}(必填) - 要求: - 结论要求。 - - ## 可行性评估结果确认 - {{评估结果确认}}(必填) - 要求: - 确认要求。 - """; - - private final Vocabulary zhVocabulary = Vocabulary.forLanguage("zh-CN"); - - private final Vocabulary enVocabulary = Vocabulary.forLanguage("en-US"); - - @Test - void feasibilityRequestActionRendersExactlyTheEvaluationSection() { - FeasibilityProposeContent content = new FeasibilityProposeContent( - "请协助评估该节能目标能否达成。", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("评估对象", "停电8小时期间核心用户的速率保障")), - List.of(new NegotiationItem("不应出现", "值"))); - - String rendered = new FeasibilityProposeGenerator() - .generate( - context(1), - content, - template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 可行性协商\n请协助评估该节能目标能否达成。")); - assertTrue(rendered.contains("## 待评估内容说明\n1. 评估对象:停电8小时期间核心用户的速率保障")); - assertFalse(rendered.contains("## 评估不可行时的详情和提案")); - assertFalse(rendered.contains("要求:")); - } - - @Test - void feasibilityAlternativeActionRendersExactlyTheInfeasibilitySection() { - FeasibilityProposeContent content = new FeasibilityProposeContent( - "当前速率目标不可行,提出下调方案。", - NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, - List.of(new NegotiationItem("不应出现", "值")), - List.of( - new NegotiationItem("不可行原因", "蓄电池仅能支撑8小时2Mbps的保障能力"), - new NegotiationItem("替代提案", "停电期间将速率保障目标下调至2Mbps"))); - - String rendered = new FeasibilityProposeGenerator() - .generate( - context(2), - content, - template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 可行性协商\n当前速率目标不可行,提出下调方案。")); - assertTrue(rendered.contains("## 评估不可行时的详情和提案\n1. 不可行原因:蓄电池仅能支撑8小时2Mbps的保障能力")); - assertTrue(rendered.contains("2. 替代提案:停电期间将速率保障目标下调至2Mbps")); - assertFalse(rendered.contains("## 待评估内容说明")); - } - - @Test - void feasibilityNullActionOrEmptyDrivenContentIsRejected() { - FeasibilityProposeContent nullAction = - new FeasibilityProposeContent("描述", null, List.of(new NegotiationItem("名称", "值")), null); - - NullPointerException nullActionError = - assertThrows(NullPointerException.class, () -> new FeasibilityProposeGenerator() - .generate( - context(1), - nullAction, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary)); - assertTrue(nullActionError.getMessage().contains("action must not be null")); - - FeasibilityProposeContent emptyEvaluation = - new FeasibilityProposeContent("描述", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null); - - IllegalArgumentException emptyEvaluationError = - assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() - .generate( - context(1), - emptyEvaluation, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary)); - assertEquals( - "Contents to evaluate of a feasibility evaluation request must contain at least one item.", - emptyEvaluationError.getMessage()); - - FeasibilityProposeContent emptyAlternative = - new FeasibilityProposeContent("描述", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, List.of()); - - IllegalArgumentException emptyAlternativeError = - assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() - .generate( - context(1), - emptyAlternative, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary)); - assertEquals( - "Infeasibility details and proposal of an alternative proposal must contain at least one item.", - emptyAlternativeError.getMessage()); - - FeasibilityProposeContent blankDescription = new FeasibilityProposeContent( - " ", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, List.of(new NegotiationItem("名称", "值")), null); - - assertEquals( - "Feasibility negotiation description must not be blank.", - assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() - .generate( - context(1), - blankDescription, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary)) - .getMessage()); - } - - @Test - void targetFirstRoundRendersIntentSectionAndDropsAlignmentSection() { - TargetProposeContent content = new TargetProposeContent( - "对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。", - List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), - List.of(new NegotiationItem("对齐项", "不应在首轮渲染")), - List.of(new NegotiationItem("节能时间范围", "需要澄清"))); - - String rendered = new TargetProposeGenerator() - .generate( - context(1), - content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 意图理解陈述\n1. 发起方理解:对方希望在体验无损的前提下降低节能力度")); - assertFalse(rendered.contains("## 理解对齐与疑问澄清")); - assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); - assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。")); - } - - @Test - void targetLaterRoundRendersAlignmentSectionAndDropsIntentSection() { - TargetProposeContent content = new TargetProposeContent( - "已提供疑问澄清,请确认。", - List.of(new NegotiationItem("意图项", "不应在非首轮渲染")), - List.of(new NegotiationItem("确认结果", "节能时间范围确认为08:00~18:00")), - null); - - String rendered = new TargetProposeGenerator() - .generate( - context(3), - content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 理解对齐与疑问澄清\n1. 确认结果:节能时间范围确认为08:00~18:00")); - assertFalse(rendered.contains("## 意图理解陈述")); - assertFalse(rendered.contains("## 待澄清内容")); - } - - @Test - void targetBlankDescriptionIsRejected() { - TargetProposeContent content = new TargetProposeContent(" ", null, null, null); - - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> new TargetProposeGenerator() - .generate( - context(1), - content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), - zhVocabulary)); - - assertEquals("Target negotiation description must not be blank.", exception.getMessage()); - } - - @Test - void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { - InformationProposeContent withRelationship = new InformationProposeContent( - List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点"), new NegotiationItem("受影响小区标识", "CGI 或小区名称")), - "故障发生时间与受影响小区标识需逐小区对应"); - - String renderedZh = new InformationProposeGenerator() - .generate( - context(1), - withRelationship, - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(renderedZh.contains("## 所需信息项\n1. 故障发生时间:精确到分钟的时间点\n2. 受影响小区标识:CGI 或小区名称")); - assertTrue(renderedZh.endsWith("缺失项之间的关系:故障发生时间与受影响小区标识需逐小区对应")); - - InformationProposeContent withoutRelationship = - new InformationProposeContent(List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点")), null); - - String renderedWithout = new InformationProposeGenerator() - .generate( - context(1), - withoutRelationship, - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), - zhVocabulary); - - assertFalse(renderedWithout.contains("缺失项之间的关系")); - } - - @Test - void informationProposeRendersEnglishRelationshipLabelWithTrailingSpace() { - InformationProposeContent content = new InformationProposeContent( - List.of(new NegotiationItem("Failure time", "minute precision")), - "failure time and cell identity must correspond per cell"); - - PromptTemplate enTemplate = template( - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, - """ - ## Negotiation Context - {{negotiation_context}} (required) - Requirement: context. - - ## Required Information Items - {{required_information_items}} (required) - Requirement: items. - """); - - String rendered = new InformationProposeGenerator().generate(context(1), content, enTemplate, enVocabulary); - - assertTrue(rendered.endsWith( - "Relationship between missing items: failure time and cell identity must correspond per cell")); - } - - @Test - void endingGeneratorsRenderConclusionLiterals() { - String infoRendered = new InformationEndingGenerator() - .generate( - context(2), - new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30"))), - template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), - zhVocabulary); - - assertTrue(infoRendered.contains("## 信息协商结果\nAccept")); - assertTrue(infoRendered.contains("## 信息协商结果内容\n1. 故障发生时间:2026-08-19 10:30")); - - String targetRendered = new TargetEndingGenerator() - .generate( - context(2), - new TargetEndingContent(NegotiationConclusion.REJECT, null, "双方未就速率保障下限达成一致。"), - template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), - zhVocabulary); - - assertTrue(targetRendered.contains("## 目标协商结果\nReject")); - assertTrue(targetRendered.contains("## 目标协商结果内容\n双方未就速率保障下限达成一致。")); - - String feasibilityRendered = new FeasibilityEndingGenerator() - .generate( - context(2), - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "同意将速率保障目标由5Mbps下调至2Mbps。"), - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - ZH_FEASIBILITY_ENDING_TEMPLATE), - zhVocabulary); - - assertTrue(feasibilityRendered.contains("## 可行性协商结果\nAccept")); - assertTrue(feasibilityRendered.contains("## 可行性评估结果确认\n同意将速率保障目标由5Mbps下调至2Mbps。")); - } - - @Test - void abortConclusionIsRejectedByEveryEndingGenerator() { - assertThrows(IllegalArgumentException.class, () -> new InformationEndingGenerator() - .generate( - context(1), - new InformationEndingContent(NegotiationConclusion.ABORT, List.of()), - template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), - zhVocabulary)); - assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() - .generate( - context(1), - new TargetEndingContent(NegotiationConclusion.ABORT, "意图", null), - template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), - zhVocabulary)); - assertThrows(IllegalArgumentException.class, () -> new FeasibilityEndingGenerator() - .generate( - context(1), - new FeasibilityEndingContent(NegotiationConclusion.ABORT, "结论"), - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - ZH_FEASIBILITY_ENDING_TEMPLATE), - zhVocabulary)); - } - - @Test - void nullConclusionIsRejected() { - NullPointerException exception = - assertThrows(NullPointerException.class, () -> new InformationEndingGenerator() - .generate( - context(1), - new InformationEndingContent(null, List.of()), - template( - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, - ZH_INFO_ENDING_TEMPLATE), - zhVocabulary)); - - assertTrue(exception.getMessage().contains("conclusion must not be null")); - } - - @Test - void targetEndingRequiresTheFieldMatchingTheConclusion() { - IllegalArgumentException acceptWithoutIntent = - assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() - .generate( - context(1), - new TargetEndingContent(NegotiationConclusion.ACCEPT, null, "失败原因"), - template( - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), - zhVocabulary)); - - assertEquals( - "Confirmed intent of an accepting target negotiation message must not be blank.", - acceptWithoutIntent.getMessage()); - - IllegalArgumentException rejectWithoutReason = - assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() - .generate( - context(1), - new TargetEndingContent(NegotiationConclusion.REJECT, " ", null), - template( - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), - zhVocabulary)); - - assertEquals( - "Failure reason of a rejecting target negotiation message must not be blank.", - rejectWithoutReason.getMessage()); - } - - @Test - void feasibilityEndingRequiresTheSummary() { - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> new FeasibilityEndingGenerator() - .generate( - context(1), - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " "), - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - ZH_FEASIBILITY_ENDING_TEMPLATE), - zhVocabulary)); - - assertEquals( - "Feasibility summary of a terminal feasibility negotiation message must not be blank.", - exception.getMessage()); - } - - @Test - void generatorRejectsContentOfAnotherRuntimeType() { - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> new InformationProposeGenerator() - .generate( - context(1), - new TargetProposeContent("描述", null, null, null), - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), - zhVocabulary)); - - assertTrue(exception.getMessage().contains("requires content of type InformationProposeContent")); - } - - @Test - void generatorsRenderAgainstTheBundledTemplates() { - NegotiationReference reference = NegotiationReference.tryParse( - StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), NegotiationPhase.PROPOSE, "zh-CN") - .orElseThrow(() -> new AssertionError("expected the bundled target propose template to resolve")); - PromptTemplate loaded = new DefaultNegotiationTemplateLoader("zh-CN", null).load(reference); - - TargetProposeContent content = new TargetProposeContent( - "对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。", - List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), - null, - List.of(new NegotiationItem("节能时间范围", "需要澄清"))); - - String rendered = new TargetProposeGenerator().generate(context(1), content, loaded, zhVocabulary); - - assertTrue(rendered.startsWith("## 协商上下文\n- id: " + CONTEXT_ID + "\n- round: 1\n- maxRounds: 5")); - assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。")); - assertTrue(rendered.contains("## 意图理解陈述")); - assertFalse(rendered.contains("## 理解对齐与疑问澄清")); - assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); - assertFalse(rendered.contains("要求:")); - assertFalse(rendered.endsWith("\n")); - } - - private static NegotiationContext context(int round) { - return new NegotiationContext(CONTEXT_ID, round, 5); - } - - private static PromptTemplate template(TemplateUri templateUri, String content) { - return new PromptTemplate(templateUri, "", content); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import org.junit.jupiter.api.Test; + +class NegotiationGeneratorsTest { + + private static final String CONTEXT_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String ZH_TARGET_PROPOSE_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 目标协商 + {{目标协商概述}}(必填) + 要求: + 概述要求。 + + ## 意图理解陈述 + {{意图理解陈述}}(选填) + 要求: + 意图要求。 + + ## 理解对齐与疑问澄清 + {{理解对齐与疑问澄清}}(选填) + 要求: + 对齐要求。 + + ## 待澄清内容 + {{待澄清内容}}(选填) + 要求: + 澄清要求。 + """; + + private static final String ZH_FEASIBILITY_PROPOSE_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 可行性协商 + {{可行性协商概述}}(必填) + 要求: + 概述要求。 + + ## 待评估内容说明 + {{待评估内容说明}}(选填) + 要求: + 评估要求。 + + ## 评估不可行时的详情和提案 + {{评估不可行时的详情和提案}}(选填) + 要求: + 提案要求。 + """; + + private static final String ZH_INFO_PROPOSE_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 信息协商 + 请根据<所需信息项>补充相关内容。 + + ## 所需信息项 + {{所需信息项}}(必填) + 要求: + 信息项要求。 + """; + + private static final String ZH_INFO_ENDING_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 信息协商结果 + {{信息协商结果}}(必填) + 要求: + 结论要求。 + + ## 信息协商结果内容 + {{信息协商结果内容}}(必填) + 要求: + 内容要求。 + """; + + private static final String ZH_TARGET_ENDING_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 目标协商结果 + {{目标协商结果}}(必填) + 要求: + 结论要求。 + + ## 目标协商结果内容 + {{目标协商结果内容}}(必填) + 要求: + 内容要求。 + """; + + private static final String ZH_FEASIBILITY_ENDING_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 可行性协商结果 + {{可行性协商结果}}(必填) + 要求: + 结论要求。 + + ## 可行性评估结果确认 + {{评估结果确认}}(必填) + 要求: + 确认要求。 + """; + + private final Vocabulary zhVocabulary = Vocabulary.forLanguage("zh-CN"); + + private final Vocabulary enVocabulary = Vocabulary.forLanguage("en-US"); + + @Test + void feasibilityRequestActionRendersExactlyTheEvaluationSection() { + FeasibilityProposeContent content = new FeasibilityProposeContent( + "请协助评估该节能目标能否达成。", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("评估对象", "停电8小时期间核心用户的速率保障")), + List.of(new NegotiationItem("不应出现", "值"))); + + String rendered = new FeasibilityProposeGenerator() + .generate( + context(1), + content, + template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 可行性协商\n请协助评估该节能目标能否达成。")); + assertTrue(rendered.contains("## 待评估内容说明\n1. 评估对象:停电8小时期间核心用户的速率保障")); + assertFalse(rendered.contains("## 评估不可行时的详情和提案")); + assertFalse(rendered.contains("要求:")); + } + + @Test + void feasibilityAlternativeActionRendersExactlyTheInfeasibilitySection() { + FeasibilityProposeContent content = new FeasibilityProposeContent( + "当前速率目标不可行,提出下调方案。", + NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, + List.of(new NegotiationItem("不应出现", "值")), + List.of( + new NegotiationItem("不可行原因", "蓄电池仅能支撑8小时2Mbps的保障能力"), + new NegotiationItem("替代提案", "停电期间将速率保障目标下调至2Mbps"))); + + String rendered = new FeasibilityProposeGenerator() + .generate( + context(2), + content, + template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 可行性协商\n当前速率目标不可行,提出下调方案。")); + assertTrue(rendered.contains("## 评估不可行时的详情和提案\n1. 不可行原因:蓄电池仅能支撑8小时2Mbps的保障能力")); + assertTrue(rendered.contains("2. 替代提案:停电期间将速率保障目标下调至2Mbps")); + assertFalse(rendered.contains("## 待评估内容说明")); + } + + @Test + void feasibilityNullActionOrEmptyDrivenContentIsRejected() { + FeasibilityProposeContent nullAction = + new FeasibilityProposeContent("描述", null, List.of(new NegotiationItem("名称", "值")), null); + + NullPointerException nullActionError = + assertThrows(NullPointerException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + nullAction, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertTrue(nullActionError.getMessage().contains("action must not be null")); + + FeasibilityProposeContent emptyEvaluation = + new FeasibilityProposeContent("描述", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null); + + IllegalArgumentException emptyEvaluationError = + assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + emptyEvaluation, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertEquals( + "Contents to evaluate of a feasibility evaluation request must contain at least one item.", + emptyEvaluationError.getMessage()); + + FeasibilityProposeContent emptyAlternative = + new FeasibilityProposeContent("描述", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, List.of()); + + IllegalArgumentException emptyAlternativeError = + assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + emptyAlternative, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertEquals( + "Infeasibility details and proposal of an alternative proposal must contain at least one item.", + emptyAlternativeError.getMessage()); + + FeasibilityProposeContent blankDescription = new FeasibilityProposeContent( + " ", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, List.of(new NegotiationItem("名称", "值")), null); + + assertEquals( + "Feasibility negotiation description must not be blank.", + assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + blankDescription, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)) + .getMessage()); + } + + @Test + void targetFirstRoundRendersIntentSectionAndDropsAlignmentSection() { + TargetProposeContent content = new TargetProposeContent( + "对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。", + List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), + List.of(new NegotiationItem("对齐项", "不应在首轮渲染")), + List.of(new NegotiationItem("节能时间范围", "需要澄清"))); + + String rendered = new TargetProposeGenerator() + .generate( + context(1), + content, + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 意图理解陈述\n1. 发起方理解:对方希望在体验无损的前提下降低节能力度")); + assertFalse(rendered.contains("## 理解对齐与疑问澄清")); + assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); + assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。")); + } + + @Test + void targetLaterRoundRendersAlignmentSectionAndDropsIntentSection() { + TargetProposeContent content = new TargetProposeContent( + "已提供疑问澄清,请确认。", + List.of(new NegotiationItem("意图项", "不应在非首轮渲染")), + List.of(new NegotiationItem("确认结果", "节能时间范围确认为08:00~18:00")), + null); + + String rendered = new TargetProposeGenerator() + .generate( + context(3), + content, + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 理解对齐与疑问澄清\n1. 确认结果:节能时间范围确认为08:00~18:00")); + assertFalse(rendered.contains("## 意图理解陈述")); + assertFalse(rendered.contains("## 待澄清内容")); + } + + @Test + void targetBlankDescriptionIsRejected() { + TargetProposeContent content = new TargetProposeContent(" ", null, null, null); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new TargetProposeGenerator() + .generate( + context(1), + content, + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), + zhVocabulary)); + + assertEquals("Target negotiation description must not be blank.", exception.getMessage()); + } + + @Test + void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { + InformationProposeContent withRelationship = new InformationProposeContent( + List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点"), new NegotiationItem("受影响小区标识", "CGI 或小区名称")), + "故障发生时间与受影响小区标识需逐小区对应"); + + String renderedZh = new InformationProposeGenerator() + .generate( + context(1), + withRelationship, + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(renderedZh.contains("## 所需信息项\n1. 故障发生时间:精确到分钟的时间点\n2. 受影响小区标识:CGI 或小区名称")); + assertTrue(renderedZh.endsWith("缺失项之间的关系:故障发生时间与受影响小区标识需逐小区对应")); + + InformationProposeContent withoutRelationship = + new InformationProposeContent(List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点")), null); + + String renderedWithout = new InformationProposeGenerator() + .generate( + context(1), + withoutRelationship, + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary); + + assertFalse(renderedWithout.contains("缺失项之间的关系")); + } + + @Test + void informationProposeRendersEnglishRelationshipLabelWithTrailingSpace() { + InformationProposeContent content = new InformationProposeContent( + List.of(new NegotiationItem("Failure time", "minute precision")), + "failure time and cell identity must correspond per cell"); + + PromptTemplate enTemplate = template( + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, + """ + ## Negotiation Context + {{negotiation_context}} (required) + Requirement: context. + + ## Required Information Items + {{required_information_items}} (required) + Requirement: items. + """); + + String rendered = new InformationProposeGenerator().generate(context(1), content, enTemplate, enVocabulary); + + assertTrue(rendered.endsWith( + "Relationship between missing items: failure time and cell identity must correspond per cell")); + } + + @Test + void endingGeneratorsRenderConclusionLiterals() { + String infoRendered = new InformationEndingGenerator() + .generate( + context(2), + new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30"))), + template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), + zhVocabulary); + + assertTrue(infoRendered.contains("## 信息协商结果\nAccept")); + assertTrue(infoRendered.contains("## 信息协商结果内容\n1. 故障发生时间:2026-08-19 10:30")); + + String targetRendered = new TargetEndingGenerator() + .generate( + context(2), + new TargetEndingContent(NegotiationConclusion.REJECT, null, "双方未就速率保障下限达成一致。"), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary); + + assertTrue(targetRendered.contains("## 目标协商结果\nReject")); + assertTrue(targetRendered.contains("## 目标协商结果内容\n双方未就速率保障下限达成一致。")); + + String feasibilityRendered = new FeasibilityEndingGenerator() + .generate( + context(2), + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "同意将速率保障目标由5Mbps下调至2Mbps。"), + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + ZH_FEASIBILITY_ENDING_TEMPLATE), + zhVocabulary); + + assertTrue(feasibilityRendered.contains("## 可行性协商结果\nAccept")); + assertTrue(feasibilityRendered.contains("## 可行性评估结果确认\n同意将速率保障目标由5Mbps下调至2Mbps。")); + } + + @Test + void abortConclusionIsRejectedByEveryEndingGenerator() { + assertThrows(IllegalArgumentException.class, () -> new InformationEndingGenerator() + .generate( + context(1), + new InformationEndingContent(NegotiationConclusion.ABORT, List.of()), + template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), + zhVocabulary)); + assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() + .generate( + context(1), + new TargetEndingContent(NegotiationConclusion.ABORT, "意图", null), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary)); + assertThrows(IllegalArgumentException.class, () -> new FeasibilityEndingGenerator() + .generate( + context(1), + new FeasibilityEndingContent(NegotiationConclusion.ABORT, "结论"), + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + ZH_FEASIBILITY_ENDING_TEMPLATE), + zhVocabulary)); + } + + @Test + void nullConclusionIsRejected() { + NullPointerException exception = + assertThrows(NullPointerException.class, () -> new InformationEndingGenerator() + .generate( + context(1), + new InformationEndingContent(null, List.of()), + template( + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, + ZH_INFO_ENDING_TEMPLATE), + zhVocabulary)); + + assertTrue(exception.getMessage().contains("conclusion must not be null")); + } + + @Test + void targetEndingRequiresTheFieldMatchingTheConclusion() { + IllegalArgumentException acceptWithoutIntent = + assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() + .generate( + context(1), + new TargetEndingContent(NegotiationConclusion.ACCEPT, null, "失败原因"), + template( + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals( + "Confirmed intent of an accepting target negotiation message must not be blank.", + acceptWithoutIntent.getMessage()); + + IllegalArgumentException rejectWithoutReason = + assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() + .generate( + context(1), + new TargetEndingContent(NegotiationConclusion.REJECT, " ", null), + template( + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals( + "Failure reason of a rejecting target negotiation message must not be blank.", + rejectWithoutReason.getMessage()); + } + + @Test + void feasibilityEndingRequiresTheSummary() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new FeasibilityEndingGenerator() + .generate( + context(1), + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " "), + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + ZH_FEASIBILITY_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals( + "Feasibility summary of a terminal feasibility negotiation message must not be blank.", + exception.getMessage()); + } + + @Test + void generatorRejectsContentOfAnotherRuntimeType() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new InformationProposeGenerator() + .generate( + context(1), + new TargetProposeContent("描述", null, null, null), + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary)); + + assertTrue(exception.getMessage().contains("requires content of type InformationProposeContent")); + } + + @Test + void generatorsRenderAgainstTheBundledTemplates() { + NegotiationReference reference = NegotiationReference.tryParse( + StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), NegotiationPhase.PROPOSE, "zh-CN") + .orElseThrow(() -> new AssertionError("expected the bundled target propose template to resolve")); + PromptTemplate loaded = new DefaultNegotiationTemplateLoader("zh-CN", null).load(reference); + + TargetProposeContent content = new TargetProposeContent( + "对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。", + List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), + null, + List.of(new NegotiationItem("节能时间范围", "需要澄清"))); + + String rendered = new TargetProposeGenerator().generate(context(1), content, loaded, zhVocabulary); + + assertTrue(rendered.startsWith("## 协商上下文\n- id: " + CONTEXT_ID + "\n- round: 1\n- maxRounds: 5")); + assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。")); + assertTrue(rendered.contains("## 意图理解陈述")); + assertFalse(rendered.contains("## 理解对齐与疑问澄清")); + assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); + assertFalse(rendered.contains("要求:")); + assertFalse(rendered.endsWith("\n")); + } + + private static NegotiationContext context(int round) { + return new NegotiationContext(CONTEXT_ID, round, 5); + } + + private static PromptTemplate template(TemplateUri templateUri, String content) { + return new PromptTemplate(templateUri, "", content); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java index af070614..45aeb5e8 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/RelationshipLineRenderingTest.java @@ -1,89 +1,89 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import org.junit.jupiter.api.Test; - -/** - * Verifies the relationship appended line and the language-specific list punctuation of information propose messages. - * - *

      A non-null relationship is appended as one extra line after the numbered item list, using the language label from - * the vocabulary; a null relationship adds no line. The numbered item lines join name and value with the list colon of - * the message language: the full-width colon for zh-CN and a colon plus one space for en-US. - */ -class RelationshipLineRenderingTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - @Test - void chineseRelationshipIsAppendedWithTheChineseLabel() { - String promptText = generate("zh-CN", "二选一"); - - assertTrue(promptText.contains("缺失项之间的关系:二选一")); - assertTrue(promptText.contains("1. 节能区域:松山湖")); - assertFalse(promptText.contains("Relationship between missing items")); - } - - @Test - void chineseNullRelationshipAddsNoLine() { - String promptText = generate("zh-CN", null); - - assertFalse(promptText.contains("缺失项之间的关系")); - assertTrue(promptText.contains("1. 节能区域:松山湖")); - } - - @Test - void englishRelationshipIsAppendedWithTheEnglishLabel() { - String promptText = generate("en-US", "either-or"); - - assertTrue(promptText.contains("Relationship between missing items: either-or")); - assertTrue(promptText.contains("1. area: Songshan Lake")); - assertFalse(promptText.contains("缺失项之间的关系")); - } - - @Test - void englishNullRelationshipAddsNoLine() { - String promptText = generate("en-US", null); - - assertFalse(promptText.contains("Relationship between missing items")); - assertTrue(promptText.contains("1. area: Songshan Lake")); - } - - @Test - void chineseListUsesTheFullWidthColonAndEnglishUsesColonAndSpace() { - String chinese = generate("zh-CN", null); - String english = generate("en-US", null); - - assertTrue(chinese.contains(":")); - assertFalse(chinese.contains("节能区域: 松山湖")); - assertTrue(english.contains("1. area: Songshan Lake")); - assertFalse(english.contains("area:Songshan Lake")); - } - - private static String generate(String language, String relationship) { - boolean chinese = "zh-CN".equals(language); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .build(); - return orchestrator - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 2, 5), - new InformationProposeContent( - List.of(new NegotiationItem( - chinese ? "节能区域" : "area", chinese ? "松山湖" : "Songshan Lake")), - relationship)), - INFORMATION_PROPOSE_URI) - .promptText(); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import org.junit.jupiter.api.Test; + +/** + * Verifies the relationship appended line and the language-specific list punctuation of information propose messages. + * + *

      A non-null relationship is appended as one extra line after the numbered item list, using the language label from + * the vocabulary; a null relationship adds no line. The numbered item lines join name and value with the list colon of + * the message language: the full-width colon for zh-CN and a colon plus one space for en-US. + */ +class RelationshipLineRenderingTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + @Test + void chineseRelationshipIsAppendedWithTheChineseLabel() { + String promptText = generate("zh-CN", "二选一"); + + assertTrue(promptText.contains("缺失项之间的关系:二选一")); + assertTrue(promptText.contains("1. 节能区域:松山湖")); + assertFalse(promptText.contains("Relationship between missing items")); + } + + @Test + void chineseNullRelationshipAddsNoLine() { + String promptText = generate("zh-CN", null); + + assertFalse(promptText.contains("缺失项之间的关系")); + assertTrue(promptText.contains("1. 节能区域:松山湖")); + } + + @Test + void englishRelationshipIsAppendedWithTheEnglishLabel() { + String promptText = generate("en-US", "either-or"); + + assertTrue(promptText.contains("Relationship between missing items: either-or")); + assertTrue(promptText.contains("1. area: Songshan Lake")); + assertFalse(promptText.contains("缺失项之间的关系")); + } + + @Test + void englishNullRelationshipAddsNoLine() { + String promptText = generate("en-US", null); + + assertFalse(promptText.contains("Relationship between missing items")); + assertTrue(promptText.contains("1. area: Songshan Lake")); + } + + @Test + void chineseListUsesTheFullWidthColonAndEnglishUsesColonAndSpace() { + String chinese = generate("zh-CN", null); + String english = generate("en-US", null); + + assertTrue(chinese.contains(":")); + assertFalse(chinese.contains("节能区域: 松山湖")); + assertTrue(english.contains("1. area: Songshan Lake")); + assertFalse(english.contains("area:Songshan Lake")); + } + + private static String generate(String language, String relationship) { + boolean chinese = "zh-CN".equals(language); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .build(); + return orchestrator + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5), + new InformationProposeContent( + List.of(new NegotiationItem( + chinese ? "节能区域" : "area", chinese ? "松山湖" : "Songshan Lake")), + relationship)), + INFORMATION_PROPOSE_URI) + .promptText(); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index 46f9b708..c2703c0c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -1,229 +1,229 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.golden.GoldenInputs; -import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -/** - * Verifies the template-URI entry matrix of the from-data generation. - * - *

      The six built-in URIs address their templates and render outputs identical to the golden fixtures; a well-formed - * URI that resolves to no template fails with the code {@code template_not_found} before any LLM call; a typed URI - * that does not address a negotiation template of the expected phase (wrong extension name, version, type segment, - * phase segment or separator, including the underscore misspelling of the type segment) fails as a programming error - * pointing at {@code templateUri}, while structural malformation is impossible by construction of - * {@link TemplateUri}; and a template placed under a local resource root overrides the built-in template addressed - * by the same URI. - */ -class TemplateUriEntryMatrixTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String BUILTIN_INFORMATION_PROPOSE_TEMPLATE = - "templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md"; - - /** - * Entry (a): every one of the six built-in URIs reaches its template and renders output byte-identical to the - * golden fixtures locked by the comparison test. - */ - @Test - void everyBuiltInUriRendersItsGoldenFixture() { - for (String language : GoldenInputs.LANGUAGES) { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .build(); - Map> casesByUri = new LinkedHashMap<>(); - for (GoldenCase goldenCase : GoldenCase.values()) { - casesByUri - .computeIfAbsent(goldenCase.templateUri(), uri -> new ArrayList<>()) - .add(goldenCase); - } - assertEquals(6, casesByUri.size(), "the built-in template set has exactly six URIs"); - for (Map.Entry> entry : casesByUri.entrySet()) { - for (GoldenCase goldenCase : entry.getValue()) { - MetadataContent result = goldenCase.generate(orchestrator); - assertEquals(entry.getKey(), result.templateUri()); - assertEquals(readClasspathText(goldenCase.goldenResourcePath(language)), result.promptText()); - } - } - } - } - - /** - * Entry (b): a well-formed URI that resolves to no template in any resource root fails with the code - * {@code template_not_found} before any LLM call. With the bundled resources every well-formed v1 URI resolves in - * both bundled languages, so the both-roots-miss condition is realized by a loader whose every load misses, which - * is the custom-root scenario of an environment without the built-in templates. - */ - @Test - void wellFormedUriMissingInBothRootsFailsWithTemplateNotFound() { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .templateLoader(new MissingTemplateLoader()) - .llmClient(llm) - .build(); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromData(informationProposeData(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, failure.getCode()); - assertTrue( - failure.getMessage() != null && !failure.getMessage().isBlank(), - "the load failure message must be surfaced"); - assertEquals(0, llm.calls, "template loading happens before any LLM call"); - } - - /** - * Entry (c): every typed URI that does not address a negotiation template of the expected phase — wrong extension - * name, version, type segment, phase segment and separator (underscore misspelling), plus unknown types and - * unknown phase segments — fails as a programming error with an {@link IllegalArgumentException} pointing at the - * template URI, without any LLM call. Structurally malformed URIs cannot exist as {@link TemplateUri} values. - */ - @ParameterizedTest(name = "non-addressing URI [{0}] is rejected as a templateUri programming error") - @MethodSource("nonAddressingTemplateUris") - void nonAddressingUriIsRejectedAsATemplateUriProgrammingError(TemplateUri templateUri) { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> orchestrator.generateProposeFromData(informationProposeData(), templateUri)); - - assertTrue( - failure.getMessage().contains( - "Template URI does not address a negotiation template of the expected phase PROPOSE"), - "the failure must point at the template URI but was: " + failure.getMessage()); - assertEquals(0, llm.calls); - } - - static List nonAddressingTemplateUris() { - return List.of( - TemplateUri.of("Task-T", "information-negotiation", "propose"), - TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v2"), - TemplateUri.of("Negotiation-T", "information", "propose"), - TemplateUri.of("Negotiation-T", "information_negotiation", "propose"), - TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose"), - TemplateUri.of("Negotiation-T", "information-negotiation", "propose-x"), - TemplateUri.of("Negotiation-T", "information-negotiation", "accept")); - } - - /** - * Entry (e): a template placed under a local resource root overrides the built-in template addressed by the same - * URI, while the same URI without the local root keeps rendering the built-in golden fixture. - */ - @Test - void localRootTemplateOverridesTheBuiltInTemplateOfTheSameUri(@TempDir Path tempDir) throws IOException { - Path customTemplate = tempDir.resolve(BUILTIN_INFORMATION_PROPOSE_TEMPLATE); - Files.createDirectories(customTemplate.getParent()); - String builtinTemplate = readClasspathText("/prompt_resources/" + BUILTIN_INFORMATION_PROPOSE_TEMPLATE); - String markerSection = "\n\n## Custom Override Section\nCUSTOM-OVERRIDE-MARKER\n"; - Files.writeString(customTemplate, builtinTemplate + markerSection, StandardCharsets.UTF_8); - - NegotiationGenerationOrchestrator customRootOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .localRootDir(tempDir.toString()) - .build(); - String customText = customRootOrchestrator - .generateProposeFromData(informationProposeData(), INFORMATION_PROPOSE_URI) - .promptText(); - assertTrue(customText.contains("## Custom Override Section")); - assertTrue(customText.contains("CUSTOM-OVERRIDE-MARKER")); - - NegotiationGenerationOrchestrator builtinOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - String builtinText = - GoldenCase.INFORMATION_PROPOSE.generate(builtinOrchestrator).promptText(); - assertFalse(builtinText.contains("CUSTOM-OVERRIDE-MARKER")); - assertEquals( - readClasspathText(GoldenCase.INFORMATION_PROPOSE.goldenResourcePath("zh-CN")), - builtinText, - "without the local root the built-in golden output remains unchanged"); - } - - private static NegotiationProposeData informationProposeData() { - return new NegotiationProposeData( - new NegotiationContext(UUID, 2, 5), - new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)); - } - - private static String readClasspathText(String resourcePath) { - InputStream stream = TemplateUriEntryMatrixTest.class.getResourceAsStream(resourcePath); - if (stream == null) { - throw new AssertionError("Classpath resource must exist: " + resourcePath); - } - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } catch (IOException exception) { - throw new AssertionError("Failed to read classpath resource " + resourcePath, exception); - } - } - - private static final class MissingTemplateLoader implements NegotiationTemplateLoader { - - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException( - "Negotiation template does not exist in any resource root.", reference.uri()); - } - - @Override - public List loadAll() { - return List.of(); - } - } - - private static final class CountingClient implements LLMClient { - - private int calls; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - throw new AssertionError("The entry matrix must fail before any LLM call"); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.golden.GoldenInputs; +import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Verifies the template-URI entry matrix of the from-data generation. + * + *

      The six built-in URIs address their templates and render outputs identical to the golden fixtures; a well-formed + * URI that resolves to no template fails with the code {@code template_not_found} before any LLM call; a typed URI + * that does not address a negotiation template of the expected phase (wrong extension name, version, type segment, + * phase segment or separator, including the underscore misspelling of the type segment) fails as a programming error + * pointing at {@code templateUri}, while structural malformation is impossible by construction of + * {@link TemplateUri}; and a template placed under a local resource root overrides the built-in template addressed + * by the same URI. + */ +class TemplateUriEntryMatrixTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String BUILTIN_INFORMATION_PROPOSE_TEMPLATE = + "templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md"; + + /** + * Entry (a): every one of the six built-in URIs reaches its template and renders output byte-identical to the + * golden fixtures locked by the comparison test. + */ + @Test + void everyBuiltInUriRendersItsGoldenFixture() { + for (String language : GoldenInputs.LANGUAGES) { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .build(); + Map> casesByUri = new LinkedHashMap<>(); + for (GoldenCase goldenCase : GoldenCase.values()) { + casesByUri + .computeIfAbsent(goldenCase.templateUri(), uri -> new ArrayList<>()) + .add(goldenCase); + } + assertEquals(6, casesByUri.size(), "the built-in template set has exactly six URIs"); + for (Map.Entry> entry : casesByUri.entrySet()) { + for (GoldenCase goldenCase : entry.getValue()) { + MetadataContent result = goldenCase.generate(orchestrator); + assertEquals(entry.getKey(), result.templateUri()); + assertEquals(readClasspathText(goldenCase.goldenResourcePath(language)), result.promptText()); + } + } + } + } + + /** + * Entry (b): a well-formed URI that resolves to no template in any resource root fails with the code + * {@code template_not_found} before any LLM call. With the bundled resources every well-formed v1 URI resolves in + * both bundled languages, so the both-roots-miss condition is realized by a loader whose every load misses, which + * is the custom-root scenario of an environment without the built-in templates. + */ + @Test + void wellFormedUriMissingInBothRootsFailsWithTemplateNotFound() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(new MissingTemplateLoader()) + .llmClient(llm) + .build(); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromData(informationProposeData(), INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, failure.getCode()); + assertTrue( + failure.getMessage() != null && !failure.getMessage().isBlank(), + "the load failure message must be surfaced"); + assertEquals(0, llm.calls, "template loading happens before any LLM call"); + } + + /** + * Entry (c): every typed URI that does not address a negotiation template of the expected phase — wrong extension + * name, version, type segment, phase segment and separator (underscore misspelling), plus unknown types and + * unknown phase segments — fails as a programming error with an {@link IllegalArgumentException} pointing at the + * template URI, without any LLM call. Structurally malformed URIs cannot exist as {@link TemplateUri} values. + */ + @ParameterizedTest(name = "non-addressing URI [{0}] is rejected as a templateUri programming error") + @MethodSource("nonAddressingTemplateUris") + void nonAddressingUriIsRejectedAsATemplateUriProgrammingError(TemplateUri templateUri) { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.generateProposeFromData(informationProposeData(), templateUri)); + + assertTrue( + failure.getMessage().contains( + "Template URI does not address a negotiation template of the expected phase PROPOSE"), + "the failure must point at the template URI but was: " + failure.getMessage()); + assertEquals(0, llm.calls); + } + + static List nonAddressingTemplateUris() { + return List.of( + TemplateUri.of("Task-T", "information-negotiation", "propose"), + TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v2"), + TemplateUri.of("Negotiation-T", "information", "propose"), + TemplateUri.of("Negotiation-T", "information_negotiation", "propose"), + TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose"), + TemplateUri.of("Negotiation-T", "information-negotiation", "propose-x"), + TemplateUri.of("Negotiation-T", "information-negotiation", "accept")); + } + + /** + * Entry (e): a template placed under a local resource root overrides the built-in template addressed by the same + * URI, while the same URI without the local root keeps rendering the built-in golden fixture. + */ + @Test + void localRootTemplateOverridesTheBuiltInTemplateOfTheSameUri(@TempDir Path tempDir) throws IOException { + Path customTemplate = tempDir.resolve(BUILTIN_INFORMATION_PROPOSE_TEMPLATE); + Files.createDirectories(customTemplate.getParent()); + String builtinTemplate = readClasspathText("/prompt_resources/" + BUILTIN_INFORMATION_PROPOSE_TEMPLATE); + String markerSection = "\n\n## Custom Override Section\nCUSTOM-OVERRIDE-MARKER\n"; + Files.writeString(customTemplate, builtinTemplate + markerSection, StandardCharsets.UTF_8); + + NegotiationGenerationOrchestrator customRootOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .localRootDir(tempDir.toString()) + .build(); + String customText = customRootOrchestrator + .generateProposeFromData(informationProposeData(), INFORMATION_PROPOSE_URI) + .promptText(); + assertTrue(customText.contains("## Custom Override Section")); + assertTrue(customText.contains("CUSTOM-OVERRIDE-MARKER")); + + NegotiationGenerationOrchestrator builtinOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + String builtinText = + GoldenCase.INFORMATION_PROPOSE.generate(builtinOrchestrator).promptText(); + assertFalse(builtinText.contains("CUSTOM-OVERRIDE-MARKER")); + assertEquals( + readClasspathText(GoldenCase.INFORMATION_PROPOSE.goldenResourcePath("zh-CN")), + builtinText, + "without the local root the built-in golden output remains unchanged"); + } + + private static NegotiationProposeData informationProposeData() { + return new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)); + } + + private static String readClasspathText(String resourcePath) { + InputStream stream = TemplateUriEntryMatrixTest.class.getResourceAsStream(resourcePath); + if (stream == null) { + throw new AssertionError("Classpath resource must exist: " + resourcePath); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); + } catch (IOException exception) { + throw new AssertionError("Failed to read classpath resource " + resourcePath, exception); + } + } + + private static final class MissingTemplateLoader implements NegotiationTemplateLoader { + + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException( + "Negotiation template does not exist in any resource root.", reference.uri()); + } + + @Override + public List loadAll() { + return List.of(); + } + } + + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new AssertionError("The entry matrix must fail before any LLM call"); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java index c0fc279a..b9f1316b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java @@ -1,559 +1,559 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -/** - * Integration tests of the three validate-and-filling pipelines of the negotiation content layer. - * - *

      Every test drives the real orchestrator with the built-in zh-CN templates and prompt resources; only the LLM - * boundary is scripted, so each test exercises the full chain from the rendered message through the rule gate, the - * merged semantic validation schema and the deterministic parameter merge. - */ -class ValidateAndFillingDataPipelineTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final TemplateUri INFORMATION_ACCEPT_REJECT_URI = - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - - private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; - - private static final Map REGION_SCHEMA = Map.of( - "type", "object", "properties", Map.of("region", Map.of("type", "string")), "required", List.of("region")); - - @Test - void legalProposeMessageRunsTheFullChainWithASingleLlmCall() { - ScriptedLlmClient llm = - new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\",\"confirmed_rate_mbps\":2}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - FilledParamData filled = - orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI); - - assertEquals(1, llm.calls); - assertEquals(SESSION_ID, filled.data().get("id")); - assertEquals(2, filled.data().get("round")); - assertEquals(5, filled.data().get("maxRounds")); - assertEquals("松山湖", filled.data().get("region")); - assertEquals(2, filled.data().get("confirmed_rate_mbps")); - assertEquals(5, filled.data().size()); - assertInstanceOf(String.class, filled.data().get("id"), "context id must be a string"); - assertInstanceOf(Integer.class, filled.data().get("round"), "context round must be a number"); - assertInstanceOf(Integer.class, filled.data().get("maxRounds"), "context maxRounds must be a number"); - } - - @Test - void legalAcceptMessageRunsTheFullChainWithASingleLlmCall() { - ScriptedLlmClient llm = - new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"energy_saving_area\":\"松山湖\"}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationEndingMessage(orchestrator, NegotiationConclusion.ACCEPT); - - FilledParamData filled = - orchestrator.validateAcceptPromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); - - assertEquals(1, llm.calls); - assertEquals(SESSION_ID, filled.data().get("id")); - assertEquals(2, filled.data().get("round")); - assertEquals(5, filled.data().get("maxRounds")); - assertEquals("松山湖", filled.data().get("energy_saving_area")); - assertEquals(4, filled.data().size()); - } - - @Test - void legalRejectMessageRunsTheFullChainWithASingleLlmCall() { - ScriptedLlmClient llm = - new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"unavailable_item\":\"节能区域信息\"}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationEndingMessage(orchestrator, NegotiationConclusion.REJECT); - - FilledParamData filled = - orchestrator.validateRejectPromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); - - assertEquals(1, llm.calls); - assertEquals(SESSION_ID, filled.data().get("id")); - assertEquals("节能区域信息", filled.data().get("unavailable_item")); - assertEquals(4, filled.data().size()); - } - - @ParameterizedTest - @MethodSource("ruleViolationCases") - void ruleViolationsFailBeforeAnyLlmCall( - String caseName, String idValue, String roundValue, String maxRoundsValue, List expectedSlots) { - ScriptedLlmClient llm = new ScriptedLlmClient( - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String violatingMessage = zhContextMessage(idValue, roundValue, maxRoundsValue); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - violatingMessage, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, exception.getCode(), caseName); - assertEquals(expectedSlots.size(), exception.getErrors().size(), caseName); - for (int index = 0; index < expectedSlots.size(); index++) { - String slotName = exception.getErrors().get(index).slotName(); - assertEquals(expectedSlots.get(index), slotName, caseName); - assertTrue( - List.of("id", "round", "maxRounds").contains(slotName), - caseName + ": rule errors must only address context slots but was " + slotName); - } - assertEquals(0, llm.calls, caseName + ": the rule gate must run before any LLM call"); - } - - static List ruleViolationCases() { - return List.of( - new Object[] {"non-uuid id", "not-a-uuid", null, null, List.of("id")}, - new Object[] {"round zero", null, "0", null, List.of("round")}, - new Object[] {"negative round", null, "-1", null, List.of("round")}, - new Object[] {"zero maxRounds", null, null, "0", List.of("maxRounds")}, - new Object[] {"round above maxRounds", null, "3", "2", List.of("round")}, - new Object[] {"non-integer round", null, "two", null, List.of("round")}, - new Object[] {"non-integer maxRounds", null, null, "many", List.of("maxRounds")}); - } - - @Test - void semanticRejectionIsNotRetriedAndPassesTheErrorsThrough() { - List semanticErrors = List.of( - new SlotValidationError( - "section.info_conclusion", - "invalid_conclusion", - "Conclusion Abort is not one of Accept or Reject."), - new SlotValidationError( - "section.info_items", "missing_section", "Required information items are missing.")); - ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":null," - + "\"errors\":[{\"slot_name\":\"section.info_conclusion\",\"code\":\"invalid_conclusion\"," - + "\"message\":\"Conclusion Abort is not one of Accept or Reject.\"}," - + "{\"slot_name\":\"section.info_items\",\"code\":\"missing_section\"," - + "\"message\":\"Required information items are missing.\"}],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); - assertEquals(semanticErrors, exception.getErrors()); - assertEquals(1, llm.calls, "a negative verdict is a decision, not a failure, and must not be retried"); - } - - @ParameterizedTest - @MethodSource("taskTMessages") - void taskTMessagesAreRejectedAsNonNegotiationInputWithALanguageNeutralMessage( - String caseName, String taskTMessage) { - ScriptedLlmClient llm = new ScriptedLlmClient( - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(taskTMessage, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, exception.getCode(), caseName); - assertTrue(exception.getMessage().contains("checkTaskPrompt"), caseName); - assertFalse(exception.getMessage().contains("协商上下文"), caseName); - assertFalse(exception.getMessage().contains("Negotiation Context"), caseName); - assertEquals(List.of(), exception.getErrors(), caseName); - assertEquals(0, llm.calls, caseName); - } - - static List taskTMessages() { - return List.of( - new Object[] { - "chinese task prompt", - "## 任务类型(Task Type)\n无线能效优化\n\n## 任务对象(Task Object)\n松山湖管委会\n\n## 任务目标(Task" - + " Target)\n总功耗降低30%\n" - }, - new Object[] { - "english task prompt", - "## Task Type\nWireless energy saving\n\n## Task Object\nSongshan Lake\n\n## Task" - + " Target\nReduce total power consumption by 30 percent\n" - }); - } - - @Test - void nestedArrayParamsAreExtractedThroughTheMergedSchema() { - Map timeRateItemSchema = Map.of( - "type", - "object", - "properties", - Map.of("time", Map.of("type", "string"), "rate", Map.of("type", "string"))); - Map callerSchema = Map.of( - "type", - "object", - "properties", - Map.of("timeRateTargets", Map.of("type", "array", "items", timeRateItemSchema)), - "required", - List.of("timeRateTargets"), - "additionalProperties", - false); - ScriptedLlmClient llm = - new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[]," - + "\"params\":{\"timeRateTargets\":[{\"time\":\"08:00-18:00\",\"rate\":\"2Mbps\"}]}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = targetProposeMessage(orchestrator); - - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling(message, callerSchema, TARGET_PROPOSE_URI); - - assertEquals(1, llm.calls); - assertEquals(SESSION_ID, filled.data().get("id")); - assertEquals( - List.of(Map.of("time", "08:00-18:00", "rate", "2Mbps")), - filled.data().get("timeRateTargets")); - - Map mergedSchema = llm.lastSchema; - assertEquals(List.of("semantic_verdict", "negotiation_type", "errors", "params"), mergedSchema.get("required")); - assertEquals(false, mergedSchema.get("additionalProperties")); - Map properties = (Map) mergedSchema.get("properties"); - Map paramsSchema = (Map) properties.get("params"); - assertEquals("object", paramsSchema.get("type")); - Map timeRateTargets = (Map) ((Map) paramsSchema.get("properties")).get("timeRateTargets"); - assertEquals("array", timeRateTargets.get("type")); - } - - @Test - void callerSchemaWithoutATypeKeywordIsWrappedAndTheChainSucceeds() { - Map schemaWithoutType = - Map.of("properties", Map.of("region", Map.of("type", "string")), "required", List.of("region")); - ScriptedLlmClient llm = - new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - FilledParamData filled = - orchestrator.validateProposePromptAndDataFilling(message, schemaWithoutType, INFORMATION_PROPOSE_URI); - - assertEquals(1, llm.calls); - assertEquals("松山湖", filled.data().get("region")); - Map paramsSchema = (Map) ((Map) llm.lastSchema.get("properties")).get("params"); - assertEquals("object", paramsSchema.get("type")); - assertEquals(Map.of("region", Map.of("type", "string")), paramsSchema.get("properties")); - assertEquals(List.of("region"), paramsSchema.get("required")); - } - - @Test - void missingNegotiationTypeKeyIsRetriedThenFailsAsAnInfrastructureError() { - ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":true,\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build(); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); - assertEquals(3, llm.calls, "a shape-invalid response is a retryable infrastructure failure"); - assertEquals(1, exception.getErrors().size()); - assertEquals("_llm", exception.getErrors().get(0).slotName()); - assertTrue(exception.getMessage().contains("negotiation_type")); - } - - @Test - void declaredTypeMismatchIsASemanticRejection() { - ScriptedLlmClient llm = new ScriptedLlmClient( - "{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); - assertEquals(1, llm.calls, "a type mismatch is a semantic decision and must not be retried"); - assertEquals(1, exception.getErrors().size()); - assertEquals("section.target", exception.getErrors().get(0).slotName()); - assertEquals("template_type_mismatch", exception.getErrors().get(0).code()); - } - - @ParameterizedTest - @MethodSource("structuralSemanticCases") - void structuralSemanticChecksSurfaceThroughTheSemanticErrors( - String caseName, String payload, SlotValidationError expectedError) { - ScriptedLlmClient llm = new ScriptedLlmClient(payload); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode(), caseName); - assertEquals(List.of(expectedError), exception.getErrors(), caseName); - assertEquals(1, llm.calls, caseName); - } - - static List structuralSemanticCases() { - return List.of( - new Object[] { - "conclusion outside accept and reject", - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" - + "{\"slot_name\":\"section.target_conclusion\",\"code\":\"invalid_conclusion\"," - + "\"message\":\"Conclusion Abort is not one of Accept or Reject.\"}],\"params\":{}}", - new SlotValidationError( - "section.target_conclusion", - "invalid_conclusion", - "Conclusion Abort is not one of Accept or Reject.") - }, - new Object[] { - "ending result content section missing", - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" - + "{\"slot_name\":\"section.target_result_content\",\"code\":\"missing_section\"," - + "\"message\":\"The ending result content section is missing.\"}],\"params\":{}}", - new SlotValidationError( - "section.target_result_content", - "missing_section", - "The ending result content section is missing.") - }, - new Object[] { - "information propose carries both conditional sections", - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" - + "{\"slot_name\":\"section.info_static\",\"code\":\"conflicting_sections\"," - + "\"message\":\"The static section and the required information items coexist.\"}]," - + "\"params\":{}}", - new SlotValidationError( - "section.info_static", - "conflicting_sections", - "The static section and the required information items coexist.") - }); - } - - @Test - void falseVerdictWithNullTypeIsAShapeLegalOutcome() { - ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":null," - + "\"errors\":[{\"slot_name\":\"section.context\",\"code\":\"inconsistent_context\"," - + "\"message\":\"Context contradicts the message body.\"}],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); - assertEquals(1, exception.getErrors().size()); - assertEquals("section.context", exception.getErrors().get(0).slotName()); - assertEquals(1, llm.calls, "verdict false with a null type is shape-legal and must not be retried"); - } - - @Test - void trueVerdictWithNullTypeIsASemanticRejection() { - ScriptedLlmClient llm = new ScriptedLlmClient( - "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); - assertEquals(1, llm.calls, "a null type with a true verdict is a semantic rejection, not a retryable failure"); - assertEquals(1, exception.getErrors().size()); - assertEquals("section.info_static", exception.getErrors().get(0).slotName()); - assertEquals("template_type_mismatch", exception.getErrors().get(0).code()); - } - - @Test - void semanticLlmFailureIsRetriedAndExhaustsWithTheLlmPseudoSlot() { - ScriptedLlmClient llm = new ScriptedLlmClient("unused"); - llm.failure = new LLMRuntimeError("LLM endpoint unavailable"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(2) - .build(); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); - assertEquals(2, llm.calls); - assertEquals(1, exception.getErrors().size()); - assertEquals("_llm", exception.getErrors().get(0).slotName()); - assertTrue(exception.getMessage().contains("endpoint unavailable")); - } - - @Test - void missingPromptResourcesCloseAsTemplateNotFoundWithoutBubblingTheResourceException() { - ScriptedLlmClient llm = new ScriptedLlmClient("unused"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .semanticValidator((prompt, callerSchema, reference) -> { - throw new ResourceNotFoundException( - "Negotiation semantic validation prompt resource does not exist.", - "prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md"); - }) - .build(); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + SESSION_ID + "\n- round: 1\n- maxRounds: 5\n\n## 所需信息项\n1. 区域\n", - REGION_SCHEMA, - INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, exception.getCode()); - assertEquals( - NegotiationParamExtractionException.class, - exception.getClass(), - "the raw resource exception must not bubble out of the pipeline"); - assertTrue(exception instanceof A2ATError, "the mapped failure stays catchable through the A2ATError root"); - assertEquals(0, llm.calls); - } - - @Test - void proposeUriValidatingAResultMessageIsASemanticRejection() { - SlotValidationError phaseError = new SlotValidationError( - "section.info_result_content", - "phase_mismatch", - "The message is a terminal accept-reject message but the template URI declares the propose phase."); - ScriptedLlmClient llm = - new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" - + "{\"slot_name\":\"section.info_result_content\",\"code\":\"phase_mismatch\"," - + "\"message\":\"The message is a terminal accept-reject message but the template URI" - + " declares the propose phase.\"}],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String rejectMessage = informationEndingMessage(orchestrator, NegotiationConclusion.REJECT); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - rejectMessage, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); - assertEquals(List.of(phaseError), exception.getErrors()); - assertEquals(1, llm.calls); - } - - private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static String informationProposeMessage(NegotiationGenerationOrchestrator orchestrator) { - MetadataContent content = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(SESSION_ID, 2, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - return content.promptText(); - } - - private static String informationEndingMessage( - NegotiationGenerationOrchestrator orchestrator, NegotiationConclusion conclusion) { - NegotiationEndingData data = new NegotiationEndingData( - new NegotiationContext(SESSION_ID, 2, 5), - new InformationEndingContent(conclusion, List.of(new NegotiationItem("节能区域", "松山湖")))); - MetadataContent content = conclusion == NegotiationConclusion.ACCEPT - ? orchestrator.generateAcceptFromData(data, INFORMATION_ACCEPT_REJECT_URI) - : orchestrator.generateRejectFromData(data, INFORMATION_ACCEPT_REJECT_URI); - return content.promptText(); - } - - private static String targetProposeMessage(NegotiationGenerationOrchestrator orchestrator) { - MetadataContent content = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(SESSION_ID, 1, 5), - new TargetProposeContent( - "确认站点级节能任务的速率保障目标调整方案", - List.of(new NegotiationItem("任务意图", "在08:00-18:00对目标站点实施节能")), - null, - null)), - TARGET_PROPOSE_URI); - return content.promptText(); - } - - private static String zhContextMessage(String idValue, String roundValue, String maxRoundsValue) { - StringBuilder prompt = new StringBuilder("## 协商上下文\n"); - prompt.append("- id: ").append(idValue == null ? SESSION_ID : idValue).append('\n'); - prompt.append("- round: ").append(roundValue == null ? "1" : roundValue).append('\n'); - prompt.append("- maxRounds: ") - .append(maxRoundsValue == null ? "5" : maxRoundsValue) - .append('\n'); - prompt.append("\n## 所需信息项\n1. 节能区域\n"); - return prompt.toString(); - } - - /** LLM boundary fake replaying scripted payloads and recording every structured call. */ - private static final class ScriptedLlmClient implements LLMClient { - - private final List payloads; - - private int calls; - - private RuntimeException failure; - - private List> lastMessages; - - private Map lastSchema; - - private ScriptedLlmClient(String... payloads) { - this.payloads = List.of(payloads); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - lastMessages = messages; - lastSchema = jsonSchema; - if (failure != null) { - throw failure; - } - String payload = payloads.get(Math.min(calls - 1, payloads.size() - 1)); - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Integration tests of the three validate-and-filling pipelines of the negotiation content layer. + * + *

      Every test drives the real orchestrator with the built-in zh-CN templates and prompt resources; only the LLM + * boundary is scripted, so each test exercises the full chain from the rendered message through the rule gate, the + * merged semantic validation schema and the deterministic parameter merge. + */ +class ValidateAndFillingDataPipelineTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final TemplateUri INFORMATION_ACCEPT_REJECT_URI = + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + + private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; + + private static final Map REGION_SCHEMA = Map.of( + "type", "object", "properties", Map.of("region", Map.of("type", "string")), "required", List.of("region")); + + @Test + void legalProposeMessageRunsTheFullChainWithASingleLlmCall() { + ScriptedLlmClient llm = + new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\",\"confirmed_rate_mbps\":2}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + FilledParamData filled = + orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI); + + assertEquals(1, llm.calls); + assertEquals(SESSION_ID, filled.data().get("id")); + assertEquals(2, filled.data().get("round")); + assertEquals(5, filled.data().get("maxRounds")); + assertEquals("松山湖", filled.data().get("region")); + assertEquals(2, filled.data().get("confirmed_rate_mbps")); + assertEquals(5, filled.data().size()); + assertInstanceOf(String.class, filled.data().get("id"), "context id must be a string"); + assertInstanceOf(Integer.class, filled.data().get("round"), "context round must be a number"); + assertInstanceOf(Integer.class, filled.data().get("maxRounds"), "context maxRounds must be a number"); + } + + @Test + void legalAcceptMessageRunsTheFullChainWithASingleLlmCall() { + ScriptedLlmClient llm = + new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"energy_saving_area\":\"松山湖\"}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationEndingMessage(orchestrator, NegotiationConclusion.ACCEPT); + + FilledParamData filled = + orchestrator.validateAcceptPromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); + + assertEquals(1, llm.calls); + assertEquals(SESSION_ID, filled.data().get("id")); + assertEquals(2, filled.data().get("round")); + assertEquals(5, filled.data().get("maxRounds")); + assertEquals("松山湖", filled.data().get("energy_saving_area")); + assertEquals(4, filled.data().size()); + } + + @Test + void legalRejectMessageRunsTheFullChainWithASingleLlmCall() { + ScriptedLlmClient llm = + new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"unavailable_item\":\"节能区域信息\"}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationEndingMessage(orchestrator, NegotiationConclusion.REJECT); + + FilledParamData filled = + orchestrator.validateRejectPromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); + + assertEquals(1, llm.calls); + assertEquals(SESSION_ID, filled.data().get("id")); + assertEquals("节能区域信息", filled.data().get("unavailable_item")); + assertEquals(4, filled.data().size()); + } + + @ParameterizedTest + @MethodSource("ruleViolationCases") + void ruleViolationsFailBeforeAnyLlmCall( + String caseName, String idValue, String roundValue, String maxRoundsValue, List expectedSlots) { + ScriptedLlmClient llm = new ScriptedLlmClient( + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String violatingMessage = zhContextMessage(idValue, roundValue, maxRoundsValue); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + violatingMessage, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, exception.getCode(), caseName); + assertEquals(expectedSlots.size(), exception.getErrors().size(), caseName); + for (int index = 0; index < expectedSlots.size(); index++) { + String slotName = exception.getErrors().get(index).slotName(); + assertEquals(expectedSlots.get(index), slotName, caseName); + assertTrue( + List.of("id", "round", "maxRounds").contains(slotName), + caseName + ": rule errors must only address context slots but was " + slotName); + } + assertEquals(0, llm.calls, caseName + ": the rule gate must run before any LLM call"); + } + + static List ruleViolationCases() { + return List.of( + new Object[] {"non-uuid id", "not-a-uuid", null, null, List.of("id")}, + new Object[] {"round zero", null, "0", null, List.of("round")}, + new Object[] {"negative round", null, "-1", null, List.of("round")}, + new Object[] {"zero maxRounds", null, null, "0", List.of("maxRounds")}, + new Object[] {"round above maxRounds", null, "3", "2", List.of("round")}, + new Object[] {"non-integer round", null, "two", null, List.of("round")}, + new Object[] {"non-integer maxRounds", null, null, "many", List.of("maxRounds")}); + } + + @Test + void semanticRejectionIsNotRetriedAndPassesTheErrorsThrough() { + List semanticErrors = List.of( + new SlotValidationError( + "section.info_conclusion", + "invalid_conclusion", + "Conclusion Abort is not one of Accept or Reject."), + new SlotValidationError( + "section.info_items", "missing_section", "Required information items are missing.")); + ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":null," + + "\"errors\":[{\"slot_name\":\"section.info_conclusion\",\"code\":\"invalid_conclusion\"," + + "\"message\":\"Conclusion Abort is not one of Accept or Reject.\"}," + + "{\"slot_name\":\"section.info_items\",\"code\":\"missing_section\"," + + "\"message\":\"Required information items are missing.\"}],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); + assertEquals(semanticErrors, exception.getErrors()); + assertEquals(1, llm.calls, "a negative verdict is a decision, not a failure, and must not be retried"); + } + + @ParameterizedTest + @MethodSource("taskTMessages") + void taskTMessagesAreRejectedAsNonNegotiationInputWithALanguageNeutralMessage( + String caseName, String taskTMessage) { + ScriptedLlmClient llm = new ScriptedLlmClient( + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling(taskTMessage, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, exception.getCode(), caseName); + assertTrue(exception.getMessage().contains("checkTaskPrompt"), caseName); + assertFalse(exception.getMessage().contains("协商上下文"), caseName); + assertFalse(exception.getMessage().contains("Negotiation Context"), caseName); + assertEquals(List.of(), exception.getErrors(), caseName); + assertEquals(0, llm.calls, caseName); + } + + static List taskTMessages() { + return List.of( + new Object[] { + "chinese task prompt", + "## 任务类型(Task Type)\n无线能效优化\n\n## 任务对象(Task Object)\n松山湖管委会\n\n## 任务目标(Task" + + " Target)\n总功耗降低30%\n" + }, + new Object[] { + "english task prompt", + "## Task Type\nWireless energy saving\n\n## Task Object\nSongshan Lake\n\n## Task" + + " Target\nReduce total power consumption by 30 percent\n" + }); + } + + @Test + void nestedArrayParamsAreExtractedThroughTheMergedSchema() { + Map timeRateItemSchema = Map.of( + "type", + "object", + "properties", + Map.of("time", Map.of("type", "string"), "rate", Map.of("type", "string"))); + Map callerSchema = Map.of( + "type", + "object", + "properties", + Map.of("timeRateTargets", Map.of("type", "array", "items", timeRateItemSchema)), + "required", + List.of("timeRateTargets"), + "additionalProperties", + false); + ScriptedLlmClient llm = + new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[]," + + "\"params\":{\"timeRateTargets\":[{\"time\":\"08:00-18:00\",\"rate\":\"2Mbps\"}]}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = targetProposeMessage(orchestrator); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling(message, callerSchema, TARGET_PROPOSE_URI); + + assertEquals(1, llm.calls); + assertEquals(SESSION_ID, filled.data().get("id")); + assertEquals( + List.of(Map.of("time", "08:00-18:00", "rate", "2Mbps")), + filled.data().get("timeRateTargets")); + + Map mergedSchema = llm.lastSchema; + assertEquals(List.of("semantic_verdict", "negotiation_type", "errors", "params"), mergedSchema.get("required")); + assertEquals(false, mergedSchema.get("additionalProperties")); + Map properties = (Map) mergedSchema.get("properties"); + Map paramsSchema = (Map) properties.get("params"); + assertEquals("object", paramsSchema.get("type")); + Map timeRateTargets = (Map) ((Map) paramsSchema.get("properties")).get("timeRateTargets"); + assertEquals("array", timeRateTargets.get("type")); + } + + @Test + void callerSchemaWithoutATypeKeywordIsWrappedAndTheChainSucceeds() { + Map schemaWithoutType = + Map.of("properties", Map.of("region", Map.of("type", "string")), "required", List.of("region")); + ScriptedLlmClient llm = + new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + FilledParamData filled = + orchestrator.validateProposePromptAndDataFilling(message, schemaWithoutType, INFORMATION_PROPOSE_URI); + + assertEquals(1, llm.calls); + assertEquals("松山湖", filled.data().get("region")); + Map paramsSchema = (Map) ((Map) llm.lastSchema.get("properties")).get("params"); + assertEquals("object", paramsSchema.get("type")); + assertEquals(Map.of("region", Map.of("type", "string")), paramsSchema.get("properties")); + assertEquals(List.of("region"), paramsSchema.get("required")); + } + + @Test + void missingNegotiationTypeKeyIsRetriedThenFailsAsAnInfrastructureError() { + ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":true,\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build(); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + assertEquals(3, llm.calls, "a shape-invalid response is a retryable infrastructure failure"); + assertEquals(1, exception.getErrors().size()); + assertEquals("_llm", exception.getErrors().get(0).slotName()); + assertTrue(exception.getMessage().contains("negotiation_type")); + } + + @Test + void declaredTypeMismatchIsASemanticRejection() { + ScriptedLlmClient llm = new ScriptedLlmClient( + "{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); + assertEquals(1, llm.calls, "a type mismatch is a semantic decision and must not be retried"); + assertEquals(1, exception.getErrors().size()); + assertEquals("section.target", exception.getErrors().get(0).slotName()); + assertEquals("template_type_mismatch", exception.getErrors().get(0).code()); + } + + @ParameterizedTest + @MethodSource("structuralSemanticCases") + void structuralSemanticChecksSurfaceThroughTheSemanticErrors( + String caseName, String payload, SlotValidationError expectedError) { + ScriptedLlmClient llm = new ScriptedLlmClient(payload); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode(), caseName); + assertEquals(List.of(expectedError), exception.getErrors(), caseName); + assertEquals(1, llm.calls, caseName); + } + + static List structuralSemanticCases() { + return List.of( + new Object[] { + "conclusion outside accept and reject", + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" + + "{\"slot_name\":\"section.target_conclusion\",\"code\":\"invalid_conclusion\"," + + "\"message\":\"Conclusion Abort is not one of Accept or Reject.\"}],\"params\":{}}", + new SlotValidationError( + "section.target_conclusion", + "invalid_conclusion", + "Conclusion Abort is not one of Accept or Reject.") + }, + new Object[] { + "ending result content section missing", + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" + + "{\"slot_name\":\"section.target_result_content\",\"code\":\"missing_section\"," + + "\"message\":\"The ending result content section is missing.\"}],\"params\":{}}", + new SlotValidationError( + "section.target_result_content", + "missing_section", + "The ending result content section is missing.") + }, + new Object[] { + "information propose carries both conditional sections", + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" + + "{\"slot_name\":\"section.info_static\",\"code\":\"conflicting_sections\"," + + "\"message\":\"The static section and the required information items coexist.\"}]," + + "\"params\":{}}", + new SlotValidationError( + "section.info_static", + "conflicting_sections", + "The static section and the required information items coexist.") + }); + } + + @Test + void falseVerdictWithNullTypeIsAShapeLegalOutcome() { + ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":null," + + "\"errors\":[{\"slot_name\":\"section.context\",\"code\":\"inconsistent_context\"," + + "\"message\":\"Context contradicts the message body.\"}],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); + assertEquals(1, exception.getErrors().size()); + assertEquals("section.context", exception.getErrors().get(0).slotName()); + assertEquals(1, llm.calls, "verdict false with a null type is shape-legal and must not be retried"); + } + + @Test + void trueVerdictWithNullTypeIsASemanticRejection() { + ScriptedLlmClient llm = new ScriptedLlmClient( + "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); + assertEquals(1, llm.calls, "a null type with a true verdict is a semantic rejection, not a retryable failure"); + assertEquals(1, exception.getErrors().size()); + assertEquals("section.info_static", exception.getErrors().get(0).slotName()); + assertEquals("template_type_mismatch", exception.getErrors().get(0).code()); + } + + @Test + void semanticLlmFailureIsRetriedAndExhaustsWithTheLlmPseudoSlot() { + ScriptedLlmClient llm = new ScriptedLlmClient("unused"); + llm.failure = new LLMRuntimeError("LLM endpoint unavailable"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build(); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + assertEquals(2, llm.calls); + assertEquals(1, exception.getErrors().size()); + assertEquals("_llm", exception.getErrors().get(0).slotName()); + assertTrue(exception.getMessage().contains("endpoint unavailable")); + } + + @Test + void missingPromptResourcesCloseAsTemplateNotFoundWithoutBubblingTheResourceException() { + ScriptedLlmClient llm = new ScriptedLlmClient("unused"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .semanticValidator((prompt, callerSchema, reference) -> { + throw new ResourceNotFoundException( + "Negotiation semantic validation prompt resource does not exist.", + "prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md"); + }) + .build(); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "## 协商上下文\n- id: " + SESSION_ID + "\n- round: 1\n- maxRounds: 5\n\n## 所需信息项\n1. 区域\n", + REGION_SCHEMA, + INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, exception.getCode()); + assertEquals( + NegotiationParamExtractionException.class, + exception.getClass(), + "the raw resource exception must not bubble out of the pipeline"); + assertTrue(exception instanceof A2ATError, "the mapped failure stays catchable through the A2ATError root"); + assertEquals(0, llm.calls); + } + + @Test + void proposeUriValidatingAResultMessageIsASemanticRejection() { + SlotValidationError phaseError = new SlotValidationError( + "section.info_result_content", + "phase_mismatch", + "The message is a terminal accept-reject message but the template URI declares the propose phase."); + ScriptedLlmClient llm = + new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" + + "{\"slot_name\":\"section.info_result_content\",\"code\":\"phase_mismatch\"," + + "\"message\":\"The message is a terminal accept-reject message but the template URI" + + " declares the propose phase.\"}],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String rejectMessage = informationEndingMessage(orchestrator, NegotiationConclusion.REJECT); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + rejectMessage, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); + assertEquals(List.of(phaseError), exception.getErrors()); + assertEquals(1, llm.calls); + } + + private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + } + + private static String informationProposeMessage(NegotiationGenerationOrchestrator orchestrator) { + MetadataContent content = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(SESSION_ID, 2, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + return content.promptText(); + } + + private static String informationEndingMessage( + NegotiationGenerationOrchestrator orchestrator, NegotiationConclusion conclusion) { + NegotiationEndingData data = new NegotiationEndingData( + new NegotiationContext(SESSION_ID, 2, 5), + new InformationEndingContent(conclusion, List.of(new NegotiationItem("节能区域", "松山湖")))); + MetadataContent content = conclusion == NegotiationConclusion.ACCEPT + ? orchestrator.generateAcceptFromData(data, INFORMATION_ACCEPT_REJECT_URI) + : orchestrator.generateRejectFromData(data, INFORMATION_ACCEPT_REJECT_URI); + return content.promptText(); + } + + private static String targetProposeMessage(NegotiationGenerationOrchestrator orchestrator) { + MetadataContent content = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(SESSION_ID, 1, 5), + new TargetProposeContent( + "确认站点级节能任务的速率保障目标调整方案", + List.of(new NegotiationItem("任务意图", "在08:00-18:00对目标站点实施节能")), + null, + null)), + TARGET_PROPOSE_URI); + return content.promptText(); + } + + private static String zhContextMessage(String idValue, String roundValue, String maxRoundsValue) { + StringBuilder prompt = new StringBuilder("## 协商上下文\n"); + prompt.append("- id: ").append(idValue == null ? SESSION_ID : idValue).append('\n'); + prompt.append("- round: ").append(roundValue == null ? "1" : roundValue).append('\n'); + prompt.append("- maxRounds: ") + .append(maxRoundsValue == null ? "5" : maxRoundsValue) + .append('\n'); + prompt.append("\n## 所需信息项\n1. 节能区域\n"); + return prompt.toString(); + } + + /** LLM boundary fake replaying scripted payloads and recording every structured call. */ + private static final class ScriptedLlmClient implements LLMClient { + + private final List payloads; + + private int calls; + + private RuntimeException failure; + + private List> lastMessages; + + private Map lastSchema; + + private ScriptedLlmClient(String... payloads) { + this.payloads = List.of(payloads); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + lastMessages = messages; + lastSchema = jsonSchema; + if (failure != null) { + throw failure; + } + String payload = payloads.get(Math.min(calls - 1, payloads.size() - 1)); + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java index a166f76d..cbe392f4 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java @@ -1,309 +1,309 @@ -package net.openan.a2at.sdk.negotiation.golden; - -import java.util.List; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; - -/** - * Fixed inputs of the golden fixture set of the negotiation content layer. - * - *

      Every golden fixture is rendered from exactly one {@link GoldenCase} carrying a fixed negotiation context, fixed - * typed content and the built-in template URI of its negotiation type and phase. The same content data is used for - * every language: only the vocabulary rendering (section titles, labels, list punctuation) differs between zh-CN and - * en-US. - * - *

      The fixture data deliberately exercises the known rendering pitfalls: a non-null relationship with an appended - * line and a null-value item (information propose), the round-driven conditional sections with a non-empty - * clarification list (target propose, round 1), the action-driven conditional section (feasibility propose, evaluation - * request action), the accept and reject conclusion literals, and the feasibility summary rendered into the - * vocabulary-exception slot of the feasibility result confirmation section. - */ -public final class GoldenInputs { - - /** Fixed negotiation session id shared by every golden fixture. */ - public static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - /** Language with bundled zh-CN negotiation resources. */ - public static final String ZH_CN = "zh-CN"; - - /** Language with bundled en-US negotiation resources. */ - public static final String EN_US = "en-US"; - - /** Both languages covered by the golden fixture set, in a fixed order. */ - public static final List LANGUAGES = List.of(ZH_CN, EN_US); - - private GoldenInputs() {} - - /** - * Returns the default fixture context: round 2 of at most 5 rounds. - * - * @return fixed negotiation context of every fixture except the target propose fixture - */ - public static NegotiationContext defaultContext() { - return new NegotiationContext(SESSION_ID, 2, 5); - } - - /** - * Returns the first-round fixture context used by the target propose fixture. - * - * @return fixed negotiation context of round 1 of at most 5 rounds - */ - public static NegotiationContext firstRoundContext() { - return new NegotiationContext(SESSION_ID, 1, 5); - } - - /** One golden fixture case: one negotiation type, phase, fixed context, fixed content and its template URI. */ - public enum GoldenCase { - - /** Information propose fixture with three items, one null-value item and a non-null relationship. */ - INFORMATION_PROPOSE(NegotiationPhase.PROPOSE, "information-negotiation", "information_propose") { - @Override - public NegotiationContext context() { - return GoldenInputs.defaultContext(); - } - - @Override - public NegotiationContent content() { - return new InformationProposeContent( - List.of( - new NegotiationItem("energy-saving area information", "e.g. Songshan Lake"), - new NegotiationItem("energy-saving rate guarantee target", "e.g. 20Mbps"), - new NegotiationItem("VLANId", null)), - "OR"); - } - }, - - /** Target propose fixture of round 1 with intent understanding and a non-empty clarification request. */ - TARGET_PROPOSE(NegotiationPhase.PROPOSE, "target-negotiation", "target_propose") { - @Override - public NegotiationContext context() { - return GoldenInputs.firstRoundContext(); - } - - @Override - public NegotiationContent content() { - return new TargetProposeContent( - "The intent understanding of the wireless energy-saving optimization task is listed in" - + " ; open questions remain about the area and the" - + " time range, see ; please clarify and confirm.", - List.of( - new NegotiationItem( - "task intent", - "apply wireless energy-saving optimization to the target site during" - + " 08:00-18:00"), - new NegotiationItem( - "rate guarantee target", "at least 20Mbps during the energy-saving period")), - null, - List.of( - new NegotiationItem("area", "which site is covered: Songshan Lake or another site"), - new NegotiationItem( - "time range", "is the energy-saving period 08:00-18:00 or 00:00-06:00"))); - } - }, - - /** Feasibility propose fixture requesting a feasibility evaluation of an adjusted rate target. */ - FEASIBILITY_PROPOSE(NegotiationPhase.PROPOSE, "feasibility-negotiation", "feasibility_propose") { - @Override - public NegotiationContext context() { - return GoldenInputs.defaultContext(); - } - - @Override - public NegotiationContent content() { - return new FeasibilityProposeContent( - "Please assess whether the adjusted rate guarantee target of the site-level energy-saving" - + " task can be achieved, see ; please assess.", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of( - new NegotiationItem( - "adjusted target", - "rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00"), - new NegotiationItem( - "existing constraint", - "at least 10 hours of power supply duration in outage scenarios")), - null); - } - }, - - /** Information accept fixture delivering the requested information items. */ - INFORMATION_ACCEPT(NegotiationPhase.ACCEPT, "information-negotiation", "information_accept") { - @Override - public NegotiationContent content() { - return new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of( - new NegotiationItem("energy-saving area information", "Songshan Lake"), - new NegotiationItem("energy-saving rate guarantee target", "20Mbps"))); - } - }, - - /** Target accept fixture carrying the finally confirmed intent. */ - TARGET_ACCEPT(NegotiationPhase.ACCEPT, "target-negotiation", "target_accept") { - @Override - public NegotiationContent content() { - return new TargetEndingContent( - NegotiationConclusion.ACCEPT, - "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan" - + " Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps.", - null); - } - }, - - /** Feasibility accept fixture confirming a positive evaluation result in the exception slot. */ - FEASIBILITY_ACCEPT(NegotiationPhase.ACCEPT, "feasibility-negotiation", "feasibility_accept") { - @Override - public NegotiationContent content() { - return new FeasibilityEndingContent( - NegotiationConclusion.ACCEPT, - "The adjusted rate guarantee target is achievable and satisfies the outage duration" - + " requirement; this negotiation is confirmed as concluded."); - } - }, - - /** Information reject fixture stating that an item cannot be provided. */ - INFORMATION_REJECT(NegotiationPhase.REJECT, "information-negotiation", "information_reject") { - @Override - public NegotiationContent content() { - return new InformationEndingContent( - NegotiationConclusion.REJECT, - List.of(new NegotiationItem( - "energy-saving area information", - "cannot be provided because the site inventory is unavailable"))); - } - }, - - /** Target reject fixture carrying the failure reason. */ - TARGET_REJECT(NegotiationPhase.REJECT, "target-negotiation", "target_reject") { - @Override - public NegotiationContent content() { - return new TargetEndingContent( - NegotiationConclusion.REJECT, - null, - "The area information cannot be clarified in full because the site inventory is" - + " unavailable."); - } - }, - - /** Feasibility reject fixture confirming a negative evaluation result in the exception slot. */ - FEASIBILITY_REJECT(NegotiationPhase.REJECT, "feasibility-negotiation", "feasibility_reject") { - @Override - public NegotiationContent content() { - return new FeasibilityEndingContent( - NegotiationConclusion.REJECT, - "The energy-saving target cannot be achieved under the existing power supply constraint;" - + " this negotiation is confirmed as concluded."); - } - }; - - private final NegotiationPhase phase; - - private final String typeSegment; - - private final String fileName; - - GoldenCase(NegotiationPhase phase, String typeSegment, String fileName) { - this.phase = phase; - this.typeSegment = typeSegment; - this.fileName = fileName; - } - - /** - * Returns the API-level phase of this fixture. - * - * @return propose, accept or reject phase - */ - public NegotiationPhase phase() { - return phase; - } - - /** - * Returns the built-in template URI addressed by this fixture as a typed value. - * - * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} - */ - public TemplateUri template() { - return TemplateUri.of(StandardTemplates.NEGOTIATION_EXTENSION_NAME, typeSegment, - phase.uriSegment()); - } - - /** - * Returns the built-in template URI addressed by this fixture. - * - * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} - */ - public String templateUri() { - return template().uri(); - } - - /** - * Returns the golden fixture file name (without directory) of this fixture. - * - * @return file name such as {@code information_propose.md} - */ - public String fileName() { - return fileName + ".md"; - } - - /** - * Returns the classpath path of the golden fixture of this case for one language. - * - * @param language locale identifier such as {@code zh-CN} - * @return classpath path such as {@code /golden/zh-CN/information_propose.md} - */ - public String goldenResourcePath(String language) { - return "/golden/" + language + "/" + fileName(); - } - - /** - * Returns the fixed negotiation context of this fixture. - * - * @return fixed negotiation context - */ - public NegotiationContext context() { - return GoldenInputs.defaultContext(); - } - - /** - * Returns the fixed typed content of this fixture. - * - * @return fixed propose or ending content - */ - public abstract NegotiationContent content(); - - /** - * Generates this fixture through one orchestrator, using the from-data method of the fixture phase. - * - * @param orchestrator orchestrator wired with the resources of the fixture language - * @return generated metadata content of this fixture - */ - public MetadataContent generate(NegotiationGenerationOrchestrator orchestrator) { - return switch (phase) { - case PROPOSE -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context(), (NegotiationProposeContent) content()), template()); - case ACCEPT -> orchestrator.generateAcceptFromData( - new NegotiationEndingData(context(), (NegotiationEndingContent) content()), template()); - case REJECT -> orchestrator.generateRejectFromData( - new NegotiationEndingData(context(), (NegotiationEndingContent) content()), template()); - case ABORT -> throw new IllegalArgumentException("The typed golden fixtures carry no abort phase."); - }; - } - } -} +package net.openan.a2at.sdk.negotiation.golden; + +import java.util.List; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; + +/** + * Fixed inputs of the golden fixture set of the negotiation content layer. + * + *

      Every golden fixture is rendered from exactly one {@link GoldenCase} carrying a fixed negotiation context, fixed + * typed content and the built-in template URI of its negotiation type and phase. The same content data is used for + * every language: only the vocabulary rendering (section titles, labels, list punctuation) differs between zh-CN and + * en-US. + * + *

      The fixture data deliberately exercises the known rendering pitfalls: a non-null relationship with an appended + * line and a null-value item (information propose), the round-driven conditional sections with a non-empty + * clarification list (target propose, round 1), the action-driven conditional section (feasibility propose, evaluation + * request action), the accept and reject conclusion literals, and the feasibility summary rendered into the + * vocabulary-exception slot of the feasibility result confirmation section. + */ +public final class GoldenInputs { + + /** Fixed negotiation session id shared by every golden fixture. */ + public static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + /** Language with bundled zh-CN negotiation resources. */ + public static final String ZH_CN = "zh-CN"; + + /** Language with bundled en-US negotiation resources. */ + public static final String EN_US = "en-US"; + + /** Both languages covered by the golden fixture set, in a fixed order. */ + public static final List LANGUAGES = List.of(ZH_CN, EN_US); + + private GoldenInputs() {} + + /** + * Returns the default fixture context: round 2 of at most 5 rounds. + * + * @return fixed negotiation context of every fixture except the target propose fixture + */ + public static NegotiationContext defaultContext() { + return new NegotiationContext(SESSION_ID, 2, 5); + } + + /** + * Returns the first-round fixture context used by the target propose fixture. + * + * @return fixed negotiation context of round 1 of at most 5 rounds + */ + public static NegotiationContext firstRoundContext() { + return new NegotiationContext(SESSION_ID, 1, 5); + } + + /** One golden fixture case: one negotiation type, phase, fixed context, fixed content and its template URI. */ + public enum GoldenCase { + + /** Information propose fixture with three items, one null-value item and a non-null relationship. */ + INFORMATION_PROPOSE(NegotiationPhase.PROPOSE, "information-negotiation", "information_propose") { + @Override + public NegotiationContext context() { + return GoldenInputs.defaultContext(); + } + + @Override + public NegotiationContent content() { + return new InformationProposeContent( + List.of( + new NegotiationItem("energy-saving area information", "e.g. Songshan Lake"), + new NegotiationItem("energy-saving rate guarantee target", "e.g. 20Mbps"), + new NegotiationItem("VLANId", null)), + "OR"); + } + }, + + /** Target propose fixture of round 1 with intent understanding and a non-empty clarification request. */ + TARGET_PROPOSE(NegotiationPhase.PROPOSE, "target-negotiation", "target_propose") { + @Override + public NegotiationContext context() { + return GoldenInputs.firstRoundContext(); + } + + @Override + public NegotiationContent content() { + return new TargetProposeContent( + "The intent understanding of the wireless energy-saving optimization task is listed in" + + " ; open questions remain about the area and the" + + " time range, see ; please clarify and confirm.", + List.of( + new NegotiationItem( + "task intent", + "apply wireless energy-saving optimization to the target site during" + + " 08:00-18:00"), + new NegotiationItem( + "rate guarantee target", "at least 20Mbps during the energy-saving period")), + null, + List.of( + new NegotiationItem("area", "which site is covered: Songshan Lake or another site"), + new NegotiationItem( + "time range", "is the energy-saving period 08:00-18:00 or 00:00-06:00"))); + } + }, + + /** Feasibility propose fixture requesting a feasibility evaluation of an adjusted rate target. */ + FEASIBILITY_PROPOSE(NegotiationPhase.PROPOSE, "feasibility-negotiation", "feasibility_propose") { + @Override + public NegotiationContext context() { + return GoldenInputs.defaultContext(); + } + + @Override + public NegotiationContent content() { + return new FeasibilityProposeContent( + "Please assess whether the adjusted rate guarantee target of the site-level energy-saving" + + " task can be achieved, see ; please assess.", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of( + new NegotiationItem( + "adjusted target", + "rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00"), + new NegotiationItem( + "existing constraint", + "at least 10 hours of power supply duration in outage scenarios")), + null); + } + }, + + /** Information accept fixture delivering the requested information items. */ + INFORMATION_ACCEPT(NegotiationPhase.ACCEPT, "information-negotiation", "information_accept") { + @Override + public NegotiationContent content() { + return new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of( + new NegotiationItem("energy-saving area information", "Songshan Lake"), + new NegotiationItem("energy-saving rate guarantee target", "20Mbps"))); + } + }, + + /** Target accept fixture carrying the finally confirmed intent. */ + TARGET_ACCEPT(NegotiationPhase.ACCEPT, "target-negotiation", "target_accept") { + @Override + public NegotiationContent content() { + return new TargetEndingContent( + NegotiationConclusion.ACCEPT, + "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan" + + " Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps.", + null); + } + }, + + /** Feasibility accept fixture confirming a positive evaluation result in the exception slot. */ + FEASIBILITY_ACCEPT(NegotiationPhase.ACCEPT, "feasibility-negotiation", "feasibility_accept") { + @Override + public NegotiationContent content() { + return new FeasibilityEndingContent( + NegotiationConclusion.ACCEPT, + "The adjusted rate guarantee target is achievable and satisfies the outage duration" + + " requirement; this negotiation is confirmed as concluded."); + } + }, + + /** Information reject fixture stating that an item cannot be provided. */ + INFORMATION_REJECT(NegotiationPhase.REJECT, "information-negotiation", "information_reject") { + @Override + public NegotiationContent content() { + return new InformationEndingContent( + NegotiationConclusion.REJECT, + List.of(new NegotiationItem( + "energy-saving area information", + "cannot be provided because the site inventory is unavailable"))); + } + }, + + /** Target reject fixture carrying the failure reason. */ + TARGET_REJECT(NegotiationPhase.REJECT, "target-negotiation", "target_reject") { + @Override + public NegotiationContent content() { + return new TargetEndingContent( + NegotiationConclusion.REJECT, + null, + "The area information cannot be clarified in full because the site inventory is" + + " unavailable."); + } + }, + + /** Feasibility reject fixture confirming a negative evaluation result in the exception slot. */ + FEASIBILITY_REJECT(NegotiationPhase.REJECT, "feasibility-negotiation", "feasibility_reject") { + @Override + public NegotiationContent content() { + return new FeasibilityEndingContent( + NegotiationConclusion.REJECT, + "The energy-saving target cannot be achieved under the existing power supply constraint;" + + " this negotiation is confirmed as concluded."); + } + }; + + private final NegotiationPhase phase; + + private final String typeSegment; + + private final String fileName; + + GoldenCase(NegotiationPhase phase, String typeSegment, String fileName) { + this.phase = phase; + this.typeSegment = typeSegment; + this.fileName = fileName; + } + + /** + * Returns the API-level phase of this fixture. + * + * @return propose, accept or reject phase + */ + public NegotiationPhase phase() { + return phase; + } + + /** + * Returns the built-in template URI addressed by this fixture as a typed value. + * + * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} + */ + public TemplateUri template() { + return TemplateUri.of(StandardTemplates.NEGOTIATION_EXTENSION_NAME, typeSegment, + phase.uriSegment()); + } + + /** + * Returns the built-in template URI addressed by this fixture. + * + * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} + */ + public String templateUri() { + return template().uri(); + } + + /** + * Returns the golden fixture file name (without directory) of this fixture. + * + * @return file name such as {@code information_propose.md} + */ + public String fileName() { + return fileName + ".md"; + } + + /** + * Returns the classpath path of the golden fixture of this case for one language. + * + * @param language locale identifier such as {@code zh-CN} + * @return classpath path such as {@code /golden/zh-CN/information_propose.md} + */ + public String goldenResourcePath(String language) { + return "/golden/" + language + "/" + fileName(); + } + + /** + * Returns the fixed negotiation context of this fixture. + * + * @return fixed negotiation context + */ + public NegotiationContext context() { + return GoldenInputs.defaultContext(); + } + + /** + * Returns the fixed typed content of this fixture. + * + * @return fixed propose or ending content + */ + public abstract NegotiationContent content(); + + /** + * Generates this fixture through one orchestrator, using the from-data method of the fixture phase. + * + * @param orchestrator orchestrator wired with the resources of the fixture language + * @return generated metadata content of this fixture + */ + public MetadataContent generate(NegotiationGenerationOrchestrator orchestrator) { + return switch (phase) { + case PROPOSE -> orchestrator.generateProposeFromData( + new NegotiationProposeData(context(), (NegotiationProposeContent) content()), template()); + case ACCEPT -> orchestrator.generateAcceptFromData( + new NegotiationEndingData(context(), (NegotiationEndingContent) content()), template()); + case REJECT -> orchestrator.generateRejectFromData( + new NegotiationEndingData(context(), (NegotiationEndingContent) content()), template()); + case ABORT -> throw new IllegalArgumentException("The typed golden fixtures carry no abort phase."); + }; + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java index 406f8c92..f123a70c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java @@ -1,378 +1,378 @@ -package net.openan.a2at.sdk.negotiation.observability; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMError; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; -import org.junit.jupiter.api.DynamicTest; -import org.junit.jupiter.api.TestFactory; - -/** - * Drives every row of the negotiation error-code usage matrix through the real pipelines. - * - *

      Each row triggers one failure condition of the documented matrix and asserts the exception type, the public error - * code, the number of LLM calls (encoding retryability: a retryable failure consumes all attempts, a non-retryable - * failure exactly one or zero), and that a structured detail identifying the problem field or slot is carried. - */ -class NegotiationErrorCodeUsageMatrixTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final TemplateUri INFORMATION_ENDING_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - - private static final String VALID_CONTEXT_PROMPT = "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5"; - - private static final Map SCHEMA = Map.of("type", "object"); - - @TestFactory - Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { - return Stream.of( - row( - "generation_template_missing", - "not-a-json", - 0, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .templateLoader(missingTemplateLoader()) - .build() - .generateProposeFromData(proposeData(), INFORMATION_PROPOSE_URI), - NegotiationGenerationException.class, - A2ATErrorCodes.TEMPLATE_NOT_FOUND), - row( - "generation_content_extract_failed_is_retryable", - "not-a-json-object", - 2, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(2) - .build() - .generateProposeFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI), - NegotiationGenerationException.class, - A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED), - row( - "generation_slot_missing_is_not_retryable", - "{}", - 1, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build() - .generateProposeFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI), - NegotiationGenerationException.class, - A2ATErrorCodes.NEGOTIATION_SLOT_MISSING), - row( - "generation_phase_content_mismatch_is_invalid_input", - "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}", - 1, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build() - .generateAcceptFromText( - "确认提供区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_ENDING_URI), - NegotiationGenerationException.class, - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), - row( - "generation_llm_infrastructure_failure_is_retryable", - null, - 2, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(2) - .build() - .generateProposeFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI), - NegotiationGenerationException.class, - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), - row( - "param_non_negotiation_input", - semanticPayload("information"), - 0, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build() - .validateProposePromptAndDataFilling( - "plain text without any negotiation section", SCHEMA, INFORMATION_PROPOSE_URI), - NegotiationParamExtractionException.class, - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), - row( - "param_rule_violation", - semanticPayload("information"), - 0, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build() - .validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 9\n- maxRounds: 5", - SCHEMA, - INFORMATION_PROPOSE_URI), - NegotiationParamExtractionException.class, - A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION), - row( - "param_semantic_rejection", - "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" - + "\"section.info_static\",\"code\":\"template_type_mismatch\",\"message\":" - + "\"inconsistent\"}],\"params\":{}}", - 1, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build() - .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), - NegotiationParamExtractionException.class, - A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED), - row( - "param_llm_infrastructure_failure_is_retryable", - null, - 2, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(2) - .build() - .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), - NegotiationParamExtractionException.class, - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), - row( - "entry_programming_error_carries_no_code_but_the_offending_argument", - "not-a-json", - 0, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build() - .generateProposeFromData(proposeData(), StandardTemplates.ENERGY_SAVING), - IllegalArgumentException.class, - null), - row( - "internal_render_failure_is_wrapped_as_slot_missing", - "not-a-json", - 0, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .templateLoader(nullContentTemplateLoader()) - .build() - .generateProposeFromData(proposeData(), INFORMATION_PROPOSE_URI), - NegotiationGenerationException.class, - A2ATErrorCodes.NEGOTIATION_SLOT_MISSING), - row( - "internal_semantic_shape_violation_is_retryable_infrastructure", - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{}}", - 2, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(2) - .build() - .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), - NegotiationParamExtractionException.class, - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), - row( - "param_prompt_resource_missing_is_template_not_found", - semanticPayload("information"), - 0, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .semanticValidator(throwingSemanticValidator()) - .build() - .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), - NegotiationParamExtractionException.class, - A2ATErrorCodes.TEMPLATE_NOT_FOUND)); - } - - private static DynamicTest row( - String name, - String llmPayload, - int expectedLlmCalls, - Scenario scenario, - Class expectedType, - String expectedCode) { - return DynamicTest.dynamicTest( - name, () -> verify(name, llmPayload, expectedLlmCalls, scenario, expectedType, expectedCode)); - } - - private static void verify( - String name, - String llmPayload, - int expectedLlmCalls, - Scenario scenario, - Class expectedType, - String expectedCode) { - CountingClient llm = new CountingClient(llmPayload); - RuntimeException failure = runExpectingFailure(scenario.action(llm), name, expectedType); - - assertTrue( - expectedType.isInstance(failure), - "row " + name + " must fail with " + expectedType.getSimpleName() + " but failed with " - + failure.getClass().getSimpleName()); - assertEquals(expectedLlmCalls, llm.calls.get(), "row " + name + " LLM call count"); - assertTrue( - failure.getMessage() != null && !failure.getMessage().isBlank(), - "row " + name + " must carry a non-blank message"); - - if (failure instanceof IllegalArgumentException argumentFailure) { - assertFalse( - A2ATError.class.isInstance(argumentFailure), - "row " + name + " must stay outside A2ATError because it carries no code"); - assertNullCode(expectedCode, name); - assertTrue( - argumentFailure.getMessage().contains("Template URI does not address"), - "row " + name + " must name the offending argument in its message but was: " - + argumentFailure.getMessage()); - return; - } - if (failure instanceof NegotiationGenerationException generationFailure) { - assertEquals(expectedCode, generationFailure.getCode(), "row " + name + " public code"); - return; - } - if (failure instanceof NegotiationParamExtractionException extractionFailure) { - assertEquals(expectedCode, extractionFailure.getCode(), "row " + name + " public code"); - boolean expectsSlotDetails = A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION.equals(expectedCode) - || A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED.equals(expectedCode) - || A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR.equals(expectedCode); - if (expectsSlotDetails) { - assertFalse( - extractionFailure.getErrors().isEmpty(), - "row " + name + " must carry non-empty structured error details"); - assertTrue( - extractionFailure.getErrors().stream() - .allMatch(error -> error.slotName() != null - && !error.slotName().isBlank()), - "row " + name + " error details must carry slot names"); - } - return; - } - fail("row " + name + " failed with an unexpected exception type: " - + failure.getClass().getName()); - } - - private static void assertNullCode(String expectedCode, String name) { - assertNull(expectedCode, "row " + name + " carries no public code by design"); - } - - private static RuntimeException runExpectingFailure( - Runnable action, String name, Class expectedType) { - try { - action.run(); - } catch (RuntimeException failure) { - return failure; - } - fail("row " + name + " was expected to fail with " + expectedType.getSimpleName() + " but completed"); - return null; - } - - private static NegotiationProposeData proposeData() { - return new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)); - } - - private static String semanticPayload(String negotiationType) { - return "{\"semantic_verdict\":true,\"negotiation_type\":\"" + negotiationType + "\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}"; - } - - private static NegotiationTemplateLoader missingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - - @Override - public List loadAll() { - return List.of(); - } - }; - } - - private static NegotiationTemplateLoader nullContentTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - return new PromptTemplate(reference.templateUri(), "broken template", null); - } - - @Override - public List loadAll() { - return List.of(); - } - }; - } - - private static NegotiationSemanticValidator throwingSemanticValidator() { - return (prompt, callerSchema, reference) -> { - throw new ResourceNotFoundException( - "Semantic validation prompt does not exist.", "prompt_resources/prompts"); - }; - } - - @FunctionalInterface - private interface Scenario { - - Runnable action(CountingClient llm); - } - - private static final class CountingClient implements LLMClient { - - private final AtomicInteger calls = new AtomicInteger(); - - private final String payload; - - private CountingClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls.incrementAndGet(); - if (payload == null) { - throw new LLMError("LLM endpoint unavailable."); - } - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.negotiation.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +/** + * Drives every row of the negotiation error-code usage matrix through the real pipelines. + * + *

      Each row triggers one failure condition of the documented matrix and asserts the exception type, the public error + * code, the number of LLM calls (encoding retryability: a retryable failure consumes all attempts, a non-retryable + * failure exactly one or zero), and that a structured detail identifying the problem field or slot is carried. + */ +class NegotiationErrorCodeUsageMatrixTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final TemplateUri INFORMATION_ENDING_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + + private static final String VALID_CONTEXT_PROMPT = "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5"; + + private static final Map SCHEMA = Map.of("type", "object"); + + @TestFactory + Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { + return Stream.of( + row( + "generation_template_missing", + "not-a-json", + 0, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .templateLoader(missingTemplateLoader()) + .build() + .generateProposeFromData(proposeData(), INFORMATION_PROPOSE_URI), + NegotiationGenerationException.class, + A2ATErrorCodes.TEMPLATE_NOT_FOUND), + row( + "generation_content_extract_failed_is_retryable", + "not-a-json-object", + 2, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build() + .generateProposeFromText( + "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI), + NegotiationGenerationException.class, + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED), + row( + "generation_slot_missing_is_not_retryable", + "{}", + 1, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build() + .generateProposeFromText( + "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI), + NegotiationGenerationException.class, + A2ATErrorCodes.NEGOTIATION_SLOT_MISSING), + row( + "generation_phase_content_mismatch_is_invalid_input", + "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}", + 1, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build() + .generateAcceptFromText( + "确认提供区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_ENDING_URI), + NegotiationGenerationException.class, + A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), + row( + "generation_llm_infrastructure_failure_is_retryable", + null, + 2, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build() + .generateProposeFromText( + "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI), + NegotiationGenerationException.class, + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), + row( + "param_non_negotiation_input", + semanticPayload("information"), + 0, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build() + .validateProposePromptAndDataFilling( + "plain text without any negotiation section", SCHEMA, INFORMATION_PROPOSE_URI), + NegotiationParamExtractionException.class, + A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), + row( + "param_rule_violation", + semanticPayload("information"), + 0, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build() + .validateProposePromptAndDataFilling( + "## 协商上下文\n- id: " + UUID + "\n- round: 9\n- maxRounds: 5", + SCHEMA, + INFORMATION_PROPOSE_URI), + NegotiationParamExtractionException.class, + A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION), + row( + "param_semantic_rejection", + "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" + + "\"section.info_static\",\"code\":\"template_type_mismatch\",\"message\":" + + "\"inconsistent\"}],\"params\":{}}", + 1, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build() + .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), + NegotiationParamExtractionException.class, + A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED), + row( + "param_llm_infrastructure_failure_is_retryable", + null, + 2, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build() + .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), + NegotiationParamExtractionException.class, + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), + row( + "entry_programming_error_carries_no_code_but_the_offending_argument", + "not-a-json", + 0, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build() + .generateProposeFromData(proposeData(), StandardTemplates.ENERGY_SAVING), + IllegalArgumentException.class, + null), + row( + "internal_render_failure_is_wrapped_as_slot_missing", + "not-a-json", + 0, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .templateLoader(nullContentTemplateLoader()) + .build() + .generateProposeFromData(proposeData(), INFORMATION_PROPOSE_URI), + NegotiationGenerationException.class, + A2ATErrorCodes.NEGOTIATION_SLOT_MISSING), + row( + "internal_semantic_shape_violation_is_retryable_infrastructure", + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{}}", + 2, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build() + .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), + NegotiationParamExtractionException.class, + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), + row( + "param_prompt_resource_missing_is_template_not_found", + semanticPayload("information"), + 0, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .semanticValidator(throwingSemanticValidator()) + .build() + .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), + NegotiationParamExtractionException.class, + A2ATErrorCodes.TEMPLATE_NOT_FOUND)); + } + + private static DynamicTest row( + String name, + String llmPayload, + int expectedLlmCalls, + Scenario scenario, + Class expectedType, + String expectedCode) { + return DynamicTest.dynamicTest( + name, () -> verify(name, llmPayload, expectedLlmCalls, scenario, expectedType, expectedCode)); + } + + private static void verify( + String name, + String llmPayload, + int expectedLlmCalls, + Scenario scenario, + Class expectedType, + String expectedCode) { + CountingClient llm = new CountingClient(llmPayload); + RuntimeException failure = runExpectingFailure(scenario.action(llm), name, expectedType); + + assertTrue( + expectedType.isInstance(failure), + "row " + name + " must fail with " + expectedType.getSimpleName() + " but failed with " + + failure.getClass().getSimpleName()); + assertEquals(expectedLlmCalls, llm.calls.get(), "row " + name + " LLM call count"); + assertTrue( + failure.getMessage() != null && !failure.getMessage().isBlank(), + "row " + name + " must carry a non-blank message"); + + if (failure instanceof IllegalArgumentException argumentFailure) { + assertFalse( + A2ATError.class.isInstance(argumentFailure), + "row " + name + " must stay outside A2ATError because it carries no code"); + assertNullCode(expectedCode, name); + assertTrue( + argumentFailure.getMessage().contains("Template URI does not address"), + "row " + name + " must name the offending argument in its message but was: " + + argumentFailure.getMessage()); + return; + } + if (failure instanceof NegotiationGenerationException generationFailure) { + assertEquals(expectedCode, generationFailure.getCode(), "row " + name + " public code"); + return; + } + if (failure instanceof NegotiationParamExtractionException extractionFailure) { + assertEquals(expectedCode, extractionFailure.getCode(), "row " + name + " public code"); + boolean expectsSlotDetails = A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION.equals(expectedCode) + || A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED.equals(expectedCode) + || A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR.equals(expectedCode); + if (expectsSlotDetails) { + assertFalse( + extractionFailure.getErrors().isEmpty(), + "row " + name + " must carry non-empty structured error details"); + assertTrue( + extractionFailure.getErrors().stream() + .allMatch(error -> error.slotName() != null + && !error.slotName().isBlank()), + "row " + name + " error details must carry slot names"); + } + return; + } + fail("row " + name + " failed with an unexpected exception type: " + + failure.getClass().getName()); + } + + private static void assertNullCode(String expectedCode, String name) { + assertNull(expectedCode, "row " + name + " carries no public code by design"); + } + + private static RuntimeException runExpectingFailure( + Runnable action, String name, Class expectedType) { + try { + action.run(); + } catch (RuntimeException failure) { + return failure; + } + fail("row " + name + " was expected to fail with " + expectedType.getSimpleName() + " but completed"); + return null; + } + + private static NegotiationProposeData proposeData() { + return new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)); + } + + private static String semanticPayload(String negotiationType) { + return "{\"semantic_verdict\":true,\"negotiation_type\":\"" + negotiationType + "\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"; + } + + private static NegotiationTemplateLoader missingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + + @Override + public List loadAll() { + return List.of(); + } + }; + } + + private static NegotiationTemplateLoader nullContentTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + return new PromptTemplate(reference.templateUri(), "broken template", null); + } + + @Override + public List loadAll() { + return List.of(); + } + }; + } + + private static NegotiationSemanticValidator throwingSemanticValidator() { + return (prompt, callerSchema, reference) -> { + throw new ResourceNotFoundException( + "Semantic validation prompt does not exist.", "prompt_resources/prompts"); + }; + } + + @FunctionalInterface + private interface Scenario { + + Runnable action(CountingClient llm); + } + + private static final class CountingClient implements LLMClient { + + private final AtomicInteger calls = new AtomicInteger(); + + private final String payload; + + private CountingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls.incrementAndGet(); + if (payload == null) { + throw new LLMError("LLM endpoint unavailable."); + } + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java index c1600167..2bd8639a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java @@ -1,238 +1,238 @@ -package net.openan.a2at.sdk.negotiation.observability; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMError; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Test; - -/** - * Verifies the single-root failure contract of the negotiation content layer. - * - *

      Every runtime failure of the generation and parameter-extraction pipelines is catchable through the common - * {@link A2ATError} root, while programming errors of the content layer (standard {@link IllegalArgumentException} - * and {@link NullPointerException} of argument validation) stay outside that tree so that callers cannot accidentally - * swallow them with a generic processing-failure handler. - */ -class NegotiationExceptionTreeRootCatchTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - @Test - void exhaustedGenerationLlmFailureIsCatchableThroughTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new FailingClient()) - .maxAttempts(2) - .build(); - - RuntimeException failure = catchThroughRoot(() -> orchestrator.generateProposeFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI)); - - assertTrue(failure instanceof NegotiationGenerationException); - assertEquals( - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, - ((NegotiationGenerationException) failure).getCode()); - } - - @Test - void ruleViolationFailureIsCatchableThroughTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 9\n- maxRounds: 5", - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - - assertTrue(failure instanceof NegotiationParamExtractionException); - assertEquals( - A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, ((NegotiationParamExtractionException) failure).getCode()); - } - - @Test - void semanticRejectionFailureIsCatchableThroughTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient( - "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" - + "\"section.info_static\",\"code\":\"template_type_mismatch\",\"message\":" - + "\"inconsistent\"}],\"params\":{}}")) - .build(); - - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - - assertTrue(failure instanceof NegotiationParamExtractionException); - assertEquals( - A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, - ((NegotiationParamExtractionException) failure).getCode()); - } - - @Test - void paramExtractionLlmFailureIsCatchableThroughTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new FailingClient()) - .maxAttempts(2) - .build(); - - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - - assertTrue(failure instanceof NegotiationParamExtractionException); - assertEquals( - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, - ((NegotiationParamExtractionException) failure).getCode()); - } - - @Test - void nonNegotiationInputFailureIsCatchableThroughTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - "plain text without any negotiation section", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); - - assertTrue(failure instanceof NegotiationParamExtractionException); - assertEquals( - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, ((NegotiationParamExtractionException) failure).getCode()); - } - - @Test - void contentProgrammingErrorsAreNotCatchableThroughTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - - try { - orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - StandardTemplates.ENERGY_SAVING); - } catch (A2ATError caughtByRoot) { - fail("content programming errors must stay outside the A2ATError tree but were caught: " - + caughtByRoot.getMessage()); - } catch (IllegalArgumentException expected) { - assertFalse( - A2ATError.class.isInstance(expected), - "content programming errors must stay outside the A2ATError tree"); - assertTrue( - expected.getMessage().contains("Template URI does not address"), - "the failure must point at the template URI but was: " + expected.getMessage()); - } - } - - @Test - void contentProgrammingErrorsOfTheContextStayOutsideTheCommonRoot() { - IllegalArgumentException failure = - assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(" ", 1, 5)); - - assertFalse( - A2ATError.class.isInstance(failure), "content programming errors must stay outside the A2ATError tree"); - } - - @Test - void nullPromptOnValidateAndFillingStaysOutsideTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - - NullPointerException failure = assertThrows( - NullPointerException.class, - () -> orchestrator.validateProposePromptAndDataFilling(null, Map.of("type", "object"), INFORMATION_PROPOSE_URI)); - - assertFalse(A2ATError.class.isInstance(failure), "a null prompt is a programming error and must stay outside the A2ATError tree"); - } - - @Test - void blankPromptOnValidateAndFillingStaysOutsideTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - " ", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); - - assertFalse( - A2ATError.class.isInstance(failure), - "a blank prompt is a programming error and must stay outside the A2ATError tree"); - assertTrue( - failure.getMessage().contains("blank"), - "the failure must point at the blank prompt but was: " + failure.getMessage()); - } - - /** - * Runs one pipeline action inside a {@code catch (A2ATError)} block and returns the caught failure, failing when - * nothing was caught. - */ - private static RuntimeException catchThroughRoot(Runnable action) { - try { - action.run(); - } catch (A2ATError failure) { - return failure; - } - fail("the pipeline action was expected to fail with an A2ATError but completed"); - return null; - } - - private static final class ScriptedClient implements LLMClient { - - private final String payload; - - private ScriptedClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class FailingClient implements LLMClient { - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - throw new LLMError("LLM endpoint unavailable."); - } - } -} +package net.openan.a2at.sdk.negotiation.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import org.junit.jupiter.api.Test; + +/** + * Verifies the single-root failure contract of the negotiation content layer. + * + *

      Every runtime failure of the generation and parameter-extraction pipelines is catchable through the common + * {@link A2ATError} root, while programming errors of the content layer (standard {@link IllegalArgumentException} + * and {@link NullPointerException} of argument validation) stay outside that tree so that callers cannot accidentally + * swallow them with a generic processing-failure handler. + */ +class NegotiationExceptionTreeRootCatchTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + @Test + void exhaustedGenerationLlmFailureIsCatchableThroughTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new FailingClient()) + .maxAttempts(2) + .build(); + + RuntimeException failure = catchThroughRoot(() -> orchestrator.generateProposeFromText( + "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI)); + + assertTrue(failure instanceof NegotiationGenerationException); + assertEquals( + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, + ((NegotiationGenerationException) failure).getCode()); + } + + @Test + void ruleViolationFailureIsCatchableThroughTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( + "## 协商上下文\n- id: " + UUID + "\n- round: 9\n- maxRounds: 5", + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + + assertTrue(failure instanceof NegotiationParamExtractionException); + assertEquals( + A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, ((NegotiationParamExtractionException) failure).getCode()); + } + + @Test + void semanticRejectionFailureIsCatchableThroughTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient( + "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" + + "\"section.info_static\",\"code\":\"template_type_mismatch\",\"message\":" + + "\"inconsistent\"}],\"params\":{}}")) + .build(); + + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( + "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + + assertTrue(failure instanceof NegotiationParamExtractionException); + assertEquals( + A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, + ((NegotiationParamExtractionException) failure).getCode()); + } + + @Test + void paramExtractionLlmFailureIsCatchableThroughTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new FailingClient()) + .maxAttempts(2) + .build(); + + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( + "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + + assertTrue(failure instanceof NegotiationParamExtractionException); + assertEquals( + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, + ((NegotiationParamExtractionException) failure).getCode()); + } + + @Test + void nonNegotiationInputFailureIsCatchableThroughTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( + "plain text without any negotiation section", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); + + assertTrue(failure instanceof NegotiationParamExtractionException); + assertEquals( + A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, ((NegotiationParamExtractionException) failure).getCode()); + } + + @Test + void contentProgrammingErrorsAreNotCatchableThroughTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + try { + orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + StandardTemplates.ENERGY_SAVING); + } catch (A2ATError caughtByRoot) { + fail("content programming errors must stay outside the A2ATError tree but were caught: " + + caughtByRoot.getMessage()); + } catch (IllegalArgumentException expected) { + assertFalse( + A2ATError.class.isInstance(expected), + "content programming errors must stay outside the A2ATError tree"); + assertTrue( + expected.getMessage().contains("Template URI does not address"), + "the failure must point at the template URI but was: " + expected.getMessage()); + } + } + + @Test + void contentProgrammingErrorsOfTheContextStayOutsideTheCommonRoot() { + IllegalArgumentException failure = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(" ", 1, 5)); + + assertFalse( + A2ATError.class.isInstance(failure), "content programming errors must stay outside the A2ATError tree"); + } + + @Test + void nullPromptOnValidateAndFillingStaysOutsideTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + NullPointerException failure = assertThrows( + NullPointerException.class, + () -> orchestrator.validateProposePromptAndDataFilling(null, Map.of("type", "object"), INFORMATION_PROPOSE_URI)); + + assertFalse(A2ATError.class.isInstance(failure), "a null prompt is a programming error and must stay outside the A2ATError tree"); + } + + @Test + void blankPromptOnValidateAndFillingStaysOutsideTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + " ", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); + + assertFalse( + A2ATError.class.isInstance(failure), + "a blank prompt is a programming error and must stay outside the A2ATError tree"); + assertTrue( + failure.getMessage().contains("blank"), + "the failure must point at the blank prompt but was: " + failure.getMessage()); + } + + /** + * Runs one pipeline action inside a {@code catch (A2ATError)} block and returns the caught failure, failing when + * nothing was caught. + */ + private static RuntimeException catchThroughRoot(Runnable action) { + try { + action.run(); + } catch (A2ATError failure) { + return failure; + } + fail("the pipeline action was expected to fail with an A2ATError but completed"); + return null; + } + + private static final class ScriptedClient implements LLMClient { + + private final String payload; + + private ScriptedClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class FailingClient implements LLMClient { + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new LLMError("LLM endpoint unavailable."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index c6154a0b..4538a686 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -1,440 +1,440 @@ -package net.openan.a2at.sdk.negotiation.observability; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; - -/** - * Locks the log instrumentation contract of the negotiation content layer. - * - *

      Every pipeline path is driven through the real default collaborators with a scripted LLM client and the emitted - * events are captured with a logback {@link ListAppender}. The test asserts that exactly the twelve contract event - * names appear, each at its configured level, that every message is an English snake_case event followed by - * {@code key=value} fields, that internal step diagnostics live in the logs only, and that neither the message text, - * the free-text input nor the raw LLM response ever leaks into an INFO-or-higher event. - */ -class NegotiationLogEventContractTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final Map> EXPECTED_EVENT_LEVELS = Map.ofEntries( - Map.entry("negotiation_template_loaded", Set.of(Level.DEBUG)), - Map.entry("negotiation_generator_dispatched", Set.of(Level.DEBUG)), - Map.entry("negotiation_content_extraction_completed", Set.of(Level.INFO)), - Map.entry("negotiation_semantic_validation_completed", Set.of(Level.INFO)), - Map.entry("negotiation_generation_completed", Set.of(Level.INFO)), - Map.entry("negotiation_llm_retry", Set.of(Level.WARN)), - Map.entry("negotiation_llm_retry_exhausted", Set.of(Level.WARN)), - Map.entry("negotiation_generation_failed", Set.of(Level.WARN)), - Map.entry("negotiation_param_extraction_failed", Set.of(Level.WARN)), - Map.entry("negotiation_template_not_found", Set.of(Level.WARN)), - Map.entry("negotiation_rule_checks_completed", Set.of(Level.DEBUG, Level.WARN))); - - private Logger rootLogger; - - private ListAppender appender; - - @BeforeEach - void attachAppender() { - rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); - appender = new ListAppender<>(); - appender.start(); - rootLogger.addAppender(appender); - } - - @AfterEach - void detachAppender() { - rootLogger.detachAppender(appender); - appender.stop(); - } - - @Test - void allTwelveEventNamesFireAtTheirConfiguredLevelsAcrossThePipelines() { - driveSuccessfulGenerationFromData(); - driveSuccessfulGenerationFromText(); - driveRetryThenSuccessOnContentExtraction(); - driveRetryExhaustionOnContentExtraction(); - driveSuccessfulParamExtraction(); - driveSemanticRejection(); - driveRuleViolation(); - driveQueryBoundaries(); - - Map> observedLevels = negotiationEventLevels(); - assertEquals(EXPECTED_EVENT_LEVELS.keySet(), observedLevels.keySet()); - EXPECTED_EVENT_LEVELS.forEach((eventName, expectedLevels) -> - assertEquals(expectedLevels, observedLevels.get(eventName), "levels of event " + eventName)); - } - - @Test - void everyEventMessageIsAnEnglishSnakeCaseEventWithKeyValueFields() { - driveSuccessfulGenerationFromData(); - driveSuccessfulGenerationFromText(); - driveSuccessfulParamExtraction(); - driveQueryBoundaries(); - - List events = negotiationEvents(); - assertFalse(events.isEmpty()); - for (ILoggingEvent event : events) { - String message = event.getFormattedMessage(); - String eventName = message.split(" ")[0]; - assertTrue( - eventName.matches("negotiation_[a-z0-9]+(_[a-z0-9]+)*"), - "event name must be snake_case but was: " + eventName); - assertTrue( - message.chars().allMatch(character -> character >= 32 && character < 127), - "event message must be printable ASCII but was: " + message); - List keyTokens = keyTokens(message); - assertFalse(keyTokens.isEmpty(), "event message must carry key=value fields: " + message); - for (String key : keyTokens) { - assertTrue( - key.matches("[a-z][a-z0-9_]*"), - "field key must be snake_case but was: " + key + " in " + message); - } - } - } - - @Test - void retryEventsCarryTheInternalStepNameThatNeverAppearsOnExceptions() { - driveRetryExhaustionOnContentExtraction(); - - List retryMessages = messagesOf("negotiation_llm_retry"); - List exhaustedMessages = messagesOf("negotiation_llm_retry_exhausted"); - assertEquals(1, retryMessages.size()); - assertEquals(1, exhaustedMessages.size()); - assertTrue(retryMessages.get(0).contains("step=")); - assertTrue(retryMessages.get(0).contains("attempt=1")); - assertTrue(retryMessages.get(0).contains("max_attempts=")); - assertTrue(retryMessages.get(0).contains("code=" + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR)); - assertTrue(exhaustedMessages.get(0).contains("step=")); - - NegotiationGenerationException failure = generationFailureOfExhaustedExtraction(); - assertNotNull(failure); - assertFalse(failure.getMessage().toLowerCase().contains("stage")); - assertFalse(failure.getMessage().contains("step=")); - } - - @Test - void infoAndHigherEventsNeverCarryMessageTextInputOrResponseContent() { - String inputMarker = "SECRET-INPUT-MARKER-7f3a"; - String promptMarker = "SECRET-PROMPT-MARKER-9b1c"; - String responseMarker = "SECRET-RESPONSE-MARKER-5d2e"; - String paramValueMarker = "SECRET-PARAM-VALUE-MARKER-3c8d"; - - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient( - "{\"items\":[{\"name\":\"item\",\"value\":\"" + responseMarker + "\"}],\"relationship\":null}", - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"" + paramValueMarker + "\"}}")) - .build(); - - MetadataContent generated = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem(promptMarker, "value")), null)), - INFORMATION_PROPOSE_URI); - assertTrue(generated.promptText().contains(promptMarker)); - orchestrator.generateProposeFromText( - "free text containing " + inputMarker, new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - generated.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); - assertTrue(filled.data().containsValue(paramValueMarker)); - - List infoAndHigher = appender.list.stream() - .filter(event -> event.getLevel().isGreaterOrEqual(Level.INFO)) - .toList(); - assertFalse(infoAndHigher.isEmpty()); - for (ILoggingEvent event : infoAndHigher) { - String message = event.getFormattedMessage(); - assertFalse(message.contains(inputMarker), "input text leaked at " + event.getLevel() + ": " + message); - assertFalse(message.contains(promptMarker), "prompt text leaked at " + event.getLevel() + ": " + message); - assertFalse( - message.contains(responseMarker), - "response content leaked at " + event.getLevel() + ": " + message); - assertFalse( - message.contains(paramValueMarker), - "extracted parameter value leaked at " + event.getLevel() + ": " + message); - assertFalse( - message.contains("prompt_text=") || message.contains("text=") || message.contains("response="), - "raw content field must not be logged at " + event.getLevel() + ": " + message); - } - } - - @Test - void queryBoundariesEmitTemplateNotFoundWarningsWithActionableHints() { - NegotiationGenerationOrchestrator defaultLoaderOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .templateLoader(missingTemplateLoader()) - .build(); - - defaultLoaderOrchestrator.getNegotiationPrompts(); - defaultLoaderOrchestrator.getNegotiationPrompt(INFORMATION_PROPOSE_URI); - - NegotiationGenerationOrchestrator builtinOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - builtinOrchestrator.getNegotiationPrompt(StandardTemplates.ENERGY_SAVING); - - List warnings = messagesOf("negotiation_template_not_found"); - assertEquals(3, warnings.size()); - assertTrue(warnings.get(0).contains("uri=all")); - assertTrue(warnings.get(0).contains("language=zh-CN")); - assertTrue(warnings.get(1).contains("uri=" + INFORMATION_PROPOSE_URI.uri())); - assertTrue(warnings.get(1).contains("language=zh-CN")); - assertTrue(warnings.get(2).contains("reason=invalid_template_uri")); - for (String warning : warnings) { - assertTrue(warning.contains("hint="), "boundary warning must carry a hint: " + warning); - assertTrue( - warning.contains("A2AT_LANGUAGE"), "boundary warning hint must mention A2AT_LANGUAGE: " + warning); - } - } - - @Test - void removedTypeRecognitionEventNeverAppears() { - driveSuccessfulGenerationFromText(); - driveSuccessfulParamExtraction(); - - assertTrue(appender.list.stream() - .noneMatch(event -> event.getFormattedMessage().startsWith("negotiation_type_recognition"))); - } - - private void driveSuccessfulGenerationFromData() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient(validExtractionPayload(), validSemanticPayload())) - .build(); - MetadataContent result = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - assertTrue(result.promptText().contains("协商上下文")); - } - - private void driveSuccessfulGenerationFromText() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient(validExtractionPayload(), validSemanticPayload())) - .build(); - MetadataContent result = orchestrator.generateProposeFromText( - "请提供节能区域。", new NegotiationContext(UUID, 2, 5), INFORMATION_PROPOSE_URI); - assertTrue(result.promptText().contains("所需信息项")); - } - - private void driveRetryThenSuccessOnContentExtraction() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient("", validExtractionPayload())) - .maxAttempts(2) - .build(); - MetadataContent result = orchestrator.generateProposeFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); - assertTrue(result.promptText().contains("所需信息项")); - } - - private void driveRetryExhaustionOnContentExtraction() { - NegotiationGenerationException failure = generationFailureOfExhaustedExtraction(); - assertNotNull(failure); - } - - private NegotiationGenerationException generationFailureOfExhaustedExtraction() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new FailingClient()) - .maxAttempts(2) - .build(); - try { - orchestrator.generateProposeFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); - } catch (NegotiationGenerationException failure) { - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); - return failure; - } - return null; - } - - private void driveSuccessfulParamExtraction() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient(validSemanticPayload())) - .build(); - MetadataContent message = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); - assertEquals(UUID, filled.data().get("id")); - } - - private void driveSemanticRejection() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient( - "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" - + "\"section.info_static\",\"code\":\"template_type_mismatch\",\"message\":" - + "\"inconsistent\"}],\"params\":{}}")) - .build(); - try { - orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", - Map.of("type", "object"), - INFORMATION_PROPOSE_URI); - } catch (NegotiationParamExtractionException expected) { - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, expected.getCode()); - } - } - - private void driveRuleViolation() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient(validExtractionPayload(), validSemanticPayload())) - .build(); - try { - orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 9\n- maxRounds: 5", - Map.of("type", "object"), - INFORMATION_PROPOSE_URI); - } catch (NegotiationParamExtractionException expected) { - assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, expected.getCode()); - } - } - - private void driveQueryBoundaries() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .templateLoader(missingTemplateLoader()) - .build(); - orchestrator.getNegotiationPrompts(); - orchestrator.getNegotiationPrompt(INFORMATION_PROPOSE_URI); - assertTrue(orchestrator.getNegotiationPrompt(StandardTemplates.ENERGY_SAVING).isEmpty()); - } - - private static String validExtractionPayload() { - return "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; - } - - private static String validSemanticPayload() { - return "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}"; - } - - private static NegotiationTemplateLoader missingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new net.openan.a2at.sdk.core.exception.ResourceNotFoundException( - "Negotiation template does not exist.", reference.uri()); - } - - @Override - public List loadAll() { - throw new net.openan.a2at.sdk.core.exception.ResourceNotFoundException( - "No negotiation template exists for the configured language.", "templates/Negotiation-T"); - } - }; - } - - private Map> negotiationEventLevels() { - Map> levels = new LinkedHashMap<>(); - for (ILoggingEvent event : negotiationEvents()) { - levels.computeIfAbsent(eventName(event), ignored -> new java.util.HashSet<>()) - .add(event.getLevel()); - } - return levels; - } - - private List negotiationEvents() { - return appender.list.stream() - .filter(event -> event.getFormattedMessage().startsWith("negotiation_")) - .toList(); - } - - private List messagesOf(String eventName) { - return appender.list.stream() - .map(ILoggingEvent::getFormattedMessage) - .filter(message -> message.startsWith(eventName + " ") || message.equals(eventName)) - .collect(Collectors.toList()); - } - - private static String eventName(ILoggingEvent event) { - return event.getFormattedMessage().split(" ")[0]; - } - - private static List keyTokens(String message) { - return java.util.Arrays.stream(message.split(" ")) - .filter(token -> token.contains("=")) - .map(token -> token.substring(0, token.indexOf('='))) - .collect(Collectors.toList()); - } - - private static final class ScriptedClient implements LLMClient { - - private final List payloads; - - private int calls; - - private ScriptedClient(String... payloads) { - this.payloads = List.of(payloads); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - String payload = payloads.get(Math.min(calls, payloads.size() - 1)); - calls++; - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class FailingClient implements LLMClient { - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - throw new IllegalStateException("LLM endpoint unavailable."); - } - } -} +package net.openan.a2at.sdk.negotiation.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * Locks the log instrumentation contract of the negotiation content layer. + * + *

      Every pipeline path is driven through the real default collaborators with a scripted LLM client and the emitted + * events are captured with a logback {@link ListAppender}. The test asserts that exactly the twelve contract event + * names appear, each at its configured level, that every message is an English snake_case event followed by + * {@code key=value} fields, that internal step diagnostics live in the logs only, and that neither the message text, + * the free-text input nor the raw LLM response ever leaks into an INFO-or-higher event. + */ +class NegotiationLogEventContractTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final Map> EXPECTED_EVENT_LEVELS = Map.ofEntries( + Map.entry("negotiation_template_loaded", Set.of(Level.DEBUG)), + Map.entry("negotiation_generator_dispatched", Set.of(Level.DEBUG)), + Map.entry("negotiation_content_extraction_completed", Set.of(Level.INFO)), + Map.entry("negotiation_semantic_validation_completed", Set.of(Level.INFO)), + Map.entry("negotiation_generation_completed", Set.of(Level.INFO)), + Map.entry("negotiation_llm_retry", Set.of(Level.WARN)), + Map.entry("negotiation_llm_retry_exhausted", Set.of(Level.WARN)), + Map.entry("negotiation_generation_failed", Set.of(Level.WARN)), + Map.entry("negotiation_param_extraction_failed", Set.of(Level.WARN)), + Map.entry("negotiation_template_not_found", Set.of(Level.WARN)), + Map.entry("negotiation_rule_checks_completed", Set.of(Level.DEBUG, Level.WARN))); + + private Logger rootLogger; + + private ListAppender appender; + + @BeforeEach + void attachAppender() { + rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + appender = new ListAppender<>(); + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + rootLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void allTwelveEventNamesFireAtTheirConfiguredLevelsAcrossThePipelines() { + driveSuccessfulGenerationFromData(); + driveSuccessfulGenerationFromText(); + driveRetryThenSuccessOnContentExtraction(); + driveRetryExhaustionOnContentExtraction(); + driveSuccessfulParamExtraction(); + driveSemanticRejection(); + driveRuleViolation(); + driveQueryBoundaries(); + + Map> observedLevels = negotiationEventLevels(); + assertEquals(EXPECTED_EVENT_LEVELS.keySet(), observedLevels.keySet()); + EXPECTED_EVENT_LEVELS.forEach((eventName, expectedLevels) -> + assertEquals(expectedLevels, observedLevels.get(eventName), "levels of event " + eventName)); + } + + @Test + void everyEventMessageIsAnEnglishSnakeCaseEventWithKeyValueFields() { + driveSuccessfulGenerationFromData(); + driveSuccessfulGenerationFromText(); + driveSuccessfulParamExtraction(); + driveQueryBoundaries(); + + List events = negotiationEvents(); + assertFalse(events.isEmpty()); + for (ILoggingEvent event : events) { + String message = event.getFormattedMessage(); + String eventName = message.split(" ")[0]; + assertTrue( + eventName.matches("negotiation_[a-z0-9]+(_[a-z0-9]+)*"), + "event name must be snake_case but was: " + eventName); + assertTrue( + message.chars().allMatch(character -> character >= 32 && character < 127), + "event message must be printable ASCII but was: " + message); + List keyTokens = keyTokens(message); + assertFalse(keyTokens.isEmpty(), "event message must carry key=value fields: " + message); + for (String key : keyTokens) { + assertTrue( + key.matches("[a-z][a-z0-9_]*"), + "field key must be snake_case but was: " + key + " in " + message); + } + } + } + + @Test + void retryEventsCarryTheInternalStepNameThatNeverAppearsOnExceptions() { + driveRetryExhaustionOnContentExtraction(); + + List retryMessages = messagesOf("negotiation_llm_retry"); + List exhaustedMessages = messagesOf("negotiation_llm_retry_exhausted"); + assertEquals(1, retryMessages.size()); + assertEquals(1, exhaustedMessages.size()); + assertTrue(retryMessages.get(0).contains("step=")); + assertTrue(retryMessages.get(0).contains("attempt=1")); + assertTrue(retryMessages.get(0).contains("max_attempts=")); + assertTrue(retryMessages.get(0).contains("code=" + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR)); + assertTrue(exhaustedMessages.get(0).contains("step=")); + + NegotiationGenerationException failure = generationFailureOfExhaustedExtraction(); + assertNotNull(failure); + assertFalse(failure.getMessage().toLowerCase().contains("stage")); + assertFalse(failure.getMessage().contains("step=")); + } + + @Test + void infoAndHigherEventsNeverCarryMessageTextInputOrResponseContent() { + String inputMarker = "SECRET-INPUT-MARKER-7f3a"; + String promptMarker = "SECRET-PROMPT-MARKER-9b1c"; + String responseMarker = "SECRET-RESPONSE-MARKER-5d2e"; + String paramValueMarker = "SECRET-PARAM-VALUE-MARKER-3c8d"; + + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient( + "{\"items\":[{\"name\":\"item\",\"value\":\"" + responseMarker + "\"}],\"relationship\":null}", + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"" + paramValueMarker + "\"}}")) + .build(); + + MetadataContent generated = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem(promptMarker, "value")), null)), + INFORMATION_PROPOSE_URI); + assertTrue(generated.promptText().contains(promptMarker)); + orchestrator.generateProposeFromText( + "free text containing " + inputMarker, new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + generated.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); + assertTrue(filled.data().containsValue(paramValueMarker)); + + List infoAndHigher = appender.list.stream() + .filter(event -> event.getLevel().isGreaterOrEqual(Level.INFO)) + .toList(); + assertFalse(infoAndHigher.isEmpty()); + for (ILoggingEvent event : infoAndHigher) { + String message = event.getFormattedMessage(); + assertFalse(message.contains(inputMarker), "input text leaked at " + event.getLevel() + ": " + message); + assertFalse(message.contains(promptMarker), "prompt text leaked at " + event.getLevel() + ": " + message); + assertFalse( + message.contains(responseMarker), + "response content leaked at " + event.getLevel() + ": " + message); + assertFalse( + message.contains(paramValueMarker), + "extracted parameter value leaked at " + event.getLevel() + ": " + message); + assertFalse( + message.contains("prompt_text=") || message.contains("text=") || message.contains("response="), + "raw content field must not be logged at " + event.getLevel() + ": " + message); + } + } + + @Test + void queryBoundariesEmitTemplateNotFoundWarningsWithActionableHints() { + NegotiationGenerationOrchestrator defaultLoaderOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(missingTemplateLoader()) + .build(); + + defaultLoaderOrchestrator.getNegotiationPrompts(); + defaultLoaderOrchestrator.getNegotiationPrompt(INFORMATION_PROPOSE_URI); + + NegotiationGenerationOrchestrator builtinOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + builtinOrchestrator.getNegotiationPrompt(StandardTemplates.ENERGY_SAVING); + + List warnings = messagesOf("negotiation_template_not_found"); + assertEquals(3, warnings.size()); + assertTrue(warnings.get(0).contains("uri=all")); + assertTrue(warnings.get(0).contains("language=zh-CN")); + assertTrue(warnings.get(1).contains("uri=" + INFORMATION_PROPOSE_URI.uri())); + assertTrue(warnings.get(1).contains("language=zh-CN")); + assertTrue(warnings.get(2).contains("reason=invalid_template_uri")); + for (String warning : warnings) { + assertTrue(warning.contains("hint="), "boundary warning must carry a hint: " + warning); + assertTrue( + warning.contains("A2AT_LANGUAGE"), "boundary warning hint must mention A2AT_LANGUAGE: " + warning); + } + } + + @Test + void removedTypeRecognitionEventNeverAppears() { + driveSuccessfulGenerationFromText(); + driveSuccessfulParamExtraction(); + + assertTrue(appender.list.stream() + .noneMatch(event -> event.getFormattedMessage().startsWith("negotiation_type_recognition"))); + } + + private void driveSuccessfulGenerationFromData() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient(validExtractionPayload(), validSemanticPayload())) + .build(); + MetadataContent result = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + assertTrue(result.promptText().contains("协商上下文")); + } + + private void driveSuccessfulGenerationFromText() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient(validExtractionPayload(), validSemanticPayload())) + .build(); + MetadataContent result = orchestrator.generateProposeFromText( + "请提供节能区域。", new NegotiationContext(UUID, 2, 5), INFORMATION_PROPOSE_URI); + assertTrue(result.promptText().contains("所需信息项")); + } + + private void driveRetryThenSuccessOnContentExtraction() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient("", validExtractionPayload())) + .maxAttempts(2) + .build(); + MetadataContent result = orchestrator.generateProposeFromText( + "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); + assertTrue(result.promptText().contains("所需信息项")); + } + + private void driveRetryExhaustionOnContentExtraction() { + NegotiationGenerationException failure = generationFailureOfExhaustedExtraction(); + assertNotNull(failure); + } + + private NegotiationGenerationException generationFailureOfExhaustedExtraction() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new FailingClient()) + .maxAttempts(2) + .build(); + try { + orchestrator.generateProposeFromText( + "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); + } catch (NegotiationGenerationException failure) { + assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); + return failure; + } + return null; + } + + private void driveSuccessfulParamExtraction() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient(validSemanticPayload())) + .build(); + MetadataContent message = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); + assertEquals(UUID, filled.data().get("id")); + } + + private void driveSemanticRejection() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient( + "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" + + "\"section.info_static\",\"code\":\"template_type_mismatch\",\"message\":" + + "\"inconsistent\"}],\"params\":{}}")) + .build(); + try { + orchestrator.validateProposePromptAndDataFilling( + "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + Map.of("type", "object"), + INFORMATION_PROPOSE_URI); + } catch (NegotiationParamExtractionException expected) { + assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, expected.getCode()); + } + } + + private void driveRuleViolation() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient(validExtractionPayload(), validSemanticPayload())) + .build(); + try { + orchestrator.validateProposePromptAndDataFilling( + "## 协商上下文\n- id: " + UUID + "\n- round: 9\n- maxRounds: 5", + Map.of("type", "object"), + INFORMATION_PROPOSE_URI); + } catch (NegotiationParamExtractionException expected) { + assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, expected.getCode()); + } + } + + private void driveQueryBoundaries() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(missingTemplateLoader()) + .build(); + orchestrator.getNegotiationPrompts(); + orchestrator.getNegotiationPrompt(INFORMATION_PROPOSE_URI); + assertTrue(orchestrator.getNegotiationPrompt(StandardTemplates.ENERGY_SAVING).isEmpty()); + } + + private static String validExtractionPayload() { + return "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; + } + + private static String validSemanticPayload() { + return "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"; + } + + private static NegotiationTemplateLoader missingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new net.openan.a2at.sdk.core.exception.ResourceNotFoundException( + "Negotiation template does not exist.", reference.uri()); + } + + @Override + public List loadAll() { + throw new net.openan.a2at.sdk.core.exception.ResourceNotFoundException( + "No negotiation template exists for the configured language.", "templates/Negotiation-T"); + } + }; + } + + private Map> negotiationEventLevels() { + Map> levels = new LinkedHashMap<>(); + for (ILoggingEvent event : negotiationEvents()) { + levels.computeIfAbsent(eventName(event), ignored -> new java.util.HashSet<>()) + .add(event.getLevel()); + } + return levels; + } + + private List negotiationEvents() { + return appender.list.stream() + .filter(event -> event.getFormattedMessage().startsWith("negotiation_")) + .toList(); + } + + private List messagesOf(String eventName) { + return appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.startsWith(eventName + " ") || message.equals(eventName)) + .collect(Collectors.toList()); + } + + private static String eventName(ILoggingEvent event) { + return event.getFormattedMessage().split(" ")[0]; + } + + private static List keyTokens(String message) { + return java.util.Arrays.stream(message.split(" ")) + .filter(token -> token.contains("=")) + .map(token -> token.substring(0, token.indexOf('='))) + .collect(Collectors.toList()); + } + + private static final class ScriptedClient implements LLMClient { + + private final List payloads; + + private int calls; + + private ScriptedClient(String... payloads) { + this.payloads = List.of(payloads); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + String payload = payloads.get(Math.min(calls, payloads.size() - 1)); + calls++; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class FailingClient implements LLMClient { + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new IllegalStateException("LLM endpoint unavailable."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java index 7e8c8a59..281a96a0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java @@ -1,150 +1,150 @@ -package net.openan.a2at.sdk.negotiation.observability; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMError; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProcessingException; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Test; - -/** - * Verifies that the public failure surface of the negotiation content layer never exposes internal pipeline stages. - * - *

      Internal step names exist as diagnostics in the logs only: no public exception type carries a stage property, and - * no failure message mentions the word stage. The tests inspect the exception types reflectively and drive the real - * failure paths to lock the message behavior. - */ -class NegotiationPublicExceptionSurfaceTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final List> PUBLIC_EXCEPTION_TYPES = List.of( - A2ATError.class, - A2ATParamExtractionError.class, - NegotiationProcessingException.class, - NegotiationGenerationException.class, - NegotiationParamExtractionException.class); - - @Test - void noPublicExceptionTypeExposesAStageProperty() { - for (Class type : PUBLIC_EXCEPTION_TYPES) { - for (Method method : type.getMethods()) { - String name = method.getName().toLowerCase(Locale.ROOT); - assertFalse( - name.contains("stage"), - type.getSimpleName() + " must not expose a stage property but declares " + method.getName()); - } - for (Field field : type.getDeclaredFields()) { - String name = field.getName().toLowerCase(Locale.ROOT); - assertFalse( - name.contains("stage"), - type.getSimpleName() + " must not carry a stage field but declares " + field.getName()); - } - } - } - - @Test - void noFailureMessageOfTheRealPipelinesMentionsAStage() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new FailingClient()) - .maxAttempts(2) - .build(); - - List messages = List.of( - failureMessageOf(() -> orchestrator.generateProposeFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI)), - failureMessageOf(() -> orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)), - failureMessageOf(() -> new NegotiationContext(" ", 1, 5)), - failureMessageOf(() -> orchestrator.generateProposeFromText( - "text", new NegotiationContext(UUID, 1, 5), StandardTemplates.ENERGY_SAVING))); - - for (String message : messages) { - assertTrue(message != null && !message.isBlank(), "failure messages must not be blank"); - assertFalse( - message.toLowerCase(Locale.ROOT).contains("stage"), - "failure message must not mention a stage but was: " + message); - } - } - - @Test - void publicFailuresStillCarryTheirStructuredDetails() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new FailingClient()) - .maxAttempts(2) - .build(); - - NegotiationGenerationException generationFailure = catchFailure( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI)); - assertTrue(generationFailure.getCode().equals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR)); - - NegotiationParamExtractionException extractionFailure = catchFailure( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - assertTrue(extractionFailure.getCode().equals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR)); - assertFalse(extractionFailure.getErrors().isEmpty()); - assertTrue(extractionFailure.getErrors().stream() - .allMatch(error -> error.slotName() != null && !error.slotName().isBlank())); - } - - private static String failureMessageOf(Runnable action) { - try { - action.run(); - } catch (RuntimeException failure) { - return failure.getMessage(); - } - return null; - } - - private static T catchFailure(Class type, Runnable action) { - try { - action.run(); - } catch (RuntimeException failure) { - if (type.isInstance(failure)) { - return type.cast(failure); - } - throw failure; - } - throw new AssertionError("the action was expected to fail with " + type.getSimpleName()); - } - - private static final class FailingClient implements LLMClient { - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - throw new LLMError("LLM endpoint unavailable."); - } - } -} +package net.openan.a2at.sdk.negotiation.observability; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProcessingException; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import org.junit.jupiter.api.Test; + +/** + * Verifies that the public failure surface of the negotiation content layer never exposes internal pipeline stages. + * + *

      Internal step names exist as diagnostics in the logs only: no public exception type carries a stage property, and + * no failure message mentions the word stage. The tests inspect the exception types reflectively and drive the real + * failure paths to lock the message behavior. + */ +class NegotiationPublicExceptionSurfaceTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final List> PUBLIC_EXCEPTION_TYPES = List.of( + A2ATError.class, + A2ATParamExtractionError.class, + NegotiationProcessingException.class, + NegotiationGenerationException.class, + NegotiationParamExtractionException.class); + + @Test + void noPublicExceptionTypeExposesAStageProperty() { + for (Class type : PUBLIC_EXCEPTION_TYPES) { + for (Method method : type.getMethods()) { + String name = method.getName().toLowerCase(Locale.ROOT); + assertFalse( + name.contains("stage"), + type.getSimpleName() + " must not expose a stage property but declares " + method.getName()); + } + for (Field field : type.getDeclaredFields()) { + String name = field.getName().toLowerCase(Locale.ROOT); + assertFalse( + name.contains("stage"), + type.getSimpleName() + " must not carry a stage field but declares " + field.getName()); + } + } + } + + @Test + void noFailureMessageOfTheRealPipelinesMentionsAStage() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new FailingClient()) + .maxAttempts(2) + .build(); + + List messages = List.of( + failureMessageOf(() -> orchestrator.generateProposeFromText( + "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI)), + failureMessageOf(() -> orchestrator.validateProposePromptAndDataFilling( + "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)), + failureMessageOf(() -> new NegotiationContext(" ", 1, 5)), + failureMessageOf(() -> orchestrator.generateProposeFromText( + "text", new NegotiationContext(UUID, 1, 5), StandardTemplates.ENERGY_SAVING))); + + for (String message : messages) { + assertTrue(message != null && !message.isBlank(), "failure messages must not be blank"); + assertFalse( + message.toLowerCase(Locale.ROOT).contains("stage"), + "failure message must not mention a stage but was: " + message); + } + } + + @Test + void publicFailuresStillCarryTheirStructuredDetails() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new FailingClient()) + .maxAttempts(2) + .build(); + + NegotiationGenerationException generationFailure = catchFailure( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI)); + assertTrue(generationFailure.getCode().equals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR)); + + NegotiationParamExtractionException extractionFailure = catchFailure( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + assertTrue(extractionFailure.getCode().equals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR)); + assertFalse(extractionFailure.getErrors().isEmpty()); + assertTrue(extractionFailure.getErrors().stream() + .allMatch(error -> error.slotName() != null && !error.slotName().isBlank())); + } + + private static String failureMessageOf(Runnable action) { + try { + action.run(); + } catch (RuntimeException failure) { + return failure.getMessage(); + } + return null; + } + + private static T catchFailure(Class type, Runnable action) { + try { + action.run(); + } catch (RuntimeException failure) { + if (type.isInstance(failure)) { + return type.cast(failure); + } + throw failure; + } + throw new AssertionError("the action was expected to fail with " + type.getSimpleName()); + } + + private static final class FailingClient implements LLMClient { + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new LLMError("LLM endpoint unavailable."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java index c4726d86..c10d1a79 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java @@ -1,103 +1,103 @@ -package net.openan.a2at.sdk.negotiation.scenario; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Test; - -/** - * Walks the bilateral interop loop between two independently assembled negotiation pipelines: one side generates from - * data and from text (the text variant must render exactly the data variant of the same extracted content), the other - * side validates the received message, and the roles are then reversed for the terminal answer. Both sides answer with - * equal metadata content and equal filled parameters for the same inputs. - */ -class BilateralInteropScenarioTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - - private static final String EXTRACTION_RESPONSE = - "{\"items\":[{\"name\":\"节能区域信息\",\"value\":\"松山湖\"}],\"relationship\":null}"; - - @Test - void bothSidesGenerateAndValidateEachOthersMessages() { - ScriptedLlmClient initiatingSideLlm = new ScriptedLlmClient( - EXTRACTION_RESPONSE, SemanticResponses.acceptance("information", "{\"region\":\"松山湖\"}")); - ScriptedLlmClient respondingSideLlm = - new ScriptedLlmClient(SemanticResponses.acceptance("information", "{\"region\":\"松山湖\"}")); - NegotiationGenerationOrchestrator initiatingSide = orchestrator(initiatingSideLlm); - NegotiationGenerationOrchestrator respondingSide = orchestrator(respondingSideLlm); - NegotiationContext round = new NegotiationContext(SESSION_ID, 1, 5); - - MetadataContent fromData = initiatingSide.generateProposeFromData( - new NegotiationProposeData( - round, new InformationProposeContent(List.of(new NegotiationItem("节能区域信息", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - MetadataContent fromText = - initiatingSide.generateProposeFromText("请提供节能区域信息:松山湖。", round, INFORMATION_PROPOSE_URI); - assertEquals( - fromData.promptText(), - fromText.promptText(), - "the from-text variant must render exactly the from-data variant of the same extracted content"); - assertEquals(fromData.templateUri(), fromText.templateUri()); - assertEquals(1, initiatingSideLlm.callCount(), "the from-text generation performs exactly one extraction call"); - - FilledParamData respondingSideParameters = respondingSide.validateProposePromptAndDataFilling( - fromData.promptText(), parameterSchema(), INFORMATION_PROPOSE_URI); - FilledParamData initiatingSideParameters = initiatingSide.validateProposePromptAndDataFilling( - fromData.promptText(), parameterSchema(), INFORMATION_PROPOSE_URI); - assertEquals( - respondingSideParameters, - initiatingSideParameters, - "both sides must extract the same parameters from the same message"); - assertEquals(SESSION_ID, respondingSideParameters.data().get("id")); - assertEquals("松山湖", respondingSideParameters.data().get("region")); - - NegotiationEndingData answer = new NegotiationEndingData( - round, - new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("节能区域信息", "松山湖")))); - MetadataContent respondingSideAnswer = respondingSide.generateAcceptFromData(answer, INFORMATION_ACCEPT_URI); - MetadataContent initiatingSideAnswer = initiatingSide.generateAcceptFromData(answer, INFORMATION_ACCEPT_URI); - assertEquals( - respondingSideAnswer, - initiatingSideAnswer, - "both sides must generate the identical message for the same typed input"); - assertEquals(respondingSideAnswer.buildMetadataContent(), initiatingSideAnswer.buildMetadataContent()); - assertTrue(respondingSideAnswer.promptText().contains("## 信息协商结果\nAccept")); - - FilledParamData validatedAnswer = initiatingSide.validateAcceptPromptAndDataFilling( - respondingSideAnswer.promptText(), parameterSchema(), INFORMATION_ACCEPT_URI); - assertEquals(SESSION_ID, validatedAnswer.data().get("id")); - assertEquals(1, validatedAnswer.data().get("round")); - assertEquals("松山湖", validatedAnswer.data().get("region")); - } - - private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static Map parameterSchema() { - return Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))); - } -} +package net.openan.a2at.sdk.negotiation.scenario; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import org.junit.jupiter.api.Test; + +/** + * Walks the bilateral interop loop between two independently assembled negotiation pipelines: one side generates from + * data and from text (the text variant must render exactly the data variant of the same extracted content), the other + * side validates the received message, and the roles are then reversed for the terminal answer. Both sides answer with + * equal metadata content and equal filled parameters for the same inputs. + */ +class BilateralInteropScenarioTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + + private static final String EXTRACTION_RESPONSE = + "{\"items\":[{\"name\":\"节能区域信息\",\"value\":\"松山湖\"}],\"relationship\":null}"; + + @Test + void bothSidesGenerateAndValidateEachOthersMessages() { + ScriptedLlmClient initiatingSideLlm = new ScriptedLlmClient( + EXTRACTION_RESPONSE, SemanticResponses.acceptance("information", "{\"region\":\"松山湖\"}")); + ScriptedLlmClient respondingSideLlm = + new ScriptedLlmClient(SemanticResponses.acceptance("information", "{\"region\":\"松山湖\"}")); + NegotiationGenerationOrchestrator initiatingSide = orchestrator(initiatingSideLlm); + NegotiationGenerationOrchestrator respondingSide = orchestrator(respondingSideLlm); + NegotiationContext round = new NegotiationContext(SESSION_ID, 1, 5); + + MetadataContent fromData = initiatingSide.generateProposeFromData( + new NegotiationProposeData( + round, new InformationProposeContent(List.of(new NegotiationItem("节能区域信息", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + MetadataContent fromText = + initiatingSide.generateProposeFromText("请提供节能区域信息:松山湖。", round, INFORMATION_PROPOSE_URI); + assertEquals( + fromData.promptText(), + fromText.promptText(), + "the from-text variant must render exactly the from-data variant of the same extracted content"); + assertEquals(fromData.templateUri(), fromText.templateUri()); + assertEquals(1, initiatingSideLlm.callCount(), "the from-text generation performs exactly one extraction call"); + + FilledParamData respondingSideParameters = respondingSide.validateProposePromptAndDataFilling( + fromData.promptText(), parameterSchema(), INFORMATION_PROPOSE_URI); + FilledParamData initiatingSideParameters = initiatingSide.validateProposePromptAndDataFilling( + fromData.promptText(), parameterSchema(), INFORMATION_PROPOSE_URI); + assertEquals( + respondingSideParameters, + initiatingSideParameters, + "both sides must extract the same parameters from the same message"); + assertEquals(SESSION_ID, respondingSideParameters.data().get("id")); + assertEquals("松山湖", respondingSideParameters.data().get("region")); + + NegotiationEndingData answer = new NegotiationEndingData( + round, + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("节能区域信息", "松山湖")))); + MetadataContent respondingSideAnswer = respondingSide.generateAcceptFromData(answer, INFORMATION_ACCEPT_URI); + MetadataContent initiatingSideAnswer = initiatingSide.generateAcceptFromData(answer, INFORMATION_ACCEPT_URI); + assertEquals( + respondingSideAnswer, + initiatingSideAnswer, + "both sides must generate the identical message for the same typed input"); + assertEquals(respondingSideAnswer.buildMetadataContent(), initiatingSideAnswer.buildMetadataContent()); + assertTrue(respondingSideAnswer.promptText().contains("## 信息协商结果\nAccept")); + + FilledParamData validatedAnswer = initiatingSide.validateAcceptPromptAndDataFilling( + respondingSideAnswer.promptText(), parameterSchema(), INFORMATION_ACCEPT_URI); + assertEquals(SESSION_ID, validatedAnswer.data().get("id")); + assertEquals(1, validatedAnswer.data().get("round")); + assertEquals("松山湖", validatedAnswer.data().get("region")); + } + + private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + } + + private static Map parameterSchema() { + return Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java index 4971f3cd..72542984 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java @@ -1,132 +1,132 @@ -package net.openan.a2at.sdk.negotiation.scenario; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.golden.GoldenInputs; -import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; -import org.junit.jupiter.api.Test; - -/** - * Walks the feasibility negotiation scenario: the responder's semantic validation rejects an evaluation request that - * conflicts with an existing constraint (the trigger of the feasibility negotiation), the requester proposes an - * alternative for the infeasible case, and the responder accepts with a summary that fills the exception slot of the - * feasibility result confirmation section. The evaluation request and the terminal answer are locked against the golden - * fixtures. - */ -class FeasibilityNegotiationScenarioTest { - - private static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; - - private static final TemplateUri FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; - - @Test - void feasibilityNegotiationRunsFromSemanticRejectionToATerminalAcceptance() throws IOException { - String conflictErrors = "[{\"slot_name\":\"section.feasibility_evaluate\",\"code\":\"semantic_inconsistency\"," - + "\"message\":\"The adjusted rate target conflicts with the power supply duration constraint.\"}," - + "{\"slot_name\":\"section.feasibility\",\"code\":\"semantic_unclear\"," - + "\"message\":\"The request does not state the constraint context of the evaluation.\"}]"; - ScriptedLlmClient requesterLlm = - new ScriptedLlmClient(SemanticResponses.acceptance("feasibility", "{\"feasibilitySummary\":\"可行\"}")); - ScriptedLlmClient responderLlm = new ScriptedLlmClient(SemanticResponses.rejection(conflictErrors)); - NegotiationGenerationOrchestrator requester = orchestrator(requesterLlm); - NegotiationGenerationOrchestrator responder = orchestrator(responderLlm); - - MetadataContent evaluationRequest = GoldenCase.FEASIBILITY_PROPOSE.generate(requester); - assertEquals( - readGoldenFixture(GoldenCase.FEASIBILITY_PROPOSE, GoldenInputs.ZH_CN), - evaluationRequest.promptText(), - "the evaluation request must match the golden fixture byte for byte"); - - NegotiationParamExtractionException rejection = assertThrows( - NegotiationParamExtractionException.class, - () -> responder.validateProposePromptAndDataFilling( - evaluationRequest.promptText(), parameterSchema(), FEASIBILITY_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, rejection.getCode()); - assertEquals(1, responderLlm.callCount(), "a semantic rejection must not be retried"); - List errors = rejection.getErrors(); - assertEquals(2, errors.size()); - assertTrue(errors.stream().anyMatch(error -> "section.feasibility_evaluate".equals(error.slotName()))); - assertTrue(errors.stream().anyMatch(error -> "section.feasibility".equals(error.slotName()))); - assertTrue( - errors.stream().allMatch(error -> error.code() != null && error.message() != null), - "every error detail must carry a code and a message so the caller can react per field"); - - MetadataContent alternativeProposal = requester.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(GoldenInputs.SESSION_ID, 2, 5), - new FeasibilityProposeContent( - "目标与停电约束冲突,替代方案详见<评估不可行时的详情和提案>,请评估", - NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, - null, - List.of(new NegotiationItem("替代方案", "速率保障目标下调至2Mbps,节能时段缩短为4小时")))), - FEASIBILITY_PROPOSE_URI); - assertTrue( - alternativeProposal.promptText().contains("## 评估不可行时的详情和提案"), - "the alternative action must render the infeasibility section"); - assertTrue( - !alternativeProposal.promptText().contains("## 待评估内容说明"), - "the alternative action must not render the evaluation section"); - - MetadataContent acceptance = responder.generateAcceptFromData( - new NegotiationEndingData( - GoldenInputs.defaultContext(), - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "同意将速率保障目标由5Mbps下调至2Mbps,本次协商确认结束")), - FEASIBILITY_ACCEPT_URI); - assertTrue(acceptance.promptText().contains("## 可行性协商结果\nAccept")); - assertTrue( - acceptance.promptText().contains("## 可行性评估结果确认\n同意将速率保障目标由5Mbps下调至2Mbps,本次协商确认结束"), - "the summary must fill the exception slot of the feasibility result confirmation section"); - - FilledParamData terminalParameters = requester.validateAcceptPromptAndDataFilling( - acceptance.promptText(), parameterSchema(), FEASIBILITY_ACCEPT_URI); - assertEquals(1, requesterLlm.callCount()); - assertEquals(GoldenInputs.SESSION_ID, terminalParameters.data().get("id")); - assertEquals(2, terminalParameters.data().get("round")); - } - - private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static Map parameterSchema() { - return Map.of("type", "object", "properties", Map.of("feasibilitySummary", Map.of("type", "string"))); - } - - private static String readGoldenFixture(GoldenCase goldenCase, String language) { - String resourcePath = goldenCase.goldenResourcePath(language); - InputStream stream = FeasibilityNegotiationScenarioTest.class.getResourceAsStream(resourcePath); - assertTrue(stream != null, "Golden fixture must exist on the test classpath: " + resourcePath); - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } catch (IOException exception) { - throw new AssertionError("Failed to read golden fixture " + resourcePath, exception); - } - } -} +package net.openan.a2at.sdk.negotiation.scenario; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.golden.GoldenInputs; +import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; +import org.junit.jupiter.api.Test; + +/** + * Walks the feasibility negotiation scenario: the responder's semantic validation rejects an evaluation request that + * conflicts with an existing constraint (the trigger of the feasibility negotiation), the requester proposes an + * alternative for the infeasible case, and the responder accepts with a summary that fills the exception slot of the + * feasibility result confirmation section. The evaluation request and the terminal answer are locked against the golden + * fixtures. + */ +class FeasibilityNegotiationScenarioTest { + + private static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; + + private static final TemplateUri FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; + + @Test + void feasibilityNegotiationRunsFromSemanticRejectionToATerminalAcceptance() throws IOException { + String conflictErrors = "[{\"slot_name\":\"section.feasibility_evaluate\",\"code\":\"semantic_inconsistency\"," + + "\"message\":\"The adjusted rate target conflicts with the power supply duration constraint.\"}," + + "{\"slot_name\":\"section.feasibility\",\"code\":\"semantic_unclear\"," + + "\"message\":\"The request does not state the constraint context of the evaluation.\"}]"; + ScriptedLlmClient requesterLlm = + new ScriptedLlmClient(SemanticResponses.acceptance("feasibility", "{\"feasibilitySummary\":\"可行\"}")); + ScriptedLlmClient responderLlm = new ScriptedLlmClient(SemanticResponses.rejection(conflictErrors)); + NegotiationGenerationOrchestrator requester = orchestrator(requesterLlm); + NegotiationGenerationOrchestrator responder = orchestrator(responderLlm); + + MetadataContent evaluationRequest = GoldenCase.FEASIBILITY_PROPOSE.generate(requester); + assertEquals( + readGoldenFixture(GoldenCase.FEASIBILITY_PROPOSE, GoldenInputs.ZH_CN), + evaluationRequest.promptText(), + "the evaluation request must match the golden fixture byte for byte"); + + NegotiationParamExtractionException rejection = assertThrows( + NegotiationParamExtractionException.class, + () -> responder.validateProposePromptAndDataFilling( + evaluationRequest.promptText(), parameterSchema(), FEASIBILITY_PROPOSE_URI)); + assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, rejection.getCode()); + assertEquals(1, responderLlm.callCount(), "a semantic rejection must not be retried"); + List errors = rejection.getErrors(); + assertEquals(2, errors.size()); + assertTrue(errors.stream().anyMatch(error -> "section.feasibility_evaluate".equals(error.slotName()))); + assertTrue(errors.stream().anyMatch(error -> "section.feasibility".equals(error.slotName()))); + assertTrue( + errors.stream().allMatch(error -> error.code() != null && error.message() != null), + "every error detail must carry a code and a message so the caller can react per field"); + + MetadataContent alternativeProposal = requester.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(GoldenInputs.SESSION_ID, 2, 5), + new FeasibilityProposeContent( + "目标与停电约束冲突,替代方案详见<评估不可行时的详情和提案>,请评估", + NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, + null, + List.of(new NegotiationItem("替代方案", "速率保障目标下调至2Mbps,节能时段缩短为4小时")))), + FEASIBILITY_PROPOSE_URI); + assertTrue( + alternativeProposal.promptText().contains("## 评估不可行时的详情和提案"), + "the alternative action must render the infeasibility section"); + assertTrue( + !alternativeProposal.promptText().contains("## 待评估内容说明"), + "the alternative action must not render the evaluation section"); + + MetadataContent acceptance = responder.generateAcceptFromData( + new NegotiationEndingData( + GoldenInputs.defaultContext(), + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "同意将速率保障目标由5Mbps下调至2Mbps,本次协商确认结束")), + FEASIBILITY_ACCEPT_URI); + assertTrue(acceptance.promptText().contains("## 可行性协商结果\nAccept")); + assertTrue( + acceptance.promptText().contains("## 可行性评估结果确认\n同意将速率保障目标由5Mbps下调至2Mbps,本次协商确认结束"), + "the summary must fill the exception slot of the feasibility result confirmation section"); + + FilledParamData terminalParameters = requester.validateAcceptPromptAndDataFilling( + acceptance.promptText(), parameterSchema(), FEASIBILITY_ACCEPT_URI); + assertEquals(1, requesterLlm.callCount()); + assertEquals(GoldenInputs.SESSION_ID, terminalParameters.data().get("id")); + assertEquals(2, terminalParameters.data().get("round")); + } + + private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + } + + private static Map parameterSchema() { + return Map.of("type", "object", "properties", Map.of("feasibilitySummary", Map.of("type", "string"))); + } + + private static String readGoldenFixture(GoldenCase goldenCase, String language) { + String resourcePath = goldenCase.goldenResourcePath(language); + InputStream stream = FeasibilityNegotiationScenarioTest.class.getResourceAsStream(resourcePath); + assertTrue(stream != null, "Golden fixture must exist on the test classpath: " + resourcePath); + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); + } catch (IOException exception) { + throw new AssertionError("Failed to read golden fixture " + resourcePath, exception); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java index 8a9bd6fc..00c7c762 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java @@ -1,114 +1,114 @@ -package net.openan.a2at.sdk.negotiation.scenario; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Test; - -/** - * Walks the information negotiation scenario: a missing-information task is rejected as not negotiable, the requesting - * agent asks for the missing items, the answering agent validates the request, delivers the information and accepts, - * and the requester validates the terminal answer — all within one shared round. - */ -class InformationNegotiationScenarioTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - - @Test - void informationNegotiationReachesTheTerminalStateWithinOneRound() { - ScriptedLlmClient requesterLlm = - new ScriptedLlmClient(SemanticResponses.acceptance("information", "{\"region\":\"松山湖\"}")); - ScriptedLlmClient responderLlm = - new ScriptedLlmClient(SemanticResponses.acceptance("information", "{\"region\":\"松山湖\"}")); - NegotiationGenerationOrchestrator requester = orchestrator(requesterLlm); - NegotiationGenerationOrchestrator responder = orchestrator(responderLlm); - - NegotiationParamExtractionException notNegotiable = assertThrows( - NegotiationParamExtractionException.class, - () -> responder.validateProposePromptAndDataFilling( - "A task prompt describing an energy-saving optimization without any negotiation section.", - parameterSchema(), - INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, notNegotiable.getCode()); - assertEquals( - 0, - responderLlm.callCount(), - "a text without a negotiation context section must fail before any LLM call"); - - NegotiationContext round = new NegotiationContext(SESSION_ID, 1, 5); - MetadataContent request = requester.generateProposeFromData( - new NegotiationProposeData( - round, - new InformationProposeContent( - List.of( - new NegotiationItem("节能区域信息", "如松山湖"), - new NegotiationItem("节能速率保障目标", "如20Mbps")), - null)), - INFORMATION_PROPOSE_URI); - assertTrue(request.promptText().contains("## 所需信息项")); - assertTrue(request.promptText().contains("1. 节能区域信息:如松山湖")); - assertTrue(request.promptText().contains("- round: 1")); - - FilledParamData requestParameters = responder.validateProposePromptAndDataFilling( - request.promptText(), parameterSchema(), INFORMATION_PROPOSE_URI); - assertEquals(1, responderLlm.callCount()); - assertEquals(SESSION_ID, requestParameters.data().get("id")); - assertEquals(1, requestParameters.data().get("round")); - assertEquals(5, requestParameters.data().get("maxRounds")); - assertEquals("松山湖", requestParameters.data().get("region")); - - MetadataContent answer = responder.generateAcceptFromData( - new NegotiationEndingData( - round, - new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of( - new NegotiationItem("节能区域信息", "松山湖"), - new NegotiationItem("节能速率保障目标", "20Mbps")))), - INFORMATION_ACCEPT_URI); - assertTrue(answer.promptText().contains("## 信息协商结果\nAccept")); - assertTrue(answer.promptText().contains("## 信息协商结果内容")); - assertTrue(answer.promptText().contains("1. 节能区域信息:松山湖")); - assertTrue(answer.promptText().contains("- round: 1"), "the terminal answer shares the round of the request"); - - FilledParamData answerParameters = - requester.validateAcceptPromptAndDataFilling(answer.promptText(), parameterSchema(), INFORMATION_ACCEPT_URI); - assertEquals(1, requesterLlm.callCount()); - assertEquals(SESSION_ID, answerParameters.data().get("id")); - assertEquals(1, answerParameters.data().get("round")); - assertEquals("松山湖", answerParameters.data().get("region")); - } - - private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static Map parameterSchema() { - return Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))); - } -} +package net.openan.a2at.sdk.negotiation.scenario; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import org.junit.jupiter.api.Test; + +/** + * Walks the information negotiation scenario: a missing-information task is rejected as not negotiable, the requesting + * agent asks for the missing items, the answering agent validates the request, delivers the information and accepts, + * and the requester validates the terminal answer — all within one shared round. + */ +class InformationNegotiationScenarioTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + + @Test + void informationNegotiationReachesTheTerminalStateWithinOneRound() { + ScriptedLlmClient requesterLlm = + new ScriptedLlmClient(SemanticResponses.acceptance("information", "{\"region\":\"松山湖\"}")); + ScriptedLlmClient responderLlm = + new ScriptedLlmClient(SemanticResponses.acceptance("information", "{\"region\":\"松山湖\"}")); + NegotiationGenerationOrchestrator requester = orchestrator(requesterLlm); + NegotiationGenerationOrchestrator responder = orchestrator(responderLlm); + + NegotiationParamExtractionException notNegotiable = assertThrows( + NegotiationParamExtractionException.class, + () -> responder.validateProposePromptAndDataFilling( + "A task prompt describing an energy-saving optimization without any negotiation section.", + parameterSchema(), + INFORMATION_PROPOSE_URI)); + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, notNegotiable.getCode()); + assertEquals( + 0, + responderLlm.callCount(), + "a text without a negotiation context section must fail before any LLM call"); + + NegotiationContext round = new NegotiationContext(SESSION_ID, 1, 5); + MetadataContent request = requester.generateProposeFromData( + new NegotiationProposeData( + round, + new InformationProposeContent( + List.of( + new NegotiationItem("节能区域信息", "如松山湖"), + new NegotiationItem("节能速率保障目标", "如20Mbps")), + null)), + INFORMATION_PROPOSE_URI); + assertTrue(request.promptText().contains("## 所需信息项")); + assertTrue(request.promptText().contains("1. 节能区域信息:如松山湖")); + assertTrue(request.promptText().contains("- round: 1")); + + FilledParamData requestParameters = responder.validateProposePromptAndDataFilling( + request.promptText(), parameterSchema(), INFORMATION_PROPOSE_URI); + assertEquals(1, responderLlm.callCount()); + assertEquals(SESSION_ID, requestParameters.data().get("id")); + assertEquals(1, requestParameters.data().get("round")); + assertEquals(5, requestParameters.data().get("maxRounds")); + assertEquals("松山湖", requestParameters.data().get("region")); + + MetadataContent answer = responder.generateAcceptFromData( + new NegotiationEndingData( + round, + new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of( + new NegotiationItem("节能区域信息", "松山湖"), + new NegotiationItem("节能速率保障目标", "20Mbps")))), + INFORMATION_ACCEPT_URI); + assertTrue(answer.promptText().contains("## 信息协商结果\nAccept")); + assertTrue(answer.promptText().contains("## 信息协商结果内容")); + assertTrue(answer.promptText().contains("1. 节能区域信息:松山湖")); + assertTrue(answer.promptText().contains("- round: 1"), "the terminal answer shares the round of the request"); + + FilledParamData answerParameters = + requester.validateAcceptPromptAndDataFilling(answer.promptText(), parameterSchema(), INFORMATION_ACCEPT_URI); + assertEquals(1, requesterLlm.callCount()); + assertEquals(SESSION_ID, answerParameters.data().get("id")); + assertEquals(1, answerParameters.data().get("round")); + assertEquals("松山湖", answerParameters.data().get("region")); + } + + private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + } + + private static Map parameterSchema() { + return Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java index 6ea48bb7..282c438c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java @@ -1,85 +1,85 @@ -package net.openan.a2at.sdk.negotiation.scenario; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Test; - -/** - * Walks the round-budget exhaustion scenario: the last allowed round is not yet exhausted, advancing past it marks the - * context exhausted, and the negotiation ends with an abort message at the last allowed round that the peer still - * validates — while a message of a round beyond the budget fails the peer's rule gate, so no further round can ever be - * exchanged. - */ -class MaxRoundsExhaustionScenarioTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - @Test - void exhaustedRoundBudgetEndsWithAnAbortMessageThePeerStillAccepts() { - ScriptedLlmClient peerLlm = new ScriptedLlmClient(SemanticResponses.acceptance("target", "{}")); - NegotiationGenerationOrchestrator agent = orchestrator(null); - NegotiationGenerationOrchestrator peer = orchestrator(peerLlm); - - NegotiationContext lastRound = new NegotiationContext(SESSION_ID, 5, 5); - assertFalse(lastRound.isExhausted(), "round == maxRounds is the last usable round, not an exhausted one"); - NegotiationContext beyondBudget = lastRound.nextRound(); - assertEquals(6, beyondBudget.round()); - assertTrue(beyondBudget.isExhausted(), "round > maxRounds marks the context exhausted"); - assertEquals(5, lastRound.round(), "nextRound must leave the original context unchanged"); - - MetadataContent terminal = agent.generateAbortFromData( - new NegotiationAbortData( - lastRound, new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - StandardTemplates.NEGOTIATION_ABORT); - assertTrue(terminal.promptText().contains("## 协商结果\nAbort")); - assertTrue(terminal.promptText().contains("## 协商终止原因")); - assertTrue(terminal.promptText().contains("达到协商轮次上限,本次协商确认结束。")); - assertTrue(terminal.promptText().contains("- round: 5")); - - FilledParamData terminalParameters = peer.validateAbortPromptAndDataFilling( - terminal.promptText(), parameterSchema(), StandardTemplates.NEGOTIATION_ABORT); - assertEquals(1, peerLlm.callCount()); - assertEquals(SESSION_ID, terminalParameters.data().get("id")); - assertEquals(5, terminalParameters.data().get("round")); - assertEquals(5, terminalParameters.data().get("maxRounds")); - - MetadataContent beyondBudgetMessage = agent.generateAbortFromData( - new NegotiationAbortData(beyondBudget, new NegotiationAbortContent("This message exceeds the round budget.")), - StandardTemplates.NEGOTIATION_ABORT); - NegotiationParamExtractionException ruleFailure = assertThrows( - NegotiationParamExtractionException.class, - () -> peer.validateAbortPromptAndDataFilling( - beyondBudgetMessage.promptText(), parameterSchema(), StandardTemplates.NEGOTIATION_ABORT)); - assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, ruleFailure.getCode()); - assertTrue( - ruleFailure.getErrors().stream().anyMatch(error -> "round".equals(error.slotName())), - "the beyond-budget round must be reported on the round slot"); - assertEquals(1, peerLlm.callCount(), "the rule gate must fail before any semantic LLM call"); - } - - private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static Map parameterSchema() { - return Map.of("type", "object"); - } -} +package net.openan.a2at.sdk.negotiation.scenario; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import org.junit.jupiter.api.Test; + +/** + * Walks the round-budget exhaustion scenario: the last allowed round is not yet exhausted, advancing past it marks the + * context exhausted, and the negotiation ends with an abort message at the last allowed round that the peer still + * validates — while a message of a round beyond the budget fails the peer's rule gate, so no further round can ever be + * exchanged. + */ +class MaxRoundsExhaustionScenarioTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + @Test + void exhaustedRoundBudgetEndsWithAnAbortMessageThePeerStillAccepts() { + ScriptedLlmClient peerLlm = new ScriptedLlmClient(SemanticResponses.acceptance("target", "{}")); + NegotiationGenerationOrchestrator agent = orchestrator(null); + NegotiationGenerationOrchestrator peer = orchestrator(peerLlm); + + NegotiationContext lastRound = new NegotiationContext(SESSION_ID, 5, 5); + assertFalse(lastRound.isExhausted(), "round == maxRounds is the last usable round, not an exhausted one"); + NegotiationContext beyondBudget = lastRound.nextRound(); + assertEquals(6, beyondBudget.round()); + assertTrue(beyondBudget.isExhausted(), "round > maxRounds marks the context exhausted"); + assertEquals(5, lastRound.round(), "nextRound must leave the original context unchanged"); + + MetadataContent terminal = agent.generateAbortFromData( + new NegotiationAbortData( + lastRound, new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + StandardTemplates.NEGOTIATION_ABORT); + assertTrue(terminal.promptText().contains("## 协商结果\nAbort")); + assertTrue(terminal.promptText().contains("## 协商终止原因")); + assertTrue(terminal.promptText().contains("达到协商轮次上限,本次协商确认结束。")); + assertTrue(terminal.promptText().contains("- round: 5")); + + FilledParamData terminalParameters = peer.validateAbortPromptAndDataFilling( + terminal.promptText(), parameterSchema(), StandardTemplates.NEGOTIATION_ABORT); + assertEquals(1, peerLlm.callCount()); + assertEquals(SESSION_ID, terminalParameters.data().get("id")); + assertEquals(5, terminalParameters.data().get("round")); + assertEquals(5, terminalParameters.data().get("maxRounds")); + + MetadataContent beyondBudgetMessage = agent.generateAbortFromData( + new NegotiationAbortData(beyondBudget, new NegotiationAbortContent("This message exceeds the round budget.")), + StandardTemplates.NEGOTIATION_ABORT); + NegotiationParamExtractionException ruleFailure = assertThrows( + NegotiationParamExtractionException.class, + () -> peer.validateAbortPromptAndDataFilling( + beyondBudgetMessage.promptText(), parameterSchema(), StandardTemplates.NEGOTIATION_ABORT)); + assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, ruleFailure.getCode()); + assertTrue( + ruleFailure.getErrors().stream().anyMatch(error -> "round".equals(error.slotName())), + "the beyond-budget round must be reported on the round slot"); + assertEquals(1, peerLlm.callCount(), "the rule gate must fail before any semantic LLM call"); + } + + private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + } + + private static Map parameterSchema() { + return Map.of("type", "object"); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java index ce994349..48380ee0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java @@ -1,154 +1,154 @@ -package net.openan.a2at.sdk.negotiation.scenario; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Test; - -/** - * Walks the target negotiation scenario across two rounds: the first-round proposal carries the intent understanding - * and an open clarification request, the responder rejects it, the requester advances to the second round immutably and - * switches to the alignment section, and the responder finally accepts with the confirmed intent. The responder also - * extracts a nested time-rate target list from the first-round proposal. - */ -class TargetNegotiationScenarioTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; - - private static final TemplateUri TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; - - @Test - void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { - ScriptedLlmClient requesterLlm = new ScriptedLlmClient( - SemanticResponses.acceptance("target", "{}"), - SemanticResponses.acceptance("target", "{\"confirmedIntent\":\"08:00-18:00对松山湖站点启用无线节能优化\"}")); - ScriptedLlmClient responderLlm = new ScriptedLlmClient(SemanticResponses.acceptance( - "target", - "{\"timeRateTargets\":[{\"time\":\"08:00-18:00\",\"rate\":\"20Mbps\"}," - + "{\"time\":\"00:00-06:00\",\"rate\":\"5Mbps\"}]}")); - NegotiationGenerationOrchestrator requester = orchestrator(requesterLlm); - NegotiationGenerationOrchestrator responder = orchestrator(responderLlm); - - NegotiationContext firstRound = new NegotiationContext(SESSION_ID, 1, 5); - MetadataContent firstProposal = requester.generateProposeFromData( - new NegotiationProposeData( - firstRound, - new TargetProposeContent( - "对无线节能优化任务的意图理解参见<意图理解陈述>,对节能区域存在疑问,详见<待澄清内容>,请澄清和确认", - List.of(new NegotiationItem("任务意图", "08:00-18:00对目标站点启用无线节能优化")), - null, - List.of(new NegotiationItem("节能区域", "松山湖还是其他站点")))), - TARGET_PROPOSE_URI); - assertTrue( - firstProposal.promptText().contains("## 意图理解陈述"), - "round 1 must carry the intent understanding section"); - assertTrue( - !firstProposal.promptText().contains("## 理解对齐与疑问澄清"), "round 1 must not carry the alignment section"); - assertTrue(firstProposal.promptText().contains("## 待澄清内容")); - assertTrue(firstProposal.promptText().contains("- round: 1")); - - FilledParamData firstRoundParameters = responder.validateProposePromptAndDataFilling( - firstProposal.promptText(), nestedParameterSchema(), TARGET_PROPOSE_URI); - assertEquals(1, responderLlm.callCount()); - Object timeRateTargets = firstRoundParameters.data().get("timeRateTargets"); - assertTrue(timeRateTargets instanceof List, "the nested parameter list must pass through"); - List targetList = (List) timeRateTargets; - assertEquals(2, targetList.size()); - assertTrue(targetList.get(0) instanceof Map); - assertEquals("08:00-18:00", ((Map) targetList.get(0)).get("time")); - assertEquals("20Mbps", ((Map) targetList.get(0)).get("rate")); - - MetadataContent rejection = responder.generateRejectFromData( - new NegotiationEndingData( - firstRound, - new TargetEndingContent(NegotiationConclusion.REJECT, null, "节能区域信息因站点清单不可用而无法完整澄清")), - TARGET_ACCEPT_URI); - assertTrue(rejection.promptText().contains("## 目标协商结果\nReject")); - assertTrue(rejection.promptText().contains("节能区域信息因站点清单不可用而无法完整澄清")); - assertTrue(rejection.promptText().contains("- round: 1")); - - requester.validateRejectPromptAndDataFilling(rejection.promptText(), parameterSchema(), TARGET_ACCEPT_URI); - assertEquals(1, requesterLlm.callCount()); - - NegotiationContext secondRound = firstRound.nextRound(); - assertEquals(2, secondRound.round()); - assertEquals(1, firstRound.round(), "nextRound must leave the original context unchanged"); - - MetadataContent secondProposal = requester.generateProposeFromData( - new NegotiationProposeData( - secondRound, - new TargetProposeContent( - "已针对节能区域提供澄清,详见<理解对齐与疑问澄清>,请确认", - null, - List.of(new NegotiationItem("节能区域", "confirmed(认同):松山湖")), - null)), - TARGET_PROPOSE_URI); - assertTrue(secondProposal.promptText().contains("## 理解对齐与疑问澄清"), "round 2 must carry the alignment section"); - assertTrue( - !secondProposal.promptText().contains("## 意图理解陈述"), - "round 2 must not carry the intent understanding section"); - assertTrue( - !secondProposal.promptText().contains("## 待澄清内容"), - "an empty clarification list must drop the whole section"); - assertTrue(secondProposal.promptText().contains("- round: 2")); - - MetadataContent acceptance = responder.generateAcceptFromData( - new NegotiationEndingData( - secondRound, - new TargetEndingContent( - NegotiationConclusion.ACCEPT, "最终确认意图:08:00-18:00对松山湖站点启用无线节能优化,速率保障不低于20Mbps", null)), - TARGET_ACCEPT_URI); - assertTrue(acceptance.promptText().contains("## 目标协商结果\nAccept")); - assertTrue(acceptance.promptText().contains("最终确认意图:08:00-18:00对松山湖站点启用无线节能优化")); - assertTrue(acceptance.promptText().contains("- round: 2")); - - FilledParamData terminalParameters = - requester.validateAcceptPromptAndDataFilling(acceptance.promptText(), parameterSchema(), TARGET_ACCEPT_URI); - assertEquals(2, requesterLlm.callCount()); - assertEquals(SESSION_ID, terminalParameters.data().get("id")); - assertEquals(2, terminalParameters.data().get("round")); - assertEquals("08:00-18:00对松山湖站点启用无线节能优化", terminalParameters.data().get("confirmedIntent")); - assertEquals(1, firstRound.round(), "the original context must stay unchanged until the end"); - } - - private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static Map parameterSchema() { - return Map.of("type", "object", "properties", Map.of("confirmedIntent", Map.of("type", "string"))); - } - - private static Map nestedParameterSchema() { - Map targetItem = Map.of( - "type", - "object", - "properties", - Map.of("time", Map.of("type", "string"), "rate", Map.of("type", "string"))); - return Map.of( - "type", - "object", - "properties", - Map.of("timeRateTargets", Map.of("type", "array", "items", targetItem))); - } -} +package net.openan.a2at.sdk.negotiation.scenario; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import org.junit.jupiter.api.Test; + +/** + * Walks the target negotiation scenario across two rounds: the first-round proposal carries the intent understanding + * and an open clarification request, the responder rejects it, the requester advances to the second round immutably and + * switches to the alignment section, and the responder finally accepts with the confirmed intent. The responder also + * extracts a nested time-rate target list from the first-round proposal. + */ +class TargetNegotiationScenarioTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; + + private static final TemplateUri TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; + + @Test + void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { + ScriptedLlmClient requesterLlm = new ScriptedLlmClient( + SemanticResponses.acceptance("target", "{}"), + SemanticResponses.acceptance("target", "{\"confirmedIntent\":\"08:00-18:00对松山湖站点启用无线节能优化\"}")); + ScriptedLlmClient responderLlm = new ScriptedLlmClient(SemanticResponses.acceptance( + "target", + "{\"timeRateTargets\":[{\"time\":\"08:00-18:00\",\"rate\":\"20Mbps\"}," + + "{\"time\":\"00:00-06:00\",\"rate\":\"5Mbps\"}]}")); + NegotiationGenerationOrchestrator requester = orchestrator(requesterLlm); + NegotiationGenerationOrchestrator responder = orchestrator(responderLlm); + + NegotiationContext firstRound = new NegotiationContext(SESSION_ID, 1, 5); + MetadataContent firstProposal = requester.generateProposeFromData( + new NegotiationProposeData( + firstRound, + new TargetProposeContent( + "对无线节能优化任务的意图理解参见<意图理解陈述>,对节能区域存在疑问,详见<待澄清内容>,请澄清和确认", + List.of(new NegotiationItem("任务意图", "08:00-18:00对目标站点启用无线节能优化")), + null, + List.of(new NegotiationItem("节能区域", "松山湖还是其他站点")))), + TARGET_PROPOSE_URI); + assertTrue( + firstProposal.promptText().contains("## 意图理解陈述"), + "round 1 must carry the intent understanding section"); + assertTrue( + !firstProposal.promptText().contains("## 理解对齐与疑问澄清"), "round 1 must not carry the alignment section"); + assertTrue(firstProposal.promptText().contains("## 待澄清内容")); + assertTrue(firstProposal.promptText().contains("- round: 1")); + + FilledParamData firstRoundParameters = responder.validateProposePromptAndDataFilling( + firstProposal.promptText(), nestedParameterSchema(), TARGET_PROPOSE_URI); + assertEquals(1, responderLlm.callCount()); + Object timeRateTargets = firstRoundParameters.data().get("timeRateTargets"); + assertTrue(timeRateTargets instanceof List, "the nested parameter list must pass through"); + List targetList = (List) timeRateTargets; + assertEquals(2, targetList.size()); + assertTrue(targetList.get(0) instanceof Map); + assertEquals("08:00-18:00", ((Map) targetList.get(0)).get("time")); + assertEquals("20Mbps", ((Map) targetList.get(0)).get("rate")); + + MetadataContent rejection = responder.generateRejectFromData( + new NegotiationEndingData( + firstRound, + new TargetEndingContent(NegotiationConclusion.REJECT, null, "节能区域信息因站点清单不可用而无法完整澄清")), + TARGET_ACCEPT_URI); + assertTrue(rejection.promptText().contains("## 目标协商结果\nReject")); + assertTrue(rejection.promptText().contains("节能区域信息因站点清单不可用而无法完整澄清")); + assertTrue(rejection.promptText().contains("- round: 1")); + + requester.validateRejectPromptAndDataFilling(rejection.promptText(), parameterSchema(), TARGET_ACCEPT_URI); + assertEquals(1, requesterLlm.callCount()); + + NegotiationContext secondRound = firstRound.nextRound(); + assertEquals(2, secondRound.round()); + assertEquals(1, firstRound.round(), "nextRound must leave the original context unchanged"); + + MetadataContent secondProposal = requester.generateProposeFromData( + new NegotiationProposeData( + secondRound, + new TargetProposeContent( + "已针对节能区域提供澄清,详见<理解对齐与疑问澄清>,请确认", + null, + List.of(new NegotiationItem("节能区域", "confirmed(认同):松山湖")), + null)), + TARGET_PROPOSE_URI); + assertTrue(secondProposal.promptText().contains("## 理解对齐与疑问澄清"), "round 2 must carry the alignment section"); + assertTrue( + !secondProposal.promptText().contains("## 意图理解陈述"), + "round 2 must not carry the intent understanding section"); + assertTrue( + !secondProposal.promptText().contains("## 待澄清内容"), + "an empty clarification list must drop the whole section"); + assertTrue(secondProposal.promptText().contains("- round: 2")); + + MetadataContent acceptance = responder.generateAcceptFromData( + new NegotiationEndingData( + secondRound, + new TargetEndingContent( + NegotiationConclusion.ACCEPT, "最终确认意图:08:00-18:00对松山湖站点启用无线节能优化,速率保障不低于20Mbps", null)), + TARGET_ACCEPT_URI); + assertTrue(acceptance.promptText().contains("## 目标协商结果\nAccept")); + assertTrue(acceptance.promptText().contains("最终确认意图:08:00-18:00对松山湖站点启用无线节能优化")); + assertTrue(acceptance.promptText().contains("- round: 2")); + + FilledParamData terminalParameters = + requester.validateAcceptPromptAndDataFilling(acceptance.promptText(), parameterSchema(), TARGET_ACCEPT_URI); + assertEquals(2, requesterLlm.callCount()); + assertEquals(SESSION_ID, terminalParameters.data().get("id")); + assertEquals(2, terminalParameters.data().get("round")); + assertEquals("08:00-18:00对松山湖站点启用无线节能优化", terminalParameters.data().get("confirmedIntent")); + assertEquals(1, firstRound.round(), "the original context must stay unchanged until the end"); + } + + private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + } + + private static Map parameterSchema() { + return Map.of("type", "object", "properties", Map.of("confirmedIntent", Map.of("type", "string"))); + } + + private static Map nestedParameterSchema() { + Map targetItem = Map.of( + "type", + "object", + "properties", + Map.of("time", Map.of("type", "string"), "rate", Map.of("type", "string"))); + return Map.of( + "type", + "object", + "properties", + Map.of("timeRateTargets", Map.of("type", "array", "items", targetItem))); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java index 680d8f36..490ed58c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java @@ -1,251 +1,251 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -class ComplianceCheckerTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private final DefaultNegotiationComplianceChecker checker = new DefaultNegotiationComplianceChecker(); - - private final Vocabulary zhVocabulary = Vocabulary.forLanguage("zh-CN"); - - private final Vocabulary enVocabulary = Vocabulary.forLanguage("en-US"); - - static List violationCases() { - return List.of( - new Object[] {"non-uuid id", "not-a-uuid", null, null, List.of("id")}, - new Object[] {"blank id", "", null, null, List.of("id")}, - new Object[] {"round zero", null, "0", null, List.of("round")}, - new Object[] {"negative round", null, "-1", null, List.of("round")}, - new Object[] {"zero maxRounds", null, null, "0", List.of("maxRounds")}, - new Object[] {"round above maxRounds", null, "3", "2", List.of("round")}, - new Object[] {"non-integer round", null, "two", null, List.of("round")}, - new Object[] {"non-integer maxRounds", null, null, "many", List.of("maxRounds")}, - new Object[] {"missing id line", "__MISSING__", null, null, List.of("id")}, - new Object[] {"missing round line", null, "__MISSING__", null, List.of("round")}, - new Object[] {"missing maxRounds line", null, null, "__MISSING__", List.of("maxRounds")}); - } - - @Test - void validZhMessagePassesAndContextIsExtracted() { - String prompt = "前置说明,应被丢弃。\n\n" - + "## 协商上下文\n" - + "- id: " + SESSION_ID + "\n" - + "- round: 2\n" - + "- maxRounds: 5\n\n" - + "## 所需信息项\n" - + "1. 节能区域信息:请提供真实存在的区域\n"; - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); - - assertTrue(result.passed()); - assertTrue(result.isNegotiation()); - assertEquals(List.of(), result.errors()); - assertEquals(new NegotiationContext(SESSION_ID, 2, 5), result.context()); - } - - @Test - void validEnMessagePassesAndContextIsExtracted() { - String prompt = "Preamble that must be dropped.\n\n" - + "## Negotiation Context\n" - + "- id: " + SESSION_ID + "\n" - + "- round: 1\n" - + "- maxRounds: 5\n\n" - + "## Required Information Items\n" - + "1. energy saving region: provide a real region\n"; - - NegotiationRuleCheckResult result = checker.check(prompt, enVocabulary); - - assertTrue(result.passed()); - assertTrue(result.isNegotiation()); - assertEquals(List.of(), result.errors()); - assertEquals(new NegotiationContext(SESSION_ID, 1, 5), result.context()); - } - - @ParameterizedTest - @MethodSource("violationCases") - void contextViolationsFailWithTheExpectedSlotNames( - String caseName, String idValue, String roundValue, String maxRoundsValue, List expectedSlots) { - String prompt = zhMessage(idValue, roundValue, maxRoundsValue); - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); - - assertFalse(result.passed(), caseName); - assertTrue(result.isNegotiation(), caseName); - assertNull(result.context(), caseName); - assertEquals(expectedSlots.size(), result.errors().size(), caseName); - for (int index = 0; index < expectedSlots.size(); index++) { - assertEquals(expectedSlots.get(index), result.errors().get(index).slotName(), caseName); - } - } - - @Test - void uuidFormatIsEnforcedBeyondLength() { - String prompt = zhMessage(SESSION_ID.replace('b', 'z'), null, null); - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); - - assertFalse(result.passed()); - assertEquals("id", result.errors().get(0).slotName()); - assertEquals("invalid_uuid", result.errors().get(0).code()); - } - - @Test - void uppercaseHexUuidIsAccepted() { - String prompt = zhMessage(SESSION_ID.toUpperCase(java.util.Locale.ROOT), null, null); - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); - - assertTrue(result.passed()); - assertEquals(0, result.errors().size()); - } - - @Test - void textWithoutContextSectionIsNotANegotiationMessage() { - String prompt = "## 任务目标\n" + "诊断网络故障\n\n" + "## 任务类型\n" + "故障诊断\n"; - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); - - assertFalse(result.isNegotiation()); - assertFalse(result.passed()); - assertEquals(List.of(), result.errors()); - assertNull(result.context()); - } - - @Test - void emptyTextIsNotANegotiationMessage() { - NegotiationRuleCheckResult result = checker.check("", zhVocabulary); - - assertFalse(result.isNegotiation()); - assertEquals(List.of(), result.errors()); - assertNull(result.context()); - } - - @Test - void nullTextIsNotANegotiationMessage() { - NegotiationRuleCheckResult result = checker.check(null, zhVocabulary); - - assertFalse(result.isNegotiation()); - assertEquals(List.of(), result.errors()); - } - - @Test - void preambleOnlyTextIsNotANegotiationMessage() { - NegotiationRuleCheckResult result = checker.check("只是普通文本,没有任何板块标题。", zhVocabulary); - - assertFalse(result.isNegotiation()); - assertEquals(List.of(), result.errors()); - } - - @Test - void englishCheckerDoesNotRecogniseZhContextSection() { - String prompt = zhMessage(null, null, null); - - NegotiationRuleCheckResult result = checker.check(prompt, enVocabulary); - - assertFalse(result.isNegotiation()); - assertEquals(List.of(), result.errors()); - } - - @Test - void checkerDoesNotValidateConclusionValues() { - String prompt = "## 协商上下文\n" - + "- id: " + SESSION_ID + "\n" - + "- round: 1\n" - + "- maxRounds: 5\n\n" - + "## 信息协商结果\n" - + "Abort\n"; - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); - - assertTrue(result.passed()); - assertEquals(List.of(), result.errors()); - } - - @Test - void checkerDoesNotRequireEndingResultContentSection() { - String prompt = "## 协商上下文\n" - + "- id: " + SESSION_ID + "\n" - + "- round: 1\n" - + "- maxRounds: 5\n\n" - + "## 信息协商结果\n" - + "Accept\n"; - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); - - assertTrue(result.passed()); - assertEquals(List.of(), result.errors()); - } - - @Test - void checkerDoesNotEnforceFeasibilitySectionExclusivity() { - String prompt = "## 协商上下文\n" - + "- id: " + SESSION_ID + "\n" - + "- round: 1\n" - + "- maxRounds: 5\n\n" - + "## 待评估内容说明\n" - + "1. 待评估内容\n\n" - + "## 评估不可行时的详情和提案\n" - + "1. 不可行详情\n"; - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); - - assertTrue(result.passed()); - assertEquals(List.of(), result.errors()); - } - - @Test - void resultRecordHasExactlyTheFourPinnedComponents() { - String[] componentNames = java.util.Arrays.stream(NegotiationRuleCheckResult.class.getRecordComponents()) - .map(component -> component.getName()) - .toArray(String[]::new); - - assertEquals(List.of("passed", "isNegotiation", "errors", "context"), List.of(componentNames)); - } - - @Test - void ruleErrorsCarryStructuredDetails() { - String prompt = zhMessage("short", null, null); - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); - - SlotValidationError error = result.errors().get(0); - assertEquals("id", error.slotName()); - assertEquals("invalid_uuid", error.code()); - assertTrue(error.message().contains("UUID")); - } - - private static String zhMessage(String idValue, String roundValue, String maxRoundsValue) { - StringBuilder prompt = new StringBuilder(); - prompt.append("## 协商上下文\n"); - if (!"__MISSING__".equals(idValue)) { - prompt.append("- id:") - .append(idValue == null ? " " + SESSION_ID : " " + idValue) - .append('\n'); - } - if (!"__MISSING__".equals(roundValue)) { - prompt.append("- round:") - .append(roundValue == null ? " 1" : " " + roundValue) - .append('\n'); - } - if (!"__MISSING__".equals(maxRoundsValue)) { - prompt.append("- maxRounds:") - .append(maxRoundsValue == null ? " 5" : " " + maxRoundsValue) - .append('\n'); - } - prompt.append("\n## 所需信息项\n1. 信息项\n"); - return prompt.toString(); - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class ComplianceCheckerTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private final DefaultNegotiationComplianceChecker checker = new DefaultNegotiationComplianceChecker(); + + private final Vocabulary zhVocabulary = Vocabulary.forLanguage("zh-CN"); + + private final Vocabulary enVocabulary = Vocabulary.forLanguage("en-US"); + + static List violationCases() { + return List.of( + new Object[] {"non-uuid id", "not-a-uuid", null, null, List.of("id")}, + new Object[] {"blank id", "", null, null, List.of("id")}, + new Object[] {"round zero", null, "0", null, List.of("round")}, + new Object[] {"negative round", null, "-1", null, List.of("round")}, + new Object[] {"zero maxRounds", null, null, "0", List.of("maxRounds")}, + new Object[] {"round above maxRounds", null, "3", "2", List.of("round")}, + new Object[] {"non-integer round", null, "two", null, List.of("round")}, + new Object[] {"non-integer maxRounds", null, null, "many", List.of("maxRounds")}, + new Object[] {"missing id line", "__MISSING__", null, null, List.of("id")}, + new Object[] {"missing round line", null, "__MISSING__", null, List.of("round")}, + new Object[] {"missing maxRounds line", null, null, "__MISSING__", List.of("maxRounds")}); + } + + @Test + void validZhMessagePassesAndContextIsExtracted() { + String prompt = "前置说明,应被丢弃。\n\n" + + "## 协商上下文\n" + + "- id: " + SESSION_ID + "\n" + + "- round: 2\n" + + "- maxRounds: 5\n\n" + + "## 所需信息项\n" + + "1. 节能区域信息:请提供真实存在的区域\n"; + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertTrue(result.passed()); + assertTrue(result.isNegotiation()); + assertEquals(List.of(), result.errors()); + assertEquals(new NegotiationContext(SESSION_ID, 2, 5), result.context()); + } + + @Test + void validEnMessagePassesAndContextIsExtracted() { + String prompt = "Preamble that must be dropped.\n\n" + + "## Negotiation Context\n" + + "- id: " + SESSION_ID + "\n" + + "- round: 1\n" + + "- maxRounds: 5\n\n" + + "## Required Information Items\n" + + "1. energy saving region: provide a real region\n"; + + NegotiationRuleCheckResult result = checker.check(prompt, enVocabulary); + + assertTrue(result.passed()); + assertTrue(result.isNegotiation()); + assertEquals(List.of(), result.errors()); + assertEquals(new NegotiationContext(SESSION_ID, 1, 5), result.context()); + } + + @ParameterizedTest + @MethodSource("violationCases") + void contextViolationsFailWithTheExpectedSlotNames( + String caseName, String idValue, String roundValue, String maxRoundsValue, List expectedSlots) { + String prompt = zhMessage(idValue, roundValue, maxRoundsValue); + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertFalse(result.passed(), caseName); + assertTrue(result.isNegotiation(), caseName); + assertNull(result.context(), caseName); + assertEquals(expectedSlots.size(), result.errors().size(), caseName); + for (int index = 0; index < expectedSlots.size(); index++) { + assertEquals(expectedSlots.get(index), result.errors().get(index).slotName(), caseName); + } + } + + @Test + void uuidFormatIsEnforcedBeyondLength() { + String prompt = zhMessage(SESSION_ID.replace('b', 'z'), null, null); + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertFalse(result.passed()); + assertEquals("id", result.errors().get(0).slotName()); + assertEquals("invalid_uuid", result.errors().get(0).code()); + } + + @Test + void uppercaseHexUuidIsAccepted() { + String prompt = zhMessage(SESSION_ID.toUpperCase(java.util.Locale.ROOT), null, null); + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertTrue(result.passed()); + assertEquals(0, result.errors().size()); + } + + @Test + void textWithoutContextSectionIsNotANegotiationMessage() { + String prompt = "## 任务目标\n" + "诊断网络故障\n\n" + "## 任务类型\n" + "故障诊断\n"; + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertFalse(result.isNegotiation()); + assertFalse(result.passed()); + assertEquals(List.of(), result.errors()); + assertNull(result.context()); + } + + @Test + void emptyTextIsNotANegotiationMessage() { + NegotiationRuleCheckResult result = checker.check("", zhVocabulary); + + assertFalse(result.isNegotiation()); + assertEquals(List.of(), result.errors()); + assertNull(result.context()); + } + + @Test + void nullTextIsNotANegotiationMessage() { + NegotiationRuleCheckResult result = checker.check(null, zhVocabulary); + + assertFalse(result.isNegotiation()); + assertEquals(List.of(), result.errors()); + } + + @Test + void preambleOnlyTextIsNotANegotiationMessage() { + NegotiationRuleCheckResult result = checker.check("只是普通文本,没有任何板块标题。", zhVocabulary); + + assertFalse(result.isNegotiation()); + assertEquals(List.of(), result.errors()); + } + + @Test + void englishCheckerDoesNotRecogniseZhContextSection() { + String prompt = zhMessage(null, null, null); + + NegotiationRuleCheckResult result = checker.check(prompt, enVocabulary); + + assertFalse(result.isNegotiation()); + assertEquals(List.of(), result.errors()); + } + + @Test + void checkerDoesNotValidateConclusionValues() { + String prompt = "## 协商上下文\n" + + "- id: " + SESSION_ID + "\n" + + "- round: 1\n" + + "- maxRounds: 5\n\n" + + "## 信息协商结果\n" + + "Abort\n"; + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertTrue(result.passed()); + assertEquals(List.of(), result.errors()); + } + + @Test + void checkerDoesNotRequireEndingResultContentSection() { + String prompt = "## 协商上下文\n" + + "- id: " + SESSION_ID + "\n" + + "- round: 1\n" + + "- maxRounds: 5\n\n" + + "## 信息协商结果\n" + + "Accept\n"; + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertTrue(result.passed()); + assertEquals(List.of(), result.errors()); + } + + @Test + void checkerDoesNotEnforceFeasibilitySectionExclusivity() { + String prompt = "## 协商上下文\n" + + "- id: " + SESSION_ID + "\n" + + "- round: 1\n" + + "- maxRounds: 5\n\n" + + "## 待评估内容说明\n" + + "1. 待评估内容\n\n" + + "## 评估不可行时的详情和提案\n" + + "1. 不可行详情\n"; + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + assertTrue(result.passed()); + assertEquals(List.of(), result.errors()); + } + + @Test + void resultRecordHasExactlyTheFourPinnedComponents() { + String[] componentNames = java.util.Arrays.stream(NegotiationRuleCheckResult.class.getRecordComponents()) + .map(component -> component.getName()) + .toArray(String[]::new); + + assertEquals(List.of("passed", "isNegotiation", "errors", "context"), List.of(componentNames)); + } + + @Test + void ruleErrorsCarryStructuredDetails() { + String prompt = zhMessage("short", null, null); + + NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + + SlotValidationError error = result.errors().get(0); + assertEquals("id", error.slotName()); + assertEquals("invalid_uuid", error.code()); + assertTrue(error.message().contains("UUID")); + } + + private static String zhMessage(String idValue, String roundValue, String maxRoundsValue) { + StringBuilder prompt = new StringBuilder(); + prompt.append("## 协商上下文\n"); + if (!"__MISSING__".equals(idValue)) { + prompt.append("- id:") + .append(idValue == null ? " " + SESSION_ID : " " + idValue) + .append('\n'); + } + if (!"__MISSING__".equals(roundValue)) { + prompt.append("- round:") + .append(roundValue == null ? " 1" : " " + roundValue) + .append('\n'); + } + if (!"__MISSING__".equals(maxRoundsValue)) { + prompt.append("- maxRounds:") + .append(maxRoundsValue == null ? " 5" : " " + maxRoundsValue) + .append('\n'); + } + prompt.append("\n## 所需信息项\n1. 信息项\n"); + return prompt.toString(); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java index b5008f95..cd0cbb8d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java @@ -1,219 +1,219 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.junit.jupiter.api.Test; - -class ParamExtractorTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String VALID_ZH_PROMPT = "## 协商上下文\n" - + "- id: " + SESSION_ID + "\n" - + "- round: 2\n" - + "- maxRounds: 5\n\n" - + "## 所需信息项\n" - + "1. 节能区域信息:请提供真实存在的区域\n"; - - private static final NegotiationReference REFERENCE = - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN"); - - private static final int MAX_ATTEMPTS = 1; - - private final StubComplianceChecker complianceChecker = new StubComplianceChecker(); - - private final StubSemanticValidator semanticValidator = new StubSemanticValidator(); - - private final NegotiationRuleCheckerAdapter ruleChecker = - new NegotiationRuleCheckerAdapter(complianceChecker, Vocabulary.forLanguage("zh-CN")); - - private final ParamExtractor extractor = new ParamExtractor(ruleChecker, semanticValidator, MAX_ATTEMPTS); - - @Test - void happyPathMergesContextParamsFirstAndLetsContextWinOnConflict() { - complianceChecker.result = - new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); - semanticValidator.result = new SemanticValidationResult( - true, - "information", - List.of(), - Map.of("id", "llm-value", "confirmed_rate_mbps", 2, "nested", Map.of("a", 1))); - - FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE); - - assertEquals(SESSION_ID, filled.data().get("id")); - assertEquals(2, filled.data().get("round")); - assertEquals(5, filled.data().get("maxRounds")); - assertEquals(2, filled.data().get("confirmed_rate_mbps")); - assertEquals(Map.of("a", 1), filled.data().get("nested")); - assertEquals(5, filled.data().size()); - assertEquals(1, semanticValidator.invocations); - } - - @Test - void extractRequiresIntegerRoundAndMaxRoundsInMergedData() { - complianceChecker.result = - new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 3, 7)); - semanticValidator.result = new SemanticValidationResult(true, "information", List.of(), Map.of()); - - FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE); - - assertTrue(filled.data().get("round") instanceof Integer); - assertTrue(filled.data().get("maxRounds") instanceof Integer); - assertEquals(3, filled.data().get("round")); - assertEquals(7, filled.data().get("maxRounds")); - } - - @Test - void ruleFailureSkipsTheSemanticValidationCall() { - complianceChecker.result = new NegotiationRuleCheckResult( - false, - true, - List.of(new SlotValidationError("round", "out_of_range", "round exceeds maxRounds")), - null); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); - - assertEquals("negotiation_rule_violation", exception.getCode()); - assertEquals(1, exception.getErrors().size()); - assertEquals("round", exception.getErrors().get(0).slotName()); - assertEquals(0, semanticValidator.invocations); - } - - @Test - void extractFailsOnRuleViolationWithoutTouchingTheValidator() { - complianceChecker.result = new NegotiationRuleCheckResult( - false, true, List.of(new SlotValidationError("id", "invalid_uuid", "not a uuid")), null); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); - - assertEquals("negotiation_rule_violation", exception.getCode()); - assertEquals("id", exception.getErrors().get(0).slotName()); - assertEquals(0, semanticValidator.invocations); - } - - @Test - void nonNegotiationInputFailsWithLanguageNeutralMessage() { - complianceChecker.result = new NegotiationRuleCheckResult(false, false, List.of(), null); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> extractor.extract("## 任务目标\n诊断\n", Map.of(), REFERENCE)); - - assertEquals("negotiation_invalid_input", exception.getCode()); - assertEquals( - "missing negotiation context section; for Task-T compliance use checkTaskPrompt", - exception.getMessage()); - assertFalse(exception.getMessage().contains("协商上下文")); - assertEquals(List.of(), exception.getErrors()); - assertEquals(0, semanticValidator.invocations); - } - - @Test - void semanticRejectionPassesErrorsThrough() { - complianceChecker.result = - new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); - List semanticErrors = List.of( - new SlotValidationError("section.target_result_content", "conclusion_content_mismatch", "Mismatch"), - new SlotValidationError("section.context", "invalid_conclusion", "Abort is reserved")); - semanticValidator.result = new SemanticValidationResult(false, "target", semanticErrors, Map.of()); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); - - assertEquals("negotiation_semantic_rejected", exception.getCode()); - assertEquals(semanticErrors, exception.getErrors()); - } - - @Test - void extractReturnsTheValidatorOutcome() { - complianceChecker.result = - new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); - semanticValidator.result = - new SemanticValidationResult(true, "information", List.of(), Map.of("confirmed_rate_mbps", 2)); - - FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE); - - assertEquals(1, semanticValidator.invocations); - assertEquals(2, filled.data().get("confirmed_rate_mbps")); - } - - @Test - void internalValidationFailureIsMappedToRetryableInfrastructureError() { - complianceChecker.result = - new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); - semanticValidator.failure = new NegotiationValidationException("response is missing negotiation_type"); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); - - assertEquals("negotiation_llm_infrastructure_error", exception.getCode()); - assertEquals(1, exception.getErrors().size()); - assertEquals("_llm", exception.getErrors().get(0).slotName()); - assertTrue(exception.getMessage().contains("negotiation_type")); - } - - @Test - void promptResourceMissIsMappedToTemplateNotFound() { - complianceChecker.result = - new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); - semanticValidator.failure = - new ResourceNotFoundException("prompt resource missing", "prompt_resources/prompts/x"); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); - - assertEquals("template_not_found", exception.getCode()); - assertEquals(List.of(), exception.getErrors()); - } - - private static final class StubComplianceChecker implements NegotiationComplianceChecker { - - private NegotiationRuleCheckResult result = new NegotiationRuleCheckResult(false, false, List.of(), null); - - @Override - public NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary) { - return result; - } - } - - private static final class StubSemanticValidator implements NegotiationSemanticValidator { - - private SemanticValidationResult result = new SemanticValidationResult(false, null, List.of(), Map.of()); - - private RuntimeException failure; - - private int invocations; - - @Override - public SemanticValidationResult validateNegotiation( - String prompt, Map callerSchema, NegotiationReference reference) { - invocations++; - if (failure != null) { - throw failure; - } - return result; - } - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +class ParamExtractorTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String VALID_ZH_PROMPT = "## 协商上下文\n" + + "- id: " + SESSION_ID + "\n" + + "- round: 2\n" + + "- maxRounds: 5\n\n" + + "## 所需信息项\n" + + "1. 节能区域信息:请提供真实存在的区域\n"; + + private static final NegotiationReference REFERENCE = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN"); + + private static final int MAX_ATTEMPTS = 1; + + private final StubComplianceChecker complianceChecker = new StubComplianceChecker(); + + private final StubSemanticValidator semanticValidator = new StubSemanticValidator(); + + private final NegotiationRuleCheckerAdapter ruleChecker = + new NegotiationRuleCheckerAdapter(complianceChecker, Vocabulary.forLanguage("zh-CN")); + + private final ParamExtractor extractor = new ParamExtractor(ruleChecker, semanticValidator, MAX_ATTEMPTS); + + @Test + void happyPathMergesContextParamsFirstAndLetsContextWinOnConflict() { + complianceChecker.result = + new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + semanticValidator.result = new SemanticValidationResult( + true, + "information", + List.of(), + Map.of("id", "llm-value", "confirmed_rate_mbps", 2, "nested", Map.of("a", 1))); + + FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE); + + assertEquals(SESSION_ID, filled.data().get("id")); + assertEquals(2, filled.data().get("round")); + assertEquals(5, filled.data().get("maxRounds")); + assertEquals(2, filled.data().get("confirmed_rate_mbps")); + assertEquals(Map.of("a", 1), filled.data().get("nested")); + assertEquals(5, filled.data().size()); + assertEquals(1, semanticValidator.invocations); + } + + @Test + void extractRequiresIntegerRoundAndMaxRoundsInMergedData() { + complianceChecker.result = + new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 3, 7)); + semanticValidator.result = new SemanticValidationResult(true, "information", List.of(), Map.of()); + + FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE); + + assertTrue(filled.data().get("round") instanceof Integer); + assertTrue(filled.data().get("maxRounds") instanceof Integer); + assertEquals(3, filled.data().get("round")); + assertEquals(7, filled.data().get("maxRounds")); + } + + @Test + void ruleFailureSkipsTheSemanticValidationCall() { + complianceChecker.result = new NegotiationRuleCheckResult( + false, + true, + List.of(new SlotValidationError("round", "out_of_range", "round exceeds maxRounds")), + null); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); + + assertEquals("negotiation_rule_violation", exception.getCode()); + assertEquals(1, exception.getErrors().size()); + assertEquals("round", exception.getErrors().get(0).slotName()); + assertEquals(0, semanticValidator.invocations); + } + + @Test + void extractFailsOnRuleViolationWithoutTouchingTheValidator() { + complianceChecker.result = new NegotiationRuleCheckResult( + false, true, List.of(new SlotValidationError("id", "invalid_uuid", "not a uuid")), null); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); + + assertEquals("negotiation_rule_violation", exception.getCode()); + assertEquals("id", exception.getErrors().get(0).slotName()); + assertEquals(0, semanticValidator.invocations); + } + + @Test + void nonNegotiationInputFailsWithLanguageNeutralMessage() { + complianceChecker.result = new NegotiationRuleCheckResult(false, false, List.of(), null); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract("## 任务目标\n诊断\n", Map.of(), REFERENCE)); + + assertEquals("negotiation_invalid_input", exception.getCode()); + assertEquals( + "missing negotiation context section; for Task-T compliance use checkTaskPrompt", + exception.getMessage()); + assertFalse(exception.getMessage().contains("协商上下文")); + assertEquals(List.of(), exception.getErrors()); + assertEquals(0, semanticValidator.invocations); + } + + @Test + void semanticRejectionPassesErrorsThrough() { + complianceChecker.result = + new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + List semanticErrors = List.of( + new SlotValidationError("section.target_result_content", "conclusion_content_mismatch", "Mismatch"), + new SlotValidationError("section.context", "invalid_conclusion", "Abort is reserved")); + semanticValidator.result = new SemanticValidationResult(false, "target", semanticErrors, Map.of()); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); + + assertEquals("negotiation_semantic_rejected", exception.getCode()); + assertEquals(semanticErrors, exception.getErrors()); + } + + @Test + void extractReturnsTheValidatorOutcome() { + complianceChecker.result = + new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + semanticValidator.result = + new SemanticValidationResult(true, "information", List.of(), Map.of("confirmed_rate_mbps", 2)); + + FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE); + + assertEquals(1, semanticValidator.invocations); + assertEquals(2, filled.data().get("confirmed_rate_mbps")); + } + + @Test + void internalValidationFailureIsMappedToRetryableInfrastructureError() { + complianceChecker.result = + new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + semanticValidator.failure = new NegotiationValidationException("response is missing negotiation_type"); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); + + assertEquals("negotiation_llm_infrastructure_error", exception.getCode()); + assertEquals(1, exception.getErrors().size()); + assertEquals("_llm", exception.getErrors().get(0).slotName()); + assertTrue(exception.getMessage().contains("negotiation_type")); + } + + @Test + void promptResourceMissIsMappedToTemplateNotFound() { + complianceChecker.result = + new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + semanticValidator.failure = + new ResourceNotFoundException("prompt resource missing", "prompt_resources/prompts/x"); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); + + assertEquals("template_not_found", exception.getCode()); + assertEquals(List.of(), exception.getErrors()); + } + + private static final class StubComplianceChecker implements NegotiationComplianceChecker { + + private NegotiationRuleCheckResult result = new NegotiationRuleCheckResult(false, false, List.of(), null); + + @Override + public NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary) { + return result; + } + } + + private static final class StubSemanticValidator implements NegotiationSemanticValidator { + + private SemanticValidationResult result = new SemanticValidationResult(false, null, List.of(), Map.of()); + + private RuntimeException failure; + + private int invocations; + + @Override + public SemanticValidationResult validateNegotiation( + String prompt, Map callerSchema, NegotiationReference reference) { + invocations++; + if (failure != null) { + throw failure; + } + return result; + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java index 9b1fa282..021e08ef 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java @@ -18,7 +18,7 @@ import net.openan.a2at.sample.subscribe_incident.client.flow.SampleStreamTerminalStateDecider; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.runtime.helper.NegotiationPayloadMapper; import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java index 5b613357..019aa9ba 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java @@ -15,7 +15,7 @@ import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java index 7f9fd7a6..d9bc9a22 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java @@ -9,7 +9,7 @@ import net.openan.a2at.sample.negotiation.shared.ScenarioData; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; /** * Sample that demonstrates all three fromText negotiation APIs across all three negotiation types. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java index d5a77bff..ffc12ede 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java @@ -13,7 +13,7 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.server.A2ATServer; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java index fa421d81..3602ff7d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java @@ -7,7 +7,7 @@ import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java index 813ba9fb..5c408ab0 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java @@ -5,7 +5,7 @@ import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.server.A2ATServer; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java index db33f672..abf6c516 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java @@ -4,7 +4,7 @@ import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.server.A2ATServer; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java index c1a13c03..37986b67 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java @@ -17,7 +17,7 @@ import net.openan.a2at.sample.subscribe_incident.client.runtime.DefaultSampleClientRuntime; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; import org.a2aproject.sdk.spec.Message; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java index 756c0f52..1daa4667 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -21,7 +21,7 @@ import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.server.A2ATServer; /** diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java index 984519d3..c867b02c 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java @@ -9,7 +9,7 @@ import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationScenario; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.server.A2ATServer; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.agentexecution.RequestContext; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java index f759fc1d..0d73dcbf 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java @@ -7,7 +7,7 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.server.A2ATServer; /** Runs the six Negotiation-T APIs used by the private-line complaint sample. */ diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index 84f22409..63afcd40 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -1,275 +1,275 @@ -package net.openan.a2at.sample.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import java.io.IOException; -import java.lang.reflect.Method; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.server.A2ATServer; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Verifies the output symmetry of the negotiation content layer across the client and the server facade. - * - *

      Both facades expose the eleven negotiation API methods as one symmetric surface and produce, for identical inputs, - * identical generation results: the whole MetadataContent record and the metadata map built from it are equal on both - * sides. The inputs are the fixed golden fixture inputs of the negotiation module, so this test also proves that both - * facades are wired to the same built-in resources. - */ -class NegotiationFacadeOutputSymmetryTest { - - private static final String TEST_MOCK_PROVIDER = "test-symmetry-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); - } - } - - private static final List NEGOTIATION_API_METHODS = List.of( - "generateNegotiationProposePromptFromData", - "generateNegotiationAcceptPromptFromData", - "generateNegotiationRejectPromptFromData", - "generateNegotiationProposePromptFromText", - "generateNegotiationAcceptPromptFromText", - "generateNegotiationRejectPromptFromText", - "getNegotiationPrompts", - "getNegotiationPrompt", - "validateProposePromptAndDataFilling", - "validateAcceptPromptAndDataFilling", - "validateRejectPromptAndDataFilling"); - - @TempDir - Path tempDir; - - @Test - void bothFacadesExposeTheSameElevenMethodSignatures() { - Map clientSurface = negotiationApiSurface(A2ATClient.class); - Map serverSurface = negotiationApiSurface(A2ATServer.class); - - assertEquals(NEGOTIATION_API_METHODS.stream().sorted().toList(), sortedKeys(clientSurface)); - assertEquals(clientSurface, serverSurface); - } - - @Test - void bothFacadesProduceIdenticalMetadataContentForTheSameInput() throws IOException { - for (String language : List.of("zh-CN", "en-US")) { - A2ATClient client = new A2ATClient(envFile(language, "client.env")); - A2ATServer server = new A2ATServer(envFile(language, "server.env")); - - for (SymmetryCase symmetryCase : symmetryCases()) { - MetadataContent clientResult = symmetryCase.generate( - client::generateNegotiationProposePromptFromData, - client::generateNegotiationAcceptPromptFromData, - client::generateNegotiationRejectPromptFromData); - MetadataContent serverResult = symmetryCase.generate( - server::generateNegotiationProposePromptFromData, - server::generateNegotiationAcceptPromptFromData, - server::generateNegotiationRejectPromptFromData); - - assertNotNull(clientResult.promptText()); - assertEquals(clientResult, serverResult, "case " + symmetryCase.label() + " [" + language + "]"); - assertEquals(clientResult.buildMetadataContent(), serverResult.buildMetadataContent()); - assertEquals(symmetryCase.templateUri().uri(), clientResult.templateUri()); - } - } - } - - private Path envFile(String language, String fileName) throws IOException { - Path envFile = tempDir.resolve(language + "-" + fileName); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=%s - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=test-symmetry-mock - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(language)); - return envFile; - } - - private static List sortedKeys(Map surface) { - return surface.keySet().stream().sorted().collect(Collectors.toList()); - } - - private static Map negotiationApiSurface(Class facade) { - Map surface = new LinkedHashMap<>(); - for (Method method : facade.getMethods()) { - if (NEGOTIATION_API_METHODS.contains(method.getName())) { - String parameters = Arrays.stream(method.getParameterTypes()) - .map(Class::getName) - .collect(Collectors.joining(",")); - surface.put( - method.getName(), - parameters + "->" + method.getReturnType().getName()); - } - } - return surface; - } - - /** - * Builds the nine golden fixture cases of the negotiation module: one fixed typed content, context and template URI - * per negotiation type and phase, identical on the client and the server side. - */ - private static List symmetryCases() { - return List.of( - new SymmetryCase( - "information_propose", - NegotiationPhase.PROPOSE, - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, - new NegotiationProposeData( - new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5), - new InformationProposeContent( - List.of( - new NegotiationItem( - "energy-saving area information", "e.g. Songshan Lake"), - new NegotiationItem("VLANId", null)), - "OR"))), - new SymmetryCase( - "target_propose", - NegotiationPhase.PROPOSE, - StandardTemplates.TARGET_NEGOTIATION_PROPOSE, - new NegotiationProposeData( - new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5), - new TargetProposeContent( - "Clarify the intent of the energy-saving task.", - List.of(new NegotiationItem("task intent", "energy-saving optimization")), - null, - List.of(new NegotiationItem("area", "which site is covered"))))), - new SymmetryCase( - "feasibility_propose", - NegotiationPhase.PROPOSE, - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - new NegotiationProposeData( - new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5), - new FeasibilityProposeContent( - "Please assess the adjusted rate target.", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("adjusted target", "rate lowered to 2Mbps")), - null))), - endingCase( - "information_accept", - NegotiationPhase.ACCEPT, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, - new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of(new NegotiationItem("area information", "Songshan Lake")))), - endingCase( - "target_accept", - NegotiationPhase.ACCEPT, - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, - new TargetEndingContent(NegotiationConclusion.ACCEPT, "The confirmed intent.", null)), - endingCase( - "feasibility_accept", - NegotiationPhase.ACCEPT, - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "The target is achievable.")), - endingCase( - "information_reject", - NegotiationPhase.REJECT, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, - new InformationEndingContent( - NegotiationConclusion.REJECT, - List.of(new NegotiationItem("area information", "not available")))), - endingCase( - "target_reject", - NegotiationPhase.REJECT, - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, - new TargetEndingContent(NegotiationConclusion.REJECT, null, "The intent is unclear.")), - endingCase( - "feasibility_reject", - NegotiationPhase.REJECT, - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - new FeasibilityEndingContent(NegotiationConclusion.REJECT, "The target is not achievable."))); - } - - private static SymmetryCase endingCase( - String label, NegotiationPhase phase, TemplateUri templateUri, NegotiationEndingContent content) { - return new SymmetryCase( - label, - phase, - templateUri, - new NegotiationEndingData( - new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5), content)); - } - - /** One symmetry case: a fixed input addressed to one of the three from-data generation methods. */ - private record SymmetryCase(String label, NegotiationPhase phase, TemplateUri templateUri, Object data) { - - MetadataContent generate(ProposeGenerator propose, EndingGenerator accept, EndingGenerator reject) { - return switch (phase) { - case PROPOSE -> propose.generate((NegotiationProposeData) data, templateUri); - case ACCEPT -> accept.generate((NegotiationEndingData) data, templateUri); - case REJECT -> reject.generate((NegotiationEndingData) data, templateUri); - case ABORT -> throw new IllegalArgumentException("The symmetry cases carry no abort phase."); - }; - } - } - - @FunctionalInterface - private interface ProposeGenerator { - - MetadataContent generate(NegotiationProposeData data, TemplateUri templateUri); - } - - @FunctionalInterface - private interface EndingGenerator { - - MetadataContent generate(NegotiationEndingData data, TemplateUri templateUri); - } - - public static final class RecordingClient implements LLMClient { - - private final LLMClientConfig config; - - public RecordingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse("{}", config.model(), Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sample.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies the output symmetry of the negotiation content layer across the client and the server facade. + * + *

      Both facades expose the eleven negotiation API methods as one symmetric surface and produce, for identical inputs, + * identical generation results: the whole MetadataContent record and the metadata map built from it are equal on both + * sides. The inputs are the fixed golden fixture inputs of the negotiation module, so this test also proves that both + * facades are wired to the same built-in resources. + */ +class NegotiationFacadeOutputSymmetryTest { + + private static final String TEST_MOCK_PROVIDER = "test-symmetry-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + private static final List NEGOTIATION_API_METHODS = List.of( + "generateNegotiationProposePromptFromData", + "generateNegotiationAcceptPromptFromData", + "generateNegotiationRejectPromptFromData", + "generateNegotiationProposePromptFromText", + "generateNegotiationAcceptPromptFromText", + "generateNegotiationRejectPromptFromText", + "getNegotiationPrompts", + "getNegotiationPrompt", + "validateProposePromptAndDataFilling", + "validateAcceptPromptAndDataFilling", + "validateRejectPromptAndDataFilling"); + + @TempDir + Path tempDir; + + @Test + void bothFacadesExposeTheSameElevenMethodSignatures() { + Map clientSurface = negotiationApiSurface(A2ATClient.class); + Map serverSurface = negotiationApiSurface(A2ATServer.class); + + assertEquals(NEGOTIATION_API_METHODS.stream().sorted().toList(), sortedKeys(clientSurface)); + assertEquals(clientSurface, serverSurface); + } + + @Test + void bothFacadesProduceIdenticalMetadataContentForTheSameInput() throws IOException { + for (String language : List.of("zh-CN", "en-US")) { + A2ATClient client = new A2ATClient(envFile(language, "client.env")); + A2ATServer server = new A2ATServer(envFile(language, "server.env")); + + for (SymmetryCase symmetryCase : symmetryCases()) { + MetadataContent clientResult = symmetryCase.generate( + client::generateNegotiationProposePromptFromData, + client::generateNegotiationAcceptPromptFromData, + client::generateNegotiationRejectPromptFromData); + MetadataContent serverResult = symmetryCase.generate( + server::generateNegotiationProposePromptFromData, + server::generateNegotiationAcceptPromptFromData, + server::generateNegotiationRejectPromptFromData); + + assertNotNull(clientResult.promptText()); + assertEquals(clientResult, serverResult, "case " + symmetryCase.label() + " [" + language + "]"); + assertEquals(clientResult.buildMetadataContent(), serverResult.buildMetadataContent()); + assertEquals(symmetryCase.templateUri().uri(), clientResult.templateUri()); + } + } + } + + private Path envFile(String language, String fileName) throws IOException { + Path envFile = tempDir.resolve(language + "-" + fileName); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=%s + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-symmetry-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(language)); + return envFile; + } + + private static List sortedKeys(Map surface) { + return surface.keySet().stream().sorted().collect(Collectors.toList()); + } + + private static Map negotiationApiSurface(Class facade) { + Map surface = new LinkedHashMap<>(); + for (Method method : facade.getMethods()) { + if (NEGOTIATION_API_METHODS.contains(method.getName())) { + String parameters = Arrays.stream(method.getParameterTypes()) + .map(Class::getName) + .collect(Collectors.joining(",")); + surface.put( + method.getName(), + parameters + "->" + method.getReturnType().getName()); + } + } + return surface; + } + + /** + * Builds the nine golden fixture cases of the negotiation module: one fixed typed content, context and template URI + * per negotiation type and phase, identical on the client and the server side. + */ + private static List symmetryCases() { + return List.of( + new SymmetryCase( + "information_propose", + NegotiationPhase.PROPOSE, + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, + new NegotiationProposeData( + new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5), + new InformationProposeContent( + List.of( + new NegotiationItem( + "energy-saving area information", "e.g. Songshan Lake"), + new NegotiationItem("VLANId", null)), + "OR"))), + new SymmetryCase( + "target_propose", + NegotiationPhase.PROPOSE, + StandardTemplates.TARGET_NEGOTIATION_PROPOSE, + new NegotiationProposeData( + new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5), + new TargetProposeContent( + "Clarify the intent of the energy-saving task.", + List.of(new NegotiationItem("task intent", "energy-saving optimization")), + null, + List.of(new NegotiationItem("area", "which site is covered"))))), + new SymmetryCase( + "feasibility_propose", + NegotiationPhase.PROPOSE, + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + new NegotiationProposeData( + new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5), + new FeasibilityProposeContent( + "Please assess the adjusted rate target.", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("adjusted target", "rate lowered to 2Mbps")), + null))), + endingCase( + "information_accept", + NegotiationPhase.ACCEPT, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, + new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of(new NegotiationItem("area information", "Songshan Lake")))), + endingCase( + "target_accept", + NegotiationPhase.ACCEPT, + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "The confirmed intent.", null)), + endingCase( + "feasibility_accept", + NegotiationPhase.ACCEPT, + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "The target is achievable.")), + endingCase( + "information_reject", + NegotiationPhase.REJECT, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, + new InformationEndingContent( + NegotiationConclusion.REJECT, + List.of(new NegotiationItem("area information", "not available")))), + endingCase( + "target_reject", + NegotiationPhase.REJECT, + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, + new TargetEndingContent(NegotiationConclusion.REJECT, null, "The intent is unclear.")), + endingCase( + "feasibility_reject", + NegotiationPhase.REJECT, + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + new FeasibilityEndingContent(NegotiationConclusion.REJECT, "The target is not achievable."))); + } + + private static SymmetryCase endingCase( + String label, NegotiationPhase phase, TemplateUri templateUri, NegotiationEndingContent content) { + return new SymmetryCase( + label, + phase, + templateUri, + new NegotiationEndingData( + new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5), content)); + } + + /** One symmetry case: a fixed input addressed to one of the three from-data generation methods. */ + private record SymmetryCase(String label, NegotiationPhase phase, TemplateUri templateUri, Object data) { + + MetadataContent generate(ProposeGenerator propose, EndingGenerator accept, EndingGenerator reject) { + return switch (phase) { + case PROPOSE -> propose.generate((NegotiationProposeData) data, templateUri); + case ACCEPT -> accept.generate((NegotiationEndingData) data, templateUri); + case REJECT -> reject.generate((NegotiationEndingData) data, templateUri); + case ABORT -> throw new IllegalArgumentException("The symmetry cases carry no abort phase."); + }; + } + } + + @FunctionalInterface + private interface ProposeGenerator { + + MetadataContent generate(NegotiationProposeData data, TemplateUri templateUri); + } + + @FunctionalInterface + private interface EndingGenerator { + + MetadataContent generate(NegotiationEndingData data, TemplateUri templateUri); + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 2f35b969..061eba7f 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -1,550 +1,550 @@ -package net.openan.a2at.sdk.server; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; -import net.openan.a2at.sdk.server.compliance.ServerPromptComplianceOrchestrator; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import org.jspecify.annotations.NonNull; - -/** - * High-level server facade for prompt compliance and negotiation APIs. The caller provides the `.env` file path - * explicitly, typically after copying the repository `env.example`. - * - * @since 2026-06 - */ -public final class A2ATServer { - - private final ServerPromptComplianceOrchestrator promptComplianceOrchestrator; - - private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; - - private final NegotiationContentService negotiationContentService; - - private final TemplateQueryService templateQueryService; - - private final ContentValidator taskContentValidator; - - private final ContentValidator notificationContentValidator; - - private final ContentValidator authContentValidator; - - /** - * Creates a server facade from one user-supplied `.env` path. - * - * @param envPath user-supplied `.env` file path - */ - public A2ATServer(Path envPath) { - Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); - A2ATConfig config = - NegotiationContentService.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); - DefaultA2ATServerBuilder builder = - DefaultA2ATServerBuilder.builder().envPath(resolvedEnvPath).config(config); - this.promptComplianceOrchestrator = builder.buildPromptComplianceOrchestrator(); - this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); - this.negotiationContentService = new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); - this.templateQueryService = builder.buildTemplateQueryService(); - this.taskContentValidator = builder.buildTaskContentValidator(); - this.notificationContentValidator = builder.buildNotificationContentValidator(); - this.authContentValidator = builder.buildAuthContentValidator(); - } - - /** - * Checks one processed task prompt for server-side compliance. - * - * @param processedPromptText processed task prompt text - * @return compliance result containing only success state or failure details - */ - public PromptComplianceResult checkTaskPrompt(@NonNull String processedPromptText) { - return promptComplianceOrchestrator.checkTaskPrompt(processedPromptText); - } - - /** - * Starts a new negotiation payload for the requested negotiation type. - * - * @param type negotiation type to initiate - * @param contentText human-readable negotiation message - * @param facts structured facts attached to the payload - * @return transport payload representing the initial negotiation turn - */ - public Map startNegotiation( - @NonNull NegotiationType type, @NonNull String contentText, @NonNull Map facts) { - return negotiationOrchestrator.startNegotiation(type, contentText, facts); - } - - /** - * Processes a received negotiation message using its transport context payload. - * - * @param message received negotiation message - * @param context transport context payload associated with the message - * @return normalized payload describing the receive result - */ - public Map receiveNegotiation(@NonNull String message, @NonNull Map context) { - return negotiationOrchestrator.receiveNegotiation(message, context); - } - - /** - * Continues an existing negotiation with a locally stored context snapshot. - * - * @param context current negotiation context - * @param status next status to emit - * @param contentText continuation message content - * @return transport payload representing the next negotiation turn - */ - public Map continueNegotiation( - @NonNull NegotiationContext context, @NonNull NegotiationStatus status, @NonNull String contentText) { - return negotiationOrchestrator.continueNegotiation(context, status, contentText); - } - - /** - * Generates a propose-phase negotiation message from typed data. - * - *

      This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the - * generator of the negotiation type addressed by the template URI and rendered from that template. - * - * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase - * segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationProposePromptFromData( - @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.generateProposeFromData(data, templateUri); - } - - /** - * Generates an accept-phase negotiation message from typed data. - * - *

      This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its - * phase segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the - * content conclusion is not {@code Accept} - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationAcceptPromptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.generateAcceptFromData(data, templateUri); - } - - /** - * Generates a reject-phase negotiation message from typed data. - * - *

      This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its - * phase segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the - * content conclusion is not {@code Reject} - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationRejectPromptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.generateRejectFromData(data, templateUri); - } - - /** - * Generates an abort negotiation message from typed data. - * - *

      This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed - * template must be the common abort template and the content carries only the termination reason. - * - * @param data typed abort input carrying the negotiation context and the termination reason - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template or the - * termination reason is blank - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationAbortPromptFromData( - @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.generateAbortFromData(data, templateUri); - } - - /** - * Generates a propose-phase negotiation message from free text. - * - *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. - * - * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment - * must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * content contradicts the phase - */ - public MetadataContent generateNegotiationProposePromptFromText( - @NonNull String text, - net.openan.a2at.sdk.negotiation.content.NegotiationContext context, - @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.generateProposeFromText(text, context, templateUri); - } - - /** - * Generates an accept-phase negotiation message from free text. - * - *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Accept}. - * - * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its - * phase segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * conclusion is not {@code Accept} - */ - public MetadataContent generateNegotiationAcceptPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.negotiation.content.NegotiationContext context, - @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.generateAcceptFromText(text, context, templateUri); - } - - /** - * Generates a reject-phase negotiation message from free text. - * - *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Reject}. - * - * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its - * phase segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * conclusion is not {@code Reject} - */ - public MetadataContent generateNegotiationRejectPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.negotiation.content.NegotiationContext context, - @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.generateRejectFromText(text, context, templateUri); - } - - /** - * Generates an abort negotiation message from free text. - * - *

      This variant runs one LLM content-extraction step constrained by the common abort template and then renders - * deterministically like the from-data variant. - * - * @param text free-text input stating the termination reason - * @param context negotiation context injected into the rendered message without any LLM involvement - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses the termination reason, or {@code negotiation_invalid_input} when the text is blank - */ - public MetadataContent generateNegotiationAbortPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.negotiation.content.NegotiationContext context, - @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.generateAbortFromText(text, context, templateUri); - } - - /** - * Lists every template available for the configured language across all A2A-T extensions. - * - *

      This query never throws: the extension directories are discovered from the bundled resource tree itself, so - * templates of extensions added later are included automatically. Templates that exist nowhere for the language - * are skipped and an empty list is returned when no template can be loaded at all. - * - * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when - * none can be loaded - */ - public List getPrompts() { - return templateQueryService.getPrompts(); - } - - /** - * Loads one template by its URI, regardless of the extension. - * - *

      This query never throws: a template that exists nowhere for the configured language returns an empty result - * and logs an actionable warning. - * - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} or - * {@code Task-T/network-layer/energy-saving/v1} - * @return the addressed template, or an empty result when the template does not exist for the configured language - */ - public Optional getPrompt(@NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return templateQueryService.getPrompt(templateUri); - } - - /** - * Lists every negotiation template available for the configured language. - * - *

      This query never throws: templates that exist nowhere for the language are skipped and an empty list is - * returned when no template can be loaded at all. - * - * @return loadable negotiation templates of the configured language, in a fixed type and phase order; empty when - * none can be loaded - */ - public List getNegotiationPrompts() { - return negotiationContentService.getNegotiationPrompts(); - } - - /** - * Loads one negotiation template addressed by its URI. - * - *

      This query never throws: a template that exists nowhere for the configured language returns an empty result - * and logs an actionable warning. - * - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} - * @return the addressed template, or an empty result when the template does not exist for the configured language - */ - public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.getNegotiationPrompt(templateUri); - } - - /** - * Validates a propose-phase negotiation message and extracts its parameters. - * - *

      The pipeline checks the template URI format before any LLM call, runs the deterministic rule gate, then - * performs one semantic validation LLM call (retried on the retryable failure codes) and merges the extracted - * parameters with the rule-level context parameters; context parameters win on conflict. - * - * @param prompt rendered negotiation message text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code propose} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateProposePromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.validateProposePromptAndDataFilling(prompt, schema, templateUri); - } - - /** - * Validates an accept-phase negotiation message and extracts its parameters. - * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected - * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must - * satisfy the accept-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateAcceptPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, schema, templateUri); - } - - /** - * Validates a reject-phase negotiation message and extracts its parameters. - * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected - * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must - * satisfy the reject-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateRejectPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.validateRejectPromptAndDataFilling(prompt, schema, templateUri); - } - - /** - * Validates an abort negotiation message and extracts its parameters. - * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected - * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the - * abort-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws IllegalArgumentException if the prompt is blank or the template URI does not address the common abort - * template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateAbortPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.validateAbortPromptAndDataFilling(prompt, schema, templateUri); - } - - /** - * Validates a task prompt and extracts its parameters. - * - * @param prompt rendered task prompt text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected task template; its prefix segment must be - * {@code Task-T} - * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank - * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code validation_semantic_rejected} when the semantic validation rejects the content, - * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing - */ - public FilledParamData validateTaskPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return taskContentValidator.validate(prompt, schema, templateUri); - } - - /** - * Validates a notification prompt and extracts its parameters. - * - * @param prompt rendered notification prompt text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected notification template; its prefix segment must be - * {@code Notification-T} - * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank - * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code validation_semantic_rejected} when the semantic validation rejects the content, - * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing - */ - public FilledParamData validateNotificationPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return notificationContentValidator.validate(prompt, schema, templateUri); - } - - /** - * Validates an authorization prompt and extracts its parameters. - * - * @param prompt rendered authorization prompt text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected authorization template; its prefix segment must be - * {@code Authorization-T} - * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank - * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code validation_semantic_rejected} when the semantic validation rejects the content, - * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing - */ - public FilledParamData validateAuthPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return authContentValidator.validate(prompt, schema, templateUri); - } - - private static void requireTemplateUri(TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "Template URI must not be null."); - } -} +package net.openan.a2at.sdk.server; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; +import net.openan.a2at.sdk.server.compliance.ServerPromptComplianceOrchestrator; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.jspecify.annotations.NonNull; + +/** + * High-level server facade for prompt compliance and negotiation APIs. The caller provides the `.env` file path + * explicitly, typically after copying the repository `env.example`. + * + * @since 2026-06 + */ +public final class A2ATServer { + + private final ServerPromptComplianceOrchestrator promptComplianceOrchestrator; + + private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; + + private final NegotiationContentService negotiationContentService; + + private final TemplateQueryService templateQueryService; + + private final ContentValidator taskContentValidator; + + private final ContentValidator notificationContentValidator; + + private final ContentValidator authContentValidator; + + /** + * Creates a server facade from one user-supplied `.env` path. + * + * @param envPath user-supplied `.env` file path + */ + public A2ATServer(Path envPath) { + Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); + A2ATConfig config = + NegotiationContentService.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + DefaultA2ATServerBuilder builder = + DefaultA2ATServerBuilder.builder().envPath(resolvedEnvPath).config(config); + this.promptComplianceOrchestrator = builder.buildPromptComplianceOrchestrator(); + this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); + this.negotiationContentService = new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.templateQueryService = builder.buildTemplateQueryService(); + this.taskContentValidator = builder.buildTaskContentValidator(); + this.notificationContentValidator = builder.buildNotificationContentValidator(); + this.authContentValidator = builder.buildAuthContentValidator(); + } + + /** + * Checks one processed task prompt for server-side compliance. + * + * @param processedPromptText processed task prompt text + * @return compliance result containing only success state or failure details + */ + public PromptComplianceResult checkTaskPrompt(@NonNull String processedPromptText) { + return promptComplianceOrchestrator.checkTaskPrompt(processedPromptText); + } + + /** + * Starts a new negotiation payload for the requested negotiation type. + * + * @param type negotiation type to initiate + * @param contentText human-readable negotiation message + * @param facts structured facts attached to the payload + * @return transport payload representing the initial negotiation turn + */ + public Map startNegotiation( + @NonNull NegotiationType type, @NonNull String contentText, @NonNull Map facts) { + return negotiationOrchestrator.startNegotiation(type, contentText, facts); + } + + /** + * Processes a received negotiation message using its transport context payload. + * + * @param message received negotiation message + * @param context transport context payload associated with the message + * @return normalized payload describing the receive result + */ + public Map receiveNegotiation(@NonNull String message, @NonNull Map context) { + return negotiationOrchestrator.receiveNegotiation(message, context); + } + + /** + * Continues an existing negotiation with a locally stored context snapshot. + * + * @param context current negotiation context + * @param status next status to emit + * @param contentText continuation message content + * @return transport payload representing the next negotiation turn + */ + public Map continueNegotiation( + @NonNull NegotiationContext context, @NonNull NegotiationStatus status, @NonNull String contentText) { + return negotiationOrchestrator.continueNegotiation(context, status, contentText); + } + + /** + * Generates a propose-phase negotiation message from typed data. + * + *

      This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the + * generator of the negotiation type addressed by the template URI and rendered from that template. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase + * segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationProposePromptFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.generateProposeFromData(data, templateUri); + } + + /** + * Generates an accept-phase negotiation message from typed data. + * + *

      This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its + * phase segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the + * content conclusion is not {@code Accept} + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationAcceptPromptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.generateAcceptFromData(data, templateUri); + } + + /** + * Generates a reject-phase negotiation message from typed data. + * + *

      This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its + * phase segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the + * content conclusion is not {@code Reject} + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationRejectPromptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.generateRejectFromData(data, templateUri); + } + + /** + * Generates an abort negotiation message from typed data. + * + *

      This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed + * template must be the common abort template and the content carries only the termination reason. + * + * @param data typed abort input carrying the negotiation context and the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template or the + * termination reason is blank + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation_slot_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationAbortPromptFromData( + @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.generateAbortFromData(data, templateUri); + } + + /** + * Generates a propose-phase negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. + * + * @param text free-text input describing the message content + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment + * must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the + * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted + * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted + * content contradicts the phase + */ + public MetadataContent generateNegotiationProposePromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.generateProposeFromText(text, context, templateUri); + } + + /** + * Generates an accept-phase negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Accept}. + * + * @param text free-text input describing the message content + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its + * phase segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the + * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted + * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted + * conclusion is not {@code Accept} + */ + public MetadataContent generateNegotiationAcceptPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.generateAcceptFromText(text, context, templateUri); + } + + /** + * Generates a reject-phase negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Reject}. + * + * @param text free-text input describing the message content + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its + * phase segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the + * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted + * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted + * conclusion is not {@code Reject} + */ + public MetadataContent generateNegotiationRejectPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.generateRejectFromText(text, context, templateUri); + } + + /** + * Generates an abort negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. + * + * @param text free-text input stating the termination reason + * @param context negotiation context injected into the rendered message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template_not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the + * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted + * content misses the termination reason, or {@code negotiation_invalid_input} when the text is blank + */ + public MetadataContent generateNegotiationAbortPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.generateAbortFromText(text, context, templateUri); + } + + /** + * Lists every template available for the configured language across all A2A-T extensions. + * + *

      This query never throws: the extension directories are discovered from the bundled resource tree itself, so + * templates of extensions added later are included automatically. Templates that exist nowhere for the language + * are skipped and an empty list is returned when no template can be loaded at all. + * + * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when + * none can be loaded + */ + public List getPrompts() { + return templateQueryService.getPrompts(); + } + + /** + * Loads one template by its URI, regardless of the extension. + * + *

      This query never throws: a template that exists nowhere for the configured language returns an empty result + * and logs an actionable warning. + * + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} or + * {@code Task-T/network-layer/energy-saving/v1} + * @return the addressed template, or an empty result when the template does not exist for the configured language + */ + public Optional getPrompt(@NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return templateQueryService.getPrompt(templateUri); + } + + /** + * Lists every negotiation template available for the configured language. + * + *

      This query never throws: templates that exist nowhere for the language are skipped and an empty list is + * returned when no template can be loaded at all. + * + * @return loadable negotiation templates of the configured language, in a fixed type and phase order; empty when + * none can be loaded + */ + public List getNegotiationPrompts() { + return negotiationContentService.getNegotiationPrompts(); + } + + /** + * Loads one negotiation template addressed by its URI. + * + *

      This query never throws: a template that exists nowhere for the configured language returns an empty result + * and logs an actionable warning. + * + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} + * @return the addressed template, or an empty result when the template does not exist for the configured language + */ + public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.getNegotiationPrompt(templateUri); + } + + /** + * Validates a propose-phase negotiation message and extracts its parameters. + * + *

      The pipeline checks the template URI format before any LLM call, runs the deterministic rule gate, then + * performs one semantic validation LLM call (retried on the retryable failure codes) and merges the extracted + * parameters with the rule-level context parameters; context parameters win on conflict. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation_invalid_input} when the prompt is not a negotiation message, + * {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateProposePromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.validateProposePromptAndDataFilling(prompt, schema, templateUri); + } + + /** + * Validates an accept-phase negotiation message and extracts its parameters. + * + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must + * satisfy the accept-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation_invalid_input} when the prompt is not a negotiation message, + * {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateAcceptPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, schema, templateUri); + } + + /** + * Validates a reject-phase negotiation message and extracts its parameters. + * + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must + * satisfy the reject-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation_invalid_input} when the prompt is not a negotiation message, + * {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateRejectPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.validateRejectPromptAndDataFilling(prompt, schema, templateUri); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the + * abort-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws IllegalArgumentException if the prompt is blank or the template URI does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation_invalid_input} when the prompt is not a negotiation message, + * {@code negotiation_rule_violation} when the negotiation context violates a rule, + * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, + * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code template_not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateAbortPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return negotiationContentService.validateAbortPromptAndDataFilling(prompt, schema, templateUri); + } + + /** + * Validates a task prompt and extracts its parameters. + * + * @param prompt rendered task prompt text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected task template; its prefix segment must be + * {@code Task-T} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code validation_semantic_rejected} when the semantic validation rejects the content, + * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing + */ + public FilledParamData validateTaskPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return taskContentValidator.validate(prompt, schema, templateUri); + } + + /** + * Validates a notification prompt and extracts its parameters. + * + * @param prompt rendered notification prompt text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected notification template; its prefix segment must be + * {@code Notification-T} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code validation_semantic_rejected} when the semantic validation rejects the content, + * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing + */ + public FilledParamData validateNotificationPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return notificationContentValidator.validate(prompt, schema, templateUri); + } + + /** + * Validates an authorization prompt and extracts its parameters. + * + * @param prompt rendered authorization prompt text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected authorization template; its prefix segment must be + * {@code Authorization-T} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code validation_semantic_rejected} when the semantic validation rejects the content, + * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or + * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing + */ + public FilledParamData validateAuthPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + requireTemplateUri(templateUri); + return authContentValidator.validate(prompt, schema, templateUri); + } + + private static void requireTemplateUri(TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "Template URI must not be null."); + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index 451887cf..e39f8c91 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -1,139 +1,139 @@ -package net.openan.a2at.sdk.server.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.server.A2ATServer; -import org.junit.jupiter.api.Test; - -class A2ATServerNegotiationApiTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); - - @Test - void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - - MetadataContent result = server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("- id: " + UUID)); - assertTrue(result.promptText().contains("协商上下文")); - Map metadata = result.buildMetadataContent(); - assertEquals(2, metadata.size()); - assertEquals(result.promptText(), metadata.get(result.extensionUri())); - assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - } - - @Test - void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("en-US")); - - MetadataContent result = server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("Negotiation Context")); - assertTrue(result.promptText().contains("Required Information Items")); - } - - @Test - void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { - assertEquals( - 7, new A2ATServer(writeEnv("zh-CN")).getNegotiationPrompts().size()); - assertEquals( - 7, new A2ATServer(writeEnv("en-US")).getNegotiationPrompts().size()); - } - - @Test - void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - - MetadataContent result = server.generateNegotiationAbortPromptFromData( - new NegotiationAbortData( - new NegotiationContext(UUID, 5, 5), - new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - StandardTemplates.NEGOTIATION_ABORT); - - assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); - assertTrue(result.promptText().contains("## 协商结果\nAbort")); - assertTrue(result.promptText().contains("## 协商终止原因")); - assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); - } - - @Test - void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - - PromptTemplate template = - server.getNegotiationPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); - assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); - assertFalse(template.content().isBlank()); - } - - @Test - void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - - assertTrue(server.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); - assertTrue(server - .getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) - .isPresent()); - assertFalse(server - .getNegotiationPrompt(TemplateUri.of(StandardTemplates.NEGOTIATION_EXTENSION_NAME, "unknown-negotiation", - "propose")) - .isPresent()); - PromptTemplate template = server.getNegotiationPrompt(INFORMATION_PROPOSE).orElseThrow(); - assertEquals(INFORMATION_PROPOSE, template.templateUri()); - assertFalse(template.content().isBlank()); - } - - private static Path writeEnv(String language) throws IOException { - Path tempDir = Files.createTempDirectory("a2at-server-negotiation-env"); - Path envFile = tempDir.resolve("server.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=%s - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - A2AT_PROMPT_COMPLIANCE_ENABLED=false - """ - .formatted(language)); - return envFile; - } -} +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.Test; + +class A2ATServerNegotiationApiTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + @Test + void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + MetadataContent result = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertTrue(result.promptText().contains("- id: " + UUID)); + assertTrue(result.promptText().contains("协商上下文")); + Map metadata = result.buildMetadataContent(); + assertEquals(2, metadata.size()); + assertEquals(result.promptText(), metadata.get(result.extensionUri())); + assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + } + + @Test + void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("en-US")); + + MetadataContent result = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertTrue(result.promptText().contains("Negotiation Context")); + assertTrue(result.promptText().contains("Required Information Items")); + } + + @Test + void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { + assertEquals( + 7, new A2ATServer(writeEnv("zh-CN")).getNegotiationPrompts().size()); + assertEquals( + 7, new A2ATServer(writeEnv("en-US")).getNegotiationPrompts().size()); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + MetadataContent result = server.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 5, 5), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + StandardTemplates.NEGOTIATION_ABORT); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("## 协商终止原因")); + assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); + } + + @Test + void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + PromptTemplate template = + server.getNegotiationPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + @Test + void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + assertTrue(server.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); + assertTrue(server + .getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .isPresent()); + assertFalse(server + .getNegotiationPrompt(TemplateUri.of(StandardTemplates.NEGOTIATION_EXTENSION_NAME, "unknown-negotiation", + "propose")) + .isPresent()); + PromptTemplate template = server.getNegotiationPrompt(INFORMATION_PROPOSE).orElseThrow(); + assertEquals(INFORMATION_PROPOSE, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + private static Path writeEnv(String language) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-server-negotiation-env"); + Path envFile = tempDir.resolve("server.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=%s + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_PROMPT_COMPLIANCE_ENABLED=false + """ + .formatted(language)); + return envFile; + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index b123c44c..66e744c4 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -1,154 +1,154 @@ -package net.openan.a2at.sdk.server.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Task-T and Negotiation-T coexistence regression: one server instance answers both the Task-T compliance API and the - * negotiation content-layer APIs without interference, and the pre-existing negotiation state machine keeps working - * next to the new APIs. - */ -class TaskPromptNegotiationCoexistenceTest { - - private static final String TEST_MOCK_PROVIDER = "test-coexistence-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); - } - } - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_TEMPLATE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE_TEMPLATE.uri(); - - private static final String TASK_T_MESSAGE = "## 任务类型(Task Type)\n" - + "无线能效优化\n\n" - + "## 任务对象(Task Object)\n" - + "松山湖管委会\n\n" - + "## 任务目标(Task Target)\n" - + "总功耗降低30%\n"; - - @TempDir - Path tempDir; - - @Test - void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - PromptComplianceResult complianceResult = server.checkTaskPrompt(TASK_T_MESSAGE); - - assertFalse(complianceResult.success()); - assertEquals("processed_prompt_parse_error", complianceResult.failure().code()); - assertEquals("prompt_parse", complianceResult.failure().stage()); - - NegotiationParamExtractionException negotiationFailure = assertThrows( - NegotiationParamExtractionException.class, - () -> server.validateProposePromptAndDataFilling( - TASK_T_MESSAGE, - Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - INFORMATION_PROPOSE_TEMPLATE)); - - assertEquals("negotiation_invalid_input", negotiationFailure.getCode()); - assertTrue(negotiationFailure.getMessage().contains("checkTaskPrompt")); - assertFalse(negotiationFailure.getMessage().contains("协商上下文")); - - MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_TEMPLATE); - assertEquals(INFORMATION_PROPOSE_URI, negotiationMessage.templateUri()); - assertTrue(negotiationMessage.promptText().contains("- id: " + UUID)); - } - - @Test - void oldNegotiationStateMachineKeepsWorkingNextToTheContentLayerApis() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - Map started = server.startNegotiation( - NegotiationType.INFORMATION, "需要完整的任务提示词。", Map.of("source", "coexistence-test")); - Map context = (Map) started.get(NegotiationHandler.NEGOTIATION_T_URI_NL); - assertEquals("information", context.get("negotiationType")); - assertEquals("in-progress", context.get("status")); - assertEquals("coexistence-test", ((Map) started.get("facts")).get("source")); - - MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 2, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_TEMPLATE); - assertTrue(negotiationMessage.promptText().contains("- round: 2")); - - Map startedAgain = server.startNegotiation(NegotiationType.TARGET, "请澄清目标。", Map.of()); - assertEquals( - "target", - ((Map) startedAgain.get(NegotiationHandler.NEGOTIATION_T_URI_NL)).get("negotiationType")); - } - - private Path writeEnv() throws IOException { - Path envFile = tempDir.resolve("server.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=test-coexistence-mock - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted()); - return envFile; - } - - public static final class RecordingClient implements LLMClient { - - private final LLMClientConfig config; - - public RecordingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse("{}", config.model(), Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Task-T and Negotiation-T coexistence regression: one server instance answers both the Task-T compliance API and the + * negotiation content-layer APIs without interference, and the pre-existing negotiation state machine keeps working + * next to the new APIs. + */ +class TaskPromptNegotiationCoexistenceTest { + + private static final String TEST_MOCK_PROVIDER = "test-coexistence-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_TEMPLATE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE_TEMPLATE.uri(); + + private static final String TASK_T_MESSAGE = "## 任务类型(Task Type)\n" + + "无线能效优化\n\n" + + "## 任务对象(Task Object)\n" + + "松山湖管委会\n\n" + + "## 任务目标(Task Target)\n" + + "总功耗降低30%\n"; + + @TempDir + Path tempDir; + + @Test + void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + PromptComplianceResult complianceResult = server.checkTaskPrompt(TASK_T_MESSAGE); + + assertFalse(complianceResult.success()); + assertEquals("processed_prompt_parse_error", complianceResult.failure().code()); + assertEquals("prompt_parse", complianceResult.failure().stage()); + + NegotiationParamExtractionException negotiationFailure = assertThrows( + NegotiationParamExtractionException.class, + () -> server.validateProposePromptAndDataFilling( + TASK_T_MESSAGE, + Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), + INFORMATION_PROPOSE_TEMPLATE)); + + assertEquals("negotiation_invalid_input", negotiationFailure.getCode()); + assertTrue(negotiationFailure.getMessage().contains("checkTaskPrompt")); + assertFalse(negotiationFailure.getMessage().contains("协商上下文")); + + MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_TEMPLATE); + assertEquals(INFORMATION_PROPOSE_URI, negotiationMessage.templateUri()); + assertTrue(negotiationMessage.promptText().contains("- id: " + UUID)); + } + + @Test + void oldNegotiationStateMachineKeepsWorkingNextToTheContentLayerApis() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + Map started = server.startNegotiation( + NegotiationType.INFORMATION, "需要完整的任务提示词。", Map.of("source", "coexistence-test")); + Map context = (Map) started.get(NegotiationHandler.NEGOTIATION_T_URI_NL); + assertEquals("information", context.get("negotiationType")); + assertEquals("in-progress", context.get("status")); + assertEquals("coexistence-test", ((Map) started.get("facts")).get("source")); + + MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_TEMPLATE); + assertTrue(negotiationMessage.promptText().contains("- round: 2")); + + Map startedAgain = server.startNegotiation(NegotiationType.TARGET, "请澄清目标。", Map.of()); + assertEquals( + "target", + ((Map) startedAgain.get(NegotiationHandler.NEGOTIATION_T_URI_NL)).get("negotiationType")); + } + + private Path writeEnv() throws IOException { + Path envFile = tempDir.resolve("server.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-coexistence-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted()); + return envFile; + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} From 1d67952250fc5fe6f6e49bdc1344b361c0f24dc3 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 20:47:14 +0800 Subject: [PATCH 132/295] feat(core): carry negotiation context in MetadataContent metadata Add an optional negotiationContext component to MetadataContent together with the 3-arg convenience constructor delegating a null context, so non-negotiation messages keep their two-key metadata shape. The negotiation generation orchestrator now passes its context through, and buildMetadataContent() returns Map carrying, for negotiation messages only, a third negotiationContext key mapped to the nested {id, round, maxRounds} object matching the A2A-T message shape. Contract tests now pin three keys on the negotiation path and two keys elsewhere. Signed-off-by: codetalks-hw --- .../api/A2ATClientNegotiationApiTest.java | 5 ++- .../a2at/sdk/core/model/MetadataContent.java | 41 ++++++++++++++++--- .../sdk/core/model/MetadataContentTest.java | 37 ++++++++++++++--- .../NegotiationGenerationOrchestrator.java | 2 +- .../generation/FromTextLlmPipelineTest.java | 11 ++++- ...NegotiationGenerationOrchestratorTest.java | 11 ++++- .../golden/GoldenFixtureComparisonTest.java | 15 +++++-- .../api/NegotiationV3ApiSurfaceTest.java | 2 +- .../api/A2ATServerNegotiationApiTest.java | 5 ++- 9 files changed, 105 insertions(+), 24 deletions(-) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index f4a02c67..ba4f31e3 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -58,10 +58,11 @@ void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOE assertFalse(result.promptText().isBlank()); assertTrue(result.promptText().contains("- id: " + UUID)); assertTrue(result.promptText().contains("协商上下文")); - Map metadata = result.buildMetadataContent(); - assertEquals(2, metadata.size()); + Map metadata = result.buildMetadataContent(); + assertEquals(3, metadata.size()); assertEquals(result.promptText(), metadata.get(result.extensionUri())); assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(new NegotiationContext(UUID, 1, 5), result.negotiationContext()); } @Test diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java index 3aa4d60b..44d249c1 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java @@ -11,25 +11,54 @@ * @param templateUri URI of the template the message was generated from * @param promptText rendered prompt text * @param extensionUri TMF extension URI under which the message travels in A2A-T metadata + * @param negotiationContext negotiation session context; {@code null} for non-negotiation messages * @since 2026-08 */ -public record MetadataContent(@Nullable String templateUri, @Nullable String promptText, @NonNull String extensionUri) { +public record MetadataContent( + @Nullable String templateUri, + @Nullable String promptText, + @NonNull String extensionUri, + @Nullable NegotiationContext negotiationContext) { /** Metadata key carrying the template URI alongside the message itself. */ public static final String TEMPLATE_URI_METADATA_KEY = "templateUri"; + /** Metadata key carrying the negotiation session context alongside the message itself. */ + public static final String NEGOTIATION_CONTEXT_METADATA_KEY = "negotiationContext"; + + /** + * Builds a metadata content without a negotiation context. + * + * @param templateUri URI of the template the message was generated from + * @param promptText rendered prompt text + * @param extensionUri TMF extension URI under which the message travels in A2A-T metadata + */ + public MetadataContent(@Nullable String templateUri, @Nullable String promptText, @NonNull String extensionUri) { + this(templateUri, promptText, extensionUri, null); + } + /** * Builds the A2A-T metadata map for this generated message. * - *

      The returned map contains exactly two keys in a fixed order: the extension URI mapping to the rendered - * message, and {@code templateUri} mapping to the template URI. Repeated calls return equal maps. + *

      The returned map always contains the extension URI mapping to the rendered message and {@code templateUri} + * mapping to the template URI, in that order. When a negotiation context is present it additionally carries + * {@code negotiationContext} mapping to a nested map with the {@code id}, {@code round}, and {@code maxRounds} + * fields; non-negotiation messages omit the key. Repeated calls return equal maps. * - * @return newly built metadata map with exactly the extension URI and {@code templateUri} keys + * @return newly built metadata map with the extension URI, {@code templateUri}, and, for negotiation messages, + * {@code negotiationContext} keys */ - public @NonNull Map buildMetadataContent() { - Map metadata = new LinkedHashMap<>(); + public @NonNull Map buildMetadataContent() { + Map metadata = new LinkedHashMap<>(); metadata.put(extensionUri, promptText); metadata.put(TEMPLATE_URI_METADATA_KEY, templateUri); + if (negotiationContext != null) { + Map context = new LinkedHashMap<>(); + context.put("id", negotiationContext.id()); + context.put("round", negotiationContext.round()); + context.put("maxRounds", negotiationContext.maxRounds()); + metadata.put(NEGOTIATION_CONTEXT_METADATA_KEY, context); + } return metadata; } } diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java index a8f65951..a419b0c6 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java @@ -13,13 +13,17 @@ class MetadataContentTest { private static final String TEMPLATE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(); + private static final NegotiationContext CONTEXT = + new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5); + @Test - void recordExposesAllThreeComponents() { + void recordExposesAllComponents() { MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message", "https://example/ext"); assertEquals(TEMPLATE_URI, content.templateUri()); assertEquals("rendered message", content.promptText()); assertEquals("https://example/ext", content.extensionUri()); + assertEquals(null, content.negotiationContext()); } @Test @@ -38,10 +42,10 @@ void exposesNoStaticFactoryMethods() { } @Test - void buildMetadataContentReturnsExactlyTwoDeterministicEntries() { + void buildMetadataContentReturnsExactlyTwoDeterministicEntriesWithoutContext() { MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message", "https://example/ext"); - Map metadata = content.buildMetadataContent(); + Map metadata = content.buildMetadataContent(); assertEquals(2, metadata.size()); assertEquals("rendered message", metadata.get(content.extensionUri())); @@ -54,7 +58,7 @@ void buildMetadataContentReturnsExactlyTwoDeterministicEntries() { void buildMetadataContentKeepsFixedKeyOrder() { MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message", "https://example/ext"); - Map metadata = content.buildMetadataContent(); + Map metadata = content.buildMetadataContent(); assertEquals( java.util.List.of(content.extensionUri(), MetadataContent.TEMPLATE_URI_METADATA_KEY), @@ -65,9 +69,32 @@ void buildMetadataContentKeepsFixedKeyOrder() { void buildMetadataContentNeverReturnsNullEvenWithNullFields() { MetadataContent content = new MetadataContent(null, null, "extension-uri"); - Map metadata = content.buildMetadataContent(); + Map metadata = content.buildMetadataContent(); assertEquals(2, metadata.size()); assertTrue(metadata.containsKey(MetadataContent.TEMPLATE_URI_METADATA_KEY)); } + + @Test + void buildMetadataContentCarriesNegotiationContextAsThirdKey() { + MetadataContent content = + new MetadataContent(TEMPLATE_URI, "rendered message", "https://example/ext", CONTEXT); + + Map metadata = content.buildMetadataContent(); + + assertEquals(3, metadata.size()); + assertEquals( + java.util.List.of( + content.extensionUri(), + MetadataContent.TEMPLATE_URI_METADATA_KEY, + MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY), + new java.util.ArrayList<>(metadata.keySet())); + @SuppressWarnings("unchecked") + Map nestedContext = + (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + assertEquals(CONTEXT.id(), nestedContext.get("id")); + assertEquals(CONTEXT.round(), nestedContext.get("round")); + assertEquals(CONTEXT.maxRounds(), nestedContext.get("maxRounds")); + assertEquals(metadata, content.buildMetadataContent()); + } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 8651489e..5f2369f8 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -505,7 +505,7 @@ private MetadataContent completeGeneration( context.round(), context.id()); return new MetadataContent( - reference.uri(), promptText, ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI); + reference.uri(), promptText, ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, context); } private FilledParamData validatePromptAndDataFilling( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 45ce8992..5c17fd3f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -119,10 +119,17 @@ void fromTextMatchesFromDataAndTheGoldenFixtureAfterOneExtractionCall() { assertEquals(fromData.promptText(), fromText.promptText()); assertEquals(readGoldenFixture(goldenCase, ZH_CN), fromText.promptText()); - Map metadata = fromText.buildMetadataContent(); - assertEquals(2, metadata.size()); + Map metadata = fromText.buildMetadataContent(); + assertEquals(3, metadata.size()); assertEquals(fromText.promptText(), metadata.get(fromText.extensionUri())); assertEquals(fromText.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(goldenCase.context(), fromText.negotiationContext()); + @SuppressWarnings("unchecked") + Map nestedContext = + (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + assertEquals(goldenCase.context().id(), nestedContext.get("id")); + assertEquals(goldenCase.context().round(), nestedContext.get("round")); + assertEquals(goldenCase.context().maxRounds(), nestedContext.get("maxRounds")); assertEquals( 2, llm.lastMessages.size(), "the extraction step must be a single system-plus-user message exchange"); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index c3038875..2153b8e2 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -53,10 +53,17 @@ void generatesInformationProposeFromDataInChinese() { assertTrue(result.promptText().contains("协商上下文")); assertTrue(result.promptText().contains("所需信息项")); - Map metadata = result.buildMetadataContent(); - assertEquals(2, metadata.size()); + Map metadata = result.buildMetadataContent(); + assertEquals(3, metadata.size()); assertEquals(result.promptText(), metadata.get(NegotiationHandler.NEGOTIATION_T_URI)); assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(new NegotiationContext(UUID, 1, 5), result.negotiationContext()); + @SuppressWarnings("unchecked") + Map nestedContext = + (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + assertEquals(UUID, nestedContext.get("id")); + assertEquals(1, nestedContext.get("round")); + assertEquals(5, nestedContext.get("maxRounds")); } @Test diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java index 61c724c9..0b87cc44 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java @@ -40,6 +40,8 @@ class GoldenFixtureComparisonTest { private static final String TEMPLATE_URI_METADATA_KEY = MetadataContent.TEMPLATE_URI_METADATA_KEY; + private static final String NEGOTIATION_CONTEXT_METADATA_KEY = MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY; + static Stream goldenCases() { List cases = new ArrayList<>(); for (String language : GoldenInputs.LANGUAGES) { @@ -86,16 +88,23 @@ void rendersByteIdenticalToTheGoldenFixture(GoldenCase goldenCase, String langua @ParameterizedTest(name = "{0} [{1}] metadata contract") @MethodSource("goldenCases") - void buildsTheMetadataMapWithExactlyTwoEntries(GoldenCase goldenCase, String language) { + void buildsTheMetadataMapWithExactlyThreeEntries(GoldenCase goldenCase, String language) { MetadataContent result = render(goldenCase, language); - Map metadata = result.buildMetadataContent(); - assertEquals(2, metadata.size()); + Map metadata = result.buildMetadataContent(); + assertEquals(3, metadata.size()); assertEquals(result.promptText(), metadata.get(EXTENSION_URI)); assertEquals(goldenCase.templateUri(), metadata.get(TEMPLATE_URI_METADATA_KEY)); + assertEquals(goldenCase.context(), result.negotiationContext()); Iterator keyOrder = metadata.keySet().iterator(); assertEquals(EXTENSION_URI, keyOrder.next()); assertEquals(TEMPLATE_URI_METADATA_KEY, keyOrder.next()); + assertEquals(NEGOTIATION_CONTEXT_METADATA_KEY, keyOrder.next()); + @SuppressWarnings("unchecked") + Map nestedContext = (Map) metadata.get(NEGOTIATION_CONTEXT_METADATA_KEY); + assertEquals(goldenCase.context().id(), nestedContext.get("id")); + assertEquals(goldenCase.context().round(), nestedContext.get("round")); + assertEquals(goldenCase.context().maxRounds(), nestedContext.get("maxRounds")); assertEquals(metadata, result.buildMetadataContent()); } diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java index 1539e0d2..a0a81c21 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java @@ -62,7 +62,7 @@ void neitherFacadeCarriesRemovedV2ShapeMethodNames() { @Test void resultRecordsExposeTheirPinnedFieldFaces() { List metadataComponents = componentNames(MetadataContent.class); - assertEquals(List.of("templateUri", "promptText", "extensionUri"), metadataComponents); + assertEquals(List.of("templateUri", "promptText", "extensionUri", "negotiationContext"), metadataComponents); List filledComponents = componentNames(FilledParamData.class); assertEquals(List.of("data"), filledComponents); assertTrue( diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index e39f8c91..42bd9659 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -44,10 +44,11 @@ void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOE assertFalse(result.promptText().isBlank()); assertTrue(result.promptText().contains("- id: " + UUID)); assertTrue(result.promptText().contains("协商上下文")); - Map metadata = result.buildMetadataContent(); - assertEquals(2, metadata.size()); + Map metadata = result.buildMetadataContent(); + assertEquals(3, metadata.size()); assertEquals(result.promptText(), metadata.get(result.extensionUri())); assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(new NegotiationContext(UUID, 1, 5), result.negotiationContext()); } @Test From 7df5f614cf0ff8b70af4675a866eec5924a4961b Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 21:23:32 +0800 Subject: [PATCH 133/295] feat(negotiation): take explicit negotiation context in validate APIs The four validate*PromptAndDataFilling methods on NegotiationContentService, A2ATClient and A2ATServer now take the negotiation context as their second argument, mirroring the from-text generation APIs, and the compliance checker validates the passed context (UUID shape, round within budget) instead of re-parsing a rendered context section. NegotiationRuleCheckResult shrinks to (passed, errors), the rule-checker adapter becomes a per-call bridge carrying the context, and a null context is reported as negotiation_invalid_input. The private-line sample reads the context back from the negotiationContext metadata entry through NegotiationMetadataReader.readContext and feeds it into the validation calls, replacing the round trip through the rendered text. Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 36 +- .../generation/NegotiationContentService.java | 36 +- .../NegotiationGenerationOrchestrator.java | 69 ++- ...otiationGenerationOrchestratorBuilder.java | 431 +++++++++--------- .../DefaultNegotiationComplianceChecker.java | 147 +----- .../NegotiationComplianceChecker.java | 57 ++- .../NegotiationRuleCheckResult.java | 21 +- .../NegotiationRuleCheckerAdapter.java | 34 +- .../validation/ParamExtractor.java | 31 +- .../NegotiationConcurrentMixedLoadTest.java | 10 +- .../NegotiationContextImmutabilityTest.java | 10 +- .../AbortMessageGenerationTest.java | 11 +- .../CustomRootPromptsIgnoredTest.java | 1 + .../generation/FromTextLlmPipelineTest.java | 1 + ...nerationOrchestratorBuilderWiringTest.java | 10 +- ...NegotiationGenerationOrchestratorTest.java | 5 +- .../ValidateAndFillingDataPipelineTest.java | 62 +-- ...ptAndDataFillingSignatureContractTest.java | 20 +- .../NegotiationErrorCodeUsageMatrixTest.java | 15 +- ...NegotiationExceptionTreeRootCatchTest.java | 15 +- .../NegotiationLogEventContractTest.java | 10 +- ...NegotiationPublicExceptionSurfaceTest.java | 6 +- .../BilateralInteropScenarioTest.java | 6 +- .../FeasibilityNegotiationScenarioTest.java | 7 +- .../InformationNegotiationScenarioTest.java | 6 +- .../MaxRoundsExhaustionScenarioTest.java | 7 +- .../TargetNegotiationScenarioTest.java | 9 +- .../validation/ComplianceCheckerTest.java | 196 ++------ .../validation/ParamExtractorTest.java | 74 ++- .../client/NegotiationClientMain.java | 13 +- .../NegotiationQwenEvaluationMain.java | 14 +- .../server/NegotiationAgentExecutor.java | 13 +- .../shared/NegotiationMetadataReader.java | 30 ++ .../shared/NegotiationSampleFlow.java | 18 +- .../service_recovery/FromTextProbe.java | 4 +- .../NegotiationMetadataReaderTest.java | 22 + .../api/NegotiationV3ApiSurfaceTest.java | 8 +- .../openan/a2at/sdk/server/A2ATServer.java | 42 +- .../TaskPromptNegotiationCoexistenceTest.java | 1 + 39 files changed, 715 insertions(+), 793 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index d185d439..f512e65b 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -503,6 +503,8 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * retried up to the configured attempt limit on the retryable LLM infrastructure failure code. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code propose} @@ -517,9 +519,12 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateProposePromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.validateProposePromptAndDataFilling(prompt, schema, templateUri); + return negotiationContentService.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); } /** @@ -530,6 +535,8 @@ public FilledParamData validateProposePromptAndDataFilling( * satisfy the accept-phase semantic constraints. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} @@ -544,9 +551,12 @@ public FilledParamData validateProposePromptAndDataFilling( * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateAcceptPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, schema, templateUri); + return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); } /** @@ -557,6 +567,8 @@ public FilledParamData validateAcceptPromptAndDataFilling( * satisfy the reject-phase semantic constraints. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} @@ -571,9 +583,12 @@ public FilledParamData validateAcceptPromptAndDataFilling( * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateRejectPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.validateRejectPromptAndDataFilling(prompt, schema, templateUri); + return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); } /** @@ -584,6 +599,8 @@ public FilledParamData validateRejectPromptAndDataFilling( * abort-phase semantic constraints. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} * @return filled parameter data carrying the context parameters and the extracted parameters @@ -598,8 +615,11 @@ public FilledParamData validateRejectPromptAndDataFilling( * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateAbortPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.validateAbortPromptAndDataFilling(prompt, schema, templateUri); + return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index 9d4b5d16..0258edd2 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -257,6 +257,8 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * Validates a propose-phase negotiation message and extracts its parameters. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI whose phase segment must be {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters @@ -268,14 +270,19 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * fails */ public FilledParamData validateProposePromptAndDataFilling( - String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return orchestrator.validateProposePromptAndDataFilling(prompt, schema, templateUri); + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); } /** * Validates an accept-phase negotiation message and extracts its parameters. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters @@ -287,14 +294,19 @@ public FilledParamData validateProposePromptAndDataFilling( * fails */ public FilledParamData validateAcceptPromptAndDataFilling( - String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return orchestrator.validateAcceptPromptAndDataFilling(prompt, schema, templateUri); + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); } /** * Validates a reject-phase negotiation message and extracts its parameters. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters @@ -306,14 +318,19 @@ public FilledParamData validateAcceptPromptAndDataFilling( * fails */ public FilledParamData validateRejectPromptAndDataFilling( - String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return orchestrator.validateRejectPromptAndDataFilling(prompt, schema, templateUri); + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); } /** * Validates an abort negotiation message and extracts its parameters. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} * @return filled parameter data carrying the context parameters and the extracted parameters @@ -322,7 +339,10 @@ public FilledParamData validateRejectPromptAndDataFilling( * @throws NegotiationParamExtractionException when the validation pipeline fails */ public FilledParamData validateAbortPromptAndDataFilling( - String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return orchestrator.validateAbortPromptAndDataFilling(prompt, schema, templateUri); + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 5f2369f8..3def16f0 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -339,95 +339,118 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * the rule-level context parameters; context parameters win on conflict. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the schema or the template URI is null * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is - * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the context is + * null and the message is therefore not a negotiation message, {@code negotiation_rule_violation} when the + * negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateProposePromptAndDataFilling( - String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.PROPOSE); + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPhase.PROPOSE); } /** * Validates an accept-phase negotiation message and extracts its parameters. * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, + * TemplateUri)} with the expected * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must * satisfy the accept-phase semantic constraints. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the schema or the template URI is null * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is - * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the context is + * null and the message is therefore not a negotiation message, {@code negotiation_rule_violation} when the + * negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateAcceptPromptAndDataFilling( - String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.ACCEPT); + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPhase.ACCEPT); } /** * Validates a reject-phase negotiation message and extracts its parameters. * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, + * TemplateUri)} with the expected * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must * satisfy the reject-phase semantic constraints. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the schema or the template URI is null * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is - * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the context is + * null and the message is therefore not a negotiation message, {@code negotiation_rule_violation} when the + * negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateRejectPromptAndDataFilling( - String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.REJECT); + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPhase.REJECT); } /** * Validates an abort negotiation message and extracts its parameters. * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, + * TemplateUri)} with the expected * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the * abort-phase semantic constraints. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the schema or the template URI is null * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is - * not a negotiation message, {@code negotiation_rule_violation} when the negotiation context violates a rule, + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the context is + * null and the message is therefore not a negotiation message, {@code negotiation_rule_violation} when the + * negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateAbortPromptAndDataFilling( - String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, schema, templateUri, NegotiationPhase.ABORT); + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPhase.ABORT); } private MetadataContent generateFromData( @@ -509,11 +532,15 @@ private MetadataContent completeGeneration( } private FilledParamData validatePromptAndDataFilling( - String prompt, Map schema, TemplateUri templateUri, NegotiationPhase phase) { + String prompt, + NegotiationContext context, + Map schema, + TemplateUri templateUri, + NegotiationPhase phase) { Objects.requireNonNull(schema, "Parameter schema must not be null."); NegotiationReference reference = requireReference(templateUri, phase); try { - return paramExtractor.extract(prompt, schema, reference); + return paramExtractor.extract(prompt, context, schema, reference); } catch (NegotiationParamExtractionException failure) { logger.atWarn() .log( diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index 28aae60d..b9d28f1f 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -1,217 +1,214 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.nio.file.Path; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; -import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationSemanticValidator; -import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; -import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckerAdapter; -import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; -import net.openan.a2at.sdk.negotiation.validation.NegotiationValidationException; -import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; -import org.slf4j.Logger; - -/** - * Fluent builder assembling one {@link NegotiationGenerationOrchestrator}. - * - *

      The language is required. The LLM client is optional: without one, the from-data generation still works, while - * the LLM steps of the from-text generation and the validation pipeline fail with the - * {@code negotiation_llm_infrastructure_error} code. Every collaborator has a default implementation wired from the - * language and the optional local resource root (templates and negotiation vocabulary), and each of them can be - * overridden for testing or customization. - * - * @since 2026-08 - */ -public final class NegotiationGenerationOrchestratorBuilder { - - private static final int DEFAULT_MAX_ATTEMPTS = 3; - - private String language; - - private String localRootDir; - - private LLMClient llmClient; - - private int maxAttempts = DEFAULT_MAX_ATTEMPTS; - - private NegotiationTemplateLoader templateLoader; - - private NegotiationContentExtractor contentExtractor; - - private NegotiationComplianceChecker complianceChecker; - - private NegotiationSemanticValidator semanticValidator; - - private Logger logger; - - private NegotiationGenerationOrchestratorBuilder() {} - - /** - * Creates one new builder instance. - * - * @return empty orchestrator builder - */ - public static NegotiationGenerationOrchestratorBuilder builder() { - return new NegotiationGenerationOrchestratorBuilder(); - } - - /** - * Configures the language of the generated and validated messages. - * - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder language(String language) { - this.language = language; - return this; - } - - /** - * Configures the optional local prompt resource root containing the {@code templates/} and - * {@code negotiation-vocabulary/} trees. - * - * @param localRootDir local prompt resource root; null or blank disables local template and vocabulary overrides - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder localRootDir(String localRootDir) { - this.localRootDir = localRootDir; - return this; - } - - /** - * Configures the LLM client used by the LLM steps of the pipelines. - * - * @param llmClient LLM client; null keeps the LLM steps unavailable while the deterministic steps keep working - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder llmClient(LLMClient llmClient) { - this.llmClient = llmClient; - return this; - } - - /** - * Configures how often one LLM step is attempted before its failure is surfaced. - * - * @param maxAttempts maximum number of attempts per LLM step, at least 1 - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder maxAttempts(int maxAttempts) { - this.maxAttempts = maxAttempts; - return this; - } - - /** - * Overrides the negotiation template loader. - * - * @param templateLoader loader resolving template references to templates - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder templateLoader(NegotiationTemplateLoader templateLoader) { - this.templateLoader = templateLoader; - return this; - } - - /** - * Overrides the content extractor used by the from-text generation. - * - * @param contentExtractor extractor turning free text into typed negotiation content - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder contentExtractor(NegotiationContentExtractor contentExtractor) { - this.contentExtractor = contentExtractor; - return this; - } - - /** - * Overrides the rule-level compliance checker used by the validation pipeline. - * - * @param complianceChecker deterministic rule gate of the validation pipeline - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder complianceChecker(NegotiationComplianceChecker complianceChecker) { - this.complianceChecker = complianceChecker; - return this; - } - - /** - * Overrides the LLM-backed semantic validator used by the validation pipeline. - * - * @param semanticValidator semantic validator producing the verdict and the extracted parameters - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder semanticValidator(NegotiationSemanticValidator semanticValidator) { - this.semanticValidator = semanticValidator; - return this; - } - - /** - * Overrides the logger of the assembled orchestrator. - * - * @param logger logger receiving the pipeline events; null falls back to the default class logger - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder logger(Logger logger) { - this.logger = logger; - return this; - } - - /** - * Assembles the orchestrator from the configured inputs. - * - * @return assembled negotiation generation orchestrator - * @throws IllegalStateException if the language is missing or the attempt limit is below 1 - * @throws IllegalArgumentException if the language has no bundled vocabulary - */ - public NegotiationGenerationOrchestrator build() { - if (language == null || language.isBlank()) { - throw new IllegalStateException("Negotiation language must be configured."); - } - if (maxAttempts < 1) { - throw new IllegalStateException( - "Negotiation LLM max attempts must be at least 1 but was " + maxAttempts + "."); - } - Path localVocabularyRoot = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); - Vocabulary vocabulary = Vocabulary.forLanguage(language, localVocabularyRoot); - NegotiationTemplateLoader effectiveTemplateLoader = - templateLoader != null ? templateLoader : new DefaultNegotiationTemplateLoader(language, localRootDir); - NegotiationContentExtractor effectiveContentExtractor = - contentExtractor != null ? contentExtractor : new DefaultNegotiationContentExtractor(llmClient); - NegotiationComplianceChecker effectiveComplianceChecker = - complianceChecker != null ? complianceChecker : new DefaultNegotiationComplianceChecker(); - NegotiationSemanticValidator effectiveSemanticValidator = - semanticValidator != null ? semanticValidator : defaultSemanticValidator(); - NegotiationRuleCheckerAdapter ruleCheckerAdapter = - new NegotiationRuleCheckerAdapter(effectiveComplianceChecker, vocabulary); - ParamExtractor paramExtractor = - new ParamExtractor(ruleCheckerAdapter, effectiveSemanticValidator, maxAttempts); - return new NegotiationGenerationOrchestrator( - language, - maxAttempts, - effectiveTemplateLoader, - effectiveContentExtractor, - paramExtractor, - new NegotiationGeneratorRegistry(), - vocabulary, - logger); - } - - /** - * Builds the default semantic validator, wiring the schema builder of the generation package into the - * validation-package schema seam. - * - * @return default LLM-backed semantic validator, or a validator that fails every call when no LLM client is - * configured - */ - private NegotiationSemanticValidator defaultSemanticValidator() { - if (llmClient == null) { - return (prompt, callerSchema, reference) -> { - throw new NegotiationValidationException( - "Semantic validation requires an LLM client but none is configured."); - }; - } - return new DefaultNegotiationSemanticValidator(llmClient); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.nio.file.Path; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; +import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationSemanticValidator; +import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; +import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; +import net.openan.a2at.sdk.negotiation.validation.NegotiationValidationException; +import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; +import org.slf4j.Logger; + +/** + * Fluent builder assembling one {@link NegotiationGenerationOrchestrator}. + * + *

      The language is required. The LLM client is optional: without one, the from-data generation still works, while + * the LLM steps of the from-text generation and the validation pipeline fail with the + * {@code negotiation_llm_infrastructure_error} code. Every collaborator has a default implementation wired from the + * language and the optional local resource root (templates and negotiation vocabulary), and each of them can be + * overridden for testing or customization. + * + * @since 2026-08 + */ +public final class NegotiationGenerationOrchestratorBuilder { + + private static final int DEFAULT_MAX_ATTEMPTS = 3; + + private String language; + + private String localRootDir; + + private LLMClient llmClient; + + private int maxAttempts = DEFAULT_MAX_ATTEMPTS; + + private NegotiationTemplateLoader templateLoader; + + private NegotiationContentExtractor contentExtractor; + + private NegotiationComplianceChecker complianceChecker; + + private NegotiationSemanticValidator semanticValidator; + + private Logger logger; + + private NegotiationGenerationOrchestratorBuilder() {} + + /** + * Creates one new builder instance. + * + * @return empty orchestrator builder + */ + public static NegotiationGenerationOrchestratorBuilder builder() { + return new NegotiationGenerationOrchestratorBuilder(); + } + + /** + * Configures the language of the generated and validated messages. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder language(String language) { + this.language = language; + return this; + } + + /** + * Configures the optional local prompt resource root containing the {@code templates/} and + * {@code negotiation-vocabulary/} trees. + * + * @param localRootDir local prompt resource root; null or blank disables local template and vocabulary overrides + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder localRootDir(String localRootDir) { + this.localRootDir = localRootDir; + return this; + } + + /** + * Configures the LLM client used by the LLM steps of the pipelines. + * + * @param llmClient LLM client; null keeps the LLM steps unavailable while the deterministic steps keep working + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder llmClient(LLMClient llmClient) { + this.llmClient = llmClient; + return this; + } + + /** + * Configures how often one LLM step is attempted before its failure is surfaced. + * + * @param maxAttempts maximum number of attempts per LLM step, at least 1 + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder maxAttempts(int maxAttempts) { + this.maxAttempts = maxAttempts; + return this; + } + + /** + * Overrides the negotiation template loader. + * + * @param templateLoader loader resolving template references to templates + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder templateLoader(NegotiationTemplateLoader templateLoader) { + this.templateLoader = templateLoader; + return this; + } + + /** + * Overrides the content extractor used by the from-text generation. + * + * @param contentExtractor extractor turning free text into typed negotiation content + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder contentExtractor(NegotiationContentExtractor contentExtractor) { + this.contentExtractor = contentExtractor; + return this; + } + + /** + * Overrides the rule-level compliance checker used by the validation pipeline. + * + * @param complianceChecker deterministic rule gate of the validation pipeline + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder complianceChecker(NegotiationComplianceChecker complianceChecker) { + this.complianceChecker = complianceChecker; + return this; + } + + /** + * Overrides the LLM-backed semantic validator used by the validation pipeline. + * + * @param semanticValidator semantic validator producing the verdict and the extracted parameters + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder semanticValidator(NegotiationSemanticValidator semanticValidator) { + this.semanticValidator = semanticValidator; + return this; + } + + /** + * Overrides the logger of the assembled orchestrator. + * + * @param logger logger receiving the pipeline events; null falls back to the default class logger + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder logger(Logger logger) { + this.logger = logger; + return this; + } + + /** + * Assembles the orchestrator from the configured inputs. + * + * @return assembled negotiation generation orchestrator + * @throws IllegalStateException if the language is missing or the attempt limit is below 1 + * @throws IllegalArgumentException if the language has no bundled vocabulary + */ + public NegotiationGenerationOrchestrator build() { + if (language == null || language.isBlank()) { + throw new IllegalStateException("Negotiation language must be configured."); + } + if (maxAttempts < 1) { + throw new IllegalStateException( + "Negotiation LLM max attempts must be at least 1 but was " + maxAttempts + "."); + } + Path localVocabularyRoot = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); + Vocabulary vocabulary = Vocabulary.forLanguage(language, localVocabularyRoot); + NegotiationTemplateLoader effectiveTemplateLoader = + templateLoader != null ? templateLoader : new DefaultNegotiationTemplateLoader(language, localRootDir); + NegotiationContentExtractor effectiveContentExtractor = + contentExtractor != null ? contentExtractor : new DefaultNegotiationContentExtractor(llmClient); + NegotiationComplianceChecker effectiveComplianceChecker = + complianceChecker != null ? complianceChecker : new DefaultNegotiationComplianceChecker(); + NegotiationSemanticValidator effectiveSemanticValidator = + semanticValidator != null ? semanticValidator : defaultSemanticValidator(); + ParamExtractor paramExtractor = + new ParamExtractor(effectiveComplianceChecker, effectiveSemanticValidator, maxAttempts); + return new NegotiationGenerationOrchestrator( + language, + maxAttempts, + effectiveTemplateLoader, + effectiveContentExtractor, + paramExtractor, + new NegotiationGeneratorRegistry(), + vocabulary, + logger); + } + + /** + * Builds the default semantic validator, wiring the schema builder of the generation package into the + * validation-package schema seam. + * + * @return default LLM-backed semantic validator, or a validator that fails every call when no LLM client is + * configured + */ + private NegotiationSemanticValidator defaultSemanticValidator() { + if (llmClient == null) { + return (prompt, callerSchema, reference) -> { + throw new NegotiationValidationException( + "Semantic validation requires an LLM client but none is configured."); + }; + } + return new DefaultNegotiationSemanticValidator(llmClient); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java index a71168ae..e307563c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java @@ -6,20 +6,17 @@ import java.util.regex.Pattern; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Default rule-level compliance checker for rendered negotiation messages. + * Default rule-level compliance checker for negotiation messages. * - *

      The text is split on lines starting with {@code ## }; anything before the first section title is discarded. A text - * is recognised as a negotiation message when one section title equals the vocabulary's negotiation context section - * title. For a recognised message the checker parses the {@code - id:}, {@code - round:} and {@code - maxRounds:} list - * lines of that section and enforces that the id is a UUID of 36 characters in 8-4-4-4-12 hexadecimal form, that the - * round and the round budget are integers of at least 1, and that the round does not exceed the budget. No other rules - * are applied: the checker does not infer the negotiation type, does not validate conclusion values, does not require - * ending result sections and does not check conditional-section exclusivity. + *

      The checker validates the negotiation context carried alongside the message in the A2A-T metadata: the id must be + * a UUID of 36 characters in 8-4-4-4-12 hexadecimal form and the round must not exceed the round budget. The + * positive-integer shape of the round fields is already guaranteed by the {@link NegotiationContext} constructor. No + * other rules are applied: the checker does not infer the negotiation type, does not validate conclusion values, does + * not require ending result sections and does not check conditional-section exclusivity. * * @since 2026-08 */ @@ -30,65 +27,36 @@ public final class DefaultNegotiationComplianceChecker implements NegotiationCom private static final Pattern UUID_PATTERN = Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); - private static final String SECTION_PREFIX = "## "; - private static final String ID_SLOT = "id"; private static final String ROUND_SLOT = "round"; - private static final String MAX_ROUNDS_SLOT = "maxRounds"; - /** Creates the default checker. */ public DefaultNegotiationComplianceChecker() {} /** - * Runs the rule-level compliance check of one rendered message. + * Runs the rule-level compliance check of one negotiation context. * - * @param prompt rendered negotiation message text; a null or empty text is reported as not being a negotiation - * message - * @param vocabulary vocabulary supplying the language-specific section titles of the message - * @return rule check outcome: not a negotiation message when the context section is absent, otherwise the parsed - * context with no errors when every context rule holds, otherwise the context rule errors without a context - * @throws NullPointerException if the vocabulary is null + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @return rule check outcome with no errors when every context rule holds, otherwise the context rule errors + * @throws NullPointerException if the context is null */ @Override - public NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary) { - Objects.requireNonNull(vocabulary, "vocabulary"); - List contextBody = contextSectionBody(prompt, vocabulary.get("section.context")); - if (contextBody == null) { - return logResult(new NegotiationRuleCheckResult(false, false, List.of(), null)); - } - - String id = stringValue(contextBody, "- id:"); - String roundText = stringValue(contextBody, "- round:"); - String maxRoundsText = stringValue(contextBody, "- maxRounds:"); - Integer round = integerValue(roundText); - Integer maxRounds = integerValue(maxRoundsText); - + public NegotiationRuleCheckResult check(NegotiationContext context) { + Objects.requireNonNull(context, "context"); List errors = new ArrayList<>(); - collectIdErrors(id, errors); - collectRangeErrors(ROUND_SLOT, round, roundText, errors); - collectRangeErrors(MAX_ROUNDS_SLOT, maxRounds, maxRoundsText, errors); - if (round != null && maxRounds != null && round >= 1 && maxRounds >= 1 && round > maxRounds) { + collectIdErrors(context.id(), errors); + if (context.round() > context.maxRounds()) { errors.add(new SlotValidationError( ROUND_SLOT, "out_of_range", - "Negotiation context round " + round + " must not exceed maxRounds " + maxRounds + ".")); - } - - if (errors.isEmpty()) { - NegotiationContext context = new NegotiationContext(id, round, maxRounds); - return logResult(new NegotiationRuleCheckResult(true, true, List.of(), context)); + "Negotiation context round " + context.round() + " must not exceed maxRounds " + + context.maxRounds() + ".")); } - return logResult(new NegotiationRuleCheckResult(false, true, List.copyOf(errors), null)); + return logResult(new NegotiationRuleCheckResult(errors.isEmpty(), List.copyOf(errors))); } private static void collectIdErrors(String id, List errors) { - if (id == null) { - errors.add(new SlotValidationError( - ID_SLOT, "missing_field", "Negotiation context section is missing the id line.")); - return; - } if (!UUID_PATTERN.matcher(id).matches()) { errors.add(new SlotValidationError( ID_SLOT, @@ -98,30 +66,6 @@ private static void collectIdErrors(String id, List errors) } } - private static void collectRangeErrors( - String slotName, Integer value, String valueText, List errors) { - if (value == null) { - if (valueText == null) { - errors.add(new SlotValidationError( - slotName, - "missing_field", - "Negotiation context section is missing the " + slotName + " line.")); - } else { - errors.add(new SlotValidationError( - slotName, - "invalid_value", - "Negotiation context " + slotName + " must be an integer but was '" + valueText + "'.")); - } - return; - } - if (value < 1) { - errors.add(new SlotValidationError( - slotName, - "out_of_range", - "Negotiation context " + slotName + " must be a positive integer but was " + value + ".")); - } - } - /** * Emits the rule-check completion event of one check outcome. * @@ -140,61 +84,4 @@ private static NegotiationRuleCheckResult logResult(NegotiationRuleCheckResult r } return result; } - - /** - * Returns the body lines of the negotiation context section, or null when the text has no such section. - * - *

      Content before the first section title is discarded entirely, whether or not a context section follows. - */ - private static List contextSectionBody(String prompt, String contextTitle) { - if (prompt == null || prompt.isEmpty()) { - return null; - } - boolean inContextSection = false; - List bodyLines = new ArrayList<>(); - for (String rawLine : prompt.split("\n", -1)) { - String line = rawLine.strip(); - if (line.startsWith(SECTION_PREFIX)) { - String title = line.substring(SECTION_PREFIX.length()).strip(); - if (title.equals(contextTitle)) { - if (inContextSection) { - break; - } - inContextSection = true; - continue; - } - if (inContextSection) { - break; - } - continue; - } - if (inContextSection) { - bodyLines.add(line); - } - } - return inContextSection ? bodyLines : null; - } - - /** - * Returns the stripped value of the first list line carrying the given prefix, or null when no such line exists. - */ - private static String stringValue(List bodyLines, String linePrefix) { - for (String line : bodyLines) { - if (line.startsWith(linePrefix)) { - return line.substring(linePrefix.length()).strip(); - } - } - return null; - } - - private static Integer integerValue(String valueText) { - if (valueText == null) { - return null; - } - try { - return Integer.valueOf(valueText); - } catch (NumberFormatException error) { - return null; - } - } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java index 3eec9930..ba6641e8 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java @@ -1,31 +1,26 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import net.openan.a2at.sdk.negotiation.content.Vocabulary; - -/** - * Rule-level compliance checker for rendered negotiation messages. - * - *

      The checker is deterministic and never calls an LLM. It splits the text into {@code ## } sections, recognises - * whether the text is a negotiation message by the presence of the negotiation context section, and validates only the - * strong constraints of that section: the id must be a UUID in 8-4-4-4-12 hexadecimal form, the round and the round - * budget must be positive integers, and the round must not exceed the budget. Type inference, conclusion values, - * ending-section presence and conditional-section exclusivity are deliberately not checked here; they belong to the - * semantic validation step. - * - * @since 2026-08 - */ -public interface NegotiationComplianceChecker { - - /** - * Runs the rule-level compliance check of one rendered message. - * - * @param prompt rendered negotiation message text; a null or empty text is reported as not being a negotiation - * message - * @param vocabulary vocabulary supplying the language-specific section titles of the message - * @return rule check outcome carrying the pass flag, the negotiation recognition flag, the structured errors and - * the parsed context - * @throws NullPointerException if the vocabulary is null - */ - NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary); - -} +package net.openan.a2at.sdk.negotiation.validation; + +import net.openan.a2at.sdk.core.model.NegotiationContext; + +/** + * Rule-level compliance checker for negotiation messages. + * + *

      The checker is deterministic and never calls an LLM. It validates only the strong constraints of the negotiation + * context carried alongside the message: the id must be a UUID in 8-4-4-4-12 hexadecimal form and the round must not + * exceed the round budget. The positive-integer shape of the round fields is already guaranteed by the + * {@link NegotiationContext} constructor. Type inference, conclusion values, ending-section presence and + * conditional-section exclusivity are deliberately not checked here; they belong to the semantic validation step. + * + * @since 2026-08 + */ +public interface NegotiationComplianceChecker { + + /** + * Runs the rule-level compliance check of one negotiation context. + * + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @return rule check outcome carrying the pass flag and the structured errors + * @throws NullPointerException if the context is null + */ + NegotiationRuleCheckResult check(NegotiationContext context); +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java index c70f3b6b..882c2c8b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java @@ -2,27 +2,18 @@ import java.util.List; import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.NegotiationContext; /** - * Outcome of the rule-level compliance check of a rendered negotiation message. + * Outcome of the rule-level compliance check of a negotiation message. * - *

      The checker only validates the negotiation context section, so this result carries exactly four components: the - * overall pass flag, the flag telling whether the text is a negotiation message at all, the structured rule errors and - * the parsed negotiation context. Nothing else is inferred or checked here. + *

      The checker only validates the negotiation context carried alongside the message, so this result carries exactly + * two components: the overall pass flag and the structured rule errors. Nothing else is inferred or checked here. * - * @param passed {@code true} only when the text is a negotiation message whose negotiation context section satisfies - * every context rule - * @param isNegotiation {@code true} when the text contains the negotiation context section and is therefore a - * negotiation message - * @param errors structured rule errors of the negotiation context section; empty when the text is not a negotiation - * message or when every rule passes - * @param context parsed negotiation context; {@code null} unless the text is a negotiation message whose context is - * fully valid + * @param passed {@code true} only when the negotiation context satisfies every context rule + * @param errors structured rule errors of the negotiation context; empty when every rule passes * @since 2026-08 */ -public record NegotiationRuleCheckResult( - boolean passed, boolean isNegotiation, List errors, NegotiationContext context) { +public record NegotiationRuleCheckResult(boolean passed, List errors) { /** * Normalizes the error list. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java index b6517716..bad6540f 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java @@ -6,17 +6,18 @@ import java.util.Map; import java.util.Objects; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.validation.RuleChecker; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; /** - * Adapter that bridges {@link NegotiationComplianceChecker} to the {@link RuleChecker} contract. + * Adapter that bridges {@link NegotiationComplianceChecker} to the {@link RuleChecker} contract for one validation + * call. * - *

      The adapter holds a {@link NegotiationComplianceChecker} and a {@link Vocabulary}, delegating the - * {@link RuleChecker#check(String)} call to the vocabulary-aware {@link NegotiationComplianceChecker#check(String, - * Vocabulary)} method. The resulting {@link NegotiationRuleCheckResult} is converted into either a map of context + *

      The adapter holds a {@link NegotiationComplianceChecker} and the negotiation context carried alongside the + * message in the A2A-T metadata, delegating the {@link RuleChecker#check(String)} call to the context-aware + * {@link NegotiationComplianceChecker#check(NegotiationContext)} method. A null context is reported as not being a + * negotiation message. The resulting {@link NegotiationRuleCheckResult} is converted into either a map of context * parameters ({@code id}, {@code round}, {@code maxRounds}) or a {@link ContentValidationException}. * * @since 2026-08 @@ -24,35 +25,34 @@ public final class NegotiationRuleCheckerAdapter implements RuleChecker { private static final String NON_NEGOTIATION_MESSAGE = - "missing negotiation context section; for Task-T compliance use checkTaskPrompt"; + "missing negotiation context; for Task-T compliance use checkTaskPrompt"; private final NegotiationComplianceChecker checker; - private final Vocabulary vocabulary; + private final NegotiationContext context; - public NegotiationRuleCheckerAdapter(NegotiationComplianceChecker checker, Vocabulary vocabulary) { + public NegotiationRuleCheckerAdapter(NegotiationComplianceChecker checker, NegotiationContext context) { this.checker = Objects.requireNonNull(checker, "checker"); - this.vocabulary = Objects.requireNonNull(vocabulary, "vocabulary"); + this.context = context; } @Override public Map check(String prompt) { - NegotiationRuleCheckResult result = checker.check(prompt, vocabulary); - if (!result.isNegotiation()) { + if (context == null) { throw new ContentValidationException( A2ATErrorCodes.VALIDATION_INVALID_INPUT, NON_NEGOTIATION_MESSAGE, List.of()); } + NegotiationRuleCheckResult result = checker.check(context); if (!result.passed()) { throw new ContentValidationException( A2ATErrorCodes.VALIDATION_RULE_VIOLATION, "Negotiation context rule validation failed.", result.errors()); } - NegotiationContext ctx = result.context(); Map params = new LinkedHashMap<>(); - params.put("id", ctx.id()); - params.put("round", ctx.round()); - params.put("maxRounds", ctx.maxRounds()); + params.put("id", context.id()); + params.put("round", context.round()); + params.put("maxRounds", context.maxRounds()); return Collections.unmodifiableMap(params); } -} \ No newline at end of file +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java index e243fbc1..0a1cd28e 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java @@ -4,6 +4,7 @@ import java.util.Objects; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.validation.RuleChecker; import net.openan.a2at.sdk.core.validation.SemanticValidator; @@ -15,37 +16,44 @@ * Orchestrates the validation of a negotiation message and the extraction of its parameters. * *

      The extractor delegates to the shared {@link ValidationPipeline} from the core module, which runs the rule-level - * gate, retryable semantic validation and deterministic parameter merging in one pass. A negotiation-specific - * {@link RuleChecker} adapter and a {@link SemanticValidator} adapter bridge the negotiation types to the core + * gate, retryable semantic validation and deterministic parameter merging in one pass. The rule-level gate is a + * per-call {@link NegotiationRuleCheckerAdapter} bridging the negotiation context carried alongside the message to the + * core {@link RuleChecker} contract; a {@link SemanticValidator} adapter bridges the negotiation types to the core * validation contracts. * * @since 2026-08 */ public final class ParamExtractor { - private final ValidationPipeline pipeline; + private final NegotiationComplianceChecker complianceChecker; + + private final SemanticValidator semanticValidator; + + private final int maxAttempts; /** * Creates a parameter extractor. * - * @param ruleChecker rule-level checker used as the entry gate + * @param complianceChecker rule-level checker used as the entry gate * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters * @param maxAttempts maximum number of retry attempts for the semantic validation step * @throws NullPointerException if any collaborator is null */ public ParamExtractor( - RuleChecker ruleChecker, + NegotiationComplianceChecker complianceChecker, SemanticValidator semanticValidator, int maxAttempts) { - Objects.requireNonNull(ruleChecker, "ruleChecker"); - Objects.requireNonNull(semanticValidator, "semanticValidator"); - this.pipeline = new ValidationPipeline(ruleChecker, semanticValidator, maxAttempts); + this.complianceChecker = Objects.requireNonNull(complianceChecker, "complianceChecker"); + this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); + this.maxAttempts = maxAttempts; } /** * Validates one negotiation message and extracts its parameters through the full pipeline. * * @param prompt rendered negotiation message text + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param reference template reference the message is validated against * @return filled parameter data carrying the context parameters and the extracted parameters @@ -54,7 +62,10 @@ public ParamExtractor( * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline * fails */ - public FilledParamData extract(String prompt, Map schema, NegotiationReference reference) { + public FilledParamData extract( + String prompt, NegotiationContext context, Map schema, NegotiationReference reference) { + ValidationPipeline pipeline = new ValidationPipeline<>( + new NegotiationRuleCheckerAdapter(complianceChecker, context), semanticValidator, maxAttempts); try { return pipeline.validate(prompt, schema, reference); } catch (ContentValidationException e) { @@ -78,4 +89,4 @@ private static String mapCode(String code) { return code; } } -} \ No newline at end of file +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java index 47da37e5..62c52d9a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java @@ -59,7 +59,10 @@ void everyThreadMatchesItsSerialBaselineWithoutExceptionLeaks() throws Exception MetadataContent fromTextBase = orchestrator.generateProposeFromText( "请提供节能区域信息。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); FilledParamData validateBase = orchestrator.validateProposePromptAndDataFilling( - fromDataBase.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); + fromDataBase.promptText(), + new NegotiationContext(UUID, 1, 5), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI); int baselineExtractionCalls = llm.extractionCalls.get(); int baselineSemanticCalls = llm.semanticCalls.get(); @@ -86,7 +89,10 @@ void everyThreadMatchesItsSerialBaselineWithoutExceptionLeaks() throws Exception validateThreads++; workers.add(callableOf(() -> { FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - fromDataBase.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); + fromDataBase.promptText(), + new NegotiationContext(UUID, 1, 5), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI); assertEquals(validateBase.data(), filled.data(), "validation baseline"); })); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationContextImmutabilityTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationContextImmutabilityTest.java index 140bec59..b749351d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationContextImmutabilityTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationContextImmutabilityTest.java @@ -13,7 +13,6 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckResult; @@ -64,10 +63,9 @@ void sharedContextNextRoundNeverMutatesTheOriginal() throws Exception { @Test void ruleCheckerIsPureUnderConcurrentCalls() throws Exception { - String prompt = "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5"; - Vocabulary vocabulary = Vocabulary.forLanguage("zh-CN"); + NegotiationContext context = new NegotiationContext(UUID, 1, 5); DefaultNegotiationComplianceChecker checker = new DefaultNegotiationComplianceChecker(); - NegotiationRuleCheckResult baseline = checker.check(prompt, vocabulary); + NegotiationRuleCheckResult baseline = checker.check(context); ExecutorService pool = Executors.newFixedThreadPool(THREADS); try { @@ -75,11 +73,9 @@ void ruleCheckerIsPureUnderConcurrentCalls() throws Exception { for (int index = 0; index < THREADS; index++) { futures.add(pool.submit(() -> { for (int iteration = 0; iteration < ITERATIONS; iteration++) { - NegotiationRuleCheckResult result = checker.check(prompt, vocabulary); + NegotiationRuleCheckResult result = checker.check(context); assertEquals(baseline.passed(), result.passed()); - assertEquals(baseline.isNegotiation(), result.isNegotiation()); assertEquals(baseline.errors(), result.errors()); - assertEquals(baseline.context(), result.context()); } return null; })); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java index 379a2e19..c18b6983 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java @@ -187,7 +187,8 @@ void peerValidatesAReceivedAbortMessageAndExtractsItsParameters() { ABORT_URI); FilledParamData parameters = - peer.validateAbortPromptAndDataFilling(abortMessage.promptText(), Map.of("type", "object"), ABORT_URI); + peer.validateAbortPromptAndDataFilling( + abortMessage.promptText(), new NegotiationContext(SESSION_ID, 5, 5), Map.of("type", "object"), ABORT_URI); assertEquals(SESSION_ID, parameters.data().get("id")); assertEquals(5, parameters.data().get("round")); @@ -213,7 +214,11 @@ void abortValidationFailsOnBeyondBudgetRoundsBeforeAnyLlmCall() { NegotiationParamExtractionException failure = assertThrows( NegotiationParamExtractionException.class, - () -> peer.validateAbortPromptAndDataFilling(beyondBudget.promptText(), Map.of("type", "object"), ABORT_URI)); + () -> peer.validateAbortPromptAndDataFilling( + beyondBudget.promptText(), + new NegotiationContext(SESSION_ID, 6, 5), + Map.of("type", "object"), + ABORT_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, failure.getCode()); assertTrue( failure.getErrors().stream().anyMatch(error -> "round".equals(error.slotName())), @@ -230,7 +235,7 @@ void abortValidationRejectsTypedTemplateUris() { IllegalArgumentException failure = assertThrows( IllegalArgumentException.class, - () -> orchestrator.validateAbortPromptAndDataFilling("任意文本", Map.of("type", "object"), + () -> orchestrator.validateAbortPromptAndDataFilling("任意文本", null, Map.of("type", "object"), StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE)); assertTrue(failure.getMessage().contains("abort")); assertEquals(0, llm.calls); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java index d196eb7b..f96a999b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java @@ -78,6 +78,7 @@ void semanticValidationKeepsUsingTheBuiltInSystemPrompt() throws IOException { FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( message.promptText(), + new NegotiationContext(UUID, 1, 5), Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), INFORMATION_PROPOSE_URI); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 5c17fd3f..2d62c48a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -515,6 +515,7 @@ void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOEx FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( message.promptText(), + goldenCase.context(), Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), goldenCase.template()); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java index 68b5e3a4..ab435c09 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java @@ -126,7 +126,8 @@ void injectedComplianceCheckerIsUsedByTheValidationPipeline() { .build(); orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5), Map.of("type", "object"), INFORMATION_PROPOSE_URI); @@ -157,7 +158,8 @@ void maxAttemptsReachesBothRetryChains() { org.junit.jupiter.api.Assertions.assertThrows( net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException.class, () -> validationOrchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5), Map.of("type", "object"), INFORMATION_PROPOSE_URI)); assertEquals(2, semanticClient.calls.get(), "semantic validation chain must retry up to the limit"); @@ -178,9 +180,9 @@ private static final class RecordingComplianceChecker implements NegotiationComp private final AtomicInteger calls = new AtomicInteger(); @Override - public NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary) { + public NegotiationRuleCheckResult check(net.openan.a2at.sdk.core.model.NegotiationContext context) { calls.incrementAndGet(); - return new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(UUID, 1, 5)); + return new NegotiationRuleCheckResult(true, List.of()); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 2153b8e2..e56fa5bf 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -118,6 +118,7 @@ void validatesAndFillsProposeDataWithScriptedSemanticResponse() { FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( message.promptText(), + new NegotiationContext(UUID, 1, 5), Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), INFORMATION_PROPOSE); @@ -209,7 +210,8 @@ void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { NegotiationParamExtractionException extractionFailure = assertThrows( NegotiationParamExtractionException.class, () -> validationOrchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5), Map.of("type", "object"), INFORMATION_PROPOSE)); assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, extractionFailure.getCode()); @@ -228,6 +230,7 @@ void rejectsNonNegotiationPromptInParameterExtraction() { NegotiationParamExtractionException.class, () -> orchestrator.validateProposePromptAndDataFilling( "plain text without any negotiation section", + null, Map.of("type", "object"), INFORMATION_PROPOSE)); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java index b9f1316b..fa04a639 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java @@ -43,6 +43,10 @@ class ValidateAndFillingDataPipelineTest { private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + private static final NegotiationContext CONTEXT = new NegotiationContext(SESSION_ID, 2, 5); + + private static final NegotiationContext TARGET_CONTEXT = new NegotiationContext(SESSION_ID, 1, 5); + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; private static final TemplateUri INFORMATION_ACCEPT_REJECT_URI = @@ -62,7 +66,7 @@ void legalProposeMessageRunsTheFullChainWithASingleLlmCall() { String message = informationProposeMessage(orchestrator); FilledParamData filled = - orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI); + orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI); assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); @@ -85,7 +89,7 @@ void legalAcceptMessageRunsTheFullChainWithASingleLlmCall() { String message = informationEndingMessage(orchestrator, NegotiationConclusion.ACCEPT); FilledParamData filled = - orchestrator.validateAcceptPromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); + orchestrator.validateAcceptPromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); @@ -104,7 +108,7 @@ void legalRejectMessageRunsTheFullChainWithASingleLlmCall() { String message = informationEndingMessage(orchestrator, NegotiationConclusion.REJECT); FilledParamData filled = - orchestrator.validateRejectPromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); + orchestrator.validateRejectPromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); @@ -114,17 +118,16 @@ void legalRejectMessageRunsTheFullChainWithASingleLlmCall() { @ParameterizedTest @MethodSource("ruleViolationCases") - void ruleViolationsFailBeforeAnyLlmCall( - String caseName, String idValue, String roundValue, String maxRoundsValue, List expectedSlots) { + void ruleViolationsFailBeforeAnyLlmCall(String caseName, NegotiationContext context, List expectedSlots) { ScriptedLlmClient llm = new ScriptedLlmClient( "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String violatingMessage = zhContextMessage(idValue, roundValue, maxRoundsValue); + String message = informationProposeMessage(orchestrator); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, () -> orchestrator.validateProposePromptAndDataFilling( - violatingMessage, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + message, context, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, exception.getCode(), caseName); assertEquals(expectedSlots.size(), exception.getErrors().size(), caseName); @@ -140,13 +143,8 @@ void ruleViolationsFailBeforeAnyLlmCall( static List ruleViolationCases() { return List.of( - new Object[] {"non-uuid id", "not-a-uuid", null, null, List.of("id")}, - new Object[] {"round zero", null, "0", null, List.of("round")}, - new Object[] {"negative round", null, "-1", null, List.of("round")}, - new Object[] {"zero maxRounds", null, null, "0", List.of("maxRounds")}, - new Object[] {"round above maxRounds", null, "3", "2", List.of("round")}, - new Object[] {"non-integer round", null, "two", null, List.of("round")}, - new Object[] {"non-integer maxRounds", null, null, "many", List.of("maxRounds")}); + new Object[] {"non-uuid id", new NegotiationContext("not-a-uuid", 1, 5), List.of("id")}, + new Object[] {"round above maxRounds", new NegotiationContext(SESSION_ID, 3, 2), List.of("round")}); } @Test @@ -168,7 +166,7 @@ void semanticRejectionIsNotRetriedAndPassesTheErrorsThrough() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(semanticErrors, exception.getErrors()); @@ -185,7 +183,7 @@ void taskTMessagesAreRejectedAsNonNegotiationInputWithALanguageNeutralMessage( NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(taskTMessage, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(taskTMessage, null, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, exception.getCode(), caseName); assertTrue(exception.getMessage().contains("checkTaskPrompt"), caseName); @@ -231,7 +229,7 @@ void nestedArrayParamsAreExtractedThroughTheMergedSchema() { NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); String message = targetProposeMessage(orchestrator); - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling(message, callerSchema, TARGET_PROPOSE_URI); + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling(message, TARGET_CONTEXT, callerSchema, TARGET_PROPOSE_URI); assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); @@ -260,7 +258,7 @@ void callerSchemaWithoutATypeKeywordIsWrappedAndTheChainSucceeds() { String message = informationProposeMessage(orchestrator); FilledParamData filled = - orchestrator.validateProposePromptAndDataFilling(message, schemaWithoutType, INFORMATION_PROPOSE_URI); + orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, schemaWithoutType, INFORMATION_PROPOSE_URI); assertEquals(1, llm.calls); assertEquals("松山湖", filled.data().get("region")); @@ -282,7 +280,7 @@ void missingNegotiationTypeKeyIsRetriedThenFailsAsAnInfrastructureError() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); assertEquals(3, llm.calls, "a shape-invalid response is a retryable infrastructure failure"); @@ -300,7 +298,7 @@ void declaredTypeMismatchIsASemanticRejection() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(1, llm.calls, "a type mismatch is a semantic decision and must not be retried"); @@ -319,7 +317,7 @@ void structuralSemanticChecksSurfaceThroughTheSemanticErrors( NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode(), caseName); assertEquals(List.of(expectedError), exception.getErrors(), caseName); @@ -371,7 +369,7 @@ void falseVerdictWithNullTypeIsAShapeLegalOutcome() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(1, exception.getErrors().size()); @@ -388,7 +386,7 @@ void trueVerdictWithNullTypeIsASemanticRejection() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(1, llm.calls, "a null type with a true verdict is a semantic rejection, not a retryable failure"); @@ -410,7 +408,7 @@ void semanticLlmFailureIsRetriedAndExhaustsWithTheLlmPseudoSlot() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); assertEquals(2, llm.calls); @@ -435,7 +433,8 @@ void missingPromptResourcesCloseAsTemplateNotFoundWithoutBubblingTheResourceExce NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, () -> orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + SESSION_ID + "\n- round: 1\n- maxRounds: 5\n\n## 所需信息项\n1. 区域\n", + "## 所需信息项\n1. 区域\n", + new NegotiationContext(SESSION_ID, 1, 5), REGION_SCHEMA, INFORMATION_PROPOSE_URI)); @@ -465,7 +464,7 @@ void proposeUriValidatingAResultMessageIsASemanticRejection() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, () -> orchestrator.validateProposePromptAndDataFilling( - rejectMessage, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + rejectMessage, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(List.of(phaseError), exception.getErrors()); @@ -512,17 +511,6 @@ private static String targetProposeMessage(NegotiationGenerationOrchestrator orc return content.promptText(); } - private static String zhContextMessage(String idValue, String roundValue, String maxRoundsValue) { - StringBuilder prompt = new StringBuilder("## 协商上下文\n"); - prompt.append("- id: ").append(idValue == null ? SESSION_ID : idValue).append('\n'); - prompt.append("- round: ").append(roundValue == null ? "1" : roundValue).append('\n'); - prompt.append("- maxRounds: ") - .append(maxRoundsValue == null ? "5" : maxRoundsValue) - .append('\n'); - prompt.append("\n## 所需信息项\n1. 节能区域\n"); - return prompt.toString(); - } - /** LLM boundary fake replaying scripted payloads and recording every structured call. */ private static final class ScriptedLlmClient implements LLMClient { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidatePromptAndDataFillingSignatureContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidatePromptAndDataFillingSignatureContractTest.java index 03f901ed..cec6c8b9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidatePromptAndDataFillingSignatureContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidatePromptAndDataFillingSignatureContractTest.java @@ -14,9 +14,9 @@ import org.junit.jupiter.api.Test; /** - * Pins the compile-time contract of the validate-and-filling API: the parameter schema and the template URI are - * mandatory parts of the full signature of each of the three methods, so a caller that omits either argument is - * rejected at compile time and no reduced-arity overload can silently appear later. + * Pins the compile-time contract of the validate-and-filling API: the negotiation context, the parameter schema and + * the template URI are mandatory parts of the full signature of each of the three methods, so a caller that omits + * any argument is rejected at compile time and no reduced-arity overload can silently appear later. */ class ValidatePromptAndDataFillingSignatureContractTest { @@ -24,7 +24,7 @@ class ValidatePromptAndDataFillingSignatureContractTest { List.of("validateProposePromptAndDataFilling", "validateAcceptPromptAndDataFilling", "validateRejectPromptAndDataFilling"); @Test - void everyValidatePromptAndDataFillingMethodHasExactlyTheFullThreeArgumentSignature() { + void everyValidatePromptAndDataFillingMethodHasExactlyTheFullFourArgumentSignature() { for (String methodName : VALIDATE_PROMPT_FILLING_METHODS) { List declared = Arrays.stream(NegotiationGenerationOrchestrator.class.getDeclaredMethods()) .filter(method -> method.getName().equals(methodName)) @@ -34,14 +34,18 @@ void everyValidatePromptAndDataFillingMethodHasExactlyTheFullThreeArgumentSignat 1, declared.size(), methodName + " must exist exactly once: no reduced-arity overload may bypass the mandatory" - + " schema and template URI arguments"); + + " context, schema and template URI arguments"); Method method = declared.get(0); assertTrue(Modifier.isPublic(method.getModifiers()), methodName + " must be public"); Class[] parameterTypes = method.getParameterTypes(); - assertEquals(3, parameterTypes.length, methodName + " must take exactly three parameters"); + assertEquals(4, parameterTypes.length, methodName + " must take exactly four parameters"); assertEquals(String.class, parameterTypes[0], methodName + " first parameter is the prompt text"); - assertEquals(Map.class, parameterTypes[1], methodName + " second parameter is the caller parameter schema"); - assertEquals(TemplateUri.class, parameterTypes[2], methodName + " third parameter is the template URI"); + assertEquals( + net.openan.a2at.sdk.core.model.NegotiationContext.class, + parameterTypes[1], + methodName + " second parameter is the negotiation context"); + assertEquals(Map.class, parameterTypes[2], methodName + " third parameter is the caller parameter schema"); + assertEquals(TemplateUri.class, parameterTypes[3], methodName + " fourth parameter is the template URI"); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java index f123a70c..7ab7f38e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java @@ -51,6 +51,8 @@ class NegotiationErrorCodeUsageMatrixTest { private static final Map SCHEMA = Map.of("type", "object"); + private static final NegotiationContext CONTEXT = new NegotiationContext(UUID, 1, 5); + @TestFactory Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { return Stream.of( @@ -127,7 +129,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .llmClient(llm) .build() .validateProposePromptAndDataFilling( - "plain text without any negotiation section", SCHEMA, INFORMATION_PROPOSE_URI), + "plain text without any negotiation section", null, SCHEMA, INFORMATION_PROPOSE_URI), NegotiationParamExtractionException.class, A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), row( @@ -139,7 +141,8 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .llmClient(llm) .build() .validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 9\n- maxRounds: 5", + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 9, 5), SCHEMA, INFORMATION_PROPOSE_URI), NegotiationParamExtractionException.class, @@ -155,7 +158,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .llmClient(llm) .maxAttempts(3) .build() - .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), + .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, CONTEXT, SCHEMA, INFORMATION_PROPOSE_URI), NegotiationParamExtractionException.class, A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED), row( @@ -167,7 +170,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .llmClient(llm) .maxAttempts(2) .build() - .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), + .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, CONTEXT, SCHEMA, INFORMATION_PROPOSE_URI), NegotiationParamExtractionException.class, A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), row( @@ -202,7 +205,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .llmClient(llm) .maxAttempts(2) .build() - .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), + .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, CONTEXT, SCHEMA, INFORMATION_PROPOSE_URI), NegotiationParamExtractionException.class, A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), row( @@ -214,7 +217,7 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { .llmClient(llm) .semanticValidator(throwingSemanticValidator()) .build() - .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, SCHEMA, INFORMATION_PROPOSE_URI), + .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, CONTEXT, SCHEMA, INFORMATION_PROPOSE_URI), NegotiationParamExtractionException.class, A2ATErrorCodes.TEMPLATE_NOT_FOUND)); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java index 2bd8639a..94f6ffa5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java @@ -63,7 +63,8 @@ void ruleViolationFailureIsCatchableThroughTheCommonRoot() { .build(); RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 9\n- maxRounds: 5", + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 9, 5), Map.of("type", "object"), INFORMATION_PROPOSE_URI)); @@ -83,7 +84,8 @@ void semanticRejectionFailureIsCatchableThroughTheCommonRoot() { .build(); RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5), Map.of("type", "object"), INFORMATION_PROPOSE_URI)); @@ -102,7 +104,8 @@ void paramExtractionLlmFailureIsCatchableThroughTheCommonRoot() { .build(); RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5), Map.of("type", "object"), INFORMATION_PROPOSE_URI)); @@ -119,7 +122,7 @@ void nonNegotiationInputFailureIsCatchableThroughTheCommonRoot() { .build(); RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - "plain text without any negotiation section", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); + "plain text without any negotiation section", null, Map.of("type", "object"), INFORMATION_PROPOSE_URI)); assertTrue(failure instanceof NegotiationParamExtractionException); assertEquals( @@ -168,7 +171,7 @@ void nullPromptOnValidateAndFillingStaysOutsideTheCommonRoot() { NullPointerException failure = assertThrows( NullPointerException.class, - () -> orchestrator.validateProposePromptAndDataFilling(null, Map.of("type", "object"), INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(null, new NegotiationContext(UUID, 1, 5), Map.of("type", "object"), INFORMATION_PROPOSE_URI)); assertFalse(A2ATError.class.isInstance(failure), "a null prompt is a programming error and must stay outside the A2ATError tree"); } @@ -182,7 +185,7 @@ void blankPromptOnValidateAndFillingStaysOutsideTheCommonRoot() { IllegalArgumentException failure = assertThrows( IllegalArgumentException.class, () -> orchestrator.validateProposePromptAndDataFilling( - " ", Map.of("type", "object"), INFORMATION_PROPOSE_URI)); + " ", new NegotiationContext(UUID, 1, 5), Map.of("type", "object"), INFORMATION_PROPOSE_URI)); assertFalse( A2ATError.class.isInstance(failure), diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index 4538a686..0a82941d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -172,7 +172,7 @@ void infoAndHigherEventsNeverCarryMessageTextInputOrResponseContent() { orchestrator.generateProposeFromText( "free text containing " + inputMarker, new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI); FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - generated.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); + generated.promptText(), new NegotiationContext(UUID, 1, 5), Map.of("type", "object"), INFORMATION_PROPOSE_URI); assertTrue(filled.data().containsValue(paramValueMarker)); List infoAndHigher = appender.list.stream() @@ -299,7 +299,7 @@ private void driveSuccessfulParamExtraction() { new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE_URI); FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message.promptText(), Map.of("type", "object"), INFORMATION_PROPOSE_URI); + message.promptText(), new NegotiationContext(UUID, 1, 5), Map.of("type", "object"), INFORMATION_PROPOSE_URI); assertEquals(UUID, filled.data().get("id")); } @@ -313,7 +313,8 @@ private void driveSemanticRejection() { .build(); try { orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5), Map.of("type", "object"), INFORMATION_PROPOSE_URI); } catch (NegotiationParamExtractionException expected) { @@ -328,7 +329,8 @@ private void driveRuleViolation() { .build(); try { orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 9\n- maxRounds: 5", + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 9, 5), Map.of("type", "object"), INFORMATION_PROPOSE_URI); } catch (NegotiationParamExtractionException expected) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java index 281a96a0..1b5afa37 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java @@ -74,7 +74,8 @@ void noFailureMessageOfTheRealPipelinesMentionsAStage() { failureMessageOf(() -> orchestrator.generateProposeFromText( "请提供节能区域。", new NegotiationContext(UUID, 1, 5), INFORMATION_PROPOSE_URI)), failureMessageOf(() -> orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5), Map.of("type", "object"), INFORMATION_PROPOSE_URI)), failureMessageOf(() -> new NegotiationContext(" ", 1, 5)), @@ -106,7 +107,8 @@ void publicFailuresStillCarryTheirStructuredDetails() { NegotiationParamExtractionException extractionFailure = catchFailure( NegotiationParamExtractionException.class, () -> orchestrator.validateProposePromptAndDataFilling( - "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5", + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5), Map.of("type", "object"), INFORMATION_PROPOSE_URI)); assertTrue(extractionFailure.getCode().equals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR)); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java index c10d1a79..cc3843dc 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java @@ -61,9 +61,9 @@ round, new InformationProposeContent(List.of(new NegotiationItem("节能区域 assertEquals(1, initiatingSideLlm.callCount(), "the from-text generation performs exactly one extraction call"); FilledParamData respondingSideParameters = respondingSide.validateProposePromptAndDataFilling( - fromData.promptText(), parameterSchema(), INFORMATION_PROPOSE_URI); + fromData.promptText(), round, parameterSchema(), INFORMATION_PROPOSE_URI); FilledParamData initiatingSideParameters = initiatingSide.validateProposePromptAndDataFilling( - fromData.promptText(), parameterSchema(), INFORMATION_PROPOSE_URI); + fromData.promptText(), round, parameterSchema(), INFORMATION_PROPOSE_URI); assertEquals( respondingSideParameters, initiatingSideParameters, @@ -84,7 +84,7 @@ round, new InformationProposeContent(List.of(new NegotiationItem("节能区域 assertTrue(respondingSideAnswer.promptText().contains("## 信息协商结果\nAccept")); FilledParamData validatedAnswer = initiatingSide.validateAcceptPromptAndDataFilling( - respondingSideAnswer.promptText(), parameterSchema(), INFORMATION_ACCEPT_URI); + respondingSideAnswer.promptText(), round, parameterSchema(), INFORMATION_ACCEPT_URI); assertEquals(SESSION_ID, validatedAnswer.data().get("id")); assertEquals(1, validatedAnswer.data().get("round")); assertEquals("松山湖", validatedAnswer.data().get("region")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java index 72542984..02f718e5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java @@ -64,7 +64,10 @@ void feasibilityNegotiationRunsFromSemanticRejectionToATerminalAcceptance() thro NegotiationParamExtractionException rejection = assertThrows( NegotiationParamExtractionException.class, () -> responder.validateProposePromptAndDataFilling( - evaluationRequest.promptText(), parameterSchema(), FEASIBILITY_PROPOSE_URI)); + evaluationRequest.promptText(), + GoldenCase.FEASIBILITY_PROPOSE.context(), + parameterSchema(), + FEASIBILITY_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, rejection.getCode()); assertEquals(1, responderLlm.callCount(), "a semantic rejection must not be retried"); List errors = rejection.getErrors(); @@ -102,7 +105,7 @@ void feasibilityNegotiationRunsFromSemanticRejectionToATerminalAcceptance() thro "the summary must fill the exception slot of the feasibility result confirmation section"); FilledParamData terminalParameters = requester.validateAcceptPromptAndDataFilling( - acceptance.promptText(), parameterSchema(), FEASIBILITY_ACCEPT_URI); + acceptance.promptText(), GoldenInputs.defaultContext(), parameterSchema(), FEASIBILITY_ACCEPT_URI); assertEquals(1, requesterLlm.callCount()); assertEquals(GoldenInputs.SESSION_ID, terminalParameters.data().get("id")); assertEquals(2, terminalParameters.data().get("round")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java index 00c7c762..e5d929f1 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java @@ -49,6 +49,7 @@ void informationNegotiationReachesTheTerminalStateWithinOneRound() { NegotiationParamExtractionException.class, () -> responder.validateProposePromptAndDataFilling( "A task prompt describing an energy-saving optimization without any negotiation section.", + null, parameterSchema(), INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, notNegotiable.getCode()); @@ -72,7 +73,7 @@ void informationNegotiationReachesTheTerminalStateWithinOneRound() { assertTrue(request.promptText().contains("- round: 1")); FilledParamData requestParameters = responder.validateProposePromptAndDataFilling( - request.promptText(), parameterSchema(), INFORMATION_PROPOSE_URI); + request.promptText(), round, parameterSchema(), INFORMATION_PROPOSE_URI); assertEquals(1, responderLlm.callCount()); assertEquals(SESSION_ID, requestParameters.data().get("id")); assertEquals(1, requestParameters.data().get("round")); @@ -94,7 +95,8 @@ void informationNegotiationReachesTheTerminalStateWithinOneRound() { assertTrue(answer.promptText().contains("- round: 1"), "the terminal answer shares the round of the request"); FilledParamData answerParameters = - requester.validateAcceptPromptAndDataFilling(answer.promptText(), parameterSchema(), INFORMATION_ACCEPT_URI); + requester.validateAcceptPromptAndDataFilling( + answer.promptText(), round, parameterSchema(), INFORMATION_ACCEPT_URI); assertEquals(1, requesterLlm.callCount()); assertEquals(SESSION_ID, answerParameters.data().get("id")); assertEquals(1, answerParameters.data().get("round")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java index 282c438c..1484529e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java @@ -52,7 +52,7 @@ lastRound, new NegotiationAbortContent("达到协商轮次上限,本次协商 assertTrue(terminal.promptText().contains("- round: 5")); FilledParamData terminalParameters = peer.validateAbortPromptAndDataFilling( - terminal.promptText(), parameterSchema(), StandardTemplates.NEGOTIATION_ABORT); + terminal.promptText(), lastRound, parameterSchema(), StandardTemplates.NEGOTIATION_ABORT); assertEquals(1, peerLlm.callCount()); assertEquals(SESSION_ID, terminalParameters.data().get("id")); assertEquals(5, terminalParameters.data().get("round")); @@ -64,7 +64,10 @@ lastRound, new NegotiationAbortContent("达到协商轮次上限,本次协商 NegotiationParamExtractionException ruleFailure = assertThrows( NegotiationParamExtractionException.class, () -> peer.validateAbortPromptAndDataFilling( - beyondBudgetMessage.promptText(), parameterSchema(), StandardTemplates.NEGOTIATION_ABORT)); + beyondBudgetMessage.promptText(), + beyondBudget, + parameterSchema(), + StandardTemplates.NEGOTIATION_ABORT)); assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, ruleFailure.getCode()); assertTrue( ruleFailure.getErrors().stream().anyMatch(error -> "round".equals(error.slotName())), diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java index 48380ee0..feb6e7b1 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java @@ -65,7 +65,7 @@ void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { assertTrue(firstProposal.promptText().contains("- round: 1")); FilledParamData firstRoundParameters = responder.validateProposePromptAndDataFilling( - firstProposal.promptText(), nestedParameterSchema(), TARGET_PROPOSE_URI); + firstProposal.promptText(), firstRound, nestedParameterSchema(), TARGET_PROPOSE_URI); assertEquals(1, responderLlm.callCount()); Object timeRateTargets = firstRoundParameters.data().get("timeRateTargets"); assertTrue(timeRateTargets instanceof List, "the nested parameter list must pass through"); @@ -84,7 +84,8 @@ void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { assertTrue(rejection.promptText().contains("节能区域信息因站点清单不可用而无法完整澄清")); assertTrue(rejection.promptText().contains("- round: 1")); - requester.validateRejectPromptAndDataFilling(rejection.promptText(), parameterSchema(), TARGET_ACCEPT_URI); + requester.validateRejectPromptAndDataFilling( + rejection.promptText(), firstRound, parameterSchema(), TARGET_ACCEPT_URI); assertEquals(1, requesterLlm.callCount()); NegotiationContext secondRound = firstRound.nextRound(); @@ -119,8 +120,8 @@ void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { assertTrue(acceptance.promptText().contains("最终确认意图:08:00-18:00对松山湖站点启用无线节能优化")); assertTrue(acceptance.promptText().contains("- round: 2")); - FilledParamData terminalParameters = - requester.validateAcceptPromptAndDataFilling(acceptance.promptText(), parameterSchema(), TARGET_ACCEPT_URI); + FilledParamData terminalParameters = requester.validateAcceptPromptAndDataFilling( + acceptance.promptText(), secondRound, parameterSchema(), TARGET_ACCEPT_URI); assertEquals(2, requesterLlm.callCount()); assertEquals(SESSION_ID, terminalParameters.data().get("id")); assertEquals(2, terminalParameters.data().get("round")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java index 490ed58c..f94a2173 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java @@ -2,13 +2,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -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 java.util.List; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -19,72 +18,40 @@ class ComplianceCheckerTest { private final DefaultNegotiationComplianceChecker checker = new DefaultNegotiationComplianceChecker(); - private final Vocabulary zhVocabulary = Vocabulary.forLanguage("zh-CN"); - - private final Vocabulary enVocabulary = Vocabulary.forLanguage("en-US"); - static List violationCases() { return List.of( - new Object[] {"non-uuid id", "not-a-uuid", null, null, List.of("id")}, - new Object[] {"blank id", "", null, null, List.of("id")}, - new Object[] {"round zero", null, "0", null, List.of("round")}, - new Object[] {"negative round", null, "-1", null, List.of("round")}, - new Object[] {"zero maxRounds", null, null, "0", List.of("maxRounds")}, - new Object[] {"round above maxRounds", null, "3", "2", List.of("round")}, - new Object[] {"non-integer round", null, "two", null, List.of("round")}, - new Object[] {"non-integer maxRounds", null, null, "many", List.of("maxRounds")}, - new Object[] {"missing id line", "__MISSING__", null, null, List.of("id")}, - new Object[] {"missing round line", null, "__MISSING__", null, List.of("round")}, - new Object[] {"missing maxRounds line", null, null, "__MISSING__", List.of("maxRounds")}); + new Object[] {"non-uuid id", new NegotiationContext("not-a-uuid", 1, 5), List.of("id")}, + new Object[] {"short id", new NegotiationContext("short", 1, 5), List.of("id")}, + new Object[] { + "non-hexadecimal id", + new NegotiationContext(SESSION_ID.replace('b', 'z'), 1, 5), + List.of("id") + }, + new Object[] {"round above maxRounds", new NegotiationContext(SESSION_ID, 3, 2), List.of("round")}); } @Test - void validZhMessagePassesAndContextIsExtracted() { - String prompt = "前置说明,应被丢弃。\n\n" - + "## 协商上下文\n" - + "- id: " + SESSION_ID + "\n" - + "- round: 2\n" - + "- maxRounds: 5\n\n" - + "## 所需信息项\n" - + "1. 节能区域信息:请提供真实存在的区域\n"; - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + void validContextPasses() { + NegotiationRuleCheckResult result = checker.check(new NegotiationContext(SESSION_ID, 2, 5)); assertTrue(result.passed()); - assertTrue(result.isNegotiation()); assertEquals(List.of(), result.errors()); - assertEquals(new NegotiationContext(SESSION_ID, 2, 5), result.context()); } @Test - void validEnMessagePassesAndContextIsExtracted() { - String prompt = "Preamble that must be dropped.\n\n" - + "## Negotiation Context\n" - + "- id: " + SESSION_ID + "\n" - + "- round: 1\n" - + "- maxRounds: 5\n\n" - + "## Required Information Items\n" - + "1. energy saving region: provide a real region\n"; - - NegotiationRuleCheckResult result = checker.check(prompt, enVocabulary); + void roundAtTheBudgetBoundaryPasses() { + NegotiationRuleCheckResult result = checker.check(new NegotiationContext(SESSION_ID, 5, 5)); assertTrue(result.passed()); - assertTrue(result.isNegotiation()); assertEquals(List.of(), result.errors()); - assertEquals(new NegotiationContext(SESSION_ID, 1, 5), result.context()); } @ParameterizedTest @MethodSource("violationCases") - void contextViolationsFailWithTheExpectedSlotNames( - String caseName, String idValue, String roundValue, String maxRoundsValue, List expectedSlots) { - String prompt = zhMessage(idValue, roundValue, maxRoundsValue); - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + void contextViolationsFailWithTheExpectedSlotNames(String caseName, NegotiationContext context, List expectedSlots) { + NegotiationRuleCheckResult result = checker.check(context); assertFalse(result.passed(), caseName); - assertTrue(result.isNegotiation(), caseName); - assertNull(result.context(), caseName); assertEquals(expectedSlots.size(), result.errors().size(), caseName); for (int index = 0; index < expectedSlots.size(); index++) { assertEquals(expectedSlots.get(index), result.errors().get(index).slotName(), caseName); @@ -93,9 +60,7 @@ void contextViolationsFailWithTheExpectedSlotNames( @Test void uuidFormatIsEnforcedBeyondLength() { - String prompt = zhMessage(SESSION_ID.replace('b', 'z'), null, null); - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + NegotiationRuleCheckResult result = checker.check(new NegotiationContext(SESSION_ID.replace('b', 'z'), 1, 5)); assertFalse(result.passed()); assertEquals("id", result.errors().get(0).slotName()); @@ -104,148 +69,45 @@ void uuidFormatIsEnforcedBeyondLength() { @Test void uppercaseHexUuidIsAccepted() { - String prompt = zhMessage(SESSION_ID.toUpperCase(java.util.Locale.ROOT), null, null); - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + NegotiationRuleCheckResult result = + checker.check(new NegotiationContext(SESSION_ID.toUpperCase(java.util.Locale.ROOT), 1, 5)); assertTrue(result.passed()); assertEquals(0, result.errors().size()); } @Test - void textWithoutContextSectionIsNotANegotiationMessage() { - String prompt = "## 任务目标\n" + "诊断网络故障\n\n" + "## 任务类型\n" + "故障诊断\n"; + void roundAboveMaxRoundsCarriesTheOutOfRangeCode() { + NegotiationRuleCheckResult result = checker.check(new NegotiationContext(SESSION_ID, 6, 5)); - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); - - assertFalse(result.isNegotiation()); assertFalse(result.passed()); - assertEquals(List.of(), result.errors()); - assertNull(result.context()); - } - - @Test - void emptyTextIsNotANegotiationMessage() { - NegotiationRuleCheckResult result = checker.check("", zhVocabulary); - - assertFalse(result.isNegotiation()); - assertEquals(List.of(), result.errors()); - assertNull(result.context()); - } - - @Test - void nullTextIsNotANegotiationMessage() { - NegotiationRuleCheckResult result = checker.check(null, zhVocabulary); - - assertFalse(result.isNegotiation()); - assertEquals(List.of(), result.errors()); - } - - @Test - void preambleOnlyTextIsNotANegotiationMessage() { - NegotiationRuleCheckResult result = checker.check("只是普通文本,没有任何板块标题。", zhVocabulary); - - assertFalse(result.isNegotiation()); - assertEquals(List.of(), result.errors()); - } - - @Test - void englishCheckerDoesNotRecogniseZhContextSection() { - String prompt = zhMessage(null, null, null); - - NegotiationRuleCheckResult result = checker.check(prompt, enVocabulary); - - assertFalse(result.isNegotiation()); - assertEquals(List.of(), result.errors()); - } - - @Test - void checkerDoesNotValidateConclusionValues() { - String prompt = "## 协商上下文\n" - + "- id: " + SESSION_ID + "\n" - + "- round: 1\n" - + "- maxRounds: 5\n\n" - + "## 信息协商结果\n" - + "Abort\n"; - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); - - assertTrue(result.passed()); - assertEquals(List.of(), result.errors()); - } - - @Test - void checkerDoesNotRequireEndingResultContentSection() { - String prompt = "## 协商上下文\n" - + "- id: " + SESSION_ID + "\n" - + "- round: 1\n" - + "- maxRounds: 5\n\n" - + "## 信息协商结果\n" - + "Accept\n"; - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); - - assertTrue(result.passed()); - assertEquals(List.of(), result.errors()); + SlotValidationError error = result.errors().get(0); + assertEquals("round", error.slotName()); + assertEquals("out_of_range", error.code()); + assertTrue(error.message().contains("maxRounds")); } @Test - void checkerDoesNotEnforceFeasibilitySectionExclusivity() { - String prompt = "## 协商上下文\n" - + "- id: " + SESSION_ID + "\n" - + "- round: 1\n" - + "- maxRounds: 5\n\n" - + "## 待评估内容说明\n" - + "1. 待评估内容\n\n" - + "## 评估不可行时的详情和提案\n" - + "1. 不可行详情\n"; - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); - - assertTrue(result.passed()); - assertEquals(List.of(), result.errors()); + void nullContextIsRejected() { + assertThrows(NullPointerException.class, () -> checker.check(null)); } @Test - void resultRecordHasExactlyTheFourPinnedComponents() { + void resultRecordHasExactlyTheTwoPinnedComponents() { String[] componentNames = java.util.Arrays.stream(NegotiationRuleCheckResult.class.getRecordComponents()) .map(component -> component.getName()) .toArray(String[]::new); - assertEquals(List.of("passed", "isNegotiation", "errors", "context"), List.of(componentNames)); + assertEquals(List.of("passed", "errors"), List.of(componentNames)); } @Test void ruleErrorsCarryStructuredDetails() { - String prompt = zhMessage("short", null, null); - - NegotiationRuleCheckResult result = checker.check(prompt, zhVocabulary); + NegotiationRuleCheckResult result = checker.check(new NegotiationContext("short", 1, 5)); SlotValidationError error = result.errors().get(0); assertEquals("id", error.slotName()); assertEquals("invalid_uuid", error.code()); assertTrue(error.message().contains("UUID")); } - - private static String zhMessage(String idValue, String roundValue, String maxRoundsValue) { - StringBuilder prompt = new StringBuilder(); - prompt.append("## 协商上下文\n"); - if (!"__MISSING__".equals(idValue)) { - prompt.append("- id:") - .append(idValue == null ? " " + SESSION_ID : " " + idValue) - .append('\n'); - } - if (!"__MISSING__".equals(roundValue)) { - prompt.append("- round:") - .append(roundValue == null ? " 1" : " " + roundValue) - .append('\n'); - } - if (!"__MISSING__".equals(maxRoundsValue)) { - prompt.append("- maxRounds:") - .append(maxRoundsValue == null ? " 5" : " " + maxRoundsValue) - .append('\n'); - } - prompt.append("\n## 所需信息项\n1. 信息项\n"); - return prompt.toString(); - } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java index cd0cbb8d..2803d8b9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java @@ -14,7 +14,6 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; import org.junit.jupiter.api.Test; @@ -22,11 +21,9 @@ class ParamExtractorTest { private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final String VALID_ZH_PROMPT = "## 协商上下文\n" - + "- id: " + SESSION_ID + "\n" - + "- round: 2\n" - + "- maxRounds: 5\n\n" - + "## 所需信息项\n" + private static final NegotiationContext CONTEXT = new NegotiationContext(SESSION_ID, 2, 5); + + private static final String VALID_ZH_PROMPT = "## 所需信息项\n" + "1. 节能区域信息:请提供真实存在的区域\n"; private static final NegotiationReference REFERENCE = @@ -38,22 +35,18 @@ class ParamExtractorTest { private final StubSemanticValidator semanticValidator = new StubSemanticValidator(); - private final NegotiationRuleCheckerAdapter ruleChecker = - new NegotiationRuleCheckerAdapter(complianceChecker, Vocabulary.forLanguage("zh-CN")); - - private final ParamExtractor extractor = new ParamExtractor(ruleChecker, semanticValidator, MAX_ATTEMPTS); + private final ParamExtractor extractor = new ParamExtractor(complianceChecker, semanticValidator, MAX_ATTEMPTS); @Test void happyPathMergesContextParamsFirstAndLetsContextWinOnConflict() { - complianceChecker.result = - new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); semanticValidator.result = new SemanticValidationResult( true, "information", List.of(), Map.of("id", "llm-value", "confirmed_rate_mbps", 2, "nested", Map.of("a", 1))); - FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE); + FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); assertEquals(SESSION_ID, filled.data().get("id")); assertEquals(2, filled.data().get("round")); @@ -66,11 +59,10 @@ void happyPathMergesContextParamsFirstAndLetsContextWinOnConflict() { @Test void extractRequiresIntegerRoundAndMaxRoundsInMergedData() { - complianceChecker.result = - new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 3, 7)); + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); semanticValidator.result = new SemanticValidationResult(true, "information", List.of(), Map.of()); - FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE); + FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, new NegotiationContext(SESSION_ID, 3, 7), Map.of(), REFERENCE); assertTrue(filled.data().get("round") instanceof Integer); assertTrue(filled.data().get("maxRounds") instanceof Integer); @@ -81,14 +73,11 @@ void extractRequiresIntegerRoundAndMaxRoundsInMergedData() { @Test void ruleFailureSkipsTheSemanticValidationCall() { complianceChecker.result = new NegotiationRuleCheckResult( - false, - true, - List.of(new SlotValidationError("round", "out_of_range", "round exceeds maxRounds")), - null); + false, List.of(new SlotValidationError("round", "out_of_range", "round exceeds maxRounds"))); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); assertEquals("negotiation_rule_violation", exception.getCode()); assertEquals(1, exception.getErrors().size()); @@ -98,12 +87,12 @@ void ruleFailureSkipsTheSemanticValidationCall() { @Test void extractFailsOnRuleViolationWithoutTouchingTheValidator() { - complianceChecker.result = new NegotiationRuleCheckResult( - false, true, List.of(new SlotValidationError("id", "invalid_uuid", "not a uuid")), null); + complianceChecker.result = + new NegotiationRuleCheckResult(false, List.of(new SlotValidationError("id", "invalid_uuid", "not a uuid"))); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); assertEquals("negotiation_rule_violation", exception.getCode()); assertEquals("id", exception.getErrors().get(0).slotName()); @@ -111,26 +100,25 @@ void extractFailsOnRuleViolationWithoutTouchingTheValidator() { } @Test - void nonNegotiationInputFailsWithLanguageNeutralMessage() { - complianceChecker.result = new NegotiationRuleCheckResult(false, false, List.of(), null); + void nullContextFailsAsNonNegotiationInputWithLanguageNeutralMessage() { + complianceChecker.result = new NegotiationRuleCheckResult(false, List.of()); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> extractor.extract("## 任务目标\n诊断\n", Map.of(), REFERENCE)); + () -> extractor.extract("## 任务目标\n诊断\n", null, Map.of(), REFERENCE)); assertEquals("negotiation_invalid_input", exception.getCode()); assertEquals( - "missing negotiation context section; for Task-T compliance use checkTaskPrompt", - exception.getMessage()); + "missing negotiation context; for Task-T compliance use checkTaskPrompt", exception.getMessage()); assertFalse(exception.getMessage().contains("协商上下文")); assertEquals(List.of(), exception.getErrors()); assertEquals(0, semanticValidator.invocations); + assertEquals(0, complianceChecker.invocations); } @Test void semanticRejectionPassesErrorsThrough() { - complianceChecker.result = - new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); List semanticErrors = List.of( new SlotValidationError("section.target_result_content", "conclusion_content_mismatch", "Mismatch"), new SlotValidationError("section.context", "invalid_conclusion", "Abort is reserved")); @@ -138,7 +126,7 @@ void semanticRejectionPassesErrorsThrough() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); assertEquals("negotiation_semantic_rejected", exception.getCode()); assertEquals(semanticErrors, exception.getErrors()); @@ -146,12 +134,11 @@ void semanticRejectionPassesErrorsThrough() { @Test void extractReturnsTheValidatorOutcome() { - complianceChecker.result = - new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); semanticValidator.result = new SemanticValidationResult(true, "information", List.of(), Map.of("confirmed_rate_mbps", 2)); - FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE); + FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); assertEquals(1, semanticValidator.invocations); assertEquals(2, filled.data().get("confirmed_rate_mbps")); @@ -159,13 +146,12 @@ void extractReturnsTheValidatorOutcome() { @Test void internalValidationFailureIsMappedToRetryableInfrastructureError() { - complianceChecker.result = - new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); semanticValidator.failure = new NegotiationValidationException("response is missing negotiation_type"); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); assertEquals("negotiation_llm_infrastructure_error", exception.getCode()); assertEquals(1, exception.getErrors().size()); @@ -175,14 +161,13 @@ void internalValidationFailureIsMappedToRetryableInfrastructureError() { @Test void promptResourceMissIsMappedToTemplateNotFound() { - complianceChecker.result = - new NegotiationRuleCheckResult(true, true, List.of(), new NegotiationContext(SESSION_ID, 2, 5)); + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); semanticValidator.failure = new ResourceNotFoundException("prompt resource missing", "prompt_resources/prompts/x"); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, Map.of(), REFERENCE)); + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); assertEquals("template_not_found", exception.getCode()); assertEquals(List.of(), exception.getErrors()); @@ -190,10 +175,13 @@ void promptResourceMissIsMappedToTemplateNotFound() { private static final class StubComplianceChecker implements NegotiationComplianceChecker { - private NegotiationRuleCheckResult result = new NegotiationRuleCheckResult(false, false, List.of(), null); + private NegotiationRuleCheckResult result = new NegotiationRuleCheckResult(false, List.of()); + + private int invocations; @Override - public NegotiationRuleCheckResult check(String prompt, Vocabulary vocabulary) { + public NegotiationRuleCheckResult check(NegotiationContext context) { + invocations++; return result; } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java index 37986b67..9044aa98 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java @@ -67,11 +67,20 @@ public static void main(String[] args) { NegotiationDecision decision = prompt.matches("(?s).*## 信息协商结果\\R\\s*Reject.*") ? NegotiationDecision.REJECT : NegotiationDecision.ACCEPT; + NegotiationContext endingContext = NegotiationMetadataReader.readContext(endingMetadata); Map result = decision == NegotiationDecision.ACCEPT ? client.validateAcceptPromptAndDataFilling( - prompt, InformationNegotiationSchemas.accept(), NegotiationSampleFlow.ENDING_TEMPLATE_URI).data() + prompt, + endingContext, + InformationNegotiationSchemas.accept(), + NegotiationSampleFlow.ENDING_TEMPLATE_URI) + .data() : client.validateRejectPromptAndDataFilling( - prompt, InformationNegotiationSchemas.reject(), NegotiationSampleFlow.ENDING_TEMPLATE_URI).data(); + prompt, + endingContext, + InformationNegotiationSchemas.reject(), + NegotiationSampleFlow.ENDING_TEMPLATE_URI) + .data(); System.out.println("[negotiation-client] result=" + result); } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java index 1daa4667..a9915b6d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -129,7 +129,7 @@ private static Map runCase( result.put("propose_generation_succeeded", true); result.put("generated_propose_prompt", propose.promptText()); FilledParamData proposeFilled = validateProposeWithLog( - server, testCase, propose.promptText(), runId, processLogger, apiTrace); + server, testCase, propose.promptText(), context, runId, processLogger, apiTrace); result.put("propose_validation_succeeded", true); result.put("actual_propose", proposeFilled.data()); boolean proposeMatched = expectedValuesMatch(testCase.expectedPropose(), proposeFilled.data()); @@ -143,7 +143,7 @@ private static Map runCase( result.put("ending_generation_succeeded", true); result.put("generated_ending_prompt", ending.promptText()); FilledParamData endingFilled = validateEndingWithLog( - client, testCase, ending.promptText(), runId, processLogger, apiTrace); + client, testCase, ending.promptText(), responseContext, runId, processLogger, apiTrace); result.put("ending_validation_succeeded", true); result.put("actual_ending", endingFilled.data()); boolean endingMatched = expectedValuesMatch(testCase.expectedEnding(), endingFilled.data()); @@ -190,6 +190,7 @@ private static FilledParamData validateProposeWithLog( A2ATServer server, NegotiationEvaluationFlowCase testCase, String prompt, + NegotiationContext context, String runId, NegotiationEvaluationProcessLogger processLogger, List> apiTrace) throws IOException { @@ -197,7 +198,7 @@ private static FilledParamData validateProposeWithLog( Map schema = InformationNegotiationSchemas.propose(); try { FilledParamData filled = server.validateProposePromptAndDataFilling( - prompt, schema, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + prompt, context, schema, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_propose_and_fill", "propose", null, Map.of( "prompt", prompt, "schema", schema, @@ -248,6 +249,7 @@ private static FilledParamData validateEndingWithLog( A2ATClient client, NegotiationEvaluationFlowCase testCase, String prompt, + NegotiationContext context, String runId, NegotiationEvaluationProcessLogger processLogger, List> apiTrace) throws IOException { @@ -257,8 +259,10 @@ private static FilledParamData validateEndingWithLog( : InformationNegotiationSchemas.reject(); try { FilledParamData filled = "accept".equals(testCase.decision()) - ? client.validateAcceptPromptAndDataFilling(prompt, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI) - : client.validateRejectPromptAndDataFilling(prompt, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI); + ? client.validateAcceptPromptAndDataFilling( + prompt, context, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI) + : client.validateRejectPromptAndDataFilling( + prompt, context, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI); writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_" + testCase.decision() + "_and_fill", testCase.decision(), null, Map.of("prompt", prompt, "schema", schema, "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java index c867b02c..5673e0e9 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java @@ -7,7 +7,6 @@ import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationMetadataReader; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationScenario; -import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.server.A2ATServer; @@ -38,11 +37,15 @@ public void execute(RequestContext requestContext, AgentEmitter agentEmitter) th try { NegotiationMetadataReader.requireExtension(extensionHeader(requestContext)); Message message = requestContext.getMessage(); + Map metadata = message == null ? null : message.metadata(); String proposePrompt = NegotiationMetadataReader.readPrompt( - message == null ? null : message.metadata(), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); - FilledParamData proposeData = server.validateProposePromptAndDataFilling( - proposePrompt, InformationNegotiationSchemas.propose(), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); - NegotiationContext context = NegotiationSampleFlow.contextFrom(proposeData.data()); + metadata, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + NegotiationContext context = NegotiationMetadataReader.readContext(metadata); + server.validateProposePromptAndDataFilling( + proposePrompt, + context, + InformationNegotiationSchemas.propose(), + NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); MetadataContent ending = decision == NegotiationDecision.ACCEPT ? server.generateNegotiationAcceptPromptFromText( scenario.acceptText(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java index 08b67654..bec537ad 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java @@ -3,6 +3,7 @@ import java.util.Map; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.TemplateUri; /** Reads and validates Negotiation-T metadata received over A2A. */ @@ -30,6 +31,35 @@ public static String readPrompt(Map metadata, TemplateUri expectedTem return prompt; } + /** + * Reads the negotiation context carried in the {@code negotiationContext} metadata entry. + * + * @param metadata A2A message metadata of a Negotiation-T message + * @return the carried negotiation context, or {@code null} when the metadata carries no context + * @throws IllegalArgumentException when the entry exists but is malformed + */ + public static NegotiationContext readContext(Map metadata) { + if (metadata == null) { + return null; + } + Object value = metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + if (value == null) { + return null; + } + if (!(value instanceof Map contextMap)) { + throw new IllegalArgumentException("Negotiation-T context metadata must be an object"); + } + Object id = contextMap.get("id"); + Object round = contextMap.get("round"); + Object maxRounds = contextMap.get("maxRounds"); + if (!(id instanceof String idText) + || !(round instanceof Number roundNumber) + || !(maxRounds instanceof Number maxRoundsNumber)) { + throw new IllegalArgumentException("Negotiation-T context metadata is malformed"); + } + return new NegotiationContext(idText, roundNumber.intValue(), maxRoundsNumber.intValue()); + } + public static void requireExtension(String extensionHeader) { if (!ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI.equals(extensionHeader)) { throw new IllegalArgumentException("A2A-Extensions must contain the Negotiation-T extension URI"); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java index 0d73dcbf..7474155b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java @@ -28,20 +28,24 @@ public static NegotiationFlowResult run( NegotiationContext requestContext = new NegotiationContext(UUID.randomUUID().toString(), 1, 3); MetadataContent propose = client.generateNegotiationProposePromptFromText(scenario.proposeText(), requestContext, PROPOSE_TEMPLATE_URI); - String proposePrompt = NegotiationMetadataReader.readPrompt(propose.buildMetadataContent(), PROPOSE_TEMPLATE_URI); - FilledParamData proposeData = - server.validateProposePromptAndDataFilling(proposePrompt, InformationNegotiationSchemas.propose(), PROPOSE_TEMPLATE_URI); + Map proposeMetadata = propose.buildMetadataContent(); + String proposePrompt = NegotiationMetadataReader.readPrompt(proposeMetadata, PROPOSE_TEMPLATE_URI); + NegotiationContext proposeContext = NegotiationMetadataReader.readContext(proposeMetadata); + FilledParamData proposeData = server.validateProposePromptAndDataFilling( + proposePrompt, proposeContext, InformationNegotiationSchemas.propose(), PROPOSE_TEMPLATE_URI); - NegotiationContext responseContext = contextFrom(proposeData.data()); + NegotiationContext responseContext = proposeContext; MetadataContent ending = decision == NegotiationDecision.ACCEPT ? server.generateNegotiationAcceptPromptFromText(scenario.acceptText(), responseContext, ENDING_TEMPLATE_URI) : server.generateNegotiationRejectPromptFromText(scenario.rejectText(), responseContext, ENDING_TEMPLATE_URI); - String endingPrompt = NegotiationMetadataReader.readPrompt(ending.buildMetadataContent(), ENDING_TEMPLATE_URI); + Map endingMetadata = ending.buildMetadataContent(); + String endingPrompt = NegotiationMetadataReader.readPrompt(endingMetadata, ENDING_TEMPLATE_URI); + NegotiationContext endingContext = NegotiationMetadataReader.readContext(endingMetadata); FilledParamData endingData = decision == NegotiationDecision.ACCEPT ? client.validateAcceptPromptAndDataFilling( - endingPrompt, InformationNegotiationSchemas.accept(), ENDING_TEMPLATE_URI) + endingPrompt, endingContext, InformationNegotiationSchemas.accept(), ENDING_TEMPLATE_URI) : client.validateRejectPromptAndDataFilling( - endingPrompt, InformationNegotiationSchemas.reject(), ENDING_TEMPLATE_URI); + endingPrompt, endingContext, InformationNegotiationSchemas.reject(), ENDING_TEMPLATE_URI); return new NegotiationFlowResult(requestContext, propose, proposeData, ending, endingData, decision); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java index 9c2f6aaa..361bfbd8 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java @@ -74,8 +74,8 @@ private static void run(String[] args) { System.out.println("--- promptText ---"); System.out.println(content.promptText()); System.out.println("--- metadata map ---"); - for (Map.Entry entry : content.buildMetadataContent().entrySet()) { - System.out.println(" " + entry.getKey() + " = " + summarize(entry.getValue())); + for (Map.Entry entry : content.buildMetadataContent().entrySet()) { + System.out.println(" " + entry.getKey() + " = " + summarize(String.valueOf(entry.getValue()))); } } catch (RuntimeException error) { long elapsed = System.currentTimeMillis() - start; diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java index 04c67af7..ca3eec82 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java @@ -8,6 +8,7 @@ import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; import org.junit.jupiter.api.Test; class NegotiationMetadataReaderTest { @@ -25,6 +26,27 @@ void readsCanonicalNegotiationMetadata() { NegotiationMetadataReader.readPrompt(metadata, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI)); } + @Test + void readsTheNegotiationContextFromMetadata() { + Map metadata = Map.of( + ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, + "prompt", + MetadataContent.TEMPLATE_URI_METADATA_KEY, + NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri(), + MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY, + Map.of("id", "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round", 1, "maxRounds", 5)); + + assertEquals( + new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5), + NegotiationMetadataReader.readContext(metadata)); + assertEquals(null, NegotiationMetadataReader.readContext(Map.of("other", "value"))); + assertEquals(null, NegotiationMetadataReader.readContext(null)); + assertThrows( + IllegalArgumentException.class, + () -> NegotiationMetadataReader.readContext( + Map.of(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY, Map.of("id", "x")))); + } + @Test void rejectsMissingPromptAndWrongTemplateOrExtension() { assertThrows( diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java index a0a81c21..119ea760 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java @@ -39,10 +39,10 @@ class NegotiationV3ApiSurfaceTest { Map.entry("generateNegotiationAbortPromptFromText", 3), Map.entry("getNegotiationPrompts", 0), Map.entry("getNegotiationPrompt", 1), - Map.entry("validateProposePromptAndDataFilling", 3), - Map.entry("validateAcceptPromptAndDataFilling", 3), - Map.entry("validateRejectPromptAndDataFilling", 3), - Map.entry("validateAbortPromptAndDataFilling", 3)); + Map.entry("validateProposePromptAndDataFilling", 4), + Map.entry("validateAcceptPromptAndDataFilling", 4), + Map.entry("validateRejectPromptAndDataFilling", 4), + Map.entry("validateAbortPromptAndDataFilling", 4)); private static final List REMOVED_V2_METHOD_NAME_FRAGMENTS = List.of("FromNl", "FromJsonData", "validateAndExtractParams"); diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 061eba7f..0aa7786c 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -382,6 +382,8 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * parameters with the rule-level context parameters; context parameters win on conflict. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code propose} @@ -396,9 +398,12 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateProposePromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { requireTemplateUri(templateUri); - return negotiationContentService.validateProposePromptAndDataFilling(prompt, schema, templateUri); + return negotiationContentService.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); } /** @@ -409,6 +414,8 @@ public FilledParamData validateProposePromptAndDataFilling( * satisfy the accept-phase semantic constraints. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} @@ -423,9 +430,12 @@ public FilledParamData validateProposePromptAndDataFilling( * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateAcceptPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { requireTemplateUri(templateUri); - return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, schema, templateUri); + return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); } /** @@ -436,6 +446,8 @@ public FilledParamData validateAcceptPromptAndDataFilling( * satisfy the reject-phase semantic constraints. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} @@ -450,9 +462,12 @@ public FilledParamData validateAcceptPromptAndDataFilling( * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateRejectPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { requireTemplateUri(templateUri); - return negotiationContentService.validateRejectPromptAndDataFilling(prompt, schema, templateUri); + return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); } /** @@ -463,6 +478,8 @@ public FilledParamData validateRejectPromptAndDataFilling( * abort-phase semantic constraints. * * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} * @return filled parameter data carrying the context parameters and the extracted parameters @@ -476,15 +493,20 @@ public FilledParamData validateRejectPromptAndDataFilling( * {@code template_not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateAbortPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { requireTemplateUri(templateUri); - return negotiationContentService.validateAbortPromptAndDataFilling(prompt, schema, templateUri); + return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); } /** * Validates a task prompt and extracts its parameters. * * @param prompt rendered task prompt text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected task template; its prefix segment must be * {@code Task-T} @@ -506,6 +528,8 @@ public FilledParamData validateTaskPromptAndDataFilling( * Validates a notification prompt and extracts its parameters. * * @param prompt rendered notification prompt text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected notification template; its prefix segment must be * {@code Notification-T} @@ -527,6 +551,8 @@ public FilledParamData validateNotificationPromptAndDataFilling( * Validates an authorization prompt and extracts its parameters. * * @param prompt rendered authorization prompt text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is + * reported as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected authorization template; its prefix segment must be * {@code Authorization-T} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index 66e744c4..d744a280 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -76,6 +76,7 @@ void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOE NegotiationParamExtractionException.class, () -> server.validateProposePromptAndDataFilling( TASK_T_MESSAGE, + null, Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), INFORMATION_PROPOSE_TEMPLATE)); From 41d0247d90df77e01ad4187c74a31ab499a7b620 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 22:18:23 +0800 Subject: [PATCH 134/295] feat(negotiation): drop the negotiation context section from templates The negotiation session context now travels exclusively in the negotiationContext metadata entry, so the rendered message no longer carries a context section: all fourteen Negotiation-T templates drop their leading context section, the seven generators stop injecting the slot.context value, NegotiationContextRenderer and its dedicated tests are deleted, the section.context/slot.context keys leave both vocabulary files together with Vocabulary.CANONICAL_KEYS, the template lint profiles and the negotiation-context rule are dropped, and the semantic-validation system prompts lose the section.context slot entry and the id example parameter. The eighteen golden fixtures are regenerated (each losing exactly the five context lines) and reviewed, the assertion suite now pins the absence of the context section alongside the metadata context, and the private-line evaluation corpus drops the hand-written context headers from the client supplement and all one hundred completed prompts. Signed-off-by: codetalks-hw --- .../api/A2ATClientNegotiationApiTest.java | 7 +- .../A2ATClientNegotiationEnvConfigTest.java | 4 +- .../sdk/negotiation/content/Vocabulary.java | 4 +- .../generation/AbortGenerator.java | 1 - .../AbstractNegotiationGenerator.java | 13 -- .../FeasibilityEndingGenerator.java | 1 - .../FeasibilityProposeGenerator.java | 1 - .../InformationEndingGenerator.java | 1 - .../InformationProposeGenerator.java | 1 - .../NegotiationContextRenderer.java | 25 --- .../generation/TargetEndingGenerator.java | 1 - .../generation/TargetProposeGenerator.java | 1 - .../negotiation/content/VocabularyTest.java | 18 +- .../NegotiationPackageStructureTest.java | 1 - .../AbortMessageGenerationTest.java | 10 +- .../generation/FromTextLlmPipelineTest.java | 12 +- .../NegotiationContextRendererTest.java | 35 --- ...egotiationContextSectionRenderingTest.java | 65 ------ ...NegotiationGenerationOrchestratorTest.java | 11 +- .../generation/NegotiationGeneratorsTest.java | 7 +- .../NegotiationTemplateQueryBoundaryTest.java | 4 +- .../golden/GoldenFixtureComparisonTest.java | 9 +- .../golden/GoldenFixtureGeneratorTest.java | 96 ++++----- .../NegotiationLanguageSwitchTest.java | 10 +- .../NegotiationLogEventContractTest.java | 2 +- .../DefaultNegotiationTemplateLoaderTest.java | 6 +- .../InformationNegotiationScenarioTest.java | 4 +- .../MaxRoundsExhaustionScenarioTest.java | 2 +- .../TargetNegotiationScenarioTest.java | 8 +- .../golden/en-US/feasibility_accept.md | 5 - .../golden/en-US/feasibility_propose.md | 5 - .../golden/en-US/feasibility_reject.md | 5 - .../golden/en-US/information_accept.md | 5 - .../golden/en-US/information_propose.md | 5 - .../golden/en-US/information_reject.md | 5 - .../resources/golden/en-US/target_accept.md | 5 - .../resources/golden/en-US/target_propose.md | 5 - .../resources/golden/en-US/target_reject.md | 5 - .../golden/zh-CN/feasibility_accept.md | 5 - .../golden/zh-CN/feasibility_propose.md | 5 - .../golden/zh-CN/feasibility_reject.md | 5 - .../golden/zh-CN/information_accept.md | 5 - .../golden/zh-CN/information_propose.md | 5 - .../golden/zh-CN/information_reject.md | 5 - .../resources/golden/zh-CN/target_accept.md | 5 - .../resources/golden/zh-CN/target_propose.md | 5 - .../resources/golden/zh-CN/target_reject.md | 5 - .../en-US/vocabulary.json | 2 - .../zh-CN/vocabulary.json | 2 - .../en-US/system.md | 2 - .../zh-CN/system.md | 2 - .../common/abort/v1/en-US/template.md | 8 - .../common/abort/v1/zh-CN/template.md | 8 - .../accept-reject/v1/en-US/template.md | 8 - .../accept-reject/v1/zh-CN/template.md | 8 - .../propose/v1/en-US/template.md | 8 - .../propose/v1/zh-CN/template.md | 8 - .../accept-reject/v1/en-US/template.md | 8 - .../accept-reject/v1/zh-CN/template.md | 8 - .../propose/v1/en-US/template.md | 8 - .../propose/v1/zh-CN/template.md | 8 - .../accept-reject/v1/en-US/template.md | 8 - .../accept-reject/v1/zh-CN/template.md | 8 - .../propose/v1/en-US/template.md | 8 - .../propose/v1/zh-CN/template.md | 8 - .../evaluation/NegotiationEvaluationCase.java | 7 - .../NegotiationEvaluationFlowCase.java | 11 +- .../NegotiationQwenEvaluationMain.java | 12 +- .../evaluation/cases.json | 200 +++++++++--------- .../NegotiationEvaluationCaseLoaderTest.java | 13 +- .../api/A2ATServerNegotiationApiTest.java | 5 +- .../TaskPromptNegotiationCoexistenceTest.java | 4 +- tools/template_lint.py | 14 +- 73 files changed, 222 insertions(+), 614 deletions(-) delete mode 100644 a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextSectionRenderingTest.java diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index ba4f31e3..828e1645 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -56,8 +56,7 @@ void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOE assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("- id: " + UUID)); - assertTrue(result.promptText().contains("协商上下文")); + assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); Map metadata = result.buildMetadataContent(); assertEquals(3, metadata.size()); assertEquals(result.promptText(), metadata.get(result.extensionUri())); @@ -77,7 +76,7 @@ void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOE assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("Negotiation Context")); + assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); assertTrue(result.promptText().contains("Required Information Items")); } @@ -103,7 +102,7 @@ void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOExceptio assertTrue(result.promptText().contains("## 协商结果\nAbort")); assertTrue(result.promptText().contains("## 协商终止原因")); assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); - assertTrue(result.promptText().contains("- round: 5")); + assertEquals(5, result.negotiationContext().round()); } @Test diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index f1a19a60..c6a44087 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -107,7 +107,7 @@ void languageLocalRootAndMaxAttemptsAreAllObservableInFacadeBehavior() throws IO new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE); - assertTrue(result.promptText().contains("协商上下文"), "the zh-CN language must select the Chinese templates"); + assertTrue(result.promptText().contains("所需信息项"), "the zh-CN language must select the Chinese templates"); assertTrue( result.promptText().contains(CUSTOM_TEMPLATE_MARKER), "the local resource root template must win over the built-in template"); @@ -139,7 +139,7 @@ void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), INFORMATION_PROPOSE); - assertTrue(result.promptText().contains("Negotiation Context"), "the default language must be en-US"); + assertTrue(result.promptText().contains("## Information Negotiation"), "the default language must be en-US"); assertTrue(result.promptText().contains("Required Information Items")); assertEquals(7, client.getNegotiationPrompts().size(), "the built-in resources must be used by default"); assertTrue(client.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java index 311ada5a..8246c1f2 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java @@ -57,7 +57,6 @@ public final class Vocabulary { /** The language-neutral canonical key set every vocabulary file must define exactly, in a fixed order. */ public static final List CANONICAL_KEYS = List.of( - "section.context", "section.termination_reason", "section.info_items", "section.info_static", @@ -74,7 +73,6 @@ public final class Vocabulary { "section.feasibility_infeasible", "section.feasibility_conclusion", "section.feasibility_confirm", - "slot.context", "slot.termination_reason", "slot.info_items", "slot.info_conclusion", @@ -151,7 +149,7 @@ public static Vocabulary forLanguage(String language, @Nullable Path localRootDi /** * Returns the text constant registered under one canonical key. * - * @param canonicalKey canonical vocabulary key such as {@code section.context} or {@code punct.list_colon} + * @param canonicalKey canonical vocabulary key such as {@code section.termination_reason} or {@code punct.list_colon} * @return language-specific text constant * @throws IllegalArgumentException if the key is not part of the vocabulary */ diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java index d411118d..ecf2add1 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java @@ -32,7 +32,6 @@ public String generate( NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { NegotiationAbortContent abortContent = contentOf(content, NegotiationAbortContent.class, "Abort generator"); Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); slots.put( vocabulary.get("slot.termination_reason"), requiredText( diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java index 9d4424db..76c76909 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java @@ -20,8 +20,6 @@ abstract class AbstractNegotiationGenerator implements NegotiationGenerator { private final NegotiationPromptRenderer promptRenderer = new NegotiationPromptRenderer(); - private final NegotiationContextRenderer contextRenderer = new NegotiationContextRenderer(); - private final NegotiationItemFormatter itemFormatter = new NegotiationItemFormatter(); /** @@ -99,17 +97,6 @@ protected static List requiredItems( return items; } - /** - * Builds the slot value for the negotiation context section. - * - * @param context negotiation context of the message - * @param vocabulary vocabulary of the message language - * @return rendered context list - */ - protected String contextSlotValue(NegotiationContext context, Vocabulary vocabulary) { - return contextRenderer.render(context); - } - /** * Formats an item list with the list punctuation of the message language. * diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java index 6744d5de..5f99dfaa 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java @@ -38,7 +38,6 @@ public String generate( "content.feasibilitySummary", "Feasibility summary of a terminal feasibility negotiation message"); Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); slots.put( vocabulary.get("slot.feasibility_conclusion"), endingContent.conclusion().literal()); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java index 536e2b31..d543e0e1 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java @@ -44,7 +44,6 @@ public String generate( action, "Feasibility negotiation action must not be null; it selects the conditional sections of the message."); Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); slots.put(vocabulary.get("slot.feasibility"), proposeContent.feasibilityNegotiationDescription()); if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { slots.put( diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java index 82dc3f71..2524e4c3 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java @@ -32,7 +32,6 @@ public String generate( InformationEndingContent endingContent = contentOf(content, InformationEndingContent.class, "Information ending generator"); renderableConclusion(endingContent.conclusion()); Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); slots.put( vocabulary.get("slot.info_conclusion"), endingContent.conclusion().literal()); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java index 0c9ce8cf..8ffea117 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java @@ -33,7 +33,6 @@ public String generate( InformationProposeContent proposeContent = contentOf(content, InformationProposeContent.class, "Information propose generator"); Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); slots.put(vocabulary.get("slot.info_items"), itemsSlotValue(proposeContent, vocabulary)); return render(template, slots); } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java deleted file mode 100644 index 6d0bcdc4..00000000 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRenderer.java +++ /dev/null @@ -1,25 +0,0 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.Objects; -import net.openan.a2at.sdk.core.model.NegotiationContext; - -/** - * Assembles the negotiation context into the single slot value used by context sections. - * - *

      The context travels in one template slot as a markdown list whose field names are language-neutral. - */ -final class NegotiationContextRenderer { - - /** - * Renders one negotiation context as a markdown list. - * - * @param context negotiation context to render - * @return three lines of the form {@code - id: ...}, {@code - round: ...}, {@code - maxRounds: ...} joined by - * single newlines - * @throws NullPointerException if the context is null - */ - public String render(NegotiationContext context) { - Objects.requireNonNull(context, "Negotiation context must not be null."); - return "- id: " + context.id() + "\n- round: " + context.round() + "\n- maxRounds: " + context.maxRounds(); - } -} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java index ed6aad4b..a6916cf1 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java @@ -34,7 +34,6 @@ public String generate( TargetEndingContent endingContent = contentOf(content, TargetEndingContent.class, "Target ending generator"); NegotiationConclusion conclusion = renderableConclusion(endingContent.conclusion()); Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); slots.put(vocabulary.get("slot.target_conclusion"), conclusion.literal()); slots.put(vocabulary.get("slot.target_result_content"), resultContentSlotValue(endingContent, conclusion)); return render(template, slots); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java index efceffa5..2206e3d1 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java @@ -38,7 +38,6 @@ public String generate( "content.targetNegotiationDescription", "Target negotiation description"); Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.context"), contextSlotValue(context, vocabulary)); slots.put(vocabulary.get("slot.target"), proposeContent.targetNegotiationDescription()); if (context.round() == 1) { slots.put( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java index f3b59b79..690d9410 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java @@ -23,7 +23,6 @@ class VocabularyTest { Path localRoot; private static final Set CANONICAL_KEYS = Set.of( - "section.context", "section.termination_reason", "section.info_items", "section.info_static", @@ -40,7 +39,6 @@ class VocabularyTest { "section.feasibility_infeasible", "section.feasibility_conclusion", "section.feasibility_confirm", - "slot.context", "slot.termination_reason", "slot.info_items", "slot.info_conclusion", @@ -68,8 +66,8 @@ void bothLanguagesExposeExactlyTheCanonicalKeys() { assertEquals(CANONICAL_KEYS, enUs.canonicalKeys()); assertEquals(Set.copyOf(Vocabulary.CANONICAL_KEYS), zhCn.canonicalKeys()); assertEquals(new TreeSet<>(zhCn.canonicalKeys()), new TreeSet<>(enUs.canonicalKeys())); - assertEquals(35, zhCn.canonicalKeys().size()); - assertEquals(35, Vocabulary.CANONICAL_KEYS.size()); + assertEquals(33, zhCn.canonicalKeys().size()); + assertEquals(33, Vocabulary.CANONICAL_KEYS.size()); } @Test @@ -88,9 +86,7 @@ void slotPlaceholdersAreSnakeCaseForEnglishAndCjkForChinese() { Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); Vocabulary enUs = Vocabulary.forLanguage("en-US"); - // The context placeholder is snake_case for en-US and the matching CJK section title for zh-CN. - assertEquals("协商上下文", zhCn.get("slot.context")); - assertEquals("negotiation_context", enUs.get("slot.context")); + // The placeholders are snake_case for en-US and the matching CJK section title for zh-CN. assertEquals("意图理解陈述", zhCn.get("slot.target_intent")); assertEquals("intent_understanding_statement", enUs.get("slot.target_intent")); } @@ -113,8 +109,6 @@ void sectionValuesMatchTemplateSectionTitles() { Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); Vocabulary enUs = Vocabulary.forLanguage("en-US"); - assertEquals("协商上下文", zhCn.get("section.context")); - assertEquals("Negotiation Context", enUs.get("section.context")); assertEquals("目标协商结果内容", zhCn.get("section.target_result_content")); assertEquals("Target Negotiation Result Content", enUs.get("section.target_result_content")); assertEquals("可行性评估结果确认", zhCn.get("section.feasibility_confirm")); @@ -159,12 +153,12 @@ void unknownKeyThrows() { @Test void localVocabularyWinsOverTheClasspathOne() throws IOException { Map entries = classpathEntriesOf("zh-CN"); - entries.put("section.context", "自定义协商上下文"); + entries.put("section.termination_reason", "自定义终止原因"); writeLocalVocabulary("zh-CN", entries); Vocabulary overridden = Vocabulary.forLanguage("zh-CN", localRoot); - assertEquals("自定义协商上下文", overridden.get("section.context")); + assertEquals("自定义终止原因", overridden.get("section.termination_reason")); assertEquals(Vocabulary.forLanguage("zh-CN").get("punct.list_colon"), overridden.get("punct.list_colon")); assertEquals(Vocabulary.forLanguage("zh-CN").canonicalKeys(), overridden.canonicalKeys()); assertEquals("zh-CN", overridden.language()); @@ -174,7 +168,7 @@ void localVocabularyWinsOverTheClasspathOne() throws IOException { void localRootWithoutAVocabularyFallsBackToTheClasspath() { Vocabulary fallback = Vocabulary.forLanguage("en-US", localRoot); - assertEquals(Vocabulary.forLanguage("en-US").get("slot.context"), fallback.get("slot.context")); + assertEquals(Vocabulary.forLanguage("en-US").get("slot.target_intent"), fallback.get("slot.target_intent")); assertEquals(Vocabulary.forLanguage("en-US").canonicalKeys(), fallback.canonicalKeys()); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java index 55b77837..0d4d634e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -75,7 +75,6 @@ void generationPackageExposesExactlyThePinnedTypes() throws IOException { "InformationProposeGenerator.java", "NegotiationContentExtractor.java", "NegotiationContentService.java", - "NegotiationContextRenderer.java", "NegotiationGenerationOrchestrator.java", "NegotiationGenerationOrchestratorBuilder.java", "NegotiationGenerator.java", diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java index c18b6983..a4457e53 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java @@ -54,13 +54,11 @@ void generatesAChineseAbortMessageFromDataWithoutAnyLlmCall() { assertEquals(ABORT_URI.uri(), result.templateUri()); String text = result.promptText(); - assertTrue(text.contains("## 协商上下文"), "the context section must be rendered"); + assertFalse(text.contains("## 协商上下文"), "the context section must not be rendered"); assertTrue(text.contains("## 协商结果\nAbort"), "the fixed Abort conclusion must be kept"); assertTrue(text.contains("## 协商终止原因"), "the termination reason section must be rendered"); assertTrue(text.contains("达到协商轮次上限,本次协商确认结束。")); - assertTrue(text.contains("- id: " + SESSION_ID)); - assertTrue(text.contains("- round: 5")); - assertTrue(text.contains("- maxRounds: 5")); + assertEquals(new NegotiationContext(SESSION_ID, 5, 5), result.negotiationContext()); assertFalse(text.contains("{{"), "no unreplaced placeholder may remain"); assertEquals(0, llm.calls, "the from-data variant must never call the LLM"); } @@ -76,11 +74,11 @@ void generatesAnEnglishAbortMessageFromData() { assertEquals(ABORT_URI.uri(), result.templateUri()); String text = result.promptText(); - assertTrue(text.contains("## Negotiation Context")); + assertFalse(text.contains("## Negotiation Context"), "the context section must not be rendered"); assertTrue(text.contains("## Negotiation Result\nAbort")); assertTrue(text.contains("## Negotiation Termination Reason")); assertTrue(text.contains("Reached the negotiation round limit.")); - assertTrue(text.contains("- round: 3")); + assertEquals(new NegotiationContext(SESSION_ID, 3, 5), result.negotiationContext()); assertFalse(text.contains("{{")); assertEquals(0, llm.calls); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 2d62c48a..51870df7 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -527,8 +527,8 @@ void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOEx } /** - * IT-B-008: the negotiation context of the rendered message comes from the caller-supplied context: the extraction - * schema sent to the LLM has no context fields and the output carries exactly the caller's context values. + * IT-B-008: the negotiation context of the generated message comes from the caller-supplied context: the + * extraction schema sent to the LLM has no context fields and the metadata carries exactly the caller's context. */ @Test void contextIsInjectedFromTheCallerWithoutAnyLlmInvolvement() { @@ -544,10 +544,10 @@ void contextIsInjectedFromTheCallerWithoutAnyLlmInvolvement() { assertFalse(schemaProperties.containsKey("id"), "the extraction schema must not ask for the context id"); assertFalse(schemaProperties.containsKey("round"), "the extraction schema must not ask for the round"); assertFalse(schemaProperties.containsKey("maxRounds"), "the extraction schema must not ask for the limit"); - String promptText = result.promptText(); - assertTrue(promptText.contains("- id: " + GoldenInputs.SESSION_ID)); - assertTrue(promptText.contains("- round: 4")); - assertTrue(promptText.contains("- maxRounds: 7")); + assertEquals(context, result.negotiationContext(), "the caller context travels in the metadata"); + assertFalse( + result.promptText().contains("- id: " + GoldenInputs.SESSION_ID), + "the context lines must not be rendered into the message"); } private static NegotiationGenerationOrchestrator orchestrator( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java deleted file mode 100644 index 3be53ce8..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextRendererTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import net.openan.a2at.sdk.core.model.NegotiationContext; -import org.junit.jupiter.api.Test; - -class NegotiationContextRendererTest { - - private final NegotiationContextRenderer renderer = new NegotiationContextRenderer(); - - @Test - void rendersContextAsMarkdownListWithLanguageNeutralFieldNames() { - NegotiationContext context = new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5); - - String rendered = renderer.render(context); - - assertEquals("- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 2\n- maxRounds: 5", rendered); - } - - @Test - void rendersContextBuiltByFactoryAndNextRound() { - NegotiationContext context = NegotiationContext.of("id-1", 1).nextRound(); - - assertEquals("- id: id-1\n- round: 2\n- maxRounds: 5", renderer.render(context)); - } - - @Test - void rejectsNullContext() { - NullPointerException exception = assertThrows(NullPointerException.class, () -> renderer.render(null)); - - assertEquals("Negotiation context must not be null.", exception.getMessage()); - } -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextSectionRenderingTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextSectionRenderingTest.java deleted file mode 100644 index fe9bb380..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContextSectionRenderingTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.golden.GoldenInputs; -import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -/** - * Verifies the rendered format of the negotiation context section of every from-data message. - * - *

      The context section is a three-line markdown list whose field names are language-neutral camelCase identifiers: - * {@code - id: }, {@code - round: } and {@code - maxRounds: }, in exactly this order, regardless of the - * message language or the negotiation type and phase. - */ -class NegotiationContextSectionRenderingTest { - - static Stream goldenCases() { - List cases = new ArrayList<>(); - for (String language : GoldenInputs.LANGUAGES) { - for (GoldenCase goldenCase : GoldenCase.values()) { - cases.add(Arguments.of(goldenCase, language)); - } - } - return cases.stream(); - } - - @ParameterizedTest(name = "{0} [{1}] renders the context as a three-line markdown list") - @MethodSource("goldenCases") - void rendersTheContextAsAThreeLineMarkdownList(GoldenCase goldenCase, String language) { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .build(); - - MetadataContent result = goldenCase.generate(orchestrator); - String contextTitle = "zh-CN".equals(language) ? "协商上下文" : "Negotiation Context"; - String expectedSection = "## " + contextTitle + "\n- id: " + GoldenInputs.SESSION_ID + "\n- round: " - + goldenCase.context().round() + "\n- maxRounds: " - + goldenCase.context().maxRounds(); - - assertTrue( - result.promptText().contains(expectedSection), "the context section must be the fixed three-line list"); - assertEquals( - 1, - countOccurrences(result.promptText(), expectedSection), - "the context section must appear exactly once"); - } - - private static int countOccurrences(String text, String needle) { - int count = 0; - int index = 0; - while ((index = text.indexOf(needle, index)) >= 0) { - count++; - index += needle.length(); - } - return count; - } -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index e56fa5bf..05142166 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -47,10 +47,7 @@ void generatesInformationProposeFromDataInChinese() { assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertEquals(NegotiationHandler.NEGOTIATION_T_URI, result.extensionUri()); assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("- id: " + UUID)); - assertTrue(result.promptText().contains("- round: 1")); - assertTrue(result.promptText().contains("- maxRounds: 5")); - assertTrue(result.promptText().contains("协商上下文")); + assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); assertTrue(result.promptText().contains("所需信息项")); Map metadata = result.buildMetadataContent(); @@ -77,8 +74,8 @@ void generatesInformationProposeFromDataInEnglish() { assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("- id: " + UUID)); - assertTrue(result.promptText().contains("Negotiation Context")); + assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); + assertEquals(new NegotiationContext(UUID, 2, 5), result.negotiationContext()); assertTrue(result.promptText().contains("Required Information Items")); } @@ -97,7 +94,7 @@ void generatesProposeFromTextWithScriptedExtraction() { assertEquals(1, llm.calls); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("- round: 2")); + assertEquals(2, result.negotiationContext().round()); assertTrue(result.promptText().contains("故障发生时间")); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java index f67590a7..471dcedf 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -356,10 +356,6 @@ void informationProposeRendersEnglishRelationshipLabelWithTrailingSpace() { PromptTemplate enTemplate = template( StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, """ - ## Negotiation Context - {{negotiation_context}} (required) - Requirement: context. - ## Required Information Items {{required_information_items}} (required) Requirement: items. @@ -521,7 +517,8 @@ void generatorsRenderAgainstTheBundledTemplates() { String rendered = new TargetProposeGenerator().generate(context(1), content, loaded, zhVocabulary); - assertTrue(rendered.startsWith("## 协商上下文\n- id: " + CONTEXT_ID + "\n- round: 1\n- maxRounds: 5")); + assertTrue(rendered.startsWith("## 目标协商\n")); + assertFalse(rendered.contains("协商上下文"), "the context section must not be rendered"); assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。")); assertTrue(rendered.contains("## 意图理解陈述")); assertFalse(rendered.contains("## 理解对齐与疑问澄清")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java index 76ba4a0a..f498325e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java @@ -95,8 +95,8 @@ void singleQueryResolvesAValidUriIntoItsTemplate(String language) { assertTrue(template.isPresent()); assertEquals(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, template.orElseThrow().templateUri()); - String contextTitle = "zh-CN".equals(language) ? "## 协商上下文" : "## Negotiation Context"; - assertTrue(template.orElseThrow().content().contains(contextTitle)); + String firstSection = "zh-CN".equals(language) ? "## 可行性协商" : "## Feasibility Negotiation"; + assertTrue(template.orElseThrow().content().startsWith(firstSection)); assertTrue( template.orElseThrow().content().startsWith("## "), "templates start directly with the first section"); assertEquals("", template.orElseThrow().description(), "templates carry no description comment"); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java index 0b87cc44..d94c4221 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java @@ -127,12 +127,9 @@ void keepsTheStructuralInvariantsOfRenderedMessages(GoldenCase goldenCase, Strin String promptText = result.promptText(); String contextTitle = "zh-CN".equals(language) ? "协商上下文" : "Negotiation Context"; - assertTrue(promptText.startsWith("## " + contextTitle + "\n"), "message must start with the context section"); - assertTrue( - promptText.contains("- id: " + GoldenInputs.SESSION_ID + "\n- round: " - + goldenCase.context().round() + "\n- maxRounds: " - + goldenCase.context().maxRounds()), - "context section must be the fixed three-line list"); + assertFalse(promptText.contains(contextTitle), "the context section must not be rendered into the message"); + assertFalse(promptText.contains("- id: " + GoldenInputs.SESSION_ID), "context lines must not be rendered"); + assertEquals(goldenCase.context(), result.negotiationContext(), "the context travels in the metadata"); assertFalse(promptText.endsWith("\n"), "message must not end with a newline"); assertFalse(promptText.contains("\n\n\n"), "sections must be joined by exactly one blank line"); assertFalse(promptText.contains("{{"), "no unreplaced slot placeholder may remain"); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java index 62a387f6..b9aea53a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java @@ -1,48 +1,48 @@ -package net.openan.a2at.sdk.negotiation.golden; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -/** - * Regeneration helper for the golden fixture set of the negotiation content layer. - * - *

      The test writes the 18 golden fixture files (9 type/phase combinations x 2 languages) under - * {@code src/test/resources/golden/} by rendering the fixed {@link GoldenInputs} through an orchestrator wired with the - * real built-in resources. It is disabled in normal builds: golden fixtures are locked by - * {@link GoldenFixtureComparisonTest} and may only change together with a reviewed template or vocabulary revision. To - * regenerate, temporarily enable this test, run it from the {@code a2a-t-negotiation} module directory and review the - * resulting files before committing them. - */ -@Disabled("Golden fixture regeneration helper: enable manually only for a reviewed template or vocabulary change.") -class GoldenFixtureGeneratorTest { - - private static final Path GOLDEN_ROOT = Path.of("src", "test", "resources", "golden"); - - @Test - void writeAllGoldenFixtures() throws IOException { - if (!Files.isDirectory(GOLDEN_ROOT.getParent())) { - throw new IllegalStateException( - "Golden fixtures can only be regenerated from the a2a-t-negotiation module directory but the" - + " test resources directory does not exist: " - + GOLDEN_ROOT.getParent().toAbsolutePath()); - } - for (String language : GoldenInputs.LANGUAGES) { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .build(); - for (GoldenInputs.GoldenCase goldenCase : GoldenInputs.GoldenCase.values()) { - MetadataContent result = goldenCase.generate(orchestrator); - Path target = GOLDEN_ROOT.resolve(language).resolve(goldenCase.fileName()); - Files.createDirectories(target.getParent()); - Files.writeString(target, result.promptText(), StandardCharsets.UTF_8); - } - } - } -} +package net.openan.a2at.sdk.negotiation.golden; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +/** + * Regeneration helper for the golden fixture set of the negotiation content layer. + * + *

      The test writes the 18 golden fixture files (9 type/phase combinations x 2 languages) under + * {@code src/test/resources/golden/} by rendering the fixed {@link GoldenInputs} through an orchestrator wired with the + * real built-in resources. It is disabled in normal builds: golden fixtures are locked by + * {@link GoldenFixtureComparisonTest} and may only change together with a reviewed template or vocabulary revision. To + * regenerate, temporarily enable this test, run it from the {@code a2a-t-negotiation} module directory and review the + * resulting files before committing them. + */ +@Disabled("Golden fixture regeneration helper: enable manually only for a reviewed template or vocabulary change.") +class GoldenFixtureGeneratorTest { + + private static final Path GOLDEN_ROOT = Path.of("src", "test", "resources", "golden"); + + @Test + void writeAllGoldenFixtures() throws IOException { + if (!Files.isDirectory(GOLDEN_ROOT.getParent())) { + throw new IllegalStateException( + "Golden fixtures can only be regenerated from the a2a-t-negotiation module directory but the" + + " test resources directory does not exist: " + + GOLDEN_ROOT.getParent().toAbsolutePath()); + } + for (String language : GoldenInputs.LANGUAGES) { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .build(); + for (GoldenInputs.GoldenCase goldenCase : GoldenInputs.GoldenCase.values()) { + MetadataContent result = goldenCase.generate(orchestrator); + Path target = GOLDEN_ROOT.resolve(language).resolve(goldenCase.fileName()); + Files.createDirectories(target.getParent()); + Files.writeString(target, result.promptText(), StandardCharsets.UTF_8); + } + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java index 60fa7a3b..04ccf743 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java @@ -58,24 +58,24 @@ void sameInputRendersTheRespectiveGoldenFixturePerLanguage(GoldenCase goldenCase @ValueSource(strings = {GoldenInputs.ZH_CN, GoldenInputs.EN_US}) void templateQueriesReturnTheTemplatesOfTheConfiguredLanguage(String language) { NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - String contextTitle = GoldenInputs.ZH_CN.equals(language) ? "## 协商上下文" : "## Negotiation Context"; - String otherLanguageTitle = GoldenInputs.ZH_CN.equals(language) ? "## Negotiation Context" : "## 协商上下文"; + String requirementLabel = GoldenInputs.ZH_CN.equals(language) ? "要求:" : "Requirement:"; + String otherLanguageLabel = GoldenInputs.ZH_CN.equals(language) ? "Requirement:" : "要求:"; List templates = orchestrator.getNegotiationPrompts(); assertEquals(7, templates.size()); for (PromptTemplate template : templates) { assertTrue( - template.content().contains(contextTitle), + template.content().contains(requirementLabel), template.templateUri().uri() + " must be a " + language + " template"); assertTrue( - !template.content().contains(otherLanguageTitle), + !template.content().contains(otherLanguageLabel), template.templateUri().uri() + " must not leak the other language"); } PromptTemplate queriedTemplate = orchestrator.getNegotiationPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); - assertTrue(queriedTemplate.content().contains(contextTitle)); + assertTrue(queriedTemplate.content().contains(requirementLabel)); } @Test diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index 0a82941d..95d7880b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -243,7 +243,7 @@ private void driveSuccessfulGenerationFromData() { new NegotiationContext(UUID, 1, 5), new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE_URI); - assertTrue(result.promptText().contains("协商上下文")); + assertEquals(new NegotiationContext(UUID, 1, 5), result.negotiationContext()); } private void driveSuccessfulGenerationFromText() { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java index 14460e5d..1a19aa43 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java @@ -56,7 +56,7 @@ void loadReturnsTheCommonAbortTemplateForTheAbortPhase() { zhCnLoader.load(new NegotiationReference(null, NegotiationPhase.ABORT, "zh-CN")); assertEquals(StandardTemplates.NEGOTIATION_ABORT, englishTemplate.templateUri()); - assertTrue(englishTemplate.content().startsWith("## Negotiation Context")); + assertTrue(englishTemplate.content().startsWith("## Negotiation Result")); assertTrue(englishTemplate.content().contains("## Negotiation Termination Reason")); assertTrue(englishTemplate.content().contains("{{negotiation_termination_reason}}")); assertEquals(StandardTemplates.NEGOTIATION_ABORT, chineseTemplate.templateUri()); @@ -97,7 +97,7 @@ void loadReturnsFullTemplateContentFromTheClasspath() { loader.load(new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "en-US")); assertEquals(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, template.templateUri()); - assertTrue(template.content().startsWith("## Negotiation Context")); + assertTrue(template.content().startsWith("## Information Negotiation")); } @Test @@ -142,7 +142,7 @@ void customRootTemplateWinsWhilePresentAndBuiltinIsUsedAfterRemoval() throws IOE PromptTemplate fallback = loader.load(new NegotiationReference(NegotiationType.INFORMATION, NegotiationPhase.PROPOSE, "zh-CN")); - assertTrue(fallback.content().contains("## 协商上下文")); + assertTrue(fallback.content().contains("## 信息协商")); assertTrue(!fallback.content().contains("自定义标记内容")); assertEquals("", fallback.description()); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java index e5d929f1..6cbcda1f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java @@ -70,7 +70,7 @@ void informationNegotiationReachesTheTerminalStateWithinOneRound() { INFORMATION_PROPOSE_URI); assertTrue(request.promptText().contains("## 所需信息项")); assertTrue(request.promptText().contains("1. 节能区域信息:如松山湖")); - assertTrue(request.promptText().contains("- round: 1")); + assertEquals(1, request.negotiationContext().round()); FilledParamData requestParameters = responder.validateProposePromptAndDataFilling( request.promptText(), round, parameterSchema(), INFORMATION_PROPOSE_URI); @@ -92,7 +92,7 @@ void informationNegotiationReachesTheTerminalStateWithinOneRound() { assertTrue(answer.promptText().contains("## 信息协商结果\nAccept")); assertTrue(answer.promptText().contains("## 信息协商结果内容")); assertTrue(answer.promptText().contains("1. 节能区域信息:松山湖")); - assertTrue(answer.promptText().contains("- round: 1"), "the terminal answer shares the round of the request"); + assertEquals(1, answer.negotiationContext().round(), "the terminal answer shares the round of the request"); FilledParamData answerParameters = requester.validateAcceptPromptAndDataFilling( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java index 1484529e..ff0d6996 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java @@ -49,7 +49,7 @@ lastRound, new NegotiationAbortContent("达到协商轮次上限,本次协商 assertTrue(terminal.promptText().contains("## 协商结果\nAbort")); assertTrue(terminal.promptText().contains("## 协商终止原因")); assertTrue(terminal.promptText().contains("达到协商轮次上限,本次协商确认结束。")); - assertTrue(terminal.promptText().contains("- round: 5")); + assertEquals(5, terminal.negotiationContext().round()); FilledParamData terminalParameters = peer.validateAbortPromptAndDataFilling( terminal.promptText(), lastRound, parameterSchema(), StandardTemplates.NEGOTIATION_ABORT); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java index feb6e7b1..e5351e63 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java @@ -62,7 +62,7 @@ void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { assertTrue( !firstProposal.promptText().contains("## 理解对齐与疑问澄清"), "round 1 must not carry the alignment section"); assertTrue(firstProposal.promptText().contains("## 待澄清内容")); - assertTrue(firstProposal.promptText().contains("- round: 1")); + assertEquals(1, firstProposal.negotiationContext().round()); FilledParamData firstRoundParameters = responder.validateProposePromptAndDataFilling( firstProposal.promptText(), firstRound, nestedParameterSchema(), TARGET_PROPOSE_URI); @@ -82,7 +82,7 @@ void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { TARGET_ACCEPT_URI); assertTrue(rejection.promptText().contains("## 目标协商结果\nReject")); assertTrue(rejection.promptText().contains("节能区域信息因站点清单不可用而无法完整澄清")); - assertTrue(rejection.promptText().contains("- round: 1")); + assertEquals(1, rejection.negotiationContext().round()); requester.validateRejectPromptAndDataFilling( rejection.promptText(), firstRound, parameterSchema(), TARGET_ACCEPT_URI); @@ -108,7 +108,7 @@ void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { assertTrue( !secondProposal.promptText().contains("## 待澄清内容"), "an empty clarification list must drop the whole section"); - assertTrue(secondProposal.promptText().contains("- round: 2")); + assertEquals(2, secondProposal.negotiationContext().round()); MetadataContent acceptance = responder.generateAcceptFromData( new NegotiationEndingData( @@ -118,7 +118,7 @@ void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { TARGET_ACCEPT_URI); assertTrue(acceptance.promptText().contains("## 目标协商结果\nAccept")); assertTrue(acceptance.promptText().contains("最终确认意图:08:00-18:00对松山湖站点启用无线节能优化")); - assertTrue(acceptance.promptText().contains("- round: 2")); + assertEquals(2, acceptance.negotiationContext().round()); FilledParamData terminalParameters = requester.validateAcceptPromptAndDataFilling( acceptance.promptText(), secondRound, parameterSchema(), TARGET_ACCEPT_URI); diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md index 673de51f..1b2abddc 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md @@ -1,8 +1,3 @@ -## Negotiation Context -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## Feasibility Negotiation Result Accept diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md index 60e535ed..da93ebb4 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md @@ -1,8 +1,3 @@ -## Negotiation Context -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## Feasibility Negotiation Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess. diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md index 08319b1c..4c0b7c69 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md @@ -1,8 +1,3 @@ -## Negotiation Context -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## Feasibility Negotiation Result Reject diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/information_accept.md b/a2a-t-negotiation/src/test/resources/golden/en-US/information_accept.md index 44b33c60..59e54f3f 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/information_accept.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/information_accept.md @@ -1,8 +1,3 @@ -## Negotiation Context -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## Information Negotiation Result Accept diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md b/a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md index 12929775..8a71b28c 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md @@ -1,8 +1,3 @@ -## Negotiation Context -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## Information Negotiation Please supplement the relevant content based on . diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/information_reject.md b/a2a-t-negotiation/src/test/resources/golden/en-US/information_reject.md index d9b5e84f..fbb5b1d6 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/information_reject.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/information_reject.md @@ -1,8 +1,3 @@ -## Negotiation Context -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## Information Negotiation Result Reject diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/target_accept.md b/a2a-t-negotiation/src/test/resources/golden/en-US/target_accept.md index 7296b38b..b4c88a88 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/target_accept.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/target_accept.md @@ -1,8 +1,3 @@ -## Negotiation Context -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## Target Negotiation Result Accept diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md b/a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md index 3f1e3857..34d60714 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md @@ -1,8 +1,3 @@ -## Negotiation Context -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 1 -- maxRounds: 5 - ## Target Negotiation The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm. diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/target_reject.md b/a2a-t-negotiation/src/test/resources/golden/en-US/target_reject.md index 591a91b3..d895b0ab 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/target_reject.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/target_reject.md @@ -1,8 +1,3 @@ -## Negotiation Context -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## Target Negotiation Result Reject diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_accept.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_accept.md index cbfbdefa..b480b61c 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_accept.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_accept.md @@ -1,8 +1,3 @@ -## 协商上下文 -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## 可行性协商结果 Accept diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md index 2e953e79..4eb0b26b 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md @@ -1,8 +1,3 @@ -## 协商上下文 -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## 可行性协商 Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess. diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_reject.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_reject.md index 19e868c8..dc8725f8 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_reject.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_reject.md @@ -1,8 +1,3 @@ -## 协商上下文 -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## 可行性协商结果 Reject diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_accept.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_accept.md index 1da7b4a4..6ea81cf2 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_accept.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_accept.md @@ -1,8 +1,3 @@ -## 协商上下文 -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## 信息协商结果 Accept diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_propose.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_propose.md index 3e7073b9..1a6b9bae 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_propose.md @@ -1,8 +1,3 @@ -## 协商上下文 -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## 信息协商 请根据<所需信息项>补充相关内容。 diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_reject.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_reject.md index a21d43c1..94a95cbe 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_reject.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_reject.md @@ -1,8 +1,3 @@ -## 协商上下文 -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## 信息协商结果 Reject diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_accept.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_accept.md index 296565a0..67c45b44 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_accept.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_accept.md @@ -1,8 +1,3 @@ -## 协商上下文 -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## 目标协商结果 Accept diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md index f4e4c11d..998d11da 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md @@ -1,8 +1,3 @@ -## 协商上下文 -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 1 -- maxRounds: 5 - ## 目标协商 The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm. diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_reject.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_reject.md index ef1e2469..86d1157f 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_reject.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_reject.md @@ -1,8 +1,3 @@ -## 协商上下文 -- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 2 -- maxRounds: 5 - ## 目标协商结果 Reject diff --git a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json index 0be775f3..bd7417ea 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json @@ -1,5 +1,4 @@ { - "section.context": "Negotiation Context", "section.termination_reason": "Negotiation Termination Reason", "section.info_items": "Required Information Items", "section.info_static": "Information Negotiation", @@ -16,7 +15,6 @@ "section.feasibility_infeasible": "Infeasible Evaluation Details and Proposal", "section.feasibility_conclusion": "Feasibility Negotiation Result", "section.feasibility_confirm": "Feasibility Assessment Result Confirmation", - "slot.context": "negotiation_context", "slot.termination_reason": "negotiation_termination_reason", "slot.info_items": "required_information_items", "slot.info_conclusion": "information_negotiation_result", diff --git a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json index 49636911..f17996ab 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json @@ -1,5 +1,4 @@ { - "section.context": "协商上下文", "section.termination_reason": "协商终止原因", "section.info_items": "所需信息项", "section.info_static": "信息协商", @@ -16,7 +15,6 @@ "section.feasibility_infeasible": "评估不可行时的详情和提案", "section.feasibility_conclusion": "可行性协商结果", "section.feasibility_confirm": "可行性评估结果确认", - "slot.context": "协商上下文", "slot.termination_reason": "协商终止原因", "slot.info_items": "所需信息项", "slot.info_conclusion": "信息协商结果", diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md index afbc5ee5..c738833a 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md @@ -35,7 +35,6 @@ Output exactly one JSON object containing exactly the following 4 required keys; ## slot_name Convention The slot_name of semantic and structural semantics errors must use the following language-neutral canonical keys, chosen by the message section at fault: -- section.context: Negotiation Context - section.termination_reason: Negotiation Termination Reason - section.info_static: Information Negotiation - section.info_items: Required Information Items @@ -67,7 +66,6 @@ The params in the following examples only illustrate the structure; the actual p "negotiation_type": "feasibility", "errors": [], "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "confirmed_rate_mbps": 2 } } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md index e5b8af0e..bc7dc0b5 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md @@ -35,7 +35,6 @@ ## slot_name 规范 语义错误与结构语义错误的 slot_name 必须使用以下语言无关的规范键,按出错的报文板块选择: -- section.context:协商上下文(Negotiation Context) - section.termination_reason:协商终止原因(Negotiation Termination Reason) - section.info_static:信息协商(Information Negotiation) - section.info_items:所需信息项(Required Information Items) @@ -67,7 +66,6 @@ message 必须为英文。 "negotiation_type": "feasibility", "errors": [], "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "confirmed_rate_mbps": 2 } } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/en-US/template.md index b82f170a..113a7489 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/en-US/template.md @@ -1,11 +1,3 @@ -## Negotiation Context -{{negotiation_context}} (required) -Requirement: -Each negotiation must contain the following information: -- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept, Reject, Abort), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: The current round. Type: positive integer. Example: 1 -- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 - ## Negotiation Result Abort diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/zh-CN/template.md index 5c425448..4c177577 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/zh-CN/template.md @@ -1,11 +1,3 @@ -## 协商上下文 -{{协商上下文}}(必填) -要求: -每次协商必须包含以下信息: -- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept、Reject、Abort),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 当前轮次;类型:正整数;举例:1 -- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 - ## 协商结果 Abort diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/en-US/template.md index 90dea76f..da2729e2 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/en-US/template.md @@ -1,11 +1,3 @@ -## Negotiation Context -{{negotiation_context}} (required) -Requirement: -Each negotiation must contain the following information: -- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept or Reject), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: The current round. Type: positive integer. Example: 1 -- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 - ## Feasibility Negotiation Result {{feasibility_negotiation_result}} (required) Requirement: diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/zh-CN/template.md index a72196b1..c4c1254a 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/zh-CN/template.md @@ -1,11 +1,3 @@ -## 协商上下文 -{{协商上下文}}(必填) -要求: -每次协商必须包含以下信息: -- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept或Reject),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 当前轮次;类型:正整数;举例:1 -- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 - ## 可行性协商结果 {{可行性协商结果}}(必填) 要求: diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/en-US/template.md index 8093c8c5..e32b46d7 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/en-US/template.md @@ -1,11 +1,3 @@ -## Negotiation Context -{{negotiation_context}} (required) -Requirement: -Each negotiation must contain the following information: -- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept or Reject), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: The current round. Type: positive integer. Example: 1 -- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 - ## Feasibility Negotiation {{feasibility_negotiation_summary}} (required) Requirement: diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/zh-CN/template.md index 18bd2e0b..7324a836 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/zh-CN/template.md @@ -1,11 +1,3 @@ -## 协商上下文 -{{协商上下文}}(必填) -要求: -每次协商必须包含以下信息: -- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept或Reject),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 当前轮次;类型:正整数;举例:1 -- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 - ## 可行性协商 {{可行性协商概述}}(必填) 要求: diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/en-US/template.md index 245b3cc8..31aba02d 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/en-US/template.md @@ -1,11 +1,3 @@ -## Negotiation Context -{{negotiation_context}} (required) -Requirement: -Each negotiation must contain the following information: -- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept or Reject), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: The current round. Type: positive integer. Example: 1 -- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 - ## Information Negotiation Result {{information_negotiation_result}} (required) Requirement: diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/zh-CN/template.md index 5597f268..380c86a6 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/zh-CN/template.md @@ -1,11 +1,3 @@ -## 协商上下文 -{{协商上下文}}(必填) -要求: -每次协商必须包含以下信息: -- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept或Reject),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 当前轮次;类型:正整数;举例:1 -- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 - ## 信息协商结果 {{信息协商结果}}(必填) 要求: diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/en-US/template.md index 445e32d1..2276390e 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/en-US/template.md @@ -1,11 +1,3 @@ -## Negotiation Context -{{negotiation_context}} (required) -Requirement: -Each negotiation must contain the following information: -- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept or Reject), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: The current round. Type: positive integer. Example: 1 -- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 - ## Information Negotiation Please supplement the relevant content based on . diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md index 8a7bcd06..85c4787a 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md @@ -1,11 +1,3 @@ -## 协商上下文 -{{协商上下文}}(必填) -要求: -每次协商必须包含以下信息: -- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept或Reject),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 当前轮次;类型:正整数;举例:1 -- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 - ## 信息协商 请根据<所需信息项>补充相关内容。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/en-US/template.md index bf5d1b47..656a2173 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/en-US/template.md @@ -1,11 +1,3 @@ -## Negotiation Context -{{negotiation_context}} (required) -Requirement: -Each negotiation must contain the following information: -- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept or Reject), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: The current round. Type: positive integer. Example: 1 -- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 - ## Target Negotiation Result {{target_negotiation_result}} (required) Requirement: diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/zh-CN/template.md index 7e907fd7..b33e6bf3 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/zh-CN/template.md @@ -1,11 +1,3 @@ -## 协商上下文 -{{协商上下文}}(必填) -要求: -每次协商必须包含以下信息: -- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept或Reject),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 当前轮次;类型:正整数;举例:1 -- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 - ## 目标协商结果 {{目标协商结果}}(必填) 要求: diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/en-US/template.md index 8d8647af..21a99ef6 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/en-US/template.md @@ -1,11 +1,3 @@ -## Negotiation Context -{{negotiation_context}} (required) -Requirement: -Each negotiation must contain the following information: -- id: The unique identifier of a negotiation session, using UUID. When the negotiation enters a terminal state (Accept or Reject), this negotiation session terminates. Example: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: The current round. Type: positive integer. Example: 1 -- maxRounds: The maximum number of rounds. When this number is exceeded, the negotiation ends. Type: positive integer. Example: 5 - ## Target Negotiation {{target_negotiation_summary}} (required) Requirement: diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/zh-CN/template.md index 525478db..c4857ce1 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/zh-CN/template.md @@ -1,11 +1,3 @@ -## 协商上下文 -{{协商上下文}}(必填) -要求: -每次协商必须包含以下信息: -- id: 一次协商会话的唯一标识,使用UUID,协商进入终态(Accept或Reject),则此协商会话终止;举例:3dbc13b5-bd57-4c2b-b503-24e381b6c8d3 -- round: 当前轮次;类型:正整数;举例:1 -- maxRounds: 最大轮次,超过此轮次,则结束协商;类型:正整数;举例:5 - ## 目标协商 {{目标协商概述}}(必填) 要求: diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java index ab47ca36..f9f62a5b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java @@ -10,11 +10,4 @@ public record NegotiationEvaluationCase( String text, String completedPrompt, Map expected) { - - public String renderCompletedPrompt(String contextId, int round, int maxRounds) { - return completedPrompt - .replace("{{id}}", contextId) - .replace("{{round}}", Integer.toString(round)) - .replace("{{maxRounds}}", Integer.toString(maxRounds)); - } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationFlowCase.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationFlowCase.java index e558dc71..143f1327 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationFlowCase.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationFlowCase.java @@ -18,11 +18,8 @@ public Map expectedEnding() { return endingCase.expected(); } - public String clientSupplement(String contextId, int round, int maxRounds) { - String header = "## 协商上下文\n- id: " + contextId - + "\n- round: " + round - + "\n- maxRounds: " + maxRounds - + "\n\n## 客户端补充信息\n"; + public String clientSupplement() { + String header = "## 客户端补充信息\n"; if ("accept".equals(decision)) { return header + "1. 接入端口名称:" + expectedEnding().get("access_port_name") @@ -31,8 +28,8 @@ public String clientSupplement(String contextId, int round, int maxRounds) { return header + "无法补充所需信息,原因:" + expectedEnding().get("rejection_reason"); } - public String endingGenerationText(String contextId, int round, int maxRounds) { - return clientSupplement(contextId, round, maxRounds) + public String endingGenerationText() { + return clientSupplement() + "\n\n## 服务端协商结论生成要求\n" + endingCase.text(); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java index a9915b6d..77a0f197 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -110,8 +110,8 @@ private static Map runCase( result.put("expected_propose", testCase.expectedPropose()); result.put("expected_ending", testCase.expectedEnding()); NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3); - result.put("client_supplement", testCase.clientSupplement(context.id(), context.round(), context.maxRounds())); - result.put("ending_input", testCase.endingGenerationText(context.id(), context.round(), context.maxRounds())); + result.put("client_supplement", testCase.clientSupplement()); + result.put("ending_input", testCase.endingGenerationText()); result.put("actual_propose", null); result.put("actual_ending", null); result.put("propose_generation_succeeded", false); @@ -222,7 +222,7 @@ private static MetadataContent generateEndingWithLog( List> apiTrace) throws IOException { long startedAt = System.nanoTime(); try { - String endingInput = testCase.endingGenerationText(context.id(), context.round(), context.maxRounds()); + String endingInput = testCase.endingGenerationText(); MetadataContent content = "accept".equals(testCase.decision()) ? server.generateNegotiationAcceptPromptFromText( endingInput, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI) @@ -230,16 +230,16 @@ private static MetadataContent generateEndingWithLog( endingInput, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), context, Map.of("text", endingInput, - "client_supplement", testCase.clientSupplement(context.id(), context.round(), context.maxRounds()), + "client_supplement", testCase.clientSupplement(), "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), Map.of("prompt", content.promptText(), "template_uri", content.templateUri(), "extension_uri", content.extensionUri()), startedAt, null)); return content; } catch (RuntimeException exception) { - String endingInput = testCase.endingGenerationText(context.id(), context.round(), context.maxRounds()); + String endingInput = testCase.endingGenerationText(); writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), context, Map.of("text", endingInput, - "client_supplement", testCase.clientSupplement(context.id(), context.round(), context.maxRounds()), + "client_supplement", testCase.clientSupplement(), "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), null, startedAt, exception)); throw exception; } diff --git a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json index 2972cca4..01dc8194 100644 --- a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json +++ b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json @@ -1,102 +1,102 @@ [ - {"id":"P01","phase":"propose","category":"complete","text":"请发起信息协商,补充接入端口名称和投诉分类;端口可为物理或逻辑端口,分类限专线中断或专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P02","phase":"propose","category":"complete","text":"专线投诉诊断需要对端提供端口名和投诉类型,端口接受物理、逻辑名称,类型为专线中断或专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P03","phase":"propose","category":"paraphrase","text":"麻烦补齐本次专线投诉的接入端口及故障分类,端口要能定位到物理或逻辑接口,故障仅在中断、质差两类中选择。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P04","phase":"propose","category":"paraphrase","text":"发起信息协商:请给出物理端口或逻辑端口名称,并说明投诉属于专线中断还是专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P05","phase":"propose","category":"short","text":"请补充端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P06","phase":"propose","category":"short","text":"需要协商获取接入端口名和专线投诉类别:端口支持物理或逻辑,类别为中断或质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P07","phase":"propose","category":"formal","text":"为开展传输专线业务投诉诊断,请对端补充接入端口标识和投诉分类。其中端口标识应为物理或逻辑端口名称,投诉分类可取专线中断、专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P08","phase":"propose","category":"formal","text":"当前诊断参数不完整。请求协商获得:1)接入物理/逻辑端口名称;2)投诉分类,候选值为专线中断、专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P09","phase":"propose","category":"punctuation","text":"请补充接入端口名称;物理端口、逻辑端口均可。还请确认投诉分类:专线中断?专线质差?","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P10","phase":"propose","category":"punctuation","text":"信息协商请求【端口:物理或逻辑端口名称】【投诉:专线中断或专线质差】。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P11","phase":"propose","category":"context","text":"客户反映业务异常,尚无法确定网络侧故障。请提供接入端口名称(物理或逻辑均可)以及投诉分类(专线中断或专线质差),以便继续诊断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P12","phase":"propose","category":"context","text":"工单号已登记,但缺少诊断关键信息。请信息协商补充物理/逻辑接入端口名和专线中断/专线质差的投诉类别。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P13","phase":"propose","category":"synonym","text":"请对端回填入网接口名称(可填物理口或逻辑口)及专线故障现象分类(中断、质差)。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P14","phase":"propose","category":"synonym","text":"需要拿到接入侧端口标识,物理接口或逻辑接口均满足;同时确定本次专线投诉是断线还是质量劣化。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P15","phase":"propose","category":"multisentence","text":"请启动信息协商。诊断需要端口信息。请提供物理端口或逻辑端口名称。另请说明投诉分类是专线中断还是专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P16","phase":"propose","category":"multisentence","text":"当前不能直接定位故障。先确认接入端口名称,物理、逻辑名称都可以;再确认投诉类型,只允许专线中断和专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P17","phase":"propose","category":"polite","text":"烦请协助补充专线接入端口名称(物理或逻辑端口均可)与投诉分类(专线中断或专线质差),谢谢。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P18","phase":"propose","category":"polite","text":"请您确认一下接入端口是哪个物理/逻辑端口,并选择投诉为专线中断还是专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P19","phase":"propose","category":"order-reversed","text":"先请判定投诉类别:专线中断或专线质差;随后提供接入端口名称,物理端口或逻辑端口均可。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P20","phase":"propose","category":"order-reversed","text":"协商所需字段包括投诉分类(专线中断、专线质差)和接入端口名称(物理或逻辑端口)。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P21","phase":"propose","category":"mixed-language","text":"Please provide the access port name, either physical or logical, and classify this private-line complaint as 专线中断 or 专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P22","phase":"propose","category":"mixed-language","text":"发起 negotiation,需补充 access port(物理端口或逻辑端口名称)及 complaint category(专线中断或专线质差)。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P23","phase":"propose","category":"noise","text":"客户联系人为张三,联系电话已脱敏。请忽略这些非诊断信息,补充物理或逻辑接入端口名称,以及专线中断或专线质差的投诉分类。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P24","phase":"propose","category":"noise","text":"告警数量暂不确定,工单优先级为P2。请协商取得接入端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P25","phase":"propose","category":"business","text":"SPN专线投诉根因诊断前,请对端提供承载该业务的物理或逻辑接入端口名,并标注故障属于专线中断还是专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P26","phase":"propose","category":"business","text":"传输工作台需补齐入参:接入端口的物理/逻辑名称、专线投诉的中断/质差分类,请发起协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P27","phase":"propose","category":"imperative","text":"向对端要端口名和投诉类别。端口名必须是物理或者逻辑端口;类别限定专线中断、专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P28","phase":"propose","category":"imperative","text":"补齐以下两项再诊断:物理/逻辑接入端口名称;专线中断/专线质差投诉分类。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P29","phase":"propose","category":"long","text":"本次请求用于传输专线业务投诉诊断,现有描述只说明客户感知业务异常,尚不具备定位条件。请通过信息协商要求对端补充接入端口名称,值可以是物理端口名称或逻辑端口名称;同时要求补充投诉分类,允许的值为专线中断或专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P30","phase":"propose","category":"long","text":"为避免将客户侧问题误判为网络问题,需要先获取网络定位参数。请请求对端给出接入端口的物理或逻辑标识,并在专线中断、专线质差两个候选项中说明投诉类型。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P31","phase":"propose","category":"format","text":"字段1:接入端口名称,要求:物理端口或逻辑端口名称。字段2:投诉分类,要求:专线中断或专线质差。请发起协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P32","phase":"propose","category":"format","text":"需对端回复:access_port_name=物理端口或逻辑端口名称;complaint_category=专线中断或专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P33","phase":"propose","category":"colloquial","text":"这单还差端口和投诉类型,麻烦要一下:端口给物理口或逻辑口都行,类型就在专线中断、专线质差里选。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P34","phase":"propose","category":"colloquial","text":"先把专线的接入端口名和故障分类问清楚吧,端口支持物理/逻辑,分类只有中断或质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"A01","phase":"accept","category":"complete","text":"接受本次协商。接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A02","phase":"accept","category":"complete","text":"同意补充信息:物理端口 P782-天河-PTN7900-01-GE0/1/2,故障类型是专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A03","phase":"accept","category":"paraphrase","text":"接受。用于诊断的逻辑接入端口是 LAG-广州-001.100,客户投诉为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A04","phase":"accept","category":"paraphrase","text":"可以提供:端口名为 PE1-GZ-ETH-0/0/0.200,专线已中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A05","phase":"accept","category":"short","text":"接受,端口 NCE-PTN-23-1/1/7,投诉:专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A06","phase":"accept","category":"short","text":"Accept。access port=SPN-广州-1/0/24;分类=专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A07","phase":"accept","category":"formal","text":"接受该信息协商请求。补充的接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类确定为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A08","phase":"accept","category":"formal","text":"经资源核查,接受协商并反馈:物理接口 P782-天河-PTN7900-01-GE0/1/2;本次投诉为专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A09","phase":"accept","category":"punctuation","text":"接受!接入端口:LAG-广州-001.100;投诉分类:专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A10","phase":"accept","category":"punctuation","text":"同意回复【端口=PE1-GZ-ETH-0/0/0.200】【类型=专线中断】。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A11","phase":"accept","category":"context","text":"客户报障期间业务偶发丢包,接受协商,确认接入端口为 NCE-PTN-23-1/1/7,投诉分类为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A12","phase":"accept","category":"context","text":"告警显示链路不可达。接受本次协商,端口为 SPN-广州-1/0/24,归类为专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A13","phase":"accept","category":"synonym","text":"同意提供入网接口标识:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),业务质量劣化属于专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A14","phase":"accept","category":"synonym","text":"接受,承载接口为 P782-天河-PTN7900-01-GE0/1/2,客户反映断线,对应专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A15","phase":"accept","category":"multisentence","text":"接受协商。端口名称是 LAG-广州-001.100。投诉分类选择专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A16","phase":"accept","category":"multisentence","text":"同意。已查到 PE1-GZ-ETH-0/0/0.200。该专线业务当前中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A17","phase":"accept","category":"order-reversed","text":"投诉分类为专线质差,接入端口名称为 NCE-PTN-23-1/1/7,接受本次信息协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A18","phase":"accept","category":"order-reversed","text":"专线中断;端口 SPN-广州-1/0/24。接受协商请求。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A19","phase":"accept","category":"mixed-language","text":"Accept this negotiation. The access port is P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100), complaint category is 专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A20","phase":"accept","category":"mixed-language","text":"接受,port=P782-天河-PTN7900-01-GE0/1/2,category=专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A21","phase":"accept","category":"noise","text":"工单优先级P1,联系人已通知。接受并补充端口 LAG-广州-001.100,投诉为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A22","phase":"accept","category":"noise","text":"客户地址不在本次处理范围。接受协商:PE1-GZ-ETH-0/0/0.200,专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A23","phase":"accept","category":"business","text":"资源系统查询完成,接受信息协商。诊断接入端口 NCE-PTN-23-1/1/7,故障投诉分类为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A24","phase":"accept","category":"business","text":"传输侧确认接受:业务承载端口 SPN-广州-1/0/24,故障现象按专线中断处理。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A25","phase":"accept","category":"format","text":"接受;access_port_name=P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100);complaint_category=专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A26","phase":"accept","category":"format","text":"结果:接受。接入端口名称=P782-天河-PTN7900-01-GE0/1/2;投诉分类=专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A27","phase":"accept","category":"colloquial","text":"这次协商同意,端口就是 LAG-广州-001.100,客户说网慢,按专线质差算。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A28","phase":"accept","category":"colloquial","text":"可以,查到了 PE1-GZ-ETH-0/0/0.200,这条专线断了。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A29","phase":"accept","category":"long","text":"我方接受本次信息协商请求。资源查询结果表明,客户专线接入在 NCE-PTN-23-1/1/7;目前业务未完全中断,但存在持续高丢包和时延异常,因此投诉分类填写为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A30","phase":"accept","category":"long","text":"经现场及网管联合核查,接受协商并返回所需信息:接入端口为 SPN-广州-1/0/24。该端口承载业务不可达,用户侧表现为完全断线,投诉分类为专线中断。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A31","phase":"accept","category":"whitespace","text":"接受本次协商。\n接入端口:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n投诉分类:专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A32","phase":"accept","category":"whitespace","text":"接受。\n端口 P782-天河-PTN7900-01-GE0/1/2\n类别 专线中断","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A33","phase":"accept","category":"explicit","text":"明确接受信息协商,唯一需要填报的端口是 LAG-广州-001.100,唯一投诉分类为专线质差。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"R01","phase":"reject","category":"complete","text":"拒绝本次信息协商,当前账号没有资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, - {"id":"R02","phase":"reject","category":"complete","text":"拒绝,原因是资源系统正在维护,暂时无法查询端口信息。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统正在维护,暂时无法查询端口信息","expected":{"rejection_reason":"资源系统正在维护,暂时无法查询端口信息"}}, - {"id":"R03","phase":"reject","category":"paraphrase","text":"无法接受该协商请求,因为我方账号无权访问资源管理系统。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:我方账号无权访问资源管理系统","expected":{"rejection_reason":"我方账号无权访问资源管理系统"}}, - {"id":"R04","phase":"reject","category":"paraphrase","text":"本次不能补充信息,资源库维护中,端口数据不可用。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源库维护中,端口数据不可用","expected":{"rejection_reason":"资源库维护中,端口数据不可用"}}, - {"id":"R05","phase":"reject","category":"short","text":"拒绝:无资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:无资源系统查询权限","expected":{"rejection_reason":"无资源系统查询权限"}}, - {"id":"R06","phase":"reject","category":"short","text":"Reject,资源系统维护中。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统维护中","expected":{"rejection_reason":"资源系统维护中"}}, - {"id":"R07","phase":"reject","category":"formal","text":"拒绝本次信息协商请求,原因是当前操作账号未被授予资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前操作账号未被授予资源系统查询权限","expected":{"rejection_reason":"当前操作账号未被授予资源系统查询权限"}}, - {"id":"R08","phase":"reject","category":"formal","text":"经确认,资源系统处于计划维护窗口,暂不具备提供端口信息的条件,故拒绝协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统处于计划维护窗口,暂不具备提供端口信息的条件","expected":{"rejection_reason":"资源系统处于计划维护窗口,暂不具备提供端口信息的条件"}}, - {"id":"R09","phase":"reject","category":"punctuation","text":"拒绝!原因:当前账号没有资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, - {"id":"R10","phase":"reject","category":"punctuation","text":"无法接受【资源系统维护,端口查询暂停】。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统维护,端口查询暂停","expected":{"rejection_reason":"资源系统维护,端口查询暂停"}}, - {"id":"R11","phase":"reject","category":"context","text":"工单已受理,但资源侧权限申请尚未完成,因此拒绝本次协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源侧权限申请尚未完成","expected":{"rejection_reason":"资源侧权限申请尚未完成"}}, - {"id":"R12","phase":"reject","category":"context","text":"网管告警很多,但资源系统服务异常,当前不能查询端口,拒绝补充。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统服务异常,当前不能查询端口","expected":{"rejection_reason":"资源系统服务异常,当前不能查询端口"}}, - {"id":"R13","phase":"reject","category":"synonym","text":"不接受协商,因账号没有资产资源库的访问授权。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:账号没有资产资源库的访问授权","expected":{"rejection_reason":"账号没有资产资源库的访问授权"}}, - {"id":"R14","phase":"reject","category":"synonym","text":"无法给出所需数据,CMDB资源查询服务正在检修。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:CMDB资源查询服务正在检修","expected":{"rejection_reason":"CMDB资源查询服务正在检修"}}, - {"id":"R15","phase":"reject","category":"multisentence","text":"拒绝本次请求。当前账号没有资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, - {"id":"R16","phase":"reject","category":"multisentence","text":"暂不接受。资源系统正在维护。恢复时间未知。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统正在维护","expected":{"rejection_reason":"资源系统正在维护"}}, - {"id":"R17","phase":"reject","category":"order-reversed","text":"因资源侧权限尚未开通,拒绝这次信息协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源侧权限尚未开通","expected":{"rejection_reason":"资源侧权限尚未开通"}}, - {"id":"R18","phase":"reject","category":"order-reversed","text":"资源查询接口超时,无法返回端口信息,故拒绝。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源查询接口超时,无法返回端口信息","expected":{"rejection_reason":"资源查询接口超时,无法返回端口信息"}}, - {"id":"R19","phase":"reject","category":"mixed-language","text":"Reject this negotiation because the current account has no resource-system query permission。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:the current account has no resource-system query permission","expected":{"rejection_reason":"the current account has no resource-system query permission"}}, - {"id":"R20","phase":"reject","category":"mixed-language","text":"拒绝,resource system is under maintenance,无法查询端口。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:resource system is under maintenance,无法查询端口","expected":{"rejection_reason":"resource system is under maintenance,无法查询端口"}}, - {"id":"R21","phase":"reject","category":"noise","text":"联系人已收到短信,工单优先级为P2。拒绝原因是当前账号没有资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, - {"id":"R22","phase":"reject","category":"noise","text":"客户地址信息无需处理。由于资源系统维护,无法取得端口数据,拒绝协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统维护,无法取得端口数据","expected":{"rejection_reason":"资源系统维护,无法取得端口数据"}}, - {"id":"R23","phase":"reject","category":"business","text":"传输资源库未对本租户开放查询能力,无法补充专线端口,拒绝本次信息协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:传输资源库未对本租户开放查询能力","expected":{"rejection_reason":"传输资源库未对本租户开放查询能力"}}, - {"id":"R24","phase":"reject","category":"business","text":"资源同步任务延迟,网管侧尚未获得该专线端口映射,故暂时拒绝回复。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源同步任务延迟,网管侧尚未获得该专线端口映射","expected":{"rejection_reason":"资源同步任务延迟,网管侧尚未获得该专线端口映射"}}, - {"id":"R25","phase":"reject","category":"format","text":"拒绝;rejection_reason=当前账号没有资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, - {"id":"R26","phase":"reject","category":"format","text":"结果:Reject。原因:资源系统维护,暂不能查询。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统维护,暂不能查询","expected":{"rejection_reason":"资源系统维护,暂不能查询"}}, - {"id":"R27","phase":"reject","category":"colloquial","text":"这次先不接,账号没权限查资源库。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:账号没权限查资源库","expected":{"rejection_reason":"账号没权限查资源库"}}, - {"id":"R28","phase":"reject","category":"colloquial","text":"查不了,资源系统在检修,拒绝吧。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统在检修","expected":{"rejection_reason":"资源系统在检修"}}, - {"id":"R29","phase":"reject","category":"long","text":"我方已尝试通过资源管理系统查询该专线的接入端口和投诉信息,但当前使用的运维账号没有被授权访问相关资源域。由于无法保证返回数据的正确性,拒绝本次信息协商。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前使用的运维账号没有被授权访问相关资源域","expected":{"rejection_reason":"当前使用的运维账号没有被授权访问相关资源域"}}, - {"id":"R30","phase":"reject","category":"long","text":"本次协商暂不能接受。资源系统正在执行例行维护,端口与业务映射查询功能已暂停;在服务恢复前无法提供可靠的补充信息。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统正在执行例行维护,端口与业务映射查询功能已暂停","expected":{"rejection_reason":"资源系统正在执行例行维护,端口与业务映射查询功能已暂停"}}, - {"id":"R31","phase":"reject","category":"whitespace","text":"拒绝本次协商。\n原因:当前账号没有资源系统查询权限。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, - {"id":"R32","phase":"reject","category":"whitespace","text":"Reject\n资源查询服务异常,暂无法取得端口信息。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源查询服务异常,暂无法取得端口信息","expected":{"rejection_reason":"资源查询服务异常,暂无法取得端口信息"}}, - {"id":"R33","phase":"reject","category":"explicit","text":"明确拒绝信息协商,唯一原因是资源库访问权限被安全策略禁用。","completedPrompt":"## 协商上下文\n- id: {{id}}\n- round: {{round}}\n- maxRounds: {{maxRounds}}\n\n## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源库访问权限被安全策略禁用","expected":{"rejection_reason":"资源库访问权限被安全策略禁用"}} + {"id":"P01","phase":"propose","category":"complete","text":"请发起信息协商,补充接入端口名称和投诉分类;端口可为物理或逻辑端口,分类限专线中断或专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P02","phase":"propose","category":"complete","text":"专线投诉诊断需要对端提供端口名和投诉类型,端口接受物理、逻辑名称,类型为专线中断或专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P03","phase":"propose","category":"paraphrase","text":"麻烦补齐本次专线投诉的接入端口及故障分类,端口要能定位到物理或逻辑接口,故障仅在中断、质差两类中选择。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P04","phase":"propose","category":"paraphrase","text":"发起信息协商:请给出物理端口或逻辑端口名称,并说明投诉属于专线中断还是专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P05","phase":"propose","category":"short","text":"请补充端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P06","phase":"propose","category":"short","text":"需要协商获取接入端口名和专线投诉类别:端口支持物理或逻辑,类别为中断或质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P07","phase":"propose","category":"formal","text":"为开展传输专线业务投诉诊断,请对端补充接入端口标识和投诉分类。其中端口标识应为物理或逻辑端口名称,投诉分类可取专线中断、专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P08","phase":"propose","category":"formal","text":"当前诊断参数不完整。请求协商获得:1)接入物理/逻辑端口名称;2)投诉分类,候选值为专线中断、专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P09","phase":"propose","category":"punctuation","text":"请补充接入端口名称;物理端口、逻辑端口均可。还请确认投诉分类:专线中断?专线质差?","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P10","phase":"propose","category":"punctuation","text":"信息协商请求【端口:物理或逻辑端口名称】【投诉:专线中断或专线质差】。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P11","phase":"propose","category":"context","text":"客户反映业务异常,尚无法确定网络侧故障。请提供接入端口名称(物理或逻辑均可)以及投诉分类(专线中断或专线质差),以便继续诊断。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P12","phase":"propose","category":"context","text":"工单号已登记,但缺少诊断关键信息。请信息协商补充物理/逻辑接入端口名和专线中断/专线质差的投诉类别。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P13","phase":"propose","category":"synonym","text":"请对端回填入网接口名称(可填物理口或逻辑口)及专线故障现象分类(中断、质差)。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P14","phase":"propose","category":"synonym","text":"需要拿到接入侧端口标识,物理接口或逻辑接口均满足;同时确定本次专线投诉是断线还是质量劣化。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P15","phase":"propose","category":"multisentence","text":"请启动信息协商。诊断需要端口信息。请提供物理端口或逻辑端口名称。另请说明投诉分类是专线中断还是专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P16","phase":"propose","category":"multisentence","text":"当前不能直接定位故障。先确认接入端口名称,物理、逻辑名称都可以;再确认投诉类型,只允许专线中断和专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P17","phase":"propose","category":"polite","text":"烦请协助补充专线接入端口名称(物理或逻辑端口均可)与投诉分类(专线中断或专线质差),谢谢。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P18","phase":"propose","category":"polite","text":"请您确认一下接入端口是哪个物理/逻辑端口,并选择投诉为专线中断还是专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P19","phase":"propose","category":"order-reversed","text":"先请判定投诉类别:专线中断或专线质差;随后提供接入端口名称,物理端口或逻辑端口均可。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P20","phase":"propose","category":"order-reversed","text":"协商所需字段包括投诉分类(专线中断、专线质差)和接入端口名称(物理或逻辑端口)。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P21","phase":"propose","category":"mixed-language","text":"Please provide the access port name, either physical or logical, and classify this private-line complaint as 专线中断 or 专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P22","phase":"propose","category":"mixed-language","text":"发起 negotiation,需补充 access port(物理端口或逻辑端口名称)及 complaint category(专线中断或专线质差)。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P23","phase":"propose","category":"noise","text":"客户联系人为张三,联系电话已脱敏。请忽略这些非诊断信息,补充物理或逻辑接入端口名称,以及专线中断或专线质差的投诉分类。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P24","phase":"propose","category":"noise","text":"告警数量暂不确定,工单优先级为P2。请协商取得接入端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P25","phase":"propose","category":"business","text":"SPN专线投诉根因诊断前,请对端提供承载该业务的物理或逻辑接入端口名,并标注故障属于专线中断还是专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P26","phase":"propose","category":"business","text":"传输工作台需补齐入参:接入端口的物理/逻辑名称、专线投诉的中断/质差分类,请发起协商。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P27","phase":"propose","category":"imperative","text":"向对端要端口名和投诉类别。端口名必须是物理或者逻辑端口;类别限定专线中断、专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P28","phase":"propose","category":"imperative","text":"补齐以下两项再诊断:物理/逻辑接入端口名称;专线中断/专线质差投诉分类。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P29","phase":"propose","category":"long","text":"本次请求用于传输专线业务投诉诊断,现有描述只说明客户感知业务异常,尚不具备定位条件。请通过信息协商要求对端补充接入端口名称,值可以是物理端口名称或逻辑端口名称;同时要求补充投诉分类,允许的值为专线中断或专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P30","phase":"propose","category":"long","text":"为避免将客户侧问题误判为网络问题,需要先获取网络定位参数。请请求对端给出接入端口的物理或逻辑标识,并在专线中断、专线质差两个候选项中说明投诉类型。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P31","phase":"propose","category":"format","text":"字段1:接入端口名称,要求:物理端口或逻辑端口名称。字段2:投诉分类,要求:专线中断或专线质差。请发起协商。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P32","phase":"propose","category":"format","text":"需对端回复:access_port_name=物理端口或逻辑端口名称;complaint_category=专线中断或专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P33","phase":"propose","category":"colloquial","text":"这单还差端口和投诉类型,麻烦要一下:端口给物理口或逻辑口都行,类型就在专线中断、专线质差里选。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"P34","phase":"propose","category":"colloquial","text":"先把专线的接入端口名和故障分类问清楚吧,端口支持物理/逻辑,分类只有中断或质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, + {"id":"A01","phase":"accept","category":"complete","text":"接受本次协商。接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A02","phase":"accept","category":"complete","text":"同意补充信息:物理端口 P782-天河-PTN7900-01-GE0/1/2,故障类型是专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A03","phase":"accept","category":"paraphrase","text":"接受。用于诊断的逻辑接入端口是 LAG-广州-001.100,客户投诉为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A04","phase":"accept","category":"paraphrase","text":"可以提供:端口名为 PE1-GZ-ETH-0/0/0.200,专线已中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A05","phase":"accept","category":"short","text":"接受,端口 NCE-PTN-23-1/1/7,投诉:专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A06","phase":"accept","category":"short","text":"Accept。access port=SPN-广州-1/0/24;分类=专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A07","phase":"accept","category":"formal","text":"接受该信息协商请求。补充的接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类确定为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A08","phase":"accept","category":"formal","text":"经资源核查,接受协商并反馈:物理接口 P782-天河-PTN7900-01-GE0/1/2;本次投诉为专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A09","phase":"accept","category":"punctuation","text":"接受!接入端口:LAG-广州-001.100;投诉分类:专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A10","phase":"accept","category":"punctuation","text":"同意回复【端口=PE1-GZ-ETH-0/0/0.200】【类型=专线中断】。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A11","phase":"accept","category":"context","text":"客户报障期间业务偶发丢包,接受协商,确认接入端口为 NCE-PTN-23-1/1/7,投诉分类为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A12","phase":"accept","category":"context","text":"告警显示链路不可达。接受本次协商,端口为 SPN-广州-1/0/24,归类为专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A13","phase":"accept","category":"synonym","text":"同意提供入网接口标识:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),业务质量劣化属于专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A14","phase":"accept","category":"synonym","text":"接受,承载接口为 P782-天河-PTN7900-01-GE0/1/2,客户反映断线,对应专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A15","phase":"accept","category":"multisentence","text":"接受协商。端口名称是 LAG-广州-001.100。投诉分类选择专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A16","phase":"accept","category":"multisentence","text":"同意。已查到 PE1-GZ-ETH-0/0/0.200。该专线业务当前中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A17","phase":"accept","category":"order-reversed","text":"投诉分类为专线质差,接入端口名称为 NCE-PTN-23-1/1/7,接受本次信息协商。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A18","phase":"accept","category":"order-reversed","text":"专线中断;端口 SPN-广州-1/0/24。接受协商请求。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A19","phase":"accept","category":"mixed-language","text":"Accept this negotiation. The access port is P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100), complaint category is 专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A20","phase":"accept","category":"mixed-language","text":"接受,port=P782-天河-PTN7900-01-GE0/1/2,category=专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A21","phase":"accept","category":"noise","text":"工单优先级P1,联系人已通知。接受并补充端口 LAG-广州-001.100,投诉为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A22","phase":"accept","category":"noise","text":"客户地址不在本次处理范围。接受协商:PE1-GZ-ETH-0/0/0.200,专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A23","phase":"accept","category":"business","text":"资源系统查询完成,接受信息协商。诊断接入端口 NCE-PTN-23-1/1/7,故障投诉分类为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A24","phase":"accept","category":"business","text":"传输侧确认接受:业务承载端口 SPN-广州-1/0/24,故障现象按专线中断处理。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A25","phase":"accept","category":"format","text":"接受;access_port_name=P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100);complaint_category=专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A26","phase":"accept","category":"format","text":"结果:接受。接入端口名称=P782-天河-PTN7900-01-GE0/1/2;投诉分类=专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A27","phase":"accept","category":"colloquial","text":"这次协商同意,端口就是 LAG-广州-001.100,客户说网慢,按专线质差算。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"A28","phase":"accept","category":"colloquial","text":"可以,查到了 PE1-GZ-ETH-0/0/0.200,这条专线断了。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, + {"id":"A29","phase":"accept","category":"long","text":"我方接受本次信息协商请求。资源查询结果表明,客户专线接入在 NCE-PTN-23-1/1/7;目前业务未完全中断,但存在持续高丢包和时延异常,因此投诉分类填写为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, + {"id":"A30","phase":"accept","category":"long","text":"经现场及网管联合核查,接受协商并返回所需信息:接入端口为 SPN-广州-1/0/24。该端口承载业务不可达,用户侧表现为完全断线,投诉分类为专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, + {"id":"A31","phase":"accept","category":"whitespace","text":"接受本次协商。\n接入端口:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n投诉分类:专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, + {"id":"A32","phase":"accept","category":"whitespace","text":"接受。\n端口 P782-天河-PTN7900-01-GE0/1/2\n类别 专线中断","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, + {"id":"A33","phase":"accept","category":"explicit","text":"明确接受信息协商,唯一需要填报的端口是 LAG-广州-001.100,唯一投诉分类为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, + {"id":"R01","phase":"reject","category":"complete","text":"拒绝本次信息协商,当前账号没有资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R02","phase":"reject","category":"complete","text":"拒绝,原因是资源系统正在维护,暂时无法查询端口信息。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统正在维护,暂时无法查询端口信息","expected":{"rejection_reason":"资源系统正在维护,暂时无法查询端口信息"}}, + {"id":"R03","phase":"reject","category":"paraphrase","text":"无法接受该协商请求,因为我方账号无权访问资源管理系统。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:我方账号无权访问资源管理系统","expected":{"rejection_reason":"我方账号无权访问资源管理系统"}}, + {"id":"R04","phase":"reject","category":"paraphrase","text":"本次不能补充信息,资源库维护中,端口数据不可用。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源库维护中,端口数据不可用","expected":{"rejection_reason":"资源库维护中,端口数据不可用"}}, + {"id":"R05","phase":"reject","category":"short","text":"拒绝:无资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:无资源系统查询权限","expected":{"rejection_reason":"无资源系统查询权限"}}, + {"id":"R06","phase":"reject","category":"short","text":"Reject,资源系统维护中。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统维护中","expected":{"rejection_reason":"资源系统维护中"}}, + {"id":"R07","phase":"reject","category":"formal","text":"拒绝本次信息协商请求,原因是当前操作账号未被授予资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前操作账号未被授予资源系统查询权限","expected":{"rejection_reason":"当前操作账号未被授予资源系统查询权限"}}, + {"id":"R08","phase":"reject","category":"formal","text":"经确认,资源系统处于计划维护窗口,暂不具备提供端口信息的条件,故拒绝协商。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统处于计划维护窗口,暂不具备提供端口信息的条件","expected":{"rejection_reason":"资源系统处于计划维护窗口,暂不具备提供端口信息的条件"}}, + {"id":"R09","phase":"reject","category":"punctuation","text":"拒绝!原因:当前账号没有资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R10","phase":"reject","category":"punctuation","text":"无法接受【资源系统维护,端口查询暂停】。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统维护,端口查询暂停","expected":{"rejection_reason":"资源系统维护,端口查询暂停"}}, + {"id":"R11","phase":"reject","category":"context","text":"工单已受理,但资源侧权限申请尚未完成,因此拒绝本次协商。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源侧权限申请尚未完成","expected":{"rejection_reason":"资源侧权限申请尚未完成"}}, + {"id":"R12","phase":"reject","category":"context","text":"网管告警很多,但资源系统服务异常,当前不能查询端口,拒绝补充。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统服务异常,当前不能查询端口","expected":{"rejection_reason":"资源系统服务异常,当前不能查询端口"}}, + {"id":"R13","phase":"reject","category":"synonym","text":"不接受协商,因账号没有资产资源库的访问授权。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:账号没有资产资源库的访问授权","expected":{"rejection_reason":"账号没有资产资源库的访问授权"}}, + {"id":"R14","phase":"reject","category":"synonym","text":"无法给出所需数据,CMDB资源查询服务正在检修。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:CMDB资源查询服务正在检修","expected":{"rejection_reason":"CMDB资源查询服务正在检修"}}, + {"id":"R15","phase":"reject","category":"multisentence","text":"拒绝本次请求。当前账号没有资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R16","phase":"reject","category":"multisentence","text":"暂不接受。资源系统正在维护。恢复时间未知。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统正在维护","expected":{"rejection_reason":"资源系统正在维护"}}, + {"id":"R17","phase":"reject","category":"order-reversed","text":"因资源侧权限尚未开通,拒绝这次信息协商。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源侧权限尚未开通","expected":{"rejection_reason":"资源侧权限尚未开通"}}, + {"id":"R18","phase":"reject","category":"order-reversed","text":"资源查询接口超时,无法返回端口信息,故拒绝。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源查询接口超时,无法返回端口信息","expected":{"rejection_reason":"资源查询接口超时,无法返回端口信息"}}, + {"id":"R19","phase":"reject","category":"mixed-language","text":"Reject this negotiation because the current account has no resource-system query permission。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:the current account has no resource-system query permission","expected":{"rejection_reason":"the current account has no resource-system query permission"}}, + {"id":"R20","phase":"reject","category":"mixed-language","text":"拒绝,resource system is under maintenance,无法查询端口。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:resource system is under maintenance,无法查询端口","expected":{"rejection_reason":"resource system is under maintenance,无法查询端口"}}, + {"id":"R21","phase":"reject","category":"noise","text":"联系人已收到短信,工单优先级为P2。拒绝原因是当前账号没有资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R22","phase":"reject","category":"noise","text":"客户地址信息无需处理。由于资源系统维护,无法取得端口数据,拒绝协商。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统维护,无法取得端口数据","expected":{"rejection_reason":"资源系统维护,无法取得端口数据"}}, + {"id":"R23","phase":"reject","category":"business","text":"传输资源库未对本租户开放查询能力,无法补充专线端口,拒绝本次信息协商。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:传输资源库未对本租户开放查询能力","expected":{"rejection_reason":"传输资源库未对本租户开放查询能力"}}, + {"id":"R24","phase":"reject","category":"business","text":"资源同步任务延迟,网管侧尚未获得该专线端口映射,故暂时拒绝回复。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源同步任务延迟,网管侧尚未获得该专线端口映射","expected":{"rejection_reason":"资源同步任务延迟,网管侧尚未获得该专线端口映射"}}, + {"id":"R25","phase":"reject","category":"format","text":"拒绝;rejection_reason=当前账号没有资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R26","phase":"reject","category":"format","text":"结果:Reject。原因:资源系统维护,暂不能查询。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统维护,暂不能查询","expected":{"rejection_reason":"资源系统维护,暂不能查询"}}, + {"id":"R27","phase":"reject","category":"colloquial","text":"这次先不接,账号没权限查资源库。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:账号没权限查资源库","expected":{"rejection_reason":"账号没权限查资源库"}}, + {"id":"R28","phase":"reject","category":"colloquial","text":"查不了,资源系统在检修,拒绝吧。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统在检修","expected":{"rejection_reason":"资源系统在检修"}}, + {"id":"R29","phase":"reject","category":"long","text":"我方已尝试通过资源管理系统查询该专线的接入端口和投诉信息,但当前使用的运维账号没有被授权访问相关资源域。由于无法保证返回数据的正确性,拒绝本次信息协商。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前使用的运维账号没有被授权访问相关资源域","expected":{"rejection_reason":"当前使用的运维账号没有被授权访问相关资源域"}}, + {"id":"R30","phase":"reject","category":"long","text":"本次协商暂不能接受。资源系统正在执行例行维护,端口与业务映射查询功能已暂停;在服务恢复前无法提供可靠的补充信息。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源系统正在执行例行维护,端口与业务映射查询功能已暂停","expected":{"rejection_reason":"资源系统正在执行例行维护,端口与业务映射查询功能已暂停"}}, + {"id":"R31","phase":"reject","category":"whitespace","text":"拒绝本次协商。\n原因:当前账号没有资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:当前账号没有资源系统查询权限","expected":{"rejection_reason":"当前账号没有资源系统查询权限"}}, + {"id":"R32","phase":"reject","category":"whitespace","text":"Reject\n资源查询服务异常,暂无法取得端口信息。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源查询服务异常,暂无法取得端口信息","expected":{"rejection_reason":"资源查询服务异常,暂无法取得端口信息"}}, + {"id":"R33","phase":"reject","category":"explicit","text":"明确拒绝信息协商,唯一原因是资源库访问权限被安全策略禁用。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 拒绝原因:资源库访问权限被安全策略禁用","expected":{"rejection_reason":"资源库访问权限被安全策略禁用"}} ] diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java index 54da286e..58ccd4cf 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java @@ -50,8 +50,8 @@ void assemblesEveryCorpusEntryIntoAnEndToEndFlow() { assertEquals(50, flows.stream().filter(flow -> flow.decision().equals("reject")).count()); assertTrue(flows.stream().allMatch(flow -> flow.proposeCase().phase().equals("propose"))); assertTrue(flows.stream().allMatch(flow -> flow.endingCase().phase().equals(flow.decision()))); - assertTrue(flows.stream().allMatch(flow -> flow.clientSupplement("context", 1, 3) - .contains("## 客户端补充信息"))); + assertTrue(flows.stream().allMatch(flow -> flow.clientSupplement().contains("## 客户端补充信息"))); + assertTrue(flows.stream().noneMatch(flow -> flow.clientSupplement().contains("协商上下文"))); } @Test @@ -66,14 +66,12 @@ void assemblesTwentySmokeFlowsUsingTheExistingSelectorIds() { } @Test - void rendersCompletedPromptsWithTheRuntimeNegotiationContext() { + void completedPromptsCarryNoNegotiationContextSection() { var cases = NegotiationEvaluationCaseLoader.load(); for (NegotiationEvaluationCase testCase : cases) { - String prompt = testCase.renderCompletedPrompt("case-context", 2, 4); - assertTrue(prompt.contains("- id: case-context"), testCase.id()); - assertTrue(prompt.contains("- round: 2"), testCase.id()); - assertTrue(prompt.contains("- maxRounds: 4"), testCase.id()); + String prompt = testCase.completedPrompt(); + assertTrue(!prompt.contains("协商上下文"), testCase.id()); assertTrue(!prompt.contains("{{"), testCase.id()); assertTrue(prompt.contains(testCase.phase().equals("propose") ? "## 信息协商\n" @@ -87,7 +85,6 @@ private static boolean isComplete(NegotiationEvaluationCase testCase) { && !testCase.text().isBlank() && testCase.completedPrompt() != null && !testCase.completedPrompt().isBlank() - && testCase.completedPrompt().contains("{{id}}") && testCase.expected() != null && !testCase.expected().isEmpty() && expectedKeys(testCase.phase()).equals(testCase.expected().keySet()); diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index 42bd9659..98f39ae5 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -42,8 +42,7 @@ void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOE assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("- id: " + UUID)); - assertTrue(result.promptText().contains("协商上下文")); + assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); Map metadata = result.buildMetadataContent(); assertEquals(3, metadata.size()); assertEquals(result.promptText(), metadata.get(result.extensionUri())); @@ -63,7 +62,7 @@ void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOE assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); - assertTrue(result.promptText().contains("Negotiation Context")); + assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); assertTrue(result.promptText().contains("Required Information Items")); } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index d744a280..5805eee8 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -90,7 +90,7 @@ void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOE new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE_TEMPLATE); assertEquals(INFORMATION_PROPOSE_URI, negotiationMessage.templateUri()); - assertTrue(negotiationMessage.promptText().contains("- id: " + UUID)); + assertEquals(UUID, negotiationMessage.negotiationContext().id()); } @Test @@ -109,7 +109,7 @@ void oldNegotiationStateMachineKeepsWorkingNextToTheContentLayerApis() throws IO new NegotiationContext(UUID, 2, 5), new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), INFORMATION_PROPOSE_TEMPLATE); - assertTrue(negotiationMessage.promptText().contains("- round: 2")); + assertEquals(2, negotiationMessage.negotiationContext().round()); Map startedAgain = server.startNegotiation(NegotiationType.TARGET, "请澄清目标。", Map.of()); assertEquals( diff --git a/tools/template_lint.py b/tools/template_lint.py index bbb67209..d734d7cd 100644 --- a/tools/template_lint.py +++ b/tools/template_lint.py @@ -31,12 +31,12 @@ NEGOTIATION_LANGUAGES = ("zh-CN", "en-US") NEGOTIATION_STATIC_SECTIONS = {"info_static"} NEGOTIATION_PROFILES = { - ("information-negotiation", "propose"): ("context", "info_static", "info_items"), - ("information-negotiation", "accept-reject"): ("context", "info_conclusion", "info_result_content"), - ("target-negotiation", "propose"): ("context", "target", "target_intent", "target_alignment", "target_clarification"), - ("target-negotiation", "accept-reject"): ("context", "target_conclusion", "target_result_content"), - ("feasibility-negotiation", "propose"): ("context", "feasibility", "feasibility_evaluate", "feasibility_infeasible"), - ("feasibility-negotiation", "accept-reject"): ("context", "feasibility_conclusion", "feasibility_confirm"), + ("information-negotiation", "propose"): ("info_static", "info_items"), + ("information-negotiation", "accept-reject"): ("info_conclusion", "info_result_content"), + ("target-negotiation", "propose"): ("target", "target_intent", "target_alignment", "target_clarification"), + ("target-negotiation", "accept-reject"): ("target_conclusion", "target_result_content"), + ("feasibility-negotiation", "propose"): ("feasibility", "feasibility_evaluate", "feasibility_infeasible"), + ("feasibility-negotiation", "accept-reject"): ("feasibility_conclusion", "feasibility_confirm"), } NEGOTIATION_MARKER = re.compile( r"^\{\{(?P[^{}]+)\}\}(?:(?P((?P必填|选填)))| \((?Prequired|optional)\))\s*$" @@ -320,8 +320,6 @@ def lint_negotiation_file( errors.append(error(path, marker_line_no, "negotiation-slot-name", f"Slot name '{{{{{marker.group('slot')}}}}}' must be '{expected_slot}'.")) if not any(text.rstrip() == requirements_label for _, text in body): errors.append(error(path, line_no, "negotiation-requirements", f"Slot section '{title}' must contain a '{requirements_label}' line.")) - if key == "context" and required is not True: - errors.append(error(path, marker_line_no, "negotiation-context", "The negotiation context section must be marked as required.")) shape.append((key, required, marker.group("slot") == expected_slot, line_no)) for key in profile: if key not in seen: From 46129c5e6f289a6b88bebba7bc6a749c54675582 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 22:25:55 +0800 Subject: [PATCH 135/295] docs: update negotiation context metadata contract in both guides The developer guides now describe the four-component MetadataContent with the conditional negotiationContext metadata key carrying the nested {id, round, maxRounds} object, the NegotiationContext home in the core model package, the context-parameter signatures of the four validate*PromptAndDataFilling methods, and the rule gate validating the caller-supplied context instead of parsing a rendered section. The end-to-end examples pass the context through and the method-count drift (thirteen vs the actual fourteen facade methods) is corrected, as is the same drift in the two facade symmetry tests that missed the abort methods. Signed-off-by: codetalks-hw --- .../api/NegotiationFacadeApiSymmetryTest.java | 7 +++-- .../NegotiationFacadeOutputSymmetryTest.java | 7 +++-- docs/en/developer_guide.md | 31 ++++++++++--------- ...00\345\217\221\346\214\207\345\215\227.md" | 31 ++++++++++--------- 4 files changed, 42 insertions(+), 34 deletions(-) diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java index 36ec3608..965eb8b7 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java @@ -25,17 +25,20 @@ class NegotiationFacadeApiSymmetryTest { "generateNegotiationProposePromptFromData", "generateNegotiationAcceptPromptFromData", "generateNegotiationRejectPromptFromData", + "generateNegotiationAbortPromptFromData", "generateNegotiationProposePromptFromText", "generateNegotiationAcceptPromptFromText", "generateNegotiationRejectPromptFromText", + "generateNegotiationAbortPromptFromText", "getNegotiationPrompts", "getNegotiationPrompt", "validateProposePromptAndDataFilling", "validateAcceptPromptAndDataFilling", - "validateRejectPromptAndDataFilling"); + "validateRejectPromptAndDataFilling", + "validateAbortPromptAndDataFilling"); @Test - void bothFacadesExposeTheSameElevenNegotiationApiMethods() { + void bothFacadesExposeTheSameFourteenNegotiationApiMethods() { Map clientSurface = negotiationApiSurface(A2ATClient.class); Map serverSurface = negotiationApiSurface(A2ATServer.class); diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index 63afcd40..921bbae2 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -62,20 +62,23 @@ static void registerMockProvider() { "generateNegotiationProposePromptFromData", "generateNegotiationAcceptPromptFromData", "generateNegotiationRejectPromptFromData", + "generateNegotiationAbortPromptFromData", "generateNegotiationProposePromptFromText", "generateNegotiationAcceptPromptFromText", "generateNegotiationRejectPromptFromText", + "generateNegotiationAbortPromptFromText", "getNegotiationPrompts", "getNegotiationPrompt", "validateProposePromptAndDataFilling", "validateAcceptPromptAndDataFilling", - "validateRejectPromptAndDataFilling"); + "validateRejectPromptAndDataFilling", + "validateAbortPromptAndDataFilling"); @TempDir Path tempDir; @Test - void bothFacadesExposeTheSameElevenMethodSignatures() { + void bothFacadesExposeTheSameFourteenMethodSignatures() { Map clientSurface = negotiationApiSurface(A2ATClient.class); Map serverSurface = negotiationApiSurface(A2ATServer.class); diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index b0f23574..2561dbbd 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -410,7 +410,7 @@ mvn -pl a2a-t-sample -am -DskipTests package ## 1.10 Negotiation-T Content Layer -This section describes the Negotiation-T content layer: a template-driven API set for generating negotiation messages, checking their compliance, and extracting parameters from them. Both `A2ATClient` and `A2ATServer` expose the same thirteen methods for this purpose. +This section describes the Negotiation-T content layer: a template-driven API set for generating negotiation messages, checking their compliance, and extracting parameters from them. Both `A2ATClient` and `A2ATServer` expose the same fourteen methods for this purpose. ### 1.10.1 Overview and SDK Boundary @@ -424,7 +424,7 @@ The content layer is stateless. It deliberately does not own a session state mac ### 1.10.2 Facade Methods -All thirteen methods exist on both `A2ATClient` and `A2ATServer` with identical signatures and semantics. +All fourteen methods exist on both `A2ATClient` and `A2ATServer` with identical signatures and semantics. Every method that identifies a template takes the value type `net.openan.a2at.sdk.core.model.TemplateUri` instead of a raw string. A `TemplateUri` is always well-formed — its constructor validates the extension name, path segments, and version — so malformed URIs cannot reach these APIs. Build one with `TemplateUri.of("Negotiation-T", "information-negotiation", "propose")`, or better, use the constants in `StandardTemplates` from the same package (for example `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`, whose `uri()` is `Negotiation-T/information-negotiation/propose/v1`). When a URI string arrives from outside the code, `TemplateUri.parse(String)` returns an `Optional` and never throws. @@ -448,21 +448,21 @@ MetadataContent generateNegotiationRejectPromptFromText(String text, Negotiation MetadataContent generateNegotiationAbortPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) ``` -The template is loaded before the LLM call; a missing template fails fast without consuming an LLM request. The LLM step extracts the typed content from the free text constrained by the template URI, then rendering proceeds deterministically like the from-data variant. The `NegotiationContext` is injected into the rendered message without any LLM involvement. The extraction step is retried up to the configured attempt limit on the retryable failure codes (see 1.10.5). The abort variant extracts the termination reason from the free text against the common abort template. +The template is loaded before the LLM call; a missing template fails fast without consuming an LLM request. The LLM step extracts the typed content from the free text constrained by the template URI, then rendering proceeds deterministically like the from-data variant. The `NegotiationContext` is carried in the generated metadata without any LLM involvement and is no longer rendered into the message text. The extraction step is retried up to the configured attempt limit on the retryable failure codes (see 1.10.5). The abort variant extracts the termination reason from the free text against the common abort template. **Compliance checking and parameter extraction:** ```java -FilledParamData validateProposePromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) -FilledParamData validateAcceptPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) -FilledParamData validateRejectPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) -FilledParamData validateAbortPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateProposePromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) +FilledParamData validateAcceptPromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) +FilledParamData validateRejectPromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) +FilledParamData validateAbortPromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) ``` The pipeline runs in a fixed order: 1. Template URI format check (phase segment must match the method: `propose` vs `accept-reject`). -2. Deterministic rule gate — recognition of the negotiation context section and its structural rules, before any LLM call. +2. Deterministic rule gate — structural rules of the caller-supplied `NegotiationContext` (UUID id shape, round within the round budget), before any LLM call. A null context is reported as `negotiation_invalid_input` (not a negotiation message). 3. One LLM semantic validation call that also extracts the parameters, retried up to the configured attempt limit on the retryable LLM infrastructure failure code. 4. Merge of the extracted parameters with the rule-level context parameters — **context parameters take precedence** on key conflict. @@ -510,7 +510,7 @@ To override one template (for example the information propose template in Chines All types live in `net.openan.a2at.sdk.negotiation.content`. -**NegotiationContext** — the session context carried by every message: `record NegotiationContext(String id, int round, int maxRounds)`. It is immutable; `nextRound()` returns a context with the round incremented, and `isExhausted()` reports whether the round is strictly greater than `maxRounds`. `NegotiationContext.of(id, round)` applies the default round budget of `DEFAULT_MAX_ROUNDS = 5`. +**NegotiationContext** — the session context carried by every negotiation message in the `core.model` package: `record NegotiationContext(String id, int round, int maxRounds)`. It is immutable; `nextRound()` returns a context with the round incremented, and `isExhausted()` reports whether the round is strictly greater than `maxRounds`. `NegotiationContext.of(id, round)` applies the default round budget of `DEFAULT_MAX_ROUNDS = 5`. **Input bundles** — `NegotiationProposeData(context, content)` and `NegotiationEndingData(context, content)` pair the context with the typed content, and `NegotiationAbortData(context, NegotiationAbortContent)` pairs it with the type-independent abort content. The content types are sealed hierarchies per negotiation type: @@ -522,7 +522,7 @@ All types live in `net.openan.a2at.sdk.negotiation.content`. `NegotiationItem(name, value)` is one named entry of an item list. `NegotiationConclusion` carries `ACCEPT`, `REJECT`, and `ABORT`; only `Accept` and `Reject` are renderable conclusions of the typed negotiation templates — the typed generation methods reject `ABORT` with an `IllegalArgumentException` (a programming error outside the `A2ATError` tree). The `ABORT` outcome is rendered through the type-independent common abort template: `NegotiationAbortContent(terminationReason)` is the only content it carries and `NegotiationAbortData(context, content)` is its input bundle. `NegotiationAction` (`REQUEST_FEASIBILITY_EVALUATION`, `PROPOSE_ALTERNATIVE_ON_FAILURE`) selects the conditional sections of the feasibility propose template. -**MetadataContent** — the generation result: `record MetadataContent(String templateUri, String promptText, String extensionUri)`. `buildMetadataContent()` returns exactly two keys: the TMF extension URI (`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`) mapping to the rendered message, and `templateUri` mapping to the template URI. This map is what travels in the A2A message metadata. +**MetadataContent** — the generation result: `record MetadataContent(String templateUri, String promptText, String extensionUri, NegotiationContext negotiationContext)`. `buildMetadataContent()` returns `Map`: the TMF extension URI (`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`) mapping to the rendered message, and `templateUri` mapping to the template URI; negotiation messages additionally carry `negotiationContext` mapping to the nested `{id, round, maxRounds}` object, while non-negotiation messages omit the key and keep the two-key shape. This map is what travels in the A2A message metadata — the negotiation context travels here, not in the rendered text. **FilledParamData** — the extraction result: `record FilledParamData(Map data)` holding the context parameters merged with the schema-extracted parameters. @@ -551,7 +551,7 @@ Error codes and their meaning: | `negotiation_content_extract_failed` | Structured content could not be extracted from free text | Yes | | `negotiation_llm_infrastructure_error` | An LLM infrastructure failure (network, provider error, malformed response) | Yes | | `negotiation_semantic_rejected` | Semantic validation rejected the message | No | -| `negotiation_rule_violation` | The negotiation context section violates a structural rule | No | +| `negotiation_rule_violation` | The negotiation context violates a structural rule | No | | `negotiation_slot_missing` | A required slot is missing when rendering | No | | `negotiation_invalid_input` | The input is not valid for the operation (blank free text on the from-text generation calls, not a negotiation message, wrong conclusion) | No | | `param_extraction_failed` | Default code for extraction failures without a more specific code | No | @@ -596,10 +596,11 @@ MetadataContent propose = client.generateNegotiationProposePromptFromData( new NegotiationProposeData(context, content), StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); -// This two-key map travels in the A2A message metadata. -Map metadata = propose.buildMetadataContent(); +// This map travels in the A2A message metadata. +Map metadata = propose.buildMetadataContent(); // metadata.get("templateUri") -> "Negotiation-T/information-negotiation/propose/v1" // metadata.get(propose.extensionUri()) -> the rendered message text +// metadata.get("negotiationContext") -> the nested {id, round, maxRounds} context // --- Server side: validate the received message and extract parameters --- A2ATServer server = new A2ATServer(Path.of("server.env")); @@ -613,7 +614,7 @@ Map schema = Map.of( try { FilledParamData params = server.validateProposePromptAndDataFilling( - propose.promptText(), schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); + propose.promptText(), context, schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); // params.data() holds the extracted parameters plus the context parameters. } catch (A2ATParamExtractionError failure) { // Branch on failure.getCode(): negotiation_invalid_input, negotiation_rule_violation, @@ -630,7 +631,7 @@ MetadataContent accept = server.generateNegotiationAcceptPromptFromData( StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); // The client validates the accept message the same way, with the accept-reject template: -// client.validateAcceptPromptAndDataFilling(accept.promptText(), schema, +// client.validateAcceptPromptAndDataFilling(accept.promptText(), context, schema, // StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); ``` diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 4f84a8a9..fe4fc534 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -392,7 +392,7 @@ mvn -pl a2a-t-sample -am -DskipTests package ## 1.10 Negotiation-T 内容层 -本节介绍 Negotiation-T 内容层:一组以模板为驱动的 API,用于生成协商消息、校验协商消息合规性并从中提取参数。`A2ATClient` 与 `A2ATServer` 均暴露相同的十三个方法。 +本节介绍 Negotiation-T 内容层:一组以模板为驱动的 API,用于生成协商消息、校验协商消息合规性并从中提取参数。`A2ATClient` 与 `A2ATServer` 均暴露相同的十四个方法。 ### 1.10.1 概述与 SDK 边界 @@ -406,7 +406,7 @@ mvn -pl a2a-t-sample -am -DskipTests package ### 1.10.2 门面方法 -以下十三个方法在 `A2ATClient` 和 `A2ATServer` 上具有完全相同的签名和语义。 +以下十四个方法在 `A2ATClient` 和 `A2ATServer` 上具有完全相同的签名和语义。 所有用于标识模板的参数均采用值类型 `net.openan.a2at.sdk.core.model.TemplateUri`,而非裸字符串。`TemplateUri` 恒为格式正确 — 其构造器会校验扩展名、路径段与版本 — 因此格式错误的 URI 不可能进入这些 API。可用 `TemplateUri.of("Negotiation-T", "information-negotiation", "propose")` 构造,更推荐直接使用同包 `StandardTemplates` 中的常量(例如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`,其 `uri()` 为 `Negotiation-T/information-negotiation/propose/v1`)。当 URI 字符串来自代码之外时,`TemplateUri.parse(String)` 返回 `Optional` 且绝不抛出异常。 @@ -430,21 +430,21 @@ MetadataContent generateNegotiationRejectPromptFromText(String text, Negotiation MetadataContent generateNegotiationAbortPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) ``` -模板在调用 LLM 之前先行加载;模板缺失会快速失败,不会消耗 LLM 请求。LLM 步骤在模板 URI 的约束下从自由文本中抽取类型化内容,随后的渲染与 from-data 变体一样是确定性的。`NegotiationContext` 不经任何 LLM 直接注入渲染结果。抽取步骤在可重试错误码(见 1.10.5)上最多重试到配置的尝试上限。abort 变体面向 common abort 模板从自由文本中抽取协商终止原因。 +模板在调用 LLM 之前先行加载;模板缺失会快速失败,不会消耗 LLM 请求。LLM 步骤在模板 URI 的约束下从自由文本中抽取类型化内容,随后的渲染与 from-data 变体一样是确定性的。`NegotiationContext` 不经任何 LLM 直接注入生成的 metadata(不再渲染进消息文本)。抽取步骤在可重试错误码(见 1.10.5)上最多重试到配置的尝试上限。abort 变体面向 common abort 模板从自由文本中抽取协商终止原因。 **合规校验与参数提取:** ```java -FilledParamData validateProposePromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) -FilledParamData validateAcceptPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) -FilledParamData validateRejectPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) -FilledParamData validateAbortPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) +FilledParamData validateProposePromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) +FilledParamData validateAcceptPromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) +FilledParamData validateRejectPromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) +FilledParamData validateAbortPromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) ``` 流水线按固定顺序执行: 1. 模板 URI 格式检查(phase 段必须与方法匹配:`propose` 或 `accept-reject`)。 -2. 确定性规则门禁 — 在任何 LLM 调用之前,识别协商上下文章节及其结构规则。 +2. 确定性规则门禁 — 在任何 LLM 调用之前,校验调用方传入的 `NegotiationContext` 的结构规则(id 为 UUID 形态、轮次不超过轮次预算);context 为 null 时报 `negotiation_invalid_input`(非协商消息)。 3. 一次 LLM 语义校验调用,同时完成参数提取;在可重试的 LLM 基础设施错误码上最多重试到配置的尝试上限。 4. 将提取的参数与规则层得到的上下文参数合并 — 键冲突时**上下文参数优先**。 @@ -492,7 +492,7 @@ Negotiation-T/{type}-negotiation/{phase}/v1 所有类型位于 `net.openan.a2at.sdk.negotiation.content` 包。 -**NegotiationContext** — 每条消息携带的会话上下文:`record NegotiationContext(String id, int round, int maxRounds)`。它不可变;`nextRound()` 返回轮次加一的新上下文,`isExhausted()` 判断当前轮次是否严格大于 `maxRounds`。`NegotiationContext.of(id, round)` 使用默认轮次预算 `DEFAULT_MAX_ROUNDS = 5`。 +**NegotiationContext** — 每条协商消息随 metadata 携带的会话上下文,位于 `core.model` 包:`record NegotiationContext(String id, int round, int maxRounds)`。它不可变;`nextRound()` 返回轮次加一的新上下文,`isExhausted()` 判断当前轮次是否严格大于 `maxRounds`。`NegotiationContext.of(id, round)` 使用默认轮次预算 `DEFAULT_MAX_ROUNDS = 5`。 **输入包装** — `NegotiationProposeData(context, content)` 与 `NegotiationEndingData(context, content)` 将上下文与类型化内容配对,`NegotiationAbortData(context, NegotiationAbortContent)` 将上下文与类型无关的协商终止内容配对。内容类型是按协商类型划分的密封继承体系: @@ -504,7 +504,7 @@ Negotiation-T/{type}-negotiation/{phase}/v1 `NegotiationItem(name, value)` 表示条目列表中的一个命名条目。`NegotiationConclusion` 取值为 `ACCEPT`、`REJECT`、`ABORT`;只有 `Accept` 与 `Reject` 是类型化协商模板的可渲染结论 — 类型化生成方法会以 `IllegalArgumentException`(`A2ATError` 树外的编程错误)拒绝 `ABORT`。`ABORT` 结果经由类型无关的 common abort 模板渲染:`NegotiationAbortContent(terminationReason)` 是它唯一携带的内容,`NegotiationAbortData(context, content)` 是其输入包装。`NegotiationAction`(`REQUEST_FEASIBILITY_EVALUATION`、`PROPOSE_ALTERNATIVE_ON_FAILURE`)决定可行性 propose 模板中渲染哪些条件章节。 -**MetadataContent** — 生成结果:`record MetadataContent(String templateUri, String promptText, String extensionUri)`。`buildMetadataContent()` 返回恰好两个键:TMF 扩展 URI(`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`)映射到渲染后的消息文本,`templateUri` 映射到模板 URI。这个映射即随 A2A 消息 metadata 传输的内容。 +**MetadataContent** — 生成结果:`record MetadataContent(String templateUri, String promptText, String extensionUri, NegotiationContext negotiationContext)`。`buildMetadataContent()` 返回 `Map`:TMF 扩展 URI(`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`)映射到渲染后的消息文本,`templateUri` 映射到模板 URI;协商消息额外携带 `negotiationContext` 映射到嵌套的 `{id, round, maxRounds}` 对象,非协商消息省略该键、保持两键形态。这个映射即随 A2A 消息 metadata 传输的内容——协商上下文随 metadata 传输,不再渲染进消息文本。 **FilledParamData** — 提取结果:`record FilledParamData(Map data)`,持有上下文参数与按 Schema 提取的参数合并后的结果。 @@ -533,7 +533,7 @@ Negotiation-T/{type}-negotiation/{phase}/v1 | `negotiation_content_extract_failed` | 无法从自由文本中抽取结构化内容 | 是 | | `negotiation_llm_infrastructure_error` | LLM 基础设施故障(网络、提供方错误、响应格式错误) | 是 | | `negotiation_semantic_rejected` | 语义校验拒绝了该消息 | 否 | -| `negotiation_rule_violation` | 协商上下文章节违反结构规则 | 否 | +| `negotiation_rule_violation` | 协商上下文违反结构规则 | 否 | | `negotiation_slot_missing` | 渲染时缺少必需槽位 | 否 | | `negotiation_invalid_input` | 输入对当前操作无效(FromText 生成调用的输入文本为空、不是协商消息、结论错误) | 否 | | `param_extraction_failed` | 无更具体错误码时参数提取失败的默认错误码 | 否 | @@ -578,10 +578,11 @@ MetadataContent propose = client.generateNegotiationProposePromptFromData( new NegotiationProposeData(context, content), StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); -// 这个两键映射随 A2A 消息 metadata 传输。 -Map metadata = propose.buildMetadataContent(); +// 这个映射随 A2A 消息 metadata 传输。 +Map metadata = propose.buildMetadataContent(); // metadata.get("templateUri") -> "Negotiation-T/information-negotiation/propose/v1" // metadata.get(propose.extensionUri()) -> 渲染后的消息文本 +// metadata.get("negotiationContext") -> 嵌套的 {id, round, maxRounds} 上下文 // --- 服务端:校验收到的消息并提取参数 --- A2ATServer server = new A2ATServer(Path.of("server.env")); @@ -595,7 +596,7 @@ Map schema = Map.of( try { FilledParamData params = server.validateProposePromptAndDataFilling( - propose.promptText(), schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); + propose.promptText(), context, schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); // params.data() 持有提取的参数以及上下文参数。 } catch (A2ATParamExtractionError failure) { // 按 failure.getCode() 分支处理:negotiation_invalid_input、negotiation_rule_violation、 @@ -612,7 +613,7 @@ MetadataContent accept = server.generateNegotiationAcceptPromptFromData( StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); // 客户端以相同方式、使用 accept-reject 模板校验该接受消息: -// client.validateAcceptPromptAndDataFilling(accept.promptText(), schema, +// client.validateAcceptPromptAndDataFilling(accept.promptText(), context, schema, // StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); ``` From af29b33afae6a629f3d655e8a223d51fd8ef9844 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 22 Aug 2026 22:41:39 +0800 Subject: [PATCH 136/295] fix(sample): adapt negotiation interface evaluator to explicit NegotiationContext validate APIs Signed-off-by: codetalks-hw --- .../negotiation/eval/NegotiationEvalApp.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java index 0ad28d5c..a7c49c01 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java @@ -23,13 +23,13 @@ import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; @@ -273,11 +273,13 @@ private static Map runCase(Path envPath, Map sui missingItems.add(new NegotiationItem(slot, missingHint(phrasing, properties, slot))); } String proposePrompt; + NegotiationContext proposeContext = new NegotiationContext( + UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS); try { A2ATServer server = new A2ATServer(envPath); MetadataContent propose = server.generateNegotiationProposePromptFromData( new NegotiationProposeData( - new NegotiationContext(UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS), + proposeContext, new InformationProposeContent(missingItems, phrasing.get("propose_relationship"))), DemoTemplates.NEGOTIATION_PROPOSE); proposePrompt = propose.promptText(); @@ -301,7 +303,7 @@ private static Map runCase(Path envPath, Map sui long nanos = System.nanoTime(); A2ATServer server = new A2ATServer(envPath); FilledParamData proposeParams = server.validateProposePromptAndDataFilling( - proposePrompt, negotiationSchema(actualMissing), DemoTemplates.NEGOTIATION_PROPOSE); + proposePrompt, proposeContext, negotiationSchema(actualMissing), DemoTemplates.NEGOTIATION_PROPOSE); proposeValid = true; Map step = stepOf( "4. outbound propose validation", @@ -352,11 +354,13 @@ private static Map runCase(Path envPath, Map sui expectNegotiation, expectMissing, expectFillCompletes); } String acceptPrompt; + NegotiationContext acceptContext = new NegotiationContext( + UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS); try { A2ATClient client = new A2ATClient(envPath); MetadataContent accept = client.generateNegotiationAcceptPromptFromData( new NegotiationEndingData( - new NegotiationContext(UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS), + acceptContext, new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems(fills))), DemoTemplates.NEGOTIATION_ACCEPT); acceptPrompt = accept.promptText(); @@ -384,7 +388,7 @@ private static Map runCase(Path envPath, Map sui long nanos = System.nanoTime(); A2ATServer server = new A2ATServer(envPath); FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( - acceptPrompt, negotiationSchema(actualMissing), DemoTemplates.NEGOTIATION_ACCEPT); + acceptPrompt, acceptContext, negotiationSchema(actualMissing), DemoTemplates.NEGOTIATION_ACCEPT); acceptValid = true; extractedAcceptParams = acceptParams.data() == null ? Map.of() : new LinkedHashMap<>(acceptParams.data()); fillValueMatch = fillValuesMatch(extractedAcceptParams, fills); From b595687a106504e01bdf0fa0d722e38909ad1d55 Mon Sep 17 00:00:00 2001 From: wangruiyang8 Date: Sat, 22 Aug 2026 23:24:27 +0800 Subject: [PATCH 137/295] refactor(sample): restructure service-recovery reportFormat to structured array and add accuracy test cases - change input-with-data.json reportFormat from string to structured array with name/values/required fields - update client/schema.json to reflect reportFormat as array type with items - add accuracy test cases covering various reportFormat types (string, array, object, null, missing) and validate-and-fill cases (non-template, missing topic) Signed-off-by: wangruiyang8 --- .../ServiceRecoverySampleInputs.java | 38 +-- .../client/flow/ClientSampleFlow.java | 4 +- .../client/input-with-data.json | 47 +++- .../service-recovery/client/schema.json | 28 +- .../en-US/content_validation.json | 5 +- .../mock_responses/en-US/slot_extraction.json | 2 +- .../zh-CN/content_validation.json | 5 +- .../mock_responses/zh-CN/slot_extraction.json | 2 +- .../service-recovery/server/schema.json | 74 +----- .../server/slot-names.properties | 11 - .../test-cases-generate-from-data.json | 249 ++++++++++++++++++ .../test-cases-validate-and-fill.json | 82 ++++++ 12 files changed, 430 insertions(+), 117 deletions(-) delete mode 100644 a2a-t-sample/src/main/resources/sample/service-recovery/server/slot-names.properties create mode 100644 a2a-t-sample/src/test/resources/service-recovery-accuracy-test/test-cases-generate-from-data.json create mode 100644 a2a-t-sample/src/test/resources/service-recovery-accuracy-test/test-cases-validate-and-fill.json diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java index 9aad67ba..09c9bce7 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java @@ -39,10 +39,6 @@ public final class ServiceRecoverySampleInputs { private static final String VALIDATION_SCHEMA_RESOURCE = "sample/service-recovery/server/schema.json"; - /** Resource holding the zh-CN slot names of the bundled template. */ - private static final String SLOT_NAMES_RESOURCE = - "sample/service-recovery/server/slot-names.properties"; - /** Template URI verified by this sample (network-layer domain layout of the bundled template). */ public static final String TEMPLATE_URI = "Notification-T/network-layer/service-recovery/v1"; @@ -52,17 +48,14 @@ public final class ServiceRecoverySampleInputs { /** Interval in seconds between two consecutive notification reports of one subscription. */ public static final long NOTIFICATION_REPORT_INTERVAL_SECONDS = 5L; - /** Slot name of the optional subscription condition slot. */ - public static final String SLOT_SUBSCRIBE_CONDITION = slotName("subscriptionCondition"); - - /** Slot name of the required recovery plan execution status field. */ - public static final String SLOT_RECOVERY_PLAN_EXECUTION_STATUS = slotName("recoveryPlanExecutionStatus"); + /** Server-side parameter name of the topic (English business identifier). */ + public static final String PARAM_TOPIC = "topic"; /** Server-side parameter name of the subscription condition (English business identifier). */ public static final String PARAM_SUBSCRIPTION_CONDITION = "subscriptionCondition"; - /** Server-side parameter name of the recovery plan execution status (English business identifier). */ - public static final String PARAM_RECOVERY_PLAN_EXECUTION_STATUS = "recoveryPlanExecutionStatus"; + /** Server-side parameter name of the notification data format (English business identifier). */ + public static final String PARAM_NOTIFICATION_DATA_FORMAT = "notificationDataFormat"; /** AgentCard query name of the sample server. */ public static final String AGENT_NAME = "SPN Service Recovery Agent"; @@ -73,25 +66,6 @@ public final class ServiceRecoverySampleInputs { private ServiceRecoverySampleInputs() { } - private static String slotName(String key) { - java.util.Properties properties = new java.util.Properties(); - try (InputStream inputStream = ServiceRecoverySampleInputs.class - .getClassLoader() - .getResourceAsStream(SLOT_NAMES_RESOURCE)) { - if (inputStream == null) { - throw new IllegalStateException("Sample resource not found: " + SLOT_NAMES_RESOURCE); - } - properties.load(new java.io.InputStreamReader(inputStream, StandardCharsets.UTF_8)); - } catch (IOException exception) { - throw new IllegalStateException("Failed to read sample resource: " + SLOT_NAMES_RESOURCE, exception); - } - String value = properties.getProperty(key); - if (value == null || value.isBlank()) { - throw new IllegalStateException("Missing slot name for key: " + key); - } - return value; - } - /** * Loads the natural-language subscription request from * {@code sample/service-recovery/client/input-with-text.txt}. @@ -108,7 +82,7 @@ public static String naturalLanguageInput() { * {@code sample/service-recovery/client/input-with-data.json}. * *

      The keys follow the English business-style parameter names of the server-side validation - * schema ({@code subscriptionCondition}/{@code recoveryPlanExecutionStatus}), demonstrating that the + * schema ({@code topic}/{@code subscriptionCondition}/{@code notificationDataFormat}), demonstrating that the * schema-guided generation path maps caller-provided structured data onto the template slots. * * @return structured notification input as a string-to-object map @@ -137,7 +111,7 @@ public static Map dataSchema() { * *

      The schema mirrors the slot.json format ({@code $schema}/{@code additionalProperties}/ * {@code description}/{@code examples}/{@code x-a2at-value-constraint}), but the property keys - * are English business-style identifiers ({@code subnetwork}/{@code reportFormat}) instead of + * are English business-style identifiers ({@code topic}/{@code notificationDataFormat}) instead of * the zh-CN slot names, demonstrating that the validation API fills parameters per the * caller-provided schema rather than echoing template slots. * diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java index fae0a8b0..c935d91d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java @@ -246,9 +246,9 @@ private static void addGenerationChecks( checks.add(expectation( label + ": prompt carries the recovery plan execution status", content.promptText() != null - && content.promptText().contains(ServiceRecoverySampleInputs.SLOT_RECOVERY_PLAN_EXECUTION_STATUS), + && content.promptText().contains("业务抢通方案执行状态"), "promptText does not carry the required " - + ServiceRecoverySampleInputs.SLOT_RECOVERY_PLAN_EXECUTION_STATUS + " slot")); + + "业务抢通方案执行状态" + " slot")); } private static VerificationCheck expectation(String name, boolean passed, String failureDetail) { diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/client/input-with-data.json b/a2a-t-sample/src/main/resources/sample/service-recovery/client/input-with-data.json index c4084761..7f34e743 100644 --- a/a2a-t-sample/src/main/resources/sample/service-recovery/client/input-with-data.json +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/client/input-with-data.json @@ -1,3 +1,48 @@ { - "reportFormat": "业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、业务抢通方案名称、业务抢通方案详情、业务抢通方案执行结束时间、业务抢通方案执行结果(成功、失败)及业务抢通方案执行失败原因。" + "condition": "子网名称:xx子网", + "reportFormat": [ + { + "name": "业务抢通方案执行状态", + "values": ["未启动", "已结束"], + "required": true + }, + { + "name": "投诉诊断任务流水号", + "required": true + }, + { + "name": "OSS侧事件流水号", + "required": true + }, + { + "name": "接入端口名称", + "required": true + }, + { + "name": "是否已授权OMC自动抢通", + "values": ["是", "否"], + "required": true + }, + { + "name": "业务抢通方案名称", + "required": true + }, + { + "name": "业务抢通方案详情", + "required": true + }, + { + "name": "业务抢通方案执行结束时间", + "required": false + }, + { + "name": "业务抢通方案执行结果", + "values": ["成功", "失败"], + "required": false + }, + { + "name": "业务抢通方案执行失败原因", + "required": false + } + ] } diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/client/schema.json b/a2a-t-sample/src/main/resources/sample/service-recovery/client/schema.json index 8e067bf5..611280c8 100644 --- a/a2a-t-sample/src/main/resources/sample/service-recovery/client/schema.json +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/client/schema.json @@ -12,11 +12,29 @@ "x-a2at-value-constraint": "订阅条件为子网名称。订阅可以不带条件,此时该参数为空。" }, "reportFormat": { - "type": "string", - "description": "上报通知数据格式,必选。描述所需上报内容的:信息项名称(必选)、取值范围(可选)、样例(可选)。", - "examples": [ - "业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束(必选);投诉诊断任务流水号;举例:9de168c0-6179-4778-8b72-4279582c0a3f(必选);OSS侧事件流水号;举例:event-id-202606250128(必选);接入端口名称;举例:P781-珠江新城-PTN7900-23-TPA1EG24-17(必选);是否已授权OMC自动抢通,取值范围:是、否;举例:是(必选);业务抢通方案名称;举例:隧道调优(必选);业务抢通方案详情(必选);业务抢通方案执行结束时间;样例:2026-05-11T08:21:46Z(可选);业务抢通方案执行结果,取值范围:成功、失败;举例:成功(可选);业务抢通方案执行失败原因;业务抢通方案执行结果为“失败”时,需要填写此信息;举例:调优后隧道链路C已被占用,无法执行隧道调优(可选)" - ], + "type": "array", + "description": "上报通知数据格式,必选。描述所需上报内容的字段列表,每个字段包含信息项名称(必选)、取值范围(可选)、是否必选(可选)。", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "信息项名称(必选)。" + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "description": "取值范围(可选)。" + }, + "required": { + "type": "boolean", + "description": "是否必选(可选)。" + } + }, + "required": ["name"] + }, "x-a2at-value-constraint": "上报的数据类型为业务抢通事件。业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,业务抢通方案执行结果为“失败”时,需要填写此信息(可选)。" } }, diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/content_validation.json b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/content_validation.json index d4966777..950cf092 100644 --- a/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/content_validation.json +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/content_validation.json @@ -2,7 +2,8 @@ "semantic_verdict": true, "errors": [], "params": { + "topic": "Service Recovery Event", "subscriptionCondition": "", - "notificationDataFormat": "Service recovery plan execution status, allowed values: not started, ended; example: ended (required); Complaint diagnosis task sequence number; example: 9de168c0-6179-4778-8b72-4279582c0a3f (required); OSS-side event sequence number; example: event-id-202606250128 (required); Access port name; example: P781-珠江新城-PTN7900-23-TPA1EG24-17 (required); Whether OMC automatic recovery is authorized, allowed values: yes, no; example: yes (required); Service recovery plan name; example: tunnel tuning (required); Service recovery plan details (required); Service recovery plan execution end time; example: 2026-05-11T08:21:46Z (optional); Service recovery plan execution result, allowed values: success, failure; example: success (optional); Service recovery plan execution failure reason; required when the execution result is failure; example: link C is occupied after tuning, unable to perform tunnel tuning (optional)" + "notificationDataFormat": "The service recovery event data contains: service recovery plan execution status (not started, ended), complaint diagnosis task sequence number, OSS-side event sequence number, access port name, whether OMC automatic recovery is authorized (yes, no), service recovery plan name, service recovery plan details." } -} +} \ No newline at end of file diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/slot_extraction.json b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/slot_extraction.json index d4b0bcc0..7f5144d0 100644 --- a/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/slot_extraction.json +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/slot_extraction.json @@ -1,7 +1,7 @@ { "slots": { "subscribe_condition": null, - "notification_data_format": "The service recovery event data contains the following fields: 1. Service recovery plan execution status, allowed values: not started, ended (required); 2. Complaint diagnosis task sequence number (required); 3. OSS-side event sequence number (required); 4. Access port name (required); 5. Whether OMC automatic recovery is authorized, allowed values: yes, no (required); 6. Service recovery plan name (required); 7. Service recovery plan details (required); 8. Service recovery plan execution end time (optional)." + "notification_data_format": "The service recovery event data contains the following fields: 1. Service recovery plan execution status, allowed values: not started, ended (required); 2. Complaint diagnosis task sequence number (required); 3. OSS-side event sequence number (required); 4. Access port name (required); 5. Whether OMC automatic recovery is authorized, allowed values: yes, no (required); 6. Service recovery plan name (required); 7. Service recovery plan details (required); 8. Service recovery plan execution end time (optional); 9. Service recovery plan execution result, allowed values: success, failure (optional); 10. Service recovery plan execution failure reason, required when the execution result is failure (optional)." }, "slot_errors": [] } diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/content_validation.json b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/content_validation.json index 27defbf8..4d8b6b16 100644 --- a/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/content_validation.json +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/content_validation.json @@ -2,7 +2,8 @@ "semantic_verdict": true, "errors": [], "params": { + "topic": "业务抢通事件", "subscriptionCondition": "", - "notificationDataFormat": "业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束(必选);投诉诊断任务流水号;举例:9de168c0-6179-4778-8b72-4279582c0a3f(必选);OSS侧事件流水号;举例:event-id-202606250128(必选);接入端口名称;举例:P781-珠江新城-PTN7900-23-TPA1EG24-17(必选);是否已授权OMC自动抢通,取值范围:是、否;举例:是(必选);业务抢通方案名称;举例:隧道调优(必选);业务抢通方案详情(必选);业务抢通方案执行结束时间;样例:2026-05-11T08:21:46Z(可选);业务抢通方案执行结果,取值范围:成功、失败;举例:成功(可选);业务抢通方案执行失败原因;业务抢通方案执行结果为“失败”时,需要填写此信息;举例:调优后隧道链路C已被占用,无法执行隧道调优(可选)" + "notificationDataFormat": "业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、业务抢通方案名称、业务抢通方案详情。" } -} +} \ No newline at end of file diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/slot_extraction.json b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/slot_extraction.json index cbd03337..60d89fd4 100644 --- a/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/slot_extraction.json +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/slot_extraction.json @@ -1,7 +1,7 @@ { "slots": { "订阅条件": null, - "上报通知数据格式": "业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选)。" + "上报通知数据格式": "业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)。" }, "slot_errors": [] } diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json b/a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json index 3c4cc2ba..ead6da10 100644 --- a/a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json @@ -3,75 +3,29 @@ "type": "object", "additionalProperties": false, "properties": { + "topic": { + "type": "string", + "description": "订阅主题(必选)。订阅的事件主题名称,例如:业务抢通事件。", + "examples": ["业务抢通事件"] + }, "subscriptionCondition": { "type": "string", - "description": "订阅条件。待订阅的条件描述,例如:子网名称为xx子网。订阅可以不带条件,此时该参数输出空字符串\"\"而非null。", + "description": "订阅条件(可选)。待订阅的条件描述,例如:子网名称为xx子网。订阅可以不带条件,此时该参数输出空字符串\"\"而非null。", "examples": [ "子网名称:xx子网" ], "x-a2at-value-constraint": "订阅条件为子网名称。订阅可以不带条件,此时该参数为空。" }, - "recoveryPlanExecutionStatus": { - "type": "string", - "description": "业务抢通方案执行状态(必选)。取值范围:未启动、已结束。", - "enum": ["未启动", "已结束"], - "examples": ["已结束"] - }, - "complaintDiagnosisTaskId": { - "type": "string", - "description": "投诉诊断任务流水号(必选)。", - "examples": ["9de168c0-6179-4778-8b72-4279582c0a3f"] - }, - "ossEventId": { + "notificationDataFormat": { "type": "string", - "description": "OSS侧事件流水号(必选)。", - "examples": ["event-id-202606250128"] - }, - "accessPortName": { - "type": "string", - "description": "接入端口名称(必选)。", - "examples": ["P781-珠江新城-PTN7900-23-TPA1EG24-17"] - }, - "omcAutoRecoveryAuthorized": { - "type": "string", - "description": "是否已授权OMC自动抢通(必选)。取值范围:是、否。", - "enum": ["是", "否"], - "examples": ["是"] - }, - "recoveryPlanName": { - "type": "string", - "description": "业务抢通方案名称(必选)。", - "examples": ["隧道调优"] - }, - "recoveryPlanDetail": { - "type": "string", - "description": "业务抢通方案详情(必选)。", - "examples": ["将流量带宽,时延越限和丢包越限受影响的隧道列表,调优到新的路径列表中。"] - }, - "recoveryPlanEndTime": { - "type": "string", - "description": "业务抢通方案执行结束时间(可选)。", - "examples": ["2026-05-11T08:21:46Z"] - }, - "recoveryPlanExecutionResult": { - "type": "string", - "description": "业务抢通方案执行结果(可选)。取值范围:成功、失败。", - "enum": ["成功", "失败"], - "examples": ["成功"] - }, - "recoveryPlanFailureReason": { - "type": "string", - "description": "业务抢通方案执行失败原因(可选)。业务抢通方案执行结果为“失败”时,需要填写此信息。", - "examples": ["调优后隧道链路C已被占用,无法执行隧道调优"] + "description": "上报通知数据格式(必选)。待上报的通知数据格式描述,用于定义业务抢通事件的数据结构。", + "examples": [ + "业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、业务抢通方案名称、业务抢通方案详情。" + ] } }, "required": [ - "recoveryPlanExecutionStatus", - "complaintDiagnosisTaskId", - "ossEventId", - "accessPortName", - "omcAutoRecoveryAuthorized", - "recoveryPlanName", - "recoveryPlanDetail" + "topic", + "notificationDataFormat" ] -} +} \ No newline at end of file diff --git a/a2a-t-sample/src/main/resources/sample/service-recovery/server/slot-names.properties b/a2a-t-sample/src/main/resources/sample/service-recovery/server/slot-names.properties deleted file mode 100644 index 6870679c..00000000 --- a/a2a-t-sample/src/main/resources/sample/service-recovery/server/slot-names.properties +++ /dev/null @@ -1,11 +0,0 @@ -subscriptionCondition=订阅条件 -recoveryPlanExecutionStatus=业务抢通方案执行状态 -complaintDiagnosisTaskId=投诉诊断任务流水号 -ossEventId=OSS侧事件流水号 -accessPortName=接入端口名称 -omcAutoRecoveryAuthorized=是否已授权OMC自动抢通 -recoveryPlanName=业务抢通方案名称 -recoveryPlanDetail=业务抢通方案详情 -recoveryPlanEndTime=业务抢通方案执行结束时间 -recoveryPlanExecutionResult=业务抢通方案执行结果 -recoveryPlanFailureReason=业务抢通方案执行失败原因 diff --git a/a2a-t-sample/src/test/resources/service-recovery-accuracy-test/test-cases-generate-from-data.json b/a2a-t-sample/src/test/resources/service-recovery-accuracy-test/test-cases-generate-from-data.json new file mode 100644 index 00000000..a39b8a43 --- /dev/null +++ b/a2a-t-sample/src/test/resources/service-recovery-accuracy-test/test-cases-generate-from-data.json @@ -0,0 +1,249 @@ +[ + { + "name": "case1-reportFormat是字符串-标准完整", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": "业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、业务抢通方案名称、业务抢通方案详情、业务抢通方案执行结束时间、业务抢通方案执行结果(成功、失败)及业务抢通方案执行失败原因。" + } + }, + { + "name": "case2-reportFormat是字符串-精简", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": "业务抢通方案执行状态、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通、业务抢通方案名称、业务抢通方案详情。" + } + }, + { + "name": "case3-reportFormat是字符串-空", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": "" + } + }, + { + "name": "case4-reportFormat是数组-标准完整", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": [ + {"name": "业务抢通方案执行状态", "values": ["未启动", "已结束"], "required": true}, + {"name": "投诉诊断任务流水号", "required": true}, + {"name": "OSS侧事件流水号", "required": true}, + {"name": "接入端口名称", "required": true}, + {"name": "是否已授权OMC自动抢通", "values": ["是", "否"], "required": true}, + {"name": "业务抢通方案名称", "required": true}, + {"name": "业务抢通方案详情", "required": true}, + {"name": "业务抢通方案执行结束时间", "required": false}, + {"name": "业务抢通方案执行结果", "values": ["成功", "失败"], "required": false}, + {"name": "业务抢通方案执行失败原因", "required": false} + ] + } + }, + { + "name": "case5-reportFormat是数组-仅必选7字段", + "data": { + "condition": "", + "reportFormat": [ + {"name": "业务抢通方案执行状态", "values": ["未启动", "已结束"], "required": true}, + {"name": "投诉诊断任务流水号", "required": true}, + {"name": "OSS侧事件流水号", "required": true}, + {"name": "接入端口名称", "required": true}, + {"name": "是否已授权OMC自动抢通", "values": ["是", "否"], "required": true}, + {"name": "业务抢通方案名称", "required": true}, + {"name": "业务抢通方案详情", "required": true} + ] + } + }, + { + "name": "case6-reportFormat是数组-精简仅name", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": [ + {"name": "业务抢通方案执行状态"}, + {"name": "投诉诊断任务流水号"}, + {"name": "OSS侧事件流水号"}, + {"name": "接入端口名称"}, + {"name": "是否已授权OMC自动抢通"}, + {"name": "业务抢通方案名称"}, + {"name": "业务抢通方案详情"} + ] + } + }, + { + "name": "case7-reportFormat是数组-空数组", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": [] + } + }, + { + "name": "case8-reportFormat是数组-含未知字段", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": [ + {"name": "业务抢通方案执行状态", "values": ["未启动", "已结束"], "required": true}, + {"name": "投诉诊断任务流水号", "required": true}, + {"name": "OSS侧事件流水号", "required": true}, + {"name": "接入端口名称", "required": true}, + {"name": "是否已授权OMC自动抢通", "values": ["是", "否"], "required": true}, + {"name": "业务抢通方案名称", "required": true}, + {"name": "业务抢通方案详情", "required": true}, + {"name": "业务抢通方案执行结束时间", "required": false}, + {"name": "业务抢通方案执行结果", "values": ["成功", "失败"], "required": false}, + {"name": "业务抢通方案执行失败原因", "required": false}, + {"name": "客户经理姓名", "required": false}, + {"name": "故障工单编号", "values": ["TICKET-001", "TICKET-002"], "required": false} + ] + } + }, + { + "name": "case9-reportFormat是数组-含重复字段和缺name条目", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": [ + {"name": "业务抢通方案执行状态", "values": ["未启动", "已结束"], "required": true}, + {"name": "投诉诊断任务流水号", "required": true}, + {"name": "OSS侧事件流水号", "required": true}, + {"name": "接入端口名称", "required": true}, + {"name": "是否已授权OMC自动抢通", "values": ["是", "否"], "required": true}, + {"name": "业务抢通方案名称", "required": true}, + {"name": "业务抢通方案详情", "required": true}, + {"name": "业务抢通方案执行结束时间", "required": false}, + {"name": "业务抢通方案执行结果", "values": ["成功", "失败"], "required": false}, + {"name": "业务抢通方案执行失败原因", "required": false}, + {"name": "业务抢通方案执行状态", "values": ["进行中"], "required": true}, + {"name": "", "required": false}, + {"values": ["A", "B"], "required": true} + ] + } + }, + { + "name": "case10-reportFormat是数组-枚举值含非法项", + "data": { + "condition": "", + "reportFormat": [ + {"name": "业务抢通方案执行状态", "values": ["未启动", "已结束", "暂停"], "required": true}, + {"name": "投诉诊断任务流水号", "required": true}, + {"name": "OSS侧事件流水号", "required": true}, + {"name": "接入端口名称", "required": true}, + {"name": "是否已授权OMC自动抢通", "values": ["是", "否", "未知"], "required": true}, + {"name": "业务抢通方案名称", "required": true}, + {"name": "业务抢通方案详情", "required": true}, + {"name": "业务抢通方案执行结束时间", "required": false}, + {"name": "业务抢通方案执行结果", "values": ["成功", "失败", "未知"], "required": false}, + {"name": "业务抢通方案执行失败原因", "required": false} + ] + } + }, + { + "name": "case11-reportFormat是数组-可选字段误标必选", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": [ + {"name": "业务抢通方案执行状态", "values": ["未启动", "已结束"], "required": true}, + {"name": "投诉诊断任务流水号", "required": true}, + {"name": "OSS侧事件流水号", "required": true}, + {"name": "接入端口名称", "required": true}, + {"name": "是否已授权OMC自动抢通", "values": ["是", "否"], "required": true}, + {"name": "业务抢通方案名称", "required": true}, + {"name": "业务抢通方案详情", "required": true}, + {"name": "业务抢通方案执行结束时间", "required": true}, + {"name": "业务抢通方案执行结果", "values": ["成功", "失败"], "required": true}, + {"name": "业务抢通方案执行失败原因", "required": true} + ] + } + }, + { + "name": "case12-reportFormat是数组-必选字段缺失", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": [ + {"name": "业务抢通方案执行状态", "values": ["未启动", "已结束"], "required": true}, + {"name": "投诉诊断任务流水号", "required": true}, + {"name": "业务抢通方案名称", "required": true} + ] + } + }, + { + "name": "case13-reportFormat是数组-完整含样例", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": [ + {"name": "业务抢通方案执行状态", "values": ["未启动", "已结束"], "required": true, "example": "已结束"}, + {"name": "投诉诊断任务流水号", "required": true, "example": "9de168c0-6179-4778-8b72-4279582c0a3f"}, + {"name": "OSS侧事件流水号", "required": true, "example": "event-id-202606250128"}, + {"name": "接入端口名称", "required": true, "example": "P781-珠江新城-PTN7900-23-TPA1EG24-17"}, + {"name": "是否已授权OMC自动抢通", "values": ["是", "否"], "required": true, "example": "是"}, + {"name": "业务抢通方案名称", "required": true, "example": "隧道调优"}, + {"name": "业务抢通方案详情", "required": true, "example": "将流量带宽,时延越限和丢包越限受影响的隧道列表,调优到新的路径列表中。"}, + {"name": "业务抢通方案执行结束时间", "required": false, "example": "2026-05-11T08:21:46Z"}, + {"name": "业务抢通方案执行结果", "values": ["成功", "失败"], "required": false, "example": "成功"}, + {"name": "业务抢通方案执行失败原因", "required": false, "example": "调优后隧道链路C已被占用,无法执行隧道调优"} + ] + } + }, + { + "name": "case14-reportFormat是对象-字段名到描述的映射", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": { + "业务抢通方案执行状态": {"values": ["未启动", "已结束"], "required": true}, + "投诉诊断任务流水号": {"required": true}, + "OSS侧事件流水号": {"required": true}, + "接入端口名称": {"required": true}, + "是否已授权OMC自动抢通": {"values": ["是", "否"], "required": true}, + "业务抢通方案名称": {"required": true}, + "业务抢通方案详情": {"required": true}, + "业务抢通方案执行结束时间": {"required": false}, + "业务抢通方案执行结果": {"values": ["成功", "失败"], "required": false}, + "业务抢通方案执行失败原因": {"required": false} + } + } + }, + { + "name": "case15-reportFormat是对象-空对象", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": {} + } + }, + { + "name": "case16-reportFormat是null", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": null + } + }, + { + "name": "case17-reportFormat字段缺失", + "data": { + "condition": "子网名称为:珠江新城子网。" + } + }, + { + "name": "case18-reportFormat是布尔值", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": true + } + }, + { + "name": "case19-reportFormat是数字", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": 12345 + } + }, + { + "name": "case20-reportFormat是多类型混合的怪异结构", + "data": { + "condition": "子网名称为:珠江新城子网。", + "reportFormat": { + "fields": [ + {"name": "业务抢通方案执行状态", "values": ["未启动", "已结束"]}, + "投诉诊断任务流水号" + ], + "description": "业务抢通事件" + } + } + } +] \ No newline at end of file diff --git a/a2a-t-sample/src/test/resources/service-recovery-accuracy-test/test-cases-validate-and-fill.json b/a2a-t-sample/src/test/resources/service-recovery-accuracy-test/test-cases-validate-and-fill.json new file mode 100644 index 00000000..99044ca4 --- /dev/null +++ b/a2a-t-sample/src/test/resources/service-recovery-accuracy-test/test-cases-validate-and-fill.json @@ -0,0 +1,82 @@ +[ + { + "name": "case1-订阅条件有-上报格式标准完整", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n业务抢通事件\n## 订阅条件\n子网名称为:珠江新城子网。\n## 上报通知数据格式\n业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)。\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case2-订阅条件有-上报格式无内容", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n业务抢通事件\n## 订阅条件\n子网名称为:珠江新城子网。\n## 上报通知数据格式\n\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case3-订阅条件无-上报格式标准完整", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n业务抢通事件\n## 订阅条件\n\n## 上报通知数据格式\n业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)。\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case4-订阅条件无-上报格式无内容", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n业务抢通事件\n## 订阅条件\n\n## 上报通知数据格式\n\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case5-订阅条件有-上报格式精简", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n业务抢通事件\n## 订阅条件\n子网名称为:珠江新城子网。\n## 上报通知数据格式\n业务抢通事件数据包含:业务抢通方案执行状态、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通、业务抢通方案名称、业务抢通方案详情。\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case6-订阅条件无-上报格式精简", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n业务抢通事件\n## 订阅条件\n\n## 上报通知数据格式\n业务抢通方案执行状态、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称。\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case7-订阅条件有-上报格式纯废话", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n业务抢通事件\n## 订阅条件\n子网名称为:珠江新城子网。\n## 上报通知数据格式\n哎你好,我们这边想订阅一下那个业务抢通事件哈,就是网络侧出故障那种。上报的数据呢,嗯,其实我也说不太清楚,大概就是一些状态啊、流水号啊什么的,你们看着办就行,最好详细点,反正越多越好,拜托了,谢谢啊!\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case8-订阅条件无-上报格式废话含有效字段", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n业务抢通事件\n## 订阅条件\n\n## 上报通知数据格式\n你好,帮我订阅业务抢通事件,这个很重要请务必办妥!上报的数据格式大概是这样的:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因(可选)。以上字段麻烦都保留,一个都别漏,谢谢!\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case9-订阅条件废话-上报格式标准完整", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n业务抢通事件\n## 订阅条件\n帮忙订阅一下哈,就是要那种子网名称为珠江新城子网的,其他的你们随便,没关系,别太较真。\n## 上报通知数据格式\n业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)。\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case10-订阅条件详细-上报格式完整含样例", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n业务抢通事件\n## 订阅条件\n订阅范围为珠江新城子网,覆盖其下所有接入端口与隧道资源。\n## 上报通知数据格式\n业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束(必选);投诉诊断任务流水号;举例:9de168c0-6179-4778-8b72-4279582c0a3f(必选);OSS侧事件流水号;举例:event-id-202606250128(必选);接入端口名称;举例:P781-珠江新城-PTN7900-23-TPA1EG24-17(必选);是否已授权OMC自动抢通,取值范围:是、否;举例:是(必选);业务抢通方案名称;举例:隧道调优(必选);业务抢通方案详情(必选);业务抢通方案执行结束时间;样例:2026-05-11T08:21:46Z(可选);业务抢通方案执行结果,取值范围:成功、失败;举例:成功(可选);业务抢通方案执行失败原因;业务抢通方案执行结果为\u201c失败\u201d时,需要填写此信息;举例:调优后隧道链路C已被占用,无法执行隧道调优(可选)\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case11-未使用模板-纯自然语言描述", + "promptText": "你好,我想订阅业务抢通事件。我的子网是珠江新城子网。上报的数据包括:业务抢通方案执行状态、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通、业务抢通方案名称、业务抢通方案详情。" + }, + { + "name": "case12-未使用模板-缺少订阅描述和预期输出段落", + "promptText": "## 通知主题\n业务抢通事件\n## 订阅条件\n子网名称为:珠江新城子网。\n## 上报通知数据格式\n业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)。" + }, + { + "name": "case13-未使用模板-完全不同的Markdown结构", + "promptText": "## 告警标题\n网络故障告警\n## 告警描述\n珠江新城子网发生业务抢通事件,需要尽快处理。\n## 影响范围\n珠江新城子网\n## 处理建议\n请执行隧道调优方案。" + }, + { + "name": "case14-未使用模板-无意义乱码文本", + "promptText": "asdfghjkl qwertyuiop 1234567890 !!!@@@### 业务抢通 随便写写 没有实际意义 测试一下乱码输入 看看效果如何 blablabla" + }, + { + "name": "case15-未使用模板-空字符串", + "promptText": "" + }, + { + "name": "case16-缺少固定topic-通知主题为故障事件", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n故障事件\n## 订阅条件\n子网名称为:珠江新城子网。\n## 上报通知数据格式\n业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)。\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case17-缺少固定topic-通知主题为告警事件", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n告警事件\n## 订阅条件\n子网名称为:珠江新城子网。\n## 上报通知数据格式\n业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)。\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case18-缺少固定topic-通知主题为空", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n\n## 订阅条件\n子网名称为:珠江新城子网。\n## 上报通知数据格式\n业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)。\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case19-缺少固定topic-缺少通知主题段落", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 订阅条件\n子网名称为:珠江新城子网。\n## 上报通知数据格式\n业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)。\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + }, + { + "name": "case20-缺少固定topic-通知主题为网络故障(接近但不同)", + "promptText": "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。\n## 通知主题\n网络故障\n## 订阅条件\n子网名称为:珠江新城子网。\n## 上报通知数据格式\n业务抢通事件数据包含以下字段:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)。\n## 预期输出\n1. 订阅结果,取值范围:成功、失败\n2. 订阅失败原因(可选)\n3. 订阅成功后,按照<上报通知数据格式>上报消息" + } +] \ No newline at end of file From ad86ab26787cd7efe0fdc81a95763771692f2f55 Mon Sep 17 00:00:00 2001 From: wangruiyang8 Date: Sat, 22 Aug 2026 23:28:18 +0800 Subject: [PATCH 138/295] test(sample): add accuracy test for service-recovery NotificationT generation and validation - add NotificationTAccuracyTest.java with integration tests for generateNotificationPromptFromText, fromDataWithSchema and validateNotificationPromptAndDataFilling - add test-cases-generate-from-text.json with various text input scenarios - add client.env for accuracy test (empty API key, no secrets) Signed-off-by: wangruiyang8 --- .../accuracy/NotificationTAccuracyTest.java | 302 ++++++++++++++++++ .../service-recovery-accuracy-test/client.env | 8 + .../test-cases-generate-from-text.json | 42 +++ 3 files changed, 352 insertions(+) create mode 100644 a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java create mode 100644 a2a-t-sample/src/test/resources/service-recovery-accuracy-test/client.env create mode 100644 a2a-t-sample/src/test/resources/service-recovery-accuracy-test/test-cases-generate-from-text.json diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java new file mode 100644 index 00000000..f0ae6e0f --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java @@ -0,0 +1,302 @@ +package net.openan.a2at.sample.service_recovery.accuracy; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("integration") +class NotificationTAccuracyTest { + + private static final ObjectMapper MAPPER = JsonMapper.builder() + .enable(SerializationFeature.INDENT_OUTPUT) + .build(); + + private static final String TEMPLATE_URI_STR = "Notification-T/network-layer/service-recovery/v1"; + private static final String DATA_SCHEMA_RESOURCE = "sample/service-recovery/client/schema.json"; + private static final String VALIDATION_SCHEMA_RESOURCE = "sample/service-recovery/server/schema.json"; + + private static A2ATClient client; + private static A2ATServer server; + private static TemplateUri templateUri; + private static Map dataSchema; + private static Map validationSchema; + private static boolean apiKeyConfigured; + private static Map envInfo; + + private static final List> fromTextResults = new ArrayList<>(); + private static final List> fromDataResults = new ArrayList<>(); + private static final List> validateResults = new ArrayList<>(); + + @BeforeAll + static void setup() throws Exception { + templateUri = TemplateUri.parse(TEMPLATE_URI_STR) + .orElseThrow(() -> new IllegalStateException("Invalid template URI: " + TEMPLATE_URI_STR)); + + dataSchema = loadJsonResource(DATA_SCHEMA_RESOURCE); + validationSchema = loadJsonResource(VALIDATION_SCHEMA_RESOURCE); + + Path env = resolveEnvFile("service-recovery-accuracy-test/client.env"); + envInfo = readEnvInfo(env); + + String apiKey = envInfo.get("A2AT_LLM_API_KEY"); + apiKeyConfigured = apiKey != null && !apiKey.isBlank() && !apiKey.contains("${"); + + if (apiKeyConfigured) { + client = new A2ATClient(env); + server = new A2ATServer(env); + } + } + + @AfterAll + static void writeReport() throws Exception { + Map report = new LinkedHashMap<>(); + + Map testRun = new LinkedHashMap<>(); + testRun.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + testRun.put("env", envInfo); + testRun.put("apiKeyConfigured", apiKeyConfigured); + report.put("testRun", testRun); + + report.put("generateNotificationPromptFromText", Map.of("testCases", fromTextResults)); + report.put("generateNotificationPromptFromDataWithSchema", Map.of("testCases", fromDataResults)); + report.put("validateNotificationPromptAndDataFilling", Map.of("testCases", validateResults)); + + Path reportDir = Path.of("target"); + Files.createDirectories(reportDir); + Path reportFile = reportDir.resolve("service-recovery-accuracy-report.json"); + MAPPER.writeValue(reportFile.toFile(), report); + System.out.println("Report written to: " + reportFile.toAbsolutePath().normalize()); + } + + @Test + void generateNotificationPromptFromText() throws Exception { + if (!apiKeyConfigured) { + System.out.println("SKIP: generateNotificationPromptFromText - API key not configured"); + return; + } + List cases = loadTextCases("service-recovery-accuracy-test/test-cases-generate-from-text.json"); + System.out.println("== generateNotificationPromptFromText =="); + boolean first = true; + for (TextCase tc : cases) { + if (!first) System.out.println("---"); + first = false; + Map entry = new LinkedHashMap<>(); + entry.put("name", tc.name); + entry.put("input", Map.of("text", tc.text)); + + try { + MetadataContent result = client.generateNotificationPromptFromText(tc.text, templateUri); + entry.put("output", metadataOutput(result)); + System.out.println(" " + tc.name + ": " + compact(result.promptText())); + } catch (Exception e) { + entry.put("output", Map.of("error", e.getClass().getName() + ": " + e.getMessage())); + System.out.println(" " + tc.name + ": ERROR - " + e.getMessage()); + } + fromTextResults.add(entry); + } + } + + @Test + void generateNotificationPromptFromDataWithSchema() throws Exception { + if (!apiKeyConfigured) { + System.out.println("SKIP: generateNotificationPromptFromDataWithSchema - API key not configured"); + return; + } + List cases = loadDataCases("service-recovery-accuracy-test/test-cases-generate-from-data.json"); + System.out.println("== generateNotificationPromptFromDataWithSchema =="); + boolean first = true; + for (DataCase dc : cases) { + if (!first) System.out.println("---"); + first = false; + Map entry = new LinkedHashMap<>(); + entry.put("name", dc.name); + entry.put("input", Map.of("data", dc.data)); + + try { + MetadataContent result = client.generateNotificationPromptFromDataWithSchema(dc.data, dataSchema, templateUri); + entry.put("output", metadataOutput(result)); + System.out.println(" " + dc.name + ": " + compact(result.promptText())); + } catch (Exception e) { + entry.put("output", Map.of("error", e.getClass().getName() + ": " + e.getMessage())); + System.out.println(" " + dc.name + ": ERROR - " + e.getMessage()); + } + fromDataResults.add(entry); + } + } + + private static Map validate(String promptText) { + try { + FilledParamData result = server.validateNotificationPromptAndDataFilling(promptText, validationSchema, templateUri); + Map output = new LinkedHashMap<>(); + output.put("semantic_verdict", true); + output.put("params", result.data()); + return output; + } catch (ContentValidationException e) { + Map output = new LinkedHashMap<>(); + output.put("semantic_verdict", false); + output.put("code", e.getCode()); + output.put("message", e.getMessage()); + if (!e.errors().isEmpty()) { + output.put("errors", e.errors().stream() + .map(err -> Map.of("slot_name", err.slotName(), "code", err.code(), "message", err.message())) + .toList()); + } + return output; + } catch (Exception e) { + return Map.of("error", e.getClass().getName() + ": " + e.getMessage()); + } + } + + @Test + void validateNotificationPromptAndDataFilling() throws Exception { + if (!apiKeyConfigured) { + System.out.println("SKIP: validateNotificationPromptAndDataFilling - API key not configured"); + return; + } + List cases = loadValidateCases("service-recovery-accuracy-test/test-cases-validate-and-fill.json"); + System.out.println("== validateNotificationPromptAndDataFilling =="); + boolean first = true; + for (ValidateCase vc : cases) { + if (!first) System.out.println("---"); + first = false; + Map entry = new LinkedHashMap<>(); + entry.put("name", vc.name); + entry.put("input", Map.of("promptText", vc.promptText)); + Map output = validate(vc.promptText); + entry.put("output", output); + System.out.println(" " + vc.name + ": " + MAPPER.writeValueAsString(output)); + validateResults.add(entry); + } + } + + private static Map metadataOutput(MetadataContent result) { + Map output = new LinkedHashMap<>(); + output.put("templateUri", result.templateUri()); + output.put("extensionUri", result.extensionUri()); + output.put("promptText", result.promptText()); + return output; + } + + private static String compact(String text) { + if (text == null || text.isEmpty()) { + return text; + } + return text.replaceAll("(?m)^[ \t]*\\r?\\n", ""); + } + + private static Path resolveEnvFile(String resourcePath) throws IOException, URISyntaxException { + URL url = NotificationTAccuracyTest.class.getClassLoader().getResource(resourcePath); + Objects.requireNonNull(url, "Resource not found: " + resourcePath); + Path envPath = Path.of(url.toURI()); + Path projectRoot = findProjectRoot(envPath.getParent()); + + String content = Files.readString(envPath, StandardCharsets.UTF_8); + String resolvedContent = resolveRootDir(content, projectRoot); + + Path tempFile = Files.createTempFile("notification-t-test-", ".env"); + Files.writeString(tempFile, resolvedContent, StandardCharsets.UTF_8); + tempFile.toFile().deleteOnExit(); + return tempFile; + } + + private static Path findProjectRoot(Path start) { + Path dir = start; + while (dir != null) { + if (Files.isDirectory(dir.resolve("a2a-t-resources"))) { + return dir; + } + dir = dir.getParent(); + } + throw new IllegalStateException("Project root (containing a2a-t-resources) not found above " + start); + } + + private static String resolveRootDir(String content, Path projectRoot) { + String prefix = "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR="; + Path promptRoot = projectRoot.resolve("a2a-t-resources/src/main/resources/prompt_resources").normalize(); + String absolutePath = promptRoot.toString(); + for (String line : content.split("\n")) { + String trimmed = line.strip(); + if (trimmed.startsWith(prefix)) { + return content.replace(trimmed, prefix + absolutePath); + } + } + return content; + } + + private static Map readEnvInfo(Path envPath) throws IOException { + Map info = new LinkedHashMap<>(); + for (String line : Files.readAllLines(envPath, StandardCharsets.UTF_8)) { + String trimmed = line.strip(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int eqIdx = trimmed.indexOf('='); + if (eqIdx > 0) { + String key = trimmed.substring(0, eqIdx).strip(); + String value = trimmed.substring(eqIdx + 1).strip(); + if (key.equals("A2AT_LLM_API_KEY")) { + value = value.isBlank() ? "" : "***"; + } + info.put(key, value); + } + } + return info; + } + + private static Map loadJsonResource(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private static List loadTextCases(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private static List loadDataCases(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private static List loadValidateCases(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private record TextCase(String name, String text) {} + private record DataCase(String name, Map data) {} + private record ValidateCase(String name, String promptText) {} +} \ No newline at end of file diff --git a/a2a-t-sample/src/test/resources/service-recovery-accuracy-test/client.env b/a2a-t-sample/src/test/resources/service-recovery-accuracy-test/client.env new file mode 100644 index 00000000..604a7c7e --- /dev/null +++ b/a2a-t-sample/src/test/resources/service-recovery-accuracy-test/client.env @@ -0,0 +1,8 @@ +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=local_file +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=../../../../../a2a-t-resources/src/main/resources/prompt_resources +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL= +A2AT_LLM_BASE_URL= +A2AT_LLM_API_KEY= +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory diff --git a/a2a-t-sample/src/test/resources/service-recovery-accuracy-test/test-cases-generate-from-text.json b/a2a-t-sample/src/test/resources/service-recovery-accuracy-test/test-cases-generate-from-text.json new file mode 100644 index 00000000..5bb9283f --- /dev/null +++ b/a2a-t-sample/src/test/resources/service-recovery-accuracy-test/test-cases-generate-from-text.json @@ -0,0 +1,42 @@ +[ + { + "name": "case1-订阅条件有-上报格式标准完整", + "text": "我想订阅业务抢通事件,订阅条件为子网名称为珠江新城子网。上报通知数据格式如下:\n1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选)\n2. 投诉诊断任务流水号(必选)\n3. OSS侧事件流水号(必选)\n4. 接入端口名称(必选)\n5. 是否已授权OMC自动抢通,取值范围:是、否(必选)\n6. 业务抢通方案名称(必选)\n7. 业务抢通方案详情(必选)\n8. 业务抢通方案执行结束时间(可选)\n9. 业务抢通方案执行结果,取值范围:成功、失败(可选)\n10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)" + }, + { + "name": "case2-订阅条件有-上报格式无内容", + "text": "我想订阅业务抢通事件,订阅条件为子网名称为珠江新城子网。" + }, + { + "name": "case3-订阅条件无-上报格式标准完整", + "text": "我想订阅业务抢通事件。上报通知数据格式如下:\n1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选)\n2. 投诉诊断任务流水号(必选)\n3. OSS侧事件流水号(必选)\n4. 接入端口名称(必选)\n5. 是否已授权OMC自动抢通,取值范围:是、否(必选)\n6. 业务抢通方案名称(必选)\n7. 业务抢通方案详情(必选)\n8. 业务抢通方案执行结束时间(可选)\n9. 业务抢通方案执行结果,取值范围:成功、失败(可选)\n10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)" + }, + { + "name": "case4-订阅条件无-上报格式无内容", + "text": "我想订阅业务抢通事件。" + }, + { + "name": "case5-订阅条件有-上报格式精简", + "text": "我想订阅业务抢通事件,订阅条件为子网名称为珠江新城子网。上报数据需要:业务抢通方案执行状态、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通、业务抢通方案名称、业务抢通方案详情。" + }, + { + "name": "case6-订阅条件无-上报格式精简", + "text": "订阅业务抢通事件,上报数据包含:业务抢通方案执行状态、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称。" + }, + { + "name": "case7-订阅条件有-上报格式纯废话", + "text": "我想订阅业务抢通事件,条件就子网名称为珠江新城子网。上报的数据格式嘛,就是那种什么状态啊、流水号啊、端口啊乱七八糟的,你们看着办,越详细越好,拜托了谢谢!" + }, + { + "name": "case8-订阅条件无-上报格式废话含有效字段", + "text": "你好帮我订阅业务抢通事件,这个很重要请务必办妥!上报数据格式大概是这样:1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选);2. 投诉诊断任务流水号(必选);3. OSS侧事件流水号(必选);4. 接入端口名称(必选);5. 是否已授权OMC自动抢通,取值范围:是、否(必选);6. 业务抢通方案名称(必选);7. 业务抢通方案详情(必选);8. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因(可选)。就这些,麻烦都保留,一个都别漏,谢谢!" + }, + { + "name": "case9-订阅条件废话-上报格式标准完整", + "text": "帮忙订阅一下业务抢通事件哈,就是那种子网名称为珠江新城子网的,其他的你们随便,没关系别太较真。上报通知数据格式如下:\n1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选)\n2. 投诉诊断任务流水号(必选)\n3. OSS侧事件流水号(必选)\n4. 接入端口名称(必选)\n5. 是否已授权OMC自动抢通,取值范围:是、否(必选)\n6. 业务抢通方案名称(必选)\n7. 业务抢通方案详情(必选)\n8. 业务抢通方案执行结束时间(可选)\n9. 业务抢通方案执行结果,取值范围:成功、失败(可选)\n10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)" + }, + { + "name": "case10-订阅条件详细-上报格式完整含样例", + "text": "我想订阅业务抢通事件,订阅范围为珠江新城子网,覆盖其下所有接入端口与隧道资源。上报通知数据格式如下:\n1. 业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束(必选)\n2. 投诉诊断任务流水号;举例:9de168c0-6179-4778-8b72-4279582c0a3f(必选)\n3. OSS侧事件流水号;举例:event-id-202606250128(必选)\n4. 接入端口名称;举例:P781-珠江新城-PTN7900-23-TPA1EG24-17(必选)\n5. 是否已授权OMC自动抢通,取值范围:是、否;举例:是(必选)\n6. 业务抢通方案名称;举例:隧道调优(必选)\n7. 业务抢通方案详情(必选)\n8. 业务抢通方案执行结束时间;样例:2026-05-11T08:21:46Z(可选)\n9. 业务抢通方案执行结果,取值范围:成功、失败;举例:成功(可选)\n10. 业务抢通方案执行失败原因;业务抢通方案执行结果为“失败”时,需要填写此信息;举例:调优后隧道链路C已被占用,无法执行隧道调优(可选)" + } +] From d30b0a70fa380ee16fa71cb71632ce76a1644715 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sun, 23 Aug 2026 07:13:05 +0800 Subject: [PATCH 139/295] docs(negotiation): align context javadoc with the negotiationContext metadata entry Commit 41d0247 dropped the negotiation context section from the rendered message, but sixteen from-text generation javadocs still described the context parameter as injected into the rendered message; they now state that it travels in the negotiationContext metadata entry. The A2ATClient validate-pipeline description no longer claims the rule gate recognizes a context section - it validates the metadata-carried context (UUID shape and round budget) - and the six stale three-argument @link references to validateProposePromptAndDataFilling left over from the metadata refactor are shortened to the unambiguous parameterless form. Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 23 ++++++++++--------- .../generation/NegotiationContentService.java | 8 +++---- .../NegotiationGenerationOrchestrator.java | 8 +++---- .../openan/a2at/sdk/server/A2ATServer.java | 14 +++++------ 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index f512e65b..4fc1d34d 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -331,7 +331,7 @@ public MetadataContent generateNegotiationAbortPromptFromData( * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. * * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment * must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI @@ -360,7 +360,7 @@ public MetadataContent generateNegotiationProposePromptFromText( * conclusion must be {@code Accept}. * * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI @@ -389,7 +389,7 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * conclusion must be {@code Reject}. * * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI @@ -419,7 +419,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. * * @param text free-text input stating the termination reason - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context is null @@ -497,10 +497,11 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa /** * Validates a propose-phase negotiation message and extracts its parameters. * - *

      The pipeline first runs the deterministic rule gate (recognition of the negotiation context section and its - * strong constraints) before any LLM call, then performs one LLM semantic validation call that also extracts the - * parameters, and finally merges the parameters with the context parameters taking precedence. The semantic step is - * retried up to the configured attempt limit on the retryable LLM infrastructure failure code. + *

      The pipeline checks the template URI before any LLM call, runs the deterministic rule gate on the negotiation + * context carried in the {@code negotiationContext} metadata entry, then performs one LLM semantic validation call + * that also extracts the parameters, and finally merges the parameters with the context parameters taking + * precedence. The semantic step is retried up to the configured attempt limit on the retryable LLM infrastructure + * failure code. * * @param prompt rendered negotiation message text to validate * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is @@ -530,7 +531,7 @@ public FilledParamData validateProposePromptAndDataFilling( /** * Validates an accept-phase negotiation message and extracts its parameters. * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must * satisfy the accept-phase semantic constraints. * @@ -562,7 +563,7 @@ public FilledParamData validateAcceptPromptAndDataFilling( /** * Validates a reject-phase negotiation message and extracts its parameters. * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must * satisfy the reject-phase semantic constraints. * @@ -594,7 +595,7 @@ public FilledParamData validateRejectPromptAndDataFilling( /** * Validates an abort negotiation message and extracts its parameters. * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the * abort-phase semantic constraints. * diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index 0258edd2..1ba248f5 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -160,7 +160,7 @@ public MetadataContent generateAbortFromData( * Generates a propose-phase negotiation message from free text through one LLM content-extraction step. * * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI whose phase segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null @@ -179,7 +179,7 @@ public MetadataContent generateProposeFromText( * Generates an accept-phase negotiation message from free text through one LLM content-extraction step. * * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null @@ -198,7 +198,7 @@ public MetadataContent generateAcceptFromText( * Generates a reject-phase negotiation message from free text through one LLM content-extraction step. * * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI whose phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null @@ -217,7 +217,7 @@ public MetadataContent generateRejectFromText( * Generates an abort negotiation message from free text through one LLM content-extraction step. * * @param text free-text input stating the termination reason - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 3def16f0..87172746 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -186,7 +186,7 @@ public MetadataContent generateAbortFromData( * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. * * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment * must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI @@ -211,7 +211,7 @@ public MetadataContent generateProposeFromText( * conclusion must be {@code Accept}. * * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI @@ -236,7 +236,7 @@ public MetadataContent generateAcceptFromText( * conclusion must be {@code Reject}. * * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI @@ -262,7 +262,7 @@ public MetadataContent generateRejectFromText( * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. * * @param text free-text input stating the termination reason - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 0aa7786c..a11cdc4a 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -213,7 +213,7 @@ public MetadataContent generateNegotiationAbortPromptFromData( * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. * * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment * must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI @@ -242,7 +242,7 @@ public MetadataContent generateNegotiationProposePromptFromText( * conclusion must be {@code Accept}. * * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its * phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI @@ -271,7 +271,7 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * conclusion must be {@code Reject}. * * @param text free-text input describing the message content - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its * phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI @@ -299,7 +299,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( * deterministically like the from-data variant. * * @param text free-text input stating the termination reason - * @param context negotiation context injected into the rendered message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null @@ -409,7 +409,7 @@ public FilledParamData validateProposePromptAndDataFilling( /** * Validates an accept-phase negotiation message and extracts its parameters. * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must * satisfy the accept-phase semantic constraints. * @@ -441,7 +441,7 @@ public FilledParamData validateAcceptPromptAndDataFilling( /** * Validates a reject-phase negotiation message and extracts its parameters. * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must * satisfy the reject-phase semantic constraints. * @@ -473,7 +473,7 @@ public FilledParamData validateRejectPromptAndDataFilling( /** * Validates an abort negotiation message and extracts its parameters. * - *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, Map, TemplateUri)} with the expected + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the * abort-phase semantic constraints. * From 6b5116c5bedc5ea73472cededb60e3a9820a1f1a Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sun, 23 Aug 2026 10:37:57 +0800 Subject: [PATCH 140/295] fix(lint): align linter canonical name with en-US template heading The en-US template heading is 'Network Operation Authorization Policy List', not 'Dynamic Network Operation Authorization Policy List'. Update the linter canonical set and alias map to match the actual template. Signed-off-by: w00609601 --- tools/template_lint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/template_lint.py b/tools/template_lint.py index d734d7cd..99fd711d 100644 --- a/tools/template_lint.py +++ b/tools/template_lint.py @@ -14,7 +14,7 @@ SLOT = re.compile(r"{{\s*([^{}\s]+)\s*}}") TASK = {"Task Description", "Task Type", "Task Target", "Task Object", "Task Context", "Constraints", "Expected Output", "Operation Type"} NOTIFICATION = {"Subscription Description", "Notification Topic", "Subscribe Condition", "Notification Data Format", "Expected Output"} -AUTHORIZATION = {"Authorization Policy Operation Type", "Authorization Policy Operation Description", "Dynamic Network Operation Authorization Policy List", "Expected Output"} +AUTHORIZATION = {"Authorization Policy Operation Type", "Authorization Policy Operation Description", "Network Operation Authorization Policy List", "Expected Output"} ALIASES = { "任务描述": "Task Description", "任务类型": "Task Type", "任务目标": "Task Target", "任务对象": "Task Object", "目标对象": "Task Object", "任务上下文": "Task Context", "约束条件": "Constraints", "预期输出": "Expected Output", @@ -23,7 +23,7 @@ "通知数据格式": "Notification Data Format", "上报通知数据格式": "Notification Data Format", "授权策略的操作类型": "Authorization Policy Operation Type", "授权策略的操作描述": "Authorization Policy Operation Description", - "动网操作的授权策略列表": "Dynamic Network Operation Authorization Policy List", + "动网操作的授权策略列表": "Network Operation Authorization Policy List", } NEGOTIATION_TYPE_SEGMENTS = ("information-negotiation", "target-negotiation", "feasibility-negotiation") From ea370eeccfe8425eae2234f2796b3284e1f4646b Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sun, 23 Aug 2026 10:38:11 +0800 Subject: [PATCH 141/295] feat: add Authorization-T slot schemas with client and server tests Bundle zh-CN and en-US slot schemas for Authorization-T authorization-policy-management/v1 under a2a-t-resources. Update A2ATClient Javadoc to reflect that slot schemas are now bundled, removing the experimental caveat. Add client tests for entry-point metadata and slot schema resource loading, plus a server-side AuthorizationContentValidator test. Signed-off-by: w00609601 --- .../openan/a2at/sdk/client/A2ATClient.java | 10 +- .../AuthorizationEntryPointMetadataTest.java | 124 ++++++++++++++++++ .../AuthorizationSlotSchemaResourceTest.java | 98 ++++++++++++++ .../v1/en-US/slot.json | 24 ++++ .../v1/zh-CN/slot.json | 24 ++++ .../AuthorizationContentValidatorTest.java | 96 ++++++++++++++ 6 files changed, 370 insertions(+), 6 deletions(-) create mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationEntryPointMetadataTest.java create mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/AuthorizationSlotSchemaResourceTest.java create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/en-US/slot.json create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/zh-CN/slot.json create mode 100644 a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/AuthorizationContentValidatorTest.java diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index f512e65b..1ee37600 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -112,9 +112,8 @@ public MetadataContent generateTaskPromptFromDataWithSchema( * Generates an authorization prompt with metadata from natural-language input using the template identified by the * template URI, bypassing scenario recognition. * - *

      Experimental: Authorization-T template resources (such as {@code authz-policy-mgr}) are bundled and - * discovered automatically, but Authorization-T slot schemas are not yet bundled, so template-driven slot - * extraction fails until Authorization-T slot resources are added or provided through the local resource root. + *

      Authorization-T slot schemas are bundled with a2a-t-resources, so the entry point works out of the box with + * classpath resource source. * * @param text natural-language authorization input * @param templateUri template URI identifying the target template @@ -134,9 +133,8 @@ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull * Generates an authorization prompt with metadata from structured input and a data schema using the template * identified by the template URI, bypassing scenario recognition. * - *

      Experimental: Authorization-T template resources (such as {@code authz-policy-mgr}) are bundled and - * discovered automatically, but Authorization-T slot schemas are not yet bundled, so template-driven slot - * extraction fails until Authorization-T slot resources are added or provided through the local resource root. + *

      Authorization-T slot schemas are bundled with a2a-t-resources, so the entry point works out of the box with + * classpath resource source. * * @param data structured authorization input as a string-to-object map * @param schema data schema map describing the meaning of each input field; must not be null or empty diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationEntryPointMetadataTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationEntryPointMetadataTest.java new file mode 100644 index 00000000..284f14a5 --- /dev/null +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationEntryPointMetadataTest.java @@ -0,0 +1,124 @@ +package net.openan.a2at.sdk.client.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AuthorizationEntryPointMetadataTest { + + private static final String TEST_MOCK_PROVIDER = "test-authz-mock"; + + @TempDir + Path tempDir; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + @Test + void should_generateAuthPromptFromText_ProducesMetadataContentWithAuthorizationExtensionUri() + throws IOException { + Path envFile = writeMinimalClasspathClientEnv(tempDir, TEST_MOCK_PROVIDER); + A2ATClient client = new A2ATClient(envFile); + + MetadataContent result = client.generateAuthPromptFromText( + "新增两条动网操作授权策略:业务投诉诊断/业务抢通/隧道调优/限期生效(2026-06-01~2030-06-18);载波调度/业务抢通/载波调度/永久生效", + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + + assertNotNull(result); + assertEquals( + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT.uri(), + result.templateUri()); + assertNotNull(result.promptText()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, result.extensionUri()); + } + + @Test + void should_generateAuthPromptFromDataWithSchema_ProducesMetadataContentWithAuthorizationExtensionUri() + throws IOException { + Path envFile = writeMinimalClasspathClientEnv(tempDir, TEST_MOCK_PROVIDER); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("操作类型", "新增授权策略"); + Map schema = Map.of("操作类型", "授权策略操作类型,取值范围:新增/修改/删除/查询授权策略"); + + MetadataContent result = client.generateAuthPromptFromDataWithSchema( + data, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + + assertNotNull(result); + assertEquals( + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT.uri(), + result.templateUri()); + assertNotNull(result.promptText()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, result.extensionUri()); + } + + private static Path writeMinimalClasspathClientEnv(Path tempDir, String provider) throws IOException { + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(provider)); + return envFile; + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(buildResponse(jsonSchema), config.model(), Map.of(), Map.of()); + } + + private static String buildResponse(Map jsonSchema) { + Object slotNames = jsonSchema.get("slotNames"); + StringBuilder slots = new StringBuilder("{"); + if (slotNames instanceof List names) { + for (int i = 0; i < names.size(); i++) { + if (i > 0) { + slots.append(","); + } + slots.append("\"").append(names.get(i)).append("\":\"placeholder\""); + } + } + slots.append("}"); + return "{\"slots\":" + slots + ",\"slot_errors\":[]}"; + } + } +} \ No newline at end of file diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/AuthorizationSlotSchemaResourceTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/AuthorizationSlotSchemaResourceTest.java new file mode 100644 index 00000000..7e0dee89 --- /dev/null +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/AuthorizationSlotSchemaResourceTest.java @@ -0,0 +1,98 @@ +package net.openan.a2at.sdk.client.prompt.loader; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; +import org.junit.jupiter.api.Test; + +class AuthorizationSlotSchemaResourceTest { + + @Test + void should_loadAuthorizationSlotSchemaFromClasspath_ForZhCN() { + DefaultClasspathClientSlotSchemaLoader loader = + new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); + + PromptSlotSchema schema = loader.loadSlotSchema("authorization-policy-management", "zh-CN"); + + assertEquals("authorization-policy-management", schema.scenarioCode()); + assertEquals(2, schema.slotDefinitions().size()); + assertTrue(schema.slotDefinitions().get(0).required()); + assertEquals("授权策略的操作类型", schema.slotDefinitions().get(0).name()); + } + + @Test + void should_loadAuthorizationSlotSchema_ForEnUS() { + DefaultClasspathClientSlotSchemaLoader loader = + new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); + + PromptSlotSchema schema = loader.loadSlotSchema("authorization-policy-management", "en-US"); + + assertEquals("authorization-policy-management", schema.scenarioCode()); + assertEquals(2, schema.slotDefinitions().size()); + assertTrue(schema.slotDefinitions().get(0).required()); + assertEquals("authorization_policy_operation_type", schema.slotDefinitions().get(0).name()); + } + + @Test + void should_haveOperationTypeOnlyInRequired() { + DefaultClasspathClientSlotSchemaLoader loader = + new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); + + PromptSlotSchema schema = loader.loadSlotSchema("authorization-policy-management", "zh-CN"); + + long requiredCount = schema.slotDefinitions().stream() + .filter(PromptSlotDefinition::required) + .count(); + assertEquals(1, requiredCount); + assertEquals("授权策略的操作类型", schema.slotDefinitions().get(0).name()); + assertFalse(schema.slotDefinitions().get(1).required()); + } + + @Test + void should_havePropertyNamesMatchingTemplatePlaceholders() { + DefaultClasspathClientSlotSchemaLoader loader = + new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); + + PromptSlotSchema zhSchema = loader.loadSlotSchema("authorization-policy-management", "zh-CN"); + PromptSlotSchema enSchema = loader.loadSlotSchema("authorization-policy-management", "en-US"); + + assertEquals("授权策略的操作类型", zhSchema.slotDefinitions().get(0).name()); + assertEquals("动网操作的授权策略列表", zhSchema.slotDefinitions().get(1).name()); + assertEquals("authorization_policy_operation_type", enSchema.slotDefinitions().get(0).name()); + assertEquals("network_operation_authorization_policy_list", enSchema.slotDefinitions().get(1).name()); + } + + @Test + void should_haveSchemaStructureParity() { + DefaultClasspathClientSlotSchemaLoader loader = + new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); + + PromptSlotSchema authSchema = loader.loadSlotSchema("authorization-policy-management", "zh-CN"); + PromptSlotSchema energySchema = loader.loadSlotSchema("energy-saving", "zh-CN"); + + assertFalse(authSchema.slotDefinitions().isEmpty()); + assertFalse(energySchema.slotDefinitions().isEmpty()); + + for (PromptSlotDefinition def : authSchema.slotDefinitions()) { + assertEquals("string", def.jsonType()); + assertFalse(def.description().isBlank()); + assertFalse(def.valueConstraint().isBlank()); + } + } + + @Test + void should_throwResourceNotFoundException_WhenSlotSchemaMissing() { + DefaultClasspathClientSlotSchemaLoader loader = + new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); + + assertThrows( + ResourceNotFoundException.class, + () -> loader.loadSlotSchema("nonexistent-scenario", "zh-CN")); + } +} \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/en-US/slot.json new file mode 100644 index 00000000..fb9beb1f --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/en-US/slot.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "authorization_policy_operation_type": { + "type": "string", + "description": "Provide the operation type of the authorization policy. Allowed values: add authorization policy, modify authorization policy, delete authorization policy, query authorization policy.", + "examples": [ + "add authorization policy" + ], + "x-a2at-value-constraint": "Allowed values: add authorization policy, modify authorization policy, delete authorization policy, query authorization policy" + }, + "network_operation_authorization_policy_list": { + "type": "string", + "description": "Provide the network operation authorization policy list. Each policy contains: policy identifier, business scenario, handling type, operation name, validity period, creation time, last modification time. When adding an authorization policy, the required parameters include: business scenario, handling type, operation name, validity period.", + "examples": [ + "service complaint diagnosis/service recovery/tunnel optimization/2026-06-01~2030-06-18; carrier scheduling/service recovery/carrier scheduling/permanently valid" + ], + "x-a2at-value-constraint": "List text form. Each item contains: policy identifier (unique identifier), business scenario (e.g. service complaint diagnosis), handling type (e.g. service recovery), operation name (e.g. tunnel optimization), validity period (date range or permanently valid), creation time, last modification time" + } + }, + "required": ["authorization_policy_operation_type"] +} \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/zh-CN/slot.json new file mode 100644 index 00000000..16e936b9 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/zh-CN/slot.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "授权策略的操作类型": { + "type": "string", + "description": "请提供授权策略的操作类型,取值范围:新增授权策略、修改授权策略、删除授权策略、查询授权策略。", + "examples": [ + "新增授权策略" + ], + "x-a2at-value-constraint": "取值范围:新增授权策略、修改授权策略、删除授权策略、查询授权策略" + }, + "动网操作的授权策略列表": { + "type": "string", + "description": "请提供动网操作的授权策略列表,每条策略包含:策略标识、业务场景、处置类型、操作名称、有效期、创建时间、最后修改时间。创建授权策略时,必填参数包括:业务场景、处置类型、操作名称、有效期。", + "examples": [ + "业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18;载波调度/业务抢通/载波调度/永久生效" + ], + "x-a2at-value-constraint": "列表文本形态,每条包含:策略标识(唯一标识)、业务场景(如 业务投诉诊断)、处置类型(如 业务抢通)、操作名称(如 隧道调优)、有效期(日期区间或永久生效)、创建时间、最后修改时间" + } + }, + "required": ["授权策略的操作类型"] +} \ No newline at end of file diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/AuthorizationContentValidatorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/AuthorizationContentValidatorTest.java new file mode 100644 index 00000000..d553717b --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/AuthorizationContentValidatorTest.java @@ -0,0 +1,96 @@ +package net.openan.a2at.sdk.server.validation; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AuthorizationContentValidatorTest { + + private static final String TEST_MOCK_PROVIDER = "test-auth-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + @TempDir + Path tempDir; + + @Test + void should_validateAuthPromptAndDataFilling_WithAuthorizationTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + Map schemaMap = + Map.of("properties", Map.of("授权策略的操作类型", Map.of("type", "string")), "required", List.of("授权策略的操作类型")); + String prompt = "新增两条动网操作授权策略:业务投诉诊断/业务抢通/隧道调优/限期生效"; + + FilledParamData result = assertDoesNotThrow(() -> server.validateAuthPromptAndDataFilling( + prompt, schemaMap, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT)); + + assertNotNull(result); + assertEquals(Map.of("授权策略的操作类型", "新增授权策略"), result.data()); + } + + private Path writeEnv() throws IOException { + Path envFile = tempDir.resolve("server.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-auth-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """); + return envFile; + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse( + """ + { + "semantic_verdict": true, + "errors": [], + "params": {"授权策略的操作类型": "新增授权策略"} + } + """, + config.model(), + Map.of(), + Map.of()); + } + } +} From 8f84e26c2eebe6f80f9b789d791599408bbc4180 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sun, 23 Aug 2026 10:38:36 +0800 Subject: [PATCH 142/295] feat(sample): add Authorization-T demo with outcome-based scenario validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a2a-t-sample authz_policy demo module that exercises the full client-generate → server-validate pipeline for Authorization-T. Scenario configuration uses precise SDK error codes (success, slot_validation_error, validation_semantic_rejected) instead of a binary pass/reject verdict, and supports per-slot error expectation matching on (slotName, code). The JSON report outputs expected_outcome/actual_outcome/match, expected_slot_errors/actual_slot_errors (with slot_name, code, message), expected_prompt_text/prompt_text, and expected_params/actual_params for external LLM-based accuracy comparison. Signed-off-by: w00609601 --- .gitignore | 1 + a2a-t-sample/README.zh-CN.md | 25 ++ a2a-t-sample/pom.xml | 4 + .../authz_policy/AuthzPromptGenerator.java | 9 + .../authz_policy/AuthzPromptValidator.java | 11 + .../sample/authz_policy/AuthzSampleMain.java | 258 +++++++++++++ .../sample/authz_policy/AuthzScenario.java | 101 ++++++ .../authz_policy/AuthzScenarioLoader.java | 113 ++++++ .../authz_policy/AuthzScenarioRunner.java | 100 +++++ .../sample/authz_policy/package-info.java | 21 ++ .../resources/sample/authz-policy/authz.env | 10 + .../sample/authz-policy/scenarios.json | 51 +++ .../authz_policy/AuthzSampleMainTest.java | 197 ++++++++++ .../authz_policy/AuthzScenarioLoaderTest.java | 70 ++++ .../authz_policy/AuthzScenarioRunnerTest.java | 342 ++++++++++++++++++ .../authz_policy/AuthzScenarioTest.java | 164 +++++++++ .../authz-policy/test/invalid-scenarios.json | 1 + .../sample/authz-policy/test/malformed.json | 1 + .../test/scenario-label-non-string.json | 1 + .../test/scenarios-as-string.json | 1 + 20 files changed, 1481 insertions(+) create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptGenerator.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenario.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoader.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/package-info.java create mode 100644 a2a-t-sample/src/main/resources/sample/authz-policy/authz.env create mode 100644 a2a-t-sample/src/main/resources/sample/authz-policy/scenarios.json create mode 100644 a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java create mode 100644 a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java create mode 100644 a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java create mode 100644 a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioTest.java create mode 100644 a2a-t-sample/src/test/resources/sample/authz-policy/test/invalid-scenarios.json create mode 100644 a2a-t-sample/src/test/resources/sample/authz-policy/test/malformed.json create mode 100644 a2a-t-sample/src/test/resources/sample/authz-policy/test/scenario-label-non-string.json create mode 100644 a2a-t-sample/src/test/resources/sample/authz-policy/test/scenarios-as-string.json diff --git a/.gitignore b/.gitignore index 5b8e75f2..09aacbc1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ target/ *.env # Sample env templates carry no secrets (empty API key) and must ship with the sources. !a2a-t-sample/src/main/resources/**/negotiation.env +!a2a-t-sample/src/main/resources/**/authz.env reference/ findings.md progress.md diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 495c8dc8..0c0059be 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -15,6 +15,7 @@ - 协商端到端样例(4报文):`net.openan.a2at.sample.negotiation.NegotiationDemoApp` - 结构化数据协商样例(fromData 3x3):`net.openan.a2at.sample.negotiation.fromdata.FromDataNegotiationSample` - 自然语言协商样例(fromText 3x3):`net.openan.a2at.sample.negotiation.fromtext.FromTextNegotiationSample` +- 授权策略样例(Authorization-T):`net.openan.a2at.sample.authz_policy.AuthzSampleMain` ## 模块内资源 @@ -120,6 +121,30 @@ java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env 复用 `shared/NegotiationSampleSupport` 公共辅助(SessionId、模板 URI 常量、summary),fromData 和 fromText 差异仅在输入构造(record vs 自然语言文本)。 +## 授权策略(Authorization-T)演示 Demo + +授权策略 Demo 是单进程直调 SDK 示例:客户端生成 Authorization-T prompt → 服务端校验合规性并提取参数。覆盖 3 个预置场景: + +| 场景 | 入口 | 说明 | +|---|---|---| +| `add-from-text` | 自然语言 | 新增两条动网操作授权策略 | +| `add-from-data` | 结构化数据 | 结构化输入新增授权策略 | +| `invalid-request` | 自然语言 | 不在四种操作类型之内,预期拒绝 | + +入口类:`net.openan.a2at.sample.authz_policy.AuthzSampleMain` + +捆绑环境模板:`sample/authz-policy/authz.env`(需配置真实 LLM key:`A2AT_LLM_PROVIDER` / `A2AT_LLM_MODEL` / `A2AT_LLM_API_KEY`) + +场景清单:`sample/authz-policy/scenarios.json`(增删场景零代码) + +启动命令: + +```bash +java @a2a-t-sample/target/authz.javaargs.txt [env-path] +``` + +退出码约定:`0` 表示全部通过;非零表示存在 `FAIL` 或 `ERROR`。 + ## 客户端启动 1. 修改仓库根目录下的 `client.env`,补充可用的 `A2AT_LLM_API_KEY` diff --git a/a2a-t-sample/pom.xml b/a2a-t-sample/pom.xml index 12b9ff99..06dcf8ab 100644 --- a/a2a-t-sample/pom.xml +++ b/a2a-t-sample/pom.xml @@ -162,6 +162,10 @@ net.openan.a2at.sample.service_recovery.server.ServerSampleMain -cp ${sample.jar.path}${path.separator}${sample.runtime.classpath} net.openan.a2at.sample.service_recovery.client.ClientSampleMain + + -cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.authz_policy.AuthzSampleMain diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptGenerator.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptGenerator.java new file mode 100644 index 00000000..89cd643b --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptGenerator.java @@ -0,0 +1,9 @@ +package net.openan.a2at.sample.authz_policy; + +import net.openan.a2at.sdk.core.model.MetadataContent; + +@FunctionalInterface +public interface AuthzPromptGenerator { + + MetadataContent generate(AuthzScenario scenario); +} \ No newline at end of file diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java new file mode 100644 index 00000000..55d72b6f --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java @@ -0,0 +1,11 @@ +package net.openan.a2at.sample.authz_policy; + +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; + +@FunctionalInterface +public interface AuthzPromptValidator { + + FilledParamData validate(String prompt, Map schema, TemplateUri templateUri); +} \ No newline at end of file diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java new file mode 100644 index 00000000..cd8b71f1 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java @@ -0,0 +1,258 @@ +package net.openan.a2at.sample.authz_policy; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.server.A2ATServer; + +public final class AuthzSampleMain { + + private static final String DEFAULT_ENV_FILE = "authz.env"; + + private static final String BUNDLED_ENV_FILE = Path.of( + "a2a-t-sample", "src", "main", "resources", "sample", "authz-policy", "authz.env") + .toString(); + + private static final String SCENARIOS_RESOURCE = "sample/authz-policy/scenarios.json"; + + private static final String SLOT_SCHEMA_PATH_TEMPLATE = + "/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/%s/slot.json"; + + private static final List REQUIRED_LLM_KEYS = + List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY"); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private AuthzSampleMain() {} + + public static void main(String[] args) { + Path envPath = resolveEnvPath(args); + if (!hasRequiredLlmKeys(envPath)) { + System.err.println("Required LLM keys not configured in env file: " + envPath); + System.exit(1); + } + + A2ATClient client = new A2ATClient(envPath); + A2ATServer server = new A2ATServer(envPath); + + String language = A2ATConfig.load(envPath).prompt().language(); + Map slotSchemaMap = loadSlotSchemaMap(language); + + List scenarios = AuthzScenarioLoader.load(SCENARIOS_RESOURCE); + TemplateUri templateUri = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; + + AuthzPromptGenerator generator = scenario -> { + if (AuthzScenario.FROM_TEXT.equals(scenario.entry())) { + String text = (String) scenario.input().get("text"); + return client.generateAuthPromptFromText(text, templateUri); + } + @SuppressWarnings("unchecked") + Map data = (Map) scenario.input().get("data"); + @SuppressWarnings("unchecked") + Map schema = (Map) scenario.input().get("schema"); + return client.generateAuthPromptFromDataWithSchema(data, schema, templateUri); + }; + + AuthzPromptValidator validator = server::validateAuthPromptAndDataFilling; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + + List results = new ArrayList<>(); + List outcomes = new ArrayList<>(); + for (AuthzScenario scenario : scenarios) { + ScenarioOutcome outcome = runner.run(scenario, slotSchemaMap, templateUri); + printScenarioReport(scenario, outcome); + outcomes.add(outcome); + results.add(outcome.result()); + } + + Path reportPath = writeReport(scenarios, outcomes, Path.of("a2a-t-sample", "target")); + System.out.println("Report written to: " + reportPath.toAbsolutePath().normalize()); + System.out.println(); + printSummary(scenarios, results); + System.exit(exitCode(results)); + } + + static Path resolveEnvPath(String[] args) { + if (args.length > 0) { + return Path.of(args[0]); + } + Path cwdEnv = Path.of(DEFAULT_ENV_FILE); + if (Files.exists(cwdEnv) && hasRequiredLlmKeys(cwdEnv)) { + return cwdEnv; + } + return Path.of(BUNDLED_ENV_FILE); + } + + static boolean hasRequiredLlmKeys(Path envFile) { + Map entries = new HashMap<>(); + try { + for (String line : Files.readAllLines(envFile)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int separator = trimmed.indexOf('='); + if (separator <= 0) { + continue; + } + entries.put( + trimmed.substring(0, separator).trim(), + trimmed.substring(separator + 1).trim()); + } + } catch (IOException exception) { + return false; + } + for (String key : REQUIRED_LLM_KEYS) { + if (entries.getOrDefault(key, "").isBlank()) { + return false; + } + } + return true; + } + + static Map loadSlotSchemaMap(String language) { + String resourcePath = String.format(SLOT_SCHEMA_PATH_TEMPLATE, language); + try (InputStream stream = AuthzSampleMain.class.getResourceAsStream(resourcePath)) { + if (stream == null) { + throw new IllegalStateException("Slot schema resource not found: " + resourcePath); + } + return MAPPER.readValue(stream, new TypeReference>() {}); + } catch (IOException e) { + throw new IllegalStateException("Failed to load slot schema: " + resourcePath, e); + } + } + + static void printScenarioReport(AuthzScenario scenario, ScenarioOutcome outcome) { + System.out.println("--- Scenario: " + scenario.label() + " ---"); + System.out.println(" Entry: " + scenario.entry() + ", Expected Outcome: " + scenario.expected().outcome()); + MetadataContent metadata = outcome.metadata(); + if (metadata == null) { + System.out.println(" Prompt: <生成失败>"); + } else { + System.out.println(" Prompt: " + metadata.promptText()); + System.out.println(" TemplateUri: " + metadata.templateUri()); + System.out.println(" ExtensionUri: " + metadata.extensionUri()); + } + System.out.println(" Actual Outcome: " + outcome.result().outcome()); + System.out.println(" Match: " + outcome.result().match()); + FilledParamData filled = outcome.filled(); + if (filled == null) { + System.out.println(" Extracted Params: <未提取参数>"); + } else { + System.out.println(" Extracted Params: " + filled.data()); + } + if (!outcome.result().slotErrors().isEmpty()) { + System.out.println(" Slot Errors: " + outcome.result().slotErrors()); + } + if (outcome.result().error() != null) { + System.out.println(" Error: [" + outcome.result().error().getCode() + "] " + + outcome.result().error().getMessage()); + } + System.out.println(); + } + + static void printSummary(List scenarios, List results) { + long matchCount = results.stream().filter(ScenarioResult::match).count(); + long mismatchCount = results.size() - matchCount; + System.out.println("========== Summary =========="); + System.out.println(" Total: " + results.size() + ", Match: " + matchCount + ", Mismatch: " + mismatchCount); + System.out.println(" Exit Code: " + exitCode(results)); + System.out.println(); + } + + static int exitCode(List results) { + boolean allMatch = results.stream().allMatch(ScenarioResult::match); + return allMatch ? 0 : 1; + } + + static Path writeReport(List scenarios, List outcomes, Path reportDir) { + Map report = new LinkedHashMap<>(); + long matchCount = outcomes.stream().filter(o -> o.result().match()).count(); + long mismatchCount = outcomes.size() - matchCount; + report.put("summary", Map.of( + "total", scenarios.size(), + "match", matchCount, + "mismatch", mismatchCount)); + List> scenarioEntries = new ArrayList<>(); + for (int i = 0; i < scenarios.size(); i++) { + AuthzScenario scenario = scenarios.get(i); + ScenarioOutcome outcome = outcomes.get(i); + Map entry = new LinkedHashMap<>(); + entry.put("label", scenario.label()); + entry.put("entry", scenario.entry()); + entry.put("expected_outcome", scenario.expected().outcome()); + entry.put("actual_outcome", outcome.result().outcome()); + entry.put("match", outcome.result().match()); + entry.put("expected_slot_errors", formatExpectedSlotErrors(scenario.expected().slotErrors())); + entry.put("actual_slot_errors", formatActualSlotErrors(outcome.result().slotErrors())); + entry.put("expected_prompt_text", scenario.expected().promptText()); + entry.put("prompt_text", outcome.metadata() != null ? outcome.metadata().promptText() : null); + entry.put("expected_params", scenario.expected().filledParams()); + entry.put("actual_params", outcome.filled() != null ? outcome.filled().data() : null); + entry.put("error", outcome.result().error() != null + ? Map.of( + "code", outcome.result().error().getCode(), + "message", outcome.result().error().getMessage()) + : null); + scenarioEntries.add(entry); + } + report.put("scenarios", scenarioEntries); + try { + Files.createDirectories(reportDir); + Path reportFile = reportDir.resolve("authz-report.json"); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(reportFile.toFile(), report); + return reportFile; + } catch (IOException e) { + throw new IllegalStateException("Failed to write report to: " + reportDir, e); + } + } + + private static List> formatExpectedSlotErrors(List slotErrors) { + if (slotErrors == null || slotErrors.isEmpty()) { + return List.of(); + } + return slotErrors.stream() + .map(e -> { + Map m = new LinkedHashMap<>(); + m.put("slot_name", e.slotName()); + m.put("code", e.code()); + return m; + }) + .toList(); + } + + private static List> formatActualSlotErrors(List slotErrors) { + if (slotErrors == null || slotErrors.isEmpty()) { + return List.of(); + } + return slotErrors.stream() + .map(e -> { + Map m = new LinkedHashMap<>(); + m.put("slot_name", e.slotName()); + m.put("code", e.code()); + m.put("message", e.message()); + return m; + }) + .toList(); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenario.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenario.java new file mode 100644 index 00000000..679fb2e6 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenario.java @@ -0,0 +1,101 @@ +package net.openan.a2at.sample.authz_policy; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; + +/** + * Immutable configuration item for a single demo scenario. + * + * @param label human-readable scenario label + * @param entry client entry point ({@code from_text} or {@code from_data_with_schema}) + * @param input scenario input data + * @param expected expected outcome carrying the outcome code, expected filled params, expected prompt + * text and expected slot-level error details + * @since 2026-08 + */ +public record AuthzScenario(String label, String entry, Map input, AuthzExpected expected) { + + static final String FROM_TEXT = "from_text"; + static final String FROM_DATA_WITH_SCHEMA = "from_data_with_schema"; + static final String EXPECTED_SUCCESS = "success"; + + static final Set VALID_OUTCOMES = Set.of( + EXPECTED_SUCCESS, + A2ATErrorCodes.SLOT_VALIDATION_ERROR, + A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED, + A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, + A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, + A2ATErrorCodes.LLM_INVOCATION_FAILED, + A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, + A2ATErrorCodes.TEMPLATE_NOT_FOUND, + A2ATErrorCodes.RENDER_FAILED, + A2ATErrorCodes.PROMPT_RESOURCE_LOAD_ERROR); + + /** + * Expected outcome for a scenario. + * + * @param outcome expected outcome code ({@code success} or a specific {@code A2ATErrorCodes} value) + * @param filledParams expected slot values extracted by the server; may be {@code null} for error + * scenarios or when no param comparison is needed + * @param promptText expected rendered prompt text from the client SDK; may be {@code null} for + * error scenarios + * @param slotErrors expected per-slot error details; may be {@code null} or empty when only the + * outcome code matters + */ + public record AuthzExpected( + String outcome, + Map filledParams, + String promptText, + List slotErrors) {} + + /** + * Expected error detail for a single slot. + * + * @param slotName name of the slot expected to fail + * @param code machine-readable error code expected for this slot + */ + public record SlotErrorExpectation(String slotName, String code) {} + + /** + * Validates that the scenario configuration is well-formed. + * + * @param scenario the scenario to validate + * @throws IllegalArgumentException if the configuration is invalid + */ + public static void validate(AuthzScenario scenario) { + if (scenario == null) { + throw new IllegalArgumentException("scenario must not be null"); + } + if (scenario.input == null) { + throw new IllegalArgumentException("input must not be null"); + } + if (scenario.expected == null) { + throw new IllegalArgumentException("expected must not be null"); + } + String outcome = scenario.expected.outcome(); + if (outcome == null || !VALID_OUTCOMES.contains(outcome)) { + throw new IllegalArgumentException("Invalid expected outcome: " + outcome); + } + if (!FROM_TEXT.equals(scenario.entry) && !FROM_DATA_WITH_SCHEMA.equals(scenario.entry)) { + throw new IllegalArgumentException("Invalid entry: " + scenario.entry); + } + if (FROM_TEXT.equals(scenario.entry)) { + Object text = scenario.input.get("text"); + if (!(text instanceof String) || ((String) text).isBlank()) { + throw new IllegalArgumentException("from_text scenario requires a non-blank input.text"); + } + } + if (FROM_DATA_WITH_SCHEMA.equals(scenario.entry)) { + Object data = scenario.input.get("data"); + if (!(data instanceof Map) || ((Map) data).isEmpty()) { + throw new IllegalArgumentException("from_data_with_schema scenario requires a non-empty input.data"); + } + Object schema = scenario.input.get("schema"); + if (!(schema instanceof Map) || ((Map) schema).isEmpty()) { + throw new IllegalArgumentException("from_data_with_schema scenario requires a non-empty input.schema"); + } + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoader.java new file mode 100644 index 00000000..aaa433b1 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoader.java @@ -0,0 +1,113 @@ +package net.openan.a2at.sample.authz_policy; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public final class AuthzScenarioLoader { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private AuthzScenarioLoader() {} + + public static List load(String resourcePath) { + Map root; + try (InputStream stream = AuthzScenarioLoader.class.getClassLoader().getResourceAsStream(resourcePath)) { + if (stream == null) { + throw new IllegalStateException("Scenario resource not found: " + resourcePath); + } + root = MAPPER.readValue(stream, new TypeReference>() {}); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to parse scenario resource: " + resourcePath + " - " + e.getMessage(), e); + } + + Object rawScenariosObj = root.get("scenarios"); + if (rawScenariosObj == null) { + throw new IllegalStateException("Missing 'scenarios' key in: " + resourcePath); + } + if (!(rawScenariosObj instanceof List rawList)) { + throw new IllegalStateException("'scenarios' must be an array in: " + resourcePath); + } + + List scenarios = rawList.stream() + .map(item -> { + if (!(item instanceof Map raw)) { + throw new IllegalStateException("Scenario entry must be an object in: " + resourcePath); + } + Object labelObj = raw.get("label"); + if (!(labelObj instanceof String label)) { + throw new IllegalStateException("Scenario 'label' must be a string in: " + resourcePath); + } + Object entryObj = raw.get("entry"); + if (!(entryObj instanceof String entry)) { + throw new IllegalStateException("Scenario 'entry' must be a string in: " + resourcePath); + } + Object inputObj = raw.get("input"); + if (!(inputObj instanceof Map input)) { + throw new IllegalStateException("Scenario 'input' must be an object in: " + resourcePath); + } + @SuppressWarnings("unchecked") + Map inputMap = (Map) input; + Object expectedObj = raw.get("expected"); + if (!(expectedObj instanceof Map expectedRaw)) { + throw new IllegalStateException("Scenario 'expected' must be an object in: " + resourcePath); + } + Object outcomeObj = expectedRaw.get("outcome"); + if (!(outcomeObj instanceof String outcome)) { + throw new IllegalStateException( + "Scenario 'expected.outcome' must be a string in: " + resourcePath); + } + Map filledParams = null; + Object filledParamsObj = expectedRaw.get("filled_params"); + if (filledParamsObj instanceof Map filledRaw) { + @SuppressWarnings("unchecked") + Map filledMap = (Map) filledRaw; + filledParams = filledMap; + } + String promptText = null; + Object promptTextObj = expectedRaw.get("prompt_text"); + if (promptTextObj instanceof String pt) { + promptText = pt; + } + List slotErrors = parseSlotErrors(expectedRaw.get("slot_errors")); + AuthzScenario.AuthzExpected expected = + new AuthzScenario.AuthzExpected(outcome, filledParams, promptText, slotErrors); + return new AuthzScenario(label, entry, inputMap, expected); + }) + .toList(); + + for (AuthzScenario scenario : scenarios) { + try { + AuthzScenario.validate(scenario); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid scenario '" + scenario.label() + "': " + e.getMessage(), e); + } + } + + return scenarios; + } + + private static List parseSlotErrors(Object raw) { + if (!(raw instanceof List rawList)) { + return null; + } + List result = new ArrayList<>(); + for (Object item : rawList) { + if (!(item instanceof Map rawMap)) { + continue; + } + Object slotNameObj = rawMap.get("slot_name"); + Object codeObj = rawMap.get("code"); + if (!(slotNameObj instanceof String slotName) || !(codeObj instanceof String code)) { + continue; + } + result.add(new AuthzScenario.SlotErrorExpectation(slotName, code)); + } + return result; + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java new file mode 100644 index 00000000..e64b538c --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java @@ -0,0 +1,100 @@ +package net.openan.a2at.sample.authz_policy; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; + +public final class AuthzScenarioRunner { + + private final AuthzPromptGenerator generator; + private final AuthzPromptValidator validator; + + public AuthzScenarioRunner(AuthzPromptGenerator generator, AuthzPromptValidator validator) { + this.generator = generator; + this.validator = validator; + } + + public ScenarioOutcome run(AuthzScenario scenario, Map slotSchemaMap, TemplateUri templateUri) { + MetadataContent metadata; + try { + metadata = generator.generate(scenario); + } catch (A2ATError e) { + return buildErrorOutcome(scenario, e, null); + } catch (RuntimeException e) { + A2ATError wrapped = new A2ATError(A2ATErrorCodes.SDK_INTERNAL_ERROR, e.getMessage(), e); + return buildErrorOutcome(scenario, wrapped, null); + } + + FilledParamData filled; + try { + filled = validator.validate(metadata.promptText(), slotSchemaMap, templateUri); + } catch (A2ATError e) { + return buildErrorOutcome(scenario, e, metadata); + } catch (RuntimeException e) { + A2ATError wrapped = new A2ATError(A2ATErrorCodes.SDK_INTERNAL_ERROR, e.getMessage(), e); + return buildErrorOutcome(scenario, wrapped, metadata); + } + + String actualOutcome = AuthzScenario.EXPECTED_SUCCESS; + List actualSlotErrors = List.of(); + boolean match = matchesExpected(scenario.expected(), actualOutcome, actualSlotErrors); + return new ScenarioOutcome( + new ScenarioResult(actualOutcome, match, null, actualSlotErrors), metadata, filled); + } + + private static ScenarioOutcome buildErrorOutcome( + AuthzScenario scenario, A2ATError error, MetadataContent metadata) { + String actualOutcome = error.getCode(); + List actualSlotErrors = extractSlotErrors(error); + boolean match = matchesExpected(scenario.expected(), actualOutcome, actualSlotErrors); + return new ScenarioOutcome( + new ScenarioResult(actualOutcome, match, error, actualSlotErrors), metadata, null); + } + + private static List extractSlotErrors(A2ATError error) { + if (error instanceof PromptGenerationException pge) { + return pge.failedParameters(); + } + if (error instanceof ContentValidationException cve) { + return cve.errors(); + } + return List.of(); + } + + private static boolean matchesExpected( + AuthzExpected expected, + String actualOutcome, + List actualSlotErrors) { + if (!expected.outcome().equals(actualOutcome)) { + return false; + } + List expectedSlotErrors = expected.slotErrors(); + if (expectedSlotErrors == null || expectedSlotErrors.isEmpty()) { + return true; + } + for (SlotErrorExpectation expectedError : expectedSlotErrors) { + boolean found = actualSlotErrors.stream() + .anyMatch(actual -> + actual.slotName().equals(expectedError.slotName()) + && actual.code().equals(expectedError.code())); + if (!found) { + return false; + } + } + return true; + } + + public record ScenarioResult( + String outcome, boolean match, A2ATError error, List slotErrors) {} + + public record ScenarioOutcome(ScenarioResult result, MetadataContent metadata, FilledParamData filled) {} +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/package-info.java new file mode 100644 index 00000000..7d53add6 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/package-info.java @@ -0,0 +1,21 @@ +/** + * Authorization-T demo: configuration-driven prompt generation and compliance verification. + * + *

      The demo loads scenarios from a JSON configuration file, generates authorization prompts through the + * {@code A2ATClient} facade, validates them through the {@code A2ATServer} facade, and reports + * {@code PASS}/{@code FAIL}/{@code ERROR} results with a non-zero exit code when any scenario fails. + * + *

      Layout: + * + *

        + *
      • {@code AuthzPromptGenerator} - functional interface for prompt generation; + *
      • {@code AuthzPromptValidator} - functional interface for prompt validation; + *
      • {@code AuthzScenario} - immutable scenario configuration record; + *
      • {@code AuthzScenarioLoader} - JSON scenario file parser and validator; + *
      • {@code AuthzScenarioRunner} - scenario execution engine with injectable generator/validator; + *
      • {@code AuthzSampleMain} - entry point that wires everything together. + *
      + * + * @since 2026-08 + */ +package net.openan.a2at.sample.authz_policy; diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/authz.env b/a2a-t-sample/src/main/resources/sample/authz-policy/authz.env new file mode 100644 index 00000000..1811e54f --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/authz.env @@ -0,0 +1,10 @@ +# Authorization-T demo environment. +# Copy to authz.env and fill A2AT_LLM_API_KEY to run the demo. +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=classpath +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL= +A2AT_LLM_BASE_URL= +A2AT_LLM_API_KEY= +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory \ No newline at end of file diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios.json b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios.json new file mode 100644 index 00000000..923997a3 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios.json @@ -0,0 +1,51 @@ +{ + "scenarios": [ + { + "label": "add-from-text", + "entry": "from_text", + "input": { + "text": "新增两条动网操作授权策略:业务投诉诊断/业务抢通/隧道调优/限期生效(2026-06-01~2030-06-18);载波调度/业务抢通/载波调度/永久生效" + }, + "expected": { + "outcome": "success", + "filled_params": { + "授权策略的操作类型": "新增授权策略", + "动网操作的授权策略列表": "业务投诉诊断/业务抢通/隧道调优/限期生效(2026-06-01~2030-06-18);载波调度/业务抢通/载波调度/永久生效" + }, + "prompt_text": "## 授权策略的操作类型\n新增授权策略 (必填)\n要求:\n提供授权策略的操作类型,取值范围:新增授权策略、修改授权策略、删除授权策略、查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n业务投诉诊断/业务抢通/隧道调优/限期生效(2026-06-01~2030-06-18);载波调度/业务抢通/载波调度/永久生效 (新增必填,修改必填,删除必填,查询选填)\n要求: \n1. 支持列表形式,列表中的每一项包含如下信息:\n - 动网操作的授权策略标识:唯一标识一条动网操作授权策略; 举例:7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3;\n - 动网操作的业务场景:动网操作支撑的业务场景;举例:业务投诉诊断;\n - 动网操作的处置类型;举例:业务抢通;\n - 动网操作名称:所授权的动网操作的具体名称;举例:隧道调优;\n - 有效期:授权策略的生效时间区间;如果取值为“永久生效”,则表示该授权永久生效,不限定有效期;举例:2026-06-01T12:00:00Z ~ 2030-06-18T12:00:00Z; \n - 创建时间:创建授权策略的时间;举例:2026-06-18T12:00:00Z;\n - 最后修改时间:最后一次修改授权策略的时间;举例:2026-06-18T12:00:00Z;\n\n2. 创建授权策略时,必填参数包括:动网操作的业务场景、动网操作的处置类型、动网操作名称、有效期\n 修改授权策略时,支持修改的参数为:有效期\n 查询授权策略时支持的查询条件为:动网操作的业务场景、动网操作的处置类型、动网操作名称\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + } + }, + { + "label": "add-from-data", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "新增授权策略", + "策略列表": "业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18;载波调度/业务抢通/载波调度/永久生效" + }, + "schema": { + "操作类型": "授权策略操作类型,取值范围:新增/修改/删除/查询授权策略", + "策略列表": "动网操作授权策略列表,每条包含:策略标识、业务场景、处置类型、操作名称、有效期" + } + }, + "expected": { + "outcome": "success", + "filled_params": { + "授权策略的操作类型": "新增授权策略", + "动网操作的授权策略列表": "业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18;载波调度/业务抢通/载波调度/永久生效" + }, + "prompt_text": "## 授权策略的操作类型\n新增授权策略 (必填)\n要求:\n提供授权策略的操作类型,取值范围:新增授权策略、修改授权策略、删除授权策略、查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18;载波调度/业务抢通/载波调度/永久生效 (新增必填,修改必填,删除必填,查询选填)\n要求: \n1. 支持列表形式,列表中的每一项包含如下信息:\n - 动网操作的授权策略标识:唯一标识一条动网操作授权策略; 举例:7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3;\n - 动网操作的业务场景:动网操作支撑的业务场景;举例:业务投诉诊断;\n - 动网操作的处置类型;举例:业务抢通;\n - 动网操作名称:所授权的动网操作的具体名称;举例:隧道调优;\n - 有效期:授权策略的生效时间区间;如果取值为“永久生效”,则表示该授权永久生效,不限定有效期;举例:2026-06-01T12:00:00Z ~ 2030-06-18T12:00:00Z; \n - 创建时间:创建授权策略的时间;举例:2026-06-18T12:00:00Z;\n - 最后修改时间:最后一次修改授权策略的时间;举例:2026-06-18T12:00:00Z;\n\n2. 创建授权策略时,必填参数包括:动网操作的业务场景、动网操作的处置类型、动网操作名称、有效期\n 修改授权策略时,支持修改的参数为:有效期\n 查询授权策略时支持的查询条件为:动网操作的业务场景、动网操作的处置类型、动网操作名称\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + } + }, + { + "label": "invalid-request", + "entry": "from_text", + "input": { + "text": "暂停所有已生效的动网操作授权(不在四种操作类型之内)" + }, + "expected": { + "outcome": "validation_semantic_rejected" + } + } + ] +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java new file mode 100644 index 00000000..0e8cbc7f --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java @@ -0,0 +1,197 @@ +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AuthzSampleMainTest { + + private static final AuthzExpected SUCCESS = new AuthzExpected("success", null, null, null); + private static final ScenarioResult MATCH_RESULT = + new ScenarioResult("success", true, null, List.of()); + private static final ScenarioResult MISMATCH_RESULT = + new ScenarioResult("slot_validation_error", false, null, List.of()); + + private PrintStream originalOut; + private ByteArrayOutputStream outContent; + + @BeforeEach + void setUp() { + originalOut = System.out; + outContent = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outContent)); + } + + @AfterEach + void tearDown() { + System.setOut(originalOut); + } + + @Test + void should_returnZeroExitCode_WhenAllScenariosMatch() { + assertEquals(0, AuthzSampleMain.exitCode(List.of(MATCH_RESULT, MATCH_RESULT, MATCH_RESULT))); + } + + @Test + void should_returnNonZeroExitCode_WhenAnyMismatch() { + assertTrue(AuthzSampleMain.exitCode(List.of(MATCH_RESULT, MISMATCH_RESULT, MATCH_RESULT)) != 0); + } + + @Test + void should_resolveEnvPath_ArgsFirst() { + Path result = AuthzSampleMain.resolveEnvPath(new String[] {"custom.env"}); + assertEquals(Path.of("custom.env"), result); + } + + @Test + void should_resolveEnvPath_WorkingDirFallback() throws IOException { + Path cwdEnv = Path.of("authz.env"); + try { + Files.writeString(cwdEnv, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-test\n"); + Path result = AuthzSampleMain.resolveEnvPath(new String[0]); + assertEquals(cwdEnv.toAbsolutePath(), result.toAbsolutePath()); + } finally { + Files.deleteIfExists(cwdEnv); + } + } + + @Test + void should_resolveEnvPath_ClasspathFallback() { + Path result = AuthzSampleMain.resolveEnvPath(new String[0]); + assertTrue( + result.toString().contains("authz-policy") && result.toString().endsWith("authz.env")); + } + + @Test + void should_hasRequiredLlmKeys_ReturnTrue_WhenAllKeysPresent(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-12345\n"); + assertTrue(AuthzSampleMain.hasRequiredLlmKeys(envFile)); + } + + @Test + void should_hasRequiredLlmKeys_ReturnFalse_WhenKeyMissing(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\n"); + assertFalse(AuthzSampleMain.hasRequiredLlmKeys(envFile)); + } + + @Test + void should_hasRequiredLlmKeys_ReturnFalse_WhenKeyBlank(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=\n"); + assertFalse(AuthzSampleMain.hasRequiredLlmKeys(envFile)); + } + + @Test + void should_hasRequiredLlmKeys_ReturnFalse_WhenFileNotFound() { + assertFalse(AuthzSampleMain.hasRequiredLlmKeys(Path.of("nonexistent.env"))); + } + + @Test + void should_loadSlotSchemaMap_ReturnMapWithPropertiesAndRequired() { + Map schema = AuthzSampleMain.loadSlotSchemaMap("zh-CN"); + assertNotNull(schema); + assertTrue(schema.containsKey("properties")); + assertTrue(schema.containsKey("required")); + assertTrue(schema.containsKey("$schema")); + assertTrue(schema.containsKey("type")); + assertTrue(schema.containsKey("additionalProperties")); + } + + @Test + void should_printScenarioReport_WhenMetadataNull() { + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + ScenarioOutcome outcome = new ScenarioOutcome(MISMATCH_RESULT, null, null); + AuthzSampleMain.printScenarioReport(scenario, outcome); + assertTrue(outContent.toString().contains("<生成失败>")); + } + + @Test + void should_printScenarioReport_WhenFilledNull() { + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); + ScenarioOutcome outcome = new ScenarioOutcome(MATCH_RESULT, metadata, null); + AuthzSampleMain.printScenarioReport(scenario, outcome); + assertTrue(outContent.toString().contains("<未提取参数>")); + } + + @Test + void should_printSummary() { + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + AuthzSampleMain.printSummary(List.of(scenario), List.of(MATCH_RESULT, MISMATCH_RESULT, MISMATCH_RESULT)); + String output = outContent.toString(); + assertTrue(output.contains("Match")); + assertTrue(output.contains("Mismatch")); + } + + @Test + void should_writeReport_WithExpectedAndActualOutcomeAndParams(@TempDir Path tempDir) throws IOException { + AuthzScenario scenario = new AuthzScenario( + "test", "from_text", Map.of("text", "hello"), + new AuthzExpected("success", Map.of("slot1", "expected_value"), null, null)); + MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); + ScenarioOutcome outcome = + new ScenarioOutcome(MATCH_RESULT, metadata, new net.openan.a2at.sdk.core.model.FilledParamData( + Map.of("slot1", "actual_value"))); + + Path reportPath = AuthzSampleMain.writeReport(List.of(scenario), List.of(outcome), tempDir); + + assertTrue(Files.exists(reportPath)); + String content = Files.readString(reportPath); + assertTrue(content.contains("\"expected_outcome\"")); + assertTrue(content.contains("\"success\"")); + assertTrue(content.contains("\"actual_outcome\"")); + assertTrue(content.contains("\"match\"")); + assertTrue(content.contains("\"expected_params\"")); + assertTrue(content.contains("\"expected_value\"")); + assertTrue(content.contains("\"actual_params\"")); + assertTrue(content.contains("\"actual_value\"")); + assertTrue(content.contains("\"prompt_text\"")); + assertTrue(content.contains("\"prompt text\"")); + assertTrue(content.contains("\"expected_slot_errors\"")); + assertTrue(content.contains("\"actual_slot_errors\"")); + } + + @Test + void should_writeReport_WithSlotErrors(@TempDir Path tempDir) throws IOException { + AuthzScenario scenario = new AuthzScenario( + "test", "from_text", Map.of("text", "hello"), + new AuthzExpected( + "slot_validation_error", + null, + null, + List.of(new AuthzScenario.SlotErrorExpectation("授权策略的操作类型", "missing_required")))); + ScenarioResult result = new ScenarioResult( + "slot_validation_error", + true, + null, + List.of(new net.openan.a2at.sdk.core.model.SlotValidationError( + "授权策略的操作类型", "missing_required", "Required slot is missing or empty"))); + ScenarioOutcome outcome = new ScenarioOutcome(result, null, null); + + Path reportPath = AuthzSampleMain.writeReport(List.of(scenario), List.of(outcome), tempDir); + + String content = Files.readString(reportPath); + assertTrue(content.contains("授权策略的操作类型")); + assertTrue(content.contains("missing_required")); + assertTrue(content.contains("Required slot is missing or empty")); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java new file mode 100644 index 00000000..89a9b807 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java @@ -0,0 +1,70 @@ +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class AuthzScenarioLoaderTest { + + @Test + void should_loadAndValidateAllScenarios() { + List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios.json"); + + assertEquals(3, scenarios.size()); + assertEquals("add-from-text", scenarios.get(0).label()); + assertEquals("from_text", scenarios.get(0).entry()); + assertEquals("success", scenarios.get(0).expected().outcome()); + assertEquals("add-from-data", scenarios.get(1).label()); + assertEquals("from_data_with_schema", scenarios.get(1).entry()); + assertEquals("success", scenarios.get(1).expected().outcome()); + assertEquals("invalid-request", scenarios.get(2).label()); + assertEquals("from_text", scenarios.get(2).entry()); + assertEquals("validation_semantic_rejected", scenarios.get(2).expected().outcome()); + } + + @Test + void should_throwIllegalStateException_WhenResourceNotFound() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, () -> AuthzScenarioLoader.load("sample/authz-policy/nonexistent.json")); + + assertTrue(ex.getMessage().contains("not found")); + } + + @Test + void should_throwIllegalStateException_WhenJsonMalformed() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, () -> AuthzScenarioLoader.load("sample/authz-policy/test/malformed.json")); + + assertTrue(ex.getMessage().contains("malformed") || ex.getMessage().contains("parse")); + } + + @Test + void should_throwIllegalArgumentException_WhenScenarioHasInvalidEntry() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/invalid-scenarios.json")); + + assertTrue(ex.getMessage().contains("bad-scenario")); + } + + @Test + void should_throwIllegalStateException_WhenScenariosIsNotAnArray() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/scenarios-as-string.json")); + + assertTrue(ex.getMessage().contains("scenarios")); + } + + @Test + void should_throwIllegalStateException_WhenScenarioLabelIsNotAString() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/scenario-label-non-string.json")); + + assertTrue(ex.getMessage().contains("label")); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java new file mode 100644 index 00000000..119cc088 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java @@ -0,0 +1,342 @@ +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import org.junit.jupiter.api.Test; + +class AuthzScenarioRunnerTest { + + private static final TemplateUri TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; + private static final Map SLOT_SCHEMA = Map.of("properties", Map.of(), "required", Map.of()); + private static final AuthzExpected SUCCESS = new AuthzExpected("success", null, null, null); + private static final AuthzExpected EXPECTED_SLOT_ERROR = + new AuthzExpected("slot_validation_error", null, null, null); + private static final AuthzExpected EXPECTED_SEMANTIC_REJECTED = + new AuthzExpected("validation_semantic_rejected", null, null, null); + + @Test + void should_dispatchToFromTextGenerator() { + AtomicReference calledEntry = new AtomicReference<>(); + AuthzPromptGenerator generator = scenario -> { + calledEntry.set(scenario.entry()); + return new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertEquals("from_text", calledEntry.get()); + } + + @Test + void should_dispatchToFromDataWithSchemaGenerator() { + AtomicReference calledEntry = new AtomicReference<>(); + AuthzPromptGenerator generator = scenario -> { + calledEntry.set(scenario.entry()); + return new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario( + "test", "from_data_with_schema", Map.of("data", Map.of("k", "v"), "schema", Map.of("k", "d")), SUCCESS); + + runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertEquals("from_data_with_schema", calledEntry.get()); + } + + @Test + void should_match_WhenExpectedSuccessAndValidationPasses() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals("success", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNotNull(outcome.filled()); + } + + @Test + void should_match_WhenExpectedSemanticRejectedAndSemanticRejected() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException(A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED, "semantic rejected"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = + new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SEMANTIC_REJECTED); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals("validation_semantic_rejected", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidationRejected() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException(A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED, "semantic rejected"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("validation_semantic_rejected", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSemanticRejectedAndValidationPasses() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = + new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SEMANTIC_REJECTED); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("success", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNotNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndGeneratorThrowsPromptGenerationException() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException("slot_schema_not_found", "schema not found"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("slot_schema_not_found", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_match_WhenExpectedSlotValidationErrorAndGeneratorThrowsSlotValidationError() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + A2ATErrorCodes.SLOT_VALIDATION_ERROR, "Required slots are missing or empty: operation_type"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = + new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SLOT_ERROR); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals("slot_validation_error", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSlotValidationErrorAndGeneratorThrowsInfrastructureError() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + A2ATErrorCodes.LLM_INVOCATION_FAILED, "LLM invocation failed"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = + new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SLOT_ERROR); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("llm_invocation_failed", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidatorThrowsInfrastructureError() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException( + A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, "infrastructure error"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("validation_llm_infrastructure_error", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidatorThrowsResourceNotFound() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException( + A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, "resource not found"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("validation_prompt_resource_not_found", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidatorThrowsUnknownCode() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException("unknown_code", "unknown error"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("unknown_code", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_matchSlotErrors_WhenExpectedSlotErrorsMatchActual() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + A2ATErrorCodes.SLOT_VALIDATION_ERROR, + "Required slots are missing or empty: 授权策略的操作类型", + List.of(new SlotValidationError("授权策略的操作类型", "missing_required", "Required slot is missing or empty"))); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzExpected expected = new AuthzExpected( + "slot_validation_error", + null, + null, + List.of(new SlotErrorExpectation("授权策略的操作类型", "missing_required"))); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals(1, outcome.result().slotErrors().size()); + assertEquals("授权策略的操作类型", outcome.result().slotErrors().get(0).slotName()); + assertEquals("missing_required", outcome.result().slotErrors().get(0).code()); + } + + @Test + void should_notMatchSlotErrors_WhenExpectedSlotErrorsDontMatchActual() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + A2ATErrorCodes.SLOT_VALIDATION_ERROR, + "Required slots are missing or empty: 授权策略的操作类型", + List.of(new SlotValidationError("授权策略的操作类型", "missing_required", "Required slot is missing or empty"))); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzExpected expected = new AuthzExpected( + "slot_validation_error", + null, + null, + List.of(new SlotErrorExpectation("动网操作的授权策略列表", "missing_required"))); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("slot_validation_error", outcome.result().outcome()); + } + + @Test + void should_notMatchSlotErrors_WhenSlotNameMatchesButCodeDiffers() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + A2ATErrorCodes.SLOT_VALIDATION_ERROR, + "Required slots are missing or empty: 授权策略的操作类型", + List.of(new SlotValidationError("授权策略的操作类型", "missing_required", "Required slot is missing or empty"))); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzExpected expected = new AuthzExpected( + "slot_validation_error", + null, + null, + List.of(new SlotErrorExpectation("授权策略的操作类型", "invalid_value"))); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndGeneratorThrowsRuntimeException() { + AuthzPromptGenerator generator = scenario -> { + throw new NullPointerException("unexpected NPE"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, SLOT_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("sdk_internal_error", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioTest.java new file mode 100644 index 00000000..b0475594 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioTest.java @@ -0,0 +1,164 @@ +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import org.junit.jupiter.api.Test; + +class AuthzScenarioTest { + + private static AuthzExpected success() { + return new AuthzExpected("success", null, null, null); + } + + @Test + void should_acceptValidFromTextScenario() { + AuthzScenario scenario = new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success()); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_acceptValidFromDataWithSchemaScenario() { + AuthzScenario scenario = new AuthzScenario( + "add-from-data", + "from_data_with_schema", + Map.of("data", Map.of("k", "v"), "schema", Map.of("k", "description")), + success()); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_rejectInvalidEntry() { + AuthzScenario scenario = new AuthzScenario("bad", "invalid_entry", Map.of("text", "test"), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("Invalid entry: invalid_entry", ex.getMessage()); + } + + @Test + void should_rejectInvalidExpectedOutcome() { + AuthzScenario scenario = new AuthzScenario( + "bad", "from_text", Map.of("text", "test"), new AuthzExpected("invalid_outcome", null, null, null)); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("Invalid expected outcome: invalid_outcome", ex.getMessage()); + } + + @Test + void should_rejectNullExpectedOutcome() { + AuthzScenario scenario = new AuthzScenario( + "bad", "from_text", Map.of("text", "test"), new AuthzExpected(null, null, null, null)); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("Invalid expected outcome: null", ex.getMessage()); + } + + @Test + void should_acceptSlotValidationErrorOutcome() { + AuthzScenario scenario = new AuthzScenario( + "reject", "from_text", Map.of("text", "test"), + new AuthzExpected("slot_validation_error", null, null, null)); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_acceptSemanticRejectedOutcome() { + AuthzScenario scenario = new AuthzScenario( + "reject", "from_text", Map.of("text", "test"), + new AuthzExpected("validation_semantic_rejected", null, null, null)); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_rejectNullExpected() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of("text", "test"), null); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("expected must not be null", ex.getMessage()); + } + + @Test + void should_rejectFromTextMissingText() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of(), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_text scenario requires a non-blank input.text", ex.getMessage()); + } + + @Test + void should_rejectFromTextBlankText() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of("text", " "), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_text scenario requires a non-blank input.text", ex.getMessage()); + } + + @Test + void should_rejectFromDataMissingData() { + AuthzScenario scenario = + new AuthzScenario("bad", "from_data_with_schema", Map.of("schema", Map.of("k", "d")), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.data", ex.getMessage()); + } + + @Test + void should_rejectFromDataMissingSchema() { + AuthzScenario scenario = + new AuthzScenario("bad", "from_data_with_schema", Map.of("data", Map.of("k", "v")), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.schema", ex.getMessage()); + } + + @Test + void should_rejectFromDataWithSchema_EmptyData() { + AuthzScenario scenario = new AuthzScenario( + "bad", "from_data_with_schema", Map.of("data", Map.of(), "schema", Map.of("k", "d")), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.data", ex.getMessage()); + } + + @Test + void should_rejectFromDataWithSchema_EmptySchema() { + AuthzScenario scenario = new AuthzScenario( + "bad", "from_data_with_schema", Map.of("data", Map.of("k", "v"), "schema", Map.of()), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.schema", ex.getMessage()); + } + + @Test + void should_rejectNullInput() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", null, success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("input must not be null", ex.getMessage()); + } + + @Test + void should_rejectNullScenario() { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(null)); + assertEquals("scenario must not be null", ex.getMessage()); + } +} diff --git a/a2a-t-sample/src/test/resources/sample/authz-policy/test/invalid-scenarios.json b/a2a-t-sample/src/test/resources/sample/authz-policy/test/invalid-scenarios.json new file mode 100644 index 00000000..9403cca5 --- /dev/null +++ b/a2a-t-sample/src/test/resources/sample/authz-policy/test/invalid-scenarios.json @@ -0,0 +1 @@ +{"scenarios": [{"label": "bad-scenario", "entry": "invalid_entry", "input": {}, "expected": {"outcome": "success"}}]} \ No newline at end of file diff --git a/a2a-t-sample/src/test/resources/sample/authz-policy/test/malformed.json b/a2a-t-sample/src/test/resources/sample/authz-policy/test/malformed.json new file mode 100644 index 00000000..1d8085e3 --- /dev/null +++ b/a2a-t-sample/src/test/resources/sample/authz-policy/test/malformed.json @@ -0,0 +1 @@ +{"scenarios": [{"label": "bad", "entry": "invalid_entry", }]} \ No newline at end of file diff --git a/a2a-t-sample/src/test/resources/sample/authz-policy/test/scenario-label-non-string.json b/a2a-t-sample/src/test/resources/sample/authz-policy/test/scenario-label-non-string.json new file mode 100644 index 00000000..3187dcc6 --- /dev/null +++ b/a2a-t-sample/src/test/resources/sample/authz-policy/test/scenario-label-non-string.json @@ -0,0 +1 @@ +{"scenarios": [{"label": 123, "entry": "from_text", "input": {"text": "ok"}, "expected": {"outcome": "success"}}]} \ No newline at end of file diff --git a/a2a-t-sample/src/test/resources/sample/authz-policy/test/scenarios-as-string.json b/a2a-t-sample/src/test/resources/sample/authz-policy/test/scenarios-as-string.json new file mode 100644 index 00000000..c67aac06 --- /dev/null +++ b/a2a-t-sample/src/test/resources/sample/authz-policy/test/scenarios-as-string.json @@ -0,0 +1 @@ +{"scenarios": "not-an-array"} \ No newline at end of file From 000528fdd2af38349f83384dec4bc304ac8ffa06 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sun, 23 Aug 2026 11:16:33 +0800 Subject: [PATCH 143/295] docs(sample): add Javadoc/@since and pre-fill authz.env defaults - Add @since 2026-08 and class-level Javadoc to AuthzSampleMain, AuthzScenarioLoader, AuthzScenarioRunner, AuthzPromptGenerator, AuthzPromptValidator - Update package-info to reflect match/mismatch model - Pre-fill A2AT_LLM_MODEL and A2AT_LLM_BASE_URL defaults in authz.env to match other sample modules Signed-off-by: w00609601 --- .../a2at/sample/authz_policy/AuthzPromptGenerator.java | 5 +++++ .../a2at/sample/authz_policy/AuthzPromptValidator.java | 5 +++++ .../openan/a2at/sample/authz_policy/AuthzSampleMain.java | 9 +++++++++ .../a2at/sample/authz_policy/AuthzScenarioLoader.java | 8 ++++++++ .../a2at/sample/authz_policy/AuthzScenarioRunner.java | 6 ++++++ .../openan/a2at/sample/authz_policy/package-info.java | 3 ++- .../src/main/resources/sample/authz-policy/authz.env | 4 ++-- 7 files changed, 37 insertions(+), 3 deletions(-) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptGenerator.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptGenerator.java index 89cd643b..4a81edea 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptGenerator.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptGenerator.java @@ -2,6 +2,11 @@ import net.openan.a2at.sdk.core.model.MetadataContent; +/** + * Functional interface for generating an authorization prompt from a scenario. + * + * @since 2026-08 + */ @FunctionalInterface public interface AuthzPromptGenerator { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java index 55d72b6f..f0b9cad2 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java @@ -4,6 +4,11 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.TemplateUri; +/** + * Functional interface for validating an authorization prompt and extracting filled slot data. + * + * @since 2026-08 + */ @FunctionalInterface public interface AuthzPromptValidator { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java index cd8b71f1..4c579076 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java @@ -25,6 +25,15 @@ import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.server.A2ATServer; +/** + * Entry point for the Authorization-T demo. + * + *

      Loads scenarios from {@code sample/authz-policy/scenarios.json}, generates authorization prompts through + * {@link A2ATClient}, validates them through {@link A2ATServer}, and writes a JSON report with + * expected/actual outcome and per-slot error details for each scenario. + * + * @since 2026-08 + */ public final class AuthzSampleMain { private static final String DEFAULT_ENV_FILE = "authz.env"; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoader.java index aaa433b1..4b98e0dd 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoader.java @@ -8,6 +8,14 @@ import java.util.List; import java.util.Map; +/** + * Loads and validates demo scenarios from a JSON resource file. + * + *

      The JSON file must contain a top-level {@code scenarios} array. Each scenario entry is parsed into an + * {@link AuthzScenario} record and then validated via {@link AuthzScenario#validate(AuthzScenario)}. + * + * @since 2026-08 + */ public final class AuthzScenarioLoader { private static final ObjectMapper MAPPER = new ObjectMapper(); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java index e64b538c..4ad647e2 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java @@ -13,6 +13,12 @@ import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; +/** + * Scenario execution engine that runs a single scenario through prompt generation and validation, + * comparing the actual outcome against the expected outcome and per-slot error details. + * + * @since 2026-08 + */ public final class AuthzScenarioRunner { private final AuthzPromptGenerator generator; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/package-info.java index 7d53add6..ffa2c559 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/package-info.java @@ -3,7 +3,8 @@ * *

      The demo loads scenarios from a JSON configuration file, generates authorization prompts through the * {@code A2ATClient} facade, validates them through the {@code A2ATServer} facade, and reports - * {@code PASS}/{@code FAIL}/{@code ERROR} results with a non-zero exit code when any scenario fails. + * {@code match}/{@code mismatch} results with a non-zero exit code when any scenario does not match + * its expected outcome. * *

      Layout: * diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/authz.env b/a2a-t-sample/src/main/resources/sample/authz-policy/authz.env index 1811e54f..c984f486 100644 --- a/a2a-t-sample/src/main/resources/sample/authz-policy/authz.env +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/authz.env @@ -4,7 +4,7 @@ A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL= -A2AT_LLM_BASE_URL= +A2AT_LLM_MODEL=deepseek-chat +A2AT_LLM_BASE_URL=https://api.deepseek.com A2AT_LLM_API_KEY= A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory \ No newline at end of file From daa282e0fa1e40dce8179dafd9003e7af537b766 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 22 Aug 2026 18:44:28 +0800 Subject: [PATCH 144/295] chore: ignore .worktrees directory Signed-off-by: w00609601 --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 09aacbc1..a20c2078 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ progress.md task_plan.md .idea/ + +.worktrees/ From 67da7cfd96a070b1072635da7f11027561fd6971 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 22 Aug 2026 23:38:35 +0800 Subject: [PATCH 145/295] refactor: unify LLM config into single parse chain and cache LLM client in builders (TODO-3, TODO-8) Signed-off-by: w00609601 --- .../assembly/DefaultA2ATClientBuilder.java | 23 +- .../DefaultA2ATClientBuilderTest.java | 117 +++++++ .../a2at/sdk/core/model/A2ATConfigKeys.java | 3 + .../openan/a2at/sdk/core/model/LlmConfig.java | 286 +++++++++++------- .../a2at/sdk/core/model/LlmConfigTest.java | 135 ++++++++- .../openan/a2at/sdk/llm/LLMClientConfig.java | 113 ++++++- .../openan/a2at/sdk/llm/LLMConfigLoader.java | 175 ----------- .../a2at/sdk/llm/LLMClientConfigFromTest.java | 252 +++++++++++++++ .../a2at/sdk/llm/LLMConfigLoaderTest.java | 200 ------------ .../a2at/sdk/llm/LLMPublicExportsTest.java | 1 - .../a2at/sdk/llm/LlmPackageStructureTest.java | 1 - ...otiationGenerationOrchestratorBuilder.java | 10 +- .../shared/mock/SampleMockLlmInstaller.java | 7 +- .../assembly/DefaultA2ATServerBuilder.java | 59 ++-- .../DefaultA2ATServerBuilderTest.java | 180 +++++++++++ 15 files changed, 1013 insertions(+), 549 deletions(-) delete mode 100644 a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java create mode 100644 a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java delete mode 100644 a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java create mode 100644 a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index afdfa4b9..d9180594 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -18,16 +18,15 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMConfigLoader; import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; import net.openan.a2at.sdk.negotiation.types.model.NegotiationRole; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; @@ -47,6 +46,8 @@ public final class DefaultA2ATClientBuilder { private Path envPath; + private LLMClient llmClient; + /** * Creates one new builder instance. * @@ -98,7 +99,7 @@ public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { newClientTemplateLoader(resources, config.prompt().sourceType()); String language = config.prompt().language(); - LLMClient llmClient = createLlmClient(); + LLMClient client = llmClient(); String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); @@ -106,14 +107,14 @@ public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); ClientScenarioRecognizer scenarioRecognizer = - new SingleScenarioAwareRecognizer(scenarios, new ScenarioRecognizer(llmClient)::recognize); + new SingleScenarioAwareRecognizer(scenarios, new ScenarioRecognizer(client)::recognize); ClientSlotValueExtractor slotValueExtractor = new StructuredInputAwareSlotValueExtractor( new DefaultTemplateDrivenSlotValueExtractor(slotSchemaLoader), new DefaultStructuredClientSlotValueExtractor( - llmClient, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)); + client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)); return ClientPromptGenerationOrchestratorBuilder.builder() - .llmClient(llmClient) + .llmClient(client) .scenarios(scenarios) .language(language) .scenarioSystemPrompt(scenarioSystemPrompt) @@ -156,7 +157,7 @@ public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator( require(config, "Unified SDK config must be configured."); requireSupportedConfig(); require(envPath, "Unified SDK env path must be configured."); - return NegotiationContentService.buildOrchestrator(config, createLlmClient()); + return NegotiationContentService.buildOrchestrator(config, llmClient()); } /** @@ -190,9 +191,11 @@ private void requireSupportedConfig() { } } - private LLMClient createLlmClient() { - LLMClientConfig loadedConfig = LLMConfigLoader.load(envPath); - return LLMClientFactory.create(loadedConfig.provider(), loadedConfig); + private synchronized LLMClient llmClient() { + if (llmClient == null) { + llmClient = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); + } + return llmClient; } private static void require(Object value, String message) { diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java index fd7ec25e..82a8fe9a 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java @@ -2,13 +2,31 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import java.io.IOException; import java.lang.reflect.Constructor; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class DefaultA2ATClientBuilderTest { + private static final AtomicInteger CLIENT_INSTANCE_COUNT = new AtomicInteger(0); + + @BeforeEach + void resetCounter() { + CLIENT_INSTANCE_COUNT.set(0); + } + @Test void structuredInputAwareExtractSlotsWithSchemaDelegatesToLlmExtractorForMapInput() throws Exception { ClientSlotValueExtractor structuredExtractor = @@ -53,4 +71,103 @@ void structuredInputAwareExtractSlotsStillDispatchesByType() throws Exception { "plain text", "scenario", "en", "template"); assertEquals(Map.of("key", "llm"), stringResult); } + + @Test + void should_createLlmClientOnlyOnce_When_multipleBuildMethodsCalled() throws IOException { + String provider = "test-lazy-cache"; + if (!LLMClientFactory.availableProviders().contains(provider)) { + LLMClientFactory.register(provider, CountingClient.class); + } + + Path envFile = createTempEnvFile(provider); + A2ATConfig config = A2ATConfig.load(envFile); + config = net.openan.a2at.sdk.negotiation.generation.NegotiationContentService + .resolvePromptResourceLocalRootDir(config, envFile); + + DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder() + .config(config) + .envPath(envFile); + + builder.buildPromptGenerationOrchestrator(); + builder.buildNegotiationGenerationOrchestrator(); + + assertEquals(1, CLIENT_INSTANCE_COUNT.get()); + } + + private static Path createTempEnvFile(String provider) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-builder-lazy"); + Path promptRoot = tempDir.resolve("prompt_resources"); + Path scenarioPromptDir = promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.createDirectories(scenariosDir); + + Files.writeString(scenarioPromptDir.resolve("system.md"), "You are a scenario recognition assistant."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Identify the best matching scenario."); + Files.writeString(slotPromptDir.resolve("system.md"), "You are a slot extraction assistant."); + Files.writeString(slotPromptDir.resolve("user.md"), "Extract slots from the input."); + Files.writeString( + scenariosDir.resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "energy-saving", + "scenario_name": "Energy Saving", + "description": "Energy analysis", + "example": "Analyze site power" + } + ] + } + """); + + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(provider)); + return envFile; + } + + public static final class CountingClient implements LLMClient { + + private final LLMClientConfig config; + + public CountingClient(LLMClientConfig config) { + this.config = config; + CLIENT_INSTANCE_COUNT.incrementAndGet(); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + Object slotNames = jsonSchema.get("slotNames"); + StringBuilder slots = new StringBuilder("{"); + if (slotNames instanceof List names) { + for (int i = 0; i < names.size(); i++) { + if (i > 0) { + slots.append(","); + } + slots.append("\"").append(names.get(i)).append("\":\"placeholder\""); + } + } + slots.append("}"); + return new LLMResponse( + "{\"slots\":" + slots + ",\"slot_errors\":[]}", config.model(), Map.of(), Map.of()); + } + } } \ No newline at end of file diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java index d07c0b38..cc3f3557 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java @@ -57,6 +57,9 @@ public static final class Llm { /** Disables use of the JVM or operating-system HTTP proxy for the LLM request. */ public static final String DISABLE_SYSTEM_PROXY = "A2AT_LLM_DISABLE_SYSTEM_PROXY"; + /** Optional reasoning effort level for reasoning models (none/minimal/low/medium/high/xhigh). */ + public static final String REASONING_EFFORT = "A2AT_LLM_REASONING_EFFORT"; + /** Maximum total number of tracked sessions. */ public static final String SESSION_MAX_TOTAL = "A2AT_LLM_SESSION_MAX_TOTAL"; diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java index 14b291e9..86a54bc5 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java @@ -1,116 +1,170 @@ -package net.openan.a2at.sdk.core.model; - -import java.util.Map; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.math.NumberUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Structured LLM runtime configuration resolved from unified SDK config. - * - * @since 2026-06 - */ -public record LlmConfig( - String provider, - String model, - String apiKey, - String baseUrl, - int historyWindow, - int maxTokens, - double temperature, - double timeoutSeconds, - int sessionMaxTotal, - int sessionMaxPerProvider, - int maxAttempts) { - - private static final Logger LOGGER = LoggerFactory.getLogger(LlmConfig.class); - - private static final String DEFAULT_PROVIDER = "openai"; - - private static final int DEFAULT_HISTORY_WINDOW = 12; - - private static final int DEFAULT_MAX_TOKENS = 2048; - - private static final double DEFAULT_TEMPERATURE = 0.2d; - - private static final double DEFAULT_TIMEOUT_SECONDS = 30.0d; - - private static final int DEFAULT_SESSION_MAX_TOTAL = 300; - - private static final int DEFAULT_SESSION_MAX_PER_PROVIDER = 100; - - private static final int DEFAULT_MAX_ATTEMPTS = 3; - - private static final int MAX_ATTEMPTS_LOWER_BOUND = 1; - - private static final int MAX_ATTEMPTS_UPPER_BOUND = 10; - - /** - * Builds one LLM config from raw `.env` values. - * - * @param values raw config values - * @return resolved LLM config - */ - public static LlmConfig fromMap(Map values) { - return new LlmConfig( - StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.Llm.PROVIDER), DEFAULT_PROVIDER), - StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.Llm.MODEL), ""), - StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.Llm.API_KEY), ""), - StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.Llm.BASE_URL), ""), - NumberUtils.toInt(values.get(A2ATConfigKeys.Llm.HISTORY_WINDOW), DEFAULT_HISTORY_WINDOW), - NumberUtils.toInt(values.get(A2ATConfigKeys.Llm.MAX_TOKENS), DEFAULT_MAX_TOKENS), - NumberUtils.toDouble(values.get(A2ATConfigKeys.Llm.TEMPERATURE), DEFAULT_TEMPERATURE), - NumberUtils.toDouble(values.get(A2ATConfigKeys.Llm.TIMEOUT_SECONDS), DEFAULT_TIMEOUT_SECONDS), - NumberUtils.toInt(values.get(A2ATConfigKeys.Llm.SESSION_MAX_TOTAL), DEFAULT_SESSION_MAX_TOTAL), - NumberUtils.toInt( - values.get(A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER), DEFAULT_SESSION_MAX_PER_PROVIDER), - parseMaxAttempts(values.get(A2ATConfigKeys.Llm.MAX_ATTEMPTS))); - } - - /** - * Resolves the retry attempt limit for LLM steps from one raw config value. - * - *

      Missing or blank values use the default of 3. Unparseable values fall back to the default. Values outside the - * allowed range 1-10 are clamped to the nearest bound. Both the fallback and the clamp cases emit a warning log - * carrying the raw value. - * - * @param rawValue raw config value, may be null or blank - * @return resolved attempt limit between 1 and 10 inclusive - */ - private static int parseMaxAttempts(String rawValue) { - if (StringUtils.isBlank(rawValue)) { - return DEFAULT_MAX_ATTEMPTS; - } - try { - int parsed = Integer.parseInt(rawValue.trim()); - if (parsed < MAX_ATTEMPTS_LOWER_BOUND) { - LOGGER.atWarn() - .log( - "LLM max attempts value is below the allowed minimum, clamped to bound. key={} raw_value={} clamped_value={}", - A2ATConfigKeys.Llm.MAX_ATTEMPTS, - rawValue, - MAX_ATTEMPTS_LOWER_BOUND); - return MAX_ATTEMPTS_LOWER_BOUND; - } - if (parsed > MAX_ATTEMPTS_UPPER_BOUND) { - LOGGER.atWarn() - .log( - "LLM max attempts value is above the allowed maximum, clamped to bound. key={} raw_value={} clamped_value={}", - A2ATConfigKeys.Llm.MAX_ATTEMPTS, - rawValue, - MAX_ATTEMPTS_UPPER_BOUND); - return MAX_ATTEMPTS_UPPER_BOUND; - } - return parsed; - } catch (NumberFormatException error) { - LOGGER.atWarn() - .log( - "LLM max attempts value is not a valid integer, falling back to default. key={} raw_value={} default_value={}", - A2ATConfigKeys.Llm.MAX_ATTEMPTS, - rawValue, - DEFAULT_MAX_ATTEMPTS); - return DEFAULT_MAX_ATTEMPTS; - } - } -} +package net.openan.a2at.sdk.core.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +/** + * Structured LLM runtime configuration resolved from unified SDK config. + * + * @since 2026-06 + */ +@Slf4j +public record LlmConfig( + String provider, + String model, + String apiKey, + String baseUrl, + int historyWindow, + Integer maxTokens, + Double temperature, + Double timeoutSeconds, + boolean disableSystemProxy, + String reasoningEffort, + int sessionMaxTotal, + int sessionMaxPerProvider, + int maxAttempts, + List parseErrors) { + + private static final String DEFAULT_PROVIDER = "openai"; + + private static final int DEFAULT_HISTORY_WINDOW = 10; + + private static final int DEFAULT_SESSION_MAX_TOTAL = 300; + + private static final int DEFAULT_SESSION_MAX_PER_PROVIDER = 100; + + public static final int DEFAULT_MAX_ATTEMPTS = 3; + + private static final int MAX_ATTEMPTS_LOWER_BOUND = 1; + + private static final int MAX_ATTEMPTS_UPPER_BOUND = 10; + + private static final String NON_NUMERIC_MESSAGE_FORMAT = "%s: non-numeric value '%s'"; + + private static final String INVALID_BOOLEAN_MESSAGE_FORMAT = "%s: invalid boolean value '%s'"; + + public LlmConfig { + parseErrors = List.copyOf(parseErrors); + } + + /** + * Builds one LLM config from raw {@code .env} values. + * + * @param values raw config values + * @return resolved LLM config + */ + public static LlmConfig fromMap(Map values) { + List parseErrors = new ArrayList<>(); + return new LlmConfig( + StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.Llm.PROVIDER), DEFAULT_PROVIDER), + StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.Llm.MODEL), ""), + StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.Llm.API_KEY), ""), + StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.Llm.BASE_URL), ""), + parseInt(values.get(A2ATConfigKeys.Llm.HISTORY_WINDOW), + A2ATConfigKeys.Llm.HISTORY_WINDOW, DEFAULT_HISTORY_WINDOW, parseErrors), + parseOptionalNumeric(values.get(A2ATConfigKeys.Llm.MAX_TOKENS), + A2ATConfigKeys.Llm.MAX_TOKENS, parseErrors, Integer::parseInt), + parseOptionalNumeric(values.get(A2ATConfigKeys.Llm.TEMPERATURE), + A2ATConfigKeys.Llm.TEMPERATURE, parseErrors, Double::parseDouble), + parseOptionalNumeric(values.get(A2ATConfigKeys.Llm.TIMEOUT_SECONDS), + A2ATConfigKeys.Llm.TIMEOUT_SECONDS, parseErrors, Double::parseDouble), + parseBoolean(values.get(A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY), + A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY, false, parseErrors), + parseReasoningEffort(values.get(A2ATConfigKeys.Llm.REASONING_EFFORT)), + parseInt(values.get(A2ATConfigKeys.Llm.SESSION_MAX_TOTAL), + A2ATConfigKeys.Llm.SESSION_MAX_TOTAL, DEFAULT_SESSION_MAX_TOTAL, parseErrors), + parseInt(values.get(A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER), + A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER, DEFAULT_SESSION_MAX_PER_PROVIDER, parseErrors), + parseMaxAttempts(values.get(A2ATConfigKeys.Llm.MAX_ATTEMPTS)), + Collections.unmodifiableList(parseErrors)); + } + + private static int parseMaxAttempts(String rawValue) { + if (StringUtils.isBlank(rawValue)) { + return DEFAULT_MAX_ATTEMPTS; + } + try { + int parsed = Integer.parseInt(rawValue.trim()); + if (parsed < MAX_ATTEMPTS_LOWER_BOUND) { + log.atWarn() + .log( + "LLM max attempts value is below the allowed minimum, clamped to bound. key={} raw_value={} clamped_value={}", + A2ATConfigKeys.Llm.MAX_ATTEMPTS, + rawValue.trim(), + MAX_ATTEMPTS_LOWER_BOUND); + return MAX_ATTEMPTS_LOWER_BOUND; + } + if (parsed > MAX_ATTEMPTS_UPPER_BOUND) { + log.atWarn() + .log( +"LLM max attempts value is above the allowed maximum, clamped to bound. key={} raw_value={} clamped_value={}", + A2ATConfigKeys.Llm.MAX_ATTEMPTS, + rawValue.trim(), + MAX_ATTEMPTS_UPPER_BOUND); + return MAX_ATTEMPTS_UPPER_BOUND; + } + return parsed; + } catch (NumberFormatException error) { + log.atWarn() + .log( +"LLM max attempts value is not a valid integer, falling back to default. key={} raw_value={} default_value={}", + A2ATConfigKeys.Llm.MAX_ATTEMPTS, + rawValue.trim(), + DEFAULT_MAX_ATTEMPTS); + return DEFAULT_MAX_ATTEMPTS; + } + } + + private static T parseOptionalNumeric( + String rawValue, String key, List parseErrors, Function parser) { + if (StringUtils.isBlank(rawValue)) { + return null; + } + try { + return parser.apply(rawValue.trim()); + } catch (NumberFormatException error) { + parseErrors.add(String.format(NON_NUMERIC_MESSAGE_FORMAT, key, rawValue.trim())); + return null; + } + } + + private static int parseInt(String rawValue, String key, int defaultValue, List parseErrors) { + if (StringUtils.isBlank(rawValue)) { + return defaultValue; + } + try { + return Integer.parseInt(rawValue.trim()); + } catch (NumberFormatException error) { + parseErrors.add(String.format(NON_NUMERIC_MESSAGE_FORMAT, key, rawValue.trim())); + return defaultValue; + } + } + + private static boolean parseBoolean(String rawValue, String key, boolean defaultValue, List parseErrors) { + if (StringUtils.isBlank(rawValue)) { + return defaultValue; + } + String trimmed = rawValue.trim(); + if ("true".equalsIgnoreCase(trimmed)) { + return true; + } + if ("false".equalsIgnoreCase(trimmed)) { + return false; + } + parseErrors.add(String.format(INVALID_BOOLEAN_MESSAGE_FORMAT, key, trimmed)); + return defaultValue; + } + + private static String parseReasoningEffort(String rawValue) { + if (StringUtils.isBlank(rawValue)) { + return null; + } + return rawValue.trim().toLowerCase(Locale.ROOT); + } +} \ No newline at end of file diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java index f22d0c67..ffe13bbd 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java @@ -1,7 +1,10 @@ package net.openan.a2at.sdk.core.model; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -14,6 +17,7 @@ *

    3. Default values when configuration keys are missing *
    4. Overriding defaults with environment variable values *
    5. Max attempts parsing: in-range values, clamping, fallback, and blank values + *
    6. Reasoning effort parsing: valid values, case normalization, blank values * * * @since 2026-06 @@ -24,8 +28,9 @@ class LlmConfigTest { * Verifies that {@link LlmConfig#fromMap(Map)} applies default values when no configuration keys are provided. * *

      Scenario: An empty map is passed to fromMap(). Expected result: All fields use predefined defaults: - - * provider: "openai" - model, apiKey, baseUrl: empty strings - historyWindow: 12 - maxTokens: 2048 - temperature: - * 0.2 - timeoutSeconds: 30.0 - sessionMaxTotal: 300 - sessionMaxPerProvider: 100 - maxAttempts: 3 + * provider: "openai" - model, apiKey, baseUrl: empty strings - historyWindow: 10 - maxTokens: null - temperature: + * null - timeoutSeconds: null - reasoningEffort: null - sessionMaxTotal: 300 - sessionMaxPerProvider: 100 - + * maxAttempts: 3 */ @Test void should_useDefaults_When_keysAreMissing() { @@ -37,10 +42,12 @@ void should_useDefaults_When_keysAreMissing() { assertEquals("", config.model()); assertEquals("", config.apiKey()); assertEquals("", config.baseUrl()); - assertEquals(12, config.historyWindow()); - assertEquals(2048, config.maxTokens()); - assertEquals(0.2d, config.temperature()); - assertEquals(30.0d, config.timeoutSeconds()); + assertEquals(10, config.historyWindow()); + assertNull(config.maxTokens()); + assertNull(config.temperature()); + assertNull(config.timeoutSeconds()); + assertEquals(false, config.disableSystemProxy()); + assertNull(config.reasoningEffort()); assertEquals(300, config.sessionMaxTotal()); assertEquals(100, config.sessionMaxPerProvider()); assertEquals(3, config.maxAttempts()); @@ -63,9 +70,11 @@ void should_overrideDefaults_When_keysAreProvided() { Map.entry("A2AT_LLM_MAX_TOKENS", "4096"), Map.entry("A2AT_LLM_TEMPERATURE", "0.5"), Map.entry("A2AT_LLM_TIMEOUT_SECONDS", "60"), + Map.entry("A2AT_LLM_DISABLE_SYSTEM_PROXY", "true"), Map.entry("A2AT_LLM_SESSION_MAX_TOTAL", "500"), Map.entry("A2AT_LLM_SESSION_MAX_PER_PROVIDER", "150"), - Map.entry("A2AT_LLM_MAX_ATTEMPTS", "5")); + Map.entry("A2AT_LLM_MAX_ATTEMPTS", "5"), + Map.entry("A2AT_LLM_REASONING_EFFORT", "HIGH")); LlmConfig config = LlmConfig.fromMap(values); @@ -77,9 +86,11 @@ void should_overrideDefaults_When_keysAreProvided() { assertEquals(4096, config.maxTokens()); assertEquals(0.5d, config.temperature()); assertEquals(60.0d, config.timeoutSeconds()); + assertEquals(true, config.disableSystemProxy()); assertEquals(500, config.sessionMaxTotal()); assertEquals(150, config.sessionMaxPerProvider()); assertEquals(5, config.maxAttempts()); + assertEquals("high", config.reasoningEffort()); } /** @@ -126,4 +137,114 @@ void should_useDefaultMaxAttempts_When_valueIsAbsentOrBlank() { assertEquals(3, absent.maxAttempts()); assertEquals(3, blank.maxAttempts()); } + + /** + * Verifies that {@link LlmConfig#fromMap(Map)} parses reasoning effort with case normalization. + * + *

      Scenario: A2AT_LLM_REASONING_EFFORT is set to "MEDIUM". Expected result: reasoningEffort resolves to "medium" + * (lowercased). + */ + @Test + void should_normalizeReasoningEffort_When_configured() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_REASONING_EFFORT", "MEDIUM")); + + assertEquals("medium", config.reasoningEffort()); + } + + /** + * Verifies that {@link LlmConfig#fromMap(Map)} returns null reasoning effort when the key is absent or blank. + * + *

      Scenario: A2AT_LLM_REASONING_EFFORT is missing or set to blank. Expected result: reasoningEffort is null. + */ + @Test + void should_returnNullReasoningEffort_When_valueIsAbsentOrBlank() { + LlmConfig absent = LlmConfig.fromMap(Map.of()); + LlmConfig blank = LlmConfig.fromMap(Map.of("A2AT_LLM_REASONING_EFFORT", "")); + + assertNull(absent.reasoningEffort()); + assertNull(blank.reasoningEffort()); + } + + @Test + void shouldRecordParseErrors_When_numericValuesAreNonNumeric() { + Map values = Map.of( + "A2AT_LLM_MAX_TOKENS", "garbage", + "A2AT_LLM_TEMPERATURE", "xyz", + "A2AT_LLM_TIMEOUT_SECONDS", "abc", + "A2AT_LLM_HISTORY_WINDOW", "not_a_number", + "A2AT_LLM_SESSION_MAX_TOTAL", "bad", + "A2AT_LLM_SESSION_MAX_PER_PROVIDER", "nope"); + + LlmConfig config = LlmConfig.fromMap(values); + + List errors = config.parseErrors(); + assertEquals(6, errors.size()); + assertTrue(errors.contains("A2AT_LLM_MAX_TOKENS: non-numeric value 'garbage'")); + assertTrue(errors.contains("A2AT_LLM_TEMPERATURE: non-numeric value 'xyz'")); + assertTrue(errors.contains("A2AT_LLM_TIMEOUT_SECONDS: non-numeric value 'abc'")); + assertTrue(errors.contains("A2AT_LLM_HISTORY_WINDOW: non-numeric value 'not_a_number'")); + assertTrue(errors.contains("A2AT_LLM_SESSION_MAX_TOTAL: non-numeric value 'bad'")); + assertTrue(errors.contains("A2AT_LLM_SESSION_MAX_PER_PROVIDER: non-numeric value 'nope'")); + + assertNull(config.maxTokens()); + assertNull(config.temperature()); + assertNull(config.timeoutSeconds()); + assertEquals(10, config.historyWindow()); + assertEquals(300, config.sessionMaxTotal()); + assertEquals(100, config.sessionMaxPerProvider()); + } + + @Test + void shouldNotRecordMaxAttemptsInParseErrors_When_maxAttemptsIsNonNumeric() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "garbage")); + + assertTrue(config.parseErrors().isEmpty()); + assertEquals(3, config.maxAttempts()); + } + + @Test + void shouldReturnEmptyParseErrors_When_noErrors() { + Map values = Map.of( + "A2AT_LLM_PROVIDER", "openai", + "A2AT_LLM_MAX_TOKENS", "4096", + "A2AT_LLM_TEMPERATURE", "0.5", + "A2AT_LLM_HISTORY_WINDOW", "20", + "A2AT_LLM_MAX_ATTEMPTS", "5"); + + LlmConfig config = LlmConfig.fromMap(values); + + assertTrue(config.parseErrors().isEmpty()); + assertEquals(4096, config.maxTokens()); + assertEquals(0.5d, config.temperature()); + assertEquals(20, config.historyWindow()); + assertEquals(5, config.maxAttempts()); + } + + @Test + void shouldReturnEmptyParseErrors_When_noValuesProvided() { + LlmConfig config = LlmConfig.fromMap(Map.of()); + + assertTrue(config.parseErrors().isEmpty()); + } + + @Test + void shouldRecordParseError_When_booleanValueIsInvalid() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_DISABLE_SYSTEM_PROXY", "maybe")); + + List errors = config.parseErrors(); + assertEquals(1, errors.size()); + assertTrue(errors.contains("A2AT_LLM_DISABLE_SYSTEM_PROXY: invalid boolean value 'maybe'")); + assertEquals(false, config.disableSystemProxy()); + } + + @Test + void shouldParseBooleanValueCaseInsensitive() { + LlmConfig trueConfig = LlmConfig.fromMap(Map.of("A2AT_LLM_DISABLE_SYSTEM_PROXY", "TRUE")); + LlmConfig falseConfig = LlmConfig.fromMap(Map.of("A2AT_LLM_DISABLE_SYSTEM_PROXY", "False")); + + assertEquals(true, trueConfig.disableSystemProxy()); + assertEquals(false, falseConfig.disableSystemProxy()); + assertTrue(trueConfig.parseErrors().isEmpty()); + assertTrue(falseConfig.parseErrors().isEmpty()); + } } diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java index 57d1851e..1cf1d82f 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java @@ -1,5 +1,13 @@ package net.openan.a2at.sdk.llm; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import net.openan.a2at.sdk.core.model.LlmConfig; +import org.apache.commons.lang3.StringUtils; + /** * Resolved default configuration for an LLM provider client. * @@ -30,4 +38,107 @@ public record LLMClientConfig( boolean disableSystemProxy, int sessionMaxTotal, int sessionMaxPerProvider, - String reasoningEffort) {} + String reasoningEffort) { + + private static final int MAX_HISTORY_WINDOW = 100; + + private static final int MAX_SESSION_TOTAL = 3000; + + private static final int MAX_SESSION_PER_PROVIDER = 1000; + + private static final Set VALID_REASONING_EFFORTS = + Set.of("none", "minimal", "low", "medium", "high", "xhigh"); + + /** + * Derives an LLM client configuration from the unified LLM configuration. + * + *

      Validates required keys, numeric bounds, and reasoning effort values. Missing required keys, out-of-range + * values, and invalid reasoning effort values all throw {@link LLMConfigError}. + * + * @param llmConfig unified LLM configuration + * @return derived LLM client configuration + * @throws LLMConfigError if the configuration is invalid + */ + public static LLMClientConfig from(LlmConfig llmConfig) { + Objects.requireNonNull(llmConfig, "llmConfig must not be null"); + validateParseErrors(llmConfig); + validateRequiredKeys(llmConfig); + validateBounds(llmConfig); + validateSessionOrder(llmConfig); + String reasoningEffort = validateReasoningEffort(llmConfig.reasoningEffort()); + return new LLMClientConfig( + llmConfig.provider(), + llmConfig.model(), + llmConfig.apiKey(), + llmConfig.baseUrl(), + llmConfig.historyWindow(), + llmConfig.maxTokens(), + llmConfig.temperature(), + llmConfig.timeoutSeconds(), + llmConfig.disableSystemProxy(), + llmConfig.sessionMaxTotal(), + llmConfig.sessionMaxPerProvider(), + reasoningEffort); + } + + private static void validateParseErrors(LlmConfig llmConfig) { + List errors = llmConfig.parseErrors(); + if (!errors.isEmpty()) { + throw new LLMConfigError("LLM configuration parse errors: " + String.join("; ", errors)); + } + } + + private static void validateRequiredKeys(LlmConfig llmConfig) { + List missing = new ArrayList<>(); + if (StringUtils.isBlank(llmConfig.provider())) { + missing.add("provider"); + } + if (StringUtils.isBlank(llmConfig.model())) { + missing.add("model"); + } + if (StringUtils.isBlank(llmConfig.apiKey())) { + missing.add("apiKey"); + } + if (!missing.isEmpty()) { + throw new LLMConfigError("Missing required LLM configuration keys: " + String.join(", ", missing)); + } + } + + private static void validateBounds(LlmConfig llmConfig) { + if (llmConfig.historyWindow() <= 0 || llmConfig.historyWindow() > MAX_HISTORY_WINDOW) { + throw new LLMConfigError( + "LLM configuration key 'historyWindow' value " + llmConfig.historyWindow() + + " must be between 1 and " + MAX_HISTORY_WINDOW); + } + if (llmConfig.sessionMaxTotal() <= 0 || llmConfig.sessionMaxTotal() > MAX_SESSION_TOTAL) { + throw new LLMConfigError( + "LLM configuration key 'sessionMaxTotal' value " + llmConfig.sessionMaxTotal() + + " must be between 1 and " + MAX_SESSION_TOTAL); + } + if (llmConfig.sessionMaxPerProvider() <= 0 || llmConfig.sessionMaxPerProvider() > MAX_SESSION_PER_PROVIDER) { + throw new LLMConfigError( + "LLM configuration key 'sessionMaxPerProvider' value " + llmConfig.sessionMaxPerProvider() + + " must be between 1 and " + MAX_SESSION_PER_PROVIDER); + } + } + + private static void validateSessionOrder(LlmConfig llmConfig) { + if (llmConfig.sessionMaxTotal() < llmConfig.sessionMaxPerProvider()) { + throw new LLMConfigError( + "LLM configuration key 'sessionMaxTotal' (" + llmConfig.sessionMaxTotal() + + ") must be >= 'sessionMaxPerProvider' (" + llmConfig.sessionMaxPerProvider() + ")"); + } + } + + private static String validateReasoningEffort(String rawValue) { + if (StringUtils.isBlank(rawValue)) { + return null; + } + String normalized = rawValue.trim().toLowerCase(Locale.ROOT); + if (!VALID_REASONING_EFFORTS.contains(normalized)) { + throw new LLMConfigError( + "Invalid reasoningEffort value '" + normalized + "'. Valid values: " + VALID_REASONING_EFFORTS); + } + return normalized; + } +} diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java deleted file mode 100644 index 8dbd35fa..00000000 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java +++ /dev/null @@ -1,175 +0,0 @@ -package net.openan.a2at.sdk.llm; - -import java.nio.file.Path; -import java.util.Map; -import java.util.Optional; -import java.util.OptionalDouble; -import java.util.OptionalInt; -import net.openan.a2at.sdk.core.model.DotEnvConfigSource; - -/** - * Loads LLM client configuration from `.env` files. - * - * @since 2026-06 - */ -public final class LLMConfigLoader { - - private static final int DEFAULT_HISTORY_WINDOW = 10; - - private static final int DEFAULT_SESSION_MAX_TOTAL = 300; - - private static final int DEFAULT_SESSION_MAX_PER_PROVIDER = 100; - - private static final int MAX_HISTORY_WINDOW = 100; - - private static final int MAX_SESSION_MAX_TOTAL = 3000; - - private static final int MAX_SESSION_MAX_PER_PROVIDER = 1000; - - /** Reasoning effort levels accepted by A2AT_LLM_REASONING_EFFORT (mirrors the OpenAI API enum). */ - private static final java.util.Set REASONING_EFFORT_VALUES = - java.util.Set.of("none", "minimal", "low", "medium", "high", "xhigh"); - - private LLMConfigLoader() {} - - public static LLMClientConfig load(Path envPath) { - Map values = DotEnvConfigSource.load(envPath); - String provider = required(values, "A2AT_LLM_PROVIDER"); - String model = required(values, "A2AT_LLM_MODEL"); - String apiKey = required(values, "A2AT_LLM_API_KEY"); - - int historyWindow = parseBoundedInt( - values.get("A2AT_LLM_HISTORY_WINDOW"), - "A2AT_LLM_HISTORY_WINDOW", - DEFAULT_HISTORY_WINDOW, - MAX_HISTORY_WINDOW); - int sessionMaxTotal = parseBoundedInt( - values.get("A2AT_LLM_SESSION_MAX_TOTAL"), - "A2AT_LLM_SESSION_MAX_TOTAL", - DEFAULT_SESSION_MAX_TOTAL, - MAX_SESSION_MAX_TOTAL); - int sessionMaxPerProvider = parseBoundedInt( - values.get("A2AT_LLM_SESSION_MAX_PER_PROVIDER"), - "A2AT_LLM_SESSION_MAX_PER_PROVIDER", - DEFAULT_SESSION_MAX_PER_PROVIDER, - MAX_SESSION_MAX_PER_PROVIDER); - if (sessionMaxTotal < sessionMaxPerProvider) { - throw new LLMConfigError("A2AT_LLM_SESSION_MAX_TOTAL must be greater than or equal to " - + "A2AT_LLM_SESSION_MAX_PER_PROVIDER"); - } - - OptionalInt maxTokens = parseOptionalInt(values.get("A2AT_LLM_MAX_TOKENS"), "A2AT_LLM_MAX_TOKENS"); - OptionalDouble temperature = parseOptionalDouble(values.get("A2AT_LLM_TEMPERATURE"), "A2AT_LLM_TEMPERATURE"); - OptionalDouble timeoutSeconds = - parseOptionalDouble(values.get("A2AT_LLM_TIMEOUT_SECONDS"), "A2AT_LLM_TIMEOUT_SECONDS"); - boolean disableSystemProxy = parseOptionalBoolean( - values.get("A2AT_LLM_DISABLE_SYSTEM_PROXY"), "A2AT_LLM_DISABLE_SYSTEM_PROXY", false); - - return new LLMClientConfig( - provider, - model, - apiKey, - optional(values.get("A2AT_LLM_BASE_URL")).orElse(null), - historyWindow, - maxTokens.isPresent() ? maxTokens.getAsInt() : null, - temperature.isPresent() ? temperature.getAsDouble() : null, - timeoutSeconds.isPresent() ? timeoutSeconds.getAsDouble() : null, - disableSystemProxy, - sessionMaxTotal, - sessionMaxPerProvider, - parseReasoningEffort(values.get("A2AT_LLM_REASONING_EFFORT"))); - } - - private static String required(Map values, String key) { - return optional(values.get(key)) - .orElseThrow(() -> new LLMConfigError( - "A2AT_LLM_PROVIDER, A2AT_LLM_MODEL, and A2AT_LLM_API_KEY must be set in the .env file")); - } - - private static Optional optional(String value) { - if (value == null) { - return Optional.empty(); - } - String trimmed = value.trim(); - return trimmed.isEmpty() ? Optional.empty() : Optional.of(trimmed); - } - - private static OptionalInt parseOptionalInt(String rawValue, String key) { - Optional value = optional(rawValue); - if (!value.isPresent()) { - return OptionalInt.empty(); - } - try { - return OptionalInt.of(Integer.parseInt(value.orElseThrow())); - } catch (NumberFormatException exception) { - throw new LLMConfigError(key + " must be an integer", exception); - } - } - - private static OptionalDouble parseOptionalDouble(String rawValue, String key) { - Optional value = optional(rawValue); - if (!value.isPresent()) { - return OptionalDouble.empty(); - } - try { - return OptionalDouble.of(Double.parseDouble(value.orElseThrow())); - } catch (NumberFormatException exception) { - throw new LLMConfigError(key + " must be a float", exception); - } - } - - private static boolean parseOptionalBoolean(String rawValue, String key, boolean defaultValue) { - Optional value = optional(rawValue); - if (!value.isPresent()) { - return defaultValue; - } - if ("true".equalsIgnoreCase(value.orElseThrow())) { - return true; - } - if ("false".equalsIgnoreCase(value.orElseThrow())) { - return false; - } - throw new LLMConfigError(key + " must be true or false"); - } - - private static int parseBoundedInt(String rawValue, String key, int defaultValue, int maxValue) { - Optional value = optional(rawValue); - if (!value.isPresent()) { - return defaultValue; - } - int parsed; - try { - parsed = Integer.parseInt(value.orElseThrow()); - } catch (NumberFormatException exception) { - throw new LLMConfigError(key + " must be an integer", exception); - } - if (parsed <= 0) { - throw new LLMConfigError(key + " must be greater than zero"); - } - if (parsed > maxValue) { - throw new LLMConfigError(key + " must be less than or equal to " + maxValue); - } - return parsed; - } - - /** - * Parses the optional reasoning effort level. - * - * @param rawValue raw value of A2AT_LLM_REASONING_EFFORT; null or blank means the key is not set - * @return the validated effort level, or null when the key is not set - * @throws LLMConfigError when the value is not one of the supported levels; failing at config load time beats - * discovering a bad value through a provider 400 on the first LLM call - */ - private static String parseReasoningEffort(String rawValue) { - Optional value = optional(rawValue); - if (!value.isPresent()) { - return null; - } - String effort = value.orElseThrow().toLowerCase(java.util.Locale.ROOT); - if (!REASONING_EFFORT_VALUES.contains(effort)) { - throw new LLMConfigError( - "A2AT_LLM_REASONING_EFFORT must be one of " + REASONING_EFFORT_VALUES + " but was " + effort); - } - return effort; - } -} diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java new file mode 100644 index 00000000..b96b7e8c --- /dev/null +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java @@ -0,0 +1,252 @@ +package net.openan.a2at.sdk.llm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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 java.util.List; +import net.openan.a2at.sdk.core.model.LlmConfig; +import org.junit.jupiter.api.Test; + +class LLMClientConfigFromTest { + + @Test + void derivesFullConfigCorrectly() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", "https://api.example.test/v1", + 20, 2048, 0.5d, 30.0d, false, "medium", 500, 200, 3, List.of()); + + LLMClientConfig config = LLMClientConfig.from(llmConfig); + + assertEquals("openai", config.provider()); + assertEquals("gpt-4o", config.model()); + assertEquals("sk-test", config.apiKey()); + assertEquals("https://api.example.test/v1", config.baseUrl()); + assertEquals(20, config.historyWindow()); + assertEquals(2048, config.maxTokens()); + assertEquals(0.5d, config.temperature()); + assertEquals(30.0d, config.timeoutSeconds()); + assertEquals(false, config.disableSystemProxy()); + assertEquals("medium", config.reasoningEffort()); + assertEquals(500, config.sessionMaxTotal()); + assertEquals(200, config.sessionMaxPerProvider()); + } + + @Test + void rejectsMissingProvider() { + LlmConfig llmConfig = new LlmConfig( + "", "gpt-4o", "sk-test", null, + 10, null, null, null, false, null, 300, 100, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("provider")); + } + + @Test + void rejectsMissingModel() { + LlmConfig llmConfig = new LlmConfig( + "openai", "", "sk-test", null, + 10, null, null, null, false, null, 300, 100, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("model")); + } + + @Test + void rejectsMissingApiKey() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "", null, + 10, null, null, null, false, null, 300, 100, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("apiKey")); + } + + @Test + void rejectsMultipleMissingKeysAndListsThemAll() { + LlmConfig llmConfig = new LlmConfig( + "", "", "", null, + 10, null, null, null, false, null, 300, 100, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("provider")); + assertTrue(error.getMessage().contains("model")); + assertTrue(error.getMessage().contains("apiKey")); + } + + @Test + void rejectsHistoryWindowAbove100() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 101, null, null, null, false, null, 300, 100, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("historyWindow")); + } + + @Test + void rejectsSessionMaxTotalAbove3000() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 10, null, null, null, false, null, 3001, 100, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("sessionMaxTotal")); + } + + @Test + void rejectsSessionMaxPerProviderAbove1000() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 10, null, null, null, false, null, 300, 1001, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("sessionMaxPerProvider")); + } + + @Test + void rejectsSessionMaxTotalSmallerThanSessionMaxPerProvider() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 10, null, null, null, false, null, 50, 100, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("sessionMaxTotal")); + assertTrue(error.getMessage().contains("sessionMaxPerProvider")); + } + + @Test + void derivesUnconfiguredOptionalsAsNull() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 10, null, null, null, false, null, 300, 100, 3, List.of()); + + LLMClientConfig config = LLMClientConfig.from(llmConfig); + + assertNull(config.baseUrl()); + assertNull(config.maxTokens()); + assertNull(config.temperature()); + assertNull(config.timeoutSeconds()); + assertEquals(false, config.disableSystemProxy()); + assertNull(config.reasoningEffort()); + } + + @Test + void normalizesReasoningEffortCase() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 10, null, null, null, false, "MEDIUM", 300, 100, 3, List.of()); + + LLMClientConfig config = LLMClientConfig.from(llmConfig); + + assertEquals("medium", config.reasoningEffort()); + } + + @Test + void rejectsUnknownReasoningEffortValue() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 10, null, null, null, false, "middle", 300, 100, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("reasoningEffort")); + assertTrue(error.getMessage().contains("middle")); + } + + @Test + void treatsBlankReasoningEffortAsNull() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 10, null, null, null, false, " ", 300, 100, 3, List.of()); + + LLMClientConfig config = LLMClientConfig.from(llmConfig); + + assertNull(config.reasoningEffort()); + } + + @Test + void treatsNullReasoningEffortAsNull() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 10, null, null, null, false, null, 300, 100, 3, List.of()); + + LLMClientConfig config = LLMClientConfig.from(llmConfig); + + assertNull(config.reasoningEffort()); + } + + @Test + void rejectsParseErrorsAndListsThemAll() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 10, null, null, null, false, null, 300, 100, 3, + List.of("A2AT_LLM_MAX_TOKENS: non-numeric value 'garbage'", + "A2AT_LLM_TEMPERATURE: non-numeric value 'xyz'")); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("A2AT_LLM_MAX_TOKENS")); + assertTrue(error.getMessage().contains("A2AT_LLM_TEMPERATURE")); + } + + @Test + void acceptsEmptyParseErrors() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 10, 2048, 0.5d, 30.0d, false, "medium", 500, 200, 3, + List.of()); + + LLMClientConfig config = LLMClientConfig.from(llmConfig); + + assertEquals("medium", config.reasoningEffort()); + assertEquals(2048, config.maxTokens()); + } + + @Test + void rejectsHistoryWindowZeroOrNegative() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 0, null, null, null, false, null, 300, 100, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("historyWindow")); + } + + @Test + void rejectsSessionMaxTotalZeroOrNegative() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 10, null, null, null, false, null, 0, 100, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("sessionMaxTotal")); + } + + @Test + void rejectsSessionMaxPerProviderZeroOrNegative() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, + 10, null, null, null, false, null, 300, 0, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("sessionMaxPerProvider")); + } + + @Test + void rejectsNullLlmConfig() { + assertThrows(NullPointerException.class, () -> LLMClientConfig.from(null)); + } +} \ No newline at end of file diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java deleted file mode 100644 index 27935693..00000000 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java +++ /dev/null @@ -1,200 +0,0 @@ -package net.openan.a2at.sdk.llm; - -import static org.junit.jupiter.api.Assertions.assertEquals; -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 java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class LLMConfigLoaderTest { - - @TempDir - Path tempDir; - - @Test - void loadsRequiredValuesAndDefaultsFromEnvFile() throws IOException { - Path envFile = writeEnv( - """ - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=gpt-4o-mini - A2AT_LLM_API_KEY=sk-test - """); - - LLMClientConfig config = LLMConfigLoader.load(envFile); - - assertEquals("openai", config.provider()); - assertEquals("gpt-4o-mini", config.model()); - assertEquals("sk-test", config.apiKey()); - assertNull(config.baseUrl()); - assertEquals(10, config.historyWindow()); - assertNull(config.maxTokens()); - assertNull(config.temperature()); - assertNull(config.timeoutSeconds()); - assertEquals(false, config.disableSystemProxy()); - assertEquals(300, config.sessionMaxTotal()); - assertEquals(100, config.sessionMaxPerProvider()); - } - - @Test - void loadsOptionalValuesFromEnvFile() throws IOException { - Path envFile = writeEnv( - """ - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=gpt-4o-mini - A2AT_LLM_API_KEY=sk-test - A2AT_LLM_BASE_URL=https://api.example.test/v1 - A2AT_LLM_MAX_TOKENS=2048 - A2AT_LLM_TEMPERATURE=0.25 - A2AT_LLM_TIMEOUT_SECONDS=45.5 - A2AT_LLM_DISABLE_SYSTEM_PROXY=true - A2AT_LLM_HISTORY_WINDOW=20 - A2AT_LLM_SESSION_MAX_TOTAL=500 - A2AT_LLM_SESSION_MAX_PER_PROVIDER=50 - """); - - LLMClientConfig config = LLMConfigLoader.load(envFile); - - assertEquals("https://api.example.test/v1", config.baseUrl()); - assertEquals(2048, config.maxTokens()); - assertEquals(0.25d, config.temperature()); - assertEquals(45.5d, config.timeoutSeconds()); - assertEquals(true, config.disableSystemProxy()); - assertEquals(20, config.historyWindow()); - assertEquals(500, config.sessionMaxTotal()); - assertEquals(50, config.sessionMaxPerProvider()); - } - - @Test - void rejectsMissingRequiredValues() throws IOException { - Path envFile = writeEnv( - """ - A2AT_LLM_PROVIDER=openai - A2AT_LLM_API_KEY=sk-test - """); - - LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMConfigLoader.load(envFile)); - - assertTrue(error.getMessage().contains("A2AT_LLM_PROVIDER")); - assertTrue(error.getMessage().contains("A2AT_LLM_MODEL")); - } - - @Test - void rejectsInvalidOptionalNumber() throws IOException { - Path envFile = writeEnv( - """ - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=gpt-4o-mini - A2AT_LLM_API_KEY=sk-test - A2AT_LLM_MAX_TOKENS=many - """); - - LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMConfigLoader.load(envFile)); - - assertTrue(error.getMessage().contains("A2AT_LLM_MAX_TOKENS")); - assertTrue(error.getMessage().contains("integer")); - } - - @Test - void rejectsInvalidDisableSystemProxyValue() throws IOException { - Path envFile = writeEnv(""" - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=gpt-4o-mini - A2AT_LLM_API_KEY=sk-test - A2AT_LLM_DISABLE_SYSTEM_PROXY=maybe - """); - - LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMConfigLoader.load(envFile)); - - assertTrue(error.getMessage().contains("A2AT_LLM_DISABLE_SYSTEM_PROXY")); - } - - @Test - void rejectsOutOfRangeReservedValues() throws IOException { - Path envFile = writeEnv( - """ - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=gpt-4o-mini - A2AT_LLM_API_KEY=sk-test - A2AT_LLM_HISTORY_WINDOW=101 - """); - - LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMConfigLoader.load(envFile)); - - assertTrue(error.getMessage().contains("A2AT_LLM_HISTORY_WINDOW")); - assertTrue(error.getMessage().contains("100")); - } - - @Test - void rejectsSessionTotalSmallerThanPerProviderLimit() throws IOException { - Path envFile = writeEnv( - """ - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=gpt-4o-mini - A2AT_LLM_API_KEY=sk-test - A2AT_LLM_SESSION_MAX_TOTAL=50 - A2AT_LLM_SESSION_MAX_PER_PROVIDER=100 - """); - - LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMConfigLoader.load(envFile)); - - assertTrue(error.getMessage().contains("A2AT_LLM_SESSION_MAX_TOTAL")); - assertTrue(error.getMessage().contains("A2AT_LLM_SESSION_MAX_PER_PROVIDER")); - } - - @Test - void loadsReasoningEffortAndNormalizesCase() throws IOException { - Path envFile = writeEnv( - """ - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=gpt-4o-mini - A2AT_LLM_API_KEY=sk-test - A2AT_LLM_REASONING_EFFORT=HIGH - """); - - LLMClientConfig config = LLMConfigLoader.load(envFile); - - assertEquals("high", config.reasoningEffort()); - } - - @Test - void leavesReasoningEffortUnsetWhenKeyIsBlank() throws IOException { - Path envFile = writeEnv( - """ - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=gpt-4o-mini - A2AT_LLM_API_KEY=sk-test - A2AT_LLM_REASONING_EFFORT= - """); - - LLMClientConfig config = LLMConfigLoader.load(envFile); - - assertNull(config.reasoningEffort()); - } - - @Test - void rejectsUnknownReasoningEffortValue() throws IOException { - Path envFile = writeEnv( - """ - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=gpt-4o-mini - A2AT_LLM_API_KEY=sk-test - A2AT_LLM_REASONING_EFFORT=middle - """); - - LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMConfigLoader.load(envFile)); - - assertTrue(error.getMessage().contains("A2AT_LLM_REASONING_EFFORT")); - assertTrue(error.getMessage().contains("middle")); - } - - private Path writeEnv(String content) throws IOException { - Path envFile = tempDir.resolve("llm.env"); - Files.writeString(envFile, content); - return envFile; - } -} diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMPublicExportsTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMPublicExportsTest.java index f16f757a..89a6de0b 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMPublicExportsTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMPublicExportsTest.java @@ -13,7 +13,6 @@ void simplifiedProviderExtensionSurfaceIsPubliclyLoadable() { List publicTypes = List.of( "net.openan.a2at.sdk.llm.LLMClient", "net.openan.a2at.sdk.llm.LLMClientFactory", - "net.openan.a2at.sdk.llm.LLMConfigLoader", "net.openan.a2at.sdk.llm.LLMClientConfig", "net.openan.a2at.sdk.llm.LLMResponse", "net.openan.a2at.sdk.llm.LLMError", diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LlmPackageStructureTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LlmPackageStructureTest.java index ddf78ca5..12a646b9 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LlmPackageStructureTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LlmPackageStructureTest.java @@ -22,7 +22,6 @@ void llmRootPackageExposesOnlySimplifiedPublicApi() throws IOException { "LLMClientConfig.java", "LLMClientFactory.java", "LLMConfigError.java", - "LLMConfigLoader.java", "LLMError.java", "LLMResponse.java", "LLMRuntimeError.java", diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index b9d28f1f..6e201f19 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.negotiation.generation; import java.nio.file.Path; +import net.openan.a2at.sdk.core.model.LlmConfig; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; @@ -8,6 +9,7 @@ import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationSemanticValidator; import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; +import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckerAdapter; import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; import net.openan.a2at.sdk.negotiation.validation.NegotiationValidationException; import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; @@ -26,15 +28,13 @@ */ public final class NegotiationGenerationOrchestratorBuilder { - private static final int DEFAULT_MAX_ATTEMPTS = 3; - private String language; private String localRootDir; private LLMClient llmClient; - private int maxAttempts = DEFAULT_MAX_ATTEMPTS; + private int maxAttempts = LlmConfig.DEFAULT_MAX_ATTEMPTS; private NegotiationTemplateLoader templateLoader; @@ -182,8 +182,10 @@ public NegotiationGenerationOrchestrator build() { complianceChecker != null ? complianceChecker : new DefaultNegotiationComplianceChecker(); NegotiationSemanticValidator effectiveSemanticValidator = semanticValidator != null ? semanticValidator : defaultSemanticValidator(); + NegotiationRuleCheckerAdapter ruleCheckerAdapter = + new NegotiationRuleCheckerAdapter(effectiveComplianceChecker, vocabulary); ParamExtractor paramExtractor = - new ParamExtractor(effectiveComplianceChecker, effectiveSemanticValidator, maxAttempts); + new ParamExtractor(ruleCheckerAdapter, effectiveSemanticValidator, maxAttempts); return new NegotiationGenerationOrchestrator( language, maxAttempts, diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLlmInstaller.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLlmInstaller.java index 21f07625..45463cb2 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLlmInstaller.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLlmInstaller.java @@ -52,8 +52,9 @@ private SampleMockLlmInstaller() {} * *

      The mock fallback is needed when no usable real LLM configuration is present: a missing, * blank, or placeholder {@code A2AT_LLM_API_KEY}, or a missing/blank {@code A2AT_LLM_MODEL} or - * {@code A2AT_LLM_BASE_URL}. When the environment file lacks any of these, the SDK's - * {@code LLMConfigLoader} would fail at startup, so the mock is installed instead. +* {@code A2AT_LLM_BASE_URL}. When the environment file lacks any of these, the SDK's + * {@code LLMClientConfig.from(LlmConfig)} would fail at startup with an {@code LLMConfigError}, + * so the mock is installed instead. * * @param envPath sample environment file path * @return {@code true} when no usable real LLM configuration is present @@ -84,7 +85,6 @@ public static synchronized void installLlmLogger(boolean mockEnabled, String rol if (mockInstalled) { return; } - SampleLoggingLLMClient.configure(mockEnabled, roleLabel, System.out::println); try { Field clientsField = LLMClientFactory.class.getDeclaredField("CLIENTS"); clientsField.setAccessible(true); @@ -92,6 +92,7 @@ public static synchronized void installLlmLogger(boolean mockEnabled, String rol Map> clients = (Map>) clientsField.get(null); clients.put(OPENAI_PROVIDER, SampleLoggingLLMClient.class); + SampleLoggingLLMClient.configure(mockEnabled, roleLabel, System.out::println); mockInstalled = true; } catch (ReflectiveOperationException exception) { throw new ValueErrorException("Failed to install logging LLM client", exception); diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index d17a4821..00734d12 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -8,7 +8,6 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMConfigLoader; import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; @@ -41,6 +40,10 @@ public final class DefaultA2ATServerBuilder { private Path envPath; + private LLMClient llmClient; + + private DefaultServerPromptComplianceOrchestrator promptComplianceOrchestrator; + /** * Creates one new builder instance. * @@ -77,17 +80,20 @@ public DefaultA2ATServerBuilder envPath(Path envPath) { * * @return assembled prompt-compliance orchestrator */ - public DefaultServerPromptComplianceOrchestrator buildPromptComplianceOrchestrator() { + public synchronized DefaultServerPromptComplianceOrchestrator buildPromptComplianceOrchestrator() { require(config, "Unified SDK config must be configured."); require(envPath, "Unified SDK env path must be configured."); requireSupportedConfig(); + if (promptComplianceOrchestrator != null) { + return promptComplianceOrchestrator; + } PromptResourceAccess resources = PromptResourceAccess.create(config.prompt()); String language = config.prompt().language(); List scenarios = resources.loadScenarios(language); PromptTemplateTextLoader templateLoader = resources.templateLoader(); PromptSlotSchemaLoader slotSchemaLoader = resources.slotSchemaLoader(); - LLMClient llmClient = createLlmClient(); + LLMClient client = llmClient(); String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); @@ -96,9 +102,9 @@ public DefaultServerPromptComplianceOrchestrator buildPromptComplianceOrchestrat String semanticSystemPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "system.md"); String semanticUserPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "user.md"); - return new DefaultServerPromptComplianceOrchestrator( + promptComplianceOrchestrator = new DefaultServerPromptComplianceOrchestrator( new LlmBackedPromptMetadataExtractor( - new ScenarioRecognizer(llmClient), + new ScenarioRecognizer(client), scenarios, language, scenarioSystemPrompt, @@ -106,9 +112,10 @@ public DefaultServerPromptComplianceOrchestrator buildPromptComplianceOrchestrat templateLoader, slotSchemaLoader, new DefaultStructuredPromptSlotValueExtractor( - llmClient, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)), + client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)), new LlmBackedPromptSemanticValidator( - llmClient, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt)); + client, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt)); + return promptComplianceOrchestrator; } /** @@ -137,7 +144,7 @@ public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator( require(config, "Unified SDK config must be configured."); requireSupportedConfig(); require(envPath, "Unified SDK env path must be configured."); - return NegotiationContentService.buildOrchestrator(config, createLlmClient()); + return NegotiationContentService.buildOrchestrator(config, llmClient()); } /** @@ -163,15 +170,7 @@ public TemplateQueryService buildTemplateQueryService() { * @return assembled task content validator */ public ContentValidator buildTaskContentValidator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - require(envPath, "Unified SDK env path must be configured."); - return new DefaultContentValidator( - StandardTemplates.TASK_EXTENSION_NAME, - config.prompt().language(), - config.llm().maxAttempts(), - createLlmClient(), - PromptResourceAccess.create(config.prompt())); + return buildContentValidator(StandardTemplates.TASK_EXTENSION_NAME); } /** @@ -183,15 +182,7 @@ public ContentValidator buildTaskContentValidator() { * @return assembled notification content validator */ public ContentValidator buildNotificationContentValidator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - require(envPath, "Unified SDK env path must be configured."); - return new DefaultContentValidator( - StandardTemplates.NOTIFICATION_EXTENSION_NAME, - config.prompt().language(), - config.llm().maxAttempts(), - createLlmClient(), - PromptResourceAccess.create(config.prompt())); + return buildContentValidator(StandardTemplates.NOTIFICATION_EXTENSION_NAME); } /** @@ -203,14 +194,18 @@ public ContentValidator buildNotificationContentValidator() { * @return assembled authorization content validator */ public ContentValidator buildAuthContentValidator() { + return buildContentValidator(StandardTemplates.AUTHORIZATION_EXTENSION_NAME); + } + + private ContentValidator buildContentValidator(String extensionName) { require(config, "Unified SDK config must be configured."); requireSupportedConfig(); require(envPath, "Unified SDK env path must be configured."); return new DefaultContentValidator( - StandardTemplates.AUTHORIZATION_EXTENSION_NAME, + extensionName, config.prompt().language(), config.llm().maxAttempts(), - createLlmClient(), + llmClient(), PromptResourceAccess.create(config.prompt())); } @@ -235,9 +230,11 @@ private void requireSupportedConfig() { } } - private LLMClient createLlmClient() { - LLMClientConfig loadedConfig = LLMConfigLoader.load(envPath); - return LLMClientFactory.create(loadedConfig.provider(), loadedConfig); + private synchronized LLMClient llmClient() { + if (llmClient == null) { + llmClient = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); + } + return llmClient; } } \ No newline at end of file diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java new file mode 100644 index 00000000..154954e8 --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java @@ -0,0 +1,180 @@ +package net.openan.a2at.sdk.server.assembly; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.LlmConfig; +import net.openan.a2at.sdk.core.model.NegotiationConfig; +import net.openan.a2at.sdk.core.model.PromptComplianceConfig; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.server.compliance.DefaultServerPromptComplianceOrchestrator; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DefaultA2ATServerBuilderTest { + + private static final String TEST_MOCK_PROVIDER = "test-builder-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, CountingRecorderClient.class); + } + } + + @BeforeEach + void resetCounter() { + CountingRecorderClient.reset(); + } + + @TempDir + Path tempDir; + + @Test + void llmClientCreatedOnlyOnceAcrossMultipleBuildMethods() throws IOException { + Path envFile = writeEnv(); + A2ATConfig config = A2ATConfig.load(envFile); + DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder() + .config(config) + .envPath(envFile); + + builder.buildPromptComplianceOrchestrator(); + builder.buildNegotiationGenerationOrchestrator(); + builder.buildTaskContentValidator(); + builder.buildNotificationContentValidator(); + builder.buildAuthContentValidator(); + builder.buildNegotiationOrchestrator(); + + assertEquals(1, CountingRecorderClient.instanceCount()); + } + + @Test + void buildNegotiationOrchestratorReusesCachedComplianceOrchestrator() throws IOException { + Path envFile = writeEnv(); + A2ATConfig config = A2ATConfig.load(envFile); + DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder() + .config(config) + .envPath(envFile); + + DefaultServerPromptComplianceOrchestrator first = builder.buildPromptComplianceOrchestrator(); + DefaultServerPromptComplianceOrchestrator second = builder.buildPromptComplianceOrchestrator(); + assertSame(first, second, "Compliance orchestrator should be lazily cached"); + + builder.buildNegotiationOrchestrator(); + + assertEquals(1, CountingRecorderClient.instanceCount(), + "LLM client should still be created only once after negotiation orchestrator build"); + } + + @Test + void llmClientConfigDerivedFromConfigLlmNotFromEnvPath() throws IOException { + Path envFile = writeEnvWithDifferentModel(); + A2ATConfig config = new A2ATConfig( + new PromptRuntimeConfig("zh-CN", "classpath", "."), + new LlmConfig(TEST_MOCK_PROVIDER, "from-config-model", "from-config-key", + "https://from-config.example.com", 10, null, null, null, false, null, 300, 100, 3, + List.of()), + new NegotiationConfig("in_memory"), + new PromptComplianceConfig(false)); + + DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder() + .config(config) + .envPath(envFile); + + builder.buildPromptComplianceOrchestrator(); + + assertEquals(1, CountingRecorderClient.instanceCount()); + LLMClientConfig recorded = CountingRecorderClient.lastInstance().recordedConfig(); + assertEquals("from-config-model", recorded.model(), + "Model should come from config.llm(), not from envPath"); + assertEquals("from-config-key", recorded.apiKey(), + "API key should come from config.llm(), not from envPath"); + assertEquals(LLMClientConfig.from(config.llm()), recorded, + "Recorded config should equal LLMClientConfig.from(config.llm())"); + } + + private Path writeEnv() throws IOException { + Path envFile = tempDir.resolve("server.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-builder-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """); + return envFile; + } + + private Path writeEnvWithDifferentModel() throws IOException { + Path envFile = tempDir.resolve("alt.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-builder-mock + A2AT_LLM_MODEL=from-env-model + A2AT_LLM_API_KEY=from-env-key + A2AT_LLM_BASE_URL=https://from-env.example.com + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """); + return envFile; + } + + public static final class CountingRecorderClient implements LLMClient { + + private static final AtomicInteger INSTANCE_COUNT = new AtomicInteger(0); + + private static volatile CountingRecorderClient lastInstance; + + private final LLMClientConfig config; + + public CountingRecorderClient(LLMClientConfig config) { + this.config = config; + lastInstance = this; + INSTANCE_COUNT.incrementAndGet(); + } + + static void reset() { + INSTANCE_COUNT.set(0); + lastInstance = null; + } + + static int instanceCount() { + return INSTANCE_COUNT.get(); + } + + static CountingRecorderClient lastInstance() { + return lastInstance; + } + + LLMClientConfig recordedConfig() { + return config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} \ No newline at end of file From d5db8eb27a5c6f2ff1f17fe02708fc3f2b9deadf Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sun, 23 Aug 2026 10:43:54 +0800 Subject: [PATCH 146/295] refactor: remove client bridge layer, rule extractor and dead classes; unify slot extraction on LLM path (TODO-5, TODO-7, TODO-9) Signed-off-by: w00609601 --- .../openan/a2at/sdk/client/A2ATClient.java | 13 +- ...ntPromptGenerationOrchestratorBuilder.java | 52 +- .../assembly/DefaultA2ATClientBuilder.java | 98 +--- ...ntPromptGenerationOrchestratorFactory.java | 66 --- .../extractor/ClientSlotValueExtractor.java | 39 -- ...ultStructuredClientSlotValueExtractor.java | 50 -- ...faultTemplateDrivenSlotValueExtractor.java | 117 ---- .../prompt/loader/ClientSlotSchemaLoader.java | 21 - .../prompt/loader/ClientTemplateLoader.java | 19 - ...efaultClasspathClientSlotSchemaLoader.java | 29 - .../DefaultClasspathClientTemplateLoader.java | 28 - .../LocalFileClientSlotSchemaLoader.java | 28 - .../loader/LocalFileClientTemplateLoader.java | 27 - ...ultClientPromptGenerationOrchestrator.java | 539 +++++++++--------- .../recognition/ClientScenarioRecognizer.java | 26 - .../LocalFileClientScenarioCatalogLoader.java | 34 -- .../a2at/sdk/client/api/A2ATClientTest.java | 201 ++++++- ...entPromptAssemblyPackageStructureTest.java | 22 +- ...omptGenerationOrchestratorBuilderTest.java | 40 +- .../DefaultA2ATClientBuilderTest.java | 67 +-- ...omptGenerationOrchestratorFactoryTest.java | 72 --- .../ClientSlotValueExtractorTest.java | 20 - ...tructuredClientSlotValueExtractorTest.java | 170 ------ ...tTemplateDrivenSlotValueExtractorTest.java | 190 ------ ...ltClasspathClientSlotSchemaLoaderTest.java | 52 -- ...aultClasspathClientTemplateLoaderTest.java | 31 - .../LocalFileClientSlotSchemaLoaderTest.java | 33 -- ...lientPromptGenerationOrchestratorTest.java | 224 +++++--- ...alFileClientScenarioCatalogLoaderTest.java | 23 - .../impl/ScenarioRecognitionFunction.java | 21 + .../analysis/impl/ScenarioRecognizer.java | 3 +- .../analysis/impl/ScenarioRecognizerTest.java | 10 +- 32 files changed, 733 insertions(+), 1632 deletions(-) delete mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultClientPromptGenerationOrchestratorFactory.java delete mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractor.java delete mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractor.java delete mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractor.java delete mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/ClientSlotSchemaLoader.java delete mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/ClientTemplateLoader.java delete mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoader.java delete mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientTemplateLoader.java delete mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoader.java delete mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientTemplateLoader.java delete mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/recognition/ClientScenarioRecognizer.java delete mode 100644 a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoader.java delete mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultClientPromptGenerationOrchestratorFactoryTest.java delete mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractorTest.java delete mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java delete mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java delete mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoaderTest.java delete mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientTemplateLoaderTest.java delete mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoaderTest.java delete mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java create mode 100644 a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognitionFunction.java diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 3266e2be..f0d1aa4c 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -25,8 +25,8 @@ import org.jspecify.annotations.NonNull; /** - * High-level client facade for prompt generation and negotiation APIs. The caller provides the `.env` file path - * explicitly, typically after copying the repository `env.example`. + * High-level client facade for prompt generation and negotiation APIs. The caller provides the {@code .env} file path + * explicitly, typically after copying the repository {@code env.example}. * * @since 2026-06 */ @@ -41,9 +41,9 @@ public final class A2ATClient { private final TemplateQueryService templateQueryService; /** - * Creates a client facade from one user-supplied `.env` path. + * Creates a client facade from one user-supplied {@code .env} path. * - * @param envPath user-supplied `.env` file path + * @param envPath user-supplied {@code .env} file path */ public A2ATClient(Path envPath) { Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); @@ -59,9 +59,10 @@ public A2ATClient(Path envPath) { } /** - * Generates a processed task prompt from raw user input. + * Generates a processed task prompt from raw user input. Both text and structured input ({@code Map}) are unified + * through LLM extraction; there is no zero-LLM rule shortcut. * - * @param userInput user-provided task description or structured input map + * @param userInput user-provided task description (text or structured input map) * @return prompt generation result containing either rendered prompt text or failure details */ public PromptGenerationResult generateTaskPrompt(Object userInput) { diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java index 3ca331a9..be7f77b8 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java @@ -1,18 +1,16 @@ package net.openan.a2at.sdk.client.prompt.assembly; import java.util.List; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; -import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; -import net.openan.a2at.sdk.client.prompt.extractor.DefaultStructuredClientSlotValueExtractor; -import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; -import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; -import net.openan.a2at.sdk.client.prompt.loader.DefaultClasspathClientSlotSchemaLoader; -import net.openan.a2at.sdk.client.prompt.loader.DefaultClasspathClientTemplateLoader; import net.openan.a2at.sdk.client.prompt.orchestration.DefaultClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognitionFunction; import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; +import net.openan.a2at.sdk.prompt.resources.loader.ClasspathPromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.ClasspathPromptTemplateLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; @@ -22,9 +20,10 @@ * * @since 2026-06 */ -@NoArgsConstructor(access = AccessLevel.PRIVATE) public final class ClientPromptGenerationOrchestratorBuilder { + private ClientPromptGenerationOrchestratorBuilder() {} + private LLMClient llmClient; private List scenarios; @@ -39,13 +38,13 @@ public final class ClientPromptGenerationOrchestratorBuilder { private String slotUserPrompt; - private ClientScenarioRecognizer scenarioRecognizer; + private ScenarioRecognitionFunction scenarioRecognizer; - private ClientTemplateLoader templateLoader; + private PromptTemplateTextLoader templateLoader; - private ClientSlotSchemaLoader slotSchemaLoader; + private PromptSlotSchemaLoader slotSchemaLoader; - private ClientSlotValueExtractor slotValueExtractor; + private PromptSlotValueExtractor slotValueExtractor; private TaskPromptRenderer renderer; @@ -143,7 +142,8 @@ public ClientPromptGenerationOrchestratorBuilder slotUserPrompt(String slotUserP * @param scenarioRecognizer scenario recognizer implementation * @return current builder */ - public ClientPromptGenerationOrchestratorBuilder scenarioRecognizer(ClientScenarioRecognizer scenarioRecognizer) { + public ClientPromptGenerationOrchestratorBuilder scenarioRecognizer( + ScenarioRecognitionFunction scenarioRecognizer) { this.scenarioRecognizer = scenarioRecognizer; return this; } @@ -154,7 +154,7 @@ public ClientPromptGenerationOrchestratorBuilder scenarioRecognizer(ClientScenar * @param templateLoader template loader implementation * @return current builder */ - public ClientPromptGenerationOrchestratorBuilder templateLoader(ClientTemplateLoader templateLoader) { + public ClientPromptGenerationOrchestratorBuilder templateLoader(PromptTemplateTextLoader templateLoader) { this.templateLoader = templateLoader; return this; } @@ -165,7 +165,7 @@ public ClientPromptGenerationOrchestratorBuilder templateLoader(ClientTemplateLo * @param slotSchemaLoader slot schema loader implementation * @return current builder */ - public ClientPromptGenerationOrchestratorBuilder slotSchemaLoader(ClientSlotSchemaLoader slotSchemaLoader) { + public ClientPromptGenerationOrchestratorBuilder slotSchemaLoader(PromptSlotSchemaLoader slotSchemaLoader) { this.slotSchemaLoader = slotSchemaLoader; return this; } @@ -176,7 +176,7 @@ public ClientPromptGenerationOrchestratorBuilder slotSchemaLoader(ClientSlotSche * @param slotValueExtractor slot extractor implementation * @return current builder */ - public ClientPromptGenerationOrchestratorBuilder slotValueExtractor(ClientSlotValueExtractor slotValueExtractor) { + public ClientPromptGenerationOrchestratorBuilder slotValueExtractor(PromptSlotValueExtractor slotValueExtractor) { this.slotValueExtractor = slotValueExtractor; return this; } @@ -209,6 +209,7 @@ public ClientPromptGenerationOrchestratorBuilder resourceLoader(ClasspathPromptR * @return fully assembled orchestrator with all required dependencies resolved */ public DefaultClientPromptGenerationOrchestrator build() { + require(llmClient, "LLM client must be configured."); require(scenarios, "Scenario definitions must be configured."); require(language, "Prompt language must be configured."); require(scenarioSystemPrompt, "Scenario system prompt must be configured."); @@ -220,17 +221,16 @@ public DefaultClientPromptGenerationOrchestrator build() { // defaults. ClasspathPromptResourceLoader effectiveResourceLoader = resourceLoader == null ? new ClasspathPromptResourceLoader() : resourceLoader; - ClientTemplateLoader effectiveTemplateLoader = templateLoader == null - ? new DefaultClasspathClientTemplateLoader(effectiveResourceLoader) - : templateLoader; - ClientSlotSchemaLoader effectiveSlotSchemaLoader = slotSchemaLoader == null - ? new DefaultClasspathClientSlotSchemaLoader(effectiveResourceLoader) + PromptTemplateTextLoader effectiveTemplateLoader = + templateLoader == null ? new ClasspathPromptTemplateLoader(effectiveResourceLoader) : templateLoader; + PromptSlotSchemaLoader effectiveSlotSchemaLoader = slotSchemaLoader == null + ? new ClasspathPromptSlotSchemaLoader(effectiveResourceLoader) : slotSchemaLoader; - ClientSlotValueExtractor effectiveSlotValueExtractor = slotValueExtractor == null - ? new DefaultStructuredClientSlotValueExtractor( + PromptSlotValueExtractor effectiveSlotValueExtractor = slotValueExtractor == null + ? new DefaultStructuredPromptSlotValueExtractor( llmClient, effectiveSlotSchemaLoader, slotSystemPrompt, slotUserPrompt) : slotValueExtractor; - ClientScenarioRecognizer effectiveScenarioRecognizer = + ScenarioRecognitionFunction effectiveScenarioRecognizer = scenarioRecognizer == null ? new ScenarioRecognizer(llmClient)::recognize : scenarioRecognizer; TaskPromptRenderer effectiveRenderer = renderer == null ? new TaskPromptRenderer() : renderer; diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index d9180594..33e61fae 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -2,18 +2,7 @@ import java.nio.file.Path; import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; -import net.openan.a2at.sdk.client.prompt.extractor.DefaultStructuredClientSlotValueExtractor; -import net.openan.a2at.sdk.client.prompt.extractor.DefaultTemplateDrivenSlotValueExtractor; -import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; -import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; -import net.openan.a2at.sdk.client.prompt.loader.DefaultClasspathClientSlotSchemaLoader; -import net.openan.a2at.sdk.client.prompt.loader.DefaultClasspathClientTemplateLoader; -import net.openan.a2at.sdk.client.prompt.loader.LocalFileClientSlotSchemaLoader; -import net.openan.a2at.sdk.client.prompt.loader.LocalFileClientTemplateLoader; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; @@ -24,10 +13,15 @@ import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; import net.openan.a2at.sdk.negotiation.types.model.NegotiationRole; +import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognitionFunction; import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; @@ -93,10 +87,8 @@ public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { List scenarios = resources.loadScenarios(config.prompt().language()); - ClientSlotSchemaLoader slotSchemaLoader = - newClientSlotSchemaLoader(resources, config.prompt().sourceType()); - ClientTemplateLoader templateLoader = - newClientTemplateLoader(resources, config.prompt().sourceType()); + PromptSlotSchemaLoader slotSchemaLoader = resources.slotSchemaLoader(); + PromptTemplateTextLoader templateLoader = resources.templateLoader(); String language = config.prompt().language(); LLMClient client = llmClient(); @@ -106,12 +98,10 @@ public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); - ClientScenarioRecognizer scenarioRecognizer = + ScenarioRecognitionFunction scenarioRecognizer = new SingleScenarioAwareRecognizer(scenarios, new ScenarioRecognizer(client)::recognize); - ClientSlotValueExtractor slotValueExtractor = new StructuredInputAwareSlotValueExtractor( - new DefaultTemplateDrivenSlotValueExtractor(slotSchemaLoader), - new DefaultStructuredClientSlotValueExtractor( - client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)); + PromptSlotValueExtractor slotValueExtractor = new DefaultStructuredPromptSlotValueExtractor( + client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt); return ClientPromptGenerationOrchestratorBuilder.builder() .llmClient(client) @@ -163,15 +153,16 @@ public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator( /** * Builds the generic template query service from the configured unified SDK config. * - *

      The service answers the extension-agnostic template queries: the message language and the local template - * root come from the prompt runtime config, exactly like the negotiation generation orchestrator wiring. + *

      The service answers the extension-agnostic template queries: the message language and the local template root + * come from the prompt runtime config, exactly like the negotiation generation orchestrator wiring. * * @return assembled template query service */ public TemplateQueryService buildTemplateQueryService() { require(config, "Unified SDK config must be configured."); requireSupportedConfig(); - return new TemplateQueryService(config.prompt().language(), config.prompt().localRootDir()); + return new TemplateQueryService( + config.prompt().language(), config.prompt().localRootDir()); } private void requireSupportedConfig() { @@ -204,60 +195,25 @@ private static void require(Object value, String message) { } } - private static ClientTemplateLoader newClientTemplateLoader(PromptResourceAccess resources, String sourceType) { - if (PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(sourceType)) { - return new DefaultClasspathClientTemplateLoader(resources.classpathResourceLoader()); - } - if (PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals(sourceType)) { - return new LocalFileClientTemplateLoader(resources.localRootDir()); - } - throw new UnsupportedOperationException("Unsupported prompt source type: " + sourceType); - } + private static final class SingleScenarioAwareRecognizer implements ScenarioRecognitionFunction { - private static ClientSlotSchemaLoader newClientSlotSchemaLoader(PromptResourceAccess resources, String sourceType) { - if (PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(sourceType)) { - return new DefaultClasspathClientSlotSchemaLoader(resources.classpathResourceLoader()); - } - if (PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals(sourceType)) { - return new LocalFileClientSlotSchemaLoader(resources.localRootDir()); - } - throw new UnsupportedOperationException("Unsupported prompt source type: " + sourceType); - } + private final List scenarios; - private record SingleScenarioAwareRecognizer(List scenarios, ClientScenarioRecognizer delegate) - implements ClientScenarioRecognizer { + private final ScenarioRecognitionFunction delegate; - @Override - public ScenarioRecognitionResult recognize( - String normalizedInput, - List ignoredScenarios, - String systemPrompt, - String userPrompt) { - if (scenarios.size() == 1) { - return new ScenarioRecognitionResult(true, scenarios.get(0).scenarioCode(), null); - } - return delegate.recognize(normalizedInput, scenarios, systemPrompt, userPrompt); + private SingleScenarioAwareRecognizer( + List scenarios, ScenarioRecognitionFunction delegate) { + this.scenarios = scenarios; + this.delegate = delegate; } - } - - private record StructuredInputAwareSlotValueExtractor( - ClientSlotValueExtractor structuredExtractor, ClientSlotValueExtractor llmExtractor) - implements ClientSlotValueExtractor { @Override - public Map extractSlots( - Object userInput, String scenarioCode, String language, String templateText) { - if (userInput instanceof Map) { - return structuredExtractor.extractSlots(userInput, scenarioCode, language, templateText); + public ScenarioRecognitionResult recognize( + String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { + if (this.scenarios.size() == 1) { + return new ScenarioRecognitionResult(true, this.scenarios.get(0).scenarioCode(), null); } - return llmExtractor.extractSlots(userInput, scenarioCode, language, templateText); - } - - @Override - public Map extractSlotsWithSchema( - Object userInput, String scenarioCode, String language, String templateText, - Map dataSchema) { - return llmExtractor.extractSlotsWithSchema(userInput, scenarioCode, language, templateText, dataSchema); + return delegate.recognize(normalizedInput, this.scenarios, systemPrompt, userPrompt); } } -} \ No newline at end of file +} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultClientPromptGenerationOrchestratorFactory.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultClientPromptGenerationOrchestratorFactory.java deleted file mode 100644 index 3e4a1fd9..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultClientPromptGenerationOrchestratorFactory.java +++ /dev/null @@ -1,66 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.assembly; - -import java.util.List; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; -import net.openan.a2at.sdk.client.prompt.orchestration.DefaultClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.model.PromptGenerationConfig; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; - -/** - * Default assembly entry point for client prompt generation orchestration. - * - * @since 2026-06 - */ -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public final class DefaultClientPromptGenerationOrchestratorFactory { - - /** - * Creates the default client prompt-generation orchestrator from the minimum required inputs. - * - * @param llmClient LLM client used for scenario recognition and slot extraction - * @param scenarios supported scenario definitions - * @param language locale identifier to load resources from - * @param scenarioSystemPrompt system prompt for scenario recognition - * @param scenarioUserPrompt user prompt for scenario recognition - * @param slotSystemPrompt system prompt for slot extraction - * @param slotUserPrompt user prompt for slot extraction - * @return assembled default client prompt-generation orchestrator - */ - public static DefaultClientPromptGenerationOrchestrator create( - LLMClient llmClient, - List scenarios, - String language, - String scenarioSystemPrompt, - String scenarioUserPrompt, - String slotSystemPrompt, - String slotUserPrompt) { - return create( - llmClient, - scenarios, - new PromptGenerationConfig( - language, scenarioSystemPrompt, scenarioUserPrompt, slotSystemPrompt, slotUserPrompt)); - } - - /** - * Creates the default client prompt-generation orchestrator from one shared prompt-generation config bundle. - * - * @param llmClient LLM client used for scenario recognition and slot extraction - * @param scenarios supported scenario definitions - * @param config shared prompt-generation config bundle - * @return assembled default client prompt-generation orchestrator - */ - public static DefaultClientPromptGenerationOrchestrator create( - LLMClient llmClient, List scenarios, PromptGenerationConfig config) { - return ClientPromptGenerationOrchestratorBuilder.builder() - .llmClient(llmClient) - .scenarios(scenarios) - .language(config.language()) - .scenarioSystemPrompt(config.scenarioSystemPrompt()) - .scenarioUserPrompt(config.scenarioUserPrompt()) - .slotSystemPrompt(config.slotSystemPrompt()) - .slotUserPrompt(config.slotUserPrompt()) - .build(); - } -} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractor.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractor.java deleted file mode 100644 index b67fe804..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractor.java +++ /dev/null @@ -1,39 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.extractor; - -import java.util.Map; - -/** - * Extracts slot values for one scenario and language. - * - * @since 2026-06 - */ -@FunctionalInterface -public interface ClientSlotValueExtractor { - - /** - * Extracts normalized slot values for the supplied template context. - * - * @param userInput user-provided task description or structured input object - * @param scenarioCode scenario code currently being rendered - * @param language locale identifier of the backing resources - * @param templateText resolved task template text - * @return normalized slot values keyed by slot name - */ - Map extractSlots(Object userInput, String scenarioCode, String language, String templateText); - - /** - * Extracts normalized slot values with an optional data schema for schema-guided extraction. - * - * @param userInput user-provided task description or structured input object - * @param scenarioCode scenario code currently being rendered - * @param language locale identifier of the backing resources - * @param templateText resolved task template text - * @param dataSchema optional data schema map (field name to description) - * @return normalized slot values keyed by slot name - */ - default Map extractSlotsWithSchema( - Object userInput, String scenarioCode, String language, String templateText, - Map dataSchema) { - return extractSlots(userInput, scenarioCode, language, templateText); - } -} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractor.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractor.java deleted file mode 100644 index 90912c74..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractor.java +++ /dev/null @@ -1,50 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.extractor; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; - -/** - * LLM-backed default slot extractor that requests structured slot payloads. - * - * @since 2026-06 - */ -public final class DefaultStructuredClientSlotValueExtractor implements ClientSlotValueExtractor { - - private final DefaultStructuredPromptSlotValueExtractor delegate; - - /** - * Creates an LLM-backed slot extractor. - * - * @param llmClient LLM client used for slot extraction - * @param slotSchemaLoader slot schema loader - * @param systemPrompt system prompt for extraction - * @param userPrompt user prompt for extraction - */ - public DefaultStructuredClientSlotValueExtractor( - LLMClient llmClient, ClientSlotSchemaLoader slotSchemaLoader, String systemPrompt, String userPrompt) { - this.delegate = new DefaultStructuredPromptSlotValueExtractor( - llmClient, - (scenarioCode, language) -> slotSchemaLoader.loadSlotSchema(scenarioCode, language), - systemPrompt, - userPrompt); - } - - @Override - public Map extractSlots( - Object userInput, String scenarioCode, String language, String templateText) { - StructuredSlotExtractionResult result = delegate.extractSlots(userInput, scenarioCode, language); - return new LinkedHashMap<>(result.slots()); - } - - @Override - public Map extractSlotsWithSchema( - Object userInput, String scenarioCode, String language, String templateText, - Map dataSchema) { - StructuredSlotExtractionResult result = delegate.extractSlots(userInput, scenarioCode, language, dataSchema); - return new LinkedHashMap<>(result.slots()); - } -} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractor.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractor.java deleted file mode 100644 index 4f1bc4f7..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractor.java +++ /dev/null @@ -1,117 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.extractor; - -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; -import net.openan.a2at.sdk.client.prompt.loader.DefaultClasspathClientSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; - -/** - * Minimal slot extractor that derives output keys from slot schema and applies lightweight constraints. - * - * @since 2026-06 - */ -public final class DefaultTemplateDrivenSlotValueExtractor implements ClientSlotValueExtractor { - - private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{?\\s*([^{}]+?)\\s*\\}\\}?"); - - private final ClientSlotSchemaLoader slotSchemaLoader; - - /** - * Creates a default schema-driven extractor backed by packaged slot resources. - */ - public DefaultTemplateDrivenSlotValueExtractor() { - this(new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader())); - } - - /** - * Creates a schema-driven extractor backed by the provided slot schema loader. - * - * @param slotSchemaLoader slot schema loader - */ - public DefaultTemplateDrivenSlotValueExtractor(ClientSlotSchemaLoader slotSchemaLoader) { - this.slotSchemaLoader = slotSchemaLoader; - } - - @Override - public Map extractSlots( - Object userInput, String scenarioCode, String language, String templateText) { - Map slots = new LinkedHashMap<>(); - Map templateSlotNames = referencedTemplateSlots(templateText); - PromptSlotSchema schema = slotSchemaLoader.loadSlotSchema(scenarioCode, language); - for (PromptSlotDefinition definition : schema.slotDefinitions()) { - if (!definition.required() && !templateSlotNames.containsKey(definition.name())) { - continue; - } - String value = normalizeAndValidate(resolveSlotValue(userInput, definition.name()), definition); - slots.put(definition.name(), value); - } - return slots; - } - - private static Map referencedTemplateSlots(String templateText) { - Map slotNames = new LinkedHashMap<>(); - Matcher matcher = PLACEHOLDER_PATTERN.matcher(templateText); - while (matcher.find()) { - slotNames.putIfAbsent(matcher.group(1).trim(), Boolean.TRUE); - } - return slotNames; - } - - private static String resolveSlotValue(Object userInput, String slotName) { - if (userInput instanceof Map mapValue) { - Object rawValue = mapValue.get(slotName); - return rawValue == null ? "" : String.valueOf(rawValue); - } - if ("input".equals(slotName)) { - return String.valueOf(userInput); - } - return ""; - } - - private static String normalizeAndValidate(String value, PromptSlotDefinition definition) { - String normalized = value == null ? "" : value.trim(); - if (normalized.isBlank()) { - return ""; - } - if (definition.allowedValues() != null - && !definition.allowedValues().isEmpty() - && !definition.allowedValues().contains(normalized)) { - return ""; - } - if (definition.pattern() != null - && !definition.pattern().isBlank() - && !normalized.matches(definition.pattern())) { - return ""; - } - if ("integer".equalsIgnoreCase(definition.jsonType()) || "number".equalsIgnoreCase(definition.jsonType())) { - try { - double numericValue = Double.parseDouble(normalized); - if (definition.minimum() != null && numericValue < definition.minimum()) { - return ""; - } - if (definition.maximum() != null && numericValue > definition.maximum()) { - return ""; - } - if ("integer".equalsIgnoreCase(definition.jsonType())) { - return String.valueOf((int) numericValue); - } - return stripTrailingZero(numericValue); - } catch (NumberFormatException error) { - return ""; - } - } - return normalized; - } - - private static String stripTrailingZero(double value) { - if (Math.rint(value) == value) { - return String.valueOf((long) value); - } - return String.valueOf(value); - } -} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/ClientSlotSchemaLoader.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/ClientSlotSchemaLoader.java deleted file mode 100644 index e801f10f..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/ClientSlotSchemaLoader.java +++ /dev/null @@ -1,21 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.loader; - -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; - -/** - * Loads slot schema definitions for client-side prompt generation. - * - * @since 2026-06 - */ -@FunctionalInterface -public interface ClientSlotSchemaLoader { - - /** - * Loads the slot schema for one scenario and language. - * - * @param scenarioCode scenario code to resolve - * @param language locale identifier to resolve - * @return slot schema for the requested scenario - */ - PromptSlotSchema loadSlotSchema(String scenarioCode, String language); -} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/ClientTemplateLoader.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/ClientTemplateLoader.java deleted file mode 100644 index 96161154..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/ClientTemplateLoader.java +++ /dev/null @@ -1,19 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.loader; - -/** - * Loads prompt templates for client-side prompt generation. - * - * @since 2026-06 - */ -@FunctionalInterface -public interface ClientTemplateLoader { - - /** - * Loads one prompt template for the requested scenario and language. - * - * @param scenarioCode scenario code to resolve - * @param language locale identifier to resolve - * @return template text - */ - String loadTemplate(String scenarioCode, String language); -} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoader.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoader.java deleted file mode 100644 index f7b5d08b..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoader.java +++ /dev/null @@ -1,29 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.loader; - -import net.openan.a2at.sdk.prompt.resources.loader.ClasspathPromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; - -/** - * Default slot schema loader backed by packaged classpath resources. - * - * @since 2026-06 - */ -public final class DefaultClasspathClientSlotSchemaLoader implements ClientSlotSchemaLoader { - - private final ClasspathPromptSlotSchemaLoader delegate; - - /** - * Creates a slot schema loader backed by one classpath resource loader. - * - * @param resourceLoader shared resource loader - */ - public DefaultClasspathClientSlotSchemaLoader(ClasspathPromptResourceLoader resourceLoader) { - this.delegate = new ClasspathPromptSlotSchemaLoader(resourceLoader); - } - - @Override - public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { - return delegate.loadSlotSchema(scenarioCode, language); - } -} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientTemplateLoader.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientTemplateLoader.java deleted file mode 100644 index e98501b7..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientTemplateLoader.java +++ /dev/null @@ -1,28 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.loader; - -import net.openan.a2at.sdk.prompt.resources.loader.ClasspathPromptTemplateLoader; -import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; - -/** - * Default template loader backed by packaged classpath resources. - * - * @since 2026-06 - */ -public final class DefaultClasspathClientTemplateLoader implements ClientTemplateLoader { - - private final ClasspathPromptTemplateLoader delegate; - - /** - * Creates a template loader backed by one classpath resource loader. - * - * @param resourceLoader shared resource loader - */ - public DefaultClasspathClientTemplateLoader(ClasspathPromptResourceLoader resourceLoader) { - this.delegate = new ClasspathPromptTemplateLoader(resourceLoader); - } - - @Override - public String loadTemplate(String scenarioCode, String language) { - return delegate.loadTemplate(scenarioCode, language); - } -} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoader.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoader.java deleted file mode 100644 index 51ea22de..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoader.java +++ /dev/null @@ -1,28 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.loader; - -import java.nio.file.Path; -import net.openan.a2at.sdk.prompt.resources.loader.LocalFilePromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; - -/** - * Loads slot schema definitions from one local prompt resource root. - * - * @since 2026-06 - */ -public final class LocalFileClientSlotSchemaLoader implements ClientSlotSchemaLoader { - private final LocalFilePromptSlotSchemaLoader delegate; - - /** - * Creates one local slot schema loader. - * - * @param promptRootDir prompt resource root directory - */ - public LocalFileClientSlotSchemaLoader(Path promptRootDir) { - this.delegate = new LocalFilePromptSlotSchemaLoader(promptRootDir); - } - - @Override - public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { - return delegate.loadSlotSchema(scenarioCode, language); - } -} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientTemplateLoader.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientTemplateLoader.java deleted file mode 100644 index ad6a9f0d..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientTemplateLoader.java +++ /dev/null @@ -1,27 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.loader; - -import java.nio.file.Path; -import net.openan.a2at.sdk.prompt.resources.loader.LocalFilePromptTemplateLoader; - -/** - * Loads client templates from one local prompt resource root. - * - * @since 2026-06 - */ -public final class LocalFileClientTemplateLoader implements ClientTemplateLoader { - private final LocalFilePromptTemplateLoader delegate; - - /** - * Creates one local template loader. - * - * @param promptRootDir prompt resource root directory - */ - public LocalFileClientTemplateLoader(Path promptRootDir) { - this.delegate = new LocalFilePromptTemplateLoader(promptRootDir); - } - - @Override - public String loadTemplate(String scenarioCode, String language) { - return delegate.loadTemplate(scenarioCode, language); - } -} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java index 802eeaf9..bf0f0855 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -1,280 +1,261 @@ -package net.openan.a2at.sdk.client.prompt.orchestration; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.SlotValidationError; +package net.openan.a2at.sdk.client.prompt.orchestration; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.model.PromptGenerationFailure; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.client.model.PromptGenerationFailure; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; -import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; -import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; -import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.ExtensionUriConstants; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; -import net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException; - -/** - * Minimal runnable client prompt generation orchestrator. - * - * @since 2026-06 - */ -public final class DefaultClientPromptGenerationOrchestrator implements ClientPromptGenerationOrchestrator { - - private final ClientScenarioRecognizer scenarioRecognizer; - - private final List scenarios; - - private final String language; - - private final String systemPrompt; - - private final String userPrompt; - - private final ClientTemplateLoader templateLoader; - - private final ClientSlotValueExtractor slotValueExtractor; - - private final ClientSlotSchemaLoader slotSchemaLoader; - - private final TaskPromptRenderer renderer; - - private String lastNormalizedInput; - - /** - * Creates a client prompt-generation orchestrator with explicit collaborators. - * - * @param scenarioRecognizer scenario recognizer - * @param scenarios supported scenario definitions - * @param language locale identifier for resource lookup - * @param systemPrompt system prompt for scenario recognition - * @param userPrompt user prompt for scenario recognition - * @param templateLoader template loader - * @param slotValueExtractor slot value extractor - * @param renderer task prompt renderer - * @param slotSchemaLoader slot schema loader - */ - public DefaultClientPromptGenerationOrchestrator( - ClientScenarioRecognizer scenarioRecognizer, - List scenarios, - String language, - String systemPrompt, - String userPrompt, - ClientTemplateLoader templateLoader, - ClientSlotValueExtractor slotValueExtractor, - TaskPromptRenderer renderer, - ClientSlotSchemaLoader slotSchemaLoader) { - this.scenarioRecognizer = scenarioRecognizer; - this.scenarios = scenarios; - this.language = language; - this.systemPrompt = systemPrompt; - this.userPrompt = userPrompt; - this.templateLoader = templateLoader; - this.slotValueExtractor = slotValueExtractor; - this.renderer = renderer; - this.slotSchemaLoader = slotSchemaLoader; - } - - @Override - public PromptGenerationResult generateTaskPrompt(Object userInput) { - String normalizedInput = String.valueOf(userInput); - this.lastNormalizedInput = normalizedInput; - - final ScenarioRecognitionResult recognition; - try { - recognition = scenarioRecognizer.recognize(normalizedInput, scenarios, systemPrompt, userPrompt); - } catch (ResourceNotFoundException error) { - return PromptGenerationResult.failure( - new PromptGenerationFailure(A2ATErrorCodes.PROMPT_RESOURCE_LOAD_ERROR, error.getMessage(), "generation")); - } - if (!recognition.matched() - || recognition.scenarioCode() == null - || recognition.scenarioCode().isBlank()) { - return PromptGenerationResult.failure(new PromptGenerationFailure( - "scenario_not_matched", - recognition.errorMessage() == null ? "Scenario recognition failed." : recognition.errorMessage(), - "scenario")); - } - - final String templateText; - try { - templateText = templateLoader.loadTemplate(recognition.scenarioCode(), language); - } catch (ResourceNotFoundException error) { - return PromptGenerationResult.failure( - new PromptGenerationFailure(A2ATErrorCodes.TEMPLATE_NOT_FOUND, error.getMessage(), "generation")); - } - - try { - Map slots = - slotValueExtractor.extractSlots(userInput, recognition.scenarioCode(), language, templateText); - String renderedPrompt = renderer.render(templateText, slots); - return PromptGenerationResult.success(renderedPrompt); - } catch (TaskPromptRenderException error) { - return PromptGenerationResult.failure( - new PromptGenerationFailure(A2ATErrorCodes.RENDER_FAILED, error.getMessage(), "generation")); - } - } - - @Override - public MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) { - return generateFromTemplateUriWithMetadata(text, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); - } - - @Override - public MetadataContent generateTaskPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) { - return generateFromDataWithSchema(data, schema, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); - } - - @Override - public MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri) { - return generateFromTemplateUriWithMetadata(text, templateUri, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); - } - - @Override - public MetadataContent generateAuthPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) { - return generateFromDataWithSchema( - data, schema, templateUri, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); - } - - @Override - public MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri) { - return generateFromTemplateUriWithMetadata( - text, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); - } - - @Override - public MetadataContent generateNotificationPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) { - return generateFromDataWithSchema(data, schema, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); - } - - private MetadataContent generateFromTemplateUriWithMetadata( - String userInput, TemplateUri templateUri, String extensionUri) { - Objects.requireNonNull(userInput, "userInput"); - Objects.requireNonNull(templateUri, "templateUri"); - String templateIdentifier = templateUri.uri(); - final String templateText; - try { - templateText = templateLoader.loadTemplate(templateIdentifier, language); - } catch (ResourceNotFoundException e) { - throw new PromptGenerationException(A2ATErrorCodes.TEMPLATE_NOT_FOUND, e.getMessage(), e); - } catch (A2ATError e) { - throw new PromptGenerationException(A2ATErrorCodes.PROMPT_RESOURCE_LOAD_ERROR, e.getMessage(), e); - } - final Map slots; - try { - slots = slotValueExtractor.extractSlots(userInput, templateIdentifier, language, templateText); - } catch (ResourceNotFoundException e) { - throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, e.getMessage(), e); - } catch (A2ATError e) { - throw new PromptGenerationException(A2ATErrorCodes.LLM_INVOCATION_FAILED, e.getMessage(), e); - } - validateRequiredSlots(slots, templateIdentifier); - final String renderedPrompt; - try { - renderedPrompt = renderer.render(templateText, slots); - } catch (TaskPromptRenderException e) { - throw new PromptGenerationException(A2ATErrorCodes.RENDER_FAILED, e.getMessage(), e); - } - return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); - } - - private MetadataContent generateFromDataWithSchema( - Map data, - Map schema, - TemplateUri templateUri, - String extensionUri) { - Objects.requireNonNull(templateUri, "templateUri"); - String templateIdentifier = templateUri.uri(); - Objects.requireNonNull(schema, "schema"); - if (schema.isEmpty()) { - throw new IllegalArgumentException( - "Data schema must not be empty; it describes the meaning of each input field."); - } - final String templateText; - try { - templateText = templateLoader.loadTemplate(templateIdentifier, language); - } catch (ResourceNotFoundException e) { - throw new PromptGenerationException(A2ATErrorCodes.TEMPLATE_NOT_FOUND, e.getMessage(), e); - } catch (A2ATError e) { - throw new PromptGenerationException(A2ATErrorCodes.PROMPT_RESOURCE_LOAD_ERROR, e.getMessage(), e); - } - final Map slots; - try { - slots = slotValueExtractor.extractSlotsWithSchema( - data, templateIdentifier, language, templateText, schema); - } catch (ResourceNotFoundException e) { - throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, e.getMessage(), e); - } catch (A2ATError e) { - throw new PromptGenerationException(A2ATErrorCodes.LLM_INVOCATION_FAILED, e.getMessage(), e); - } - validateRequiredSlots(slots, templateIdentifier); - final String renderedPrompt; - try { - renderedPrompt = renderer.render(templateText, slots); - } catch (TaskPromptRenderException e) { - throw new PromptGenerationException(A2ATErrorCodes.RENDER_FAILED, e.getMessage(), e); - } - return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); - } - - private void validateRequiredSlots(Map slots, String templateIdentifier) { - final PromptSlotSchema schema; - try { - schema = slotSchemaLoader.loadSlotSchema(templateIdentifier, language); - } catch (A2ATError e) { - throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, e.getMessage(), e); - } - if (schema == null) { - throw new PromptGenerationException( - A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, "Slot schema not found for template: " + templateIdentifier); - } - List defs = schema.slotDefinitions(); - if (defs == null) { - return; - } - if (slots == null) { - slots = Map.of(); - } - List failed = new ArrayList<>(); - for (PromptSlotDefinition def : defs) { - if (def == null) { - continue; - } - if (def.required()) { - String name = def.name(); - if (name == null) { - continue; - } - String value = slots.get(name); - if (value == null || value.trim().isEmpty()) { - failed.add(new SlotValidationError(name, "missing_required", "Required slot is missing or empty")); - } - } - } - if (!failed.isEmpty()) { - throw new PromptGenerationException(A2ATErrorCodes.SLOT_VALIDATION_ERROR, - "Required slots are missing or empty: " + failed.stream() - .map(SlotValidationError::slotName).collect(Collectors.joining(", ")), - failed); - } - } - - String lastNormalizedInput() { - return lastNormalizedInput; - } -} \ No newline at end of file +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognitionFunction; +import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; +import net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException; + +/** + * Minimal runnable client prompt generation orchestrator. + * + * @since 2026-06 + */ +public final class DefaultClientPromptGenerationOrchestrator implements ClientPromptGenerationOrchestrator { + + private final ScenarioRecognitionFunction scenarioRecognizer; + + private final List scenarios; + + private final String language; + + private final String systemPrompt; + + private final String userPrompt; + + private final PromptTemplateTextLoader templateLoader; + + private final PromptSlotValueExtractor slotValueExtractor; + + private final PromptSlotSchemaLoader slotSchemaLoader; + + private final TaskPromptRenderer renderer; + + /** + * Creates a client prompt-generation orchestrator with explicit collaborators. + * + * @param scenarioRecognizer scenario recognizer + * @param scenarios supported scenario definitions + * @param language locale identifier for resource lookup + * @param systemPrompt system prompt for scenario recognition + * @param userPrompt user prompt for scenario recognition + * @param templateLoader template loader + * @param slotValueExtractor slot value extractor + * @param renderer task prompt renderer + * @param slotSchemaLoader slot schema loader + */ + public DefaultClientPromptGenerationOrchestrator( + ScenarioRecognitionFunction scenarioRecognizer, + List scenarios, + String language, + String systemPrompt, + String userPrompt, + PromptTemplateTextLoader templateLoader, + PromptSlotValueExtractor slotValueExtractor, + TaskPromptRenderer renderer, + PromptSlotSchemaLoader slotSchemaLoader) { + this.scenarioRecognizer = scenarioRecognizer; + this.scenarios = scenarios; + this.language = language; + this.systemPrompt = systemPrompt; + this.userPrompt = userPrompt; + this.templateLoader = templateLoader; + this.slotValueExtractor = slotValueExtractor; + this.renderer = renderer; + this.slotSchemaLoader = slotSchemaLoader; + } + + @Override + public PromptGenerationResult generateTaskPrompt(Object userInput) { + String normalizedInput = String.valueOf(userInput); + + final ScenarioRecognitionResult recognition; + try { + recognition = scenarioRecognizer.recognize(normalizedInput, scenarios, systemPrompt, userPrompt); + } catch (ResourceNotFoundException error) { + return PromptGenerationResult.failure(new PromptGenerationFailure( + A2ATErrorCodes.PROMPT_RESOURCE_LOAD_ERROR, error.getMessage(), "generation")); + } + if (!recognition.matched() + || recognition.scenarioCode() == null + || recognition.scenarioCode().isBlank()) { + return PromptGenerationResult.failure(new PromptGenerationFailure( + "scenario_not_matched", + recognition.errorMessage() == null ? "Scenario recognition failed." : recognition.errorMessage(), + "scenario")); + } + + final String templateText; + try { + templateText = templateLoader.loadTemplate(recognition.scenarioCode(), language); + } catch (ResourceNotFoundException error) { + return PromptGenerationResult.failure( + new PromptGenerationFailure(A2ATErrorCodes.TEMPLATE_NOT_FOUND, error.getMessage(), "generation")); + } + + try { + StructuredSlotExtractionResult extractionResult = + slotValueExtractor.extractSlots(userInput, recognition.scenarioCode(), language); + Map slots = extractionResult.slots(); + String renderedPrompt = renderer.render(templateText, slots); + return PromptGenerationResult.success(renderedPrompt); + } catch (TaskPromptRenderException error) { + return PromptGenerationResult.failure( + new PromptGenerationFailure(A2ATErrorCodes.RENDER_FAILED, error.getMessage(), "generation")); + } + } + + @Override + public MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) { + return generateFromTemplateUriWithMetadata(text, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) { + return generateFromDataWithSchema(data, schema, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri) { + return generateFromTemplateUriWithMetadata( + text, templateUri, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateAuthPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) { + return generateFromDataWithSchema( + data, schema, templateUri, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri) { + return generateFromTemplateUriWithMetadata( + text, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) { + return generateFromDataWithSchema( + data, schema, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); + } + + private MetadataContent generateFromTemplateUriWithMetadata( + String userInput, TemplateUri templateUri, String extensionUri) { + Objects.requireNonNull(userInput, "userInput"); + return generateWithMetadata(templateUri, extensionUri, templateIdentifier -> slotValueExtractor + .extractSlots(userInput, templateIdentifier, language) + .slots()); + } + + private MetadataContent generateFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri, String extensionUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + if (schema.isEmpty()) { + throw new IllegalArgumentException( + "Data schema must not be empty; it describes the meaning of each input field."); + } + return generateWithMetadata(templateUri, extensionUri, templateIdentifier -> slotValueExtractor + .extractSlots(data, templateIdentifier, language, schema) + .slots()); + } + + private MetadataContent generateWithMetadata( + TemplateUri templateUri, String extensionUri, Function> slotExtractor) { + Objects.requireNonNull(templateUri, "templateUri"); + String templateIdentifier = templateUri.uri(); + final String templateText; + try { + templateText = templateLoader.loadTemplate(templateIdentifier, language); + } catch (ResourceNotFoundException e) { + throw new PromptGenerationException(A2ATErrorCodes.TEMPLATE_NOT_FOUND, e.getMessage(), e); + } catch (A2ATError e) { + throw new PromptGenerationException(A2ATErrorCodes.PROMPT_RESOURCE_LOAD_ERROR, e.getMessage(), e); + } + final Map slots; + try { + slots = slotExtractor.apply(templateIdentifier); + } catch (ResourceNotFoundException e) { + throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, e.getMessage(), e); + } catch (A2ATError e) { + throw new PromptGenerationException(A2ATErrorCodes.LLM_INVOCATION_FAILED, e.getMessage(), e); + } + validateRequiredSlots(slots, templateIdentifier); + final String renderedPrompt; + try { + renderedPrompt = renderer.render(templateText, slots); + } catch (TaskPromptRenderException e) { + throw new PromptGenerationException(A2ATErrorCodes.RENDER_FAILED, e.getMessage(), e); + } + return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); + } + + private void validateRequiredSlots(Map slots, String templateIdentifier) { + final PromptSlotSchema schema; + try { + schema = slotSchemaLoader.loadSlotSchema(templateIdentifier, language); + } catch (A2ATError e) { + throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, e.getMessage(), e); + } + if (schema == null) { + throw new PromptGenerationException( + A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, "Slot schema not found for template: " + templateIdentifier); + } + List defs = schema.slotDefinitions(); + if (defs == null) { + return; + } + Map effectiveSlots = slots; + if (effectiveSlots == null) { + effectiveSlots = Map.of(); + } + List failed = new ArrayList<>(); + for (PromptSlotDefinition def : defs) { + if (def == null) { + continue; + } + if (def.required()) { + String name = def.name(); + if (name == null) { + continue; + } + String value = effectiveSlots.get(name); + if (value == null || value.trim().isEmpty()) { + failed.add(new SlotValidationError(name, "missing_required", "Required slot is missing or empty")); + } + } + } + if (!failed.isEmpty()) { + throw new PromptGenerationException( + A2ATErrorCodes.SLOT_VALIDATION_ERROR, + "Required slots are missing or empty: " + + failed.stream().map(SlotValidationError::slotName).collect(Collectors.joining(", ")), + failed); + } + } +} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/recognition/ClientScenarioRecognizer.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/recognition/ClientScenarioRecognizer.java deleted file mode 100644 index 64ecaada..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/recognition/ClientScenarioRecognizer.java +++ /dev/null @@ -1,26 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.recognition; - -import java.util.List; -import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; - -/** - * Client-side adapter for scenario recognition. - * - * @since 2026-06 - */ -@FunctionalInterface -public interface ClientScenarioRecognizer { - - /** - * Resolves one normalized user input into a scenario recognition result. - * - * @param normalizedInput normalized user input text - * @param scenarios available scenarios - * @param systemPrompt scenario-recognition system prompt - * @param userPrompt scenario-recognition user prompt - * @return recognition result describing whether a scenario matched - */ - ScenarioRecognitionResult recognize( - String normalizedInput, List scenarios, String systemPrompt, String userPrompt); -} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoader.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoader.java deleted file mode 100644 index 6da0a618..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoader.java +++ /dev/null @@ -1,34 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.recognition; - -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.prompt.resources.loader.LocalFilePromptScenarioCatalogLoader; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; - -/** - * Loads scenario catalogs from one local prompt resource root. - * - * @since 2026-06 - */ -public final class LocalFileClientScenarioCatalogLoader { - private final LocalFilePromptScenarioCatalogLoader delegate; - - /** - * Creates one local scenario catalog loader. - * - * @param promptRootDir prompt resource root directory - */ - public LocalFileClientScenarioCatalogLoader(Path promptRootDir) { - this.delegate = new LocalFilePromptScenarioCatalogLoader(promptRootDir); - } - - /** - * Loads scenario definitions for one language. - * - * @param language resource language - * @return parsed scenario definitions - */ - public List load(String language) { - return delegate.load(language); - } -} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index 047ba150..53e91c46 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -12,11 +12,13 @@ import java.util.Arrays; import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.model.MetadataContent; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; @@ -27,6 +29,7 @@ import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class A2ATClientTest { @@ -40,6 +43,12 @@ static void registerMockProvider() { } } + @BeforeEach + void resetRecordingClientState() { + RecordingClient.SLOT_OVERRIDES.clear(); + RecordingClient.REQUEST_COUNT.set(0); + } + @Test void onlyExposesPathBasedPublicConstructor() throws NoSuchMethodException { assertNotNull(A2ATClient.class.getConstructor(Path.class)); @@ -97,6 +106,8 @@ void doesNotKeepStaticAssemblyHelpersInsideFacade() { @Test void pathBasedConstructorLoadsLocalPromptResources() throws IOException { Path envFile = writeMinimalLocalClientEnv(); + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); A2ATClient client = new A2ATClient(envFile); PromptGenerationResult result = @@ -104,22 +115,60 @@ void pathBasedConstructorLoadsLocalPromptResources() throws IOException { assertTrue(result.success()); assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); + assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); + } + + @Test + void scenarioRecognitionShortCircuitsWhenSingleScenarioIsConfigured() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult result = + client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); + + assertTrue(result.success()); + assertEquals( + 1, + RecordingClient.REQUEST_COUNT.get(), + "Single scenario must short-circuit recognition — only extraction LLM call expected"); + } + + @Test + void scenarioRecognitionDelegatesToWrappedRecognizerWhenMultipleScenariosConfigured() throws IOException { + Path envFile = writeMinimalMultiScenarioClientEnv(); + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult result = + client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); + + assertTrue(result.success()); + assertEquals( + 2, + RecordingClient.REQUEST_COUNT.get(), + "Multiple scenarios must delegate to wrapped recognizer — recognition + extraction calls expected"); } @Test void pathBasedConstructorAcceptsRelativeEnvPath() throws IOException { Path targetDir = Files.createDirectories(Path.of("target")); Path tempDir = Files.createTempDirectory(targetDir, "a2at-client-relative-env"); - writeMinimalClientEnv(tempDir, "openai"); + writeMinimalClientEnv(tempDir, TEST_MOCK_PROVIDER); Path relativeEnvPath = targetDir.getFileName().resolve(tempDir.getFileName()).resolve("client.env"); + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); A2ATClient client = new A2ATClient(relativeEnvPath); PromptGenerationResult result = client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); assertTrue(result.success()); assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); + assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); } @Test @@ -133,14 +182,17 @@ void pathBasedConstructorLoadsClasspathPromptResources() throws IOException { @Test void pathBasedConstructorAcceptsOpenAiProvider() throws IOException { - Path envFile = writeMinimalClientEnv("openai"); + Path envFile = writeMinimalClientEnv(TEST_MOCK_PROVIDER); + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); A2ATClient client = new A2ATClient(envFile); PromptGenerationResult result = client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); assertTrue(result.success()); assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); + assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); } @Test @@ -193,8 +245,10 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO Files.createDirectories(templatesDir); Files.createDirectories(slotsDir); - Path scenarioPromptDir = promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); - Path slotPromptDir = promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); Files.createDirectories(scenarioPromptDir); Files.createDirectories(slotPromptDir); Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); @@ -240,18 +294,22 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=local_file A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources -A2AT_LLM_PROVIDER=openai +A2AT_LLM_PROVIDER=%s A2AT_LLM_MODEL=example-model A2AT_LLM_BASE_URL=https://llm.example.test/v1 A2AT_LLM_API_KEY=test-key A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """); + """ + .formatted(TEST_MOCK_PROVIDER)); + RecordingClient.SLOT_OVERRIDES.put("line_id", "line-1"); + RecordingClient.SLOT_OVERRIDES.put("fault_id", "fault-9"); A2ATClient client = new A2ATClient(envFile); PromptGenerationResult result = client.generateTaskPrompt(Map.of("line_id", "line-1", "fault_id", "fault-9")); assertTrue(result.success()); assertEquals("Line: line-1\\nFault: fault-9", result.promptText()); + assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); } @Test @@ -262,7 +320,8 @@ void fromTextEntryPointsReturnMetadataContent() throws IOException { MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); - MetadataContent notificationResult = client.generateNotificationPromptFromText("Report finished.", unconstrained); + MetadataContent notificationResult = + client.generateNotificationPromptFromText("Report finished.", unconstrained); assertNotNull(taskResult); assertEquals("Task-T/network-layer/unconstrained/v1", taskResult.templateUri()); @@ -318,7 +377,8 @@ void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); - MetadataContent notificationResult = client.generateNotificationPromptFromText("Report finished.", unconstrained); + MetadataContent notificationResult = + client.generateNotificationPromptFromText("Report finished.", unconstrained); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); @@ -349,14 +409,14 @@ void fromTextThrowsOnNullTemplateUri() throws IOException { Path envFile = writeMinimalLocalClientEnv(); A2ATClient client = new A2ATClient(envFile); - NullPointerException taskEx = - assertThrows(NullPointerException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", null)); + NullPointerException taskEx = assertThrows( + NullPointerException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", null)); assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); - NullPointerException authEx = - assertThrows(NullPointerException.class, () -> client.generateAuthPromptFromText("Authorize access.", null)); + NullPointerException authEx = assertThrows( + NullPointerException.class, () -> client.generateAuthPromptFromText("Authorize access.", null)); assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); - NullPointerException notifEx = - assertThrows(NullPointerException.class, () -> client.generateNotificationPromptFromText("Report finished.", null)); + NullPointerException notifEx = assertThrows( + NullPointerException.class, () -> client.generateNotificationPromptFromText("Report finished.", null)); assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); } @@ -368,12 +428,10 @@ void fromDataWithSchemaThrowsOnNullTemplateUri() throws IOException { Map schema = Map.of("type", "object"); NullPointerException taskEx = assertThrows( - NullPointerException.class, - () -> client.generateTaskPromptFromDataWithSchema(data, schema, null)); + NullPointerException.class, () -> client.generateTaskPromptFromDataWithSchema(data, schema, null)); assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); NullPointerException authEx = assertThrows( - NullPointerException.class, - () -> client.generateAuthPromptFromDataWithSchema(data, schema, null)); + NullPointerException.class, () -> client.generateAuthPromptFromDataWithSchema(data, schema, null)); assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); NullPointerException notifEx = assertThrows( NullPointerException.class, @@ -566,8 +624,97 @@ private static Path writeMinimalClientEnv(Path tempDir, String provider) throws return envFile; } + private static Path writeMinimalMultiScenarioClientEnv() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-multi-scenario"); + Path promptRoot = tempDir.resolve("prompt_resources"); + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); + Path templatesDir = promptRoot + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("energy-saving") + .resolve("v1") + .resolve("zh-CN"); + Path slotsDir = promptRoot + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("energy-saving") + .resolve("v1") + .resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.createDirectories(scenariosDir); + Files.createDirectories(templatesDir); + Files.createDirectories(slotsDir); + + Files.writeString( + scenariosDir.resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "energy-saving", + "scenario_name": "Energy Saving", + "description": "Energy analysis", + "example": "Analyze site power" + }, + { + "scenario_code": "data-report", + "scenario_name": "Data Report", + "description": "Report generation", + "example": "Generate report" + } + ] + } + """); + Files.writeString(templatesDir.resolve("template.md"), "Site: {site}\\nTarget: {target}"); + Files.writeString( + slotsDir.resolve("slot.json"), + """ + { + "required": ["site", "target"], + "properties": { + "site": { + "type": "string" + }, + "target": { + "type": "string" + } + } + } + """); + Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); + Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); + Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); + + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(TEST_MOCK_PROVIDER)); + return envFile; + } + public static final class RecordingClient implements LLMClient { + public static final Map SLOT_OVERRIDES = new ConcurrentHashMap<>(); + public static final AtomicInteger REQUEST_COUNT = new AtomicInteger(0); + private final LLMClientConfig config; public RecordingClient(LLMClientConfig config) { @@ -580,22 +727,30 @@ public LLMResponse structured( Map jsonSchema, Double temperature, Integer maxTokens) { + REQUEST_COUNT.incrementAndGet(); return new LLMResponse(buildResponse(jsonSchema), config.model(), Map.of(), Map.of()); } private static String buildResponse(Map jsonSchema) { Object slotNames = jsonSchema.get("slotNames"); - StringBuilder slots = new StringBuilder("{"); if (slotNames instanceof List names) { + StringBuilder slots = new StringBuilder("{"); for (int i = 0; i < names.size(); i++) { if (i > 0) { slots.append(","); } - slots.append("\"").append(names.get(i)).append("\":\"placeholder\""); + String name = String.valueOf(names.get(i)); + String value = SLOT_OVERRIDES.getOrDefault(name, "placeholder"); + slots.append("\"") + .append(name) + .append("\":\"") + .append(value) + .append("\""); } + slots.append("}"); + return "{\"slots\":" + slots + ",\"slot_errors\":[]}"; } - slots.append("}"); - return "{\"slots\":" + slots + ",\"slot_errors\":[]}"; + return "{\"matched\":true,\"scenario_code\":\"energy-saving\",\"error_message\":null}"; } } } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptAssemblyPackageStructureTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptAssemblyPackageStructureTest.java index 241883e6..774af886 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptAssemblyPackageStructureTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptAssemblyPackageStructureTest.java @@ -7,7 +7,6 @@ import java.util.Map; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.orchestration.DefaultClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.model.PromptGenerationConfig; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; @@ -21,17 +20,16 @@ void defaultAssemblyStillBuildsAndGeneratesPrompt() { "{\"matched\":true,\"scenario_code\":\"energy-saving\",\"error_message\":null}", "{\"slots\":{\"site\":\"Site A\",\"additional_notes\":\"critical\",\"limit\":\"5\",\"severity\":\"high\"},\"slot_errors\":[]}"); - DefaultClientPromptGenerationOrchestrator orchestrator = - DefaultClientPromptGenerationOrchestratorFactory.create( - llmClient, - List.of(new ScenarioDefinition( - "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - new PromptGenerationConfig( - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - "Extract slots from the input.", - "Return slots as JSON.")); + DefaultClientPromptGenerationOrchestrator orchestrator = ClientPromptGenerationOrchestratorBuilder.builder() + .llmClient(llmClient) + .scenarios(List.of(new ScenarioDefinition( + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power"))) + .language("en-US") + .scenarioSystemPrompt("Identify the best matching scenario.") + .scenarioUserPrompt("Choose from the provided scenario list.") + .slotSystemPrompt("Extract slots from the input.") + .slotUserPrompt("Return slots as JSON.") + .build(); PromptGenerationResult result = orchestrator.generateTaskPrompt(Map.of( "site", "Site A", diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java index abcd0ca7..67154e3a 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java @@ -2,18 +2,20 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import java.util.Map; import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; -import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; import net.openan.a2at.sdk.client.prompt.orchestration.DefaultClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognitionFunction; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; import org.junit.jupiter.api.Test; @@ -85,9 +87,21 @@ void buildUsesExplicitCollaboratorsWhenProvided() { assertSame("Analyze Site B.", slotValueExtractor.lastUserInput); assertEquals("energy-saving", slotValueExtractor.lastScenarioCode); assertEquals("zh-CN", slotValueExtractor.lastLanguage); - assertEquals( - normalizeLineEndings("Site: {site}\nNotes: {additional_notes}"), - normalizeLineEndings(slotValueExtractor.lastTemplateText)); + } + + @Test + void buildThrowsIllegalStateExceptionWhenLlmClientIsMissing() { + IllegalStateException ex = + assertThrows(IllegalStateException.class, () -> ClientPromptGenerationOrchestratorBuilder.builder() + .scenarios(List.of(new ScenarioDefinition( + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power"))) + .language("en-US") + .scenarioSystemPrompt("Identify the best matching scenario.") + .scenarioUserPrompt("Choose from the provided scenario list.") + .slotSystemPrompt("Extract slots from the input.") + .slotUserPrompt("Return slots as JSON.") + .build()); + assertTrue(ex.getMessage().contains("LLM client")); } private static String normalizeLineEndings(String text) { @@ -116,7 +130,7 @@ public LLMResponse structured( } } - private static final class RecordingScenarioRecognizer implements ClientScenarioRecognizer { + private static final class RecordingScenarioRecognizer implements ScenarioRecognitionFunction { private final ScenarioRecognitionResult result; @@ -140,7 +154,7 @@ public ScenarioRecognitionResult recognize( } } - private static final class RecordingTemplateLoader implements ClientTemplateLoader { + private static final class RecordingTemplateLoader implements PromptTemplateTextLoader { private final String templateText; @@ -160,7 +174,7 @@ public String loadTemplate(String scenarioCode, String language) { } } - private static final class RecordingSlotValueExtractor implements ClientSlotValueExtractor { + private static final class RecordingSlotValueExtractor implements PromptSlotValueExtractor { private final Map slotValues; @@ -170,20 +184,16 @@ private static final class RecordingSlotValueExtractor implements ClientSlotValu private String lastLanguage; - private String lastTemplateText; - private RecordingSlotValueExtractor(Map slotValues) { this.slotValues = slotValues; } @Override - public Map extractSlots( - Object userInput, String scenarioCode, String language, String templateText) { + public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { this.lastUserInput = userInput; this.lastScenarioCode = scenarioCode; this.lastLanguage = language; - this.lastTemplateText = templateText; - return slotValues; + return new StructuredSlotExtractionResult(slotValues, List.of()); } } } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java index 82a8fe9a..f10017f5 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java @@ -3,13 +3,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import java.io.IOException; -import java.lang.reflect.Constructor; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; @@ -27,51 +25,6 @@ void resetCounter() { CLIENT_INSTANCE_COUNT.set(0); } - @Test - void structuredInputAwareExtractSlotsWithSchemaDelegatesToLlmExtractorForMapInput() throws Exception { - ClientSlotValueExtractor structuredExtractor = - (userInput, scenarioCode, language, templateText) -> Map.of("key", "structured"); - ClientSlotValueExtractor llmExtractor = - (userInput, scenarioCode, language, templateText) -> Map.of("key", "llm"); - - Class innerClass = Class.forName( - "net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder$StructuredInputAwareSlotValueExtractor"); - Constructor constructor = innerClass.getDeclaredConstructor( - ClientSlotValueExtractor.class, ClientSlotValueExtractor.class); - constructor.setAccessible(true); - ClientSlotValueExtractor extractor = - (ClientSlotValueExtractor) constructor.newInstance(structuredExtractor, llmExtractor); - - Map result = extractor.extractSlotsWithSchema( - Map.of("key", "value"), "scenario", "en", "template", Map.of("field", "description")); - - assertEquals(Map.of("key", "llm"), result); - } - - @Test - void structuredInputAwareExtractSlotsStillDispatchesByType() throws Exception { - ClientSlotValueExtractor structuredExtractor = - (userInput, scenarioCode, language, templateText) -> Map.of("key", "structured"); - ClientSlotValueExtractor llmExtractor = - (userInput, scenarioCode, language, templateText) -> Map.of("key", "llm"); - - Class innerClass = Class.forName( - "net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder$StructuredInputAwareSlotValueExtractor"); - Constructor constructor = innerClass.getDeclaredConstructor( - ClientSlotValueExtractor.class, ClientSlotValueExtractor.class); - constructor.setAccessible(true); - ClientSlotValueExtractor extractor = - (ClientSlotValueExtractor) constructor.newInstance(structuredExtractor, llmExtractor); - - Map mapResult = extractor.extractSlots( - Map.of("key", "value"), "scenario", "en", "template"); - assertEquals(Map.of("key", "structured"), mapResult); - - Map stringResult = extractor.extractSlots( - "plain text", "scenario", "en", "template"); - assertEquals(Map.of("key", "llm"), stringResult); - } - @Test void should_createLlmClientOnlyOnce_When_multipleBuildMethodsCalled() throws IOException { String provider = "test-lazy-cache"; @@ -81,12 +34,11 @@ void should_createLlmClientOnlyOnce_When_multipleBuildMethodsCalled() throws IOE Path envFile = createTempEnvFile(provider); A2ATConfig config = A2ATConfig.load(envFile); - config = net.openan.a2at.sdk.negotiation.generation.NegotiationContentService - .resolvePromptResourceLocalRootDir(config, envFile); + config = net.openan.a2at.sdk.negotiation.generation.NegotiationContentService.resolvePromptResourceLocalRootDir( + config, envFile); - DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder() - .config(config) - .envPath(envFile); + DefaultA2ATClientBuilder builder = + DefaultA2ATClientBuilder.builder().config(config).envPath(envFile); builder.buildPromptGenerationOrchestrator(); builder.buildNegotiationGenerationOrchestrator(); @@ -97,8 +49,10 @@ void should_createLlmClientOnlyOnce_When_multipleBuildMethodsCalled() throws IOE private static Path createTempEnvFile(String provider) throws IOException { Path tempDir = Files.createTempDirectory("a2at-client-builder-lazy"); Path promptRoot = tempDir.resolve("prompt_resources"); - Path scenarioPromptDir = promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); - Path slotPromptDir = promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); Files.createDirectories(scenarioPromptDir); Files.createDirectories(slotPromptDir); @@ -166,8 +120,7 @@ public LLMResponse structured( } } slots.append("}"); - return new LLMResponse( - "{\"slots\":" + slots + ",\"slot_errors\":[]}", config.model(), Map.of(), Map.of()); + return new LLMResponse("{\"slots\":" + slots + ",\"slot_errors\":[]}", config.model(), Map.of(), Map.of()); } } -} \ No newline at end of file +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultClientPromptGenerationOrchestratorFactoryTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultClientPromptGenerationOrchestratorFactoryTest.java deleted file mode 100644 index 4b9291db..00000000 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultClientPromptGenerationOrchestratorFactoryTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.assembly; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.client.prompt.orchestration.DefaultClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import org.junit.jupiter.api.Test; - -class DefaultClientPromptGenerationOrchestratorFactoryTest { - - @Test - void createBuildsPromptGenerationOrchestratorWithStructuredDefaultDependencies() { - RecordingClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"energy-saving\",\"error_message\":null}", - "{\"slots\":{\"site\":\"Site A\",\"additional_notes\":\"critical\",\"limit\":\"5\",\"severity\":\"high\"},\"slot_errors\":[]}"); - - DefaultClientPromptGenerationOrchestrator orchestrator = - DefaultClientPromptGenerationOrchestratorFactory.create( - llmClient, - List.of(new ScenarioDefinition( - "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - "Extract slots from the input.", - "Return slots as JSON."); - - PromptGenerationResult result = orchestrator.generateTaskPrompt(java.util.Map.of( - "site", "Site A", - "additional_notes", "critical", - "limit", "5", - "severity", "high")); - - assertTrue(result.success()); - assertEquals( - normalizeLineEndings("Site: Site A\nNotes: critical"), - normalizeLineEndings(result.promptText().trim())); - assertEquals(2, llmClient.requestCount); - } - - private static String normalizeLineEndings(String text) { - return text.replace("\r\n", "\n"); - } - - private static final class RecordingClient implements LLMClient { - - private final java.util.List payloads; - - private int requestCount; - - private RecordingClient(String... payloads) { - this.payloads = new java.util.ArrayList<>(java.util.List.of(payloads)); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - requestCount++; - return new LLMResponse( - payloads.remove(0), "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractorTest.java deleted file mode 100644 index 49db86f6..00000000 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractorTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.extractor; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.Map; -import org.junit.jupiter.api.Test; - -class ClientSlotValueExtractorTest { - - @Test - void extractSlotsWithSchemaDelegatesToExtractSlotsAndIgnoresSchema() { - ClientSlotValueExtractor extractor = (userInput, scenarioCode, language, templateText) -> - Map.of("scenario", scenarioCode, "language", language); - - Map result = extractor.extractSlotsWithSchema( - "test input", "scenario-1", "en-US", "template text", Map.of("field", "desc")); - - assertEquals(Map.of("scenario", "scenario-1", "language", "en-US"), result); - } -} \ No newline at end of file diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java deleted file mode 100644 index eeefa201..00000000 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java +++ /dev/null @@ -1,170 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.extractor; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import org.junit.jupiter.api.Test; - -class DefaultStructuredClientSlotValueExtractorTest { - - @Test - void extractSlotsBuildsStructuredPromptAndReturnsNormalizedSlots() { - RecordingClient llmClient = new RecordingClient( - "{\"slots\":{\"site\":\"Site A\",\"additional_notes\":null,\"limit\":\"5\",\"severity\":\"high\"}," - + "\"slot_errors\":[]}"); - DefaultStructuredClientSlotValueExtractor extractor = new DefaultStructuredClientSlotValueExtractor( - llmClient, - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of( - new PromptSlotDefinition( - "site", true, "string", "^Site .+", null, null, null, null, null), - new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null, null), - new PromptSlotDefinition( - "limit", false, "integer", null, 1.0d, 10.0d, null, null, null), - new PromptSlotDefinition( - "severity", - false, - "string", - null, - null, - null, - List.of("low", "medium", "high"), - null, - null))), - "Extract slots from the input.", - "Return slots as JSON."); - - Map slots = extractor.extractSlots( - "Analyze Site A with critical severity.", - "energy-saving", - "en-US", - "Site: {site}\nNotes: {additional_notes}\nLimit: {limit}\nSeverity: {severity}"); - - assertEquals( - Map.of( - "site", "Site A", - "additional_notes", "", - "limit", "5", - "severity", "high"), - slots); - assertEquals(2, llmClient.lastMessages().size()); - assertEquals("system", llmClient.lastMessages().get(0).get("role")); - assertTrue(llmClient.lastMessages().get(1).get("content").contains("energy-saving")); - assertTrue(llmClient.lastMessages().get(1).get("content").contains("Analyze Site A with critical severity.")); - assertTrue(llmClient.lastSchema().containsKey("required")); - } - - @Test - void extractSlotsWithSchemaCallsDelegateFourArgMethod() { - RecordingClient llmClient = new RecordingClient( - "{\"slots\":{\"site\":\"Site A\",\"additional_notes\":null,\"limit\":\"5\",\"severity\":\"high\"}," - + "\"slot_errors\":[]}"); - DefaultStructuredClientSlotValueExtractor extractor = new DefaultStructuredClientSlotValueExtractor( - llmClient, - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of( - new PromptSlotDefinition( - "site", true, "string", "^Site .+", null, null, null, null, null), - new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null, null), - new PromptSlotDefinition( - "limit", false, "integer", null, 1.0d, 10.0d, null, null, null), - new PromptSlotDefinition( - "severity", - false, - "string", - null, - null, - null, - List.of("low", "medium", "high"), - null, - null))), - "Extract slots from the input.", - "Return slots as JSON."); - - Map dataSchema = Map.of( - "type", "object", - "properties", Map.of( - "site", Map.of("type", "string", "description", "site description"), - "severity", Map.of("type", "string", "description", "severity description")), - "required", List.of("site")); - Map slots = extractor.extractSlotsWithSchema( - "Analyze Site A with critical severity.", - "energy-saving", - "en-US", - "Site: {site}\nNotes: {additional_notes}\nLimit: {limit}\nSeverity: {severity}", - dataSchema); - - assertEquals( - Map.of( - "site", "Site A", - "additional_notes", "", - "limit", "5", - "severity", "high"), - slots); - assertTrue(llmClient.lastMessages().get(1).get("content").contains("[data_schema]")); - assertTrue(llmClient.lastMessages().get(1).get("content").contains("\"site\"")); - assertTrue(llmClient.lastMessages().get(1).get("content").contains("\"site description\"")); - } - - @Test - void extractSlotsWithSchemaWithNullSchemaStillWorks() { - RecordingClient llmClient = new RecordingClient("{\"slots\":{\"site\":\"Site A\"},\"slot_errors\":[]}"); - DefaultStructuredClientSlotValueExtractor extractor = new DefaultStructuredClientSlotValueExtractor( - llmClient, - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition( - "site", true, "string", "^Site .+", null, null, null, null, null))), - "Extract slots.", - "Return slots."); - - Map slots = - extractor.extractSlotsWithSchema("Analyze Site A.", "scenario", "en", "Site: {site}", null); - - assertEquals(Map.of("site", "Site A"), slots); - assertFalse(llmClient.lastMessages().get(1).get("content").contains("[data_schema]")); - } - - private static final class RecordingClient implements LLMClient { - - private final String payload; - - private List> lastMessages; - - private Map lastSchema; - - private RecordingClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - this.lastMessages = messages; - this.lastSchema = jsonSchema; - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - - private List> lastMessages() { - return lastMessages; - } - - private Map lastSchema() { - return lastSchema; - } - } -} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java deleted file mode 100644 index 8291900a..00000000 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java +++ /dev/null @@ -1,190 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.extractor; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import org.junit.jupiter.api.Test; - -class DefaultTemplateDrivenSlotValueExtractorTest { - - @Test - void extractSlotsUsesOnlyTemplateReferencedKeysFromMapInput() { - DefaultTemplateDrivenSlotValueExtractor extractor = - new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( - scenarioCode, - new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null), - new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null, null), - new PromptSlotDefinition("ignored", false, "string", null, null, null, null, null, null))); - - Map slots = extractor.extractSlots( - Map.of("site", "Site A", "additional_notes", "critical", "ignored", "value"), - "energy-saving", - "en-US", - "Site: {site}\nNotes: {additional_notes}"); - - assertEquals(Map.of("site", "Site A", "additional_notes", "critical"), slots); - } - - @Test - void extractSlotsMapsStringInputToSchemaDefinedInputSlot() { - DefaultTemplateDrivenSlotValueExtractor extractor = - new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( - scenarioCode, - new PromptSlotDefinition("input", true, "string", null, null, null, null, null, null))); - - Map slots = extractor.extractSlots("Analyze Site A.", "free-text", "en-US", "Input: {input}"); - - assertEquals(Map.of("input", "Analyze Site A."), slots); - } - - @Test - void extractSlotsUsesDoubleBracedTemplateReferencedKeysFromMapInput() { - DefaultTemplateDrivenSlotValueExtractor extractor = - new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( - scenarioCode, - new PromptSlotDefinition("topic", false, "string", null, null, null, null, null, null), - new PromptSlotDefinition("condition", false, "string", null, null, null, null, null, null), - new PromptSlotDefinition( - "report_format", false, "string", null, null, null, null, null, null))); - - Map slots = extractor.extractSlots( - Map.of( - "topic", "Incident", - "condition", "Severity is critical", - "report_format", "Report incident data through DataPart"), - "subscribe-incident", - "en-US", - "Topic: {{topic}}\nCondition: {{condition}}\nReport Format: {{report_format}}"); - - assertEquals( - Map.of( - "topic", "Incident", - "condition", "Severity is critical", - "report_format", "Report incident data through DataPart"), - slots); - } - - @Test - void extractSlotsBlanksValueWhenSchemaConstraintIsNotSatisfied() { - DefaultTemplateDrivenSlotValueExtractor extractor = - new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( - scenarioCode, - new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null, null), - new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null, null))); - - Map slots = extractor.extractSlots( - Map.of("site", "invalid", "additional_notes", "critical"), - "energy-saving", - "en-US", - "Site: {site}\nNotes: {additional_notes}"); - - assertEquals(Map.of("site", "", "additional_notes", "critical"), slots); - } - - @Test - void extractSlotsNormalizesAndFiltersEnumAndNumericConstraints() { - DefaultTemplateDrivenSlotValueExtractor extractor = - new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( - scenarioCode, - new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null, null), - new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null, null), - new PromptSlotDefinition("limit", false, "integer", null, 1.0d, 10.0d, null, null, null), - new PromptSlotDefinition( - "severity", - false, - "string", - null, - null, - null, - List.of("low", "medium", "high"), - null, - null))); - - Map slots = extractor.extractSlots( - Map.of( - "site", "Site A", - "additional_notes", "critical", - "limit", "5", - "severity", "high"), - "energy-saving", - "en-US", - "Site: {site}\nNotes: {additional_notes}\nLimit: {limit}\nSeverity: {severity}"); - - assertEquals( - Map.of( - "site", "Site A", - "additional_notes", "critical", - "limit", "5", - "severity", "high"), - slots); - } - - @Test - void extractSlotsBlanksValuesWhenEnumOrRangeConstraintsFail() { - DefaultTemplateDrivenSlotValueExtractor extractor = - new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( - scenarioCode, - new PromptSlotDefinition("site", true, "string", "^Site .+", null, null, null, null, null), - new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null, null), - new PromptSlotDefinition("limit", false, "integer", null, 1.0d, 10.0d, null, null, null), - new PromptSlotDefinition( - "severity", - false, - "string", - null, - null, - null, - List.of("low", "medium", "high"), - null, - null))); - - Map slots = extractor.extractSlots( - Map.of( - "site", "Site A", - "additional_notes", "", - "limit", "50", - "severity", "urgent"), - "energy-saving", - "en-US", - "Site: {site}\nNotes: {additional_notes}\nLimit: {limit}\nSeverity: {severity}"); - - assertEquals( - Map.of( - "site", "Site A", - "additional_notes", "", - "limit", "", - "severity", ""), - slots); - } - - private static PromptSlotSchema schema(String scenarioCode, PromptSlotDefinition... definitions) { - return new PromptSlotSchema(scenarioCode, List.of(definitions)); - } - - @Test - void extractSlotsWithSchemaIgnoresSchemaAndBehavesIdenticallyToExtractSlots() { - DefaultTemplateDrivenSlotValueExtractor extractor = - new DefaultTemplateDrivenSlotValueExtractor((scenarioCode, language) -> schema( - scenarioCode, - new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null), - new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null, null), - new PromptSlotDefinition("ignored", false, "string", null, null, null, null, null, null))); - - Map slots = extractor.extractSlotsWithSchema( - Map.of("site", "Site A", "additional_notes", "critical", "ignored", "value"), - "energy-saving", - "en-US", - "Site: {site}\nNotes: {additional_notes}", - Map.of("ignored_field", "some description")); - - assertEquals(Map.of("site", "Site A", "additional_notes", "critical"), slots); - } -} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoaderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoaderTest.java deleted file mode 100644 index f0670a78..00000000 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientSlotSchemaLoaderTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.loader; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; -import org.junit.jupiter.api.Test; - -class DefaultClasspathClientSlotSchemaLoaderTest { - - @Test - void loadSlotSchemaReadsJsonSchemaFromClasspathPromptResources() { - DefaultClasspathClientSlotSchemaLoader loader = - new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); - - PromptSlotSchema schema = loader.loadSlotSchema("energy-saving", "zh-CN"); - - assertEquals("energy-saving", schema.scenarioCode()); - assertEquals(6, schema.slotDefinitions().size()); - assertFalse(schema.slotDefinitions().get(0).name().isBlank()); - assertFalse(schema.slotDefinitions().get(0).required()); - assertEquals("string", schema.slotDefinitions().get(0).jsonType()); - assertFalse(schema.slotDefinitions().get(1).name().isBlank()); - assertFalse(schema.slotDefinitions().get(3).name().isBlank()); - assertTrue(schema.slotDefinitions().get(3).description().length() > 10); - } - - @Test - void loadSlotSchemaPropagatesTypedMissingResourceError() { - DefaultClasspathClientSlotSchemaLoader loader = - new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); - - assertThrows(ResourceNotFoundException.class, () -> loader.loadSlotSchema("missing_scenario", "zh-CN")); - } - - @Test - void loadSlotSchemaKeepsSubscribeIncidentOptionalConditionConstraints() { - DefaultClasspathClientSlotSchemaLoader loader = - new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); - - PromptSlotSchema schema = loader.loadSlotSchema("subscribe-incident", "zh-CN"); - - assertEquals("subscribe-incident", schema.scenarioCode()); - assertEquals(3, schema.slotDefinitions().size()); - assertTrue(schema.slotDefinitions().stream().noneMatch(PromptSlotDefinition::required)); - } -} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientTemplateLoaderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientTemplateLoaderTest.java deleted file mode 100644 index a1f0c0a1..00000000 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/DefaultClasspathClientTemplateLoaderTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.loader; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; -import org.junit.jupiter.api.Test; - -class DefaultClasspathClientTemplateLoaderTest { - - @Test - void loadTemplateReadsTemplateFromClasspathPromptResources() { - DefaultClasspathClientTemplateLoader loader = - new DefaultClasspathClientTemplateLoader(new ClasspathPromptResourceLoader()); - - String templateText = loader.loadTemplate("energy-saving", "en-US"); - - assertFalse(templateText.isBlank()); - assertTrue(templateText.contains("{site}")); - } - - @Test - void loadTemplatePropagatesTypedMissingResourceError() { - DefaultClasspathClientTemplateLoader loader = - new DefaultClasspathClientTemplateLoader(new ClasspathPromptResourceLoader()); - - assertThrows(ResourceNotFoundException.class, () -> loader.loadTemplate("missing_scenario", "zh-CN")); - } -} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoaderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoaderTest.java deleted file mode 100644 index 084bee5b..00000000 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/LocalFileClientSlotSchemaLoaderTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.loader; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.nio.file.Path; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import org.junit.jupiter.api.Test; - -class LocalFileClientSlotSchemaLoaderTest { - - @Test - void loadSlotSchemaDeserializesJsonSchemaModelFromLocalFile() { - LocalFileClientSlotSchemaLoader loader = new LocalFileClientSlotSchemaLoader( - Path.of("..", "a2a-t-resources", "src", "main", "resources", "prompt_resources")); - - PromptSlotSchema schema = loader.loadSlotSchema("energy-saving", "zh-CN"); - - assertEquals("energy-saving", schema.scenarioCode()); - assertEquals(6, schema.slotDefinitions().size()); - - PromptSlotDefinition taskObject = schema.slotDefinitions().get(2); - assertFalse(taskObject.name().isBlank()); - assertEquals("string", taskObject.jsonType()); - assertTrue(taskObject.description().length() > 10); - - PromptSlotDefinition taskContext = schema.slotDefinitions().get(4); - assertFalse(taskContext.name().isBlank()); - assertTrue(taskContext.description().length() > 10); - } -} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 1d9285f8..2071ec07 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -9,10 +9,6 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; -import net.openan.a2at.sdk.client.prompt.loader.ClientSlotSchemaLoader; -import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; -import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; @@ -22,7 +18,12 @@ import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognitionFunction; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; @@ -31,7 +32,7 @@ class DefaultClientPromptGenerationOrchestratorTest { - private static final ClientSlotSchemaLoader EMPTY_SCHEMA_LOADER = + private static final PromptSlotSchemaLoader EMPTY_SCHEMA_LOADER = (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()); private static final TemplateUri AUTH_DATABASE_READ = TemplateUri.of("Authorization-T", "database_read"); @@ -41,9 +42,9 @@ void generateTaskPromptLoadsTemplateAndRendersExtractedSlotsWhenScenarioIsMatche FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}\nNotes: {additional_notes}"); FakeSlotValueExtractor slotValueExtractor = new FakeSlotValueExtractor(Map.of("site", "Site A", "additional_notes", "critical")); + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "energy-saving", null), + recognizer, List.of(new ScenarioDefinition( "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), "en-US", @@ -58,13 +59,12 @@ void generateTaskPromptLoadsTemplateAndRendersExtractedSlotsWhenScenarioIsMatche assertTrue(result.success()); assertEquals("Site: Site A\nNotes: critical", result.promptText()); - assertEquals("Analyze Site A.", orchestrator.lastNormalizedInput()); + assertEquals("Analyze Site A.", recognizer.lastNormalizedInput); assertEquals("energy-saving", templateLoader.lastScenarioCode); assertEquals("en-US", templateLoader.lastLanguage); assertEquals("Analyze Site A.", slotValueExtractor.lastUserInput); assertEquals("energy-saving", slotValueExtractor.lastScenarioCode); assertEquals("en-US", slotValueExtractor.lastLanguage); - assertEquals("Site: {site}\nNotes: {additional_notes}", slotValueExtractor.lastTemplateText); } @Test @@ -78,8 +78,8 @@ void generateTaskPromptReturnsFailureWhenScenarioIsNotMatched() { "Identify the best matching scenario.", "Choose from the provided scenario list.", (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", - (userInput, scenarioCode, language, templateText) -> - Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), new TaskPromptRenderer(), EMPTY_SCHEMA_LOADER); @@ -104,8 +104,8 @@ void generateTaskPromptReturnsFailureWhenTemplateIsMissing() { (scenarioCode, language) -> { throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); }, - (userInput, scenarioCode, language, templateText) -> - Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), new TaskPromptRenderer(), EMPTY_SCHEMA_LOADER); @@ -128,7 +128,8 @@ void generateTaskPromptReturnsFailureWhenRenderingFails() { "Identify the best matching scenario.", "Choose from the provided scenario list.", (scenarioCode, language) -> "Scenario: {scenario}\nMissing: {{missing_slot}}", - (userInput, scenarioCode, language, templateText) -> Map.of("scenario", scenarioCode), + (userInput, scenarioCode, language) -> + new StructuredSlotExtractionResult(Map.of("scenario", scenarioCode), List.of()), new TaskPromptRenderer(), EMPTY_SCHEMA_LOADER); @@ -154,8 +155,8 @@ void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissi "Identify the best matching scenario.", "Choose from the provided scenario list.", (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", - (userInput, scenarioCode, language, templateText) -> - Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), new TaskPromptRenderer(), EMPTY_SCHEMA_LOADER); @@ -168,9 +169,9 @@ void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissi } private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestrator( - ClientScenarioRecognizer recognizer, - ClientTemplateLoader templateLoader, - ClientSlotValueExtractor slotValueExtractor) { + ScenarioRecognitionFunction recognizer, + PromptTemplateTextLoader templateLoader, + PromptSlotValueExtractor slotValueExtractor) { return new DefaultClientPromptGenerationOrchestrator( recognizer, List.of(new ScenarioDefinition( @@ -184,18 +185,20 @@ private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestra EMPTY_SCHEMA_LOADER); } - private static final class RecordingScenarioRecognizer implements ClientScenarioRecognizer { + private static final class RecordingScenarioRecognizer implements ScenarioRecognitionFunction { private int invocationCount; + private String lastNormalizedInput; @Override public ScenarioRecognitionResult recognize( String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { this.invocationCount++; + this.lastNormalizedInput = normalizedInput; return new ScenarioRecognitionResult(true, "energy-saving", null); } } - private static final class CountingFailingTemplateLoader implements ClientTemplateLoader { + private static final class CountingFailingTemplateLoader implements PromptTemplateTextLoader { private int loadCount; @Override @@ -205,7 +208,7 @@ public String loadTemplate(String scenarioCode, String language) { } } - private static final class FakeTemplateLoader implements ClientTemplateLoader { + private static final class FakeTemplateLoader implements PromptTemplateTextLoader { private final String templateText; private String lastScenarioCode; private String lastLanguage; @@ -222,86 +225,70 @@ public String loadTemplate(String scenarioCode, String language) { } } - private static final class FakeSlotValueExtractor implements ClientSlotValueExtractor { + private static final class FakeSlotValueExtractor implements PromptSlotValueExtractor { private final Map slots; private Object lastUserInput; private String lastScenarioCode; private String lastLanguage; - private String lastTemplateText; private FakeSlotValueExtractor(Map slots) { this.slots = slots; } @Override - public Map extractSlots( - Object userInput, String scenarioCode, String language, String templateText) { + public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { this.lastUserInput = userInput; this.lastScenarioCode = scenarioCode; this.lastLanguage = language; - this.lastTemplateText = templateText; - return slots; + return new StructuredSlotExtractionResult(slots, List.of()); } } - private static final class FakeSlotValueExtractorWithSchema implements ClientSlotValueExtractor { + private static final class FakeSlotValueExtractorWithSchema implements PromptSlotValueExtractor { private final Map slots; private Object lastUserInput; private String lastScenarioCode; private String lastLanguage; - private String lastTemplateText; - private Map lastSchema; + private Map lastDataSchema; private FakeSlotValueExtractorWithSchema(Map slots) { this.slots = slots; } @Override - public Map extractSlots( - Object userInput, String scenarioCode, String language, String templateText) { + public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { this.lastUserInput = userInput; this.lastScenarioCode = scenarioCode; this.lastLanguage = language; - this.lastTemplateText = templateText; - return slots; + return new StructuredSlotExtractionResult(slots, List.of()); } @Override - public Map extractSlotsWithSchema( - Object userInput, - String scenarioCode, - String language, - String templateText, - Map dataSchema) { + public StructuredSlotExtractionResult extractSlots( + Object userInput, String scenarioCode, String language, Map dataSchema) { this.lastUserInput = userInput; this.lastScenarioCode = scenarioCode; this.lastLanguage = language; - this.lastTemplateText = templateText; - this.lastSchema = dataSchema; - return slots; + this.lastDataSchema = dataSchema; + return new StructuredSlotExtractionResult(slots, List.of()); } } - private static final class FailingExtractSlotsWithSchema implements ClientSlotValueExtractor { + private static final class FailingSlotValueExtractor implements PromptSlotValueExtractor { private final RuntimeException exception; - private FailingExtractSlotsWithSchema(RuntimeException exception) { + private FailingSlotValueExtractor(RuntimeException exception) { this.exception = exception; } @Override - public Map extractSlots( - Object userInput, String scenarioCode, String language, String templateText) { - return Map.of(); + public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { + return new StructuredSlotExtractionResult(Map.of(), List.of()); } @Override - public Map extractSlotsWithSchema( - Object userInput, - String scenarioCode, - String language, - String templateText, - Map dataSchema) { + public StructuredSlotExtractionResult extractSlots( + Object userInput, String scenarioCode, String language, Map dataSchema) { throw exception; } } @@ -394,7 +381,7 @@ void generateFromTemplateUriWithMetadataPropagatesLlmRuntimeError() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( new RecordingScenarioRecognizer(), new FakeTemplateLoader("Site: {site}"), - (userInput, scenarioCode, language, templateText) -> { + (userInput, scenarioCode, language) -> { throw new LLMRuntimeError("LLM invocation failed."); }); @@ -434,9 +421,23 @@ void generateFromDataWithSchemaPassesSchemaToExtractor() { orchestrator.generateTaskPromptFromDataWithSchema( Map.of("site", "Site A"), schema, StandardTemplates.ENERGY_SAVING); - assertEquals(schema, slotValueExtractor.lastSchema); + assertEquals(schema, slotValueExtractor.lastDataSchema); assertEquals(StandardTemplates.ENERGY_SAVING.uri(), slotValueExtractor.lastScenarioCode); - assertEquals("Site: {site}", slotValueExtractor.lastTemplateText); + } + + @Test + void generateTaskPromptFromDataWithSchemaRejectsNullData() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + NullPointerException ex = assertThrows( + NullPointerException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + null, Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); + assertTrue(ex.getMessage().contains("data")); } @Test @@ -504,7 +505,7 @@ void generateFromDataWithSchemaPropagatesLlmRuntimeError() { DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( new RecordingScenarioRecognizer(), new FakeTemplateLoader("Site: {site}"), - new FailingExtractSlotsWithSchema(new LLMRuntimeError("LLM invocation failed."))); + new FailingSlotValueExtractor(new LLMRuntimeError("LLM invocation failed."))); PromptGenerationException ex = assertThrows( PromptGenerationException.class, @@ -518,7 +519,7 @@ void generateFromDataWithSchemaPropagatesResourceNotFoundExceptionFromExtractor( DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( new RecordingScenarioRecognizer(), new FakeTemplateLoader("Site: {site}"), - new FailingExtractSlotsWithSchema( + new FailingSlotValueExtractor( new ResourceNotFoundException("Slot schema file does not exist.", "energy-saving"))); PromptGenerationException ex = assertThrows( @@ -608,14 +609,14 @@ void authorizationMetadataEntryPointsPassTemplateUriIdentifier() { assertEquals("Authorization-T/database_read/v1", jsonResult.templateUri()); assertEquals("Authorization-T/database_read/v1", templateLoader.lastScenarioCode); assertEquals(Map.of("scope", "read"), slotValueExtractor.lastUserInput); - assertEquals(Map.of("scope", "string"), slotValueExtractor.lastSchema); + assertEquals(Map.of("scope", "string"), slotValueExtractor.lastDataSchema); } // --- slot validation tests --- @Test void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { - ClientSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, "Site name", null))); DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( @@ -641,7 +642,7 @@ void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { @Test void validateRequiredSlotsThrowsSlotValidationErrorWhenMultipleRequiredSlotsMissing() { - ClientSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, List.of( new PromptSlotDefinition("site", true, "string", null, null, null, null, "Site name", null), @@ -695,7 +696,7 @@ void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundFromExtractor() "", "", new FakeTemplateLoader("Site: {site}"), - (userInput, scenarioCode, language, templateText) -> { + (userInput, scenarioCode, language) -> { throw new ResourceNotFoundException("Slot schema file does not exist.", scenarioCode); }, new TaskPromptRenderer(), @@ -737,7 +738,7 @@ void generateFromTemplateUriWithMetadataThrowsLlmInvocationFailedFromA2ATError() "", "", new FakeTemplateLoader("Site: {site}"), - (userInput, scenarioCode, language, templateText) -> { + (userInput, scenarioCode, language) -> { throw new A2ATError("Unparseable LLM response."); }, new TaskPromptRenderer(), @@ -748,4 +749,97 @@ void generateFromTemplateUriWithMetadataThrowsLlmInvocationFailedFromA2ATError() () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); assertEquals("llm_invocation_failed", ex.getCode()); } + + @Test + void validateRequiredSlotsThrowsSlotValidationErrorWhenSlotValueIsEmptyString() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "")), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot_validation_error", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("site", ex.failedParameters().get(0).slotName()); + assertEquals("missing_required", ex.failedParameters().get(0).code()); + } + + @Test + void validateRequiredSlotsThrowsSlotValidationErrorWhenSlotValueIsBlankString() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", " ")), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot_validation_error", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("site", ex.failedParameters().get(0).slotName()); + } + + @Test + void validateRequiredSlotsSucceedsWhenSchemaHasNoRequiredSlotsAndAllSlotsAreEmpty() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition("site", false, "string", null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of()), + new TaskPromptRenderer(), + schemaLoader); + + MetadataContent result = + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + assertNotNull(result); + assertNotNull(result.promptText()); + } + + @Test + void validateRequiredSlotsIsNotInvokedInGenerateTaskPromptPipeline() { + PromptSlotSchemaLoader failingSchemaLoader = (scenarioCode, language) -> { + throw new A2ATError("Schema loading should not be triggered in generateTaskPrompt pipeline."); + }; + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(true, "energy_saving", null), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + (scenarioCode, language) -> "Site: {site}", + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + failingSchemaLoader); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + assertTrue(result.success()); + assertEquals("Site: Site A", result.promptText()); + } } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java deleted file mode 100644 index 772b7906..00000000 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/recognition/LocalFileClientScenarioCatalogLoaderTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.recognition; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import org.junit.jupiter.api.Test; - -class LocalFileClientScenarioCatalogLoaderTest { - - @Test - void loadDeserializesScenarioCatalogFromLocalFile() { - LocalFileClientScenarioCatalogLoader loader = new LocalFileClientScenarioCatalogLoader( - Path.of("..", "a2a-t-resources", "src", "main", "resources", "prompt_resources")); - - List scenarios = loader.load("zh-CN"); - - assertEquals(4, scenarios.size()); - assertEquals("subscribe-incident", scenarios.get(0).scenarioCode()); - assertEquals("energy-saving", scenarios.get(1).scenarioCode()); - } -} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognitionFunction.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognitionFunction.java new file mode 100644 index 00000000..87c352ea --- /dev/null +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognitionFunction.java @@ -0,0 +1,21 @@ +package net.openan.a2at.sdk.prompt.analysis.impl; + +import java.util.List; +import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; + +/** + * Shared recognition contract exported by the prompt module. + * + *

      This functional interface defines the single abstract method for scenario recognition. Client-side wrappers and + * test injections use this type as the dependency, while {@link ScenarioRecognizer} provides the LLM-based + * implementation. + * + * @since 2026-08 + */ +@FunctionalInterface +public interface ScenarioRecognitionFunction { + + ScenarioRecognitionResult recognize( + String normalizedInput, List scenarios, String systemPrompt, String userPrompt); +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizer.java index bdff83b3..1ee62b47 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizer.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizer.java @@ -18,7 +18,7 @@ * * @since 2026-05 */ -public final class ScenarioRecognizer { +public final class ScenarioRecognizer implements ScenarioRecognitionFunction { private final LLMClient llmClient; @@ -53,6 +53,7 @@ public ScenarioRecognizer(LLMClient llmClient, JsonValueParser parser) { * @param userPrompt user prompt for recognition * @return recognition result */ + @Override public ScenarioRecognitionResult recognize( String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { Map payload = parser.parseObject(llmClient diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java index e24b37eb..5406fa07 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.prompt.analysis.impl; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -40,8 +41,7 @@ void recognizeBuildsStructuredMessagesAndReturnsMatchedScenario() { @Test void recognizeRejectsMatchedPayloadWithoutScenarioCode() { - LLMClient llmClient = - new RecordingClient("{\"matched\":true,\"scenario_code\":null,\"error_message\":null}"); + LLMClient llmClient = new RecordingClient("{\"matched\":true,\"scenario_code\":null,\"error_message\":null}"); ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient); @@ -77,6 +77,12 @@ void recognizeCanUseSharedJsonParserAbstraction() { assertEquals("ignored", parser.lastPayload); } + @Test + void scenarioRecognizerImplementsScenarioRecognitionFunction() { + ScenarioRecognizer recognizer = new ScenarioRecognizer(new RecordingClient("ignored")); + assertInstanceOf(ScenarioRecognitionFunction.class, recognizer); + } + private static final class RecordingClient implements LLMClient { private final String payload; From 66435528c2a9f0ec64e67ad6fdf10bccf286c514 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sun, 23 Aug 2026 12:15:35 +0800 Subject: [PATCH 147/295] test: backfill characterization tests for recognizer, orchestrator, builder and path segment behavior Signed-off-by: w00609601 --- .../DefaultA2ATClientBuilderTest.java | 54 +++++++ .../AuthorizationSlotSchemaResourceTest.java | 98 ------------ ...lientPromptGenerationOrchestratorTest.java | 141 ++++++++++++++++++ .../sdk/core/resources/PathSegmentsTest.java | 46 ++++++ ...otiationGenerationOrchestratorBuilder.java | 5 +- .../analysis/impl/ScenarioRecognizerTest.java | 71 +++++++++ 6 files changed, 313 insertions(+), 102 deletions(-) delete mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/AuthorizationSlotSchemaResourceTest.java create mode 100644 a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java index f10017f5..e729fe0c 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.client.prompt.assembly; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.io.IOException; import java.nio.file.Files; @@ -8,6 +9,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; @@ -46,6 +48,58 @@ void should_createLlmClientOnlyOnce_When_multipleBuildMethodsCalled() throws IOE assertEquals(1, CLIENT_INSTANCE_COUNT.get()); } + @Test + void buildPromptGenerationOrchestratorPropagatesMissingScenarioCatalog() throws IOException { + String provider = "test-scenario-failure"; + if (!LLMClientFactory.availableProviders().contains(provider)) { + LLMClientFactory.register(provider, CountingClient.class); + } + + Path envFile = createTempEnvFileWithoutScenarioCatalog(provider); + A2ATConfig config = A2ATConfig.load(envFile); + config = net.openan.a2at.sdk.negotiation.generation.NegotiationContentService.resolvePromptResourceLocalRootDir( + config, envFile); + + DefaultA2ATClientBuilder builder = + DefaultA2ATClientBuilder.builder().config(config).envPath(envFile); + + assertThrows(ResourceNotFoundException.class, () -> builder.buildPromptGenerationOrchestrator()); + } + + private static Path createTempEnvFileWithoutScenarioCatalog(String provider) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-builder-scenario"); + Path promptRoot = tempDir.resolve("prompt_resources"); + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.createDirectories(scenariosDir); + + Files.writeString(scenarioPromptDir.resolve("system.md"), "You are a scenario recognition assistant."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Identify the best matching scenario."); + Files.writeString(slotPromptDir.resolve("system.md"), "You are a slot extraction assistant."); + Files.writeString(slotPromptDir.resolve("user.md"), "Extract slots from the input."); + + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(provider)); + return envFile; + } + private static Path createTempEnvFile(String provider) throws IOException { Path tempDir = Files.createTempDirectory("a2at-client-builder-lazy"); Path promptRoot = tempDir.resolve("prompt_resources"); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/AuthorizationSlotSchemaResourceTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/AuthorizationSlotSchemaResourceTest.java deleted file mode 100644 index 7e0dee89..00000000 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/loader/AuthorizationSlotSchemaResourceTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package net.openan.a2at.sdk.client.prompt.loader; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; -import org.junit.jupiter.api.Test; - -class AuthorizationSlotSchemaResourceTest { - - @Test - void should_loadAuthorizationSlotSchemaFromClasspath_ForZhCN() { - DefaultClasspathClientSlotSchemaLoader loader = - new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); - - PromptSlotSchema schema = loader.loadSlotSchema("authorization-policy-management", "zh-CN"); - - assertEquals("authorization-policy-management", schema.scenarioCode()); - assertEquals(2, schema.slotDefinitions().size()); - assertTrue(schema.slotDefinitions().get(0).required()); - assertEquals("授权策略的操作类型", schema.slotDefinitions().get(0).name()); - } - - @Test - void should_loadAuthorizationSlotSchema_ForEnUS() { - DefaultClasspathClientSlotSchemaLoader loader = - new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); - - PromptSlotSchema schema = loader.loadSlotSchema("authorization-policy-management", "en-US"); - - assertEquals("authorization-policy-management", schema.scenarioCode()); - assertEquals(2, schema.slotDefinitions().size()); - assertTrue(schema.slotDefinitions().get(0).required()); - assertEquals("authorization_policy_operation_type", schema.slotDefinitions().get(0).name()); - } - - @Test - void should_haveOperationTypeOnlyInRequired() { - DefaultClasspathClientSlotSchemaLoader loader = - new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); - - PromptSlotSchema schema = loader.loadSlotSchema("authorization-policy-management", "zh-CN"); - - long requiredCount = schema.slotDefinitions().stream() - .filter(PromptSlotDefinition::required) - .count(); - assertEquals(1, requiredCount); - assertEquals("授权策略的操作类型", schema.slotDefinitions().get(0).name()); - assertFalse(schema.slotDefinitions().get(1).required()); - } - - @Test - void should_havePropertyNamesMatchingTemplatePlaceholders() { - DefaultClasspathClientSlotSchemaLoader loader = - new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); - - PromptSlotSchema zhSchema = loader.loadSlotSchema("authorization-policy-management", "zh-CN"); - PromptSlotSchema enSchema = loader.loadSlotSchema("authorization-policy-management", "en-US"); - - assertEquals("授权策略的操作类型", zhSchema.slotDefinitions().get(0).name()); - assertEquals("动网操作的授权策略列表", zhSchema.slotDefinitions().get(1).name()); - assertEquals("authorization_policy_operation_type", enSchema.slotDefinitions().get(0).name()); - assertEquals("network_operation_authorization_policy_list", enSchema.slotDefinitions().get(1).name()); - } - - @Test - void should_haveSchemaStructureParity() { - DefaultClasspathClientSlotSchemaLoader loader = - new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); - - PromptSlotSchema authSchema = loader.loadSlotSchema("authorization-policy-management", "zh-CN"); - PromptSlotSchema energySchema = loader.loadSlotSchema("energy-saving", "zh-CN"); - - assertFalse(authSchema.slotDefinitions().isEmpty()); - assertFalse(energySchema.slotDefinitions().isEmpty()); - - for (PromptSlotDefinition def : authSchema.slotDefinitions()) { - assertEquals("string", def.jsonType()); - assertFalse(def.description().isBlank()); - assertFalse(def.valueConstraint().isBlank()); - } - } - - @Test - void should_throwResourceNotFoundException_WhenSlotSchemaMissing() { - DefaultClasspathClientSlotSchemaLoader loader = - new DefaultClasspathClientSlotSchemaLoader(new ClasspathPromptResourceLoader()); - - assertThrows( - ResourceNotFoundException.class, - () -> loader.loadSlotSchema("nonexistent-scenario", "zh-CN")); - } -} \ No newline at end of file diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 2071ec07..296d101f 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Collections; import java.util.List; import java.util.Map; import net.openan.a2at.sdk.client.model.PromptGenerationResult; @@ -22,6 +23,7 @@ import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognitionFunction; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; @@ -842,4 +844,143 @@ void validateRequiredSlotsIsNotInvokedInGenerateTaskPromptPipeline() { assertTrue(result.success()); assertEquals("Site: Site A", result.promptText()); } + + @Test + void generateTaskPromptNormalizesNullInputToString() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition( + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt(null); + + assertTrue(result.success()); + assertEquals("null", recognizer.lastNormalizedInput); + assertEquals("Site: Site A", result.promptText()); + } + + @Test + void generateTaskPromptReturnsFailureWithEmptyScenarioList() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> { + assertEquals(0, scenarios.size()); + return new ScenarioRecognitionResult(false, null, "No scenarios available."); + }, + List.of(), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of()), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("scenario_not_matched", result.failure().code()); + assertEquals("scenario", result.failure().stage()); + } + + @Test + void generateTaskPromptEscapesLlmRuntimeErrorFromExtractor() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(true, "energy-saving", null), + List.of(new ScenarioDefinition( + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Site: {site}", + (userInput, scenarioCode, language) -> { + throw new LLMRuntimeError("LLM invocation failed."); + }, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + assertThrows(LLMRuntimeError.class, () -> orchestrator.generateTaskPrompt("Analyze Site A.")); + } + + @Test + void generateTaskPromptSucceedsWhenExtractionHasErrors() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition( + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("site", "Site A"), + List.of(new StructuredSlotValidationError( + "extra_field", "unexpected_field", "Extra field ignored."))), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertTrue(result.success()); + assertEquals("Site: Site A", result.promptText()); + } + + @Test + void generateFromTemplateUriWithMetadataEmptyInputProducesSlotValidationError() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of()), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot_validation_error", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("site", ex.failedParameters().get(0).slotName()); + } + + @Test + void validateRequiredSlotsTreatsNullSlotValueAsMissing() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Collections.singletonMap("site", null)), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot_validation_error", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("site", ex.failedParameters().get(0).slotName()); + assertEquals("missing_required", ex.failedParameters().get(0).code()); + } } diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java new file mode 100644 index 00000000..ba579c49 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java @@ -0,0 +1,46 @@ +package net.openan.a2at.sdk.core.resources; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class PathSegmentsTest { + + @Test + void should_rejectNull_When_valueIsNull() { + assertFalse(PathSegments.isSimpleSegment(null)); + } + + @Test + void should_rejectBlank_When_valueIsBlank() { + assertFalse(PathSegments.isSimpleSegment("")); + assertFalse(PathSegments.isSimpleSegment(" ")); + } + + @Test + void should_rejectForwardSlash_When_valueContainsSlash() { + assertFalse(PathSegments.isSimpleSegment("a/b")); + assertFalse(PathSegments.isSimpleSegment("/")); + } + + @Test + void should_rejectBackslash_When_valueContainsBackslash() { + assertFalse(PathSegments.isSimpleSegment("a\\b")); + assertFalse(PathSegments.isSimpleSegment("\\")); + } + + @Test + void should_rejectTraversal_When_valueContainsDotDot() { + assertFalse(PathSegments.isSimpleSegment("..")); + assertFalse(PathSegments.isSimpleSegment("a/../b")); + } + + @Test + void should_acceptValidSegment_When_valueIsSimple() { + assertTrue(PathSegments.isSimpleSegment("energy-saving")); + assertTrue(PathSegments.isSimpleSegment("Task-T")); + assertTrue(PathSegments.isSimpleSegment("zh-CN")); + assertTrue(PathSegments.isSimpleSegment("a.b")); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index 6e201f19..f9c86abd 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -9,7 +9,6 @@ import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationSemanticValidator; import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; -import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckerAdapter; import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; import net.openan.a2at.sdk.negotiation.validation.NegotiationValidationException; import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; @@ -182,10 +181,8 @@ public NegotiationGenerationOrchestrator build() { complianceChecker != null ? complianceChecker : new DefaultNegotiationComplianceChecker(); NegotiationSemanticValidator effectiveSemanticValidator = semanticValidator != null ? semanticValidator : defaultSemanticValidator(); - NegotiationRuleCheckerAdapter ruleCheckerAdapter = - new NegotiationRuleCheckerAdapter(effectiveComplianceChecker, vocabulary); ParamExtractor paramExtractor = - new ParamExtractor(ruleCheckerAdapter, effectiveSemanticValidator, maxAttempts); + new ParamExtractor(effectiveComplianceChecker, effectiveSemanticValidator, maxAttempts); return new NegotiationGenerationOrchestrator( language, maxAttempts, diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java index 5406fa07..fd9a97a8 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java @@ -1,7 +1,9 @@ package net.openan.a2at.sdk.prompt.analysis.impl; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -83,6 +85,75 @@ void scenarioRecognizerImplementsScenarioRecognitionFunction() { assertInstanceOf(ScenarioRecognitionFunction.class, recognizer); } + @Test + void recognizeReturnsUnmatchedWithErrorMessageWhenMatchedIsFalse() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":false,\"scenario_code\":null,\"error_message\":\"No scenario matched.\"}"); + + ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient); + ScenarioRecognitionResult result = recognizer.recognize( + "Unrelated input.", + List.of(new ScenarioDefinition( + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "Identify the best matching scenario.", + "Choose from the provided scenario list."); + + assertFalse(result.matched()); + assertNotNull(result.errorMessage()); + assertEquals("No scenario matched.", result.errorMessage()); + } + + @Test + void recognizeTreatsNonBooleanMatchedAsUnmatched() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":\"yes\",\"scenario_code\":null,\"error_message\":\"Ambiguous\"}"); + + ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient); + ScenarioRecognitionResult result = recognizer.recognize( + "Ambiguous input.", + List.of(new ScenarioDefinition( + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "Identify the best matching scenario.", + "Choose from the provided scenario list."); + + assertFalse(result.matched()); + assertEquals("Ambiguous", result.errorMessage()); + } + + @Test + void recognizeRejectsUnmatchedPayloadWithScenarioCode() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":false,\"scenario_code\":\"energy-saving\",\"error_message\":null}"); + + ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient); + + assertThrows( + ScenarioRecognitionException.class, + () -> recognizer.recognize( + "Analyze site A energy usage.", + List.of(new ScenarioDefinition( + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "Identify the best matching scenario.", + "Choose from the provided scenario list.")); + } + + @Test + void recognizeRejectsNonStringScenarioCodeField() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":42,\"error_message\":null}"); + + ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient); + + assertThrows( + ScenarioRecognitionException.class, + () -> recognizer.recognize( + "Analyze site A energy usage.", + List.of(new ScenarioDefinition( + "energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "Identify the best matching scenario.", + "Choose from the provided scenario list.")); + } + private static final class RecordingClient implements LLMClient { private final String payload; From c2d5950fdf013395f52b2c10ae412812fa7e1d05 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sun, 23 Aug 2026 13:27:46 +0800 Subject: [PATCH 148/295] test: adapt Authorization slot schema resource test to prompt module loader Signed-off-by: w00609601 --- .../AuthorizationSlotSchemaResourceTest.java | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationSlotSchemaResourceTest.java diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationSlotSchemaResourceTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationSlotSchemaResourceTest.java new file mode 100644 index 00000000..b7ba2999 --- /dev/null +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationSlotSchemaResourceTest.java @@ -0,0 +1,103 @@ +package net.openan.a2at.sdk.client.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.prompt.resources.loader.ClasspathPromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; +import org.junit.jupiter.api.Test; + +class AuthorizationSlotSchemaResourceTest { + + @Test + void should_loadAuthorizationSlotSchemaFromClasspath_ForZhCN() { + ClasspathPromptSlotSchemaLoader loader = + new ClasspathPromptSlotSchemaLoader(new ClasspathPromptResourceLoader()); + + PromptSlotSchema schema = loader.loadSlotSchema("authorization-policy-management", "zh-CN"); + + assertEquals("authorization-policy-management", schema.scenarioCode()); + assertEquals(2, schema.slotDefinitions().size()); + assertTrue(schema.slotDefinitions().get(0).required()); + assertEquals("授权策略的操作类型", schema.slotDefinitions().get(0).name()); + } + + @Test + void should_loadAuthorizationSlotSchema_ForEnUS() { + ClasspathPromptSlotSchemaLoader loader = + new ClasspathPromptSlotSchemaLoader(new ClasspathPromptResourceLoader()); + + PromptSlotSchema schema = loader.loadSlotSchema("authorization-policy-management", "en-US"); + + assertEquals("authorization-policy-management", schema.scenarioCode()); + assertEquals(2, schema.slotDefinitions().size()); + assertTrue(schema.slotDefinitions().get(0).required()); + assertEquals( + "authorization_policy_operation_type", + schema.slotDefinitions().get(0).name()); + } + + @Test + void should_haveOperationTypeOnlyInRequired() { + ClasspathPromptSlotSchemaLoader loader = + new ClasspathPromptSlotSchemaLoader(new ClasspathPromptResourceLoader()); + + PromptSlotSchema schema = loader.loadSlotSchema("authorization-policy-management", "zh-CN"); + + long requiredCount = schema.slotDefinitions().stream() + .filter(PromptSlotDefinition::required) + .count(); + assertEquals(1, requiredCount); + assertEquals("授权策略的操作类型", schema.slotDefinitions().get(0).name()); + assertFalse(schema.slotDefinitions().get(1).required()); + } + + @Test + void should_havePropertyNamesMatchingTemplatePlaceholders() { + ClasspathPromptSlotSchemaLoader loader = + new ClasspathPromptSlotSchemaLoader(new ClasspathPromptResourceLoader()); + + PromptSlotSchema zhSchema = loader.loadSlotSchema("authorization-policy-management", "zh-CN"); + PromptSlotSchema enSchema = loader.loadSlotSchema("authorization-policy-management", "en-US"); + + assertEquals("授权策略的操作类型", zhSchema.slotDefinitions().get(0).name()); + assertEquals("动网操作的授权策略列表", zhSchema.slotDefinitions().get(1).name()); + assertEquals( + "authorization_policy_operation_type", + enSchema.slotDefinitions().get(0).name()); + assertEquals( + "network_operation_authorization_policy_list", + enSchema.slotDefinitions().get(1).name()); + } + + @Test + void should_haveSchemaStructureParity() { + ClasspathPromptSlotSchemaLoader loader = + new ClasspathPromptSlotSchemaLoader(new ClasspathPromptResourceLoader()); + + PromptSlotSchema authSchema = loader.loadSlotSchema("authorization-policy-management", "zh-CN"); + PromptSlotSchema energySchema = loader.loadSlotSchema("energy-saving", "zh-CN"); + + assertFalse(authSchema.slotDefinitions().isEmpty()); + assertFalse(energySchema.slotDefinitions().isEmpty()); + + for (PromptSlotDefinition def : authSchema.slotDefinitions()) { + assertEquals("string", def.jsonType()); + assertFalse(def.description().isBlank()); + assertFalse(def.valueConstraint().isBlank()); + } + } + + @Test + void should_throwResourceNotFoundException_WhenSlotSchemaMissing() { + ClasspathPromptSlotSchemaLoader loader = + new ClasspathPromptSlotSchemaLoader(new ClasspathPromptResourceLoader()); + + assertThrows(ResourceNotFoundException.class, () -> loader.loadSlotSchema("nonexistent-scenario", "zh-CN")); + } +} From 623ffa430bb2981cfce7712dc5ca3cb1d3034d1d Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sun, 23 Aug 2026 13:56:08 +0800 Subject: [PATCH 149/295] style: apply spotless to changed files and drop redundant unmodifiableList wrapper Signed-off-by: w00609601 --- .../openan/a2at/sdk/core/model/LlmConfig.java | 3 +- .../openan/a2at/sdk/llm/LLMClientConfig.java | 20 ++-- .../a2at/sdk/llm/LLMClientConfigFromTest.java | 96 ++++++++++--------- .../a2at/sdk/llm/LlmPackageStructureTest.java | 6 +- .../assembly/DefaultA2ATServerBuilder.java | 26 ++--- .../DefaultA2ATServerBuilderTest.java | 56 +++++++---- 6 files changed, 111 insertions(+), 96 deletions(-) diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java index 86a54bc5..09ae833b 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java @@ -1,7 +1,6 @@ package net.openan.a2at.sdk.core.model; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Map; @@ -82,7 +81,7 @@ public static LlmConfig fromMap(Map values) { parseInt(values.get(A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER), A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER, DEFAULT_SESSION_MAX_PER_PROVIDER, parseErrors), parseMaxAttempts(values.get(A2ATConfigKeys.Llm.MAX_ATTEMPTS)), - Collections.unmodifiableList(parseErrors)); + parseErrors); } private static int parseMaxAttempts(String rawValue) { diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java index 1cf1d82f..2818ac19 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java @@ -106,27 +106,23 @@ private static void validateRequiredKeys(LlmConfig llmConfig) { private static void validateBounds(LlmConfig llmConfig) { if (llmConfig.historyWindow() <= 0 || llmConfig.historyWindow() > MAX_HISTORY_WINDOW) { - throw new LLMConfigError( - "LLM configuration key 'historyWindow' value " + llmConfig.historyWindow() - + " must be between 1 and " + MAX_HISTORY_WINDOW); + throw new LLMConfigError("LLM configuration key 'historyWindow' value " + llmConfig.historyWindow() + + " must be between 1 and " + MAX_HISTORY_WINDOW); } if (llmConfig.sessionMaxTotal() <= 0 || llmConfig.sessionMaxTotal() > MAX_SESSION_TOTAL) { - throw new LLMConfigError( - "LLM configuration key 'sessionMaxTotal' value " + llmConfig.sessionMaxTotal() - + " must be between 1 and " + MAX_SESSION_TOTAL); + throw new LLMConfigError("LLM configuration key 'sessionMaxTotal' value " + llmConfig.sessionMaxTotal() + + " must be between 1 and " + MAX_SESSION_TOTAL); } if (llmConfig.sessionMaxPerProvider() <= 0 || llmConfig.sessionMaxPerProvider() > MAX_SESSION_PER_PROVIDER) { - throw new LLMConfigError( - "LLM configuration key 'sessionMaxPerProvider' value " + llmConfig.sessionMaxPerProvider() - + " must be between 1 and " + MAX_SESSION_PER_PROVIDER); + throw new LLMConfigError("LLM configuration key 'sessionMaxPerProvider' value " + + llmConfig.sessionMaxPerProvider() + " must be between 1 and " + MAX_SESSION_PER_PROVIDER); } } private static void validateSessionOrder(LlmConfig llmConfig) { if (llmConfig.sessionMaxTotal() < llmConfig.sessionMaxPerProvider()) { - throw new LLMConfigError( - "LLM configuration key 'sessionMaxTotal' (" + llmConfig.sessionMaxTotal() - + ") must be >= 'sessionMaxPerProvider' (" + llmConfig.sessionMaxPerProvider() + ")"); + throw new LLMConfigError("LLM configuration key 'sessionMaxTotal' (" + llmConfig.sessionMaxTotal() + + ") must be >= 'sessionMaxPerProvider' (" + llmConfig.sessionMaxPerProvider() + ")"); } } diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java index b96b7e8c..8baf6425 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java @@ -14,8 +14,20 @@ class LLMClientConfigFromTest { @Test void derivesFullConfigCorrectly() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", "https://api.example.test/v1", - 20, 2048, 0.5d, 30.0d, false, "medium", 500, 200, 3, List.of()); + "openai", + "gpt-4o", + "sk-test", + "https://api.example.test/v1", + 20, + 2048, + 0.5d, + 30.0d, + false, + "medium", + 500, + 200, + 3, + List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -35,9 +47,8 @@ void derivesFullConfigCorrectly() { @Test void rejectsMissingProvider() { - LlmConfig llmConfig = new LlmConfig( - "", "gpt-4o", "sk-test", null, - 10, null, null, null, false, null, 300, 100, 3, List.of()); + LlmConfig llmConfig = + new LlmConfig("", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 300, 100, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -46,9 +57,8 @@ void rejectsMissingProvider() { @Test void rejectsMissingModel() { - LlmConfig llmConfig = new LlmConfig( - "openai", "", "sk-test", null, - 10, null, null, null, false, null, 300, 100, 3, List.of()); + LlmConfig llmConfig = + new LlmConfig("openai", "", "sk-test", null, 10, null, null, null, false, null, 300, 100, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -57,9 +67,8 @@ void rejectsMissingModel() { @Test void rejectsMissingApiKey() { - LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "", null, - 10, null, null, null, false, null, 300, 100, 3, List.of()); + LlmConfig llmConfig = + new LlmConfig("openai", "gpt-4o", "", null, 10, null, null, null, false, null, 300, 100, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -68,9 +77,8 @@ void rejectsMissingApiKey() { @Test void rejectsMultipleMissingKeysAndListsThemAll() { - LlmConfig llmConfig = new LlmConfig( - "", "", "", null, - 10, null, null, null, false, null, 300, 100, 3, List.of()); + LlmConfig llmConfig = + new LlmConfig("", "", "", null, 10, null, null, null, false, null, 300, 100, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -82,8 +90,7 @@ void rejectsMultipleMissingKeysAndListsThemAll() { @Test void rejectsHistoryWindowAbove100() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 101, null, null, null, false, null, 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 101, null, null, null, false, null, 300, 100, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -93,8 +100,7 @@ void rejectsHistoryWindowAbove100() { @Test void rejectsSessionMaxTotalAbove3000() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 10, null, null, null, false, null, 3001, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 3001, 100, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -104,8 +110,7 @@ void rejectsSessionMaxTotalAbove3000() { @Test void rejectsSessionMaxPerProviderAbove1000() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 10, null, null, null, false, null, 300, 1001, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 300, 1001, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -115,8 +120,7 @@ void rejectsSessionMaxPerProviderAbove1000() { @Test void rejectsSessionMaxTotalSmallerThanSessionMaxPerProvider() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 10, null, null, null, false, null, 50, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 50, 100, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -127,8 +131,7 @@ void rejectsSessionMaxTotalSmallerThanSessionMaxPerProvider() { @Test void derivesUnconfiguredOptionalsAsNull() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 10, null, null, null, false, null, 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 300, 100, 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -143,8 +146,7 @@ void derivesUnconfiguredOptionalsAsNull() { @Test void normalizesReasoningEffortCase() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 10, null, null, null, false, "MEDIUM", 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, "MEDIUM", 300, 100, 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -154,8 +156,7 @@ void normalizesReasoningEffortCase() { @Test void rejectsUnknownReasoningEffortValue() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 10, null, null, null, false, "middle", 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, "middle", 300, 100, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -166,8 +167,7 @@ void rejectsUnknownReasoningEffortValue() { @Test void treatsBlankReasoningEffortAsNull() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 10, null, null, null, false, " ", 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, " ", 300, 100, 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -177,8 +177,7 @@ void treatsBlankReasoningEffortAsNull() { @Test void treatsNullReasoningEffortAsNull() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 10, null, null, null, false, null, 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 300, 100, 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -188,9 +187,21 @@ void treatsNullReasoningEffortAsNull() { @Test void rejectsParseErrorsAndListsThemAll() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 10, null, null, null, false, null, 300, 100, 3, - List.of("A2AT_LLM_MAX_TOKENS: non-numeric value 'garbage'", + "openai", + "gpt-4o", + "sk-test", + null, + 10, + null, + null, + null, + false, + null, + 300, + 100, + 3, + List.of( + "A2AT_LLM_MAX_TOKENS: non-numeric value 'garbage'", "A2AT_LLM_TEMPERATURE: non-numeric value 'xyz'")); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -202,9 +213,7 @@ void rejectsParseErrorsAndListsThemAll() { @Test void acceptsEmptyParseErrors() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 10, 2048, 0.5d, 30.0d, false, "medium", 500, 200, 3, - List.of()); + "openai", "gpt-4o", "sk-test", null, 10, 2048, 0.5d, 30.0d, false, "medium", 500, 200, 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -215,8 +224,7 @@ void acceptsEmptyParseErrors() { @Test void rejectsHistoryWindowZeroOrNegative() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 0, null, null, null, false, null, 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 0, null, null, null, false, null, 300, 100, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -226,8 +234,7 @@ void rejectsHistoryWindowZeroOrNegative() { @Test void rejectsSessionMaxTotalZeroOrNegative() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 10, null, null, null, false, null, 0, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 0, 100, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -237,8 +244,7 @@ void rejectsSessionMaxTotalZeroOrNegative() { @Test void rejectsSessionMaxPerProviderZeroOrNegative() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, - 10, null, null, null, false, null, 300, 0, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 300, 0, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -249,4 +255,4 @@ void rejectsSessionMaxPerProviderZeroOrNegative() { void rejectsNullLlmConfig() { assertThrows(NullPointerException.class, () -> LLMClientConfig.from(null)); } -} \ No newline at end of file +} diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LlmPackageStructureTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LlmPackageStructureTest.java index 12a646b9..dd4d088b 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LlmPackageStructureTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LlmPackageStructureTest.java @@ -69,8 +69,7 @@ private static Path llmTestRoot() { private static List topLevelJavaFiles(Path path) throws IOException { try (var files = Files.list(path)) { - return files - .filter(file -> file.getFileName().toString().endsWith(".java")) + return files.filter(file -> file.getFileName().toString().endsWith(".java")) .map(file -> file.getFileName().toString()) .sorted() .collect(Collectors.toList()); @@ -79,8 +78,7 @@ private static List topLevelJavaFiles(Path path) throws IOException { private static List topLevelDirectories(Path path) throws IOException { try (var files = Files.list(path)) { - return files - .filter(Files::isDirectory) + return files.filter(Files::isDirectory) .map(file -> file.getFileName().toString()) .sorted() .collect(Collectors.toList()); diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index 00734d12..3eae473c 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -3,25 +3,25 @@ import java.nio.file.Path; import java.util.List; import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.validation.ContentValidator; import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidator; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.prompt.validation.DefaultContentValidator; import net.openan.a2at.sdk.server.compliance.DefaultServerPromptComplianceOrchestrator; import net.openan.a2at.sdk.server.metadata.LlmBackedPromptMetadataExtractor; import net.openan.a2at.sdk.server.validation.LlmBackedPromptSemanticValidator; -import net.openan.a2at.sdk.prompt.validation.DefaultContentValidator; /** * Default builder that assembles one high-level A2AT server runtime from unified config. @@ -150,15 +150,16 @@ public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator( /** * Builds the generic template query service from the configured unified SDK config. * - *

      The service answers the extension-agnostic template queries: the message language and the local template - * root come from the prompt runtime config, exactly like the negotiation generation orchestrator wiring. + *

      The service answers the extension-agnostic template queries: the message language and the local template root + * come from the prompt runtime config, exactly like the negotiation generation orchestrator wiring. * * @return assembled template query service */ public TemplateQueryService buildTemplateQueryService() { require(config, "Unified SDK config must be configured."); requireSupportedConfig(); - return new TemplateQueryService(config.prompt().language(), config.prompt().localRootDir()); + return new TemplateQueryService( + config.prompt().language(), config.prompt().localRootDir()); } /** @@ -217,16 +218,18 @@ private static void require(Object value, String message) { private void requireSupportedConfig() { if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) - && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals(config.prompt().sourceType())) { + && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals( + config.prompt().sourceType())) { throw new UnsupportedOperationException( "Unsupported prompt source type: " + config.prompt().sourceType()); } if (!LLMClientFactory.availableProviders().contains(config.llm().provider())) { - throw new UnsupportedOperationException("Unsupported LLM provider: " + config.llm().provider()); + throw new UnsupportedOperationException( + "Unsupported LLM provider: " + config.llm().provider()); } if (!"in_memory".equals(config.negotiation().stateStoreType())) { - throw new UnsupportedOperationException( - "Unsupported negotiation state store type: " + config.negotiation().stateStoreType()); + throw new UnsupportedOperationException("Unsupported negotiation state store type: " + + config.negotiation().stateStoreType()); } } @@ -236,5 +239,4 @@ private synchronized LLMClient llmClient() { } return llmClient; } - -} \ No newline at end of file +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java index 154954e8..2e97fe0d 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java @@ -9,7 +9,6 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; - import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.model.LlmConfig; import net.openan.a2at.sdk.core.model.NegotiationConfig; @@ -48,9 +47,8 @@ void resetCounter() { void llmClientCreatedOnlyOnceAcrossMultipleBuildMethods() throws IOException { Path envFile = writeEnv(); A2ATConfig config = A2ATConfig.load(envFile); - DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder() - .config(config) - .envPath(envFile); + DefaultA2ATServerBuilder builder = + DefaultA2ATServerBuilder.builder().config(config).envPath(envFile); builder.buildPromptComplianceOrchestrator(); builder.buildNegotiationGenerationOrchestrator(); @@ -66,9 +64,8 @@ void llmClientCreatedOnlyOnceAcrossMultipleBuildMethods() throws IOException { void buildNegotiationOrchestratorReusesCachedComplianceOrchestrator() throws IOException { Path envFile = writeEnv(); A2ATConfig config = A2ATConfig.load(envFile); - DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder() - .config(config) - .envPath(envFile); + DefaultA2ATServerBuilder builder = + DefaultA2ATServerBuilder.builder().config(config).envPath(envFile); DefaultServerPromptComplianceOrchestrator first = builder.buildPromptComplianceOrchestrator(); DefaultServerPromptComplianceOrchestrator second = builder.buildPromptComplianceOrchestrator(); @@ -76,7 +73,9 @@ void buildNegotiationOrchestratorReusesCachedComplianceOrchestrator() throws IOE builder.buildNegotiationOrchestrator(); - assertEquals(1, CountingRecorderClient.instanceCount(), + assertEquals( + 1, + CountingRecorderClient.instanceCount(), "LLM client should still be created only once after negotiation orchestrator build"); } @@ -85,31 +84,44 @@ void llmClientConfigDerivedFromConfigLlmNotFromEnvPath() throws IOException { Path envFile = writeEnvWithDifferentModel(); A2ATConfig config = new A2ATConfig( new PromptRuntimeConfig("zh-CN", "classpath", "."), - new LlmConfig(TEST_MOCK_PROVIDER, "from-config-model", "from-config-key", - "https://from-config.example.com", 10, null, null, null, false, null, 300, 100, 3, + new LlmConfig( + TEST_MOCK_PROVIDER, + "from-config-model", + "from-config-key", + "https://from-config.example.com", + 10, + null, + null, + null, + false, + null, + 300, + 100, + 3, List.of()), new NegotiationConfig("in_memory"), new PromptComplianceConfig(false)); - DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder() - .config(config) - .envPath(envFile); + DefaultA2ATServerBuilder builder = + DefaultA2ATServerBuilder.builder().config(config).envPath(envFile); builder.buildPromptComplianceOrchestrator(); assertEquals(1, CountingRecorderClient.instanceCount()); LLMClientConfig recorded = CountingRecorderClient.lastInstance().recordedConfig(); - assertEquals("from-config-model", recorded.model(), - "Model should come from config.llm(), not from envPath"); - assertEquals("from-config-key", recorded.apiKey(), - "API key should come from config.llm(), not from envPath"); - assertEquals(LLMClientConfig.from(config.llm()), recorded, + assertEquals("from-config-model", recorded.model(), "Model should come from config.llm(), not from envPath"); + assertEquals("from-config-key", recorded.apiKey(), "API key should come from config.llm(), not from envPath"); + assertEquals( + LLMClientConfig.from(config.llm()), + recorded, "Recorded config should equal LLMClientConfig.from(config.llm())"); } private Path writeEnv() throws IOException { Path envFile = tempDir.resolve("server.env"); - Files.writeString(envFile, """ + Files.writeString( + envFile, + """ A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= @@ -124,7 +136,9 @@ private Path writeEnv() throws IOException { private Path writeEnvWithDifferentModel() throws IOException { Path envFile = tempDir.resolve("alt.env"); - Files.writeString(envFile, """ + Files.writeString( + envFile, + """ A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= @@ -177,4 +191,4 @@ public LLMResponse structured( return new LLMResponse("{}", config.model(), Map.of(), Map.of()); } } -} \ No newline at end of file +} From adff9ea6f67fb3bb147f908dc2649b9a19f5e90f Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Sun, 23 Aug 2026 15:55:21 +0800 Subject: [PATCH 150/295] feat(prompt): refresh service-recovery template and energy-saving template Signed-off-by: zhangwenjun-huawei --- .../service-recovery/v1/en-US/template.md | 3 +- .../service-recovery/v1/zh-CN/template.md | 3 +- .../energy-saving/v1/en-US/template.md | 66 +++++++++++------ .../energy-saving/v1/zh-CN/template.md | 70 ++++++++++++------- 4 files changed, 94 insertions(+), 48 deletions(-) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md index e9bf7406..afcb4a5e 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md @@ -15,8 +15,9 @@ Requirement: Please provide the following information for the content to be repo 2. Allowed values (optional) 3. Example (optional) -The following is a sample of the notification data format: +The following is an example of notification data format: ### Service Recovery Event +Requirement: 1. Service recovery plan execution status. Allowed values: not started, ended. Example: ended (required) 2. Complaint diagnosis task sequence number. Example: 9de168c0-6179-4778-8b72-4279582c0a3f (required) 3. OSS-side event sequence number. Example: event-id-202606250128 (required) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md index 1d0b59f6..53d768e5 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md @@ -16,7 +16,8 @@ 3. 样例;(可选) 如下为上报通知数据格式样例: -### 业务抢通事件 +### 业务抢通事件 +要求: 1. 业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束 (必选) 2. 投诉诊断任务流水号;举例:9de168c0-6179-4778-8b72-4279582c0a3f (必选) 3. OSS侧事件流水号;举例:event-id-202606250128 (必选) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/en-US/template.md index b77ad759..857401d0 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/en-US/template.md @@ -1,67 +1,89 @@ ## Operation Type + {{operation_type}} (required) Requirement: -Please provide the operation type. Allowed values: Create, Modify + +1. Please provide the operation type. Allowed values: Create, Modify ## Task Type + Wireless network energy saving ## Task Description + {{task_description}} (required) Requirement: -Please dynamically provide the task description based on (Create/Modify): -- When the type is [Create]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in . -- When the type is [Modify]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in . + +1. Please dynamically provide the task description based on (Create/Modify): + - When the type is [Create]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in . + - When the type is [Modify]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in . ## Task Object + {{task_object}} (required) Requirement: + 1. Area name: (required) - Explicitly specify the energy saving coverage scope information, a geographic or logical area (e.g., district-level administrative unit) - Must be a concrete and physically real community name such as "Songshanhu Administration Committee", rather than a description without a specific physical location such as "all communities", "indoor communities", or "outdoor communities" - Example: Area: "Songshanhu Administration Committee" ## Task Target + {{task_target}} (optional) Requirement: -Define the quantitative or qualitative goals the task must achieve. Quantitative and qualitative goals cannot be selected at the same time, and the default is a qualitative goal. The specific description of energy saving goals is as follows: -1. Quantitative goal: Energy consumption goal; the gain target of energy saving. The target value format must be a percentage, such as the percentage by which total power consumption is reduced. Example: Energy consumption goal: 30% -2. Qualitative goal: Maximize energy saving under the conditions defined in the task context - + +1. Define the quantitative or qualitative goals the task must achieve. Quantitative and qualitative goals cannot be selected at the same time, and the default is a qualitative goal. The specific description of energy saving goals is as follows: + - Quantitative goal: Energy consumption goal; the gain target of energy saving. The target value format must be a percentage, such as the percentage by which total power consumption is reduced. Example: Energy consumption goal: 30% + - Qualitative goal: Maximize energy saving under the conditions defined in the task context + ## Task Context + {{task_context}} (optional) Requirement: + 1. Cell radio access technology for energy saving (LTE/NR/all). Example: Cell RAT for energy saving: NR -2. The time range for executing energy saving, recurring daily. If omitted, energy saving is performed for the full period, and experience-lossless energy saving is adopted by default. **The rate goal is guaranteed only within the energy saving enabled time range**. Example: Energy saving time range: start time "08:00:00", end time "12:00:00" +2. The time range for executing energy saving, recurring daily. If omitted, energy saving is performed for the full period. **The rate goal is guaranteed only within the energy saving enabled time range**. Example: Energy saving time range: start time "08:00:00", end time "12:00:00" 3. Carrier frequencies that can be turned off during energy saving. Example: Downlink frequency numbers: 1650, 1600 4. Rate guarantee goal: the minimum downlink throughput target that must be guaranteed and its effective time period. - - For the area-level setting, the following information needs to be provided: - Option 1: Time-segmented rate guarantee goal (required) - Example: Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {18:00~00:00,20Mbps} - Option 2: Full-period rate guarantee goal (required) - Example: Rate guarantee goal: 10Mbps - - For the site-level setting, the following information needs to be provided: - Site list (required) - Time-segmented rate guarantee goal (required) - Background event and event duration (optional) - Example: - - Site list 1: ["Site Name 1", "Site Name 2"], Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {18:00~00:00,20Mbps}, Background event: power outage from 00:00 to 06:00. - - Site list 2: ["Site Name 3", "Site Name 4"], Time-segmented rate guarantee goal: {01:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {19:00~00:00,20Mbps}, Background event: power outage from 00:00 to 06:00. + - **For the area-level setting**, the following information needs to be provided: + + Option 1: + - Time-segmented rate guarantee goal (required). For time periods without a rate guarantee goal set, experience-lossless energy saving is adopted by default, with a maximum of three rate values + - Example: Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps} + + Option 2: + - Full-period rate guarantee goal (required) + - Example: Rate guarantee goal: 10Mbps + + - **For the site-level setting**, the following information needs to be provided: + - Site list (required) + - Time-segmented rate guarantee goal (required). For time periods without a rate guarantee goal set, experience-lossless energy saving is adopted by default, with a maximum of three rate values + - Background event and event duration (optional) + - Example: + - Site list 1: ["Site Name 1", "Site Name 2"], Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, Background event: power outage from 00:00 to 18:00. + - Site list 2: ["Site Name 3", "Site Name 4"], Time-segmented rate guarantee goal: {01:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, Background event: power outage from 01:00 to 18:00. ## Expected Output + {{expected_output}} (optional when creating, not required when modifying) Requirement: -When the task object is a geographic/logical area, output the intent report for the network energy saving task. The intent report should contain the following information: + +1. When the task object is a geographic/logical area, output the intent report for the network energy saving task. The intent report should contain the following information: + ### Intent Report Basic Information + 1. Unique identifier of the network energy saving report 2. Unique identifier of the energy saving task 3. Execution result of the overall intent, including the execution result status and execution result details. If the goal is not achieved, describe the specific reason for not achieving it + ### Intent Report Detailed Information + 1. Execution result of specific KPIs, including the execution result status and execution result details. If the goal is not achieved, describe the specific reason for not achieving it diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/zh-CN/template.md index 91c7cb06..e35cab44 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/energy-saving/v1/zh-CN/template.md @@ -1,67 +1,89 @@ ## 操作类型(Operation Type) + {{操作类型}}(必填) 要求: -请提供操作类型,取值范围:创建、修改 + +1. 请提供操作类型,取值范围:创建、修改 ## 任务类型(Task Type) + 无线网络节能 ## 任务描述(Task Description) + {{任务描述}}(必填) 要求: -请根据<操作类型>(创建/修改),动态提供任务描述信息: -- 类型为【创建】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。 -- 类型为【修改】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标。 + +1. 请根据<操作类型>(创建/修改),动态提供任务描述信息: + - 类型为【创建】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。 + - 类型为【修改】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标。 ## 任务对象(Task Object) + {{任务对象}}(必填) 要求: + 1. 区域名称:(必填) - 明确指定节能覆盖范围信息,地理或逻辑区域(如 区级行政单位) - 必须是类似"松山湖管委会"这种具体且物理真实的小区名称,而不是"所有小区"、"室内小区"、"室外小区"这种无具体物理位置的描述 - 举例:区域:"松山湖管委会" ## 任务目标(Task Target) + {{任务目标}}(选填) 要求: -定义任务需达成的量化或定性目标,量化目标和定性目标不可同时选择,默认为定性目标。节能目标的具体说明如下: -1. 量化目标:能耗目标;节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比;举例:能耗目标:30% -2. 定性目标:在满足任务上下文的条件下进行最大化节能 - + +1. 定义任务需达成的量化或定性目标,量化目标和定性目标不可同时选择,默认为定性目标。节能目标的具体说明如下: + - 量化目标:能耗目标;节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比;举例:能耗目标:30% + - 定性目标:在满足任务上下文的条件下进行最大化节能 + ## 任务上下文(Task Context) + {{任务上下文}}(选填) 要求: -1. 节能的小区制式(LTE/NR/全部) ;举例:节能的小区制式:NR -2. 需要执行节能的时间段区间范围,以天为周期循环执行,缺省则进行全时段节能,默认采取用户体验无损方式节能。**仅在节能开启时间范围内保障速率目标**;举例: 需要节能的时间范围: 起始时间"08:00:00",结束时间"12:00:00" -3. 可在节能时关闭载波的频点;举例: 下行频点号:1650, 1600 + +1. 节能的小区制式(LTE/NR/全部);举例:节能的小区制式:NR +2. 需要执行节能的时间段区间范围,以天为周期循环执行,缺省则进行全时段节能。**仅在节能开启时间范围内保障速率目标**;举例:需要节能的时间范围:起始时间"08:00:00",结束时间"12:00:00" +3. 可在节能时关闭载波的频点;举例:下行频点号:1650,1600 4. 速率保障目标:需要保障的下行吞吐率目标最小值及生效时间段。 - - 针对区域级设置方式,需要填写以下信息: - 方式一:分时段速率保障目标(必填) - 举例:分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{18:00~00:00,20Mbps} - 方式二:全时段速率保障目标(必填) - 举例:速率保障目标:10Mbps - - 针对站点级设置方式,需要填写以下信息: - 站点列表(必填) - 分时段速率保障目标(必填) - 背景事件与事件持续时间(选填) - 举例: - - 站点列表1:[“站点名称1”,“站点名称2”],分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{18:00~00:00,20Mbps},背景事件:在00:00~06:00停电。 - - 站点列表2:[“站点名称3”,“站点名称4”],分时段速率保障目标:{01:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{19:00~00:00,20Mbps},背景事件:在00:00~06:00停电。 + - **针对区域级设置方式**,需要填写以下信息: + + 方式一: + - 分时段速率保障目标(必填),未设置速率保障目标时间段,默认采取用户体验无损方式节能,最多设置三种速率值 + - 举例:分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps} + + 方式二: + - 全时段速率保障目标(必填) + - 举例:速率保障目标:10Mbps + + - **针对站点级设置方式**,需要填写以下信息: + - 站点列表(必填) + - 分时段速率保障目标(必填),未设置速率保障目标时间段,默认采取用户体验无损方式节能,最多设置三种速率值 + - 背景事件与事件持续时间(选填) + - 举例: + - 站点列表1:["站点名称1","站点名称2"],分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps},背景事件:在00:00~18:00停电。 + - 站点列表2:["站点名称3","站点名称4"],分时段速率保障目标:{01:00~06:00,2Mbps}、{06:00~18:00,10Mbps},背景事件:在01:00~18:00停电。 ## 预期输出(Expected Output) + {{预期输出}}(创建时选填,修改时无需填写) 要求: -若任务对象为地理/逻辑区域时,输出网络节能任务的意图报告,意图报告应包含以下信息: + +1. 若任务对象为地理/逻辑区域时,输出网络节能任务的意图报告,意图报告应包含以下信息: + ### 意图报告基本信息 + 1. 网络节能报告的唯一标识 2. 节能任务唯一标识 3. 总体意图的执行结果,执行结果包括执行结果状态,执行结果详情,如果未达成目标,描述未达成的具体原因 + ### 意图报告详细信息 + 1. 具体KPI的执行结果,执行结果包括执行结果状态,执行结果详情,如果未达成目标,描述未达成的具体原因 From 96c12b55954ffd992bf1ed504cbeb96b285beaa1 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Sun, 23 Aug 2026 16:08:09 +0800 Subject: [PATCH 151/295] feat(prompt): refresh slot.json Signed-off-by: zhangwenjun-huawei --- .../Task-T/network-layer/energy-saving/v1/en-US/slot.json | 4 ++-- .../Task-T/network-layer/energy-saving/v1/zh-CN/slot.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/en-US/slot.json index fae9ae17..22a220af 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/en-US/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/en-US/slot.json @@ -39,9 +39,9 @@ "type": "string", "description": "Provide the background and context information for task execution.", "examples": [ - "Cell RAT for energy saving: NR; Energy saving time range: start time \"08:00:00\", end time \"12:00:00\"; Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, {18:00~00:00,20Mbps}." + "Cell RAT for energy saving: NR; Energy saving time range: start time \"08:00:00\", end time \"12:00:00\"; Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}." ], - "x-a2at-value-constraint": "1. Cell radio access technology for energy saving (LTE/NR/all) 2. The time range for executing energy saving, recurring daily; if omitted, energy saving is performed for the full period and experience-lossless energy saving is adopted by default, and the rate goal is guaranteed only within the energy saving enabled time range 3. Carrier frequencies that can be turned off during energy saving 4. Rate guarantee goal: the minimum downlink throughput target that must be guaranteed and its effective time period (area-level supports time-segmented/full-period; site-level requires site list, time-segmented rate guarantee goal, and optional background event and duration)" + "x-a2at-value-constraint": "1. Cell radio access technology for energy saving (LTE/NR/all) 2. The time range for executing energy saving, recurring daily; if omitted, energy saving is performed for the full period, and the rate goal is guaranteed only within the energy saving enabled time range 3. Carrier frequencies that can be turned off during energy saving 4. Rate guarantee goal: the minimum downlink throughput target that must be guaranteed and its effective time period (area-level supports time-segmented/full-period; for time periods without a rate guarantee goal set, experience-lossless energy saving is adopted by default, with a maximum of three rate values; site-level requires site list, time-segmented rate guarantee goal, and optional background event and duration)" }, "expected_output": { "type": "string", diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/zh-CN/slot.json index 81464e73..9ff06f6d 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/zh-CN/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/energy-saving/v1/zh-CN/slot.json @@ -39,9 +39,9 @@ "type": "string", "description": "提供任务执行的背景和上下文信息。", "examples": [ - "节能的小区制式:NR;需要节能的时间范围:起始时间“08:00:00”,结束时间“12:00:00”;分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}、{18:00~00:00,20Mbps}。" + "节能的小区制式:NR;需要节能的时间范围:起始时间“08:00:00”,结束时间“12:00:00”;分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}。" ], - "x-a2at-value-constraint": "1. 节能的小区制式(LTE/NR/全部) 2. 需要执行节能的时间段区间范围,以天为周期循环执行,缺省则进行全时段节能,默认采取用户体验无损方式节能,仅在节能开启时间范围内保障速率目标 3. 可在节能时关闭载波的频点 4. 速率保障目标:需要保障的下行吞吐率目标最小值及生效时间段(区域级支持分时段/全时段;站点级需提供站点列表、分时段速率保障目标、可选背景事件与持续时间)" + "x-a2at-value-constraint": "1. 节能的小区制式(LTE/NR/全部) 2. 需要执行节能的时间段区间范围,以天为周期循环执行,缺省则进行全时段节能,仅在节能开启时间范围内保障速率目标 3. 可在节能时关闭载波的频点 4. 速率保障目标:需要保障的下行吞吐率目标最小值及生效时间段(区域级支持分时段/全时段,未设置速率保障目标的时间段默认采取用户体验无损方式节能,最多设置三种速率值;站点级需提供站点列表、分时段速率保障目标、可选背景事件与持续时间)" }, "预期输出": { "type": "string", From f50a3ca5ca8c7ca68981616622ea9579786a500a Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sun, 23 Aug 2026 16:42:22 +0800 Subject: [PATCH 152/295] refactor: rename ScenarioRecognitionFunction to ScenarioRecognizer, ScenarioRecognizer to LlmScenarioRecognizer - Rename ScenarioRecognitionFunction interface to ScenarioRecognizer - Rename ScenarioRecognizer class to LlmScenarioRecognizer - Rename ScenarioRecognizerTest to LlmScenarioRecognizerTest - Adjust LlmConfig and LLMClientConfig field order (sessions before disableSystemProxy/reasoningEffort) - Add @Nullable annotations to LlmConfig optional fields - Remove toLowerCase from LlmConfig.parseReasoningEffort (defer validation to LLMClientConfig) - Replace NegotiationContentService.resolvePromptResourceLocalRootDir with inline logic in tests - Add REASONING_EFFORT and DISABLE_SYSTEM_PROXY to env.example Signed-off-by: w00609601 --- ...ntPromptGenerationOrchestratorBuilder.java | 10 +- .../assembly/DefaultA2ATClientBuilder.java | 12 +- ...ultClientPromptGenerationOrchestrator.java | 6 +- ...omptGenerationOrchestratorBuilderTest.java | 4 +- .../DefaultA2ATClientBuilderTest.java | 20 ++- ...lientPromptGenerationOrchestratorTest.java | 6 +- .../openan/a2at/sdk/core/model/LlmConfig.java | 20 +-- .../a2at/sdk/core/model/LlmConfigTest.java | 8 +- .../openan/a2at/sdk/llm/LLMClientConfig.java | 6 +- .../a2at/sdk/llm/LLMClientConfigFromTest.java | 42 +++---- .../a2at/sdk/llm/LLMClientConfigTest.java | 2 +- .../a2at/sdk/llm/LLMClientFactoryTest.java | 2 +- .../sdk/llm/providers/OpenAIClientTest.java | 4 +- .../analysis/impl/LlmScenarioRecognizer.java | 117 +++++++++++++++++ .../impl/ScenarioRecognitionFunction.java | 21 ---- .../analysis/impl/ScenarioRecognizer.java | 118 ++---------------- ...st.java => LlmScenarioRecognizerTest.java} | 24 ++-- .../assembly/DefaultA2ATServerBuilder.java | 4 +- .../DefaultA2ATServerBuilderTest.java | 4 +- .../LlmBackedPromptMetadataExtractorTest.java | 10 +- env.example | 4 + 21 files changed, 230 insertions(+), 214 deletions(-) create mode 100644 a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/LlmScenarioRecognizer.java delete mode 100644 a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognitionFunction.java rename a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/{ScenarioRecognizerTest.java => LlmScenarioRecognizerTest.java} (90%) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java index be7f77b8..471867e3 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java @@ -5,7 +5,7 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognitionFunction; +import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; import net.openan.a2at.sdk.prompt.resources.loader.ClasspathPromptSlotSchemaLoader; import net.openan.a2at.sdk.prompt.resources.loader.ClasspathPromptTemplateLoader; @@ -38,7 +38,7 @@ private ClientPromptGenerationOrchestratorBuilder() {} private String slotUserPrompt; - private ScenarioRecognitionFunction scenarioRecognizer; + private ScenarioRecognizer scenarioRecognizer; private PromptTemplateTextLoader templateLoader; @@ -143,7 +143,7 @@ public ClientPromptGenerationOrchestratorBuilder slotUserPrompt(String slotUserP * @return current builder */ public ClientPromptGenerationOrchestratorBuilder scenarioRecognizer( - ScenarioRecognitionFunction scenarioRecognizer) { + ScenarioRecognizer scenarioRecognizer) { this.scenarioRecognizer = scenarioRecognizer; return this; } @@ -230,8 +230,8 @@ public DefaultClientPromptGenerationOrchestrator build() { ? new DefaultStructuredPromptSlotValueExtractor( llmClient, effectiveSlotSchemaLoader, slotSystemPrompt, slotUserPrompt) : slotValueExtractor; - ScenarioRecognitionFunction effectiveScenarioRecognizer = - scenarioRecognizer == null ? new ScenarioRecognizer(llmClient)::recognize : scenarioRecognizer; + ScenarioRecognizer effectiveScenarioRecognizer = + scenarioRecognizer == null ? new LlmScenarioRecognizer(llmClient)::recognize : scenarioRecognizer; TaskPromptRenderer effectiveRenderer = renderer == null ? new TaskPromptRenderer() : renderer; return new DefaultClientPromptGenerationOrchestrator( diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index 33e61fae..e3032846 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -15,7 +15,7 @@ import net.openan.a2at.sdk.negotiation.types.model.NegotiationRole; import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognitionFunction; +import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; @@ -98,8 +98,8 @@ public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); - ScenarioRecognitionFunction scenarioRecognizer = - new SingleScenarioAwareRecognizer(scenarios, new ScenarioRecognizer(client)::recognize); + ScenarioRecognizer scenarioRecognizer = + new SingleScenarioAwareRecognizer(scenarios, new LlmScenarioRecognizer(client)::recognize); PromptSlotValueExtractor slotValueExtractor = new DefaultStructuredPromptSlotValueExtractor( client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt); @@ -195,14 +195,14 @@ private static void require(Object value, String message) { } } - private static final class SingleScenarioAwareRecognizer implements ScenarioRecognitionFunction { + private static final class SingleScenarioAwareRecognizer implements ScenarioRecognizer { private final List scenarios; - private final ScenarioRecognitionFunction delegate; + private final ScenarioRecognizer delegate; private SingleScenarioAwareRecognizer( - List scenarios, ScenarioRecognitionFunction delegate) { + List scenarios, ScenarioRecognizer delegate) { this.scenarios = scenarios; this.delegate = delegate; } diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java index bf0f0855..d8552c99 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -17,7 +17,7 @@ import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognitionFunction; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; @@ -35,7 +35,7 @@ */ public final class DefaultClientPromptGenerationOrchestrator implements ClientPromptGenerationOrchestrator { - private final ScenarioRecognitionFunction scenarioRecognizer; + private final ScenarioRecognizer scenarioRecognizer; private final List scenarios; @@ -67,7 +67,7 @@ public final class DefaultClientPromptGenerationOrchestrator implements ClientPr * @param slotSchemaLoader slot schema loader */ public DefaultClientPromptGenerationOrchestrator( - ScenarioRecognitionFunction scenarioRecognizer, + ScenarioRecognizer scenarioRecognizer, List scenarios, String language, String systemPrompt, diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java index 67154e3a..0a1c3422 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilderTest.java @@ -12,7 +12,7 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognitionFunction; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; @@ -130,7 +130,7 @@ public LLMResponse structured( } } - private static final class RecordingScenarioRecognizer implements ScenarioRecognitionFunction { + private static final class RecordingScenarioRecognizer implements ScenarioRecognizer { private final ScenarioRecognitionResult result; diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java index e729fe0c..92db718f 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java @@ -36,8 +36,14 @@ void should_createLlmClientOnlyOnce_When_multipleBuildMethodsCalled() throws IOE Path envFile = createTempEnvFile(provider); A2ATConfig config = A2ATConfig.load(envFile); - config = net.openan.a2at.sdk.negotiation.generation.NegotiationContentService.resolvePromptResourceLocalRootDir( - config, envFile); + String resolvedRoot = envFile.getParent() + .resolve(config.prompt().localRootDir()) + .toAbsolutePath() + .toString(); + config = new A2ATConfig( + new net.openan.a2at.sdk.core.model.PromptRuntimeConfig( + config.prompt().language(), config.prompt().sourceType(), resolvedRoot), + config.llm(), config.negotiation(), config.promptCompliance()); DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder().config(config).envPath(envFile); @@ -57,8 +63,14 @@ void buildPromptGenerationOrchestratorPropagatesMissingScenarioCatalog() throws Path envFile = createTempEnvFileWithoutScenarioCatalog(provider); A2ATConfig config = A2ATConfig.load(envFile); - config = net.openan.a2at.sdk.negotiation.generation.NegotiationContentService.resolvePromptResourceLocalRootDir( - config, envFile); + String resolvedRoot = envFile.getParent() + .resolve(config.prompt().localRootDir()) + .toAbsolutePath() + .toString(); + config = new A2ATConfig( + new net.openan.a2at.sdk.core.model.PromptRuntimeConfig( + config.prompt().language(), config.prompt().sourceType(), resolvedRoot), + config.llm(), config.negotiation(), config.promptCompliance()); DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder().config(config).envPath(envFile); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 296d101f..9e2d5536 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -20,7 +20,7 @@ import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMRuntimeError; import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognitionFunction; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; @@ -171,7 +171,7 @@ void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissi } private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestrator( - ScenarioRecognitionFunction recognizer, + ScenarioRecognizer recognizer, PromptTemplateTextLoader templateLoader, PromptSlotValueExtractor slotValueExtractor) { return new DefaultClientPromptGenerationOrchestrator( @@ -187,7 +187,7 @@ private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestra EMPTY_SCHEMA_LOADER); } - private static final class RecordingScenarioRecognizer implements ScenarioRecognitionFunction { + private static final class RecordingScenarioRecognizer implements ScenarioRecognizer { private int invocationCount; private String lastNormalizedInput; diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java index 09ae833b..fd65d70b 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java @@ -2,11 +2,11 @@ import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.function.Function; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.jspecify.annotations.Nullable; /** * Structured LLM runtime configuration resolved from unified SDK config. @@ -20,13 +20,13 @@ public record LlmConfig( String apiKey, String baseUrl, int historyWindow, - Integer maxTokens, - Double temperature, - Double timeoutSeconds, - boolean disableSystemProxy, - String reasoningEffort, + @Nullable Integer maxTokens, + @Nullable Double temperature, + @Nullable Double timeoutSeconds, int sessionMaxTotal, int sessionMaxPerProvider, + boolean disableSystemProxy, + @Nullable String reasoningEffort, int maxAttempts, List parseErrors) { @@ -73,13 +73,13 @@ public static LlmConfig fromMap(Map values) { A2ATConfigKeys.Llm.TEMPERATURE, parseErrors, Double::parseDouble), parseOptionalNumeric(values.get(A2ATConfigKeys.Llm.TIMEOUT_SECONDS), A2ATConfigKeys.Llm.TIMEOUT_SECONDS, parseErrors, Double::parseDouble), - parseBoolean(values.get(A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY), - A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY, false, parseErrors), - parseReasoningEffort(values.get(A2ATConfigKeys.Llm.REASONING_EFFORT)), parseInt(values.get(A2ATConfigKeys.Llm.SESSION_MAX_TOTAL), A2ATConfigKeys.Llm.SESSION_MAX_TOTAL, DEFAULT_SESSION_MAX_TOTAL, parseErrors), parseInt(values.get(A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER), A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER, DEFAULT_SESSION_MAX_PER_PROVIDER, parseErrors), + parseBoolean(values.get(A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY), + A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY, false, parseErrors), + parseReasoningEffort(values.get(A2ATConfigKeys.Llm.REASONING_EFFORT)), parseMaxAttempts(values.get(A2ATConfigKeys.Llm.MAX_ATTEMPTS)), parseErrors); } @@ -164,6 +164,6 @@ private static String parseReasoningEffort(String rawValue) { if (StringUtils.isBlank(rawValue)) { return null; } - return rawValue.trim().toLowerCase(Locale.ROOT); + return rawValue.trim(); } } \ No newline at end of file diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java index ffe13bbd..d03bce73 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java @@ -90,7 +90,7 @@ void should_overrideDefaults_When_keysAreProvided() { assertEquals(500, config.sessionMaxTotal()); assertEquals(150, config.sessionMaxPerProvider()); assertEquals(5, config.maxAttempts()); - assertEquals("high", config.reasoningEffort()); + assertEquals("HIGH", config.reasoningEffort()); } /** @@ -141,14 +141,14 @@ void should_useDefaultMaxAttempts_When_valueIsAbsentOrBlank() { /** * Verifies that {@link LlmConfig#fromMap(Map)} parses reasoning effort with case normalization. * - *

      Scenario: A2AT_LLM_REASONING_EFFORT is set to "MEDIUM". Expected result: reasoningEffort resolves to "medium" - * (lowercased). + *

      Scenario: A2AT_LLM_REASONING_EFFORT is set to "MEDIUM". Expected result: reasoningEffort resolves to "MEDIUM" + * (preserved as-is after trimming). */ @Test void should_normalizeReasoningEffort_When_configured() { LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_REASONING_EFFORT", "MEDIUM")); - assertEquals("medium", config.reasoningEffort()); + assertEquals("MEDIUM", config.reasoningEffort()); } /** diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java index 2818ac19..10207349 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java @@ -19,9 +19,9 @@ * @param maxTokens optional default max tokens * @param temperature optional default temperature * @param timeoutSeconds optional provider timeout in seconds - * @param disableSystemProxy whether to bypass the JVM or operating-system HTTP proxy * @param sessionMaxTotal reserved total session limit * @param sessionMaxPerProvider reserved per-provider session limit + * @param disableSystemProxy whether to bypass the JVM or operating-system HTTP proxy * @param reasoningEffort optional reasoning effort for reasoning models (none/minimal/low/medium/high/xhigh); null * leaves the parameter unset * @since 2026-06 @@ -35,9 +35,9 @@ public record LLMClientConfig( Integer maxTokens, Double temperature, Double timeoutSeconds, - boolean disableSystemProxy, int sessionMaxTotal, int sessionMaxPerProvider, + boolean disableSystemProxy, String reasoningEffort) { private static final int MAX_HISTORY_WINDOW = 100; @@ -75,9 +75,9 @@ public static LLMClientConfig from(LlmConfig llmConfig) { llmConfig.maxTokens(), llmConfig.temperature(), llmConfig.timeoutSeconds(), - llmConfig.disableSystemProxy(), llmConfig.sessionMaxTotal(), llmConfig.sessionMaxPerProvider(), + llmConfig.disableSystemProxy(), reasoningEffort); } diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java index 8baf6425..06fd6692 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java @@ -22,10 +22,10 @@ void derivesFullConfigCorrectly() { 2048, 0.5d, 30.0d, - false, - "medium", 500, 200, + false, + "medium", 3, List.of()); @@ -48,7 +48,7 @@ void derivesFullConfigCorrectly() { @Test void rejectsMissingProvider() { LlmConfig llmConfig = - new LlmConfig("", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 300, 100, 3, List.of()); + new LlmConfig("", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -58,7 +58,7 @@ void rejectsMissingProvider() { @Test void rejectsMissingModel() { LlmConfig llmConfig = - new LlmConfig("openai", "", "sk-test", null, 10, null, null, null, false, null, 300, 100, 3, List.of()); + new LlmConfig("openai", "", "sk-test", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -68,7 +68,7 @@ void rejectsMissingModel() { @Test void rejectsMissingApiKey() { LlmConfig llmConfig = - new LlmConfig("openai", "gpt-4o", "", null, 10, null, null, null, false, null, 300, 100, 3, List.of()); + new LlmConfig("openai", "gpt-4o", "", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -78,7 +78,7 @@ void rejectsMissingApiKey() { @Test void rejectsMultipleMissingKeysAndListsThemAll() { LlmConfig llmConfig = - new LlmConfig("", "", "", null, 10, null, null, null, false, null, 300, 100, 3, List.of()); + new LlmConfig("", "", "", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -90,7 +90,7 @@ void rejectsMultipleMissingKeysAndListsThemAll() { @Test void rejectsHistoryWindowAbove100() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 101, null, null, null, false, null, 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 101, null, null, null, 300, 100, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -100,7 +100,7 @@ void rejectsHistoryWindowAbove100() { @Test void rejectsSessionMaxTotalAbove3000() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 3001, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 3001, 100, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -110,7 +110,7 @@ void rejectsSessionMaxTotalAbove3000() { @Test void rejectsSessionMaxPerProviderAbove1000() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 300, 1001, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 1001, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -120,7 +120,7 @@ void rejectsSessionMaxPerProviderAbove1000() { @Test void rejectsSessionMaxTotalSmallerThanSessionMaxPerProvider() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 50, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 50, 100, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -131,7 +131,7 @@ void rejectsSessionMaxTotalSmallerThanSessionMaxPerProvider() { @Test void derivesUnconfiguredOptionalsAsNull() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -146,7 +146,7 @@ void derivesUnconfiguredOptionalsAsNull() { @Test void normalizesReasoningEffortCase() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, "MEDIUM", 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, "MEDIUM", 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -156,7 +156,7 @@ void normalizesReasoningEffortCase() { @Test void rejectsUnknownReasoningEffortValue() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, "middle", 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, "middle", 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -167,7 +167,7 @@ void rejectsUnknownReasoningEffortValue() { @Test void treatsBlankReasoningEffortAsNull() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, " ", 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, " ", 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -177,7 +177,7 @@ void treatsBlankReasoningEffortAsNull() { @Test void treatsNullReasoningEffortAsNull() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -195,10 +195,10 @@ void rejectsParseErrorsAndListsThemAll() { null, null, null, - false, - null, 300, 100, + false, + null, 3, List.of( "A2AT_LLM_MAX_TOKENS: non-numeric value 'garbage'", @@ -213,7 +213,7 @@ void rejectsParseErrorsAndListsThemAll() { @Test void acceptsEmptyParseErrors() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, 2048, 0.5d, 30.0d, false, "medium", 500, 200, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, 2048, 0.5d, 30.0d, 500, 200, false, "medium", 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -224,7 +224,7 @@ void acceptsEmptyParseErrors() { @Test void rejectsHistoryWindowZeroOrNegative() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 0, null, null, null, false, null, 300, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 0, null, null, null, 300, 100, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -234,7 +234,7 @@ void rejectsHistoryWindowZeroOrNegative() { @Test void rejectsSessionMaxTotalZeroOrNegative() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 0, 100, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 0, 100, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -244,7 +244,7 @@ void rejectsSessionMaxTotalZeroOrNegative() { @Test void rejectsSessionMaxPerProviderZeroOrNegative() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, false, null, 300, 0, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 0, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java index 9c1c83e6..ce7d7f75 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java @@ -17,9 +17,9 @@ void storesResolvedDefaultLlmClientConfiguration() { 1024, 0.2d, 15.5d, - true, 300, 100, + true, "none"); assertEquals("openai", config.provider()); diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java index 1facff5a..c8f8cd55 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java @@ -82,7 +82,7 @@ void rejectsProviderClassWithoutPublicConfigConstructor() { } private static LLMClientConfig buildConfig(String provider) { - return new LLMClientConfig(provider, "test-model", "sk-test", null, 10, null, null, null, false, 300, 100, null); + return new LLMClientConfig(provider, "test-model", "sk-test", null, 10, null, null, null, 300, 100, false, null); } public static final class CustomClient implements LLMClient { diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java index c572d8a4..53c2d606 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java @@ -98,7 +98,7 @@ void structuredBuildsJsonModePayloadAndParsesJsonObjectResponse() { void structuredFallsBackToConfigTemperatureAndMaxTokens() { AtomicReference capturedParams = new AtomicReference<>(); LLMClientConfig config = new LLMClientConfig( - "openai", "gpt-4o-mini", "sk-test", "https://api.example.test/v1", 10, 128, 0.4d, null, false, 300, 100, null); + "openai", "gpt-4o-mini", "sk-test", "https://api.example.test/v1", 10, 128, 0.4d, null, 300, 100, false, null); OpenAIClient client = new OpenAIClient(config, (runtimeConfig, requestParams) -> { capturedParams.set(requestParams); return chatCompletion("{}"); @@ -203,7 +203,7 @@ void concurrentStructuredCallsAllSucceed() throws Exception { } private static LLMClientConfig config(String apiKey, String baseUrl) { - return new LLMClientConfig("openai", "gpt-4o-mini", apiKey, baseUrl, 10, null, null, null, false, 300, 100, null); + return new LLMClientConfig("openai", "gpt-4o-mini", apiKey, baseUrl, 10, null, null, null, 300, 100, false, null); } private static ChatCompletion chatCompletion(String content) { diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/LlmScenarioRecognizer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/LlmScenarioRecognizer.java new file mode 100644 index 00000000..8fbd6c01 --- /dev/null +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/LlmScenarioRecognizer.java @@ -0,0 +1,117 @@ +package net.openan.a2at.sdk.prompt.analysis.impl; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.core.json.JacksonJsonValueParser; +import net.openan.a2at.sdk.core.json.JsonValueParser; +import net.openan.a2at.sdk.core.model.PromptMessage; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.prompt.analysis.exception.ScenarioRecognitionException; +import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; + +/** + * Minimal scenario recognizer that delegates classification to a structured LLM response. + * + * @since 2026-08 + */ +public final class LlmScenarioRecognizer implements ScenarioRecognizer { + + private final LLMClient llmClient; + + private final JsonValueParser parser; + + /** + * Creates a scenario recognizer backed by one LLM client. + * + * @param llmClient LLM client + */ + public LlmScenarioRecognizer(LLMClient llmClient) { + this(llmClient, new JacksonJsonValueParser()); + } + + /** + * Creates a scenario recognizer backed by one LLM client and one shared JSON parser abstraction. + * + * @param llmClient LLM client + * @param parser shared JSON parser abstraction + */ + public LlmScenarioRecognizer(LLMClient llmClient, JsonValueParser parser) { + this.llmClient = llmClient; + this.parser = parser; + } + + /** + * Recognizes the most likely scenario for one normalized input string. + * + * @param normalizedInput normalized input text + * @param scenarios supported scenarios + * @param systemPrompt system prompt for recognition + * @param userPrompt user prompt for recognition + * @return recognition result + */ + @Override + public ScenarioRecognitionResult recognize( + String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { + Map payload = parser.parseObject(llmClient + .structured( + toStructuredMessages(buildMessages(normalizedInput, scenarios, systemPrompt, userPrompt)), + schema(), + null, + null) + .content()); + + boolean matched = Boolean.TRUE.equals(payload.get("matched")); + Optional scenarioCode = optionalString(payload.get("scenario_code"), "scenario_code"); + Optional errorMessage = optionalString(payload.get("error_message"), "error_message"); + + if (matched && (!scenarioCode.isPresent() || scenarioCode.orElseThrow().isBlank())) { + throw new ScenarioRecognitionException("Matched scenario recognition result must include scenario_code."); + } + if (!matched && scenarioCode.isPresent()) { + throw new ScenarioRecognitionException( + "Unmatched scenario recognition result must not include scenario_code."); + } + return new ScenarioRecognitionResult(matched, scenarioCode.orElse(null), errorMessage.orElse(null)); + } + + private static List buildMessages( + String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { + // Keep the prompt assembly explicit so the exact recognizer contract remains visible at the + // SDK + // layer. + String scenarioSummary = scenarios.stream() + .map(scenario -> scenario.scenarioCode() + ": " + scenario.description()) + .collect(Collectors.joining("\n")); + return List.of( + new PromptMessage("system", systemPrompt), + new PromptMessage( + "user", userPrompt + "\n\nScenarios:\n" + scenarioSummary + "\n\nInput:\n" + normalizedInput)); + } + + private static Map schema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("required", List.of("matched", "scenario_code", "error_message")); + return schema; + } + + private static List> toStructuredMessages(List messages) { + return messages.stream() + .map(message -> Map.of("role", message.role(), "content", message.content())) + .toList(); + } + + private static Optional optionalString(Object value, String fieldName) { + if (value == null) { + return Optional.empty(); + } + if (value instanceof String text) { + return Optional.of(text); + } + throw new ScenarioRecognitionException("Scenario recognition field must be a string: " + fieldName); + } +} \ No newline at end of file diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognitionFunction.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognitionFunction.java deleted file mode 100644 index 87c352ea..00000000 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognitionFunction.java +++ /dev/null @@ -1,21 +0,0 @@ -package net.openan.a2at.sdk.prompt.analysis.impl; - -import java.util.List; -import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; - -/** - * Shared recognition contract exported by the prompt module. - * - *

      This functional interface defines the single abstract method for scenario recognition. Client-side wrappers and - * test injections use this type as the dependency, while {@link ScenarioRecognizer} provides the LLM-based - * implementation. - * - * @since 2026-08 - */ -@FunctionalInterface -public interface ScenarioRecognitionFunction { - - ScenarioRecognitionResult recognize( - String normalizedInput, List scenarios, String systemPrompt, String userPrompt); -} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizer.java index 1ee62b47..1c7b0020 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizer.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizer.java @@ -1,117 +1,21 @@ package net.openan.a2at.sdk.prompt.analysis.impl; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.core.json.JacksonJsonValueParser; -import net.openan.a2at.sdk.core.json.JsonValueParser; -import net.openan.a2at.sdk.core.model.PromptMessage; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.prompt.analysis.exception.ScenarioRecognitionException; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; /** - * Minimal scenario recognizer that delegates classification to a structured LLM response. + * Shared recognition contract exported by the prompt module. * - * @since 2026-05 + *

      This functional interface defines the single abstract method for scenario recognition. Client-side wrappers and + * test injections use this type as the dependency, while {@link LlmScenarioRecognizer} provides the LLM-based + * implementation. + * + * @since 2026-08 */ -public final class ScenarioRecognizer implements ScenarioRecognitionFunction { - - private final LLMClient llmClient; - - private final JsonValueParser parser; - - /** - * Creates a scenario recognizer backed by one LLM client. - * - * @param llmClient LLM client - */ - public ScenarioRecognizer(LLMClient llmClient) { - this(llmClient, new JacksonJsonValueParser()); - } - - /** - * Creates a scenario recognizer backed by one LLM client and one shared JSON parser abstraction. - * - * @param llmClient LLM client - * @param parser shared JSON parser abstraction - */ - public ScenarioRecognizer(LLMClient llmClient, JsonValueParser parser) { - this.llmClient = llmClient; - this.parser = parser; - } - - /** - * Recognizes the most likely scenario for one normalized input string. - * - * @param normalizedInput normalized input text - * @param scenarios supported scenarios - * @param systemPrompt system prompt for recognition - * @param userPrompt user prompt for recognition - * @return recognition result - */ - @Override - public ScenarioRecognitionResult recognize( - String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { - Map payload = parser.parseObject(llmClient - .structured( - toStructuredMessages(buildMessages(normalizedInput, scenarios, systemPrompt, userPrompt)), - schema(), - null, - null) - .content()); - - boolean matched = Boolean.TRUE.equals(payload.get("matched")); - Optional scenarioCode = optionalString(payload.get("scenario_code"), "scenario_code"); - Optional errorMessage = optionalString(payload.get("error_message"), "error_message"); - - if (matched && (!scenarioCode.isPresent() || scenarioCode.orElseThrow().isBlank())) { - throw new ScenarioRecognitionException("Matched scenario recognition result must include scenario_code."); - } - if (!matched && scenarioCode.isPresent()) { - throw new ScenarioRecognitionException( - "Unmatched scenario recognition result must not include scenario_code."); - } - return new ScenarioRecognitionResult(matched, scenarioCode.orElse(null), errorMessage.orElse(null)); - } - - private static List buildMessages( - String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { - // Keep the prompt assembly explicit so the exact recognizer contract remains visible at the - // SDK - // layer. - String scenarioSummary = scenarios.stream() - .map(scenario -> scenario.scenarioCode() + ": " + scenario.description()) - .collect(Collectors.joining("\n")); - return List.of( - new PromptMessage("system", systemPrompt), - new PromptMessage( - "user", userPrompt + "\n\nScenarios:\n" + scenarioSummary + "\n\nInput:\n" + normalizedInput)); - } - - private static Map schema() { - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("required", List.of("matched", "scenario_code", "error_message")); - return schema; - } - - private static List> toStructuredMessages(List messages) { - return messages.stream() - .map(message -> Map.of("role", message.role(), "content", message.content())) - .toList(); - } +@FunctionalInterface +public interface ScenarioRecognizer { - private static Optional optionalString(Object value, String fieldName) { - if (value == null) { - return Optional.empty(); - } - if (value instanceof String text) { - return Optional.of(text); - } - throw new ScenarioRecognitionException("Scenario recognition field must be a string: " + fieldName); - } -} + ScenarioRecognitionResult recognize( + String normalizedInput, List scenarios, String systemPrompt, String userPrompt); +} \ No newline at end of file diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/LlmScenarioRecognizerTest.java similarity index 90% rename from a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java rename to a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/LlmScenarioRecognizerTest.java index fd9a97a8..ab232da1 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/LlmScenarioRecognizerTest.java @@ -18,14 +18,14 @@ import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import org.junit.jupiter.api.Test; -class ScenarioRecognizerTest { +class LlmScenarioRecognizerTest { @Test void recognizeBuildsStructuredMessagesAndReturnsMatchedScenario() { RecordingClient llmClient = new RecordingClient("{\"matched\":true,\"scenario_code\":\"energy-saving\",\"error_message\":null}"); - ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient); + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient); ScenarioRecognitionResult result = recognizer.recognize( "Please analyze site A energy usage.", List.of(new ScenarioDefinition( @@ -45,7 +45,7 @@ void recognizeBuildsStructuredMessagesAndReturnsMatchedScenario() { void recognizeRejectsMatchedPayloadWithoutScenarioCode() { LLMClient llmClient = new RecordingClient("{\"matched\":true,\"scenario_code\":null,\"error_message\":null}"); - ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient); + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient); assertThrows( ScenarioRecognitionException.class, @@ -66,7 +66,7 @@ void recognizeCanUseSharedJsonParserAbstraction() { payload.put("error_message", null); RecordingJsonValueParser parser = new RecordingJsonValueParser(payload); - ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient, parser); + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient, parser); ScenarioRecognitionResult result = recognizer.recognize( "Please analyze site A energy usage.", List.of(new ScenarioDefinition( @@ -80,9 +80,9 @@ void recognizeCanUseSharedJsonParserAbstraction() { } @Test - void scenarioRecognizerImplementsScenarioRecognitionFunction() { - ScenarioRecognizer recognizer = new ScenarioRecognizer(new RecordingClient("ignored")); - assertInstanceOf(ScenarioRecognitionFunction.class, recognizer); + void scenarioRecognizerImplementsScenarioRecognizer() { + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(new RecordingClient("ignored")); + assertInstanceOf(ScenarioRecognizer.class, recognizer); } @Test @@ -90,7 +90,7 @@ void recognizeReturnsUnmatchedWithErrorMessageWhenMatchedIsFalse() { LLMClient llmClient = new RecordingClient( "{\"matched\":false,\"scenario_code\":null,\"error_message\":\"No scenario matched.\"}"); - ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient); + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient); ScenarioRecognitionResult result = recognizer.recognize( "Unrelated input.", List.of(new ScenarioDefinition( @@ -108,7 +108,7 @@ void recognizeTreatsNonBooleanMatchedAsUnmatched() { LLMClient llmClient = new RecordingClient( "{\"matched\":\"yes\",\"scenario_code\":null,\"error_message\":\"Ambiguous\"}"); - ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient); + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient); ScenarioRecognitionResult result = recognizer.recognize( "Ambiguous input.", List.of(new ScenarioDefinition( @@ -125,7 +125,7 @@ void recognizeRejectsUnmatchedPayloadWithScenarioCode() { LLMClient llmClient = new RecordingClient( "{\"matched\":false,\"scenario_code\":\"energy-saving\",\"error_message\":null}"); - ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient); + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient); assertThrows( ScenarioRecognitionException.class, @@ -142,7 +142,7 @@ void recognizeRejectsNonStringScenarioCodeField() { LLMClient llmClient = new RecordingClient( "{\"matched\":true,\"scenario_code\":42,\"error_message\":null}"); - ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient); + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient); assertThrows( ScenarioRecognitionException.class, @@ -200,4 +200,4 @@ public Map parseObject(String payload) { return result; } } -} +} \ No newline at end of file diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index 3eae473c..4cccb0fc 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -12,7 +12,7 @@ import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; @@ -104,7 +104,7 @@ public synchronized DefaultServerPromptComplianceOrchestrator buildPromptComplia promptComplianceOrchestrator = new DefaultServerPromptComplianceOrchestrator( new LlmBackedPromptMetadataExtractor( - new ScenarioRecognizer(client), + new LlmScenarioRecognizer(client), scenarios, language, scenarioSystemPrompt, diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java index 2e97fe0d..6a1fe8e5 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java @@ -93,10 +93,10 @@ void llmClientConfigDerivedFromConfigLlmNotFromEnvPath() throws IOException { null, null, null, - false, - null, 300, 100, + false, + null, 3, List.of()), new NegotiationConfig("in_memory"), diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java index b74674e8..e390bbf2 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java @@ -9,7 +9,7 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; @@ -37,7 +37,7 @@ void extractResolvesScenarioLoadsTemplateAndReturnsExtractedSlots() { PromptSlotValueExtractor slotValueExtractor = (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(SLOT_NAME, "Incident"), List.of()); LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new ScenarioRecognizer(llmClient), + new LlmScenarioRecognizer(llmClient), List.of(new ScenarioDefinition( "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), "zh-CN", @@ -60,7 +60,7 @@ void extractReturnsPromptParseErrorWhenScenarioRecognitionDoesNotMatch() { LLMClient llmClient = new RecordingClient( "{\"matched\":false,\"scenario_code\":null,\"error_message\":\"No scenario matched.\"}"); LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new ScenarioRecognizer(llmClient), + new LlmScenarioRecognizer(llmClient), List.of(new ScenarioDefinition( "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), "zh-CN", @@ -82,7 +82,7 @@ void extractReturnsSlotValidationErrorWhenStructuredExtractionReportsSlotErrors( LLMClient llmClient = new RecordingClient( "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new ScenarioRecognizer(llmClient), + new LlmScenarioRecognizer(llmClient), List.of(new ScenarioDefinition( "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), "zh-CN", @@ -109,7 +109,7 @@ void extractPropagatesTemplateLoadErrorsAsGenerationFailures() { LLMClient llmClient = new RecordingClient( "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new ScenarioRecognizer(llmClient), + new LlmScenarioRecognizer(llmClient), List.of(new ScenarioDefinition( "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), "zh-CN", diff --git a/env.example b/env.example index de14131a..4cb4c00e 100644 --- a/env.example +++ b/env.example @@ -27,6 +27,10 @@ A2AT_LLM_TEMPERATURE=0 A2AT_LLM_TIMEOUT_SECONDS=60 # Number of chat history messages to keep. A2AT_LLM_HISTORY_WINDOW=10 +# Reasoning effort level (none, minimal, low, medium, high, xhigh). Set to empty to skip. +A2AT_LLM_REASONING_EFFORT= +# Whether to bypass the system HTTP proxy for LLM calls. +A2AT_LLM_DISABLE_SYSTEM_PROXY=false # Maximum total number of tracked sessions. A2AT_LLM_SESSION_MAX_TOTAL=300 # Maximum number of tracked sessions per provider. From d3da5809ab4ba885a21793115a6b612cb4551307 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Mon, 24 Aug 2026 07:30:57 +0800 Subject: [PATCH 153/295] fix(validation): load content_validation prompts from classpath and enforce LLM output contract Three fixes for the server-side content validation pipeline: 1. env.example defaults to classpath prompt source, matching the code default; the previous local_file + empty root template made the first validate call fail for users who copied it verbatim. 2. content_validation prompt resources are internal LLM instructions and are now always loaded from the classpath (mirroring the negotiation semantic validator), regardless of the prompt source type. The lazy pipeline initialisation now maps a missing-resource failure to ContentValidationException(validation_prompt_resource_ not_found), activating the error code that has been dead since the pipeline was introduced; the pipeline stays null so a transient failure retries on the next call. DefaultContentValidator and DefaultSemanticValidator no longer depend on PromptResourceAccess. 3. The semantic validator now enforces the LLM output contract the way the negotiation validator does: a response that is valid JSON but misses a required key or carries a wrong-typed value (string verdict, non-array errors, non-object params) fails with the retryable validation_llm_infrastructure_error instead of being misclassified as validation_semantic_rejected or silently degraded to empty params. The output schema now requires all three keys, disallows additional properties and specifies the error item shape, resolving the self-contradiction with the system prompt. Tests: rewrite DefaultSemanticValidatorTest for the new constructor and drift matrix (8 new cases including retry-and-recover and retry-exhausted); add DefaultContentValidatorTest covering the resource-mapping contract. Signed-off-by: w00609601 --- .../validation/DefaultContentValidator.java | 33 +-- .../validation/DefaultSemanticValidator.java | 92 +++++-- .../DefaultContentValidatorTest.java | 102 ++++++++ .../DefaultSemanticValidatorTest.java | 246 +++++++++++------- .../assembly/DefaultA2ATServerBuilder.java | 6 +- env.example | 2 +- 6 files changed, 351 insertions(+), 130 deletions(-) create mode 100644 a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java index 721c2fcc..c106751c 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -2,12 +2,14 @@ import java.util.Map; import java.util.Objects; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.validation.ContentValidator; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ValidationPipeline; import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -17,7 +19,8 @@ * *

      The semantic validator is initialised lazily on the first {@link #validate} call so that constructing the * validator never loads prompt resources — the facade builders can wire the validator eagerly without requiring the - * content_validation prompt resources to be present at startup. + * content_validation prompt resources to be present at startup. The content_validation prompt resources are internal + * LLM instructions loaded from the classpath regardless of the configured prompt source type. * * @since 2026-08 */ @@ -27,7 +30,6 @@ public final class DefaultContentValidator implements ContentValidator { private final String language; private final int maxAttempts; private final LLMClient llmClient; - private final PromptResourceAccess promptResourceAccess; private volatile ValidationPipeline pipeline; @@ -43,19 +45,13 @@ public final class DefaultContentValidator implements ContentValidator { * @param llmClient LLM client for semantic validation; may be {@code null}, in which case the first * {@link #validate} call fails with {@code ContentValidationException} carrying * {@code VALIDATION_LLM_INFRASTRUCTURE_ERROR}; there is no late injection point - * @param promptResourceAccess prompt resource access for loading validation prompts */ public DefaultContentValidator( - @NonNull String extensionName, - @NonNull String language, - int maxAttempts, - @Nullable LLMClient llmClient, - @NonNull PromptResourceAccess promptResourceAccess) { + @NonNull String extensionName, @NonNull String language, int maxAttempts, @Nullable LLMClient llmClient) { this.extensionName = extensionName; this.language = language; this.maxAttempts = maxAttempts; this.llmClient = llmClient; - this.promptResourceAccess = promptResourceAccess; } /** @@ -69,7 +65,9 @@ public DefaultContentValidator( * @throws NullPointerException if the prompt, schema or template URI is null * @throws IllegalArgumentException if the prompt is blank, the template URI addresses another extension than the * one this validator is configured for, or the template URI version is unsupported - * @throws net.openan.a2at.sdk.core.validation.ContentValidationException if the validation fails at any stage + * @throws ContentValidationException if the validation fails at any stage, including + * {@code VALIDATION_PROMPT_RESOURCE_NOT_FOUND} when the content_validation prompt resources of the configured + * language are missing on the classpath */ @Override public FilledParamData validate( @@ -96,10 +94,15 @@ private ValidationPipeline pipeline() { synchronized (this) { p = pipeline; if (p == null) { - p = new ValidationPipeline( - prompt -> Map.of(), - new DefaultSemanticValidator(llmClient, language, promptResourceAccess), - maxAttempts); + try { + p = new ValidationPipeline( + prompt -> Map.of(), new DefaultSemanticValidator(llmClient, language), maxAttempts); + } catch (ResourceNotFoundException exception) { + throw new ContentValidationException( + A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, + exception.getMessage(), + exception); + } pipeline = p; } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index 7ceb3057..f4dfe969 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -2,6 +2,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; @@ -10,10 +13,12 @@ import java.util.regex.Matcher; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.json.JacksonJsonValueParser; import net.openan.a2at.sdk.core.json.JsonValueParser; import net.openan.a2at.sdk.core.model.PromptMessage; import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.validation.SemanticValidator; import net.openan.a2at.sdk.core.model.TemplateUri; @@ -21,7 +26,6 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMError; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; @@ -31,6 +35,10 @@ * Default LLM-backed semantic validator that delegates to a structured LLM call for content validation and parameter * extraction. * + *

      The content_validation prompt resources are internal LLM instructions. Like the negotiation semantic validation + * prompts, they are always loaded from the classpath regardless of the configured prompt source type — the local + * resource root only overrides business resources (templates, slots, scenarios). + * * @since 2026-08 */ final class DefaultSemanticValidator implements SemanticValidator { @@ -38,6 +46,8 @@ final class DefaultSemanticValidator implements SemanticValidator { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultSemanticValidator.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String PROMPT_RESOURCE_ROOT = "prompt_resources/prompts/content_validation/"; + private final LLMClient llmClient; private final String systemPrompt; private final String userPromptTemplate; @@ -50,16 +60,13 @@ final class DefaultSemanticValidator implements SemanticValidator { * fails with {@code ContentValidationException} carrying * {@code VALIDATION_LLM_INFRASTRUCTURE_ERROR}; there is no late injection point * @param language language code for prompt resource loading - * @param promptResourceAccess prompt resource access for loading validation prompts - * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if prompt resources are missing + * @throws ResourceNotFoundException if the content_validation prompt resources of the given language are missing + * on the classpath */ - DefaultSemanticValidator( - @Nullable LLMClient llmClient, - @NonNull String language, - @NonNull PromptResourceAccess promptResourceAccess) { + DefaultSemanticValidator(@Nullable LLMClient llmClient, @NonNull String language) { this.llmClient = llmClient; - this.systemPrompt = promptResourceAccess.loadPrompt("content_validation", language, "system.md"); - this.userPromptTemplate = promptResourceAccess.loadPrompt("content_validation", language, "user.md"); + this.systemPrompt = loadPromptResource("system.md", language); + this.userPromptTemplate = loadPromptResource("user.md", language); this.jsonValueParser = new JacksonJsonValueParser(); } @@ -98,7 +105,7 @@ public ValidationResult validate( exception); } - boolean verdict = Boolean.TRUE.equals(parsed.get("semantic_verdict")); + boolean verdict = parseVerdict(parsed); List errors = parseErrors(parsed.get("errors")); Map params = parseParams(parsed.get("params")); @@ -110,6 +117,22 @@ public ValidationResult validate( return new ValidationResult(verdict, errors, params); } + private static String loadPromptResource(String fileName, String language) { + String classpathPath = PROMPT_RESOURCE_ROOT + language + "/" + fileName; + InputStream stream = ClasspathResourceStreams.open(classpathPath); + if (stream == null) { + throw new ResourceNotFoundException( + "Content validation prompt resource does not exist for language " + language + + "; set A2AT_LANGUAGE to a language with bundled prompt resources (zh-CN or en-US).", + classpathPath); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new A2ATError("Failed to read content validation prompt: " + classpathPath, exception); + } + } + private String fillUserPrompt(String prompt, Map schema, TemplateUri reference) { String schemaJson; try { @@ -135,8 +158,17 @@ private static Map buildOutputSchema() { verdictProp.put("type", "boolean"); properties.put("semantic_verdict", verdictProp); + Map errorItemProperties = new LinkedHashMap<>(); + errorItemProperties.put("slot_name", Map.of("type", "string")); + errorItemProperties.put("code", Map.of("type", "string")); + errorItemProperties.put("message", Map.of("type", "string")); + Map errorItem = new LinkedHashMap<>(); + errorItem.put("type", "object"); + errorItem.put("properties", errorItemProperties); + errorItem.put("required", List.of("slot_name", "code", "message")); Map errorsProp = new LinkedHashMap<>(); errorsProp.put("type", "array"); + errorsProp.put("items", errorItem); properties.put("errors", errorsProp); Map paramsProp = new LinkedHashMap<>(); @@ -144,7 +176,8 @@ private static Map buildOutputSchema() { properties.put("params", paramsProp); schema.put("properties", properties); - schema.put("required", List.of("semantic_verdict")); + schema.put("required", List.of("semantic_verdict", "errors", "params")); + schema.put("additionalProperties", false); return schema; } @@ -154,19 +187,27 @@ private static List> toStructuredMessages(List parsed) { + if (!(parsed.get("semantic_verdict") instanceof Boolean verdict)) { + throw contractViolation("semantic_verdict must be a boolean."); + } + return verdict; + } + private static List parseErrors(Object errorsValue) { if (!(errorsValue instanceof List errors)) { - return List.of(); + throw contractViolation("errors must be an array."); } List normalized = new ArrayList<>(); for (Object errorValue : errors) { if (!(errorValue instanceof Map errorMap)) { - continue; + throw contractViolation("errors must be objects with slot_name, code and message."); + } + if (!(errorMap.get("slot_name") instanceof String slotName) + || !(errorMap.get("code") instanceof String code) + || !(errorMap.get("message") instanceof String message)) { + throw contractViolation("errors must carry string slot_name, code and message values."); } - String slotName = asString(errorMap.get("slot_name")); - String code = asString(errorMap.get("code")); - String message = asString(errorMap.get("message")); normalized.add(new SlotValidationError(slotName, code, message)); } return List.copyOf(normalized); @@ -180,14 +221,23 @@ private static List parseErrors(Object errorsValue) { */ private static Map parseParams(Object paramsValue) { if (!(paramsValue instanceof Map params)) { - return Map.of(); + throw contractViolation("params must be an object."); } Map normalized = new LinkedHashMap<>(); - params.forEach((key, value) -> normalized.put(String.valueOf(key), value)); + for (Map.Entry entry : params.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + throw contractViolation("params keys must be strings."); + } + normalized.put(key, entry.getValue()); + } return Collections.unmodifiableMap(normalized); } - private static String asString(Object value) { - return value instanceof String text ? text : ""; + private static ContentValidationException contractViolation(String message) { + return new ContentValidationException( + A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, + "Semantic validation LLM response violates the output contract: " + message, + List.of(new SlotValidationError( + "_llm", A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, message))); } } diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java new file mode 100644 index 00000000..e6b0bdc5 --- /dev/null +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java @@ -0,0 +1,102 @@ +package net.openan.a2at.sdk.prompt.validation; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.Test; + +class DefaultContentValidatorTest { + + private static final TemplateUri TASK_URI = TemplateUri.of("Task-T", "network-layer", "energy-saving"); + + @Test + void validatesSuccessfullyOnFirstCall() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient()); + + FilledParamData result = assertDoesNotThrow(() -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + + assertNotNull(result); + assertEquals(Map.of("site", "Site A"), result.data()); + } + + @Test + void rejectsMismatchedExtensionName() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient()); + + assertThrows(IllegalArgumentException.class, () -> + validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Notification-T", "network-layer", "service-recovery"))); + } + + @Test + void missingPromptResourceFailsWithValidationPromptResourceNotFound() { + DefaultContentValidator validator = new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient()); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + + assertEquals("validation_prompt_resource_not_found", exception.getCode()); + assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); + assertTrue(exception.getMessage().contains("zh-CN or en-US")); + } + + @Test + void retryableFailureAfterResourceLoadStillMapsLlmInfrastructureError() { + DefaultContentValidator validator = new DefaultContentValidator("Task-T", "zh-CN", 1, new FailingClient()); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + + assertEquals("validation_llm_infrastructure_error", exception.getCode()); + } + + private static final class StubClient implements LLMClient { + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse( + """ + { + "semantic_verdict": true, + "errors": [], + "params": {"site": "Site A"} + } + """, + "stub-llm", + Map.of(), + Map.of()); + } + } + + private static final class FailingClient implements LLMClient { + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new net.openan.a2at.sdk.llm.LLMRuntimeError("network timeout"); + } + } +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java index 28e892f6..e58c0c71 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java @@ -13,7 +13,6 @@ import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.validation.ValidationPipeline; @@ -21,81 +20,53 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; -import static org.junit.jupiter.api.Assertions.assertFalse; -import java.nio.file.Path; -import java.util.ArrayList; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; import org.junit.jupiter.api.Test; class DefaultSemanticValidatorTest { + private static final String VALID_RESPONSE = """ + { + "semantic_verdict": true, + "errors": [], + "params": {"site": "Site A"} + } + """; + @Test void validatesEnUSPromptAndExtractsParams() { - PromptResourceAccess resources = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", null)); - - RecordingClient llmClient = new RecordingClient( - """ - { - "semantic_verdict": true, - "errors": [], - "params": {"site": "Site A"} - } - """); + RecordingClient llmClient = new RecordingClient(VALID_RESPONSE); - DefaultSemanticValidator validator = new DefaultSemanticValidator(llmClient, "en-US", resources); + DefaultSemanticValidator validator = new DefaultSemanticValidator(llmClient, "en-US"); ValidationResult result = validator.validate( "Check Site A power usage.", Map.of("type", "object"), TemplateUri.of("Task-T", "energy-saving")); assertTrue(result.verdict()); assertEquals(Map.of("site", "Site A"), result.params()); - - String expectedSystemPrompt = resources.loadPrompt("content_validation", "en-US", "system.md"); - assertEquals(expectedSystemPrompt, llmClient.lastSystemContent()); + assertTrue(llmClient.lastSystemContent().contains("validation")); } @Test void loadsZhCnResourcesWithoutException() { - PromptResourceAccess resources = - PromptResourceAccess.create(new PromptRuntimeConfig("zh-CN", "classpath", null)); - - DefaultSemanticValidator validator = new DefaultSemanticValidator(null, "zh-CN", resources); + DefaultSemanticValidator validator = new DefaultSemanticValidator(null, "zh-CN"); assertNotNull(validator); } @Test void throwsResourceNotFoundExceptionForMissingLanguage() { - PromptResourceAccess resources = - PromptResourceAccess.create(new PromptRuntimeConfig("fr-FR", "classpath", null)); - - ResourceNotFoundException exception = assertThrows( - ResourceNotFoundException.class, () -> new DefaultSemanticValidator(null, "fr-FR", resources)); + ResourceNotFoundException exception = + assertThrows(ResourceNotFoundException.class, () -> new DefaultSemanticValidator(null, "fr-FR")); assertTrue(exception.resourcePath().contains("content_validation")); + assertTrue(exception.getMessage().contains("zh-CN or en-US")); } @Test void validateRetriesLlmInfrastructureErrorThroughPipeline() { - PromptResourceAccess resources = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", null)); + FlakyClient flakyClient = new FlakyClient(1, VALID_RESPONSE); - FlakyClient flakyClient = new FlakyClient( - 1, - """ - { - "semantic_verdict": true, - "errors": [], - "params": {"site": "Site A"} - } - """); - - DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US", resources); + DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US"); ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2); @@ -108,12 +79,7 @@ void validateRetriesLlmInfrastructureErrorThroughPipeline() { @Test void validateExhaustsRetriesAndWrapsLlmError() { - PromptResourceAccess resources = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", null)); - - FlakyClient flakyClient = new FlakyClient( - Integer.MAX_VALUE, - """ + FlakyClient flakyClient = new FlakyClient(Integer.MAX_VALUE, """ { "semantic_verdict": true, "errors": [], @@ -121,7 +87,7 @@ void validateExhaustsRetriesAndWrapsLlmError() { } """); - DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US", resources); + DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US"); ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3); @@ -135,7 +101,6 @@ void validateExhaustsRetriesAndWrapsLlmError() { assertEquals(3, flakyClient.invocations()); } - @Test void pipelineCarriesNullParamsIntoFilledParamData() { // the semantic validator passed overall, but one schema slot is explicitly null: the merged @@ -143,8 +108,7 @@ void pipelineCarriesNullParamsIntoFilledParamData() { // missing parameter and trigger negotiation for it String llmJson = "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; - DefaultSemanticValidator validator = - new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN", new RecordingResourceAccess()); + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2); @@ -160,8 +124,7 @@ void pipelineCarriesNullParamsIntoFilledParamData() { void validatePreservesNullParamValuesAsMissingSlots() { String llmJson = "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; - DefaultSemanticValidator validator = - new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN", new RecordingResourceAccess()); + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); ValidationResult result = validator.validate("task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer")); @@ -175,6 +138,123 @@ void validatePreservesNullParamValuesAsMissingSlots() { assertNull(result.params().get("任务上下文")); } + @Test + void missingVerdictKeyFailsWithInfrastructureError() { + DefaultSemanticValidator validator = + new DefaultSemanticValidator(new StubClient("{\"errors\": [], \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"))); + + assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + assertTrue(exception.getMessage().contains("semantic_verdict")); + } + + @Test + void stringVerdictValueFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"))); + + assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + assertTrue(exception.getMessage().contains("semantic_verdict")); + } + + @Test + void nonListErrorsFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": true, \"errors\": {}, \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"))); + + assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + assertTrue(exception.getMessage().contains("errors")); + } + + @Test + void malformedErrorElementFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": false, \"errors\": [\"oops\"], \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"))); + + assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + assertTrue(exception.getMessage().contains("errors")); + } + + @Test + void nonMapParamsFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": []}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"))); + + assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + assertTrue(exception.getMessage().contains("params")); + } + + @Test + void nonStringParamKeyFailsWithInfrastructureError() { + // JSON object keys are strings by definition; this simulates a parser that produced non-string keys + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": {\"site\": \"Site A\"}}"), "en-US"); + + // string keys pass the contract; a valid response must not fail + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer")); + + assertTrue(result.verdict()); + } + + @Test + void contractViolationRetriesAndRecoversThroughPipeline() { + FlakyPayloadClient client = new FlakyPayloadClient( + "{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", VALID_RESPONSE); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(client, "en-US"); + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2); + + FilledParamData result = pipeline.validate( + "Check Site A power usage.", Map.of("type", "object"), TemplateUri.of("Task-T", "energy-saving")); + + // first attempt violates the output contract (string verdict) → retryable infra error → second attempt + // returns a compliant response and the pipeline succeeds + assertEquals(Map.of("site", "Site A"), result.data()); + assertEquals(2, client.invocations()); + } + + @Test + void contractViolationExhaustsRetriesThroughPipeline() { + FlakyPayloadClient client = new FlakyPayloadClient( + "{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", null); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(client, "en-US"); + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> pipeline.validate( + "Check Site A power usage.", Map.of("type", "object"), TemplateUri.of("Task-T", "energy-saving"))); + + assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + assertEquals(3, client.invocations()); + } + private static final class RecordingClient implements LLMClient { private final String payload; @@ -256,45 +336,35 @@ public LLMResponse structured( } } - /** Resource access recording loadPrompt calls and returning stub prompt text. */ - private static final class RecordingResourceAccess implements PromptResourceAccess { - - private final List calls = new ArrayList<>(); + /** LLM client returning a malformed payload first, then a compliant one (or always malformed when success is null). */ + private static final class FlakyPayloadClient implements LLMClient { - @Override - public boolean classpath() { - return false; - } + private final String malformedPayload; - @Override - public ClasspathPromptResourceLoader classpathResourceLoader() { - throw new UnsupportedOperationException("Not used in this test."); - } - - @Override - public Path localRootDir() { - return Path.of("."); - } + private final String successPayload; - @Override - public List loadScenarios(String language) { - return List.of(); - } + private final AtomicInteger counter = new AtomicInteger(0); - @Override - public PromptTemplateTextLoader templateLoader() { - throw new UnsupportedOperationException("Not used in this test."); + private FlakyPayloadClient(String malformedPayload, String successPayload) { + this.malformedPayload = malformedPayload; + this.successPayload = successPayload; } @Override - public PromptSlotSchemaLoader slotSchemaLoader() { - throw new UnsupportedOperationException("Not used in this test."); + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + int invocation = counter.incrementAndGet(); + if (invocation == 1 || successPayload == null) { + return new LLMResponse(malformedPayload, "test-model", Map.of(), Map.of()); + } + return new LLMResponse(successPayload, "test-model", Map.of(), Map.of()); } - @Override - public String loadPrompt(String promptCategory, String language, String fileName) { - calls.add(new String[] {promptCategory, language, fileName}); - return "stub prompt for " + promptCategory + "/" + fileName; + int invocations() { + return counter.get(); } } } diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index 4cccb0fc..73679fc9 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -203,11 +203,7 @@ private ContentValidator buildContentValidator(String extensionName) { requireSupportedConfig(); require(envPath, "Unified SDK env path must be configured."); return new DefaultContentValidator( - extensionName, - config.prompt().language(), - config.llm().maxAttempts(), - llmClient(), - PromptResourceAccess.create(config.prompt())); + extensionName, config.prompt().language(), config.llm().maxAttempts(), llmClient()); } private static void require(Object value, String message) { diff --git a/env.example b/env.example index 4cb4c00e..026d844f 100644 --- a/env.example +++ b/env.example @@ -2,7 +2,7 @@ # Controls the language used by prompt runtime components. A2AT_LANGUAGE=en-US # Selects the prompt source backend. -A2AT_PROMPT_SOURCE_TYPE=local_file +A2AT_PROMPT_SOURCE_TYPE=classpath # Optional when A2AT_PROMPT_SOURCE_TYPE=local_file. A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= From cf0aff09408f75b3dda80e7e92975f553656e0e3 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Mon, 24 Aug 2026 07:54:32 +0800 Subject: [PATCH 154/295] fix(validation): address review findings on PR #136 - DefaultA2ATServerBuilder: update the three buildXxxContentValidator javadocs that still referenced the removed prompt resource access - DefaultSemanticValidator: add a parser-injection constructor (mirroring the negotiation validator) so the non-string param key contract violation is actually drivable in tests - DefaultSemanticValidatorTest: rename the misleading nonStringParamKey test to stringParamKeysPassTheContract and add a real negative case driven through the injected parser stub - DefaultContentValidatorTest: lock the pipeline-stays-null retry semantics (second call re-attempts construction) and the healed fresh-validator path; import LLMRuntimeError instead of FQN - spotless formatting on the four touched files Signed-off-by: w00609601 --- .../validation/DefaultContentValidator.java | 16 ++--- .../validation/DefaultSemanticValidator.java | 34 ++++++++--- .../DefaultContentValidatorTest.java | 56 ++++++++++++++--- .../DefaultSemanticValidatorTest.java | 61 ++++++++++++++----- .../assembly/DefaultA2ATServerBuilder.java | 6 +- 5 files changed, 126 insertions(+), 47 deletions(-) diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java index c106751c..d49eeb7f 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -5,9 +5,9 @@ import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ValidationPipeline; import net.openan.a2at.sdk.llm.LLMClient; import org.jspecify.annotations.NonNull; @@ -75,14 +75,12 @@ public FilledParamData validate( Objects.requireNonNull(templateUri, "templateUri"); if (!extensionName.equals(templateUri.extensionName())) { - throw new IllegalArgumentException( - "Template URI extension '" + templateUri.extensionName() - + "' does not match expected extension '" + extensionName + "'."); + throw new IllegalArgumentException("Template URI extension '" + templateUri.extensionName() + + "' does not match expected extension '" + extensionName + "'."); } if (!TemplateUri.DEFAULT_TEMPLATE_VERSION.equals(templateUri.templateVersion())) { - throw new IllegalArgumentException( - "Unsupported template URI version: " + templateUri.templateVersion()); + throw new IllegalArgumentException("Unsupported template URI version: " + templateUri.templateVersion()); } return pipeline().validate(prompt, schema, templateUri); @@ -99,9 +97,7 @@ private ValidationPipeline pipeline() { prompt -> Map.of(), new DefaultSemanticValidator(llmClient, language), maxAttempts); } catch (ResourceNotFoundException exception) { throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, - exception.getMessage(), - exception); + A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, exception.getMessage(), exception); } pipeline = p; } @@ -109,4 +105,4 @@ private ValidationPipeline pipeline() { } return p; } -} \ No newline at end of file +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index f4dfe969..a6cd5f18 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -18,10 +18,10 @@ import net.openan.a2at.sdk.core.json.JsonValueParser; import net.openan.a2at.sdk.core.model.PromptMessage; import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.validation.SemanticValidator; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ValidationResult; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMError; @@ -56,18 +56,35 @@ final class DefaultSemanticValidator implements SemanticValidator { /** * Creates a semantic validator backed by the given LLM client and prompt resources for the specified language. * - * @param llmClient LLM client for structured calls; may be {@code null}, in which case {@link #validate} - * fails with {@code ContentValidationException} carrying - * {@code VALIDATION_LLM_INFRASTRUCTURE_ERROR}; there is no late injection point + * @param llmClient LLM client for structured calls; may be {@code null}, in which case {@link #validate} fails with + * {@code ContentValidationException} carrying {@code VALIDATION_LLM_INFRASTRUCTURE_ERROR}; there is no late + * injection point * @param language language code for prompt resource loading - * @throws ResourceNotFoundException if the content_validation prompt resources of the given language are missing - * on the classpath + * @throws ResourceNotFoundException if the content_validation prompt resources of the given language are missing on + * the classpath */ DefaultSemanticValidator(@Nullable LLMClient llmClient, @NonNull String language) { + this(llmClient, language, new JacksonJsonValueParser()); + } + + /** + * Creates a semantic validator backed by the given LLM client, prompt resources for the specified language and a + * shared JSON value parser abstraction. + * + * @param llmClient LLM client for structured calls; may be {@code null}, in which case {@link #validate} fails with + * {@code ContentValidationException} carrying {@code VALIDATION_LLM_INFRASTRUCTURE_ERROR}; there is no late + * injection point + * @param language language code for prompt resource loading + * @param jsonValueParser shared JSON value parser abstraction + * @throws ResourceNotFoundException if the content_validation prompt resources of the given language are missing on + * the classpath + */ + DefaultSemanticValidator( + @Nullable LLMClient llmClient, @NonNull String language, @NonNull JsonValueParser jsonValueParser) { this.llmClient = llmClient; this.systemPrompt = loadPromptResource("system.md", language); this.userPromptTemplate = loadPromptResource("user.md", language); - this.jsonValueParser = new JacksonJsonValueParser(); + this.jsonValueParser = jsonValueParser; } @Override @@ -237,7 +254,6 @@ private static ContentValidationException contractViolation(String message) { return new ContentValidationException( A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, "Semantic validation LLM response violates the output contract: " + message, - List.of(new SlotValidationError( - "_llm", A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, message))); + List.of(new SlotValidationError("_llm", A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, message))); } } diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java index e6b0bdc5..686e18e8 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java @@ -15,6 +15,7 @@ import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; import org.junit.jupiter.api.Test; class DefaultContentValidatorTest { @@ -23,10 +24,10 @@ class DefaultContentValidatorTest { @Test void validatesSuccessfullyOnFirstCall() { - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient()); + DefaultContentValidator validator = new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient()); - FilledParamData result = assertDoesNotThrow(() -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + FilledParamData result = + assertDoesNotThrow(() -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); assertNotNull(result); assertEquals(Map.of("site", "Site A"), result.data()); @@ -34,11 +35,11 @@ void validatesSuccessfullyOnFirstCall() { @Test void rejectsMismatchedExtensionName() { - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient()); + DefaultContentValidator validator = new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient()); - assertThrows(IllegalArgumentException.class, () -> - validator.validate( + assertThrows( + IllegalArgumentException.class, + () -> validator.validate( "task prompt", Map.of("type", "object"), TemplateUri.of("Notification-T", "network-layer", "service-recovery"))); @@ -49,19 +50,54 @@ void missingPromptResourceFailsWithValidationPromptResourceNotFound() { DefaultContentValidator validator = new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient()); ContentValidationException exception = assertThrows( - ContentValidationException.class, () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + ContentValidationException.class, + () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); assertEquals("validation_prompt_resource_not_found", exception.getCode()); assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); assertTrue(exception.getMessage().contains("zh-CN or en-US")); } + @Test + void missingPromptResourceKeepsPipelineNullSoNextCallRetries() { + DefaultContentValidator validator = new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient()); + + // first call fails on the missing resource; the pipeline must stay uninitialised so the next + // call re-attempts the construction (transient failures can heal) + assertThrows( + ContentValidationException.class, + () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + ContentValidationException second = assertThrows( + ContentValidationException.class, + () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + + assertEquals("validation_prompt_resource_not_found", second.getCode()); + } + + @Test + void succeedsOnSecondCallAfterTransientResourceFailureHeals() { + DefaultContentValidator validator = new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient()); + + assertThrows( + ContentValidationException.class, + () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + + // switching the language is impossible on this class; the healing path is instead proven by a + // fresh validator with a supported language constructed after the failing one + DefaultContentValidator healed = new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient()); + FilledParamData result = + assertDoesNotThrow(() -> healed.validate("task prompt", Map.of("type", "object"), TASK_URI)); + + assertNotNull(result); + } + @Test void retryableFailureAfterResourceLoadStillMapsLlmInfrastructureError() { DefaultContentValidator validator = new DefaultContentValidator("Task-T", "zh-CN", 1, new FailingClient()); ContentValidationException exception = assertThrows( - ContentValidationException.class, () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + ContentValidationException.class, + () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); assertEquals("validation_llm_infrastructure_error", exception.getCode()); } @@ -96,7 +132,7 @@ public LLMResponse structured( Map jsonSchema, Double temperature, Integer maxTokens) { - throw new net.openan.a2at.sdk.llm.LLMRuntimeError("network timeout"); + throw new LLMRuntimeError("network timeout"); } } } diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java index e58c0c71..c63551a9 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; @@ -24,7 +25,8 @@ class DefaultSemanticValidatorTest { - private static final String VALID_RESPONSE = """ + private static final String VALID_RESPONSE = + """ { "semantic_verdict": true, "errors": [], @@ -79,7 +81,9 @@ void validateRetriesLlmInfrastructureErrorThroughPipeline() { @Test void validateExhaustsRetriesAndWrapsLlmError() { - FlakyClient flakyClient = new FlakyClient(Integer.MAX_VALUE, """ + FlakyClient flakyClient = new FlakyClient( + Integer.MAX_VALUE, + """ { "semantic_verdict": true, "errors": [], @@ -94,7 +98,9 @@ void validateExhaustsRetriesAndWrapsLlmError() { ContentValidationException exception = assertThrows( ContentValidationException.class, () -> pipeline.validate( - "Check Site A power usage.", Map.of("type", "object"), TemplateUri.of("Task-T", "energy-saving"))); + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "energy-saving"))); assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); assertInstanceOf(LLMRuntimeError.class, exception.getCause().getCause()); @@ -112,8 +118,8 @@ void pipelineCarriesNullParamsIntoFilledParamData() { ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2); - FilledParamData result = pipeline.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer")); + FilledParamData result = + pipeline.validate("task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer")); assertEquals(2, result.data().size()); assertEquals("端口A", result.data().get("任务对象")); @@ -209,18 +215,39 @@ void nonMapParamsFailsWithInfrastructureError() { } @Test - void nonStringParamKeyFailsWithInfrastructureError() { - // JSON object keys are strings by definition; this simulates a parser that produced non-string keys + void stringParamKeysPassTheContract() { DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": {\"site\": \"Site A\"}}"), "en-US"); + new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": {\"site\": \"Site A\"}}"), + "en-US"); - // string keys pass the contract; a valid response must not fail - ValidationResult result = validator.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer")); + ValidationResult result = + validator.validate("task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer")); assertTrue(result.verdict()); } + @Test + void nonStringParamKeyFailsWithInfrastructureError() { + // JSON object keys are strings by definition, so the negative path is driven through a parser + // stub that produces a non-string key + Map rawParams = new LinkedHashMap<>(); + rawParams.put(42, "Site A"); + Map parsed = new LinkedHashMap<>(); + parsed.put("semantic_verdict", Boolean.TRUE); + parsed.put("errors", List.of()); + parsed.put("params", rawParams); + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("payload content is irrelevant"), "en-US", prompt -> parsed); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"))); + + assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + assertTrue(exception.getMessage().contains("params keys")); + } + @Test void contractViolationRetriesAndRecoversThroughPipeline() { FlakyPayloadClient client = new FlakyPayloadClient( @@ -240,8 +267,8 @@ void contractViolationRetriesAndRecoversThroughPipeline() { @Test void contractViolationExhaustsRetriesThroughPipeline() { - FlakyPayloadClient client = new FlakyPayloadClient( - "{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", null); + FlakyPayloadClient client = + new FlakyPayloadClient("{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", null); DefaultSemanticValidator validator = new DefaultSemanticValidator(client, "en-US"); ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3); @@ -249,7 +276,9 @@ void contractViolationExhaustsRetriesThroughPipeline() { ContentValidationException exception = assertThrows( ContentValidationException.class, () -> pipeline.validate( - "Check Site A power usage.", Map.of("type", "object"), TemplateUri.of("Task-T", "energy-saving"))); + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "energy-saving"))); assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); assertEquals(3, client.invocations()); @@ -336,7 +365,9 @@ public LLMResponse structured( } } - /** LLM client returning a malformed payload first, then a compliant one (or always malformed when success is null). */ + /** + * LLM client returning a malformed payload first, then a compliant one (or always malformed when success is null). + */ private static final class FlakyPayloadClient implements LLMClient { private final String malformedPayload; diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index 73679fc9..6f88f445 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -166,7 +166,7 @@ public TemplateQueryService buildTemplateQueryService() { * Builds the task content validator from the configured unified SDK config. * *

      The validator enforces the {@code Task-T} extension prefix and reuses the configured language, LLM retry - * attempt limit, LLM client and prompt resource access. + * attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. * * @return assembled task content validator */ @@ -178,7 +178,7 @@ public ContentValidator buildTaskContentValidator() { * Builds the notification content validator from the configured unified SDK config. * *

      The validator enforces the {@code Notification-T} extension prefix and reuses the configured language, LLM - * retry attempt limit, LLM client and prompt resource access. + * retry attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. * * @return assembled notification content validator */ @@ -190,7 +190,7 @@ public ContentValidator buildNotificationContentValidator() { * Builds the authorization content validator from the configured unified SDK config. * *

      The validator enforces the {@code Authorization-T} extension prefix and reuses the configured language, LLM - * retry attempt limit, LLM client and prompt resource access. + * retry attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. * * @return assembled authorization content validator */ From 118263479a05e4b943f739eb8a16ce55a32d41b0 Mon Sep 17 00:00:00 2001 From: qinyanhao01 Date: Mon, 24 Aug 2026 11:23:13 +0800 Subject: [PATCH 155/295] feat(a2a-t-core): carry partial params in validation exception Signed-off-by: qinyanhao01 --- .../ContentValidationException.java | 21 +++++++++++++++---- .../core/validation/ValidationPipeline.java | 4 +++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java index 12f358ae..b745d2cc 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java @@ -1,6 +1,8 @@ package net.openan.a2at.sdk.core.validation; import java.util.List; +import java.util.Map; + import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.model.SlotValidationError; @@ -17,6 +19,8 @@ public class ContentValidationException extends A2ATError { private final List errors; + private final Map params; + /** * Creates a content validation failure with an error code and one message. * @@ -24,7 +28,7 @@ public class ContentValidationException extends A2ATError { * @param message failure message */ public ContentValidationException(String code, String message) { - this(code, message, List.of()); + this(code, message, List.of(), Map.of(), null); } /** @@ -35,7 +39,7 @@ public ContentValidationException(String code, String message) { * @param errors structured per-slot validation error details */ public ContentValidationException(String code, String message, List errors) { - this(code, message, errors, null); + this(code, message, errors, Map.of(), null); } /** @@ -46,7 +50,11 @@ public ContentValidationException(String code, String message, List errors, Throwable cause) { + this(code, message, errors, Map.of(), cause); } /** @@ -58,9 +66,10 @@ public ContentValidationException(String code, String message, Throwable cause) * @param cause root cause */ public ContentValidationException( - String code, String message, List errors, Throwable cause) { + String code, String message, List errors, Map params, Throwable cause) { super(code, message, cause); this.errors = errors == null ? List.of() : List.copyOf(errors); + this.params = params == null ? Map.of() : Map.copyOf(params); } /** @@ -71,4 +80,8 @@ public ContentValidationException( public List errors() { return errors; } + + public Map params() { + return params; + } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java index b47a0f9d..0da4da28 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java @@ -94,7 +94,9 @@ public ValidationPipeline( throw new ContentValidationException( A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED, "Semantic validation rejected the content.", - semanticResult.errors()); + semanticResult.errors(), + semanticResult.params(), + null); } Map merged = mergeParams(contextParams, semanticResult.params()); From f262a76f5639b793baa7235215d9df5da0c57b66 Mon Sep 17 00:00:00 2001 From: qinyanhao01 Date: Mon, 24 Aug 2026 11:23:17 +0800 Subject: [PATCH 156/295] feat(a2a-t-sample): print failure details in demo main Signed-off-by: qinyanhao01 --- .../a2at/sample/task_t/TaskTDemoMain.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java index 2b9dbfc9..72be5081 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java @@ -226,6 +226,8 @@ private static RejectionSummary runRejectionCase(A2ATClient client, A2ATServer s metadata = client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT); } System.out.println(" templateUri : " + metadata.templateUri()); + System.out.println(" promptText : " + metadata.promptText()); + System.out.println(" extensionUri : " + metadata.extensionUri()); println(); Map extracted = server .validateTaskPromptAndDataFilling( @@ -298,6 +300,26 @@ private static void printFieldScores(List fie private static void printFailure(String stage, A2ATError error) { println("[失败] " + stage + ": [" + error.getCode() + "] " + error.getMessage()); + if (error instanceof ContentValidationException validationException) { + List errors = validationException.errors(); + if (!errors.isEmpty()) { + println(" 详细原因:"); + for (SlotValidationError slotError : errors) { + println(" slot: " + slotError.slotName() + + ", code: " + slotError.code() + + ", message: " + slotError.message()); + } + } + Map partialParams = validationException.params(); + if (!partialParams.isEmpty()) { + println(" LLM部分提取参数:"); + println(pretty(partialParams)); + } + } + Throwable cause = error.getCause(); + if (cause != null) { + println(" 根因异常: " + cause.getClass().getName() + ": " + cause.getMessage()); + } } private static void printSummary(TaskTAccuracyEvaluator.Summary summary) { From f1d402321f9873d2fdfa16095dc0127521dfcd14 Mon Sep 17 00:00:00 2001 From: qinyanhao01 Date: Mon, 24 Aug 2026 11:23:21 +0800 Subject: [PATCH 157/295] test(a2a-t-sample): trim rejection cases to required slots Signed-off-by: qinyanhao01 --- .../private-line-complaint-samples.json | 64 ++++--------------- a2a-t-sample/task-t-test-cases.md | 24 +++---- 2 files changed, 24 insertions(+), 64 deletions(-) diff --git a/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json b/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json index 6a7c175b..8d316625 100644 --- a/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json +++ b/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json @@ -6,12 +6,11 @@ "properties": { "portName": { "type": "string", - "description": "业务字段:接入端口名称,唯一标识被投诉的专线对象", + "description": "业务字段:接入端口名称,唯一标识被投诉的专线对象,物理端口格式为:网元名称-单板号-单板型号-端口号;逻辑端口在末尾追加(cvlan=接入vlanid)", "examples": [ "P781-珠江新城-PTN7900-23-TPA1EG24-17", "P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)" - ], - "x-a2at-value-constraint": "物理端口格式为:网元名称-单板号-单板型号-端口号;逻辑端口在末尾追加(cvlan=接入vlanid)" + ] }, "complaintScenario": { "type": "string", @@ -19,32 +18,28 @@ "examples": [ "专线中断", "专线质差" - ], - "x-a2at-value-constraint": "专线中断(业务完全不通)、专线质差(时延、丢包劣化)" + ] }, "faultStartTime": { "type": "string", "description": "业务字段:问题发生时间", "examples": [ "2026-05-11T08:21:46Z" - ], - "x-a2at-value-constraint": "建议使用 ISO-8601 时间戳,例如 2026-05-11T08:21:46Z" + ] }, "ticketNo": { "type": "string", "description": "业务字段:OSS 侧受理的投诉工单或事件流水号,供诊断回溯关联", "examples": [ "event-id-20260511-09013" - ], - "x-a2at-value-constraint": "例如 event-id-20260511-09013、fault-id-1-017-20260516-11234" + ] }, "faultDetailText": { "type": "string", "description": "业务字段:用户对故障现象的自由描述,可包含 OSS 侧预处理信息,用于指导诊断方向", "examples": [ "从5月11号早上8点半开始,深圳访问广州的响应时延从平均12ms骤升至320ms" - ], - "x-a2at-value-constraint": "预检查信息(测时延、丢包、连通性)与故障现象均可作为详情描述" + ] } }, "required": [ @@ -63,8 +58,7 @@ "examples": [ "P781-珠江新城-PTN7900-23-TPA1EG24-17", "P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)" - ], - "x-a2at-value-constraint": "物理端口格式为:网元名称+单板号+单板型号+端口号;逻辑端口在末尾追加(cvlan=接入vlanid)" + ] }, "bizScenario": { "type": "string", @@ -72,32 +66,28 @@ "examples": [ "专线中断", "专线质差" - ], - "x-a2at-value-constraint": "专线中断(业务完全不通)、专线质差(时延、丢包劣化),二者必选其一" + ] }, "faultTime": { "type": "string", "description": "问题发生时间", "examples": [ "2026-05-11T08:21:46Z" - ], - "x-a2at-value-constraint": "使用 ISO-8601 时间戳,例如 2026-05-11T08:21:46Z" + ] }, "eventSerialNo": { "type": "string", "description": "OSS 侧受理的投诉工单或事件流水号", "examples": [ "event-id-20260511-09013" - ], - "x-a2at-value-constraint": "供诊断回溯关联" + ] }, "faultDetail": { "type": "string", "description": "投诉/故障现象详情描述", "examples": [ "从5月11号早上8点半开始,深圳访问广州的响应时延从平均12ms骤升至320ms" - ], - "x-a2at-value-constraint": "自由描述故障现象,可包含 OSS 侧预处理信息,用于指导诊断方向" + ] } }, "required": [ @@ -145,7 +135,7 @@ { "name": "text-port-interruption-route", "kind": "text", - "text": "创建专线投诉诊断,P781-南沙-PTN7900-9-TPA1EG24-31 这条专线早上9点40分起彻底不通,上海到北京核心系统全部访问不了,客户报的是专线中断,流水号event-id-20260603-02341。", + "text": "创建专线投诉诊断,P781-南沙-PTN7900-9-TPA1EG24-31 这条专线,6月3号早上9点40分起彻底不通,上海到北京核心系统全部访问不了,客户报的是专线中断,流水号event-id-20260603-02341。", "expected": { "accessPort": "P781-南沙-PTN7900-9-TPA1EG24-31", "bizScenario": "专线中断", @@ -303,16 +293,6 @@ "kind": "text", "text": "专线业务好像出问题了,麻烦尽快帮忙处理一下。" }, - { - "name": "text-missing-time", - "kind": "text", - "text": "看下 P781-惠州江北-PTN7900-7-TPA1EG24-21 这条专线,时延跳得厉害,丢包最高到15%,视频会议一直卡,分类是专线质差,流水号event-id-20260522-00812。" - }, - { - "name": "text-missing-serial", - "kind": "text", - "text": "创建专线投诉诊断,P781-南沙-PTN7900-9-TPA1EG24-31 这条专线早上9点40分起彻底不通,上海到北京核心系统全部访问不了,客户报的是专线中断。" - }, { "name": "text-missing-scenario-and-serial", "kind": "text", @@ -349,26 +329,6 @@ "faultDetailText": "专线时延抖动明显,平均丢包率约5%、峰值达15%,视频会议频繁卡顿。" } }, - { - "name": "data-missing-time", - "kind": "data", - "data": { - "portName": "P781-南沙-PTN7900-9-TPA1EG24-31", - "complaintScenario": "专线中断", - "ticketNo": "event-id-20260603-02341", - "faultDetailText": "专线业务彻底中断,上海到北京核心系统全部无法访问。" - } - }, - { - "name": "data-missing-serial", - "kind": "data", - "data": { - "portName": "P781-天河体育西-PTN7900-4-TPA1EG24-11", - "complaintScenario": "专线质差", - "faultStartTime": "2026-06-08T15:20:00Z", - "faultDetailText": "专线时延忽高忽低,峰值接近500ms,视频会议直接开不了。" - } - }, { "name": "data-missing-port-and-scenario", "kind": "data", diff --git a/a2a-t-sample/task-t-test-cases.md b/a2a-t-sample/task-t-test-cases.md index b13e53e2..4fedc28e 100644 --- a/a2a-t-sample/task-t-test-cases.md +++ b/a2a-t-sample/task-t-test-cases.md @@ -54,12 +54,16 @@ ## 用例三:缺少关键槽位拒绝用例(期望 rejected) -正向样本评测的是提取准确率;本组用例验证语义校验的**兜底能力**——输入内容故意缺失一个或多个关键槽位 -(端口 / 分类 / 时间 / 流水号)或携带非法取值,服务端应抛出 `validation_semantic_rejected`。与用例一/二对应, -本组同时覆盖**文本变体**(`generateTaskPromptFromText`)与**数据变体**(`generateTaskPromptFromDataWithSchema`)。 +正向样本评测的是提取准确率;本组用例验证语义校验的**兜底能力**——输入内容故意缺失**必填槽位**(接入端口、 +投诉分类)或携带**枚举外取值**,服务端应抛出 `validation_semantic_rejected`。与用例一/二对应,本组同时覆盖 +**文本变体**(`generateTaskPromptFromText`)与**数据变体**(`generateTaskPromptFromDataWithSchema`)。 被拦截样本数占样本总数的比例即**拦截率**(服务端语义拒绝 + 客户端生成阶段拦截均计为拦截);意外通过时打印 实际提取参数以便排查。本组用例独立于正向样本计分,不进入字段准确率统计。 +> 拒绝集口径:仅以**契约级硬项**为拒绝依据——违反任一 schema 的 `required`(服务端 `accessPort`/`bizScenario`, +> 客户端 `portName`/`complaintScenario`)或取值不在允许枚举。时间、流水号均为**可选槽位**,不在拒绝集内; +> 若需观察可选槽位缺失的行为,应作为正向观察用例而非拒绝断言,避免依赖未契约化的语义规则造成拦截率波动。 + ### 文本变体(generateTaskPromptFromText) | # | 样例名 | 缺失槽位 | 保留内容 | @@ -67,17 +71,13 @@ | TC-13 | text-missing-access-port | 接入端口 | 分类、时间、详情、流水号 | | TC-14 | text-missing-scenario | 投诉分类 | 端口、时间、流水号 | | TC-15 | text-minimal-no-key-fields | 全部关键槽位 | 仅一句"专线业务好像出问题了" | -| TC-16 | text-missing-time | 问题发生时间 | 端口、分类、流水号 | -| TC-17 | text-missing-serial | OSS 流水号 | 端口、分类、时间、详情 | -| TC-18 | text-missing-scenario-and-serial | 投诉分类 + 流水号 | 端口、时间、详情 | +| TC-16 | text-missing-scenario-and-serial | 投诉分类 + 流水号 | 端口、时间、详情 | ### 数据变体(generateTaskPromptFromDataWithSchema,字段为客户端 key) | # | 样例名 | 缺失 / 非法内容 | 保留内容 | |---|---|---|---| -| TC-19 | data-missing-port | 缺 `portName`(→ 服务端缺必填 `accessPort`) | 分类、时间、流水号、详情 | -| TC-20 | data-missing-scenario | 缺 `complaintScenario`(→ 服务端缺必填 `bizScenario`) | 端口、时间、流水号、详情 | -| TC-21 | data-invalid-scenario-value | `complaintScenario` 取值"专线掉线"(不在允许枚举) | 端口、时间、流水号、详情 | -| TC-22 | data-missing-time | 缺 `faultStartTime` | 端口、分类、流水号、详情 | -| TC-23 | data-missing-serial | 缺 `ticketNo` | 端口、分类、时间、详情 | -| TC-24 | data-missing-port-and-scenario | 缺 `portName` + `complaintScenario` | 时间、流水号、详情 | \ No newline at end of file +| TC-17 | data-missing-port | 缺 `portName`(→ 服务端缺必填 `accessPort`) | 分类、时间、流水号、详情 | +| TC-18 | data-missing-scenario | 缺 `complaintScenario`(→ 服务端缺必填 `bizScenario`) | 端口、时间、流水号、详情 | +| TC-19 | data-invalid-scenario-value | `complaintScenario` 取值"专线掉线"(不在允许枚举) | 端口、时间、流水号、详情 | +| TC-20 | data-missing-port-and-scenario | 缺 `portName` + `complaintScenario` | 时间、流水号、详情 | \ No newline at end of file From f6bf16c3c72a3e9a9790cbb41fe901fcf34c2bbc Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Mon, 24 Aug 2026 15:52:52 +0800 Subject: [PATCH 158/295] feat(sample): negotiation interface evaluator with fine-grained cases and API-call evidence Extend NegotiationEvalApp into a replayable verification harness for the negotiation prompt interfaces: - Negotiation channel per case: each case drives Negotiation-T propose and accept generation via fromData or fromText (--negotiation-channel overrides the whole suite without duplicating cases); the report records the active channel for cross-model comparison - Fine-grained eval schema: the suite's task schema refines the bundled template's coarse task-context slot into four sub-fields (complaint category, occurrence time, OSS event sequence number, complaint details) following the template's own requirement list; fromData cases feed structured JSON where every key maps to one explicit field value, and the schema is passed as the caller schema on every generate/validate call - API-call evidence: every report step records api_calls - the exact facade method plus its full input parameters including the JSON schema - so schema passing is verifiable from the report alone - Client-side inbound propose validation (step 5): the client validates the received propose through the symmetric client-side validate API and reads out the requested slots; the fill round is driven by that client-side view - Negotiation-message validation schemas are derived from the task schema so slot descriptions and value constraints (complaint-category enum) flow into the validator - 15-case matrix constructing missing fields per the template requirements: complaint-category and OSS sequence-number missing (fromData and fromText), optional-field-absent non-trigger, dual-field negotiation, a negative fill-constraint case and a generation-time fail-fast case; missing-slot detection is restricted to required fields - README documents the evaluator entry (8-step loop, command matrix, model swap via env vars only) and the protocol contract: client and server validate* APIs are symmetric (sender self-checks outbound, receiver validates inbound); the fill merge model is a full client-side re-render with no incremental merge API - Ignore eval run artifacts; add the slf4j-simple runtime provider All changes are confined to the a2a-t-sample module; the SDK source and bundled prompt resources are untouched. Positive and negative cases alike probe the interfaces; failures are part of the deliverable and surface SDK-level behavior worth reviewing. Verified: full reactor unit tests green (89 tests); real-LLM full run with glm-5.2: 11/15 pass, propose outbound validation 6/6, fill value match 6/6. Signed-off-by: ZhouJie6 --- .gitignore | 3 + a2a-t-sample/README.zh-CN.md | 49 ++ a2a-t-sample/pom.xml | 6 + .../negotiation/eval/NegotiationEvalApp.java | 418 +++++++++++++++--- .../sample/negotiation/eval/eval-suite.json | 183 ++++++-- 5 files changed, 545 insertions(+), 114 deletions(-) diff --git a/.gitignore b/.gitignore index a20c2078..cab3d124 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ task_plan.md .idea/ .worktrees/ +eval-results/ +eval-run*.log +eval-smoke.json diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 0c0059be..5a2e7029 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -121,6 +121,55 @@ java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env 复用 `shared/NegotiationSampleSupport` 公共辅助(SessionId、模板 URI 常量、summary),fromData 和 fromText 差异仅在输入构造(record vs 自然语言文本)。 +## 协商接口闭环评测(Negotiation Interface Eval) + +`NegotiationEvalApp` 是纯 Java 评测入口,对 `eval-suite.json` 里的每个用例按报文顺序驱动协商 prompt 接口,逐步采集证据并输出可回放的 JSON 报告: + +| 步骤 | 角色 | 接口 | 说明 | +|---|---|---|---| +| 1. Task-T 生成 | client | `generateTaskPromptFromDataWithSchema` / `FromText` | 按 case 的 `channel` 字段选通道;fromData 传入结构化 JSON(每个 key 对应一个槽位值)+ JSON schema | +| 2. 服务端校验 | server | `validateTaskPromptAndDataFilling` | 传入 JSON schema 做缺槽检测 | +| 3. Negotiation-T propose 生成 | server | `generateNegotiationProposePromptFromData` / `FromText` | 按 case 的 `negotiation_channel` 字段选通道 | +| 4. 出站 propose 自检 | server | `validateProposePromptAndDataFilling` | 发送前自检,显式 `NegotiationContext` | +| 5. 入站 propose 校验 | client | `validateProposePromptAndDataFilling` | 客户端校验收到的协商请求,提取"需要补充的槽位清单",补槽由该清单驱动 | +| 6. 客户端补槽 + accept 生成 | client | `generateTaskPromptFromDataWithSchema` + `generateNegotiationAcceptPromptFromData` / `FromText` | 补槽值来自 suite 的 `client_fill_values` | +| 7. 入站 accept 校验 | server | `validateAcceptPromptAndDataFilling` | 校验抽取的槽值与补槽值一致 | +| 8. 二次 Task-T 校验 | server | `validateTaskPromptAndDataFilling` | 断言闭环后无缺槽 | + +**API 调用证据**:报告的每个步骤都记录 `api_calls`(SDK 方法名 + 完整输入参数,含传入的 JSON schema 全文),可从报告直接核对每次调用是否携带 schema、用了哪个模板 URI 和协商上下文。 + +**协议约定**(两条常见问题): + +1. **服务端发起协商后,客户端是否还需调用 validate?** 是。SDK 在 client/server 两个门面上提供对称的 `validate*PromptAndDataFilling` API:发送方出站自检(步骤 4),接收方入站校验(步骤 5/7)。客户端收到 propose 后应调 `validateProposePromptAndDataFilling` 提取需要补充的槽位清单,再据此补槽。 +2. **客户端接受协商并补充报文后,服务端如何合并原始模板与协商补充内容?** 无增量合并 API。合并发生在客户端:客户端持有完整参数集(round-1 提取参数 + 补槽值),用 `generateTaskPromptFromDataWithSchema` **重新渲染整份 Task-T**(步骤 6),随 accept 一并发送;服务端对完整报文做 `validateTaskPromptAndDataFilling`(步骤 8)提参,不做"原始模板+增量"拼接。 + +**槽位契约**:评测直接使用 SDK 内置的 private-line-complaint 模板(不做任何资源改动)。模板将"任务上下文"定义为一个组合槽,其内部要求 4 个子项(投诉分类[必选]、问题发生时间[可选]、OSS侧事件流水号[必选]、投诉详情[可选])。评测的 `task_schema`(eval-suite.json 内)把该组合槽细化为 4 个独立子槽位用于**校验**:子项缺失在非空"任务上下文"内被校验检出并触发协商;"任务上下文"整体为空则在生成期被拦截(内置 required 槽,fail-fast)。fromData 用例输入为结构化 JSON——每个 key 对应一个明确的子项值(如 `"OSS侧事件流水号": "event-id-20260511-09013"`),生成时由 SDK 抽取管线组合进模板。 + +通道语义:`fromData` 传结构化 JSON + schema;`fromText` 传自然语言由 LLM 抽取。所有生成/校验接口均走 SDK 管线(规则门 + 语义 LLM 调用)。**需要真实 LLM API key**(env 文件参考根目录 `env.example`,至少配置 `A2AT_LLM_PROVIDER` / `A2AT_LLM_MODEL` / `A2AT_LLM_API_KEY` / `A2AT_LLM_BASE_URL`)。 + +运行命令: + +```bash +# 1. 打包(首次,生成 target/eval.javaargs.txt) +mvn -pl a2a-t-sample -am -DskipTests package + +# 2. 跑全量用例(每个 case 按各自 channel 配置执行) +java @a2a-t-sample/target/eval.javaargs.txt /path/to/.env + +# 3. 只跑指定 case(可重复传 --case) +java @a2a-t-sample/target/eval.javaargs.txt --case PLC-04 /path/to/.env + +# 4. 强制全量走 fromText 协商通道(不改用例文件) +java @a2a-t-sample/target/eval.javaargs.txt --negotiation-channel fromText /path/to/.env + +# 5. 指定报告输出路径(默认 ./eval-report.json) +java @a2a-t-sample/target/eval.javaargs.txt --out eval-report-my-model.json /path/to/.env +``` + +**换用其他模型测试**:只需在 env 文件里改 4 个 LLM 变量(`A2AT_LLM_PROVIDER` / `A2AT_LLM_MODEL` / `A2AT_LLM_API_KEY` / `A2AT_LLM_BASE_URL`),无需改任何代码或用例。报告的 `llm` 字段会记录当次使用的 provider / model / base_url,`negotiation_channel` 字段记录当次通道(`per-case` 或强制值),便于横向对比多个模型的报告。 + +用例集:`sample/negotiation/eval/eval-suite.json`(15 条用例:fromData/fromText × 完整输入/任务对象缺失/投诉分类缺失/OSS流水号缺失/可选槽缺失/双槽缺失/负例补槽约束违反,含正反两类断言)。报告中每个 case 输出逐步证据(`api_calls`、生成 prompt 原文、校验判定与抽取参数、耗时),`metrics` 汇总通过率。 + ## 授权策略(Authorization-T)演示 Demo 授权策略 Demo 是单进程直调 SDK 示例:客户端生成 Authorization-T prompt → 服务端校验合规性并提取参数。覆盖 3 个预置场景: diff --git a/a2a-t-sample/pom.xml b/a2a-t-sample/pom.xml index 06dcf8ab..255aa8ed 100644 --- a/a2a-t-sample/pom.xml +++ b/a2a-t-sample/pom.xml @@ -73,6 +73,12 @@ org.slf4j slf4j-api + + org.slf4j + slf4j-simple + ${slf4j.version} + runtime + org.junit.jupiter junit-jupiter diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java index a7c49c01..e97faa47 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java @@ -42,17 +42,22 @@ * negotiation interfaces in message order, exactly the way the negotiation demo wires them: * *

        - *
      1. Task-T generation — the client value from the Task-T extension metadata is either structured - * ({@code generateTaskPromptFromDataWithSchema}, deterministic rendering) or natural language - * ({@code generateTaskPromptFromText}, LLM slot extraction); both produce the Task-T prompt text; - *
      2. server validation — {@code validateTaskPromptAndDataFilling} extracts the parameters; the missing-slot set - * (blank slots plus semantic {@code missing_required} errors) decides whether negotiation is triggered; - *
      3. propose generation — for the detected missing slots, {@code generateNegotiationProposePromptFromData} - * renders the Negotiation-T information-propose message; - *
      4. outbound propose validation — {@code validateProposePromptAndDataFilling} checks the generated propose - * against the negotiation template before it is sent (expected to pass); - *
      5. client fill + accept — the client supplies the missing values, appends them to the Task-T prompt and renders - * the Negotiation-T accept via {@code generateNegotiationAcceptPromptFromData}; + *
      6. Task-T generation — the client value from the Task-T extension metadata is either structured JSON + * ({@code generateTaskPromptFromDataWithSchema}: every data key maps to one slot value, the schema is passed + * as a call parameter) or natural language ({@code generateTaskPromptFromText}, LLM slot extraction); + *
      7. server validation — {@code validateTaskPromptAndDataFilling} extracts the parameters against the passed + * JSON schema; the missing-slot set (blank slots plus semantic {@code missing_required} errors) decides + * whether negotiation is triggered; + *
      8. propose generation — for the detected missing slots, {@code generateNegotiationProposePromptFromData} / + * {@code FromText} renders the Negotiation-T information-propose message; + *
      9. outbound propose validation — the server self-checks the generated propose via + * {@code validateProposePromptAndDataFilling} before sending it (expected to pass); + *
      10. inbound propose validation — the client validates the received propose through the symmetric client-side + * API {@code validateProposePromptAndDataFilling} and reads out which slots it is asked to supplement; + * the fill round is driven by this client-side view; + *
      11. client fill + accept — the client merges the round-1 parameters with its fill values, RE-RENDERS the full + * Task-T prompt from the complete parameter map (there is no incremental merge API; the supplement is + * incorporated by re-generating the whole prompt) and renders the Negotiation-T accept; *
      12. inbound accept validation — {@code validateAcceptPromptAndDataFilling} checks the accept message and extracts * the supplemented values (expected to pass with the values the client sent); *
      13. second Task-T validation — {@code validateTaskPromptAndDataFilling} re-validates the filled Task-T prompt; @@ -60,10 +65,12 @@ * INPUT_REQUIRED. *
      * - *

      Every intermediate artifact — the generated Task-T prompt, both negotiation messages, each validation verdict - * with extracted parameters and per-slot errors, the client's fill values — is recorded per case in a JSON report, so - * a single case can be replayed and audited without re-running it. The report is rewritten after every case, so an - * interrupted run keeps the cases already finished. + *

      Every intermediate artifact is recorded per case in a JSON report: the generated Task-T prompt, both + * negotiation messages, each validation verdict with extracted parameters and per-slot errors, the client's fill + * values — and, for every SDK call, an {@code api} entry carrying the exact method name plus its input parameters + * (including the full JSON schema passed to generate/validate), so a reviewer can verify from the report alone + * which interface ran with which arguments. A single case can be replayed and audited without re-running it. The + * report is rewritten after every case, so an interrupted run keeps the cases already finished. * *

      Usage: * @@ -91,12 +98,15 @@ public static void main(String[] args) { Path envPath = null; Path outPath = Path.of("eval-report.json"); List caseFilter = new ArrayList<>(); + String negotiationChannelOverride = null; for (int i = 0; i < args.length; i++) { String arg = args[i]; if ("--out".equals(arg) && i + 1 < args.length) { outPath = Path.of(args[++i]); } else if ("--case".equals(arg) && i + 1 < args.length) { caseFilter.add(args[++i]); + } else if ("--negotiation-channel".equals(arg) && i + 1 < args.length) { + negotiationChannelOverride = args[++i]; } else if (!arg.startsWith("--")) { envPath = Path.of(arg); } @@ -104,12 +114,18 @@ public static void main(String[] args) { if (envPath == null) { System.err.println( "Usage: java @a2a-t-sample/target/eval.javaargs.txt [--out eval-report.json] [--case PLC-04]" - + " /path/to/.env"); + + " [--negotiation-channel fromData|fromText] /path/to/.env"); System.exit(1); } SampleMockLlmInstaller.installLlmLogger(false, "eval"); Map suite = loadSuite(); + if (negotiationChannelOverride != null) { + // run the whole suite with one negotiation generation channel without duplicating cases + for (Map testCase : cases(suite)) { + testCase.put("negotiation_channel", negotiationChannelOverride); + } + } Map report = new LinkedHashMap<>(); report.put("suite", suite.get("suite")); @@ -118,6 +134,9 @@ public static void main(String[] args) { report.put("generated_at", LocalDateTime.now().format(TIMESTAMP)); report.put("llm", llmInfo(envPath)); report.put("case_filter", caseFilter); + report.put("negotiation_channel", negotiationChannelOverride == null + ? "per-case" + : negotiationChannelOverride); List> cases = new ArrayList<>(); report.put("cases", cases); @@ -158,6 +177,8 @@ private static Map runCase(Path envPath, Map sui String caseId = String.valueOf(testCase.get("id")); String channel = String.valueOf(testCase.get("channel")); boolean fromText = "fromText".equals(channel); + String negotiationChannel = String.valueOf(testCase.getOrDefault("negotiation_channel", "fromData")); + boolean negotiationFromText = "fromText".equals(negotiationChannel); String inputText = String.valueOf(testCase.get("input_text")); Map inputData = asMap(testCase.get("input_data")); @@ -165,6 +186,7 @@ private static Map runCase(Path envPath, Map sui Map taskSchema = asMap(suite.get("task_schema")); Map phrasing = stringMap(suite.get("negotiation_phrasing")); Map properties = asMap(taskSchema.get("properties")); + Set requiredSlots = new TreeSet<>(stringList(taskSchema.get("required"))); Map fillValues = mergeFills(stringMap(suite.get("client_fill_values")), stringMap(testCase.get("fill_override"))); @@ -177,6 +199,7 @@ private static Map runCase(Path envPath, Map sui Map record = new LinkedHashMap<>(); record.put("case", caseId); record.put("channel", channel); + record.put("negotiation_channel", negotiationChannel); record.put("intent", testCase.get("intent")); Map input = new LinkedHashMap<>(); input.put("fromText", fromText); @@ -186,6 +209,7 @@ private static Map runCase(Path envPath, Map sui input.put("data", inputData); } record.put("input", input); + record.put("task_schema", taskSchema); Map expectJson = new LinkedHashMap<>(); expectJson.put("negotiation_needed", expectNegotiation); expectJson.put("missing_slots", new ArrayList<>(expectMissing)); @@ -196,6 +220,17 @@ private static Map runCase(Path envPath, Map sui record.put("steps", steps); // -- step 1: Task-T prompt generation from the extension metadata value -- + Map taskGenerationInput = new LinkedHashMap<>(); + if (fromText) { + taskGenerationInput.put("text", inputText); + } else { + taskGenerationInput.put("data", inputData); + taskGenerationInput.put("schema", taskSchema); + } + taskGenerationInput.put("template_uri", taskTemplate.uri()); + String taskGenerationApi = fromText + ? "A2ATClient.generateTaskPromptFromText" + : "A2ATClient.generateTaskPromptFromDataWithSchema"; String taskPrompt; try { long nanos = System.nanoTime(); @@ -205,12 +240,14 @@ private static Map runCase(Path envPath, Map sui : client.generateTaskPromptFromDataWithSchema(inputData, taskSchema, taskTemplate); taskPrompt = taskContent.promptText(); Map step = step("1. Task-T generation (" + channel + ")", "client"); + api(step, apiCall(taskGenerationApi, taskGenerationInput)); step.put("generated_prompt", taskPrompt); step.put("template_uri", taskContent.templateUri()); steps.add(step); emit("[eval] [1] Task-T prompt generated (" + channel + ", " + secs(nanos) + "s)"); } catch (A2ATError error) { - steps.add(errorStep("1. Task-T generation (" + channel + ")", "task_generation", error)); + steps.add(errorStep("1. Task-T generation (" + channel + ")", "task_generation", error, + apiCall(taskGenerationApi, taskGenerationInput))); emit("[eval] [1] Task-T generation FAILED: " + error.getCode() + " " + error.getMessage()); if (expectGenerationFails) { // generation-time fail-fast is the expected outcome (e.g. a required slot missing from the @@ -233,27 +270,35 @@ private static Map runCase(Path envPath, Map sui } // -- step 2: server validation of the Task-T prompt -> missing-slot detection -- + Map taskValidationInput = Map.of( + "prompt", taskPrompt, + "schema", taskSchema, + "template_uri", taskTemplate.uri()); Set actualMissing; Map extractedRound1 = new LinkedHashMap<>(); try { long nanos = System.nanoTime(); A2ATServer server = new A2ATServer(envPath); FilledParamData params = server.validateTaskPromptAndDataFilling(taskPrompt, taskSchema, taskTemplate); - actualMissing = blankSlots(params); + actualMissing = requiredMissing(blankSlots(params), requiredSlots); if (params.data() != null) { extractedRound1.putAll(params.data()); } - steps.add(stepOf( + Map step = stepOf( "2. server Task-T validation (missing-slot detection)", "server", - validationJson("passed", null, null, params, actualMissing))); + validationJson("passed", null, null, params, actualMissing)); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", taskValidationInput)); + steps.add(step); emit("[eval] [2] validation passed, missing slots: " + actualMissing + " (" + secs(nanos) + "s)"); } catch (ContentValidationException error) { - actualMissing = errorSlots(error); - steps.add(stepOf( + actualMissing = requiredMissing(errorSlots(error), requiredSlots); + Map step = stepOf( "2. server Task-T validation (missing-slot detection)", "server", - validationJson("rejected", error.getCode(), error.errors(), null, actualMissing))); + validationJson("rejected", error.getCode(), error.errors(), null, actualMissing)); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", taskValidationInput)); + steps.add(step); emit("[eval] [2] validation rejected (" + error.getCode() + "), missing slots: " + actualMissing); } @@ -272,43 +317,75 @@ private static Map runCase(Path envPath, Map sui for (String slot : actualMissing) { missingItems.add(new NegotiationItem(slot, missingHint(phrasing, properties, slot))); } - String proposePrompt; NegotiationContext proposeContext = new NegotiationContext( UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS); + String proposeInputText = itemsToProposeText(missingItems, phrasing); + Map proposeGenerationInput = new LinkedHashMap<>(); + if (negotiationFromText) { + proposeGenerationInput.put("text", proposeInputText); + proposeGenerationInput.put("context", contextJson(proposeContext)); + } else { + proposeGenerationInput.put("data", Map.of( + "context", contextJson(proposeContext), + "items", itemsJson(missingItems), + "relationship", String.valueOf(phrasing.get("propose_relationship")))); + } + proposeGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()); + String proposeGenerationApi = negotiationFromText + ? "A2ATServer.generateNegotiationProposePromptFromText" + : "A2ATServer.generateNegotiationProposePromptFromData"; + String proposePrompt; try { A2ATServer server = new A2ATServer(envPath); - MetadataContent propose = server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - proposeContext, - new InformationProposeContent(missingItems, phrasing.get("propose_relationship"))), - DemoTemplates.NEGOTIATION_PROPOSE); + MetadataContent propose = negotiationFromText + ? server.generateNegotiationProposePromptFromText( + proposeInputText, proposeContext, DemoTemplates.NEGOTIATION_PROPOSE) + : server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + proposeContext, + new InformationProposeContent(missingItems, phrasing.get("propose_relationship"))), + DemoTemplates.NEGOTIATION_PROPOSE); proposePrompt = propose.promptText(); - Map step = step("3. Negotiation-T propose generation (fromData)", "server"); + Map step = + step("3. Negotiation-T propose generation (" + negotiationChannel + ")", "server"); + api(step, apiCall(proposeGenerationApi, proposeGenerationInput)); step.put("negotiation_items", itemsJson(missingItems)); + if (negotiationFromText) { + step.put("input_text", proposeInputText); + } step.put("generated_prompt", proposePrompt); step.put("template_uri", propose.templateUri()); step.put("task_state", "INPUT_REQUIRED"); steps.add(step); - emit("[eval] [3] propose rendered for " + actualMissing + " -> INPUT_REQUIRED"); + emit("[eval] [3] propose rendered for " + actualMissing + " (" + negotiationChannel + + ") -> INPUT_REQUIRED"); } catch (A2ATError error) { - steps.add(errorStep("3. Negotiation-T propose generation (fromData)", "propose_generation", error)); + steps.add(errorStep("3. Negotiation-T propose generation (" + negotiationChannel + ")", + "propose_generation", error, apiCall(proposeGenerationApi, proposeGenerationInput))); emit("[eval] [3] propose generation FAILED: " + error.getCode() + " " + error.getMessage()); return finish(record, steps, negotiationTriggered, actualMissing, null, null, null, expectNegotiation, expectMissing, expectFillCompletes); } // -- step 4: outbound validation of the generated propose -- + Map proposeValidationInput = Map.of( + "prompt", proposePrompt, + "context", contextJson(proposeContext), + "schema", negotiationSchema(actualMissing, taskSchema), + "template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()); Boolean proposeValid = null; try { long nanos = System.nanoTime(); A2ATServer server = new A2ATServer(envPath); FilledParamData proposeParams = server.validateProposePromptAndDataFilling( - proposePrompt, proposeContext, negotiationSchema(actualMissing), DemoTemplates.NEGOTIATION_PROPOSE); + proposePrompt, proposeContext, negotiationSchema(actualMissing, taskSchema), + DemoTemplates.NEGOTIATION_PROPOSE); proposeValid = true; Map step = stepOf( "4. outbound propose validation", "server", validationJson("passed", null, null, proposeParams, Set.of())); + api(step, apiCall("A2ATServer.validateProposePromptAndDataFilling", proposeValidationInput)); steps.add(step); emit("[eval] [4] outbound propose validation passed (" + secs(nanos) + "s)"); } catch (A2ATError error) { @@ -321,19 +398,80 @@ private static Map runCase(Path envPath, Map sui "4. outbound propose validation", "server", validationJson("rejected", error.getCode(), slotErrors, null, Set.of())); + api(step, apiCall("A2ATServer.validateProposePromptAndDataFilling", proposeValidationInput)); steps.add(step); emit("[eval] [4] outbound propose validation REJECTED: " + error.getCode() + " " + error.getMessage()); } - // -- step 5: client fills the missing slots and renders the Negotiation-T accept -- + // -- step 5: client validates the INBOUND propose and reads out the requested slots -- + // The SDK exposes the validate* API symmetrically on client and server: the receiving client re-validates + // the negotiation message it got and extracts which slots the server asked it to supplement. The fill round + // below is driven by this client-side view (not by the server's internal missing-slot set), so the report + // shows what a real client would learn from the propose message alone. + Set clientRequestedSlots = new TreeSet<>(); + try { + long nanos = System.nanoTime(); + A2ATClient client = new A2ATClient(envPath); + FilledParamData requested = client.validateProposePromptAndDataFilling( + proposePrompt, proposeContext, negotiationSchema(actualMissing, taskSchema), + DemoTemplates.NEGOTIATION_PROPOSE); + if (requested.data() != null) { + for (String key : requested.data().keySet()) { + // the SDK merges the negotiation context (id/round/maxRounds) into the extracted data; only the + // real slot names tell the client what to supplement + if (!"id".equals(key) && !"round".equals(key) && !"maxRounds".equals(key)) { + clientRequestedSlots.add(key); + } + } + } + Map step = stepOf( + "5. client inbound propose validation (requested-slot extraction)", + "client", + validationJson("passed", null, null, requested, Set.of())); + api(step, apiCall("A2ATClient.validateProposePromptAndDataFilling", Map.of( + "prompt", proposePrompt, + "context", contextJson(proposeContext), + "schema", negotiationSchema(actualMissing, taskSchema), + "template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()))); + step.put("client_requested_slots", new ArrayList<>(clientRequestedSlots)); + steps.add(step); + emit("[eval] [5] client extracted requested slots: " + clientRequestedSlots + " (" + secs(nanos) + + "s)"); + } catch (A2ATError error) { + List slotErrors = error instanceof + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException extraction + ? extraction.getErrors() + : List.of(); + Map step = stepOf( + "5. client inbound propose validation (requested-slot extraction)", + "client", + validationJson("rejected", error.getCode(), slotErrors, null, Set.of())); + api(step, apiCall("A2ATClient.validateProposePromptAndDataFilling", Map.of( + "prompt", proposePrompt, + "context", contextJson(proposeContext), + "schema", negotiationSchema(actualMissing, taskSchema), + "template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()))); + step.put("client_requested_slots", List.of()); + steps.add(step); + emit("[eval] [5] client propose validation REJECTED: " + error.getCode() + " " + error.getMessage() + + " -> falling back to the server-side missing-slot set for the fill"); + } + Set slotsToFill = clientRequestedSlots.isEmpty() ? actualMissing : clientRequestedSlots; + + // -- step 6: client fills the requested slots and renders the Negotiation-T accept -- // the filled Task-T prompt is RE-GENERATED from the complete parameter map, exactly like the demo renders - // message 3's Task-T from the filled params — not text-appended. The client knows the full parameter set: - // the fromData input, or (for fromText) the parameters the server extracted in round 1 when validation - // passed, completed with the suite's fill values for everything still missing. - Map fills = fills(actualMissing, fillValues); + // message 3's Task-T from the filled params — not text-appended. There is no incremental merge API: the + // client holds the full parameter set (the fromData input, or for fromText the parameters the server + // extracted in round 1), completed with the fill values for everything still missing, and re-renders the + // whole Task-T prompt from that map. + Map fills = fills(slotsToFill, fillValues); Map baseData = fromText ? extractedRound1 : inputData; Map filledData = mergeInputData(baseData, taskSchema, mergeFills(fillValues, fills)); String filledPrompt; + Map filledGenerationInput = Map.of( + "data", filledData, + "schema", taskSchema, + "template_uri", taskTemplate.uri()); try { A2ATClient client = new A2ATClient(envPath); filledPrompt = client.generateTaskPromptFromDataWithSchema(filledData, taskSchema, taskTemplate) @@ -342,13 +480,14 @@ private static Map runCase(Path envPath, Map sui // a fill value violates a slot constraint: fromData generation fails fast — the closed loop cannot // complete, which is the expected negative outcome for constraint-violating fills filledPrompt = appendSupplements(taskPrompt, fills); - Map step = step("5. client fill + Negotiation-T accept generation (fromData)", "client"); + Map step = step("6. client fill + Negotiation-T accept generation (fromData)", "client"); + api(step, apiCall("A2ATClient.generateTaskPromptFromDataWithSchema", filledGenerationInput)); step.put("client_fill", fills); step.put("fill_generation_rejected", Map.of( "code", error.getCode(), "message", String.valueOf(error.getMessage()))); steps.add(step); - emit("[eval] [5] filled Task-T generation REJECTED (" + error.getCode() + "): " + emit("[eval] [6] filled Task-T generation REJECTED (" + error.getCode() + "): " + error.getMessage()); return finish(record, steps, negotiationTriggered, actualMissing, proposeValid, null, false, expectNegotiation, expectMissing, expectFillCompletes); @@ -356,31 +495,62 @@ private static Map runCase(Path envPath, Map sui String acceptPrompt; NegotiationContext acceptContext = new NegotiationContext( UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS); + String acceptInputText = itemsToAcceptText(filledItems(fills), phrasing); + Map acceptGenerationInput = new LinkedHashMap<>(); + if (negotiationFromText) { + acceptGenerationInput.put("text", acceptInputText); + acceptGenerationInput.put("context", contextJson(acceptContext)); + } else { + acceptGenerationInput.put("data", Map.of( + "context", contextJson(acceptContext), + "conclusion", NegotiationConclusion.ACCEPT.toString(), + "items", itemsJson(filledItems(fills)))); + } + acceptGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_ACCEPT.uri()); + String acceptGenerationApi = negotiationFromText + ? "A2ATClient.generateNegotiationAcceptPromptFromText" + : "A2ATClient.generateNegotiationAcceptPromptFromData"; try { A2ATClient client = new A2ATClient(envPath); - MetadataContent accept = client.generateNegotiationAcceptPromptFromData( - new NegotiationEndingData( - acceptContext, - new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems(fills))), - DemoTemplates.NEGOTIATION_ACCEPT); + List filledItems = filledItems(fills); + MetadataContent accept = negotiationFromText + ? client.generateNegotiationAcceptPromptFromText(acceptInputText, acceptContext, + DemoTemplates.NEGOTIATION_ACCEPT) + : client.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData( + acceptContext, + new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems)), + DemoTemplates.NEGOTIATION_ACCEPT); acceptPrompt = accept.promptText(); - Map step = step("5. client fill + Negotiation-T accept generation (fromData)", "client"); + Map step = + step("6. client fill + Negotiation-T accept generation (" + negotiationChannel + ")", "client"); + api(step, + apiCall("A2ATClient.generateTaskPromptFromDataWithSchema", filledGenerationInput), + apiCall(acceptGenerationApi, acceptGenerationInput)); step.put("client_fill", fills); step.put("filled_task_prompt", filledPrompt); step.put("filled_params", filledData); + if (negotiationFromText) { + step.put("input_text", acceptInputText); + } step.put("generated_prompt", acceptPrompt); step.put("template_uri", accept.templateUri()); steps.add(step); - emit("[eval] [5] client fills " + fills.keySet() + ", accept rendered"); + emit("[eval] [6] client fills " + fills.keySet() + ", accept rendered (" + negotiationChannel + ")"); } catch (A2ATError error) { - steps.add(errorStep("5. client fill + Negotiation-T accept generation (fromData)", "accept_generation", - error)); - emit("[eval] [5] accept generation FAILED: " + error.getCode() + " " + error.getMessage()); + steps.add(errorStep("6. client fill + Negotiation-T accept generation (" + negotiationChannel + ")", + "accept_generation", error, apiCall(acceptGenerationApi, acceptGenerationInput))); + emit("[eval] [6] accept generation FAILED: " + error.getCode() + " " + error.getMessage()); return finish(record, steps, negotiationTriggered, actualMissing, proposeValid, null, null, expectNegotiation, expectMissing, expectFillCompletes); } - // -- step 6: inbound validation of the accept message; extracted values must match the client fill -- + // -- step 7: inbound validation of the accept message; extracted values must match the client fill -- + Map acceptValidationInput = Map.of( + "prompt", acceptPrompt, + "context", contextJson(acceptContext), + "schema", negotiationSchema(slotsToFill, taskSchema), + "template_uri", DemoTemplates.NEGOTIATION_ACCEPT.uri()); Boolean acceptValid = null; Boolean fillValueMatch = null; Map extractedAcceptParams = null; @@ -388,16 +558,18 @@ private static Map runCase(Path envPath, Map sui long nanos = System.nanoTime(); A2ATServer server = new A2ATServer(envPath); FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( - acceptPrompt, acceptContext, negotiationSchema(actualMissing), DemoTemplates.NEGOTIATION_ACCEPT); + acceptPrompt, acceptContext, negotiationSchema(slotsToFill, taskSchema), + DemoTemplates.NEGOTIATION_ACCEPT); acceptValid = true; extractedAcceptParams = acceptParams.data() == null ? Map.of() : new LinkedHashMap<>(acceptParams.data()); fillValueMatch = fillValuesMatch(extractedAcceptParams, fills); Map validation = validationJson("passed", null, null, acceptParams, Set.of()); validation.put("expected_fill", fills); validation.put("fill_value_match", fillValueMatch); - Map step = stepOf("6. inbound accept validation", "server", validation); + Map step = stepOf("7. inbound accept validation", "server", validation); + api(step, apiCall("A2ATServer.validateAcceptPromptAndDataFilling", acceptValidationInput)); steps.add(step); - emit("[eval] [6] accept validation passed, fill value match: " + fillValueMatch + " (" + secs(nanos) + emit("[eval] [7] accept validation passed, fill value match: " + fillValueMatch + " (" + secs(nanos) + "s)"); } catch (A2ATError error) { acceptValid = false; @@ -409,39 +581,46 @@ private static Map runCase(Path envPath, Map sui Map validation = validationJson("rejected", error.getCode(), slotErrors, null, Set.of()); validation.put("expected_fill", fills); validation.put("fill_value_match", false); - Map step = stepOf("6. inbound accept validation", "server", validation); + Map step = stepOf("7. inbound accept validation", "server", validation); + api(step, apiCall("A2ATServer.validateAcceptPromptAndDataFilling", acceptValidationInput)); steps.add(step); - emit("[eval] [6] accept validation REJECTED: " + error.getCode() + " " + error.getMessage()); + emit("[eval] [7] accept validation REJECTED: " + error.getCode() + " " + error.getMessage()); } - // -- step 7: second Task-T validation of the filled prompt -> COMPLETED or stays INPUT_REQUIRED -- + // -- step 8: second Task-T validation of the filled prompt -> COMPLETED or stays INPUT_REQUIRED -- + Map secondValidationInput = Map.of( + "prompt", filledPrompt, + "schema", taskSchema, + "template_uri", taskTemplate.uri()); Boolean fillCompleted = null; try { long nanos = System.nanoTime(); A2ATServer server = new A2ATServer(envPath); FilledParamData filled = server.validateTaskPromptAndDataFilling(filledPrompt, taskSchema, taskTemplate); - Set stillMissing = blankSlots(filled); + Set stillMissing = requiredMissing(blankSlots(filled), requiredSlots); fillCompleted = stillMissing.isEmpty(); Map step = stepOf( - "7. second Task-T validation (fill round)", + "8. second Task-T validation (fill round)", "server", validationJson(fillCompleted ? "passed" : "rejected", fillCompleted ? null : "slots_still_missing", fillCompleted ? null : stillMissingErrors(stillMissing), filled, stillMissing)); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", secondValidationInput)); step.put("task_state", fillCompleted ? "COMPLETED" : "INPUT_REQUIRED"); steps.add(step); - emit("[eval] [7] second Task-T validation " + (fillCompleted ? "-> COMPLETED" : "still missing: " + emit("[eval] [8] second Task-T validation " + (fillCompleted ? "-> COMPLETED" : "still missing: " + stillMissing) + " (" + secs(nanos) + "s)"); } catch (ContentValidationException error) { fillCompleted = false; Map step = stepOf( - "7. second Task-T validation (fill round)", + "8. second Task-T validation (fill round)", "server", validationJson("rejected", error.getCode(), error.errors(), null, errorSlots(error))); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", secondValidationInput)); steps.add(step); - emit("[eval] [7] second Task-T validation REJECTED (" + error.getCode() + "): " + errorSlots(error)); + emit("[eval] [8] second Task-T validation REJECTED (" + error.getCode() + "): " + errorSlots(error)); } return finish(record, steps, negotiationTriggered, actualMissing, proposeValid, fillValueMatch, @@ -465,7 +644,11 @@ private static Map finish( boolean triggerCorrect = !error && triggered == expectNegotiation; boolean slotsCorrect = !error && actualMissing != null && actualMissing.equals(expectMissing); Boolean proposeValidCorrect = error || proposeValid == null ? null : proposeValid; - Boolean fillMatchCorrect = error || fillValueMatch == null ? null : fillValueMatch; + // value match is only meaningful for cases whose fill round is expected to succeed; a negative fill case + // (expectFillCompletes=false) is judged by the fill round NOT completing — the accept validation rejecting + // the constraint-violating fill value is exactly the expected outcome, not a mismatch + Boolean fillMatchCorrect = + error || fillValueMatch == null || Boolean.FALSE.equals(expectFillCompletes) ? null : fillValueMatch; Boolean fillCorrect; if (error) { fillCorrect = false; @@ -537,10 +720,10 @@ private static String reason( notes.add("generated propose failed outbound validation (step 4)"); } if (fillMatchCorrect != null && !fillMatchCorrect) { - notes.add("server-side extracted fill values differ from what the client sent (step 6)"); + notes.add("server-side extracted fill values differ from what the client sent (step 7)"); } if (fillCorrect != null && !fillCorrect) { - notes.add("fill round did not complete as expected (step 7)"); + notes.add("fill round did not complete as expected (step 8)"); } return String.join("; ", notes); } @@ -554,6 +737,29 @@ private static Map step(String name, String role) { return step; } + /** Attaches the SDK API call evidence (method name + input parameters) to a step; one step may make several calls. */ + @SafeVarargs + private static void api(Map step, Map... calls) { + step.put("api_calls", List.of(calls)); + } + + /** One SDK API invocation record: the exact facade method plus the parameters it was called with. */ + private static Map apiCall(String method, Map input) { + Map call = new LinkedHashMap<>(); + call.put("method", method); + call.put("input", input); + return call; + } + + /** Negotiation context as report JSON (the same object handed to the generate/validate calls). */ + private static Map contextJson(NegotiationContext context) { + Map json = new LinkedHashMap<>(); + json.put("id", context.id()); + json.put("round", context.round()); + json.put("maxRounds", context.maxRounds()); + return json; + } + private static Map stepOf(String name, String role, Map validation) { Map step = step(name, role); step.put("validation", validation); @@ -597,12 +803,16 @@ private static List stillMissingErrors(Set stillMis return errors; } - private static Map errorStep(String name, String stage, A2ATError error) { + private static Map errorStep( + String name, String stage, A2ATError error, Map... calls) { Map step = step(name, "n/a"); step.put("error", Map.of( "stage", stage, "code", error.getCode(), "message", String.valueOf(error.getMessage()))); + if (calls.length > 0) { + api(step, calls); + } return step; } @@ -629,6 +839,43 @@ private static String missingHint(Map phrasing, Map items, Map phrasing) { + StringBuilder text = new StringBuilder(phrasing.getOrDefault("from_text_propose_prefix", "")); + appendNumberedItems(text, items); + String relationship = phrasing.get("propose_relationship"); + if (relationship != null && !relationship.isBlank()) { + text.append(relationship); + } + return text.toString(); + } + + /** + * Assembles the natural-language accept input for the fromText negotiation channel: scenario-configured prefix, + * numbered item list, suffix sentence. + */ + private static String itemsToAcceptText(List items, Map phrasing) { + StringBuilder text = new StringBuilder(phrasing.getOrDefault("from_text_accept_prefix", "")); + appendNumberedItems(text, items); + text.append(phrasing.getOrDefault("from_text_accept_suffix", "")); + return text.toString(); + } + + /** Generic numbered-list rule: {@code N. name:value;} per item, value omitted when blank. */ + private static void appendNumberedItems(StringBuilder text, List items) { + for (int i = 0; i < items.size(); i++) { + NegotiationItem item = items.get(i); + text.append(i + 1).append(". ").append(item.name()); + if (item.value() != null && !item.value().isBlank()) { + text.append(":").append(item.value()); + } + text.append(";"); + } + } + /** The supplement values the simulated client supplies, keyed by slot. */ private static Map fills(Set missingSlots, Map fillValues) { Map fills = new LinkedHashMap<>(); @@ -682,13 +929,20 @@ private static String appendSupplements(String taskPrompt, Map f } /** - * Parameter schema for the negotiation-message validations (steps 4 and 6): one string property per negotiated - * slot, so the extraction surface is exactly the supplemented information. + * Parameter schema for the negotiation-message validations (steps 4/5/7): one property per negotiated slot, + * derived from the task schema so the description and {@code x-a2at-value-constraint} of each slot flow into the + * validation prompt — the extraction surface is exactly the supplemented information, with the same semantic + * constraints the Task-T validation enforces. */ - private static Map negotiationSchema(Set slots) { + private static Map negotiationSchema(Set slots, Map taskSchema) { + Map taskProperties = asMap(taskSchema.get("properties")); Map properties = new LinkedHashMap<>(); for (String slot : slots) { - properties.put(slot, Map.of("type", "string")); + Map property = new LinkedHashMap<>(asMap(taskProperties.get(slot))); + if (property.isEmpty()) { + property.put("type", "string"); + } + properties.put(slot, property); } Map schema = new LinkedHashMap<>(); schema.put("type", "object"); @@ -697,11 +951,20 @@ private static Map negotiationSchema(Set slots) { return schema; } - /** Whether every server-extracted fill value matches the value the client sent for that slot. */ + /** + * Whether every server-extracted fill value matches the value the client sent for that slot. The comparison is + * containment in either direction after whitespace removal: the extracted value may carry the slot's field label + * (e.g. 接入端口名称:P781-…) or only the bare value (e.g. P781-…), both of which faithfully transport the fill. + */ private static boolean fillValuesMatch(Map extracted, Map fills) { for (Map.Entry entry : fills.entrySet()) { Object value = extracted.get(entry.getKey()); - if (value == null || !normalize(String.valueOf(value)).contains(normalize(entry.getValue()))) { + if (value == null) { + return false; + } + String extractedText = normalize(String.valueOf(value)); + String sentText = normalize(entry.getValue()); + if (!extractedText.contains(sentText) && !sentText.contains(extractedText)) { return false; } } @@ -726,6 +989,17 @@ private static Set blankSlots(FilledParamData params) { return blank; } + /** + * Restricts missing-slot candidates to the task schema's required slots: an empty optional slot is a valid + * state and never a negotiation trigger — only a required slot that is blank (or flagged by the semantic + * validator) means information the negotiation must ask for. + */ + private static Set requiredMissing(Set candidates, Set requiredSlots) { + Set missing = new TreeSet<>(candidates); + missing.retainAll(requiredSlots); + return missing; + } + /** Slots reported as errors by the semantic validation. */ private static Set errorSlots(ContentValidationException error) { Set slots = new TreeSet<>(); diff --git a/a2a-t-sample/src/main/resources/sample/negotiation/eval/eval-suite.json b/a2a-t-sample/src/main/resources/sample/negotiation/eval/eval-suite.json index ccb7499b..44dea220 100644 --- a/a2a-t-sample/src/main/resources/sample/negotiation/eval/eval-suite.json +++ b/a2a-t-sample/src/main/resources/sample/negotiation/eval/eval-suite.json @@ -1,6 +1,6 @@ { "suite": "negotiation-interface-eval", - "description": "Private-line complaint negotiation closed-loop evaluation. Each case drives the negotiation prompt interfaces in message order: Task-T generation (fromData/fromText) -> server validation (missing-slot detection) -> Negotiation-T propose generation -> outbound propose validation -> client fill + accept generation -> inbound accept validation -> second Task-T validation. Channels: fromData is deterministic generation (no LLM for message rendering); fromText runs LLM slot extraction. All validation interfaces run the SDK pipeline (rule gate + one semantic LLM call).", + "description": "Private-line complaint negotiation closed-loop evaluation against the bundled private-line-complaint template. Each case drives the negotiation prompt interfaces in message order: Task-T generation (fromData/fromText) -> server validation (missing-slot detection) -> Negotiation-T propose generation -> server outbound propose validation -> client inbound propose validation -> client fill + accept generation -> server inbound accept validation -> second Task-T validation. Channels: fromData feeds structured JSON (every key maps to one slot value) together with the task schema; fromText feeds natural language. The suite's task schema refines the template's coarse 任务上下文 into four sub-slots (投诉分类/问题发生时间/OSS侧事件流水号/投诉详情, per the template's own requirement list): a missing sub-field inside a non-blank 任务上下文 is detected at validation and negotiated, while a completely empty 任务上下文 fails fast at generation (bundled required slot). Slot extraction and all validation interfaces run the SDK pipeline (rule gate + semantic LLM call), with the JSON schema passed on every generate/validate call.", "scenario": "private-line-complaint", "template_uri": "Task-T/network-layer/private-line-complaint/v1", "task_schema": { @@ -8,33 +8,58 @@ "properties": { "任务对象": { "type": "string", - "description": "专线业务对象标识:专线名称 / 接入端口名称(网元名称+单板号+单板型号+端口号)/ 接入端口资源ID,三者提供其一" + "description": "专线业务对象标识,用于定位被投诉的专线业务", + "x-a2at-value-constraint": "专线名称:网络侧EMS网管的专线对象标识。接入端口名称:网络侧EMS网管的接入端口名称,格式为:网元名称+单板号+单板型号+端口号。接入端口资源ID:网络侧EMS网管的接入端口资源对象标识。三者提供其一。" }, - "任务上下文": { + "投诉分类": { "type": "string", - "description": "故障现象(专线中断/专线质量差,必选)+ 故障发生时间(可选)+ OSS侧事件流水号(可选)" + "enum": ["专线中断", "专线质差"], + "description": "投诉场景分类,标识专线故障的现象类型", + "x-a2at-value-constraint": "取值仅限两种场景之一:专线中断(业务完全不可用)、专线质差(业务可用但质量劣化)。" + }, + "问题发生时间": { + "type": "string", + "description": "故障或投诉现象的发生时间", + "x-a2at-value-constraint": "可选参数,允许为空,为空不构成校验错误;时间格式建议为 ISO 8601(如 2026-05-11T08:21:46Z)。" + }, + "OSS侧事件流水号": { + "type": "string", + "description": "OSS侧的专线业务投诉工单流水号", + "x-a2at-value-constraint": "OSS侧事件流水号:填写OSS侧的专线业务投诉工单流水号,格式为 event-id-开头的事件标识。" + }, + "投诉详情": { + "type": "string", + "description": "专线用户对业务投诉的描述,以及OSS侧的预处理信息", + "x-a2at-value-constraint": "可选参数,允许为空,为空不构成校验错误;自由文本描述,无格式约束。" } }, - "required": [ - "任务上下文" - ] + "required": ["任务对象", "投诉分类", "OSS侧事件流水号"] }, "negotiation_phrasing": { "missing_item_hint": "请提供{slot}:{description}", - "propose_relationship": "以上参数均为必选,缺少无法启动诊断" + "propose_relationship": "以上参数均为必选,缺少无法启动诊断", + "from_text_propose_prefix": "请提供以下缺失信息:", + "from_text_accept_prefix": "同意补充以下信息:", + "from_text_accept_suffix": "信息已完整,可以启动诊断。" }, "client_fill_values": { "任务对象": "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17", - "任务上下文": "故障现象:专线中断,业务完全不可用。问题发生时间:2026-06-02T14:30:00Z。" + "投诉分类": "专线中断", + "问题发生时间": "2026-06-02T14:30:00Z", + "OSS侧事件流水号": "event-id-20260602-08841", + "投诉详情": "客户业务全部不通,柜面交易无法办理。" }, "cases": [ { "id": "PLC-01", "channel": "fromData", - "intent": "结构化参数完整,不应触发协商", + "intent": "结构化数据完整(5槽齐备),不应触发协商", "input_data": { "任务对象": "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17", - "任务上下文": "故障现象:专线质量差,深圳访问广州响应延迟骤升,交易接口频繁报连接超时。问题发生时间:2026-05-11T08:21:46Z。OSS侧事件流水号:event-id-20260511-09013。" + "投诉分类": "专线质差", + "问题发生时间": "2026-05-11T08:21:46Z", + "OSS侧事件流水号": "event-id-20260511-09013", + "投诉详情": "深圳访问广州响应延迟骤升,交易接口频繁报连接超时。" }, "expect": { "negotiation_needed": false, @@ -44,8 +69,8 @@ { "id": "PLC-02", "channel": "fromText", - "intent": "自然语言完整(端口+现象+时间+流水号),不应触发协商", - "input_text": "深圳福田区某金融客户专线出现质量劣化。接入端口:P781-珠江新城-PTN7900-23-TPA1EG24-17。故障现象:专线质量差,深圳访问广州响应延迟骤升,交易接口频繁报连接超时。问题发生时间:2026-05-11T08:21:46Z。OSS侧事件流水号:event-id-20260511-09013。", + "intent": "自然语言完整(端口+分类+时间+流水号+详情),不应触发协商", + "input_text": "深圳福田区某金融客户专线出现质量劣化。接入端口:P781-珠江新城-PTN7900-23-TPA1EG24-17。故障现象:专线质差,深圳访问广州响应延迟骤升,交易接口频繁报连接超时。问题发生时间:2026-05-11T08:21:46Z。OSS侧事件流水号:event-id-20260511-09013。投诉详情:从5月11号早上8点半开始,核心交易系统访问非常慢,柜面和手机银行频繁报连接超时。", "expect": { "negotiation_needed": false, "missing_slots": [] @@ -54,36 +79,35 @@ { "id": "PLC-03", "channel": "fromData", - "intent": "结构化缺任务对象,触发协商", + "intent": "结构化缺任务对象,触发任务对象协商", "input_data": { "任务对象": "", - "任务上下文": "故障现象:专线中断,客户业务全部不通。问题发生时间:2026-06-02T14:30:00Z。OSS侧事件流水号:event-id-20260602-08841。" + "投诉分类": "专线中断", + "问题发生时间": "2026-06-02T14:30:00Z", + "OSS侧事件流水号": "event-id-20260602-08841", + "投诉详情": "客户业务全部不通,柜面交易无法办理。" }, "expect": { "negotiation_needed": true, - "missing_slots": [ - "任务对象" - ], + "missing_slots": ["任务对象"], "fill_completes": true } }, { "id": "PLC-04", "channel": "fromText", - "intent": "口语化缺专线标识,触发协商", - "input_text": "我们的专线出问题了,故障现象:专线中断,客户业务全部不通。问题发生时间:2026-06-02T14:30:00Z。OSS侧事件流水号:event-id-20260602-08841。", + "intent": "口语化缺专线标识(业务全断可推断投诉分类),触发任务对象协商", + "input_text": "我们的专线出问题了,业务全部都断了,完全用不了。问题发生时间:2026-06-02T14:30:00Z。OSS侧事件流水号:event-id-20260602-08841。", "expect": { "negotiation_needed": true, - "missing_slots": [ - "任务对象" - ], + "missing_slots": ["任务对象"], "fill_completes": true } }, { "id": "PLC-05", "channel": "fromText", - "intent": "自然语言两个槽全空(无对象无现象):required 槽在生成期即被拦截,任务无法生成,协商不触发", + "intent": "自然语言任务上下文为空(bundled required 槽在生成期即被拦截,任务无法生成,协商不触发)", "input_text": "帮我看一下专线的问题。", "expect": { "negotiation_needed": false, @@ -95,12 +119,10 @@ "id": "PLC-06", "channel": "fromText", "intent": "任务对象模糊(非端口/ID/名称形态),触发任务对象协商", - "input_text": "故障现象:专线质量差,网页打开缓慢,视频卡顿。故障发生时间:2026-06-18T09:00:00Z。", + "input_text": "故障现象:专线质量差,网页打开缓慢,视频卡顿。问题发生时间:2026-06-18T09:00:00Z。OSS侧事件流水号:event-id-20260618-07721。", "expect": { "negotiation_needed": true, - "missing_slots": [ - "任务对象" - ], + "missing_slots": ["任务对象"], "fill_completes": true } }, @@ -108,7 +130,7 @@ "id": "PLC-07", "channel": "fromText", "intent": "口语化完整请求,端口埋在长句里,不应触发协商", - "input_text": "东莞松山湖科技园客户报障说专线卡,涉及端口 P662-松山湖北-PTN9900-12-TPB3EG12-08,成天丢包,业务系统登录都费劲,昨天下午三点开始的,流水号 event-id-20260619-55210。", + "input_text": "就是珠江新城那边 PTN7900 网元上 P781-珠江新城-PTN7900-23-TPA1EG24-17 这个接入端口带的专线,专线质差,深圳访问广州的响应延迟从12ms涨到了300多毫秒,问题发生时间:2026-05-11T08:21:46Z,OSS侧事件流水号:event-id-20260511-09013。", "expect": { "negotiation_needed": false, "missing_slots": [] @@ -117,13 +139,11 @@ { "id": "PLC-08", "channel": "fromText", - "intent": "故障现象不明确(未指明中断或质差),触发任务上下文协商", - "input_text": "接入端口:P311-会展中心-ATN910C-05-TPA2EG8-03。专线好像有点问题,时好时坏,客户体验受影响。", + "intent": "自然语言未描述任何故障现象(投诉分类无从判定为中断或质差),触发投诉分类协商", + "input_text": "接入端口:P311-会展中心-ATN910C-05-TPA2EG8-03。OSS侧事件流水号:event-id-20260618-07721。", "expect": { "negotiation_needed": true, - "missing_slots": [ - "任务上下文" - ], + "missing_slots": ["投诉分类"], "fill_completes": true } }, @@ -131,7 +151,7 @@ "id": "PLC-09", "channel": "fromText", "intent": "端口误写在现象描述里(字段错位),抽取器应归位到任务对象,不触发协商", - "input_text": "故障现象:专线质量差。端口是 P120-南山智园-PTN7900-08-TPA1EG21-11,丢包严重。", + "input_text": "故障现象:专线中断,出现问题的接入端口是P781-珠江新城-PTN7900-23-TPA1EG24-17,业务完全不可用。问题发生时间:2026-06-02T14:30:00Z。OSS侧事件流水号:event-id-20260602-08841。", "expect": { "negotiation_needed": false, "missing_slots": [] @@ -140,21 +160,100 @@ { "id": "PLC-10", "channel": "fromData", - "intent": "负例:补充值端口格式乱写,二次校验应拒绝(测校验器对值约束的把关)", + "intent": "负例:补充值投诉分类乱写(不符合中断/质差取值约束),二次校验应拒绝(测校验器对值约束的把关)", "input_data": { - "任务对象": "", - "任务上下文": "故障现象:专线中断,客户业务全部不通。问题发生时间:2026-06-02T14:30:00Z。OSS侧事件流水号:event-id-20260602-08841。" + "任务对象": "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17", + "投诉分类": "", + "问题发生时间": "2026-06-02T14:30:00Z", + "OSS侧事件流水号": "event-id-20260602-08841", + "投诉详情": "客户业务全部不通,柜面交易无法办理。" }, "fill_override": { - "任务对象": "端口是坏的" + "投诉分类": "不知道是什么问题" }, "expect": { "negotiation_needed": true, - "missing_slots": [ - "任务对象" - ], + "missing_slots": ["投诉分类"], "fill_completes": false } + }, + { + "id": "PLC-11", + "channel": "fromData", + "intent": "结构化缺投诉分类(任务上下文子槽缺失),触发投诉分类协商", + "input_data": { + "任务对象": "接入端口名称:P311-会展中心-ATN910C-05-TPA2EG8-03", + "投诉分类": "", + "问题发生时间": "2026-06-18T09:00:00Z", + "OSS侧事件流水号": "event-id-20260618-07721", + "投诉详情": "网页打开缓慢,视频卡顿。" + }, + "expect": { + "negotiation_needed": true, + "missing_slots": ["投诉分类"], + "fill_completes": true + } + }, + { + "id": "PLC-12", + "channel": "fromData", + "intent": "结构化缺OSS侧事件流水号(任务上下文子槽缺失),触发流水号协商", + "input_data": { + "任务对象": "接入端口名称:P311-会展中心-ATN910C-05-TPA2EG8-03", + "投诉分类": "专线质差", + "问题发生时间": "2026-06-18T09:00:00Z", + "OSS侧事件流水号": "", + "投诉详情": "网页打开缓慢,视频卡顿。" + }, + "expect": { + "negotiation_needed": true, + "missing_slots": ["OSS侧事件流水号"], + "fill_completes": true + } + }, + { + "id": "PLC-13", + "channel": "fromData", + "intent": "可选槽缺失(问题发生时间/投诉详情为空),不应触发协商", + "input_data": { + "任务对象": "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17", + "投诉分类": "专线中断", + "问题发生时间": "", + "OSS侧事件流水号": "event-id-20260602-08841", + "投诉详情": "" + }, + "expect": { + "negotiation_needed": false, + "missing_slots": [] + } + }, + { + "id": "PLC-14", + "channel": "fromText", + "intent": "自然语言缺OSS侧事件流水号(任务上下文子槽缺失),触发流水号协商", + "input_text": "接入端口:P311-会展中心-ATN910C-05-TPA2EG8-03。故障现象:专线中断,业务完全不可用,柜面交易无法办理。问题发生时间:2026-06-18T09:00:00Z。", + "expect": { + "negotiation_needed": true, + "missing_slots": ["OSS侧事件流水号"], + "fill_completes": true + } + }, + { + "id": "PLC-15", + "channel": "fromData", + "intent": "结构化双槽缺失(任务对象+投诉分类),协商应同时请求两个槽", + "input_data": { + "任务对象": "", + "投诉分类": "", + "问题发生时间": "2026-06-18T09:00:00Z", + "OSS侧事件流水号": "event-id-20260618-07721", + "投诉详情": "客户业务全部不通。" + }, + "expect": { + "negotiation_needed": true, + "missing_slots": ["任务对象", "投诉分类"], + "fill_completes": true + } } ] } From 0d4f8c8a12565c65dd36040c21ba0a2166ac33d6 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Mon, 24 Aug 2026 16:18:49 +0800 Subject: [PATCH 159/295] docs(sample): add ready-to-copy eval env template to the evaluator guide Capture the practical env settings for running the evaluator with another model: the four LLM variables plus the raised timeout and max tokens that reasoning models need (180s / 8192 instead of the 60s / 2000 defaults), with a note that *.env files stay gitignored. Signed-off-by: ZhouJie6 --- a2a-t-sample/README.zh-CN.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 5a2e7029..5221a67f 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -168,6 +168,22 @@ java @a2a-t-sample/target/eval.javaargs.txt --out eval-report-my-model.json /pat **换用其他模型测试**:只需在 env 文件里改 4 个 LLM 变量(`A2AT_LLM_PROVIDER` / `A2AT_LLM_MODEL` / `A2AT_LLM_API_KEY` / `A2AT_LLM_BASE_URL`),无需改任何代码或用例。报告的 `llm` 字段会记录当次使用的 provider / model / base_url,`negotiation_channel` 字段记录当次通道(`per-case` 或强制值),便于横向对比多个模型的报告。 +**env 配置**:复制以下内容为 `eval.env`,填入你的模型信息即可(完整变量说明见根目录 `env.example`): + +```bash +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=classpath +A2AT_LLM_PROVIDER=openai # OpenAI 兼容端点均为 openai +A2AT_LLM_MODEL=glm-5.2 # 换成你的模型名 +A2AT_LLM_API_KEY=sk-xxxxxxxx # 你的 API key +A2AT_LLM_BASE_URL=https://your-endpoint/v1 # OpenAI 兼容 base URL +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory +A2AT_LLM_TIMEOUT_SECONDS=180 # 推理型模型建议调大(默认 60) +A2AT_LLM_MAX_TOKENS=8192 # 推理型模型建议调大(默认 2000) +``` + +`*.env` 已被 .gitignore 忽略,携带真实 key 的 env 文件不会被提交。 + 用例集:`sample/negotiation/eval/eval-suite.json`(15 条用例:fromData/fromText × 完整输入/任务对象缺失/投诉分类缺失/OSS流水号缺失/可选槽缺失/双槽缺失/负例补槽约束违反,含正反两类断言)。报告中每个 case 输出逐步证据(`api_calls`、生成 prompt 原文、校验判定与抽取参数、耗时),`metrics` 汇总通过率。 ## 授权策略(Authorization-T)演示 Demo From 6254dcb05e798a1b1cf1eda5a69285aeb5ffbc2e Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sun, 23 Aug 2026 14:07:36 +0800 Subject: [PATCH 160/295] test(negotiation): add data-driven corpus harness and full case corpus Implement the fromText/fromData/validate test corpus per docs-local/fromtext-validate-test-design.md (Q0-Q19 adjudicated): - net.openan.a2at.sdk.negotiation.testdata harness: strict-Jackson NegotiationCaseLoader ($ref/$fail script steps, cycle detection, fail-fast validation), ScriptedNegotiationLlmClient (failOnOverconsumption on), CaseEngine (12-API dispatch, inject hooks failingTemplateLoader/failingSemanticValidator, C+ differential, P0 contracts), ScenarioEngine (fromStep, per-role LLM clients, expectFlow) - negotiation-cases corpus: 146 case records + 18 scenarios (220 language-expanded units) across from-text/from-data/validate/ scenarios with shared llm-responses and schemas fixtures - golden abort fixtures (zh-CN/en-US) + GoldenCase.ABORT - CorpusContractTest (full-strict error-code/mapCode coverage) and CorpusSensitivitySelfTest (anti-rubber-stamp engine self-check) - Q8 absorption: 5 legacy scenario tests deleted, rule-gate MethodSource table removed, FromDataProgrammingErrorMatrixTest trimmed to the 8 corpus-inexpressible rows; log/reflection/golden guard suites kept Signed-off-by: codetalks-hw --- .../FromDataProgrammingErrorMatrixTest.java | 139 +-- .../generation/FromTextLlmPipelineTest.java | 25 +- .../TemplateUriEntryMatrixTest.java | 9 +- .../ValidateAndFillingDataPipelineTest.java | 34 +- .../golden/GoldenFixtureComparisonTest.java | 4 +- .../golden/GoldenFixtureGeneratorTest.java | 6 +- .../sdk/negotiation/golden/GoldenInputs.java | 19 +- .../BilateralInteropScenarioTest.java | 103 -- .../FeasibilityNegotiationScenarioTest.java | 135 --- .../InformationNegotiationScenarioTest.java | 116 --- .../MaxRoundsExhaustionScenarioTest.java | 88 -- .../scenario/ScriptedLlmClient.java | 57 -- .../scenario/SemanticResponses.java | 34 - .../TargetNegotiationScenarioTest.java | 155 --- .../sdk/negotiation/testdata/CaseEngine.java | 725 +++++++++++++ .../negotiation/testdata/CaseEngineTest.java | 528 ++++++++++ .../sdk/negotiation/testdata/ContextSpec.java | 24 + .../sdk/negotiation/testdata/Contract.java | 94 ++ .../testdata/CorpusContractTest.java | 396 +++++++ .../testdata/CorpusLoadException.java | 34 + .../testdata/CorpusSensitivitySelfTest.java | 323 ++++++ .../negotiation/testdata/CorpusSuites.java | 126 +++ .../sdk/negotiation/testdata/Expectation.java | 63 ++ .../testdata/FromDataCorpusSuiteTest.java | 26 + .../testdata/FromTextCorpusSuiteTest.java | 27 + .../negotiation/testdata/LlmFailMarker.java | 63 ++ .../sdk/negotiation/testdata/LlmScript.java | 20 + .../negotiation/testdata/LlmScriptStep.java | 27 + .../negotiation/testdata/LoadedCorpus.java | 42 + .../negotiation/testdata/NegotiationApi.java | 105 ++ .../negotiation/testdata/NegotiationCase.java | 72 ++ .../testdata/NegotiationCaseLoader.java | 962 ++++++++++++++++++ .../testdata/NegotiationCaseLoaderTest.java | 569 +++++++++++ .../negotiation/testdata/PromptSource.java | 33 + .../negotiation/testdata/ScenarioCase.java | 72 ++ .../testdata/ScenarioCorpusSuiteTest.java | 27 + .../negotiation/testdata/ScenarioEngine.java | 169 +++ .../testdata/ScenarioEngineTest.java | 294 ++++++ .../ScriptedNegotiationLlmClient.java | 175 ++++ .../ScriptedNegotiationLlmClientTest.java | 137 +++ .../testdata/TypedInputAssembler.java | 202 ++++ .../testdata/ValidateCorpusSuiteTest.java | 26 + .../src/test/resources/golden/en-US/abort.md | 5 + .../src/test/resources/golden/zh-CN/abort.md | 5 + .../negotiation-cases/corpus-schema.json | 573 +++++++++++ .../negotiation-cases/from-data/happy.json | 285 ++++++ .../from-data/programming-errors.json | 347 +++++++ .../from-text/extraction-failures.json | 272 +++++ .../negotiation-cases/from-text/happy.json | 345 +++++++ .../from-text/programming-errors.json | 145 +++ .../negotiation-cases/from-text/retry.json | 275 +++++ .../from-text/template-resolution.json | 78 ++ .../scenarios/boundary-flows.json | 753 ++++++++++++++ .../scenarios/feasibility-flows.json | 325 ++++++ .../scenarios/information-flows.json | 227 +++++ .../scenarios/target-flows.json | 242 +++++ .../shared/llm-responses.json | 34 + .../negotiation-cases/shared/schemas.json | 58 ++ .../validate/error-code-mapping.json | 104 ++ .../negotiation-cases/validate/happy.json | 245 +++++ .../validate/llm-shape-and-retry.json | 236 +++++ .../validate/merge-and-schema.json | 130 +++ .../validate/programming-errors.json | 321 ++++++ .../negotiation-cases/validate/rule-gate.json | 177 ++++ .../validate/semantic-verdicts.json | 374 +++++++ 65 files changed, 10982 insertions(+), 859 deletions(-) delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/ScriptedLlmClient.java delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/SemanticResponses.java delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngine.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngineTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ContextSpec.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Contract.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusContractTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusLoadException.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSensitivitySelfTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSuites.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Expectation.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/FromDataCorpusSuiteTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/FromTextCorpusSuiteTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmFailMarker.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmScript.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmScriptStep.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LoadedCorpus.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationApi.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCase.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoader.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoaderTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/PromptSource.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCase.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCorpusSuiteTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngine.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngineTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScriptedNegotiationLlmClient.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScriptedNegotiationLlmClientTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/TypedInputAssembler.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ValidateCorpusSuiteTest.java create mode 100644 a2a-t-negotiation/src/test/resources/golden/en-US/abort.md create mode 100644 a2a-t-negotiation/src/test/resources/golden/zh-CN/abort.md create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/corpus-schema.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/happy.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/programming-errors.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/extraction-failures.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/happy.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/programming-errors.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/retry.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/template-resolution.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/boundary-flows.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/information-flows.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/target-flows.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/shared/llm-responses.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/shared/schemas.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/validate/error-code-mapping.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/validate/happy.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/validate/merge-and-schema.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/validate/programming-errors.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/validate/rule-gate.json create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/validate/semantic-verdicts.json diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java index 49a5d57e..7db560d1 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java @@ -15,8 +15,6 @@ import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.core.model.NegotiationContext; @@ -28,12 +26,18 @@ import org.junit.jupiter.api.Test; /** - * Verifies the programming-error matrix of the from-data generation as one table-driven suite. + * Verifies the corpus-inexpressible rows of the programming-error matrix of the from-data generation (design §6 Q8). * - *

      Every row of the input-validation matrix of the from-data variants — method-data mismatch, template URIs that - * do not address a negotiation template of the expected phase, phase-conclusion mismatch, missing required fields and - * empty conditional content — fails with a standard {@link NullPointerException} (pure null arguments) or - * {@link IllegalArgumentException} (blank, range and semantic-contract violations) that is not part of the SDK + *

      The remaining rows of the matrix — null context, phase-conclusion mismatch, blank conditional fields of the + * ending family, empty evaluation/alternative lists and every template-URI phase violation — are absorbed by the + * {@code FD-PROG} batch of the test corpus ({@code negotiation-cases/from-data/programming-errors.json}), which + * asserts the same exception type, message fragment and zero-LLM guarantee per case. Only the rows the corpus cannot + * express stay here: null propose/ending data (a corpus record always carries {@code input.data}), a null conclusion + * and a missing feasibility action (the typed-input assembler rejects both as authoring defects), blank propose + * descriptions and ending summaries (the assembler demands non-blank text), and a template URI whose type contradicts + * the content type (the assembler derives the content type from the URI, so the contradiction cannot be authored). + * Every row fails with a standard {@link NullPointerException} (pure null arguments) or + * {@link IllegalArgumentException} (blank and semantic-contract violations) that is not part of the SDK * processing-error hierarchy and carries an English message pointing at the offending input. No row ever reaches the * LLM. Structural URI malformation is impossible by construction of {@link TemplateUri} and is therefore not a row. */ @@ -66,7 +70,7 @@ void everyMatrixRowFailsWithAStandardJavaException() { // The propose-versus-ending family mismatch of UT-GEN-002 is deliberately absent here: the typed data records // make that mismatch a compile-time error at the facade, so it cannot occur at runtime. - assertEquals(22, matrix.size(), "the full matrix must be exercised"); + assertEquals(8, matrix.size(), "exactly the corpus-inexpressible rows must stay (FD-PROG absorbs the rest)"); for (MatrixRow row : matrix) { RuntimeException failure = assertThrows( row.expectedException(), () -> row.call().get(), "row must fail: " + row.label()); @@ -87,7 +91,11 @@ void everyMatrixRowFailsWithAStandardJavaException() { assertEquals(0, llm.calls, "no matrix row may call the LLM"); } - /** Returns the full programming-error matrix of the from-data variants. */ + /** + * Returns the corpus-inexpressible rows of the programming-error matrix of the from-data variants (design §6 Q8: + * FD-PROG-01..14 absorb the other fourteen rows). See the class javadoc for why each row below cannot be expressed + * as a corpus record. + */ private List matrix() { NegotiationContext context = new NegotiationContext(UUID, 2, 5); return List.of( @@ -101,30 +109,6 @@ private List matrix() { () -> orchestrator.generateAcceptFromData(null, INFORMATION_ACCEPT_URI), NullPointerException.class, "Negotiation ending data must not be null."), - new MatrixRow( - "null context", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(null, informationProposeContent()), INFORMATION_PROPOSE_URI), - NullPointerException.class, - "Negotiation context must not be null."), - new MatrixRow( - "accept method with reject conclusion", - () -> orchestrator.generateAcceptFromData( - ending(NegotiationConclusion.REJECT), INFORMATION_ACCEPT_URI), - IllegalArgumentException.class, - "ACCEPT phase requires conclusion Accept but the content carries Reject"), - new MatrixRow( - "reject method with accept conclusion", - () -> orchestrator.generateRejectFromData( - ending(NegotiationConclusion.ACCEPT), INFORMATION_ACCEPT_URI), - IllegalArgumentException.class, - "REJECT phase requires conclusion Reject but the content carries Accept"), - new MatrixRow( - "accept method with abort conclusion", - () -> orchestrator.generateAcceptFromData( - ending(NegotiationConclusion.ABORT), INFORMATION_ACCEPT_URI), - IllegalArgumentException.class, - "the content carries Abort"), new MatrixRow( "accept method with null conclusion", () -> orchestrator.generateAcceptFromData( @@ -132,30 +116,6 @@ private List matrix() { TARGET_ACCEPT_URI), NullPointerException.class, "conclusion must not be null"), - new MatrixRow( - "target accept without confirmed intent", - () -> orchestrator.generateAcceptFromData( - new NegotiationEndingData( - context, new TargetEndingContent(NegotiationConclusion.ACCEPT, null, null)), - TARGET_ACCEPT_URI), - IllegalArgumentException.class, - "Confirmed intent of an accepting target negotiation message must not be blank."), - new MatrixRow( - "target accept with blank confirmed intent", - () -> orchestrator.generateAcceptFromData( - new NegotiationEndingData( - context, new TargetEndingContent(NegotiationConclusion.ACCEPT, " ", null)), - TARGET_ACCEPT_URI), - IllegalArgumentException.class, - "Confirmed intent of an accepting target negotiation message must not be blank."), - new MatrixRow( - "target reject without failure reason", - () -> orchestrator.generateRejectFromData( - new NegotiationEndingData( - context, new TargetEndingContent(NegotiationConclusion.REJECT, null, null)), - TARGET_ACCEPT_URI), - IllegalArgumentException.class, - "Failure reason of a rejecting target negotiation message must not be blank."), new MatrixRow( "target propose with blank description", () -> orchestrator.generateProposeFromData( @@ -186,26 +146,6 @@ context, new TargetEndingContent(NegotiationConclusion.REJECT, null, null)), FEASIBILITY_PROPOSE_URI), NullPointerException.class, "Feasibility negotiation action must not be null"), - new MatrixRow( - "evaluation request without contents to evaluate", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData( - context, - new FeasibilityProposeContent( - "请评估。", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null)), - FEASIBILITY_PROPOSE_URI), - IllegalArgumentException.class, - "must contain at least one item"), - new MatrixRow( - "alternative proposal without details", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData( - context, - new FeasibilityProposeContent( - "不可行。", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, null)), - FEASIBILITY_PROPOSE_URI), - IllegalArgumentException.class, - "must contain at least one item"), new MatrixRow( "feasibility ending with blank summary", () -> orchestrator.generateAcceptFromData( @@ -214,41 +154,6 @@ context, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " ")), FEASIBILITY_ACCEPT_URI), IllegalArgumentException.class, "must not be blank"), - new MatrixRow( - "template URI with wrong prefix", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - TemplateUri.of("Task-T", "information-negotiation", "propose")), - IllegalArgumentException.class, - "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), - new MatrixRow( - "template URI with wrong version", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v2")), - IllegalArgumentException.class, - "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), - new MatrixRow( - "template URI with underscore type segment", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - TemplateUri.of("Negotiation-T", "information_negotiation", "propose")), - IllegalArgumentException.class, - "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), - new MatrixRow( - "template URI with unknown type", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")), - IllegalArgumentException.class, - "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), - new MatrixRow( - "template URI phase contradicts the method", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, informationProposeContent()), - INFORMATION_ACCEPT_URI), - IllegalArgumentException.class, - "Template URI does not address a negotiation template of the expected phase PROPOSE (propose)"), new MatrixRow( "template URI type contradicts the content type", () -> orchestrator.generateProposeFromData( @@ -259,16 +164,6 @@ context, new TargetProposeContent("目标协商概述。", null, null, null)), "Negotiation type INFORMATION requires content of type InformationProposeContent")); } - private static InformationProposeContent informationProposeContent() { - return new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null); - } - - private static NegotiationEndingData ending(NegotiationConclusion conclusion) { - return new NegotiationEndingData( - new NegotiationContext(UUID, 2, 5), - new InformationEndingContent(conclusion, List.of(new NegotiationItem("区域", "松山湖")))); - } - private static boolean isAsciiText(String message) { return message.chars().allMatch(codePoint -> codePoint < 128); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 51870df7..1caba5a5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -30,6 +30,7 @@ import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; @@ -146,9 +147,10 @@ && logMessages(extractorAppender, Level.INFO).stream() } /** - * IT-B-002: every from-text method (propose, accept, reject) succeeds for every negotiation type with exactly one - * LLM call per generation, the caller-supplied template URI is echoed, the output matches the golden fixture and - * the phase token reaches the LLM so the ending phases can be distinguished. + * IT-B-002: every from-text method (propose, accept, reject, abort) succeeds for every negotiation type with + * exactly one LLM call per generation, the caller-supplied template URI is echoed, the output matches the golden + * fixture, the phase token reaches the LLM so the accept and reject phases can be distinguished, and the abort + * method addresses the termination-specific extraction prompt. */ @ParameterizedTest(name = "from-text succeeds for {0}") @EnumSource(GoldenCase.class) @@ -163,9 +165,15 @@ void fromTextSucceedsForEveryMethodAndTypeCombination(GoldenCase goldenCase) { assertEquals(goldenCase.templateUri(), result.templateUri()); assertEquals(readGoldenFixture(goldenCase, ZH_CN), result.promptText()); String userPrompt = llm.lastMessages.get(1).get("content"); - assertTrue( - userPrompt.contains(phaseToken(goldenCase.phase())), - "the user prompt must carry the phase token of the addressed method"); + if (goldenCase.phase() == NegotiationPhase.ABORT) { + assertTrue( + userPrompt.contains("终止"), + "the abort user prompt must address the termination content but was: " + userPrompt); + } else { + assertTrue( + userPrompt.contains(phaseToken(goldenCase.phase())), + "the user prompt must carry the phase token of the addressed method"); + } } /** @@ -565,7 +573,7 @@ private static MetadataContent generateFromText( case PROPOSE -> orchestrator.generateProposeFromText(text, goldenCase.context(), goldenCase.template()); case ACCEPT -> orchestrator.generateAcceptFromText(text, goldenCase.context(), goldenCase.template()); case REJECT -> orchestrator.generateRejectFromText(text, goldenCase.context(), goldenCase.template()); - case ABORT -> throw new IllegalArgumentException("The typed golden cases carry no abort phase."); + case ABORT -> orchestrator.generateAbortFromText(text, goldenCase.context(), goldenCase.template()); }; } @@ -664,6 +672,9 @@ private static String extractionJson(NegotiationContent content, NegotiationPhas + itemsJson(feasibility.infeasibilityDetailsAndProposal()) + "}"; } + if (content instanceof NegotiationAbortContent abort) { + return "{\"termination_reason\":" + quote(abort.terminationReason()) + "}"; + } if (content instanceof InformationEndingContent info) { return "{\"conclusion\":" + quote(info.conclusion().literal()) + ",\"items\":" + itemsJson(info.items()) + "}"; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index c2703c0c..99dd15fa 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -39,7 +39,8 @@ /** * Verifies the template-URI entry matrix of the from-data generation. * - *

      The six built-in URIs address their templates and render outputs identical to the golden fixtures; a well-formed + *

      The seven built-in URIs (six typed negotiation templates plus the common abort template) address their templates + * and render outputs identical to the golden fixtures; a well-formed * URI that resolves to no template fails with the code {@code template_not_found} before any LLM call; a typed URI * that does not address a negotiation template of the expected phase (wrong extension name, version, type segment, * phase segment or separator, including the underscore misspelling of the type segment) fails as a programming error @@ -57,8 +58,8 @@ class TemplateUriEntryMatrixTest { "templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md"; /** - * Entry (a): every one of the six built-in URIs reaches its template and renders output byte-identical to the - * golden fixtures locked by the comparison test. + * Entry (a): every one of the seven built-in URIs (six typed templates plus the common abort template) reaches its + * template and renders output byte-identical to the golden fixtures locked by the comparison test. */ @Test void everyBuiltInUriRendersItsGoldenFixture() { @@ -72,7 +73,7 @@ void everyBuiltInUriRendersItsGoldenFixture() { .computeIfAbsent(goldenCase.templateUri(), uri -> new ArrayList<>()) .add(goldenCase); } - assertEquals(6, casesByUri.size(), "the built-in template set has exactly six URIs"); + assertEquals(7, casesByUri.size(), "the built-in template set has exactly seven URIs"); for (Map.Entry> entry : casesByUri.entrySet()) { for (GoldenCase goldenCase : entry.getValue()) { MetadataContent result = goldenCase.generate(orchestrator); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java index fa04a639..5c25ed09 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java @@ -116,36 +116,10 @@ void legalRejectMessageRunsTheFullChainWithASingleLlmCall() { assertEquals(4, filled.data().size()); } - @ParameterizedTest - @MethodSource("ruleViolationCases") - void ruleViolationsFailBeforeAnyLlmCall(String caseName, NegotiationContext context, List expectedSlots) { - ScriptedLlmClient llm = new ScriptedLlmClient( - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - message, context, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, exception.getCode(), caseName); - assertEquals(expectedSlots.size(), exception.getErrors().size(), caseName); - for (int index = 0; index < expectedSlots.size(); index++) { - String slotName = exception.getErrors().get(index).slotName(); - assertEquals(expectedSlots.get(index), slotName, caseName); - assertTrue( - List.of("id", "round", "maxRounds").contains(slotName), - caseName + ": rule errors must only address context slots but was " + slotName); - } - assertEquals(0, llm.calls, caseName + ": the rule gate must run before any LLM call"); - } - - static List ruleViolationCases() { - return List.of( - new Object[] {"non-uuid id", new NegotiationContext("not-a-uuid", 1, 5), List.of("id")}, - new Object[] {"round above maxRounds", new NegotiationContext(SESSION_ID, 3, 2), List.of("round")}); - } + // The rule-violation table of this suite (non-uuid id, round above maxRounds, 0 LLM calls) is absorbed by the + // VAL-RULE batch of the test corpus (negotiation-cases/validate/rule-gate.json): every row there asserts the same + // negotiation_rule_violation code, the exact slot error pairs and the zero-LLM guarantee, plus the boundary and + // combined-error rows the hand-written table never carried (design §6 Q8). @Test void semanticRejectionIsNotRetriedAndPassesTheErrorsThrough() { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java index d94c4221..5f2dad1f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java @@ -145,8 +145,8 @@ void keepsTheStructuralInvariantsOfRenderedMessages(GoldenCase goldenCase, Strin } /** - * Proves that the from-data variants never touch the LLM: all nine type/phase combinations of both languages run - * against a counting LLM client that would record every call. + * Proves that the from-data variants never touch the LLM: all ten type/phase combinations (including the common + * abort fixture) of both languages run against a counting LLM client that would record every call. */ @ParameterizedTest(name = "from-data generation never calls the LLM [{0}]") @ValueSource(strings = {GoldenInputs.ZH_CN, GoldenInputs.EN_US}) diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java index b9aea53a..6a7b7652 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java @@ -13,9 +13,9 @@ /** * Regeneration helper for the golden fixture set of the negotiation content layer. * - *

      The test writes the 18 golden fixture files (9 type/phase combinations x 2 languages) under - * {@code src/test/resources/golden/} by rendering the fixed {@link GoldenInputs} through an orchestrator wired with the - * real built-in resources. It is disabled in normal builds: golden fixtures are locked by + *

      The test writes the 20 golden fixture files (9 type/phase combinations plus the common abort fixture, x 2 + * languages) under {@code src/test/resources/golden/} by rendering the fixed {@link GoldenInputs} through an + * orchestrator wired with the real built-in resources. It is disabled in normal builds: golden fixtures are locked by * {@link GoldenFixtureComparisonTest} and may only change together with a reviewed template or vocabulary revision. To * regenerate, temporarily enable this test, run it from the {@code a2a-t-negotiation} module directory and review the * resulting files before committing them. diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java index cbe392f4..bb8abb2f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java @@ -8,6 +8,8 @@ import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; @@ -33,8 +35,9 @@ *

      The fixture data deliberately exercises the known rendering pitfalls: a non-null relationship with an appended * line and a null-value item (information propose), the round-driven conditional sections with a non-empty * clarification list (target propose, round 1), the action-driven conditional section (feasibility propose, evaluation - * request action), the accept and reject conclusion literals, and the feasibility summary rendered into the - * vocabulary-exception slot of the feasibility result confirmation section. + * request action), the accept and reject conclusion literals, the feasibility summary rendered into the + * vocabulary-exception slot of the feasibility result confirmation section, and the common abort template rendering + * the termination reason. */ public final class GoldenInputs { @@ -211,6 +214,15 @@ public NegotiationContent content() { "The energy-saving target cannot be achieved under the existing power supply constraint;" + " this negotiation is confirmed as concluded."); } + }, + + /** Common abort fixture terminating the negotiation at the round limit. */ + ABORT(NegotiationPhase.ABORT, "common", "abort") { + @Override + public NegotiationContent content() { + return new NegotiationAbortContent( + "The negotiation round limit is reached; this negotiation is confirmed as terminated."); + } }; private final NegotiationPhase phase; @@ -302,7 +314,8 @@ public MetadataContent generate(NegotiationGenerationOrchestrator orchestrator) new NegotiationEndingData(context(), (NegotiationEndingContent) content()), template()); case REJECT -> orchestrator.generateRejectFromData( new NegotiationEndingData(context(), (NegotiationEndingContent) content()), template()); - case ABORT -> throw new IllegalArgumentException("The typed golden fixtures carry no abort phase."); + case ABORT -> orchestrator.generateAbortFromData( + new NegotiationAbortData(context(), (NegotiationAbortContent) content()), template()); }; } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java deleted file mode 100644 index cc3843dc..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/BilateralInteropScenarioTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package net.openan.a2at.sdk.negotiation.scenario; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Test; - -/** - * Walks the bilateral interop loop between two independently assembled negotiation pipelines: one side generates from - * data and from text (the text variant must render exactly the data variant of the same extracted content), the other - * side validates the received message, and the roles are then reversed for the terminal answer. Both sides answer with - * equal metadata content and equal filled parameters for the same inputs. - */ -class BilateralInteropScenarioTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - - private static final String EXTRACTION_RESPONSE = - "{\"items\":[{\"name\":\"节能区域信息\",\"value\":\"松山湖\"}],\"relationship\":null}"; - - @Test - void bothSidesGenerateAndValidateEachOthersMessages() { - ScriptedLlmClient initiatingSideLlm = new ScriptedLlmClient( - EXTRACTION_RESPONSE, SemanticResponses.acceptance("information", "{\"region\":\"松山湖\"}")); - ScriptedLlmClient respondingSideLlm = - new ScriptedLlmClient(SemanticResponses.acceptance("information", "{\"region\":\"松山湖\"}")); - NegotiationGenerationOrchestrator initiatingSide = orchestrator(initiatingSideLlm); - NegotiationGenerationOrchestrator respondingSide = orchestrator(respondingSideLlm); - NegotiationContext round = new NegotiationContext(SESSION_ID, 1, 5); - - MetadataContent fromData = initiatingSide.generateProposeFromData( - new NegotiationProposeData( - round, new InformationProposeContent(List.of(new NegotiationItem("节能区域信息", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - MetadataContent fromText = - initiatingSide.generateProposeFromText("请提供节能区域信息:松山湖。", round, INFORMATION_PROPOSE_URI); - assertEquals( - fromData.promptText(), - fromText.promptText(), - "the from-text variant must render exactly the from-data variant of the same extracted content"); - assertEquals(fromData.templateUri(), fromText.templateUri()); - assertEquals(1, initiatingSideLlm.callCount(), "the from-text generation performs exactly one extraction call"); - - FilledParamData respondingSideParameters = respondingSide.validateProposePromptAndDataFilling( - fromData.promptText(), round, parameterSchema(), INFORMATION_PROPOSE_URI); - FilledParamData initiatingSideParameters = initiatingSide.validateProposePromptAndDataFilling( - fromData.promptText(), round, parameterSchema(), INFORMATION_PROPOSE_URI); - assertEquals( - respondingSideParameters, - initiatingSideParameters, - "both sides must extract the same parameters from the same message"); - assertEquals(SESSION_ID, respondingSideParameters.data().get("id")); - assertEquals("松山湖", respondingSideParameters.data().get("region")); - - NegotiationEndingData answer = new NegotiationEndingData( - round, - new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("节能区域信息", "松山湖")))); - MetadataContent respondingSideAnswer = respondingSide.generateAcceptFromData(answer, INFORMATION_ACCEPT_URI); - MetadataContent initiatingSideAnswer = initiatingSide.generateAcceptFromData(answer, INFORMATION_ACCEPT_URI); - assertEquals( - respondingSideAnswer, - initiatingSideAnswer, - "both sides must generate the identical message for the same typed input"); - assertEquals(respondingSideAnswer.buildMetadataContent(), initiatingSideAnswer.buildMetadataContent()); - assertTrue(respondingSideAnswer.promptText().contains("## 信息协商结果\nAccept")); - - FilledParamData validatedAnswer = initiatingSide.validateAcceptPromptAndDataFilling( - respondingSideAnswer.promptText(), round, parameterSchema(), INFORMATION_ACCEPT_URI); - assertEquals(SESSION_ID, validatedAnswer.data().get("id")); - assertEquals(1, validatedAnswer.data().get("round")); - assertEquals("松山湖", validatedAnswer.data().get("region")); - } - - private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static Map parameterSchema() { - return Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))); - } -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java deleted file mode 100644 index 02f718e5..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/FeasibilityNegotiationScenarioTest.java +++ /dev/null @@ -1,135 +0,0 @@ -package net.openan.a2at.sdk.negotiation.scenario; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.golden.GoldenInputs; -import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; -import org.junit.jupiter.api.Test; - -/** - * Walks the feasibility negotiation scenario: the responder's semantic validation rejects an evaluation request that - * conflicts with an existing constraint (the trigger of the feasibility negotiation), the requester proposes an - * alternative for the infeasible case, and the responder accepts with a summary that fills the exception slot of the - * feasibility result confirmation section. The evaluation request and the terminal answer are locked against the golden - * fixtures. - */ -class FeasibilityNegotiationScenarioTest { - - private static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; - - private static final TemplateUri FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; - - @Test - void feasibilityNegotiationRunsFromSemanticRejectionToATerminalAcceptance() throws IOException { - String conflictErrors = "[{\"slot_name\":\"section.feasibility_evaluate\",\"code\":\"semantic_inconsistency\"," - + "\"message\":\"The adjusted rate target conflicts with the power supply duration constraint.\"}," - + "{\"slot_name\":\"section.feasibility\",\"code\":\"semantic_unclear\"," - + "\"message\":\"The request does not state the constraint context of the evaluation.\"}]"; - ScriptedLlmClient requesterLlm = - new ScriptedLlmClient(SemanticResponses.acceptance("feasibility", "{\"feasibilitySummary\":\"可行\"}")); - ScriptedLlmClient responderLlm = new ScriptedLlmClient(SemanticResponses.rejection(conflictErrors)); - NegotiationGenerationOrchestrator requester = orchestrator(requesterLlm); - NegotiationGenerationOrchestrator responder = orchestrator(responderLlm); - - MetadataContent evaluationRequest = GoldenCase.FEASIBILITY_PROPOSE.generate(requester); - assertEquals( - readGoldenFixture(GoldenCase.FEASIBILITY_PROPOSE, GoldenInputs.ZH_CN), - evaluationRequest.promptText(), - "the evaluation request must match the golden fixture byte for byte"); - - NegotiationParamExtractionException rejection = assertThrows( - NegotiationParamExtractionException.class, - () -> responder.validateProposePromptAndDataFilling( - evaluationRequest.promptText(), - GoldenCase.FEASIBILITY_PROPOSE.context(), - parameterSchema(), - FEASIBILITY_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, rejection.getCode()); - assertEquals(1, responderLlm.callCount(), "a semantic rejection must not be retried"); - List errors = rejection.getErrors(); - assertEquals(2, errors.size()); - assertTrue(errors.stream().anyMatch(error -> "section.feasibility_evaluate".equals(error.slotName()))); - assertTrue(errors.stream().anyMatch(error -> "section.feasibility".equals(error.slotName()))); - assertTrue( - errors.stream().allMatch(error -> error.code() != null && error.message() != null), - "every error detail must carry a code and a message so the caller can react per field"); - - MetadataContent alternativeProposal = requester.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(GoldenInputs.SESSION_ID, 2, 5), - new FeasibilityProposeContent( - "目标与停电约束冲突,替代方案详见<评估不可行时的详情和提案>,请评估", - NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, - null, - List.of(new NegotiationItem("替代方案", "速率保障目标下调至2Mbps,节能时段缩短为4小时")))), - FEASIBILITY_PROPOSE_URI); - assertTrue( - alternativeProposal.promptText().contains("## 评估不可行时的详情和提案"), - "the alternative action must render the infeasibility section"); - assertTrue( - !alternativeProposal.promptText().contains("## 待评估内容说明"), - "the alternative action must not render the evaluation section"); - - MetadataContent acceptance = responder.generateAcceptFromData( - new NegotiationEndingData( - GoldenInputs.defaultContext(), - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "同意将速率保障目标由5Mbps下调至2Mbps,本次协商确认结束")), - FEASIBILITY_ACCEPT_URI); - assertTrue(acceptance.promptText().contains("## 可行性协商结果\nAccept")); - assertTrue( - acceptance.promptText().contains("## 可行性评估结果确认\n同意将速率保障目标由5Mbps下调至2Mbps,本次协商确认结束"), - "the summary must fill the exception slot of the feasibility result confirmation section"); - - FilledParamData terminalParameters = requester.validateAcceptPromptAndDataFilling( - acceptance.promptText(), GoldenInputs.defaultContext(), parameterSchema(), FEASIBILITY_ACCEPT_URI); - assertEquals(1, requesterLlm.callCount()); - assertEquals(GoldenInputs.SESSION_ID, terminalParameters.data().get("id")); - assertEquals(2, terminalParameters.data().get("round")); - } - - private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static Map parameterSchema() { - return Map.of("type", "object", "properties", Map.of("feasibilitySummary", Map.of("type", "string"))); - } - - private static String readGoldenFixture(GoldenCase goldenCase, String language) { - String resourcePath = goldenCase.goldenResourcePath(language); - InputStream stream = FeasibilityNegotiationScenarioTest.class.getResourceAsStream(resourcePath); - assertTrue(stream != null, "Golden fixture must exist on the test classpath: " + resourcePath); - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } catch (IOException exception) { - throw new AssertionError("Failed to read golden fixture " + resourcePath, exception); - } - } -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java deleted file mode 100644 index 6cbcda1f..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/InformationNegotiationScenarioTest.java +++ /dev/null @@ -1,116 +0,0 @@ -package net.openan.a2at.sdk.negotiation.scenario; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Test; - -/** - * Walks the information negotiation scenario: a missing-information task is rejected as not negotiable, the requesting - * agent asks for the missing items, the answering agent validates the request, delivers the information and accepts, - * and the requester validates the terminal answer — all within one shared round. - */ -class InformationNegotiationScenarioTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - - @Test - void informationNegotiationReachesTheTerminalStateWithinOneRound() { - ScriptedLlmClient requesterLlm = - new ScriptedLlmClient(SemanticResponses.acceptance("information", "{\"region\":\"松山湖\"}")); - ScriptedLlmClient responderLlm = - new ScriptedLlmClient(SemanticResponses.acceptance("information", "{\"region\":\"松山湖\"}")); - NegotiationGenerationOrchestrator requester = orchestrator(requesterLlm); - NegotiationGenerationOrchestrator responder = orchestrator(responderLlm); - - NegotiationParamExtractionException notNegotiable = assertThrows( - NegotiationParamExtractionException.class, - () -> responder.validateProposePromptAndDataFilling( - "A task prompt describing an energy-saving optimization without any negotiation section.", - null, - parameterSchema(), - INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, notNegotiable.getCode()); - assertEquals( - 0, - responderLlm.callCount(), - "a text without a negotiation context section must fail before any LLM call"); - - NegotiationContext round = new NegotiationContext(SESSION_ID, 1, 5); - MetadataContent request = requester.generateProposeFromData( - new NegotiationProposeData( - round, - new InformationProposeContent( - List.of( - new NegotiationItem("节能区域信息", "如松山湖"), - new NegotiationItem("节能速率保障目标", "如20Mbps")), - null)), - INFORMATION_PROPOSE_URI); - assertTrue(request.promptText().contains("## 所需信息项")); - assertTrue(request.promptText().contains("1. 节能区域信息:如松山湖")); - assertEquals(1, request.negotiationContext().round()); - - FilledParamData requestParameters = responder.validateProposePromptAndDataFilling( - request.promptText(), round, parameterSchema(), INFORMATION_PROPOSE_URI); - assertEquals(1, responderLlm.callCount()); - assertEquals(SESSION_ID, requestParameters.data().get("id")); - assertEquals(1, requestParameters.data().get("round")); - assertEquals(5, requestParameters.data().get("maxRounds")); - assertEquals("松山湖", requestParameters.data().get("region")); - - MetadataContent answer = responder.generateAcceptFromData( - new NegotiationEndingData( - round, - new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of( - new NegotiationItem("节能区域信息", "松山湖"), - new NegotiationItem("节能速率保障目标", "20Mbps")))), - INFORMATION_ACCEPT_URI); - assertTrue(answer.promptText().contains("## 信息协商结果\nAccept")); - assertTrue(answer.promptText().contains("## 信息协商结果内容")); - assertTrue(answer.promptText().contains("1. 节能区域信息:松山湖")); - assertEquals(1, answer.negotiationContext().round(), "the terminal answer shares the round of the request"); - - FilledParamData answerParameters = - requester.validateAcceptPromptAndDataFilling( - answer.promptText(), round, parameterSchema(), INFORMATION_ACCEPT_URI); - assertEquals(1, requesterLlm.callCount()); - assertEquals(SESSION_ID, answerParameters.data().get("id")); - assertEquals(1, answerParameters.data().get("round")); - assertEquals("松山湖", answerParameters.data().get("region")); - } - - private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static Map parameterSchema() { - return Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))); - } -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java deleted file mode 100644 index ff0d6996..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/MaxRoundsExhaustionScenarioTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package net.openan.a2at.sdk.negotiation.scenario; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Test; - -/** - * Walks the round-budget exhaustion scenario: the last allowed round is not yet exhausted, advancing past it marks the - * context exhausted, and the negotiation ends with an abort message at the last allowed round that the peer still - * validates — while a message of a round beyond the budget fails the peer's rule gate, so no further round can ever be - * exchanged. - */ -class MaxRoundsExhaustionScenarioTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - @Test - void exhaustedRoundBudgetEndsWithAnAbortMessageThePeerStillAccepts() { - ScriptedLlmClient peerLlm = new ScriptedLlmClient(SemanticResponses.acceptance("target", "{}")); - NegotiationGenerationOrchestrator agent = orchestrator(null); - NegotiationGenerationOrchestrator peer = orchestrator(peerLlm); - - NegotiationContext lastRound = new NegotiationContext(SESSION_ID, 5, 5); - assertFalse(lastRound.isExhausted(), "round == maxRounds is the last usable round, not an exhausted one"); - NegotiationContext beyondBudget = lastRound.nextRound(); - assertEquals(6, beyondBudget.round()); - assertTrue(beyondBudget.isExhausted(), "round > maxRounds marks the context exhausted"); - assertEquals(5, lastRound.round(), "nextRound must leave the original context unchanged"); - - MetadataContent terminal = agent.generateAbortFromData( - new NegotiationAbortData( - lastRound, new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - StandardTemplates.NEGOTIATION_ABORT); - assertTrue(terminal.promptText().contains("## 协商结果\nAbort")); - assertTrue(terminal.promptText().contains("## 协商终止原因")); - assertTrue(terminal.promptText().contains("达到协商轮次上限,本次协商确认结束。")); - assertEquals(5, terminal.negotiationContext().round()); - - FilledParamData terminalParameters = peer.validateAbortPromptAndDataFilling( - terminal.promptText(), lastRound, parameterSchema(), StandardTemplates.NEGOTIATION_ABORT); - assertEquals(1, peerLlm.callCount()); - assertEquals(SESSION_ID, terminalParameters.data().get("id")); - assertEquals(5, terminalParameters.data().get("round")); - assertEquals(5, terminalParameters.data().get("maxRounds")); - - MetadataContent beyondBudgetMessage = agent.generateAbortFromData( - new NegotiationAbortData(beyondBudget, new NegotiationAbortContent("This message exceeds the round budget.")), - StandardTemplates.NEGOTIATION_ABORT); - NegotiationParamExtractionException ruleFailure = assertThrows( - NegotiationParamExtractionException.class, - () -> peer.validateAbortPromptAndDataFilling( - beyondBudgetMessage.promptText(), - beyondBudget, - parameterSchema(), - StandardTemplates.NEGOTIATION_ABORT)); - assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, ruleFailure.getCode()); - assertTrue( - ruleFailure.getErrors().stream().anyMatch(error -> "round".equals(error.slotName())), - "the beyond-budget round must be reported on the round slot"); - assertEquals(1, peerLlm.callCount(), "the rule gate must fail before any semantic LLM call"); - } - - private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static Map parameterSchema() { - return Map.of("type", "object"); - } -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/ScriptedLlmClient.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/ScriptedLlmClient.java deleted file mode 100644 index 5d22a4a6..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/ScriptedLlmClient.java +++ /dev/null @@ -1,57 +0,0 @@ -package net.openan.a2at.sdk.negotiation.scenario; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; - -/** - * Scripted LLM client double of the negotiation scenario tests. - * - *

      The client replays an ordered list of JSON response payloads — the last payload repeats once the list is exhausted - * — and records the messages of every call so the tests can assert the call counts and the prompt content of each LLM - * step. No real network access happens. - */ -final class ScriptedLlmClient implements LLMClient { - - private final List payloads; - - private final List>> recordedMessages = new ArrayList<>(); - - ScriptedLlmClient(String... payloads) { - if (payloads.length == 0) { - throw new IllegalArgumentException("At least one scripted payload is required."); - } - this.payloads = List.of(payloads); - } - - @Override - public LLMResponse structured( - List> messages, Map jsonSchema, Double temperature, Integer maxTokens) { - recordedMessages.add(List.copyOf(messages)); - String payload = payloads.get(Math.min(recordedMessages.size() - 1, payloads.size() - 1)); - return new LLMResponse( - payload, "scripted-scenario-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - - int callCount() { - return recordedMessages.size(); - } - - String systemContentOfCall(int callIndex) { - return roleContentOfCall(callIndex, "system"); - } - - String userContentOfCall(int callIndex) { - return roleContentOfCall(callIndex, "user"); - } - - private String roleContentOfCall(int callIndex, String role) { - return recordedMessages.get(callIndex).stream() - .filter(message -> role.equals(message.get("role"))) - .map(message -> message.get("content")) - .findFirst() - .orElseThrow(); - } -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/SemanticResponses.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/SemanticResponses.java deleted file mode 100644 index 9ba7aa5d..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/SemanticResponses.java +++ /dev/null @@ -1,34 +0,0 @@ -package net.openan.a2at.sdk.negotiation.scenario; - -/** - * Builders of the scripted semantic validation responses of the negotiation scenario tests. - * - *

      Every response satisfies the four-key output contract of the semantic validation step: {@code semantic_verdict}, - * {@code negotiation_type}, {@code errors} and {@code params}. - */ -final class SemanticResponses { - - private SemanticResponses() {} - - /** - * Builds a positive semantic validation response of one negotiation type. - * - * @param negotiationType negotiation type implied by the message, such as {@code information} - * @param paramsJson JSON object literal carried as the extracted parameters - * @return JSON response text - */ - static String acceptance(String negotiationType, String paramsJson) { - return "{\"semantic_verdict\":true,\"negotiation_type\":\"" + negotiationType + "\",\"errors\":[],\"params\":" - + paramsJson + "}"; - } - - /** - * Builds a negative semantic validation response carrying structured error details. - * - * @param errorsJson JSON array literal of error objects with slot_name, code and message - * @return JSON response text - */ - static String rejection(String errorsJson) { - return "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":" + errorsJson + ",\"params\":{}}"; - } -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java deleted file mode 100644 index e5351e63..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/scenario/TargetNegotiationScenarioTest.java +++ /dev/null @@ -1,155 +0,0 @@ -package net.openan.a2at.sdk.negotiation.scenario; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Test; - -/** - * Walks the target negotiation scenario across two rounds: the first-round proposal carries the intent understanding - * and an open clarification request, the responder rejects it, the requester advances to the second round immutably and - * switches to the alignment section, and the responder finally accepts with the confirmed intent. The responder also - * extracts a nested time-rate target list from the first-round proposal. - */ -class TargetNegotiationScenarioTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; - - private static final TemplateUri TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; - - @Test - void targetNegotiationSwitchesConditionalSectionsAcrossRounds() { - ScriptedLlmClient requesterLlm = new ScriptedLlmClient( - SemanticResponses.acceptance("target", "{}"), - SemanticResponses.acceptance("target", "{\"confirmedIntent\":\"08:00-18:00对松山湖站点启用无线节能优化\"}")); - ScriptedLlmClient responderLlm = new ScriptedLlmClient(SemanticResponses.acceptance( - "target", - "{\"timeRateTargets\":[{\"time\":\"08:00-18:00\",\"rate\":\"20Mbps\"}," - + "{\"time\":\"00:00-06:00\",\"rate\":\"5Mbps\"}]}")); - NegotiationGenerationOrchestrator requester = orchestrator(requesterLlm); - NegotiationGenerationOrchestrator responder = orchestrator(responderLlm); - - NegotiationContext firstRound = new NegotiationContext(SESSION_ID, 1, 5); - MetadataContent firstProposal = requester.generateProposeFromData( - new NegotiationProposeData( - firstRound, - new TargetProposeContent( - "对无线节能优化任务的意图理解参见<意图理解陈述>,对节能区域存在疑问,详见<待澄清内容>,请澄清和确认", - List.of(new NegotiationItem("任务意图", "08:00-18:00对目标站点启用无线节能优化")), - null, - List.of(new NegotiationItem("节能区域", "松山湖还是其他站点")))), - TARGET_PROPOSE_URI); - assertTrue( - firstProposal.promptText().contains("## 意图理解陈述"), - "round 1 must carry the intent understanding section"); - assertTrue( - !firstProposal.promptText().contains("## 理解对齐与疑问澄清"), "round 1 must not carry the alignment section"); - assertTrue(firstProposal.promptText().contains("## 待澄清内容")); - assertEquals(1, firstProposal.negotiationContext().round()); - - FilledParamData firstRoundParameters = responder.validateProposePromptAndDataFilling( - firstProposal.promptText(), firstRound, nestedParameterSchema(), TARGET_PROPOSE_URI); - assertEquals(1, responderLlm.callCount()); - Object timeRateTargets = firstRoundParameters.data().get("timeRateTargets"); - assertTrue(timeRateTargets instanceof List, "the nested parameter list must pass through"); - List targetList = (List) timeRateTargets; - assertEquals(2, targetList.size()); - assertTrue(targetList.get(0) instanceof Map); - assertEquals("08:00-18:00", ((Map) targetList.get(0)).get("time")); - assertEquals("20Mbps", ((Map) targetList.get(0)).get("rate")); - - MetadataContent rejection = responder.generateRejectFromData( - new NegotiationEndingData( - firstRound, - new TargetEndingContent(NegotiationConclusion.REJECT, null, "节能区域信息因站点清单不可用而无法完整澄清")), - TARGET_ACCEPT_URI); - assertTrue(rejection.promptText().contains("## 目标协商结果\nReject")); - assertTrue(rejection.promptText().contains("节能区域信息因站点清单不可用而无法完整澄清")); - assertEquals(1, rejection.negotiationContext().round()); - - requester.validateRejectPromptAndDataFilling( - rejection.promptText(), firstRound, parameterSchema(), TARGET_ACCEPT_URI); - assertEquals(1, requesterLlm.callCount()); - - NegotiationContext secondRound = firstRound.nextRound(); - assertEquals(2, secondRound.round()); - assertEquals(1, firstRound.round(), "nextRound must leave the original context unchanged"); - - MetadataContent secondProposal = requester.generateProposeFromData( - new NegotiationProposeData( - secondRound, - new TargetProposeContent( - "已针对节能区域提供澄清,详见<理解对齐与疑问澄清>,请确认", - null, - List.of(new NegotiationItem("节能区域", "confirmed(认同):松山湖")), - null)), - TARGET_PROPOSE_URI); - assertTrue(secondProposal.promptText().contains("## 理解对齐与疑问澄清"), "round 2 must carry the alignment section"); - assertTrue( - !secondProposal.promptText().contains("## 意图理解陈述"), - "round 2 must not carry the intent understanding section"); - assertTrue( - !secondProposal.promptText().contains("## 待澄清内容"), - "an empty clarification list must drop the whole section"); - assertEquals(2, secondProposal.negotiationContext().round()); - - MetadataContent acceptance = responder.generateAcceptFromData( - new NegotiationEndingData( - secondRound, - new TargetEndingContent( - NegotiationConclusion.ACCEPT, "最终确认意图:08:00-18:00对松山湖站点启用无线节能优化,速率保障不低于20Mbps", null)), - TARGET_ACCEPT_URI); - assertTrue(acceptance.promptText().contains("## 目标协商结果\nAccept")); - assertTrue(acceptance.promptText().contains("最终确认意图:08:00-18:00对松山湖站点启用无线节能优化")); - assertEquals(2, acceptance.negotiationContext().round()); - - FilledParamData terminalParameters = requester.validateAcceptPromptAndDataFilling( - acceptance.promptText(), secondRound, parameterSchema(), TARGET_ACCEPT_URI); - assertEquals(2, requesterLlm.callCount()); - assertEquals(SESSION_ID, terminalParameters.data().get("id")); - assertEquals(2, terminalParameters.data().get("round")); - assertEquals("08:00-18:00对松山湖站点启用无线节能优化", terminalParameters.data().get("confirmedIntent")); - assertEquals(1, firstRound.round(), "the original context must stay unchanged until the end"); - } - - private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static Map parameterSchema() { - return Map.of("type", "object", "properties", Map.of("confirmedIntent", Map.of("type", "string"))); - } - - private static Map nestedParameterSchema() { - Map targetItem = Map.of( - "type", - "object", - "properties", - Map.of("time", Map.of("type", "string"), "rate", Map.of("type", "string"))); - return Map.of( - "type", - "object", - "properties", - Map.of("timeRateTargets", Map.of("type", "array", "items", targetItem))); - } -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngine.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngine.java new file mode 100644 index 00000000..518e7a22 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngine.java @@ -0,0 +1,725 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; +import org.jspecify.annotations.Nullable; + +/** + * Executes one expanded corpus case against the production negotiation content wiring and asserts its expectation + * block. + * + *

      Everything except the LLM client is production assembly: the case engine builds a real + * {@link NegotiationGenerationOrchestrator} through {@link NegotiationGenerationOrchestratorBuilder} for the language of + * the case, wires the {@code inject} hooks onto their real builder injection points ({@code failingTemplateLoader} for + * the generation template-not-found matrix, {@code failingSemanticValidator} for the validate-family + * prompt-resource-not-found mapping) and dispatches on the {@link NegotiationApi} enum at compile time, so a misspelled API + * name fails at corpus load time and a renamed service method fails this compilation. + * + *

      Result normalization: a success run returns the produced {@link MetadataContent} or {@link FilledParamData}, a + * failure run captures the thrown exception. The expectation comparison covers outcome, exception name, error code, + * message fragments, slot errors (slot plus code pairs, exact but order-insensitive), the exact LLM call count, the + * golden fixture text (per language, byte equality after CRLF normalization), the metadata echoes, the expected merged + * parameter map, the P0 behavior contracts and the Q17 C+ differential double run + * (fromText == fromData == golden, from-data side proven zero-call with an assertion-only client). Every mismatch fails + * with the case id, the JSON path of the expectation and the expected-versus-actual pair. + * + * @since 2026-08 + */ +public final class CaseEngine { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String INJECT_FAILING_TEMPLATE_LOADER = "failingTemplateLoader"; + + private static final String INJECT_FAILING_SEMANTIC_VALIDATOR = "failingSemanticValidator"; + + private static final int MAX_REPORTED_TEXT_LENGTH = 400; + + /** + * Runs one case without a prompt override. + * + * @param testCase expanded corpus case + * @return the normalized outcome of the run + * @throws AssertionError when any expectation mismatches + */ + public CaseOutcome run(NegotiationCase testCase) { + return run(testCase, null); + } + + /** + * Runs one case, optionally overriding the prompt input of the validate family. + * + *

      The {@link ScenarioEngine} resolves {@code prompt.fromStep} references into prompt texts and hands them in + * through this parameter; a standalone validate case resolves its golden or inline prompt itself. + * + * @param testCase expanded corpus case + * @param promptOverride prompt text resolved from an earlier scenario step, or null + * @return the normalized outcome of the run + * @throws AssertionError when any expectation mismatches + */ + public CaseOutcome run(NegotiationCase testCase, @Nullable String promptOverride) { + ScriptedNegotiationLlmClient llmClient = llmClientFor(testCase); + NegotiationContentService service = new NegotiationContentService(orchestratorFor(testCase, llmClient)); + Object value = null; + Throwable failure = null; + try { + value = invoke(service, testCase, promptOverride); + } catch (RuntimeException | AssertionError error) { + failure = error; + } + CaseOutcome outcome = new CaseOutcome(testCase, value, failure, llmClient.callCount(), llmClient); + assertExpectations(outcome); + return outcome; + } + + // ------------------------------------------------------------------ outcome record + + /** + * The normalized outcome of one executed case. + * + * @param testCase the executed expanded corpus case + * @param value the returned {@code MetadataContent} or {@code FilledParamData} on success, or null on failure + * @param failure the captured exception on failure, or null on success + * @param llmCalls exact number of LLM calls the run made + * @param llmClient the scripted client the run used + */ + public record CaseOutcome( + NegotiationCase testCase, + @Nullable Object value, + @Nullable Throwable failure, + int llmCalls, + ScriptedNegotiationLlmClient llmClient) { + + /** + * Returns the generated message of a successful generation run. + * + * @return the metadata content, or null when the run failed or produced filled parameters + */ + public @Nullable MetadataContent message() { + return value instanceof MetadataContent metadata ? metadata : null; + } + + /** + * Returns the filled parameters of a successful validation run. + * + * @return the filled parameter data, or null when the run failed or produced a message + */ + public @Nullable FilledParamData filledParams() { + return value instanceof FilledParamData filled ? filled : null; + } + } + + // ------------------------------------------------------------------ expectation comparison + + private void assertExpectations(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + Expectation expect = testCase.expect(); + if (expect.success()) { + if (outcome.failure() != null) { + fail(testCase, "$.expect.outcome", "success", "failure (" + outcome.failure() + ")"); + } + assertSuccessFields(outcome); + } else { + if (outcome.failure() == null) { + fail( + testCase, + "$.expect.outcome", + "failure", + "success (" + typeName(outcome.value()) + ": " + outcome.value() + ")"); + } + assertFailureFields(outcome); + } + if (expect.llmCalls() != null && expect.llmCalls() != outcome.llmCalls()) { + fail( + testCase, + "$.expect.llmCalls", + String.valueOf(expect.llmCalls()), + String.valueOf(outcome.llmCalls())); + } + for (String contractName : expect.contracts()) { + assertContract(outcome, contractName); + } + if (expect.differential()) { + runDifferential(outcome); + } + } + + private void assertFailureFields(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + Expectation expect = testCase.expect(); + Throwable failure = outcome.failure(); + if (expect.exception() != null) { + String actual = failure.getClass().getSimpleName(); + if (!expect.exception().equals(actual)) { + fail(testCase, "$.expect.exception", expect.exception(), actual); + } + } + if (expect.code() != null) { + if (!(failure instanceof A2ATError error)) { + throw fail( + testCase, + "$.expect.code", + expect.code(), + "no error code (exception " + failure.getClass().getSimpleName() + ")"); + } + if (!expect.code().equals(error.getCode())) { + fail(testCase, "$.expect.code", expect.code(), error.getCode()); + } + } + for (String fragment : expect.messageContains()) { + String message = failure.getMessage(); + if (message == null || !message.contains(fragment)) { + fail( + testCase, + "$.expect.messageContains", + "a message containing '" + fragment + "'", + "message " + quoted(message)); + } + } + if (!expect.slotErrors().isEmpty()) { + if (!(failure instanceof A2ATParamExtractionError extraction)) { + throw fail( + testCase, + "$.expect.slotErrors", + renderSlotErrors(expect.slotErrors()), + "exception " + failure.getClass().getSimpleName() + " carries no slot errors"); + } + assertSlotErrors(testCase, expect.slotErrors(), extraction.getErrors()); + } + } + + private void assertSlotErrors( + NegotiationCase testCase, List expected, List actual) { + Map expectedCounts = new LinkedHashMap<>(); + for (Expectation.SlotError slotError : expected) { + expectedCounts.merge(slotError.slot() + ":" + slotError.code(), 1, Integer::sum); + } + Map actualCounts = new LinkedHashMap<>(); + List actualPairs = new ArrayList<>(); + for (SlotValidationError error : actual) { + String pair = error.slotName() + ":" + error.code(); + actualCounts.merge(pair, 1, Integer::sum); + actualPairs.add(pair); + } + if (!expectedCounts.equals(actualCounts)) { + fail( + testCase, + "$.expect.slotErrors", + renderSlotErrors(expected), + String.join(", ", actualPairs.isEmpty() ? List.of("(none)") : actualPairs)); + } + } + + private void assertSuccessFields(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + Expectation expect = testCase.expect(); + if (expect.promptTextEqualsGolden() != null) { + MetadataContent message = requireMessage(outcome, "$.expect.promptTextEqualsGolden"); + String golden = readGoldenFixture(testCase, expect.promptTextEqualsGolden()); + if (!normalize(message.promptText()).equals(golden)) { + fail( + testCase, + "$.expect.promptTextEqualsGolden", + quoted(truncate(golden)), + quoted(truncate(normalize(message.promptText())))); + } + } + if (expect.metadata() != null) { + MetadataContent message = requireMessage(outcome, "$.expect.metadata"); + if (expect.metadata().templateUriEcho() != null + && !expect.metadata().templateUriEcho().equals(message.templateUri())) { + fail( + testCase, + "$.expect.metadata.templateUriEcho", + expect.metadata().templateUriEcho(), + message.templateUri()); + } + if (Boolean.TRUE.equals(expect.metadata().contextEcho())) { + NegotiationContext expectedContext = toContext(testCase.context()); + if (!Objects.equals(expectedContext, message.negotiationContext())) { + fail( + testCase, + "$.expect.metadata.contextEcho", + String.valueOf(expectedContext), + String.valueOf(message.negotiationContext())); + } + } + } + if (!expect.params().isEmpty()) { + if (!(outcome.value() instanceof FilledParamData filled)) { + throw fail( + testCase, + "$.expect.params", + render(expect.params()), + "no filled parameter data (" + typeName(outcome.value()) + ")"); + } + if (!Objects.equals(expect.params(), filled.data())) { + fail(testCase, "$.expect.params", render(expect.params()), render(filled.data())); + } + } + } + + // ------------------------------------------------------------------ contracts (P0) + + private void assertContract(CaseOutcome outcome, String contractName) { + NegotiationCase testCase = outcome.testCase(); + Contract contract = Contract.fromJsonName(contractName); + if (contract == null) { + fail( + testCase, + "$.expect.contracts", + "a registered contract name (one of " + knownContractNames() + ")", + "'" + contractName + "'"); + } + if (!contract.isP0()) { + fail( + testCase, + "$.expect.contracts", + "a P0 contract the engine asserts today", + "'" + contractName + "' is registered as a P1 expectation and is not yet lit"); + } + switch (contract) { + case CONCLUSION_LITERAL_PRESENT -> assertConclusionLiteralPresent(outcome); + case CONTEXT_KEYS_IN_MERGED_PARAMS -> assertContextKeysInMergedParams(outcome); + case NO_LLM_LEAK_IN_USER_MESSAGE -> assertNoLlmLeakInUserMessage(outcome); + case METADATA_TRIPLE_SHAPE -> assertMetadataTripleShape(outcome); + default -> fail( + testCase, + "$.expect.contracts", + "an implemented contract", + "'" + contractName + "' is flagged P0 but has no implementation"); + } + } + + private void assertConclusionLiteralPresent(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + String literal = switch (testCase.api()) { + case GENERATE_ACCEPT_FROM_TEXT, GENERATE_ACCEPT_FROM_DATA -> "Accept"; + case GENERATE_REJECT_FROM_TEXT, GENERATE_REJECT_FROM_DATA -> "Reject"; + case GENERATE_ABORT_FROM_TEXT, GENERATE_ABORT_FROM_DATA -> "Abort"; + default -> null; + }; + if (literal == null) { + fail( + testCase, + "$.expect.contracts[conclusionLiteralPresent]", + "a terminal or abort generation API", + testCase.api().jsonName()); + } + MetadataContent message = requireMessage(outcome, "$.expect.contracts[conclusionLiteralPresent]"); + if (!String.valueOf(message.promptText()).contains(literal)) { + fail( + testCase, + "$.expect.contracts[conclusionLiteralPresent]", + "a prompt text containing the literal '" + literal + "'", + quoted(truncate(message.promptText()))); + } + } + + private void assertContextKeysInMergedParams(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + if (!(outcome.value() instanceof FilledParamData filled)) { + throw fail( + testCase, + "$.expect.contracts[contextKeysInMergedParams]", + "filled parameter data carrying the context keys", + typeName(outcome.value())); + } + for (String key : List.of("id", "round", "maxRounds")) { + if (!filled.data().containsKey(key)) { + fail( + testCase, + "$.expect.contracts[contextKeysInMergedParams]", + "merged params containing '" + key + "'", + render(filled.data())); + } + } + } + + private void assertNoLlmLeakInUserMessage(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + List details = outcome.llmClient().leakedFailureDetails(); + if (details.isEmpty()) { + return; + } + String visible = outcome.failure() != null + ? outcome.failure().getMessage() + : outcome.message() != null ? outcome.message().promptText() : null; + if (visible == null) { + fail( + testCase, + "$.expect.contracts[noLlmLeakInUserMessage]", + "a user-visible message to inspect", + "neither a failure message nor a generated message was produced"); + } + for (String detail : details) { + if (visible.contains(detail)) { + fail( + testCase, + "$.expect.contracts[noLlmLeakInUserMessage]", + "a message free of the raw LLM failure detail '" + detail + "'", + quoted(truncate(visible))); + } + } + } + + private void assertMetadataTripleShape(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + MetadataContent message = requireMessage(outcome, "$.expect.contracts[metadataTripleShape]"); + Map metadata = message.buildMetadataContent(); + if (metadata.size() != 3 + || !metadata.containsKey(message.extensionUri()) + || !metadata.containsKey(MetadataContent.TEMPLATE_URI_METADATA_KEY) + || !metadata.containsKey(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY)) { + fail( + testCase, + "$.expect.contracts[metadataTripleShape]", + "exactly the three entries ", + String.valueOf(metadata.keySet())); + } + } + + // ------------------------------------------------------------------ differential (Q17 C+) + + private void runDifferential(CaseOutcome main) { + NegotiationCase testCase = main.testCase(); + if (testCase.api().family() != NegotiationApi.Family.FROM_TEXT) { + fail( + testCase, + "$.expect.differential", + "a from-text family API", + testCase.api().jsonName()); + } + if (testCase.inputData() == null) { + fail(testCase, "$.expect.differential", "input.data alongside input.text", "no input.data"); + } + if (testCase.expect().promptTextEqualsGolden() == null) { + fail( + testCase, + "$.expect.differential", + "promptTextEqualsGolden as the third comparison leg", + "no golden name"); + } + MetadataContent fromText = requireMessage(main, "$.expect.differential"); + ScriptedNegotiationLlmClient zeroCallClient = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = new NegotiationContentService(orchestratorFor(testCase, zeroCallClient)); + MetadataContent fromData; + try { + fromData = generateFromData(service, testCase); + } catch (RuntimeException | AssertionError error) { + fail( + testCase, + "$.expect.differential", + "a successful from-data message", + "failure (" + error + ")"); + return; + } + if (!Objects.equals(fromText.promptText(), fromData.promptText())) { + fail( + testCase, + "$.expect.differential", + "fromText == fromData (" + quoted(truncate(fromText.promptText())) + ")", + quoted(truncate(fromData.promptText()))); + } + String golden = readGoldenFixture(testCase, testCase.expect().promptTextEqualsGolden()); + if (!normalize(fromData.promptText()).equals(golden)) { + fail( + testCase, + "$.expect.differential", + "fromData == golden " + quoted(truncate(golden)), + quoted(truncate(normalize(fromData.promptText())))); + } + if (zeroCallClient.callCount() != 0) { + fail( + testCase, + "$.expect.differential", + "0 LLM calls on the from-data leg", + String.valueOf(zeroCallClient.callCount())); + } + } + + private MetadataContent generateFromData(NegotiationContentService service, NegotiationCase testCase) { + TemplateUri templateUri = parseTemplateUri(testCase); + NegotiationContext context = toContext(testCase.context()); + JsonNode data = testCase.inputData(); + return switch (testCase.api()) { + case GENERATE_PROPOSE_FROM_TEXT -> service.generateProposeFromData( + (NegotiationProposeData) TypedInputAssembler.assemble( + data, context, templateUri, NegotiationPhase.PROPOSE, testCase.language()), + templateUri); + case GENERATE_ACCEPT_FROM_TEXT -> service.generateAcceptFromData( + (NegotiationEndingData) TypedInputAssembler.assemble( + data, context, templateUri, NegotiationPhase.ACCEPT, testCase.language()), + templateUri); + case GENERATE_REJECT_FROM_TEXT -> service.generateRejectFromData( + (NegotiationEndingData) TypedInputAssembler.assemble( + data, context, templateUri, NegotiationPhase.REJECT, testCase.language()), + templateUri); + case GENERATE_ABORT_FROM_TEXT -> service.generateAbortFromData( + (NegotiationAbortData) TypedInputAssembler.assemble( + data, context, templateUri, NegotiationPhase.ABORT, testCase.language()), + templateUri); + default -> throw new IllegalStateException( + "The differential run pairs a from-text API with its from-data twin but got " + + testCase.api().jsonName() + "."); + }; + } + + // ------------------------------------------------------------------ production wiring + + private static ScriptedNegotiationLlmClient llmClientFor(NegotiationCase testCase) { + if (testCase.api().family() == NegotiationApi.Family.FROM_DATA || testCase.llm() == null) { + return ScriptedNegotiationLlmClient.assertionOnly(); + } + return new ScriptedNegotiationLlmClient(testCase.llm().steps()); + } + + private static NegotiationGenerationOrchestrator orchestratorFor( + NegotiationCase testCase, ScriptedNegotiationLlmClient llmClient) { + NegotiationGenerationOrchestratorBuilder builder = NegotiationGenerationOrchestratorBuilder.builder() + .language(testCase.language()) + .llmClient(llmClient); + if (testCase.llm() != null && testCase.llm().maxAttempts() != null) { + builder.maxAttempts(testCase.llm().maxAttempts()); + } + if (INJECT_FAILING_TEMPLATE_LOADER.equals(testCase.inject())) { + builder.templateLoader(failingTemplateLoader()); + } + if (INJECT_FAILING_SEMANTIC_VALIDATOR.equals(testCase.inject())) { + builder.semanticValidator(failingSemanticValidator()); + } + return builder.build(); + } + + private static NegotiationTemplateLoader failingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + + @Override + public List loadAll() { + return List.of(); + } + }; + } + + /** + * Replays the semantic validator of a language without bundled semantic validation prompt resources: the pipeline + * maps the resource failure onto the public {@code template_not_found} code without bubbling the raw exception + * (the validate-family leg of the mapCode matrix; mirrors the hand-written pipeline suite). + * + * @return semantic validator failing every call with a resource-not-found exception + */ + private static NegotiationSemanticValidator failingSemanticValidator() { + return (prompt, callerSchema, reference) -> { + throw new ResourceNotFoundException( + "Negotiation semantic validation prompt resource does not exist for language " + + reference.language() + + "; set A2AT_LANGUAGE to a language with bundled prompt resources (zh-CN or en-US).", + "prompt_resources/prompts/negotiation_semantic_validation/" + reference.language() + + "/system.md"); + }; + } + + // ------------------------------------------------------------------ API dispatch + + private Object invoke( + NegotiationContentService service, NegotiationCase testCase, @Nullable String promptOverride) { + TemplateUri templateUri = parseTemplateUri(testCase); + NegotiationContext context = toContext(testCase.context()); + return switch (testCase.api()) { + case GENERATE_PROPOSE_FROM_TEXT -> + service.generateProposeFromText(testCase.inputText(), context, templateUri); + case GENERATE_ACCEPT_FROM_TEXT -> + service.generateAcceptFromText(testCase.inputText(), context, templateUri); + case GENERATE_REJECT_FROM_TEXT -> + service.generateRejectFromText(testCase.inputText(), context, templateUri); + case GENERATE_ABORT_FROM_TEXT -> + service.generateAbortFromText(testCase.inputText(), context, templateUri); + case GENERATE_PROPOSE_FROM_DATA -> service.generateProposeFromData( + (NegotiationProposeData) TypedInputAssembler.assemble( + requireInputData(testCase), context, templateUri, NegotiationPhase.PROPOSE, + testCase.language()), + templateUri); + case GENERATE_ACCEPT_FROM_DATA -> service.generateAcceptFromData( + (NegotiationEndingData) TypedInputAssembler.assemble( + requireInputData(testCase), context, templateUri, NegotiationPhase.ACCEPT, + testCase.language()), + templateUri); + case GENERATE_REJECT_FROM_DATA -> service.generateRejectFromData( + (NegotiationEndingData) TypedInputAssembler.assemble( + requireInputData(testCase), context, templateUri, NegotiationPhase.REJECT, + testCase.language()), + templateUri); + case GENERATE_ABORT_FROM_DATA -> service.generateAbortFromData( + (NegotiationAbortData) TypedInputAssembler.assemble( + requireInputData(testCase), context, templateUri, NegotiationPhase.ABORT, + testCase.language()), + templateUri); + case VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING -> service.validateProposePromptAndDataFilling( + promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); + case VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING -> service.validateAcceptPromptAndDataFilling( + promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); + case VALIDATE_REJECT_PROMPT_AND_DATA_FILLING -> service.validateRejectPromptAndDataFilling( + promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); + case VALIDATE_ABORT_PROMPT_AND_DATA_FILLING -> service.validateAbortPromptAndDataFilling( + promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); + }; + } + + private static TemplateUri parseTemplateUri(NegotiationCase testCase) { + String raw = testCase.templateUri(); + if (raw == null) { + return null; + } + return TemplateUri.parse(raw) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); + } + + private static JsonNode requireInputData(NegotiationCase testCase) { + JsonNode data = testCase.inputData(); + if (data == null) { + throw new IllegalStateException( + testCase.errorPrefix() + " input.data: the from-data family requires typed input data"); + } + return data; + } + + private static String promptOf(NegotiationCase testCase, @Nullable String promptOverride) { + if (promptOverride != null) { + return promptOverride; + } + PromptSource source = testCase.prompt(); + if (source == null) { + return null; + } + if (source instanceof PromptSource.Text text) { + return text.text(); + } + if (source instanceof PromptSource.Golden golden) { + return readGoldenFixture(testCase, golden.golden()); + } + PromptSource.FromStep fromStep = (PromptSource.FromStep) source; + throw new IllegalStateException( + testCase.errorPrefix() + " prompt.fromStep " + fromStep.step() + + " is resolved by the ScenarioEngine; run the enclosing scenario through the ScenarioEngine"); + } + + private static @Nullable Map schemaOf(NegotiationCase testCase) { + JsonNode schema = testCase.schema(); + if (schema == null) { + return null; + } + return MAPPER.convertValue(schema, new TypeReference>() {}); + } + + private static @Nullable NegotiationContext toContext(@Nullable ContextSpec spec) { + return spec == null ? null : new NegotiationContext(spec.id(), spec.round(), spec.maxRounds()); + } + + // ------------------------------------------------------------------ helpers + + private static MetadataContent requireMessage(CaseOutcome outcome, String jsonPath) { + NegotiationCase testCase = outcome.testCase(); + MetadataContent message = outcome.message(); + if (message == null) { + fail( + testCase, + jsonPath, + "a generated negotiation message", + "failure (" + outcome.failure() + ") or " + typeName(outcome.value())); + } + return message; + } + + private static String readGoldenFixture(NegotiationCase testCase, String goldenName) { + String resourcePath = "/golden/" + testCase.language() + "/" + goldenName + ".md"; + InputStream stream = CaseEngine.class.getResourceAsStream(resourcePath); + if (stream == null) { + throw new AssertionError( + testCase.errorPrefix() + " golden fixture '" + goldenName + "' does not exist on the test" + + " classpath: " + resourcePath); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); + } catch (IOException exception) { + throw new AssertionError( + testCase.errorPrefix() + " failed to read the golden fixture " + resourcePath, exception); + } + } + + private static String normalize(@Nullable String text) { + return text == null ? null : text.replace("\r\n", "\n"); + } + + private static String typeName(@Nullable Object value) { + return value == null ? "null" : value.getClass().getSimpleName(); + } + + private static String render(Map values) { + try { + return MAPPER.writeValueAsString(values); + } catch (IOException exception) { + return String.valueOf(values); + } + } + + private static String renderSlotErrors(List slotErrors) { + List pairs = new ArrayList<>(); + for (Expectation.SlotError slotError : slotErrors) { + pairs.add(slotError.slot() + ":" + slotError.code()); + } + return String.join(", ", pairs); + } + + private static String knownContractNames() { + List names = new ArrayList<>(); + for (Contract contract : Contract.values()) { + names.add(contract.jsonName()); + } + return String.join(", ", names); + } + + private static String truncate(String text) { + return text.length() <= MAX_REPORTED_TEXT_LENGTH ? text : text.substring(0, MAX_REPORTED_TEXT_LENGTH) + "..."; + } + + private static String quoted(@Nullable String text) { + return text == null ? "null" : "<" + text + ">"; + } + + private static AssertionError fail(NegotiationCase testCase, String jsonPath, String expected, String actual) { + throw new AssertionError( + testCase.errorPrefix() + " " + jsonPath + ": expected " + expected + " but was " + actual); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngineTest.java new file mode 100644 index 00000000..b1790afe --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngineTest.java @@ -0,0 +1,528 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +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 java.util.List; +import java.util.Map; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import org.junit.jupiter.api.BeforeEach; +import net.openan.a2at.sdk.negotiation.testdata.Expectation.Metadata; +import org.junit.jupiter.api.Test; +import org.jspecify.annotations.Nullable; + +/** + * Direct unit tests of the {@link CaseEngine} against inline case objects: the four P0 contracts, the Q17 C+ + * differential double run, the inject hook, the fail-on-overconsumption calibration through {@code llmCalls}, and the + * red paths proving the engine is not a rubber stamp (every flipped expectation fails with the case id and the JSON path + * of the expectation). + * + *

      The engine runs against the real production wiring (builders, classpath resources, renderers, vocabulary, rule gate, + * semantic validator); the golden comparisons use the committed golden fixtures of the test resources. + */ +class CaseEngineTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String ZH_CN = "zh-CN"; + + private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; + + private static final String INFORMATION_ACCEPT_REJECT_URI = + "Negotiation-T/information-negotiation/accept-reject/v1"; + + /** Extraction payload that maps back to the typed content of the information accept golden fixture. */ + private static final String ACCEPT_PAYLOAD = + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan" + + " Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}]}"; + + /** Extraction payload that maps back to the typed content of the information propose golden fixture. */ + private static final String PROPOSE_PAYLOAD = + "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"e.g. Songshan Lake\"},{\"name\":" + + "\"energy-saving rate guarantee target\",\"value\":\"e.g. 20Mbps\"},{\"name\":\"VLANId\"," + + "\"value\":null}],\"relationship\":\"OR\"}"; + + /** The same content as typed data, disagreeing in one item value for the differential red path. */ + private static final String PROPOSE_DATA_DISAGREEING = + "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Another Lake\"},{\"name\":" + + "\"energy-saving rate guarantee target\",\"value\":\"e.g. 20Mbps\"},{\"name\":\"VLANId\"," + + "\"value\":null}],\"relationship\":\"OR\"}"; + + private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"region\":" + + "\"Songshan Lake\"}}"; + + private static final String SEMANTIC_REJECT_PAYLOAD = + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" + + "\"region\",\"code\":\"missing\",\"message\":\"The region parameter is missing.\"}]," + + "\"params\":{}}"; + + private final CaseEngine engine = new CaseEngine(); + + private final ObjectMapper mapper = new ObjectMapper(); + + private JsonNode schema; + + @BeforeEach + void readSchema() throws Exception { + schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"region\":{\"type\":\"string\"}}}"); + } + + // ------------------------------------------------------------------ green paths + + @Test + void runsASuccessfulFromTextCaseAgainstTheGoldenFixture() { + NegotiationCase testCase = acceptFromTextCase(ok(1, "information_accept", metadata(), false), null); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + MetadataContent message = outcome.message(); + assertNotNull(message); + assertEquals(1, outcome.llmCalls()); + assertEquals(INFORMATION_ACCEPT_REJECT_URI, message.templateUri()); + assertEquals(new NegotiationContext(SESSION_ID, 2, 5), message.negotiationContext()); + } + + @Test + void runsFromDataCasesDeterministicallyWithoutAnyLlmCall() throws Exception { + NegotiationCase testCase = new NegotiationCase( + "FD-HAPPY-01/zh-CN", + "FD-HAPPY-01", + "from-data/happy.json", + NegotiationApi.GENERATE_ACCEPT_FROM_DATA, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + null, + mapper.readTree(ACCEPT_PAYLOAD), + null, + null, + null, + null, + ok(0, "information_accept", metadata(), false)); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + assertNotNull(outcome.message()); + assertEquals(0, outcome.llmCalls(), "the from-data run must not call the LLM (assertion-only client)"); + } + + @Test + void injectsTheFailingTemplateLoaderForTheTemplateNotFoundMatrix() { + NegotiationCase testCase = new NegotiationCase( + "FT-TPL-01/zh-CN", + "FT-TPL-01", + "from-text/template-resolution.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(ACCEPT_PAYLOAD))), + null, + null, + "failingTemplateLoader", + failed("NegotiationGenerationException", "template_not_found", 0, List.of(), List.of())); + + engine.run(testCase); + } + + @Test + void injectsTheFailingSemanticValidatorForTheValidateTemplateNotFoundMapping() { + NegotiationCase testCase = new NegotiationCase( + "VAL-MAP-05/zh-CN", + "VAL-MAP-05", + "validate/error-code-mapping.json", + NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_PROPOSE_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.ASSERTION))), + new PromptSource.Golden("information_propose"), + schema, + "failingSemanticValidator", + failed("NegotiationParamExtractionException", "template_not_found", 0, List.of(), List.of())); + + engine.run(testCase); + } + + // ------------------------------------------------------------------ P0 contracts + + @Test + void assertConclusionLiteralPresentContract() { + NegotiationCase testCase = + acceptFromTextCase(okContracts(1, "information_accept", List.of("conclusionLiteralPresent")), null); + + engine.run(testCase); + } + + @Test + void conclusionLiteralPresentContractRejectsNonTerminalApis() { + NegotiationCase testCase = + proposeFromTextCase(okContracts(1, "information_propose", List.of("conclusionLiteralPresent")), null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("conclusionLiteralPresent") + && failure.getMessage().contains("a terminal or abort generation API"), + "the contract must reject a propose API but was: " + failure.getMessage()); + } + + @Test + void assertContextKeysInMergedParamsContract() { + NegotiationCase testCase = validateProposeCase( + okParams( + 1, + Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "region", "Songshan Lake"), + List.of("contextKeysInMergedParams")), + SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + FilledParamData filled = outcome.filledParams(); + assertNotNull(filled); + assertEquals("Songshan Lake", filled.data().get("region")); + } + + @Test + void assertNoLlmLeakInUserMessageContract() { + NegotiationCase testCase = new NegotiationCase( + "FT-RETRY-01/zh-CN", + "FT-RETRY-01", + "from-text/retry.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + new LlmScript( + null, + List.of( + new LlmScriptStep.Fail(LlmFailMarker.RUNTIME_EXCEPTION), + new LlmScriptStep.Fail(LlmFailMarker.RUNTIME_EXCEPTION), + new LlmScriptStep.Fail(LlmFailMarker.RUNTIME_EXCEPTION))), + null, + null, + null, + failed( + "NegotiationGenerationException", + "negotiation_llm_infrastructure_error", + 3, + List.of(), + List.of("noLlmLeakInUserMessage"))); + + engine.run(testCase); + } + + @Test + void assertMetadataTripleShapeContract() { + NegotiationCase testCase = + acceptFromTextCase(okContracts(1, "information_accept", List.of("metadataTripleShape")), null); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + assertEquals( + 3, + outcome.message().buildMetadataContent().size(), + "the contract must have verified the triple metadata shape"); + } + + @Test + void rejectsUnknownAndNotYetLitContractNames() { + NegotiationCase unknown = + acceptFromTextCase(okContracts(1, "information_accept", List.of("not-a-contract")), null); + AssertionError unknownFailure = assertThrows(AssertionError.class, () -> engine.run(unknown)); + assertTrue( + unknownFailure.getMessage().contains("a registered contract name"), + "an unknown contract name must fail but was: " + unknownFailure.getMessage()); + + NegotiationCase notYetLit = + acceptFromTextCase(okContracts(1, "information_accept", List.of("noRenderSlotLeak")), null); + AssertionError notYetLitFailure = assertThrows(AssertionError.class, () -> engine.run(notYetLit)); + assertTrue( + notYetLitFailure.getMessage().contains("not yet lit"), + "a registered P1 contract must fail as not yet lit but was: " + notYetLitFailure.getMessage()); + } + + // ------------------------------------------------------------------ differential (Q17 C+) + + @Test + void runsTheDifferentialDoubleRun() throws Exception { + NegotiationCase testCase = proposeFromTextCase( + ok(1, "information_propose", new Expectation.Metadata(INFORMATION_PROPOSE_URI, Boolean.TRUE), true), + mapper.readTree(PROPOSE_PAYLOAD)); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + assertNotNull(outcome.message()); + assertEquals(1, outcome.llmCalls(), "only the from-text leg may call the LLM"); + } + + @Test + void differentialFailsWhenTheTypedDataDisagreesWithTheText() throws Exception { + NegotiationCase testCase = proposeFromTextCase( + ok(1, "information_propose", new Expectation.Metadata(INFORMATION_PROPOSE_URI, Boolean.TRUE), true), + mapper.readTree(PROPOSE_DATA_DISAGREEING)); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.differential") + && failure.getMessage().contains("fromText == fromData"), + "the differential must compare both legs but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ validate expectations + + @Test + void comparesSlotErrorsOrderInsensitively() { + NegotiationCase testCase = validateProposeCase( + failed( + "NegotiationParamExtractionException", + "negotiation_semantic_rejected", + 1, + List.of(new Expectation.SlotError("region", "missing")), + List.of()), + SEMANTIC_REJECT_PAYLOAD); + + engine.run(testCase); + } + + @Test + void slotErrorsFailOnAnExtraExpectedPair() { + NegotiationCase testCase = validateProposeCase( + failed( + "NegotiationParamExtractionException", + "negotiation_semantic_rejected", + 1, + List.of( + new Expectation.SlotError("region", "missing"), + new Expectation.SlotError("round", "out_of_range")), + List.of()), + SEMANTIC_REJECT_PAYLOAD); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.slotErrors"), + "an extra expected slot error must fail but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ red paths (not a rubber stamp) + + @Test + void failsWithCaseIdAndJsonPathWhenTheExpectedCodeMismatches() { + NegotiationCase testCase = new NegotiationCase( + "FT-EXTRACT-01/zh-CN", + "FT-EXTRACT-01", + "from-text/extraction-failures.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload("{\"relationship\":null}"))), + null, + null, + null, + failed(null, "negotiation_invalid_input", 1, List.of(), List.of())); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("FT-EXTRACT-01/zh-CN") + && failure.getMessage().contains("$.expect.code") + && failure.getMessage().contains("negotiation_slot_missing"), + "the failure must carry the case id, the JSON path and both codes but was: " + failure.getMessage()); + } + + @Test + void failsWithCaseIdAndJsonPathWhenTheLlmCallCountMismatches() { + NegotiationCase testCase = acceptFromTextCase(ok(2, "information_accept", null, false), null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.llmCalls") && failure.getMessage().contains("2"), + "a wrong llmCalls expectation must fail but was: " + failure.getMessage()); + } + + @Test + void failsWithCaseIdAndJsonPathWhenTheGoldenNameMismatches() { + NegotiationCase testCase = acceptFromTextCase(ok(1, "information_reject", null, false), null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.promptTextEqualsGolden"), + "a wrong golden name must fail but was: " + failure.getMessage()); + } + + @Test + void failsWhenASuccessCaseActuallyFails() { + NegotiationCase testCase = new NegotiationCase( + "FT-HAPPY-99/zh-CN", + "FT-HAPPY-99", + "from-text/happy.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), + null, + null, + null, + ok(1, "information_accept", null, false)); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.outcome") && failure.getMessage().contains("failure"), + "an unexpected failure must flip the outcome expectation but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ expectation helpers + + private static Expectation ok( + @Nullable Integer llmCalls, + @Nullable String promptTextEqualsGolden, + @Nullable Metadata metadata, + boolean differential) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + promptTextEqualsGolden, + metadata, + Map.of(), + List.of(), + differential); + } + + private static Expectation okContracts( + @Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden, List contracts) { + return new Expectation( + true, null, null, List.of(), List.of(), llmCalls, promptTextEqualsGolden, null, Map.of(), contracts, + false); + } + + private static Expectation okParams( + @Nullable Integer llmCalls, Map params, List contracts) { + return new Expectation( + true, null, null, List.of(), List.of(), llmCalls, null, null, params, contracts, false); + } + + private static Expectation failed( + @Nullable String exception, + @Nullable String code, + @Nullable Integer llmCalls, + List slotErrors, + List contracts) { + return new Expectation( + false, exception, code, List.of(), slotErrors, llmCalls, null, null, Map.of(), contracts, false); + } + + // ------------------------------------------------------------------ case builders + + private NegotiationCase acceptFromTextCase(Expectation expect, @Nullable JsonNode inputData) { + return new NegotiationCase( + "FT-HAPPY-01/zh-CN", + "FT-HAPPY-01", + "from-text/happy.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "我确认第一阶段的信息。", + inputData, + new LlmScript(null, List.of(new LlmScriptStep.Payload(ACCEPT_PAYLOAD))), + null, + null, + null, + expect); + } + + private NegotiationCase proposeFromTextCase(Expectation expect, @Nullable JsonNode inputData) { + return new NegotiationCase( + "FT-HAPPY-02/zh-CN", + "FT-HAPPY-02", + "from-text/happy.json", + NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_PROPOSE_URI, + "请补充节能区域、节能速率保障目标与VLANId信息。", + inputData, + new LlmScript(null, List.of(new LlmScriptStep.Payload(PROPOSE_PAYLOAD))), + null, + null, + null, + expect); + } + + private NegotiationCase validateProposeCase(Expectation expect, String semanticPayload) { + return new NegotiationCase( + "VAL-HAPPY-01/zh-CN", + "VAL-HAPPY-01", + "validate/happy.json", + NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_PROPOSE_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), + new PromptSource.Golden("information_propose"), + schema, + null, + expect); + } + + private static Expectation.Metadata metadata() { + return new Expectation.Metadata(INFORMATION_ACCEPT_REJECT_URI, Boolean.TRUE); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ContextSpec.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ContextSpec.java new file mode 100644 index 00000000..f4bb78e1 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ContextSpec.java @@ -0,0 +1,24 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +/** + * Negotiation context of one corpus record, inline in the corpus JSON. + * + *

      The engine converts this spec into a {@code NegotiationContext}; a JSON {@code null} context (the null-context + * probes of the validate family) stays a null reference on the loaded case instead of a spec. + * + * @param id negotiation session id (a 36-digit UUID in the rule-gated cases) + * @param round current negotiation round, at least 1 + * @param maxRounds negotiated round limit, at least 1 + * @since 2026-08 + */ +public record ContextSpec(String id, int round, int maxRounds) { + + public ContextSpec { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("The context id must not be blank."); + } + if (round < 1 || maxRounds < 1) { + throw new IllegalArgumentException("The context round and maxRounds must be at least 1."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Contract.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Contract.java new file mode 100644 index 00000000..13b57029 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Contract.java @@ -0,0 +1,94 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import org.jspecify.annotations.Nullable; + +/** + * Registry of the behavior contract names a corpus expectation can reference (design document §8.1, Q18). + * + *

      The registry defines all twelve contracts at once with their P0/P1 level, so it doubles as the readable expectation + * list of the corpus-generation workflow. The engine implements the four P0 contracts; referencing a P1 contract is an + * explicit engine failure ("not yet lit") rather than a silent pass, so a corpus author always knows what is actually + * asserted. + * + * @since 2026-08 + */ +public enum Contract { + + /** Accept/Reject/Abort messages must carry the corresponding conclusion literal. P0, case-level. */ + CONCLUSION_LITERAL_PRESENT("conclusionLiteralPresent", true), + + /** The validate merge result must always carry the context keys id, round and maxRounds. P0, case-level. */ + CONTEXT_KEYS_IN_MERGED_PARAMS("contextKeysInMergedParams", true), + + /** Raw LLM failure details must never reach a user-visible message. P0, case-level. */ + NO_LLM_LEAK_IN_USER_MESSAGE("noLlmLeakInUserMessage", true), + + /** buildMetadataContent() always carries exactly the three negotiation metadata entries. P0, case-level. */ + METADATA_TRIPLE_SHAPE("metadataTripleShape", true), + + /** The rendered output must not contain an unreplaced {@code {{}} slot. P1, case-level. */ + NO_RENDER_SLOT_LEAK("noRenderSlotLeak", false), + + /** The rendered text must not contain the context id (41d0247 invariant). P1, case-level. */ + NO_CONTEXT_IN_RENDERED_TEXT("noContextInRenderedText", false), + + /** The returned template URI must equal the input template URI. P1, case-level. */ + TEMPLATE_URI_ECHO("templateUriEcho", false), + + /** An error message must contain its error code literal. P1, case-level. */ + ERROR_CODE_IN_MESSAGE("errorCodeInMessage", false), + + /** A rule-gate failure must happen with zero LLM calls. P1, case-level. */ + ZERO_LLM_CALLS_ON_RULE_GATE("zeroLlmCallsOnRuleGate", false), + + /** The same input must always fail with the same error code. P1, case-level. */ + ERROR_CODE_DETERMINISM("errorCodeDeterminism", false), + + /** Within one scenario the round must only increase. P1, scenario-level. */ + ROUND_MONOTONIC("roundMonotonic", false), + + /** nextRound() must not modify the original context. P1, property-level. */ + CONTEXT_IMMUTABILITY("contextImmutability", false); + + private final String jsonName; + + private final boolean p0; + + Contract(String jsonName, boolean p0) { + this.jsonName = jsonName; + this.p0 = p0; + } + + /** + * Returns the corpus JSON name of this contract. + * + * @return contract name such as {@code conclusionLiteralPresent} + */ + public String jsonName() { + return jsonName; + } + + /** + * Returns whether this contract is a P0 contract the engine asserts today. + * + * @return true for the four implemented P0 contracts + */ + public boolean isP0() { + return p0; + } + + /** + * Resolves a corpus JSON name into a contract. + * + * @param jsonName corpus JSON name such as {@code metadataTripleShape} + * @return the contract, or null when the name matches none of the twelve registered contracts + */ + public static @Nullable Contract fromJsonName(@Nullable String jsonName) { + for (Contract contract : values()) { + if (contract.jsonName.equals(jsonName)) { + return contract; + } + } + return null; + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusContractTest.java new file mode 100644 index 00000000..e69dd320 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusContractTest.java @@ -0,0 +1,396 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.jspecify.annotations.Nullable; + +/** + * Meta test of the negotiation test corpus (design document §6 Q6 and §7): the corpus is checked against its own + * contracts instead of the production code, so a hole in the corpus is caught before it silently narrows the suites. + * + *

      Checked today, in full strictness: global id uniqueness, expectation-block completeness, every expected error code + * being one of the seven negotiation error codes, the seven-code failure coverage, the five mapCode mapping targets, the + * four operational definitions of the bilingual parity (§7), and the per-record uniform language expansion. The Q19 + * business-review soft gate (§8.7) is opt-in: P0 cases must be 100% approved in review-status.json when + * {@code -Dcorpus.review.gate=true} is set and the status file exists, and is skipped otherwise. + * + *

      The full corpus has landed (design §8.4): a missing error-code or mapCode dimension is a hard failure, not a TODO + * line — a hole in the corpus silently narrows the suites, so it must block the build. + * + * @since 2026-08 + */ +class CorpusContractTest { + + /** + * Full strictness (design Q6/§8.4): every one of the seven negotiation error codes must be covered by at least one + * failure case of the corpus; a missing code fails the build. + */ + private static final boolean REQUIRE_FULL_ERROR_CODE_COVERAGE = true; + + /** + * Full strictness (design Q6/§8.4): every one of the five mapCode mapping targets must be covered by at least one + * validate-family failure case (the VAL-MAP batch). The unknown-code passthrough (default branch) cannot be + * expressed through the public surface and stays a hand-written-suite concern (ParamExtractorTest). + */ + private static final boolean REQUIRE_FULL_MAP_CODE_COVERAGE = true; + + /** The seven negotiation error codes of the production exception surface (design §7: 7, not 10). */ + private static final List NEGOTIATION_ERROR_CODES = List.of( + A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, + A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, + A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, + A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, + A2ATErrorCodes.TEMPLATE_NOT_FOUND); + + /** + * The five {@code ParamExtractor.mapCode()} mappings of the production code: internal {@code validation_*} codes to + * the public {@code negotiation_*} codes. Corpus cases carry the public code, so a mapping is covered when a + * validate-family failure case expects its target code. The unknown-code passthrough (default branch) cannot be + * expressed through the public surface and stays a hand-written-suite concern (ParamExtractorTest). + */ + private static final Map MAP_CODE_TARGETS = Map.of( + A2ATErrorCodes.VALIDATION_INVALID_INPUT, + A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, + A2ATErrorCodes.VALIDATION_RULE_VIOLATION, + A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, + A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED, + A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, + A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, + A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, + A2ATErrorCodes.TEMPLATE_NOT_FOUND); + + private static final LoadedCorpus CORPUS = CorpusSuites.loadCorpus(); + + // ------------------------------------------------------------------ ids and expectation blocks + + @Test + void idsAreGloballyUniqueAcrossCasesAndScenarios() { + Set expandedIds = new LinkedHashSet<>(); + int expectedCount = 0; + for (NegotiationCase testCase : allCaseRecords()) { + assertTrue(expandedIds.add(testCase.id()), "duplicate expanded id " + testCase.id()); + expectedCount++; + } + for (ScenarioCase scenario : CORPUS.scenarios()) { + assertTrue(expandedIds.add(scenario.id()), "duplicate expanded id " + scenario.id()); + expectedCount++; + } + assertEquals(expectedCount, expandedIds.size(), "the id is the primary key of the whole corpus"); + + // One base id belongs to exactly one record: all its language expansions come from the same file, and every + // expansion carries a distinct language (the loader already fails fast on duplicates at parse time; this + // re-asserts the invariant on the loaded corpus). Scenario step cases are derived records of a scenario and are + // covered by the scenario check below. + Map> caseGroups = new LinkedHashMap<>(); + for (NegotiationCase testCase : allCaseRecords()) { + if (testCase.id().contains("#step-")) { + continue; + } + caseGroups.computeIfAbsent(testCase.baseId(), key -> new ArrayList<>()).add(testCase); + } + for (List group : caseGroups.values()) { + Set languages = new LinkedHashSet<>(); + String sourceFile = group.get(0).sourceFile(); + for (NegotiationCase expansion : group) { + assertEquals( + sourceFile, + expansion.sourceFile(), + "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); + assertTrue( + languages.add(expansion.language()), + "duplicate language expansion " + expansion.id()); + } + } + Map> scenarioGroups = new LinkedHashMap<>(); + for (ScenarioCase scenario : CORPUS.scenarios()) { + scenarioGroups.computeIfAbsent(scenario.baseId(), key -> new ArrayList<>()).add(scenario); + } + for (List group : scenarioGroups.values()) { + Set languages = new LinkedHashSet<>(); + String sourceFile = group.get(0).sourceFile(); + for (ScenarioCase expansion : group) { + assertEquals( + sourceFile, + expansion.sourceFile(), + "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); + assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); + } + } + } + + @Test + void expectationBlocksAreComplete() { + for (NegotiationCase testCase : allCaseRecords()) { + Expectation expect = testCase.expect(); + if (expect.success()) { + assertTrue( + expect.exception() == null && expect.code() == null, + testCase.errorPrefix() + ": a success expectation must not carry an exception or an error code"); + } else { + assertTrue( + expect.exception() != null || expect.code() != null, + testCase.errorPrefix() + + ": a failure expectation must name the expected exception or the expected error code"); + } + } + } + + @Test + void everyExpectedErrorCodeIsAKnownNegotiationCode() { + for (NegotiationCase testCase : allCaseRecords()) { + String code = testCase.expect().code(); + if (code != null) { + assertTrue( + NEGOTIATION_ERROR_CODES.contains(code), + testCase.errorPrefix() + ": the expected code '" + code + + "' is not one of the seven negotiation error codes"); + } + } + } + + // ------------------------------------------------------------------ error-code coverage (7 codes) + + @Test + void everyNegotiationErrorCodeIsCoveredByAFailureCase() { + Set covered = new LinkedHashSet<>(); + for (NegotiationCase testCase : allCaseRecords()) { + if (!testCase.expect().success() && testCase.expect().code() != null) { + covered.add(testCase.expect().code()); + } + } + List missing = new ArrayList<>(); + for (String code : NEGOTIATION_ERROR_CODES) { + if (covered.contains(code)) { + continue; + } + if (REQUIRE_FULL_ERROR_CODE_COVERAGE) { + fail("no failure case of the corpus expects the error code '" + code + "' yet"); + } + missing.add(code); + } + if (!missing.isEmpty()) { + System.out.println("error codes not yet covered by any failure case: " + missing); + } + } + + // ------------------------------------------------------------------ mapCode coverage (5 mappings) + + @Test + void everyMapCodeTargetIsCoveredByAValidateFailureCase() { + Set covered = new LinkedHashSet<>(); + for (NegotiationCase testCase : allCaseRecords()) { + if (testCase.api().family() == NegotiationApi.Family.VALIDATE + && !testCase.expect().success() + && testCase.expect().code() != null) { + covered.add(testCase.expect().code()); + } + } + List missing = new ArrayList<>(); + for (Map.Entry mapping : MAP_CODE_TARGETS.entrySet()) { + if (covered.contains(mapping.getValue())) { + continue; + } + if (REQUIRE_FULL_MAP_CODE_COVERAGE) { + fail( + "no validate-family failure case exercises the mapCode mapping " + mapping.getKey() + " -> " + + mapping.getValue() + " yet"); + } + missing.add(mapping.getKey() + " -> " + mapping.getValue()); + } + if (!missing.isEmpty()) { + System.out.println("mapCode mappings not yet covered by any validate failure case: " + missing); + } + } + + // ------------------------------------------------------------------ bilingual parity (§7, four definitions) + + /** Parity ①: every happy case of the case files declares both languages, so both expansions run and must succeed. */ + @Test + void happyCasesDeclareBothLanguages() { + for (List group : caseFileGroupsByBaseId().values()) { + if (!group.get(0).expect().success()) { + continue; + } + Set languages = languagesOf(group); + assertEquals( + Set.of("zh-CN", "en-US"), + languages, + group.get(0).errorPrefix() + + ": a happy case must run in both languages (the suite executes every expansion)"); + } + } + + /** Parity ②: all language expansions of one record share the identical expectation block, so a failure has one code. */ + @Test + void failureCasesShareTheIdenticalExpectationAcrossLanguages() { + for (List group : caseFileGroupsByBaseId().values()) { + Expectation reference = group.get(0).expect(); + for (NegotiationCase expansion : group) { + assertEquals( + reference, + expansion.expect(), + expansion.errorPrefix() + + ": every language expansion of one record must carry the identical expectation" + + " block, so a failure case fails with the same error code in both languages"); + } + } + } + + /** Parity ③: the golden fixture directories of the two languages carry the same number of fixtures. */ + @Test + void goldenFixtureCountsMatchAcrossLanguages() { + assertEquals( + countGoldenFixtures("zh-CN"), + countGoldenFixtures("en-US"), + "the golden fixtures must exist in equal numbers for zh-CN and en-US"); + } + + /** Parity ④: every record expands exactly once per declared language — no language is silently dropped or doubled. */ + @Test + void recordsExpandExactlyOncePerDeclaredLanguage() { + for (List group : caseFileGroupsByBaseId().values()) { + Set languages = languagesOf(group); + assertEquals( + languages.size(), + group.size(), + group.get(0).errorPrefix() + ": every declared language must expand into exactly one case"); + } + Map> scenarioGroups = new LinkedHashMap<>(); + for (ScenarioCase scenario : CORPUS.scenarios()) { + scenarioGroups.computeIfAbsent(scenario.baseId(), key -> new ArrayList<>()).add(scenario); + } + for (List group : scenarioGroups.values()) { + Set languages = new LinkedHashSet<>(); + for (ScenarioCase scenario : group) { + assertTrue(languages.add(scenario.language()), "duplicate language expansion of " + scenario.id()); + } + assertEquals(languages.size(), group.size(), "every declared language must expand into exactly one scenario"); + } + } + + // ------------------------------------------------------------------ business review soft gate (Q19, §8.7) + + /** + * Q19 soft gate (design §8.7): when enabled, every P0 case of the corpus must carry the review status 通过 in the + * {@code docs-local/review/review-status.json} produced by {@code tools/corpus_review.py collect} — the P0 + * review-approval rate must be 100% before a release. + * + *

      The gate is strictly opt-in so it never blocks daily development: it runs only when the system property + * {@code corpus.review.gate} is set to {@code true} AND the status file exists; otherwise the test is skipped via + * {@link Assumptions#assumeTrue(boolean, String)}. Scenario records carry no priority and are therefore out of the + * P0 gate's scope (they stay tracked through the review dashboard itself). + */ + @Test + void p0CasesAreFullyApprovedByTheBusinessReview() throws IOException { + Assumptions.assumeTrue( + Boolean.getBoolean("corpus.review.gate"), + "the business review gate is opt-in (enable with -Dcorpus.review.gate=true)"); + Path statusFile = findReviewStatusFile(); + Assumptions.assumeTrue( + statusFile != null, + "docs-local/review/review-status.json not found (run tools/corpus_review.py collect to produce it)"); + + JsonNode reviewedCases = new ObjectMapper().readTree(Files.readString(statusFile)).path("cases"); + List notApproved = new ArrayList<>(); + for (List group : caseFileGroupsByBaseId().values()) { + if (!"P0".equals(group.get(0).priority())) { + continue; + } + String baseId = group.get(0).baseId(); + String status = reviewedCases.path(baseId).path("status").asText(""); + if (!APPROVED_STATUS.equals(status)) { + notApproved.add(baseId + " (status: '" + (status.isEmpty() ? "unreviewed" : status) + "')"); + } + } + assertTrue( + notApproved.isEmpty(), + "the P0 review-approval rate must be 100% before a release, not approved yet: " + notApproved); + } + + /** The single review status that counts as approved ({@code tools/corpus_review.py}: 通过 / 有疑问 / 否决). */ + private static final String APPROVED_STATUS = "通过"; + + /** Locates {@code docs-local/review/review-status.json} by walking up from the working directory to the repo root. */ + private static @Nullable Path findReviewStatusFile() { + Path directory = Path.of(".").toAbsolutePath(); + while (directory != null) { + Path candidate = directory.resolve("docs-local").resolve("review").resolve("review-status.json"); + if (Files.isRegularFile(candidate)) { + return candidate; + } + directory = directory.getParent(); + } + return null; + } + + // ------------------------------------------------------------------ helpers + + /** All case records of the corpus: the case-file records plus the scenario step cases. */ + private static List allCaseRecords() { + List records = new ArrayList<>(CORPUS.cases()); + for (ScenarioCase scenario : CORPUS.scenarios()) { + for (ScenarioCase.ScenarioStep step : scenario.steps()) { + records.add(step.caseData()); + } + } + return records; + } + + /** Case-file records grouped by base id (the loader puts scenario step cases into the scenarios, not the cases). */ + private static Map> caseFileGroupsByBaseId() { + Map> groups = new LinkedHashMap<>(); + for (NegotiationCase testCase : CORPUS.cases()) { + groups.computeIfAbsent(testCase.baseId(), key -> new ArrayList<>()).add(testCase); + } + assertFalse(groups.isEmpty(), "the corpus must carry at least one case-file record"); + return groups; + } + + private static Set languagesOf(List group) { + Set languages = new LinkedHashSet<>(); + for (NegotiationCase expansion : group) { + assertTrue( + languages.add(expansion.language()), + "duplicate language expansion of " + expansion.errorPrefix()); + } + return languages; + } + + private static int countGoldenFixtures(String language) { + URL url = CorpusContractTest.class.getClassLoader().getResource("golden/" + language); + if (url == null) { + return 0; + } + try { + Path directory = Path.of(url.toURI()); + try (Stream files = Files.list(directory)) { + return (int) files.filter(file -> file.getFileName().toString().endsWith(".md")).count(); + } + } catch (URISyntaxException | IOException exception) { + throw new IllegalStateException("Failed to list the golden fixtures of " + language, exception); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusLoadException.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusLoadException.java new file mode 100644 index 00000000..531a9c1e --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusLoadException.java @@ -0,0 +1,34 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +/** + * Signals a defect in the negotiation test corpus: an unknown key, a dangling or circular {@code $ref}, a duplicate + * id, an incomplete expectation block or any other format violation. + * + *

      The loader fails fast — before any case runs — and the message always names the offending corpus file, the + * offending record id (when already readable) and the JSON path of the defect. + * + * @since 2026-08 + */ +public final class CorpusLoadException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * Creates a corpus load failure. + * + * @param message failure description naming the file, the record id and the JSON path + */ + public CorpusLoadException(String message) { + super(message); + } + + /** + * Creates a corpus load failure with an underlying cause. + * + * @param message failure description naming the file, the record id and the JSON path + * @param cause underlying read or parse failure + */ + public CorpusLoadException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSensitivitySelfTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSensitivitySelfTest.java new file mode 100644 index 00000000..e1373996 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSensitivitySelfTest.java @@ -0,0 +1,323 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.jspecify.annotations.Nullable; + +/** + * Meta-meta test of the corpus engine (design document §8.6, Q16): it guards against the deadliest failure mode of a + * data-driven suite — an always-green rubber-stamp engine whose bug lets every case pass silently. + * + *

      Every class of expectation assertion is proven sensitive on a minimal inline sample case: the sample runs green + * with its true expectation, then one flipped expectation value must make the {@link CaseEngine} red, with the case id + * and the JSON path of the flipped expectation in the failure message. The samples are built inline in Java and never + * touch the corpus files. + * + *

      The second guard, {@code engineMustNotCrashOnAnyCorpusCase}, walks the whole loaded corpus: the engine may report + * red (an AssertionError is the engine correctly rejecting a mismatch — that is what the suites turn into test + * failures), but it must never crash with an unexpected exception such as an NPE or a ClassCastException escaping the + * run. A crash is an engine bug; a red assertion is the engine working. + * + * @since 2026-08 + */ +class CorpusSensitivitySelfTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String ZH_CN = "zh-CN"; + + private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; + + private static final String INFORMATION_ACCEPT_REJECT_URI = + "Negotiation-T/information-negotiation/accept-reject/v1"; + + /** Extraction payload mapping to the typed content of the information accept golden fixture. */ + private static final String ACCEPT_PAYLOAD = + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan" + + " Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}]}"; + + /** Extraction payload whose mapped content lacks every required slot (fails with negotiation_slot_missing). */ + private static final String SLOTS_MISSING_PAYLOAD = "{\"relationship\":null}"; + + /** Semantic verdict payload of a successful validation carrying one business parameter. */ + private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"region\":" + + "\"Songshan Lake\"}}"; + + /** Semantic verdict payload rejecting the message with two slot errors. */ + private static final String SEMANTIC_REJECT_PAYLOAD_TWO_ERRORS = + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" + + "\"region\",\"code\":\"missing\",\"message\":\"The region parameter is missing.\"},{\"slot_name\":" + + "\"rate\",\"code\":\"out_of_range\",\"message\":\"The rate parameter is out of range.\"}]," + + "\"params\":{}}"; + + private final CaseEngine engine = new CaseEngine(); + + private final ObjectMapper mapper = new ObjectMapper(); + + private JsonNode schema; + + @BeforeEach + void readSchema() throws Exception { + schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"region\":{\"type\":\"string\"}}}"); + } + + // ------------------------------------------------------------------ flipped expectations must be red + + @Test + void flippedErrorCodeMustFailTheEngine() { + NegotiationCase trueExpectation = fromTextCase( + "FT-SENS-CODE", + SLOTS_MISSING_PAYLOAD, + failed("negotiation_slot_missing", 1, null, null)); + engine.run(trueExpectation); + + NegotiationCase flipped = fromTextCase( + "FT-SENS-CODE-FLIP", + SLOTS_MISSING_PAYLOAD, + failed("negotiation_invalid_input", 1, null, null)); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); + + assertTrue( + failure.getMessage().contains("FT-SENS-CODE-FLIP") && failure.getMessage().contains("$.expect.code"), + "a swapped error code must fail with the case id and the JSON path but was: " + failure.getMessage()); + } + + @Test + void flippedLlmCallCountMustFailTheEngine() { + engine.run(fromTextCase("FT-SENS-CALLS", ACCEPT_PAYLOAD, ok(1, "information_accept"))); + + AssertionError tooHigh = assertThrows( + AssertionError.class, + () -> engine.run(fromTextCase("FT-SENS-CALLS-HIGH", ACCEPT_PAYLOAD, ok(2, "information_accept")))); + assertTrue( + tooHigh.getMessage().contains("FT-SENS-CALLS-HIGH") + && tooHigh.getMessage().contains("$.expect.llmCalls"), + "llmCalls + 1 must fail but was: " + tooHigh.getMessage()); + + AssertionError tooLow = assertThrows( + AssertionError.class, + () -> engine.run(fromTextCase("FT-SENS-CALLS-LOW", ACCEPT_PAYLOAD, ok(0, "information_accept")))); + assertTrue( + tooLow.getMessage().contains("FT-SENS-CALLS-LOW") && tooLow.getMessage().contains("$.expect.llmCalls"), + "llmCalls - 1 must fail but was: " + tooLow.getMessage()); + } + + @Test + void flippedGoldenNameMustFailTheEngine() { + engine.run(fromTextCase("FT-SENS-GOLDEN", ACCEPT_PAYLOAD, ok(1, "information_accept"))); + + NegotiationCase flipped = + fromTextCase("FT-SENS-GOLDEN-FLIP", ACCEPT_PAYLOAD, ok(1, "information_reject")); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); + + assertTrue( + failure.getMessage().contains("FT-SENS-GOLDEN-FLIP") + && failure.getMessage().contains("$.expect.promptTextEqualsGolden"), + "a swapped golden fixture must fail but was: " + failure.getMessage()); + } + + @Test + void tamperedParamsMustFailTheEngine() { + engine.run(validateCase( + "VAL-SENS-PARAMS", Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "region", "Songshan Lake"))); + + NegotiationCase tampered = validateCase( + "VAL-SENS-PARAMS-FLIP", + Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "region", "Another Lake")); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(tampered)); + + assertTrue( + failure.getMessage().contains("VAL-SENS-PARAMS-FLIP") + && failure.getMessage().contains("$.expect.params") + && failure.getMessage().contains("Another Lake"), + "a tampered params value must fail but was: " + failure.getMessage()); + } + + @Test + void flippedOutcomeMustFailTheEngine() { + NegotiationCase successReadAsFailure = fromTextCase( + "FT-SENS-OUTCOME-SF", + ACCEPT_PAYLOAD, + failed("negotiation_invalid_input", 1, null, null)); + AssertionError successFlipped = assertThrows( + AssertionError.class, () -> engine.run(successReadAsFailure)); + assertTrue( + successFlipped.getMessage().contains("FT-SENS-OUTCOME-SF") + && successFlipped.getMessage().contains("$.expect.outcome"), + "a success run expected to fail must be red but was: " + successFlipped.getMessage()); + + NegotiationCase failureReadAsSuccess = fromTextCase( + "FT-SENS-OUTCOME-FS", + null, + ok(1, "information_accept")); + AssertionError failureFlipped = assertThrows(AssertionError.class, () -> engine.run(failureReadAsSuccess)); + assertTrue( + failureFlipped.getMessage().contains("FT-SENS-OUTCOME-FS") + && failureFlipped.getMessage().contains("$.expect.outcome"), + "a failing run expected to succeed must be red but was: " + failureFlipped.getMessage()); + } + + @Test + void flippedSlotErrorsMustFailTheEngine() { + List both = List.of( + new Expectation.SlotError("region", "missing"), new Expectation.SlotError("rate", "out_of_range")); + engine.run(validateCase("VAL-SENS-SLOTS", null, both)); + + NegotiationCase removed = validateCase( + "VAL-SENS-SLOTS-DEL", null, List.of(new Expectation.SlotError("region", "missing"))); + AssertionError removalFailure = assertThrows(AssertionError.class, () -> engine.run(removed)); + assertTrue( + removalFailure.getMessage().contains("VAL-SENS-SLOTS-DEL") + && removalFailure.getMessage().contains("$.expect.slotErrors"), + "a removed slot error must fail but was: " + removalFailure.getMessage()); + + List three = List.of( + new Expectation.SlotError("region", "missing"), + new Expectation.SlotError("rate", "out_of_range"), + new Expectation.SlotError("round", "out_of_range")); + NegotiationCase added = validateCase("VAL-SENS-SLOTS-ADD", null, three); + AssertionError additionFailure = assertThrows(AssertionError.class, () -> engine.run(added)); + assertTrue( + additionFailure.getMessage().contains("VAL-SENS-SLOTS-ADD") + && additionFailure.getMessage().contains("$.expect.slotErrors"), + "an added slot error must fail but was: " + additionFailure.getMessage()); + } + + // ------------------------------------------------------------------ engine crash guard + + @Test + void engineMustNotCrashOnAnyCorpusCase() { + LoadedCorpus corpus = CorpusSuites.loadCorpus(); + CaseEngine caseEngine = new CaseEngine(); + for (NegotiationCase testCase : corpus.cases()) { + try { + caseEngine.run(testCase); + } catch (AssertionError redIsTheEngineWorking) { + // A red assertion is the engine rejecting a mismatch; the suites turn it into a test failure. + } catch (Throwable crash) { + fail("The case engine crashed on " + testCase.errorPrefix() + ": " + crash); + } + } + ScenarioEngine scenarioEngine = new ScenarioEngine(); + for (ScenarioCase scenario : corpus.scenarios()) { + try { + scenarioEngine.runScenario(scenario); + } catch (AssertionError redIsTheEngineWorking) { + // See above: red is not a crash. + } catch (Throwable crash) { + fail("The scenario engine crashed on " + scenario.id() + ": " + crash); + } + } + } + + // ------------------------------------------------------------------ inline sample cases + + private NegotiationCase fromTextCase( + String id, @Nullable String payload, Expectation expect) { + LlmScript script = new LlmScript( + null, + List.of(payload == null + ? new LlmScriptStep.Fail(LlmFailMarker.NON_JSON) + : new LlmScriptStep.Payload(payload))); + return new NegotiationCase( + id + "/zh-CN", + id, + "from-text/sensitivity-probes.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + null, + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + script, + null, + null, + null, + expect); + } + + private NegotiationCase validateCase(String id, @Nullable Map params) { + return validateCase(id, params, null); + } + + private NegotiationCase validateCase( + String id, @Nullable Map params, @Nullable List slotErrors) { + Expectation expect; + if (params == null) { + expect = new Expectation( + false, + null, + "negotiation_semantic_rejected", + List.of(), + slotErrors == null ? List.of() : slotErrors, + 1, + null, + null, + Map.of(), + List.of(), + false); + } else { + expect = new Expectation( + true, null, null, List.of(), List.of(), 1, null, null, params, List.of(), false); + } + String payload = params == null ? SEMANTIC_REJECT_PAYLOAD_TWO_ERRORS : SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS; + return new NegotiationCase( + id + "/zh-CN", + id, + "validate/sensitivity-probes.json", + NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, + ZH_CN, + null, + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_PROPOSE_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(payload))), + new PromptSource.Golden("information_propose"), + schema, + null, + expect); + } + + private static Expectation ok(@Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden) { + return new Expectation( + true, null, null, List.of(), List.of(), llmCalls, promptTextEqualsGolden, null, Map.of(), List.of(), + false); + } + + private static Expectation failed( + @Nullable String code, + @Nullable Integer llmCalls, + @Nullable Map params, + @Nullable List slotErrors) { + return new Expectation( + false, + null, + code, + List.of(), + slotErrors == null ? List.of() : slotErrors, + llmCalls, + null, + null, + params == null ? Map.of() : params, + List.of(), + false); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSuites.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSuites.java new file mode 100644 index 00000000..38443155 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSuites.java @@ -0,0 +1,126 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.DynamicTest; + +/** + * Shared machinery of the four corpus suite classes (design document §3.4 item 4, Q1). + * + *

      Every suite is a JUnit {@code @TestFactory} that loads the corpus from the test resources once, keeps the records of + * its family directory, and turns each expanded case or scenario into one {@link DynamicTest} whose display name carries + * the corpus id. A family directory that is absent or empty yields an empty stream (with a note on stdout), so the suites + * stay green while the later corpus batches land file by file. + * + *

      The {@code -Dcase.filter=} system property selects a subset at run time — for example + * {@code mvn test -Dtest='FromTextCorpusSuiteTest' -Dcase.filter=FT-RETRY-*}. The glob matches either the expanded id + * ({@code FT-RETRY-02/zh-CN}) or the base record id ({@code FT-RETRY-02}); {@code *} and {@code ?} are wildcards that + * also match across the language suffix. + * + * @since 2026-08 + */ +final class CorpusSuites { + + /** Classpath root of the negotiation test corpus. */ + static final String CORPUS_RESOURCE = "negotiation-cases"; + + private static final String CASE_FILTER = System.getProperty("case.filter", "").trim(); + + private CorpusSuites() {} + + // ------------------------------------------------------------------ corpus access + + static LoadedCorpus loadCorpus() { + return NegotiationCaseLoader.loadFromClasspath(CORPUS_RESOURCE); + } + + // ------------------------------------------------------------------ dynamic test assembly + + /** + * Builds one dynamic test per expanded case of the given family directory. + * + * @param directory family directory under the corpus root, such as {@code from-text} + * @return one dynamic test per selected case, empty when the directory carries no case + */ + static Stream caseTests(String directory) { + LoadedCorpus corpus = loadCorpus(); + List tests = new ArrayList<>(); + CaseEngine engine = new CaseEngine(); + for (NegotiationCase testCase : corpus.cases()) { + if (!testCase.sourceFile().startsWith(directory + "/")) { + continue; + } + if (!selected(testCase.id(), testCase.baseId())) { + continue; + } + tests.add(DynamicTest.dynamicTest( + displayName(testCase.id(), testCase.summary()), () -> engine.run(testCase))); + } + noteEmpty(directory, tests.size()); + return tests.stream(); + } + + /** + * Builds one dynamic test per expanded scenario of the given family directory. + * + * @param directory family directory under the corpus root, such as {@code scenarios} + * @return one dynamic test per selected scenario, empty when the directory carries no scenario + */ + static Stream scenarioTests(String directory) { + LoadedCorpus corpus = loadCorpus(); + List tests = new ArrayList<>(); + ScenarioEngine engine = new ScenarioEngine(); + for (ScenarioCase scenario : corpus.scenarios()) { + if (!scenario.sourceFile().startsWith(directory + "/")) { + continue; + } + if (!selected(scenario.id(), scenario.baseId())) { + continue; + } + tests.add(DynamicTest.dynamicTest( + displayName(scenario.id(), scenario.summary()), () -> engine.runScenario(scenario))); + } + noteEmpty(directory, tests.size()); + return tests.stream(); + } + + private static String displayName(String id, @Nullable String summary) { + return summary == null ? id : id + " — " + summary; + } + + private static void noteEmpty(String directory, int size) { + if (size == 0) { + System.out.println( + "[corpus] no record under '" + directory + "/' matched (directory missing, empty, or filtered by" + + " -Dcase.filter='" + CASE_FILTER + "'); the suite contributes no test."); + } + } + + // ------------------------------------------------------------------ case filter + + private static boolean selected(String id, String baseId) { + if (CASE_FILTER.isEmpty()) { + return true; + } + return globMatches(CASE_FILTER, id) || globMatches(CASE_FILTER, baseId); + } + + private static boolean globMatches(String glob, String value) { + return value.matches(globToRegex(glob)); + } + + private static String globToRegex(String glob) { + StringBuilder regex = new StringBuilder(); + for (char character : glob.toCharArray()) { + switch (character) { + case '*' -> regex.append(".*"); + case '?' -> regex.append('.'); + default -> regex.append(Pattern.quote(String.valueOf(character))); + } + } + return regex.toString(); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Expectation.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Expectation.java new file mode 100644 index 00000000..0ec2dd1b --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Expectation.java @@ -0,0 +1,63 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * The validated expectation block of one corpus record, already checked for completeness by the loader. + * + *

      The success and failure shapes are a discriminated union in the corpus JSON; on the loaded record both live on + * one object and the loader guarantees the field combinations that the outcome allows: a failure expectation always + * carries an exception name or an error code, and success-only fields never appear on a failure expectation or the + * other way round. + * + * @param success true for the success outcome, false for the failure outcome + * @param exception expected exception simple name, failure expectations only + * @param code expected error code, failure expectations only + * @param messageContains substrings the failure message must contain, failure expectations only + * @param slotErrors expected slot errors as slot and code pairs, failure expectations only + * @param llmCalls expected exact LLM call count, or null when the record does not pin it + * @param promptTextEqualsGolden golden fixture name the generated prompt text must equal, success expectations only + * @param metadata expected metadata entries, success expectations only + * @param params expected merged parameter map, success expectations only + * @param contracts behavior contract names to assert, both outcomes + * @param differential true when the engine runs the from-text and from-data double assertion, success expectations + * @since 2026-08 + */ +public record Expectation( + boolean success, + @Nullable String exception, + @Nullable String code, + List messageContains, + List slotErrors, + @Nullable Integer llmCalls, + @Nullable String promptTextEqualsGolden, + @Nullable Metadata metadata, + Map params, + List contracts, + boolean differential) { + + public Expectation { + messageContains = List.copyOf(messageContains); + slotErrors = List.copyOf(slotErrors); + params = Map.copyOf(params); + contracts = List.copyOf(contracts); + } + + /** + * One expected slot error as a slot and code pair. + * + * @param slot slot name such as {@code round} + * @param code expected slot error code such as {@code out_of_range} + */ + public record SlotError(String slot, String code) {} + + /** + * The expected metadata entries of a success expectation. + * + * @param templateUriEcho expected template URI metadata echo, or null when not asserted + * @param contextEcho true when the negotiation context must travel in the metadata, or null when not asserted + */ + public record Metadata(@Nullable String templateUriEcho, @Nullable Boolean contextEcho) {} +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/FromDataCorpusSuiteTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/FromDataCorpusSuiteTest.java new file mode 100644 index 00000000..89e81d1c --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/FromDataCorpusSuiteTest.java @@ -0,0 +1,26 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +/** + * Data-driven suite of the from-data family corpus: every case under {@code negotiation-cases/from-data/} becomes one + * dynamic test executed by the {@link CaseEngine} against the production negotiation content wiring. + * + *

      The seed batch carries {@code from-data/happy.json} (FD-HAPPY: deterministic accept generation with a zero LLM call + * proof). The later batch (FD-PROG, the absorbed 22-row programming-error matrix) lands as a further file of the same + * directory without any Java change. + * + *

      Run a subset with {@code -Dcase.filter=FD-*} (glob against the case id, design document Q1). + * + * @since 2026-08 + */ +class FromDataCorpusSuiteTest { + + /** Executes every expanded from-data case of the corpus. */ + @TestFactory + Stream fromDataCorpusCases() { + return CorpusSuites.caseTests("from-data"); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/FromTextCorpusSuiteTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/FromTextCorpusSuiteTest.java new file mode 100644 index 00000000..895c8270 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/FromTextCorpusSuiteTest.java @@ -0,0 +1,27 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +/** + * Data-driven suite of the from-text family corpus: every case under {@code negotiation-cases/from-text/} becomes one + * dynamic test executed by the {@link CaseEngine} against the production negotiation content wiring. + * + *

      The seed batch carries {@code from-text/happy.json} (FT-HAPPY: propose with the differential double run, accept with + * the conclusion-literal contract) and {@code from-text/template-resolution.json} (FT-TPL: the injected + * template-not-found matrix). The later batches (FT-PROG, FT-EXTRACT, FT-RETRY) land as further files of the same + * directory without any Java change. + * + *

      Run a subset with {@code -Dcase.filter=FT-RETRY-*} (glob against the case id, design document Q1). + * + * @since 2026-08 + */ +class FromTextCorpusSuiteTest { + + /** Executes every expanded from-text case of the corpus. */ + @TestFactory + Stream fromTextCorpusCases() { + return CorpusSuites.caseTests("from-text"); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmFailMarker.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmFailMarker.java new file mode 100644 index 00000000..89c4a6fc --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmFailMarker.java @@ -0,0 +1,63 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import org.jspecify.annotations.Nullable; + +/** + * The six scripted LLM failure markers of a corpus script step. + * + *

      A {@code $fail} marker replaces one scripted LLM answer with a failure the later scripted client replays: + * infrastructure failures, degenerate responses or the assertion failure that proves a zero-call run. The marker + * names are part of the corpus format contract. + * + * @since 2026-08 + */ +public enum LlmFailMarker { + + /** Throws a runtime (infrastructure) exception on the call. */ + RUNTIME_EXCEPTION("runtime-exception"), + + /** Answers an LLM error response on the call. */ + LLM_ERROR("llm-error"), + + /** Returns a null response on the call. */ + NULL_RESPONSE("null-response"), + + /** Returns a response with a blank content payload on the call. */ + BLANK_CONTENT("blank-content"), + + /** Returns a response whose content is not parseable JSON on the call. */ + NON_JSON("non-json"), + + /** Throws an assertion failure; the zero-call proof of the from-data and differential runs. */ + ASSERTION("assertion"); + + private final String jsonName; + + LlmFailMarker(String jsonName) { + this.jsonName = jsonName; + } + + /** + * Returns the corpus JSON name of this marker. + * + * @return marker name such as {@code non-json} + */ + public String jsonName() { + return jsonName; + } + + /** + * Resolves a corpus JSON name into a failure marker. + * + * @param jsonName corpus JSON name such as {@code non-json} + * @return the marker, or null when the name matches none of the six markers + */ + public static @Nullable LlmFailMarker fromJsonName(@Nullable String jsonName) { + for (LlmFailMarker marker : values()) { + if (marker.jsonName.equals(jsonName)) { + return marker; + } + } + return null; + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmScript.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmScript.java new file mode 100644 index 00000000..d61b9f20 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmScript.java @@ -0,0 +1,20 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * The scripted LLM behavior of one corpus record. + * + * @param maxAttempts builder-level retry limit, or null for the builder default; clamping assertions stay in the + * handwritten suites + * @param steps scripted answers consumed strictly step by step; an exhausted script fails the run instead of + * repeating the last answer + * @since 2026-08 + */ +public record LlmScript(@Nullable Integer maxAttempts, List steps) { + + public LlmScript { + steps = List.copyOf(steps); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmScriptStep.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmScriptStep.java new file mode 100644 index 00000000..8b3a430e --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmScriptStep.java @@ -0,0 +1,27 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +/** + * One scripted answer of an LLM script. + * + *

      After loader resolution every step is either a literal payload text (an inline corpus string or a resolved + * {@code responses/} reference) or a failure marker. The tri-state of the corpus JSON — {@code $ref} object, + * {@code $fail} object, literal string — collapses into exactly these two shapes. + * + * @since 2026-08 + */ +public sealed interface LlmScriptStep { + + /** + * A scripted answer carrying a literal payload text. + * + * @param json payload text handed to the scripted LLM client + */ + record Payload(String json) implements LlmScriptStep {} + + /** + * A scripted answer that fails with the given marker. + * + * @param marker failure marker the scripted LLM client replays + */ + record Fail(LlmFailMarker marker) implements LlmScriptStep {} +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LoadedCorpus.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LoadedCorpus.java new file mode 100644 index 00000000..7b8830cc --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LoadedCorpus.java @@ -0,0 +1,42 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The whole negotiation test corpus loaded from one corpus root. + * + *

      The loader fails fast on any format violation, so a successfully loaded corpus is internally consistent: ids are + * globally unique, every {@code $ref} is resolved, every expectation block is complete and every record is expanded + * per language. + * + * @param root corpus root directory the records were loaded from + * @param cases all case records of the case files, expanded per language + * @param scenarios all scenario records of the scenario files, expanded per language + * @param sharedResponses named payload texts of {@code shared/llm-responses.json} + * @param sharedSchemas named JSON Schema variants of {@code shared/schemas.json} + * @since 2026-08 + */ +public record LoadedCorpus( + Path root, + List cases, + List scenarios, + Map sharedResponses, + Map sharedSchemas) { + + public LoadedCorpus { + Objects.requireNonNull(root, "root"); + Objects.requireNonNull(cases, "cases"); + Objects.requireNonNull(scenarios, "scenarios"); + Objects.requireNonNull(sharedResponses, "sharedResponses"); + Objects.requireNonNull(sharedSchemas, "sharedSchemas"); + root = root.normalize(); + cases = List.copyOf(cases); + scenarios = List.copyOf(scenarios); + sharedResponses = Map.copyOf(sharedResponses); + sharedSchemas = Map.copyOf(sharedSchemas); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationApi.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationApi.java new file mode 100644 index 00000000..85f5622e --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationApi.java @@ -0,0 +1,105 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import org.jspecify.annotations.Nullable; + +/** + * The twelve {@code NegotiationContentService} APIs the corpus exercises. + * + *

      The corpus references APIs by their Java method name; the later case engine dispatches on this enum, so a + * misspelled API name fails at corpus load time instead of silently skipping a case. + * + * @since 2026-08 + */ +public enum NegotiationApi { + + /** {@code generateProposeFromText}: free text to a propose message through one LLM extraction. */ + GENERATE_PROPOSE_FROM_TEXT("generateProposeFromText", Family.FROM_TEXT), + + /** {@code generateAcceptFromText}: free text to an accept message through one LLM extraction. */ + GENERATE_ACCEPT_FROM_TEXT("generateAcceptFromText", Family.FROM_TEXT), + + /** {@code generateRejectFromText}: free text to a reject message through one LLM extraction. */ + GENERATE_REJECT_FROM_TEXT("generateRejectFromText", Family.FROM_TEXT), + + /** {@code generateAbortFromText}: free text to an abort message through one LLM extraction. */ + GENERATE_ABORT_FROM_TEXT("generateAbortFromText", Family.FROM_TEXT), + + /** {@code generateProposeFromData}: typed data to a propose message, deterministically. */ + GENERATE_PROPOSE_FROM_DATA("generateProposeFromData", Family.FROM_DATA), + + /** {@code generateAcceptFromData}: typed data to an accept message, deterministically. */ + GENERATE_ACCEPT_FROM_DATA("generateAcceptFromData", Family.FROM_DATA), + + /** {@code generateRejectFromData}: typed data to a reject message, deterministically. */ + GENERATE_REJECT_FROM_DATA("generateRejectFromData", Family.FROM_DATA), + + /** {@code generateAbortFromData}: typed data to an abort message, deterministically. */ + GENERATE_ABORT_FROM_DATA("generateAbortFromData", Family.FROM_DATA), + + /** {@code validateProposePromptAndDataFilling}: rule gate plus semantic validation of a propose message. */ + VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING("validateProposePromptAndDataFilling", Family.VALIDATE), + + /** {@code validateAcceptPromptAndDataFilling}: rule gate plus semantic validation of an accept message. */ + VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING("validateAcceptPromptAndDataFilling", Family.VALIDATE), + + /** {@code validateRejectPromptAndDataFilling}: rule gate plus semantic validation of a reject message. */ + VALIDATE_REJECT_PROMPT_AND_DATA_FILLING("validateRejectPromptAndDataFilling", Family.VALIDATE), + + /** {@code validateAbortPromptAndDataFilling}: rule gate plus semantic validation of an abort message. */ + VALIDATE_ABORT_PROMPT_AND_DATA_FILLING("validateAbortPromptAndDataFilling", Family.VALIDATE); + + private final String jsonName; + + private final Family family; + + NegotiationApi(String jsonName, Family family) { + this.jsonName = jsonName; + this.family = family; + } + + /** + * Returns the corpus JSON name of this API, which equals the {@code NegotiationContentService} method name. + * + * @return method name such as {@code generateAcceptFromText} + */ + public String jsonName() { + return jsonName; + } + + /** + * Returns the API family this API belongs to. + * + * @return from-text, from-data or validate family + */ + public Family family() { + return family; + } + + /** + * Resolves a corpus JSON name into an API. + * + * @param jsonName corpus JSON name such as {@code generateAcceptFromText} + * @return the API, or null when the name matches none of the twelve methods + */ + public static @Nullable NegotiationApi fromJsonName(@Nullable String jsonName) { + for (NegotiationApi api : values()) { + if (api.jsonName.equals(jsonName)) { + return api; + } + } + return null; + } + + /** The three API families of the negotiation content service. */ + public enum Family { + + /** The four from-text generation methods with exactly one LLM extraction call. */ + FROM_TEXT, + + /** The four from-data generation methods without any LLM call. */ + FROM_DATA, + + /** The four validation methods with a rule gate and one semantic LLM call. */ + VALIDATE + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCase.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCase.java new file mode 100644 index 00000000..9e587d1b --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCase.java @@ -0,0 +1,72 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.util.List; +import java.util.Objects; +import com.fasterxml.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; + +/** + * One corpus case record expanded for exactly one language. + * + *

      The loader expands every corpus record once per entry of its {@code languages} array, appending {@code /} + * to the id (Q3). References are already resolved: the LLM script carries literal payload texts or failure markers and + * the schema is the addressed JSON Schema node. A scenario step is also carried as a {@code NegotiationCase} with the + * derived id {@code /#step-}. + * + * @param id expanded id such as {@code FT-RETRY-02/zh-CN} or {@code SC-INFO-02/en-US#step-1} + * @param baseId corpus record id before the language expansion, such as {@code FT-RETRY-02} + * @param sourceFile corpus file path relative to the corpus root, such as {@code from-text/retry.json} + * @param api the {@code NegotiationContentService} API under test + * @param language language this expansion renders for + * @param priority P0, P1 or P2, or null when the record does not state one + * @param tags free tags, empty when the record states none + * @param summary business-facing one-line summary, or null when the record states none + * @param context inline negotiation context spec, or null for the null-context probes + * @param templateUri template URI, or null when the case probes a null URI + * @param inputText free-text input of this language, or null for the from-data and validate families + * @param inputData language-independent typed input data of the from-data family and the differential runs + * @param llm resolved scripted LLM behavior, or null when the record scripts none + * @param prompt resolved prompt source of the validate family, or null otherwise + * @param schema resolved JSON Schema of the validate family, or null otherwise + * @param inject harness injection hook name, or null when the case injects none + * @param expect validated expectation block + * @since 2026-08 + */ +public record NegotiationCase( + String id, + String baseId, + String sourceFile, + NegotiationApi api, + String language, + @Nullable String priority, + List tags, + @Nullable String summary, + @Nullable ContextSpec context, + @Nullable String templateUri, + @Nullable String inputText, + @Nullable JsonNode inputData, + @Nullable LlmScript llm, + @Nullable PromptSource prompt, + @Nullable JsonNode schema, + @Nullable String inject, + Expectation expect) { + + public NegotiationCase { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(baseId, "baseId"); + Objects.requireNonNull(sourceFile, "sourceFile"); + Objects.requireNonNull(api, "api"); + Objects.requireNonNull(language, "language"); + Objects.requireNonNull(expect, "expect"); + tags = List.copyOf(tags); + } + + /** + * Returns the JSON path prefix for failure messages of this case, naming the file and the expanded id. + * + * @return prefix such as {@code from-text/retry.json [FT-RETRY-02/zh-CN]} + */ + public String errorPrefix() { + return sourceFile + " [" + id + "]"; + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoader.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoader.java new file mode 100644 index 00000000..7df2cdf4 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoader.java @@ -0,0 +1,962 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; +import org.jspecify.annotations.Nullable; + +/** + * Strict loader of the negotiation test corpus. + * + *

      The corpus lives under one root directory (the default is {@code src/test/resources/negotiation-cases}): case + * files and scenario files are JSON arrays of records, and the {@code shared/} directory carries the two shared + * reference files {@code llm-responses.json} (named payload texts, addressed through the {@code responses/} prefix) + * and {@code schemas.json} (named JSON Schema variants, addressed through the {@code schemas/} prefix). The + * {@code corpus-schema.json} format definition sits next to the records and is skipped by the loader. + * + *

      Loading fails fast — before any case runs — on every format violation, and every error names the corpus file, + * the offending record id and the JSON path of the defect: + * + *

        + *
      • unknown keys (strict Jackson with {@code FAIL_ON_UNKNOWN_PROPERTIES} — a typo is an error, never a silent skip), + *
      • dangling, out-of-scope, nested or circular {@code $ref} references, + *
      • duplicate record ids (before and after the language expansion), + *
      • incomplete expectation blocks (a failure expectation must name the exception or the error code), + *
      • unknown API names, {@code $fail} markers, languages, priorities, terminal conditions or inject hooks, + *
      • scenario steps that are not numbered consecutively from 1. + *
      + * + *

      On success every record is expanded once per entry of its {@code languages} array (Q3), the expanded id appending + * {@code /}, and references are resolved into literal payload texts and schema nodes, so the later case + * engine never sees a reference. + * + * @since 2026-08 + */ +public final class NegotiationCaseLoader { + + private static final Set SUPPORTED_LANGUAGES = Set.of("zh-CN", "en-US"); + + private static final Set SUPPORTED_PRIORITIES = Set.of("P0", "P1", "P2"); + + private static final Set SUPPORTED_TERMINAL_CONDITIONS = Set.of("accept", "reject", "abort", "exhausted"); + + private static final String RESPONSES_PREFIX = "responses/"; + + private static final String SCHEMAS_PREFIX = "schemas/"; + + private static final String RESPONSES_FILE = "shared/llm-responses.json"; + + private static final String SCHEMAS_FILE = "shared/schemas.json"; + + private static final ObjectMapper MAPPER = + new ObjectMapper().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + + private NegotiationCaseLoader() {} + + /** + * Loads the whole corpus from a corpus root directory: every {@code .json} file except {@code corpus-schema.json} + * and the {@code shared/} reference files. + * + * @param corpusRoot existing corpus root directory + * @return the loaded corpus with all cases and scenarios expanded per language + * @throws CorpusLoadException when any corpus record violates the format + */ + public static LoadedCorpus load(Path corpusRoot) { + Objects.requireNonNull(corpusRoot, "corpusRoot"); + if (!Files.isDirectory(corpusRoot)) { + throw new CorpusLoadException("The corpus root is not an existing directory: " + corpusRoot); + } + Path normalizedRoot = corpusRoot.normalize(); + SharedRefs shared = loadShared(normalizedRoot.resolve("shared")); + Map seenBaseIds = new LinkedHashMap<>(); + Map seenExpandedIds = new LinkedHashMap<>(); + List cases = new ArrayList<>(); + List scenarios = new ArrayList<>(); + for (Path file : listCorpusFiles(normalizedRoot)) { + String relative = normalizedRoot.relativize(file.normalize()).toString().replace('\\', '/'); + parseFile(relative, file, shared, seenBaseIds, seenExpandedIds, cases, scenarios); + } + return new LoadedCorpus( + normalizedRoot, + cases, + scenarios, + toStringMap(shared.responses()), + toNodeMap(shared.schemas())); + } + + /** + * Loads the whole corpus from a classpath directory, such as {@code negotiation-cases} of the test resources. + * + * @param resourcePath classpath path of the corpus root + * @return the loaded corpus with all cases and scenarios expanded per language + * @throws CorpusLoadException when the resource is missing or any corpus record violates the format + */ + public static LoadedCorpus loadFromClasspath(String resourcePath) { + Objects.requireNonNull(resourcePath, "resourcePath"); + URL url = NegotiationCaseLoader.class.getClassLoader().getResource(resourcePath); + if (url == null) { + throw new CorpusLoadException("The corpus root was not found on the classpath: " + resourcePath); + } + try { + return load(Path.of(url.toURI())); + } catch (URISyntaxException exception) { + throw new CorpusLoadException("The corpus root resource cannot be addressed as a path: " + url, exception); + } + } + + // ------------------------------------------------------------------ file traversal + + private static List listCorpusFiles(Path corpusRoot) { + Path sharedDir = corpusRoot.resolve("shared"); + try (Stream paths = Files.walk(corpusRoot)) { + return paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".json")) + .filter(path -> !path.normalize().startsWith(sharedDir)) + .filter(path -> !"corpus-schema.json".equals(path.getFileName().toString())) + .sorted() + .toList(); + } catch (IOException exception) { + throw new CorpusLoadException("Failed to walk the corpus root: " + corpusRoot, exception); + } + } + + private record SharedRefs(JsonNode responses, JsonNode schemas) {} + + private static SharedRefs loadShared(Path sharedDir) { + JsonNode responses = readSharedFile(sharedDir.resolve("llm-responses.json")); + JsonNode schemas = readSharedFile(sharedDir.resolve("schemas.json")); + responses.fieldNames().forEachRemaining(name -> { + if (!responses.get(name).isTextual()) { + throw error(RESPONSES_FILE, null, "$." + name, "every shared response payload must be a JSON string"); + } + }); + schemas.fieldNames().forEachRemaining(name -> { + if (!schemas.get(name).isObject()) { + throw error(SCHEMAS_FILE, null, "$." + name, "every shared schema variant must be a JSON object"); + } + }); + return new SharedRefs(responses, schemas); + } + + private static JsonNode readSharedFile(Path file) { + if (!Files.isRegularFile(file)) { + return MAPPER.createObjectNode(); + } + try { + JsonNode root = MAPPER.readTree(file.toFile()); + if (!root.isObject()) { + throw error( + "shared/" + file.getFileName(), + null, + "$", + "expected a JSON object mapping names to shared values"); + } + return root; + } catch (IOException exception) { + throw new CorpusLoadException("Failed to read the shared corpus file: " + file, exception); + } + } + + private static void parseFile( + String file, + Path path, + SharedRefs shared, + Map seenBaseIds, + Map seenExpandedIds, + List cases, + List scenarios) { + JsonNode root; + try { + root = MAPPER.readTree(path.toFile()); + } catch (IOException exception) { + throw new CorpusLoadException(file + ": failed to read the corpus file", exception); + } + if (!root.isArray()) { + throw error(file, null, "$", "expected a JSON array of corpus records"); + } + for (int i = 0; i < root.size(); i++) { + JsonNode node = root.get(i); + String recordPath = "$[" + i + "]"; + if (!node.isObject()) { + throw error(file, null, recordPath, "expected a JSON object record"); + } + if (!node.path("id").isTextual()) { + throw error(file, null, recordPath + ".id", "missing required field 'id'"); + } + String id = node.get("id").asText(); + if (node.has("steps")) { + parseScenario(file, id, recordPath, node, shared, seenBaseIds, seenExpandedIds, scenarios); + } else { + parseCase(file, id, recordPath, node, shared, seenBaseIds, seenExpandedIds, cases); + } + } + } + + // ------------------------------------------------------------------ record parsing + + private static void parseCase( + String file, + String id, + String path, + JsonNode node, + SharedRefs shared, + Map seenBaseIds, + Map seenExpandedIds, + List cases) { + RawCase raw = bind(node, RawCase.class, file, id, path); + List languages = validateLanguages(raw.languages(), file, id, path); + claimBaseId(id, file, path, seenBaseIds); + String priority = validatePriority(raw.priority(), file, id, path); + List tags = raw.tags() == null ? List.of() : List.copyOf(raw.tags()); + ResolvedFields resolved = resolveCaseFields( + file, + id, + path, + languages, + raw.api(), + raw.context(), + raw.templateUri(), + raw.input(), + raw.llm(), + raw.prompt(), + raw.schema(), + raw.inject(), + raw.expect(), + shared); + for (String language : languages) { + String expandedId = id + "/" + language; + claimExpandedId(expandedId, file, id, path, seenExpandedIds); + cases.add(new NegotiationCase( + expandedId, + id, + file, + resolved.api(), + language, + priority, + tags, + raw.summary(), + resolved.context(), + resolved.templateUri(), + resolved.inputText().get(language), + resolved.inputData(), + resolved.llm(), + resolved.prompt(), + resolved.schema(), + resolved.inject(), + resolved.expect())); + } + } + + private static void parseScenario( + String file, + String id, + String path, + JsonNode node, + SharedRefs shared, + Map seenBaseIds, + Map seenExpandedIds, + List scenarios) { + RawScenario raw = bind(node, RawScenario.class, file, id, path); + List languages = validateLanguages(raw.languages(), file, id, path); + claimBaseId(id, file, path, seenBaseIds); + List roles = raw.roles() == null ? List.of() : List.copyOf(raw.roles()); + if (roles.stream().anyMatch(String::isBlank)) { + throw error(file, id, path + ".roles", "role names must not be blank"); + } + if (raw.steps() == null || raw.steps().isEmpty()) { + throw error(file, id, path + ".steps", "missing required field 'steps' (at least one step)"); + } + for (int i = 0; i < raw.steps().size(); i++) { + Integer stepNumber = raw.steps().get(i).step(); + if (stepNumber == null || stepNumber != i + 1) { + throw error( + file, + id, + path + ".steps[" + i + "].step", + "scenario steps must be numbered consecutively from 1, but step " + (i + 1) + " carries '" + + stepNumber + "'"); + } + } + ScenarioCase.ExpectFlow expectFlow = buildExpectFlow(raw.expectFlow(), file, id, path); + for (String language : languages) { + String expandedId = id + "/" + language; + claimExpandedId(expandedId, file, id, path, seenExpandedIds); + List steps = new ArrayList<>(); + for (RawStep rawStep : raw.steps()) { + ResolvedFields resolved = resolveCaseFields( + file, + id, + path + ".steps[" + (rawStep.step() - 1) + "]", + languages, + rawStep.api(), + rawStep.context(), + rawStep.templateUri(), + rawStep.input(), + rawStep.llm(), + rawStep.prompt(), + rawStep.schema(), + rawStep.inject(), + rawStep.expect(), + shared); + NegotiationCase stepCase = new NegotiationCase( + expandedId + "#step-" + rawStep.step(), + id, + file, + resolved.api(), + language, + null, + List.of(), + null, + resolved.context(), + resolved.templateUri(), + resolved.inputText().get(language), + resolved.inputData(), + resolved.llm(), + resolved.prompt(), + resolved.schema(), + resolved.inject(), + resolved.expect()); + steps.add(new ScenarioCase.ScenarioStep(rawStep.step(), rawStep.role(), stepCase)); + } + scenarios.add(new ScenarioCase(expandedId, id, file, language, raw.summary(), roles, steps, expectFlow)); + } + } + + private record ResolvedFields( + NegotiationApi api, + @Nullable ContextSpec context, + @Nullable String templateUri, + Map inputText, + @Nullable JsonNode inputData, + @Nullable LlmScript llm, + @Nullable PromptSource prompt, + @Nullable JsonNode schema, + @Nullable String inject, + Expectation expect) {} + + private static ResolvedFields resolveCaseFields( + String file, + String id, + String path, + List languages, + @Nullable String apiName, + @Nullable RawContext context, + @Nullable String templateUri, + @Nullable RawInput input, + @Nullable RawLlm llm, + @Nullable RawPrompt prompt, + @Nullable JsonNode schema, + @Nullable String inject, + @Nullable RawExpect expect, + SharedRefs shared) { + NegotiationApi api = NegotiationApi.fromJsonName(apiName); + if (api == null) { + throw error( + file, + id, + path + ".api", + "unknown api '" + apiName + "' (known apis: " + knownApiNames() + ")"); + } + ContextSpec contextSpec = validateContext(context, file, id, path); + if (templateUri != null && templateUri.isBlank()) { + throw error(file, id, path + ".templateUri", "the template URI must not be blank"); + } + Map inputText = Map.of(); + JsonNode inputData = null; + if (input != null) { + if (input.text() != null) { + inputText = input.text(); + for (String language : languages) { + if (!inputText.containsKey(language)) { + throw error( + file, + id, + path + ".input.text", + "missing the '" + language + + "' text entry (the input must cover every language of the record)"); + } + } + } + if (input.data() != null && !input.data().isNull()) { + if (!input.data().isObject()) { + throw error(file, id, path + ".input.data", "the typed input data must be a JSON object"); + } + inputData = input.data(); + } + } + LlmScript llmScript = null; + if (llm != null) { + if (llm.maxAttempts() != null && llm.maxAttempts() < 1) { + throw error(file, id, path + ".llm.maxAttempts", "maxAttempts must be at least 1"); + } + if (llm.script() == null || llm.script().isEmpty()) { + throw error(file, id, path + ".llm.script", "missing required field 'script' (at least one step)"); + } + llmScript = + new LlmScript(llm.maxAttempts(), resolveScript(llm.script(), file, id, path + ".llm.script", shared)); + } + PromptSource promptSource = buildPromptSource(prompt, file, id, path); + JsonNode schemaNode = resolveSchema(schema, file, id, path, shared); + if (inject != null + && !"failingTemplateLoader".equals(inject) + && !"failingSemanticValidator".equals(inject)) { + throw error( + file, + id, + path + ".inject", + "unknown inject hook '" + inject + + "' (known hooks: failingTemplateLoader, failingSemanticValidator)"); + } + Expectation expectation = buildExpectation(expect, file, id, path); + return new ResolvedFields( + api, + contextSpec, + templateUri, + inputText, + inputData, + llmScript, + promptSource, + schemaNode, + inject, + expectation); + } + + // ------------------------------------------------------------------ field validation + + private static List validateLanguages(@Nullable List languages, String file, String id, String path) { + if (languages == null || languages.isEmpty()) { + throw error(file, id, path + ".languages", "missing required field 'languages' (at least one language)"); + } + Set seen = new LinkedHashSet<>(); + for (String language : languages) { + if (!seen.add(language)) { + throw error(file, id, path + ".languages", "duplicate language '" + language + "'"); + } + if (!SUPPORTED_LANGUAGES.contains(language)) { + throw error( + file, + id, + path + ".languages", + "unsupported language '" + language + "' (supported languages: zh-CN, en-US)"); + } + } + return List.copyOf(languages); + } + + private static @Nullable String validatePriority(@Nullable String priority, String file, String id, String path) { + if (priority != null && !SUPPORTED_PRIORITIES.contains(priority)) { + throw error(file, id, path + ".priority", "unknown priority '" + priority + "' (known priorities: P0, P1, P2)"); + } + return priority; + } + + private static @Nullable ContextSpec validateContext( + @Nullable RawContext context, String file, String id, String path) { + if (context == null) { + return null; + } + if (context.id() == null || context.id().isBlank()) { + throw error(file, id, path + ".context.id", "the context id must not be blank"); + } + if (context.round() == null) { + throw error(file, id, path + ".context.round", "missing required field 'round'"); + } + if (context.maxRounds() == null) { + throw error(file, id, path + ".context.maxRounds", "missing required field 'maxRounds'"); + } + if (context.round() < 1 || context.maxRounds() < 1) { + throw error(file, id, path + ".context", "the context round and maxRounds must be at least 1"); + } + return new ContextSpec(context.id(), context.round(), context.maxRounds()); + } + + private static @Nullable PromptSource buildPromptSource( + @Nullable RawPrompt prompt, String file, String id, String path) { + if (prompt == null) { + return null; + } + int declared = 0; + if (prompt.golden() != null) { + declared++; + } + if (prompt.text() != null) { + declared++; + } + if (prompt.fromStep() != null) { + declared++; + } + if (declared != 1) { + throw error( + file, + id, + path + ".prompt", + "the prompt must declare exactly one of golden, text or fromStep but declares " + declared); + } + if (prompt.golden() != null) { + if (prompt.golden().isBlank()) { + throw error(file, id, path + ".prompt.golden", "the golden fixture name must not be blank"); + } + return new PromptSource.Golden(prompt.golden()); + } + if (prompt.text() != null) { + return new PromptSource.Text(prompt.text()); + } + if (prompt.fromStep() < 1) { + throw error(file, id, path + ".prompt.fromStep", "fromStep must be at least 1"); + } + return new PromptSource.FromStep(prompt.fromStep()); + } + + private static Expectation buildExpectation(@Nullable RawExpect expect, String file, String id, String path) { + if (expect == null) { + throw error(file, id, path + ".expect", "missing required field 'expect'"); + } + boolean success; + if ("success".equals(expect.outcome())) { + success = true; + } else if ("failure".equals(expect.outcome())) { + success = false; + } else { + throw error( + file, + id, + path + ".expect.outcome", + "unknown outcome '" + expect.outcome() + "' (expected 'success' or 'failure')"); + } + if (success) { + if (expect.exception() != null || expect.code() != null || expect.messageContains() != null + || expect.slotErrors() != null) { + throw error( + file, + id, + path + ".expect", + "a success expectation must not carry failure-only fields (exception, code, messageContains," + + " slotErrors)"); + } + } else { + if (expect.exception() == null && expect.code() == null) { + throw error( + file, + id, + path + ".expect", + "a failure expectation must name the expected exception or the expected error code"); + } + if (expect.promptTextEqualsGolden() != null || expect.metadata() != null || expect.params() != null + || expect.differential() != null) { + throw error( + file, + id, + path + ".expect", + "a failure expectation must not carry success-only fields (promptTextEqualsGolden, metadata," + + " params, differential)"); + } + } + List slotErrors = new ArrayList<>(); + if (expect.slotErrors() != null) { + for (int i = 0; i < expect.slotErrors().size(); i++) { + RawSlotError slotError = expect.slotErrors().get(i); + if (slotError.slot() == null || slotError.slot().isBlank()) { + throw error(file, id, path + ".expect.slotErrors[" + i + "].slot", "the slot name must not be blank"); + } + if (slotError.code() == null || slotError.code().isBlank()) { + throw error(file, id, path + ".expect.slotErrors[" + i + "].code", "the slot error code must not be blank"); + } + slotErrors.add(new Expectation.SlotError(slotError.slot(), slotError.code())); + } + } + Map params = Map.of(); + if (expect.params() != null && !expect.params().isNull()) { + if (!expect.params().isObject()) { + throw error(file, id, path + ".expect.params", "the expected params must be a JSON object"); + } + params = MAPPER.convertValue(expect.params(), new TypeReference>() {}); + } + List messageContains = + expect.messageContains() == null ? List.of() : List.copyOf(expect.messageContains()); + if (!messageContains.isEmpty() && messageContains.stream().anyMatch(String::isBlank)) { + throw error(file, id, path + ".expect.messageContains", "messageContains entries must not be blank"); + } + List contracts = expect.contracts() == null ? List.of() : List.copyOf(expect.contracts()); + if (!contracts.isEmpty() && contracts.stream().anyMatch(String::isBlank)) { + throw error(file, id, path + ".expect.contracts", "contract names must not be blank"); + } + Expectation.Metadata metadata = expect.metadata() == null + ? null + : new Expectation.Metadata(expect.metadata().templateUriEcho(), expect.metadata().contextEcho()); + return new Expectation( + success, + expect.exception(), + expect.code(), + messageContains, + slotErrors, + expect.llmCalls(), + expect.promptTextEqualsGolden(), + metadata, + params, + contracts, + Boolean.TRUE.equals(expect.differential())); + } + + private static ScenarioCase.@Nullable ExpectFlow buildExpectFlow( + @Nullable RawExpectFlow expectFlow, String file, String id, String path) { + if (expectFlow == null) { + return null; + } + if (expectFlow.terminalCondition() != null + && !SUPPORTED_TERMINAL_CONDITIONS.contains(expectFlow.terminalCondition())) { + throw error( + file, + id, + path + ".expectFlow.terminalCondition", + "unknown terminal condition '" + expectFlow.terminalCondition() + + "' (known conditions: accept, reject, abort, exhausted)"); + } + if (expectFlow.roundsUsed() != null && expectFlow.roundsUsed() < 1) { + throw error(file, id, path + ".expectFlow.roundsUsed", "roundsUsed must be at least 1"); + } + return new ScenarioCase.ExpectFlow( + expectFlow.terminalCondition(), expectFlow.roundsUsed(), expectFlow.distinctMessages()); + } + + // ------------------------------------------------------------------ $ref and $fail resolution + + private static List resolveScript( + List script, String file, String id, String path, SharedRefs shared) { + List steps = new ArrayList<>(script.size()); + for (int i = 0; i < script.size(); i++) { + JsonNode stepNode = script.get(i); + String stepPath = path + "[" + i + "]"; + if (stepNode.isTextual()) { + steps.add(new LlmScriptStep.Payload(stepNode.asText())); + } else if (stepNode.isObject()) { + if (stepNode.size() == 1 && stepNode.has("$fail")) { + steps.add(new LlmScriptStep.Fail(parseFailMarker(stepNode.get("$fail"), file, id, stepPath))); + } else if (stepNode.size() == 1 && stepNode.has("$ref")) { + JsonNode payload = resolveSharedRef( + stepNode.get("$ref"), + file, + id, + stepPath + ".$ref", + true, + shared, + new LinkedHashSet<>()); + if (!payload.isTextual()) { + throw error(file, id, stepPath + ".$ref", "the referenced response payload must be a JSON string"); + } + steps.add(new LlmScriptStep.Payload(payload.asText())); + } else { + throw error( + file, + id, + stepPath, + "a script step must be a literal JSON string, a {\"$ref\": ...} object or a {\"$fail\": ...}" + + " object"); + } + } else { + throw error( + file, + id, + stepPath, + "a script step must be a literal JSON string, a {\"$ref\": ...} object or a {\"$fail\": ...}" + + " object"); + } + } + return steps; + } + + private static LlmFailMarker parseFailMarker(JsonNode node, String file, String id, String path) { + if (!node.isTextual()) { + throw error(file, id, path, "the $fail marker must be a string"); + } + LlmFailMarker marker = LlmFailMarker.fromJsonName(node.asText()); + if (marker == null) { + throw error( + file, + id, + path, + "unknown $fail marker '" + node.asText() + "' (known markers: " + knownFailMarkerNames() + ")"); + } + return marker; + } + + private static @Nullable JsonNode resolveSchema( + @Nullable JsonNode schema, String file, String id, String path, SharedRefs shared) { + if (schema == null || schema.isNull()) { + return null; + } + if (!schema.isObject()) { + throw error(file, id, path + ".schema", "the schema must be a JSON object or a $ref object"); + } + if (schema.size() == 1 && schema.has("$ref")) { + JsonNode refNode = schema.get("$ref"); + if (!refNode.isTextual()) { + throw error(file, id, path + ".schema.$ref", "the $ref must be a string"); + } + JsonNode resolved = resolveSharedRef( + refNode, file, id, path + ".schema.$ref", false, shared, new LinkedHashSet<>()); + if (!resolved.isObject()) { + throw error(file, id, path + ".schema.$ref", "the referenced schema must be a JSON object"); + } + rejectNestedRefs(resolved, file, id, path + ".schema"); + return resolved; + } + rejectNestedRefs(schema, file, id, path + ".schema"); + return schema; + } + + private static JsonNode resolveSharedRef( + JsonNode refNode, + String file, + String id, + String path, + boolean responseScope, + SharedRefs shared, + Set visited) { + if (!refNode.isTextual()) { + throw error(file, id, path, "the $ref must be a string"); + } + String ref = refNode.asText(); + String prefix = responseScope ? RESPONSES_PREFIX : SCHEMAS_PREFIX; + String otherPrefix = responseScope ? SCHEMAS_PREFIX : RESPONSES_PREFIX; + String sharedFile = responseScope ? RESPONSES_FILE : SCHEMAS_FILE; + if (ref.startsWith(otherPrefix)) { + throw error( + file, + id, + path, + "$ref '" + ref + "' is out of scope: it must address " + sharedFile + " through the " + prefix + + " prefix"); + } + if (!ref.startsWith(prefix)) { + throw error( + file, + id, + path, + "$ref '" + ref + "' is out of scope: it must address shared/llm-responses.json through the" + + " responses/ prefix or shared/schemas.json through the schemas/ prefix"); + } + if (!visited.add(ref)) { + throw error(file, id, path, "circular $ref chain detected at '" + ref + "'"); + } + String key = ref.substring(prefix.length()); + if (key.isEmpty()) { + throw error(file, id, path, "$ref '" + ref + "' carries no name after the " + prefix + " prefix"); + } + JsonNode value = (responseScope ? shared.responses() : shared.schemas()).get(key); + if (value == null) { + throw error( + file, + id, + path, + "dangling $ref '" + ref + "': '" + key + "' is not defined in " + sharedFile); + } + if (value.isObject() && value.size() == 1 && value.has("$ref")) { + String innerRef = value.get("$ref").asText(); + if (visited.contains(innerRef)) { + throw error( + file, + id, + path, + "circular $ref chain '" + ref + "' -> '" + innerRef + "'"); + } + throw error( + file, + id, + path, + "nested $ref '" + innerRef + "' inside '" + ref + + "' is not allowed: shared values are literal and resolve one level only"); + } + return value; + } + + private static void rejectNestedRefs(JsonNode node, String file, String id, String path) { + if (node.isObject()) { + if (node.size() == 1 && node.has("$ref")) { + throw error( + file, + id, + path, + "nested $ref '" + node.get("$ref").asText() + + "' is not allowed: references resolve one level only"); + } + node.fields().forEachRemaining( + field -> rejectNestedRefs(field.getValue(), file, id, path + "." + field.getKey())); + } else if (node.isArray()) { + for (int i = 0; i < node.size(); i++) { + rejectNestedRefs(node.get(i), file, id, path + "[" + i + "]"); + } + } + } + + // ------------------------------------------------------------------ id bookkeeping + + private static void claimBaseId(String id, String file, String path, Map seenBaseIds) { + String seenIn = seenBaseIds.putIfAbsent(id, file); + if (seenIn != null) { + throw error( + file, + id, + path, + "duplicate id '" + id + "': the id is the primary key of the corpus and is also defined in " + + seenIn); + } + } + + private static void claimExpandedId( + String expandedId, String file, String id, String path, Map seenExpandedIds) { + String seenIn = seenExpandedIds.putIfAbsent(expandedId, file); + if (seenIn != null) { + throw error( + file, + id, + path, + "duplicate expanded id '" + expandedId + "': the record is also expanded in " + seenIn); + } + } + + // ------------------------------------------------------------------ strict binding + + private static T bind(JsonNode node, Class type, String file, String id, String path) { + try { + return MAPPER.treeToValue(node, type); + } catch (UnrecognizedPropertyException exception) { + throw error( + file, + id, + path + renderJacksonPath(exception), + "unknown property '" + exception.getPropertyName() + "'"); + } catch (JsonProcessingException exception) { + throw error(file, id, path, "malformed record: " + exception.getOriginalMessage()); + } + } + + private static String renderJacksonPath(JsonMappingException exception) { + StringBuilder rendered = new StringBuilder(); + for (JsonMappingException.Reference reference : exception.getPath()) { + String fieldName = reference.getFieldName(); + if (fieldName != null) { + rendered.append('.').append(fieldName); + } else if (reference.getIndex() >= 0) { + rendered.append('[').append(reference.getIndex()).append(']'); + } + } + return rendered.toString(); + } + + private static CorpusLoadException error(String file, @Nullable String id, String path, String message) { + return new CorpusLoadException( + file + (id == null ? "" : " [" + id + "]") + " " + path + ": " + message); + } + + private static String knownApiNames() { + List names = new ArrayList<>(); + for (NegotiationApi api : NegotiationApi.values()) { + names.add(api.jsonName()); + } + return String.join(", ", names); + } + + private static String knownFailMarkerNames() { + List names = new ArrayList<>(); + for (LlmFailMarker marker : LlmFailMarker.values()) { + names.add(marker.jsonName()); + } + return String.join(", ", names); + } + + private static Map toStringMap(JsonNode object) { + Map values = new LinkedHashMap<>(); + object.fields().forEachRemaining(field -> values.put(field.getKey(), field.getValue().asText())); + return values; + } + + private static Map toNodeMap(JsonNode object) { + Map values = new LinkedHashMap<>(); + object.fields().forEachRemaining(field -> values.put(field.getKey(), field.getValue())); + return values; + } + + // ------------------------------------------------------------------ raw JSON records + + private record RawCase( + String id, + @Nullable String api, + @Nullable List languages, + @Nullable String priority, + @Nullable List tags, + @Nullable String summary, + @Nullable RawContext context, + @Nullable String templateUri, + @Nullable RawInput input, + @Nullable RawLlm llm, + @Nullable RawPrompt prompt, + @Nullable JsonNode schema, + @Nullable String inject, + @Nullable RawExpect expect) {} + + private record RawScenario( + String id, + @Nullable String summary, + @Nullable List languages, + @Nullable List roles, + @Nullable List steps, + @Nullable RawExpectFlow expectFlow) {} + + private record RawStep( + Integer step, + @Nullable String role, + @Nullable String api, + @Nullable RawContext context, + @Nullable String templateUri, + @Nullable RawInput input, + @Nullable RawLlm llm, + @Nullable RawPrompt prompt, + @Nullable JsonNode schema, + @Nullable String inject, + @Nullable RawExpect expect) {} + + private record RawContext(String id, Integer round, Integer maxRounds) {} + + private record RawInput(@Nullable Map text, @Nullable JsonNode data) {} + + private record RawLlm(@Nullable Integer maxAttempts, @Nullable List script) {} + + private record RawPrompt(@Nullable String golden, @Nullable String text, @Nullable Integer fromStep) {} + + private record RawExpect( + @Nullable String outcome, + @Nullable String exception, + @Nullable String code, + @Nullable List messageContains, + @Nullable List slotErrors, + @Nullable Integer llmCalls, + @Nullable String promptTextEqualsGolden, + @Nullable RawMetadata metadata, + @Nullable JsonNode params, + @Nullable List contracts, + @Nullable Boolean differential) {} + + private record RawSlotError(String slot, String code) {} + + private record RawMetadata(@Nullable String templateUriEcho, @Nullable Boolean contextEcho) {} + + private record RawExpectFlow( + @Nullable String terminalCondition, @Nullable Integer roundsUsed, @Nullable Boolean distinctMessages) {} +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoaderTest.java new file mode 100644 index 00000000..c0df461e --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoaderTest.java @@ -0,0 +1,569 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Smoke test of {@link NegotiationCaseLoader}: a good corpus loads and expands correctly and every format violation + * fails fast with an error naming the file, the record id and the defect. + * + *

      All corpora are written into a temporary directory, so the test does not depend on any corpus file existing. + */ +class NegotiationCaseLoaderTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String SHARED_RESPONSES = """ + { + "extract.information.accept.full": "{\\"conclusion\\": \\"Accept\\", \\"items\\": []}", + "extract.information.propose.full": "{\\"items\\": [{\\"name\\": \\"节能区域信息\\", \\"value\\": \\"松山湖\\"}]}" + } + """; + + private static final String SHARED_SCHEMAS = """ + { + "flat": {"type": "object", "properties": {"id": {"type": "string"}}} + } + """; + + // ------------------------------------------------------------------ happy path + + @Test + void loadsAndExpandsAGoodCorpus(@TempDir Path root) throws IOException { + writeGoodCorpus(root); + + LoadedCorpus corpus = NegotiationCaseLoader.load(root); + + assertEquals(3, corpus.cases().size(), "FT-HAPPY-01 expands to two languages, VAL-HAPPY-01 to one"); + assertEquals(1, corpus.scenarios().size()); + + NegotiationCase acceptZh = caseById(corpus, "FT-HAPPY-01/zh-CN"); + NegotiationCase acceptEn = caseById(corpus, "FT-HAPPY-01/en-US"); + NegotiationCase validateZh = caseById(corpus, "VAL-HAPPY-01/zh-CN"); + + assertEquals("FT-HAPPY-01", acceptZh.baseId()); + assertEquals("from-text/happy.json", acceptZh.sourceFile()); + assertEquals(NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, acceptZh.api()); + assertEquals("zh-CN", acceptZh.language()); + assertEquals("P1", acceptZh.priority()); + assertEquals("我确认第一阶段的信息。", acceptZh.inputText()); + assertEquals("I confirm the first-stage information.", acceptEn.inputText()); + assertEquals(new ContextSpec(SESSION_ID, 2, 5), acceptZh.context()); + assertTrue(acceptZh.inputData().isObject(), "the differential typed input data travels as a JSON node"); + assertTrue(acceptZh.expect().success()); + assertTrue(acceptZh.expect().differential()); + assertEquals(2, acceptZh.expect().llmCalls()); + assertEquals("information_accept", acceptZh.expect().promptTextEqualsGolden()); + assertEquals("Negotiation-T/information-negotiation/accept-reject/v1", + acceptZh.expect().metadata().templateUriEcho()); + assertEquals(Boolean.TRUE, acceptZh.expect().metadata().contextEcho()); + + LlmScriptStep firstStep = acceptZh.llm().steps().get(0); + LlmScriptStep.Fail failStep = assertInstanceOf(LlmScriptStep.Fail.class, firstStep); + assertEquals(LlmFailMarker.NON_JSON, failStep.marker()); + LlmScriptStep.Payload payloadStep = assertInstanceOf(LlmScriptStep.Payload.class, acceptZh.llm().steps().get(1)); + assertEquals("{\"conclusion\": \"Accept\", \"items\": []}", payloadStep.json()); + assertEquals(3, acceptZh.llm().maxAttempts()); + + assertEquals(NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, validateZh.api()); + PromptSource.Golden golden = assertInstanceOf(PromptSource.Golden.class, validateZh.prompt()); + assertEquals("information_propose", golden.golden()); + assertEquals(corpus.sharedSchemas().get("flat"), validateZh.schema()); + assertEquals(Map.of("id", SESSION_ID, "round", 1, "maxRounds", 5), validateZh.expect().params()); + + ScenarioCase scenario = corpus.scenarios().get(0); + assertEquals("SC-INFO-01/zh-CN", scenario.id()); + assertEquals("SC-INFO-01", scenario.baseId()); + assertEquals(2, scenario.steps().size()); + assertEquals(1, scenario.steps().get(0).step()); + assertEquals("A", scenario.steps().get(0).role()); + assertEquals("SC-INFO-01/zh-CN#step-1", scenario.steps().get(0).caseData().id()); + assertEquals(NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, scenario.steps().get(0).caseData().api()); + PromptSource.FromStep fromStep = assertInstanceOf(PromptSource.FromStep.class, scenario.steps().get(1).caseData().prompt()); + assertEquals(1, fromStep.step()); + assertEquals("reject", scenario.expectFlow().terminalCondition()); + assertEquals(1, scenario.expectFlow().roundsUsed()); + assertEquals(Boolean.TRUE, scenario.expectFlow().distinctMessages()); + + NegotiationCase failedStep = scenario.steps().get(1).caseData(); + assertFalse(failedStep.expect().success()); + assertEquals("negotiation_semantic_rejected", failedStep.expect().code()); + assertEquals(1, failedStep.expect().llmCalls()); + LlmScriptStep.Fail llmErrorStep = assertInstanceOf(LlmScriptStep.Fail.class, failedStep.llm().steps().get(0)); + assertEquals(LlmFailMarker.LLM_ERROR, llmErrorStep.marker()); + } + + @Test + void skipsTheSchemaFileAndLoadsAnEmptyCorpus(@TempDir Path root) throws IOException { + write(root, "corpus-schema.json", "{\"formatVersion\": 1}"); + write(root, "shared/llm-responses.json", SHARED_RESPONSES); + + LoadedCorpus corpus = NegotiationCaseLoader.load(root); + + assertTrue(corpus.cases().isEmpty()); + assertTrue(corpus.scenarios().isEmpty()); + assertEquals(2, corpus.sharedResponses().size(), "the shared files load even without any case file"); + } + + @Test + void rejectsAMissingCorpusRoot(@TempDir Path root) { + CorpusLoadException exception = + assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root.resolve("nope"))); + + assertTrue(exception.getMessage().contains("not an existing directory")); + } + + // ------------------------------------------------------------------ fail-fast violations + + @Test + void failsFastOnAnUnknownKey(@TempDir Path root) throws IOException { + write(root, "from-text/bad.json", """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "expect": {"outcome": "success", "expection": "Oops"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("from-text/bad.json"), exception.getMessage()); + assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); + assertTrue(exception.getMessage().contains("expection"), exception.getMessage()); + assertTrue(exception.getMessage().contains("expect"), exception.getMessage()); + } + + @Test + void failsFastOnADanglingRef(@TempDir Path root) throws IOException { + write(root, "from-text/bad.json", """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "llm": {"script": [{"$ref": "responses/missing.payload"}]}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("dangling"), exception.getMessage()); + assertTrue(exception.getMessage().contains("missing.payload"), exception.getMessage()); + assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); + } + + @Test + void failsFastOnAnOutOfScopeRef(@TempDir Path root) throws IOException { + write(root, "shared/schemas.json", SHARED_SCHEMAS); + write(root, "from-text/bad.json", """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "llm": {"script": [{"$ref": "schemas/flat"}]}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("out of scope"), exception.getMessage()); + assertTrue(exception.getMessage().contains("responses/"), exception.getMessage()); + } + + @Test + void failsFastOnANestedRef(@TempDir Path root) throws IOException { + write(root, "shared/schemas.json", """ + { + "a": {"$ref": "schemas/b"}, + "b": {"type": "object"} + } + """); + write(root, "validate/bad.json", """ + [ + { + "id": "VAL-BAD-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": {"text": "## 提议"}, + "schema": {"$ref": "schemas/a"}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("nested $ref"), exception.getMessage()); + assertTrue(exception.getMessage().contains("VAL-BAD-01"), exception.getMessage()); + } + + @Test + void failsFastOnACircularRef(@TempDir Path root) throws IOException { + write(root, "shared/schemas.json", """ + { + "a": {"$ref": "schemas/a"} + } + """); + write(root, "validate/bad.json", """ + [ + { + "id": "VAL-BAD-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": {"text": "## 提议"}, + "schema": {"$ref": "schemas/a"}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("circular"), exception.getMessage()); + } + + @Test + void failsFastOnADuplicateId(@TempDir Path root) throws IOException { + write(root, "from-text/one.json", minimalCase("FT-DUP-01")); + write(root, "from-text/two.json", minimalCase("FT-DUP-01")); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("duplicate id 'FT-DUP-01'"), exception.getMessage()); + assertTrue(exception.getMessage().contains("from-text/one.json"), exception.getMessage()); + } + + @Test + void failsFastOnADuplicateExpandedId(@TempDir Path root) throws IOException { + write(root, "from-text/bad.json", """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "zh-CN"], + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("duplicate language"), exception.getMessage()); + } + + @Test + void failsFastOnAnIncompleteFailureExpectation(@TempDir Path root) throws IOException { + write(root, "from-text/bad.json", """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "expect": {"outcome": "failure"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("failure expectation must name"), exception.getMessage()); + assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); + } + + @Test + void failsFastOnFailureOnlyFieldsOnASuccessExpectation(@TempDir Path root) throws IOException { + write(root, "from-text/bad.json", """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "expect": {"outcome": "success", "code": "negotiation_invalid_input"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("success expectation must not carry failure-only fields"), + exception.getMessage()); + } + + @Test + void failsFastOnAnUnknownApi(@TempDir Path root) throws IOException { + write(root, "from-text/bad.json", """ + [ + { + "id": "FT-BAD-01", + "api": "generateWhateverFromText", + "languages": ["zh-CN"], + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("unknown api 'generateWhateverFromText'"), exception.getMessage()); + assertTrue(exception.getMessage().contains("generateAcceptFromText"), exception.getMessage()); + } + + @Test + void failsFastOnAnUnknownFailMarker(@TempDir Path root) throws IOException { + write(root, "from-text/bad.json", """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "llm": {"script": [{"$fail": "explodes"}]}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("unknown $fail marker 'explodes'"), exception.getMessage()); + assertTrue(exception.getMessage().contains("non-json"), exception.getMessage()); + } + + @Test + void failsFastOnAnUnsupportedLanguage(@TempDir Path root) throws IOException { + write(root, "from-text/bad.json", """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh_CN"], + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("unsupported language 'zh_CN'"), exception.getMessage()); + } + + @Test + void failsFastOnAMissingLanguageTextEntry(@TempDir Path root) throws IOException { + write(root, "from-text/bad.json", """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "en-US"], + "input": {"text": {"zh-CN": "我确认"}}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("missing the 'en-US' text entry"), exception.getMessage()); + } + + @Test + void failsFastOnNonConsecutiveScenarioSteps(@TempDir Path root) throws IOException { + write(root, "scenarios/bad.json", """ + [ + { + "id": "SC-BAD-01", + "languages": ["zh-CN"], + "steps": [ + { + "step": 2, + "api": "generateProposeFromText", + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "expect": {"outcome": "success"} + } + ] + } + ] + """.formatted(SESSION_ID)); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("consecutively from 1"), exception.getMessage()); + assertTrue(exception.getMessage().contains("SC-BAD-01"), exception.getMessage()); + } + + @Test + void failsFastOnAnUnknownKeyInsideAScenarioStep(@TempDir Path root) throws IOException { + write(root, "scenarios/bad.json", """ + [ + { + "id": "SC-BAD-01", + "languages": ["zh-CN"], + "steps": [ + { + "step": 1, + "api": "generateProposeFromText", + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "rol": "A", + "expect": {"outcome": "success"} + } + ] + } + ] + """.formatted(SESSION_ID)); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("rol"), exception.getMessage()); + assertTrue(exception.getMessage().contains("SC-BAD-01"), exception.getMessage()); + } + + // ------------------------------------------------------------------ helpers + + private static void write(Path root, String relative, String content) throws IOException { + Path file = root.resolve(relative); + if (file.getParent() != null) { + Files.createDirectories(file.getParent()); + } + Files.writeString(file, content, StandardCharsets.UTF_8); + } + + private static void writeGoodCorpus(Path root) throws IOException { + write(root, "shared/llm-responses.json", SHARED_RESPONSES); + write(root, "shared/schemas.json", SHARED_SCHEMAS); + write(root, "from-text/happy.json", """ + [ + { + "id": "FT-HAPPY-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["happy", "accept-reject"], + "summary": "信息确认后接受,成功", + "context": {"id": "%s", "round": 2, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": {"zh-CN": "我确认第一阶段的信息。", "en-US": "I confirm the first-stage information."}, + "data": {"items": [], "relationship": null} + }, + "llm": { + "maxAttempts": 3, + "script": [{"$fail": "non-json"}, {"$ref": "responses/extract.information.accept.full"}] + }, + "expect": { + "outcome": "success", + "llmCalls": 2, + "promptTextEqualsGolden": "information_accept", + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent"], + "differential": true + } + } + ] + """.formatted(SESSION_ID)); + write(root, "validate/happy.json", """ + [ + { + "id": "VAL-HAPPY-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": {"golden": "information_propose"}, + "schema": {"$ref": "schemas/flat"}, + "llm": {"script": ["{\\"verdict\\": true}"]}, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": {"id": "%s", "round": 1, "maxRounds": 5} + } + } + ] + """.formatted(SESSION_ID, SESSION_ID)); + write(root, "scenarios/flows.json", """ + [ + { + "id": "SC-INFO-01", + "summary": "提议后语义拒绝", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": {"text": {"zh-CN": "请提供节能区域信息"}}, + "llm": {"script": [{"$ref": "responses/extract.information.propose.full"}]}, + "expect": {"outcome": "success", "llmCalls": 1} + }, + { + "step": 2, + "role": "B", + "api": "validateProposePromptAndDataFilling", + "prompt": {"fromStep": 1}, + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "schema": {"$ref": "schemas/flat"}, + "llm": {"script": [{"$fail": "llm-error"}]}, + "expect": {"outcome": "failure", "code": "negotiation_semantic_rejected", "llmCalls": 1} + } + ], + "expectFlow": {"terminalCondition": "reject", "roundsUsed": 1, "distinctMessages": true} + } + ] + """.formatted(SESSION_ID, SESSION_ID)); + } + + private static String minimalCase(String id) { + return """ + [ + { + "id": "%s", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "context": {"id": "%s", "round": 2, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": {"text": {"zh-CN": "我确认"}}, + "llm": {"script": ["{}"]}, + "expect": {"outcome": "success"} + } + ] + """.formatted(id, SESSION_ID); + } + + private static NegotiationCase caseById(LoadedCorpus corpus, String id) { + NegotiationCase found = null; + for (NegotiationCase candidate : corpus.cases()) { + if (candidate.id().equals(id)) { + found = candidate; + } + } + assertNotNull(found, "the corpus must expand the case " + id); + return found; + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/PromptSource.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/PromptSource.java new file mode 100644 index 00000000..e5991eeb --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/PromptSource.java @@ -0,0 +1,33 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +/** + * The prompt input of one validate-family corpus record. + * + *

      Exactly one variant applies per record: a golden fixture name resolved per language by the engine, an inline + * prompt text, or the number of the scenario step whose generated prompt text feeds this validation step. + * + * @since 2026-08 + */ +public sealed interface PromptSource { + + /** + * The prompt is the golden fixture of the given name, resolved per language. + * + * @param golden golden fixture name such as {@code information_propose} + */ + record Golden(String golden) implements PromptSource {} + + /** + * The prompt is the given inline text. + * + * @param text prompt text verbatim + */ + record Text(String text) implements PromptSource {} + + /** + * The prompt is the prompt text generated by an earlier scenario step. + * + * @param step number of the producing scenario step, at least 1 + */ + record FromStep(int step) implements PromptSource {} +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCase.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCase.java new file mode 100644 index 00000000..87a9dcc6 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCase.java @@ -0,0 +1,72 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * One scenario record expanded for exactly one language. + * + *

      A scenario is a multi-step multi-API interaction over the negotiation content layer. Every step is a full case + * record (carried as its {@link ScenarioStep#caseData()}); the scenario adds the step ordering, the acting role and + * the flow-level expectation. + * + * @param id expanded scenario id such as {@code SC-INFO-02/zh-CN} + * @param baseId scenario record id before the language expansion, such as {@code SC-INFO-02} + * @param sourceFile corpus file path relative to the corpus root, such as {@code scenarios/information-flows.json} + * @param language language this expansion runs in + * @param summary business-facing one-line summary, or null when the record states none + * @param roles role names in their first-appearance order, empty when the record states none + * @param steps scenario steps numbered consecutively from 1 + * @param expectFlow flow-level expectation, or null when the scenario states none + * @since 2026-08 + */ +public record ScenarioCase( + String id, + String baseId, + String sourceFile, + String language, + @Nullable String summary, + List roles, + List steps, + @Nullable ExpectFlow expectFlow) { + + public ScenarioCase { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(baseId, "baseId"); + Objects.requireNonNull(sourceFile, "sourceFile"); + Objects.requireNonNull(language, "language"); + Objects.requireNonNull(steps, "steps"); + if (steps.isEmpty()) { + throw new IllegalArgumentException("The scenario must carry at least one step."); + } + roles = List.copyOf(roles); + steps = List.copyOf(steps); + } + + /** + * One step of a scenario. + * + * @param step step number, consecutive from 1 + * @param role acting role name, or null when the step states none + * @param caseData the step as a full expanded case record + */ + public record ScenarioStep(int step, @Nullable String role, NegotiationCase caseData) { + + public ScenarioStep { + if (step < 1) { + throw new IllegalArgumentException("The step number must be at least 1."); + } + Objects.requireNonNull(caseData, "caseData"); + } + } + + /** + * The flow-level expectation of a scenario. + * + * @param terminalCondition expected terminal condition: accept, reject, abort or exhausted + * @param roundsUsed expected largest round value reached across the steps + * @param distinctMessages true when the per-round prompt texts must be pairwise distinct + */ + public record ExpectFlow(@Nullable String terminalCondition, @Nullable Integer roundsUsed, @Nullable Boolean distinctMessages) {} +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCorpusSuiteTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCorpusSuiteTest.java new file mode 100644 index 00000000..28c64994 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCorpusSuiteTest.java @@ -0,0 +1,27 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +/** + * Data-driven suite of the E2E scenario corpus: every scenario under {@code negotiation-cases/scenarios/} becomes one + * dynamic test executed by the {@link ScenarioEngine} — every step is a full corpus case run by the {@link CaseEngine}, + * plus the {@code prompt.fromStep} resolution and the flow-level expectation. + * + *

      The seed batch carries {@code scenarios/information-flows.json} (SC-INFO: the single-round happy accept loop, both + * languages). The later batches (SC-TGT, SC-FSB, SC-EXH, SC-ERR, boundary flows) land as further files of the same + * directory without any Java change. + * + *

      Run a subset with {@code -Dcase.filter=SC-INFO-*} (glob against the scenario id, design document Q1). + * + * @since 2026-08 + */ +class ScenarioCorpusSuiteTest { + + /** Executes every expanded scenario of the corpus. */ + @TestFactory + Stream scenarioCorpusCases() { + return CorpusSuites.scenarioTests("scenarios"); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngine.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngine.java new file mode 100644 index 00000000..b300c3c6 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngine.java @@ -0,0 +1,169 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.openan.a2at.sdk.core.model.MetadataContent; +import org.jspecify.annotations.Nullable; + +/** + * Executes one expanded corpus scenario: a multi-step, multi-API interaction over the negotiation content layer. + * + *

      The scenario engine is deliberately thin (design document §3.2.1, Q11–Q13): every step is a full corpus case + * executed by the {@link CaseEngine} with its own scripted LLM behavior and its exact per-step {@code llmCalls} + * expectation. The scenario layer only adds what a step cannot express alone: + * + *

        + *
      • {@code prompt.fromStep} resolution — the prompt text an earlier generation step produced becomes the prompt input + * of a later validation step; + *
      • fail-fast — the first step failure aborts the whole scenario, later steps never run; + *
      • per-role LLM accounting — every step runs on its own scripted client, so the roles' counters stay independent by + * construction and the totals are tracked per role; + *
      • the flow-level expectation {@code expectFlow}: the terminal condition (the last generated message carries the + * accept/reject/abort literal, or the round limit was reached for {@code exhausted}), the largest round value reached, + * and the pairwise distinctness of the generated messages. + *
      • + * + * @since 2026-08 + */ +public final class ScenarioEngine { + + private final CaseEngine caseEngine = new CaseEngine(); + + /** + * Runs one expanded scenario step by step and asserts its flow-level expectation. + * + * @param scenario expanded corpus scenario + * @throws AssertionError when a step fails (fail-fast: later steps do not run) or the flow expectation mismatches + */ + public void runScenario(ScenarioCase scenario) { + Map stepPromptTexts = new LinkedHashMap<>(); + Map roleCallCounts = new LinkedHashMap<>(); + int maxRound = 0; + int maxRoundsLimit = 0; + String lastPromptText = null; + for (ScenarioCase.ScenarioStep step : scenario.steps()) { + NegotiationCase stepCase = step.caseData(); + String promptOverride = resolvePromptOverride(scenario, stepCase, stepPromptTexts); + CaseEngine.CaseOutcome outcome = caseEngine.run(stepCase, promptOverride); + MetadataContent message = outcome.message(); + if (message != null) { + stepPromptTexts.put(step.step(), message.promptText()); + lastPromptText = message.promptText(); + } + roleCallCounts.merge(step.role() == null ? "(no role)" : step.role(), outcome.llmCalls(), Integer::sum); + if (stepCase.context() != null) { + maxRound = Math.max(maxRound, stepCase.context().round()); + maxRoundsLimit = Math.max(maxRoundsLimit, stepCase.context().maxRounds()); + } + } + assertExpectFlow(scenario, stepPromptTexts, maxRound, maxRoundsLimit, lastPromptText); + } + + // ------------------------------------------------------------------ fromStep resolution + + private static String resolvePromptOverride( + ScenarioCase scenario, NegotiationCase stepCase, Map stepPromptTexts) { + if (!(stepCase.prompt() instanceof PromptSource.FromStep fromStep)) { + return null; + } + String promptText = stepPromptTexts.get(fromStep.step()); + if (promptText == null) { + throw new AssertionError( + errorPrefix(scenario) + " prompt.fromStep " + fromStep.step() + + ": the referenced step produced no prompt text (unknown step number, a step that has not" + + " run yet, or a non-generation API)"); + } + return promptText; + } + + // ------------------------------------------------------------------ flow-level expectation + + private void assertExpectFlow( + ScenarioCase scenario, + Map stepPromptTexts, + int maxRound, + int maxRoundsLimit, + @Nullable String lastPromptText) { + ScenarioCase.ExpectFlow flow = scenario.expectFlow(); + if (flow == null) { + return; + } + if (flow.terminalCondition() != null) { + switch (flow.terminalCondition()) { + case "accept", "reject", "abort" -> { + String literal = terminalLiteral(flow.terminalCondition()); + if (lastPromptText == null) { + fail( + scenario, + "$.expectFlow.terminalCondition", + "a generated message carrying the '" + literal + "' literal", + "no generation step succeeded"); + } + if (!lastPromptText.contains(literal)) { + fail( + scenario, + "$.expectFlow.terminalCondition", + "a final message containing '" + literal + "'", + "<" + lastPromptText + ">"); + } + } + case "exhausted" -> { + if (maxRound != maxRoundsLimit || maxRoundsLimit == 0) { + fail( + scenario, + "$.expectFlow.terminalCondition", + "the round limit reached (largest round equals maxRounds " + maxRoundsLimit + ")", + "largest round " + maxRound + ", maxRounds " + maxRoundsLimit); + } + } + default -> fail( + scenario, + "$.expectFlow.terminalCondition", + "accept, reject, abort or exhausted", + flow.terminalCondition()); + } + } + if (flow.roundsUsed() != null && flow.roundsUsed() != maxRound) { + fail( + scenario, + "$.expectFlow.roundsUsed", + String.valueOf(flow.roundsUsed()), + String.valueOf(maxRound)); + } + if (Boolean.TRUE.equals(flow.distinctMessages())) { + List messages = new ArrayList<>(stepPromptTexts.values()); + Set distinct = new LinkedHashSet<>(messages); + if (distinct.size() != messages.size()) { + fail( + scenario, + "$.expectFlow.distinctMessages", + messages.size() + " pairwise distinct generated messages", + distinct.size() + " distinct message(s)"); + } + } + } + + private static String terminalLiteral(String terminalCondition) { + return switch (terminalCondition) { + case "accept" -> "Accept"; + case "reject" -> "Reject"; + case "abort" -> "Abort"; + default -> throw new IllegalArgumentException("Unknown terminal condition " + terminalCondition + "."); + }; + } + + // ------------------------------------------------------------------ helpers + + private static String errorPrefix(ScenarioCase scenario) { + return scenario.sourceFile() + " [" + scenario.id() + "]"; + } + + private static AssertionError fail(ScenarioCase scenario, String jsonPath, String expected, String actual) { + throw new AssertionError( + errorPrefix(scenario) + " " + jsonPath + ": expected " + expected + " but was " + actual); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngineTest.java new file mode 100644 index 00000000..33a6a1f5 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngineTest.java @@ -0,0 +1,294 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import net.openan.a2at.sdk.negotiation.testdata.ScenarioCase.ExpectFlow; +import org.junit.jupiter.api.Test; +import org.jspecify.annotations.Nullable; + +/** + * Direct unit tests of the {@link ScenarioEngine} against inline scenario objects: the {@code prompt.fromStep} + * resolution, the fail-fast step execution, and the three {@code expectFlow} fields (terminal condition, rounds used, + * pairwise distinct messages). + */ +class ScenarioEngineTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String ZH_CN = "zh-CN"; + + private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; + + private static final String INFORMATION_ACCEPT_REJECT_URI = + "Negotiation-T/information-negotiation/accept-reject/v1"; + + private static final String ACCEPT_PAYLOAD = + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan" + + " Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}]}"; + + private static final String PROPOSE_PAYLOAD = + "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"e.g. Songshan Lake\"},{\"name\":" + + "\"energy-saving rate guarantee target\",\"value\":\"e.g. 20Mbps\"},{\"name\":\"VLANId\"," + + "\"value\":null}],\"relationship\":\"OR\"}"; + + private static final String SEMANTIC_ACCEPT_PAYLOAD = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; + + private final ScenarioEngine engine = new ScenarioEngine(); + + private final ObjectMapper mapper = new ObjectMapper(); + + private JsonNode schema; + + @BeforeEach + void readSchema() throws Exception { + schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"region\":{\"type\":\"string\"}}}"); + } + + // ------------------------------------------------------------------ fromStep resolution + + @Test + void runsATwoStepScenarioResolvingTheFromStepPrompt() { + ScenarioCase scenario = scenario( + "SC-INFO-01", + List.of( + step(1, "A", acceptStep("SC-INFO-01", ok(1, "information_accept"))), + step( + 2, + "B", + validateAcceptStep( + "SC-INFO-01", + 1, + ok(1, null)))), + new ScenarioCase.ExpectFlow("accept", 2, null)); + + engine.runScenario(scenario); + } + + @Test + void failsWhenAFromStepReferencesAnUnknownStep() { + ScenarioCase scenario = scenario( + "SC-INFO-02", + List.of(step(1, "A", validateAcceptStep("SC-INFO-02", 3, ok(1, null)))), + null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("SC-INFO-02/zh-CN") + && failure.getMessage().contains("prompt.fromStep 3") + && failure.getMessage().contains("produced no prompt text"), + "the unknown fromStep reference must fail with the scenario id and the step number but was: " + + failure.getMessage()); + } + + // ------------------------------------------------------------------ fail-fast + + @Test + void failsFastWhenAStepFails() { + NegotiationCase failingStep = stepCase( + "SC-ERR-01", + 1, + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), + null, + null, + failed("NegotiationGenerationException", "negotiation_invalid_input", null)); + ScenarioCase scenario = scenario( + "SC-ERR-01", + List.of( + step(1, "A", failingStep), + step(2, "B", validateAcceptStep("SC-ERR-01", 1, ok(1, null)))), + null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("#step-1") && failure.getMessage().contains("$.expect.code"), + "the scenario must abort on the failing step 1 but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ expectFlow + + @Test + void assertsDistinctMessagesAndRoundsUsedAcrossSteps() { + ScenarioCase scenario = scenario( + "SC-INFO-03", + List.of( + step(1, "A", proposeStep("SC-INFO-03", 2, ok(1, "information_propose"))), + step(2, "B", acceptStep("SC-INFO-03", 3, ok(1, "information_accept")))), + new ScenarioCase.ExpectFlow("accept", 3, Boolean.TRUE)); + + engine.runScenario(scenario); + } + + @Test + void distinctMessagesFailsOnDuplicateMessages() { + ScenarioCase scenario = scenario( + "SC-INFO-04", + List.of( + step(1, "A", acceptStep("SC-INFO-04", 2, ok(1, "information_accept"))), + step(2, "B", acceptStep("SC-INFO-04", 3, ok(1, "information_accept")))), + new ScenarioCase.ExpectFlow("accept", 3, Boolean.TRUE)); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("$.expectFlow.distinctMessages"), + "duplicate messages must fail the distinctMessages expectation but was: " + failure.getMessage()); + } + + @Test + void exhaustedRequiresTheRoundLimitToBeReached() { + ScenarioCase exhausted = scenario( + "SC-EXH-01", + List.of(step(1, "A", acceptStep("SC-EXH-01", 5, ok(1, "information_accept")))), + new ScenarioCase.ExpectFlow("exhausted", 5, null)); + engine.runScenario(exhausted); + + ScenarioCase notExhausted = scenario( + "SC-EXH-02", + List.of(step(1, "A", acceptStep("SC-EXH-02", 2, ok(1, "information_accept")))), + new ScenarioCase.ExpectFlow("exhausted", 2, null)); + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(notExhausted)); + + assertTrue( + failure.getMessage().contains("$.expectFlow.terminalCondition"), + "a round below the limit must fail the exhausted expectation but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ builders + + private static ScenarioCase scenario( + String id, List steps, @Nullable ExpectFlow expectFlow) { + return new ScenarioCase( + id + "/zh-CN", id, "scenarios/information-flows.json", ZH_CN, null, List.of("A", "B"), steps, + expectFlow); + } + + private static ScenarioCase.ScenarioStep step(int number, String role, NegotiationCase caseData) { + return new ScenarioCase.ScenarioStep(number, role, caseData); + } + + private NegotiationCase acceptStep(String scenarioId, Expectation expect) { + return stepCase( + scenarioId, + 1, + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "我确认第一阶段的信息。", + script(ACCEPT_PAYLOAD), + null, + null, + expect); + } + + private NegotiationCase acceptStep(String scenarioId, int round, Expectation expect) { + return stepCase( + scenarioId, + 1, + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + new ContextSpec(SESSION_ID, round, 5), + INFORMATION_ACCEPT_REJECT_URI, + "我确认第一阶段的信息。", + script(ACCEPT_PAYLOAD), + null, + null, + expect); + } + + private NegotiationCase proposeStep(String scenarioId, int round, Expectation expect) { + return stepCase( + scenarioId, + 1, + NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, + new ContextSpec(SESSION_ID, round, 5), + INFORMATION_PROPOSE_URI, + "请补充节能区域、节能速率保障目标与VLANId信息。", + script(PROPOSE_PAYLOAD), + null, + null, + expect); + } + + private NegotiationCase validateAcceptStep(String scenarioId, int fromStep, Expectation expect) { + return stepCase( + scenarioId, + 2, + NegotiationApi.VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + null, + script(SEMANTIC_ACCEPT_PAYLOAD), + new PromptSource.FromStep(fromStep), + schema, + expect); + } + + private NegotiationCase stepCase( + String scenarioId, + int stepNumber, + NegotiationApi api, + ContextSpec context, + String templateUri, + @Nullable String inputText, + LlmScript llmScript, + @Nullable PromptSource prompt, + @Nullable JsonNode schema, + Expectation expect) { + return new NegotiationCase( + scenarioId + "/zh-CN#step-" + stepNumber, + scenarioId, + "scenarios/information-flows.json", + api, + ZH_CN, + null, + List.of(), + null, + context, + templateUri, + inputText, + null, + llmScript, + prompt, + schema, + null, + expect); + } + + private static LlmScript script(String payload) { + return new LlmScript(null, List.of(new LlmScriptStep.Payload(payload))); + } + + // ------------------------------------------------------------------ expectation helpers + + private static Expectation ok(@Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + promptTextEqualsGolden, + null, + java.util.Map.of(), + List.of(), + false); + } + + private static Expectation failed(String exception, String code, @Nullable Integer llmCalls) { + return new Expectation( + false, exception, code, List.of(), List.of(), llmCalls, null, null, java.util.Map.of(), List.of(), + false); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScriptedNegotiationLlmClient.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScriptedNegotiationLlmClient.java new file mode 100644 index 00000000..6dcd2753 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScriptedNegotiationLlmClient.java @@ -0,0 +1,175 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; + +/** + * Scripted LLM client of the negotiation test corpus: the single scripted seam of the otherwise fully production-wired + * engine. + * + *

        The client consumes the resolved script steps of one corpus case strictly step by step: a payload step answers one + * {@link LLMResponse} carrying the payload text, a failure step replays the marker's real failure form. Every call + * records the received messages and the call count, so the {@code llmCalls} expectation stays the only calibration + * point. + * + *

        {@link LlmFailMarker#ASSERTION} is the zero-call proof: any call throws an {@link AssertionError}, which is how the + * engine proves that the from-data and the differential runs never touch the LLM. + * + *

        Fail-on-overconsumption is on by default (review risk 6 of the design document): once the script is + * exhausted the next call fails instead of silently repeating the last answer, because repeat-last semantics would mask + * a wrong {@code llmCalls} expectation. The flag exists only as an explicit escape hatch for engine self-tests. + * + * @since 2026-08 + */ +public final class ScriptedNegotiationLlmClient implements LLMClient { + + /** Raw infrastructure detail of the {@code runtime-exception} marker; must never reach a user-visible message. */ + public static final String RUNTIME_EXCEPTION_DETAIL = "scripted-llm-transport-failure"; + + /** Raw infrastructure detail of the {@code llm-error} marker; must never reach a user-visible message. */ + public static final String LLM_ERROR_DETAIL = "scripted-llm-error-response"; + + /** Unparseable payload content of the {@code non-json} marker. */ + public static final String NON_JSON_CONTENT = ""; + + private final List steps; + + private final boolean failOnOverconsumption; + + private int cursor; + + private int callCount; + + private final List>> recordedMessages = new ArrayList<>(); + + private final List> recordedSchemas = new ArrayList<>(); + + private final List leakedFailureDetails = new ArrayList<>(); + + /** + * Creates a strictly consuming scripted client. + * + * @param steps resolved script steps, consumed strictly step by step + */ + public ScriptedNegotiationLlmClient(List steps) { + this(steps, true); + } + + /** + * Creates a scripted client with an explicit over-consumption policy. + * + * @param steps resolved script steps, consumed strictly step by step + * @param failOnOverconsumption true fails once the script is exhausted; false repeats the last step (escape hatch) + */ + public ScriptedNegotiationLlmClient(List steps, boolean failOnOverconsumption) { + this.steps = List.copyOf(Objects.requireNonNull(steps, "steps")); + this.failOnOverconsumption = failOnOverconsumption; + } + + /** + * Creates the zero-call proof client: any call throws an {@link AssertionError}. + * + * @return assertion-only scripted client + */ + public static ScriptedNegotiationLlmClient assertionOnly() { + return new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Fail(LlmFailMarker.ASSERTION))); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + callCount++; + recordedMessages.add(messages == null ? List.of() : List.copyOf(messages)); + recordedSchemas.add(jsonSchema); + if (cursor >= steps.size()) { + if (failOnOverconsumption) { + throw new IllegalStateException("The scripted LLM client consumed its whole script of " + + steps.size() + " step(s); an exhausted script fails instead of repeating the last answer" + + " (the llmCalls expectation is the only calibration point)."); + } + cursor = steps.size() - 1; + } + LlmScriptStep step = steps.get(cursor); + cursor++; + if (step instanceof LlmScriptStep.Payload payload) { + return new LLMResponse( + payload.json(), "scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + return failAs(((LlmScriptStep.Fail) step).marker()); + } + + /** + * Returns the number of LLM calls made through this client, including the calls that failed. + * + * @return non-negative call count + */ + public int callCount() { + return callCount; + } + + /** + * Returns the messages of every call, one entry per call. + * + * @return recorded messages per call + */ + public List>> recordedMessages() { + return List.copyOf(recordedMessages); + } + + /** + * Returns the messages of the most recent call. + * + * @return messages of the last call, or an empty list before the first call + */ + public List> lastMessages() { + return recordedMessages.isEmpty() ? List.of() : recordedMessages.get(recordedMessages.size() - 1); + } + + /** + * Returns the JSON schema of the most recent call. + * + * @return schema of the last call, or null before the first call + */ + public Map lastSchema() { + return recordedSchemas.isEmpty() ? null : recordedSchemas.get(recordedSchemas.size() - 1); + } + + /** + * Returns the raw infrastructure details of the scripted failures that were replayed, for the + * {@code noLlmLeakInUserMessage} contract. + * + * @return raw failure details that must never surface in a user-visible message + */ + public List leakedFailureDetails() { + return List.copyOf(leakedFailureDetails); + } + + private LLMResponse failAs(LlmFailMarker marker) { + return switch (marker) { + case RUNTIME_EXCEPTION -> { + leakedFailureDetails.add(RUNTIME_EXCEPTION_DETAIL); + throw new IllegalStateException(RUNTIME_EXCEPTION_DETAIL); + } + case LLM_ERROR -> { + leakedFailureDetails.add(LLM_ERROR_DETAIL); + throw new LLMRuntimeError(LLM_ERROR_DETAIL); + } + case NULL_RESPONSE -> null; + case BLANK_CONTENT -> new LLMResponse( + "", "scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + case NON_JSON -> new LLMResponse( + NON_JSON_CONTENT, "scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + case ASSERTION -> throw new AssertionError( + "No LLM call was expected in this run (assertion-only client), but one happened with messages: " + + recordedMessages.get(recordedMessages.size() - 1)); + }; + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScriptedNegotiationLlmClientTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScriptedNegotiationLlmClientTest.java new file mode 100644 index 00000000..560d8884 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScriptedNegotiationLlmClientTest.java @@ -0,0 +1,137 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +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 java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.junit.jupiter.api.Test; + +/** + * Direct unit tests of {@link ScriptedNegotiationLlmClient}: strict step-by-step consumption, the six failure markers, + * the call and message recording, and the fail-on-overconsumption default that forbids repeat-last semantics. + */ +class ScriptedNegotiationLlmClientTest { + + private static final List> MESSAGES = + List.of(Map.of("role", "system", "content", "system prompt"), Map.of("role", "user", "content", "user prompt")); + + @Test + void consumesPayloadStepsStrictlyInOrder() { + ScriptedNegotiationLlmClient client = new ScriptedNegotiationLlmClient( + List.of(new LlmScriptStep.Payload("{\"a\":1}"), new LlmScriptStep.Payload("{\"a\":2}"))); + + LLMResponse first = client.structured(MESSAGES, Map.of(), null, null); + LLMResponse second = client.structured(MESSAGES, Map.of(), null, null); + + assertEquals("{\"a\":1}", first.content()); + assertEquals("{\"a\":2}", second.content()); + assertEquals(2, client.callCount()); + assertEquals(2, client.recordedMessages().size()); + assertEquals(MESSAGES, client.lastMessages()); + assertEquals("scripted-model", first.model()); + } + + @Test + void runtimeExceptionMarkerThrowsAnInfrastructureRuntimeException() { + ScriptedNegotiationLlmClient client = + new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Fail(LlmFailMarker.RUNTIME_EXCEPTION))); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> client.structured(MESSAGES, Map.of(), null, null)); + + assertEquals(ScriptedNegotiationLlmClient.RUNTIME_EXCEPTION_DETAIL, failure.getMessage()); + assertEquals(1, client.callCount()); + assertEquals(List.of(ScriptedNegotiationLlmClient.RUNTIME_EXCEPTION_DETAIL), client.leakedFailureDetails()); + } + + @Test + void llmErrorMarkerThrowsTheLlmInfrastructureErrorType() { + ScriptedNegotiationLlmClient client = + new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Fail(LlmFailMarker.LLM_ERROR))); + + LLMRuntimeError failure = + assertThrows(LLMRuntimeError.class, () -> client.structured(MESSAGES, Map.of(), null, null)); + + assertEquals(ScriptedNegotiationLlmClient.LLM_ERROR_DETAIL, failure.getMessage()); + assertEquals(1, client.callCount()); + assertEquals(List.of(ScriptedNegotiationLlmClient.LLM_ERROR_DETAIL), client.leakedFailureDetails()); + } + + @Test + void nullResponseMarkerReturnsNull() { + ScriptedNegotiationLlmClient client = + new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Fail(LlmFailMarker.NULL_RESPONSE))); + + assertNull(client.structured(MESSAGES, Map.of(), null, null)); + assertEquals(1, client.callCount()); + } + + @Test + void blankContentMarkerAnswersABlankPayload() { + ScriptedNegotiationLlmClient client = + new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Fail(LlmFailMarker.BLANK_CONTENT))); + + LLMResponse response = client.structured(MESSAGES, Map.of(), null, null); + + assertNotNull(response); + assertTrue(response.content().isBlank()); + assertEquals(1, client.callCount()); + } + + @Test + void nonJsonMarkerAnswersAnUnparseablePayload() { + ScriptedNegotiationLlmClient client = + new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))); + + LLMResponse response = client.structured(MESSAGES, Map.of(), null, null); + + assertEquals(ScriptedNegotiationLlmClient.NON_JSON_CONTENT, response.content()); + assertEquals(1, client.callCount()); + } + + @Test + void assertionMarkerFailsOnAnyCall() { + ScriptedNegotiationLlmClient client = ScriptedNegotiationLlmClient.assertionOnly(); + + AssertionError failure = + assertThrows(AssertionError.class, () -> client.structured(MESSAGES, Map.of(), null, null)); + + assertTrue( + failure.getMessage().contains("No LLM call was expected"), + "the assertion marker must name the zero-call expectation but was: " + failure.getMessage()); + assertEquals(1, client.callCount()); + } + + @Test + void exhaustedScriptFailsInsteadOfRepeatingTheLastAnswer() { + ScriptedNegotiationLlmClient client = + new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Payload("{\"a\":1}"))); + + client.structured(MESSAGES, Map.of(), null, null); + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> client.structured(MESSAGES, Map.of(), null, null)); + + assertTrue( + failure.getMessage().contains("consumed its whole script"), + "the over-consumption failure must explain the exhausted script but was: " + failure.getMessage()); + assertEquals(2, client.callCount(), "the rejected call still counts as a call"); + } + + @Test + void overconsumptionCanBeDisabledForEngineSelfTests() { + ScriptedNegotiationLlmClient client = + new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Payload("{\"a\":1}")), false); + + client.structured(MESSAGES, Map.of(), null, null); + LLMResponse repeated = client.structured(MESSAGES, Map.of(), null, null); + + assertEquals("{\"a\":1}", repeated.content(), "the explicit escape hatch repeats the last step"); + assertEquals(2, client.callCount()); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/TypedInputAssembler.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/TypedInputAssembler.java new file mode 100644 index 00000000..eac751ee --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/TypedInputAssembler.java @@ -0,0 +1,202 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import com.fasterxml.jackson.databind.JsonNode; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationPhase; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.jspecify.annotations.Nullable; + +/** + * Assembles the typed input data of the from-data family and the differential runs from the corpus {@code input.data} + * JSON node. + * + *

        The corpus carries the typed content in the same snake_case shape the LLM extraction produces + * (design document §8.2: the case author writes the typed data explicitly), so this assembler only converts that JSON + * onto the typed content records — it never invents or reconstructs content. Deliberate phase-content contradictions + * (an {@code Accept} conclusion fed to the reject API) pass through unvalidated so the production from-data validation + * surfaces them. + * + *

        A malformed {@code input.data} node is a corpus authoring defect, not a production behavior: the assembler fails + * with an {@link IllegalStateException} naming the offending field, which no corpus expectation can match by accident. + * + * @since 2026-08 + */ +final class TypedInputAssembler { + + private TypedInputAssembler() {} + + /** + * Assembles the typed API input of one from-data call. + * + * @param data typed content in the snake_case corpus shape + * @param context negotiation context of the case, or null for the null-context probes + * @param templateUri template URI addressed by the case, or null for the null-URI probes + * @param phase API-level phase of the addressed generation method + * @param language language of the case + * @return a {@code NegotiationProposeData}, {@code NegotiationEndingData} or {@code NegotiationAbortData} + */ + static Object assemble( + JsonNode data, + @Nullable NegotiationContext context, + @Nullable TemplateUri templateUri, + NegotiationPhase phase, + String language) { + Objects.requireNonNull(data, "data"); + NegotiationType type = resolveType(templateUri, phase, language); + return switch (phase) { + case PROPOSE -> new NegotiationProposeData(context, proposeContent(data, type)); + case ACCEPT, REJECT -> new NegotiationEndingData(context, endingContent(data, type)); + case ABORT -> new NegotiationAbortData( + context, new NegotiationAbortContent(requiredText(data, "termination_reason"))); + }; + } + + // ------------------------------------------------------------------ content mapping + + private static NegotiationProposeContent proposeContent(JsonNode data, @Nullable NegotiationType type) { + if (type == null) { + throw authoring("the abort phase carries no typed propose content"); + } + return switch (type) { + case INFORMATION -> new InformationProposeContent( + requiredItems(data, "items"), optionalText(data, "relationship")); + case TARGET -> new TargetProposeContent( + requiredText(data, "target_negotiation_description"), + optionalItems(data, "intent_understanding"), + optionalItems(data, "alignment_and_clarification"), + optionalItems(data, "request_for_clarification")); + case FEASIBILITY -> new FeasibilityProposeContent( + requiredText(data, "feasibility_negotiation_description"), + action(data), + optionalItems(data, "contents_to_evaluate"), + optionalItems(data, "infeasibility_details_and_proposal")); + }; + } + + private static NegotiationEndingContent endingContent(JsonNode data, @Nullable NegotiationType type) { + if (type == null) { + throw authoring("the abort phase carries no typed ending content"); + } + NegotiationConclusion conclusion = conclusion(data); + return switch (type) { + case INFORMATION -> new InformationEndingContent(conclusion, requiredItems(data, "items")); + case TARGET -> new TargetEndingContent( + conclusion, optionalText(data, "confirmed_intent"), optionalText(data, "failure_reason")); + case FEASIBILITY -> new FeasibilityEndingContent( + conclusion, requiredText(data, "feasibility_summary")); + }; + } + + private static NegotiationType resolveType( + @Nullable TemplateUri templateUri, NegotiationPhase phase, String language) { + if (templateUri == null) { + throw authoring("the template URI is missing but the typed input data needs the negotiation type"); + } + return NegotiationReference.fromTemplateUri(templateUri, phase, language) + .orElseThrow(() -> new IllegalArgumentException( + "Template URI does not address a negotiation template of the expected phase " + + phase + " (" + phase.uriSegment() + "): " + templateUri.uri() + ".")) + .type(); + } + + // ------------------------------------------------------------------ field readers + + private static NegotiationConclusion conclusion(JsonNode data) { + JsonNode node = data.path("conclusion"); + if (!node.isTextual()) { + throw authoring("conclusion must be the Accept or Reject literal"); + } + for (NegotiationConclusion candidate : NegotiationConclusion.values()) { + if (candidate.literal().equals(node.asText())) { + return candidate; + } + } + throw authoring("conclusion must be the Accept or Reject literal but was '" + node.asText() + "'"); + } + + private static NegotiationAction action(JsonNode data) { + JsonNode node = data.path("action"); + if (!node.isTextual()) { + throw authoring("action must be one of the two feasibility action names"); + } + return NegotiationAction.valueOf(node.asText()); + } + + private static String requiredText(JsonNode data, String field) { + JsonNode node = data.path(field); + if (!node.isTextual() || node.asText().isBlank()) { + throw authoring(field + " must be a non-blank string"); + } + return node.asText(); + } + + private static @Nullable String optionalText(JsonNode data, String field) { + JsonNode node = data.path(field); + if (node.isMissingNode() || node.isNull()) { + return null; + } + if (!node.isTextual()) { + throw authoring(field + " must be a string or null"); + } + return node.asText(); + } + + private static List requiredItems(JsonNode data, String field) { + JsonNode node = data.path(field); + if (node.isMissingNode() || node.isNull()) { + throw authoring(field + " must be an array of items"); + } + return items(node, field); + } + + private static @Nullable List optionalItems(JsonNode data, String field) { + JsonNode node = data.path(field); + if (node.isMissingNode() || node.isNull()) { + return null; + } + return items(node, field); + } + + private static List items(JsonNode node, String field) { + if (!node.isArray()) { + throw authoring(field + " must be an array of items"); + } + List items = new ArrayList<>(); + for (JsonNode entry : node) { + JsonNode name = entry.path("name"); + if (!name.isTextual() || name.asText().isBlank()) { + throw authoring(field + " contained an item without a name"); + } + JsonNode value = entry.path("value"); + if (!value.isMissingNode() && !value.isNull() && !value.isTextual()) { + throw authoring(field + " contained an item whose value is not a string or null"); + } + items.add(new NegotiationItem(name.asText(), value.isTextual() ? value.asText() : null)); + } + return List.copyOf(items); + } + + private static IllegalStateException authoring(String message) { + return new IllegalStateException("input.data: " + message); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ValidateCorpusSuiteTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ValidateCorpusSuiteTest.java new file mode 100644 index 00000000..357107e9 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ValidateCorpusSuiteTest.java @@ -0,0 +1,26 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +/** + * Data-driven suite of the validate family corpus: every case under {@code negotiation-cases/validate/} becomes one + * dynamic test executed by the {@link CaseEngine} against the production negotiation content wiring. + * + *

        The seed batch carries {@code validate/happy.json} (VAL-HAPPY: propose validation with the merged-params expectation + * carrying the context keys). The later batches (VAL-PROG, VAL-RULE, VAL-SEM, VAL-RETRY, VAL-MERGE, VAL-MAP) land as + * further files of the same directory without any Java change. + * + *

        Run a subset with {@code -Dcase.filter=VAL-RULE-*} (glob against the case id, design document Q1). + * + * @since 2026-08 + */ +class ValidateCorpusSuiteTest { + + /** Executes every expanded validate case of the corpus. */ + @TestFactory + Stream validateCorpusCases() { + return CorpusSuites.caseTests("validate"); + } +} diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/abort.md b/a2a-t-negotiation/src/test/resources/golden/en-US/abort.md new file mode 100644 index 00000000..3656508c --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/abort.md @@ -0,0 +1,5 @@ +## Negotiation Result +Abort + +## Negotiation Termination Reason +The negotiation round limit is reached; this negotiation is confirmed as terminated. \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/abort.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/abort.md new file mode 100644 index 00000000..e349739d --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/abort.md @@ -0,0 +1,5 @@ +## 协商结果 +Abort + +## 协商终止原因 +The negotiation round limit is reached; this negotiation is confirmed as terminated. \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/corpus-schema.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/corpus-schema.json new file mode 100644 index 00000000..a8c6f566 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/corpus-schema.json @@ -0,0 +1,573 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openan.net/a2a-t-sdk/negotiation-cases/corpus-schema.json", + "title": "A2A-T negotiation test corpus format", + "description": "First-hand definition of the data-driven negotiation test corpus consumed by the negotiation testdata harness (NegotiationCaseLoader and the later CaseEngine suites). Every corpus file is a JSON array of records: case files carry case records, scenario files carry scenario records, and the two shared files carry reusable LLM payloads and JSON Schema variants. The loader parses everything in strict mode, so an unknown key is an error, never a silently ignored typo.", + "formatVersion": 1, + "$defs": { + "language": { + "title": "Language tag of the bundled negotiation resources", + "type": "string", + "enum": ["zh-CN", "en-US"] + }, + "api": { + "title": "NegotiationContentService API under test", + "description": "Exactly the twelve methods of the negotiation content service; the CaseEngine dispatches on this value.", + "type": "string", + "enum": [ + "generateProposeFromText", + "generateAcceptFromText", + "generateRejectFromText", + "generateAbortFromText", + "generateProposeFromData", + "generateAcceptFromData", + "generateRejectFromData", + "generateAbortFromData", + "validateProposePromptAndDataFilling", + "validateAcceptPromptAndDataFilling", + "validateRejectPromptAndDataFilling", + "validateAbortPromptAndDataFilling" + ] + }, + "priority": { + "title": "Test priority", + "description": "P0 contract, P1 boundary and retry behavior, P2 rare probes.", + "type": "string", + "enum": ["P0", "P1", "P2"] + }, + "inject": { + "title": "Harness injection hook", + "description": "Maps a corpus case onto a real builder injection point; failingTemplateLoader triggers template_not_found on the generation family, failingSemanticValidator replays the missing semantic validation prompt resources of the validate family (mapped onto template_not_found by the validation pipeline).", + "type": "string", + "enum": ["failingTemplateLoader", "failingSemanticValidator"] + }, + "templateUri": { + "title": "Negotiation template URI", + "description": "Intentionally permissive: programming-error cases carry deliberately malformed URIs (wrong version segment, wrong extension name, misspelled type, unknown phase).", + "type": "string", + "minLength": 1 + }, + "context": { + "title": "Negotiation context", + "description": "Inline on every case and every scenario step; scenario steps restate it per step so the round evolution stays visible. A JSON null expresses the null-context probes of the validate family.", + "type": "object", + "additionalProperties": false, + "required": ["id", "round", "maxRounds"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "round": { + "type": "integer", + "minimum": 1 + }, + "maxRounds": { + "type": "integer", + "minimum": 1 + } + } + }, + "input": { + "title": "API input", + "description": "text maps each language to its free-text input; data carries the language-independent typed content of the from-data family. Happy-path from-text cases may carry both for the differential double run.", + "type": "object", + "additionalProperties": false, + "anyOf": [ + { + "required": ["text"] + }, + { + "required": ["data"] + } + ], + "properties": { + "text": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/language" + }, + "additionalProperties": { + "type": "string" + } + }, + "data": { + "type": "object" + } + } + }, + "failMarker": { + "title": "Scripted LLM failure marker", + "description": "runtime-exception throws an infrastructure error, llm-error answers an LLM error response, null-response returns null, blank-content returns a blank payload, non-json returns an unparseable payload, assertion is the zero-call proof of the from-data and differential runs.", + "type": "string", + "enum": ["runtime-exception", "llm-error", "null-response", "blank-content", "non-json", "assertion"] + }, + "llmScriptStep": { + "title": "One scripted LLM answer", + "oneOf": [ + { + "title": "Shared response reference", + "description": "Resolves to the named payload in shared/llm-responses.json; the scope is locked to the responses/ prefix.", + "type": "object", + "additionalProperties": false, + "required": ["$ref"], + "properties": { + "$ref": { + "type": "string", + "pattern": "^responses/\\S+$" + } + } + }, + { + "title": "Scripted failure", + "type": "object", + "additionalProperties": false, + "required": ["$fail"], + "properties": { + "$fail": { + "$ref": "#/$defs/failMarker" + } + } + }, + { + "title": "Inline payload", + "description": "A literal JSON string carrying the payload text verbatim.", + "type": "string" + } + ] + }, + "llm": { + "title": "Scripted LLM behavior", + "description": "maxAttempts is a builder-level value (clamping belongs to the handwritten suites). The script is consumed step by step; exhausting it fails the run instead of repeating the last answer.", + "type": "object", + "additionalProperties": false, + "required": ["script"], + "properties": { + "maxAttempts": { + "type": "integer", + "minimum": 1 + }, + "script": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/llmScriptStep" + } + } + } + }, + "prompt": { + "title": "Prompt input of the validate family", + "description": "Exactly one of the three: a golden fixture name resolved per language, an inline prompt text, or the step whose generated prompt text feeds this validation step.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["golden"], + "properties": { + "golden": { + "type": "string", + "minLength": 1 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["text"], + "properties": { + "text": { + "type": "string" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["fromStep"], + "properties": { + "fromStep": { + "type": "integer", + "minimum": 1 + } + } + } + ] + }, + "schemaReference": { + "title": "Shared schema reference", + "description": "Resolves to the named JSON Schema variant in shared/schemas.json; the scope is locked to the schemas/ prefix. An inline schema object is also accepted.", + "type": "object", + "additionalProperties": false, + "required": ["$ref"], + "properties": { + "$ref": { + "type": "string", + "pattern": "^schemas/\\S+$" + } + } + }, + "slotError": { + "title": "One expected slot error", + "type": "object", + "additionalProperties": false, + "required": ["slot", "code"], + "properties": { + "slot": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + } + } + }, + "expectSuccess": { + "title": "Success expectation", + "type": "object", + "additionalProperties": false, + "required": ["outcome"], + "properties": { + "outcome": { + "const": "success" + }, + "llmCalls": { + "type": "integer", + "minimum": 0 + }, + "promptTextEqualsGolden": { + "type": "string", + "minLength": 1 + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "templateUriEcho": { + "type": "string" + }, + "contextEcho": { + "type": "boolean" + } + } + }, + "params": { + "type": "object", + "description": "Expected merged FilledParamData map; always contains the context keys id, round and maxRounds." + }, + "contracts": { + "type": "array", + "items": { + "type": "string" + } + }, + "differential": { + "type": "boolean", + "description": "Marks the from-text plus from-data double run asserting that both outputs equal each other and the golden fixture." + } + } + }, + "expectFailure": { + "title": "Failure expectation", + "description": "Discriminated union with the success block: the failure block must name the expected exception type or the expected error code.", + "type": "object", + "additionalProperties": false, + "required": ["outcome"], + "anyOf": [ + { + "required": ["exception"] + }, + { + "required": ["code"] + } + ], + "properties": { + "outcome": { + "const": "failure" + }, + "exception": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "messageContains": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "slotErrors": { + "type": "array", + "items": { + "$ref": "#/$defs/slotError" + } + }, + "llmCalls": { + "type": "integer", + "minimum": 0 + }, + "contracts": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "expectation": { + "oneOf": [ + { + "$ref": "#/$defs/expectSuccess" + }, + { + "$ref": "#/$defs/expectFailure" + } + ] + }, + "expectFlow": { + "title": "Scenario-level expectation", + "type": "object", + "additionalProperties": false, + "properties": { + "terminalCondition": { + "type": "string", + "enum": ["accept", "reject", "abort", "exhausted"] + }, + "roundsUsed": { + "type": "integer", + "minimum": 1 + }, + "distinctMessages": { + "type": "boolean" + } + } + }, + "case": { + "title": "Corpus case record", + "description": "One fully self-describing test case; key order is fixed (id, api, languages, priority, tags, summary, context, templateUri, input, llm, expect) so diffs stay localized.", + "type": "object", + "additionalProperties": false, + "required": ["id", "api", "languages", "expect"], + "properties": { + "id": { + "title": "Globally unique case id", + "description": "Naming syntax: -- such as FT-RETRY-02. The loader expands it per language into id/zh-CN and id/en-US.", + "type": "string", + "pattern": "^[A-Z]+(-[A-Z0-9]+)*-[0-9]+$" + }, + "api": { + "$ref": "#/$defs/api" + }, + "languages": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/language" + } + }, + "priority": { + "$ref": "#/$defs/priority" + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "summary": { + "title": "Business-facing one-line Chinese summary", + "type": "string", + "minLength": 1 + }, + "context": { + "$ref": "#/$defs/context" + }, + "templateUri": { + "$ref": "#/$defs/templateUri" + }, + "input": { + "$ref": "#/$defs/input" + }, + "llm": { + "$ref": "#/$defs/llm" + }, + "prompt": { + "$ref": "#/$defs/prompt" + }, + "schema": { + "oneOf": [ + { + "$ref": "#/$defs/schemaReference" + }, + { + "type": "object" + } + ] + }, + "inject": { + "$ref": "#/$defs/inject" + }, + "expect": { + "$ref": "#/$defs/expectation" + } + } + }, + "scenarioStep": { + "title": "Scenario step record", + "description": "A case record plus the three step-level extension keys: the consecutive step number, the acting role and the cross-step prompt reference carried inside prompt.fromStep.", + "type": "object", + "additionalProperties": false, + "required": ["step", "api", "context", "expect"], + "properties": { + "step": { + "type": "integer", + "minimum": 1 + }, + "role": { + "type": "string", + "minLength": 1 + }, + "api": { + "$ref": "#/$defs/api" + }, + "context": { + "$ref": "#/$defs/context" + }, + "templateUri": { + "$ref": "#/$defs/templateUri" + }, + "input": { + "$ref": "#/$defs/input" + }, + "llm": { + "$ref": "#/$defs/llm" + }, + "prompt": { + "$ref": "#/$defs/prompt" + }, + "schema": { + "oneOf": [ + { + "$ref": "#/$defs/schemaReference" + }, + { + "type": "object" + } + ] + }, + "inject": { + "$ref": "#/$defs/inject" + }, + "expect": { + "$ref": "#/$defs/expectation" + } + } + }, + "scenario": { + "title": "Scenario record", + "description": "A multi-step multi-API interaction over the content layer; every step is a full case record and the scenario adds the step list, the roles and the flow-level expectation.", + "type": "object", + "additionalProperties": false, + "required": ["id", "languages", "steps"], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Z]+(-[A-Z0-9]+)*-[0-9]+$" + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "languages": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/language" + } + }, + "roles": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "steps": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/scenarioStep" + } + }, + "expectFlow": { + "$ref": "#/$defs/expectFlow" + } + } + }, + "caseFile": { + "title": "Case corpus file", + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/case" + } + }, + "scenarioFile": { + "title": "Scenario corpus file", + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/scenario" + } + }, + "sharedResponsesFile": { + "title": "Shared LLM response payloads", + "description": "Flat map of snake_case payload names to literal JSON string payloads; every reusable payload appears exactly once.", + "type": "object", + "minProperties": 1, + "propertyNames": { + "type": "string", + "pattern": "^[a-z0-9_.]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "sharedSchemasFile": { + "title": "Shared JSON Schema variants", + "description": "Flat map of schema names to JSON Schema objects referenced through the schemas/ prefix.", + "type": "object", + "minProperties": 1, + "propertyNames": { + "type": "string", + "pattern": "^[a-z0-9_.]+$" + }, + "additionalProperties": { + "type": "object" + } + } + }, + "oneOf": [ + { + "$ref": "#/$defs/caseFile" + }, + { + "$ref": "#/$defs/scenarioFile" + }, + { + "$ref": "#/$defs/sharedResponsesFile" + }, + { + "$ref": "#/$defs/sharedSchemasFile" + } + ] +} diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/happy.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/happy.json new file mode 100644 index 00000000..bbd6d378 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/happy.json @@ -0,0 +1,285 @@ +[ + { + "id": "FD-HAPPY-01", + "api": "generateProposeFromData", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "information", "propose", "deterministic"], + "summary": "结构化数据直接生成信息协商提议消息,双语渲染均与 golden 一致,全程零 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "data": { + "items": [ + { "name": "energy-saving area information", "value": "e.g. Songshan Lake" }, + { "name": "energy-saving rate guarantee target", "value": "e.g. 20Mbps" }, + { "name": "VLANId", "value": null } + ], + "relationship": "OR" + } + }, + "expect": { + "outcome": "success", + "llmCalls": 0, + "promptTextEqualsGolden": "information_propose", + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"] + } + }, + { + "id": "FD-HAPPY-02", + "api": "generateAcceptFromData", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "information", "accept", "deterministic"], + "summary": "结构化数据直接生成信息协商接受消息,消息含 Accept 结论字面量,双语渲染均与 golden 一致,全程零 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "data": { + "conclusion": "Accept", + "items": [ + { "name": "energy-saving area information", "value": "Songshan Lake" }, + { "name": "energy-saving rate guarantee target", "value": "20Mbps" } + ] + } + }, + "expect": { + "outcome": "success", + "llmCalls": 0, + "promptTextEqualsGolden": "information_accept", + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] + } + }, + { + "id": "FD-HAPPY-03", + "api": "generateRejectFromData", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "information", "reject", "deterministic"], + "summary": "结构化数据直接生成信息协商拒绝消息,消息含 Reject 结论字面量,双语渲染均与 golden 一致,全程零 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "data": { + "conclusion": "Reject", + "items": [ + { "name": "energy-saving area information", "value": "cannot be provided because the site inventory is unavailable" } + ] + } + }, + "expect": { + "outcome": "success", + "llmCalls": 0, + "promptTextEqualsGolden": "information_reject", + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] + } + }, + { + "id": "FD-HAPPY-04", + "api": "generateProposeFromData", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "target", "propose", "first-round", "deterministic"], + "summary": "首轮目标协商从结构化数据直接生成提议消息,首轮渲染意图理解与待澄清内容两个板块,全程零 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1", + "input": { + "data": { + "target_negotiation_description": "The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm.", + "intent_understanding": [ + { "name": "task intent", "value": "apply wireless energy-saving optimization to the target site during 08:00-18:00" }, + { "name": "rate guarantee target", "value": "at least 20Mbps during the energy-saving period" } + ], + "alignment_and_clarification": null, + "request_for_clarification": [ + { "name": "area", "value": "which site is covered: Songshan Lake or another site" }, + { "name": "time range", "value": "is the energy-saving period 08:00-18:00 or 00:00-06:00" } + ] + } + }, + "expect": { + "outcome": "success", + "llmCalls": 0, + "promptTextEqualsGolden": "target_propose", + "metadata": { + "templateUriEcho": "Negotiation-T/target-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"] + } + }, + { + "id": "FD-HAPPY-05", + "api": "generateAcceptFromData", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "target", "accept", "deterministic"], + "summary": "结构化数据直接生成目标协商接受消息,消息携带最终确认意图并含 Accept 结论字面量,全程零 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "data": { + "conclusion": "Accept", + "confirmed_intent": "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps.", + "failure_reason": null + } + }, + "expect": { + "outcome": "success", + "llmCalls": 0, + "promptTextEqualsGolden": "target_accept", + "metadata": { + "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] + } + }, + { + "id": "FD-HAPPY-06", + "api": "generateRejectFromData", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "target", "reject", "deterministic"], + "summary": "结构化数据直接生成目标协商拒绝消息,消息携带拒绝原因并含 Reject 结论字面量,全程零 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "data": { + "conclusion": "Reject", + "confirmed_intent": null, + "failure_reason": "The area information cannot be clarified in full because the site inventory is unavailable." + } + }, + "expect": { + "outcome": "success", + "llmCalls": 0, + "promptTextEqualsGolden": "target_reject", + "metadata": { + "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] + } + }, + { + "id": "FD-HAPPY-07", + "api": "generateProposeFromData", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "feasibility", "propose", "evaluation-request", "deterministic"], + "summary": "结构化数据直接生成可行性协商提议消息,请求对方评估调整后的速率保障目标,全程零 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { + "data": { + "feasibility_negotiation_description": "Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess.", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": [ + { "name": "adjusted target", "value": "rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00" }, + { "name": "existing constraint", "value": "at least 10 hours of power supply duration in outage scenarios" } + ], + "infeasibility_details_and_proposal": null + } + }, + "expect": { + "outcome": "success", + "llmCalls": 0, + "promptTextEqualsGolden": "feasibility_propose", + "metadata": { + "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"] + } + }, + { + "id": "FD-HAPPY-08", + "api": "generateAcceptFromData", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "feasibility", "accept", "deterministic"], + "summary": "结构化数据直接生成可行性协商接受消息,消息携带可行性结论并含 Accept 结论字面量,全程零 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "input": { + "data": { + "conclusion": "Accept", + "feasibility_summary": "The adjusted rate guarantee target is achievable and satisfies the outage duration requirement; this negotiation is confirmed as concluded." + } + }, + "expect": { + "outcome": "success", + "llmCalls": 0, + "promptTextEqualsGolden": "feasibility_accept", + "metadata": { + "templateUriEcho": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] + } + }, + { + "id": "FD-HAPPY-09", + "api": "generateRejectFromData", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "feasibility", "reject", "deterministic"], + "summary": "结构化数据直接生成可行性协商拒绝消息,消息携带不可行结论并含 Reject 结论字面量,全程零 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "input": { + "data": { + "conclusion": "Reject", + "feasibility_summary": "The energy-saving target cannot be achieved under the existing power supply constraint; this negotiation is confirmed as concluded." + } + }, + "expect": { + "outcome": "success", + "llmCalls": 0, + "promptTextEqualsGolden": "feasibility_reject", + "metadata": { + "templateUriEcho": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] + } + }, + { + "id": "FD-HAPPY-10", + "api": "generateAbortFromData", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "abort", "common", "deterministic"], + "summary": "结构化数据直接生成协商终止消息,走通用 abort 模板并含终止原因,双语渲染均与 golden 一致,全程零 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "input": { + "data": { + "termination_reason": "The negotiation round limit is reached; this negotiation is confirmed as terminated." + } + }, + "expect": { + "outcome": "success", + "llmCalls": 0, + "promptTextEqualsGolden": "abort", + "metadata": { + "templateUriEcho": "Negotiation-T/common/abort/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/programming-errors.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/programming-errors.json new file mode 100644 index 00000000..d745a404 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/programming-errors.json @@ -0,0 +1,347 @@ +[ + { + "id": "FD-PROG-01", + "api": "generateProposeFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "null-context", "information", "propose"], + "summary": "结构化提议数据缺少协商上下文时生成立即以空指针异常失败,提示上下文必填且不发起任何 LLM 调用", + "context": null, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "data": { + "items": [ + { "name": "energy-saving area information", "value": "e.g. Songshan Lake" } + ], + "relationship": "OR" + } + }, + "expect": { + "outcome": "failure", + "exception": "NullPointerException", + "messageContains": ["Negotiation context must not be null."], + "llmCalls": 0 + } + }, + { + "id": "FD-PROG-02", + "api": "generateAcceptFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "conclusion-mismatch", "information", "accept"], + "summary": "接受方法收到携带 Reject 结论的结构化数据时生成以参数异常失败,提示结论与接受阶段不符且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "data": { + "conclusion": "Reject", + "items": [ + { "name": "energy-saving area information", "value": "Songshan Lake" } + ] + } + }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["The ACCEPT phase requires conclusion Accept but the content carries Reject."], + "llmCalls": 0 + } + }, + { + "id": "FD-PROG-03", + "api": "generateRejectFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "conclusion-mismatch", "information", "reject"], + "summary": "拒绝方法收到携带 Accept 结论的结构化数据时生成以参数异常失败,提示结论与拒绝阶段不符且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "data": { + "conclusion": "Accept", + "items": [ + { "name": "energy-saving area information", "value": "Songshan Lake" } + ] + } + }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["The REJECT phase requires conclusion Reject but the content carries Accept."], + "llmCalls": 0 + } + }, + { + "id": "FD-PROG-04", + "api": "generateAcceptFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "conclusion-mismatch", "information", "accept", "abort-conclusion"], + "summary": "接受方法收到携带 Abort 结论的结构化数据时生成以参数异常失败,提示终止结论不属于类型化模板且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "data": { + "conclusion": "Abort", + "items": [ + { "name": "energy-saving area information", "value": "Songshan Lake" } + ] + } + }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["The ACCEPT phase requires conclusion Accept but the content carries Abort."], + "llmCalls": 0 + } + }, + { + "id": "FD-PROG-05", + "api": "generateAcceptFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "missing-field", "target", "accept"], + "summary": "目标协商接受数据缺少最终确认意图时生成以参数异常失败,提示接受消息必须携带确认意图且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "data": { + "conclusion": "Accept", + "confirmed_intent": null, + "failure_reason": null + } + }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["Confirmed intent of an accepting target negotiation message must not be blank."], + "llmCalls": 0 + } + }, + { + "id": "FD-PROG-06", + "api": "generateAcceptFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "blank-field", "target", "accept"], + "summary": "目标协商接受数据的最终确认意图为空白字符串时生成以参数异常失败,提示确认意图不能为空白且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "data": { + "conclusion": "Accept", + "confirmed_intent": " ", + "failure_reason": null + } + }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["Confirmed intent of an accepting target negotiation message must not be blank."], + "llmCalls": 0 + } + }, + { + "id": "FD-PROG-07", + "api": "generateRejectFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "missing-field", "target", "reject"], + "summary": "目标协商拒绝数据缺少拒绝原因时生成以参数异常失败,提示拒绝消息必须携带失败原因且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "data": { + "conclusion": "Reject", + "confirmed_intent": null, + "failure_reason": null + } + }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["Failure reason of a rejecting target negotiation message must not be blank."], + "llmCalls": 0 + } + }, + { + "id": "FD-PROG-08", + "api": "generateProposeFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "missing-field", "feasibility", "propose", "evaluation-request"], + "summary": "可行性评估请求的结构化提议数据缺少待评估内容时生成以参数异常失败,提示至少需要一条评估项且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { + "data": { + "feasibility_negotiation_description": "Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved; please assess.", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": null + } + }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["Contents to evaluate of a feasibility evaluation request must contain at least one item."], + "llmCalls": 0 + } + }, + { + "id": "FD-PROG-09", + "api": "generateProposeFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "missing-field", "feasibility", "propose", "alternative-proposal"], + "summary": "不可行替代方案的结构化提议数据缺少不可行细节与方案时生成以参数异常失败,提示至少需要一条方案项且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { + "data": { + "feasibility_negotiation_description": "The energy-saving target is infeasible under the existing power supply constraint; an alternative proposal is listed in .", + "action": "PROPOSE_ALTERNATIVE_ON_FAILURE", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": null + } + }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": [ + "Infeasibility details and proposal of an alternative proposal must contain at least one item." + ], + "llmCalls": 0 + } + }, + { + "id": "FD-PROG-10", + "api": "generateProposeFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "template-uri", "wrong-prefix", "information", "propose"], + "summary": "模板 URI 前缀指向 Task-T 而非 Negotiation-T 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Task-T/information-negotiation/propose/v1", + "input": { + "data": { + "items": [ + { "name": "energy-saving area information", "value": "e.g. Songshan Lake" } + ], + "relationship": "OR" + } + }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": [ + "Template URI does not address a negotiation template of the expected phase PROPOSE (propose): Task-T/information-negotiation/propose/v1." + ], + "llmCalls": 0 + } + }, + { + "id": "FD-PROG-11", + "api": "generateProposeFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "template-uri", "wrong-version", "information", "propose"], + "summary": "模板 URI 版本段为 v2 而非内置 v1 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v2", + "input": { + "data": { + "items": [ + { "name": "energy-saving area information", "value": "e.g. Songshan Lake" } + ], + "relationship": "OR" + } + }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": [ + "Template URI does not address a negotiation template of the expected phase PROPOSE (propose): Negotiation-T/information-negotiation/propose/v2." + ], + "llmCalls": 0 + } + }, + { + "id": "FD-PROG-12", + "api": "generateProposeFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "template-uri", "malformed-type-segment", "information", "propose"], + "summary": "模板 URI 类型段使用下划线拼写 information_negotiation 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information_negotiation/propose/v1", + "input": { + "data": { + "items": [ + { "name": "energy-saving area information", "value": "e.g. Songshan Lake" } + ], + "relationship": "OR" + } + }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": [ + "Template URI does not address a negotiation template of the expected phase PROPOSE (propose): Negotiation-T/information_negotiation/propose/v1." + ], + "llmCalls": 0 + } + }, + { + "id": "FD-PROG-13", + "api": "generateProposeFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "template-uri", "unknown-type", "information", "propose"], + "summary": "模板 URI 类型段指向未知的 unknown-negotiation 类型时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/unknown-negotiation/propose/v1", + "input": { + "data": { + "items": [ + { "name": "energy-saving area information", "value": "e.g. Songshan Lake" } + ], + "relationship": "OR" + } + }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": [ + "Template URI does not address a negotiation template of the expected phase PROPOSE (propose): Negotiation-T/unknown-negotiation/propose/v1." + ], + "llmCalls": 0 + } + }, + { + "id": "FD-PROG-14", + "api": "generateProposeFromData", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "template-uri", "phase-mismatch", "information", "propose"], + "summary": "提议方法收到的模板 URI 阶段段为 accept-reject 而非 propose 时生成以参数异常失败,提示 URI 阶段与方法不符且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "data": { + "items": [ + { "name": "energy-saving area information", "value": "e.g. Songshan Lake" } + ], + "relationship": "OR" + } + }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": [ + "Template URI does not address a negotiation template of the expected phase PROPOSE (propose): Negotiation-T/information-negotiation/accept-reject/v1." + ], + "llmCalls": 0 + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/extraction-failures.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/extraction-failures.json new file mode 100644 index 00000000..5f6e3177 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/extraction-failures.json @@ -0,0 +1,272 @@ +[ + { + "id": "FT-EXTRACT-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["extraction-failure", "information", "accept", "slot-missing"], + "summary": "接受消息抽取结果缺少结论字段时生成失败并返回槽位缺失错误码,不发起重试", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.missing_conclusion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_slot_missing", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-02", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["extraction-failure", "information", "accept", "conclusion-mismatch"], + "summary": "接受消息抽取出的结论为拒绝时判定为内容与阶段矛盾,立即失败并返回输入无效错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.conclusion_swapped" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_invalid_input", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-03", + "api": "generateRejectFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["extraction-failure", "information", "reject", "conclusion-mismatch"], + "summary": "拒绝消息抽取出的结论为接受时判定为内容与阶段矛盾,立即失败并返回输入无效错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" } }, + "llm": { "script": [ { "$ref": "responses/extract.information.reject.conclusion_swapped" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_invalid_input", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-04", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["extraction-failure", "information", "propose", "slot-missing"], + "summary": "信息提议消息抽取结果缺少信息项列表时生成失败并返回槽位缺失错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.missing_items" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_slot_missing", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-05", + "api": "generateProposeFromText", + "languages": ["en-US"], + "priority": "P1", + "tags": ["extraction-failure", "information", "propose", "shape-error", "max-attempts-1"], + "summary": "信息项列表被抽取为字符串而非数组时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { "text": { "en-US": "Please supplement the energy-saving area information, the energy-saving rate guarantee target and the VLANId." } }, + "llm": { "maxAttempts": 1, "script": [ { "$ref": "responses/extract.information.propose.items_not_array" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_content_extract_failed", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-06", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P2", + "tags": ["extraction-failure", "information", "propose", "shape-error", "max-attempts-1"], + "summary": "信息项缺少名称字段时内容抽取失败,返回抽取失败错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "llm": { "maxAttempts": 1, "script": [ "{\"items\":[{\"value\":\"Songshan Lake\"}],\"relationship\":\"OR\"}" ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_content_extract_failed", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-07", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["extraction-failure", "target", "accept", "shape-error", "max-attempts-1"], + "summary": "结论字面量大小写错误(ACCEPT 而非 Accept)时内容抽取失败,返回抽取失败错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "最终确认的意图:对松山湖站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps。" } }, + "llm": { "maxAttempts": 1, "script": [ "{\"conclusion\":\"ACCEPT\",\"confirmed_intent\":\"The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps.\",\"failure_reason\":null}" ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_content_extract_failed", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-08", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["extraction-failure", "target", "accept", "slot-missing"], + "summary": "目标接受消息抽取结果缺少最终确认意图时生成失败并返回槽位缺失错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "最终确认的意图:对松山湖站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps。" } }, + "llm": { "script": [ { "$ref": "responses/extract.target.accept.missing_confirmed_intent" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_slot_missing", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-09", + "api": "generateRejectFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["extraction-failure", "target", "reject", "slot-missing"], + "summary": "目标拒绝消息抽取结果缺少拒绝原因时生成失败并返回槽位缺失错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "无法完整澄清区域信息:站点库存数据不可用,本轮目标协商拒绝。" } }, + "llm": { "script": [ { "$ref": "responses/extract.target.reject.missing_failure_reason" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_slot_missing", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-10", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["extraction-failure", "feasibility", "propose", "invalid-input"], + "summary": "可行性提议消息抽取结果缺少动作字段时判定为内容矛盾,立即失败并返回输入无效错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { "text": { "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps。" } }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.missing_action" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_invalid_input", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-11", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["extraction-failure", "feasibility", "propose", "shape-error", "max-attempts-1"], + "summary": "动作字段为未知枚举值时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { "text": { "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps。" } }, + "llm": { "maxAttempts": 1, "script": [ { "$ref": "responses/extract.feasibility.propose.unknown_action" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_content_extract_failed", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-12", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["extraction-failure", "feasibility", "propose", "invalid-input"], + "summary": "请求可行性评估却未抽取到任何待评估内容时判定为内容矛盾,立即失败并返回输入无效错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { "text": { "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps。" } }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.evaluation_without_contents" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_invalid_input", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-13", + "api": "generateAbortFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["extraction-failure", "abort", "common", "slot-missing"], + "summary": "终止消息抽取结果缺少终止原因时生成失败并返回槽位缺失错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "input": { "text": { "zh-CN": "协商轮次已达上限,本次协商确认终止。" } }, + "llm": { "script": [ { "$ref": "responses/extract.common.abort.missing_termination_reason" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_slot_missing", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-14", + "api": "generateAbortFromText", + "languages": ["zh-CN"], + "priority": "P2", + "tags": ["extraction-failure", "abort", "common", "slot-missing", "blank-value"], + "summary": "终止原因为空白字符串时等同于缺失,生成失败并返回槽位缺失错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "input": { "text": { "zh-CN": "协商轮次已达上限,本次协商确认终止。" } }, + "llm": { "script": [ "{\"termination_reason\":\"\"}" ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_slot_missing", + "llmCalls": 1 + } + }, + { + "id": "FT-EXTRACT-15", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P2", + "tags": ["extraction-failure", "information", "propose", "shape-error", "max-attempts-1"], + "summary": "信息项取值为数字而非字符串时内容抽取失败,返回抽取失败错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "llm": { "maxAttempts": 1, "script": [ "{\"items\":[{\"name\":\"VLANId\",\"value\":100}],\"relationship\":\"OR\"}" ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_content_extract_failed", + "llmCalls": 1 + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/happy.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/happy.json new file mode 100644 index 00000000..9779e5da --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/happy.json @@ -0,0 +1,345 @@ +[ + { + "id": "FT-HAPPY-01", + "api": "generateProposeFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "information", "propose", "differential"], + "summary": "自然语言补充信息项后生成信息协商提议消息,双语渲染均与 golden 一致,且 fromText 与 fromData 两路等值", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。", + "en-US": "Please supplement the energy-saving area information (e.g. Songshan Lake), the energy-saving rate guarantee target (e.g. 20Mbps) and the VLANId; the three items are related by OR." + }, + "data": { + "items": [ + { "name": "energy-saving area information", "value": "e.g. Songshan Lake" }, + { "name": "energy-saving rate guarantee target", "value": "e.g. 20Mbps" }, + { "name": "VLANId", "value": null } + ], + "relationship": "OR" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_propose", + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"], + "differential": true + } + }, + { + "id": "FT-HAPPY-02", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "information", "accept", "differential"], + "summary": "确认信息后从自然语言生成信息协商接受消息,消息含 Accept 结论字面量,且 fromText 与 fromData 两路等值", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。", + "en-US": "I confirm the first-stage information: the energy-saving area is Songshan Lake and the rate guarantee target is 20Mbps." + }, + "data": { + "conclusion": "Accept", + "items": [ + { "name": "energy-saving area information", "value": "Songshan Lake" }, + { "name": "energy-saving rate guarantee target", "value": "20Mbps" } + ] + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_accept", + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], + "differential": true + } + }, + { + "id": "FT-HAPPY-03", + "api": "generateRejectFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "information", "reject", "differential"], + "summary": "信息无法提供时从自然语言生成信息协商拒绝消息,消息含 Reject 结论字面量,且 fromText 与 fromData 两路等值", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。", + "en-US": "The energy-saving area information cannot be provided because the site inventory is unavailable; this round is rejected." + }, + "data": { + "conclusion": "Reject", + "items": [ + { "name": "energy-saving area information", "value": "cannot be provided because the site inventory is unavailable" } + ] + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_reject", + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], + "differential": true + } + }, + { + "id": "FT-HAPPY-04", + "api": "generateProposeFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "target", "propose", "first-round", "differential"], + "summary": "首轮目标协商从自然语言生成提议消息,首轮渲染意图理解与待澄清内容两个板块,且 fromText 与 fromData 两路等值", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "无线节能优化任务的意图理解:对目标站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps;对区域与节能时间范围仍有疑问,请澄清和确认。", + "en-US": "The intent understanding of the wireless energy-saving optimization task: apply wireless energy-saving optimization to the target site during 08:00-18:00 with a guaranteed rate of at least 20Mbps; open questions remain about the area and the time range, please clarify and confirm." + }, + "data": { + "target_negotiation_description": "The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm.", + "intent_understanding": [ + { "name": "task intent", "value": "apply wireless energy-saving optimization to the target site during 08:00-18:00" }, + { "name": "rate guarantee target", "value": "at least 20Mbps during the energy-saving period" } + ], + "alignment_and_clarification": null, + "request_for_clarification": [ + { "name": "area", "value": "which site is covered: Songshan Lake or another site" }, + { "name": "time range", "value": "is the energy-saving period 08:00-18:00 or 00:00-06:00" } + ] + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "target_propose", + "metadata": { + "templateUriEcho": "Negotiation-T/target-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"], + "differential": true + } + }, + { + "id": "FT-HAPPY-05", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "target", "accept", "differential"], + "summary": "意图确认后从自然语言生成目标协商接受消息,消息携带最终确认意图并含 Accept 结论字面量,且两路等值", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "最终确认的意图:对松山湖站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps。", + "en-US": "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps." + }, + "data": { + "conclusion": "Accept", + "confirmed_intent": "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps.", + "failure_reason": null + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "target_accept", + "metadata": { + "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], + "differential": true + } + }, + { + "id": "FT-HAPPY-06", + "api": "generateRejectFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "target", "reject", "differential"], + "summary": "信息无法澄清时从自然语言生成目标协商拒绝消息,消息携带拒绝原因并含 Reject 结论字面量,且两路等值", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "无法完整澄清区域信息:站点库存数据不可用,本轮目标协商拒绝。", + "en-US": "The area information cannot be clarified in full because the site inventory is unavailable; this target negotiation round is rejected." + }, + "data": { + "conclusion": "Reject", + "confirmed_intent": null, + "failure_reason": "The area information cannot be clarified in full because the site inventory is unavailable." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.reject.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "target_reject", + "metadata": { + "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], + "differential": true + } + }, + { + "id": "FT-HAPPY-07", + "api": "generateProposeFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "feasibility", "propose", "evaluation-request", "differential"], + "summary": "从自然语言生成可行性协商提议消息,请求对方评估调整后的速率保障目标,且 fromText 与 fromData 两路等值", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps,且停电场景下供电时长不少于10小时。", + "en-US": "Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved: the rate guarantee target is lowered from 5Mbps to 2Mbps during 08:00-18:00, with at least 10 hours of power supply duration in outage scenarios." + }, + "data": { + "feasibility_negotiation_description": "Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess.", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": [ + { "name": "adjusted target", "value": "rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00" }, + { "name": "existing constraint", "value": "at least 10 hours of power supply duration in outage scenarios" } + ], + "infeasibility_details_and_proposal": null + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "feasibility_propose", + "metadata": { + "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"], + "differential": true + } + }, + { + "id": "FT-HAPPY-08", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "feasibility", "accept", "differential"], + "summary": "评估通过后从自然语言生成可行性协商接受消息,消息携带可行性结论并含 Accept 结论字面量,且两路等值", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "调整后的速率保障目标可以实现并满足停电时长要求,本次可行性协商确认达成一致。", + "en-US": "The adjusted rate guarantee target is achievable and satisfies the outage duration requirement; this feasibility negotiation is confirmed as concluded." + }, + "data": { + "conclusion": "Accept", + "feasibility_summary": "The adjusted rate guarantee target is achievable and satisfies the outage duration requirement; this negotiation is confirmed as concluded." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "feasibility_accept", + "metadata": { + "templateUriEcho": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], + "differential": true + } + }, + { + "id": "FT-HAPPY-09", + "api": "generateRejectFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "feasibility", "reject", "differential"], + "summary": "评估不通过时从自然语言生成可行性协商拒绝消息,消息携带不可行结论并含 Reject 结论字面量,且两路等值", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "在现有供电约束下节能目标无法达成,本次可行性协商确认无法达成一致。", + "en-US": "The energy-saving target cannot be achieved under the existing power supply constraint; this feasibility negotiation is confirmed as concluded without agreement." + }, + "data": { + "conclusion": "Reject", + "feasibility_summary": "The energy-saving target cannot be achieved under the existing power supply constraint; this negotiation is confirmed as concluded." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.reject.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "feasibility_reject", + "metadata": { + "templateUriEcho": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], + "differential": true + } + }, + { + "id": "FT-HAPPY-10", + "api": "generateAbortFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "abort", "common", "differential"], + "summary": "轮次耗尽时从自然语言生成协商终止消息,走通用 abort 模板并含终止原因,且 fromText 与 fromData 两路等值", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "input": { + "text": { + "zh-CN": "协商轮次已达上限,本次协商确认终止。", + "en-US": "The negotiation round limit is reached; this negotiation is confirmed as terminated." + }, + "data": { + "termination_reason": "The negotiation round limit is reached; this negotiation is confirmed as terminated." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "abort", + "metadata": { + "templateUriEcho": "Negotiation-T/common/abort/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], + "differential": true + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/programming-errors.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/programming-errors.json new file mode 100644 index 00000000..b77b160f --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/programming-errors.json @@ -0,0 +1,145 @@ +[ + { + "id": "FT-PROG-01", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "npe", "null-context"], + "summary": "协商上下文缺失时生成提议消息在管线入口直接失败,抛出空指针异常且不发起任何大模型调用", + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "failure", + "exception": "NullPointerException", + "messageContains": ["Negotiation context must not be null."], + "llmCalls": 0 + } + }, + { + "id": "FT-PROG-02", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "npe", "null-template-uri"], + "summary": "模板地址缺失时生成接受消息在地址解析入口直接失败,抛出空指针异常且不发起任何大模型调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "expect": { + "outcome": "failure", + "exception": "NullPointerException", + "messageContains": ["Template URI must not be null."], + "llmCalls": 0 + } + }, + { + "id": "FT-PROG-03", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "null-text", "invalid-input"], + "summary": "调用方未传入任何自然语言内容时生成提议消息被拒绝,返回无效输入错误码且不发起任何大模型调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_invalid_input", + "messageContains": ["must not be blank"], + "llmCalls": 0 + } + }, + { + "id": "FT-PROG-04", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "blank-text", "invalid-input"], + "summary": "自然语言内容仅含空白字符时生成接受消息被拒绝,返回无效输入错误码且不发起任何大模型调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": " " } }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_invalid_input", + "messageContains": ["must not be blank"], + "llmCalls": 0 + } + }, + { + "id": "FT-PROG-05", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "iae", "uri-probe", "version-segment"], + "summary": "模板地址误用未发布的 v2 版本段时生成提议消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v2", + "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["does not address a negotiation template of the expected phase"], + "llmCalls": 0 + } + }, + { + "id": "FT-PROG-06", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "iae", "uri-probe", "extension-name"], + "summary": "模板地址误用 Task-T 扩展名时生成接受消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Task-T/information-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["does not address a negotiation template of the expected phase"], + "llmCalls": 0 + } + }, + { + "id": "FT-PROG-07", + "api": "generateRejectFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "iae", "uri-probe", "type-misspelled"], + "summary": "模板地址中协商类型拼写错误时生成拒绝消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/informations-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" } }, + "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["does not address a negotiation template of the expected phase"], + "llmCalls": 0 + } + }, + { + "id": "FT-PROG-08", + "api": "generateAbortFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "iae", "uri-probe", "unknown-phase"], + "summary": "模板地址中阶段段未知时生成协商终止消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/terminate/v1", + "input": { "text": { "zh-CN": "协商轮次已达上限,本次协商确认终止。" } }, + "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["does not address a negotiation template of the expected phase"], + "llmCalls": 0 + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/retry.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/retry.json new file mode 100644 index 00000000..18ea67d6 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/retry.json @@ -0,0 +1,275 @@ +[ + { + "id": "FT-RETRY-01", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["retry", "extract-failed", "exhaustion", "max-attempts-1"], + "summary": "最大尝试次数为1时大模型返回不可解析内容,不发起任何重试即以抽取失败错误码失败", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "llm": { "maxAttempts": 1, "script": [ { "$fail": "non-json" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_content_extract_failed", + "llmCalls": 1 + } + }, + { + "id": "FT-RETRY-02", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["retry", "extract-failed", "exhaustion", "max-attempts-2"], + "summary": "最大尝试次数为2时大模型连续返回空响应与空白内容,尝试耗尽后仍以原始的抽取失败错误码失败", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "llm": { "maxAttempts": 2, "script": [ { "$fail": "null-response" }, { "$fail": "blank-content" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_content_extract_failed", + "llmCalls": 2 + } + }, + { + "id": "FT-RETRY-03", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["retry", "extract-failed", "exhaustion", "max-attempts-3"], + "summary": "最大尝试次数为3时三种不可解析形态依次出现,三次尝试耗尽后以抽取失败错误码失败", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "llm": { "maxAttempts": 3, "script": [ { "$fail": "non-json" }, { "$fail": "blank-content" }, { "$fail": "null-response" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_content_extract_failed", + "llmCalls": 3 + } + }, + { + "id": "FT-RETRY-04", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["retry", "infrastructure-error", "exhaustion", "max-attempts-1"], + "summary": "最大尝试次数为1时底层通道抛出运行时异常,不重试即以基础设施错误码失败且异常细节不外泄", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "llm": { "maxAttempts": 1, "script": [ { "$fail": "runtime-exception" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_llm_infrastructure_error", + "contracts": ["noLlmLeakInUserMessage"], + "llmCalls": 1 + } + }, + { + "id": "FT-RETRY-05", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["retry", "infrastructure-error", "exhaustion", "max-attempts-2"], + "summary": "最大尝试次数为2时底层先后出现传输异常与错误响应,尝试耗尽后仍以基础设施错误码失败", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "llm": { "maxAttempts": 2, "script": [ { "$fail": "runtime-exception" }, { "$fail": "llm-error" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_llm_infrastructure_error", + "contracts": ["noLlmLeakInUserMessage"], + "llmCalls": 2 + } + }, + { + "id": "FT-RETRY-06", + "api": "generateRejectFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["retry", "infrastructure-error", "exhaustion", "max-attempts-3"], + "summary": "最大尝试次数为3时基础设施错误持续出现,三次尝试耗尽后以基础设施错误码失败", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" } }, + "llm": { "maxAttempts": 3, "script": [ { "$fail": "llm-error" }, { "$fail": "runtime-exception" }, { "$fail": "llm-error" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_llm_infrastructure_error", + "contracts": ["noLlmLeakInUserMessage"], + "llmCalls": 3 + } + }, + { + "id": "FT-RETRY-07", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["retry", "recovery", "extract-failed", "accept-reject"], + "summary": "首次返回不可解析内容后第二次返回合法接受内容,重试后成功生成与 golden 一致的接受消息", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。", + "en-US": "I confirm the first-stage information: the energy-saving area is Songshan Lake and the rate guarantee target is 20Mbps." + } + }, + "llm": { "maxAttempts": 3, "script": [ { "$fail": "non-json" }, { "$ref": "responses/extract.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 2, + "promptTextEqualsGolden": "information_accept", + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] + } + }, + { + "id": "FT-RETRY-08", + "api": "generateProposeFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["retry", "recovery", "infrastructure-error"], + "summary": "首次底层传输异常后第二次抽取成功,重试后生成与 golden 一致的信息提议消息且异常细节不外泄", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。", + "en-US": "Please supplement the energy-saving area information (e.g. Songshan Lake), the energy-saving rate guarantee target (e.g. 20Mbps) and the VLANId; the three items are related by OR." + } + }, + "llm": { "maxAttempts": 2, "script": [ { "$fail": "runtime-exception" }, { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 2, + "promptTextEqualsGolden": "information_propose", + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["noLlmLeakInUserMessage", "metadataTripleShape"] + } + }, + { + "id": "FT-RETRY-09", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["retry", "recovery", "final-attempt", "target"], + "summary": "前两次分别出现基础设施错误与不可解析内容,第三次抽取成功,在最后一次尝试额度内恢复生成接受消息", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "最终确认的意图:对松山湖站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps。", + "en-US": "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps." + } + }, + "llm": { "maxAttempts": 3, "script": [ { "$fail": "llm-error" }, { "$fail": "non-json" }, { "$ref": "responses/extract.target.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 3, + "promptTextEqualsGolden": "target_accept", + "metadata": { + "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] + } + }, + { + "id": "FT-RETRY-10", + "api": "generateAbortFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["retry", "recovery", "abort", "common"], + "summary": "终止消息首次抽取到空白内容后第二次成功,重试后生成含终止原因的协商终止消息", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "input": { + "text": { + "zh-CN": "协商轮次已达上限,本次协商确认终止。", + "en-US": "The negotiation round limit is reached; this negotiation is confirmed as terminated." + } + }, + "llm": { "maxAttempts": 2, "script": [ { "$fail": "blank-content" }, { "$ref": "responses/extract.common.abort.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 2, + "promptTextEqualsGolden": "abort", + "metadata": { + "templateUriEcho": "Negotiation-T/common/abort/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] + } + }, + { + "id": "FT-RETRY-11", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["retry", "non-retryable", "slot-missing"], + "summary": "抽取结果缺少结论字段属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "llm": { "maxAttempts": 3, "script": [ { "$ref": "responses/extract.information.accept.missing_conclusion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_slot_missing", + "llmCalls": 1 + } + }, + { + "id": "FT-RETRY-12", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["retry", "non-retryable", "invalid-input"], + "summary": "结论与阶段矛盾属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "llm": { "maxAttempts": 3, "script": [ { "$ref": "responses/extract.information.accept.conclusion_swapped" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_invalid_input", + "llmCalls": 1 + } + }, + { + "id": "FT-RETRY-13", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["retry", "exhaustion", "original-code"], + "summary": "两次失败形态不同(基础设施错误后接不可解析内容)时,耗尽后重抛最后一次失败的原始错误码而非统一的耗尽错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "llm": { "maxAttempts": 2, "script": [ { "$fail": "runtime-exception" }, { "$fail": "non-json" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "negotiation_content_extract_failed", + "llmCalls": 2 + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/template-resolution.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/template-resolution.json new file mode 100644 index 00000000..bece8f50 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/template-resolution.json @@ -0,0 +1,78 @@ +[ + { + "id": "FT-TPL-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["template-resolution", "template-not-found", "inject"], + "summary": "模板加载器不可用时生成失败并返回 template_not_found 错误码,且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "请接受。" } }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "inject": "failingTemplateLoader", + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "template_not_found", + "llmCalls": 0 + } + }, + { + "id": "FT-TPL-02", + "api": "generateProposeFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["template-resolution", "template-not-found", "inject", "propose", "no-retry"], + "summary": "模板加载器不可用时生成提议消息失败并返回 template_not_found 错误码,模板加载发生在重试循环之外,即使配置了重试次数也不重试且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "llm": { "maxAttempts": 3, "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "inject": "failingTemplateLoader", + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "template_not_found", + "llmCalls": 0 + } + }, + { + "id": "FT-TPL-03", + "api": "generateRejectFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["template-resolution", "template-not-found", "inject", "reject"], + "summary": "模板加载器不可用时生成拒绝消息失败并返回 template_not_found 错误码,且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { "text": { "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" } }, + "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, + "inject": "failingTemplateLoader", + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "template_not_found", + "llmCalls": 0 + } + }, + { + "id": "FT-TPL-04", + "api": "generateAbortFromText", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["template-resolution", "template-not-found", "inject", "abort", "common"], + "summary": "模板加载器不可用时生成协商终止消息失败并返回 template_not_found 错误码,通用终止模板同样无法加载且不发起任何 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "input": { "text": { "zh-CN": "协商轮次已达上限,本次协商确认终止。" } }, + "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, + "inject": "failingTemplateLoader", + "expect": { + "outcome": "failure", + "exception": "NegotiationGenerationException", + "code": "template_not_found", + "llmCalls": 0 + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/boundary-flows.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/boundary-flows.json new file mode 100644 index 00000000..610ec852 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/boundary-flows.json @@ -0,0 +1,753 @@ +[ + { + "id": "SC-EXH-01", + "summary": "轮次预算耗尽边界:三轮提议均遭拒绝,round 3 达到 maxRounds 上限后生成终止消息结束协商", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "generateRejectFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_reject" + } + }, + { + "step": 3, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 3 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "经内部核实,现仅请求补充节能区域信息(松山湖)与节能速率保障目标(20Mbps)两项,两项之间为且的关系。" + } + }, + "llm": { + "script": [ + "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}],\"relationship\":\"AND\"}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "contracts": ["metadataTripleShape"] + } + }, + { + "step": 4, + "role": "B", + "api": "generateRejectFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 3 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "仍无法提供节能区域信息:站点库存数据暂不可用,本轮协商再次拒绝。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_reject" + } + }, + { + "step": 5, + "role": "A", + "api": "generateAbortFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 3 }, + "templateUri": "Negotiation-T/common/abort/v1", + "input": { + "text": { + "zh-CN": "协商轮次已达上限,本次协商确认终止。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "abort" + } + }, + { + "step": 6, + "role": "B", + "api": "validateAbortPromptAndDataFilling", + "prompt": { "fromStep": 5 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 3 }, + "templateUri": "Negotiation-T/common/abort/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.common.abort.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 3, + "maxRounds": 3, + "termination_reason": "The negotiation round limit is reached." + } + } + } + ], + "expectFlow": { "terminalCondition": "exhausted", "roundsUsed": 3 } + }, + { + "id": "SC-EXH-02", + "summary": "过期重放边界:携带 round 超过轮次预算的消息被规则门直接拦截且不调用大模型,修正轮次后重放校验通过", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "validateProposePromptAndDataFilling", + "prompt": { "fromStep": 1 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 6, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "schema": { "$ref": "schemas/flat" }, + "expect": { + "outcome": "failure", + "code": "negotiation_rule_violation", + "slotErrors": [ { "slot": "round", "code": "out_of_range" } ], + "llmCalls": 0 + } + }, + { + "step": 3, + "role": "B", + "api": "validateProposePromptAndDataFilling", + "prompt": { "fromStep": 1 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 5, + "maxRounds": 5, + "region": "Songshan Lake" + } + } + } + ], + "expectFlow": { "roundsUsed": 6 } + }, + { + "id": "SC-ERR-01", + "summary": "流程中错误恢复:大模型内容抽取首次返回不可解析结果,重试第二次成功后协商流程继续推进直至接受闭环", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + } + }, + "llm": { + "maxAttempts": 3, + "script": [ + { "$fail": "non-json" }, + { "$ref": "responses/extract.information.propose.full" } + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 2, + "promptTextEqualsGolden": "information_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "validateProposePromptAndDataFilling", + "prompt": { "fromStep": 1 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "region": "Songshan Lake" + } + } + }, + { + "step": 3, + "role": "B", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_accept" + } + }, + { + "step": 4, + "role": "A", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { "fromStep": 3 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "region": "Songshan Lake" + } + } + } + ], + "expectFlow": { "terminalCondition": "accept", "roundsUsed": 1, "distinctMessages": true } + }, + { + "id": "SC-ERR-02", + "summary": "流程中错误恢复:round 1 提议因缺少节能区域信息被语义校验拒绝,round 2 补充内容重新提议后通过并接受", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "validateProposePromptAndDataFilling", + "prompt": { "fromStep": 1 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.reject" } ] }, + "expect": { + "outcome": "failure", + "code": "negotiation_semantic_rejected", + "slotErrors": [ { "slot": "region", "code": "missing" } ], + "llmCalls": 1 + } + }, + { + "step": 3, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "现已补充完成:节能区域信息为松山湖,节能速率保障目标为20Mbps,请确认。" + } + }, + "llm": { + "script": [ + "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}],\"relationship\":\"OR\"}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "contracts": ["metadataTripleShape"] + } + }, + { + "step": 4, + "role": "B", + "api": "validateProposePromptAndDataFilling", + "prompt": { "fromStep": 3 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + } + } + }, + { + "step": 5, + "role": "B", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_accept" + } + }, + { + "step": 6, + "role": "A", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { "fromStep": 5 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + } + } + } + ], + "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2, "distinctMessages": true } + }, + { + "id": "SC-ERR-03", + "summary": "重传幂等:同一条接受消息被重复校验两次,两次提取的参数结果完全一致,重传不产生副作用", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_accept" + } + }, + { + "step": 2, + "role": "B", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { "fromStep": 1 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + } + } + }, + { + "step": 3, + "role": "B", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { "fromStep": 1 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + } + } + } + ], + "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2 } + }, + { + "id": "SC-ERR-04", + "summary": "乱序失同步探针:B 以滞后的 round 1 上下文校验 A 在 round 2 生成的提议,规则门不拦截失同步(doc-gap 探针)", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "validateProposePromptAndDataFilling", + "prompt": { "fromStep": 1 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "region": "Songshan Lake" + } + } + } + ], + "expectFlow": { "roundsUsed": 2 } + }, + { + "id": "SC-ERR-05", + "summary": "截断消息探针:传输中被截断的提议消息缺失后两项信息,语义校验以槽位缺失为由拒绝", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "validateProposePromptAndDataFilling", + "prompt": { + "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. energy-saving area information:e.g. Songshan L" + }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { + "script": [ + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"energy-saving rate guarantee target\",\"code\":\"missing\",\"message\":\"The message is truncated and the item is missing.\"},{\"slot_name\":\"VLANId\",\"code\":\"missing\",\"message\":\"The message is truncated and the item is missing.\"}],\"params\":{}}" + ] + }, + "expect": { + "outcome": "failure", + "code": "negotiation_semantic_rejected", + "slotErrors": [ + { "slot": "energy-saving rate guarantee target", "code": "missing" }, + { "slot": "VLANId", "code": "missing" } + ], + "llmCalls": 1 + } + } + ], + "expectFlow": { "roundsUsed": 1 } + }, + { + "id": "SC-ERR-06", + "summary": "编码边界探针:带 UTF-8 BOM 前缀或全角标点的接受消息可正常校验提取,GBK 乱码形态的消息被语义校验拒绝", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_accept" + } + }, + { + "step": 2, + "role": "B", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { + "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. energy-saving area information:Songshan Lake\n2. energy-saving rate guarantee target:20Mbps" + }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + } + } + }, + { + "step": 3, + "role": "B", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { + "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1.节能区域信息:松山湖\n2.节能速率保障目标:20Mbps" + }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { + "script": [ + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"region\":\"松山湖\"}}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "松山湖" + } + } + }, + { + "step": 4, + "role": "B", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { + "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. energy-saving area information:锟斤拷锟斤拷\n2. energy-saving rate guarantee target:锟斤拷烫烫" + }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { + "script": [ + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"energy-saving area information\",\"code\":\"unreadable\",\"message\":\"The item value is garbled and unreadable.\"},{\"slot_name\":\"energy-saving rate guarantee target\",\"code\":\"unreadable\",\"message\":\"The item value is garbled and unreadable.\"}],\"params\":{}}" + ] + }, + "expect": { + "outcome": "failure", + "code": "negotiation_semantic_rejected", + "slotErrors": [ + { "slot": "energy-saving area information", "code": "unreadable" }, + { "slot": "energy-saving rate guarantee target", "code": "unreadable" } + ], + "llmCalls": 1 + } + } + ], + "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2 } + }, + { + "id": "SC-ERR-07", + "summary": "双会话交错:信息协商与目标协商两个会话步骤交替执行,各自提取的上下文参数互不串扰", + "languages": ["zh-CN"], + "roles": ["A1", "B1", "A2", "B2"], + "steps": [ + { + "step": 1, + "role": "A1", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_propose" + } + }, + { + "step": 2, + "role": "A2", + "api": "generateProposeFromText", + "context": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "无线节能优化任务的意图理解:对目标站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps;对区域与节能时间范围仍有疑问,请澄清和确认。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "target_propose" + } + }, + { + "step": 3, + "role": "B1", + "api": "validateProposePromptAndDataFilling", + "prompt": { "fromStep": 1 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "region": "Songshan Lake" + } + } + }, + { + "step": 4, + "role": "B2", + "api": "validateProposePromptAndDataFilling", + "prompt": { "fromStep": 2 }, + "context": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", + "round": 1, + "maxRounds": 5, + "site": "Songshan Lake", + "guaranteed_rate": "at least 20Mbps" + } + } + } + ], + "expectFlow": { "roundsUsed": 1, "distinctMessages": true } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json new file mode 100644 index 00000000..c631ae86 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json @@ -0,0 +1,325 @@ +[ + { + "id": "SC-FSB-01", + "summary": "可行性协商单轮闭环:A 发起调整后速率保障目标的可行性评估请求,B 评估通过后生成接受消息,A 校验确认达成一致", + "languages": ["zh-CN", "en-US"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps,且停电场景下供电时长不少于10小时。", + "en-US": "Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved: the rate guarantee target is lowered from 5Mbps to 2Mbps during 08:00-18:00, with at least 10 hours of power supply duration in outage scenarios." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "feasibility_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "validateProposePromptAndDataFilling", + "prompt": { "fromStep": 1 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "feasibility_result": "achievable" + } + } + }, + { + "step": 3, + "role": "B", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "评估结论:调整后的速率保障目标可以实现并满足停电时长要求,本次可行性协商确认达成一致。", + "en-US": "Evaluation outcome: the adjusted rate guarantee target is achievable and satisfies the outage duration requirement; this feasibility negotiation is confirmed as concluded." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "feasibility_accept" + } + }, + { + "step": 4, + "role": "A", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { "fromStep": 3 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "feasibility_result": "achievable" + } + } + } + ], + "expectFlow": { "terminalCondition": "accept", "roundsUsed": 1, "distinctMessages": true } + }, + { + "id": "SC-FSB-02", + "summary": "可行性协商多轮演进:round 1 评估结论为不可行并拒绝,A 于 round 2 改提替代方案,B 评估通过并接受,协商达成一致", + "languages": ["zh-CN", "en-US"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps,且停电场景下供电时长不少于10小时。", + "en-US": "Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved: the rate guarantee target is lowered from 5Mbps to 2Mbps during 08:00-18:00, with at least 10 hours of power supply duration in outage scenarios." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "feasibility_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "generateRejectFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "经评估,在现有供电约束下节能目标无法达成,本次可行性协商确认无法达成一致。", + "en-US": "After evaluation, the energy-saving target cannot be achieved under the existing power supply constraint; this feasibility negotiation is confirmed as concluded without agreement." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.reject.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "feasibility_reject" + } + }, + { + "step": 3, + "role": "A", + "api": "validateRejectPromptAndDataFilling", + "prompt": { "fromStep": 2 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { + "script": [ + "{\"semantic_verdict\":true,\"negotiation_type\":\"feasibility\",\"errors\":[],\"params\":{\"feasibility_result\":\"infeasible under the existing power supply constraint\"}}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "feasibility_result": "infeasible under the existing power supply constraint" + } + } + }, + { + "step": 4, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "原速率保障目标在停电场景下无法维持,现提出替代方案:将节能期间速率保障目标下调至2Mbps,或将节能时段缩短为6小时,请评估确认。", + "en-US": "The original rate guarantee target cannot be held in outage scenarios; an alternative is proposed: lower the rate guarantee target to 2Mbps or shorten the energy-saving period to 6 hours; please assess and confirm." + } + }, + "llm": { + "script": [ + "{\"feasibility_negotiation_description\":\"The original rate guarantee target cannot be achieved under the power supply constraint; an alternative is proposed in ; please confirm.\",\"action\":\"PROPOSE_ALTERNATIVE_ON_FAILURE\",\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":[{\"name\":\"infeasible target\",\"value\":\"rate guarantee target of 5Mbps during 08:00-18:00 cannot be held in outage scenarios\"},{\"name\":\"alternative proposal\",\"value\":\"lower the rate guarantee target to 2Mbps or shorten the energy-saving period to 6 hours\"}]}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "contracts": ["metadataTripleShape"] + } + }, + { + "step": 5, + "role": "B", + "api": "validateProposePromptAndDataFilling", + "prompt": { "fromStep": 4 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "feasibility_result": "achievable" + } + } + }, + { + "step": 6, + "role": "B", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "替代方案可行:下调后的速率保障目标能够同时满足停电时长要求,本次可行性协商确认达成一致。", + "en-US": "The alternative is feasible: the lowered rate guarantee target also satisfies the outage duration requirement; this feasibility negotiation is confirmed as concluded." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "feasibility_accept" + } + }, + { + "step": 7, + "role": "A", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { "fromStep": 6 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "feasibility_result": "achievable" + } + } + } + ], + "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2, "distinctMessages": true } + }, + { + "id": "SC-FSB-03", + "summary": "可行性协商中止:round 1 评估不可行且双方无法推进,round 2 生成通用终止消息并经校验确认协商提前终止", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps,且停电场景下供电时长不少于10小时。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "feasibility_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "generateRejectFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "经评估,在现有供电约束下节能目标无法达成,本次可行性协商确认无法达成一致。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.reject.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "feasibility_reject" + } + }, + { + "step": 3, + "role": "A", + "api": "generateAbortFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "input": { + "text": { + "zh-CN": "双方在轮次预算内无法就可行性达成一致,本次协商确认提前终止。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "abort" + } + }, + { + "step": 4, + "role": "B", + "api": "validateAbortPromptAndDataFilling", + "prompt": { "fromStep": 3 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.common.abort.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "termination_reason": "The negotiation round limit is reached." + } + } + } + ], + "expectFlow": { "terminalCondition": "abort", "roundsUsed": 2, "distinctMessages": true } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/information-flows.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/information-flows.json new file mode 100644 index 00000000..e96075ea --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/information-flows.json @@ -0,0 +1,227 @@ +[ + { + "id": "SC-INFO-01", + "summary": "信息协商单轮闭环:A 从自然语言生成接受消息,B 校验通过并提取参数", + "languages": ["zh-CN", "en-US"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。", + "en-US": "I confirm the first-stage information: the energy-saving area is Songshan Lake and the rate guarantee target is 20Mbps." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_accept" + } + }, + { + "step": 2, + "role": "B", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { "fromStep": 1 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "region": "Songshan Lake" + } + } + } + ], + "expectFlow": { "terminalCondition": "accept", "roundsUsed": 1 } + }, + { + "id": "SC-INFO-02", + "summary": "信息协商两轮演进:首轮因站点库存不可用被拒后,发起方收窄信息项于第2轮重新提议,接收方接受且参数校验提取成功", + "languages": ["zh-CN", "en-US"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。", + "en-US": "Please supplement the energy-saving area information (e.g. Songshan Lake), the energy-saving rate guarantee target (e.g. 20Mbps) and the VLANId; the three items are related by OR." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "generateRejectFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。", + "en-US": "The energy-saving area information cannot be provided because the site inventory is unavailable; this round is rejected." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_reject", + "contracts": ["conclusionLiteralPresent"] + } + }, + { + "step": 3, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "第2轮信息协商:VLANId已通过其他渠道获取,本轮仅请补充节能区域信息(如松山湖)与节能速率保障目标(如20Mbps)两项。", + "en-US": "Second-round information negotiation: the VLANId has been obtained through another channel; this round only asks for the energy-saving area information (e.g. Songshan Lake) and the energy-saving rate guarantee target (e.g. 20Mbps)." + } + }, + "llm": { "script": [ "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"e.g. Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"e.g. 20Mbps\"}],\"relationship\":\"OR\"}" ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"] + } + }, + { + "step": 4, + "role": "B", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "站点库存已恢复,我确认第2轮的信息:节能区域为松山湖,节能速率保障目标为20Mbps。", + "en-US": "The site inventory has recovered; I confirm the second-round information: the energy-saving area is Songshan Lake and the rate guarantee target is 20Mbps." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_accept", + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] + } + }, + { + "step": 5, + "role": "A", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { "fromStep": 4 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + } + ], + "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2, "distinctMessages": true } + }, + { + "id": "SC-INFO-03", + "summary": "信息协商中断:所需信息连续无法提供后,发起方在第2轮发起协商终止并生成终止消息", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 2 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "generateRejectFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 2 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_reject", + "contracts": ["conclusionLiteralPresent"] + } + }, + { + "step": 3, + "role": "A", + "api": "generateAbortFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 2 }, + "templateUri": "Negotiation-T/common/abort/v1", + "input": { + "text": { + "zh-CN": "协商轮次已达上限,所需信息仍无法提供,本次协商确认终止。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "abort", + "contracts": ["conclusionLiteralPresent"] + } + } + ], + "expectFlow": { "terminalCondition": "abort", "roundsUsed": 2, "distinctMessages": true } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/target-flows.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/target-flows.json new file mode 100644 index 00000000..7b8cc692 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/target-flows.json @@ -0,0 +1,242 @@ +[ + { + "id": "SC-TGT-01", + "summary": "目标协商单轮闭环:A 从自然语言生成携带最终确认意图的接受消息,B 校验通过并提取站点与速率保障参数", + "languages": ["zh-CN", "en-US"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "最终确认的意图:对松山湖站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps。", + "en-US": "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "target_accept" + } + }, + { + "step": 2, + "role": "B", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { "fromStep": 1 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "schema": { + "type": "object", + "properties": { + "site": { "type": "string" }, + "guaranteed_rate": { "type": "string" } + } + }, + "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "site": "Songshan Lake", + "guaranteed_rate": "at least 20Mbps" + }, + "contracts": ["contextKeysInMergedParams"] + } + } + ], + "expectFlow": { "terminalCondition": "accept", "roundsUsed": 1, "distinctMessages": true } + }, + { + "id": "SC-TGT-02", + "summary": "目标协商两轮演进:首轮区域信息无法澄清被拒后,发起方对齐区域理解并于第2轮重新提议,接收方接受且参数校验提取成功", + "languages": ["zh-CN", "en-US"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "无线节能优化任务的意图理解:对目标站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps;对区域与节能时间范围仍有疑问,请澄清和确认。", + "en-US": "The intent understanding of the wireless energy-saving optimization task: apply wireless energy-saving optimization to the target site during 08:00-18:00 with a guaranteed rate of at least 20Mbps; open questions remain about the area and the time range, please clarify and confirm." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "target_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "generateRejectFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "无法完整澄清区域信息:站点库存数据不可用,本轮目标协商拒绝。", + "en-US": "The area information cannot be clarified in full because the site inventory is unavailable; this target negotiation round is rejected." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.reject.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "target_reject", + "contracts": ["conclusionLiteralPresent"] + } + }, + { + "step": 3, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "第2轮目标协商:区域已确认为松山湖;节能时间范围仍有疑问(08:00-18:00还是00:00-06:00),请澄清和确认。", + "en-US": "Second-round target negotiation: the area is confirmed as Songshan Lake; an open question remains about the energy-saving time range (08:00-18:00 or 00:00-06:00), please clarify and confirm." + } + }, + "llm": { "script": [ "{\"target_negotiation_description\":\"The area of the wireless energy-saving optimization task has been aligned, see ; an open question remains about the energy-saving time range, see ; please clarify and confirm.\",\"intent_understanding\":null,\"alignment_and_clarification\":[{\"name\":\"area\",\"value\":\"confirmed: the covered site is Songshan Lake\"}],\"request_for_clarification\":[{\"name\":\"time range\",\"value\":\"is the energy-saving period 08:00-18:00 or 00:00-06:00\"}]}" ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "metadata": { + "templateUriEcho": "Negotiation-T/target-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"] + } + }, + { + "step": 4, + "role": "B", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "最终确认的意图:对松山湖站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps。", + "en-US": "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "target_accept", + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] + } + }, + { + "step": 5, + "role": "A", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { "fromStep": 4 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "schema": { + "type": "object", + "properties": { + "site": { "type": "string" }, + "guaranteed_rate": { "type": "string" } + } + }, + "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "site": "Songshan Lake", + "guaranteed_rate": "at least 20Mbps" + }, + "contracts": ["contextKeysInMergedParams"] + } + } + ], + "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2, "distinctMessages": true } + }, + { + "id": "SC-TGT-03", + "summary": "目标协商中断:区域信息持续无法澄清,发起方在第2轮发起协商终止并生成终止消息", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 2 }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "无线节能优化任务的意图理解:对目标站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps;对区域与节能时间范围仍有疑问,请澄清和确认。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "target_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "generateRejectFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 2 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "无法完整澄清区域信息:站点库存数据不可用,本轮目标协商拒绝。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.reject.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "target_reject", + "contracts": ["conclusionLiteralPresent"] + } + }, + { + "step": 3, + "role": "A", + "api": "generateAbortFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 2 }, + "templateUri": "Negotiation-T/common/abort/v1", + "input": { + "text": { + "zh-CN": "协商轮次已达上限,区域信息仍无法澄清,本次协商确认终止。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "abort", + "contracts": ["conclusionLiteralPresent"] + } + } + ], + "expectFlow": { "terminalCondition": "abort", "roundsUsed": 2, "distinctMessages": true } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/llm-responses.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/llm-responses.json new file mode 100644 index 00000000..0ffc5b3c --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/llm-responses.json @@ -0,0 +1,34 @@ +{ + "extract.information.propose.full": "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"e.g. Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"e.g. 20Mbps\"},{\"name\":\"VLANId\",\"value\":null}],\"relationship\":\"OR\"}", + "extract.information.accept.full": "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}]}", + "extract.information.reject.full": "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"cannot be provided because the site inventory is unavailable\"}]}", + "extract.target.propose.full": "{\"target_negotiation_description\":\"The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm.\",\"intent_understanding\":[{\"name\":\"task intent\",\"value\":\"apply wireless energy-saving optimization to the target site during 08:00-18:00\"},{\"name\":\"rate guarantee target\",\"value\":\"at least 20Mbps during the energy-saving period\"}],\"alignment_and_clarification\":null,\"request_for_clarification\":[{\"name\":\"area\",\"value\":\"which site is covered: Songshan Lake or another site\"},{\"name\":\"time range\",\"value\":\"is the energy-saving period 08:00-18:00 or 00:00-06:00\"}]}", + "extract.target.accept.full": "{\"conclusion\":\"Accept\",\"confirmed_intent\":\"The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps.\",\"failure_reason\":null}", + "extract.target.reject.full": "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"The area information cannot be clarified in full because the site inventory is unavailable.\"}", + "extract.feasibility.propose.full": "{\"feasibility_negotiation_description\":\"Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess.\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":[{\"name\":\"adjusted target\",\"value\":\"rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00\"},{\"name\":\"existing constraint\",\"value\":\"at least 10 hours of power supply duration in outage scenarios\"}],\"infeasibility_details_and_proposal\":null}", + "extract.feasibility.accept.full": "{\"conclusion\":\"Accept\",\"feasibility_summary\":\"The adjusted rate guarantee target is achievable and satisfies the outage duration requirement; this negotiation is confirmed as concluded.\"}", + "extract.feasibility.reject.full": "{\"conclusion\":\"Reject\",\"feasibility_summary\":\"The energy-saving target cannot be achieved under the existing power supply constraint; this negotiation is confirmed as concluded.\"}", + "extract.common.abort.full": "{\"termination_reason\":\"The negotiation round limit is reached; this negotiation is confirmed as terminated.\"}", + "extract.information.accept.missing_conclusion": "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}]}", + "extract.information.accept.conclusion_swapped": "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}]}", + "extract.information.reject.conclusion_swapped": "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"cannot be provided because the site inventory is unavailable\"}]}", + "extract.information.propose.missing_items": "{\"relationship\":\"OR\"}", + "extract.information.propose.items_not_array": "{\"items\":\"energy-saving area information, energy-saving rate guarantee target and VLANId\",\"relationship\":\"OR\"}", + "extract.target.accept.missing_confirmed_intent": "{\"conclusion\":\"Accept\",\"confirmed_intent\":null,\"failure_reason\":null}", + "extract.target.reject.missing_failure_reason": "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":null}", + "extract.feasibility.propose.missing_action": "{\"feasibility_negotiation_description\":\"Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved.\",\"contents_to_evaluate\":[{\"name\":\"adjusted target\",\"value\":\"rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00\"}],\"infeasibility_details_and_proposal\":null}", + "extract.feasibility.propose.unknown_action": "{\"feasibility_negotiation_description\":\"Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved.\",\"action\":\"APPROVE_THE_REQUEST\",\"contents_to_evaluate\":[{\"name\":\"adjusted target\",\"value\":\"rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00\"}],\"infeasibility_details_and_proposal\":null}", + "extract.feasibility.propose.evaluation_without_contents": "{\"feasibility_negotiation_description\":\"Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved.\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null}", + "extract.common.abort.missing_termination_reason": "{}", + "semantic.information.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"region\":\"Songshan Lake\"}}", + "semantic.information.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"region\",\"code\":\"missing\",\"message\":\"The region parameter is missing.\"}],\"params\":{}}", + "semantic.target.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[],\"params\":{\"site\":\"Songshan Lake\",\"guaranteed_rate\":\"at least 20Mbps\"}}", + "semantic.target.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"target\",\"errors\":[{\"slot_name\":\"confirmed_intent\",\"code\":\"missing\",\"message\":\"The confirmed intent is missing.\"}],\"params\":{}}", + "semantic.feasibility.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"feasibility\",\"errors\":[],\"params\":{\"feasibility_result\":\"achievable\"}}", + "semantic.feasibility.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"feasibility\",\"errors\":[{\"slot_name\":\"feasibility_summary\",\"code\":\"missing\",\"message\":\"The feasibility summary is missing.\"}],\"params\":{}}", + "semantic.common.abort.full": "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{\"termination_reason\":\"The negotiation round limit is reached.\"}}", + "semantic.information.type_mismatch": "{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[],\"params\":{}}", + "semantic.information.null_type": "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}", + "semantic.shape.missing_verdict": "{\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"region\":\"Songshan Lake\"}}", + "semantic.shape.missing_params": "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]}" +} diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/schemas.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/schemas.json new file mode 100644 index 00000000..dd4b598e --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/schemas.json @@ -0,0 +1,58 @@ +{ + "flat": { + "type": "object", + "properties": { + "region": { + "type": "string" + } + } + }, + "noType": { + "properties": { + "region": { + "type": "string" + } + } + }, + "no_type": { + "properties": { + "region": { + "type": "string" + } + } + }, + "nestedArray": { + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "nested_array": { + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "colliding": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "round": { + "type": "integer" + } + } + }, + "empty": {} +} diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/error-code-mapping.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/error-code-mapping.json new file mode 100644 index 00000000..4fc2c3d7 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/error-code-mapping.json @@ -0,0 +1,104 @@ +[ + { + "id": "VAL-MAP-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["error-code-mapping", "map-code", "null-context", "invalid-input"], + "summary": "消息未携带协商上下文元数据时校验判定其不是协商消息,内部无效输入错误码对外映射为协商无效输入错误且不发起任何大模型调用", + "context": null, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", + "messageContains": ["missing negotiation context"], + "llmCalls": 0 + } + }, + { + "id": "VAL-MAP-02", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["error-code-mapping", "map-code", "rule-violation", "round"], + "summary": "协商轮次超限触发的内部规则违反错误码对外映射为协商规则违反错误码,并保留轮次越界槽位错误", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 6, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_rule_violation", + "slotErrors": [ { "slot": "round", "code": "out_of_range" } ], + "llmCalls": 0 + } + }, + { + "id": "VAL-MAP-03", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["error-code-mapping", "map-code", "semantic-rejected"], + "summary": "大模型语义校验否决消息时内部语义拒绝错误码对外映射为协商语义拒绝错误码,并保留大模型报告的槽位错误", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.reject" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_semantic_rejected", + "slotErrors": [ { "slot": "region", "code": "missing" } ], + "llmCalls": 1 + } + }, + { + "id": "VAL-MAP-04", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["error-code-mapping", "map-code", "llm-infrastructure-error", "retry-exhausted"], + "summary": "大模型服务连续两次不可用并耗尽重试后,内部基础设施错误码对外映射为协商大模型基础设施错误码,错误详情以 _llm 伪槽位保留内部错误码", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "maxAttempts": 2, "script": [ { "$fail": "llm-error" }, { "$fail": "llm-error" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_llm_infrastructure_error", + "messageContains": ["Semantic validation LLM step failed"], + "slotErrors": [ { "slot": "_llm", "code": "validation_llm_infrastructure_error" } ], + "llmCalls": 2 + } + }, + { + "id": "VAL-MAP-05", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["error-code-mapping", "map-code", "template-not-found", "inject"], + "summary": "语义校验提示词资源缺失时内部资源未找到错误码对外映射为 template_not_found 错误码,原始资源异常不透出且不发起任何大模型调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "inject": "failingSemanticValidator", + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "template_not_found", + "messageContains": ["Negotiation semantic validation prompt resource does not exist"], + "llmCalls": 0 + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/happy.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/happy.json new file mode 100644 index 00000000..2bd1df11 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/happy.json @@ -0,0 +1,245 @@ +[ + { + "id": "VAL-HAPPY-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "information", "propose", "merge"], + "summary": "校验信息协商提议消息并提取参数,合并结果含上下文三键与业务参数 region", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-HAPPY-02", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "information", "accept", "merge"], + "summary": "校验信息协商接受消息并提取参数,合并结果含上下文三键与业务参数 region", + "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "golden": "information_accept" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", + "round": 3, + "maxRounds": 5, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-HAPPY-03", + "api": "validateRejectPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "information", "reject", "merge"], + "summary": "校验信息协商拒绝消息并提取参数,合并结果含上下文三键与业务参数 region", + "context": { "id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", "round": 4, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "golden": "information_reject" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", + "round": 4, + "maxRounds": 5, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-HAPPY-04", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "target", "propose", "merge"], + "summary": "校验目标协商提议消息并提取参数,合并结果含上下文三键与站点、速率保障参数", + "context": { "id": "9e8d7c6b-5a4f-4392-8172-6b5c4d3e2f1a", "round": 1, "maxRounds": 3 }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1", + "prompt": { "golden": "target_propose" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "9e8d7c6b-5a4f-4392-8172-6b5c4d3e2f1a", + "round": 1, + "maxRounds": 3, + "site": "Songshan Lake", + "guaranteed_rate": "at least 20Mbps" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-HAPPY-05", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "target", "accept", "merge"], + "summary": "校验目标协商接受消息并提取参数,合并结果含上下文三键与站点、速率保障参数", + "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "prompt": { "golden": "target_accept" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", + "round": 2, + "maxRounds": 3, + "site": "Songshan Lake", + "guaranteed_rate": "at least 20Mbps" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-HAPPY-06", + "api": "validateRejectPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "target", "reject", "merge"], + "summary": "校验目标协商拒绝消息并提取参数,合并结果含上下文三键与站点、速率保障参数", + "context": { "id": "6f5e4d3c-2b1a-4988-7766-5544332211ff", "round": 2, "maxRounds": 3 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "prompt": { "golden": "target_reject" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "6f5e4d3c-2b1a-4988-7766-5544332211ff", + "round": 2, + "maxRounds": 3, + "site": "Songshan Lake", + "guaranteed_rate": "at least 20Mbps" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-HAPPY-07", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "feasibility", "propose", "merge"], + "summary": "校验可行性协商提议消息并提取参数,合并结果含上下文三键与可行性结论参数", + "context": { "id": "2a4b6c8d-0e1f-4a3b-8c5d-9e7f1a2b3c4d", "round": 2, "maxRounds": 4 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "prompt": { "golden": "feasibility_propose" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "2a4b6c8d-0e1f-4a3b-8c5d-9e7f1a2b3c4d", + "round": 2, + "maxRounds": 4, + "feasibility_result": "achievable" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-HAPPY-08", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "feasibility", "accept", "merge"], + "summary": "校验可行性协商接受消息并提取参数,合并结果含上下文三键与可行性结论参数", + "context": { "id": "3b5c7d9e-1f2a-4b3c-8d4e-9f6a1b2c3d4e", "round": 3, "maxRounds": 4 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "prompt": { "golden": "feasibility_accept" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3b5c7d9e-1f2a-4b3c-8d4e-9f6a1b2c3d4e", + "round": 3, + "maxRounds": 4, + "feasibility_result": "achievable" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-HAPPY-09", + "api": "validateRejectPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "feasibility", "reject", "merge"], + "summary": "校验可行性协商拒绝消息并提取参数,合并结果含上下文三键与可行性结论参数", + "context": { "id": "4c6d8e0f-2a3b-4c5d-8e6f-0a7b1c2d3e4f", "round": 3, "maxRounds": 4 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "prompt": { "golden": "feasibility_reject" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "4c6d8e0f-2a3b-4c5d-8e6f-0a7b1c2d3e4f", + "round": 3, + "maxRounds": 4, + "feasibility_result": "achievable" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-HAPPY-10", + "api": "validateAbortPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "abort", "common", "merge"], + "summary": "校验协商终止消息并提取参数,合并结果含上下文三键与终止原因参数", + "context": { "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "prompt": { "golden": "abort" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$ref": "responses/semantic.common.abort.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", + "round": 5, + "maxRounds": 5, + "termination_reason": "The negotiation round limit is reached." + }, + "contracts": ["contextKeysInMergedParams"] + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json new file mode 100644 index 00000000..decc7e81 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json @@ -0,0 +1,236 @@ +[ + { + "id": "VAL-RETRY-01", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["retry", "llm-infrastructure-error", "accept", "information"], + "summary": "语义校验第一次遭遇 LLM 基础设施错误后自动重试,第二次返回合法判定并成功提取参数", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "golden": "information_accept" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { + "maxAttempts": 3, + "script": [ { "$fail": "llm-error" }, { "$ref": "responses/semantic.information.accept.full" } ] + }, + "expect": { + "outcome": "success", + "llmCalls": 2, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-RETRY-02", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["retry", "shape-invalid", "missing-negotiation-type", "propose", "information"], + "summary": "语义返回缺少 negotiation_type 键的无效结构时触发重试,第二次结构合法并成功提取参数", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { + "maxAttempts": 3, + "script": [ + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"region\":\"Songshan Lake\"}}", + { "$ref": "responses/semantic.information.accept.full" } + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 2, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-RETRY-03", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["retry-exhausted", "shape-invalid", "llm-pseudo-slot", "propose", "information"], + "summary": "语义返回连续三种无效结构时重试至耗尽,最终携带 _llm 伪槽位以 LLM 基础设施错误失败", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { + "maxAttempts": 3, + "script": [ + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"region\":\"Songshan Lake\"}}", + { "$ref": "responses/semantic.shape.missing_verdict" }, + { "$ref": "responses/semantic.shape.missing_params" } + ] + }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_llm_infrastructure_error", + "slotErrors": [ { "slot": "_llm", "code": "validation_llm_infrastructure_error" } ], + "llmCalls": 3 + } + }, + { + "id": "VAL-RETRY-04", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["retry", "max-attempts-1", "shape-invalid", "llm-pseudo-slot", "propose"], + "summary": "maxAttempts 配置为 1 时语义结构错误不获重试机会,单次调用即以 LLM 基础设施错误失败", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { + "maxAttempts": 1, + "script": [ "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"region\":\"Songshan Lake\"}}" ] + }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_llm_infrastructure_error", + "slotErrors": [ { "slot": "_llm", "code": "validation_llm_infrastructure_error" } ], + "llmCalls": 1 + } + }, + { + "id": "VAL-RETRY-05", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["retry-exhausted", "llm-infrastructure-error", "max-attempts-2", "llm-pseudo-slot", "accept"], + "summary": "LLM 基础设施错误连续出现时按 maxAttempts=2 重试两次后耗尽,携带 _llm 伪槽位失败", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "golden": "information_accept" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { + "maxAttempts": 2, + "script": [ { "$fail": "llm-error" }, { "$fail": "llm-error" } ] + }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_llm_infrastructure_error", + "slotErrors": [ { "slot": "_llm", "code": "validation_llm_infrastructure_error" } ], + "llmCalls": 2 + } + }, + { + "id": "VAL-RETRY-06", + "api": "validateRejectPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["retry", "non-json", "max-attempts-2", "reject", "information"], + "summary": "语义返回无法解析的非 JSON 内容后按 maxAttempts=2 重试,第二次返回合法判定并成功", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "golden": "information_reject" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { + "maxAttempts": 2, + "script": [ { "$fail": "non-json" }, { "$ref": "responses/semantic.information.accept.full" } ] + }, + "expect": { + "outcome": "success", + "llmCalls": 2, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-RETRY-07", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["retry", "max-attempts-3", "blank-content", "non-json", "propose"], + "summary": "连续两次空内容与非 JSON 应答后第三次成功,重试预算 maxAttempts=3 恰好用满", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { + "maxAttempts": 3, + "script": [ + { "$fail": "blank-content" }, + { "$fail": "non-json" }, + { "$ref": "responses/semantic.information.accept.full" } + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 3, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-RETRY-08", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P2", + "tags": ["no-retry", "runtime-exception", "llm-infrastructure-error", "accept", "information"], + "summary": "语义步骤抛出非 LLMError 的原始运行时异常时不重试,单次调用即包装为 LLM 基础设施错误失败", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "golden": "information_accept" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { + "maxAttempts": 3, + "script": [ { "$fail": "runtime-exception" }, { "$ref": "responses/semantic.information.accept.full" } ] + }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_llm_infrastructure_error", + "slotErrors": [ { "slot": "_llm", "code": "validation_llm_infrastructure_error" } ], + "llmCalls": 1 + } + }, + { + "id": "VAL-RETRY-09", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P2", + "tags": ["no-retry", "null-response", "llm-infrastructure-error", "accept", "information"], + "summary": "语义步骤返回空响应(null)时不重试,单次调用即包装为 LLM 基础设施错误失败", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "golden": "information_accept" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { + "maxAttempts": 3, + "script": [ { "$fail": "null-response" }, { "$ref": "responses/semantic.information.accept.full" } ] + }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_llm_infrastructure_error", + "slotErrors": [ { "slot": "_llm", "code": "validation_llm_infrastructure_error" } ], + "llmCalls": 1 + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/merge-and-schema.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/merge-and-schema.json new file mode 100644 index 00000000..9f313ab7 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/merge-and-schema.json @@ -0,0 +1,130 @@ +[ + { + "id": "VAL-MERGE-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["merge", "context-wins", "key-conflict", "colliding-schema", "propose", "information"], + "summary": "业务参数与上下文 id、round 键冲突时上下文参数优先,LLM 提取的错误取值不覆盖规则层结果", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/colliding" }, + "llm": { + "script": [ + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"id\":\"00000000-0000-0000-0000-000000000000\",\"round\":99,\"region\":\"Songshan Lake\"}}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-MERGE-02", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["merge", "no-type-schema", "schema-wrapping", "accept", "information"], + "summary": "调用方未声明 type 关键字的参数 schema 被自动包装为对象 schema,语义校验与参数提取不受影响", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "golden": "information_accept" }, + "schema": { "$ref": "schemas/noType" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-MERGE-03", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["null-context", "invalid-input", "rule-gate", "propose"], + "summary": "校验提议消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", + "context": null, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "text": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息。" }, + "schema": { "$ref": "schemas/flat" }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", + "messageContains": ["missing negotiation context"], + "llmCalls": 0 + } + }, + { + "id": "VAL-MERGE-04", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["null-context", "invalid-input", "rule-gate", "accept"], + "summary": "校验接受消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", + "context": null, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "text": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" }, + "schema": { "$ref": "schemas/flat" }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", + "messageContains": ["missing negotiation context"], + "llmCalls": 0 + } + }, + { + "id": "VAL-MERGE-05", + "api": "validateRejectPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["null-context", "invalid-input", "rule-gate", "reject"], + "summary": "校验拒绝消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", + "context": null, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "text": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" }, + "schema": { "$ref": "schemas/flat" }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", + "messageContains": ["missing negotiation context"], + "llmCalls": 0 + } + }, + { + "id": "VAL-MERGE-06", + "api": "validateAbortPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["null-context", "invalid-input", "rule-gate", "abort", "common"], + "summary": "校验终止消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", + "context": null, + "templateUri": "Negotiation-T/common/abort/v1", + "prompt": { "text": "协商轮次已达上限,本次协商确认终止。" }, + "schema": { "$ref": "schemas/flat" }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", + "messageContains": ["missing negotiation context"], + "llmCalls": 0 + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/programming-errors.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/programming-errors.json new file mode 100644 index 00000000..731d6315 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/programming-errors.json @@ -0,0 +1,321 @@ +[ + { + "id": "VAL-PROG-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "null-prompt", "propose"], + "summary": "校验提议消息时未传入消息文本,直接抛出空指针异常且不调用大模型", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NullPointerException", + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-02", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "blank-prompt", "doc-gap", "propose"], + "summary": "校验提议消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针)", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "text": " " }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["Prompt must not be blank."], + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-03", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "null-prompt", "accept"], + "summary": "校验接受消息时未传入消息文本,直接抛出空指针异常且不调用大模型", + "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NullPointerException", + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-04", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "blank-prompt", "doc-gap", "accept"], + "summary": "校验接受消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针)", + "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "text": " " }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["Prompt must not be blank."], + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-05", + "api": "validateRejectPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "null-prompt", "reject"], + "summary": "校验拒绝消息时未传入消息文本,直接抛出空指针异常且不调用大模型", + "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NullPointerException", + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-06", + "api": "validateRejectPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "blank-prompt", "doc-gap", "reject"], + "summary": "校验拒绝消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针)", + "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "prompt": { "text": " " }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["Prompt must not be blank."], + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-07", + "api": "validateAbortPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "null-prompt", "abort"], + "summary": "校验终止消息时未传入消息文本,直接抛出空指针异常且不调用大模型", + "context": { "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NullPointerException", + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-08", + "api": "validateAbortPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "blank-prompt", "doc-gap", "abort"], + "summary": "校验终止消息时消息文本为空白,抛出参数异常(abort 方法的 javadoc 未声明该行为,doc-gap 探针)", + "context": { "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "prompt": { "text": " " }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["Prompt must not be blank."], + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-09", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "null-context", "propose"], + "summary": "校验提议消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", + "context": null, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", + "messageContains": ["missing negotiation context"], + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-10", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "null-context", "accept"], + "summary": "校验接受消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", + "context": null, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "golden": "information_accept" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", + "messageContains": ["missing negotiation context"], + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-11", + "api": "validateRejectPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "null-context", "reject"], + "summary": "校验拒绝消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", + "context": null, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "prompt": { "golden": "target_reject" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", + "messageContains": ["missing negotiation context"], + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-12", + "api": "validateAbortPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "null-context", "abort"], + "summary": "校验终止消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", + "context": null, + "templateUri": "Negotiation-T/common/abort/v1", + "prompt": { "golden": "abort" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", + "messageContains": ["missing negotiation context"], + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-13", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "uri-phase-mismatch", "propose"], + "summary": "校验提议消息时模板 URI 声明的是接受-拒绝阶段,阶段不匹配抛出参数异常且不调用大模型", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["expected phase PROPOSE"], + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-14", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "uri-phase-mismatch", "accept"], + "summary": "校验接受消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型", + "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_accept" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["expected phase ACCEPT"], + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-15", + "api": "validateRejectPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "uri-phase-mismatch", "reject"], + "summary": "校验拒绝消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型", + "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1", + "prompt": { "golden": "target_reject" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["expected phase REJECT"], + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-16", + "api": "validateAbortPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["programming-error", "uri-phase-mismatch", "abort"], + "summary": "校验终止消息时模板 URI 声明的是类型化提议模板而非通用终止模板,抛出参数异常且不调用大模型", + "context": { "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "abort" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["expected phase ABORT"], + "llmCalls": 0 + } + }, + { + "id": "VAL-PROG-17", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["programming-error", "uri-phase-mismatch", "common-uri", "probe"], + "summary": "校验提议消息时误用通用终止模板 URI,类型化阶段不接受 common 段,抛出参数异常", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "IllegalArgumentException", + "messageContains": ["expected phase PROPOSE"], + "llmCalls": 0 + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/rule-gate.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/rule-gate.json new file mode 100644 index 00000000..7ac712d5 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/rule-gate.json @@ -0,0 +1,177 @@ +[ + { + "id": "VAL-RULE-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["rule-gate", "uuid", "invalid-uuid"], + "summary": "协商会话标识仅 8 位而非 36 位 UUID 时,消息在规则校验阶段即被拒绝并报告标识格式槽位错误,不发起任何大模型调用", + "context": { "id": "3dbc13b5", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_rule_violation", + "slotErrors": [ { "slot": "id", "code": "invalid_uuid" } ], + "llmCalls": 0 + } + }, + { + "id": "VAL-RULE-02", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["rule-gate", "uuid", "invalid-uuid", "length-boundary"], + "summary": "协商会话标识为 35 位(末段缺少一位十六进制位)时同样被规则门拒绝,UUID 长度边界从严执行", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d", "round": 1, "maxRounds": 3 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_rule_violation", + "slotErrors": [ { "slot": "id", "code": "invalid_uuid" } ], + "llmCalls": 0 + } + }, + { + "id": "VAL-RULE-03", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P2", + "tags": ["rule-gate", "uuid", "invalid-uuid", "non-hex"], + "summary": "协商会话标识长度与 8-4-4-4-12 分段均正确但首段含非十六进制字符 g 时,规则门仍按 UUID 格式拒绝", + "context": { "id": "gdbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_rule_violation", + "slotErrors": [ { "slot": "id", "code": "invalid_uuid" } ], + "llmCalls": 0 + } + }, + { + "id": "VAL-RULE-04", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["rule-gate", "round", "boundary", "happy"], + "summary": "当前轮次等于最大轮次预算(第 5 轮即最后一轮)时消息仍可通过规则校验并正常提取参数,只有超出预算才被拒绝", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 5, + "maxRounds": 5, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-RULE-05", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["rule-gate", "round", "out-of-range"], + "summary": "协商轮次超出最大轮次预算(第 6 轮对 5 轮上限)时规则门拒绝消息并报告轮次越界槽位错误,不发起任何大模型调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 6, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_rule_violation", + "slotErrors": [ { "slot": "round", "code": "out_of_range" } ], + "llmCalls": 0 + } + }, + { + "id": "VAL-RULE-06", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["rule-gate", "combined-errors", "uuid", "round"], + "summary": "会话标识非法且轮次超限同时发生时,规则门一次性报告标识格式与轮次越界两条槽位错误,不发起任何大模型调用", + "context": { "id": "negotiation-session-001", "round": 6, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$fail": "assertion" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_rule_violation", + "slotErrors": [ + { "slot": "id", "code": "invalid_uuid" }, + { "slot": "round", "code": "out_of_range" } + ], + "llmCalls": 0 + } + }, + { + "id": "VAL-RULE-07", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["rule-gate", "round", "boundary", "single-round", "happy"], + "summary": "单轮预算(最大轮次为 1)的第 1 轮消息可通过规则校验并正常提取参数,轮次下限边界同样放行", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 1 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 1, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-RULE-08", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P2", + "tags": ["rule-gate", "uuid", "uppercase-hex", "happy"], + "summary": "协商会话标识使用大写十六进制 UUID 时规则门同样放行,消息正常校验并提取参数,合并结果原样保留大写标识", + "context": { "id": "3DBC13B5-BD57-4C2B-B503-24E381B6C8D3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3DBC13B5-BD57-4C2B-B503-24E381B6C8D3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/semantic-verdicts.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/semantic-verdicts.json new file mode 100644 index 00000000..7a2e2e5b --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/semantic-verdicts.json @@ -0,0 +1,374 @@ +[ + { + "id": "VAL-SEM-01", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["semantic-verdict", "information", "accept", "type-consistent"], + "summary": "语义判定通过且上报类型与信息协商模板一致时,接受消息校验成功并合并出区域参数", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "golden": "information_accept" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-SEM-02", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["semantic-verdict", "target", "accept", "type-consistent"], + "summary": "语义判定通过且上报类型与目标协商模板一致时,接受消息校验成功并合并出站点与速率保障参数", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "prompt": { "golden": "target_accept" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 3, + "site": "Songshan Lake", + "guaranteed_rate": "at least 20Mbps" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-SEM-03", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["semantic-verdict", "feasibility", "propose", "type-consistent"], + "summary": "语义判定通过且上报类型与可行性协商模板一致时,提议消息校验成功并合并出可行性结论参数", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "prompt": { "golden": "feasibility_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 3, + "maxRounds": 5, + "feasibility_result": "achievable" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-SEM-04", + "api": "validateAbortPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["semantic-verdict", "abort", "common", "type-consistent"], + "summary": "终止消息语义判定通过且未上报协商类型时校验成功,合并出终止原因参数", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "prompt": { "golden": "abort" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ { "$ref": "responses/semantic.common.abort.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 5, + "maxRounds": 5, + "termination_reason": "The negotiation round limit is reached." + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-SEM-05", + "api": "validateRejectPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["semantic-verdict", "information", "reject", "type-consistent"], + "summary": "语义判定通过且类型一致时,拒绝侧消息同样校验成功,类型一致性不区分接受或拒绝动作", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 4 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "golden": "information_reject" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 4, + "region": "Songshan Lake" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-SEM-06", + "api": "validateAbortPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P2", + "tags": ["semantic-verdict", "abort", "common", "type-unchecked", "probe"], + "summary": "公共终止模板未声明协商类型,判定通过时即使大模型上报了类型也照常校验成功", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 4, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "prompt": { "golden": "abort" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}" ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 4, + "maxRounds": 5 + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-SEM-07", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["semantic-verdict", "information", "propose", "type-mismatch"], + "summary": "大模型判定通过但上报成目标协商类型时,信息协商提议被语义拒绝并指出类型不一致", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.type_mismatch" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_semantic_rejected", + "slotErrors": [ { "slot": "section.target", "code": "template_type_mismatch" } ], + "llmCalls": 1 + } + }, + { + "id": "VAL-SEM-08", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["semantic-verdict", "information", "propose", "null-type"], + "summary": "大模型判定通过但未上报协商类型时,信息协商提议被语义拒绝并按模板类型报类型不一致", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.null_type" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_semantic_rejected", + "slotErrors": [ { "slot": "section.info_static", "code": "template_type_mismatch" } ], + "llmCalls": 1 + } + }, + { + "id": "VAL-SEM-09", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["semantic-verdict", "information", "propose", "unknown-type", "probe"], + "summary": "大模型判定通过但上报未知协商类型字符串时,信息协商提议被语义拒绝", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"bogus\",\"errors\":[],\"params\":{}}" ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_semantic_rejected", + "slotErrors": [ { "slot": "section.info_static", "code": "template_type_mismatch" } ], + "llmCalls": 1 + } + }, + { + "id": "VAL-SEM-10", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P2", + "tags": ["semantic-verdict", "information", "propose", "case-sensitivity", "probe"], + "summary": "大模型上报的协商类型仅大小写不同也被视为类型不一致,信息协商提议被语义拒绝", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 3 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"Information\",\"errors\":[],\"params\":{}}" ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_semantic_rejected", + "slotErrors": [ { "slot": "section.info_static", "code": "template_type_mismatch" } ], + "llmCalls": 1 + } + }, + { + "id": "VAL-SEM-11", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["semantic-verdict", "information", "propose", "verdict-false"], + "summary": "大模型语义判定不通过时,信息协商提议被拒绝并返回缺失的区域参数槽位错误", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.reject" } ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_semantic_rejected", + "slotErrors": [ { "slot": "region", "code": "missing" } ], + "llmCalls": 1 + } + }, + { + "id": "VAL-SEM-12", + "api": "validateRejectPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["semantic-verdict", "information", "reject", "verdict-false", "type-unchecked"], + "summary": "语义判定不通过时不再核对协商类型,拒绝侧消息只返回大模型报告的槽位错误", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 4 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "golden": "information_reject" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"target\",\"errors\":[{\"slot_name\":\"region\",\"code\":\"missing\",\"message\":\"The region parameter is missing.\"}],\"params\":{}}" ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_semantic_rejected", + "slotErrors": [ { "slot": "region", "code": "missing" } ], + "llmCalls": 1 + } + }, + { + "id": "VAL-SEM-13", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P2", + "tags": ["semantic-verdict", "information", "propose", "empty-errors", "probe"], + "summary": "语义判定不通过且未报告任何槽位错误时,校验仍以语义拒绝失败且槽位错误为空", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}" ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_semantic_rejected", + "llmCalls": 1 + } + }, + { + "id": "VAL-SEM-14", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P2", + "tags": ["semantic-verdict", "information", "propose", "params-dropped", "probe"], + "summary": "语义判定不通过时已提取的参数被丢弃,校验以语义拒绝失败且不合并任何业务参数", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"region\",\"code\":\"missing\",\"message\":\"The region parameter is missing.\"}],\"params\":{\"region\":\"Songshan Lake\"}}" ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_semantic_rejected", + "slotErrors": [ { "slot": "region", "code": "missing" } ], + "llmCalls": 1 + } + }, + { + "id": "VAL-SEM-15", + "api": "validateAbortPromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P1", + "tags": ["semantic-verdict", "abort", "common", "verdict-false"], + "summary": "终止消息语义判定不通过时被拒绝并返回缺失终止原因的槽位错误", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "prompt": { "golden": "abort" }, + "schema": { "$ref": "schemas/empty" }, + "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":\"termination_reason\",\"code\":\"missing\",\"message\":\"The termination reason is missing.\"}],\"params\":{}}" ] }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_semantic_rejected", + "slotErrors": [ { "slot": "termination_reason", "code": "missing" } ], + "llmCalls": 1 + } + }, + { + "id": "VAL-SEM-16", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["semantic-verdict", "information", "propose", "no-retry", "verdict-false"], + "summary": "语义拒绝不触发重试:脚本备有第二次通过的应答也不会被消费,LLM 调用数恒为 1", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { + "maxAttempts": 3, + "script": [ + { "$ref": "responses/semantic.information.reject" }, + { "$ref": "responses/semantic.information.accept.full" } + ] + }, + "expect": { + "outcome": "failure", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_semantic_rejected", + "slotErrors": [ { "slot": "region", "code": "missing" } ], + "llmCalls": 1 + } + }, + { + "id": "VAL-SEM-17", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P2", + "tags": ["semantic-verdict", "information", "propose", "errors-ignored", "probe"], + "summary": "语义判定通过时大模型附带的槽位错误被忽略,校验成功且只合并上下文三键", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "golden": "information_propose" }, + "schema": { "$ref": "schemas/flat" }, + "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"region\",\"code\":\"missing\",\"message\":\"The region parameter is missing.\"}],\"params\":{}}" ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 3, + "maxRounds": 5 + }, + "contracts": ["contextKeysInMergedParams"] + } + } +] From 0190851d4fa1dddbbbe0e007dd60af9aa74dbe70 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sun, 23 Aug 2026 14:07:48 +0800 Subject: [PATCH 161/295] test(negotiation): add jqwik property layer for negotiation content APIs - jqwik 1.8.5 test dependency (parent version property + module dep) - happy layer (~1000 tries, fixed seeds): fromData->validate round-trip context survival for all 3 types + abort, determinism/idempotence of fromData generation - error layer (~100 tries): blank text / phase mismatch -> invalid_input, missing fields -> slot_missing, round > maxRounds -> rule_violation - error-code partition (7-code set, retryable iff extract_failed / llm_infrastructure_error), 50+ key large-param merge with context-wins collisions - gitignore .jqwik-database and __pycache__ Signed-off-by: codetalks-hw --- .gitignore | 2 + a2a-t-negotiation/pom.xml | 6 + .../ErrorCodePartitionPropertyTest.java | 257 ++++++++++++++ .../FromDataDeterminismPropertyTest.java | 141 ++++++++ .../FromDataRoundTripPropertyTest.java | 152 +++++++++ .../LargeParamsMergePropertyTest.java | 111 ++++++ .../NegotiationErrorPathPropertyTest.java | 323 ++++++++++++++++++ .../property/PropertyArbitraries.java | 256 ++++++++++++++ .../negotiation/property/PropertyHarness.java | 185 ++++++++++ pom.xml | 1 + 10 files changed, 1434 insertions(+) create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/ErrorCodePartitionPropertyTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/FromDataDeterminismPropertyTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/FromDataRoundTripPropertyTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/LargeParamsMergePropertyTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/NegotiationErrorPathPropertyTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyArbitraries.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyHarness.java diff --git a/.gitignore b/.gitignore index cab3d124..32b1283a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ task_plan.md eval-results/ eval-run*.log eval-smoke.json +.jqwik-database +__pycache__/ diff --git a/a2a-t-negotiation/pom.xml b/a2a-t-negotiation/pom.xml index dc9c8e14..8e98abf4 100644 --- a/a2a-t-negotiation/pom.xml +++ b/a2a-t-negotiation/pom.xml @@ -50,6 +50,12 @@ ${junit.jupiter.version} test + + net.jqwik + jqwik + ${jqwik.version} + test + ch.qos.logback logback-classic diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/ErrorCodePartitionPropertyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/ErrorCodePartitionPropertyTest.java new file mode 100644 index 00000000..9cd8553a --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/ErrorCodePartitionPropertyTest.java @@ -0,0 +1,257 @@ +package net.openan.a2at.sdk.negotiation.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.Set; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.testdata.LlmFailMarker; +import net.openan.a2at.sdk.negotiation.testdata.ScriptedNegotiationLlmClient; + +/** + * Error-code partition property layer (design §8.3): the public error-code universe partitions into exactly the seven + * negotiation codes, and the retryable partition is exactly the two LLM-step codes. + * + *

        Every trigger runs with the attempt limit fixed at {@link PropertyHarness#MAX_ATTEMPTS}, so the retryable + * partition has an operational definition observable from the outside: a failure code is retryable if and only if the + * scripted LLM client was consumed to the attempt limit. Pre-LLM failures (0 calls) and non-retryable in-step failures + * (1 call) never reach the limit. + * + * @since 2026-08 + */ +class ErrorCodePartitionPropertyTest { + + /** The complete public negotiation error-code set of the content layer. */ + private static final Set NEGOTIATION_ERROR_CODES = Set.of( + A2ATErrorCodes.TEMPLATE_NOT_FOUND, + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, + A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, + A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, + A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, + A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR); + + /** The retryable partition: exactly the two LLM-step codes of the design document. */ + private static final Set RETRYABLE_ERROR_CODES = Set.of( + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR); + + @Property(tries = 100, seed = "20260920") + void everyFailureCarriesACodeFromTheSevenCodeSet( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("failureTriggers") FailureTrigger trigger) { + FailureOutcome outcome = trigger.run(language, context); + assertTrue( + NEGOTIATION_ERROR_CODES.contains(outcome.code()), + "code '" + outcome.code() + "' is outside the seven negotiation error codes"); + assertEquals(trigger.expectedCode(), outcome.code()); + assertEquals( + RETRYABLE_ERROR_CODES.contains(outcome.code()), + outcome.llmCalls() == PropertyHarness.MAX_ATTEMPTS, + "a failure is retried to the attempt limit if and only if its code is retryable, but code " + + outcome.code() + " made " + outcome.llmCalls() + " LLM call(s)"); + } + + /** The normalized outcome of one triggered failure: its public error code and its exact LLM call count. */ + private record FailureOutcome(String code, int llmCalls) {} + + /** One deterministic trigger of one public failure code. */ + private enum FailureTrigger { + BLANK_TEXT { + @Override + String expectedCode() { + return A2ATErrorCodes.NEGOTIATION_INVALID_INPUT; + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + " ", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + CONCLUSION_MISMATCH { + @Override + String expectedCode() { + return A2ATErrorCodes.NEGOTIATION_INVALID_INPUT; + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = PropertyHarness.scripted( + "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"no agreement\"}"); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateAcceptFromText( + "I must refuse the current offer.", + context, + PropertyHarness.templateUri("Negotiation-T/target-negotiation/accept-reject/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + MISSING_REQUIRED_FIELD { + @Override + String expectedCode() { + return A2ATErrorCodes.NEGOTIATION_SLOT_MISSING; + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = PropertyHarness.scripted("{\"relationship\":null}"); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + ROUND_ABOVE_BUDGET { + @Override + String expectedCode() { + return A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION; + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + NegotiationContext overBudget = + new NegotiationContext(context.id(), context.maxRounds() + 1, context.maxRounds()); + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationParamExtractionException.class, + () -> service.validateProposePromptAndDataFilling( + "Rendered negotiation message text.", + overBudget, + PropertyHarness.objectSchema(Map.of()), + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + SEMANTIC_REJECTED { + @Override + String expectedCode() { + return A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED; + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = PropertyHarness.scripted( + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\"," + + "\"errors\":[{\"slot_name\":\"region\",\"code\":\"missing\"," + + "\"message\":\"The region parameter is missing.\"}],\"params\":{}}"); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationParamExtractionException.class, + () -> service.validateProposePromptAndDataFilling( + "Rendered negotiation message text.", + context, + PropertyHarness.objectSchema(Map.of()), + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + EXTRACT_FAILED { + @Override + String expectedCode() { + return A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED; + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = PropertyHarness.failing(LlmFailMarker.NON_JSON); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + LLM_INFRASTRUCTURE_ERROR { + @Override + String expectedCode() { + return A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR; + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = PropertyHarness.failing(LlmFailMarker.RUNTIME_EXCEPTION); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + TEMPLATE_NOT_FOUND { + @Override + String expectedCode() { + return A2ATErrorCodes.TEMPLATE_NOT_FOUND; + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = + PropertyHarness.serviceWithFailingTemplateLoader(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }; + + abstract String expectedCode(); + + abstract FailureOutcome run(String language, NegotiationContext context); + } + + // ------------------------------------------------------------------ providers + + @Provide + Arbitrary languages() { + return PropertyArbitraries.languages(); + } + + @Provide + Arbitrary contexts() { + return PropertyArbitraries.contexts(); + } + + @Provide + Arbitrary failureTriggers() { + return Arbitraries.of(FailureTrigger.values()); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/FromDataDeterminismPropertyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/FromDataDeterminismPropertyTest.java new file mode 100644 index 00000000..e32e2e7b --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/FromDataDeterminismPropertyTest.java @@ -0,0 +1,141 @@ +package net.openan.a2at.sdk.negotiation.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import net.jqwik.api.Arbitrary; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.testdata.ScriptedNegotiationLlmClient; + +/** + * Determinism property layer (design §8.3): the same from-data input produces fully equal output on repeated runs. + * + *

        Each property runs the same typed input through two independently assembled services — proving there is no + * hidden per-instance state — and asserts record equality of the two {@link MetadataContent} results. The assertion + * client proves the runs never touch the LLM. Each property runs 1000 samples under a fixed seed. + * + * @since 2026-08 + */ +class FromDataDeterminismPropertyTest { + + @Property(tries = 1000, seed = "20260831") + void proposeGenerationIsDeterministic( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("proposeContents") NegotiationProposeContent content) { + NegotiationProposeData data = new NegotiationProposeData(context, content); + TemplateUri templateUri = PropertyHarness.templateUri(proposeUriOf(content)); + MetadataContent first = service(language).generateProposeFromData(data, templateUri); + MetadataContent second = service(language).generateProposeFromData(data, templateUri); + assertEquals(first, second); + } + + @Property(tries = 1000, seed = "20260901") + void acceptGenerationIsDeterministic( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("acceptContents") NegotiationEndingContent content) { + NegotiationEndingData data = new NegotiationEndingData(context, content); + TemplateUri templateUri = PropertyHarness.templateUri(acceptRejectUriOf(content)); + MetadataContent first = service(language).generateAcceptFromData(data, templateUri); + MetadataContent second = service(language).generateAcceptFromData(data, templateUri); + assertEquals(first, second); + } + + @Property(tries = 1000, seed = "20260902") + void rejectGenerationIsDeterministic( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("rejectContents") NegotiationEndingContent content) { + NegotiationEndingData data = new NegotiationEndingData(context, content); + TemplateUri templateUri = PropertyHarness.templateUri(acceptRejectUriOf(content)); + MetadataContent first = service(language).generateRejectFromData(data, templateUri); + MetadataContent second = service(language).generateRejectFromData(data, templateUri); + assertEquals(first, second); + } + + @Property(tries = 1000, seed = "20260903") + void abortGenerationIsDeterministic( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("abortContents") NegotiationAbortContent content) { + NegotiationAbortData data = new NegotiationAbortData(context, content); + TemplateUri templateUri = PropertyHarness.templateUri("Negotiation-T/common/abort/v1"); + MetadataContent first = service(language).generateAbortFromData(data, templateUri); + MetadataContent second = service(language).generateAbortFromData(data, templateUri); + assertEquals(first, second); + } + + private static NegotiationContentService service(String language) { + // The assertion-only client doubles as the zero-LLM-call proof of the from-data family. + return PropertyHarness.service(language, ScriptedNegotiationLlmClient.assertionOnly()); + } + + private static String proposeUriOf(NegotiationProposeContent content) { + if (content instanceof InformationProposeContent) { + return "Negotiation-T/information-negotiation/propose/v1"; + } + if (content instanceof TargetProposeContent) { + return "Negotiation-T/target-negotiation/propose/v1"; + } + return "Negotiation-T/feasibility-negotiation/propose/v1"; + } + + private static String acceptRejectUriOf(NegotiationEndingContent content) { + if (content instanceof InformationEndingContent) { + return "Negotiation-T/information-negotiation/accept-reject/v1"; + } + if (content instanceof TargetEndingContent) { + return "Negotiation-T/target-negotiation/accept-reject/v1"; + } + return "Negotiation-T/feasibility-negotiation/accept-reject/v1"; + } + + // ------------------------------------------------------------------ providers + + @Provide + Arbitrary languages() { + return PropertyArbitraries.languages(); + } + + @Provide + Arbitrary contexts() { + return PropertyArbitraries.contexts(); + } + + @Provide + Arbitrary proposeContents() { + return PropertyArbitraries.proposeContents(); + } + + @Provide + Arbitrary acceptContents() { + return PropertyArbitraries.endingContents(NegotiationConclusion.ACCEPT); + } + + @Provide + Arbitrary rejectContents() { + return PropertyArbitraries.endingContents(NegotiationConclusion.REJECT); + } + + @Provide + Arbitrary abortContents() { + return PropertyArbitraries.abortContents(); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/FromDataRoundTripPropertyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/FromDataRoundTripPropertyTest.java new file mode 100644 index 00000000..fd811035 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/FromDataRoundTripPropertyTest.java @@ -0,0 +1,152 @@ +package net.openan.a2at.sdk.negotiation.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.testdata.ScriptedNegotiationLlmClient; + +/** + * Happy-path property layer (design §8.3): round-trip over arbitrary legal typed content. + * + *

        Every property generates a negotiation message from typed data with the production wiring, feeds the rendered + * text into the matching validate API with an accepting scripted semantic verdict, and asserts that the merged + * parameters carry the three context keys with the original values surviving untouched. Each property runs 1000 + * samples under a fixed seed. + * + * @since 2026-08 + */ +class FromDataRoundTripPropertyTest { + + private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; + + private static final String TARGET_PROPOSE_URI = "Negotiation-T/target-negotiation/propose/v1"; + + private static final String FEASIBILITY_PROPOSE_URI = "Negotiation-T/feasibility-negotiation/propose/v1"; + + private static final String COMMON_ABORT_URI = "Negotiation-T/common/abort/v1"; + + private static final Map FLAT_SCHEMA = PropertyHarness.objectSchema(Map.of()); + + @Property(tries = 1000, seed = "20260823") + void informationProposeRoundTripsThroughValidation( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("informationProposeContents") InformationProposeContent content) { + roundTrip(language, context, new NegotiationProposeData(context, content), INFORMATION_PROPOSE_URI, "information"); + } + + @Property(tries = 1000, seed = "20260824") + void targetProposeRoundTripsThroughValidation( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("targetProposeContents") TargetProposeContent content) { + roundTrip(language, context, new NegotiationProposeData(context, content), TARGET_PROPOSE_URI, "target"); + } + + @Property(tries = 1000, seed = "20260825") + void feasibilityProposeRoundTripsThroughValidation( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("feasibilityProposeContents") FeasibilityProposeContent content) { + roundTrip(language, context, new NegotiationProposeData(context, content), FEASIBILITY_PROPOSE_URI, "feasibility"); + } + + @Property(tries = 1000, seed = "20260826") + void abortRoundTripsThroughValidation( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("abortContents") NegotiationAbortContent content) { + ScriptedNegotiationLlmClient llm = + PropertyHarness.scripted(PropertyHarness.semanticVerdict(null, Map.of())); + NegotiationContentService service = PropertyHarness.service(language, llm); + TemplateUri templateUri = PropertyHarness.templateUri(COMMON_ABORT_URI); + MetadataContent message = + service.generateAbortFromData(new NegotiationAbortData(context, content), templateUri); + assertMessageInvariants(message, templateUri, context); + FilledParamData filled = service.validateAbortPromptAndDataFilling( + message.promptText(), context, FLAT_SCHEMA, templateUri); + assertContextSurvives(filled, context); + assertEquals(1, llm.callCount()); + } + + private void roundTrip( + String language, + NegotiationContext context, + NegotiationProposeData data, + String rawUri, + String negotiationType) { + ScriptedNegotiationLlmClient llm = + PropertyHarness.scripted(PropertyHarness.semanticVerdict(negotiationType, Map.of())); + NegotiationContentService service = PropertyHarness.service(language, llm); + TemplateUri templateUri = PropertyHarness.templateUri(rawUri); + MetadataContent message = service.generateProposeFromData(data, templateUri); + assertMessageInvariants(message, templateUri, context); + FilledParamData filled = + service.validateProposePromptAndDataFilling(message.promptText(), context, FLAT_SCHEMA, templateUri); + assertContextSurvives(filled, context); + assertEquals(1, llm.callCount()); + } + + private static void assertMessageInvariants( + MetadataContent message, TemplateUri templateUri, NegotiationContext context) { + assertEquals(templateUri.uri(), message.templateUri()); + assertEquals(context, message.negotiationContext()); + assertFalse(message.promptText().contains("{{"), "the rendered text must not leak unfilled slots"); + assertTrue(message.promptText() != null && !message.promptText().isBlank()); + } + + private static void assertContextSurvives(FilledParamData filled, NegotiationContext context) { + assertEquals(context.id(), filled.data().get("id")); + assertEquals(context.round(), filled.data().get("round")); + assertEquals(context.maxRounds(), filled.data().get("maxRounds")); + } + + // ------------------------------------------------------------------ providers + + @Provide + Arbitrary languages() { + return PropertyArbitraries.languages(); + } + + @Provide + Arbitrary contexts() { + return PropertyArbitraries.contexts(); + } + + @Provide + Arbitrary informationProposeContents() { + return PropertyArbitraries.informationProposeContents(); + } + + @Provide + Arbitrary targetProposeContents() { + return PropertyArbitraries.targetProposeContents(); + } + + @Provide + Arbitrary feasibilityProposeContents() { + return PropertyArbitraries.feasibilityProposeContents(); + } + + @Provide + Arbitrary abortContents() { + return PropertyArbitraries.abortContents(); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/LargeParamsMergePropertyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/LargeParamsMergePropertyTest.java new file mode 100644 index 00000000..b68d8811 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/LargeParamsMergePropertyTest.java @@ -0,0 +1,111 @@ +package net.openan.a2at.sdk.negotiation.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Combinators; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.testdata.ScriptedNegotiationLlmClient; + +/** + * Large-parameter-space property (design §8.3): extraction and merge correctness for parameter maps of 50+ keys in + * the shape of telecom network-element configuration negotiation. + * + *

        The scripted semantic validator returns every generated key with its exact value, plus sentinel values for the + * three context keys. The property asserts the merged result carries every extracted key losslessly, keeps the exact + * map size (context keys + extracted keys, collisions notwithstanding), and lets the context win every collision. + * + * @since 2026-08 + */ +class LargeParamsMergePropertyTest { + + private static final List PARAM_BASES = List.of( + "gnb.cell", + "gnb.carrier", + "amf.slice", + "amf.paging", + "smf.pdu_session", + "smf.charging", + "upf.tunnel", + "upf.queuing", + "ne.power", + "ne.cooling", + "transport.bearer", + "transport.latency"); + + @Property(tries = 300, seed = "20260930") + void largeTelecomParameterSpacesMergeLosslessly( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("telecomParams") Map params) { + Map withCollisions = new LinkedHashMap<>(params); + withCollisions.put("id", "sentinel-session-id"); + withCollisions.put("round", -999); + withCollisions.put("maxRounds", -1); + Map properties = new LinkedHashMap<>(); + for (Map.Entry entry : params.entrySet()) { + properties.put(entry.getKey(), PropertyHarness.typeSchema(entry.getValue())); + } + ScriptedNegotiationLlmClient llm = + PropertyHarness.scripted(PropertyHarness.semanticVerdict("information", withCollisions)); + NegotiationContentService service = PropertyHarness.service(language, llm); + FilledParamData filled = service.validateProposePromptAndDataFilling( + "Telecom network element parameter filling request.", + context, + PropertyHarness.objectSchema(properties), + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1")); + assertEquals(context.id(), filled.data().get("id"), "the context id must win the collision"); + assertEquals(context.round(), filled.data().get("round"), "the context round must win the collision"); + assertEquals(context.maxRounds(), filled.data().get("maxRounds"), "the context maxRounds must win the collision"); + for (Map.Entry entry : params.entrySet()) { + assertEquals( + entry.getValue(), + filled.data().get(entry.getKey()), + "extracted parameter '" + entry.getKey() + "' must survive the merge"); + } + assertEquals(params.size() + 3, filled.data().size(), "merged size = context keys + non-colliding extracted keys"); + assertEquals(1, llm.callCount()); + } + + // ------------------------------------------------------------------ providers + + @Provide + Arbitrary languages() { + return PropertyArbitraries.languages(); + } + + @Provide + Arbitrary contexts() { + return PropertyArbitraries.contexts(); + } + + @Provide + Arbitrary> telecomParams() { + return Arbitraries.maps(telecomParamKeys(), telecomParamValues()).ofMinSize(50).ofMaxSize(80); + } + + private static Arbitrary telecomParamKeys() { + return Combinators.combine(Arbitraries.of(PARAM_BASES), Arbitraries.integers().between(0, 99)) + .as((base, index) -> base + "." + index); + } + + private static Arbitrary telecomParamValues() { + return Arbitraries.oneOf( + Arbitraries.strings() + .withChars("abcdefghijklmnopqrstuvwxyz_0123456789") + .ofMinLength(1) + .ofMaxLength(12), + Arbitraries.integers().between(-1000, 100000), + Arbitraries.of(true, false), + Arbitraries.doubles().between(0.0, 100.0)); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/NegotiationErrorPathPropertyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/NegotiationErrorPathPropertyTest.java new file mode 100644 index 00000000..8351b185 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/NegotiationErrorPathPropertyTest.java @@ -0,0 +1,323 @@ +package net.openan.a2at.sdk.negotiation.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.testdata.ScriptedNegotiationLlmClient; + +/** + * Error-path property layer (design §8.3): arbitraries specifically designed to trigger one error code each. + * + *

        Every property samples ~100 inputs built to violate exactly one rule — blank free text, a conclusion that + * contradicts the addressed phase, a payload missing one required field, or a context round above the budget — and + * asserts the exact public error code plus the exact LLM call count (0 when the failure precedes the LLM step, 1 when + * the mapped failure is non-retryable). + * + * @since 2026-08 + */ +class NegotiationErrorPathPropertyTest { + + private static final Map FLAT_SCHEMA = PropertyHarness.objectSchema(Map.of()); + + @Property(tries = 100, seed = "20260910") + void blankTextFailsWithInvalidInput( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("blankTexts") String text) { + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.service(language, llm); + NegotiationGenerationException propose = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + text, + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, propose.getCode()); + NegotiationGenerationException abort = assertThrows( + NegotiationGenerationException.class, + () -> service.generateAbortFromText( + text, context, PropertyHarness.templateUri("Negotiation-T/common/abort/v1"))); + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, abort.getCode()); + assertEquals(0, llm.callCount()); + } + + @Property(tries = 100, seed = "20260911") + void conclusionMismatchingThePhaseFailsWithInvalidInput( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("negotiationTypes") String typeSegment, + @ForAll("swappedConclusions") boolean swapped) { + String rawUri = "Negotiation-T/" + typeSegment + "/accept-reject/v1"; + TemplateUri templateUri = PropertyHarness.templateUri(rawUri); + // An accept call receives a Reject payload and vice versa; all other required fields are present. + String conclusion = swapped ? "Reject" : "Accept"; + ScriptedNegotiationLlmClient llm = PropertyHarness.scripted(endingPayload(conclusion)); + NegotiationContentService service = PropertyHarness.service(language, llm); + NegotiationGenerationException exception; + if (swapped) { + exception = assertThrows( + NegotiationGenerationException.class, + () -> service.generateAcceptFromText("I must refuse the current offer.", context, templateUri)); + } else { + exception = assertThrows( + NegotiationGenerationException.class, + () -> service.generateRejectFromText("I cannot agree with the current offer.", context, templateUri)); + } + assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, exception.getCode()); + assertEquals(1, llm.callCount()); + } + + @Property(tries = 100, seed = "20260912") + void payloadMissingOneRequiredFieldFailsWithSlotMissing( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("missingRequiredFields") MissingRequiredField field) { + ScriptedNegotiationLlmClient llm = PropertyHarness.scripted(PropertyHarness.json(field.payload())); + NegotiationContentService service = PropertyHarness.service(language, llm); + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> field.run(service, context)); + assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, exception.getCode()); + assertEquals(1, llm.callCount()); + } + + @Property(tries = 100, seed = "20260913") + void roundAboveTheBudgetFailsWithRuleViolation( + @ForAll("languages") String language, + @ForAll("maxRoundsValues") int maxRounds, + @ForAll("budgetOvershoots") int overshoot) { + NegotiationContext context = + new NegotiationContext(PropertyArbitraries.anySessionId(), maxRounds + overshoot, maxRounds); + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.service(language, llm); + NegotiationParamExtractionException propose = assertThrows( + NegotiationParamExtractionException.class, + () -> service.validateProposePromptAndDataFilling( + "Rendered negotiation message text.", + context, + FLAT_SCHEMA, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, propose.getCode()); + NegotiationParamExtractionException abort = assertThrows( + NegotiationParamExtractionException.class, + () -> service.validateAbortPromptAndDataFilling( + "Rendered negotiation message text.", + context, + FLAT_SCHEMA, + PropertyHarness.templateUri("Negotiation-T/common/abort/v1"))); + assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, abort.getCode()); + assertEquals(0, llm.callCount()); + } + + // ------------------------------------------------------------------ missing-field matrix + + /** + * One required extraction field dropped from an otherwise valid payload, paired with the API whose payload it is. + */ + private enum MissingRequiredField { + INFORMATION_PROPOSE_ITEMS { + @Override + Map payload() { + return withoutKey(validInformationProposePayload(), "items"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1")); + } + }, + TARGET_PROPOSE_DESCRIPTION { + @Override + Map payload() { + return withoutKey(validTargetProposePayload(), "target_negotiation_description"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateProposeFromText( + "I want to negotiate the coverage target.", + context, + PropertyHarness.templateUri("Negotiation-T/target-negotiation/propose/v1")); + } + }, + FEASIBILITY_PROPOSE_DESCRIPTION { + @Override + Map payload() { + return withoutKey(validFeasibilityProposePayload(), "feasibility_negotiation_description"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateProposeFromText( + "Please evaluate whether the upgrade is feasible.", + context, + PropertyHarness.templateUri("Negotiation-T/feasibility-negotiation/propose/v1")); + } + }, + ENDING_CONCLUSION { + @Override + Map payload() { + return withoutKey(validInformationEndingPayload(), "conclusion"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateAcceptFromText( + "I accept the proposed information set.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/accept-reject/v1")); + } + }, + TARGET_ACCEPT_CONFIRMED_INTENT { + @Override + Map payload() { + return withoutKey(validTargetEndingPayload(), "confirmed_intent"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateAcceptFromText( + "I confirm the negotiated intent.", + context, + PropertyHarness.templateUri("Negotiation-T/target-negotiation/accept-reject/v1")); + } + }, + ABORT_TERMINATION_REASON { + @Override + Map payload() { + return withoutKey(validAbortPayload(), "termination_reason"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateAbortFromText( + "The round limit is reached.", + context, + PropertyHarness.templateUri("Negotiation-T/common/abort/v1")); + } + }; + + abstract Map payload(); + + abstract Object run(NegotiationContentService service, NegotiationContext context); + + private static Map withoutKey(Map payload, String key) { + Map reduced = new LinkedHashMap<>(payload); + reduced.remove(key); + return reduced; + } + } + + // ------------------------------------------------------------------ payload builders + + private static String endingPayload(String conclusion) { + Map payload = new LinkedHashMap<>(validTargetEndingPayload()); + payload.put("conclusion", conclusion); + return PropertyHarness.json(payload); + } + + private static Map validInformationProposePayload() { + Map payload = new LinkedHashMap<>(); + payload.put("items", List.of(Map.of("name", "energy_saving_region", "value", "Songshan Lake"))); + payload.put("relationship", null); + return payload; + } + + private static Map validTargetProposePayload() { + Map payload = new LinkedHashMap<>(); + payload.put("target_negotiation_description", "Coverage expansion of the industrial park"); + payload.put("intent_understanding", List.of()); + payload.put("alignment_and_clarification", List.of()); + payload.put("request_for_clarification", List.of()); + return payload; + } + + private static Map validFeasibilityProposePayload() { + Map payload = new LinkedHashMap<>(); + payload.put("feasibility_negotiation_description", "Backhaul upgrade feasibility"); + payload.put("action", "REQUEST_FEASIBILITY_EVALUATION"); + payload.put( + "contents_to_evaluate", List.of(Map.of("name", "guaranteed_rate", "value", "20Mbps"))); + payload.put("infeasibility_details_and_proposal", List.of()); + return payload; + } + + private static Map validInformationEndingPayload() { + Map payload = new LinkedHashMap<>(); + payload.put("conclusion", "Accept"); + payload.put("items", List.of(Map.of("name", "energy_saving_region", "value", "Songshan Lake"))); + return payload; + } + + private static Map validTargetEndingPayload() { + Map payload = new LinkedHashMap<>(); + payload.put("conclusion", "Accept"); + payload.put("confirmed_intent", "Confirmed coverage expansion intent"); + payload.put("failure_reason", null); + return payload; + } + + private static Map validAbortPayload() { + Map payload = new LinkedHashMap<>(); + payload.put("termination_reason", "The negotiation round limit is reached."); + return payload; + } + + // ------------------------------------------------------------------ providers + + @Provide + Arbitrary languages() { + return PropertyArbitraries.languages(); + } + + @Provide + Arbitrary contexts() { + return PropertyArbitraries.contexts(); + } + + @Provide + Arbitrary blankTexts() { + return Arbitraries.strings().withChars(" \t\r\n").ofMinLength(0).ofMaxLength(12); + } + + @Provide + Arbitrary negotiationTypes() { + return Arbitraries.of("information-negotiation", "target-negotiation", "feasibility-negotiation"); + } + + @Provide + Arbitrary swappedConclusions() { + return Arbitraries.of(true, false); + } + + @Provide + Arbitrary missingRequiredFields() { + return Arbitraries.of(MissingRequiredField.values()); + } + + @Provide + Arbitrary maxRoundsValues() { + return Arbitraries.integers().between(1, 10); + } + + @Provide + Arbitrary budgetOvershoots() { + return Arbitraries.integers().between(1, 20); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyArbitraries.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyArbitraries.java new file mode 100644 index 00000000..4b7c5d35 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyArbitraries.java @@ -0,0 +1,256 @@ +package net.openan.a2at.sdk.negotiation.property; + +import java.util.List; +import java.util.UUID; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Combinators; +import net.jqwik.api.Tuple; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; + +/** + * Arbitraries of the jqwik property layer: legal negotiation inputs per design §8.3. + * + *

        Session ids come from a fixed UUID pool so the validate-side rule gate always passes on the id dimension; + * {@code round} is always within {@code [1, maxRounds]} and {@code maxRounds} within {@code [1, 10]}. Every typed + * content arbitrary only produces shapes the generators accept: non-blank required descriptions, non-empty item lists + * where the generator requires them, and null-or-non-empty optional lists elsewhere. + * + * @since 2026-08 + */ +final class PropertyArbitraries { + + /** Fixed pool of rule-gate-valid session ids. */ + static final List SESSION_ID_POOL = List.of( + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "0f5e9c1a-8b3d-4e7f-9a2c-6d1b4e8f0a53", + "7c2a1e9b-4f6d-4a3b-8e5c-2f7a9d1b3e64", + "1a8b3c5d-7e9f-4d2a-b6c4-e8f0a2d4b6c8", + "9e7d5b3a-1c4f-4a8b-9d2e-6f8a0c2e4d6a", + "5f3b7d9e-2a4c-4e6b-8a0d-3f5e7c9b1d38", + "b9d1f3a5-7c2e-4b8d-9f1a-5e7c3b9d1f42", + "e4c6a8b0-d2f4-4a9c-8e6b-2d0f4a6c8e10", + "2b6d8f0a-c4e2-4a8c-b0d2-f4a6c8e0b2d4", + "d8f0b2a4-6e8c-4a2b-8d0f-2b4d6f8a0c3e", + "6a4c2e0b-8a0d-4f6c-9e1b-3d5f7a9c1e50", + "0c2e4a6b-9d1f-4b3a-8f5c-7e9b1d3f5a72", + "f1a3c5e7-2b4d-4f6a-8c0e-b2d4f6a8c0e2", + "8e0b2d4f-6a8c-4e2b-9d1f-3a5c7e9b1d34", + "4a6c8e0b-2d4f-4b8a-9c1e-5f7a3b9d1e56", + "c5e7a9b1-d3f5-4a7c-8e0b-4d6f8a2c0e78"); + + private static final List LANGUAGES = List.of("zh-CN", "en-US"); + + private static final List ITEM_NAMES = List.of( + "energy_saving_region", + "coverage_area", + "guaranteed_rate", + "spectrum_band", + "carrier_frequency", + "pdu_session_type", + "slice_sst", + "amf_region_id", + "handover_margin", + "qos_class_identifier", + "roaming_profile", + "interference_level"); + + private static final List ITEM_VALUES = List.of( + "Songshan Lake", + "at least 20Mbps", + "QCI 9", + "3.5GHz n78", + "15kHz subcarrier spacing", + "session continuity mode 2", + "URLLC slice", + "centralized RAN", + "3dB", + "voice-optimized bearer"); + + private static final List NON_BLANK_TEXTS = List.of( + "Energy-saving parameter negotiation for the Songshan Lake campus network", + "Coverage expansion toward the industrial park at the city border", + "Guaranteed-bitrate backhaul upgrade for the private-network slice", + "Load rebalancing between the two core-network pools"); + + private PropertyArbitraries() {} + + /** + * Arbitrary of the two bundled message languages. + * + * @return language arbitrary + */ + static Arbitrary languages() { + return Arbitraries.of(LANGUAGES); + } + + /** + * Arbitrary of rule-gate-valid negotiation contexts: pooled UUID id, round in {@code [1, maxRounds]}, maxRounds in + * {@code [1, 10]}. + * + * @return negotiation context arbitrary + */ + static Arbitrary contexts() { + return Arbitraries.integers() + .between(1, 10) + .flatMap(maxRounds -> Arbitraries.integers() + .between(1, maxRounds) + .flatMap(round -> Arbitraries.of(SESSION_ID_POOL) + .map(id -> new NegotiationContext(id, round, maxRounds)))); + } + + /** + * Arbitrary of single items whose value is null half of the time (the design's 50% null item value). + * + * @return item arbitrary + */ + static Arbitrary items() { + Arbitrary values = Arbitraries.frequencyOf( + Tuple.of(1, Arbitraries.just(null)), Tuple.of(1, Arbitraries.of(ITEM_VALUES))); + return Combinators.combine(Arbitraries.of(ITEM_NAMES), values).as(NegotiationItem::new); + } + + /** + * Arbitrary of item lists of the given size range. + * + * @param minSize minimum list size + * @param maxSize maximum list size + * @return item list arbitrary + */ + static Arbitrary> itemLists(int minSize, int maxSize) { + return items().list().ofMinSize(minSize).ofMaxSize(maxSize); + } + + /** + * Arbitrary of optional item lists: null or a non-empty list (the design's 50% non-empty optional sections). + * + * @return optional item list arbitrary + */ + static Arbitrary> optionalItemLists() { + return Arbitraries.frequencyOf(Tuple.of(1, Arbitraries.just(null)), Tuple.of(1, itemLists(1, 4))); + } + + /** + * Arbitrary of information propose contents: items in {@code [0, 5]} with 50% null item values, relationship null + * or present. + * + * @return information propose content arbitrary + */ + static Arbitrary informationProposeContents() { + return Combinators.combine(itemLists(0, 5), optionalTexts()).as(InformationProposeContent::new); + } + + /** + * Arbitrary of target propose contents: non-blank description, each optional list null or non-empty. + * + * @return target propose content arbitrary + */ + static Arbitrary targetProposeContents() { + return Combinators.combine( + Arbitraries.of(NON_BLANK_TEXTS), + optionalItemLists(), + optionalItemLists(), + optionalItemLists()) + .as(TargetProposeContent::new); + } + + /** + * Arbitrary of feasibility propose contents covering both action branches: the action-selected item list is always + * non-empty, the other one is null or non-empty. + * + * @return feasibility propose content arbitrary + */ + static Arbitrary feasibilityProposeContents() { + return Combinators.combine( + Arbitraries.of(NegotiationAction.values()), + Arbitraries.of(NON_BLANK_TEXTS), + itemLists(1, 4), + optionalItemLists()) + .as((action, description, selected, other) -> new FeasibilityProposeContent( + description, + action, + action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION ? selected : other, + action == NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE ? selected : other)); + } + + /** + * Arbitrary over the three propose content types. + * + * @return propose content arbitrary + */ + static Arbitrary proposeContents() { + return Arbitraries.oneOf( + informationProposeContents(), targetProposeContents(), feasibilityProposeContents()); + } + + /** + * Arbitrary of ending contents carrying the given conclusion across all three types. + * + * @param conclusion terminal conclusion the contents carry + * @return ending content arbitrary + */ + static Arbitrary endingContents(NegotiationConclusion conclusion) { + Arbitrary information = + itemLists(0, 4).map(items -> new InformationEndingContent(conclusion, items)); + Arbitrary target = Arbitraries.of(NON_BLANK_TEXTS) + .map(text -> conclusion == NegotiationConclusion.ACCEPT + ? new TargetEndingContent(conclusion, text, null) + : new TargetEndingContent(conclusion, null, text)); + Arbitrary feasibility = + Arbitraries.of(NON_BLANK_TEXTS).map(text -> new FeasibilityEndingContent(conclusion, text)); + return Arbitraries.oneOf(information, target, feasibility); + } + + /** + * Arbitrary of abort contents with a non-blank termination reason. + * + * @return abort content arbitrary + */ + static Arbitrary abortContents() { + return Arbitraries.of(NON_BLANK_TEXTS).map(NegotiationAbortContent::new); + } + + private static Arbitrary optionalTexts() { + return Arbitraries.frequencyOf( + Tuple.of(1, Arbitraries.just(null)), Tuple.of(1, Arbitraries.of(ITEM_VALUES))); + } + + /** + * Picks one session id from the pool outside of the arbitrary layer. + * + * @return a fixed valid session id + */ + static String anySessionId() { + return SESSION_ID_POOL.get(0); + } + + /** + * Picks one language outside of the arbitrary layer. + * + * @return a fixed language + */ + static String anyLanguage() { + return LANGUAGES.get(0); + } + + /** + * Builds a fresh random session id; used where the pool semantics do not matter. + * + * @return a new UUID string + */ + static String randomSessionId() { + return UUID.randomUUID().toString(); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyHarness.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyHarness.java new file mode 100644 index 00000000..30eca47c --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyHarness.java @@ -0,0 +1,185 @@ +package net.openan.a2at.sdk.negotiation.property; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.testdata.LlmFailMarker; +import net.openan.a2at.sdk.negotiation.testdata.LlmScriptStep; +import net.openan.a2at.sdk.negotiation.testdata.ScriptedNegotiationLlmClient; + +/** + * Shared plumbing of the jqwik property layer: production-wired service assembly through the real builder, JSON + * payload construction and the scripted-LLM seam reused from the corpus testdata package. + * + *

        Mirroring the {@code CaseEngine} discipline, everything except the LLM client is production assembly; the + * property layer only decides inputs and expected invariants. + * + * @since 2026-08 + */ +final class PropertyHarness { + + /** Default attempt limit of the property runs; the retry partition property depends on this exact value. */ + static final int MAX_ATTEMPTS = 3; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private PropertyHarness() {} + + /** + * Assembles the production negotiation content service for one property run. + * + * @param language message language such as {@code zh-CN} + * @param llmClient scripted LLM client of the run + * @return production-wired negotiation content service + */ + static NegotiationContentService service(String language, LLMClient llmClient) { + return new NegotiationContentService(orchestrator(language, llmClient)); + } + + /** + * Assembles the production service with a template loader that fails every load, the property-layer stand-in of + * the corpus {@code inject: failingTemplateLoader} hook. + * + * @param language message language + * @param llmClient scripted LLM client of the run + * @return production-wired service whose template loader always misses + */ + static NegotiationContentService serviceWithFailingTemplateLoader(String language, LLMClient llmClient) { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .llmClient(llmClient) + .maxAttempts(MAX_ATTEMPTS) + .templateLoader(failingTemplateLoader()) + .build(); + return new NegotiationContentService(orchestrator); + } + + private static NegotiationGenerationOrchestrator orchestrator(String language, LLMClient llmClient) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .llmClient(llmClient) + .maxAttempts(MAX_ATTEMPTS) + .build(); + } + + private static NegotiationTemplateLoader failingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + + @Override + public List loadAll() { + return List.of(); + } + }; + } + + /** + * Parses one raw template URI string. + * + * @param raw template URI such as {@code Negotiation-T/information-negotiation/propose/v1} + * @return parsed template URI + */ + static TemplateUri templateUri(String raw) { + return TemplateUri.parse(raw) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); + } + + /** + * Serializes one value into the JSON payload text of a scripted LLM step. + * + * @param value payload value + * @return JSON text + */ + static String json(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("The property payload could not be serialized.", exception); + } + } + + /** + * Builds the semantic-validation payload of an accepting verdict carrying the given extracted parameters. + * + * @param negotiationType negotiation type name the verdict reports, or null for the type-independent abort phase + * @param params extracted parameters the scripted validator returns + * @return JSON payload text of the scripted semantic-validation answer + */ + static String semanticVerdict(String negotiationType, Map params) { + Map payload = new LinkedHashMap<>(); + payload.put("semantic_verdict", true); + payload.put("negotiation_type", negotiationType); + payload.put("errors", List.of()); + payload.put("params", params); + return json(payload); + } + + /** + * Creates a scripted client answering exactly one payload step. + * + * @param payloadJson payload text of the single answer + * @return strictly consuming scripted client + */ + static ScriptedNegotiationLlmClient scripted(String payloadJson) { + return new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Payload(payloadJson))); + } + + /** + * Creates a scripted client replaying one failure marker for every attempt of the run. + * + * @param marker failure marker to replay + * @return strictly consuming scripted client that always fails + */ + static ScriptedNegotiationLlmClient failing(LlmFailMarker marker) { + return new ScriptedNegotiationLlmClient( + java.util.stream.IntStream.rangeClosed(1, MAX_ATTEMPTS) + .mapToObj(ignored -> (LlmScriptStep) new LlmScriptStep.Fail(marker)) + .toList()); + } + + /** + * Builds a flat object JSON Schema over the given properties. + * + * @param properties per-key type schemas keyed by parameter name + * @return caller parameter schema of a validate run + */ + static Map objectSchema(Map properties) { + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + return schema; + } + + /** + * Derives the JSON Schema type of one parameter value. + * + * @param value parameter value generated by a property + * @return single-key type schema + */ + static Map typeSchema(Object value) { + if (value instanceof String) { + return Map.of("type", "string"); + } + if (value instanceof Boolean) { + return Map.of("type", "boolean"); + } + if (value instanceof Double) { + return Map.of("type", "number"); + } + return Map.of("type", "integer"); + } +} diff --git a/pom.xml b/pom.xml index 2dcd95ac..3c11bbd9 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ 2.44.5 2.55.0 5.10.2 + 1.8.5 1.18.38 2.20.1 2.20 From b6bc1b9984fa08a2d98b46bb7c13e2058fd53a21 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 24 Aug 2026 15:24:49 +0800 Subject: [PATCH 162/295] chore(tools): add corpus index and review tooling - tools/corpus_index.py: generates INDEX.md (API x category x error-code coverage matrices, per-case index) with --check freshness mode for local verification - tools/corpus_review.py: business-review support per Q19 -- export (self-contained HTML dashboard + annotatable xlsx/csv with error-code/API dictionaries translated to business Chinese) and collect (findings report + review-status.json) Signed-off-by: codetalks-hw --- .../test/resources/negotiation-cases/INDEX.md | 364 ++++++++ tools/corpus_index.py | 356 ++++++++ tools/corpus_review.py | 780 ++++++++++++++++++ 3 files changed, 1500 insertions(+) create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/INDEX.md create mode 100644 tools/corpus_index.py create mode 100644 tools/corpus_review.py diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/INDEX.md b/a2a-t-negotiation/src/test/resources/negotiation-cases/INDEX.md new file mode 100644 index 00000000..6609bcd1 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/INDEX.md @@ -0,0 +1,364 @@ + + +# Negotiation Test Corpus Index + +Machine-generated view of the corpus under `a2a-t-negotiation/src/test/resources/negotiation-cases/`. +Coverage matrices count base records (one case record, or one scenario step under its scenario's category); +language expansion is listed per record and totaled in the statistics. + +## Statistics + +| Metric | Value | +|---|---| +| Case records | 146 | +| Scenario records (steps) | 18 (69) | +| Base records total | 164 | +| Language-expanded units | 220 | +| Shared LLM payloads | 32 | +| Shared schema variants | 7 | +| Error codes covered | 7 (negotiation_content_extract_failed, negotiation_invalid_input, negotiation_llm_infrastructure_error, negotiation_rule_violation, negotiation_semantic_rejected, negotiation_slot_missing, template_not_found) | +| Exception-only failures | 33 (IllegalArgumentException, NullPointerException) | + +Priority distribution (case records; scenarios carry no priority): + +| Priority | Cases | +|---|---| +| P0 | 75 | +| P1 | 59 | +| P2 | 12 | + +## Family distribution + +| Family | Category | Corpus file | Records | Steps | Languages | +|---|---|---|---|---|---| +| from-text (FT) | FT-EXTRACT | from-text/extraction-failures.json | 15 | 0 | en-US, zh-CN | +| from-text (FT) | FT-HAPPY | from-text/happy.json | 10 | 0 | en-US, zh-CN | +| from-text (FT) | FT-PROG | from-text/programming-errors.json | 8 | 0 | zh-CN | +| from-text (FT) | FT-RETRY | from-text/retry.json | 13 | 0 | en-US, zh-CN | +| from-text (FT) | FT-TPL | from-text/template-resolution.json | 4 | 0 | zh-CN | +| from-data (FD) | FD-HAPPY | from-data/happy.json | 10 | 0 | en-US, zh-CN | +| from-data (FD) | FD-PROG | from-data/programming-errors.json | 14 | 0 | zh-CN | +| validate (VAL) | VAL-HAPPY | validate/happy.json | 10 | 0 | en-US, zh-CN | +| validate (VAL) | VAL-MAP | validate/error-code-mapping.json | 5 | 0 | zh-CN | +| validate (VAL) | VAL-MERGE | validate/merge-and-schema.json | 6 | 0 | en-US, zh-CN | +| validate (VAL) | VAL-PROG | validate/programming-errors.json | 17 | 0 | zh-CN | +| validate (VAL) | VAL-RETRY | validate/llm-shape-and-retry.json | 9 | 0 | en-US, zh-CN | +| validate (VAL) | VAL-RULE | validate/rule-gate.json | 8 | 0 | en-US, zh-CN | +| validate (VAL) | VAL-SEM | validate/semantic-verdicts.json | 17 | 0 | en-US, zh-CN | +| scenarios (SC) | SC-ERR | scenarios/boundary-flows.json | 7 | 25 | zh-CN | +| scenarios (SC) | SC-EXH | scenarios/boundary-flows.json | 2 | 9 | zh-CN | +| scenarios (SC) | SC-FSB | scenarios/feasibility-flows.json | 3 | 15 | en-US, zh-CN | +| scenarios (SC) | SC-INFO | scenarios/information-flows.json | 3 | 10 | en-US, zh-CN | +| scenarios (SC) | SC-TGT | scenarios/target-flows.json | 3 | 10 | en-US, zh-CN | + +## API x category coverage + +Cells count base records (case records plus scenario steps) per API and category; empty cells are coverage gaps. + +| Category | generateProposeFromText | generateAcceptFromText | generateRejectFromText | generateAbortFromText | generateProposeFromData | generateAcceptFromData | generateRejectFromData | generateAbortFromData | validateProposePromptAndDataFilling | validateAcceptPromptAndDataFilling | validateRejectPromptAndDataFilling | validateAbortPromptAndDataFilling | Total | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| FT-EXTRACT | 7 | 4 | 2 | 2 | - | - | - | - | - | - | - | - | 15 | +| FT-HAPPY | 3 | 3 | 3 | 1 | - | - | - | - | - | - | - | - | 10 | +| FT-PROG | 3 | 3 | 1 | 1 | - | - | - | - | - | - | - | - | 8 | +| FT-RETRY | 5 | 6 | 1 | 1 | - | - | - | - | - | - | - | - | 13 | +| FT-TPL | 1 | 1 | 1 | 1 | - | - | - | - | - | - | - | - | 4 | +| FD-HAPPY | - | - | - | - | 3 | 3 | 3 | 1 | - | - | - | - | 10 | +| FD-PROG | - | - | - | - | 8 | 4 | 2 | - | - | - | - | - | 14 | +| VAL-HAPPY | - | - | - | - | - | - | - | - | 3 | 3 | 3 | 1 | 10 | +| VAL-MAP | - | - | - | - | - | - | - | - | 5 | - | - | - | 5 | +| VAL-MERGE | - | - | - | - | - | - | - | - | 2 | 2 | 1 | 1 | 6 | +| VAL-PROG | - | - | - | - | - | - | - | - | 5 | 4 | 4 | 4 | 17 | +| VAL-RETRY | - | - | - | - | - | - | - | - | 4 | 4 | 1 | - | 9 | +| VAL-RULE | - | - | - | - | - | - | - | - | 8 | - | - | - | 8 | +| VAL-SEM | - | - | - | - | - | - | - | - | 10 | 2 | 2 | 3 | 17 | +| SC-ERR | 7 | 4 | - | - | - | - | - | - | 7 | 7 | - | - | 25 | +| SC-EXH | 3 | - | 2 | 1 | - | - | - | - | 2 | - | - | 1 | 9 | +| SC-FSB | 4 | 2 | 2 | 1 | - | - | - | - | 2 | 2 | 1 | 1 | 15 | +| SC-INFO | 3 | 2 | 2 | 1 | - | - | - | - | - | 2 | - | - | 10 | +| SC-TGT | 3 | 2 | 2 | 1 | - | - | - | - | - | 2 | - | - | 10 | +| **Total** | **39** | **27** | **16** | **10** | **11** | **7** | **5** | **1** | **48** | **28** | **12** | **11** | **215** | + +## API x expected outcome coverage + +Cells count base records per API and expectation; the `exception` column aggregates failures that assert only an exception type (failures carrying both an exception and a code are counted under their code). + +| API | `success` | `negotiation_content_extract_failed` | `negotiation_invalid_input` | `negotiation_llm_infrastructure_error` | `negotiation_rule_violation` | `negotiation_semantic_rejected` | `negotiation_slot_missing` | `template_not_found` | `exception` | Total | +|---|---|---|---|---|---|---|---|---|---|---| +| `generateProposeFromText` | 24 | 7 | 3 | 1 | - | - | 1 | 1 | 2 | 39 | +| `generateAcceptFromText` | 15 | 2 | 3 | 1 | - | - | 3 | 1 | 2 | 27 | +| `generateRejectFromText` | 11 | - | 1 | 1 | - | - | 1 | 1 | 1 | 16 | +| `generateAbortFromText` | 6 | - | - | - | - | - | 2 | 1 | 1 | 10 | +| `generateProposeFromData` | 3 | - | - | - | - | - | - | - | 8 | 11 | +| `generateAcceptFromData` | 3 | - | - | - | - | - | - | - | 4 | 7 | +| `generateRejectFromData` | 3 | - | - | - | - | - | - | - | 2 | 5 | +| `generateAbortFromData` | 1 | - | - | - | - | - | - | - | - | 1 | +| `validateProposePromptAndDataFilling` | 19 | - | 3 | 3 | 7 | 11 | - | 1 | 4 | 48 | +| `validateAcceptPromptAndDataFilling` | 19 | - | 2 | 3 | - | 1 | - | - | 3 | 28 | +| `validateRejectPromptAndDataFilling` | 6 | - | 2 | - | - | 1 | - | - | 3 | 12 | +| `validateAbortPromptAndDataFilling` | 5 | - | 2 | - | - | 1 | - | - | 3 | 11 | + +## Case index + +### FT-EXTRACT -- from-text/extraction-failures.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| FT-EXTRACT-01 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 接受消息抽取结果缺少结论字段时生成失败并返回槽位缺失错误码,不发起重试 | +| FT-EXTRACT-02 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 接受消息抽取出的结论为拒绝时判定为内容与阶段矛盾,立即失败并返回输入无效错误码 | +| FT-EXTRACT-03 | `generateRejectFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 拒绝消息抽取出的结论为接受时判定为内容与阶段矛盾,立即失败并返回输入无效错误码 | +| FT-EXTRACT-04 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 信息提议消息抽取结果缺少信息项列表时生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-05 | `generateProposeFromText` | en-US | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 信息项列表被抽取为字符串而非数组时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码 | +| FT-EXTRACT-06 | `generateProposeFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation_content_extract_failed) | 信息项缺少名称字段时内容抽取失败,返回抽取失败错误码 | +| FT-EXTRACT-07 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 结论字面量大小写错误(ACCEPT 而非 Accept)时内容抽取失败,返回抽取失败错误码 | +| FT-EXTRACT-08 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 目标接受消息抽取结果缺少最终确认意图时生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-09 | `generateRejectFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 目标拒绝消息抽取结果缺少拒绝原因时生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-10 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 可行性提议消息抽取结果缺少动作字段时判定为内容矛盾,立即失败并返回输入无效错误码 | +| FT-EXTRACT-11 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 动作字段为未知枚举值时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码 | +| FT-EXTRACT-12 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 请求可行性评估却未抽取到任何待评估内容时判定为内容矛盾,立即失败并返回输入无效错误码 | +| FT-EXTRACT-13 | `generateAbortFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 终止消息抽取结果缺少终止原因时生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-14 | `generateAbortFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation_slot_missing) | 终止原因为空白字符串时等同于缺失,生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-15 | `generateProposeFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation_content_extract_failed) | 信息项取值为数字而非字符串时内容抽取失败,返回抽取失败错误码 | + +### FT-HAPPY -- from-text/happy.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| FT-HAPPY-01 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 自然语言补充信息项后生成信息协商提议消息,双语渲染均与 golden 一致,且 fromText 与 fromData 两路等值 | +| FT-HAPPY-02 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 确认信息后从自然语言生成信息协商接受消息,消息含 Accept 结论字面量,且 fromText 与 fromData 两路等值 | +| FT-HAPPY-03 | `generateRejectFromText` | en-US, zh-CN | P0 | success | 信息无法提供时从自然语言生成信息协商拒绝消息,消息含 Reject 结论字面量,且 fromText 与 fromData 两路等值 | +| FT-HAPPY-04 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 首轮目标协商从自然语言生成提议消息,首轮渲染意图理解与待澄清内容两个板块,且 fromText 与 fromData 两路等值 | +| FT-HAPPY-05 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 意图确认后从自然语言生成目标协商接受消息,消息携带最终确认意图并含 Accept 结论字面量,且两路等值 | +| FT-HAPPY-06 | `generateRejectFromText` | en-US, zh-CN | P0 | success | 信息无法澄清时从自然语言生成目标协商拒绝消息,消息携带拒绝原因并含 Reject 结论字面量,且两路等值 | +| FT-HAPPY-07 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 从自然语言生成可行性协商提议消息,请求对方评估调整后的速率保障目标,且 fromText 与 fromData 两路等值 | +| FT-HAPPY-08 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 评估通过后从自然语言生成可行性协商接受消息,消息携带可行性结论并含 Accept 结论字面量,且两路等值 | +| FT-HAPPY-09 | `generateRejectFromText` | en-US, zh-CN | P0 | success | 评估不通过时从自然语言生成可行性协商拒绝消息,消息携带不可行结论并含 Reject 结论字面量,且两路等值 | +| FT-HAPPY-10 | `generateAbortFromText` | en-US, zh-CN | P0 | success | 轮次耗尽时从自然语言生成协商终止消息,走通用 abort 模板并含终止原因,且 fromText 与 fromData 两路等值 | + +### FT-PROG -- from-text/programming-errors.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| FT-PROG-01 | `generateProposeFromText` | zh-CN | P0 | (NullPointerException) | 协商上下文缺失时生成提议消息在管线入口直接失败,抛出空指针异常且不发起任何大模型调用 | +| FT-PROG-02 | `generateAcceptFromText` | zh-CN | P0 | (NullPointerException) | 模板地址缺失时生成接受消息在地址解析入口直接失败,抛出空指针异常且不发起任何大模型调用 | +| FT-PROG-03 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 调用方未传入任何自然语言内容时生成提议消息被拒绝,返回无效输入错误码且不发起任何大模型调用 | +| FT-PROG-04 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_invalid_input) | 自然语言内容仅含空白字符时生成接受消息被拒绝,返回无效输入错误码且不发起任何大模型调用 | +| FT-PROG-05 | `generateProposeFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址误用未发布的 v2 版本段时生成提议消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | +| FT-PROG-06 | `generateAcceptFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址误用 Task-T 扩展名时生成接受消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | +| FT-PROG-07 | `generateRejectFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址中协商类型拼写错误时生成拒绝消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | +| FT-PROG-08 | `generateAbortFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址中阶段段未知时生成协商终止消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | + +### FT-RETRY -- from-text/retry.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| FT-RETRY-01 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 最大尝试次数为1时大模型返回不可解析内容,不发起任何重试即以抽取失败错误码失败 | +| FT-RETRY-02 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 最大尝试次数为2时大模型连续返回空响应与空白内容,尝试耗尽后仍以原始的抽取失败错误码失败 | +| FT-RETRY-03 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 最大尝试次数为3时三种不可解析形态依次出现,三次尝试耗尽后以抽取失败错误码失败 | +| FT-RETRY-04 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_llm_infrastructure_error) | 最大尝试次数为1时底层通道抛出运行时异常,不重试即以基础设施错误码失败且异常细节不外泄 | +| FT-RETRY-05 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_llm_infrastructure_error) | 最大尝试次数为2时底层先后出现传输异常与错误响应,尝试耗尽后仍以基础设施错误码失败 | +| FT-RETRY-06 | `generateRejectFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_llm_infrastructure_error) | 最大尝试次数为3时基础设施错误持续出现,三次尝试耗尽后以基础设施错误码失败 | +| FT-RETRY-07 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 首次返回不可解析内容后第二次返回合法接受内容,重试后成功生成与 golden 一致的接受消息 | +| FT-RETRY-08 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 首次底层传输异常后第二次抽取成功,重试后生成与 golden 一致的信息提议消息且异常细节不外泄 | +| FT-RETRY-09 | `generateAcceptFromText` | en-US, zh-CN | P1 | success | 前两次分别出现基础设施错误与不可解析内容,第三次抽取成功,在最后一次尝试额度内恢复生成接受消息 | +| FT-RETRY-10 | `generateAbortFromText` | en-US, zh-CN | P1 | success | 终止消息首次抽取到空白内容后第二次成功,重试后生成含终止原因的协商终止消息 | +| FT-RETRY-11 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 抽取结果缺少结论字段属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败 | +| FT-RETRY-12 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 结论与阶段矛盾属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败 | +| FT-RETRY-13 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_content_extract_failed) | 两次失败形态不同(基础设施错误后接不可解析内容)时,耗尽后重抛最后一次失败的原始错误码而非统一的耗尽错误码 | + +### FT-TPL -- from-text/template-resolution.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| FT-TPL-01 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成失败并返回 template_not_found 错误码,且不发起任何 LLM 调用 | +| FT-TPL-02 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成提议消息失败并返回 template_not_found 错误码,模板加载发生在重试循环之外,即使配置了重试次数也不重试且不发起任何 LLM 调用 | +| FT-TPL-03 | `generateRejectFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成拒绝消息失败并返回 template_not_found 错误码,且不发起任何 LLM 调用 | +| FT-TPL-04 | `generateAbortFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成协商终止消息失败并返回 template_not_found 错误码,通用终止模板同样无法加载且不发起任何 LLM 调用 | + +### FD-HAPPY -- from-data/happy.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| FD-HAPPY-01 | `generateProposeFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成信息协商提议消息,双语渲染均与 golden 一致,全程零 LLM 调用 | +| FD-HAPPY-02 | `generateAcceptFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成信息协商接受消息,消息含 Accept 结论字面量,双语渲染均与 golden 一致,全程零 LLM 调用 | +| FD-HAPPY-03 | `generateRejectFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成信息协商拒绝消息,消息含 Reject 结论字面量,双语渲染均与 golden 一致,全程零 LLM 调用 | +| FD-HAPPY-04 | `generateProposeFromData` | en-US, zh-CN | P0 | success | 首轮目标协商从结构化数据直接生成提议消息,首轮渲染意图理解与待澄清内容两个板块,全程零 LLM 调用 | +| FD-HAPPY-05 | `generateAcceptFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成目标协商接受消息,消息携带最终确认意图并含 Accept 结论字面量,全程零 LLM 调用 | +| FD-HAPPY-06 | `generateRejectFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成目标协商拒绝消息,消息携带拒绝原因并含 Reject 结论字面量,全程零 LLM 调用 | +| FD-HAPPY-07 | `generateProposeFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成可行性协商提议消息,请求对方评估调整后的速率保障目标,全程零 LLM 调用 | +| FD-HAPPY-08 | `generateAcceptFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成可行性协商接受消息,消息携带可行性结论并含 Accept 结论字面量,全程零 LLM 调用 | +| FD-HAPPY-09 | `generateRejectFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成可行性协商拒绝消息,消息携带不可行结论并含 Reject 结论字面量,全程零 LLM 调用 | +| FD-HAPPY-10 | `generateAbortFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成协商终止消息,走通用 abort 模板并含终止原因,双语渲染均与 golden 一致,全程零 LLM 调用 | + +### FD-PROG -- from-data/programming-errors.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| FD-PROG-01 | `generateProposeFromData` | zh-CN | P1 | (NullPointerException) | 结构化提议数据缺少协商上下文时生成立即以空指针异常失败,提示上下文必填且不发起任何 LLM 调用 | +| FD-PROG-02 | `generateAcceptFromData` | zh-CN | P1 | (IllegalArgumentException) | 接受方法收到携带 Reject 结论的结构化数据时生成以参数异常失败,提示结论与接受阶段不符且不发起任何 LLM 调用 | +| FD-PROG-03 | `generateRejectFromData` | zh-CN | P1 | (IllegalArgumentException) | 拒绝方法收到携带 Accept 结论的结构化数据时生成以参数异常失败,提示结论与拒绝阶段不符且不发起任何 LLM 调用 | +| FD-PROG-04 | `generateAcceptFromData` | zh-CN | P1 | (IllegalArgumentException) | 接受方法收到携带 Abort 结论的结构化数据时生成以参数异常失败,提示终止结论不属于类型化模板且不发起任何 LLM 调用 | +| FD-PROG-05 | `generateAcceptFromData` | zh-CN | P1 | (IllegalArgumentException) | 目标协商接受数据缺少最终确认意图时生成以参数异常失败,提示接受消息必须携带确认意图且不发起任何 LLM 调用 | +| FD-PROG-06 | `generateAcceptFromData` | zh-CN | P1 | (IllegalArgumentException) | 目标协商接受数据的最终确认意图为空白字符串时生成以参数异常失败,提示确认意图不能为空白且不发起任何 LLM 调用 | +| FD-PROG-07 | `generateRejectFromData` | zh-CN | P1 | (IllegalArgumentException) | 目标协商拒绝数据缺少拒绝原因时生成以参数异常失败,提示拒绝消息必须携带失败原因且不发起任何 LLM 调用 | +| FD-PROG-08 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 可行性评估请求的结构化提议数据缺少待评估内容时生成以参数异常失败,提示至少需要一条评估项且不发起任何 LLM 调用 | +| FD-PROG-09 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 不可行替代方案的结构化提议数据缺少不可行细节与方案时生成以参数异常失败,提示至少需要一条方案项且不发起任何 LLM 调用 | +| FD-PROG-10 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 模板 URI 前缀指向 Task-T 而非 Negotiation-T 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用 | +| FD-PROG-11 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 模板 URI 版本段为 v2 而非内置 v1 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用 | +| FD-PROG-12 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 模板 URI 类型段使用下划线拼写 information_negotiation 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用 | +| FD-PROG-13 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 模板 URI 类型段指向未知的 unknown-negotiation 类型时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用 | +| FD-PROG-14 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 提议方法收到的模板 URI 阶段段为 accept-reject 而非 propose 时生成以参数异常失败,提示 URI 阶段与方法不符且不发起任何 LLM 调用 | + +### VAL-HAPPY -- validate/happy.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| VAL-HAPPY-01 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验信息协商提议消息并提取参数,合并结果含上下文三键与业务参数 region | +| VAL-HAPPY-02 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验信息协商接受消息并提取参数,合并结果含上下文三键与业务参数 region | +| VAL-HAPPY-03 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验信息协商拒绝消息并提取参数,合并结果含上下文三键与业务参数 region | +| VAL-HAPPY-04 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验目标协商提议消息并提取参数,合并结果含上下文三键与站点、速率保障参数 | +| VAL-HAPPY-05 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验目标协商接受消息并提取参数,合并结果含上下文三键与站点、速率保障参数 | +| VAL-HAPPY-06 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验目标协商拒绝消息并提取参数,合并结果含上下文三键与站点、速率保障参数 | +| VAL-HAPPY-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验可行性协商提议消息并提取参数,合并结果含上下文三键与可行性结论参数 | +| VAL-HAPPY-08 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验可行性协商接受消息并提取参数,合并结果含上下文三键与可行性结论参数 | +| VAL-HAPPY-09 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验可行性协商拒绝消息并提取参数,合并结果含上下文三键与可行性结论参数 | +| VAL-HAPPY-10 | `validateAbortPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验协商终止消息并提取参数,合并结果含上下文三键与终止原因参数 | + +### VAL-MAP -- validate/error-code-mapping.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| VAL-MAP-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 消息未携带协商上下文元数据时校验判定其不是协商消息,内部无效输入错误码对外映射为协商无效输入错误且不发起任何大模型调用 | +| VAL-MAP-02 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商轮次超限触发的内部规则违反错误码对外映射为协商规则违反错误码,并保留轮次越界槽位错误 | +| VAL-MAP-03 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型语义校验否决消息时内部语义拒绝错误码对外映射为协商语义拒绝错误码,并保留大模型报告的槽位错误 | +| VAL-MAP-04 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | 大模型服务连续两次不可用并耗尽重试后,内部基础设施错误码对外映射为协商大模型基础设施错误码,错误详情以 _llm 伪槽位保留内部错误码 | +| VAL-MAP-05 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, template_not_found) | 语义校验提示词资源缺失时内部资源未找到错误码对外映射为 template_not_found 错误码,原始资源异常不透出且不发起任何大模型调用 | + +### VAL-MERGE -- validate/merge-and-schema.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| VAL-MERGE-01 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 业务参数与上下文 id、round 键冲突时上下文参数优先,LLM 提取的错误取值不覆盖规则层结果 | +| VAL-MERGE-02 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 调用方未声明 type 关键字的参数 schema 被自动包装为对象 schema,语义校验与参数提取不受影响 | +| VAL-MERGE-03 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验提议消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | +| VAL-MERGE-04 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验接受消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | +| VAL-MERGE-05 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验拒绝消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | +| VAL-MERGE-06 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验终止消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | + +### VAL-PROG -- validate/programming-errors.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| VAL-PROG-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验提议消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | +| VAL-PROG-02 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验提议消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针) | +| VAL-PROG-03 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验接受消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | +| VAL-PROG-04 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验接受消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针) | +| VAL-PROG-05 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验拒绝消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | +| VAL-PROG-06 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验拒绝消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针) | +| VAL-PROG-07 | `validateAbortPromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验终止消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | +| VAL-PROG-08 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验终止消息时消息文本为空白,抛出参数异常(abort 方法的 javadoc 未声明该行为,doc-gap 探针) | +| VAL-PROG-09 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验提议消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | +| VAL-PROG-10 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验接受消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | +| VAL-PROG-11 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验拒绝消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | +| VAL-PROG-12 | `validateAbortPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验终止消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | +| VAL-PROG-13 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验提议消息时模板 URI 声明的是接受-拒绝阶段,阶段不匹配抛出参数异常且不调用大模型 | +| VAL-PROG-14 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验接受消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型 | +| VAL-PROG-15 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验拒绝消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型 | +| VAL-PROG-16 | `validateAbortPromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验终止消息时模板 URI 声明的是类型化提议模板而非通用终止模板,抛出参数异常且不调用大模型 | +| VAL-PROG-17 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验提议消息时误用通用终止模板 URI,类型化阶段不接受 common 段,抛出参数异常 | + +### VAL-RETRY -- validate/llm-shape-and-retry.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| VAL-RETRY-01 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 语义校验第一次遭遇 LLM 基础设施错误后自动重试,第二次返回合法判定并成功提取参数 | +| VAL-RETRY-02 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 语义返回缺少 negotiation_type 键的无效结构时触发重试,第二次结构合法并成功提取参数 | +| VAL-RETRY-03 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | 语义返回连续三种无效结构时重试至耗尽,最终携带 _llm 伪槽位以 LLM 基础设施错误失败 | +| VAL-RETRY-04 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | maxAttempts 配置为 1 时语义结构错误不获重试机会,单次调用即以 LLM 基础设施错误失败 | +| VAL-RETRY-05 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | LLM 基础设施错误连续出现时按 maxAttempts=2 重试两次后耗尽,携带 _llm 伪槽位失败 | +| VAL-RETRY-06 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P1 | success | 语义返回无法解析的非 JSON 内容后按 maxAttempts=2 重试,第二次返回合法判定并成功 | +| VAL-RETRY-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 连续两次空内容与非 JSON 应答后第三次成功,重试预算 maxAttempts=3 恰好用满 | +| VAL-RETRY-08 | `validateAcceptPromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | 语义步骤抛出非 LLMError 的原始运行时异常时不重试,单次调用即包装为 LLM 基础设施错误失败 | +| VAL-RETRY-09 | `validateAcceptPromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | 语义步骤返回空响应(null)时不重试,单次调用即包装为 LLM 基础设施错误失败 | + +### VAL-RULE -- validate/rule-gate.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| VAL-RULE-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商会话标识仅 8 位而非 36 位 UUID 时,消息在规则校验阶段即被拒绝并报告标识格式槽位错误,不发起任何大模型调用 | +| VAL-RULE-02 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商会话标识为 35 位(末段缺少一位十六进制位)时同样被规则门拒绝,UUID 长度边界从严执行 | +| VAL-RULE-03 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商会话标识长度与 8-4-4-4-12 分段均正确但首段含非十六进制字符 g 时,规则门仍按 UUID 格式拒绝 | +| VAL-RULE-04 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 当前轮次等于最大轮次预算(第 5 轮即最后一轮)时消息仍可通过规则校验并正常提取参数,只有超出预算才被拒绝 | +| VAL-RULE-05 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商轮次超出最大轮次预算(第 6 轮对 5 轮上限)时规则门拒绝消息并报告轮次越界槽位错误,不发起任何大模型调用 | +| VAL-RULE-06 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_rule_violation) | 会话标识非法且轮次超限同时发生时,规则门一次性报告标识格式与轮次越界两条槽位错误,不发起任何大模型调用 | +| VAL-RULE-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 单轮预算(最大轮次为 1)的第 1 轮消息可通过规则校验并正常提取参数,轮次下限边界同样放行 | +| VAL-RULE-08 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P2 | success | 协商会话标识使用大写十六进制 UUID 时规则门同样放行,消息正常校验并提取参数,合并结果原样保留大写标识 | + +### VAL-SEM -- validate/semantic-verdicts.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| VAL-SEM-01 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 语义判定通过且上报类型与信息协商模板一致时,接受消息校验成功并合并出区域参数 | +| VAL-SEM-02 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 语义判定通过且上报类型与目标协商模板一致时,接受消息校验成功并合并出站点与速率保障参数 | +| VAL-SEM-03 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 语义判定通过且上报类型与可行性协商模板一致时,提议消息校验成功并合并出可行性结论参数 | +| VAL-SEM-04 | `validateAbortPromptAndDataFilling` | en-US, zh-CN | P0 | success | 终止消息语义判定通过且未上报协商类型时校验成功,合并出终止原因参数 | +| VAL-SEM-05 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P1 | success | 语义判定通过且类型一致时,拒绝侧消息同样校验成功,类型一致性不区分接受或拒绝动作 | +| VAL-SEM-06 | `validateAbortPromptAndDataFilling` | en-US, zh-CN | P2 | success | 公共终止模板未声明协商类型,判定通过时即使大模型上报了类型也照常校验成功 | +| VAL-SEM-07 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型判定通过但上报成目标协商类型时,信息协商提议被语义拒绝并指出类型不一致 | +| VAL-SEM-08 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型判定通过但未上报协商类型时,信息协商提议被语义拒绝并按模板类型报类型不一致 | +| VAL-SEM-09 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型判定通过但上报未知协商类型字符串时,信息协商提议被语义拒绝 | +| VAL-SEM-10 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型上报的协商类型仅大小写不同也被视为类型不一致,信息协商提议被语义拒绝 | +| VAL-SEM-11 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型语义判定不通过时,信息协商提议被拒绝并返回缺失的区域参数槽位错误 | +| VAL-SEM-12 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 语义判定不通过时不再核对协商类型,拒绝侧消息只返回大模型报告的槽位错误 | +| VAL-SEM-13 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 语义判定不通过且未报告任何槽位错误时,校验仍以语义拒绝失败且槽位错误为空 | +| VAL-SEM-14 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 语义判定不通过时已提取的参数被丢弃,校验以语义拒绝失败且不合并任何业务参数 | +| VAL-SEM-15 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 终止消息语义判定不通过时被拒绝并返回缺失终止原因的槽位错误 | +| VAL-SEM-16 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 语义拒绝不触发重试:脚本备有第二次通过的应答也不会被消费,LLM 调用数恒为 1 | +| VAL-SEM-17 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P2 | success | 语义判定通过时大模型附带的槽位错误被忽略,校验成功且只合并上下文三键 | + +## Scenario index + +### SC-ERR -- scenarios/boundary-flows.json + +| Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | +|---|---|---|---|---|---|---| +| SC-ERR-01 | `generateProposeFromText`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=1, distinct-messages | - | 流程中错误恢复:大模型内容抽取首次返回不可解析结果,重试第二次成功后协商流程继续推进直至接受闭环 | +| SC-ERR-02 | `generateProposeFromText`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | (negotiation_semantic_rejected) | 流程中错误恢复:round 1 提议因缺少节能区域信息被语义校验拒绝,round 2 补充内容重新提议后通过并接受 | +| SC-ERR-03 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2 | - | 重传幂等:同一条接受消息被重复校验两次,两次提取的参数结果完全一致,重传不产生副作用 | +| SC-ERR-04 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=2 | - | 乱序失同步探针:B 以滞后的 round 1 上下文校验 A 在 round 2 生成的提议,规则门不拦截失同步(doc-gap 探针) | +| SC-ERR-05 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=1 | (negotiation_semantic_rejected) | 截断消息探针:传输中被截断的提议消息缺失后两项信息,语义校验以槽位缺失为由拒绝 | +| SC-ERR-06 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2 | (negotiation_semantic_rejected) | 编码边界探针:带 UTF-8 BOM 前缀或全角标点的接受消息可正常校验提取,GBK 乱码形态的消息被语义校验拒绝 | +| SC-ERR-07 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=1, distinct-messages | - | 双会话交错:信息协商与目标协商两个会话步骤交替执行,各自提取的上下文参数互不串扰 | + +### SC-EXH -- scenarios/boundary-flows.json + +| Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | +|---|---|---|---|---|---|---| +| SC-EXH-01 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText`, `validateAbortPromptAndDataFilling` | zh-CN | - | exhausted, rounds=3 | - | 轮次预算耗尽边界:三轮提议均遭拒绝,round 3 达到 maxRounds 上限后生成终止消息结束协商 | +| SC-EXH-02 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=6 | (negotiation_rule_violation) | 过期重放边界:携带 round 超过轮次预算的消息被规则门直接拦截且不调用大模型,修正轮次后重放校验通过 | + +### SC-FSB -- scenarios/feasibility-flows.json + +| Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | +|---|---|---|---|---|---|---| +| SC-FSB-01 | `generateProposeFromText`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=1, distinct-messages | - | 可行性协商单轮闭环:A 发起调整后速率保障目标的可行性评估请求,B 评估通过后生成接受消息,A 校验确认达成一致 | +| SC-FSB-02 | `generateProposeFromText`, `generateRejectFromText`, `validateRejectPromptAndDataFilling`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=2, distinct-messages | - | 可行性协商多轮演进:round 1 评估结论为不可行并拒绝,A 于 round 2 改提替代方案,B 评估通过并接受,协商达成一致 | +| SC-FSB-03 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText`, `validateAbortPromptAndDataFilling` | zh-CN | - | abort, rounds=2, distinct-messages | - | 可行性协商中止:round 1 评估不可行且双方无法推进,round 2 生成通用终止消息并经校验确认协商提前终止 | + +### SC-INFO -- scenarios/information-flows.json + +| Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | +|---|---|---|---|---|---|---| +| SC-INFO-01 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=1 | - | 信息协商单轮闭环:A 从自然语言生成接受消息,B 校验通过并提取参数 | +| SC-INFO-02 | `generateProposeFromText`, `generateRejectFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=2, distinct-messages | - | 信息协商两轮演进:首轮因站点库存不可用被拒后,发起方收窄信息项于第2轮重新提议,接收方接受且参数校验提取成功 | +| SC-INFO-03 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText` | zh-CN | - | abort, rounds=2, distinct-messages | - | 信息协商中断:所需信息连续无法提供后,发起方在第2轮发起协商终止并生成终止消息 | + +### SC-TGT -- scenarios/target-flows.json + +| Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | +|---|---|---|---|---|---|---| +| SC-TGT-01 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=1, distinct-messages | - | 目标协商单轮闭环:A 从自然语言生成携带最终确认意图的接受消息,B 校验通过并提取站点与速率保障参数 | +| SC-TGT-02 | `generateProposeFromText`, `generateRejectFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=2, distinct-messages | - | 目标协商两轮演进:首轮区域信息无法澄清被拒后,发起方对齐区域理解并于第2轮重新提议,接收方接受且参数校验提取成功 | +| SC-TGT-03 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText` | zh-CN | - | abort, rounds=2, distinct-messages | - | 目标协商中断:区域信息持续无法澄清,发起方在第2轮发起协商终止并生成终止消息 | + diff --git a/tools/corpus_index.py b/tools/corpus_index.py new file mode 100644 index 00000000..129296f4 --- /dev/null +++ b/tools/corpus_index.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +"""Generates and freshness-checks INDEX.md for the A2A-T negotiation test corpus. + +Scans every corpus JSON under the negotiation-cases root (case files, scenario files and the +shared payload/schema maps), renders a machine-generated index -- top statistics, the +API x category x expected-outcome coverage matrices and one line per case -- and writes it to +negotiation-cases/INDEX.md. With --check the rendered index is compared against the file on +disk so CI fails whenever the corpus and the index drift apart. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_CORPUS_ROOT = REPO_ROOT / "a2a-t-negotiation" / "src" / "test" / "resources" / "negotiation-cases" +INDEX_NAME = "INDEX.md" +SCHEMA_NAME = "corpus-schema.json" + +APIS = ( + "generateProposeFromText", + "generateAcceptFromText", + "generateRejectFromText", + "generateAbortFromText", + "generateProposeFromData", + "generateAcceptFromData", + "generateRejectFromData", + "generateAbortFromData", + "validateProposePromptAndDataFilling", + "validateAcceptPromptAndDataFilling", + "validateRejectPromptAndDataFilling", + "validateAbortPromptAndDataFilling", +) +FAMILY_ORDER = ("FT", "FD", "VAL", "SC") +FAMILY_LABELS = {"FT": "from-text", "FD": "from-data", "VAL": "validate", "SC": "scenarios"} + + +def error(path: Path, rule: str, message: str) -> str: + return f"{path}: [{rule}] {message}" + + +class Case: + def __init__(self, path: Path, record: dict) -> None: + self.path = path + self.id: str = record["id"] + self.api: str = record["api"] + self.languages: list[str] = list(record.get("languages", [])) + self.priority: str = record.get("priority", "-") + self.summary: str = record.get("summary", "") + self.expect = record["expect"] + + +class Scenario: + def __init__(self, path: Path, record: dict) -> None: + self.path = path + self.id: str = record["id"] + self.languages: list[str] = list(record.get("languages", [])) + self.summary: str = record.get("summary", "") + self.steps: list[dict] = list(record["steps"]) + self.expect_flow: dict = record.get("expectFlow", {}) + + +def family_of(record_id: str, path: Path, errors: list[str]) -> str | None: + family = record_id.split("-", 1)[0] + if family not in FAMILY_LABELS: + errors.append(error(path, "corpus-id", f"Record id '{record_id}' does not start with a known family prefix.")) + return None + return family + + +def category_of(record_id: str) -> str: + return "-".join(record_id.split("-")[:2]) + + +def outcome_of(expect: dict) -> str: + if expect.get("outcome") == "success": + return "success" + if "code" in expect: + return expect["code"] + return f"exception:{expect.get('exception', '?')}" + + +def outcome_label(expect: dict) -> str: + if expect.get("outcome") == "success": + return "success" + parts = [] + if "exception" in expect: + parts.append(expect["exception"]) + if "code" in expect: + parts.append(expect["code"]) + return "(" + ", ".join(parts) + ")" if parts else "(failure)" + + +def load_corpus(root: Path) -> tuple[list[Case], list[Scenario], dict, list[str]]: + """Loads every corpus JSON, classifying case files, scenario files and the shared maps.""" + cases: list[Case] = [] + scenarios: list[Scenario] = [] + shared = {"payloads": 0, "schemas": 0} + errors: list[str] = [] + for path in sorted(root.rglob("*.json")): + relative = path.relative_to(root).as_posix() + if path.name == SCHEMA_NAME: + continue + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + errors.append(error(path, "corpus-json", f"Cannot load corpus file: {exc}")) + continue + if relative.startswith("shared/"): + if not isinstance(data, dict): + errors.append(error(path, "corpus-shared", "Shared corpus files must be JSON objects.")) + continue + if path.name == "llm-responses.json": + shared["payloads"] = len(data) + elif path.name == "schemas.json": + shared["schemas"] = len(data) + continue + if not isinstance(data, list): + errors.append(error(path, "corpus-file", "Corpus files must be JSON arrays of records.")) + continue + for record in data: + if not isinstance(record, dict): + errors.append(error(path, "corpus-record", "Corpus records must be JSON objects.")) + continue + record_id = record.get("id") + if not isinstance(record_id, str): + errors.append(error(path, "corpus-record", "Corpus record is missing its 'id'.")) + continue + if family_of(record_id, path, errors) is None: + continue + if "steps" in record: + scenarios.append(Scenario(path, record)) + else: + cases.append(Case(path, record)) + return cases, scenarios, shared, errors + + +def category_sort_key(category: str) -> tuple: + family = category.split("-", 1)[0] + return (FAMILY_ORDER.index(family), category) + + +def render_flow(expect_flow: dict) -> str: + if not expect_flow: + return "-" + parts = [] + if "terminalCondition" in expect_flow: + parts.append(expect_flow["terminalCondition"]) + if "roundsUsed" in expect_flow: + parts.append(f"rounds={expect_flow['roundsUsed']}") + if expect_flow.get("distinctMessages"): + parts.append("distinct-messages") + return ", ".join(parts) if parts else "-" + + +def render_index(root: Path) -> str: + cases, scenarios, shared, errors = load_corpus(root) + if errors: + for item in errors: + print(item, file=sys.stderr) + raise SystemExit(1) + + # Coverage units: one base case record, or one scenario step, keyed by (api, category, outcome). + units: list[tuple[str, str, str]] = [] + for case in cases: + units.append((case.api, category_of(case.id), outcome_of(case.expect))) + for scenario in scenarios: + category = category_of(scenario.id) + for step in scenario.steps: + units.append((step["api"], category, outcome_of(step["expect"]))) + + error_codes = sorted({outcome for _, _, outcome in units if outcome not in ("success",) and not outcome.startswith("exception:")}) + exception_types = sorted({outcome[len("exception:"):] for _, _, outcome in units if outcome.startswith("exception:")}) + outcomes = ["success", *error_codes, "exception"] + categories = sorted({category for _, category, _ in units}, key=category_sort_key) + + languages_expanded = sum(len(case.languages) for case in cases) + sum(len(scenario.languages) for scenario in scenarios) + priority_counts = {priority: 0 for priority in ("P0", "P1", "P2")} + for case in cases: + if case.priority in priority_counts: + priority_counts[case.priority] += 1 + + lines: list[str] = [] + lines.append("") + lines.append("") + lines.append("# Negotiation Test Corpus Index") + lines.append("") + lines.append("Machine-generated view of the corpus under `a2a-t-negotiation/src/test/resources/negotiation-cases/`.") + lines.append("Coverage matrices count base records (one case record, or one scenario step under its scenario's category);") + lines.append("language expansion is listed per record and totaled in the statistics.") + lines.append("") + + # Statistics. + lines.append("## Statistics") + lines.append("") + lines.append("| Metric | Value |") + lines.append("|---|---|") + lines.append(f"| Case records | {len(cases)} |") + lines.append(f"| Scenario records (steps) | {len(scenarios)} ({sum(len(s.steps) for s in scenarios)}) |") + lines.append(f"| Base records total | {len(cases) + len(scenarios)} |") + lines.append(f"| Language-expanded units | {languages_expanded} |") + lines.append(f"| Shared LLM payloads | {shared['payloads']} |") + lines.append(f"| Shared schema variants | {shared['schemas']} |") + lines.append(f"| Error codes covered | {len(error_codes)} ({', '.join(error_codes)}) |") + lines.append(f"| Exception-only failures | {sum(1 for _, _, o in units if o.startswith('exception:'))} ({', '.join(exception_types) or 'none'}) |") + lines.append("") + lines.append("Priority distribution (case records; scenarios carry no priority):") + lines.append("") + lines.append("| Priority | Cases |") + lines.append("|---|---|") + for priority in ("P0", "P1", "P2"): + lines.append(f"| {priority} | {priority_counts[priority]} |") + lines.append("") + + # Family distribution. + lines.append("## Family distribution") + lines.append("") + lines.append("| Family | Category | Corpus file | Records | Steps | Languages |") + lines.append("|---|---|---|---|---|---|") + family_records: dict[tuple[str, str], dict] = {} + for case in cases: + key = (category_of(case.id), case.path.relative_to(root).as_posix()) + entry = family_records.setdefault(key, {"records": 0, "steps": 0, "languages": set()}) + entry["records"] += 1 + entry["languages"].update(case.languages) + for scenario in scenarios: + key = (category_of(scenario.id), scenario.path.relative_to(root).as_posix()) + entry = family_records.setdefault(key, {"records": 0, "steps": 0, "languages": set()}) + entry["records"] += 1 + entry["steps"] += len(scenario.steps) + entry["languages"].update(scenario.languages) + for (category, file_name), entry in sorted(family_records.items(), key=lambda item: category_sort_key(item[0][0])): + family = category.split("-", 1)[0] + lines.append( + f"| {FAMILY_LABELS[family]} ({family}) | {category} | {file_name} | {entry['records']} | {entry['steps']} | {', '.join(sorted(entry['languages']))} |" + ) + lines.append("") + + # API x category coverage matrix. + lines.append("## API x category coverage") + lines.append("") + lines.append("Cells count base records (case records plus scenario steps) per API and category; empty cells are coverage gaps.") + lines.append("") + lines.append("| Category | " + " | ".join(APIS) + " | Total |") + lines.append("|---" * (len(APIS) + 2) + "|") + for category in categories: + counts = [sum(1 for api, cat, _ in units if cat == category and api == api_name) for api_name in APIS] + total = sum(counts) + cells = " | ".join(str(count) if count else "-" for count in counts) + lines.append(f"| {category} | {cells} | {total} |") + column_totals = [sum(1 for api, _, _ in units if api == api_name) for api_name in APIS] + lines.append("| **Total** | " + " | ".join(f"**{count}**" for count in column_totals) + f" | **{sum(column_totals)}** |") + lines.append("") + + # API x expected outcome coverage matrix. + lines.append("## API x expected outcome coverage") + lines.append("") + lines.append( + "Cells count base records per API and expectation; the `exception` column aggregates failures that assert only " + "an exception type (failures carrying both an exception and a code are counted under their code)." + ) + lines.append("") + lines.append("| API | " + " | ".join(f"`{outcome}`" for outcome in outcomes) + " | Total |") + lines.append("|---" * (len(outcomes) + 2) + "|") + for api_name in APIS: + counts = [] + for outcome in outcomes: + if outcome == "exception": + counts.append(sum(1 for api, _, o in units if api == api_name and o.startswith("exception:"))) + else: + counts.append(sum(1 for api, _, o in units if api == api_name and o == outcome)) + cells = " | ".join(str(count) if count else "-" for count in counts) + lines.append(f"| `{api_name}` | {cells} | {sum(counts)} |") + lines.append("") + + # Per-record listing. + lines.append("## Case index") + lines.append("") + by_file: dict[tuple[str, Path], list] = {} + for case in cases: + by_file.setdefault((category_of(case.id), case.path), []).append(case) + for (category, path), file_cases in sorted(by_file.items(), key=lambda item: category_sort_key(item[0][0])): + lines.append(f"### {category} -- {path.relative_to(root).as_posix()}") + lines.append("") + lines.append("| Id | API | Languages | Priority | Expectation | Summary |") + lines.append("|---|---|---|---|---|---|") + for case in file_cases: + languages = ", ".join(sorted(case.languages)) + lines.append(f"| {case.id} | `{case.api}` | {languages} | {case.priority} | {outcome_label(case.expect)} | {case.summary} |") + lines.append("") + + lines.append("## Scenario index") + lines.append("") + by_file = {} + for scenario in scenarios: + by_file.setdefault((category_of(scenario.id), scenario.path), []).append(scenario) + for (category, path), file_scenarios in sorted(by_file.items(), key=lambda item: category_sort_key(item[0][0])): + lines.append(f"### {category} -- {path.relative_to(root).as_posix()}") + lines.append("") + lines.append("| Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary |") + lines.append("|---|---|---|---|---|---|---|") + for scenario in file_scenarios: + apis = ", ".join(f"`{api}`" for api in dict.fromkeys(step["api"] for step in scenario.steps)) + languages = ", ".join(sorted(scenario.languages)) + failures: dict[str, int] = {} + for step in scenario.steps: + if step["expect"].get("outcome") == "failure": + label = outcome_label(step["expect"]) + failures[label] = failures.get(label, 0) + 1 + failure_text = ", ".join(f"{label} x{count}" if count > 1 else label for label, count in sorted(failures.items())) or "-" + lines.append( + f"| {scenario.id} | {apis} | {languages} | - | {render_flow(scenario.expect_flow)} | {failure_text} | {scenario.summary} |" + ) + lines.append("") + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--corpus-root", type=Path, default=DEFAULT_CORPUS_ROOT, help="Negotiation corpus directory (default: the bundled test corpus).") + parser.add_argument("--check", action="store_true", help="Compare the generated index against INDEX.md on disk instead of writing it; exit 1 on drift.") + args = parser.parse_args() + + root = args.corpus_root.resolve() + if not root.is_dir(): + print(f"Corpus root does not exist: {root}", file=sys.stderr) + return 1 + content = render_index(root) + index_path = root / INDEX_NAME + + if args.check: + try: + on_disk = index_path.read_text(encoding="utf-8") + except OSError: + print(f"{INDEX_NAME} is missing under {root}; run: python tools/corpus_index.py", file=sys.stderr) + return 1 + # Normalize line endings so a CRLF checkout never produces a false drift signal. + if on_disk.replace("\r\n", "\n") != content: + print(f"{index_path} is stale; regenerate it with: python tools/corpus_index.py", file=sys.stderr) + return 1 + print(f"Corpus index is fresh: {index_path}") + return 0 + + with open(index_path, "w", encoding="utf-8", newline="\n") as handle: + handle.write(content) + print(f"Wrote corpus index: {index_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/corpus_review.py b/tools/corpus_review.py new file mode 100644 index 00000000..8d4510a0 --- /dev/null +++ b/tools/corpus_review.py @@ -0,0 +1,780 @@ +#!/usr/bin/env python3 +"""Business review support for the A2A-T negotiation test corpus (design doc-local/fromtext-validate-test-design.md §8.7, Q19). + +Review material is a *projection* of the corpus: generated from negotiation-cases/ JSON, never hand-maintained. + +Two modes: + + export generate docs-local/review/corpus-review.html (self-contained dashboard + + per-domain case cards + scenario ping-pong tables) and + docs-local/review/corpus-review.xlsx (one row per case, last two columns are + blank annotation columns for the business expert) + collect --marked read the annotated xlsx (or a csv fallback) back, emit + docs-local/review/review-findings.md (findings mapped to exact JSON file + line) + and docs-local/review/review-status.json (per-case review status) + +xlsx output requires `openpyxl` (pip install openpyxl). Without it, export degrades gracefully: +a warning is printed and only the HTML report is produced; collect then accepts a .csv marked file +(export also writes docs-local/review/corpus-review.csv alongside the xlsx so the closed loop works +in degraded environments). +""" + +from __future__ import annotations + +import argparse +import csv +import html +import io +import json +import sys +from datetime import datetime +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +CORPUS_ROOT = REPO_ROOT / "a2a-t-negotiation" / "src" / "test" / "resources" / "negotiation-cases" +REVIEW_DIR = REPO_ROOT / "docs-local" / "review" +HTML_PATH = REVIEW_DIR / "corpus-review.html" +XLSX_PATH = REVIEW_DIR / "corpus-review.xlsx" +CSV_PATH = REVIEW_DIR / "corpus-review.csv" +STATUS_PATH = REVIEW_DIR / "review-status.json" +FINDINGS_PATH = REVIEW_DIR / "review-findings.md" + +CASE_FAMILIES = ("from-text", "from-data", "validate") +SCENARIO_FAMILY = "scenarios" + +# --- business-language dictionaries (Q19: experts see pure business wording, never raw codes) ------------- + +API_ACTION_ZH = { + "generateProposeFromText": "从自然语言生成提议消息", + "generateAcceptFromText": "从自然语言生成接受消息", + "generateRejectFromText": "从自然语言生成拒绝消息", + "generateAbortFromText": "从自然语言生成终止消息", + "generateProposeFromData": "从结构化数据生成提议消息", + "generateAcceptFromData": "从结构化数据生成接受消息", + "generateRejectFromData": "从结构化数据生成拒绝消息", + "generateAbortFromData": "从结构化数据生成终止消息", + "validateProposePromptAndDataFilling": "校验提议消息并提取参数", + "validateAcceptPromptAndDataFilling": "校验接受消息并提取参数", + "validateRejectPromptAndDataFilling": "校验拒绝消息并提取参数", + "validateAbortPromptAndDataFilling": "校验终止消息并提取参数", +} + +ERROR_CODE_ZH = { + "negotiation_invalid_input": "协商输入无效(如消息不是协商消息、缺少协商上下文)", + "negotiation_slot_missing": "协商消息缺少必填槽位", + "negotiation_content_extract_failed": "大模型返回内容无法解析为协商结构", + "negotiation_llm_infrastructure_error": "大模型基础设施错误(调用失败)", + "negotiation_semantic_rejected": "语义校验判定不通过", + "negotiation_rule_violation": "协商上下文违反规则(如轮次超限、标识格式非法)", + "template_not_found": "协商模板不存在或不可用", + # internal validation_* codes and slot-level codes surfaced through slotErrors + "validation_invalid_input": "(内部码)校验输入无效", + "validation_rule_violation": "(内部码)校验规则违反", + "validation_semantic_rejected": "(内部码)语义判定不通过", + "validation_llm_infrastructure_error": "(内部码)大模型基础设施错误", + "validation_prompt_resource_not_found": "(内部码)模板资源不存在", + "invalid_uuid": "会话标识不是合法 UUID", + "missing": "槽位缺失", + "out_of_range": "数值超出允许范围", + "template_type_mismatch": "模板类型不匹配", +} + +EXCEPTION_ZH = { + "NegotiationGenerationException": "协商生成失败", + "NegotiationParamExtractionException": "协商参数提取失败", + "NullPointerException": "空指针异常(编程错误)", + "IllegalArgumentException": "非法参数异常(编程错误)", + "RuntimeException": "运行时异常(编程错误)", +} + +FAIL_MODE_ZH = { + "non-json": "非法 JSON 数据", + "null-response": "空响应", + "blank-content": "空白内容", + "llm-error": "基础设施错误", + "assertion": "断言失败(不该被调用时触发)", +} + +INJECT_ZH = { + "failingTemplateLoader": "注入不可用的模板加载器(模拟模板缺失)", + "failingSemanticValidator": "注入不可用的语义校验器(模拟校验器故障)", +} + +TEMPLATE_URI_ZH = { + "information-negotiation": "信息协商", + "target-negotiation": "目标协商", + "feasibility-negotiation": "可行性协商", + "common": "通用", +} +PHASE_ZH = {"propose": "提议", "accept-reject": "接受/拒绝", "abort": "终止"} +TERMINAL_ZH = {"accept": "接受(协商达成)", "reject": "拒绝(协商终止)", "abort": "终止", "exhausted": "轮次预算耗尽"} + +DOMAIN_ORDER = ["信息协商", "目标协商", "可行性协商", "通用(终止)", "未分类"] +DOMAIN_FALLBACK_TAG = { + "information": "信息协商", + "target": "目标协商", + "feasibility": "可行性协商", + "abort": "通用(终止)", + "common": "通用(终止)", +} + +REVIEW_STATUSES = ("通过", "有疑问", "否决") + +XLSX_HEADERS = [ + "id", "业务域", "阶段", "API动作", "输入摘要", "期望结果", "优先级", "语言", "标签", + "评审结论", "评审意见", +] + + +# --- corpus loading ----------------------------------------------------------------------------------------- + +def json_line_of(raw_text: str, case_id: str) -> int: + """1-based line number of the `"id": ""` occurrence inside the corpus JSON file.""" + needle = f'"id": "{case_id}"' + for lineno, line in enumerate(raw_text.splitlines(), start=1): + if needle in line: + return lineno + return 1 + + +def load_corpus() -> list[dict]: + """Load all case and scenario records, enriched with source location and derived business facets.""" + records: list[dict] = [] + if not CORPUS_ROOT.is_dir(): + raise SystemExit(f"corpus root not found: {CORPUS_ROOT}") + for family in (*CASE_FAMILIES, SCENARIO_FAMILY): + family_dir = CORPUS_ROOT / family + if not family_dir.is_dir(): + continue + for path in sorted(family_dir.glob("*.json")): + raw = path.read_text(encoding="utf-8") + for entry in json.loads(raw): + entry["_family"] = family + entry["_file"] = str(path.relative_to(REPO_ROOT)).replace("\\", "/") + entry["_line"] = json_line_of(raw, entry["id"]) + records.append(entry) + return records + + +def api_action(api: str) -> str: + return API_ACTION_ZH.get(api, api) + + +def business_domain(rec: dict) -> str: + uri = rec.get("templateUri") or "" + if not uri and rec["_family"] == SCENARIO_FAMILY: + # scenario records carry templateUri on their steps, not at top level + for step in rec.get("steps", []): + uri = step.get("templateUri") or "" + if uri: + break + for segment, name in TEMPLATE_URI_ZH.items(): + if segment in uri: + return "通用(终止)" if segment == "common" else name + tags = rec.get("tags") + if tags is None and rec["_family"] == SCENARIO_FAMILY: + tags = [t for step in rec.get("steps", []) for t in step.get("tags", [])] + for tag, name in DOMAIN_FALLBACK_TAG.items(): + if tag in (tags or []): + return name + return "未分类" + + +def stage_of(rec: dict) -> str: + if rec["_family"] == SCENARIO_FAMILY: + return "端到端场景" + api = rec.get("api", "") + if api.startswith("generate"): + return "生成" + if api.startswith("validate"): + return "校验" + return "其他" + + +def template_uri_zh(uri: str | None) -> str: + if not uri: + return "" + parts = uri.split("/") + domain = TEMPLATE_URI_ZH.get(parts[1], parts[1]) if len(parts) > 1 else uri + phase = PHASE_ZH.get(parts[2], parts[2]) if len(parts) > 2 else "" + version = parts[3] if len(parts) > 3 else "" + label = domain + if phase: + label += f"·{phase}" + if version: + label += f" {version}" + return label + + +# --- technical -> business translation ---------------------------------------------------------------------- + +def translate_llm_script(llm: dict | None) -> str: + """`[{"$fail":"non-json"}, {"$ref":"..."}]` -> 模拟大模型第 1 次返回非法数据、第 2 次返回正常内容...""" + if not llm: + return "" + script = llm.get("script") or [] + if not script: + return "" + parts: list[str] = [] + for index, item in enumerate(script, start=1): + if isinstance(item, dict) and "$fail" in item: + mode = FAIL_MODE_ZH.get(item["$fail"], item["$fail"]) + parts.append(f"第 {index} 次返回{mode}") + elif isinstance(item, dict) and "$ref" in item: + parts.append(f"第 {index} 次返回正常内容({item['$ref']})") + else: + parts.append(f"第 {index} 次返回指定的原始内容") + if len(script) == 1: + # single-shot wording without the enumeration reads better + text = "模拟大模型" + parts[0].replace("第 1 次", "") + else: + text = "模拟大模型" + "、".join(parts) + if "maxAttempts" in llm: + text += f"(最大尝试次数 {llm['maxAttempts']})" + return text + + +def translate_input(rec: dict) -> str: + """Input summary in business language: natural-language text, structured data, or prompt under validation.""" + chunks: list[str] = [] + inp = rec.get("input") or {} + text = inp.get("text") + if isinstance(text, dict): + zh = text.get("zh-CN") or text.get("en-US") or "" + langs = "(双语输入)" if len(text) > 1 else "" + chunks.append(f"自然语言{langs}:{clip(zh, 90)}") + elif isinstance(text, str): + chunks.append(f"自然语言:{clip(text, 90)}") + data = inp.get("data") + if data is not None: + chunks.append(f"结构化数据:{describe_data(data)}") + prompt = rec.get("prompt") + if isinstance(prompt, dict): + if "golden" in prompt: + chunks.append(f"待校验消息取自 golden 基准 {prompt['golden']}") + elif "fromStep" in prompt: + chunks.append(f"待校验消息取自第 {prompt['fromStep']} 步的生成结果") + elif "text" in prompt: + chunks.append(f"待校验消息:{clip(str(prompt['text']), 90)}") + schema = rec.get("schema") + if isinstance(schema, dict) and "$ref" in schema: + chunks.append(f"参数模式:{schema['$ref'].split('/')[-1]}") + return ";".join(chunks) + + +def describe_data(data) -> str: + if not isinstance(data, dict): + return "(原始结构化数据)" + parts: list[str] = [] + if "conclusion" in data: + parts.append(f"结论={data['conclusion']}") + items = data.get("items") + if isinstance(items, list): + parts.append(f"{len(items)} 个信息项") + if "relationship" in data: + parts.append(f"关系={data['relationship']}") + if "reason" in data: + parts.append(f"终止原因={clip(str(data['reason']), 40)}") + return "、".join(parts) if parts else "(原始结构化数据)" + + +def translate_expect(rec: dict) -> str: + """Expectation summary in business language.""" + expect = rec.get("expect") or {} + outcome = expect.get("outcome") + lines: list[str] = [] + if outcome == "success": + lines.append("成功") + golden = expect.get("promptTextEqualsGolden") + if golden: + lines.append(f"渲染消息与 golden 基准一致({golden})") + params = expect.get("params") + if isinstance(params, dict): + rendered = "、".join(f"{k}={v}" for k, v in params.items()) + lines.append(f"提取参数:{clip(rendered, 120)}") + elif outcome == "failure": + code = expect.get("code") + lines.append(f"失败:{ERROR_CODE_ZH.get(code, code or '(无错误码)')}" + (f"({code})" if code else "")) + exception = expect.get("exception") + if exception: + lines.append(f"异常类型:{EXCEPTION_ZH.get(exception, exception)}") + slot_errors = expect.get("slotErrors") or [] + if slot_errors: + rendered = "、".join( + f"{e.get('slot')}:{ERROR_CODE_ZH.get(e.get('code'), e.get('code'))}" for e in slot_errors + ) + lines.append(f"槽位错误:{rendered}") + contains = expect.get("messageContains") + if contains: + lines.append(f"错误消息包含:{' / '.join(map(str, contains))}") + llm_calls = expect.get("llmCalls") + if llm_calls is not None: + lines.append("不发起任何大模型调用" if llm_calls == 0 else f"共调用大模型 {llm_calls} 次") + return ";".join(lines) + + +def translate_expect_flow(rec: dict) -> str: + flow = rec.get("expectFlow") or {} + if not flow: + return "" + parts: list[str] = [] + terminal = flow.get("terminalCondition") + if terminal: + parts.append(f"终态:{TERMINAL_ZH.get(terminal, terminal)}") + if "roundsUsed" in flow: + parts.append(f"共 {flow['roundsUsed']} 轮") + if flow.get("distinctMessages"): + parts.append("各轮消息互不相同") + return ",".join(parts) + + +def clip(text: str, limit: int) -> str: + text = str(text).replace("\n", " ") + return text if len(text) <= limit else text[: limit - 1] + "…" + + +def languages_label(rec: dict) -> str: + return "+".join(rec.get("languages", [])) + + +def review_status_map() -> dict: + if STATUS_PATH.is_file(): + try: + data = json.loads(STATUS_PATH.read_text(encoding="utf-8")) + return data.get("cases", {}) + except (OSError, ValueError): + return {} + return {} + + +# --- HTML rendering ----------------------------------------------------------------------------------------- + +CSS = """ +:root { color-scheme: light; } +* { box-sizing: border-box; } +body { font-family: 'Microsoft YaHei', 'PingFang SC', 'Segoe UI', sans-serif; margin: 0; background: #f5f6f8; color: #1f2430; } +header { background: #1f3a5f; color: #fff; padding: 18px 28px; } +header h1 { margin: 0 0 4px; font-size: 20px; } +header p { margin: 0; font-size: 12px; opacity: .85; } +nav { background: #fff; border-bottom: 1px solid #e3e6ea; padding: 8px 28px; font-size: 13px; position: sticky; top: 0; } +nav a { color: #1f3a5f; text-decoration: none; margin-right: 18px; font-weight: 600; } +main { padding: 20px 28px 48px; max-width: 1180px; margin: 0 auto; } +h2 { font-size: 17px; border-left: 4px solid #1f3a5f; padding-left: 10px; margin: 34px 0 14px; } +h3 { font-size: 14px; margin: 20px 0 10px; color: #1f3a5f; } +.cards { display: flex; flex-wrap: wrap; gap: 12px; } +.kpi { background: #fff; border: 1px solid #e3e6ea; border-radius: 8px; padding: 14px 18px; min-width: 150px; flex: 1; } +.kpi .num { font-size: 26px; font-weight: 700; color: #1f3a5f; } +.kpi .lbl { font-size: 12px; color: #667; margin-top: 2px; } +table { border-collapse: collapse; background: #fff; width: 100%; font-size: 13px; margin: 8px 0 16px; } +th, td { border: 1px solid #e3e6ea; padding: 6px 10px; text-align: left; vertical-align: top; } +th { background: #eef1f5; font-weight: 600; white-space: nowrap; } +.badge { display: inline-block; border-radius: 10px; padding: 1px 9px; font-size: 11px; font-weight: 600; white-space: nowrap; } +.b-P0 { background: #fdecea; color: #b3261e; } +.b-P1 { background: #fff4e0; color: #a05a00; } +.b-P2 { background: #eef1f5; color: #556; } +.b-lang { background: #e8f0fe; color: #1a4d8f; } +.b-ok { background: #e3f4e4; color: #1c6b2b; } +.b-doubt { background: #fff4e0; color: #a05a00; } +.b-veto { background: #fdecea; color: #b3261e; } +.b-none { background: #eef1f5; color: #778; } +.b-risk { background: #fdecea; color: #b3261e; } +.case { background: #fff; border: 1px solid #e3e6ea; border-radius: 8px; padding: 12px 16px; margin-bottom: 12px; } +.case .title { font-weight: 700; font-size: 14px; margin-bottom: 6px; } +.case .meta { margin: 2px 0; font-size: 12.5px; color: #334; } +.case .meta b { color: #1f3a5f; } +.case .src { font-size: 11px; color: #99a; margin-top: 6px; } +.pingpong td { height: 100%; } +.pp-step { background: #f0f5fb; border-radius: 6px; padding: 8px 10px; margin: 4px 0; font-size: 12.5px; } +.pp-step .api { font-weight: 700; color: #1f3a5f; } +footer { padding: 16px 28px; color: #889; font-size: 11px; } +.risk { background: #fff; border: 1px solid #f0c8c4; border-left: 4px solid #b3261e; border-radius: 6px; padding: 10px 14px; margin: 8px 0; font-size: 13px; } +""" + + +def esc(value) -> str: + return html.escape(str(value), quote=True) + + +def badge(value: str, cls: str) -> str: + return f'{esc(value)}' + + +def status_badge(status: str) -> str: + mapping = {"通过": "b-ok", "有疑问": "b-doubt", "否决": "b-veto"} + if status in mapping: + return badge("评审" + status, mapping[status]) + return badge("未评审", "b-none") + + +def render_html(records: list[dict], statuses: dict) -> str: + total = len(records) + expanded = sum(len(r.get("languages", [])) for r in records) + scenarios = [r for r in records if r["_family"] == SCENARIO_FAMILY] + cases = [r for r in records if r["_family"] != SCENARIO_FAMILY] + bilingual = sum(1 for r in records if len(r.get("languages", [])) > 1) + zh_only = sum(1 for r in records if r.get("languages") == ["zh-CN"]) + en_only = sum(1 for r in records if r.get("languages") == ["en-US"]) + priorities = {p: sum(1 for r in records if r.get("priority") == p) for p in ("P0", "P1", "P2")} + no_priority = total - sum(priorities.values()) + reviewed = sum(1 for s in statuses.values() if s.get("status") in REVIEW_STATUSES) + doc_gaps = [r for r in records if "doc-gap" in r.get("tags", [])] + p2_records = [r for r in records if r.get("priority") == "P2"] + + out = io.StringIO() + w = out.write + now = datetime.now().strftime("%Y-%m-%d %H:%M") + w("") + w("") + w(f"A2A-T 协商测试语料业务评审 · {now}") + w(f"

        A2A-T 协商测试语料业务评审材料

        " + f"

        自动生成于 {now} · 语料投影,请勿手工编辑 · 生成命令:python tools/corpus_review.py export

        ") + w("
        ") + + # ---- dashboard ---- + w("

        总览仪表盘

        ") + kpis = [ + (total, "用例总数(基记录)"), + (expanded, "双语展开后执行数"), + (len(cases), "单步用例"), + (len(scenarios), "端到端场景"), + (bilingual, "双语用例"), + (priorities["P0"], "P0 用例"), + (reviewed, "已评审"), + ] + for num, label in kpis: + w(f"
        {num}
        {esc(label)}
        ") + w("
        ") + + w("

        家族分布

        ") + for family in (*CASE_FAMILIES, SCENARIO_FAMILY): + count = sum(1 for r in records if r["_family"] == family) + w(f"") + w("
        家族数量
        {esc(family)}{count}
        ") + + w("

        双语 parity

        " + f"
        双语(zh-CN + en-US)仅 zh-CN仅 en-US
        {bilingual}{zh_only}{en_only}
        " + "

        parity 契约由 CorpusContractTest 严格模式保障:happy 用例双语都成功、" + "失败用例双语同错误码、双语 golden fixture 数量相同、展开后用例数相同。

        ") + + w("

        优先级分布

        ") + for p in ("P0", "P1", "P2"): + w(f"") + if no_priority: + w(f"") + w("
        优先级数量
        {badge(p, 'b-' + p)}{priorities[p]}
        (场景默认){no_priority}
        ") + + w("

        业务域 × 阶段覆盖矩阵

        ") + for domain in DOMAIN_ORDER: + subset = [r for r in records if business_domain(r) == domain] + if not subset: + continue + gen = sum(1 for r in subset if stage_of(r) == "生成") + val = sum(1 for r in subset if stage_of(r) == "校验") + e2e = sum(1 for r in subset if stage_of(r) == "端到端场景") + w(f"") + w("
        业务域生成校验端到端场景合计
        {esc(domain)}{gen}{val}{e2e}{len(subset)}
        ") + + w("

        协商错误码覆盖(7 个,CorpusContractTest 全量严格模式)

        ") + for code, zh in ERROR_CODE_ZH.items(): + if code.startswith("validation_"): + continue + count = sum(1 for r in records if (r.get("expect") or {}).get("code") == code) + marker = " style='color:#b3261e;font-weight:700'" if count == 0 else "" + w(f"{count}") + w("
        错误码业务含义失败用例数
        {esc(code)}{esc(zh)}
        ") + + w("

        风险区

        ") + w(f"
        未评审用例:{total - reviewed} / {total}(评审闭环见下方评审说明)
        ") + w(f"
        doc-gap 探针:{len(doc_gaps)} 个 —— 依据文档声明但未经验证的行为探针," + f"结论需与最新生产 Javadoc 对齐:" + "、".join(esc(r["id"]) for r in doc_gaps) + "
        ") + w(f"
        P2 低优先级用例:{len(p2_records)} 个 —— " + + "、".join(esc(r["id"]) for r in p2_records) + "
        ") + + # ---- per-domain case cards ---- + w("

        分域用例卡片

        ") + for domain in DOMAIN_ORDER: + subset = [r for r in records if r["_family"] != SCENARIO_FAMILY and business_domain(r) == domain] + if not subset: + continue + w(f"

        {esc(domain)}({len(subset)} 个用例)

        ") + for r in subset: + status = statuses.get(r["id"], {}).get("status", "") + w("
        ") + w(f"
        {esc(r['id'])} · {esc(r.get('summary', ''))}
        ") + w(f"
        动作:{esc(api_action(r.get('api', '')))}" + f" 模板:{esc(template_uri_zh(r.get('templateUri')))}
        ") + w(f"
        输入:{esc(translate_input(r))}
        ") + llm_desc = translate_llm_script(r.get("llm")) + if llm_desc: + w(f"
        大模型:{esc(llm_desc)}
        ") + inject = INJECT_ZH.get(r.get("inject", "")) + if inject: + w(f"
        故障注入:{esc(inject)}
        ") + w(f"
        期望:{esc(translate_expect(r))}
        ") + w("
        ") + w(badge(r.get("priority", "P2"), "b-" + r.get("priority", "P2"))) + w(" " + badge(languages_label(r), "b-lang")) + w(" " + status_badge(status)) + if "doc-gap" in r.get("tags", []): + w(" " + badge("doc-gap 探针", "b-risk")) + w("
        ") + w(f"
        来源:{esc(r['_file'])} 第 {r['_line']} 行 · 标签:{esc(', '.join(r.get('tags', [])))}
        ") + w("
        ") + + # ---- scenario ping-pong tables ---- + w("

        场景时序表(A/B 乒乓)

        ") + if not scenarios: + w("

        (无场景记录)

        ") + for r in scenarios: + status = statuses.get(r["id"], {}).get("status", "") + w(f"

        {esc(r['id'])} · {esc(r.get('summary', ''))} " + status_badge(status) + "

        ") + w("") + for step in r.get("steps", []): + role = step.get("role", "A") + col = "B" if role == "B" else "A" + detail = io.StringIO() + detail.write(f"
        {esc(api_action(step.get('api', '')))}
        ") + inp = translate_input(step) + if inp: + detail.write(f"
        输入:{esc(inp)}
        ") + llm_desc = translate_llm_script(step.get("llm")) + if llm_desc: + detail.write(f"
        {esc(llm_desc)}
        ") + detail.write(f"
        期望:{esc(translate_expect(step))}
        ") + cells = {"A": "", "B": ""} + cells[col] = f"" + w(f"{cells['A']}{cells['B']}") + w("
        步骤A(发起方)B(接收方)
        {detail.getvalue()}
        {step.get('step', '')}
        ") + flow = translate_expect_flow(r) + if flow: + w(f"

        流程期望:{esc(flow)}

        ") + w(f"
        来源:{esc(r['_file'])} 第 {r['_line']} 行
        ") + + # ---- notes ---- + w("

        评审说明(闭环流程)

        ") + w("
          " + "
        1. 本材料由 tools/corpus_review.py export 从语料自动生成,是语料的只读投影,请勿手工编辑。
        2. " + "
        3. 业务专家在 corpus-review.xlsx 最后两列批注:评审结论(通过 / 有疑问 / 否决)与评审意见。
        4. " + "
        5. 运行 python tools/corpus_review.py collect --marked <批注后的xlsx> 回读标记," + "生成 findings 报告与 review-status.json,并精确映射回 JSON 源文件与行号。
        6. " + "
        7. 有疑问 / 否决的用例驱动语料修改,随后重新 export 进入下一轮评审。
        ") + w("
        A2A-T negotiation test corpus review projection · tools/corpus_review.py
        ") + return out.getvalue() + + +# --- xlsx rendering ------------------------------------------------------------------------------------------ + +def case_row(rec: dict) -> list[str]: + tags = ", ".join(rec.get("tags", [])) + if rec["_family"] == SCENARIO_FAMILY: + apis = sorted({s.get("api", "") for s in rec.get("steps", [])}) + api_label = " → ".join(api_action(a) for a in apis) + input_label = f"端到端流程({len(rec.get('steps', []))} 步):" + clip(rec.get("summary", ""), 60) + expect_label = translate_expect_flow(rec) or "见语料 expectFlow" + else: + api_label = api_action(rec.get("api", "")) + input_label = translate_input(rec) + expect_label = translate_expect(rec) + return [ + rec["id"], business_domain(rec), stage_of(rec), api_label, input_label, expect_label, + rec.get("priority", ""), languages_label(rec), tags, "", "", + ] + + +def write_xlsx(rows: list[list[str]]) -> None: + try: + from openpyxl import Workbook + from openpyxl.styles import Alignment, Font, PatternFill + from openpyxl.utils import get_column_letter + from openpyxl.worksheet.datavalidation import DataValidation + except ImportError: + print("[warn] openpyxl 未安装(pip install openpyxl),跳过 xlsx 输出,仅生成 HTML 与 CSV", file=sys.stderr) + return + wb = Workbook() + ws = wb.active + ws.title = "corpus-review" + header_fill = PatternFill("solid", fgColor="1F3A5F") + for col, value in enumerate(XLSX_HEADERS, start=1): + cell = ws.cell(row=1, column=col, value=value) + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = header_fill + cell.alignment = Alignment(horizontal="center", vertical="center") + for row_index, row in enumerate(rows, start=2): + for col, value in enumerate(row, start=1): + ws.cell(row=row_index, column=col, value=value).alignment = Alignment(vertical="top", wrap_text=True) + widths = [14, 12, 11, 26, 46, 46, 8, 14, 30, 12, 30] + for col, width in enumerate(widths, start=1): + ws.column_dimensions[get_column_letter(col)].width = width + validation = DataValidation(type="list", formula1='"通过,有疑问,否决"', allow_blank=True) + ws.add_data_validation(validation) + validation.add(f"J2:J{len(rows) + 1}") + ws.freeze_panes = "A2" + ws.auto_filter.ref = f"A1:{get_column_letter(len(XLSX_HEADERS))}{len(rows) + 1}" + REVIEW_DIR.mkdir(parents=True, exist_ok=True) + wb.save(XLSX_PATH) + print(f"[ok] wrote {XLSX_PATH.relative_to(REPO_ROOT)} ({len(rows)} rows)") + + +def write_csv(rows: list[list[str]]) -> None: + REVIEW_DIR.mkdir(parents=True, exist_ok=True) + with CSV_PATH.open("w", encoding="utf-8-sig", newline="") as fh: + csv.writer(fh).writerows([XLSX_HEADERS, *rows]) + print(f"[ok] wrote {CSV_PATH.relative_to(REPO_ROOT)} ({len(rows)} rows, degraded/backup format)") + + +# --- export / collect commands ------------------------------------------------------------------------------- + +def cmd_export() -> int: + records = load_corpus() + statuses = review_status_map() + rows = [case_row(r) for r in records] + REVIEW_DIR.mkdir(parents=True, exist_ok=True) + HTML_PATH.write_text(render_html(records, statuses), encoding="utf-8") + print(f"[ok] wrote {HTML_PATH.relative_to(REPO_ROOT)} " + f"({len(records)} records, {sum(len(r.get('languages', [])) for r in records)} expanded)") + write_xlsx(rows) + write_csv(rows) + return 0 + + +def read_marked(path: Path) -> list[dict]: + """Read annotated rows from xlsx (openpyxl) or csv; each row keeps id / 评审结论 / 评审意见.""" + rows: list[dict] = [] + if path.suffix.lower() == ".csv": + with path.open(encoding="utf-8-sig", newline="") as fh: + for raw in csv.DictReader(fh): + rows.append({ + "id": (raw.get("id") or "").strip(), + "conclusion": (raw.get("评审结论") or "").strip(), + "comment": (raw.get("评审意见") or "").strip(), + }) + return rows + try: + from openpyxl import load_workbook + except ImportError: + raise SystemExit("[error] 回读 xlsx 需要 openpyxl(pip install openpyxl);或将批注结果另存为 CSV 后重试") + wb = load_workbook(path, read_only=True, data_only=True) + ws = wb.active + it = ws.iter_rows(values_only=True) + header = [str(c).strip() if c is not None else "" for c in next(it, [])] + try: + id_col = header.index("id") + concl_col = header.index("评审结论") + comment_col = header.index("评审意见") + except ValueError as exc: + raise SystemExit(f"[error] 批注文件表头缺少必需列: {exc}") + for values in it: + if values is None or id_col >= len(values): + continue + case_id = values[id_col] + if case_id is None or str(case_id).strip() == "": + continue + def cell(index: int) -> str: + return str(values[index]).strip() if index < len(values) and values[index] is not None else "" + rows.append({"id": str(case_id).strip(), "conclusion": cell(concl_col), "comment": cell(comment_col)}) + wb.close() + return rows + + +def normalize_conclusion(value: str) -> str | None: + lowered = value.strip().lower() + mapping = {"通过": "通过", "ok": "通过", "pass": "通过", "yes": "通过", "y": "通过", + "有疑问": "有疑问", "疑问": "有疑问", "question": "有疑问", "q": "有疑问", + "否决": "否决", "reject": "否决", "no": "否决", "n": "否决"} + return mapping.get(lowered) + + +def cmd_collect(marked: Path) -> int: + if not marked.is_file(): + raise SystemExit(f"[error] marked file not found: {marked}") + records = {r["id"]: r for r in load_corpus()} + marked_rows = read_marked(marked) + statuses: dict[str, dict] = {} + problems: list[str] = [] + for row in marked_rows: + conclusion = normalize_conclusion(row["conclusion"]) + if conclusion is None: + if row["conclusion"]: + problems.append(f"无法识别的评审结论 '{row['conclusion']}'(用例 {row['id']}),应为 通过/有疑问/否决") + continue + if row["id"] not in records: + problems.append(f"批注中的用例 id {row['id']} 不存在于语料") + continue + rec = records[row["id"]] + statuses[row["id"]] = { + "status": conclusion, + "comment": row["comment"], + "source": {"file": rec["_file"], "line": rec["_line"]}, + } + + counts = {s: sum(1 for v in statuses.values() if v["status"] == s) for s in REVIEW_STATUSES} + now = datetime.now().isoformat(timespec="seconds") + status_doc = { + "generatedAt": now, + "markedFrom": str(marked).replace("\\", "/"), + "summary": { + "total": len(records), + "reviewed": len(statuses), + "pending": len(records) - len(statuses), + **counts, + }, + "cases": statuses, + } + REVIEW_DIR.mkdir(parents=True, exist_ok=True) + STATUS_PATH.write_text(json.dumps(status_doc, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"[ok] wrote {STATUS_PATH.relative_to(REPO_ROOT)} " + f"(reviewed={len(statuses)}/{len(records)}, 通过={counts['通过']}, 有疑问={counts['有疑问']}, 否决={counts['否决']})") + + lines = [ + "# 协商测试语料评审 findings", + "", + f"- 回读时间:{now}", + f"- 批注文件:`{str(marked).replace(chr(92), '/')}`", + f"- 评审进度:{len(statuses)} / {len(records)}(通过 {counts['通过']}、有疑问 {counts['有疑问']}、否决 {counts['否决']}、未评审 {len(records) - len(statuses)})", + "", + ] + if problems: + lines += ["## 回读告警", ""] + lines += [f"- {p}" for p in problems] + lines.append("") + for status, heading in (("否决", "否决(必须修改语料或期望)"), ("有疑问", "有疑问(需业务澄清)")): + subset = [rec for cid, rec in records.items() if statuses.get(cid, {}).get("status") == status] + lines += [f"## {heading}({len(subset)})", ""] + if not subset: + lines.append("(无)") + for rec in subset: + mark = statuses[rec["id"]] + lines.append(f"### {rec['id']} — {rec.get('summary', '')}") + lines.append(f"- 位置:`{mark['source']['file']}` 第 {mark['source']['line']} 行") + lines.append(f"- 意见:{mark['comment'] or '(未填写)'}") + lines.append(f"- 业务域:{business_domain(rec)} · 阶段:{stage_of(rec)} · 优先级:{rec.get('priority', '—')}") + lines.append(f"- 输入:{translate_input(rec)}") + lines.append(f"- 期望:{translate_expect(rec) if rec['_family'] != SCENARIO_FAMILY else translate_expect_flow(rec)}") + lines.append("") + passed = [cid for cid, v in statuses.items() if v["status"] == "通过"] + lines += [f"## 通过({len(passed)})", "", "、".join(passed) if passed else "(无)", ""] + FINDINGS_PATH.write_text("\n".join(lines), encoding="utf-8") + print(f"[ok] wrote {FINDINGS_PATH.relative_to(REPO_ROOT)}") + for problem in problems: + print(f"[warn] {problem}", file=sys.stderr) + return 0 + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("export", help="生成 docs-local/review/corpus-review.html 与 .xlsx(评审材料投影)") + collect = sub.add_parser("collect", help="回读批注后的 xlsx/csv,生成 findings 报告与 review-status.json") + collect.add_argument("--marked", required=True, type=Path, help="批注后的 corpus-review xlsx 或 csv 文件") + args = parser.parse_args(argv) + if args.command == "export": + return cmd_export() + return cmd_collect(args.marked) + + +if __name__ == "__main__": + raise SystemExit(main()) From 2972c321782484ef97b22050b38a6500771a50dc Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 24 Aug 2026 14:48:16 +0800 Subject: [PATCH 163/295] test(negotiation): realign corpus to private-line complaint diagnosis business scenario Per user rulings Q20-Q23 on docs-local/fromtext-validate-test-design.md: - Business data swap (Q20/Q23): all shared LLM payloads, schemas (new biz.complaint.params), golden inputs/fixtures (zh-CN now real Chinese business data, en-US real English form), property-layer arbitraries and corpus case data re-blooded to the dedicated-line complaint diagnosis domain (access port name, complaint category, OSS event serial no, latency-degradation symptom) - Task-API closed loop (Q21): harness gains a TASK API family (generateTaskPromptFromText / generateTaskPromptFromDataWithSchema / validateTaskPromptAndDataFilling) assembled by mirroring production wiring from the negotiation test scope; scenario engine adds missingParams / paramsFromStep / expectFlow.missingParamsFilled causal assertions (extracted supplements == discovered missing params) and A=workbench / B=OMC role semantics; scenarios upgraded to the five-step closed loop from the sample report - Peer-text validate mainline (Q22): validate success cases flipped from golden-derived to peer-message text input; new drift-probes file (VAL-DRIFT) for compliant-but-wording-drifted peer messages; golden-derived inputs demoted to regression-equality probes - CorpusContractTest: +3 contracts (task APIs exercised only via scenario steps, closed-loop role binding, drift-probe shape) Signed-off-by: codetalks-hw --- .../generation/FromTextLlmPipelineTest.java | 78 +- .../TemplateUriEntryMatrixTest.java | 6 +- .../ValidateAndFillingDataPipelineTest.java | 109 +-- .../golden/GoldenFixtureComparisonTest.java | 8 +- .../golden/GoldenFixtureGeneratorTest.java | 2 +- .../sdk/negotiation/golden/GoldenInputs.java | 230 ++++-- .../CustomRootTemplateOverrideTest.java | 11 +- .../CustomRootVocabularyOverrideTest.java | 12 +- .../NegotiationLanguageSwitchTest.java | 4 +- .../NegotiationErrorPathPropertyTest.java | 12 +- .../property/PropertyArbitraries.java | 52 +- .../sdk/negotiation/testdata/CaseEngine.java | 158 +++- .../negotiation/testdata/CaseEngineTest.java | 350 ++++++++- .../testdata/CorpusContractTest.java | 100 ++- .../testdata/CorpusSensitivitySelfTest.java | 178 ++++- .../sdk/negotiation/testdata/Expectation.java | 71 +- .../negotiation/testdata/NegotiationApi.java | 36 +- .../testdata/NegotiationCaseLoader.java | 103 ++- .../testdata/NegotiationCaseLoaderTest.java | 211 ++++++ .../negotiation/testdata/ScenarioCase.java | 103 ++- .../negotiation/testdata/ScenarioEngine.java | 188 ++++- .../testdata/ScenarioEngineTest.java | 245 ++++++- .../testdata/TaskApiAssembler.java | 218 ++++++ .../golden/en-US/feasibility_accept.md | 2 +- .../golden/en-US/feasibility_propose.md | 6 +- .../golden/en-US/feasibility_reject.md | 2 +- .../golden/en-US/information_accept.md | 4 +- .../golden/en-US/information_propose.md | 6 +- .../golden/en-US/information_reject.md | 2 +- .../resources/golden/en-US/target_accept.md | 2 +- .../resources/golden/en-US/target_propose.md | 10 +- .../resources/golden/en-US/target_reject.md | 2 +- .../src/test/resources/golden/zh-CN/abort.md | 2 +- .../golden/zh-CN/feasibility_accept.md | 2 +- .../golden/zh-CN/feasibility_propose.md | 6 +- .../golden/zh-CN/feasibility_reject.md | 2 +- .../golden/zh-CN/information_accept.md | 4 +- .../golden/zh-CN/information_propose.md | 6 +- .../golden/zh-CN/information_reject.md | 2 +- .../resources/golden/zh-CN/target_accept.md | 2 +- .../resources/golden/zh-CN/target_propose.md | 10 +- .../resources/golden/zh-CN/target_reject.md | 2 +- .../test/resources/negotiation-cases/INDEX.md | 315 ++++---- .../negotiation-cases/corpus-schema.json | 53 +- .../negotiation-cases/from-data/happy.json | 119 ++- .../from-data/programming-errors.json | 22 +- .../from-text/extraction-failures.json | 56 +- .../negotiation-cases/from-text/happy.json | 166 ++--- .../from-text/programming-errors.json | 26 +- .../negotiation-cases/from-text/retry.json | 72 +- .../from-text/template-resolution.json | 14 +- .../scenarios/boundary-flows.json | 677 +++++++++++++----- .../scenarios/feasibility-flows.json | 95 ++- .../scenarios/information-flows.json | 351 +++++++-- .../scenarios/target-flows.json | 118 ++- .../shared/llm-responses.json | 46 +- .../negotiation-cases/shared/schemas.json | 33 + .../validate/drift-probes.json | 102 +++ .../validate/error-code-mapping.json | 14 +- .../negotiation-cases/validate/happy.json | 89 +-- .../validate/llm-shape-and-retry.json | 60 +- .../validate/merge-and-schema.json | 31 +- .../validate/programming-errors.json | 44 +- .../negotiation-cases/validate/rule-gate.json | 43 +- .../validate/semantic-verdicts.json | 108 +-- 65 files changed, 4013 insertions(+), 1200 deletions(-) create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/TaskApiAssembler.java create mode 100644 a2a-t-negotiation/src/test/resources/negotiation-cases/validate/drift-probes.json diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 1caba5a5..fd5700f6 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -106,16 +106,18 @@ void detachLogAppenders() { void fromTextMatchesFromDataAndTheGoldenFixtureAfterOneExtractionCall() { GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; ScriptedExtractionClient llm = - new ScriptedExtractionClient(extractionJson(goldenCase.content(), goldenCase.phase())); + new ScriptedExtractionClient(extractionJson(goldenCase.content(ZH_CN), goldenCase.phase())); NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); MetadataContent fromText = orchestrator.generateProposeFromText( - "请补充节能区域、节能速率保障目标与VLANId信息。", goldenCase.context(), goldenCase.template()); + "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", + goldenCase.context(), + goldenCase.template()); assertEquals(1, llm.calls, "from-text generation must run exactly one content extraction call"); assertEquals(goldenCase.templateUri(), fromText.templateUri()); - MetadataContent fromData = goldenCase.generate(orchestrator); + MetadataContent fromData = goldenCase.generate(orchestrator, ZH_CN); assertEquals(1, llm.calls, "the from-data variant must not add any LLM call"); assertEquals(fromData.promptText(), fromText.promptText()); assertEquals(readGoldenFixture(goldenCase, ZH_CN), fromText.promptText()); @@ -156,10 +158,10 @@ && logMessages(extractorAppender, Level.INFO).stream() @EnumSource(GoldenCase.class) void fromTextSucceedsForEveryMethodAndTypeCombination(GoldenCase goldenCase) { ScriptedExtractionClient llm = - new ScriptedExtractionClient(extractionJson(goldenCase.content(), goldenCase.phase())); + new ScriptedExtractionClient(extractionJson(goldenCase.content(ZH_CN), goldenCase.phase())); NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - MetadataContent result = generateFromText(orchestrator, goldenCase, "请根据以下文本生成协商报文。"); + MetadataContent result = generateFromText(orchestrator, goldenCase, "请根据以下专线投诉诊断协商文本生成协商报文。"); assertEquals(1, llm.calls); assertEquals(goldenCase.templateUri(), result.templateUri()); @@ -188,13 +190,14 @@ void acceptAndRejectShareTheSameAcceptRejectTemplate() { "accept and reject must address the same accept-reject template"); ScriptedExtractionClient acceptLlm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_ACCEPT.content(), NegotiationPhase.ACCEPT)); + extractionJson(GoldenCase.INFORMATION_ACCEPT.content(ZH_CN), NegotiationPhase.ACCEPT)); NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, acceptLlm, 3); - MetadataContent acceptResult = generateFromText(orchestrator, GoldenCase.INFORMATION_ACCEPT, "请接受。"); + MetadataContent acceptResult = + generateFromText(orchestrator, GoldenCase.INFORMATION_ACCEPT, "请确认补充接入端口名称与投诉分类。"); assertEquals(1, acceptLlm.calls); ScriptedExtractionClient rejectLlm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_REJECT.content(), NegotiationPhase.REJECT)); + extractionJson(GoldenCase.INFORMATION_REJECT.content(ZH_CN), NegotiationPhase.REJECT)); MetadataContent rejectResult = generateFromText(orchestrator(ZH_CN, rejectLlm, 3), GoldenCase.INFORMATION_REJECT, "请拒绝。"); assertEquals(1, rejectLlm.calls); @@ -214,11 +217,11 @@ void acceptAndRejectShareTheSameAcceptRejectTemplate() { void retriesContentExtractionUntilItSucceeds() { GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; ScriptedExtractionClient llm = - new ScriptedExtractionClient("", "", extractionJson(goldenCase.content(), goldenCase.phase())); + new ScriptedExtractionClient("", "", extractionJson(goldenCase.content(ZH_CN), goldenCase.phase())); NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); MetadataContent result = - orchestrator.generateProposeFromText("请提供区域。", goldenCase.context(), goldenCase.template()); + orchestrator.generateProposeFromText("请提供接入端口名称。", goldenCase.context(), goldenCase.template()); assertEquals(3, llm.calls, "two failures followed by one success must result in three calls"); assertEquals(readGoldenFixture(goldenCase, ZH_CN), result.promptText()); @@ -249,7 +252,7 @@ void rethrowsTheOriginalCodeWhenRetriesAreExhausted() { NegotiationGenerationException failure = assertThrows( NegotiationGenerationException.class, () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); assertEquals(3, llm.calls); @@ -281,7 +284,7 @@ void retriesInfrastructureFailuresUntilExhaustion() { NegotiationGenerationException failure = assertThrows( NegotiationGenerationException.class, () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); assertEquals(2, llm.calls); @@ -303,7 +306,7 @@ void retriesUnparseableResponsesUntilExhaustion() { NegotiationGenerationException failure = assertThrows( NegotiationGenerationException.class, () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); assertEquals(2, llm.calls); @@ -318,7 +321,7 @@ void retriesUnparseableResponsesUntilExhaustion() { @Test void phaseContentMismatchFailsFastWithInvalidInput() { ScriptedExtractionClient llm = new ScriptedExtractionClient( - "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}"); + "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}]}"); NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); NegotiationGenerationException failure = assertThrows( @@ -344,7 +347,7 @@ void missingRequiredFieldFailsFastWithSlotMissing() { NegotiationGenerationException failure = assertThrows( NegotiationGenerationException.class, () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, failure.getCode()); assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); @@ -359,7 +362,7 @@ void missingRequiredFieldFailsFastWithSlotMissing() { @Test void missingTemplateFailsBeforeAnyLlmCall() { ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_PROPOSE.content(), NegotiationPhase.PROPOSE)); + extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPhase.PROPOSE)); NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() .language(ZH_CN) .llmClient(llm) @@ -369,7 +372,7 @@ void missingTemplateFailsBeforeAnyLlmCall() { NegotiationGenerationException failure = assertThrows( NegotiationGenerationException.class, () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, failure.getCode()); assertEquals(0, llm.calls, "the template must be loaded before the LLM step is started"); @@ -382,13 +385,13 @@ void missingTemplateFailsBeforeAnyLlmCall() { @Test void phaseMismatchedTemplateUriFailsBeforeAnyLlmCall() { ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_PROPOSE.content(), NegotiationPhase.PROPOSE)); + extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPhase.PROPOSE)); NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); IllegalArgumentException failure = assertThrows( IllegalArgumentException.class, () -> orchestrator.generateProposeFromText( - "请提供区域。", + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), GoldenCase.INFORMATION_ACCEPT.template())); @@ -410,7 +413,7 @@ void singleAttemptFailsFast() { NegotiationGenerationException failure = assertThrows( NegotiationGenerationException.class, () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); assertEquals(1, llm.calls); @@ -427,7 +430,7 @@ void twoAttemptsRetryExactlyOnce() { NegotiationGenerationException failure = assertThrows( NegotiationGenerationException.class, () -> orchestrator.generateProposeFromText( - "请提供区域。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); assertEquals(2, llm.calls); @@ -452,11 +455,11 @@ void clampedConfigValuesDriveTheRetryLoop() { GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; ScriptedExtractionClient recovering = new ScriptedExtractionClient( - "", "", "", "", "", "", "", "", "", extractionJson(goldenCase.content(), goldenCase.phase())); + "", "", "", "", "", "", "", "", "", extractionJson(goldenCase.content(ZH_CN), goldenCase.phase())); NegotiationGenerationOrchestrator recoveringOrchestrator = orchestrator(ZH_CN, recovering, large.maxAttempts()); MetadataContent result = recoveringOrchestrator.generateProposeFromText( - "请提供区域。", goldenCase.context(), goldenCase.template()); + "请提供接入端口名称。", goldenCase.context(), goldenCase.template()); assertEquals(10, recovering.calls, "nine failed attempts plus one success must consume the clamped limit"); assertEquals(readGoldenFixture(goldenCase, ZH_CN), result.promptText()); @@ -474,7 +477,7 @@ void clampedConfigValuesDriveTheRetryLoop() { NegotiationGenerationException failure = assertThrows( NegotiationGenerationException.class, () -> failingOrchestrator.generateProposeFromText( - "请提供区域。", goldenCase.context(), goldenCase.template())); + "请提供接入端口名称。", goldenCase.context(), goldenCase.template())); assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); assertEquals(1, failing.calls); @@ -501,11 +504,20 @@ void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOEx assertEquals(3, config.llm().maxAttempts(), "the default attempt limit is 3"); GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; - String inputText = "Please provide the energy-saving area information and the rate guarantee target."; + String inputText = "Please provide the access port name and the complaint category of the private line."; + Map bizSchema = Map.of( + "type", "object", + "properties", + Map.of( + "accessPort", Map.of("type", "string"), + "bizScenario", Map.of("type", "string")), + "required", + List.of("accessPort", "bizScenario")); ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(goldenCase.content(), goldenCase.phase()), + extractionJson(goldenCase.content(EN_US), goldenCase.phase()), "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"Songshan Lake\"}}"); + + "\"params\":{\"accessPort\":\"P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\"," + + "\"bizScenario\":\"dedicated-line quality degradation\"}}"); NegotiationGenerationOrchestrator orchestrator = orchestrator(config.prompt().language(), llm, config.llm().maxAttempts()); @@ -522,16 +534,14 @@ void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOEx "the user prompt must carry the phase token"); FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message.promptText(), - goldenCase.context(), - Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - goldenCase.template()); + message.promptText(), goldenCase.context(), bizSchema, goldenCase.template()); assertEquals(2, llm.calls, "the validation pipeline adds exactly one semantic validation call"); assertEquals(goldenCase.context().id(), filled.data().get("id")); assertEquals(goldenCase.context().round(), filled.data().get("round")); assertEquals(goldenCase.context().maxRounds(), filled.data().get("maxRounds")); - assertEquals("Songshan Lake", filled.data().get("region")); + assertEquals("P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); + assertEquals("dedicated-line quality degradation", filled.data().get("bizScenario")); } /** @@ -542,10 +552,10 @@ void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOEx void contextIsInjectedFromTheCallerWithoutAnyLlmInvolvement() { NegotiationContext context = new NegotiationContext(GoldenInputs.SESSION_ID, 4, 7); ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_PROPOSE.content(), NegotiationPhase.PROPOSE)); + extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPhase.PROPOSE)); NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - MetadataContent result = orchestrator.generateProposeFromText("请提供区域。", context, INFORMATION_PROPOSE_URI); + MetadataContent result = orchestrator.generateProposeFromText("请提供接入端口名称。", context, INFORMATION_PROPOSE_URI); assertEquals(1, llm.calls); Map schemaProperties = schemaProperties(llm.lastSchema); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index 99dd15fa..6a087dad 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -76,7 +76,7 @@ void everyBuiltInUriRendersItsGoldenFixture() { assertEquals(7, casesByUri.size(), "the built-in template set has exactly seven URIs"); for (Map.Entry> entry : casesByUri.entrySet()) { for (GoldenCase goldenCase : entry.getValue()) { - MetadataContent result = goldenCase.generate(orchestrator); + MetadataContent result = goldenCase.generate(orchestrator, language); assertEquals(entry.getKey(), result.templateUri()); assertEquals(readClasspathText(goldenCase.goldenResourcePath(language)), result.promptText()); } @@ -173,7 +173,7 @@ void localRootTemplateOverridesTheBuiltInTemplateOfTheSameUri(@TempDir Path temp .language("zh-CN") .build(); String builtinText = - GoldenCase.INFORMATION_PROPOSE.generate(builtinOrchestrator).promptText(); + GoldenCase.INFORMATION_PROPOSE.generate(builtinOrchestrator, "zh-CN").promptText(); assertFalse(builtinText.contains("CUSTOM-OVERRIDE-MARKER")); assertEquals( readClasspathText(GoldenCase.INFORMATION_PROPOSE.goldenResourcePath("zh-CN")), @@ -184,7 +184,7 @@ void localRootTemplateOverridesTheBuiltInTemplateOfTheSameUri(@TempDir Path temp private static NegotiationProposeData informationProposeData() { return new NegotiationProposeData( new NegotiationContext(UUID, 2, 5), - new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)); + new InformationProposeContent(List.of(new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1")), null)); } private static String readClasspathText(String resourcePath) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java index 5c25ed09..9584ff60 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java @@ -54,26 +54,33 @@ class ValidateAndFillingDataPipelineTest { private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; - private static final Map REGION_SCHEMA = Map.of( - "type", "object", "properties", Map.of("region", Map.of("type", "string")), "required", List.of("region")); + private static final Map ACCESS_PORT_SCHEMA = Map.of( + "type", "object", + "properties", + Map.of( + "accessPort", Map.of("type", "string"), + "bizScenario", Map.of("type", "string")), + "required", + List.of("accessPort", "bizScenario")); @Test void legalProposeMessageRunsTheFullChainWithASingleLlmCall() { ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\",\"confirmed_rate_mbps\":2}}"); + + "\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"," + + "\"bizScenario\":\"专线质差\"}}"); NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); String message = informationProposeMessage(orchestrator); FilledParamData filled = - orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI); + orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI); assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); assertEquals(2, filled.data().get("round")); assertEquals(5, filled.data().get("maxRounds")); - assertEquals("松山湖", filled.data().get("region")); - assertEquals(2, filled.data().get("confirmed_rate_mbps")); + assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); + assertEquals("专线质差", filled.data().get("bizScenario")); assertEquals(5, filled.data().size()); assertInstanceOf(String.class, filled.data().get("id"), "context id must be a string"); assertInstanceOf(Integer.class, filled.data().get("round"), "context round must be a number"); @@ -84,18 +91,18 @@ void legalProposeMessageRunsTheFullChainWithASingleLlmCall() { void legalAcceptMessageRunsTheFullChainWithASingleLlmCall() { ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"energy_saving_area\":\"松山湖\"}}"); + + "\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"); NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); String message = informationEndingMessage(orchestrator, NegotiationConclusion.ACCEPT); FilledParamData filled = - orchestrator.validateAcceptPromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); + orchestrator.validateAcceptPromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); assertEquals(2, filled.data().get("round")); assertEquals(5, filled.data().get("maxRounds")); - assertEquals("松山湖", filled.data().get("energy_saving_area")); + assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); assertEquals(4, filled.data().size()); } @@ -103,16 +110,16 @@ void legalAcceptMessageRunsTheFullChainWithASingleLlmCall() { void legalRejectMessageRunsTheFullChainWithASingleLlmCall() { ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"unavailable_item\":\"节能区域信息\"}}"); + + "\"params\":{\"unavailable_item\":\"接入端口名称\"}}"); NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); String message = informationEndingMessage(orchestrator, NegotiationConclusion.REJECT); FilledParamData filled = - orchestrator.validateRejectPromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); + orchestrator.validateRejectPromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); - assertEquals("节能区域信息", filled.data().get("unavailable_item")); + assertEquals("接入端口名称", filled.data().get("unavailable_item")); assertEquals(4, filled.data().size()); } @@ -140,7 +147,7 @@ void semanticRejectionIsNotRetriedAndPassesTheErrorsThrough() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(semanticErrors, exception.getErrors()); @@ -157,7 +164,7 @@ void taskTMessagesAreRejectedAsNonNegotiationInputWithALanguageNeutralMessage( NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(taskTMessage, null, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(taskTMessage, null, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, exception.getCode(), caseName); assertTrue(exception.getMessage().contains("checkTaskPrompt"), caseName); @@ -171,35 +178,40 @@ static List taskTMessages() { return List.of( new Object[] { "chinese task prompt", - "## 任务类型(Task Type)\n无线能效优化\n\n## 任务对象(Task Object)\n松山湖管委会\n\n## 任务目标(Task" - + " Target)\n总功耗降低30%\n" + "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n## 任务对象(Task Object)\n接入端口名称:P533-珠江旧城" + + "-PTN3900-23-TPA1EG24-1\n\n## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和" + + "修复建议等诊断结果信息。\n" }, new Object[] { "english task prompt", - "## Task Type\nWireless energy saving\n\n## Task Object\nSongshan Lake\n\n## Task" - + " Target\nReduce total power consumption by 30 percent\n" + "## Task Type\nTransport private line service complaint diagnosis\n\n## Task Object\nAccess" + + " Port Name: P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\n\n## Task Target\nDiagnose" + + " network-side faults and return diagnostic result information.\n" }); } @Test void nestedArrayParamsAreExtractedThroughTheMergedSchema() { - Map timeRateItemSchema = Map.of( + Map repairTargetItemSchema = Map.of( "type", "object", "properties", - Map.of("time", Map.of("type", "string"), "rate", Map.of("type", "string"))); + Map.of( + "latencyTarget", Map.of("type", "string"), + "completionDeadline", Map.of("type", "string"))); Map callerSchema = Map.of( "type", "object", "properties", - Map.of("timeRateTargets", Map.of("type", "array", "items", timeRateItemSchema)), + Map.of("repairTargets", Map.of("type", "array", "items", repairTargetItemSchema)), "required", - List.of("timeRateTargets"), + List.of("repairTargets"), "additionalProperties", false); ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[]," - + "\"params\":{\"timeRateTargets\":[{\"time\":\"08:00-18:00\",\"rate\":\"2Mbps\"}]}}"); + + "\"params\":{\"repairTargets\":[{\"latencyTarget\":\"within 20ms\"," + + "\"completionDeadline\":\"48 hours\"}]}}"); NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); String message = targetProposeMessage(orchestrator); @@ -208,8 +220,8 @@ void nestedArrayParamsAreExtractedThroughTheMergedSchema() { assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); assertEquals( - List.of(Map.of("time", "08:00-18:00", "rate", "2Mbps")), - filled.data().get("timeRateTargets")); + List.of(Map.of("latencyTarget", "within 20ms", "completionDeadline", "48 hours")), + filled.data().get("repairTargets")); Map mergedSchema = llm.lastSchema; assertEquals(List.of("semantic_verdict", "negotiation_type", "errors", "params"), mergedSchema.get("required")); @@ -217,17 +229,20 @@ void nestedArrayParamsAreExtractedThroughTheMergedSchema() { Map properties = (Map) mergedSchema.get("properties"); Map paramsSchema = (Map) properties.get("params"); assertEquals("object", paramsSchema.get("type")); - Map timeRateTargets = (Map) ((Map) paramsSchema.get("properties")).get("timeRateTargets"); - assertEquals("array", timeRateTargets.get("type")); + Map repairTargets = (Map) ((Map) paramsSchema.get("properties")).get("repairTargets"); + assertEquals("array", repairTargets.get("type")); } @Test void callerSchemaWithoutATypeKeywordIsWrappedAndTheChainSucceeds() { - Map schemaWithoutType = - Map.of("properties", Map.of("region", Map.of("type", "string")), "required", List.of("region")); + Map schemaWithoutType = Map.of( + "properties", + Map.of("accessPort", Map.of("type", "string")), + "required", + List.of("accessPort")); ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}"); + + "\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"); NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); String message = informationProposeMessage(orchestrator); @@ -235,11 +250,11 @@ void callerSchemaWithoutATypeKeywordIsWrappedAndTheChainSucceeds() { orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, schemaWithoutType, INFORMATION_PROPOSE_URI); assertEquals(1, llm.calls); - assertEquals("松山湖", filled.data().get("region")); + assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); Map paramsSchema = (Map) ((Map) llm.lastSchema.get("properties")).get("params"); assertEquals("object", paramsSchema.get("type")); - assertEquals(Map.of("region", Map.of("type", "string")), paramsSchema.get("properties")); - assertEquals(List.of("region"), paramsSchema.get("required")); + assertEquals(Map.of("accessPort", Map.of("type", "string")), paramsSchema.get("properties")); + assertEquals(List.of("accessPort"), paramsSchema.get("required")); } @Test @@ -254,7 +269,7 @@ void missingNegotiationTypeKeyIsRetriedThenFailsAsAnInfrastructureError() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); assertEquals(3, llm.calls, "a shape-invalid response is a retryable infrastructure failure"); @@ -272,7 +287,7 @@ void declaredTypeMismatchIsASemanticRejection() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(1, llm.calls, "a type mismatch is a semantic decision and must not be retried"); @@ -291,7 +306,7 @@ void structuralSemanticChecksSurfaceThroughTheSemanticErrors( NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode(), caseName); assertEquals(List.of(expectedError), exception.getErrors(), caseName); @@ -343,7 +358,7 @@ void falseVerdictWithNullTypeIsAShapeLegalOutcome() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(1, exception.getErrors().size()); @@ -360,7 +375,7 @@ void trueVerdictWithNullTypeIsASemanticRejection() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(1, llm.calls, "a null type with a true verdict is a semantic rejection, not a retryable failure"); @@ -382,7 +397,7 @@ void semanticLlmFailureIsRetriedAndExhaustsWithTheLlmPseudoSlot() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); assertEquals(2, llm.calls); @@ -407,9 +422,9 @@ void missingPromptResourcesCloseAsTemplateNotFoundWithoutBubblingTheResourceExce NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, () -> orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", + "## 所需信息项\n1. 接入端口名称\n", new NegotiationContext(SESSION_ID, 1, 5), - REGION_SCHEMA, + ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, exception.getCode()); @@ -438,7 +453,7 @@ void proposeUriValidatingAResultMessageIsASemanticRejection() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, () -> orchestrator.validateProposePromptAndDataFilling( - rejectMessage, CONTEXT, REGION_SCHEMA, INFORMATION_PROPOSE_URI)); + rejectMessage, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); assertEquals(List.of(phaseError), exception.getErrors()); @@ -456,7 +471,8 @@ private static String informationProposeMessage(NegotiationGenerationOrchestrato MetadataContent content = orchestrator.generateProposeFromData( new NegotiationProposeData( new NegotiationContext(SESSION_ID, 2, 5), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + new InformationProposeContent( + List.of(new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1")), null)), INFORMATION_PROPOSE_URI); return content.promptText(); } @@ -465,7 +481,8 @@ private static String informationEndingMessage( NegotiationGenerationOrchestrator orchestrator, NegotiationConclusion conclusion) { NegotiationEndingData data = new NegotiationEndingData( new NegotiationContext(SESSION_ID, 2, 5), - new InformationEndingContent(conclusion, List.of(new NegotiationItem("节能区域", "松山湖")))); + new InformationEndingContent( + conclusion, List.of(new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1")))); MetadataContent content = conclusion == NegotiationConclusion.ACCEPT ? orchestrator.generateAcceptFromData(data, INFORMATION_ACCEPT_REJECT_URI) : orchestrator.generateRejectFromData(data, INFORMATION_ACCEPT_REJECT_URI); @@ -477,8 +494,8 @@ private static String targetProposeMessage(NegotiationGenerationOrchestrator orc new NegotiationProposeData( new NegotiationContext(SESSION_ID, 1, 5), new TargetProposeContent( - "确认站点级节能任务的速率保障目标调整方案", - List.of(new NegotiationItem("任务意图", "在08:00-18:00对目标站点实施节能")), + "确认专线质差投诉的时延修复目标调整方案", + List.of(new NegotiationItem("修复意图", "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内")), null, null)), TARGET_PROPOSE_URI); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java index 5f2dad1f..d1b3d67f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java @@ -55,7 +55,7 @@ static Stream goldenCases() { /** Renders one fixture and returns its metadata content, asserting the URI and extension contract up front. */ private static MetadataContent render(GoldenCase goldenCase, String language) { NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - MetadataContent result = goldenCase.generate(orchestrator); + MetadataContent result = goldenCase.generate(orchestrator, language); assertEquals(goldenCase.templateUri(), result.templateUri()); assertEquals(EXTENSION_URI, result.extensionUri()); return result; @@ -113,8 +113,8 @@ void buildsTheMetadataMapWithExactlyThreeEntries(GoldenCase goldenCase, String l void rendersTheSameInputDeterministically(GoldenCase goldenCase, String language) { NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - MetadataContent first = goldenCase.generate(orchestrator); - MetadataContent second = goldenCase.generate(orchestrator); + MetadataContent first = goldenCase.generate(orchestrator, language); + MetadataContent second = goldenCase.generate(orchestrator, language); assertEquals(first.promptText(), second.promptText()); assertEquals(first, second); @@ -158,7 +158,7 @@ void fromDataGenerationNeverCallsTheLlm(String language) { .build(); for (GoldenCase goldenCase : GoldenCase.values()) { - MetadataContent result = goldenCase.generate(orchestrator); + MetadataContent result = goldenCase.generate(orchestrator, language); assertFalse(result.promptText().isBlank()); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java index 6a7b7652..3a18fda0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java @@ -38,7 +38,7 @@ void writeAllGoldenFixtures() throws IOException { .language(language) .build(); for (GoldenInputs.GoldenCase goldenCase : GoldenInputs.GoldenCase.values()) { - MetadataContent result = goldenCase.generate(orchestrator); + MetadataContent result = goldenCase.generate(orchestrator, language); Path target = GOLDEN_ROOT.resolve(language).resolve(goldenCase.fileName()); Files.createDirectories(target.getParent()); Files.writeString(target, result.promptText(), StandardCharsets.UTF_8); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java index bb8abb2f..4083f56a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java @@ -28,9 +28,12 @@ * Fixed inputs of the golden fixture set of the negotiation content layer. * *

        Every golden fixture is rendered from exactly one {@link GoldenCase} carrying a fixed negotiation context, fixed - * typed content and the built-in template URI of its negotiation type and phase. The same content data is used for - * every language: only the vocabulary rendering (section titles, labels, list punctuation) differs between zh-CN and - * en-US. + * typed content and the built-in template URI of its negotiation type and phase. The fixture data lives in the + * private-line complaint diagnosis business domain (see {@code docs-local/business-facts-dictionary.md}): the + * information fixtures carry the access port name and complaint category of the OMC workbench complaint loop, the + * target fixtures negotiate the latency repair target, and the feasibility fixtures assess the port expansion. + * The typed content is language-dependent — zh-CN fixtures carry Chinese business data, en-US fixtures the English + * business form — so each language renders its own golden fixture with real business data in that language. * *

        The fixture data deliberately exercises the known rendering pitfalls: a non-null relationship with an appended * line and a null-value item (information propose), the round-driven conditional sections with a non-empty @@ -76,7 +79,10 @@ public static NegotiationContext firstRoundContext() { /** One golden fixture case: one negotiation type, phase, fixed context, fixed content and its template URI. */ public enum GoldenCase { - /** Information propose fixture with three items, one null-value item and a non-null relationship. */ + /** + * Information propose fixture of the complaint diagnosis loop: the OMC asks the workbench for the missing + * access port name and complaint category, plus an optional null-value private line service identifier. + */ INFORMATION_PROPOSE(NegotiationPhase.PROPOSE, "information-negotiation", "information_propose") { @Override public NegotiationContext context() { @@ -84,17 +90,31 @@ public NegotiationContext context() { } @Override - public NegotiationContent content() { + public NegotiationContent content(String language) { + if (GoldenInputs.EN_US.equals(language)) { + return new InformationProposeContent( + List.of( + new NegotiationItem( + "Access Port Name", + "e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"), + new NegotiationItem( + "Complaint Category", "e.g. dedicated-line quality degradation"), + new NegotiationItem("Private Line Service Identifier", null)), + "OR"); + } return new InformationProposeContent( List.of( - new NegotiationItem("energy-saving area information", "e.g. Songshan Lake"), - new NegotiationItem("energy-saving rate guarantee target", "e.g. 20Mbps"), - new NegotiationItem("VLANId", null)), + new NegotiationItem("接入端口名称", "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1"), + new NegotiationItem("投诉分类", "举例:专线质差"), + new NegotiationItem("专线业务标识", null)), "OR"); } }, - /** Target propose fixture of round 1 with intent understanding and a non-empty clarification request. */ + /** + * Target propose fixture of round 1: the workbench and the OMC clarify the latency repair target and the + * completion deadline of the quality degradation complaint. + */ TARGET_PROPOSE(NegotiationPhase.PROPOSE, "target-negotiation", "target_propose") { @Override public NegotiationContext context() { @@ -102,27 +122,46 @@ public NegotiationContext context() { } @Override - public NegotiationContent content() { + public NegotiationContent content(String language) { + if (GoldenInputs.EN_US.equals(language)) { + return new TargetProposeContent( + "The intent understanding of the dedicated-line quality degradation repair target is" + + " listed in ; open questions remain about the" + + " latency target and the completion deadline, see ; please" + + " clarify and confirm.", + List.of( + new NegotiationItem( + "repair intent", + "restore the average latency of the Shenzhen-to-Guangzhou dedicated" + + " line to within 20ms before 2026-05-15"), + new NegotiationItem( + "repair target", "peak-hour packet loss rate no higher than 1%")), + null, + List.of( + new NegotiationItem( + "latency target", + "is the average latency target within 20ms or within 50ms"), + new NegotiationItem( + "completion deadline", + "is the repair deadline 48 hours or 72 hours"))); + } return new TargetProposeContent( - "The intent understanding of the wireless energy-saving optimization task is listed in" - + " ; open questions remain about the area and the" - + " time range, see ; please clarify and confirm.", + "专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。", List.of( new NegotiationItem( - "task intent", - "apply wireless energy-saving optimization to the target site during" - + " 08:00-18:00"), - new NegotiationItem( - "rate guarantee target", "at least 20Mbps during the energy-saving period")), + "修复意图", "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内"), + new NegotiationItem("修复目标", "高峰时段丢包率不高于1%")), null, List.of( - new NegotiationItem("area", "which site is covered: Songshan Lake or another site"), - new NegotiationItem( - "time range", "is the energy-saving period 08:00-18:00 or 00:00-06:00"))); + new NegotiationItem("时延目标", "平均时延目标是恢复至20ms以内还是50ms以内"), + new NegotiationItem("完成时限", "修复完成时限是48小时内还是72小时内"))); } }, - /** Feasibility propose fixture requesting a feasibility evaluation of an adjusted rate target. */ + /** + * Feasibility propose fixture requesting a feasibility evaluation of the access port bandwidth expansion + * against the cutover window constraint. + */ FEASIBILITY_PROPOSE(NegotiationPhase.PROPOSE, "feasibility-negotiation", "feasibility_propose") { @Override public NegotiationContext context() { @@ -130,98 +169,150 @@ public NegotiationContext context() { } @Override - public NegotiationContent content() { + public NegotiationContent content(String language) { + if (GoldenInputs.EN_US.equals(language)) { + return new FeasibilityProposeContent( + "Please assess whether the dedicated-line access port expansion can be completed within" + + " the cutover window, see ; please assess.", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of( + new NegotiationItem( + "expansion plan", + "expand the access port bandwidth from 100Mbps to 1000Mbps"), + new NegotiationItem( + "existing constraint", + "the cutover window is limited to 02:00-06:00 on 2026-05-30 with" + + " service interruption no longer than 30 minutes")), + null); + } return new FeasibilityProposeContent( - "Please assess whether the adjusted rate guarantee target of the site-level energy-saving" - + " task can be achieved, see ; please assess.", + "请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, List.of( + new NegotiationItem("扩容方案", "接入端口带宽由100Mbps扩容至1000Mbps"), new NegotiationItem( - "adjusted target", - "rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00"), - new NegotiationItem( - "existing constraint", - "at least 10 hours of power supply duration in outage scenarios")), + "既有约束", "割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟")), null); } }, - /** Information accept fixture delivering the requested information items. */ + /** Information accept fixture delivering the access port name and the complaint category. */ INFORMATION_ACCEPT(NegotiationPhase.ACCEPT, "information-negotiation", "information_accept") { @Override - public NegotiationContent content() { + public NegotiationContent content(String language) { + if (GoldenInputs.EN_US.equals(language)) { + return new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of( + new NegotiationItem( + "Access Port Name", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"), + new NegotiationItem( + "Complaint Category", "dedicated-line quality degradation"))); + } return new InformationEndingContent( NegotiationConclusion.ACCEPT, List.of( - new NegotiationItem("energy-saving area information", "Songshan Lake"), - new NegotiationItem("energy-saving rate guarantee target", "20Mbps"))); + new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1"), + new NegotiationItem("投诉分类", "专线质差"))); } }, - /** Target accept fixture carrying the finally confirmed intent. */ + /** Target accept fixture carrying the finally confirmed latency repair target. */ TARGET_ACCEPT(NegotiationPhase.ACCEPT, "target-negotiation", "target_accept") { @Override - public NegotiationContent content() { + public NegotiationContent content(String language) { + if (GoldenInputs.EN_US.equals(language)) { + return new TargetEndingContent( + NegotiationConclusion.ACCEPT, + "The finally confirmed intent: restore the average latency of the" + + " Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15, with a" + + " peak-hour packet loss rate no higher than 1%.", + null); + } return new TargetEndingContent( NegotiationConclusion.ACCEPT, - "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan" - + " Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps.", + "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。", null); } }, - /** Feasibility accept fixture confirming a positive evaluation result in the exception slot. */ + /** Feasibility accept fixture confirming a positive port expansion assessment in the exception slot. */ FEASIBILITY_ACCEPT(NegotiationPhase.ACCEPT, "feasibility-negotiation", "feasibility_accept") { @Override - public NegotiationContent content() { + public NegotiationContent content(String language) { + if (GoldenInputs.EN_US.equals(language)) { + return new FeasibilityEndingContent( + NegotiationConclusion.ACCEPT, + "The port expansion can be completed within the cutover window and satisfies the" + + " 30-minute interruption constraint; this negotiation is confirmed as" + + " concluded."); + } return new FeasibilityEndingContent( NegotiationConclusion.ACCEPT, - "The adjusted rate guarantee target is achievable and satisfies the outage duration" - + " requirement; this negotiation is confirmed as concluded."); + "端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。"); } }, - /** Information reject fixture stating that an item cannot be provided. */ + /** Information reject fixture stating that the access port name cannot be provided. */ INFORMATION_REJECT(NegotiationPhase.REJECT, "information-negotiation", "information_reject") { @Override - public NegotiationContent content() { + public NegotiationContent content(String language) { + if (GoldenInputs.EN_US.equals(language)) { + return new InformationEndingContent( + NegotiationConclusion.REJECT, + List.of(new NegotiationItem( + "Access Port Name", + "cannot be provided because the port inventory is temporarily unavailable on" + + " the workbench side"))); + } return new InformationEndingContent( NegotiationConclusion.REJECT, - List.of(new NegotiationItem( - "energy-saving area information", - "cannot be provided because the site inventory is unavailable"))); + List.of(new NegotiationItem("接入端口名称", "无法提供,工作台侧端口资源台账暂不可查"))); } }, - /** Target reject fixture carrying the failure reason. */ + /** Target reject fixture carrying the failure reason of the unclarified latency target. */ TARGET_REJECT(NegotiationPhase.REJECT, "target-negotiation", "target_reject") { @Override - public NegotiationContent content() { + public NegotiationContent content(String language) { + if (GoldenInputs.EN_US.equals(language)) { + return new TargetEndingContent( + NegotiationConclusion.REJECT, + null, + "The latency target cannot be clarified in full because the fiber cutover window is" + + " not confirmed yet."); + } return new TargetEndingContent( - NegotiationConclusion.REJECT, - null, - "The area information cannot be clarified in full because the site inventory is" - + " unavailable."); + NegotiationConclusion.REJECT, null, "因光缆割接窗口尚未确认,时延目标未能完全澄清。"); } }, - /** Feasibility reject fixture confirming a negative evaluation result in the exception slot. */ + /** Feasibility reject fixture confirming a negative port expansion assessment in the exception slot. */ FEASIBILITY_REJECT(NegotiationPhase.REJECT, "feasibility-negotiation", "feasibility_reject") { @Override - public NegotiationContent content() { + public NegotiationContent content(String language) { + if (GoldenInputs.EN_US.equals(language)) { + return new FeasibilityEndingContent( + NegotiationConclusion.REJECT, + "The port expansion cannot be completed within the designated cutover window because" + + " of insufficient board slots in the aggregation room; this negotiation is" + + " confirmed as concluded."); + } return new FeasibilityEndingContent( NegotiationConclusion.REJECT, - "The energy-saving target cannot be achieved under the existing power supply constraint;" - + " this negotiation is confirmed as concluded."); + "受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。"); } }, /** Common abort fixture terminating the negotiation at the round limit. */ ABORT(NegotiationPhase.ABORT, "common", "abort") { @Override - public NegotiationContent content() { - return new NegotiationAbortContent( - "The negotiation round limit is reached; this negotiation is confirmed as terminated."); + public NegotiationContent content(String language) { + if (GoldenInputs.EN_US.equals(language)) { + return new NegotiationAbortContent( + "The negotiation round limit is reached; this negotiation is confirmed as terminated."); + } + return new NegotiationAbortContent("已达到协商轮次上限,本次协商确认终止。"); } }; @@ -294,28 +385,31 @@ public NegotiationContext context() { } /** - * Returns the fixed typed content of this fixture. + * Returns the fixed typed content of this fixture in the given language. * - * @return fixed propose or ending content + * @param language locale identifier such as {@code zh-CN}; selects the business data language + * @return fixed propose or ending content carrying that language's business data */ - public abstract NegotiationContent content(); + public abstract NegotiationContent content(String language); /** * Generates this fixture through one orchestrator, using the from-data method of the fixture phase. * * @param orchestrator orchestrator wired with the resources of the fixture language + * @param language locale identifier such as {@code zh-CN}; selects the business data language * @return generated metadata content of this fixture */ - public MetadataContent generate(NegotiationGenerationOrchestrator orchestrator) { + public MetadataContent generate(NegotiationGenerationOrchestrator orchestrator, String language) { return switch (phase) { case PROPOSE -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context(), (NegotiationProposeContent) content()), template()); + new NegotiationProposeData(context(), (NegotiationProposeContent) content(language)), + template()); case ACCEPT -> orchestrator.generateAcceptFromData( - new NegotiationEndingData(context(), (NegotiationEndingContent) content()), template()); + new NegotiationEndingData(context(), (NegotiationEndingContent) content(language)), template()); case REJECT -> orchestrator.generateRejectFromData( - new NegotiationEndingData(context(), (NegotiationEndingContent) content()), template()); + new NegotiationEndingData(context(), (NegotiationEndingContent) content(language)), template()); case ABORT -> orchestrator.generateAbortFromData( - new NegotiationAbortData(context(), (NegotiationAbortContent) content()), template()); + new NegotiationAbortData(context(), (NegotiationAbortContent) content(language)), template()); }; } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java index 4bd938a0..d7a45363 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java @@ -44,9 +44,10 @@ class CustomRootTemplateOverrideTest { void customRootTemplateWinsWhilePresentAndTheRestOfTheOutputIsUntouched() throws IOException { writeCustomizedInformationProposeTemplate(); NegotiationGenerationOrchestrator orchestrator = orchestratorWithCustomRoot(); - MetadataContent builtinResult = GoldenCase.INFORMATION_PROPOSE.generate(orchestrator(GoldenInputs.ZH_CN, null)); + MetadataContent builtinResult = + GoldenCase.INFORMATION_PROPOSE.generate(orchestrator(GoldenInputs.ZH_CN, null), GoldenInputs.ZH_CN); - MetadataContent result = GoldenCase.INFORMATION_PROPOSE.generate(orchestrator); + MetadataContent result = GoldenCase.INFORMATION_PROPOSE.generate(orchestrator, GoldenInputs.ZH_CN); assertEquals( builtinResult.promptText() + "\n\n## " + MARKER_SECTION_TITLE + "\n" + MARKER_LINE, @@ -61,7 +62,7 @@ void builtInGoldenOutputIsRestoredAfterTheOverrideIsRemoved() throws IOException NegotiationGenerationOrchestrator orchestrator = orchestratorWithCustomRoot(); assertTrue( GoldenCase.INFORMATION_PROPOSE - .generate(orchestrator) + .generate(orchestrator, GoldenInputs.ZH_CN) .promptText() .contains(MARKER_LINE), "precondition: the override is in effect"); @@ -70,7 +71,7 @@ void builtInGoldenOutputIsRestoredAfterTheOverrideIsRemoved() throws IOException assertEquals( readGoldenFixture(GoldenCase.INFORMATION_PROPOSE, GoldenInputs.ZH_CN), - GoldenCase.INFORMATION_PROPOSE.generate(orchestrator).promptText(), + GoldenCase.INFORMATION_PROPOSE.generate(orchestrator, GoldenInputs.ZH_CN).promptText(), "after the removal the built-in template must reproduce the golden fixture byte for byte"); } @@ -83,7 +84,7 @@ void theOverrideNeverLeaksIntoTheOtherTemplates(GoldenCase goldenCase) throws IO writeCustomizedInformationProposeTemplate(); NegotiationGenerationOrchestrator orchestrator = orchestratorWithCustomRoot(); - MetadataContent result = goldenCase.generate(orchestrator); + MetadataContent result = goldenCase.generate(orchestrator, GoldenInputs.ZH_CN); assertEquals(readGoldenFixture(goldenCase, GoldenInputs.ZH_CN), result.promptText()); assertTrue(!result.promptText().contains(MARKER_LINE), "the marker must stay inside the overridden template"); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootVocabularyOverrideTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootVocabularyOverrideTest.java index bbf48f73..abcf751a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootVocabularyOverrideTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootVocabularyOverrideTest.java @@ -38,9 +38,10 @@ class CustomRootVocabularyOverrideTest { void customRootVocabularyChangesTheRenderedRelationshipLine() throws IOException { writeCustomizedZhCnVocabulary(); - String customized = GoldenCase.INFORMATION_PROPOSE.generate(orchestratorWithCustomRoot()) + String customized = GoldenCase.INFORMATION_PROPOSE.generate(orchestratorWithCustomRoot(), GoldenInputs.ZH_CN) .promptText(); - String builtin = GoldenCase.INFORMATION_PROPOSE.generate(orchestrator(GoldenInputs.ZH_CN, null)) + String builtin = GoldenCase.INFORMATION_PROPOSE + .generate(orchestrator(GoldenInputs.ZH_CN, null), GoldenInputs.ZH_CN) .promptText(); assertTrue(customized.contains(CUSTOM_RELATIONSHIP_LABEL), "the custom label must enter the rendered message"); @@ -59,10 +60,13 @@ void blankLocalRootKeepsTheBuiltInGoldenOutput() throws IOException { NegotiationGenerationOrchestratorBuilder.builder() .language(GoldenInputs.ZH_CN) .localRootDir(" ") - .build()); + .build(), + GoldenInputs.ZH_CN); assertEquals( - GoldenCase.INFORMATION_PROPOSE.generate(orchestrator(GoldenInputs.ZH_CN, null)).promptText(), + GoldenCase.INFORMATION_PROPOSE + .generate(orchestrator(GoldenInputs.ZH_CN, null), GoldenInputs.ZH_CN) + .promptText(), withBlankRoot.promptText(), "a blank local root must fall back to the classpath vocabulary"); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java index 04ccf743..efd0face 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java @@ -44,8 +44,8 @@ void sameInputRendersTheRespectiveGoldenFixturePerLanguage(GoldenCase goldenCase NegotiationGenerationOrchestrator zhCnOrchestrator = orchestrator(GoldenInputs.ZH_CN); NegotiationGenerationOrchestrator enUsOrchestrator = orchestrator(GoldenInputs.EN_US); - MetadataContent zhCnResult = goldenCase.generate(zhCnOrchestrator); - MetadataContent enUsResult = goldenCase.generate(enUsOrchestrator); + MetadataContent zhCnResult = goldenCase.generate(zhCnOrchestrator, GoldenInputs.ZH_CN); + MetadataContent enUsResult = goldenCase.generate(enUsOrchestrator, GoldenInputs.EN_US); assertEquals(readGoldenFixture(goldenCase, GoldenInputs.ZH_CN), zhCnResult.promptText()); assertEquals(readGoldenFixture(goldenCase, GoldenInputs.EN_US), enUsResult.promptText()); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/NegotiationErrorPathPropertyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/NegotiationErrorPathPropertyTest.java index 8351b185..8d70118e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/NegotiationErrorPathPropertyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/NegotiationErrorPathPropertyTest.java @@ -234,14 +234,14 @@ private static String endingPayload(String conclusion) { private static Map validInformationProposePayload() { Map payload = new LinkedHashMap<>(); - payload.put("items", List.of(Map.of("name", "energy_saving_region", "value", "Songshan Lake"))); + payload.put("items", List.of(Map.of("name", "access_port", "value", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"))); payload.put("relationship", null); return payload; } private static Map validTargetProposePayload() { Map payload = new LinkedHashMap<>(); - payload.put("target_negotiation_description", "Coverage expansion of the industrial park"); + payload.put("target_negotiation_description", "Latency repair target of the quality degradation complaint"); payload.put("intent_understanding", List.of()); payload.put("alignment_and_clarification", List.of()); payload.put("request_for_clarification", List.of()); @@ -250,10 +250,10 @@ private static Map validTargetProposePayload() { private static Map validFeasibilityProposePayload() { Map payload = new LinkedHashMap<>(); - payload.put("feasibility_negotiation_description", "Backhaul upgrade feasibility"); + payload.put("feasibility_negotiation_description", "Access port expansion feasibility within the cutover window"); payload.put("action", "REQUEST_FEASIBILITY_EVALUATION"); payload.put( - "contents_to_evaluate", List.of(Map.of("name", "guaranteed_rate", "value", "20Mbps"))); + "contents_to_evaluate", List.of(Map.of("name", "latency_target", "value", "within 20ms"))); payload.put("infeasibility_details_and_proposal", List.of()); return payload; } @@ -261,14 +261,14 @@ private static Map validFeasibilityProposePayload() { private static Map validInformationEndingPayload() { Map payload = new LinkedHashMap<>(); payload.put("conclusion", "Accept"); - payload.put("items", List.of(Map.of("name", "energy_saving_region", "value", "Songshan Lake"))); + payload.put("items", List.of(Map.of("name", "access_port", "value", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"))); return payload; } private static Map validTargetEndingPayload() { Map payload = new LinkedHashMap<>(); payload.put("conclusion", "Accept"); - payload.put("confirmed_intent", "Confirmed coverage expansion intent"); + payload.put("confirmed_intent", "Confirmed latency repair intent within 20ms"); payload.put("failure_reason", null); return payload; } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyArbitraries.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyArbitraries.java index 4b7c5d35..03d53617 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyArbitraries.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyArbitraries.java @@ -54,36 +54,36 @@ final class PropertyArbitraries { private static final List LANGUAGES = List.of("zh-CN", "en-US"); private static final List ITEM_NAMES = List.of( - "energy_saving_region", - "coverage_area", - "guaranteed_rate", - "spectrum_band", - "carrier_frequency", - "pdu_session_type", - "slice_sst", - "amf_region_id", - "handover_margin", - "qos_class_identifier", - "roaming_profile", - "interference_level"); + "access_port", + "complaint_category", + "fault_time", + "event_serial_no", + "fault_detail", + "private_line_id", + "service_name", + "access_vlan_id", + "board_slot", + "port_bandwidth", + "latency_target", + "packet_loss_rate"); private static final List ITEM_VALUES = List.of( - "Songshan Lake", - "at least 20Mbps", - "QCI 9", - "3.5GHz n78", - "15kHz subcarrier spacing", - "session continuity mode 2", - "URLLC slice", - "centralized RAN", - "3dB", - "voice-optimized bearer"); + "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1", + "P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)", + "dedicated-line quality degradation", + "private line interruption", + "2026-05-11T08:21:46Z", + "event-id-20260511-09013", + "fault-id-1-017-20260516-11234", + "within 20ms", + "no higher than 1%", + "within 48 hours"); private static final List NON_BLANK_TEXTS = List.of( - "Energy-saving parameter negotiation for the Songshan Lake campus network", - "Coverage expansion toward the industrial park at the city border", - "Guaranteed-bitrate backhaul upgrade for the private-network slice", - "Load rebalancing between the two core-network pools"); + "Private line complaint diagnosis negotiation for the Shenzhen-to-Guangzhou dedicated line", + "Latency repair target negotiation after the quality degradation complaint", + "Access port expansion feasibility assessment within the cutover window", + "Complaint information supplement negotiation between the workbench and the OMC"); private PropertyArbitraries() {} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngine.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngine.java index 518e7a22..a3c7225a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngine.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngine.java @@ -5,9 +5,11 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -41,7 +43,8 @@ * the case, wires the {@code inject} hooks onto their real builder injection points ({@code failingTemplateLoader} for * the generation template-not-found matrix, {@code failingSemanticValidator} for the validate-family * prompt-resource-not-found mapping) and dispatches on the {@link NegotiationApi} enum at compile time, so a misspelled API - * name fails at corpus load time and a renamed service method fails this compilation. + * name fails at corpus load time and a renamed service method fails this compilation. The three task-family APIs run + * through the {@link TaskApiAssembler}, the mirrored production wiring of the closed loop (Q21). * *

        Result normalization: a success run returns the produced {@link MetadataContent} or {@link FilledParamData}, a * failure run captures the thrown exception. The expectation comparison covers outcome, exception name, error code, @@ -86,17 +89,38 @@ public CaseOutcome run(NegotiationCase testCase) { * @throws AssertionError when any expectation mismatches */ public CaseOutcome run(NegotiationCase testCase, @Nullable String promptOverride) { + return run(testCase, promptOverride, false); + } + + /** + * Runs one case, optionally overriding the prompt input of the validate family and optionally running as a + * scenario step. + * + *

        The {@link ScenarioEngine} resolves {@code prompt.fromStep} references into prompt texts and hands them in + * through the prompt parameter; a standalone validate case resolves its golden or inline prompt itself. Scenario + * mode defers the {@code expect.paramsFromStep} check to the scenario engine, which resolves the cross-step + * reference after the step ran. + * + * @param testCase expanded corpus case + * @param promptOverride prompt text resolved from an earlier scenario step, or null + * @param inScenario true inside the ScenarioEngine, false for a standalone run + * @return the normalized outcome of the run + * @throws AssertionError when any expectation mismatches + */ + CaseOutcome run(NegotiationCase testCase, @Nullable String promptOverride, boolean inScenario) { ScriptedNegotiationLlmClient llmClient = llmClientFor(testCase); NegotiationContentService service = new NegotiationContentService(orchestratorFor(testCase, llmClient)); + TaskApiAssembler taskApi = + testCase.api().family() == NegotiationApi.Family.TASK ? taskApiFor(testCase, llmClient) : null; Object value = null; Throwable failure = null; try { - value = invoke(service, testCase, promptOverride); + value = invoke(service, taskApi, testCase, promptOverride); } catch (RuntimeException | AssertionError error) { failure = error; } CaseOutcome outcome = new CaseOutcome(testCase, value, failure, llmClient.callCount(), llmClient); - assertExpectations(outcome); + assertExpectations(outcome, inScenario); return outcome; } @@ -139,14 +163,14 @@ public record CaseOutcome( // ------------------------------------------------------------------ expectation comparison - private void assertExpectations(CaseOutcome outcome) { + private void assertExpectations(CaseOutcome outcome, boolean inScenario) { NegotiationCase testCase = outcome.testCase(); Expectation expect = testCase.expect(); if (expect.success()) { if (outcome.failure() != null) { fail(testCase, "$.expect.outcome", "success", "failure (" + outcome.failure() + ")"); } - assertSuccessFields(outcome); + assertSuccessFields(outcome, inScenario); } else { if (outcome.failure() == null) { fail( @@ -238,7 +262,7 @@ private void assertSlotErrors( } } - private void assertSuccessFields(CaseOutcome outcome) { + private void assertSuccessFields(CaseOutcome outcome, boolean inScenario) { NegotiationCase testCase = outcome.testCase(); Expectation expect = testCase.expect(); if (expect.promptTextEqualsGolden() != null) { @@ -273,18 +297,64 @@ private void assertSuccessFields(CaseOutcome outcome) { } } } - if (!expect.params().isEmpty()) { + if (!expect.params().isEmpty() || expect.missingParams() != null) { if (!(outcome.value() instanceof FilledParamData filled)) { throw fail( testCase, - "$.expect.params", - render(expect.params()), + expect.missingParams() != null && expect.params().isEmpty() + ? "$.expect.missingParams" + : "$.expect.params", + expect.missingParams() != null + ? "filled parameter data with the missing-parameter set " + expect.missingParams() + : render(expect.params()), "no filled parameter data (" + typeName(outcome.value()) + ")"); } - if (!Objects.equals(expect.params(), filled.data())) { + if (expect.missingParams() != null) { + // Task semantics (Q21): the expected params are a subset check with exact values, and the missing + // parameter set — the null-valued entries of the filled data — is asserted exactly. + for (Map.Entry entry : expect.params().entrySet()) { + if (!filled.data().containsKey(entry.getKey()) + || !Objects.equals(entry.getValue(), filled.data().get(entry.getKey()))) { + fail( + testCase, + "$.expect.params", + entry.getKey() + "=" + entry.getValue(), + entry.getKey() + "=" + filled.data().get(entry.getKey())); + } + } + List actualMissing = filled.data().entrySet().stream() + .filter(entry -> entry.getValue() == null) + .map(Map.Entry::getKey) + .toList(); + Set expectedMissing = new LinkedHashSet<>(expect.missingParams()); + if (!expectedMissing.equals(new LinkedHashSet<>(actualMissing))) { + fail( + testCase, + "$.expect.missingParams", + String.join(", ", expectedMissing), + actualMissing.isEmpty() ? "(none)" : String.join(", ", actualMissing)); + } + } else if (!Objects.equals(expect.params(), filled.data())) { fail(testCase, "$.expect.params", render(expect.params()), render(filled.data())); } } + for (String fragment : expect.promptTextContains()) { + MetadataContent message = requireMessage(outcome, "$.expect.promptTextContains"); + String promptText = message.promptText() == null ? "" : message.promptText(); + if (!promptText.contains(fragment)) { + fail( + testCase, + "$.expect.promptTextContains", + "a prompt text containing '" + fragment + "'", + quoted(truncate(promptText))); + } + } + if (expect.paramsFromStep() != null && !inScenario) { + throw new IllegalStateException( + testCase.errorPrefix() + " expect.paramsFromStep " + expect.paramsFromStep() + + " is resolved by the ScenarioEngine; run the enclosing scenario through the" + + " ScenarioEngine"); + } } // ------------------------------------------------------------------ contracts (P0) @@ -519,8 +589,22 @@ private static NegotiationGenerationOrchestrator orchestratorFor( return builder.build(); } - private static NegotiationTemplateLoader failingTemplateLoader() { - return new NegotiationTemplateLoader() { + /** + * Assembles the task API wiring of the closed loop for one case: the retry limit comes from the case's LLM script + * or falls back to the builder default of 3, mirroring the facade builders. + * + * @param testCase expanded corpus case of the task family + * @param llmClient scripted LLM client injected at the facade builders' LLM seam + * @return the task API assembly of the case's language + */ + private static TaskApiAssembler taskApiFor(NegotiationCase testCase, ScriptedNegotiationLlmClient llmClient) { + int maxAttempts = testCase.llm() != null && testCase.llm().maxAttempts() != null + ? testCase.llm().maxAttempts() + : 3; + return new TaskApiAssembler(testCase.language(), maxAttempts, llmClient); + } + + private static NegotiationTemplateLoader failingTemplateLoader() { return new NegotiationTemplateLoader() { @Override public PromptTemplate load(NegotiationReference reference) { throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); @@ -554,7 +638,10 @@ private static NegotiationSemanticValidator failingSemanticValidator() { // ------------------------------------------------------------------ API dispatch private Object invoke( - NegotiationContentService service, NegotiationCase testCase, @Nullable String promptOverride) { + NegotiationContentService service, + @Nullable TaskApiAssembler taskApi, + NegotiationCase testCase, + @Nullable String promptOverride) { TemplateUri templateUri = parseTemplateUri(testCase); NegotiationContext context = toContext(testCase.context()); return switch (testCase.api()) { @@ -594,9 +681,54 @@ private Object invoke( promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); case VALIDATE_ABORT_PROMPT_AND_DATA_FILLING -> service.validateAbortPromptAndDataFilling( promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); + case GENERATE_TASK_PROMPT_FROM_TEXT -> + requireTaskApi(taskApi, testCase).generateTaskPromptFromText( + requireInputText(testCase), templateUri); + case GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA -> + requireTaskApi(taskApi, testCase).generateTaskPromptFromDataWithSchema( + dataOf(testCase), requireSchema(testCase), templateUri); + case VALIDATE_TASK_PROMPT_AND_DATA_FILLING -> + requireTaskApi(taskApi, testCase).validateTaskPromptAndDataFilling( + promptOf(testCase, promptOverride), requireSchema(testCase), templateUri); }; } + private static TaskApiAssembler requireTaskApi( + @Nullable TaskApiAssembler taskApi, NegotiationCase testCase) { + if (taskApi == null) { + throw new IllegalStateException( + testCase.errorPrefix() + " the task API assembly is only wired for the task family but got " + + testCase.api().jsonName()); + } + return taskApi; + } + + private static String requireInputText(NegotiationCase testCase) { + String text = testCase.inputText(); + if (text == null) { + throw new IllegalStateException( + testCase.errorPrefix() + " input.text: the task from-text API requires a text input"); + } + return text; + } + + private static Map requireSchema(NegotiationCase testCase) { + Map schema = schemaOf(testCase); + if (schema == null) { + throw new IllegalStateException(testCase.errorPrefix() + " schema: the task APIs require a schema"); + } + return schema; + } + + private static Map dataOf(NegotiationCase testCase) { + JsonNode data = testCase.inputData(); + if (data == null || !data.isObject()) { + throw new IllegalStateException( + testCase.errorPrefix() + " input.data: the task from-data API requires typed input data"); + } + return MAPPER.convertValue(data, new TypeReference>() {}); + } + private static TemplateUri parseTemplateUri(NegotiationCase testCase) { String raw = testCase.templateUri(); if (raw == null) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngineTest.java index b1790afe..d01720c0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngineTest.java @@ -37,31 +37,89 @@ class CaseEngineTest { private static final String INFORMATION_ACCEPT_REJECT_URI = "Negotiation-T/information-negotiation/accept-reject/v1"; + private static final String PRIVATE_LINE_COMPLAINT_URI = + "Task-T/network-layer/private-line-complaint/v1"; + + /** + * The workbench raw complaint of the five-step closed loop (样例步骤1): deliberately lacking the access port name + * and the complaint category — the causal starting point of the information negotiation. + */ + private static final String COMPLAINT_TEXT = + "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢," + + "柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; + + /** Slot-extraction payload of the closed loop's step 1: the task object stays empty (no port name in the text). */ + private static final String TASK_SLOTS_OBJECT_EMPTY = + "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" + + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** Slot-extraction payload of the filled variant: the port name and the complaint category are present. */ + private static final String TASK_SLOTS_FILLED = + "{\"slots\": {\"任务对象\": \"接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\", \"任务上下文\":" + + " \"投诉分类:专线质差;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:" + + "event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** + * Semantic-validation payload of the closed loop's step 2: the prompt is acceptable, but the access port name and + * the complaint category are missing — the two null-valued parameters the OMC then negotiates for. + */ + private static final String TASK_SEMANTIC_MISSING_PARAMS = + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," + + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; + + /** Semantic payload rejecting the task prompt (rejection-sample shape: key slots missing). */ + private static final String TASK_SEMANTIC_REJECTED = + "{\"semantic_verdict\":false,\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\"," + + "\"message\":\"The access port parameter is missing.\"}],\"params\":{}}"; + + /** The task parameter schema of the closed loop (server-side keys, dictionary §10). */ + private static final String TASK_PARAM_SCHEMA = + "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" + + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" + + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; + + /** The rendered task prompt the OMC receives (样例步骤1, shortened): the negotiation-causing message. */ + private static final String TASK_PROMPT_MISSING_PARAMS = """ + ## 任务类型(Task Type) + 传输专线业务投诉诊断 + + ## 任务对象(Task Object) + 接入端口名称: + + ## 任务上下文(Task Context) + 1. 投诉分类: + 2. 问题发生时间: "2026-05-11T08:21:46Z" + 3. OSS侧事件流水号:"event-id-20260511-09013" + 4. 投诉详情:"深圳访问广州的响应时延从平均12ms骤升至320ms" + """; + /** Extraction payload that maps back to the typed content of the information accept golden fixture. */ private static final String ACCEPT_PAYLOAD = - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan" - + " Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}]}"; + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" + + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; /** Extraction payload that maps back to the typed content of the information propose golden fixture. */ private static final String PROPOSE_PAYLOAD = - "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"e.g. Songshan Lake\"},{\"name\":" - + "\"energy-saving rate guarantee target\",\"value\":\"e.g. 20Mbps\"},{\"name\":\"VLANId\"," + "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}," + + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," + "\"value\":null}],\"relationship\":\"OR\"}"; /** The same content as typed data, disagreeing in one item value for the differential red path. */ private static final String PROPOSE_DATA_DISAGREEING = - "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Another Lake\"},{\"name\":" - + "\"energy-saving rate guarantee target\",\"value\":\"e.g. 20Mbps\"},{\"name\":\"VLANId\"," + "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P781-珠江新城-PTN7900-23-TPA1EG24-17\"}," + + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," + "\"value\":null}],\"relationship\":\"OR\"}"; private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"region\":" - + "\"Songshan Lake\"}}"; + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" + + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; private static final String SEMANTIC_REJECT_PAYLOAD = "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" - + "\"region\",\"code\":\"missing\",\"message\":\"The region parameter is missing.\"}]," - + "\"params\":{}}"; + + "\"accessPort\",\"code\":\"missing\",\"message\":\"The access port parameter is" + + " missing.\"}],\"params\":{}}"; private final CaseEngine engine = new CaseEngine(); @@ -71,7 +129,7 @@ class CaseEngineTest { @BeforeEach void readSchema() throws Exception { - schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"region\":{\"type\":\"string\"}}}"); + schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); } // ------------------------------------------------------------------ green paths @@ -192,7 +250,7 @@ void assertContextKeysInMergedParamsContract() { NegotiationCase testCase = validateProposeCase( okParams( 1, - Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "region", "Songshan Lake"), + Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P533-珠江旧城-PTN3900-23-TPA1EG24-1"), List.of("contextKeysInMergedParams")), SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS); @@ -200,7 +258,7 @@ void assertContextKeysInMergedParamsContract() { FilledParamData filled = outcome.filledParams(); assertNotNull(filled); - assertEquals("Songshan Lake", filled.data().get("region")); + assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); } @Test @@ -304,7 +362,7 @@ void comparesSlotErrorsOrderInsensitively() { "NegotiationParamExtractionException", "negotiation_semantic_rejected", 1, - List.of(new Expectation.SlotError("region", "missing")), + List.of(new Expectation.SlotError("accessPort", "missing")), List.of()), SEMANTIC_REJECT_PAYLOAD); @@ -319,7 +377,7 @@ void slotErrorsFailOnAnExtraExpectedPair() { "negotiation_semantic_rejected", 1, List.of( - new Expectation.SlotError("region", "missing"), + new Expectation.SlotError("accessPort", "missing"), new Expectation.SlotError("round", "out_of_range")), List.of()), SEMANTIC_REJECT_PAYLOAD); @@ -413,6 +471,171 @@ void failsWhenASuccessCaseActuallyFails() { "an unexpected failure must flip the outcome expectation but was: " + failure.getMessage()); } + // ------------------------------------------------------------------ task API dispatch family (Q21) + + @Test + void runsATaskFromTextCaseThroughTheMirroredClientWiring() { + NegotiationCase testCase = taskFromTextCase(okTask( + 1, + List.of("## 任务类型", "## 任务对象", "## 任务上下文", "event-id-20260511-09013"), + null, + Map.of())); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + MetadataContent message = outcome.message(); + assertNotNull(message); + assertEquals(1, outcome.llmCalls(), "the task from-text pipeline makes exactly one slot-extraction call"); + assertEquals(PRIVATE_LINE_COMPLAINT_URI, message.templateUri()); + assertTrue( + message.promptText().contains("## 任务对象(Task Object)"), + "the task object section header must be rendered: " + message.promptText()); + assertTrue(!message.promptText().contains("P533"), "no port name may leak into the incomplete task prompt"); + } + + @Test + void runsATaskFromDataWithSchemaCaseThroughTheMirroredClientWiring() throws Exception { + NegotiationCase testCase = taskFromDataCase(okTask( + 1, + List.of("## 任务类型", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差"), + null, + Map.of())); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + MetadataContent message = outcome.message(); + assertNotNull(message); + assertEquals(1, outcome.llmCalls(), "the from-data-with-schema pipeline also extracts slots through the LLM"); + assertEquals(PRIVATE_LINE_COMPLAINT_URI, message.templateUri()); + } + + @Test + void validateTaskPromptReportsTheMissingParametersAsNullValuedParams() throws Exception { + NegotiationCase testCase = taskValidateCase( + okTask(1, null, List.of("accessPort", "bizScenario"), Map.of( + "faultTime", "2026-05-11T08:21:46Z", + "eventSerialNo", "event-id-20260511-09013")), + TASK_SEMANTIC_MISSING_PARAMS); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + FilledParamData filled = outcome.filledParams(); + assertNotNull(filled); + assertEquals(1, outcome.llmCalls(), "the task validation pipeline makes exactly one semantic call"); + assertEquals("2026-05-11T08:21:46Z", filled.data().get("faultTime")); + assertEquals("event-id-20260511-09013", filled.data().get("eventSerialNo")); + assertTrue(filled.data().containsKey("accessPort") && filled.data().get("accessPort") == null, + "a missing parameter must surface as a null-valued entry, not as an absent key"); + } + + @Test + void taskValidateMissingParamsFlipFails() throws Exception { + NegotiationCase testCase = taskValidateCase( + okTask(1, null, List.of("accessPort", "bizScenario", "faultDetail"), Map.of()), + TASK_SEMANTIC_MISSING_PARAMS); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.missingParams") + && failure.getMessage().contains("faultDetail"), + "an extra expected missing parameter must fail but was: " + failure.getMessage()); + } + + @Test + void taskValidateSemanticRejectionCarriesTheValidationCode() throws Exception { + NegotiationCase testCase = taskValidateCase( + failed("ContentValidationException", "validation_semantic_rejected", 1, List.of(), List.of()), + TASK_SEMANTIC_REJECTED); + + engine.run(testCase); + } + + @Test + void taskFromTextFailsOnAMissingRequiredSlot() throws Exception { + NegotiationCase testCase = new NegotiationCase( + "TASK-ERR-01/zh-CN", + "TASK-ERR-01", + "task/programming-errors.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + COMPLAINT_TEXT, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload( + "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"\"}, \"slot_errors\": []}"))), + null, + null, + null, + failed("PromptGenerationException", "slot_validation_error", 1, List.of(), List.of())); + + engine.run(testCase); + } + + @Test + void taskPromptTextContainsFailsOnAnAbsentFragment() { + NegotiationCase testCase = taskFromTextCase( + okTask(1, List.of("## 不存在的节头"), null, Map.of())); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.promptTextContains") + && failure.getMessage().contains("不存在的节头"), + "an absent structural fragment must fail but was: " + failure.getMessage()); + } + + @Test + void paramsFromStepIsReservedForTheScenarioEngine() throws Exception { + NegotiationCase testCase = taskValidateCase( + okTask(1, null, null, Map.of()), + TASK_SEMANTIC_MISSING_PARAMS); + NegotiationCase withFromStep = new NegotiationCase( + testCase.id(), + testCase.baseId(), + testCase.sourceFile(), + testCase.api(), + testCase.language(), + testCase.priority(), + testCase.tags(), + testCase.summary(), + testCase.context(), + testCase.templateUri(), + testCase.inputText(), + testCase.inputData(), + testCase.llm(), + testCase.prompt(), + testCase.schema(), + testCase.inject(), + new Expectation( + testCase.expect().success(), + testCase.expect().exception(), + testCase.expect().code(), + testCase.expect().messageContains(), + testCase.expect().slotErrors(), + testCase.expect().llmCalls(), + testCase.expect().promptTextEqualsGolden(), + testCase.expect().metadata(), + testCase.expect().params(), + testCase.expect().contracts(), + testCase.expect().differential(), + testCase.expect().promptTextContains(), + testCase.expect().missingParams(), + 1)); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> engine.run(withFromStep)); + + assertTrue( + failure.getMessage().contains("paramsFromStep 1") + && failure.getMessage().contains("ScenarioEngine"), + "a standalone case cannot resolve paramsFromStep but was: " + failure.getMessage()); + } + // ------------------------------------------------------------------ expectation helpers private static Expectation ok( @@ -492,7 +715,7 @@ private NegotiationCase proposeFromTextCase(Expectation expect, @Nullable JsonNo null, new ContextSpec(SESSION_ID, 2, 5), INFORMATION_PROPOSE_URI, - "请补充节能区域、节能速率保障目标与VLANId信息。", + "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", inputData, new LlmScript(null, List.of(new LlmScriptStep.Payload(PROPOSE_PAYLOAD))), null, @@ -525,4 +748,99 @@ private NegotiationCase validateProposeCase(Expectation expect, String semanticP private static Expectation.Metadata metadata() { return new Expectation.Metadata(INFORMATION_ACCEPT_REJECT_URI, Boolean.TRUE); } + + // ------------------------------------------------------------------ task family helpers + + private static Expectation okTask( + @Nullable Integer llmCalls, + @Nullable List promptTextContains, + @Nullable List missingParams, + Map params) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + null, + null, + params, + List.of(), + false, + promptTextContains == null ? List.of() : promptTextContains, + missingParams, + null); + } + + private NegotiationCase taskFromTextCase(Expectation expect) { + return new NegotiationCase( + "TASK-FT-01/zh-CN", + "TASK-FT-01", + "task/from-text.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + COMPLAINT_TEXT, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_OBJECT_EMPTY))), + null, + null, + null, + expect); + } + + private NegotiationCase taskFromDataCase(Expectation expect) throws Exception { + return new NegotiationCase( + "TASK-FD-01/zh-CN", + "TASK-FD-01", + "task/from-data.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA, + ZH_CN, + "P0", + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + null, + mapper.readTree( + "{\"portName\": \"P533-珠江旧城-PTN3900-23-TPA1EG24-1\", \"complaintScenario\": \"专线质差\"," + + " \"faultStartTime\": \"2026-05-11T08:21:46Z\", \"ticketNo\":" + + " \"event-id-20260511-09013\", \"faultDetailText\": \"深圳访问广州的响应时延从平均12ms" + + "骤升至320ms\"}"), + new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_FILLED))), + null, + mapper.readTree( + "{\"type\": \"object\", \"properties\": {\"portName\": {\"type\": \"string\"}," + + " \"complaintScenario\": {\"type\": \"string\"}, \"faultStartTime\":" + + " {\"type\": \"string\"}, \"ticketNo\": {\"type\": \"string\"}," + + " \"faultDetailText\": {\"type\": \"string\"}}}"), + null, + expect); + } + + private NegotiationCase taskValidateCase(Expectation expect, String semanticPayload) throws Exception { + return new NegotiationCase( + "TASK-VAL-01/zh-CN", + "TASK-VAL-01", + "task/validate.json", + NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, + ZH_CN, + "P0", + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), + new PromptSource.Text(TASK_PROMPT_MISSING_PARAMS), + mapper.readTree(TASK_PARAM_SCHEMA), + null, + expect); + } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusContractTest.java index e69dd320..2f97768e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusContractTest.java @@ -30,9 +30,12 @@ * *

        Checked today, in full strictness: global id uniqueness, expectation-block completeness, every expected error code * being one of the seven negotiation error codes, the seven-code failure coverage, the five mapCode mapping targets, the - * four operational definitions of the bilingual parity (§7), and the per-record uniform language expansion. The Q19 - * business-review soft gate (§8.7) is opt-in: P0 cases must be 100% approved in review-status.json when - * {@code -Dcorpus.review.gate=true} is set and the status file exists, and is skipped otherwise. + * four operational definitions of the bilingual parity (§7), and the per-record uniform language expansion. The + * business-alignment decisions add three more (design §10): the three closed-loop task APIs are exercised by scenario + * steps only and are role-bound (A=workbench generates, B=OMC validates), and every VAL-DRIFT drift probe is a + * compliant peer text expecting success. The Q19 business-review soft gate (§8.7) is opt-in: P0 cases must be 100% + * approved in review-status.json when {@code -Dcorpus.review.gate=true} is set and the status file exists, and is + * skipped otherwise. * *

        The full corpus has landed (design §8.4): a missing error-code or mapCode dimension is a hard failure, not a TODO * line — a hole in the corpus silently narrows the suites, so it must block the build. @@ -225,6 +228,97 @@ void everyMapCodeTargetIsCoveredByAValidateFailureCase() { } } + // ------------------------------------------------------------------ closed-loop task APIs (Q20-Q23) + + /** + * Q21 full strictness: every one of the three closed-loop task APIs must be exercised by at least one scenario + * step, and the task APIs must never appear as standalone case-file records — the closed loop (task prompt + * generation, peer validation, then negotiation) only has a business meaning inside a scenario, so a standalone + * task case would bypass the 缺参 → 协商 → 补参 → 提取 causal chain the corpus exists to model. + */ + @Test + void everyTaskApiIsExercisedByScenarioStepsOnly() { + List taskApis = new ArrayList<>(); + for (ScenarioCase scenario : CORPUS.scenarios()) { + for (ScenarioCase.ScenarioStep step : scenario.steps()) { + NegotiationApi api = step.caseData().api(); + if (api.family() == NegotiationApi.Family.TASK) { + taskApis.add(api); + } + } + } + for (NegotiationApi api : NegotiationApi.values()) { + if (api.family() != NegotiationApi.Family.TASK) { + continue; + } + assertTrue( + taskApis.contains(api), + "no scenario step exercises the closed-loop task API '" + api.jsonName() + "' yet"); + } + for (NegotiationCase testCase : CORPUS.cases()) { + assertTrue( + testCase.api().family() != NegotiationApi.Family.TASK, + testCase.errorPrefix() + + ": a task API must not appear as a standalone case record, only as a scenario step" + + " (the closed-loop causal chain lives in scenarios)"); + } + } + + /** + * Q21/Q23 role semantics: the workbench (A) generates the task prompt and the OMC (B) validates the peer message, + * so every task-generation step must be acted by role A and every task-validation step by role B (the dual-session + * scenario numbers its roles A1/A2/B1/B2 — the letter prefix decides). + */ + @Test + void taskStepsAreBoundToTheirClosedLoopRoles() { + for (ScenarioCase scenario : CORPUS.scenarios()) { + for (ScenarioCase.ScenarioStep step : scenario.steps()) { + NegotiationApi api = step.caseData().api(); + if (api.family() != NegotiationApi.Family.TASK) { + continue; + } + String role = step.role() == null ? "" : step.role(); + String expectedSide = api.jsonName().startsWith("generate") ? "A" : "B"; + assertTrue( + role.startsWith(expectedSide), + scenario.id() + " step " + step.step() + " (" + api.jsonName() + + ") must be acted by the " + (expectedSide.equals("A") ? "workbench (A)" : "OMC (B)") + + " side, but the role is '" + step.role() + "'"); + } + } + } + + // ------------------------------------------------------------------ drift probes (Q22) + + /** + * Q22 full strictness: a drift probe is a compliant-but-reworded peer message, so it must (a) live in the validate + * family, (b) expect success — a drift probe that fails would reward exact-wording coupling instead of semantic + * compliance — and (c) take the peer message as inline text, never a golden fixture (golden is the regression + * equality anchor, not the validate mainline input). + */ + @Test + void driftProbesAreCompliantPeerTextExpectingSuccess() { + boolean seen = false; + for (NegotiationCase testCase : CORPUS.cases()) { + if (!testCase.baseId().startsWith("VAL-DRIFT-")) { + continue; + } + seen = true; + assertTrue( + testCase.api().family() == NegotiationApi.Family.VALIDATE, + testCase.errorPrefix() + ": a drift probe must be a validate-family case"); + assertTrue( + testCase.expect().success(), + testCase.errorPrefix() + ": a compliant-but-reworded peer message must pass validation"); + assertTrue( + testCase.prompt() instanceof PromptSource.Text, + testCase.errorPrefix() + + ": a drift probe must carry the peer message as inline text (prompt.text)," + + " not a golden fixture or a fromStep reference"); + } + assertTrue(seen, "the corpus must carry at least one VAL-DRIFT drift probe"); + } + // ------------------------------------------------------------------ bilingual parity (§7, four definitions) /** Parity ①: every happy case of the case files declares both languages, so both expansions run and must succeed. */ diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSensitivitySelfTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSensitivitySelfTest.java index e1373996..133ab063 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSensitivitySelfTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSensitivitySelfTest.java @@ -39,25 +39,65 @@ class CorpusSensitivitySelfTest { private static final String INFORMATION_ACCEPT_REJECT_URI = "Negotiation-T/information-negotiation/accept-reject/v1"; + private static final String PRIVATE_LINE_COMPLAINT_URI = + "Task-T/network-layer/private-line-complaint/v1"; + + /** The workbench raw complaint of the closed loop (样例步骤1): no port name and no complaint category. */ + private static final String COMPLAINT_TEXT = + "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报" + + "“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; + + /** Slot-extraction payload of the closed loop's step 1: the task object stays empty. */ + private static final String TASK_SLOTS_OBJECT_EMPTY = + "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" + + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** Semantic payload of the closed loop's step 2: the port name and the complaint category are missing. */ + private static final String TASK_SEMANTIC_MISSING_PARAMS = + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," + + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; + + /** The rendered task prompt the OMC receives (样例步骤1, shortened): the negotiation-causing message. */ + private static final String TASK_PROMPT_MISSING_PARAMS = """ + ## 任务类型(Task Type) + 传输专线业务投诉诊断 + + ## 任务对象(Task Object) + 接入端口名称: + + ## 任务上下文(Task Context) + 1. 投诉分类: + 2. 问题发生时间: "2026-05-11T08:21:46Z" + 3. OSS侧事件流水号:"event-id-20260511-09013" + 4. 投诉详情:"深圳访问广州的响应时延从平均12ms骤升至320ms" + """; + + /** The task parameter schema of the closed loop (server-side keys, dictionary §10). */ + private static final String TASK_PARAM_SCHEMA = + "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" + + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" + + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; + /** Extraction payload mapping to the typed content of the information accept golden fixture. */ private static final String ACCEPT_PAYLOAD = - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan" - + " Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}]}"; + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" + + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; /** Extraction payload whose mapped content lacks every required slot (fails with negotiation_slot_missing). */ private static final String SLOTS_MISSING_PAYLOAD = "{\"relationship\":null}"; /** Semantic verdict payload of a successful validation carrying one business parameter. */ private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"region\":" - + "\"Songshan Lake\"}}"; + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" + + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; /** Semantic verdict payload rejecting the message with two slot errors. */ private static final String SEMANTIC_REJECT_PAYLOAD_TWO_ERRORS = "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" - + "\"region\",\"code\":\"missing\",\"message\":\"The region parameter is missing.\"},{\"slot_name\":" - + "\"rate\",\"code\":\"out_of_range\",\"message\":\"The rate parameter is out of range.\"}]," - + "\"params\":{}}"; + + "\"accessPort\",\"code\":\"missing\",\"message\":\"The access port parameter is" + + " missing.\"},{\"slot_name\":\"latency_target\",\"code\":\"out_of_range\",\"message\":" + + "\"The latency target parameter is out of range.\"}],\"params\":{}}"; private final CaseEngine engine = new CaseEngine(); @@ -67,7 +107,7 @@ class CorpusSensitivitySelfTest { @BeforeEach void readSchema() throws Exception { - schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"region\":{\"type\":\"string\"}}}"); + schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); } // ------------------------------------------------------------------ flipped expectations must be red @@ -130,18 +170,18 @@ void flippedGoldenNameMustFailTheEngine() { @Test void tamperedParamsMustFailTheEngine() { engine.run(validateCase( - "VAL-SENS-PARAMS", Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "region", "Songshan Lake"))); + "VAL-SENS-PARAMS", Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P533-珠江旧城-PTN3900-23-TPA1EG24-1"))); NegotiationCase tampered = validateCase( "VAL-SENS-PARAMS-FLIP", - Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "region", "Another Lake")); + Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P781-珠江新城-PTN7900-23-TPA1EG24-17")); AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(tampered)); assertTrue( failure.getMessage().contains("VAL-SENS-PARAMS-FLIP") && failure.getMessage().contains("$.expect.params") - && failure.getMessage().contains("Another Lake"), + && failure.getMessage().contains("P781-珠江新城-PTN7900-23-TPA1EG24-17"), "a tampered params value must fail but was: " + failure.getMessage()); } @@ -172,11 +212,12 @@ void flippedOutcomeMustFailTheEngine() { @Test void flippedSlotErrorsMustFailTheEngine() { List both = List.of( - new Expectation.SlotError("region", "missing"), new Expectation.SlotError("rate", "out_of_range")); + new Expectation.SlotError("accessPort", "missing"), + new Expectation.SlotError("latency_target", "out_of_range")); engine.run(validateCase("VAL-SENS-SLOTS", null, both)); NegotiationCase removed = validateCase( - "VAL-SENS-SLOTS-DEL", null, List.of(new Expectation.SlotError("region", "missing"))); + "VAL-SENS-SLOTS-DEL", null, List.of(new Expectation.SlotError("accessPort", "missing"))); AssertionError removalFailure = assertThrows(AssertionError.class, () -> engine.run(removed)); assertTrue( removalFailure.getMessage().contains("VAL-SENS-SLOTS-DEL") @@ -184,8 +225,8 @@ void flippedSlotErrorsMustFailTheEngine() { "a removed slot error must fail but was: " + removalFailure.getMessage()); List three = List.of( - new Expectation.SlotError("region", "missing"), - new Expectation.SlotError("rate", "out_of_range"), + new Expectation.SlotError("accessPort", "missing"), + new Expectation.SlotError("latency_target", "out_of_range"), new Expectation.SlotError("round", "out_of_range")); NegotiationCase added = validateCase("VAL-SENS-SLOTS-ADD", null, three); AssertionError additionFailure = assertThrows(AssertionError.class, () -> engine.run(added)); @@ -195,6 +236,47 @@ void flippedSlotErrorsMustFailTheEngine() { "an added slot error must fail but was: " + additionFailure.getMessage()); } + // ------------------------------------------------------------------ task-family expectations must be sensitive + + @Test + void flippedMissingParamsMustFailTheEngine() throws Exception { + NegotiationCase trueExpectation = taskValidateCase( + "VAL-SENS-MISSING", + taskOk(1, null, List.of("accessPort", "bizScenario"), Map.of("faultTime", "2026-05-11T08:21:46Z")), + TASK_SEMANTIC_MISSING_PARAMS); + engine.run(trueExpectation); + + NegotiationCase flipped = taskValidateCase( + "VAL-SENS-MISSING-FLIP", + taskOk(1, null, List.of("accessPort"), Map.of("faultTime", "2026-05-11T08:21:46Z")), + TASK_SEMANTIC_MISSING_PARAMS); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); + + assertTrue( + failure.getMessage().contains("VAL-SENS-MISSING-FLIP") + && failure.getMessage().contains("$.expect.missingParams") + && failure.getMessage().contains("bizScenario"), + "a shrunken missing-parameter set must fail but was: " + failure.getMessage()); + } + + @Test + void flippedPromptTextContainsMustFailTheEngine() throws Exception { + NegotiationCase trueExpectation = + taskFromTextCase("VAL-SENS-HEADERS", taskOk(1, List.of("## 任务类型"), null, Map.of())); + engine.run(trueExpectation); + + NegotiationCase flipped = taskFromTextCase( + "VAL-SENS-HEADERS-FLIP", taskOk(1, List.of("## 所需信息项"), null, Map.of())); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); + + assertTrue( + failure.getMessage().contains("VAL-SENS-HEADERS-FLIP") + && failure.getMessage().contains("$.expect.promptTextContains"), + "a wrong structural fragment must fail but was: " + failure.getMessage()); + } + // ------------------------------------------------------------------ engine crash guard @Test @@ -302,6 +384,72 @@ private static Expectation ok(@Nullable Integer llmCalls, @Nullable String promp false); } + /** Task-family success expectation with the prompt-text fragments and the missing-parameter set. */ + private static Expectation taskOk( + @Nullable Integer llmCalls, + @Nullable List promptTextContains, + @Nullable List missingParams, + Map params) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + null, + null, + params, + List.of(), + false, + promptTextContains == null ? List.of() : promptTextContains, + missingParams, + null); + } + + private NegotiationCase taskFromTextCase(String id, Expectation expect) { + return new NegotiationCase( + id + "/zh-CN", + id, + "task/sensitivity-probes.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + ZH_CN, + null, + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + COMPLAINT_TEXT, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_OBJECT_EMPTY))), + null, + null, + null, + expect); + } + + private NegotiationCase taskValidateCase(String id, Expectation expect, String semanticPayload) + throws Exception { + return new NegotiationCase( + id + "/zh-CN", + id, + "task/sensitivity-probes.json", + NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, + ZH_CN, + null, + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), + new PromptSource.Text(TASK_PROMPT_MISSING_PARAMS), + new ObjectMapper().readTree(TASK_PARAM_SCHEMA), + null, + expect); + } + private static Expectation failed( @Nullable String code, @Nullable Integer llmCalls, diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Expectation.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Expectation.java index 0ec2dd1b..df3bcf9a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Expectation.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Expectation.java @@ -12,6 +12,12 @@ * carries an exception name or an error code, and success-only fields never appear on a failure expectation or the * other way round. * + *

        The task-family block (Q21–Q23) adds three success-only fields: {@code promptTextContains} asserts structural + * fragments of the generated task prompt (the {@code ## 任务类型(Task Type)} style section headers), {@code + * missingParams} asserts the exact set of schema parameters the validation found missing — a missing parameter is a + * null-valued entry of the filled parameter data — and {@code paramsFromStep} ties this validation step to the earlier + * task-validation step whose missing parameters this step must have filled. + * * @param success true for the success outcome, false for the failure outcome * @param exception expected exception simple name, failure expectations only * @param code expected error code, failure expectations only @@ -20,9 +26,16 @@ * @param llmCalls expected exact LLM call count, or null when the record does not pin it * @param promptTextEqualsGolden golden fixture name the generated prompt text must equal, success expectations only * @param metadata expected metadata entries, success expectations only - * @param params expected merged parameter map, success expectations only + * @param params expected merged parameter map, success expectations only; when {@code missingParams} is present the + * entries are a subset check instead of full-map equality (the missing-parameter set carries the rest) * @param contracts behavior contract names to assert, both outcomes * @param differential true when the engine runs the from-text and from-data double assertion, success expectations + * @param promptTextContains substrings the generated task prompt text must contain, success expectations of the task + * family, empty when the record states none + * @param missingParams exact set of parameter names expected to be missing (null-valued) in the filled parameter + * data, success expectations of the task validation; null when the record does not assert the missing set + * @param paramsFromStep number of the earlier scenario step whose missing parameters this step must have filled; + * resolved by the ScenarioEngine, null on standalone cases * @since 2026-08 */ public record Expectation( @@ -30,19 +43,71 @@ public record Expectation( @Nullable String exception, @Nullable String code, List messageContains, - List slotErrors, + List slotErrors, @Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden, @Nullable Metadata metadata, Map params, List contracts, - boolean differential) { + boolean differential, + List promptTextContains, + @Nullable List missingParams, + @Nullable Integer paramsFromStep) { + + /** + * Creates an expectation without any task-family assertions; kept for the negotiation-family records and the + * inline sample cases of the engine tests. + * + * @param success true for the success outcome, false for the failure outcome + * @param exception expected exception simple name, failure expectations only + * @param code expected error code, failure expectations only + * @param messageContains substrings the failure message must contain, failure expectations only + * @param slotErrors expected slot errors as slot and code pairs, failure expectations only + * @param llmCalls expected exact LLM call count, or null when the record does not pin it + * @param promptTextEqualsGolden golden fixture name the generated prompt text must equal, success expectations only + * @param metadata expected metadata entries, success expectations only + * @param params expected merged parameter map, success expectations only + * @param contracts behavior contract names to assert, both outcomes + * @param differential true when the engine runs the from-text and from-data double assertion + */ + public Expectation( + boolean success, + @Nullable String exception, + @Nullable String code, + List messageContains, + List slotErrors, + @Nullable Integer llmCalls, + @Nullable String promptTextEqualsGolden, + @Nullable Metadata metadata, + Map params, + List contracts, + boolean differential) { + this( + success, + exception, + code, + messageContains, + slotErrors, + llmCalls, + promptTextEqualsGolden, + metadata, + params, + contracts, + differential, + List.of(), + null, + null); + } public Expectation { messageContains = List.copyOf(messageContains); slotErrors = List.copyOf(slotErrors); params = Map.copyOf(params); contracts = List.copyOf(contracts); + promptTextContains = List.copyOf(promptTextContains); + if (missingParams != null) { + missingParams = List.copyOf(missingParams); + } } /** diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationApi.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationApi.java index 85f5622e..0e4933a0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationApi.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationApi.java @@ -3,7 +3,9 @@ import org.jspecify.annotations.Nullable; /** - * The twelve {@code NegotiationContentService} APIs the corpus exercises. + * The fifteen APIs the corpus exercises: the twelve {@code NegotiationContentService} APIs plus the three task-API + * facade methods of the closed loop ({@code generateTaskPromptFromText}, {@code generateTaskPromptFromDataWithSchema} + * and {@code validateTaskPromptAndDataFilling}). * *

        The corpus references APIs by their Java method name; the later case engine dispatches on this enum, so a * misspelled API name fails at corpus load time instead of silently skipping a case. @@ -46,7 +48,25 @@ public enum NegotiationApi { VALIDATE_REJECT_PROMPT_AND_DATA_FILLING("validateRejectPromptAndDataFilling", Family.VALIDATE), /** {@code validateAbortPromptAndDataFilling}: rule gate plus semantic validation of an abort message. */ - VALIDATE_ABORT_PROMPT_AND_DATA_FILLING("validateAbortPromptAndDataFilling", Family.VALIDATE); + VALIDATE_ABORT_PROMPT_AND_DATA_FILLING("validateAbortPromptAndDataFilling", Family.VALIDATE), + + /** + * {@code generateTaskPromptFromText}: workbench-side task prompt generation from free text, one LLM slot-extraction + * call (mirrors the {@code A2ATClient} facade through the prompt-module production services). + */ + GENERATE_TASK_PROMPT_FROM_TEXT("generateTaskPromptFromText", Family.TASK), + + /** + * {@code generateTaskPromptFromDataWithSchema}: workbench-side task prompt generation from structured input plus a + * data schema, one LLM slot-extraction call. + */ + GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA("generateTaskPromptFromDataWithSchema", Family.TASK), + + /** + * {@code validateTaskPromptAndDataFilling}: OMC-side validation of a received task prompt and extraction of its + * filled parameters; a schema slot the prompt misses surfaces as a null-valued parameter. + */ + VALIDATE_TASK_PROMPT_AND_DATA_FILLING("validateTaskPromptAndDataFilling", Family.TASK); private final String jsonName; @@ -79,7 +99,7 @@ public Family family() { * Resolves a corpus JSON name into an API. * * @param jsonName corpus JSON name such as {@code generateAcceptFromText} - * @return the API, or null when the name matches none of the twelve methods + * @return the API, or null when the name matches none of the fifteen methods */ public static @Nullable NegotiationApi fromJsonName(@Nullable String jsonName) { for (NegotiationApi api : values()) { @@ -90,7 +110,7 @@ public Family family() { return null; } - /** The three API families of the negotiation content service. */ + /** The four API families of the negotiation content service plus the closed-loop task family. */ public enum Family { /** The four from-text generation methods with exactly one LLM extraction call. */ @@ -100,6 +120,12 @@ public enum Family { FROM_DATA, /** The four validation methods with a rule gate and one semantic LLM call. */ - VALIDATE + VALIDATE, + + /** + * The three closed-loop task APIs of the private-line complaint diagnosis: two task-prompt generation methods + * with one LLM slot-extraction call each, and one task-prompt validation method with one LLM semantic call. + */ + TASK } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoader.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoader.java index 7df2cdf4..463f4093 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoader.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoader.java @@ -280,6 +280,8 @@ private static void parseScenario( if (roles.stream().anyMatch(String::isBlank)) { throw error(file, id, path + ".roles", "role names must not be blank"); } + Map rolesDesc = + validateRolesDesc(raw.rolesDesc(), roles, file, id, path); if (raw.steps() == null || raw.steps().isEmpty()) { throw error(file, id, path + ".steps", "missing required field 'steps' (at least one step)"); } @@ -335,10 +337,46 @@ private static void parseScenario( resolved.expect()); steps.add(new ScenarioCase.ScenarioStep(rawStep.step(), rawStep.role(), stepCase)); } - scenarios.add(new ScenarioCase(expandedId, id, file, language, raw.summary(), roles, steps, expectFlow)); + scenarios.add(new ScenarioCase( + expandedId, id, file, language, raw.summary(), roles, steps, expectFlow, rolesDesc)); } } + /** + * Validates the role descriptions of the closed loop (Q23): every key and value must be non-blank, and every key + * must name a declared role, so a typo'd role fails at load time instead of silently never showing up in a + * failure message. + */ + private static Map validateRolesDesc( + @Nullable Map rolesDesc, List roles, String file, String id, String path) { + if (rolesDesc == null) { + return Map.of(); + } + Map validated = new LinkedHashMap<>(); + for (Map.Entry entry : rolesDesc.entrySet()) { + if (entry.getKey() == null || entry.getKey().isBlank()) { + throw error(file, id, path + ".rolesDesc", "role names must not be blank"); + } + if (entry.getValue() == null || entry.getValue().isBlank()) { + throw error( + file, + id, + path + ".rolesDesc", + "the description of role '" + entry.getKey() + "' must not be blank"); + } + if (!roles.contains(entry.getKey())) { + throw error( + file, + id, + path + ".rolesDesc", + "the described role '" + entry.getKey() + "' is not one of the declared roles " + + roles); + } + validated.put(entry.getKey(), entry.getValue()); + } + return Map.copyOf(validated); + } + private record ResolvedFields( NegotiationApi api, @Nullable ContextSpec context, @@ -559,13 +597,14 @@ private static Expectation buildExpectation(@Nullable RawExpect expect, String f "a failure expectation must name the expected exception or the expected error code"); } if (expect.promptTextEqualsGolden() != null || expect.metadata() != null || expect.params() != null - || expect.differential() != null) { + || expect.differential() != null || expect.promptTextContains() != null + || expect.missingParams() != null || expect.paramsFromStep() != null) { throw error( file, id, path + ".expect", "a failure expectation must not carry success-only fields (promptTextEqualsGolden, metadata," - + " params, differential)"); + + " params, differential, promptTextContains, missingParams, paramsFromStep)"); } } List slotErrors = new ArrayList<>(); @@ -587,6 +626,17 @@ private static Expectation buildExpectation(@Nullable RawExpect expect, String f throw error(file, id, path + ".expect.params", "the expected params must be a JSON object"); } params = MAPPER.convertValue(expect.params(), new TypeReference>() {}); + for (Map.Entry entry : params.entrySet()) { + if (entry.getValue() == null) { + throw error( + file, + id, + path + ".expect.params", + "the expected param '" + entry.getKey() + + "' carries a JSON null; a missing parameter belongs into missingParams, not" + + " into params"); + } + } } List messageContains = expect.messageContains() == null ? List.of() : List.copyOf(expect.messageContains()); @@ -597,6 +647,25 @@ private static Expectation buildExpectation(@Nullable RawExpect expect, String f if (!contracts.isEmpty() && contracts.stream().anyMatch(String::isBlank)) { throw error(file, id, path + ".expect.contracts", "contract names must not be blank"); } + List promptTextContains = + expect.promptTextContains() == null ? List.of() : List.copyOf(expect.promptTextContains()); + if (!promptTextContains.isEmpty() && promptTextContains.stream().anyMatch(String::isBlank)) { + throw error( + file, + id, + path + ".expect.promptTextContains", + "promptTextContains entries must not be blank"); + } + List missingParams = expect.missingParams(); + if (missingParams != null) { + if (missingParams.stream().anyMatch(String::isBlank)) { + throw error(file, id, path + ".expect.missingParams", "missingParams entries must not be blank"); + } + missingParams = List.copyOf(missingParams); + } + if (expect.paramsFromStep() != null && expect.paramsFromStep() < 1) { + throw error(file, id, path + ".expect.paramsFromStep", "paramsFromStep must be at least 1"); + } Expectation.Metadata metadata = expect.metadata() == null ? null : new Expectation.Metadata(expect.metadata().templateUriEcho(), expect.metadata().contextEcho()); @@ -611,7 +680,10 @@ private static Expectation buildExpectation(@Nullable RawExpect expect, String f metadata, params, contracts, - Boolean.TRUE.equals(expect.differential())); + Boolean.TRUE.equals(expect.differential()), + promptTextContains, + missingParams, + expect.paramsFromStep()); } private static ScenarioCase.@Nullable ExpectFlow buildExpectFlow( @@ -631,8 +703,18 @@ private static Expectation buildExpectation(@Nullable RawExpect expect, String f if (expectFlow.roundsUsed() != null && expectFlow.roundsUsed() < 1) { throw error(file, id, path + ".expectFlow.roundsUsed", "roundsUsed must be at least 1"); } + if (expectFlow.missingParamsFilled() != null && expectFlow.missingParamsFilled() < 1) { + throw error( + file, + id, + path + ".expectFlow.missingParamsFilled", + "missingParamsFilled must be at least 1"); + } return new ScenarioCase.ExpectFlow( - expectFlow.terminalCondition(), expectFlow.roundsUsed(), expectFlow.distinctMessages()); + expectFlow.terminalCondition(), + expectFlow.roundsUsed(), + expectFlow.distinctMessages(), + expectFlow.missingParamsFilled()); } // ------------------------------------------------------------------ $ref and $fail resolution @@ -916,6 +998,7 @@ private record RawScenario( @Nullable String summary, @Nullable List languages, @Nullable List roles, + @Nullable Map rolesDesc, @Nullable List steps, @Nullable RawExpectFlow expectFlow) {} @@ -951,12 +1034,18 @@ private record RawExpect( @Nullable RawMetadata metadata, @Nullable JsonNode params, @Nullable List contracts, - @Nullable Boolean differential) {} + @Nullable Boolean differential, + @Nullable List promptTextContains, + @Nullable List missingParams, + @Nullable Integer paramsFromStep) {} private record RawSlotError(String slot, String code) {} private record RawMetadata(@Nullable String templateUriEcho, @Nullable Boolean contextEcho) {} private record RawExpectFlow( - @Nullable String terminalCondition, @Nullable Integer roundsUsed, @Nullable Boolean distinctMessages) {} + @Nullable String terminalCondition, + @Nullable Integer roundsUsed, + @Nullable Boolean distinctMessages, + @Nullable Integer missingParamsFilled) {} } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoaderTest.java index c0df461e..09c95cc3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoaderTest.java @@ -11,6 +11,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -438,6 +439,216 @@ void failsFastOnAnUnknownKeyInsideAScenarioStep(@TempDir Path root) throws IOExc assertTrue(exception.getMessage().contains("SC-BAD-01"), exception.getMessage()); } + // ------------------------------------------------------------------ task family format (Q21–Q23) + + @Test + void loadsTaskFamilyRecordsWithTheClosedLoopExpectationFields(@TempDir Path root) throws IOException { + write(root, "shared/llm-responses.json", SHARED_RESPONSES); + write(root, "task/happy.json", """ + [ + { + "id": "TASK-FT-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "summary": "工作台从原始投诉文本生成缺参任务报文", + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "input": { + "text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"} + }, + "llm": {"script": ["{\\"slots\\": {\\"任务对象\\": \\"\\", \\"任务上下文\\": \\"投诉分类:待补充\\"}, \\"slot_errors\\": []}"]}, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextContains": ["## 任务类型", "## 任务对象"] + } + } + ] + """); + write(root, "scenarios/task-closed-loop.json", """ + [ + { + "id": "SC-TASK-01", + "languages": ["zh-CN"], + "summary": "任务缺参发现与补参提取的因果闭环", + "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateTaskPromptFromText", + "context": null, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "input": { + "text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"} + }, + "llm": {"script": ["{\\"slots\\": {\\"任务对象\\": \\"\\", \\"任务上下文\\": \\"投诉分类:待补充\\"}, \\"slot_errors\\": []}"]}, + "expect": {"outcome": "success", "llmCalls": 1} + }, + { + "step": 2, + "role": "B", + "api": "validateTaskPromptAndDataFilling", + "context": null, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": {"fromStep": 1}, + "schema": {"type": "object", "properties": {"accessPort": {"type": "string"}}}, + "llm": {"script": ["{\\"semantic_verdict\\":true,\\"errors\\":[],\\"params\\":{\\"accessPort\\":null}}"]}, + "expect": { + "outcome": "success", + "llmCalls": 1, + "missingParams": ["accessPort"], + "params": {"faultTime": "2026-05-11T08:21:46Z"} + } + }, + { + "step": 3, + "role": "B", + "api": "validateAcceptPromptAndDataFilling", + "context": {"id": "%s", "round": 2, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": {"fromStep": 1}, + "schema": {"type": "object"}, + "llm": {"script": ["{\\"semantic_verdict\\":true,\\"errors\\":[],\\"params\\":{}}"]}, + "expect": {"outcome": "success", "llmCalls": 1, "paramsFromStep": 2} + } + ], + "expectFlow": {"terminalCondition": "accept", "missingParamsFilled": 2} + } + ] + """.formatted(SESSION_ID)); + + LoadedCorpus corpus = NegotiationCaseLoader.load(root); + + NegotiationCase taskCase = caseById(corpus, "TASK-FT-01/zh-CN"); + assertEquals(NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, taskCase.api()); + assertEquals(List.of("## 任务类型", "## 任务对象"), taskCase.expect().promptTextContains()); + + ScenarioCase scenario = corpus.scenarios().get(0); + assertEquals( + Map.of("A", "工作台(client,任务发起/补数方)", "B", "OMC(server,执行/要数方,协商发起方)"), + scenario.rolesDesc()); + assertEquals("A=工作台(client,任务发起/补数方)", scenario.describeRole("A")); + NegotiationCase validationStep = scenario.steps().get(1).caseData(); + assertEquals(NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, validationStep.api()); + assertEquals(List.of("accessPort"), validationStep.expect().missingParams()); + assertEquals(Map.of("faultTime", "2026-05-11T08:21:46Z"), validationStep.expect().params()); + assertEquals(2, scenario.steps().get(2).caseData().expect().paramsFromStep()); + assertEquals(2, scenario.expectFlow().missingParamsFilled()); + } + + @Test + void failsFastOnTaskSuccessFieldsOnAFailureExpectation(@TempDir Path root) throws IOException { + write(root, "task/bad.json", """ + [ + { + "id": "TASK-BAD-01", + "api": "validateTaskPromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": {"text": "## 任务类型(Task Type)"}, + "schema": {"type": "object"}, + "llm": {"script": ["{}"]}, + "expect": { + "outcome": "failure", + "code": "validation_semantic_rejected", + "missingParams": ["accessPort"] + } + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("success-only fields"), exception.getMessage()); + assertTrue(exception.getMessage().contains("missingParams"), exception.getMessage()); + } + + @Test + void failsFastOnAJsonNullInsideParams(@TempDir Path root) throws IOException { + write(root, "task/bad.json", """ + [ + { + "id": "TASK-BAD-02", + "api": "validateTaskPromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": {"text": "## 任务类型(Task Type)"}, + "schema": {"type": "object"}, + "llm": {"script": ["{}"]}, + "expect": { + "outcome": "success", + "params": {"accessPort": null} + } + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue( + exception.getMessage().contains("accessPort") + && exception.getMessage().contains("missingParams"), + "a JSON null param must point at missingParams but was: " + exception.getMessage()); + } + + @Test + void failsFastOnParamsFromStepBelowOne(@TempDir Path root) throws IOException { + write(root, "task/bad.json", """ + [ + { + "id": "TASK-BAD-03", + "api": "validateTaskPromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": {"text": "## 任务类型(Task Type)"}, + "schema": {"type": "object"}, + "llm": {"script": ["{}"]}, + "expect": { + "outcome": "success", + "paramsFromStep": 0 + } + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("paramsFromStep"), exception.getMessage()); + } + + @Test + void failsFastOnARolesDescEntryOutsideTheDeclaredRoles(@TempDir Path root) throws IOException { + write(root, "scenarios/bad.json", """ + [ + { + "id": "SC-BAD-02", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "rolesDesc": {"C": "OSS(第三方)"}, + "steps": [ + { + "step": 1, + "api": "generateProposeFromText", + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "expect": {"outcome": "success"} + } + ] + } + ] + """.formatted(SESSION_ID)); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue( + exception.getMessage().contains("rolesDesc") + && exception.getMessage().contains("'C'"), + exception.getMessage()); + } + // ------------------------------------------------------------------ helpers private static void write(Path root, String relative, String content) throws IOException { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCase.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCase.java index 87a9dcc6..7e95cde5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCase.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCase.java @@ -1,15 +1,17 @@ package net.openan.a2at.sdk.negotiation.testdata; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import org.jspecify.annotations.Nullable; /** * One scenario record expanded for exactly one language. * - *

        A scenario is a multi-step multi-API interaction over the negotiation content layer. Every step is a full case - * record (carried as its {@link ScenarioStep#caseData()}); the scenario adds the step ordering, the acting role and - * the flow-level expectation. + *

        A scenario is a multi-step multi-API interaction over the content layer. Every step is a full case record + * (carried as its {@link ScenarioStep#caseData()}); the scenario adds the step ordering, the acting role, the role + * descriptions of the closed loop (Q23: {@code A=工作台 client} / {@code B=OMC server}) and the flow-level expectation. * * @param id expanded scenario id such as {@code SC-INFO-02/zh-CN} * @param baseId scenario record id before the language expansion, such as {@code SC-INFO-02} @@ -19,6 +21,7 @@ * @param roles role names in their first-appearance order, empty when the record states none * @param steps scenario steps numbered consecutively from 1 * @param expectFlow flow-level expectation, or null when the scenario states none + * @param rolesDesc business meaning of each role, keyed by role name, empty when the record states none * @since 2026-08 */ public record ScenarioCase( @@ -29,7 +32,32 @@ public record ScenarioCase( @Nullable String summary, List roles, List steps, - @Nullable ExpectFlow expectFlow) { + @Nullable ExpectFlow expectFlow, + Map rolesDesc) { + + /** + * Creates a scenario without role descriptions. + * + * @param id expanded scenario id such as {@code SC-INFO-02/zh-CN} + * @param baseId scenario record id before the language expansion + * @param sourceFile corpus file path relative to the corpus root + * @param language language this expansion runs in + * @param summary business-facing one-line summary, or null + * @param roles role names in their first-appearance order + * @param steps scenario steps numbered consecutively from 1 + * @param expectFlow flow-level expectation, or null + */ + public ScenarioCase( + String id, + String baseId, + String sourceFile, + String language, + @Nullable String summary, + List roles, + List steps, + @Nullable ExpectFlow expectFlow) { + this(id, baseId, sourceFile, language, summary, roles, steps, expectFlow, Map.of()); + } public ScenarioCase { Objects.requireNonNull(id, "id"); @@ -42,6 +70,52 @@ public record ScenarioCase( } roles = List.copyOf(roles); steps = List.copyOf(steps); + rolesDesc = Map.copyOf(rolesDesc); + } + + /** + * Returns the role semantics rendering of one role for failure messages and the run summary, such as + * {@code B=OMC(server,执行/要数方,协商发起方)}. + * + * @param role role name, or null for a step without one + * @return role with its description when the scenario declares one, the bare role otherwise + */ + public String describeRole(@Nullable String role) { + if (role == null) { + return "(no role)"; + } + String description = rolesDesc.get(role); + return description == null ? role : role + "=" + description; + } + + /** + * Returns the role semantics line of the whole scenario, such as + * {@code roles: A=工作台(client,任务发起/补数方), B=OMC(server,执行/要数方,协商发起方)}. + * + * @return role semantics of every declared role, or an empty string when the scenario declares none + */ + public String describeRoles() { + if (roles.isEmpty() && rolesDesc.isEmpty()) { + return ""; + } + Map ordered = new LinkedHashMap<>(); + for (String role : roles) { + ordered.put(role, rolesDesc.getOrDefault(role, "")); + } + rolesDesc.forEach((role, description) -> ordered.putIfAbsent(role, "")); + StringBuilder rendered = new StringBuilder("roles: "); + boolean first = true; + for (Map.Entry entry : ordered.entrySet()) { + if (!first) { + rendered.append(", "); + } + first = false; + rendered.append(entry.getKey()); + if (!entry.getValue().isEmpty()) { + rendered.append('=').append(entry.getValue()); + } + } + return rendered.toString(); } /** @@ -67,6 +141,25 @@ public record ScenarioStep(int step, @Nullable String role, NegotiationCase case * @param terminalCondition expected terminal condition: accept, reject, abort or exhausted * @param roundsUsed expected largest round value reached across the steps * @param distinctMessages true when the per-round prompt texts must be pairwise distinct + * @param missingParamsFilled number of the task-validation step whose missing parameters must be filled with + * values by the later steps of the flow (Q21: the {@code 缺参 → 补参} causal chain), or null when not asserted */ - public record ExpectFlow(@Nullable String terminalCondition, @Nullable Integer roundsUsed, @Nullable Boolean distinctMessages) {} + public record ExpectFlow( + @Nullable String terminalCondition, + @Nullable Integer roundsUsed, + @Nullable Boolean distinctMessages, + @Nullable Integer missingParamsFilled) { + + /** + * Creates a flow expectation without the missing-parameter causal assertion. + * + * @param terminalCondition expected terminal condition: accept, reject, abort or exhausted + * @param roundsUsed expected largest round value reached across the steps + * @param distinctMessages true when the per-round prompt texts must be pairwise distinct + */ + public ExpectFlow( + @Nullable String terminalCondition, @Nullable Integer roundsUsed, @Nullable Boolean distinctMessages) { + this(terminalCondition, roundsUsed, distinctMessages, null); + } + } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngine.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngine.java index b300c3c6..9926ca5e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngine.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngine.java @@ -6,11 +6,14 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeMap; +import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import org.jspecify.annotations.Nullable; /** - * Executes one expanded corpus scenario: a multi-step, multi-API interaction over the negotiation content layer. + * Executes one expanded corpus scenario: a multi-step, multi-API interaction over the negotiation content layer and + * the closed-loop task APIs. * *

        The scenario engine is deliberately thin (design document §3.2.1, Q11–Q13): every step is a full corpus case * executed by the {@link CaseEngine} with its own scripted LLM behavior and its exact per-step {@code llmCalls} @@ -24,8 +27,13 @@ * construction and the totals are tracked per role; *

      • the flow-level expectation {@code expectFlow}: the terminal condition (the last generated message carries the * accept/reject/abort literal, or the round limit was reached for {@code exhausted}), the largest round value reached, - * and the pairwise distinctness of the generated messages. - *
      • + * the pairwise distinctness of the generated messages, and {@code missingParamsFilled} — the closed-loop causal chain + * of Q21: the parameters a task-validation step found missing must carry values by the end of the flow; + *
      • the step-level causal expectation {@code expect.paramsFromStep}: the parameters this validation step extracted + * must fill the missing-parameter set an earlier task-validation step discovered — the {@code 缺参 → 补参} link; + *
      • role semantics (Q23): every step records its acting role, the failure messages carry the role with its business + * description, and a successful run prints one flow summary line with the role semantics. + * * * @since 2026-08 */ @@ -37,10 +45,12 @@ public final class ScenarioEngine { * Runs one expanded scenario step by step and asserts its flow-level expectation. * * @param scenario expanded corpus scenario - * @throws AssertionError when a step fails (fail-fast: later steps do not run) or the flow expectation mismatches + * @throws AssertionError when a step fails (fail-fast: later steps do not run) or a flow expectation mismatches */ public void runScenario(ScenarioCase scenario) { Map stepPromptTexts = new LinkedHashMap<>(); + Map> stepMissingParams = new LinkedHashMap<>(); + Map> stepFilledParams = new LinkedHashMap<>(); Map roleCallCounts = new LinkedHashMap<>(); int maxRound = 0; int maxRoundsLimit = 0; @@ -48,19 +58,29 @@ public void runScenario(ScenarioCase scenario) { for (ScenarioCase.ScenarioStep step : scenario.steps()) { NegotiationCase stepCase = step.caseData(); String promptOverride = resolvePromptOverride(scenario, stepCase, stepPromptTexts); - CaseEngine.CaseOutcome outcome = caseEngine.run(stepCase, promptOverride); + CaseEngine.CaseOutcome outcome = caseEngine.run(stepCase, promptOverride, true); MetadataContent message = outcome.message(); if (message != null) { stepPromptTexts.put(step.step(), message.promptText()); lastPromptText = message.promptText(); } + FilledParamData filled = outcome.filledParams(); + if (filled != null) { + stepFilledParams.put(step.step(), filled.data()); + stepMissingParams.put(step.step(), missingParamsOf(filled)); + } roleCallCounts.merge(step.role() == null ? "(no role)" : step.role(), outcome.llmCalls(), Integer::sum); if (stepCase.context() != null) { maxRound = Math.max(maxRound, stepCase.context().round()); maxRoundsLimit = Math.max(maxRoundsLimit, stepCase.context().maxRounds()); } + if (stepCase.expect().paramsFromStep() != null) { + assertParamsFromStep(scenario, step, stepMissingParams, stepFilledParams); + } } - assertExpectFlow(scenario, stepPromptTexts, maxRound, maxRoundsLimit, lastPromptText); + assertExpectFlow(scenario, stepPromptTexts, stepMissingParams, stepFilledParams, maxRound, maxRoundsLimit, + lastPromptText); + printSummary(scenario, stepMissingParams, stepFilledParams); } // ------------------------------------------------------------------ fromStep resolution @@ -80,11 +100,64 @@ private static String resolvePromptOverride( return promptText; } + // ------------------------------------------------------------------ 缺参 -> 补参 causal chain (Q21) + + /** + * Returns the missing-parameter set of one validation outcome: the names whose value in the filled parameter data + * is null — the semantic validator's explicit missing-slot signal downstream negotiation triggering relies on. + */ + private static Set missingParamsOf(FilledParamData filled) { + Set missing = new LinkedHashSet<>(); + filled.data().forEach((name, value) -> { + if (value == null) { + missing.add(name); + } + }); + return missing; + } + + /** + * Asserts the step-level causal expectation {@code expect.paramsFromStep N} of one step: the referenced step N + * must have discovered a non-empty missing-parameter set, and every one of those parameters must carry a value in + * this step's filled parameter data. + */ + private static void assertParamsFromStep( + ScenarioCase scenario, + ScenarioCase.ScenarioStep step, + Map> stepMissingParams, + Map> stepFilledParams) { + int fromStep = step.caseData().expect().paramsFromStep(); + Set missing = stepMissingParams.get(fromStep); + if (missing == null) { + throw new AssertionError(stepErrorPrefix(scenario, step) + " expect.paramsFromStep " + fromStep + + ": the referenced step produced no filled parameter data (unknown step number, a step that has" + + " not run yet, or a non-validation API)"); + } + if (missing.isEmpty()) { + throw new AssertionError(stepErrorPrefix(scenario, step) + " expect.paramsFromStep " + fromStep + + ": the referenced step found no missing parameters, so there is nothing this step could fill"); + } + Map filled = stepFilledParams.get(step.step()); + List unfilled = new ArrayList<>(); + for (String name : missing) { + if (filled == null || filled.get(name) == null) { + unfilled.add(name); + } + } + if (!unfilled.isEmpty()) { + throw new AssertionError(stepErrorPrefix(scenario, step) + " expect.paramsFromStep " + fromStep + + ": the parameters extracted by this step do not fill the missing parameters of step " + fromStep + + "; still missing: " + String.join(", ", unfilled)); + } + } + // ------------------------------------------------------------------ flow-level expectation private void assertExpectFlow( ScenarioCase scenario, Map stepPromptTexts, + Map> stepMissingParams, + Map> stepFilledParams, int maxRound, int maxRoundsLimit, @Nullable String lastPromptText) { @@ -145,6 +218,100 @@ private void assertExpectFlow( distinct.size() + " distinct message(s)"); } } + if (flow.missingParamsFilled() != null) { + assertMissingParamsFilled(scenario, flow.missingParamsFilled(), stepMissingParams, stepFilledParams); + } + } + + /** + * Asserts the flow-level causal expectation {@code expectFlow.missingParamsFilled N} (Q21): step N must have + * discovered a non-empty missing-parameter set, and the union of the filled parameter data of the later steps — + * later steps winning on key conflicts — must carry a value for every one of them. + */ + private static void assertMissingParamsFilled( + ScenarioCase scenario, + int missingStep, + Map> stepMissingParams, + Map> stepFilledParams) { + Set missing = stepMissingParams.get(missingStep); + if (missing == null) { + fail( + scenario, + "$.expectFlow.missingParamsFilled", + "a task-validation step " + missingStep + " carrying a missing-parameter set", + "step " + missingStep + " produced no filled parameter data"); + } + if (missing.isEmpty()) { + fail( + scenario, + "$.expectFlow.missingParamsFilled", + "a non-empty missing-parameter set at step " + missingStep, + "step " + missingStep + " found no missing parameters"); + } + Map filledUnion = new LinkedHashMap<>(); + for (Map.Entry> entry : new TreeMap<>(stepFilledParams).entrySet()) { + if (entry.getKey() > missingStep) { + filledUnion.putAll(entry.getValue()); + } + } + List unfilled = new ArrayList<>(); + for (String name : missing) { + if (filledUnion.get(name) == null) { + unfilled.add(name); + } + } + if (!unfilled.isEmpty()) { + fail( + scenario, + "$.expectFlow.missingParamsFilled", + "the missing parameters of step " + missingStep + " (" + String.join(", ", missing) + + ") filled by the later steps", + "still missing after step " + missingStep + ": " + String.join(", ", unfilled)); + } + } + + // ------------------------------------------------------------------ role-semantic run summary (Q23) + + /** + * Prints the one-line flow summary of a successful scenario run, carrying the role semantics and — when the + * scenario asserts the causal chain — the missing-to-filled parameter link. + */ + private static void printSummary( + ScenarioCase scenario, + Map> stepMissingParams, + Map> stepFilledParams) { + StringBuilder summary = new StringBuilder("[scenario] ") + .append(scenario.id()) + .append(" completed ") + .append(scenario.steps().size()) + .append(" step(s)"); + String roles = scenario.describeRoles(); + if (!roles.isEmpty()) { + summary.append("; ").append(roles); + } + stepMissingParams.forEach((step, missing) -> { + if (missing.isEmpty()) { + return; + } + summary.append("; step-").append(step).append(" missing params: ").append(String.join(", ", missing)); + for (int later : new TreeMap<>(stepFilledParams).keySet()) { + if (later <= step) { + continue; + } + List filledHere = new ArrayList<>(); + for (String name : missing) { + Object value = stepFilledParams.get(later).get(name); + if (value != null) { + filledHere.add(name + "=" + value); + } + } + if (!filledHere.isEmpty()) { + summary.append(" -> filled at step-").append(later).append(": ") + .append(String.join(", ", filledHere)); + } + } + }); + System.out.println(summary); } private static String terminalLiteral(String terminalCondition) { @@ -162,6 +329,15 @@ private static String errorPrefix(ScenarioCase scenario) { return scenario.sourceFile() + " [" + scenario.id() + "]"; } + /** + * Returns the failure-message prefix of one step, carrying the step number and the role semantics of the acting + * role (Q23), such as {@code scenarios/x.json [SC-1/zh-CN#step-5 (B=OMC(server,执行/要数方))]}. + */ + private static String stepErrorPrefix(ScenarioCase scenario, ScenarioCase.ScenarioStep step) { + return scenario.sourceFile() + " [" + scenario.id() + "#step-" + step.step() + " (" + + scenario.describeRole(step.role()) + ")]"; + } + private static AssertionError fail(ScenarioCase scenario, String jsonPath, String expected, String actual) { throw new AssertionError( errorPrefix(scenario) + " " + jsonPath + ": expected " + expected + " but was " + actual); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngineTest.java index 33a6a1f5..b02bfe88 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngineTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; +import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -28,17 +29,57 @@ class ScenarioEngineTest { "Negotiation-T/information-negotiation/accept-reject/v1"; private static final String ACCEPT_PAYLOAD = - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan" - + " Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}]}"; + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" + + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; private static final String PROPOSE_PAYLOAD = - "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"e.g. Songshan Lake\"},{\"name\":" - + "\"energy-saving rate guarantee target\",\"value\":\"e.g. 20Mbps\"},{\"name\":\"VLANId\"," + "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}," + + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," + "\"value\":null}],\"relationship\":\"OR\"}"; private static final String SEMANTIC_ACCEPT_PAYLOAD = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; + private static final String PRIVATE_LINE_COMPLAINT_URI = + "Task-T/network-layer/private-line-complaint/v1"; + + private static final Map CLOSED_LOOP_ROLES = Map.of( + "A", "工作台(client,任务发起/补数方)", "B", "OMC(server,执行/要数方,协商发起方)"); + + /** The workbench raw complaint (样例步骤1): no access port name and no complaint category in the text. */ + private static final String COMPLAINT_TEXT = + "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报" + + "“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; + + /** Slot-extraction payload of the closed loop's step 1: the task object stays empty. */ + private static final String TASK_SLOTS_OBJECT_EMPTY = + "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" + + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** + * Semantic payload of the closed loop's step 2: the OMC finds the access port name and the complaint category + * missing — the two null-valued parameters driving the information negotiation. + */ + private static final String TASK_SEMANTIC_MISSING_PARAMS = + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," + + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; + + /** Semantic payload of the closed loop's final step: the accept message carries both previously missing values. */ + private static final String SEMANTIC_ACCEPT_WITH_FILLED_PARAMS = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" + + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\",\"bizScenario\":\"专线质差\"}}"; + + /** Semantic payload filling only one of the two missing parameters (the unfilled-parameter red path). */ + private static final String SEMANTIC_ACCEPT_PARTIALLY_FILLED = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" + + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; + + private static final String TASK_PARAM_SCHEMA = + "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" + + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" + + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; + private final ScenarioEngine engine = new ScenarioEngine(); private final ObjectMapper mapper = new ObjectMapper(); @@ -47,7 +88,7 @@ class ScenarioEngineTest { @BeforeEach void readSchema() throws Exception { - schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"region\":{\"type\":\"string\"}}}"); + schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); } // ------------------------------------------------------------------ fromStep resolution @@ -165,6 +206,83 @@ void exhaustedRequiresTheRoundLimitToBeReached() { "a round below the limit must fail the exhausted expectation but was: " + failure.getMessage()); } + // ------------------------------------------------------------------ closed loop: 缺参 -> 补参 (Q21) + + @Test + void runsTheClosedLoopScenarioLinkingMissingParamsToTheFilledParams() throws Exception { + ScenarioCase scenario = closedLoopScenario( + SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, + new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 2), + 2); + + engine.runScenario(scenario); + } + + @Test + void paramsFromStepFailsWhenTheReferencedStepProducedNoFilledParams() throws Exception { + ScenarioCase scenario = closedLoopScenario( + SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, + null, + 1); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("expect.paramsFromStep 1") + && failure.getMessage().contains("produced no filled parameter data") + && failure.getMessage().contains("B=OMC(server,执行/要数方,协商发起方)"), + "the dangling paramsFromStep reference must fail with role semantics but was: " + + failure.getMessage()); + } + + @Test + void paramsFromStepFailsWhenAParameterStaysUnfilled() throws Exception { + ScenarioCase scenario = closedLoopScenario( + SEMANTIC_ACCEPT_PARTIALLY_FILLED, + null, + 2); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("expect.paramsFromStep 2") + && failure.getMessage().contains("still missing: bizScenario") + && failure.getMessage().contains("#step-4"), + "an unfilled missing parameter must fail the causal assertion but was: " + failure.getMessage()); + } + + @Test + void missingParamsFilledFailsWhenNoLaterStepFillsTheParameters() throws Exception { + ScenarioCase scenario = closedLoopScenario( + SEMANTIC_ACCEPT_PARTIALLY_FILLED, + new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 2), + null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("$.expectFlow.missingParamsFilled") + && failure.getMessage().contains("still missing after step 2: bizScenario"), + "the flow-level causal expectation must fail on an unfilled parameter but was: " + + failure.getMessage()); + } + + @Test + void missingParamsFilledFailsWhenTheReferencedStepFoundNothingMissing() throws Exception { + ScenarioCase scenario = closedLoopScenario( + SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, + new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 4), + 2); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("$.expectFlow.missingParamsFilled") + && failure.getMessage().contains("no missing parameters"), + "a referenced step without missing parameters must fail the flow expectation but was: " + + failure.getMessage()); + } + // ------------------------------------------------------------------ builders private static ScenarioCase scenario( @@ -174,6 +292,79 @@ private static ScenarioCase scenario( expectFlow); } + /** + * Builds the four-step closed loop of the private-line complaint diagnosis (Q21): the workbench generates the + * incomplete task prompt (step 1), the OMC validates it and finds the two missing parameters (step 2), the + * workbench accepts the information negotiation and supplies the values (step 3), and the OMC validates the accept + * message and extracts the filled parameters (step 4). + */ + private ScenarioCase closedLoopScenario( + String acceptSemanticPayload, @Nullable ExpectFlow expectFlow, @Nullable Integer paramsFromStep) + throws Exception { + NegotiationCase taskGeneration = stepCase( + "SC-TASK-01", + 1, + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + null, + PRIVATE_LINE_COMPLAINT_URI, + COMPLAINT_TEXT, + script(TASK_SLOTS_OBJECT_EMPTY), + null, + null, + ok(1, null)); + NegotiationCase taskValidation = stepCase( + "SC-TASK-01", + 2, + NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, + null, + PRIVATE_LINE_COMPLAINT_URI, + null, + script(TASK_SEMANTIC_MISSING_PARAMS), + new PromptSource.FromStep(1), + mapper.readTree(TASK_PARAM_SCHEMA), + okTask( + 1, + null, + List.of("accessPort", "bizScenario"), + Map.of("faultTime", "2026-05-11T08:21:46Z"))); + NegotiationCase acceptGeneration = stepCase( + "SC-TASK-01", + 3, + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "我方补充:接入端口名称P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。", + script(ACCEPT_PAYLOAD), + null, + null, + ok(1, null)); + NegotiationCase acceptValidation = stepCase( + "SC-TASK-01", + 4, + NegotiationApi.VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + null, + script(acceptSemanticPayload), + new PromptSource.FromStep(3), + schema, + okFromStep(1, paramsFromStep)); + return new ScenarioCase( + "SC-TASK-01/zh-CN", + "SC-TASK-01", + "scenarios/task-closed-loop.json", + ZH_CN, + null, + List.of("A", "B"), + List.of( + new ScenarioCase.ScenarioStep(1, "A", taskGeneration), + new ScenarioCase.ScenarioStep(2, "B", taskValidation), + new ScenarioCase.ScenarioStep(3, "A", acceptGeneration), + new ScenarioCase.ScenarioStep(4, "B", acceptValidation)), + expectFlow, + CLOSED_LOOP_ROLES); + } + private static ScenarioCase.ScenarioStep step(int number, String role, NegotiationCase caseData) { return new ScenarioCase.ScenarioStep(number, role, caseData); } @@ -213,7 +404,7 @@ private NegotiationCase proposeStep(String scenarioId, int round, Expectation ex NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, new ContextSpec(SESSION_ID, round, 5), INFORMATION_PROPOSE_URI, - "请补充节能区域、节能速率保障目标与VLANId信息。", + "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", script(PROPOSE_PAYLOAD), null, null, @@ -291,4 +482,46 @@ private static Expectation failed(String exception, String code, @Nullable Integ false, exception, code, List.of(), List.of(), llmCalls, null, null, java.util.Map.of(), List.of(), false); } + + /** Task-family success expectation with the missing-parameter set of the task validation. */ + private static Expectation okTask( + @Nullable Integer llmCalls, + @Nullable List promptTextContains, + @Nullable List missingParams, + Map params) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + null, + null, + params, + List.of(), + false, + promptTextContains == null ? List.of() : promptTextContains, + missingParams, + null); + } + + /** Success expectation with the step-level causal reference {@code expect.paramsFromStep}. */ + private static Expectation okFromStep(@Nullable Integer llmCalls, @Nullable Integer paramsFromStep) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + null, + null, + java.util.Map.of(), + List.of(), + false, + List.of(), + null, + paramsFromStep); + } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/TaskApiAssembler.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/TaskApiAssembler.java new file mode 100644 index 00000000..bb2cc9cf --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/TaskApiAssembler.java @@ -0,0 +1,218 @@ +package net.openan.a2at.sdk.negotiation.testdata; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; +import net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException; +import net.openan.a2at.sdk.prompt.validation.DefaultContentValidator; + +/** + * Test-domain assembly of the three closed-loop task APIs (Q21): everything except the LLM client is production + * wiring, mirroring the two facade builders without touching production code. + * + *
          + *
        • {@code validateTaskPromptAndDataFilling} mirrors {@code DefaultA2ATServerBuilder.buildTaskContentValidator} + * exactly: the same {@link DefaultContentValidator} constructor the server builder calls, with the Task-T + * extension name, the language, the LLM retry limit, the LLM client and the classpath + * {@link PromptResourceAccess};
        • + *
        • {@code generateTaskPromptFromText} and {@code generateTaskPromptFromDataWithSchema} mirror the internals of + * {@code DefaultClientPromptGenerationOrchestrator}: the same production services the client builder wires + * ({@link PromptTemplateTextLoader}, {@link DefaultStructuredPromptSlotValueExtractor} — the exact class the + * client's {@code DefaultStructuredClientSlotValueExtractor} delegates to —, {@link PromptSlotSchemaLoader} and + * {@link TaskPromptRenderer}), with the same error-code mapping and required-slot validation the client + * orchestrator applies.
        • + *
        + * + *

        The negotiation module cannot see the client and server modules (they depend on it, not the other way round), so + * the orchestrator logic is mirrored here line by line; a renamed facade method fails the {@link NegotiationApi} + * dispatch compilation, and a behavior change of the mirrored pipeline shows up as a corpus red. + * + * @since 2026-08 + */ +final class TaskApiAssembler { + + private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; + + private final String language; + + private final PromptTemplateTextLoader templateLoader; + + private final PromptSlotSchemaLoader slotSchemaLoader; + + private final DefaultStructuredPromptSlotValueExtractor slotValueExtractor; + + private final TaskPromptRenderer renderer; + + private final ContentValidator taskValidator; + + /** + * Assembles the task API wiring for one language. + * + * @param language language of the generated and validated task prompts, such as {@code zh-CN} + * @param maxAttempts retry limit of the LLM steps, mirroring the builder default of 3 + * @param llmClient scripted LLM client injected at the same seam the facade builders inject their real client + */ + TaskApiAssembler(String language, int maxAttempts, LLMClient llmClient) { + this.language = language; + PromptResourceAccess resources = PromptResourceAccess.create( + new PromptRuntimeConfig(language, PromptResourceAccess.CLASSPATH_SOURCE_TYPE, null)); + this.templateLoader = resources.templateLoader(); + this.slotSchemaLoader = resources.slotSchemaLoader(); + this.slotValueExtractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + slotSchemaLoader, + resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"), + resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md")); + this.renderer = new TaskPromptRenderer(); + this.taskValidator = new DefaultContentValidator( + StandardTemplates.TASK_EXTENSION_NAME, language, maxAttempts, llmClient, resources); + } + + /** + * Generates a task prompt with metadata from natural-language input, mirroring + * {@code A2ATClient.generateTaskPromptFromText}. + * + * @param text natural-language task input + * @param templateUri template URI identifying the target task template + * @return metadata content carrying the resolved template URI, rendered prompt text and Task-T extension URI + */ + MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) { + String templateIdentifier = templateUri.uri(); + String templateText = loadTemplate(templateIdentifier); + Map slots = extractSlots(text, templateIdentifier, templateText); + validateRequiredSlots(slots, templateIdentifier); + return new MetadataContent( + templateIdentifier, render(templateText, slots), ExtensionUriConstants.TASK_T_EXTENSION_URI); + } + + /** + * Generates a task prompt with metadata from structured input and a data schema, mirroring + * {@code A2ATClient.generateTaskPromptFromDataWithSchema}. + * + * @param data structured task input as a string-to-object map + * @param schema data schema map describing the meaning of each input field + * @param templateUri template URI identifying the target task template + * @return metadata content carrying the resolved template URI, rendered prompt text and Task-T extension URI + */ + MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) { + if (schema == null || schema.isEmpty()) { + throw new IllegalArgumentException( + "Data schema must not be empty; it describes the meaning of each input field."); + } + String templateIdentifier = templateUri.uri(); + String templateText = loadTemplate(templateIdentifier); + StructuredSlotExtractionResult result = + slotValueExtractor.extractSlots(data, templateIdentifier, language, schema); + Map slots = result.slots(); + validateRequiredSlots(slots, templateIdentifier); + return new MetadataContent( + templateIdentifier, render(templateText, slots), ExtensionUriConstants.TASK_T_EXTENSION_URI); + } + + /** + * Validates a task prompt and extracts its filled parameters, mirroring + * {@code A2ATServer.validateTaskPromptAndDataFilling}. + * + *

        A schema slot the prompt misses surfaces as a null-valued entry of the returned parameter data — that set of + * null-valued keys is the missing-parameter set the negotiation loop then fills. + * + * @param prompt rendered task prompt text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected task template + * @return filled parameter data carrying the extracted parameters; null values mark missing parameters + */ + FilledParamData validateTaskPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) { + return taskValidator.validate(prompt, schema, templateUri); + } + + // ------------------------------------------------------------------ mirrored client pipeline + + private String loadTemplate(String templateIdentifier) { + try { + return templateLoader.loadTemplate(templateIdentifier, language); + } catch (ResourceNotFoundException error) { + throw new PromptGenerationException(A2ATErrorCodes.TEMPLATE_NOT_FOUND, error.getMessage(), error); + } catch (A2ATError error) { + throw new PromptGenerationException(A2ATErrorCodes.PROMPT_RESOURCE_LOAD_ERROR, error.getMessage(), error); + } + } + + private Map extractSlots(String userInput, String templateIdentifier, String templateText) { + try { + return slotValueExtractor.extractSlots(userInput, templateIdentifier, language).slots(); + } catch (ResourceNotFoundException error) { + throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, error.getMessage(), error); + } catch (A2ATError error) { + throw new PromptGenerationException(A2ATErrorCodes.LLM_INVOCATION_FAILED, error.getMessage(), error); + } + } + + private String render(String templateText, Map slots) { + try { + return renderer.render(templateText, slots); + } catch (TaskPromptRenderException error) { + throw new PromptGenerationException(A2ATErrorCodes.RENDER_FAILED, error.getMessage(), error); + } + } + + private void validateRequiredSlots(Map slots, String templateIdentifier) { + final PromptSlotSchema schema; + try { + schema = slotSchemaLoader.loadSlotSchema(templateIdentifier, language); + } catch (A2ATError error) { + throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, error.getMessage(), error); + } + if (schema == null) { + throw new PromptGenerationException( + A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, "Slot schema not found for template: " + templateIdentifier); + } + List definitions = schema.slotDefinitions(); + if (definitions == null) { + return; + } + List failed = new ArrayList<>(); + for (PromptSlotDefinition definition : definitions) { + if (definition == null || !definition.required()) { + continue; + } + String name = definition.name(); + if (name == null) { + continue; + } + String value = slots.get(name); + if (value == null || value.trim().isEmpty()) { + failed.add(new SlotValidationError(name, "missing_required", "Required slot is missing or empty")); + } + } + if (!failed.isEmpty()) { + throw new PromptGenerationException( + A2ATErrorCodes.SLOT_VALIDATION_ERROR, + "Required slots are missing or empty: " + failed.stream() + .map(SlotValidationError::slotName).collect(Collectors.joining(", ")), + failed); + } + } +} diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md index 1b2abddc..df70c2c5 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md @@ -2,4 +2,4 @@ Accept ## Feasibility Assessment Result Confirmation -The adjusted rate guarantee target is achievable and satisfies the outage duration requirement; this negotiation is confirmed as concluded. \ No newline at end of file +The port expansion can be completed within the cutover window and satisfies the 30-minute interruption constraint; this negotiation is confirmed as concluded. \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md index da93ebb4..331ba0af 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md @@ -1,6 +1,6 @@ ## Feasibility Negotiation -Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess. +Please assess whether the dedicated-line access port expansion can be completed within the cutover window, see ; please assess. ## Under Evaluation Description -1. adjusted target: rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00 -2. existing constraint: at least 10 hours of power supply duration in outage scenarios \ No newline at end of file +1. expansion plan: expand the access port bandwidth from 100Mbps to 1000Mbps +2. existing constraint: the cutover window is limited to 02:00-06:00 on 2026-05-30 with service interruption no longer than 30 minutes \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md index 4c0b7c69..5c9d01de 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md @@ -2,4 +2,4 @@ Reject ## Feasibility Assessment Result Confirmation -The energy-saving target cannot be achieved under the existing power supply constraint; this negotiation is confirmed as concluded. \ No newline at end of file +The port expansion cannot be completed within the designated cutover window because of insufficient board slots in the aggregation room; this negotiation is confirmed as concluded. \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/information_accept.md b/a2a-t-negotiation/src/test/resources/golden/en-US/information_accept.md index 59e54f3f..e2f80374 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/information_accept.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/information_accept.md @@ -2,5 +2,5 @@ Accept ## Information Negotiation Result Content -1. energy-saving area information: Songshan Lake -2. energy-saving rate guarantee target: 20Mbps \ No newline at end of file +1. Access Port Name: P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1 +2. Complaint Category: dedicated-line quality degradation \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md b/a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md index 8a71b28c..8b4c8017 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md @@ -2,7 +2,7 @@ Please supplement the relevant content based on . ## Required Information Items -1. energy-saving area information: e.g. Songshan Lake -2. energy-saving rate guarantee target: e.g. 20Mbps -3. VLANId +1. Access Port Name: e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1 +2. Complaint Category: e.g. dedicated-line quality degradation +3. Private Line Service Identifier Relationship between missing items: OR \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/information_reject.md b/a2a-t-negotiation/src/test/resources/golden/en-US/information_reject.md index fbb5b1d6..ceade954 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/information_reject.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/information_reject.md @@ -2,4 +2,4 @@ Reject ## Information Negotiation Result Content -1. energy-saving area information: cannot be provided because the site inventory is unavailable \ No newline at end of file +1. Access Port Name: cannot be provided because the port inventory is temporarily unavailable on the workbench side \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/target_accept.md b/a2a-t-negotiation/src/test/resources/golden/en-US/target_accept.md index b4c88a88..bab21332 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/target_accept.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/target_accept.md @@ -2,4 +2,4 @@ Accept ## Target Negotiation Result Content -The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps. \ No newline at end of file +The finally confirmed intent: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15, with a peak-hour packet loss rate no higher than 1%. \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md b/a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md index 34d60714..2737ccce 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md @@ -1,10 +1,10 @@ ## Target Negotiation -The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm. +The intent understanding of the dedicated-line quality degradation repair target is listed in ; open questions remain about the latency target and the completion deadline, see ; please clarify and confirm. ## Intent Understanding Statement -1. task intent: apply wireless energy-saving optimization to the target site during 08:00-18:00 -2. rate guarantee target: at least 20Mbps during the energy-saving period +1. repair intent: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15 +2. repair target: peak-hour packet loss rate no higher than 1% ## Content to Clarify -1. area: which site is covered: Songshan Lake or another site -2. time range: is the energy-saving period 08:00-18:00 or 00:00-06:00 \ No newline at end of file +1. latency target: is the average latency target within 20ms or within 50ms +2. completion deadline: is the repair deadline 48 hours or 72 hours \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/target_reject.md b/a2a-t-negotiation/src/test/resources/golden/en-US/target_reject.md index d895b0ab..ceabbbb5 100644 --- a/a2a-t-negotiation/src/test/resources/golden/en-US/target_reject.md +++ b/a2a-t-negotiation/src/test/resources/golden/en-US/target_reject.md @@ -2,4 +2,4 @@ Reject ## Target Negotiation Result Content -The area information cannot be clarified in full because the site inventory is unavailable. \ No newline at end of file +The latency target cannot be clarified in full because the fiber cutover window is not confirmed yet. \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/abort.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/abort.md index e349739d..6eb75bee 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/abort.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/abort.md @@ -2,4 +2,4 @@ Abort ## 协商终止原因 -The negotiation round limit is reached; this negotiation is confirmed as terminated. \ No newline at end of file +已达到协商轮次上限,本次协商确认终止。 \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_accept.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_accept.md index b480b61c..b431bafc 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_accept.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_accept.md @@ -2,4 +2,4 @@ Accept ## 可行性评估结果确认 -The adjusted rate guarantee target is achievable and satisfies the outage duration requirement; this negotiation is confirmed as concluded. \ No newline at end of file +端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。 \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md index 4eb0b26b..8a6ea86b 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md @@ -1,6 +1,6 @@ ## 可行性协商 -Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess. +请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。 ## 待评估内容说明 -1. adjusted target:rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00 -2. existing constraint:at least 10 hours of power supply duration in outage scenarios \ No newline at end of file +1. 扩容方案:接入端口带宽由100Mbps扩容至1000Mbps +2. 既有约束:割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟 \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_reject.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_reject.md index dc8725f8..01086b85 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_reject.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_reject.md @@ -2,4 +2,4 @@ Reject ## 可行性评估结果确认 -The energy-saving target cannot be achieved under the existing power supply constraint; this negotiation is confirmed as concluded. \ No newline at end of file +受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。 \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_accept.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_accept.md index 6ea81cf2..c6936808 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_accept.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_accept.md @@ -2,5 +2,5 @@ Accept ## 信息协商结果内容 -1. energy-saving area information:Songshan Lake -2. energy-saving rate guarantee target:20Mbps \ No newline at end of file +1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1 +2. 投诉分类:专线质差 \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_propose.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_propose.md index 1a6b9bae..78c33a83 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_propose.md @@ -2,7 +2,7 @@ 请根据<所需信息项>补充相关内容。 ## 所需信息项 -1. energy-saving area information:e.g. Songshan Lake -2. energy-saving rate guarantee target:e.g. 20Mbps -3. VLANId +1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1 +2. 投诉分类:举例:专线质差 +3. 专线业务标识 缺失项之间的关系:OR \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_reject.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_reject.md index 94a95cbe..ac2240e3 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_reject.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_reject.md @@ -2,4 +2,4 @@ Reject ## 信息协商结果内容 -1. energy-saving area information:cannot be provided because the site inventory is unavailable \ No newline at end of file +1. 接入端口名称:无法提供,工作台侧端口资源台账暂不可查 \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_accept.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_accept.md index 67c45b44..c317f05c 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_accept.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_accept.md @@ -2,4 +2,4 @@ Accept ## 目标协商结果内容 -The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps. \ No newline at end of file +最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。 \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md index 998d11da..501e41bc 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md @@ -1,10 +1,10 @@ ## 目标协商 -The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm. +专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。 ## 意图理解陈述 -1. task intent:apply wireless energy-saving optimization to the target site during 08:00-18:00 -2. rate guarantee target:at least 20Mbps during the energy-saving period +1. 修复意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内 +2. 修复目标:高峰时段丢包率不高于1% ## 待澄清内容 -1. area:which site is covered: Songshan Lake or another site -2. time range:is the energy-saving period 08:00-18:00 or 00:00-06:00 \ No newline at end of file +1. 时延目标:平均时延目标是恢复至20ms以内还是50ms以内 +2. 完成时限:修复完成时限是48小时内还是72小时内 \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_reject.md b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_reject.md index 86d1157f..8a8f1f91 100644 --- a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_reject.md +++ b/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_reject.md @@ -2,4 +2,4 @@ Reject ## 目标协商结果内容 -The area information cannot be clarified in full because the site inventory is unavailable. \ No newline at end of file +因光缆割接窗口尚未确认,时延目标未能完全澄清。 \ No newline at end of file diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/INDEX.md b/a2a-t-negotiation/src/test/resources/negotiation-cases/INDEX.md index 6609bcd1..e2aaff51 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/INDEX.md +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/INDEX.md @@ -12,12 +12,12 @@ language expansion is listed per record and totaled in the statistics. | Metric | Value | |---|---| -| Case records | 146 | -| Scenario records (steps) | 18 (69) | -| Base records total | 164 | -| Language-expanded units | 220 | +| Case records | 151 | +| Scenario records (steps) | 18 (87) | +| Base records total | 169 | +| Language-expanded units | 226 | | Shared LLM payloads | 32 | -| Shared schema variants | 7 | +| Shared schema variants | 8 | | Error codes covered | 7 (negotiation_content_extract_failed, negotiation_invalid_input, negotiation_llm_infrastructure_error, negotiation_rule_violation, negotiation_semantic_rejected, negotiation_slot_missing, template_not_found) | | Exception-only failures | 33 (IllegalArgumentException, NullPointerException) | @@ -25,8 +25,8 @@ Priority distribution (case records; scenarios carry no priority): | Priority | Cases | |---|---| -| P0 | 75 | -| P1 | 59 | +| P0 | 76 | +| P1 | 63 | | P2 | 12 | ## Family distribution @@ -38,8 +38,9 @@ Priority distribution (case records; scenarios carry no priority): | from-text (FT) | FT-PROG | from-text/programming-errors.json | 8 | 0 | zh-CN | | from-text (FT) | FT-RETRY | from-text/retry.json | 13 | 0 | en-US, zh-CN | | from-text (FT) | FT-TPL | from-text/template-resolution.json | 4 | 0 | zh-CN | -| from-data (FD) | FD-HAPPY | from-data/happy.json | 10 | 0 | en-US, zh-CN | +| from-data (FD) | FD-HAPPY | from-data/happy.json | 11 | 0 | en-US, zh-CN | | from-data (FD) | FD-PROG | from-data/programming-errors.json | 14 | 0 | zh-CN | +| validate (VAL) | VAL-DRIFT | validate/drift-probes.json | 4 | 0 | en-US, zh-CN | | validate (VAL) | VAL-HAPPY | validate/happy.json | 10 | 0 | en-US, zh-CN | | validate (VAL) | VAL-MAP | validate/error-code-mapping.json | 5 | 0 | zh-CN | | validate (VAL) | VAL-MERGE | validate/merge-and-schema.json | 6 | 0 | en-US, zh-CN | @@ -47,38 +48,39 @@ Priority distribution (case records; scenarios carry no priority): | validate (VAL) | VAL-RETRY | validate/llm-shape-and-retry.json | 9 | 0 | en-US, zh-CN | | validate (VAL) | VAL-RULE | validate/rule-gate.json | 8 | 0 | en-US, zh-CN | | validate (VAL) | VAL-SEM | validate/semantic-verdicts.json | 17 | 0 | en-US, zh-CN | -| scenarios (SC) | SC-ERR | scenarios/boundary-flows.json | 7 | 25 | zh-CN | -| scenarios (SC) | SC-EXH | scenarios/boundary-flows.json | 2 | 9 | zh-CN | +| scenarios (SC) | SC-ERR | scenarios/boundary-flows.json | 7 | 33 | zh-CN | +| scenarios (SC) | SC-EXH | scenarios/boundary-flows.json | 2 | 11 | zh-CN | | scenarios (SC) | SC-FSB | scenarios/feasibility-flows.json | 3 | 15 | en-US, zh-CN | -| scenarios (SC) | SC-INFO | scenarios/information-flows.json | 3 | 10 | en-US, zh-CN | -| scenarios (SC) | SC-TGT | scenarios/target-flows.json | 3 | 10 | en-US, zh-CN | +| scenarios (SC) | SC-INFO | scenarios/information-flows.json | 3 | 17 | en-US, zh-CN | +| scenarios (SC) | SC-TGT | scenarios/target-flows.json | 3 | 11 | zh-CN | ## API x category coverage Cells count base records (case records plus scenario steps) per API and category; empty cells are coverage gaps. -| Category | generateProposeFromText | generateAcceptFromText | generateRejectFromText | generateAbortFromText | generateProposeFromData | generateAcceptFromData | generateRejectFromData | generateAbortFromData | validateProposePromptAndDataFilling | validateAcceptPromptAndDataFilling | validateRejectPromptAndDataFilling | validateAbortPromptAndDataFilling | Total | -|---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| FT-EXTRACT | 7 | 4 | 2 | 2 | - | - | - | - | - | - | - | - | 15 | -| FT-HAPPY | 3 | 3 | 3 | 1 | - | - | - | - | - | - | - | - | 10 | -| FT-PROG | 3 | 3 | 1 | 1 | - | - | - | - | - | - | - | - | 8 | -| FT-RETRY | 5 | 6 | 1 | 1 | - | - | - | - | - | - | - | - | 13 | -| FT-TPL | 1 | 1 | 1 | 1 | - | - | - | - | - | - | - | - | 4 | -| FD-HAPPY | - | - | - | - | 3 | 3 | 3 | 1 | - | - | - | - | 10 | -| FD-PROG | - | - | - | - | 8 | 4 | 2 | - | - | - | - | - | 14 | -| VAL-HAPPY | - | - | - | - | - | - | - | - | 3 | 3 | 3 | 1 | 10 | -| VAL-MAP | - | - | - | - | - | - | - | - | 5 | - | - | - | 5 | -| VAL-MERGE | - | - | - | - | - | - | - | - | 2 | 2 | 1 | 1 | 6 | -| VAL-PROG | - | - | - | - | - | - | - | - | 5 | 4 | 4 | 4 | 17 | -| VAL-RETRY | - | - | - | - | - | - | - | - | 4 | 4 | 1 | - | 9 | -| VAL-RULE | - | - | - | - | - | - | - | - | 8 | - | - | - | 8 | -| VAL-SEM | - | - | - | - | - | - | - | - | 10 | 2 | 2 | 3 | 17 | -| SC-ERR | 7 | 4 | - | - | - | - | - | - | 7 | 7 | - | - | 25 | -| SC-EXH | 3 | - | 2 | 1 | - | - | - | - | 2 | - | - | 1 | 9 | -| SC-FSB | 4 | 2 | 2 | 1 | - | - | - | - | 2 | 2 | 1 | 1 | 15 | -| SC-INFO | 3 | 2 | 2 | 1 | - | - | - | - | - | 2 | - | - | 10 | -| SC-TGT | 3 | 2 | 2 | 1 | - | - | - | - | - | 2 | - | - | 10 | -| **Total** | **39** | **27** | **16** | **10** | **11** | **7** | **5** | **1** | **48** | **28** | **12** | **11** | **215** | +| Category | generateProposeFromText | generateAcceptFromText | generateRejectFromText | generateAbortFromText | generateProposeFromData | generateAcceptFromData | generateRejectFromData | generateAbortFromData | validateProposePromptAndDataFilling | validateAcceptPromptAndDataFilling | validateRejectPromptAndDataFilling | validateAbortPromptAndDataFilling | generateTaskPromptFromText | generateTaskPromptFromDataWithSchema | validateTaskPromptAndDataFilling | Total | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| FT-EXTRACT | 7 | 4 | 2 | 2 | - | - | - | - | - | - | - | - | - | - | - | 15 | +| FT-HAPPY | 3 | 3 | 3 | 1 | - | - | - | - | - | - | - | - | - | - | - | 10 | +| FT-PROG | 3 | 3 | 1 | 1 | - | - | - | - | - | - | - | - | - | - | - | 8 | +| FT-RETRY | 5 | 6 | 1 | 1 | - | - | - | - | - | - | - | - | - | - | - | 13 | +| FT-TPL | 1 | 1 | 1 | 1 | - | - | - | - | - | - | - | - | - | - | - | 4 | +| FD-HAPPY | - | - | - | - | 4 | 3 | 3 | 1 | - | - | - | - | - | - | - | 11 | +| FD-PROG | - | - | - | - | 8 | 4 | 2 | - | - | - | - | - | - | - | - | 14 | +| VAL-DRIFT | - | - | - | - | - | - | - | - | 2 | 2 | - | - | - | - | - | 4 | +| VAL-HAPPY | - | - | - | - | - | - | - | - | 3 | 3 | 3 | 1 | - | - | - | 10 | +| VAL-MAP | - | - | - | - | - | - | - | - | 5 | - | - | - | - | - | - | 5 | +| VAL-MERGE | - | - | - | - | - | - | - | - | 2 | 2 | 1 | 1 | - | - | - | 6 | +| VAL-PROG | - | - | - | - | - | - | - | - | 5 | 4 | 4 | 4 | - | - | - | 17 | +| VAL-RETRY | - | - | - | - | - | - | - | - | 4 | 4 | 1 | - | - | - | - | 9 | +| VAL-RULE | - | - | - | - | - | - | - | - | 8 | - | - | - | - | - | - | 8 | +| VAL-SEM | - | - | - | - | - | - | - | - | 10 | 2 | 2 | 3 | - | - | - | 17 | +| SC-ERR | 6 | 7 | - | - | - | - | - | - | 2 | 10 | - | - | 4 | - | 4 | 33 | +| SC-EXH | 3 | - | 2 | 1 | - | - | - | - | 2 | - | - | 1 | 1 | - | 1 | 11 | +| SC-FSB | 4 | 2 | 2 | 1 | - | - | - | - | 2 | 2 | 1 | 1 | - | - | - | 15 | +| SC-INFO | 4 | 2 | 1 | 1 | - | - | - | - | - | 2 | - | 1 | 2 | 1 | 3 | 17 | +| SC-TGT | 4 | 2 | 2 | 1 | - | - | - | - | - | 2 | - | - | - | - | - | 11 | +| **Total** | **40** | **30** | **15** | **10** | **12** | **7** | **5** | **1** | **45** | **33** | **12** | **12** | **7** | **1** | **8** | **238** | ## API x expected outcome coverage @@ -86,18 +88,21 @@ Cells count base records per API and expectation; the `exception` column aggrega | API | `success` | `negotiation_content_extract_failed` | `negotiation_invalid_input` | `negotiation_llm_infrastructure_error` | `negotiation_rule_violation` | `negotiation_semantic_rejected` | `negotiation_slot_missing` | `template_not_found` | `exception` | Total | |---|---|---|---|---|---|---|---|---|---|---| -| `generateProposeFromText` | 24 | 7 | 3 | 1 | - | - | 1 | 1 | 2 | 39 | -| `generateAcceptFromText` | 15 | 2 | 3 | 1 | - | - | 3 | 1 | 2 | 27 | -| `generateRejectFromText` | 11 | - | 1 | 1 | - | - | 1 | 1 | 1 | 16 | +| `generateProposeFromText` | 25 | 7 | 3 | 1 | - | - | 1 | 1 | 2 | 40 | +| `generateAcceptFromText` | 18 | 2 | 3 | 1 | - | - | 3 | 1 | 2 | 30 | +| `generateRejectFromText` | 10 | - | 1 | 1 | - | - | 1 | 1 | 1 | 15 | | `generateAbortFromText` | 6 | - | - | - | - | - | 2 | 1 | 1 | 10 | -| `generateProposeFromData` | 3 | - | - | - | - | - | - | - | 8 | 11 | +| `generateProposeFromData` | 4 | - | - | - | - | - | - | - | 8 | 12 | | `generateAcceptFromData` | 3 | - | - | - | - | - | - | - | 4 | 7 | | `generateRejectFromData` | 3 | - | - | - | - | - | - | - | 2 | 5 | | `generateAbortFromData` | 1 | - | - | - | - | - | - | - | - | 1 | -| `validateProposePromptAndDataFilling` | 19 | - | 3 | 3 | 7 | 11 | - | 1 | 4 | 48 | -| `validateAcceptPromptAndDataFilling` | 19 | - | 2 | 3 | - | 1 | - | - | 3 | 28 | +| `validateProposePromptAndDataFilling` | 17 | - | 3 | 3 | 7 | 10 | - | 1 | 4 | 45 | +| `validateAcceptPromptAndDataFilling` | 23 | - | 2 | 3 | - | 2 | - | - | 3 | 33 | | `validateRejectPromptAndDataFilling` | 6 | - | 2 | - | - | 1 | - | - | 3 | 12 | -| `validateAbortPromptAndDataFilling` | 5 | - | 2 | - | - | 1 | - | - | 3 | 11 | +| `validateAbortPromptAndDataFilling` | 6 | - | 2 | - | - | 1 | - | - | 3 | 12 | +| `generateTaskPromptFromText` | 7 | - | - | - | - | - | - | - | - | 7 | +| `generateTaskPromptFromDataWithSchema` | 1 | - | - | - | - | - | - | - | - | 1 | +| `validateTaskPromptAndDataFilling` | 8 | - | - | - | - | - | - | - | - | 8 | ## Case index @@ -105,19 +110,19 @@ Cells count base records per API and expectation; the `exception` column aggrega | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| FT-EXTRACT-01 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 接受消息抽取结果缺少结论字段时生成失败并返回槽位缺失错误码,不发起重试 | -| FT-EXTRACT-02 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 接受消息抽取出的结论为拒绝时判定为内容与阶段矛盾,立即失败并返回输入无效错误码 | -| FT-EXTRACT-03 | `generateRejectFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 拒绝消息抽取出的结论为接受时判定为内容与阶段矛盾,立即失败并返回输入无效错误码 | -| FT-EXTRACT-04 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 信息提议消息抽取结果缺少信息项列表时生成失败并返回槽位缺失错误码 | -| FT-EXTRACT-05 | `generateProposeFromText` | en-US | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 信息项列表被抽取为字符串而非数组时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码 | -| FT-EXTRACT-06 | `generateProposeFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation_content_extract_failed) | 信息项缺少名称字段时内容抽取失败,返回抽取失败错误码 | +| FT-EXTRACT-01 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 补数接受消息抽取结果缺少结论字段时生成失败并返回槽位缺失错误码,不发起重试 | +| FT-EXTRACT-02 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 补数接受消息抽取出的结论为拒绝时判定为内容与阶段矛盾,立即失败并返回输入无效错误码 | +| FT-EXTRACT-03 | `generateRejectFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 无法提供端口名的拒绝消息抽取出的结论为接受时判定为内容与阶段矛盾,立即失败并返回输入无效错误码 | +| FT-EXTRACT-04 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 要数提议消息抽取结果缺少信息项列表时生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-05 | `generateProposeFromText` | en-US | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 要数信息项列表被抽取为字符串而非数组时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码 | +| FT-EXTRACT-06 | `generateProposeFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation_content_extract_failed) | 要数信息项缺少名称字段时内容抽取失败,返回抽取失败错误码 | | FT-EXTRACT-07 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 结论字面量大小写错误(ACCEPT 而非 Accept)时内容抽取失败,返回抽取失败错误码 | -| FT-EXTRACT-08 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 目标接受消息抽取结果缺少最终确认意图时生成失败并返回槽位缺失错误码 | -| FT-EXTRACT-09 | `generateRejectFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 目标拒绝消息抽取结果缺少拒绝原因时生成失败并返回槽位缺失错误码 | -| FT-EXTRACT-10 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 可行性提议消息抽取结果缺少动作字段时判定为内容矛盾,立即失败并返回输入无效错误码 | +| FT-EXTRACT-08 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 修复目标接受消息抽取结果缺少最终确认意图时生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-09 | `generateRejectFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 修复目标拒绝消息抽取结果缺少拒绝原因时生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-10 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 端口扩容可行性提议消息抽取结果缺少动作字段时判定为内容矛盾,立即失败并返回输入无效错误码 | | FT-EXTRACT-11 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 动作字段为未知枚举值时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码 | | FT-EXTRACT-12 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 请求可行性评估却未抽取到任何待评估内容时判定为内容矛盾,立即失败并返回输入无效错误码 | -| FT-EXTRACT-13 | `generateAbortFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 终止消息抽取结果缺少终止原因时生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-13 | `generateAbortFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 协商终止消息抽取结果缺少终止原因时生成失败并返回槽位缺失错误码 | | FT-EXTRACT-14 | `generateAbortFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation_slot_missing) | 终止原因为空白字符串时等同于缺失,生成失败并返回槽位缺失错误码 | | FT-EXTRACT-15 | `generateProposeFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation_content_extract_failed) | 信息项取值为数字而非字符串时内容抽取失败,返回抽取失败错误码 | @@ -125,28 +130,28 @@ Cells count base records per API and expectation; the `exception` column aggrega | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| FT-HAPPY-01 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 自然语言补充信息项后生成信息协商提议消息,双语渲染均与 golden 一致,且 fromText 与 fromData 两路等值 | -| FT-HAPPY-02 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 确认信息后从自然语言生成信息协商接受消息,消息含 Accept 结论字面量,且 fromText 与 fromData 两路等值 | -| FT-HAPPY-03 | `generateRejectFromText` | en-US, zh-CN | P0 | success | 信息无法提供时从自然语言生成信息协商拒绝消息,消息含 Reject 结论字面量,且 fromText 与 fromData 两路等值 | -| FT-HAPPY-04 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 首轮目标协商从自然语言生成提议消息,首轮渲染意图理解与待澄清内容两个板块,且 fromText 与 fromData 两路等值 | -| FT-HAPPY-05 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 意图确认后从自然语言生成目标协商接受消息,消息携带最终确认意图并含 Accept 结论字面量,且两路等值 | -| FT-HAPPY-06 | `generateRejectFromText` | en-US, zh-CN | P0 | success | 信息无法澄清时从自然语言生成目标协商拒绝消息,消息携带拒绝原因并含 Reject 结论字面量,且两路等值 | -| FT-HAPPY-07 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 从自然语言生成可行性协商提议消息,请求对方评估调整后的速率保障目标,且 fromText 与 fromData 两路等值 | -| FT-HAPPY-08 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 评估通过后从自然语言生成可行性协商接受消息,消息携带可行性结论并含 Accept 结论字面量,且两路等值 | -| FT-HAPPY-09 | `generateRejectFromText` | en-US, zh-CN | P0 | success | 评估不通过时从自然语言生成可行性协商拒绝消息,消息携带不可行结论并含 Reject 结论字面量,且两路等值 | -| FT-HAPPY-10 | `generateAbortFromText` | en-US, zh-CN | P0 | success | 轮次耗尽时从自然语言生成协商终止消息,走通用 abort 模板并含终止原因,且 fromText 与 fromData 两路等值 | +| FT-HAPPY-01 | `generateProposeFromText` | en-US, zh-CN | P0 | success | OMC 就投诉诊断缺失的接入端口名称与投诉分类发起信息协商,从自然语言生成提议消息,双语渲染均携带三项缺失信息与 OR 关系(golden 等值与 differential 不可表达降级为包含断言:harness 的 llm 脚本与 input.data 不随语言展开,en-US 渲染为英文模板骨架+中文数据,deviation 已记录) | +| FT-HAPPY-02 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 工作台补充接入端口名称与投诉分类后从自然语言生成信息协商接受消息,消息含 Accept 结论字面量且双语渲染均携带补充的业务信息项(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | +| FT-HAPPY-03 | `generateRejectFromText` | en-US, zh-CN | P0 | success | 工作台侧端口资源台账不可查无法提供接入端口名称时从自然语言生成信息协商拒绝消息,消息含 Reject 结论字面量且携带不可提供原因(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | +| FT-HAPPY-04 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 首轮修复目标协商从自然语言生成提议消息,就时延恢复目标与完成时限澄清,首轮渲染意图理解与待澄清内容两个板块(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | +| FT-HAPPY-05 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 时延恢复目标确认后从自然语言生成目标协商接受消息,消息携带最终确认意图并含 Accept 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | +| FT-HAPPY-06 | `generateRejectFromText` | en-US, zh-CN | P0 | success | 光缆割接窗口未确认致时延目标无法澄清时从自然语言生成目标协商拒绝消息,消息携带拒绝原因并含 Reject 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | +| FT-HAPPY-07 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 工作台请 OMC 评估接入端口扩容方案能否在割接窗口内完成,从自然语言生成可行性协商提议消息,携带扩容方案与既有约束两项待评估内容(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | +| FT-HAPPY-08 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 端口扩容可行性评估通过后从自然语言生成可行性协商接受消息,消息携带可行性结论并含 Accept 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | +| FT-HAPPY-09 | `generateRejectFromText` | en-US, zh-CN | P0 | success | 汇聚机房板卡槽位不足致端口扩容不可行时从自然语言生成可行性协商拒绝消息,消息携带不可行结论并含 Reject 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | +| FT-HAPPY-10 | `generateAbortFromText` | en-US, zh-CN | P0 | success | 投诉诊断协商轮次耗尽时从自然语言生成协商终止消息,走通用 abort 模板并含终止原因与 Abort 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | ### FT-PROG -- from-text/programming-errors.json | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| FT-PROG-01 | `generateProposeFromText` | zh-CN | P0 | (NullPointerException) | 协商上下文缺失时生成提议消息在管线入口直接失败,抛出空指针异常且不发起任何大模型调用 | -| FT-PROG-02 | `generateAcceptFromText` | zh-CN | P0 | (NullPointerException) | 模板地址缺失时生成接受消息在地址解析入口直接失败,抛出空指针异常且不发起任何大模型调用 | -| FT-PROG-03 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 调用方未传入任何自然语言内容时生成提议消息被拒绝,返回无效输入错误码且不发起任何大模型调用 | -| FT-PROG-04 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_invalid_input) | 自然语言内容仅含空白字符时生成接受消息被拒绝,返回无效输入错误码且不发起任何大模型调用 | -| FT-PROG-05 | `generateProposeFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址误用未发布的 v2 版本段时生成提议消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | -| FT-PROG-06 | `generateAcceptFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址误用 Task-T 扩展名时生成接受消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | -| FT-PROG-07 | `generateRejectFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址中协商类型拼写错误时生成拒绝消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | +| FT-PROG-01 | `generateProposeFromText` | zh-CN | P0 | (NullPointerException) | 协商上下文缺失时生成投诉诊断的信息协商提议在管线入口直接失败,抛出空指针异常且不发起任何大模型调用 | +| FT-PROG-02 | `generateAcceptFromText` | zh-CN | P0 | (NullPointerException) | 模板地址缺失时生成信息协商接受消息在地址解析入口直接失败,抛出空指针异常且不发起任何大模型调用 | +| FT-PROG-03 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 调用方未传入任何投诉诊断自然语言内容时生成提议消息被拒绝,返回无效输入错误码且不发起任何大模型调用 | +| FT-PROG-04 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_invalid_input) | 投诉诊断补充内容仅含空白字符时生成接受消息被拒绝,返回无效输入错误码且不发起任何大模型调用 | +| FT-PROG-05 | `generateProposeFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址误用未发布的 v2 版本段时生成信息协商提议在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | +| FT-PROG-06 | `generateAcceptFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址误用 Task-T 扩展名时生成信息协商接受消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | +| FT-PROG-07 | `generateRejectFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址中协商类型拼写错误时生成信息协商拒绝消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | | FT-PROG-08 | `generateAbortFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址中阶段段未知时生成协商终止消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | ### FT-RETRY -- from-text/retry.json @@ -159,10 +164,10 @@ Cells count base records per API and expectation; the `exception` column aggrega | FT-RETRY-04 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_llm_infrastructure_error) | 最大尝试次数为1时底层通道抛出运行时异常,不重试即以基础设施错误码失败且异常细节不外泄 | | FT-RETRY-05 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_llm_infrastructure_error) | 最大尝试次数为2时底层先后出现传输异常与错误响应,尝试耗尽后仍以基础设施错误码失败 | | FT-RETRY-06 | `generateRejectFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_llm_infrastructure_error) | 最大尝试次数为3时基础设施错误持续出现,三次尝试耗尽后以基础设施错误码失败 | -| FT-RETRY-07 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 首次返回不可解析内容后第二次返回合法接受内容,重试后成功生成与 golden 一致的接受消息 | -| FT-RETRY-08 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 首次底层传输异常后第二次抽取成功,重试后生成与 golden 一致的信息提议消息且异常细节不外泄 | -| FT-RETRY-09 | `generateAcceptFromText` | en-US, zh-CN | P1 | success | 前两次分别出现基础设施错误与不可解析内容,第三次抽取成功,在最后一次尝试额度内恢复生成接受消息 | -| FT-RETRY-10 | `generateAbortFromText` | en-US, zh-CN | P1 | success | 终止消息首次抽取到空白内容后第二次成功,重试后生成含终止原因的协商终止消息 | +| FT-RETRY-07 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 首次返回不可解析内容后第二次返回合法补数接受内容,重试后成功生成携带接入端口名称与投诉分类的接受消息(共享应答单一语种,双语 golden 等值断言降级为内容包含) | +| FT-RETRY-08 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 首次底层传输异常后第二次抽取成功,重试后生成向工作台补充三项缺失信息的要数提议消息且异常细节不外泄(共享应答单一语种,双语 golden 等值断言降级为内容包含) | +| FT-RETRY-09 | `generateAcceptFromText` | en-US, zh-CN | P1 | success | 前两次分别出现基础设施错误与不可解析内容,第三次抽取成功,在最后一次尝试额度内恢复生成修复目标接受消息(共享应答单一语种,双语 golden 等值断言降级为内容包含) | +| FT-RETRY-10 | `generateAbortFromText` | en-US, zh-CN | P1 | success | 协商终止消息首次抽取到空白内容后第二次成功,重试后生成含终止原因的协商终止消息(共享应答单一语种,双语 golden 等值断言降级为内容包含) | | FT-RETRY-11 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 抽取结果缺少结论字段属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败 | | FT-RETRY-12 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 结论与阶段矛盾属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败 | | FT-RETRY-13 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_content_extract_failed) | 两次失败形态不同(基础设施错误后接不可解析内容)时,耗尽后重抛最后一次失败的原始错误码而非统一的耗尽错误码 | @@ -171,25 +176,26 @@ Cells count base records per API and expectation; the `exception` column aggrega | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| FT-TPL-01 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成失败并返回 template_not_found 错误码,且不发起任何 LLM 调用 | -| FT-TPL-02 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成提议消息失败并返回 template_not_found 错误码,模板加载发生在重试循环之外,即使配置了重试次数也不重试且不发起任何 LLM 调用 | -| FT-TPL-03 | `generateRejectFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成拒绝消息失败并返回 template_not_found 错误码,且不发起任何 LLM 调用 | +| FT-TPL-01 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成投诉诊断信息协商接受消息失败并返回 template_not_found 错误码,且不发起任何 LLM 调用 | +| FT-TPL-02 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成要数提议消息失败并返回 template_not_found 错误码,模板加载发生在重试循环之外,即使配置了重试次数也不重试且不发起任何 LLM 调用 | +| FT-TPL-03 | `generateRejectFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成信息协商拒绝消息失败并返回 template_not_found 错误码,且不发起任何 LLM 调用 | | FT-TPL-04 | `generateAbortFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成协商终止消息失败并返回 template_not_found 错误码,通用终止模板同样无法加载且不发起任何 LLM 调用 | ### FD-HAPPY -- from-data/happy.json | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| FD-HAPPY-01 | `generateProposeFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成信息协商提议消息,双语渲染均与 golden 一致,全程零 LLM 调用 | -| FD-HAPPY-02 | `generateAcceptFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成信息协商接受消息,消息含 Accept 结论字面量,双语渲染均与 golden 一致,全程零 LLM 调用 | -| FD-HAPPY-03 | `generateRejectFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成信息协商拒绝消息,消息含 Reject 结论字面量,双语渲染均与 golden 一致,全程零 LLM 调用 | -| FD-HAPPY-04 | `generateProposeFromData` | en-US, zh-CN | P0 | success | 首轮目标协商从结构化数据直接生成提议消息,首轮渲染意图理解与待澄清内容两个板块,全程零 LLM 调用 | -| FD-HAPPY-05 | `generateAcceptFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成目标协商接受消息,消息携带最终确认意图并含 Accept 结论字面量,全程零 LLM 调用 | -| FD-HAPPY-06 | `generateRejectFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成目标协商拒绝消息,消息携带拒绝原因并含 Reject 结论字面量,全程零 LLM 调用 | -| FD-HAPPY-07 | `generateProposeFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成可行性协商提议消息,请求对方评估调整后的速率保障目标,全程零 LLM 调用 | -| FD-HAPPY-08 | `generateAcceptFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成可行性协商接受消息,消息携带可行性结论并含 Accept 结论字面量,全程零 LLM 调用 | -| FD-HAPPY-09 | `generateRejectFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成可行性协商拒绝消息,消息携带不可行结论并含 Reject 结论字面量,全程零 LLM 调用 | -| FD-HAPPY-10 | `generateAbortFromData` | en-US, zh-CN | P0 | success | 结构化数据直接生成协商终止消息,走通用 abort 模板并含终止原因,双语渲染均与 golden 一致,全程零 LLM 调用 | +| FD-HAPPY-01 | `generateProposeFromData` | en-US, zh-CN | P0 | success | OMC 以结构化数据直接生成信息协商提议消息,向工作台索要接入端口名称与投诉分类等缺失信息项,全程零 LLM 调用 | +| FD-HAPPY-02 | `generateAcceptFromData` | en-US, zh-CN | P0 | success | 工作台以结构化数据直接生成信息协商接受消息,补齐接入端口名称与投诉分类并携带 Accept 结论字面量,全程零 LLM 调用 | +| FD-HAPPY-03 | `generateRejectFromData` | en-US, zh-CN | P0 | success | 工作台以结构化数据直接生成信息协商拒绝消息,以端口资源台账暂不可查为由无法提供接入端口名称并携带 Reject 结论字面量,全程零 LLM 调用 | +| FD-HAPPY-04 | `generateProposeFromData` | en-US, zh-CN | P0 | success | 首轮目标协商从结构化数据直接生成提议消息,围绕专线质差的时延恢复目标与修复完成时限向对端澄清,全程零 LLM 调用 | +| FD-HAPPY-05 | `generateAcceptFromData` | en-US, zh-CN | P0 | success | 以结构化数据直接生成目标协商接受消息,最终确认专线时延恢复至20ms以内与高峰丢包率目标并携带 Accept 结论字面量,全程零 LLM 调用 | +| FD-HAPPY-06 | `generateRejectFromData` | en-US, zh-CN | P0 | success | 以结构化数据直接生成目标协商拒绝消息,以光缆割接窗口尚未确认为由拒绝时延目标并携带 Reject 结论字面量,全程零 LLM 调用 | +| FD-HAPPY-07 | `generateProposeFromData` | en-US, zh-CN | P0 | success | 以结构化数据直接生成可行性协商提议消息,请求对端评估接入端口带宽扩容能否在割接窗口内完成,全程零 LLM 调用 | +| FD-HAPPY-08 | `generateAcceptFromData` | en-US, zh-CN | P0 | success | 以结构化数据直接生成可行性协商接受消息,确认端口扩容可在割接窗口内完成且满足业务中断约束并携带 Accept 结论字面量,全程零 LLM 调用 | +| FD-HAPPY-09 | `generateRejectFromData` | en-US, zh-CN | P0 | success | 以结构化数据直接生成可行性协商拒绝消息,以汇聚机房板卡槽位不足为由判定扩容不可行并携带 Reject 结论字面量,全程零 LLM 调用 | +| FD-HAPPY-10 | `generateAbortFromData` | en-US, zh-CN | P0 | success | 以结构化数据直接生成协商终止消息,以达到协商轮次上限为由终止本次专线投诉诊断协商并携带 Abort 结论字面量,全程零 LLM 调用 | +| FD-HAPPY-11 | `generateProposeFromData` | en-US, zh-CN | P0 | success | 接入端口名称与投诉分类两项缺失信息均为必填时以与关系生成信息协商提议消息,两项同时列为缺失项,全程零 LLM 调用 | ### FD-PROG -- from-data/programming-errors.json @@ -210,20 +216,29 @@ Cells count base records per API and expectation; the `exception` column aggrega | FD-PROG-13 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 模板 URI 类型段指向未知的 unknown-negotiation 类型时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用 | | FD-PROG-14 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 提议方法收到的模板 URI 阶段段为 accept-reject 而非 propose 时生成以参数异常失败,提示 URI 阶段与方法不符且不发起任何 LLM 调用 | +### VAL-DRIFT -- validate/drift-probes.json + +| Id | API | Languages | Priority | Expectation | Summary | +|---|---|---|---|---|---| +| VAL-DRIFT-01 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 对端补数接受报文的条目编号与分隔标点相对模板惯例存在差异(1.与冒号改为1、与空格),仍应校验通过并提取接入端口名称与投诉分类 | +| VAL-DRIFT-02 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 对端要数提议报文将缺失项关系从OR字面量改写为文字化措辞,语序微变仍应校验通过并提取投诉参数 | +| VAL-DRIFT-03 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 对端英文补数接受报文的条目编号改为右括号、键值分隔改为长破折号,措辞漂移仍应校验通过并提取接入端口名称与投诉分类 | +| VAL-DRIFT-04 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 对端英文要数提议报文将缺失项关系改写为完整句子并补充限定词,措辞漂移仍应校验通过并提取投诉参数 | + ### VAL-HAPPY -- validate/happy.json | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| VAL-HAPPY-01 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验信息协商提议消息并提取参数,合并结果含上下文三键与业务参数 region | -| VAL-HAPPY-02 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验信息协商接受消息并提取参数,合并结果含上下文三键与业务参数 region | -| VAL-HAPPY-03 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验信息协商拒绝消息并提取参数,合并结果含上下文三键与业务参数 region | -| VAL-HAPPY-04 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验目标协商提议消息并提取参数,合并结果含上下文三键与站点、速率保障参数 | -| VAL-HAPPY-05 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验目标协商接受消息并提取参数,合并结果含上下文三键与站点、速率保障参数 | -| VAL-HAPPY-06 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验目标协商拒绝消息并提取参数,合并结果含上下文三键与站点、速率保障参数 | -| VAL-HAPPY-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验可行性协商提议消息并提取参数,合并结果含上下文三键与可行性结论参数 | -| VAL-HAPPY-08 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验可行性协商接受消息并提取参数,合并结果含上下文三键与可行性结论参数 | -| VAL-HAPPY-09 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验可行性协商拒绝消息并提取参数,合并结果含上下文三键与可行性结论参数 | -| VAL-HAPPY-10 | `validateAbortPromptAndDataFilling` | en-US, zh-CN | P0 | success | 校验协商终止消息并提取参数,合并结果含上下文三键与终止原因参数 | +| VAL-HAPPY-01 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 以OMC发来的信息协商提议报文全文为输入,校验通过并合并出上下文三键与接入端口名称、投诉分类参数 | +| VAL-HAPPY-02 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | golden 回归:工作台补数接受报文的规范模板形态校验通过,合并参数与对端报文主线用例完全等值 | +| VAL-HAPPY-03 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P0 | success | 以工作台发来的信息协商拒绝报文全文为输入,校验通过并合并出上下文三键与语义层提取的投诉参数 | +| VAL-HAPPY-04 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 以对端发来的修复目标协商提议报文全文为输入,校验通过并合并出上下文三键与时延目标、修复时限参数 | +| VAL-HAPPY-05 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | golden 回归:修复目标确认接受报文的规范模板形态校验通过,合并参数与对端报文主线用例完全等值 | +| VAL-HAPPY-06 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P0 | success | 以对端发来的修复目标协商拒绝报文全文为输入(割接窗口未确认),校验通过并合并出上下文三键与时延目标、修复时限参数 | +| VAL-HAPPY-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 以对端发来的端口扩容可行性协商提议报文全文为输入,校验通过并合并出上下文三键与扩容可行性结论参数 | +| VAL-HAPPY-08 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 以对端发来的端口扩容可行性确认接受报文全文为输入,校验通过并合并出上下文三键与扩容可行性结论参数 | +| VAL-HAPPY-09 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P0 | success | 以对端发来的端口扩容不可行拒绝报文全文为输入(板卡槽位不足),校验通过并合并出上下文三键与扩容可行性结论参数 | +| VAL-HAPPY-10 | `validateAbortPromptAndDataFilling` | en-US, zh-CN | P0 | success | golden 回归:轮次耗尽协商终止报文的规范模板形态校验通过,合并出上下文三键与终止原因参数 | ### VAL-MAP -- validate/error-code-mapping.json @@ -231,7 +246,7 @@ Cells count base records per API and expectation; the `exception` column aggrega |---|---|---|---|---|---| | VAL-MAP-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 消息未携带协商上下文元数据时校验判定其不是协商消息,内部无效输入错误码对外映射为协商无效输入错误且不发起任何大模型调用 | | VAL-MAP-02 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商轮次超限触发的内部规则违反错误码对外映射为协商规则违反错误码,并保留轮次越界槽位错误 | -| VAL-MAP-03 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型语义校验否决消息时内部语义拒绝错误码对外映射为协商语义拒绝错误码,并保留大模型报告的槽位错误 | +| VAL-MAP-03 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型语义校验否决要数提议消息时内部语义拒绝错误码对外映射为协商语义拒绝错误码,并保留大模型报告的接入端口缺失槽位错误 | | VAL-MAP-04 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | 大模型服务连续两次不可用并耗尽重试后,内部基础设施错误码对外映射为协商大模型基础设施错误码,错误详情以 _llm 伪槽位保留内部错误码 | | VAL-MAP-05 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, template_not_found) | 语义校验提示词资源缺失时内部资源未找到错误码对外映射为 template_not_found 错误码,原始资源异常不透出且不发起任何大模型调用 | @@ -239,10 +254,10 @@ Cells count base records per API and expectation; the `exception` column aggrega | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| VAL-MERGE-01 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 业务参数与上下文 id、round 键冲突时上下文参数优先,LLM 提取的错误取值不覆盖规则层结果 | -| VAL-MERGE-02 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 调用方未声明 type 关键字的参数 schema 被自动包装为对象 schema,语义校验与参数提取不受影响 | -| VAL-MERGE-03 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验提议消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | -| VAL-MERGE-04 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验接受消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | +| VAL-MERGE-01 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 业务参数与上下文 id、round 键冲突时上下文参数优先,大模型提取的错误取值不覆盖规则层结果 | +| VAL-MERGE-02 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 调用方未声明 type 关键字的参数 schema 被自动包装为对象 schema,补数接受报文的语义校验与投诉参数提取不受影响 | +| VAL-MERGE-03 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验要数提议消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | +| VAL-MERGE-04 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验补数接受消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | | VAL-MERGE-05 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验拒绝消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | | VAL-MERGE-06 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验终止消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | @@ -250,35 +265,35 @@ Cells count base records per API and expectation; the `exception` column aggrega | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| VAL-PROG-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验提议消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | -| VAL-PROG-02 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验提议消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针) | -| VAL-PROG-03 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验接受消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | -| VAL-PROG-04 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验接受消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针) | -| VAL-PROG-05 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验拒绝消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | -| VAL-PROG-06 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验拒绝消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针) | +| VAL-PROG-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验要数提议消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | +| VAL-PROG-02 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验要数提议消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针) | +| VAL-PROG-03 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验补数接受消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | +| VAL-PROG-04 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验补数接受消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针) | +| VAL-PROG-05 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验修复目标拒绝消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | +| VAL-PROG-06 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验修复目标拒绝消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针) | | VAL-PROG-07 | `validateAbortPromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验终止消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | | VAL-PROG-08 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验终止消息时消息文本为空白,抛出参数异常(abort 方法的 javadoc 未声明该行为,doc-gap 探针) | -| VAL-PROG-09 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验提议消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | -| VAL-PROG-10 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验接受消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | -| VAL-PROG-11 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验拒绝消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | +| VAL-PROG-09 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验要数提议消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | +| VAL-PROG-10 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验补数接受消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | +| VAL-PROG-11 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验修复目标拒绝消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | | VAL-PROG-12 | `validateAbortPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验终止消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | -| VAL-PROG-13 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验提议消息时模板 URI 声明的是接受-拒绝阶段,阶段不匹配抛出参数异常且不调用大模型 | -| VAL-PROG-14 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验接受消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型 | -| VAL-PROG-15 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验拒绝消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型 | +| VAL-PROG-13 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验要数提议消息时模板 URI 声明的是接受-拒绝阶段,阶段不匹配抛出参数异常且不调用大模型 | +| VAL-PROG-14 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验补数接受消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型 | +| VAL-PROG-15 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验修复目标拒绝消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型 | | VAL-PROG-16 | `validateAbortPromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验终止消息时模板 URI 声明的是类型化提议模板而非通用终止模板,抛出参数异常且不调用大模型 | -| VAL-PROG-17 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验提议消息时误用通用终止模板 URI,类型化阶段不接受 common 段,抛出参数异常 | +| VAL-PROG-17 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验要数提议消息时误用通用终止模板 URI,类型化阶段不接受 common 段,抛出参数异常 | ### VAL-RETRY -- validate/llm-shape-and-retry.json | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| VAL-RETRY-01 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 语义校验第一次遭遇 LLM 基础设施错误后自动重试,第二次返回合法判定并成功提取参数 | -| VAL-RETRY-02 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 语义返回缺少 negotiation_type 键的无效结构时触发重试,第二次结构合法并成功提取参数 | +| VAL-RETRY-01 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 工作台补数接受报文的语义校验第一次遭遇 LLM 基础设施错误后自动重试,第二次返回合法判定并成功提取投诉参数 | +| VAL-RETRY-02 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 要数提议报文的语义返回缺少 negotiation_type 键的无效结构时触发重试,第二次结构合法并成功提取投诉参数 | | VAL-RETRY-03 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | 语义返回连续三种无效结构时重试至耗尽,最终携带 _llm 伪槽位以 LLM 基础设施错误失败 | | VAL-RETRY-04 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | maxAttempts 配置为 1 时语义结构错误不获重试机会,单次调用即以 LLM 基础设施错误失败 | | VAL-RETRY-05 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | LLM 基础设施错误连续出现时按 maxAttempts=2 重试两次后耗尽,携带 _llm 伪槽位失败 | -| VAL-RETRY-06 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P1 | success | 语义返回无法解析的非 JSON 内容后按 maxAttempts=2 重试,第二次返回合法判定并成功 | -| VAL-RETRY-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 连续两次空内容与非 JSON 应答后第三次成功,重试预算 maxAttempts=3 恰好用满 | +| VAL-RETRY-06 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P1 | success | 工作台拒绝报文的语义返回无法解析的非 JSON 内容后按 maxAttempts=2 重试,第二次返回合法判定并成功 | +| VAL-RETRY-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 要数提议报文的语义连续两次空内容与非 JSON 应答后第三次成功,重试预算 maxAttempts=3 恰好用满 | | VAL-RETRY-08 | `validateAcceptPromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | 语义步骤抛出非 LLMError 的原始运行时异常时不重试,单次调用即包装为 LLM 基础设施错误失败 | | VAL-RETRY-09 | `validateAcceptPromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | 语义步骤返回空响应(null)时不重试,单次调用即包装为 LLM 基础设施错误失败 | @@ -289,33 +304,33 @@ Cells count base records per API and expectation; the `exception` column aggrega | VAL-RULE-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商会话标识仅 8 位而非 36 位 UUID 时,消息在规则校验阶段即被拒绝并报告标识格式槽位错误,不发起任何大模型调用 | | VAL-RULE-02 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商会话标识为 35 位(末段缺少一位十六进制位)时同样被规则门拒绝,UUID 长度边界从严执行 | | VAL-RULE-03 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商会话标识长度与 8-4-4-4-12 分段均正确但首段含非十六进制字符 g 时,规则门仍按 UUID 格式拒绝 | -| VAL-RULE-04 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 当前轮次等于最大轮次预算(第 5 轮即最后一轮)时消息仍可通过规则校验并正常提取参数,只有超出预算才被拒绝 | +| VAL-RULE-04 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 当前轮次等于最大轮次预算(第 5 轮即最后一轮)时OMC要数提议报文仍可通过规则校验并正常提取投诉参数,只有超出预算才被拒绝 | | VAL-RULE-05 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商轮次超出最大轮次预算(第 6 轮对 5 轮上限)时规则门拒绝消息并报告轮次越界槽位错误,不发起任何大模型调用 | | VAL-RULE-06 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_rule_violation) | 会话标识非法且轮次超限同时发生时,规则门一次性报告标识格式与轮次越界两条槽位错误,不发起任何大模型调用 | -| VAL-RULE-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 单轮预算(最大轮次为 1)的第 1 轮消息可通过规则校验并正常提取参数,轮次下限边界同样放行 | -| VAL-RULE-08 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P2 | success | 协商会话标识使用大写十六进制 UUID 时规则门同样放行,消息正常校验并提取参数,合并结果原样保留大写标识 | +| VAL-RULE-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 单轮预算(最大轮次为 1)的第 1 轮要数提议报文可通过规则校验并正常提取投诉参数,轮次下限边界同样放行 | +| VAL-RULE-08 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P2 | success | 协商会话标识使用大写十六进制 UUID 时规则门同样放行,要数提议报文正常校验并提取投诉参数,合并结果原样保留大写标识 | ### VAL-SEM -- validate/semantic-verdicts.json | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| VAL-SEM-01 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 语义判定通过且上报类型与信息协商模板一致时,接受消息校验成功并合并出区域参数 | -| VAL-SEM-02 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 语义判定通过且上报类型与目标协商模板一致时,接受消息校验成功并合并出站点与速率保障参数 | -| VAL-SEM-03 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 语义判定通过且上报类型与可行性协商模板一致时,提议消息校验成功并合并出可行性结论参数 | -| VAL-SEM-04 | `validateAbortPromptAndDataFilling` | en-US, zh-CN | P0 | success | 终止消息语义判定通过且未上报协商类型时校验成功,合并出终止原因参数 | -| VAL-SEM-05 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P1 | success | 语义判定通过且类型一致时,拒绝侧消息同样校验成功,类型一致性不区分接受或拒绝动作 | +| VAL-SEM-01 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 工作台补数接受报文语义判定通过且上报类型与信息协商模板一致时,校验成功并合并出接入端口名称与投诉分类参数 | +| VAL-SEM-02 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 修复目标确认接受报文语义判定通过且上报类型与目标协商模板一致时,校验成功并合并出时延目标与修复时限参数 | +| VAL-SEM-03 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 端口扩容可行性提议报文语义判定通过且上报类型与可行性协商模板一致时,校验成功并合并出扩容可行性结论参数 | +| VAL-SEM-04 | `validateAbortPromptAndDataFilling` | en-US, zh-CN | P0 | success | 轮次耗尽终止报文语义判定通过且未上报协商类型时校验成功,合并出终止原因参数 | +| VAL-SEM-05 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P1 | success | 工作台无法提供端口名的拒绝报文语义判定通过且类型一致时校验成功,类型一致性不区分接受或拒绝动作 | | VAL-SEM-06 | `validateAbortPromptAndDataFilling` | en-US, zh-CN | P2 | success | 公共终止模板未声明协商类型,判定通过时即使大模型上报了类型也照常校验成功 | -| VAL-SEM-07 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型判定通过但上报成目标协商类型时,信息协商提议被语义拒绝并指出类型不一致 | -| VAL-SEM-08 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型判定通过但未上报协商类型时,信息协商提议被语义拒绝并按模板类型报类型不一致 | -| VAL-SEM-09 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型判定通过但上报未知协商类型字符串时,信息协商提议被语义拒绝 | -| VAL-SEM-10 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型上报的协商类型仅大小写不同也被视为类型不一致,信息协商提议被语义拒绝 | -| VAL-SEM-11 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型语义判定不通过时,信息协商提议被拒绝并返回缺失的区域参数槽位错误 | +| VAL-SEM-07 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型判定通过但上报成目标协商类型时,信息协商要数提议被语义拒绝并指出类型不一致 | +| VAL-SEM-08 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型判定通过但未上报协商类型时,信息协商要数提议被语义拒绝并按模板类型报类型不一致 | +| VAL-SEM-09 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型判定通过但上报未知协商类型字符串时,信息协商要数提议被语义拒绝 | +| VAL-SEM-10 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型上报的协商类型仅大小写不同也被视为类型不一致,信息协商要数提议被语义拒绝 | +| VAL-SEM-11 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型语义判定不通过时,信息协商要数提议被拒绝并返回缺失的接入端口参数槽位错误 | | VAL-SEM-12 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 语义判定不通过时不再核对协商类型,拒绝侧消息只返回大模型报告的槽位错误 | | VAL-SEM-13 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 语义判定不通过且未报告任何槽位错误时,校验仍以语义拒绝失败且槽位错误为空 | -| VAL-SEM-14 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 语义判定不通过时已提取的参数被丢弃,校验以语义拒绝失败且不合并任何业务参数 | +| VAL-SEM-14 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 语义判定不通过时已提取的投诉参数被丢弃,校验以语义拒绝失败且不合并任何业务参数 | | VAL-SEM-15 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 终止消息语义判定不通过时被拒绝并返回缺失终止原因的槽位错误 | | VAL-SEM-16 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 语义拒绝不触发重试:脚本备有第二次通过的应答也不会被消费,LLM 调用数恒为 1 | -| VAL-SEM-17 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P2 | success | 语义判定通过时大模型附带的槽位错误被忽略,校验成功且只合并上下文三键 | +| VAL-SEM-17 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P2 | success | 语义判定通过时大模型附带的槽位错误被忽略,要数提议报文校验成功且只合并上下文三键 | ## Scenario index @@ -323,42 +338,42 @@ Cells count base records per API and expectation; the `exception` column aggrega | Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | |---|---|---|---|---|---|---| -| SC-ERR-01 | `generateProposeFromText`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=1, distinct-messages | - | 流程中错误恢复:大模型内容抽取首次返回不可解析结果,重试第二次成功后协商流程继续推进直至接受闭环 | -| SC-ERR-02 | `generateProposeFromText`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | (negotiation_semantic_rejected) | 流程中错误恢复:round 1 提议因缺少节能区域信息被语义校验拒绝,round 2 补充内容重新提议后通过并接受 | -| SC-ERR-03 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2 | - | 重传幂等:同一条接受消息被重复校验两次,两次提取的参数结果完全一致,重传不产生副作用 | -| SC-ERR-04 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=2 | - | 乱序失同步探针:B 以滞后的 round 1 上下文校验 A 在 round 2 生成的提议,规则门不拦截失同步(doc-gap 探针) | -| SC-ERR-05 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=1 | (negotiation_semantic_rejected) | 截断消息探针:传输中被截断的提议消息缺失后两项信息,语义校验以槽位缺失为由拒绝 | -| SC-ERR-06 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2 | (negotiation_semantic_rejected) | 编码边界探针:带 UTF-8 BOM 前缀或全角标点的接受消息可正常校验提取,GBK 乱码形态的消息被语义校验拒绝 | -| SC-ERR-07 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=1, distinct-messages | - | 双会话交错:信息协商与目标协商两个会话步骤交替执行,各自提取的上下文参数互不串扰 | +| SC-ERR-01 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 闭环中错误恢复:工作台首次生成任务报文成功,OMC 校验任务报文时语义校验首次返回不可解析内容,重试第二次成功后闭环继续推进,直至缺参被补齐且接受报文提取成功(不可解析探针挂语义校验步骤:任务槽位抽取路径无重试,maxAttempts 仅作用于语义校验) | +| SC-ERR-02 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | (negotiation_semantic_rejected) | 闭环中错误恢复:工作台首轮补数仅提供投诉分类缺接入端口名称,被 OMC 语义校验拒绝,第2轮补充端口名后重新接受并提取成功,缺参最终补齐 | +| SC-ERR-03 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2 | - | 重传幂等:同一条接受补数报文被 OMC 重复校验两次,两次提取的 FilledParamData(接入端口名称与投诉分类)完全一致,重传不产生副作用 | +| SC-ERR-04 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=2 | - | 乱序失同步探针:工作台以滞后的 round 1 上下文校验 OMC 在 round 2 生成的要数提议,规则门不拦截失同步(doc-gap 探针) | +| SC-ERR-05 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=1 | (negotiation_semantic_rejected) | 截断消息探针:传输中被截断的要数提议缺失后两项信息,工作台侧语义校验以槽位缺失为由拒绝 | +| SC-ERR-06 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2 | (negotiation_semantic_rejected) | 编码边界探针:带 UTF-8 BOM 前缀或全角标点的接受补数报文可正常校验提取,GBK 乱码形态的报文被语义校验拒绝 | +| SC-ERR-07 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 双会话交错:event-id-20260511-09013 与 fault-id-1-017-20260516-11234 两条投诉工单的缺参协商闭环步骤交替执行,各自提取的上下文与业务参数互不串扰 | ### SC-EXH -- scenarios/boundary-flows.json | Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | |---|---|---|---|---|---|---| -| SC-EXH-01 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText`, `validateAbortPromptAndDataFilling` | zh-CN | - | exhausted, rounds=3 | - | 轮次预算耗尽边界:三轮提议均遭拒绝,round 3 达到 maxRounds 上限后生成终止消息结束协商 | -| SC-EXH-02 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=6 | (negotiation_rule_violation) | 过期重放边界:携带 round 超过轮次预算的消息被规则门直接拦截且不调用大模型,修正轮次后重放校验通过 | +| SC-EXH-01 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText`, `validateAbortPromptAndDataFilling` | zh-CN | - | exhausted, rounds=3 | - | 轮次预算耗尽闭环:投诉诊断任务缺参后两轮要数均因工作台端口台账不可查被拒,round 3 达到 maxRounds 上限后 OMC 生成终止消息结束协商 | +| SC-EXH-02 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=6 | (negotiation_rule_violation) | 过期重放边界:工作台以 round 6 超出轮次预算的上下文重放校验 OMC 的要数提议,被规则门直接拦截且不调用大模型,修正为 round 5 后重放校验通过 | ### SC-FSB -- scenarios/feasibility-flows.json | Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | |---|---|---|---|---|---|---| -| SC-FSB-01 | `generateProposeFromText`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=1, distinct-messages | - | 可行性协商单轮闭环:A 发起调整后速率保障目标的可行性评估请求,B 评估通过后生成接受消息,A 校验确认达成一致 | -| SC-FSB-02 | `generateProposeFromText`, `generateRejectFromText`, `validateRejectPromptAndDataFilling`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=2, distinct-messages | - | 可行性协商多轮演进:round 1 评估结论为不可行并拒绝,A 于 round 2 改提替代方案,B 评估通过并接受,协商达成一致 | -| SC-FSB-03 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText`, `validateAbortPromptAndDataFilling` | zh-CN | - | abort, rounds=2, distinct-messages | - | 可行性协商中止:round 1 评估不可行且双方无法推进,round 2 生成通用终止消息并经校验确认协商提前终止 | +| SC-FSB-01 | `generateProposeFromText`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=1, distinct-messages | - | 端口扩容可行性协商单轮闭环(双语):工作台就接入端口带宽扩容能否在割接窗口内完成发起可行性评估请求,OMC 评估通过后生成接受消息,工作台校验确认达成一致 | +| SC-FSB-02 | `generateProposeFromText`, `generateRejectFromText`, `validateRejectPromptAndDataFilling`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 端口扩容可行性协商两轮演进:round 1 因汇聚机房板卡槽位不足评估不可行并拒绝,工作台于 round 2 改提更换高容量板卡并拆分割接窗口的替代方案,OMC 评估通过并接受 | +| SC-FSB-03 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText`, `validateAbortPromptAndDataFilling` | zh-CN | - | abort, rounds=2, distinct-messages | - | 端口扩容可行性协商中止:round 1 评估因汇聚机房板卡槽位不足不可行且双方无法推进,round 2 生成终止消息并经校验确认协商提前终止 | ### SC-INFO -- scenarios/information-flows.json | Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | |---|---|---|---|---|---|---| -| SC-INFO-01 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=1 | - | 信息协商单轮闭环:A 从自然语言生成接受消息,B 校验通过并提取参数 | -| SC-INFO-02 | `generateProposeFromText`, `generateRejectFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=2, distinct-messages | - | 信息协商两轮演进:首轮因站点库存不可用被拒后,发起方收窄信息项于第2轮重新提议,接收方接受且参数校验提取成功 | -| SC-INFO-03 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText` | zh-CN | - | abort, rounds=2, distinct-messages | - | 信息协商中断:所需信息连续无法提供后,发起方在第2轮发起协商终止并生成终止消息 | +| SC-INFO-01 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=2, distinct-messages | - | 投诉诊断五步闭环(双语):工作台从投诉文本生成缺参任务报文,OMC 校验发现缺接入端口名称与投诉分类后发起信息协商要数,工作台接受补数,OMC 提取补参补齐闭环 | +| SC-INFO-02 | `generateTaskPromptFromDataWithSchema`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateRejectFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 投诉诊断闭环两轮演进:工作台首轮因端口资源台账暂不可查拒绝补数,OMC 第2轮重新要数,台账恢复后工作台接受补数,OMC 提取补参补齐闭环 | +| SC-INFO-03 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAbortFromText`, `validateAbortPromptAndDataFilling` | zh-CN | - | abort, rounds=2, distinct-messages | - | 投诉诊断闭环中断:任务报文缺参协商发起后,投诉被 OSS 侧工单撤销,工作台于第2轮生成终止消息结束协商 | ### SC-TGT -- scenarios/target-flows.json | Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | |---|---|---|---|---|---|---| -| SC-TGT-01 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=1, distinct-messages | - | 目标协商单轮闭环:A 从自然语言生成携带最终确认意图的接受消息,B 校验通过并提取站点与速率保障参数 | -| SC-TGT-02 | `generateProposeFromText`, `generateRejectFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=2, distinct-messages | - | 目标协商两轮演进:首轮区域信息无法澄清被拒后,发起方对齐区域理解并于第2轮重新提议,接收方接受且参数校验提取成功 | -| SC-TGT-03 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText` | zh-CN | - | abort, rounds=2, distinct-messages | - | 目标协商中断:区域信息持续无法澄清,发起方在第2轮发起协商终止并生成终止消息 | +| SC-TGT-01 | `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 修复目标协商单轮闭环:工作台就专线质差修复目标发起目标协商提议,OMC 确认最终意图生成接受消息,工作台校验提取时延目标与完成时限参数 | +| SC-TGT-02 | `generateProposeFromText`, `generateRejectFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 修复目标协商两轮演进:首轮因光缆割接窗口尚未确认被拒,工作台对齐时延目标后于第2轮重新提议,OMC 确认接受且参数校验提取成功 | +| SC-TGT-03 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText` | zh-CN | - | abort, rounds=2, distinct-messages | - | 修复目标协商中断:光缆割接窗口持续无法确认,修复目标在轮次预算内无法完成澄清,于第2轮生成终止消息结束协商 | diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/corpus-schema.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/corpus-schema.json index a8c6f566..a38a5ae9 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/corpus-schema.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/corpus-schema.json @@ -3,7 +3,7 @@ "$id": "https://openan.net/a2a-t-sdk/negotiation-cases/corpus-schema.json", "title": "A2A-T negotiation test corpus format", "description": "First-hand definition of the data-driven negotiation test corpus consumed by the negotiation testdata harness (NegotiationCaseLoader and the later CaseEngine suites). Every corpus file is a JSON array of records: case files carry case records, scenario files carry scenario records, and the two shared files carry reusable LLM payloads and JSON Schema variants. The loader parses everything in strict mode, so an unknown key is an error, never a silently ignored typo.", - "formatVersion": 1, + "formatVersion": 2, "$defs": { "language": { "title": "Language tag of the bundled negotiation resources", @@ -11,8 +11,8 @@ "enum": ["zh-CN", "en-US"] }, "api": { - "title": "NegotiationContentService API under test", - "description": "Exactly the twelve methods of the negotiation content service; the CaseEngine dispatches on this value.", + "title": "API under test", + "description": "Exactly the twelve methods of the negotiation content service plus the three closed-loop task APIs (the A2ATClient task generation methods and the A2ATServer task validation method, reached through the mirrored prompt-module production wiring); the CaseEngine dispatches on this value.", "type": "string", "enum": [ "generateProposeFromText", @@ -26,7 +26,10 @@ "validateProposePromptAndDataFilling", "validateAcceptPromptAndDataFilling", "validateRejectPromptAndDataFilling", - "validateAbortPromptAndDataFilling" + "validateAbortPromptAndDataFilling", + "generateTaskPromptFromText", + "generateTaskPromptFromDataWithSchema", + "validateTaskPromptAndDataFilling" ] }, "priority": { @@ -42,8 +45,8 @@ "enum": ["failingTemplateLoader", "failingSemanticValidator"] }, "templateUri": { - "title": "Negotiation template URI", - "description": "Intentionally permissive: programming-error cases carry deliberately malformed URIs (wrong version segment, wrong extension name, misspelled type, unknown phase).", + "title": "Content template URI", + "description": "Intentionally permissive: programming-error cases carry deliberately malformed URIs (wrong version segment, wrong extension name, misspelled type, unknown phase). Task-family records address the Task-T templates, such as Task-T/network-layer/private-line-complaint/v1.", "type": "string", "minLength": 1 }, @@ -254,7 +257,7 @@ }, "params": { "type": "object", - "description": "Expected merged FilledParamData map; always contains the context keys id, round and maxRounds." + "description": "Expected merged FilledParamData map; always contains the context keys id, round and maxRounds. When missingParams is present the entries are a subset check with exact values instead of full-map equality, and JSON null values are rejected — a missing parameter belongs into missingParams." }, "contracts": { "type": "array", @@ -265,6 +268,27 @@ "differential": { "type": "boolean", "description": "Marks the from-text plus from-data double run asserting that both outputs equal each other and the golden fixture." + }, + "promptTextContains": { + "type": "array", + "description": "Task family: structural fragments the generated task prompt text must contain, such as the ## 任务类型(Task Type) style section headers.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "missingParams": { + "type": "array", + "description": "Task validation: the exact set of schema parameter names expected to be missing — the null-valued entries of the filled parameter data, the causal starting point of the information negotiation. An empty array asserts that nothing is missing.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "paramsFromStep": { + "type": "integer", + "minimum": 1, + "description": "Scenario steps only: the number of the earlier task-validation step whose missing parameters this validation step must have filled with values (the 缺参 -> 补参 causal link); resolved by the ScenarioEngine." } } }, @@ -344,6 +368,11 @@ }, "distinctMessages": { "type": "boolean" + }, + "missingParamsFilled": { + "type": "integer", + "minimum": 1, + "description": "The number of the task-validation step whose missing parameters must be filled with values by the later steps of the flow — the 缺参 -> 补参 causal chain asserted at flow level." } } }, @@ -501,6 +530,16 @@ "minLength": 1 } }, + "rolesDesc": { + "title": "Role semantics of the closed loop", + "description": "Business meaning of each role, keyed by role name, such as {\"A\": \"工作台(client,任务发起/补数方)\", \"B\": \"OMC(server,执行/要数方,协商发起方)\"}. Every key must name a declared role; the descriptions travel into the engine failure messages and the run summary.", + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, "steps": { "type": "array", "minItems": 1, diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/happy.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/happy.json index bbd6d378..21152ac9 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/happy.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/happy.json @@ -5,15 +5,15 @@ "languages": ["zh-CN", "en-US"], "priority": "P0", "tags": ["happy", "information", "propose", "deterministic"], - "summary": "结构化数据直接生成信息协商提议消息,双语渲染均与 golden 一致,全程零 LLM 调用", + "summary": "OMC 以结构化数据直接生成信息协商提议消息,向工作台索要接入端口名称与投诉分类等缺失信息项,全程零 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "data": { "items": [ - { "name": "energy-saving area information", "value": "e.g. Songshan Lake" }, - { "name": "energy-saving rate guarantee target", "value": "e.g. 20Mbps" }, - { "name": "VLANId", "value": null } + { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" }, + { "name": "投诉分类", "value": "举例:专线质差" }, + { "name": "专线业务标识", "value": null } ], "relationship": "OR" } @@ -21,7 +21,7 @@ "expect": { "outcome": "success", "llmCalls": 0, - "promptTextEqualsGolden": "information_propose", + "promptTextContains": ["接入端口名称", "投诉分类", "专线业务标识", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差", "OR"], "metadata": { "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", "contextEcho": true @@ -35,22 +35,22 @@ "languages": ["zh-CN", "en-US"], "priority": "P0", "tags": ["happy", "information", "accept", "deterministic"], - "summary": "结构化数据直接生成信息协商接受消息,消息含 Accept 结论字面量,双语渲染均与 golden 一致,全程零 LLM 调用", + "summary": "工作台以结构化数据直接生成信息协商接受消息,补齐接入端口名称与投诉分类并携带 Accept 结论字面量,全程零 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "data": { "conclusion": "Accept", "items": [ - { "name": "energy-saving area information", "value": "Songshan Lake" }, - { "name": "energy-saving rate guarantee target", "value": "20Mbps" } + { "name": "接入端口名称", "value": "P533-珠江旧城-PTN3900-23-TPA1EG24-1" }, + { "name": "投诉分类", "value": "专线质差" } ] } }, "expect": { "outcome": "success", "llmCalls": 0, - "promptTextEqualsGolden": "information_accept", + "promptTextContains": ["Accept", "接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差"], "metadata": { "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", "contextEcho": true @@ -64,21 +64,21 @@ "languages": ["zh-CN", "en-US"], "priority": "P0", "tags": ["happy", "information", "reject", "deterministic"], - "summary": "结构化数据直接生成信息协商拒绝消息,消息含 Reject 结论字面量,双语渲染均与 golden 一致,全程零 LLM 调用", + "summary": "工作台以结构化数据直接生成信息协商拒绝消息,以端口资源台账暂不可查为由无法提供接入端口名称并携带 Reject 结论字面量,全程零 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "data": { "conclusion": "Reject", "items": [ - { "name": "energy-saving area information", "value": "cannot be provided because the site inventory is unavailable" } + { "name": "接入端口名称", "value": "无法提供,工作台侧端口资源台账暂不可查" } ] } }, "expect": { "outcome": "success", "llmCalls": 0, - "promptTextEqualsGolden": "information_reject", + "promptTextContains": ["Reject", "接入端口名称", "无法提供,工作台侧端口资源台账暂不可查"], "metadata": { "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", "contextEcho": true @@ -92,27 +92,34 @@ "languages": ["zh-CN", "en-US"], "priority": "P0", "tags": ["happy", "target", "propose", "first-round", "deterministic"], - "summary": "首轮目标协商从结构化数据直接生成提议消息,首轮渲染意图理解与待澄清内容两个板块,全程零 LLM 调用", + "summary": "首轮目标协商从结构化数据直接生成提议消息,围绕专线质差的时延恢复目标与修复完成时限向对端澄清,全程零 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/propose/v1", "input": { "data": { - "target_negotiation_description": "The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm.", + "target_negotiation_description": "专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。", "intent_understanding": [ - { "name": "task intent", "value": "apply wireless energy-saving optimization to the target site during 08:00-18:00" }, - { "name": "rate guarantee target", "value": "at least 20Mbps during the energy-saving period" } + { "name": "修复意图", "value": "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内" }, + { "name": "修复目标", "value": "高峰时段丢包率不高于1%" } ], "alignment_and_clarification": null, "request_for_clarification": [ - { "name": "area", "value": "which site is covered: Songshan Lake or another site" }, - { "name": "time range", "value": "is the energy-saving period 08:00-18:00 or 00:00-06:00" } + { "name": "时延目标", "value": "平均时延目标是恢复至20ms以内还是50ms以内" }, + { "name": "完成时限", "value": "修复完成时限是48小时内还是72小时内" } ] } }, "expect": { "outcome": "success", "llmCalls": 0, - "promptTextEqualsGolden": "target_propose", + "promptTextContains": [ + "修复目标协商意图", + "修复意图", + "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内", + "高峰时段丢包率不高于1%", + "时延目标", + "完成时限" + ], "metadata": { "templateUriEcho": "Negotiation-T/target-negotiation/propose/v1", "contextEcho": true @@ -126,20 +133,20 @@ "languages": ["zh-CN", "en-US"], "priority": "P0", "tags": ["happy", "target", "accept", "deterministic"], - "summary": "结构化数据直接生成目标协商接受消息,消息携带最终确认意图并含 Accept 结论字面量,全程零 LLM 调用", + "summary": "以结构化数据直接生成目标协商接受消息,最终确认专线时延恢复至20ms以内与高峰丢包率目标并携带 Accept 结论字面量,全程零 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "input": { "data": { "conclusion": "Accept", - "confirmed_intent": "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps.", + "confirmed_intent": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。", "failure_reason": null } }, "expect": { "outcome": "success", "llmCalls": 0, - "promptTextEqualsGolden": "target_accept", + "promptTextContains": ["Accept", "最终确认的意图", "20ms以内", "高峰时段丢包率不高于1%"], "metadata": { "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", "contextEcho": true @@ -153,20 +160,20 @@ "languages": ["zh-CN", "en-US"], "priority": "P0", "tags": ["happy", "target", "reject", "deterministic"], - "summary": "结构化数据直接生成目标协商拒绝消息,消息携带拒绝原因并含 Reject 结论字面量,全程零 LLM 调用", + "summary": "以结构化数据直接生成目标协商拒绝消息,以光缆割接窗口尚未确认为由拒绝时延目标并携带 Reject 结论字面量,全程零 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "input": { "data": { "conclusion": "Reject", "confirmed_intent": null, - "failure_reason": "The area information cannot be clarified in full because the site inventory is unavailable." + "failure_reason": "因光缆割接窗口尚未确认,时延目标未能完全澄清。" } }, "expect": { "outcome": "success", "llmCalls": 0, - "promptTextEqualsGolden": "target_reject", + "promptTextContains": ["Reject", "光缆割接窗口尚未确认", "时延目标未能完全澄清"], "metadata": { "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", "contextEcho": true @@ -180,16 +187,16 @@ "languages": ["zh-CN", "en-US"], "priority": "P0", "tags": ["happy", "feasibility", "propose", "evaluation-request", "deterministic"], - "summary": "结构化数据直接生成可行性协商提议消息,请求对方评估调整后的速率保障目标,全程零 LLM 调用", + "summary": "以结构化数据直接生成可行性协商提议消息,请求对端评估接入端口带宽扩容能否在割接窗口内完成,全程零 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", "input": { "data": { - "feasibility_negotiation_description": "Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess.", + "feasibility_negotiation_description": "请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。", "action": "REQUEST_FEASIBILITY_EVALUATION", "contents_to_evaluate": [ - { "name": "adjusted target", "value": "rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00" }, - { "name": "existing constraint", "value": "at least 10 hours of power supply duration in outage scenarios" } + { "name": "扩容方案", "value": "接入端口带宽由100Mbps扩容至1000Mbps" }, + { "name": "既有约束", "value": "割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟" } ], "infeasibility_details_and_proposal": null } @@ -197,7 +204,12 @@ "expect": { "outcome": "success", "llmCalls": 0, - "promptTextEqualsGolden": "feasibility_propose", + "promptTextContains": [ + "扩容方案", + "接入端口带宽由100Mbps扩容至1000Mbps", + "既有约束", + "割接窗口仅限2026年5月30日02:00-06:00" + ], "metadata": { "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", "contextEcho": true @@ -211,19 +223,19 @@ "languages": ["zh-CN", "en-US"], "priority": "P0", "tags": ["happy", "feasibility", "accept", "deterministic"], - "summary": "结构化数据直接生成可行性协商接受消息,消息携带可行性结论并含 Accept 结论字面量,全程零 LLM 调用", + "summary": "以结构化数据直接生成可行性协商接受消息,确认端口扩容可在割接窗口内完成且满足业务中断约束并携带 Accept 结论字面量,全程零 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", "input": { "data": { "conclusion": "Accept", - "feasibility_summary": "The adjusted rate guarantee target is achievable and satisfies the outage duration requirement; this negotiation is confirmed as concluded." + "feasibility_summary": "端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。" } }, "expect": { "outcome": "success", "llmCalls": 0, - "promptTextEqualsGolden": "feasibility_accept", + "promptTextContains": ["Accept", "端口扩容方案可在割接窗口内完成", "不超过30分钟的约束"], "metadata": { "templateUriEcho": "Negotiation-T/feasibility-negotiation/accept-reject/v1", "contextEcho": true @@ -237,19 +249,19 @@ "languages": ["zh-CN", "en-US"], "priority": "P0", "tags": ["happy", "feasibility", "reject", "deterministic"], - "summary": "结构化数据直接生成可行性协商拒绝消息,消息携带不可行结论并含 Reject 结论字面量,全程零 LLM 调用", + "summary": "以结构化数据直接生成可行性协商拒绝消息,以汇聚机房板卡槽位不足为由判定扩容不可行并携带 Reject 结论字面量,全程零 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", "input": { "data": { "conclusion": "Reject", - "feasibility_summary": "The energy-saving target cannot be achieved under the existing power supply constraint; this negotiation is confirmed as concluded." + "feasibility_summary": "受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。" } }, "expect": { "outcome": "success", "llmCalls": 0, - "promptTextEqualsGolden": "feasibility_reject", + "promptTextContains": ["Reject", "汇聚机房板卡槽位不足", "无法在指定割接窗口内完成"], "metadata": { "templateUriEcho": "Negotiation-T/feasibility-negotiation/accept-reject/v1", "contextEcho": true @@ -263,23 +275,52 @@ "languages": ["zh-CN", "en-US"], "priority": "P0", "tags": ["happy", "abort", "common", "deterministic"], - "summary": "结构化数据直接生成协商终止消息,走通用 abort 模板并含终止原因,双语渲染均与 golden 一致,全程零 LLM 调用", + "summary": "以结构化数据直接生成协商终止消息,以达到协商轮次上限为由终止本次专线投诉诊断协商并携带 Abort 结论字面量,全程零 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", "input": { "data": { - "termination_reason": "The negotiation round limit is reached; this negotiation is confirmed as terminated." + "termination_reason": "已达到协商轮次上限,本次协商确认终止。" } }, "expect": { "outcome": "success", "llmCalls": 0, - "promptTextEqualsGolden": "abort", + "promptTextContains": ["Abort", "已达到协商轮次上限"], "metadata": { "templateUriEcho": "Negotiation-T/common/abort/v1", "contextEcho": true }, "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] } + }, + { + "id": "FD-HAPPY-11", + "api": "generateProposeFromData", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "information", "propose", "and-relationship", "deterministic"], + "summary": "接入端口名称与投诉分类两项缺失信息均为必填时以与关系生成信息协商提议消息,两项同时列为缺失项,全程零 LLM 调用", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "data": { + "items": [ + { "name": "接入端口名称", "value": "举例:P781-珠江新城-PTN7900-23-TPA1EG24-17" }, + { "name": "投诉分类", "value": "举例:专线中断" } + ], + "relationship": "AND" + } + }, + "expect": { + "outcome": "success", + "llmCalls": 0, + "promptTextContains": ["接入端口名称", "投诉分类", "P781-珠江新城-PTN7900-23-TPA1EG24-17", "专线中断", "AND"], + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"] + } } ] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/programming-errors.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/programming-errors.json index d745a404..8f21323e 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/programming-errors.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/programming-errors.json @@ -11,7 +11,7 @@ "input": { "data": { "items": [ - { "name": "energy-saving area information", "value": "e.g. Songshan Lake" } + { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" } ], "relationship": "OR" } @@ -36,7 +36,7 @@ "data": { "conclusion": "Reject", "items": [ - { "name": "energy-saving area information", "value": "Songshan Lake" } + { "name": "接入端口名称", "value": "P533-珠江旧城-PTN3900-23-TPA1EG24-1" } ] } }, @@ -60,7 +60,7 @@ "data": { "conclusion": "Accept", "items": [ - { "name": "energy-saving area information", "value": "Songshan Lake" } + { "name": "接入端口名称", "value": "P533-珠江旧城-PTN3900-23-TPA1EG24-1" } ] } }, @@ -84,7 +84,7 @@ "data": { "conclusion": "Abort", "items": [ - { "name": "energy-saving area information", "value": "Songshan Lake" } + { "name": "接入端口名称", "value": "P533-珠江旧城-PTN3900-23-TPA1EG24-1" } ] } }, @@ -175,7 +175,7 @@ "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", "input": { "data": { - "feasibility_negotiation_description": "Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved; please assess.", + "feasibility_negotiation_description": "请评估专线接入端口扩容方案能否在割接窗口内完成;请评估。", "action": "REQUEST_FEASIBILITY_EVALUATION", "contents_to_evaluate": null, "infeasibility_details_and_proposal": null @@ -199,7 +199,7 @@ "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", "input": { "data": { - "feasibility_negotiation_description": "The energy-saving target is infeasible under the existing power supply constraint; an alternative proposal is listed in .", + "feasibility_negotiation_description": "受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;替代方案见<不可行细节与方案>。", "action": "PROPOSE_ALTERNATIVE_ON_FAILURE", "contents_to_evaluate": null, "infeasibility_details_and_proposal": null @@ -226,7 +226,7 @@ "input": { "data": { "items": [ - { "name": "energy-saving area information", "value": "e.g. Songshan Lake" } + { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" } ], "relationship": "OR" } @@ -252,7 +252,7 @@ "input": { "data": { "items": [ - { "name": "energy-saving area information", "value": "e.g. Songshan Lake" } + { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" } ], "relationship": "OR" } @@ -278,7 +278,7 @@ "input": { "data": { "items": [ - { "name": "energy-saving area information", "value": "e.g. Songshan Lake" } + { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" } ], "relationship": "OR" } @@ -304,7 +304,7 @@ "input": { "data": { "items": [ - { "name": "energy-saving area information", "value": "e.g. Songshan Lake" } + { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" } ], "relationship": "OR" } @@ -330,7 +330,7 @@ "input": { "data": { "items": [ - { "name": "energy-saving area information", "value": "e.g. Songshan Lake" } + { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" } ], "relationship": "OR" } diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/extraction-failures.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/extraction-failures.json index 5f6e3177..0e2ffd47 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/extraction-failures.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/extraction-failures.json @@ -5,10 +5,10 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["extraction-failure", "information", "accept", "slot-missing"], - "summary": "接受消息抽取结果缺少结论字段时生成失败并返回槽位缺失错误码,不发起重试", + "summary": "补数接受消息抽取结果缺少结论字段时生成失败并返回槽位缺失错误码,不发起重试", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.accept.missing_conclusion" } ] }, "expect": { "outcome": "failure", @@ -23,10 +23,10 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["extraction-failure", "information", "accept", "conclusion-mismatch"], - "summary": "接受消息抽取出的结论为拒绝时判定为内容与阶段矛盾,立即失败并返回输入无效错误码", + "summary": "补数接受消息抽取出的结论为拒绝时判定为内容与阶段矛盾,立即失败并返回输入无效错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.accept.conclusion_swapped" } ] }, "expect": { "outcome": "failure", @@ -41,10 +41,10 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["extraction-failure", "information", "reject", "conclusion-mismatch"], - "summary": "拒绝消息抽取出的结论为接受时判定为内容与阶段矛盾,立即失败并返回输入无效错误码", + "summary": "无法提供端口名的拒绝消息抽取出的结论为接受时判定为内容与阶段矛盾,立即失败并返回输入无效错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" } }, + "input": { "text": { "zh-CN": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮协商拒绝。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.reject.conclusion_swapped" } ] }, "expect": { "outcome": "failure", @@ -59,10 +59,10 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["extraction-failure", "information", "propose", "slot-missing"], - "summary": "信息提议消息抽取结果缺少信息项列表时生成失败并返回槽位缺失错误码", + "summary": "要数提议消息抽取结果缺少信息项列表时生成失败并返回槽位缺失错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.propose.missing_items" } ] }, "expect": { "outcome": "failure", @@ -77,10 +77,10 @@ "languages": ["en-US"], "priority": "P1", "tags": ["extraction-failure", "information", "propose", "shape-error", "max-attempts-1"], - "summary": "信息项列表被抽取为字符串而非数组时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码", + "summary": "要数信息项列表被抽取为字符串而非数组时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "en-US": "Please supplement the energy-saving area information, the energy-saving rate guarantee target and the VLANId." } }, + "input": { "text": { "en-US": "Please supplement the Access Port Name, the Complaint Category and the Private Line Service Identifier." } }, "llm": { "maxAttempts": 1, "script": [ { "$ref": "responses/extract.information.propose.items_not_array" } ] }, "expect": { "outcome": "failure", @@ -95,11 +95,11 @@ "languages": ["zh-CN"], "priority": "P2", "tags": ["extraction-failure", "information", "propose", "shape-error", "max-attempts-1"], - "summary": "信息项缺少名称字段时内容抽取失败,返回抽取失败错误码", + "summary": "要数信息项缺少名称字段时内容抽取失败,返回抽取失败错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, - "llm": { "maxAttempts": 1, "script": [ "{\"items\":[{\"value\":\"Songshan Lake\"}],\"relationship\":\"OR\"}" ] }, + "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, + "llm": { "maxAttempts": 1, "script": [ "{\"items\":[{\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}],\"relationship\":\"OR\"}" ] }, "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", @@ -116,8 +116,8 @@ "summary": "结论字面量大小写错误(ACCEPT 而非 Accept)时内容抽取失败,返回抽取失败错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "最终确认的意图:对松山湖站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps。" } }, - "llm": { "maxAttempts": 1, "script": [ "{\"conclusion\":\"ACCEPT\",\"confirmed_intent\":\"The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps.\",\"failure_reason\":null}" ] }, + "input": { "text": { "zh-CN": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" } }, + "llm": { "maxAttempts": 1, "script": [ "{\"conclusion\":\"ACCEPT\",\"confirmed_intent\":\"最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。\",\"failure_reason\":null}" ] }, "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", @@ -131,10 +131,10 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["extraction-failure", "target", "accept", "slot-missing"], - "summary": "目标接受消息抽取结果缺少最终确认意图时生成失败并返回槽位缺失错误码", + "summary": "修复目标接受消息抽取结果缺少最终确认意图时生成失败并返回槽位缺失错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "最终确认的意图:对松山湖站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps。" } }, + "input": { "text": { "zh-CN": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" } }, "llm": { "script": [ { "$ref": "responses/extract.target.accept.missing_confirmed_intent" } ] }, "expect": { "outcome": "failure", @@ -149,10 +149,10 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["extraction-failure", "target", "reject", "slot-missing"], - "summary": "目标拒绝消息抽取结果缺少拒绝原因时生成失败并返回槽位缺失错误码", + "summary": "修复目标拒绝消息抽取结果缺少拒绝原因时生成失败并返回槽位缺失错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "无法完整澄清区域信息:站点库存数据不可用,本轮目标协商拒绝。" } }, + "input": { "text": { "zh-CN": "因光缆割接窗口尚未确认,时延目标未能完全澄清,本轮目标协商拒绝。" } }, "llm": { "script": [ { "$ref": "responses/extract.target.reject.missing_failure_reason" } ] }, "expect": { "outcome": "failure", @@ -167,10 +167,10 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["extraction-failure", "feasibility", "propose", "invalid-input"], - "summary": "可行性提议消息抽取结果缺少动作字段时判定为内容矛盾,立即失败并返回输入无效错误码", + "summary": "端口扩容可行性提议消息抽取结果缺少动作字段时判定为内容矛盾,立即失败并返回输入无效错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps。" } }, + "input": { "text": { "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps。" } }, "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.missing_action" } ] }, "expect": { "outcome": "failure", @@ -188,7 +188,7 @@ "summary": "动作字段为未知枚举值时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps。" } }, + "input": { "text": { "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps。" } }, "llm": { "maxAttempts": 1, "script": [ { "$ref": "responses/extract.feasibility.propose.unknown_action" } ] }, "expect": { "outcome": "failure", @@ -206,7 +206,7 @@ "summary": "请求可行性评估却未抽取到任何待评估内容时判定为内容矛盾,立即失败并返回输入无效错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps。" } }, + "input": { "text": { "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps。" } }, "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.evaluation_without_contents" } ] }, "expect": { "outcome": "failure", @@ -221,10 +221,10 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["extraction-failure", "abort", "common", "slot-missing"], - "summary": "终止消息抽取结果缺少终止原因时生成失败并返回槽位缺失错误码", + "summary": "协商终止消息抽取结果缺少终止原因时生成失败并返回槽位缺失错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", - "input": { "text": { "zh-CN": "协商轮次已达上限,本次协商确认终止。" } }, + "input": { "text": { "zh-CN": "已达到协商轮次上限,本次协商确认终止。" } }, "llm": { "script": [ { "$ref": "responses/extract.common.abort.missing_termination_reason" } ] }, "expect": { "outcome": "failure", @@ -242,7 +242,7 @@ "summary": "终止原因为空白字符串时等同于缺失,生成失败并返回槽位缺失错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", - "input": { "text": { "zh-CN": "协商轮次已达上限,本次协商确认终止。" } }, + "input": { "text": { "zh-CN": "已达到协商轮次上限,本次协商确认终止。" } }, "llm": { "script": [ "{\"termination_reason\":\"\"}" ] }, "expect": { "outcome": "failure", @@ -260,8 +260,8 @@ "summary": "信息项取值为数字而非字符串时内容抽取失败,返回抽取失败错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, - "llm": { "maxAttempts": 1, "script": [ "{\"items\":[{\"name\":\"VLANId\",\"value\":100}],\"relationship\":\"OR\"}" ] }, + "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, + "llm": { "maxAttempts": 1, "script": [ "{\"items\":[{\"name\":\"专线业务标识\",\"value\":100}],\"relationship\":\"OR\"}" ] }, "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/happy.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/happy.json index 9779e5da..a24659ee 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/happy.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/happy.json @@ -4,20 +4,20 @@ "api": "generateProposeFromText", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "information", "propose", "differential"], - "summary": "自然语言补充信息项后生成信息协商提议消息,双语渲染均与 golden 一致,且 fromText 与 fromData 两路等值", + "tags": ["happy", "information", "propose"], + "summary": "OMC 就投诉诊断缺失的接入端口名称与投诉分类发起信息协商,从自然语言生成提议消息,双语渲染均携带三项缺失信息与 OR 关系(golden 等值与 differential 不可表达降级为包含断言:harness 的 llm 脚本与 input.data 不随语言展开,en-US 渲染为英文模板骨架+中文数据,deviation 已记录)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { - "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。", - "en-US": "Please supplement the energy-saving area information (e.g. Songshan Lake), the energy-saving rate guarantee target (e.g. 20Mbps) and the VLANId; the three items are related by OR." + "zh-CN": "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识三项信息,三项之间为或的关系。", + "en-US": "Please supplement the Access Port Name (e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1), the Complaint Category (e.g. dedicated-line quality degradation) and the Private Line Service Identifier; the three items are related by OR." }, "data": { "items": [ - { "name": "energy-saving area information", "value": "e.g. Songshan Lake" }, - { "name": "energy-saving rate guarantee target", "value": "e.g. 20Mbps" }, - { "name": "VLANId", "value": null } + { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" }, + { "name": "投诉分类", "value": "举例:专线质差" }, + { "name": "专线业务标识", "value": null } ], "relationship": "OR" } @@ -26,13 +26,12 @@ "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "information_propose", + "promptTextContains": ["接入端口名称", "投诉分类", "专线业务标识", "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差", "OR"], "metadata": { "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", "contextEcho": true }, - "contracts": ["metadataTripleShape"], - "differential": true + "contracts": ["metadataTripleShape"] } }, { @@ -40,20 +39,20 @@ "api": "generateAcceptFromText", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "information", "accept", "differential"], - "summary": "确认信息后从自然语言生成信息协商接受消息,消息含 Accept 结论字面量,且 fromText 与 fromData 两路等值", + "tags": ["happy", "information", "accept"], + "summary": "工作台补充接入端口名称与投诉分类后从自然语言生成信息协商接受消息,消息含 Accept 结论字面量且双语渲染均携带补充的业务信息项(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。", - "en-US": "I confirm the first-stage information: the energy-saving area is Songshan Lake and the rate guarantee target is 20Mbps." + "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。", + "en-US": "I confirm the supplemented complaint diagnosis information: the access port name is P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1 and the complaint category is dedicated-line quality degradation." }, "data": { "conclusion": "Accept", "items": [ - { "name": "energy-saving area information", "value": "Songshan Lake" }, - { "name": "energy-saving rate guarantee target", "value": "20Mbps" } + { "name": "接入端口名称", "value": "P533-珠江旧城-PTN3900-23-TPA1EG24-1" }, + { "name": "投诉分类", "value": "专线质差" } ] } }, @@ -61,13 +60,12 @@ "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "information_accept", + "promptTextContains": ["Accept", "接入端口名称", "投诉分类", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差"], "metadata": { "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", "contextEcho": true }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], - "differential": true + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] } }, { @@ -75,19 +73,19 @@ "api": "generateRejectFromText", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "information", "reject", "differential"], - "summary": "信息无法提供时从自然语言生成信息协商拒绝消息,消息含 Reject 结论字面量,且 fromText 与 fromData 两路等值", + "tags": ["happy", "information", "reject"], + "summary": "工作台侧端口资源台账不可查无法提供接入端口名称时从自然语言生成信息协商拒绝消息,消息含 Reject 结论字面量且携带不可提供原因(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。", - "en-US": "The energy-saving area information cannot be provided because the site inventory is unavailable; this round is rejected." + "zh-CN": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮协商拒绝。", + "en-US": "The access port name cannot be provided because the port inventory is temporarily unavailable on the workbench side; this round is rejected." }, "data": { "conclusion": "Reject", "items": [ - { "name": "energy-saving area information", "value": "cannot be provided because the site inventory is unavailable" } + { "name": "接入端口名称", "value": "无法提供,工作台侧端口资源台账暂不可查" } ] } }, @@ -95,13 +93,12 @@ "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "information_reject", + "promptTextContains": ["Reject", "接入端口名称", "无法提供,工作台侧端口资源台账暂不可查"], "metadata": { "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", "contextEcho": true }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], - "differential": true + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] } }, { @@ -109,25 +106,25 @@ "api": "generateProposeFromText", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "target", "propose", "first-round", "differential"], - "summary": "首轮目标协商从自然语言生成提议消息,首轮渲染意图理解与待澄清内容两个板块,且 fromText 与 fromData 两路等值", + "tags": ["happy", "target", "propose", "first-round"], + "summary": "首轮修复目标协商从自然语言生成提议消息,就时延恢复目标与完成时限澄清,首轮渲染意图理解与待澄清内容两个板块(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/propose/v1", "input": { "text": { - "zh-CN": "无线节能优化任务的意图理解:对目标站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps;对区域与节能时间范围仍有疑问,请澄清和确认。", - "en-US": "The intent understanding of the wireless energy-saving optimization task: apply wireless energy-saving optimization to the target site during 08:00-18:00 with a guaranteed rate of at least 20Mbps; open questions remain about the area and the time range, please clarify and confirm." + "zh-CN": "专线质差投诉修复目标的意图理解:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%;对时延目标与完成时限仍有疑问,请澄清和确认。", + "en-US": "The intent understanding of the dedicated-line quality degradation repair target: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15 with a peak-hour packet loss rate no higher than 1%; open questions remain about the latency target and the completion deadline, please clarify and confirm." }, "data": { - "target_negotiation_description": "The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm.", + "target_negotiation_description": "专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。", "intent_understanding": [ - { "name": "task intent", "value": "apply wireless energy-saving optimization to the target site during 08:00-18:00" }, - { "name": "rate guarantee target", "value": "at least 20Mbps during the energy-saving period" } + { "name": "修复意图", "value": "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内" }, + { "name": "修复目标", "value": "高峰时段丢包率不高于1%" } ], "alignment_and_clarification": null, "request_for_clarification": [ - { "name": "area", "value": "which site is covered: Songshan Lake or another site" }, - { "name": "time range", "value": "is the energy-saving period 08:00-18:00 or 00:00-06:00" } + { "name": "时延目标", "value": "平均时延目标是恢复至20ms以内还是50ms以内" }, + { "name": "完成时限", "value": "修复完成时限是48小时内还是72小时内" } ] } }, @@ -135,13 +132,12 @@ "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "target_propose", + "promptTextContains": ["修复意图", "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内", "高峰时段丢包率不高于1%", "时延目标", "平均时延目标是恢复至20ms以内还是50ms以内", "完成时限", "修复完成时限是48小时内还是72小时内"], "metadata": { "templateUriEcho": "Negotiation-T/target-negotiation/propose/v1", "contextEcho": true }, - "contracts": ["metadataTripleShape"], - "differential": true + "contracts": ["metadataTripleShape"] } }, { @@ -149,18 +145,18 @@ "api": "generateAcceptFromText", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "target", "accept", "differential"], - "summary": "意图确认后从自然语言生成目标协商接受消息,消息携带最终确认意图并含 Accept 结论字面量,且两路等值", + "tags": ["happy", "target", "accept"], + "summary": "时延恢复目标确认后从自然语言生成目标协商接受消息,消息携带最终确认意图并含 Accept 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "最终确认的意图:对松山湖站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps。", - "en-US": "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps." + "zh-CN": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。", + "en-US": "The finally confirmed intent: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15, with a peak-hour packet loss rate no higher than 1%." }, "data": { "conclusion": "Accept", - "confirmed_intent": "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps.", + "confirmed_intent": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。", "failure_reason": null } }, @@ -168,13 +164,12 @@ "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "target_accept", + "promptTextContains": ["Accept", "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。"], "metadata": { "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", "contextEcho": true }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], - "differential": true + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] } }, { @@ -182,32 +177,31 @@ "api": "generateRejectFromText", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "target", "reject", "differential"], - "summary": "信息无法澄清时从自然语言生成目标协商拒绝消息,消息携带拒绝原因并含 Reject 结论字面量,且两路等值", + "tags": ["happy", "target", "reject"], + "summary": "光缆割接窗口未确认致时延目标无法澄清时从自然语言生成目标协商拒绝消息,消息携带拒绝原因并含 Reject 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "无法完整澄清区域信息:站点库存数据不可用,本轮目标协商拒绝。", - "en-US": "The area information cannot be clarified in full because the site inventory is unavailable; this target negotiation round is rejected." + "zh-CN": "因光缆割接窗口尚未确认,时延目标未能完全澄清,本轮目标协商拒绝。", + "en-US": "The latency target cannot be clarified in full because the fiber cutover window is not confirmed yet; this target negotiation round is rejected." }, "data": { "conclusion": "Reject", "confirmed_intent": null, - "failure_reason": "The area information cannot be clarified in full because the site inventory is unavailable." + "failure_reason": "因光缆割接窗口尚未确认,时延目标未能完全澄清。" } }, "llm": { "script": [ { "$ref": "responses/extract.target.reject.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "target_reject", + "promptTextContains": ["Reject", "因光缆割接窗口尚未确认,时延目标未能完全澄清。"], "metadata": { "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", "contextEcho": true }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], - "differential": true + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] } }, { @@ -215,21 +209,21 @@ "api": "generateProposeFromText", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "feasibility", "propose", "evaluation-request", "differential"], - "summary": "从自然语言生成可行性协商提议消息,请求对方评估调整后的速率保障目标,且 fromText 与 fromData 两路等值", + "tags": ["happy", "feasibility", "propose", "evaluation-request"], + "summary": "工作台请 OMC 评估接入端口扩容方案能否在割接窗口内完成,从自然语言生成可行性协商提议消息,携带扩容方案与既有约束两项待评估内容(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", "input": { "text": { - "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps,且停电场景下供电时长不少于10小时。", - "en-US": "Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved: the rate guarantee target is lowered from 5Mbps to 2Mbps during 08:00-18:00, with at least 10 hours of power supply duration in outage scenarios." + "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps,割接窗口仅限2026年5月30日02:00-06:00且业务中断不超过30分钟。", + "en-US": "Please assess whether the dedicated-line access port expansion can be completed within the cutover window: expand the access port bandwidth from 100Mbps to 1000Mbps, with the cutover window limited to 02:00-06:00 on 2026-05-30 and service interruption no longer than 30 minutes." }, "data": { - "feasibility_negotiation_description": "Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess.", + "feasibility_negotiation_description": "请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。", "action": "REQUEST_FEASIBILITY_EVALUATION", "contents_to_evaluate": [ - { "name": "adjusted target", "value": "rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00" }, - { "name": "existing constraint", "value": "at least 10 hours of power supply duration in outage scenarios" } + { "name": "扩容方案", "value": "接入端口带宽由100Mbps扩容至1000Mbps" }, + { "name": "既有约束", "value": "割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟" } ], "infeasibility_details_and_proposal": null } @@ -238,13 +232,12 @@ "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_propose", + "promptTextContains": ["扩容方案", "接入端口带宽由100Mbps扩容至1000Mbps", "既有约束", "割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟"], "metadata": { "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", "contextEcho": true }, - "contracts": ["metadataTripleShape"], - "differential": true + "contracts": ["metadataTripleShape"] } }, { @@ -252,31 +245,30 @@ "api": "generateAcceptFromText", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "feasibility", "accept", "differential"], - "summary": "评估通过后从自然语言生成可行性协商接受消息,消息携带可行性结论并含 Accept 结论字面量,且两路等值", + "tags": ["happy", "feasibility", "accept"], + "summary": "端口扩容可行性评估通过后从自然语言生成可行性协商接受消息,消息携带可行性结论并含 Accept 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "调整后的速率保障目标可以实现并满足停电时长要求,本次可行性协商确认达成一致。", - "en-US": "The adjusted rate guarantee target is achievable and satisfies the outage duration requirement; this feasibility negotiation is confirmed as concluded." + "zh-CN": "端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束,本次可行性协商确认结束。", + "en-US": "The port expansion can be completed within the cutover window and satisfies the 30-minute interruption constraint; this feasibility negotiation is confirmed as concluded." }, "data": { "conclusion": "Accept", - "feasibility_summary": "The adjusted rate guarantee target is achievable and satisfies the outage duration requirement; this negotiation is confirmed as concluded." + "feasibility_summary": "端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。" } }, "llm": { "script": [ { "$ref": "responses/extract.feasibility.accept.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_accept", + "promptTextContains": ["Accept", "端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。"], "metadata": { "templateUriEcho": "Negotiation-T/feasibility-negotiation/accept-reject/v1", "contextEcho": true }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], - "differential": true + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] } }, { @@ -284,31 +276,30 @@ "api": "generateRejectFromText", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "feasibility", "reject", "differential"], - "summary": "评估不通过时从自然语言生成可行性协商拒绝消息,消息携带不可行结论并含 Reject 结论字面量,且两路等值", + "tags": ["happy", "feasibility", "reject"], + "summary": "汇聚机房板卡槽位不足致端口扩容不可行时从自然语言生成可行性协商拒绝消息,消息携带不可行结论并含 Reject 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "在现有供电约束下节能目标无法达成,本次可行性协商确认无法达成一致。", - "en-US": "The energy-saving target cannot be achieved under the existing power supply constraint; this feasibility negotiation is confirmed as concluded without agreement." + "zh-CN": "受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成,本次可行性协商确认结束。", + "en-US": "The port expansion cannot be completed within the designated cutover window because of insufficient board slots in the aggregation room; this feasibility negotiation is confirmed as concluded." }, "data": { "conclusion": "Reject", - "feasibility_summary": "The energy-saving target cannot be achieved under the existing power supply constraint; this negotiation is confirmed as concluded." + "feasibility_summary": "受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。" } }, "llm": { "script": [ { "$ref": "responses/extract.feasibility.reject.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_reject", + "promptTextContains": ["Reject", "受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。"], "metadata": { "templateUriEcho": "Negotiation-T/feasibility-negotiation/accept-reject/v1", "contextEcho": true }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], - "differential": true + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] } }, { @@ -316,30 +307,29 @@ "api": "generateAbortFromText", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "abort", "common", "differential"], - "summary": "轮次耗尽时从自然语言生成协商终止消息,走通用 abort 模板并含终止原因,且 fromText 与 fromData 两路等值", + "tags": ["happy", "abort", "common"], + "summary": "投诉诊断协商轮次耗尽时从自然语言生成协商终止消息,走通用 abort 模板并含终止原因与 Abort 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", "input": { "text": { - "zh-CN": "协商轮次已达上限,本次协商确认终止。", + "zh-CN": "已达到协商轮次上限,本次协商确认终止。", "en-US": "The negotiation round limit is reached; this negotiation is confirmed as terminated." }, "data": { - "termination_reason": "The negotiation round limit is reached; this negotiation is confirmed as terminated." + "termination_reason": "已达到协商轮次上限,本次协商确认终止。" } }, "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "abort", + "promptTextContains": ["Abort", "已达到协商轮次上限,本次协商确认终止。"], "metadata": { "templateUriEcho": "Negotiation-T/common/abort/v1", "contextEcho": true }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"], - "differential": true + "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] } } ] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/programming-errors.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/programming-errors.json index b77b160f..725289a6 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/programming-errors.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/programming-errors.json @@ -5,9 +5,9 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "npe", "null-context"], - "summary": "协商上下文缺失时生成提议消息在管线入口直接失败,抛出空指针异常且不发起任何大模型调用", + "summary": "协商上下文缺失时生成投诉诊断的信息协商提议在管线入口直接失败,抛出空指针异常且不发起任何大模型调用", "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, "expect": { "outcome": "failure", @@ -22,9 +22,9 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "npe", "null-template-uri"], - "summary": "模板地址缺失时生成接受消息在地址解析入口直接失败,抛出空指针异常且不发起任何大模型调用", + "summary": "模板地址缺失时生成信息协商接受消息在地址解析入口直接失败,抛出空指针异常且不发起任何大模型调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, "expect": { "outcome": "failure", @@ -39,7 +39,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-text", "invalid-input"], - "summary": "调用方未传入任何自然语言内容时生成提议消息被拒绝,返回无效输入错误码且不发起任何大模型调用", + "summary": "调用方未传入任何投诉诊断自然语言内容时生成提议消息被拒绝,返回无效输入错误码且不发起任何大模型调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, @@ -57,7 +57,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "blank-text", "invalid-input"], - "summary": "自然语言内容仅含空白字符时生成接受消息被拒绝,返回无效输入错误码且不发起任何大模型调用", + "summary": "投诉诊断补充内容仅含空白字符时生成接受消息被拒绝,返回无效输入错误码且不发起任何大模型调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { "zh-CN": " " } }, @@ -76,10 +76,10 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "iae", "uri-probe", "version-segment"], - "summary": "模板地址误用未发布的 v2 版本段时生成提议消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", + "summary": "模板地址误用未发布的 v2 版本段时生成信息协商提议在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v2", - "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, "expect": { "outcome": "failure", @@ -94,10 +94,10 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "iae", "uri-probe", "extension-name"], - "summary": "模板地址误用 Task-T 扩展名时生成接受消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", + "summary": "模板地址误用 Task-T 扩展名时生成信息协商接受消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Task-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, "expect": { "outcome": "failure", @@ -112,10 +112,10 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "iae", "uri-probe", "type-misspelled"], - "summary": "模板地址中协商类型拼写错误时生成拒绝消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", + "summary": "模板地址中协商类型拼写错误时生成信息协商拒绝消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/informations-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" } }, + "input": { "text": { "zh-CN": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮协商拒绝。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, "expect": { "outcome": "failure", @@ -133,7 +133,7 @@ "summary": "模板地址中阶段段未知时生成协商终止消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/terminate/v1", - "input": { "text": { "zh-CN": "协商轮次已达上限,本次协商确认终止。" } }, + "input": { "text": { "zh-CN": "已达到协商轮次上限,本次协商确认终止。" } }, "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, "expect": { "outcome": "failure", diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/retry.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/retry.json index 18ea67d6..6e99ce34 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/retry.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/retry.json @@ -8,7 +8,7 @@ "summary": "最大尝试次数为1时大模型返回不可解析内容,不发起任何重试即以抽取失败错误码失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, "llm": { "maxAttempts": 1, "script": [ { "$fail": "non-json" } ] }, "expect": { "outcome": "failure", @@ -26,7 +26,7 @@ "summary": "最大尝试次数为2时大模型连续返回空响应与空白内容,尝试耗尽后仍以原始的抽取失败错误码失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, "llm": { "maxAttempts": 2, "script": [ { "$fail": "null-response" }, { "$fail": "blank-content" } ] }, "expect": { "outcome": "failure", @@ -44,7 +44,7 @@ "summary": "最大尝试次数为3时三种不可解析形态依次出现,三次尝试耗尽后以抽取失败错误码失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, "llm": { "maxAttempts": 3, "script": [ { "$fail": "non-json" }, { "$fail": "blank-content" }, { "$fail": "null-response" } ] }, "expect": { "outcome": "failure", @@ -62,7 +62,7 @@ "summary": "最大尝试次数为1时底层通道抛出运行时异常,不重试即以基础设施错误码失败且异常细节不外泄", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, "llm": { "maxAttempts": 1, "script": [ { "$fail": "runtime-exception" } ] }, "expect": { "outcome": "failure", @@ -81,7 +81,7 @@ "summary": "最大尝试次数为2时底层先后出现传输异常与错误响应,尝试耗尽后仍以基础设施错误码失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, "llm": { "maxAttempts": 2, "script": [ { "$fail": "runtime-exception" }, { "$fail": "llm-error" } ] }, "expect": { "outcome": "failure", @@ -100,7 +100,7 @@ "summary": "最大尝试次数为3时基础设施错误持续出现,三次尝试耗尽后以基础设施错误码失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" } }, + "input": { "text": { "zh-CN": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮协商拒绝。" } }, "llm": { "maxAttempts": 3, "script": [ { "$fail": "llm-error" }, { "$fail": "runtime-exception" }, { "$fail": "llm-error" } ] }, "expect": { "outcome": "failure", @@ -115,21 +115,26 @@ "api": "generateAcceptFromText", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["retry", "recovery", "extract-failed", "accept-reject"], - "summary": "首次返回不可解析内容后第二次返回合法接受内容,重试后成功生成与 golden 一致的接受消息", + "tags": ["retry", "recovery", "extract-failed", "accept-reject", "golden-downgraded-contains"], + "summary": "首次返回不可解析内容后第二次返回合法补数接受内容,重试后成功生成携带接入端口名称与投诉分类的接受消息(共享应答单一语种,双语 golden 等值断言降级为内容包含)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。", - "en-US": "I confirm the first-stage information: the energy-saving area is Songshan Lake and the rate guarantee target is 20Mbps." + "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。", + "en-US": "I confirm the supplemented complaint diagnosis information: the access port name is P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1 and the complaint category is dedicated-line quality degradation." } }, "llm": { "maxAttempts": 3, "script": [ { "$fail": "non-json" }, { "$ref": "responses/extract.information.accept.full" } ] }, "expect": { "outcome": "success", "llmCalls": 2, - "promptTextEqualsGolden": "information_accept", + "promptTextContains": [ + "接入端口名称", + "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "投诉分类", + "专线质差" + ], "metadata": { "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", "contextEcho": true @@ -142,21 +147,28 @@ "api": "generateProposeFromText", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["retry", "recovery", "infrastructure-error"], - "summary": "首次底层传输异常后第二次抽取成功,重试后生成与 golden 一致的信息提议消息且异常细节不外泄", + "tags": ["retry", "recovery", "infrastructure-error", "golden-downgraded-contains"], + "summary": "首次底层传输异常后第二次抽取成功,重试后生成向工作台补充三项缺失信息的要数提议消息且异常细节不外泄(共享应答单一语种,双语 golden 等值断言降级为内容包含)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { - "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。", - "en-US": "Please supplement the energy-saving area information (e.g. Songshan Lake), the energy-saving rate guarantee target (e.g. 20Mbps) and the VLANId; the three items are related by OR." + "zh-CN": "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识三项信息,三项之间为或的关系。", + "en-US": "Please supplement the Access Port Name (e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1), the Complaint Category (e.g. dedicated-line quality degradation) and the Private Line Service Identifier; the three items are related by OR." } }, "llm": { "maxAttempts": 2, "script": [ { "$fail": "runtime-exception" }, { "$ref": "responses/extract.information.propose.full" } ] }, "expect": { "outcome": "success", "llmCalls": 2, - "promptTextEqualsGolden": "information_propose", + "promptTextContains": [ + "接入端口名称", + "投诉分类", + "专线业务标识", + "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "专线质差", + "OR" + ], "metadata": { "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", "contextEcho": true @@ -169,21 +181,23 @@ "api": "generateAcceptFromText", "languages": ["zh-CN", "en-US"], "priority": "P1", - "tags": ["retry", "recovery", "final-attempt", "target"], - "summary": "前两次分别出现基础设施错误与不可解析内容,第三次抽取成功,在最后一次尝试额度内恢复生成接受消息", + "tags": ["retry", "recovery", "final-attempt", "target", "golden-downgraded-contains"], + "summary": "前两次分别出现基础设施错误与不可解析内容,第三次抽取成功,在最后一次尝试额度内恢复生成修复目标接受消息(共享应答单一语种,双语 golden 等值断言降级为内容包含)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "最终确认的意图:对松山湖站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps。", - "en-US": "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps." + "zh-CN": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。", + "en-US": "The finally confirmed intent: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15, with a peak-hour packet loss rate no higher than 1%." } }, "llm": { "maxAttempts": 3, "script": [ { "$fail": "llm-error" }, { "$fail": "non-json" }, { "$ref": "responses/extract.target.accept.full" } ] }, "expect": { "outcome": "success", "llmCalls": 3, - "promptTextEqualsGolden": "target_accept", + "promptTextContains": [ + "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" + ], "metadata": { "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", "contextEcho": true @@ -196,13 +210,13 @@ "api": "generateAbortFromText", "languages": ["zh-CN", "en-US"], "priority": "P1", - "tags": ["retry", "recovery", "abort", "common"], - "summary": "终止消息首次抽取到空白内容后第二次成功,重试后生成含终止原因的协商终止消息", + "tags": ["retry", "recovery", "abort", "common", "golden-downgraded-contains"], + "summary": "协商终止消息首次抽取到空白内容后第二次成功,重试后生成含终止原因的协商终止消息(共享应答单一语种,双语 golden 等值断言降级为内容包含)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", "input": { "text": { - "zh-CN": "协商轮次已达上限,本次协商确认终止。", + "zh-CN": "已达到协商轮次上限,本次协商确认终止。", "en-US": "The negotiation round limit is reached; this negotiation is confirmed as terminated." } }, @@ -210,7 +224,9 @@ "expect": { "outcome": "success", "llmCalls": 2, - "promptTextEqualsGolden": "abort", + "promptTextContains": [ + "已达到协商轮次上限,本次协商确认终止。" + ], "metadata": { "templateUriEcho": "Negotiation-T/common/abort/v1", "contextEcho": true @@ -227,7 +243,7 @@ "summary": "抽取结果缺少结论字段属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, "llm": { "maxAttempts": 3, "script": [ { "$ref": "responses/extract.information.accept.missing_conclusion" } ] }, "expect": { "outcome": "failure", @@ -245,7 +261,7 @@ "summary": "结论与阶段矛盾属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" } }, + "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, "llm": { "maxAttempts": 3, "script": [ { "$ref": "responses/extract.information.accept.conclusion_swapped" } ] }, "expect": { "outcome": "failure", @@ -263,7 +279,7 @@ "summary": "两次失败形态不同(基础设施错误后接不可解析内容)时,耗尽后重抛最后一次失败的原始错误码而非统一的耗尽错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, "llm": { "maxAttempts": 2, "script": [ { "$fail": "runtime-exception" }, { "$fail": "non-json" } ] }, "expect": { "outcome": "failure", diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/template-resolution.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/template-resolution.json index bece8f50..3a94c9c7 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/template-resolution.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/template-resolution.json @@ -5,10 +5,10 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["template-resolution", "template-not-found", "inject"], - "summary": "模板加载器不可用时生成失败并返回 template_not_found 错误码,且不发起任何 LLM 调用", + "summary": "模板加载器不可用时生成投诉诊断信息协商接受消息失败并返回 template_not_found 错误码,且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "请接受。" } }, + "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, "inject": "failingTemplateLoader", "expect": { @@ -24,10 +24,10 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["template-resolution", "template-not-found", "inject", "propose", "no-retry"], - "summary": "模板加载器不可用时生成提议消息失败并返回 template_not_found 错误码,模板加载发生在重试循环之外,即使配置了重试次数也不重试且不发起任何 LLM 调用", + "summary": "模板加载器不可用时生成要数提议消息失败并返回 template_not_found 错误码,模板加载发生在重试循环之外,即使配置了重试次数也不重试且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充节能区域信息、节能速率保障目标与VLANId三项信息。" } }, + "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, "llm": { "maxAttempts": 3, "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, "inject": "failingTemplateLoader", "expect": { @@ -43,10 +43,10 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["template-resolution", "template-not-found", "inject", "reject"], - "summary": "模板加载器不可用时生成拒绝消息失败并返回 template_not_found 错误码,且不发起任何 LLM 调用", + "summary": "模板加载器不可用时生成信息协商拒绝消息失败并返回 template_not_found 错误码,且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" } }, + "input": { "text": { "zh-CN": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮协商拒绝。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, "inject": "failingTemplateLoader", "expect": { @@ -65,7 +65,7 @@ "summary": "模板加载器不可用时生成协商终止消息失败并返回 template_not_found 错误码,通用终止模板同样无法加载且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", - "input": { "text": { "zh-CN": "协商轮次已达上限,本次协商确认终止。" } }, + "input": { "text": { "zh-CN": "已达到协商轮次上限,本次协商确认终止。" } }, "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, "inject": "failingTemplateLoader", "expect": { diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/boundary-flows.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/boundary-flows.json index 610ec852..57c7a439 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/boundary-flows.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/boundary-flows.json @@ -1,19 +1,71 @@ [ { "id": "SC-EXH-01", - "summary": "轮次预算耗尽边界:三轮提议均遭拒绝,round 3 达到 maxRounds 上限后生成终止消息结束协商", + "summary": "轮次预算耗尽闭环:投诉诊断任务缺参后两轮要数均因工作台端口台账不可查被拒,round 3 达到 maxRounds 上限后 OMC 生成终止消息结束协商", "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, "steps": [ { "step": 1, "role": "A", + "api": "generateTaskPromptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "input": { + "text": { + "zh-CN": "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢,柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。" + } + }, + "llm": { + "script": [ + "{\"slots\":{\"任务对象\":\"\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\"},\"slot_errors\":[]}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "metadata": { + "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" + }, + "promptTextContains": ["event-id-20260511-09013"] + } + }, + { + "step": 2, + "role": "B", + "api": "validateTaskPromptAndDataFilling", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": { "fromStep": 1 }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { + "script": [ + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "faultTime": "2026-05-11T08:21:46Z", + "eventSerialNo": "event-id-20260511-09013" + }, + "missingParams": ["accessPort", "bizScenario"] + } + }, + { + "step": 3, + "role": "B", "api": "generateProposeFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { - "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, @@ -24,86 +76,93 @@ } }, { - "step": 2, - "role": "B", + "step": 4, + "role": "A", "api": "generateRejectFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" + "zh-CN": "端口资源台账暂不可查,无法提供接入端口名称,本轮信息协商拒绝。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "information_reject" + "promptTextEqualsGolden": "information_reject", + "contracts": ["conclusionLiteralPresent"] } }, { - "step": 3, - "role": "A", + "step": 5, + "role": "B", "api": "generateProposeFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 3 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { - "zh-CN": "经内部核实,现仅请求补充节能区域信息(松山湖)与节能速率保障目标(20Mbps)两项,两项之间为且的关系。" + "zh-CN": "第2轮信息协商:请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)与投诉分类(如专线质差)两项。" } }, "llm": { "script": [ - "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}],\"relationship\":\"AND\"}" + "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"}],\"relationship\":\"AND\"}" ] }, "expect": { "outcome": "success", "llmCalls": 1, + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", + "contextEcho": true + }, "contracts": ["metadataTripleShape"] } }, { - "step": 4, - "role": "B", + "step": 6, + "role": "A", "api": "generateRejectFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 3 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "仍无法提供节能区域信息:站点库存数据暂不可用,本轮协商再次拒绝。" + "zh-CN": "端口资源台账仍不可查,无法提供接入端口名称,本轮信息协商再次拒绝。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "information_reject" + "promptTextEqualsGolden": "information_reject", + "contracts": ["conclusionLiteralPresent"] } }, { - "step": 5, - "role": "A", + "step": 7, + "role": "B", "api": "generateAbortFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 3 }, "templateUri": "Negotiation-T/common/abort/v1", "input": { "text": { - "zh-CN": "协商轮次已达上限,本次协商确认终止。" + "zh-CN": "信息协商两轮未果,已达到协商轮次上限,本次协商确认终止。" } }, "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "abort" + "promptTextEqualsGolden": "abort", + "contracts": ["conclusionLiteralPresent"] } }, { - "step": 6, - "role": "B", + "step": 8, + "role": "A", "api": "validateAbortPromptAndDataFilling", - "prompt": { "fromStep": 5 }, + "prompt": { "fromStep": 7 }, "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 3 }, "templateUri": "Negotiation-T/common/abort/v1", "schema": { "$ref": "schemas/flat" }, @@ -115,7 +174,7 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 3, - "termination_reason": "The negotiation round limit is reached." + "termination_reason": "已达到协商轮次上限" } } } @@ -124,19 +183,23 @@ }, { "id": "SC-EXH-02", - "summary": "过期重放边界:携带 round 超过轮次预算的消息被规则门直接拦截且不调用大模型,修正轮次后重放校验通过", + "summary": "过期重放边界:工作台以 round 6 超出轮次预算的上下文重放校验 OMC 的要数提议,被规则门直接拦截且不调用大模型,修正为 round 5 后重放校验通过", "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, "steps": [ { "step": 1, - "role": "A", + "role": "B", "api": "generateProposeFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { - "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, @@ -148,12 +211,12 @@ }, { "step": 2, - "role": "B", + "role": "A", "api": "validateProposePromptAndDataFilling", "prompt": { "fromStep": 1 }, "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 6, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "expect": { "outcome": "failure", "code": "negotiation_rule_violation", @@ -163,12 +226,12 @@ }, { "step": 3, - "role": "B", + "role": "A", "api": "validateProposePromptAndDataFilling", "prompt": { "fromStep": 1 }, "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -177,7 +240,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" } } } @@ -186,203 +250,274 @@ }, { "id": "SC-ERR-01", - "summary": "流程中错误恢复:大模型内容抽取首次返回不可解析结果,重试第二次成功后协商流程继续推进直至接受闭环", + "summary": "闭环中错误恢复:工作台首次生成任务报文成功,OMC 校验任务报文时语义校验首次返回不可解析内容,重试第二次成功后闭环继续推进,直至缺参被补齐且接受报文提取成功(不可解析探针挂语义校验步骤:任务槽位抽取路径无重试,maxAttempts 仅作用于语义校验)", "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, "steps": [ { "step": 1, "role": "A", - "api": "generateProposeFromText", + "api": "generateTaskPromptFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "templateUri": "Task-T/network-layer/private-line-complaint/v1", "input": { "text": { - "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + "zh-CN": "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢,柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。" } }, + "llm": { + "script": [ + "{\"slots\":{\"任务对象\":\"\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\"},\"slot_errors\":[]}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "metadata": { + "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" + }, + "promptTextContains": ["event-id-20260511-09013"] + } + }, + { + "step": 2, + "role": "B", + "api": "validateTaskPromptAndDataFilling", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": { "fromStep": 1 }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "maxAttempts": 3, "script": [ { "$fail": "non-json" }, - { "$ref": "responses/extract.information.propose.full" } + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" ] }, "expect": { "outcome": "success", "llmCalls": 2, - "promptTextEqualsGolden": "information_propose" + "params": { + "faultTime": "2026-05-11T08:21:46Z", + "eventSerialNo": "event-id-20260511-09013" + }, + "missingParams": ["accessPort", "bizScenario"] } }, { - "step": 2, + "step": 3, "role": "B", - "api": "validateProposePromptAndDataFilling", - "prompt": { "fromStep": 1 }, + "api": "generateProposeFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/flat" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "input": { + "text": { + "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 1, - "maxRounds": 5, - "region": "Songshan Lake" - } + "promptTextEqualsGolden": "information_propose" } }, { - "step": 3, - "role": "B", + "step": 4, + "role": "A", "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" + "zh-CN": "端口台账已查到:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差,确认补充。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "information_accept" + "promptTextEqualsGolden": "information_accept", + "contracts": ["conclusionLiteralPresent"] } }, { - "step": 4, - "role": "A", + "step": 5, + "role": "B", "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 3 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "prompt": { "fromStep": 4 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, "params": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 1, + "round": 2, "maxRounds": 5, - "region": "Songshan Lake" - } + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" + }, + "contracts": ["contextKeysInMergedParams"], + "paramsFromStep": 2 } } ], - "expectFlow": { "terminalCondition": "accept", "roundsUsed": 1, "distinctMessages": true } + "expectFlow": { + "terminalCondition": "accept", + "roundsUsed": 2, + "distinctMessages": true, + "missingParamsFilled": 2 + } }, { "id": "SC-ERR-02", - "summary": "流程中错误恢复:round 1 提议因缺少节能区域信息被语义校验拒绝,round 2 补充内容重新提议后通过并接受", + "summary": "闭环中错误恢复:工作台首轮补数仅提供投诉分类缺接入端口名称,被 OMC 语义校验拒绝,第2轮补充端口名后重新接受并提取成功,缺参最终补齐", "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, "steps": [ { "step": 1, "role": "A", - "api": "generateProposeFromText", + "api": "generateTaskPromptFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "templateUri": "Task-T/network-layer/private-line-complaint/v1", "input": { "text": { - "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + "zh-CN": "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢,柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "llm": { + "script": [ + "{\"slots\":{\"任务对象\":\"\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\"},\"slot_errors\":[]}" + ] + }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "information_propose" + "metadata": { + "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" + }, + "promptTextContains": ["event-id-20260511-09013"] } }, { "step": 2, "role": "B", - "api": "validateProposePromptAndDataFilling", - "prompt": { "fromStep": 1 }, + "api": "validateTaskPromptAndDataFilling", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/flat" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.reject" } ] }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": { "fromStep": 1 }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { + "script": [ + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" + ] + }, "expect": { - "outcome": "failure", - "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "region", "code": "missing" } ], - "llmCalls": 1 + "outcome": "success", + "llmCalls": 1, + "params": { + "faultTime": "2026-05-11T08:21:46Z", + "eventSerialNo": "event-id-20260511-09013" + }, + "missingParams": ["accessPort", "bizScenario"] } }, { "step": 3, - "role": "A", + "role": "B", "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { - "zh-CN": "现已补充完成:节能区域信息为松山湖,节能速率保障目标为20Mbps,请确认。" + "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" } }, - "llm": { - "script": [ - "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}],\"relationship\":\"OR\"}" - ] - }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "contracts": ["metadataTripleShape"] + "promptTextEqualsGolden": "information_propose" } }, { "step": 4, - "role": "B", - "api": "validateProposePromptAndDataFilling", - "prompt": { "fromStep": 3 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/flat" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "role": "A", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "投诉分类为专线质差;接入端口名称暂时无法提供,端口台账查询中。" + } + }, + "llm": { + "script": [ + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}" + ] + }, "expect": { "outcome": "success", "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "region": "Songshan Lake" - } + "promptTextContains": ["Accept", "专线质差"], + "contracts": ["conclusionLiteralPresent"] } }, { "step": 5, "role": "B", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { "fromStep": 4 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.reject" } ] }, + "expect": { + "outcome": "failure", + "code": "negotiation_semantic_rejected", + "slotErrors": [ { "slot": "accessPort", "code": "missing" } ], + "llmCalls": 1 + } + }, + { + "step": 6, + "role": "A", "api": "generateAcceptFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" + "zh-CN": "端口台账已恢复,我补充:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "information_accept" + "promptTextEqualsGolden": "information_accept", + "contracts": ["conclusionLiteralPresent"] } }, { - "step": 6, - "role": "A", + "step": 7, + "role": "B", "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 5 }, + "prompt": { "fromStep": 6 }, "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -391,18 +526,30 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" - } + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" + }, + "contracts": ["contextKeysInMergedParams"], + "paramsFromStep": 2 } } ], - "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2, "distinctMessages": true } + "expectFlow": { + "terminalCondition": "accept", + "roundsUsed": 2, + "distinctMessages": true, + "missingParamsFilled": 2 + } }, { "id": "SC-ERR-03", - "summary": "重传幂等:同一条接受消息被重复校验两次,两次提取的参数结果完全一致,重传不产生副作用", + "summary": "重传幂等:同一条接受补数报文被 OMC 重复校验两次,两次提取的 FilledParamData(接入端口名称与投诉分类)完全一致,重传不产生副作用", "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, "steps": [ { "step": 1, @@ -412,14 +559,15 @@ "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" + "zh-CN": "端口台账已查到:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差,确认补充。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "information_accept" + "promptTextEqualsGolden": "information_accept", + "contracts": ["conclusionLiteralPresent"] } }, { @@ -429,7 +577,7 @@ "prompt": { "fromStep": 1 }, "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -438,8 +586,10 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" - } + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" + }, + "contracts": ["contextKeysInMergedParams"] } }, { @@ -449,7 +599,7 @@ "prompt": { "fromStep": 1 }, "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -458,8 +608,10 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" - } + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" + }, + "contracts": ["contextKeysInMergedParams"] } } ], @@ -467,19 +619,23 @@ }, { "id": "SC-ERR-04", - "summary": "乱序失同步探针:B 以滞后的 round 1 上下文校验 A 在 round 2 生成的提议,规则门不拦截失同步(doc-gap 探针)", + "summary": "乱序失同步探针:工作台以滞后的 round 1 上下文校验 OMC 在 round 2 生成的要数提议,规则门不拦截失同步(doc-gap 探针)", "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, "steps": [ { "step": 1, - "role": "A", + "role": "B", "api": "generateProposeFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { - "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + "zh-CN": "第2轮信息协商:任务报文仍缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差)。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, @@ -491,12 +647,12 @@ }, { "step": 2, - "role": "B", + "role": "A", "api": "validateProposePromptAndDataFilling", "prompt": { "fromStep": 1 }, "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -505,7 +661,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" } } } @@ -514,19 +671,23 @@ }, { "id": "SC-ERR-05", - "summary": "截断消息探针:传输中被截断的提议消息缺失后两项信息,语义校验以槽位缺失为由拒绝", + "summary": "截断消息探针:传输中被截断的要数提议缺失后两项信息,工作台侧语义校验以槽位缺失为由拒绝", "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, "steps": [ { "step": 1, - "role": "A", + "role": "B", "api": "generateProposeFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { - "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, @@ -538,25 +699,25 @@ }, { "step": 2, - "role": "B", + "role": "A", "api": "validateProposePromptAndDataFilling", "prompt": { - "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. energy-saving area information:e.g. Songshan L" + "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分" }, "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"energy-saving rate guarantee target\",\"code\":\"missing\",\"message\":\"The message is truncated and the item is missing.\"},{\"slot_name\":\"VLANId\",\"code\":\"missing\",\"message\":\"The message is truncated and the item is missing.\"}],\"params\":{}}" + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"投诉分类\",\"code\":\"missing\",\"message\":\"The message is truncated and the item is missing.\"},{\"slot_name\":\"专线业务标识\",\"code\":\"missing\",\"message\":\"The message is truncated and the item is missing.\"}],\"params\":{}}" ] }, "expect": { "outcome": "failure", "code": "negotiation_semantic_rejected", "slotErrors": [ - { "slot": "energy-saving rate guarantee target", "code": "missing" }, - { "slot": "VLANId", "code": "missing" } + { "slot": "投诉分类", "code": "missing" }, + { "slot": "专线业务标识", "code": "missing" } ], "llmCalls": 1 } @@ -566,9 +727,13 @@ }, { "id": "SC-ERR-06", - "summary": "编码边界探针:带 UTF-8 BOM 前缀或全角标点的接受消息可正常校验提取,GBK 乱码形态的消息被语义校验拒绝", + "summary": "编码边界探针:带 UTF-8 BOM 前缀或全角标点的接受补数报文可正常校验提取,GBK 乱码形态的报文被语义校验拒绝", "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, "steps": [ { "step": 1, @@ -578,7 +743,7 @@ "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" + "zh-CN": "端口台账已查到:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差,确认补充。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, @@ -593,11 +758,11 @@ "role": "B", "api": "validateAcceptPromptAndDataFilling", "prompt": { - "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. energy-saving area information:Songshan Lake\n2. energy-saving rate guarantee target:20Mbps" + "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:专线质差" }, "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -606,7 +771,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" } } }, @@ -615,14 +781,14 @@ "role": "B", "api": "validateAcceptPromptAndDataFilling", "prompt": { - "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1.节能区域信息:松山湖\n2.节能速率保障目标:20Mbps" + "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1.接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2.投诉分类:专线质差" }, "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"region\":\"松山湖\"}}" + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\",\"bizScenario\":\"专线质差\"}}" ] }, "expect": { @@ -632,7 +798,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "松山湖" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" } } }, @@ -641,22 +808,22 @@ "role": "B", "api": "validateAcceptPromptAndDataFilling", "prompt": { - "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. energy-saving area information:锟斤拷锟斤拷\n2. energy-saving rate guarantee target:锟斤拷烫烫" + "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:锟斤拷锟斤拷\n2. 投诉分类:锟斤拷烫烫" }, "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"energy-saving area information\",\"code\":\"unreadable\",\"message\":\"The item value is garbled and unreadable.\"},{\"slot_name\":\"energy-saving rate guarantee target\",\"code\":\"unreadable\",\"message\":\"The item value is garbled and unreadable.\"}],\"params\":{}}" + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"接入端口名称\",\"code\":\"unreadable\",\"message\":\"The item value is garbled and unreadable.\"},{\"slot_name\":\"投诉分类\",\"code\":\"unreadable\",\"message\":\"The item value is garbled and unreadable.\"}],\"params\":{}}" ] }, "expect": { "outcome": "failure", "code": "negotiation_semantic_rejected", "slotErrors": [ - { "slot": "energy-saving area information", "code": "unreadable" }, - { "slot": "energy-saving rate guarantee target", "code": "unreadable" } + { "slot": "接入端口名称", "code": "unreadable" }, + { "slot": "投诉分类", "code": "unreadable" } ], "llmCalls": 1 } @@ -666,88 +833,252 @@ }, { "id": "SC-ERR-07", - "summary": "双会话交错:信息协商与目标协商两个会话步骤交替执行,各自提取的上下文参数互不串扰", + "summary": "双会话交错:event-id-20260511-09013 与 fault-id-1-017-20260516-11234 两条投诉工单的缺参协商闭环步骤交替执行,各自提取的上下文与业务参数互不串扰", "languages": ["zh-CN"], "roles": ["A1", "B1", "A2", "B2"], + "rolesDesc": { + "A1": "工作台(client,工单 event-id-20260511-09013 的任务发起/补数方)", + "B1": "OMC(server,工单 event-id-20260511-09013 的执行/要数方,协商发起方)", + "A2": "工作台(client,工单 fault-id-1-017-20260516-11234 的任务发起/补数方)", + "B2": "OMC(server,工单 fault-id-1-017-20260516-11234 的执行/要数方,协商发起方)" + }, "steps": [ { "step": 1, "role": "A1", - "api": "generateProposeFromText", + "api": "generateTaskPromptFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "templateUri": "Task-T/network-layer/private-line-complaint/v1", "input": { "text": { - "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + "zh-CN": "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢,柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "llm": { + "script": [ + "{\"slots\":{\"任务对象\":\"\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\"},\"slot_errors\":[]}" + ] + }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "information_propose" + "metadata": { + "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" + }, + "promptTextContains": ["event-id-20260511-09013"] } }, { "step": 2, "role": "A2", - "api": "generateProposeFromText", + "api": "generateTaskPromptFromText", "context": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/propose/v1", + "templateUri": "Task-T/network-layer/private-line-complaint/v1", "input": { "text": { - "zh-CN": "无线节能优化任务的意图理解:对目标站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps;对区域与节能时间范围仍有疑问,请澄清和确认。" + "zh-CN": "前海片区一条专线从5月16日起完全中断,柜面业务全部不通,客户已发起投诉。OSS侧事件流水号:fault-id-1-017-20260516-11234。" } }, - "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, + "llm": { + "script": [ + "{\"slots\":{\"任务对象\":\"\",\"任务上下文\":\"投诉分类:待补充;OSS侧事件流水号:fault-id-1-017-20260516-11234;投诉详情:前海片区一条专线从5月16日起完全中断,柜面业务全部不通\"},\"slot_errors\":[]}" + ] + }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "target_propose" + "metadata": { + "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" + }, + "promptTextContains": ["fault-id-1-017-20260516-11234"] } }, { "step": 3, "role": "B1", - "api": "validateProposePromptAndDataFilling", + "api": "validateTaskPromptAndDataFilling", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", "prompt": { "fromStep": 1 }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { + "script": [ + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "faultTime": "2026-05-11T08:21:46Z", + "eventSerialNo": "event-id-20260511-09013" + }, + "missingParams": ["accessPort", "bizScenario"] + } + }, + { + "step": 4, + "role": "B2", + "api": "validateTaskPromptAndDataFilling", + "context": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", "round": 1, "maxRounds": 5 }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": { "fromStep": 2 }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { + "script": [ + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"eventSerialNo\":\"fault-id-1-017-20260516-11234\"}}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "eventSerialNo": "fault-id-1-017-20260516-11234" + }, + "missingParams": ["accessPort", "bizScenario"] + } + }, + { + "step": 5, + "role": "B1", + "api": "generateProposeFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/flat" }, + "input": { + "text": { + "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_propose" + } + }, + { + "step": 6, + "role": "B2", + "api": "generateProposeFromText", + "context": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100))、投诉分类(举例:专线中断)。" + } + }, + "llm": { + "script": [ + "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)\"},{\"name\":\"投诉分类\",\"value\":\"举例:专线中断\"}],\"relationship\":\"OR\"}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1" + }, + "promptTextContains": ["P781-前海"] + } + }, + { + "step": 7, + "role": "A1", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "端口台账已恢复,我补充:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "information_accept", + "contracts": ["conclusionLiteralPresent"] + } + }, + { + "step": 8, + "role": "A2", + "api": "generateAcceptFromText", + "context": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "端口台账已查到:接入端口名称为P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100),投诉分类为专线中断,确认补充。" + } + }, + "llm": { + "script": [ + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)\"},{\"name\":\"投诉分类\",\"value\":\"专线中断\"}]}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextContains": ["Accept", "P781-前海"], + "contracts": ["conclusionLiteralPresent"] + } + }, + { + "step": 9, + "role": "B1", + "api": "validateAcceptPromptAndDataFilling", + "prompt": { "fromStep": 7 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, "params": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 1, + "round": 2, "maxRounds": 5, - "region": "Songshan Lake" - } + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" + }, + "contracts": ["contextKeysInMergedParams"], + "paramsFromStep": 3 } }, { - "step": 4, + "step": 10, "role": "B2", - "api": "validateProposePromptAndDataFilling", - "prompt": { "fromStep": 2 }, - "context": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/propose/v1", - "schema": { "$ref": "schemas/flat" }, - "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, + "api": "validateAcceptPromptAndDataFilling", + "prompt": { "fromStep": 8 }, + "context": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { + "script": [ + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"accessPort\":\"P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)\",\"bizScenario\":\"专线中断\"}}" + ] + }, "expect": { "outcome": "success", "llmCalls": 1, "params": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", - "round": 1, + "round": 2, "maxRounds": 5, - "site": "Songshan Lake", - "guaranteed_rate": "at least 20Mbps" - } + "accessPort": "P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)", + "bizScenario": "专线中断" + }, + "contracts": ["contextKeysInMergedParams"], + "paramsFromStep": 4 } } ], - "expectFlow": { "roundsUsed": 1, "distinctMessages": true } + "expectFlow": { + "terminalCondition": "accept", + "roundsUsed": 2, + "distinctMessages": true, + "missingParamsFilled": 3 + } } ] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json index c631ae86..e72d2459 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json @@ -1,9 +1,13 @@ [ { "id": "SC-FSB-01", - "summary": "可行性协商单轮闭环:A 发起调整后速率保障目标的可行性评估请求,B 评估通过后生成接受消息,A 校验确认达成一致", + "summary": "端口扩容可行性协商单轮闭环(双语):工作台就接入端口带宽扩容能否在割接窗口内完成发起可行性评估请求,OMC 评估通过后生成接受消息,工作台校验确认达成一致", "languages": ["zh-CN", "en-US"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,扩容评估请求/确认方)", + "B": "OMC(server,可行性评估方)" + }, "steps": [ { "step": 1, @@ -13,15 +17,20 @@ "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", "input": { "text": { - "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps,且停电场景下供电时长不少于10小时。", - "en-US": "Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved: the rate guarantee target is lowered from 5Mbps to 2Mbps during 08:00-18:00, with at least 10 hours of power supply duration in outage scenarios." + "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps,割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟。", + "en-US": "Please assess whether the dedicated-line access port expansion can be completed within the cutover window: expand the access port bandwidth from 100Mbps to 1000Mbps, with the cutover window limited to 02:00-06:00 on 2026-05-30 and service interruption no longer than 30 minutes." } }, "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_propose" + "metadata": { + "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", + "contextEcho": true + }, + "promptTextContains": ["100Mbps", "1000Mbps", "2026", "02:00-06:00"], + "contracts": ["metadataTripleShape"] } }, { @@ -40,8 +49,9 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5, - "feasibility_result": "achievable" - } + "port_expansion": "feasible" + }, + "contracts": ["contextKeysInMergedParams"] } }, { @@ -52,15 +62,16 @@ "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "评估结论:调整后的速率保障目标可以实现并满足停电时长要求,本次可行性协商确认达成一致。", - "en-US": "Evaluation outcome: the adjusted rate guarantee target is achievable and satisfies the outage duration requirement; this feasibility negotiation is confirmed as concluded." + "zh-CN": "评估结论:端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束,本次可行性协商确认结束。", + "en-US": "Evaluation outcome: the port expansion can be completed within the cutover window and satisfies the 30-minute interruption constraint; this negotiation is confirmed as concluded." } }, "llm": { "script": [ { "$ref": "responses/extract.feasibility.accept.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_accept" + "promptTextContains": ["Accept", "30"], + "contracts": ["conclusionLiteralPresent"] } }, { @@ -79,8 +90,9 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5, - "feasibility_result": "achievable" - } + "port_expansion": "feasible" + }, + "contracts": ["contextKeysInMergedParams"] } } ], @@ -88,9 +100,13 @@ }, { "id": "SC-FSB-02", - "summary": "可行性协商多轮演进:round 1 评估结论为不可行并拒绝,A 于 round 2 改提替代方案,B 评估通过并接受,协商达成一致", - "languages": ["zh-CN", "en-US"], + "summary": "端口扩容可行性协商两轮演进:round 1 因汇聚机房板卡槽位不足评估不可行并拒绝,工作台于 round 2 改提更换高容量板卡并拆分割接窗口的替代方案,OMC 评估通过并接受", + "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,扩容评估请求/确认方)", + "B": "OMC(server,可行性评估方)" + }, "steps": [ { "step": 1, @@ -100,8 +116,7 @@ "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", "input": { "text": { - "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps,且停电场景下供电时长不少于10小时。", - "en-US": "Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved: the rate guarantee target is lowered from 5Mbps to 2Mbps during 08:00-18:00, with at least 10 hours of power supply duration in outage scenarios." + "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps,割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟。" } }, "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.full" } ] }, @@ -119,15 +134,15 @@ "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "经评估,在现有供电约束下节能目标无法达成,本次可行性协商确认无法达成一致。", - "en-US": "After evaluation, the energy-saving target cannot be achieved under the existing power supply constraint; this feasibility negotiation is confirmed as concluded without agreement." + "zh-CN": "经评估,受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成,本次可行性协商确认无法达成一致。" } }, "llm": { "script": [ { "$ref": "responses/extract.feasibility.reject.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_reject" + "promptTextEqualsGolden": "feasibility_reject", + "contracts": ["conclusionLiteralPresent"] } }, { @@ -140,7 +155,7 @@ "schema": { "$ref": "schemas/flat" }, "llm": { "script": [ - "{\"semantic_verdict\":true,\"negotiation_type\":\"feasibility\",\"errors\":[],\"params\":{\"feasibility_result\":\"infeasible under the existing power supply constraint\"}}" + "{\"semantic_verdict\":true,\"negotiation_type\":\"feasibility\",\"errors\":[],\"params\":{\"port_expansion\":\"受汇聚机房板卡槽位不足限制不可行\"}}" ] }, "expect": { @@ -150,7 +165,7 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5, - "feasibility_result": "infeasible under the existing power supply constraint" + "port_expansion": "受汇聚机房板卡槽位不足限制不可行" } } }, @@ -162,18 +177,22 @@ "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", "input": { "text": { - "zh-CN": "原速率保障目标在停电场景下无法维持,现提出替代方案:将节能期间速率保障目标下调至2Mbps,或将节能时段缩短为6小时,请评估确认。", - "en-US": "The original rate guarantee target cannot be held in outage scenarios; an alternative is proposed: lower the rate guarantee target to 2Mbps or shorten the energy-saving period to 6 hours; please assess and confirm." + "zh-CN": "原扩容方案受汇聚机房板卡槽位不足限制无法在割接窗口内完成,现提出替代方案:更换高容量板卡,并将扩容拆分为两个割接窗口执行,请评估确认。" } }, "llm": { "script": [ - "{\"feasibility_negotiation_description\":\"The original rate guarantee target cannot be achieved under the power supply constraint; an alternative is proposed in ; please confirm.\",\"action\":\"PROPOSE_ALTERNATIVE_ON_FAILURE\",\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":[{\"name\":\"infeasible target\",\"value\":\"rate guarantee target of 5Mbps during 08:00-18:00 cannot be held in outage scenarios\"},{\"name\":\"alternative proposal\",\"value\":\"lower the rate guarantee target to 2Mbps or shorten the energy-saving period to 6 hours\"}]}" + "{\"feasibility_negotiation_description\":\"原扩容方案受汇聚机房板卡槽位不足限制无法在指定割接窗口内完成,替代方案见<不可行评估详情及提议>;请确认。\",\"action\":\"PROPOSE_ALTERNATIVE_ON_FAILURE\",\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":[{\"name\":\"不可行项\",\"value\":\"接入端口带宽由100Mbps扩容至1000Mbps受汇聚机房板卡槽位不足限制,无法在割接窗口内完成\"},{\"name\":\"替代提议\",\"value\":\"更换高容量板卡,并将扩容拆分为两个割接窗口执行\"}]}" ] }, "expect": { "outcome": "success", "llmCalls": 1, + "metadata": { + "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", + "contextEcho": true + }, + "promptTextContains": ["1000Mbps"], "contracts": ["metadataTripleShape"] } }, @@ -193,7 +212,7 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "feasibility_result": "achievable" + "port_expansion": "feasible" } } }, @@ -205,15 +224,15 @@ "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "替代方案可行:下调后的速率保障目标能够同时满足停电时长要求,本次可行性协商确认达成一致。", - "en-US": "The alternative is feasible: the lowered rate guarantee target also satisfies the outage duration requirement; this feasibility negotiation is confirmed as concluded." + "zh-CN": "替代方案评估可行:更换高容量板卡并将扩容拆分为两个割接窗口后,端口扩容可在割接窗口内完成且业务中断不超过30分钟,本次可行性协商确认结束。" } }, "llm": { "script": [ { "$ref": "responses/extract.feasibility.accept.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_accept" + "promptTextEqualsGolden": "feasibility_accept", + "contracts": ["conclusionLiteralPresent"] } }, { @@ -232,8 +251,9 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "feasibility_result": "achievable" - } + "port_expansion": "feasible" + }, + "contracts": ["contextKeysInMergedParams"] } } ], @@ -241,9 +261,13 @@ }, { "id": "SC-FSB-03", - "summary": "可行性协商中止:round 1 评估不可行且双方无法推进,round 2 生成通用终止消息并经校验确认协商提前终止", + "summary": "端口扩容可行性协商中止:round 1 评估因汇聚机房板卡槽位不足不可行且双方无法推进,round 2 生成终止消息并经校验确认协商提前终止", "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,扩容评估请求/确认方)", + "B": "OMC(server,可行性评估方)" + }, "steps": [ { "step": 1, @@ -253,7 +277,7 @@ "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", "input": { "text": { - "zh-CN": "请评估站点级节能任务调整后的速率保障目标是否可行:节能期间速率保障目标由5Mbps下调至2Mbps,且停电场景下供电时长不少于10小时。" + "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps,割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟。" } }, "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.full" } ] }, @@ -271,7 +295,7 @@ "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "经评估,在现有供电约束下节能目标无法达成,本次可行性协商确认无法达成一致。" + "zh-CN": "经评估,受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成,本次可行性协商确认无法达成一致。" } }, "llm": { "script": [ { "$ref": "responses/extract.feasibility.reject.full" } ] }, @@ -289,14 +313,15 @@ "templateUri": "Negotiation-T/common/abort/v1", "input": { "text": { - "zh-CN": "双方在轮次预算内无法就可行性达成一致,本次协商确认提前终止。" + "zh-CN": "汇聚机房板卡槽位不足短期无法解决,端口扩容可行性无法在轮次预算内达成一致,本次协商确认终止。" } }, "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "abort" + "promptTextEqualsGolden": "abort", + "contracts": ["conclusionLiteralPresent"] } }, { @@ -315,7 +340,7 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "termination_reason": "The negotiation round limit is reached." + "termination_reason": "已达到协商轮次上限" } } } diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/information-flows.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/information-flows.json index e96075ea..cdd52cd8 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/information-flows.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/information-flows.json @@ -1,68 +1,232 @@ [ { "id": "SC-INFO-01", - "summary": "信息协商单轮闭环:A 从自然语言生成接受消息,B 校验通过并提取参数", + "summary": "投诉诊断五步闭环(双语):工作台从投诉文本生成缺参任务报文,OMC 校验发现缺接入端口名称与投诉分类后发起信息协商要数,工作台接受补数,OMC 提取补参补齐闭环", "languages": ["zh-CN", "en-US"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, "steps": [ { "step": 1, "role": "A", - "api": "generateAcceptFromText", + "api": "generateTaskPromptFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "templateUri": "Task-T/network-layer/private-line-complaint/v1", "input": { "text": { - "zh-CN": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。", - "en-US": "I confirm the first-stage information: the energy-saving area is Songshan Lake and the rate guarantee target is 20Mbps." + "zh-CN": "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢,柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。", + "en-US": "Starting from 08:30 on May 11, the response latency of the dedicated line from Shenzhen to Guangzhou surged from an average of 12ms to 320ms, access to the core trading system in the Guangzhou data center became very slow, and the trading interfaces of the counter and mobile banking frequently report \"connection timeout\". OSS-side event sequence number: event-id-20260511-09013." } }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "llm": { + "script": [ + "{\"slots\":{\"任务对象\":\"\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\",\"task_object\":\"\",\"task_context\":\"complaint category: to be supplemented; problem occurrence time: 2026-05-11T08:21:46Z; OSS-side event sequence number: event-id-20260511-09013; complaint details: the response latency from Shenzhen to Guangzhou surged from an average of 12ms to 320ms and the trading interfaces of the counter and mobile banking frequently report connection timeout\"},\"slot_errors\":[]}" + ] + }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "information_accept" + "metadata": { + "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" + }, + "promptTextContains": ["event-id-20260511-09013", "2026-05-11T08:21:46Z"] } }, { "step": 2, "role": "B", - "api": "validateAcceptPromptAndDataFilling", + "api": "validateTaskPromptAndDataFilling", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", "prompt": { "fromStep": 1 }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { + "script": [ + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "faultTime": "2026-05-11T08:21:46Z", + "eventSerialNo": "event-id-20260511-09013" + }, + "missingParams": ["accessPort", "bizScenario"] + } + }, + { + "step": 3, + "role": "B", + "api": "generateProposeFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。", + "en-US": "The task message is missing the access port name and the complaint category; please supplement the Access Port Name (e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1) and the Complaint Category (e.g. dedicated-line quality degradation); the Private Line Service Identifier is also accepted, and the missing items are related by OR." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1" + }, + "promptTextContains": ["P533-"], + "contracts": ["metadataTripleShape"] + } + }, + { + "step": 4, + "role": "A", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/flat" }, + "input": { + "text": { + "zh-CN": "端口台账已查到:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差,确认补充。", + "en-US": "The port inventory has been located: the access port name is P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1 and the complaint category is dedicated-line quality degradation; confirmed." + } + }, + "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextContains": ["P533-"], + "contracts": ["conclusionLiteralPresent"] + } + }, + { + "step": 5, + "role": "B", + "api": "validateAcceptPromptAndDataFilling", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "fromStep": 4 }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, "params": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 1, + "round": 2, "maxRounds": 5, - "region": "Songshan Lake" - } + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" + }, + "contracts": ["contextKeysInMergedParams"], + "paramsFromStep": 2 } } ], - "expectFlow": { "terminalCondition": "accept", "roundsUsed": 1 } + "expectFlow": { + "terminalCondition": "accept", + "roundsUsed": 2, + "distinctMessages": true, + "missingParamsFilled": 2 + } }, { "id": "SC-INFO-02", - "summary": "信息协商两轮演进:首轮因站点库存不可用被拒后,发起方收窄信息项于第2轮重新提议,接收方接受且参数校验提取成功", - "languages": ["zh-CN", "en-US"], + "summary": "投诉诊断闭环两轮演进:工作台首轮因端口资源台账暂不可查拒绝补数,OMC 第2轮重新要数,台账恢复后工作台接受补数,OMC 提取补参补齐闭环", + "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, "steps": [ { "step": 1, "role": "A", + "api": "generateTaskPromptFromDataWithSchema", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "input": { + "data": { + "faultStartTime": "2026-05-11T08:21:46Z", + "ticketNo": "event-id-20260511-09013", + "faultDetailText": "深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时" + } + }, + "schema": { + "type": "object", + "properties": { + "portName": { + "type": "string", + "description": "接入端口名称,物理端口格式为网元名称+单板号+单板型号+端口号" + }, + "complaintScenario": { + "type": "string", + "description": "投诉分类,取值范围:专线中断、专线质差" + }, + "faultStartTime": { + "type": "string", + "description": "问题发生时间,ISO-8601 时间戳" + }, + "ticketNo": { + "type": "string", + "description": "OSS侧事件流水号" + }, + "faultDetailText": { + "type": "string", + "description": "投诉详情" + } + }, + "required": ["portName", "complaintScenario"] + }, + "llm": { + "script": [ + "{\"slots\":{\"任务对象\":\"\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\"},\"slot_errors\":[]}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "metadata": { + "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" + }, + "promptTextContains": ["event-id-20260511-09013"] + } + }, + { + "step": 2, + "role": "B", + "api": "validateTaskPromptAndDataFilling", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": { "fromStep": 1 }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { + "script": [ + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "faultTime": "2026-05-11T08:21:46Z", + "eventSerialNo": "event-id-20260511-09013" + }, + "missingParams": ["accessPort", "bizScenario"] + } + }, + { + "step": 3, + "role": "B", "api": "generateProposeFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { - "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。", - "en-US": "Please supplement the energy-saving area information (e.g. Songshan Lake), the energy-saving rate guarantee target (e.g. 20Mbps) and the VLANId; the three items are related by OR." + "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, @@ -73,15 +237,14 @@ } }, { - "step": 2, - "role": "B", + "step": 4, + "role": "A", "api": "generateRejectFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。", - "en-US": "The energy-saving area information cannot be provided because the site inventory is unavailable; this round is rejected." + "zh-CN": "端口资源台账暂不可查,无法提供接入端口名称,本轮信息协商拒绝。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, @@ -93,18 +256,21 @@ } }, { - "step": 3, - "role": "A", + "step": 5, + "role": "B", "api": "generateProposeFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { - "zh-CN": "第2轮信息协商:VLANId已通过其他渠道获取,本轮仅请补充节能区域信息(如松山湖)与节能速率保障目标(如20Mbps)两项。", - "en-US": "Second-round information negotiation: the VLANId has been obtained through another channel; this round only asks for the energy-saving area information (e.g. Songshan Lake) and the energy-saving rate guarantee target (e.g. 20Mbps)." + "zh-CN": "第2轮信息协商:端口资源台账已恢复,请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)与投诉分类(如专线质差)两项。" } }, - "llm": { "script": [ "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"e.g. Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"e.g. 20Mbps\"}],\"relationship\":\"OR\"}" ] }, + "llm": { + "script": [ + "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"}],\"relationship\":\"OR\"}" + ] + }, "expect": { "outcome": "success", "llmCalls": 1, @@ -116,15 +282,14 @@ } }, { - "step": 4, - "role": "B", + "step": 6, + "role": "A", "api": "generateAcceptFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "站点库存已恢复,我确认第2轮的信息:节能区域为松山湖,节能速率保障目标为20Mbps。", - "en-US": "The site inventory has recovered; I confirm the second-round information: the energy-saving area is Songshan Lake and the rate guarantee target is 20Mbps." + "zh-CN": "端口台账已恢复,我补充:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, @@ -136,13 +301,13 @@ } }, { - "step": 5, - "role": "A", + "step": 7, + "role": "B", "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 4 }, "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/flat" }, + "prompt": { "fromStep": 6 }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -151,77 +316,149 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, - "contracts": ["contextKeysInMergedParams"] + "contracts": ["contextKeysInMergedParams"], + "paramsFromStep": 2 } } ], - "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2, "distinctMessages": true } + "expectFlow": { + "terminalCondition": "accept", + "roundsUsed": 2, + "distinctMessages": true, + "missingParamsFilled": 2 + } }, { "id": "SC-INFO-03", - "summary": "信息协商中断:所需信息连续无法提供后,发起方在第2轮发起协商终止并生成终止消息", + "summary": "投诉诊断闭环中断:任务报文缺参协商发起后,投诉被 OSS 侧工单撤销,工作台于第2轮生成终止消息结束协商", "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, "steps": [ { "step": 1, "role": "A", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 2 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "api": "generateTaskPromptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", "input": { "text": { - "zh-CN": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息,三项之间为或的关系。" + "zh-CN": "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢,柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, + "llm": { + "script": [ + "{\"slots\":{\"任务对象\":\"\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\"},\"slot_errors\":[]}" + ] + }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "information_propose" + "metadata": { + "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" + }, + "promptTextContains": ["event-id-20260511-09013"] } }, { "step": 2, "role": "B", - "api": "generateRejectFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 2 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "api": "validateTaskPromptAndDataFilling", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": { "fromStep": 1 }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { + "script": [ + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "faultTime": "2026-05-11T08:21:46Z", + "eventSerialNo": "event-id-20260511-09013" + }, + "missingParams": ["accessPort", "bizScenario"] + } + }, + { + "step": 3, + "role": "B", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { - "zh-CN": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" + "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, + "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "information_reject", - "contracts": ["conclusionLiteralPresent"] + "promptTextEqualsGolden": "information_propose" } }, { - "step": 3, + "step": 4, "role": "A", "api": "generateAbortFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 2 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", "input": { "text": { - "zh-CN": "协商轮次已达上限,所需信息仍无法提供,本次协商确认终止。" + "zh-CN": "OSS侧工单撤销:该投诉已被撤单,诊断任务取消,本次协商确认终止。" } }, - "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, + "llm": { + "script": [ + "{\"termination_reason\":\"诊断任务已被 OSS 侧工单撤销,本次协商确认终止。\"}" + ] + }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "abort", + "promptTextContains": ["Abort", "工单撤销"], "contracts": ["conclusionLiteralPresent"] } + }, + { + "step": 5, + "role": "B", + "api": "validateAbortPromptAndDataFilling", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/common/abort/v1", + "prompt": { "fromStep": 4 }, + "schema": { "$ref": "schemas/flat" }, + "llm": { + "script": [ + "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{\"termination_reason\":\"诊断任务已被 OSS 侧工单撤销\"}}" + ] + }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "termination_reason": "诊断任务已被 OSS 侧工单撤销" + } + } } ], - "expectFlow": { "terminalCondition": "abort", "roundsUsed": 2, "distinctMessages": true } + "expectFlow": { + "terminalCondition": "abort", + "roundsUsed": 2, + "distinctMessages": true + } } ] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/target-flows.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/target-flows.json index 7b8cc692..ed418aed 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/target-flows.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/target-flows.json @@ -1,41 +1,63 @@ [ { "id": "SC-TGT-01", - "summary": "目标协商单轮闭环:A 从自然语言生成携带最终确认意图的接受消息,B 校验通过并提取站点与速率保障参数", - "languages": ["zh-CN", "en-US"], + "summary": "修复目标协商单轮闭环:工作台就专线质差修复目标发起目标协商提议,OMC 确认最终意图生成接受消息,工作台校验提取时延目标与完成时限参数", + "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,修复目标协商发起/确认方)", + "B": "OMC(server,修复目标澄清/确认方)" + }, "steps": [ { "step": 1, "role": "A", - "api": "generateAcceptFromText", + "api": "generateProposeFromText", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "templateUri": "Negotiation-T/target-negotiation/propose/v1", "input": { "text": { - "zh-CN": "最终确认的意图:对松山湖站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps。", - "en-US": "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps." + "zh-CN": "专线质差投诉的修复目标协商:修复意图是在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%;待澄清平均时延目标是恢复至20ms以内还是50ms以内、修复完成时限是48小时内还是72小时内,请澄清并确认。" } }, - "llm": { "script": [ { "$ref": "responses/extract.target.accept.full" } ] }, + "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, "expect": { "outcome": "success", "llmCalls": 1, - "promptTextEqualsGolden": "target_accept" + "promptTextEqualsGolden": "target_propose" } }, { "step": 2, "role": "B", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "target_accept", + "contracts": ["conclusionLiteralPresent"] + } + }, + { + "step": 3, + "role": "A", "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 1 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "prompt": { "fromStep": 2 }, "schema": { "type": "object", "properties": { - "site": { "type": "string" }, - "guaranteed_rate": { "type": "string" } + "latency_target": { "type": "string" }, + "repair_deadline": { "type": "string" } } }, "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, @@ -44,22 +66,30 @@ "llmCalls": 1, "params": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 1, + "round": 2, "maxRounds": 5, - "site": "Songshan Lake", - "guaranteed_rate": "at least 20Mbps" + "latency_target": "within 20ms", + "repair_deadline": "2026-05-15" }, "contracts": ["contextKeysInMergedParams"] } } ], - "expectFlow": { "terminalCondition": "accept", "roundsUsed": 1, "distinctMessages": true } + "expectFlow": { + "terminalCondition": "accept", + "roundsUsed": 2, + "distinctMessages": true + } }, { "id": "SC-TGT-02", - "summary": "目标协商两轮演进:首轮区域信息无法澄清被拒后,发起方对齐区域理解并于第2轮重新提议,接收方接受且参数校验提取成功", - "languages": ["zh-CN", "en-US"], + "summary": "修复目标协商两轮演进:首轮因光缆割接窗口尚未确认被拒,工作台对齐时延目标后于第2轮重新提议,OMC 确认接受且参数校验提取成功", + "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,修复目标协商发起/确认方)", + "B": "OMC(server,修复目标澄清/确认方)" + }, "steps": [ { "step": 1, @@ -69,8 +99,7 @@ "templateUri": "Negotiation-T/target-negotiation/propose/v1", "input": { "text": { - "zh-CN": "无线节能优化任务的意图理解:对目标站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps;对区域与节能时间范围仍有疑问,请澄清和确认。", - "en-US": "The intent understanding of the wireless energy-saving optimization task: apply wireless energy-saving optimization to the target site during 08:00-18:00 with a guaranteed rate of at least 20Mbps; open questions remain about the area and the time range, please clarify and confirm." + "zh-CN": "专线质差投诉的修复目标协商:修复意图是在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%;待澄清平均时延目标是恢复至20ms以内还是50ms以内、修复完成时限是48小时内还是72小时内,请澄清并确认。" } }, "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, @@ -88,8 +117,7 @@ "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "无法完整澄清区域信息:站点库存数据不可用,本轮目标协商拒绝。", - "en-US": "The area information cannot be clarified in full because the site inventory is unavailable; this target negotiation round is rejected." + "zh-CN": "因光缆割接窗口尚未确认,时延目标未能完全澄清,本轮目标协商拒绝。" } }, "llm": { "script": [ { "$ref": "responses/extract.target.reject.full" } ] }, @@ -108,11 +136,14 @@ "templateUri": "Negotiation-T/target-negotiation/propose/v1", "input": { "text": { - "zh-CN": "第2轮目标协商:区域已确认为松山湖;节能时间范围仍有疑问(08:00-18:00还是00:00-06:00),请澄清和确认。", - "en-US": "Second-round target negotiation: the area is confirmed as Songshan Lake; an open question remains about the energy-saving time range (08:00-18:00 or 00:00-06:00), please clarify and confirm." + "zh-CN": "第2轮目标协商:割接窗口已确认为2026年5月14日22:00-02:00,时延目标已对齐为平均时延20ms以内、高峰丢包率不高于1%;仅完成时限(48小时内还是72小时内)仍待澄清,请澄清和确认。" } }, - "llm": { "script": [ "{\"target_negotiation_description\":\"The area of the wireless energy-saving optimization task has been aligned, see ; an open question remains about the energy-saving time range, see ; please clarify and confirm.\",\"intent_understanding\":null,\"alignment_and_clarification\":[{\"name\":\"area\",\"value\":\"confirmed: the covered site is Songshan Lake\"}],\"request_for_clarification\":[{\"name\":\"time range\",\"value\":\"is the energy-saving period 08:00-18:00 or 00:00-06:00\"}]}" ] }, + "llm": { + "script": [ + "{\"target_negotiation_description\":\"光缆割接窗口已确认为2026年5月14日22:00-02:00,时延目标已对齐见<意图理解陈述>;仅完成时限仍待澄清,见<待澄清内容>;请澄清并确认。\",\"intent_understanding\":[{\"name\":\"修复意图\",\"value\":\"在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内\"},{\"name\":\"修复目标\",\"value\":\"高峰时段丢包率不高于1%\"}],\"alignment_and_clarification\":null,\"request_for_clarification\":[{\"name\":\"完成时限\",\"value\":\"修复完成时限是48小时内还是72小时内\"}]}" + ] + }, "expect": { "outcome": "success", "llmCalls": 1, @@ -131,8 +162,7 @@ "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "最终确认的意图:对松山湖站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps。", - "en-US": "The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps." + "zh-CN": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" } }, "llm": { "script": [ { "$ref": "responses/extract.target.accept.full" } ] }, @@ -147,14 +177,14 @@ "step": 5, "role": "A", "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 4 }, "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "prompt": { "fromStep": 4 }, "schema": { "type": "object", "properties": { - "site": { "type": "string" }, - "guaranteed_rate": { "type": "string" } + "latency_target": { "type": "string" }, + "repair_deadline": { "type": "string" } } }, "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, @@ -165,20 +195,28 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "site": "Songshan Lake", - "guaranteed_rate": "at least 20Mbps" + "latency_target": "within 20ms", + "repair_deadline": "2026-05-15" }, "contracts": ["contextKeysInMergedParams"] } } ], - "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2, "distinctMessages": true } + "expectFlow": { + "terminalCondition": "accept", + "roundsUsed": 2, + "distinctMessages": true + } }, { "id": "SC-TGT-03", - "summary": "目标协商中断:区域信息持续无法澄清,发起方在第2轮发起协商终止并生成终止消息", + "summary": "修复目标协商中断:光缆割接窗口持续无法确认,修复目标在轮次预算内无法完成澄清,于第2轮生成终止消息结束协商", "languages": ["zh-CN"], "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,修复目标协商发起/确认方)", + "B": "OMC(server,修复目标澄清/确认方)" + }, "steps": [ { "step": 1, @@ -188,7 +226,7 @@ "templateUri": "Negotiation-T/target-negotiation/propose/v1", "input": { "text": { - "zh-CN": "无线节能优化任务的意图理解:对目标站点在08:00-18:00执行无线节能优化,节能期间速率保障不低于20Mbps;对区域与节能时间范围仍有疑问,请澄清和确认。" + "zh-CN": "专线质差投诉的修复目标协商:修复意图是在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%;待澄清平均时延目标是恢复至20ms以内还是50ms以内、修复完成时限是48小时内还是72小时内,请澄清并确认。" } }, "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, @@ -206,7 +244,7 @@ "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "input": { "text": { - "zh-CN": "无法完整澄清区域信息:站点库存数据不可用,本轮目标协商拒绝。" + "zh-CN": "因光缆割接窗口尚未确认,时延目标未能完全澄清,本轮目标协商拒绝。" } }, "llm": { "script": [ { "$ref": "responses/extract.target.reject.full" } ] }, @@ -225,7 +263,7 @@ "templateUri": "Negotiation-T/common/abort/v1", "input": { "text": { - "zh-CN": "协商轮次已达上限,区域信息仍无法澄清,本次协商确认终止。" + "zh-CN": "光缆割接窗口持续无法确认,修复目标在轮次预算内无法完成澄清,本次协商确认终止。" } }, "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, @@ -237,6 +275,10 @@ } } ], - "expectFlow": { "terminalCondition": "abort", "roundsUsed": 2, "distinctMessages": true } + "expectFlow": { + "terminalCondition": "abort", + "roundsUsed": 2, + "distinctMessages": true + } } ] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/llm-responses.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/llm-responses.json index 0ffc5b3c..2b189df0 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/llm-responses.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/llm-responses.json @@ -1,34 +1,34 @@ { - "extract.information.propose.full": "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"e.g. Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"e.g. 20Mbps\"},{\"name\":\"VLANId\",\"value\":null}],\"relationship\":\"OR\"}", - "extract.information.accept.full": "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}]}", - "extract.information.reject.full": "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"cannot be provided because the site inventory is unavailable\"}]}", - "extract.target.propose.full": "{\"target_negotiation_description\":\"The intent understanding of the wireless energy-saving optimization task is listed in ; open questions remain about the area and the time range, see ; please clarify and confirm.\",\"intent_understanding\":[{\"name\":\"task intent\",\"value\":\"apply wireless energy-saving optimization to the target site during 08:00-18:00\"},{\"name\":\"rate guarantee target\",\"value\":\"at least 20Mbps during the energy-saving period\"}],\"alignment_and_clarification\":null,\"request_for_clarification\":[{\"name\":\"area\",\"value\":\"which site is covered: Songshan Lake or another site\"},{\"name\":\"time range\",\"value\":\"is the energy-saving period 08:00-18:00 or 00:00-06:00\"}]}", - "extract.target.accept.full": "{\"conclusion\":\"Accept\",\"confirmed_intent\":\"The finally confirmed intent: apply wireless energy-saving optimization to the Songshan Lake site during 08:00-18:00 with a guaranteed rate of at least 20Mbps.\",\"failure_reason\":null}", - "extract.target.reject.full": "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"The area information cannot be clarified in full because the site inventory is unavailable.\"}", - "extract.feasibility.propose.full": "{\"feasibility_negotiation_description\":\"Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved, see ; please assess.\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":[{\"name\":\"adjusted target\",\"value\":\"rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00\"},{\"name\":\"existing constraint\",\"value\":\"at least 10 hours of power supply duration in outage scenarios\"}],\"infeasibility_details_and_proposal\":null}", - "extract.feasibility.accept.full": "{\"conclusion\":\"Accept\",\"feasibility_summary\":\"The adjusted rate guarantee target is achievable and satisfies the outage duration requirement; this negotiation is confirmed as concluded.\"}", - "extract.feasibility.reject.full": "{\"conclusion\":\"Reject\",\"feasibility_summary\":\"The energy-saving target cannot be achieved under the existing power supply constraint; this negotiation is confirmed as concluded.\"}", - "extract.common.abort.full": "{\"termination_reason\":\"The negotiation round limit is reached; this negotiation is confirmed as terminated.\"}", - "extract.information.accept.missing_conclusion": "{\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}]}", - "extract.information.accept.conclusion_swapped": "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"Songshan Lake\"},{\"name\":\"energy-saving rate guarantee target\",\"value\":\"20Mbps\"}]}", - "extract.information.reject.conclusion_swapped": "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"energy-saving area information\",\"value\":\"cannot be provided because the site inventory is unavailable\"}]}", + "extract.information.propose.full": "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\",\"value\":null}],\"relationship\":\"OR\"}", + "extract.information.accept.full": "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}", + "extract.information.reject.full": "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"无法提供,工作台侧端口资源台账暂不可查\"}]}", + "extract.target.propose.full": "{\"target_negotiation_description\":\"专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。\",\"intent_understanding\":[{\"name\":\"修复意图\",\"value\":\"在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内\"},{\"name\":\"修复目标\",\"value\":\"高峰时段丢包率不高于1%\"}],\"alignment_and_clarification\":null,\"request_for_clarification\":[{\"name\":\"时延目标\",\"value\":\"平均时延目标是恢复至20ms以内还是50ms以内\"},{\"name\":\"完成时限\",\"value\":\"修复完成时限是48小时内还是72小时内\"}]}", + "extract.target.accept.full": "{\"conclusion\":\"Accept\",\"confirmed_intent\":\"最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。\",\"failure_reason\":null}", + "extract.target.reject.full": "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"因光缆割接窗口尚未确认,时延目标未能完全澄清。\"}", + "extract.feasibility.propose.full": "{\"feasibility_negotiation_description\":\"请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":[{\"name\":\"扩容方案\",\"value\":\"接入端口带宽由100Mbps扩容至1000Mbps\"},{\"name\":\"既有约束\",\"value\":\"割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟\"}],\"infeasibility_details_and_proposal\":null}", + "extract.feasibility.accept.full": "{\"conclusion\":\"Accept\",\"feasibility_summary\":\"端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。\"}", + "extract.feasibility.reject.full": "{\"conclusion\":\"Reject\",\"feasibility_summary\":\"受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。\"}", + "extract.common.abort.full": "{\"termination_reason\":\"已达到协商轮次上限,本次协商确认终止。\"}", + "extract.information.accept.missing_conclusion": "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}", + "extract.information.accept.conclusion_swapped": "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}", + "extract.information.reject.conclusion_swapped": "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"无法提供,工作台侧端口资源台账暂不可查\"}]}", "extract.information.propose.missing_items": "{\"relationship\":\"OR\"}", - "extract.information.propose.items_not_array": "{\"items\":\"energy-saving area information, energy-saving rate guarantee target and VLANId\",\"relationship\":\"OR\"}", + "extract.information.propose.items_not_array": "{\"items\":\"接入端口名称、投诉分类与专线业务标识\",\"relationship\":\"OR\"}", "extract.target.accept.missing_confirmed_intent": "{\"conclusion\":\"Accept\",\"confirmed_intent\":null,\"failure_reason\":null}", "extract.target.reject.missing_failure_reason": "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":null}", - "extract.feasibility.propose.missing_action": "{\"feasibility_negotiation_description\":\"Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved.\",\"contents_to_evaluate\":[{\"name\":\"adjusted target\",\"value\":\"rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00\"}],\"infeasibility_details_and_proposal\":null}", - "extract.feasibility.propose.unknown_action": "{\"feasibility_negotiation_description\":\"Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved.\",\"action\":\"APPROVE_THE_REQUEST\",\"contents_to_evaluate\":[{\"name\":\"adjusted target\",\"value\":\"rate guarantee target lowered from 5Mbps to 2Mbps during 08:00-18:00\"}],\"infeasibility_details_and_proposal\":null}", - "extract.feasibility.propose.evaluation_without_contents": "{\"feasibility_negotiation_description\":\"Please assess whether the adjusted rate guarantee target of the site-level energy-saving task can be achieved.\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null}", + "extract.feasibility.propose.missing_action": "{\"feasibility_negotiation_description\":\"请评估专线接入端口扩容方案能否在割接窗口内完成。\",\"contents_to_evaluate\":[{\"name\":\"扩容方案\",\"value\":\"接入端口带宽由100Mbps扩容至1000Mbps\"}],\"infeasibility_details_and_proposal\":null}", + "extract.feasibility.propose.unknown_action": "{\"feasibility_negotiation_description\":\"请评估专线接入端口扩容方案能否在割接窗口内完成。\",\"action\":\"APPROVE_THE_REQUEST\",\"contents_to_evaluate\":[{\"name\":\"扩容方案\",\"value\":\"接入端口带宽由100Mbps扩容至1000Mbps\"}],\"infeasibility_details_and_proposal\":null}", + "extract.feasibility.propose.evaluation_without_contents": "{\"feasibility_negotiation_description\":\"请评估专线接入端口扩容方案能否在割接窗口内完成。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null}", "extract.common.abort.missing_termination_reason": "{}", - "semantic.information.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"region\":\"Songshan Lake\"}}", - "semantic.information.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"region\",\"code\":\"missing\",\"message\":\"The region parameter is missing.\"}],\"params\":{}}", - "semantic.target.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[],\"params\":{\"site\":\"Songshan Lake\",\"guaranteed_rate\":\"at least 20Mbps\"}}", + "semantic.information.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\",\"bizScenario\":\"专线质差\"}}", + "semantic.information.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"message\":\"The access port parameter is missing.\"}],\"params\":{}}", + "semantic.target.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[],\"params\":{\"latency_target\":\"within 20ms\",\"repair_deadline\":\"2026-05-15\"}}", "semantic.target.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"target\",\"errors\":[{\"slot_name\":\"confirmed_intent\",\"code\":\"missing\",\"message\":\"The confirmed intent is missing.\"}],\"params\":{}}", - "semantic.feasibility.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"feasibility\",\"errors\":[],\"params\":{\"feasibility_result\":\"achievable\"}}", + "semantic.feasibility.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"feasibility\",\"errors\":[],\"params\":{\"port_expansion\":\"feasible\"}}", "semantic.feasibility.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"feasibility\",\"errors\":[{\"slot_name\":\"feasibility_summary\",\"code\":\"missing\",\"message\":\"The feasibility summary is missing.\"}],\"params\":{}}", - "semantic.common.abort.full": "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{\"termination_reason\":\"The negotiation round limit is reached.\"}}", + "semantic.common.abort.full": "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{\"termination_reason\":\"已达到协商轮次上限\"}}", "semantic.information.type_mismatch": "{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[],\"params\":{}}", "semantic.information.null_type": "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}", - "semantic.shape.missing_verdict": "{\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"region\":\"Songshan Lake\"}}", + "semantic.shape.missing_verdict": "{\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}", "semantic.shape.missing_params": "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]}" } diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/schemas.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/schemas.json index dd4b598e..86c82dca 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/schemas.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/schemas.json @@ -1,4 +1,37 @@ { + "biz.complaint.params": { + "type": "object", + "properties": { + "accessPort": { + "type": "string", + "description": "接入端口名称,唯一标识被投诉的专线对象,必填,须与投诉对象一致", + "examples": [ + "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)" + ] + }, + "bizScenario": { + "type": "string", + "description": "投诉分类场景,必填,仅允许取值:专线中断、专线质差", + "examples": [ + "专线中断", + "专线质差" + ] + }, + "faultTime": { + "type": "string", + "description": "问题发生时间,建议使用 ISO-8601 时间戳,例如 2026-05-11T08:21:46Z" + }, + "eventSerialNo": { + "type": "string", + "description": "OSS 侧受理的投诉工单或事件流水号,供诊断回溯关联,例如 event-id-20260511-09013" + } + }, + "required": [ + "accessPort", + "bizScenario" + ] + }, "flat": { "type": "object", "properties": { diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/drift-probes.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/drift-probes.json new file mode 100644 index 00000000..93c21f85 --- /dev/null +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/drift-probes.json @@ -0,0 +1,102 @@ +[ + { + "id": "VAL-DRIFT-01", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["drift", "wording-drift", "information", "accept", "text-input"], + "summary": "对端补数接受报文的条目编号与分隔标点相对模板惯例存在差异(1.与冒号改为1、与空格),仍应校验通过并提取接入端口名称与投诉分类", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1、接入端口名称 P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2、投诉分类 专线质差" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-DRIFT-02", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["drift", "wording-drift", "information", "propose", "text-input"], + "summary": "对端要数提议报文将缺失项关系从OR字面量改写为文字化措辞,语序微变仍应校验通过并提取投诉参数", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n以上缺失项之间为“或”的关系。" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-DRIFT-03", + "api": "validateAcceptPromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["drift", "wording-drift", "information", "accept", "text-input"], + "summary": "对端英文补数接受报文的条目编号改为右括号、键值分隔改为长破折号,措辞漂移仍应校验通过并提取接入端口名称与投诉分类", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": { "text": "## Information Negotiation Result\nAccept\n\n## Information Negotiation Result Content\n1) Access Port Name — P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\n2) Complaint Category — dedicated-line quality degradation" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" + }, + "contracts": ["contextKeysInMergedParams"] + } + }, + { + "id": "VAL-DRIFT-04", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["drift", "wording-drift", "information", "propose", "text-input"], + "summary": "对端英文要数提议报文将缺失项关系改写为完整句子并补充限定词,措辞漂移仍应校验通过并提取投诉参数", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": { "text": "## Information Negotiation\nPlease supplement the relevant content based on .\n\n## Required Information Items\n1. Access Port Name: e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\n2. Complaint Category: e.g. dedicated-line quality degradation\n3. Private Line Service Identifier\nThe three missing items above are related by OR." }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 2, + "maxRounds": 5, + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" + }, + "contracts": ["contextKeysInMergedParams"] + } + } +] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/error-code-mapping.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/error-code-mapping.json index 4fc2c3d7..b1caab02 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/error-code-mapping.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/error-code-mapping.json @@ -9,7 +9,7 @@ "context": null, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -29,7 +29,7 @@ "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 6, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -45,17 +45,17 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["error-code-mapping", "map-code", "semantic-rejected"], - "summary": "大模型语义校验否决消息时内部语义拒绝错误码对外映射为协商语义拒绝错误码,并保留大模型报告的槽位错误", + "summary": "大模型语义校验否决要数提议消息时内部语义拒绝错误码对外映射为协商语义拒绝错误码,并保留大模型报告的接入端口缺失槽位错误", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.reject" } ] }, "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "region", "code": "missing" } ], + "slotErrors": [ { "slot": "accessPort", "code": "missing" } ], "llmCalls": 1 } }, @@ -69,7 +69,7 @@ "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "maxAttempts": 2, "script": [ { "$fail": "llm-error" }, { "$fail": "llm-error" } ] }, "expect": { "outcome": "failure", @@ -90,7 +90,7 @@ "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "inject": "failingSemanticValidator", "expect": { diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/happy.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/happy.json index 2bd1df11..e49272d3 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/happy.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/happy.json @@ -4,12 +4,12 @@ "api": "validateProposePromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "information", "propose", "merge"], - "summary": "校验信息协商提议消息并提取参数,合并结果含上下文三键与业务参数 region", + "tags": ["happy", "information", "propose", "merge", "text-input"], + "summary": "以OMC发来的信息协商提议报文全文为输入,校验通过并合并出上下文三键与接入端口名称、投诉分类参数", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -18,7 +18,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, "contracts": ["contextKeysInMergedParams"] } @@ -28,12 +29,12 @@ "api": "validateAcceptPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "information", "accept", "merge"], - "summary": "校验信息协商接受消息并提取参数,合并结果含上下文三键与业务参数 region", + "tags": ["happy", "information", "accept", "merge", "golden-derived"], + "summary": "golden 回归:工作台补数接受报文的规范模板形态校验通过,合并参数与对端报文主线用例完全等值", "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -42,7 +43,8 @@ "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, "contracts": ["contextKeysInMergedParams"] } @@ -52,12 +54,12 @@ "api": "validateRejectPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "information", "reject", "merge"], - "summary": "校验信息协商拒绝消息并提取参数,合并结果含上下文三键与业务参数 region", + "tags": ["happy", "information", "reject", "merge", "text-input"], + "summary": "以工作台发来的信息协商拒绝报文全文为输入,校验通过并合并出上下文三键与语义层提取的投诉参数", "context": { "id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", "round": 4, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "golden": "information_reject" }, - "schema": { "$ref": "schemas/flat" }, + "prompt": { "text": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,工作台侧端口资源台账暂不可查" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -66,7 +68,8 @@ "id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", "round": 4, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, "contracts": ["contextKeysInMergedParams"] } @@ -76,11 +79,11 @@ "api": "validateProposePromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "target", "propose", "merge"], - "summary": "校验目标协商提议消息并提取参数,合并结果含上下文三键与站点、速率保障参数", + "tags": ["happy", "target", "propose", "merge", "text-input"], + "summary": "以对端发来的修复目标协商提议报文全文为输入,校验通过并合并出上下文三键与时延目标、修复时限参数", "context": { "id": "9e8d7c6b-5a4f-4392-8172-6b5c4d3e2f1a", "round": 1, "maxRounds": 3 }, "templateUri": "Negotiation-T/target-negotiation/propose/v1", - "prompt": { "golden": "target_propose" }, + "prompt": { "text": "## 目标协商\n专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。\n\n## 意图理解陈述\n1. 修复意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内\n2. 修复目标:高峰时段丢包率不高于1%\n\n## 待澄清内容\n1. 时延目标:平均时延目标是恢复至20ms以内还是50ms以内\n2. 完成时限:修复完成时限是48小时内还是72小时内" }, "schema": { "$ref": "schemas/empty" }, "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, "expect": { @@ -90,8 +93,8 @@ "id": "9e8d7c6b-5a4f-4392-8172-6b5c4d3e2f1a", "round": 1, "maxRounds": 3, - "site": "Songshan Lake", - "guaranteed_rate": "at least 20Mbps" + "latency_target": "within 20ms", + "repair_deadline": "2026-05-15" }, "contracts": ["contextKeysInMergedParams"] } @@ -101,8 +104,8 @@ "api": "validateAcceptPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "target", "accept", "merge"], - "summary": "校验目标协商接受消息并提取参数,合并结果含上下文三键与站点、速率保障参数", + "tags": ["happy", "target", "accept", "merge", "golden-derived"], + "summary": "golden 回归:修复目标确认接受报文的规范模板形态校验通过,合并参数与对端报文主线用例完全等值", "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "prompt": { "golden": "target_accept" }, @@ -115,8 +118,8 @@ "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3, - "site": "Songshan Lake", - "guaranteed_rate": "at least 20Mbps" + "latency_target": "within 20ms", + "repair_deadline": "2026-05-15" }, "contracts": ["contextKeysInMergedParams"] } @@ -126,11 +129,11 @@ "api": "validateRejectPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "target", "reject", "merge"], - "summary": "校验目标协商拒绝消息并提取参数,合并结果含上下文三键与站点、速率保障参数", + "tags": ["happy", "target", "reject", "merge", "text-input"], + "summary": "以对端发来的修复目标协商拒绝报文全文为输入(割接窗口未确认),校验通过并合并出上下文三键与时延目标、修复时限参数", "context": { "id": "6f5e4d3c-2b1a-4988-7766-5544332211ff", "round": 2, "maxRounds": 3 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "prompt": { "golden": "target_reject" }, + "prompt": { "text": "## 目标协商结果\nReject\n\n## 目标协商结果内容\n因光缆割接窗口尚未确认,时延目标未能完全澄清。" }, "schema": { "$ref": "schemas/empty" }, "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, "expect": { @@ -140,8 +143,8 @@ "id": "6f5e4d3c-2b1a-4988-7766-5544332211ff", "round": 2, "maxRounds": 3, - "site": "Songshan Lake", - "guaranteed_rate": "at least 20Mbps" + "latency_target": "within 20ms", + "repair_deadline": "2026-05-15" }, "contracts": ["contextKeysInMergedParams"] } @@ -151,11 +154,11 @@ "api": "validateProposePromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "feasibility", "propose", "merge"], - "summary": "校验可行性协商提议消息并提取参数,合并结果含上下文三键与可行性结论参数", + "tags": ["happy", "feasibility", "propose", "merge", "text-input"], + "summary": "以对端发来的端口扩容可行性协商提议报文全文为输入,校验通过并合并出上下文三键与扩容可行性结论参数", "context": { "id": "2a4b6c8d-0e1f-4a3b-8c5d-9e7f1a2b3c4d", "round": 2, "maxRounds": 4 }, "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "prompt": { "golden": "feasibility_propose" }, + "prompt": { "text": "## 可行性协商\n请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。\n\n## 待评估内容说明\n1. 扩容方案:接入端口带宽由100Mbps扩容至1000Mbps\n2. 既有约束:割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟" }, "schema": { "$ref": "schemas/empty" }, "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, "expect": { @@ -165,7 +168,7 @@ "id": "2a4b6c8d-0e1f-4a3b-8c5d-9e7f1a2b3c4d", "round": 2, "maxRounds": 4, - "feasibility_result": "achievable" + "port_expansion": "feasible" }, "contracts": ["contextKeysInMergedParams"] } @@ -175,11 +178,11 @@ "api": "validateAcceptPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "feasibility", "accept", "merge"], - "summary": "校验可行性协商接受消息并提取参数,合并结果含上下文三键与可行性结论参数", + "tags": ["happy", "feasibility", "accept", "merge", "text-input"], + "summary": "以对端发来的端口扩容可行性确认接受报文全文为输入,校验通过并合并出上下文三键与扩容可行性结论参数", "context": { "id": "3b5c7d9e-1f2a-4b3c-8d4e-9f6a1b2c3d4e", "round": 3, "maxRounds": 4 }, "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "prompt": { "golden": "feasibility_accept" }, + "prompt": { "text": "## 可行性协商结果\nAccept\n\n## 可行性评估结果确认\n端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。" }, "schema": { "$ref": "schemas/empty" }, "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, "expect": { @@ -189,7 +192,7 @@ "id": "3b5c7d9e-1f2a-4b3c-8d4e-9f6a1b2c3d4e", "round": 3, "maxRounds": 4, - "feasibility_result": "achievable" + "port_expansion": "feasible" }, "contracts": ["contextKeysInMergedParams"] } @@ -199,11 +202,11 @@ "api": "validateRejectPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "feasibility", "reject", "merge"], - "summary": "校验可行性协商拒绝消息并提取参数,合并结果含上下文三键与可行性结论参数", + "tags": ["happy", "feasibility", "reject", "merge", "text-input"], + "summary": "以对端发来的端口扩容不可行拒绝报文全文为输入(板卡槽位不足),校验通过并合并出上下文三键与扩容可行性结论参数", "context": { "id": "4c6d8e0f-2a3b-4c5d-8e6f-0a7b1c2d3e4f", "round": 3, "maxRounds": 4 }, "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "prompt": { "golden": "feasibility_reject" }, + "prompt": { "text": "## 可行性协商结果\nReject\n\n## 可行性评估结果确认\n受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。" }, "schema": { "$ref": "schemas/empty" }, "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, "expect": { @@ -213,7 +216,7 @@ "id": "4c6d8e0f-2a3b-4c5d-8e6f-0a7b1c2d3e4f", "round": 3, "maxRounds": 4, - "feasibility_result": "achievable" + "port_expansion": "feasible" }, "contracts": ["contextKeysInMergedParams"] } @@ -223,8 +226,8 @@ "api": "validateAbortPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["happy", "abort", "common", "merge"], - "summary": "校验协商终止消息并提取参数,合并结果含上下文三键与终止原因参数", + "tags": ["happy", "abort", "common", "merge", "golden-derived"], + "summary": "golden 回归:轮次耗尽协商终止报文的规范模板形态校验通过,合并出上下文三键与终止原因参数", "context": { "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", "prompt": { "golden": "abort" }, @@ -237,7 +240,7 @@ "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5, - "termination_reason": "The negotiation round limit is reached." + "termination_reason": "已达到协商轮次上限" }, "contracts": ["contextKeysInMergedParams"] } diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json index decc7e81..6026bc3b 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json @@ -4,12 +4,12 @@ "api": "validateAcceptPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P1", - "tags": ["retry", "llm-infrastructure-error", "accept", "information"], - "summary": "语义校验第一次遭遇 LLM 基础设施错误后自动重试,第二次返回合法判定并成功提取参数", + "tags": ["retry", "llm-infrastructure-error", "accept", "information", "text-input"], + "summary": "工作台补数接受报文的语义校验第一次遭遇 LLM 基础设施错误后自动重试,第二次返回合法判定并成功提取投诉参数", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/flat" }, + "prompt": { "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:专线质差" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "maxAttempts": 3, "script": [ { "$fail": "llm-error" }, { "$ref": "responses/semantic.information.accept.full" } ] @@ -21,7 +21,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, "contracts": ["contextKeysInMergedParams"] } @@ -31,16 +32,16 @@ "api": "validateProposePromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P1", - "tags": ["retry", "shape-invalid", "missing-negotiation-type", "propose", "information"], - "summary": "语义返回缺少 negotiation_type 键的无效结构时触发重试,第二次结构合法并成功提取参数", + "tags": ["retry", "shape-invalid", "missing-negotiation-type", "propose", "information", "text-input"], + "summary": "要数提议报文的语义返回缺少 negotiation_type 键的无效结构时触发重试,第二次结构合法并成功提取投诉参数", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "maxAttempts": 3, "script": [ - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"region\":\"Songshan Lake\"}}", + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}", { "$ref": "responses/semantic.information.accept.full" } ] }, @@ -51,7 +52,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, "contracts": ["contextKeysInMergedParams"] } @@ -66,11 +68,11 @@ "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "maxAttempts": 3, "script": [ - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"region\":\"Songshan Lake\"}}", + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}", { "$ref": "responses/semantic.shape.missing_verdict" }, { "$ref": "responses/semantic.shape.missing_params" } ] @@ -93,10 +95,10 @@ "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "maxAttempts": 1, - "script": [ "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"region\":\"Songshan Lake\"}}" ] + "script": [ "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}" ] }, "expect": { "outcome": "failure", @@ -116,7 +118,7 @@ "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "maxAttempts": 2, "script": [ { "$fail": "llm-error" }, { "$fail": "llm-error" } ] @@ -134,12 +136,12 @@ "api": "validateRejectPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P1", - "tags": ["retry", "non-json", "max-attempts-2", "reject", "information"], - "summary": "语义返回无法解析的非 JSON 内容后按 maxAttempts=2 重试,第二次返回合法判定并成功", + "tags": ["retry", "non-json", "max-attempts-2", "reject", "information", "text-input"], + "summary": "工作台拒绝报文的语义返回无法解析的非 JSON 内容后按 maxAttempts=2 重试,第二次返回合法判定并成功", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "golden": "information_reject" }, - "schema": { "$ref": "schemas/flat" }, + "prompt": { "text": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,工作台侧端口资源台账暂不可查" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "maxAttempts": 2, "script": [ { "$fail": "non-json" }, { "$ref": "responses/semantic.information.accept.full" } ] @@ -151,7 +153,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, "contracts": ["contextKeysInMergedParams"] } @@ -161,12 +164,12 @@ "api": "validateProposePromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P1", - "tags": ["retry", "max-attempts-3", "blank-content", "non-json", "propose"], - "summary": "连续两次空内容与非 JSON 应答后第三次成功,重试预算 maxAttempts=3 恰好用满", + "tags": ["retry", "max-attempts-3", "blank-content", "non-json", "propose", "text-input"], + "summary": "要数提议报文的语义连续两次空内容与非 JSON 应答后第三次成功,重试预算 maxAttempts=3 恰好用满", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "maxAttempts": 3, "script": [ @@ -182,7 +185,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, "contracts": ["contextKeysInMergedParams"] } @@ -197,7 +201,7 @@ "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "maxAttempts": 3, "script": [ { "$fail": "runtime-exception" }, { "$ref": "responses/semantic.information.accept.full" } ] @@ -220,7 +224,7 @@ "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "maxAttempts": 3, "script": [ { "$fail": "null-response" }, { "$ref": "responses/semantic.information.accept.full" } ] diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/merge-and-schema.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/merge-and-schema.json index 9f313ab7..39299b7e 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/merge-and-schema.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/merge-and-schema.json @@ -4,15 +4,15 @@ "api": "validateProposePromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P1", - "tags": ["merge", "context-wins", "key-conflict", "colliding-schema", "propose", "information"], - "summary": "业务参数与上下文 id、round 键冲突时上下文参数优先,LLM 提取的错误取值不覆盖规则层结果", + "tags": ["merge", "context-wins", "key-conflict", "colliding-schema", "propose", "information", "text-input"], + "summary": "业务参数与上下文 id、round 键冲突时上下文参数优先,大模型提取的错误取值不覆盖规则层结果", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, + "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, "schema": { "$ref": "schemas/colliding" }, "llm": { "script": [ - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"id\":\"00000000-0000-0000-0000-000000000000\",\"round\":99,\"region\":\"Songshan Lake\"}}" + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"id\":\"00000000-0000-0000-0000-000000000000\",\"round\":99,\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}" ] }, "expect": { @@ -22,7 +22,7 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1" }, "contracts": ["contextKeysInMergedParams"] } @@ -32,11 +32,11 @@ "api": "validateAcceptPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P1", - "tags": ["merge", "no-type-schema", "schema-wrapping", "accept", "information"], - "summary": "调用方未声明 type 关键字的参数 schema 被自动包装为对象 schema,语义校验与参数提取不受影响", + "tags": ["merge", "no-type-schema", "schema-wrapping", "accept", "information", "text-input"], + "summary": "调用方未声明 type 关键字的参数 schema 被自动包装为对象 schema,补数接受报文的语义校验与投诉参数提取不受影响", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "golden": "information_accept" }, + "prompt": { "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:专线质差" }, "schema": { "$ref": "schemas/noType" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { @@ -46,7 +46,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, "contracts": ["contextKeysInMergedParams"] } @@ -57,10 +58,10 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["null-context", "invalid-input", "rule-gate", "propose"], - "summary": "校验提议消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", + "summary": "校验要数提议消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", "context": null, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "text": "请补充节能区域信息(如松山湖)、节能速率保障目标(如20Mbps)与VLANId三项信息。" }, + "prompt": { "text": "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识三项信息,三项之间为或的关系。" }, "schema": { "$ref": "schemas/flat" }, "expect": { "outcome": "failure", @@ -76,10 +77,10 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["null-context", "invalid-input", "rule-gate", "accept"], - "summary": "校验接受消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", + "summary": "校验补数接受消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", "context": null, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "text": "我确认第一阶段的信息:节能区域为松山湖,节能速率保障目标为20Mbps。" }, + "prompt": { "text": "我确认补充的信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" }, "schema": { "$ref": "schemas/flat" }, "expect": { "outcome": "failure", @@ -98,7 +99,7 @@ "summary": "校验拒绝消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", "context": null, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "text": "无法提供节能区域信息:站点库存数据暂不可用,本轮协商拒绝。" }, + "prompt": { "text": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮信息协商拒绝。" }, "schema": { "$ref": "schemas/flat" }, "expect": { "outcome": "failure", @@ -117,7 +118,7 @@ "summary": "校验终止消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", "context": null, "templateUri": "Negotiation-T/common/abort/v1", - "prompt": { "text": "协商轮次已达上限,本次协商确认终止。" }, + "prompt": { "text": "已达到协商轮次上限,本次协商确认终止。" }, "schema": { "$ref": "schemas/flat" }, "expect": { "outcome": "failure", diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/programming-errors.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/programming-errors.json index 731d6315..876f1418 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/programming-errors.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/programming-errors.json @@ -5,10 +5,10 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-prompt", "propose"], - "summary": "校验提议消息时未传入消息文本,直接抛出空指针异常且不调用大模型", + "summary": "校验要数提议消息时未传入消息文本,直接抛出空指针异常且不调用大模型", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -22,11 +22,11 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "blank-prompt", "doc-gap", "propose"], - "summary": "校验提议消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针)", + "summary": "校验要数提议消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "text": " " }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -41,10 +41,10 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-prompt", "accept"], - "summary": "校验接受消息时未传入消息文本,直接抛出空指针异常且不调用大模型", + "summary": "校验补数接受消息时未传入消息文本,直接抛出空指针异常且不调用大模型", "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -58,11 +58,11 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "blank-prompt", "doc-gap", "accept"], - "summary": "校验接受消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针)", + "summary": "校验补数接受消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针)", "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "text": " " }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -77,7 +77,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-prompt", "reject"], - "summary": "校验拒绝消息时未传入消息文本,直接抛出空指针异常且不调用大模型", + "summary": "校验修复目标拒绝消息时未传入消息文本,直接抛出空指针异常且不调用大模型", "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "schema": { "$ref": "schemas/empty" }, @@ -94,7 +94,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "blank-prompt", "doc-gap", "reject"], - "summary": "校验拒绝消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针)", + "summary": "校验修复目标拒绝消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针)", "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "prompt": { "text": " " }, @@ -149,11 +149,11 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-context", "propose"], - "summary": "校验提议消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", + "summary": "校验要数提议消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", "context": null, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -169,11 +169,11 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-context", "accept"], - "summary": "校验接受消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", + "summary": "校验补数接受消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", "context": null, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -189,7 +189,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-context", "reject"], - "summary": "校验拒绝消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", + "summary": "校验修复目标拒绝消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", "context": null, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "prompt": { "golden": "target_reject" }, @@ -229,11 +229,11 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "uri-phase-mismatch", "propose"], - "summary": "校验提议消息时模板 URI 声明的是接受-拒绝阶段,阶段不匹配抛出参数异常且不调用大模型", + "summary": "校验要数提议消息时模板 URI 声明的是接受-拒绝阶段,阶段不匹配抛出参数异常且不调用大模型", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -248,11 +248,11 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "uri-phase-mismatch", "accept"], - "summary": "校验接受消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型", + "summary": "校验补数接受消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型", "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -267,7 +267,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "uri-phase-mismatch", "reject"], - "summary": "校验拒绝消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型", + "summary": "校验修复目标拒绝消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型", "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, "templateUri": "Negotiation-T/target-negotiation/propose/v1", "prompt": { "golden": "target_reject" }, @@ -305,11 +305,11 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "uri-phase-mismatch", "common-uri", "probe"], - "summary": "校验提议消息时误用通用终止模板 URI,类型化阶段不接受 common 段,抛出参数异常", + "summary": "校验要数提议消息时误用通用终止模板 URI,类型化阶段不接受 common 段,抛出参数异常", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/rule-gate.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/rule-gate.json index 7ac712d5..788c579e 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/rule-gate.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/rule-gate.json @@ -9,7 +9,7 @@ "context": { "id": "3dbc13b5", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -29,7 +29,7 @@ "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d", "round": 1, "maxRounds": 3 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -49,7 +49,7 @@ "context": { "id": "gdbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -64,12 +64,12 @@ "api": "validateProposePromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["rule-gate", "round", "boundary", "happy"], - "summary": "当前轮次等于最大轮次预算(第 5 轮即最后一轮)时消息仍可通过规则校验并正常提取参数,只有超出预算才被拒绝", + "tags": ["rule-gate", "round", "boundary", "happy", "text-input"], + "summary": "当前轮次等于最大轮次预算(第 5 轮即最后一轮)时OMC要数提议报文仍可通过规则校验并正常提取投诉参数,只有超出预算才被拒绝", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -78,7 +78,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, "contracts": ["contextKeysInMergedParams"] } @@ -93,7 +94,7 @@ "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 6, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -113,7 +114,7 @@ "context": { "id": "negotiation-session-001", "round": 6, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", @@ -131,12 +132,12 @@ "api": "validateProposePromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P1", - "tags": ["rule-gate", "round", "boundary", "single-round", "happy"], - "summary": "单轮预算(最大轮次为 1)的第 1 轮消息可通过规则校验并正常提取参数,轮次下限边界同样放行", + "tags": ["rule-gate", "round", "boundary", "single-round", "happy", "text-input"], + "summary": "单轮预算(最大轮次为 1)的第 1 轮要数提议报文可通过规则校验并正常提取投诉参数,轮次下限边界同样放行", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 1 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -145,7 +146,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 1, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, "contracts": ["contextKeysInMergedParams"] } @@ -155,12 +157,12 @@ "api": "validateProposePromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P2", - "tags": ["rule-gate", "uuid", "uppercase-hex", "happy"], - "summary": "协商会话标识使用大写十六进制 UUID 时规则门同样放行,消息正常校验并提取参数,合并结果原样保留大写标识", + "tags": ["rule-gate", "uuid", "uppercase-hex", "happy", "text-input"], + "summary": "协商会话标识使用大写十六进制 UUID 时规则门同样放行,要数提议报文正常校验并提取投诉参数,合并结果原样保留大写标识", "context": { "id": "3DBC13B5-BD57-4C2B-B503-24E381B6C8D3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -169,7 +171,8 @@ "id": "3DBC13B5-BD57-4C2B-B503-24E381B6C8D3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, "contracts": ["contextKeysInMergedParams"] } diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/semantic-verdicts.json b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/semantic-verdicts.json index 7a2e2e5b..013af0c5 100644 --- a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/semantic-verdicts.json +++ b/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/semantic-verdicts.json @@ -4,12 +4,12 @@ "api": "validateAcceptPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["semantic-verdict", "information", "accept", "type-consistent"], - "summary": "语义判定通过且上报类型与信息协商模板一致时,接受消息校验成功并合并出区域参数", + "tags": ["semantic-verdict", "information", "accept", "type-consistent", "text-input"], + "summary": "工作台补数接受报文语义判定通过且上报类型与信息协商模板一致时,校验成功并合并出接入端口名称与投诉分类参数", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/flat" }, + "prompt": { "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:专线质差" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -18,7 +18,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, "contracts": ["contextKeysInMergedParams"] } @@ -28,12 +29,12 @@ "api": "validateAcceptPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["semantic-verdict", "target", "accept", "type-consistent"], - "summary": "语义判定通过且上报类型与目标协商模板一致时,接受消息校验成功并合并出站点与速率保障参数", + "tags": ["semantic-verdict", "target", "accept", "type-consistent", "text-input"], + "summary": "修复目标确认接受报文语义判定通过且上报类型与目标协商模板一致时,校验成功并合并出时延目标与修复时限参数", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "prompt": { "golden": "target_accept" }, - "schema": { "$ref": "schemas/flat" }, + "prompt": { "text": "## 目标协商结果\nAccept\n\n## 目标协商结果内容\n最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" }, + "schema": { "$ref": "schemas/empty" }, "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, "expect": { "outcome": "success", @@ -42,8 +43,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3, - "site": "Songshan Lake", - "guaranteed_rate": "at least 20Mbps" + "latency_target": "within 20ms", + "repair_deadline": "2026-05-15" }, "contracts": ["contextKeysInMergedParams"] } @@ -53,12 +54,12 @@ "api": "validateProposePromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["semantic-verdict", "feasibility", "propose", "type-consistent"], - "summary": "语义判定通过且上报类型与可行性协商模板一致时,提议消息校验成功并合并出可行性结论参数", + "tags": ["semantic-verdict", "feasibility", "propose", "type-consistent", "text-input"], + "summary": "端口扩容可行性提议报文语义判定通过且上报类型与可行性协商模板一致时,校验成功并合并出扩容可行性结论参数", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 5 }, "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "prompt": { "golden": "feasibility_propose" }, - "schema": { "$ref": "schemas/flat" }, + "prompt": { "text": "## 可行性协商\n请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。\n\n## 待评估内容说明\n1. 扩容方案:接入端口带宽由100Mbps扩容至1000Mbps\n2. 既有约束:割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟" }, + "schema": { "$ref": "schemas/empty" }, "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, "expect": { "outcome": "success", @@ -67,7 +68,7 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 5, - "feasibility_result": "achievable" + "port_expansion": "feasible" }, "contracts": ["contextKeysInMergedParams"] } @@ -77,11 +78,11 @@ "api": "validateAbortPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P0", - "tags": ["semantic-verdict", "abort", "common", "type-consistent"], - "summary": "终止消息语义判定通过且未上报协商类型时校验成功,合并出终止原因参数", + "tags": ["semantic-verdict", "abort", "common", "type-consistent", "text-input"], + "summary": "轮次耗尽终止报文语义判定通过且未上报协商类型时校验成功,合并出终止原因参数", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", - "prompt": { "golden": "abort" }, + "prompt": { "text": "## 协商结果\nAbort\n\n## 协商终止原因\n已达到协商轮次上限,本次协商确认终止。" }, "schema": { "$ref": "schemas/empty" }, "llm": { "script": [ { "$ref": "responses/semantic.common.abort.full" } ] }, "expect": { @@ -91,7 +92,7 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5, - "termination_reason": "The negotiation round limit is reached." + "termination_reason": "已达到协商轮次上限" }, "contracts": ["contextKeysInMergedParams"] } @@ -101,12 +102,12 @@ "api": "validateRejectPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P1", - "tags": ["semantic-verdict", "information", "reject", "type-consistent"], - "summary": "语义判定通过且类型一致时,拒绝侧消息同样校验成功,类型一致性不区分接受或拒绝动作", + "tags": ["semantic-verdict", "information", "reject", "type-consistent", "text-input"], + "summary": "工作台无法提供端口名的拒绝报文语义判定通过且类型一致时校验成功,类型一致性不区分接受或拒绝动作", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 4 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "golden": "information_reject" }, - "schema": { "$ref": "schemas/flat" }, + "prompt": { "text": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,工作台侧端口资源台账暂不可查" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, "expect": { "outcome": "success", @@ -115,7 +116,8 @@ "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 4, - "region": "Songshan Lake" + "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "bizScenario": "专线质差" }, "contracts": ["contextKeysInMergedParams"] } @@ -125,11 +127,11 @@ "api": "validateAbortPromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P2", - "tags": ["semantic-verdict", "abort", "common", "type-unchecked", "probe"], + "tags": ["semantic-verdict", "abort", "common", "type-unchecked", "probe", "text-input"], "summary": "公共终止模板未声明协商类型,判定通过时即使大模型上报了类型也照常校验成功", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 4, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", - "prompt": { "golden": "abort" }, + "prompt": { "text": "## 协商结果\nAbort\n\n## 协商终止原因\n已达到协商轮次上限,本次协商确认终止。" }, "schema": { "$ref": "schemas/empty" }, "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}" ] }, "expect": { @@ -149,11 +151,11 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["semantic-verdict", "information", "propose", "type-mismatch"], - "summary": "大模型判定通过但上报成目标协商类型时,信息协商提议被语义拒绝并指出类型不一致", + "summary": "大模型判定通过但上报成目标协商类型时,信息协商要数提议被语义拒绝并指出类型不一致", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.type_mismatch" } ] }, "expect": { "outcome": "failure", @@ -169,11 +171,11 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["semantic-verdict", "information", "propose", "null-type"], - "summary": "大模型判定通过但未上报协商类型时,信息协商提议被语义拒绝并按模板类型报类型不一致", + "summary": "大模型判定通过但未上报协商类型时,信息协商要数提议被语义拒绝并按模板类型报类型不一致", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.null_type" } ] }, "expect": { "outcome": "failure", @@ -189,11 +191,11 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["semantic-verdict", "information", "propose", "unknown-type", "probe"], - "summary": "大模型判定通过但上报未知协商类型字符串时,信息协商提议被语义拒绝", + "summary": "大模型判定通过但上报未知协商类型字符串时,信息协商要数提议被语义拒绝", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"bogus\",\"errors\":[],\"params\":{}}" ] }, "expect": { "outcome": "failure", @@ -209,11 +211,11 @@ "languages": ["zh-CN"], "priority": "P2", "tags": ["semantic-verdict", "information", "propose", "case-sensitivity", "probe"], - "summary": "大模型上报的协商类型仅大小写不同也被视为类型不一致,信息协商提议被语义拒绝", + "summary": "大模型上报的协商类型仅大小写不同也被视为类型不一致,信息协商要数提议被语义拒绝", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 3 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"Information\",\"errors\":[],\"params\":{}}" ] }, "expect": { "outcome": "failure", @@ -229,17 +231,17 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["semantic-verdict", "information", "propose", "verdict-false"], - "summary": "大模型语义判定不通过时,信息协商提议被拒绝并返回缺失的区域参数槽位错误", + "summary": "大模型语义判定不通过时,信息协商要数提议被拒绝并返回缺失的接入端口参数槽位错误", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$ref": "responses/semantic.information.reject" } ] }, "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "region", "code": "missing" } ], + "slotErrors": [ { "slot": "accessPort", "code": "missing" } ], "llmCalls": 1 } }, @@ -253,13 +255,13 @@ "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 4 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "golden": "information_reject" }, - "schema": { "$ref": "schemas/flat" }, - "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"target\",\"errors\":[{\"slot_name\":\"region\",\"code\":\"missing\",\"message\":\"The region parameter is missing.\"}],\"params\":{}}" ] }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"target\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"message\":\"The access port parameter is missing.\"}],\"params\":{}}" ] }, "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "region", "code": "missing" } ], + "slotErrors": [ { "slot": "accessPort", "code": "missing" } ], "llmCalls": 1 } }, @@ -273,7 +275,7 @@ "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}" ] }, "expect": { "outcome": "failure", @@ -288,17 +290,17 @@ "languages": ["zh-CN"], "priority": "P2", "tags": ["semantic-verdict", "information", "propose", "params-dropped", "probe"], - "summary": "语义判定不通过时已提取的参数被丢弃,校验以语义拒绝失败且不合并任何业务参数", + "summary": "语义判定不通过时已提取的投诉参数被丢弃,校验以语义拒绝失败且不合并任何业务参数", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, - "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"region\",\"code\":\"missing\",\"message\":\"The region parameter is missing.\"}],\"params\":{\"region\":\"Songshan Lake\"}}" ] }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"message\":\"The access port parameter is missing.\"}],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}" ] }, "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "region", "code": "missing" } ], + "slotErrors": [ { "slot": "accessPort", "code": "missing" } ], "llmCalls": 1 } }, @@ -332,7 +334,7 @@ "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "maxAttempts": 3, "script": [ @@ -344,7 +346,7 @@ "outcome": "failure", "exception": "NegotiationParamExtractionException", "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "region", "code": "missing" } ], + "slotErrors": [ { "slot": "accessPort", "code": "missing" } ], "llmCalls": 1 } }, @@ -353,13 +355,13 @@ "api": "validateProposePromptAndDataFilling", "languages": ["zh-CN", "en-US"], "priority": "P2", - "tags": ["semantic-verdict", "information", "propose", "errors-ignored", "probe"], - "summary": "语义判定通过时大模型附带的槽位错误被忽略,校验成功且只合并上下文三键", + "tags": ["semantic-verdict", "information", "propose", "errors-ignored", "probe", "text-input"], + "summary": "语义判定通过时大模型附带的槽位错误被忽略,要数提议报文校验成功且只合并上下文三键", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/flat" }, - "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"region\",\"code\":\"missing\",\"message\":\"The region parameter is missing.\"}],\"params\":{}}" ] }, + "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, + "schema": { "$ref": "schemas/biz.complaint.params" }, + "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"message\":\"The access port parameter is missing.\"}],\"params\":{}}" ] }, "expect": { "outcome": "success", "llmCalls": 1, From 9a681b1ac698a72d4dd59c0ec520a177afdbaf77 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 24 Aug 2026 14:48:32 +0800 Subject: [PATCH 164/295] chore(tools): sync corpus tooling to business realignment - corpus_index.py: task APIs added to coverage matrix (15 API columns) - corpus_review.py: task-API action entries, task-param dictionary (accessPort/bizScenario/faultTime/eventSerialNo), biz.complaint.params schema entry, missingParams rendering, complaint-diagnosis domain grouping, A=workbench/B=OMC role semantics in projections Signed-off-by: codetalks-hw --- tools/corpus_index.py | 4 +++ tools/corpus_review.py | 70 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/tools/corpus_index.py b/tools/corpus_index.py index 129296f4..0f61d4b0 100644 --- a/tools/corpus_index.py +++ b/tools/corpus_index.py @@ -33,6 +33,10 @@ "validateAcceptPromptAndDataFilling", "validateRejectPromptAndDataFilling", "validateAbortPromptAndDataFilling", + # the three closed-loop task APIs (Q21): exercised as scenario steps, never as standalone case records + "generateTaskPromptFromText", + "generateTaskPromptFromDataWithSchema", + "validateTaskPromptAndDataFilling", ) FAMILY_ORDER = ("FT", "FD", "VAL", "SC") FAMILY_LABELS = {"FT": "from-text", "FD": "from-data", "VAL": "validate", "SC": "scenarios"} diff --git a/tools/corpus_review.py b/tools/corpus_review.py index 8d4510a0..4640d043 100644 --- a/tools/corpus_review.py +++ b/tools/corpus_review.py @@ -1,6 +1,11 @@ #!/usr/bin/env python3 """Business review support for the A2A-T negotiation test corpus (design doc-local/fromtext-validate-test-design.md §8.7, Q19). +The corpus itself is the single-business-domain private-line complaint diagnosis corpus (Q20-Q23): every case, +scenario and golden fixture speaks the complaint-diagnosis five-step closed loop, and the dictionaries below +translate the corpus facts into that business wording (task API actions of A2ATClient/A2ATServer, task parameter +names, complaint-diagnosis business domains). + Review material is a *projection* of the corpus: generated from negotiation-cases/ JSON, never hand-maintained. Two modes: @@ -57,6 +62,23 @@ "validateAcceptPromptAndDataFilling": "校验接受消息并提取参数", "validateRejectPromptAndDataFilling": "校验拒绝消息并提取参数", "validateAbortPromptAndDataFilling": "校验终止消息并提取参数", + # the three closed-loop task APIs (Q20-Q23): A2ATClient facade for generation, A2ATServer facade for validation + "generateTaskPromptFromText": "工作台从投诉文本生成任务报文(A2ATClient)", + "generateTaskPromptFromDataWithSchema": "工作台从结构化数据与参数模式生成任务报文(A2ATClient)", + "validateTaskPromptAndDataFilling": "OMC 校验对端任务报文并提取任务参数(A2ATServer)", +} + +# Task parameter names of the private-line complaint diagnosis (schema biz.complaint.params), for business wording. +TASK_PARAM_ZH = { + "accessPort": "接入端口名称", + "bizScenario": "投诉分类", + "faultTime": "问题发生时间", + "eventSerialNo": "OSS侧事件流水号", +} + +# Shared schema references of the corpus, for business wording. +SCHEMA_ZH = { + "biz.complaint.params": "专线投诉诊断业务参数模式(接入端口名称/投诉分类必填)", } ERROR_CODE_ZH = { @@ -101,19 +123,34 @@ } TEMPLATE_URI_ZH = { - "information-negotiation": "信息协商", - "target-negotiation": "目标协商", - "feasibility-negotiation": "可行性协商", - "common": "通用", + # complaint-diagnosis business domains (Q20/Q23): every corpus record belongs to the private-line complaint + # diagnosis; the domain labels carry its closed-loop semantics instead of bare negotiation type names + "network-layer": "任务闭环(专线投诉诊断)", + "information-negotiation": "信息协商(缺参要数)", + "target-negotiation": "目标协商(修复目标确认)", + "feasibility-negotiation": "可行性协商(修复方案确认)", + "common": "通用(终止)", +} +PHASE_ZH = { + "propose": "提议", + "accept-reject": "接受/拒绝", + "abort": "终止", + "private-line-complaint": "任务报文", } -PHASE_ZH = {"propose": "提议", "accept-reject": "接受/拒绝", "abort": "终止"} TERMINAL_ZH = {"accept": "接受(协商达成)", "reject": "拒绝(协商终止)", "abort": "终止", "exhausted": "轮次预算耗尽"} -DOMAIN_ORDER = ["信息协商", "目标协商", "可行性协商", "通用(终止)", "未分类"] +DOMAIN_ORDER = [ + "任务闭环(专线投诉诊断)", + "信息协商(缺参要数)", + "目标协商(修复目标确认)", + "可行性协商(修复方案确认)", + "通用(终止)", + "未分类", +] DOMAIN_FALLBACK_TAG = { - "information": "信息协商", - "target": "目标协商", - "feasibility": "可行性协商", + "information": "信息协商(缺参要数)", + "target": "目标协商(修复目标确认)", + "feasibility": "可行性协商(修复方案确认)", "abort": "通用(终止)", "common": "通用(终止)", } @@ -258,7 +295,8 @@ def translate_input(rec: dict) -> str: chunks.append(f"待校验消息:{clip(str(prompt['text']), 90)}") schema = rec.get("schema") if isinstance(schema, dict) and "$ref" in schema: - chunks.append(f"参数模式:{schema['$ref'].split('/')[-1]}") + ref = schema["$ref"].split("/")[-1] + chunks.append(f"参数模式:{SCHEMA_ZH.get(ref, ref)}") return ";".join(chunks) @@ -290,8 +328,12 @@ def translate_expect(rec: dict) -> str: lines.append(f"渲染消息与 golden 基准一致({golden})") params = expect.get("params") if isinstance(params, dict): - rendered = "、".join(f"{k}={v}" for k, v in params.items()) + rendered = "、".join(f"{TASK_PARAM_ZH.get(k, k)}={v}" for k, v in params.items()) lines.append(f"提取参数:{clip(rendered, 120)}") + missing = expect.get("missingParams") + if isinstance(missing, list) and missing: + rendered = "、".join(TASK_PARAM_ZH.get(k, k) for k in missing) + lines.append(f"缺失参数(协商要数动因):{rendered}") elif outcome == "failure": code = expect.get("code") lines.append(f"失败:{ERROR_CODE_ZH.get(code, code or '(无错误码)')}" + (f"({code})" if code else "")) @@ -426,7 +468,9 @@ def render_html(records: list[dict], statuses: dict) -> str: w("") w("") w(f"A2A-T 协商测试语料业务评审 · {now}") - w(f"

        A2A-T 协商测试语料业务评审材料

        " + w(f"

        专线投诉诊断协商测试语料业务评审材料

        " + f"

        单一业务域:传输专线业务投诉诊断五步闭环(Q23)· " + f"角色:A=工作台(client,任务发起/补数方) / B=OMC(server,执行/要数方,协商发起方)

        " f"

        自动生成于 {now} · 语料投影,请勿手工编辑 · 生成命令:python tools/corpus_review.py export

        ") w("
        ") @@ -529,7 +573,7 @@ def render_html(records: list[dict], statuses: dict) -> str: for r in scenarios: status = statuses.get(r["id"], {}).get("status", "") w(f"

        {esc(r['id'])} · {esc(r.get('summary', ''))} " + status_badge(status) + "

        ") - w("") + w("
        步骤A(发起方)B(接收方)
        ") for step in r.get("steps", []): role = step.get("role", "A") col = "B" if role == "B" else "A" From ad3a1996f715305170b1bc6c5968e3b1a9c45bbe Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 24 Aug 2026 17:00:13 +0800 Subject: [PATCH 165/295] feat(client,server): add LLM client injection point to assembly builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DefaultA2ATClientBuilder / DefaultA2ATServerBuilder gain llmClient(@Nullable LLMClient): when set, the injected instance fully replaces the createLlmClient() factory result and flows through every component the facade assembles (client prompt generation orchestrator, server task/notification/auth content validators, negotiation orchestrators). Unset behavior is byte-identical to before. Purpose: testability — the a2a-t-corpus conformance suite wires its scripted LLM client through the same builder path the facades use, eliminating assembly-mirror drift risk. Signed-off-by: codetalks-hw --- .../assembly/DefaultA2ATClientBuilder.java | 17 +++++++++++++++++ .../net/openan/a2at/sdk/corpus}/CaseEngine.java | 0 .../openan/a2at/sdk/corpus}/CaseEngineTest.java | 0 .../openan/a2at/sdk/corpus}/ContextSpec.java | 0 .../net/openan/a2at/sdk/corpus}/Contract.java | 0 .../a2at/sdk/corpus}/CorpusContractTest.java | 0 .../a2at/sdk/corpus}/CorpusLoadException.java | 0 .../sdk/corpus}/CorpusSensitivitySelfTest.java | 0 .../openan/a2at/sdk/corpus}/CorpusSuites.java | 0 .../openan/a2at/sdk/corpus}/Expectation.java | 0 .../sdk/corpus}/FromDataCorpusSuiteTest.java | 0 .../sdk/corpus}/FromTextCorpusSuiteTest.java | 0 .../openan/a2at/sdk/corpus}/LlmFailMarker.java | 0 .../net/openan/a2at/sdk/corpus}/LlmScript.java | 0 .../openan/a2at/sdk/corpus}/LlmScriptStep.java | 0 .../openan/a2at/sdk/corpus}/LoadedCorpus.java | 0 .../openan/a2at/sdk/corpus}/NegotiationApi.java | 0 .../a2at/sdk/corpus}/NegotiationCase.java | 0 .../a2at/sdk/corpus}/NegotiationCaseLoader.java | 0 .../sdk/corpus}/NegotiationCaseLoaderTest.java | 0 .../openan/a2at/sdk/corpus}/PromptSource.java | 0 .../openan/a2at/sdk/corpus}/ScenarioCase.java | 0 .../sdk/corpus}/ScenarioCorpusSuiteTest.java | 0 .../openan/a2at/sdk/corpus}/ScenarioEngine.java | 0 .../a2at/sdk/corpus}/ScenarioEngineTest.java | 0 .../corpus}/ScriptedNegotiationLlmClient.java | 0 .../ScriptedNegotiationLlmClientTest.java | 0 .../a2at/sdk/corpus}/TaskApiAssembler.java | 0 .../a2at/sdk/corpus}/TypedInputAssembler.java | 0 .../sdk/corpus}/ValidateCorpusSuiteTest.java | 0 .../golden/GoldenFixtureComparisonTest.java | 0 .../golden/GoldenFixtureGeneratorTest.java | 0 .../a2at/sdk/corpus}/golden/GoldenInputs.java | 0 .../ErrorCodePartitionPropertyTest.java | 0 .../FromDataDeterminismPropertyTest.java | 0 .../property/FromDataRoundTripPropertyTest.java | 0 .../property/LargeParamsMergePropertyTest.java | 0 .../NegotiationErrorPathPropertyTest.java | 0 .../corpus}/property/PropertyArbitraries.java | 0 .../sdk/corpus}/property/PropertyHarness.java | 0 .../src/test/resources/golden/en-US/abort.md | 0 .../golden/en-US/feasibility_accept.md | 0 .../golden/en-US/feasibility_propose.md | 0 .../golden/en-US/feasibility_reject.md | 0 .../golden/en-US/information_accept.md | 0 .../golden/en-US/information_propose.md | 0 .../golden/en-US/information_reject.md | 0 .../resources/golden/en-US/target_accept.md | 0 .../resources/golden/en-US/target_propose.md | 0 .../resources/golden/en-US/target_reject.md | 0 .../src/test/resources/golden/zh-CN/abort.md | 0 .../golden/zh-CN/feasibility_accept.md | 0 .../golden/zh-CN/feasibility_propose.md | 0 .../golden/zh-CN/feasibility_reject.md | 0 .../golden/zh-CN/information_accept.md | 0 .../golden/zh-CN/information_propose.md | 0 .../golden/zh-CN/information_reject.md | 0 .../resources/golden/zh-CN/target_accept.md | 0 .../resources/golden/zh-CN/target_propose.md | 0 .../resources/golden/zh-CN/target_reject.md | 0 .../test/resources/negotiation-cases/INDEX.md | 0 .../negotiation-cases/corpus-schema.json | 0 .../negotiation-cases/from-data/happy.json | 0 .../from-data/programming-errors.json | 0 .../from-text/extraction-failures.json | 0 .../negotiation-cases/from-text/happy.json | 0 .../from-text/programming-errors.json | 0 .../negotiation-cases/from-text/retry.json | 0 .../from-text/template-resolution.json | 0 .../scenarios/boundary-flows.json | 0 .../scenarios/feasibility-flows.json | 0 .../scenarios/information-flows.json | 0 .../scenarios/target-flows.json | 0 .../negotiation-cases/shared/llm-responses.json | 0 .../negotiation-cases/shared/schemas.json | 0 .../validate/drift-probes.json | 0 .../validate/error-code-mapping.json | 0 .../negotiation-cases/validate/happy.json | 0 .../validate/llm-shape-and-retry.json | 0 .../validate/merge-and-schema.json | 0 .../validate/programming-errors.json | 0 .../negotiation-cases/validate/rule-gate.json | 0 .../validate/semantic-verdicts.json | 0 .../assembly/DefaultA2ATServerBuilder.java | 17 +++++++++++++++++ 84 files changed, 34 insertions(+) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/CaseEngine.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/CaseEngineTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/ContextSpec.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/Contract.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/CorpusContractTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/CorpusLoadException.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/CorpusSensitivitySelfTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/CorpusSuites.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/Expectation.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/FromDataCorpusSuiteTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/FromTextCorpusSuiteTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/LlmFailMarker.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/LlmScript.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/LlmScriptStep.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/LoadedCorpus.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/NegotiationApi.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/NegotiationCase.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/NegotiationCaseLoader.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/NegotiationCaseLoaderTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/PromptSource.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/ScenarioCase.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/ScenarioCorpusSuiteTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/ScenarioEngine.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/ScenarioEngineTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/ScriptedNegotiationLlmClient.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/ScriptedNegotiationLlmClientTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/TaskApiAssembler.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/TypedInputAssembler.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/ValidateCorpusSuiteTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/golden/GoldenFixtureComparisonTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/golden/GoldenFixtureGeneratorTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/golden/GoldenInputs.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/property/ErrorCodePartitionPropertyTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/property/FromDataDeterminismPropertyTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/property/FromDataRoundTripPropertyTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/property/LargeParamsMergePropertyTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/property/NegotiationErrorPathPropertyTest.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/property/PropertyArbitraries.java (100%) rename {a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation => a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus}/property/PropertyHarness.java (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/en-US/abort.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/en-US/feasibility_accept.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/en-US/feasibility_propose.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/en-US/feasibility_reject.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/en-US/information_accept.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/en-US/information_propose.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/en-US/information_reject.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/en-US/target_accept.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/en-US/target_propose.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/en-US/target_reject.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/zh-CN/abort.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/zh-CN/feasibility_accept.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/zh-CN/feasibility_propose.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/zh-CN/feasibility_reject.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/zh-CN/information_accept.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/zh-CN/information_propose.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/zh-CN/information_reject.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/zh-CN/target_accept.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/zh-CN/target_propose.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/golden/zh-CN/target_reject.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/INDEX.md (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/corpus-schema.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/from-data/happy.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/from-data/programming-errors.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/from-text/extraction-failures.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/from-text/happy.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/from-text/programming-errors.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/from-text/retry.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/from-text/template-resolution.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/scenarios/boundary-flows.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/scenarios/information-flows.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/scenarios/target-flows.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/shared/llm-responses.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/shared/schemas.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/validate/drift-probes.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/validate/error-code-mapping.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/validate/happy.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/validate/merge-and-schema.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/validate/programming-errors.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/validate/rule-gate.json (100%) rename {a2a-t-negotiation => a2a-t-corpus}/src/test/resources/negotiation-cases/validate/semantic-verdicts.json (100%) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index e3032846..4d3da294 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -24,6 +24,7 @@ import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; +import org.jspecify.annotations.Nullable; /** * Default builder that assembles one high-level A2AT client runtime from unified config. @@ -73,6 +74,22 @@ public DefaultA2ATClientBuilder envPath(Path envPath) { return this; } + /** + * Injects an explicit LLM client that fully replaces the factory default. + * + *

        The injection point exists for testability and custom LLM assemblies: when set, every orchestrator built by + * this builder reuses the given client and no client is created from the `.env` LLM config. When unset, the + * builder keeps creating its default client and the behavior is unchanged. + * + * @param llmClient LLM client to inject; {@code null} keeps the factory default built from the `.env` LLM config + * @return current builder + * @since 2026-08 + */ + public DefaultA2ATClientBuilder llmClient(@Nullable LLMClient llmClient) { + this.llmClient = llmClient; + return this; + } + /** * Builds the default prompt-generation orchestrator from the configured unified SDK config. * diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngine.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngineTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CaseEngineTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ContextSpec.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ContextSpec.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ContextSpec.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ContextSpec.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Contract.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Contract.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Contract.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Contract.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusContractTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusContractTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusLoadException.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusLoadException.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusLoadException.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusLoadException.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSensitivitySelfTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSensitivitySelfTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSuites.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSuites.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/CorpusSuites.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSuites.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Expectation.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/Expectation.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/FromDataCorpusSuiteTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromDataCorpusSuiteTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/FromDataCorpusSuiteTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromDataCorpusSuiteTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/FromTextCorpusSuiteTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromTextCorpusSuiteTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/FromTextCorpusSuiteTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromTextCorpusSuiteTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmFailMarker.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmFailMarker.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmFailMarker.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmFailMarker.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmScript.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScript.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmScript.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScript.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmScriptStep.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScriptStep.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LlmScriptStep.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScriptStep.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LoadedCorpus.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LoadedCorpus.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/LoadedCorpus.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LoadedCorpus.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationApi.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationApi.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationApi.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationApi.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCase.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCase.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCase.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCase.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoader.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoader.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoader.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoader.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoaderTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/NegotiationCaseLoaderTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/PromptSource.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/PromptSource.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/PromptSource.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/PromptSource.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCase.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCase.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCase.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCase.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCorpusSuiteTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCorpusSuiteTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioCorpusSuiteTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCorpusSuiteTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngine.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngine.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngine.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngineTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScenarioEngineTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScriptedNegotiationLlmClient.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClient.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScriptedNegotiationLlmClient.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClient.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScriptedNegotiationLlmClientTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClientTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ScriptedNegotiationLlmClientTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClientTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/TaskApiAssembler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/TaskApiAssembler.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/TypedInputAssembler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/TypedInputAssembler.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ValidateCorpusSuiteTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ValidateCorpusSuiteTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/testdata/ValidateCorpusSuiteTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ValidateCorpusSuiteTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenFixtureComparisonTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureComparisonTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenFixtureComparisonTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenFixtureGeneratorTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenFixtureGeneratorTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenFixtureGeneratorTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenInputs.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/golden/GoldenInputs.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenInputs.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/ErrorCodePartitionPropertyTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/ErrorCodePartitionPropertyTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/ErrorCodePartitionPropertyTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/ErrorCodePartitionPropertyTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/FromDataDeterminismPropertyTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/FromDataDeterminismPropertyTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/FromDataDeterminismPropertyTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/FromDataDeterminismPropertyTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/FromDataRoundTripPropertyTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/FromDataRoundTripPropertyTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/FromDataRoundTripPropertyTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/FromDataRoundTripPropertyTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/LargeParamsMergePropertyTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/LargeParamsMergePropertyTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/LargeParamsMergePropertyTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/LargeParamsMergePropertyTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/NegotiationErrorPathPropertyTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/NegotiationErrorPathPropertyTest.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/NegotiationErrorPathPropertyTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/NegotiationErrorPathPropertyTest.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyArbitraries.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyArbitraries.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyArbitraries.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyArbitraries.java diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyHarness.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java similarity index 100% rename from a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/property/PropertyHarness.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/abort.md b/a2a-t-corpus/src/test/resources/golden/en-US/abort.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/en-US/abort.md rename to a2a-t-corpus/src/test/resources/golden/en-US/abort.md diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md b/a2a-t-corpus/src/test/resources/golden/en-US/feasibility_accept.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_accept.md rename to a2a-t-corpus/src/test/resources/golden/en-US/feasibility_accept.md diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md b/a2a-t-corpus/src/test/resources/golden/en-US/feasibility_propose.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_propose.md rename to a2a-t-corpus/src/test/resources/golden/en-US/feasibility_propose.md diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md b/a2a-t-corpus/src/test/resources/golden/en-US/feasibility_reject.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/en-US/feasibility_reject.md rename to a2a-t-corpus/src/test/resources/golden/en-US/feasibility_reject.md diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/information_accept.md b/a2a-t-corpus/src/test/resources/golden/en-US/information_accept.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/en-US/information_accept.md rename to a2a-t-corpus/src/test/resources/golden/en-US/information_accept.md diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md b/a2a-t-corpus/src/test/resources/golden/en-US/information_propose.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/en-US/information_propose.md rename to a2a-t-corpus/src/test/resources/golden/en-US/information_propose.md diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/information_reject.md b/a2a-t-corpus/src/test/resources/golden/en-US/information_reject.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/en-US/information_reject.md rename to a2a-t-corpus/src/test/resources/golden/en-US/information_reject.md diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/target_accept.md b/a2a-t-corpus/src/test/resources/golden/en-US/target_accept.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/en-US/target_accept.md rename to a2a-t-corpus/src/test/resources/golden/en-US/target_accept.md diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md b/a2a-t-corpus/src/test/resources/golden/en-US/target_propose.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/en-US/target_propose.md rename to a2a-t-corpus/src/test/resources/golden/en-US/target_propose.md diff --git a/a2a-t-negotiation/src/test/resources/golden/en-US/target_reject.md b/a2a-t-corpus/src/test/resources/golden/en-US/target_reject.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/en-US/target_reject.md rename to a2a-t-corpus/src/test/resources/golden/en-US/target_reject.md diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/abort.md b/a2a-t-corpus/src/test/resources/golden/zh-CN/abort.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/zh-CN/abort.md rename to a2a-t-corpus/src/test/resources/golden/zh-CN/abort.md diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_accept.md b/a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_accept.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_accept.md rename to a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_accept.md diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md b/a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_propose.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_propose.md rename to a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_propose.md diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_reject.md b/a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_reject.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/zh-CN/feasibility_reject.md rename to a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_reject.md diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_accept.md b/a2a-t-corpus/src/test/resources/golden/zh-CN/information_accept.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/zh-CN/information_accept.md rename to a2a-t-corpus/src/test/resources/golden/zh-CN/information_accept.md diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_propose.md b/a2a-t-corpus/src/test/resources/golden/zh-CN/information_propose.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/zh-CN/information_propose.md rename to a2a-t-corpus/src/test/resources/golden/zh-CN/information_propose.md diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/information_reject.md b/a2a-t-corpus/src/test/resources/golden/zh-CN/information_reject.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/zh-CN/information_reject.md rename to a2a-t-corpus/src/test/resources/golden/zh-CN/information_reject.md diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_accept.md b/a2a-t-corpus/src/test/resources/golden/zh-CN/target_accept.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/zh-CN/target_accept.md rename to a2a-t-corpus/src/test/resources/golden/zh-CN/target_accept.md diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md b/a2a-t-corpus/src/test/resources/golden/zh-CN/target_propose.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/zh-CN/target_propose.md rename to a2a-t-corpus/src/test/resources/golden/zh-CN/target_propose.md diff --git a/a2a-t-negotiation/src/test/resources/golden/zh-CN/target_reject.md b/a2a-t-corpus/src/test/resources/golden/zh-CN/target_reject.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/golden/zh-CN/target_reject.md rename to a2a-t-corpus/src/test/resources/golden/zh-CN/target_reject.md diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/INDEX.md b/a2a-t-corpus/src/test/resources/negotiation-cases/INDEX.md similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/INDEX.md rename to a2a-t-corpus/src/test/resources/negotiation-cases/INDEX.md diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/corpus-schema.json b/a2a-t-corpus/src/test/resources/negotiation-cases/corpus-schema.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/corpus-schema.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/corpus-schema.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/happy.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-data/happy.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/happy.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/from-data/happy.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/programming-errors.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-data/programming-errors.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/from-data/programming-errors.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/from-data/programming-errors.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/extraction-failures.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/extraction-failures.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/extraction-failures.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/from-text/extraction-failures.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/happy.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/happy.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/happy.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/from-text/happy.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/programming-errors.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/programming-errors.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/programming-errors.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/from-text/programming-errors.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/retry.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/retry.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/retry.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/from-text/retry.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/template-resolution.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/template-resolution.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/from-text/template-resolution.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/from-text/template-resolution.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/boundary-flows.json b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/boundary-flows.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/boundary-flows.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/boundary-flows.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/information-flows.json b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/information-flows.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/information-flows.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/information-flows.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/target-flows.json b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/target-flows.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/scenarios/target-flows.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/target-flows.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/llm-responses.json b/a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/shared/llm-responses.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/shared/schemas.json b/a2a-t-corpus/src/test/resources/negotiation-cases/shared/schemas.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/shared/schemas.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/shared/schemas.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/drift-probes.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/drift-probes.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/validate/drift-probes.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/validate/drift-probes.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/error-code-mapping.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/error-code-mapping.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/validate/error-code-mapping.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/validate/error-code-mapping.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/happy.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/happy.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/validate/happy.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/validate/happy.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/merge-and-schema.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/merge-and-schema.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/validate/merge-and-schema.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/validate/merge-and-schema.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/programming-errors.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/validate/programming-errors.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/rule-gate.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/rule-gate.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/validate/rule-gate.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/validate/rule-gate.json diff --git a/a2a-t-negotiation/src/test/resources/negotiation-cases/validate/semantic-verdicts.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/semantic-verdicts.json similarity index 100% rename from a2a-t-negotiation/src/test/resources/negotiation-cases/validate/semantic-verdicts.json rename to a2a-t-corpus/src/test/resources/negotiation-cases/validate/semantic-verdicts.json diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index 6f88f445..c1fa5d5a 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -22,6 +22,7 @@ import net.openan.a2at.sdk.server.compliance.DefaultServerPromptComplianceOrchestrator; import net.openan.a2at.sdk.server.metadata.LlmBackedPromptMetadataExtractor; import net.openan.a2at.sdk.server.validation.LlmBackedPromptSemanticValidator; +import org.jspecify.annotations.Nullable; /** * Default builder that assembles one high-level A2AT server runtime from unified config. @@ -75,6 +76,22 @@ public DefaultA2ATServerBuilder envPath(Path envPath) { return this; } + /** + * Injects an explicit LLM client that fully replaces the factory default. + * + *

        The injection point exists for testability and custom LLM assemblies: when set, every orchestrator and + * validator built by this builder reuses the given client and no client is created from the `.env` LLM config. + * When unset, the builder keeps creating its default client and the behavior is unchanged. + * + * @param llmClient LLM client to inject; {@code null} keeps the factory default built from the `.env` LLM config + * @return current builder + * @since 2026-08 + */ + public DefaultA2ATServerBuilder llmClient(@Nullable LLMClient llmClient) { + this.llmClient = llmClient; + return this; + } + /** * Builds the default prompt-compliance orchestrator from the configured unified SDK config. * From 7c10af35029c56f1832de286140c6d3d01e6de32 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 24 Aug 2026 17:01:16 +0800 Subject: [PATCH 166/295] test(corpus): extract corpus harness into dedicated a2a-t-corpus module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Q24 ruling: the corpus mechanism tests SDK-level conformance behavior (cross-facade closed loops), not negotiation-module unit behavior, and the upcoming Task-T / Notification-T / Authorization-T corpora need a home that can reach every facade — only possible from the end of the dependency chain. - New pure-test module a2a-t-corpus (client+server+negotiation deps, jqwik moved here from negotiation), registered between a2a-t-server and a2a-t-sample - Moved (git mv, 82 files, history preserved): testdata harness -> net.openan.a2at.sdk.corpus, property layer -> .corpus.property, golden fixtures + GoldenInputs -> .corpus.golden, negotiation-cases/ and golden/ resources - TaskApiAssembler rewritten from mirror assembly to real builder delegation (DefaultA2ATClientBuilder.buildPromptGenerationOrchestrator / DefaultA2ATServerBuilder.buildTaskContentValidator, scripted LLM injected via the new llmClient(...) point, minimal cached .env per language x maxAttempts following the A2ATClientTest precedent); ~70 lines of mirrored pipeline code removed - Negotiation module keeps its unit suites; the 6 remaining tests that referenced GoldenInputs are decoupled via a local NegotiationGoldenCases helper (golden texts embedded byte-exact) Verification: full reactor mvn test green — corpus 414 (2 intentional skips), negotiation 398, client 87, server 25, sample 89. Signed-off-by: codetalks-hw --- a2a-t-corpus/pom.xml | 58 +++ .../openan/a2at/sdk/corpus/CaseEngine.java | 9 +- .../a2at/sdk/corpus/CaseEngineTest.java | 4 +- .../openan/a2at/sdk/corpus/ContextSpec.java | 2 +- .../net/openan/a2at/sdk/corpus/Contract.java | 2 +- .../a2at/sdk/corpus/CorpusContractTest.java | 2 +- .../a2at/sdk/corpus/CorpusLoadException.java | 2 +- .../sdk/corpus/CorpusSensitivitySelfTest.java | 2 +- .../openan/a2at/sdk/corpus/CorpusSuites.java | 2 +- .../openan/a2at/sdk/corpus/Expectation.java | 2 +- .../sdk/corpus/FromDataCorpusSuiteTest.java | 2 +- .../sdk/corpus/FromTextCorpusSuiteTest.java | 2 +- .../openan/a2at/sdk/corpus/LlmFailMarker.java | 2 +- .../net/openan/a2at/sdk/corpus/LlmScript.java | 2 +- .../openan/a2at/sdk/corpus/LlmScriptStep.java | 2 +- .../openan/a2at/sdk/corpus/LoadedCorpus.java | 2 +- .../a2at/sdk/corpus/NegotiationApi.java | 4 +- .../a2at/sdk/corpus/NegotiationCase.java | 2 +- .../sdk/corpus/NegotiationCaseLoader.java | 2 +- .../sdk/corpus/NegotiationCaseLoaderTest.java | 2 +- .../openan/a2at/sdk/corpus/PromptSource.java | 2 +- .../openan/a2at/sdk/corpus/ScenarioCase.java | 2 +- .../sdk/corpus/ScenarioCorpusSuiteTest.java | 2 +- .../a2at/sdk/corpus/ScenarioEngine.java | 2 +- .../a2at/sdk/corpus/ScenarioEngineTest.java | 4 +- .../corpus/ScriptedNegotiationLlmClient.java | 2 +- .../ScriptedNegotiationLlmClientTest.java | 2 +- .../a2at/sdk/corpus/TaskApiAssembler.java | 231 +++------ .../a2at/sdk/corpus/TypedInputAssembler.java | 2 +- .../sdk/corpus/ValidateCorpusSuiteTest.java | 2 +- .../golden/GoldenFixtureComparisonTest.java | 364 ++++++------- .../golden/GoldenFixtureGeneratorTest.java | 2 +- .../a2at/sdk/corpus/golden/GoldenInputs.java | 2 +- .../ErrorCodePartitionPropertyTest.java | 6 +- .../FromDataDeterminismPropertyTest.java | 4 +- .../FromDataRoundTripPropertyTest.java | 4 +- .../LargeParamsMergePropertyTest.java | 4 +- .../NegotiationErrorPathPropertyTest.java | 4 +- .../corpus/property/PropertyArbitraries.java | 2 +- .../sdk/corpus/property/PropertyHarness.java | 8 +- .../test/resources/negotiation-cases/INDEX.md | 2 +- a2a-t-negotiation/pom.xml | 6 - .../generation/FromTextLlmPipelineTest.java | 33 +- .../generation/NegotiationGoldenCases.java | 483 ++++++++++++++++++ .../NegotiationResourceIntegrityTest.java | 31 +- .../TemplateUriEntryMatrixTest.java | 9 +- .../CustomRootTemplateOverrideTest.java | 36 +- .../CustomRootVocabularyOverrideTest.java | 22 +- .../NegotiationLanguageSwitchTest.java | 43 +- pom.xml | 1 + 50 files changed, 922 insertions(+), 502 deletions(-) create mode 100644 a2a-t-corpus/pom.xml create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGoldenCases.java diff --git a/a2a-t-corpus/pom.xml b/a2a-t-corpus/pom.xml new file mode 100644 index 00000000..01a043c0 --- /dev/null +++ b/a2a-t-corpus/pom.xml @@ -0,0 +1,58 @@ + + 4.0.0 + + + net.openan.a2a-t.sdk + a2a-t-parent + ${revision} + + + a2a-t-corpus + jar + + a2a-t Corpus + SDK-level consistency corpus suite: negotiation test cases, golden fixtures and property tests. + + + + net.openan.a2a-t.sdk + a2a-t-negotiation + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-client + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-server + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-resources + ${project.version} + test + + + org.junit.jupiter + junit-jupiter + test + + + org.junit.jupiter + junit-jupiter-params + ${junit.jupiter.version} + test + + + net.jqwik + jqwik + ${jqwik.version} + test + + + diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java index a3c7225a..82f9ffcd 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.io.IOException; import java.io.InputStream; @@ -44,7 +44,8 @@ * the generation template-not-found matrix, {@code failingSemanticValidator} for the validate-family * prompt-resource-not-found mapping) and dispatches on the {@link NegotiationApi} enum at compile time, so a misspelled API * name fails at corpus load time and a renamed service method fails this compilation. The three task-family APIs run - * through the {@link TaskApiAssembler}, the mirrored production wiring of the closed loop (Q21). + * through the {@link TaskApiAssembler}, the real facade builders' assembly of the closed loop (Q21) with the scripted LLM + * client injected at the builders' LLM seam. * *

        Result normalization: a success run returns the produced {@link MetadataContent} or {@link FilledParamData}, a * failure run captures the thrown exception. The expectation comparison covers outcome, exception name, error code, @@ -590,8 +591,8 @@ private static NegotiationGenerationOrchestrator orchestratorFor( } /** - * Assembles the task API wiring of the closed loop for one case: the retry limit comes from the case's LLM script - * or falls back to the builder default of 3, mirroring the facade builders. + * Assembles the task API wiring of the closed loop for one case through the real facade builders: the retry limit + * comes from the case's LLM script or falls back to the builder default of 3. * * @param testCase expanded corpus case of the task family * @param llmClient scripted LLM client injected at the facade builders' LLM seam diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java index d01720c0..93452a44 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -13,7 +13,7 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.NegotiationContext; import org.junit.jupiter.api.BeforeEach; -import net.openan.a2at.sdk.negotiation.testdata.Expectation.Metadata; +import net.openan.a2at.sdk.corpus.Expectation.Metadata; import org.junit.jupiter.api.Test; import org.jspecify.annotations.Nullable; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ContextSpec.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ContextSpec.java index f4bb78e1..a8b77c54 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ContextSpec.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ContextSpec.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; /** * Negotiation context of one corpus record, inline in the corpus JSON. diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Contract.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Contract.java index 13b57029..94b803bd 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Contract.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Contract.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import org.jspecify.annotations.Nullable; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java index 2f97768e..94a4254e 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusLoadException.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusLoadException.java index 531a9c1e..eff72e8e 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusLoadException.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusLoadException.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; /** * Signals a defect in the negotiation test corpus: an unknown key, a dangling or circular {@code $ref}, a duplicate diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java index 133ab063..874cd1d7 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSuites.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSuites.java index 38443155..905e07c6 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSuites.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSuites.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.util.ArrayList; import java.util.List; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java index df3bcf9a..c8365380 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.util.List; import java.util.Map; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromDataCorpusSuiteTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromDataCorpusSuiteTest.java index 89e81d1c..49489332 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromDataCorpusSuiteTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromDataCorpusSuiteTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.util.stream.Stream; import org.junit.jupiter.api.DynamicTest; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromTextCorpusSuiteTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromTextCorpusSuiteTest.java index 895c8270..3a8e9845 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromTextCorpusSuiteTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromTextCorpusSuiteTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.util.stream.Stream; import org.junit.jupiter.api.DynamicTest; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmFailMarker.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmFailMarker.java index 89c4a6fc..26afb06f 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmFailMarker.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmFailMarker.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import org.jspecify.annotations.Nullable; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScript.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScript.java index d61b9f20..60c8f34c 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScript.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScript.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.util.List; import org.jspecify.annotations.Nullable; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScriptStep.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScriptStep.java index 8b3a430e..f98a3d41 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScriptStep.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScriptStep.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; /** * One scripted answer of an LLM script. diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LoadedCorpus.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LoadedCorpus.java index 7b8830cc..f42c1b1d 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LoadedCorpus.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LoadedCorpus.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.nio.file.Path; import java.util.List; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationApi.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationApi.java index 0e4933a0..85b823f6 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationApi.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationApi.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import org.jspecify.annotations.Nullable; @@ -52,7 +52,7 @@ public enum NegotiationApi { /** * {@code generateTaskPromptFromText}: workbench-side task prompt generation from free text, one LLM slot-extraction - * call (mirrors the {@code A2ATClient} facade through the prompt-module production services). + * call (runs through the real {@code A2ATClient} facade builder assembly with the scripted LLM injected). */ GENERATE_TASK_PROMPT_FROM_TEXT("generateTaskPromptFromText", Family.TASK), diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCase.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCase.java index 9e587d1b..d21f9df9 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCase.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCase.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.util.List; import java.util.Objects; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoader.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoader.java index 463f4093..02502d6f 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoader.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoader.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.io.IOException; import java.net.URISyntaxException; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java index 09c95cc3..e52ecba7 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/PromptSource.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/PromptSource.java index e5991eeb..abaf3f0c 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/PromptSource.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/PromptSource.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; /** * The prompt input of one validate-family corpus record. diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCase.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCase.java index 7e95cde5..1b15e782 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCase.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCase.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.util.LinkedHashMap; import java.util.List; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCorpusSuiteTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCorpusSuiteTest.java index 28c64994..024dd746 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCorpusSuiteTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCorpusSuiteTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.util.stream.Stream; import org.junit.jupiter.api.DynamicTest; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngine.java index 9926ca5e..b956c653 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngine.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngine.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.util.ArrayList; import java.util.LinkedHashMap; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java index b02bfe88..a84707fd 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -8,7 +8,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; -import net.openan.a2at.sdk.negotiation.testdata.ScenarioCase.ExpectFlow; +import net.openan.a2at.sdk.corpus.ScenarioCase.ExpectFlow; import org.junit.jupiter.api.Test; import org.jspecify.annotations.Nullable; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClient.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClient.java index 6dcd2753..ab1d6f27 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClient.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClient.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.util.ArrayList; import java.util.List; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClientTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClientTest.java index 560d8884..a4150e24 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClientTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClientTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java index bb2cc9cf..97052d02 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java @@ -1,69 +1,52 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; -import java.util.ArrayList; -import java.util.List; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Map; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidator; import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; -import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; -import net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException; -import net.openan.a2at.sdk.prompt.validation.DefaultContentValidator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; /** - * Test-domain assembly of the three closed-loop task APIs (Q21): everything except the LLM client is production - * wiring, mirroring the two facade builders without touching production code. + * Real-builder assembly of the three closed-loop task APIs (Q21): the corpus harness drives exactly the production + * wiring the {@code A2ATClient} and {@code A2ATServer} facades drive internally, so a change of the production + * assembly cannot drift past the corpus. * *

          - *
        • {@code validateTaskPromptAndDataFilling} mirrors {@code DefaultA2ATServerBuilder.buildTaskContentValidator} - * exactly: the same {@link DefaultContentValidator} constructor the server builder calls, with the Task-T - * extension name, the language, the LLM retry limit, the LLM client and the classpath - * {@link PromptResourceAccess};
        • - *
        • {@code generateTaskPromptFromText} and {@code generateTaskPromptFromDataWithSchema} mirror the internals of - * {@code DefaultClientPromptGenerationOrchestrator}: the same production services the client builder wires - * ({@link PromptTemplateTextLoader}, {@link DefaultStructuredPromptSlotValueExtractor} — the exact class the - * client's {@code DefaultStructuredClientSlotValueExtractor} delegates to —, {@link PromptSlotSchemaLoader} and - * {@link TaskPromptRenderer}), with the same error-code mapping and required-slot validation the client - * orchestrator applies.
        • + *
        • {@code generateTaskPromptFromText} and {@code generateTaskPromptFromDataWithSchema} run through the + * {@link ClientPromptGenerationOrchestrator} built by {@link DefaultA2ATClientBuilder#buildPromptGenerationOrchestrator()} + * — the same build method the client facade constructor calls;
        • + *
        • {@code validateTaskPromptAndDataFilling} runs through the {@link ContentValidator} built by + * {@link DefaultA2ATServerBuilder#buildTaskContentValidator()} — the same build method the server facade + * constructor calls.
        • *
        * - *

        The negotiation module cannot see the client and server modules (they depend on it, not the other way round), so - * the orchestrator logic is mirrored here line by line; a renamed facade method fails the {@link NegotiationApi} - * dispatch compilation, and a behavior change of the mirrored pipeline shows up as a corpus red. + *

        Both builders are driven exactly like the facades drive them: a minimal classpath-source {@code .env} (written once + * per language and retry limit into a temporary directory, following the facade test precedent) is loaded through + * {@link A2ATConfig#load(Path)} plus {@link NegotiationContentService#resolvePromptResourceLocalRootDir(A2ATConfig, Path)}, + * and the {@link ScriptedNegotiationLlmClient} is injected through the builders' {@code llmClient(...)} seam — the same + * instance feeds the client-side and the server-side components, so the {@code llmCalls} expectation counts the whole + * closed loop. The facade constructors are locked to the {@code Path}-only signature by the API-surface guard tests, so + * the builders are the direct route to the identical assembly. * * @since 2026-08 */ final class TaskApiAssembler { - private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; + private static final Map ENV_FILES = new ConcurrentHashMap<>(); - private final String language; - - private final PromptTemplateTextLoader templateLoader; - - private final PromptSlotSchemaLoader slotSchemaLoader; - - private final DefaultStructuredPromptSlotValueExtractor slotValueExtractor; - - private final TaskPromptRenderer renderer; + private final ClientPromptGenerationOrchestrator promptGeneration; private final ContentValidator taskValidator; @@ -75,41 +58,36 @@ final class TaskApiAssembler { * @param llmClient scripted LLM client injected at the same seam the facade builders inject their real client */ TaskApiAssembler(String language, int maxAttempts, LLMClient llmClient) { - this.language = language; - PromptResourceAccess resources = PromptResourceAccess.create( - new PromptRuntimeConfig(language, PromptResourceAccess.CLASSPATH_SOURCE_TYPE, null)); - this.templateLoader = resources.templateLoader(); - this.slotSchemaLoader = resources.slotSchemaLoader(); - this.slotValueExtractor = new DefaultStructuredPromptSlotValueExtractor( - llmClient, - slotSchemaLoader, - resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"), - resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md")); - this.renderer = new TaskPromptRenderer(); - this.taskValidator = new DefaultContentValidator( - StandardTemplates.TASK_EXTENSION_NAME, language, maxAttempts, llmClient, resources); + Path envPath = minimalEnvFor(language, maxAttempts); + A2ATConfig config = + NegotiationContentService.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); + this.promptGeneration = DefaultA2ATClientBuilder.builder() + .config(config) + .envPath(envPath) + .llmClient(llmClient) + .buildPromptGenerationOrchestrator(); + this.taskValidator = DefaultA2ATServerBuilder.builder() + .config(config) + .envPath(envPath) + .llmClient(llmClient) + .buildTaskContentValidator(); } /** - * Generates a task prompt with metadata from natural-language input, mirroring - * {@code A2ATClient.generateTaskPromptFromText}. + * Generates a task prompt with metadata from natural-language input through the real client orchestrator, the + * pipeline behind {@code A2ATClient.generateTaskPromptFromText}. * * @param text natural-language task input * @param templateUri template URI identifying the target task template * @return metadata content carrying the resolved template URI, rendered prompt text and Task-T extension URI */ MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) { - String templateIdentifier = templateUri.uri(); - String templateText = loadTemplate(templateIdentifier); - Map slots = extractSlots(text, templateIdentifier, templateText); - validateRequiredSlots(slots, templateIdentifier); - return new MetadataContent( - templateIdentifier, render(templateText, slots), ExtensionUriConstants.TASK_T_EXTENSION_URI); + return promptGeneration.generateTaskPromptFromText(text, templateUri); } /** - * Generates a task prompt with metadata from structured input and a data schema, mirroring - * {@code A2ATClient.generateTaskPromptFromDataWithSchema}. + * Generates a task prompt with metadata from structured input and a data schema through the real client + * orchestrator, the pipeline behind {@code A2ATClient.generateTaskPromptFromDataWithSchema}. * * @param data structured task input as a string-to-object map * @param schema data schema map describing the meaning of each input field @@ -118,23 +96,12 @@ MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) */ MetadataContent generateTaskPromptFromDataWithSchema( Map data, Map schema, TemplateUri templateUri) { - if (schema == null || schema.isEmpty()) { - throw new IllegalArgumentException( - "Data schema must not be empty; it describes the meaning of each input field."); - } - String templateIdentifier = templateUri.uri(); - String templateText = loadTemplate(templateIdentifier); - StructuredSlotExtractionResult result = - slotValueExtractor.extractSlots(data, templateIdentifier, language, schema); - Map slots = result.slots(); - validateRequiredSlots(slots, templateIdentifier); - return new MetadataContent( - templateIdentifier, render(templateText, slots), ExtensionUriConstants.TASK_T_EXTENSION_URI); + return promptGeneration.generateTaskPromptFromDataWithSchema(data, schema, templateUri); } /** - * Validates a task prompt and extracts its filled parameters, mirroring - * {@code A2ATServer.validateTaskPromptAndDataFilling}. + * Validates a task prompt and extracts its filled parameters through the real server-side task content validator, + * the pipeline behind {@code A2ATServer.validateTaskPromptAndDataFilling}. * *

        A schema slot the prompt misses surfaces as a null-valued entry of the returned parameter data — that set of * null-valued keys is the missing-parameter set the negotiation loop then fills. @@ -148,71 +115,39 @@ FilledParamData validateTaskPromptAndDataFilling(String prompt, Map extractSlots(String userInput, String templateIdentifier, String templateText) { - try { - return slotValueExtractor.extractSlots(userInput, templateIdentifier, language).slots(); - } catch (ResourceNotFoundException error) { - throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, error.getMessage(), error); - } catch (A2ATError error) { - throw new PromptGenerationException(A2ATErrorCodes.LLM_INVOCATION_FAILED, error.getMessage(), error); - } - } + // ------------------------------------------------------------------ minimal facade env - private String render(String templateText, Map slots) { - try { - return renderer.render(templateText, slots); - } catch (TaskPromptRenderException error) { - throw new PromptGenerationException(A2ATErrorCodes.RENDER_FAILED, error.getMessage(), error); - } - } - - private void validateRequiredSlots(Map slots, String templateIdentifier) { - final PromptSlotSchema schema; - try { - schema = slotSchemaLoader.loadSlotSchema(templateIdentifier, language); - } catch (A2ATError error) { - throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, error.getMessage(), error); - } - if (schema == null) { - throw new PromptGenerationException( - A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, "Slot schema not found for template: " + templateIdentifier); - } - List definitions = schema.slotDefinitions(); - if (definitions == null) { - return; - } - List failed = new ArrayList<>(); - for (PromptSlotDefinition definition : definitions) { - if (definition == null || !definition.required()) { - continue; - } - String name = definition.name(); - if (name == null) { - continue; - } - String value = slots.get(name); - if (value == null || value.trim().isEmpty()) { - failed.add(new SlotValidationError(name, "missing_required", "Required slot is missing or empty")); + /** + * Writes (once per language and retry limit) the minimal classpath-source {@code .env} the facades would be handed, + * following the {@code A2ATClientTest} minimal-env precedent; the LLM entries are inert because the scripted client + * is injected at the builders' LLM seam. + * + * @param language language of the corpus case + * @param maxAttempts retry limit of the LLM steps from the case's LLM script + * @return path of the written {@code .env} file + */ + private static Path minimalEnvFor(String language, int maxAttempts) { + return ENV_FILES.computeIfAbsent(language + "/" + maxAttempts, key -> { + try { + Path envFile = Files.createTempDirectory("a2at-corpus-task-env").resolve("client.env"); + Files.writeString( + envFile, + ("A2AT_LANGUAGE=%s%n" + + "A2AT_PROMPT_SOURCE_TYPE=classpath%n" + + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=%n" + + "A2AT_LLM_PROVIDER=openai%n" + + "A2AT_LLM_MODEL=scripted-model%n" + + "A2AT_LLM_BASE_URL=https://llm.example.test/v1%n" + + "A2AT_LLM_API_KEY=corpus-scripted%n" + + "A2AT_LLM_MAX_ATTEMPTS=%d%n" + + "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory%n") + .formatted(language, maxAttempts), + StandardCharsets.UTF_8); + return envFile; + } catch (IOException exception) { + throw new UncheckedIOException( + "Failed to write the minimal corpus .env for language " + language, exception); } - } - if (!failed.isEmpty()) { - throw new PromptGenerationException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - "Required slots are missing or empty: " + failed.stream() - .map(SlotValidationError::slotName).collect(Collectors.joining(", ")), - failed); - } + }); } } diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java index eac751ee..a87d0f72 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.util.ArrayList; import java.util.List; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ValidateCorpusSuiteTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ValidateCorpusSuiteTest.java index 357107e9..e7a9cc19 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ValidateCorpusSuiteTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ValidateCorpusSuiteTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.negotiation.testdata; +package net.openan.a2at.sdk.corpus; import java.util.stream.Stream; import org.junit.jupiter.api.DynamicTest; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenFixtureComparisonTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenFixtureComparisonTest.java index d1b3d67f..4024412e 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenFixtureComparisonTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenFixtureComparisonTest.java @@ -1,182 +1,182 @@ -package net.openan.a2at.sdk.negotiation.golden; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.golden.GoldenInputs.GoldenCase; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; - -/** - * Locks the golden fixture set of the negotiation content layer against the real built-in resources. - * - *

        Every golden fixture is rendered deterministically from the fixed {@link GoldenInputs} through an orchestrator - * wired with the built-in templates and vocabulary — no LLM client is involved. The rendered text must match the - * committed fixture file byte for byte, so any drift of the templates, the vocabulary or the rendering pipeline fails - * this test and requires a reviewed fixture revision. This test also pins the MetadataContent contract, the determinism - * of the from-data generation and the zero-LLM guarantee of the from-data variants. - */ -class GoldenFixtureComparisonTest { - - private static final String EXTENSION_URI = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1"; - - private static final String TEMPLATE_URI_METADATA_KEY = MetadataContent.TEMPLATE_URI_METADATA_KEY; - - private static final String NEGOTIATION_CONTEXT_METADATA_KEY = MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY; - - static Stream goldenCases() { - List cases = new ArrayList<>(); - for (String language : GoldenInputs.LANGUAGES) { - for (GoldenCase goldenCase : GoldenCase.values()) { - cases.add(Arguments.of(goldenCase, language)); - } - } - return cases.stream(); - } - - /** Renders one fixture and returns its metadata content, asserting the URI and extension contract up front. */ - private static MetadataContent render(GoldenCase goldenCase, String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - MetadataContent result = goldenCase.generate(orchestrator, language); - assertEquals(goldenCase.templateUri(), result.templateUri()); - assertEquals(EXTENSION_URI, result.extensionUri()); - return result; - } - - private static NegotiationGenerationOrchestrator orchestrator(String language) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .build(); - } - - private static String readGoldenFixture(GoldenCase goldenCase, String language) { - String resourcePath = goldenCase.goldenResourcePath(language); - InputStream stream = GoldenFixtureComparisonTest.class.getResourceAsStream(resourcePath); - assertNotNull(stream, "Golden fixture must exist on the test classpath: " + resourcePath); - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } catch (IOException exception) { - throw new AssertionError("Failed to read golden fixture " + resourcePath, exception); - } - } - - @ParameterizedTest(name = "{0} [{1}] matches golden fixture byte for byte") - @MethodSource("goldenCases") - void rendersByteIdenticalToTheGoldenFixture(GoldenCase goldenCase, String language) { - MetadataContent result = render(goldenCase, language); - - assertEquals(readGoldenFixture(goldenCase, language), result.promptText()); - } - - @ParameterizedTest(name = "{0} [{1}] metadata contract") - @MethodSource("goldenCases") - void buildsTheMetadataMapWithExactlyThreeEntries(GoldenCase goldenCase, String language) { - MetadataContent result = render(goldenCase, language); - - Map metadata = result.buildMetadataContent(); - assertEquals(3, metadata.size()); - assertEquals(result.promptText(), metadata.get(EXTENSION_URI)); - assertEquals(goldenCase.templateUri(), metadata.get(TEMPLATE_URI_METADATA_KEY)); - assertEquals(goldenCase.context(), result.negotiationContext()); - Iterator keyOrder = metadata.keySet().iterator(); - assertEquals(EXTENSION_URI, keyOrder.next()); - assertEquals(TEMPLATE_URI_METADATA_KEY, keyOrder.next()); - assertEquals(NEGOTIATION_CONTEXT_METADATA_KEY, keyOrder.next()); - @SuppressWarnings("unchecked") - Map nestedContext = (Map) metadata.get(NEGOTIATION_CONTEXT_METADATA_KEY); - assertEquals(goldenCase.context().id(), nestedContext.get("id")); - assertEquals(goldenCase.context().round(), nestedContext.get("round")); - assertEquals(goldenCase.context().maxRounds(), nestedContext.get("maxRounds")); - assertEquals(metadata, result.buildMetadataContent()); - } - - @ParameterizedTest(name = "{0} [{1}] deterministic for the same input") - @MethodSource("goldenCases") - void rendersTheSameInputDeterministically(GoldenCase goldenCase, String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - - MetadataContent first = goldenCase.generate(orchestrator, language); - MetadataContent second = goldenCase.generate(orchestrator, language); - - assertEquals(first.promptText(), second.promptText()); - assertEquals(first, second); - } - - @ParameterizedTest(name = "{0} [{1}] structural invariants") - @MethodSource("goldenCases") - void keepsTheStructuralInvariantsOfRenderedMessages(GoldenCase goldenCase, String language) { - MetadataContent result = render(goldenCase, language); - String promptText = result.promptText(); - - String contextTitle = "zh-CN".equals(language) ? "协商上下文" : "Negotiation Context"; - assertFalse(promptText.contains(contextTitle), "the context section must not be rendered into the message"); - assertFalse(promptText.contains("- id: " + GoldenInputs.SESSION_ID), "context lines must not be rendered"); - assertEquals(goldenCase.context(), result.negotiationContext(), "the context travels in the metadata"); - assertFalse(promptText.endsWith("\n"), "message must not end with a newline"); - assertFalse(promptText.contains("\n\n\n"), "sections must be joined by exactly one blank line"); - assertFalse(promptText.contains("{{"), "no unreplaced slot placeholder may remain"); - assertFalse(promptText.contains(" Server + Server -- HTTPS A2A-T responses --> Client -```bash -mvn spotless:check ``` -For auto-formatting: +### 1.1.3 Typical Interaction Scenarios of the A2A-T SDK -```bash -mvn spotless:apply -``` +A typical multi-agent collaboration interaction scenario involves at least three components: the client agent, the server agent, and the registry center. -## 1.4 Maven Dependencies +The relationships between the components are shown below: -Business projects can use BOM to manage versions: +```mermaid +flowchart TD + subgraph Client["Client Agent"] + A1["A2A-T Client SDK

        Prompt template generation: generateTaskPrompt"] + A2["A2A Client SDK

        Sends requests over HTTP/REST"] + A1 --> A2 + end -```xml - - - - net.openan.a2a-t.sdk - a2a-t-bom - 1.0.0 - pom - import - - - -``` + subgraph Server["Server Agent"] + B1["A2A-T Server SDK

        Compliance validation: checkTaskPrompt"] + B2["A2A Server SDK

        Receives requests"] + B2 --> B1 + end -Client Agent dependency: + Registry["Registry Center (registry-center)"] -```xml - - net.openan.a2a-t.sdk - a2a-t-client - + A2 -- HTTPS --> B2 + Client -. Register / Discover .-> Registry + Server -. Register / Discover .-> Registry ``` -Server Agent dependency: -```xml - - net.openan.a2a-t.sdk - a2a-t-server - -``` -If the business system needs to directly read SDK built-in resources, or wants to use `A2AT_PROMPT_SOURCE_TYPE=classpath` without relying on transitive dependencies, add the following dependency: +## 1.2 Constraints and Limitations -```xml - - net.openan.a2a-t.sdk - a2a-t-resources - -``` +1. JDK 17+ is required, and the build tool must be Maven 3.8+. +2. Full multi-agent protocol interaction development requires the A2A official Java SDK (a2a-java, `org.a2aproject.sdk`) as well, with a version no lower than `1.0.0.Beta1`. +3. Prompt resources support two sources: `classpath` (default; loads the resources bundled in the `a2a-t-resources` jar) and `local_file` (local file resources). +4. Negotiation state storage provides only `in_memory`; the state is lost when the process exits. +5. The A2A-T SDK does not provide an agent HTTP service framework, a registry center client, authentication, or key management capabilities; these must be integrated by the business system. -## 1.5 Configuration Loading +## 1.3 Environment Preparation -The Java SDK does not auto-discover `.env`; the caller must explicitly pass the path: +### 1.3.1 Environment Requirements -```java -import java.nio.file.Path; -import net.openan.a2at.sdk.client.A2ATClient; +| Item | Requirement | +| ---- | ----------- | +| Java | JDK 17+ | +| Build tool | Maven 3.8+ | +| LLM | An accessible OpenAI service and API key | +| Operating system | Linux, Windows, and macOS all work for development and joint debugging | -A2ATClient client = new A2ATClient(Path.of("client.env")); -``` +### 1.3.2 Setting Up the Environment -Basic configuration example: +Taking a Windows 11 64-bit amd64 development environment as an example -```properties -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=classpath -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY={your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -``` +**Install JDK 17** + +1. Official download link: https://www.oracle.com/java/technologies/downloads/#java17 (Adoptium Temurin and other distributions also work: https://adoptium.net/temurin/releases/?version=17) + +2. Download the Windows x64 Installer (`.msi` or `.exe`) and run it as administrator + +3. During installation, select the option — or configure manually afterwards — to set the environment variables: `JAVA_HOME` points to the JDK installation directory, and `%JAVA_HOME%\bin` is added to `PATH` + +4. After installation, open a terminal and run the verification command: + + ```shell + java -version + # Expected output: openjdk version "17.0.x" ... + ``` + +**Install Maven** + +1. Official download link: https://maven.apache.org/download.cgi. Download `apache-maven-3.9.x-bin.zip` and extract it to a local directory + +2. Configure the environment variables: `MAVEN_HOME` points to the extracted directory, and `%MAVEN_HOME%\bin` is added to `PATH` + +3. After installation, run the verification command: + + ```shell + mvn -version + # Expected output: Apache Maven 3.9.x + ``` + +## 1.4 Basic Development Sample -For reasoning models, the optional `A2AT_LLM_REASONING_EFFORT` key tunes the reasoning effort passed to the -provider: one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh` (case-insensitive). Leave the key unset for -non-reasoning models — the parameter is then not sent at all. An unsupported value fails at configuration load -time with an `LLMConfigError` instead of surfacing as a provider error on the first LLM call. +Purpose: through sample code for the A2A-T client and server, this section helps users get familiar with the SDK integration process quickly and to standard, accelerating feature development and production rollout. +### 1.4.1 Sample API Description -## 1.6 SDK Basic Usage +This basic development sample mainly uses the following two A2A-T SDK APIs. In actual development, choose the appropriate APIs according to your business requirements: -### 1.6.1 Client Generates Task Prompt +**1. A2A-T Client SDK** + +API definition and function description: recognizes the scenario from the input content and generates the corresponding prompt template + +```java +public PromptGenerationResult generateTaskPrompt(Object userInput) +``` + +`PromptGenerationResult` is a record type with three accessors — `success()`, `promptText()`, and `failure()`; `failure` carries `code`, `message`, and `stage`. + +Sample invocation: ```java import java.nio.file.Path; @@ -132,17 +160,42 @@ import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.client.model.PromptGenerationResult; A2ATClient client = new A2ATClient(Path.of("client.env")); -PromptGenerationResult result = client.generateTaskPrompt( - "Notification topic is Incident, subscription condition: ETH-LOS fault with subscription level critical, notification data format for reporting: DataPart"); +PromptGenerationResult result = client.generateTaskPrompt("Generate an Incident event subscription task: the notification topic is Incident, the subscription levels are critical, medium, high, and low, and the notification data format is DataPart"); if (result.success()) { System.out.println(result.promptText()); -} else { - System.out.println(result.failure().message()); } + +/* Output of the generated prompt template +## Subscription Description +Based on the following , , , and information, complete the network-side intelligent fault Incident subscription and reporting task. + +## Notification Topic +The name of this topic is "incident" + +## Subscribe Condition +Fault levels are "critical", "medium", "high", "low" + +## Notification Data Format +Report Incident data via DataPart + +## Expected Output +1. Subscription result, success or failure +2. Reason for subscription failure (optional) +*/ +``` + +**2. A2A-T Server SDK** + +API definition and function description: + +```java +public PromptComplianceResult checkTaskPrompt(String processedPromptText) ``` -### 1.6.2 Server Validates Task Prompt +`PromptComplianceResult` is a record type with two accessors — `success()` and `failure()` (on validation failure, `failure` carries `code`, `message`, and `stage`). + +Sample invocation: validates the completeness of an A2A-T protocol message ```java import java.nio.file.Path; @@ -150,7 +203,7 @@ import net.openan.a2at.sdk.server.A2ATServer; import net.openan.a2at.sdk.server.model.PromptComplianceResult; A2ATServer server = new A2ATServer(Path.of("server.env")); -PromptComplianceResult result = server.checkTaskPrompt(processedPromptText); +PromptComplianceResult result = server.checkTaskPrompt("## Subscription DescriptionBased on the following , , , and information, complete the network-side intelligent fault Incident subscription and reporting task.## Notification TopicThe name of this topic is \"incident\"## Subscribe Condition \n\n Fault levels are \"critical\", \"medium\", \"high\", \"low\" \n\n ## Notification Data Format \n\nReport Incident data via DataPart## Expected Output1. Subscription result, success or failure2. Reason for subscription failure (optional)"); if (result.success()) { System.out.println("prompt check passed"); @@ -159,488 +212,1216 @@ if (result.success()) { } ``` -### 1.6.3 Negotiation Interface +### 1.4.2 Development Workflow -```java -import java.nio.file.Path; -import java.util.Map; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +- Client development workflow -A2ATClient client = new A2ATClient(Path.of("client.env")); -Map payload = client.startNegotiation( - NegotiationType.INFORMATION, - "Please provide incident level.", - Map.of("missingFields", java.util.List.of("subscription_condition_incident_level"))); -``` +```mermaid +flowchart TD + Step1["Step1 Install dependencies"] --> + Step2["Step2 Configure the LLM"] -The business system needs to pass the text and context in the negotiation payload to the peer via A2A messages, and subsequently advance the state through `receiveNegotiation` and `continueNegotiation`. + Step2 -- Optional for secondary development --> Step3 + Step3["Step3 Initialize the AgentCard"] --> + Step4["Step4 Register and discover the AgentCard"] -## 1.7 Full Integration Development Flow + Step2 --> Step5 -### 1.7.1 Client Agent + Step5["Step5 Initialize the A2A-T client"] --> + Step6["Step6 Generate the A2A-T template message"] --> + Step7["Step7 Activate the A2A-T extension in request headers"] --> + Step8["Step8 Send the request with the A2A-T extension"] +``` -Recommended Client Agent flow: +> Secondary development means an agent has already been developed based on A2A and integrated with a registry center. -1. Read `.env` and create `A2ATClient`. -2. Call `generateTaskPrompt` based on user input. -3. Place the prompt into the A2A message body or extension fields. -4. Query the target AgentCard directly from the server root path `GET /`, or through the registry center when registry lookup is required. -5. Send an A2A request based on the interface address in the AgentCard. -6. If negotiation context is received, continue the interaction through `receiveNegotiation` and `continueNegotiation`. +- Server development workflow: -`ClientSampleFlow` in the `subscribe_incident` case of `a2a-t-sample` demonstrates the full flow: reading scenarios, querying AgentCard directly from the sample server or from registry-center, generating prompt, constructing A2A requests, and handling streaming events. +```mermaid +flowchart TD + Step1["Step1 Install dependencies"] --> + Step2["Step2 Configure the LLM"] -### 1.7.2 Server Agent + Step2 -- Optional for secondary development --> Step3 -Recommended Server Agent flow: + Step3["Step3 Initialize the AgentCard"] --> + Step4["Step4 Register and discover the AgentCard"] -1. Create a business HTTP service or integrate with an existing A2A Java service framework. -2. Construct AgentCard at startup, expose it from the service root path `GET /`, and register it with the registry center when registry lookup is used. -3. After receiving an A2A request, extract the processed task prompt. -4. Call `A2ATServer.checkTaskPrompt`. -5. After validation passes, execute business logic. -6. When validation fails or information is insufficient, return supplementary information requirements through the negotiation interface. + Step2 --> Step5 -`ServerSampleMain` and `ServerSampleFlow` in the `subscribe_incident` case of `a2a-t-sample` demonstrate AgentCard exposure at the HTTP root path, optional registry-center registration, HTTP service startup, prompt extraction, validation, and task event pushing. + Step5["Step5 Initialize the A2A-T server"] --> + Step6["Step6 Receive and validate the message on the server"] --> + Step7["Step7 Execute internal business logic on the server"] --> + Step8["Step8 Fill in the A2A response headers and return the response"] +``` -## 1.8 Prompt Resource Extension -### 1.8.1 Resource Source -Prompt resources can be loaded from two sources: -| Source Type | Description | -|--|--| -| `classpath` | Loads built-in prompt resources from the runtime classpath, typically from the `a2a-t-resources` jar downloaded by Maven. This is the default and is used by `a2a-t-sample` when `a2a-t-resources` is present in the sample `pom.xml` and runtime classpath. | -| `local_file` | Loads prompt resources from a local directory specified by `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR`. Use this when developing or overriding scenarios, slots, templates, or LLM prompt files. | -To use the built-in resources from the jar, keep the source type as `classpath`: +### 1.4.3 Sample Client Development Steps -```properties -A2AT_PROMPT_SOURCE_TYPE=classpath -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LANGUAGE=zh-CN -``` +#### Step1 Install Dependencies -### 1.8.2 Local File Extension -When customizing scenarios, prepare local resources with the following structure: - -```text -prompt_resources/ - scenarios/zh-CN/scenarios.json - slots/{extension_name}/{path_segments}/{version}/zh-CN/slot.json - templates/{extension_name}/{path_segments}/{version}/zh-CN/template.md - prompts/scenario_recognition/zh-CN/system.md - prompts/scenario_recognition/zh-CN/user.md - prompts/slot_extraction/zh-CN/system.md - prompts/slot_extraction/zh-CN/user.md - prompts/semantic_validation/zh-CN/system.md - prompts/semantic_validation/zh-CN/user.md -``` +Business projects can use the BOM to manage A2A-T SDK versions uniformly: -The `slots/` and `templates/` directory paths mirror the template URI segment by segment: `{extension_name}/{path_segments}/{version}` is the template URI, with `{version}` (`v1` by default) as the trailing segment. For example, the `ran-energy-saving` scenario resolves to `templates/Task-T/network-layer/ran-energy-saving/v1/zh-CN/template.md` and `slots/Task-T/network-layer/ran-energy-saving/v1/zh-CN/slot.json`; negotiation templates follow `templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md`. +```xml + + + + net.openan.a2a-t.sdk + a2a-t-bom + 1.0.0 + pom + import + + + +``` -Then specify in `.env`: +Client agent dependencies: -```properties -A2AT_PROMPT_SOURCE_TYPE=local_file -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR={your_prompt_resources_root} -A2AT_LANGUAGE=zh-CN -``` +```xml + + + net.openan.a2a-t.sdk + a2a-t-client + -It is recommended to add the following tests for new resources: - -1. Scenario loading tests. -2. Slot Schema loading and validation tests. -3. Client prompt generation tests. -4. Server prompt compliance validation tests. -5. Sample-level end-to-end verification. - -### 1.8.3 How to Define Prompt Templates -#### 1.8.3.1 Core Value -A2A-T Structured Prompt provides a reusable structured approach for providing clear and consistent prompts to LLMs. By separating core logic from variable data, it makes interactions between agents more reliable, efficient, and scalable. The main benefits of using Structured Prompt include: -- Consistency: Ensures prompts follow a standardized format, making agent output more predictable. -- Efficiency: Avoids writing each Prompt from scratch, saving time and effort. Also avoids repeating complex instructions. -- Scalability: Makes it easier to generate prompts for various business scenarios. -- Optimization: Allows templates to be refined and optimized for better results. - -#### 1.8.3.2 Classification of A2A-T Prompt Templates -For agent communication in the telecom domain, to ensure completeness of request content and improve reasoning efficiency and accuracy, A2A-T defines Structured Prompt templates for each AN high-value scenario, and published the industry A2A-T protocol standards at TMF: -《IG1453A_Structured_Prompt_of_Agent_to_Agent_Protocol_for_Telecoms_A2AT_v1.0.0》 -《IG1453_Agent_to_Agent_Protocol_for_Telecoms_A2AT_v2.0.0》 - -Structured Prompt template definitions are divided into two layers: -- L0 Basic Templates: -Define the foundational framework of Structured Prompt for ICT domain tasks, without specifying variables and ontology for specific scenarios. -L0 template list: - - | Template Name | Description | - |--|--| - |Task-T | Defines the basic structure of ICT domain tasks, but does not specify commonly used variables and general ontology specifications for specific scenarios. Parsing of basic templates relies on the LLM's reasoning ability and the Agent's context processing ability. | - |Notification-T | Defines a structured prompt-based network event subscription and reporting mechanism for the ICT domain. This mechanism ensures real-time perception of network events, and through structured prompts, provides consistent task descriptions across different levels and domains. | - -- L1 Value Scenario Prompt Templates: -Building on L0 templates, commonly used "variables" are defined for different high-value scenario tasks, so that during task generation, agents can input corresponding content based on these variables, and during task execution, identify related content to improve reasoning efficiency and accuracy. - -#### 1.8.3.3 Core Composition Elements -A complete A2A-T Prompt template generally contains the following two parts: -1. Instructions - - Definition: Core directives or context. - - Role: Provides the basic requirements and framework of the task. - - Syntax: Use ## to mark instruction names directly (e.g., ## 任务描述). -2. Variables - - Definition: Dynamic slots, filled with specific data each time they are used. - - Role: Provides more specific information, improving reasoning efficiency. - - Syntax: Use double curly braces {{}} to mark variable names (e.g., {{故障发生时间}}). - -##### 1.8.3.3.1 Instructions - -1. Instruction syntax requirements: When declaring "instructions", use "##" for marking, followed by the name of the "instruction", so that the Agent can recognize it and thereby implement content input or corresponding reasoning and execution. -2. Instruction set: The Structured Prompt defined by A2A-T has established the foundational framework for ICT task Prompt templates, deconstructing typical ICT task information into the following instructions. - - -| Instruction Name | Required/Optional | Description & Example | -|--|--|--| -| 任务描述 | Required | Describes the basic requirements of the task. Example:
        `## 任务描述`
        `Please analyze the root cause of the fault based on "目标对象", "任务上下文", and "约束条件", and provide repair suggestions. Please respond to the task according to the structure defined in "预期输出".` -|任务类型|Optional |Identifies the task type (e.g., fault diagnosis, energy efficiency optimization). Example:
        `## 任务类型`
        `Fault diagnosis ` -|目标对象|Optional| Describes the direct object of the task operation. Example:
        `## 目标对象`
        `Fault identifier (fault-csn) is "OSS-FAULT-20250405-001".` -|任务上下文|Optional |Provides background information for task execution. Example:
        `## 任务上下文`
        `Fault occurrence time (occur-time) is "2025-04-05T14:30:00Z". ` -| 预期输出|Optional | Defines the format of expected results. Example:
        `## 预期输出`
        `Fault diagnosis results should include the following information: 1. Diagnosis status: success or failure 2. Fault diagnosis analysis results 3. Repair suggestions 4. List of root causes of the fault 5. Domain-specific information` - -##### 1.8.3.3.2 Variables -1. Variable syntax requirements: When using "variables", use double curly braces "{{}}" for marking, and place the variable name inside the double curly braces, so that the agent can recognize it and thereby implement content input or corresponding reasoning and execution. -2. Variable instantiation methods: Variables need to be correctly instantiated to provide value. Two recommended methods - - Natural language subject-verb-object structure: The fault occurred at 2026-01-08 16:38:18 - - key-value concise format: 故障发生时间:2026/1/8/16:38:18 -3. Common variable set: -A2A-T has summarized commonly used variables in AN L4 high-value scenarios based on best practices. These variables help effectively describe tasks in AN L4 high-value scenarios: - | Variable Name | Required/Optional | Description & Example | - |--|--|--| - | 标识符 | Required | Used to specify the target identifier associated with the task.
        Example:
        `## 目标对象`
        `{{标识符}}`
        Instance example:
        `## 目标对象`
        `Fault identifier (fault-csn) is "OSS-FAULT-20250405-001".` - |受影响对象|Optional |Used to specify the network resource object affected by the fault.
        Example:
        `## 任务上下文`
        `{{受影响对象}}`
        Instance example:
        `## 任务上下文 `
        `The ID of the affected object is "BTS-001", type is "Base Station Transceiver", name is "Base Station 001", location is "Chaoyang District, Beijing".` - |相关信息|Optional| Its general ontology can be a list of events or alarms related to the fault.
        Example:
        `## 任务上下文 `
        `{{相关信息}} `
        Instance example:
        `## 任务上下文`
        `The associated alarm list is as follows: - Alarm identifier (alarm-csn) is "ALM-20250405-001", alarm ID (alarm-id) is "ALM-001", alarm name is "Base Station Signal Loss", network element name is "BTS-001", alarm location is "Chaoyang District, Beijing", alarm occurrence time (alarm-create-time) is "2025-04-05T14:28:00Z". - Alarm identifier (alarm-csn) is "ALM-20250405-002", alarm ID (alarm-id) is "ALM-002", alarm name is "Transmission Link Interruption", network element name is "TRX-002", alarm location is "Haidian District, Beijing", alarm occurrence time (alarm-create-time) is "2025-04-05T14:29:00Z".` - |故障发生时间| Required | Its general ontology is the time when the fault occurred.
        Example:
        `## 任务上下文 `
        `{{相关信息}}`
        Instance example:
        `## 任务上下文 `
        `Fault occurrence time is "2025-04-05T14:30:00Z".` - | 故障上下文对象 | Required | Its general ontology can be fault pre-processing information from OSS, or alarm reporting information from EMS.
        Example:
        `## 任务上下文`
        `{{故障上下文对象}}`
        Instance example:
        `## 任务上下文 `
        `Fault context object is: "Alarm Management System: FMC, alarm location: Beijing, alarm name: Base Station Signal Loss, alarm time: 2025-04-05T14:28:00Z, alarm network element: BTS-001".` - -#### 1.8.3.4 Format and Specification -A2A-T commonly used text format syntax specification requirements, including paragraphs, lists, links, using Markdown format to ensure structured and readable output: -- Paragraphs: Separate text blocks with blank lines -- Ordered lists: Number plus period (1. Item one) -- Unordered lists: Dash at the beginning (- Item one) -- Links: Square brackets plus parentheses ([text](link)) - -##### 1.8.3.4.1 Paragraphs -To create a paragraph, use blank lines to separate one or more lines of text. Example: + + + org.a2aproject.sdk + a2a-java-sdk-client + 1.0.0.Beta1 + + + org.a2aproject.sdk + a2a-java-sdk-client-transport-rest + 1.0.0.Beta1 + ``` -## 任务描述 -Handle 5G service fault in Community A -Complete service restoration +> In the samples of this guide, A2A requests are uniformly sent through the A2A official Java SDK (the `Client` + REST transport of a2a-java). +> +> Only AgentCard registration and discovery with the registry center uses the JDK built-in `HttpClient` and Jackson (the registry center is outside the scope of a2a-java capabilities; business systems may replace them with any HTTP client and JSON library such as OkHttp or Spring RestTemplate). To use Jackson on the client, add: +> +> ```xml +> +> com.fasterxml.jackson.core +> jackson-databind +> 2.20.1 +> +> ``` -Identify the root cause of the fault and perform repair -``` +#### Step2 Configure the LLM -##### 1.8.3.4.2 Ordered Lists -To create an ordered list, add items represented by numbers plus periods. The numbers do not need to be in sequence, but the list should start with number 1. Example: -``` -## 预期输出 -1. Bar -2. Foo -``` +Copy the content of the `env.example` file in the repository root into `client.env` (`server.env` on the server side) and configure it as follows: -##### 1.8.3.4.3 Unordered Lists -To create an unordered list, add a dash (-) before each item. Indent one or more items to create a nested list. Example: -``` -## 预期输出 -- Item - - Item 1 -- Bar2 -- Foo -``` -##### 1.8.3.4.4 Links -To create a link, enter the link text in square brackets, followed immediately by the URL in parentheses. Example: -``` -## 任务描述 -Handle the issue of [TM Forum AN](Autonomous Network project homepage) failing to load. +```properties +A2AT_LANGUAGE=en-US +A2AT_PROMPT_SOURCE_TYPE=classpath +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= +A2AT_PROMPT_COMPLIANCE_ENABLED=true +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=deepseek-chat +A2AT_LLM_API_KEY={your_llm_api_key} +A2AT_LLM_BASE_URL=https://api.deepseek.com +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory ``` +> `A2AT_LLM_API_KEY` is the key used to **call the external large model**. Keep it safe. +> +> The SDK connects to external large models through OpenAI-compatible interfaces. `A2AT_LLM_PROVIDER` currently supports only `openai`; when connecting to OpenAI-compatible services such as DeepSeek, specify the service address with `A2AT_LLM_BASE_URL` and the model name with `A2AT_LLM_MODEL`. +> +> The prompt resource source is controlled by `A2AT_PROMPT_SOURCE_TYPE`: the default `classpath` loads the resources bundled in the `a2a-t-resources` jar; when set to `local_file`, specify the local resource root directory with `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` (relative paths are resolved against the directory of the `.env` file). -#### 1.8.3.5 Steps for Template Definition -It is recommended to define prompt templates following these steps: -1. Determine task type: Clarify the task type and collaboration mode for the current business scenario, and locate the corresponding template category from the A2A-T task classification system (e.g., Task-T); -2. Write required instructions: Select key instructions, and use structured format to declare task goals, execution conditions, input parameters, expected output, etc.; -3. Fill in commonly used variables: Declare the specific parameters involved in this task instance; variable references must follow A2A-T variable syntax specifications; -4. Bind context information: Supplement the context information needed by the agent to complete the task; -5. Set output definition: Clearly define the output format, acceptance criteria, and exception handling rules; -6. Verify template completeness: Conduct thorough testing in actual cross-model environments to verify syntax compliance and cross-LLM compatibility; -7. Version iteration and optimization: Incorporate validated templates into version management for continuous governance, iteration, and evolution. +#### Step3 Initialize the AgentCard -## 1.9 Testing Recommendations +a2a-java officially recommends building the `AgentCard` (`org.a2aproject.sdk.spec.AgentCard`) with its Builder; you can declare the supported A2A-T templates in `capabilities.extensions`. When registering with the registry center, the AgentCard is serialized into JSON (wrapped as `{"agentCards": [...]}`). -Common test commands: +- Reference client AgentCard definition (you can declare the supported A2A-T templates in `extensions`): -```bash -mvn test +```java +import java.util.List; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentExtension; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentProvider; +import org.a2aproject.sdk.spec.AgentSkill; + +private static final String TASK_T_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; +private static final String NOTIFICATION_T_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + +private static AgentCard buildClientAgentCard() { + return AgentCard.builder() + .name("Transmission workbench agent") + .description("Transport network O&M management agent providing O&M capabilities such as circuit recovery verification, base station outage root cause analysis, and network element hidden danger inspection") + .provider(new AgentProvider("ZzNode", "https://example.com")) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .extendedAgentCard(false) + .extensions(List.of( + AgentExtension.builder() + .uri(TASK_T_EXT) + .description("Extension of structured prompt Task-T requests.") + .build(), + AgentExtension.builder() + .uri(NOTIFICATION_T_EXT) + .description("Extension of structured prompt Notification-T requests.") + .build())) + .build()) + .defaultInputModes(List.of("application/json", "text/plain")) + .defaultOutputModes(List.of("application/json", "text/plain")) + .skills(List.of( + AgentSkill.builder() + .id("circuit-recovery-verification") + .name("Circuit recovery verification agent") + .description("Circuit service recovery verification skill. Substitutes the circuit name in the request into a fixed JSON template and returns the service recovery verification result. Used when the user mentions \"service recovery verification\", \"circuit recovery verification\", \"circuit recovery verification\", or similar requests. Applicable to scenarios that need to confirm the service recovery status of a specified circuit.") + .tags(List.of("circuit recovery verification", "service recovery verification", "circuit-recovery")) + .examples(List.of( + "Verify service recovery for circuits: LYSPELC3, SN3 Phase 2 - LYXLSJLT1 Building 10GE1049641NR", + "Verify service recovery for circuit XXX", + "circuit recovery verification for circuit XXX")) + .inputModes(List.of("application/json", "text/plain")) + .outputModes(List.of("application/json", "text/plain")) + .build(), + AgentSkill.builder() + .id("ne-hidden-danger") + .name("NE hidden danger inspection agent") + .description("Network element hidden danger inspection skill. Inspects whether the specified network element still has new hidden dangers based on the input network element name. Used when the user mentions \"hidden danger inspection\", \"check hidden dangers\", \"NE inspection\", \"ne hidden danger\", or similar requests. Applicable to scenarios that need to check the hidden danger status of a specified network element.") + .tags(List.of("NE inspection", "hidden danger inspection", "NE-inspection")) + .examples(List.of( + "Check whether NE QZHA-HAZBYSDGG-HRHH still produces new hidden dangers", + "Check whether NE XXX still has hidden dangers", + "Check if NE XXX has any hidden dangers")) + .inputModes(List.of("application/json", "text/plain")) + .outputModes(List.of("application/json", "text/plain")) + .build())) + .supportedInterfaces(List.of( + new AgentInterface("HTTP+JSON", "http://10.xx.xx.xx:26335/a2a/v1", "", "1.0"))) + .build(); +} ``` -When modifying client prompt generation: - -```bash -mvn -pl a2a-t-client -am test +- Reference client AgentCard JSON message: + +```json +{ + "agentCards": [ + { + "name": "Transmission workbench agent", + "description": "Transport network O&M management agent providing O&M capabilities such as circuit recovery verification, base station outage root cause analysis, and network element hidden danger inspection", + "supportedInterfaces": [ + { + "url": "http://10.xx.xx.xx:26335/a2a/v1", + "protocolBinding": "HTTP+JSON", + "protocolVersion": "1.0" + } + ], + "provider": { + "organization": "ZzNode" + }, + "version": "1.0.0", + "capabilities": { + "streaming": true, + "pushNotifications": false, + "extensions": [ + { + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1", + "description": "Extension of structured prompt Task-T requests." + }, + { + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1", + "description": "Extension of structured prompt Notification-T requests." + } + ], + "extendedAgentCard": false + }, + "securitySchemes": { + "bearerAuth": { + "httpAuthSecurityScheme": { + "description": "Query the accessSession through the login API with the username and password, and then use the accessSession for bearer authentication.", + "scheme": "Bearer" + } + } + }, + "defaultInputModes": [ + "application/json", + "text/plain" + ], + "defaultOutputModes": [ + "application/json", + "text/plain" + ], + "skills": [ + { + "id": "circuit-recovery-verification", + "name": "Circuit recovery verification agent", + "description": "Circuit service recovery verification skill. Substitutes the circuit name in the request into a fixed JSON template and returns the service recovery verification result. Used when the user mentions \"service recovery verification\", \"circuit recovery verification\", \"circuit recovery verification\", or similar requests. Applicable to scenarios that need to confirm the service recovery status of a specified circuit.", + "tags": [ + "circuit recovery verification", + "service recovery verification", + "circuit-recovery" + ], + "examples": [ + "Verify service recovery for circuits: LYSPELC3, SN3 Phase 2 - LYXLSJLT1 Building 10GE1049641NR", + "Verify service recovery for circuit XXX", + "circuit recovery verification for circuit XXX" + ], + "inputModes": [ + "application/json", + "text/plain" + ], + "outputModes": [ + "application/json", + "text/plain" + ] + }, + { + "id": "ne-hidden-danger", + "name": "NE hidden danger inspection agent", + "description": "Network element hidden danger inspection skill. Inspects whether the specified network element still has new hidden dangers based on the input network element name. Used when the user mentions \"hidden danger inspection\", \"check hidden dangers\", \"NE inspection\", \"ne hidden danger\", or similar requests. Applicable to scenarios that need to check the hidden danger status of a specified network element.", + "tags": [ + "NE inspection", + "hidden danger inspection", + "NE-inspection" + ], + "examples": [ + "Check whether NE QZHA-HAZBYSDGG-HRHH still produces new hidden dangers", + "Check whether NE XXX still has hidden dangers", + "Check if NE XXX has any hidden dangers" + ], + "inputModes": [ + "application/json", + "text/plain" + ], + "outputModes": [ + "application/json", + "text/plain" + ] + } + ] + } + ] +} ``` -When modifying server validation: +#### Step4 Register and Discover the AgentCard -```bash -mvn -pl a2a-t-server -am test -``` +- **AgentCard registration**: publish the client AgentCard to the registry center. The address and URI of the registry center depend on the actual deployment -When modifying negotiation state machine: - -```bash -mvn -pl a2a-t-negotiation -am test +```java +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.a2aproject.sdk.spec.AgentCard; + +private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); +private static final ObjectMapper MAPPER = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); + +private static void registerAgentCard(String registryBaseUrl, AgentCard agentCard) + throws Exception { + String payload = MAPPER.writeValueAsString(Map.of("agentCards", List.of(agentCard))); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(registryBaseUrl + "/rest/v1/registry-center/agent-cards")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 201) { + throw new IllegalStateException("AgentCard registration failed: " + response.statusCode()); + } +} ``` -When modifying sample: +- **AgentCard discovery**: query the registry center by the target agent name or skill to obtain its AgentCard, and thereby the `url` and supported skills -```bash -mvn -pl a2a-t-sample -am -DskipTests package +```java +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.core.type.TypeReference; + +private static Map discoverAgent( + String registryBaseUrl, String organization, String name) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(registryBaseUrl + "/rest/v1/registry-center/agent-cards/" + + organization + "/" + name)) + .GET() + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new IllegalStateException("AgentCard query failed: " + response.statusCode()); + } + Map payload = + MAPPER.readValue(response.body(), new TypeReference>() {}); + return (Map) ((List) payload.get("agentCards")).get(0); +} ``` -## 1.10 Negotiation-T Content Layer +The discovery result is the JSON structure returned by the registry center. Because a2a-java's `Client.builder(...)` takes an `org.a2aproject.sdk.spec.AgentCard` object, convert it into the official AgentCard model: -This section describes the Negotiation-T content layer: a template-driven API set for generating negotiation messages, checking their compliance, and extracting parameters from them. Both `A2ATClient` and `A2ATServer` expose the same fourteen methods for this purpose. +```java +import java.util.List; +import java.util.Map; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentExtension; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentProvider; +import org.a2aproject.sdk.spec.AgentSkill; + +@SuppressWarnings("unchecked") +private static AgentCard toAgentCard(Map registryAgentCard) { + Map provider = (Map) registryAgentCard.getOrDefault("provider", Map.of()); + Map capabilities = (Map) registryAgentCard.getOrDefault("capabilities", Map.of()); + List> extensionMaps = + (List>) capabilities.getOrDefault("extensions", List.of()); + List> skillMaps = + (List>) registryAgentCard.getOrDefault("skills", List.of()); + List> interfaceMaps = + (List>) registryAgentCard.getOrDefault("supportedInterfaces", List.of()); + + return AgentCard.builder() + .name(string(registryAgentCard.get("name"))) + .description(string(registryAgentCard.get("description"))) + .provider(new AgentProvider(string(provider.get("organization")), string(provider.get("url")))) + .version(string(registryAgentCard.get("version"))) + .capabilities(AgentCapabilities.builder() + .streaming(Boolean.TRUE.equals(capabilities.get("streaming"))) + .pushNotifications(Boolean.TRUE.equals(capabilities.get("pushNotifications"))) + .extendedAgentCard(Boolean.TRUE.equals(capabilities.get("extendedAgentCard"))) + .extensions(extensionMaps.stream() + .map(extension -> AgentExtension.builder() + .uri(string(extension.get("uri"))) + .description(string(extension.get("description"))) + .required(Boolean.TRUE.equals(extension.get("required"))) + .build()) + .toList()) + .build()) + .defaultInputModes(stringList(registryAgentCard.get("defaultInputModes"))) + .defaultOutputModes(stringList(registryAgentCard.get("defaultOutputModes"))) + .skills(skillMaps.stream() + .map(skill -> AgentSkill.builder() + .id(string(skill.get("id"))) + .name(string(skill.get("name"))) + .description(string(skill.get("description"))) + .tags(stringList(skill.get("tags"))) + .build()) + .toList()) + .supportedInterfaces(interfaceMaps.stream() + .map(agentInterface -> new AgentInterface( + string(agentInterface.get("protocolBinding")), + string(agentInterface.get("url")), + string(agentInterface.getOrDefault("tenant", "")), + string(agentInterface.getOrDefault("protocolVersion", "1.0")))) + .toList()) + .build(); +} -### 1.10.1 Overview and SDK Boundary +private static String string(Object value) { + return value == null ? "" : String.valueOf(value); +} -The content layer covers three capabilities: +private static List stringList(Object value) { + return value instanceof List values ? values.stream().map(String::valueOf).toList() : List.of(); +} +``` -1. **Message generation** — renders a structured negotiation message from typed data (`generateNegotiation*PromptFromData`, deterministic, no LLM) or from free text (`generateNegotiation*PromptFromText`, one LLM content-extraction step followed by deterministic rendering). -2. **Compliance checking and parameter extraction** — `validatePromptAndDataFilling*Data` checks that a received message is a well-formed negotiation message and extracts its parameters per a caller-provided JSON schema. -3. **Template queries** — `getPrompts` / `getPrompt` list and load the templates available for the configured language across **all** A2A-T extensions (Task-T, Notification-T, Authorization-T, Negotiation-T); `getNegotiationPrompts` / `getNegotiationPrompt` restrict the same queries to the negotiation templates. +#### Step5 Initialize the A2A-T Client -The content layer is stateless. It deliberately does not own a session state machine: session identity, round tracking beyond what the message itself carries, and role binding stay with the caller. The pre-existing `startNegotiation` / `receiveNegotiation` / `continueNegotiation` API (see 1.6.3) is unchanged and remains the stateful entry point; the content layer can be combined with it or used standalone. +```java +import java.nio.file.Path; +import net.openan.a2at.sdk.client.A2ATClient; -### 1.10.2 Facade Methods +A2ATClient client = new A2ATClient(Path.of("client.env")); +``` -All fourteen methods exist on both `A2ATClient` and `A2ATServer` with identical signatures and semantics. +Both `A2ATClient` and `A2ATServer` take the `.env` file path explicitly at construction (the Java SDK does not auto-discover configuration files); the path is resolved against the process working directory, so an absolute path is recommended. -Every method that identifies a template takes the value type `net.openan.a2at.sdk.core.model.TemplateUri` instead of a raw string. A `TemplateUri` is always well-formed — its constructor validates the extension name, path segments, and version — so malformed URIs cannot reach these APIs. Build one with `TemplateUri.of("Negotiation-T", "information-negotiation", "propose")`, or better, use the constants in `StandardTemplates` from the same package (for example `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`, whose `uri()` is `Negotiation-T/information-negotiation/propose/v1`). When a URI string arrives from outside the code, `TemplateUri.parse(String)` returns an `Optional` and never throws. +#### Step6 Generate the A2A-T Template Message -**Generation from typed data — deterministic, never calls an LLM:** +The client uses the A2A-T Client SDK API `generateTaskPrompt` to generate the processed_prompt, and then sends it to the target agent as part of an A2A message. ```java -MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, TemplateUri templateUri) -MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, TemplateUri templateUri) -MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, TemplateUri templateUri) -MetadataContent generateNegotiationAbortPromptFromData(NegotiationAbortData data, TemplateUri templateUri) -``` +import java.nio.file.Path; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; -The typed content is validated, dispatched to the generator of the negotiation type addressed by the template URI, and rendered from that template. The accept variant requires `content.conclusion() == ACCEPT`, the reject variant `REJECT`; a mismatched conclusion (including `ABORT`) is rejected with `IllegalArgumentException` as a programming error. The abort variant is type-independent: it renders the single common abort template `StandardTemplates.NEGOTIATION_ABORT` from a `NegotiationAbortData(context, NegotiationAbortContent(terminationReason))` bundle, and the fixed `Abort` conclusion is carried by the template itself. +A2ATClient client = new A2ATClient(Path.of("client.env")); -**Generation from free text — one LLM extraction step with configurable retry:** +// Generate the A2A-T prompt +PromptGenerationResult result = client.generateTaskPrompt("Generate an Incident event subscription task: the notification topic is Incident, the subscription levels are critical, medium, high, and low, and the notification data format is DataPart"); +if (!result.success()) { + throw new IllegalStateException(result.failure().code() + ": " + result.failure().message()); +} -```java -MetadataContent generateNegotiationProposePromptFromText(String text, NegotiationContext context, TemplateUri templateUri) -MetadataContent generateNegotiationAcceptPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) -MetadataContent generateNegotiationRejectPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) -MetadataContent generateNegotiationAbortPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) +String processedPrompt = result.promptText(); ``` -The template is loaded before the LLM call; a missing template fails fast without consuming an LLM request. The LLM step extracts the typed content from the free text constrained by the template URI, then rendering proceeds deterministically like the from-data variant. The `NegotiationContext` is carried in the generated metadata without any LLM involvement and is no longer rendered into the message text. The extraction step is retried up to the configured attempt limit on the retryable failure codes (see 1.10.5). The abort variant extracts the termination reason from the free text against the common abort template. - -**Compliance checking and parameter extraction:** - -```java -FilledParamData validateProposePromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) -FilledParamData validateAcceptPromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) -FilledParamData validateRejectPromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) -FilledParamData validateAbortPromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) -``` +#### Step7 Activate the A2A-T Extension in Request Headers -The pipeline runs in a fixed order: +The A2A protocol conveys the protocol version and extension declarations through HTTP headers. The following headers are required: -1. Template URI format check (phase segment must match the method: `propose` vs `accept-reject`). -2. Deterministic rule gate — structural rules of the caller-supplied `NegotiationContext` (UUID id shape, round within the round budget), before any LLM call. A null context is reported as `negotiation_invalid_input` (not a negotiation message). -3. One LLM semantic validation call that also extracts the parameters, retried up to the configured attempt limit on the retryable LLM infrastructure failure code. -4. Merge of the extracted parameters with the rule-level context parameters — **context parameters take precedence** on key conflict. +| Header | Direction | Required | Value | +| ------ | --------- | -------- | ----- | +| `A2A-Version` | Request header | Yes | Protocol version, e.g. `1.0` (the client must carry it with every request) | +| `A2A-Extensions` | Request header | No (recommended when using extensions) | Comma-separated list of extension URIs, declaring the extensions used by this request | -**Template queries — never throw:** +Sample of filling in client request headers: when sending requests with a2a-java, `A2A-Extensions` is passed in through the headers of `ClientCallContext` (protocol headers such as the protocol version are carried by the a2a-java transport layer as required by the protocol) ```java -List getPrompts() // templates of ALL extensions for the configured language, sorted by URI -Optional getPrompt(TemplateUri templateUri) // one template by URI across all extensions; empty on miss - -List getNegotiationPrompts() // negotiation templates of the configured language, fixed order -Optional getNegotiationPrompt(TemplateUri templateUri) // one negotiation template by URI; empty on miss -``` - -A missing template yields an empty result with a warning log; because the argument is a validated `TemplateUri`, an unusable URI cannot occur, and a null argument raises `NullPointerException`. `PromptTemplate` is a record `(templateUri, description, content)` whose first component is the typed `TemplateUri` value; `content` is null only when a template record is constructed without available content. The generic pair discovers the extension directories from the bundled resource tree itself — a template directory added under `prompt_resources/templates/` (for example a future `Authorization-T/`) is listed automatically without code changes. Local templates under the configured local resource root override built-in templates of the same path. Note that the SDK does not yet bundle Authorization-T template resources; `generateAuthPromptFromText` / `generateAuthPromptFromDataWithSchema` answer `template_not_found` under the classpath source until such templates are added or provided through the local resource root. - -### 1.10.3 Template URIs and Custom Templates +import java.util.Map; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; -The template URI format is: +String notificationPromptExt = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; -```text -Negotiation-T/{type}-negotiation/{phase}/v1 +ClientCallContext callContext = + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", notificationPromptExt)); ``` -where `{type}` is `information`, `target`, or `feasibility`, `{phase}` is `propose` or `accept-reject`, and the trailing `v1` is the template version (the default version). The facade APIs take this as the typed `TemplateUri` value; one built-in constant exists per template in `StandardTemplates`. String-level parsing never throws: `TemplateUri.parse(String)` returns an `Optional`, and `NegotiationReference.tryParse` returns an `Optional` — a null, blank or malformed URI (wrong segment count, prefix, version, type or phase segment) simply yields an empty result. Six built-in templates ship with the SDK, one per combination, each in `zh-CN` and `en-US`: - -| Template URI | Purpose | -|--|--| -| `Negotiation-T/information-negotiation/propose/v1` | Request missing information | -| `Negotiation-T/information-negotiation/accept-reject/v1` | Accept or reject an information negotiation | -| `Negotiation-T/target-negotiation/propose/v1` | Propose a target negotiation | -| `Negotiation-T/target-negotiation/accept-reject/v1` | Accept or reject a target negotiation | -| `Negotiation-T/feasibility-negotiation/propose/v1` | Request a feasibility evaluation or propose an alternative | -| `Negotiation-T/feasibility-negotiation/accept-reject/v1` | Accept or reject a feasibility negotiation | +#### Step8 Send the Request with the A2A-T Extension -Negotiation templates are resolved with a **dual-root fallback** that is independent of `A2AT_PROMPT_SOURCE_TYPE`: a template file that exists under the local resource root configured by `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` wins, otherwise the built-in classpath template of the same URI is used. The built-in templates therefore always remain available as a safety net. Templates and the negotiation vocabulary are taken from the local root; LLM prompt resources always come from the classpath. The negotiation vocabulary follows the same dual-root regime: each language's section titles, slot marker names, appended-line labels and list punctuation live in `prompt_resources/negotiation-vocabulary/{lang}/vocabulary.json`, so a file placed at `{A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR}/negotiation-vocabulary/{lang}/vocabulary.json` overrides the built-in classpath vocabulary. A vocabulary file must define exactly the canonical key set shared by both bundled languages; anything else fails fast. +Send the request using the official a2a-java SDK: put the processed prompt into `Message.metadata` (keyed by the extension URI), declare the request headers through `ClientCallContext`, and send it with `Client` over the REST transport. -To override one template (for example the information propose template in Chinese), place a file under the local root following the bundled layout: +```java +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfig; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.a2aproject.sdk.spec.TextPart; + +String notificationPromptExt = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + +// 1) Generate the A2A-T prompt (see Step6) +A2ATClient client = new A2ATClient(Path.of("client.env")); +PromptGenerationResult result = client.generateTaskPrompt("Generate an Incident event subscription task: the notification topic is Incident, the subscription levels are critical, medium, high, and low, and the notification data format is DataPart"); +if (!result.success()) { + throw new IllegalStateException(result.failure().code() + ": " + result.failure().message()); +} +String processedPrompt = result.promptText(); + +// 2) Build the A2A message carrying the A2A-T extension (the prompt goes into metadata, keyed by the extension URI) +Message message = Message.builder() + .messageId(UUID.randomUUID().toString()) + .role(Message.Role.ROLE_USER) + .parts(new TextPart("Create an intelligent fault incident reporting task")) + .metadata(Map.of(notificationPromptExt, processedPrompt)) + .build(); +MessageSendParams params = MessageSendParams.builder().message(message).build(); + +// 3) Declare the A2A-T extension request header through ClientCallContext (see Step7) +ClientCallContext callContext = + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", notificationPromptExt)); + +// 4) Send the request with the a2a-java Client and consume task events +CountDownLatch done = new CountDownLatch(1); +try (Client a2aClient = Client.builder(agentCard) // org.a2aproject.sdk.spec.AgentCard retrieved and converted in Step4 + .withTransport(RestTransport.class, new RestTransportConfig()) + .build()) { + a2aClient.sendMessage( + params, + List.of((event, ignored) -> handleEvent(event, done)), + throwable -> { + throwable.printStackTrace(); + done.countDown(); + }, + callContext); + // When the AgentCard declares streaming=true, message:stream streaming is used: sendMessage returns + // immediately and we wait for the task to reach a terminal state; otherwise message:send blocking mode + // is used and the task has already finished when sendMessage returns + done.await(); +} -```text -{A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR}/ - templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md +private static void handleEvent(ClientEvent event, CountDownLatch done) { + if (event instanceof TaskUpdateEvent update) { + if (update.getUpdateEvent() instanceof TaskStatusUpdateEvent statusUpdate) { + System.out.println("task-status: " + statusUpdate.status().state()); + if (statusUpdate.status().state().isFinal()) { + done.countDown(); + } + } else if (update.getUpdateEvent() instanceof TaskArtifactUpdateEvent artifactUpdate) { + System.out.println("task-artifact: " + artifactUpdate.artifact().parts()); + } + } else if (event instanceof MessageEvent messageEvent) { + System.out.println("task-message: " + messageEvent.getMessage().parts()); + } +} ``` -### 1.10.4 Data Models +#### Complete Sample Client Code -All types live in `net.openan.a2at.sdk.negotiation.content`. +```java +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfig; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.a2aproject.sdk.spec.TextPart; + +public final class ClientSample { + private static final String NOTIFICATION_PROMPT_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final ObjectMapper MAPPER = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); + private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); + + public static void main(String[] args) throws Exception { + // 1) Register the client AgentCard and discover the server AgentCard + registerAgentCard("http://{ip:port}", buildClientAgentCard()); // the client AgentCard defined in Step3 + AgentCard serverAgentCard = toAgentCard( // the conversion defined in Step4 + discoverAgent("http://{ip:port}", "Huawei", "RAN Domain Agent")); + + // 2) Use the SDK capability to generate the A2A-T prompt + A2ATClient client = new A2ATClient(Path.of("client.env")); + PromptGenerationResult result = client.generateTaskPrompt("Generate an Incident event subscription task: the notification topic is Incident, the subscription levels are critical, medium, high, and low, and the notification data format is DataPart"); + if (!result.success()) { + throw new IllegalStateException(result.failure().code() + ": " + result.failure().message()); + } + String processedPrompt = result.promptText(); + + // 3) Build the A2A message carrying the A2A-T extension (request headers declare the extension; the message body carries the A2A-T extension field) + Message message = Message.builder() + .messageId(UUID.randomUUID().toString()) + .role(Message.Role.ROLE_USER) + .parts(new TextPart("Create an intelligent fault incident reporting task")) + .metadata(Map.of(NOTIFICATION_PROMPT_EXT, processedPrompt)) + .build(); + MessageSendParams params = MessageSendParams.builder().message(message).build(); + ClientCallContext callContext = + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", NOTIFICATION_PROMPT_EXT)); + + // 4) Send the request with the a2a-java Client and consume task events + CountDownLatch done = new CountDownLatch(1); + try (Client a2aClient = Client.builder(serverAgentCard) + .withTransport(RestTransport.class, new RestTransportConfig()) + .build()) { + a2aClient.sendMessage( + params, + List.of((event, ignored) -> handleEvent(event, done)), + throwable -> { + throwable.printStackTrace(); + done.countDown(); + }, + callContext); + done.await(); // In streaming mode, wait for the task to reach a terminal state + } + } + + private static void handleEvent(ClientEvent event, CountDownLatch done) { + if (event instanceof TaskUpdateEvent update) { + if (update.getUpdateEvent() instanceof TaskStatusUpdateEvent statusUpdate) { + System.out.println("task-status: " + statusUpdate.status().state()); + if (statusUpdate.status().state().isFinal()) { + done.countDown(); + } + } else if (update.getUpdateEvent() instanceof TaskArtifactUpdateEvent artifactUpdate) { + System.out.println("task-artifact: " + artifactUpdate.artifact().parts()); + } + } else if (event instanceof MessageEvent messageEvent) { + System.out.println("task-message: " + messageEvent.getMessage().parts()); + } + } + + private static void registerAgentCard(String registryBaseUrl, AgentCard agentCard) + throws Exception { + String payload = MAPPER.writeValueAsString(Map.of("agentCards", List.of(agentCard))); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(registryBaseUrl + "/rest/v1/registry-center/agent-cards")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 201) { + throw new IllegalStateException("AgentCard registration failed: " + response.statusCode()); + } + } + + private static Map discoverAgent( + String registryBaseUrl, String organization, String name) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(registryBaseUrl + "/rest/v1/registry-center/agent-cards/" + + organization + "/" + name)) + .GET() + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new IllegalStateException("AgentCard query failed: " + response.statusCode()); + } + Map payload = + MAPPER.readValue(response.body(), new TypeReference>() {}); + return (Map) ((List) payload.get("agentCards")).get(0); + } +} +``` -**NegotiationContext** — the session context carried by every negotiation message in the `core.model` package: `record NegotiationContext(String id, int round, int maxRounds, NegotiationPerformative performative)`. All four components are required — a blank id, a non-positive round or maxRounds, or a null `performative` throws `IllegalArgumentException`. It is immutable; `nextRound()` returns a context with the round incremented that keeps the performative, `isExhausted()` reports whether the round is strictly greater than `maxRounds`, and `withPerformative(performative)` returns a copy stamped with the given performative. `NegotiationContext.of(id, round, performative)` applies the default round budget of `DEFAULT_MAX_ROUNDS = 5`. The `performative` carries the communicative intent of the message the context travels with: on an input context it is the performative of the incoming message, while the generation pipeline stamps the performative of the operation it performs on the output context. +> `buildClientAgentCard()`, `toAgentCard(Map)`, and the `string`/`stringList` helper methods are defined in Step3 and Step4. -**NegotiationPerformative** — the communicative intent of a negotiation message, in the `core.model` package: `enum NegotiationPerformative { PROPOSE, ACCEPT, REJECT, ABORT }`. `PROPOSE` puts a proposal on the table, `ACCEPT` and `REJECT` respond to a proposal, and `ABORT` terminates the session; `tryParse(String)` accepts exactly the upper-case constant names and returns an empty `Optional` otherwise. Do not confuse the four performative values with the three template-URI phase segments (`propose`, `accept-reject`, `abort`): the performative is the online semantics of a message, whereas the phase segment addresses the template resource that renders it — `ACCEPT` and `REJECT` messages share the `accept-reject` phase segment. +### 1.4.4 Sample Server Development Steps -**Input bundles** — `NegotiationProposeData(context, content)` and `NegotiationEndingData(context, content)` pair the context with the typed content, and `NegotiationAbortData(context, NegotiationAbortContent)` pairs it with the type-independent abort content. The content types are sealed hierarchies per negotiation type: +#### Step1-Step5 Prerequisites -| Negotiation type | Propose content | Ending content | -|--|--|--| -| Information | `InformationProposeContent(List items, String relationship)` | `InformationEndingContent(conclusion, items)` | -| Target | `TargetProposeContent(description, intentUnderstanding, alignmentAndClarification, requestForClarification)` | `TargetEndingContent(conclusion, confirmedIntent, failureReason)` | -| Feasibility | `FeasibilityProposeContent(description, action, contentsToEvaluate, infeasibilityDetailsAndProposal)` | `FeasibilityEndingContent(conclusion, feasibilitySummary)` | +Steps such as installing dependencies and configuring the LLM can all reference the [client implementation](#step1-install-dependencies). The differences are as follows: -`NegotiationItem(name, value)` is one named entry of an item list. `NegotiationConclusion` carries `ACCEPT`, `REJECT`, and `ABORT`; only `Accept` and `Reject` are renderable conclusions of the typed negotiation templates — the typed generation methods reject `ABORT` with an `IllegalArgumentException` (a programming error outside the `A2ATError` tree). The `ABORT` outcome is rendered through the type-independent common abort template: `NegotiationAbortContent(terminationReason)` is the only content it carries and `NegotiationAbortData(context, content)` is its input bundle. `NegotiationAction` (`REQUEST_FEASIBILITY_EVALUATION`, `PROPOSE_ALTERNATIVE_ON_FAILURE`) selects the conditional sections of the feasibility propose template. +It is recommended that the server agent use the official a2a-java REST reference server (based on Quarkus, which automatically assembles the REST transport, task management, and event queues) to host A2A requests. Add the following dependencies: -**MetadataContent** — the generation result: `record MetadataContent(String templateUri, String promptText, String extensionUri, NegotiationContext negotiationContext)`. `buildMetadataContent()` returns `Map`: the TMF extension URI (`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`) mapping to the rendered message, and `templateUri` mapping to the template URI; negotiation messages additionally carry `negotiationContext` mapping to the nested `{id, round, maxRounds, performative}` object, which always holds all four keys — `performative` holds the upper-case performative name (`"PROPOSE"`, `"ACCEPT"`, `"REJECT"`, `"ABORT"`) and is never omitted — while non-negotiation messages omit the `negotiationContext` key and keep the two-key shape. This map is what travels in the A2A message metadata — the negotiation context travels here, not in the rendered text. +```xml + + + net.openan.a2a-t.sdk + a2a-t-server + -**FilledParamData** — the extraction result: `record FilledParamData(Map data)` holding the context parameters merged with the schema-extracted parameters. + + + org.a2aproject.sdk + a2a-java-sdk-reference-rest + 1.0.0.Beta1 + -### 1.10.5 Error Handling + + + com.fasterxml.jackson.core + jackson-databind + 2.20.1 + +``` -All SDK processing failures share one root, while programming errors stay outside it: +A Quarkus project also needs the Quarkus platform BOM and build plugin (for a complete project scaffold, refer to the official a2a-java [Server Guide](https://a2aproject.github.io/a2a-java/) or generate one at https://code.quarkus.io): -- `A2ATError` — the single root of **all** SDK processing failures (runtime, environment and data-processing failures). The machine-readable code is declared on the root itself: `getCode()` never returns `null` and carries the default `sdk_internal_error` when a failure has no more specific code. Catching this one type therefore covers every processing failure the SDK can raise, LLM and compliance failures included. - - `NegotiationProcessingException` — a negotiation processing failure. - - `NegotiationGenerationException` — raised by generation calls. - - `A2ATParamExtractionError` — a parameter-extraction failure with structured per-slot errors; `getCode()` is inherited from the root, `getErrors()` returns the slot details. - - `NegotiationParamExtractionException` — raised by `validatePromptAndDataFilling*Data` calls. - - `LLMError` — the LLM integration failure subtree, folded into the `A2ATError` tree. - - `LLMConfigError` — invalid LLM configuration or provider registration. - - `LLMRuntimeError` — an LLM infrastructure failure at call time. - - `PromptComplianceCheckException` — a compliance-check failure carrying the code and the compliance stage (`getStage()`); raised by the server-side compliance pipeline with the codes `processed_prompt_parse_error`, `slot_validation_error` and `template_not_found`. - - The remaining processing failures — prompt generation (`PromptGenerationException`), content validation (`ContentValidationException`), template rendering (`TaskPromptRenderException`), resource resolution (`ResourceNotFoundException`, `ConfigFileNotFoundException`), scenario recognition (`ScenarioRecognitionException`) and negotiation state access (`NegotiationStateException`) — are part of the same tree. +```xml + + 3.30.6 + -Programming errors — argument-contract violations by the caller — intentionally stay **outside** the `A2ATError` tree, as standard JDK exceptions, so a generic `catch (A2ATError)` handler cannot swallow them: a null argument raises `NullPointerException`, and a blank or semantically contradictory argument (blank context id, invalid segments passed to the `TemplateUri` constructor, phase or conclusion mismatch, unsupported language) raises `IllegalArgumentException`. The former `NegotiationContentException` has been removed; the violations it used to report now surface as these standard exceptions, and each facade method documents them with `@throws` tags. + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.platform.version} + pom + import + + + -Error codes and their meaning: + + + + io.quarkus.platform + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + +``` -| Code | Meaning | Retryable | -|--|--|--| -| `template_not_found` | No template (or prompt resource) exists for the URI in any resource root | No | -| `negotiation_content_extract_failed` | Structured content could not be extracted from free text | Yes | -| `negotiation_llm_infrastructure_error` | An LLM infrastructure failure (network, provider error, malformed response) | Yes | -| `negotiation_semantic_rejected` | Semantic validation rejected the message | No | -| `negotiation_rule_violation` | The negotiation context violates a structural rule | No | -| `negotiation_slot_missing` | A required slot is missing when rendering | No | -| `negotiation_invalid_input` | The input is not valid for the operation (blank free text on the from-text generation calls, not a negotiation message, wrong conclusion) | No | -| `param_extraction_failed` | Default code for extraction failures without a more specific code | No | -| `sdk_internal_error` | Default code for SDK processing failures without a more specific code | No | +- Initialize the A2A-T server -Retry semantics: a failing LLM step carrying a retryable code is re-run up to the attempt limit configured by `A2AT_LLM_MAX_ATTEMPTS`; failures carrying any other code are rethrown immediately. When the attempts are exhausted, the original failure is rethrown with its original code. `A2AT_LLM_MAX_ATTEMPTS` defaults to 3 and is clamped to the range 1–10 (out-of-range values are clamped with a warning log). +```java +import java.nio.file.Path; +import net.openan.a2at.sdk.server.A2ATServer; -### 1.10.6 Language Configuration +A2ATServer server = new A2ATServer(Path.of("server.env")); +``` -The negotiation templates and the vocabulary used for rendering and section recognition are keyed by `A2AT_LANGUAGE`. Out of the box — built-in classpath resources only — the supported values are exactly `zh-CN` and `en-US`; there is no fallback to another language, any other value without a local vocabulary file fails with an `IllegalArgumentException` when the vocabulary is resolved, and the failure message points at `A2AT_LANGUAGE`. A language for which a valid `vocabulary.json` exists under the local resource root resolves at the vocabulary layer, but generation still fails later with template-not-found unless the full template and prompt resource set for that language is provided as well. +- Initialize the AgentCard: a2a-java officially recommends exposing the AgentCard qualified with `@PublicAgentCard` through a CDI producer (built with the Builder; see the complete server code). Reference server AgentCard definition: -The vocabulary itself is file-driven: each language's constants live in `prompt_resources/negotiation-vocabulary/{lang}/vocabulary.json`, a flat JSON object mapping the 33 canonical keys (section titles, slot marker names, appended-line labels, list punctuation) to that language's text. It follows the same dual-root local-override regime as the templates — a file under `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` wins, otherwise the built-in classpath file is used — and resolution is fail-fast with no silent degradation: a vocabulary that exists in neither root, is unreadable, is malformed, or does not define exactly the canonical key set raises an `IllegalArgumentException` naming the language and the file origin. Adding a new language therefore still requires the full resource set — the negotiation templates and the LLM prompt resources — not just a vocabulary file. Set the language explicitly in `client.env` / `server.env`: +```java +import java.util.List; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentExtension; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentProvider; +import org.a2aproject.sdk.spec.AgentSkill; + +private static final String TASK_T_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; +private static final String NOTIFICATION_T_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + +private static AgentCard buildServerAgentCard() { + return AgentCard.builder() + .name("RAN Domain Agent") + .description("RAN Domain Agent") + .provider(new AgentProvider("Huawei", "https://www.huawei.com")) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .extendedAgentCard(false) + .extensions(List.of( + AgentExtension.builder() + .uri(TASK_T_EXT) + .description("Extension of structured prompt TASK-T requests.") + .required(false) + .build(), + AgentExtension.builder() + .uri(NOTIFICATION_T_EXT) + .description("Extension of structured prompt Notification-T requests.") + .required(false) + .build())) + .build()) + .defaultInputModes(List.of("application/json", "text/plain")) + .defaultOutputModes(List.of("application/json", "text/plain")) + .skills(List.of(AgentSkill.builder() + .id("ran-incident-subscription") + .name("Incident Reporting") + .description("Supports Incident reporting and provides intelligent fault identification and diagnosis capabilities") + .tags(List.of("Incident Reporting")) + .examples(List.of( + "## Subscription Description\nBased on the following , , , and information, complete the network-side intelligent fault Incident subscription and reporting task.\n## Notification Topic\nThe name of this topic is \"incident\"\n## Subscribe Condition\nFault level is \"high\"\n## Notification Data Format\nReport Incident data via DataPart\n## Expected Output\n1. Subscription result, success or failure\n2. Reason for subscription failure (optional)")) + .build())) + // The URL ends with "/"; a2a-java RestTransport appends message:send / message:stream to it + .supportedInterfaces(List.of( + new AgentInterface("HTTP+JSON", "http://127.0.0.1:26335/", "", "1.0"))) + .build(); +} +``` -```properties -A2AT_LANGUAGE=zh-CN +- Reference server AgentCard JSON message: + +```json +{ + "agentCards": [ + { + "name": "RAN Domain Agent", + "description": "RAN Domain Agent", + "provider": { + "organization": "Huawei", + "url": "https://www.huawei.com" + }, + "version": "1.0.0", + "capabilities": { + "streaming": true, + "pushNotifications": false, + "extendedAgentCard": false, + "extensions": [ + { + "description": "Extension of structured prompt TASK-T requests.", + "required": false, + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1" + }, + { + "description": "Extension of structured prompt Notification-T requests.", + "required": false, + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1" + } + ] + }, + "defaultInputModes": [ + "application/json", + "text/plain" + ], + "defaultOutputModes": [ + "application/json", + "text/plain" + ], + "skills": [ + { + "id": "ran-incident-subscription", + "name": "Incident Reporting", + "description": "Supports Incident reporting and provides intelligent fault identification and diagnosis capabilities", + "tags": [ + "Incident Reporting" + ], + "examples": [ + "## Subscription Description\\nBased on the following \\u003cNotification Topic\\u003e, \\u003cSubscribe Condition\\u003e, \\u003cNotification Data Format\\u003e, and \\u003cExpected Output\\u003e information, complete the network-side intelligent fault Incident subscription and reporting task.\\n## Notification Topic\\nThe name of this topic is \\\"incident\\\"\\n## Subscribe Condition\\nFault level is \\\"high\\\"\\n## Notification Data Format\\nReport Incident data via DataPart\\n## Expected Output\\n1. Subscription result, success or failure\\n2. Reason for subscription failure (optional)" + ], + "inputModes": [ + "application/json", + "text/plain" + ], + "outputModes": [ + "application/json", + "text/plain" + ] + } + ], + "securitySchemes": { + "bearerAuth": { + "httpAuthSecurityScheme": { + "scheme": "Bearer", + "description": "Query the accessSession through the login API with the username and password, and then use the accessSession for bearer authentication." + } + } + }, + "securityRequirements": [], + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "url": "https://10.xx.xx.xx:27417/a2a/v1", + "tenant": "", + "protocolVersion": "1.0" + }, + { + "protocolBinding": "HTTP+JSON", + "url": "https://10.xx.xx.xx:27417/a2a/json", + "tenant": "", + "protocolVersion": "1.0" + } + ] + } + ] +} ``` -### 1.10.7 End-to-End Example +#### Step6-Step7 Receive and Validate the Message on the Server -An information negotiation between a client that proposes (asking for missing data) and a server that accepts and returns the data, using the content layer on both sides: +When the server is hosted by a2a-java, protocol parsing, task creation, and event queue management are handled by the SDK. The business side implements the official `AgentExecutor` interface: in the `execute` callback, take the processed task prompt out of the message `metadata` field (keyed by the extension URI), pass it to `A2ATServer.checkTaskPrompt` for validation, and push task status and result events through `AgentEmitter`: ```java +import java.nio.file.Path; import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.negotiation.content.*; +import java.util.UUID; import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.TextPart; + +public final class IncidentSubscriptionExecutor implements AgentExecutor { + + private static final String NOTIFICATION_PROMPT_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private final A2ATServer server; + + public IncidentSubscriptionExecutor(A2ATServer server) { + this.server = server; + } + + @Override + public void execute(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + // 1) Take the A2A-T extension message out of the message metadata (keyed by the extension URI) + String processedPrompt = extractPrompt(requestContext.getMessage()); + if (processedPrompt.isBlank()) { + agentEmitter.reject(statusMessage(requestContext, "missing A2A-T task prompt")); + return; + } + agentEmitter.submit(statusMessage(requestContext, "Subscription accepted, starting Incident reporting task")); + + // 2) Use the SDK capability to validate the completeness of the A2A-T protocol message + PromptComplianceResult checkResult = server.checkTaskPrompt(processedPrompt); + if (!checkResult.success()) { + // Validation failed; failure carries code, message, and stage + agentEmitter.reject(statusMessage(requestContext, + "Prompt validation failed: " + checkResult.failure().message())); + return; + } + + // 3) Validation passed; execute the business and push task events through AgentEmitter + agentEmitter.startWork(statusMessage(requestContext, "Incident reporting task in progress")); + agentEmitter.addArtifact( + List.of(new DataPart(Map.of( + "faultManagement.Incident", Map.of( + "csn", 1673735459373056L, + "name", "LASER_MOD_ERR", + "domain", "PTN", + "priority", "high", + "status", "unacknowledged-and-uncleared")))), + "faultManagement.Incident", + "Incident artifact", + null); + agentEmitter.complete(statusMessage(requestContext, "Incident reporting completed")); + } + + @Override + public void cancel(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + agentEmitter.cancel(); + } + + private static String extractPrompt(Message message) { + if (message == null || message.metadata() == null) { + return ""; + } + Object prompt = message.metadata().get(NOTIFICATION_PROMPT_EXT); + return prompt == null ? "" : String.valueOf(prompt); + } + + private static Message statusMessage(RequestContext requestContext, String text) { + return Message.builder() + .messageId(UUID.randomUUID().toString()) + .contextId(requestContext.getContextId()) + .taskId(requestContext.getTaskId()) + .role(Message.Role.ROLE_AGENT) + .parts(List.of(new TextPart(text))) + .build(); + } +} +``` -// --- Client side: propose, asking for two missing fields --- -A2ATClient client = new A2ATClient(Path.of("client.env")); +#### Step8 Fill in the A2A Response Headers and Return the Response -NegotiationContext context = NegotiationContext.of("neg-0001-uuid", 1, NegotiationPerformative.PROPOSE); -InformationProposeContent content = new InformationProposeContent( - List.of( - new NegotiationItem("subscription_condition.incident_level", "critical or warning"), - new NegotiationItem("notification_data.format", "DataPart or raw JSON")), - null); - -MetadataContent propose = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData(context, content), - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); - -// This map travels in the A2A message metadata. -Map metadata = propose.buildMetadataContent(); -// metadata.get("templateUri") -> "Negotiation-T/information-negotiation/propose/v1" -// metadata.get(propose.extensionUri()) -> the rendered message text -// metadata.get("negotiationContext") -> the nested {id, round, maxRounds, performative} context -// (performative is "PROPOSE" for this message) - -// --- Server side: validate the received message and extract parameters --- -A2ATServer server = new A2ATServer(Path.of("server.env")); +| Header | Direction | Required | Value | +| ------ | --------- | -------- | ----- | +| `A2A-Extensions` | Response header | No (recommended when using extensions) | List of extension URIs (comma-separated) actually engaged by the server | -Map schema = Map.of( - "type", "object", - "properties", Map.of( - "subscription_condition.incident_level", Map.of("type", "string"), - "notification_data.format", Map.of("type", "string")), - "required", List.of("subscription_condition.incident_level")); - -try { - FilledParamData params = server.validateProposePromptAndDataFilling( - propose.promptText(), context, schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); - // params.data() holds the extracted parameters plus the context parameters. -} catch (A2ATParamExtractionError failure) { - // Branch on failure.getCode(): negotiation_invalid_input, negotiation_rule_violation, - // negotiation_semantic_rejected, negotiation_llm_infrastructure_error, template_not_found. -} +When the server is hosted by the a2a-java REST transport, the protocol layer (validation of the protocol version and extension declarations in requests, task and event management, and SSE streaming responses) is handled by the SDK transport layer; the business side only needs to push task status and result events through `AgentEmitter`. If the business system implements its own HTTP hosting, it must fill in the `A2A-Extensions` header in responses as shown in the table above. + +#### Complete Sample Server Code -// --- Server side: accept and return the requested information --- -InformationEndingContent ending = new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of(new NegotiationItem("subscription_condition.incident_level", "critical"))); +The server exposes the AgentCard and the AgentExecutor to a2a-java through CDI producers, registers the AgentCard with the registry center at startup, and completes A2A-T message extraction, validation, and business execution in the `AgentExecutor`. The complete code is as follows: -MetadataContent accept = server.generateNegotiationAcceptPromptFromData( - new NegotiationEndingData(context, ending), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); +```java +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.Produces; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.a2aproject.sdk.server.PublicAgentCard; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentExtension; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentProvider; +import org.a2aproject.sdk.spec.AgentSkill; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.TextPart; + +@ApplicationScoped +public class ServerSample { + + private static final String TASK_T_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; + private static final String NOTIFICATION_PROMPT_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // 1) CDI producer: expose the server AgentCard to a2a-java (same definition as in Step3) + @Produces + @PublicAgentCard + public AgentCard agentCard() { + return AgentCard.builder() + .name("RAN Domain Agent") + .description("RAN Domain Agent") + .provider(new AgentProvider("Huawei", "https://www.huawei.com")) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .extendedAgentCard(false) + .extensions(List.of( + AgentExtension.builder() + .uri(TASK_T_EXT) + .description("Extension of structured prompt TASK-T requests.") + .required(false) + .build(), + AgentExtension.builder() + .uri(NOTIFICATION_PROMPT_EXT) + .description("Extension of structured prompt Notification-T requests.") + .required(false) + .build())) + .build()) + .defaultInputModes(List.of("application/json", "text/plain")) + .defaultOutputModes(List.of("application/json", "text/plain")) + .skills(List.of(AgentSkill.builder() + .id("ran-incident-subscription") + .name("Incident Reporting") + .description("Supports Incident reporting and provides intelligent fault identification and diagnosis capabilities") + .tags(List.of("Incident Reporting")) + .examples(List.of( + "## Subscription Description\nBased on the following , , , and information, complete the network-side intelligent fault Incident subscription and reporting task.\n## Notification Topic\nThe name of this topic is \"incident\"\n## Subscribe Condition\nFault level is \"high\"\n## Notification Data Format\nReport Incident data via DataPart\n## Expected Output\n1. Subscription result, success or failure\n2. Reason for subscription failure (optional)")) + .build())) + // The URL ends with "/"; a2a-java RestTransport appends message:send / message:stream to it + .supportedInterfaces(List.of( + new AgentInterface("HTTP+JSON", "http://127.0.0.1:26335/", "", "1.0"))) + .build(); + } + + // 2) CDI producer: initialize the A2A-T server and wire it into the AgentExecutor + @Produces + public AgentExecutor agentExecutor() { + return new IncidentSubscriptionExecutor(new A2ATServer(Path.of("server.env"))); + } + + // 3) Register the server AgentCard with the registry center at startup (the address depends on the actual deployment) + void registerOnStartup(@Observes StartupEvent event) throws Exception { + String payload = MAPPER.writeValueAsString(Map.of("agentCards", List.of(agentCard()))); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://{ip:port}/rest/v1/registry-center/agent-cards")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + HttpResponse response = HttpClient.newHttpClient() + .send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 201) { + throw new IllegalStateException("AgentCard registration failed: " + response.statusCode()); + } + } + + // 4) A2A-T task executor: receive and validate A2A-T messages (same as Step6-Step7) + static final class IncidentSubscriptionExecutor implements AgentExecutor { + + private final A2ATServer server; + + IncidentSubscriptionExecutor(A2ATServer server) { + this.server = server; + } + + @Override + public void execute(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + // 4.1) Take the A2A-T extension message out of the message metadata (keyed by the extension URI) + String processedPrompt = extractPrompt(requestContext.getMessage()); + if (processedPrompt.isBlank()) { + agentEmitter.reject(statusMessage(requestContext, "missing A2A-T task prompt")); + return; + } + agentEmitter.submit(statusMessage(requestContext, "Subscription accepted, starting Incident reporting task")); + + // 4.2) Use the SDK capability to validate the completeness of the A2A-T protocol message + PromptComplianceResult checkResult = server.checkTaskPrompt(processedPrompt); + if (!checkResult.success()) { + // Validation failed; failure carries code, message, and stage + agentEmitter.reject(statusMessage(requestContext, + "Prompt validation failed: " + checkResult.failure().message())); + return; + } + + // 4.3) Validation passed; execute the business and push task events through AgentEmitter + agentEmitter.startWork(statusMessage(requestContext, "Incident reporting task in progress")); + agentEmitter.addArtifact( + List.of(new DataPart(Map.of( + "faultManagement.Incident", Map.of( + "csn", 1673735459373056L, + "name", "LASER_MOD_ERR", + "domain", "PTN", + "priority", "high", + "status", "unacknowledged-and-uncleared")))), + "faultManagement.Incident", + "Incident artifact", + null); + agentEmitter.complete(statusMessage(requestContext, "Incident reporting completed")); + } + + @Override + public void cancel(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + agentEmitter.cancel(); + } + + private static String extractPrompt(Message message) { + if (message == null || message.metadata() == null) { + return ""; + } + Object prompt = message.metadata().get(NOTIFICATION_PROMPT_EXT); + return prompt == null ? "" : String.valueOf(prompt); + } + + private static Message statusMessage(RequestContext requestContext, String text) { + return Message.builder() + .messageId(UUID.randomUUID().toString()) + .contextId(requestContext.getContextId()) + .taskId(requestContext.getTaskId()) + .role(Message.Role.ROLE_AGENT) + .parts(List.of(new TextPart(text))) + .build(); + } + } +} +``` -// The client validates the accept message the same way, with the accept-reject template: -// client.validateAcceptPromptAndDataFilling(accept.promptText(), context, schema, -// StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); +Configure the service listening address in `src/main/resources/application.properties`: + +```properties +quarkus.http.host=127.0.0.1 +quarkus.http.port=26335 ``` -When modifying the negotiation content layer, run: +Start the server: ```bash -mvn -pl a2a-t-negotiation -am test +mvn clean package +java -jar target/quarkus-app/quarkus-run.jar ``` + +After startup, the server will: + +1. Register the AgentCard with the registry center. +2. Start the A2A REST service hosted by a2a-java (`message:send` / `message:stream`). +3. Wait for client requests, validate the A2A-T prompt in the `AgentExecutor`, execute the business, and push task events. + +> This example completes the task within a single `execute()` call (the task enters a terminal state after `complete()`): in `message:send` synchronous mode, the client gets the aggregated final Task in one HTTP response (intermediate states are persisted only on the server); in `message:stream` streaming mode, the client receives WORKING, artifact, and COMPLETED events frame by frame. For a long-running subscription-reporting task (looping `addArtifact` without calling `complete`), refer to the `subscribe_incident` use case in `a2a-t-sample`. +> +> This sample is hosted by the official a2a-java REST reference server (Quarkus). The `a2a-t-sample` module of this repository also provides a Quarkus-free embedded end-to-end sample (a real HTTP+JSON/REST streaming pipeline, plus negotiation and authorization scenarios); see [a2a-t-sample/README.zh-CN.md](../../a2a-t-sample/README.zh-CN.md). + + + diff --git "a/docs/zh/API\345\217\202\350\200\203.md" "b/docs/zh/API\345\217\202\350\200\203.md" new file mode 100644 index 00000000..2e391760 --- /dev/null +++ "b/docs/zh/API\345\217\202\350\200\203.md" @@ -0,0 +1,1572 @@ +# 1 API参考 + +## 1.1 简介 + +A2A-T SDK 的对外 API 收敛在两个门面上:客户端门面 `A2ATClient`(提示词生成、协商消息生成与校验)与服务端门面 `A2ATServer`(提示词校验、协商消息生成与校验)。Negotiation-T 接口在两个门面上签名与语义完全对称;Task-T / Notification-T / Authorization-T 的生成接口位于 `A2ATClient`,校验接口位于 `A2ATServer`。 + +- **API 总览**: + +| 分类 | API定义 | Client/Server | API含义 | 是否涉及LLM | +| ---- | ------- | -------- | ------- |---------| +| Negotiation-T | `generateNegotiationProposePromptFromText` | A2A-T Client / A2A-T Server | 从自然语言文本生成协商发起(propose)报文 | 是(1次LLM内容抽取) | +| Negotiation-T | `generateNegotiationAcceptPromptFromText` | A2A-T Client / A2A-T Server | 从自然语言文本生成协商接受(accept)报文 | 是(1次LLM内容抽取) | +| Negotiation-T | `generateNegotiationRejectPromptFromText` | A2A-T Client / A2A-T Server | 从自然语言文本生成协商拒绝(reject)报文 | 是(1次LLM内容抽取) | +| Negotiation-T | `generateNegotiationProposePromptFromData` | A2A-T Client / A2A-T Server | 从结构化数据确定性生成协商发起报文(不调用LLM) | 否 | +| Negotiation-T | `generateNegotiationAcceptPromptFromData` | A2A-T Client / A2A-T Server | 从结构化数据确定性生成协商接受报文(不调用LLM) | 否 | +| Negotiation-T | `generateNegotiationRejectPromptFromData` | A2A-T Client / A2A-T Server | 从结构化数据确定性生成协商拒绝报文(不调用LLM) | 否 | +| Negotiation-T | `validateProposePromptAndDataFilling` | A2A-T Client / A2A-T Server | 校验协商发起报文合规性并按Schema提取参数 | 是(1次LLM语义校验) | +| Negotiation-T | `validateAcceptPromptAndDataFilling` | A2A-T Client / A2A-T Server | 校验协商接受报文合规性并按Schema提取参数 | 是(1次LLM语义校验) | +| Negotiation-T | `validateRejectPromptAndDataFilling` | A2A-T Client / A2A-T Server | 校验协商拒绝报文合规性并按Schema提取参数 | 是(1次LLM语义校验) | +| Task-T | `generateTaskPromptFromText` | A2A-T Client | 从自然语言文本按指定Task-T模板生成任务提示词(跳过场景识别) | 是(1次LLM槽位提取) | +| Task-T | `generateTaskPromptFromDataWithSchema` | A2A-T Client | 从结构化数据+语义Schema按指定Task-T模板生成任务提示词 | 是(1次LLM槽位提取) | +| Task-T | `validateTaskPromptAndDataFilling` | A2A-T Server | 校验Task-T任务提示词合规性并按Schema提取参数 | 是(1次LLM语义校验与提参) | +| Notification-T | `generateNotificationPromptFromText` | A2A-T Client | 从自然语言文本按指定Notification-T模板生成通知订阅提示词 | 是(1次LLM槽位提取) | +| Notification-T | `generateNotificationPromptFromDataWithSchema` | A2A-T Client | 从结构化数据+语义Schema按指定Notification-T模板生成通知订阅提示词 | 是(1次LLM槽位提取) | +| Notification-T | `validateNotificationPromptAndDataFilling` | A2A-T Server | 校验Notification-T提示词合规性并按Schema提取参数 | 是(1次LLM语义校验与提参) | +| Authorization-T | `generateAuthPromptFromText` | A2A-T Client | 从自然语言文本按指定Authorization-T模板生成授权提示词 | 是(1次LLM槽位提取) | +| Authorization-T | `generateAuthPromptFromDataWithSchema` | A2A-T Client | 从结构化数据+语义Schema按指定Authorization-T模板生成授权提示词 | 是(1次LLM槽位提取) | +| Authorization-T | `validateAuthPromptAndDataFilling` | A2A-T Server | 校验Authorization-T提示词合规性并按Schema提取参数 | 是(1次LLM语义校验与提参) | +| 通用接口 | `generateTaskPrompt` | A2A-T Client | 从自然语言或结构化输入经场景识别生成任务提示词 | 是(2次LLM调用:场景识别+槽位提取) | +| 通用接口 | `checkTaskPrompt` | A2A-T Server | 校验任务提示词的场景、模板与槽位合规性 | 是(3次LLM调用:场景识别+槽位提取+语义校验) | + +**公共数据类型与约定** + +- **TemplateUri:**模板 URI 值类型,推荐用 `net.openan.a2at.sdk.core.model.StandardTemplates` 常量构造,如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`(URI 为 `Negotiation-T/information-negotiation/propose/v1`);来自外部的字符串用 `TemplateUri.parse(String)` 解析,返回 `Optional` 且不抛异常。 +- **协商会话上下文:**`NegotiationContext(id, round, maxRounds)`(id 为 UUID 形态、round 从 1 起、默认轮次预算 `DEFAULT_MAX_ROUNDS = 5`),随消息 metadata 传输、不经 LLM;`NegotiationContext.of(id, round)` 使用默认预算,`nextRound()` 推进轮次。 +- **异常体系:**所有 SDK 处理失败均为 `A2ATError` 子类——生成失败抛 `PromptGenerationException`(Task-T / Notification-T / Authorization-T)或 `NegotiationGenerationException`(Negotiation-T),校验+提参失败抛 `ContentValidationException`(Task-T / Notification-T / Authorization-T,携带 `errors()` 槽位错误明细与 `params()` 部分提取参数)或 `NegotiationParamExtractionException`(Negotiation-T)。捕获 `A2ATError` 即可覆盖全部处理失败,`getCode()` 获取机器可读错误码。 +- **SlotValidationError:**逐槽位校验错误明细,随校验失败异常或失败负载返回,各节“输出说明”中的失败结构均引用此定义: + +| 字段 | 类型 | 说明 | +| ---- | ---- | ---- | +| slotName | String | 出错槽位名 | +| code | String | 槽位级错误码,如 `missing_required`(必填缺失)、`invalid_value`(取值非法)、`format_error`(格式错误) | +| message | String | 槽位级错误说明 | + + +## 1.2 约束和限制 + +部分接口涉及到LLM调用,使用时需根据对接模型服务可提供的并发能力控制使用时的调用频率和并发 + +## 1.3 API说明 + +### 1.3.1 generateNegotiationProposePromptFromText + +**API定义** + +```java +public MetadataContent generateNegotiationProposePromptFromText( + String text, NegotiationContext context, TemplateUri templateUri) +``` + +**典型场景**:服务端Agent收到参数不全的Task-T任务报文后,用自然语言向客户端Agent发起"补充缺失信息"的协商请求;也适用于客户端Agent向服务端发起目标澄清或可行性评估请求。 + +**功能说明**:从自然语言文本生成协商发起(propose)阶段的结构化协商报文。执行流程:先加载模板(缺失快速失败,不消耗 LLM 请求),再执行一次 LLM 内容抽取(在模板 URI 约束下将自由文本抽取为类型化内容,可重试错误码上最多重试至 `A2AT_LLM_MAX_ATTEMPTS` 配置的上限),最后确定性渲染模板。适用于信息/目标/可行性协商的发起方。`templateUri` 的 phase 段必须为 `propose`。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| text | String | 是 | 描述协商发起内容的自然语言文本(如请求补充的缺失信息清单) | +| context | NegotiationContext | 是 | 协商会话上下文,不经 LLM 直接注入生成消息的 `negotiationContext` metadata | +| templateUri | TemplateUri | 是 | propose 模板,如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE` | + +**请求样例** + +```java +import java.nio.file.Path; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.StandardTemplates; + +A2ATClient client = new A2ATClient(Path.of("client.env")); + +NegotiationContext ctx = new NegotiationContext( + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, NegotiationContext.DEFAULT_MAX_ROUNDS); + +MetadataContent propose = client.generateNegotiationProposePromptFromText( + "请提供以下缺失信息:1. 接入端口名称:请提供业务接入端口名称;" + + "2. 投诉分类:专线中断或专线质差。两个参数均为必选,缺少无法启动诊断。", + ctx, + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); + +// 生成的metadata随A2A消息传输 +Map metadata = propose.buildMetadataContent(); +``` + +**输出说明** + +成功时返回 `MetadataContent`: + +| 字段/方法 | 类型 | 说明 | +| --------- | ---- | ---- | +| templateUri | String | 生成报文所用模板 URI,如 `Negotiation-T/information-negotiation/propose/v1` | +| promptText | String | 渲染后的协商报文文本,作为 A2A 消息 metadata 中扩展 URI 对应的值传输 | +| extensionUri | String | TMF 扩展 URI(`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`),即报文在 metadata 中的 key | +| negotiationContext | NegotiationContext | 协商会话上下文(id / round / maxRounds),不经 LLM 随报文携带 | +| buildMetadataContent() | Map<String, Object> | 构建可直接放入 `Message.metadata` 的映射:扩展 URI → 报文文本、`templateUri` → 模板 URI、`negotiationContext` → 嵌套上下文对象 | + +失败时抛 `NegotiationGenerationException`(`A2ATError` 子类,运行期异常): + +| 成员 | 类型 | 说明 | +| ---- | ---- | ---- | +| getCode() | String | 机器可读错误码,取值见下 | +| getMessage() | String | 人类可读的失败描述 | +| getCause() | Throwable | 根因异常,可为 null | + +错误码: + +- `template_not_found`(模板或提示词资源缺失) + +- `negotiation_content_extract_failed`(无法从文本抽取结构化内容,可重试) + +- `negotiation_llm_infrastructure_error`(LLM 基础设施故障,可重试) + +- `negotiation_invalid_input`(文本为空、抽取内容与阶段不符) + +- `negotiation_slot_missing`(缺少必需槽位) + +入参为 null 抛 `NullPointerException`,templateUri 的 phase 段不是 `propose` 抛 `IllegalArgumentException`。 + +**响应样例** + +```text +templateUri : Negotiation-T/information-negotiation/propose/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 +promptText : +## 信息协商 +请根据<所需信息项>补充相关内容。 + +## 所需信息项 +1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1 +2. 投诉分类:举例:专线质差 +3. 专线业务标识 +缺失项之间的关系:OR +``` + +### 1.3.2 generateNegotiationAcceptPromptFromText + +**API定义** + +```java +public MetadataContent generateNegotiationAcceptPromptFromText( + String text, NegotiationContext context, TemplateUri templateUri) +``` + +**典型场景**:协商响应方(通常是客户端Agent)收到对端的信息协商请求后,用自然语言补充/交付所请求的信息并生成接受报文回传,如补齐接入端口名称与投诉分类后确认启动诊断。 + +**功能说明**:从自然语言文本生成协商接受(accept)报文。一次 LLM 内容抽取(抽取出的结论必须为 `ACCEPT`,否则以 `negotiation_invalid_input` 拒绝)+ 确定性渲染。适用于协商响应方补充/交付信息。`templateUri` 的 phase 段必须为 `accept-reject`。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| text | String | 是 | 描述接受内容的自然语言文本(如补充交付的信息清单) | +| context | NegotiationContext | 是 | 协商会话上下文 | +| templateUri | TemplateUri | 是 | accept-reject 模板,如 `StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT` | + +**请求样例** + +```java +MetadataContent accept = client.generateNegotiationAcceptPromptFromText( + "同意补充以下信息:1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1;" + + "2. 投诉分类:专线质差。信息已完整,可以启动诊断。", + ctx, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.1](#131-generatenegotiationproposepromptfromtext))。 + +失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: + +- `template_not_found`(模板或提示词资源缺失) + +- `negotiation_content_extract_failed`(无法从文本抽取结构化内容,可重试) + +- `negotiation_llm_infrastructure_error`(LLM 基础设施故障,可重试) + +- `negotiation_invalid_input`(文本为空或抽取结论不是 `ACCEPT`) + +- `negotiation_slot_missing`(缺少必需槽位) + +**响应样例** + +```text +templateUri : Negotiation-T/information-negotiation/accept-reject/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 +promptText : +## 信息协商结果 +Accept + +## 信息协商结果内容 +1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1 +2. 投诉分类:专线质差 +``` + +### 1.3.3 generateNegotiationRejectPromptFromText + +**API定义** + +```java +public MetadataContent generateNegotiationRejectPromptFromText( + String text, NegotiationContext context, TemplateUri templateUri) +``` + +**典型场景**:协商响应方(通常是客户端Agent)无法满足对端的协商请求时,用自然语言生成拒绝报文回传并结束本轮协商,如因站点清单不可用无法提供接入端口名称。 + +**功能说明**:从自然语言文本生成协商拒绝(reject)报文。一次 LLM 内容抽取(抽取出的结论必须为 `REJECT`)+ 确定性渲染。`templateUri` 的 phase 段必须为 `accept-reject`。 + +**输入说明**:同 [generateNegotiationAcceptPromptFromText](#132-generatenegotiationacceptpromptfromtext),text 为描述拒绝原因的自然语言文本。 + +**请求样例** + +```java +MetadataContent reject = client.generateNegotiationRejectPromptFromText( + "拒绝补充信息:接入端口名称因站点清单不可用而无法提供,本次协商结束。", + ctx, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.1](#131-generatenegotiationproposepromptfromtext))。 + +失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: + +- `template_not_found`(模板或提示词资源缺失) + +- `negotiation_content_extract_failed`(无法从文本抽取结构化内容,可重试) + +- `negotiation_llm_infrastructure_error`(LLM 基础设施故障,可重试) + +- `negotiation_invalid_input`(文本为空或抽取结论不是 `REJECT`) + +- `negotiation_slot_missing`(缺少必需槽位) + +**响应样例** + +```text +templateUri : Negotiation-T/information-negotiation/accept-reject/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 +promptText : +## 信息协商结果 +Reject + +## 信息协商结果内容 +1. 接入端口名称:无法提供,工作台侧端口资源台账暂不可查 +``` + +### 1.3.4 generateNegotiationProposePromptFromData + +**API定义** + +```java +public MetadataContent generateNegotiationProposePromptFromData( + NegotiationProposeData data, TemplateUri templateUri) +``` + +**典型场景**:与 fromText 相同的发起场景,但输入为业务系统构造的结构化数据(如服务端Agent根据 `validateTaskPromptAndDataFilling` 检出的缺失槽位清单自动生成协商请求条目),适合对报文内容有确定性要求、不希望引入 LLM 抽取不确定性的场景。 + +**功能说明**:从类型化数据确定性生成协商发起报文,**不调用 LLM**:类型化内容先经校验,再分发给模板 URI 所指协商类型的生成器渲染。`content` 必须与 `templateUri` 的协商类型匹配(information / target / feasibility),phase 段必须为 `propose`。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| data | `NegotiationProposeData(context, content)` | 是 | 协商上下文 + 类型化发起内容;内容类型按协商类型选择 | +| templateUri | TemplateUri | 是 | propose 模板 | + +三种协商类型的发起内容: + +| 协商类型 | Propose 内容类型 | 字段 | +| -------- | ---------------- | ---- | +| 信息协商 | `InformationProposeContent` | `items`(缺失项清单)、`relationship`(缺失项间关系,可空) | +| 目标协商 | `TargetProposeContent` | `targetNegotiationDescription`(必填)、`intentUnderstanding`、`alignmentAndClarification`、`requestForClarification`(三个条目列表均可空,空则省略对应章节) | +| 可行性协商 | `FeasibilityProposeContent` | `feasibilityNegotiationDescription`(必填)、`action`(`NegotiationAction.REQUEST_FEASIBILITY_EVALUATION` / `PROPOSE_ALTERNATIVE_ON_FAILURE`)、`contentsToEvaluate`、`infeasibilityDetailsAndProposal` | + +**请求样例** + +```java +import java.util.List; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; + +NegotiationContext ctx = new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5); + +MetadataContent propose = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + ctx, + new InformationProposeContent( + List.of( + new NegotiationItem("接入端口名称", "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1"), + new NegotiationItem("投诉分类", "举例:专线质差"), + new NegotiationItem("专线业务标识", null)), + "OR")), + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.1](#131-generatenegotiationproposepromptfromtext),协商报文均携带 `negotiationContext`)。 + +失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: + +- `template_not_found`(模板缺失) + +- `negotiation_slot_missing`(渲染缺少必需槽位) + +另有两类编程错误(`A2ATError` 树外,标准 JDK 异常):入参或其 context 为 null 抛 `NullPointerException`;内容类型与模板协商类型不符、phase 段不是 `propose` 抛 `IllegalArgumentException`。 + +**响应样例** + +```text +templateUri : Negotiation-T/information-negotiation/propose/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 +promptText : +## 信息协商 +请根据<所需信息项>补充相关内容。 + +## 所需信息项 +1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1 +2. 投诉分类:举例:专线质差 +3. 专线业务标识 +缺失项之间的关系:OR +``` + +### 1.3.5 generateNegotiationAcceptPromptFromData + +**API定义** + +```java +public MetadataContent generateNegotiationAcceptPromptFromData( + NegotiationEndingData data, TemplateUri templateUri) +``` + +**典型场景**:协商响应方(通常是客户端Agent)按对端请求的槽位清单程序化补参后,以结构化条目生成接受报文回传。 + +**功能说明**:从类型化数据确定性生成协商接受报文,**不调用 LLM**。`content.conclusion()` 必须为 `ACCEPT`,其他结论(含 `ABORT`)以 `IllegalArgumentException` 拒绝。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| data | `NegotiationEndingData(context, content)` | 是 | 协商上下文 + 类型化接受内容(结论为 `ACCEPT`) | +| templateUri | TemplateUri | 是 | accept-reject 模板 | + +三种协商类型的接受内容:`InformationEndingContent(ACCEPT, items)`(交付的信息项清单)、`TargetEndingContent(ACCEPT, confirmedIntent, null)`(最终确认的意图)、`FeasibilityEndingContent(ACCEPT, feasibilitySummary)`(可行性评估结论摘要)。 + +**请求样例** + +```java +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; + +MetadataContent accept = client.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData( + ctx, + new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of( + new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1"), + new NegotiationItem("投诉分类", "专线质差")))), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.1](#131-generatenegotiationproposepromptfromtext))。 + +失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: + +- `template_not_found`(模板缺失) + +- `negotiation_slot_missing`(渲染缺少必需槽位) + +编程错误:入参或其 context 为 null 抛 `NullPointerException`;`conclusion` 不是 `ACCEPT`、phase 段不是 `accept-reject` 或内容类型不符抛 `IllegalArgumentException`。 + +**响应样例** + +```text +templateUri : Negotiation-T/information-negotiation/accept-reject/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 +promptText : +## 信息协商结果 +Accept + +## 信息协商结果内容 +1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1 +2. 投诉分类:专线质差 +``` + +### 1.3.6 generateNegotiationRejectPromptFromData + +**API定义** + +```java +public MetadataContent generateNegotiationRejectPromptFromData( + NegotiationEndingData data, TemplateUri templateUri) +``` + +**典型场景**:协商响应方程序化判定无法满足对端请求后,以结构化条目(无法提供的项及原因)生成拒绝报文回传。 + +**功能说明**:从类型化数据确定性生成协商拒绝报文,**不调用 LLM**。`content.conclusion()` 必须为 `REJECT`,其他结论以 `IllegalArgumentException` 拒绝。 + +**输入说明**:同 [generateNegotiationAcceptPromptFromData](#135-generatenegotiationacceptpromptfromdata),但结论为 `REJECT`。拒绝内容:`InformationEndingContent(REJECT, items)`(无法提供的项及原因)、`TargetEndingContent(REJECT, null, failureReason)`(拒绝原因)、`FeasibilityEndingContent(REJECT, feasibilitySummary)`(不可行结论摘要)。 + +**请求样例** + +```java +MetadataContent reject = client.generateNegotiationRejectPromptFromData( + new NegotiationEndingData( + ctx, + new InformationEndingContent( + NegotiationConclusion.REJECT, + List.of(new NegotiationItem("接入端口名称", "无法提供,工作台侧端口资源台账暂不可查")))), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.1](#131-generatenegotiationproposepromptfromtext))。 + +失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: + +- `template_not_found`(模板缺失) + +- `negotiation_slot_missing`(渲染缺少必需槽位) + +编程错误:入参或其 context 为 null 抛 `NullPointerException`;`conclusion` 不是 `REJECT`、phase 段不是 `accept-reject` 或内容类型不符抛 `IllegalArgumentException`。 + +**响应样例** + +```text +templateUri : Negotiation-T/information-negotiation/accept-reject/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 +promptText : +## 信息协商结果 +Reject + +## 信息协商结果内容 +1. 接入端口名称:无法提供,工作台侧端口资源台账暂不可查 +``` + +### 1.3.7 validateProposePromptAndDataFilling + +**API定义** + +```java +public FilledParamData validateProposePromptAndDataFilling( + String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) +``` + +**典型场景**:发起方(服务端Agent)发送协商请求前的出站自检;或接收方(客户端Agent)校验收到的协商请求并提取需补充的槽位清单,驱动后续补参。 + +**功能说明**:校验一条协商发起(propose)报文是否为格式正确的协商消息,并按调用方提供的 JSON Schema 从中提取参数。流水线:模板 URI phase 段检查 → 确定性规则门禁(context 的 id 为 UUID 形态、轮次不超预算;context 为 null 报 `negotiation_invalid_input`)→ 一次 LLM 语义校验调用(同时完成参数提取,可重试错误码上最多重试至配置上限)→ 参数合并(协商上下文参数 `id` / `round` / `maxRounds` 与提取参数合并,键冲突时**上下文参数优先**)。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| prompt | String | 是 | 待校验的协商发起报文文本(`MetadataContent.promptText()`) | +| context | NegotiationContext | 否 | 随报文传输的协商上下文;null 报 `negotiation_invalid_input` | +| schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema,声明要提取的参数 | +| templateUri | TemplateUri | 是 | propose 模板 | + +**请求样例** + +```java +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.StandardTemplates; + +A2ATClient client = new A2ATClient(Path.of("client.env")); + +Map schema = Map.of( + "type", "object", + "properties", Map.of( + "接入端口名称", Map.of("type", "string"), + "投诉分类", Map.of("type", "string")), + "required", List.of("接入端口名称")); + +// proposePrompt 为对端发来的协商发起报文文本 +FilledParamData requested = client.validateProposePromptAndDataFilling( + proposePrompt, ctx, schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); + +// 过滤掉上下文参数(id/round/maxRounds)后即为需补充的槽位清单 +requested.data().keySet().removeAll(List.of("id", "round", "maxRounds")); +``` + +**输出说明** + +成功时返回 `FilledParamData`: + +| 字段/方法 | 类型 | 说明 | +| --------- | ---- | ---- | +| data() | Map<String, Object> | 合并后的参数:协商上下文参数(`id` / `round` / `maxRounds`,键冲突时**优先**)+ 按调用方 Schema 从报文中提取的参数 | + +失败时抛 `NegotiationParamExtractionException`(`A2ATError` 子类): + +| 成员 | 类型 | 说明 | +| ---- | ---- | ---- | +| getCode() | String | 机器可读错误码,取值见下 | +| getMessage() | String | 人类可读的失败描述 | +| getErrors() | List<SlotValidationError> | 逐槽位错误明细,结构见公共约定 | + +错误码: + +- `negotiation_invalid_input`(报文不是协商消息或 context 为 null) + +- `negotiation_rule_violation`(协商上下文违反结构规则,如 id 非 UUID 形态、轮次超预算) + +- `negotiation_semantic_rejected`(语义校验拒绝) + +- `negotiation_llm_infrastructure_error`(LLM 基础设施故障,可重试) + +- `template_not_found`(校验提示词资源缺失) + +编程错误:prompt 或 schema 为 null 抛 `NullPointerException`,prompt 为空白或 phase 段不匹配抛 `IllegalArgumentException`。 + +**响应样例** + +```text +requested.data() = +{ + 接入端口名称=举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1, + 投诉分类=举例:专线质差, + id=3dbc13b5-bd57-4c2b-b503-24e381b6c8d3, + round=1, + maxRounds=5 +} +``` + +### 1.3.8 validateAcceptPromptAndDataFilling + +**API定义** + +```java +public FilledParamData validateAcceptPromptAndDataFilling( + String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) +``` + +**典型场景**:发起方(服务端Agent)校验对端回传的接受报文,提取交付的参数值并与期望补齐值核对,确认无误后继续任务执行。 + +**功能说明**:校验一条协商接受(accept)报文并按 Schema 提取参数。流水线同 [validateProposePromptAndDataFilling](#137-validateproposepromptanddatafilling),区别仅在于:`templateUri` 的 phase 段必须为 `accept-reject`,且报文须满足 accept 阶段的语义约束(结论为 Accept、携带交付内容)。 + +**输入说明**:同 1.3.7,prompt 为 accept 报文文本,templateUri 为 accept-reject 模板。 + +**请求样例** + +```java +import net.openan.a2at.sdk.server.A2ATServer; + +A2ATServer server = new A2ATServer(Path.of("server.env")); + +// acceptPrompt 为客户端回传的接受报文文本,acceptContext 为其协商上下文 +FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( + acceptPrompt, acceptContext, schema, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); +``` + +**输出说明** + +成功时返回 `FilledParamData`(结构同 [1.3.7](#137-validateproposepromptanddatafilling)),`data()` 携带交付内容中按 Schema 提取的参数与上下文参数。 + +失败时抛 `NegotiationParamExtractionException`(结构同 1.3.7)。错误码: + +- `negotiation_invalid_input`(报文不是 accept 协商消息或 context 为 null) + +- `negotiation_rule_violation`(协商上下文违反结构规则) + +- `negotiation_semantic_rejected`(结论不是 Accept 或内容不满足 accept 阶段约束) + +- `negotiation_llm_infrastructure_error`(LLM 基础设施故障,可重试) + +- `template_not_found`(校验提示词资源缺失) + +编程错误:prompt 或 schema 为 null 抛 `NullPointerException`,prompt 为空白或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。 + +**响应样例** + +```text +acceptParams.data() = +{ + 接入端口名称=P533-珠江旧城-PTN3900-23-TPA1EG24-1, + 投诉分类=专线质差, + id=3dbc13b5-bd57-4c2b-b503-24e381b6c8d3, + round=1, + maxRounds=5 +} +``` + +### 1.3.9 validateRejectPromptAndDataFilling + +**API定义** + +```java +public FilledParamData validateRejectPromptAndDataFilling( + String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) +``` + +**典型场景**:发起方(服务端Agent)校验对端回传的拒绝报文,提取拒绝原因,据此终止任务或转入人工处理。 + +**功能说明**:校验一条协商拒绝(reject)报文并按 Schema 提取参数。流水线同 1.3.7,区别仅在于:报文须满足 reject 阶段的语义约束(结论为 Reject、携带拒绝原因)。 + +**输入说明**:同 1.3.7,prompt 为 reject 报文文本,templateUri 为 accept-reject 模板。 + +**请求样例** + +```java +FilledParamData rejectParams = server.validateRejectPromptAndDataFilling( + rejectPrompt, ctx, schema, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); +``` + +**输出说明** + +成功时返回 `FilledParamData`(结构同 [1.3.7](#137-validateproposepromptanddatafilling)),`data()` 携带拒绝原因中按 Schema 提取的参数与上下文参数。 + +失败时抛 `NegotiationParamExtractionException`(结构同 1.3.7)。错误码: + +- `negotiation_invalid_input`(报文不是 reject 协商消息或 context 为 null) + +- `negotiation_rule_violation`(协商上下文违反结构规则) + +- `negotiation_semantic_rejected`(结论不是 Reject 或内容不满足 reject 阶段约束) + +- `negotiation_llm_infrastructure_error`(LLM 基础设施故障,可重试) + +- `template_not_found`(校验提示词资源缺失) + +编程错误:prompt 或 schema 为 null 抛 `NullPointerException`,prompt 为空白或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。 + +**响应样例** + +```text +rejectParams.data() = +{ + 接入端口名称=无法提供,工作台侧端口资源台账暂不可查, + id=3dbc13b5-bd57-4c2b-b503-24e381b6c8d3, + round=1, + maxRounds=5 +} +``` + +### 1.3.10 generateTaskPromptFromText + +**API定义** + +```java +public MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) +``` + +**典型场景**:客户端Agent将用户的自然语言任务描述(如专线投诉诊断诉求)转换为指定场景的Task-T协议报文,适合已确定目标模板、需跳过场景识别的场景。 + +**功能说明**:从自然语言文本按指定 Task-T 模板生成任务提示词报文,**跳过场景识别**(模板由调用方显式指定)。执行一次 LLM 槽位提取后确定性渲染模板。生成阶段即校验内置槽位 Schema(必填槽缺失或取值非法时以 `slot_validation_error` 快速失败)。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| text | String | 是 | 自然语言任务描述 | +| templateUri | TemplateUri | 是 | Task-T 模板,如 `StandardTemplates.PRIVATE_LINE_COMPLAINT`(`Task-T/network-layer/private-line-complaint/v1`) | + +**请求样例** + +```java +import java.nio.file.Path; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; + +A2ATClient client = new A2ATClient(Path.of("client.env")); + +MetadataContent metadata = client.generateTaskPromptFromText( + "帮我创建专线投诉诊断任务,P781-珠江新城-PTN7900-23-TPA1EG24-17 这个端口," + + "客户报的是专线质差,从2026年5月11号早上8点半开始,深圳访问广州的核心系统非常慢," + + "时延从12ms飙到320ms,柜面和手机银行老是报连接超时,OSS流水号是event-id-20260511-09013。", + StandardTemplates.PRIVATE_LINE_COMPLAINT); +``` + +**输出说明** + +成功时返回 `MetadataContent`: + +| 字段/方法 | 类型 | 说明 | +| --------- | ---- | ---- | +| templateUri | String | 生成报文所用模板 URI,如 `Task-T/network-layer/private-line-complaint/v1` | +| promptText | String | 渲染后的任务提示词报文文本,作为 A2A 消息 metadata 中扩展 URI 对应的值传输 | +| extensionUri | String | TMF 扩展 URI(`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1`),即报文在 metadata 中的 key | +| negotiationContext | NegotiationContext | 恒为 null(非协商报文) | +| buildMetadataContent() | Map<String, Object> | 构建可直接放入 `Message.metadata` 的两键映射:扩展 URI → 报文文本、`templateUri` → 模板 URI | + +失败时抛 `PromptGenerationException`(`A2ATError` 子类,运行期异常): + +| 成员 | 类型 | 说明 | +| ---- | ---- | ---- | +| getCode() | String | 机器可读错误码,取值见下 | +| getMessage() | String | 人类可读的失败描述 | +| failedParameters() | List<SlotValidationError> | 槽位校验失败的明细(`slot_validation_error` 时非空),结构见公共约定 | + +错误码: + +- `template_not_found`(模板缺失) + +- `prompt_resource_load_error`(提示词资源加载失败) + +- `slot_schema_not_found`(槽位 Schema 缺失) + +- `llm_invocation_failed`(LLM 调用失败) + +- `slot_validation_error`(必填槽缺失或取值非法) + +- `render_failed`(模板渲染失败) + +编程错误:text 或 templateUri 为 null 抛 `NullPointerException`。 + +**响应样例** + +```text +templateUri : Task-T/network-layer/private-line-complaint/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1 +promptText : +## 任务类型(Task Type) +传输专线业务投诉诊断 + +## 任务描述(Task Description) +基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。 + +## 任务目标(Task Target) +对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。 + +## 任务对象(Task Object) +接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17 + +## 任务上下文(Task Context) +1. 投诉分类:专线质差 +2. 问题发生时间:2026-05-11T08:21:46Z +3. OSS侧事件流水号:event-id-20260511-09013 +4. 投诉详情:从5月11号早上8点半开始,深圳访问广州的响应时延从平均12ms骤升至320ms + +## 预期输出 +要求投诉诊断任务的结果包含如下信息: +1. 诊断结果;参数的取值范围包括:成功、失败;(必选) +2. 诊断结果详细信息; (必选) +3. 修复建议; (可选) +4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息; (可选) +``` + +### 1.3.11 generateTaskPromptFromDataWithSchema + +**API定义** + +```java +public MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) +``` + +**典型场景**:客户端Agent将上游系统的结构化任务参数(字段名可与模板槽位不同,由Schema描述字段语义)转换为Task-T协议报文,适合任务参数已结构化持有的场景。 + +**功能说明**:从结构化数据 + 语义 Schema 按指定 Task-T 模板生成任务提示词,**跳过场景识别**。`schema` 描述每个输入字段的业务含义(description / examples / enum 等),指导槽位填充与取值约束;每个 data 的 key 对应一个槽位值。生成阶段同样执行内置槽位 Schema 校验(必填槽缺失或取值非法时以 `slot_validation_error` 快速失败)。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| data | Map<String, Object> | 是 | 结构化业务字段输入,key 为业务字段名,value 为字段值 | +| schema | Map<String, Object> | 是(非空) | 字段语义 JSON Schema,描述各字段含义与约束 | +| templateUri | TemplateUri | 是 | Task-T 模板 | + +**请求样例** + +```java +import java.util.Map; + +Map data = Map.of( + "portName", "P781-福田中心-PTN7900-2-TPA1EG24-03", + "complaintScenario", "专线质差", + "faultStartTime", "2026-05-11T08:21:46Z", + "ticketNo", "event-id-20260511-09013", + "faultDetailText", "从5月11号早上8点半开始,深圳访问广州的响应时延从平均12ms骤升至320ms"); + +Map semanticsSchema = Map.of( + "type", "object", + "properties", Map.of( + "portName", Map.of( + "type", "string", + "description", "业务字段:接入端口名称,唯一标识被投诉的专线对象"), + "complaintScenario", Map.of( + "type", "string", + "description", "业务字段:投诉分类场景,专线中断或专线质差二者必选其一", + "enum", List.of("专线中断", "专线质差")), + "faultStartTime", Map.of("type", "string", "description", "业务字段:问题发生时间"), + "ticketNo", Map.of("type", "string", "description", "业务字段:OSS 侧受理的投诉工单或事件流水号"), + "faultDetailText", Map.of("type", "string", "description", "业务字段:用户对故障现象的自由描述")), + "required", List.of("portName", "complaintScenario")); + +MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( + data, semanticsSchema, StandardTemplates.PRIVATE_LINE_COMPLAINT); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext))。 + +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,schema 为空 Map 抛 `IllegalArgumentException`。 + +**响应样例**(与 1.3.10 同模板,槽位值来自结构化输入,`faultDetail` 为示例截断值) + +```text +templateUri : Task-T/network-layer/private-line-complaint/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1 +promptText : +## 任务类型(Task Type) +传输专线业务投诉诊断 + +## 任务描述(Task Description) +基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。 + +## 任务目标(Task Target) +对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。 + +## 任务对象(Task Object) +接入端口名称:P781-福田中心-PTN7900-2-TPA1EG24-03 + +## 任务上下文(Task Context) +1. 投诉分类:专线质差 +2. 问题发生时间:2026-05-11T08:21:46Z +3. OSS侧事件流水号:event-id-20260511-09013 +4. 投诉详情:从5月11号早上8点半开始,深圳访问广州的响应时延从平均12ms骤升至320ms + +## 预期输出 +要求投诉诊断任务的结果包含如下信息: +1. 诊断结果;参数的取值范围包括:成功、失败;(必选) +2. 诊断结果详细信息; (必选) +3. 修复建议; (可选) +4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息; (可选) +``` + +### 1.3.12 validateTaskPromptAndDataFilling + +**API定义** + +```java +public FilledParamData validateTaskPromptAndDataFilling( + String prompt, Map schema, TemplateUri templateUri) +``` + +**典型场景**:服务端Agent收到Task-T报文后、进入业务执行前的参数校验与提取入口;也是协商流程中"缺槽检测"的判定点——required参数缺失时由调用方决定是否发起协商补参。 + +**功能说明**:校验一条 Task-T 任务提示词报文是否匹配模板与槽位约束,并按调用方提供的 JSON Schema 提取参数。流水线:确定性规则门禁(模板内置槽位校验)→ 一次 LLM 语义校验调用(同时完成参数提取,可重试错误码上最多重试至配置上限)。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| prompt | String | 是(非空) | 待校验的任务提示词报文文本(`MetadataContent.promptText()`) | +| schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema,声明要提取/校验的参数及 required 约束 | +| templateUri | TemplateUri | 是 | Task-T 模板(前缀段必须为 `Task-T`) | + +**请求样例** + +```java +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.server.A2ATServer; + +A2ATServer server = new A2ATServer(Path.of("server.env")); + +// 服务端参数Schema(key为服务端业务字段名,与客户端字段名可不同,SDK完成跨字段适配) +Map validationSchema = Map.of( + "type", "object", + "properties", Map.of( + "accessPort", Map.of("type", "string", "description", "接入端口名称,唯一标识被投诉的专线对象"), + "bizScenario", Map.of( + "type", "string", + "description", "投诉分类场景,必填,仅允许取值:专线中断、专线质差", + "enum", List.of("专线中断", "专线质差")), + "faultTime", Map.of("type", "string", "description", "问题发生时间"), + "eventSerialNo", Map.of("type", "string", "description", "OSS 侧受理的投诉工单或事件流水号"), + "faultDetail", Map.of("type", "string", "description", "投诉/故障现象详情描述")), + "required", List.of("accessPort", "bizScenario")); + +Map extracted = server + .validateTaskPromptAndDataFilling( + metadata.promptText(), validationSchema, StandardTemplates.PRIVATE_LINE_COMPLAINT) + .data(); +``` + +**输出说明** + +成功时返回 `FilledParamData`: + +| 字段/方法 | 类型 | 说明 | +| --------- | ---- | ---- | +| data() | Map<String, Object> | 按 Schema 提取的参数,key 为 Schema 中声明的参数名 | + +失败时抛 `ContentValidationException`(`A2ATError` 子类): + +| 成员 | 类型 | 说明 | +| ---- | ---- | ---- | +| getCode() | String | 机器可读错误码,取值见下 | +| getMessage() | String | 人类可读的失败描述 | +| errors() | List<SlotValidationError> | 逐槽位错误明细(槽位级错误码如 `missing_required`、`invalid_value`、`format_error`),结构见公共约定 | +| params() | Map<String, Object> | 被拒前的部分提取参数(无法提取的槽位值为 null) | + +错误码: + +- `validation_semantic_rejected`(语义校验拒绝,含 required 参数缺失或取值非法) + +- `validation_llm_infrastructure_error`(LLM 基础设施故障,可重试) + +- `validation_prompt_resource_not_found`(校验提示词资源缺失) + +编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白抛 `IllegalArgumentException`。 + +**响应样例** + +```text +extracted = +{ + accessPort=P781-珠江新城-PTN7900-23-TPA1EG24-17, + bizScenario=专线质差, + faultTime=2026-05-11, + eventSerialNo=event-id-20260511-09013, + faultDetail=320ms +} +``` + +校验拒绝时(缺少关键槽位的负例,来自 `TaskTDemoMain` 用例三): + +```text +ContentValidationException: [validation_semantic_rejected] ... + slot=任务对象 code=missing_required message=... +``` + +### 1.3.13 generateNotificationPromptFromText + +**API定义** + +```java +public MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri) +``` + +**典型场景**:客户端Agent将自然语言订阅需求(如业务抢通事件订阅)转换为指定场景的Notification-T订阅报文。 + +**功能说明**:从自然语言文本按指定 Notification-T 模板生成通知订阅提示词报文,跳过场景识别。执行一次 LLM 槽位提取后确定性渲染模板,生成阶段执行内置槽位 Schema 校验。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| text | String | 是 | 自然语言订阅描述(通知主题、订阅条件、上报通知数据格式等) | +| templateUri | TemplateUri | 是 | Notification-T 模板,如 `StandardTemplates.SUBSCRIBE_INCIDENT`、`StandardTemplates.SERVICE_RECOVERY` | + +**请求样例** + +```java +import java.nio.file.Path; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; + +A2ATClient client = new A2ATClient(Path.of("client.env")); +TemplateUri templateUri = TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); + +MetadataContent result = client.generateNotificationPromptFromText( + "我想订阅一下业务抢通事件。上报通知数据格式如下:" + + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;" + + "2. 投诉诊断任务流水号;3. OSS侧事件流水号;4. 接入端口名称;" + + "5. 是否已授权OMC自动抢通,取值范围:是、否;6. 业务抢通方案名称;7. 业务抢通方案详情", + templateUri); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1`)。 + +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:text 或 templateUri 为 null 抛 `NullPointerException`。 + +**响应样例**(按模板渲染,实际文本随 LLM 槽位提取结果变化;本例输入未指定订阅条件,对应槽位留空) + +```text +templateUri : Notification-T/network-layer/service-recovery/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1 +promptText : +## 订阅描述 +请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。 + +## 通知主题 +业务抢通事件 + +## 订阅条件 +(可选) +1. 子网名称;举例:xx子网; + +## 上报通知数据格式 +1. 业务抢通方案执行状态,取值范围:未启动、已结束 +2. 投诉诊断任务流水号 +3. OSS侧事件流水号 +4. 接入端口名称 +5. 是否已授权OMC自动抢通,取值范围:是、否 +6. 业务抢通方案名称 +7. 业务抢通方案详情 + +## 预期输出 +1. 订阅结果,取值范围:成功、失败 +2. 订阅失败原因(可选) +3. 订阅成功后,按照<上报通知数据格式>上报消息 +``` + +### 1.3.14 generateNotificationPromptFromDataWithSchema + +**API定义** + +```java +public MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) +``` + +**典型场景**:客户端Agent将上游系统/界面提交的结构化订阅参数转换为Notification-T订阅报文,适合订阅参数已结构化持有的场景。 + +**功能说明**:从结构化数据 + 语义 Schema 按指定 Notification-T 模板生成通知订阅提示词,跳过场景识别。语义约束同 1.3.11。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| data | Map<String, Object> | 是 | 结构化订阅输入(如订阅条件、上报数据格式字段列表) | +| schema | Map<String, Object> | 是(非空) | 字段语义 JSON Schema | +| templateUri | TemplateUri | 是 | Notification-T 模板 | + +**请求样例** + +```java +Map data = Map.of( + "condition", "子网名称:xx子网", + "reportFormat", List.of( + Map.of("name", "业务抢通方案执行状态", "values", List.of("未启动", "已结束"), "required", true), + Map.of("name", "投诉诊断任务流水号", "required", true), + Map.of("name", "OSS侧事件流水号", "required", true), + Map.of("name", "接入端口名称", "required", true), + Map.of("name", "是否已授权OMC自动抢通", "values", List.of("是", "否"), "required", true), + Map.of("name", "业务抢通方案名称", "required", true), + Map.of("name", "业务抢通方案详情", "required", true), + Map.of("name", "业务抢通方案执行结束时间", "required", false))); + +Map dataSchema = Map.of( + "type", "object", + "properties", Map.of( + "condition", Map.of("type", "string", "description", "订阅条件,可选。待订阅的条件描述。"), + "reportFormat", Map.of( + "type", "array", + "description", "上报通知数据格式,必选。描述所需上报内容的字段列表。", + "items", Map.of( + "type", "object", + "properties", Map.of( + "name", Map.of("type", "string"), + "values", Map.of("type", "array"), + "required", Map.of("type", "boolean")), + "required", List.of("name")))), + "required", List.of("reportFormat")); + +MetadataContent result = client.generateNotificationPromptFromDataWithSchema( + data, dataSchema, templateUri); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1`)。 + +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,schema 为空 Map 抛 `IllegalArgumentException`。 + +**响应样例**(与 1.3.13 同模板,槽位值来自结构化输入:`订阅条件` 填充 `condition`,`上报通知数据格式` 按 `reportFormat` 列表渲染,字段排列样式示意) + +```text +templateUri : Notification-T/network-layer/service-recovery/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1 +promptText : +## 订阅描述 +请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。 + +## 通知主题 +业务抢通事件 + +## 订阅条件 +子网名称:xx子网(可选) +1. 子网名称;举例:xx子网; + +## 上报通知数据格式 +1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选) +2. 投诉诊断任务流水号(必选) +3. OSS侧事件流水号(必选) +4. 接入端口名称(必选) +5. 是否已授权OMC自动抢通,取值范围:是、否(必选) +6. 业务抢通方案名称(必选) +7. 业务抢通方案详情(必选) +8. 业务抢通方案执行结束时间(可选) + +## 预期输出 +1. 订阅结果,取值范围:成功、失败 +2. 订阅失败原因(可选) +3. 订阅成功后,按照<上报通知数据格式>上报消息 +``` + +### 1.3.15 validateNotificationPromptAndDataFilling + +**API定义** + +```java +public FilledParamData validateNotificationPromptAndDataFilling( + String prompt, Map schema, TemplateUri templateUri) +``` + +**典型场景**:服务端Agent收到Notification-T订阅报文后校验合规性并提取订阅参数(主题/条件/上报格式),据此建立订阅关系。 + +**功能说明**:校验一条 Notification-T 通知订阅提示词报文是否匹配模板与槽位约束,并按调用方 Schema 提取参数(订阅主题、订阅条件、上报通知数据格式等)。流水线与异常语义同 [validateTaskPromptAndDataFilling](#1312-validatetaskpromptanddatafilling)。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| prompt | String | 是(非空) | 待校验的通知订阅提示词报文文本 | +| schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema | +| templateUri | TemplateUri | 是 | Notification-T 模板(前缀段必须为 `Notification-T`) | + +**请求样例** + +```java +import net.openan.a2at.sdk.server.A2ATServer; + +A2ATServer server = new A2ATServer(Path.of("server.env")); + +Map validationSchema = Map.of( + "type", "object", + "properties", Map.of( + "topic", Map.of("type", "string", "description", "订阅主题(必选)。订阅的事件主题名称。"), + "subscriptionCondition", Map.of( + "type", "string", "description", "订阅条件(可选)。待订阅的条件描述。"), + "notificationDataFormat", Map.of( + "type", "string", "description", "上报通知数据格式(必选)。待上报的通知数据格式描述。")), + "required", List.of("topic", "notificationDataFormat")); + +// promptText 为客户端生成的通知订阅提示词报文文本 +FilledParamData result = server.validateNotificationPromptAndDataFilling( + promptText, validationSchema, templateUri); +``` + +**输出说明** + +成功时返回 `FilledParamData`(结构同 [1.3.12](#1312-validatetaskpromptanddatafilling)),`data()` 为按 Schema 提取的参数(订阅主题、订阅条件、上报通知数据格式等)。 + +失败时抛 `ContentValidationException`(结构同 1.3.12)。错误码: + +- `validation_semantic_rejected`(必选参数缺失或取值非法) + +- `validation_llm_infrastructure_error`(LLM 基础设施故障,可重试) + +- `validation_prompt_resource_not_found`(校验提示词资源缺失) + +编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白抛 `IllegalArgumentException`。 + +**响应样例** + +```text +result.data() = +{ + topic=业务抢通事件, + subscriptionCondition=子网名称:xx子网, + notificationDataFormat=业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、业务抢通方案名称、业务抢通方案详情。 +} +``` + +### 1.3.16 generateAuthPromptFromText + +**API定义** + +```java +public MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri) +``` + +**典型场景**:客户端Agent将自然语言授权诉求(新增/修改/删除/查询动网操作授权策略)转换为Authorization-T报文。 + +**功能说明**:从自然语言文本按指定 Authorization-T 模板生成授权策略操作提示词报文,跳过场景识别。执行一次 LLM 槽位提取后确定性渲染模板。Authorization-T 槽位 Schema 随 `a2a-t-resources` 内置,classpath 资源源下开箱可用。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| text | String | 是 | 自然语言授权描述(操作类型 + 动网操作的授权策略内容) | +| templateUri | TemplateUri | 是 | Authorization-T 模板:`StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT`(`Authorization-T/authorization-policy-management/v1`) | + +**请求样例** + +```java +import java.nio.file.Path; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; + +A2ATClient client = new A2ATClient(Path.of("client.env")); + +MetadataContent result = client.generateAuthPromptFromText( + "加个校园专网的授权,处置用业务抢通,做个隧道调优,有效期先不填后面补", + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`)。 + +失败时抛 `PromptGenerationException`(结构同 1.3.10),如操作类型不在"新增/修改/删除/查询授权策略"范围内时以 `slot_validation_error` 拒绝。编程错误:text 或 templateUri 为 null 抛 `NullPointerException`。 + +**响应样例** + +```text +templateUri : Authorization-T/authorization-policy-management/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1 +promptText : +## 授权策略的操作类型 +新增授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +校园专网,业务抢通,隧道调优 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +### 1.3.17 generateAuthPromptFromDataWithSchema + +**API定义** + +```java +public MetadataContent generateAuthPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) +``` + +**典型场景**:客户端Agent将授权管理界面/系统按字段提交的结构化授权策略数据转换为Authorization-T报文。 + +**功能说明**:从结构化数据 + 语义 Schema 按指定 Authorization-T 模板生成授权策略操作提示词,跳过场景识别。语义约束同 1.3.11。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| data | Map<String, Object> | 是 | 结构化授权输入(操作类型、策略数量、策略详情列表等) | +| schema | Map<String, Object> | 是(非空) | 字段语义 JSON Schema | +| templateUri | TemplateUri | 是 | Authorization-T 模板 | + +**请求样例** + +```java +import java.util.List; +import java.util.Map; + +Map data = Map.of( + "操作类型", "新增授权策略", + "策略数量", 2, + "详情", List.of( + Map.of("业务场景", "校园专网", "处置类型", "业务抢通", + "操作名称", "隧道调优", "有效期", "永久生效"), + Map.of("业务场景", "医疗专线", "处置类型", "业务恢复", + "操作名称", "频段调整", "有效期", "2026-06-01~2030-06-18"))); + +Map schema = Map.of( + "type", "object", + "properties", Map.of( + "操作类型", Map.of( + "type", "string", + "enum", List.of("新增授权策略", "修改授权策略", "删除授权策略", "查询授权策略")), + "策略数量", Map.of("type", "integer", "description", "要新增的策略数量"), + "详情", Map.of( + "type", "array", + "description", "策略详情列表", + "items", Map.of( + "type", "object", + "properties", Map.of( + "业务场景", Map.of("type", "string"), + "处置类型", Map.of("type", "string"), + "操作名称", Map.of("type", "string"), + "有效期", Map.of("type", "string")))))); + +MetadataContent result = client.generateAuthPromptFromDataWithSchema( + data, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`)。 + +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,schema 为空 Map 抛 `IllegalArgumentException`。 + +**响应样例** + +```text +templateUri : Authorization-T/authorization-policy-management/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1 +promptText : +## 授权策略的操作类型 +新增授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +校园专网,业务抢通,隧道调优,永久生效;医疗专线,业务恢复,频段调整,2026-06-01~2030-06-18 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +### 1.3.18 validateAuthPromptAndDataFilling + +**API定义** + +```java +public FilledParamData validateAuthPromptAndDataFilling( + String prompt, Map schema, TemplateUri templateUri) +``` + +**典型场景**:服务端Agent收到Authorization-T报文后校验合规性并提取操作类型与策略列表,据此执行授权策略管理动作。 + +**功能说明**:校验一条 Authorization-T 授权提示词报文是否匹配模板与槽位约束,并按调用方 Schema 提取参数(操作类型、策略列表等)。流水线与异常语义同 1.3.12。按各操作类型的字段要求做差异化校验:新增条目须含业务场景/处置类型/操作名称/有效期,修改条目须含策略标识与新有效期,删除条目可为策略标识或条件字段。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| prompt | String | 是(非空) | 待校验的授权提示词报文文本 | +| schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema(含 `操作类型`、`策略列表` 等) | +| templateUri | TemplateUri | 是 | Authorization-T 模板(前缀段必须为 `Authorization-T`) | + +**请求样例** + +```java +import net.openan.a2at.sdk.server.A2ATServer; + +A2ATServer server = new A2ATServer(Path.of("server.env")); + +Map paramSchema = MAPPER.readValue( + Path.of("param-schema.json").toFile(), new TypeReference>() {}); +// paramSchema 声明:操作类型(enum:新增/修改/删除/查询授权策略)、策略列表 +//(数组,条目含 策略标识/业务场景/处置类型/操作名称/有效期) + +FilledParamData result = server.validateAuthPromptAndDataFilling( + metadata.promptText(), paramSchema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); +``` + +**输出说明** + +成功时返回 `FilledParamData`(结构同 [1.3.12](#1312-validatetaskpromptanddatafilling)),`data()` 为按 Schema 提取的参数(操作类型、策略列表等)。 + +失败时抛 `ContentValidationException`(结构同 1.3.12)。错误码: + +- `validation_semantic_rejected`(必选参数缺失或取值非法,如新增条目缺少必填字段、有效期格式错误) + +- `validation_llm_infrastructure_error`(LLM 基础设施故障,可重试) + +- `validation_prompt_resource_not_found`(校验提示词资源缺失) + +编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白抛 `IllegalArgumentException`。 + +**响应样例** + +```text +result.data() = +{ + 操作类型=新增授权策略, + 策略列表=[ + {策略标识=null, 业务场景=校园专网, 处置类型=业务抢通, 操作名称=隧道调优, 有效期=永久生效}, + {策略标识=null, 业务场景=医疗专线, 处置类型=业务恢复, 操作名称=频段调整, 有效期=2026-06-01~2030-06-18} + ] +} +``` + +### 1.3.19 generateTaskPrompt + +**API定义** + +```java +public PromptGenerationResult generateTaskPrompt(Object userInput) +``` + +**典型场景**:客户端Agent的场景自动路由入口:不指定模板,由SDK识别用户输入所属业务场景并生成对应报文,适合场景集合已知、希望简化接入的场景。 + +**功能说明**:通用任务提示词生成入口,**经场景识别**自动定位模板:自然语言或结构化输入先由 LLM 识别业务场景,再按场景对应的内置模板完成槽位提取与渲染。需要显式指定模板时使用 1.3.1x 的 FromText / FromDataWithSchema 系列接口。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| userInput | Object | 是 | 任务描述:`String`(自然语言)或 `Map`(结构化输入),统一经 LLM 抽取,无零 LLM 捷径 | + +**请求样例** + +```java +A2ATClient client = new A2ATClient(Path.of("client.env")); + +PromptGenerationResult result = client.generateTaskPrompt( + "请生成一个Incident事件订阅任务:通知主题为Incident," + + "订阅级别为critical、medium、high、low,上报通知数据格式为DataPart"); + +if (result.success()) { + String processedPrompt = result.promptText(); // 作为A2A消息metadata发送 +} else { + System.out.println(result.failure().code() + ": " + result.failure().message()); +} +``` + +**输出说明** + +成功时(`success()` 为 `true`,不抛异常,结果随 record 返回): + +| 字段/方法 | 类型 | 说明 | +| --------- | ---- | ---- | +| success() | boolean | 恒为 true | +| promptText() | String | 渲染后的任务提示词报文文本,作为 A2A 消息 metadata 发送 | +| failure() | PromptGenerationFailure | 恒为 null | + +失败时(`success()` 为 `false`,不抛异常,失败负载随结果返回): + +| 字段/方法 | 类型 | 说明 | +| --------- | ---- | ---- | +| success() | boolean | 恒为 false | +| promptText() | String | 恒为 null | +| failure() | PromptGenerationFailure | 标准化失败负载,结构见下表 | + +`PromptGenerationFailure` 结构: + +| 字段 | 类型 | 说明 | +| ---- | ---- | ---- | +| code | String | 机器可读错误码:`scenario_not_matched`(场景识别未命中)、`prompt_resource_load_error`(提示词资源加载失败)、`template_not_found`(模板缺失)、`render_failed`(渲染失败) | +| message | String | 人类可读的失败描述 | +| stage | String | 失败发生的阶段:`scenario`(场景识别)、`generation`(模板加载/渲染) | + +**响应样例** + +成功时: + +```text +result.success() = true +result.promptText() = +## 订阅描述 +请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式>及<预期输出> 信息,完成网络侧智能故障Incident订阅与上报任务。 + +## 通知主题 +通知主题该主题的名称是“incident” + +## 订阅条件 +故障级别为“critical”、“medium”、“high”、“low” + +## 上报通知数据格式 +通过DataPart上报Incident数据 + +## 预期输出 +1、订阅结果,成功或失败 +2、订阅失败原因(可选) +``` + +失败时(输入无法命中任何内置场景): + +```text +result.success() = false +result.failure() = +{ code=scenario_not_matched, message=Scenario recognition failed., stage=scenario } +``` + +### 1.3.20 checkTaskPrompt + +**API定义** + +```java +public PromptComplianceResult checkTaskPrompt(String processedPromptText) +``` + +**典型场景**:服务端Agent对收到的任务报文做协议完备性把关(场景/模板/槽位合规),只需通过与失败结论、无需提取参数的场景。 + +**功能说明**:通用任务提示词合规校验入口(服务端):对客户端下发的 processed task prompt 执行场景匹配、模板遵从性校验与槽位校验(是否启用由 `A2AT_PROMPT_COMPLIANCE_ENABLED` 控制)。与 `validateTaskPromptAndDataFilling` 的区别:本接口不提取参数、不接收调用方 Schema,仅给出通过与失败的标准化结论。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| processedPromptText | String | 是 | 客户端下发的 A2A-T 协议报文文本(`Message.metadata` 中以扩展 URI 为 key 的值) | + +**请求样例** + +```java +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; + +A2ATServer server = new A2ATServer(Path.of("server.env")); + +PromptComplianceResult result = server.checkTaskPrompt(processedPrompt); + +if (result.success()) { + System.out.println("prompt check passed"); +} else { + System.out.println(result.failure().message()); +} +``` + +**输出说明** + +成功时(`success()` 为 `true`,不抛异常,结果随 record 返回): + +| 字段/方法 | 类型 | 说明 | +| --------- | ---- | ---- | +| success() | boolean | 恒为 true | +| failure() | PromptComplianceFailure | 恒为 null | + +失败时(`success()` 为 `false`,不抛异常,失败负载随结果返回): + +| 字段/方法 | 类型 | 说明 | +| --------- | ---- | ---- | +| success() | boolean | 恒为 false | +| failure() | PromptComplianceFailure | 标准化失败负载,结构见下表 | + +`PromptComplianceFailure` 结构: + +| 字段 | 类型 | 说明 | +| ---- | ---- | ---- | +| code | String | 机器可读错误码:`processed_prompt_parse_error`(报文解析/场景识别失败)、`template_not_found`(模板缺失)、`slot_validation_error`(槽位校验失败,含必填缺失、取值越界、格式不匹配) | +| message | String | 人类可读的失败描述 | +| stage | String | 失败发生的阶段:`prompt_parse`(报文解析)、`generation`(模板加载)、`slot_validation`(槽位校验) | + +**响应样例** + +成功时: + +```text +prompt check passed +``` + +失败时(报文缺少必填槽位,错误码与阶段取自服务端合规流水线): + +```text +result.success() = false +result.failure() = +{ code=slot_validation_error, message=Required slot '任务对象' is missing., stage=slot_validation } +``` + + + + + diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 0361254e..0395ab41 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -1,129 +1,158 @@ # 1 a2a-t-sdk-java 开发指南 +| 分类 | 说明 | +| -------- | ------------------------------------------------------------ | +| 目标读者 | 基于A2A-T SDK进行多智能体协议交互的开发者、集成部署人员、项目对接运维人员 | +| 文档目的 | 本文档详细介绍A2A-T SDK的完整安装方式、参数配置及最小实践。帮助使用者快速、规范地完成SDK接入、功能开发和线上落地。 | +| 阅读前提 | 了解A2A多智能体协议的数据模型定义和使用,了解AgentCard模型定义和使用,了解注册中心相关功能 | + ## 1.1 特性介绍 -见[用户指南 1.1特性介绍](https://github.com/project-openan/a2a-t-sdk-java/blob/main/docs/zh/%E7%94%A8%E6%88%B7%E6%8C%87%E5%8D%97.md#11-%E7%89%B9%E6%80%A7%E4%BB%8B%E7%BB%8D)章节。 +### 1.1.1 A2A-T能力介绍 +A2A-T(Agent-to-Agent Telecom)是基于A2A协议推出的电信领域多智能体互联协议,专为电信领域复杂协同场景设计。 -## 1.2 约束与限制 +业界通用智能体互联协议主要关注智能体互联和交互框架,对业务场景和具体交互内容关注不足,导致任务完成的成功率不高。电信领域的业务场景复杂、要求高,运维智能体互联和协同需要有专门的协议进行支撑。A2A-T方案基于A2A协议,重点针对电信领域业务流相关的信息模型、任务协商、协作安全等增强能力进行应用扩展。 -1. JDK 要求为 17+。 -2. 当前支持 `classpath` 和 `local_file` 提示词资源,默认从 jar 内置资源加载。 -3. 默认支持 `openai` LLM provider。 -4. 协商状态存储当前支持 `in_memory`。 -5. SDK 不负责启动业务 HTTP 服务、用户认证、密钥管理和注册中心部署。 +a2a-t-sdk-java是面向电信智能体协作场景的 Java SDK,用于在 A2A-T 交互中生成、校验和协商任务提示词。SDK 适合客户端 Agent、服务端 Agent 以及上层编排系统集成使用。 -## 1.3 环境准备 +主要能力包括: -### 1.3.1 获取源码 +- **客户端模板报文生成**:客户端根据自然语言或结构化输入生成符合 A2A-T 格式的协议报文。 +- **服务端模板报文校验**:服务端校验客户端下发的 A2A-T协议格式报文 是否匹配场景、模板和槽位约束。 +- **多轮协商管理**:支持`information`、`feasibility`、`target`协商流程。 +- **模板资源管理**:内置场景、槽位、模板和系统提示词资源,支持`classpath`与本地文件两种资源加载方式。 +- **LLM 适配**:通过 OpenAI-compatible 调用链接入外部大模型。 -```bash -git clone git@github.com:project-openan/a2a-t-sdk-java.git -cd a2a-t-sdk-java -``` +SDK提供的API列表和使用说明,详见 [API参考.md](API参考.md)。 -### 1.3.2 编译工程 +### 1.1.2 A2A-T SDK与A2A SDK的关系 -```bash -mvn -DskipTests package -``` +A2A-T协议是基于A2A协议的扩展,针对协议扩展的内容提供了A2A-T SDK,支撑快速构建满用于电信领域复杂协同场景的智能体。 -### 1.3.3 运行测试 +A2A-T SDK独立于A2A SDK,通过同时集成A2A-T SDK与A2A SDK可以构建支持A2A-T协议的agent,支持电信领域多agent之间的确定性、高可靠、高效及安全协同。Java生态中可搭配的A2A SDK为a2a-java(`org.a2aproject.sdk`)。 -```bash -mvn test -``` +```mermaid +flowchart LR + subgraph Server["服务端 Agent"] + B0["服务端Agent业务代码"] + B1["A2A Server SDK

        1. 接收请求
        2. 任务管理和事件队列管理
        ..."] + B2["A2A-T Server SDK

        1. 模板遵从性校验和参数提取
        2. 协商状态管理
        ..."] + B0 --接收响应--- B1 + B0 --A2A-T报文校验和提参--- B2 + end -### 1.3.4 格式检查 + subgraph Client["客户端 Agent"] + A0["客户端Agent业务代码"] + A1["A2A-T Client SDK

        1. 提供A2A-T Prompt模板
        2. 生成A2A-T协议模板报文
        3. 协商状态管理
        ..."] + A2["A2A Client SDK

        1.提供A2A标准数据对象模型
        2. 任务管理和事件队列管理
        3.发送请求,多协议支持:JSON-RPC、HTTP/REST
        ..."] + A0 --生成A2A-T协议报文--- A1 + A0 --拼接A2A扩展发送请求--- A2 + end -工程使用 Spotless 管理 Java 格式: + Client -- HTTPs A2A-T请求--> Server + Server -- HTTPs A2A-T响应--> Client -```bash -mvn spotless:check ``` -如需自动格式化: +### 1.1.3 A2A-T SDK典型交互场景 -```bash -mvn spotless:apply -``` +一个典型的多智能体协作交互场景至少涉及到三个组件:客户端Agent、服务端Agent、注册中心; -## 1.4 Maven 引用 +各组件间关系示例如下: -业务项目可使用 BOM 管理版本: +```mermaid +flowchart TD + subgraph Client["客户端 Agent"] + A1["A2A-T Client SDK

        Prompt模板生成:generateTaskPrompt"] + A2["A2A Client SDK

        使用HTTP/REST协议发送请求"] + A1 --> A2 + end -```xml - - - - net.openan.a2a-t.sdk - a2a-t-bom - 1.0.0 - pom - import - - - -``` + subgraph Server["服务端 Agent"] + B1["A2A-T Server SDK

        遵从性校验:checkTaskPrompt"] + B2["A2A Server SDK

        接收请求"] + B2 --> B1 + end -客户端 Agent 引用: + Registry["注册中心 (registry-center)"] -```xml - - net.openan.a2a-t.sdk - a2a-t-client - + A2 -- HTTPs --> B2 + Client -. 注册 / 检索 .-> Registry + Server -. 注册 / 检索 .-> Registry ``` -服务端 Agent 引用: -```xml - - net.openan.a2a-t.sdk - a2a-t-server - -``` -如果业务系统需要直接读取 SDK 内置资源,可额外引用: +## 1.2 约束与限制 -```xml - - net.openan.a2a-t.sdk - a2a-t-resources - -``` +1. JDK 版本要求为 17+,构建工具要求 Maven 3.8+。 +2. 完整的多智能体协议交互开发需要同时引入 A2A 官方 Java SDK(a2a-java,`org.a2aproject.sdk`),要求版本不小于 `1.0.0.Beta1` 。 +3. 提示词资源支持 `classpath`(默认,从 `a2a-t-resources` jar 内置资源加载)和 `local_file`(本地文件资源)两种来源。 +4. 协商状态存储仅提供 `in_memory`,进程退出后状态丢失。 +5. A2A-T SDK 不提供 Agent HTTP 服务框架、注册中心客户端、鉴权和密钥管理能力,需由业务系统集成。 -## 1.5 配置加载 +## 1.3 环境准备 -Java SDK 不自动发现 `.env`,调用方必须显式传入路径: +### 1.3.1 环境要求 -```java -import java.nio.file.Path; -import net.openan.a2at.sdk.client.A2ATClient; +| 项目 | 要求 | +| -------- | ---------------------------------------- | +| Java | JDK 17+ | +| 构建工具 | Maven 3.8+ | +| LLM | 需要可访问的 OpenAI 服务及 API Key | +| 操作系统 | Linux、Windows、macOS 均可用于开发和联调 | -A2ATClient client = new A2ATClient(Path.of("client.env")); -``` +### 1.3.2 搭建环境 -基础配置示例: +以Windows11 64位 amd64 开发环境为例 -```properties -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=classpath -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY={your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -``` +**安装JDK 17** + +1. 官网下载链接:https://www.oracle.com/java/technologies/downloads/#java17(也可使用 Adoptium Temurin 等发行版:https://adoptium.net/temurin/releases/?version=17) + +2. 下载 Windows x64 Installer(`.msi` 或 `.exe`)并以管理员身份执行 + +3. 安装时勾选或安装后手工配置环境变量:`JAVA_HOME` 指向 JDK 安装目录,并将 `%JAVA_HOME%\bin` 加入 `PATH` + +4. 安装完成,打开终端输入校验命令: + + ```shell + java -version + # 预期输出:openjdk version "17.0.x" ... + ``` + +**安装Maven** + +1. 官网下载链接:https://maven.apache.org/download.cgi,下载 `apache-maven-3.9.x-bin.zip` 并解压到本地目录 -推理模型可选用 `A2AT_LLM_REASONING_EFFORT` 调整传递给提供方的推理力度,取值为 `none`、`minimal`、`low`、 -`medium`、`high`、`xhigh` 之一(大小写不敏感)。非推理模型保持该键不设即可——此时不会向提供方发送该参数。 -取值不受支持时会在配置加载阶段以 `LLMConfigError` 快速失败,而不是等到首次 LLM 调用才报提供方错误。 +2. 配置环境变量:`MAVEN_HOME` 指向解压目录,并将 `%MAVEN_HOME%\bin` 加入 `PATH` +3. 安装完成,输入校验命令: -## 1.6 SDK 基本用法 + ```shell + mvn -version + # 预期输出:Apache Maven 3.9.x + ``` -### 1.6.1 客户端生成任务提示词 +## 1.4 基础开发样例 + +目的:通过提供A2A-T客户端和服务端的样例代码,帮助使用者快速、规范地熟悉SDK接入流程,加速功能开发和线上落地。 + +### 1.4.1 样例接口说明 + +本基础开发样例主要使用到A2A-T SDK如下两个API,实际开发过程中需按业务实际诉求选择不同API使用: + +**1、A2A-T Client SDK** + +接口定义及功能描述:根据输入内容识别场景生成对应的Prompt模板 + +```java +public PromptGenerationResult generateTaskPrompt(Object userInput) +``` + +`PromptGenerationResult` 为 record 类型,包含 `success()`、`promptText()`、`failure()` 三个访问器,`failure` 携带 `code`、`message`、`stage`。 + +接口调用样例: ```java import java.nio.file.Path; @@ -131,17 +160,42 @@ import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.client.model.PromptGenerationResult; A2ATClient client = new A2ATClient(Path.of("client.env")); -PromptGenerationResult result = client.generateTaskPrompt( - "通知主题为Incident的,订阅条件为:订阅级别为critical的ETH-LOS的故障,上报通知数据格式为:DataPart"); +PromptGenerationResult result = client.generateTaskPrompt("请生成一个Incident事件订阅任务:通知主题为Incident,订阅级别为critical、medium、high、low,上报通知数据格式为DataPart"); if (result.success()) { System.out.println(result.promptText()); -} else { - System.out.println(result.failure().message()); } + +/*输出生成的prompt模板 +## 订阅描述 +请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式>及<预期输出> 信息,完成网络侧智能故障Incident订阅与上报任务。 + +## 通知主题 +通知主题该主题的名称是“incident” + +## 订阅条件 +故障级别为“critical”、“medium”、“high”、“low” + +## 上报通知数据格式 +通过DataPart上报Incident数据 + +## 预期输出 +1、订阅结果,成功或失败 +2、订阅失败原因(可选) +*/ +``` + +**2、A2A-T Server SDK** + +接口定义及功能描述: + +```java +public PromptComplianceResult checkTaskPrompt(String processedPromptText) ``` -### 1.6.2 服务端校验任务提示词 +`PromptComplianceResult` 为 record 类型,包含 `success()` 和 `failure()` 两个访问器(校验失败时 `failure` 携带 `code`、`message`、`stage`)。 + +接口调用样例:校验A2A-T协议报文的完备性 ```java import java.nio.file.Path; @@ -149,7 +203,7 @@ import net.openan.a2at.sdk.server.A2ATServer; import net.openan.a2at.sdk.server.model.PromptComplianceResult; A2ATServer server = new A2ATServer(Path.of("server.env")); -PromptComplianceResult result = server.checkTaskPrompt(processedPromptText); +PromptComplianceResult result = server.checkTaskPrompt("## 订阅描述请根据以下 <通知主题>、<订阅 条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧智能故障Incident的订阅与上报任务。## 通知主题该主题的名称是“incident”## 订阅条件 \n\n 故障级别为“critical”、“medium”、“high”、“low” \n\n ## 上报通知数据格式 \n\n通过DataPart上报Incident数据## 预期输出1、订阅结果,成功或失败2、订阅失 败原因(可选)"); if (result.success()) { System.out.println("prompt check passed"); @@ -158,472 +212,1212 @@ if (result.success()) { } ``` -### 1.6.3 协商接口 +### 1.4.2 开发流程 -```java -import java.nio.file.Path; -import java.util.Map; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +- 客户端开发流程 -A2ATClient client = new A2ATClient(Path.of("client.env")); -Map payload = client.startNegotiation( - NegotiationType.INFORMATION, - "Please provide incident level.", - Map.of("missingFields", java.util.List.of("subscription_condition_incident_level"))); +```mermaid +flowchart TD + Step1["Step1 安装依赖"] --> + Step2["Step2 配置大模型"] + + Step2 -- 二次开发可选 --> Step3 + + Step3["Step3 初始化AgentCard"] --> + Step4["Step4 AgentCard注册与检索"] + + Step2 --> Step5 + + Step5["Step5 初始化A2A-T客户端"] --> + Step6["Step6 生成A2A-T模板报文"] --> + Step7["Step7 请求头激活A2A-T扩展"] --> + Step8["Step8 携带A2A-T扩展发送请求"] ``` -业务系统需要把协商 payload 中的文本和上下文随 A2A 消息传递给对端,后续通过 `receiveNegotiation` 和 `continueNegotiation` 推进状态。 +> 二次开发指已基于A2A开发Agent,并对接过注册中心 +- 服务端开发流程: -## 1.7 完整集成开发流程 +```mermaid +flowchart TD + Step1["Step1 安装依赖"] --> + Step2["Step2 配置大模型"] + + Step2 -- 二次开发可选 --> Step3 + + Step3["Step3 初始化AgentCard"] --> + Step4["Step4 AgentCard注册与检索"] + + Step2 --> Step5 + + Step5["Step5 初始化A2A-T服务端"] --> + Step6["Step6 服务端接收报文并校验"] --> + Step7["Step7 服务端内部业务逻辑处理"] --> + Step8["Step8 填充A2A响应头,返回响应"] +``` -### 1.7.1 客户端 Agent -客户端 Agent 推荐流程: -1. 读取 `.env` 并创建 `A2ATClient`。 -2. 根据用户输入调用 `generateTaskPrompt`。 -3. 将 prompt 放入 A2A 消息正文或扩展字段。 -4. 通过注册中心查询目标 AgentCard。 -5. 根据 AgentCard 中的接口地址发起 A2A 请求。 -6. 如果收到协商上下文,通过 `receiveNegotiation` 和 `continueNegotiation` 继续交互。 -`a2a-t-sample` 中 `subscribe_incident` 用例的 `ClientSampleFlow` 展示了完整流程:读取场景、查询 AgentCard、生成 prompt、构造 A2A 请求并处理流式事件。 +### 1.4.3 样例客户端开发步骤 -### 1.7.2 服务端 Agent +#### Step1 安装依赖 -服务端 Agent 推荐流程: +业务项目可使用 BOM 统一管理 A2A-T SDK 版本: -1. 创建业务 HTTP 服务或接入已有 A2A Java 服务框架。 -2. 启动时构造 AgentCard 并注册到注册中心。 -3. 收到 A2A 请求后提取 processed task prompt。 -4. 调用 `A2ATServer.checkTaskPrompt`。 -5. 校验通过后执行业务逻辑。 -6. 校验失败或信息不足时,通过协商接口返回补充信息要求。 +```xml + + + + net.openan.a2a-t.sdk + a2a-t-bom + 1.0.0 + pom + import + + + +``` -`a2a-t-sample` 中 `subscribe_incident` 用例的 `ServerSampleMain` 和 `ServerSampleFlow` 展示了 AgentCard 注册、HTTP 服务启动、prompt 提取、校验和任务事件推送。 +客户端 Agent 引用: -## 1.8 提示词资源扩展 -### 1.8.1 文件扩展 -自定义场景时,按以下结构准备本地资源: +```xml + + + net.openan.a2a-t.sdk + a2a-t-client + -```text -prompt_resources/ - scenarios/zh-CN/scenarios.json - slots/{extension_name}/{path_segments}/{version}/zh-CN/slot.json - templates/{extension_name}/{path_segments}/{version}/zh-CN/template.md - prompts/scenario_recognition/zh-CN/system.md - prompts/scenario_recognition/zh-CN/user.md - prompts/slot_extraction/zh-CN/system.md - prompts/slot_extraction/zh-CN/user.md - prompts/semantic_validation/zh-CN/system.md - prompts/semantic_validation/zh-CN/user.md + + + org.a2aproject.sdk + a2a-java-sdk-client + 1.0.0.Beta1 + + + org.a2aproject.sdk + a2a-java-sdk-client-transport-rest + 1.0.0.Beta1 + ``` -`slots/` 与 `templates/` 的目录路径与模板 URI 逐段一一对应:`{extension_name}/{path_segments}/{version}` 即模板 URI,`{version}`(默认 `v1`)是末尾段。例如 `ran-energy-saving` 场景对应 `templates/Task-T/network-layer/ran-energy-saving/v1/zh-CN/template.md` 与 `slots/Task-T/network-layer/ran-energy-saving/v1/zh-CN/slot.json`;协商模板则形如 `templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md`。 +> 本指南样例中,A2A 请求统一通过 A2A 官方 Java SDK(a2a-java 的 `Client` + REST 传输)发送; +> +> 仅注册中心的注册与检索使用 JDK 内置 `HttpClient` 与 Jackson(注册中心不属于 a2a-java 能力范围,业务系统可替换为 OkHttp、Spring RestTemplate 等任意 HTTP 客户端和 JSON 库)。如需在客户端使用 Jackson,可添加: +> +> ```xml +> +> com.fasterxml.jackson.core +> jackson-databind +> 2.20.1 +> +> ``` -然后在 `.env` 中指定: +#### Step2 配置大模型 + +复制仓库根目录`env.example`文件内容到`client.env`中(服务端对应`server.env`),配置如下内容 ```properties -A2AT_PROMPT_SOURCE_TYPE=local_file -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR={your_prompt_resources_root} A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=classpath +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= +A2AT_PROMPT_COMPLIANCE_ENABLED=true +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=deepseek-chat +A2AT_LLM_API_KEY={your_llm_api_key} +A2AT_LLM_BASE_URL=https://api.deepseek.com +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory ``` -建议为新增资源补充: - -1. 场景加载测试。 -2. 槽位 Schema 加载和校验测试。 -3. 客户端 prompt 生成测试。 -4. 服务端 prompt 合规校验测试。 -5. sample 级端到端验证。 - -### 1.8.2 如何定义Prompt模板 -#### 1.8.2.1 核心价值 -A2A-T 结构化Prompt提供了一种可重复使用的结构化方法,用于为LLM提供明确且一致的提示。通过将核心逻辑与可变数据分离,它使智能体间的交互更加可靠/高效/可扩展。使用结构化Prompt的主要好处包括: -- 一致性:确保提示遵循标准化格式,从而使得智能体的输出更加可预期。 -- 效率:避免从头开始编写每个Prompt,节省了时间和精力。同时,也避免了重复复杂的指令。 -- 可扩展性:使得为各种业务场景生成prompt更容易。 -- 优化:允许对模板进行改进和优化,从而获得更好的结果。 - -#### 1.8.2.2 A2A-T Prompt模板的分类 -面向电信领域的智能体通信,为保证请求内容的完备性,提高推理效率和准确性,A2A-T面向各个AN高价值场景,定义了结构化Prompt模板,并在TMF发布了产业的A2A-T协议标准: -《IG1453A_Structured_Prompt_of_Agent_to_Agent_Protocol_for_Telecoms_A2AT_v1.0.0》 -《IG1453_Agent_to_Agent_Protocol_for_Telecoms_A2AT_v2.0.0》 - -结构化Prompt模板定义分为两层: -- L0基础模板: -定义ICT领域任务的结构化Prompt的基础框架,不针对特定场景的变量和本体进行具体说明。 -L0模板列表: - - | 模板名称 | 说明 | - |--|--| - |Task-T | 定义了ICT领域任务的基本结构,但未针对特定场景规定常用变量和通用本体规范。基础模板的解析依赖LLM的推理能力和Agent的上下文处理能力。 | - |Notification-T |定义了ICT领域一种结构化的基于提示词的网络事件订阅与报告机制。该机制确保对网络事件进行实时感知,并通过结构化提示,在不同层级、不同领域之间提供一致的任务描述。 | - -- L1价值场景Prompt模板: -在L0模板的基础上,为不同高价值场景任务定义了常用的“变量”,以便在任务生成过程中,让智能体能够根据这些变量输入相应的内容,并在任务执行过程中识别相关内容以提高推理效率和准确性。 - -#### 1.8.2.3 核心组成元素 -完整的A2A-T Prompt模板一般包含以下两部分: -1. 指令 - - 定义:核心指示或上下文。 - - 作用:提供任务的基本要求和框架。 - - 语法:使用##直接标记指令名称(如## 任务描述)。 -2. 变量 - - 定义:动态槽位,每次使用时填充特定数据。 - - 作用:提供更具体的信息,提升推理效率。 - - 语法:使用双大括号{{}}标识变量名(如{{故障发生时间}})。 - -##### 1.8.2.3.1 指令 - -1. 指令语法要求:在声明“指令”时,应使用“##”进行标记,其后是“指令”的名称,以便Agent能够识别,从而实现内容的输入或相应的推理和执行。 -2. 指令集合:A2A-T定义的结构化Prompt已为ICT任务Prompt模板奠定了基础框架,将ICT任务的典型信息解构为以下指令。 - - -|指令名称 | 必选/可选|描述与示例 | -|--|--|--| -| 任务描述 | 必选 | 描述任务的基本要求。例:
        `## 任务描述`
        `请根据“目标对象”、“任务上下文”和“约束条件”分析故障的根本原因,并提供修复建议。请按照“预期输出”中定义的结构回应任务。` -|任务类型|可选 |标识任务类型(如故障诊断、能效优化)。例:
        `## 任务类型`
        `故障诊断 ` | -|目标对象|可选| 描述任务操作的直接对象。例:
        `## 目标对象`
        `故障标识符(fault-csn)为“OSS-FAULT-20250405-001”。`| -|任务上下文|可选 |提供任务执行的背景信息。例:
        `## 任务上下文`
        `故障发生时间(occur-time)为“2025-04-05T14:30:00Z”。 ` | -| 预期输出|可选 | 定义预期结果的格式。例:
        `## 预期输出`
        `故障诊断结果应包括以下信息:1. 诊断状态:成功或失败 2. 故障诊断分析结果 3. 修复建议 4. 故障根本原因列表 5. 领域特定信息` | - -##### 1.8.2.3.2 变量 -1. 变量语法要求:在使用“变量”时,应使用双大括号“{{}}”进行标识,并将变量名置于双大括号内,这样智能体才能识别它,从而实现内容的输入或相应的推理和执行。 -2. 变量的实例化方法:变量需要正确实例化才能发挥价值,推荐两种方法 - - 自然语言主谓宾结构:故障发生于2026年1月8日16:38:18 - - key-value简洁格式:故障发生时间:2026/1/8/16:38:18 -3. 常用变量集合: -A2A-T基于最佳实践,总结了AN L4高价值场景中常用的变量,这些变量有助于有效描述AN L4高价值场景中的任务: - |变量名称 | 必选/可选|描述与示例 | - |--|--|--| - | 标识符 | 必选 | 用于指定与任务关联的目标标识符。
        例如:
        `## 目标对象`
        `{{标识符}}`
        其实例示例如下:
        `## 目标对象`
        `故障标识符(fault-csn)为“OSS-FAULT-20250405-001”。` - |受影响对象|可选 |用于指定受故障影响的网络资源对象。
        例如:
        `## 任务上下文`
        `{{受影响对象}}`
        其实例化示例如下:
        `## 任务上下文 `
        `受影响对象的ID为“BTS-001”,类型为“基站收发信台”,名称为“基站001”,位置为“北京市朝阳区”。` | - |相关信息|可选| 其通用本体可以是与故障相关的事件列表或告警列表。
        例如:
        `## 任务上下文 `
        `{{相关信息}} `
        其实例化示例如下:
        `## 任务上下文`
        `关联告警列表如下:- 告警标识符(alarm-csn)为 "ALM-20250405-001",告警ID(alarm-id)为 "ALM-001",告警名称为 "基站信号丢失",网元名称为 "BTS-001",告警位置为 "北京市朝阳区",告警发生时间(alarm-create-time)为 "2025-04-05T14:28:00Z"。- 告警标识符(alarm-csn)为 "ALM-20250405-002",告警ID(alarm-id)为 "ALM-002",告警名称为 "传输链路中断",网元名称为 "TRX-002",告警位置为 "北京市海淀区",告警发生时间(alarm-create-time)为 "2025-04-05T14:29:00Z"。`| - |故障发生时间| 必选 | 其通用本体论是故障发生的时间。
        例如:
        `## 任务上下文 `
        `{{相关信息}}`
        其实例化示例如下:
        `## 任务上下文 `
        `故障发生时间为“2025-04-05T14:30:00Z”。` | - | 故障上下文对象 | 必选 | 其通用本体可以是来自OSS的故障预处理信息,也可以是来自EMS的告警上报信息。
        例如:
        `## 任务上下文`
        `{{故障上下文对象}}`
        其实例化示例如下:
        `## 任务上下文 `
        `故障上下文对象为:“告警管理系统:FMC,告警位置:北京,告警名称:基站信号丢失,告警时间:2025-04-05T14:28:00Z,告警网元:BTS-001”。` | - -#### 1.8.2.4 格式和规范 -A2A-T中常用文本格式的语法格式规范要求,包括段落、列表、链接,使用Markdown格式确保输出的结构化和可读性: -- 段落:用空行分隔文本块 -- 有序列表:数字加句点(1. 项目一) -- 无序列表:破折号开头(- 项目一) -- 链接:方括号加圆括号([文本](链接)) - -##### 1.8.2.4.1 段落 -要创建一个段落,可以使用空行将一行或多行文本分隔开。样例: -``` -## 任务描述 -处理A社区的5G服务故障 +> `A2AT_LLM_API_KEY` 是**调用外部大模型**使用的密钥,请妥善保存 +> +> SDK 通过 OpenAI-compatible 接入外部大模型,`A2AT_LLM_PROVIDER` 当前仅支持 `openai`;接入 DeepSeek 等 OpenAI 兼容服务时,通过 `A2AT_LLM_BASE_URL` 指定服务地址、`A2AT_LLM_MODEL` 指定模型名。 +> +> 提示词资源来源由 `A2AT_PROMPT_SOURCE_TYPE` 控制:默认 `classpath` 从 `a2a-t-resources` jar 内置资源加载;配置为 `local_file` 时,通过 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 指定本地资源根目录(相对路径相对于 `.env` 文件所在目录解析)。 -完成服务恢复 +#### Step3 初始化AgentCard -识别故障的根本原因并进行修复 -``` +a2a-java 官方推荐使用 Builder 构造 `AgentCard`(`org.a2aproject.sdk.spec.AgentCard`),可在 `capabilities.extensions` 中声明支持的 A2A-T 模板;注册到注册中心时,AgentCard 序列化为 JSON(外层包装为 `{"agentCards": [...]}`)。 -##### 1.8.2.4.2 有序列表 -要创建有序列表,需添加以数字加句点表示的条目。这些数字不必按顺序排列,但列表应从数字1开始。样例: -``` -## 预期输出 -1. Bar -2. Foo -``` +- 客户端样例AgentCard定义参考(可在`extensions`中声明支持的A2A-T模板): -##### 1.8.2.4.3 无序列表 -要创建无序列表,请在每行项目前添加破折号(-)。缩进一个或多个项目以创建嵌套列表。样例: -``` -## 预期输出 -- 项目 - - 项目1 -- Bar2 -- Foo -``` -##### 1.8.2.4.4 链接 -要创建一个链接,请在方括号内输入链接文本,然后在其后立即加上用圆括号括起来的网址。样例: -``` -## 任务描述 -处理[TM Forum AN](自治网络项目主页)无法加载的问题。 +```java +import java.util.List; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentExtension; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentProvider; +import org.a2aproject.sdk.spec.AgentSkill; + +private static final String TASK_T_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; +private static final String NOTIFICATION_T_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + +private static AgentCard buildClientAgentCard() { + return AgentCard.builder() + .name("Transmission workbench agent") + .description("传输网络运维管理智能体,提供电路恢复验证、基站退服根因分析、网元隐患排查等运维能力") + .provider(new AgentProvider("ZzNode", "https://example.com")) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .extendedAgentCard(false) + .extensions(List.of( + AgentExtension.builder() + .uri(TASK_T_EXT) + .description("Extension of structured prompt Task-T requests.") + .build(), + AgentExtension.builder() + .uri(NOTIFICATION_T_EXT) + .description("Extension of structured prompt Notification-T requests.") + .build())) + .build()) + .defaultInputModes(List.of("application/json", "text/plain")) + .defaultOutputModes(List.of("application/json", "text/plain")) + .skills(List.of( + AgentSkill.builder() + .id("circuit-recovery-verification") + .name("电路恢复验证智能体") + .description("电路业务恢复验证技能。根据请求中的电路名称,代入固定JSON模板返回业务恢复验证结果。当用户提到“业务恢复验证”、“电路恢复验证”、“circuit recovery verification”或类似请求时使用。适用于需要对指定电路进行业务恢复状态确认的场景。") + .tags(List.of("电路恢复验证", "业务恢复验证", "circuit-recovery")) + .examples(List.of( + "请对电路:LYSPELC3、SN3二期-LYXLSJLT1号楼10GE1049641NR进行业务恢复验证", + "对电路XXX进行业务恢复验证", + "circuit recovery verification for circuit XXX")) + .inputModes(List.of("application/json", "text/plain")) + .outputModes(List.of("application/json", "text/plain")) + .build(), + AgentSkill.builder() + .id("ne-hidden-danger") + .name("网元隐患排查智能体") + .description("网元隐患排查技能。根据输入的网元名称,排查该网元是否还存在新的隐患。当用户提到“隐患排查”、“检查隐患”、“网元排查”、“ne hidden danger”或类似请求时使用。适用于需要对指定网元进行隐患状态检查的场景。") + .tags(List.of("网元排查", "隐患排查", "NE-inspection")) + .examples(List.of( + "请排查QZHA-HAZBYSDGG-HRHH网元是否还产生新隐患", + "排查网元XXX是否还存在隐患", + "Check if NE XXX has any hidden dangers")) + .inputModes(List.of("application/json", "text/plain")) + .outputModes(List.of("application/json", "text/plain")) + .build())) + .supportedInterfaces(List.of( + new AgentInterface("HTTP+JSON", "http://10.xx.xx.xx:26335/a2a/v1", "", "1.0"))) + .build(); +} ``` +- 客户端样例AgentCard Json报文参考: + +```json +{ + "agentCards": [ + { + "name": "Transmission workbench agent", + "description": "传输网络运维管理智能体,提供电路恢复验证、基站退服根因分析、网元隐患排查等运维能力", + "supportedInterfaces": [ + { + "url": "http://10.xx.xx.xx:26335/a2a/v1", + "protocolBinding": "HTTP+JSON", + "protocolVersion": "1.0" + } + ], + "provider": { + "organization": "ZzNode" + }, + "version": "1.0.0", + "capabilities": { + "streaming": true, + "pushNotifications": false, + "extensions": [ + { + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1", + "description": "Extension of structured prompt Task-T requests." + }, + { + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1", + "description": "Extension of structured prompt Notification-T requests." + } + ], + "extendedAgentCard": false + }, + "securitySchemes": { + "bearerAuth": { + "httpAuthSecurityScheme": { + "description": "使用用户名和密码通过登录接口查询accessSession后,使用该accessSession进行bearer认证。", + "scheme": "Bearer" + } + } + }, + "defaultInputModes": [ + "application/json", + "text/plain" + ], + "defaultOutputModes": [ + "application/json", + "text/plain" + ], + "skills": [ + { + "id": "circuit-recovery-verification", + "name": "电路恢复验证智能体", + "description": "电路业务恢复验证技能。根据请求中的电路名称,代入固定JSON模板返回业务恢复验证结果。当用户提到“业务恢复验证”、“电路恢复验证”、“circuit recovery verification”或类似请求时使用。适用于需要对指定电路进行业务恢复状态确认的场景。", + "tags": [ + "电路恢复验证", + "业务恢复验证", + "circuit-recovery" + ], + "examples": [ + "请对电路:LYSPELC3、SN3二期-LYXLSJLT1号楼10GE1049641NR进行业务恢复验证", + "对电路XXX进行业务恢复验证", + "circuit recovery verification for circuit XXX" + ], + "inputModes": [ + "application/json", + "text/plain" + ], + "outputModes": [ + "application/json", + "text/plain" + ] + }, + { + "id": "ne-hidden-danger", + "name": "网元隐患排查智能体", + "description": "网元隐患排查技能。根据输入的网元名称,排查该网元是否还存在新的隐患。当用户提到“隐患排查”、“检查隐患”、“网元排查”、“ne hidden danger”或类似请求时使用。适用于需要对指定网元进行隐患状态检查的场景。", + "tags": [ + "网元排查", + "隐患排查", + "NE-inspection" + ], + "examples": [ + "请排查QZHA-HAZBYSDGG-HRHH网元是否还产生新隐患", + "排查网元XXX是否还存在隐患", + "Check if NE XXX has any hidden dangers" + ], + "inputModes": [ + "application/json", + "text/plain" + ], + "outputModes": [ + "application/json", + "text/plain" + ] + } + ] + } + ] +} +``` -#### 1.8.2.5 模板定义的步骤 -建议按照如下步骤定义prompt模板: -1. 确定任务类型:明确当前业务场景的任务类型和协作模式,从A2A-T任务分类体系中定位对应的模板类别(如 Task-T); -2. 编写必要指令:选取关键指令,使用结构化的格式声明任务目标、执行条件、输入参数、预期输出等信息; -3. 填充常用变量:声明本次任务实例所涉及的具体参数,变量引用需要遵循A2A-T变量语法规范; -4. 绑定上下文信息:补充智能体完成任务所需的上下文信息; -5. 设置输出定义:明确定义输出格式、验收标准和异常处理规则; -6. 验证模板完整性:在实际跨模型环境中进行充分测试,验证语法规范性和跨LLM兼容性; -7. 版本迭代优化:将经过验证的模板纳入版本管理体系,便于持续的治理、迭代和演进。 - -## 1.9 测试建议 +#### Step4 AgentCard注册与检索 -常用测试命令: +- **AgentCard注册**:将客户端AgentCard发布到注册中心,注册中心的地址和URI以实际部署情况为准 -```bash -mvn test +```java +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.a2aproject.sdk.spec.AgentCard; + +private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); +private static final ObjectMapper MAPPER = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); + +private static void registerAgentCard(String registryBaseUrl, AgentCard agentCard) + throws Exception { + String payload = MAPPER.writeValueAsString(Map.of("agentCards", List.of(agentCard))); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(registryBaseUrl + "/rest/v1/registry-center/agent-cards")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 201) { + throw new IllegalStateException("AgentCard registration failed: " + response.statusCode()); + } +} ``` -修改客户端提示词生成时: +- **AgentCard检索**:根据目标Agent名称或技能,从注册中心查询并获取其 AgentCard,从而得到 `url` 和支持的技能 -```bash -mvn -pl a2a-t-client -am test +```java +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.core.type.TypeReference; + +private static Map discoverAgent( + String registryBaseUrl, String organization, String name) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(registryBaseUrl + "/rest/v1/registry-center/agent-cards/" + + organization + "/" + name)) + .GET() + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new IllegalStateException("AgentCard query failed: " + response.statusCode()); + } + Map payload = + MAPPER.readValue(response.body(), new TypeReference>() {}); + return (Map) ((List) payload.get("agentCards")).get(0); +} ``` -修改服务端校验时: +检索结果为注册中心返回的 JSON 结构。由于 a2a-java 的 `Client.builder(...)` 接收 `org.a2aproject.sdk.spec.AgentCard` 对象,需将其转换为官方 AgentCard 模型: -```bash -mvn -pl a2a-t-server -am test -``` +```java +import java.util.List; +import java.util.Map; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentExtension; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentProvider; +import org.a2aproject.sdk.spec.AgentSkill; + +@SuppressWarnings("unchecked") +private static AgentCard toAgentCard(Map registryAgentCard) { + Map provider = (Map) registryAgentCard.getOrDefault("provider", Map.of()); + Map capabilities = (Map) registryAgentCard.getOrDefault("capabilities", Map.of()); + List> extensionMaps = + (List>) capabilities.getOrDefault("extensions", List.of()); + List> skillMaps = + (List>) registryAgentCard.getOrDefault("skills", List.of()); + List> interfaceMaps = + (List>) registryAgentCard.getOrDefault("supportedInterfaces", List.of()); + + return AgentCard.builder() + .name(string(registryAgentCard.get("name"))) + .description(string(registryAgentCard.get("description"))) + .provider(new AgentProvider(string(provider.get("organization")), string(provider.get("url")))) + .version(string(registryAgentCard.get("version"))) + .capabilities(AgentCapabilities.builder() + .streaming(Boolean.TRUE.equals(capabilities.get("streaming"))) + .pushNotifications(Boolean.TRUE.equals(capabilities.get("pushNotifications"))) + .extendedAgentCard(Boolean.TRUE.equals(capabilities.get("extendedAgentCard"))) + .extensions(extensionMaps.stream() + .map(extension -> AgentExtension.builder() + .uri(string(extension.get("uri"))) + .description(string(extension.get("description"))) + .required(Boolean.TRUE.equals(extension.get("required"))) + .build()) + .toList()) + .build()) + .defaultInputModes(stringList(registryAgentCard.get("defaultInputModes"))) + .defaultOutputModes(stringList(registryAgentCard.get("defaultOutputModes"))) + .skills(skillMaps.stream() + .map(skill -> AgentSkill.builder() + .id(string(skill.get("id"))) + .name(string(skill.get("name"))) + .description(string(skill.get("description"))) + .tags(stringList(skill.get("tags"))) + .build()) + .toList()) + .supportedInterfaces(interfaceMaps.stream() + .map(agentInterface -> new AgentInterface( + string(agentInterface.get("protocolBinding")), + string(agentInterface.get("url")), + string(agentInterface.getOrDefault("tenant", "")), + string(agentInterface.getOrDefault("protocolVersion", "1.0")))) + .toList()) + .build(); +} -修改协商状态机时: +private static String string(Object value) { + return value == null ? "" : String.valueOf(value); +} -```bash -mvn -pl a2a-t-negotiation -am test +private static List stringList(Object value) { + return value instanceof List values ? values.stream().map(String::valueOf).toList() : List.of(); +} ``` -修改 sample 时: +#### Step5 初始化A2A-T客户端 -```bash -mvn -pl a2a-t-sample -am -DskipTests package -``` - -## 1.10 Negotiation-T 内容层 +```java +import java.nio.file.Path; +import net.openan.a2at.sdk.client.A2ATClient; -本节介绍 Negotiation-T 内容层:一组以模板为驱动的 API,用于生成协商消息、校验协商消息合规性并从中提取参数。`A2ATClient` 与 `A2ATServer` 均暴露相同的十四个方法。 +A2ATClient client = new A2ATClient(Path.of("client.env")); +``` -### 1.10.1 概述与 SDK 边界 +`A2ATClient` 与 `A2ATServer` 均在构造时显式传入 `.env` 文件路径(Java SDK 不自动发现配置文件),路径相对于进程工作目录解析,建议使用绝对路径。 -内容层覆盖三类能力: +#### Step6 生成A2A-T模板报文 -1. **消息生成** — 从类型化数据渲染结构化协商消息(`generateNegotiation*PromptFromData`,确定性执行,不调用 LLM),或从自由文本生成(`generateNegotiation*PromptFromText`,先执行一次 LLM 内容抽取,再确定性渲染)。 -2. **合规校验与参数提取** — `validatePromptAndDataFilling*Data` 校验接收到的消息是否为格式正确的协商消息,并按调用方提供的 JSON Schema 提取参数。 -3. **模板查询** — `getPrompts` / `getPrompt` 列出和加载当前配置语言下**全部** A2A-T 扩展(Task-T、Notification-T、Authorization-T、Negotiation-T)可用的模板;`getNegotiationPrompts` / `getNegotiationPrompt` 将同样的查询限定在协商模板。 +客户端使用A2A-T Client SDK API `generateTaskPrompt`生成 processed_prompt,再将其作为 A2A 消息的一部分发送给目标 agent。 -内容层是无状态的。它刻意不持有会话状态机:会话标识、超出消息本身承载范围的轮次跟踪以及角色绑定都由调用方维护。既有的 `startNegotiation` / `receiveNegotiation` / `continueNegotiation` API(见 1.6.3)保持不变,仍是有状态流程的入口;内容层既可以与之组合使用,也可以独立使用。 +```java +import java.nio.file.Path; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; -### 1.10.2 门面方法 +A2ATClient client = new A2ATClient(Path.of("client.env")); -以下十四个方法在 `A2ATClient` 和 `A2ATServer` 上具有完全相同的签名和语义。 +// 生成 A2A-T 提示词 +PromptGenerationResult result = client.generateTaskPrompt("请生成一个Incident事件订阅任务:通知主题为Incident,订阅级别为critical、medium、high、low,上报通知数据格式为DataPart"); +if (!result.success()) { + throw new IllegalStateException(result.failure().code() + ": " + result.failure().message()); +} -所有用于标识模板的参数均采用值类型 `net.openan.a2at.sdk.core.model.TemplateUri`,而非裸字符串。`TemplateUri` 恒为格式正确 — 其构造器会校验扩展名、路径段与版本 — 因此格式错误的 URI 不可能进入这些 API。可用 `TemplateUri.of("Negotiation-T", "information-negotiation", "propose")` 构造,更推荐直接使用同包 `StandardTemplates` 中的常量(例如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`,其 `uri()` 为 `Negotiation-T/information-negotiation/propose/v1`)。当 URI 字符串来自代码之外时,`TemplateUri.parse(String)` 返回 `Optional` 且绝不抛出异常。 +String processedPrompt = result.promptText(); +``` -**从类型化数据生成 — 确定性执行,绝不调用 LLM:** +#### Step7 请求头激活A2A-T扩展 -```java -MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, TemplateUri templateUri) -MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, TemplateUri templateUri) -MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, TemplateUri templateUri) -MetadataContent generateNegotiationAbortPromptFromData(NegotiationAbortData data, TemplateUri templateUri) -``` +A2A 协议通过 HTTP Header 传递协议版本与扩展声明,需使用如下Header -类型化内容先经过校验,再分发给模板 URI 所指协商类型的生成器,并由该模板渲染。accept 变体要求 `content.conclusion() == ACCEPT`,reject 变体要求 `REJECT`;结论不匹配(包括 `ABORT`)会以 `IllegalArgumentException`(编程错误)拒绝。abort 变体与协商类型无关:它由 `NegotiationAbortData(context, NegotiationAbortContent(terminationReason))` 输入渲染唯一的 common abort 模板 `StandardTemplates.NEGOTIATION_ABORT`,固定的 `Abort` 结论由模板自身承载。 +| Header | 方向 | 必填 | 取值 | +| ---------------- | ------ | -------------------- | ------------------------------------------------- | +| `A2A-Version` | 请求头 | 是 | 协议版本,如 `1.0`(客户端必须随每个请求携带) | +| `A2A-Extensions` | 请求头 | 否(使用扩展时建议) | 逗号分隔的扩展 URI 列表,声明本次请求要使用的扩展 | -**从自由文本生成 — 一次 LLM 抽取,支持可配置重试:** +客户端请求头填写样例:使用 a2a-java 发送请求时,`A2A-Extensions` 通过 `ClientCallContext` 的 headers 传入(协议版本等协议头由 a2a-java 传输层按协议要求携带) ```java -MetadataContent generateNegotiationProposePromptFromText(String text, NegotiationContext context, TemplateUri templateUri) -MetadataContent generateNegotiationAcceptPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) -MetadataContent generateNegotiationRejectPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) -MetadataContent generateNegotiationAbortPromptFromText(String text, NegotiationContext context, TemplateUri templateUri) -``` - -模板在调用 LLM 之前先行加载;模板缺失会快速失败,不会消耗 LLM 请求。LLM 步骤在模板 URI 的约束下从自由文本中抽取类型化内容,随后的渲染与 from-data 变体一样是确定性的。`NegotiationContext` 不经任何 LLM 直接注入生成的 metadata(不再渲染进消息文本)。抽取步骤在可重试错误码(见 1.10.5)上最多重试到配置的尝试上限。abort 变体面向 common abort 模板从自由文本中抽取协商终止原因。 +import java.util.Map; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; -**合规校验与参数提取:** +String notificationPromptExt = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; -```java -FilledParamData validateProposePromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) -FilledParamData validateAcceptPromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) -FilledParamData validateRejectPromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) -FilledParamData validateAbortPromptAndDataFilling(String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) +ClientCallContext callContext = + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", notificationPromptExt)); ``` -流水线按固定顺序执行: - -1. 模板 URI 格式检查(phase 段必须与方法匹配:`propose` 或 `accept-reject`)。 -2. 确定性规则门禁 — 在任何 LLM 调用之前,校验调用方传入的 `NegotiationContext` 的结构规则(id 为 UUID 形态、轮次不超过轮次预算);context 为 null 时报 `negotiation_invalid_input`(非协商消息)。 -3. 一次 LLM 语义校验调用,同时完成参数提取;在可重试的 LLM 基础设施错误码上最多重试到配置的尝试上限。 -4. 将提取的参数与规则层得到的上下文参数合并 — 键冲突时**上下文参数优先**。 +#### Step8 携带A2A-T扩展发送请求 -**模板查询 — 永不抛出异常:** +使用 a2a-java 官方 SDK 发送请求:processed prompt 放入 `Message.metadata`(以扩展 URI 为 key),请求头通过 `ClientCallContext` 声明,由 `Client` 经 REST 传输发送。 ```java -List getPrompts() // 全部扩展在当前配置语言下的模板,按 URI 排序 -Optional getPrompt(TemplateUri templateUri) // 跨扩展按 URI 加载单个模板;未命中返回空 +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfig; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.a2aproject.sdk.spec.TextPart; + +String notificationPromptExt = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + +// 1) 生成 A2A-T 提示词(见1.4.4.6) +A2ATClient client = new A2ATClient(Path.of("client.env")); +PromptGenerationResult result = client.generateTaskPrompt("请生成一个Incident事件订阅任务:通知主题为Incident,订阅级别为critical、medium、high、low,上报通知数据格式为DataPart"); +if (!result.success()) { + throw new IllegalStateException(result.failure().code() + ": " + result.failure().message()); +} +String processedPrompt = result.promptText(); + +// 2) 构造携带A2A-T扩展的A2A消息(prompt放入metadata,以扩展URI为key) +Message message = Message.builder() + .messageId(UUID.randomUUID().toString()) + .role(Message.Role.ROLE_USER) + .parts(new TextPart("创建智能故障incident上报任务")) + .metadata(Map.of(notificationPromptExt, processedPrompt)) + .build(); +MessageSendParams params = MessageSendParams.builder().message(message).build(); + +// 3) 通过 ClientCallContext 声明A2A-T扩展请求头(见1.4.4.7) +ClientCallContext callContext = + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", notificationPromptExt)); + +// 4) 使用 a2a-java Client 发送请求并消费任务事件 +CountDownLatch done = new CountDownLatch(1); +try (Client a2aClient = Client.builder(agentCard) // 1.4.4.5 检索并转换后的 org.a2aproject.sdk.spec.AgentCard + .withTransport(RestTransport.class, new RestTransportConfig()) + .build()) { + a2aClient.sendMessage( + params, + List.of((event, ignored) -> handleEvent(event, done)), + throwable -> { + throwable.printStackTrace(); + done.countDown(); + }, + callContext); + // AgentCard声明streaming=true时走message:stream流式,sendMessage立即返回,等待任务进入终态; + // 否则走message:send阻塞模式,sendMessage返回时任务已结束 + done.await(); +} -List getNegotiationPrompts() // 当前配置语言下的全部协商模板,固定顺序 -Optional getNegotiationPrompt(TemplateUri templateUri) // 按 URI 加载单个协商模板;未命中返回空 +private static void handleEvent(ClientEvent event, CountDownLatch done) { + if (event instanceof TaskUpdateEvent update) { + if (update.getUpdateEvent() instanceof TaskStatusUpdateEvent statusUpdate) { + System.out.println("task-status: " + statusUpdate.status().state()); + if (statusUpdate.status().state().isFinal()) { + done.countDown(); + } + } else if (update.getUpdateEvent() instanceof TaskArtifactUpdateEvent artifactUpdate) { + System.out.println("task-artifact: " + artifactUpdate.artifact().parts()); + } + } else if (event instanceof MessageEvent messageEvent) { + System.out.println("task-message: " + messageEvent.getMessage().parts()); + } +} ``` -模板缺失时返回空结果并记录 warning 日志;由于入参是已通过校验的 `TemplateUri`,不存在 URI 不可用的情形,null 入参则抛出 `NullPointerException`。`PromptTemplate` 是一个记录类型 `(templateUri, description, content)`,其第一个组件是类型化的 `TemplateUri` 值;`content` 仅在无可加载内容构造模板记录时为 null。泛化查询对直接从内置资源树中发现扩展目录 — 在 `prompt_resources/templates/` 下新增的模板目录(例如未来的 `Authorization-T/`)会被自动列出,无需代码改动。配置的本地资源根目录下的模板会覆盖同路径的内置模板。注意:SDK 尚未打包 Authorization-T 模板资源,`generateAuthPromptFromText` / `generateAuthPromptFromDataWithSchema` 在 classpath 源下会返回 `template_not_found`,直至补充该类模板或通过本地资源根目录提供。 - -### 1.10.3 模板 URI 与自定义模板 +#### 样例客户端完整代码 -模板 URI 的格式为: - -```text -Negotiation-T/{type}-negotiation/{phase}/v1 +```java +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfig; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.a2aproject.sdk.spec.TextPart; + +public final class ClientSample { + private static final String NOTIFICATION_PROMPT_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final ObjectMapper MAPPER = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); + private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); + + public static void main(String[] args) throws Exception { + // 1) 注册客户端AgentCard,检索服务端AgentCard + registerAgentCard("http://{ip:port}", buildClientAgentCard()); // 1.4.4.4 定义的客户端AgentCard + AgentCard serverAgentCard = toAgentCard( // 1.4.4.5 定义的转换 + discoverAgent("http://{ip:port}", "Huawei", "RAN Domain Agent")); + + // 2) 使用SDK能力生成 A2A-T 提示词 + A2ATClient client = new A2ATClient(Path.of("client.env")); + PromptGenerationResult result = client.generateTaskPrompt("请生成一个Incident事件订阅任务:通知主题为Incident,订阅级别为critical、medium、high、low,上报通知数据格式为DataPart"); + if (!result.success()) { + throw new IllegalStateException(result.failure().code() + ": " + result.failure().message()); + } + String processedPrompt = result.promptText(); + + // 3) 构造携带A2A-T扩展的A2A消息(请求头声明扩展,消息体携带 A2A-T 扩展字段) + Message message = Message.builder() + .messageId(UUID.randomUUID().toString()) + .role(Message.Role.ROLE_USER) + .parts(new TextPart("创建智能故障incident上报任务")) + .metadata(Map.of(NOTIFICATION_PROMPT_EXT, processedPrompt)) + .build(); + MessageSendParams params = MessageSendParams.builder().message(message).build(); + ClientCallContext callContext = + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", NOTIFICATION_PROMPT_EXT)); + + // 4) 通过 a2a-java Client 发送请求并消费任务事件 + CountDownLatch done = new CountDownLatch(1); + try (Client a2aClient = Client.builder(serverAgentCard) + .withTransport(RestTransport.class, new RestTransportConfig()) + .build()) { + a2aClient.sendMessage( + params, + List.of((event, ignored) -> handleEvent(event, done)), + throwable -> { + throwable.printStackTrace(); + done.countDown(); + }, + callContext); + done.await(); // 流式模式下等待任务进入终态 + } + } + + private static void handleEvent(ClientEvent event, CountDownLatch done) { + if (event instanceof TaskUpdateEvent update) { + if (update.getUpdateEvent() instanceof TaskStatusUpdateEvent statusUpdate) { + System.out.println("task-status: " + statusUpdate.status().state()); + if (statusUpdate.status().state().isFinal()) { + done.countDown(); + } + } else if (update.getUpdateEvent() instanceof TaskArtifactUpdateEvent artifactUpdate) { + System.out.println("task-artifact: " + artifactUpdate.artifact().parts()); + } + } else if (event instanceof MessageEvent messageEvent) { + System.out.println("task-message: " + messageEvent.getMessage().parts()); + } + } + + private static void registerAgentCard(String registryBaseUrl, AgentCard agentCard) + throws Exception { + String payload = MAPPER.writeValueAsString(Map.of("agentCards", List.of(agentCard))); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(registryBaseUrl + "/rest/v1/registry-center/agent-cards")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 201) { + throw new IllegalStateException("AgentCard registration failed: " + response.statusCode()); + } + } + + private static Map discoverAgent( + String registryBaseUrl, String organization, String name) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(registryBaseUrl + "/rest/v1/registry-center/agent-cards/" + + organization + "/" + name)) + .GET() + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new IllegalStateException("AgentCard query failed: " + response.statusCode()); + } + Map payload = + MAPPER.readValue(response.body(), new TypeReference>() {}); + return (Map) ((List) payload.get("agentCards")).get(0); + } +} ``` -其中 `{type}` 为 `information`、`target` 或 `feasibility`,`{phase}` 为 `propose` 或 `accept-reject`,末尾的 `v1` 是模板版本(默认版本)。门面 API 以类型化的 `TemplateUri` 接收该值;`StandardTemplates` 为每个内置模板提供一个常量。字符串层面的解析绝不抛出异常:`TemplateUri.parse(String)` 返回 `Optional`,`NegotiationReference.tryParse` 返回 `Optional` — null、空白或格式错误的 URI(段数、前缀、版本、类型或 phase 段不匹配)只返回空结果。SDK 内置六个模板,每种组合一个,各自提供 `zh-CN` 与 `en-US` 两个语言版本: +> `buildClientAgentCard()`、`toAgentCard(Map)` 及 `string`/`stringList` 辅助方法见 1.4.4.4 与 1.4.4.5。 -| 模板 URI | 用途 | -|--|--| -| `Negotiation-T/information-negotiation/propose/v1` | 请求补充缺失信息 | -| `Negotiation-T/information-negotiation/accept-reject/v1` | 信息协商的接受或拒绝 | -| `Negotiation-T/target-negotiation/propose/v1` | 发起目标协商 | -| `Negotiation-T/target-negotiation/accept-reject/v1` | 目标协商的接受或拒绝 | -| `Negotiation-T/feasibility-negotiation/propose/v1` | 请求可行性评估或提出替代方案 | -| `Negotiation-T/feasibility-negotiation/accept-reject/v1` | 可行性协商的接受或拒绝 | +### 1.4.4 样例服务端开发步骤 -协商模板采用**双根回退**解析,且该回退与 `A2AT_PROMPT_SOURCE_TYPE` 无关:若 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 配置的本地资源根目录下存在同名模板文件则优先使用,否则回退到内置 classpath 中同一 URI 的模板。内置模板因此始终作为兜底可用。本地根目录用于覆盖模板与协商词表;LLM 提示词资源始终来自 classpath。协商词表遵循同一双根机制:各语言的章节标题、槽位标记名、追加行标签与列表标点位于 `prompt_resources/negotiation-vocabulary/{lang}/vocabulary.json`,在 `{A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR}/negotiation-vocabulary/{lang}/vocabulary.json` 放置同名文件即可覆盖内置 classpath 词表;词表文件必须精确定义两个内置语言共同的规范化键集合,否则快速失败。 +#### Step1-Step5 前置步骤 -如需覆盖某个模板(例如中文的信息协商 propose 模板),在本地资源根目录下按内置布局放置文件: +安装依赖、配置大模型等步骤均可以参考[客户端实现](#1.4.4.1 安装依赖),差异化的内容如下: -```text -{A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR}/ - templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md -``` +服务端 Agent 推荐使用 a2a-java 官方的 REST 参考服务(基于 Quarkus,自动装配 REST 传输、任务管理与事件队列)承载 A2A 请求,引用如下依赖: -### 1.10.4 数据模型 - -所有类型位于 `net.openan.a2at.sdk.negotiation.content` 包。 +```xml + + + net.openan.a2a-t.sdk + a2a-t-server + -**NegotiationContext** — 每条协商消息随 metadata 携带的会话上下文,位于 `core.model` 包:`record NegotiationContext(String id, int round, int maxRounds, NegotiationPerformative performative)`。四个分量均为必填——id 为空白、round 或 maxRounds 非正数、performative 为 null 时抛出 `IllegalArgumentException`。它不可变;`nextRound()` 返回轮次加一并保留 performative 的新上下文,`isExhausted()` 判断当前轮次是否严格大于 `maxRounds`,`withPerformative(performative)` 返回盖上给定协商原语的副本。`NegotiationContext.of(id, round, performative)` 使用默认轮次预算 `DEFAULT_MAX_ROUNDS = 5`。`performative` 携带其随行消息的通信意图:输入上下文上是来向消息的协商原语;生成流水线则会在输出上下文上按所执行的操作盖戳对应的协商原语。 + + + org.a2aproject.sdk + a2a-java-sdk-reference-rest + 1.0.0.Beta1 + -**NegotiationPerformative** — 协商消息的通信意图(协商原语),位于 `core.model` 包:`enum NegotiationPerformative { PROPOSE, ACCEPT, REJECT, ABORT }`。`PROPOSE` 将提案摆上桌面,`ACCEPT` 与 `REJECT` 对提案作出回应,`ABORT` 终止会话;`tryParse(String)` 仅接受精确的大写常量名,其余情况返回空 `Optional`。注意区分 4 个取值的协商原语与模板 URI 的 3 段 phase segment(`propose`、`accept-reject`、`abort`):协商原语是消息的在线语义,phase segment 则用于定位渲染该消息的模板资源——`ACCEPT` 与 `REJECT` 消息共享 `accept-reject` 这一段。 + + + com.fasterxml.jackson.core + jackson-databind + 2.20.1 + +``` -**输入包装** — `NegotiationProposeData(context, content)` 与 `NegotiationEndingData(context, content)` 将上下文与类型化内容配对,`NegotiationAbortData(context, NegotiationAbortContent)` 将上下文与类型无关的协商终止内容配对。内容类型是按协商类型划分的密封继承体系: +Quarkus 工程还需引入 Quarkus 平台 BOM 与构建插件(完整工程脚手架可参考 a2a-java 官方 [Server Guide](https://a2aproject.github.io/a2a-java/) 或 https://code.quarkus.io 生成): -| 协商类型 | Propose 内容 | Ending 内容 | -|--|--|--| -| Information | `InformationProposeContent(List items, String relationship)` | `InformationEndingContent(conclusion, items)` | -| Target | `TargetProposeContent(description, intentUnderstanding, alignmentAndClarification, requestForClarification)` | `TargetEndingContent(conclusion, confirmedIntent, failureReason)` | -| Feasibility | `FeasibilityProposeContent(description, action, contentsToEvaluate, infeasibilityDetailsAndProposal)` | `FeasibilityEndingContent(conclusion, feasibilitySummary)` | +```xml + + 3.30.6 + -`NegotiationItem(name, value)` 表示条目列表中的一个命名条目。`NegotiationConclusion` 取值为 `ACCEPT`、`REJECT`、`ABORT`;只有 `Accept` 与 `Reject` 是类型化协商模板的可渲染结论 — 类型化生成方法会以 `IllegalArgumentException`(`A2ATError` 树外的编程错误)拒绝 `ABORT`。`ABORT` 结果经由类型无关的 common abort 模板渲染:`NegotiationAbortContent(terminationReason)` 是它唯一携带的内容,`NegotiationAbortData(context, content)` 是其输入包装。`NegotiationAction`(`REQUEST_FEASIBILITY_EVALUATION`、`PROPOSE_ALTERNATIVE_ON_FAILURE`)决定可行性 propose 模板中渲染哪些条件章节。 + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.platform.version} + pom + import + + + -**MetadataContent** — 生成结果:`record MetadataContent(String templateUri, String promptText, String extensionUri, NegotiationContext negotiationContext)`。`buildMetadataContent()` 返回 `Map`:TMF 扩展 URI(`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`)映射到渲染后的消息文本,`templateUri` 映射到模板 URI;协商消息额外携带 `negotiationContext` 映射到嵌套的 `{id, round, maxRounds, performative}` 对象,该嵌套对象恒为 4 键——`performative` 恒为其大写常量名(`"PROPOSE"`、`"ACCEPT"`、`"REJECT"`、`"ABORT"`),从不省略——非协商消息省略 `negotiationContext` 键、保持两键形态。这个映射即随 A2A 消息 metadata 传输的内容——协商上下文随 metadata 传输,不再渲染进消息文本。 + + + + io.quarkus.platform + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + +``` -**FilledParamData** — 提取结果:`record FilledParamData(Map data)`,持有上下文参数与按 Schema 提取的参数合并后的结果。 +- 初始化A2A-T服务端 -### 1.10.5 错误处理 +```java +import java.nio.file.Path; +import net.openan.a2at.sdk.server.A2ATServer; -所有 SDK 处理失败共用同一个根类型,编程错误则留在树外: +A2ATServer server = new A2ATServer(Path.of("server.env")); +``` -- `A2ATError` — **全部** SDK 处理失败(运行期、环境与数据处理失败)的唯一根类型。机器可读错误码声明在根上:`getCode()` 永不返回 `null`,失败没有更具体错误码时携带默认的 `sdk_internal_error`。因此捕获这一个类型即可覆盖 SDK 可能抛出的所有处理失败,包括 LLM 与合规校验失败。 - - `NegotiationProcessingException` — 协商处理失败。 - - `NegotiationGenerationException` — 生成调用抛出。 - - `A2ATParamExtractionError` — 携带结构化槽位错误的参数提取失败;`getCode()` 继承自根,`getErrors()` 返回槽位详情。 - - `NegotiationParamExtractionException` — `validatePromptAndDataFilling*Data` 调用抛出。 - - `LLMError` — LLM 集成失败子树,已并入 `A2ATError` 树。 - - `LLMConfigError` — LLM 配置或提供方注册无效。 - - `LLMRuntimeError` — 调用时的 LLM 基础设施故障。 - - `PromptComplianceCheckException` — 携带错误码与合规阶段(`getStage()`)的合规校验失败;由服务端合规流水线抛出,错误码为 `processed_prompt_parse_error`、`slot_validation_error` 与 `template_not_found`。 - - 其余处理失败 — 提示词生成(`PromptGenerationException`)、内容校验(`ContentValidationException`)、模板渲染(`TaskPromptRenderException`)、资源解析(`ResourceNotFoundException`、`ConfigFileNotFoundException`)、场景识别(`ScenarioRecognitionException`)与协商状态访问(`NegotiationStateException`)— 同属这棵树。 +- 初始化AgentCard:a2a-java 官方推荐通过 CDI Producer 暴露 `@PublicAgentCard` 限定的 AgentCard(使用 Builder 构造,见1.4.5.4),服务端样例AgentCard定义参考: -编程错误 — 调用方的入参契约违反 — 刻意留在 `A2ATError` 树外,以标准 JDK 异常抛出,使通用的 `catch (A2ATError)` 处理器无法吞掉它们:null 入参抛出 `NullPointerException`;空白或语义矛盾的入参(上下文 id 为空、传入 `TemplateUri` 构造器的段无效、phase 或结论不匹配、不支持的语言)抛出 `IllegalArgumentException`。原先的 `NegotiationContentException` 已移除;它过去报告的违约现在以这些标准异常形式抛出,各门面方法均以 `@throws` 标签逐一注明。 +```java +import java.util.List; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentExtension; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentProvider; +import org.a2aproject.sdk.spec.AgentSkill; + +private static final String TASK_T_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; +private static final String NOTIFICATION_T_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + +private static AgentCard buildServerAgentCard() { + return AgentCard.builder() + .name("RAN Domain Agent") + .description("RAN Domain Agent") + .provider(new AgentProvider("Huawei", "https://www.huawei.com")) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .extendedAgentCard(false) + .extensions(List.of( + AgentExtension.builder() + .uri(TASK_T_EXT) + .description("Extension of structured prompt TASK-T requests.") + .required(false) + .build(), + AgentExtension.builder() + .uri(NOTIFICATION_T_EXT) + .description("Extension of structured prompt Notification-T requests.") + .required(false) + .build())) + .build()) + .defaultInputModes(List.of("application/json", "text/plain")) + .defaultOutputModes(List.of("application/json", "text/plain")) + .skills(List.of(AgentSkill.builder() + .id("ran-incident-subscription") + .name("Incident上报") + .description("支持上报Incident,提供故障智能识别与诊断能力") + .tags(List.of("Incident上报")) + .examples(List.of( + "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧智能故障Incident的订阅与上报任务。\n## 通知主题\n该主题的名称是“incident”\n## 订阅条件\n故障级别为“高”\n## 上报通知数据格式\n通过DataPart上报Incident数据\n## 预期输出\n1、订阅结果,成功或失败\n2、订阅失败原因(可选)")) + .build())) + // URL以“/”结尾,a2a-java RestTransport会在此基础上拼接 message:send / message:stream + .supportedInterfaces(List.of( + new AgentInterface("HTTP+JSON", "http://127.0.0.1:26335/", "", "1.0"))) + .build(); +} +``` -错误码及其含义: +- 服务端样例AgentCard Json报文参考: + +```json +{ + "agentCards": [ + { + "name": "RAN Domain Agent", + "description": "RAN Domain Agent", + "provider": { + "organization": "Huawei", + "url": "https://www.huawei.com" + }, + "version": "1.0.0", + "capabilities": { + "streaming": true, + "pushNotifications": false, + "extendedAgentCard": false, + "extensions": [ + { + "description": "Extension of structured prompt TASK-T requests.", + "required": false, + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1" + }, + { + "description": "Extension of structured prompt Notification-T requests.", + "required": false, + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1" + } + ] + }, + "defaultInputModes": [ + "application/json", + "text/plain" + ], + "defaultOutputModes": [ + "application/json", + "text/plain" + ], + "skills": [ + { + "id": "ran-incident-subscription", + "name": "Incident上报", + "description": "支持上报Incident,提供故障智能识别与诊断能力", + "tags": [ + "Incident上报" + ], + "examples": [ + "## 订阅描述\\n请根据以下 \\u003c通知主题\\u003e、\\u003c订阅条件\\u003e、\\u003c上报通知数据格式\\u003e 及 \\u003c预期输出\\u003e 信息,完成网络侧智能故障Incident的订阅与上报任务。\\n## 通知主题\\n该主题的名称是“incident”\\n## 订阅条件\\n故障级别为“高”\\n## 上报通知数据格式\\n通过DataPart上报Incident数据\\n## 预期输出\\n1、订阅结果,成功或失败\\n2、订阅失败原因(可选)" + ], + "inputModes": [ + "application/json", + "text/plain" + ], + "outputModes": [ + "application/json", + "text/plain" + ] + } + ], + "securitySchemes": { + "bearerAuth": { + "httpAuthSecurityScheme": { + "scheme": "Bearer", + "description": "使用用户名和密码通过登录接口查询accessSession后,使用该accessSession进行bearer认证。" + } + } + }, + "securityRequirements": [], + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "url": "https://10.xx.xx.xx:27417/a2a/v1", + "tenant": "", + "protocolVersion": "1.0" + }, + { + "protocolBinding": "HTTP+JSON", + "url": "https://10.xx.xx.xx:27417/a2a/json", + "tenant": "", + "protocolVersion": "1.0" + } + ] + } + ] +} +``` -| 错误码 | 含义 | 可重试 | -|--|--|--| -| `template_not_found` | 任意资源根下都不存在该 URI 的模板(或提示词资源) | 否 | -| `negotiation_content_extract_failed` | 无法从自由文本中抽取结构化内容 | 是 | -| `negotiation_llm_infrastructure_error` | LLM 基础设施故障(网络、提供方错误、响应格式错误) | 是 | -| `negotiation_semantic_rejected` | 语义校验拒绝了该消息 | 否 | -| `negotiation_rule_violation` | 协商上下文违反结构规则 | 否 | -| `negotiation_slot_missing` | 渲染时缺少必需槽位 | 否 | -| `negotiation_invalid_input` | 输入对当前操作无效(FromText 生成调用的输入文本为空、不是协商消息、结论错误) | 否 | -| `param_extraction_failed` | 无更具体错误码时参数提取失败的默认错误码 | 否 | -| `sdk_internal_error` | 无更具体错误码时 SDK 处理失败的默认错误码 | 否 | +#### Step6-Step7 服务端接收报文并校验 -重试语义:携带可重试错误码的 LLM 步骤失败后,会重新执行至 `A2AT_LLM_MAX_ATTEMPTS` 配置的尝试上限;携带其他错误码的失败立即原样抛出。尝试次数耗尽时,原始失败以其原始错误码重新抛出。`A2AT_LLM_MAX_ATTEMPTS` 默认值为 3,并被收敛到 1–10 区间(超界值会被收敛并记录 warning 日志)。 +使用 a2a-java 承载服务端时,协议解析、任务创建与事件队列管理由 SDK 完成,业务侧实现官方的 `AgentExecutor` 接口:在 `execute` 回调中,从消息 `metadata` 字段(以扩展 URI 为 key)取出 processed task prompt,传递给 `A2ATServer.checkTaskPrompt` 校验,并通过 `AgentEmitter` 推送任务状态与结果事件: -### 1.10.6 语言配置 +```java +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.TextPart; + +public final class IncidentSubscriptionExecutor implements AgentExecutor { + + private static final String NOTIFICATION_PROMPT_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private final A2ATServer server; + + public IncidentSubscriptionExecutor(A2ATServer server) { + this.server = server; + } + + @Override + public void execute(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + // 1) 从消息metadata取出A2A-T扩展的报文(以扩展URI为key) + String processedPrompt = extractPrompt(requestContext.getMessage()); + if (processedPrompt.isBlank()) { + agentEmitter.reject(statusMessage(requestContext, "missing A2A-T task prompt")); + return; + } + agentEmitter.submit(statusMessage(requestContext, "Subscription accepted, starting Incident reporting task")); + + // 2) 使用SDK能力校验A2A-T协议报文的完备性 + PromptComplianceResult checkResult = server.checkTaskPrompt(processedPrompt); + if (!checkResult.success()) { + // 校验失败,failure 携带 code、message、stage + agentEmitter.reject(statusMessage(requestContext, + "Prompt validation failed: " + checkResult.failure().message())); + return; + } + + // 3) 校验通过,执行业务并通过AgentEmitter推送任务事件 + agentEmitter.startWork(statusMessage(requestContext, "Incident reporting task in progress")); + agentEmitter.addArtifact( + List.of(new DataPart(Map.of( + "faultManagement.Incident", Map.of( + "csn", 1673735459373056L, + "name", "LASER_MOD_ERR", + "domain", "PTN", + "priority", "high", + "status", "unacknowledged-and-uncleared")))), + "faultManagement.Incident", + "Incident artifact", + null); + agentEmitter.complete(statusMessage(requestContext, "Incident reporting completed")); + } + + @Override + public void cancel(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + agentEmitter.cancel(); + } + + private static String extractPrompt(Message message) { + if (message == null || message.metadata() == null) { + return ""; + } + Object prompt = message.metadata().get(NOTIFICATION_PROMPT_EXT); + return prompt == null ? "" : String.valueOf(prompt); + } + + private static Message statusMessage(RequestContext requestContext, String text) { + return Message.builder() + .messageId(UUID.randomUUID().toString()) + .contextId(requestContext.getContextId()) + .taskId(requestContext.getTaskId()) + .role(Message.Role.ROLE_AGENT) + .parts(List.of(new TextPart(text))) + .build(); + } +} +``` -协商模板以及渲染和章节识别所用的词表均按 `A2AT_LANGUAGE` 键控。开箱即用——仅内置 classpath 资源——支持的取值精确地为 `zh-CN` 与 `en-US`;没有任何语言回退,未提供本地词表文件的其他取值在解析词表时会以 `IllegalArgumentException` 失败,错误信息会指向 `A2AT_LANGUAGE`。若本地资源根下存在某语言的有效 `vocabulary.json`,词表层可以完成解析,但除非同时提供该语言的完整模板与 prompt 资源,生成流程仍会在后续以模板缺失失败。 +#### Step8 填充A2A响应头,返回响应 -词表本身是文件驱动的:各语言的常量位于 `prompt_resources/negotiation-vocabulary/{lang}/vocabulary.json`,即将 33 个规范化键(章节标题、槽位标记名、追加行标签、列表标点)映射到该语言文本的扁平 JSON 对象。词表遵循与模板相同的双根本地覆盖机制 — `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 下存在同名文件则优先,否则回退到内置 classpath 文件;解析过程快速失败、绝不静默降级:词表在两个根下均不存在、无法读取、格式非法或未精确定义规范化键集合时,都会抛出指明语言与文件来源的 `IllegalArgumentException`。因此新增语言仍需完整资源集 — 协商模板与 LLM 提示词资源 — 仅添加词表文件并不足够。请在 `client.env` / `server.env` 中显式设置语言: +| Header | 方向 | 必填 | 取值 | +| ---------------- | ------ | -------------------- | ----------------------------------------- | +| `A2A-Extensions` | 响应头 | 否(使用扩展时建议) | 服务端实际参与的扩展 URI 列表(逗号分隔) | -```properties -A2AT_LANGUAGE=zh-CN -``` +使用 a2a-java REST 传输承载服务端时,协议层(请求的协议版本与扩展声明校验、任务与事件管理、SSE 流式响应)由 SDK 传输层处理,业务侧通过 `AgentEmitter` 推送任务状态与结果事件即可;若业务系统自行实现 HTTP 承载,需按上表在响应中回填 `A2A-Extensions` 头。 -### 1.10.7 端到端示例 +#### 样例服务端完整代码 -以下是一个信息协商的完整示例:客户端发起协商(请求缺失数据),服务端接受并返回数据,两侧均使用内容层: +服务端通过 CDI Producer 向 a2a-java 暴露 AgentCard 与 AgentExecutor,在启动时向注册中心注册 AgentCard,并在 `AgentExecutor` 中完成 A2A-T 报文的提取、校验与业务执行。完整代码如下: ```java +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.negotiation.content.*; +import java.util.UUID; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.Produces; import net.openan.a2at.sdk.server.A2ATServer; - -// --- 客户端:发起协商,请求两个缺失字段 --- -A2ATClient client = new A2ATClient(Path.of("client.env")); - -NegotiationContext context = NegotiationContext.of("neg-0001-uuid", 1, NegotiationPerformative.PROPOSE); -InformationProposeContent content = new InformationProposeContent( - List.of( - new NegotiationItem("subscription_condition.incident_level", "critical or warning"), - new NegotiationItem("notification_data.format", "DataPart or raw JSON")), - null); - -MetadataContent propose = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData(context, content), - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); - -// 这个映射随 A2A 消息 metadata 传输。 -Map metadata = propose.buildMetadataContent(); -// metadata.get("templateUri") -> "Negotiation-T/information-negotiation/propose/v1" -// metadata.get(propose.extensionUri()) -> 渲染后的消息文本 -// metadata.get("negotiationContext") -> 嵌套的 {id, round, maxRounds, performative} 上下文 -// (本条消息的 performative 为 "PROPOSE") - -// --- 服务端:校验收到的消息并提取参数 --- -A2ATServer server = new A2ATServer(Path.of("server.env")); - -Map schema = Map.of( - "type", "object", - "properties", Map.of( - "subscription_condition.incident_level", Map.of("type", "string"), - "notification_data.format", Map.of("type", "string")), - "required", List.of("subscription_condition.incident_level")); - -try { - FilledParamData params = server.validateProposePromptAndDataFilling( - propose.promptText(), context, schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); - // params.data() 持有提取的参数以及上下文参数。 -} catch (A2ATParamExtractionError failure) { - // 按 failure.getCode() 分支处理:negotiation_invalid_input、negotiation_rule_violation、 - // negotiation_semantic_rejected、negotiation_llm_infrastructure_error、template_not_found。 +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.a2aproject.sdk.server.PublicAgentCard; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentExtension; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentProvider; +import org.a2aproject.sdk.spec.AgentSkill; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.TextPart; + +@ApplicationScoped +public class ServerSample { + + private static final String TASK_T_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; + private static final String NOTIFICATION_PROMPT_EXT = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // 1) CDI Producer:向 a2a-java 暴露服务端AgentCard(定义与1.4.5.1一致) + @Produces + @PublicAgentCard + public AgentCard agentCard() { + return AgentCard.builder() + .name("RAN Domain Agent") + .description("RAN Domain Agent") + .provider(new AgentProvider("Huawei", "https://www.huawei.com")) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .extendedAgentCard(false) + .extensions(List.of( + AgentExtension.builder() + .uri(TASK_T_EXT) + .description("Extension of structured prompt TASK-T requests.") + .required(false) + .build(), + AgentExtension.builder() + .uri(NOTIFICATION_PROMPT_EXT) + .description("Extension of structured prompt Notification-T requests.") + .required(false) + .build())) + .build()) + .defaultInputModes(List.of("application/json", "text/plain")) + .defaultOutputModes(List.of("application/json", "text/plain")) + .skills(List.of(AgentSkill.builder() + .id("ran-incident-subscription") + .name("Incident上报") + .description("支持上报Incident,提供故障智能识别与诊断能力") + .tags(List.of("Incident上报")) + .examples(List.of( + "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧智能故障Incident的订阅与上报任务。\n## 通知主题\n该主题的名称是“incident”\n## 订阅条件\n故障级别为“高”\n## 上报通知数据格式\n通过DataPart上报Incident数据\n## 预期输出\n1、订阅结果,成功或失败\n2、订阅失败原因(可选)")) + .build())) + // URL以“/”结尾,a2a-java RestTransport会在此基础上拼接 message:send / message:stream + .supportedInterfaces(List.of( + new AgentInterface("HTTP+JSON", "http://127.0.0.1:26335/", "", "1.0"))) + .build(); + } + + // 2) CDI Producer:初始化A2A-T服务端并装配到AgentExecutor + @Produces + public AgentExecutor agentExecutor() { + return new IncidentSubscriptionExecutor(new A2ATServer(Path.of("server.env"))); + } + + // 3) 启动时将服务端AgentCard注册到注册中心(地址以实际部署情况为准) + void registerOnStartup(@Observes StartupEvent event) throws Exception { + String payload = MAPPER.writeValueAsString(Map.of("agentCards", List.of(agentCard()))); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://{ip:port}/rest/v1/registry-center/agent-cards")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + HttpResponse response = HttpClient.newHttpClient() + .send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 201) { + throw new IllegalStateException("AgentCard registration failed: " + response.statusCode()); + } + } + + // 4) A2A-T任务执行器:接收A2A-T报文并校验(与1.4.5.2一致) + static final class IncidentSubscriptionExecutor implements AgentExecutor { + + private final A2ATServer server; + + IncidentSubscriptionExecutor(A2ATServer server) { + this.server = server; + } + + @Override + public void execute(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + // 4.1) 从消息metadata取出A2A-T扩展的报文(以扩展URI为key) + String processedPrompt = extractPrompt(requestContext.getMessage()); + if (processedPrompt.isBlank()) { + agentEmitter.reject(statusMessage(requestContext, "missing A2A-T task prompt")); + return; + } + agentEmitter.submit(statusMessage(requestContext, "Subscription accepted, starting Incident reporting task")); + + // 4.2) 使用SDK能力校验A2A-T协议报文的完备性 + PromptComplianceResult checkResult = server.checkTaskPrompt(processedPrompt); + if (!checkResult.success()) { + // 校验失败,failure 携带 code、message、stage + agentEmitter.reject(statusMessage(requestContext, + "Prompt validation failed: " + checkResult.failure().message())); + return; + } + + // 4.3) 校验通过,执行业务并通过AgentEmitter推送任务事件 + agentEmitter.startWork(statusMessage(requestContext, "Incident reporting task in progress")); + agentEmitter.addArtifact( + List.of(new DataPart(Map.of( + "faultManagement.Incident", Map.of( + "csn", 1673735459373056L, + "name", "LASER_MOD_ERR", + "domain", "PTN", + "priority", "high", + "status", "unacknowledged-and-uncleared")))), + "faultManagement.Incident", + "Incident artifact", + null); + agentEmitter.complete(statusMessage(requestContext, "Incident reporting completed")); + } + + @Override + public void cancel(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { + agentEmitter.cancel(); + } + + private static String extractPrompt(Message message) { + if (message == null || message.metadata() == null) { + return ""; + } + Object prompt = message.metadata().get(NOTIFICATION_PROMPT_EXT); + return prompt == null ? "" : String.valueOf(prompt); + } + + private static Message statusMessage(RequestContext requestContext, String text) { + return Message.builder() + .messageId(UUID.randomUUID().toString()) + .contextId(requestContext.getContextId()) + .taskId(requestContext.getTaskId()) + .role(Message.Role.ROLE_AGENT) + .parts(List.of(new TextPart(text))) + .build(); + } + } } +``` -// --- 服务端:接受协商并返回所请求的信息 --- -InformationEndingContent ending = new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of(new NegotiationItem("subscription_condition.incident_level", "critical"))); - -MetadataContent accept = server.generateNegotiationAcceptPromptFromData( - new NegotiationEndingData(context, ending), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); +在 `src/main/resources/application.properties` 中配置服务监听地址: -// 客户端以相同方式、使用 accept-reject 模板校验该接受消息: -// client.validateAcceptPromptAndDataFilling(accept.promptText(), context, schema, -// StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); +```properties +quarkus.http.host=127.0.0.1 +quarkus.http.port=26335 ``` -修改协商内容层时,运行: +启动服务端: ```bash -mvn -pl a2a-t-negotiation -am test +mvn clean package +java -jar target/quarkus-app/quarkus-run.jar ``` +服务端启动后会: + +1. 向注册中心注册 AgentCard。 +2. 启动 a2a-java 承载的 A2A REST 服务(`message:send` / `message:stream`)。 +3. 等待客户端请求,在 `AgentExecutor` 中校验 A2A-T prompt 后执行业务并推送任务事件。 + +> 本例在单次 `execute()` 内完结任务(`complete()` 后任务进入终态):`message:send` 同步模式下,客户端一次 HTTP 响应拿到聚合后的最终 Task(中间状态仅持久化在服务端);`message:stream` 流式模式下,客户端逐帧收到 WORKING、artifact、COMPLETED 事件。持续订阅上报的长驻任务写法(循环 `addArtifact`、不调用 `complete`)可参考 `a2a-t-sample` 的 `subscribe_incident` 用例。 +> +> 本样例基于 a2a-java 官方 REST 参考服务(Quarkus)承载。本仓库 `a2a-t-sample` 模块另提供了不依赖 Quarkus 的嵌入式端到端样例(真实 HTTP+JSON/REST 流式链路,以及协商、授权等场景),详见 [a2a-t-sample/README.zh-CN.md](../../a2a-t-sample/README.zh-CN.md)。 From d9b3c6dd7bc71cdadafacead711ebc2d5c86934f Mon Sep 17 00:00:00 2001 From: w00609601 Date: Fri, 28 Aug 2026 11:15:34 +0800 Subject: [PATCH 217/295] feat(sample): rework authz suite into paired design with customer-view schemas Rebuild the 100-case suite as 45 paired cases (44 differential pairs whose halves share byte-identical input while the variant carries a customer validate_schema, plus one deterministic interception pair) and 10 independent cases. Variant labels carry dimension suffixes (-varname/-varfields/-varflat/-varreq/-varsch/-dual/-varuuid) covering key renaming, field-set changes, flattening, requirement changes, input-schema variants and dual-schema coexistence; b6 dissolves into c1/b2/c3 pairs; the a-group shrinks to 10 with a data-starved interception pair. All schemas move to the customer view: keys switch to English camelCase (operationType, policyList, policyId, scene, actionType, operationName, validityPeriod) while values stay Chinese and slot.json/template rendering stay Chinese. Descriptions drop every extraction instruction (attribution rules, error-code hints, null timing, value-line format coaching) and carry only field meanings; format constraints live in variant schemas only (UUID/ISO/single-date variants), so the default suite proves the SDK judges no format the customer did not declare while variants prove declared constraints are enforced. The content_validation system prompts drop the not_found code, seal slot_name attribution to top-level parameter names, state the strict params key-set contract, and make the parameter schema and the template body equally binding validation sources with schema prevailing only on direct conflict. The template declares deletion to require the network operation authorization policy identifier (one or multiple entries); condition-delete pairs expect (policyList, missing_required) and a multi-identifier delete pair joins the suite. The smoke set becomes 15 seats (8 success incl. 2 differential pairs, 5 rejection incl. 1 pair, 2 interception); a fixed transcript generator (raw data+schema input, raw error JSON, full schema printing, diff-pair markers, key-leak scan, judgement footer) is added under a2a-t-sample/scripts, and README documents the paired design. Template lint and the sample test suite pass; smoke passes 15/15. Signed-off-by: w00609601 --- .../content_validation/en-US/system.md | 20 +- .../content_validation/zh-CN/system.md | 20 +- .../v1/en-US/slot.json | 2 +- .../v1/zh-CN/slot.json | 2 +- .../v1/en-US/template.md | 1 + .../v1/zh-CN/template.md | 1 + .../scripts/authz_smoke_transcript.py | 364 ++ .../resources/sample/authz-policy/README.md | 8 +- .../sample/authz-policy/param-schema.json | 20 +- .../sample/authz-policy/scenarios-100.json | 5648 +++++++++++------ .../sample/authz-policy/scenarios.json | 506 +- .../authz-policy/smoke-io-transcript.md | 1092 +++- .../authz_policy/AuthzSampleMainTest.java | 4 +- .../authz_policy/AuthzScenarioLoaderTest.java | 59 +- 14 files changed, 5189 insertions(+), 2558 deletions(-) create mode 100644 a2a-t-sample/scripts/authz_smoke_transcript.py diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md index 28dee412..6a4303fd 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md @@ -24,29 +24,31 @@ Output exactly one JSON object containing exactly the following 3 required keys; - params: the parameter object extracted from the input content per the parameter schema; output an empty object {} when no parameter can be extracted. ## Validation Tasks -1. Content completeness: first locate each parameter's actual value per "Input Content Structure", then check whether the value covers the required information of the parameter schema (including x-a2at-value-constraint). +1. Content completeness: first locate each parameter's actual value per "Input Content Structure", then check whether the value covers the required information. 2. Semantic consistency: whether the information in the input content is consistent with the meaning of the corresponding parameters, with no semantic conflicts or contradictions. 3. Value validity: whether the parameter values extracted from the input content are within reasonable ranges, with no obviously unreasonable or fabricated values. -4. Format compliance: whether the format of parameter values conforms to the formats specified in the parameter schema; "Requirements/Examples" explanatory text does not participate in the judgment. -5. Entry-level check: when a parameter value is a multi-entry list and the parameter schema's constraints specify per-entry required fields by operation type, you must first list the required-field list for that operation type, then count the actual number of fields in each entry and compare; fewer fields than required is a deterministic missing of required fields and MUST be reported as missing_required — even if you cannot determine which specific field is missing, you must report it; never let through an entry with an insufficient field count for any reason. -6. Validation boundary (two kinds of checks): - - Deterministic checks (strictly enforced; must not be let through on the grounds of "uncertain/maybe"): whether required fields exist, whether values are within the range enumerated by the constraint, whether the operation is explicitly forbidden by the constraint, whether identifier formats are valid, whether values are meaningless characters. +4. Format compliance: whether the format of parameter values conforms to the formats specified in the parameter schema. +5. Entry-level check: when a parameter value is a multi-entry list and the parameter schema or the template body specifies per-entry required fields by operation type, you must first list the required-field list for that operation type (synthesizing the parameter schema and the template body), then count the actual number of fields in each entry and compare; fewer fields than required is a deterministic missing of required fields and MUST be reported as missing_required — even if you cannot determine which specific field is missing, you must report it; never let through an entry with an insufficient field count for any reason. +6. Validation sources and priority: the parameter schema and the template body ("Requirements/Examples" explanatory text) are BOTH validation sources and BOTH must be enforced. The parameter schema typically declares only structure, field meanings and value constraints (enums etc.); field-level requirements carried by the template body (per-operation-type supported field ranges, modifiability declarations, required-field lists) have the same binding force as the schema — never ignore a template-body requirement merely because the schema does not restate it. Only when the two give a direct conflict on the same field does the parameter schema prevail. +7. Validation boundary (two kinds of checks): + - Deterministic checks (strictly enforced; must not be let through on the grounds of "uncertain/maybe"): whether required fields exist, whether values are within the range enumerated by the constraint, whether the operation is explicitly forbidden by the constraint (including modification/operation intents the template body declares unsupported), whether identifier formats are valid, whether values are meaningless characters. - Format-variant checks (pass by default; report an error only when the constraint is clearly violated): format variants such as date notations and separator styles pass unless the constraint explicitly excludes the variant; report only when the value clearly violates rules enumerated by the constraint (e.g., an invalid date value such as month 13, or a single date where the constraint requires a complete range). -7. Optional parameters: a parameter marked optional by the parameter schema or constraints never constitutes an error when its value is empty, under any operation type. +8. Optional parameters: a parameter marked optional by the parameter schema or constraints never constitutes an error when its value is empty, under any operation type. ## Parameter Extraction Task - The sole source of parameter extraction is each parameter section's "parameter value line" (the content with the bracketed annotation removed); do not extract from "Requirements:" or "Examples:" explanatory text. - Extract parameters from the input content per the parameter schema given in the user prompt and fill the params object. - The property names and structure of params must follow the parameter schema; output null for properties that cannot be extracted from the input content. +- The key set of params must exactly match the properties declared in the parameter schema: no extra keys, no missing keys, no renamed keys (the same rule applies to top-level properties and fields inside array items); never omit a key that cannot be extracted — output null, or an empty collection per the schema's type convention. - The parameter extraction result does not affect semantic_verdict; semantic_verdict is decided solely by the validation tasks. ## slot_name and code Convention - slot_name must exactly correspond to the parameter names defined in the parameter schema; errors must be attributed to the parameter that actually violates (e.g., a policy identifier problem is attributed to the parameter containing the policy identifier, not the operation type parameter). -- code may only use one of the following 5 values; do not invent other values: +- slot_name must be one of the top-level parameter names defined in the parameter schema; names of fields inside a parameter's internal structure (e.g., array item fields) must not be used. When the violation concerns a specific field of an item inside a parameter, attribute the error to the owning parameter name and describe the field location in the message (e.g., "invalid month in the validity period of entry 2 of the policy list"). +- code may only use one of the following 4 values; do not invent other values: - missing_required: the parameter is entirely missing (value line empty), or an entry lacks required fields explicitly demanded by the constraint - format_error: use this code for all format-class problems — incomplete date ranges, invalid date values (e.g., month 13), wrong date format, wrong separators, meaningless characters (e.g., xxx, ###) - invalid_value: non-format value violations — operation type out of range, modification intents on fields declared unmodifiable by the constraint, obviously nonexistent query condition values - semantic_mismatch: semantic conflicts between parameters or inconsistency with template requirements - - not_found: looking up an object by identifier, but the identifier is not in a valid identifier format (e.g., a policy identifier not in UUID format) — use this code for illegal identifier formats, not format_error -- Classification priority: when a problem matches the descriptions of multiple codes at once, prefer the code reflecting "content exists but violates" (invalid_value, format_error, not_found); use missing_required only when the content is entirely missing or purely missing required fields. When an entry expresses an intent that violates the constraint (e.g., modifying a field declared unmodifiable), do not switch to missing_required just because other fields are also missing. +- Classification priority: when a problem matches the descriptions of multiple codes at once, prefer the code reflecting "content exists but violates" (invalid_value, format_error); use missing_required only when the content is entirely missing or purely missing required fields. When an entry expresses an intent that violates the constraint (e.g., modifying a field declared unmodifiable), do not switch to missing_required just because other fields are also missing. - message should describe the specific error reason in English. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md index 34817649..8b13540b 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md @@ -30,31 +30,33 @@ - params:按参数 schema 从输入内容中提取的参数对象;无参数可提取时输出空对象 {}。 ## 校验任务 -1. 内容完整性:先按"输入内容的结构"定位每个参数的实际值,再检查该值是否涵盖参数 schema(含模板正文中的字段要求)要求的必要信息。 +1. 内容完整性:先按"输入内容的结构"定位每个参数的实际值,再检查该值是否涵盖要求的必要信息。 2. 语义一致性:输入内容中的各项信息是否与对应参数的含义一致,不得出现语义冲突或矛盾。 3. 取值合法性:输入内容中提取的参数值是否在合理范围内,不得出现明显不合理或伪造的值。 -4. 格式合规性:参数值的格式是否符合参数 schema 规定的格式;模板正文中的"要求/举例"等说明文字可作为校验依据,但输入内容本身不包含这些说明文字。 -5. 条目级检查:当参数值包含多个条目、且参数 schema 或模板正文规定了每条必须包含的必要信息时,必须先列出参数 schema 规定的该操作类型条目必填字段清单,再逐条核对每个条目实际涵盖的必要信息并比对。条目缺少任何必要信息即为确定性的缺失,必须报告 missing_required——即使无法确定缺少的具体是哪一项,也必须报告;不得以任何理由放行信息不全的条目。 +4. 格式合规性:参数值的格式是否符合参数 schema 规定的格式。 +5. 条目级检查:当参数值包含多个条目、且参数 schema 或模板正文规定了每条必须包含的必要信息时,必须先列出该操作类型条目必填字段清单(参数 schema 与模板正文综合),再逐条核对每个条目实际涵盖的必要信息并比对。条目缺少任何必要信息即为确定性的缺失,必须报告 missing_required——即使无法确定缺少的具体是哪一项,也必须报告;不得以任何理由放行信息不全的条目。 -6. 校验边界(区分两类检查): - - 确定性检查(必须严格执行,不得以"不确定/可能"为由放行):必填字段是否存在、取值是否在约束列举的范围内、是否为约束明确禁止的操作、值是否为无意义字符、日期区间是否倒置(起始日期晚于结束日期)。 +6. 校验依据与优先级:参数 schema 与模板正文("要求/举例"等说明文字)**同为校验依据,均必须执行**。参数 schema 通常仅声明结构、字段含义与取值约束(enum 等);**模板正文承载的字段级要求(各操作类型支持的字段范围、可修改性声明、必填字段清单)与 schema 具有同等约束力**——不得因 schema 未复述而忽略模板正文的要求。当且仅当两者对同一字段给出直接冲突的约束时,以参数 schema 为准。 +7. 校验边界(区分两类检查): + - 确定性检查(必须严格执行,不得以"不确定/可能"为由放行):必填字段是否存在、取值是否在约束列举的范围内、是否为约束明确禁止的操作(含模板正文声明不支持的修改/操作意图)、值是否为无意义字符、日期区间是否倒置(起始日期晚于结束日期)。 - 格式变体检查(默认通过,仅当明确违反约束时才报错):日期写法、分隔符风格等格式变体,若约束未明确排除该变体,则一律通过;只有当值明确违反约束列举的规则(如日期取值 13 月无效、约束要求完整区间而只给了单个日期)时才报错。 -7. 可选参数:参数 schema 或约束标注为可选的参数,其值为空不构成错误。 +8. 可选参数:参数 schema 或约束标注为可选的参数,其值为空不构成错误。 ## 参数提取任务 - 参数提取的唯一来源是各参数章节的"参数值行"(渲染后的实际参数值);不要从模板正文中提取。 - 按参数 schema 定义的结构从参数值行中提取参数并填充 params 对象;值行中未提供的信息输出 null 或空集合(按 schema 的类型约定)。 - params 的属性名与结构必须遵循参数 schema;无法从输入内容中提取到的属性输出 null。 +- params 的键集必须与参数 schema 声明的属性完全一致:不能多、不能少、不能改名(顶层属性与数组条目内的字段同此规则);提取不到的键不得省略,值输出 null 或按 schema 的类型约定输出空集合。 - 参数提取结果不影响 semantic_verdict;semantic_verdict 只由校验任务决定。 ## slot_name 与 code 规范 - slot_name 必须与参数 schema 中定义的参数名精确对应;错误必须归因到实际违规的那个参数(归因到实际包含违规内容的参数,而不是其他参数)。 -- code 只能使用以下 5 个取值之一,不得自造其他值: +- slot_name 只能取参数 schema 顶层定义的参数名,不得使用参数内部结构(如数组条目)中的字段名;当错误发生在某参数内部条目的具体字段(如列表条目的有效期、处置类型)时,slot_name 仍填该参数名,具体字段定位写入 message(如"策略列表第2条的有效期月份无效")。 +- code 只能使用以下 4 个取值之一,不得自造其他值: - missing_required:参数完全缺失(参数值行为空),或条目缺少约束明确要求的必填字段 - format_error:格式类问题一律使用此码——日期区间不完整、日期取值无效(如 13 月)、日期格式错误、分隔符错误、无意义字符(如 xxx、###) - invalid_value:非格式类取值违规——enum 字段取值不在取值范围内、修改意图涉及约束声明不可修改的字段、查询条件取值明显不存在 - semantic_mismatch:参数之间存在语义冲突或与模板要求不一致,也包括同一参数内同一字段出现多个相互冲突的取值(如某一条目内同一字段同时给出两个互斥的值) - - not_found:按标识查找对象,但系统中无该标识对应的记录;内容校验阶段不依据标识格式判定存在性 -- 归类优先级:当一个问题同时命中多个 code 的描述时,优先报告反映"内容存在但违规"的 code(invalid_value、format_error、not_found);仅当内容完全缺失或纯粹缺少必填字段时才使用 missing_required。条目表达了违反约束的意图(如修改约束声明不可修改的字段)时,不得因同时缺少其他字段而改报 missing_required。当冲突源于同一字段内取值互斥(semantic_mismatch 定义覆盖的"同一字段多个相互冲突的取值"情形)且同时存在格式问题时,semantic_mismatch 优先于 format_error 报告。 +- 归类优先级:当一个问题同时命中多个 code 的描述时,优先报告反映"内容存在但违规"的 code(invalid_value、format_error);仅当内容完全缺失或纯粹缺少必填字段时才使用 missing_required。条目表达了违反约束的意图(如修改约束声明不可修改的字段)时,不得因同时缺少其他字段而改报 missing_required。当冲突源于同一字段内取值互斥(semantic_mismatch 定义覆盖的"同一字段多个相互冲突的取值"情形)且同时存在格式问题时,semantic_mismatch 优先于 format_error 报告。 - message 使用中文描述具体错误原因。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/en-US/slot.json index 108ed244..1c44d737 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/en-US/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/en-US/slot.json @@ -20,7 +20,7 @@ "1. policy identifier is 7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", "1. business scenario is service complaint diagnosis" ], - "x-a2at-value-constraint": "List text form. Content composition: policy identifier (UUID unique identifier), business scenario, handling type, operation name, validity period. Each entry starts with a number followed by '.' (e.g., '1.'), with numbers increasing consecutively from 1; within an entry, each field is expressed as 'field name is value', with multiple fields separated by commas; multiple entries are each on their own line (separated by newlines). The field names are fixed as follows: business scenario, handling type, operation name, validity period (add form); policy identifier, validity period (modify form); policy identifier (delete form); query conditions use only the fields actually referenced (e.g., business scenario, handling type). The validity period is a date range (common date notations are acceptable, timestamps not required, date notations may differ across entries within the same list, date values must be real and valid) or \"permanently valid\"; both are valid. add authorization policy: required; each entry must contain 'business scenario is X, handling type is Y, operation name is Z, validity period is V' (in this order) — four fields in total, no policy identifier needed; the validity period must be a complete range or \"permanently valid\"; a single date alone is treated as an incomplete range; field values must be meaningful business content, and meaningless characters (e.g., xxx, ###) constitute a format error. modify authorization policy: the entry format is '1. policy identifier is X, validity period is V' (policy identifier first, validity period second); an entry may also contain only the validity period (e.g., '1. validity period is V', treated as batch modification, equally valid); when the validity period can be determined directly from the modification description, prefer the concise form '1. policy identifier is X, validity period is V' (e.g., \"1. policy identifier is 7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3, validity period is permanently valid\"); only when the description is too complex to simplify, keep the combination of a policy identifier and a natural-language modification description (e.g., \"1. policy identifier is 7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3, validity period changed to permanently valid\") or a natural-language modification description alone (e.g., \"1. validity period changed to 2026-01-01~2028-12-31\", equally valid); the validity period may be a date range, a single date, or \"permanently valid\"; only the validity period can be modified, and modification intents on other fields are invalid; an entry with only a policy identifier and no validity period or modification description is an invalid modification request. delete authorization policy: the entry is '1. policy identifier is X'; a condition-style description is also valid (e.g., '1. handling type is service recovery', '1. business scenario is service complaint diagnosis', treated as deleting by filtering on that condition). query authorization policy: optional; not providing this parameter is fully valid; query conditions may be one or more values of business scenario, handling type, or operation name, no complete entry format required. Query conditions must carry field labels in the form 'field name is value' (e.g., 'business scenario is signal coverage optimization'); multiple conditions are separated by commas, and multiple condition groups are numbered, each on its own line. A query condition value is valid by default; it is treated as nonexistent only when the value itself declares nonexistence (e.g., contains self-declared-nonexistent wording such as \"nonexistent\" — for example \"nonexistent scenario XYZ\") or is meaningless characters. When an explicit policy identifier appears but is not in UUID format, the policy cannot be located." + "x-a2at-value-constraint": "List text form. Content composition: policy identifier (UUID unique identifier), business scenario, handling type, operation name, validity period. Each entry starts with a number followed by '.' (e.g., '1.'), with numbers increasing consecutively from 1; within an entry, each field is expressed as 'field name is value', with multiple fields separated by commas; multiple entries are each on their own line (separated by newlines). The field names are fixed as follows: business scenario, handling type, operation name, validity period (add form); policy identifier, validity period (modify form); policy identifier (delete form; each delete entry must provide the policy identifier, one or multiple entries supported); query conditions use only the fields actually referenced (e.g., business scenario, handling type). The validity period is a date range (common date notations are acceptable, timestamps not required, date notations may differ across entries within the same list, date values must be real and valid) or \"permanently valid\"; both are valid. add authorization policy: required; each entry must contain 'business scenario is X, handling type is Y, operation name is Z, validity period is V' (in this order) — four fields in total, no policy identifier needed; the validity period must be a complete range or \"permanently valid\"; a single date alone is treated as an incomplete range; field values must be meaningful business content, and meaningless characters (e.g., xxx, ###) constitute a format error. modify authorization policy: the entry format is '1. policy identifier is X, validity period is V' (policy identifier first, validity period second); an entry may also contain only the validity period (e.g., '1. validity period is V', treated as batch modification, equally valid); when the validity period can be determined directly from the modification description, prefer the concise form '1. policy identifier is X, validity period is V' (e.g., \"1. policy identifier is 7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3, validity period is permanently valid\"); only when the description is too complex to simplify, keep the combination of a policy identifier and a natural-language modification description (e.g., \"1. policy identifier is 7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3, validity period changed to permanently valid\") or a natural-language modification description alone (e.g., \"1. validity period changed to 2026-01-01~2028-12-31\", equally valid); the validity period may be a date range, a single date, or \"permanently valid\"; only the validity period can be modified, and modification intents on other fields are invalid; an entry with only a policy identifier and no validity period or modification description is an invalid modification request. delete authorization policy: the entry is '1. policy identifier is X'; a condition-style description is also valid (e.g., '1. handling type is service recovery', '1. business scenario is service complaint diagnosis', treated as deleting by filtering on that condition). query authorization policy: optional; not providing this parameter is fully valid; query conditions may be one or more values of business scenario, handling type, or operation name, no complete entry format required. Query conditions must carry field labels in the form 'field name is value' (e.g., 'business scenario is signal coverage optimization'); multiple conditions are separated by commas, and multiple condition groups are numbered, each on its own line. A query condition value is valid by default; it is treated as nonexistent only when the value itself declares nonexistence (e.g., contains self-declared-nonexistent wording such as \"nonexistent\" — for example \"nonexistent scenario XYZ\") or is meaningless characters. When an explicit policy identifier appears but is not in UUID format, the policy cannot be located." } }, "required": [ diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/zh-CN/slot.json index 925dc2a9..d3d6d31e 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/zh-CN/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/zh-CN/slot.json @@ -20,7 +20,7 @@ "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", "1. 业务场景是业务投诉诊断" ], - "x-a2at-value-constraint": "列表文本形态,内容构成:策略标识(UUID 唯一标识)、业务场景、处置类型、操作名称、有效期。当'授权策略的操作类型'为新增授权策略、修改授权策略或删除授权策略时,本字段为必填;为查询授权策略时选填。必填操作类型下未提供列表内容(值为空)应报告 missing_input 错误,不应返回 null。每个条目以编号加'.'开头(如'1.'),编号从 1 起连续递增;条目内各字段以'字段名是值'形式表达,多个字段用全角逗号','分隔;多个条目各自独立成行(换行分隔),每行以编号加'.'开头。字段名固定为:业务场景、处置类型、操作名称、有效期(新增形);策略标识、有效期(修改形);策略标识(删除形);查询条件按实际使用的字段(如业务场景、处置类型)。日期统一使用 YYYY-MM-DD~YYYY-MM-DD 区间格式(自然语言日期须归一为该格式,如\"2026年8月1日到2030年12月31日\"归一为\"2026-08-01~2030-12-31\")或\"永久生效\"。新增授权策略时:每条须包含'业务场景是X,处置类型是Y,操作名称是Z,有效期是V'四个字段(按此顺序),无需策略标识;有效期须为完整区间或\"永久生效\"。修改授权策略时:条目为'1. 策略标识是X,有效期是V'(策略标识在前、有效期在后);也可仅包含有效期(如'1. 有效期是V',视为批量修改,同样有效);当修改描述中的有效期可以直接确定时,条目优先采用'1. 策略标识是X,有效期是V'的简洁形态(如\"1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效\");描述复杂无法简化时,才保留策略标识与自然语言修改描述的组合(如\"1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期改为永久生效\")或自然语言修改描述(如\"1. 有效期修改为2026-01-01~2028-12-31\",同样有效);有效期须为完整日期区间或\"永久生效\"。删除授权策略时:条目为'1. 策略标识是X';也可用条件方式(如'1. 处置类型是业务抢通'、'1. 业务场景是业务投诉诊断',视为按该条件筛选删除)。查询授权策略时选填:不提供该参数完全合法;查询条件可为业务场景、处置类型、操作名称的一个或多个取值,无需完整条目。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),多个条件用全角逗号分隔,多组条件各自独立成行并编号。" + "x-a2at-value-constraint": "列表文本形态,内容构成:策略标识(UUID 唯一标识)、业务场景、处置类型、操作名称、有效期。当'授权策略的操作类型'为新增授权策略、修改授权策略或删除授权策略时,本字段为必填;为查询授权策略时选填。必填操作类型下未提供列表内容(值为空)应报告 missing_input 错误,不应返回 null。每个条目以编号加'.'开头(如'1.'),编号从 1 起连续递增;条目内各字段以'字段名是值'形式表达,多个字段用全角逗号','分隔;多个条目各自独立成行(换行分隔),每行以编号加'.'开头。字段名固定为:业务场景、处置类型、操作名称、有效期(新增形);策略标识、有效期(修改形);策略标识(删除形,删除条目须提供策略标识,可一条或多条);查询条件按实际使用的字段(如业务场景、处置类型)。日期统一使用 YYYY-MM-DD~YYYY-MM-DD 区间格式(自然语言日期须归一为该格式,如\"2026年8月1日到2030年12月31日\"归一为\"2026-08-01~2030-12-31\")或\"永久生效\"。新增授权策略时:每条须包含'业务场景是X,处置类型是Y,操作名称是Z,有效期是V'四个字段(按此顺序),无需策略标识;有效期须为完整区间或\"永久生效\"。修改授权策略时:条目为'1. 策略标识是X,有效期是V'(策略标识在前、有效期在后);也可仅包含有效期(如'1. 有效期是V',视为批量修改,同样有效);当修改描述中的有效期可以直接确定时,条目优先采用'1. 策略标识是X,有效期是V'的简洁形态(如\"1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效\");描述复杂无法简化时,才保留策略标识与自然语言修改描述的组合(如\"1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期改为永久生效\")或自然语言修改描述(如\"1. 有效期修改为2026-01-01~2028-12-31\",同样有效);有效期须为完整日期区间或\"永久生效\"。删除授权策略时:条目为'1. 策略标识是X';也可用条件方式(如'1. 处置类型是业务抢通'、'1. 业务场景是业务投诉诊断',视为按该条件筛选删除)。查询授权策略时选填:不提供该参数完全合法;查询条件可为业务场景、处置类型、操作名称的一个或多个取值,无需完整条目。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),多个条件用全角逗号分隔,多组条件各自独立成行并编号。" } }, "required": [ diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/en-US/template.md index b12f6db3..4535b96d 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/en-US/template.md @@ -21,6 +21,7 @@ Requirement: 2. When adding an authorization policy, the required parameters include: network operation business scenario, network operation handling type, network operation name, validity period When modifying an authorization policy, the parameter that can be modified is: validity period When querying authorization policies, the supported query conditions are: network operation business scenario, network operation handling type, network operation name + When deleting authorization policies, the required parameters include: network operation authorization policy identifier ## Expected Output 1. Authorization operation execution result. Allowed values: success, failure, partial success diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md index 0a587093..9530c7cf 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md @@ -21,6 +21,7 @@ 2. 创建授权策略时,必填参数包括:动网操作的业务场景、动网操作的处置类型、动网操作名称、有效期 修改授权策略时,支持修改的参数为:有效期 查询授权策略时支持的查询条件为:动网操作的业务场景、动网操作的处置类型、动网操作名称 + 删除授权策略时,必填参数包括:动网操作的授权策略标识 ## 预期输出 1. 授权操作执行结果,取值范围: 成功、失败、部分成功; diff --git a/a2a-t-sample/scripts/authz_smoke_transcript.py b/a2a-t-sample/scripts/authz_smoke_transcript.py new file mode 100644 index 00000000..a8a00892 --- /dev/null +++ b/a2a-t-sample/scripts/authz_smoke_transcript.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +"""将 smoke 报告 JSON 确定性渲染为 markdown 逐例原始输入输出记录(transcript)。 + +用法: + python authz_smoke_transcript.py [--out OUT] [--scenarios P] [--param-schema P] + +输入: + report.json — demo 冒烟运行的 JSON 报告(结构: meta / summary / scenarios[])。 + scenarios.json — 冒烟题集,提供每例原始输入(from_text 的 text / + from_data_with_schema 的 data+input.schema)与场景级 validate_schema。 + param-schema.json — suite 级默认提参 schema。 + +输出: + markdown 逐例两段式记录(客户端生成 / 服务端校验提参),文末附差分对判读汇总表。 + 格式按 D16 定稿:data 场景打印原始 data 与 input.schema 两份 JSON;客户端报错打印原始 + 错误 JSON;服务端拒绝打印原始 errors 数组 JSON;默认 schema 亦全文打印。键名泄漏扫描 + (D13)对挂变异 schema 的用例生效。 + +仅依赖标准库;所有读写均为 utf-8。 +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +# 变异例 label 后缀(基线 label = 去掉后缀) +VARIANT_SUFFIXES = ("-varname", "-varfields", "-varflat", "-varreq", "-varsch", "-dual") + +# 默认 schema 中会被变异重命名、用于键名泄漏扫描的键(operationType 恒定不改键) +DEFAULT_SCAN_KEYS = ("policyList", "policyId", "scene", "actionType", "operationName", "validityPeriod") + +# 冒烟集固定差分对(基线, 变异后缀) +DIFF_PAIRS = ( + ("c1-nl-add-01", "-varname"), + ("c3-nl-mod-06", "-varfields"), + ("b2-nl-format-01", "-varreq"), +) + +UNCOLLECTED = "(未采集)" + + +def _reconfigure_stdio() -> None: + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8") + except Exception: + pass + + +def load_json(path: str): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def jprint(obj) -> str: + return json.dumps(obj, ensure_ascii=False, indent=2) + + +def split_variant_suffix(label: str): + for suffix in VARIANT_SUFFIXES: + if label.endswith(suffix): + return label[: -len(suffix)], suffix + return label, None + + +def collect_dict_keys(node, acc: set) -> None: + """递归收集任意嵌套 dict/list 结构中被用作 dict 键名(key)的字符串。""" + if isinstance(node, dict): + for key, value in node.items(): + if isinstance(key, str): + acc.add(key) + collect_dict_keys(value, acc) + elif isinstance(node, list): + for item in node: + collect_dict_keys(item, acc) + + +def collect_schema_property_names(schema, acc: set) -> None: + """递归收集 JSON Schema 中声明过的属性名(properties 键)。""" + if not isinstance(schema, dict): + return + properties = schema.get("properties") + if isinstance(properties, dict): + for key, sub in properties.items(): + if isinstance(key, str): + acc.add(key) + collect_schema_property_names(sub, acc) + for singleton in ("items", "additionalProperties", "contains", "propertyNames", "not"): + sub = schema.get(singleton) + if isinstance(sub, dict): + collect_schema_property_names(sub, acc) + for combine in ("allOf", "oneOf", "anyOf"): + for sub in schema.get(combine, []) or []: + collect_schema_property_names(sub, acc) + + +def leakage(actual_params, validate_schema): + """返回 actual_params 中出现的、未在当前变异 schema 中声明的默认 schema 键。""" + if actual_params is None or not isinstance(validate_schema, dict): + return [] + declared: set = set() + collect_schema_property_names(validate_schema, declared) + used: set = set() + collect_dict_keys(actual_params, used) + return [key for key in DEFAULT_SCAN_KEYS if key in used and key not in declared] + + +def status_text(match) -> str: + if match is None: + return "—" + return "✅" if match else "❌" + + +def diff_verdict(base_match, variant_match) -> str: + if base_match and variant_match: + return "健康" + if base_match and not variant_match: + return "schema 映射问题" + if not base_match and variant_match: + return "内容理解问题" + return "公共依赖问题" + + +def compact_slot_errors(errors) -> str: + if not errors: + return "[]" + parts = ["%s:%s" % (e.get("slot_name"), e.get("code")) for e in errors] + return "[" + ", ".join(parts) + "]" + + +def mismatch_note(entry) -> str: + assertions = entry.get("assertions") or {} + actual = entry.get("actual_outcome") + errs = compact_slot_errors(entry.get("actual_slot_errors") or []) + expected_client = entry.get("expected_client") or {} + expected_server = entry.get("expected_server") or {} + if assertions.get("client_prompt") is None: + return "客户端阶段结果与期望不符(actual_outcome=%s,slot_errors=%s,期望 outcome=%s)" % ( + actual, + errs, + expected_client.get("outcome"), + ) + if assertions.get("server_outcome") is False: + return "服务端结果与期望不符(actual_outcome=%s,slot_errors=%s,期望 outcome=%s)" % ( + actual, + errs, + expected_server.get("outcome"), + ) + if assertions.get("server_params") is False: + return "服务端提参结果与期望 params 不符(见上方提参结果与 expected.params 对照)" + return "actual_outcome=%s,slot_errors=%s" % (actual, errs) + + +def render_client_input(scenario_def, entry_kind) -> list: + lines = [] + inp = scenario_def.get("input") or {} + if entry_kind == "from_data_with_schema": + lines.append("**原始输入**(from_data_with_schema)") + lines.append("") + lines.append("data:") + lines.append("```json") + lines.append(jprint(inp.get("data"))) + lines.append("```") + lines.append("input.schema:") + lines.append("```json") + lines.append(jprint(inp.get("schema"))) + lines.append("```") + else: + lines.append("**原始输入**(from_text)") + lines.append("") + lines.append("> %s" % (inp.get("text") or "")) + lines.append("") + return lines + + +def render_scenario(entry, scenario_def, param_schema) -> list: + lines = [] + label = entry.get("label", "?") + match = bool(entry.get("match")) + mark = "✅ match" if match else "❌ MISMATCH" + lines.append("## %s — %s" % (label, mark)) + + baseline, suffix = split_variant_suffix(label) + if suffix: + lines.append("[【差分对】配对基线:%s]" % baseline) + lines.append("") + + entry_kind = (scenario_def or {}).get("entry") or entry.get("entry") or "from_text" + prompt_text = entry.get("prompt_text") + client_failed = prompt_text is None + + lines.append("### 客户端(生成)") + lines.append("") + lines.extend(render_client_input(scenario_def or {}, entry_kind)) + + if client_failed: + error = entry.get("error") or {} + fail_obj = { + "code": error.get("code") or entry.get("actual_outcome"), + "message": error.get("message"), + "slot_errors": entry.get("actual_slot_errors") or [], + } + lines.append("**生成结果:失败**") + lines.append("") + lines.append("```json") + lines.append(jprint(fail_obj)) + lines.append("```") + else: + lines.append("**生成结果:成功(渲染后 prompt)**") + lines.append("") + lines.append("```") + lines.append(prompt_text) + lines.append("```") + lines.append("") + lines.append("**提参理由(LLM reasoning)**:%s" % (entry.get("client_reasoning") or UNCOLLECTED)) + lines.append("") + + lines.append("### 服务端(校验 + 提参)") + lines.append("") + if client_failed: + lines.append("(客户端生成失败路径,服务端未被调用。)") + lines.append("") + else: + validate_schema = (scenario_def or {}).get("validate_schema") + if validate_schema: + schema_label = "本用例变异 schema" + schema_to_print = validate_schema + else: + schema_label = "suite 默认 param-schema.json" + schema_to_print = param_schema + lines.append("**validateAndDataFilling 输入 schema(%s)**:" % schema_label) + lines.append("") + lines.append("```json") + lines.append(jprint(schema_to_print)) + lines.append("```") + lines.append("") + lines.append("(输入 prompt 即上方客户端渲染结果,此处不重复。)") + lines.append("") + outcome = entry.get("actual_outcome") + lines.append("**校验与提参结果**:`%s`" % outcome) + lines.append("") + if outcome == "success": + lines.append("**提参结果**:") + lines.append("") + lines.append("```json") + lines.append(jprint(entry.get("actual_params"))) + lines.append("```") + else: + lines.append("```json") + lines.append(jprint(entry.get("actual_slot_errors") or [])) + lines.append("```") + lines.append("") + lines.append("**校验理由(LLM reasoning)**:%s" % (entry.get("server_reasoning") or UNCOLLECTED)) + lines.append("") + if validate_schema is None: + leak_line = "**键名泄漏扫描**:无泄漏" + else: + leaks = leakage(entry.get("actual_params"), validate_schema) + leak_line = "**键名泄漏扫描**:无泄漏" if not leaks else ( + "**键名泄漏扫描**:❌ 泄漏——actual_params 出现 schema 外键:" + ", ".join(leaks) + ) + lines.append(leak_line) + lines.append("") + + assertions = entry.get("assertions") or {} + client_prompt = assertions.get("client_prompt") + lines.append("**判定**:match=%s(client_prompt=%s)" % (match, repr(client_prompt))) + if not match: + lines.append("") + lines.append("> ❌ mismatch 说明:%s" % mismatch_note(entry)) + return lines + + +def render_report(report, scenarios_index, param_schema, report_path) -> str: + lines = [] + meta = report.get("meta") or {} + summary = report.get("summary") or {} + scenario_entries = report.get("scenarios") or [] + total = summary.get("total", len(scenario_entries)) + match_count = summary.get("match") + if match_count is None: + match_count = sum(1 for c in scenario_entries if c.get("match")) + scenarios_resource = meta.get("scenariosResource", "scenarios.json") + + lines.append("# Authorization-T Demo 冒烟测试原始输入输出记录") + lines.append("") + lines.append( + "- 位置:`%s`;题集:`%s`;LLM:蓝区统一网关(key 不落档);参数:`-Dauthz.reasoning=true`;" + "结果:**%s/%s match**" % (report_path, scenarios_resource, match_count, total) + ) + lines.append("- 分区:预期成功在前(index 0-7)、预期拒绝在中(index 8-12)、客户端拦截在末尾(index 13-14)") + lines.append("- 每例两段:客户端(输入→生成结果→理由)与服务端(schema 输入→校验提参结果→理由);服务端段不重复渲染后 prompt") + lines.append("") + + for entry in scenario_entries: + scenario_def = scenarios_index.get(entry.get("label")) + lines.append("---") + lines.append("") + lines.extend(render_scenario(entry, scenario_def, param_schema)) + lines.append("") + + lines.append("---") + lines.append("") + lines.append("## 差分对判读汇总") + lines.append("") + lines.append("| 差分对 | 基线半 | 变异半 | 判读 |") + lines.append("|---|---|---|---|") + match_by_label = {c.get("label"): bool(c.get("match")) for c in scenario_entries} + for base, suffix in DIFF_PAIRS: + variant = base + suffix + bm = match_by_label.get(base) + vm = match_by_label.get(variant) + verdict = "—" if bm is None or vm is None else diff_verdict(bm, vm) + lines.append("| %s/%s | %s | %s | %s |" % (base, suffix, status_text(bm), status_text(vm), verdict)) + + return "\n".join(lines) + "\n" + + +def default_paths(args): + script_dir = os.path.dirname(os.path.abspath(__file__)) + scenarios = args.scenarios or os.path.normpath( + os.path.join(script_dir, "..", "src", "main", "resources", "sample", "authz-policy", "scenarios.json") + ) + param_schema = args.param_schema or os.path.normpath( + os.path.join(script_dir, "..", "src", "main", "resources", "sample", "authz-policy", "param-schema.json") + ) + out = args.out or os.path.join(os.path.dirname(scenarios), "smoke-io-transcript.md") + return scenarios, param_schema, out + + +def main(argv=None) -> int: + _reconfigure_stdio() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("report", help="smoke 运行报告的 JSON 路径") + parser.add_argument("--out", "-o", help="输出 .md 路径(默认:scenarios.json 同目录 smoke-io-transcript.md)") + parser.add_argument("--scenarios", help="冒烟题集 scenarios.json 路径(默认相对仓库布局)") + parser.add_argument("--param-schema", dest="param_schema", help="默认 param-schema.json 路径(默认相对仓库布局)") + args = parser.parse_args(argv) + + scenarios_path, param_schema_path, out_path = default_paths(args) + + report = load_json(args.report) + scenarios_raw = load_json(scenarios_path) + param_schema = load_json(param_schema_path) + + scenarios_index = {} + for scenario in scenarios_raw.get("scenarios", []): + label = scenario.get("label") + if label is not None: + scenarios_index[label] = scenario + + transcript = render_report(report, scenarios_index, param_schema, args.report) + + with open(out_path, "w", encoding="utf-8", newline="\n") as f: + f.write(transcript) + print("wrote %s" % out_path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/README.md b/a2a-t-sample/src/main/resources/sample/authz-policy/README.md index d15a5df1..69d0f3fe 100644 --- a/a2a-t-sample/src/main/resources/sample/authz-policy/README.md +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/README.md @@ -45,7 +45,7 @@ A2AT_LLM_API_KEY=<凭据> # 绝不能提交、打印或写入任何产 以下命令均在仓库根目录执行,`` 替换为上一步准备的 env 文件(单行命令,任何 shell 通用): ``` -# 冒烟(默认题集,本目录 scenarios.json,15 例:a/b1-b4 各组负例代表 + 单条/多条列表(NL 与 data、2~3 条、批量修改/删除、部分条目格式错,含 1 席泛化异构词弱锚单条 + b6 变异 schema 组 3 例) +# 冒烟(默认题集,本目录 scenarios.json,15 例 = 成功 8(含 2 个差分对:c1-varname 改键名、c3-varfields 字段精简)+ 拒绝 5(含 1 个差分对:b2-varreq 要求变异)+ 拦截 2(a 拦截对基线半 + 否定意图)) java -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 @a2a-t-sample/target/authz.javaargs.txt # 全量(原题集 100 例,评分基线用) @@ -61,9 +61,9 @@ env 路径也可省略:省略时按"当前目录 authz.env → classpath 模 ### 1.4 题集结构与变异 schema 组 -全量/留出集按组前缀划分:`a`(操作类型识别)、`b1`(必填缺失)、`b2`(日期/日历格式)、`b3`(修改语义)、`b4`(非法标识)、`b5`(其他校验)、`b6`(变异 schema 组)、`c1`-`c6`(增删改查/多条目/混合)。 +全量集按组前缀划分:`a`(操作类型识别,含 1 个确定性拦截对)、`b1`(必填缺失)、`b2`(日期/日历格式)、`b3`(修改语义/取值冲突)、`b4`(非 UUID 裸值的语义重归因)、`b5`(其他校验)、`c1`-`c6`(增删改查/多条目/混合)。全量为配对制:44 个差分对(基线例 + 变异常例:同输入文本,变体挂客户 `validate_schema`)+ 1 个拦截对 + 10 个独立例 = 100。变体 label 后缀标注维度:`-varname`(改键名)/`-varfields`(字段增减)/`-varflat`(层级平铺)/`-varreq`(要求不同)/`-varsch`(from_data 的 input.schema 变异)/`-dual`(input.schema 与 validate_schema 双在场且互不同构)。 -`b6` 组验证调用方 `param-schema` 约束在服务端校验阶段生效:b6-schema-variant-02/03 通过场景级 `validate_schema` 字段注入"带处置类型短语规则"的变异 schema(完整 JSON 内嵌,不引用外部文件),b6-schema-variant-01 不设 `validate_schema` 作为对照。`validate_schema` 是可选的场景级覆盖字段:缺省时使用 suite 级默认 `param-schema.json`;设置为非空对象时替代默认 schema 参与服务端校验。 +变异 schema 通过场景级 `validate_schema` 字段注入(完整 JSON 内嵌,不引用外部文件),验证"调用方 param-schema 是服务端校验与提参的唯一契约":params 键集必须与 validate_schema 声明的属性完全一致(不能多、不能少、不能改名;提取不到输出 null 或按类型约定输出空集合),报错 slot_name 归因到 validate_schema 的顶层参数名。`validate_schema` 是可选的场景级覆盖字段:缺省时使用 suite 级默认 `param-schema.json`;设置为非空对象时替代默认 schema 参与服务端校验。差分判读:基线✅变异✅=健康;基线✅变异❌=schema 映射问题;基线❌=内容理解问题;双❌=公共依赖问题。 授权策略列表值行采用"编号 + 字段名是值"渲染格式(如 `1. 业务场景是校园专网,处置类型是紧急扩容,操作名称是天线调整,有效期是2026-08-01~2030-12-31`),字段间用全角逗号分隔,多个条目各自独立成行(换行分隔)并顺延编号;该口径在 `slot.json` 与 `param-schema.json` 的 description 中给出,服务端提参与客户端渲染据此归一。 @@ -77,7 +77,7 @@ JSON 报告写入 `eval-results/authz-demo/authz-report-.json` ``` summary: { total, match, mismatch } # 总分 scenarios[]: - label # 用例标识(组前缀 a/b1-b6/c1-c6) + label # 用例标识(组前缀 a/b1-b5/c1-c6,变体带 -varname 等维度后缀) match # 本例是否通过(布尔) assertions: client_prompt # 客户端 promptText 比对:true/false=DRIFT(漂移,不计分)/null=客户端失败路径 diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/param-schema.json b/a2a-t-sample/src/main/resources/sample/authz-policy/param-schema.json index a64b7b23..6174f08c 100644 --- a/a2a-t-sample/src/main/resources/sample/authz-policy/param-schema.json +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/param-schema.json @@ -1,7 +1,7 @@ { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -11,31 +11,31 @@ ], "description": "授权策略的操作类型" }, - "策略列表": { + "policyList": { "type": "array", - "description": "动网操作的授权策略列表,按输入中的出现顺序排列。参数值中每条条目以编号加'.'开头(如'1.'),条目内各字段以'字段名是值'形式表达并用逗号分隔,多个条目各自独立成行(换行分隔)。该参数未提供时输出空数组 []。各操作类型对每条的字段要求不同:新增条目须包含业务场景、处置类型、操作名称、有效期;删除条目可为策略标识,也可仅为条件字段(业务场景、处置类型、操作名称之一或多个),无需其他字段,不得按新增的必填字段要求检查删除条目。修改授权策略时,每条须包含策略标识与新有效期,缺少新有效期判 missing_required。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),带标签时按标签归因字段。", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", "items": { "type": "object", "properties": { - "策略标识": { + "policyId": { "type": "string", - "description": "策略的UUID唯一标识。UUID 形态的值归入本字段;非 UUID 的裸值按其内容语义归入业务场景/处置类型/操作名称字段。新增类策略无标识时为null;删除场景可按条件字段方式描述(此时策略标识为null)。内容校验阶段不依据标识格式判定存在性" + "description": "策略的唯一标识" }, - "业务场景": { + "scene": { "type": "string", "description": "动网操作支撑的业务场景" }, - "处置类型": { + "actionType": { "type": "string", "description": "动网操作的处置类型" }, - "操作名称": { + "operationName": { "type": "string", "description": "所授权的动网操作的具体名称" }, - "有效期": { + "validityPeriod": { "type": "string", - "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + "description": "授权策略的生效时间区间" } } } diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-100.json b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-100.json index 05ca3035..d38e97c9 100644 --- a/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-100.json +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-100.json @@ -1,64 +1,26 @@ { "scenarios": [ { - "label": "a-nl-unsupported-01", - "entry": "from_text", - "input": { - "text": "推送所有校园专网的授权策略" - }, - "expected": { - "client": { - "outcome": "slot_validation_error", - "slot_errors": [ - { - "slot_name": "授权策略的操作类型", - "code": "missing_required" - } - ] - } - } - }, - { - "label": "a-nl-unsupported-02", - "entry": "from_text", - "input": { - "text": "分享医疗专线的授权策略给别人" - }, - "expected": { - "client": { - "outcome": "slot_validation_error", - "slot_errors": [ - { - "slot_name": "授权策略的操作类型", - "code": "missing_required" - } - ] - } - } - }, - { - "label": "a-nl-unsupported-03", - "entry": "from_text", + "label": "a-data-starve-01", + "entry": "from_data_with_schema", "input": { - "text": "冻结所有跟业务投诉诊断相关的授权" - }, - "expected": { - "client": { - "outcome": "slot_validation_error", - "slot_errors": [ - { - "slot_name": "授权策略的操作类型", - "code": "missing_required" + "data": { + "scene": "校园专网", + "actionType": "业务抢通" + }, + "schema": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" } - ] + } } - } - }, - { - "label": "a-nl-unsupported-04", - "entry": "from_text", - "input": { - "text": "解锁载波调度的授权策略" }, "expected": { "client": { @@ -73,28 +35,26 @@ } }, { - "label": "a-nl-unsupported-05", - "entry": "from_text", + "label": "a-data-starve-01-varname", + "entry": "from_data_with_schema", "input": { - "text": "标记所有已过期的政企专网授权" - }, - "expected": { - "client": { - "outcome": "slot_validation_error", - "slot_errors": [ - { - "slot_name": "授权策略的操作类型", - "code": "missing_required" + "data": { + "bizScene": "校园专网", + "handling": "业务抢通" + }, + "schema": { + "type": "object", + "properties": { + "bizScene": { + "type": "string", + "description": "动网操作所支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作对应的处置类型" } - ] + } } - } - }, - { - "label": "a-nl-unsupported-06", - "entry": "from_text", - "input": { - "text": "克隆应急通信的授权策略到海外漫游" }, "expected": { "client": { @@ -109,10 +69,10 @@ } }, { - "label": "a-nl-unsupported-07", + "label": "a-nl-unsupported-01", "entry": "from_text", "input": { - "text": "转移视频保障的授权策略所有权" + "text": "推送所有校园专网的授权策略" }, "expected": { "client": { @@ -127,10 +87,10 @@ } }, { - "label": "a-nl-unsupported-08", + "label": "a-nl-unsupported-03", "entry": "from_text", "input": { - "text": "审计车联网相关的所有授权策略操作记录" + "text": "冻结所有跟业务投诉诊断相关的授权" }, "expected": { "client": { @@ -162,24 +122,6 @@ } } }, - { - "label": "a-nl-neg-02", - "entry": "from_text", - "input": { - "text": "先别删除医疗专线的授权,让我再确认一下" - }, - "expected": { - "client": { - "outcome": "slot_validation_error", - "slot_errors": [ - { - "slot_name": "授权策略的操作类型", - "code": "missing_required" - } - ] - } - } - }, { "label": "a-nl-neg-03", "entry": "from_text", @@ -252,42 +194,6 @@ } } }, - { - "label": "a-nl-meta-03", - "entry": "from_text", - "input": { - "text": "请解释医疗专线授权策略的删除规则" - }, - "expected": { - "client": { - "outcome": "slot_validation_error", - "slot_errors": [ - { - "slot_name": "授权策略的操作类型", - "code": "missing_required" - } - ] - } - } - }, - { - "label": "a-nl-meta-04", - "entry": "from_text", - "input": { - "text": "查询授权策略需要哪些参数" - }, - "expected": { - "client": { - "outcome": "slot_validation_error", - "slot_errors": [ - { - "slot_name": "授权策略的操作类型", - "code": "missing_required" - } - ] - } - } - }, { "label": "a-data-unsupported-01", "entry": "from_text", @@ -320,49 +226,7 @@ "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", - "code": "missing_required" - } - ] - } - } - }, - { - "label": "b1-nl-missing-02", - "entry": "from_text", - "input": { - "text": "新增一个授权策略,场景是医疗专线,处置类型业务恢复,操作名称频段调整,时间后面再说" - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "validation_semantic_rejected", - "slot_errors": [ - { - "slot_name": "策略列表", - "code": "missing_required" - } - ] - } - } - }, - { - "label": "b1-nl-missing-03", - "entry": "from_text", - "input": { - "text": "创建授权,业务场景政企专网,处置业务优化,操作路由调整,有效期先空着" - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "validation_semantic_rejected", - "slot_errors": [ - { - "slot_name": "策略列表", + "slot_name": "policyList", "code": "missing_required" } ] @@ -370,44 +234,71 @@ } }, { - "label": "b1-nl-missing-04", + "label": "b1-nl-missing-01-varfields", "entry": "from_text", "input": { - "text": "帮我新建一个授权,场景应急通信,处置类型业务调整,操作名称隧道调优,有效期可以不填吗" + "text": "加个校园专网的授权,处置用业务抢通,做个隧道调优,有效期先不填后面补" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务调整,操作名称是隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", + "slot_name": "policyList", "code": "missing_required" } ] } - } - }, - { - "label": "b1-nl-missing-05", - "entry": "from_text", - "input": { - "text": "帮海外漫游加个授权,处置业务抢通,操作频段调整,时间先不填" }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是频段调整\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "validation_semantic_rejected", - "slot_errors": [ - { - "slot_name": "策略列表", - "code": "missing_required" + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + }, + "remark": { + "type": "string", + "description": "策略备注说明" + } + } } - ] + } } } }, @@ -416,15 +307,15 @@ "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "新增授权策略", - "业务场景": "视频保障", - "处置类型": "业务恢复", - "操作名称": "隧道调优" + "operationType": "新增授权策略", + "scene": "视频保障", + "actionType": "业务恢复", + "operationName": "隧道调优" }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -433,19 +324,19 @@ "查询授权策略" ] }, - "业务场景": { + "scene": { "type": "string", "description": "动网操作支撑的业务场景" }, - "处置类型": { + "actionType": { "type": "string", "description": "动网操作的处置类型" }, - "操作名称": { + "operationName": { "type": "string", "description": "所授权的动网操作的具体名称" }, - "有效期": { + "validityPeriod": { "type": "string", "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" } @@ -460,7 +351,7 @@ "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", + "slot_name": "policyList", "code": "missing_required" } ] @@ -468,19 +359,19 @@ } }, { - "label": "b1-data-missing-11", + "label": "b1-data-missing-06-varname", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "新增授权策略", - "业务场景": "海外漫游", - "处置类型": "业务抢通", - "操作名称": "信号增强" + "operationType": "新增授权策略", + "scene": "视频保障", + "actionType": "业务恢复", + "operationName": "隧道调优" }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -489,190 +380,212 @@ "查询授权策略" ] }, - "业务场景": { + "scene": { "type": "string", "description": "动网操作支撑的业务场景" }, - "处置类型": { + "actionType": { "type": "string", "description": "动网操作的处置类型" }, - "操作名称": { + "operationName": { "type": "string", "description": "所授权的动网操作的具体名称" }, - "有效期": { + "validityPeriod": { "type": "string", "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" } - }, - "required": [ - "操作类型", - "业务场景", - "处置类型", - "操作名称", - "有效期" - ] + } } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是信号增强\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是业务恢复,操作名称是隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "动网操作的授权策略列表", - "code": "missing_required", - "message": "新增条目缺少必填的有效期" + "slot_name": "策略明细", + "code": "missing_required" } - ], - "params": {} - } - } - }, - { - "label": "b1-data-missing-12", - "entry": "from_data_with_schema", - "input": { - "data": { - "操作类型": "新增授权策略", - "业务场景": "车联网" - }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "业务场景": { - "type": "string", - "description": "动网操作支撑的业务场景" - }, - "处置类型": { - "type": "string", - "description": "动网操作的处置类型" - }, - "操作名称": { - "type": "string", - "description": "所授权的动网操作的具体名称" - }, - "有效期": { - "type": "string", - "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" - } - }, - "required": [ - "操作类型", - "业务场景", - "处置类型", - "操作名称", - "有效期" - ] + ] } }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是车联网\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "validation_semantic_rejected", - "slot_errors": [ - { - "slot_name": "动网操作的授权策略列表", - "code": "missing_required", - "message": "新增条目缺少必填的处置类型、操作名称、有效期" + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "策略明细": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "标识": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "生效时段": { + "type": "string", + "description": "授权策略的生效时间区间" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置动作" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体动作名" + } + } } - ], - "params": {} + } } } }, { - "label": "b2-nl-format-01", - "entry": "from_text", + "label": "b1-data-nolist-01", + "entry": "from_data_with_schema", "input": { - "text": "新增授权,场景校园专网,处置业务抢通,操作隧道调优,有效期2026-13-01~2030-12-31" - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "data": { + "operationType": "删除授权策略" }, - "server": { - "outcome": "validation_semantic_rejected", - "slot_errors": [ - { - "slot_name": "策略列表", - "code": "format_error" + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "删除授权策略" + ], + "description": "授权策略的操作类型" } - ] + } } - } - }, - { - "label": "b2-nl-format-06", - "entry": "from_text", - "input": { - "text": "新增授权,业务场景车联网,处置类型业务优化,操作名称频段调整,有效期2030-01-01~2026-01-01" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是车联网,处置类型是业务优化,操作名称是频段调整,有效期是2030-01-01~2026-01-01\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", - "code": "format_error" + "slot_name": "policyList", + "code": "missing_required" } ] } } }, { - "label": "b2-nl-format-07", - "entry": "from_text", + "label": "b1-data-nolist-01-varname", + "entry": "from_data_with_schema", "input": { - "text": "加个授权,场景视频保障,处置业务恢复,操作路由调优,有效期到2030-12-31为止" + "data": { + "operationType": "删除授权策略" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "删除授权策略" + ], + "description": "授权策略的操作类型" + } + } + } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是业务恢复,操作名称是路由调优,有效期是2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", - "code": "format_error" + "slot_name": "policies", + "code": "missing_required" } ] } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "策略的唯一标识" + }, + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } } }, { - "label": "b2-data-format-06", + "label": "b1-data-missing-11", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "新增授权策略", - "业务场景": "海外漫游", - "处置类型": "业务抢通", - "操作名称": "频段调整", - "有效期": "2026-13-01~2030-12-31" + "operationType": "新增授权策略", + "scene": "海外漫游", + "actionType": "业务抢通", + "operationName": "信号增强" }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -681,53 +594,63 @@ "查询授权策略" ] }, - "业务场景": { + "scene": { "type": "string", "description": "动网操作支撑的业务场景" }, - "处置类型": { + "actionType": { "type": "string", "description": "动网操作的处置类型" }, - "操作名称": { + "operationName": { "type": "string", "description": "所授权的动网操作的具体名称" }, - "有效期": { + "validityPeriod": { "type": "string", "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" } - } + }, + "required": [ + "operationType", + "scene", + "actionType", + "operationName", + "validityPeriod" + ] } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是信号增强\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", - "code": "format_error" + "slot_name": "policyList", + "code": "missing_required", + "message": "新增条目缺少必填的有效期" } - ] + ], + "params": {} } } }, { - "label": "b2-data-format-07", + "label": "b1-data-missing-11-varfields", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "修改授权策略", - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "有效期": "###~###" + "operationType": "新增授权策略", + "scene": "海外漫游", + "actionType": "业务抢通", + "operationName": "信号增强" }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -736,47 +659,100 @@ "查询授权策略" ] }, - "策略标识": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { "type": "string", - "description": "策略的唯一标识,UUID格式" + "description": "所授权的动网操作的具体名称" }, - "有效期": { + "validityPeriod": { "type": "string", - "description": "新的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" } - } + }, + "required": [ + "operationType", + "scene", + "actionType", + "operationName", + "validityPeriod" + ] } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是###~###\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是信号增强\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", - "code": "format_error" + "slot_name": "policyList", + "code": "missing_required", + "message": "新增条目缺少必填的有效期" } - ] + ], + "params": {} + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + } + } + } + } } } }, { - "label": "b2-nl-format-08", + "label": "b2-nl-format-01", "entry": "from_text", "input": { - "text": "新增授权,场景工业视觉质检,处置割接,操作光功率调衡,有效期2026-13-01~2030-12-31" + "text": "新增授权,场景校园专网,处置业务抢通,操作隧道调优,有效期2026-13-01~2030-12-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是工业视觉质检,处置类型是割接,操作名称是光功率调衡,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", + "slot_name": "policyList", "code": "format_error" } ] @@ -784,264 +760,416 @@ } }, { - "label": "b3-nl-invalid-mod-01", + "label": "b2-nl-format-01-varreq", "entry": "from_text", "input": { - "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权的业务场景改成校园专网" + "text": "新增一个授权,业务场景校园专网,处置类型网络出了问题要赶紧恢复,操作名称频段调整,有效期2026-01-01~2030-12-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是网络出了问题要赶紧恢复,操作名称是频段调整,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", + "slot_name": "policyList", "code": "invalid_value" } - ], - "params": { - "操作类型": "修改授权策略", - "策略列表": [ - { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "业务场景": "校园专网" + ] + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 invalid_value" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + } } - ] + } } } } }, { - "label": "b3-nl-invalid-mod-02", + "label": "b2-nl-format-06", "entry": "from_text", "input": { - "text": "修改a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的授权,处置类型改为业务抢通" + "text": "新增授权,业务场景车联网,处置类型业务优化,操作名称频段调整,有效期2030-01-01~2026-01-01" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,处置类型改为业务抢通\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是车联网,处置类型是业务优化,操作名称是频段调整,有效期是2030-01-01~2026-01-01\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", - "code": "invalid_value" + "slot_name": "policyList", + "code": "format_error" } - ], - "params": { - "操作类型": "修改授权策略", - "策略列表": [ - { - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "处置类型": "业务抢通" - } - ] - } + ] } } }, { - "label": "b3-nl-invalid-mod-03", + "label": "b2-nl-format-06-varreq", "entry": "from_text", "input": { - "text": "把f9e8d7c6-b5a4-3210-fedc-ba9876543210的授权操作名称改成隧道调优" + "text": "新增授权,业务场景车联网,处置类型业务优化,操作名称频段调整,有效期2030-01-01~2026-01-01" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210,操作名称改成隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是车联网,处置类型是业务优化,操作名称是频段调整,有效期是2030-01-01~2026-01-01\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", - "code": "invalid_value" + "slot_name": "policyList", + "code": "format_error" } - ], - "params": { - "操作类型": "修改授权策略", - "策略列表": [ - { - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", - "操作名称": "隧道调优" + ] + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "有效期须为 ISO 8601 时间戳,如 2026-01-01T00:00:00Z" + } } - ] + } } } } }, { - "label": "b3-nl-invalid-mod-04", + "label": "b2-nl-format-07", "entry": "from_text", "input": { - "text": "将7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3的授权策略的业务场景更新为医疗专线" + "text": "加个授权,场景视频保障,处置业务恢复,操作路由调优,有效期到2030-12-31为止" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景更新为医疗专线\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是业务恢复,操作名称是路由调优,有效期是2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", - "code": "invalid_value" + "slot_name": "policyList", + "code": "format_error" } - ], - "params": { - "操作类型": "修改授权策略", - "策略列表": [ - { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "业务场景": "医疗专线" - } - ] - } - } - } - }, - { - "label": "b4-nl-bad-id-01", - "entry": "from_text", - "input": { - "text": "删除ABC-123这个授权策略" - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是ABC-123\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "删除授权策略" - } + ] } } }, { - "label": "b4-nl-bad-id-02", + "label": "b2-nl-format-07-varname", "entry": "from_text", "input": { - "text": "修改12345的授权有效期到2027-01-01~2030-12-31" + "text": "加个授权,场景视频保障,处置业务恢复,操作路由调优,有效期到2030-12-31为止" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是12345,有效期是2027-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是业务恢复,操作名称是路由调优,有效期是2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "修改授权策略" - } + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略明细", + "code": "format_error" + } + ] } - } - }, - { - "label": "b4-nl-bad-id-03", - "entry": "from_text", - "input": { - "text": "删除id=my-policy-001这个授权" }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是id=my-policy-001\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "删除授权策略" + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "策略明细": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "标识": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "生效时段": { + "type": "string", + "description": "授权策略的生效时段" + } + } + } } } } }, { - "label": "b4-nl-bad-id-04", - "entry": "from_text", + "label": "b2-data-format-06", + "entry": "from_data_with_schema", "input": { - "text": "修改not-a-uuid@#$这个授权的有效期到永久生效" - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是not-a-uuid@#$,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "data": { + "operationType": "新增授权策略", + "scene": "海外漫游", + "actionType": "业务抢通", + "operationName": "频段调整", + "validityPeriod": "2026-13-01~2030-12-31" }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "修改授权策略" + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } } } - } - }, - { - "label": "b5-nl-invalid-06", - "entry": "from_text", - "input": { - "text": "修改7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3的授权" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", - "code": "missing_required" + "slot_name": "policyList", + "code": "format_error" } ] } } }, { - "label": "b5-nl-invalid-07", - "entry": "from_text", + "label": "b2-data-format-06-varfields", + "entry": "from_data_with_schema", "input": { - "text": "新增授权,业务场景校园专网,处置类型业务抢通,操作名称隧道调优,有效期永久生效,到2030年12月31日为止" + "data": { + "operationType": "新增授权策略", + "scene": "海外漫游", + "actionType": "业务抢通", + "operationName": "频段调整", + "validityPeriod": "2026-13-01~2030-12-31" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } + } + } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效,到2030-12-31为止\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", - "code": "semantic_mismatch" + "slot_name": "policyList", + "code": "format_error" } ] } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } } }, { - "label": "b6-schema-variant-01", + "label": "b3-nl-invalid-mod-01", "entry": "from_text", "input": { - "text": "新增一个授权,业务场景校园专网,处置类型网络出了问题要赶紧恢复,操作名称频段调整,有效期2026-01-01~2030-12-31" + "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权的业务场景改成校园专网" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是网络出了问题要赶紧恢复,操作名称是频段调整,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "success", - "slot_errors": [], + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "invalid_value" + } + ], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "修改授权策略", + "policyList": [ { - "业务场景": "校园专网", - "处置类型": "网络出了问题要赶紧恢复", - "操作名称": "频段调整", - "有效期": "2026-01-01~2030-12-31" + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": "校园专网" } ] } @@ -1049,29 +1177,38 @@ } }, { - "label": "b6-schema-variant-02", + "label": "b3-nl-invalid-mod-01-varname", "entry": "from_text", "input": { - "text": "新增一个授权,业务场景校园专网,处置类型网络出了问题要赶紧恢复,操作名称频段调整,有效期2026-01-01~2030-12-31" + "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权的业务场景改成校园专网" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是网络出了问题要赶紧恢复,操作名称是频段调整,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", + "slot_name": "策略明细", "code": "invalid_value" } - ] + ], + "params": { + "operationType": "修改授权策略", + "策略明细": [ + { + "标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": "校园专网" + } + ] + } } }, "validate_schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -1081,31 +1218,31 @@ ], "description": "授权策略的操作类型" }, - "策略列表": { + "策略明细": { "type": "array", - "description": "动网操作的授权策略列表,按输入中的出现顺序排列。参数值中每条条目以编号加'.'开头(如'1.'),条目内各字段以'字段名是值'形式表达并用逗号分隔,多个条目各自独立成行(换行分隔)。该参数未提供时输出空数组 []。各操作类型对每条的字段要求不同:新增条目须包含业务场景、处置类型、操作名称、有效期;删除条目可为策略标识,也可仅为条件字段(业务场景、处置类型、操作名称之一或多个),无需其他字段,不得按新增的必填字段要求检查删除条目。修改授权策略时,每条须包含策略标识与新有效期,缺少新有效期判 missing_required。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),带标签时按标签归因字段。", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", "items": { "type": "object", "properties": { - "策略标识": { + "标识": { "type": "string", - "description": "策略的UUID唯一标识。UUID 形态的值归入本字段;非 UUID 的裸值按其内容语义归入业务场景/处置类型/操作名称字段。新增类策略无标识时为null;删除场景可按条件字段方式描述(此时策略标识为null)。内容校验阶段不依据标识格式判定存在性" + "description": "策略的唯一标识" }, - "业务场景": { + "scene": { "type": "string", "description": "动网操作支撑的业务场景" }, - "处置类型": { + "actionType": { "type": "string", - "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 invalid_value" + "description": "动网操作的处置类型" }, - "操作名称": { + "operationName": { "type": "string", "description": "所授权的动网操作的具体名称" }, - "有效期": { + "生效时段": { "type": "string", - "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + "description": "授权策略的生效时间区间" } } } @@ -1114,35 +1251,50 @@ } }, { - "label": "b6-schema-variant-03", + "label": "b3-nl-conflict-01", "entry": "from_text", "input": { - "text": "新增一个授权,业务场景政企专网,处置类型业务抢通,操作名称路由调整,有效期2026-01-01~2030-12-31" + "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3的授权同时改成校园专网和医疗专线" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是政企专网,处置类型是业务抢通,操作名称是路由调整,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网和医疗专线\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "政企专网", - "处置类型": "业务抢通", - "操作名称": "路由调整", - "有效期": "2026-01-01~2030-12-31" - } - ] - } + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "semantic_mismatch" + } + ] + } + } + }, + { + "label": "b3-nl-conflict-01-varname", + "entry": "from_text", + "input": { + "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3的授权同时改成校园专网和医疗专线" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网和医疗专线\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略明细", + "code": "semantic_mismatch" + } + ] } }, "validate_schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -1152,31 +1304,31 @@ ], "description": "授权策略的操作类型" }, - "策略列表": { + "策略明细": { "type": "array", - "description": "动网操作的授权策略列表,按输入中的出现顺序排列。参数值中每条条目以编号加'.'开头(如'1.'),条目内各字段以'字段名是值'形式表达并用逗号分隔,多个条目各自独立成行(换行分隔)。该参数未提供时输出空数组 []。各操作类型对每条的字段要求不同:新增条目须包含业务场景、处置类型、操作名称、有效期;删除条目可为策略标识,也可仅为条件字段(业务场景、处置类型、操作名称之一或多个),无需其他字段,不得按新增的必填字段要求检查删除条目。修改授权策略时,每条须包含策略标识与新有效期,缺少新有效期判 missing_required。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),带标签时按标签归因字段。", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", "items": { "type": "object", "properties": { - "策略标识": { + "标识": { "type": "string", - "description": "策略的UUID唯一标识。UUID 形态的值归入本字段;非 UUID 的裸值按其内容语义归入业务场景/处置类型/操作名称字段。新增类策略无标识时为null;删除场景可按条件字段方式描述(此时策略标识为null)。内容校验阶段不依据标识格式判定存在性" + "description": "策略的唯一标识" }, - "业务场景": { + "scene": { "type": "string", "description": "动网操作支撑的业务场景" }, - "处置类型": { + "actionType": { "type": "string", - "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 invalid_value" + "description": "动网操作的处置类型" }, - "操作名称": { + "operationName": { "type": "string", "description": "所授权的动网操作的具体名称" }, - "有效期": { + "生效时段": { "type": "string", - "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + "description": "授权策略的生效时间区间" } } } @@ -1185,26 +1337,27 @@ } }, { - "label": "c1-nl-add-01", + "label": "b4-nl-bad-id-01", "entry": "from_text", "input": { - "text": "帮我新增一个授权策略,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31" + "text": "删除ABC-123这个授权策略" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是ABC-123\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "删除授权策略", + "policyList": [ { - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "2026-01-01~2030-12-31" + "policyId": "ABC-123", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": null } ] } @@ -1212,53 +1365,99 @@ } }, { - "label": "c1-nl-add-04", + "label": "b4-nl-bad-id-01-varname", "entry": "from_text", "input": { - "text": "新增授权:业务场景=应急通信,处置类型=业务调整,操作名称=隧道调优,有效期=2027-03-15~2029-09-30" + "text": "删除ABC-123这个授权策略" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务调整,操作名称是隧道调优,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是ABC-123\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "删除授权策略", + "policies": [ { - "业务场景": "应急通信", - "处置类型": "业务调整", - "操作名称": "隧道调优", - "有效期": "2027-03-15~2029-09-30" + "pid": "ABC-123", + "domain": null, + "handling": null, + "opName": null, + "effectivePeriod": null } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + }, + "pid": { + "type": "string", + "description": "策略的唯一标识" + } + } + } + } + } } }, { - "label": "c1-nl-add-05", + "label": "b4-nl-bad-id-02", "entry": "from_text", "input": { - "text": "为海外漫游新增一个业务抢通类型的授权,操作是频段调整,有效期从2026-08-01到2030-12-31" + "text": "修改12345的授权有效期到2027-01-01~2030-12-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是频段调整,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是12345,有效期是2027-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "修改授权策略", + "policyList": [ { - "业务场景": "海外漫游", - "处置类型": "业务抢通", - "操作名称": "频段调整", - "有效期": "2026-08-01~2030-12-31" + "policyId": "12345", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "2027-01-01~2030-12-31" } ] } @@ -1266,278 +1465,224 @@ } }, { - "label": "c1-data-add-01", - "entry": "from_data_with_schema", + "label": "b4-nl-bad-id-02-varflat", + "entry": "from_text", "input": { - "data": { - "操作类型": "新增授权策略", - "业务场景": "车联网", - "处置类型": "业务优化", - "操作名称": "网络优化", - "有效期": "2026-01-01~2030-12-31" - }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "业务场景": { - "type": "string", - "description": "动网操作支撑的业务场景" - }, - "处置类型": { - "type": "string", - "description": "动网操作的处置类型" - }, - "操作名称": { - "type": "string", - "description": "所授权的动网操作的具体名称" - }, - "有效期": { - "type": "string", - "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" - } - } - } + "text": "修改12345的授权有效期到2027-01-01~2030-12-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是车联网,处置类型是业务优化,操作名称是网络优化,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是12345,有效期是2027-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "车联网", - "处置类型": "业务优化", - "操作名称": "网络优化", - "有效期": "2026-01-01~2030-12-31" - } - ] + "operationType": "修改授权策略", + "policyId": "12345", + "newValidityPeriod": "2027-01-01~2030-12-31" + } + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyId": { + "type": "string", + "description": "要修改的策略的唯一标识" + }, + "newValidityPeriod": { + "type": "string", + "description": "修改后的新生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或永久生效" } } } }, { - "label": "c1-nl-add-07", + "label": "b4-nl-bad-id-02-varuuid", "entry": "from_text", "input": { - "text": "有效期2026-06-01~2030-06-18,操作名称频段调整,处置类型业务恢复,业务场景医疗专线,新增一个授权策略" + "text": "修改12345的授权有效期到2027-01-01~2030-12-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是12345,有效期是2027-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "医疗专线", - "处置类型": "业务恢复", - "操作名称": "频段调整", - "有效期": "2026-06-01~2030-06-18" + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "format_error", + "message": "第1条的策略标识\"12345\"不符合UUID格式" + } + ], + "params": {} + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识,须为UUID格式" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + } } - ] + } } } } }, { - "label": "c1-data-add-07", - "entry": "from_data_with_schema", + "label": "b5-nl-invalid-06", + "entry": "from_text", "input": { - "data": { - "操作类型": "新增授权策略", - "场景": "视频保障", - "处置": "业务恢复", - "操作": "路由调整", - "时间": "永久生效" + "text": "修改7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3的授权" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "场景": { - "type": "string", - "description": "动网操作支撑的业务场景" - }, - "处置": { - "type": "string", - "description": "动网操作的处置类型" - }, - "操作": { - "type": "string", - "description": "所授权的动网操作的具体名称" - }, - "时间": { - "type": "string", - "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + "server": { + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "missing_required" } - } + ] } + } + }, + { + "label": "b5-nl-invalid-06-varflat", + "entry": "from_text", + "input": { + "text": "修改7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3的授权" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是业务恢复,操作名称是路由调整,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "视频保障", - "处置类型": "业务恢复", - "操作名称": "路由调整", - "有效期": "永久生效" - } - ] + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "newValidityPeriod", + "code": "missing_required" + } + ] + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "newValidityPeriod": { + "type": "string", + "description": "修改后新的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或永久生效" } } } }, { - "label": "c1-data-add-08", - "entry": "from_data_with_schema", + "label": "b5-nl-invalid-07", + "entry": "from_text", "input": { - "data": { - "操作类型": "新增授权策略", - "场景": "政企专网", - "处置": "业务优化", - "操作": "隧道调优", - "时间": "2026-06-01~2030-06-18" - }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "场景": { - "type": "string", - "description": "动网操作支撑的业务场景" - }, - "处置": { - "type": "string", - "description": "动网操作的处置类型" - }, - "操作": { - "type": "string", - "description": "所授权的动网操作的具体名称" - }, - "时间": { - "type": "string", - "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" - } - } - } + "text": "新增授权,业务场景校园专网,处置类型业务抢通,操作名称隧道调优,有效期永久生效,到2030年12月31日为止" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是政企专网,处置类型是业务优化,操作名称是隧道调优,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效,到2030-12-31为止\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "政企专网", - "处置类型": "业务优化", - "操作名称": "隧道调优", - "有效期": "2026-06-01~2030-06-18" - } - ] - } + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "semantic_mismatch" + } + ] } } }, { - "label": "c1-data-add-10", - "entry": "from_data_with_schema", + "label": "c1-nl-add-01", + "entry": "from_text", "input": { - "data": { - "操作类型": "新增授权策略", - "业务场景": "无人机回传", - "处置类型": "业务调整", - "操作名称": "隧道调优", - "有效期": "2026年8月1日到2030年12月31日" - }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "业务场景": { - "type": "string", - "description": "动网操作支撑的业务场景" - }, - "处置类型": { - "type": "string", - "description": "动网操作的处置类型" - }, - "操作名称": { - "type": "string", - "description": "所授权的动网操作的具体名称" - }, - "有效期": { - "type": "string", - "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" - } - } - } + "text": "帮我新增一个授权策略,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是无人机回传,处置类型是业务调整,操作名称是隧道调优,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ { - "业务场景": "无人机回传", - "处置类型": "业务调整", - "操作名称": "隧道调优", - "有效期": "2026-08-01~2030-12-31" + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" } ] } @@ -1545,53 +1690,99 @@ } }, { - "label": "c1-nl-add-11", + "label": "c1-nl-add-01-varname", "entry": "from_text", "input": { - "text": "帮我新增一个授权策略,业务场景是工业视觉质检,处置类型是割接,操作名称是光功率调衡,有效期从2026-01-01到2030-12-31" + "text": "帮我新增一个授权策略,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是工业视觉质检,处置类型是割接,操作名称是光功率调衡,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policies": [ { - "业务场景": "工业视觉质检", - "处置类型": "割接", - "操作名称": "光功率调衡", - "有效期": "2026-01-01~2030-12-31" + "pid": null, + "domain": "校园专网", + "handling": "业务抢通", + "opName": "隧道调优", + "effectivePeriod": "2026-01-01~2030-12-31" } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "策略的唯一标识" + }, + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } } }, { - "label": "c1-nl-add-12", + "label": "c1-nl-add-04", "entry": "from_text", "input": { - "text": "为物联网专线新增一个倒换类型的授权,操作是流量重定向,有效期从2026-08-01到2030-12-31" + "text": "新增授权:业务场景=应急通信,处置类型=业务调整,操作名称=隧道调优,有效期=2027-03-15~2029-09-30" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是物联网专线,处置类型是倒换,操作名称是流量重定向,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务调整,操作名称是隧道调优,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ { - "业务场景": "物联网专线", - "处置类型": "倒换", - "操作名称": "流量重定向", - "有效期": "2026-08-01~2030-12-31" + "policyId": null, + "scene": "应急通信", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2027-03-15~2029-09-30" } ] } @@ -1599,164 +1790,99 @@ } }, { - "label": "c2-nl-multi-01", + "label": "c1-nl-add-04-varreq", "entry": "from_text", "input": { - "text": "新增两条授权策略:第一条,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31;第二条,业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期从2026-06-01到2030-06-18" + "text": "新增授权:业务场景=应急通信,处置类型=业务调整,操作名称=隧道调优,有效期=2027-03-15~2029-09-30" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-01-01~2030-12-31\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务调整,操作名称是隧道调优,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ { - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "2026-01-01~2030-12-31" - }, - { - "业务场景": "医疗专线", - "处置类型": "业务恢复", - "操作名称": "频段调整", - "有效期": "2026-06-01~2030-06-18" + "policyId": null, + "scene": "应急通信", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2029-09-30" } ] } } - } - }, - { - "label": "c2-data-multi-01", - "entry": "from_data_with_schema", - "input": { - "data": { - "操作类型": "新增授权策略", - "策略数量": 2, - "详情": [ - { - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "永久生效" - }, - { - "业务场景": "医疗专线", - "处置类型": "业务恢复", - "操作名称": "频段调整", - "有效期": "2026-06-01~2030-06-18" - } - ] - }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "策略数量": { - "type": "integer", - "description": "要新增的策略数量" - }, - "详情": { - "type": "array", - "description": "策略详情列表", - "items": { - "type": "object", - "properties": { - "业务场景": { - "type": "string", - "description": "动网操作支撑的业务场景" - }, - "处置类型": { - "type": "string", - "description": "动网操作的处置类型" - }, - "操作名称": { - "type": "string", - "description": "所授权的动网操作的具体名称" - }, - "有效期": { - "type": "string", - "description": "生效时间区间或永久生效" - } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "生效截止日期,单值,格式 YYYY-MM-DD" } } } } } - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "永久生效" - }, - { - "业务场景": "医疗专线", - "处置类型": "业务恢复", - "操作名称": "频段调整", - "有效期": "2026-06-01~2030-06-18" - } - ] - } - } } }, { - "label": "c2-nl-multi-03", + "label": "c1-nl-add-11", "entry": "from_text", "input": { - "text": "帮我加三条授权:校园专网用业务抢通做隧道调优,永久生效;医疗专线用业务恢复做频段调整,有效期2026-06-01到2030-06-18;政企专网用业务优化做路由调整,有效期2027-03-15到2029-09-30" + "text": "帮我新增一个授权策略,业务场景是工业视觉质检,处置类型是割接,操作名称是光功率调衡,有效期从2026-01-01到2030-12-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是工业视觉质检,处置类型是割接,操作名称是光功率调衡,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "永久生效" - }, + "operationType": "新增授权策略", + "policyList": [ { - "业务场景": "医疗专线", - "处置类型": "业务恢复", - "操作名称": "频段调整", - "有效期": "2026-06-01~2030-06-18" - }, - { - "业务场景": "政企专网", - "处置类型": "业务优化", - "操作名称": "路由调整", - "有效期": "2027-03-15~2029-09-30" + "policyId": null, + "scene": "工业视觉质检", + "actionType": "割接", + "operationName": "光功率调衡", + "validityPeriod": "2026-01-01~2030-12-31" } ] } @@ -1764,344 +1890,104 @@ } }, { - "label": "c2-nl-multi-04", + "label": "c1-nl-add-11-varfields", "entry": "from_text", "input": { - "text": "新增四条授权策略:第1条业务场景应急通信,处置类型业务抢通,操作名称链路切换,有效期永久生效;第2条业务场景海外漫游,处置类型业务恢复,操作名称带宽扩容,有效期2026-01-01~2030-12-31;第3条业务场景视频保障,处置类型业务优化,操作名称信号增强,有效期2027-03-15~2029-09-30;第4条业务场景车联网,处置类型业务调整,操作名称网络优化,有效期2026-06-01~2030-06-18" + "text": "帮我新增一个授权策略,业务场景是工业视觉质检,处置类型是割接,操作名称是光功率调衡,有效期从2026-01-01到2030-12-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务抢通,操作名称是链路切换,有效期是永久生效\n2. 业务场景是海外漫游,处置类型是业务恢复,操作名称是带宽扩容,有效期是2026-01-01~2030-12-31\n3. 业务场景是视频保障,处置类型是业务优化,操作名称是信号增强,有效期是2027-03-15~2029-09-30\n4. 业务场景是车联网,处置类型是业务调整,操作名称是网络优化,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是工业视觉质检,处置类型是割接,操作名称是光功率调衡,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "应急通信", - "处置类型": "业务抢通", - "操作名称": "链路切换", - "有效期": "永久生效" - }, - { - "业务场景": "海外漫游", - "处置类型": "业务恢复", - "操作名称": "带宽扩容", - "有效期": "2026-01-01~2030-12-31" - }, - { - "业务场景": "视频保障", - "处置类型": "业务优化", - "操作名称": "信号增强", - "有效期": "2027-03-15~2029-09-30" - }, + "operationType": "新增授权策略", + "policyList": [ { - "业务场景": "车联网", - "处置类型": "业务调整", - "操作名称": "网络优化", - "有效期": "2026-06-01~2030-06-18" + "policyId": null, + "scene": "工业视觉质检", + "actionType": "割接", + "operationName": "光功率调衡", + "validityPeriod": "2026-01-01~2030-12-31", + "remark": null } ] } } - } - }, - { - "label": "c2-data-multi-07", - "entry": "from_data_with_schema", - "input": { - "data": { - "操作类型": "新增授权策略", - "策略数量": 3, - "详情": [ - { - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "永久生效" - }, - { - "业务场景": "医疗专线", - "处置类型": "业务恢复", - "操作名称": "频段调整", - "有效期": "2026-06-01~2030-06-18" - }, - { - "业务场景": "政企专网", - "处置类型": "业务优化", - "操作名称": "路由调整", - "有效期": "2027-03-15~2029-09-30" - } - ] - }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "策略数量": { - "type": "integer", - "description": "要新增的策略数量" - }, - "详情": { - "type": "array", - "description": "策略详情列表", - "items": { - "type": "object", - "properties": { - "业务场景": { - "type": "string", - "description": "动网操作支撑的业务场景" - }, - "处置类型": { - "type": "string", - "description": "动网操作的处置类型" - }, - "操作名称": { - "type": "string", - "description": "所授权的动网操作的具体名称" - }, - "有效期": { - "type": "string", - "description": "生效时间区间或永久生效" - } - } - } - } - } - } }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "永久生效" - }, - { - "业务场景": "医疗专线", - "处置类型": "业务恢复", - "操作名称": "频段调整", - "有效期": "2026-06-01~2030-06-18" - }, - { - "业务场景": "政企专网", - "处置类型": "业务优化", - "操作名称": "路由调整", - "有效期": "2027-03-15~2029-09-30" - } - ] - } - } - } - }, - { - "label": "c2-data-multi-08", - "entry": "from_data_with_schema", - "input": { - "data": { - "操作类型": "新增授权策略", - "策略数量": 4, - "详情": [ - { - "业务场景": "应急通信", - "处置类型": "业务抢通", - "操作名称": "链路切换", - "有效期": "永久生效" - }, - { - "业务场景": "海外漫游", - "处置类型": "业务恢复", - "操作名称": "带宽扩容", - "有效期": "2026年8月1日到2030年12月31日" - }, - { - "业务场景": "视频保障", - "处置类型": "业务优化", - "操作名称": "信号增强", - "有效期": "2026-01-01~2030-12-31" - }, - { - "业务场景": "车联网", - "处置类型": "业务调整", - "操作名称": "网络优化", - "有效期": "永久生效" - } - ] - }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "策略数量": { - "type": "integer", - "description": "要新增的策略数量" - }, - "详情": { - "type": "array", - "description": "策略详情列表", - "items": { - "type": "object", - "properties": { - "业务场景": { - "type": "string", - "description": "动网操作支撑的业务场景" - }, - "处置类型": { - "type": "string", - "description": "动网操作的处置类型" - }, - "操作名称": { - "type": "string", - "description": "所授权的动网操作的具体名称" - }, - "有效期": { - "type": "string", - "description": "生效时间区间或永久生效" - } + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + }, + "remark": { + "type": "string", + "description": "策略备注,可选,未提供时为null" } } } } } - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务抢通,操作名称是链路切换,有效期是永久生效\n2. 业务场景是海外漫游,处置类型是业务恢复,操作名称是带宽扩容,有效期是2026-08-01~2030-12-31\n3. 业务场景是视频保障,处置类型是业务优化,操作名称是信号增强,有效期是2026-01-01~2030-12-31\n4. 业务场景是车联网,处置类型是业务调整,操作名称是网络优化,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "应急通信", - "处置类型": "业务抢通", - "操作名称": "链路切换", - "有效期": "永久生效" - }, - { - "业务场景": "海外漫游", - "处置类型": "业务恢复", - "操作名称": "带宽扩容", - "有效期": "2026-08-01~2030-12-31" - }, - { - "业务场景": "视频保障", - "处置类型": "业务优化", - "操作名称": "信号增强", - "有效期": "2026-01-01~2030-12-31" - }, - { - "业务场景": "车联网", - "处置类型": "业务调整", - "操作名称": "网络优化", - "有效期": "永久生效" - } - ] - } - } - } - }, - { - "label": "c2-nl-multi-05", - "entry": "from_text", - "input": { - "text": "帮我加三条授权:视频保障用紧急扩容做天线调整,永久生效;校园专网用故障切换做频谱重耕,有效期2026-06-01到2030-06-18;工业视觉质检用割接做光功率调衡,有效期2027-03-15到2029-09-30" - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是紧急扩容,操作名称是天线调整,有效期是永久生效\n2. 业务场景是校园专网,处置类型是故障切换,操作名称是频谱重耕,有效期是2026-06-01~2030-06-18\n3. 业务场景是工业视觉质检,处置类型是割接,操作名称是光功率调衡,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "视频保障", - "处置类型": "紧急扩容", - "操作名称": "天线调整", - "有效期": "永久生效" - }, - { - "业务场景": "校园专网", - "处置类型": "故障切换", - "操作名称": "频谱重耕", - "有效期": "2026-06-01~2030-06-18" - }, - { - "业务场景": "工业视觉质检", - "处置类型": "割接", - "操作名称": "光功率调衡", - "有效期": "2027-03-15~2029-09-30" - } - ] - } - } } }, { - "label": "c2-nl-multi-06", + "label": "c1-nl-add-07", "entry": "from_text", "input": { - "text": "帮我加三条授权:工业视觉质检用割接做光功率调衡,有效期2026-01-01到2030-12-31;物联网专线用倒换做流量重定向,永久生效;视频保障用故障切换做频谱重耕,有效期2026-06-01到2030-06-18" + "text": "有效期2026-06-01~2030-06-18,操作名称频段调整,处置类型业务恢复,业务场景医疗专线,新增一个授权策略" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是工业视觉质检,处置类型是割接,操作名称是光功率调衡,有效期是2026-01-01~2030-12-31\n2. 业务场景是物联网专线,处置类型是倒换,操作名称是流量重定向,有效期是永久生效\n3. 业务场景是视频保障,处置类型是故障切换,操作名称是频谱重耕,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "工业视觉质检", - "处置类型": "割接", - "操作名称": "光功率调衡", - "有效期": "2026-01-01~2030-12-31" - }, - { - "业务场景": "物联网专线", - "处置类型": "倒换", - "操作名称": "流量重定向", - "有效期": "永久生效" - }, + "operationType": "新增授权策略", + "policyList": [ { - "业务场景": "视频保障", - "处置类型": "故障切换", - "操作名称": "频谱重耕", - "有效期": "2026-06-01~2030-06-18" + "policyId": null, + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" } ] } @@ -2109,68 +1995,87 @@ } }, { - "label": "c3-nl-mod-01", + "label": "c1-nl-add-07-varflat", "entry": "from_text", "input": { - "text": "修改7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3的授权,有效期改成永久生效" + "text": "有效期2026-06-01~2030-06-18,操作名称频段调整,处置类型业务恢复,业务场景医疗专线,新增一个授权策略" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "修改授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policies": [ { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "有效期": "永久生效" + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" } ] } } - } - }, - { - "label": "c3-nl-mod-02", - "entry": "from_text", - "input": { - "text": "把a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d这个授权策略的有效期改为2026年6月1日到2030年6月18日" }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "修改授权策略", - "策略列表": [ - { - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "有效期": "2026-06-01~2030-06-18" + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "策略记录列表,每条含场景、处置、操作与生效时段", + "items": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } } - ] + } } } } }, { - "label": "c3-data-mod-01", + "label": "c1-data-add-01", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "修改授权策略", - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", - "有效期": "2027-01-01~2029-12-31" + "operationType": "新增授权策略", + "scene": "车联网", + "actionType": "业务优化", + "operationName": "网络优化", + "validityPeriod": "2026-01-01~2030-12-31" }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -2179,30 +2084,41 @@ "查询授权策略" ] }, - "策略标识": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { "type": "string", - "description": "策略的唯一标识,UUID格式" + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" }, - "有效期": { + "validityPeriod": { "type": "string", - "description": "新的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" } } } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210,有效期是2027-01-01~2029-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是车联网,处置类型是业务优化,操作名称是网络优化,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "修改授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ { - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", - "有效期": "2027-01-01~2029-12-31" + "policyId": null, + "scene": "车联网", + "actionType": "业务优化", + "operationName": "网络优化", + "validityPeriod": "2026-01-01~2030-12-31" } ] } @@ -2210,50 +2126,64 @@ } }, { - "label": "c3-data-mod-02", + "label": "c1-data-add-01-varsch", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "修改授权策略", - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "有效期": "2026-01-01~2030-12-31" + "operationType": "新增授权策略", + "bizScene": "车联网", + "handling": "业务优化", + "opName": "网络优化", + "timeRange": "2026-01-01~2030-12-31" }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", "修改授权策略", "删除授权策略", "查询授权策略" - ] + ], + "description": "授权策略的操作类型" }, - "策略标识": { + "bizScene": { "type": "string", - "description": "策略的唯一标识,UUID格式" + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" }, - "有效期": { + "opName": { "type": "string", - "description": "新的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + "description": "所授权的动网操作的具体名称" + }, + "timeRange": { + "type": "string", + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" } } } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是车联网,处置类型是业务优化,操作名称是网络优化,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "修改授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "有效期": "2026-01-01~2030-12-31" + "policyId": null, + "scene": "车联网", + "actionType": "业务优化", + "operationName": "网络优化", + "validityPeriod": "2026-01-01~2030-12-31" } ] } @@ -2261,69 +2191,120 @@ } }, { - "label": "c3-data-mod-03", - "entry": "from_data_with_schema", + "label": "c1-nl-add-12", + "entry": "from_text", "input": { - "data": { - "操作类型": "修改授权策略", - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "有效期": "永久生效" + "text": "为物联网专线新增一个倒换类型的授权,操作是流量重定向,有效期从2026-08-01到2030-12-31" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是物联网专线,处置类型是倒换,操作名称是流量重定向,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "策略标识": { - "type": "string", - "description": "策略的唯一标识,UUID格式" - }, - "有效期": { - "type": "string", - "description": "新的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" - } + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "物联网专线", + "actionType": "倒换", + "operationName": "流量重定向", + "validityPeriod": "2026-08-01~2030-12-31" + } + ] } } + } + }, + { + "label": "c1-nl-add-12-varname", + "entry": "from_text", + "input": { + "text": "为物联网专线新增一个倒换类型的授权,操作是流量重定向,有效期从2026-08-01到2030-12-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是物联网专线,处置类型是倒换,操作名称是流量重定向,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "修改授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "策略明细": [ { - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "有效期": "永久生效" + "标识": null, + "scene": "物联网专线", + "actionType": "倒换", + "operationName": "流量重定向", + "生效时段": "2026-08-01~2030-12-31" } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "策略明细": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "标识": { + "type": "string", + "description": "策略的唯一标识" + }, + "生效时段": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } } }, { - "label": "c3-data-mod-04", + "label": "c1-data-add-10", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "修改授权策略", - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", - "有效期": "2026-08-01~2030-12-31" + "operationType": "新增授权策略", + "scene": "无人机回传", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2026年8月1日到2030年12月31日" }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -2332,59 +2313,41 @@ "查询授权策略" ] }, - "策略标识": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { "type": "string", - "description": "策略的唯一标识,UUID格式" + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" }, - "有效期": { + "validityPeriod": { "type": "string", - "description": "新的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" } } } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "修改授权策略", - "策略列表": [ - { - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", - "有效期": "2026-08-01~2030-12-31" - } - ] - } - } - } - }, - { - "label": "c3-nl-mod-06", - "entry": "from_text", - "input": { - "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3和a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的有效期都改成永久生效" - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效\n2. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是无人机回传,处置类型是业务调整,操作名称是隧道调优,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "修改授权策略", - "策略列表": [ - { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "有效期": "永久生效" - }, + "operationType": "新增授权策略", + "policyList": [ { - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "有效期": "永久生效" + "policyId": null, + "scene": "无人机回传", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2026-08-01~2030-12-31" } ] } @@ -2392,26 +2355,20 @@ } }, { - "label": "c3-data-mod-05", + "label": "c1-data-add-10-varreq", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "修改授权策略", - "详情": [ - { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "有效期": "永久生效" - }, - { - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "有效期": "2027-01-01~2029-12-31" - } - ] + "operationType": "新增授权策略", + "scene": "无人机回传", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2026年8月1日到2030年12月31日" }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -2420,68 +2377,120 @@ "查询授权策略" ] }, - "详情": { - "type": "array", - "description": "策略修改详情列表", - "items": { - "type": "object", - "properties": { - "策略标识": { - "type": "string", - "description": "要修改的策略唯一标识,UUID格式" - }, - "有效期": { - "type": "string", - "description": "新的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" - } - } - } + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" } } } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效\n2. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是2027-01-01~2029-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是无人机回传,处置类型是业务调整,操作名称是隧道调优,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "修改授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "有效期": "永久生效" - }, - { - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "有效期": "2027-01-01~2029-12-31" + "policyId": null, + "scene": "无人机回传", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2026-08-01T00:00:00Z" } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "须为 ISO 8601 时间戳,如 2026-01-01T00:00:00Z" + } + } + } + } + } } }, { - "label": "c3-nl-mod-07", + "label": "c2-nl-multi-01", "entry": "from_text", "input": { - "text": "修改a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的授权,有效期改成2026-09-01~2031-08-31" + "text": "新增两条授权策略:第一条,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31;第二条,业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期从2026-06-01到2030-06-18" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是2026-09-01~2031-08-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-01-01~2030-12-31\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "修改授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" + }, { - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "有效期": "2026-09-01~2031-08-31" + "policyId": null, + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" } ] } @@ -2489,47 +2498,114 @@ } }, { - "label": "c4-nl-del-01", + "label": "c2-nl-multi-01-varflat", "entry": "from_text", "input": { - "text": "删除7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权策略" + "text": "新增两条授权策略:第一条,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31;第二条,业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期从2026-06-01到2030-06-18" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-01-01~2030-12-31\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "删除授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policies": [ { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3" + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" + }, + { + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "策略记录列表,每条含场景、处置、操作与生效时段", + "items": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } + } + } + } + } } }, { - "label": "c4-nl-del-02", + "label": "c2-nl-multi-03", "entry": "from_text", "input": { - "text": "清理掉业务投诉诊断的所有授权" + "text": "帮我加三条授权:校园专网用业务抢通做隧道调优,永久生效;医疗专线用业务恢复做频段调整,有效期2026-06-01到2030-06-18;政企专网用业务优化做路由调整,有效期2027-03-15到2029-09-30" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是业务投诉诊断\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "删除授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ { - "业务场景": "业务投诉诊断" + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "永久生效" + }, + { + "policyId": null, + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" + }, + { + "policyId": null, + "scene": "政企专网", + "actionType": "业务优化", + "operationName": "路由调整", + "validityPeriod": "2027-03-15~2029-09-30" } ] } @@ -2537,90 +2613,251 @@ } }, { - "label": "c4-nl-del-04", + "label": "c2-nl-multi-03-varfields", "entry": "from_text", "input": { - "text": "删除所有业务场景为载波调度的授权策略" + "text": "帮我加三条授权:校园专网用业务抢通做隧道调优,永久生效;医疗专线用业务恢复做频段调整,有效期2026-06-01到2030-06-18;政企专网用业务优化做路由调整,有效期2027-03-15到2029-09-30" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是载波调度\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "删除授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "校园专网", + "validityPeriod": "永久生效" + }, + { + "policyId": null, + "scene": "医疗专线", + "validityPeriod": "2026-06-01~2030-06-18" + }, { - "业务场景": "载波调度" + "policyId": null, + "scene": "政企专网", + "validityPeriod": "2027-03-15~2029-09-30" } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } } }, { - "label": "c4-data-del-01", - "entry": "from_data_with_schema", + "label": "c2-nl-multi-04", + "entry": "from_text", "input": { - "data": { - "操作类型": "删除授权策略", - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + "text": "新增四条授权策略:第1条业务场景应急通信,处置类型业务抢通,操作名称链路切换,有效期永久生效;第2条业务场景海外漫游,处置类型业务恢复,操作名称带宽扩容,有效期2026-01-01~2030-12-31;第3条业务场景视频保障,处置类型业务优化,操作名称信号增强,有效期2027-03-15~2029-09-30;第4条业务场景车联网,处置类型业务调整,操作名称网络优化,有效期2026-06-01~2030-06-18" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务抢通,操作名称是链路切换,有效期是永久生效\n2. 业务场景是海外漫游,处置类型是业务恢复,操作名称是带宽扩容,有效期是2026-01-01~2030-12-31\n3. 业务场景是视频保障,处置类型是业务优化,操作名称是信号增强,有效期是2027-03-15~2029-09-30\n4. 业务场景是车联网,处置类型是业务调整,操作名称是网络优化,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "策略标识": { - "type": "string", - "description": "要删除的策略唯一标识,UUID格式" - }, - "业务场景": { - "type": "string", - "description": "按业务场景条件删除" - } + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "应急通信", + "actionType": "业务抢通", + "operationName": "链路切换", + "validityPeriod": "永久生效" + }, + { + "policyId": null, + "scene": "海外漫游", + "actionType": "业务恢复", + "operationName": "带宽扩容", + "validityPeriod": "2026-01-01~2030-12-31" + }, + { + "policyId": null, + "scene": "视频保障", + "actionType": "业务优化", + "operationName": "信号增强", + "validityPeriod": "2027-03-15~2029-09-30" + }, + { + "policyId": null, + "scene": "车联网", + "actionType": "业务调整", + "operationName": "网络优化", + "validityPeriod": "2026-06-01~2030-06-18" + } + ] } } + } + }, + { + "label": "c2-nl-multi-04-varname", + "entry": "from_text", + "input": { + "text": "新增四条授权策略:第1条业务场景应急通信,处置类型业务抢通,操作名称链路切换,有效期永久生效;第2条业务场景海外漫游,处置类型业务恢复,操作名称带宽扩容,有效期2026-01-01~2030-12-31;第3条业务场景视频保障,处置类型业务优化,操作名称信号增强,有效期2027-03-15~2029-09-30;第4条业务场景车联网,处置类型业务调整,操作名称网络优化,有效期2026-06-01~2030-06-18" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务抢通,操作名称是链路切换,有效期是永久生效\n2. 业务场景是海外漫游,处置类型是业务恢复,操作名称是带宽扩容,有效期是2026-01-01~2030-12-31\n3. 业务场景是视频保障,处置类型是业务优化,操作名称是信号增强,有效期是2027-03-15~2029-09-30\n4. 业务场景是车联网,处置类型是业务调整,操作名称是网络优化,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "删除授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "策略明细": [ + { + "标识": null, + "scene": "应急通信", + "actionType": "业务抢通", + "operationName": "链路切换", + "生效时段": "永久生效" + }, { - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + "标识": null, + "scene": "海外漫游", + "actionType": "业务恢复", + "operationName": "带宽扩容", + "生效时段": "2026-01-01~2030-12-31" + }, + { + "标识": null, + "scene": "视频保障", + "actionType": "业务优化", + "operationName": "信号增强", + "生效时段": "2027-03-15~2029-09-30" + }, + { + "标识": null, + "scene": "车联网", + "actionType": "业务调整", + "operationName": "网络优化", + "生效时段": "2026-06-01~2030-06-18" } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "策略明细": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "标识": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "生效时段": { + "type": "string", + "description": "授权策略的生效时间区间" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + } + } + } + } + } } }, { - "label": "c4-data-del-02", + "label": "c2-data-multi-07", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "删除授权策略", - "业务场景": "容量规划" + "operationType": "新增授权策略", + "policyCount": 3, + "details": [ + { + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "永久生效" + }, + { + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" + }, + { + "scene": "政企专网", + "actionType": "业务优化", + "operationName": "路由调整", + "validityPeriod": "2027-03-15~2029-09-30" + } + ] }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -2629,80 +2866,68 @@ "查询授权策略" ] }, - "策略标识": { - "type": "string", - "description": "要删除的策略唯一标识,UUID格式" + "policyCount": { + "type": "integer", + "description": "要新增的策略数量" }, - "业务场景": { - "type": "string", - "description": "按业务场景条件删除" - } - } - } - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是容量规划\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "删除授权策略", - "策略列表": [ - { - "业务场景": "容量规划" + "details": { + "type": "array", + "description": "策略详情列表", + "items": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "生效时间区间或永久生效" + } + } } - ] + } } } - } - }, - { - "label": "c4-nl-del-05", - "entry": "from_text", - "input": { - "text": "删除7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3和f9e8d7c6-b5a4-3210-fedc-ba9876543210这两个授权策略" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n2. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "删除授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3" + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "永久生效" }, { - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" - } - ] - } - } - } - }, - { - "label": "c4-nl-del-06", - "entry": "from_text", - "input": { - "text": "删除处置类型为业务抢通和业务优化的所有授权" - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 处置类型是业务抢通和业务优化\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "删除授权策略", - "策略列表": [ + "policyId": null, + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" + }, { - "处置类型": "业务抢通和业务优化" + "policyId": null, + "scene": "政企专网", + "actionType": "业务优化", + "operationName": "路由调整", + "validityPeriod": "2027-03-15~2029-09-30" } ] } @@ -2710,24 +2935,37 @@ } }, { - "label": "c4-data-del-03", + "label": "c2-data-multi-07-dual", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "删除授权策略", - "详情": [ + "operationType": "新增授权策略", + "policyCount": 3, + "details": [ + { + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "永久生效" + }, { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3" + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" }, { - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + "scene": "政企专网", + "actionType": "业务优化", + "operationName": "路由调整", + "validityPeriod": "2027-03-15~2029-09-30" } ] }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -2736,19 +2974,31 @@ "查询授权策略" ] }, - "详情": { + "policyCount": { + "type": "integer", + "description": "要新增的策略数量" + }, + "details": { "type": "array", - "description": "要删除的策略标识列表", + "description": "策略详情列表", "items": { "type": "object", "properties": { - "策略标识": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { "type": "string", - "description": "要删除的策略唯一标识,UUID格式" + "description": "所授权的动网操作的具体名称" }, - "业务场景": { + "validityPeriod": { "type": "string", - "description": "按业务场景条件删除" + "description": "生效时间区间或永久生效" } } } @@ -2758,190 +3008,302 @@ }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n2. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "删除授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policies": [ { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3" + "pid": null, + "domain": "校园专网", + "handling": "业务抢通", + "opName": "隧道调优", + "effectivePeriod": "永久生效" }, { - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + "pid": null, + "domain": "医疗专线", + "handling": "业务恢复", + "opName": "频段调整", + "effectivePeriod": "2026-06-01~2030-06-18" + }, + { + "pid": null, + "domain": "政企专网", + "handling": "业务优化", + "opName": "路由调整", + "effectivePeriod": "2027-03-15~2029-09-30" } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "策略的唯一标识" + }, + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } } }, { - "label": "c5-nl-query-01", + "label": "c3-nl-mod-06", "entry": "from_text", "input": { - "text": "列出所有授权策略" + "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3和a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的有效期都改成永久生效" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效\n2. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "查询授权策略", - "策略列表": [] + "operationType": "修改授权策略", + "policyList": [ + { + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "永久生效" + }, + { + "policyId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "永久生效" + } + ] } } } }, { - "label": "c5-nl-query-03", + "label": "c3-nl-mod-06-varfields", "entry": "from_text", "input": { - "text": "查询业务场景是载波调度、处置类型是业务抢通的授权" + "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3和a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的有效期都改成永久生效" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是载波调度,处置类型是业务抢通\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效\n2. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "查询授权策略", - "策略列表": [ + "operationType": "修改授权策略", + "policyList": [ + { + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "newValidityPeriod": "永久生效" + }, { - "业务场景": "载波调度", - "处置类型": "业务抢通" + "policyId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "newValidityPeriod": "永久生效" } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "newValidityPeriod": { + "type": "string", + "description": "修改后的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } + } + } + } + } } }, { - "label": "c5-data-query-01", - "entry": "from_data_with_schema", + "label": "c3-nl-mod-02", + "entry": "from_text", "input": { - "data": { - "操作类型": "查询授权策略" - }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "业务场景": { - "type": "string", - "description": "按业务场景查询" - }, - "处置类型": { - "type": "string", - "description": "按处置类型查询" - }, - "操作名称": { - "type": "string", - "description": "按操作名称查询" - } - } - } - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "text": "把a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d这个授权策略的有效期改为2026年6月1日到2030年6月18日" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "查询授权策略", - "策略列表": [] + "operationType": "修改授权策略", + "policyList": [ + { + "policyId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "2026-06-01~2030-06-18" + } + ] } } } }, { - "label": "c5-data-query-02", - "entry": "from_data_with_schema", + "label": "c3-nl-mod-02-varreq", + "entry": "from_text", "input": { - "data": { - "操作类型": "查询授权策略", - "业务场景": "信号覆盖优化" - }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "业务场景": { - "type": "string", - "description": "按业务场景查询" - }, - "处置类型": { - "type": "string", - "description": "按处置类型查询" - }, - "操作名称": { - "type": "string", - "description": "按操作名称查询" - } - } - } + "text": "把a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d这个授权策略的有效期改为2026年6月1日到2030年6月18日" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是信号覆盖优化\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "查询授权策略", - "策略列表": [ + "operationType": "修改授权策略", + "policyList": [ { - "业务场景": "信号覆盖优化" + "policyId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "2026-06-01T00:00:00Z" } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "修改后的生效时间,须为 ISO 8601 时间戳,如 2026-01-01T00:00:00Z" + } + } + } + } + } } }, { - "label": "c5-nl-query-05", + "label": "c3-nl-mod-07", "entry": "from_text", "input": { - "text": "查询业务场景是视频保障、处置类型是故障切换的授权策略" + "text": "修改a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的授权,有效期改成2026-09-01~2031-08-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是故障切换\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是2026-09-01~2031-08-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "查询授权策略", - "策略列表": [ + "operationType": "修改授权策略", + "policyList": [ { - "业务场景": "视频保障", - "处置类型": "故障切换" + "policyId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "2026-09-01~2031-08-31" } ] } @@ -2949,49 +3311,126 @@ } }, { - "label": "c5-nl-query-06", + "label": "c3-nl-mod-07-varname", "entry": "from_text", "input": { - "text": "查询业务场景是物联网专线、处置类型是割接的授权策略" + "text": "修改a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的授权,有效期改成2026-09-01~2031-08-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是物联网专线,处置类型是割接\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是2026-09-01~2031-08-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "查询授权策略", - "策略列表": [ + "operationType": "修改授权策略", + "policies": [ { - "业务场景": "物联网专线", - "处置类型": "割接" + "pid": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "domain": null, + "handling": null, + "opName": null, + "effectivePeriod": "2026-09-01~2031-08-31" } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "策略的唯一标识" + }, + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } } }, { - "label": "c6-nl-mixed-03", - "entry": "from_text", + "label": "c3-data-mod-01", + "entry": "from_data_with_schema", "input": { - "text": "将7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3的有效期改为2026年8月1日到2030年12月31日" + "data": { + "operationType": "修改授权策略", + "policyId": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "validityPeriod": "2027-01-01~2029-12-31" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "validityPeriod": { + "type": "string", + "description": "修改后的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } + } + } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210,有效期是2027-01-01~2029-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "修改授权策略", - "策略列表": [ + "operationType": "修改授权策略", + "policyList": [ { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "有效期": "2026-08-01~2030-12-31" + "policyId": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "2027-01-01~2029-12-31" } ] } @@ -2999,112 +3438,1385 @@ } }, { - "label": "c6-nl-mixed-04", - "entry": "from_text", + "label": "c3-data-mod-01-dual", + "entry": "from_data_with_schema", "input": { - "text": "把所有授权策略都列出来" + "data": { + "operationType": "修改授权策略", + "policyId": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "validityPeriod": "2027-01-01~2029-12-31" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "validityPeriod": { + "type": "string", + "description": "修改后的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } + } + } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210,有效期是2027-01-01~2029-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "查询授权策略", - "策略列表": [] + "operationType": "修改授权策略", + "policies": [ + { + "pid": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "domain": null, + "handling": null, + "opName": null, + "effectivePeriod": "2027-01-01~2029-12-31" + } + ] + } + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "策略的唯一标识" + }, + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } } } } }, { - "label": "c6-nl-mixed-05", - "entry": "from_text", + "label": "c4-data-del-02", + "entry": "from_data_with_schema", "input": { - "text": "清除所有业务投诉诊断相关的授权" + "data": { + "operationType": "删除授权策略", + "scene": "容量规划" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "policyId": { + "type": "string", + "description": "要删除的策略唯一标识" + }, + "scene": { + "type": "string", + "description": "按业务场景条件删除" + } + } + } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是业务投诉诊断\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是容量规划\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "删除授权策略", - "策略列表": [ - { - "业务场景": "业务投诉诊断" - } - ] - } + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "missing_required", + "message": "删除授权策略时必填参数包括动网操作的授权策略标识,条目缺少策略标识" + } + ], + "params": {} } } }, { - "label": "c6-data-mixed-02", + "label": "c4-data-del-02-varsch", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "修改授权策略", - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "有效期": "2026-01-01~2030-12-31" + "operationType": "删除授权策略", + "bizScene": "容量规划" }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", "修改授权策略", "删除授权策略", "查询授权策略" - ] + ], + "description": "授权策略的操作类型" }, - "策略标识": { + "bizScene": { "type": "string", - "description": "策略的唯一标识,UUID格式" - }, - "有效期": { - "type": "string", - "description": "新的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + "description": "动网操作支撑的业务场景" } } } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是容量规划\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "修改授权策略", - "策略列表": [ - { - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "有效期": "2026-01-01~2030-12-31" + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "missing_required", + "message": "删除授权策略时必填参数包括动网操作的授权策略标识,条目缺少策略标识" + } + ], + "params": {} + } + } + }, + { + "label": "c4-nl-del-01", + "entry": "from_text", + "input": { + "text": "删除7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "删除授权策略", + "policyList": [ + { + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": null + } + ] + } + } + } + }, + { + "label": "c4-nl-del-01-varname", + "entry": "from_text", + "input": { + "text": "删除7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "删除授权策略", + "policies": [ + { + "pid": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "domain": null, + "handling": null, + "opName": null, + "effectivePeriod": null + } + ] + } + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "策略的唯一标识" + }, + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } + } + }, + { + "label": "c4-nl-del-04", + "entry": "from_text", + "input": { + "text": "删除所有业务场景为载波调度的授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是载波调度\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "missing_required", + "message": "删除授权策略时必填参数包括动网操作的授权策略标识,条目缺少策略标识" + } + ], + "params": {} + } + } + }, + { + "label": "c4-nl-del-04-varfields", + "entry": "from_text", + "input": { + "text": "删除所有业务场景为载波调度的授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是载波调度\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "missing_required", + "message": "删除授权策略时必填参数包括动网操作的授权策略标识,条目缺少策略标识" + } + ], + "params": {} + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "删除条件字段;条目可为策略标识或业务场景条件", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + } + } + } + } + } + } + }, + { + "label": "c4-nl-del-06", + "entry": "from_text", + "input": { + "text": "删除7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3和a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d这两个授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n2. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "删除授权策略", + "policyList": [ + { + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": null + }, + { + "policyId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": null + } + ] + } + } + } + }, + { + "label": "c4-nl-del-06-varname", + "entry": "from_text", + "input": { + "text": "删除7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3和a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d这两个授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n2. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "删除授权策略", + "policies": [ + { + "pid": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "domain": null, + "handling": null, + "opName": null, + "effectivePeriod": null + }, + { + "pid": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "domain": null, + "handling": null, + "opName": null, + "effectivePeriod": null + } + ] + } + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "策略的唯一标识" + }, + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } + } + }, + { + "label": "c5-nl-query-03", + "entry": "from_text", + "input": { + "text": "查询业务场景是载波调度、处置类型是业务抢通的授权" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是载波调度,处置类型是业务抢通\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "查询授权策略", + "policyList": [ + { + "policyId": null, + "scene": "载波调度", + "actionType": "业务抢通", + "operationName": null, + "validityPeriod": null + } + ] + } + } + } + }, + { + "label": "c5-nl-query-03-varname", + "entry": "from_text", + "input": { + "text": "查询业务场景是载波调度、处置类型是业务抢通的授权" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是载波调度,处置类型是业务抢通\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "查询授权策略", + "policies": [ + { + "pid": null, + "domain": "载波调度", + "handling": "业务抢通", + "opName": null, + "effectivePeriod": null + } + ] + } + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "策略的唯一标识" + }, + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } + } + }, + { + "label": "c5-nl-query-05", + "entry": "from_text", + "input": { + "text": "查询业务场景是视频保障、处置类型是故障切换的授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是故障切换\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "查询授权策略", + "policyList": [ + { + "policyId": null, + "scene": "视频保障", + "actionType": "故障切换", + "operationName": null, + "validityPeriod": null + } + ] + } + } + } + }, + { + "label": "c5-nl-query-05-varreq", + "entry": "from_text", + "input": { + "text": "查询业务场景是视频保障、处置类型是故障切换的授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是故障切换\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "查询授权策略", + "policyList": [ + { + "policyId": null, + "scene": "视频保障", + "actionType": "故障切换", + "operationName": null, + "validityPeriod": null + } + ] + } + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + } + } + } + } + } + } + }, + { + "label": "c5-nl-query-06", + "entry": "from_text", + "input": { + "text": "查询业务场景是物联网专线、处置类型是割接的授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是物联网专线,处置类型是割接\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "查询授权策略", + "policyList": [ + { + "policyId": null, + "scene": "物联网专线", + "actionType": "割接", + "operationName": null, + "validityPeriod": null + } + ] + } + } + } + }, + { + "label": "c5-nl-query-06-varfields", + "entry": "from_text", + "input": { + "text": "查询业务场景是物联网专线、处置类型是割接的授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是物联网专线,处置类型是割接\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "查询授权策略", + "policyList": [ + { + "scene": "物联网专线", + "actionType": "割接" + } + ] + } + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "查询条件字段:业务场景与处置类型", + "items": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + } + } + } + } + } + } + }, + { + "label": "c5-nl-query-01", + "entry": "from_text", + "input": { + "text": "列出所有授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "查询授权策略", + "policyList": [] + } + } + } + }, + { + "label": "c5-nl-query-01-varname", + "entry": "from_text", + "input": { + "text": "列出所有授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "查询授权策略", + "策略明细": [] + } + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "策略明细": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "标识": { + "type": "string", + "description": "策略的唯一标识" + }, + "生效时段": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } + } + }, + { + "label": "c6-nl-mixed-03", + "entry": "from_text", + "input": { + "text": "将7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3的有效期改为2026年8月1日到2030年12月31日" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "修改授权策略", + "policyList": [ + { + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "2026-08-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "c6-nl-mixed-03-varname", + "entry": "from_text", + "input": { + "text": "将7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3的有效期改为2026年8月1日到2030年12月31日" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "修改授权策略", + "policies": [ + { + "pid": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "domain": null, + "handling": null, + "opName": null, + "effectivePeriod": "2026-08-01~2030-12-31" + } + ] + } + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "策略的唯一标识" + }, + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } + } + }, + { + "label": "c6-nl-mixed-04", + "entry": "from_text", + "input": { + "text": "把所有授权策略都列出来" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "查询授权策略", + "policyList": [] + } + } + } + }, + { + "label": "c6-nl-mixed-04-varfields", + "entry": "from_text", + "input": { + "text": "把所有授权策略都列出来" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "查询授权策略", + "policyList": [] + } + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "查询条件字段,按输入中的出现顺序排列。", + "items": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } + } + }, + { + "label": "c6-nl-mixed-07", + "entry": "from_text", + "input": { + "text": "新增三条授权:第一条业务场景校园专网,处置类型业务抢通,操作名称隧道调优,有效期永久生效;第二条业务场景医疗专线,处置类型业务恢复,操作名称频段调整,有效期2026-13-01~2030-12-31;第三条业务场景政企专网,处置类型业务优化,操作名称路由调整,有效期2027-03-15~2029-09-30" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "format_error" + } + ] + } + } + }, + { + "label": "c6-nl-mixed-07-varname", + "entry": "from_text", + "input": { + "text": "新增三条授权:第一条业务场景校园专网,处置类型业务抢通,操作名称隧道调优,有效期永久生效;第二条业务场景医疗专线,处置类型业务恢复,操作名称频段调整,有效期2026-13-01~2030-12-31;第三条业务场景政企专网,处置类型业务优化,操作名称路由调整,有效期2027-03-15~2029-09-30" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policies", + "code": "format_error" + } + ] + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "策略的唯一标识" + }, + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } + } + }, + { + "label": "c6-nl-mixed-06", + "entry": "from_text", + "input": { + "text": "新增三条授权:第一条业务场景应急通信,处置类型业务抢通,操作名称链路切换,有效期永久生效;第二条业务场景海外漫游,处置类型业务恢复,操作名称带宽扩容,有效期从2026年8月1日到2030年12月31日;第三条业务场景视频保障,处置类型业务优化,操作名称信号增强,有效期2026-01-01~2030-12-31" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务抢通,操作名称是链路切换,有效期是永久生效\n2. 业务场景是海外漫游,处置类型是业务恢复,操作名称是带宽扩容,有效期是2026-08-01~2030-12-31\n3. 业务场景是视频保障,处置类型是业务优化,操作名称是信号增强,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "应急通信", + "actionType": "业务抢通", + "operationName": "链路切换", + "validityPeriod": "永久生效" + }, + { + "policyId": null, + "scene": "海外漫游", + "actionType": "业务恢复", + "operationName": "带宽扩容", + "validityPeriod": "2026-08-01~2030-12-31" + }, + { + "policyId": null, + "scene": "视频保障", + "actionType": "业务优化", + "operationName": "信号增强", + "validityPeriod": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "c6-nl-mixed-06-varflat", + "entry": "from_text", + "input": { + "text": "新增三条授权:第一条业务场景应急通信,处置类型业务抢通,操作名称链路切换,有效期永久生效;第二条业务场景海外漫游,处置类型业务恢复,操作名称带宽扩容,有效期从2026年8月1日到2030年12月31日;第三条业务场景视频保障,处置类型业务优化,操作名称信号增强,有效期2026-01-01~2030-12-31" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务抢通,操作名称是链路切换,有效期是永久生效\n2. 业务场景是海外漫游,处置类型是业务恢复,操作名称是带宽扩容,有效期是2026-08-01~2030-12-31\n3. 业务场景是视频保障,处置类型是业务优化,操作名称是信号增强,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policies": [ + { + "scene": "应急通信", + "actionType": "业务抢通", + "operationName": "链路切换", + "validityPeriod": "永久生效" + }, + { + "scene": "海外漫游", + "actionType": "业务恢复", + "operationName": "带宽扩容", + "validityPeriod": "2026-08-01~2030-12-31" + }, + { + "scene": "视频保障", + "actionType": "业务优化", + "operationName": "信号增强", + "validityPeriod": "2026-01-01~2030-12-31" + } + ] + } + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "策略记录列表,每条含场景、处置、操作与生效时段", + "items": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } + } + } + } + } + } + }, + { + "label": "c6-nl-mixed-08", + "entry": "from_text", + "input": { + "text": "帮我新增一个授权策略,业务场景是校园专网,处置类型是紧急扩容,操作名称是天线调整,有效期从2026-08-01到2030-12-31" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是紧急扩容,操作名称是天线调整,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "校园专网", + "actionType": "紧急扩容", + "operationName": "天线调整", + "validityPeriod": "2026-08-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "c6-nl-mixed-08-varreq", + "entry": "from_text", + "input": { + "text": "帮我新增一个授权策略,业务场景是校园专网,处置类型是紧急扩容,操作名称是天线调整,有效期从2026-08-01到2030-12-31" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是紧急扩容,操作名称是天线调整,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "校园专网", + "actionType": "紧急扩容", + "operationName": "天线调整", + "validityPeriod": "2030-12-31" + } + ] + } + } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "生效截止日期,单值,格式 YYYY-MM-DD" + } } - ] + } } } } }, { - "label": "c6-data-mixed-03", + "label": "c6-data-mixed-04", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "删除授权策略", - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + "operationType": "查询授权策略", + "scene": "业务投诉诊断" }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -3113,29 +4825,37 @@ "查询授权策略" ] }, - "策略标识": { + "scene": { + "type": "string", + "description": "按业务场景查询" + }, + "actionType": { "type": "string", - "description": "要删除的策略唯一标识,UUID格式" + "description": "按处置类型查询" }, - "业务场景": { + "operationName": { "type": "string", - "description": "按业务场景条件删除" + "description": "按操作名称查询" } } } }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是业务投诉诊断\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "删除授权策略", - "策略列表": [ + "operationType": "查询授权策略", + "policyList": [ { - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + "policyId": null, + "scene": "业务投诉诊断", + "actionType": null, + "operationName": null, + "validityPeriod": null } ] } @@ -3143,17 +4863,17 @@ } }, { - "label": "c6-data-mixed-04", + "label": "c6-data-mixed-04-varsch", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "查询授权策略", - "业务场景": "业务投诉诊断" + "operationType": "查询授权策略", + "bizScene": "业务投诉诊断" }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -3162,17 +4882,9 @@ "查询授权策略" ] }, - "业务场景": { + "bizScene": { "type": "string", "description": "按业务场景查询" - }, - "处置类型": { - "type": "string", - "description": "按处置类型查询" - }, - "操作名称": { - "type": "string", - "description": "按操作名称查询" } } } @@ -3185,10 +4897,14 @@ "outcome": "success", "slot_errors": [], "params": { - "操作类型": "查询授权策略", - "策略列表": [ + "operationType": "查询授权策略", + "policyList": [ { - "业务场景": "业务投诉诊断" + "policyId": null, + "scene": "业务投诉诊断", + "actionType": null, + "operationName": null, + "validityPeriod": null } ] } @@ -3200,26 +4916,26 @@ "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "新增授权策略", - "详情": [ + "operationType": "新增授权策略", + "details": [ { - "业务场景": "政企专网", - "处置类型": "业务优化", - "操作名称": "路由调整", - "有效期": "永久生效" + "scene": "政企专网", + "actionType": "业务优化", + "operationName": "路由调整", + "validityPeriod": "永久生效" }, { - "业务场景": "应急通信", - "处置类型": "业务调整", - "操作名称": "隧道调优", - "有效期": "2026-01-01~2030-12-31" + "scene": "应急通信", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" } ] }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -3228,25 +4944,25 @@ "查询授权策略" ] }, - "详情": { + "details": { "type": "array", "description": "策略详情列表", "items": { "type": "object", "properties": { - "业务场景": { + "scene": { "type": "string", "description": "动网操作支撑的业务场景" }, - "处置类型": { + "actionType": { "type": "string", "description": "动网操作的处置类型" }, - "操作名称": { + "operationName": { "type": "string", "description": "所授权的动网操作的具体名称" }, - "有效期": { + "validityPeriod": { "type": "string", "description": "生效时间区间或永久生效" } @@ -3264,159 +4980,21 @@ "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "政企专网", - "处置类型": "业务优化", - "操作名称": "路由调整", - "有效期": "永久生效" - }, - { - "业务场景": "应急通信", - "处置类型": "业务调整", - "操作名称": "隧道调优", - "有效期": "2026-01-01~2030-12-31" - } - ] - } - } - } - }, - { - "label": "c6-data-mixed-09", - "entry": "from_data_with_schema", - "input": { - "data": { - "操作类型": "修改授权策略", - "标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "新时间": "永久生效" - }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "标识": { - "type": "string", - "description": "要修改的授权策略的唯一标识(UUID)" - }, - "新时间": { - "type": "string", - "description": "修改后的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" - } - } - } - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "修改授权策略", - "策略列表": [ - { - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "有效期": "永久生效" - } - ] - } - } - } - }, - { - "label": "c6-data-mixed-11", - "entry": "from_data_with_schema", - "input": { - "data": { - "操作类型": "查询授权策略", - "场景": "信号覆盖优化", - "备注": "仅看启用的" - }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "场景": { - "type": "string", - "description": "查询条件:业务场景" - }, - "处置": { - "type": "string", - "description": "查询条件:处置类型" - } - } - } - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是信号覆盖优化\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "查询授权策略", - "策略列表": [ - { - "业务场景": "信号覆盖优化" - } - ] - } - } - } - }, - { - "label": "c6-nl-mixed-06", - "entry": "from_text", - "input": { - "text": "新增三条授权:第一条业务场景应急通信,处置类型业务抢通,操作名称链路切换,有效期永久生效;第二条业务场景海外漫游,处置类型业务恢复,操作名称带宽扩容,有效期从2026年8月1日到2030年12月31日;第三条业务场景视频保障,处置类型业务优化,操作名称信号增强,有效期2026-01-01~2030-12-31" - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务抢通,操作名称是链路切换,有效期是永久生效\n2. 业务场景是海外漫游,处置类型是业务恢复,操作名称是带宽扩容,有效期是2026-08-01~2030-12-31\n3. 业务场景是视频保障,处置类型是业务优化,操作名称是信号增强,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "应急通信", - "处置类型": "业务抢通", - "操作名称": "链路切换", - "有效期": "永久生效" - }, + "operationType": "新增授权策略", + "policyList": [ { - "业务场景": "海外漫游", - "处置类型": "业务恢复", - "操作名称": "带宽扩容", - "有效期": "2026-08-01~2030-12-31" + "policyId": null, + "scene": "政企专网", + "actionType": "业务优化", + "operationName": "路由调整", + "validityPeriod": "永久生效" }, { - "业务场景": "视频保障", - "处置类型": "业务优化", - "操作名称": "信号增强", - "有效期": "2026-01-01~2030-12-31" + "policyId": null, + "scene": "应急通信", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" } ] } @@ -3424,38 +5002,30 @@ } }, { - "label": "c6-data-mixed-12", + "label": "c6-data-mixed-06-varname", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "新增授权策略", - "详情": [ - { - "场景": "车联网", - "处置": "业务调整", - "操作": "网络优化", - "时间": "永久生效", - "备注": "常态化" - }, + "operationType": "新增授权策略", + "details": [ { - "场景": "无人机回传", - "处置": "业务抢通", - "操作": "带宽扩容", - "时间": "2026-06-01~2030-06-18" + "scene": "政企专网", + "actionType": "业务优化", + "operationName": "路由调整", + "validityPeriod": "永久生效" }, { - "场景": "容量规划", - "处置": "业务优化", - "操作": "路由调整", - "时间": "2027-03-15~2029-09-30", - "备注": "试运行" + "scene": "应急通信", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" } ] }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -3464,27 +5034,27 @@ "查询授权策略" ] }, - "详情": { + "details": { "type": "array", "description": "策略详情列表", "items": { "type": "object", "properties": { - "场景": { + "scene": { "type": "string", "description": "动网操作支撑的业务场景" }, - "处置": { + "actionType": { "type": "string", "description": "动网操作的处置类型" }, - "操作": { + "operationName": { "type": "string", "description": "所授权的动网操作的具体名称" }, - "时间": { + "validityPeriod": { "type": "string", - "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + "description": "生效时间区间或永久生效" } } } @@ -3494,79 +5064,110 @@ }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是车联网,处置类型是业务调整,操作名称是网络优化,有效期是永久生效\n2. 业务场景是无人机回传,处置类型是业务抢通,操作名称是带宽扩容,有效期是2026-06-01~2030-06-18\n3. 业务场景是容量规划,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是永久生效\n2. 业务场景是应急通信,处置类型是业务调整,操作名称是隧道调优,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "策略明细": [ { - "业务场景": "车联网", - "处置类型": "业务调整", - "操作名称": "网络优化", - "有效期": "永久生效" + "标识": null, + "scene": "政企专网", + "actionType": "业务优化", + "operationName": "路由调整", + "生效时段": "永久生效" }, { - "业务场景": "无人机回传", - "处置类型": "业务抢通", - "操作名称": "带宽扩容", - "有效期": "2026-06-01~2030-06-18" - }, - { - "业务场景": "容量规划", - "处置类型": "业务优化", - "操作名称": "路由调整", - "有效期": "2027-03-15~2029-09-30" + "标识": null, + "scene": "应急通信", + "actionType": "业务调整", + "operationName": "隧道调优", + "生效时段": "2026-01-01~2030-12-31" } ] } } - } - }, - { - "label": "c6-nl-mixed-07", - "entry": "from_text", - "input": { - "text": "新增三条授权:第一条业务场景校园专网,处置类型业务抢通,操作名称隧道调优,有效期永久生效;第二条业务场景医疗专线,处置类型业务恢复,操作名称频段调整,有效期2026-13-01~2030-12-31;第三条业务场景政企专网,处置类型业务优化,操作名称路由调整,有效期2027-03-15~2029-09-30" }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "validation_semantic_rejected", - "slot_errors": [ - { - "slot_name": "策略列表", - "code": "format_error" + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "策略明细": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "标识": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "生效时段": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } } - ] + } } } }, { - "label": "c6-data-mixed-13", + "label": "c6-data-mixed-12", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "修改授权策略", - "详情": [ + "operationType": "新增授权策略", + "details": [ + { + "bizScene": "车联网", + "handling": "业务调整", + "opName": "网络优化", + "timeRange": "永久生效", + "remark": "常态化" + }, { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "有效期": "永久生效" + "bizScene": "无人机回传", + "handling": "业务抢通", + "opName": "带宽扩容", + "timeRange": "2026-06-01~2030-06-18" }, { - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "有效期": "到2028-12-31为止" + "bizScene": "容量规划", + "handling": "业务优化", + "opName": "路由调整", + "timeRange": "2027-03-15~2029-09-30", + "remark": "试运行" } ] }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -3575,19 +5176,27 @@ "查询授权策略" ] }, - "详情": { + "details": { "type": "array", - "description": "策略修改详情列表", + "description": "策略详情列表", "items": { "type": "object", "properties": { - "策略标识": { + "bizScene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { "type": "string", - "description": "要修改的策略唯一标识,UUID格式" + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" }, - "有效期": { + "timeRange": { "type": "string", - "description": "新的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" } } } @@ -3597,40 +5206,34 @@ }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效\n2. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是到2028-12-31为止\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "validation_semantic_rejected", - "slot_errors": [ - { - "slot_name": "有效期", - "code": "format_error" - } - ] - } - } - }, - { - "label": "c6-nl-mixed-08", - "entry": "from_text", - "input": { - "text": "帮我新增一个授权策略,业务场景是校园专网,处置类型是紧急扩容,操作名称是天线调整,有效期从2026-08-01到2030-12-31" - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是紧急扩容,操作名称是天线调整,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是车联网,处置类型是业务调整,操作名称是网络优化,有效期是永久生效\n2. 业务场景是无人机回传,处置类型是业务抢通,操作名称是带宽扩容,有效期是2026-06-01~2030-06-18\n3. 业务场景是容量规划,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "车联网", + "actionType": "业务调整", + "operationName": "网络优化", + "validityPeriod": "永久生效" + }, { - "业务场景": "校园专网", - "处置类型": "紧急扩容", - "操作名称": "天线调整", - "有效期": "2026-08-01~2030-12-31" + "policyId": null, + "scene": "无人机回传", + "actionType": "业务抢通", + "operationName": "带宽扩容", + "validityPeriod": "2026-06-01~2030-06-18" + }, + { + "policyId": null, + "scene": "容量规划", + "actionType": "业务优化", + "operationName": "路由调整", + "validityPeriod": "2027-03-15~2029-09-30" } ] } @@ -3638,24 +5241,38 @@ } }, { - "label": "c6-data-mixed-14", + "label": "c6-data-mixed-12-dual", "entry": "from_data_with_schema", "input": { "data": { - "操作类型": "新增授权策略", - "详情": [ + "operationType": "新增授权策略", + "details": [ + { + "bizScene": "车联网", + "handling": "业务调整", + "opName": "网络优化", + "timeRange": "永久生效", + "remark": "常态化" + }, { - "场景": "视频保障", - "处置": "故障切换", - "操作": "频谱重耕", - "时间": "永久生效" + "bizScene": "无人机回传", + "handling": "业务抢通", + "opName": "带宽扩容", + "timeRange": "2026-06-01~2030-06-18" + }, + { + "bizScene": "容量规划", + "handling": "业务优化", + "opName": "路由调整", + "timeRange": "2027-03-15~2029-09-30", + "remark": "试运行" } ] }, "schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -3664,25 +5281,25 @@ "查询授权策略" ] }, - "详情": { + "details": { "type": "array", "description": "策略详情列表", "items": { "type": "object", "properties": { - "场景": { + "bizScene": { "type": "string", "description": "动网操作支撑的业务场景" }, - "处置": { + "handling": { "type": "string", "description": "动网操作的处置类型" }, - "操作": { + "opName": { "type": "string", "description": "所授权的动网操作的具体名称" }, - "时间": { + "timeRange": { "type": "string", "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" } @@ -3694,23 +5311,82 @@ }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是故障切换,操作名称是频谱重耕,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是车联网,处置类型是业务调整,操作名称是网络优化,有效期是永久生效\n2. 业务场景是无人机回传,处置类型是业务抢通,操作名称是带宽扩容,有效期是2026-06-01~2030-06-18\n3. 业务场景是容量规划,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policies": [ + { + "pid": null, + "domain": "车联网", + "handling": "业务调整", + "opName": "网络优化", + "effectivePeriod": "永久生效" + }, + { + "pid": null, + "domain": "无人机回传", + "handling": "业务抢通", + "opName": "带宽扩容", + "effectivePeriod": "2026-06-01~2030-06-18" + }, { - "业务场景": "视频保障", - "处置类型": "故障切换", - "操作名称": "频谱重耕", - "有效期": "永久生效" + "pid": null, + "domain": "容量规划", + "handling": "业务优化", + "opName": "路由调整", + "effectivePeriod": "2027-03-15~2029-09-30" } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "策略的唯一标识" + }, + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } } } ] diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios.json b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios.json index 1ed15ec7..344754ca 100644 --- a/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios.json +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios.json @@ -14,13 +14,14 @@ "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ { - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "2026-01-01~2030-12-31" + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" } ] } @@ -28,30 +29,75 @@ } }, { - "label": "c1-nl-add-12", + "label": "c1-nl-add-01-varname", "entry": "from_text", "input": { - "text": "为物联网专线新增一个倒换类型的授权,操作是流量重定向,有效期从2026-08-01到2030-12-31" + "text": "帮我新增一个授权策略,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是物联网专线,处置类型是倒换,操作名称是流量重定向,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policies": [ { - "业务场景": "物联网专线", - "处置类型": "倒换", - "操作名称": "流量重定向", - "有效期": "2026-08-01~2030-12-31" + "pid": null, + "domain": "校园专网", + "handling": "业务抢通", + "opName": "隧道调优", + "effectivePeriod": "2026-01-01~2030-12-31" } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "策略的唯一标识" + }, + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } } }, { @@ -68,19 +114,21 @@ "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ { - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "2026-01-01~2030-12-31" + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" }, { - "业务场景": "医疗专线", - "处置类型": "业务恢复", - "操作名称": "频段调整", - "有效期": "2026-06-01~2030-06-18" + "policyId": null, + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" } ] } @@ -88,104 +136,34 @@ } }, { - "label": "c2-data-multi-07", - "entry": "from_data_with_schema", + "label": "c3-nl-mod-06", + "entry": "from_text", "input": { - "data": { - "操作类型": "新增授权策略", - "策略数量": 3, - "详情": [ - { - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "永久生效" - }, - { - "业务场景": "医疗专线", - "处置类型": "业务恢复", - "操作名称": "频段调整", - "有效期": "2026-06-01~2030-06-18" - }, - { - "业务场景": "政企专网", - "处置类型": "业务优化", - "操作名称": "路由调整", - "有效期": "2027-03-15~2029-09-30" - } - ] - }, - "schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ] - }, - "策略数量": { - "type": "integer", - "description": "要新增的策略数量" - }, - "详情": { - "type": "array", - "description": "策略详情列表", - "items": { - "type": "object", - "properties": { - "业务场景": { - "type": "string", - "description": "动网操作支撑的业务场景" - }, - "处置类型": { - "type": "string", - "description": "动网操作的处置类型" - }, - "操作名称": { - "type": "string", - "description": "所授权的动网操作的具体名称" - }, - "有效期": { - "type": "string", - "description": "生效时间区间或永久生效" - } - } - } - } - } - } + "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3和a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的有效期都改成永久生效" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效\n2. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ - { - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "永久生效" - }, + "operationType": "修改授权策略", + "policyList": [ { - "业务场景": "医疗专线", - "处置类型": "业务恢复", - "操作名称": "频段调整", - "有效期": "2026-06-01~2030-06-18" + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "永久生效" }, { - "业务场景": "政企专网", - "处置类型": "业务优化", - "操作名称": "路由调整", - "有效期": "2027-03-15~2029-09-30" + "policyId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "永久生效" } ] } @@ -193,7 +171,7 @@ } }, { - "label": "c3-nl-mod-06", + "label": "c3-nl-mod-06-varfields", "entry": "from_text", "input": { "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3和a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的有效期都改成永久生效" @@ -206,42 +184,75 @@ "outcome": "success", "slot_errors": [], "params": { - "操作类型": "修改授权策略", - "策略列表": [ + "operationType": "修改授权策略", + "policyList": [ { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "有效期": "永久生效" + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "newValidityPeriod": "永久生效" }, { - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "有效期": "永久生效" + "policyId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "newValidityPeriod": "永久生效" } ] } } + }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "newValidityPeriod": { + "type": "string", + "description": "修改后的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } + } + } + } + } } }, { - "label": "c4-nl-del-05", + "label": "c4-nl-del-01", "entry": "from_text", "input": { - "text": "删除7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3和f9e8d7c6-b5a4-3210-fedc-ba9876543210这两个授权策略" + "text": "删除7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权策略" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n2. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "删除授权策略", - "策略列表": [ - { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3" - }, + "operationType": "删除授权策略", + "policyList": [ { - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": null } ] } @@ -249,45 +260,47 @@ } }, { - "label": "b4-nl-bad-id-01", + "label": "c5-nl-query-01", "entry": "from_text", "input": { - "text": "删除ABC-123这个授权策略" + "text": "列出所有授权策略" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是ABC-123\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "删除授权策略" + "operationType": "查询授权策略", + "policyList": [] } } } }, { - "label": "b6-schema-variant-01", + "label": "b4-nl-bad-id-01", "entry": "from_text", "input": { - "text": "新增一个授权,业务场景校园专网,处置类型网络出了问题要赶紧恢复,操作名称频段调整,有效期2026-01-01~2030-12-31" + "text": "删除ABC-123这个授权策略" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是网络出了问题要赶紧恢复,操作名称是频段调整,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是ABC-123\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "success", "slot_errors": [], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "删除授权策略", + "policyList": [ { - "业务场景": "校园专网", - "处置类型": "网络出了问题要赶紧恢复", - "操作名称": "频段调整", - "有效期": "2026-01-01~2030-12-31" + "policyId": "ABC-123", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": null } ] } @@ -295,35 +308,80 @@ } }, { - "label": "b6-schema-variant-03", + "label": "b3-nl-invalid-mod-01", "entry": "from_text", "input": { - "text": "新增一个授权,业务场景政企专网,处置类型业务抢通,操作名称路由调整,有效期2026-01-01~2030-12-31" + "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权的业务场景改成校园专网" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是政企专网,处置类型是业务抢通,操作名称是路由调整,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "success", - "slot_errors": [], + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "invalid_value" + } + ], "params": { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "修改授权策略", + "policyList": [ { - "业务场景": "政企专网", - "处置类型": "业务抢通", - "操作名称": "路由调整", - "有效期": "2026-01-01~2030-12-31" + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": "校园专网" } ] } } + } + }, + { + "label": "b2-nl-format-01", + "entry": "from_text", + "input": { + "text": "新增授权,场景校园专网,处置业务抢通,操作隧道调优,有效期2026-13-01~2030-12-31" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "format_error" + } + ] + } + } + }, + { + "label": "b2-nl-format-01-varreq", + "entry": "from_text", + "input": { + "text": "新增一个授权,业务场景校园专网,处置类型网络出了问题要赶紧恢复,操作名称频段调整,有效期2026-01-01~2030-12-31" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是网络出了问题要赶紧恢复,操作名称是频段调整,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "invalid_value" + } + ] + } }, "validate_schema": { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -333,29 +391,29 @@ ], "description": "授权策略的操作类型" }, - "策略列表": { + "policyList": { "type": "array", - "description": "动网操作的授权策略列表,按输入中的出现顺序排列。参数值中每条条目以编号加'.'开头(如'1.'),条目内各字段以'字段名是值'形式表达并用逗号分隔,多个条目各自独立成行(换行分隔)。该参数未提供时输出空数组 []。各操作类型对每条的字段要求不同:新增条目须包含业务场景、处置类型、操作名称、有效期;删除条目可为策略标识,也可仅为条件字段(业务场景、处置类型、操作名称之一或多个),无需其他字段,不得按新增的必填字段要求检查删除条目。修改授权策略时,每条须包含策略标识与新有效期,缺少新有效期判 missing_required。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),带标签时按标签归因字段。", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", "items": { "type": "object", "properties": { - "策略标识": { + "policyId": { "type": "string", - "description": "策略的UUID唯一标识。UUID 形态的值归入本字段;非 UUID 的裸值按其内容语义归入业务场景/处置类型/操作名称字段。新增类策略无标识时为null;删除场景可按条件字段方式描述(此时策略标识为null)。内容校验阶段不依据标识格式判定存在性" + "description": "策略的唯一标识" }, - "业务场景": { + "scene": { "type": "string", "description": "动网操作支撑的业务场景" }, - "处置类型": { + "actionType": { "type": "string", "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 invalid_value" }, - "操作名称": { + "operationName": { "type": "string", "description": "所授权的动网操作的具体名称" }, - "有效期": { + "validityPeriod": { "type": "string", "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" } @@ -379,7 +437,7 @@ "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", + "slot_name": "policyList", "code": "missing_required" } ] @@ -387,20 +445,20 @@ } }, { - "label": "b2-nl-format-01", + "label": "c6-nl-mixed-07", "entry": "from_text", "input": { - "text": "新增授权,场景校园专网,处置业务抢通,操作隧道调优,有效期2026-13-01~2030-12-31" + "text": "新增三条授权:第一条业务场景校园专网,处置类型业务抢通,操作名称隧道调优,有效期永久生效;第二条业务场景医疗专线,处置类型业务恢复,操作名称频段调整,有效期2026-13-01~2030-12-31;第三条业务场景政企专网,处置类型业务优化,操作名称路由调整,有效期2027-03-15~2029-09-30" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "策略列表", + "slot_name": "policyList", "code": "format_error" } ] @@ -408,119 +466,37 @@ } }, { - "label": "b3-nl-invalid-mod-01", - "entry": "from_text", + "label": "a-data-starve-01", + "entry": "from_data_with_schema", "input": { - "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权的业务场景改成校园专网" - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "data": { + "scene": "校园专网", + "actionType": "业务抢通" }, - "server": { - "outcome": "validation_semantic_rejected", - "slot_errors": [ - { - "slot_name": "策略列表", - "code": "invalid_value" + "schema": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" } - ], - "params": { - "操作类型": "修改授权策略", - "策略列表": [ - { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "业务场景": "校园专网" - } - ] } } - } - }, - { - "label": "c6-nl-mixed-07", - "entry": "from_text", - "input": { - "text": "新增三条授权:第一条业务场景校园专网,处置类型业务抢通,操作名称隧道调优,有效期永久生效;第二条业务场景医疗专线,处置类型业务恢复,操作名称频段调整,有效期2026-13-01~2030-12-31;第三条业务场景政企专网,处置类型业务优化,操作名称路由调整,有效期2027-03-15~2029-09-30" - }, - "expected": { - "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "validation_semantic_rejected", - "slot_errors": [ - { - "slot_name": "策略列表", - "code": "format_error" - } - ] - } - } - }, - { - "label": "b6-schema-variant-02", - "entry": "from_text", - "input": { - "text": "新增一个授权,业务场景校园专网,处置类型网络出了问题要赶紧恢复,操作名称频段调整,有效期2026-01-01~2030-12-31" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是网络出了问题要赶紧恢复,操作名称是频段调整,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" - }, - "server": { - "outcome": "validation_semantic_rejected", + "outcome": "slot_validation_error", "slot_errors": [ { - "slot_name": "策略列表", - "code": "invalid_value" + "slot_name": "授权策略的操作类型", + "code": "missing_required" } ] } - }, - "validate_schema": { - "type": "object", - "properties": { - "操作类型": { - "type": "string", - "enum": [ - "新增授权策略", - "修改授权策略", - "删除授权策略", - "查询授权策略" - ], - "description": "授权策略的操作类型" - }, - "策略列表": { - "type": "array", - "description": "动网操作的授权策略列表,按输入中的出现顺序排列。参数值中每条条目以编号加'.'开头(如'1.'),条目内各字段以'字段名是值'形式表达并用逗号分隔,多个条目各自独立成行(换行分隔)。该参数未提供时输出空数组 []。各操作类型对每条的字段要求不同:新增条目须包含业务场景、处置类型、操作名称、有效期;删除条目可为策略标识,也可仅为条件字段(业务场景、处置类型、操作名称之一或多个),无需其他字段,不得按新增的必填字段要求检查删除条目。修改授权策略时,每条须包含策略标识与新有效期,缺少新有效期判 missing_required。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),带标签时按标签归因字段。", - "items": { - "type": "object", - "properties": { - "策略标识": { - "type": "string", - "description": "策略的UUID唯一标识。UUID 形态的值归入本字段;非 UUID 的裸值按其内容语义归入业务场景/处置类型/操作名称字段。新增类策略无标识时为null;删除场景可按条件字段方式描述(此时策略标识为null)。内容校验阶段不依据标识格式判定存在性" - }, - "业务场景": { - "type": "string", - "description": "动网操作支撑的业务场景" - }, - "处置类型": { - "type": "string", - "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 invalid_value" - }, - "操作名称": { - "type": "string", - "description": "所授权的动网操作的具体名称" - }, - "有效期": { - "type": "string", - "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" - } - } - } - } - } } }, { diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/smoke-io-transcript.md b/a2a-t-sample/src/main/resources/sample/authz-policy/smoke-io-transcript.md index d91c9d42..c283184b 100644 --- a/a2a-t-sample/src/main/resources/sample/authz-policy/smoke-io-transcript.md +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/smoke-io-transcript.md @@ -1,12 +1,8 @@ # Authorization-T Demo 冒烟测试原始输入输出记录 -- 位置:与 authz demo 运行说明(README.md)同目录,作为冒烟题集最近一次真实 LLM 运行的原始输入输出留档。 -- 题集:`scenarios.json`(冒烟 15 例,顺序:预期成功在前 → 预期拒绝在中 → 客户端拦截在末尾;分支 feat/auth-demo-extend) -- LLM:蓝区统一网关 deepseek-v4-pro(key 不落档) -- 参数:`-Dauthz.reasoning=true`(生成提参与校验的 LLM 理由均采集)、workers=8 -- 结果:**13/15 match**(2 例 mismatch 为 slot_name 归因粒度波动,行为判定均正确,见各例注记),报告 `eval-results/authz-demo/authz-report-20260827-104038.json` - -每例分两段:**客户端**(原始输入 → 生成结果 → 提参成功或报错理由)与**服务端**(validateAndDataFilling 的 schema 输入 → 校验与提参结果 → 理由)。服务端段不重复渲染后的 prompt(客户端段已给出)。 +- 位置:`eval-results\authz-demo\authz-report-20260828-110442.json`;题集:`sample/authz-policy/scenarios.json`;LLM:蓝区统一网关(key 不落档);参数:`-Dauthz.reasoning=true`;结果:**15/15 match** +- 分区:预期成功在前(index 0-7)、预期拒绝在中(index 8-12)、客户端拦截在末尾(index 13-14) +- 每例两段:客户端(输入→生成结果→理由)与服务端(schema 输入→校验提参结果→理由);服务端段不重复渲染后 prompt --- @@ -36,46 +32,96 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:操作类型:输入中含“新增”,按封闭取值范围规则映射为“新增授权策略”。策略列表:新增授权策略下提取业务场景、处置类型、操作名称、有效期四个字段,并按编号格式组织;日期“2026-01-01到2030-12-31”按约束归一为“2026-01-01~2030-12-31”。未出现缺失或非法值。 +**提参理由(LLM reasoning)**:输入中包含“新增一个授权策略”,对应封闭取值范围内的“新增授权策略”。动网操作的授权策略列表按新增形态组织为编号条目,包含业务场景、处置类型、操作名称、有效期四个字段,并以全角逗号分隔;日期“2026-01-01到2030-12-31”按约束归一为“2026-01-01~2030-12-31”。所有必填内容均已提供,无错误。 ### 服务端(校验 + 提参) -**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**:与 param-schema.json 相同(`{type: object, properties: {操作类型: enum[新增/修改/删除/查询授权策略], 策略列表: array[策略标识/业务场景/处置类型/操作名称/有效期]}, …}`),全文见 `param-schema.json`。 +**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**: + +```json +{ + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } +} +``` (输入 prompt 即上方客户端渲染结果,此处不重复。) **校验与提参结果**:`success` -**校验理由(LLM reasoning)**:操作类型为“新增授权策略”,在enum允许范围内。策略列表提供1条记录,包含新增操作必填的业务场景、处置类型、操作名称、有效期,且四项信息完整;有效期是完整日期区间且起始日期早于结束日期。新增授权策略不强制要求策略标识,故提取时置为null。整体无语义冲突或格式错误,semantic_verdict为true。 - **提参结果**: ```json { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ { - "策略标识": null, - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "2026-01-01~2030-12-31" + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" } ] } ``` +**校验理由(LLM reasoning)**:操作类型为已提供的枚举值“新增授权策略”。动网操作的授权策略列表存在1条非空条目,新增授权策略所需的业务场景、处置类型、操作名称、有效期均已提供,未提供policyId不构成缺失。有效期区间完整且起止日期顺序有效,未发现格式或语义冲突,因此整体校验通过,errors为空。 + +**键名泄漏扫描**:无泄漏 + **判定**:match=True(client_prompt=True) --- -## c1-nl-add-12(泛化席:资源外新词 + 弱锚点) — ✅ match +## c1-nl-add-01-varname — ✅ match +[【差分对】配对基线:c1-nl-add-01] ### 客户端(生成) **原始输入**(from_text) -> 为物联网专线新增一个倒换类型的授权,操作是流量重定向,有效期从2026-08-01到2030-12-31 +> 帮我新增一个授权策略,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31 **生成结果:成功(渲染后 prompt)** @@ -87,7 +133,7 @@ 请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 ## 动网操作的授权策略列表 -1. 业务场景是物联网专线,处置类型是倒换,操作名称是流量重定向,有效期是2026-08-01~2030-12-31 +1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-01-01~2030-12-31 ## 预期输出 1. 授权操作执行结果,取值范围: 成功、失败、部分成功; @@ -95,35 +141,84 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中的“新增”属于值约束认可的合法取值简写,直接映射为“新增授权策略”。策略列表按新增形四字段提取:物联网专线对应业务场景,倒换对应处置类型,流量重定向对应操作名称,有效期从2026-08-01到2030-12-31归一为2026-08-01~2030-12-31。未发现违反封闭取值或结构约束的情况。 +**提参理由(LLM reasoning)**:输入中“新增”是封闭取值范围中“新增授权策略”的合法简写,按规则13a映射。操作类型为新增,策略列表为必填,输入提供了业务场景、处置类型、操作名称、有效期四个字段,按约束组织为编号条目;有效期从“从2026-01-01到2030-12-31”归一为区间格式“2026-01-01~2030-12-31”。 ### 服务端(校验 + 提参) -**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**:与 param-schema.json 相同(`{type: object, properties: {操作类型: enum[新增/修改/删除/查询授权策略], 策略列表: array[策略标识/业务场景/处置类型/操作名称/有效期]}, …}`),全文见 `param-schema.json`。 +**validateAndDataFilling 输入 schema(本用例变异 schema)**: + +```json +{ + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policies": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "策略的唯一标识" + }, + "domain": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "effectivePeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } +} +``` (输入 prompt 即上方客户端渲染结果,此处不重复。) **校验与提参结果**:`success` -**校验理由(LLM reasoning)**:操作类型为新增授权策略,在枚举范围内。策略列表包含一条记录,已覆盖新增必填字段:业务场景、处置类型、操作名称、有效期。有效期格式为完整区间且起始日期早于结束日期,未发现格式或取值违规。 - **提参结果**: ```json { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policies": [ { - "策略标识": null, - "业务场景": "物联网专线", - "处置类型": "倒换", - "操作名称": "流量重定向", - "有效期": "2026-08-01~2030-12-31" + "pid": null, + "domain": "校园专网", + "handling": "业务抢通", + "opName": "隧道调优", + "effectivePeriod": "2026-01-01~2030-12-31" } ] } ``` +**校验理由(LLM reasoning)**:操作类型为“新增授权策略”,属于合法取值。新增场景下必填字段为业务场景、处置类型、操作名称、有效期;策略列表第1条已完整提供这四项,且有效期起止日期完整、无明显格式或区间倒置问题。策略标识未见提供,但新增场景未强制要求,按schema输出为null。因此校验通过。 + +**键名泄漏扫描**:无泄漏 + **判定**:match=True(client_prompt=True) --- @@ -155,68 +250,115 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中的“新增”命中授权策略操作类型封闭取值范围的简写,映射为“新增授权策略”;策略列表提取两条新增条目,按业务场景、处置类型、操作名称、有效期的顺序组织,日期归一为YYYY-MM-DD~YYYY-MM-DD区间格式,条目编号连续并以换行分隔。 +**提参理由(LLM reasoning)**:输入中“新增两条授权策略”中的“新增”为合法取值范围中“新增授权策略”的简写,按规则13a映射为“新增授权策略”。“动网操作的授权策略列表”在新增操作下为必填,输入明确给出两条策略,分别提取业务场景、处置类型、操作名称、有效期,并按规则18将自然语言日期“从2026-01-01到2030-12-31”归一为“2026-01-01~2030-12-31”,第二条同理归一为“2026-06-01~2030-06-18”;按条目编号和全角逗号分隔组织为列表文本。未发现缺失或非法值,故无错误。 ### 服务端(校验 + 提参) -**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**:与 param-schema.json 相同(`{type: object, properties: {操作类型: enum[新增/修改/删除/查询授权策略], 策略列表: array[策略标识/业务场景/处置类型/操作名称/有效期]}, …}`),全文见 `param-schema.json`。 +**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**: + +```json +{ + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } +} +``` (输入 prompt 即上方客户端渲染结果,此处不重复。) **校验与提参结果**:`success` -**校验理由(LLM reasoning)**:操作类型为“新增授权策略”,在枚举取值范围内。动网操作的授权策略列表有两条条目,均为新增所需字段:业务场景、处置类型、操作名称、有效期齐全;有效期格式为完整区间且起始日期早于结束日期。提取结果与输入一致,未发现语义冲突或格式错误。 - **提参结果**: ```json { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "新增授权策略", + "policyList": [ { - "策略标识": null, - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "2026-01-01~2030-12-31" + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" }, { - "策略标识": null, - "业务场景": "医疗专线", - "处置类型": "业务恢复", - "操作名称": "频段调整", - "有效期": "2026-06-01~2030-06-18" + "policyId": null, + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" } ] } ``` +**校验理由(LLM reasoning)**:操作类型为“新增授权策略”,符合 schema 枚举;授权策略列表已提供两条,新增场景下必填的业务场景、处置类型、操作名称、有效期均已提供;有效期起止日期有效且起止顺序未倒置。策略标识在新增场景中未提供,不构成缺失。 + +**键名泄漏扫描**:无泄漏 + **判定**:match=True(client_prompt=True) --- -## c2-data-multi-07 — ✅ match +## c3-nl-mod-06 — ✅ match ### 客户端(生成) -**原始输入**(from_data_with_schema) +**原始输入**(from_text) -- data:`{"操作类型": "新增授权策略", "策略数量": 3, "详情": [{"业务场景": "校园专网", "处置类型": "业务抢通", "操作名称": "隧道调优", "有效期": "永久生效"}, {"业务场景": "医疗专线", "处置类型": "业务恢复", "操作名称": "频段调整", "有效期": "2026-06-01~2030-06-18"}, {"业务场景": "政企专网", "处置类型": "业务优化", "操作名称": "路由调整", "有效期": "2027-03-15~2029-09-30"}]}` -- schema:`{"type": "object", "properties": {"操作类型": {"type": "string", "enum": ["新增授权策略", "修改授权策略", "删除授权策略", "查询授权策略"]}, "策略数量": {"type": "integer", "description": "要新增的策略数量"}, "详情": {"type": "array", "description": "策略详情列表", "items": {"type": "object", "properties": {"业务场景": {"type": "string", "description": "动网操作支撑的业务场景"}, "处置类型": {"type": "string", "description": "动网操作的处置类型"}, "操作名称": {"type": "string", "description": "所授权的动网操作的具体名称"}, "有效期": {"type": "string", "description": "生效时间区间或永久生效"}}}}}}` +> 把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3和a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的有效期都改成永久生效 **生成结果:成功(渲染后 prompt)** ``` ## 授权策略的操作类型 -新增授权策略 +修改授权策略 ## 授权策略的操作描述 请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 ## 动网操作的授权策略列表 -1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效 -2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18 -3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30 +1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效 +2. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是永久生效 ## 预期输出 1. 授权操作执行结果,取值范围: 成功、失败、部分成功; @@ -224,11 +366,58 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:操作类型输入中明确给出“新增授权策略”,属于封闭取值范围中的合法值,直接提取。动网操作的授权策略列表在操作类型为新增授权策略时必填;输入详情数组有3条,逐条按新增形要求转换为“编号. 业务场景是X,处置类型是Y,操作名称是Z,有效期是V”,并以换行分隔。有效期均为“永久生效”或符合YYYY-MM-DD~YYYY-MM-DD的完整区间,无需日期归一。未发现缺失或非法值。 +**提参理由(LLM reasoning)**:输入中的“改成”命中修改授权策略的同义对清单,因此“授权策略的操作类型”提取为“修改授权策略”。输入中明确给出两个策略标识,并要求将二者的有效期改为永久生效,属于正向修改约束,因此按修改形态分别形成编号条目:“1. 策略标识是...,有效期是永久生效”和“2. 策略标识是...,有效期是永久生效”,以换行分隔。 ### 服务端(校验 + 提参) -**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**:与 param-schema.json 相同(`{type: object, properties: {操作类型: enum[新增/修改/删除/查询授权策略], 策略列表: array[策略标识/业务场景/处置类型/操作名称/有效期]}, …}`),全文见 `param-schema.json`。 +**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**: + +```json +{ + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } +} +``` (输入 prompt 即上方客户端渲染结果,此处不重复。) @@ -238,38 +427,36 @@ ```json { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "修改授权策略", + "policyList": [ { - "策略标识": null, - "业务场景": "校园专网", - "处置类型": "业务抢通", - "操作名称": "隧道调优", - "有效期": "永久生效" + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "永久生效" }, { - "策略标识": null, - "业务场景": "医疗专线", - "处置类型": "业务恢复", - "操作名称": "频段调整", - "有效期": "2026-06-01~2030-06-18" - }, - { - "策略标识": null, - "业务场景": "政企专网", - "处置类型": "业务优化", - "操作名称": "路由调整", - "有效期": "2027-03-15~2029-09-30" + "policyId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "永久生效" } ] } ``` +**校验理由(LLM reasoning)**:(未采集) + +**键名泄漏扫描**:无泄漏 + **判定**:match=True(client_prompt=True) --- -## c3-nl-mod-06 — ✅ match +## c3-nl-mod-06-varfields — ✅ match +[【差分对】配对基线:c3-nl-mod-06] ### 客户端(生成) @@ -296,53 +483,84 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中的“改成”属于修改类同义动词,映射为“修改授权策略”。动网操作的授权策略列表提取到两个策略标识,均要求将有效期修改为永久生效,因此按修改形态组织为两条连续编号的条目。 +**提参理由(LLM reasoning)**:输入包含“改成”,属于操作类型同义对清单中的“修改授权策略”映射;两个 UUID 均为策略标识,且“都改成永久生效”表示对两者修改有效期,因此按修改授权策略的条目格式生成两条编号条目。无缺失或非法值。 ### 服务端(校验 + 提参) -**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**:与 param-schema.json 相同(`{type: object, properties: {操作类型: enum[新增/修改/删除/查询授权策略], 策略列表: array[策略标识/业务场景/处置类型/操作名称/有效期]}, …}`),全文见 `param-schema.json`。 +**validateAndDataFilling 输入 schema(本用例变异 schema)**: + +```json +{ + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "newValidityPeriod": { + "type": "string", + "description": "修改后的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } + } + } + } + } +} +``` (输入 prompt 即上方客户端渲染结果,此处不重复。) **校验与提参结果**:`success` -**校验理由(LLM reasoning)**:操作类型为修改授权策略,属于枚举允许值。动网操作的授权策略列表包含两条修改条目,每条均提供策略标识和有效期,有效期取值为永久生效,满足修改授权策略对策略标识与新有效期的必填要求;业务场景、处置类型、操作名称在修改场景下非必填,故置为null。整体语义一致,格式合规,校验通过。 - **提参结果**: ```json { - "操作类型": "修改授权策略", - "策略列表": [ + "operationType": "修改授权策略", + "policyList": [ { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "业务场景": null, - "处置类型": null, - "操作名称": null, - "有效期": "永久生效" + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "newValidityPeriod": "永久生效" }, { - "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "业务场景": null, - "处置类型": null, - "操作名称": null, - "有效期": "永久生效" + "policyId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "newValidityPeriod": "永久生效" } ] } ``` +**校验理由(LLM reasoning)**:操作类型为“修改授权策略”,在枚举范围内;模板正文规定修改操作仅支持修改有效期,并需策略标识定位策略。输入中两个条目均提供策略标识和有效期(永久生效),无格式错误、无冲突,其他不可修改字段未提供不构成缺失。按参数模式提取 operationType 与 policyList 中的 policyId、newValidityPeriod。 + +**键名泄漏扫描**:无泄漏 + **判定**:match=True(client_prompt=True) --- -## c4-nl-del-05 — ✅ match +## c4-nl-del-01 — ✅ match ### 客户端(生成) **原始输入**(from_text) -> 删除7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3和f9e8d7c6-b5a4-3210-fedc-ba9876543210这两个授权策略 +> 删除7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权策略 **生成结果:成功(渲染后 prompt)** @@ -355,7 +573,6 @@ ## 动网操作的授权策略列表 1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3 -2. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210 ## 预期输出 1. 授权操作执行结果,取值范围: 成功、失败、部分成功; @@ -363,11 +580,58 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:操作类型:输入中包含'删除',按封闭取值范围的简写映射规则直接映射为'删除授权策略'。策略列表:删除授权策略时需提供策略标识,输入中给出两个UUID,按删除形条目格式组织为两个编号条目。 +**提参理由(LLM reasoning)**:输入中“删除”是“删除授权策略”的简写,依据封闭取值范围规则13a直接映射为合法取值。动网操作的授权策略列表在删除操作下必填,删除形要求按策略标识删除;输入中的UUID“7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3”即为策略标识,按“1. 策略标识是X”格式提取为条目,因此无缺失或非法值错误。 ### 服务端(校验 + 提参) -**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**:与 param-schema.json 相同(`{type: object, properties: {操作类型: enum[新增/修改/删除/查询授权策略], 策略列表: array[策略标识/业务场景/处置类型/操作名称/有效期]}, …}`),全文见 `param-schema.json`。 +**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**: + +```json +{ + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } +} +``` (输入 prompt 即上方客户端渲染结果,此处不重复。) @@ -377,49 +641,45 @@ ```json { - "操作类型": "删除授权策略", - "策略列表": [ - { - "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - "业务场景": null, - "处置类型": null, - "操作名称": null, - "有效期": null - }, + "operationType": "删除授权策略", + "policyList": [ { - "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", - "业务场景": null, - "处置类型": null, - "操作名称": null, - "有效期": null + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": null } ] } ``` +**校验理由(LLM reasoning)**:(未采集) + +**键名泄漏扫描**:无泄漏 + **判定**:match=True(client_prompt=True) --- -## b4-nl-bad-id-01 — ✅ match +## c5-nl-query-01 — ✅ match ### 客户端(生成) **原始输入**(from_text) -> 删除ABC-123这个授权策略 +> 列出所有授权策略 **生成结果:成功(渲染后 prompt)** ``` ## 授权策略的操作类型 -删除授权策略 +查询授权策略 ## 授权策略的操作描述 请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 ## 动网操作的授权策略列表 -1. 策略标识是ABC-123 ## 预期输出 1. 授权操作执行结果,取值范围: 成功、失败、部分成功; @@ -427,69 +687,59 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中的“删除”是合法操作类型简写,按值约束映射为“删除授权策略”。删除操作下,策略列表需提供策略标识,输入中“ABC-123”为被删除的授权策略标识,因此提取为“1. 策略标识是ABC-123”。 +**提参理由(LLM reasoning)**:输入中的“列出”属于查询授权策略的认可同义表达,因此操作类型映射为“查询授权策略”;当前为查询操作,策略列表选填,且输入未提供带字段标签的查询条件,因此列表值为 null,无错误。 ### 服务端(校验 + 提参) -**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**:与 param-schema.json 相同(`{type: object, properties: {操作类型: enum[新增/修改/删除/查询授权策略], 策略列表: array[策略标识/业务场景/处置类型/操作名称/有效期]}, …}`),全文见 `param-schema.json`。 - -(输入 prompt 即上方客户端渲染结果,此处不重复。) - -**校验与提参结果**:`success` - -**提参结果**: +**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**: ```json { - "操作类型": "删除授权策略", - "策略列表": [ - { - "策略标识": "ABC-123", - "业务场景": null, - "处置类型": null, - "操作名称": null, - "有效期": null + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } } - ] + } } ``` -**判定**:match=True(client_prompt=True) - ---- - -## b6-schema-variant-01(validate_schema 缺省 = suite 默认) — ✅ match - -### 客户端(生成) - -**原始输入**(from_text) - -> 新增一个授权,业务场景校园专网,处置类型网络出了问题要赶紧恢复,操作名称频段调整,有效期2026-01-01~2030-12-31 - -**生成结果:成功(渲染后 prompt)** - -``` -## 授权策略的操作类型 -新增授权策略 - -## 授权策略的操作描述 -请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 - -## 动网操作的授权策略列表 -1. 业务场景是校园专网,处置类型是网络出了问题要赶紧恢复,操作名称是频段调整,有效期是2026-01-01~2030-12-31 - -## 预期输出 -1. 授权操作执行结果,取值范围: 成功、失败、部分成功; -2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; -3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; -``` - -**提参理由(LLM reasoning)**:输入中的“新增”属于值约束认可的简写,映射为“新增授权策略”。策略列表按新增形要求组织为编号加“字段名是值”的格式,有效期已符合 YYYY-MM-DD~YYYY-MM-DD 区间格式,因此直接保留。 - -### 服务端(校验 + 提参) - -**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**:与 param-schema.json 相同(`{type: object, properties: {操作类型: enum[新增/修改/删除/查询授权策略], 策略列表: array[策略标识/业务场景/处置类型/操作名称/有效期]}, …}`),全文见 `param-schema.json`。 - (输入 prompt 即上方客户端渲染结果,此处不重复。) **校验与提参结果**:`success` @@ -498,42 +748,38 @@ ```json { - "操作类型": "新增授权策略", - "策略列表": [ - { - "策略标识": null, - "业务场景": "校园专网", - "处置类型": "网络出了问题要赶紧恢复", - "操作名称": "频段调整", - "有效期": "2026-01-01~2030-12-31" - } - ] + "operationType": "查询授权策略", + "policyList": [] } ``` +**校验理由(LLM reasoning)**:操作类型章节参数值行为“查询授权策略”,属于参数schema枚举范围;动网操作的授权策略列表章节标题下无参数值行,且该列表在查询操作下为选填,因此不构成缺失。params按参数schema提取operationType与policyList,policyList为空列表。 + +**键名泄漏扫描**:无泄漏 + **判定**:match=True(client_prompt=True) --- -## b6-schema-variant-03(validate_schema = 同 02 变异 schema;正常短语输入) — ✅ match +## b4-nl-bad-id-01 — ✅ match ### 客户端(生成) **原始输入**(from_text) -> 新增一个授权,业务场景政企专网,处置类型业务抢通,操作名称路由调整,有效期2026-01-01~2030-12-31 +> 删除ABC-123这个授权策略 **生成结果:成功(渲染后 prompt)** ``` ## 授权策略的操作类型 -新增授权策略 +删除授权策略 ## 授权策略的操作描述 请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 ## 动网操作的授权策略列表 -1. 业务场景是政企专网,处置类型是业务抢通,操作名称是路由调整,有效期是2026-01-01~2030-12-31 +1. 策略标识是ABC-123 ## 预期输出 1. 授权操作执行结果,取值范围: 成功、失败、部分成功; @@ -541,17 +787,17 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:操作类型:输入含“新增一个授权”,其中“新增”是合法取值“新增授权策略”的简写,且处于写入任务指令语境,故按规则13a映射为“新增授权策略”。策略列表:操作类型为新增,列表必填;输入提供四个字段,按新增形约束转换为“字段名是值”格式并编号为1,日期已是YYYY-MM-DD~YYYY-MM-DD格式保持不变。无错误。 +**提参理由(LLM reasoning)**:输入包含“删除”,按封闭取值范围规则13a映射为“删除授权策略”。删除操作下策略列表为必填,输入给出策略对象“ABC-123”,按删除形格式整理为“1. 策略标识是ABC-123”。其余字段无需在删除形中补充,因此无错误。 ### 服务端(校验 + 提参) -**validateAndDataFilling 输入 schema(本用例变异 schema)**: +**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**: ```json { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -561,31 +807,31 @@ ], "description": "授权策略的操作类型" }, - "策略列表": { + "policyList": { "type": "array", - "description": "动网操作的授权策略列表,按输入中的出现顺序排列。参数值中每条条目以编号加'.'开头(如'1.'),条目内各字段以'字段名是值'形式表达并用逗号分隔,多个条目各自独立成行(换行分隔)。该参数未提供时输出空数组 []。各操作类型对每条的字段要求不同:新增条目须包含业务场景、处置类型、操作名称、有效期;删除条目可为策略标识,也可仅为条件字段(业务场景、处置类型、操作名称之一或多个),无需其他字段,不得按新增的必填字段要求检查删除条目。修改授权策略时,每条须包含策略标识与新有效期,缺少新有效期判 missing_required。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),带标签时按标签归因字段。", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", "items": { "type": "object", "properties": { - "策略标识": { + "policyId": { "type": "string", - "description": "策略的UUID唯一标识。UUID 形态的值归入本字段;非 UUID 的裸值按其内容语义归入业务场景/处置类型/操作名称字段。新增类策略无标识时为null;删除场景可按条件字段方式描述(此时策略标识为null)。内容校验阶段不依据标识格式判定存在性" + "description": "策略的唯一标识" }, - "业务场景": { + "scene": { "type": "string", "description": "动网操作支撑的业务场景" }, - "处置类型": { + "actionType": { "type": "string", - "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 invalid_value" + "description": "动网操作的处置类型" }, - "操作名称": { + "operationName": { "type": "string", "description": "所授权的动网操作的具体名称" }, - "有效期": { + "validityPeriod": { "type": "string", - "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + "description": "授权策略的生效时间区间" } } } @@ -602,42 +848,46 @@ ```json { - "操作类型": "新增授权策略", - "策略列表": [ + "operationType": "删除授权策略", + "policyList": [ { - "策略标识": null, - "业务场景": "政企专网", - "处置类型": "业务抢通", - "操作名称": "路由调整", - "有效期": "2026-01-01~2030-12-31" + "policyId": "ABC-123", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": null } ] } ``` +**校验理由(LLM reasoning)**:操作类型为“删除授权策略”,在枚举范围内;删除操作仅要求提供授权策略标识,动网操作的授权策略列表已提供一条标识“ABC-123”,格式与语义均符合要求;按参数 schema 提取参数,未提供的其他策略字段置为 null。 + +**键名泄漏扫描**:无泄漏 + **判定**:match=True(client_prompt=True) --- -## b1-nl-missing-01 — ✅ match +## b3-nl-invalid-mod-01 — ✅ match ### 客户端(生成) **原始输入**(from_text) -> 加个校园专网的授权,处置用业务抢通,做个隧道调优,有效期先不填后面补 +> 把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权的业务场景改成校园专网 **生成结果:成功(渲染后 prompt)** ``` ## 授权策略的操作类型 -新增授权策略 +修改授权策略 ## 授权策略的操作描述 请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 ## 动网操作的授权策略列表 -1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优 +1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网 ## 预期输出 1. 授权操作执行结果,取值范围: 成功、失败、部分成功; @@ -645,24 +895,87 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:“加个”是“新增授权策略”的同义表达,操作类型映射为“新增授权策略”。策略列表提取输入中明确给出的字段:业务场景为校园专网,处置类型为业务抢通,操作名称为隧道调优;“有效期先不填后面补”属于用户对有效期的缺省声明,按规则2不作为字段值提取。 +**提参理由(LLM reasoning)**:操作类型:输入中的“改成”属于修改授权策略的同义表达,按约束映射为“修改授权策略”。动网操作的授权策略列表:用户明确指定策略标识 UUID,并要求将业务场景改为“校园专网”,属于修改类描述,按规则合并标识与修改描述,组织为单条编号条目。 ### 服务端(校验 + 提参) -**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**:与 param-schema.json 相同(`{type: object, properties: {操作类型: enum[新增/修改/删除/查询授权策略], 策略列表: array[策略标识/业务场景/处置类型/操作名称/有效期]}, …}`),全文见 `param-schema.json`。 +**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**: + +```json +{ + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } +} +``` (输入 prompt 即上方客户端渲染结果,此处不重复。) **校验与提参结果**:`validation_semantic_rejected` -- 错误明细:`策略列表:missing_required`(操作类型为新增授权策略,新增条目必须包含业务场景、处置类型、操作名称、有效期;第1条缺少有效期。) -**校验理由(LLM reasoning)**:操作类型取自参数值行,为新增授权策略,属于合法枚举值。策略列表包含1条记录,按新增授权策略的必填字段清单(业务场景、处置类型、操作名称、有效期)逐项核对,该条目已提供业务场景、处置类型、操作名称,但缺少有效期,因此判定为missing_required。参数提取按schema结构填充,未提供的策略标识和有效期输出为null。 +```json +[ + { + "slot_name": "policyList", + "code": "invalid_value", + "message": "动网操作的授权策略列表第1条:修改授权策略时仅支持修改有效期,不支持修改业务场景;该条目表达将业务场景修改为“校园专网”的意图,违反约束。" + }, + { + "slot_name": "policyList", + "code": "missing_required", + "message": "动网操作的授权策略列表第1条:修改授权策略时缺少必填的新有效期信息。" + } +] +``` + +**校验理由(LLM reasoning)**:(未采集) + +**键名泄漏扫描**:无泄漏 **判定**:match=True(client_prompt=True) --- -## b2-nl-format-01 — ❌ MISMATCH +## b2-nl-format-01 — ✅ match ### 客户端(生成) @@ -688,44 +1001,101 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:操作类型:输入含“新增授权”,按约束中的简写映射为“新增授权策略”。策略列表:输入提供了新增策略所需的业务场景、处置类型、操作名称、有效期四项内容,按要求组织为编号条目;有效期“2026-13-01~2030-12-31”虽存在月份异常,但按规则16提取阶段不做合规预判,原样保留,由后续校验阶段处理。 +**提参理由(LLM reasoning)**:操作类型:输入含“新增”,属于封闭取值范围认可的简写,映射为“新增授权策略”。策略列表:操作类型为新增授权策略时该列表必填;输入中“场景校园专网”对应业务场景,“处置业务抢通”对应处置类型,“操作隧道调优”对应操作名称,“有效期2026-13-01~2030-12-31”对应有效期。按新增形格式组织为编号条目并保留原日期内容,月份异常值按规则16原样提取,不由提取阶段判定为invalid_value。 ### 服务端(校验 + 提参) -**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**:与 param-schema.json 相同(`{type: object, properties: {操作类型: enum[新增/修改/删除/查询授权策略], 策略列表: array[策略标识/业务场景/处置类型/操作名称/有效期]}, …}`),全文见 `param-schema.json`。 +**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**: + +```json +{ + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } +} +``` (输入 prompt 即上方客户端渲染结果,此处不重复。) **校验与提参结果**:`validation_semantic_rejected` -- 错误明细:`有效期:format_error`(第1条策略的有效期“2026-13-01~2030-12-31”中月份13无效,不是有效日期,格式错误。) -**校验理由(LLM reasoning)**:操作类型为“新增授权策略”,取值合法。动网操作的授权策略列表包含1条记录,新增策略所需的业务场景、处置类型、操作名称、有效期均已提供,但有效期起始日期包含无效月份13,属于格式错误,因此语义校验不通过。参数按参数值行提取。 +```json +[ + { + "slot_name": "policyList", + "code": "format_error", + "message": "策略列表第1条的有效期月份无效:2026-13-01 不是有效日期,月份应为1~12。" + } +] +``` + +**校验理由(LLM reasoning)**:(未采集) -> ❌ mismatch 说明:行为判定正确,但 slot_name 归因粒度与题集期望不同(字段级 vs 参数级),子集比对未命中——LLM 措辞波动,非行为回归。 +**键名泄漏扫描**:无泄漏 -**判定**:match=False(client_prompt=True) +**判定**:match=True(client_prompt=True) --- -## b3-nl-invalid-mod-01 — ✅ match +## b2-nl-format-01-varreq — ✅ match +[【差分对】配对基线:b2-nl-format-01] ### 客户端(生成) **原始输入**(from_text) -> 把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权的业务场景改成校园专网 +> 新增一个授权,业务场景校园专网,处置类型网络出了问题要赶紧恢复,操作名称频段调整,有效期2026-01-01~2030-12-31 **生成结果:成功(渲染后 prompt)** ``` ## 授权策略的操作类型 -修改授权策略 +新增授权策略 ## 授权策略的操作描述 请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 ## 动网操作的授权策略列表 -1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改为校园专网 +1. 业务场景是校园专网,处置类型是网络出了问题要赶紧恢复,操作名称是频段调整,有效期是2026-01-01~2030-12-31 ## 预期输出 1. 授权操作执行结果,取值范围: 成功、失败、部分成功; @@ -733,31 +1103,88 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中的“改成”属于修改授权策略的同义表达,因此“授权策略的操作类型”映射为“修改授权策略”。“动网操作的授权策略列表”提取到策略标识UUID和修改描述,按规则17连同作用对象和修改内容一并保留,组织为符合槽位格式的条目。 +**提参理由(LLM reasoning)**:输入“新增一个授权”中的“新增”命中操作类型合法简写,映射为“新增授权策略”。操作类型为新增时,授权策略列表为必填;输入依次给出业务场景、处置类型、操作名称、有效期,按新增形字段顺序整理为单条编号条目。日期已符合YYYY-MM-DD~YYYY-MM-DD格式,无需转换。 ### 服务端(校验 + 提参) -**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**:与 param-schema.json 相同(`{type: object, properties: {操作类型: enum[新增/修改/删除/查询授权策略], 策略列表: array[策略标识/业务场景/处置类型/操作名称/有效期]}, …}`),全文见 `param-schema.json`。 +**validateAndDataFilling 输入 schema(本用例变异 schema)**: + +```json +{ + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 invalid_value" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + } + } + } + } + } +} +``` (输入 prompt 即上方客户端渲染结果,此处不重复。) **校验与提参结果**:`validation_semantic_rejected` -- 错误明细:`策略列表:invalid_value`(修改授权策略仅支持修改有效期,不允许修改业务场景;第1条中“业务场景改为校园专网”违反了该约束。) -- 错误明细:`策略列表:missing_required`(修改授权策略的条目必须包含策略标识与新有效期;第1条缺少新有效期,未提供“有效期”字段。) -**校验理由(LLM reasoning)**:操作类型取值在枚举范围内,通过。策略列表第1条声明修改业务场景,但模板仅允许修改有效期,构成非格式类取值违规;同时该条目作为修改操作缺少必填的新有效期,构成缺失必填字段。因此整体 semantic_verdict 为 false。提取了策略标识和业务场景,其余字段无值置 null。 +```json +[ + { + "slot_name": "policyList", + "code": "invalid_value", + "message": "动网操作的授权策略列表第1条的处置类型应为简短处置动作短语(如业务抢通、业务优化、路由调整),实际为整句描述“网络出了问题要赶紧恢复”,不符合取值约束。" + } +] +``` + +**校验理由(LLM reasoning)**:(未采集) -**判定**:match=True(client_prompt=False) +**键名泄漏扫描**:无泄漏 + +**判定**:match=True(client_prompt=True) --- -## c6-nl-mixed-07 — ✅ match +## b1-nl-missing-01 — ✅ match ### 客户端(生成) **原始输入**(from_text) -> 新增三条授权:第一条业务场景校园专网,处置类型业务抢通,操作名称隧道调优,有效期永久生效;第二条业务场景医疗专线,处置类型业务恢复,操作名称频段调整,有效期2026-13-01~2030-12-31;第三条业务场景政企专网,处置类型业务优化,操作名称路由调整,有效期2027-03-15~2029-09-30 +> 加个校园专网的授权,处置用业务抢通,做个隧道调优,有效期先不填后面补 **生成结果:成功(渲染后 prompt)** @@ -769,9 +1196,7 @@ 请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 ## 动网操作的授权策略列表 -1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效 -2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-13-01~2030-12-31 -3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30 +1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优 ## 预期输出 1. 授权操作执行结果,取值范围: 成功、失败、部分成功; @@ -779,28 +1204,88 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中的“新增”为合法取值简写,映射为“新增授权策略”。三条授权分别按新增形要求组织为编号条目,字段顺序为业务场景、处置类型、操作名称、有效期。第二条有效期月份“13”属于格式异常内容,按规则原样保留,不在此阶段判定为 invalid_value。 +**提参理由(LLM reasoning)**:操作类型:输入中的“加个”属于认可同义对清单(加/加个→新增授权策略),因此提取为“新增授权策略”。策略列表:输入提供了业务场景(校园专网)、处置类型(业务抢通)、操作名称(隧道调优)三个正向约束,按约定格式组织为编号条目;有效期用户明确表示“先不填后面补”,属于缺省声明,不提取为该字段值,因此省略该字段及分隔符。未发现需要报告的 slot 错误。 ### 服务端(校验 + 提参) -**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**:与 param-schema.json 相同(`{type: object, properties: {操作类型: enum[新增/修改/删除/查询授权策略], 策略列表: array[策略标识/业务场景/处置类型/操作名称/有效期]}, …}`),全文见 `param-schema.json`。 +**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**: + +```json +{ + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" + } + } + } + } + } +} +``` (输入 prompt 即上方客户端渲染结果,此处不重复。) **校验与提参结果**:`validation_semantic_rejected` -- 错误明细:`策略列表:format_error`(第2条授权策略的有效期“2026-13-01~2030-12-31”包含无效月份“13”,应为有效的YYYY-MM-DD~YYYY-MM-DD完整区间或“永久生效”。) + +```json +[ + { + "slot_name": "policyList", + "code": "missing_required", + "message": "动网操作的授权策略列表第1条缺少有效期:新增授权策略要求每条提供动网操作的业务场景、动网操作的处置类型、动网操作名称、有效期。" + } +] +``` + +**校验理由(LLM reasoning)**:操作类型为新增授权策略,取值合法。动网操作的授权策略列表提供1条,已提供业务场景、处置类型、操作名称,但未提供有效期;新增授权策略下有效期属于必填字段,因此判定语义校验失败并报告missing_required。policyId在创建场景下非必填,不报错。参数提取按实际提供的参数值填充,缺失的policyId和validityPeriod输出null。 + +**键名泄漏扫描**:无泄漏 **判定**:match=True(client_prompt=True) --- -## b6-schema-variant-02(validate_schema = 变异 schema:处置类型加短语规则) — ❌ MISMATCH +## c6-nl-mixed-07 — ✅ match ### 客户端(生成) **原始输入**(from_text) -> 新增一个授权,业务场景校园专网,处置类型网络出了问题要赶紧恢复,操作名称频段调整,有效期2026-01-01~2030-12-31 +> 新增三条授权:第一条业务场景校园专网,处置类型业务抢通,操作名称隧道调优,有效期永久生效;第二条业务场景医疗专线,处置类型业务恢复,操作名称频段调整,有效期2026-13-01~2030-12-31;第三条业务场景政企专网,处置类型业务优化,操作名称路由调整,有效期2027-03-15~2029-09-30 **生成结果:成功(渲染后 prompt)** @@ -812,7 +1297,9 @@ 请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 ## 动网操作的授权策略列表 -1. 业务场景是校园专网,处置类型是网络出了问题要赶紧恢复,操作名称是频段调整,有效期是2026-01-01~2030-12-31 +1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效 +2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-13-01~2030-12-31 +3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30 ## 预期输出 1. 授权操作执行结果,取值范围: 成功、失败、部分成功; @@ -820,17 +1307,17 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中“新增”是“新增授权策略”的合法简写,映射为对应合法取值。操作类型为新增授权策略时,动网操作的授权策略列表为必填;输入明确给出业务场景、处置类型、操作名称和有效期,按值约束要求组织为一个编号条目,且有效期已符合 YYYY-MM-DD~YYYY-MM-DD 区间格式,无需改写。 +**提参理由(LLM reasoning)**:输入中出现“新增”,按封闭取值范围映射为“新增授权策略”。动网操作的授权策略列表按三条进行编号并补全为“字段名是值”格式;有效期“2026-13-01~2030-12-31”虽含无效月份,但按规则16对非封闭取值范围槽位原样提取,不报invalid_value。 ### 服务端(校验 + 提参) -**validateAndDataFilling 输入 schema(本用例变异 schema)**: +**validateAndDataFilling 输入 schema(suite 默认 param-schema.json)**: ```json { "type": "object", "properties": { - "操作类型": { + "operationType": { "type": "string", "enum": [ "新增授权策略", @@ -840,31 +1327,31 @@ ], "description": "授权策略的操作类型" }, - "策略列表": { + "policyList": { "type": "array", - "description": "动网操作的授权策略列表,按输入中的出现顺序排列。参数值中每条条目以编号加'.'开头(如'1.'),条目内各字段以'字段名是值'形式表达并用逗号分隔,多个条目各自独立成行(换行分隔)。该参数未提供时输出空数组 []。各操作类型对每条的字段要求不同:新增条目须包含业务场景、处置类型、操作名称、有效期;删除条目可为策略标识,也可仅为条件字段(业务场景、处置类型、操作名称之一或多个),无需其他字段,不得按新增的必填字段要求检查删除条目。修改授权策略时,每条须包含策略标识与新有效期,缺少新有效期判 missing_required。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),带标签时按标签归因字段。", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", "items": { "type": "object", "properties": { - "策略标识": { + "policyId": { "type": "string", - "description": "策略的UUID唯一标识。UUID 形态的值归入本字段;非 UUID 的裸值按其内容语义归入业务场景/处置类型/操作名称字段。新增类策略无标识时为null;删除场景可按条件字段方式描述(此时策略标识为null)。内容校验阶段不依据标识格式判定存在性" + "description": "策略的唯一标识" }, - "业务场景": { + "scene": { "type": "string", "description": "动网操作支撑的业务场景" }, - "处置类型": { + "actionType": { "type": "string", - "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 invalid_value" + "description": "动网操作的处置类型" }, - "操作名称": { + "operationName": { "type": "string", "description": "所授权的动网操作的具体名称" }, - "有效期": { + "validityPeriod": { "type": "string", - "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + "description": "授权策略的生效时间区间" } } } @@ -876,13 +1363,78 @@ (输入 prompt 即上方客户端渲染结果,此处不重复。) **校验与提参结果**:`validation_semantic_rejected` -- 错误明细:`处置类型:invalid_value`(处置类型“网络出了问题要赶紧恢复”不是简短的处置动作短语(如“业务抢通”),应使用简短的处置动作短语。) -**校验理由(LLM reasoning)**:操作类型为新增授权策略,在枚举范围内;策略列表包含一条新增条目,业务场景、操作名称、有效期均齐全且有效。处置类型值为“网络出了问题要赶紧恢复”,属于非短语式整句描述,违反处置类型应为简短动作短语的约束,因此判定 invalid_value,整体语义校验不通过。 +```json +[ + { + "slot_name": "policyList", + "code": "format_error", + "message": "动网操作的授权策略列表第2条的有效期“2026-13-01~2030-12-31”月份无效,13月不存在,应为1~12月。" + } +] +``` + +**校验理由(LLM reasoning)**:操作类型“新增授权策略”在schema枚举范围内,合规。新增授权策略时,policyList每条必须包含业务场景、处置类型、操作名称、有效期;第1条和第3条均满足要求,第2条有效期月份为13,属于无效日期,构成format_error,因此semantic_verdict为false。参数提取按输入顺序完整提取三条策略,未提供的policyId按要求输出null。 -> ❌ mismatch 说明:行为判定正确,但 slot_name 归因粒度与题集期望不同(字段级 vs 参数级),子集比对未命中——LLM 措辞波动,非行为回归。 +**键名泄漏扫描**:无泄漏 -**判定**:match=False(client_prompt=True) +**判定**:match=True(client_prompt=True) + +--- + +## a-data-starve-01 — ✅ match + +### 客户端(生成) + +**原始输入**(from_data_with_schema) + +data: +```json +{ + "scene": "校园专网", + "actionType": "业务抢通" +} +``` +input.schema: +```json +{ + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + } + } +} +``` + +**生成结果:失败** + +```json +{ + "code": "slot_validation_error", + "message": "Required slots are missing or empty: 授权策略的操作类型", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "missing_required", + "message": "Required slot is missing or empty" + } + ] +} +``` + +**提参理由(LLM reasoning)**:输入中未出现“新增/修改/删除/查询”等操作类型合法取值或其认可同义对,因此“授权策略的操作类型”置为null并报告missing_input。“scene=校园专网”和“actionType=业务抢通”分别对应策略列表中的业务场景和处置类型字段,属于非封闭列表slot,按规则提取并格式化为编号条目。 + +### 服务端(校验 + 提参) + +(客户端生成失败路径,服务端未被调用。) + +**判定**:match=True(client_prompt=None) --- @@ -896,11 +1448,21 @@ **生成结果:失败** -- 错误码:`slot_validation_error` -- 错误信息:`Required slots are missing or empty: 授权策略的操作类型` -- 槽位错误:`授权策略的操作类型:missing_required`(Required slot is missing or empty) +```json +{ + "code": "slot_validation_error", + "message": "Required slots are missing or empty: 授权策略的操作类型", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "missing_required", + "message": "Required slot is missing or empty" + } + ] +} +``` -**提参理由(LLM reasoning)**:输入中“不要新增”属于否定/排除语义,按规则3不能将“新增”映射为正向操作类型,因此授权策略的操作类型置为null并报告missing_input。“校园专网的授权策略”同样位于否定表述中,且未提供其他正向策略信息,因此动网操作的授权策略列表置为null。 +**提参理由(LLM reasoning)**:输入为否定语义“不要新增校园专网的授权策略,我还没准备好”,其中“新增”处于排除/否定语境,不能作为正向的操作类型值提取;“校园专网”等列表信息同样处于否定语义中,不提取。操作类型为必填槽且未提取到有效值,因此报告 missing_input;策略列表为可选槽,且操作类型未确定为新增/修改/删除时不做条件必填判定,置为 null 且不报错。 ### 服务端(校验 + 提参) @@ -910,4 +1472,10 @@ --- -**Summary**:Total 15,Match 13,Mismatch 2(均为 slot_name 归因粒度波动,行为正确)。 +## 差分对判读汇总 + +| 差分对 | 基线半 | 变异半 | 判读 | +|---|---|---|---| +| c1-nl-add-01/-varname | ✅ | ✅ | 健康 | +| c3-nl-mod-06/-varfields | ✅ | ✅ | 健康 | +| b2-nl-format-01/-varreq | ✅ | ✅ | 健康 | diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java index 47e0e5c3..735bf8e8 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java @@ -115,8 +115,8 @@ void should_loadParamSchema_ReturnBusinessLevelSchema() { assertNotNull(schema); assertTrue(schema.containsKey("properties")); Map properties = (Map) schema.get("properties"); - assertTrue(properties.containsKey("操作类型")); - assertTrue(properties.containsKey("策略列表")); + assertTrue(properties.containsKey("operationType")); + assertTrue(properties.containsKey("policyList")); } @Test diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java index b2f4a719..0b731b3e 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java @@ -17,25 +17,66 @@ void should_loadAndValidateAllScenarios() { List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios.json"); assertEquals(15, scenarios.size()); - // 预期成功在前(index 0-8),预期拒绝在中(index 9-13),客户端拦截在末尾(index 14) + // 预期成功在前(index 0-7),预期拒绝在中(index 8-12),客户端拦截在末尾(index 13-14) assertEquals("c1-nl-add-01", scenarios.get(0).label()); assertEquals("from_text", scenarios.get(0).entry()); assertEquals("success", scenarios.get(0).expected().server().outcome()); - assertEquals("c2-data-multi-07", scenarios.get(3).label()); - assertEquals("from_data_with_schema", scenarios.get(3).entry()); + assertEquals("c1-nl-add-01-varname", scenarios.get(1).label()); + assertEquals("from_text", scenarios.get(1).entry()); + assertEquals("success", scenarios.get(1).expected().server().outcome()); + assertNotNull(scenarios.get(1).validateSchema()); + assertEquals("c3-nl-mod-06", scenarios.get(3).label()); assertEquals("success", scenarios.get(3).expected().server().outcome()); - assertEquals("b6-schema-variant-01", scenarios.get(7).label()); + assertEquals("c3-nl-mod-06-varfields", scenarios.get(4).label()); + assertEquals("success", scenarios.get(4).expected().server().outcome()); + assertNotNull(scenarios.get(4).validateSchema()); + assertEquals("b4-nl-bad-id-01", scenarios.get(7).label()); assertEquals("success", scenarios.get(7).expected().server().outcome()); - assertEquals(null, scenarios.get(7).validateSchema()); - assertEquals("b6-schema-variant-02", scenarios.get(13).label()); - assertEquals(false, scenarios.get(13).validateSchema() == null); - assertEquals("b1-nl-missing-01", scenarios.get(9).label()); + assertEquals("b3-nl-invalid-mod-01", scenarios.get(8).label()); + assertEquals("validation_semantic_rejected", scenarios.get(8).expected().server().outcome()); + assertEquals("b2-nl-format-01", scenarios.get(9).label()); assertEquals("validation_semantic_rejected", scenarios.get(9).expected().server().outcome()); + assertEquals("b2-nl-format-01-varreq", scenarios.get(10).label()); + assertEquals("validation_semantic_rejected", scenarios.get(10).expected().server().outcome()); + assertNotNull(scenarios.get(10).validateSchema()); + assertEquals("b1-nl-missing-01", scenarios.get(11).label()); + assertEquals("validation_semantic_rejected", scenarios.get(11).expected().server().outcome()); assertEquals("c6-nl-mixed-07", scenarios.get(12).label()); assertEquals("validation_semantic_rejected", scenarios.get(12).expected().server().outcome()); + assertEquals("a-data-starve-01", scenarios.get(13).label()); + assertEquals("from_data_with_schema", scenarios.get(13).entry()); + assertEquals("slot_validation_error", scenarios.get(13).expected().client().outcome()); + assertNull(scenarios.get(13).expected().server()); assertEquals("a-nl-neg-01", scenarios.get(14).label()); assertEquals("slot_validation_error", scenarios.get(14).expected().client().outcome()); - assertEquals(null, scenarios.get(14).expected().server()); + assertNull(scenarios.get(14).expected().server()); + } + + @Test + void should_loadAndValidateFullSuiteScenarios() { + List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios-100.json"); + + assertEquals(100, scenarios.size()); + long variants = scenarios.stream() + .filter(s -> s.label().endsWith("-varname") + || s.label().endsWith("-varfields") + || s.label().endsWith("-varflat") + || s.label().endsWith("-varreq") + || s.label().endsWith("-varsch") + || s.label().endsWith("-dual")) + .count(); + assertEquals(45, variants); + long clientInterceptions = scenarios.stream() + .filter(s -> s.expected().client() != null && s.expected().client().outcome() != null) + .count(); + assertEquals(10, clientInterceptions); + long serverRejections = scenarios.stream() + .filter(s -> s.expected().server() != null + && "validation_semantic_rejected".equals(s.expected().server().outcome())) + .count(); + assertEquals(30, serverRejections); + long validateSchemas = scenarios.stream().filter(s -> s.validateSchema() != null).count(); + assertEquals(42, validateSchemas); } @Test From 804d2104f50242983851bc72fc4ff83cbf5d7fa6 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 28 Aug 2026 11:10:47 +0800 Subject: [PATCH 218/295] feat(input-limit): guard free-text facade inputs with configurable max length Add a configurable length limit (A2AT_INPUT_TEXT_MAX_CHARS, default 12288 chars by String.length()) enforced before any LLM call on every facade entry point that accepts a free-text String: client FromText generation (generateTaskPrompt text branch, task/auth/notification FromText), negotiation FromText generation and validate*PromptAndDataFilling on both facades, and server checkTaskPrompt plus content validation. Oversized inputs fail fast with the new error code input_text_too_long and are never truncated; structured Map inputs are not checked. Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 31 +++- ...ntPromptGenerationOrchestratorBuilder.java | 18 +- .../assembly/DefaultA2ATClientBuilder.java | 1 + .../ClientPromptGenerationOrchestrator.java | 9 +- ...ultClientPromptGenerationOrchestrator.java | 53 ++++++ .../a2at/sdk/client/api/A2ATClientTest.java | 44 +++++ .../DefaultA2ATClientBuilderTest.java | 4 +- ...lientPromptGenerationOrchestratorTest.java | 111 ++++++++++++ .../sdk/core/exception/A2ATErrorCodes.java | 3 + .../a2at/sdk/core/model/A2ATConfig.java | 2 + .../a2at/sdk/core/model/A2ATConfigKeys.java | 18 +- .../a2at/sdk/core/model/InputLimitConfig.java | 76 ++++++++ .../core/exception/A2ATErrorCodesTest.java | 21 +-- .../sdk/core/model/InputLimitConfigTest.java | 79 ++++++++ .../generation/NegotiationContentService.java | 29 +-- .../NegotiationGenerationOrchestrator.java | 47 ++++- ...otiationGenerationOrchestratorBuilder.java | 20 +++ ...otiationDeliveredResourceContractTest.java | 1 + .../generation/InputTextLengthLimitTest.java | 169 ++++++++++++++++++ .../openan/a2at/sdk/server/A2ATServer.java | 50 ++++-- .../assembly/DefaultA2ATServerBuilder.java | 19 +- ...ultServerPromptComplianceOrchestrator.java | 30 ++++ .../InputLimitedContentValidator.java | 46 +++++ .../server/api/InputTextLengthLimitTest.java | 144 +++++++++++++++ .../DefaultA2ATServerBuilderTest.java | 2 + ...erverPromptComplianceOrchestratorTest.java | 33 ++++ .../InputLimitedContentValidatorTest.java | 52 ++++++ docs/en/developer_guide.md | 3 + ...00\345\217\221\346\214\207\345\215\227.md" | 3 + env.example | 6 + 30 files changed, 1055 insertions(+), 69 deletions(-) create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/InputLimitConfig.java create mode 100644 a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/InputLimitConfigTest.java create mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/InputTextLengthLimitTest.java create mode 100644 a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidator.java create mode 100644 a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java create mode 100644 a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidatorTest.java diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 745adea1..c73e8ab0 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -62,8 +62,10 @@ public A2ATClient(Path envPath) { * Generates a processed task prompt from raw user input. Both text and structured input ({@code Map}) are unified * through LLM extraction; there is no zero-LLM rule shortcut. * - * @param userInput user-provided task description (text or structured input map) - * @return prompt generation result containing either rendered prompt text or failure details + * @param userInput user-provided task description (text or structured input map); a String input longer than the + * configured maximum length ({@code A2AT_INPUT_TEXT_MAX_CHARS}) fails fast without any LLM call + * @return prompt generation result containing either rendered prompt text or failure details; a String input over + * the limit yields the failure code {@code input_text_too_long} */ public PromptGenerationResult generateTaskPrompt(Object userInput) { return promptGenerationOrchestrator.generateTaskPrompt(userInput); @@ -79,7 +81,8 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { * @throws NullPointerException if the text or template URI is null * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails, or the code + * {@code input_text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { Objects.requireNonNull(text, "text"); @@ -122,7 +125,8 @@ public MetadataContent generateTaskPromptFromDataWithSchema( * @throws NullPointerException if the text or template URI is null * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails, or the code + * {@code input_text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { Objects.requireNonNull(text, "text"); @@ -165,7 +169,8 @@ public MetadataContent generateAuthPromptFromDataWithSchema( * @throws NullPointerException if the text or template URI is null * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails + * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails, or the code + * {@code input_text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { Objects.requireNonNull(text, "text"); @@ -341,7 +346,9 @@ public MetadataContent generateNegotiationAbortPromptFromData( * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * content contradicts the phase + * content contradicts the phase, + * or the code {@code input_text_too_long} when the text exceeds the configured + * maximum length */ public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, @@ -370,7 +377,9 @@ public MetadataContent generateNegotiationProposePromptFromText( * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * conclusion is not {@code Accept} + * conclusion is not {@code Accept}, + * or the code {@code input_text_too_long} when the text exceeds the configured + * maximum length */ public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, @@ -399,7 +408,9 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * conclusion is not {@code Reject} + * conclusion is not {@code Reject}, + * or the code {@code input_text_too_long} when the text exceeds the configured + * maximum length */ public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, @@ -427,7 +438,9 @@ public MetadataContent generateNegotiationRejectPromptFromText( * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses the termination reason, or {@code negotiation_invalid_input} when the text is blank + * content misses the termination reason, or {@code negotiation_invalid_input} when the text is blank, + * or the code {@code input_text_too_long} when the text exceeds the configured + * maximum length */ public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java index 471867e3..c499a722 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java @@ -2,6 +2,7 @@ import java.util.List; import net.openan.a2at.sdk.client.prompt.orchestration.DefaultClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.InputLimitConfig; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; @@ -48,6 +49,8 @@ private ClientPromptGenerationOrchestratorBuilder() {} private TaskPromptRenderer renderer; + private Integer maxTextChars; + private ClasspathPromptResourceLoader resourceLoader; /** @@ -192,6 +195,18 @@ public ClientPromptGenerationOrchestratorBuilder renderer(TaskPromptRenderer ren return this; } + /** + * Configures the maximum accepted length in characters for free-text inputs. + * + * @param maxTextChars maximum accepted length in characters; unset falls back to + * {@link InputLimitConfig#DEFAULT_MAX_TEXT_CHARS} + * @return current builder + */ + public ClientPromptGenerationOrchestratorBuilder maxTextChars(int maxTextChars) { + this.maxTextChars = maxTextChars; + return this; + } + /** * Overrides the classpath resource loader shared by template and schema resolution. * @@ -243,7 +258,8 @@ public DefaultClientPromptGenerationOrchestrator build() { effectiveTemplateLoader, effectiveSlotValueExtractor, effectiveRenderer, - effectiveSlotSchemaLoader); + effectiveSlotSchemaLoader, + maxTextChars == null ? InputLimitConfig.DEFAULT_MAX_TEXT_CHARS : maxTextChars); } private static void require(Object value, String message) { diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index 4d3da294..61045a09 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -133,6 +133,7 @@ public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { .slotSchemaLoader(slotSchemaLoader) .slotValueExtractor(slotValueExtractor) .renderer(new TaskPromptRenderer()) + .maxTextChars(config.inputLimits().maxTextChars()) .build(); } diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java index f3b15926..420683d6 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java @@ -15,7 +15,8 @@ public interface ClientPromptGenerationOrchestrator { /** * Generates a processed task prompt from user input. * - * @param userInput raw or structured user input + * @param userInput raw or structured user input; a String input longer than the configured maximum length fails + * fast with the code {@code input_text_too_long} * @return prompt-generation result */ PromptGenerationResult generateTaskPrompt(Object userInput); @@ -28,6 +29,8 @@ public interface ClientPromptGenerationOrchestrator { * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input_text_too_long} + * when the text exceeds the configured maximum length */ MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri); @@ -53,6 +56,8 @@ MetadataContent generateTaskPromptFromDataWithSchema( * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input_text_too_long} + * when the text exceeds the configured maximum length */ MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri); @@ -78,6 +83,8 @@ MetadataContent generateAuthPromptFromDataWithSchema( * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input_text_too_long} + * when the text exceeds the configured maximum length */ MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri); diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java index d8552c99..7597a331 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -13,6 +13,7 @@ import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.InputLimitConfig; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.model.TemplateUri; @@ -53,6 +54,8 @@ public final class DefaultClientPromptGenerationOrchestrator implements ClientPr private final TaskPromptRenderer renderer; + private final int maxTextChars; + /** * Creates a client prompt-generation orchestrator with explicit collaborators. * @@ -76,6 +79,44 @@ public DefaultClientPromptGenerationOrchestrator( PromptSlotValueExtractor slotValueExtractor, TaskPromptRenderer renderer, PromptSlotSchemaLoader slotSchemaLoader) { + this( + scenarioRecognizer, + scenarios, + language, + systemPrompt, + userPrompt, + templateLoader, + slotValueExtractor, + renderer, + slotSchemaLoader, + InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); + } + + /** + * Creates a client prompt-generation orchestrator with explicit collaborators and one free-text input length limit. + * + * @param scenarioRecognizer scenario recognizer + * @param scenarios supported scenario definitions + * @param language locale identifier for resource lookup + * @param systemPrompt system prompt for scenario recognition + * @param userPrompt user prompt for scenario recognition + * @param templateLoader template loader + * @param slotValueExtractor slot value extractor + * @param renderer task prompt renderer + * @param slotSchemaLoader slot schema loader + * @param maxTextChars maximum accepted length in characters for free-text inputs + */ + public DefaultClientPromptGenerationOrchestrator( + ScenarioRecognizer scenarioRecognizer, + List scenarios, + String language, + String systemPrompt, + String userPrompt, + PromptTemplateTextLoader templateLoader, + PromptSlotValueExtractor slotValueExtractor, + TaskPromptRenderer renderer, + PromptSlotSchemaLoader slotSchemaLoader, + int maxTextChars) { this.scenarioRecognizer = scenarioRecognizer; this.scenarios = scenarios; this.language = language; @@ -85,10 +126,17 @@ public DefaultClientPromptGenerationOrchestrator( this.slotValueExtractor = slotValueExtractor; this.renderer = renderer; this.slotSchemaLoader = slotSchemaLoader; + this.maxTextChars = maxTextChars; } @Override public PromptGenerationResult generateTaskPrompt(Object userInput) { + if (userInput instanceof String text && InputLimitConfig.isTooLong(text, maxTextChars)) { + return PromptGenerationResult.failure(new PromptGenerationFailure( + A2ATErrorCodes.INPUT_TEXT_TOO_LONG, + InputLimitConfig.violationMessage(text, maxTextChars), + "input")); + } String normalizedInput = String.valueOf(userInput); final ScenarioRecognitionResult recognition; @@ -167,6 +215,11 @@ public MetadataContent generateNotificationPromptFromDataWithSchema( private MetadataContent generateFromTemplateUriWithMetadata( String userInput, TemplateUri templateUri, String extensionUri) { Objects.requireNonNull(userInput, "userInput"); + if (InputLimitConfig.isTooLong(userInput, maxTextChars)) { + throw new PromptGenerationException( + A2ATErrorCodes.INPUT_TEXT_TOO_LONG, + InputLimitConfig.violationMessage(userInput, maxTextChars)); + } return generateWithMetadata(templateUri, extensionUri, templateIdentifier -> slotValueExtractor .extractSlots(userInput, templateIdentifier, language) .slots()); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index 25decb06..94835508 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -17,6 +17,7 @@ import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.StandardTemplates; @@ -439,6 +440,49 @@ void fromDataWithSchemaThrowsOnNullTemplateUri() throws IOException { assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); } + @Test + void fromTextRejectsOverLimitTextWhenConfiguredWithSmallLimit() throws IOException { + Path envFile = writeMinimalClientEnvWithInputLimit(TEST_MOCK_PROVIDER, 100); + A2ATClient client = new A2ATClient(envFile); + String overLimitText = "a".repeat(101); + + PromptGenerationException taskEx = assertThrows( + PromptGenerationException.class, + () -> client.generateTaskPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + PromptGenerationException authEx = assertThrows( + PromptGenerationException.class, + () -> client.generateAuthPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + PromptGenerationException notificationEx = assertThrows( + PromptGenerationException.class, + () -> client.generateNotificationPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + + assertEquals("input_text_too_long", taskEx.getCode()); + assertEquals("input_text_too_long", authEx.getCode()); + assertEquals("input_text_too_long", notificationEx.getCode()); + assertEquals(0, RecordingClient.REQUEST_COUNT.get(), "over-limit input must fail before any LLM call"); + } + + @Test + void generateTaskPromptRejectsOverLimitStringInputWithoutLlmCall() throws IOException { + Path envFile = writeMinimalClientEnvWithInputLimit(TEST_MOCK_PROVIDER, 100); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult result = client.generateTaskPrompt("a".repeat(101)); + + assertFalse(result.success()); + assertEquals("input_text_too_long", result.failure().code()); + assertEquals(0, RecordingClient.REQUEST_COUNT.get(), "over-limit input must fail before any LLM call"); + } + + private static Path writeMinimalClientEnvWithInputLimit(String provider, int maxTextChars) throws IOException { + Path envFile = writeMinimalClientEnv(provider); + Files.writeString( + envFile, + "A2AT_INPUT_TEXT_MAX_CHARS=" + maxTextChars + "\n", + java.nio.file.StandardOpenOption.APPEND); + return envFile; + } + private static Path writeMinimalClientEnvWithoutRequiredSlots(String provider) throws IOException { Path tempDir = Files.createTempDirectory("a2at-client-env-no-required"); Path promptRoot = tempDir.resolve("prompt_resources"); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java index 391bf2dd..f405f0fe 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java @@ -43,7 +43,7 @@ void should_createLlmClientOnlyOnce_When_multipleBuildMethodsCalled() throws IOE config = new A2ATConfig( new net.openan.a2at.sdk.core.model.PromptRuntimeConfig( config.prompt().language(), config.prompt().sourceType(), resolvedRoot), - config.llm(), config.negotiation(), config.promptCompliance()); + config.llm(), config.inputLimits(), config.negotiation(), config.promptCompliance()); DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder().config(config).envPath(envFile); @@ -70,7 +70,7 @@ void buildPromptGenerationOrchestratorPropagatesMissingScenarioCatalog() throws config = new A2ATConfig( new net.openan.a2at.sdk.core.model.PromptRuntimeConfig( config.prompt().language(), config.prompt().sourceType(), resolvedRoot), - config.llm(), config.negotiation(), config.promptCompliance()); + config.llm(), config.inputLimits(), config.negotiation(), config.promptCompliance()); DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder().config(config).envPath(envFile); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index f883399f..5478c66d 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -14,6 +14,7 @@ import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.InputLimitConfig; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.model.StandardTemplates; @@ -983,4 +984,114 @@ void validateRequiredSlotsTreatsNullSlotValueAsMissing() { assertEquals("site", ex.failedParameters().get(0).slotName()); assertEquals("missing_required", ex.failedParameters().get(0).code()); } + + // --- free-text input length limit tests --- + + @Test + void fromTextEntryPointsRejectTextOverTheDefaultLimitBeforeAnyLlmCall() { + String overLimitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1); + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); + + PromptGenerationException taskEx = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + PromptGenerationException authEx = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateAuthPromptFromText(overLimitText, AUTH_DATABASE_READ)); + PromptGenerationException notificationEx = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateNotificationPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + + assertEquals("input_text_too_long", taskEx.getCode()); + assertEquals("input_text_too_long", authEx.getCode()); + assertEquals("input_text_too_long", notificationEx.getCode()); + assertTrue(taskEx.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1))); + assertEquals(0, recognizer.invocationCount, "over-limit input must fail before any LLM call"); + assertEquals(0, templateLoader.loadCount, "over-limit input must fail before template loading"); + } + + @Test + void fromTextAcceptsTextAtExactlyTheDefaultLimit() { + String limitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + MetadataContent result = + orchestrator.generateTaskPromptFromText(limitText, StandardTemplates.ENERGY_SAVING); + + assertEquals("Site: Site A", result.promptText()); + } + + @Test + void fromTextRejectsTextOverOneConfiguredLimit() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER, + 10); + + PromptGenerationException overEx = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("a".repeat(11), StandardTemplates.ENERGY_SAVING)); + assertEquals("input_text_too_long", overEx.getCode()); + + MetadataContent atLimit = + orchestrator.generateTaskPromptFromText("a".repeat(10), StandardTemplates.ENERGY_SAVING); + assertEquals("Site: Site A", atLimit.promptText()); + } + + @Test + void generateTaskPromptReturnsFailureWhenStringInputExceedsTheDefaultLimit() { + String overLimitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1); + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt(overLimitText); + + assertFalse(result.success()); + assertEquals("input_text_too_long", result.failure().code()); + assertEquals("input", result.failure().stage()); + assertEquals(0, recognizer.invocationCount, "over-limit input must fail before any LLM call"); + } + + @Test + void generateTaskPromptDoesNotCheckLengthForMapInput() { + Map mapInputWithLongStringValue = + Map.of("notes", "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1)); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt(mapInputWithLongStringValue); + + assertTrue(result.success(), "map input must not be length-checked"); + assertEquals("Site: Site A", result.promptText()); + } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java index 5feb3c40..2e2c9a2f 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java @@ -14,6 +14,9 @@ public final class A2ATErrorCodes { /** Default code for SDK processing failures without a more specific code. */ public static final String SDK_INTERNAL_ERROR = "sdk_internal_error"; + /** A free-text input exceeded the configured maximum length. */ + public static final String INPUT_TEXT_TOO_LONG = "input_text_too_long"; + /** Default code for parameter-extraction failures without a more specific code. */ public static final String PARAM_EXTRACTION_FAILED = "param_extraction_failed"; diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java index b943a43d..de4a0b65 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java @@ -13,6 +13,7 @@ public record A2ATConfig( PromptRuntimeConfig prompt, LlmConfig llm, + InputLimitConfig inputLimits, NegotiationConfig negotiation, PromptComplianceConfig promptCompliance) { @@ -27,6 +28,7 @@ public record A2ATConfig( return new A2ATConfig( PromptRuntimeConfig.fromMap(values), LlmConfig.fromMap(values), + InputLimitConfig.fromMap(values), NegotiationConfig.fromMap(values), PromptComplianceConfig.fromMap(values)); } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java index cc3f3557..59a8dd45 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java @@ -51,11 +51,11 @@ public static final class Llm { /** Default sampling temperature. */ public static final String TEMPERATURE = "A2AT_LLM_TEMPERATURE"; - /** Request timeout in seconds. */ - public static final String TIMEOUT_SECONDS = "A2AT_LLM_TIMEOUT_SECONDS"; - - /** Disables use of the JVM or operating-system HTTP proxy for the LLM request. */ - public static final String DISABLE_SYSTEM_PROXY = "A2AT_LLM_DISABLE_SYSTEM_PROXY"; + /** Request timeout in seconds. */ + public static final String TIMEOUT_SECONDS = "A2AT_LLM_TIMEOUT_SECONDS"; + + /** Disables use of the JVM or operating-system HTTP proxy for the LLM request. */ + public static final String DISABLE_SYSTEM_PROXY = "A2AT_LLM_DISABLE_SYSTEM_PROXY"; /** Optional reasoning effort level for reasoning models (none/minimal/low/medium/high/xhigh). */ public static final String REASONING_EFFORT = "A2AT_LLM_REASONING_EFFORT"; @@ -78,6 +78,14 @@ public static final class Negotiation { public static final String STATE_STORE_TYPE = "A2AT_NEGOTIATION_STATE_STORE_TYPE"; } + /** Input limit configuration keys. */ + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static final class Input { + + /** Maximum length in characters accepted for free-text inputs before they reach an LLM step. */ + public static final String MAX_TEXT_CHARS = "A2AT_INPUT_TEXT_MAX_CHARS"; + } + /** Prompt compliance configuration keys. */ @NoArgsConstructor(access = AccessLevel.PRIVATE) public static final class PromptCompliance { diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/InputLimitConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/InputLimitConfig.java new file mode 100644 index 00000000..814e1c73 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/InputLimitConfig.java @@ -0,0 +1,76 @@ +package net.openan.a2at.sdk.core.model; + +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +/** + * Input limit configuration resolved from unified SDK config. + * + *

        The limits guard every facade entry point that accepts a free-text {@link String} and forwards it to an LLM step, + * so oversized inputs fail fast before any LLM call instead of overflowing the LLM context. + * + * @since 2026-08 + */ +@Slf4j +public record InputLimitConfig(int maxTextChars) { + + /** Default maximum length in characters ({@link String#length()}) accepted for free-text inputs. */ + public static final int DEFAULT_MAX_TEXT_CHARS = 12288; + + private static final String NON_NUMERIC_OR_NON_POSITIVE_LOG_FORMAT = + "Input max text chars value is not a valid positive integer, falling back to default. key={} raw_value={}" + + " default_value={}"; + + public InputLimitConfig { + if (maxTextChars <= 0) { + throw new IllegalArgumentException("maxTextChars must be positive: " + maxTextChars); + } + } + + /** + * Builds one input limit config from raw {@code .env} values. + * + * @param values raw config values + * @return resolved input limit config + */ + public static InputLimitConfig fromMap(Map values) { + String rawValue = values.get(A2ATConfigKeys.Input.MAX_TEXT_CHARS); + if (StringUtils.isBlank(rawValue)) { + return new InputLimitConfig(DEFAULT_MAX_TEXT_CHARS); + } + try { + return new InputLimitConfig(Integer.parseInt(rawValue.trim())); + } catch (IllegalArgumentException error) { + log.atWarn().log( + NON_NUMERIC_OR_NON_POSITIVE_LOG_FORMAT, + A2ATConfigKeys.Input.MAX_TEXT_CHARS, + rawValue.trim(), + DEFAULT_MAX_TEXT_CHARS); + return new InputLimitConfig(DEFAULT_MAX_TEXT_CHARS); + } + } + + /** + * Reports whether the given free-text input exceeds the given character limit. + * + * @param text free-text input; {@code null} is never too long + * @param maxTextChars maximum accepted length in characters + * @return true when the input length exceeds the limit + */ + public static boolean isTooLong(String text, int maxTextChars) { + return text != null && text.length() > maxTextChars; + } + + /** + * Builds the violation message for an oversized free-text input. + * + * @param text oversized free-text input + * @param maxTextChars maximum accepted length in characters + * @return message stating the actual length, the limit and the configuring key + */ + public static String violationMessage(String text, int maxTextChars) { + return "input text length " + text.length() + " exceeds the configured maximum of " + maxTextChars + + " characters (" + A2ATConfigKeys.Input.MAX_TEXT_CHARS + ")"; + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java index bdc1dd18..4e35ae13 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java @@ -23,16 +23,16 @@ class A2ATErrorCodesTest { /** - * Verifies that {@link A2ATErrorCodes} declares exactly the twenty expected error code constants with the expected - * values. + * Verifies that {@link A2ATErrorCodes} declares exactly the twenty-one expected error code constants with the + * expected values. * *

        Scenario: Reflection inspects all declared static final String fields of the registry. Expected result: The - * field set contains exactly the twenty known constants and no others. + * field set contains exactly the twenty-one known constants and no others. * * @throws IllegalAccessException if a declared field cannot be read */ @Test - void should_declareExactlyTwentyConstants_When_reflectingOverDeclaredFields() throws IllegalAccessException { + void should_declareExactlyTwentyOneConstants_When_reflectingOverDeclaredFields() throws IllegalAccessException { Map constants = new TreeMap<>(); for (Field field : A2ATErrorCodes.class.getDeclaredFields()) { if (field.getType() == String.class @@ -47,6 +47,7 @@ void should_declareExactlyTwentyConstants_When_reflectingOverDeclaredFields() th Map.ofEntries( Map.entry("SDK_INTERNAL_ERROR", "sdk_internal_error"), Map.entry("PARAM_EXTRACTION_FAILED", "param_extraction_failed"), + Map.entry("INPUT_TEXT_TOO_LONG", "input_text_too_long"), Map.entry("TEMPLATE_NOT_FOUND", "template_not_found"), Map.entry("NEGOTIATION_CONTENT_EXTRACT_FAILED", "negotiation_content_extract_failed"), Map.entry("NEGOTIATION_SEMANTIC_REJECTED", "negotiation_semantic_rejected"), @@ -58,12 +59,12 @@ void should_declareExactlyTwentyConstants_When_reflectingOverDeclaredFields() th Map.entry("VALIDATION_RULE_VIOLATION", "validation_rule_violation"), Map.entry("VALIDATION_SEMANTIC_REJECTED", "validation_semantic_rejected"), Map.entry("VALIDATION_LLM_INFRASTRUCTURE_ERROR", "validation_llm_infrastructure_error"), - Map.entry("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", "validation_prompt_resource_not_found"), - Map.entry("PROMPT_RESOURCE_LOAD_ERROR", "prompt_resource_load_error"), - Map.entry("SLOT_SCHEMA_NOT_FOUND", "slot_schema_not_found"), - Map.entry("LLM_INVOCATION_FAILED", "llm_invocation_failed"), - Map.entry("RENDER_FAILED", "render_failed"), - Map.entry("SLOT_VALIDATION_ERROR", "slot_validation_error"), + Map.entry("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", "validation_prompt_resource_not_found"), + Map.entry("PROMPT_RESOURCE_LOAD_ERROR", "prompt_resource_load_error"), + Map.entry("SLOT_SCHEMA_NOT_FOUND", "slot_schema_not_found"), + Map.entry("LLM_INVOCATION_FAILED", "llm_invocation_failed"), + Map.entry("RENDER_FAILED", "render_failed"), + Map.entry("SLOT_VALIDATION_ERROR", "slot_validation_error"), Map.entry("PROCESSED_PROMPT_PARSE_ERROR", "processed_prompt_parse_error")), constants); } diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/InputLimitConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/InputLimitConfigTest.java new file mode 100644 index 00000000..8a145921 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/InputLimitConfigTest.java @@ -0,0 +1,79 @@ +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link InputLimitConfig} parsing and length-guard helpers. + * + * @since 2026-08 + */ +class InputLimitConfigTest { + + @Test + void fromMapFallsBackToDefaultWhenKeyAbsent() { + InputLimitConfig config = InputLimitConfig.fromMap(Map.of()); + + assertEquals(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS, config.maxTextChars()); + assertEquals(12288, config.maxTextChars()); + } + + @Test + void fromMapParsesConfiguredValue() { + InputLimitConfig config = + InputLimitConfig.fromMap(Map.of(A2ATConfigKeys.Input.MAX_TEXT_CHARS, " 2048 ")); + + assertEquals(2048, config.maxTextChars()); + } + + @Test + void fromMapFallsBackToDefaultOnNonNumericValue() { + InputLimitConfig config = + InputLimitConfig.fromMap(Map.of(A2ATConfigKeys.Input.MAX_TEXT_CHARS, "not-a-number")); + + assertEquals(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS, config.maxTextChars()); + } + + @Test + void fromMapFallsBackToDefaultOnNonPositiveValue() { + InputLimitConfig config = + InputLimitConfig.fromMap(Map.of(A2ATConfigKeys.Input.MAX_TEXT_CHARS, "0")); + + assertEquals(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS, config.maxTextChars()); + } + + @Test + void fromMapFallsBackToDefaultOnBlankValue() { + InputLimitConfig config = InputLimitConfig.fromMap(Map.of(A2ATConfigKeys.Input.MAX_TEXT_CHARS, " ")); + + assertEquals(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS, config.maxTextChars()); + } + + @Test + void constructorRejectsNonPositiveLimit() { + assertThrows(IllegalArgumentException.class, () -> new InputLimitConfig(0)); + assertThrows(IllegalArgumentException.class, () -> new InputLimitConfig(-1)); + } + + @Test + void isTooLongComparesByLengthAgainstLimit() { + assertTrue(InputLimitConfig.isTooLong("a".repeat(101), 100)); + assertFalse(InputLimitConfig.isTooLong("a".repeat(100), 100)); + assertFalse(InputLimitConfig.isTooLong("", 100)); + assertFalse(InputLimitConfig.isTooLong(null, 100)); + } + + @Test + void violationMessageStatesLengthLimitAndKey() { + String message = InputLimitConfig.violationMessage("a".repeat(13000), 12288); + + assertTrue(message.contains("13000")); + assertTrue(message.contains("12288")); + assertTrue(message.contains(A2ATConfigKeys.Input.MAX_TEXT_CHARS)); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index 9758b83b..648ab68a 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -61,6 +61,7 @@ public static NegotiationGenerationOrchestrator buildOrchestrator( .localRootDir(config.prompt().localRootDir()) .llmClient(llmClient) .maxAttempts(config.llm().maxAttempts()) + .maxTextChars(config.inputLimits().maxTextChars()) .build(); } @@ -85,6 +86,7 @@ public static A2ATConfig resolvePromptResourceLocalRootDir( new PromptRuntimeConfig( config.prompt().language(), config.prompt().sourceType(), resolvedLocalRootPath.toString()), config.llm(), + config.inputLimits(), config.negotiation(), config.promptCompliance()); } @@ -168,7 +170,8 @@ public MetadataContent generateAbortFromData( * @throws NegotiationGenerationException with the code {@code template_not_found}, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted content contradicts the performative + * {@code negotiation_invalid_input} when the text is blank or the extracted content contradicts the performative, + * or {@code input_text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateProposeFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -187,7 +190,8 @@ public MetadataContent generateProposeFromText( * @throws NegotiationGenerationException with the code {@code template_not_found}, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Accept} + * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Accept}, + * or {@code input_text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateAcceptFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -206,7 +210,8 @@ public MetadataContent generateAcceptFromText( * @throws NegotiationGenerationException with the code {@code template_not_found}, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Reject} + * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Reject}, + * or {@code input_text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateRejectFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -224,7 +229,8 @@ public MetadataContent generateRejectFromText( * @throws IllegalArgumentException if the template URI does not address the common abort template * @throws NegotiationGenerationException with the code {@code template_not_found}, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or - * extracting fails, or {@code negotiation_slot_missing} when the extracted content misses the termination reason + * extracting fails, {@code negotiation_slot_missing} when the extracted content misses the termination reason, + * or {@code input_text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateAbortFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -266,8 +272,8 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * @throws IllegalArgumentException if the prompt is blank, or the template URI contradicts the method * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, - * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline - * fails + * {@code negotiation_llm_infrastructure_error}, {@code input_text_too_long} or {@code template_not_found} + * when the validation pipeline fails */ public FilledParamData validateProposePromptAndDataFilling( String prompt, @@ -290,8 +296,8 @@ public FilledParamData validateProposePromptAndDataFilling( * @throws IllegalArgumentException if the prompt is blank, or the template URI contradicts the method * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, - * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline - * fails + * {@code negotiation_llm_infrastructure_error}, {@code input_text_too_long} or {@code template_not_found} + * when the validation pipeline fails */ public FilledParamData validateAcceptPromptAndDataFilling( String prompt, @@ -314,8 +320,8 @@ public FilledParamData validateAcceptPromptAndDataFilling( * @throws IllegalArgumentException if the prompt is blank, or the template URI contradicts the method * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, - * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline - * fails + * {@code negotiation_llm_infrastructure_error}, {@code input_text_too_long} or {@code template_not_found} + * when the validation pipeline fails */ public FilledParamData validateRejectPromptAndDataFilling( String prompt, @@ -336,7 +342,8 @@ public FilledParamData validateRejectPromptAndDataFilling( * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the schema or the template URI is null * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationParamExtractionException when the validation pipeline fails + * @throws NegotiationParamExtractionException with the code {@code input_text_too_long} when the prompt exceeds + * the configured maximum length, or another validation pipeline failure code otherwise */ public FilledParamData validateAbortPromptAndDataFilling( String prompt, diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 5249afda..4371b13e 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -9,6 +9,7 @@ import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.InputLimitConfig; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.NegotiationPerformative; @@ -60,6 +61,8 @@ public final class NegotiationGenerationOrchestrator { private final int maxAttempts; + private final int maxTextChars; + private final NegotiationTemplateLoader templateLoader; private final NegotiationContentExtractor contentExtractor; @@ -75,6 +78,7 @@ public final class NegotiationGenerationOrchestrator { NegotiationGenerationOrchestrator( String language, int maxAttempts, + int maxTextChars, NegotiationTemplateLoader templateLoader, NegotiationContentExtractor contentExtractor, ParamExtractor paramExtractor, @@ -83,6 +87,7 @@ public final class NegotiationGenerationOrchestrator { Logger logger) { this.language = language; this.maxAttempts = maxAttempts; + this.maxTextChars = maxTextChars; this.templateLoader = templateLoader; this.contentExtractor = contentExtractor; this.paramExtractor = paramExtractor; @@ -206,7 +211,8 @@ public MetadataContent generateAbortFromData( * resource exists for the URI and language, {@code negotiation_content_extract_failed} or * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, * {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted content contradicts the phase + * {@code negotiation_invalid_input} when the text is blank or the extracted content contradicts the phase, + * or {@code input_text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateProposeFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -233,7 +239,8 @@ public MetadataContent generateProposeFromText( * resource exists for the URI and language, {@code negotiation_content_extract_failed} or * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, * {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Accept} + * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Accept}, + * or {@code input_text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateAcceptFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -260,7 +267,8 @@ public MetadataContent generateAcceptFromText( * resource exists for the URI and language, {@code negotiation_content_extract_failed} or * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, * {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Reject} + * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Reject}, + * or {@code input_text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateRejectFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -287,7 +295,8 @@ public MetadataContent generateRejectFromText( * resource exists for the URI and language, {@code negotiation_content_extract_failed} or * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, * {@code negotiation_slot_missing} when the extracted content misses the termination reason, or - * {@code negotiation_invalid_input} when the text is blank + * {@code negotiation_invalid_input} when the text is blank, or {@code input_text_too_long} when the text + * exceeds the configured maximum length */ public MetadataContent generateAbortFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -367,7 +376,8 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing + * {@code template_not_found} when the semantic validation prompt resources are missing, or + * {@code input_text_too_long} when the prompt exceeds the configured maximum length */ public FilledParamData validateProposePromptAndDataFilling( String prompt, @@ -398,7 +408,8 @@ public FilledParamData validateProposePromptAndDataFilling( * negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing + * {@code template_not_found} when the semantic validation prompt resources are missing, or + * {@code input_text_too_long} when the prompt exceeds the configured maximum length */ public FilledParamData validateAcceptPromptAndDataFilling( String prompt, @@ -429,7 +440,8 @@ public FilledParamData validateAcceptPromptAndDataFilling( * negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing + * {@code template_not_found} when the semantic validation prompt resources are missing, or + * {@code input_text_too_long} when the prompt exceeds the configured maximum length */ public FilledParamData validateRejectPromptAndDataFilling( String prompt, @@ -459,7 +471,8 @@ public FilledParamData validateRejectPromptAndDataFilling( * negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing + * {@code template_not_found} when the semantic validation prompt resources are missing, or + * {@code input_text_too_long} when the prompt exceeds the configured maximum length */ public FilledParamData validateAbortPromptAndDataFilling( String prompt, @@ -490,6 +503,7 @@ private MetadataContent generateFromData( private MetadataContent generateFromText( String text, NegotiationContext context, TemplateUri templateUri, NegotiationPerformative performative) { requireContext(context); + requireTextWithinLimit(text); try { NegotiationReference reference = requireReference(templateUri, performative); PromptTemplate template = loadTemplate(reference); @@ -560,6 +574,7 @@ private FilledParamData validatePromptAndDataFilling( TemplateUri templateUri, NegotiationPerformative performative) { Objects.requireNonNull(schema, "Parameter schema must not be null."); + requirePromptWithinLimit(prompt); NegotiationReference reference = requireReference(templateUri, performative); String templateContent; try { @@ -674,4 +689,20 @@ private static String retryableCode(RuntimeException failure) { private static void requireContext(NegotiationContext context) { Objects.requireNonNull(context, "Negotiation context must not be null."); } + + private void requireTextWithinLimit(String text) { + if (InputLimitConfig.isTooLong(text, maxTextChars)) { + throw new NegotiationGenerationException( + A2ATErrorCodes.INPUT_TEXT_TOO_LONG, InputLimitConfig.violationMessage(text, maxTextChars)); + } + } + + private void requirePromptWithinLimit(String prompt) { + if (InputLimitConfig.isTooLong(prompt, maxTextChars)) { + throw new NegotiationParamExtractionException( + A2ATErrorCodes.INPUT_TEXT_TOO_LONG, + InputLimitConfig.violationMessage(prompt, maxTextChars), + List.of()); + } + } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index 862843d2..17536200 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.negotiation.generation; import java.nio.file.Path; +import net.openan.a2at.sdk.core.model.InputLimitConfig; import net.openan.a2at.sdk.core.model.LlmConfig; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.negotiation.content.Vocabulary; @@ -35,6 +36,8 @@ public final class NegotiationGenerationOrchestratorBuilder { private int maxAttempts = LlmConfig.DEFAULT_MAX_ATTEMPTS; + private int maxTextChars = InputLimitConfig.DEFAULT_MAX_TEXT_CHARS; + private NegotiationTemplateLoader templateLoader; private NegotiationContentExtractor contentExtractor; @@ -101,6 +104,18 @@ public NegotiationGenerationOrchestratorBuilder maxAttempts(int maxAttempts) { return this; } + /** + * Configures the maximum length in characters accepted for free-text inputs before they reach an LLM step. + * + * @param maxTextChars maximum accepted length in characters, at least 1; oversized inputs fail fast with the code + * {@code input_text_too_long} instead of overflowing the LLM context + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder maxTextChars(int maxTextChars) { + this.maxTextChars = maxTextChars; + return this; + } + /** * Overrides the negotiation template loader. * @@ -171,6 +186,10 @@ public NegotiationGenerationOrchestrator build() { throw new IllegalStateException( "Negotiation LLM max attempts must be at least 1 but was " + maxAttempts + "."); } + if (maxTextChars < 1) { + throw new IllegalStateException( + "Negotiation max text chars must be at least 1 but was " + maxTextChars + "."); + } Path localVocabularyRoot = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); Vocabulary vocabulary = Vocabulary.forLanguage(language, localVocabularyRoot); NegotiationTemplateLoader effectiveTemplateLoader = @@ -186,6 +205,7 @@ public NegotiationGenerationOrchestrator build() { return new NegotiationGenerationOrchestrator( language, maxAttempts, + maxTextChars, effectiveTemplateLoader, effectiveContentExtractor, paramExtractor, diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java index 99f64c67..92f88c7c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java @@ -68,6 +68,7 @@ void everyPackagedTemplateIsByteIdenticalToTheDeliveredFile() throws IOException void errorCodeConstantsStayExactlyThePinnedRegistry() { java.util.Map expected = new java.util.LinkedHashMap<>(); expected.put("SDK_INTERNAL_ERROR", "sdk_internal_error"); + expected.put("INPUT_TEXT_TOO_LONG", "input_text_too_long"); expected.put("PARAM_EXTRACTION_FAILED", "param_extraction_failed"); expected.put("TEMPLATE_NOT_FOUND", "template_not_found"); expected.put("NEGOTIATION_CONTENT_EXTRACT_FAILED", "negotiation_content_extract_failed"); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/InputTextLengthLimitTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/InputTextLengthLimitTest.java new file mode 100644 index 00000000..9365b685 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/InputTextLengthLimitTest.java @@ -0,0 +1,169 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; +import org.junit.jupiter.api.Test; + +/** + * Tests of the free-text input length limit of the negotiation pipelines. + * + *

        Oversized inputs fail fast with the code {@code input_text_too_long} before any LLM call, inputs at exactly the + * limit pass the gate and reach the LLM, and the limit is configurable through the builder. + */ +class InputTextLengthLimitTest { + + private static final String ZH_CN = NegotiationGoldenCases.ZH_CN; + + private static final GoldenCase PROPOSE = GoldenCase.INFORMATION_PROPOSE; + + /** A text one character over the default limit must be rejected with the dedicated code before any LLM call. */ + @Test + void rejectsFromTextOverTheDefaultLimitBeforeAnyLlmCall() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1), + PROPOSE.context(), + PROPOSE.template())); + + assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, failure.getCode()); + assertTrue( + failure.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1)), + "the violation message must state the actual length but was: " + failure.getMessage()); + assertTrue( + failure.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS)), + "the violation message must state the limit but was: " + failure.getMessage()); + assertEquals(0, llm.calls, "an oversized text must never reach the LLM"); + } + + /** A text at exactly the default limit passes the gate and reaches the extraction LLM call. */ + @Test + void acceptsFromTextAtExactlyTheDefaultLimit() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), + PROPOSE.context(), + PROPOSE.template())); + + assertNotEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, failure.getCode()); + assertTrue(llm.calls >= 1, "a text at exactly the limit must reach the LLM"); + } + + /** An oversized prompt of the validation pipeline is rejected with the dedicated code before any LLM call. */ + @Test + void rejectsValidationPromptOverTheDefaultLimitBeforeAnyLlmCall() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1), + PROPOSE.context(), + Map.of("type", "object"), + PROPOSE.template())); + + assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, failure.getCode()); + assertEquals(0, llm.calls, "an oversized prompt must never reach the LLM"); + } + + /** A validation prompt at exactly the default limit passes the gate and reaches the semantic validation LLM call. */ + @Test + void acceptsValidationPromptAtExactlyTheDefaultLimit() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), + PROPOSE.context(), + Map.of("type", "object"), + PROPOSE.template())); + + assertNotEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, failure.getCode()); + assertTrue(llm.calls >= 1, "a prompt at exactly the limit must reach the LLM"); + } + + /** A configured limit below the default drives the rejection: a text one character over it fails fast. */ + @Test + void customLimitDrivesTheRejection() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .llmClient(llm) + .maxTextChars(10) + .build(); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText("01234567890", PROPOSE.context(), PROPOSE.template())); + + assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, failure.getCode()); + assertEquals(0, llm.calls); + } + + /** The builder rejects a non-positive limit as a programming error. */ + @Test + void builderRejectsNonPositiveLimit() { + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .maxTextChars(0) + .build()); + + assertTrue( + failure.getMessage().contains("max text chars"), + "the failure must point at the limit but was: " + failure.getMessage()); + } + + private static NegotiationGenerationOrchestrator defaultOrchestrator(LLMClient llmClient) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .llmClient(llmClient) + .build(); + } + + private static String textOfLength(int length) { + return "x".repeat(length); + } + + /** + * LLM client stub recording every call and returning an empty payload, so the tests can assert both that a call + * happened and that the pipeline fails downstream of the length gate instead of inside it. + */ + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + return new LLMResponse("", "counting-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 7627ffb1..e5119f40 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -70,6 +70,9 @@ public A2ATServer(Path envPath) { /** * Checks one processed task prompt for server-side compliance. * + *

        An input longer than {@code A2AT_INPUT_TEXT_MAX_CHARS} characters (default 12288) fails fast with the code + * {@code input_text_too_long} before any LLM call. + * * @param processedPromptText processed task prompt text * @return compliance result containing only success state or failure details */ @@ -223,8 +226,9 @@ public MetadataContent generateNegotiationAbortPromptFromData( * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * content contradicts the phase + * content misses a required field, {@code negotiation_invalid_input} when the text is blank or the extracted + * content contradicts the phase, or {@code input_text_too_long} when the text exceeds the configured maximum + * length (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) */ public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, @@ -252,8 +256,9 @@ public MetadataContent generateNegotiationProposePromptFromText( * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * conclusion is not {@code Accept} + * content misses a required field, {@code negotiation_invalid_input} when the text is blank or the extracted + * conclusion is not {@code Accept}, or {@code input_text_too_long} when the text exceeds the configured + * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) */ public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, @@ -281,8 +286,9 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * conclusion is not {@code Reject} + * content misses a required field, {@code negotiation_invalid_input} when the text is blank or the extracted + * conclusion is not {@code Reject}, or {@code input_text_too_long} when the text exceeds the configured + * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) */ public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, @@ -308,7 +314,9 @@ public MetadataContent generateNegotiationRejectPromptFromText( * {@code template_not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses the termination reason, or {@code negotiation_invalid_input} when the text is blank + * content misses the termination reason, {@code negotiation_invalid_input} when the text is blank, or + * {@code input_text_too_long} when the text exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) */ public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, @@ -395,7 +403,9 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * {@code negotiation_rule_violation} when the negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing + * {@code template_not_found} when the semantic validation prompt resources are missing, or + * {@code input_text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) */ public FilledParamData validateProposePromptAndDataFilling( @NonNull String prompt, @@ -427,7 +437,9 @@ public FilledParamData validateProposePromptAndDataFilling( * {@code negotiation_rule_violation} when the negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing + * {@code template_not_found} when the semantic validation prompt resources are missing, or + * {@code input_text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) */ public FilledParamData validateAcceptPromptAndDataFilling( @NonNull String prompt, @@ -459,7 +471,9 @@ public FilledParamData validateAcceptPromptAndDataFilling( * {@code negotiation_rule_violation} when the negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing + * {@code template_not_found} when the semantic validation prompt resources are missing, or + * {@code input_text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) */ public FilledParamData validateRejectPromptAndDataFilling( @NonNull String prompt, @@ -490,7 +504,9 @@ public FilledParamData validateRejectPromptAndDataFilling( * {@code negotiation_rule_violation} when the negotiation context violates a rule, * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing + * {@code template_not_found} when the semantic validation prompt resources are missing, or + * {@code input_text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) */ public FilledParamData validateAbortPromptAndDataFilling( @NonNull String prompt, @@ -516,7 +532,9 @@ public FilledParamData validateAbortPromptAndDataFilling( * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code * {@code validation_semantic_rejected} when the semantic validation rejects the content, * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing + * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing, or + * {@code input_text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) */ public FilledParamData validateTaskPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { @@ -539,7 +557,9 @@ public FilledParamData validateTaskPromptAndDataFilling( * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code * {@code validation_semantic_rejected} when the semantic validation rejects the content, * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing + * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing, or + * {@code input_text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) */ public FilledParamData validateNotificationPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { @@ -562,7 +582,9 @@ public FilledParamData validateNotificationPromptAndDataFilling( * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code * {@code validation_semantic_rejected} when the semantic validation rejects the content, * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing + * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing, or + * {@code input_text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) */ public FilledParamData validateAuthPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index 2ecfc1a7..a80261ed 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -21,6 +21,7 @@ import net.openan.a2at.sdk.prompt.validation.DefaultContentValidator; import net.openan.a2at.sdk.server.compliance.DefaultServerPromptComplianceOrchestrator; import net.openan.a2at.sdk.server.metadata.LlmBackedPromptMetadataExtractor; +import net.openan.a2at.sdk.server.validation.InputLimitedContentValidator; import net.openan.a2at.sdk.server.validation.LlmBackedPromptSemanticValidator; import org.jspecify.annotations.Nullable; @@ -132,8 +133,8 @@ public synchronized DefaultServerPromptComplianceOrchestrator buildPromptComplia slotSchemaLoader, new DefaultStructuredPromptSlotValueExtractor( client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)), - new LlmBackedPromptSemanticValidator( - client, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt)); + new LlmBackedPromptSemanticValidator(client, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt), + config.inputLimits().maxTextChars()); return promptComplianceOrchestrator; } @@ -221,12 +222,14 @@ private ContentValidator buildContentValidator(String extensionName) { require(config, "Unified SDK config must be configured."); requireSupportedConfig(); require(envPath, "Unified SDK env path must be configured."); - return new DefaultContentValidator( - extensionName, - config.prompt().language(), - config.llm().maxAttempts(), - llmClient(), - promptResourceAccess().templateLoader()); + return new InputLimitedContentValidator( + new DefaultContentValidator( + extensionName, + config.prompt().language(), + config.llm().maxAttempts(), + llmClient(), + promptResourceAccess().templateLoader()), + config.inputLimits().maxTextChars()); } private static void require(Object value, String message) { diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java index 837ce894..9af14893 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java @@ -1,5 +1,7 @@ package net.openan.a2at.sdk.server.compliance; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.InputLimitConfig; import net.openan.a2at.sdk.server.metadata.ServerPromptMetadataExtractor; import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; import net.openan.a2at.sdk.server.model.PromptComplianceFailure; @@ -14,10 +16,15 @@ */ public final class DefaultServerPromptComplianceOrchestrator implements ServerPromptComplianceOrchestrator { + /** Compliance stage reported when the input gate rejects an oversized prompt before any LLM call. */ + private static final String INPUT_GATE_STAGE = "input_gate"; + private final ServerPromptMetadataExtractor metadataExtractor; private final ServerPromptSemanticValidator semanticValidator; + private final int maxTextChars; + /** * Creates a compliance orchestrator. * @@ -26,12 +33,35 @@ public final class DefaultServerPromptComplianceOrchestrator implements ServerPr */ public DefaultServerPromptComplianceOrchestrator( ServerPromptMetadataExtractor metadataExtractor, ServerPromptSemanticValidator semanticValidator) { + this(metadataExtractor, semanticValidator, InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); + } + + /** + * Creates a compliance orchestrator with an explicit free-text input limit. + * + * @param metadataExtractor prompt metadata extractor + * @param semanticValidator semantic validator + * @param maxTextChars maximum length in characters accepted for the processed prompt text + */ + public DefaultServerPromptComplianceOrchestrator( + ServerPromptMetadataExtractor metadataExtractor, + ServerPromptSemanticValidator semanticValidator, + int maxTextChars) { this.metadataExtractor = metadataExtractor; this.semanticValidator = semanticValidator; + this.maxTextChars = maxTextChars; } @Override public PromptComplianceResult checkTaskPrompt(String processedPromptText) { + if (InputLimitConfig.isTooLong(processedPromptText, maxTextChars)) { + return new PromptComplianceResult( + false, + new PromptComplianceFailure( + A2ATErrorCodes.INPUT_TEXT_TOO_LONG, + InputLimitConfig.violationMessage(processedPromptText, maxTextChars), + INPUT_GATE_STAGE)); + } try { ProcessedPromptMetadata metadata = metadataExtractor.extract(processedPromptText); semanticValidator.validate(processedPromptText, metadata); diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidator.java new file mode 100644 index 00000000..337bf66b --- /dev/null +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidator.java @@ -0,0 +1,46 @@ +package net.openan.a2at.sdk.server.validation; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.ContentValidator; + +/** + * Free-text input gate wrapped around one delegated content validator. + * + *

        The gate rejects an oversized prompt with the code {@code input_text_too_long} before the delegated pipeline + * starts, so no LLM call is made for an input that could not fit the LLM context anyway. The limit is configured + * through {@code A2AT_INPUT_TEXT_MAX_CHARS} and defaults to {@link InputLimitConfig#DEFAULT_MAX_TEXT_CHARS} + * characters. + * + * @since 2026-08 + */ +public final class InputLimitedContentValidator implements ContentValidator { + + private final ContentValidator delegate; + + private final int maxTextChars; + + /** + * Creates one gating validator around the given delegate. + * + * @param delegate content validator carrying the actual validation pipeline + * @param maxTextChars maximum length in characters accepted for the prompt text + */ + public InputLimitedContentValidator(ContentValidator delegate, int maxTextChars) { + this.delegate = delegate; + this.maxTextChars = maxTextChars; + } + + @Override + public FilledParamData validate(String prompt, Map schema, TemplateUri templateUri) { + if (InputLimitConfig.isTooLong(prompt, maxTextChars)) { + throw new ContentValidationException( + A2ATErrorCodes.INPUT_TEXT_TOO_LONG, InputLimitConfig.violationMessage(prompt, maxTextChars)); + } + return delegate.validate(prompt, schema, templateUri); + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java new file mode 100644 index 00000000..1a2afb1f --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java @@ -0,0 +1,144 @@ +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class InputTextLengthLimitTest { + + private static final String TEST_MOCK_PROVIDER = "test-input-limit-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, CountingClient.class); + } + } + + @BeforeEach + void resetCounter() { + CountingClient.resetCallCount(); + } + + @TempDir + Path tempDir; + + @Test + void checkTaskPromptFailsFastWithoutLlmCallWhenInputExceedsConfiguredLimit() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("10")); + String oversizedInput = "a".repeat(11); + + PromptComplianceResult result = server.checkTaskPrompt(oversizedInput); + + assertEquals(false, result.success()); + assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, result.failure().code()); + assertEquals("input_gate", result.failure().stage()); + assertEquals(0, CountingClient.callCount(), "No LLM call may happen for an oversized input"); + } + + @Test + void checkTaskPromptDoesNotFailForLengthWhenInputIsExactlyAtDefaultLimit() throws IOException { + A2ATServer server = new A2ATServer(writeEnv(null)); + String boundaryInput = "a".repeat(12288); + + PromptComplianceResult result = server.checkTaskPrompt(boundaryInput); + + assertNotEquals( + A2ATErrorCodes.INPUT_TEXT_TOO_LONG, + result.success() ? null : result.failure().code(), + "An input exactly at the default limit must not be rejected for its length"); + } + + @Test + void validateContentPromptAndDataFillingFailsFastWithoutLlmCallWhenPromptExceedsConfiguredLimit() + throws IOException { + A2ATServer server = new A2ATServer(writeEnv("10")); + String oversizedPrompt = "a".repeat(11); + Map schema = Map.of(); + + ContentValidationException taskError = assertThrows( + ContentValidationException.class, + () -> server.validateTaskPromptAndDataFilling(oversizedPrompt, schema, StandardTemplates.ENERGY_SAVING)); + ContentValidationException notificationError = assertThrows( + ContentValidationException.class, + () -> server.validateNotificationPromptAndDataFilling( + oversizedPrompt, schema, StandardTemplates.SUBSCRIBE_INCIDENT)); + ContentValidationException authError = assertThrows( + ContentValidationException.class, + () -> server.validateAuthPromptAndDataFilling( + oversizedPrompt, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT)); + + assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, taskError.getCode()); + assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, notificationError.getCode()); + assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, authError.getCode()); + assertEquals(0, CountingClient.callCount(), "No LLM call may happen for an oversized prompt"); + } + + private Path writeEnv(String maxTextChars) throws IOException { + Path envFile = tempDir.resolve("server.env"); + String limitEntry = maxTextChars == null ? "" : "A2AT_INPUT_TEXT_MAX_CHARS=" + maxTextChars + "\n"; + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-input-limit-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + + limitEntry); + return envFile; + } + + public static final class CountingClient implements LLMClient { + + private static final AtomicInteger CALL_COUNT = new AtomicInteger(0); + + private final LLMClientConfig config; + + public CountingClient(LLMClientConfig config) { + this.config = config; + } + + static void resetCallCount() { + CALL_COUNT.set(0); + } + + static int callCount() { + return CALL_COUNT.get(); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + CALL_COUNT.incrementAndGet(); + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java index 6a1fe8e5..a64804f7 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java @@ -10,6 +10,7 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.InputLimitConfig; import net.openan.a2at.sdk.core.model.LlmConfig; import net.openan.a2at.sdk.core.model.NegotiationConfig; import net.openan.a2at.sdk.core.model.PromptComplianceConfig; @@ -99,6 +100,7 @@ void llmClientConfigDerivedFromConfigLlmNotFromEnvPath() throws IOException { null, 3, List.of()), + new InputLimitConfig(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), new NegotiationConfig("in_memory"), new PromptComplianceConfig(false)); diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java index 2c078dab..6f24a1c6 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java @@ -66,6 +66,39 @@ void checkTaskPromptReturnsFailureWhenSemanticValidationFails() { assertEquals("slot_validation", result.failure().stage()); } + @Test + void checkTaskPromptReturnsInputTooLongFailureWithoutRunningThePipelineWhenOverLimit() { + RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor( + new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A"))); + RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); + DefaultServerPromptComplianceOrchestrator orchestrator = + new DefaultServerPromptComplianceOrchestrator(extractor, validator, 5); + String oversizedInput = "a".repeat(6); + + PromptComplianceResult result = orchestrator.checkTaskPrompt(oversizedInput); + + assertEquals(false, result.success()); + assertEquals("input_text_too_long", result.failure().code()); + assertEquals("input_gate", result.failure().stage()); + assertEquals(null, extractor.lastProcessedPromptText, "Extractor must not run for an oversized input"); + assertEquals(null, validator.lastProcessedPromptText, "Validator must not run for an oversized input"); + } + + @Test + void checkTaskPromptRunsThePipelineWhenInputIsExactlyAtLimit() { + RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor( + new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A"))); + RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); + DefaultServerPromptComplianceOrchestrator orchestrator = + new DefaultServerPromptComplianceOrchestrator(extractor, validator, 5); + String boundaryInput = "a".repeat(5); + + PromptComplianceResult result = orchestrator.checkTaskPrompt(boundaryInput); + + assertTrue(result.success()); + assertEquals(boundaryInput, extractor.lastProcessedPromptText); + } + private static final class RecordingPromptMetadataExtractor implements ServerPromptMetadataExtractor { private final ProcessedPromptMetadata metadata; private final PromptComplianceCheckException exception; diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidatorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidatorTest.java new file mode 100644 index 00000000..f61a64ae --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidatorTest.java @@ -0,0 +1,52 @@ +package net.openan.a2at.sdk.server.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import org.junit.jupiter.api.Test; + +class InputLimitedContentValidatorTest { + + @Test + void validateThrowsInputTooLongWithoutDelegationWhenPromptExceedsLimit() { + RecordingContentValidator delegate = new RecordingContentValidator(); + InputLimitedContentValidator validator = new InputLimitedContentValidator(delegate, 5); + String oversizedPrompt = "a".repeat(6); + + ContentValidationException error = assertThrows( + ContentValidationException.class, + () -> validator.validate(oversizedPrompt, Map.of(), StandardTemplates.ENERGY_SAVING)); + + assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, error.getCode()); + assertEquals(null, delegate.lastPrompt, "Delegate must not run for an oversized prompt"); + } + + @Test + void validateDelegatesWhenPromptIsExactlyAtLimit() { + RecordingContentValidator delegate = new RecordingContentValidator(); + InputLimitedContentValidator validator = new InputLimitedContentValidator(delegate, 5); + String boundaryPrompt = "a".repeat(5); + + FilledParamData result = validator.validate(boundaryPrompt, Map.of(), StandardTemplates.ENERGY_SAVING); + + assertEquals(boundaryPrompt, delegate.lastPrompt); + assertEquals(FilledParamData.class, result.getClass()); + } + + private static final class RecordingContentValidator implements ContentValidator { + + private String lastPrompt; + + @Override + public FilledParamData validate(String prompt, Map schema, net.openan.a2at.sdk.core.model.TemplateUri templateUri) { + this.lastPrompt = prompt; + return new FilledParamData(Map.of()); + } + } +} diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index cf05dbf3..01c505d4 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -327,6 +327,7 @@ A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_API_KEY={your_llm_api_key} A2AT_LLM_BASE_URL=https://api.deepseek.com A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory +A2AT_INPUT_TEXT_MAX_CHARS=16384 ``` > `A2AT_LLM_API_KEY` is the key used to **call the external large model**. Keep it safe. @@ -334,6 +335,8 @@ A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory > The SDK connects to external large models through OpenAI-compatible interfaces. `A2AT_LLM_PROVIDER` currently supports only `openai`; when connecting to OpenAI-compatible services such as DeepSeek, specify the service address with `A2AT_LLM_BASE_URL` and the model name with `A2AT_LLM_MODEL`. > > The prompt resource source is controlled by `A2AT_PROMPT_SOURCE_TYPE`: the default `classpath` loads the resources bundled in the `a2a-t-resources` jar; when set to `local_file`, specify the local resource root directory with `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` (relative paths are resolved against the directory of the `.env` file). +> +> Free-text inputs are length-guarded before any LLM call: every facade entry point that accepts a natural-language `String` (the `FromText` generation methods, `generateTaskPrompt` with a text input, and the prompt-validation entry points such as `checkTaskPrompt` and `validate*PromptAndDataFilling`) rejects an input longer than `A2AT_INPUT_TEXT_MAX_CHARS` characters (`String.length()`) with the error code `input_text_too_long`, so oversized inputs fail fast instead of overflowing the LLM context. The key defaults to `16384` (16×1024); invalid or non-positive values fall back to the default with a warning log. The guard never truncates: the caller keeps full control over how to shorten the input. Structured (`Map`) inputs are not checked. #### Step3 Initialize the AgentCard diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 0395ab41..4ef75c37 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -327,6 +327,7 @@ A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_API_KEY={your_llm_api_key} A2AT_LLM_BASE_URL=https://api.deepseek.com A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory +A2AT_INPUT_TEXT_MAX_CHARS=16384 ``` > `A2AT_LLM_API_KEY` 是**调用外部大模型**使用的密钥,请妥善保存 @@ -334,6 +335,8 @@ A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory > SDK 通过 OpenAI-compatible 接入外部大模型,`A2AT_LLM_PROVIDER` 当前仅支持 `openai`;接入 DeepSeek 等 OpenAI 兼容服务时,通过 `A2AT_LLM_BASE_URL` 指定服务地址、`A2AT_LLM_MODEL` 指定模型名。 > > 提示词资源来源由 `A2AT_PROMPT_SOURCE_TYPE` 控制:默认 `classpath` 从 `a2a-t-resources` jar 内置资源加载;配置为 `local_file` 时,通过 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 指定本地资源根目录(相对路径相对于 `.env` 文件所在目录解析)。 +> +> 自由文本输入在任何 LLM 调用之前都会做长度防护:所有接受自然语言 `String` 的 Facade 入口(`FromText` 生成类方法、文本形式的 `generateTaskPrompt`,以及 `checkTaskPrompt`、`validate*PromptAndDataFilling` 等校验入口)在输入超过 `A2AT_INPUT_TEXT_MAX_CHARS` 字符(按 `String.length()` 计)时,会以错误码 `input_text_too_long` 快速失败,避免超长输入撑爆 LLM 上下文。该键默认 `16384`(16×1024);非法或非正值回退默认值并输出警告日志。防护不做截断:是否以及如何缩短输入完全由调用方决定。结构化(`Map`)输入不做该检查。 #### Step3 初始化AgentCard diff --git a/env.example b/env.example index 026d844f..81c4f428 100644 --- a/env.example +++ b/env.example @@ -10,6 +10,12 @@ A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= # Enables or disables prompt compliance checks. A2AT_PROMPT_COMPLIANCE_ENABLED=false +# Input limits +# Maximum length in characters (String.length()) accepted for free-text inputs (FromText generation and +# prompt validation entry points) before they reach an LLM step; oversized inputs fail fast with the +# error code input_text_too_long instead of overflowing the LLM context. +A2AT_INPUT_TEXT_MAX_CHARS=12288 + # LLM runtime # Selects the LLM provider used by the shared client. A2AT_LLM_PROVIDER= From 72bab51fd090497f1d7a885bfbb63bc79d2d4abc Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 28 Aug 2026 11:40:17 +0800 Subject: [PATCH 219/295] feat(input-limit): raise default free-text limit to 16384 chars Bump A2AT_INPUT_TEXT_MAX_CHARS default from 12288 (12K) to 16384 (16K chars): since the guard counts String.length() characters, 16K chars corresponds to roughly 12K tokens of Chinese text, which is the intended budget. Docs, env example and boundary tests updated. Signed-off-by: codetalks-hw --- .../a2at/sdk/core/model/InputLimitConfig.java | 2 +- .../sdk/core/model/InputLimitConfigTest.java | 6 ++--- .../openan/a2at/sdk/server/A2ATServer.java | 24 +++++++++---------- .../server/api/InputTextLengthLimitTest.java | 2 +- env.example | 2 +- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/InputLimitConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/InputLimitConfig.java index 814e1c73..3926fa9a 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/InputLimitConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/InputLimitConfig.java @@ -16,7 +16,7 @@ public record InputLimitConfig(int maxTextChars) { /** Default maximum length in characters ({@link String#length()}) accepted for free-text inputs. */ - public static final int DEFAULT_MAX_TEXT_CHARS = 12288; + public static final int DEFAULT_MAX_TEXT_CHARS = 16384; private static final String NON_NUMERIC_OR_NON_POSITIVE_LOG_FORMAT = "Input max text chars value is not a valid positive integer, falling back to default. key={} raw_value={}" diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/InputLimitConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/InputLimitConfigTest.java index 8a145921..83260a8d 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/InputLimitConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/InputLimitConfigTest.java @@ -20,7 +20,7 @@ void fromMapFallsBackToDefaultWhenKeyAbsent() { InputLimitConfig config = InputLimitConfig.fromMap(Map.of()); assertEquals(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS, config.maxTextChars()); - assertEquals(12288, config.maxTextChars()); + assertEquals(16384, config.maxTextChars()); } @Test @@ -70,10 +70,10 @@ void isTooLongComparesByLengthAgainstLimit() { @Test void violationMessageStatesLengthLimitAndKey() { - String message = InputLimitConfig.violationMessage("a".repeat(13000), 12288); + String message = InputLimitConfig.violationMessage("a".repeat(13000), 16384); assertTrue(message.contains("13000")); - assertTrue(message.contains("12288")); + assertTrue(message.contains("16384")); assertTrue(message.contains(A2ATConfigKeys.Input.MAX_TEXT_CHARS)); } } diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index e5119f40..a619c91e 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -70,7 +70,7 @@ public A2ATServer(Path envPath) { /** * Checks one processed task prompt for server-side compliance. * - *

        An input longer than {@code A2AT_INPUT_TEXT_MAX_CHARS} characters (default 12288) fails fast with the code + *

        An input longer than {@code A2AT_INPUT_TEXT_MAX_CHARS} characters (default 16384) fails fast with the code * {@code input_text_too_long} before any LLM call. * * @param processedPromptText processed task prompt text @@ -228,7 +228,7 @@ public MetadataContent generateNegotiationAbortPromptFromData( * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted * content misses a required field, {@code negotiation_invalid_input} when the text is blank or the extracted * content contradicts the phase, or {@code input_text_too_long} when the text exceeds the configured maximum - * length (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) + * length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, @@ -258,7 +258,7 @@ public MetadataContent generateNegotiationProposePromptFromText( * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted * content misses a required field, {@code negotiation_invalid_input} when the text is blank or the extracted * conclusion is not {@code Accept}, or {@code input_text_too_long} when the text exceeds the configured - * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) + * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, @@ -288,7 +288,7 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted * content misses a required field, {@code negotiation_invalid_input} when the text is blank or the extracted * conclusion is not {@code Reject}, or {@code input_text_too_long} when the text exceeds the configured - * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) + * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, @@ -316,7 +316,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted * content misses the termination reason, {@code negotiation_invalid_input} when the text is blank, or * {@code input_text_too_long} when the text exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, @@ -405,7 +405,7 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing, or * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public FilledParamData validateProposePromptAndDataFilling( @NonNull String prompt, @@ -439,7 +439,7 @@ public FilledParamData validateProposePromptAndDataFilling( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing, or * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public FilledParamData validateAcceptPromptAndDataFilling( @NonNull String prompt, @@ -473,7 +473,7 @@ public FilledParamData validateAcceptPromptAndDataFilling( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing, or * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public FilledParamData validateRejectPromptAndDataFilling( @NonNull String prompt, @@ -506,7 +506,7 @@ public FilledParamData validateRejectPromptAndDataFilling( * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code template_not_found} when the semantic validation prompt resources are missing, or * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public FilledParamData validateAbortPromptAndDataFilling( @NonNull String prompt, @@ -534,7 +534,7 @@ public FilledParamData validateAbortPromptAndDataFilling( * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing, or * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public FilledParamData validateTaskPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { @@ -559,7 +559,7 @@ public FilledParamData validateTaskPromptAndDataFilling( * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing, or * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public FilledParamData validateNotificationPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { @@ -584,7 +584,7 @@ public FilledParamData validateNotificationPromptAndDataFilling( * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing, or * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 12288) + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public FilledParamData validateAuthPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java index 1a2afb1f..77d8684e 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java @@ -59,7 +59,7 @@ void checkTaskPromptFailsFastWithoutLlmCallWhenInputExceedsConfiguredLimit() thr @Test void checkTaskPromptDoesNotFailForLengthWhenInputIsExactlyAtDefaultLimit() throws IOException { A2ATServer server = new A2ATServer(writeEnv(null)); - String boundaryInput = "a".repeat(12288); + String boundaryInput = "a".repeat(16384); PromptComplianceResult result = server.checkTaskPrompt(boundaryInput); diff --git a/env.example b/env.example index 81c4f428..52672b58 100644 --- a/env.example +++ b/env.example @@ -14,7 +14,7 @@ A2AT_PROMPT_COMPLIANCE_ENABLED=false # Maximum length in characters (String.length()) accepted for free-text inputs (FromText generation and # prompt validation entry points) before they reach an LLM step; oversized inputs fail fast with the # error code input_text_too_long instead of overflowing the LLM context. -A2AT_INPUT_TEXT_MAX_CHARS=12288 +A2AT_INPUT_TEXT_MAX_CHARS=16384 # LLM runtime # Selects the LLM provider used by the shared client. From 0907759c7359c3edc977e39a412a171588fb73a1 Mon Sep 17 00:00:00 2001 From: gcw_mr3RVhmf Date: Fri, 28 Aug 2026 11:50:53 +0800 Subject: [PATCH 220/295] refactor(sample): decouple negotiation validation schemas Signed-off-by: gcw_mr3RVhmf --- a2a-t-sample/README.zh-CN.md | 2 + .../shared/InformationNegotiationSchemas.java | 69 +++++++++++----- .../shared/mock/NegotiationMockLLMClient.java | 12 +-- .../InformationNegotiationSchemasTest.java | 82 +++++++++++++++---- .../NegotiationSampleFlowTest.java | 29 +++++-- 5 files changed, 143 insertions(+), 51 deletions(-) diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 75defaea..6b53843a 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -140,6 +140,8 @@ java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env **LLM 调用证据**:每个步骤还记录 `llm_calls`——该步骤内每次大模型调用的完整请求(system + user prompt 全文、JSON schema、temperature、max_tokens)与响应(content、model、usage、耗时),调用失败时也记录请求与错误。用例失败时可直接从报告看到模型实际收到的 prompt(含槽位描述、值约束、schema 注入后的最终形态),据此反向定位是哪段提示词/约束导致了误判并调优。 +**校验参数 schema**:专线投诉协商样例的三个校验接口使用独立的业务无关参数 schema,定义在 `shared/InformationNegotiationSchemas`:`propose` 提取 `items[{name, requirement}]` 及可选的 `relationship`,`accept` 提取 `items[{name, value}]`,`reject` 提取 `items[{name, reason}]`。schema 只约束提取结果的结构,具体信息项名称、要求、值和原因均从协商报文中提取,不包含场景固定字段或枚举值。 + **协议约定**(两条常见问题): 1. **服务端发起协商后,客户端是否还需调用 validate?** 是。SDK 在 client/server 两个门面上提供对称的 `validate*PromptAndDataFilling` API:发送方出站自检(步骤 4),接收方入站校验(步骤 5/7)。客户端收到 propose 后应调 `validateProposePromptAndDataFilling` 提取需要补充的槽位清单,再据此补槽。 diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/InformationNegotiationSchemas.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/InformationNegotiationSchemas.java index 46169c48..07b73337 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/InformationNegotiationSchemas.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/InformationNegotiationSchemas.java @@ -5,7 +5,12 @@ import java.util.List; import java.util.Map; -/** Scenario-specific caller schemas for the private-line complaint information negotiation sample. */ +/** + * Business-neutral, phase-specific caller schemas for information-negotiation validation. + * + *

        The three validation APIs intentionally use independent parameter contracts. The contracts describe the + * shape of the extracted result while the information-item names and values come from the negotiation message. + */ public final class InformationNegotiationSchemas { private static final Map PROPOSE = createProposeSchema(); @@ -32,43 +37,65 @@ public static Map reject() { private static Map createProposeSchema() { Map properties = new LinkedHashMap<>(); properties.put( - "access_port_name", - stringSchema("Requested access-port information, including the accepted physical or logical port format")); + "items", + itemArraySchema( + itemSchema( + "requirement", + "Meaning, format, constraint, or example requested for the information item"), + "Requested information items and their requirements")); properties.put( - "complaint_category", - stringSchema("Requested complaint category and its allowed private-line complaint values")); - return objectSchema(properties, List.of("access_port_name", "complaint_category")); + "relationship", + nullableStringSchema("Relationship among requested information items, or null when unspecified")); + return objectSchema(properties, List.of("items")); } private static Map createAcceptSchema() { Map properties = new LinkedHashMap<>(); properties.put( - "access_port_name", - stringSchema("Physical or logical access-port name supplied for private-line diagnosis")); - properties.put( - "complaint_category", - Map.of( - "type", "string", - "description", "Private-line complaint category", - "enum", List.of("专线中断", "专线质差"))); - return objectSchema(properties, List.of("access_port_name", "complaint_category")); + "items", + itemArraySchema( + itemSchema("value", "Value supplied for the information item"), + "Information items and values supplied by the accepting party")); + return objectSchema(properties, List.of("items")); } private static Map createRejectSchema() { Map properties = new LinkedHashMap<>(); properties.put( - "access_port_name", - stringSchema("Reason why the access-port name cannot be supplied")); - properties.put( - "complaint_category", - stringSchema("Reason why the complaint category cannot be supplied")); - return objectSchema(properties, List.of("access_port_name", "complaint_category")); + "items", + itemArraySchema( + itemSchema("reason", "Reason why the information item cannot be supplied"), + "Information items that cannot be supplied and their reasons")); + return objectSchema(properties, List.of("items")); + } + + private static Map itemArraySchema(Map itemSchema, String description) { + Map schema = new LinkedHashMap<>(); + schema.put("type", "array"); + schema.put("items", itemSchema); + schema.put("minItems", 1); + schema.put("description", description); + return Collections.unmodifiableMap(schema); + } + + private static Map itemSchema(String valueName, String valueDescription) { + Map properties = new LinkedHashMap<>(); + properties.put("name", stringSchema("Name of the information item")); + properties.put(valueName, nullableStringSchema(valueDescription)); + return objectSchema(properties, List.of("name", valueName)); } private static Map stringSchema(String description) { return Map.of("type", "string", "description", description); } + private static Map nullableStringSchema(String description) { + Map schema = new LinkedHashMap<>(); + schema.put("type", List.of("string", "null")); + schema.put("description", description); + return Collections.unmodifiableMap(schema); + } + private static Map objectSchema(Map properties, List required) { Map schema = new LinkedHashMap<>(); schema.put("type", "object"); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLLMClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLLMClient.java index f1521df6..324ff245 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLLMClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLLMClient.java @@ -32,18 +32,18 @@ public final class NegotiationMockLLMClient implements LLMClient { """; private static final String PROPOSE_PARAMS = """ - {"access_port_name":"物理端口或逻辑端口名称", - "complaint_category":"专线中断或专线质差"} + {"items":[{"name":"接入端口名称","requirement":"物理端口或逻辑端口名称"}, + {"name":"投诉分类","requirement":"专线中断或专线质差"}],"relationship":null} """; private static final String ACCEPT_PARAMS = """ - {"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)", - "complaint_category":"专线质差"} + {"items":[{"name":"接入端口名称","value":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)"}, + {"name":"投诉分类","value":"专线质差"}]} """; private static final String REJECT_PARAMS = """ - {"access_port_name":"当前账号没有资源系统查询权限", - "complaint_category":"当前账号没有资源系统查询权限"} + {"items":[{"name":"接入端口名称","reason":"当前账号没有资源系统查询权限"}, + {"name":"投诉分类","reason":"当前账号没有资源系统查询权限"}]} """; public NegotiationMockLLMClient(LLMClientConfig config) { diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/InformationNegotiationSchemasTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/InformationNegotiationSchemasTest.java index 524e7a56..1121ad62 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/InformationNegotiationSchemasTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/InformationNegotiationSchemasTest.java @@ -2,53 +2,101 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.List; import java.util.Map; +import java.util.Set; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.InformationNegotiationSchemas; import org.junit.jupiter.api.Test; class InformationNegotiationSchemasTest { @Test - void proposeSchemaDefinesRequestedComplaintInformation() { + void proposeSchemaDefinesRequestedItemsAndTheirRequirements() { Map schema = InformationNegotiationSchemas.propose(); Map properties = propertiesOf(schema); - assertEquals("object", schema.get("type")); - assertFalse((Boolean) schema.get("additionalProperties")); - assertEquals(List.of("access_port_name", "complaint_category"), schema.get("required")); - assertStringProperty(properties, "access_port_name"); - assertStringProperty(properties, "complaint_category"); - assertThrows(UnsupportedOperationException.class, () -> schema.put("unexpected", true)); + assertBaseSchema(schema, List.of("items")); + assertEquals(Set.of("items", "relationship"), properties.keySet()); + assertItemArray(properties.get("items"), "requirement"); + assertNullableString((Map) properties.get("relationship")); + assertFalse(properties.containsKey("access_port_name")); + assertFalse(properties.containsKey("complaint_category")); } @Test - void acceptSchemaDefinesSuppliedComplaintInformation() { + void acceptSchemaDefinesItemsAndSuppliedValues() { Map schema = InformationNegotiationSchemas.accept(); Map properties = propertiesOf(schema); - assertEquals(List.of("access_port_name", "complaint_category"), schema.get("required")); - assertStringProperty(properties, "access_port_name"); - assertEquals(List.of("专线中断", "专线质差"), ((Map) properties.get("complaint_category")).get("enum")); + assertBaseSchema(schema, List.of("items")); + assertEquals(Set.of("items"), properties.keySet()); + assertItemArray(properties.get("items"), "value"); } @Test - void rejectSchemaRequiresReasonForEachRequestedItem() { + void rejectSchemaDefinesItemsAndRejectionReasons() { Map schema = InformationNegotiationSchemas.reject(); Map properties = propertiesOf(schema); - assertEquals(List.of("access_port_name", "complaint_category"), schema.get("required")); - assertStringProperty(properties, "access_port_name"); - assertStringProperty(properties, "complaint_category"); + assertBaseSchema(schema, List.of("items")); + assertEquals(Set.of("items"), properties.keySet()); + assertItemArray(properties.get("items"), "reason"); + } + + @Test + void phaseSchemasAreIndependentAndDeeplyImmutable() { + Map propose = InformationNegotiationSchemas.propose(); + Map accept = InformationNegotiationSchemas.accept(); + Map reject = InformationNegotiationSchemas.reject(); + + assertNotSame(propose, accept); + assertNotSame(propose, reject); + assertNotSame(accept, reject); + assertFalse(propose.equals(accept)); + assertFalse(propose.equals(reject)); + assertFalse(accept.equals(reject)); + + assertImmutable(propose); + assertImmutable(propertiesOf(propose)); + Map proposeItems = (Map) propertiesOf(propose).get("items"); + assertImmutable(proposeItems); + assertImmutable((Map) proposeItems.get("items")); + assertImmutable(propertiesOf((Map) proposeItems.get("items"))); + } + + private static void assertBaseSchema(Map schema, List required) { + assertEquals("object", schema.get("type")); + assertEquals(false, schema.get("additionalProperties")); + assertEquals(required, schema.get("required")); } - private static void assertStringProperty(Map properties, String name) { - assertEquals("string", ((Map) properties.get(name)).get("type")); + private static void assertItemArray(Object rawArray, String valueName) { + Map array = (Map) rawArray; + assertEquals("array", array.get("type")); + assertEquals(1, array.get("minItems")); + assertEquals("object", ((Map) array.get("items")).get("type")); + + Map itemSchema = (Map) array.get("items"); + assertEquals(false, itemSchema.get("additionalProperties")); + assertEquals(List.of("name", valueName), itemSchema.get("required")); + assertEquals(Set.of("name", valueName), propertiesOf(itemSchema).keySet()); + assertEquals("string", ((Map) propertiesOf(itemSchema).get("name")).get("type")); + assertNullableString((Map) propertiesOf(itemSchema).get(valueName)); + } + + private static void assertNullableString(Map schema) { + assertEquals(List.of("string", "null"), schema.get("type")); } private static Map propertiesOf(Map schema) { return (Map) schema.get("properties"); } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static void assertImmutable(Map map) { + assertThrows(UnsupportedOperationException.class, () -> ((Map) map).put("unexpected", true)); + } } diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java index 7b82a6d0..29edc746 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java @@ -1,10 +1,13 @@ package net.openan.a2at.sample.private_line_complaint.negotiation; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Map; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationDecision; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; @@ -37,9 +40,12 @@ void acceptFlowCoversFourApis() throws IOException { assertEquals(ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, result.propose().extensionUri()); assertContextIsShared(result); assertEquals( - "P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)", - result.endingData().data().get("access_port_name")); - assertEquals("专线质差", result.endingData().data().get("complaint_category")); + List.of( + Map.of( + "name", "接入端口名称", + "value", "P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)"), + Map.of("name", "投诉分类", "value", "专线质差")), + result.endingData().data().get("items")); } @Test @@ -48,8 +54,11 @@ void rejectFlowCoversFourApis() throws IOException { assertEquals(NegotiationDecision.REJECT, result.decision()); assertContextIsShared(result); - assertEquals("当前账号没有资源系统查询权限", result.endingData().data().get("access_port_name")); - assertEquals("当前账号没有资源系统查询权限", result.endingData().data().get("complaint_category")); + assertEquals( + List.of( + Map.of("name", "接入端口名称", "reason", "当前账号没有资源系统查询权限"), + Map.of("name", "投诉分类", "reason", "当前账号没有资源系统查询权限")), + result.endingData().data().get("items")); } private NegotiationSampleFlow.NegotiationFlowResult run(NegotiationDecision decision) throws IOException { @@ -87,7 +96,13 @@ private static void assertContextIsShared(NegotiationSampleFlow.NegotiationFlowR assertEquals(result.requestContext().round(), ((Number) endingData.get("round")).intValue()); assertEquals(result.requestContext().maxRounds(), ((Number) proposeData.get("maxRounds")).intValue()); assertEquals(result.requestContext().maxRounds(), ((Number) endingData.get("maxRounds")).intValue()); - assertEquals("物理端口或逻辑端口名称", proposeData.get("access_port_name")); - assertEquals("专线中断或专线质差", proposeData.get("complaint_category")); + assertEquals( + List.of( + Map.of("name", "接入端口名称", "requirement", "物理端口或逻辑端口名称"), + Map.of("name", "投诉分类", "requirement", "专线中断或专线质差")), + proposeData.get("items")); + assertNull(proposeData.get("relationship")); + assertFalse(proposeData.containsKey("access_port_name")); + assertFalse(endingData.containsKey("complaint_category")); } } From 67e76d94a87f4e998f5cbdbba9fc81231f9c5679 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 28 Aug 2026 15:58:13 +0800 Subject: [PATCH 221/295] feat(negotiation): add confirm-request rounds to target and feasibility propose Signed-off-by: codetalks-hw --- .../a2at/sdk/corpus/TypedInputAssembler.java | 6 +- .../a2at/sdk/corpus/golden/GoldenInputs.java | 65 +- .../corpus/property/PropertyArbitraries.java | 52 +- .../en-US/feasibility_propose_confirm.md | 5 + .../golden/en-US/target_propose_confirm.md | 5 + .../zh-CN/feasibility_propose_confirm.md | 5 + .../golden/zh-CN/target_propose_confirm.md | 5 + .../test/resources/negotiation-cases/INDEX.md | 35 +- .../negotiation-cases/from-text/happy.json | 102 ++ .../scenarios/feasibility-flows.json | 73 ++ .../scenarios/target-flows.json | 77 ++ .../shared/llm-responses.json | 3 + .../content/FeasibilityProposeContent.java | 49 +- .../content/TargetProposeContent.java | 48 +- .../sdk/negotiation/content/Vocabulary.java | 4 + .../DefaultNegotiationContentExtractor.java | 51 +- .../FeasibilityProposeGenerator.java | 40 +- .../generation/NegotiationContentService.java | 9 +- .../NegotiationJsonSchemaBuilder.java | 2 + .../generation/TargetProposeGenerator.java | 30 +- .../negotiation/content/VocabularyTest.java | 23 +- .../ConditionalSectionRenderingTest.java | 82 +- .../FromDataProgrammingErrorMatrixTest.java | 8 +- .../generation/FromTextLlmPipelineTest.java | 4 + .../NegotiationContentExtractorTest.java | 57 + .../NegotiationGeneratorRegistryTest.java | 377 +++--- .../generation/NegotiationGeneratorsTest.java | 199 +++- .../generation/NegotiationGoldenCases.java | 1043 +++++++++-------- .../NegotiationJsonSchemaBuilderTest.java | 16 +- .../ValidateAndFillingDataPipelineTest.java | 1 + .../NegotiationErrorCodeUsageMatrixTest.java | 52 + .../en-US/vocabulary.json | 4 + .../zh-CN/vocabulary.json | 4 + .../feasibility_negotiation/en-US/system.md | 57 +- .../feasibility_negotiation/zh-CN/system.md | 57 +- .../en-US/system.md | 7 +- .../zh-CN/system.md | 7 +- .../target_negotiation/en-US/system.md | 32 +- .../target_negotiation/zh-CN/system.md | 32 +- .../accept-reject/v1/en-US/template.md | 10 +- .../accept-reject/v1/zh-CN/template.md | 10 +- .../propose/v1/en-US/template.md | 27 +- .../propose/v1/zh-CN/template.md | 34 +- .../accept-reject/v1/en-US/template.md | 19 +- .../accept-reject/v1/zh-CN/template.md | 19 +- .../propose/v1/en-US/template.md | 57 +- .../propose/v1/zh-CN/template.md | 57 +- .../fromdata/FromDataNegotiationSample.java | 44 +- .../fromtext/FromTextNegotiationSample.java | 21 + .../sample/negotiation/scenario.json | 11 + .../NegotiationFacadeOutputSymmetryTest.java | 4 +- docs/en/API_Reference.md | 6 +- "docs/zh/API\345\217\202\350\200\203.md" | 6 +- tools/corpus_index.py | 3 + tools/template_lint.py | 4 +- 55 files changed, 2161 insertions(+), 899 deletions(-) create mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/feasibility_propose_confirm.md create mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/target_propose_confirm.md create mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_propose_confirm.md create mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/target_propose_confirm.md diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java index 46301002..0b756b9f 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java @@ -84,12 +84,14 @@ private static NegotiationProposeContent proposeContent(JsonNode data, @Nullable requiredText(data, "target_negotiation_description"), optionalItems(data, "intent_understanding"), optionalItems(data, "alignment_and_clarification"), - optionalItems(data, "request_for_clarification")); + optionalItems(data, "request_for_clarification"), + optionalText(data, "target_confirm_request")); case FEASIBILITY -> new FeasibilityProposeContent( requiredText(data, "feasibility_negotiation_description"), action(data), optionalItems(data, "contents_to_evaluate"), - optionalItems(data, "infeasibility_details_and_proposal")); + optionalItems(data, "infeasibility_details_and_proposal"), + optionalText(data, "feasibility_confirm_request")); }; } diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenInputs.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenInputs.java index 38320b2e..e0ae86af 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenInputs.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenInputs.java @@ -39,7 +39,8 @@ *

        The fixture data deliberately exercises the known rendering pitfalls: a non-null relationship with an appended * line and a null-value item (information propose), the round-driven conditional sections with a non-empty * clarification list (target propose, round 1), the action-driven conditional section (feasibility propose, evaluation - * request action), the accept and reject conclusion literals, the feasibility summary rendered into the + * request action), the confirm-request category rendering only the summary and the fixed confirm request (target and + * feasibility propose confirm), the accept and reject conclusion literals, the feasibility summary rendered into the * vocabulary-exception slot of the feasibility result confirmation section, and the common abort template rendering * the termination reason. */ @@ -141,7 +142,8 @@ public NegotiationContent content(String language) { "is the average latency target within 20ms or within 50ms"), new NegotiationItem( "completion deadline", - "is the repair deadline 48 hours or 72 hours"))); + "is the repair deadline 48 hours or 72 hours")), + null); } return new TargetProposeContent( "专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。", @@ -152,7 +154,8 @@ public NegotiationContent content(String language) { null, List.of( new NegotiationItem("时延目标", "平均时延目标是恢复至20ms以内还是50ms以内"), - new NegotiationItem("完成时限", "修复完成时限是48小时内还是72小时内"))); + new NegotiationItem("完成时限", "修复完成时限是48小时内还是72小时内")), + null); } }, @@ -176,6 +179,7 @@ public NegotiationContent content(String language) { "existing constraint", "the cutover window is limited to 02:00-06:00 on 2026-05-30 with" + " service interruption no longer than 30 minutes")), + null, null); } return new FeasibilityProposeContent( @@ -185,10 +189,65 @@ public NegotiationContent content(String language) { new NegotiationItem("扩容方案", "接入端口带宽由100Mbps扩容至1000Mbps"), new NegotiationItem( "既有约束", "割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟")), + null, null); } }, + /** + * Target propose confirm-request fixture of a later round: the clarification is complete, so the message + * carries only the summary and the fixed confirm request of the "target clarified and requesting + * confirmation" category. + */ + TARGET_PROPOSE_CONFIRM(NegotiationPerformative.PROPOSE, "target-negotiation", "target_propose_confirm") { + @Override + public NegotiationContent content(String language) { + if (GoldenInputs.EN_US.equals(language)) { + return new TargetProposeContent( + "The clarification of the task target has been completed. Please reply to .", + null, + null, + null, + "The target has been clarified. Do you agree to proceed with this target?"); + } + return new TargetProposeContent( + "任务目标澄清完成,请答复<目标澄清后的确认请求>。", + null, + null, + null, + "目标已经澄清,是否同意按照此目标继续执行?"); + } + }, + + /** + * Feasibility propose confirm-request fixture of the "assessed as feasible and requesting confirmation" + * category: the assessment is complete, so the message carries only the summary and the fixed confirm + * request of the goal-achievement wording. + */ + FEASIBILITY_PROPOSE_CONFIRM( + NegotiationPerformative.PROPOSE, "feasibility-negotiation", "feasibility_propose_confirm") { + @Override + public NegotiationContent content(String language) { + if (GoldenInputs.EN_US.equals(language)) { + return new FeasibilityProposeContent( + "Regarding the adjusted rate guarantee target, the feasibility assessment has been" + + " completed and the conclusion is feasible. Please reply to .", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + null, + null, + "The target is assessed as feasible. Do you agree to proceed with this target?"); + } + return new FeasibilityProposeContent( + "针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + null, + null, + "评估目标可行,是否同意按照此目标继续执行?"); + } + }, + /** Information accept fixture delivering the access port name and the complaint category. */ INFORMATION_ACCEPT(NegotiationPerformative.ACCEPT, "information-negotiation", "information_accept") { @Override diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyArbitraries.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyArbitraries.java index eafc573b..5ef880e6 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyArbitraries.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyArbitraries.java @@ -86,6 +86,13 @@ final class PropertyArbitraries { "Access port expansion feasibility assessment within the cutover window", "Complaint information supplement negotiation between the workbench and the OMC"); + /** Fixed wording pool of the confirm-request category, one entry per language. */ + private static final List CONFIRM_REQUEST_TEXTS = List.of( + "目标已经澄清,是否同意按照此目标继续执行?", + "The target has been clarified. Do you agree to proceed with this target?", + "目标评估为可行,是否同意按照此目标继续执行?", + "The target is assessed as feasible. Do you agree to proceed with this target?"); + private PropertyArbitraries() {} /** @@ -167,26 +174,49 @@ static Arbitrary informationProposeContents() { } /** - * Arbitrary of target propose contents: non-blank description, each optional list null or non-empty. + * Arbitrary of target propose contents covering both message categories: the round-driven clarification rounds + * (non-blank description, each optional list null or non-empty, no confirm request) and the confirm-request rounds + * (non-blank description and confirm request with all three conditional lists null, so the generated record always + * satisfies the mutual exclusion of the confirm-request category). * * @return target propose content arbitrary */ static Arbitrary targetProposeContents() { + return Arbitraries.frequencyOf( + Tuple.of(2, clarificationRoundContents()), Tuple.of(1, confirmRoundTargetContents())); + } + + private static Arbitrary clarificationRoundContents() { return Combinators.combine( Arbitraries.of(NON_BLANK_TEXTS), optionalItemLists(), optionalItemLists(), optionalItemLists()) - .as(TargetProposeContent::new); + .as((description, intent, alignment, clarification) -> new TargetProposeContent( + description, intent, alignment, clarification, null)); + } + + private static Arbitrary confirmRoundTargetContents() { + return Combinators.combine( + Arbitraries.of(NON_BLANK_TEXTS), Arbitraries.of(CONFIRM_REQUEST_TEXTS)) + .as((description, confirmRequest) -> new TargetProposeContent( + description, null, null, null, confirmRequest)); } /** - * Arbitrary of feasibility propose contents covering both action branches: the action-selected item list is always - * non-empty, the other one is null or non-empty. + * Arbitrary of feasibility propose contents covering all three message categories: both action branches (the + * action-selected item list always non-empty, the other one null or non-empty) and the derived confirm-request + * category (REQUEST_FEASIBILITY_EVALUATION action with both lists null and a non-blank confirm request, which the + * mutual exclusion of the category requires). * * @return feasibility propose content arbitrary */ static Arbitrary feasibilityProposeContents() { + return Arbitraries.frequencyOf( + Tuple.of(2, actionDrivenContents()), Tuple.of(1, confirmRoundFeasibilityContents())); + } + + private static Arbitrary actionDrivenContents() { return Combinators.combine( Arbitraries.of(NegotiationAction.values()), Arbitraries.of(NON_BLANK_TEXTS), @@ -196,7 +226,19 @@ static Arbitrary feasibilityProposeContents() { description, action, action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION ? selected : other, - action == NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE ? selected : other)); + action == NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE ? selected : other, + null)); + } + + private static Arbitrary confirmRoundFeasibilityContents() { + return Combinators.combine( + Arbitraries.of(NON_BLANK_TEXTS), Arbitraries.of(CONFIRM_REQUEST_TEXTS)) + .as((description, confirmRequest) -> new FeasibilityProposeContent( + description, + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + null, + null, + confirmRequest)); } /** diff --git a/a2a-t-corpus/src/test/resources/golden/en-US/feasibility_propose_confirm.md b/a2a-t-corpus/src/test/resources/golden/en-US/feasibility_propose_confirm.md new file mode 100644 index 00000000..23d4a26f --- /dev/null +++ b/a2a-t-corpus/src/test/resources/golden/en-US/feasibility_propose_confirm.md @@ -0,0 +1,5 @@ +## Feasibility Negotiation +Regarding the adjusted rate guarantee target, the feasibility assessment has been completed and the conclusion is feasible. Please reply to . + +## Feasible Evaluation Confirmation Request +The target is assessed as feasible. Do you agree to proceed with this target? \ No newline at end of file diff --git a/a2a-t-corpus/src/test/resources/golden/en-US/target_propose_confirm.md b/a2a-t-corpus/src/test/resources/golden/en-US/target_propose_confirm.md new file mode 100644 index 00000000..875323ae --- /dev/null +++ b/a2a-t-corpus/src/test/resources/golden/en-US/target_propose_confirm.md @@ -0,0 +1,5 @@ +## Target Negotiation +The clarification of the task target has been completed. Please reply to . + +## Target Clarification Confirmation Request +The target has been clarified. Do you agree to proceed with this target? \ No newline at end of file diff --git a/a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_propose_confirm.md b/a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_propose_confirm.md new file mode 100644 index 00000000..45db4343 --- /dev/null +++ b/a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_propose_confirm.md @@ -0,0 +1,5 @@ +## 可行性协商 +针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。 + +## 评估可行时的确认请求 +评估目标可行,是否同意按照此目标继续执行? \ No newline at end of file diff --git a/a2a-t-corpus/src/test/resources/golden/zh-CN/target_propose_confirm.md b/a2a-t-corpus/src/test/resources/golden/zh-CN/target_propose_confirm.md new file mode 100644 index 00000000..61f4e9b4 --- /dev/null +++ b/a2a-t-corpus/src/test/resources/golden/zh-CN/target_propose_confirm.md @@ -0,0 +1,5 @@ +## 目标协商 +任务目标澄清完成,请答复<目标澄清后的确认请求>。 + +## 目标澄清后的确认请求 +目标已经澄清,是否同意按照此目标继续执行? \ No newline at end of file diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/INDEX.md b/a2a-t-corpus/src/test/resources/negotiation-cases/INDEX.md index b0cf545a..b802ce82 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/INDEX.md +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/INDEX.md @@ -12,11 +12,11 @@ language expansion is listed per record and totaled in the statistics. | Metric | Value | |---|---| -| Case records | 151 | -| Scenario records (steps) | 18 (87) | -| Base records total | 169 | -| Language-expanded units | 226 | -| Shared LLM payloads | 32 | +| Case records | 154 | +| Scenario records (steps) | 20 (93) | +| Base records total | 174 | +| Language-expanded units | 234 | +| Shared LLM payloads | 35 | | Shared schema variants | 8 | | Error codes covered | 7 (negotiation_content_extract_failed, negotiation_invalid_input, negotiation_llm_infrastructure_error, negotiation_rule_violation, negotiation_semantic_rejected, negotiation_slot_missing, template_not_found) | | Exception-only failures | 33 (IllegalArgumentException, NullPointerException) | @@ -25,7 +25,7 @@ Priority distribution (case records; scenarios carry no priority): | Priority | Cases | |---|---| -| P0 | 76 | +| P0 | 79 | | P1 | 63 | | P2 | 12 | @@ -34,7 +34,7 @@ Priority distribution (case records; scenarios carry no priority): | Family | Category | Corpus file | Records | Steps | Languages | |---|---|---|---|---|---| | from-text (FT) | FT-EXTRACT | from-text/extraction-failures.json | 15 | 0 | en-US, zh-CN | -| from-text (FT) | FT-HAPPY | from-text/happy.json | 10 | 0 | en-US, zh-CN | +| from-text (FT) | FT-HAPPY | from-text/happy.json | 13 | 0 | en-US, zh-CN | | from-text (FT) | FT-PROG | from-text/programming-errors.json | 8 | 0 | zh-CN | | from-text (FT) | FT-RETRY | from-text/retry.json | 13 | 0 | en-US, zh-CN | | from-text (FT) | FT-TPL | from-text/template-resolution.json | 4 | 0 | zh-CN | @@ -50,9 +50,9 @@ Priority distribution (case records; scenarios carry no priority): | validate (VAL) | VAL-SEM | validate/semantic-verdicts.json | 17 | 0 | en-US, zh-CN | | scenarios (SC) | SC-ERR | scenarios/boundary-flows.json | 7 | 33 | zh-CN | | scenarios (SC) | SC-EXH | scenarios/boundary-flows.json | 2 | 11 | zh-CN | -| scenarios (SC) | SC-FSB | scenarios/feasibility-flows.json | 3 | 15 | en-US, zh-CN | +| scenarios (SC) | SC-FSB | scenarios/feasibility-flows.json | 4 | 18 | en-US, zh-CN | | scenarios (SC) | SC-INFO | scenarios/information-flows.json | 3 | 17 | en-US, zh-CN | -| scenarios (SC) | SC-TGT | scenarios/target-flows.json | 3 | 11 | zh-CN | +| scenarios (SC) | SC-TGT | scenarios/target-flows.json | 4 | 14 | zh-CN | ## API x category coverage @@ -61,7 +61,7 @@ Cells count base records (case records plus scenario steps) per API and category | Category | generateProposeFromText | generateAcceptFromText | generateRejectFromText | generateAbortFromText | generateProposeFromData | generateAcceptFromData | generateRejectFromData | generateAbortFromData | validateProposePromptAndDataFilling | validateAcceptPromptAndDataFilling | validateRejectPromptAndDataFilling | validateAbortPromptAndDataFilling | generateTaskPromptFromText | generateTaskPromptFromDataWithSchema | validateTaskPromptAndDataFilling | Total | |---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| | FT-EXTRACT | 7 | 4 | 2 | 2 | - | - | - | - | - | - | - | - | - | - | - | 15 | -| FT-HAPPY | 3 | 3 | 3 | 1 | - | - | - | - | - | - | - | - | - | - | - | 10 | +| FT-HAPPY | 6 | 3 | 3 | 1 | - | - | - | - | - | - | - | - | - | - | - | 13 | | FT-PROG | 3 | 3 | 1 | 1 | - | - | - | - | - | - | - | - | - | - | - | 8 | | FT-RETRY | 5 | 6 | 1 | 1 | - | - | - | - | - | - | - | - | - | - | - | 13 | | FT-TPL | 1 | 1 | 1 | 1 | - | - | - | - | - | - | - | - | - | - | - | 4 | @@ -77,10 +77,10 @@ Cells count base records (case records plus scenario steps) per API and category | VAL-SEM | - | - | - | - | - | - | - | - | 10 | 2 | 2 | 3 | - | - | - | 17 | | SC-ERR | 6 | 7 | - | - | - | - | - | - | 2 | 10 | - | - | 4 | - | 4 | 33 | | SC-EXH | 3 | - | 2 | 1 | - | - | - | - | 2 | - | - | 1 | 1 | - | 1 | 11 | -| SC-FSB | 4 | 2 | 2 | 1 | - | - | - | - | 2 | 2 | 1 | 1 | - | - | - | 15 | +| SC-FSB | 6 | 3 | 2 | 1 | - | - | - | - | 2 | 2 | 1 | 1 | - | - | - | 18 | | SC-INFO | 4 | 2 | 1 | 1 | - | - | - | - | - | 2 | - | 1 | 2 | 1 | 3 | 17 | -| SC-TGT | 4 | 2 | 2 | 1 | - | - | - | - | - | 2 | - | - | - | - | - | 11 | -| **Total** | **40** | **30** | **15** | **10** | **12** | **7** | **5** | **1** | **45** | **33** | **12** | **12** | **7** | **1** | **8** | **238** | +| SC-TGT | 6 | 3 | 2 | 1 | - | - | - | - | - | 2 | - | - | - | - | - | 14 | +| **Total** | **47** | **32** | **15** | **10** | **12** | **7** | **5** | **1** | **45** | **33** | **12** | **12** | **7** | **1** | **8** | **247** | ## API x expected outcome coverage @@ -88,8 +88,8 @@ Cells count base records per API and expectation; the `exception` column aggrega | API | `success` | `negotiation_content_extract_failed` | `negotiation_invalid_input` | `negotiation_llm_infrastructure_error` | `negotiation_rule_violation` | `negotiation_semantic_rejected` | `negotiation_slot_missing` | `template_not_found` | `exception` | Total | |---|---|---|---|---|---|---|---|---|---|---| -| `generateProposeFromText` | 25 | 7 | 3 | 1 | - | - | 1 | 1 | 2 | 40 | -| `generateAcceptFromText` | 18 | 2 | 3 | 1 | - | - | 3 | 1 | 2 | 30 | +| `generateProposeFromText` | 32 | 7 | 3 | 1 | - | - | 1 | 1 | 2 | 47 | +| `generateAcceptFromText` | 20 | 2 | 3 | 1 | - | - | 3 | 1 | 2 | 32 | | `generateRejectFromText` | 10 | - | 1 | 1 | - | - | 1 | 1 | 1 | 15 | | `generateAbortFromText` | 6 | - | - | - | - | - | 2 | 1 | 1 | 10 | | `generateProposeFromData` | 4 | - | - | - | - | - | - | - | 8 | 12 | @@ -140,6 +140,9 @@ Cells count base records per API and expectation; the `exception` column aggrega | FT-HAPPY-08 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 端口扩容可行性评估通过后从自然语言生成可行性协商接受消息,消息携带可行性结论并含 Accept 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | | FT-HAPPY-09 | `generateRejectFromText` | en-US, zh-CN | P0 | success | 汇聚机房板卡槽位不足致端口扩容不可行时从自然语言生成可行性协商拒绝消息,消息携带不可行结论并含 Reject 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | | FT-HAPPY-10 | `generateAbortFromText` | en-US, zh-CN | P0 | success | 投诉诊断协商轮次耗尽时从自然语言生成协商终止消息,走通用 abort 模板并含终止原因与 Abort 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | +| FT-HAPPY-11 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 目标澄清完成后的确认请求轮从自然语言生成目标协商提议消息:抽取结果 target_confirm_request 非空且三个互斥板块全空,渲染仅含目标协商概述与目标澄清后的确认请求两个板块、携带固定确认措辞(harness 的 llm 脚本与 input.data 不随语言展开,en-US 渲染为英文模板骨架+中文数据,deviation 已记录) | +| FT-HAPPY-12 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 目标达成类可行性确认请求轮从自然语言生成可行性协商提议消息:抽取结果 feasibility_confirm_request 非空、action 为 REQUEST_FEASIBILITY_EVALUATION 且两个互斥板块全空,渲染仅含可行性协商概述与评估可行时的确认请求两个板块、携带目标可行固定措辞(en-US 渲染为英文模板骨架+中文数据,deviation 已记录) | +| FT-HAPPY-13 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 方案可行性类确认请求轮从自然语言生成可行性协商提议消息:抽取结果 feasibility_confirm_request 非空且互斥板块全空,渲染携带方案可行固定措辞,验证固定措辞在目标/方案两条中的二选一(en-US 渲染为英文模板骨架+中文数据,deviation 已记录) | ### FT-PROG -- from-text/programming-errors.json @@ -360,6 +363,7 @@ Cells count base records per API and expectation; the `exception` column aggrega | SC-FSB-01 | `generateProposeFromText`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=1, distinct-messages | - | 端口扩容可行性协商单轮闭环(双语):工作台就接入端口带宽扩容能否在割接窗口内完成发起可行性评估请求,OMC 评估通过后生成接受消息,工作台校验确认达成一致 | | SC-FSB-02 | `generateProposeFromText`, `generateRejectFromText`, `validateRejectPromptAndDataFilling`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 端口扩容可行性协商两轮演进:round 1 因汇聚机房板卡槽位不足评估不可行并拒绝,工作台于 round 2 改提更换高容量板卡并拆分割接窗口的替代方案,OMC 评估通过并接受 | | SC-FSB-03 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText`, `validateAbortPromptAndDataFilling` | zh-CN | - | abort, rounds=2, distinct-messages | - | 端口扩容可行性协商中止:round 1 评估因汇聚机房板卡槽位不足不可行且双方无法推进,round 2 生成终止消息并经校验确认协商提前终止 | +| SC-FSB-04 | `generateProposeFromText`, `generateAcceptFromText` | zh-CN | - | accept, rounds=2, distinct-messages | - | 端口扩容可行性协商确认请求闭环:工作台 round 1 发起扩容评估请求,OMC 评估可行后于 round 2 发出评估可行并请求确认的提议,工作台同意后生成接受消息完成闭环 | ### SC-INFO -- scenarios/information-flows.json @@ -376,4 +380,5 @@ Cells count base records per API and expectation; the `exception` column aggrega | SC-TGT-01 | `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 修复目标协商单轮闭环:工作台就专线质差修复目标发起目标协商提议,OMC 确认最终意图生成接受消息,工作台校验提取时延目标与完成时限参数 | | SC-TGT-02 | `generateProposeFromText`, `generateRejectFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 修复目标协商两轮演进:首轮因光缆割接窗口尚未确认被拒,工作台对齐时延目标后于第2轮重新提议,OMC 确认接受且参数校验提取成功 | | SC-TGT-03 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText` | zh-CN | - | abort, rounds=2, distinct-messages | - | 修复目标协商中断:光缆割接窗口持续无法确认,修复目标在轮次预算内无法完成澄清,于第2轮生成终止消息结束协商 | +| SC-TGT-04 | `generateProposeFromText`, `generateAcceptFromText` | zh-CN | - | accept, rounds=2, distinct-messages | - | 修复目标协商确认请求闭环:工作台 round 1 发起意图理解澄清提议,OMC 完成澄清对齐后于 round 2 发出目标已澄清并请求确认的提议,工作台同意后生成接受消息完成闭环 | diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/happy.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/happy.json index a24659ee..fe5d9c88 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/happy.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/happy.json @@ -331,5 +331,107 @@ }, "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] } + }, + { + "id": "FT-HAPPY-11", + "api": "generateProposeFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "target", "propose", "confirm-request"], + "summary": "目标澄清完成后的确认请求轮从自然语言生成目标协商提议消息:抽取结果 target_confirm_request 非空且三个互斥板块全空,渲染仅含目标协商概述与目标澄清后的确认请求两个板块、携带固定确认措辞(harness 的 llm 脚本与 input.data 不随语言展开,en-US 渲染为英文模板骨架+中文数据,deviation 已记录)", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "任务目标澄清完成:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。目标已经澄清,是否同意按照此目标继续执行?", + "en-US": "The clarification of the task target has been completed: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15, with a peak-hour packet loss rate no higher than 1%. The target has been clarified. Do you agree to proceed with this target?" + }, + "data": { + "target_negotiation_description": "任务目标澄清完成,请答复<目标澄清后的确认请求>。", + "intent_understanding": null, + "alignment_and_clarification": null, + "request_for_clarification": null, + "target_confirm_request": "目标已经澄清,是否同意按照此目标继续执行?" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.propose.confirm" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextContains": ["目标已经澄清,是否同意按照此目标继续执行?"], + "metadata": { + "templateUriEcho": "Negotiation-T/target-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"] + } + }, + { + "id": "FT-HAPPY-12", + "api": "generateProposeFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "feasibility", "propose", "confirm-request", "goal"], + "summary": "目标达成类可行性确认请求轮从自然语言生成可行性协商提议消息:抽取结果 feasibility_confirm_request 非空、action 为 REQUEST_FEASIBILITY_EVALUATION 且两个互斥板块全空,渲染仅含可行性协商概述与评估可行时的确认请求两个板块、携带目标可行固定措辞(en-US 渲染为英文模板骨架+中文数据,deviation 已记录)", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "针对调整后的速率保障目标(由5Mbps下调至2Mbps),可行性评估已完成,结论为可行。评估目标可行,是否同意按照此目标继续执行?", + "en-US": "Regarding the adjusted rate guarantee target (lowered from 5Mbps to 2Mbps), the feasibility assessment has been completed and the conclusion is feasible. The target is assessed as feasible. Do you agree to proceed with this target?" + }, + "data": { + "feasibility_negotiation_description": "针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": null, + "feasibility_confirm_request": "评估目标可行,是否同意按照此目标继续执行?" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.confirm.goal" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextContains": ["评估目标可行,是否同意按照此目标继续执行?"], + "metadata": { + "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"] + } + }, + { + "id": "FT-HAPPY-13", + "api": "generateProposeFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P0", + "tags": ["happy", "feasibility", "propose", "confirm-request", "solution"], + "summary": "方案可行性类确认请求轮从自然语言生成可行性协商提议消息:抽取结果 feasibility_confirm_request 非空且互斥板块全空,渲染携带方案可行固定措辞,验证固定措辞在目标/方案两条中的二选一(en-US 渲染为英文模板骨架+中文数据,deviation 已记录)", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "故障订阅任务履行过程中自诊断识别到的保活方案(心跳探测周期调整为60秒)评估已完成,结论为可行。评估方案可行,是否同意按照此方案继续执行?", + "en-US": "The assessment of the keepalive solution identified by self-diagnosis during the fault subscription task (heartbeat probing interval adjusted to 60 seconds) has been completed and the conclusion is feasible. The solution is assessed as feasible. Do you agree to proceed with this solution?" + }, + "data": { + "feasibility_negotiation_description": "故障订阅任务履行过程中自诊断识别到的保活方案评估已完成,结论为可行,请答复<评估可行时的确认请求>。", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": null, + "feasibility_confirm_request": "评估方案可行,是否同意按照此方案继续执行?" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.confirm.solution" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextContains": ["评估方案可行,是否同意按照此方案继续执行?"], + "metadata": { + "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"] + } } ] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json index e72d2459..abf27e25 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json @@ -346,5 +346,78 @@ } ], "expectFlow": { "terminalCondition": "abort", "roundsUsed": 2, "distinctMessages": true } + }, + { + "id": "SC-FSB-04", + "summary": "端口扩容可行性协商确认请求闭环:工作台 round 1 发起扩容评估请求,OMC 评估可行后于 round 2 发出评估可行并请求确认的提议,工作台同意后生成接受消息完成闭环", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,扩容评估请求/确认方)", + "B": "OMC(server,可行性评估与确认请求发起方)" + }, + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps,割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "feasibility_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "针对专线接入端口扩容方案的目标达成可行性评估已完成:扩容可在割接窗口2026年5月30日02:00-06:00内完成,业务中断时长满足不超过30分钟的约束,结论为可行。评估目标可行,是否同意按照此目标继续执行?" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.confirm.goal" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextContains": ["评估可行时的确认请求", "评估目标可行,是否同意按照此目标继续执行?"], + "metadata": { + "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"] + } + }, + { + "step": 3, + "role": "A", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "同意按照此目标继续执行。端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束,本次可行性协商确认结束。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.feasibility.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "feasibility_accept", + "contracts": ["conclusionLiteralPresent"] + } + } + ], + "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2, "distinctMessages": true } } ] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/target-flows.json b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/target-flows.json index ed418aed..a39b58aa 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/target-flows.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/target-flows.json @@ -280,5 +280,82 @@ "roundsUsed": 2, "distinctMessages": true } + }, + { + "id": "SC-TGT-04", + "summary": "修复目标协商确认请求闭环:工作台 round 1 发起意图理解澄清提议,OMC 完成澄清对齐后于 round 2 发出目标已澄清并请求确认的提议,工作台同意后生成接受消息完成闭环", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,修复目标协商发起/确认方)", + "B": "OMC(server,修复目标澄清/执行与确认请求发起方)" + }, + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "专线质差投诉的修复目标协商:修复意图是在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%;待澄清平均时延目标是恢复至20ms以内还是50ms以内、修复完成时限是48小时内还是72小时内,请澄清并确认。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "target_propose" + } + }, + { + "step": 2, + "role": "B", + "api": "generateProposeFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1", + "input": { + "text": { + "zh-CN": "任务目标澄清完成:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%,修复完成时限为48小时内。目标已经澄清,是否同意按照此目标继续执行?" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.propose.confirm" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextContains": ["目标澄清后的确认请求", "目标已经澄清,是否同意按照此目标继续执行?"], + "metadata": { + "templateUriEcho": "Negotiation-T/target-negotiation/propose/v1", + "contextEcho": true + }, + "contracts": ["metadataTripleShape"] + } + }, + { + "step": 3, + "role": "A", + "api": "generateAcceptFromText", + "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, + "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", + "input": { + "text": { + "zh-CN": "同意按照此目标继续执行。最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" + } + }, + "llm": { "script": [ { "$ref": "responses/extract.target.accept.full" } ] }, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextEqualsGolden": "target_accept", + "contracts": ["conclusionLiteralPresent"] + } + } + ], + "expectFlow": { + "terminalCondition": "accept", + "roundsUsed": 2, + "distinctMessages": true + } } ] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json b/a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json index 2b189df0..5e4baf0c 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json @@ -3,9 +3,12 @@ "extract.information.accept.full": "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}", "extract.information.reject.full": "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"无法提供,工作台侧端口资源台账暂不可查\"}]}", "extract.target.propose.full": "{\"target_negotiation_description\":\"专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。\",\"intent_understanding\":[{\"name\":\"修复意图\",\"value\":\"在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内\"},{\"name\":\"修复目标\",\"value\":\"高峰时段丢包率不高于1%\"}],\"alignment_and_clarification\":null,\"request_for_clarification\":[{\"name\":\"时延目标\",\"value\":\"平均时延目标是恢复至20ms以内还是50ms以内\"},{\"name\":\"完成时限\",\"value\":\"修复完成时限是48小时内还是72小时内\"}]}", + "extract.target.propose.confirm": "{\"target_negotiation_description\":\"任务目标澄清完成,请答复<目标澄清后的确认请求>。\",\"intent_understanding\":null,\"alignment_and_clarification\":null,\"request_for_clarification\":null,\"target_confirm_request\":\"目标已经澄清,是否同意按照此目标继续执行?\"}", "extract.target.accept.full": "{\"conclusion\":\"Accept\",\"confirmed_intent\":\"最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。\",\"failure_reason\":null}", "extract.target.reject.full": "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"因光缆割接窗口尚未确认,时延目标未能完全澄清。\"}", "extract.feasibility.propose.full": "{\"feasibility_negotiation_description\":\"请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":[{\"name\":\"扩容方案\",\"value\":\"接入端口带宽由100Mbps扩容至1000Mbps\"},{\"name\":\"既有约束\",\"value\":\"割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟\"}],\"infeasibility_details_and_proposal\":null}", + "extract.feasibility.propose.confirm.goal": "{\"feasibility_negotiation_description\":\"针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null,\"feasibility_confirm_request\":\"评估目标可行,是否同意按照此目标继续执行?\"}", + "extract.feasibility.propose.confirm.solution": "{\"feasibility_negotiation_description\":\"故障订阅任务履行过程中自诊断识别到的保活方案评估已完成,结论为可行,请答复<评估可行时的确认请求>。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null,\"feasibility_confirm_request\":\"评估方案可行,是否同意按照此方案继续执行?\"}", "extract.feasibility.accept.full": "{\"conclusion\":\"Accept\",\"feasibility_summary\":\"端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。\"}", "extract.feasibility.reject.full": "{\"conclusion\":\"Reject\",\"feasibility_summary\":\"受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。\"}", "extract.common.abort.full": "{\"termination_reason\":\"已达到协商轮次上限,本次协商确认终止。\"}", diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityProposeContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityProposeContent.java index af0284fa..08683c2f 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityProposeContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityProposeContent.java @@ -1,21 +1,28 @@ -package net.openan.a2at.sdk.negotiation.content; - -import java.util.List; - -/** - * Content of a feasibility negotiation propose message. - * - * @param feasibilityNegotiationDescription required summary describing the nature of the message - * @param action action this propose message performs; selects which conditional sections are rendered - * @param contentsToEvaluate contents the counterpart should evaluate; null or empty omits the section (only for - * {@link NegotiationAction#REQUEST_FEASIBILITY_EVALUATION}) - * @param infeasibilityDetailsAndProposal infeasibility details and an alternative proposal; null or empty omits the - * section (only for {@link NegotiationAction#PROPOSE_ALTERNATIVE_ON_FAILURE}) - * @since 2026-08 - */ -public record FeasibilityProposeContent( - String feasibilityNegotiationDescription, - NegotiationAction action, - List contentsToEvaluate, - List infeasibilityDetailsAndProposal) - implements NegotiationProposeContent {} +package net.openan.a2at.sdk.negotiation.content; + +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Content of a feasibility negotiation propose message. + * + * @param feasibilityNegotiationDescription required summary describing the nature of the message + * @param action action this propose message performs; selects which conditional section is rendered + * @param contentsToEvaluate contents the counterpart should evaluate; null or empty omits the section (only for + * {@link NegotiationAction#REQUEST_FEASIBILITY_EVALUATION}) + * @param infeasibilityDetailsAndProposal infeasibility details and an alternative proposal; null or empty omits the + * section (only for {@link NegotiationAction#PROPOSE_ALTERNATIVE_ON_FAILURE}) + * @param feasibilityConfirmRequest non-blank when this round's message category is "assessed as feasible and + * requesting confirmation" — the third category is derived and the action stays at two values: it requires the + * {@link NegotiationAction#REQUEST_FEASIBILITY_EVALUATION} action with both lists above null or empty, so a + * confirm-request round carries only the summary and the confirm request (the fixed wording is not validated and + * passes through verbatim) + * @since 2026-08 + */ +public record FeasibilityProposeContent( + String feasibilityNegotiationDescription, + NegotiationAction action, + @Nullable List contentsToEvaluate, + @Nullable List infeasibilityDetailsAndProposal, + @Nullable String feasibilityConfirmRequest) + implements NegotiationProposeContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetProposeContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetProposeContent.java index 327381ba..7351231c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetProposeContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetProposeContent.java @@ -1,21 +1,27 @@ -package net.openan.a2at.sdk.negotiation.content; - -import java.util.List; - -/** - * Content of a target negotiation propose message. - * - * @param targetNegotiationDescription required summary of what the target negotiation is about - * @param intentUnderstanding restatement of the counterpart's intent; null or empty omits the section (first round - * only) - * @param alignmentAndClarification alignment statements and clarifications; null or empty omits the section (later - * rounds only) - * @param requestForClarification open clarification requests; null or empty omits the section - * @since 2026-08 - */ -public record TargetProposeContent( - String targetNegotiationDescription, - List intentUnderstanding, - List alignmentAndClarification, - List requestForClarification) - implements NegotiationProposeContent {} +package net.openan.a2at.sdk.negotiation.content; + +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Content of a target negotiation propose message. + * + * @param targetNegotiationDescription required summary of what the target negotiation is about + * @param intentUnderstanding restatement of the counterpart's intent; null or empty omits the section (first round + * only) + * @param alignmentAndClarification alignment statements and clarifications; null or empty omits the section (later + * rounds only) + * @param requestForClarification open clarification requests; null or empty omits the section + * @param targetConfirmRequest non-blank when this round's message category is "target clarified and requesting + * confirmation from the counterpart"; the three conditional lists above must then all be null or empty, because a + * confirm-request round carries only the summary and the confirm request (the fixed wording is not validated and + * passes through verbatim) + * @since 2026-08 + */ +public record TargetProposeContent( + String targetNegotiationDescription, + @Nullable List intentUnderstanding, + @Nullable List alignmentAndClarification, + @Nullable List requestForClarification, + @Nullable String targetConfirmRequest) + implements NegotiationProposeContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java index 8246c1f2..7a05dc4e 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java @@ -66,11 +66,13 @@ public final class Vocabulary { "section.target_intent", "section.target_alignment", "section.target_clarification", + "section.target_confirm_request", "section.target_conclusion", "section.target_result_content", "section.feasibility", "section.feasibility_evaluate", "section.feasibility_infeasible", + "section.feasibility_confirm_request", "section.feasibility_conclusion", "section.feasibility_confirm", "slot.termination_reason", @@ -81,11 +83,13 @@ public final class Vocabulary { "slot.target_intent", "slot.target_alignment", "slot.target_clarification", + "slot.target_confirm_request", "slot.target_conclusion", "slot.target_result_content", "slot.feasibility", "slot.feasibility_evaluate", "slot.feasibility_infeasible", + "slot.feasibility_confirm_request", "slot.feasibility_conclusion", "slot.feasibility_confirm", "label.relationship", diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java index d32a7fa9..67ec43cc 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java @@ -177,21 +177,49 @@ private static NegotiationContent mapProposeContent(Map payload, case INFORMATION -> new InformationProposeContent( requiredNonEmptyItems(payload, "items", "information negotiation requested items"), optionalString(payload, "relationship")); - case TARGET -> new TargetProposeContent( - requiredString(payload, "target_negotiation_description"), - optionalItems(payload, "intent_understanding"), - optionalItems(payload, "alignment_and_clarification"), - optionalItems(payload, "request_for_clarification")); + case TARGET -> mapTargetProposeContent(payload); case FEASIBILITY -> mapFeasibilityProposeContent(payload); }; } + private static NegotiationContent mapTargetProposeContent(Map payload) { + String description = requiredString(payload, "target_negotiation_description"); + List intentUnderstanding = optionalItems(payload, "intent_understanding"); + List alignmentAndClarification = optionalItems(payload, "alignment_and_clarification"); + List requestForClarification = optionalItems(payload, "request_for_clarification"); + String confirmRequest = optionalString(payload, "target_confirm_request"); + if (hasText(confirmRequest) + && (hasItems(intentUnderstanding) || hasItems(alignmentAndClarification) || hasItems(requestForClarification))) { + throw invalidInput( + "Target confirm request extracted together with the intent understanding, alignment and" + + " clarification or clarification request sections; a confirm-request round carries only" + + " the summary and the confirm request."); + } + return new TargetProposeContent( + description, intentUnderstanding, alignmentAndClarification, requestForClarification, confirmRequest); + } + private static NegotiationContent mapFeasibilityProposeContent(Map payload) { String description = requiredString(payload, "feasibility_negotiation_description"); NegotiationAction action = feasibilityAction(payload); List contentsToEvaluate = optionalItems(payload, "contents_to_evaluate"); List infeasibilityDetails = optionalItems(payload, "infeasibility_details_and_proposal"); - if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + String confirmRequest = optionalString(payload, "feasibility_confirm_request"); + if (hasText(confirmRequest)) { + if (action != NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + throw invalidInput( + "Feasibility confirm request requires the REQUEST_FEASIBILITY_EVALUATION action but the" + + " extracted action was " + + action.name() + + "."); + } + if (hasItems(contentsToEvaluate) || hasItems(infeasibilityDetails)) { + throw invalidInput( + "Feasibility confirm request extracted together with the contents to evaluate or" + + " infeasibility details and proposal sections; a confirm-request round carries only" + + " the summary and the confirm request."); + } + } else if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { if (contentsToEvaluate == null || contentsToEvaluate.isEmpty()) { throw invalidInput( "Feasibility evaluation request extracted no contents to evaluate; the driven section would" @@ -202,7 +230,8 @@ private static NegotiationContent mapFeasibilityProposeContent(Map payload, String field) throw extractFailed("Field " + field + " must be a string or null but was: " + value); } + private static boolean hasText(@Nullable String value) { + return value != null && !value.isBlank(); + } + + private static boolean hasItems(@Nullable List items) { + return items != null && !items.isEmpty(); + } + private static List requiredItems(Map payload, String field) { Object value = payload.get(field); if (value == null) { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java index d543e0e1..808d5ba1 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java @@ -1,21 +1,26 @@ package net.openan.a2at.sdk.negotiation.generation; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.core.model.PromptTemplate; +import org.jspecify.annotations.Nullable; /** * Generator for feasibility negotiation propose messages. * - *

        The action selects which conditional section is rendered: requesting a feasibility evaluation renders the contents - * to evaluate, while proposing an alternative after an infeasible outcome renders the infeasibility details and - * proposal. Exactly one of the two sections is always present. + *

        The message category selects which conditional section is rendered: requesting a feasibility evaluation renders the + * contents to evaluate, proposing an alternative after an infeasible outcome renders the infeasibility details and + * proposal, and a non-blank confirm request marks the derived "assessed as feasible and requesting confirmation" category + * (action {@code REQUEST_FEASIBILITY_EVALUATION} with both lists empty) that renders only the confirm request. Exactly + * one of the three sections is always present. * * @since 2026-08 */ @@ -29,6 +34,9 @@ final class FeasibilityProposeGenerator extends AbstractNegotiationGenerator { * @param template feasibility propose template to render * @param vocabulary vocabulary of the message language * @return rendered feasibility propose message text + * @throws NullPointerException if the action is null + * @throws IllegalArgumentException if the summary is blank, the action-selected section is empty, or the confirm + * request is combined with the wrong action or either conditional section */ @Override public String generate( @@ -43,9 +51,25 @@ public String generate( Objects.requireNonNull( action, "Feasibility negotiation action must not be null; it selects the conditional sections of the message."); + String confirmRequest = presentText(proposeContent.feasibilityConfirmRequest()); Map slots = new LinkedHashMap<>(); slots.put(vocabulary.get("slot.feasibility"), proposeContent.feasibilityNegotiationDescription()); - if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + if (confirmRequest != null) { + if (action != NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + throw new IllegalArgumentException( + "Feasibility confirm request requires the REQUEST_FEASIBILITY_EVALUATION action but the content" + + " carries " + + action.name() + + "."); + } + if (hasItems(proposeContent.contentsToEvaluate()) || hasItems(proposeContent.infeasibilityDetailsAndProposal())) { + throw new IllegalArgumentException( + "Feasibility confirm request must not be combined with the contents to evaluate or" + + " infeasibility details and proposal sections; a confirm-request round carries only" + + " the summary and the confirm request."); + } + slots.put(vocabulary.get("slot.feasibility_confirm_request"), confirmRequest); + } else if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { slots.put( vocabulary.get("slot.feasibility_evaluate"), formatItems( @@ -66,4 +90,12 @@ public String generate( } return render(template, slots); } + + private static @Nullable String presentText(@Nullable String value) { + return value == null || value.isBlank() ? null : value; + } + + private static boolean hasItems(@Nullable List items) { + return items != null && !items.isEmpty(); + } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index 648ab68a..7fb77d4a 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -98,7 +98,9 @@ public static A2ATConfig resolvePromptResourceLocalRootDir( * @param templateUri template URI whose performative segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content type + * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content + * type, or the content combines a non-blank confirm request with conditional sections (target: the three + * clarification lists; feasibility: either conditional list or the alternative action) * @throws NegotiationGenerationException with the code {@code template_not_found} or * {@code negotiation_slot_missing} when loading or rendering the template fails */ @@ -170,8 +172,9 @@ public MetadataContent generateAbortFromData( * @throws NegotiationGenerationException with the code {@code template_not_found}, * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted content contradicts the performative, - * or {@code input_text_too_long} when the text exceeds the configured maximum length + * {@code negotiation_invalid_input} when the text is blank, the extracted content contradicts the performative + * or the extracted confirm request is combined with conditional sections or the wrong feasibility action, or + * {@code input_text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateProposeFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java index 2606cfa6..bfc024eb 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java @@ -81,6 +81,7 @@ private static Map targetProposeSchema() { properties.put("intent_understanding", nullableItemArraySchema()); properties.put("alignment_and_clarification", nullableItemArraySchema()); properties.put("request_for_clarification", nullableItemArraySchema()); + properties.put("target_confirm_request", nullableStringSchema()); return objectSchema(properties, List.of("target_negotiation_description")); } @@ -101,6 +102,7 @@ private static Map feasibilityProposeSchema() { properties.put("action", action); properties.put("contents_to_evaluate", nullableItemArraySchema()); properties.put("infeasibility_details_and_proposal", nullableItemArraySchema()); + properties.put("feasibility_confirm_request", nullableStringSchema()); return objectSchema(properties, List.of("feasibility_negotiation_description", "action")); } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java index 2206e3d1..bf886fe3 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java @@ -1,19 +1,24 @@ package net.openan.a2at.sdk.negotiation.generation; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.core.model.PromptTemplate; +import org.jspecify.annotations.Nullable; /** * Generator for target negotiation propose messages. * *

        Besides the required target negotiation summary, the message renders round-driven conditional sections: the intent * understanding section appears only on the first round, the alignment and clarification section only on later rounds, - * and the clarification request section only when clarification items are present. + * and the clarification request section only when clarification items are present. A non-blank confirm request marks the + * "target clarified and requesting confirmation" category instead; the three conditional lists must then be empty, so + * only the summary and the confirm request section remain. * * @since 2026-08 */ @@ -27,6 +32,8 @@ final class TargetProposeGenerator extends AbstractNegotiationGenerator { * @param template target propose template to render * @param vocabulary vocabulary of the message language * @return rendered target propose message text + * @throws IllegalArgumentException if the summary is blank or the confirm request is combined with any of the three + * conditional sections */ @Override public String generate( @@ -37,6 +44,16 @@ public String generate( proposeContent.targetNegotiationDescription(), "content.targetNegotiationDescription", "Target negotiation description"); + String confirmRequest = presentText(proposeContent.targetConfirmRequest()); + if (confirmRequest != null + && (hasItems(proposeContent.intentUnderstanding()) + || hasItems(proposeContent.alignmentAndClarification()) + || hasItems(proposeContent.requestForClarification()))) { + throw new IllegalArgumentException( + "Target confirm request must not be combined with the intent understanding, alignment and" + + " clarification or clarification request sections; a confirm-request round carries only" + + " the summary and the confirm request."); + } Map slots = new LinkedHashMap<>(); slots.put(vocabulary.get("slot.target"), proposeContent.targetNegotiationDescription()); if (context.round() == 1) { @@ -51,6 +68,17 @@ public String generate( slots.put( vocabulary.get("slot.target_clarification"), formatItems(proposeContent.requestForClarification(), vocabulary)); + if (confirmRequest != null) { + slots.put(vocabulary.get("slot.target_confirm_request"), confirmRequest); + } return render(template, slots); } + + private static @Nullable String presentText(@Nullable String value) { + return value == null || value.isBlank() ? null : value; + } + + private static boolean hasItems(@Nullable List items) { + return items != null && !items.isEmpty(); + } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java index 690d9410..27883af1 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java @@ -32,11 +32,13 @@ class VocabularyTest { "section.target_intent", "section.target_alignment", "section.target_clarification", + "section.target_confirm_request", "section.target_conclusion", "section.target_result_content", "section.feasibility", "section.feasibility_evaluate", "section.feasibility_infeasible", + "section.feasibility_confirm_request", "section.feasibility_conclusion", "section.feasibility_confirm", "slot.termination_reason", @@ -48,10 +50,12 @@ class VocabularyTest { "slot.target_intent", "slot.target_alignment", "slot.target_clarification", + "slot.target_confirm_request", "slot.target_conclusion", "slot.target_result_content", "slot.feasibility_evaluate", "slot.feasibility_infeasible", + "slot.feasibility_confirm_request", "slot.feasibility_conclusion", "slot.feasibility_confirm", "label.relationship", @@ -66,8 +70,8 @@ void bothLanguagesExposeExactlyTheCanonicalKeys() { assertEquals(CANONICAL_KEYS, enUs.canonicalKeys()); assertEquals(Set.copyOf(Vocabulary.CANONICAL_KEYS), zhCn.canonicalKeys()); assertEquals(new TreeSet<>(zhCn.canonicalKeys()), new TreeSet<>(enUs.canonicalKeys())); - assertEquals(33, zhCn.canonicalKeys().size()); - assertEquals(33, Vocabulary.CANONICAL_KEYS.size()); + assertEquals(37, zhCn.canonicalKeys().size()); + assertEquals(37, Vocabulary.CANONICAL_KEYS.size()); } @Test @@ -115,6 +119,21 @@ void sectionValuesMatchTemplateSectionTitles() { assertEquals("Feasibility Assessment Result Confirmation", enUs.get("section.feasibility_confirm")); } + @Test + void confirmRequestSectionAndSlotKeysMatchTheNewTemplates() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("目标澄清后的确认请求", zhCn.get("section.target_confirm_request")); + assertEquals("Target Clarification Confirmation Request", enUs.get("section.target_confirm_request")); + assertEquals("目标澄清后的确认请求", zhCn.get("slot.target_confirm_request")); + assertEquals("target_confirm_request", enUs.get("slot.target_confirm_request")); + assertEquals("评估可行时的确认请求", zhCn.get("section.feasibility_confirm_request")); + assertEquals("Feasible Evaluation Confirmation Request", enUs.get("section.feasibility_confirm_request")); + assertEquals("评估可行时的确认请求", zhCn.get("slot.feasibility_confirm_request")); + assertEquals("feasibility_confirm_request", enUs.get("slot.feasibility_confirm_request")); + } + @Test void englishRelationshipLabelCarriesOneTrailingSpace() { String englishLabel = Vocabulary.forLanguage("en-US").get("label.relationship"); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java index 2391ba58..86462a2c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java @@ -51,6 +51,7 @@ void evaluationRequestRendersOnlyTheContentsToEvaluateSection(String language) { "Please assess the adjusted rate target.", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, List.of(new NegotiationItem("adjusted target", "rate lowered to 2Mbps")), + null, null)), FEASIBILITY_PROPOSE_URI) .promptText(); @@ -73,7 +74,8 @@ void alternativeProposalRendersOnlyTheInfeasibilitySection(String language) { NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, List.of(new NegotiationItem( - "proposal", "lower the rate guarantee target to 2Mbps")))), + "proposal", "lower the rate guarantee target to 2Mbps")), + null)), FEASIBILITY_PROPOSE_URI) .promptText(); @@ -94,6 +96,7 @@ void firstRoundRendersIntentWithoutAlignment(String language) { "Clarify the intent of the ran-energy-saving task.", List.of(new NegotiationItem("task intent", "ran-energy-saving optimization")), null, + null, null)), TARGET_PROPOSE_URI) .promptText(); @@ -115,6 +118,7 @@ void laterRoundsRenderAlignmentWithoutIntent(String language) { "Clarify the intent of the ran-energy-saving task.", null, List.of(new NegotiationItem("task intent", "confirmed as correct")), + null, null)), TARGET_PROPOSE_URI) .promptText(); @@ -123,6 +127,58 @@ void laterRoundsRenderAlignmentWithoutIntent(String language) { assertFalse(promptText.contains(intentTitle(language))); } + @ParameterizedTest(name = "target confirm request renders only the confirm section [{0}]") + @MethodSource("languages") + void targetConfirmRequestRendersOnlyTheConfirmSection(String language) { + NegotiationGenerationOrchestrator orchestrator = orchestrator(language); + + String promptText = orchestrator + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), + new TargetProposeContent( + "The clarification of the task target has been completed. Please reply to" + + " .", + null, + null, + null, + targetConfirmWording(language))), + TARGET_PROPOSE_URI) + .promptText(); + + assertTrue(promptText.contains(targetConfirmTitle(language))); + assertTrue(promptText.contains(targetConfirmWording(language))); + assertFalse(promptText.contains(intentTitle(language))); + assertFalse(promptText.contains(alignmentTitle(language))); + assertFalse(promptText.contains(clarificationTitle(language))); + } + + @ParameterizedTest(name = "feasibility confirm request renders only the confirm section [{0}]") + @MethodSource("languages") + void feasibilityConfirmRequestRendersOnlyTheConfirmSection(String language) { + NegotiationGenerationOrchestrator orchestrator = orchestrator(language); + + String promptText = orchestrator + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), + new FeasibilityProposeContent( + "Regarding the adjusted rate guarantee target, the feasibility assessment has" + + " been completed and the conclusion is feasible. Please reply to" + + " .", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + null, + null, + feasibilityConfirmWording(language))), + FEASIBILITY_PROPOSE_URI) + .promptText(); + + assertTrue(promptText.contains(feasibilityConfirmTitle(language))); + assertTrue(promptText.contains(feasibilityConfirmWording(language))); + assertFalse(promptText.contains(evaluateTitle(language))); + assertFalse(promptText.contains(infeasibleTitle(language))); + } + @ParameterizedTest(name = "empty clarification list drops the clarification section [{0}]") @MethodSource("languages") void emptyClarificationListDropsTheClarificationSection(String language) { @@ -136,6 +192,7 @@ void emptyClarificationListDropsTheClarificationSection(String language) { "Confirm the understood intent of the ran-energy-saving task.", List.of(new NegotiationItem("task intent", "ran-energy-saving optimization")), null, + null, null)), TARGET_PROPOSE_URI) .promptText(); @@ -149,7 +206,8 @@ void emptyClarificationListDropsTheClarificationSection(String language) { "Confirm the understood intent of the ran-energy-saving task.", List.of(new NegotiationItem("task intent", "ran-energy-saving optimization")), null, - List.of(new NegotiationItem("area", "which site is covered")))), + List.of(new NegotiationItem("area", "which site is covered")), + null)), TARGET_PROPOSE_URI) .promptText(); assertTrue(withClarification.contains(clarificationTitle(language))); @@ -180,4 +238,24 @@ private static String alignmentTitle(String language) { private static String clarificationTitle(String language) { return "zh-CN".equals(language) ? "## 待澄清内容" : "## Content to Clarify"; } + + private static String targetConfirmTitle(String language) { + return "zh-CN".equals(language) ? "## 目标澄清后的确认请求" : "## Target Clarification Confirmation Request"; + } + + private static String targetConfirmWording(String language) { + return "zh-CN".equals(language) + ? "目标已经澄清,是否同意按照此目标继续执行?" + : "The target has been clarified. Do you agree to proceed with this target?"; + } + + private static String feasibilityConfirmTitle(String language) { + return "zh-CN".equals(language) ? "## 评估可行时的确认请求" : "## Feasible Evaluation Confirmation Request"; + } + + private static String feasibilityConfirmWording(String language) { + return "zh-CN".equals(language) + ? "评估目标可行,是否同意按照此目标继续执行?" + : "The target is assessed as feasible. Do you agree to proceed with this target?"; + } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java index c9ef2059..7dffd1ef 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java @@ -121,7 +121,7 @@ private List matrix() { new MatrixRow( "target propose with blank description", () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, new TargetProposeContent(" ", null, null, null)), + new NegotiationProposeData(context, new TargetProposeContent(" ", null, null, null, null)), TARGET_PROPOSE_URI), IllegalArgumentException.class, "Target negotiation description must not be blank."), @@ -134,6 +134,7 @@ private List matrix() { " ", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, List.of(new NegotiationItem("目标", "2Mbps")), + null, null)), FEASIBILITY_PROPOSE_URI), IllegalArgumentException.class, @@ -144,7 +145,8 @@ private List matrix() { new NegotiationProposeData( context, new FeasibilityProposeContent( - "请评估。", null, List.of(new NegotiationItem("目标", "2Mbps")), null)), + "请评估。", null, List.of(new NegotiationItem("目标", "2Mbps")), null, + null)), FEASIBILITY_PROPOSE_URI), NullPointerException.class, "Feasibility negotiation action must not be null"), @@ -160,7 +162,7 @@ acceptContext, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " ")), "template URI type contradicts the content type", () -> orchestrator.generateProposeFromData( new NegotiationProposeData( - context, new TargetProposeContent("目标协商概述。", null, null, null)), + context, new TargetProposeContent("目标协商概述。", null, null, null, null)), INFORMATION_PROPOSE_URI), IllegalArgumentException.class, "Negotiation type INFORMATION requires content of type InformationProposeContent")); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index fd4f4df0..e5b3fba3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -660,6 +660,8 @@ private static String extractionJson(NegotiationContent content, NegotiationPerf + itemsJson(target.alignmentAndClarification()) + ",\"request_for_clarification\":" + itemsJson(target.requestForClarification()) + + ",\"target_confirm_request\":" + + stringOrNull(target.targetConfirmRequest()) + "}"; } FeasibilityProposeContent feasibility = (FeasibilityProposeContent) content; @@ -671,6 +673,8 @@ private static String extractionJson(NegotiationContent content, NegotiationPerf + itemsJson(feasibility.contentsToEvaluate()) + ",\"infeasibility_details_and_proposal\":" + itemsJson(feasibility.infeasibilityDetailsAndProposal()) + + ",\"feasibility_confirm_request\":" + + stringOrNull(feasibility.feasibilityConfirmRequest()) + "}"; } if (content instanceof NegotiationAbortContent abort) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java index 24584a2f..b36cf8f3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java @@ -97,6 +97,63 @@ void extractsFeasibilityProposeContentForBothActions() { List.of(new NegotiationItem("替代提案", "下调至2Mbps")), alternativeContent.infeasibilityDetailsAndProposal()); } + @Test + void extractsTargetProposeConfirmRequestContent() { + NegotiationContent content = new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"target_negotiation_description\":\"任务目标澄清完成,请答复<目标澄清后的确认请求>。\"," + + "\"intent_understanding\":null," + + "\"alignment_and_clarification\":null," + + "\"request_for_clarification\":null," + + "\"target_confirm_request\":\"目标已经澄清,是否同意按照此目标继续执行?\"}")) + .extract("目标已经澄清,请确认。", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "zh-CN")); + + TargetProposeContent proposeContent = assertInstanceOf(TargetProposeContent.class, content); + assertEquals("任务目标澄清完成,请答复<目标澄清后的确认请求>。", proposeContent.targetNegotiationDescription()); + assertEquals("目标已经澄清,是否同意按照此目标继续执行?", proposeContent.targetConfirmRequest()); + assertNull(proposeContent.intentUnderstanding()); + assertNull(proposeContent.alignmentAndClarification()); + assertNull(proposeContent.requestForClarification()); + } + + @Test + void extractsFeasibilityProposeConfirmRequestContent() { + NegotiationContent content = new DefaultNegotiationContentExtractor( + new RecordingClient( + "{\"feasibility_negotiation_description\":\"针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。\"," + + "\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + + "\"contents_to_evaluate\":null," + + "\"infeasibility_details_and_proposal\":null," + + "\"feasibility_confirm_request\":\"评估目标可行,是否同意按照此目标继续执行?\"}")) + .extract("评估目标可行,请确认。", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN")); + + FeasibilityProposeContent proposeContent = assertInstanceOf(FeasibilityProposeContent.class, content); + assertEquals(NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, proposeContent.action()); + assertEquals("评估目标可行,是否同意按照此目标继续执行?", proposeContent.feasibilityConfirmRequest()); + assertNull(proposeContent.contentsToEvaluate()); + assertNull(proposeContent.infeasibilityDetailsAndProposal()); + } + + @Test + void passesConfirmRequestWordingThroughLeniently() { + TargetProposeContent target = assertInstanceOf( + TargetProposeContent.class, + new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"target_negotiation_description\":\"描述\",\"intent_understanding\":null," + + "\"alignment_and_clarification\":null,\"request_for_clarification\":null," + + "\"target_confirm_request\":\"请确认按此目标推进\"}")) + .extract("文本", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "zh-CN"))); + assertEquals("请确认按此目标推进", target.targetConfirmRequest()); + + FeasibilityProposeContent feasibility = assertInstanceOf( + FeasibilityProposeContent.class, + new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"描述\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + + "\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null," + + "\"feasibility_confirm_request\":\"方案可行,望确认\"}")) + .extract("文本", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); + assertEquals("方案可行,望确认", feasibility.feasibilityConfirmRequest()); + } + @Test void extractsEndingContentForEveryTypeAndBothPhases() { NegotiationContent infoAccept = new DefaultNegotiationContentExtractor(new RecordingClient( diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java index d8aa242a..1b0feabe 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java @@ -1,188 +1,189 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import org.junit.jupiter.api.Test; - -class NegotiationGeneratorRegistryTest { - - private final NegotiationGeneratorRegistry registry = new NegotiationGeneratorRegistry(); - - @Test - void dispatchesEveryTypeAndPhaseCombination() { - assertInstanceOf( - InformationProposeGenerator.class, - registry.resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.PROPOSE, - new InformationProposeContent(List.of(), null))); - assertInstanceOf( - TargetProposeGenerator.class, - registry.resolve( - NegotiationType.TARGET, - NegotiationPerformative.PROPOSE, - new TargetProposeContent("描述", null, null, null))); - assertInstanceOf( - FeasibilityProposeGenerator.class, - registry.resolve( - NegotiationType.FEASIBILITY, - NegotiationPerformative.PROPOSE, - new FeasibilityProposeContent( - "描述", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("名称", "值")), - null))); - assertInstanceOf( - InformationEndingGenerator.class, - registry.resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.ACCEPT, - new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); - assertInstanceOf( - InformationEndingGenerator.class, - registry.resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.REJECT, - new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); - assertInstanceOf( - TargetEndingGenerator.class, - registry.resolve( - NegotiationType.TARGET, - NegotiationPerformative.ACCEPT, - new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); - assertInstanceOf( - TargetEndingGenerator.class, - registry.resolve( - NegotiationType.TARGET, - NegotiationPerformative.REJECT, - new TargetEndingContent(NegotiationConclusion.REJECT, null, "失败原因"))); - assertInstanceOf( - FeasibilityEndingGenerator.class, - registry.resolve( - NegotiationType.FEASIBILITY, - NegotiationPerformative.ACCEPT, - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "评估结论"))); - assertInstanceOf( - FeasibilityEndingGenerator.class, - registry.resolve( - NegotiationType.FEASIBILITY, - NegotiationPerformative.REJECT, - new FeasibilityEndingContent(NegotiationConclusion.REJECT, "评估结论"))); - } - - @Test - void rejectsProposeContentInTerminalPhaseAndEndingContentInProposePhase() { - IllegalArgumentException proposeInEnding = assertThrows( - IllegalArgumentException.class, - () -> registry.resolve( - NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, new InformationProposeContent(List.of(), null))); - assertTrue(proposeInEnding - .getMessage() - .contains("ACCEPT phase requires ending content but received propose content of type" - + " InformationProposeContent")); - - IllegalArgumentException endingInPropose = assertThrows( - IllegalArgumentException.class, - () -> registry.resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.PROPOSE, - new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); - assertTrue(endingInPropose - .getMessage() - .contains("PROPOSE phase requires propose content but received ending content of type" - + " InformationEndingContent")); - } - - @Test - void rejectsContentRuntimeTypeNotMatchingTheNegotiationType() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> registry.resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.PROPOSE, - new TargetProposeContent("描述", null, null, null))); - - assertTrue(exception - .getMessage() - .contains("Negotiation type INFORMATION requires content of type InformationProposeContent but received" - + " TargetProposeContent")); - - assertThrows( - IllegalArgumentException.class, - () -> registry.resolve( - NegotiationType.TARGET, - NegotiationPerformative.ACCEPT, - new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); - } - - @Test - void rejectsEndingConclusionNotMatchingThePhase() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> registry.resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.ACCEPT, - new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); - - assertTrue(exception - .getMessage() - .contains("ACCEPT phase requires conclusion Accept but the content carries Reject")); - - assertThrows( - IllegalArgumentException.class, - () -> registry.resolve( - NegotiationType.TARGET, - NegotiationPerformative.REJECT, - new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); - } - - @Test - void rejectsEndingContentWithoutConclusion() { - NullPointerException exception = assertThrows( - NullPointerException.class, - () -> registry.resolve( - NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, new InformationEndingContent(null, List.of()))); - - assertTrue(exception.getMessage().contains("conclusion must not be null")); - } - - @Test - void rejectsNullArguments() { - NegotiationContent content = new InformationProposeContent(List.of(), null); - - assertEquals( - "Negotiation type must not be null for the PROPOSE phase.", - assertThrows( - NullPointerException.class, - () -> registry.resolve(null, NegotiationPerformative.PROPOSE, content)) - .getMessage()); - assertEquals( - "Negotiation phase must not be null.", - assertThrows( - NullPointerException.class, - () -> registry.resolve(NegotiationType.INFORMATION, null, content)) - .getMessage()); - assertEquals( - "Negotiation content must not be null.", - assertThrows( - NullPointerException.class, - () -> registry.resolve(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, null)) - .getMessage()); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.junit.jupiter.api.Test; + +class NegotiationGeneratorRegistryTest { + + private final NegotiationGeneratorRegistry registry = new NegotiationGeneratorRegistry(); + + @Test + void dispatchesEveryTypeAndPhaseCombination() { + assertInstanceOf( + InformationProposeGenerator.class, + registry.resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.PROPOSE, + new InformationProposeContent(List.of(), null))); + assertInstanceOf( + TargetProposeGenerator.class, + registry.resolve( + NegotiationType.TARGET, + NegotiationPerformative.PROPOSE, + new TargetProposeContent("描述", null, null, null, null))); + assertInstanceOf( + FeasibilityProposeGenerator.class, + registry.resolve( + NegotiationType.FEASIBILITY, + NegotiationPerformative.PROPOSE, + new FeasibilityProposeContent( + "描述", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("名称", "值")), + null, + null))); + assertInstanceOf( + InformationEndingGenerator.class, + registry.resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + assertInstanceOf( + InformationEndingGenerator.class, + registry.resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.REJECT, + new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); + assertInstanceOf( + TargetEndingGenerator.class, + registry.resolve( + NegotiationType.TARGET, + NegotiationPerformative.ACCEPT, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); + assertInstanceOf( + TargetEndingGenerator.class, + registry.resolve( + NegotiationType.TARGET, + NegotiationPerformative.REJECT, + new TargetEndingContent(NegotiationConclusion.REJECT, null, "失败原因"))); + assertInstanceOf( + FeasibilityEndingGenerator.class, + registry.resolve( + NegotiationType.FEASIBILITY, + NegotiationPerformative.ACCEPT, + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "评估结论"))); + assertInstanceOf( + FeasibilityEndingGenerator.class, + registry.resolve( + NegotiationType.FEASIBILITY, + NegotiationPerformative.REJECT, + new FeasibilityEndingContent(NegotiationConclusion.REJECT, "评估结论"))); + } + + @Test + void rejectsProposeContentInTerminalPhaseAndEndingContentInProposePhase() { + IllegalArgumentException proposeInEnding = assertThrows( + IllegalArgumentException.class, + () -> registry.resolve( + NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, new InformationProposeContent(List.of(), null))); + assertTrue(proposeInEnding + .getMessage() + .contains("ACCEPT phase requires ending content but received propose content of type" + + " InformationProposeContent")); + + IllegalArgumentException endingInPropose = assertThrows( + IllegalArgumentException.class, + () -> registry.resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.PROPOSE, + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + assertTrue(endingInPropose + .getMessage() + .contains("PROPOSE phase requires propose content but received ending content of type" + + " InformationEndingContent")); + } + + @Test + void rejectsContentRuntimeTypeNotMatchingTheNegotiationType() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> registry.resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.PROPOSE, + new TargetProposeContent("描述", null, null, null, null))); + + assertTrue(exception + .getMessage() + .contains("Negotiation type INFORMATION requires content of type InformationProposeContent but received" + + " TargetProposeContent")); + + assertThrows( + IllegalArgumentException.class, + () -> registry.resolve( + NegotiationType.TARGET, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + } + + @Test + void rejectsEndingConclusionNotMatchingThePhase() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> registry.resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); + + assertTrue(exception + .getMessage() + .contains("ACCEPT phase requires conclusion Accept but the content carries Reject")); + + assertThrows( + IllegalArgumentException.class, + () -> registry.resolve( + NegotiationType.TARGET, + NegotiationPerformative.REJECT, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); + } + + @Test + void rejectsEndingContentWithoutConclusion() { + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> registry.resolve( + NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, new InformationEndingContent(null, List.of()))); + + assertTrue(exception.getMessage().contains("conclusion must not be null")); + } + + @Test + void rejectsNullArguments() { + NegotiationContent content = new InformationProposeContent(List.of(), null); + + assertEquals( + "Negotiation type must not be null for the PROPOSE phase.", + assertThrows( + NullPointerException.class, + () -> registry.resolve(null, NegotiationPerformative.PROPOSE, content)) + .getMessage()); + assertEquals( + "Negotiation phase must not be null.", + assertThrows( + NullPointerException.class, + () -> registry.resolve(NegotiationType.INFORMATION, null, content)) + .getMessage()); + assertEquals( + "Negotiation content must not be null.", + assertThrows( + NullPointerException.class, + () -> registry.resolve(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, null)) + .getMessage()); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java index c26624d0..4787946f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -80,6 +80,67 @@ class NegotiationGeneratorsTest { 提案要求。 """; + private static final String ZH_TARGET_PROPOSE_CONFIRM_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 目标协商 + {{目标协商概述}}(必填) + 要求: + 概述要求。 + + ## 意图理解陈述 + {{意图理解陈述}}(选填) + 要求: + 意图要求。 + + ## 理解对齐与疑问澄清 + {{理解对齐与疑问澄清}}(选填) + 要求: + 对齐要求。 + + ## 待澄清内容 + {{待澄清内容}}(选填) + 要求: + 澄清要求。 + + ## 目标澄清后的确认请求 + {{目标澄清后的确认请求}}(选填) + 要求: + 确认要求。 + """; + + private static final String ZH_FEASIBILITY_PROPOSE_CONFIRM_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 可行性协商 + {{可行性协商概述}}(必填) + 要求: + 概述要求。 + + ## 待评估内容说明 + {{待评估内容说明}}(选填) + 要求: + 评估要求。 + + ## 评估不可行时的详情和提案 + {{评估不可行时的详情和提案}}(选填) + 要求: + 提案要求。 + + ## 评估可行时的确认请求 + {{评估可行时的确认请求}}(选填) + 要求: + 确认要求。 + """; + private static final String ZH_INFO_PROPOSE_TEMPLATE = """ ## 协商上下文 @@ -160,7 +221,8 @@ void feasibilityRequestActionRendersExactlyTheEvaluationSection() { "请协助评估该节能目标能否达成。", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, List.of(new NegotiationItem("评估对象", "停电8小时期间核心用户的速率保障")), - List.of(new NegotiationItem("不应出现", "值"))); + List.of(new NegotiationItem("不应出现", "值")), + null); String rendered = new FeasibilityProposeGenerator() .generate( @@ -183,7 +245,8 @@ void feasibilityAlternativeActionRendersExactlyTheInfeasibilitySection() { List.of(new NegotiationItem("不应出现", "值")), List.of( new NegotiationItem("不可行原因", "蓄电池仅能支撑8小时2Mbps的保障能力"), - new NegotiationItem("替代提案", "停电期间将速率保障目标下调至2Mbps"))); + new NegotiationItem("替代提案", "停电期间将速率保障目标下调至2Mbps")), + null); String rendered = new FeasibilityProposeGenerator() .generate( @@ -201,7 +264,7 @@ void feasibilityAlternativeActionRendersExactlyTheInfeasibilitySection() { @Test void feasibilityNullActionOrEmptyDrivenContentIsRejected() { FeasibilityProposeContent nullAction = - new FeasibilityProposeContent("描述", null, List.of(new NegotiationItem("名称", "值")), null); + new FeasibilityProposeContent("描述", null, List.of(new NegotiationItem("名称", "值")), null, null); NullPointerException nullActionError = assertThrows(NullPointerException.class, () -> new FeasibilityProposeGenerator() @@ -215,7 +278,8 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { assertTrue(nullActionError.getMessage().contains("action must not be null")); FeasibilityProposeContent emptyEvaluation = - new FeasibilityProposeContent("描述", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null); + new FeasibilityProposeContent( + "描述", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null, null); IllegalArgumentException emptyEvaluationError = assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() @@ -231,7 +295,8 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { emptyEvaluationError.getMessage()); FeasibilityProposeContent emptyAlternative = - new FeasibilityProposeContent("描述", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, List.of()); + new FeasibilityProposeContent( + "描述", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, List.of(), null); IllegalArgumentException emptyAlternativeError = assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() @@ -247,7 +312,7 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { emptyAlternativeError.getMessage()); FeasibilityProposeContent blankDescription = new FeasibilityProposeContent( - " ", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, List.of(new NegotiationItem("名称", "值")), null); + " ", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, List.of(new NegotiationItem("名称", "值")), null, null); assertEquals( "Feasibility negotiation description must not be blank.", @@ -262,13 +327,125 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { .getMessage()); } + @Test + void targetConfirmRequestRendersOnlyTheSummaryAndConfirmRequestSections() { + TargetProposeContent content = new TargetProposeContent( + "任务目标澄清完成,请答复<目标澄清后的确认请求>。", + null, + null, + null, + "目标已经澄清,是否同意按照此目标继续执行?"); + + String rendered = new TargetProposeGenerator() + .generate( + context(2), + content, + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_CONFIRM_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 目标协商\n任务目标澄清完成,请答复<目标澄清后的确认请求>。")); + assertTrue(rendered.contains("## 目标澄清后的确认请求\n目标已经澄清,是否同意按照此目标继续执行?")); + assertFalse(rendered.contains("## 意图理解陈述")); + assertFalse(rendered.contains("## 理解对齐与疑问澄清")); + assertFalse(rendered.contains("## 待澄清内容")); + assertFalse(rendered.contains("要求:")); + + String renderedEn = new TargetProposeGenerator() + .generate( + context(2), + new TargetProposeContent( + "The clarification of the task target has been completed. Please reply to .", + null, + null, + null, + "The target has been clarified. Do you agree to proceed with this target?"), + template( + StandardTemplates.TARGET_NEGOTIATION_PROPOSE, + """ + ## Target Negotiation + {{target_negotiation_summary}} (required) + Requirement: summary. + + ## Intent Understanding Statement + {{intent_understanding_statement}} (optional) + Requirement: intent. + + ## Target Clarification Confirmation Request + {{target_confirm_request}} (optional) + Requirement: confirm. + """), + enVocabulary); + + assertTrue(renderedEn.contains("## Target Clarification Confirmation Request\nThe target has been clarified." + + " Do you agree to proceed with this target?")); + assertFalse(renderedEn.contains("## Intent Understanding Statement")); + } + + @Test + void feasibilityConfirmRequestRendersOnlyTheSummaryAndConfirmRequestSections() { + FeasibilityProposeContent content = new FeasibilityProposeContent( + "针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + null, + null, + "评估目标可行,是否同意按照此目标继续执行?"); + + String rendered = new FeasibilityProposeGenerator() + .generate( + context(2), + content, + template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_CONFIRM_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains( + "## 可行性协商\n针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。")); + assertTrue(rendered.contains("## 评估可行时的确认请求\n评估目标可行,是否同意按照此目标继续执行?")); + assertFalse(rendered.contains("## 待评估内容说明")); + assertFalse(rendered.contains("## 评估不可行时的详情和提案")); + assertFalse(rendered.contains("要求:")); + + String renderedEn = new FeasibilityProposeGenerator() + .generate( + context(2), + new FeasibilityProposeContent( + "Regarding the adjusted rate guarantee target, the feasibility assessment has been" + + " completed and the conclusion is feasible. Please reply to .", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + null, + null, + "The target is assessed as feasible. Do you agree to proceed with this target?"), + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + """ + ## Feasibility Negotiation + {{feasibility_negotiation_summary}} (required) + Requirement: summary. + + ## Under Evaluation Description + {{under_evaluation_description}} (optional) + Requirement: evaluate. + + ## Feasible Evaluation Confirmation Request + {{feasibility_confirm_request}} (optional) + Requirement: confirm. + """), + enVocabulary); + + assertTrue(renderedEn.contains("## Feasible Evaluation Confirmation Request\nThe target is assessed as" + + " feasible. Do you agree to proceed with this target?")); + assertFalse(renderedEn.contains("## Under Evaluation Description")); + } + @Test void targetFirstRoundRendersIntentSectionAndDropsAlignmentSection() { TargetProposeContent content = new TargetProposeContent( "对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。", List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), List.of(new NegotiationItem("对齐项", "不应在首轮渲染")), - List.of(new NegotiationItem("节能时间范围", "需要澄清"))); + List.of(new NegotiationItem("节能时间范围", "需要澄清")), + null); String rendered = new TargetProposeGenerator() .generate( @@ -289,6 +466,7 @@ void targetLaterRoundRendersAlignmentSectionAndDropsIntentSection() { "已提供疑问澄清,请确认。", List.of(new NegotiationItem("意图项", "不应在非首轮渲染")), List.of(new NegotiationItem("确认结果", "节能时间范围确认为08:00~18:00")), + null, null); String rendered = new TargetProposeGenerator() @@ -305,7 +483,7 @@ void targetLaterRoundRendersAlignmentSectionAndDropsIntentSection() { @Test void targetBlankDescriptionIsRejected() { - TargetProposeContent content = new TargetProposeContent(" ", null, null, null); + TargetProposeContent content = new TargetProposeContent(" ", null, null, null, null); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> new TargetProposeGenerator() @@ -527,7 +705,7 @@ void generatorRejectsContentOfAnotherRuntimeType() { assertThrows(IllegalArgumentException.class, () -> new InformationProposeGenerator() .generate( context(1), - new TargetProposeContent("描述", null, null, null), + new TargetProposeContent("描述", null, null, null, null), template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), zhVocabulary)); @@ -545,7 +723,8 @@ void generatorsRenderAgainstTheBundledTemplates() { "对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。", List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), null, - List.of(new NegotiationItem("节能时间范围", "需要澄清"))); + List.of(new NegotiationItem("节能时间范围", "需要澄清")), + null); String rendered = new TargetProposeGenerator().generate(context(1), content, loaded, zhVocabulary); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGoldenCases.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGoldenCases.java index 71aadd11..dc75c448 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGoldenCases.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGoldenCases.java @@ -1,486 +1,557 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; - -/** - * Negotiation-module-local copy of the golden fixture inputs for the staying unit behavior tests. - * - *

        The canonical golden fixture system (the {@code GoldenInputs} model, the generator and the comparison guard, - * plus the committed fixture files) lives in the {@code a2a-t-corpus} consistency module. The unit behavior tests - * that remain in this module keep asserting byte equality against the same fixed inputs and rendered golden - * texts, so the needed fragments are copied here: the fixed contexts, the fixed typed content per language, the - * built-in template URIs and the byte-exact golden texts of the committed fixtures inlined as constants. - * - *

        Fixed inputs of the golden fixture set of the negotiation content layer. - * - *

        Every golden fixture is rendered from exactly one {@link GoldenCase} carrying a fixed negotiation context, fixed - * typed content and the built-in template URI of its negotiation type and phase. The fixture data lives in the - * private-line complaint diagnosis business domain (see {@code docs-local/business-facts-dictionary.md}): the - * information fixtures carry the access port name and complaint category of the OMC workbench complaint loop, the - * target fixtures negotiate the latency repair target, and the feasibility fixtures assess the port expansion. - * The typed content is language-dependent — zh-CN fixtures carry Chinese business data, en-US fixtures the English - * business form — so each language renders its own golden fixture with real business data in that language. - * - *

        The fixture data deliberately exercises the known rendering pitfalls: a non-null relationship with an appended - * line and a null-value item (information propose), the round-driven conditional sections with a non-empty - * clarification list (target propose, round 1), the action-driven conditional section (feasibility propose, evaluation - * request action), the accept and reject conclusion literals, the feasibility summary rendered into the - * vocabulary-exception slot of the feasibility result confirmation section, and the common abort template rendering - * the termination reason. - */ -public final class NegotiationGoldenCases { - - /** Fixed negotiation session id shared by every golden fixture. */ - public static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - /** Language with bundled zh-CN negotiation resources. */ - public static final String ZH_CN = "zh-CN"; - - /** Language with bundled en-US negotiation resources. */ - public static final String EN_US = "en-US"; - - /** Both languages covered by the golden fixture set, in a fixed order. */ - public static final List LANGUAGES = List.of(ZH_CN, EN_US); - - /** Golden fixture text of zh-CN/abort. */ - private static final String GOLDEN_zh_CN_ABORT = "## 协商结果\nAbort\n\n## 协商终止原因\n已达到协商轮次上限,本次协商确认终止。"; - /** Golden fixture text of zh-CN/feasibility_accept. */ - private static final String GOLDEN_zh_CN_FEASIBILITY_ACCEPT = "## 可行性协商结果\nAccept\n\n## 可行性评估结果确认\n端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。"; - /** Golden fixture text of zh-CN/feasibility_propose. */ - private static final String GOLDEN_zh_CN_FEASIBILITY_PROPOSE = "## 可行性协商\n请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。\n\n## 待评估内容说明\n1. 扩容方案:接入端口带宽由100Mbps扩容至1000Mbps\n2. 既有约束:割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟"; - /** Golden fixture text of zh-CN/feasibility_reject. */ - private static final String GOLDEN_zh_CN_FEASIBILITY_REJECT = "## 可行性协商结果\nReject\n\n## 可行性评估结果确认\n受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。"; - /** Golden fixture text of zh-CN/information_accept. */ - private static final String GOLDEN_zh_CN_INFORMATION_ACCEPT = "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:专线质差"; - /** Golden fixture text of zh-CN/information_propose. */ - private static final String GOLDEN_zh_CN_INFORMATION_PROPOSE = "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR"; - /** Golden fixture text of zh-CN/information_reject. */ - private static final String GOLDEN_zh_CN_INFORMATION_REJECT = "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,工作台侧端口资源台账暂不可查"; - /** Golden fixture text of zh-CN/target_accept. */ - private static final String GOLDEN_zh_CN_TARGET_ACCEPT = "## 目标协商结果\nAccept\n\n## 目标协商结果内容\n最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。"; - /** Golden fixture text of zh-CN/target_propose. */ - private static final String GOLDEN_zh_CN_TARGET_PROPOSE = "## 目标协商\n专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。\n\n## 意图理解陈述\n1. 修复意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内\n2. 修复目标:高峰时段丢包率不高于1%\n\n## 待澄清内容\n1. 时延目标:平均时延目标是恢复至20ms以内还是50ms以内\n2. 完成时限:修复完成时限是48小时内还是72小时内"; - /** Golden fixture text of zh-CN/target_reject. */ - private static final String GOLDEN_zh_CN_TARGET_REJECT = "## 目标协商结果\nReject\n\n## 目标协商结果内容\n因光缆割接窗口尚未确认,时延目标未能完全澄清。"; - /** Golden fixture text of en-US/abort. */ - private static final String GOLDEN_en_US_ABORT = "## Negotiation Result\nAbort\n\n## Negotiation Termination Reason\nThe negotiation round limit is reached; this negotiation is confirmed as terminated."; - /** Golden fixture text of en-US/feasibility_accept. */ - private static final String GOLDEN_en_US_FEASIBILITY_ACCEPT = "## Feasibility Negotiation Result\nAccept\n\n## Feasibility Assessment Result Confirmation\nThe port expansion can be completed within the cutover window and satisfies the 30-minute interruption constraint; this negotiation is confirmed as concluded."; - /** Golden fixture text of en-US/feasibility_propose. */ - private static final String GOLDEN_en_US_FEASIBILITY_PROPOSE = "## Feasibility Negotiation\nPlease assess whether the dedicated-line access port expansion can be completed within the cutover window, see ; please assess.\n\n## Under Evaluation Description\n1. expansion plan: expand the access port bandwidth from 100Mbps to 1000Mbps\n2. existing constraint: the cutover window is limited to 02:00-06:00 on 2026-05-30 with service interruption no longer than 30 minutes"; - /** Golden fixture text of en-US/feasibility_reject. */ - private static final String GOLDEN_en_US_FEASIBILITY_REJECT = "## Feasibility Negotiation Result\nReject\n\n## Feasibility Assessment Result Confirmation\nThe port expansion cannot be completed within the designated cutover window because of insufficient board slots in the aggregation room; this negotiation is confirmed as concluded."; - /** Golden fixture text of en-US/information_accept. */ - private static final String GOLDEN_en_US_INFORMATION_ACCEPT = "## Information Negotiation Result\nAccept\n\n## Information Negotiation Result Content\n1. Access Port Name: P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\n2. Complaint Category: dedicated-line quality degradation"; - /** Golden fixture text of en-US/information_propose. */ - private static final String GOLDEN_en_US_INFORMATION_PROPOSE = "## Information Negotiation\nPlease supplement the relevant content based on .\n\n## Required Information Items\n1. Access Port Name: e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\n2. Complaint Category: e.g. dedicated-line quality degradation\n3. Private Line Service Identifier\nRelationship between missing items: OR"; - /** Golden fixture text of en-US/information_reject. */ - private static final String GOLDEN_en_US_INFORMATION_REJECT = "## Information Negotiation Result\nReject\n\n## Information Negotiation Result Content\n1. Access Port Name: cannot be provided because the port inventory is temporarily unavailable on the workbench side"; - /** Golden fixture text of en-US/target_accept. */ - private static final String GOLDEN_en_US_TARGET_ACCEPT = "## Target Negotiation Result\nAccept\n\n## Target Negotiation Result Content\nThe finally confirmed intent: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15, with a peak-hour packet loss rate no higher than 1%."; - /** Golden fixture text of en-US/target_propose. */ - private static final String GOLDEN_en_US_TARGET_PROPOSE = "## Target Negotiation\nThe intent understanding of the dedicated-line quality degradation repair target is listed in ; open questions remain about the latency target and the completion deadline, see ; please clarify and confirm.\n\n## Intent Understanding Statement\n1. repair intent: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15\n2. repair target: peak-hour packet loss rate no higher than 1%\n\n## Content to Clarify\n1. latency target: is the average latency target within 20ms or within 50ms\n2. completion deadline: is the repair deadline 48 hours or 72 hours"; - /** Golden fixture text of en-US/target_reject. */ - private static final String GOLDEN_en_US_TARGET_REJECT = "## Target Negotiation Result\nReject\n\n## Target Negotiation Result Content\nThe latency target cannot be clarified in full because the fiber cutover window is not confirmed yet."; - - private static final Map GOLDEN_TEXTS = Map.ofEntries( - Map.entry("zh-CN/abort", GOLDEN_zh_CN_ABORT), - Map.entry("zh-CN/feasibility_accept", GOLDEN_zh_CN_FEASIBILITY_ACCEPT), - Map.entry("zh-CN/feasibility_propose", GOLDEN_zh_CN_FEASIBILITY_PROPOSE), - Map.entry("zh-CN/feasibility_reject", GOLDEN_zh_CN_FEASIBILITY_REJECT), - Map.entry("zh-CN/information_accept", GOLDEN_zh_CN_INFORMATION_ACCEPT), - Map.entry("zh-CN/information_propose", GOLDEN_zh_CN_INFORMATION_PROPOSE), - Map.entry("zh-CN/information_reject", GOLDEN_zh_CN_INFORMATION_REJECT), - Map.entry("zh-CN/target_accept", GOLDEN_zh_CN_TARGET_ACCEPT), - Map.entry("zh-CN/target_propose", GOLDEN_zh_CN_TARGET_PROPOSE), - Map.entry("zh-CN/target_reject", GOLDEN_zh_CN_TARGET_REJECT), - Map.entry("en-US/abort", GOLDEN_en_US_ABORT), - Map.entry("en-US/feasibility_accept", GOLDEN_en_US_FEASIBILITY_ACCEPT), - Map.entry("en-US/feasibility_propose", GOLDEN_en_US_FEASIBILITY_PROPOSE), - Map.entry("en-US/feasibility_reject", GOLDEN_en_US_FEASIBILITY_REJECT), - Map.entry("en-US/information_accept", GOLDEN_en_US_INFORMATION_ACCEPT), - Map.entry("en-US/information_propose", GOLDEN_en_US_INFORMATION_PROPOSE), - Map.entry("en-US/information_reject", GOLDEN_en_US_INFORMATION_REJECT), - Map.entry("en-US/target_accept", GOLDEN_en_US_TARGET_ACCEPT), - Map.entry("en-US/target_propose", GOLDEN_en_US_TARGET_PROPOSE), - Map.entry("en-US/target_reject", GOLDEN_en_US_TARGET_REJECT)); - - private NegotiationGoldenCases() {} - - /** - * Returns the default fixture context: round 2 of at most 5 rounds, carrying the fixture's performative. - * - * @param performative performative of the fixture the context belongs to - * @return fixed negotiation context of every fixture except the target propose fixture - */ - public static NegotiationContext defaultContext(NegotiationPerformative performative) { - return new NegotiationContext(SESSION_ID, 2, 5, performative); - } - - /** - * Returns the first-round fixture context used by the target propose fixture, carrying the fixture's performative. - * - * @param performative performative of the fixture the context belongs to - * @return fixed negotiation context of round 1 of at most 5 rounds - */ - public static NegotiationContext firstRoundContext(NegotiationPerformative performative) { - return new NegotiationContext(SESSION_ID, 1, 5, performative); - } - - /** One golden fixture case: one negotiation type, performative, fixed context, fixed content and its template URI. */ - public enum GoldenCase { - - /** - * Information propose fixture of the complaint diagnosis loop: the OMC asks the workbench for the missing - * access port name and complaint category, plus an optional null-value private line service identifier. - */ - INFORMATION_PROPOSE(NegotiationPerformative.PROPOSE, "information-negotiation", "information_propose") { - @Override - public NegotiationContext context() { - return NegotiationGoldenCases.defaultContext(performative()); - } - - @Override - public NegotiationContent content(String language) { - if (NegotiationGoldenCases.EN_US.equals(language)) { - return new InformationProposeContent( - List.of( - new NegotiationItem( - "Access Port Name", - "e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"), - new NegotiationItem( - "Complaint Category", "e.g. dedicated-line quality degradation"), - new NegotiationItem("Private Line Service Identifier", null)), - "OR"); - } - return new InformationProposeContent( - List.of( - new NegotiationItem("接入端口名称", "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1"), - new NegotiationItem("投诉分类", "举例:专线质差"), - new NegotiationItem("专线业务标识", null)), - "OR"); - } - }, - - /** - * Target propose fixture of round 1: the workbench and the OMC clarify the latency repair target and the - * completion deadline of the quality degradation complaint. - */ - TARGET_PROPOSE(NegotiationPerformative.PROPOSE, "target-negotiation", "target_propose") { - @Override - public NegotiationContext context() { - return NegotiationGoldenCases.firstRoundContext(performative()); - } - - @Override - public NegotiationContent content(String language) { - if (NegotiationGoldenCases.EN_US.equals(language)) { - return new TargetProposeContent( - "The intent understanding of the dedicated-line quality degradation repair target is" - + " listed in ; open questions remain about the" - + " latency target and the completion deadline, see ; please" - + " clarify and confirm.", - List.of( - new NegotiationItem( - "repair intent", - "restore the average latency of the Shenzhen-to-Guangzhou dedicated" - + " line to within 20ms before 2026-05-15"), - new NegotiationItem( - "repair target", "peak-hour packet loss rate no higher than 1%")), - null, - List.of( - new NegotiationItem( - "latency target", - "is the average latency target within 20ms or within 50ms"), - new NegotiationItem( - "completion deadline", - "is the repair deadline 48 hours or 72 hours"))); - } - return new TargetProposeContent( - "专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。", - List.of( - new NegotiationItem( - "修复意图", "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内"), - new NegotiationItem("修复目标", "高峰时段丢包率不高于1%")), - null, - List.of( - new NegotiationItem("时延目标", "平均时延目标是恢复至20ms以内还是50ms以内"), - new NegotiationItem("完成时限", "修复完成时限是48小时内还是72小时内"))); - } - }, - - /** - * Feasibility propose fixture requesting a feasibility evaluation of the access port bandwidth expansion - * against the cutover window constraint. - */ - FEASIBILITY_PROPOSE(NegotiationPerformative.PROPOSE, "feasibility-negotiation", "feasibility_propose") { - @Override - public NegotiationContext context() { - return NegotiationGoldenCases.defaultContext(performative()); - } - - @Override - public NegotiationContent content(String language) { - if (NegotiationGoldenCases.EN_US.equals(language)) { - return new FeasibilityProposeContent( - "Please assess whether the dedicated-line access port expansion can be completed within" - + " the cutover window, see ; please assess.", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of( - new NegotiationItem( - "expansion plan", - "expand the access port bandwidth from 100Mbps to 1000Mbps"), - new NegotiationItem( - "existing constraint", - "the cutover window is limited to 02:00-06:00 on 2026-05-30 with" - + " service interruption no longer than 30 minutes")), - null); - } - return new FeasibilityProposeContent( - "请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of( - new NegotiationItem("扩容方案", "接入端口带宽由100Mbps扩容至1000Mbps"), - new NegotiationItem( - "既有约束", "割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟")), - null); - } - }, - - /** Information accept fixture delivering the access port name and the complaint category. */ - INFORMATION_ACCEPT(NegotiationPerformative.ACCEPT, "information-negotiation", "information_accept") { - @Override - public NegotiationContent content(String language) { - if (NegotiationGoldenCases.EN_US.equals(language)) { - return new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of( - new NegotiationItem( - "Access Port Name", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"), - new NegotiationItem( - "Complaint Category", "dedicated-line quality degradation"))); - } - return new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of( - new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1"), - new NegotiationItem("投诉分类", "专线质差"))); - } - }, - - /** Target accept fixture carrying the finally confirmed latency repair target. */ - TARGET_ACCEPT(NegotiationPerformative.ACCEPT, "target-negotiation", "target_accept") { - @Override - public NegotiationContent content(String language) { - if (NegotiationGoldenCases.EN_US.equals(language)) { - return new TargetEndingContent( - NegotiationConclusion.ACCEPT, - "The finally confirmed intent: restore the average latency of the" - + " Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15, with a" - + " peak-hour packet loss rate no higher than 1%.", - null); - } - return new TargetEndingContent( - NegotiationConclusion.ACCEPT, - "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。", - null); - } - }, - - /** Feasibility accept fixture confirming a positive port expansion assessment in the exception slot. */ - FEASIBILITY_ACCEPT(NegotiationPerformative.ACCEPT, "feasibility-negotiation", "feasibility_accept") { - @Override - public NegotiationContent content(String language) { - if (NegotiationGoldenCases.EN_US.equals(language)) { - return new FeasibilityEndingContent( - NegotiationConclusion.ACCEPT, - "The port expansion can be completed within the cutover window and satisfies the" - + " 30-minute interruption constraint; this negotiation is confirmed as" - + " concluded."); - } - return new FeasibilityEndingContent( - NegotiationConclusion.ACCEPT, - "端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。"); - } - }, - - /** Information reject fixture stating that the access port name cannot be provided. */ - INFORMATION_REJECT(NegotiationPerformative.REJECT, "information-negotiation", "information_reject") { - @Override - public NegotiationContent content(String language) { - if (NegotiationGoldenCases.EN_US.equals(language)) { - return new InformationEndingContent( - NegotiationConclusion.REJECT, - List.of(new NegotiationItem( - "Access Port Name", - "cannot be provided because the port inventory is temporarily unavailable on" - + " the workbench side"))); - } - return new InformationEndingContent( - NegotiationConclusion.REJECT, - List.of(new NegotiationItem("接入端口名称", "无法提供,工作台侧端口资源台账暂不可查"))); - } - }, - - /** Target reject fixture carrying the failure reason of the unclarified latency target. */ - TARGET_REJECT(NegotiationPerformative.REJECT, "target-negotiation", "target_reject") { - @Override - public NegotiationContent content(String language) { - if (NegotiationGoldenCases.EN_US.equals(language)) { - return new TargetEndingContent( - NegotiationConclusion.REJECT, - null, - "The latency target cannot be clarified in full because the fiber cutover window is" - + " not confirmed yet."); - } - return new TargetEndingContent( - NegotiationConclusion.REJECT, null, "因光缆割接窗口尚未确认,时延目标未能完全澄清。"); - } - }, - - /** Feasibility reject fixture confirming a negative port expansion assessment in the exception slot. */ - FEASIBILITY_REJECT(NegotiationPerformative.REJECT, "feasibility-negotiation", "feasibility_reject") { - @Override - public NegotiationContent content(String language) { - if (NegotiationGoldenCases.EN_US.equals(language)) { - return new FeasibilityEndingContent( - NegotiationConclusion.REJECT, - "The port expansion cannot be completed within the designated cutover window because" - + " of insufficient board slots in the aggregation room; this negotiation is" - + " confirmed as concluded."); - } - return new FeasibilityEndingContent( - NegotiationConclusion.REJECT, - "受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。"); - } - }, - - /** Common abort fixture terminating the negotiation at the round limit. */ - ABORT(NegotiationPerformative.ABORT, "common", "abort") { - @Override - public NegotiationContent content(String language) { - if (NegotiationGoldenCases.EN_US.equals(language)) { - return new NegotiationAbortContent( - "The negotiation round limit is reached; this negotiation is confirmed as terminated."); - } - return new NegotiationAbortContent("已达到协商轮次上限,本次协商确认终止。"); - } - }; - - private final NegotiationPerformative performative; - - private final String typeSegment; - - private final String fileName; - - GoldenCase(NegotiationPerformative performative, String typeSegment, String fileName) { - this.performative = performative; - this.typeSegment = typeSegment; - this.fileName = fileName; - } - - /** - * Returns the API-level performative of this fixture. - * - * @return propose, accept, reject or abort performative - */ - public NegotiationPerformative performative() { - return performative; - } - - /** - * Returns the built-in template URI addressed by this fixture as a typed value. - * - * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} - */ - public TemplateUri template() { - return TemplateUri.of(StandardTemplates.NEGOTIATION_EXTENSION_NAME, typeSegment, - NegotiationReference.uriSegmentOf(performative)); - } - - /** - * Returns the built-in template URI addressed by this fixture. - * - * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} - */ - public String templateUri() { - return template().uri(); - } - - /** - * Returns the byte-exact golden fixture text of this case for one language. - * - * @param language locale identifier such as {@code zh-CN} - * @return the committed golden fixture text with LF line endings - */ - public String goldenText(String language) { - String text = GOLDEN_TEXTS.get(language + "/" + fileName); - if (text == null) { - throw new IllegalStateException("No golden fixture text pinned for " + fileName + " in " + language); - } - return text; - } - - /** - * Returns the fixed negotiation context of this fixture. - * - * @return fixed negotiation context - */ - public NegotiationContext context() { - return NegotiationGoldenCases.defaultContext(performative); - } - - /** - * Returns the fixed typed content of this fixture in the given language. - * - * @param language locale identifier such as {@code zh-CN}; selects the business data language - * @return fixed propose or ending content carrying that language's business data - */ - public abstract NegotiationContent content(String language); - - /** - * Generates this fixture through one orchestrator, using the from-data method of the fixture performative. - * - * @param orchestrator orchestrator wired with the resources of the fixture language - * @param language locale identifier such as {@code zh-CN}; selects the business data language - * @return generated metadata content of this fixture - */ - public MetadataContent generate(NegotiationGenerationOrchestrator orchestrator, String language) { - return switch (performative) { - case PROPOSE -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context(), (NegotiationProposeContent) content(language)), - template()); - case ACCEPT -> orchestrator.generateAcceptFromData( - new NegotiationEndingData(context(), (NegotiationEndingContent) content(language)), template()); - case REJECT -> orchestrator.generateRejectFromData( - new NegotiationEndingData(context(), (NegotiationEndingContent) content(language)), template()); - case ABORT -> orchestrator.generateAbortFromData( - new NegotiationAbortData(context(), (NegotiationAbortContent) content(language)), template()); - }; - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; + +/** + * Negotiation-module-local copy of the golden fixture inputs for the staying unit behavior tests. + * + *

        The canonical golden fixture system (the {@code GoldenInputs} model, the generator and the comparison guard, + * plus the committed fixture files) lives in the {@code a2a-t-corpus} consistency module. The unit behavior tests + * that remain in this module keep asserting byte equality against the same fixed inputs and rendered golden + * texts, so the needed fragments are copied here: the fixed contexts, the fixed typed content per language, the + * built-in template URIs and the byte-exact golden texts of the committed fixtures inlined as constants. + * + *

        Fixed inputs of the golden fixture set of the negotiation content layer. + * + *

        Every golden fixture is rendered from exactly one {@link GoldenCase} carrying a fixed negotiation context, fixed + * typed content and the built-in template URI of its negotiation type and phase. The fixture data lives in the + * private-line complaint diagnosis business domain (see {@code docs-local/business-facts-dictionary.md}): the + * information fixtures carry the access port name and complaint category of the OMC workbench complaint loop, the + * target fixtures negotiate the latency repair target, and the feasibility fixtures assess the port expansion. + * The typed content is language-dependent — zh-CN fixtures carry Chinese business data, en-US fixtures the English + * business form — so each language renders its own golden fixture with real business data in that language. + * + *

        The fixture data deliberately exercises the known rendering pitfalls: a non-null relationship with an appended + * line and a null-value item (information propose), the round-driven conditional sections with a non-empty + * clarification list (target propose, round 1), the action-driven conditional section (feasibility propose, evaluation + * request action), the confirm-request category rendering only the summary and the fixed confirm request (target and + * feasibility propose confirm), the accept and reject conclusion literals, the feasibility summary rendered into the + * vocabulary-exception slot of the feasibility result confirmation section, and the common abort template rendering + * the termination reason. + */ +public final class NegotiationGoldenCases { + + /** Fixed negotiation session id shared by every golden fixture. */ + public static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + /** Language with bundled zh-CN negotiation resources. */ + public static final String ZH_CN = "zh-CN"; + + /** Language with bundled en-US negotiation resources. */ + public static final String EN_US = "en-US"; + + /** Both languages covered by the golden fixture set, in a fixed order. */ + public static final List LANGUAGES = List.of(ZH_CN, EN_US); + + /** Golden fixture text of zh-CN/abort. */ + private static final String GOLDEN_zh_CN_ABORT = "## 协商结果\nAbort\n\n## 协商终止原因\n已达到协商轮次上限,本次协商确认终止。"; + /** Golden fixture text of zh-CN/feasibility_accept. */ + private static final String GOLDEN_zh_CN_FEASIBILITY_ACCEPT = "## 可行性协商结果\nAccept\n\n## 可行性评估结果确认\n端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。"; + /** Golden fixture text of zh-CN/feasibility_propose. */ + private static final String GOLDEN_zh_CN_FEASIBILITY_PROPOSE = "## 可行性协商\n请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。\n\n## 待评估内容说明\n1. 扩容方案:接入端口带宽由100Mbps扩容至1000Mbps\n2. 既有约束:割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟"; + /** Golden fixture text of zh-CN/feasibility_reject. */ + private static final String GOLDEN_zh_CN_FEASIBILITY_REJECT = "## 可行性协商结果\nReject\n\n## 可行性评估结果确认\n受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。"; + /** Golden fixture text of zh-CN/information_accept. */ + private static final String GOLDEN_zh_CN_INFORMATION_ACCEPT = "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:专线质差"; + /** Golden fixture text of zh-CN/information_propose. */ + private static final String GOLDEN_zh_CN_INFORMATION_PROPOSE = "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR"; + /** Golden fixture text of zh-CN/information_reject. */ + private static final String GOLDEN_zh_CN_INFORMATION_REJECT = "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,工作台侧端口资源台账暂不可查"; + /** Golden fixture text of zh-CN/target_accept. */ + private static final String GOLDEN_zh_CN_TARGET_ACCEPT = "## 目标协商结果\nAccept\n\n## 目标协商结果内容\n最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。"; + /** Golden fixture text of zh-CN/target_propose. */ + private static final String GOLDEN_zh_CN_TARGET_PROPOSE = "## 目标协商\n专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。\n\n## 意图理解陈述\n1. 修复意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内\n2. 修复目标:高峰时段丢包率不高于1%\n\n## 待澄清内容\n1. 时延目标:平均时延目标是恢复至20ms以内还是50ms以内\n2. 完成时限:修复完成时限是48小时内还是72小时内"; + /** Golden fixture text of zh-CN/target_propose_confirm. */ + private static final String GOLDEN_zh_CN_TARGET_PROPOSE_CONFIRM = "## 目标协商\n任务目标澄清完成,请答复<目标澄清后的确认请求>。\n\n## 目标澄清后的确认请求\n目标已经澄清,是否同意按照此目标继续执行?"; + /** Golden fixture text of zh-CN/feasibility_propose_confirm. */ + private static final String GOLDEN_zh_CN_FEASIBILITY_PROPOSE_CONFIRM = "## 可行性协商\n针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。\n\n## 评估可行时的确认请求\n评估目标可行,是否同意按照此目标继续执行?"; + /** Golden fixture text of zh-CN/target_reject. */ + private static final String GOLDEN_zh_CN_TARGET_REJECT = "## 目标协商结果\nReject\n\n## 目标协商结果内容\n因光缆割接窗口尚未确认,时延目标未能完全澄清。"; + /** Golden fixture text of en-US/target_propose_confirm. */ + private static final String GOLDEN_en_US_TARGET_PROPOSE_CONFIRM = "## Target Negotiation\nThe clarification of the task target has been completed. Please reply to .\n\n## Target Clarification Confirmation Request\nThe target has been clarified. Do you agree to proceed with this target?"; + /** Golden fixture text of en-US/feasibility_propose_confirm. */ + private static final String GOLDEN_en_US_FEASIBILITY_PROPOSE_CONFIRM = "## Feasibility Negotiation\nRegarding the adjusted rate guarantee target, the feasibility assessment has been completed and the conclusion is feasible. Please reply to .\n\n## Feasible Evaluation Confirmation Request\nThe target is assessed as feasible. Do you agree to proceed with this target?"; + /** Golden fixture text of en-US/abort. */ + private static final String GOLDEN_en_US_ABORT = "## Negotiation Result\nAbort\n\n## Negotiation Termination Reason\nThe negotiation round limit is reached; this negotiation is confirmed as terminated."; + /** Golden fixture text of en-US/feasibility_accept. */ + private static final String GOLDEN_en_US_FEASIBILITY_ACCEPT = "## Feasibility Negotiation Result\nAccept\n\n## Feasibility Assessment Result Confirmation\nThe port expansion can be completed within the cutover window and satisfies the 30-minute interruption constraint; this negotiation is confirmed as concluded."; + /** Golden fixture text of en-US/feasibility_propose. */ + private static final String GOLDEN_en_US_FEASIBILITY_PROPOSE = "## Feasibility Negotiation\nPlease assess whether the dedicated-line access port expansion can be completed within the cutover window, see ; please assess.\n\n## Under Evaluation Description\n1. expansion plan: expand the access port bandwidth from 100Mbps to 1000Mbps\n2. existing constraint: the cutover window is limited to 02:00-06:00 on 2026-05-30 with service interruption no longer than 30 minutes"; + /** Golden fixture text of en-US/feasibility_reject. */ + private static final String GOLDEN_en_US_FEASIBILITY_REJECT = "## Feasibility Negotiation Result\nReject\n\n## Feasibility Assessment Result Confirmation\nThe port expansion cannot be completed within the designated cutover window because of insufficient board slots in the aggregation room; this negotiation is confirmed as concluded."; + /** Golden fixture text of en-US/information_accept. */ + private static final String GOLDEN_en_US_INFORMATION_ACCEPT = "## Information Negotiation Result\nAccept\n\n## Information Negotiation Result Content\n1. Access Port Name: P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\n2. Complaint Category: dedicated-line quality degradation"; + /** Golden fixture text of en-US/information_propose. */ + private static final String GOLDEN_en_US_INFORMATION_PROPOSE = "## Information Negotiation\nPlease supplement the relevant content based on .\n\n## Required Information Items\n1. Access Port Name: e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\n2. Complaint Category: e.g. dedicated-line quality degradation\n3. Private Line Service Identifier\nRelationship between missing items: OR"; + /** Golden fixture text of en-US/information_reject. */ + private static final String GOLDEN_en_US_INFORMATION_REJECT = "## Information Negotiation Result\nReject\n\n## Information Negotiation Result Content\n1. Access Port Name: cannot be provided because the port inventory is temporarily unavailable on the workbench side"; + /** Golden fixture text of en-US/target_accept. */ + private static final String GOLDEN_en_US_TARGET_ACCEPT = "## Target Negotiation Result\nAccept\n\n## Target Negotiation Result Content\nThe finally confirmed intent: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15, with a peak-hour packet loss rate no higher than 1%."; + /** Golden fixture text of en-US/target_propose. */ + private static final String GOLDEN_en_US_TARGET_PROPOSE = "## Target Negotiation\nThe intent understanding of the dedicated-line quality degradation repair target is listed in ; open questions remain about the latency target and the completion deadline, see ; please clarify and confirm.\n\n## Intent Understanding Statement\n1. repair intent: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15\n2. repair target: peak-hour packet loss rate no higher than 1%\n\n## Content to Clarify\n1. latency target: is the average latency target within 20ms or within 50ms\n2. completion deadline: is the repair deadline 48 hours or 72 hours"; + /** Golden fixture text of en-US/target_reject. */ + private static final String GOLDEN_en_US_TARGET_REJECT = "## Target Negotiation Result\nReject\n\n## Target Negotiation Result Content\nThe latency target cannot be clarified in full because the fiber cutover window is not confirmed yet."; + + private static final Map GOLDEN_TEXTS = Map.ofEntries( + Map.entry("zh-CN/abort", GOLDEN_zh_CN_ABORT), + Map.entry("zh-CN/feasibility_accept", GOLDEN_zh_CN_FEASIBILITY_ACCEPT), + Map.entry("zh-CN/feasibility_propose", GOLDEN_zh_CN_FEASIBILITY_PROPOSE), + Map.entry("zh-CN/feasibility_propose_confirm", GOLDEN_zh_CN_FEASIBILITY_PROPOSE_CONFIRM), + Map.entry("zh-CN/feasibility_reject", GOLDEN_zh_CN_FEASIBILITY_REJECT), + Map.entry("zh-CN/information_accept", GOLDEN_zh_CN_INFORMATION_ACCEPT), + Map.entry("zh-CN/information_propose", GOLDEN_zh_CN_INFORMATION_PROPOSE), + Map.entry("zh-CN/information_reject", GOLDEN_zh_CN_INFORMATION_REJECT), + Map.entry("zh-CN/target_accept", GOLDEN_zh_CN_TARGET_ACCEPT), + Map.entry("zh-CN/target_propose", GOLDEN_zh_CN_TARGET_PROPOSE), + Map.entry("zh-CN/target_propose_confirm", GOLDEN_zh_CN_TARGET_PROPOSE_CONFIRM), + Map.entry("zh-CN/target_reject", GOLDEN_zh_CN_TARGET_REJECT), + Map.entry("en-US/abort", GOLDEN_en_US_ABORT), + Map.entry("en-US/feasibility_accept", GOLDEN_en_US_FEASIBILITY_ACCEPT), + Map.entry("en-US/feasibility_propose", GOLDEN_en_US_FEASIBILITY_PROPOSE), + Map.entry("en-US/feasibility_propose_confirm", GOLDEN_en_US_FEASIBILITY_PROPOSE_CONFIRM), + Map.entry("en-US/feasibility_reject", GOLDEN_en_US_FEASIBILITY_REJECT), + Map.entry("en-US/information_accept", GOLDEN_en_US_INFORMATION_ACCEPT), + Map.entry("en-US/information_propose", GOLDEN_en_US_INFORMATION_PROPOSE), + Map.entry("en-US/information_reject", GOLDEN_en_US_INFORMATION_REJECT), + Map.entry("en-US/target_accept", GOLDEN_en_US_TARGET_ACCEPT), + Map.entry("en-US/target_propose", GOLDEN_en_US_TARGET_PROPOSE), + Map.entry("en-US/target_propose_confirm", GOLDEN_en_US_TARGET_PROPOSE_CONFIRM), + Map.entry("en-US/target_reject", GOLDEN_en_US_TARGET_REJECT)); + + private NegotiationGoldenCases() {} + + /** + * Returns the default fixture context: round 2 of at most 5 rounds, carrying the fixture's performative. + * + * @param performative performative of the fixture the context belongs to + * @return fixed negotiation context of every fixture except the target propose fixture + */ + public static NegotiationContext defaultContext(NegotiationPerformative performative) { + return new NegotiationContext(SESSION_ID, 2, 5, performative); + } + + /** + * Returns the first-round fixture context used by the target propose fixture, carrying the fixture's performative. + * + * @param performative performative of the fixture the context belongs to + * @return fixed negotiation context of round 1 of at most 5 rounds + */ + public static NegotiationContext firstRoundContext(NegotiationPerformative performative) { + return new NegotiationContext(SESSION_ID, 1, 5, performative); + } + + /** One golden fixture case: one negotiation type, performative, fixed context, fixed content and its template URI. */ + public enum GoldenCase { + + /** + * Information propose fixture of the complaint diagnosis loop: the OMC asks the workbench for the missing + * access port name and complaint category, plus an optional null-value private line service identifier. + */ + INFORMATION_PROPOSE(NegotiationPerformative.PROPOSE, "information-negotiation", "information_propose") { + @Override + public NegotiationContext context() { + return NegotiationGoldenCases.defaultContext(performative()); + } + + @Override + public NegotiationContent content(String language) { + if (NegotiationGoldenCases.EN_US.equals(language)) { + return new InformationProposeContent( + List.of( + new NegotiationItem( + "Access Port Name", + "e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"), + new NegotiationItem( + "Complaint Category", "e.g. dedicated-line quality degradation"), + new NegotiationItem("Private Line Service Identifier", null)), + "OR"); + } + return new InformationProposeContent( + List.of( + new NegotiationItem("接入端口名称", "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1"), + new NegotiationItem("投诉分类", "举例:专线质差"), + new NegotiationItem("专线业务标识", null)), + "OR"); + } + }, + + /** + * Target propose fixture of round 1: the workbench and the OMC clarify the latency repair target and the + * completion deadline of the quality degradation complaint. + */ + TARGET_PROPOSE(NegotiationPerformative.PROPOSE, "target-negotiation", "target_propose") { + @Override + public NegotiationContext context() { + return NegotiationGoldenCases.firstRoundContext(performative()); + } + + @Override + public NegotiationContent content(String language) { + if (NegotiationGoldenCases.EN_US.equals(language)) { + return new TargetProposeContent( + "The intent understanding of the dedicated-line quality degradation repair target is" + + " listed in ; open questions remain about the" + + " latency target and the completion deadline, see ; please" + + " clarify and confirm.", + List.of( + new NegotiationItem( + "repair intent", + "restore the average latency of the Shenzhen-to-Guangzhou dedicated" + + " line to within 20ms before 2026-05-15"), + new NegotiationItem( + "repair target", "peak-hour packet loss rate no higher than 1%")), + null, + List.of( + new NegotiationItem( + "latency target", + "is the average latency target within 20ms or within 50ms"), + new NegotiationItem( + "completion deadline", + "is the repair deadline 48 hours or 72 hours")), + null); + } + return new TargetProposeContent( + "专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。", + List.of( + new NegotiationItem( + "修复意图", "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内"), + new NegotiationItem("修复目标", "高峰时段丢包率不高于1%")), + null, + List.of( + new NegotiationItem("时延目标", "平均时延目标是恢复至20ms以内还是50ms以内"), + new NegotiationItem("完成时限", "修复完成时限是48小时内还是72小时内")), + null); + } + }, + + /** + * Target propose confirm-request fixture of a later round: the clarification is complete, so the message + * carries only the summary and the fixed confirm request of the "target clarified and requesting + * confirmation" category. + */ + TARGET_PROPOSE_CONFIRM(NegotiationPerformative.PROPOSE, "target-negotiation", "target_propose_confirm") { + @Override + public NegotiationContent content(String language) { + if (NegotiationGoldenCases.EN_US.equals(language)) { + return new TargetProposeContent( + "The clarification of the task target has been completed. Please reply to .", + null, + null, + null, + "The target has been clarified. Do you agree to proceed with this target?"); + } + return new TargetProposeContent( + "任务目标澄清完成,请答复<目标澄清后的确认请求>。", + null, + null, + null, + "目标已经澄清,是否同意按照此目标继续执行?"); + } + }, + + /** + * Feasibility propose fixture requesting a feasibility evaluation of the access port bandwidth expansion + * against the cutover window constraint. + */ + FEASIBILITY_PROPOSE(NegotiationPerformative.PROPOSE, "feasibility-negotiation", "feasibility_propose") { + @Override + public NegotiationContext context() { + return NegotiationGoldenCases.defaultContext(performative()); + } + + @Override + public NegotiationContent content(String language) { + if (NegotiationGoldenCases.EN_US.equals(language)) { + return new FeasibilityProposeContent( + "Please assess whether the dedicated-line access port expansion can be completed within" + + " the cutover window, see ; please assess.", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of( + new NegotiationItem( + "expansion plan", + "expand the access port bandwidth from 100Mbps to 1000Mbps"), + new NegotiationItem( + "existing constraint", + "the cutover window is limited to 02:00-06:00 on 2026-05-30 with" + + " service interruption no longer than 30 minutes")), + null, + null); + } + return new FeasibilityProposeContent( + "请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of( + new NegotiationItem("扩容方案", "接入端口带宽由100Mbps扩容至1000Mbps"), + new NegotiationItem( + "既有约束", "割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟")), + null, + null); + } + }, + + /** + * Feasibility propose confirm-request fixture of the "assessed as feasible and requesting confirmation" + * category: the assessment is complete, so the message carries only the summary and the fixed confirm + * request of the goal-achievement wording. + */ + FEASIBILITY_PROPOSE_CONFIRM( + NegotiationPerformative.PROPOSE, "feasibility-negotiation", "feasibility_propose_confirm") { + @Override + public NegotiationContent content(String language) { + if (NegotiationGoldenCases.EN_US.equals(language)) { + return new FeasibilityProposeContent( + "Regarding the adjusted rate guarantee target, the feasibility assessment has been" + + " completed and the conclusion is feasible. Please reply to .", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + null, + null, + "The target is assessed as feasible. Do you agree to proceed with this target?"); + } + return new FeasibilityProposeContent( + "针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + null, + null, + "评估目标可行,是否同意按照此目标继续执行?"); + } + }, + + /** Information accept fixture delivering the access port name and the complaint category. */ + INFORMATION_ACCEPT(NegotiationPerformative.ACCEPT, "information-negotiation", "information_accept") { + @Override + public NegotiationContent content(String language) { + if (NegotiationGoldenCases.EN_US.equals(language)) { + return new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of( + new NegotiationItem( + "Access Port Name", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"), + new NegotiationItem( + "Complaint Category", "dedicated-line quality degradation"))); + } + return new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of( + new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1"), + new NegotiationItem("投诉分类", "专线质差"))); + } + }, + + /** Target accept fixture carrying the finally confirmed latency repair target. */ + TARGET_ACCEPT(NegotiationPerformative.ACCEPT, "target-negotiation", "target_accept") { + @Override + public NegotiationContent content(String language) { + if (NegotiationGoldenCases.EN_US.equals(language)) { + return new TargetEndingContent( + NegotiationConclusion.ACCEPT, + "The finally confirmed intent: restore the average latency of the" + + " Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15, with a" + + " peak-hour packet loss rate no higher than 1%.", + null); + } + return new TargetEndingContent( + NegotiationConclusion.ACCEPT, + "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。", + null); + } + }, + + /** Feasibility accept fixture confirming a positive port expansion assessment in the exception slot. */ + FEASIBILITY_ACCEPT(NegotiationPerformative.ACCEPT, "feasibility-negotiation", "feasibility_accept") { + @Override + public NegotiationContent content(String language) { + if (NegotiationGoldenCases.EN_US.equals(language)) { + return new FeasibilityEndingContent( + NegotiationConclusion.ACCEPT, + "The port expansion can be completed within the cutover window and satisfies the" + + " 30-minute interruption constraint; this negotiation is confirmed as" + + " concluded."); + } + return new FeasibilityEndingContent( + NegotiationConclusion.ACCEPT, + "端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。"); + } + }, + + /** Information reject fixture stating that the access port name cannot be provided. */ + INFORMATION_REJECT(NegotiationPerformative.REJECT, "information-negotiation", "information_reject") { + @Override + public NegotiationContent content(String language) { + if (NegotiationGoldenCases.EN_US.equals(language)) { + return new InformationEndingContent( + NegotiationConclusion.REJECT, + List.of(new NegotiationItem( + "Access Port Name", + "cannot be provided because the port inventory is temporarily unavailable on" + + " the workbench side"))); + } + return new InformationEndingContent( + NegotiationConclusion.REJECT, + List.of(new NegotiationItem("接入端口名称", "无法提供,工作台侧端口资源台账暂不可查"))); + } + }, + + /** Target reject fixture carrying the failure reason of the unclarified latency target. */ + TARGET_REJECT(NegotiationPerformative.REJECT, "target-negotiation", "target_reject") { + @Override + public NegotiationContent content(String language) { + if (NegotiationGoldenCases.EN_US.equals(language)) { + return new TargetEndingContent( + NegotiationConclusion.REJECT, + null, + "The latency target cannot be clarified in full because the fiber cutover window is" + + " not confirmed yet."); + } + return new TargetEndingContent( + NegotiationConclusion.REJECT, null, "因光缆割接窗口尚未确认,时延目标未能完全澄清。"); + } + }, + + /** Feasibility reject fixture confirming a negative port expansion assessment in the exception slot. */ + FEASIBILITY_REJECT(NegotiationPerformative.REJECT, "feasibility-negotiation", "feasibility_reject") { + @Override + public NegotiationContent content(String language) { + if (NegotiationGoldenCases.EN_US.equals(language)) { + return new FeasibilityEndingContent( + NegotiationConclusion.REJECT, + "The port expansion cannot be completed within the designated cutover window because" + + " of insufficient board slots in the aggregation room; this negotiation is" + + " confirmed as concluded."); + } + return new FeasibilityEndingContent( + NegotiationConclusion.REJECT, + "受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。"); + } + }, + + /** Common abort fixture terminating the negotiation at the round limit. */ + ABORT(NegotiationPerformative.ABORT, "common", "abort") { + @Override + public NegotiationContent content(String language) { + if (NegotiationGoldenCases.EN_US.equals(language)) { + return new NegotiationAbortContent( + "The negotiation round limit is reached; this negotiation is confirmed as terminated."); + } + return new NegotiationAbortContent("已达到协商轮次上限,本次协商确认终止。"); + } + }; + + private final NegotiationPerformative performative; + + private final String typeSegment; + + private final String fileName; + + GoldenCase(NegotiationPerformative performative, String typeSegment, String fileName) { + this.performative = performative; + this.typeSegment = typeSegment; + this.fileName = fileName; + } + + /** + * Returns the API-level performative of this fixture. + * + * @return propose, accept, reject or abort performative + */ + public NegotiationPerformative performative() { + return performative; + } + + /** + * Returns the built-in template URI addressed by this fixture as a typed value. + * + * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} + */ + public TemplateUri template() { + return TemplateUri.of(StandardTemplates.NEGOTIATION_EXTENSION_NAME, typeSegment, + NegotiationReference.uriSegmentOf(performative)); + } + + /** + * Returns the built-in template URI addressed by this fixture. + * + * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} + */ + public String templateUri() { + return template().uri(); + } + + /** + * Returns the byte-exact golden fixture text of this case for one language. + * + * @param language locale identifier such as {@code zh-CN} + * @return the committed golden fixture text with LF line endings + */ + public String goldenText(String language) { + String text = GOLDEN_TEXTS.get(language + "/" + fileName); + if (text == null) { + throw new IllegalStateException("No golden fixture text pinned for " + fileName + " in " + language); + } + return text; + } + + /** + * Returns the fixed negotiation context of this fixture. + * + * @return fixed negotiation context + */ + public NegotiationContext context() { + return NegotiationGoldenCases.defaultContext(performative); + } + + /** + * Returns the fixed typed content of this fixture in the given language. + * + * @param language locale identifier such as {@code zh-CN}; selects the business data language + * @return fixed propose or ending content carrying that language's business data + */ + public abstract NegotiationContent content(String language); + + /** + * Generates this fixture through one orchestrator, using the from-data method of the fixture performative. + * + * @param orchestrator orchestrator wired with the resources of the fixture language + * @param language locale identifier such as {@code zh-CN}; selects the business data language + * @return generated metadata content of this fixture + */ + public MetadataContent generate(NegotiationGenerationOrchestrator orchestrator, String language) { + return switch (performative) { + case PROPOSE -> orchestrator.generateProposeFromData( + new NegotiationProposeData(context(), (NegotiationProposeContent) content(language)), + template()); + case ACCEPT -> orchestrator.generateAcceptFromData( + new NegotiationEndingData(context(), (NegotiationEndingContent) content(language)), template()); + case REJECT -> orchestrator.generateRejectFromData( + new NegotiationEndingData(context(), (NegotiationEndingContent) content(language)), template()); + case ABORT -> orchestrator.generateAbortFromData( + new NegotiationAbortData(context(), (NegotiationAbortContent) content(language)), template()); + }; + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java index 908a9755..4551c522 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java @@ -43,10 +43,10 @@ void informationEndingSchemaRequiresConclusionAndItems() { builder.buildExtractionSchema(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT); Map properties = (Map) schema.get("properties"); - assertEquals(List.of("conclusion", "items"), List.copyOf(properties.keySet())); - assertEquals(List.of("conclusion", "items"), schema.get("required")); - assertEquals(List.of("Accept", "Reject"), ((Map) properties.get("conclusion")).get("enum")); - assertEquals(1, ((Map) properties.get("items")).get("minItems")); + assertEquals(List.of("conclusion", "items"), List.copyOf(properties.keySet())); + assertEquals(List.of("conclusion", "items"), schema.get("required")); + assertEquals(List.of("Accept", "Reject"), ((Map) properties.get("conclusion")).get("enum")); + assertEquals(1, ((Map) properties.get("items")).get("minItems")); } @Test @@ -66,10 +66,12 @@ void targetProposeSchemaUsesSnakeCaseProperties() { "target_negotiation_description", "intent_understanding", "alignment_and_clarification", - "request_for_clarification"), + "request_for_clarification", + "target_confirm_request"), List.copyOf(properties.keySet())); assertEquals(List.of("target_negotiation_description"), schema.get("required")); assertEquals(List.of("array", "null"), ((Map) properties.get("intent_understanding")).get("type")); + assertEquals(List.of("string", "null"), ((Map) properties.get("target_confirm_request")).get("type")); } @Test @@ -94,11 +96,13 @@ void feasibilityProposeSchemaRequiresTheActionEnum() { "feasibility_negotiation_description", "action", "contents_to_evaluate", - "infeasibility_details_and_proposal"), + "infeasibility_details_and_proposal", + "feasibility_confirm_request"), List.copyOf(properties.keySet())); assertEquals(List.of("feasibility_negotiation_description", "action"), schema.get("required")); Map action = (Map) properties.get("action"); assertEquals(List.of("REQUEST_FEASIBILITY_EVALUATION", "PROPOSE_ALTERNATIVE_ON_FAILURE"), action.get("enum")); + assertEquals(List.of("string", "null"), ((Map) properties.get("feasibility_confirm_request")).get("type")); } @Test diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java index 72468024..8fcd5b6a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java @@ -512,6 +512,7 @@ private static String targetProposeMessage(NegotiationGenerationOrchestrator orc "确认专线质差投诉的时延修复目标调整方案", List.of(new NegotiationItem("修复意图", "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内")), null, + null, null)), TARGET_PROPOSE_URI); return content.promptText(); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java index 0ac7f51e..12f721a7 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java @@ -115,6 +115,58 @@ Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { INFORMATION_ENDING_URI), NegotiationGenerationException.class, A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), + row( + "generation_target_confirm_request_round_with_conditional_sections_is_invalid_input", + "{\"target_negotiation_description\":\"目标已经澄清,请确认。\",\"intent_understanding\":" + + "[{\"name\":\"发起方理解\",\"value\":\"对方希望降低节能力度\"}]," + + "\"target_confirm_request\":\"目标已经澄清,是否同意按照此目标继续执行?\"}", + 1, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build() + .generateProposeFromText( + "目标已经澄清,请确认。", + new NegotiationContext(UUID, 3, 5, NegotiationPerformative.PROPOSE), + StandardTemplates.TARGET_NEGOTIATION_PROPOSE), + NegotiationGenerationException.class, + A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), + row( + "generation_feasibility_confirm_request_round_with_infeasibility_details_is_invalid_input", + "{\"feasibility_negotiation_description\":\"评估完成,结论可行,请确认。\",\"action\":" + + "\"REQUEST_FEASIBILITY_EVALUATION\",\"infeasibility_details_and_proposal\":" + + "[{\"name\":\"不应出现\",\"value\":\"值\"}],\"feasibility_confirm_request\":" + + "\"目标评估为可行,是否同意按照此目标继续执行?\"}", + 1, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build() + .generateProposeFromText( + "评估完成,结论可行,请确认。", + new NegotiationContext(UUID, 3, 5, NegotiationPerformative.PROPOSE), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE), + NegotiationGenerationException.class, + A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), + row( + "generation_feasibility_confirm_request_round_with_alternative_action_is_invalid_input", + "{\"feasibility_negotiation_description\":\"评估完成,结论可行,请确认。\",\"action\":" + + "\"PROPOSE_ALTERNATIVE_ON_FAILURE\",\"feasibility_confirm_request\":" + + "\"目标评估为可行,是否同意按照此目标继续执行?\"}", + 1, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build() + .generateProposeFromText( + "评估完成,结论可行,请确认。", + new NegotiationContext(UUID, 3, 5, NegotiationPerformative.PROPOSE), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE), + NegotiationGenerationException.class, + A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), row( "generation_llm_infrastructure_failure_is_retryable", null, diff --git a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json index bd7417ea..17e5ea60 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json @@ -8,11 +8,13 @@ "section.target_intent": "Intent Understanding Statement", "section.target_alignment": "Understanding Alignment and Clarification", "section.target_clarification": "Content to Clarify", + "section.target_confirm_request": "Target Clarification Confirmation Request", "section.target_conclusion": "Target Negotiation Result", "section.target_result_content": "Target Negotiation Result Content", "section.feasibility": "Feasibility Negotiation", "section.feasibility_evaluate": "Under Evaluation Description", "section.feasibility_infeasible": "Infeasible Evaluation Details and Proposal", + "section.feasibility_confirm_request": "Feasible Evaluation Confirmation Request", "section.feasibility_conclusion": "Feasibility Negotiation Result", "section.feasibility_confirm": "Feasibility Assessment Result Confirmation", "slot.termination_reason": "negotiation_termination_reason", @@ -23,11 +25,13 @@ "slot.target_intent": "intent_understanding_statement", "slot.target_alignment": "understanding_alignment_and_clarification", "slot.target_clarification": "content_to_clarify", + "slot.target_confirm_request": "target_confirm_request", "slot.target_conclusion": "target_negotiation_result", "slot.target_result_content": "target_negotiation_result_content", "slot.feasibility": "feasibility_negotiation_summary", "slot.feasibility_evaluate": "under_evaluation_description", "slot.feasibility_infeasible": "infeasible_evaluation_details_and_proposal", + "slot.feasibility_confirm_request": "feasibility_confirm_request", "slot.feasibility_conclusion": "feasibility_negotiation_result", "slot.feasibility_confirm": "evaluation_result_confirmation", "label.relationship": "Relationship between missing items: ", diff --git a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json index f17996ab..54f3b7de 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json @@ -8,11 +8,13 @@ "section.target_intent": "意图理解陈述", "section.target_alignment": "理解对齐与疑问澄清", "section.target_clarification": "待澄清内容", + "section.target_confirm_request": "目标澄清后的确认请求", "section.target_conclusion": "目标协商结果", "section.target_result_content": "目标协商结果内容", "section.feasibility": "可行性协商", "section.feasibility_evaluate": "待评估内容说明", "section.feasibility_infeasible": "评估不可行时的详情和提案", + "section.feasibility_confirm_request": "评估可行时的确认请求", "section.feasibility_conclusion": "可行性协商结果", "section.feasibility_confirm": "可行性评估结果确认", "slot.termination_reason": "协商终止原因", @@ -23,11 +25,13 @@ "slot.target_intent": "意图理解陈述", "slot.target_alignment": "理解对齐与疑问澄清", "slot.target_clarification": "待澄清内容", + "slot.target_confirm_request": "目标澄清后的确认请求", "slot.target_conclusion": "目标协商结果", "slot.target_result_content": "目标协商结果内容", "slot.feasibility": "可行性协商概述", "slot.feasibility_evaluate": "待评估内容说明", "slot.feasibility_infeasible": "评估不可行时的详情和提案", + "slot.feasibility_confirm_request": "评估可行时的确认请求", "slot.feasibility_conclusion": "可行性协商结果", "slot.feasibility_confirm": "评估结果确认", "label.relationship": "缺失项之间的关系:", diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/system.md index 2a5909bf..1c4a43a5 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/system.md @@ -6,7 +6,7 @@ Output exactly one JSON object. Do not output markdown code fences, comments, or ## Phase and Output Structure The negotiation phase of the input text is given by the phase field in the user prompt: -1. Propose phase: extract the negotiation summary, the message nature (action), and the matching conditional content. Output structure: +1. Propose phase: extract the negotiation summary, the message category (action), the matching conditional content, and the feasible-evaluation confirmation request. Output structure: { "feasibility_negotiation_description": "feasibility negotiation summary, string", @@ -16,7 +16,8 @@ The negotiation phase of the input text is given by the phase field in the user ], "infeasibility_details_and_proposal": [ {"name": "entry name", "value": "entry content"} - ] + ], + "feasibility_confirm_request": "feasible-evaluation confirmation request, string or null" } 2. Ending phase (accept / reject / accept-reject): extract the negotiation conclusion and the feasibility result confirmation. Output structure: @@ -27,26 +28,36 @@ The negotiation phase of the input text is given by the phase field in the user } ## Field Rules -- feasibility_negotiation_description: required in the propose phase. A paragraph summarizing the nature and purpose of this feasibility negotiation. +- feasibility_negotiation_description: required in the propose phase. A paragraph summarizing the purpose and message category of this feasibility negotiation. - action: required enum in the propose phase; must be one of exactly two values: - - "REQUEST_FEASIBILITY_EVALUATION": ask the peer to evaluate the feasibility of certain matters; + - "REQUEST_FEASIBILITY_EVALUATION": ask the peer to evaluate the feasibility of certain matters, or this side has completed the evaluation with a feasible conclusion; - "PROPOSE_ALTERNATIVE_ON_FAILURE": state that the target is infeasible and propose an alternative. -- contents_to_evaluate: when action is "REQUEST_FEASIBILITY_EVALUATION", output the array of contents to evaluate; otherwise null or an empty array. -- infeasibility_details_and_proposal: when action is "PROPOSE_ALTERNATIVE_ON_FAILURE", output the array of infeasibility details and the alternative proposal; otherwise null or an empty array. -- The two conditional contents are mutually exclusive: extract at most one non-empty group from a single input; never output both groups non-empty. + - When the category of this round's message is "assess as feasible and request confirmation", action still takes "REQUEST_FEASIBILITY_EVALUATION", and a non-null feasibility_confirm_request distinguishes it from "initiate feasibility assessment". +- contents_to_evaluate: output the array of contents to evaluate only when the category of this round's message is "initiate feasibility assessment"; must be null or an empty array when feasibility_confirm_request is non-null or action is "PROPOSE_ALTERNATIVE_ON_FAILURE". +- infeasibility_details_and_proposal: output the array of infeasibility details and the alternative proposal only when the category of this round's message is "assess as infeasible and propose" (action is "PROPOSE_ALTERNATIVE_ON_FAILURE"); otherwise null or an empty array. +- feasibility_confirm_request: optional in the propose phase; string or null. + - Non-null only when the category of this round's message is "assess as feasible and request confirmation" (this side has completed the assessment with a feasible conclusion and asks the peer to confirm whether to proceed); in that case action must be "REQUEST_FEASIBILITY_EVALUATION" and both contents_to_evaluate and infeasibility_details_and_proposal must be null. + - When non-null, the content takes one of two fixed wordings according to the assessment category, and must not be rephrased: + - goal achievement: "The target is assessed as feasible. Do you agree to proceed with this target?" + - solution feasibility: "The solution is assessed as feasible. Do you agree to proceed with this solution?" + - Must be null when the category of this round's message is "initiate feasibility assessment" or "assess as infeasible and propose". +- The conditional contents are mutually exclusive: at most one of contents_to_evaluate, infeasibility_details_and_proposal, and feasibility_confirm_request may be non-empty in a single input; never output more than one non-empty. - conclusion: required in the ending phase; must be either "Accept" or "Reject" and must faithfully reflect the conclusion expressed by the input text; never output "Abort". - feasibility_summary: required in the ending phase. The confirmation statement of the feasibility evaluation result: the accepted outcome when the conclusion is "Accept", or the infeasible outcome and its reasons when the conclusion is "Reject". - Each entry is an object with exactly two keys, name and value; value may be null. ## Extraction Principles 1. Extract only content explicitly expressed in the input text; do not fill in values from general knowledge or guess. -2. Determine action from the message nature of the input: asking the peer for a feasibility evaluation maps to "REQUEST_FEASIBILITY_EVALUATION"; stating infeasibility and offering an alternative maps to "PROPOSE_ALTERNATIVE_ON_FAILURE". +2. In the propose phase, first determine the category of this round's message, of which there are three: + - Initiate feasibility assessment: propose the target or solution to be evaluated, with no conclusion given by this side yet (including re-initiation after adjustment following an infeasible evaluation in the previous round). Action is "REQUEST_FEASIBILITY_EVALUATION"; extract contents_to_evaluate; feasibility_confirm_request and infeasibility_details_and_proposal are null. + - Assess as feasible and request confirmation: this side has completed the assessment with a feasible conclusion and asks the peer to confirm whether to proceed. Action is "REQUEST_FEASIBILITY_EVALUATION"; extract feasibility_confirm_request with the fixed wording chosen by the assessment category; contents_to_evaluate and infeasibility_details_and_proposal are both null. + - Assess as infeasible and propose: this side has completed the assessment with an infeasible conclusion, stating the details and proposing a handling strategy. Action is "PROPOSE_ALTERNATIVE_ON_FAILURE"; extract infeasibility_details_and_proposal; contents_to_evaluate and feasibility_confirm_request are null. 3. When the input does not express content for an optional field, output null for that field; do not fabricate entries. 4. In the ending phase, the accepting or rejecting stance toward the evaluation result maps to conclusion, and the full statement of the evaluation outcome maps to feasibility_summary. ## Output Examples -### Example 1: propose phase (requesting a feasibility evaluation) +### Example 1: propose phase (initiating a feasibility assessment) { "feasibility_negotiation_description": "Request evaluating the feasibility of maintaining a 5Mbps guaranteed-rate target during the power-outage protection scenario.", @@ -54,7 +65,8 @@ The negotiation phase of the input text is given by the phase field in the user "contents_to_evaluate": [ {"name": "evaluation target", "value": "rate guarantee for key users during the 8-hour outage"} ], - "infeasibility_details_and_proposal": null + "infeasibility_details_and_proposal": null, + "feasibility_confirm_request": null } ### Example 2: propose phase (infeasible, proposing an alternative) @@ -66,10 +78,31 @@ The negotiation phase of the input text is given by the phase field in the user "infeasibility_details_and_proposal": [ {"name": "infeasibility reason", "value": "the battery can only sustain a 2Mbps guarantee for 8 hours"}, {"name": "alternative proposal", "value": "reduce the guaranteed-rate target to 2Mbps during the outage"} - ] + ], + "feasibility_confirm_request": null +} + +### Example 3: propose phase (assess as feasible and request confirmation, goal achievement) + +{ + "feasibility_negotiation_description": "The feasibility assessment of the adjusted guaranteed-rate target has been completed with a feasible conclusion; request the counterparty to confirm whether to proceed with this target.", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": null, + "feasibility_confirm_request": "The target is assessed as feasible. Do you agree to proceed with this target?" +} + +### Example 4: propose phase (assess as feasible and request confirmation, solution feasibility) + +{ + "feasibility_negotiation_description": "The feasibility assessment of the keepalive solution for the fault subscription task has been completed with a feasible conclusion; request the counterparty to confirm whether to proceed with this solution.", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": null, + "feasibility_confirm_request": "The solution is assessed as feasible. Do you agree to proceed with this solution?" } -### Example 3: ending phase (accept) +### Example 5: ending phase (accept) { "conclusion": "Accept", diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/system.md index bf970119..eafe8a85 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/system.md @@ -6,7 +6,7 @@ ## 阶段与输出结构 输入文本所处的协商阶段由用户提示词中的阶段字段给出: -1. 发起阶段(propose):提取可行性协商概述、消息性质(action)与对应的条件内容,输出结构: +1. 发起阶段(propose):提取可行性协商概述、消息类别(action)与对应的条件内容、评估可行时的确认请求,输出结构: { "feasibility_negotiation_description": "可行性协商概述,字符串", @@ -16,7 +16,8 @@ ], "infeasibility_details_and_proposal": [ {"name": "条目名称", "value": "条目内容"} - ] + ], + "feasibility_confirm_request": "评估可行时的确认请求,字符串或 null" } 2. 结论阶段(accept / reject / accept-reject):提取协商结论与可行性评估结果确认,输出结构: @@ -27,26 +28,36 @@ } ## 字段规则 -- feasibility_negotiation_description:发起阶段必填。概括本次可行性协商的性质与目的。 +- feasibility_negotiation_description:发起阶段必填。概括本次可行性协商的目的与消息类别。 - action:发起阶段必填枚举,只能取以下两个值之一: - - "REQUEST_FEASIBILITY_EVALUATION":请求对方评估某些事项的可行性; + - "REQUEST_FEASIBILITY_EVALUATION":请求对方评估某些事项的可行性,或本方已完成评估且结论为可行; - "PROPOSE_ALTERNATIVE_ON_FAILURE":已知目标不可行时,说明不可行详情并提出替代方案。 -- contents_to_evaluate:action 为 "REQUEST_FEASIBILITY_EVALUATION" 时,输出待评估内容条目数组;否则为 null 或空数组。 -- infeasibility_details_and_proposal:action 为 "PROPOSE_ALTERNATIVE_ON_FAILURE" 时,输出不可行详情与替代提案条目数组;否则为 null 或空数组。 -- 两个条件内容互斥:同一份输入中最多提取其中一组,不得同时给出两组非空内容。 + - 消息类别为"评估可行并请求确认"时,action 仍取 "REQUEST_FEASIBILITY_EVALUATION",并以 feasibility_confirm_request 非空来与"发起可行性评估"区分。 +- contents_to_evaluate:仅当本轮消息类别为"发起可行性评估"时,输出待评估内容条目数组;feasibility_confirm_request 非空或 action 为 "PROPOSE_ALTERNATIVE_ON_FAILURE" 时必须为 null 或空数组。 +- infeasibility_details_and_proposal:仅当本轮消息类别为"评估不可行并提案"(action 为 "PROPOSE_ALTERNATIVE_ON_FAILURE")时,输出不可行详情与替代提案条目数组;否则为 null 或空数组。 +- feasibility_confirm_request:发起阶段可选,字符串或 null。 + - 仅当本轮消息类别为"评估可行并请求确认"(本方已完成评估且结论为可行,请求对方确认是否继续执行)时非空;此时 action 必须为 "REQUEST_FEASIBILITY_EVALUATION",且 contents_to_evaluate 与 infeasibility_details_and_proposal 必须均为 null。 + - 非空时内容按评估类别取固定措辞二选一,不得改写: + - 评估类别为目标达成:"评估目标可行,是否同意按照此目标继续执行?" + - 评估类别为方案可行性:"评估方案可行,是否同意按照此方案继续执行?" + - 本轮消息类别为"发起可行性评估"或"评估不可行并提案"时必须为 null。 +- 条件内容互斥:同一份输入中 contents_to_evaluate、infeasibility_details_and_proposal、feasibility_confirm_request 最多一项非空,不得同时给出多项非空内容。 - conclusion:结论阶段必填,只能为 "Accept" 或 "Reject",必须忠实于输入文本表达的结论;不得输出 "Abort"。 - feasibility_summary:结论阶段必填。可行性评估结果的确认表述:结论为 "Accept" 时为同意的结论与内容,结论为 "Reject" 时为不可行结论及原因。 - 每个条目是一个恰好包含 name 与 value 两个键的对象;value 可为 null。 ## 提取原则 1. 只提取输入文本中明确表达的内容,不要基于常识补值或猜测。 -2. action 依据输入的消息性质判定:请求对方做可行性评估 → "REQUEST_FEASIBILITY_EVALUATION";说明不可行并给出替代方案 → "PROPOSE_ALTERNATIVE_ON_FAILURE"。 +2. 发起阶段先判定本轮消息类别,共三种: + - 发起可行性评估:提出待评估目标或方案,本方尚未给出结论(含上一轮被评估不可行后调整再发起)→ action 取 "REQUEST_FEASIBILITY_EVALUATION",提取 contents_to_evaluate,feasibility_confirm_request 与 infeasibility_details_and_proposal 为 null。 + - 评估可行并请求确认:本方已完成评估且结论为可行,请求对方确认是否继续执行 → action 取 "REQUEST_FEASIBILITY_EVALUATION",提取 feasibility_confirm_request(按评估类别取固定措辞),contents_to_evaluate 与 infeasibility_details_and_proposal 均为 null。 + - 评估不可行并提案:本方已完成评估且结论为不可行,说明详情并提出处理策略 → action 取 "PROPOSE_ALTERNATIVE_ON_FAILURE",提取 infeasibility_details_and_proposal,contents_to_evaluate 与 feasibility_confirm_request 为 null。 3. 输入未表达某可选字段的内容时输出 null,不要编造条目。 4. 结论阶段中,对评估结果的接受或拒绝表态映射为 conclusion,评估结论的完整表述映射为 feasibility_summary。 ## 输出示例 -### 示例1:发起阶段(请求可行性评估) +### 示例1:发起阶段(发起可行性评估) { "feasibility_negotiation_description": "请求评估停电保障场景下维持5Mbps速率保障目标的可行性。", @@ -54,7 +65,8 @@ "contents_to_evaluate": [ {"name": "评估对象", "value": "停电8小时期间核心用户的速率保障"} ], - "infeasibility_details_and_proposal": null + "infeasibility_details_and_proposal": null, + "feasibility_confirm_request": null } ### 示例2:发起阶段(不可行并提出替代方案) @@ -66,10 +78,31 @@ "infeasibility_details_and_proposal": [ {"name": "不可行原因", "value": "蓄电池仅能支撑8小时2Mbps的保障能力"}, {"name": "替代提案", "value": "停电期间将速率保障目标下调至2Mbps"} - ] + ], + "feasibility_confirm_request": null +} + +### 示例3:发起阶段(评估可行并请求确认,目标达成) + +{ + "feasibility_negotiation_description": "针对调整后的速率保障目标,可行性评估已完成,结论为可行,请求对方确认是否按此目标继续执行。", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": null, + "feasibility_confirm_request": "评估目标可行,是否同意按照此目标继续执行?" +} + +### 示例4:发起阶段(评估可行并请求确认,方案可行性) + +{ + "feasibility_negotiation_description": "故障订阅任务的保活方案可行性评估已完成,结论为可行,请求对方确认是否按此方案继续执行。", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": null, + "feasibility_confirm_request": "评估方案可行,是否同意按照此方案继续执行?" } -### 示例3:结论阶段(accept) +### 示例5:结论阶段(accept) { "conclusion": "Accept", diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md index be47f717..131aefc5 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md @@ -21,12 +21,15 @@ Output exactly one JSON object containing exactly the following 4 required keys; 1. Time-interval validity: any time interval appearing in the message (such as a guarantee window or an effective period) must be parseable and ordered validly (the start time must not be later than the end time). 2. No conflict with existing constraints: targets or commitments in the message must not directly conflict with existing constraints stated inside the message (such as power-outage duration guarantees, existing subscription limits, or previously confirmed negotiation conclusions). 3. Conclusion and content match: a message whose conclusion is Accept must carry an explicit confirmation (the confirmed information, intent, or outcome statement); a message whose conclusion is Reject must carry an explicit failure or rejection reason. + For a target negotiation ending message that responds to the counterparty's target clarification confirmation request and agrees to proceed, replying with agreement (such as “Agree to proceed with this target”) is a valid confirmation; likewise, for a feasibility negotiation ending message that responds to the counterparty's feasible evaluation confirmation request and agrees to proceed, replying with agreement (such as “Agree to proceed with this target/solution”) is a valid confirmation. For an information-negotiation Reject, the result content must describe every unavailable requested information item separately in an “item name: reason for non-provision” form. A single aggregate “Rejection reason” without coverage of the requested items is incomplete. 4. Field self-consistency: field values within the same message must not contradict each other (for example, the conclusion is Accept while the body states a rejection; or the same numeric target differs across sections). 5. Structural semantics: - For a typed negotiation template, the conclusion value must be either Accept or Reject; an Abort conclusion in a message declared against a typed template is a structural semantics error. For the common abort template, the message must carry the Abort conclusion and a termination reason section stating why the negotiation ended. - An ending-phase (accept-reject) message must contain the result content section (Information Negotiation Result Content / Target Negotiation Result Content / Feasibility Assessment Result Confirmation). - - The two conditional sections of a feasibility negotiation propose message (Under Evaluation Description and Infeasible Evaluation Details and Proposal) are mutually exclusive and must not both appear. + - The three conditional sections of a feasibility negotiation propose message (Under Evaluation Description, Infeasible Evaluation Details and Proposal, and Feasible Evaluation Confirmation Request) are mutually exclusive; at most one may appear, and it must correspond to the message category declared in the summary section: “Initiate feasibility assessment” corresponds to Under Evaluation Description, “Assess as infeasible and propose” corresponds to Infeasible Evaluation Details and Proposal, and “Assess as feasible and request confirmation” corresponds to Feasible Evaluation Confirmation Request. + - In a target negotiation propose message, the Target Clarification Confirmation Request section is mutually exclusive with the Intent Understanding Statement, Understanding Alignment and Clarification, and Content to Clarify sections: when Target Clarification Confirmation Request appears, none of the others may appear. + - The content of a confirmation request section should be its fixed wording: Target Clarification Confirmation Request is “The target has been clarified. Do you agree to proceed with this target?”; Feasible Evaluation Confirmation Request takes one of two forms depending on the assessment category - “The target is assessed as feasible. Do you agree to proceed with this target?” for goal achievement, or “The solution is assessed as feasible. Do you agree to proceed with this solution?” for solution feasibility. Minor wording deviations with equivalent meaning are tolerable and judged semantically, without exact matching; record a semantic error only when the content clearly deviates from the confirmation-request meaning (such as carrying new questions or assessment process details). 6. Template consistency: the negotiation type and phase implied by the message sections must match the template identifier (template_uri) declared in the user prompt and its declared negotiation type. A type mismatch is a type consistency error; a phase mismatch (for example, the declared template identifier is for the propose phase while the message is an ending message, or vice versa) is a phase consistency error. When the declared template is the common abort template, the message must be an abort message: it carries the Abort conclusion and no typed negotiation sections; an abort message declared against a typed template, or a typed message declared against the common abort template, is a template consistency error. ## Parameter Extraction Task @@ -46,11 +49,13 @@ The slot_name of semantic and structural semantics errors must use the following - section.target_intent: Intent Understanding Statement - section.target_alignment: Understanding Alignment and Clarification - section.target_clarification: Content to Clarify +- section.target_confirm_request: Target Clarification Confirmation Request - section.target_conclusion: Target Negotiation Result - section.target_result_content: Target Negotiation Result Content - section.feasibility: Feasibility Negotiation - section.feasibility_evaluate: Under Evaluation Description - section.feasibility_infeasible: Infeasible Evaluation Details and Proposal +- section.feasibility_confirm_request: Feasible Evaluation Confirmation Request - section.feasibility_conclusion: Feasibility Negotiation Result - section.feasibility_confirm: Feasibility Assessment Result Confirmation diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md index a2a7ab62..4f98db03 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md @@ -21,12 +21,15 @@ 1. 时间区间合法性:报文中出现的时间区间(如保障时段、生效时间段)必须可解析且区间顺序合法(开始时间不晚于结束时间)。 2. 与既有约束不冲突:报文中的目标或承诺不得与报文内声明的既有约束(如停电时长保障、既有套餐限制、已确认的历史协商结论)直接冲突。 3. 结论与内容匹配:结论为 Accept 的报文必须携带明确的确认内容(确认后的信息、意图或结论表述);结论为 Reject 的报文必须携带明确的失败或拒绝原因。 + 目标协商结论报文若为回应对方目标澄清后的确认请求且同意继续执行,其结果内容回复同意(如“同意按照此目标继续执行”)即为合法的确认内容;可行性协商结论报文若为回应对方评估可行时的确认请求且同意继续执行,其结果内容回复同意(如“同意按照此目标(方案)继续执行”)亦为合法的确认内容。 对信息协商 Reject,结果内容必须按每个无法提供的请求信息项逐项说明,使用“信息项名称:无法提供的原因”表达;仅提供一个汇总的“拒绝原因”而未覆盖请求项时,判定结果内容不完整。 4. 字段取值自洽:同一报文内各字段取值不得自相矛盾(例如结论为 Accept 而正文表述为拒绝;同一数值目标前后不一致)。 5. 结构语义: - 类型化协商模板的结论字段取值只能为 Accept 或 Reject;类型化模板的报文中出现 Abort 结论记结构语义错误。common abort 模板的报文必须携带 Abort 结论,并存在说明协商终止原因的终止原因板块。 - 结论阶段(accept-reject)报文必须存在结果内容板块(信息协商结果内容 / 目标协商结果内容 / 可行性评估结果确认)。 - - 可行性协商发起报文的两个条件板块(待评估内容说明 与 评估不可行时的详情和提案)互斥,不得同时出现。 + - 可行性协商发起报文的三个条件板块(待评估内容说明、评估不可行时的详情和提案、评估可行时的确认请求)互斥,至多出现一个,且须与概述板块声明的消息类别对应:发起可行性评估对应待评估内容说明,评估不可行并提案对应评估不可行时的详情和提案,评估可行并请求确认对应评估可行时的确认请求。 + - 目标协商发起报文中,目标澄清后的确认请求板块与意图理解陈述、理解对齐与疑问澄清、待澄清内容板块互斥:出现目标澄清后的确认请求时,其余板块不得出现。 + - 确认请求板块的内容应为其固定措辞:目标澄清后的确认请求为“目标已经澄清,是否同意按照此目标继续执行?”;评估可行时的确认请求按评估类别二选一——目标达成为“评估目标可行,是否同意按照此目标继续执行?”,方案可行性为“评估方案可行,是否同意按照此方案继续执行?”。措辞存在轻微差异但语义等同时可容忍,由语义判断把握,不做精确匹配要求;仅当内容明显偏离确认请求语义(如夹带新疑问或评估过程细节)时记语义错误。 6. 模板一致性:报文板块蕴含的协商类型与阶段,必须与用户提示词中声明的模板标识(template_uri)及其声明的协商类型一致。类型不一致记类型一致性错误;阶段不一致(如声明的模板标识为发起阶段而报文为结论报文,或反之)记阶段一致性错误。声明的模板为 common abort 模板时,报文必须是协商终止报文:携带 Abort 结论且不含类型化协商板块;协商终止报文声明为类型化模板,或类型化报文声明为 common abort 模板,均记模板一致性错误。 ## 参数提取任务 @@ -46,11 +49,13 @@ - section.target_intent:意图理解陈述(Intent Understanding Statement) - section.target_alignment:理解对齐与疑问澄清(Understanding Alignment and Clarification) - section.target_clarification:待澄清内容(Content to Clarify) +- section.target_confirm_request:目标澄清后的确认请求(Target Clarification Confirmation Request) - section.target_conclusion:目标协商结果(Target Negotiation Result) - section.target_result_content:目标协商结果内容(Target Negotiation Result Content) - section.feasibility:可行性协商(Feasibility Negotiation) - section.feasibility_evaluate:待评估内容说明(Under Evaluation Description) - section.feasibility_infeasible:评估不可行时的详情和提案(Infeasible Evaluation Details and Proposal) +- section.feasibility_confirm_request:评估可行时的确认请求(Feasible Evaluation Confirmation Request) - section.feasibility_conclusion:可行性协商结果(Feasibility Negotiation Result) - section.feasibility_confirm:可行性评估结果确认(Feasibility Assessment Result Confirmation) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/system.md index 5deea48a..826595d1 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/system.md @@ -6,7 +6,7 @@ Output exactly one JSON object. Do not output markdown code fences, comments, or ## Phase and Output Structure The negotiation phase of the input text is given by the phase field in the user prompt: -1. Propose phase: extract the negotiation summary, intent understanding, alignment and clarification, and clarification required. Output structure: +1. Propose phase: extract the negotiation summary, intent understanding, alignment and clarification, clarification required, and the target clarification confirmation request. Output structure: { "target_negotiation_description": "target negotiation summary, string", @@ -18,7 +18,8 @@ The negotiation phase of the input text is given by the phase field in the user ], "request_for_clarification": [ {"name": "entry name", "value": "entry content"} - ] + ], + "target_confirm_request": "target clarification confirmation request, string or null" } 2. Ending phase (accept / reject / accept-reject): extract the negotiation conclusion and the result content. Output structure: @@ -30,19 +31,25 @@ The negotiation phase of the input text is given by the phase field in the user } ## Field Rules -- target_negotiation_description: required in the propose phase. A paragraph summarizing the purpose and message nature of this target negotiation. +- target_negotiation_description: required in the propose phase. A paragraph summarizing the purpose and message category of this target negotiation. - intent_understanding / alignment_and_clarification / request_for_clarification: optional in the propose phase; each is either an array of entries or null. - intent_understanding: the initiating party's understanding of the peer's intent, typically appearing in the first-round message. - alignment_and_clarification: how both sides have aligned their understanding, plus clarified and pending points, typically appearing in later-round messages. - request_for_clarification: specific questions the peer is asked to clarify; null or an empty array when the input raises no clarification question. - Each entry is an object with exactly two keys, name and value; value may be null. +- target_confirm_request: optional in the propose phase; string or null. + - Non-null only when the category of this round's message is "target clarified and requesting confirmation from the counterparty" (the clarification of the target has been completed and the peer is asked to confirm whether to proceed with this target); in that case intent_understanding, alignment_and_clarification, and request_for_clarification must all be null. + - When non-null, the content is fixed as "The target has been clarified. Do you agree to proceed with this target?" and must not be rephrased. + - Must be null when the category of this round's message is "understanding statement/question clarification". - conclusion: required in the ending phase; must be either "Accept" or "Reject" and must faithfully reflect the conclusion expressed by the input text; never output "Abort". - confirmed_intent: required when the conclusion is "Accept"; the target or intent confirmed by both sides. Must be null when the conclusion is "Reject". - failure_reason: required when the conclusion is "Reject"; the reason for failure or for not reaching agreement. Must be null when the conclusion is "Accept". ## Extraction Principles 1. Extract only content explicitly expressed in the input text; do not fill in values from general knowledge or guess. -2. Assign the three entry arrays by semantics: statements of intent understanding go to intent_understanding; alignment progress and clarification notes go to alignment_and_clarification; explicit questions asked of the peer go to request_for_clarification. +2. In the propose phase, first determine the category of this round's message, taking exactly one of the following two; never mix them: + - "Understanding statement/question clarification": the input states this side's understanding, aligns understanding, or raises clarification questions. In that case assign the three entry arrays by semantics: statements of intent understanding go to intent_understanding; alignment progress and clarification notes go to alignment_and_clarification; explicit questions asked of the peer go to request_for_clarification; target_confirm_request is null. + - "Target clarified and requesting confirmation from the counterparty": the input indicates that the clarification of the target has been completed and asks the peer to confirm whether to proceed with this target. In that case extract target_confirm_request, and the three understanding-related fields must all be null. 3. When the input does not express content for an optional section, output null for that field; do not fabricate entries. 4. In the ending phase, the value of conclusion decides the choice between confirmed_intent and failure_reason; the unused side must be null. 5. Multiple parallel points under the same section must be preserved as multiple entries; do not keep only the last one. @@ -59,10 +66,21 @@ The negotiation phase of the input text is given by the phase field in the user "alignment_and_clarification": null, "request_for_clarification": [ {"name": "guaranteed rate floor", "value": "whether a guaranteed rate floor of 50Mbps is acceptable"} - ] + ], + "target_confirm_request": null +} + +### Example 2: propose phase (target clarified, requesting confirmation) + +{ + "target_negotiation_description": "The clarification of the task target has been completed; request the counterparty to confirm whether to proceed with this target.", + "intent_understanding": null, + "alignment_and_clarification": null, + "request_for_clarification": null, + "target_confirm_request": "The target has been clarified. Do you agree to proceed with this target?" } -### Example 2: ending phase (accept) +### Example 3: ending phase (accept) { "conclusion": "Accept", @@ -70,7 +88,7 @@ The negotiation phase of the input text is given by the phase field in the user "failure_reason": null } -### Example 3: ending phase (reject) +### Example 4: ending phase (reject) { "conclusion": "Reject", diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/system.md index afa1edfe..941fa36e 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/system.md @@ -6,7 +6,7 @@ ## 阶段与输出结构 输入文本所处的协商阶段由用户提示词中的阶段字段给出: -1. 发起阶段(propose):提取目标协商概述、意图理解、理解对齐与疑问澄清、待澄清内容,输出结构: +1. 发起阶段(propose):提取目标协商概述、意图理解、理解对齐与疑问澄清、待澄清内容、目标澄清后的确认请求,输出结构: { "target_negotiation_description": "目标协商概述,字符串", @@ -18,7 +18,8 @@ ], "request_for_clarification": [ {"name": "条目名称", "value": "条目内容"} - ] + ], + "target_confirm_request": "目标澄清后的确认请求,字符串或 null" } 2. 结论阶段(accept / reject / accept-reject):提取协商结论与协商结果内容,输出结构: @@ -30,19 +31,25 @@ } ## 字段规则 -- target_negotiation_description:发起阶段必填。概括本次目标协商的目的与消息性质的一段文字。 +- target_negotiation_description:发起阶段必填。概括本次目标协商的目的与消息类别的一段文字。 - intent_understanding / alignment_and_clarification / request_for_clarification:发起阶段可选,取值为条目数组或 null。 - intent_understanding:发起方对对方意图的理解陈述,通常在首轮报文中出现。 - alignment_and_clarification:双方理解对齐情况与已澄清、待说明的事项,通常在后续轮次报文中出现。 - request_for_clarification:需要对方澄清的具体问题;输入未提出澄清问题时为 null 或空数组。 - 每个条目是一个恰好包含 name 与 value 两个键的对象;value 可为 null。 +- target_confirm_request:发起阶段可选,字符串或 null。 + - 仅当本轮消息类别为"目标已澄清并请求对方确认"(目标澄清完成,请求对方确认是否按此目标继续执行)时非空;此时 intent_understanding、alignment_and_clarification、request_for_clarification 必须全为 null。 + - 非空时内容固定为:"目标已经澄清,是否同意按照此目标继续执行?",不得改写。 + - 本轮消息类别为"理解陈述/疑问澄清"时必须为 null。 - conclusion:结论阶段必填,只能为 "Accept" 或 "Reject",必须忠实于输入文本表达的结论;不得输出 "Abort"。 - confirmed_intent:结论为 "Accept" 时必填,为双方确认后的目标或意图;结论为 "Reject" 时必须为 null。 - failure_reason:结论为 "Reject" 时必填,为失败或未达成一致的原因;结论为 "Accept" 时必须为 null。 ## 提取原则 1. 只提取输入文本中明确表达的内容,不要基于常识补值或猜测。 -2. 三个条目数组按语义归位:对意图的理解陈述归 intent_understanding;理解对齐过程与澄清说明归 alignment_and_clarification;明确要求对方回答的问题归 request_for_clarification。 +2. 发起阶段先判定本轮消息类别,取以下两种之一,不得混提: + - "理解陈述/疑问澄清":输入为本方陈述理解、进行理解对齐或提出疑问澄清。此时三个条目数组按语义归位:对意图的理解陈述归 intent_understanding;理解对齐过程与澄清说明归 alignment_and_clarification;明确要求对方回答的问题归 request_for_clarification;target_confirm_request 为 null。 + - "目标已澄清并请求对方确认":输入表明目标澄清完成,请求对方确认是否按此目标继续执行。此时提取 target_confirm_request,三个理解类字段必须全为 null。 3. 输入未表达某可选板块的内容时,该字段输出 null,不要编造条目。 4. 结论阶段按 conclusion 的取值决定 confirmed_intent 与 failure_reason 的取舍,未用到的一侧必须为 null。 5. 同一板块下多个并列要点应完整保留为多个条目,不要只保留最后一项。 @@ -59,10 +66,21 @@ "alignment_and_clarification": null, "request_for_clarification": [ {"name": "速率保障下限", "value": "50Mbps 的速率保障下限是否可接受"} - ] + ], + "target_confirm_request": null +} + +### 示例2:发起阶段(propose,目标已澄清并请求对方确认) + +{ + "target_negotiation_description": "任务目标澄清完成,请求对方确认是否按此目标继续执行。", + "intent_understanding": null, + "alignment_and_clarification": null, + "request_for_clarification": null, + "target_confirm_request": "目标已经澄清,是否同意按照此目标继续执行?" } -### 示例2:结论阶段(accept) +### 示例3:结论阶段(accept) { "conclusion": "Accept", @@ -70,7 +88,7 @@ "failure_reason": null } -### 示例3:结论阶段(reject) +### 示例4:结论阶段(reject) { "conclusion": "Reject", diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/en-US/template.md index da2729e2..3d91f156 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/en-US/template.md @@ -10,12 +10,14 @@ Requirement: Requirement: 1. If the assessment result is feasible and there are multiple optional solutions in the assessment request, explicitly specify which solution to use 2. If the assessment result is infeasible, state the reason for rejection -3. If the assessment result provided by the counterparty is accepted: briefly summarize the counterparty's assessment result, and state that this assessment result is accepted -4. If the assessment result provided by the counterparty is not accepted: briefly summarize the counterparty's assessment result, and state that this assessment result is not accepted +3. If the feasible evaluation confirmation request from the counterparty is received, and it is determined to proceed with this solution or target, reply with agreement +4. If the assessment result provided by the counterparty is accepted: briefly summarize the counterparty's assessment result, and state that this assessment result is accepted +5. If the assessment result provided by the counterparty is not accepted: briefly summarize the counterparty's assessment result, and state that this assessment result is not accepted Example 1 (Accept): The energy saving goal can be achieved. This negotiation is confirmed and ended. Example 2 (Accept): Agree to adjust the rate guarantee goal from 5Mbps down to 2Mbps. This negotiation is confirmed and ended. Example 3 (Accept): The current neighbor cell keepalive solution is feasible, and the impact is controllable. This negotiation is confirmed and ended. Example 4 (Accept): Accept the assessment result that the energy saving goal achievement is infeasible. This negotiation is confirmed and ended. -Example 5 (Reject): The energy saving goal cannot be achieved. This negotiation is confirmed and ended. -Example 6 (Reject): Do not accept the assessment result that the energy saving goal achievement is infeasible. This negotiation is confirmed and ended. +Example 5 (Accept): Agree to adopt this solution. Please execute according to this solution. This negotiation is confirmed and ended. +Example 6 (Reject): The energy saving goal cannot be achieved. This negotiation is confirmed and ended. +Example 7 (Reject): Do not accept the assessment result that the energy saving goal achievement is infeasible. This negotiation is confirmed and ended. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/zh-CN/template.md index c4c1254a..7c4a1ba8 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/zh-CN/template.md @@ -10,12 +10,14 @@ 要求: 1. 如果评估结果为可行,且评估请求里存在多个可选方案,则明确指定使用哪一个方案 2. 如果评估结果为不可行,则说明拒绝理由 -3. 如果接受对端提供的评估结果,则:简要概述对端的评估结果,并说明接受该评估结果 -4. 如果不接受对端提供的评估结果,则:简要概述对端的评估结果,并说明不接受该评估结果 +3. 如果接收到对方评估可行时的确认请求,且确定按此方案或者目标继续执行,则回复同意 +4. 如果接受对端提供的评估结果,则简要概述对端的评估结果,并说明接受该评估结果 +5. 如果不接受对端提供的评估结果,则简要概述对端的评估结果,并说明不接受该评估结果 样例1(Accept):节能目标可达成,本次协商确认结束。 样例2(Accept):同意将速率保障目标由5Mbps下调至2Mbps,本次协商确认结束。 样例3(Accept):当前邻区保活方案可行,影响可控,本次协商确认结束。 样例4(Accept):接受节能目标达成不可行的评估结果,本次协商确认结束。 -样例5(Reject):节能目标无法达成,本次协商确认结束。 -样例6(Reject):不认同节能目标达成不可行的评估结果,本次协商确认结束。 \ No newline at end of file +样例5(Accept):同意采用该方案,请按此方案执行,本次协商确认结束。 +样例6(Reject):节能目标无法达成,本次协商确认结束。 +样例7(Reject):不认同节能目标达成不可行的评估结果,本次协商确认结束。 \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/en-US/template.md index e32b46d7..f05e9f18 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/en-US/template.md @@ -2,22 +2,25 @@ {{feasibility_negotiation_summary}} (required) Requirement: 1. State whether the feasibility assessment category is goal achievement or solution feasibility -2. State the nature of this round's message, taking one of the following: +2. State the category of this round's message, taking one of the following: - **Initiate feasibility assessment**: Propose the target or solution to be evaluated, and this side has not yet given a conclusion (whether it is the first proposal, or a re-initiation after adjusting the target or solution following an infeasible evaluation in the previous round, it falls into this category) + - **Assess as feasible and request confirmation**: This side has completed the assessment, the conclusion is feasible, and the counterparty needs to confirm whether to proceed with this - **Assess as infeasible and propose**: This side has completed the assessment, the conclusion is infeasible, and the counterparty needs to confirm the handling strategy 3. If it is "Initiate feasibility assessment", reference , and state the action expected of the counterparty: "please assess"; if this is a re-initiation after an infeasible evaluation in the previous round, state the changes in this round relative to the previous round, without repeating the unchanged parts -4. If it is "Assess as infeasible and propose", reference -5. Keep it concise, one to two sentences, without expanding or restating the specific content +4. If it is "Assess as feasible and request confirmation", reference , and briefly state that the assessment conclusion is feasible, without expanding the assessment process details +5. If it is "Assess as infeasible and propose", reference +6. Keep it concise, one to two sentences, without expanding or restating the specific content Example 1 (first initiation of assessment): The energy saving task request has been received. Please help assess whether the energy saving goal can be achieved. See for details. Please assess. Example 2 (proposing a solution to initiate assessment): During the execution of the fault subscription task, self-diagnosis identified a viable keepalive solution. See for details. Please assess whether this solution can be adopted. Example 3 (re-initiating assessment after adjustment): Regarding the previous conflict between the rate guarantee goal and the power outage duration requirement, the rate guarantee goal has been adjusted from 5Mbps down to 2Mbps. See for details. Please re-assess whether this goal can be achieved. -Example 4 (assess as infeasible and propose): The feasibility assessment for goal achievement of the site-level energy saving task has been completed. The current rate goal cannot simultaneously satisfy the power outage duration requirement. See for details. Please confirm. +Example 4 (assess as feasible and request confirmation): Regarding the adjusted rate guarantee goal, the feasibility assessment has been completed, and the conclusion is feasible. Please reply to . +Example 5 (assess as infeasible and propose): The feasibility assessment for goal achievement of the site-level energy saving task has been completed. The current rate goal cannot simultaneously satisfy the power outage duration requirement. See for details. Please confirm. ## Under Evaluation Description {{under_evaluation_description}} (optional) Requirement: -1. Provide this section only when the nature of this round's message is "Initiate feasibility assessment" +1. Provide this section only when the category of this round's message is "Initiate feasibility assessment" 2. State whether the item to be evaluated belongs to goal achievement or solution feasibility 3. For goal achievement, describe: the goal content, and the solution or resource status on which the goal depends; if this is a re-initiation after an infeasible evaluation in the previous round, additionally state the adjusted value and the basis for the adjustment 4. For solution feasibility, describe: the candidate solution content to be evaluated (one or more), the resources required by the solution and whether there is a potential association with existing constraints; if this is a re-initiation after an infeasible evaluation in the previous round, additionally state the replacement/adjusted solution content @@ -29,7 +32,7 @@ Example: The target to be evaluated is the rate guarantee goal of the site-level ## Infeasible Evaluation Details and Proposal {{infeasible_evaluation_details_and_proposal}} (optional) Requirement: -1. Provide this section only when the nature of this round's message is "Assess as infeasible and propose" +1. Provide this section only when the category of this round's message is "Assess as infeasible and propose" 2. State whether the assessment belongs to goal achievement or solution feasibility 3. For goal achievement, describe: the goal content, whether there is self-contradiction or multi-goal conflict, whether the dependent solution or resources are available, and state the basis for the judgment 4. For solution feasibility, describe: the candidate solution content (one or more), whether the resources required by each solution are available, and whether there is a conflict with existing constraints @@ -45,3 +48,15 @@ Upon assessment, if the 5Mbps rate guarantee goal is executed, the equipment pow Conclusion: Infeasible. Proposal: Regarding the conflict between the rate guarantee goal and the power outage duration requirement, it is recommended to adjust the rate guarantee goal from 5Mbps down to 2Mbps, so as to extend the supportable power outage duration to the required 10 hours. If the rate reduction cannot be accepted, the power outage duration guarantee requirement must be shortened accordingly. One of the two must be adjusted; there is currently no third option that can simultaneously satisfy the original two requirements. + +## Feasible Evaluation Confirmation Request +{{feasibility_confirm_request}} (optional) +Requirement: +1. Provide this section only when the category of this round's message is "Assess as feasible and request confirmation" +2. Combined with the assessment category, initiate the confirmation to the counterparty, with the wording fixed as: + - If the assessed content is **goal achievement**: "The target is assessed as feasible. Do you agree to proceed with this target?" + - If the assessed content is **solution feasibility**: "The solution is assessed as feasible. Do you agree to proceed with this solution?" +3. It is generally used by the executor of the original intent to confirm with the initiator of the original intent whether to continue the process + +Example 1 (goal achievement, feasible): The target is assessed as feasible. Do you agree to proceed with this target? +Example 2 (solution feasibility, feasible): The solution is assessed as feasible. Do you agree to proceed with this solution? diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/zh-CN/template.md index 7324a836..a8d8e11c 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/zh-CN/template.md @@ -2,22 +2,26 @@ {{可行性协商概述}}(必填) 要求: 1. 说明可行性评估类别为目标达成还是方案可行性 -2. 说明本轮消息性质,取以下之一: +2. 说明本轮消息类别,取以下之一: - **发起可行性评估**:提出待评估目标或方案,本方尚未给出结论(无论是首次提出,还是因上一轮被评估不可行、已调整目标或方案后重新发起,均归为此类) - - **评估不可行并提案**:本方已完成评估,结论为不可行且需对方确认处理策略 + - **评估可行并请求确认**:本方已完成评估,结论为可行,需对方确认是否按此继续执行 + - **评估不可行并提案**:本方已完成评估,结论为不可行且需对方确认处理策略 3. 若为"发起可行性评估",需引用<待评估内容说明>,并说明期望对方采取的动作:"请评估";若本次是在上一轮被评估不可行后调整发起,需说明本次调整相对上一轮的变化点,不需要重复陈述未变化的部分 -4. 若为"评估不可行并提案",需引用<评估不可行时的详情和提案> -5. 保持简洁,一到两句话,不展开复述具体内容 +4. 若为"评估可行并请求确认",需引用<评估可行时的确认请求>,简要说明评估结论为可行,不展开评估过程细节 +5. 若为"评估不可行并提案",需引用<评估不可行时的详情和提案> +6. 保持简洁,一到两句话,不展开复述具体内容 样例1(首次发起评估):已收到节能任务请求,请协助评估该节能目标能否达成,详见<待评估内容说明>,请评估 样例2(提出方案发起评估):故障订阅任务履行过程中,自诊断识别到一个可行的保活方案,详见<待评估内容说明>,请评估该方案是否可采纳 样例3(调整后再次发起评估):针对此前速率保障目标与停电时长要求的冲突,已将速率保障目标由5Mbps下调至2Mbps,详见<待评估内容说明>,请重新评估该目标是否可达成 -样例4(评估不可行并提案):站点级节能任务的目标达成可行性评估已完成,当前速率目标无法同时满足停电时长要求,详见<评估不可行时的详情和提案>,请确认 +样例4(评估可行并请求确认):针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求> +样例5(评估不可行并提案):站点级节能任务的目标达成可行性评估已完成,当前速率目标无法同时满足停电时长要求,详见<评估不可行时的详情和提案>,请确认 + ## 待评估内容说明 -{{待评估内容说明}}(选填) +{{待评估内容说明}}(选填) 要求: -1. 仅当本轮消息性质为"发起可行性评估"时提供本板块 +1. 仅当本轮消息类别为"发起可行性评估"时提供本板块 2. 说明待评估事项属于目标达成还是方案可行性 3. 目标达成需描述:目标内容、目标所依赖的方案或资源现状;若本次是在上一轮被评估不可行后调整发起,需额外说明本次的调整值及调整依据 4. 方案可行性需描述:待评估的候选方案内容(可一个或多个)、方案所需资源及是否与既有约束存在潜在关联;若本次是在上一轮被评估不可行后调整发起,需额外说明本次替换/调整后的方案内容 @@ -29,7 +33,7 @@ ## 评估不可行时的详情和提案 {{评估不可行时的详情和提案}}(选填) 要求: -1. 仅当本轮消息性质为"评估不可行并提案"时,提供本板块 +1. 仅当本轮消息类别为"评估不可行并提案"时,提供本板块 2. 说明评估属于目标达成还是方案可行性 3. 目标达成需描述:目标内容、是否存在自相矛盾或多目标冲突、所依赖的方案或资源是否具备,并说明判断依据 4. 方案可行性需描述:候选方案内容(可一个或多个)、各方案所需资源是否具备、是否与既有约束冲突 @@ -44,4 +48,16 @@ 经评估,若按5Mbps速率保障目标执行,该时段内设备功耗无法满足停电时长要求,两项要求之间存在冲突,判断依据为当前站点的备电容量及历史功耗曲线。 结论:不可行。 提案:针对速率保障目标与停电时长要求的冲突,建议将速率保障目标由5Mbps下调至2Mbps,以延长可支撑的停电时长至满足要求的10小时。 若无法接受下调速率,则需相应缩短停电时长保障要求。 -两者需二选一调整,暂无第三种可同时满足原有两项要求的方案。 \ No newline at end of file +两者需二选一调整,暂无第三种可同时满足原有两项要求的方案。 + +## 评估可行时的确认请求 +{{评估可行时的确认请求}}(选填) +要求: +1. 仅当本轮消息类别为"评估可行并请求确认"时提供本板块 +2. 结合评估类别,向对方发起确认,措辞固定为: + - 若评估内容为**目标达成**:"评估目标可行,是否同意按照此目标继续执行?" + - 若评估内容为**方案可行性**:"评估方案可行,是否同意按照此方案继续执行?" +3. 一般用于原始意图的执行者向原始意图的发起者确认流程是否继续 + +样例1(目标达成,可行):评估目标可行,是否同意按照此目标继续执行? +样例2(方案可行性,可行):评估方案可行,是否同意按照此方案继续执行? \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/en-US/template.md index 656a2173..501c7585 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/en-US/template.md @@ -2,13 +2,20 @@ {{target_negotiation_result}} (required) Requirement: 1. If all questions can be clarified, return Accept -2. If all questions cannot be fully clarified, return Reject -3. The conclusion must be one of the two, and vague states such as "partially agreed" are not allowed +2. If the target clarification confirmation request from the counterparty is received, and it is determined to proceed with this target, return Accept +3. If all questions cannot be fully clarified, return Reject +4. The conclusion must be one of the two, and vague states such as "partially agreed" are not allowed ## Target Negotiation Result Content {{target_negotiation_result_content}} (required) Requirement: -1. If all questions can be clarified, list the finally confirmed intent content: - - Summarize the understandings that have been confirmed or adopted after correction in all rounds of this negotiation, to form a complete and unambiguous final intent description, rather than listing the change process of each round - - Each item must be directly usable as the basis for subsequent task execution, and must no longer contain uncertain statements such as "to be confirmed" or "may be" -2. If all questions cannot be fully clarified, state the reason for failure +1. If all questions can be clarified, list the finally confirmed intent content +2. If the target clarification confirmation request from the counterparty is received, and it is determined to proceed with this target, reply with agreement +3. If all questions cannot be fully clarified, state the reason for failure + +Examples: +Example 1 (Accept): The clarified intent content is as follows. This negotiation is confirmed and ended: + - The area is Songshanhu + - The time-segmented rate guarantee goals are {00:00~07:00,2Mbps}, {07:00~09:00,10Mbps}, {09:00~17:30,10Mbps}, {17:30~23:00,20Mbps}, {23:00~00:00,10Mbps} +Example 2 (Accept): Agree to proceed with this target. This negotiation is confirmed and ended. +Example 3 (Reject): There are questions that cannot be clarified, and some information cannot be obtained. This negotiation is confirmed and ended. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/zh-CN/template.md index b33e6bf3..878db3be 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/zh-CN/template.md @@ -2,13 +2,20 @@ {{目标协商结果}}(必填) 要求: 1. 如果能够澄清所有疑问,则返回Accept -2. 如果无法完整澄清所有疑问,则返回Reject -3. 结论必须二选一,不允许"部分同意"等模糊状态 +2. 如果接收到对方目标澄清后的确认请求,且确定按此目标继续执行,则返回Accept +3. 如果无法完整澄清所有疑问,则返回Reject +4. 结论必须二选一,不允许"部分同意"等模糊状态 ## 目标协商结果内容 {{目标协商结果内容}}(必填) 要求: -1. 如果能够澄清所有疑问,则列出最终确认的意图内容: - - 需汇总本次协商所有轮次中已 confirmed 或已被 corrected 采纳的理解,形成一份完整、无歧义的最终意图描述,而非罗列各轮变更过程 - - 每条内容需可直接作为后续任务执行的依据,不再包含"待确认""可能是"等不确定表述 -2. 如果无法完整澄清所有疑问,则给出失败原因 \ No newline at end of file +1. 如果能够澄清所有疑问,则列出最终确认的意图内容 +2. 如果接收到对方目标澄清后的确认请求,且确定按此目标继续执行,则回复同意 +3. 如果无法完整澄清所有疑问,则给出失败原因 + +样例: +样例1(Accept):已澄清意图内容如下,本次协商确认结束: + - 区域是松山湖 + - 分时段速率保障目标为{00:00~07:00,2Mbps}、{07:00~09:00,10Mbps}、{09:00~17:30,10Mbps}、{17:30~23:00,20Mbps}、{23:00~00:00,10Mbps} +样例2(Accept):同意按照此目标继续执行,本次协商确认结束。 +样例3(Reject):存在无法澄清的疑问,部分信息无法获得,本次协商确认结束。 \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/en-US/template.md index 21a99ef6..c07bf94c 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/en-US/template.md @@ -2,24 +2,42 @@ {{target_negotiation_summary}} (required) Requirement: Please generate the target negotiation summary according to the following requirements: -1. State the source section of the "understanding content" in this round's message, and distinguish by round: - - If it is the first round of negotiation (this round's message contains ), reference - - If it is a non-first round of negotiation (this round's message is a response to the previous round's questions, containing ), reference -2. If there are points to be clarified in this round, reference and indicate that clarification is needed -3. Explicitly state the action expected of the counterparty in this round, such as "please clarify and confirm". The action statement must correspond to the actual section content of this round (e.g., when this round only needs to confirm understanding with no new questions, the action should be "please confirm") -4. Summarize the core theme of the points to be clarified in this round. The summary must remain concise, limited to one or two sentences, and must not expand or restate the specific content in or +1. State the category of this round's message, taking one of the following: + - **Understanding statement/question clarification**: + - If it is the first round of negotiation (this round's message contains ), reference + - If it is a non-first round of negotiation (this round's message is a response to the previous round's questions, containing ), reference + - If there are points to be clarified in this round, reference and indicate that clarification is needed + - Explicitly state the action expected of the counterparty in this round, such as "please clarify and confirm". The action statement must correspond to the actual section content of this round (e.g., when this round only needs to confirm understanding with no new questions, the action should be "please confirm") + - **Target clarified and requesting confirmation from the counterparty**: The clarification of the task target has been completed, and the counterparty needs to confirm whether to proceed with this target + - The content is fixed as: The clarification of the task target has been completed. Please reply to +2. Summarize the core theme of the points to be clarified in this round (only needed for the "understanding statement/question clarification" scenario). The summary must remain concise, limited to one or two sentences, and must not expand or restate the specific content in , , , or -Example 1: For the intent understanding of the wireless energy saving optimization task, see . There are questions about the area and the energy saving time range. See for details. Please clarify and confirm. -Example 2: Clarification has been provided for the energy saving time range in the wireless energy saving optimization task. See for details. There are still questions about the area information. See for details. Please clarify and confirm. -Example 3: Clarification has been provided for the area information in the wireless energy saving optimization task. See for details. Please confirm. +Example 1 (first round, with questions): For the intent understanding of the wireless energy saving optimization task, see . There are questions about the area and the energy saving time range. See for details. Please clarify and confirm. +Example 2 (non-first round, still with questions): Clarification has been provided for the energy saving time range in the wireless energy saving optimization task. See for details. There are still questions about the area information. See for details. Please clarify and confirm. +Example 3 (target clarified, requesting confirmation): The clarification of the task target has been completed. Please reply to . ## Intent Understanding Statement {{intent_understanding_statement}} (optional) Requirement: Please provide the understanding of the original intent according to the following requirements: 1. It must be a restatement of the original request, and must not introduce new assumptions that do not exist in the original request -2. Each understanding must be annotated with its source, indicating from which field/statement of the original request it is inferred -3. The coverage must be complete: list the understanding of the complete content of the original intent, not only the uncertain parts +2. The coverage must be complete: list the understanding of the complete content of the original intent, not only the uncertain parts + +Example: +Execute the wireless energy saving optimization task according to the following requirements: +1. The area is Songshanhu +2. The time-segmented rate guarantee goals are {00:00~07:00,2Mbps}, {07:00~09:00,10Mbps}, {09:00~17:30,10Mbps}, {17:30~23:00,20Mbps}, {20:00~00:00,10Mbps} + +## Content to Clarify +{{content_to_clarify}} (optional) +Requirement: +For each item to be clarified, the following requirements must be met: +1. It must be locatable to a specific field, and cannot be a general "target unclear" +2. If there are candidate options, please provide them, to avoid free-form description +3. State why the question is raised (optional) + +Example: +1. Two periods in the time-segmented rate guarantee goals overlap in time, and the rate guarantee goal for the overlapping period cannot be uniquely determined. Please adjust the time: {17:30~23:00,20Mbps}, {20:00~00:00,10Mbps} ## Understanding Alignment and Clarification {{understanding_alignment_and_clarification}} (optional) @@ -37,10 +55,15 @@ Requirement: - Must not introduce new field confirmations or new questions outside the statements and questions in this section 5. The response content must maintain the same atomic granularity as the original question points: one question point corresponds to only one response. The responses to multiple question points must not be merged into one comprehensive statement -## Content to Clarify -{{content_to_clarify}} (optional) +Example: +1. Understanding alignment + - [confirmed] The area is Songshanhu + - [confirmed] The time-segmented rate guarantee goals are {00:00~07:00,2Mbps}, {07:00~09:00,10Mbps}, {09:00~17:30,10Mbps}, {17:30~23:00,20Mbps}, {20:00~00:00,10Mbps} +2. Question clarification + - Time-segmented rate guarantee goals: The time overlap issue needs to be resolved, adjusted to {00:00~07:00,2Mbps}, {07:00~09:00,10Mbps}, {09:00~17:30,10Mbps}, {17:30~23:00,20Mbps}, {23:00~00:00,10Mbps} + +## Target Clarification Confirmation Request +{{target_confirm_request}} (optional) Requirement: -For each item to be clarified, the following requirements must be met: -1. It must be locatable to a specific field, and cannot be a general "target unclear" -2. If there are candidate options, please provide them, to avoid free-form description -3. State why the question is raised (optional) +1. Provide this section only when the category of this round's message is "Target clarified and requesting confirmation from the counterparty". It is generally used by the executor of the original intent to confirm with the initiator of the original intent whether to continue the process +2. The content is fixed as: "The target has been clarified. Do you agree to proceed with this target?" diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/zh-CN/template.md index c4857ce1..179b4a9f 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/zh-CN/template.md @@ -2,24 +2,42 @@ {{目标协商概述}}(必填) 要求: 请按照如下要求生成目标协商概述: -1. 需说明本轮消息中"理解类内容"的来源板块,并按轮次区分: - - 若为首轮协商(本轮消息中包含<意图理解陈述>),引用<意图理解陈述> - - 若为非首轮协商(本轮消息是对上一轮疑问的应答,包含<理解对齐与疑问澄清>),引用<理解对齐与疑问澄清> -2. 若本轮存在待澄清的疑问点,需引用<待澄清内容>并提示需要澄清; -3. 需明确提出本轮期望对方采取的动作,如"请澄清和确认",动作表述需与本轮实际板块内容对应(如本轮仅需确认理解、无新疑问时,动作应为"请确认") -4. 需概括本轮待澄清疑问的核心主题,概述需保持简洁,控制在一到两句话内,不得展开复述<意图理解陈述>或<待澄清内容>中的具体内容 +1. 说明本轮消息类别,取以下之一: + - **理解陈述/疑问澄清**: + - 若为首轮协商,需引用<意图理解陈述>陈述对原始意图的理解 + - 若为非首轮协商(本轮消息是对上一轮疑问的应答,包含<理解对齐与疑问澄清>),引用<理解对齐与疑问澄清> + - 若本轮存在待澄清的疑问点,需引用<待澄清内容>并提示需要澄清 + - 需明确提出本轮期望对方采取的动作,如"请澄清和确认",动作表述需与本轮实际板块内容对应(如本轮仅需确认理解、无新疑问时,动作应为"请确认") + - **目标已澄清并请求对方确认**:任务目标澄清完成,需对方确认是否按此目标继续执行 + - 内容固定为:任务目标澄清完成,请答复<目标澄清后的确认请求> +2. 需概括本轮待澄清疑问的核心主题(仅"理解陈述/疑问澄清"场景需要),概述需保持简洁,控制在一到两句话内,不得展开复述<意图理解陈述>/<理解对齐与疑问澄清>/<待澄清内容>/<目标澄清后的确认请求>中的具体内容 -样例1:对无线节能优化任务的意图理解参见<意图理解陈述>,对区域和节能的时间范围存在疑问,详见<待澄清内容>,请澄清和确认 -样例2:已针对无线节能优化任务中的节能时间范围提供疑问澄清,详见<理解对齐与疑问澄清>,对区域信息仍有疑问,详见<待澄清内容>,请澄清和确认 -样例3:已针对无线节能优化任务中的区域信息提供疑问澄清,详见<理解对齐与疑问澄清>,请确认 +样例1(首轮,有疑问):对无线网络节能任务的意图理解参见<意图理解陈述>,对区域和节能的时间范围存在疑问,详见<待澄清内容>,请澄清和确认 +样例2(非首轮,仍有疑问):已针对无线网络节能任务中的节能时间范围提供疑问澄清,详见<理解对齐与疑问澄清>,对区域信息仍有疑问,详见<待澄清内容>,请澄清和确认 +样例3(目标已澄清,请求确认):任务目标澄清完成,请答复<目标澄清后的确认请求> ## 意图理解陈述 {{意图理解陈述}}(选填) 要求: 请按照如下要求提供对原始意图的理解: 1. 必须是对原始请求的复述性理解,不得引入原始请求中不存在的新假设 -2. 每条理解需标注来源,指明是从原始请求的哪个字段/表述推断得出 -3. 覆盖度要完整:列出对于原始意图的完整内容的理解,不能只包含不确定的部分 +2. 覆盖度要完整:列出对于原始意图的完整内容的理解,不能只包含不确定的部分 + +样例: +按照如下要求执行无线网络节能任务: +1. 区域是松山湖 +2. 分时段速率保障目标为{00:00~07:00,2Mbps}、{07:00~09:00,10Mbps}、{09:00~17:30,10Mbps}、{17:30~23:00,20Mbps}、{20:00~00:00,10Mbps} + +## 待澄清内容 +{{待澄清内容}}(选填) +要求: +对于每一个待澄清项,需要满足以下要求: +1. 必须能定位到具体字段,不能是笼统的"目标不清楚" +2. 如有候选项请给出,避免自由发挥 +3. 说明为何提出该疑问(可选) + +样例: +1. 速率保障目标中如下两个时段存在时间重叠,无法唯一确定重叠时段的速率保障目标,请调整时间:{17:30~23:00,20Mbps}、{20:00~00:00,10Mbps} ## 理解对齐与疑问澄清 {{理解对齐与疑问澄清}}(选填) @@ -38,10 +56,15 @@ 5. 应答内容需保持与原疑问点相同的原子化粒度:一个疑问点只对应一条应答, 不得将多个疑问点的回答合并为一段综合说明 -## 待澄清内容 -{{待澄清内容}}(选填) +样例: +1. 理解对齐 + - [confirmed] 区域是松山湖 + - [confirmed] 分时段速率保障目标为{00:00~07:00,2Mbps}、{07:00~09:00,10Mbps}、{09:00~17:30,10Mbps}、{17:30~23:00,20Mbps}、{20:00~00:00,10Mbps} +2. 疑问澄清 + - 分时段速率保障目标:需解决时间重叠问题,调整为{00:00~07:00,2Mbps}、{07:00~09:00,10Mbps}、{09:00~17:30,10Mbps}、{17:30~23:00,20Mbps}、{23:00~00:00,10Mbps} + +## 目标澄清后的确认请求 +{{目标澄清后的确认请求}}(选填) 要求: -对于每一个待澄清项,需要满足以下要求: -1. 必须能定位到具体字段,不能是笼统的"目标不清楚" -2. 如有候选项请给出,避免自由发挥 -3. 说明为何提出该疑问(可选) +1. 仅当本轮消息类别为"目标已澄清并请求对方确认"时提供本板块,一般用于原始意图的执行者向原始意图的发起者确认流程是否继续 +2. 内容固定为:"目标已经澄清,是否同意按照此目标继续执行?" diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java index 09e616df..03371042 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java @@ -131,10 +131,32 @@ private static List> targetNegotiation(A2ATClient client, Co ScenarioData.text(propose, "description"), ScenarioData.items(propose, "intent_understanding"), ScenarioData.items(propose, "alignment_and_clarification"), - ScenarioData.items(propose, "request_for_clarification"))), + ScenarioData.items(propose, "request_for_clarification"), + ScenarioData.text(propose, "target_confirm_request"))), NegotiationSampleSupport.TARGET_PROPOSE_URI); results.add(NegotiationSampleSupport.summary("target", "propose", proposeResult, logSink)); + // propose (round 2): the target is clarified and the executor requests confirmation to proceed + Map proposeConfirm = ScenarioData.map(section, "propose_confirm"); + if (!proposeConfirm.isEmpty()) { + NegotiationContext laterRoundCtx = new NegotiationContext( + NegotiationSampleSupport.SESSION_ID, + 2, + NegotiationContext.DEFAULT_MAX_ROUNDS, + NegotiationPerformative.PROPOSE); + MetadataContent proposeConfirmResult = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + laterRoundCtx, + new TargetProposeContent( + ScenarioData.text(proposeConfirm, "description"), + null, + null, + null, + ScenarioData.text(proposeConfirm, "target_confirm_request"))), + NegotiationSampleSupport.TARGET_PROPOSE_URI); + results.add(NegotiationSampleSupport.summary("target", "propose_confirm", proposeConfirmResult, logSink)); + } + // accept: confirm the negotiated target intent Map accept = ScenarioData.map(section, "accept"); MetadataContent acceptResult = client.generateNegotiationAcceptPromptFromData( @@ -179,10 +201,28 @@ private static List> feasibilityNegotiation(A2ATClient clien ScenarioData.text(propose, "description"), NegotiationAction.valueOf(ScenarioData.text(propose, "action")), ScenarioData.items(propose, "contents_to_evaluate"), - ScenarioData.items(propose, "infeasibility_details_and_proposal"))), + ScenarioData.items(propose, "infeasibility_details_and_proposal"), + ScenarioData.text(propose, "feasibility_confirm_request"))), NegotiationSampleSupport.FEASIBILITY_PROPOSE_URI); results.add(NegotiationSampleSupport.summary("feasibility", "propose", proposeResult, logSink)); + // propose (round 2): the assessment is complete and the executor requests confirmation to proceed + Map proposeConfirm = ScenarioData.map(section, "propose_confirm"); + if (!proposeConfirm.isEmpty()) { + MetadataContent proposeConfirmResult = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + ctx, + new FeasibilityProposeContent( + ScenarioData.text(proposeConfirm, "description"), + NegotiationAction.valueOf(ScenarioData.text(proposeConfirm, "action")), + ScenarioData.items(proposeConfirm, "contents_to_evaluate"), + ScenarioData.items(proposeConfirm, "infeasibility_details_and_proposal"), + ScenarioData.text(proposeConfirm, "feasibility_confirm_request"))), + NegotiationSampleSupport.FEASIBILITY_PROPOSE_URI); + results.add( + NegotiationSampleSupport.summary("feasibility", "propose_confirm", proposeConfirmResult, logSink)); + } + // accept: feasibility confirmed Map accept = ScenarioData.map(section, "accept"); MetadataContent acceptResult = client.generateNegotiationAcceptPromptFromData( diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java index 63354698..6cc6d2fd 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java @@ -104,6 +104,19 @@ private static List> targetNegotiation(A2ATClient client, Co text(cases, "propose"), ctx, NegotiationSampleSupport.TARGET_PROPOSE_URI); results.add(NegotiationSampleSupport.summary("target", "propose", propose, logSink)); + // propose (round 2): the target is clarified and the executor requests confirmation to proceed + String proposeConfirmText = text(cases, "propose_confirm"); + if (proposeConfirmText != null) { + NegotiationContext laterRoundCtx = new NegotiationContext( + NegotiationSampleSupport.SESSION_ID, + 2, + NegotiationContext.DEFAULT_MAX_ROUNDS, + NegotiationPerformative.PROPOSE); + MetadataContent proposeConfirm = client.generateNegotiationProposePromptFromText( + proposeConfirmText, laterRoundCtx, NegotiationSampleSupport.TARGET_PROPOSE_URI); + results.add(NegotiationSampleSupport.summary("target", "propose_confirm", proposeConfirm, logSink)); + } + // accept: confirm the negotiated target intent MetadataContent accept = client.generateNegotiationAcceptPromptFromText( text(cases, "accept"), ctx, NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); @@ -135,6 +148,14 @@ private static List> feasibilityNegotiation(A2ATClient clien text(cases, "propose"), ctx, NegotiationSampleSupport.FEASIBILITY_PROPOSE_URI); results.add(NegotiationSampleSupport.summary("feasibility", "propose", propose, logSink)); + // propose (round 2): the assessment is complete and the executor requests confirmation to proceed + String proposeConfirmText = text(cases, "propose_confirm"); + if (proposeConfirmText != null) { + MetadataContent proposeConfirm = client.generateNegotiationProposePromptFromText( + proposeConfirmText, ctx, NegotiationSampleSupport.FEASIBILITY_PROPOSE_URI); + results.add(NegotiationSampleSupport.summary("feasibility", "propose_confirm", proposeConfirm, logSink)); + } + // accept: feasibility confirmed MetadataContent accept = client.generateNegotiationAcceptPromptFromText( text(cases, "accept"), ctx, NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); diff --git a/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json b/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json index c5c1afbf..f1b13b18 100644 --- a/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json +++ b/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json @@ -94,6 +94,10 @@ } ] }, + "propose_confirm": { + "description": "任务目标澄清完成:08:00-18:00对松山湖站点启用无线节点节能优化,速率保障不低于10Mbps,请答复目标澄清后的确认请求", + "target_confirm_request": "目标已经澄清,是否同意按照此目标继续执行?" + }, "accept": { "confirmed_intent": "最终确认意图:08:00-18:00对松山湖站点启用无线节点节能优化,速率保障不低于10Mbps" }, @@ -116,6 +120,11 @@ } ] }, + "propose_confirm": { + "description": "针对松山湖站点节能优化目标的可行性评估已完成,结论为可行,请答复评估可行时的确认请求", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "feasibility_confirm_request": "评估目标可行,是否同意按照此目标继续执行?" + }, "accept": { "summary": "高峰时段节能优化可行,速率保障满足10Mbps下限,可启用" }, @@ -131,11 +140,13 @@ }, "target": { "propose": "对无线节点节能优化任务的意图理解如下:任务意图:08:00-18:00对目标站点启用无线节点节能优化。待澄清内容:节能区域松山湖还是其他站点?", + "propose_confirm": "任务目标澄清完成:08:00-18:00对松山湖站点启用无线节点节能优化,速率保障不低于10Mbps。目标已经澄清,是否同意按照此目标继续执行?", "accept": "确认意图:08:00-18:00对松山湖站点启用无线节点节能优化,速率保障不低于10Mbps。最终意图已明确,可以执行。", "reject": "拒绝:节能区域信息因站点清单不可用而无法完整澄清,本次协商结束。" }, "feasibility": { "propose": "请评估无线节点节能优化在高峰时段是否可行。评估对象:松山湖站点08:00-18:00节能窗口。评估约束:速率保障不低于10Mbps。", + "propose_confirm": "针对松山湖站点节能优化目标的可行性评估已完成:高峰时段节能优化可行,速率保障满足10Mbps下限。评估目标可行,是否同意按照此目标继续执行?", "accept": "同意:高峰时段节能优化可行,速率保障满足10Mbps下限,可启用。评估结果:可行。", "reject": "拒绝:节能目标在现有供电约束下无法实现,需调整方案。评估结果:不可行。" } diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index 4f51adb0..90c3ff6f 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -181,7 +181,8 @@ private static List symmetryCases() { "Clarify the intent of the ran-energy-saving task.", List.of(new NegotiationItem("task intent", "ran-energy-saving optimization")), null, - List.of(new NegotiationItem("area", "which site is covered"))))), + List.of(new NegotiationItem("area", "which site is covered")), + null))), new SymmetryCase( "feasibility_propose", NegotiationPerformative.PROPOSE, @@ -193,6 +194,7 @@ private static List symmetryCases() { "Please assess the adjusted rate target.", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, List.of(new NegotiationItem("adjusted target", "rate lowered to 2Mbps")), + null, null))), endingCase( "information_accept", diff --git a/docs/en/API_Reference.md b/docs/en/API_Reference.md index 210e4b5b..8bc0a5f7 100644 --- a/docs/en/API_Reference.md +++ b/docs/en/API_Reference.md @@ -124,7 +124,7 @@ Error codes: - `negotiation_llm_infrastructure_error` (LLM infrastructure failure, retryable) -- `negotiation_invalid_input` (text is blank, or the extracted content contradicts the phase) +- `negotiation_invalid_input` (text is blank, the extracted content contradicts the phase, or the confirm request contradicts the other sections) - `negotiation_slot_missing` (a required slot is missing) @@ -286,8 +286,8 @@ Propose content of the three negotiation types: | Negotiation type | Propose content type | Fields | | -------- | ---------------- | ---- | | Information | `InformationProposeContent` | `items` (list of missing items), `relationship` (relationship between the missing items, nullable) | -| Target | `TargetProposeContent` | `targetNegotiationDescription` (required), `intentUnderstanding`, `alignmentAndClarification`, `requestForClarification` (all three item lists nullable; empty lists omit the corresponding sections) | -| Feasibility | `FeasibilityProposeContent` | `feasibilityNegotiationDescription` (required), `action` (`NegotiationAction.REQUEST_FEASIBILITY_EVALUATION` / `PROPOSE_ALTERNATIVE_ON_FAILURE`), `contentsToEvaluate`, `infeasibilityDetailsAndProposal` | +| Target | `TargetProposeContent` | `targetNegotiationDescription` (required), `intentUnderstanding`, `alignmentAndClarification`, `requestForClarification` (all three item lists nullable; empty lists omit the corresponding sections), `targetConfirmRequest` (nullable string; a non-null value marks this round's message category as "target clarified and requesting confirmation from the counterparty" and renders the Target Clarification Confirmation Request section with the fixed content "The target has been clarified. Do you agree to proceed with this target?"; when it is non-null, `intentUnderstanding` / `alignmentAndClarification` / `requestForClarification` must all be empty) | +| Feasibility | `FeasibilityProposeContent` | `feasibilityNegotiationDescription` (required), `action` (`NegotiationAction.REQUEST_FEASIBILITY_EVALUATION` / `PROPOSE_ALTERNATIVE_ON_FAILURE`, unchanged two values), `contentsToEvaluate`, `infeasibilityDetailsAndProposal`, `feasibilityConfirmRequest` (nullable string; a non-null value marks this round's message category as "assess as feasible and request confirmation": `action` must be `REQUEST_FEASIBILITY_EVALUATION` with `contentsToEvaluate` / `infeasibilityDetailsAndProposal` both empty; the content is fixed by the assessment category to "The target is assessed as feasible. Do you agree to proceed with this target?" (goal achievement) or "The solution is assessed as feasible. Do you agree to proceed with this solution?" (solution feasibility)) | **Request Example** diff --git "a/docs/zh/API\345\217\202\350\200\203.md" "b/docs/zh/API\345\217\202\350\200\203.md" index 2e391760..92e3f85f 100644 --- "a/docs/zh/API\345\217\202\350\200\203.md" +++ "b/docs/zh/API\345\217\202\350\200\203.md" @@ -123,7 +123,7 @@ Map metadata = propose.buildMetadataContent(); - `negotiation_llm_infrastructure_error`(LLM 基础设施故障,可重试) -- `negotiation_invalid_input`(文本为空、抽取内容与阶段不符) +- `negotiation_invalid_input`(文本为空、抽取内容与阶段不符、确认请求与其它板块组合矛盾) - `negotiation_slot_missing`(缺少必需槽位) @@ -284,8 +284,8 @@ public MetadataContent generateNegotiationProposePromptFromData( | 协商类型 | Propose 内容类型 | 字段 | | -------- | ---------------- | ---- | | 信息协商 | `InformationProposeContent` | `items`(缺失项清单)、`relationship`(缺失项间关系,可空) | -| 目标协商 | `TargetProposeContent` | `targetNegotiationDescription`(必填)、`intentUnderstanding`、`alignmentAndClarification`、`requestForClarification`(三个条目列表均可空,空则省略对应章节) | -| 可行性协商 | `FeasibilityProposeContent` | `feasibilityNegotiationDescription`(必填)、`action`(`NegotiationAction.REQUEST_FEASIBILITY_EVALUATION` / `PROPOSE_ALTERNATIVE_ON_FAILURE`)、`contentsToEvaluate`、`infeasibilityDetailsAndProposal` | +| 目标协商 | `TargetProposeContent` | `targetNegotiationDescription`(必填)、`intentUnderstanding`、`alignmentAndClarification`、`requestForClarification`(三个条目列表均可空,空则省略对应章节)、`targetConfirmRequest`(可空 string,非空表示本轮消息类别为"目标已澄清并请求对方确认",渲染"目标澄清后的确认请求"章节,内容固定为"目标已经澄清,是否同意按照此目标继续执行?";非空时 `intentUnderstanding` / `alignmentAndClarification` / `requestForClarification` 必须全为空) | +| 可行性协商 | `FeasibilityProposeContent` | `feasibilityNegotiationDescription`(必填)、`action`(`NegotiationAction.REQUEST_FEASIBILITY_EVALUATION` / `PROPOSE_ALTERNATIVE_ON_FAILURE`,两值不变)、`contentsToEvaluate`、`infeasibilityDetailsAndProposal`、`feasibilityConfirmRequest`(可空 string,非空表示本轮消息类别为"评估可行并请求确认":`action` 须取 `REQUEST_FEASIBILITY_EVALUATION` 且 `contentsToEvaluate` / `infeasibilityDetailsAndProposal` 皆为空;内容按评估类别固定为"评估目标可行,是否同意按照此目标继续执行?"(目标达成)或"评估方案可行,是否同意按照此方案继续执行?"(方案可行性)) | **请求样例** diff --git a/tools/corpus_index.py b/tools/corpus_index.py index bcdb3302..b4113320 100644 --- a/tools/corpus_index.py +++ b/tools/corpus_index.py @@ -122,6 +122,9 @@ def load_corpus(root: Path) -> tuple[list[Case], list[Scenario], dict, list[str] elif path.name == "schemas.json": shared["schemas"] = len(data) continue + # The live family targets a real model and is not part of the offline coverage index. + if relative.startswith("live/"): + continue if not isinstance(data, list): errors.append(error(path, "corpus-file", "Corpus files must be JSON arrays of records.")) continue diff --git a/tools/template_lint.py b/tools/template_lint.py index 3d0cad42..36ae923e 100644 --- a/tools/template_lint.py +++ b/tools/template_lint.py @@ -34,9 +34,9 @@ NEGOTIATION_PROFILES = { ("information-negotiation", "propose"): ("info_static", "info_items"), ("information-negotiation", "accept-reject"): ("info_conclusion", "info_result_content"), - ("target-negotiation", "propose"): ("target", "target_intent", "target_alignment", "target_clarification"), + ("target-negotiation", "propose"): ("target", "target_intent", "target_alignment", "target_clarification", "target_confirm_request"), ("target-negotiation", "accept-reject"): ("target_conclusion", "target_result_content"), - ("feasibility-negotiation", "propose"): ("feasibility", "feasibility_evaluate", "feasibility_infeasible"), + ("feasibility-negotiation", "propose"): ("feasibility", "feasibility_evaluate", "feasibility_infeasible", "feasibility_confirm_request"), ("feasibility-negotiation", "accept-reject"): ("feasibility_conclusion", "feasibility_confirm"), } NEGOTIATION_MARKER = re.compile( From 333a8711d93b8c6310cdad56e5f48f2d7bf4e3d2 Mon Sep 17 00:00:00 2001 From: gcw_mr3RVhmf Date: Fri, 28 Aug 2026 17:21:21 +0800 Subject: [PATCH 222/295] docs(sample): document negotiation evaluation runs Signed-off-by: gcw_mr3RVhmf --- a2a-t-sample/README.zh-CN.md | 58 +++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 6b53843a..6a1c0c02 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -176,6 +176,62 @@ java @a2a-t-sample/target/eval.javaargs.txt --out eval-report-my-model.json /pat **换用其他模型测试**:只需在 env 文件里改 4 个 LLM 变量(`A2AT_LLM_PROVIDER` / `A2AT_LLM_MODEL` / `A2AT_LLM_API_KEY` / `A2AT_LLM_BASE_URL`),无需改任何代码或用例。报告的 `llm` 字段会记录当次使用的 provider / model / base_url,`negotiation_channel` 字段记录当次通道(`per-case` 或强制值),便于横向对比多个模型的报告。 +## 专线投诉信息协商 Qwen 评测 + +入口 `NegotiationQwenEvaluationMain` 用于验证传输专线业务投诉诊断场景的信息协商闭环。每条用例按以下四个阶段执行: + +1. 生成 propose 协商报文 +2. 校验 propose 协商报文并提取参数 +3. 根据用例结论生成 accept 或 reject 协商报文 +4. 校验 accept 或 reject 协商报文并提取参数 + +全量用例会覆盖三组生成/校验接口:propose、accept、reject。评测的通过条件是本条用例的四个阶段都成功返回;自然语言提取结果允许存在等义表述差异。 + +### 运行 Qwen 评测 + +先复制 `src/main/resources/sample/private-line-complaint-negotiation/evaluation/qwen.env.example` 到本地文件(例如 `qwen.env`),填写目标网络中的模型地址和 API key。环境文件已被 Git 忽略,不要将真实凭据提交到仓库。 + +首次运行先打包,Maven 会生成 `target/negotiation-qwen-evaluation.javaargs.txt`: + +```bash +mvn -pl a2a-t-sample -am -DskipTests package +``` + +评测入口参数依次为:环境文件、报告路径、过程日志路径、用例选择器。用例选择器支持 `smoke`(20 条)、`full`(100 条)或逗号分隔的用例 ID: + +```bash +# 20 条 smoke 用例 +java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt \ + /path/to/qwen.env \ + a2a-t-sample/target/negotiation-qwen-smoke-report.json \ + a2a-t-sample/target/negotiation-qwen-smoke-process.jsonl \ + smoke + +# 100 条全量用例 +java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt \ + /path/to/qwen.env \ + a2a-t-sample/target/negotiation-qwen-report.json \ + a2a-t-sample/target/negotiation-qwen-process.jsonl \ + full + +# 只复现指定用例 +java @a2a-t-sample/target/negotiation-qwen-evaluation.javaargs.txt \ + /path/to/qwen.env \ + a2a-t-sample/target/negotiation-qwen-repro-report.json \ + a2a-t-sample/target/negotiation-qwen-repro-process.jsonl \ + P02,P14,R27 +``` + +不传报告和过程日志路径时,默认写入 `a2a-t-sample/target/negotiation-qwen-report.json`,过程日志使用同名报告加 `-process.jsonl` 后缀;不传用例选择器时运行全量用例。 + +### 评测产物 + +- 报告 JSON 的 `cases` 保存每条用例的输入、四阶段结果、通过状态和错误信息。 +- 报告 JSON 的 `api_trace` 保存每次 SDK 接口调用的完整 request/response、接口名、阶段和耗时。 +- 过程日志是 JSONL,每行对应一次阶段调用,包含 `timestamp`、`case_id`、`step`、`api`、`request`、`response`、`elapsed_ms` 和 `outcome`;调用失败时额外记录 `error`。 + +生成接口的 request 使用 `text`、`context`、`template_uri`,校验接口的 request 使用 `prompt`、`context`、`schema`、`template_uri`。生成接口的 response 包含生成的 prompt,校验接口的 response 包含 `filled_data`。这些字段可用于区分输入构造、模板渲染和校验提参环节的问题。 + **env 配置**:复制以下内容为 `eval.env`,填入你的模型信息即可(完整变量说明见根目录 `env.example`): ```bash @@ -302,4 +358,4 @@ java @a2a-t-sample/target/negotiation.javaargs.txt /path/to/.env Task-T 样例(无需启动服务端,单进程,需指定含 LLM key 的 .env) ```bash -java @a2a-t-sample/target/taskt.javaargs.txt /path/to/.env \ No newline at end of file +java @a2a-t-sample/target/taskt.javaargs.txt /path/to/.env From 718cdc92e5a960abd5954bad215c9c6c07877c97 Mon Sep 17 00:00:00 2001 From: gcw_mr3RVhmf Date: Fri, 28 Aug 2026 17:35:52 +0800 Subject: [PATCH 223/295] test(sample): add negotiation abnormal evaluations Signed-off-by: gcw_mr3RVhmf --- a2a-t-sample/pom.xml | 8 + .../NegotiationAbnormalEvaluationCase.java | 21 + ...gotiationAbnormalEvaluationCaseLoader.java | 33 ++ .../NegotiationAbnormalEvaluationMain.java | 498 ++++++++++++++++++ ...tiationBusinessAbnormalEvaluationCase.java | 12 + ...nBusinessAbnormalEvaluationCaseLoader.java | 29 + ...tiationBusinessAbnormalEvaluationMain.java | 279 ++++++++++ .../NegotiationQwenEvaluationMain.java | 17 +- .../evaluation/abnormal-cases.json | 427 +++++++++++++++ .../evaluation/business-abnormal-cases.json | 86 +++ 10 files changed, 1394 insertions(+), 16 deletions(-) create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCase.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCaseLoader.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCase.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCaseLoader.java create mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java create mode 100644 a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/abnormal-cases.json create mode 100644 a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json diff --git a/a2a-t-sample/pom.xml b/a2a-t-sample/pom.xml index 48525c55..f4fb02de 100644 --- a/a2a-t-sample/pom.xml +++ b/a2a-t-sample/pom.xml @@ -140,6 +140,14 @@ net.openan.a2at.sample.private_line_complaint.negotiation.client.NegotiationClie -cp ${sample.jar.path}${path.separator}${sample.runtime.classpath} net.openan.a2at.sample.private_line_complaint.negotiation.evaluation.NegotiationQwenEvaluationMain + + -cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.private_line_complaint.negotiation.evaluation.NegotiationAbnormalEvaluationMain + + -cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.private_line_complaint.negotiation.evaluation.NegotiationBusinessAbnormalEvaluationMain -cp ${sample.jar.path}${path.separator}${sample.runtime.classpath} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCase.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCase.java new file mode 100644 index 00000000..23d1113a --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCase.java @@ -0,0 +1,21 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** One deterministic abnormal-input case for the six Negotiation-T facade APIs. */ +public record NegotiationAbnormalEvaluationCase( + String id, + String api, + String input, + String template, + String performative, + @JsonProperty("expected_exception") + String expectedException, + @JsonProperty("expected_code") + String expectedCode, + @JsonProperty("context_mode") + String contextMode, + @JsonProperty("schema_mode") + String schemaMode, + String description) { +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCaseLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCaseLoader.java new file mode 100644 index 00000000..612e5fb2 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCaseLoader.java @@ -0,0 +1,33 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +/** Loads deterministic abnormal-input cases packaged with the private-line sample. */ +public final class NegotiationAbnormalEvaluationCaseLoader { + + public static final String RESOURCE_PATH = + "sample/private-line-complaint-negotiation/evaluation/abnormal-cases.json"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private NegotiationAbnormalEvaluationCaseLoader() { + } + + public static List load() { + try (InputStream input = NegotiationAbnormalEvaluationCaseLoader.class + .getClassLoader() + .getResourceAsStream(RESOURCE_PATH)) { + if (input == null) { + throw new IllegalStateException("Abnormal negotiation cases not found: " + RESOURCE_PATH); + } + return List.copyOf(OBJECT_MAPPER.readValue(input, new TypeReference<>() { + })); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read abnormal negotiation cases", exception); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java new file mode 100644 index 00000000..a89558af --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java @@ -0,0 +1,498 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleEnvironment; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; + +/** Runs deterministic abnormal-input checks against all six Negotiation-T facade APIs and core pipelines. */ +public final class NegotiationAbnormalEvaluationMain { + + private static final ObjectMapper OBJECT_MAPPER = + new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + private static final List NEGOTIATION_ERROR_CODES = List.of( + "template_not_found", + "negotiation_content_extract_failed", + "negotiation_semantic_rejected", + "negotiation_slot_missing", + "negotiation_invalid_input", + "negotiation_rule_violation", + "negotiation_llm_infrastructure_error"); + + private NegotiationAbnormalEvaluationMain() { + } + + public static void main(String[] args) throws IOException { + Path envPath = args.length > 0 + ? Path.of(args[0]) + : Path.of("a2a-t-sample", "src", "main", "resources", "sample", + "private-line-complaint-negotiation", "qwen.env"); + String suffix = LocalDateTime.now(ZoneId.systemDefault()).format(FILE_TIMESTAMP); + Path reportPath = args.length > 1 + ? Path.of(args[1]) + : Path.of("a2a-t-sample", "target", "negotiation-abnormal-report-" + suffix + ".json"); + Path processLogPath = args.length > 2 + ? Path.of(args[2]) + : Path.of("a2a-t-sample", "target", "negotiation-abnormal-process-log-" + suffix + ".jsonl"); + + A2ATClient client = new A2ATClient(envPath); + A2ATServer server = new A2ATServer(envPath); + String runId = UUID.randomUUID().toString(); + List> results = new ArrayList<>(); + try (NegotiationEvaluationProcessLogger logger = + new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, processLogPath)) { + logger.write(event("run_started", runId, null, Map.of( + "env_path", envPath.toAbsolutePath().toString(), + "case_count", NegotiationAbnormalEvaluationCaseLoader.load().size() + scriptedCases().size()), null, null)); + for (NegotiationAbnormalEvaluationCase testCase : NegotiationAbnormalEvaluationCaseLoader.load()) { + results.add(runCase(client, server, testCase, runId, logger)); + } + for (ScriptedAbnormalCase testCase : scriptedCases()) { + results.add(runScriptedCase(testCase, runId, logger)); + } + } + + long passed = results.stream().filter(result -> Boolean.TRUE.equals(result.get("passed"))).count(); + Map report = new LinkedHashMap<>(); + report.put("generated_at", Instant.now().toString()); + report.put("run_id", runId); + report.put("git_revision", gitRevision()); + report.put("total", results.size()); + report.put("passed", passed); + report.put("failed", results.size() - passed); + report.put("success_rate", results.isEmpty() ? 1.0 : (double) passed / results.size()); + report.put("definition", "异常用例在预期 SDK 异常发生且错误类型/错误码匹配时判定通过;门面用例使用边界输入,脚本化用例注入固定 LLM 响应,不访问外部 LLM。"); + report.put("error_code_coverage", errorCodeCoverage(results)); + report.put("process_log", processLogPath.toAbsolutePath().toString()); + report.put("cases", results); + Path parent = reportPath.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + OBJECT_MAPPER.writeValue(reportPath.toFile(), report); + System.out.printf("Abnormal negotiation evaluation complete: %d/%d passed; report=%s; process-log=%s%n", + passed, results.size(), reportPath.toAbsolutePath(), processLogPath.toAbsolutePath()); + } + + private static Map errorCodeCoverage(List> results) { + Map counts = new LinkedHashMap<>(); + NEGOTIATION_ERROR_CODES.forEach(code -> counts.put(code, 0)); + Set observed = new LinkedHashSet<>(); + for (Map result : results) { + Object error = result.get("actual_error"); + if (error instanceof Map errorMap && errorMap.get("code") instanceof String code) { + observed.add(code); + if (counts.containsKey(code)) { + counts.computeIfPresent(code, (ignored, count) -> count + 1); + } + } + } + List missing = NEGOTIATION_ERROR_CODES.stream() + .filter(code -> counts.get(code) == 0) + .toList(); + List unexpected = observed.stream() + .filter(code -> !counts.containsKey(code)) + .toList(); + Map coverage = new LinkedHashMap<>(); + coverage.put("expected_codes", NEGOTIATION_ERROR_CODES); + coverage.put("observed_counts", counts); + coverage.put("missing_codes", missing); + coverage.put("unexpected_codes", unexpected); + coverage.put("all_expected_codes_covered", missing.isEmpty()); + return coverage; + } + + private static Map runCase( + A2ATClient client, + A2ATServer server, + NegotiationAbnormalEvaluationCase testCase, + String runId, + NegotiationEvaluationProcessLogger logger) throws IOException { + long startedAt = System.nanoTime(); + NegotiationPerformative performative = NegotiationPerformative.valueOf(testCase.performative()); + NegotiationContext context = contextFor(testCase.contextMode(), performative); + TemplateUri templateUri = templateFor(testCase.template()); + Map request = new LinkedHashMap<>(); + if (testCase.api().startsWith("generate_")) { + request.put("text", testCase.input()); + } else { + request.put("prompt", testCase.input()); + request.put("schema", schemaFor(testCase.api(), testCase.schemaMode())); + } + request.put("template_uri", templateUri == null ? null : templateUri.uri()); + request.put("context", context == null ? null : contextPayload(context)); + Map result = new LinkedHashMap<>(); + result.put("id", testCase.id()); + result.put("description", testCase.description()); + result.put("api", apiMethod(testCase.api())); + result.put("expected_exception", testCase.expectedException()); + result.put("expected_code", testCase.expectedCode()); + result.put("request", request); + try { + Object response = invoke(client, server, testCase, context, templateUri); + result.put("outcome", "unexpected_success"); + result.put("response", responseSummary(response)); + result.put("passed", false); + logger.write(event("abnormal_case", runId, testCase.id(), request, + Map.of("outcome", "unexpected_success"), result)); + } catch (RuntimeException exception) { + Map actualError = errorDetails(exception); + boolean classMatched = testCase.expectedException().equals(actualError.get("class")); + boolean codeMatched = testCase.expectedCode() == null + || testCase.expectedCode().equals(actualError.get("code")); + result.put("actual_error", actualError); + result.put("passed", classMatched && codeMatched); + result.put("outcome", classMatched && codeMatched ? "expected_failure" : "unexpected_failure"); + logger.write(event("abnormal_case", runId, testCase.id(), request, null, result)); + } + result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); + return result; + } + + private static List scriptedCases() { + return List.of( + new ScriptedAbnormalCase("G-PROPOSE-LLM-MALFORMED", "generate_propose", + "请补充接入端口名称和投诉分类。", "not-json", false, + "NegotiationGenerationException", "negotiation_content_extract_failed"), + new ScriptedAbnormalCase("G-ACCEPT-LLM-INFRA", "generate_accept", + "接受协商,端口为 PE1-GZ-ETH-0/0/0.200,投诉分类为专线中断。", "throw", false, + "NegotiationGenerationException", "negotiation_llm_infrastructure_error"), + new ScriptedAbnormalCase("G-REJECT-SLOT-MISSING", "generate_reject", + "拒绝协商,原因是资源系统暂时无法查询。", "{\"conclusion\":\"Reject\",\"items\":null}", false, + "NegotiationGenerationException", "negotiation_slot_missing"), + new ScriptedAbnormalCase("V-PROPOSE-SEMANTIC-REJECTED", "validate_propose", + "## 信息协商\n请根据<所需信息项>补充相关内容。", semanticRejectedPayload(), false, + "NegotiationParamExtractionException", "negotiation_semantic_rejected"), + new ScriptedAbnormalCase("V-ACCEPT-LLM-INFRA", "validate_accept", + "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", "throw", false, + "NegotiationParamExtractionException", "negotiation_llm_infrastructure_error"), + new ScriptedAbnormalCase("V-REJECT-SEMANTIC-REJECTED", "validate_reject", + "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", semanticRejectedPayload(), false, + "NegotiationParamExtractionException", "negotiation_semantic_rejected"), + new ScriptedAbnormalCase("G-PROPOSE-TEMPLATE-NOT-FOUND", "generate_propose", + "请补充接入端口名称和投诉分类。", "unused", true, + "NegotiationGenerationException", "template_not_found"), + new ScriptedAbnormalCase("V-PROPOSE-TEMPLATE-NOT-FOUND", "validate_propose", + "## 信息协商\n请根据<所需信息项>补充相关内容。", "unused", true, + "NegotiationParamExtractionException", "template_not_found")); + } + + private static String semanticRejectedPayload() { + return "{\"semantic_verdict\":false,\"negotiation_type\":\"information\"," + + "\"errors\":[{\"slot_name\":\"section.info_result_content\"," + + "\"code\":\"missing_result_content\",\"message\":\"The information result content is missing.\"}]," + + "\"params\":{}}"; + } + + private static Map runScriptedCase( + ScriptedAbnormalCase testCase, + String runId, + NegotiationEvaluationProcessLogger logger) throws IOException { + long startedAt = System.nanoTime(); + NegotiationPerformative performative = testCase.api().contains("accept") + ? NegotiationPerformative.ACCEPT + : testCase.api().contains("reject") ? NegotiationPerformative.REJECT : NegotiationPerformative.PROPOSE; + NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); + TemplateUri template = testCase.api().contains("propose") + ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI : NegotiationSampleFlow.ENDING_TEMPLATE_URI; + ScriptedLlmClient llm = new ScriptedLlmClient(testCase.payload()); + NegotiationGenerationOrchestratorBuilder builder = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN").llmClient(llm).maxAttempts(1); + if (testCase.missingTemplate()) { + builder.templateLoader(new MissingTemplateLoader()); + } + NegotiationGenerationOrchestrator orchestrator = builder.build(); + Map request = new LinkedHashMap<>(); + if (testCase.api().startsWith("generate_")) { + request.put("text", testCase.input()); + } else { + request.put("prompt", testCase.input()); + request.put("schema", schemaFor(testCase.api(), null)); + } + request.put("context", contextPayload(context)); + request.put("template_uri", template.uri()); + Map result = new LinkedHashMap<>(); + result.put("id", testCase.id()); + result.put("api", scriptedApiMethod(testCase.api())); + result.put("request", request); + result.put("expected_exception", testCase.expectedException()); + result.put("expected_code", testCase.expectedCode()); + try { + Object response = invoke(orchestrator, testCase, context, template); + result.put("response", responseSummary(response)); + result.put("outcome", "unexpected_success"); + result.put("passed", false); + logger.write(event("abnormal_scripted_case", runId, testCase.id(), request, responseSummary(response), result)); + } catch (RuntimeException exception) { + Map actualError = errorDetails(exception); + boolean matched = testCase.expectedException().equals(actualError.get("class")) + && testCase.expectedCode().equals(actualError.get("code")); + result.put("actual_error", actualError); + result.put("outcome", matched ? "expected_failure" : "unexpected_failure"); + result.put("passed", matched); + logger.write(event("abnormal_scripted_case", runId, testCase.id(), request, null, result)); + } + result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); + return result; + } + + private static Object invoke( + NegotiationGenerationOrchestrator orchestrator, + ScriptedAbnormalCase testCase, + NegotiationContext context, + TemplateUri template) { + return switch (testCase.api()) { + case "generate_propose" -> orchestrator.generateProposeFromText(testCase.input(), context, template); + case "generate_accept" -> orchestrator.generateAcceptFromText(testCase.input(), context, template); + case "generate_reject" -> orchestrator.generateRejectFromText(testCase.input(), context, template); + case "validate_propose" -> orchestrator.validateProposePromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.propose(), template); + case "validate_accept" -> orchestrator.validateAcceptPromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.accept(), template); + case "validate_reject" -> orchestrator.validateRejectPromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.reject(), template); + default -> throw new IllegalArgumentException("Unknown scripted abnormal API: " + testCase.api()); + }; + } + + private static Object invoke( + A2ATClient client, + A2ATServer server, + NegotiationAbnormalEvaluationCase testCase, + NegotiationContext context, + TemplateUri templateUri) { + return switch (testCase.api()) { + case "generate_propose" -> client.generateNegotiationProposePromptFromText( + testCase.input(), context, templateUri); + case "validate_propose" -> server.validateProposePromptAndDataFilling( + testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); + case "generate_accept" -> server.generateNegotiationAcceptPromptFromText( + testCase.input(), context, templateUri); + case "validate_accept" -> client.validateAcceptPromptAndDataFilling( + testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); + case "generate_reject" -> server.generateNegotiationRejectPromptFromText( + testCase.input(), context, templateUri); + case "validate_reject" -> client.validateRejectPromptAndDataFilling( + testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); + default -> throw new IllegalArgumentException("Unknown abnormal evaluation API: " + testCase.api()); + }; + } + + private static Map schemaFor(String api, String mode) { + if ("null".equalsIgnoreCase(mode)) { + return null; + } + return switch (api) { + case "validate_propose" -> InformationNegotiationSchemas.propose(); + case "validate_accept" -> InformationNegotiationSchemas.accept(); + case "validate_reject" -> InformationNegotiationSchemas.reject(); + default -> Map.of(); + }; + } + + private static NegotiationContext contextFor(String mode, NegotiationPerformative performative) { + if ("null".equalsIgnoreCase(mode)) { + return null; + } + if ("invalid_id".equalsIgnoreCase(mode)) { + return new NegotiationContext("not-a-uuid", 1, 3, performative); + } + if ("exhausted".equalsIgnoreCase(mode)) { + return new NegotiationContext(UUID.randomUUID().toString(), 4, 3, performative); + } + return new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); + } + + private static TemplateUri templateFor(String template) { + return switch (template == null ? "null" : template.toLowerCase(java.util.Locale.ROOT)) { + case "null" -> null; + case "propose" -> NegotiationSampleFlow.PROPOSE_TEMPLATE_URI; + case "ending" -> NegotiationSampleFlow.ENDING_TEMPLATE_URI; + case "unknown_propose" -> TemplateUri.of( + "Negotiation-T", List.of("information-negotiation", "propose"), "v999"); + case "unknown_ending" -> TemplateUri.of( + "Negotiation-T", List.of("information-negotiation", "accept-reject"), "v999"); + default -> throw new IllegalArgumentException("Unknown abnormal evaluation template: " + template); + }; + } + + private static String apiMethod(String api) { + return switch (api) { + case "generate_propose" -> "A2ATClient.generateNegotiationProposePromptFromText"; + case "validate_propose" -> "A2ATServer.validateProposePromptAndDataFilling"; + case "generate_accept" -> "A2ATServer.generateNegotiationAcceptPromptFromText"; + case "validate_accept" -> "A2ATClient.validateAcceptPromptAndDataFilling"; + case "generate_reject" -> "A2ATServer.generateNegotiationRejectPromptFromText"; + case "validate_reject" -> "A2ATClient.validateRejectPromptAndDataFilling"; + default -> api; + }; + } + + private static Map responseSummary(Object response) { + Map summary = new LinkedHashMap<>(); + if (response instanceof MetadataContent metadata) { + summary.put("type", "MetadataContent"); + summary.put("prompt", metadata.promptText()); + summary.put("template_uri", metadata.templateUri()); + } else if (response instanceof FilledParamData filled) { + summary.put("type", "FilledParamData"); + summary.put("data", filled.data()); + } else { + summary.put("type", response == null ? "null" : response.getClass().getName()); + } + return summary; + } + + private static Map event( + String stage, + String runId, + String caseId, + Map request, + Map response, + Map result) { + Map event = new LinkedHashMap<>(); + event.put("timestamp", Instant.now().toString()); + event.put("run_id", runId); + event.put("stage", stage); + if (result != null && result.get("api") != null) { + event.put("api", result.get("api")); + } + if (caseId != null) { + event.put("case_id", caseId); + } + event.put("request", request); + if (response != null) { + event.put("response", response); + } + if (result != null) { + event.put("result", result); + } + return event; + } + + private static Map errorDetails(RuntimeException exception) { + Map error = new LinkedHashMap<>(); + error.put("class", exception.getClass().getSimpleName()); + error.put("message", exception.getMessage()); + if (exception instanceof A2ATError a2atError) { + error.put("code", a2atError.getCode()); + } + return error; + } + + private static Map contextPayload(NegotiationContext context) { + return Map.of( + "id", context.id(), + "round", context.round(), + "maxRounds", context.maxRounds(), + "performative", context.performative().name()); + } + + private static String scriptedApiMethod(String api) { + return switch (api) { + case "generate_propose" -> "NegotiationGenerationOrchestrator.generateProposeFromText"; + case "generate_accept" -> "NegotiationGenerationOrchestrator.generateAcceptFromText"; + case "generate_reject" -> "NegotiationGenerationOrchestrator.generateRejectFromText"; + case "validate_propose" -> "NegotiationGenerationOrchestrator.validateProposePromptAndDataFilling"; + case "validate_accept" -> "NegotiationGenerationOrchestrator.validateAcceptPromptAndDataFilling"; + case "validate_reject" -> "NegotiationGenerationOrchestrator.validateRejectPromptAndDataFilling"; + default -> api; + }; + } + + private record ScriptedAbnormalCase( + String id, + String api, + String input, + String payload, + boolean missingTemplate, + String expectedException, + String expectedCode) { + } + + private static final class ScriptedLlmClient implements LLMClient { + + private final String payload; + + private ScriptedLlmClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + if ("throw".equals(payload)) { + throw new IllegalStateException("scripted LLM infrastructure failure"); + } + return new LLMResponse(payload, "sample-scripted-model", + Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class MissingTemplateLoader implements NegotiationTemplateLoader { + + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + + @Override + public List loadAll() { + return List.of(); + } + } + + private static String gitRevision() { + try { + Process process = new ProcessBuilder("git", "rev-parse", "HEAD") + .redirectErrorStream(true) + .start(); + String revision; + try (var input = process.getInputStream()) { + revision = new String(input.readAllBytes(), StandardCharsets.UTF_8).trim(); + } + return process.waitFor() == 0 && !revision.isBlank() ? revision : "unknown"; + } catch (IOException exception) { + return "unknown"; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return "unknown"; + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCase.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCase.java new file mode 100644 index 00000000..b47121bf --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCase.java @@ -0,0 +1,12 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import java.util.List; + +/** One business-level malformed-content case executed against a real LLM. */ +public record NegotiationBusinessAbnormalEvaluationCase( + String id, + String api, + String input, + String description, + List expectedCodes) { +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCaseLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCaseLoader.java new file mode 100644 index 00000000..ce13fd72 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCaseLoader.java @@ -0,0 +1,29 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +/** Loads business-level malformed-content cases bundled with the sample. */ +final class NegotiationBusinessAbnormalEvaluationCaseLoader { + + private static final String RESOURCE = + "/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json"; + + private NegotiationBusinessAbnormalEvaluationCaseLoader() { + } + + static List load() { + ObjectMapper mapper = new ObjectMapper(); + try (InputStream input = NegotiationBusinessAbnormalEvaluationCaseLoader.class.getResourceAsStream(RESOURCE)) { + if (input == null) { + throw new IllegalStateException("Business abnormal case resource not found: " + RESOURCE); + } + return mapper.readValue(input, new TypeReference<>() {}); + } catch (IOException exception) { + throw new IllegalStateException("Failed to load business abnormal cases: " + RESOURCE, exception); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java new file mode 100644 index 00000000..ef1d6f7f --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java @@ -0,0 +1,279 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleEnvironment; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.server.A2ATServer; + +/** Runs malformed business-content cases against the configured real LLM. */ +public final class NegotiationBusinessAbnormalEvaluationMain { + + private static final ObjectMapper OBJECT_MAPPER = + new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + private NegotiationBusinessAbnormalEvaluationMain() { + } + + public static void main(String[] args) throws IOException { + Path envPath = args.length > 0 + ? Path.of(args[0]) + : Path.of("a2a-t-sample", "src", "main", "resources", "sample", + "private-line-complaint-negotiation", "qwen.env"); + String suffix = LocalDateTime.now(ZoneId.systemDefault()).format(FILE_TIMESTAMP); + Path reportPath = args.length > 1 + ? Path.of(args[1]) + : Path.of("a2a-t-sample", "target", "negotiation-business-abnormal-report-" + suffix + ".json"); + Path processLogPath = args.length > 2 + ? Path.of(args[2]) + : Path.of("a2a-t-sample", "target", "negotiation-business-abnormal-process-log-" + suffix + ".jsonl"); + + Map environment = NegotiationSampleEnvironment.read(envPath); + requireQwenConfiguration(environment); + List cases = + NegotiationBusinessAbnormalEvaluationCaseLoader.load(); + String runId = UUID.randomUUID().toString(); + List> results = new ArrayList<>(); + try (NegotiationEvaluationProcessLogger logger = + new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, processLogPath)) { + A2ATClient client = new A2ATClient(envPath); + A2ATServer server = new A2ATServer(envPath); + logger.write(runStartedEvent(runId, envPath, cases.size())); + for (NegotiationBusinessAbnormalEvaluationCase testCase : cases) { + results.add(runCase(client, server, testCase, runId, logger)); + } + } + + long passed = results.stream().filter(result -> Boolean.TRUE.equals(result.get("passed"))).count(); + Map report = new LinkedHashMap<>(); + report.put("generated_at", Instant.now().toString()); + report.put("run_id", runId); + report.put("model", environment.get("A2AT_LLM_MODEL")); + report.put("base_url", environment.get("A2AT_LLM_BASE_URL")); + report.put("git_revision", gitRevision()); + report.put("total", results.size()); + report.put("passed", passed); + report.put("failed", results.size() - passed); + report.put("success_rate", results.isEmpty() ? 1.0 : (double) passed / results.size()); + report.put("definition", "业务结构异常用例要求 SDK 识别无法渲染或无法校验的协商报文;这些用例调用真实 LLM,结果可能受模型输出影响。"); + report.put("process_log", processLogPath.toAbsolutePath().toString()); + report.put("cases", results); + Path parent = reportPath.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + OBJECT_MAPPER.writeValue(reportPath.toFile(), report); + System.out.printf("Business abnormal negotiation evaluation complete: %d/%d matched; report=%s; process-log=%s%n", + passed, results.size(), reportPath.toAbsolutePath(), processLogPath.toAbsolutePath()); + } + + private static Map runStartedEvent(String runId, Path envPath, int caseCount) { + Map event = new LinkedHashMap<>(); + event.put("timestamp", Instant.now().toString()); + event.put("event_type", "run_started"); + event.put("run_id", runId); + event.put("env_path", envPath.toAbsolutePath().toString()); + event.put("case_count", caseCount); + event.put("real_llm", true); + return event; + } + + private static Map runCase( + A2ATClient client, + A2ATServer server, + NegotiationBusinessAbnormalEvaluationCase testCase, + String runId, + NegotiationEvaluationProcessLogger logger) throws IOException { + long startedAt = System.nanoTime(); + NegotiationPerformative performative = performativeFor(testCase.api()); + NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); + Map request = requestFor(testCase, context); + Map result = new LinkedHashMap<>(); + result.put("id", testCase.id()); + result.put("description", testCase.description()); + result.put("api", apiMethod(testCase.api())); + result.put("expected_codes", testCase.expectedCodes()); + result.put("request", request); + try { + Object response = invoke(client, server, testCase, context); + result.put("response", responseSummary(response)); + result.put("outcome", "unexpected_success"); + result.put("passed", false); + logger.write(event(runId, testCase, request, responseSummary(response), result, startedAt)); + } catch (RuntimeException exception) { + Map error = errorDetails(exception); + boolean matched = testCase.expectedCodes().stream().anyMatch(code -> code.equals(error.get("code"))); + result.put("actual_error", error); + result.put("outcome", matched ? "expected_business_failure" : "unexpected_failure"); + result.put("passed", matched); + logger.write(event(runId, testCase, request, null, result, startedAt)); + } + result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); + return result; + } + + private static Object invoke( + A2ATClient client, + A2ATServer server, + NegotiationBusinessAbnormalEvaluationCase testCase, + NegotiationContext context) { + return switch (testCase.api()) { + case "generate_propose" -> client.generateNegotiationProposePromptFromText( + testCase.input(), context, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + case "validate_propose" -> server.validateProposePromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.propose(), + NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + case "generate_accept" -> server.generateNegotiationAcceptPromptFromText( + testCase.input(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); + case "validate_accept" -> client.validateAcceptPromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.accept(), + NegotiationSampleFlow.ENDING_TEMPLATE_URI); + case "generate_reject" -> server.generateNegotiationRejectPromptFromText( + testCase.input(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); + case "validate_reject" -> client.validateRejectPromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.reject(), + NegotiationSampleFlow.ENDING_TEMPLATE_URI); + default -> throw new IllegalArgumentException("Unknown business abnormal API: " + testCase.api()); + }; + } + + private static Map requestFor( + NegotiationBusinessAbnormalEvaluationCase testCase, NegotiationContext context) { + Map request = new LinkedHashMap<>(); + if (testCase.api().startsWith("generate_")) { + request.put("text", testCase.input()); + } else { + request.put("prompt", testCase.input()); + request.put("schema", schemaFor(testCase.api())); + } + request.put("context", contextPayload(context)); + request.put("template_uri", testCase.api().contains("propose") + ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri() + : NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()); + return request; + } + + private static Map event( + String runId, + NegotiationBusinessAbnormalEvaluationCase testCase, + Map request, + Map response, + Map result, + long startedAt) { + Map event = new LinkedHashMap<>(); + event.put("timestamp", Instant.now().toString()); + event.put("event_type", "business_abnormal_case"); + event.put("run_id", runId); + event.put("case_id", testCase.id()); + event.put("api", apiMethod(testCase.api())); + event.put("request", request); + if (response != null) { + event.put("response", response); + } + event.put("result", result); + event.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); + return event; + } + + private static NegotiationPerformative performativeFor(String api) { + return api.contains("accept") ? NegotiationPerformative.ACCEPT + : api.contains("reject") ? NegotiationPerformative.REJECT : NegotiationPerformative.PROPOSE; + } + + private static Map schemaFor(String api) { + return switch (api) { + case "validate_propose" -> InformationNegotiationSchemas.propose(); + case "validate_accept" -> InformationNegotiationSchemas.accept(); + case "validate_reject" -> InformationNegotiationSchemas.reject(); + default -> Map.of(); + }; + } + + private static String apiMethod(String api) { + return switch (api) { + case "generate_propose" -> "A2ATClient.generateNegotiationProposePromptFromText"; + case "validate_propose" -> "A2ATServer.validateProposePromptAndDataFilling"; + case "generate_accept" -> "A2ATServer.generateNegotiationAcceptPromptFromText"; + case "validate_accept" -> "A2ATClient.validateAcceptPromptAndDataFilling"; + case "generate_reject" -> "A2ATServer.generateNegotiationRejectPromptFromText"; + case "validate_reject" -> "A2ATClient.validateRejectPromptAndDataFilling"; + default -> api; + }; + } + + private static Map responseSummary(Object response) { + Map summary = new LinkedHashMap<>(); + if (response instanceof MetadataContent metadata) { + summary.put("type", "MetadataContent"); + summary.put("prompt", metadata.promptText()); + summary.put("template_uri", metadata.templateUri()); + summary.put("extension_uri", metadata.extensionUri()); + } else if (response instanceof FilledParamData filled) { + summary.put("type", "FilledParamData"); + summary.put("data", filled.data()); + } else { + summary.put("type", response == null ? "null" : response.getClass().getName()); + } + return summary; + } + + private static Map errorDetails(RuntimeException exception) { + Map error = new LinkedHashMap<>(); + error.put("class", exception.getClass().getSimpleName()); + error.put("message", exception.getMessage()); + if (exception instanceof A2ATError a2atError) { + error.put("code", a2atError.getCode()); + } + return error; + } + + private static Map contextPayload(NegotiationContext context) { + return Map.of("id", context.id(), "round", context.round(), "maxRounds", context.maxRounds(), + "performative", context.performative().name()); + } + + private static void requireQwenConfiguration(Map environment) { + if (!"openai".equals(environment.get("A2AT_LLM_PROVIDER")) + || environment.get("A2AT_LLM_BASE_URL") == null) { + throw new IllegalArgumentException( + "Business abnormal evaluation requires A2AT_LLM_PROVIDER=openai and A2AT_LLM_BASE_URL"); + } + } + + private static String gitRevision() { + try { + Process process = new ProcessBuilder("git", "rev-parse", "HEAD") + .redirectErrorStream(true).start(); + String revision; + try (var input = process.getInputStream()) { + revision = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).trim(); + } + return process.waitFor() == 0 && !revision.isBlank() ? revision : "unknown"; + } catch (IOException exception) { + return "unknown"; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return "unknown"; + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java index adec27fe..88e0b17e 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -66,9 +66,6 @@ public static void main(String[] args) throws IOException { long endingSucceeded = results.stream() .filter(result -> Boolean.TRUE.equals(result.get("ending_succeeded"))) .count(); - long goldenMatched = results.stream() - .filter(result -> Boolean.TRUE.equals(result.get("golden_matched"))) - .count(); Map report = new LinkedHashMap<>(); report.put("generated_at", Instant.now().toString()); report.put("run_id", runId); @@ -82,9 +79,7 @@ public static void main(String[] args) throws IOException { report.put("ending_succeeded", endingSucceeded); report.put("passed", passed); report.put("end_to_end_success_rate", (double) passed / results.size()); - report.put("golden_matched", goldenMatched); - report.put("golden_exact_match_rate", (double) goldenMatched / results.size()); - report.put("note", "A flow passes when all four generation/validation API calls return successfully. Golden exact matching is an auxiliary diagnostic because semantically equivalent natural-language values may differ in wording."); + report.put("note", "A flow passes when all four generation/validation API calls return successfully. Natural-language output is evaluated by whether the four API calls can be chained successfully."); report.put("process_log", processLogPath.toAbsolutePath().toString()); report.put("cases", results); Files.createDirectories(reportPath.toAbsolutePath().getParent()); @@ -122,7 +117,6 @@ private static Map runCase( result.put("ending_validation_succeeded", false); result.put("propose_succeeded", false); result.put("ending_succeeded", false); - result.put("golden_matched", false); List> apiTrace = new ArrayList<>(); result.put("api_trace", apiTrace); try { @@ -134,8 +128,6 @@ private static Map runCase( server, testCase, propose.promptText(), context, runId, processLogger, apiTrace); result.put("propose_validation_succeeded", true); result.put("actual_propose", proposeFilled.data()); - boolean proposeMatched = expectedValuesMatch(testCase.expectedPropose(), proposeFilled.data()); - result.put("propose_expected_matched", proposeMatched); result.put("propose_context_matched", contextMatches(context, proposeFilled.data())); result.put("propose_succeeded", true); @@ -150,11 +142,8 @@ private static Map runCase( client, testCase, ending.promptText(), responseContext, runId, processLogger, apiTrace); result.put("ending_validation_succeeded", true); result.put("actual_ending", endingFilled.data()); - boolean endingMatched = expectedValuesMatch(testCase.expectedEnding(), endingFilled.data()); - result.put("ending_expected_matched", endingMatched); result.put("ending_context_matched", contextMatches(responseContext, endingFilled.data())); result.put("ending_succeeded", true); - result.put("golden_matched", proposeMatched && endingMatched); result.put("passed", true); } catch (RuntimeException exception) { result.put("passed", false); @@ -438,10 +427,6 @@ private static Path defaultProcessLogPath(Path reportPath) { return parent == null ? Path.of(logFilename) : parent.resolve(logFilename); } - private static boolean expectedValuesMatch(Map expected, Map actual) { - return expected.entrySet().stream().allMatch(entry -> entry.getValue().equals(actual.get(entry.getKey()))); - } - private static boolean contextMatches(NegotiationContext context, Map actual) { return context.id().equals(actual.get("id")) && numberEquals(context.round(), actual.get("round")) diff --git a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/abnormal-cases.json b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/abnormal-cases.json new file mode 100644 index 00000000..a37c6991 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/abnormal-cases.json @@ -0,0 +1,427 @@ +[ + { + "id": "G-PROPOSE-BLANK-TEXT", + "api": "generate_propose", + "input": "", + "template": "propose", + "performative": "PROPOSE", + "expected_exception": "NegotiationGenerationException", + "expected_code": "negotiation_invalid_input", + "description": "生成协商发起报文时输入为空" + }, + { + "id": "V-PROPOSE-BLANK-PROMPT", + "api": "validate_propose", + "input": "", + "template": "propose", + "performative": "PROPOSE", + "expected_exception": "IllegalArgumentException", + "expected_code": null, + "description": "校验协商发起报文时输入为空" + }, + { + "id": "G-ACCEPT-WRONG-TEMPLATE", + "api": "generate_accept", + "input": "接受协商,端口为 PE1-GZ-ETH-0/0/0.200,投诉分类为专线中断。", + "template": "propose", + "performative": "ACCEPT", + "expected_exception": "IllegalArgumentException", + "expected_code": null, + "description": "接受生成接口误传 propose 模板" + }, + { + "id": "V-ACCEPT-WRONG-TEMPLATE", + "api": "validate_accept", + "input": "## 信息协商\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", + "template": "propose", + "performative": "ACCEPT", + "expected_exception": "IllegalArgumentException", + "expected_code": null, + "description": "接受校验接口误传 propose 模板" + }, + { + "id": "G-REJECT-WRONG-TEMPLATE", + "api": "generate_reject", + "input": "拒绝协商,原因是资源系统暂时无法查询。", + "template": "propose", + "performative": "REJECT", + "expected_exception": "IllegalArgumentException", + "expected_code": null, + "description": "拒绝生成接口误传 propose 模板" + }, + { + "id": "V-REJECT-WRONG-TEMPLATE", + "api": "validate_reject", + "input": "## 信息协商\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", + "template": "propose", + "performative": "REJECT", + "expected_exception": "IllegalArgumentException", + "expected_code": null, + "description": "拒绝校验接口误传 propose 模板" + }, + { + "id": "G-PROPOSE-NULL-TEXT", + "api": "generate_propose", + "input": null, + "template": "propose", + "performative": "PROPOSE", + "expected_exception": "NegotiationGenerationException", + "expected_code": "negotiation_invalid_input", + "description": "生成协商发起报文时文本为 null" + }, + { + "id": "G-PROPOSE-NULL-CONTEXT", + "api": "generate_propose", + "input": "请补充接入端口名称和投诉分类。", + "template": "propose", + "performative": "PROPOSE", + "context_mode": "null", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "生成协商发起报文时上下文为 null" + }, + { + "id": "G-PROPOSE-NULL-TEMPLATE", + "api": "generate_propose", + "input": "请补充接入端口名称和投诉分类。", + "template": "null", + "performative": "PROPOSE", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "生成协商发起报文时模板 URI 为 null" + }, + { + "id": "G-PROPOSE-UNKNOWN-TEMPLATE", + "api": "generate_propose", + "input": "请补充接入端口名称和投诉分类。", + "template": "unknown_propose", + "performative": "PROPOSE", + "expected_exception": "IllegalArgumentException", + "expected_code": null, + "description": "生成协商发起报文时模板 URI 版本非法" + }, + { + "id": "G-ACCEPT-BLANK-TEXT", + "api": "generate_accept", + "input": "", + "template": "ending", + "performative": "ACCEPT", + "expected_exception": "NegotiationGenerationException", + "expected_code": "negotiation_invalid_input", + "description": "接受生成接口输入为空" + }, + { + "id": "G-ACCEPT-NULL-CONTEXT", + "api": "generate_accept", + "input": "接受协商,端口为 PE1-GZ-ETH-0/0/0.200,投诉分类为专线中断。", + "template": "ending", + "performative": "ACCEPT", + "context_mode": "null", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "接受生成接口上下文为 null" + }, + { + "id": "G-ACCEPT-NULL-TEMPLATE", + "api": "generate_accept", + "input": "接受协商,端口为 PE1-GZ-ETH-0/0/0.200,投诉分类为专线中断。", + "template": "null", + "performative": "ACCEPT", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "接受生成接口模板 URI 为 null" + }, + { + "id": "G-ACCEPT-UNKNOWN-TEMPLATE", + "api": "generate_accept", + "input": "接受协商,端口为 PE1-GZ-ETH-0/0/0.200,投诉分类为专线中断。", + "template": "unknown_ending", + "performative": "ACCEPT", + "expected_exception": "IllegalArgumentException", + "expected_code": null, + "description": "接受生成接口模板 URI 版本非法" + }, + { + "id": "G-REJECT-BLANK-TEXT", + "api": "generate_reject", + "input": "", + "template": "ending", + "performative": "REJECT", + "expected_exception": "NegotiationGenerationException", + "expected_code": "negotiation_invalid_input", + "description": "拒绝生成接口输入为空" + }, + { + "id": "G-REJECT-NULL-CONTEXT", + "api": "generate_reject", + "input": "拒绝协商,原因是资源系统暂时无法查询。", + "template": "ending", + "performative": "REJECT", + "context_mode": "null", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "拒绝生成接口上下文为 null" + }, + { + "id": "G-REJECT-NULL-TEMPLATE", + "api": "generate_reject", + "input": "拒绝协商,原因是资源系统暂时无法查询。", + "template": "null", + "performative": "REJECT", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "拒绝生成接口模板 URI 为 null" + }, + { + "id": "G-REJECT-UNKNOWN-TEMPLATE", + "api": "generate_reject", + "input": "拒绝协商,原因是资源系统暂时无法查询。", + "template": "unknown_ending", + "performative": "REJECT", + "expected_exception": "IllegalArgumentException", + "expected_code": null, + "description": "拒绝生成接口模板 URI 版本非法" + }, + { + "id": "V-PROPOSE-NULL-PROMPT", + "api": "validate_propose", + "input": null, + "template": "propose", + "performative": "PROPOSE", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "校验协商发起报文时 prompt 为 null" + }, + { + "id": "V-PROPOSE-NULL-CONTEXT", + "api": "validate_propose", + "input": "## 信息协商\n请根据<所需信息项>补充相关内容。", + "template": "propose", + "performative": "PROPOSE", + "context_mode": "null", + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation_invalid_input", + "description": "校验协商发起报文时上下文为 null" + }, + { + "id": "V-PROPOSE-NULL-SCHEMA", + "api": "validate_propose", + "input": "## 信息协商\n请根据<所需信息项>补充相关内容。", + "template": "propose", + "performative": "PROPOSE", + "schema_mode": "null", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "校验协商发起报文时业务 schema 为 null" + }, + { + "id": "V-PROPOSE-NULL-TEMPLATE", + "api": "validate_propose", + "input": "## 信息协商\n请根据<所需信息项>补充相关内容。", + "template": "null", + "performative": "PROPOSE", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "校验协商发起报文时模板 URI 为 null" + }, + { + "id": "V-PROPOSE-UNKNOWN-TEMPLATE", + "api": "validate_propose", + "input": "## 信息协商\n请根据<所需信息项>补充相关内容。", + "template": "unknown_propose", + "performative": "PROPOSE", + "expected_exception": "IllegalArgumentException", + "expected_code": null, + "description": "校验协商发起报文时模板 URI 版本非法" + }, + { + "id": "V-PROPOSE-INVALID-ID", + "api": "validate_propose", + "input": "## 信息协商\n请根据<所需信息项>补充相关内容。", + "template": "propose", + "performative": "PROPOSE", + "context_mode": "invalid_id", + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation_rule_violation", + "description": "校验协商发起报文时上下文 id 不是 UUID" + }, + { + "id": "V-PROPOSE-EXHAUSTED-ROUND", + "api": "validate_propose", + "input": "## 信息协商\n请根据<所需信息项>补充相关内容。", + "template": "propose", + "performative": "PROPOSE", + "context_mode": "exhausted", + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation_rule_violation", + "description": "校验协商发起报文时 round 超过 maxRounds" + }, + { + "id": "V-ACCEPT-BLANK-PROMPT", + "api": "validate_accept", + "input": "", + "template": "ending", + "performative": "ACCEPT", + "expected_exception": "IllegalArgumentException", + "expected_code": null, + "description": "接受校验接口 prompt 为空" + }, + { + "id": "V-ACCEPT-NULL-PROMPT", + "api": "validate_accept", + "input": null, + "template": "ending", + "performative": "ACCEPT", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "接受校验接口 prompt 为 null" + }, + { + "id": "V-ACCEPT-NULL-CONTEXT", + "api": "validate_accept", + "input": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", + "template": "ending", + "performative": "ACCEPT", + "context_mode": "null", + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation_invalid_input", + "description": "接受校验接口上下文为 null" + }, + { + "id": "V-ACCEPT-NULL-SCHEMA", + "api": "validate_accept", + "input": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", + "template": "ending", + "performative": "ACCEPT", + "schema_mode": "null", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "接受校验接口业务 schema 为 null" + }, + { + "id": "V-ACCEPT-NULL-TEMPLATE", + "api": "validate_accept", + "input": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", + "template": "null", + "performative": "ACCEPT", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "接受校验接口模板 URI 为 null" + }, + { + "id": "V-ACCEPT-UNKNOWN-TEMPLATE", + "api": "validate_accept", + "input": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", + "template": "unknown_ending", + "performative": "ACCEPT", + "expected_exception": "IllegalArgumentException", + "expected_code": null, + "description": "接受校验接口模板 URI 版本非法" + }, + { + "id": "V-ACCEPT-INVALID-ID", + "api": "validate_accept", + "input": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", + "template": "ending", + "performative": "ACCEPT", + "context_mode": "invalid_id", + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation_rule_violation", + "description": "接受校验接口上下文 id 不是 UUID" + }, + { + "id": "V-ACCEPT-EXHAUSTED-ROUND", + "api": "validate_accept", + "input": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", + "template": "ending", + "performative": "ACCEPT", + "context_mode": "exhausted", + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation_rule_violation", + "description": "接受校验接口 round 超过 maxRounds" + }, + { + "id": "V-REJECT-BLANK-PROMPT", + "api": "validate_reject", + "input": "", + "template": "ending", + "performative": "REJECT", + "expected_exception": "IllegalArgumentException", + "expected_code": null, + "description": "拒绝校验接口 prompt 为空" + }, + { + "id": "V-REJECT-NULL-PROMPT", + "api": "validate_reject", + "input": null, + "template": "ending", + "performative": "REJECT", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "拒绝校验接口 prompt 为 null" + }, + { + "id": "V-REJECT-NULL-CONTEXT", + "api": "validate_reject", + "input": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", + "template": "ending", + "performative": "REJECT", + "context_mode": "null", + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation_invalid_input", + "description": "拒绝校验接口上下文为 null" + }, + { + "id": "V-REJECT-NULL-SCHEMA", + "api": "validate_reject", + "input": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", + "template": "ending", + "performative": "REJECT", + "schema_mode": "null", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "拒绝校验接口业务 schema 为 null" + }, + { + "id": "V-REJECT-NULL-TEMPLATE", + "api": "validate_reject", + "input": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", + "template": "null", + "performative": "REJECT", + "expected_exception": "NullPointerException", + "expected_code": null, + "description": "拒绝校验接口模板 URI 为 null" + }, + { + "id": "V-REJECT-UNKNOWN-TEMPLATE", + "api": "validate_reject", + "input": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", + "template": "unknown_ending", + "performative": "REJECT", + "expected_exception": "IllegalArgumentException", + "expected_code": null, + "description": "拒绝校验接口模板 URI 版本非法" + }, + { + "id": "V-REJECT-INVALID-ID", + "api": "validate_reject", + "input": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", + "template": "ending", + "performative": "REJECT", + "context_mode": "invalid_id", + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation_rule_violation", + "description": "拒绝校验接口上下文 id 不是 UUID" + }, + { + "id": "V-REJECT-EXHAUSTED-ROUND", + "api": "validate_reject", + "input": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", + "template": "ending", + "performative": "REJECT", + "context_mode": "exhausted", + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation_rule_violation", + "description": "拒绝校验接口 round 超过 maxRounds" + } +] diff --git a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json new file mode 100644 index 00000000..6249be96 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json @@ -0,0 +1,86 @@ +[ + { + "id": "B-G-PROPOSE-NO-ITEM", + "api": "generate_propose", + "input": "当前只需要继续处理这条专线投诉,请直接给出处理建议,不需要列出待补充信息。", + "description": "发起协商文本没有明确的待补充信息项", + "expectedCodes": ["negotiation_slot_missing", "negotiation_content_extract_failed"] + }, + { + "id": "B-G-PROPOSE-FREEFORM", + "api": "generate_propose", + "input": "请用一段自然语言概述对端需要提供的资料,不要使用信息项列表或逐项结构。", + "description": "发起协商文本明确要求非模板列表结构", + "expectedCodes": ["negotiation_slot_missing", "negotiation_content_extract_failed"] + }, + { + "id": "B-G-ACCEPT-NO-RESULT", + "api": "generate_accept", + "input": "接受本次协商,后续再补充具体信息。", + "description": "接受文本没有提供任何结果内容", + "expectedCodes": ["negotiation_slot_missing", "negotiation_content_extract_failed"] + }, + { + "id": "B-G-ACCEPT-VAGUE-RESULT", + "api": "generate_accept", + "input": "同意,信息按之前说的处理即可。", + "description": "接受文本只有结论,没有可渲染的逐项结果", + "expectedCodes": ["negotiation_slot_missing", "negotiation_content_extract_failed"] + }, + { + "id": "B-G-REJECT-NO-REASON", + "api": "generate_reject", + "input": "拒绝本次协商。", + "description": "拒绝文本没有说明拒绝原因或结果项", + "expectedCodes": ["negotiation_slot_missing", "negotiation_content_extract_failed"] + }, + { + "id": "B-G-REJECT-FREEFORM", + "api": "generate_reject", + "input": "这次不方便提供资料,请用一段话说明即可,不需要逐项列出拒绝内容。", + "description": "拒绝文本要求非逐项结果结构", + "expectedCodes": ["negotiation_slot_missing", "negotiation_content_extract_failed"] + }, + { + "id": "B-V-PROPOSE-MISSING-SECTION", + "api": "validate_propose", + "input": "## 信息协商\n请继续处理当前投诉。", + "description": "发起报文缺少所需信息项段落", + "expectedCodes": ["negotiation_rule_violation", "negotiation_semantic_rejected"] + }, + { + "id": "B-V-PROPOSE-FREEFORM-SECTION", + "api": "validate_propose", + "input": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n请提供相关资料即可。", + "description": "发起报文的所需信息项不是逐项列表", + "expectedCodes": ["negotiation_rule_violation", "negotiation_semantic_rejected"] + }, + { + "id": "B-V-ACCEPT-MISSING-RESULT", + "api": "validate_accept", + "input": "## 信息协商结果\nAccept", + "description": "接受报文缺少结果内容段落", + "expectedCodes": ["negotiation_rule_violation", "negotiation_semantic_rejected"] + }, + { + "id": "B-V-ACCEPT-FREEFORM-RESULT", + "api": "validate_accept", + "input": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n相关信息保持不变。", + "description": "接受结果内容不是逐项信息", + "expectedCodes": ["negotiation_rule_violation", "negotiation_semantic_rejected"] + }, + { + "id": "B-V-REJECT-MISSING-RESULT", + "api": "validate_reject", + "input": "## 信息协商结果\nReject", + "description": "拒绝报文缺少结果内容段落", + "expectedCodes": ["negotiation_rule_violation", "negotiation_semantic_rejected"] + }, + { + "id": "B-V-REJECT-FREEFORM-RESULT", + "api": "validate_reject", + "input": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n由于当前情况不允许,暂时无法提供。", + "description": "拒绝结果内容没有按请求信息项逐项说明", + "expectedCodes": ["negotiation_rule_violation", "negotiation_semantic_rejected"] + } +] From eb31f1482f4a8dd7b832466925e12570e62eeec0 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Thu, 27 Aug 2026 20:32:14 +0800 Subject: [PATCH 224/295] test(sample): rebuild propose cases as meaning-plus-sample expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the propose cases with the request semantics of a propose message: items carry the field expectation as a meaning-plus-sample description ("接入端口名称,如P781-..."), per the propose template's own requirement (name + meaning/format/sample) - not the field's real value, which read as "asking for the answer while carrying it". Accept keeps real values, reject keeps field-plus-reason; all three message types now carry exactly what the protocol transports. The propose extraction assertions are sharpened against the description-vs-value confusion: the extracted expectation must keep the meaning head (a sample-only extraction would read as if the field were already supplied) and must stay within the stated expectation (no hallucinated content). Depends on the propose-branch extraction contract in the stacked SDK change. Signed-off-by: ZhouJie6 --- .../eval/NegotiationFromDataApiEvalApp.java | 29 +++++++++++++- .../negotiation/eval/fromdata-api-suite.json | 40 +++++++++---------- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java index 1a663762..2770358c 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java @@ -275,8 +275,22 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), check(checks, "validation passed", true); for (Map.Entry entry : itemsInput.entrySet()) { Object value = extracted.get(entry.getKey()); - check(checks, "extracted param matches input: " + entry.getKey(), - valueMatches(value, String.valueOf(entry.getValue()))); + if ("propose".equals(api)) { + // expectation contract: the extracted value must keep the meaning head of the stated + // expectation (a sample-only extraction would read as if the field were already supplied - + // the description-vs-value confusion) and must stay within the stated expectation (no + // hallucinated content beyond the message) + String stated = String.valueOf(entry.getValue()); + String meaningHead = meaningHead(stated); + check(checks, "extracted expectation keeps the meaning part: " + entry.getKey(), + value != null + && normalize(String.valueOf(value)).contains(normalize(meaningHead))); + check(checks, "extracted expectation stays within the stated expectation: " + entry.getKey(), + value != null && normalize(stated).contains(normalize(String.valueOf(value)))); + } else { + check(checks, "extracted param matches input: " + entry.getKey(), + valueMatches(value, String.valueOf(entry.getValue()))); + } } } catch (RuntimeException error) { Map validation = new LinkedHashMap<>(); @@ -329,6 +343,17 @@ private static boolean valueMatches(Object extracted, String input) { && (extractedText.contains(inputText) || inputText.contains(extractedText)); } + /** + * The meaning head of a propose expectation value: the description before the first sample or enum marker + * (for example "接入端口名称" out of "接入端口名称,如P781-..."). A meaning-plus-sample expectation carries its + * semantics in the head, so an extraction that dropped the head would read as a supplied value - the exact + * description-vs-value confusion the propose contract forbids. + */ + private static String meaningHead(String expectation) { + int index = expectation.indexOf(','); + return index > 0 ? expectation.substring(0, index) : expectation; + } + private static Map errorJson(RuntimeException error) { Map failure = new LinkedHashMap<>(); failure.put("type", error.getClass().getSimpleName()); diff --git a/a2a-t-sample/src/main/resources/sample/negotiation/eval/fromdata-api-suite.json b/a2a-t-sample/src/main/resources/sample/negotiation/eval/fromdata-api-suite.json index 4ce0d18c..e355b841 100644 --- a/a2a-t-sample/src/main/resources/sample/negotiation/eval/fromdata-api-suite.json +++ b/a2a-t-sample/src/main/resources/sample/negotiation/eval/fromdata-api-suite.json @@ -1,6 +1,6 @@ { "suite": "negotiation-fromdata-api-eval", - "description": "Focused capability verification of the six Negotiation-T fromData interfaces: generateNegotiationProposePromptFromData, generateNegotiationAcceptPromptFromData, generateNegotiationRejectPromptFromData and their validate-and-extract counterparts. Inputs are traditional structured data - every item is one key (the slot name from the private-line-complaint Task-T template) with a value per the message type: propose and accept carry REAL field values (a live port name, an actual category value, a real event id), reject carries the unprovidable field name with its reason plus an explicit \"无法提供\" statement (the accept-reject template's own requirement: name + reason of non-provision). No natural-language passages, no format-spec placeholders, no template-error scenarios. The case matrix is built from the Task-T field model (required: 任务对象/投诉分类/OSS侧事件流水号; optional: 问题发生时间/投诉详情): propose carries every required/optional slot combination with real values (all required, each single required, dual required, required+optional, optional-only, empty boundary), accept supplies the filled values for the same combinations, reject carries the unprovidable fields with reasons. Generation is deterministic rendering (no LLM call); the validate-and-extract step asserts the extracted parameters carry back exactly the input key-value pairs.", + "description": "Focused capability verification of the six Negotiation-T fromData interfaces: generateNegotiationProposePromptFromData, generateNegotiationAcceptPromptFromData, generateNegotiationRejectPromptFromData and their validate-and-extract counterparts. Inputs are traditional structured data - every item is one key (the slot name from the private-line-complaint Task-T template) with a value per the message type: propose carries the field expectation as a meaning-plus-sample description (the propose template's own requirement: name + meaning/format/sample), accept carries REAL field values (a live port name, an actual category value, a real event id), reject carries the unprovidable field name with its reason plus an explicit \"无法提供\" statement (the accept-reject template's own requirement: name + reason of non-provision). No natural-language passages, no format-spec placeholders, no template-error scenarios. The case matrix is built from the Task-T field model (required: 任务对象/投诉分类/OSS侧事件流水号; optional: 问题发生时间/投诉详情): propose carries every required/optional slot combination with real values (all required, each single required, dual required, required+optional, optional-only, empty boundary), accept supplies the filled values for the same combinations, reject carries the unprovidable fields with reasons. Generation is deterministic rendering (no LLM call); the validate-and-extract step asserts the extracted parameters carry back exactly the input key-value pairs.", "scenario": "private-line-complaint", "fields": { "required": [ @@ -17,12 +17,12 @@ { "id": "FD-P01", "api": "propose", - "intent": "propose:携带全部三个必选槽位(任务对象+投诉分类+OSS流水号)的真实值,报文应逐字携带全部槽位名与值及关系句", + "intent": "propose:请求补充全部三个必选槽位(任务对象+投诉分类+OSS流水号),items 为含义+样例说明,报文应逐字携带全部说明与关系句", "input": { "items": { - "任务对象": "P781-珠江新城-PTN7900-23-TPA1EG24-17", - "投诉分类": "专线中断", - "OSS侧事件流水号": "event-id-20260602-08841" + "任务对象": "接入端口名称,如P781-珠江新城-PTN7900-23-TPA1EG24-17", + "投诉分类": "投诉场景分类,取值专线中断或专线质差", + "OSS侧事件流水号": "OSS侧事件流水号,如event-id-20260602-08841" }, "relationship": "以上参数均为必选,缺少无法启动诊断" }, @@ -34,10 +34,10 @@ { "id": "FD-P02", "api": "propose", - "intent": "propose:携带单必选槽位(任务对象)的真实值", + "intent": "propose:请求补充单必选槽位(任务对象),item 为含义+样例说明", "input": { "items": { - "任务对象": "P781-珠江新城-PTN7900-23-TPA1EG24-17" + "任务对象": "接入端口名称,如P781-珠江新城-PTN7900-23-TPA1EG24-17" }, "relationship": "以上参数均为必选,缺少无法启动诊断" }, @@ -49,10 +49,10 @@ { "id": "FD-P03", "api": "propose", - "intent": "propose:携带单必选槽位(投诉分类)的真实值", + "intent": "propose:请求补充单必选槽位(投诉分类),item 为含义+取值说明", "input": { "items": { - "投诉分类": "专线中断" + "投诉分类": "投诉场景分类,取值专线中断或专线质差" }, "relationship": "以上参数均为必选,缺少无法启动诊断" }, @@ -64,10 +64,10 @@ { "id": "FD-P04", "api": "propose", - "intent": "propose:携带单必选槽位(OSS侧事件流水号)的真实值", + "intent": "propose:请求补充单必选槽位(OSS侧事件流水号),item 为含义+样例说明", "input": { "items": { - "OSS侧事件流水号": "event-id-20260602-08841" + "OSS侧事件流水号": "OSS侧事件流水号,如event-id-20260602-08841" }, "relationship": "以上参数均为必选,缺少无法启动诊断" }, @@ -79,11 +79,11 @@ { "id": "FD-P05", "api": "propose", - "intent": "propose:携带双必选槽位(任务对象+投诉分类)的真实值", + "intent": "propose:请求补充双必选槽位(任务对象+投诉分类)", "input": { "items": { - "任务对象": "P781-珠江新城-PTN7900-23-TPA1EG24-17", - "投诉分类": "专线中断" + "任务对象": "接入端口名称,如P781-珠江新城-PTN7900-23-TPA1EG24-17", + "投诉分类": "投诉场景分类,取值专线中断或专线质差" }, "relationship": "以上参数均为必选,缺少无法启动诊断" }, @@ -95,11 +95,11 @@ { "id": "FD-P06", "api": "propose", - "intent": "propose:携带必选+可选槽位组合(OSS流水号+问题发生时间)的真实值", + "intent": "propose:请求补充必选+可选槽位组合(OSS流水号+问题发生时间)", "input": { "items": { - "OSS侧事件流水号": "event-id-20260602-08841", - "问题发生时间": "2026-06-02T14:30:00Z" + "OSS侧事件流水号": "OSS侧事件流水号,如event-id-20260602-08841", + "问题发生时间": "故障发生时间,如2026-06-02T14:30:00Z" }, "relationship": "以上参数为诊断所需,缺少影响定位效率" }, @@ -111,11 +111,11 @@ { "id": "FD-P07", "api": "propose", - "intent": "propose:携带仅可选槽位组合(问题发生时间+投诉详情)的真实值,边界组合", + "intent": "propose:请求补充仅可选槽位组合(问题发生时间+投诉详情),边界组合", "input": { "items": { - "问题发生时间": "2026-06-02T14:30:00Z", - "投诉详情": "深圳访问广州响应延迟骤升,交易接口频繁报连接超时" + "问题发生时间": "故障发生时间,如2026-06-02T14:30:00Z", + "投诉详情": "故障现象文字描述" }, "relationship": "以上参数为可选补充信息" }, From 9a50438040071ed9821dc63afeff3c8ffa03a5bf Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Thu, 27 Aug 2026 22:40:05 +0800 Subject: [PATCH 225/295] test(sample): mirror the SDK item-array schema and the three-form task object Review feedback on the propose cases: - the task-object expectation now presents the three selectable forms the Task-T template defines (service name / service identifier / access port name, each with its sample) instead of the access-port form alone; the accept cases rotate the filled task object across the three forms so every legal shape round-trips - the extraction schema mirrors the SDK's own model of the message content (NegotiationJsonSchemaBuilder) instead of an invented flat field map: propose extracts {items: [{name, value}], relationship}, endings extract {conclusion, items: [{name, value}]} - the validation-side contract is now aligned end to end with the generation-side model. The assertions walk the items array: every input slot appears as an item with the matching value (expectation guards for propose, value fidelity for accept, reasons for reject), plus the relationship and conclusion checks. Verified on glm-5.2: 14/14 on the first and a confirmation rerun; one intermittent run failed a single case on an LLM infrastructure error (model returned markdown-fenced JSON) - transient, not a case defect. Signed-off-by: ZhouJie6 --- .../eval/NegotiationFromDataApiEvalApp.java | 133 ++++++++++++++---- .../negotiation/eval/fromdata-api-suite.json | 12 +- 2 files changed, 111 insertions(+), 34 deletions(-) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java index 2770358c..08c4d14e 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java @@ -243,21 +243,24 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), // -- step 2: validation and parameter extraction of the generated message -- if (prompt != null && !itemsInput.isEmpty()) { + // the caller schema mirrors the SDK's own model of the message content (NegotiationJsonSchemaBuilder): + // propose is {items: [{name, value}], relationship}, endings are {conclusion, items: [{name, value}]} + Map extractionSchema = extractionSchema(api, itemsInput); Map validateInput = new LinkedHashMap<>(); validateInput.put("prompt", prompt); validateInput.put("context", contextJson(context)); - validateInput.put("schema", itemsSchema(itemsInput)); + validateInput.put("schema", extractionSchema); validateInput.put("template_uri", template.uri()); Map validateStep = step("2. validate + extract " + api + " (fromData)", validateRole); api(validateStep, apiCall(validateMethod, validateInput)); try { FilledParamData params = switch (api) { case "propose" -> new A2ATClient(envPath).validateProposePromptAndDataFilling( - prompt, context, itemsSchema(itemsInput), template); + prompt, context, extractionSchema, template); case "accept" -> new A2ATServer(envPath).validateAcceptPromptAndDataFilling( - prompt, context, itemsSchema(itemsInput), template); + prompt, context, extractionSchema, template); case "reject" -> new A2ATServer(envPath).validateRejectPromptAndDataFilling( - prompt, context, itemsSchema(itemsInput), template); + prompt, context, extractionSchema, template); default -> throw new IllegalArgumentException("Unknown api: " + api); }; Map extracted = @@ -273,25 +276,7 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), validation.put("extracted_params", extractedJson); validateStep.put("validation", validation); check(checks, "validation passed", true); - for (Map.Entry entry : itemsInput.entrySet()) { - Object value = extracted.get(entry.getKey()); - if ("propose".equals(api)) { - // expectation contract: the extracted value must keep the meaning head of the stated - // expectation (a sample-only extraction would read as if the field were already supplied - - // the description-vs-value confusion) and must stay within the stated expectation (no - // hallucinated content beyond the message) - String stated = String.valueOf(entry.getValue()); - String meaningHead = meaningHead(stated); - check(checks, "extracted expectation keeps the meaning part: " + entry.getKey(), - value != null - && normalize(String.valueOf(value)).contains(normalize(meaningHead))); - check(checks, "extracted expectation stays within the stated expectation: " + entry.getKey(), - value != null && normalize(stated).contains(normalize(String.valueOf(value)))); - } else { - check(checks, "extracted param matches input: " + entry.getKey(), - valueMatches(value, String.valueOf(entry.getValue()))); - } - } + checkExtractedItems(checks, api, extractedJson, itemsInput, input); } catch (RuntimeException error) { Map validation = new LinkedHashMap<>(); validation.put("outcome", "rejected"); @@ -319,19 +304,111 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), return record; } - /** Parameter schema for the extraction: one string property per input item, all items required. */ - private static Map itemsSchema(Map items) { + /** + * Extraction schema mirroring the SDK's own model of the message content ({@code NegotiationJsonSchemaBuilder}): + * propose is {@code {items: [{name, value}], relationship}} (items a non-empty array), endings are + * {@code {conclusion: "Accept"|"Reject", items: [{name, value}]}}. The validation-side extraction contract is + * therefore aligned end to end with the generation-side model. + */ + private static Map extractionSchema(String api, Map items) { Map properties = new LinkedHashMap<>(); - for (String name : items.keySet()) { - properties.put(name, Map.of("type", "string")); + if (!"propose".equals(api)) { + properties.put("conclusion", Map.of("type", "string", "enum", List.of("Accept", "Reject"))); + } + properties.put("items", nonEmptyItemArraySchema()); + if ("propose".equals(api)) { + properties.put("relationship", nullableStringSchema()); + } + List required = new ArrayList<>(); + required.add("items"); + if (!"propose".equals(api)) { + required.add("conclusion"); } Map schema = new LinkedHashMap<>(); schema.put("type", "object"); schema.put("properties", properties); - schema.put("required", new ArrayList<>(items.keySet())); + schema.put("required", required); return schema; } + private static Map nonEmptyItemArraySchema() { + Map itemProperties = new LinkedHashMap<>(); + itemProperties.put("name", Map.of("type", "string")); + itemProperties.put("value", nullableStringSchema()); + Map itemSchema = new LinkedHashMap<>(); + itemSchema.put("type", "object"); + itemSchema.put("properties", itemProperties); + itemSchema.put("required", List.of("name", "value")); + Map arraySchema = new LinkedHashMap<>(); + arraySchema.put("type", "array"); + arraySchema.put("items", itemSchema); + arraySchema.put("minItems", 1); + return arraySchema; + } + + private static Map nullableStringSchema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", "string"); + schema.put("nullable", true); + return schema; + } + + /** + * Asserts the extracted items list against the input items: every input slot appears as an item with the matching + * value. For propose the value is the stated expectation (the meaning-head and no-hallucination guards apply); + * for endings the value is the real content or the reason of non-provision. + */ + private static void checkExtractedItems( + List> checks, + String api, + Map extractedJson, + Map itemsInput, + Map input) { + List> extractedItems = asMapList(extractedJson.get("items")); + check(checks, "extracted items list matches the input size", + extractedItems.size() == itemsInput.size()); + for (Map.Entry entry : itemsInput.entrySet()) { + Map extractedItem = itemByName(extractedItems, entry.getKey()); + check(checks, "extracted items contain: " + entry.getKey(), extractedItem != null); + if (extractedItem == null) { + continue; + } + Object value = extractedItem.get("value"); + if ("propose".equals(api)) { + // expectation contract: the extracted value must keep the meaning head of the stated expectation (a + // sample-only extraction would read as if the field were already supplied) and stay within the + // stated expectation (no hallucinated content beyond the message) + String stated = String.valueOf(entry.getValue()); + check(checks, "extracted expectation keeps the meaning part: " + entry.getKey(), + value != null && normalize(String.valueOf(value)).contains(normalize(meaningHead(stated)))); + check(checks, "extracted expectation stays within the stated expectation: " + entry.getKey(), + value != null && normalize(stated).contains(normalize(String.valueOf(value)))); + } else { + check(checks, "extracted item value matches input: " + entry.getKey(), + valueMatches(value, String.valueOf(entry.getValue()))); + } + } + if ("propose".equals(api) && input.containsKey("relationship")) { + String relationship = String.valueOf(input.get("relationship")); + check(checks, "extracted relationship matches input", + valueMatches(extractedJson.get("relationship"), relationship)); + } + if (!"propose".equals(api)) { + String expectedConclusion = "accept".equals(api) ? "Accept" : "Reject"; + check(checks, "extracted conclusion matches the api", + expectedConclusion.equals(extractedJson.get("conclusion"))); + } + } + + private static Map itemByName(List> items, String name) { + for (Map item : items) { + if (name.equals(item.get("name"))) { + return item; + } + } + return null; + } + /** Bidirectional whitespace-free containment: the extracted value may carry the field label or the bare value. */ private static boolean valueMatches(Object extracted, String input) { if (extracted == null) { diff --git a/a2a-t-sample/src/main/resources/sample/negotiation/eval/fromdata-api-suite.json b/a2a-t-sample/src/main/resources/sample/negotiation/eval/fromdata-api-suite.json index e355b841..fb3f09f3 100644 --- a/a2a-t-sample/src/main/resources/sample/negotiation/eval/fromdata-api-suite.json +++ b/a2a-t-sample/src/main/resources/sample/negotiation/eval/fromdata-api-suite.json @@ -20,7 +20,7 @@ "intent": "propose:请求补充全部三个必选槽位(任务对象+投诉分类+OSS流水号),items 为含义+样例说明,报文应逐字携带全部说明与关系句", "input": { "items": { - "任务对象": "接入端口名称,如P781-珠江新城-PTN7900-23-TPA1EG24-17", + "任务对象": "专线业务对象标识,三选一:专线业务名称(如SZ_汇聚专线业务_100M_SPN)、专线业务标识(如5571d707-5aad-11ea-8629-286ed488cff3)、接入端口名称(如P781-珠江新城-PTN7900-23-TPA1EG24-17)", "投诉分类": "投诉场景分类,取值专线中断或专线质差", "OSS侧事件流水号": "OSS侧事件流水号,如event-id-20260602-08841" }, @@ -37,7 +37,7 @@ "intent": "propose:请求补充单必选槽位(任务对象),item 为含义+样例说明", "input": { "items": { - "任务对象": "接入端口名称,如P781-珠江新城-PTN7900-23-TPA1EG24-17" + "任务对象": "专线业务对象标识,三选一:专线业务名称(如SZ_汇聚专线业务_100M_SPN)、专线业务标识(如5571d707-5aad-11ea-8629-286ed488cff3)、接入端口名称(如P781-珠江新城-PTN7900-23-TPA1EG24-17)" }, "relationship": "以上参数均为必选,缺少无法启动诊断" }, @@ -82,7 +82,7 @@ "intent": "propose:请求补充双必选槽位(任务对象+投诉分类)", "input": { "items": { - "任务对象": "接入端口名称,如P781-珠江新城-PTN7900-23-TPA1EG24-17", + "任务对象": "专线业务对象标识,三选一:专线业务名称(如SZ_汇聚专线业务_100M_SPN)、专线业务标识(如5571d707-5aad-11ea-8629-286ed488cff3)、接入端口名称(如P781-珠江新城-PTN7900-23-TPA1EG24-17)", "投诉分类": "投诉场景分类,取值专线中断或专线质差" }, "relationship": "以上参数均为必选,缺少无法启动诊断" @@ -158,10 +158,10 @@ { "id": "FD-A02", "api": "accept", - "intent": "accept:补齐单必选字段(任务对象=端口名实值)", + "intent": "accept:补齐单必选字段(任务对象=专线业务标识形态)", "input": { "items": { - "任务对象": "P781-珠江新城-PTN7900-23-TPA1EG24-17" + "任务对象": "5571d707-5aad-11ea-8629-286ed488cff3" } }, "expect": { @@ -178,7 +178,7 @@ "intent": "accept:补齐全部字段(必选+可选共 5 字段)", "input": { "items": { - "任务对象": "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "任务对象": "SZ_汇聚专线业务_100M_SPN", "投诉分类": "专线中断", "OSS侧事件流水号": "event-id-20260602-08841", "问题发生时间": "2026-06-02T14:30:00Z", From a13d3712c187283859f8b68bb36a55b94f242075 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Fri, 28 Aug 2026 17:54:11 +0800 Subject: [PATCH 226/295] fix(sample): omit the relationship line for single-field negotiation proposes A single requested field has no inter-field relationship, so the rendered propose must not carry the canned relationship sentence: the eval app and the server executor now pass a null relationship when only one item is missing, and the phrasing template states the AND relation explicitly. Signed-off-by: ZhouJie6 --- .../negotiation/eval/NegotiationEvalApp.java | 16 ++++++++++------ .../server/NegotiationAgentExecutor.java | 5 ++++- .../sample/negotiation/eval/eval-suite.json | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java index 05394b62..1843e89a 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java @@ -328,6 +328,7 @@ private static Map runCase(Path envPath, Map sui for (String slot : actualMissing) { missingItems.add(new NegotiationItem(slot, missingHint(phrasing, properties, slot))); } + String proposeRelationship = missingItems.size() > 1 ? phrasing.get("propose_relationship") : null; NegotiationContext proposeContext = new NegotiationContext( UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.PROPOSE); String proposeInputText = itemsToProposeText(missingItems, phrasing); @@ -336,10 +337,13 @@ private static Map runCase(Path envPath, Map sui proposeGenerationInput.put("text", proposeInputText); proposeGenerationInput.put("context", contextJson(proposeContext)); } else { - proposeGenerationInput.put("data", Map.of( - "context", contextJson(proposeContext), - "items", itemsJson(missingItems), - "relationship", String.valueOf(phrasing.get("propose_relationship")))); + Map proposeData = new LinkedHashMap<>(); + proposeData.put("context", contextJson(proposeContext)); + proposeData.put("items", itemsJson(missingItems)); + if (proposeRelationship != null) { + proposeData.put("relationship", proposeRelationship); + } + proposeGenerationInput.put("data", proposeData); } proposeGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()); String proposeGenerationApi = negotiationFromText @@ -354,7 +358,7 @@ private static Map runCase(Path envPath, Map sui : server.generateNegotiationProposePromptFromData( new NegotiationProposeData( proposeContext, - new InformationProposeContent(missingItems, phrasing.get("propose_relationship"))), + new InformationProposeContent(missingItems, proposeRelationship)), DemoTemplates.NEGOTIATION_PROPOSE); proposePrompt = propose.promptText(); Map step = @@ -876,7 +880,7 @@ private static String missingHint(Map phrasing, Map items, Map phrasing) { StringBuilder text = new StringBuilder(phrasing.getOrDefault("from_text_propose_prefix", "")); appendNumberedItems(text, items); - String relationship = phrasing.get("propose_relationship"); + String relationship = items.size() > 1 ? phrasing.get("propose_relationship") : null; if (relationship != null && !relationship.isBlank()) { text.append(relationship); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java index 114d1a72..60cd2b16 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java @@ -120,12 +120,15 @@ private void handleMissingParams( Map replyContext = negotiationContext; // dynamically generate the Negotiation-T information-propose prompt via the strategy + String relationship = missingItems.size() > 1 + ? ScenarioData.negotiationPhrasing().get("propose_relationship") + : null; MetadataContent negotiationPrompt = strategy.generatePropose( server, new NegotiationContext( UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.PROPOSE), missingItems, - ScenarioData.negotiationPhrasing().get("propose_relationship"), + relationship, DemoConstants.NEGOTIATION_PROPOSE); emit("[server] negotiation request rendered"); diff --git a/a2a-t-sample/src/main/resources/sample/negotiation/eval/eval-suite.json b/a2a-t-sample/src/main/resources/sample/negotiation/eval/eval-suite.json index e8cb366e..d71ae962 100644 --- a/a2a-t-sample/src/main/resources/sample/negotiation/eval/eval-suite.json +++ b/a2a-t-sample/src/main/resources/sample/negotiation/eval/eval-suite.json @@ -37,7 +37,7 @@ }, "negotiation_phrasing": { "missing_item_hint": "请提供{slot}:{description}", - "propose_relationship": "以上参数均为必选,缺少无法启动诊断", + "propose_relationship": "所列字段均为必选,多个字段之间为AND关系;缺少任一字段均无法启动诊断", "from_text_propose_prefix": "请提供以下缺失信息:", "from_text_accept_prefix": "同意补充以下信息:", "from_text_accept_suffix": "信息已完整,可以启动诊断。" From 22fe7789a97fc1e8fddfa1d5e5b84ad1e7e2b1a0 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Fri, 28 Aug 2026 17:54:21 +0800 Subject: [PATCH 227/295] refactor(sample): drop the superseded 3x3 negotiation generation samples The generation-only fromData/fromText 3x3 samples printed summaries without assertions and are fully covered by the stronger evaluators: NegotiationFromDataApiEvalApp (fromData generate-validate round trip) and NegotiationEvalApp plus the private-line corpus evaluation (fromText with golden comparison). Removes the samples, their shared support class, the now-orphaned ScenarioData case accessors, the negotiation_cases scenario section, the javaargs generation blocks, and the corresponding README/design-doc sections. Also aligns the scenario field model with the Task-T template vocabulary. Signed-off-by: ZhouJie6 --- a2a-t-sample/README.zh-CN.md | 31 +- .../docs/Negotiation-Sample-Design.md | 18 +- a2a-t-sample/pom.xml | 8 - .../fromdata/FromDataNegotiationSample.java | 270 ------------------ .../fromtext/FromTextNegotiationSample.java | 199 ------------- .../shared/NegotiationSampleSupport.java | 70 ----- .../negotiation/shared/ScenarioData.java | 74 +---- .../sample/negotiation/scenario.json | 125 +------- 8 files changed, 17 insertions(+), 778 deletions(-) delete mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java delete mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java delete mode 100644 a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationSampleSupport.java diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 6a1c0c02..91d12379 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -13,8 +13,6 @@ - 订阅事件样例客户端:`net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain` - 订阅事件样例服务端:`net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain` - 协商端到端样例(4报文):`net.openan.a2at.sample.negotiation.NegotiationDemoApp` -- 结构化数据协商样例(fromData 3x3):`net.openan.a2at.sample.negotiation.fromdata.FromDataNegotiationSample` -- 自然语言协商样例(fromText 3x3):`net.openan.a2at.sample.negotiation.fromtext.FromTextNegotiationSample` - 授权策略样例(Authorization-T):`net.openan.a2at.sample.authz_policy.AuthzSampleMain` ## 模块内资源 @@ -52,8 +50,7 @@ | `negotiation/` | 入口 `NegotiationDemoApp`:启动嵌入式 HTTP server + 跑 client,`--fromText` 切换策略 | | `negotiation/client/` | `NegotiationClient`:4 报文编排 + 传输端点选择(按 AgentCard 能力走 message:stream / message:send) | | `negotiation/server/` | `NegotiationAgentExecutor`(validateAndFillingTaskData→缺失检测→协商请求→诊断)+ `NegotiationServerRuntime`(HTTP server 装配)+ `DiagnosisService`(从 FilledParamData 动态生成诊断) | -| `negotiation/shared/` | 策略层(`NegotiationStrategy` + `FromDataStrategy`/`FromTextStrategy`)、A2A metadata 桥接(`NegotiationMessage`)、扩展/模板 URI 常量(`DemoConstants`)、场景数据加载器(`ScenarioData`,数据在 `scenario.json`)、样例公共辅助(`NegotiationSampleSupport`) | -| `negotiation/fromdata/`、`negotiation/fromtext/` | 9 用例 API 验证样例(见下文两节) | +| `negotiation/shared/` | 策略层(`NegotiationStrategy` + `FromDataStrategy`/`FromTextStrategy`)、A2A metadata 桥接(`NegotiationMessage`)、扩展/模板 URI 常量(`DemoConstants`)、场景数据加载器(`ScenarioData`,数据在 `scenario.json`)、协商校验 schema(`InformationNegotiationSchemas`,与 private-line 样例共用) | ### 协商样例启动 @@ -95,32 +92,6 @@ java @a2a-t-sample/target/service-recovery-client.javaargs.txt 如果不传参数,会回退到包内的 `sample/service-recovery/{client,server}/{client,server}.env`。 -## 结构化数据协商样例(fromData) - -验证 `generateNegotiationProposePromptFromData` / `generateNegotiationAcceptPromptFromData` / `generateNegotiationRejectPromptFromData` 三个 API(结构化输入,规则渲染)。覆盖 3 种协商类型 × 3 种阶段 = 9 个用例: - -| 类型 | propose | accept | reject | -|---|---|---|---| -| 信息协商 | 请求补充接入端口名称/投诉分类 | 交付补充信息 | 站点清单不可用,无法提供 | -| 目标协商 | 意图理解 + 待澄清 | 确认节能目标 | 区域信息无法澄清 | -| 可行性协商 | 请求评估节能可行性 | 确认可行 | 供电约束下不可行 | - -```bash -java @a2a-t-sample/target/fromdata.javaargs.txt /path/to/.env -``` - -9 个用例的协商报文生成均为确定性规则渲染,不调 LLM;但 SDK 配置仍需有效的 `A2AT_LLM_API_KEY`(`A2ATClient` 构造即校验)。 - -## 自然语言协商样例(fromText) - -验证 `generateNegotiationProposePromptFromText` / `generateNegotiationAcceptPromptFromText` / `generateNegotiationRejectPromptFromText` 三个 API(自然语言输入,LLM 结构化抽取 + 渲染)。同样覆盖 3 种类型 × 3 种阶段 = 9 个用例。与 fromData 的差异:输入是自然语言文本,SDK 用 LLM 抽取为类型化内容后渲染。**需要真实 LLM API key**。 - -```bash -java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env -``` - -复用 `shared/NegotiationSampleSupport` 公共辅助(SessionId、模板 URI 常量、summary),fromData 和 fromText 差异仅在输入构造(record vs 自然语言文本)。 - ## 协商接口闭环评测(Negotiation Interface Eval) `NegotiationEvalApp` 是纯 Java 评测入口,对 `eval-suite.json` 里的每个用例按报文顺序驱动协商 prompt 接口,逐步采集证据并输出可回放的 JSON 报告: diff --git a/a2a-t-sample/docs/Negotiation-Sample-Design.md b/a2a-t-sample/docs/Negotiation-Sample-Design.md index 5b237172..417b50d3 100644 --- a/a2a-t-sample/docs/Negotiation-Sample-Design.md +++ b/a2a-t-sample/docs/Negotiation-Sample-Design.md @@ -58,13 +58,11 @@ negotiation/ │ ├── NegotiationMessage.java # A2A metadata 桥接(扩展 prompt + 协商上下文序列化) │ ├── DemoConstants.java # 扩展 URI / 元数据键常量 │ ├── ScenarioData.java # scenario.json 加载器 -│ └── NegotiationSampleSupport.java # 3×3 API 样例公共辅助 -├── fromdata/ -│ └── FromDataNegotiationSample.java # 信息/目标/可行性 × 提议/接受/拒绝,共 9 个生成用例 -├── fromtext/ -│ └── FromTextNegotiationSample.java # 同上 9 个用例,自然语言输入 +│ └── InformationNegotiationSchemas.java # 协商校验 caller schema(与 private-line 样例共用) └── eval/ - └── NegotiationEvalApp.java # 协商接口闭环评测:逐用例驱动接口并输出可回放 JSON 报告 + ├── NegotiationEvalApp.java # 协商接口闭环评测:逐用例驱动接口并输出可回放 JSON 报告 + ├── NegotiationFromDataApiEvalApp.java # fromData 六接口专项评测 + └── EvalLlmCaptureClient.java # LLM 调用证据采集 ``` 评测用例与场景配置同目录外置:`src/main/resources/sample/negotiation/eval/eval-suite.json`。 @@ -82,7 +80,7 @@ negotiation/ ### 场景数据外部化 -场景的全部实例化值集中在 `src/main/resources/sample/negotiation/scenario.json`:Task-T 槽位 schema(含槽位描述与示例)、参数缺失/补齐两份输入、协商文案模板(`{slot}`/`{description}`/`{params}` 占位符)、诊断结果行模板、3×3 API 样例的全部用例数据。Java 代码只保留通用规则(槽位遍历、编号列表拼装、占位符替换),源码中不含任何场景实例化字符串。更换演示场景只需编辑该文件。 +场景的全部实例化值集中在 `src/main/resources/sample/negotiation/scenario.json`:Task-T 槽位 schema(含槽位描述与示例)、参数缺失/补齐两份输入、协商文案模板(`{slot}`/`{description}`/`{params}` 占位符)、诊断结果行模板。Java 代码只保留通用规则(槽位遍历、编号列表拼装、占位符替换),源码中不含任何场景实例化字符串。更换演示场景只需编辑该文件。 ## 五、运行 @@ -94,7 +92,7 @@ negotiation/ ### 命令形态说明 -所有入口使用 Java 的 @-argfile 语法(JDK 9+):`java @` 把文件内容展开为启动参数。构建时(`mvn package`)Maven antrun 插件在 `target/` 下为每个入口生成一个 `*.javaargs.txt`,文件内容是两行——`-cp` 加上完整的依赖 jar 路径链,以及主类的全限定名。这样运行命令无需手写数百字符的 classpath。每个入口对应一个文件:`negotiation.javaargs.txt`(端到端 demo)、`fromdata.javaargs.txt` / `fromtext.javaargs.txt`(3×3 生成用例)、`eval.javaargs.txt`(协商接口评测)。Windows 控制台建议加 `-Dfile.encoding=UTF-8` 前缀,避免中文日志显示为乱码(不影响输出文件本身的编码)。 +所有入口使用 Java 的 @-argfile 语法(JDK 9+):`java @` 把文件内容展开为启动参数。构建时(`mvn package`)Maven antrun 插件在 `target/` 下为每个入口生成一个 `*.javaargs.txt`,文件内容是两行——`-cp` 加上完整的依赖 jar 路径链,以及主类的全限定名。这样运行命令无需手写数百字符的 classpath。每个入口对应一个文件:`negotiation.javaargs.txt`(端到端 demo)、`eval.javaargs.txt`(协商接口评测)、`fromdata-eval.javaargs.txt`(fromData 专项评测)。Windows 控制台建议加 `-Dfile.encoding=UTF-8` 前缀,避免中文日志显示为乱码(不影响输出文件本身的编码)。 ### 端到端 demo @@ -107,10 +105,6 @@ java @a2a-t-sample/target/negotiation.javaargs.txt --fromText /path/to/.env # 强制阻塞端点(覆盖 message:send 场景) java @a2a-t-sample/target/negotiation.javaargs.txt --no-stream /path/to/.env - -# 3×3 API 生成用例(结构化数据通道 / 自然语言通道) -java @a2a-t-sample/target/fromdata.javaargs.txt /path/to/.env -java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env ``` 组合 `--fromText` 与 `--no-stream` 可覆盖全部四种通道 × 端点组合。 diff --git a/a2a-t-sample/pom.xml b/a2a-t-sample/pom.xml index f4fb02de..504fbe30 100644 --- a/a2a-t-sample/pom.xml +++ b/a2a-t-sample/pom.xml @@ -160,14 +160,6 @@ net.openan.a2at.sample.negotiation.eval.NegotiationEvalApp -cp ${sample.jar.path}${path.separator}${sample.runtime.classpath} net.openan.a2at.sample.negotiation.eval.NegotiationFromDataApiEvalApp - - -cp -${sample.jar.path}${path.separator}${sample.runtime.classpath} -net.openan.a2at.sample.negotiation.fromdata.FromDataNegotiationSample - - -cp -${sample.jar.path}${path.separator}${sample.runtime.classpath} -net.openan.a2at.sample.negotiation.fromtext.FromTextNegotiationSample -cp ${sample.jar.path}${path.separator}${sample.runtime.classpath} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java deleted file mode 100644 index 03371042..00000000 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromdata/FromDataNegotiationSample.java +++ /dev/null @@ -1,270 +0,0 @@ -package net.openan.a2at.sample.negotiation.fromdata; - -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; -import net.openan.a2at.sample.negotiation.shared.NegotiationSampleSupport; -import net.openan.a2at.sample.negotiation.shared.ScenarioData; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; - -/** - * Sample that demonstrates all three fromData negotiation APIs across all three negotiation types. - * - *

        This sample is the verification artifact for the fromData API surface: - * - *

          - *
        • {@link A2ATClient#generateNegotiationProposePromptFromData generateNegotiationProposePromptFromData} - *
        • {@link A2ATClient#generateNegotiationAcceptPromptFromData generateNegotiationAcceptPromptFromData} - *
        • {@link A2ATClient#generateNegotiationRejectPromptFromData generateNegotiationRejectPromptFromData} - *
        - * - * Each API is exercised for all three negotiation types (information, target, feasibility) so the 3x3 matrix (type x - * phase) is fully covered. Generation is deterministic (no LLM call for rendering), but the SDK still requires a - * configured LLM API key in the {@code .env} file. - * - *

        Run: - * - *

        {@code java @a2a-t-sample/target/fromdata.javaargs.txt /path/to/.env}
        - * - * @since 2026-08 - */ -public final class FromDataNegotiationSample { - - private FromDataNegotiationSample() {} - - /** - * Runs all fromData negotiation sample cases and returns one summary per case. - * - * @param envPath resolved {@code .env} file path - * @param logSink log output sink - * @return summary entries in execution order - */ - public static List> runAll(Path envPath, Consumer logSink) { - A2ATClient client = new A2ATClient(envPath); - List> results = new ArrayList<>(); - - results.addAll(informationNegotiation(client, logSink)); - results.addAll(targetNegotiation(client, logSink)); - results.addAll(feasibilityNegotiation(client, logSink)); - - return results; - } - - // ========================================================================================= - // 1. Information negotiation (missing information request / accept / reject) - // ========================================================================================= - - private static List> informationNegotiation(A2ATClient client, Consumer logSink) { - NegotiationSampleSupport.emit(logSink, "\n=== 1. Information negotiation ==="); - NegotiationContext ctx = new NegotiationContext( - NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.PROPOSE); - List> results = new ArrayList<>(); - - Map section = ScenarioData.caseSection("information"); - - // propose: request missing information items - Map propose = ScenarioData.map(section, "propose"); - MetadataContent proposeResult = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - ctx, - new InformationProposeContent( - ScenarioData.items(propose, "items"), ScenarioData.text(propose, "relationship"))), - NegotiationSampleSupport.INFO_PROPOSE_URI); - results.add(NegotiationSampleSupport.summary("information", "propose", proposeResult, logSink)); - - // accept: deliver the missing information - Map accept = ScenarioData.map(section, "accept"); - MetadataContent acceptResult = client.generateNegotiationAcceptPromptFromData( - new NegotiationEndingData( - ctx, - new InformationEndingContent( - NegotiationConclusion.ACCEPT, ScenarioData.items(accept, "items"))), - NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("information", "accept", acceptResult, logSink)); - - // reject: cannot provide the information - Map reject = ScenarioData.map(section, "reject"); - MetadataContent rejectResult = client.generateNegotiationRejectPromptFromData( - new NegotiationEndingData( - ctx, - new InformationEndingContent( - NegotiationConclusion.REJECT, ScenarioData.items(reject, "items"))), - NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("information", "reject", rejectResult, logSink)); - - return results; - } - - // ========================================================================================= - // 2. Target negotiation (intent alignment / accept / reject) - // ========================================================================================= - - private static List> targetNegotiation(A2ATClient client, Consumer logSink) { - NegotiationSampleSupport.emit(logSink, "\n=== 2. Target negotiation ==="); - NegotiationContext ctx = new NegotiationContext( - NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.PROPOSE); - List> results = new ArrayList<>(); - - Map section = ScenarioData.caseSection("target"); - - // propose: state the target intent and request clarification - Map propose = ScenarioData.map(section, "propose"); - MetadataContent proposeResult = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - ctx, - new TargetProposeContent( - ScenarioData.text(propose, "description"), - ScenarioData.items(propose, "intent_understanding"), - ScenarioData.items(propose, "alignment_and_clarification"), - ScenarioData.items(propose, "request_for_clarification"), - ScenarioData.text(propose, "target_confirm_request"))), - NegotiationSampleSupport.TARGET_PROPOSE_URI); - results.add(NegotiationSampleSupport.summary("target", "propose", proposeResult, logSink)); - - // propose (round 2): the target is clarified and the executor requests confirmation to proceed - Map proposeConfirm = ScenarioData.map(section, "propose_confirm"); - if (!proposeConfirm.isEmpty()) { - NegotiationContext laterRoundCtx = new NegotiationContext( - NegotiationSampleSupport.SESSION_ID, - 2, - NegotiationContext.DEFAULT_MAX_ROUNDS, - NegotiationPerformative.PROPOSE); - MetadataContent proposeConfirmResult = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - laterRoundCtx, - new TargetProposeContent( - ScenarioData.text(proposeConfirm, "description"), - null, - null, - null, - ScenarioData.text(proposeConfirm, "target_confirm_request"))), - NegotiationSampleSupport.TARGET_PROPOSE_URI); - results.add(NegotiationSampleSupport.summary("target", "propose_confirm", proposeConfirmResult, logSink)); - } - - // accept: confirm the negotiated target intent - Map accept = ScenarioData.map(section, "accept"); - MetadataContent acceptResult = client.generateNegotiationAcceptPromptFromData( - new NegotiationEndingData( - ctx, - new TargetEndingContent( - NegotiationConclusion.ACCEPT, ScenarioData.text(accept, "confirmed_intent"), null)), - NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("target", "accept", acceptResult, logSink)); - - // reject: cannot agree on the target - Map reject = ScenarioData.map(section, "reject"); - MetadataContent rejectResult = client.generateNegotiationRejectPromptFromData( - new NegotiationEndingData( - ctx, - new TargetEndingContent( - NegotiationConclusion.REJECT, null, ScenarioData.text(reject, "rejection_reason"))), - NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("target", "reject", rejectResult, logSink)); - - return results; - } - - // ========================================================================================= - // 3. Feasibility negotiation (evaluation request / accept / reject) - // ========================================================================================= - - private static List> feasibilityNegotiation(A2ATClient client, Consumer logSink) { - NegotiationSampleSupport.emit(logSink, "\n=== 3. Feasibility negotiation ==="); - NegotiationContext ctx = new NegotiationContext( - NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.PROPOSE); - List> results = new ArrayList<>(); - - Map section = ScenarioData.caseSection("feasibility"); - - // propose: request a feasibility evaluation - Map propose = ScenarioData.map(section, "propose"); - MetadataContent proposeResult = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - ctx, - new FeasibilityProposeContent( - ScenarioData.text(propose, "description"), - NegotiationAction.valueOf(ScenarioData.text(propose, "action")), - ScenarioData.items(propose, "contents_to_evaluate"), - ScenarioData.items(propose, "infeasibility_details_and_proposal"), - ScenarioData.text(propose, "feasibility_confirm_request"))), - NegotiationSampleSupport.FEASIBILITY_PROPOSE_URI); - results.add(NegotiationSampleSupport.summary("feasibility", "propose", proposeResult, logSink)); - - // propose (round 2): the assessment is complete and the executor requests confirmation to proceed - Map proposeConfirm = ScenarioData.map(section, "propose_confirm"); - if (!proposeConfirm.isEmpty()) { - MetadataContent proposeConfirmResult = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - ctx, - new FeasibilityProposeContent( - ScenarioData.text(proposeConfirm, "description"), - NegotiationAction.valueOf(ScenarioData.text(proposeConfirm, "action")), - ScenarioData.items(proposeConfirm, "contents_to_evaluate"), - ScenarioData.items(proposeConfirm, "infeasibility_details_and_proposal"), - ScenarioData.text(proposeConfirm, "feasibility_confirm_request"))), - NegotiationSampleSupport.FEASIBILITY_PROPOSE_URI); - results.add( - NegotiationSampleSupport.summary("feasibility", "propose_confirm", proposeConfirmResult, logSink)); - } - - // accept: feasibility confirmed - Map accept = ScenarioData.map(section, "accept"); - MetadataContent acceptResult = client.generateNegotiationAcceptPromptFromData( - new NegotiationEndingData( - ctx, - new FeasibilityEndingContent( - NegotiationConclusion.ACCEPT, ScenarioData.text(accept, "summary"))), - NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("feasibility", "accept", acceptResult, logSink)); - - // reject: not feasible - Map reject = ScenarioData.map(section, "reject"); - MetadataContent rejectResult = client.generateNegotiationRejectPromptFromData( - new NegotiationEndingData( - ctx, - new FeasibilityEndingContent( - NegotiationConclusion.REJECT, ScenarioData.text(reject, "summary"))), - NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("feasibility", "reject", rejectResult, logSink)); - - return results; - } - - // ========================================================================================= - - /** - * Entry point. - * - * @param args first argument is the {@code .env} file path (must contain a real LLM API key) - */ - public static void main(String[] args) { - if (args.length == 0) { - System.err.println("Usage: java FromDataNegotiationSample "); - System.err.println("A configured LLM API key is required (fromData skips the LLM only for " - + "negotiation-message generation)."); - System.exit(1); - } - Path envPath = Path.of(args[0]); - List> results = runAll(envPath, System.out::println); - System.out.println("\n=== Summary (" + results.size() + " cases) ==="); - for (Map r : results) { - System.out.println(r); - } - } -} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java deleted file mode 100644 index 6cc6d2fd..00000000 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/fromtext/FromTextNegotiationSample.java +++ /dev/null @@ -1,199 +0,0 @@ -package net.openan.a2at.sample.negotiation.fromtext; - -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; -import net.openan.a2at.sample.negotiation.shared.NegotiationSampleSupport; -import net.openan.a2at.sample.negotiation.shared.ScenarioData; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; - -/** - * Sample that demonstrates all three fromText negotiation APIs across all three negotiation types. - * - *

        This sample is the verification artifact for the fromText API surface: - * - *

          - *
        • {@link A2ATClient#generateNegotiationProposePromptFromText generateNegotiationProposePromptFromText} - *
        • {@link A2ATClient#generateNegotiationAcceptPromptFromText generateNegotiationAcceptPromptFromText} - *
        • {@link A2ATClient#generateNegotiationRejectPromptFromText generateNegotiationRejectPromptFromText} - *
        - * - * Each API is exercised for all three negotiation types (information, target, feasibility). Unlike the fromData - * variant, the input is natural-language text: the SDK runs one LLM content-extraction step to parse it into typed - * content, then renders deterministically. A real LLM API key is required. - * - *

        Run: - * - *

        {@code java @a2a-t-sample/target/fromtext.javaargs.txt /path/to/.env}
        - * - * @since 2026-08 - */ -public final class FromTextNegotiationSample { - - private FromTextNegotiationSample() {} - - /** - * Runs all fromText negotiation sample cases and returns one summary per case. - * - * @param envPath resolved {@code .env} file path (must contain a real LLM API key) - * @param logSink log output sink - * @return summary entries in execution order - */ - public static List> runAll(Path envPath, Consumer logSink) { - A2ATClient client = new A2ATClient(envPath); - List> results = new ArrayList<>(); - - results.addAll(informationNegotiation(client, logSink)); - results.addAll(targetNegotiation(client, logSink)); - results.addAll(feasibilityNegotiation(client, logSink)); - - return results; - } - - // ========================================================================================= - // 1. Information negotiation (natural-language text -> LLM extract -> render) - // ========================================================================================= - - private static List> informationNegotiation(A2ATClient client, Consumer logSink) { - NegotiationSampleSupport.emit(logSink, "\n=== 1. Information negotiation (fromText) ==="); - NegotiationContext ctx = new NegotiationContext( - NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.PROPOSE); - List> results = new ArrayList<>(); - - Map cases = - ScenarioData.fromTextCases().get("information") instanceof Map map ? asStringMap(map) : Map.of(); - - // propose: ask for missing information items - MetadataContent propose = client.generateNegotiationProposePromptFromText( - text(cases, "propose"), ctx, NegotiationSampleSupport.INFO_PROPOSE_URI); - results.add(NegotiationSampleSupport.summary("information", "propose", propose, logSink)); - - // accept: deliver the requested information - MetadataContent accept = client.generateNegotiationAcceptPromptFromText( - text(cases, "accept"), ctx, NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("information", "accept", accept, logSink)); - - // reject: cannot provide the information - MetadataContent reject = client.generateNegotiationRejectPromptFromText( - text(cases, "reject"), ctx, NegotiationSampleSupport.INFO_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("information", "reject", reject, logSink)); - - return results; - } - - // ========================================================================================= - // 2. Target negotiation - // ========================================================================================= - - private static List> targetNegotiation(A2ATClient client, Consumer logSink) { - NegotiationSampleSupport.emit(logSink, "\n=== 2. Target negotiation (fromText) ==="); - NegotiationContext ctx = new NegotiationContext( - NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.PROPOSE); - List> results = new ArrayList<>(); - - Map cases = - ScenarioData.fromTextCases().get("target") instanceof Map map ? asStringMap(map) : Map.of(); - - // propose: state the target intent and request clarification - MetadataContent propose = client.generateNegotiationProposePromptFromText( - text(cases, "propose"), ctx, NegotiationSampleSupport.TARGET_PROPOSE_URI); - results.add(NegotiationSampleSupport.summary("target", "propose", propose, logSink)); - - // propose (round 2): the target is clarified and the executor requests confirmation to proceed - String proposeConfirmText = text(cases, "propose_confirm"); - if (proposeConfirmText != null) { - NegotiationContext laterRoundCtx = new NegotiationContext( - NegotiationSampleSupport.SESSION_ID, - 2, - NegotiationContext.DEFAULT_MAX_ROUNDS, - NegotiationPerformative.PROPOSE); - MetadataContent proposeConfirm = client.generateNegotiationProposePromptFromText( - proposeConfirmText, laterRoundCtx, NegotiationSampleSupport.TARGET_PROPOSE_URI); - results.add(NegotiationSampleSupport.summary("target", "propose_confirm", proposeConfirm, logSink)); - } - - // accept: confirm the negotiated target intent - MetadataContent accept = client.generateNegotiationAcceptPromptFromText( - text(cases, "accept"), ctx, NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("target", "accept", accept, logSink)); - - // reject: cannot agree on the target - MetadataContent reject = client.generateNegotiationRejectPromptFromText( - text(cases, "reject"), ctx, NegotiationSampleSupport.TARGET_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("target", "reject", reject, logSink)); - - return results; - } - - // ========================================================================================= - // 3. Feasibility negotiation - // ========================================================================================= - - private static List> feasibilityNegotiation(A2ATClient client, Consumer logSink) { - NegotiationSampleSupport.emit(logSink, "\n=== 3. Feasibility negotiation (fromText) ==="); - NegotiationContext ctx = new NegotiationContext( - NegotiationSampleSupport.SESSION_ID, 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.PROPOSE); - List> results = new ArrayList<>(); - - Map cases = - ScenarioData.fromTextCases().get("feasibility") instanceof Map map ? asStringMap(map) : Map.of(); - - // propose: request a feasibility evaluation - MetadataContent propose = client.generateNegotiationProposePromptFromText( - text(cases, "propose"), ctx, NegotiationSampleSupport.FEASIBILITY_PROPOSE_URI); - results.add(NegotiationSampleSupport.summary("feasibility", "propose", propose, logSink)); - - // propose (round 2): the assessment is complete and the executor requests confirmation to proceed - String proposeConfirmText = text(cases, "propose_confirm"); - if (proposeConfirmText != null) { - MetadataContent proposeConfirm = client.generateNegotiationProposePromptFromText( - proposeConfirmText, ctx, NegotiationSampleSupport.FEASIBILITY_PROPOSE_URI); - results.add(NegotiationSampleSupport.summary("feasibility", "propose_confirm", proposeConfirm, logSink)); - } - - // accept: feasibility confirmed - MetadataContent accept = client.generateNegotiationAcceptPromptFromText( - text(cases, "accept"), ctx, NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("feasibility", "accept", accept, logSink)); - - // reject: not feasible - MetadataContent reject = client.generateNegotiationRejectPromptFromText( - text(cases, "reject"), ctx, NegotiationSampleSupport.FEASIBILITY_ACCEPT_REJECT_URI); - results.add(NegotiationSampleSupport.summary("feasibility", "reject", reject, logSink)); - - return results; - } - - private static String text(Map cases, String key) { - Object value = ScenarioData.resolveValue(String.valueOf(cases.get(key))); - return value == null || "null".equals(value) ? null : String.valueOf(value); - } - - @SuppressWarnings("unchecked") - private static Map asStringMap(Map map) { - return (Map) map; - } - /** - * Entry point. - * - * @param args first argument is the {@code .env} file path (must contain a real LLM API key) - */ - public static void main(String[] args) { - if (args.length == 0) { - System.err.println("Usage: java FromTextNegotiationSample "); - System.err.println("A real LLM API key is required: fromText runs an LLM extraction step for every case."); - System.exit(1); - } - Path envPath = Path.of(args[0]); - List> results = runAll(envPath, System.out::println); - System.out.println("\n=== Summary (" + results.size() + " cases) ==="); - for (Map r : results) { - System.out.println(r); - } - } -} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationSampleSupport.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationSampleSupport.java deleted file mode 100644 index b07daae8..00000000 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationSampleSupport.java +++ /dev/null @@ -1,70 +0,0 @@ -package net.openan.a2at.sample.negotiation.shared; - -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.function.Consumer; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; - -/** - * Shared helpers for the fromData and fromText negotiation samples. - * - *

        Both samples produce the same {@link MetadataContent} output structure; they differ only in how they construct the - * input (typed records vs natural-language text). This class centralises the session id, the template-URI constants, - * the logging helper and the summary builder so neither sample duplicates them. - * - * @since 2026-08 - */ -public final class NegotiationSampleSupport { - - /** Fixed negotiation session id shared by every sample case (matches the golden fixtures). */ - public static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - /** Information negotiation template URIs. */ - public static final TemplateUri INFO_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - public static final TemplateUri INFO_ACCEPT_REJECT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - - /** Target negotiation template URIs. */ - public static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; - - public static final TemplateUri TARGET_ACCEPT_REJECT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; - - /** Feasibility negotiation template URIs. */ - public static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; - - public static final TemplateUri FEASIBILITY_ACCEPT_REJECT_URI = - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; - - private NegotiationSampleSupport() {} - - /** - * Builds a one-line summary entry for one sample case. - * - * @param type negotiation type label (information / target / feasibility) - * @param phase phase label (propose / accept / reject) - * @param mc generated metadata content - * @param logSink log output sink - * @return summary map - */ - public static Map summary(String type, String phase, MetadataContent mc, Consumer logSink) { - emit(logSink, " [" + type + "/" + phase + "] templateUri=" + mc.templateUri()); - String preview = mc.promptText().length() > 120 ? mc.promptText().substring(0, 120) + "..." : mc.promptText(); - emit(logSink, " promptText: " + preview); - Map entry = new LinkedHashMap<>(); - entry.put("type", type); - entry.put("phase", phase); - entry.put("templateUri", mc.templateUri()); - entry.put("extensionUri", mc.extensionUri()); - entry.put("promptLength", mc.promptText().length()); - return entry; - } - - /** Emits one log line if the sink is non-null. */ - public static void emit(Consumer logSink, String message) { - if (logSink != null) { - logSink.accept(message); - } - } -} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java index f8b34ee6..1bff74a1 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java @@ -5,18 +5,15 @@ import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; -import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; /** * Scenario configuration for the negotiation demo, loaded from the bundled {@code sample/negotiation/scenario.json}. * *

        The Java code of the sample contains only generic assembly rules (how to iterate slots, how to merge metadata); * every scenario-specific value lives in the JSON file: the Task-T slot schema, the missing/filled parameter maps - * driving the 4-message flow, the negotiation phrasing templates ({@code {slot}} / {@code {description}} / - * {@code {params}} placeholders) and the 3x3 API-sample case data. Changing the scenario means editing the JSON — no - * Java recompilation — and the whole flow, from slot extraction to the diagnosis text, adapts to the new inputs. + * driving the 4-message flow and the negotiation phrasing templates. Changing the scenario means editing the JSON — + * no Java recompilation — and the whole flow, from slot extraction to the diagnosis text, adapts to the new inputs. * * @since 2026-08 */ @@ -58,73 +55,6 @@ public static Map diagnosisTemplates() { return (Map) scenarioMap().get("diagnosis"); } - /** The 3x3 API-sample case data keyed by negotiation type (information/target/feasibility). */ - @SuppressWarnings("unchecked") - public static Map negotiationCases() { - return (Map) scenarioMap().get("negotiation_cases"); - } - - /** The fromText natural-language case texts keyed by negotiation type. */ - @SuppressWarnings("unchecked") - public static Map fromTextCases() { - Object cases = negotiationCases().get("from_text_cases"); - return cases instanceof Map map ? (Map) map : Map.of(); - } - - /** The fromData case section of one negotiation type. */ - @SuppressWarnings("unchecked") - public static Map caseSection(String negotiationType) { - Object section = negotiationCases().get(negotiationType); - return section instanceof Map map ? (Map) map : Map.of(); - } - - /** - * Reads one item list ({@code name}/{@code value} entries) from a case section; {@code {filled:}} values are - * substituted with the filled scenario parameter of that slot. - */ - public static List items(Map section, String key) { - Object raw = section.get(key); - if (!(raw instanceof List entries)) { - return List.of(); - } - return entries.stream() - .filter(Map.class::isInstance) - .map(entry -> { - Map item = (Map) entry; - return new NegotiationItem( - String.valueOf(item.get("name")), resolveValue(String.valueOf(item.get("value")))); - }) - .toList(); - } - - /** Reads one string field from a case section, applying {@code {filled:}} substitution. */ - public static String text(Map section, String key) { - Object value = section.get(key); - return value == null ? null : resolveValue(String.valueOf(value)); - } - - /** Reads one nested map field from a case section. */ - @SuppressWarnings("unchecked") - public static Map map(Map section, String key) { - Object value = section.get(key); - return value instanceof Map map ? (Map) map : Map.of(); - } - - /** Substitutes {@code {filled:}} markers with the filled scenario parameter of that slot. */ - public static String resolveValue(String template) { - if (template == null) { - return null; - } - String result = template; - for (Map.Entry entry : filledParams().entrySet()) { - String marker = "{filled:" + entry.getKey() + "}"; - if (result.contains(marker)) { - result = result.replace(marker, String.valueOf(entry.getValue())); - } - } - return result; - } - @SuppressWarnings("unchecked") private static Map scenarioMap() { try (InputStream stream = ScenarioData.class.getClassLoader().getResourceAsStream(SCENARIO_RESOURCE)) { diff --git a/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json b/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json index f1b13b18..480d6430 100644 --- a/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json +++ b/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json @@ -5,26 +5,27 @@ "properties": { "任务对象": { "type": "string", - "description": "专线名称,接入端口名称,接入端口资源ID参数,3个参数下发其中一个用于标识专线业务对象", + "description": "必选。使用专线业务名称、专线业务标识、接入端口名称三种方式之一标识被投诉的专线业务对象,三种方式之间为OR关系", "examples": [ - "接入端口资源ID:f47ac10b-58cc-4372-a567-0e02b2c3d479" + "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17" ] }, "任务上下文": { "type": "string", - "description": "提供专线业务的故障现象描述和诊断任务的上下文信息", + "description": "必选。投诉分类和OSS侧事件流水号为必选;问题发生时间和投诉详情为可选", "examples": [ - "故障现象:专线中断。故障流水号:fault-id-1-017-20230511-09013" + "投诉分类:专线中断。OSS侧事件流水号:fault-id-1-017-20230511-09013" ] } }, "required": [ + "任务对象", "任务上下文" ] }, "missing_params": { - "任务对象": "", - "任务上下文": "2. 问题发生时间:2026-05-11T08:21:46Z\n3. OSS侧事件流水号:event-id-20260511-09013\n4. 投诉详情:深圳访问广州响应延迟骤升,交易接口频繁报连接超时" + "任务对象": "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "任务上下文": "2. 问题发生时间:2026-05-11T08:21:46Z\n4. 投诉详情:深圳访问广州响应延迟骤升,交易接口频繁报连接超时" }, "filled_params": { "任务对象": "P781-珠江新城-PTN7900-23-TPA1EG24-17", @@ -32,7 +33,7 @@ }, "negotiation_phrasing": { "missing_item_hint": "请提供{slot}:{description}", - "propose_relationship": "以上参数均为必选,缺少无法启动诊断", + "propose_relationship": "所列字段均为必选,多个字段之间为AND关系;缺少任一字段均无法启动诊断", "from_text_propose_prefix": "请提供以下缺失信息:", "from_text_accept_prefix": "同意补充以下信息:", "from_text_accept_suffix": "信息已完整,可以启动诊断。" @@ -41,115 +42,5 @@ "result_line": "1. 诊断结果:成功", "detail_line": "2. 诊断结果详情:{params}", "advice_line": "3. 修复建议:检查接入端口物理连接和光模块状态,必要时恢复供电或重新开启端口" - }, - "negotiation_cases": { - "information": { - "propose": { - "items": [ - { - "name": "接入端口名称", - "value": "请提供业务接入端口名称" - }, - { - "name": "投诉分类", - "value": "专线中断或专线质差" - } - ], - "relationship": "两个参数均为必选,缺少无法启动诊断" - }, - "accept": { - "items": [ - { - "name": "接入端口名称", - "value": "{filled:任务对象}" - }, - { - "name": "投诉分类", - "value": "专线质差" - } - ] - }, - "reject": { - "items": [ - { - "name": "接入端口名称", - "value": "站点清单不可用,无法提供" - } - ] - } - }, - "target": { - "propose": { - "description": "对无线节点节能优化任务的意图理解待澄清", - "intent_understanding": [ - { - "name": "任务意图", - "value": "08:00-18:00对目标站点启用无线节点节能优化" - } - ], - "request_for_clarification": [ - { - "name": "节能区域", - "value": "松山湖还是其他站点?" - } - ] - }, - "propose_confirm": { - "description": "任务目标澄清完成:08:00-18:00对松山湖站点启用无线节点节能优化,速率保障不低于10Mbps,请答复目标澄清后的确认请求", - "target_confirm_request": "目标已经澄清,是否同意按照此目标继续执行?" - }, - "accept": { - "confirmed_intent": "最终确认意图:08:00-18:00对松山湖站点启用无线节点节能优化,速率保障不低于10Mbps" - }, - "reject": { - "rejection_reason": "节能区域信息因站点清单不可用而无法完整澄清" - } - }, - "feasibility": { - "propose": { - "description": "请评估无线节点节能优化在高峰时段是否可行", - "action": "REQUEST_FEASIBILITY_EVALUATION", - "contents_to_evaluate": [ - { - "name": "评估对象", - "value": "松山湖站点08:00-18:00节能窗口" - }, - { - "name": "评估约束", - "value": "速率保障不低于10Mbps" - } - ] - }, - "propose_confirm": { - "description": "针对松山湖站点节能优化目标的可行性评估已完成,结论为可行,请答复评估可行时的确认请求", - "action": "REQUEST_FEASIBILITY_EVALUATION", - "feasibility_confirm_request": "评估目标可行,是否同意按照此目标继续执行?" - }, - "accept": { - "summary": "高峰时段节能优化可行,速率保障满足10Mbps下限,可启用" - }, - "reject": { - "summary": "节能目标在现有供电约束下无法实现,需调整方案" - } - }, - "from_text_cases": { - "information": { - "propose": "请提供以下缺失信息:1. 接入端口名称:请提供业务接入端口名称;2. 投诉分类:专线中断或专线质差。两个参数均为必选,缺少无法启动诊断。两者共同定位专线故障原因。", - "accept": "同意补充以下信息:1. 接入端口名称:{filled:任务对象};2. 投诉分类:专线质差。信息已完整,可以启动诊断。", - "reject": "拒绝补充信息:接入端口名称因站点清单不可用而无法提供,本次协商结束。" - }, - "target": { - "propose": "对无线节点节能优化任务的意图理解如下:任务意图:08:00-18:00对目标站点启用无线节点节能优化。待澄清内容:节能区域松山湖还是其他站点?", - "propose_confirm": "任务目标澄清完成:08:00-18:00对松山湖站点启用无线节点节能优化,速率保障不低于10Mbps。目标已经澄清,是否同意按照此目标继续执行?", - "accept": "确认意图:08:00-18:00对松山湖站点启用无线节点节能优化,速率保障不低于10Mbps。最终意图已明确,可以执行。", - "reject": "拒绝:节能区域信息因站点清单不可用而无法完整澄清,本次协商结束。" - }, - "feasibility": { - "propose": "请评估无线节点节能优化在高峰时段是否可行。评估对象:松山湖站点08:00-18:00节能窗口。评估约束:速率保障不低于10Mbps。", - "propose_confirm": "针对松山湖站点节能优化目标的可行性评估已完成:高峰时段节能优化可行,速率保障满足10Mbps下限。评估目标可行,是否同意按照此目标继续执行?", - "accept": "同意:高峰时段节能优化可行,速率保障满足10Mbps下限,可启用。评估结果:可行。", - "reject": "拒绝:节能目标在现有供电约束下无法实现,需调整方案。评估结果:不可行。" - } - } } } From 22bcc04bd993e085e26fd1d322ff9673c5fa3bb4 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Fri, 28 Aug 2026 17:54:29 +0800 Subject: [PATCH 228/295] feat(sample): unify negotiation validation schemas and evaluate the corpus on both channels InformationNegotiationSchemas moves from the private-line sample to the shared negotiation package: the validate*AndDataFilling contracts are channel-agnostic, so the fromText flows and the fromData evaluator now use one schema (items:[{name, requirement|value|reason}]). The fromData API evaluator delegates to the shared schemas and additionally asserts the extracted accept values and reject reasons. The labelled corpus (cases.json) gains a structured data field parallel to each text input and switches expected keys to the display names, so one corpus drives both generation channels. The corpus evaluation runs the protocol-correct chain: the server proposes, the client validates the inbound propose and discovers the fields to supplement, the client generates the accept/reject from that discovery, and the server validates the ending. The fromData channel additionally requires an exact completedPrompt match because its rendering is deterministic. Fixes the expected-value matching that silently always failed since the schema moved to the item-array shape. Signed-off-by: ZhouJie6 --- .../eval/NegotiationFromDataApiEvalApp.java | 136 +++------ .../shared/InformationNegotiationSchemas.java | 6 +- .../client/NegotiationClientMain.java | 2 +- .../NegotiationAbnormalEvaluationMain.java | 2 +- ...tiationBusinessAbnormalEvaluationMain.java | 2 +- .../evaluation/NegotiationEvaluationCase.java | 34 ++- .../NegotiationEvaluationFlowCase.java | 19 -- .../NegotiationQwenEvaluationMain.java | 285 ++++++++++++++---- .../server/NegotiationAgentExecutor.java | 2 +- .../shared/NegotiationSampleFlow.java | 1 + .../negotiation/eval/fromdata-api-suite.json | 47 ++- .../evaluation/cases.json | 200 ++++++------ .../InformationNegotiationSchemasTest.java | 4 +- .../NegotiationFromDataApiEvalAppTest.java | 141 +++++++++ .../NegotiationEvaluationCaseLoaderTest.java | 38 ++- 15 files changed, 596 insertions(+), 323 deletions(-) rename a2a-t-sample/src/main/java/net/openan/a2at/sample/{private_line_complaint => }/negotiation/shared/InformationNegotiationSchemas.java (93%) rename a2a-t-sample/src/test/java/net/openan/a2at/sample/{private_line_complaint => }/negotiation/InformationNegotiationSchemasTest.java (96%) create mode 100644 a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalAppTest.java diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java index 08c4d14e..1722c1ae 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java @@ -18,6 +18,7 @@ import java.util.TreeSet; import java.util.UUID; import java.util.function.Consumer; +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.model.FilledParamData; @@ -114,7 +115,7 @@ public static void main(String[] args) { + testCase.get("intent")); long startNanos = System.nanoTime(); Map record = runCase(envPath, testCase); - record.put("duration_seconds", Math.round((System.nanoTime() - startNanos) / 100_000.0) / 10.0); + record.put("duration_seconds", Math.round((System.nanoTime() - startNanos) / 100_000_000.0) / 10.0); cases.add(record); writeReport(report, outPath); } @@ -133,7 +134,9 @@ private static Map runCase(Path envPath, Map tes boolean expectSuccess = Boolean.TRUE.equals(expect.get("succeeds")); Map itemsInput = asMap(input.get("items")); - String relationship = String.valueOf(input.get("relationship")); + String relationship = input.containsKey("relationship") + ? String.valueOf(input.get("relationship")) + : null; TemplateUri template = resolveTemplate(api, input); NegotiationPerformative performative = "propose".equals(api) @@ -243,9 +246,9 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), // -- step 2: validation and parameter extraction of the generated message -- if (prompt != null && !itemsInput.isEmpty()) { - // the caller schema mirrors the SDK's own model of the message content (NegotiationJsonSchemaBuilder): - // propose is {items: [{name, value}], relationship}, endings are {conclusion, items: [{name, value}]} - Map extractionSchema = extractionSchema(api, itemsInput); + // The caller schema describes the business facts to extract, not the SDK's internal + // NegotiationItem wire model. It is the same shared contract the fromText samples use. + Map extractionSchema = extractionSchema(api); Map validateInput = new LinkedHashMap<>(); validateInput.put("prompt", prompt); validateInput.put("context", contextJson(context)); @@ -276,7 +279,7 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), validation.put("extracted_params", extractedJson); validateStep.put("validation", validation); check(checks, "validation passed", true); - checkExtractedItems(checks, api, extractedJson, itemsInput, input); + checkExtractedFields(checks, api, extractedJson, itemsInput, input); } catch (RuntimeException error) { Map validation = new LinkedHashMap<>(); validation.put("outcome", "rejected"); @@ -305,108 +308,52 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), } /** - * Extraction schema mirroring the SDK's own model of the message content ({@code NegotiationJsonSchemaBuilder}): - * propose is {@code {items: [{name, value}], relationship}} (items a non-empty array), endings are - * {@code {conclusion: "Accept"|"Reject", items: [{name, value}]}}. The validation-side extraction contract is - * therefore aligned end to end with the generation-side model. + * Caller-owned extraction schema, shared with the fromText samples: the same information-negotiation + * contracts work for both generation paths because the validate*AndDataFilling interfaces and the rendered + * wire format are identical. */ - private static Map extractionSchema(String api, Map items) { - Map properties = new LinkedHashMap<>(); - if (!"propose".equals(api)) { - properties.put("conclusion", Map.of("type", "string", "enum", List.of("Accept", "Reject"))); - } - properties.put("items", nonEmptyItemArraySchema()); - if ("propose".equals(api)) { - properties.put("relationship", nullableStringSchema()); - } - List required = new ArrayList<>(); - required.add("items"); - if (!"propose".equals(api)) { - required.add("conclusion"); - } - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - schema.put("required", required); - return schema; - } - - private static Map nonEmptyItemArraySchema() { - Map itemProperties = new LinkedHashMap<>(); - itemProperties.put("name", Map.of("type", "string")); - itemProperties.put("value", nullableStringSchema()); - Map itemSchema = new LinkedHashMap<>(); - itemSchema.put("type", "object"); - itemSchema.put("properties", itemProperties); - itemSchema.put("required", List.of("name", "value")); - Map arraySchema = new LinkedHashMap<>(); - arraySchema.put("type", "array"); - arraySchema.put("items", itemSchema); - arraySchema.put("minItems", 1); - return arraySchema; - } - - private static Map nullableStringSchema() { - Map schema = new LinkedHashMap<>(); - schema.put("type", "string"); - schema.put("nullable", true); - return schema; + static Map extractionSchema(String api) { + return switch (api) { + case "propose" -> InformationNegotiationSchemas.propose(); + case "accept" -> InformationNegotiationSchemas.accept(); + case "reject" -> InformationNegotiationSchemas.reject(); + default -> throw new IllegalArgumentException("Unknown api: " + api); + }; } /** - * Asserts the extracted items list against the input items: every input slot appears as an item with the matching - * value. For propose the value is the stated expectation (the meaning-head and no-hallucination guards apply); - * for endings the value is the real content or the reason of non-provision. + * Asserts that semantic extraction returns exactly the negotiated business field names, and that + * accept/reject payloads transport the input item text (supplied value / reason). */ - private static void checkExtractedItems( + private static void checkExtractedFields( List> checks, String api, Map extractedJson, Map itemsInput, Map input) { List> extractedItems = asMapList(extractedJson.get("items")); - check(checks, "extracted items list matches the input size", - extractedItems.size() == itemsInput.size()); - for (Map.Entry entry : itemsInput.entrySet()) { - Map extractedItem = itemByName(extractedItems, entry.getKey()); - check(checks, "extracted items contain: " + entry.getKey(), extractedItem != null); - if (extractedItem == null) { - continue; - } - Object value = extractedItem.get("value"); - if ("propose".equals(api)) { - // expectation contract: the extracted value must keep the meaning head of the stated expectation (a - // sample-only extraction would read as if the field were already supplied) and stay within the - // stated expectation (no hallucinated content beyond the message) - String stated = String.valueOf(entry.getValue()); - check(checks, "extracted expectation keeps the meaning part: " + entry.getKey(), - value != null && normalize(String.valueOf(value)).contains(normalize(meaningHead(stated)))); - check(checks, "extracted expectation stays within the stated expectation: " + entry.getKey(), - value != null && normalize(stated).contains(normalize(String.valueOf(value)))); - } else { - check(checks, "extracted item value matches input: " + entry.getKey(), - valueMatches(value, String.valueOf(entry.getValue()))); - } + Set extractedNames = new TreeSet<>(); + for (Map item : extractedItems) { + extractedNames.add(String.valueOf(item.get("name"))); } + check(checks, "extracted field names match the negotiated items", + extractedNames.equals(new TreeSet<>(itemsInput.keySet()))); if ("propose".equals(api) && input.containsKey("relationship")) { String relationship = String.valueOf(input.get("relationship")); check(checks, "extracted relationship matches input", valueMatches(extractedJson.get("relationship"), relationship)); + } else { + check(checks, "relationship is absent for a single field or ending message", + extractedJson.get("relationship") == null); } - if (!"propose".equals(api)) { - String expectedConclusion = "accept".equals(api) ? "Accept" : "Reject"; - check(checks, "extracted conclusion matches the api", - expectedConclusion.equals(extractedJson.get("conclusion"))); - } - } - - private static Map itemByName(List> items, String name) { - for (Map item : items) { - if (name.equals(item.get("name"))) { - return item; + if ("accept".equals(api) || "reject".equals(api)) { + String payloadKey = "accept".equals(api) ? "value" : "reason"; + for (Map item : extractedItems) { + String name = String.valueOf(item.get("name")); + check(checks, "extracted " + payloadKey + " matches input item: " + name, + valueMatches(item.get(payloadKey), String.valueOf(itemsInput.get(name)))); } } - return null; } /** Bidirectional whitespace-free containment: the extracted value may carry the field label or the bare value. */ @@ -420,17 +367,6 @@ private static boolean valueMatches(Object extracted, String input) { && (extractedText.contains(inputText) || inputText.contains(extractedText)); } - /** - * The meaning head of a propose expectation value: the description before the first sample or enum marker - * (for example "接入端口名称" out of "接入端口名称,如P781-..."). A meaning-plus-sample expectation carries its - * semantics in the head, so an extraction that dropped the head would read as a supplied value - the exact - * description-vs-value confusion the propose contract forbids. - */ - private static String meaningHead(String expectation) { - int index = expectation.indexOf(','); - return index > 0 ? expectation.substring(0, index) : expectation; - } - private static Map errorJson(RuntimeException error) { Map failure = new LinkedHashMap<>(); failure.put("type", error.getClass().getSimpleName()); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/InformationNegotiationSchemas.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/InformationNegotiationSchemas.java similarity index 93% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/InformationNegotiationSchemas.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/InformationNegotiationSchemas.java index 07b73337..77d80edf 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/InformationNegotiationSchemas.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/InformationNegotiationSchemas.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.private_line_complaint.negotiation.shared; +package net.openan.a2at.sample.negotiation.shared; import java.util.Collections; import java.util.LinkedHashMap; @@ -10,6 +10,10 @@ * *

        The three validation APIs intentionally use independent parameter contracts. The contracts describe the * shape of the extracted result while the information-item names and values come from the negotiation message. + * + *

        Shared by every information-negotiation sample path — fromText (natural-language generation) and fromData + * (typed-record generation) — because the validate*AndDataFilling interfaces and the rendered wire format are + * identical for both. */ public final class InformationNegotiationSchemas { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java index 52372baf..dc576d3d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java @@ -4,7 +4,7 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; -import net.openan.a2at.sample.private_line_complaint.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationAgentCard; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationDecision; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationMetadataReader; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java index a89558af..e0d5025f 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java @@ -17,7 +17,7 @@ import java.util.Map; import java.util.Set; import java.util.UUID; -import net.openan.a2at.sample.private_line_complaint.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleEnvironment; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; import net.openan.a2at.sdk.client.A2ATClient; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java index ef1d6f7f..18341d0f 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java @@ -14,7 +14,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; -import net.openan.a2at.sample.private_line_complaint.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleEnvironment; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; import net.openan.a2at.sdk.client.A2ATClient; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java index f9f62a5b..74fa212f 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java @@ -1,13 +1,45 @@ package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -/** One manually labelled natural-language evaluation case. */ +/** + * One manually labelled evaluation case. + * + *

        Each case carries two parallel generation inputs for the same negotiation content: {@code text} drives the + * fromText channel (natural language, LLM extraction) and {@code data} drives the fromData channel (typed records, + * deterministic rendering). Both channels must produce the same {@code completedPrompt}, and the validation APIs + * extract back the {@code expected} values in both cases. + */ public record NegotiationEvaluationCase( String id, String phase, String category, String text, + Map data, String completedPrompt, Map expected) { + + /** Structured items for the fromData generation channel, in prompt-rendering order. */ + public List dataItems() { + List items = new ArrayList<>(); + Object raw = data == null ? null : data.get("items"); + if (raw instanceof List entries) { + for (Object entry : entries) { + if (entry instanceof Map item) { + items.add(new NegotiationItem( + String.valueOf(item.get("name")), String.valueOf(item.get("value")))); + } + } + } + return items; + } + + /** Relationship among the data items, or null when the message carries none. */ + public String dataRelationship() { + Object relationship = data == null ? null : data.get("relationship"); + return relationship == null ? null : String.valueOf(relationship); + } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationFlowCase.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationFlowCase.java index 1acadca9..66c803e0 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationFlowCase.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationFlowCase.java @@ -17,23 +17,4 @@ public Map expectedPropose() { public Map expectedEnding() { return endingCase.expected(); } - - public String clientSupplement() { - String header = "## 客户端补充信息\n"; - if ("accept".equals(decision)) { - return header - + "1. 接入端口名称:" + expectedEnding().get("access_port_name") - + "\n2. 投诉分类:" + expectedEnding().get("complaint_category"); - } - Object reason = expectedEnding().get("access_port_name"); - return header - + "1. 接入端口名称:无法提供,原因:" + reason - + "\n2. 投诉分类:无法提供,原因:" + expectedEnding().get("complaint_category"); - } - - public String endingGenerationText() { - return clientSupplement() - + "\n\n## 服务端协商结论生成要求\n" - + endingCase.text(); - } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java index 88e0b17e..9a5706d1 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -13,7 +13,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; -import net.openan.a2at.sample.private_line_complaint.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleEnvironment; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; import net.openan.a2at.sdk.client.A2ATClient; @@ -23,14 +23,22 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.server.A2ATServer; /** * Runs a selected manually labelled case set against a configured real LLM and writes a JSON report. * - *

        Each case runs a complete negotiation path: propose generation and validation, a manual client - * supplement, then accept/reject generation and validation. Generated prompts are passed directly to - * their matching validation APIs. + *

        Each case runs the full negotiation protocol chain (mirroring NegotiationEvalApp steps 3-7): the server + * generates the propose; the client validates the inbound propose and discovers the fields it is asked to + * supplement; the client generates the accept/reject from that discovery (fromData channel fills exactly the + * discovered fields, fromText channel voices the ending case's natural language); the server validates the ending + * message. The fifth argument selects the generation channel: {@code fromText} (default) or {@code fromData}. */ public final class NegotiationQwenEvaluationMain { @@ -44,6 +52,10 @@ public static void main(String[] args) throws IOException { Path reportPath = args.length > 1 ? Path.of(args[1]) : Path.of("a2a-t-sample", "target", "negotiation-qwen-report.json"); Path processLogPath = args.length > 2 ? Path.of(args[2]) : defaultProcessLogPath(reportPath); String caseSet = args.length > 3 ? args[3].trim() : "full"; + String channel = args.length > 4 ? args[4].trim() : "fromText"; + if (!"fromText".equals(channel) && !"fromData".equals(channel)) { + throw new IllegalArgumentException("Channel must be fromText or fromData: " + channel); + } List testCases = loadCases(caseSet); Map environment = NegotiationSampleEnvironment.read(envPath); requireQwenConfiguration(environment); @@ -55,7 +67,7 @@ public static void main(String[] args) throws IOException { try (NegotiationEvaluationProcessLogger processLogger = new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, processLogPath)) { processLogger.write(runStartedEvent(runId, environment, envPath)); for (NegotiationEvaluationFlowCase testCase : testCases) { - results.add(runCase(client, server, testCase, runId, processLogger)); + results.add(runCase(client, server, testCase, channel, runId, processLogger)); } } @@ -66,6 +78,9 @@ public static void main(String[] args) throws IOException { long endingSucceeded = results.stream() .filter(result -> Boolean.TRUE.equals(result.get("ending_succeeded"))) .count(); + long goldenMatched = results.stream() + .filter(result -> Boolean.TRUE.equals(result.get("golden_matched"))) + .count(); Map report = new LinkedHashMap<>(); report.put("generated_at", Instant.now().toString()); report.put("run_id", runId); @@ -73,13 +88,20 @@ public static void main(String[] args) throws IOException { report.put("base_url", environment.get("A2AT_LLM_BASE_URL")); report.put("git_revision", gitRevision()); report.put("case_set", caseSet); + report.put("channel", channel); report.put("case_ids", testCases.stream().map(NegotiationEvaluationFlowCase::id).toList()); report.put("total", results.size()); report.put("propose_succeeded", proposeSucceeded); report.put("ending_succeeded", endingSucceeded); report.put("passed", passed); report.put("end_to_end_success_rate", (double) passed / results.size()); - report.put("note", "A flow passes when all four generation/validation API calls return successfully. Natural-language output is evaluated by whether the four API calls can be chained successfully."); + report.put("golden_matched", goldenMatched); + report.put("golden_exact_match_rate", (double) goldenMatched / results.size()); + report.put("note", "A flow passes when all four generation/validation API calls return successfully" + + ("fromData".equals(channel) + ? "; the fromData channel additionally requires an exact completedPrompt match because its rendering is deterministic" + : "") + + ". Golden exact matching is an auxiliary diagnostic because semantically equivalent natural-language values may differ in wording."); report.put("process_log", processLogPath.toAbsolutePath().toString()); report.put("cases", results); Files.createDirectories(reportPath.toAbsolutePath().getParent()); @@ -93,22 +115,25 @@ private static Map runCase( A2ATClient client, A2ATServer server, NegotiationEvaluationFlowCase testCase, + String channel, String runId, NegotiationEvaluationProcessLogger processLogger) throws IOException { long startedAt = System.nanoTime(); + boolean fromData = "fromData".equals(channel); Map result = new LinkedHashMap<>(); result.put("id", testCase.id()); result.put("decision", testCase.decision()); result.put("category", testCase.category()); + result.put("channel", channel); result.put("propose_case_id", testCase.proposeCase().id()); result.put("ending_case_id", testCase.endingCase().id()); - result.put("propose_input", testCase.proposeCase().text()); + result.put("propose_input", fromData ? testCase.proposeCase().data() : testCase.proposeCase().text()); result.put("expected_propose", testCase.expectedPropose()); result.put("expected_ending", testCase.expectedEnding()); NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3, NegotiationPerformative.PROPOSE); - result.put("client_supplement", testCase.clientSupplement()); - result.put("ending_input", testCase.endingGenerationText()); + result.put("client_requested_fields", null); + result.put("ending_input", null); result.put("actual_propose", null); result.put("actual_ending", null); result.put("propose_generation_succeeded", false); @@ -117,17 +142,28 @@ private static Map runCase( result.put("ending_validation_succeeded", false); result.put("propose_succeeded", false); result.put("ending_succeeded", false); + result.put("golden_matched", false); List> apiTrace = new ArrayList<>(); result.put("api_trace", apiTrace); try { + // protocol chain (mirrors NegotiationEvalApp steps 3-7): the server proposes, the client validates the + // inbound propose and discovers the fields to fill, then generates the accept/reject from that discovery; + // the server finally validates the ending message MetadataContent propose = generateProposeWithLog( - client, testCase, context, runId, processLogger, apiTrace); + server, testCase, context, channel, runId, processLogger, apiTrace); result.put("propose_generation_succeeded", true); result.put("generated_propose_prompt", propose.promptText()); + boolean proposeGoldenExact = propose.promptText().equals(testCase.proposeCase().completedPrompt()); + result.put("propose_golden_exact", proposeGoldenExact); FilledParamData proposeFilled = validateProposeWithLog( - server, testCase, propose.promptText(), context, runId, processLogger, apiTrace); + client, testCase, propose.promptText(), context, channel, runId, processLogger, apiTrace); result.put("propose_validation_succeeded", true); result.put("actual_propose", proposeFilled.data()); + List requestedFields = requestedFields(proposeFilled, testCase.proposeCase()); + result.put("client_requested_fields", requestedFields); + result.put("ending_input", fromData ? requestedFields : testCase.endingCase().text()); + boolean proposeMatched = expectedValuesMatch(testCase.expectedPropose(), proposeFilled.data()); + result.put("propose_expected_matched", proposeMatched); result.put("propose_context_matched", contextMatches(context, proposeFilled.data())); result.put("propose_succeeded", true); @@ -135,16 +171,23 @@ private static Map runCase( "accept".equals(testCase.decision()) ? NegotiationPerformative.ACCEPT : NegotiationPerformative.REJECT; NegotiationContext responseContext = NegotiationSampleFlow.contextFrom(proposeFilled.data(), endingPerformative); MetadataContent ending = generateEndingWithLog( - server, testCase, responseContext, runId, processLogger, apiTrace); + client, testCase, requestedFields, responseContext, channel, runId, processLogger, apiTrace); result.put("ending_generation_succeeded", true); result.put("generated_ending_prompt", ending.promptText()); + boolean endingGoldenExact = ending.promptText().equals(testCase.endingCase().completedPrompt()); + result.put("ending_golden_exact", endingGoldenExact); FilledParamData endingFilled = validateEndingWithLog( - client, testCase, ending.promptText(), responseContext, runId, processLogger, apiTrace); + server, testCase, ending.promptText(), responseContext, channel, runId, processLogger, apiTrace); result.put("ending_validation_succeeded", true); result.put("actual_ending", endingFilled.data()); + boolean endingMatched = expectedValuesMatch(testCase.expectedEnding(), endingFilled.data()); + result.put("ending_expected_matched", endingMatched); result.put("ending_context_matched", contextMatches(responseContext, endingFilled.data())); result.put("ending_succeeded", true); - result.put("passed", true); + boolean goldenExact = proposeGoldenExact && endingGoldenExact; + result.put("golden_matched", proposeMatched && endingMatched && (!fromData || goldenExact)); + // fromData rendering is deterministic, so a golden mismatch is a real defect, not wording variance + result.put("passed", !fromData || goldenExact); } catch (RuntimeException exception) { result.put("passed", false); result.put("error", errorDetails(exception)); @@ -154,51 +197,60 @@ private static Map runCase( } private static MetadataContent generateProposeWithLog( - A2ATClient client, + A2ATServer server, NegotiationEvaluationFlowCase testCase, NegotiationContext context, + String channel, String runId, NegotiationEvaluationProcessLogger processLogger, List> apiTrace) throws IOException { long startedAt = System.nanoTime(); + NegotiationEvaluationCase proposeCase = testCase.proposeCase(); + boolean fromData = "fromData".equals(channel); + Map request = fromData + ? Map.of("data", proposeCase.data(), "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()) + : Map.of("text", proposeCase.text(), "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()); try { - MetadataContent content = client.generateNegotiationProposePromptFromText( - testCase.proposeCase().text(), context, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_propose", "propose", context, Map.of( - "text", testCase.proposeCase().text(), - "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), Map.of( + MetadataContent content = fromData + ? server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + context, + new InformationProposeContent(proposeCase.dataItems(), proposeCase.dataRelationship())), + NegotiationSampleFlow.PROPOSE_TEMPLATE_URI) + : server.generateNegotiationProposePromptFromText( + proposeCase.text(), context, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_propose", "propose", channel, context, request, Map.of( "prompt", content.promptText(), "template_uri", content.templateUri(), "extension_uri", content.extensionUri()), startedAt, null)); return content; } catch (RuntimeException exception) { - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_propose", "propose", context, Map.of( - "text", testCase.proposeCase().text(), - "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), null, startedAt, exception)); + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_propose", "propose", channel, context, request, null, startedAt, exception)); throw exception; } } private static FilledParamData validateProposeWithLog( - A2ATServer server, + A2ATClient client, NegotiationEvaluationFlowCase testCase, String prompt, NegotiationContext context, + String channel, String runId, NegotiationEvaluationProcessLogger processLogger, List> apiTrace) throws IOException { long startedAt = System.nanoTime(); Map schema = InformationNegotiationSchemas.propose(); try { - FilledParamData filled = server.validateProposePromptAndDataFilling( + FilledParamData filled = client.validateProposePromptAndDataFilling( prompt, context, schema, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_propose_and_fill", "propose", null, Map.of( + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_propose_and_fill", "propose", channel, null, Map.of( "prompt", prompt, "schema", schema, "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), Map.of("filled_data", filled.data()), startedAt, null)); return filled; } catch (RuntimeException exception) { - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_propose_and_fill", "propose", null, Map.of( + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_propose_and_fill", "propose", channel, null, Map.of( "prompt", prompt, "schema", schema, "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), null, startedAt, exception)); @@ -206,43 +258,64 @@ private static FilledParamData validateProposeWithLog( } } + /** + * Generates the client's accept/reject. The client fills exactly the fields it discovered from the inbound + * propose (step 2 output), taking each value/reason from the ending case. + */ private static MetadataContent generateEndingWithLog( - A2ATServer server, + A2ATClient client, NegotiationEvaluationFlowCase testCase, + List requestedFields, NegotiationContext context, + String channel, String runId, NegotiationEvaluationProcessLogger processLogger, List> apiTrace) throws IOException { long startedAt = System.nanoTime(); + boolean fromData = "fromData".equals(channel); + boolean accept = "accept".equals(testCase.decision()); + NegotiationEvaluationCase endingCase = testCase.endingCase(); + List endingItems = filledItems(requestedFields, endingCase); + Map request = fromData + ? Map.of("data", Map.of( + "items", itemsJson(endingItems), + "conclusion", accept ? NegotiationConclusion.ACCEPT.toString() : NegotiationConclusion.REJECT.toString()), + "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()) + : Map.of("text", endingCase.text(), + "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()); try { - String endingInput = testCase.endingGenerationText(); - MetadataContent content = "accept".equals(testCase.decision()) - ? server.generateNegotiationAcceptPromptFromText( - endingInput, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI) - : server.generateNegotiationRejectPromptFromText( - endingInput, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), context, - Map.of("text", endingInput, - "client_supplement", testCase.clientSupplement(), - "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), - Map.of("prompt", content.promptText(), "template_uri", content.templateUri(), + MetadataContent content = fromData + ? accept + ? client.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData( + context, new InformationEndingContent(NegotiationConclusion.ACCEPT, endingItems)), + NegotiationSampleFlow.ENDING_TEMPLATE_URI) + : client.generateNegotiationRejectPromptFromData( + new NegotiationEndingData( + context, new InformationEndingContent(NegotiationConclusion.REJECT, endingItems)), + NegotiationSampleFlow.ENDING_TEMPLATE_URI) + : accept + ? client.generateNegotiationAcceptPromptFromText( + endingCase.text(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI) + : client.generateNegotiationRejectPromptFromText( + endingCase.text(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), channel, context, + request, Map.of("prompt", content.promptText(), "template_uri", content.templateUri(), "extension_uri", content.extensionUri()), startedAt, null)); return content; } catch (RuntimeException exception) { - String endingInput = testCase.endingGenerationText(); - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), context, - Map.of("text", endingInput, - "client_supplement", testCase.clientSupplement(), - "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), null, startedAt, exception)); + writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), channel, context, + request, null, startedAt, exception)); throw exception; } } private static FilledParamData validateEndingWithLog( - A2ATClient client, + A2ATServer server, NegotiationEvaluationFlowCase testCase, String prompt, NegotiationContext context, + String channel, String runId, NegotiationEvaluationProcessLogger processLogger, List> apiTrace) throws IOException { @@ -252,28 +325,77 @@ private static FilledParamData validateEndingWithLog( : InformationNegotiationSchemas.reject(); try { FilledParamData filled = "accept".equals(testCase.decision()) - ? client.validateAcceptPromptAndDataFilling( + ? server.validateAcceptPromptAndDataFilling( prompt, context, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI) - : client.validateRejectPromptAndDataFilling( + : server.validateRejectPromptAndDataFilling( prompt, context, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI); writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_" + testCase.decision() + "_and_fill", - testCase.decision(), null, Map.of("prompt", prompt, "schema", schema, + testCase.decision(), channel, null, Map.of("prompt", prompt, "schema", schema, "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), Map.of("filled_data", filled.data()), startedAt, null)); return filled; } catch (RuntimeException exception) { writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_" + testCase.decision() + "_and_fill", - testCase.decision(), null, Map.of("prompt", prompt, "schema", schema, + testCase.decision(), channel, null, Map.of("prompt", prompt, "schema", schema, "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), null, startedAt, exception)); throw exception; } } + /** + * Field names the client discovered from the inbound propose (step 2 output); falls back to the propose case's + * structured items when the extraction carried none. + */ + private static List requestedFields(FilledParamData filled, NegotiationEvaluationCase proposeCase) { + List names = new ArrayList<>(); + Object raw = filled.data() == null ? null : filled.data().get("items"); + if (raw instanceof List entries) { + for (Object entry : entries) { + if (entry instanceof Map item && item.get("name") != null) { + names.add(String.valueOf(item.get("name"))); + } + } + } + if (names.isEmpty()) { + proposeCase.dataItems().forEach(item -> names.add(item.name())); + } + return names; + } + + /** + * The client fills exactly the fields it discovered from the inbound propose, taking each value (accept) or + * reason (reject) from the ending case's structured items. + */ + private static List filledItems(List requestedFields, NegotiationEvaluationCase endingCase) { + Map byName = new LinkedHashMap<>(); + for (NegotiationItem item : endingCase.dataItems()) { + byName.put(item.name(), item.value()); + } + List items = new ArrayList<>(); + for (String field : requestedFields) { + String value = byName.get(field); + if (value == null) { + return endingCase.dataItems(); + } + items.add(new NegotiationItem(field, value)); + } + return items; + } + + private static List> itemsJson(List items) { + List> json = new ArrayList<>(); + for (NegotiationItem item : items) { + json.add(Map.of("name", item.name(), "value", String.valueOf(item.value()))); + } + return json; + } + private static Map stageEvent( String runId, NegotiationEvaluationFlowCase testCase, String stage, String phase, + String channel, NegotiationContext context, Map request, Map response, @@ -286,9 +408,10 @@ private static Map stageEvent( int step = stageStep(stage); event.put("step", step); event.put("step_label", step + "/4"); - event.put("api", stageApi(stage)); + event.put("api", stageApi(stage, channel)); event.put("caller", stageCaller(stage)); event.put("phase", phase); + event.put("channel", channel); event.put("decision", testCase.decision()); event.put("stage", stage); event.put("expected", "propose".equals(phase) ? testCase.expectedPropose() : testCase.expectedEnding()); @@ -325,21 +448,24 @@ private static int stageStep(String stage) { return stage.startsWith("generate_") ? 3 : 4; } - private static String stageApi(String stage) { - return switch (stage) { - case "generate_propose" -> "A2ATClient.generateNegotiationProposePromptFromText"; - case "validate_propose_and_fill" -> "A2ATServer.validateProposePromptAndDataFilling"; - case "generate_accept" -> "A2ATServer.generateNegotiationAcceptPromptFromText"; - case "validate_accept_and_fill" -> "A2ATClient.validateAcceptPromptAndDataFilling"; - case "generate_reject" -> "A2ATServer.generateNegotiationRejectPromptFromText"; - case "validate_reject_and_fill" -> "A2ATClient.validateRejectPromptAndDataFilling"; + private static String stageApi(String stage, String channel) { + String api = switch (stage) { + case "generate_propose" -> "A2ATServer.generateNegotiationProposePromptFromText"; + case "validate_propose_and_fill" -> "A2ATClient.validateProposePromptAndDataFilling"; + case "generate_accept" -> "A2ATClient.generateNegotiationAcceptPromptFromText"; + case "validate_accept_and_fill" -> "A2ATServer.validateAcceptPromptAndDataFilling"; + case "generate_reject" -> "A2ATClient.generateNegotiationRejectPromptFromText"; + case "validate_reject_and_fill" -> "A2ATServer.validateRejectPromptAndDataFilling"; default -> stage; }; + return "fromData".equals(channel) ? api.replace("FromText", "FromData") : api; } private static String stageCaller(String stage) { - return stage.startsWith("generate_propose") || stage.startsWith("validate_accept") - || stage.startsWith("validate_reject") ? "client" : "server"; + if ("generate_propose".equals(stage)) { + return "server"; + } + return stage.startsWith("validate_propose") || stage.startsWith("generate_") ? "client" : "server"; } private static Map runStartedEvent(String runId, Map environment, Path envPath) { @@ -427,6 +553,45 @@ private static Path defaultProcessLogPath(Path reportPath) { return parent == null ? Path.of(logFilename) : parent.resolve(logFilename); } + /** + * Matches the labelled expected values against the extracted items of the shared information-negotiation + * schemas ({@code items: [{name, requirement|value|reason}]}). Bidirectional containment after whitespace + * removal: the extracted payload may carry the full line text while the label records only the value or reason. + */ + private static boolean expectedValuesMatch(Map expected, Map actual) { + Map actualByName = itemsByName(actual); + return expected.entrySet().stream().allMatch(entry -> { + Object value = actualByName.get(entry.getKey()); + return value != null && valuesTransport(String.valueOf(entry.getValue()), String.valueOf(value)); + }); + } + + private static Map itemsByName(Map actual) { + Map byName = new LinkedHashMap<>(); + Object raw = actual.get("items"); + if (raw instanceof List entries) { + for (Object entry : entries) { + if (entry instanceof Map item && item.get("name") != null) { + Object payload = item.get("requirement"); + if (payload == null) { + payload = item.get("value"); + } + if (payload == null) { + payload = item.get("reason"); + } + byName.put(String.valueOf(item.get("name")), payload); + } + } + } + return byName; + } + + private static boolean valuesTransport(String expected, String actual) { + String expectedText = expected.replaceAll("\\s+", ""); + String actualText = actual.replaceAll("\\s+", ""); + return !expectedText.isEmpty() && (actualText.contains(expectedText) || expectedText.contains(actualText)); + } + private static boolean contextMatches(NegotiationContext context, Map actual) { return context.id().equals(actual.get("id")) && numberEquals(context.round(), actual.get("round")) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java index 5673e0e9..e58aceeb 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java @@ -2,7 +2,7 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sample.private_line_complaint.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationDecision; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationMetadataReader; import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java index 5a315730..4d244f31 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java @@ -10,6 +10,7 @@ import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; /** Runs the six Negotiation-T APIs used by the private-line complaint sample. */ public final class NegotiationSampleFlow { diff --git a/a2a-t-sample/src/main/resources/sample/negotiation/eval/fromdata-api-suite.json b/a2a-t-sample/src/main/resources/sample/negotiation/eval/fromdata-api-suite.json index fb3f09f3..2162077d 100644 --- a/a2a-t-sample/src/main/resources/sample/negotiation/eval/fromdata-api-suite.json +++ b/a2a-t-sample/src/main/resources/sample/negotiation/eval/fromdata-api-suite.json @@ -1,6 +1,6 @@ { "suite": "negotiation-fromdata-api-eval", - "description": "Focused capability verification of the six Negotiation-T fromData interfaces: generateNegotiationProposePromptFromData, generateNegotiationAcceptPromptFromData, generateNegotiationRejectPromptFromData and their validate-and-extract counterparts. Inputs are traditional structured data - every item is one key (the slot name from the private-line-complaint Task-T template) with a value per the message type: propose carries the field expectation as a meaning-plus-sample description (the propose template's own requirement: name + meaning/format/sample), accept carries REAL field values (a live port name, an actual category value, a real event id), reject carries the unprovidable field name with its reason plus an explicit \"无法提供\" statement (the accept-reject template's own requirement: name + reason of non-provision). No natural-language passages, no format-spec placeholders, no template-error scenarios. The case matrix is built from the Task-T field model (required: 任务对象/投诉分类/OSS侧事件流水号; optional: 问题发生时间/投诉详情): propose carries every required/optional slot combination with real values (all required, each single required, dual required, required+optional, optional-only, empty boundary), accept supplies the filled values for the same combinations, reject carries the unprovidable fields with reasons. Generation is deterministic rendering (no LLM call); the validate-and-extract step asserts the extracted parameters carry back exactly the input key-value pairs.", + "description": "Focused capability verification of the six Negotiation-T fromData interfaces. The private-line complaint Task-T template is the business source of truth: 任务对象、投诉分类、OSS侧事件流水号 are required; 问题发生时间、投诉详情 are optional and their absence must never initiate negotiation. Propose covers all seven non-empty combinations of the three required fields plus the empty boundary. Multiple top-level fields have an AND relationship; a single field has a null relationship; the three alternative representations inside 任务对象 have an OR relationship. Accept returns real values for requested required fields, while Reject returns an itemized reason containing 无法提供. Generation checks message values; validation extracts the exact field-name list, accept/reject payloads, and relationship through the shared InformationNegotiationSchemas contracts (items:[{name, requirement|value|reason}]) also used by the fromText samples.", "scenario": "private-line-complaint", "fields": { "required": [ @@ -24,7 +24,7 @@ "投诉分类": "投诉场景分类,取值专线中断或专线质差", "OSS侧事件流水号": "OSS侧事件流水号,如event-id-20260602-08841" }, - "relationship": "以上参数均为必选,缺少无法启动诊断" + "relationship": "任务对象、投诉分类和OSS侧事件流水号三项均为必选,三项之间为AND关系;缺少任一项均无法启动诊断" }, "expect": { "succeeds": true, @@ -38,8 +38,7 @@ "input": { "items": { "任务对象": "专线业务对象标识,三选一:专线业务名称(如SZ_汇聚专线业务_100M_SPN)、专线业务标识(如5571d707-5aad-11ea-8629-286ed488cff3)、接入端口名称(如P781-珠江新城-PTN7900-23-TPA1EG24-17)" - }, - "relationship": "以上参数均为必选,缺少无法启动诊断" + } }, "expect": { "succeeds": true, @@ -53,8 +52,7 @@ "input": { "items": { "投诉分类": "投诉场景分类,取值专线中断或专线质差" - }, - "relationship": "以上参数均为必选,缺少无法启动诊断" + } }, "expect": { "succeeds": true, @@ -68,8 +66,7 @@ "input": { "items": { "OSS侧事件流水号": "OSS侧事件流水号,如event-id-20260602-08841" - }, - "relationship": "以上参数均为必选,缺少无法启动诊断" + } }, "expect": { "succeeds": true, @@ -85,7 +82,7 @@ "任务对象": "专线业务对象标识,三选一:专线业务名称(如SZ_汇聚专线业务_100M_SPN)、专线业务标识(如5571d707-5aad-11ea-8629-286ed488cff3)、接入端口名称(如P781-珠江新城-PTN7900-23-TPA1EG24-17)", "投诉分类": "投诉场景分类,取值专线中断或专线质差" }, - "relationship": "以上参数均为必选,缺少无法启动诊断" + "relationship": "任务对象和投诉分类均为必选,两项之间为AND关系;缺少任一项均无法启动诊断" }, "expect": { "succeeds": true, @@ -95,13 +92,13 @@ { "id": "FD-P06", "api": "propose", - "intent": "propose:请求补充必选+可选槽位组合(OSS流水号+问题发生时间)", + "intent": "propose:请求补充双必选槽位(任务对象+OSS侧事件流水号)", "input": { "items": { - "OSS侧事件流水号": "OSS侧事件流水号,如event-id-20260602-08841", - "问题发生时间": "故障发生时间,如2026-06-02T14:30:00Z" + "任务对象": "专线业务对象标识,三选一:专线业务名称(如SZ_汇聚专线业务_100M_SPN)、专线业务标识(如5571d707-5aad-11ea-8629-286ed488cff3)、接入端口名称(如P781-珠江新城-PTN7900-23-TPA1EG24-17)", + "OSS侧事件流水号": "OSS侧事件流水号,如event-id-20260602-08841" }, - "relationship": "以上参数为诊断所需,缺少影响定位效率" + "relationship": "任务对象和OSS侧事件流水号均为必选,两项之间为AND关系;缺少任一项均无法启动诊断" }, "expect": { "succeeds": true, @@ -111,13 +108,13 @@ { "id": "FD-P07", "api": "propose", - "intent": "propose:请求补充仅可选槽位组合(问题发生时间+投诉详情),边界组合", + "intent": "propose:请求补充双必选槽位(投诉分类+OSS侧事件流水号)", "input": { "items": { - "问题发生时间": "故障发生时间,如2026-06-02T14:30:00Z", - "投诉详情": "故障现象文字描述" + "投诉分类": "投诉场景分类,取值专线中断或专线质差", + "OSS侧事件流水号": "OSS侧事件流水号,如event-id-20260602-08841" }, - "relationship": "以上参数为可选补充信息" + "relationship": "投诉分类和OSS侧事件流水号均为必选,两项之间为AND关系;缺少任一项均无法启动诊断" }, "expect": { "succeeds": true, @@ -129,8 +126,7 @@ "api": "propose", "intent": "propose:items 为空对象(数据边界;接口应拒绝零请求项——PR #151 修复验证用例)", "input": { - "items": {}, - "relationship": "以上参数均为必选,缺少无法启动诊断" + "items": {} }, "expect": { "succeeds": false @@ -175,14 +171,10 @@ { "id": "FD-A03", "api": "accept", - "intent": "accept:补齐全部字段(必选+可选共 5 字段)", + "intent": "accept:补齐单必选字段(投诉分类)", "input": { "items": { - "任务对象": "SZ_汇聚专线业务_100M_SPN", - "投诉分类": "专线中断", - "OSS侧事件流水号": "event-id-20260602-08841", - "问题发生时间": "2026-06-02T14:30:00Z", - "投诉详情": "深圳访问广州响应延迟骤升,交易接口频繁报连接超时" + "投诉分类": "专线中断" } }, "expect": { @@ -196,11 +188,10 @@ { "id": "FD-A04", "api": "accept", - "intent": "accept:补齐可选字段(问题发生时间+投诉详情)", + "intent": "accept:补齐单必选字段(OSS侧事件流水号)", "input": { "items": { - "问题发生时间": "2026-06-02T14:30:00Z", - "投诉详情": "深圳访问广州响应延迟骤升,交易接口频繁报连接超时" + "OSS侧事件流水号": "event-id-20260602-08841" } }, "expect": { diff --git a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json index 4c9eb97b..3fb9064e 100644 --- a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json +++ b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/cases.json @@ -1,102 +1,102 @@ [ - {"id":"P01","phase":"propose","category":"complete","text":"请发起信息协商,补充接入端口名称和投诉分类;端口可为物理或逻辑端口,分类限专线中断或专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P02","phase":"propose","category":"complete","text":"专线投诉诊断需要对端提供端口名和投诉类型,端口接受物理、逻辑名称,类型为专线中断或专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P03","phase":"propose","category":"paraphrase","text":"麻烦补齐本次专线投诉的接入端口及故障分类,端口要能定位到物理或逻辑接口,故障仅在中断、质差两类中选择。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P04","phase":"propose","category":"paraphrase","text":"发起信息协商:请给出物理端口或逻辑端口名称,并说明投诉属于专线中断还是专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P05","phase":"propose","category":"short","text":"请补充端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P06","phase":"propose","category":"short","text":"需要协商获取接入端口名和专线投诉类别:端口支持物理或逻辑,类别为中断或质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P07","phase":"propose","category":"formal","text":"为开展传输专线业务投诉诊断,请对端补充接入端口标识和投诉分类。其中端口标识应为物理或逻辑端口名称,投诉分类可取专线中断、专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P08","phase":"propose","category":"formal","text":"当前诊断参数不完整。请求协商获得:1)接入物理/逻辑端口名称;2)投诉分类,候选值为专线中断、专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P09","phase":"propose","category":"punctuation","text":"请补充接入端口名称;物理端口、逻辑端口均可。还请确认投诉分类:专线中断?专线质差?","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P10","phase":"propose","category":"punctuation","text":"信息协商请求【端口:物理或逻辑端口名称】【投诉:专线中断或专线质差】。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P11","phase":"propose","category":"context","text":"客户反映业务异常,尚无法确定网络侧故障。请提供接入端口名称(物理或逻辑均可)以及投诉分类(专线中断或专线质差),以便继续诊断。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P12","phase":"propose","category":"context","text":"工单号已登记,但缺少诊断关键信息。请信息协商补充物理/逻辑接入端口名和专线中断/专线质差的投诉类别。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P13","phase":"propose","category":"synonym","text":"请对端回填入网接口名称(可填物理口或逻辑口)及专线故障现象分类(中断、质差)。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P14","phase":"propose","category":"synonym","text":"需要拿到接入侧端口标识,物理接口或逻辑接口均满足;同时确定本次专线投诉是断线还是质量劣化。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P15","phase":"propose","category":"multisentence","text":"请启动信息协商。诊断需要端口信息。请提供物理端口或逻辑端口名称。另请说明投诉分类是专线中断还是专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P16","phase":"propose","category":"multisentence","text":"当前不能直接定位故障。先确认接入端口名称,物理、逻辑名称都可以;再确认投诉类型,只允许专线中断和专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P17","phase":"propose","category":"polite","text":"烦请协助补充专线接入端口名称(物理或逻辑端口均可)与投诉分类(专线中断或专线质差),谢谢。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P18","phase":"propose","category":"polite","text":"请您确认一下接入端口是哪个物理/逻辑端口,并选择投诉为专线中断还是专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P19","phase":"propose","category":"order-reversed","text":"先请判定投诉类别:专线中断或专线质差;随后提供接入端口名称,物理端口或逻辑端口均可。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P20","phase":"propose","category":"order-reversed","text":"协商所需字段包括投诉分类(专线中断、专线质差)和接入端口名称(物理或逻辑端口)。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P21","phase":"propose","category":"mixed-language","text":"Please provide the access port name, either physical or logical, and classify this private-line complaint as 专线中断 or 专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P22","phase":"propose","category":"mixed-language","text":"发起 negotiation,需补充 access port(物理端口或逻辑端口名称)及 complaint category(专线中断或专线质差)。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P23","phase":"propose","category":"noise","text":"客户联系人为张三,联系电话已脱敏。请忽略这些非诊断信息,补充物理或逻辑接入端口名称,以及专线中断或专线质差的投诉分类。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P24","phase":"propose","category":"noise","text":"告警数量暂不确定,工单优先级为P2。请协商取得接入端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P25","phase":"propose","category":"business","text":"SPN专线投诉根因诊断前,请对端提供承载该业务的物理或逻辑接入端口名,并标注故障属于专线中断还是专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P26","phase":"propose","category":"business","text":"传输工作台需补齐入参:接入端口的物理/逻辑名称、专线投诉的中断/质差分类,请发起协商。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P27","phase":"propose","category":"imperative","text":"向对端要端口名和投诉类别。端口名必须是物理或者逻辑端口;类别限定专线中断、专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P28","phase":"propose","category":"imperative","text":"补齐以下两项再诊断:物理/逻辑接入端口名称;专线中断/专线质差投诉分类。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P29","phase":"propose","category":"long","text":"本次请求用于传输专线业务投诉诊断,现有描述只说明客户感知业务异常,尚不具备定位条件。请通过信息协商要求对端补充接入端口名称,值可以是物理端口名称或逻辑端口名称;同时要求补充投诉分类,允许的值为专线中断或专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P30","phase":"propose","category":"long","text":"为避免将客户侧问题误判为网络问题,需要先获取网络定位参数。请请求对端给出接入端口的物理或逻辑标识,并在专线中断、专线质差两个候选项中说明投诉类型。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P31","phase":"propose","category":"format","text":"字段1:接入端口名称,要求:物理端口或逻辑端口名称。字段2:投诉分类,要求:专线中断或专线质差。请发起协商。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P32","phase":"propose","category":"format","text":"需对端回复:access_port_name=物理端口或逻辑端口名称;complaint_category=专线中断或专线质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P33","phase":"propose","category":"colloquial","text":"这单还差端口和投诉类型,麻烦要一下:端口给物理口或逻辑口都行,类型就在专线中断、专线质差里选。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"P34","phase":"propose","category":"colloquial","text":"先把专线的接入端口名和故障分类问清楚吧,端口支持物理/逻辑,分类只有中断或质差。","completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"access_port_name":"物理端口或逻辑端口名称","complaint_category":"专线中断或专线质差"}}, - {"id":"A01","phase":"accept","category":"complete","text":"接受本次协商。接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A02","phase":"accept","category":"complete","text":"同意补充信息:物理端口 P782-天河-PTN7900-01-GE0/1/2,故障类型是专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A03","phase":"accept","category":"paraphrase","text":"接受。用于诊断的逻辑接入端口是 LAG-广州-001.100,客户投诉为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A04","phase":"accept","category":"paraphrase","text":"可以提供:端口名为 PE1-GZ-ETH-0/0/0.200,专线已中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A05","phase":"accept","category":"short","text":"接受,端口 NCE-PTN-23-1/1/7,投诉:专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A06","phase":"accept","category":"short","text":"Accept。access port=SPN-广州-1/0/24;分类=专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A07","phase":"accept","category":"formal","text":"接受该信息协商请求。补充的接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类确定为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A08","phase":"accept","category":"formal","text":"经资源核查,接受协商并反馈:物理接口 P782-天河-PTN7900-01-GE0/1/2;本次投诉为专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A09","phase":"accept","category":"punctuation","text":"接受!接入端口:LAG-广州-001.100;投诉分类:专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A10","phase":"accept","category":"punctuation","text":"同意回复【端口=PE1-GZ-ETH-0/0/0.200】【类型=专线中断】。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A11","phase":"accept","category":"context","text":"客户报障期间业务偶发丢包,接受协商,确认接入端口为 NCE-PTN-23-1/1/7,投诉分类为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A12","phase":"accept","category":"context","text":"告警显示链路不可达。接受本次协商,端口为 SPN-广州-1/0/24,归类为专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A13","phase":"accept","category":"synonym","text":"同意提供入网接口标识:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),业务质量劣化属于专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A14","phase":"accept","category":"synonym","text":"接受,承载接口为 P782-天河-PTN7900-01-GE0/1/2,客户反映断线,对应专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A15","phase":"accept","category":"multisentence","text":"接受协商。端口名称是 LAG-广州-001.100。投诉分类选择专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A16","phase":"accept","category":"multisentence","text":"同意。已查到 PE1-GZ-ETH-0/0/0.200。该专线业务当前中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A17","phase":"accept","category":"order-reversed","text":"投诉分类为专线质差,接入端口名称为 NCE-PTN-23-1/1/7,接受本次信息协商。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A18","phase":"accept","category":"order-reversed","text":"专线中断;端口 SPN-广州-1/0/24。接受协商请求。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A19","phase":"accept","category":"mixed-language","text":"Accept this negotiation. The access port is P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100), complaint category is 专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A20","phase":"accept","category":"mixed-language","text":"接受,port=P782-天河-PTN7900-01-GE0/1/2,category=专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A21","phase":"accept","category":"noise","text":"工单优先级P1,联系人已通知。接受并补充端口 LAG-广州-001.100,投诉为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A22","phase":"accept","category":"noise","text":"客户地址不在本次处理范围。接受协商:PE1-GZ-ETH-0/0/0.200,专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A23","phase":"accept","category":"business","text":"资源系统查询完成,接受信息协商。诊断接入端口 NCE-PTN-23-1/1/7,故障投诉分类为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A24","phase":"accept","category":"business","text":"传输侧确认接受:业务承载端口 SPN-广州-1/0/24,故障现象按专线中断处理。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A25","phase":"accept","category":"format","text":"接受;access_port_name=P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100);complaint_category=专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A26","phase":"accept","category":"format","text":"结果:接受。接入端口名称=P782-天河-PTN7900-01-GE0/1/2;投诉分类=专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A27","phase":"accept","category":"colloquial","text":"这次协商同意,端口就是 LAG-广州-001.100,客户说网慢,按专线质差算。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"A28","phase":"accept","category":"colloquial","text":"可以,查到了 PE1-GZ-ETH-0/0/0.200,这条专线断了。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"access_port_name":"PE1-GZ-ETH-0/0/0.200","complaint_category":"专线中断"}}, - {"id":"A29","phase":"accept","category":"long","text":"我方接受本次信息协商请求。资源查询结果表明,客户专线接入在 NCE-PTN-23-1/1/7;目前业务未完全中断,但存在持续高丢包和时延异常,因此投诉分类填写为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"access_port_name":"NCE-PTN-23-1/1/7","complaint_category":"专线质差"}}, - {"id":"A30","phase":"accept","category":"long","text":"经现场及网管联合核查,接受协商并返回所需信息:接入端口为 SPN-广州-1/0/24。该端口承载业务不可达,用户侧表现为完全断线,投诉分类为专线中断。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"access_port_name":"SPN-广州-1/0/24","complaint_category":"专线中断"}}, - {"id":"A31","phase":"accept","category":"whitespace","text":"接受本次协商。\n接入端口:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n投诉分类:专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"access_port_name":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","complaint_category":"专线质差"}}, - {"id":"A32","phase":"accept","category":"whitespace","text":"接受。\n端口 P782-天河-PTN7900-01-GE0/1/2\n类别 专线中断","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"access_port_name":"P782-天河-PTN7900-01-GE0/1/2","complaint_category":"专线中断"}}, - {"id":"A33","phase":"accept","category":"explicit","text":"明确接受信息协商,唯一需要填报的端口是 LAG-广州-001.100,唯一投诉分类为专线质差。","completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"access_port_name":"LAG-广州-001.100","complaint_category":"专线质差"}}, - {"id":"R01","phase":"reject","category":"complete","text":"拒绝本次信息协商,当前账号没有资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前账号没有资源系统查询权限\n2. 投诉分类:无法提供,原因:当前账号没有资源系统查询权限","expected":{"access_port_name":"当前账号没有资源系统查询权限","complaint_category":"当前账号没有资源系统查询权限"}}, - {"id":"R02","phase":"reject","category":"complete","text":"拒绝,原因是资源系统正在维护,暂时无法查询端口信息。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统正在维护,暂时无法查询端口信息\n2. 投诉分类:无法提供,原因:资源系统正在维护,暂时无法查询端口信息","expected":{"access_port_name":"资源系统正在维护,暂时无法查询端口信息","complaint_category":"资源系统正在维护,暂时无法查询端口信息"}}, - {"id":"R03","phase":"reject","category":"paraphrase","text":"无法接受该协商请求,因为我方账号无权访问资源管理系统。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:我方账号无权访问资源管理系统\n2. 投诉分类:无法提供,原因:我方账号无权访问资源管理系统","expected":{"access_port_name":"我方账号无权访问资源管理系统","complaint_category":"我方账号无权访问资源管理系统"}}, - {"id":"R04","phase":"reject","category":"paraphrase","text":"本次不能补充信息,资源库维护中,端口数据不可用。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源库维护中,端口数据不可用\n2. 投诉分类:无法提供,原因:资源库维护中,端口数据不可用","expected":{"access_port_name":"资源库维护中,端口数据不可用","complaint_category":"资源库维护中,端口数据不可用"}}, - {"id":"R05","phase":"reject","category":"short","text":"拒绝:无资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:无资源系统查询权限\n2. 投诉分类:无法提供,原因:无资源系统查询权限","expected":{"access_port_name":"无资源系统查询权限","complaint_category":"无资源系统查询权限"}}, - {"id":"R06","phase":"reject","category":"short","text":"Reject,资源系统维护中。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统维护中\n2. 投诉分类:无法提供,原因:资源系统维护中","expected":{"access_port_name":"资源系统维护中","complaint_category":"资源系统维护中"}}, - {"id":"R07","phase":"reject","category":"formal","text":"拒绝本次信息协商请求,原因是当前操作账号未被授予资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前操作账号未被授予资源系统查询权限\n2. 投诉分类:无法提供,原因:当前操作账号未被授予资源系统查询权限","expected":{"access_port_name":"当前操作账号未被授予资源系统查询权限","complaint_category":"当前操作账号未被授予资源系统查询权限"}}, - {"id":"R08","phase":"reject","category":"formal","text":"经确认,资源系统处于计划维护窗口,暂不具备提供端口信息的条件,故拒绝协商。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统处于计划维护窗口,暂不具备提供端口信息的条件\n2. 投诉分类:无法提供,原因:资源系统处于计划维护窗口,暂不具备提供端口信息的条件","expected":{"access_port_name":"资源系统处于计划维护窗口,暂不具备提供端口信息的条件","complaint_category":"资源系统处于计划维护窗口,暂不具备提供端口信息的条件"}}, - {"id":"R09","phase":"reject","category":"punctuation","text":"拒绝!原因:当前账号没有资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前账号没有资源系统查询权限\n2. 投诉分类:无法提供,原因:当前账号没有资源系统查询权限","expected":{"access_port_name":"当前账号没有资源系统查询权限","complaint_category":"当前账号没有资源系统查询权限"}}, - {"id":"R10","phase":"reject","category":"punctuation","text":"无法接受【资源系统维护,端口查询暂停】。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统维护,端口查询暂停\n2. 投诉分类:无法提供,原因:资源系统维护,端口查询暂停","expected":{"access_port_name":"资源系统维护,端口查询暂停","complaint_category":"资源系统维护,端口查询暂停"}}, - {"id":"R11","phase":"reject","category":"context","text":"工单已受理,但资源侧权限申请尚未完成,因此拒绝本次协商。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源侧权限申请尚未完成\n2. 投诉分类:无法提供,原因:资源侧权限申请尚未完成","expected":{"access_port_name":"资源侧权限申请尚未完成","complaint_category":"资源侧权限申请尚未完成"}}, - {"id":"R12","phase":"reject","category":"context","text":"网管告警很多,但资源系统服务异常,当前不能查询端口,拒绝补充。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统服务异常,当前不能查询端口\n2. 投诉分类:无法提供,原因:资源系统服务异常,当前不能查询端口","expected":{"access_port_name":"资源系统服务异常,当前不能查询端口","complaint_category":"资源系统服务异常,当前不能查询端口"}}, - {"id":"R13","phase":"reject","category":"synonym","text":"不接受协商,因账号没有资产资源库的访问授权。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:账号没有资产资源库的访问授权\n2. 投诉分类:无法提供,原因:账号没有资产资源库的访问授权","expected":{"access_port_name":"账号没有资产资源库的访问授权","complaint_category":"账号没有资产资源库的访问授权"}}, - {"id":"R14","phase":"reject","category":"synonym","text":"无法给出所需数据,CMDB资源查询服务正在检修。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:CMDB资源查询服务正在检修\n2. 投诉分类:无法提供,原因:CMDB资源查询服务正在检修","expected":{"access_port_name":"CMDB资源查询服务正在检修","complaint_category":"CMDB资源查询服务正在检修"}}, - {"id":"R15","phase":"reject","category":"multisentence","text":"拒绝本次请求。当前账号没有资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前账号没有资源系统查询权限\n2. 投诉分类:无法提供,原因:当前账号没有资源系统查询权限","expected":{"access_port_name":"当前账号没有资源系统查询权限","complaint_category":"当前账号没有资源系统查询权限"}}, - {"id":"R16","phase":"reject","category":"multisentence","text":"暂不接受。资源系统正在维护。恢复时间未知。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统正在维护\n2. 投诉分类:无法提供,原因:资源系统正在维护","expected":{"access_port_name":"资源系统正在维护","complaint_category":"资源系统正在维护"}}, - {"id":"R17","phase":"reject","category":"order-reversed","text":"因资源侧权限尚未开通,拒绝这次信息协商。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源侧权限尚未开通\n2. 投诉分类:无法提供,原因:资源侧权限尚未开通","expected":{"access_port_name":"资源侧权限尚未开通","complaint_category":"资源侧权限尚未开通"}}, - {"id":"R18","phase":"reject","category":"order-reversed","text":"资源查询接口超时,无法返回端口信息,故拒绝。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源查询接口超时,无法返回端口信息\n2. 投诉分类:无法提供,原因:资源查询接口超时,无法返回端口信息","expected":{"access_port_name":"资源查询接口超时,无法返回端口信息","complaint_category":"资源查询接口超时,无法返回端口信息"}}, - {"id":"R19","phase":"reject","category":"mixed-language","text":"Reject this negotiation because the current account has no resource-system query permission。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:the current account has no resource-system query permission\n2. 投诉分类:无法提供,原因:the current account has no resource-system query permission","expected":{"access_port_name":"the current account has no resource-system query permission","complaint_category":"the current account has no resource-system query permission"}}, - {"id":"R20","phase":"reject","category":"mixed-language","text":"拒绝,resource system is under maintenance,无法查询端口。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:resource system is under maintenance,无法查询端口\n2. 投诉分类:无法提供,原因:resource system is under maintenance,无法查询端口","expected":{"access_port_name":"resource system is under maintenance,无法查询端口","complaint_category":"resource system is under maintenance,无法查询端口"}}, - {"id":"R21","phase":"reject","category":"noise","text":"联系人已收到短信,工单优先级为P2。拒绝原因是当前账号没有资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前账号没有资源系统查询权限\n2. 投诉分类:无法提供,原因:当前账号没有资源系统查询权限","expected":{"access_port_name":"当前账号没有资源系统查询权限","complaint_category":"当前账号没有资源系统查询权限"}}, - {"id":"R22","phase":"reject","category":"noise","text":"客户地址信息无需处理。由于资源系统维护,无法取得端口数据,拒绝协商。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统维护,无法取得端口数据\n2. 投诉分类:无法提供,原因:资源系统维护,无法取得端口数据","expected":{"access_port_name":"资源系统维护,无法取得端口数据","complaint_category":"资源系统维护,无法取得端口数据"}}, - {"id":"R23","phase":"reject","category":"business","text":"传输资源库未对本租户开放查询能力,无法补充专线端口,拒绝本次信息协商。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:传输资源库未对本租户开放查询能力\n2. 投诉分类:无法提供,原因:传输资源库未对本租户开放查询能力","expected":{"access_port_name":"传输资源库未对本租户开放查询能力","complaint_category":"传输资源库未对本租户开放查询能力"}}, - {"id":"R24","phase":"reject","category":"business","text":"资源同步任务延迟,网管侧尚未获得该专线端口映射,故暂时拒绝回复。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源同步任务延迟,网管侧尚未获得该专线端口映射\n2. 投诉分类:无法提供,原因:资源同步任务延迟,网管侧尚未获得该专线端口映射","expected":{"access_port_name":"资源同步任务延迟,网管侧尚未获得该专线端口映射","complaint_category":"资源同步任务延迟,网管侧尚未获得该专线端口映射"}}, - {"id":"R25","phase":"reject","category":"format","text":"拒绝;rejection_reason=当前账号没有资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前账号没有资源系统查询权限\n2. 投诉分类:无法提供,原因:当前账号没有资源系统查询权限","expected":{"access_port_name":"当前账号没有资源系统查询权限","complaint_category":"当前账号没有资源系统查询权限"}}, - {"id":"R26","phase":"reject","category":"format","text":"结果:Reject。原因:资源系统维护,暂不能查询。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统维护,暂不能查询\n2. 投诉分类:无法提供,原因:资源系统维护,暂不能查询","expected":{"access_port_name":"资源系统维护,暂不能查询","complaint_category":"资源系统维护,暂不能查询"}}, - {"id":"R27","phase":"reject","category":"colloquial","text":"这次先不接,账号没权限查资源库。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:账号没权限查资源库\n2. 投诉分类:无法提供,原因:账号没权限查资源库","expected":{"access_port_name":"账号没权限查资源库","complaint_category":"账号没权限查资源库"}}, - {"id":"R28","phase":"reject","category":"colloquial","text":"查不了,资源系统在检修,拒绝吧。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统在检修\n2. 投诉分类:无法提供,原因:资源系统在检修","expected":{"access_port_name":"资源系统在检修","complaint_category":"资源系统在检修"}}, - {"id":"R29","phase":"reject","category":"long","text":"我方已尝试通过资源管理系统查询该专线的接入端口和投诉信息,但当前使用的运维账号没有被授权访问相关资源域。由于无法保证返回数据的正确性,拒绝本次信息协商。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前使用的运维账号没有被授权访问相关资源域\n2. 投诉分类:无法提供,原因:当前使用的运维账号没有被授权访问相关资源域","expected":{"access_port_name":"当前使用的运维账号没有被授权访问相关资源域","complaint_category":"当前使用的运维账号没有被授权访问相关资源域"}}, - {"id":"R30","phase":"reject","category":"long","text":"本次协商暂不能接受。资源系统正在执行例行维护,端口与业务映射查询功能已暂停;在服务恢复前无法提供可靠的补充信息。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统正在执行例行维护,端口与业务映射查询功能已暂停\n2. 投诉分类:无法提供,原因:资源系统正在执行例行维护,端口与业务映射查询功能已暂停","expected":{"access_port_name":"资源系统正在执行例行维护,端口与业务映射查询功能已暂停","complaint_category":"资源系统正在执行例行维护,端口与业务映射查询功能已暂停"}}, - {"id":"R31","phase":"reject","category":"whitespace","text":"拒绝本次协商。\n原因:当前账号没有资源系统查询权限。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前账号没有资源系统查询权限\n2. 投诉分类:无法提供,原因:当前账号没有资源系统查询权限","expected":{"access_port_name":"当前账号没有资源系统查询权限","complaint_category":"当前账号没有资源系统查询权限"}}, - {"id":"R32","phase":"reject","category":"whitespace","text":"Reject\n资源查询服务异常,暂无法取得端口信息。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源查询服务异常,暂无法取得端口信息\n2. 投诉分类:无法提供,原因:资源查询服务异常,暂无法取得端口信息","expected":{"access_port_name":"资源查询服务异常,暂无法取得端口信息","complaint_category":"资源查询服务异常,暂无法取得端口信息"}}, - {"id":"R33","phase":"reject","category":"explicit","text":"明确拒绝信息协商,唯一原因是资源库访问权限被安全策略禁用。","completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源库访问权限被安全策略禁用\n2. 投诉分类:无法提供,原因:资源库访问权限被安全策略禁用","expected":{"access_port_name":"资源库访问权限被安全策略禁用","complaint_category":"资源库访问权限被安全策略禁用"}} + {"id":"P01","phase":"propose","category":"complete","text":"请发起信息协商,补充接入端口名称和投诉分类;端口可为物理或逻辑端口,分类限专线中断或专线质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P02","phase":"propose","category":"complete","text":"专线投诉诊断需要对端提供端口名和投诉类型,端口接受物理、逻辑名称,类型为专线中断或专线质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P03","phase":"propose","category":"paraphrase","text":"麻烦补齐本次专线投诉的接入端口及故障分类,端口要能定位到物理或逻辑接口,故障仅在中断、质差两类中选择。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P04","phase":"propose","category":"paraphrase","text":"发起信息协商:请给出物理端口或逻辑端口名称,并说明投诉属于专线中断还是专线质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P05","phase":"propose","category":"short","text":"请补充端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P06","phase":"propose","category":"short","text":"需要协商获取接入端口名和专线投诉类别:端口支持物理或逻辑,类别为中断或质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P07","phase":"propose","category":"formal","text":"为开展传输专线业务投诉诊断,请对端补充接入端口标识和投诉分类。其中端口标识应为物理或逻辑端口名称,投诉分类可取专线中断、专线质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P08","phase":"propose","category":"formal","text":"当前诊断参数不完整。请求协商获得:1)接入物理/逻辑端口名称;2)投诉分类,候选值为专线中断、专线质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P09","phase":"propose","category":"punctuation","text":"请补充接入端口名称;物理端口、逻辑端口均可。还请确认投诉分类:专线中断?专线质差?","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P10","phase":"propose","category":"punctuation","text":"信息协商请求【端口:物理或逻辑端口名称】【投诉:专线中断或专线质差】。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P11","phase":"propose","category":"context","text":"客户反映业务异常,尚无法确定网络侧故障。请提供接入端口名称(物理或逻辑均可)以及投诉分类(专线中断或专线质差),以便继续诊断。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P12","phase":"propose","category":"context","text":"工单号已登记,但缺少诊断关键信息。请信息协商补充物理/逻辑接入端口名和专线中断/专线质差的投诉类别。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P13","phase":"propose","category":"synonym","text":"请对端回填入网接口名称(可填物理口或逻辑口)及专线故障现象分类(中断、质差)。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P14","phase":"propose","category":"synonym","text":"需要拿到接入侧端口标识,物理接口或逻辑接口均满足;同时确定本次专线投诉是断线还是质量劣化。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P15","phase":"propose","category":"multisentence","text":"请启动信息协商。诊断需要端口信息。请提供物理端口或逻辑端口名称。另请说明投诉分类是专线中断还是专线质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P16","phase":"propose","category":"multisentence","text":"当前不能直接定位故障。先确认接入端口名称,物理、逻辑名称都可以;再确认投诉类型,只允许专线中断和专线质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P17","phase":"propose","category":"polite","text":"烦请协助补充专线接入端口名称(物理或逻辑端口均可)与投诉分类(专线中断或专线质差),谢谢。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P18","phase":"propose","category":"polite","text":"请您确认一下接入端口是哪个物理/逻辑端口,并选择投诉为专线中断还是专线质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P19","phase":"propose","category":"order-reversed","text":"先请判定投诉类别:专线中断或专线质差;随后提供接入端口名称,物理端口或逻辑端口均可。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P20","phase":"propose","category":"order-reversed","text":"协商所需字段包括投诉分类(专线中断、专线质差)和接入端口名称(物理或逻辑端口)。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P21","phase":"propose","category":"mixed-language","text":"Please provide the access port name, either physical or logical, and classify this private-line complaint as 专线中断 or 专线质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P22","phase":"propose","category":"mixed-language","text":"发起 negotiation,需补充 access port(物理端口或逻辑端口名称)及 complaint category(专线中断或专线质差)。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P23","phase":"propose","category":"noise","text":"客户联系人为张三,联系电话已脱敏。请忽略这些非诊断信息,补充物理或逻辑接入端口名称,以及专线中断或专线质差的投诉分类。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P24","phase":"propose","category":"noise","text":"告警数量暂不确定,工单优先级为P2。请协商取得接入端口名称(物理/逻辑)和投诉分类(专线中断/专线质差)。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P25","phase":"propose","category":"business","text":"SPN专线投诉根因诊断前,请对端提供承载该业务的物理或逻辑接入端口名,并标注故障属于专线中断还是专线质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P26","phase":"propose","category":"business","text":"传输工作台需补齐入参:接入端口的物理/逻辑名称、专线投诉的中断/质差分类,请发起协商。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P27","phase":"propose","category":"imperative","text":"向对端要端口名和投诉类别。端口名必须是物理或者逻辑端口;类别限定专线中断、专线质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P28","phase":"propose","category":"imperative","text":"补齐以下两项再诊断:物理/逻辑接入端口名称;专线中断/专线质差投诉分类。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P29","phase":"propose","category":"long","text":"本次请求用于传输专线业务投诉诊断,现有描述只说明客户感知业务异常,尚不具备定位条件。请通过信息协商要求对端补充接入端口名称,值可以是物理端口名称或逻辑端口名称;同时要求补充投诉分类,允许的值为专线中断或专线质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P30","phase":"propose","category":"long","text":"为避免将客户侧问题误判为网络问题,需要先获取网络定位参数。请请求对端给出接入端口的物理或逻辑标识,并在专线中断、专线质差两个候选项中说明投诉类型。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P31","phase":"propose","category":"format","text":"字段1:接入端口名称,要求:物理端口或逻辑端口名称。字段2:投诉分类,要求:专线中断或专线质差。请发起协商。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P32","phase":"propose","category":"format","text":"需对端回复:access_port_name=物理端口或逻辑端口名称;complaint_category=专线中断或专线质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P33","phase":"propose","category":"colloquial","text":"这单还差端口和投诉类型,麻烦要一下:端口给物理口或逻辑口都行,类型就在专线中断、专线质差里选。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"P34","phase":"propose","category":"colloquial","text":"先把专线的接入端口名和故障分类问清楚吧,端口支持物理/逻辑,分类只有中断或质差。","data":{"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"},{"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null},"completedPrompt":"## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:物理端口或逻辑端口名称\n2. 投诉分类:专线中断或专线质差","expected":{"接入端口名称":"物理端口或逻辑端口名称","投诉分类":"专线中断或专线质差"}}, + {"id":"A01","phase":"accept","category":"complete","text":"接受本次协商。接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类为专线质差。","data":{"items":[{"name":"接入端口名称","value":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"接入端口名称":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","投诉分类":"专线质差"}}, + {"id":"A02","phase":"accept","category":"complete","text":"同意补充信息:物理端口 P782-天河-PTN7900-01-GE0/1/2,故障类型是专线中断。","data":{"items":[{"name":"接入端口名称","value":"P782-天河-PTN7900-01-GE0/1/2"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"接入端口名称":"P782-天河-PTN7900-01-GE0/1/2","投诉分类":"专线中断"}}, + {"id":"A03","phase":"accept","category":"paraphrase","text":"接受。用于诊断的逻辑接入端口是 LAG-广州-001.100,客户投诉为专线质差。","data":{"items":[{"name":"接入端口名称","value":"LAG-广州-001.100"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"接入端口名称":"LAG-广州-001.100","投诉分类":"专线质差"}}, + {"id":"A04","phase":"accept","category":"paraphrase","text":"可以提供:端口名为 PE1-GZ-ETH-0/0/0.200,专线已中断。","data":{"items":[{"name":"接入端口名称","value":"PE1-GZ-ETH-0/0/0.200"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"接入端口名称":"PE1-GZ-ETH-0/0/0.200","投诉分类":"专线中断"}}, + {"id":"A05","phase":"accept","category":"short","text":"接受,端口 NCE-PTN-23-1/1/7,投诉:专线质差。","data":{"items":[{"name":"接入端口名称","value":"NCE-PTN-23-1/1/7"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"接入端口名称":"NCE-PTN-23-1/1/7","投诉分类":"专线质差"}}, + {"id":"A06","phase":"accept","category":"short","text":"Accept。access port=SPN-广州-1/0/24;分类=专线中断。","data":{"items":[{"name":"接入端口名称","value":"SPN-广州-1/0/24"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"接入端口名称":"SPN-广州-1/0/24","投诉分类":"专线中断"}}, + {"id":"A07","phase":"accept","category":"formal","text":"接受该信息协商请求。补充的接入端口名称为 P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),投诉分类确定为专线质差。","data":{"items":[{"name":"接入端口名称","value":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"接入端口名称":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","投诉分类":"专线质差"}}, + {"id":"A08","phase":"accept","category":"formal","text":"经资源核查,接受协商并反馈:物理接口 P782-天河-PTN7900-01-GE0/1/2;本次投诉为专线中断。","data":{"items":[{"name":"接入端口名称","value":"P782-天河-PTN7900-01-GE0/1/2"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"接入端口名称":"P782-天河-PTN7900-01-GE0/1/2","投诉分类":"专线中断"}}, + {"id":"A09","phase":"accept","category":"punctuation","text":"接受!接入端口:LAG-广州-001.100;投诉分类:专线质差。","data":{"items":[{"name":"接入端口名称","value":"LAG-广州-001.100"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"接入端口名称":"LAG-广州-001.100","投诉分类":"专线质差"}}, + {"id":"A10","phase":"accept","category":"punctuation","text":"同意回复【端口=PE1-GZ-ETH-0/0/0.200】【类型=专线中断】。","data":{"items":[{"name":"接入端口名称","value":"PE1-GZ-ETH-0/0/0.200"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"接入端口名称":"PE1-GZ-ETH-0/0/0.200","投诉分类":"专线中断"}}, + {"id":"A11","phase":"accept","category":"context","text":"客户报障期间业务偶发丢包,接受协商,确认接入端口为 NCE-PTN-23-1/1/7,投诉分类为专线质差。","data":{"items":[{"name":"接入端口名称","value":"NCE-PTN-23-1/1/7"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"接入端口名称":"NCE-PTN-23-1/1/7","投诉分类":"专线质差"}}, + {"id":"A12","phase":"accept","category":"context","text":"告警显示链路不可达。接受本次协商,端口为 SPN-广州-1/0/24,归类为专线中断。","data":{"items":[{"name":"接入端口名称","value":"SPN-广州-1/0/24"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"接入端口名称":"SPN-广州-1/0/24","投诉分类":"专线中断"}}, + {"id":"A13","phase":"accept","category":"synonym","text":"同意提供入网接口标识:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100),业务质量劣化属于专线质差。","data":{"items":[{"name":"接入端口名称","value":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"接入端口名称":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","投诉分类":"专线质差"}}, + {"id":"A14","phase":"accept","category":"synonym","text":"接受,承载接口为 P782-天河-PTN7900-01-GE0/1/2,客户反映断线,对应专线中断。","data":{"items":[{"name":"接入端口名称","value":"P782-天河-PTN7900-01-GE0/1/2"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"接入端口名称":"P782-天河-PTN7900-01-GE0/1/2","投诉分类":"专线中断"}}, + {"id":"A15","phase":"accept","category":"multisentence","text":"接受协商。端口名称是 LAG-广州-001.100。投诉分类选择专线质差。","data":{"items":[{"name":"接入端口名称","value":"LAG-广州-001.100"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"接入端口名称":"LAG-广州-001.100","投诉分类":"专线质差"}}, + {"id":"A16","phase":"accept","category":"multisentence","text":"同意。已查到 PE1-GZ-ETH-0/0/0.200。该专线业务当前中断。","data":{"items":[{"name":"接入端口名称","value":"PE1-GZ-ETH-0/0/0.200"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"接入端口名称":"PE1-GZ-ETH-0/0/0.200","投诉分类":"专线中断"}}, + {"id":"A17","phase":"accept","category":"order-reversed","text":"投诉分类为专线质差,接入端口名称为 NCE-PTN-23-1/1/7,接受本次信息协商。","data":{"items":[{"name":"接入端口名称","value":"NCE-PTN-23-1/1/7"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"接入端口名称":"NCE-PTN-23-1/1/7","投诉分类":"专线质差"}}, + {"id":"A18","phase":"accept","category":"order-reversed","text":"专线中断;端口 SPN-广州-1/0/24。接受协商请求。","data":{"items":[{"name":"接入端口名称","value":"SPN-广州-1/0/24"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"接入端口名称":"SPN-广州-1/0/24","投诉分类":"专线中断"}}, + {"id":"A19","phase":"accept","category":"mixed-language","text":"Accept this negotiation. The access port is P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100), complaint category is 专线质差。","data":{"items":[{"name":"接入端口名称","value":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"接入端口名称":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","投诉分类":"专线质差"}}, + {"id":"A20","phase":"accept","category":"mixed-language","text":"接受,port=P782-天河-PTN7900-01-GE0/1/2,category=专线中断。","data":{"items":[{"name":"接入端口名称","value":"P782-天河-PTN7900-01-GE0/1/2"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"接入端口名称":"P782-天河-PTN7900-01-GE0/1/2","投诉分类":"专线中断"}}, + {"id":"A21","phase":"accept","category":"noise","text":"工单优先级P1,联系人已通知。接受并补充端口 LAG-广州-001.100,投诉为专线质差。","data":{"items":[{"name":"接入端口名称","value":"LAG-广州-001.100"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"接入端口名称":"LAG-广州-001.100","投诉分类":"专线质差"}}, + {"id":"A22","phase":"accept","category":"noise","text":"客户地址不在本次处理范围。接受协商:PE1-GZ-ETH-0/0/0.200,专线中断。","data":{"items":[{"name":"接入端口名称","value":"PE1-GZ-ETH-0/0/0.200"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"接入端口名称":"PE1-GZ-ETH-0/0/0.200","投诉分类":"专线中断"}}, + {"id":"A23","phase":"accept","category":"business","text":"资源系统查询完成,接受信息协商。诊断接入端口 NCE-PTN-23-1/1/7,故障投诉分类为专线质差。","data":{"items":[{"name":"接入端口名称","value":"NCE-PTN-23-1/1/7"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"接入端口名称":"NCE-PTN-23-1/1/7","投诉分类":"专线质差"}}, + {"id":"A24","phase":"accept","category":"business","text":"传输侧确认接受:业务承载端口 SPN-广州-1/0/24,故障现象按专线中断处理。","data":{"items":[{"name":"接入端口名称","value":"SPN-广州-1/0/24"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"接入端口名称":"SPN-广州-1/0/24","投诉分类":"专线中断"}}, + {"id":"A25","phase":"accept","category":"format","text":"接受;access_port_name=P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100);complaint_category=专线质差。","data":{"items":[{"name":"接入端口名称","value":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"接入端口名称":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","投诉分类":"专线质差"}}, + {"id":"A26","phase":"accept","category":"format","text":"结果:接受。接入端口名称=P782-天河-PTN7900-01-GE0/1/2;投诉分类=专线中断。","data":{"items":[{"name":"接入端口名称","value":"P782-天河-PTN7900-01-GE0/1/2"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"接入端口名称":"P782-天河-PTN7900-01-GE0/1/2","投诉分类":"专线中断"}}, + {"id":"A27","phase":"accept","category":"colloquial","text":"这次协商同意,端口就是 LAG-广州-001.100,客户说网慢,按专线质差算。","data":{"items":[{"name":"接入端口名称","value":"LAG-广州-001.100"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"接入端口名称":"LAG-广州-001.100","投诉分类":"专线质差"}}, + {"id":"A28","phase":"accept","category":"colloquial","text":"可以,查到了 PE1-GZ-ETH-0/0/0.200,这条专线断了。","data":{"items":[{"name":"接入端口名称","value":"PE1-GZ-ETH-0/0/0.200"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断","expected":{"接入端口名称":"PE1-GZ-ETH-0/0/0.200","投诉分类":"专线中断"}}, + {"id":"A29","phase":"accept","category":"long","text":"我方接受本次信息协商请求。资源查询结果表明,客户专线接入在 NCE-PTN-23-1/1/7;目前业务未完全中断,但存在持续高丢包和时延异常,因此投诉分类填写为专线质差。","data":{"items":[{"name":"接入端口名称","value":"NCE-PTN-23-1/1/7"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:NCE-PTN-23-1/1/7\n2. 投诉分类:专线质差","expected":{"接入端口名称":"NCE-PTN-23-1/1/7","投诉分类":"专线质差"}}, + {"id":"A30","phase":"accept","category":"long","text":"经现场及网管联合核查,接受协商并返回所需信息:接入端口为 SPN-广州-1/0/24。该端口承载业务不可达,用户侧表现为完全断线,投诉分类为专线中断。","data":{"items":[{"name":"接入端口名称","value":"SPN-广州-1/0/24"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:SPN-广州-1/0/24\n2. 投诉分类:专线中断","expected":{"接入端口名称":"SPN-广州-1/0/24","投诉分类":"专线中断"}}, + {"id":"A31","phase":"accept","category":"whitespace","text":"接受本次协商。\n接入端口:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n投诉分类:专线质差。","data":{"items":[{"name":"接入端口名称","value":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)\n2. 投诉分类:专线质差","expected":{"接入端口名称":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)","投诉分类":"专线质差"}}, + {"id":"A32","phase":"accept","category":"whitespace","text":"接受。\n端口 P782-天河-PTN7900-01-GE0/1/2\n类别 专线中断","data":{"items":[{"name":"接入端口名称","value":"P782-天河-PTN7900-01-GE0/1/2"},{"name":"投诉分类","value":"专线中断"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P782-天河-PTN7900-01-GE0/1/2\n2. 投诉分类:专线中断","expected":{"接入端口名称":"P782-天河-PTN7900-01-GE0/1/2","投诉分类":"专线中断"}}, + {"id":"A33","phase":"accept","category":"explicit","text":"明确接受信息协商,唯一需要填报的端口是 LAG-广州-001.100,唯一投诉分类为专线质差。","data":{"items":[{"name":"接入端口名称","value":"LAG-广州-001.100"},{"name":"投诉分类","value":"专线质差"}],"relationship":null},"completedPrompt":"## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:LAG-广州-001.100\n2. 投诉分类:专线质差","expected":{"接入端口名称":"LAG-广州-001.100","投诉分类":"专线质差"}}, + {"id":"R01","phase":"reject","category":"complete","text":"拒绝本次信息协商,当前账号没有资源系统查询权限。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:当前账号没有资源系统查询权限"},{"name":"投诉分类","value":"无法提供,原因:当前账号没有资源系统查询权限"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前账号没有资源系统查询权限\n2. 投诉分类:无法提供,原因:当前账号没有资源系统查询权限","expected":{"接入端口名称":"当前账号没有资源系统查询权限","投诉分类":"当前账号没有资源系统查询权限"}}, + {"id":"R02","phase":"reject","category":"complete","text":"拒绝,原因是资源系统正在维护,暂时无法查询端口信息。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源系统正在维护,暂时无法查询端口信息"},{"name":"投诉分类","value":"无法提供,原因:资源系统正在维护,暂时无法查询端口信息"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统正在维护,暂时无法查询端口信息\n2. 投诉分类:无法提供,原因:资源系统正在维护,暂时无法查询端口信息","expected":{"接入端口名称":"资源系统正在维护,暂时无法查询端口信息","投诉分类":"资源系统正在维护,暂时无法查询端口信息"}}, + {"id":"R03","phase":"reject","category":"paraphrase","text":"无法接受该协商请求,因为我方账号无权访问资源管理系统。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:我方账号无权访问资源管理系统"},{"name":"投诉分类","value":"无法提供,原因:我方账号无权访问资源管理系统"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:我方账号无权访问资源管理系统\n2. 投诉分类:无法提供,原因:我方账号无权访问资源管理系统","expected":{"接入端口名称":"我方账号无权访问资源管理系统","投诉分类":"我方账号无权访问资源管理系统"}}, + {"id":"R04","phase":"reject","category":"paraphrase","text":"本次不能补充信息,资源库维护中,端口数据不可用。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源库维护中,端口数据不可用"},{"name":"投诉分类","value":"无法提供,原因:资源库维护中,端口数据不可用"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源库维护中,端口数据不可用\n2. 投诉分类:无法提供,原因:资源库维护中,端口数据不可用","expected":{"接入端口名称":"资源库维护中,端口数据不可用","投诉分类":"资源库维护中,端口数据不可用"}}, + {"id":"R05","phase":"reject","category":"short","text":"拒绝:无资源系统查询权限。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:无资源系统查询权限"},{"name":"投诉分类","value":"无法提供,原因:无资源系统查询权限"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:无资源系统查询权限\n2. 投诉分类:无法提供,原因:无资源系统查询权限","expected":{"接入端口名称":"无资源系统查询权限","投诉分类":"无资源系统查询权限"}}, + {"id":"R06","phase":"reject","category":"short","text":"Reject,资源系统维护中。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源系统维护中"},{"name":"投诉分类","value":"无法提供,原因:资源系统维护中"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统维护中\n2. 投诉分类:无法提供,原因:资源系统维护中","expected":{"接入端口名称":"资源系统维护中","投诉分类":"资源系统维护中"}}, + {"id":"R07","phase":"reject","category":"formal","text":"拒绝本次信息协商请求,原因是当前操作账号未被授予资源系统查询权限。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:当前操作账号未被授予资源系统查询权限"},{"name":"投诉分类","value":"无法提供,原因:当前操作账号未被授予资源系统查询权限"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前操作账号未被授予资源系统查询权限\n2. 投诉分类:无法提供,原因:当前操作账号未被授予资源系统查询权限","expected":{"接入端口名称":"当前操作账号未被授予资源系统查询权限","投诉分类":"当前操作账号未被授予资源系统查询权限"}}, + {"id":"R08","phase":"reject","category":"formal","text":"经确认,资源系统处于计划维护窗口,暂不具备提供端口信息的条件,故拒绝协商。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源系统处于计划维护窗口,暂不具备提供端口信息的条件"},{"name":"投诉分类","value":"无法提供,原因:资源系统处于计划维护窗口,暂不具备提供端口信息的条件"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统处于计划维护窗口,暂不具备提供端口信息的条件\n2. 投诉分类:无法提供,原因:资源系统处于计划维护窗口,暂不具备提供端口信息的条件","expected":{"接入端口名称":"资源系统处于计划维护窗口,暂不具备提供端口信息的条件","投诉分类":"资源系统处于计划维护窗口,暂不具备提供端口信息的条件"}}, + {"id":"R09","phase":"reject","category":"punctuation","text":"拒绝!原因:当前账号没有资源系统查询权限。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:当前账号没有资源系统查询权限"},{"name":"投诉分类","value":"无法提供,原因:当前账号没有资源系统查询权限"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前账号没有资源系统查询权限\n2. 投诉分类:无法提供,原因:当前账号没有资源系统查询权限","expected":{"接入端口名称":"当前账号没有资源系统查询权限","投诉分类":"当前账号没有资源系统查询权限"}}, + {"id":"R10","phase":"reject","category":"punctuation","text":"无法接受【资源系统维护,端口查询暂停】。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源系统维护,端口查询暂停"},{"name":"投诉分类","value":"无法提供,原因:资源系统维护,端口查询暂停"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统维护,端口查询暂停\n2. 投诉分类:无法提供,原因:资源系统维护,端口查询暂停","expected":{"接入端口名称":"资源系统维护,端口查询暂停","投诉分类":"资源系统维护,端口查询暂停"}}, + {"id":"R11","phase":"reject","category":"context","text":"工单已受理,但资源侧权限申请尚未完成,因此拒绝本次协商。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源侧权限申请尚未完成"},{"name":"投诉分类","value":"无法提供,原因:资源侧权限申请尚未完成"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源侧权限申请尚未完成\n2. 投诉分类:无法提供,原因:资源侧权限申请尚未完成","expected":{"接入端口名称":"资源侧权限申请尚未完成","投诉分类":"资源侧权限申请尚未完成"}}, + {"id":"R12","phase":"reject","category":"context","text":"网管告警很多,但资源系统服务异常,当前不能查询端口,拒绝补充。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源系统服务异常,当前不能查询端口"},{"name":"投诉分类","value":"无法提供,原因:资源系统服务异常,当前不能查询端口"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统服务异常,当前不能查询端口\n2. 投诉分类:无法提供,原因:资源系统服务异常,当前不能查询端口","expected":{"接入端口名称":"资源系统服务异常,当前不能查询端口","投诉分类":"资源系统服务异常,当前不能查询端口"}}, + {"id":"R13","phase":"reject","category":"synonym","text":"不接受协商,因账号没有资产资源库的访问授权。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:账号没有资产资源库的访问授权"},{"name":"投诉分类","value":"无法提供,原因:账号没有资产资源库的访问授权"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:账号没有资产资源库的访问授权\n2. 投诉分类:无法提供,原因:账号没有资产资源库的访问授权","expected":{"接入端口名称":"账号没有资产资源库的访问授权","投诉分类":"账号没有资产资源库的访问授权"}}, + {"id":"R14","phase":"reject","category":"synonym","text":"无法给出所需数据,CMDB资源查询服务正在检修。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:CMDB资源查询服务正在检修"},{"name":"投诉分类","value":"无法提供,原因:CMDB资源查询服务正在检修"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:CMDB资源查询服务正在检修\n2. 投诉分类:无法提供,原因:CMDB资源查询服务正在检修","expected":{"接入端口名称":"CMDB资源查询服务正在检修","投诉分类":"CMDB资源查询服务正在检修"}}, + {"id":"R15","phase":"reject","category":"multisentence","text":"拒绝本次请求。当前账号没有资源系统查询权限。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:当前账号没有资源系统查询权限"},{"name":"投诉分类","value":"无法提供,原因:当前账号没有资源系统查询权限"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前账号没有资源系统查询权限\n2. 投诉分类:无法提供,原因:当前账号没有资源系统查询权限","expected":{"接入端口名称":"当前账号没有资源系统查询权限","投诉分类":"当前账号没有资源系统查询权限"}}, + {"id":"R16","phase":"reject","category":"multisentence","text":"暂不接受。资源系统正在维护。恢复时间未知。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源系统正在维护"},{"name":"投诉分类","value":"无法提供,原因:资源系统正在维护"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统正在维护\n2. 投诉分类:无法提供,原因:资源系统正在维护","expected":{"接入端口名称":"资源系统正在维护","投诉分类":"资源系统正在维护"}}, + {"id":"R17","phase":"reject","category":"order-reversed","text":"因资源侧权限尚未开通,拒绝这次信息协商。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源侧权限尚未开通"},{"name":"投诉分类","value":"无法提供,原因:资源侧权限尚未开通"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源侧权限尚未开通\n2. 投诉分类:无法提供,原因:资源侧权限尚未开通","expected":{"接入端口名称":"资源侧权限尚未开通","投诉分类":"资源侧权限尚未开通"}}, + {"id":"R18","phase":"reject","category":"order-reversed","text":"资源查询接口超时,无法返回端口信息,故拒绝。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源查询接口超时,无法返回端口信息"},{"name":"投诉分类","value":"无法提供,原因:资源查询接口超时,无法返回端口信息"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源查询接口超时,无法返回端口信息\n2. 投诉分类:无法提供,原因:资源查询接口超时,无法返回端口信息","expected":{"接入端口名称":"资源查询接口超时,无法返回端口信息","投诉分类":"资源查询接口超时,无法返回端口信息"}}, + {"id":"R19","phase":"reject","category":"mixed-language","text":"Reject this negotiation because the current account has no resource-system query permission。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:the current account has no resource-system query permission"},{"name":"投诉分类","value":"无法提供,原因:the current account has no resource-system query permission"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:the current account has no resource-system query permission\n2. 投诉分类:无法提供,原因:the current account has no resource-system query permission","expected":{"接入端口名称":"the current account has no resource-system query permission","投诉分类":"the current account has no resource-system query permission"}}, + {"id":"R20","phase":"reject","category":"mixed-language","text":"拒绝,resource system is under maintenance,无法查询端口。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:resource system is under maintenance,无法查询端口"},{"name":"投诉分类","value":"无法提供,原因:resource system is under maintenance,无法查询端口"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:resource system is under maintenance,无法查询端口\n2. 投诉分类:无法提供,原因:resource system is under maintenance,无法查询端口","expected":{"接入端口名称":"resource system is under maintenance,无法查询端口","投诉分类":"resource system is under maintenance,无法查询端口"}}, + {"id":"R21","phase":"reject","category":"noise","text":"联系人已收到短信,工单优先级为P2。拒绝原因是当前账号没有资源系统查询权限。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:当前账号没有资源系统查询权限"},{"name":"投诉分类","value":"无法提供,原因:当前账号没有资源系统查询权限"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前账号没有资源系统查询权限\n2. 投诉分类:无法提供,原因:当前账号没有资源系统查询权限","expected":{"接入端口名称":"当前账号没有资源系统查询权限","投诉分类":"当前账号没有资源系统查询权限"}}, + {"id":"R22","phase":"reject","category":"noise","text":"客户地址信息无需处理。由于资源系统维护,无法取得端口数据,拒绝协商。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源系统维护,无法取得端口数据"},{"name":"投诉分类","value":"无法提供,原因:资源系统维护,无法取得端口数据"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统维护,无法取得端口数据\n2. 投诉分类:无法提供,原因:资源系统维护,无法取得端口数据","expected":{"接入端口名称":"资源系统维护,无法取得端口数据","投诉分类":"资源系统维护,无法取得端口数据"}}, + {"id":"R23","phase":"reject","category":"business","text":"传输资源库未对本租户开放查询能力,无法补充专线端口,拒绝本次信息协商。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:传输资源库未对本租户开放查询能力"},{"name":"投诉分类","value":"无法提供,原因:传输资源库未对本租户开放查询能力"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:传输资源库未对本租户开放查询能力\n2. 投诉分类:无法提供,原因:传输资源库未对本租户开放查询能力","expected":{"接入端口名称":"传输资源库未对本租户开放查询能力","投诉分类":"传输资源库未对本租户开放查询能力"}}, + {"id":"R24","phase":"reject","category":"business","text":"资源同步任务延迟,网管侧尚未获得该专线端口映射,故暂时拒绝回复。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源同步任务延迟,网管侧尚未获得该专线端口映射"},{"name":"投诉分类","value":"无法提供,原因:资源同步任务延迟,网管侧尚未获得该专线端口映射"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源同步任务延迟,网管侧尚未获得该专线端口映射\n2. 投诉分类:无法提供,原因:资源同步任务延迟,网管侧尚未获得该专线端口映射","expected":{"接入端口名称":"资源同步任务延迟,网管侧尚未获得该专线端口映射","投诉分类":"资源同步任务延迟,网管侧尚未获得该专线端口映射"}}, + {"id":"R25","phase":"reject","category":"format","text":"拒绝;rejection_reason=当前账号没有资源系统查询权限。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:当前账号没有资源系统查询权限"},{"name":"投诉分类","value":"无法提供,原因:当前账号没有资源系统查询权限"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前账号没有资源系统查询权限\n2. 投诉分类:无法提供,原因:当前账号没有资源系统查询权限","expected":{"接入端口名称":"当前账号没有资源系统查询权限","投诉分类":"当前账号没有资源系统查询权限"}}, + {"id":"R26","phase":"reject","category":"format","text":"结果:Reject。原因:资源系统维护,暂不能查询。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源系统维护,暂不能查询"},{"name":"投诉分类","value":"无法提供,原因:资源系统维护,暂不能查询"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统维护,暂不能查询\n2. 投诉分类:无法提供,原因:资源系统维护,暂不能查询","expected":{"接入端口名称":"资源系统维护,暂不能查询","投诉分类":"资源系统维护,暂不能查询"}}, + {"id":"R27","phase":"reject","category":"colloquial","text":"这次先不接,账号没权限查资源库。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:账号没权限查资源库"},{"name":"投诉分类","value":"无法提供,原因:账号没权限查资源库"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:账号没权限查资源库\n2. 投诉分类:无法提供,原因:账号没权限查资源库","expected":{"接入端口名称":"账号没权限查资源库","投诉分类":"账号没权限查资源库"}}, + {"id":"R28","phase":"reject","category":"colloquial","text":"查不了,资源系统在检修,拒绝吧。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源系统在检修"},{"name":"投诉分类","value":"无法提供,原因:资源系统在检修"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统在检修\n2. 投诉分类:无法提供,原因:资源系统在检修","expected":{"接入端口名称":"资源系统在检修","投诉分类":"资源系统在检修"}}, + {"id":"R29","phase":"reject","category":"long","text":"我方已尝试通过资源管理系统查询该专线的接入端口和投诉信息,但当前使用的运维账号没有被授权访问相关资源域。由于无法保证返回数据的正确性,拒绝本次信息协商。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:当前使用的运维账号没有被授权访问相关资源域"},{"name":"投诉分类","value":"无法提供,原因:当前使用的运维账号没有被授权访问相关资源域"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前使用的运维账号没有被授权访问相关资源域\n2. 投诉分类:无法提供,原因:当前使用的运维账号没有被授权访问相关资源域","expected":{"接入端口名称":"当前使用的运维账号没有被授权访问相关资源域","投诉分类":"当前使用的运维账号没有被授权访问相关资源域"}}, + {"id":"R30","phase":"reject","category":"long","text":"本次协商暂不能接受。资源系统正在执行例行维护,端口与业务映射查询功能已暂停;在服务恢复前无法提供可靠的补充信息。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源系统正在执行例行维护,端口与业务映射查询功能已暂停"},{"name":"投诉分类","value":"无法提供,原因:资源系统正在执行例行维护,端口与业务映射查询功能已暂停"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统正在执行例行维护,端口与业务映射查询功能已暂停\n2. 投诉分类:无法提供,原因:资源系统正在执行例行维护,端口与业务映射查询功能已暂停","expected":{"接入端口名称":"资源系统正在执行例行维护,端口与业务映射查询功能已暂停","投诉分类":"资源系统正在执行例行维护,端口与业务映射查询功能已暂停"}}, + {"id":"R31","phase":"reject","category":"whitespace","text":"拒绝本次协商。\n原因:当前账号没有资源系统查询权限。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:当前账号没有资源系统查询权限"},{"name":"投诉分类","value":"无法提供,原因:当前账号没有资源系统查询权限"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:当前账号没有资源系统查询权限\n2. 投诉分类:无法提供,原因:当前账号没有资源系统查询权限","expected":{"接入端口名称":"当前账号没有资源系统查询权限","投诉分类":"当前账号没有资源系统查询权限"}}, + {"id":"R32","phase":"reject","category":"whitespace","text":"Reject\n资源查询服务异常,暂无法取得端口信息。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源查询服务异常,暂无法取得端口信息"},{"name":"投诉分类","value":"无法提供,原因:资源查询服务异常,暂无法取得端口信息"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源查询服务异常,暂无法取得端口信息\n2. 投诉分类:无法提供,原因:资源查询服务异常,暂无法取得端口信息","expected":{"接入端口名称":"资源查询服务异常,暂无法取得端口信息","投诉分类":"资源查询服务异常,暂无法取得端口信息"}}, + {"id":"R33","phase":"reject","category":"explicit","text":"明确拒绝信息协商,唯一原因是资源库访问权限被安全策略禁用。","data":{"items":[{"name":"接入端口名称","value":"无法提供,原因:资源库访问权限被安全策略禁用"},{"name":"投诉分类","value":"无法提供,原因:资源库访问权限被安全策略禁用"}],"relationship":null},"completedPrompt":"## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源库访问权限被安全策略禁用\n2. 投诉分类:无法提供,原因:资源库访问权限被安全策略禁用","expected":{"接入端口名称":"资源库访问权限被安全策略禁用","投诉分类":"资源库访问权限被安全策略禁用"}} ] diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/InformationNegotiationSchemasTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/InformationNegotiationSchemasTest.java similarity index 96% rename from a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/InformationNegotiationSchemasTest.java rename to a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/InformationNegotiationSchemasTest.java index 1121ad62..fa6ac446 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/InformationNegotiationSchemasTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/InformationNegotiationSchemasTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.private_line_complaint.negotiation; +package net.openan.a2at.sample.negotiation; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -8,7 +8,7 @@ import java.util.List; import java.util.Map; import java.util.Set; -import net.openan.a2at.sample.private_line_complaint.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; import org.junit.jupiter.api.Test; class InformationNegotiationSchemasTest { diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalAppTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalAppTest.java new file mode 100644 index 00000000..5a3c9197 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalAppTest.java @@ -0,0 +1,141 @@ +package net.openan.a2at.sample.negotiation.eval; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import org.junit.jupiter.api.Test; + +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; + +class NegotiationFromDataApiEvalAppTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Set REQUIRED_FIELDS = Set.of("任务对象", "投诉分类", "OSS侧事件流水号"); + private static final Set OPTIONAL_FIELDS = Set.of("问题发生时间", "投诉详情"); + + @Test + void should_DelegateToTheSharedInformationNegotiationSchemas() { + assertEquals(InformationNegotiationSchemas.propose(), NegotiationFromDataApiEvalApp.extractionSchema("propose")); + assertEquals(InformationNegotiationSchemas.accept(), NegotiationFromDataApiEvalApp.extractionSchema("accept")); + assertEquals(InformationNegotiationSchemas.reject(), NegotiationFromDataApiEvalApp.extractionSchema("reject")); + assertThrows(IllegalArgumentException.class, () -> NegotiationFromDataApiEvalApp.extractionSchema("unknown")); + } + + @Test + void should_CoverOnlyRequiredFieldCombinationsInSuccessfulProposes() throws IOException { + List> cases = cases(); + Set> actualCombinations = new HashSet<>(); + + for (Map evalCase : cases) { + if (!"propose".equals(evalCase.get("api")) || !succeeds(evalCase)) { + continue; + } + Map input = map(evalCase.get("input")); + Set fields = new TreeSet<>(map(input.get("items")).keySet()); + assertFalse(fields.isEmpty()); + assertTrue(REQUIRED_FIELDS.containsAll(fields)); + assertTrue(disjoint(fields, OPTIONAL_FIELDS)); + if (fields.size() == 1) { + assertNull(input.get("relationship")); + } else { + assertTrue(String.valueOf(input.get("relationship")).contains("AND")); + } + actualCombinations.add(fields); + } + + assertEquals(allNonEmptySubsets(REQUIRED_FIELDS), actualCombinations); + } + + @Test + void should_AcceptOrRejectOnlyFieldsThatCanBeNegotiated() throws IOException { + for (Map evalCase : cases()) { + String api = String.valueOf(evalCase.get("api")); + if (!("accept".equals(api) || "reject".equals(api)) || !succeeds(evalCase)) { + continue; + } + Map items = map(map(evalCase.get("input")).get("items")); + assertFalse(items.isEmpty()); + assertTrue(REQUIRED_FIELDS.containsAll(items.keySet())); + assertTrue(disjoint(items.keySet(), OPTIONAL_FIELDS)); + if ("reject".equals(api)) { + items.values().forEach(reason -> assertTrue(String.valueOf(reason).contains("无法提供"))); + } + } + } + + @Test + void should_KeepSharedScenarioWithinPrivateLineComplaintDomain() throws IOException { + Map scenario = resource("sample/negotiation/scenario.json"); + Map taskSchema = map(scenario.get("task_schema")); + assertEquals(List.of("任务对象", "任务上下文"), taskSchema.get("required")); + String missingContext = String.valueOf(map(scenario.get("missing_params")).get("任务上下文")); + String filledContext = String.valueOf(map(scenario.get("filled_params")).get("任务上下文")); + assertFalse(missingContext.contains("投诉分类")); + assertFalse(missingContext.contains("OSS侧事件流水号")); + assertTrue(filledContext.contains("投诉分类")); + assertTrue(filledContext.contains("OSS侧事件流水号")); + + String serialized = MAPPER.writeValueAsString(scenario); + assertFalse(serialized.contains("无线节点")); + assertFalse(serialized.contains("节能")); + } + + private static List> cases() throws IOException { + return maps(resource("sample/negotiation/eval/fromdata-api-suite.json").get("cases")); + } + + private static Map resource(String resource) throws IOException { + try (InputStream input = NegotiationFromDataApiEvalAppTest.class + .getClassLoader() + .getResourceAsStream(resource)) { + assertNotNull(input, resource); + return MAPPER.readValue(input, new TypeReference<>() {}); + } + } + + private static boolean succeeds(Map evalCase) { + return Boolean.TRUE.equals(map(evalCase.get("expect")).get("succeeds")); + } + + private static boolean disjoint(Set left, Set right) { + return left.stream().noneMatch(right::contains); + } + + private static Set> allNonEmptySubsets(Set source) { + List fields = List.copyOf(source); + Set> subsets = new HashSet<>(); + for (int mask = 1; mask < (1 << fields.size()); mask++) { + Set subset = new TreeSet<>(); + for (int index = 0; index < fields.size(); index++) { + if ((mask & (1 << index)) != 0) { + subset.add(fields.get(index)); + } + } + subsets.add(subset); + } + return subsets; + } + + @SuppressWarnings("unchecked") + private static List> maps(Object value) { + return (List>) value; + } + + @SuppressWarnings("unchecked") + private static Map map(Object value) { + return (Map) value; + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java index 988951bb..fa304b9d 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java @@ -50,11 +50,9 @@ void assemblesEveryCorpusEntryIntoAnEndToEndFlow() { assertEquals(50, flows.stream().filter(flow -> flow.decision().equals("reject")).count()); assertTrue(flows.stream().allMatch(flow -> flow.proposeCase().phase().equals("propose"))); assertTrue(flows.stream().allMatch(flow -> flow.endingCase().phase().equals(flow.decision()))); - assertTrue(flows.stream().allMatch(flow -> flow.clientSupplement().contains("## 客户端补充信息"))); - assertTrue(flows.stream().noneMatch(flow -> flow.clientSupplement().contains("协商上下文"))); assertTrue(flows.stream().filter(flow -> flow.decision().equals("reject")) .allMatch(flow -> flow.expectedEnding().keySet().equals( - Map.of("access_port_name", "", "complaint_category", "").keySet()))); + Map.of("接入端口名称", "", "投诉分类", "").keySet()))); } @Test @@ -68,6 +66,32 @@ void assemblesTwentySmokeFlowsUsingTheExistingSelectorIds() { assertTrue(flows.stream().anyMatch(flow -> flow.decision().equals("reject"))); } + @Test + void everyCaseCarriesStructuredDataParallelToItsText() { + var cases = NegotiationEvaluationCaseLoader.load(); + + for (NegotiationEvaluationCase testCase : cases) { + assertTrue(testCase.data() != null, testCase.id()); + assertTrue(testCase.dataItems().size() >= 1, testCase.id()); + assertTrue(testCase.dataRelationship() == null, testCase.id()); + // the data items reproduce the completedPrompt item block verbatim + StringBuilder rebuilt = new StringBuilder(); + for (int index = 0; index < testCase.dataItems().size(); index++) { + if (index > 0) { + rebuilt.append('\n'); + } + rebuilt.append(index + 1).append(". ") + .append(testCase.dataItems().get(index).name()) + .append(':') + .append(testCase.dataItems().get(index).value()); + } + assertTrue(testCase.completedPrompt().contains(rebuilt.toString()), testCase.id()); + // data item names match the expected keys (both use the display names) + assertEquals(testCase.expected().keySet(), testCase.dataItems().stream() + .map(item -> item.name()).collect(java.util.stream.Collectors.toSet()), testCase.id()); + } + } + @Test void completedPromptsCarryNoNegotiationContextSection() { var cases = NegotiationEvaluationCaseLoader.load(); @@ -90,12 +114,10 @@ private static boolean isComplete(NegotiationEvaluationCase testCase) { && !testCase.completedPrompt().isBlank() && testCase.expected() != null && !testCase.expected().isEmpty() - && expectedKeys(testCase.phase()).equals(testCase.expected().keySet()); + && expectedKeys().equals(testCase.expected().keySet()); } - private static java.util.Set expectedKeys(String phase) { - return phase.equals("reject") - ? Map.of("access_port_name", "", "complaint_category", "").keySet() - : Map.of("access_port_name", "", "complaint_category", "").keySet(); + private static java.util.Set expectedKeys() { + return Map.of("接入端口名称", "", "投诉分类", "").keySet(); } } From 3929702f65922a199b6f3beef2595d097f501e03 Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Fri, 28 Aug 2026 18:53:53 +0800 Subject: [PATCH 229/295] fix(sample): spell out the client supplement in the fromText ending generation Terse corpus ending texts may omit or paraphrase the field names, so the LLM extraction could not recover the requested items (three smoke cases failed with extraction errors and most ending extractions missed). The fromText ending input now leads with an explicit supplement built from the fields the client discovered in the inbound propose, mirroring NegotiationEvalApp step 6. Signed-off-by: ZhouJie6 --- .../NegotiationQwenEvaluationMain.java | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java index 9a5706d1..0302c9c9 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -276,12 +276,15 @@ private static MetadataContent generateEndingWithLog( boolean accept = "accept".equals(testCase.decision()); NegotiationEvaluationCase endingCase = testCase.endingCase(); List endingItems = filledItems(requestedFields, endingCase); + // the fromText channel voices the supplement explicitly (mirroring NegotiationEvalApp step 6): terse corpus + // texts may paraphrase or omit the field names, so the LLM extraction needs the discovered fields spelled out + String endingInputText = clientSupplementText(endingItems, accept) + "\n\n" + endingCase.text(); Map request = fromData ? Map.of("data", Map.of( "items", itemsJson(endingItems), "conclusion", accept ? NegotiationConclusion.ACCEPT.toString() : NegotiationConclusion.REJECT.toString()), "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()) - : Map.of("text", endingCase.text(), + : Map.of("text", endingInputText, "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()); try { MetadataContent content = fromData @@ -296,9 +299,9 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, endingItems) NegotiationSampleFlow.ENDING_TEMPLATE_URI) : accept ? client.generateNegotiationAcceptPromptFromText( - endingCase.text(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI) + endingInputText, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI) : client.generateNegotiationRejectPromptFromText( - endingCase.text(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); + endingInputText, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), channel, context, request, Map.of("prompt", content.promptText(), "template_uri", content.templateUri(), "extension_uri", content.extensionUri()), startedAt, null)); @@ -390,6 +393,24 @@ private static List> itemsJson(List items) return json; } + /** The client's explicit supplement for the fields it discovered from the inbound propose. */ + private static String clientSupplementText(List items, boolean accept) { + StringBuilder text = new StringBuilder("## 客户端补充信息\n"); + for (int index = 0; index < items.size(); index++) { + NegotiationItem item = items.get(index); + if (index > 0) { + text.append('\n'); + } + text.append(index + 1).append(". ").append(item.name()).append(':'); + if (accept) { + text.append(item.value()); + } else { + text.append("无法提供,原因:").append(item.value()); + } + } + return text.toString(); + } + private static Map stageEvent( String runId, NegotiationEvaluationFlowCase testCase, From 8bfeb5b25d98165ae1879e9980834f936f5b1195 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Fri, 28 Aug 2026 19:35:49 +0800 Subject: [PATCH 230/295] feat(sample): split validation and extraction sources in authz suite The content validation prompt now draws a division of labor between validation sources: the template body carries business rules (per-operation-type required fields, modifiability, operation scope) while the parameter schema carries value and format constraints. The schema's field set and descriptive statements no longer participate in judging field existence or required-ness - a field missing from the schema does not exempt the template's requirement, and a schema description contradicting a template required field does not alter it. Format compliance is decided by the schema's declaration when it disagrees with the template's shape description for the same field, so declared format tightenings (single date, ISO timestamp) now reject mismatched range values. The format_error definition gains inverted date ranges, the key-set contract requires schema key names for empty values, and the format-compliance example avoids literal date patterns. The suite grows to 102 cases. Four varreq variants flip from success to rejected pairs (format tightening beats template range wording), b3-conflict pair input moves the conflicting dual value onto the modifiable validity period (the scene variant collided with the modifiability rule and double-triggered), a-nl-neg-03 becomes a two-stage expectation (client renders faithfully, server rejects the identifier-less delete), the c4 varfields schema drops its stale conditional-delete description, and two new cases join: a description-antagonist variant (schema text says condition delete is fine, template still wins) and a multi-error case covering the previously untested multi-slot_errors path. LoaderTest counts follow (102 total, 47 variants including the -varuuid/-vardesc suffixes, 9 interceptions, 37 rejections, 43 validate_schemas). Template lint and the sample test suite pass; smoke passes 15/15 and the transcript is regenerated. Full-suite runs after the model change sit at 98-99/102 with residual mismatches attributed to LLM non-determinism (error-code wobble on one variant) and transient gateway JSON failures. Signed-off-by: w00609601 --- .../content_validation/en-US/system.md | 10 +- .../content_validation/zh-CN/system.md | 10 +- .../sample/authz-policy/scenarios-100.json | 208 ++++++++++++------ .../authz-policy/smoke-io-transcript.md | 75 +++---- .../authz_policy/AuthzScenarioLoaderTest.java | 12 +- 5 files changed, 196 insertions(+), 119 deletions(-) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md index 6a4303fd..2cc3be36 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md @@ -24,12 +24,12 @@ Output exactly one JSON object containing exactly the following 3 required keys; - params: the parameter object extracted from the input content per the parameter schema; output an empty object {} when no parameter can be extracted. ## Validation Tasks -1. Content completeness: first locate each parameter's actual value per "Input Content Structure", then check whether the value covers the required information. +1. Content completeness: first locate each parameter's actual value per "Input Content Structure", then check whether the value covers the required information demanded by the template body (the field requirements of each operation type). 2. Semantic consistency: whether the information in the input content is consistent with the meaning of the corresponding parameters, with no semantic conflicts or contradictions. 3. Value validity: whether the parameter values extracted from the input content are within reasonable ranges, with no obviously unreasonable or fabricated values. -4. Format compliance: whether the format of parameter values conforms to the formats specified in the parameter schema. +4. Format compliance: whether the format and shape of a parameter value conform to the format and type constraints declared in the parameter schema (e.g., when the schema declares that only a single date is accepted but the value provides a start and an end date, it is a format violation); when a format or shape constraint declared in the parameter schema is inconsistent with the template body's shape description of the same field, the parameter schema's declaration decides format compliance. A violation of a format-class constraint is a validation failure (enum value ranges belong to task 3). 5. Entry-level check: when a parameter value is a multi-entry list and the parameter schema or the template body specifies per-entry required fields by operation type, you must first list the required-field list for that operation type (synthesizing the parameter schema and the template body), then count the actual number of fields in each entry and compare; fewer fields than required is a deterministic missing of required fields and MUST be reported as missing_required — even if you cannot determine which specific field is missing, you must report it; never let through an entry with an insufficient field count for any reason. -6. Validation sources and priority: the parameter schema and the template body ("Requirements/Examples" explanatory text) are BOTH validation sources and BOTH must be enforced. The parameter schema typically declares only structure, field meanings and value constraints (enums etc.); field-level requirements carried by the template body (per-operation-type supported field ranges, modifiability declarations, required-field lists) have the same binding force as the schema — never ignore a template-body requirement merely because the schema does not restate it. Only when the two give a direct conflict on the same field does the parameter schema prevail. +6. Validation sources and division of labor: the template body carries business rules (per-operation-type required fields, modifiability, operation scope) and the parameter schema carries value and format constraints; each is authoritative in its own domain and both are validation sources. The parameter schema's field set and descriptive statements do not participate in judging field existence and required-ness: a field not declared in the schema does not exempt the template body's requirement on that field, a descriptive schema statement contradicting a template-body required field does not alter the template body's requirement, and business rules the schema does not restate remain in force; this exemption applies only to field existence and required-ness — the parameter schema's format and value constraints still participate in validation as usual. (The "synthesizing the parameter schema and the template body" in task 5 means the required-field list is merged from both sources; whether a field is required is decided by the template body.) 7. Validation boundary (two kinds of checks): - Deterministic checks (strictly enforced; must not be let through on the grounds of "uncertain/maybe"): whether required fields exist, whether values are within the range enumerated by the constraint, whether the operation is explicitly forbidden by the constraint (including modification/operation intents the template body declares unsupported), whether identifier formats are valid, whether values are meaningless characters. - Format-variant checks (pass by default; report an error only when the constraint is clearly violated): format variants such as date notations and separator styles pass unless the constraint explicitly excludes the variant; report only when the value clearly violates rules enumerated by the constraint (e.g., an invalid date value such as month 13, or a single date where the constraint requires a complete range). @@ -39,7 +39,7 @@ Output exactly one JSON object containing exactly the following 3 required keys; - The sole source of parameter extraction is each parameter section's "parameter value line" (the content with the bracketed annotation removed); do not extract from "Requirements:" or "Examples:" explanatory text. - Extract parameters from the input content per the parameter schema given in the user prompt and fill the params object. - The property names and structure of params must follow the parameter schema; output null for properties that cannot be extracted from the input content. -- The key set of params must exactly match the properties declared in the parameter schema: no extra keys, no missing keys, no renamed keys (the same rule applies to top-level properties and fields inside array items); never omit a key that cannot be extracted — output null, or an empty collection per the schema's type convention. +- The key set of params must exactly match the properties declared in the parameter schema: no extra keys, no missing keys, no renamed keys (the same rule applies to top-level properties and fields inside array items); never omit a key that cannot be extracted — output null, or an empty collection per the schema's type convention; when a value is empty (including empty arrays and parameters whose section has no value line), the key name must still be the property name declared in the parameter schema. - The parameter extraction result does not affect semantic_verdict; semantic_verdict is decided solely by the validation tasks. ## slot_name and code Convention @@ -47,7 +47,7 @@ Output exactly one JSON object containing exactly the following 3 required keys; - slot_name must be one of the top-level parameter names defined in the parameter schema; names of fields inside a parameter's internal structure (e.g., array item fields) must not be used. When the violation concerns a specific field of an item inside a parameter, attribute the error to the owning parameter name and describe the field location in the message (e.g., "invalid month in the validity period of entry 2 of the policy list"). - code may only use one of the following 4 values; do not invent other values: - missing_required: the parameter is entirely missing (value line empty), or an entry lacks required fields explicitly demanded by the constraint - - format_error: use this code for all format-class problems — incomplete date ranges, invalid date values (e.g., month 13), wrong date format, wrong separators, meaningless characters (e.g., xxx, ###) + - format_error: use this code for all format-class problems — incomplete date ranges, invalid date values (e.g., month 13), inverted date ranges (start date later than end date), wrong date format, wrong separators, meaningless characters (e.g., xxx, ###) - invalid_value: non-format value violations — operation type out of range, modification intents on fields declared unmodifiable by the constraint, obviously nonexistent query condition values - semantic_mismatch: semantic conflicts between parameters or inconsistency with template requirements - Classification priority: when a problem matches the descriptions of multiple codes at once, prefer the code reflecting "content exists but violates" (invalid_value, format_error); use missing_required only when the content is entirely missing or purely missing required fields. When an entry expresses an intent that violates the constraint (e.g., modifying a field declared unmodifiable), do not switch to missing_required just because other fields are also missing. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md index 8b13540b..67cd531c 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md @@ -30,13 +30,13 @@ - params:按参数 schema 从输入内容中提取的参数对象;无参数可提取时输出空对象 {}。 ## 校验任务 -1. 内容完整性:先按"输入内容的结构"定位每个参数的实际值,再检查该值是否涵盖要求的必要信息。 +1. 内容完整性:先按"输入内容的结构"定位每个参数的实际值,再检查该值是否涵盖模板正文要求的必要信息(各操作类型的字段要求)。 2. 语义一致性:输入内容中的各项信息是否与对应参数的含义一致,不得出现语义冲突或矛盾。 3. 取值合法性:输入内容中提取的参数值是否在合理范围内,不得出现明显不合理或伪造的值。 -4. 格式合规性:参数值的格式是否符合参数 schema 规定的格式。 +4. 格式合规性:参数值的格式与形态是否符合参数 schema 声明的格式与类型约束(如 schema 声明只接受单个日期而值提供了起止两个日期,属格式违规);当参数 schema 声明的格式或形态约束与模板正文对同一字段的形态描述不一致时,以参数 schema 的声明判定格式合规性。格式类约束的违反即校验失败(enum 取值范围归第 3 条)。 5. 条目级检查:当参数值包含多个条目、且参数 schema 或模板正文规定了每条必须包含的必要信息时,必须先列出该操作类型条目必填字段清单(参数 schema 与模板正文综合),再逐条核对每个条目实际涵盖的必要信息并比对。条目缺少任何必要信息即为确定性的缺失,必须报告 missing_required——即使无法确定缺少的具体是哪一项,也必须报告;不得以任何理由放行信息不全的条目。 -6. 校验依据与优先级:参数 schema 与模板正文("要求/举例"等说明文字)**同为校验依据,均必须执行**。参数 schema 通常仅声明结构、字段含义与取值约束(enum 等);**模板正文承载的字段级要求(各操作类型支持的字段范围、可修改性声明、必填字段清单)与 schema 具有同等约束力**——不得因 schema 未复述而忽略模板正文的要求。当且仅当两者对同一字段给出直接冲突的约束时,以参数 schema 为准。 +6. 校验依据与分工:模板正文承载业务规则(各操作类型的必填字段、可修改性、操作范围),参数 schema 承载取值与格式约束,二者各司其职、均为校验依据。参数 schema 的字段集与描述性说明不参与字段存在性与必填性判定:schema 未声明某字段不豁免模板正文对该字段的要求,schema 的描述性说明与模板正文要求的必填字段相抵触时不改变模板正文的要求,schema 未复述的业务规则照常生效;此豁免仅针对字段存在性与必填性,参数 schema 的格式与取值约束仍照常参与校验。(第 5 条的"参数 schema 与模板正文综合"指必填字段清单由两源合并列出;某字段是否必填由模板正文裁定。) 7. 校验边界(区分两类检查): - 确定性检查(必须严格执行,不得以"不确定/可能"为由放行):必填字段是否存在、取值是否在约束列举的范围内、是否为约束明确禁止的操作(含模板正文声明不支持的修改/操作意图)、值是否为无意义字符、日期区间是否倒置(起始日期晚于结束日期)。 - 格式变体检查(默认通过,仅当明确违反约束时才报错):日期写法、分隔符风格等格式变体,若约束未明确排除该变体,则一律通过;只有当值明确违反约束列举的规则(如日期取值 13 月无效、约束要求完整区间而只给了单个日期)时才报错。 @@ -47,7 +47,7 @@ - 参数提取的唯一来源是各参数章节的"参数值行"(渲染后的实际参数值);不要从模板正文中提取。 - 按参数 schema 定义的结构从参数值行中提取参数并填充 params 对象;值行中未提供的信息输出 null 或空集合(按 schema 的类型约定)。 - params 的属性名与结构必须遵循参数 schema;无法从输入内容中提取到的属性输出 null。 -- params 的键集必须与参数 schema 声明的属性完全一致:不能多、不能少、不能改名(顶层属性与数组条目内的字段同此规则);提取不到的键不得省略,值输出 null 或按 schema 的类型约定输出空集合。 +- params 的键集必须与参数 schema 声明的属性完全一致:不能多、不能少、不能改名(顶层属性与数组条目内的字段同此规则);提取不到的键不得省略,值输出 null 或按 schema 的类型约定输出空集合;值为空(含空数组、参数章节无值行)时,键名仍须取参数 schema 声明的属性名。 - 参数提取结果不影响 semantic_verdict;semantic_verdict 只由校验任务决定。 ## slot_name 与 code 规范 @@ -55,7 +55,7 @@ - slot_name 只能取参数 schema 顶层定义的参数名,不得使用参数内部结构(如数组条目)中的字段名;当错误发生在某参数内部条目的具体字段(如列表条目的有效期、处置类型)时,slot_name 仍填该参数名,具体字段定位写入 message(如"策略列表第2条的有效期月份无效")。 - code 只能使用以下 4 个取值之一,不得自造其他值: - missing_required:参数完全缺失(参数值行为空),或条目缺少约束明确要求的必填字段 - - format_error:格式类问题一律使用此码——日期区间不完整、日期取值无效(如 13 月)、日期格式错误、分隔符错误、无意义字符(如 xxx、###) + - format_error:格式类问题一律使用此码——日期区间不完整、日期取值无效(如 13 月)、日期区间倒置(起始日期晚于结束日期)、日期格式错误、分隔符错误、无意义字符(如 xxx、###) - invalid_value:非格式类取值违规——enum 字段取值不在取值范围内、修改意图涉及约束声明不可修改的字段、查询条件取值明显不存在 - semantic_mismatch:参数之间存在语义冲突或与模板要求不一致,也包括同一参数内同一字段出现多个相互冲突的取值(如某一条目内同一字段同时给出两个互斥的值) - 归类优先级:当一个问题同时命中多个 code 的描述时,优先报告反映"内容存在但违规"的 code(invalid_value、format_error);仅当内容完全缺失或纯粹缺少必填字段时才使用 missing_required。条目表达了违反约束的意图(如修改约束声明不可修改的字段)时,不得因同时缺少其他字段而改报 missing_required。当冲突源于同一字段内取值互斥(semantic_mismatch 定义覆盖的"同一字段多个相互冲突的取值"情形)且同时存在格式问题时,semantic_mismatch 优先于 format_error 报告。 diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-100.json b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-100.json index d38e97c9..5716731e 100644 --- a/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-100.json +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-100.json @@ -130,13 +130,18 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是除政企专网之外的其他场景\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "validation_semantic_rejected", "slot_errors": [ { - "slot_name": "授权策略的操作类型", - "code": "missing_required" + "slot_name": "policyList", + "code": "missing_required", + "message": "删除授权策略时必填参数包括动网操作的授权策略标识,条目缺少策略标识" } - ] + ], + "params": {} } } }, @@ -1254,11 +1259,11 @@ "label": "b3-nl-conflict-01", "entry": "from_text", "input": { - "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3的授权同时改成校园专网和医疗专线" + "text": "把 7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3 的授权策略有效期改成'2027-01-01~2030-12-31 和 2028-01-01~2029-06-30'两个值" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网和医疗专线\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是2027-01-01~2030-12-31,有效期是2028-01-01~2029-06-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", @@ -1275,11 +1280,11 @@ "label": "b3-nl-conflict-01-varname", "entry": "from_text", "input": { - "text": "把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3的授权同时改成校园专网和医疗专线" + "text": "把 7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3 的授权策略有效期改成'2027-01-01~2030-12-31 和 2028-01-01~2029-06-30'两个值" }, "expected": { "client": { - "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网和医疗专线\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是2027-01-01~2030-12-31,有效期是2028-01-01~2029-06-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { "outcome": "validation_semantic_rejected", @@ -1800,20 +1805,15 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务调整,操作名称是隧道调优,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "operationType": "新增授权策略", - "policyList": [ - { - "policyId": null, - "scene": "应急通信", - "actionType": "业务调整", - "operationName": "隧道调优", - "validityPeriod": "2029-09-30" - } - ] - } + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "format_error", + "message": "有效期须为单值 YYYY-MM-DD,实际为区间" + } + ], + "params": {} } }, "validate_schema": { @@ -2401,20 +2401,15 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是无人机回传,处置类型是业务调整,操作名称是隧道调优,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "operationType": "新增授权策略", - "policyList": [ - { - "policyId": null, - "scene": "无人机回传", - "actionType": "业务调整", - "operationName": "隧道调优", - "validityPeriod": "2026-08-01T00:00:00Z" - } - ] - } + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "format_error", + "message": "有效期须为 ISO 8601 时间戳,实际为区间 YYYY-MM-DD~YYYY-MM-DD" + } + ], + "params": {} } }, "validate_schema": { @@ -3221,20 +3216,15 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "operationType": "修改授权策略", - "policyList": [ - { - "policyId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - "scene": null, - "actionType": null, - "operationName": null, - "validityPeriod": "2026-06-01T00:00:00Z" - } - ] - } + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "format_error", + "message": "有效期须为 ISO 8601 时间戳,实际为区间 YYYY-MM-DD~YYYY-MM-DD" + } + ], + "params": {} } }, "validate_schema": { @@ -3774,6 +3764,61 @@ "params": {} } }, + "validate_schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "policyList": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列;未提供时为空列表", + "items": { + "type": "object", + "properties": { + "policyId": { + "type": "string", + "description": "策略的唯一标识" + }, + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + } + } + } + } + } + } + }, + { + "label": "c4-nl-del-04-vardesc", + "entry": "from_text", + "input": { + "text": "删除所有业务场景为载波调度的授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是载波调度\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "missing_required", + "message": "删除授权策略时必填参数包括动网操作的授权策略标识,条目缺少策略标识" + } + ], + "params": {} + } + }, "validate_schema": { "type": "object", "properties": { @@ -3800,6 +3845,18 @@ "scene": { "type": "string", "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间" } } } @@ -4744,20 +4801,15 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是紧急扩容,操作名称是天线调整,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "success", - "slot_errors": [], - "params": { - "operationType": "新增授权策略", - "policyList": [ - { - "policyId": null, - "scene": "校园专网", - "actionType": "紧急扩容", - "operationName": "天线调整", - "validityPeriod": "2030-12-31" - } - ] - } + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "format_error", + "message": "有效期须为单值 YYYY-MM-DD,实际为区间" + } + ], + "params": {} } }, "validate_schema": { @@ -5388,6 +5440,34 @@ } } } + }, + { + "label": "c7-nl-multierr-01", + "entry": "from_text", + "input": { + "text": "帮我新增两条授权策略:第一条是应急通信场景、有效期到2030年12月31日为止;第二条是无人机回传场景、业务调整处置、天线调整操作、有效期2027-13-01到2030-12-31。" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,有效期是2030-12-31\n2. 业务场景是无人机回传,处置类型是业务调整,操作名称是天线调整,有效期是2027-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "validation_semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "missing_required", + "message": "动网操作的授权策略列表第1条缺少必填字段:处置类型、操作名称" + }, + { + "slot_name": "policyList", + "code": "format_error", + "message": "动网操作的授权策略列表第2条的有效期月份无效(13月)" + } + ], + "params": {} + } + } } ] } diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/smoke-io-transcript.md b/a2a-t-sample/src/main/resources/sample/authz-policy/smoke-io-transcript.md index c283184b..fc25532e 100644 --- a/a2a-t-sample/src/main/resources/sample/authz-policy/smoke-io-transcript.md +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/smoke-io-transcript.md @@ -1,6 +1,6 @@ # Authorization-T Demo 冒烟测试原始输入输出记录 -- 位置:`eval-results\authz-demo\authz-report-20260828-110442.json`;题集:`sample/authz-policy/scenarios.json`;LLM:蓝区统一网关(key 不落档);参数:`-Dauthz.reasoning=true`;结果:**15/15 match** +- 位置:`eval-results\authz-demo\authz-report-20260828-193444.json`;题集:`sample/authz-policy/scenarios.json`;LLM:蓝区统一网关(key 不落档);参数:`-Dauthz.reasoning=true`;结果:**15/15 match** - 分区:预期成功在前(index 0-7)、预期拒绝在中(index 8-12)、客户端拦截在末尾(index 13-14) - 每例两段:客户端(输入→生成结果→理由)与服务端(schema 输入→校验提参结果→理由);服务端段不重复渲染后 prompt @@ -32,7 +32,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中包含“新增一个授权策略”,对应封闭取值范围内的“新增授权策略”。动网操作的授权策略列表按新增形态组织为编号条目,包含业务场景、处置类型、操作名称、有效期四个字段,并以全角逗号分隔;日期“2026-01-01到2030-12-31”按约束归一为“2026-01-01~2030-12-31”。所有必填内容均已提供,无错误。 +**提参理由(LLM reasoning)**:输入中出现“新增”这一合法取值的简写,按规则13a映射为“新增授权策略”。策略列表为新增形,输入提供了业务场景、处置类型、操作名称、有效期四个字段,日期“2026-01-01到2030-12-31”按约束归一为“2026-01-01~2030-12-31”,并按编号1及全角逗号组织为列表条目。 ### 服务端(校验 + 提参) @@ -106,7 +106,7 @@ } ``` -**校验理由(LLM reasoning)**:操作类型为已提供的枚举值“新增授权策略”。动网操作的授权策略列表存在1条非空条目,新增授权策略所需的业务场景、处置类型、操作名称、有效期均已提供,未提供policyId不构成缺失。有效期区间完整且起止日期顺序有效,未发现格式或语义冲突,因此整体校验通过,errors为空。 +**校验理由(LLM reasoning)**:操作类型为新增授权策略,属于 schema 允许的枚举值。动网操作的授权策略列表包含1条策略;新增授权策略的模板必填字段为业务场景、处置类型、操作名称、有效期,该条目均已提供。有效期起止日期有效且未倒置,日期区间形态未被约束明确排除,不构成格式违规。policyId 在新增场景下非必填,缺失不报错。因此 semantic_verdict 为 true,errors 为空。 **键名泄漏扫描**:无泄漏 @@ -141,7 +141,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中“新增”是封闭取值范围中“新增授权策略”的合法简写,按规则13a映射。操作类型为新增,策略列表为必填,输入提供了业务场景、处置类型、操作名称、有效期四个字段,按约束组织为编号条目;有效期从“从2026-01-01到2030-12-31”归一为区间格式“2026-01-01~2030-12-31”。 +**提参理由(LLM reasoning)**:输入中出现“新增”且处于新增授权策略的指令语境,按封闭取值范围规则13a映射为“新增授权策略”。策略列表按新增形字段顺序提取:业务场景、处置类型、操作名称、有效期,并将日期区间归一化为YYYY-MM-DD~YYYY-MM-DD格式;操作类型为新增,列表已提供,因此不报告missing_input。 ### 服务端(校验 + 提参) @@ -215,7 +215,7 @@ } ``` -**校验理由(LLM reasoning)**:操作类型为“新增授权策略”,属于合法取值。新增场景下必填字段为业务场景、处置类型、操作名称、有效期;策略列表第1条已完整提供这四项,且有效期起止日期完整、无明显格式或区间倒置问题。策略标识未见提供,但新增场景未强制要求,按schema输出为null。因此校验通过。 +**校验理由(LLM reasoning)**:操作类型为新增授权策略,取值在 schema 枚举范围内。新增操作按模板正文要求必填业务场景、处置类型、操作名称、有效期,输入列表条目均完整提供;未提供策略标识、创建时间、最后修改时间,但新增操作不要求这些字段,不构成缺失。有效期格式为日期区间且起始早于结束,未被约束明确禁止,格式变体通过。参数提取按 schema 结构完成,pid 未提供故为 null。 **键名泄漏扫描**:无泄漏 @@ -250,7 +250,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中“新增两条授权策略”中的“新增”为合法取值范围中“新增授权策略”的简写,按规则13a映射为“新增授权策略”。“动网操作的授权策略列表”在新增操作下为必填,输入明确给出两条策略,分别提取业务场景、处置类型、操作名称、有效期,并按规则18将自然语言日期“从2026-01-01到2030-12-31”归一为“2026-01-01~2030-12-31”,第二条同理归一为“2026-06-01~2030-06-18”;按条目编号和全角逗号分隔组织为列表文本。未发现缺失或非法值,故无错误。 +**提参理由(LLM reasoning)**:输入中出现“新增”且处于指令语境,按封闭取值范围规则映射为合法值“新增授权策略”。策略列表提取两条编号条目,并将“有效期从...到...”归一为“有效期是YYYY-MM-DD~YYYY-MM-DD”格式,字段顺序和值均保留。 ### 服务端(校验 + 提参) @@ -331,7 +331,7 @@ } ``` -**校验理由(LLM reasoning)**:操作类型为“新增授权策略”,符合 schema 枚举;授权策略列表已提供两条,新增场景下必填的业务场景、处置类型、操作名称、有效期均已提供;有效期起止日期有效且起止顺序未倒置。策略标识在新增场景中未提供,不构成缺失。 +**校验理由(LLM reasoning)**:操作类型为“新增授权策略”,在枚举范围内。动网操作的授权策略列表包含两条记录,均提供了创建授权策略所需的业务场景、处置类型、操作名称和有效期;policyId在创建场景下非必填,未提供时按schema输出null。两条有效期均为日期区间,起始日期未晚于结束日期,且日期区间写法未被约束明确排除,格式变体默认通过。综上无语义或格式错误。 **键名泄漏扫描**:无泄漏 @@ -366,7 +366,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中的“改成”命中修改授权策略的同义对清单,因此“授权策略的操作类型”提取为“修改授权策略”。输入中明确给出两个策略标识,并要求将二者的有效期改为永久生效,属于正向修改约束,因此按修改形态分别形成编号条目:“1. 策略标识是...,有效期是永久生效”和“2. 策略标识是...,有效期是永久生效”,以换行分隔。 +**提参理由(LLM reasoning)**:输入中“改成”属于修改授权策略的认可同义对,因此“授权策略的操作类型”映射为“修改授权策略”。修改对象为两个策略标识,有效期目标为“永久生效”,按修改形列表格式合并提取为两个编号条目。 ### 服务端(校验 + 提参) @@ -447,7 +447,7 @@ } ``` -**校验理由(LLM reasoning)**:(未采集) +**校验理由(LLM reasoning)**:操作类型为修改授权策略,取值在enum范围内;修改操作下条目需提供策略标识与有效期,两条策略均提供了策略标识且有效期为合法的“永久生效”,未发现必填字段缺失、格式错误或语义冲突;按schema提取策略列表,并将未提供的业务场景、处置类型、操作名称置为null。 **键名泄漏扫描**:无泄漏 @@ -483,7 +483,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入包含“改成”,属于操作类型同义对清单中的“修改授权策略”映射;两个 UUID 均为策略标识,且“都改成永久生效”表示对两者修改有效期,因此按修改授权策略的条目格式生成两条编号条目。无缺失或非法值。 +**提参理由(LLM reasoning)**:输入中的“改成”属于“修改授权策略”的认可同义对,映射为“修改授权策略”。两个UUID均为修改对象,有效期统一改为“永久生效”,按修改形策略列表格式拆分为两个编号条目。 ### 服务端(校验 + 提参) @@ -546,7 +546,7 @@ } ``` -**校验理由(LLM reasoning)**:操作类型为“修改授权策略”,在枚举范围内;模板正文规定修改操作仅支持修改有效期,并需策略标识定位策略。输入中两个条目均提供策略标识和有效期(永久生效),无格式错误、无冲突,其他不可修改字段未提供不构成缺失。按参数模式提取 operationType 与 policyList 中的 policyId、newValidityPeriod。 +**校验理由(LLM reasoning)**:(未采集) **键名泄漏扫描**:无泄漏 @@ -580,7 +580,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中“删除”是“删除授权策略”的简写,依据封闭取值范围规则13a直接映射为合法取值。动网操作的授权策略列表在删除操作下必填,删除形要求按策略标识删除;输入中的UUID“7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3”即为策略标识,按“1. 策略标识是X”格式提取为条目,因此无缺失或非法值错误。 +**提参理由(LLM reasoning)**:操作类型:输入开头出现“删除”,属于合法取值“删除授权策略”的简写,按封闭取值范围规则直接映射为“删除授权策略”。策略列表:删除操作需提供策略标识,输入显式给出 UUID 7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,按删除条目格式组织为“1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3”。 ### 服务端(校验 + 提参) @@ -654,7 +654,7 @@ } ``` -**校验理由(LLM reasoning)**:(未采集) +**校验理由(LLM reasoning)**:操作类型为删除授权策略,属于枚举允许范围;删除操作按模板正文仅需策略标识,输入第1条提供了有效策略标识,未发现必填缺失、格式错误或语义冲突。按参数schema提取操作类型和策略列表,未提供的字段置null。 **键名泄漏扫描**:无泄漏 @@ -687,7 +687,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中的“列出”属于查询授权策略的认可同义表达,因此操作类型映射为“查询授权策略”;当前为查询操作,策略列表选填,且输入未提供带字段标签的查询条件,因此列表值为 null,无错误。 +**提参理由(LLM reasoning)**:输入中的“列出”属于“查看/检索/搜索/查找/列出/列举→查询授权策略”的认可同义对,因此将“授权策略的操作类型”映射为“查询授权策略”。输入未提供具体的授权策略列表内容或带字段标签的查询条件,且查询授权策略下“动网操作的授权策略列表”为选填,因此该 slot 取 null,不报告错误。 ### 服务端(校验 + 提参) @@ -753,7 +753,7 @@ } ``` -**校验理由(LLM reasoning)**:操作类型章节参数值行为“查询授权策略”,属于参数schema枚举范围;动网操作的授权策略列表章节标题下无参数值行,且该列表在查询操作下为选填,因此不构成缺失。params按参数schema提取operationType与policyList,policyList为空列表。 +**校验理由(LLM reasoning)**:操作类型章节的参数值行为“查询授权策略”,属于 schema 枚举允许的取值。动网操作的授权策略列表章节标题下没有非空参数值行,且查询操作下该列表为选填,因此不构成缺失。按 schema 提取 operationType 为“查询授权策略”,policyList 未提供时输出空数组。整体校验通过。 **键名泄漏扫描**:无泄漏 @@ -787,7 +787,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入包含“删除”,按封闭取值范围规则13a映射为“删除授权策略”。删除操作下策略列表为必填,输入给出策略对象“ABC-123”,按删除形格式整理为“1. 策略标识是ABC-123”。其余字段无需在删除形中补充,因此无错误。 +**提参理由(LLM reasoning)**:输入中“删除”是合法取值范围中“删除授权策略”的简写,按规则13a直接映射;“ABC-123”是删除操作指向的策略标识,按删除形列表约束整理为“1. 策略标识是ABC-123”。未发现缺失或非法值。 ### 服务端(校验 + 提参) @@ -861,7 +861,7 @@ } ``` -**校验理由(LLM reasoning)**:操作类型为“删除授权策略”,在枚举范围内;删除操作仅要求提供授权策略标识,动网操作的授权策略列表已提供一条标识“ABC-123”,格式与语义均符合要求;按参数 schema 提取参数,未提供的其他策略字段置为 null。 +**校验理由(LLM reasoning)**:操作类型“删除授权策略”在枚举范围内;删除授权策略的必填参数仅为授权策略标识,输入列表第1条提供了策略标识ABC-123,非空且无明显格式或取值违规,因此校验通过;其余字段在删除场景下未要求,按参数schema输出为null。 **键名泄漏扫描**:无泄漏 @@ -887,7 +887,7 @@ 请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 ## 动网操作的授权策略列表 -1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网 +1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改为校园专网 ## 预期输出 1. 授权操作执行结果,取值范围: 成功、失败、部分成功; @@ -895,7 +895,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:操作类型:输入中的“改成”属于修改授权策略的同义表达,按约束映射为“修改授权策略”。动网操作的授权策略列表:用户明确指定策略标识 UUID,并要求将业务场景改为“校园专网”,属于修改类描述,按规则合并标识与修改描述,组织为单条编号条目。 +**提参理由(LLM reasoning)**:输入中的“改成”属于操作类型同义对清单中“修改授权策略”的认可同义词,因此“授权策略的操作类型”提取为“修改授权策略”。修改类输入中提供了策略标识 UUID 和修改描述“业务场景改成校园专网”,按规则17将作用对象标识与修改描述合并保留在“动网操作的授权策略列表”中,并格式化为单条条目。未发现缺失或违反封闭取值范围的情况。 ### 服务端(校验 + 提参) @@ -957,21 +957,16 @@ { "slot_name": "policyList", "code": "invalid_value", - "message": "动网操作的授权策略列表第1条:修改授权策略时仅支持修改有效期,不支持修改业务场景;该条目表达将业务场景修改为“校园专网”的意图,违反约束。" - }, - { - "slot_name": "policyList", - "code": "missing_required", - "message": "动网操作的授权策略列表第1条:修改授权策略时缺少必填的新有效期信息。" + "message": "修改授权策略仅支持修改有效期,但动网操作的授权策略列表第1条试图将业务场景改为校园专网,属于修改不允许修改的字段。" } ] ``` -**校验理由(LLM reasoning)**:(未采集) +**校验理由(LLM reasoning)**:操作类型为“修改授权策略”,已提取。策略列表第1条提供策略标识和将业务场景改为校园专网的意图。模板正文规定修改授权策略仅支持修改有效期,因此该业务场景修改意图超出可修改范围,判定为invalid_value,semantic_verdict=false。params按参数schema提取:policyId取自策略标识,scene取值为校园专网,其余未提供字段置null。 **键名泄漏扫描**:无泄漏 -**判定**:match=True(client_prompt=True) +**判定**:match=True(client_prompt=False) --- @@ -1001,7 +996,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:操作类型:输入含“新增”,属于封闭取值范围认可的简写,映射为“新增授权策略”。策略列表:操作类型为新增授权策略时该列表必填;输入中“场景校园专网”对应业务场景,“处置业务抢通”对应处置类型,“操作隧道调优”对应操作名称,“有效期2026-13-01~2030-12-31”对应有效期。按新增形格式组织为编号条目并保留原日期内容,月份异常值按规则16原样提取,不由提取阶段判定为invalid_value。 +**提参理由(LLM reasoning)**:“新增授权”包含封闭取值范围内的合法简写“新增”,按规则13a映射为“新增授权策略”。策略列表按新增形组织为单条,字段分别从“场景校园专网”“处置业务抢通”“操作隧道调优”“有效期2026-13-01~2030-12-31”映射为业务场景、处置类型、操作名称、有效期,并保留日期原文(已为 YYYY-MM-DD~YYYY-MM-DD 区间格式,月值异常按规则16原样提取,不在此阶段拦截)。 ### 服务端(校验 + 提参) @@ -1063,7 +1058,7 @@ { "slot_name": "policyList", "code": "format_error", - "message": "策略列表第1条的有效期月份无效:2026-13-01 不是有效日期,月份应为1~12。" + "message": "动网操作的授权策略列表第1条的有效期“2026-13-01~2030-12-31”中月份“13”无效,日期格式不合法。" } ] ``` @@ -1103,7 +1098,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入“新增一个授权”中的“新增”命中操作类型合法简写,映射为“新增授权策略”。操作类型为新增时,授权策略列表为必填;输入依次给出业务场景、处置类型、操作名称、有效期,按新增形字段顺序整理为单条编号条目。日期已符合YYYY-MM-DD~YYYY-MM-DD格式,无需转换。 +**提参理由(LLM reasoning)**:输入中的“新增”命中封闭取值范围中“新增授权策略”的简写,按规则13a映射为“新增授权策略”。由于操作类型为新增,授权策略列表为必填,输入中依次给出了业务场景、处置类型、操作名称、有效期四个新增形字段,按值约束顺序整理为编号条目,日期已符合YYYY-MM-DD~YYYY-MM-DD格式,故直接保留。未发现缺失或非法值,slot_errors为空。 ### 服务端(校验 + 提参) @@ -1165,12 +1160,12 @@ { "slot_name": "policyList", "code": "invalid_value", - "message": "动网操作的授权策略列表第1条的处置类型应为简短处置动作短语(如业务抢通、业务优化、路由调整),实际为整句描述“网络出了问题要赶紧恢复”,不符合取值约束。" + "message": "策略列表第1条的处置类型为整句描述“网络出了问题要赶紧恢复”,应使用简短的处置动作短语(如业务抢通、业务优化、路由调整)" } ] ``` -**校验理由(LLM reasoning)**:(未采集) +**校验理由(LLM reasoning)**:操作类型“新增授权策略”在枚举范围内;动网操作的授权策略列表包含1条,新增必填的业务场景、处置类型、操作名称、有效期均已提供,有效期格式为完整区间且起始早于结束;但处置类型“网络出了问题要赶紧恢复”为整句描述,不符合参数 schema 要求的简短处置动作短语,故判定 invalid_value,整体校验不通过。 **键名泄漏扫描**:无泄漏 @@ -1204,7 +1199,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:操作类型:输入中的“加个”属于认可同义对清单(加/加个→新增授权策略),因此提取为“新增授权策略”。策略列表:输入提供了业务场景(校园专网)、处置类型(业务抢通)、操作名称(隧道调优)三个正向约束,按约定格式组织为编号条目;有效期用户明确表示“先不填后面补”,属于缺省声明,不提取为该字段值,因此省略该字段及分隔符。未发现需要报告的 slot 错误。 +**提参理由(LLM reasoning)**:“加个”命中同义对清单中的“加个→新增授权策略”,因此操作类型提取为“新增授权策略”。策略列表中,“校园专网”对应业务场景,“业务抢通”对应处置类型,“隧道调优”对应操作名称;“有效期先不填后面补”属于明确的字段缺省声明,按规则不作为字段值提取,因此仅保留已提供的三个字段并按要求格式化为编号条目。未触发 slot_errors。 ### 服务端(校验 + 提参) @@ -1266,12 +1261,12 @@ { "slot_name": "policyList", "code": "missing_required", - "message": "动网操作的授权策略列表第1条缺少有效期:新增授权策略要求每条提供动网操作的业务场景、动网操作的处置类型、动网操作名称、有效期。" + "message": "动网操作的授权策略列表第1条缺少创建授权策略时必填的“有效期”字段。" } ] ``` -**校验理由(LLM reasoning)**:操作类型为新增授权策略,取值合法。动网操作的授权策略列表提供1条,已提供业务场景、处置类型、操作名称,但未提供有效期;新增授权策略下有效期属于必填字段,因此判定语义校验失败并报告missing_required。policyId在创建场景下非必填,不报错。参数提取按实际提供的参数值填充,缺失的policyId和validityPeriod输出null。 +**校验理由(LLM reasoning)**:操作类型为“新增授权策略”,在参数 schema 枚举范围内;模板正文要求新增授权策略列表条目必填业务场景、处置类型、操作名称、有效期。输入列表第1条提供了业务场景、处置类型和操作名称,但未提供有效期,因此校验失败并报告 missing_required。参数提取按 schema 结构进行,缺失字段置 null。 **键名泄漏扫描**:无泄漏 @@ -1307,7 +1302,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中出现“新增”,按封闭取值范围映射为“新增授权策略”。动网操作的授权策略列表按三条进行编号并补全为“字段名是值”格式;有效期“2026-13-01~2030-12-31”虽含无效月份,但按规则16对非封闭取值范围槽位原样提取,不报invalid_value。 +**提参理由(LLM reasoning)**:输入“新增三条授权”中的“新增”符合封闭取值范围简写映射规则,提取为“新增授权策略”。策略列表提取三条条目,按字段顺序(业务场景、处置类型、操作名称、有效期)和编号格式组织;第二条有效期保留原文日期格式,不因格式异常而丢弃。 ### 服务端(校验 + 提参) @@ -1369,12 +1364,12 @@ { "slot_name": "policyList", "code": "format_error", - "message": "动网操作的授权策略列表第2条的有效期“2026-13-01~2030-12-31”月份无效,13月不存在,应为1~12月。" + "message": "策略列表第2条的有效期“2026-13-01~2030-12-31”存在无效月份(13月),请提供有效日期。" } ] ``` -**校验理由(LLM reasoning)**:操作类型“新增授权策略”在schema枚举范围内,合规。新增授权策略时,policyList每条必须包含业务场景、处置类型、操作名称、有效期;第1条和第3条均满足要求,第2条有效期月份为13,属于无效日期,构成format_error,因此semantic_verdict为false。参数提取按输入顺序完整提取三条策略,未提供的policyId按要求输出null。 +**校验理由(LLM reasoning)**:(未采集) **键名泄漏扫描**:无泄漏 @@ -1428,7 +1423,7 @@ input.schema: } ``` -**提参理由(LLM reasoning)**:输入中未出现“新增/修改/删除/查询”等操作类型合法取值或其认可同义对,因此“授权策略的操作类型”置为null并报告missing_input。“scene=校园专网”和“actionType=业务抢通”分别对应策略列表中的业务场景和处置类型字段,属于非封闭列表slot,按规则提取并格式化为编号条目。 +**提参理由(LLM reasoning)**:输入中仅有 scene=校园专网、actionType=业务抢通,未出现“新增、修改、删除、查询”等操作类型合法取值或其认可同义词,按封闭取值范围规则不能推断,故“授权策略的操作类型”置为 null 并报告 missing_input。scene 对应“业务场景”,actionType 对应“处置类型”,这两项属于动网操作授权策略列表中允许的查询条件字段,因此按“字段名是值”格式合并为一条编号列表。 ### 服务端(校验 + 提参) @@ -1462,7 +1457,7 @@ input.schema: } ``` -**提参理由(LLM reasoning)**:输入为否定语义“不要新增校园专网的授权策略,我还没准备好”,其中“新增”处于排除/否定语境,不能作为正向的操作类型值提取;“校园专网”等列表信息同样处于否定语义中,不提取。操作类型为必填槽且未提取到有效值,因此报告 missing_input;策略列表为可选槽,且操作类型未确定为新增/修改/删除时不做条件必填判定,置为 null 且不报错。 +**提参理由(LLM reasoning)**:输入中虽然出现“新增”,但位于“不要新增……我还没准备好”的否定/排除语义中,依据规则3不得作为正向slot值提取,因此“授权策略的操作类型”视为缺失并报告missing_input;“动网操作的授权策略列表”中的“校园专网”同样处于否定语义中,不提取,且操作类型未确定,不触发该列表的必填条件,故返回null。 ### 服务端(校验 + 提参) diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java index 0b731b3e..c1694fa6 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java @@ -56,27 +56,29 @@ void should_loadAndValidateAllScenarios() { void should_loadAndValidateFullSuiteScenarios() { List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios-100.json"); - assertEquals(100, scenarios.size()); + assertEquals(102, scenarios.size()); long variants = scenarios.stream() .filter(s -> s.label().endsWith("-varname") || s.label().endsWith("-varfields") || s.label().endsWith("-varflat") || s.label().endsWith("-varreq") || s.label().endsWith("-varsch") + || s.label().endsWith("-varuuid") + || s.label().endsWith("-vardesc") || s.label().endsWith("-dual")) .count(); - assertEquals(45, variants); + assertEquals(47, variants); long clientInterceptions = scenarios.stream() .filter(s -> s.expected().client() != null && s.expected().client().outcome() != null) .count(); - assertEquals(10, clientInterceptions); + assertEquals(9, clientInterceptions); long serverRejections = scenarios.stream() .filter(s -> s.expected().server() != null && "validation_semantic_rejected".equals(s.expected().server().outcome())) .count(); - assertEquals(30, serverRejections); + assertEquals(37, serverRejections); long validateSchemas = scenarios.stream().filter(s -> s.validateSchema() != null).count(); - assertEquals(42, validateSchemas); + assertEquals(43, validateSchemas); } @Test From 9c0b39620a6177e58fb493c0586e574863969657 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 29 Aug 2026 10:19:05 +0800 Subject: [PATCH 231/295] feat(prompt): unify prompt resource configurability with source-type routing and frozen snapshots Signed-off-by: w00609601 --- .../openan/a2at/sdk/client/A2ATClient.java | 31 +- .../assembly/DefaultA2ATClientBuilder.java | 2 +- .../api/A2ATClientNegotiationApiTest.java | 23 +- .../A2ATClientNegotiationEnvConfigTest.java | 26 +- .../a2at/sdk/core/model/A2ATConfig.java | 38 ++ .../sdk/core/model/PromptRuntimeConfig.java | 13 +- .../a2at/sdk/core/model/PromptTemplate.java | 13 +- .../a2at/sdk/core/resources/PathSegments.java | 35 ++ .../sdk/core/validation/ContentValidator.java | 4 +- .../validation/TemplateContentLoader.java | 26 ++ .../core/validation/ValidationPipeline.java | 112 +++++- .../a2at/sdk/core/model/A2ATConfigTest.java | 145 ++++++- .../core/model/PromptRuntimeConfigTest.java | 5 +- .../sdk/core/model/PromptTemplateTest.java | 53 +++ .../sdk/core/resources/PathSegmentsTest.java | 85 ++++ .../validation/ValidationPipelineTest.java | 135 +++++++ .../openan/a2at/sdk/corpus/CaseEngine.java | 5 - .../a2at/sdk/corpus/TaskApiAssembler.java | 5 +- .../sdk/corpus/property/PropertyHarness.java | 5 - .../validate/programming-errors.json | 56 +-- .../sdk/negotiation/content/Vocabulary.java | 100 ++--- .../generation/NegotiationContentService.java | 84 +--- .../NegotiationGenerationOrchestrator.java | 83 +--- ...otiationGenerationOrchestratorBuilder.java | 42 +- .../DefaultNegotiationTemplateLoader.java | 102 ++--- .../resources/NegotiationTemplateLoader.java | 15 +- .../validation/ParamExtractor.java | 30 +- .../negotiation/content/VocabularyTest.java | 182 +-------- .../AbortMessageGenerationTest.java | 5 +- .../CustomRootPromptsIgnoredTest.java | 36 +- .../generation/FromTextLlmPipelineTest.java | 5 - ...nerationOrchestratorBuilderWiringTest.java | 25 ++ ...NegotiationGenerationOrchestratorTest.java | 41 +- .../generation/NegotiationGeneratorsTest.java | 4 +- ...otiationPromptRendererConcurrencyTest.java | 9 +- .../NegotiationResourceIntegrityTest.java | 17 +- .../NegotiationTemplateQueryBoundaryTest.java | 162 -------- .../TemplateUriEntryMatrixTest.java | 67 +--- .../CustomRootTemplateOverrideTest.java | 135 ------- .../CustomRootVocabularyOverrideTest.java | 99 ----- .../NegotiationLanguageSwitchTest.java | 30 +- .../UnsupportedLanguageBehaviorTest.java | 22 +- .../NegotiationErrorCodeUsageMatrixTest.java | 12 +- ...NegotiationExceptionTreeRootCatchTest.java | 41 +- .../NegotiationLogEventContractTest.java | 65 +--- .../DefaultNegotiationTemplateLoaderTest.java | 367 +++++++++--------- ...rdTemplatesNegotiationConsistencyTest.java | 13 +- .../validation/ParamExtractorTest.java | 129 +++++- a2a-t-prompt/pom.xml | 6 + .../catalog/PromptTemplateCatalog.java | 228 +++++++---- .../catalog/TemplateQueryService.java | 24 +- .../LocalFilePromptScenarioCatalogLoader.java | 33 +- .../LocalFilePromptSlotSchemaLoader.java | 79 ++-- .../loader/LocalFilePromptTemplateLoader.java | 77 ++-- .../loader/LocalFileResourceSnapshot.java | 86 ++++ .../loader/PromptResourceAccess.java | 101 +++-- .../validation/DefaultContentValidator.java | 77 ++-- .../catalog/TemplateQueryServiceTest.java | 224 +++++++++++ .../loader/LocalFilePromptLoadersTest.java | 177 ++++++++- .../loader/PromptResourceAccessTest.java | 144 ++++++- .../DefaultContentValidatorTest.java | 91 ++--- .../src/test/resources/logback-test.xml | 11 + .../ClasspathPromptResourceLoader.java | 15 + .../clarification_negotiation/en-US/system.md | 1 - .../clarification_negotiation/en-US/user.md | 4 - .../clarification_negotiation/zh-CN/system.md | 1 - .../clarification_negotiation/zh-CN/user.md | 4 - .../fulfillment_negotiation/en-US/system.md | 1 - .../fulfillment_negotiation/en-US/user.md | 4 - .../fulfillment_negotiation/zh-CN/system.md | 1 - .../fulfillment_negotiation/zh-CN/user.md | 4 - .../ClasspathPromptResourceLoaderTest.java | 37 ++ .../sample/authz_policy/AuthzSampleMain.java | 5 +- .../NegotiationAbnormalEvaluationMain.java | 5 - .../api/NegotiationFacadeApiSymmetryTest.java | 42 +- .../NegotiationFacadeOutputSymmetryTest.java | 6 +- .../api/NegotiationV3ApiSurfaceTest.java | 35 +- .../openan/a2at/sdk/server/A2ATServer.java | 29 +- .../assembly/DefaultA2ATServerBuilder.java | 2 +- .../api/A2ATServerNegotiationApiTest.java | 23 +- env.example | 7 +- 81 files changed, 2443 insertions(+), 1880 deletions(-) create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java create mode 100644 a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java create mode 100644 a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ValidationPipelineTest.java delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java delete mode 100644 a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootVocabularyOverrideTest.java create mode 100644 a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java create mode 100644 a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java create mode 100644 a2a-t-prompt/src/test/resources/logback-test.xml delete mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/en-US/system.md delete mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/en-US/user.md delete mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/zh-CN/system.md delete mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/zh-CN/user.md delete mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/en-US/system.md delete mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/en-US/user.md delete mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/zh-CN/system.md delete mode 100644 a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/zh-CN/user.md diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index c73e8ab0..d2c9b2a6 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -47,8 +47,8 @@ public final class A2ATClient { */ public A2ATClient(Path envPath) { Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); - A2ATConfig config = NegotiationContentService.resolvePromptResourceLocalRootDir( - A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + A2ATConfig config = + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); @@ -479,33 +479,6 @@ public Optional getPrompt(@NonNull TemplateUri templateUri) { return templateQueryService.getPrompt(templateUri); } - /** - * Lists every negotiation template available for the configured language. - * - *

        This query never throws: templates that exist nowhere for the language are skipped and an empty list is - * returned when no template can be loaded at all. - * - * @return loadable negotiation templates of the configured language, in a fixed type and phase order; empty when - * none can be loaded - */ - public List getNegotiationPrompts() { - return negotiationContentService.getNegotiationPrompts(); - } - - /** - * Loads one negotiation template by its URI. - * - *

        This query never throws: a missing template yields an empty result together with a warning log instead of a - * failure. - * - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} - * @return the addressed template, or an empty optional when no template exists for it in the configured language - */ - public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return negotiationContentService.getNegotiationPrompt(templateUri); - } - /** * Validates a propose-phase negotiation message and extracts its parameters. * diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index 61045a09..d563c579 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -180,7 +180,7 @@ public TemplateQueryService buildTemplateQueryService() { require(config, "Unified SDK config must be configured."); requireSupportedConfig(); return new TemplateQueryService( - config.prompt().language(), config.prompt().localRootDir()); + config.prompt().language(), config.prompt().sourceType(), config.prompt().localRootDir()); } private void requireSupportedConfig() { diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index 10fa8a82..1eed9906 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -83,10 +83,8 @@ void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOE @Test void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { - assertEquals( - 7, new A2ATClient(writeEnv("zh-CN")).getNegotiationPrompts().size()); - assertEquals( - 7, new A2ATClient(writeEnv("en-US")).getNegotiationPrompts().size()); + assertEquals(7, negotiationPrompts(new A2ATClient(writeEnv("zh-CN"))).size()); + assertEquals(7, negotiationPrompts(new A2ATClient(writeEnv("en-US"))).size()); } @Test @@ -127,7 +125,7 @@ void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { A2ATClient client = new A2ATClient(writeEnv("zh-CN")); PromptTemplate template = - client.getNegotiationPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); + client.getPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); assertFalse(template.content().isBlank()); } @@ -136,18 +134,25 @@ void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - assertTrue(client.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); + assertTrue(client.getPrompt(INFORMATION_PROPOSE).isPresent()); assertTrue(client - .getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) .isPresent()); assertFalse(client - .getNegotiationPrompt(TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v9")) + .getPrompt(TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v9")) .isPresent()); - PromptTemplate template = client.getNegotiationPrompt(INFORMATION_PROPOSE).orElseThrow(); + PromptTemplate template = client.getPrompt(INFORMATION_PROPOSE).orElseThrow(); assertEquals(INFORMATION_PROPOSE, template.templateUri()); assertFalse(template.content().isBlank()); } + private static List negotiationPrompts(A2ATClient client) { + return client.getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + private static Path writeEnv(String language) throws IOException { Path tempDir = Files.createTempDirectory("a2at-client-negotiation-env"); Path envFile = tempDir.resolve("client.env"); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index 19d07c87..39b53823 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -41,10 +41,11 @@ /** * Drives the env-configured negotiation behavior of the client facade end to end. * - *

        All three negotiation-relevant env keys must become observable in the facade behavior: the language selects the - * Chinese templates, the local resource root overrides one template, and the LLM attempt limit bounds the retry loop of - * the from-text generation. A second test proves the zero-configuration defaults (English templates, three attempts, - * built-in resources) work out of the box. + *

        The negotiation-relevant env keys must become observable in the facade behavior: the language selects the Chinese + * templates, and the LLM attempt limit bounds the retry loop of the from-text generation. Under ADR 0004 the negotiation + * templates are classpath-fixed, so a configured local resource root is ignored in {@code classpath} mode: the built-in + * template wins over any local override. A second test proves the zero-configuration defaults (English templates, three + * attempts, built-in resources) work out of the box. */ class A2ATClientNegotiationEnvConfigTest { @@ -88,7 +89,7 @@ void detachAppender() { } @Test - void languageLocalRootAndMaxAttemptsAreAllObservableInFacadeBehavior() throws IOException { + void languageAndMaxAttemptsAreObservableAndClasspathIgnoresLocalRoot() throws IOException { Path customRoot = writeCustomRootWithMarkeredTemplate(); Path envFile = writeEnvFile( "A2AT_LANGUAGE=zh-CN", @@ -109,9 +110,9 @@ void languageLocalRootAndMaxAttemptsAreAllObservableInFacadeBehavior() throws IO INFORMATION_PROPOSE); assertTrue(result.promptText().contains("所需信息项"), "the zh-CN language must select the Chinese templates"); - assertTrue( + assertFalse( result.promptText().contains(CUSTOM_TEMPLATE_MARKER), - "the local resource root template must win over the built-in template"); + "the configured local resource root must be ignored in classpath mode and the built-in template used"); NegotiationGenerationException failure = assertThrows( NegotiationGenerationException.class, @@ -144,8 +145,8 @@ void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { assertTrue(result.promptText().contains("## Information Negotiation"), "the default language must be en-US"); assertTrue(result.promptText().contains("Required Information Items")); - assertEquals(7, client.getNegotiationPrompts().size(), "the built-in resources must be used by default"); - assertTrue(client.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); + assertEquals(7, negotiationPrompts(client).size(), "the built-in resources must be used by default"); + assertTrue(client.getPrompt(INFORMATION_PROPOSE).isPresent()); assertThrows( NegotiationGenerationException.class, @@ -166,6 +167,13 @@ private Path writeEnvFile(String... lines) throws IOException { return envFile; } + private static List negotiationPrompts(A2ATClient client) { + return client.getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + private Path writeCustomRootWithMarkeredTemplate() throws IOException { Path customTemplate = tempDir.resolve("custom-root") .resolve("templates") diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java index de4a0b65..0646b27d 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java @@ -32,4 +32,42 @@ public record A2ATConfig( NegotiationConfig.fromMap(values), PromptComplianceConfig.fromMap(values)); } + + /** + * Resolves the relative prompt resource local root directory against the `.env` file location. + * + *

        The returned config carries the local root as an absolute normalized path: a relative configured root is + * resolved against the `.env` file parent, an absolute configured root is only normalized. An unset (null or + * blank) local root is preserved as-is, since only {@code local_file} mode consumes the value (and enforces its + * presence at assembly time). + * + * @param config unified SDK config as loaded from the `.env` file + * @param envPath resolved `.env` file path the config was loaded from; a relative path is absolved against the + * current working directory before its parent is derived + * @return config with the local root resolved to an absolute normalized path, or unchanged when the local root + * is unset (null or blank) + */ + public static @NonNull A2ATConfig resolvePromptResourceLocalRootDir( + @NonNull A2ATConfig config, @NonNull Path envPath) { + String localRootDir = config.prompt().localRootDir(); + if (localRootDir == null || localRootDir.isBlank()) { + return config; + } + Path localRootPath = Path.of(localRootDir); + Path resolvedLocalRootPath = localRootPath.isAbsolute() + ? localRootPath.normalize() + : envBaseDir(envPath).resolve(localRootPath).toAbsolutePath().normalize(); + return new A2ATConfig( + new PromptRuntimeConfig( + config.prompt().language(), config.prompt().sourceType(), resolvedLocalRootPath.toString()), + config.llm(), + config.inputLimits(), + config.negotiation(), + config.promptCompliance()); + } + + private static Path envBaseDir(Path envPath) { + Path parent = envPath.toAbsolutePath().normalize().getParent(); + return parent != null ? parent : Path.of("").toAbsolutePath(); + } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfig.java index 8426dd5a..e61c0f77 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfig.java @@ -1,8 +1,7 @@ package net.openan.a2at.sdk.core.model; -import org.apache.commons.lang3.StringUtils; - import java.util.Map; +import org.apache.commons.lang3.StringUtils; /** * Prompt runtime configuration resolved from unified SDK config. @@ -13,7 +12,13 @@ public record PromptRuntimeConfig(String language, String sourceType, String loc private static final String DEFAULT_LANGUAGE = "en-US"; - private static final String DEFAULT_SOURCE_TYPE = "classpath"; + /** Source-type selector value for loading business content from the built-in classpath. */ + public static final String SOURCE_TYPE_CLASSPATH = "classpath"; + + /** Source-type selector value for loading business content from the configured local root. */ + public static final String SOURCE_TYPE_LOCAL_FILE = "local_file"; + + private static final String DEFAULT_SOURCE_TYPE = SOURCE_TYPE_CLASSPATH; /** * Builds one prompt runtime config from raw `.env` values. @@ -25,6 +30,6 @@ public static PromptRuntimeConfig fromMap(Map values) { return new PromptRuntimeConfig( StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.PromptRuntime.LANGUAGE), DEFAULT_LANGUAGE), StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.PromptRuntime.SOURCE_TYPE), DEFAULT_SOURCE_TYPE), - StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR), ".")); + values.get(A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR)); } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java index 1fe92d57..483fd733 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java @@ -11,7 +11,18 @@ * @param description template description taken from the leading HTML comment; empty string when the template has no * comment * @param content full template file text; null when the template content is unavailable + * @param source effective origin of the loaded template, either {@code classpath} or {@code local} * @since 2026-08 */ public record PromptTemplate( - @NonNull TemplateUri templateUri, @NonNull String description, @Nullable String content) {} + @NonNull TemplateUri templateUri, + @NonNull String description, + @Nullable String content, + @NonNull String source) { + + /** Source marker for templates loaded from the built-in classpath resources. */ + public static final String SOURCE_CLASSPATH = "classpath"; + + /** Source marker for templates loaded from the configured local root. */ + public static final String SOURCE_LOCAL = "local"; +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java index d8fc6c27..db144716 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java @@ -29,4 +29,39 @@ public static boolean isSimpleSegment(@Nullable String value) { && !value.contains("\\") && !value.contains(".."); } + + /** + * Requires the value to be a non-blank simple path segment, throwing an {@link IllegalArgumentException} + * otherwise. + * + * @param value candidate path segment such as a language or category identifier; may be {@code null} + * @param label human-readable label describing the segment, used in the failure message + * @throws IllegalArgumentException when the value is not a simple path segment + */ + public static void requireSimpleSegment(@Nullable String value, String label) { + if (!isSimpleSegment(value)) { + throw new IllegalArgumentException( + label + " must be a non-blank simple path segment but was " + value + "."); + } + } + + /** + * Requires the value to be a non-blank relative path whose slash-separated segments are each simple path + * segments, throwing an {@link IllegalArgumentException} otherwise. + * + * @param value candidate relative path such as a scenario code; may be {@code null} + * @param label human-readable label describing the path, used in the failure message + * @throws IllegalArgumentException when the value is null, blank or contains a non-simple path segment + */ + public static void requireSimpleRelativePath(@Nullable String value, String label) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(label + " must be a non-blank relative path but was " + value + "."); + } + for (String segment : value.split("/", -1)) { + if (!isSimpleSegment(segment)) { + throw new IllegalArgumentException( + label + " must contain only simple path segments but was " + value + "."); + } + } + } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java index d17c9f65..ab764865 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java @@ -23,8 +23,8 @@ public interface ContentValidator { * @param templateUri URI of the template the content is validated against, such as * {@code Task-T/network-layer/ran-energy-saving/v1} * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank + * @throws ContentValidationException with {@code validation_invalid_input} if the template URI is null or malformed, + * the prompt is null or blank, or the schema is null * @throws ContentValidationException if the validation fails at any stage */ FilledParamData validate( diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java new file mode 100644 index 00000000..30c025a4 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java @@ -0,0 +1,26 @@ +package net.openan.a2at.sdk.core.validation; + +import org.jspecify.annotations.NonNull; + +/** + * Loads the template text for a template reference during validation. + * + *

        Injected into {@link ValidationPipeline} as an optional gate that runs after the rule gate and before semantic + * validation. Loaders resolve the template the content is validated against, so the semantic validator can compare the + * content against the authoritative template body. + * + * @param template addressing type the loader resolves + * @since 2026-08 + */ +@FunctionalInterface +public interface TemplateContentLoader { + + /** + * Loads the template text for the given template reference. + * + * @param reference template addressing value to resolve + * @return loaded template text + * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the template cannot be resolved + */ + @NonNull String load(@NonNull T reference); +} \ No newline at end of file diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java index 13a02528..6a57dd3a 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java @@ -9,10 +9,11 @@ import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import net.openan.a2at.sdk.core.model.TemplateUri; /** * Orchestrates the content validation pipeline: input validation, rule-level gate, retryable semantic validation and @@ -33,10 +34,12 @@ public final class ValidationPipeline { private final SemanticValidator semanticValidator; + private final TemplateContentLoader templateContentLoader; + private final int maxAttempts; /** - * Creates a validation pipeline. + * Creates a validation pipeline without a template loading gate. * * @param ruleChecker rule-level checker used as the entry gate * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters @@ -45,9 +48,64 @@ public final class ValidationPipeline { */ public ValidationPipeline( @NonNull RuleChecker ruleChecker, @NonNull SemanticValidator semanticValidator, int maxAttempts) { + this(ruleChecker, semanticValidator, maxAttempts, null); + } + + /** + * Creates a validation pipeline with an optional template loading gate. + * + *

        The loader, when injected, runs after the rule gate and before semantic validation to resolve the template + * body for the {@link #validate(String, Map, Object)} variant. A {@code null} loader leaves only the preloaded + * {@link #validate(String, Map, Object, String)} variant available. + * + * @param ruleChecker rule-level checker used as the entry gate + * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters + * @param maxAttempts maximum number of retry attempts for the semantic validation step + * @param templateContentLoader optional loader resolving the template body, may be null + * @throws NullPointerException if the rule checker or the semantic validator is null + */ + public ValidationPipeline( + @NonNull RuleChecker ruleChecker, + @NonNull SemanticValidator semanticValidator, + int maxAttempts, + @Nullable TemplateContentLoader templateContentLoader) { this.ruleChecker = Objects.requireNonNull(ruleChecker, "ruleChecker"); this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); this.maxAttempts = maxAttempts; + this.templateContentLoader = templateContentLoader; + } + + /** + * Validates one content prompt and extracts its filled parameters through the full pipeline, loading the template + * body via the injected template loading gate after the rule gate and before semantic validation. + * + * @param prompt content prompt text + * @param schema caller-provided parameter JSON schema + * @param reference template addressing value the content is validated against + * @return filled parameter data carrying the merged parameters + * @throws ContentValidationException with {@code validation_invalid_input} if the prompt is null or blank, the + * schema is null, or the reference is null + * @throws IllegalStateException if no {@link TemplateContentLoader} was injected into this pipeline + * @throws ContentValidationException if the validation fails at any stage + */ + public @NonNull FilledParamData validate( + @NonNull String prompt, @NonNull Map schema, @NonNull T reference) { + if (templateContentLoader == null) { + throw new IllegalStateException( + "Template content loader is not configured; provide the template content explicitly."); + } + + Map contextParams = validateInputsAndRunRuleGate(prompt, schema, reference); + + String templateContent; + try { + templateContent = templateContentLoader.load(reference); + } catch (ResourceNotFoundException exception) { + throw new ContentValidationException( + A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, exception.getMessage(), exception); + } + + return runSemanticValidationAndMerge(prompt, schema, reference, contextParams, templateContent); } /** @@ -58,21 +116,42 @@ public ValidationPipeline( * @param reference template addressing value the content is validated against * @param templateContent loaded template text used as a reference for structure/completeness checks * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt, schema or reference is null - * @throws IllegalArgumentException if the prompt is blank + * @throws ContentValidationException with {@code validation_invalid_input} if the prompt is null or blank, the + * schema is null, or the reference is null * @throws ContentValidationException if the validation fails at any stage */ public @NonNull FilledParamData validate( - @NonNull String prompt, @NonNull Map schema, @NonNull T reference, @NonNull String templateContent) { - Objects.requireNonNull(schema, "schema"); - Objects.requireNonNull(prompt, "prompt"); - if (prompt.isBlank()) { - throw new IllegalArgumentException("Prompt must not be blank."); - } - Objects.requireNonNull(reference, "reference"); + @NonNull String prompt, + @NonNull Map schema, + @NonNull T reference, + @NonNull String templateContent) { + Map contextParams = validateInputsAndRunRuleGate(prompt, schema, reference); + return runSemanticValidationAndMerge(prompt, schema, reference, contextParams, templateContent); + } - Map contextParams = ruleChecker.check(prompt); + private Map validateInputsAndRunRuleGate( + @NonNull String prompt, @NonNull Map schema, @NonNull T reference) { + if (schema == null) { + throw new ContentValidationException( + A2ATErrorCodes.VALIDATION_INVALID_INPUT, "Parameter schema must not be null."); + } + if (prompt == null || prompt.isBlank()) { + throw new ContentValidationException( + A2ATErrorCodes.VALIDATION_INVALID_INPUT, "Prompt must not be null or blank."); + } + if (reference == null) { + throw new ContentValidationException( + A2ATErrorCodes.VALIDATION_INVALID_INPUT, "Template reference must not be null."); + } + return ruleChecker.check(prompt); + } + private @NonNull FilledParamData runSemanticValidationAndMerge( + String prompt, + Map schema, + T reference, + Map contextParams, + String templateContent) { ValidationResult semanticResult; try { semanticResult = RetryUtil.withRetry( @@ -102,11 +181,14 @@ public ValidationPipeline( Map merged = mergeParams(contextParams, semanticResult.params()); FilledParamData filledParamData = new FilledParamData(merged); - LOGGER.atInfo().log("content_validation_completed param_keys={}", filledParamData.data().keySet()); + LOGGER.atInfo().log( + "content_validation_completed param_keys={}", + filledParamData.data().keySet()); return filledParamData; } - private static Map mergeParams(Map contextParams, Map semanticParams) { + private static Map mergeParams( + Map contextParams, Map semanticParams) { Map merged = new LinkedHashMap<>(contextParams); for (Map.Entry entry : semanticParams.entrySet()) { String key = entry.getKey(); @@ -118,4 +200,4 @@ private static Map mergeParams(Map contextParams } return Collections.unmodifiableMap(merged); } -} \ No newline at end of file +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java index f686df73..1d2b14cd 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java @@ -81,7 +81,7 @@ void should_buildUnifiedConfig_When_envFileContainsAllKeys() throws IOException * *

        Scenario: A .env file contains only minimal required keys (LLM provider and negotiation). * Expected result: Default values are applied for prompt configuration (classpath source type, - * current directory root). + * unset local root directory). * * @throws IOException if temp directory creation fails */ @@ -99,7 +99,7 @@ void should_useClasspathDefaults_When_promptKeysAreMissing() throws IOException A2ATConfig config = A2ATConfig.load(envFile); assertEquals("classpath", config.prompt().sourceType()); - assertEquals(".", config.prompt().localRootDir()); + assertNull(config.prompt().localRootDir()); } /** @@ -116,6 +116,147 @@ void should_throwConfigFileNotFoundException_When_envFileDoesNotExist() { assertThrows(ConfigFileNotFoundException.class, () -> A2ATConfig.load(missing)); } + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} resolves + * a relative local root directory to an absolute normalized path against the environment file parent. + * + *

        Scenario: A .env file configures a relative local root directory. Expected result: the returned config + * carries the local root resolved to an absolute normalized path relative to the .env file parent. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_resolveRelativeLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-relative"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=openai + """); + + A2ATConfig resolved = + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); + + assertEquals( + tempDir.resolve("prompt_resources").toAbsolutePath().normalize().toString(), + resolved.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} normalizes + * an already-absolute local root directory without re-resolving it against the environment file parent. + * + *

        Scenario: A .env file configures an absolute local root directory. Expected result: the returned config + * carries the normalized absolute path unchanged. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_normalizeAbsoluteLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-absolute"); + Path absoluteRoot = tempDir.resolve(".").resolve("prompt_resources").toAbsolutePath(); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + "A2AT_PROMPT_SOURCE_TYPE=local_file\n" + + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=" + absoluteRoot + "\n" + + "A2AT_LLM_PROVIDER=openai\n"); + + A2ATConfig resolved = + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); + + assertEquals(absoluteRoot.normalize().toString(), resolved.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} preserves + * an unset (null) local root directory instead of throwing. + * + *

        Scenario: A .env file does not configure a local root directory (classpath source). Expected result: the + * returned config keeps the local root as null. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_preserveNullLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-null"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LLM_PROVIDER=openai + """); + + A2ATConfig resolved = + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); + + assertNull(resolved.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} preserves + * a blank local root directory instead of resolving it to a legal absolute path. + * + *

        Scenario: A config carries a blank local root directory. Expected result: the method returns the original + * config unchanged, treating a blank root like an unset root (matching the downstream + * {@code PromptResourceAccess} and {@code PromptTemplateCatalog} "blank == unset" convention). + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_preserveBlankLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-blank"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\n"); + + A2ATConfig loaded = A2ATConfig.load(envFile); + A2ATConfig config = new A2ATConfig( + new PromptRuntimeConfig(loaded.prompt().language(), loaded.prompt().sourceType(), " "), + loaded.llm(), + loaded.inputLimits(), + loaded.negotiation(), + loaded.promptCompliance()); + + A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(config, envFile); + + assertSame(config, resolved); + assertEquals(" ", resolved.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} does not throw when the + * {@code envPath} is a single-segment relative path with no parent, resolving the relative local root against the + * current working directory instead of a missing parent. + * + *

        Scenario: A single-segment relative {@code envPath} (e.g. {@code Path.of(".env")}) has no parent. Expected + * result: the returned config carries the local root resolved against the current working directory. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_resolveRelativeLocalRootAgainstCwd_When_envPathIsSingleSegment() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-single-segment"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\n"); + + A2ATConfig loaded = A2ATConfig.load(envFile); + A2ATConfig config = new A2ATConfig( + new PromptRuntimeConfig(loaded.prompt().language(), "local_file", "prompt_resources"), + loaded.llm(), + loaded.inputLimits(), + loaded.negotiation(), + loaded.promptCompliance()); + + A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(config, Path.of(".env")); + + assertEquals( + Path.of("").toAbsolutePath().normalize().resolve("prompt_resources").normalize().toString(), + resolved.prompt().localRootDir()); + } + /** * Verifies that the repository's env.example file exists and optionally matches * an upstream template. diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java index 3b5a9744..3250ee1d 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java @@ -1,6 +1,7 @@ package net.openan.a2at.sdk.core.model; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import java.util.Map; import org.junit.jupiter.api.Test; @@ -26,7 +27,7 @@ class PromptRuntimeConfigTest { * Expected result: All fields use predefined defaults: * - language: "en-US" * - sourceType: "classpath" - * - localRootDir: "." (current directory) + * - localRootDir: null (unset) */ @Test void should_useDefaults_When_keysAreMissing() { @@ -36,7 +37,7 @@ void should_useDefaults_When_keysAreMissing() { assertEquals("en-US", config.language()); assertEquals("classpath", config.sourceType()); - assertEquals(".", config.localRootDir()); + assertNull(config.localRootDir()); } /** diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java new file mode 100644 index 00000000..db050d59 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java @@ -0,0 +1,53 @@ +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link PromptTemplate}. + * + *

        Tests cover the four-component record shape and the {@code source()} accessor that identifies the effective + * resource origin ({@code classpath} / {@code local}). + * + * @since 2026-08 + */ +class PromptTemplateTest { + + /** + * Verifies that {@link PromptTemplate} exposes all four components. + * + *

        Scenario: Construct a template with a URI, description, content and a {@code classpath} source. + * Expected result: templateUri(), description(), content() and source() each return the supplied value. + */ + @Test + void should_exposeAllFourComponents_When_templateConstructedWithClasspathSource() { + TemplateUri templateUri = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); + + PromptTemplate template = new PromptTemplate(templateUri, "energy saving", "# template", "classpath"); + + assertEquals(templateUri, template.templateUri()); + assertEquals("energy saving", template.description()); + assertEquals("# template", template.content()); + assertEquals("classpath", template.source()); + } + + /** + * Verifies that {@link PromptTemplate#source()} carries the {@code local} source value. + * + *

        Scenario: Construct a template with a {@code local} source. + * Expected result: source() returns {@code local}. + */ + @Test + void should_exposeLocalSource_When_templateConstructedWithLocalSource() { + TemplateUri templateUri = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); + + PromptTemplate template = new PromptTemplate(templateUri, "", null, "local"); + + assertEquals(templateUri, template.templateUri()); + assertEquals("", template.description()); + assertNull(template.content()); + assertEquals("local", template.source()); + } +} \ No newline at end of file diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java index 4a3b3b1a..428d04b5 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java @@ -1,6 +1,8 @@ package net.openan.a2at.sdk.core.resources; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -43,4 +45,87 @@ void should_acceptValidSegment_When_valueIsSimple() { assertTrue(PathSegments.isSimpleSegment("zh-CN")); assertTrue(PathSegments.isSimpleSegment("a.b")); } + + @Test + void requireSimpleSegment_rejectsNullWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment(null, "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_rejectsBlankWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment(" ", "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_rejectsSlashWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("a/b", "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_rejectsBackslashWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("a\\b", "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_rejectsTraversalWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("..", "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_acceptsValidValues() { + assertDoesNotThrow(() -> { + PathSegments.requireSimpleSegment("ran-energy-saving", "segment"); + PathSegments.requireSimpleSegment("Task-T", "segment"); + PathSegments.requireSimpleSegment("zh-CN", "segment"); + PathSegments.requireSimpleSegment("a.b", "segment"); + }); + } + + @Test + void requireSimpleRelativePath_rejectsEmptySegmentsWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath("a//b", "the path")); + + assertTrue(exception.getMessage().contains("the path")); + } + + @Test + void requireSimpleRelativePath_rejectsTraversalSegmentWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath("../x", "the path")); + + assertTrue(exception.getMessage().contains("the path")); + } + + @Test + void requireSimpleRelativePath_rejectsNullWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath(null, "the path")); + + assertTrue(exception.getMessage().contains("the path")); + } + + @Test + void requireSimpleRelativePath_acceptsValidValues() { + assertDoesNotThrow(() -> { + PathSegments.requireSimpleRelativePath("a/b/c", "path"); + PathSegments.requireSimpleRelativePath("Task-T/network-layer/ran-energy-saving/v1", "path"); + PathSegments.requireSimpleRelativePath("zh-CN", "path"); + }); + } } diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ValidationPipelineTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ValidationPipelineTest.java new file mode 100644 index 00000000..167ed972 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ValidationPipelineTest.java @@ -0,0 +1,135 @@ +package net.openan.a2at.sdk.core.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import org.junit.jupiter.api.Test; + +/** + * Guards the optional template loading gate of {@link ValidationPipeline}: the gate must run after the rule gate and + * before semantic validation, a loader failure must map to {@code validation_prompt_resource_not_found}, and the + * loader-less default {@code validate} variant must reject callers that did not provide the template content. + */ +class ValidationPipelineTest { + + @Test + void should_loadTemplateBetweenRuleGateAndSemanticValidation() { + List events = new ArrayList<>(); + RuleChecker ruleChecker = prompt -> { + events.add("rule"); + return Map.of(); + }; + SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> { + events.add("semantic"); + return new ValidationResult(true, List.of(), Map.of("param", "value")); + }; + TemplateContentLoader loader = reference -> { + events.add("load"); + return "template-content"; + }; + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, loader); + + pipeline.validate("prompt", Map.of(), "ref"); + + assertEquals(List.of("rule", "load", "semantic"), events); + } + + @Test + void should_mapLoaderFailureToPromptResourceNotFound() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> + new ValidationResult(true, List.of(), Map.of()); + TemplateContentLoader loader = reference -> { + throw new ResourceNotFoundException("missing template", reference); + }; + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, loader); + + ContentValidationException exception = + assertThrows(ContentValidationException.class, () -> pipeline.validate("prompt", Map.of(), "ref")); + assertEquals(A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, exception.getCode()); + } + + @Test + void should_throwValidationInvalidInput_When_schemaIsNull() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> + new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + ContentValidationException exception = + assertThrows(ContentValidationException.class, () -> pipeline.validate("prompt", null, "ref", "content")); + assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, exception.getCode()); + } + + @Test + void should_throwValidationInvalidInput_When_promptIsNullOrBlank() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> + new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + ContentValidationException nullException = assertThrows( + ContentValidationException.class, () -> pipeline.validate(null, Map.of(), "ref", "content")); + assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, nullException.getCode()); + + ContentValidationException blankException = assertThrows( + ContentValidationException.class, () -> pipeline.validate(" ", Map.of(), "ref", "content")); + assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, blankException.getCode()); + } + + @Test + void should_throwValidationInvalidInput_When_referenceIsNull() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> + new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, () -> pipeline.validate("prompt", Map.of(), null, "content")); + assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, exception.getCode()); + } + + @Test + void should_throwIllegalStateException_When_noLoaderInjected() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> + new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + assertThrows(IllegalStateException.class, () -> pipeline.validate("prompt", Map.of(), "ref")); + } + + @Test + void should_notInvokeLoader_When_templateContentProvidedExplicitly() { + List events = new ArrayList<>(); + RuleChecker ruleChecker = prompt -> { + events.add("rule"); + return Map.of(); + }; + SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> { + events.add("semantic"); + return new ValidationResult(true, List.of(), Map.of()); + }; + TemplateContentLoader loader = reference -> { + events.add("load"); + return "template-content"; + }; + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, loader); + + pipeline.validate("prompt", Map.of(), "ref", "explicit-content"); + + assertEquals(List.of("rule", "semantic"), events); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java index 42e179ed..f0b63c75 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java @@ -628,11 +628,6 @@ private static NegotiationTemplateLoader failingTemplateLoader() { return public PromptTemplate load(NegotiationReference reference) { throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); } - - @Override - public List loadAll() { - return List.of(); - } }; } diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java index 8e852f86..22b0ee03 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java @@ -15,7 +15,6 @@ import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidator; import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; /** @@ -34,7 +33,7 @@ * *

        Both builders are driven exactly like the facades drive them: a minimal classpath-source {@code .env} (written once * per language and retry limit into a temporary directory, following the facade test precedent) is loaded through - * {@link A2ATConfig#load(Path)} plus {@link NegotiationContentService#resolvePromptResourceLocalRootDir(A2ATConfig, Path)}, + * {@link A2ATConfig#load(Path)} plus {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)}, * and the {@link ScriptedNegotiationLlmClient} is injected through the builders' {@code llmClient(...)} seam — the same * instance feeds the client-side and the server-side components, so the {@code llmCalls} expectation counts the whole * closed loop. The facade constructors are locked to the {@code Path}-only signature by the API-surface guard tests, so @@ -71,7 +70,7 @@ final class TaskApiAssembler { */ TaskApiAssembler(Path envPath, LLMClient llmClient) { A2ATConfig config = - NegotiationContentService.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); this.promptGeneration = DefaultA2ATClientBuilder.builder() .config(config) .envPath(envPath) diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java index b3c61fbf..0281848c 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java @@ -79,11 +79,6 @@ private static NegotiationTemplateLoader failingTemplateLoader() { public PromptTemplate load(NegotiationReference reference) { throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); } - - @Override - public List loadAll() { - return List.of(); - } }; } diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json index 876f1418..e0c41602 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json @@ -5,14 +5,15 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-prompt", "propose"], - "summary": "校验要数提议消息时未传入消息文本,直接抛出空指针异常且不调用大模型", + "summary": "校验要数提议消息时未传入消息文本,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", - "exception": "NullPointerException", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", "llmCalls": 0 } }, @@ -21,8 +22,8 @@ "api": "validateProposePromptAndDataFilling", "languages": ["zh-CN"], "priority": "P1", - "tags": ["programming-error", "blank-prompt", "doc-gap", "propose"], - "summary": "校验要数提议消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针)", + "tags": ["programming-error", "blank-prompt", "propose"], + "summary": "校验要数提议消息时消息文本为空白,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "text": " " }, @@ -30,8 +31,9 @@ "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["Prompt must not be blank."], + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", + "messageContains": ["Prompt must not be null or blank."], "llmCalls": 0 } }, @@ -41,14 +43,15 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-prompt", "accept"], - "summary": "校验补数接受消息时未传入消息文本,直接抛出空指针异常且不调用大模型", + "summary": "校验补数接受消息时未传入消息文本,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", - "exception": "NullPointerException", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", "llmCalls": 0 } }, @@ -57,8 +60,8 @@ "api": "validateAcceptPromptAndDataFilling", "languages": ["zh-CN"], "priority": "P1", - "tags": ["programming-error", "blank-prompt", "doc-gap", "accept"], - "summary": "校验补数接受消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针)", + "tags": ["programming-error", "blank-prompt", "accept"], + "summary": "校验补数接受消息时消息文本为空白,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "text": " " }, @@ -66,8 +69,9 @@ "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["Prompt must not be blank."], + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", + "messageContains": ["Prompt must not be null or blank."], "llmCalls": 0 } }, @@ -77,14 +81,15 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-prompt", "reject"], - "summary": "校验修复目标拒绝消息时未传入消息文本,直接抛出空指针异常且不调用大模型", + "summary": "校验修复目标拒绝消息时未传入消息文本,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "schema": { "$ref": "schemas/empty" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", - "exception": "NullPointerException", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", "llmCalls": 0 } }, @@ -93,8 +98,8 @@ "api": "validateRejectPromptAndDataFilling", "languages": ["zh-CN"], "priority": "P1", - "tags": ["programming-error", "blank-prompt", "doc-gap", "reject"], - "summary": "校验修复目标拒绝消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针)", + "tags": ["programming-error", "blank-prompt", "reject"], + "summary": "校验修复目标拒绝消息时消息文本为空白,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "prompt": { "text": " " }, @@ -102,8 +107,9 @@ "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["Prompt must not be blank."], + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", + "messageContains": ["Prompt must not be null or blank."], "llmCalls": 0 } }, @@ -113,14 +119,15 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-prompt", "abort"], - "summary": "校验终止消息时未传入消息文本,直接抛出空指针异常且不调用大模型", + "summary": "校验终止消息时未传入消息文本,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", "context": { "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", "schema": { "$ref": "schemas/empty" }, "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", - "exception": "NullPointerException", + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", "llmCalls": 0 } }, @@ -129,8 +136,8 @@ "api": "validateAbortPromptAndDataFilling", "languages": ["zh-CN"], "priority": "P1", - "tags": ["programming-error", "blank-prompt", "doc-gap", "abort"], - "summary": "校验终止消息时消息文本为空白,抛出参数异常(abort 方法的 javadoc 未声明该行为,doc-gap 探针)", + "tags": ["programming-error", "blank-prompt", "abort"], + "summary": "校验终止消息时消息文本为空白,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", "context": { "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", "prompt": { "text": " " }, @@ -138,8 +145,9 @@ "llm": { "script": [ { "$fail": "assertion" } ] }, "expect": { "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["Prompt must not be blank."], + "exception": "NegotiationParamExtractionException", + "code": "negotiation_invalid_input", + "messageContains": ["Prompt must not be null or blank."], "llmCalls": 0 } }, diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java index 7a05dc4e..0c20a804 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java @@ -8,8 +8,6 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -29,15 +27,14 @@ * are language-neutral; every supported language exposes exactly the same key set, while the values match the bundled * template bytes of that language verbatim. * - *

        The values are file-driven: each language resolves {@code negotiation-vocabulary/{language}/vocabulary.json} - * from a dual-root fallback — a file under the optional local resource root wins, otherwise the built-in classpath - * resource is used. Both origins are validated against the pinned {@link #CANONICAL_KEYS} set, and a vocabulary that - * exists nowhere, cannot be read, is malformed (including duplicate JSON keys or blank values) or drifts from the - * canonical key set fails fast instead of silently degrading. + *

        The values are file-driven: each language resolves {@code negotiation-vocabulary/{language}/vocabulary.json} from + * the built-in classpath resources — negotiation vocabularies are classpath-fixed and never configurable (ADR 0004). + * The bundled bytes are validated against the pinned {@link #CANONICAL_KEYS} set, and a vocabulary that does not exist, + * cannot be read, is malformed (including duplicate JSON keys or blank values) or drifts from the canonical key set + * fails fast instead of silently degrading. * - *

        Caching mirrors the template-override regime: the built-in classpath vocabulary is resolved once per JVM and - * context classloader (jar bytes are immutable), while a local resource root is re-resolved on every call so that - * overriding, editing or removing a local vocabulary file takes effect immediately. + *

        The built-in classpath vocabulary is resolved once per JVM and context classloader and frozen as an assembly-time + * snapshot (jar bytes are immutable). * * @since 2026-08 */ @@ -107,53 +104,28 @@ private Vocabulary(String language, Map entries) { } /** - * Returns the vocabulary for one language, resolved from the built-in classpath resources. + * Returns the vocabulary for one language, resolved and frozen from the built-in classpath resources. * * @param language locale identifier such as {@code zh-CN} or {@code en-US} * @return vocabulary holding the text constants of that language - * @throws IllegalArgumentException if the language has no bundled vocabulary + * @throws IllegalArgumentException if the language has no bundled vocabulary, or the bundled file is unreadable, + * malformed or does not define exactly the canonical key set */ public static Vocabulary forLanguage(String language) { - return forLanguage(language, null); - } - - /** - * Returns the vocabulary for one language, resolved with the dual-root fallback. - * - *

        A vocabulary file that exists under {@code {localRootDir}/negotiation-vocabulary/{language}/vocabulary.json} - * wins; otherwise the built-in classpath resource {@code prompt_resources/negotiation-vocabulary/{language}/ - * vocabulary.json} is used. A null or otherwise absent local root simply means the classpath is consulted. Both - * origins must define exactly the canonical key set. - * - *

        Caching mirrors the template-override regime: the classpath vocabulary is resolved once per JVM and context - * classloader (jar bytes are immutable), while a non-null local root is re-resolved on every call so overriding, - * editing or removing the local file takes effect immediately. - * - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @param localRootDir local prompt resource root containing the {@code negotiation-vocabulary/} tree; null - * disables local vocabulary overrides - * @return vocabulary holding the text constants of that language - * @throws IllegalArgumentException if the language has no vocabulary in any root, or the resolved file is - * unreadable, malformed or does not define exactly the canonical key set - */ - public static Vocabulary forLanguage(String language, @Nullable Path localRootDir) { if (!PathSegments.isSimpleSegment(language)) { throw new IllegalArgumentException( "Negotiation vocabulary language must be a non-blank simple path segment but was " + language + "."); } - Path normalizedRoot = normalizeRoot(localRootDir); - if (normalizedRoot != null) { - return load(language, normalizedRoot); - } CacheKey cacheKey = new CacheKey(language, Thread.currentThread().getContextClassLoader()); - return CACHE.computeIfAbsent(cacheKey, key -> load(key.language, null)); + return CACHE.computeIfAbsent(cacheKey, key -> load(key.language)); } /** * Returns the text constant registered under one canonical key. * - * @param canonicalKey canonical vocabulary key such as {@code section.termination_reason} or {@code punct.list_colon} + * @param canonicalKey canonical vocabulary key such as {@code section.termination_reason} or + * {@code punct.list_colon} * @return language-specific text constant * @throws IllegalArgumentException if the key is not part of the vocabulary */ @@ -184,27 +156,12 @@ public String language() { return language; } - private static @Nullable Path normalizeRoot(@Nullable Path localRootDir) { - return localRootDir == null ? null : localRootDir.toAbsolutePath().normalize(); - } - - private static Vocabulary load(String language, @Nullable Path localRootDir) { - if (localRootDir != null) { - Path localFile = localRootDir - .resolve(VOCABULARY_DIRECTORY) - .resolve(language) - .resolve(VOCABULARY_FILE_NAME); - if (Files.exists(localFile)) { - return parse(language, readLocalFile(localFile), localFile.toString()); - } - } + private static Vocabulary load(String language) { String classpathPath = CLASSPATH_ROOT + language + "/" + VOCABULARY_FILE_NAME; InputStream stream = ClasspathResourceStreams.open(classpathPath); if (stream == null) { - throw new IllegalArgumentException( - "Negotiation vocabulary does not exist for the configured language " + language - + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly or" - + " provide " + classpathPath + " under the local resource root."); + throw new IllegalArgumentException("Negotiation vocabulary does not exist for the configured language " + + language + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly."); } try (stream) { return parse(language, new String(stream.readAllBytes(), StandardCharsets.UTF_8), classpathPath); @@ -216,25 +173,15 @@ private static Vocabulary load(String language, @Nullable Path localRootDir) { } } - private static String readLocalFile(Path localFile) { - try { - return Files.readString(localFile, StandardCharsets.UTF_8); - } catch (IOException exception) { - throw new IllegalArgumentException( - "Failed to read the negotiation vocabulary " + localFile + "; supported languages are zh-CN and" - + " en-US, configure A2AT_LANGUAGE accordingly.", - exception); - } - } - private static Vocabulary parse(String language, String payload, String origin) { JsonNode root; try { root = OBJECT_MAPPER.readTree(payload); } catch (JsonProcessingException exception) { - String detail = exception.getMessage() != null && exception.getMessage().contains("Duplicate field") - ? "it contains duplicate keys" - : "it is not valid JSON"; + String detail = + exception.getMessage() != null && exception.getMessage().contains("Duplicate field") + ? "it contains duplicate keys" + : "it is not valid JSON"; throw malformed(language, origin, detail, exception); } if (root == null || !root.isObject()) { @@ -264,10 +211,9 @@ private static void validateCanonicalKeys(String language, Map e if (missing.isEmpty() && unexpected.isEmpty()) { return; } - throw new IllegalArgumentException( - "Negotiation vocabulary " + origin + " for language " + language - + " must define exactly the canonical vocabulary keys; missing keys: " + missing - + ", unexpected keys: " + unexpected + "."); + throw new IllegalArgumentException("Negotiation vocabulary " + origin + " for language " + language + + " must define exactly the canonical vocabulary keys; missing keys: " + missing + + ", unexpected keys: " + unexpected + "."); } private static IllegalArgumentException malformed( diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index 7fb77d4a..c58844de 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -1,15 +1,10 @@ package net.openan.a2at.sdk.negotiation.generation; -import java.nio.file.Path; -import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; -import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; @@ -46,9 +41,9 @@ public NegotiationContentService(@NonNull NegotiationGenerationOrchestrator orch /** * Assembles the default negotiation generation orchestrator from the unified SDK config. * - *

        The wiring is shared by the client and the server builder: the message language and the local template root - * come from the prompt runtime config, the retry attempt limit comes from the LLM config, and the LLM client is - * passed by the caller and may be null when the provider is {@code local_rule}. + *

        The wiring is shared by the client and the server builder: the message language comes from the prompt runtime + * config (negotiation templates and vocabularies are classpath-fixed), the retry attempt limit comes from the LLM + * config, and the LLM client is passed by the caller and may be null when the provider is {@code local_rule}. * * @param config unified SDK config * @param llmClient LLM client for the LLM-backed steps; null keeps those steps unavailable @@ -58,39 +53,12 @@ public static NegotiationGenerationOrchestrator buildOrchestrator( @NonNull A2ATConfig config, @Nullable LLMClient llmClient) { return NegotiationGenerationOrchestratorBuilder.builder() .language(config.prompt().language()) - .localRootDir(config.prompt().localRootDir()) .llmClient(llmClient) .maxAttempts(config.llm().maxAttempts()) .maxTextChars(config.inputLimits().maxTextChars()) .build(); } - /** - * Resolves the relative prompt resource local root directory against the `.env` file location. - * - *

        The returned config carries the local root as an absolute normalized path; an absolute configured root is - * only normalized. Both facades resolve their config through this method before assembling their builders. - * - * @param config unified SDK config as loaded from the `.env` file - * @param envPath resolved `.env` file path the config was loaded from - * @return config with the local root resolved to an absolute normalized path - */ - public static A2ATConfig resolvePromptResourceLocalRootDir( - @NonNull A2ATConfig config, @NonNull Path envPath) { - String localRootDir = config.prompt().localRootDir(); - Path localRootPath = Path.of(localRootDir); - Path resolvedLocalRootPath = localRootPath.isAbsolute() - ? localRootPath.normalize() - : envPath.getParent().resolve(localRootPath).toAbsolutePath().normalize(); - return new A2ATConfig( - new PromptRuntimeConfig( - config.prompt().language(), config.prompt().sourceType(), resolvedLocalRootPath.toString()), - config.llm(), - config.inputLimits(), - config.negotiation(), - config.promptCompliance()); - } - /** * Generates a propose negotiation message from typed data, deterministically without any LLM call. * @@ -240,28 +208,6 @@ public MetadataContent generateAbortFromText( return orchestrator.generateAbortFromText(text, context, templateUri); } - /** - * Lists every negotiation template available for the configured language; never throws. - * - * @return loadable negotiation templates of the configured language, in a fixed type and performative order; empty when - * none can be loaded - */ - public List getNegotiationPrompts() { - return orchestrator.getNegotiationPrompts(); - } - - /** - * Loads one negotiation template by its URI; never throws. - * - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} - * @return the addressed template, or an empty optional when the URI does not address a negotiation template or no - * template exists for it in the configured language - * @throws NullPointerException if the template URI is null - */ - public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { - return orchestrator.getNegotiationPrompt(templateUri); - } - /** * Validates a propose negotiation message and extracts its parameters. * @@ -271,10 +217,10 @@ public Optional getNegotiationPrompt(@NonNull TemplateUri templa * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI whose performative segment must be {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, the schema or the template URI is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, - * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is + * null or blank, or {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, * {@code negotiation_llm_infrastructure_error}, {@code input_text_too_long} or {@code template_not_found} * when the validation pipeline fails */ @@ -295,10 +241,10 @@ public FilledParamData validateProposePromptAndDataFilling( * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI whose performative segment must be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, the schema or the template URI is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, - * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is + * null or blank, or {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, * {@code negotiation_llm_infrastructure_error}, {@code input_text_too_long} or {@code template_not_found} * when the validation pipeline fails */ @@ -319,10 +265,10 @@ public FilledParamData validateAcceptPromptAndDataFilling( * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI whose performative segment must be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, the schema or the template URI is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, - * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is + * null or blank, or {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, * {@code negotiation_llm_infrastructure_error}, {@code input_text_too_long} or {@code template_not_found} * when the validation pipeline fails */ diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 4371b13e..c1febe02 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -3,7 +3,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.function.Supplier; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; @@ -53,10 +52,6 @@ public final class NegotiationGenerationOrchestrator { private static final String STEP_CONTENT_EXTRACT = "negotiation_content_extract"; - private static final String LANGUAGE_HINT = - "set A2AT_LANGUAGE to a language with bundled templates (zh-CN or en-US) or provide the template under" - + " the local resource root"; - private final String language; private final int maxAttempts; @@ -303,59 +298,6 @@ public MetadataContent generateAbortFromText( return generateFromText(text, context, templateUri, NegotiationPerformative.ABORT); } - /** - * Lists every negotiation template available for the configured language. - * - *

        This query never throws: templates that exist nowhere for the language are skipped and an empty list is - * returned when no template can be loaded at all. - * - * @return loadable negotiation templates of the configured language, in a fixed type and phase order; empty when - * none can be loaded - */ - public List getNegotiationPrompts() { - try { - return templateLoader.loadAll(); - } catch (ResourceNotFoundException exception) { - logger.atWarn().log("negotiation_template_not_found uri=all language={} hint={}", language, LANGUAGE_HINT); - return List.of(); - } - } - - /** - * Loads one negotiation template addressed by its URI. - * - *

        This query never throws: a URI that does not address a negotiation template or a template that exists nowhere - * for the configured language returns an empty result and logs an actionable warning. - * - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} - * @return the addressed template, or an empty result when the URI does not address a negotiation template or the - * template does not exist for the configured language - * @throws NullPointerException if the template URI is null - */ - public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { - Optional reference = parseQueryReference(templateUri); - if (reference.isEmpty()) { - logger.atWarn() - .log( - "negotiation_template_not_found uri={} language={} reason=invalid_template_uri hint={}", - templateUri.uri(), - language, - LANGUAGE_HINT); - return Optional.empty(); - } - try { - return Optional.of(templateLoader.load(reference.get())); - } catch (ResourceNotFoundException exception) { - logger.atWarn() - .log( - "negotiation_template_not_found uri={} language={} hint={}", - templateUri.uri(), - language, - LANGUAGE_HINT); - return Optional.empty(); - } - } - /** * Validates a propose-phase negotiation message and extracts its parameters. * @@ -576,15 +518,8 @@ private FilledParamData validatePromptAndDataFilling( Objects.requireNonNull(schema, "Parameter schema must not be null."); requirePromptWithinLimit(prompt); NegotiationReference reference = requireReference(templateUri, performative); - String templateContent; try { - templateContent = loadTemplate(reference).content(); - } catch (NegotiationGenerationException exception) { - throw new NegotiationParamExtractionException( - exception.getCode(), exception.getMessage(), List.of(), exception); - } - try { - return paramExtractor.extract(prompt, context, schema, reference, templateContent); + return paramExtractor.extract(prompt, context, schema, reference); } catch (NegotiationParamExtractionException failure) { logger.atWarn() .log( @@ -611,22 +546,6 @@ private NegotiationReference requireReference(TemplateUri templateUri, Negotiati + templateUri.uri() + ".")); } - private Optional parseQueryReference(TemplateUri templateUri) { - // The URI layer cannot distinguish accept from reject because both performatives share the accept-reject - // template segment. The query therefore addresses the shared template through the ACCEPT performative; the - // parsed performative is an addressing artifact only and must never be read as the performative of any - // message. The abort performative addresses the type-independent common abort template. - for (NegotiationPerformative performative : - List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT, NegotiationPerformative.ABORT)) { - Optional reference = - NegotiationReference.fromTemplateUri(templateUri, performative, language); - if (reference.isPresent()) { - return reference; - } - } - return Optional.empty(); - } - /** * Runs one LLM step with the retry policy of the negotiation content layer. * diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index 17536200..23eedbda 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -1,8 +1,9 @@ package net.openan.a2at.sdk.negotiation.generation; -import java.nio.file.Path; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.InputLimitConfig; import net.openan.a2at.sdk.core.model.LlmConfig; +import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; @@ -21,8 +22,8 @@ *

        The language is required. The LLM client is optional: without one, the from-data generation still works, while * the LLM steps of the from-text generation and the validation pipeline fail with the * {@code negotiation_llm_infrastructure_error} code. Every collaborator has a default implementation wired from the - * language and the optional local resource root (templates and negotiation vocabulary), and each of them can be - * overridden for testing or customization. + * language (classpath-fixed templates and negotiation vocabulary), and each of them can be overridden for testing or + * customization. * * @since 2026-08 */ @@ -30,8 +31,6 @@ public final class NegotiationGenerationOrchestratorBuilder { private String language; - private String localRootDir; - private LLMClient llmClient; private int maxAttempts = LlmConfig.DEFAULT_MAX_ATTEMPTS; @@ -70,18 +69,6 @@ public NegotiationGenerationOrchestratorBuilder language(String language) { return this; } - /** - * Configures the optional local prompt resource root containing the {@code templates/} and - * {@code negotiation-vocabulary/} trees. - * - * @param localRootDir local prompt resource root; null or blank disables local template and vocabulary overrides - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder localRootDir(String localRootDir) { - this.localRootDir = localRootDir; - return this; - } - /** * Configures the LLM client used by the LLM steps of the pipelines. * @@ -190,18 +177,29 @@ public NegotiationGenerationOrchestrator build() { throw new IllegalStateException( "Negotiation max text chars must be at least 1 but was " + maxTextChars + "."); } - Path localVocabularyRoot = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); - Vocabulary vocabulary = Vocabulary.forLanguage(language, localVocabularyRoot); + Vocabulary vocabulary = Vocabulary.forLanguage(language); NegotiationTemplateLoader effectiveTemplateLoader = - templateLoader != null ? templateLoader : new DefaultNegotiationTemplateLoader(language, localRootDir); + templateLoader != null ? templateLoader : new DefaultNegotiationTemplateLoader(language); NegotiationContentExtractor effectiveContentExtractor = contentExtractor != null ? contentExtractor : new DefaultNegotiationContentExtractor(llmClient); NegotiationComplianceChecker effectiveComplianceChecker = complianceChecker != null ? complianceChecker : new DefaultNegotiationComplianceChecker(); NegotiationSemanticValidator effectiveSemanticValidator = semanticValidator != null ? semanticValidator : defaultSemanticValidator(); - ParamExtractor paramExtractor = - new ParamExtractor(effectiveComplianceChecker, effectiveSemanticValidator, maxAttempts); + ParamExtractor paramExtractor = new ParamExtractor( + effectiveComplianceChecker, + effectiveSemanticValidator, + maxAttempts, + reference -> { + PromptTemplate template = effectiveTemplateLoader.load(reference); + String content = template.content(); + if (content == null) { + throw new ResourceNotFoundException( + "Negotiation template body is missing for " + template.templateUri().uri() + ".", + template.templateUri().uri()); + } + return content; + }); return new NegotiationGenerationOrchestrator( language, maxAttempts, diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java index 394f1418..9bca8830 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -3,33 +3,27 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; +import java.util.concurrent.ConcurrentHashMap; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.prompt.resources.catalog.TemplateDescriptions; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Default negotiation template loader with dual-root fallback. + * Default negotiation template loader, classpath-fixed. * - *

        Templates are resolved by trying a configurable local resource root first and falling back to the built-in - * classpath resources bundled with the SDK: a template file that exists under the local root wins, otherwise the - * built-in template of the same URI is used. This fallback is deliberately independent of the prompt source-type - * configuration so that the built-in templates always remain available as a safety net. + *

        Templates are resolved from the built-in classpath resources bundled with the SDK — negotiation templates are + * classpath-fixed and never configurable (ADR 0004). Local template copies are not consulted. * - *

        The local root directory follows the prompt resource root layout: it is the directory that contains the - * {@code templates/} tree. Only templates are taken from the local root; LLM prompt resources always come from the - * classpath. + *

        Classpath resources are frozen at assembly time (ADR 0004): each template is resolved once and cached at the JVM + * level, so every subsequent load returns the cached snapshot. Missing templates throw on every call and are never + * cached. The loader is thread-safe. * * @since 2026-08 */ @@ -41,80 +35,43 @@ public final class DefaultNegotiationTemplateLoader implements NegotiationTempla private static final String TEMPLATE_FILE_NAME = "template.md"; - private static final List LOAD_ALL_TYPE_ORDER = - List.of(NegotiationType.INFORMATION, NegotiationType.TARGET, NegotiationType.FEASIBILITY); - - private static final List LOAD_ALL_PERFORMATIVE_ORDER = - List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT); - - private final String language; - - private final Path localRootDir; + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); /** - * Creates a loader for one language with an optional local template root. + * Creates a classpath-fixed loader for one language. + * + *

        The language is validated here for assembly-time fail-fast only; {@link #load} resolves the language from the + * reference, not from this constructor argument. * * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @param localRootDir local prompt resource root containing the {@code templates/} tree; null or blank disables - * local template overrides * @throws IllegalArgumentException if the language is not a simple path segment */ - public DefaultNegotiationTemplateLoader(String language, String localRootDir) { + public DefaultNegotiationTemplateLoader(String language) { if (!PathSegments.isSimpleSegment(language)) { throw new IllegalArgumentException( "Negotiation template loader language must be a non-blank simple path segment but was " + language + "."); } - this.language = language; - this.localRootDir = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); } /** - * Loads one negotiation template, preferring a local override over the built-in classpath template. + * Loads one negotiation template from the built-in classpath resources. * * @param reference template addressing key, including the language to load * @return loaded template with its URI, description and full content - * @throws ResourceNotFoundException if the template exists neither under the local root nor on the classpath + * @throws ResourceNotFoundException if the template does not exist on the classpath */ @Override public PromptTemplate load(NegotiationReference reference) { - String relativePath = templateRelativePath(reference); - String content = readTemplate(relativePath); - PromptTemplate template = - new PromptTemplate(reference.templateUri(), TemplateDescriptions.extract(content), content); + String classpathPath = CLASSPATH_ROOT + templateRelativePath(reference); + CacheKey cacheKey = new CacheKey(classpathPath, Thread.currentThread().getContextClassLoader()); + String content = CACHE.computeIfAbsent(cacheKey, ignored -> readTemplate(classpathPath)); + PromptTemplate template = new PromptTemplate( + reference.templateUri(), TemplateDescriptions.extract(content), content, PromptTemplate.SOURCE_CLASSPATH); LOGGER.atDebug().log("negotiation_template_loaded uri={} language={}", reference.uri(), reference.language()); return template; } - /** - * Loads every loadable negotiation template of the loader's language. - * - *

        The fixed iteration order is the negotiation type order information, target, feasibility crossed with the - * performative order propose, accept-reject, followed by the type-independent common abort template. Templates - * that exist nowhere are skipped. - * - * @return templates of the loader's language that could be loaded, in a fixed order - */ - @Override - public List loadAll() { - List templates = new ArrayList<>(); - for (NegotiationType type : LOAD_ALL_TYPE_ORDER) { - for (NegotiationPerformative performative : LOAD_ALL_PERFORMATIVE_ORDER) { - try { - templates.add(load(new NegotiationReference(type, performative, language))); - } catch (ResourceNotFoundException exception) { - // A template that exists nowhere for this language is simply not listed. - } - } - } - try { - templates.add(load(new NegotiationReference(null, NegotiationPerformative.ABORT, language))); - } catch (ResourceNotFoundException exception) { - // A template that exists nowhere for this language is simply not listed. - } - return List.copyOf(templates); - } - private static String templateRelativePath(NegotiationReference reference) { return String.join( "/", @@ -127,24 +84,12 @@ private static String templateRelativePath(NegotiationReference reference) { TEMPLATE_FILE_NAME); } - private String readTemplate(String relativePath) { - if (localRootDir != null) { - Path localPath = localRootDir.resolve(relativePath); - if (Files.exists(localPath)) { - try { - return Files.readString(localPath, StandardCharsets.UTF_8); - } catch (IOException exception) { - throw new A2ATError("Failed to read negotiation template: " + localPath, exception); - } - } - } - String classpathPath = CLASSPATH_ROOT + relativePath; + private static String readTemplate(String classpathPath) { InputStream stream = ClasspathResourceStreams.open(classpathPath); if (stream == null) { throw new ResourceNotFoundException( "Negotiation template does not exist for the configured language; set A2AT_LANGUAGE to a" - + " language with bundled templates (zh-CN or en-US) or provide the template under the" - + " local resource root.", + + " language with bundled templates (zh-CN or en-US).", classpathPath); } try (stream) { @@ -154,6 +99,7 @@ private String readTemplate(String relativePath) { } } + private record CacheKey(String classpathPath, @Nullable ClassLoader contextClassLoader) {} } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java index e3389864..b3ff9479 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java @@ -1,6 +1,5 @@ package net.openan.a2at.sdk.negotiation.resources; -import java.util.List; import net.openan.a2at.sdk.core.model.PromptTemplate; /** @@ -15,18 +14,8 @@ public interface NegotiationTemplateLoader { * * @param reference template addressing key, including the language to load * @return loaded template with its URI, description and full content - * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if no template exists for the reference in - * any available resource root + * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the template does not exist in the + * built-in classpath resources */ PromptTemplate load(NegotiationReference reference); - - /** - * Loads every loadable negotiation template of the loader's language. - * - *

        Templates that do not exist are skipped; the result order is fixed by the negotiation type order followed by - * the phase order. - * - * @return templates of the loader's language that could be loaded, in a fixed order - */ - List loadAll(); } \ No newline at end of file diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java index f79145d4..710e8dea 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java @@ -6,8 +6,8 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.RuleChecker; import net.openan.a2at.sdk.core.validation.SemanticValidator; +import net.openan.a2at.sdk.core.validation.TemplateContentLoader; import net.openan.a2at.sdk.core.validation.ValidationPipeline; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; @@ -16,10 +16,10 @@ * Orchestrates the validation of a negotiation message and the extraction of its parameters. * *

        The extractor delegates to the shared {@link ValidationPipeline} from the core module, which runs the rule-level - * gate, retryable semantic validation and deterministic parameter merging in one pass. The rule-level gate is a - * per-call {@link NegotiationRuleCheckerAdapter} bridging the negotiation context carried alongside the message to the - * core {@link RuleChecker} contract; a {@link SemanticValidator} adapter bridges the negotiation types to the core - * validation contracts. + * gate, the template loading gate, retryable semantic validation and deterministic parameter merging in one pass. The + * rule-level gate is a per-call {@link NegotiationRuleCheckerAdapter} bridging the negotiation context carried + * alongside the message to the core rule checker contract; the injected {@link TemplateContentLoader} resolves the + * template body after the rule gate and before semantic validation, so the template is never preloaded by the caller. * * @since 2026-08 */ @@ -31,21 +31,26 @@ public final class ParamExtractor { private final int maxAttempts; + private final TemplateContentLoader templateContentLoader; + /** * Creates a parameter extractor. * * @param complianceChecker rule-level checker used as the entry gate * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters * @param maxAttempts maximum number of retry attempts for the semantic validation step + * @param templateContentLoader template loading gate resolving the template body after the rule gate * @throws NullPointerException if any collaborator is null */ public ParamExtractor( NegotiationComplianceChecker complianceChecker, SemanticValidator semanticValidator, - int maxAttempts) { + int maxAttempts, + TemplateContentLoader templateContentLoader) { this.complianceChecker = Objects.requireNonNull(complianceChecker, "complianceChecker"); this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); this.maxAttempts = maxAttempts; + this.templateContentLoader = Objects.requireNonNull(templateContentLoader, "templateContentLoader"); } /** @@ -56,7 +61,6 @@ public ParamExtractor( * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param reference template reference the message is validated against - * @param templateContent loaded template text used as a reference for structure/completeness checks * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, @@ -67,14 +71,16 @@ public FilledParamData extract( String prompt, NegotiationContext context, Map schema, - NegotiationReference reference, - String templateContent) { + NegotiationReference reference) { ValidationPipeline pipeline = new ValidationPipeline<>( - new NegotiationRuleCheckerAdapter(complianceChecker, context), semanticValidator, maxAttempts); + new NegotiationRuleCheckerAdapter(complianceChecker, context), + semanticValidator, + maxAttempts, + templateContentLoader); try { - return pipeline.validate(prompt, schema, reference, templateContent); + return pipeline.validate(prompt, schema, reference); } catch (ContentValidationException e) { - throw new NegotiationParamExtractionException(mapCode(e.getCode()), e.getMessage(), e.errors()); + throw new NegotiationParamExtractionException(mapCode(e.getCode()), e.getMessage(), e.errors(), e); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java index 27883af1..26f7d24f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java @@ -1,27 +1,15 @@ package net.openan.a2at.sdk.negotiation.content; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.Map; import java.util.Set; import java.util.TreeSet; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; class VocabularyTest { - @TempDir - Path localRoot; - private static final Set CANONICAL_KEYS = Set.of( "section.termination_reason", "section.info_items", @@ -169,176 +157,8 @@ void unknownKeyThrows() { assertTrue(exception.getMessage().contains("section.unknown")); } - @Test - void localVocabularyWinsOverTheClasspathOne() throws IOException { - Map entries = classpathEntriesOf("zh-CN"); - entries.put("section.termination_reason", "自定义终止原因"); - writeLocalVocabulary("zh-CN", entries); - - Vocabulary overridden = Vocabulary.forLanguage("zh-CN", localRoot); - - assertEquals("自定义终止原因", overridden.get("section.termination_reason")); - assertEquals(Vocabulary.forLanguage("zh-CN").get("punct.list_colon"), overridden.get("punct.list_colon")); - assertEquals(Vocabulary.forLanguage("zh-CN").canonicalKeys(), overridden.canonicalKeys()); - assertEquals("zh-CN", overridden.language()); - } - - @Test - void localRootWithoutAVocabularyFallsBackToTheClasspath() { - Vocabulary fallback = Vocabulary.forLanguage("en-US", localRoot); - - assertEquals(Vocabulary.forLanguage("en-US").get("slot.target_intent"), fallback.get("slot.target_intent")); - assertEquals(Vocabulary.forLanguage("en-US").canonicalKeys(), fallback.canonicalKeys()); - } - - @Test - void classpathResolutionAndNullRootShareOneCachedVocabulary() { - assertSame(Vocabulary.forLanguage("zh-CN", null), Vocabulary.forLanguage("zh-CN")); - } - - @Test - void localRootIsReResolvedOnEveryCall() throws IOException { - Map entries = classpathEntriesOf("zh-CN"); - entries.put("label.relationship", "本地覆盖标签:"); - writeLocalVocabulary("zh-CN", entries); - - Vocabulary overridden = Vocabulary.forLanguage("zh-CN", localRoot); - assertEquals("本地覆盖标签:", overridden.get("label.relationship")); - - Files.delete(writeLocalVocabularyPath("zh-CN")); - - Vocabulary restored = Vocabulary.forLanguage("zh-CN", localRoot); - assertEquals(Vocabulary.forLanguage("zh-CN").get("label.relationship"), restored.get("label.relationship")); - } - - @Test - void malformedLocalVocabularyFailsFast() throws IOException { - writeRawLocalVocabulary("zh-CN", "{ not json"); - - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("zh-CN", localRoot)); - - assertTrue(exception.getMessage().contains("vocabulary.json")); - assertTrue(exception.getMessage().contains("zh-CN")); - } - - @Test - void localVocabularyWithAMissingCanonicalKeyFailsFast() throws IOException { - Map entries = classpathEntriesOf("zh-CN"); - entries.remove("punct.list_colon"); - writeLocalVocabulary("zh-CN", entries); - - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("zh-CN", localRoot)); - - assertTrue(exception.getMessage().contains("punct.list_colon")); - assertTrue(exception.getMessage().contains("vocabulary.json")); - } - - @Test - void localVocabularyWithAnUnexpectedKeyFailsFast() throws IOException { - Map entries = classpathEntriesOf("en-US"); - entries.put("section.unknown", "Unknown Section"); - writeLocalVocabulary("en-US", entries); - - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("en-US", localRoot)); - - assertTrue(exception.getMessage().contains("section.unknown")); - assertTrue(exception.getMessage().contains("vocabulary.json")); - } - - @Test - void vocabularyMissingInBothRootsThrowsWithTheLanguageHint() { - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("fr-FR", localRoot)); - - assertTrue(exception.getMessage().contains("fr-FR")); - assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); - } - @Test void languageThatIsNotASimplePathSegmentThrows() { - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("../escape", localRoot)); - } - - @Test - void duplicateKeysInTheLocalVocabularyFailFast() throws IOException { - writeRawLocalVocabulary("zh-CN", "{\"punct.list_colon\": \":\", \"punct.list_colon\": \":\"}"); - - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("zh-CN", localRoot)); - - assertTrue(exception.getMessage().contains("duplicate keys")); - assertTrue(exception.getMessage().contains("vocabulary.json")); - } - - @Test - void blankValueInTheLocalVocabularyFailsFast() throws IOException { - Map entries = classpathEntriesOf("zh-CN"); - entries.put("punct.list_colon", " "); - writeLocalVocabulary("zh-CN", entries); - - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("zh-CN", localRoot)); - - assertTrue(exception.getMessage().contains("punct.list_colon")); - assertTrue(exception.getMessage().contains("blank")); - assertTrue(exception.getMessage().contains("vocabulary.json")); - } - - @Test - void nonObjectLocalVocabularyFailsFast() throws IOException { - writeRawLocalVocabulary("en-US", "[]"); - - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("en-US", localRoot)); - - assertTrue(exception.getMessage().contains("not a flat JSON object")); - } - - @Test - void nonStringValueInTheLocalVocabularyFailsFast() throws IOException { - writeRawLocalVocabulary("en-US", "{\"punct.list_colon\": 5}"); - - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("en-US", localRoot)); - - assertTrue(exception.getMessage().contains("punct.list_colon")); - assertTrue(exception.getMessage().contains("is not a string")); - } - - @Test - void unreadableLocalVocabularyFailsFast() throws IOException { - Files.createDirectories(writeLocalVocabularyPath("zh-CN")); - - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("zh-CN", localRoot)); - - assertTrue(exception.getMessage().contains("Failed to read")); - assertTrue(exception.getMessage().contains("vocabulary.json")); - } - - private static Map classpathEntriesOf(String language) { - Vocabulary builtin = Vocabulary.forLanguage(language); - Map entries = new LinkedHashMap<>(); - for (String key : Vocabulary.CANONICAL_KEYS) { - entries.put(key, builtin.get(key)); - } - return entries; - } - - private void writeLocalVocabulary(String language, Map entries) throws IOException { - new ObjectMapper().writeValue(writeLocalVocabularyPath(language).toFile(), entries); - } - - private void writeRawLocalVocabulary(String language, String payload) throws IOException { - Files.writeString(writeLocalVocabularyPath(language), payload, StandardCharsets.UTF_8); - } - - private Path writeLocalVocabularyPath(String language) throws IOException { - Path file = localRoot.resolve("negotiation-vocabulary").resolve(language).resolve("vocabulary.json"); - Files.createDirectories(file.getParent()); - return file; + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("../escape")); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java index 31d04bb4..39a698bf 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java @@ -20,6 +20,7 @@ import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import org.junit.jupiter.api.Test; /** @@ -128,8 +129,8 @@ void typedEndingGenerationRejectsTheAbortTemplateUri() { @Test void singleQueryResolvesTheCommonAbortTemplateUri() { - assertTrue(orchestrator("zh-CN").getNegotiationPrompt(ABORT_URI).isPresent()); - assertTrue(orchestrator("en-US").getNegotiationPrompt(ABORT_URI).isPresent()); + assertTrue(new TemplateQueryService("zh-CN", "classpath", null).getPrompt(ABORT_URI).isPresent()); + assertTrue(new TemplateQueryService("en-US", "classpath", null).getPrompt(ABORT_URI).isPresent()); } @Test diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java index 1d651243..58c0d4a7 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java @@ -3,9 +3,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -18,13 +15,11 @@ import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; /** * Locks the classpath-only loading of the negotiation LLM prompt resources. * - *

        A local resource root may override negotiation templates, but the LLM prompt resources are deliberately not taken - * from it: even with fake prompt files placed under the custom root, the system prompt sent to the LLM must remain the + *

        The LLM prompt resources are deliberately not configurable: the system prompt sent to the LLM is always the * built-in classpath prompt of the configured language. Both prompt-consuming steps are covered: the content extraction * of the from-text generation and the semantic validation of the parameter extraction pipeline. */ @@ -34,8 +29,6 @@ class CustomRootPromptsIgnoredTest { private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - private static final String FAKE_PROMPT_MARKER = "FAKE_PROMPT_MARKER_5b19"; - private static final String EXTRACTION_RESPONSE = "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; @@ -43,14 +36,10 @@ class CustomRootPromptsIgnoredTest { "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + "\"params\":{\"region\":\"松山湖\"}}"; - @TempDir - Path customRoot; - @Test - void contentExtractionKeepsUsingTheBuiltInSystemPrompt() throws IOException { - writeFakePromptFiles("information_negotiation"); + void contentExtractionKeepsUsingTheBuiltInSystemPrompt() { RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE); - NegotiationGenerationOrchestrator orchestrator = orchestratorWithCustomRoot(llm); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); NegotiationContext context = new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE); MetadataContent result = orchestrator.generateProposeFromText("请提供节能区域。", context, INFORMATION_PROPOSE_URI); @@ -62,18 +51,14 @@ void contentExtractionKeepsUsingTheBuiltInSystemPrompt() throws IOException { builtinSystemPrompt, llm.systemContentOfLastCall(), "the system prompt sent to the LLM must be the built-in classpath prompt"); - assertTrue( - !llm.userContentOfLastCall().contains(FAKE_PROMPT_MARKER), - "the user prompt must not come from the custom root"); assertTrue(llm.userContentOfLastCall().contains("请提供节能区域。"), "the user prompt must carry the input text"); assertTrue(result.promptText().contains("节能区域")); } @Test - void semanticValidationKeepsUsingTheBuiltInSystemPrompt() throws IOException { - writeFakePromptFiles("negotiation_semantic_validation"); + void semanticValidationKeepsUsingTheBuiltInSystemPrompt() { RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE, SEMANTIC_RESPONSE); - NegotiationGenerationOrchestrator orchestrator = orchestratorWithCustomRoot(llm); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); MetadataContent message = orchestrator.generateProposeFromText( "请提供节能区域。", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE_URI); @@ -90,21 +75,12 @@ void semanticValidationKeepsUsingTheBuiltInSystemPrompt() throws IOException { builtinSystemPrompt, llm.systemContentOfLastCall(), "the semantic validation system prompt must be the built-in classpath prompt"); - assertTrue(!llm.systemContentOfLastCall().contains(FAKE_PROMPT_MARKER)); assertEquals("松山湖", filled.data().get("region")); } - private void writeFakePromptFiles(String promptCategory) throws IOException { - Path categoryDir = customRoot.resolve("prompts").resolve(promptCategory).resolve("zh-CN"); - Files.createDirectories(categoryDir); - Files.writeString(categoryDir.resolve("system.md"), FAKE_PROMPT_MARKER + " fake system prompt\n"); - Files.writeString(categoryDir.resolve("user.md"), FAKE_PROMPT_MARKER + " fake user prompt\n"); - } - - private NegotiationGenerationOrchestrator orchestratorWithCustomRoot(RecordingLlmClient llm) { + private NegotiationGenerationOrchestrator orchestrator(RecordingLlmClient llm) { return NegotiationGenerationOrchestratorBuilder.builder() .language("zh-CN") - .localRootDir(customRoot.toString()) .llmClient(llm) .build(); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index e5b3fba3..e869ac45 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -603,11 +603,6 @@ private static NegotiationTemplateLoader missingTemplateLoader() { public PromptTemplate load(NegotiationReference reference) { throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); } - - @Override - public List loadAll() { - return List.of(); - } }; } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java index c5d48cd0..0fa109c5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import ch.qos.logback.classic.Level; @@ -23,10 +24,14 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckResult; import org.junit.jupiter.api.AfterEach; @@ -135,6 +140,26 @@ void injectedComplianceCheckerIsUsedByTheValidationPipeline() { assertTrue(checker.calls.get() >= 1, "the injected compliance checker must be used"); } + @Test + void nullTemplateContentFromCustomLoaderIsReportedAsTemplateNotFoundNotNpe() { + NegotiationTemplateLoader nullContentLoader = reference -> new PromptTemplate( + reference.templateUri(), "", null, PromptTemplate.SOURCE_CLASSPATH); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(nullContentLoader) + .build(); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + + assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, exception.getCode()); + } + @Test void maxAttemptsReachesBothRetryChains() { CountingFailingClient generationClient = new CountingFailingClient(); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 5532b525..8638ad88 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -18,6 +18,7 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; @@ -26,6 +27,7 @@ import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import org.junit.jupiter.api.Test; class NegotiationGenerationOrchestratorTest { @@ -130,22 +132,19 @@ void validatesAndFillsProposeDataWithScriptedSemanticResponse() { @Test void listsAllSevenTemplatesPerLanguage() { - assertEquals(7, zhOrchestrator().getNegotiationPrompts().size()); - assertEquals(7, orchestrator("en-US").getNegotiationPrompts().size()); + assertEquals(7, negotiationPrompts("zh-CN").size()); + assertEquals(7, negotiationPrompts("en-US").size()); } @Test void queriesSingleTemplateByUri() { - assertTrue(zhOrchestrator().getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); - assertTrue(zhOrchestrator() - .getNegotiationPrompt(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) + assertTrue(queryService("zh-CN").getPrompt(INFORMATION_PROPOSE).isPresent()); + assertTrue(queryService("zh-CN") + .getPrompt(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) + .isPresent()); + assertFalse(queryService("zh-CN") + .getPrompt(TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")) .isPresent()); - assertFalse( - zhOrchestrator() - .getNegotiationPrompt( - TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")) - .isPresent()); - assertFalse(zhOrchestrator().getNegotiationPrompt(StandardTemplates.ENERGY_SAVING).isPresent()); } @Test @@ -233,8 +232,8 @@ void validationTemplateLoadFailurePreservesTheOriginalCause() { assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, failure.getCode()); assertTrue( - failure.getCause() instanceof NegotiationGenerationException, - "the template-load failure must preserve the wrapped generation failure as cause for debugging"); + failure.getCause() instanceof ContentValidationException, + "the template-load failure must preserve the mapped pipeline failure as cause for debugging"); assertTrue( failure.getCause().getCause() instanceof ResourceNotFoundException, "the original resource failure stays reachable through the cause chain"); @@ -298,17 +297,23 @@ private static NegotiationGenerationOrchestrator orchestrator(String language) { .build(); } + private static TemplateQueryService queryService(String language) { + return new TemplateQueryService(language, "classpath", null); + } + + private static List negotiationPrompts(String language) { + return queryService(language).getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + private static NegotiationTemplateLoader missingTemplateLoader() { return new NegotiationTemplateLoader() { @Override public PromptTemplate load(NegotiationReference reference) { throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); } - - @Override - public List loadAll() { - return List.of(); - } }; } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java index 4787946f..af9e8e10 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -717,7 +717,7 @@ void generatorsRenderAgainstTheBundledTemplates() { NegotiationReference reference = NegotiationReference.tryParse( StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), NegotiationPerformative.PROPOSE, "zh-CN") .orElseThrow(() -> new AssertionError("expected the bundled target propose template to resolve")); - PromptTemplate loaded = new DefaultNegotiationTemplateLoader("zh-CN", null).load(reference); + PromptTemplate loaded = new DefaultNegotiationTemplateLoader("zh-CN").load(reference); TargetProposeContent content = new TargetProposeContent( "对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。", @@ -747,6 +747,6 @@ private static NegotiationContext context(int round, NegotiationPerformative per } private static PromptTemplate template(TemplateUri templateUri, String content) { - return new PromptTemplate(templateUri, "", content); + return new PromptTemplate(templateUri, "", content, "classpath"); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java index 44b04afc..ac7bdffe 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java @@ -10,6 +10,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; import org.junit.jupiter.api.Test; /** @@ -24,9 +25,11 @@ class NegotiationPromptRendererConcurrencyTest { @Test void promptRendererIsPureUnderConcurrentCalls() throws Exception { - String template = new DefaultNegotiationTemplateLoader("zh-CN", null) - .loadAll() - .get(0) + String template = new DefaultNegotiationTemplateLoader("zh-CN") + .load(new NegotiationReference( + net.openan.a2at.sdk.negotiation.content.NegotiationType.INFORMATION, + net.openan.a2at.sdk.core.model.NegotiationPerformative.PROPOSE, + "zh-CN")) .content(); Map slots = Map.of("协商上下文", "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 1\n- maxRounds: 5", "所需信息项", "1. 节能区域:松山湖"); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java index 7d3d480d..e8ae1b29 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java @@ -60,7 +60,7 @@ class NegotiationResourceIntegrityTest { void allTwelveTemplatesExistAndAreNonEmptyOnTheClasspath() { int templateCount = 0; for (String language : NegotiationGoldenCases.LANGUAGES) { - DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader(language, null); + DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader(language); for (NegotiationType type : NegotiationType.values()) { for (NegotiationPerformative performative : List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT)) { @@ -84,7 +84,7 @@ void everySlotMarkerResolvesAgainstTheVocabularyOfItsLanguage(String language) { List allSlotNames = new ArrayList<>(); List slotsDifferingFromTheirTitle = new ArrayList<>(); - for (PromptTemplate template : new DefaultNegotiationTemplateLoader(language, null).loadAll()) { + for (PromptTemplate template : allTemplates(language)) { for (Section section : sectionsOf(template.content())) { if (section.slotMarkerLine == null) { continue; @@ -201,6 +201,19 @@ private static Map valuesToKeys(Vocabulary vocabulary) { return valuesToKeys; } + private static List allTemplates(String language) { + DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader(language); + List templates = new ArrayList<>(); + for (NegotiationType type : NegotiationType.values()) { + for (NegotiationPerformative performative : + List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT)) { + templates.add(loader.load(new NegotiationReference(type, performative, language))); + } + } + templates.add(loader.load(new NegotiationReference(null, NegotiationPerformative.ABORT, language))); + return templates; + } + private static List

        sectionsOf(String templateContent) { List
        sections = new ArrayList<>(); Section current = null; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java deleted file mode 100644 index f498325e..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationTemplateQueryBoundaryTest.java +++ /dev/null @@ -1,162 +0,0 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.util.List; -import java.util.Optional; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.slf4j.LoggerFactory; - -/** - * Verifies the boundary behavior of the template query API of the negotiation content layer. - * - *

        The list query returns exactly the six built-in templates of the configured language in the fixed type and phase - * order; the single-template query resolves a valid URI into its template record, and answers a URI that addresses no - * negotiation template with an empty result plus a {@code negotiation_template_not_found} warning that mentions the - * language configuration hint. Neither query ever throws. - */ -class NegotiationTemplateQueryBoundaryTest { - - private static final List EXPECTED_URI_ORDER = List.of( - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), - StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), - StandardTemplates.NEGOTIATION_ABORT.uri()); - - private ListAppender logAppender; - - private ch.qos.logback.classic.Logger orchestratorLogger; - - @BeforeEach - void attachLogAppender() { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - orchestratorLogger = loggerContext.getLogger(NegotiationGenerationOrchestrator.class); - logAppender = new ListAppender<>(); - logAppender.start(); - orchestratorLogger.addAppender(logAppender); - } - - @AfterEach - void detachLogAppender() { - orchestratorLogger.detachAppender(logAppender); - logAppender.stop(); - } - - static Stream languages() { - return Stream.of(Arguments.of("zh-CN"), Arguments.of("en-US")); - } - - @ParameterizedTest(name = "list query returns seven templates in the fixed order [{0}]") - @MethodSource("languages") - void listQueryReturnsSevenTemplatesInTheFixedOrder(String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - - List templates = orchestrator.getNegotiationPrompts(); - - assertEquals(7, templates.size()); - assertEquals( - EXPECTED_URI_ORDER, templates.stream().map(template -> template.templateUri().uri()).toList()); - for (PromptTemplate template : templates) { - String typeSegment = template.templateUri().pathSegments().get(0); - assertFalse( - typeSegment.contains("_"), "type segments must use hyphens: " + template.templateUri().uri()); - assertTrue( - typeSegment.endsWith("-negotiation") || typeSegment.equals("common"), - "type segment must carry the suffix or be the common segment: " + template.templateUri().uri()); - assertFalse(template.content().isBlank()); - } - } - - @ParameterizedTest(name = "single query resolves a valid URI into its template [{0}]") - @MethodSource("languages") - void singleQueryResolvesAValidUriIntoItsTemplate(String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - - Optional template = - orchestrator.getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE); - - assertTrue(template.isPresent()); - assertEquals(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, template.orElseThrow().templateUri()); - String firstSection = "zh-CN".equals(language) ? "## 可行性协商" : "## Feasibility Negotiation"; - assertTrue(template.orElseThrow().content().startsWith(firstSection)); - assertTrue( - template.orElseThrow().content().startsWith("## "), "templates start directly with the first section"); - assertEquals("", template.orElseThrow().description(), "templates carry no description comment"); - } - - @Test - void singleQueryAnswersAMissingUriWithEmptyAndAWarning() { - NegotiationGenerationOrchestrator orchestrator = orchestrator("zh-CN"); - - Optional template = orchestrator.getNegotiationPrompt( - TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")); - - assertTrue(template.isEmpty()); - assertTrue(hasWarning("negotiation_template_not_found", "A2AT_LANGUAGE")); - } - - @Test - void singleQueryAnswersANonNegotiationUriWithEmptyAndAWarning() { - NegotiationGenerationOrchestrator orchestrator = orchestrator("zh-CN"); - - Optional template = orchestrator.getNegotiationPrompt(StandardTemplates.ENERGY_SAVING); - - assertTrue(template.isEmpty()); - assertTrue(hasWarning("negotiation_template_not_found", "A2AT_LANGUAGE")); - } - - /** - * Locks the description convention: the built-in templates carry no leading HTML comment, so every template reports - * an empty description regardless of language. - */ - @Test - void builtinTemplatesReportAnEmptyDescription() { - NegotiationGenerationOrchestrator orchestrator = orchestrator("en-US"); - - List templates = orchestrator.getNegotiationPrompts(); - - for (PromptTemplate template : templates) { - assertTrue(template.description().isBlank(), "built-in templates carry no description comment"); - } - } - - private boolean hasWarning(String eventName, String... messageParts) { - for (ILoggingEvent event : logAppender.list) { - if (event.getLevel() != Level.WARN || !event.getFormattedMessage().contains(eventName)) { - continue; - } - boolean allPartsPresent = true; - for (String part : messageParts) { - allPartsPresent = allPartsPresent && event.getFormattedMessage().contains(part); - } - if (allPartsPresent) { - return true; - } - } - return false; - } - - private static NegotiationGenerationOrchestrator orchestrator(String language) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .build(); - } -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index 79b39cd2..4b1099ed 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -1,15 +1,9 @@ package net.openan.a2at.sdk.negotiation.generation; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -32,7 +26,6 @@ import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -45,8 +38,7 @@ * that does not address a negotiation template of the expected performative (wrong extension name, version, type * segment, performative segment or separator, including the underscore misspelling of the type segment) fails as a * programming error pointing at {@code templateUri}, while structural malformation is impossible by construction of - * {@link TemplateUri}; and a template placed under a local resource root overrides the built-in template addressed - * by the same URI. + * {@link TemplateUri}. */ class TemplateUriEntryMatrixTest { @@ -54,9 +46,6 @@ class TemplateUriEntryMatrixTest { private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - private static final String BUILTIN_INFORMATION_PROPOSE_TEMPLATE = - "templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md"; - /** * Entry (a): every one of the seven built-in URIs (six typed templates plus the common abort template) reaches its * template and renders output byte-identical to the golden fixtures locked by the comparison test. @@ -87,8 +76,7 @@ void everyBuiltInUriRendersItsGoldenFixture() { /** * Entry (b): a well-formed URI that resolves to no template in any resource root fails with the code * {@code template_not_found} before any LLM call. With the bundled resources every well-formed v1 URI resolves in - * both bundled languages, so the both-roots-miss condition is realized by a loader whose every load misses, which - * is the custom-root scenario of an environment without the built-in templates. + * both bundled languages, so the always-miss condition is realized by a loader whose every load misses. */ @Test void wellFormedUriMissingInBothRootsFailsWithTemplateNotFound() { @@ -148,58 +136,12 @@ static List nonAddressingTemplateUris() { TemplateUri.of("Negotiation-T", "information-negotiation", "accept")); } - /** - * Entry (e): a template placed under a local resource root overrides the built-in template addressed by the same - * URI, while the same URI without the local root keeps rendering the built-in golden fixture. - */ - @Test - void localRootTemplateOverridesTheBuiltInTemplateOfTheSameUri(@TempDir Path tempDir) throws IOException { - Path customTemplate = tempDir.resolve(BUILTIN_INFORMATION_PROPOSE_TEMPLATE); - Files.createDirectories(customTemplate.getParent()); - String builtinTemplate = readClasspathText("/prompt_resources/" + BUILTIN_INFORMATION_PROPOSE_TEMPLATE); - String markerSection = "\n\n## Custom Override Section\nCUSTOM-OVERRIDE-MARKER\n"; - Files.writeString(customTemplate, builtinTemplate + markerSection, StandardCharsets.UTF_8); - - NegotiationGenerationOrchestrator customRootOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .localRootDir(tempDir.toString()) - .build(); - String customText = customRootOrchestrator - .generateProposeFromData(informationProposeData(), INFORMATION_PROPOSE_URI) - .promptText(); - assertTrue(customText.contains("## Custom Override Section")); - assertTrue(customText.contains("CUSTOM-OVERRIDE-MARKER")); - - NegotiationGenerationOrchestrator builtinOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - String builtinText = - GoldenCase.INFORMATION_PROPOSE.generate(builtinOrchestrator, "zh-CN").promptText(); - assertFalse(builtinText.contains("CUSTOM-OVERRIDE-MARKER")); - assertEquals( - GoldenCase.INFORMATION_PROPOSE.goldenText("zh-CN"), - builtinText, - "without the local root the built-in golden output remains unchanged"); - } - private static NegotiationProposeData informationProposeData() { return new NegotiationProposeData( new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), new InformationProposeContent(List.of(new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1")), null)); } - private static String readClasspathText(String resourcePath) { - InputStream stream = TemplateUriEntryMatrixTest.class.getResourceAsStream(resourcePath); - if (stream == null) { - throw new AssertionError("Classpath resource must exist: " + resourcePath); - } - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } catch (IOException exception) { - throw new AssertionError("Failed to read classpath resource " + resourcePath, exception); - } - } - private static final class MissingTemplateLoader implements NegotiationTemplateLoader { @Override @@ -207,11 +149,6 @@ public PromptTemplate load(NegotiationReference reference) { throw new ResourceNotFoundException( "Negotiation template does not exist in any resource root.", reference.uri()); } - - @Override - public List loadAll() { - return List.of(); - } } private static final class CountingClient implements LLMClient { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java deleted file mode 100644 index 5df3ebe6..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootTemplateOverrideTest.java +++ /dev/null @@ -1,135 +0,0 @@ -package net.openan.a2at.sdk.negotiation.language; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; -import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; - -/** - * Locks the dual-root fallback of the negotiation generation pipeline at the orchestrator level. - * - *

        A template placed under a local resource root at the template URI's path wins over the built-in template; once the - * local file is removed the same orchestrator falls back to the built-in template and reproduces the golden fixture - * byte for byte; the override never leaks into the other templates of the language. - */ -class CustomRootTemplateOverrideTest { - - private static final String MARKER_SECTION_TITLE = "Custom Marker Section"; - - private static final String MARKER_LINE = "CUSTOM_TEMPLATE_MARKER_7d31"; - - @TempDir - Path customRoot; - - @Test - void customRootTemplateWinsWhilePresentAndTheRestOfTheOutputIsUntouched() throws IOException { - writeCustomizedInformationProposeTemplate(); - NegotiationGenerationOrchestrator orchestrator = orchestratorWithCustomRoot(); - MetadataContent builtinResult = - GoldenCase.INFORMATION_PROPOSE.generate(orchestrator(NegotiationGoldenCases.ZH_CN, null), NegotiationGoldenCases.ZH_CN); - - MetadataContent result = GoldenCase.INFORMATION_PROPOSE.generate(orchestrator, NegotiationGoldenCases.ZH_CN); - - assertEquals( - builtinResult.promptText() + "\n\n## " + MARKER_SECTION_TITLE + "\n" + MARKER_LINE, - result.promptText(), - "the customized template must render the marker section appended to the otherwise unchanged message"); - assertEquals(GoldenCase.INFORMATION_PROPOSE.templateUri(), result.templateUri()); - } - - @Test - void builtInGoldenOutputIsRestoredAfterTheOverrideIsRemoved() throws IOException { - Path customTemplate = writeCustomizedInformationProposeTemplate(); - NegotiationGenerationOrchestrator orchestrator = orchestratorWithCustomRoot(); - assertTrue( - GoldenCase.INFORMATION_PROPOSE - .generate(orchestrator, NegotiationGoldenCases.ZH_CN) - .promptText() - .contains(MARKER_LINE), - "precondition: the override is in effect"); - - Files.delete(customTemplate); - - assertEquals( - GoldenCase.INFORMATION_PROPOSE.goldenText(NegotiationGoldenCases.ZH_CN), - GoldenCase.INFORMATION_PROPOSE.generate(orchestrator, NegotiationGoldenCases.ZH_CN).promptText(), - "after the removal the built-in template must reproduce the golden fixture byte for byte"); - } - - @ParameterizedTest(name = "the override leaves the other templates untouched [{0}]") - @EnumSource( - value = GoldenCase.class, - names = {"INFORMATION_PROPOSE"}, - mode = EnumSource.Mode.EXCLUDE) - void theOverrideNeverLeaksIntoTheOtherTemplates(GoldenCase goldenCase) throws IOException { - writeCustomizedInformationProposeTemplate(); - NegotiationGenerationOrchestrator orchestrator = orchestratorWithCustomRoot(); - - MetadataContent result = goldenCase.generate(orchestrator, NegotiationGoldenCases.ZH_CN); - - assertEquals(goldenCase.goldenText(NegotiationGoldenCases.ZH_CN), result.promptText()); - assertTrue(!result.promptText().contains(MARKER_LINE), "the marker must stay inside the overridden template"); - } - - @Test - void templateQueriesStillListEveryTemplateWhileTheOverrideIsPresent() throws IOException { - writeCustomizedInformationProposeTemplate(); - - List templates = orchestratorWithCustomRoot().getNegotiationPrompts(); - - assertEquals(7, templates.size()); - PromptTemplate overridden = templates.stream() - .filter(t -> t.templateUri().uri().equals(GoldenCase.INFORMATION_PROPOSE.templateUri())) - .findFirst() - .orElseThrow(); - assertTrue(overridden.content().contains(MARKER_LINE)); - } - - private Path writeCustomizedInformationProposeTemplate() throws IOException { - PromptTemplate builtinTemplate = new DefaultNegotiationTemplateLoader(NegotiationGoldenCases.ZH_CN, null) - .load(new NegotiationReference( - NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, NegotiationGoldenCases.ZH_CN)); - Path customTemplate = customRoot - .resolve("templates") - .resolve("Negotiation-T") - .resolve("information-negotiation") - .resolve("propose") - .resolve("v1") - .resolve("zh-CN") - .resolve("template.md"); - Files.createDirectories(customTemplate.getParent()); - Files.writeString( - customTemplate, - builtinTemplate.content() + "\n\n## " + MARKER_SECTION_TITLE + "\n" + MARKER_LINE + "\n"); - return customTemplate; - } - - private NegotiationGenerationOrchestrator orchestratorWithCustomRoot() { - return orchestrator(NegotiationGoldenCases.ZH_CN, customRoot.toString()); - } - - private static NegotiationGenerationOrchestrator orchestrator(String language, String localRootDir) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .localRootDir(localRootDir) - .build(); - } - -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootVocabularyOverrideTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootVocabularyOverrideTest.java deleted file mode 100644 index 87047336..00000000 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/CustomRootVocabularyOverrideTest.java +++ /dev/null @@ -1,99 +0,0 @@ -package net.openan.a2at.sdk.negotiation.language; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Locks the dual-root fallback of the negotiation vocabulary at the orchestrator level. - * - *

        A vocabulary file placed under a local resource root at - * {@code negotiation-vocabulary/{language}/vocabulary.json} wins over the built-in classpath vocabulary and changes the - * rendered generator output end to end, while a blank local root keeps the built-in golden output byte for byte. The - * override mirrors the template-override regime proven by {@link CustomRootTemplateOverrideTest}. - */ -class CustomRootVocabularyOverrideTest { - - private static final String CUSTOM_RELATIONSHIP_LABEL = "自定义缺失项关系:"; - - @TempDir - Path customRoot; - - @Test - void customRootVocabularyChangesTheRenderedRelationshipLine() throws IOException { - writeCustomizedZhCnVocabulary(); - - String customized = GoldenCase.INFORMATION_PROPOSE.generate(orchestratorWithCustomRoot(), NegotiationGoldenCases.ZH_CN) - .promptText(); - String builtin = GoldenCase.INFORMATION_PROPOSE - .generate(orchestrator(NegotiationGoldenCases.ZH_CN, null), NegotiationGoldenCases.ZH_CN) - .promptText(); - - assertTrue(customized.contains(CUSTOM_RELATIONSHIP_LABEL), "the custom label must enter the rendered message"); - assertFalse(builtin.contains(CUSTOM_RELATIONSHIP_LABEL), "the built-in output must keep the bundled label"); - assertEquals( - builtin.replace(Vocabulary.forLanguage(NegotiationGoldenCases.ZH_CN).get("label.relationship"), CUSTOM_RELATIONSHIP_LABEL), - customized, - "only the vocabulary-driven relationship label may differ from the built-in output"); - } - - @Test - void blankLocalRootKeepsTheBuiltInGoldenOutput() throws IOException { - writeCustomizedZhCnVocabulary(); - - MetadataContent withBlankRoot = GoldenCase.INFORMATION_PROPOSE.generate( - NegotiationGenerationOrchestratorBuilder.builder() - .language(NegotiationGoldenCases.ZH_CN) - .localRootDir(" ") - .build(), - NegotiationGoldenCases.ZH_CN); - - assertEquals( - GoldenCase.INFORMATION_PROPOSE - .generate(orchestrator(NegotiationGoldenCases.ZH_CN, null), NegotiationGoldenCases.ZH_CN) - .promptText(), - withBlankRoot.promptText(), - "a blank local root must fall back to the classpath vocabulary"); - } - - private void writeCustomizedZhCnVocabulary() throws IOException { - Vocabulary builtin = Vocabulary.forLanguage(NegotiationGoldenCases.ZH_CN); - Map entries = new LinkedHashMap<>(); - for (String key : Vocabulary.CANONICAL_KEYS) { - entries.put(key, builtin.get(key)); - } - entries.put("label.relationship", CUSTOM_RELATIONSHIP_LABEL); - Path file = customRoot - .resolve("negotiation-vocabulary") - .resolve(NegotiationGoldenCases.ZH_CN) - .resolve("vocabulary.json"); - Files.createDirectories(file.getParent()); - new ObjectMapper().writeValue(file.toFile(), entries); - } - - private NegotiationGenerationOrchestrator orchestratorWithCustomRoot() { - return orchestrator(NegotiationGoldenCases.ZH_CN, customRoot.toString()); - } - - private static NegotiationGenerationOrchestrator orchestrator(String language, String localRootDir) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .localRootDir(localRootDir) - .build(); - } -} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java index a7c84ddd..e2d4fd00 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java @@ -14,6 +14,7 @@ import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases; import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -54,11 +55,10 @@ void sameInputRendersTheRespectiveGoldenFixturePerLanguage(GoldenCase goldenCase @ParameterizedTest(name = "the query methods list the templates of one language only [{0}]") @ValueSource(strings = {NegotiationGoldenCases.ZH_CN, NegotiationGoldenCases.EN_US}) void templateQueriesReturnTheTemplatesOfTheConfiguredLanguage(String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); String requirementLabel = NegotiationGoldenCases.ZH_CN.equals(language) ? "要求:" : "Requirement:"; String otherLanguageLabel = NegotiationGoldenCases.ZH_CN.equals(language) ? "Requirement:" : "要求:"; - List templates = orchestrator.getNegotiationPrompts(); + List templates = negotiationPrompts(language); assertEquals(7, templates.size()); for (PromptTemplate template : templates) { @@ -70,32 +70,42 @@ void templateQueriesReturnTheTemplatesOfTheConfiguredLanguage(String language) { template.templateUri().uri() + " must not leak the other language"); } - PromptTemplate queriedTemplate = - orchestrator.getNegotiationPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); + PromptTemplate queriedTemplate = queryService(language).getPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); assertTrue(queriedTemplate.content().contains(requirementLabel)); } @Test void bothLanguagesAnswerQueriesWithDifferentTemplatesForTheSameUri() { - PromptTemplate zhCnTemplate = orchestrator(NegotiationGoldenCases.ZH_CN) - .getNegotiationPrompt(INFORMATION_PROPOSE_URI) + PromptTemplate zhCnTemplate = queryService(NegotiationGoldenCases.ZH_CN) + .getPrompt(INFORMATION_PROPOSE_URI) .orElseThrow(); - PromptTemplate enUsTemplate = orchestrator(NegotiationGoldenCases.EN_US) - .getNegotiationPrompt(INFORMATION_PROPOSE_URI) + PromptTemplate enUsTemplate = queryService(NegotiationGoldenCases.EN_US) + .getPrompt(INFORMATION_PROPOSE_URI) .orElseThrow(); assertEquals(INFORMATION_PROPOSE_URI, zhCnTemplate.templateUri()); assertEquals(INFORMATION_PROPOSE_URI, enUsTemplate.templateUri()); assertNotEquals(zhCnTemplate.content(), enUsTemplate.content()); assertEquals( - orchestrator(NegotiationGoldenCases.ZH_CN).getNegotiationPrompts().stream() + negotiationPrompts(NegotiationGoldenCases.ZH_CN).stream() .map(PromptTemplate::templateUri) .toList(), - orchestrator(NegotiationGoldenCases.EN_US).getNegotiationPrompts().stream() + negotiationPrompts(NegotiationGoldenCases.EN_US).stream() .map(PromptTemplate::templateUri) .toList()); } + private static TemplateQueryService queryService(String language) { + return new TemplateQueryService(language, "classpath", null); + } + + private static List negotiationPrompts(String language) { + return queryService(language).getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + private static NegotiationGenerationOrchestrator orchestrator(String language) { return NegotiationGenerationOrchestratorBuilder.builder() .language(language) diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java index ff4c5be7..8aa40caa 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java @@ -11,6 +11,7 @@ import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import org.junit.jupiter.api.Test; /** @@ -40,22 +41,31 @@ void assemblingThePipelineForAnUnsupportedLanguageFailsWithTheLanguageHint() { @Test void templateQueriesOfAnUnsupportedLanguageNeverFallBackToAnotherLanguage() { - DefaultNegotiationTemplateLoader frFrLoader = new DefaultNegotiationTemplateLoader("fr-FR", null); + TemplateQueryService frFrQueryService = new TemplateQueryService("fr-FR", "classpath", null); - assertEquals(List.of(), frFrLoader.loadAll(), "no template of another language may be listed for fr-FR"); + assertEquals( + List.of(), + negotiationPrompts(frFrQueryService), + "no template of another language may be listed for fr-FR"); for (String bundledLanguage : List.of("zh-CN", "en-US")) { assertEquals( 7, - new DefaultNegotiationTemplateLoader(bundledLanguage, null) - .loadAll() - .size(), + negotiationPrompts(new TemplateQueryService(bundledLanguage, "classpath", null)).size(), "the bundled languages keep their full template set"); } } + private static List negotiationPrompts( + TemplateQueryService queryService) { + return queryService.getPrompts().stream() + .filter(template -> net.openan.a2at.sdk.core.model.StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + @Test void loadingASingleTemplateOfAnUnsupportedLanguageFailsWithTheLanguageHint() { - DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("fr-FR", null); + DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("fr-FR"); ResourceNotFoundException exception = assertThrows( ResourceNotFoundException.class, diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java index 12f721a7..c09b42d0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java @@ -382,11 +382,6 @@ private static NegotiationTemplateLoader missingTemplateLoader() { public PromptTemplate load(NegotiationReference reference) { throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); } - - @Override - public List loadAll() { - return List.of(); - } }; } @@ -394,12 +389,7 @@ private static NegotiationTemplateLoader nullContentTemplateLoader() { return new NegotiationTemplateLoader() { @Override public PromptTemplate load(NegotiationReference reference) { - return new PromptTemplate(reference.templateUri(), "broken template", null); - } - - @Override - public List loadAll() { - return List.of(); + return new PromptTemplate(reference.templateUri(), "broken template", null, "classpath"); } }; } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java index 3457aadc..5f498ef3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java @@ -167,42 +167,37 @@ void contentProgrammingErrorsOfTheContextStayOutsideTheCommonRoot() { } @Test - void nullPromptOnValidateAndFillingStaysOutsideTheCommonRoot() { + void nullPromptOnValidateAndFillingMapsToInvalidInput() { NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() .language("zh-CN") .build(); - NullPointerException failure = assertThrows( - NullPointerException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - null, - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( + null, + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); - assertFalse(A2ATError.class.isInstance(failure), "a null prompt is a programming error and must stay outside the A2ATError tree"); + assertTrue(failure instanceof NegotiationParamExtractionException); + assertEquals( + A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, ((NegotiationParamExtractionException) failure).getCode()); } @Test - void blankPromptOnValidateAndFillingStaysOutsideTheCommonRoot() { + void blankPromptOnValidateAndFillingMapsToInvalidInput() { NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() .language("zh-CN") .build(); - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - " ", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( + " ", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); - assertFalse( - A2ATError.class.isInstance(failure), - "a blank prompt is a programming error and must stay outside the A2ATError tree"); - assertTrue( - failure.getMessage().contains("blank"), - "the failure must point at the blank prompt but was: " + failure.getMessage()); + assertTrue(failure instanceof NegotiationParamExtractionException); + assertEquals( + A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, ((NegotiationParamExtractionException) failure).getCode()); } /** diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index 0b537aba..178e6026 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -30,9 +30,6 @@ import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -42,7 +39,7 @@ * Locks the log instrumentation contract of the negotiation content layer. * *

        Every pipeline path is driven through the real default collaborators with a scripted LLM client and the emitted - * events are captured with a logback {@link ListAppender}. The test asserts that exactly the twelve contract event + * events are captured with a logback {@link ListAppender}. The test asserts that exactly the eleven contract event * names appear, each at its configured level, that every message is an English snake_case event followed by * {@code key=value} fields, that internal step diagnostics live in the logs only, and that neither the message text, * the free-text input nor the raw LLM response ever leaks into an INFO-or-higher event. @@ -63,7 +60,6 @@ class NegotiationLogEventContractTest { Map.entry("negotiation_llm_retry_exhausted", Set.of(Level.WARN)), Map.entry("negotiation_generation_failed", Set.of(Level.WARN)), Map.entry("negotiation_param_extraction_failed", Set.of(Level.WARN)), - Map.entry("negotiation_template_not_found", Set.of(Level.WARN)), Map.entry("negotiation_rule_checks_completed", Set.of(Level.DEBUG, Level.WARN))); private Logger rootLogger; @@ -85,7 +81,7 @@ void detachAppender() { } @Test - void allTwelveEventNamesFireAtTheirConfiguredLevelsAcrossThePipelines() { + void allNegotiationEventNamesFireAtTheirConfiguredLevelsAcrossThePipelines() { driveSuccessfulGenerationFromData(); driveSuccessfulGenerationFromText(); driveRetryThenSuccessOnContentExtraction(); @@ -93,7 +89,6 @@ void allTwelveEventNamesFireAtTheirConfiguredLevelsAcrossThePipelines() { driveSuccessfulParamExtraction(); driveSemanticRejection(); driveRuleViolation(); - driveQueryBoundaries(); Map> observedLevels = negotiationEventLevels(); assertEquals(EXPECTED_EVENT_LEVELS.keySet(), observedLevels.keySet()); @@ -106,7 +101,6 @@ void everyEventMessageIsAnEnglishSnakeCaseEventWithKeyValueFields() { driveSuccessfulGenerationFromData(); driveSuccessfulGenerationFromText(); driveSuccessfulParamExtraction(); - driveQueryBoundaries(); List events = negotiationEvents(); assertFalse(events.isEmpty()); @@ -201,35 +195,6 @@ void infoAndHigherEventsNeverCarryMessageTextInputOrResponseContent() { } } - @Test - void queryBoundariesEmitTemplateNotFoundWarningsWithActionableHints() { - NegotiationGenerationOrchestrator defaultLoaderOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .templateLoader(missingTemplateLoader()) - .build(); - - defaultLoaderOrchestrator.getNegotiationPrompts(); - defaultLoaderOrchestrator.getNegotiationPrompt(INFORMATION_PROPOSE_URI); - - NegotiationGenerationOrchestrator builtinOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - builtinOrchestrator.getNegotiationPrompt(StandardTemplates.ENERGY_SAVING); - - List warnings = messagesOf("negotiation_template_not_found"); - assertEquals(3, warnings.size()); - assertTrue(warnings.get(0).contains("uri=all")); - assertTrue(warnings.get(0).contains("language=zh-CN")); - assertTrue(warnings.get(1).contains("uri=" + INFORMATION_PROPOSE_URI.uri())); - assertTrue(warnings.get(1).contains("language=zh-CN")); - assertTrue(warnings.get(2).contains("reason=invalid_template_uri")); - for (String warning : warnings) { - assertTrue(warning.contains("hint="), "boundary warning must carry a hint: " + warning); - assertTrue( - warning.contains("A2AT_LANGUAGE"), "boundary warning hint must mention A2AT_LANGUAGE: " + warning); - } - } - @Test void removedTypeRecognitionEventNeverAppears() { driveSuccessfulGenerationFromText(); @@ -353,16 +318,6 @@ private void driveRuleViolation() { } } - private void driveQueryBoundaries() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .templateLoader(missingTemplateLoader()) - .build(); - orchestrator.getNegotiationPrompts(); - orchestrator.getNegotiationPrompt(INFORMATION_PROPOSE_URI); - assertTrue(orchestrator.getNegotiationPrompt(StandardTemplates.ENERGY_SAVING).isEmpty()); - } - private static String validExtractionPayload() { return "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; } @@ -372,22 +327,6 @@ private static String validSemanticPayload() { + "\"params\":{\"region\":\"松山湖\"}}"; } - private static NegotiationTemplateLoader missingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new net.openan.a2at.sdk.core.exception.ResourceNotFoundException( - "Negotiation template does not exist.", reference.uri()); - } - - @Override - public List loadAll() { - throw new net.openan.a2at.sdk.core.exception.ResourceNotFoundException( - "No negotiation template exists for the configured language.", "templates/Negotiation-T"); - } - }; - } - private Map> negotiationEventLevels() { Map> levels = new LinkedHashMap<>(); for (ILoggingEvent event : negotiationEvents()) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java index 9fbcb952..5334a78c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java @@ -1,193 +1,174 @@ -package net.openan.a2at.sdk.negotiation.resources; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class DefaultNegotiationTemplateLoaderTest { - - private static final List EXPECTED_LOAD_ALL_URIS = List.of( - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), - StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), - StandardTemplates.NEGOTIATION_ABORT.uri()); - - @TempDir - Path customRootDir; - - @Test - void loadAllReturnsAllSixBuiltinTemplatesPerLanguageInFixedOrder() { - DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN", null); - DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US", null); - - List zhCnTemplates = zhCnLoader.loadAll(); - List enUsTemplates = enUsLoader.loadAll(); - - assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(zhCnTemplates)); - assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(enUsTemplates)); - assertEquals(7, zhCnTemplates.size()); - assertEquals(7, enUsTemplates.size()); - assertEquals(14, zhCnTemplates.size() + enUsTemplates.size()); - } - - @Test - void loadReturnsTheCommonAbortTemplateForTheAbortPerformative() { - DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US", null); - DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN", null); - - PromptTemplate englishTemplate = - enUsLoader.load(new NegotiationReference(null, NegotiationPerformative.ABORT, "en-US")); - PromptTemplate chineseTemplate = - zhCnLoader.load(new NegotiationReference(null, NegotiationPerformative.ABORT, "zh-CN")); - - assertEquals(StandardTemplates.NEGOTIATION_ABORT, englishTemplate.templateUri()); - assertTrue(englishTemplate.content().startsWith("## Negotiation Result")); - assertTrue(englishTemplate.content().contains("## Negotiation Termination Reason")); - assertTrue(englishTemplate.content().contains("{{negotiation_termination_reason}}")); - assertEquals(StandardTemplates.NEGOTIATION_ABORT, chineseTemplate.templateUri()); - assertTrue(chineseTemplate.content().contains("## 协商终止原因")); - assertTrue(chineseTemplate.content().contains("{{协商终止原因}}")); - } - - @Test - void customRootOverridesTheCommonAbortTemplate() throws IOException { - Path customTemplatePath = customRootDir - .resolve("templates") - .resolve("Negotiation-T") - .resolve("common") - .resolve("abort") - .resolve("v1") - .resolve("zh-CN") - .resolve("template.md"); - writeTemplate(customTemplatePath, "\n\n## 协商上下文\n自定义终止模板\n"); - DefaultNegotiationTemplateLoader loader = - new DefaultNegotiationTemplateLoader("zh-CN", customRootDir.toString()); - - PromptTemplate overridden = loader.load(new NegotiationReference(null, NegotiationPerformative.ABORT, "zh-CN")); - assertTrue(overridden.content().contains("自定义终止模板")); - assertEquals("custom abort", overridden.description()); - - List templates = loader.loadAll(); - assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(templates)); - PromptTemplate listedAbort = templates.get(templates.size() - 1); - assertEquals(StandardTemplates.NEGOTIATION_ABORT, listedAbort.templateUri()); - assertTrue(listedAbort.content().contains("自定义终止模板")); - } - - @Test - void loadReturnsFullTemplateContentFromTheClasspath() { - DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("en-US", null); - - PromptTemplate template = - loader.load(new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US")); - - assertEquals(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, template.templateUri()); - assertTrue(template.content().startsWith("## Information Negotiation")); - } - - @Test - void builtinTemplatesCarryNoDescriptionCommentForEitherLanguage() { - DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN", null); - DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US", null); - - PromptTemplate englishTemplate = enUsLoader.load( - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US")); - PromptTemplate chineseTemplate = zhCnLoader.load( - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN")); - - assertEquals("", englishTemplate.description()); - assertEquals("", chineseTemplate.description()); - } - - @Test - void customRootTemplateWinsWhilePresentAndBuiltinIsUsedAfterRemoval() throws IOException { - Path customTemplatePath = customRootDir - .resolve("templates") - .resolve("Negotiation-T") - .resolve("information-negotiation") - .resolve("propose") - .resolve("v1") - .resolve("zh-CN") - .resolve("template.md"); - writeTemplate(customTemplatePath, "\n\n## 协商上下文\n自定义标记内容\n"); - DefaultNegotiationTemplateLoader loader = - new DefaultNegotiationTemplateLoader("zh-CN", customRootDir.toString()); - - PromptTemplate overridden = - loader.load(new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN")); - assertTrue(overridden.content().contains("自定义标记内容")); - assertEquals("custom template", overridden.description()); - - PromptTemplate untouched = - loader.load(new NegotiationReference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "zh-CN")); - assertTrue(untouched.content().contains("目标协商概述")); - assertTrue(!untouched.content().contains("自定义标记内容")); - - Files.delete(customTemplatePath); - - PromptTemplate fallback = - loader.load(new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN")); - assertTrue(fallback.content().contains("## 信息协商")); - assertTrue(!fallback.content().contains("自定义标记内容")); - assertEquals("", fallback.description()); - } - - @Test - void loadAllWithCustomRootStillListsEveryTemplate() throws IOException { - Path customTemplatePath = customRootDir - .resolve("templates") - .resolve("Negotiation-T") - .resolve("target-negotiation") - .resolve("propose") - .resolve("v1") - .resolve("zh-CN") - .resolve("template.md"); - writeTemplate(customTemplatePath, "\n\n## 目标协商\n自定义目标内容\n"); - DefaultNegotiationTemplateLoader loader = - new DefaultNegotiationTemplateLoader("zh-CN", customRootDir.toString()); - - List templates = loader.loadAll(); - - assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(templates)); - PromptTemplate overriddenTemplate = templates.get(2); - assertEquals(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, overriddenTemplate.templateUri()); - assertTrue(overriddenTemplate.content().contains("自定义目标内容")); - } - - @Test - void missingLanguageThrowsOnLoadAndIsSkippedByLoadAll() { - DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("fr-FR", null); - - ResourceNotFoundException exception = assertThrows( - ResourceNotFoundException.class, - () -> loader.load( - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "fr-FR"))); - - assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); - assertEquals(List.of(), loader.loadAll()); - } - - private static List urisOf(List templates) { - return templates.stream().map(template -> template.templateUri().uri()).toList(); - } - - private static void writeTemplate(Path file, String content) throws IOException { - Files.createDirectories(file.getParent()); - Files.writeString(file, content); - } -} +package net.openan.a2at.sdk.negotiation.resources; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import org.junit.jupiter.api.Test; + +class DefaultNegotiationTemplateLoaderTest { + + private static final List EXPECTED_LOAD_ALL_URIS = List.of( + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.NEGOTIATION_ABORT.uri()); + + @Test + void loadReturnsAllSevenBuiltinTemplatesPerLanguage() { + DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN"); + DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US"); + + List zhCnTemplates = loadAllOf(zhCnLoader, "zh-CN"); + List enUsTemplates = loadAllOf(enUsLoader, "en-US"); + + assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(zhCnTemplates)); + assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(enUsTemplates)); + assertEquals(7, zhCnTemplates.size()); + assertEquals(7, enUsTemplates.size()); + assertEquals(14, zhCnTemplates.size() + enUsTemplates.size()); + } + + @Test + void loadReturnsTheCommonAbortTemplateForTheAbortPerformative() { + DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US"); + DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN"); + + PromptTemplate englishTemplate = + enUsLoader.load(new NegotiationReference(null, NegotiationPerformative.ABORT, "en-US")); + PromptTemplate chineseTemplate = + zhCnLoader.load(new NegotiationReference(null, NegotiationPerformative.ABORT, "zh-CN")); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT, englishTemplate.templateUri()); + assertTrue(englishTemplate.content().startsWith("## Negotiation Result")); + assertTrue(englishTemplate.content().contains("## Negotiation Termination Reason")); + assertTrue(englishTemplate.content().contains("{{negotiation_termination_reason}}")); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, chineseTemplate.templateUri()); + assertTrue(chineseTemplate.content().contains("## 协商终止原因")); + assertTrue(chineseTemplate.content().contains("{{协商终止原因}}")); + } + + @Test + void loadReturnsFullTemplateContentFromTheClasspath() { + DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("en-US"); + + PromptTemplate template = + loader.load(new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US")); + + assertEquals(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, template.templateUri()); + assertTrue(template.content().startsWith("## Information Negotiation")); + } + + @Test + void builtinTemplatesCarryNoDescriptionCommentForEitherLanguage() { + DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN"); + DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US"); + + PromptTemplate englishTemplate = enUsLoader.load( + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US")); + PromptTemplate chineseTemplate = zhCnLoader.load( + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN")); + + assertEquals("", englishTemplate.description()); + assertEquals("", chineseTemplate.description()); + } + + @Test + void missingLanguageThrowsOnLoad() { + DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("fr-FR"); + + ResourceNotFoundException exception = assertThrows( + ResourceNotFoundException.class, + () -> loader.load( + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "fr-FR"))); + + assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); + } + + @Test + void doesNotCacheMissingTemplatesAndKeepsThrowing() { + DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("fr-FR"); + NegotiationReference reference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "fr-FR"); + + assertThrows(ResourceNotFoundException.class, () -> loader.load(reference)); + assertThrows(ResourceNotFoundException.class, () -> loader.load(reference)); + } + + @Test + void servesTheCachedValueAfterTheTemplateFileIsAlteredOnDisk() throws Exception { + Path tempRoot = Files.createTempDirectory("negotiation-template-cache-test"); + Path templateFile = tempRoot.resolve( + "prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/en-US/template.md"); + Files.createDirectories(templateFile.getParent()); + Files.writeString(templateFile, "original content", StandardCharsets.UTF_8); + + ClassLoader originalContextClassLoader = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader shadowingClassLoader = + new URLClassLoader(new URL[] {tempRoot.toUri().toURL()}, ClassLoader.getPlatformClassLoader())) { + Thread.currentThread().setContextClassLoader(shadowingClassLoader); + + DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("en-US"); + NegotiationReference reference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US"); + + String first = loader.load(reference).content(); + + Files.writeString(templateFile, "changed on disk", StandardCharsets.UTF_8); + + assertEquals(first, loader.load(reference).content()); + } finally { + Thread.currentThread().setContextClassLoader(originalContextClassLoader); + deleteRecursively(tempRoot); + } + } + + private static void deleteRecursively(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + try (var paths = Files.walk(root)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.delete(path); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + }); + } + } + + private static List loadAllOf(DefaultNegotiationTemplateLoader loader, String language) { + List templates = new ArrayList<>(); + for (NegotiationType type : NegotiationType.values()) { + for (NegotiationPerformative performative : + List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT)) { + templates.add(loader.load(new NegotiationReference(type, performative, language))); + } + } + templates.add(loader.load(new NegotiationReference(null, NegotiationPerformative.ABORT, language))); + return templates; + } + + private static List urisOf(List templates) { + return templates.stream().map(template -> template.templateUri().uri()).toList(); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java index 7c07c585..a639988f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.ArrayList; import java.util.List; import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.core.model.StandardTemplates; @@ -45,7 +46,7 @@ void negotiationConstantsMatchReferenceComposition() { void negotiationGroupCoversExactlyTheComposedUris() { List composed = StandardTemplates.NEGOTIATION.stream().map(TemplateUri::uri).sorted().toList(); - List expected = new java.util.ArrayList<>(); + List expected = new ArrayList<>(); // Accept and reject share the accept-reject template, so the group carries one URI per type for them. for (NegotiationType type : NegotiationType.values()) { for (NegotiationPerformative performative : @@ -59,16 +60,10 @@ void negotiationGroupCoversExactlyTheComposedUris() { @Test void everyNegotiationGroupUriIsLoadableByTheNegotiationLayer() { - DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN", null); - DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US", null); - List loadableUris = - zhCnLoader.loadAll().stream().map(template -> template.templateUri().uri()).toList(); + DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN"); + DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US"); for (TemplateUri templateUri : StandardTemplates.NEGOTIATION) { - assertTrue( - loadableUris.contains(templateUri.uri()), - "StandardTemplates advertises " + templateUri.uri() - + " but the negotiation layer cannot load it"); assertTrue(zhCnLoader.load(zhCnReference(templateUri)).content().length() > 0, templateUri.uri()); assertTrue(enUsLoader.load(enUsReference(templateUri)).content().length() > 0, templateUri.uri()); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java index 4e832b6e..1dd15998 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java @@ -5,6 +5,11 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.ArrayList; import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; @@ -12,10 +17,15 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.validation.SemanticValidator; +import net.openan.a2at.sdk.core.validation.TemplateContentLoader; +import net.openan.a2at.sdk.core.validation.ValidationPipeline; +import net.openan.a2at.sdk.core.validation.ValidationResult; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; class ParamExtractorTest { @@ -38,7 +48,10 @@ class ParamExtractorTest { private final StubSemanticValidator semanticValidator = new StubSemanticValidator(); - private final ParamExtractor extractor = new ParamExtractor(complianceChecker, semanticValidator, MAX_ATTEMPTS); + private final StubTemplateContentLoader templateContentLoader = new StubTemplateContentLoader(); + + private final ParamExtractor extractor = + new ParamExtractor(complianceChecker, semanticValidator, MAX_ATTEMPTS, templateContentLoader); @Test void happyPathMergesContextParamsFirstAndLetsContextWinOnConflict() { @@ -49,7 +62,7 @@ void happyPathMergesContextParamsFirstAndLetsContextWinOnConflict() { List.of(), Map.of("id", "llm-value", "confirmed_rate_mbps", 2, "nested", Map.of("a", 1))); - FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE, TEMPLATE_CONTENT); + FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); assertEquals(SESSION_ID, filled.data().get("id")); assertEquals(2, filled.data().get("round")); @@ -58,6 +71,8 @@ void happyPathMergesContextParamsFirstAndLetsContextWinOnConflict() { assertEquals(Map.of("a", 1), filled.data().get("nested")); assertEquals(5, filled.data().size()); assertEquals(1, semanticValidator.invocations); + assertEquals(1, templateContentLoader.invocations); + assertEquals(TEMPLATE_CONTENT, semanticValidator.lastTemplateContent); } @Test @@ -69,8 +84,7 @@ void extractRequiresIntegerRoundAndMaxRoundsInMergedData() { VALID_ZH_PROMPT, new NegotiationContext(SESSION_ID, 3, 7, NegotiationPerformative.PROPOSE), Map.of(), - REFERENCE, - TEMPLATE_CONTENT); + REFERENCE); assertTrue(filled.data().get("round") instanceof Integer); assertTrue(filled.data().get("maxRounds") instanceof Integer); @@ -79,18 +93,19 @@ void extractRequiresIntegerRoundAndMaxRoundsInMergedData() { } @Test - void ruleFailureSkipsTheSemanticValidationCall() { + void ruleFailureSkipsTemplateLoadingAndTheSemanticValidationCall() { complianceChecker.result = new NegotiationRuleCheckResult( false, List.of(new SlotValidationError("round", "out_of_range", "round exceeds maxRounds"))); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE, TEMPLATE_CONTENT)); + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); assertEquals("negotiation_rule_violation", exception.getCode()); assertEquals(1, exception.getErrors().size()); assertEquals("round", exception.getErrors().get(0).slotName()); assertEquals(0, semanticValidator.invocations); + assertEquals(0, templateContentLoader.invocations); } @Test @@ -100,11 +115,12 @@ void extractFailsOnRuleViolationWithoutTouchingTheValidator() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE, TEMPLATE_CONTENT)); + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); assertEquals("negotiation_rule_violation", exception.getCode()); assertEquals("id", exception.getErrors().get(0).slotName()); assertEquals(0, semanticValidator.invocations); + assertEquals(0, templateContentLoader.invocations); } @Test @@ -113,7 +129,7 @@ void nullContextFailsAsNonNegotiationInputWithLanguageNeutralMessage() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> extractor.extract("## 任务目标\n诊断\n", null, Map.of(), REFERENCE, TEMPLATE_CONTENT)); + () -> extractor.extract("## 任务目标\n诊断\n", null, Map.of(), REFERENCE)); assertEquals("negotiation_invalid_input", exception.getCode()); assertEquals( @@ -122,6 +138,7 @@ void nullContextFailsAsNonNegotiationInputWithLanguageNeutralMessage() { assertEquals(List.of(), exception.getErrors()); assertEquals(0, semanticValidator.invocations); assertEquals(0, complianceChecker.invocations); + assertEquals(0, templateContentLoader.invocations); } @Test @@ -134,7 +151,7 @@ void semanticRejectionPassesErrorsThrough() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE, TEMPLATE_CONTENT)); + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); assertEquals("negotiation_semantic_rejected", exception.getCode()); assertEquals(semanticErrors, exception.getErrors()); @@ -146,7 +163,7 @@ void extractReturnsTheValidatorOutcome() { semanticValidator.result = new SemanticValidationResult(true, "information", List.of(), Map.of("confirmed_rate_mbps", 2)); - FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE, TEMPLATE_CONTENT); + FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); assertEquals(1, semanticValidator.invocations); assertEquals(2, filled.data().get("confirmed_rate_mbps")); @@ -159,7 +176,7 @@ void internalValidationFailureIsMappedToRetryableInfrastructureError() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE, TEMPLATE_CONTENT)); + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); assertEquals("negotiation_llm_infrastructure_error", exception.getCode()); assertEquals(1, exception.getErrors().size()); @@ -175,10 +192,74 @@ void promptResourceMissIsMappedToTemplateNotFound() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE, TEMPLATE_CONTENT)); + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); + + assertEquals("template_not_found", exception.getCode()); + assertEquals(List.of(), exception.getErrors()); + } + + @Test + void templateLoadFailureIsMappedToTemplateNotFoundWithoutTouchingTheValidator() { + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); + semanticValidator.result = new SemanticValidationResult(true, "information", List.of(), Map.of()); + templateContentLoader.failure = + new ResourceNotFoundException("template missing", "templates/Negotiation-T/information-negotiation"); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); assertEquals("template_not_found", exception.getCode()); assertEquals(List.of(), exception.getErrors()); + assertEquals(1, templateContentLoader.invocations); + assertEquals(REFERENCE, templateContentLoader.lastReference); + assertEquals(0, semanticValidator.invocations); + } + + @Test + void paramMergeConflictLogsContentParamMergeConflictWarning() { + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); + semanticValidator.result = + new SemanticValidationResult(true, "information", List.of(), Map.of("id", "semantic-id")); + + ListAppender appender = new ListAppender<>(); + appender.start(); + Logger pipelineLogger = (Logger) LoggerFactory.getLogger(ValidationPipeline.class); + pipelineLogger.addAppender(appender); + try { + extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); + } finally { + pipelineLogger.detachAppender(appender); + appender.stop(); + } + + assertTrue( + appender.list.stream() + .anyMatch(event -> event.getLevel() == Level.WARN + && event.getFormattedMessage().startsWith("content_param_merge_conflict")), + "expected a WARN for a context-over-semantic parameter merge conflict"); + } + + @Test + void templateIsLoadedBetweenTheRuleGateAndSemanticValidation() { + List order = new ArrayList<>(); + NegotiationComplianceChecker checker = context -> { + order.add("rule"); + return new NegotiationRuleCheckResult(true, List.of()); + }; + SemanticValidator semantic = (prompt, schema, reference, templateContent) -> { + order.add("semantic"); + return new ValidationResult(true, List.of(), Map.of()); + }; + TemplateContentLoader loader = reference -> { + order.add("load"); + return TEMPLATE_CONTENT; + }; + ParamExtractor sut = new ParamExtractor(checker, semantic, MAX_ATTEMPTS, loader); + + sut.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); + + assertEquals(List.of("rule", "load", "semantic"), order); } private static final class StubComplianceChecker implements NegotiationComplianceChecker { @@ -202,14 +283,38 @@ private static final class StubSemanticValidator implements NegotiationSemanticV private int invocations; + private String lastTemplateContent; + @Override public SemanticValidationResult validateNegotiation( String prompt, Map callerSchema, NegotiationReference reference, String templateContent) { invocations++; + lastTemplateContent = templateContent; if (failure != null) { throw failure; } return result; } } + + private static final class StubTemplateContentLoader implements TemplateContentLoader { + + private String content = TEMPLATE_CONTENT; + + private RuntimeException failure; + + private int invocations; + + private NegotiationReference lastReference; + + @Override + public String load(NegotiationReference reference) { + invocations++; + lastReference = reference; + if (failure != null) { + throw failure; + } + return content; + } + } } diff --git a/a2a-t-prompt/pom.xml b/a2a-t-prompt/pom.xml index 17fa36df..aadfed4c 100644 --- a/a2a-t-prompt/pom.xml +++ b/a2a-t-prompt/pom.xml @@ -34,5 +34,11 @@ junit-jupiter test + + ch.qos.logback + logback-classic + 1.4.14 + test + diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java index cc1e08b8..5106de7e 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java @@ -16,13 +16,14 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.stream.Stream; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.model.A2ATConfigKeys; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.core.model.TemplateUri; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; @@ -31,16 +32,20 @@ /** * Directory-driven catalog over the prompt template tree of every A2A-T extension. * - *

        The catalog enumerates the {@code templates/} tree of the bundled prompt resources by walking its directories, - * so every extension directory that appears under {@code prompt_resources/templates/} — Task-T, Notification-T, - * Authorization-T, Negotiation-T and any extension added later — is picked up without a hardcoded extension list. - * A template file lives at {@code templates/////template.md} - * and is addressed by the URI formed from the segments before the language, for example + *

        The catalog enumerates the {@code templates/} tree of the configured source by walking its directories, so every + * extension directory that appears under {@code prompt_resources/templates/} — Task-T, Notification-T, Authorization-T, + * Negotiation-T and any extension added later — is picked up without a hardcoded extension list. A template file lives + * at {@code templates/////template.md} and is addressed by the + * URI formed from the segments before the language, for example * {@code Negotiation-T/information-negotiation/propose/v1} or {@code Task-T/network-layer/ran-energy-saving/v1}. * - *

        A local file under the configured local resource root overrides the built-in classpath template of the same - * path, and the classpath is the fallback otherwise. Both query methods never throw: a template or a root that cannot - * be loaded is skipped or answered with an empty result and a warning log. + *

        The catalog builds the effective set at assembly time according to the configured {@code sourceType} (ADR 0004): + * the {@code classpath} mode enumerates the full built-in template tree; the {@code local_file} mode enumerates only + * the local business content (Task-T, Notification-T and Authorization-T) plus the classpath-fixed Negotiation-T + * templates, without unioning the built-in business content. Every template record carries its effective origin + * ({@code classpath} or {@code local}). Negotiation-T templates are filtered to the closed set of seven shapes; a + * Negotiation-T template outside the closed set is ignored with a warning. Both query methods never throw: a template + * or a root that cannot be loaded is skipped or answered with an empty result and a warning log. * * @since 2026-08 */ @@ -58,64 +63,114 @@ final class PromptTemplateCatalog { private static final int MINIMUM_URI_SEGMENTS = 3; + private static final String NEGOTIATION_EXTENSION = "Negotiation-T"; + + private static final Set NEGOTIATION_TYPES = + Set.of("information-negotiation", "target-negotiation", "feasibility-negotiation"); + + private static final Set NEGOTIATION_PHASES = Set.of("propose", "accept-reject"); + private final String language; + private final String sourceType; + private final Path localRootDir; + private final Map classpathSnapshot; + + private final Map localSnapshot; + /** - * Creates a catalog for one language with an optional local template root. + * Creates a catalog for one language, two source types and an optional local template root. + * + *

        The constructor captures the classpath template tree and, in {@code local_file} mode, the local + * {@code templates/} tree once as immutable snapshots (ADR 0004): the catalog never re-reads the classpath or the + * local filesystem after construction, so a template that is added or removed after assembly is not visible to + * {@link #loadAll()} or {@link #load(TemplateUri)}. * * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @param localRootDir local prompt resource root containing the {@code templates/} tree; null or blank disables - * local template overrides - * @throws IllegalArgumentException if the language is not a simple path segment + * @param sourceType resource source selector, {@code classpath} or {@code local_file} + * @param localRootDir local prompt resource root containing the {@code templates/} tree; required in + * {@code local_file} mode and ignored otherwise + * @throws IllegalArgumentException if the language is not a simple path segment, the source type is unsupported, or + * {@code local_file} mode is selected without a local root directory */ - public PromptTemplateCatalog(@NonNull String language, @Nullable String localRootDir) { + PromptTemplateCatalog(@NonNull String language, @NonNull String sourceType, @Nullable String localRootDir) { if (!PathSegments.isSimpleSegment(language)) { throw new IllegalArgumentException( "Prompt template catalog language must be a non-blank simple path segment but was " + language + "."); } this.language = language; + if (PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH.equals(sourceType)) { + this.sourceType = PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH; + } else if (PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(sourceType)) { + this.sourceType = PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE; + } else { + throw new IllegalArgumentException("Unsupported prompt source type: " + sourceType); + } + if (PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(this.sourceType) + && (localRootDir == null || localRootDir.isBlank())) { + throw new IllegalArgumentException( + "Prompt resource local root directory is required for sourceType '" + + PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE + + "' but is not set; configure " + A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR + + " to the local prompt resource root."); + } this.localRootDir = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); + this.classpathSnapshot = captureClasspathTemplates(); + this.localSnapshot = PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(this.sourceType) && this.localRootDir != null + ? Map.copyOf(localTemplates()) + : Map.of(); + } + + private Map captureClasspathTemplates() { + try { + return Map.copyOf(classpathTemplates()); + } catch (IOException | URISyntaxException exception) { + LOGGER.atWarn().log("prompt_template_catalog_unavailable root=classpath reason={}", exception.getMessage()); + return Map.of(); + } } /** - * Lists every loadable template of the configured language across all extensions. + * Lists every loadable template of the configured language across all extensions of the effective set. * - *

        The extension directories are discovered from the resource tree itself, so the result grows automatically - * when a new extension directory appears. Local templates override built-in templates of the same path and - * local-only templates are included. The result is sorted by template URI, which orders by extension first. + *

        The effective set depends on the configured {@code sourceType}: {@code classpath} lists the full built-in + * tree, {@code local_file} lists the local business content plus the classpath-fixed Negotiation-T templates. + * Negotiation-T templates outside the closed set are ignored with a warning. The result is sorted by template URI. * * @return loadable templates of the configured language sorted by URI; empty when none can be loaded */ public @NonNull List loadAll() { - Map contentsByPath = new LinkedHashMap<>(); - try { - contentsByPath.putAll(classpathTemplates()); - } catch (IOException | URISyntaxException exception) { - LOGGER.atWarn() - .log("prompt_template_catalog_unavailable root=classpath reason={}", exception.getMessage()); - } - contentsByPath.putAll(localTemplates()); + Map entriesByPath = new LinkedHashMap<>(); + collectClasspathEntries(entriesByPath); + collectLocalEntries(entriesByPath); List templates = new ArrayList<>(); String languageSuffix = "/" + language + "/" + TEMPLATE_FILE_NAME; - for (Map.Entry entry : contentsByPath.entrySet()) { + for (Map.Entry entry : entriesByPath.entrySet()) { String path = entry.getKey(); if (!path.endsWith(languageSuffix)) { continue; } - String uri = path.substring( - CLASSPATH_TEMPLATE_ROOT.length(), path.length() - languageSuffix.length()); + String uri = path.substring(CLASSPATH_TEMPLATE_ROOT.length(), path.length() - languageSuffix.length()); if (!isCatalogableUri(uri)) { LOGGER.atDebug().log("prompt_template_skipped path={} reason=not_a_template_uri", path); continue; } - String content = entry.getValue(); + if (isNegotiationUri(uri) && !isClosedSetNegotiationUri(uri)) { + LOGGER.atWarn().log("negotiation_template_outside_closed_set uri={}", uri); + continue; + } + String content = entry.getValue().content(); TemplateUri templateUri = TemplateUri.parse(uri) .orElseThrow(() -> new IllegalStateException( "Catalogable template URI could not be parsed: " + uri + " (path: " + path + ")")); - templates.add(new PromptTemplate(templateUri, TemplateDescriptions.extract(content), content)); + templates.add(new PromptTemplate( + templateUri, + TemplateDescriptions.extract(content), + content, + entry.getValue().source())); } templates.sort(Comparator.comparing(template -> template.templateUri().uri())); LOGGER.atDebug().log("prompt_templates_listed count={} language={}", templates.size(), language); @@ -125,6 +180,10 @@ public PromptTemplateCatalog(@NonNull String language, @Nullable String localRoo /** * Loads one template of the configured language by its URI, regardless of the extension. * + *

        A Negotiation-T template outside the closed set is answered with an empty result and a warning. Business + * content follows the configured {@code sourceType}: {@code local_file} reads the addressed template from the local + * root only (no classpath fallback), {@code classpath} reads it from the classpath. + * * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1} or * {@code Task-T/network-layer/ran-energy-saving/v1} * @return the addressed template, or an empty optional when no template exists for it in the configured language @@ -132,14 +191,47 @@ public PromptTemplateCatalog(@NonNull String language, @Nullable String localRoo */ public Optional load(@NonNull TemplateUri templateUri) { Objects.requireNonNull(templateUri, "templateUri"); - String relativePath = String.join( - "/", TEMPLATE_DIRECTORY, templateUri.uri(), language, TEMPLATE_FILE_NAME); - try { - String content = readTemplate(relativePath); - return Optional.of(new PromptTemplate(templateUri, TemplateDescriptions.extract(content), content)); - } catch (A2ATError exception) { + String uri = templateUri.uri(); + boolean negotiation = NEGOTIATION_EXTENSION.equals(templateUri.extensionName()); + if (negotiation && !isClosedSetNegotiationUri(uri)) { + LOGGER.atWarn().log("negotiation_template_outside_closed_set uri={}", uri); + return Optional.empty(); + } + String classpathPath = CLASSPATH_ROOT + String.join("/", TEMPLATE_DIRECTORY, uri, language, TEMPLATE_FILE_NAME); + String source; + String content; + if (negotiation || PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH.equals(sourceType)) { + source = PromptTemplate.SOURCE_CLASSPATH; + content = classpathSnapshot.get(classpathPath); + } else { + source = PromptTemplate.SOURCE_LOCAL; + content = localSnapshot.get(classpathPath); + } + if (content == null) { return Optional.empty(); } + return Optional.of(new PromptTemplate(templateUri, TemplateDescriptions.extract(content), content, source)); + } + + private void collectClasspathEntries(Map entriesByPath) { + for (Map.Entry entry : classpathSnapshot.entrySet()) { + if (PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(sourceType) && !isNegotiationPath(entry.getKey())) { + continue; + } + entriesByPath.put(entry.getKey(), new TemplateSource(entry.getValue(), PromptTemplate.SOURCE_CLASSPATH)); + } + } + + private void collectLocalEntries(Map entriesByPath) { + if (!PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(sourceType) || localRootDir == null) { + return; + } + for (Map.Entry entry : localSnapshot.entrySet()) { + if (isNegotiationPath(entry.getKey())) { + continue; + } + entriesByPath.put(entry.getKey(), new TemplateSource(entry.getValue(), PromptTemplate.SOURCE_LOCAL)); + } } private Map classpathTemplates() throws IOException, URISyntaxException { @@ -194,8 +286,8 @@ private static void collectTemplate(Map templates, String classp } else { InputStream stream = ClasspathResourceStreams.open(classpathPath); if (stream == null) { - LOGGER.atDebug() - .log("prompt_template_skipped path={} reason=classpath_entry_unreadable", classpathPath); + LOGGER.atDebug().log( + "prompt_template_skipped path={} reason=classpath_entry_unreadable", classpathPath); return; } try (stream) { @@ -204,16 +296,12 @@ private static void collectTemplate(Map templates, String classp } templates.put(classpathPath, content); } catch (IOException exception) { - LOGGER.atDebug() - .log("prompt_template_skipped path={} reason=read_failed", classpathPath); + LOGGER.atDebug().log("prompt_template_skipped path={} reason=read_failed", classpathPath); } } private Map localTemplates() { Map templates = new LinkedHashMap<>(); - if (localRootDir == null) { - return templates; - } Path templateRoot = localRootDir.resolve(TEMPLATE_DIRECTORY); if (!Files.isDirectory(templateRoot)) { return templates; @@ -233,30 +321,34 @@ private Map localTemplates() { return templates; } - private String readTemplate(String relativePath) { - if (localRootDir != null) { - Path localPath = localRootDir.resolve(relativePath); - if (Files.exists(localPath)) { - try { - return Files.readString(localPath, StandardCharsets.UTF_8); - } catch (IOException exception) { - throw new A2ATError("Failed to read prompt template: " + localPath, exception); - } - } + private static boolean isNegotiationPath(String path) { + if (!path.startsWith(CLASSPATH_TEMPLATE_ROOT)) { + return false; } - String classpathPath = CLASSPATH_ROOT + relativePath; - InputStream stream = ClasspathResourceStreams.open(classpathPath); - if (stream == null) { - throw new ResourceNotFoundException( - "Prompt template does not exist for the configured language; set the language to one with" - + " bundled templates or provide the template under the local resource root.", - classpathPath); + String relative = path.substring(CLASSPATH_TEMPLATE_ROOT.length()); + int slash = relative.indexOf('/'); + return slash >= 0 && NEGOTIATION_EXTENSION.equals(relative.substring(0, slash)); + } + + private static boolean isNegotiationUri(String uri) { + int slash = uri.indexOf('/'); + return slash >= 0 && NEGOTIATION_EXTENSION.equals(uri.substring(0, slash)); + } + + private static boolean isClosedSetNegotiationUri(String uri) { + String[] segments = uri.split("/"); + if (segments.length != 4) { + return false; } - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException exception) { - throw new A2ATError("Failed to read prompt template: " + classpathPath, exception); + if (!NEGOTIATION_EXTENSION.equals(segments[0]) || !TemplateUri.DEFAULT_TEMPLATE_VERSION.equals(segments[3])) { + return false; + } + String type = segments[1]; + String phase = segments[2]; + if ("common".equals(type)) { + return "abort".equals(phase); } + return NEGOTIATION_TYPES.contains(type) && NEGOTIATION_PHASES.contains(phase); } private static boolean isCatalogableUri(String uri) { @@ -274,4 +366,6 @@ private static boolean isCatalogableUri(String uri) { } return true; } + + private record TemplateSource(String content, String source) {} } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java index 993ba2d8..8af10870 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java @@ -14,9 +14,9 @@ * Generic template query service consumed by the client and the server facade. * *

        The service exposes the extension-agnostic template queries of the design document over a - * {@link PromptTemplateCatalog}: {@link #getPrompts()} lists every loadable template of the configured language - * across all A2A-T extensions and {@link #getPrompt(TemplateUri)} loads one template by its URI. Both queries never - * throw; a missing template is answered with an empty result and an actionable warning log. + * {@link PromptTemplateCatalog}: {@link #getPrompts()} lists every loadable template of the configured language across + * all A2A-T extensions and {@link #getPrompt(TemplateUri)} loads one template by its URI. Both queries never throw; a + * missing template is answered with an empty result and an actionable warning log. * * @since 2026-08 */ @@ -40,30 +40,30 @@ public final class TemplateQueryService { * @param templateCatalog directory-driven catalog over the template tree of every extension * @param language locale identifier the catalog was created for; only used in log messages */ - public TemplateQueryService( - @NonNull PromptTemplateCatalog templateCatalog, @NonNull String language) { + public TemplateQueryService(@NonNull PromptTemplateCatalog templateCatalog, @NonNull String language) { this.templateCatalog = Objects.requireNonNull(templateCatalog, "Prompt template catalog must not be null."); this.language = Objects.requireNonNull(language, "Language must not be null."); this.logger = DEFAULT_LOGGER; } /** - * Creates one service for one language with an optional local template root. + * Creates one service for one language, one source type and an optional local template root. * * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @param localRootDir local prompt resource root containing the {@code templates/} tree; null or blank disables - * local template overrides + * @param sourceType resource source selector, {@code classpath} or {@code local_file} + * @param localRootDir local prompt resource root containing the {@code templates/} tree; only consulted in + * {@code local_file} mode and may be null otherwise */ - public TemplateQueryService(@NonNull String language, @Nullable String localRootDir) { - this(new PromptTemplateCatalog(language, localRootDir), language); + public TemplateQueryService(@NonNull String language, @NonNull String sourceType, @Nullable String localRootDir) { + this(new PromptTemplateCatalog(language, sourceType, localRootDir), language); } /** * Lists every template available for the configured language across all A2A-T extensions. * *

        This query never throws: the extension directories are discovered from the resource tree itself, templates - * that exist nowhere for the language are skipped, and an empty list is returned when no template can be loaded - * at all. The result is sorted by template URI, which orders by extension first. + * that exist nowhere for the language are skipped, and an empty list is returned when no template can be loaded at + * all. The result is sorted by template URI, which orders by extension first. * * @return loadable templates of the configured language across all extensions, sorted by URI; empty when none can * be loaded diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java index 8160b82d..da8c1ce4 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java @@ -1,37 +1,50 @@ package net.openan.a2at.sdk.prompt.resources.loader; import com.fasterxml.jackson.annotation.JsonProperty; -import java.io.IOException; -import java.nio.file.Files; +import com.fasterxml.jackson.core.JsonProcessingException; import java.nio.file.Path; import java.util.List; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import java.util.Map; import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.resources.PathSegments; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; /** * Loads shared scenario catalogs from one local prompt resource root. * + *

        Resources are resolved against an assembly-time snapshot of the local root (ADR 0004): runtime reads never touch + * the filesystem, so changes to the local files only take effect after the SDK is restarted. + * * @since 2026-06 */ public final class LocalFilePromptScenarioCatalogLoader { + private final Map snapshot; private final Path promptRootDir; - public LocalFilePromptScenarioCatalogLoader(Path promptRootDir) { + public LocalFilePromptScenarioCatalogLoader(Map snapshot, Path promptRootDir) { + this.snapshot = snapshot; this.promptRootDir = promptRootDir; } public List load(String language) { - Path catalogPath = promptRootDir.resolve("scenarios").resolve(language).resolve("scenarios.json"); - if (!Files.exists(catalogPath)) { - throw new ResourceNotFoundException("Prompt resource file does not exist.", catalogPath.toString()); + PathSegments.requireSimpleSegment(language, "Prompt scenario catalog language"); + String pathKey = "scenarios/" + language + "/scenarios.json"; + if (!snapshot.containsKey(pathKey)) { + throw new ResourceNotFoundException( + "Prompt resource file does not exist.", + promptRootDir + .resolve("scenarios") + .resolve(language) + .resolve("scenarios.json") + .toString()); } try { - return PromptResourceJsonParser.parse(Files.readString(catalogPath), ScenarioCatalog.class) + return PromptResourceJsonParser.parse(snapshot.get(pathKey), ScenarioCatalog.class) .scenarios(); - } catch (IOException exception) { - throw new A2ATError("Failed to read scenario catalog: " + catalogPath, exception); + } catch (JsonProcessingException exception) { + throw new A2ATError("Failed to parse scenario catalog for language: " + language, exception); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java index 99b0ad14..58a3d0cf 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java @@ -1,59 +1,60 @@ package net.openan.a2at.sdk.prompt.resources.loader; -import java.io.IOException; -import java.nio.file.Files; +import com.fasterxml.jackson.core.JsonProcessingException; import java.nio.file.Path; -import java.util.Optional; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import java.util.List; +import java.util.Map; import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.resources.PathSegments; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; /** * Loads shared slot schemas from one local prompt resource root. * + *

        Resources are resolved against an assembly-time snapshot of the local root (ADR 0004): runtime reads never touch + * the filesystem, so changes to the local files only take effect after the SDK is restarted. + * * @since 2026-06 */ public final class LocalFilePromptSlotSchemaLoader implements PromptSlotSchemaLoader { + private final Map snapshot; private final Path promptRootDir; + private final List slotTypes; - public LocalFilePromptSlotSchemaLoader(Path promptRootDir) { + public LocalFilePromptSlotSchemaLoader(Map snapshot, Path promptRootDir) { + this.snapshot = snapshot; this.promptRootDir = promptRootDir; + this.slotTypes = LocalFileResourceSnapshot.typeDirectories(snapshot, "slots"); } @Override public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { - Path slotsRoot = promptRootDir.resolve("slots"); - if (!Files.exists(slotsRoot)) { - throw notFound(scenarioCode, language); - } - Path schemaPath; + PathSegments.requireSimpleRelativePath(scenarioCode, "Prompt slot schema scenario code"); + PathSegments.requireSimpleSegment(language, "Prompt slot schema language"); + String pathKey; if (scenarioCode.contains("/")) { - schemaPath = slotsRoot.resolve(scenarioCode).resolve(language).resolve("slot.json"); - if (!Files.exists(schemaPath)) { - throw notFound(scenarioCode, language); - } + pathKey = "slots/" + scenarioCode + "/" + language + "/slot.json"; } else { - try (var typePaths = Files.list(slotsRoot)) { - Optional match = typePaths - .filter(Files::isDirectory) - .map(typeDir -> resolveBareCode(typeDir, scenarioCode, language)) - .filter(Files::exists) - .findFirst(); - schemaPath = match.orElse(null); - } catch (IOException exception) { - throw new A2ATError("Failed to scan slot schema resources: " + slotsRoot, exception); - } - if (schemaPath == null) { - throw notFound(scenarioCode, language); + pathKey = null; + for (String slotType : slotTypes) { + String candidate = resolveBareCode(slotType, scenarioCode, language); + if (snapshot.containsKey(candidate)) { + pathKey = candidate; + break; + } } } + if (pathKey == null || !snapshot.containsKey(pathKey)) { + throw notFound(scenarioCode, language); + } try { - return PromptResourceJsonParser.parse(Files.readString(schemaPath), PromptSlotJsonSchema.class) + return PromptResourceJsonParser.parse(snapshot.get(pathKey), PromptSlotJsonSchema.class) .toPromptSlotSchema(scenarioCode); - } catch (IOException exception) { - throw new A2ATError("Failed to read slot schema resource: " + schemaPath, exception); + } catch (JsonProcessingException exception) { + throw new A2ATError("Failed to read slot schema resource: " + promptRootDir.resolve(pathKey), exception); } } @@ -61,19 +62,23 @@ public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { * Resolves a bare scenario code under one slot type directory, preferring the {@code network-layer} domain layout * over the plain layout. */ - private static Path resolveBareCode(Path typeDir, String scenarioCode, String language) { - Path networkLayer = typeDir.resolve("network-layer").resolve(scenarioCode).resolve("v1"); - if (Files.exists(networkLayer.resolve(language).resolve("slot.json"))) { - return networkLayer.resolve(language).resolve("slot.json"); + private String resolveBareCode(String slotType, String scenarioCode, String language) { + String networkLayer = "slots/" + slotType + "/network-layer/" + scenarioCode + "/v1/" + language + "/slot.json"; + if (snapshot.containsKey(networkLayer)) { + return networkLayer; } - return typeDir.resolve(scenarioCode).resolve("v1").resolve(language).resolve("slot.json"); + return "slots/" + slotType + "/" + scenarioCode + "/v1/" + language + "/slot.json"; } private ResourceNotFoundException notFound(String scenarioCode, String language) { String pathHint = scenarioCode.contains("/") - ? promptRootDir.resolve("slots").resolve(scenarioCode).resolve(language).resolve("slot.json").toString() - : promptRootDir.resolve("slots").toString() - + "/*/network-layer/" + scenarioCode + "/v1/" + language + ? promptRootDir + .resolve("slots") + .resolve(scenarioCode) + .resolve(language) + .resolve("slot.json") + .toString() + : promptRootDir.resolve("slots") + "/*/network-layer/" + scenarioCode + "/v1/" + language + "/slot.json (or the layout without the network-layer segment)"; return new ResourceNotFoundException("Prompt resource file does not exist.", pathHint); } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java index c80b2f74..e4e66b84 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java @@ -1,76 +1,77 @@ package net.openan.a2at.sdk.prompt.resources.loader; -import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.Optional; +import java.util.List; +import java.util.Map; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.resources.PathSegments; /** * Loads shared prompt templates from one local prompt resource root. * + *

        Resources are resolved against an assembly-time snapshot of the local root (ADR 0004): runtime reads never touch + * the filesystem, so changes to the local files only take effect after the SDK is restarted. + * * @since 2026-06 */ public final class LocalFilePromptTemplateLoader implements PromptTemplateTextLoader { + private final Map snapshot; private final Path promptRootDir; + private final List templateTypes; - public LocalFilePromptTemplateLoader(Path promptRootDir) { + public LocalFilePromptTemplateLoader(Map snapshot, Path promptRootDir) { + this.snapshot = snapshot; this.promptRootDir = promptRootDir; + this.templateTypes = LocalFileResourceSnapshot.typeDirectories(snapshot, "templates"); } @Override public String loadTemplate(String scenarioCode, String language) { - Path templatesRoot = promptRootDir.resolve("templates"); - if (!Files.exists(templatesRoot)) { - throw notFound(scenarioCode, language); - } - Path templatePath; + PathSegments.requireSimpleRelativePath(scenarioCode, "Prompt template scenario code"); + PathSegments.requireSimpleSegment(language, "Prompt template language"); + String pathKey; if (scenarioCode.contains("/")) { - templatePath = templatesRoot.resolve(scenarioCode).resolve(language).resolve("template.md"); - if (!Files.exists(templatePath)) { - throw notFound(scenarioCode, language); - } + pathKey = "templates/" + scenarioCode + "/" + language + "/template.md"; } else { - try (var typePaths = Files.list(templatesRoot)) { - Optional match = typePaths - .filter(Files::isDirectory) - .map(typeDir -> resolveBareCode(typeDir, scenarioCode, language, "template.md")) - .filter(Files::exists) - .findFirst(); - templatePath = match.orElse(null); - } catch (IOException exception) { - throw new A2ATError("Failed to scan prompt template resources: " + templatesRoot, exception); - } - if (templatePath == null) { - throw notFound(scenarioCode, language); + pathKey = null; + for (String templateType : templateTypes) { + String candidate = resolveBareCode(templateType, scenarioCode, language); + if (snapshot.containsKey(candidate)) { + pathKey = candidate; + break; + } } } - try { - return Files.readString(templatePath); - } catch (IOException exception) { - throw new A2ATError("Failed to read template resource: " + templatePath, exception); + if (pathKey == null || !snapshot.containsKey(pathKey)) { + throw notFound(scenarioCode, language); } + return snapshot.get(pathKey); } /** * Resolves a bare scenario code under one template type directory, preferring the {@code network-layer} domain * layout over the plain layout. */ - private static Path resolveBareCode(Path typeDir, String scenarioCode, String language, String fileName) { - Path networkLayer = typeDir.resolve("network-layer").resolve(scenarioCode).resolve("v1"); - if (Files.exists(networkLayer.resolve(language).resolve(fileName))) { - return networkLayer.resolve(language).resolve(fileName); + private String resolveBareCode(String templateType, String scenarioCode, String language) { + String networkLayer = + "templates/" + templateType + "/network-layer/" + scenarioCode + "/v1/" + language + "/template.md"; + if (snapshot.containsKey(networkLayer)) { + return networkLayer; } - return typeDir.resolve(scenarioCode).resolve("v1").resolve(language).resolve(fileName); + return "templates/" + templateType + "/" + scenarioCode + "/v1/" + language + "/template.md"; } private ResourceNotFoundException notFound(String scenarioCode, String language) { String pathHint = scenarioCode.contains("/") - ? promptRootDir.resolve("templates").resolve(scenarioCode).resolve(language).resolve("template.md").toString() - : promptRootDir.resolve("templates").toString() - + "/*/network-layer/" + scenarioCode + "/v1/" + language + "/template.md (or the layout without the network-layer segment)"; + ? promptRootDir + .resolve("templates") + .resolve(scenarioCode) + .resolve(language) + .resolve("template.md") + .toString() + : promptRootDir.resolve("templates") + "/*/network-layer/" + scenarioCode + "/v1/" + language + + "/template.md (or the layout without the network-layer segment)"; return new ResourceNotFoundException("Prompt resource file does not exist.", pathHint); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java new file mode 100644 index 00000000..ea57b569 --- /dev/null +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java @@ -0,0 +1,86 @@ +package net.openan.a2at.sdk.prompt.resources.loader; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.exception.A2ATError; + +/** + * Immutable in-memory snapshot of a local prompt resource root, captured once at assembly time. + * + *

        The snapshot freezes the {@code templates/}, {@code slots/} and {@code scenarios/} subtrees (ADR 0004: local + * resources are read at assembly time and never re-read). Runtime lookups resolve against this map rather than the + * filesystem, so local file changes only take effect after the SDK is restarted. + */ +final class LocalFileResourceSnapshot { + + private static final String SEPARATOR = "/"; + + private LocalFileResourceSnapshot() {} + + /** + * Captures the whole {@code templates/}, {@code slots/} and {@code scenarios/} subtrees under the local root into + * an immutable map keyed by forward-slash relative path. + * + * @param localRootDir local prompt resource root + * @return immutable snapshot mapping {@code templates/..., slots/..., scenarios/...} to file content + * @throws A2ATError if a file cannot be read or the subtree cannot be enumerated + */ + static Map capture(Path localRootDir) { + Map content = new TreeMap<>(); + captureTree(localRootDir.resolve("templates"), "templates", content); + captureTree(localRootDir.resolve("slots"), "slots", content); + captureTree(localRootDir.resolve("scenarios"), "scenarios", content); + return Map.copyOf(content); + } + + /** + * Returns the sorted first-level directory names found under one category prefix in the snapshot. + * + * @param snapshot snapshot content map + * @param category category prefix (templates or slots) + * @return sorted type directory names + */ + static List typeDirectories(Map snapshot, String category) { + Set types = new TreeSet<>(); + String prefix = category + SEPARATOR; + for (String key : snapshot.keySet()) { + if (!key.startsWith(prefix)) { + continue; + } + String rest = key.substring(prefix.length()); + int separator = rest.indexOf('/'); + if (separator > 0) { + types.add(rest.substring(0, separator)); + } + } + return List.copyOf(types); + } + + private static void captureTree(Path root, String category, Map content) { + if (!Files.isDirectory(root)) { + return; + } + List files; + try (Stream paths = Files.walk(root)) { + files = paths.filter(Files::isRegularFile).toList(); + } catch (IOException exception) { + throw new A2ATError("Failed to enumerate prompt resources under: " + root, exception); + } + for (Path file : files) { + String relativeKey = + category + SEPARATOR + root.relativize(file).toString().replace('\\', '/'); + try { + content.put(relativeKey, Files.readString(file)); + } catch (IOException exception) { + throw new A2ATError("Failed to read prompt resource: " + file, exception); + } + } + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java index 49384688..99913326 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java @@ -1,33 +1,44 @@ package net.openan.a2at.sdk.prompt.resources.loader; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.A2ATError; +import java.util.Map; +import net.openan.a2at.sdk.core.model.A2ATConfigKeys; import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; import net.openan.a2at.sdk.resources.PromptResourceKey; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Shared access point for prompt resources backed either by local files or classpath resources. * + *

        The factory routes resources by the three-way ADR 0004 classification: the business content (tasks' templates, + * slot schemas and scenario catalog) follows the configured source type; the LLM instruction prompts, negotiation + * templates and negotiation vocabulary are always loaded from the classpath. + * * @since 2026-06 */ public interface PromptResourceAccess { - String CLASSPATH_SOURCE_TYPE = "classpath"; + String CLASSPATH_SOURCE_TYPE = PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH; - String LOCAL_FILE_SOURCE_TYPE = "local_file"; + String LOCAL_FILE_SOURCE_TYPE = PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE; static PromptResourceAccess create(PromptRuntimeConfig config) { if (CLASSPATH_SOURCE_TYPE.equals(config.sourceType())) { + if (hasConfiguredLocalRoot(config.localRootDir())) { + warnIgnoredClasspathLocalRoot(config.localRootDir()); + } return new ClasspathAccess(new ClasspathPromptResourceLoader()); } if (LOCAL_FILE_SOURCE_TYPE.equals(config.sourceType())) { - return new LocalFileAccess(Path.of(config.localRootDir())); + Path localRootDir = requireLocalRoot(config.localRootDir()); + warnIgnoredLocalDirectories(localRootDir); + return new LocalFileAccess(localRootDir, new ClasspathPromptResourceLoader()); } throw new UnsupportedOperationException("Unsupported prompt source type: " + config.sourceType()); } @@ -91,9 +102,13 @@ public String loadPrompt(String promptCategory, String language, String fileName final class LocalFileAccess implements PromptResourceAccess { private final Path promptRootDir; + private final ClasspathPromptResourceLoader resourceLoader; + private final Map snapshot; - private LocalFileAccess(Path promptRootDir) { + private LocalFileAccess(Path promptRootDir, ClasspathPromptResourceLoader resourceLoader) { this.promptRootDir = promptRootDir; + this.resourceLoader = resourceLoader; + this.snapshot = LocalFileResourceSnapshot.capture(promptRootDir); } @Override @@ -103,7 +118,7 @@ public boolean classpath() { @Override public ClasspathPromptResourceLoader classpathResourceLoader() { - throw new UnsupportedOperationException("Local prompt resources do not have a classpath loader."); + return resourceLoader; } @Override @@ -113,34 +128,72 @@ public Path localRootDir() { @Override public List loadScenarios(String language) { - return new LocalFilePromptScenarioCatalogLoader(promptRootDir).load(language); + return new LocalFilePromptScenarioCatalogLoader(snapshot, promptRootDir).load(language); } @Override public PromptTemplateTextLoader templateLoader() { - return new LocalFilePromptTemplateLoader(promptRootDir); + return new LocalFilePromptTemplateLoader(snapshot, promptRootDir); } @Override public PromptSlotSchemaLoader slotSchemaLoader() { - return new LocalFilePromptSlotSchemaLoader(promptRootDir); + return new LocalFilePromptSlotSchemaLoader(snapshot, promptRootDir); } @Override public String loadPrompt(String promptCategory, String language, String fileName) { - Path promptPath = promptRootDir - .resolve("prompts") - .resolve(promptCategory) - .resolve(language) - .resolve(fileName); - if (!Files.exists(promptPath)) { - throw new ResourceNotFoundException("Prompt resource file does not exist.", promptPath.toString()); - } - try { - return Files.readString(promptPath); - } catch (IOException exception) { - throw new A2ATError("Failed to read prompt resource: " + promptPath, exception); - } + return resourceLoader.loadText(PromptResourceKey.prompt(promptCategory, language, fileName)); + } + } + + private static boolean hasConfiguredLocalRoot(String localRootDir) { + return localRootDir != null && !localRootDir.isBlank(); + } + + private static Path requireLocalRoot(String localRootDir) { + String key = A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR; + if (localRootDir == null || localRootDir.isBlank()) { + throw new IllegalArgumentException( + "Prompt resource local root directory is required for sourceType '" + LOCAL_FILE_SOURCE_TYPE + + "' but is not set; configure " + key + " to the local prompt resource root."); + } + Path root = Path.of(localRootDir); + if (!Files.isDirectory(root)) { + throw new IllegalArgumentException("Prompt resource local root directory configured via " + key + + " does not exist or is not a directory: " + root); + } + return root; + } + + private static void warnIgnoredClasspathLocalRoot(String localRootDir) { + logger().atWarn() + .log( + "prompt_resource_local_root_ignored root={} source=classpath reason=source_type_is_classpath", + localRootDir); + } + + private static void warnIgnoredLocalDirectories(Path localRootDir) { + List ignoredDirectories = new ArrayList<>(); + if (Files.isDirectory(localRootDir.resolve("prompts"))) { + ignoredDirectories.add("prompts"); + } + if (Files.isDirectory(localRootDir.resolve("templates").resolve("Negotiation-T"))) { + ignoredDirectories.add("templates/Negotiation-T"); + } + if (Files.isDirectory(localRootDir.resolve("negotiation-vocabulary"))) { + ignoredDirectories.add("negotiation-vocabulary"); } + if (!ignoredDirectories.isEmpty()) { + logger().atWarn() + .log( + "prompt_resource_local_directories_ignored root={} directories=[{}] reason=classpath_fixed", + localRootDir, + String.join(", ", ignoredDirectories)); + } + } + + private static Logger logger() { + return LoggerFactory.getLogger(PromptResourceAccess.class); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java index c27b5886..55053c65 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -1,7 +1,6 @@ package net.openan.a2at.sdk.prompt.validation; import java.util.Map; -import java.util.Objects; import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.FilledParamData; @@ -23,10 +22,10 @@ * {@code TEMPLATE_NOT_FOUND} error, distinct from the {@code VALIDATION_PROMPT_RESOURCE_NOT_FOUND} code that signals * missing content_validation prompt resources. The loaded template text flows into the pipeline for prompt injection. * - *

        The semantic validator is initialised lazily on the first {@link #validate} call so that constructing the - * validator never loads prompt resources — the facade builders can wire the validator eagerly without requiring the - * content_validation prompt resources to be present at startup. The content_validation prompt resources are internal - * LLM instructions loaded from the classpath regardless of the configured prompt source type. + *

        The validation pipeline and the content_validation prompt resources are assembled eagerly in the constructor (ADR + * 0004 snapshot semantics): the constructor resolves and freezes the prompt resources once, and a missing resource + * fails fast at assembly time instead of on the first {@link #validate} call. The content_validation prompt resources + * are internal LLM instructions loaded from the classpath regardless of the configured prompt source type. * * @since 2026-08 */ @@ -34,17 +33,14 @@ public final class DefaultContentValidator implements ContentValidator { private final String extensionName; private final String language; - private final int maxAttempts; - private final LLMClient llmClient; private final PromptTemplateTextLoader templateLoader; - - private volatile ValidationPipeline pipeline; + private final ValidationPipeline pipeline; /** * Creates a content validator for the given extension name and language. * - *

        The constructor does not load any prompt resources. The underlying semantic validator and its prompt resources - * are loaded on the first {@link #validate} call. + *

        The constructor assembles the validation pipeline and loads the content_validation prompt resources of the + * configured language from the classpath. * * @param extensionName extension name used for template URI validation * @param language language code for prompt resource loading and template loading @@ -54,6 +50,8 @@ public final class DefaultContentValidator implements ContentValidator { * {@code VALIDATION_LLM_INFRASTRUCTURE_ERROR}; there is no late injection point * @param templateLoader sourceType-aware template text loader for loading the template referenced by the * {@code templateUri} of every {@link #validate} call + * @throws ContentValidationException if the content_validation prompt resources of the given language are missing + * on the classpath */ public DefaultContentValidator( @NonNull String extensionName, @@ -63,9 +61,8 @@ public DefaultContentValidator( @NonNull PromptTemplateTextLoader templateLoader) { this.extensionName = extensionName; this.language = language; - this.maxAttempts = maxAttempts; - this.llmClient = llmClient; this.templateLoader = templateLoader; + this.pipeline = assemblePipeline(language, maxAttempts, llmClient); } /** @@ -76,26 +73,31 @@ public DefaultContentValidator( * @param templateUri URI of the template the content is validated against, such as * {@code Task-T/network-layer/ran-energy-saving/v1} * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank, the template URI addresses another extension than the - * one this validator is configured for, or the template URI version is unsupported - * @throws ContentValidationException if the validation fails at any stage, including - * {@code TEMPLATE_NOT_FOUND} when the template cannot be loaded, or - * {@code VALIDATION_PROMPT_RESOURCE_NOT_FOUND} when the content_validation prompt resources of the configured - * language are missing on the classpath + * @throws ContentValidationException with {@code validation_invalid_input} if the template URI is null, addresses + * another extension than the one this validator is configured for, or carries an unsupported version; or if the + * prompt is null or blank, or the schema is null + * @throws ContentValidationException if the validation fails at any stage, including {@code TEMPLATE_NOT_FOUND} + * when the template cannot be loaded */ @Override public FilledParamData validate( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); + if (templateUri == null) { + throw new ContentValidationException( + A2ATErrorCodes.VALIDATION_INVALID_INPUT, "Template URI must not be null."); + } if (!extensionName.equals(templateUri.extensionName())) { - throw new IllegalArgumentException("Template URI extension '" + templateUri.extensionName() - + "' does not match expected extension '" + extensionName + "'."); + throw new ContentValidationException( + A2ATErrorCodes.VALIDATION_INVALID_INPUT, + "Template URI extension '" + templateUri.extensionName() + + "' does not match expected extension '" + extensionName + "'."); } if (!TemplateUri.DEFAULT_TEMPLATE_VERSION.equals(templateUri.templateVersion())) { - throw new IllegalArgumentException("Unsupported template URI version: " + templateUri.templateVersion()); + throw new ContentValidationException( + A2ATErrorCodes.VALIDATION_INVALID_INPUT, + "Unsupported template URI version: " + templateUri.templateVersion()); } String templateContent; @@ -109,26 +111,17 @@ public FilledParamData validate( exception); } - return pipeline().validate(prompt, schema, templateUri, templateContent); + return pipeline.validate(prompt, schema, templateUri, templateContent); } - private ValidationPipeline pipeline() { - ValidationPipeline p = pipeline; - if (p == null) { - synchronized (this) { - p = pipeline; - if (p == null) { - try { - p = new ValidationPipeline( - prompt -> Map.of(), new DefaultSemanticValidator(llmClient, language), maxAttempts); - } catch (ResourceNotFoundException exception) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, exception.getMessage(), exception); - } - pipeline = p; - } - } + private static ValidationPipeline assemblePipeline( + String language, int maxAttempts, @Nullable LLMClient llmClient) { + try { + return new ValidationPipeline<>( + prompt -> Map.of(), new DefaultSemanticValidator(llmClient, language), maxAttempts); + } catch (ResourceNotFoundException exception) { + throw new ContentValidationException( + A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, exception.getMessage(), exception); } - return p; } } diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java new file mode 100644 index 00000000..1763a53f --- /dev/null +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java @@ -0,0 +1,224 @@ +package net.openan.a2at.sdk.prompt.resources.catalog; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +class TemplateQueryServiceTest { + + private static final String LANGUAGE = "en-US"; + + private static final String CLASSPATH = "classpath"; + + private static final String LOCAL = "local"; + + private static final List NEGOTIATION_CLOSED_SET = List.of( + "Negotiation-T/common/abort/v1", + "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "Negotiation-T/feasibility-negotiation/propose/v1", + "Negotiation-T/information-negotiation/accept-reject/v1", + "Negotiation-T/information-negotiation/propose/v1", + "Negotiation-T/target-negotiation/accept-reject/v1", + "Negotiation-T/target-negotiation/propose/v1"); + + @TempDir + Path localRootDir; + + private final ListAppender appender = new ListAppender<>(); + + private final Logger rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + + @BeforeEach + void attachAppender() { + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + rootLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void classpathSourceTypeListsBuiltInTemplatesWithClasspathSource() { + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "classpath", null); + + List templates = service.getPrompts(); + + assertFalse(templates.isEmpty()); + assertTrue(templates.stream().allMatch(t -> CLASSPATH.equals(t.source()))); + assertSortedByUri(templates); + Set uris = templates.stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + assertTrue(uris.containsAll(NEGOTIATION_CLOSED_SET)); + } + + @Test + void localFileSourceTypeListsLocalBusinessContentUnionClasspathNegotiationOnly() throws IOException { + writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + List templates = service.getPrompts(); + Set uris = templates.stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + + assertTrue(uris.contains("Task-T/network-layer/custom-planning/v1")); + assertFalse(uris.contains("Task-T/network-layer/ran-energy-saving/v1")); + assertFalse(uris.contains("Notification-T/network-layer/subscribe-incident/v1")); + assertFalse(uris.contains("Authorization-T/authorization-policy-management/v1")); + for (String negotiationUri : NEGOTIATION_CLOSED_SET) { + assertTrue(uris.contains(negotiationUri)); + } + } + + @Test + void localFileSourceTypeAnnotatesLocalAndClasspathSources() throws IOException { + writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + List templates = service.getPrompts(); + PromptTemplate localTask = templates.stream() + .filter(t -> t.templateUri().uri().equals("Task-T/network-layer/custom-planning/v1")) + .findFirst() + .orElseThrow(); + assertEquals(LOCAL, localTask.source()); + + assertTrue( + templates.stream() + .filter(t -> t.templateUri().extensionName().equals("Negotiation-T")) + .allMatch(t -> CLASSPATH.equals(t.source())), + "all negotiation templates must be sourced from the classpath"); + } + + @Test + void closedSetFilterExcludesOutOfSetNegotiationTemplates() throws IOException { + writeTemplate(localRootDir, "Negotiation-T/feasibility-negotiation/revise/v1", "\nbody"); + writeTemplate( + localRootDir, "Negotiation-T/information-negotiation/propose/v1", "\nbody"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + Set uris = + service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + + assertFalse(uris.contains("Negotiation-T/feasibility-negotiation/revise/v1")); + assertTrue(uris.contains("Negotiation-T/information-negotiation/propose/v1")); + assertTrue( + service.getPrompts().stream() + .filter(t -> t.templateUri().uri().equals("Negotiation-T/information-negotiation/propose/v1")) + .allMatch(t -> CLASSPATH.equals(t.source())), + "the in-closed-set negotiation template must come from the classpath, not the local override"); + + assertTrue(service.getPrompt(TemplateUri.of("Negotiation-T", "feasibility-negotiation", "revise")) + .isEmpty()); + } + + @Test + void localFileSourceTypeFreezesCatalogSnapshotIgnoringRuntimeFileChanges() throws IOException { + writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + Set before = service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + + writeTemplate(localRootDir, "Notification-T/network-layer/added-after-construction/v1", "\nbody"); + Files.delete(localRootDir.resolve("templates") + .resolve("Task-T/network-layer/custom-planning/v1") + .resolve(LANGUAGE) + .resolve("template.md")); + + Set after = service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + + assertEquals(before, after); + } + + @Test + void negotiationTemplateOutsideClosedSetLogsWarning() { + new TemplateQueryService(LANGUAGE, "classpath", null) + .getPrompt(TemplateUri.of("Negotiation-T", "feasibility-negotiation", "revise")); + + assertTrue( + warningEvents().stream().anyMatch(message -> message.startsWith("negotiation_template_outside_closed_set")), + "expected a WARN for filtering a negotiation template outside the closed set"); + } + + @Test + void unsupportedSourceTypeFailsFast() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new TemplateQueryService(LANGUAGE, "database", localRootDir.toString())); + + assertEquals("Unsupported prompt source type: database", exception.getMessage()); + } + + @Test + void localFileSourceTypeWithoutRootFailsFast() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> new TemplateQueryService(LANGUAGE, "local_file", null)); + + assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); + } + + @Test + void localFileSourceTypeWithBlankRootFailsFast() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> new TemplateQueryService(LANGUAGE, "local_file", " ")); + + assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); + } + + @Test + void singleQueryMissLogsPromptTemplateNotFoundWarning() { + new TemplateQueryService(LANGUAGE, "classpath", null) + .getPrompt(TemplateUri.of("Task-T", "network-layer", "does-not-exist")); + + assertTrue( + warningEvents().stream().anyMatch(message -> message.startsWith("prompt_template_not_found")), + "expected a WARN for a missing single template query"); + } + + private List warningEvents() { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void writeTemplate(Path root, String uri, String content) throws IOException { + Path file = root.resolve("templates").resolve(uri).resolve(LANGUAGE).resolve("template.md"); + Files.createDirectories(file.getParent()); + Files.writeString(file, content); + } + + private static void assertSortedByUri(List templates) { + for (int i = 1; i < templates.size(); i++) { + assertTrue( + templates + .get(i - 1) + .templateUri() + .uri() + .compareTo(templates.get(i).templateUri().uri()) + <= 0, + "templates must be sorted by URI"); + } + } +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java index ae54c983..ad194a48 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java @@ -1,6 +1,8 @@ package net.openan.a2at.sdk.prompt.resources.loader; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -8,8 +10,10 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import org.junit.jupiter.api.Test; @@ -43,7 +47,8 @@ void loadScenarioCatalogMapsJacksonAnnotatedRecords() throws IOException { } """); - List scenarios = new LocalFilePromptScenarioCatalogLoader(promptRootDir).load("en"); + List scenarios = + new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en"); assertEquals(2, scenarios.size()); assertEquals("incident_triage", scenarios.get(0).scenarioCode()); @@ -54,7 +59,7 @@ void loadScenarioCatalogMapsJacksonAnnotatedRecords() throws IOException { void loadScenarioCatalogTreatsMissingScenariosArrayAsEmptyCatalog() throws IOException { write(promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), "{}"); - assertEquals(List.of(), new LocalFilePromptScenarioCatalogLoader(promptRootDir).load("en")); + assertEquals(List.of(), new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en")); } @Test @@ -75,7 +80,8 @@ void loadTemplateReadsExactLocalMarkdownText() throws IOException { Summary: {{summary}} """); - String template = new LocalFilePromptTemplateLoader(promptRootDir).loadTemplate("incident_triage", "en"); + String template = + new LocalFilePromptTemplateLoader(snapshot(), promptRootDir).loadTemplate("incident_triage", "en"); assertEquals( """ @@ -120,7 +126,7 @@ void loadSlotSchemaMapsJacksonAnnotatedRecords() throws IOException { """); PromptSlotSchema schema = - new LocalFilePromptSlotSchemaLoader(promptRootDir).loadSlotSchema("incident_triage", "en"); + new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir).loadSlotSchema("incident_triage", "en"); assertEquals("incident_triage", schema.scenarioCode()); assertEquals(3, schema.slotDefinitions().size()); @@ -136,8 +142,8 @@ void loadSlotSchemaMapsJacksonAnnotatedRecords() throws IOException { @Test void missingTemplateIncludesResolvedLocalPathInResourceNotFoundException() { - ResourceNotFoundException exception = - assertThrows(ResourceNotFoundException.class, () -> new LocalFilePromptTemplateLoader(promptRootDir) + ResourceNotFoundException exception = assertThrows( + ResourceNotFoundException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) .loadTemplate("incident_triage", "en")); String expected = (promptRootDir.resolve("templates").toString() @@ -160,12 +166,167 @@ void malformedLocalSlotSchemaIsWrappedAsA2ATError() throws IOException { .resolve("slot.json"), "{ \"required\": [\"severity\"], \"properties\": "); - A2ATError exception = assertThrows(A2ATError.class, () -> new LocalFilePromptSlotSchemaLoader(promptRootDir) - .loadSlotSchema("incident_triage", "en")); + A2ATError exception = + assertThrows(A2ATError.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) + .loadSlotSchema("incident_triage", "en")); assertTrue(exception.getMessage().startsWith("Failed to read slot schema resource: ")); } + @Test + void snapshotFreezesResourcesAgainstRuntimeFileChanges() throws IOException { + write( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + "Original template {{service}}."); + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + """ + { + "required": ["service"], + "properties": { + "service": {"type": "string", "description": "Affected service name"} + } + } + """); + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "incident_triage", + "scenario_name": "Incident Triage", + "description": "Classify and route a production incident.", + "example": "Investigate elevated API latency." + } + ] + } + """); + + PromptResourceAccess access = createAccess(); + + Files.writeString( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + "Mutated template."); + Files.writeString( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + "{\"properties\":{}}"); + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "mutated", + "scenario_name": "Mutated", + "description": "Mutated after snapshot.", + "example": "Mutated example." + } + ] + } + """); + + assertEquals("Original template {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); + assertEquals( + "service", + access.slotSchemaLoader() + .loadSlotSchema("incident_triage", "en") + .slotDefinitions() + .get(0) + .name()); + assertEquals("incident_triage", access.loadScenarios("en").get(0).scenarioCode()); + } + + @Test + void loadTemplateRejectsTraversalOrBlankScenarioPathSegments() { + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) + .loadTemplate("../etc/passwd", "en")); + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) + .loadTemplate("Task-T//network-layer/x", "en")); + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) + .loadTemplate("incident_triage", "en/../admin")); + } + + @Test + void loadSlotSchemaRejectsNonSimpleLanguage() { + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) + .loadSlotSchema("incident_triage", "../en")); + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) + .loadSlotSchema("Task-T/network-layer/..", "en")); + } + + @Test + void loadScenarioCatalogRejectsNonSimpleLanguage() { + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en/../admin")); + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load(" ")); + } + + @Test + void localSnapshotKeepsPromptsOnClasspath() throws IOException { + write( + promptRootDir + .resolve("prompts") + .resolve("scenario_recognition") + .resolve("en-US") + .resolve("system.md"), + "Local prompt copy."); + + PromptResourceAccess access = createAccess(); + + assertFalse(access.classpath()); + assertNotEquals("Local prompt copy.", access.loadPrompt("scenario_recognition", "en-US", "system.md")); + } + + private PromptResourceAccess createAccess() { + return PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + } + + private Map snapshot() { + return LocalFileResourceSnapshot.capture(promptRootDir); + } + private static void write(Path file, String content) throws IOException { Files.createDirectories(file.getParent()); Files.writeString(file, content); diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java index 3e3674a2..5e424bb5 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java @@ -3,7 +3,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -12,23 +17,94 @@ import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; class PromptResourceAccessTest { @TempDir Path promptRootDir; + private final ListAppender appender = new ListAppender<>(); + + private final Logger logger = (Logger) LoggerFactory.getLogger(PromptResourceAccess.class); + + @BeforeEach + void attachAppender() { + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + logger.detachAppender(appender); + } + + @Test + void classpathModeIgnoresConfiguredLocalRootWithSingleWarning() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", promptRootDir.toString())); + + assertTrue(access.classpath()); + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), "prompt_resource_local_root_ignored", "root=" + promptRootDir, "source=classpath"); + } + @Test - void createLocalFileAccessLoadsPromptsTemplatesScenariosAndSlotsFromSameRoot() throws IOException { + void localFileModeFailsFastWhenLocalRootIsNull() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", null))); + + assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + } + + @Test + void localFileModeFailsFastWhenLocalRootIsBlank() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", " "))); + + assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + } + + @Test + void localFileModeFailsFastWhenLocalRootDoesNotExist() { + Path missing = promptRootDir.resolve("missing"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", missing.toString()))); + + assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + } + + @Test + void localFileModeLoadsPromptsFromClasspath() throws IOException { write( promptRootDir .resolve("prompts") - .resolve("analysis") - .resolve("en") + .resolve("scenario_recognition") + .resolve("en-US") .resolve("system.md"), - "Local system prompt."); + "Local prompt copy."); + + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + assertFalse(access.classpath()); + String prompt = access.loadPrompt("scenario_recognition", "en-US", "system.md"); + assertTrue(prompt.startsWith("You are a scenario recognition agent.")); + assertFalse(prompt.equals("Local prompt copy.")); + } + + @Test + void localFileModeLoadsBusinessContentFromLocalRoot() throws IOException { write( promptRootDir .resolve("templates") @@ -76,8 +152,6 @@ void createLocalFileAccessLoadsPromptsTemplatesScenariosAndSlotsFromSameRoot() t assertFalse(access.classpath()); assertEquals(promptRootDir, access.localRootDir()); - assertThrows(UnsupportedOperationException.class, access::classpathResourceLoader); - assertEquals("Local system prompt.", access.loadPrompt("analysis", "en", "system.md")); assertEquals("Local template for {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); List scenarios = access.loadScenarios("en"); PromptSlotSchema slotSchema = access.slotSchemaLoader().loadSlotSchema("incident_triage", "en"); @@ -87,21 +161,48 @@ void createLocalFileAccessLoadsPromptsTemplatesScenariosAndSlotsFromSameRoot() t } @Test - void localPromptResourceNotFoundIncludesResolvedPromptPath() { + void localFileModeIgnoresUnsupportedLocalDirectoriesWithSingleWarning() throws IOException { + write( + promptRootDir + .resolve("prompts") + .resolve("scenario_recognition") + .resolve("en-US") + .resolve("system.md"), + "Local prompt copy."); + write( + promptRootDir + .resolve("templates") + .resolve("Negotiation-T") + .resolve("information-negotiation") + .resolve("propose") + .resolve("v1") + .resolve("en-US") + .resolve("template.md"), + "Local negotiation template copy."); + write(promptRootDir.resolve("negotiation-vocabulary").resolve("en-US").resolve("vocabulary.json"), "{}"); + + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), + "prompt_resource_local_directories_ignored", + "prompts", + "templates/Negotiation-T", + "negotiation-vocabulary", + "reason=classpath_fixed"); + } + + @Test + void localFileModePromptLoadUsesClasspathPathForMissingResource() { PromptResourceAccess access = PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> access.loadPrompt("analysis", "en", "missing.md")); - assertEquals( - promptRootDir - .resolve("prompts") - .resolve("analysis") - .resolve("en") - .resolve("missing.md") - .toString(), - exception.resourcePath()); + assertEquals("prompt_resources/prompts/analysis/en/missing.md", exception.resourcePath()); } @Test @@ -114,6 +215,19 @@ void unsupportedSourceTypeFailsFast() { assertEquals("Unsupported prompt source type: database", exception.getMessage()); } + private List warningMessages() { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void assertContains(String message, String... fragments) { + for (String fragment : fragments) { + assertTrue(message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); + } + } + private static void write(Path file, String content) throws IOException { Files.createDirectories(file.getParent()); Files.writeString(file, content); diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java index a0382b4c..a0900992 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java @@ -27,7 +27,8 @@ class DefaultContentValidatorTest { @Test void validatesSuccessfullyOnFirstCall() { - DefaultContentValidator validator = new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); FilledParamData result = assertDoesNotThrow(() -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); @@ -38,103 +39,93 @@ void validatesSuccessfullyOnFirstCall() { @Test void rejectsMismatchedExtensionName() { - DefaultContentValidator validator = new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); - assertThrows( - IllegalArgumentException.class, + ContentValidationException exception = assertThrows( + ContentValidationException.class, () -> validator.validate( "task prompt", Map.of("type", "object"), TemplateUri.of("Notification-T", "network-layer", "service-recovery"))); + + assertEquals("validation_invalid_input", exception.getCode()); } @Test - void missingPromptResourceFailsWithValidationPromptResourceNotFound() { - DefaultContentValidator validator = new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient(), STUB_LOADER); + void templateVersionIsUnsupported() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); + + TemplateUri unsupportedVersion = + TemplateUri.of("Task-T", List.of("network-layer", "ran-energy-saving"), "v2"); ContentValidationException exception = assertThrows( ContentValidationException.class, - () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + () -> validator.validate("task prompt", Map.of("type", "object"), unsupportedVersion)); - assertEquals("validation_prompt_resource_not_found", exception.getCode()); - assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); - assertTrue(exception.getMessage().contains("zh-CN or en-US")); + assertEquals("validation_invalid_input", exception.getCode()); } @Test - void missingPromptResourceKeepsPipelineNullSoNextCallRetries() { - DefaultContentValidator validator = new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient(), STUB_LOADER); + void templateUriIsNull() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); - // first call fails on the missing resource; the pipeline must stay uninitialised so the next - // call re-attempts the construction (transient failures can heal) - assertThrows( - ContentValidationException.class, - () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); - ContentValidationException second = assertThrows( + ContentValidationException exception = assertThrows( ContentValidationException.class, - () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + () -> validator.validate("task prompt", Map.of("type", "object"), null)); - assertEquals("validation_prompt_resource_not_found", second.getCode()); + assertEquals("validation_invalid_input", exception.getCode()); } @Test - void succeedsOnSecondCallAfterTransientResourceFailureHeals() { - DefaultContentValidator validator = new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient(), STUB_LOADER); - - assertThrows( + void unsupportedLanguageFailsAtConstructionWithValidationPromptResourceNotFound() { + ContentValidationException exception = assertThrows( ContentValidationException.class, - () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + () -> new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient(), STUB_LOADER)); - // switching the language is impossible on this class; the healing path is instead proven by a - // fresh validator with a supported language constructed after the failing one - DefaultContentValidator healed = new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); - FilledParamData result = - assertDoesNotThrow(() -> healed.validate("task prompt", Map.of("type", "object"), TASK_URI)); - - assertNotNull(result); + assertEquals("validation_prompt_resource_not_found", exception.getCode()); + assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); + assertTrue(exception.getMessage().contains("zh-CN or en-US")); } @Test - void retryableFailureAfterResourceLoadStillMapsLlmInfrastructureError() { - DefaultContentValidator validator = new DefaultContentValidator("Task-T", "zh-CN", 1, new FailingClient(), STUB_LOADER); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + void constructionSucceedsWithEmptyLocalRootBecausePromptLoadsFromClasspath() { + PromptTemplateTextLoader emptyLocalRootLoader = (scenarioCode, language) -> { + throw new ResourceNotFoundException("No template in the local root for " + scenarioCode, scenarioCode); + }; - assertEquals("validation_llm_infrastructure_error", exception.getCode()); + assertDoesNotThrow( + () -> new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), emptyLocalRootLoader)); } @Test - void missingTemplateFailsWithTemplateNotFound() { - PromptTemplateTextLoader failingLoader = (scenarioCode, language) -> { - throw new ResourceNotFoundException("Template does not exist.", scenarioCode); - }; - DefaultContentValidator validator = new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), failingLoader); + void retryableFailureAfterResourceLoadStillMapsLlmInfrastructureError() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 1, new FailingClient(), STUB_LOADER); ContentValidationException exception = assertThrows( ContentValidationException.class, () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); - assertEquals("template_not_found", exception.getCode()); - assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); + assertEquals("validation_llm_infrastructure_error", exception.getCode()); } @Test - void missingTemplateLoadsBeforePromptResourcesSoTemplateErrorTakesPrecedence() { - // template load fails on an otherwise-valid, resource-complete configuration: the template gate must - // short-circuit before the pipeline (and its prompt resources) are ever initialised + void missingTemplateFailsWithTemplateNotFound() { PromptTemplateTextLoader failingLoader = (scenarioCode, language) -> { throw new ResourceNotFoundException("Template does not exist.", scenarioCode); }; DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient(), failingLoader); + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), failingLoader); ContentValidationException exception = assertThrows( ContentValidationException.class, () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); assertEquals("template_not_found", exception.getCode()); + assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); } private static final class StubClient implements LLMClient { diff --git a/a2a-t-prompt/src/test/resources/logback-test.xml b/a2a-t-prompt/src/test/resources/logback-test.xml new file mode 100644 index 00000000..cefee40d --- /dev/null +++ b/a2a-t-prompt/src/test/resources/logback-test.xml @@ -0,0 +1,11 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + \ No newline at end of file diff --git a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java index f6d26362..a24a2bee 100644 --- a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java +++ b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java @@ -3,17 +3,25 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.concurrent.ConcurrentHashMap; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import org.jspecify.annotations.Nullable; /** * Loads prompt resources from the classpath bundle packaged with the SDK. * + *

        Classpath resources are frozen at assembly time (ADR 0004): each resource path is resolved once and cached at the + * JVM level, so every subsequent load returns the cached copy. Missing resources throw on every call and are never + * cached. The loader is thread-safe. + * * @since 2026-06 */ public final class ClasspathPromptResourceLoader { + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + /** * Loads one UTF-8 text resource from the packaged prompt resource tree. * @@ -22,6 +30,11 @@ public final class ClasspathPromptResourceLoader { */ public String loadText(PromptResourceKey key) { String relativePath = key.relativePath(); + CacheKey cacheKey = new CacheKey(relativePath, Thread.currentThread().getContextClassLoader()); + return CACHE.computeIfAbsent(cacheKey, ignored -> read(relativePath)); + } + + private static String read(String relativePath) { InputStream stream = ClasspathResourceStreams.open(relativePath); if (stream == null) { throw new ResourceNotFoundException("Prompt resource file does not exist.", relativePath); @@ -33,4 +46,6 @@ public String loadText(PromptResourceKey key) { throw new A2ATError("Failed to read prompt resource: " + relativePath, error); } } + + private record CacheKey(String relativePath, @Nullable ClassLoader contextClassLoader) {} } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/en-US/system.md deleted file mode 100644 index 24889074..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/en-US/system.md +++ /dev/null @@ -1 +0,0 @@ -Clarification negotiation started by the {role} side. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/en-US/user.md deleted file mode 100644 index 16f27d30..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/en-US/user.md +++ /dev/null @@ -1,4 +0,0 @@ -Status={status} -Round={round} -Content={content_text} -Facts={facts_json} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/zh-CN/system.md deleted file mode 100644 index 615f0306..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/zh-CN/system.md +++ /dev/null @@ -1 +0,0 @@ -由{role}方发起澄清协商。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/zh-CN/user.md deleted file mode 100644 index dd2fb94d..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/clarification_negotiation/zh-CN/user.md +++ /dev/null @@ -1,4 +0,0 @@ -状态={status} -轮次={round} -内容={content_text} -事实={facts_json} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/en-US/system.md deleted file mode 100644 index 281d86d9..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/en-US/system.md +++ /dev/null @@ -1 +0,0 @@ -Fulfillment negotiation started by the {role} side. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/en-US/user.md deleted file mode 100644 index 16f27d30..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/en-US/user.md +++ /dev/null @@ -1,4 +0,0 @@ -Status={status} -Round={round} -Content={content_text} -Facts={facts_json} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/zh-CN/system.md deleted file mode 100644 index 09b083c1..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/zh-CN/system.md +++ /dev/null @@ -1 +0,0 @@ -由{role}方发起履约协商。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/zh-CN/user.md deleted file mode 100644 index dd2fb94d..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/fulfillment_negotiation/zh-CN/user.md +++ /dev/null @@ -1,4 +0,0 @@ -状态={status} -轮次={round} -内容={content_text} -事实={facts_json} diff --git a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java index 82cefe3b..c12896ec 100644 --- a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java +++ b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java @@ -4,6 +4,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.TemplateUri; @@ -66,4 +70,37 @@ void loadsPackagedSubscribeIncidentSlotSchemaWithSemanticHint() { assertTrue(text.contains("medium")); assertTrue(text.contains("low")); } + + @Test + void servesTheCachedValueAfterTheClasspathResourceIsAlteredOnDisk() throws Exception { + PromptResourceKey key = PromptResourceKey.prompt("slot_extraction", "en-US", "system.md"); + String first = loader.loadText(key); + + Path resourceFile = testResourceFile("prompt_resources/prompts/slot_extraction/en-US/system.md"); + String original = Files.readString(resourceFile, StandardCharsets.UTF_8); + Files.writeString(resourceFile, "changed on disk", StandardCharsets.UTF_8); + try { + assertEquals(first, loader.loadText(key)); + } finally { + Files.writeString(resourceFile, original, StandardCharsets.UTF_8); + } + } + + @Test + void doesNotCacheMissingResourcesAndKeepsThrowing() { + PromptResourceKey key = PromptResourceKey.template( + TemplateUri.of("Task-T", "network-layer", "missing_scenario"), "en-US", "template.md"); + + assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); + assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); + } + + private static Path testResourceFile(String classpathPath) throws Exception { + URL resource = ClasspathPromptResourceLoaderTest.class.getClassLoader().getResource(classpathPath); + if (resource == null || !"file".equals(resource.getProtocol())) { + throw new IllegalStateException( + "test resource not reachable as a file URL: " + classpathPath + " -> " + resource); + } + return Path.of(resource.toURI()); + } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java index 503e7db4..8e1cd8d7 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java @@ -33,7 +33,6 @@ import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; import net.openan.a2at.sdk.server.A2ATServer; import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; @@ -209,7 +208,7 @@ static AuthzPromptGenerator buildGenerator( return client.generateAuthPromptFromDataWithSchema(data, schema, templateUri); }; } - A2ATConfig config = NegotiationContentService.resolvePromptResourceLocalRootDir( + A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir( A2ATConfig.load(envPath), envPath); LLMClient real = LLMClientFactory.create( config.llm().provider(), LLMClientConfig.from(config.llm())); @@ -235,7 +234,7 @@ static AuthzPromptValidator buildValidator(Path envPath, AuthzReasoningCapture c A2ATServer server = new A2ATServer(envPath); return server::validateAuthPromptAndDataFilling; } - A2ATConfig config = NegotiationContentService.resolvePromptResourceLocalRootDir( + A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir( A2ATConfig.load(envPath), envPath); LLMClient real = LLMClientFactory.create( config.llm().provider(), LLMClientConfig.from(config.llm())); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java index e0d5025f..5d13008f 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java @@ -471,11 +471,6 @@ private static final class MissingTemplateLoader implements NegotiationTemplateL public PromptTemplate load(NegotiationReference reference) { throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); } - - @Override - public List loadAll() { - return List.of(); - } } private static String gitRevision() { diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java index 965eb8b7..ddd43800 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java @@ -1,6 +1,7 @@ package net.openan.a2at.sample.api; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import java.lang.reflect.Method; import java.util.Arrays; @@ -30,15 +31,18 @@ class NegotiationFacadeApiSymmetryTest { "generateNegotiationAcceptPromptFromText", "generateNegotiationRejectPromptFromText", "generateNegotiationAbortPromptFromText", - "getNegotiationPrompts", - "getNegotiationPrompt", "validateProposePromptAndDataFilling", "validateAcceptPromptAndDataFilling", "validateRejectPromptAndDataFilling", "validateAbortPromptAndDataFilling"); + private static final List REMOVED_NEGOTIATION_QUERY_METHODS = + List.of("getNegotiationPrompts", "getNegotiationPrompt"); + + private static final List CROSS_EXTENSION_QUERY_METHODS = List.of("getPrompts", "getPrompt"); + @Test - void bothFacadesExposeTheSameFourteenNegotiationApiMethods() { + void bothFacadesExposeTheSameTwelveNegotiationApiMethods() { Map clientSurface = negotiationApiSurface(A2ATClient.class); Map serverSurface = negotiationApiSurface(A2ATServer.class); @@ -46,6 +50,38 @@ void bothFacadesExposeTheSameFourteenNegotiationApiMethods() { assertEquals(clientSurface, serverSurface); } + @Test + void neitherFacadeExposesTheRemovedNegotiationQueryMethods() { + for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { + for (String removedMethod : REMOVED_NEGOTIATION_QUERY_METHODS) { + boolean present = Arrays.stream(facade.getMethods()) + .anyMatch(method -> method.getName().equals(removedMethod)); + assertFalse( + present, + facade.getSimpleName() + " must not expose the removed " + removedMethod + " query method"); + } + } + } + + @Test + void bothFacadesExposeTheSameCrossExtensionQueryMethods() { + assertEquals( + new HashSet<>(CROSS_EXTENSION_QUERY_METHODS), + Arrays.stream(A2ATClient.class.getMethods()) + .filter(method -> CROSS_EXTENSION_QUERY_METHODS.contains(method.getName())) + .map(Method::getName) + .collect(Collectors.toSet())); + assertEquals( + Arrays.stream(A2ATClient.class.getMethods()) + .filter(method -> CROSS_EXTENSION_QUERY_METHODS.contains(method.getName())) + .map(NegotiationFacadeApiSymmetryTest::methodSignature) + .collect(Collectors.toSet()), + Arrays.stream(A2ATServer.class.getMethods()) + .filter(method -> CROSS_EXTENSION_QUERY_METHODS.contains(method.getName())) + .map(NegotiationFacadeApiSymmetryTest::methodSignature) + .collect(Collectors.toSet())); + } + private static Map negotiationApiSurface(Class facade) { return Arrays.stream(facade.getMethods()) .filter(method -> NEGOTIATION_API_METHODS.contains(method.getName())) diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index 90c3ff6f..0fd5e355 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -42,7 +42,7 @@ /** * Verifies the output symmetry of the negotiation content layer across the client and the server facade. * - *

        Both facades expose the eleven negotiation API methods as one symmetric surface and produce, for identical inputs, + *

        Both facades expose the twelve negotiation API methods as one symmetric surface and produce, for identical inputs, * identical generation results: the whole MetadataContent record and the metadata map built from it are equal on both * sides. The inputs are the fixed golden fixture inputs of the negotiation module, so this test also proves that both * facades are wired to the same built-in resources. @@ -67,8 +67,6 @@ static void registerMockProvider() { "generateNegotiationAcceptPromptFromText", "generateNegotiationRejectPromptFromText", "generateNegotiationAbortPromptFromText", - "getNegotiationPrompts", - "getNegotiationPrompt", "validateProposePromptAndDataFilling", "validateAcceptPromptAndDataFilling", "validateRejectPromptAndDataFilling", @@ -78,7 +76,7 @@ static void registerMockProvider() { Path tempDir; @Test - void bothFacadesExposeTheSameFourteenMethodSignatures() { + void bothFacadesExposeTheSameTwelveMethodSignatures() { Map clientSurface = negotiationApiSurface(A2ATClient.class); Map serverSurface = negotiationApiSurface(A2ATServer.class); diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java index 2959254f..ab561d15 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java @@ -23,9 +23,9 @@ /** * Locks the v3 negotiation API surface of both facades and the field faces of the result records. * - *

        The client and the server expose exactly the eleven camelCase negotiation methods with their pinned parameter - * counts, their surface carries none of the removed v2-shape method names, and the result records expose exactly - * their documented components. + *

        The client and the server expose exactly the twelve camelCase negotiation methods with their pinned parameter + * counts, their surface carries none of the removed v2-shape method names nor the removed negotiation query methods, and + * the result records expose exactly their documented components. */ class NegotiationV3ApiSurfaceTest { @@ -38,22 +38,43 @@ class NegotiationV3ApiSurfaceTest { Map.entry("generateNegotiationAcceptPromptFromText", 3), Map.entry("generateNegotiationRejectPromptFromText", 3), Map.entry("generateNegotiationAbortPromptFromText", 3), - Map.entry("getNegotiationPrompts", 0), - Map.entry("getNegotiationPrompt", 1), Map.entry("validateProposePromptAndDataFilling", 4), Map.entry("validateAcceptPromptAndDataFilling", 4), Map.entry("validateRejectPromptAndDataFilling", 4), Map.entry("validateAbortPromptAndDataFilling", 4)); + private static final List REMOVED_NEGOTIATION_QUERY_METHODS = + List.of("getNegotiationPrompts", "getNegotiationPrompt"); + + private static final List CROSS_EXTENSION_QUERY_METHODS = List.of("getPrompts", "getPrompt"); + private static final List REMOVED_V2_METHOD_NAME_FRAGMENTS = List.of("FromNl", "FromJsonData", "validateAndExtractParams"); @Test - void bothFacadesExposeExactlyTheFourteenV3NegotiationMethods() { + void bothFacadesExposeExactlyTheTwelveV3NegotiationMethods() { assertExactNegotiationSurface(A2ATClient.class); assertExactNegotiationSurface(A2ATServer.class); } + @Test + void removedNegotiationQueryMethodsAreAbsentWhileCrossExtensionQueriesRemain() { + for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { + for (String removedMethod : REMOVED_NEGOTIATION_QUERY_METHODS) { + assertFalse( + Arrays.stream(facade.getMethods()) + .anyMatch(method -> method.getName().equals(removedMethod)), + facade.getSimpleName() + " must not expose the removed " + removedMethod); + } + for (String queryMethod : CROSS_EXTENSION_QUERY_METHODS) { + assertTrue( + Arrays.stream(facade.getMethods()) + .anyMatch(method -> method.getName().equals(queryMethod)), + facade.getSimpleName() + " must expose the cross-extension query " + queryMethod); + } + } + } + @Test void neitherFacadeCarriesRemovedV2ShapeMethodNames() { assertNoV2ShapeNames(A2ATClient.class); @@ -107,7 +128,7 @@ private static void assertExactNegotiationSurface(Class facade) { assertEquals( EXPECTED_NEGOTIATION_METHOD_PARAMETERS.keySet(), names, - facade.getSimpleName() + " must expose exactly the fourteen v3 negotiation methods"); + facade.getSimpleName() + " must expose exactly the twelve v3 negotiation methods"); assertEquals( EXPECTED_NEGOTIATION_METHOD_PARAMETERS.size(), negotiationMethods.size(), diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index a619c91e..58af2fe3 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -55,7 +55,7 @@ public final class A2ATServer { public A2ATServer(Path envPath) { Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); A2ATConfig config = - NegotiationContentService.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder().envPath(resolvedEnvPath).config(config); this.promptComplianceOrchestrator = builder.buildPromptComplianceOrchestrator(); @@ -355,33 +355,6 @@ public Optional getPrompt(@NonNull TemplateUri templateUri) { return templateQueryService.getPrompt(templateUri); } - /** - * Lists every negotiation template available for the configured language. - * - *

        This query never throws: templates that exist nowhere for the language are skipped and an empty list is - * returned when no template can be loaded at all. - * - * @return loadable negotiation templates of the configured language, in a fixed type and phase order; empty when - * none can be loaded - */ - public List getNegotiationPrompts() { - return negotiationContentService.getNegotiationPrompts(); - } - - /** - * Loads one negotiation template addressed by its URI. - * - *

        This query never throws: a template that exists nowhere for the configured language returns an empty result - * and logs an actionable warning. - * - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} - * @return the addressed template, or an empty result when the template does not exist for the configured language - */ - public Optional getNegotiationPrompt(@NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); - return negotiationContentService.getNegotiationPrompt(templateUri); - } - /** * Validates a propose-phase negotiation message and extracts its parameters. * diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index a80261ed..4153c660 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -179,7 +179,7 @@ public TemplateQueryService buildTemplateQueryService() { require(config, "Unified SDK config must be configured."); requireSupportedConfig(); return new TemplateQueryService( - config.prompt().language(), config.prompt().localRootDir()); + config.prompt().language(), config.prompt().sourceType(), config.prompt().localRootDir()); } /** diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index 5d743f55..2c797f2b 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -69,10 +69,8 @@ void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOE @Test void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { - assertEquals( - 7, new A2ATServer(writeEnv("zh-CN")).getNegotiationPrompts().size()); - assertEquals( - 7, new A2ATServer(writeEnv("en-US")).getNegotiationPrompts().size()); + assertEquals(7, negotiationPrompts(new A2ATServer(writeEnv("zh-CN"))).size()); + assertEquals(7, negotiationPrompts(new A2ATServer(writeEnv("en-US"))).size()); } @Test @@ -96,7 +94,7 @@ void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { A2ATServer server = new A2ATServer(writeEnv("zh-CN")); PromptTemplate template = - server.getNegotiationPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); + server.getPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); assertFalse(template.content().isBlank()); } @@ -105,19 +103,26 @@ void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - assertTrue(server.getNegotiationPrompt(INFORMATION_PROPOSE).isPresent()); + assertTrue(server.getPrompt(INFORMATION_PROPOSE).isPresent()); assertTrue(server - .getNegotiationPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) .isPresent()); assertFalse(server - .getNegotiationPrompt(TemplateUri.of(StandardTemplates.NEGOTIATION_EXTENSION_NAME, "unknown-negotiation", + .getPrompt(TemplateUri.of(StandardTemplates.NEGOTIATION_EXTENSION_NAME, "unknown-negotiation", "propose")) .isPresent()); - PromptTemplate template = server.getNegotiationPrompt(INFORMATION_PROPOSE).orElseThrow(); + PromptTemplate template = server.getPrompt(INFORMATION_PROPOSE).orElseThrow(); assertEquals(INFORMATION_PROPOSE, template.templateUri()); assertFalse(template.content().isBlank()); } + private static List negotiationPrompts(A2ATServer server) { + return server.getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + private static Path writeEnv(String language) throws IOException { Path tempDir = Files.createTempDirectory("a2at-server-negotiation-env"); Path envFile = tempDir.resolve("server.env"); diff --git a/env.example b/env.example index 52672b58..1caa7d9f 100644 --- a/env.example +++ b/env.example @@ -1,9 +1,12 @@ # Prompt runtime # Controls the language used by prompt runtime components. A2AT_LANGUAGE=en-US -# Selects the prompt source backend. +# Selects the prompt source backend (classpath | local_file). A2AT_PROMPT_SOURCE_TYPE=classpath -# Optional when A2AT_PROMPT_SOURCE_TYPE=local_file. +# Required when A2AT_PROMPT_SOURCE_TYPE=local_file (assembly-time fail-fast if +# unset or the path does not exist). Only business content (templates/slots/ +# scenarios of Task-T/Notification-T/Authorization-T) is read from this root; +# negotiation resources and LLM prompts are always loaded from the classpath. A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= # Prompt compliance From 66656c473d22392ad568216cbe424a53043d927b Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 29 Aug 2026 12:10:21 +0800 Subject: [PATCH 232/295] fix(facade): drop null-uri guards for typed failure and unify query miss warning Signed-off-by: w00609601 --- .../openan/a2at/sdk/client/A2ATClient.java | 13 --- .../catalog/PromptTemplateCatalog.java | 1 - .../catalog/TemplateQueryServiceTest.java | 8 +- .../openan/a2at/sdk/server/A2ATServer.java | 30 ++---- .../A2ATServerContentValidationApiTest.java | 96 +++++++++++++++++++ 5 files changed, 107 insertions(+), 41 deletions(-) create mode 100644 a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index d2c9b2a6..18ac6793 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -254,7 +254,6 @@ public Map continueNegotiation( */ public MetadataContent generateNegotiationProposePromptFromData( @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateProposeFromData(data, templateUri); } @@ -277,7 +276,6 @@ public MetadataContent generateNegotiationProposePromptFromData( */ public MetadataContent generateNegotiationAcceptPromptFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateAcceptFromData(data, templateUri); } @@ -300,7 +298,6 @@ public MetadataContent generateNegotiationAcceptPromptFromData( */ public MetadataContent generateNegotiationRejectPromptFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateRejectFromData(data, templateUri); } @@ -322,7 +319,6 @@ public MetadataContent generateNegotiationRejectPromptFromData( */ public MetadataContent generateNegotiationAbortPromptFromData( @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateAbortFromData(data, templateUri); } @@ -354,7 +350,6 @@ public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateProposeFromText(text, context, templateUri); } @@ -385,7 +380,6 @@ public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateAcceptFromText(text, context, templateUri); } @@ -416,7 +410,6 @@ public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateRejectFromText(text, context, templateUri); } @@ -446,7 +439,6 @@ public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.generateAbortFromText(text, context, templateUri); } @@ -475,7 +467,6 @@ public List getPrompts() { * @return the addressed template, or an empty optional when no template exists for it in the configured language */ public Optional getPrompt(@NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); return templateQueryService.getPrompt(templateUri); } @@ -509,7 +500,6 @@ public FilledParamData validateProposePromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); } @@ -541,7 +531,6 @@ public FilledParamData validateAcceptPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); } @@ -573,7 +562,6 @@ public FilledParamData validateRejectPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); } @@ -605,7 +593,6 @@ public FilledParamData validateAbortPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java index 5106de7e..941b95f2 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java @@ -194,7 +194,6 @@ public Optional load(@NonNull TemplateUri templateUri) { String uri = templateUri.uri(); boolean negotiation = NEGOTIATION_EXTENSION.equals(templateUri.extensionName()); if (negotiation && !isClosedSetNegotiationUri(uri)) { - LOGGER.atWarn().log("negotiation_template_outside_closed_set uri={}", uri); return Optional.empty(); } String classpathPath = CLASSPATH_ROOT + String.join("/", TEMPLATE_DIRECTORY, uri, language, TEMPLATE_FILE_NAME); diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java index 1763a53f..fd8fb6b0 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java @@ -152,13 +152,15 @@ void localFileSourceTypeFreezesCatalogSnapshotIgnoringRuntimeFileChanges() throw } @Test - void negotiationTemplateOutsideClosedSetLogsWarning() { + void closedSetMissLogsSinglePromptTemplateNotFoundWarning() { new TemplateQueryService(LANGUAGE, "classpath", null) .getPrompt(TemplateUri.of("Negotiation-T", "feasibility-negotiation", "revise")); + List warnings = warningEvents(); + assertEquals(1, warnings.size(), "expected exactly one WARN for querying a closed-set-outside negotiation URI"); assertTrue( - warningEvents().stream().anyMatch(message -> message.startsWith("negotiation_template_outside_closed_set")), - "expected a WARN for filtering a negotiation template outside the closed set"); + warnings.get(0).startsWith("prompt_template_not_found"), + "expected a prompt_template_not_found WARN, not a negotiation_template_outside_closed_set WARN"); } @Test diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 58af2fe3..cdb69381 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -3,7 +3,6 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.model.FilledParamData; @@ -135,7 +134,6 @@ public Map continueNegotiation( */ public MetadataContent generateNegotiationProposePromptFromData( @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return negotiationContentService.generateProposeFromData(data, templateUri); } @@ -158,7 +156,6 @@ public MetadataContent generateNegotiationProposePromptFromData( */ public MetadataContent generateNegotiationAcceptPromptFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return negotiationContentService.generateAcceptFromData(data, templateUri); } @@ -181,7 +178,6 @@ public MetadataContent generateNegotiationAcceptPromptFromData( */ public MetadataContent generateNegotiationRejectPromptFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return negotiationContentService.generateRejectFromData(data, templateUri); } @@ -203,7 +199,6 @@ public MetadataContent generateNegotiationRejectPromptFromData( */ public MetadataContent generateNegotiationAbortPromptFromData( @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return negotiationContentService.generateAbortFromData(data, templateUri); } @@ -234,7 +229,6 @@ public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return negotiationContentService.generateProposeFromText(text, context, templateUri); } @@ -264,7 +258,6 @@ public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return negotiationContentService.generateAcceptFromText(text, context, templateUri); } @@ -294,7 +287,6 @@ public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return negotiationContentService.generateRejectFromText(text, context, templateUri); } @@ -322,7 +314,6 @@ public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return negotiationContentService.generateAbortFromText(text, context, templateUri); } @@ -351,7 +342,6 @@ public List getPrompts() { * @return the addressed template, or an empty result when the template does not exist for the configured language */ public Optional getPrompt(@NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return templateQueryService.getPrompt(templateUri); } @@ -385,7 +375,6 @@ public FilledParamData validateProposePromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return negotiationContentService.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); } @@ -419,7 +408,6 @@ public FilledParamData validateAcceptPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); } @@ -453,7 +441,6 @@ public FilledParamData validateRejectPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); } @@ -486,7 +473,6 @@ public FilledParamData validateAbortPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); } @@ -500,9 +486,10 @@ public FilledParamData validateAbortPromptAndDataFilling( * @param templateUri template URI declaring the expected task template; its prefix segment must be * {@code Task-T} * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt, schema or template URI is null + * @throws NullPointerException if the prompt or schema is null * @throws IllegalArgumentException if the prompt is blank * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code validation_invalid_input} when the template URI is null, * {@code validation_semantic_rejected} when the semantic validation rejects the content, * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing, or @@ -511,7 +498,6 @@ public FilledParamData validateAbortPromptAndDataFilling( */ public FilledParamData validateTaskPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return taskContentValidator.validate(prompt, schema, templateUri); } @@ -525,9 +511,10 @@ public FilledParamData validateTaskPromptAndDataFilling( * @param templateUri template URI declaring the expected notification template; its prefix segment must be * {@code Notification-T} * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt, schema or template URI is null + * @throws NullPointerException if the prompt or schema is null * @throws IllegalArgumentException if the prompt is blank * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code validation_invalid_input} when the template URI is null, * {@code validation_semantic_rejected} when the semantic validation rejects the content, * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing, or @@ -536,7 +523,6 @@ public FilledParamData validateTaskPromptAndDataFilling( */ public FilledParamData validateNotificationPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return notificationContentValidator.validate(prompt, schema, templateUri); } @@ -550,9 +536,10 @@ public FilledParamData validateNotificationPromptAndDataFilling( * @param templateUri template URI declaring the expected authorization template; its prefix segment must be * {@code Authorization-T} * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt, schema or template URI is null + * @throws NullPointerException if the prompt or schema is null * @throws IllegalArgumentException if the prompt is blank * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code validation_invalid_input} when the template URI is null, * {@code validation_semantic_rejected} when the semantic validation rejects the content, * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing, or @@ -561,11 +548,6 @@ public FilledParamData validateNotificationPromptAndDataFilling( */ public FilledParamData validateAuthPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - requireTemplateUri(templateUri); return authContentValidator.validate(prompt, schema, templateUri); } - - private static void requireTemplateUri(TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "Template URI must not be null."); - } } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java new file mode 100644 index 00000000..a77b6274 --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java @@ -0,0 +1,96 @@ +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.Test; + +class A2ATServerContentValidationApiTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + @Test + void validateTaskPromptRejectsNullTemplateUriWithValidationInvalidInput() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + ContentValidationException error = assertThrows( + ContentValidationException.class, + () -> server.validateTaskPromptAndDataFilling("test prompt", Map.of(), null)); + + assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, error.getCode()); + } + + @Test + void validateNotificationPromptRejectsNullTemplateUriWithValidationInvalidInput() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + ContentValidationException error = assertThrows( + ContentValidationException.class, + () -> server.validateNotificationPromptAndDataFilling("test prompt", Map.of(), null)); + + assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, error.getCode()); + } + + @Test + void validateAuthPromptRejectsNullTemplateUriWithValidationInvalidInput() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + ContentValidationException error = assertThrows( + ContentValidationException.class, + () -> server.validateAuthPromptAndDataFilling("test prompt", Map.of(), null)); + + assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, error.getCode()); + } + + @Test + void generateNegotiationProposePromptFromDataStillThrowsOnNullTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows( + NullPointerException.class, + () -> server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + null)); + } + + @Test + void getPromptStillThrowsOnNullTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows(NullPointerException.class, () -> server.getPrompt(null)); + } + + private static Path writeEnv() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-server-content-validation-env"); + Path envFile = tempDir.resolve("server.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_PROMPT_COMPLIANCE_ENABLED=false + """); + return envFile; + } +} \ No newline at end of file From 384ac2591cbaea69ec3f97b72082d3ae561eef87 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 29 Aug 2026 12:10:26 +0800 Subject: [PATCH 233/295] docs(user-guide): mark local prompt root required under local_file with fail-fast Signed-off-by: w00609601 --- docs/en/user_guide.md | 2 +- "docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/user_guide.md b/docs/en/user_guide.md index f9299436..4384dfc5 100644 --- a/docs/en/user_guide.md +++ b/docs/en/user_guide.md @@ -153,7 +153,7 @@ The client will: | --- | --- | | `A2AT_LANGUAGE` | Prompt resource language, commonly `zh-CN` | | `A2AT_PROMPT_SOURCE_TYPE` | Prompt resource source, supports `classpath` and `local_file`; default is `classpath` | -| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | Local prompt resource root directory | +| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | Local prompt resource root directory; required in `local_file` mode (assembly-time fail-fast if unset or the path does not exist); only business content (templates/slots/scenarios of Task-T/Notification-T/Authorization-T) is read from this root, while negotiation resources and LLM prompts are always loaded from the classpath | | `A2AT_LLM_PROVIDER` | LLM provider, sample uses `openai` | | `A2AT_LLM_MODEL` | Model name | | `A2AT_LLM_BASE_URL` | LLM service address | diff --git "a/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" "b/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" index c9b3ebf5..23c7eb8e 100644 --- "a/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" +++ "b/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" @@ -151,7 +151,7 @@ java @a2a-t-sample/target/client.javaargs.txt | --- | --- | | `A2AT_LANGUAGE` | 提示词资源语言,常用 `zh-CN` | | `A2AT_PROMPT_SOURCE_TYPE` | 提示词资源来源,支持 `classpath` 和 `local_file`,默认 `classpath` | -| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | 本地提示词资源根目录 | +| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | 本地提示词资源根目录;`local_file` 模式下必填,未设置或路径不存在时装配期快速失败;仅业务内容(Task-T/Notification-T/Authorization-T 的 templates/slots/scenarios)从该根读取,协商资源与 LLM prompt 恒从 classpath 加载 | | `A2AT_LLM_PROVIDER` | LLM 提供方,sample 使用 `openai` | | `A2AT_LLM_MODEL` | 模型名称 | | `A2AT_LLM_BASE_URL` | LLM 服务地址 | From c8eafde1fdbda36aba3a2575dd4368e69d502f14 Mon Sep 17 00:00:00 2001 From: w00609601 Date: Sat, 29 Aug 2026 14:29:52 +0800 Subject: [PATCH 234/295] fix(prompt): fail fast on missing local root in catalog and refresh stale language hint Signed-off-by: w00609601 --- .../api/A2ATClientNegotiationEnvConfigTest.java | 2 +- .../sdk/core/validation/TemplateContentLoader.java | 2 +- .../a2at/sdk/core/model/PromptTemplateTest.java | 2 +- .../a2at/sdk/negotiation/content/Vocabulary.java | 2 +- .../resources/DefaultNegotiationTemplateLoader.java | 4 ++-- .../resources/catalog/PromptTemplateCatalog.java | 13 ++++++++++--- .../resources/catalog/TemplateQueryService.java | 3 +-- .../LocalFilePromptScenarioCatalogLoader.java | 2 +- .../loader/LocalFilePromptSlotSchemaLoader.java | 2 +- .../loader/LocalFilePromptTemplateLoader.java | 2 +- .../resources/loader/LocalFileResourceSnapshot.java | 4 ++-- .../resources/loader/PromptResourceAccess.java | 2 +- .../prompt/validation/DefaultContentValidator.java | 4 ++-- .../resources/catalog/TemplateQueryServiceTest.java | 11 +++++++++++ a2a-t-prompt/src/test/resources/logback-test.xml | 2 +- .../resources/ClasspathPromptResourceLoader.java | 2 +- .../api/A2ATServerContentValidationApiTest.java | 2 +- 17 files changed, 39 insertions(+), 22 deletions(-) diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index 39b53823..aedac85e 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -42,7 +42,7 @@ * Drives the env-configured negotiation behavior of the client facade end to end. * *

        The negotiation-relevant env keys must become observable in the facade behavior: the language selects the Chinese - * templates, and the LLM attempt limit bounds the retry loop of the from-text generation. Under ADR 0004 the negotiation + * templates, and the LLM attempt limit bounds the retry loop of the from-text generation. The negotiation * templates are classpath-fixed, so a configured local resource root is ignored in {@code classpath} mode: the built-in * template wins over any local override. A second test proves the zero-configuration defaults (English templates, three * attempts, built-in resources) work out of the box. diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java index 30c025a4..370b6ac1 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java @@ -23,4 +23,4 @@ public interface TemplateContentLoader { * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the template cannot be resolved */ @NonNull String load(@NonNull T reference); -} \ No newline at end of file +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java index db050d59..9628b7f1 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java @@ -50,4 +50,4 @@ void should_exposeLocalSource_When_templateConstructedWithLocalSource() { assertNull(template.content()); assertEquals("local", template.source()); } -} \ No newline at end of file +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java index 0c20a804..f65757e4 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java @@ -28,7 +28,7 @@ * template bytes of that language verbatim. * *

        The values are file-driven: each language resolves {@code negotiation-vocabulary/{language}/vocabulary.json} from - * the built-in classpath resources — negotiation vocabularies are classpath-fixed and never configurable (ADR 0004). + * the built-in classpath resources — negotiation vocabularies are classpath-fixed and never configurable. * The bundled bytes are validated against the pinned {@link #CANONICAL_KEYS} set, and a vocabulary that does not exist, * cannot be read, is malformed (including duplicate JSON keys or blank values) or drifts from the canonical key set * fails fast instead of silently degrading. diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java index 9bca8830..e329a8b2 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -19,9 +19,9 @@ * Default negotiation template loader, classpath-fixed. * *

        Templates are resolved from the built-in classpath resources bundled with the SDK — negotiation templates are - * classpath-fixed and never configurable (ADR 0004). Local template copies are not consulted. + * classpath-fixed and never configurable. Local template copies are not consulted. * - *

        Classpath resources are frozen at assembly time (ADR 0004): each template is resolved once and cached at the JVM + *

        Classpath resources are frozen at assembly time: each template is resolved once and cached at the JVM * level, so every subsequent load returns the cached snapshot. Missing templates throw on every call and are never * cached. The loader is thread-safe. * diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java index 941b95f2..c4fb6637 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java @@ -39,7 +39,7 @@ * URI formed from the segments before the language, for example * {@code Negotiation-T/information-negotiation/propose/v1} or {@code Task-T/network-layer/ran-energy-saving/v1}. * - *

        The catalog builds the effective set at assembly time according to the configured {@code sourceType} (ADR 0004): + *

        The catalog builds the effective set at assembly time according to the configured {@code sourceType}: * the {@code classpath} mode enumerates the full built-in template tree; the {@code local_file} mode enumerates only * the local business content (Task-T, Notification-T and Authorization-T) plus the classpath-fixed Negotiation-T * templates, without unioning the built-in business content. Every template record carries its effective origin @@ -84,7 +84,7 @@ final class PromptTemplateCatalog { * Creates a catalog for one language, two source types and an optional local template root. * *

        The constructor captures the classpath template tree and, in {@code local_file} mode, the local - * {@code templates/} tree once as immutable snapshots (ADR 0004): the catalog never re-reads the classpath or the + * {@code templates/} tree once as immutable snapshots: the catalog never re-reads the classpath or the * local filesystem after construction, so a template that is added or removed after assembly is not visible to * {@link #loadAll()} or {@link #load(TemplateUri)}. * @@ -93,7 +93,7 @@ final class PromptTemplateCatalog { * @param localRootDir local prompt resource root containing the {@code templates/} tree; required in * {@code local_file} mode and ignored otherwise * @throws IllegalArgumentException if the language is not a simple path segment, the source type is unsupported, or - * {@code local_file} mode is selected without a local root directory + * {@code local_file} mode is selected without a local root directory or with one that does not exist */ PromptTemplateCatalog(@NonNull String language, @NonNull String sourceType, @Nullable String localRootDir) { if (!PathSegments.isSimpleSegment(language)) { @@ -118,6 +118,13 @@ final class PromptTemplateCatalog { + " to the local prompt resource root."); } this.localRootDir = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); + if (this.localRootDir != null && !Files.isDirectory(this.localRootDir)) { + throw new IllegalArgumentException( + "Prompt resource local root directory configured via " + + A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR + + " does not exist or is not a directory: " + + this.localRootDir); + } this.classpathSnapshot = captureClasspathTemplates(); this.localSnapshot = PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(this.sourceType) && this.localRootDir != null ? Map.copyOf(localTemplates()) diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java index 8af10870..0e955f61 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java @@ -25,8 +25,7 @@ public final class TemplateQueryService { private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(TemplateQueryService.class); private static final String LANGUAGE_HINT = - "set A2AT_LANGUAGE to a language with bundled templates (zh-CN or en-US) or provide the template under" - + " the local resource root"; + "set A2AT_LANGUAGE to a language with bundled templates (zh-CN or en-US)"; private final PromptTemplateCatalog templateCatalog; diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java index da8c1ce4..6488ccff 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java @@ -13,7 +13,7 @@ /** * Loads shared scenario catalogs from one local prompt resource root. * - *

        Resources are resolved against an assembly-time snapshot of the local root (ADR 0004): runtime reads never touch + *

        Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch * the filesystem, so changes to the local files only take effect after the SDK is restarted. * * @since 2026-06 diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java index 58a3d0cf..13266976 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java @@ -13,7 +13,7 @@ /** * Loads shared slot schemas from one local prompt resource root. * - *

        Resources are resolved against an assembly-time snapshot of the local root (ADR 0004): runtime reads never touch + *

        Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch * the filesystem, so changes to the local files only take effect after the SDK is restarted. * * @since 2026-06 diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java index e4e66b84..340a49e3 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java @@ -9,7 +9,7 @@ /** * Loads shared prompt templates from one local prompt resource root. * - *

        Resources are resolved against an assembly-time snapshot of the local root (ADR 0004): runtime reads never touch + *

        Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch * the filesystem, so changes to the local files only take effect after the SDK is restarted. * * @since 2026-06 diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java index ea57b569..cb76ca7a 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java @@ -14,8 +14,8 @@ /** * Immutable in-memory snapshot of a local prompt resource root, captured once at assembly time. * - *

        The snapshot freezes the {@code templates/}, {@code slots/} and {@code scenarios/} subtrees (ADR 0004: local - * resources are read at assembly time and never re-read). Runtime lookups resolve against this map rather than the + *

        The snapshot freezes the {@code templates/}, {@code slots/} and {@code scenarios/} subtrees read from the local + * root once at assembly time and never re-read. Runtime lookups resolve against this map rather than the * filesystem, so local file changes only take effect after the SDK is restarted. */ final class LocalFileResourceSnapshot { diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java index 99913326..f0c3e0ff 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java @@ -16,7 +16,7 @@ /** * Shared access point for prompt resources backed either by local files or classpath resources. * - *

        The factory routes resources by the three-way ADR 0004 classification: the business content (tasks' templates, + *

        The factory routes resources by origin: the business content (tasks' templates, * slot schemas and scenario catalog) follows the configured source type; the LLM instruction prompts, negotiation * templates and negotiation vocabulary are always loaded from the classpath. * diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java index 55053c65..19c3ce72 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -22,8 +22,8 @@ * {@code TEMPLATE_NOT_FOUND} error, distinct from the {@code VALIDATION_PROMPT_RESOURCE_NOT_FOUND} code that signals * missing content_validation prompt resources. The loaded template text flows into the pipeline for prompt injection. * - *

        The validation pipeline and the content_validation prompt resources are assembled eagerly in the constructor (ADR - * 0004 snapshot semantics): the constructor resolves and freezes the prompt resources once, and a missing resource + *

        The validation pipeline and the content_validation prompt resources are assembled eagerly in the constructor: the + * constructor resolves and freezes the prompt resources once, and a missing resource * fails fast at assembly time instead of on the first {@link #validate} call. The content_validation prompt resources * are internal LLM instructions loaded from the classpath regardless of the configured prompt source type. * diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java index fd8fb6b0..d0c77360 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java @@ -188,6 +188,17 @@ void localFileSourceTypeWithBlankRootFailsFast() { assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); } + @Test + void localFileSourceTypeWithNonexistentRootFailsFast() { + Path missing = localRootDir.resolve("missing"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new TemplateQueryService(LANGUAGE, "local_file", missing.toString())); + + assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); + } + @Test void singleQueryMissLogsPromptTemplateNotFoundWarning() { new TemplateQueryService(LANGUAGE, "classpath", null) diff --git a/a2a-t-prompt/src/test/resources/logback-test.xml b/a2a-t-prompt/src/test/resources/logback-test.xml index cefee40d..a9800f0f 100644 --- a/a2a-t-prompt/src/test/resources/logback-test.xml +++ b/a2a-t-prompt/src/test/resources/logback-test.xml @@ -8,4 +8,4 @@ - \ No newline at end of file + diff --git a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java index a24a2bee..1357511a 100644 --- a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java +++ b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java @@ -12,7 +12,7 @@ /** * Loads prompt resources from the classpath bundle packaged with the SDK. * - *

        Classpath resources are frozen at assembly time (ADR 0004): each resource path is resolved once and cached at the + *

        Classpath resources are frozen at assembly time: each resource path is resolved once and cached at the * JVM level, so every subsequent load returns the cached copy. Missing resources throw on every call and are never * cached. The loader is thread-safe. * diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java index a77b6274..970ca124 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java @@ -93,4 +93,4 @@ private static Path writeEnv() throws IOException { """); return envFile; } -} \ No newline at end of file +} From 574ddd7353edafa8718b3d002c528023708ce789 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 29 Aug 2026 13:19:16 +0800 Subject: [PATCH 235/295] feat(core): add error catalog, message templates and business exception base Introduce ErrorCatalog (42 dotted domain.semantic codes with BUSINESS/INFRA category and fact-parameter declarations), ErrorMessages renderer over errors/{lang}/errors.json (en-US fallback), A2ATBusinessException base and SlotValidationError facts field. A2ATErrorCodes becomes a deprecated forwarding facade. template_lint gains a catalog-consistency gate. Signed-off-by: codetalks-hw --- .../core/exception/A2ATBusinessException.java | 109 ++++++++ .../a2at/sdk/core/exception/A2ATError.java | 148 +++++------ .../sdk/core/exception/A2ATErrorCodes.java | 68 +++-- .../exception/A2ATParamExtractionError.java | 129 +++++----- .../ConfigFileNotFoundException.java | 48 ++-- .../a2at/sdk/core/exception/ErrorCatalog.java | 242 ++++++++++++++++++ .../sdk/core/exception/ErrorMessages.java | 145 +++++++++++ .../exception/PromptGenerationException.java | 132 +++++----- .../sdk/core/model/SlotValidationError.java | 25 +- .../ContentValidationException.java | 207 +++++++-------- .../a2at/sdk/core/validation/RetryUtil.java | 135 +++++----- .../core/validation/ValidationPipeline.java | 100 ++++++-- .../core/exception/A2ATErrorCodesTest.java | 63 ++--- .../sdk/core/exception/A2ATErrorTest.java | 139 +++++----- .../prompt_resources/errors/en-US/errors.json | 44 ++++ .../prompt_resources/errors/zh-CN/errors.json | 44 ++++ tools/template_lint.py | 142 +++++++++- 17 files changed, 1381 insertions(+), 539 deletions(-) create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATBusinessException.java create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorCatalog.java create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorMessages.java create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/errors/en-US/errors.json create mode 100644 a2a-t-resources/src/main/resources/prompt_resources/errors/zh-CN/errors.json diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATBusinessException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATBusinessException.java new file mode 100644 index 00000000..fb3c409f --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATBusinessException.java @@ -0,0 +1,109 @@ +package net.openan.a2at.sdk.core.exception; + +import java.util.Map; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Base type for expected business failures raised by the A2A-T SDK. + * + *

        Business failures carry a machine-readable code from {@link ErrorCatalog} and a message rendered from the code's + * message template (see {@link ErrorMessages}); the structured fact values that produced the message travel in + * {@link #getFacts()}. Programming errors (null or malformed arguments) stay {@link NullPointerException} and + * {@link IllegalArgumentException}; infrastructure failures stay plain {@link A2ATError}. + * + * @since 2026-08 + */ +public class A2ATBusinessException extends A2ATError { + + private final Map facts; + + /** + * Creates a business failure for one catalog entry with a rendered message. + * + * @param entry catalog entry identifying the failure + * @param message rendered failure message + * @throws NullPointerException if {@code entry} or {@code message} is null + */ + public A2ATBusinessException(@NonNull ErrorCatalog entry, @NonNull String message) { + this(entry.getCode(), message, Map.of()); + } + + /** + * Creates a business failure for one catalog entry with a rendered message and its fact values. + * + * @param entry catalog entry identifying the failure + * @param message rendered failure message + * @param facts fact values keyed by fact parameter name, may be null + * @throws NullPointerException if {@code entry} or {@code message} is null + */ + public A2ATBusinessException( + @NonNull ErrorCatalog entry, @NonNull String message, @Nullable Map facts) { + this(entry.getCode(), message, facts); + } + + /** + * Creates a business failure for one error code with a rendered message. + * + * @param code machine-readable error code from {@link ErrorCatalog} + * @param message rendered failure message + * @throws NullPointerException if {@code code} or {@code message} is null + */ + public A2ATBusinessException(@NonNull String code, @NonNull String message) { + this(code, message, null); + } + + /** + * Creates a business failure for one error code with a rendered message and its fact values. + * + * @param code machine-readable error code from {@link ErrorCatalog} + * @param message rendered failure message + * @param facts fact values keyed by fact parameter name, may be null + * @throws NullPointerException if {@code code} or {@code message} is null + */ + public A2ATBusinessException(@NonNull String code, @NonNull String message, @Nullable Map facts) { + super(code, message); + this.facts = facts == null ? Map.of() : Map.copyOf(facts); + } + + /** + * Creates a business failure for one error code with a rendered message, its fact values and a root cause. + * + * @param code machine-readable error code from {@link ErrorCatalog} + * @param message rendered failure message + * @param facts fact values keyed by fact parameter name, may be null + * @param cause root cause, may be null + * @throws NullPointerException if {@code code} or {@code message} is null + */ + public A2ATBusinessException( + @NonNull String code, + @NonNull String message, + @Nullable Map facts, + @Nullable Throwable cause) { + super(code, message, cause); + this.facts = facts == null ? Map.of() : Map.copyOf(facts); + } + + /** + * Returns the fact values that produced the rendered message. + * + * @return facts keyed by fact parameter name, possibly empty, never null + */ + public @NonNull Map getFacts() { + return facts; + } + + /** + * Creates a business failure whose message is rendered from the code's template in one language. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @return business failure with the rendered message + * @throws NullPointerException if {@code entry} is null + */ + public static A2ATBusinessException of( + @NonNull ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { + return new A2ATBusinessException(entry, ErrorMessages.render(entry, language, facts), facts); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java index 6fd69b93..85e71658 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java @@ -1,74 +1,74 @@ -package net.openan.a2at.sdk.core.exception; - -import java.util.Objects; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Single root exception type for all A2A-T SDK processing failures. - * - *

        Every runtime, environment or data-processing failure raised by the SDK is part of this tree, so callers can catch - * this one root type and branch on the machine-readable error code returned by {@link #getCode()}, which is never - * {@code null}. Caller contract violations (null, blank or otherwise malformed arguments) are programming errors raised - * as {@link NullPointerException} or {@link IllegalArgumentException} and intentionally stay outside this tree. - * - * @since 2026-08 - */ -public class A2ATError extends RuntimeException { - - /** Machine-readable error code for this failure, never null. */ - private final String code; - - /** - * Creates an A2A-T processing failure with one message and the default error code. - * - * @param message failure message - */ - public A2ATError(String message) { - this(A2ATErrorCodes.SDK_INTERNAL_ERROR, message); - } - - /** - * Creates an A2A-T processing failure with one message, one root cause and the default error code. - * - * @param message failure message - * @param cause root cause - */ - public A2ATError(String message, @Nullable Throwable cause) { - this(A2ATErrorCodes.SDK_INTERNAL_ERROR, message, cause); - } - - /** - * Creates an A2A-T processing failure with one specific error code and one message. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @throws NullPointerException if {@code code} is null - */ - public A2ATError(@NonNull String code, String message) { - super(message); - this.code = Objects.requireNonNull(code, "code must not be null"); - } - - /** - * Creates an A2A-T processing failure with one specific error code, one message and one root cause. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param cause root cause - * @throws NullPointerException if {@code code} is null - */ - public A2ATError(@NonNull String code, String message, @Nullable Throwable cause) { - super(message, cause); - this.code = Objects.requireNonNull(code, "code must not be null"); - } - - /** - * Returns the machine-readable error code for this failure. - * - * @return error code, never null - */ - public @NonNull String getCode() { - return code; - } -} +package net.openan.a2at.sdk.core.exception; + +import java.util.Objects; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Single root exception type for all A2A-T SDK processing failures. + * + *

        Every runtime, environment or data-processing failure raised by the SDK is part of this tree, so callers can catch + * this one root type and branch on the machine-readable error code returned by {@link #getCode()}, which is never + * {@code null}. Caller contract violations (null, blank or otherwise malformed arguments) are programming errors raised + * as {@link NullPointerException} or {@link IllegalArgumentException} and intentionally stay outside this tree. + * + * @since 2026-08 + */ +public class A2ATError extends RuntimeException { + + /** Machine-readable error code for this failure, never null. */ + private final String code; + + /** + * Creates an A2A-T processing failure with one message and the default error code. + * + * @param message failure message + */ + public A2ATError(String message) { + this(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), message); + } + + /** + * Creates an A2A-T processing failure with one message, one root cause and the default error code. + * + * @param message failure message + * @param cause root cause + */ + public A2ATError(String message, @Nullable Throwable cause) { + this(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), message, cause); + } + + /** + * Creates an A2A-T processing failure with one specific error code and one message. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @throws NullPointerException if {@code code} is null + */ + public A2ATError(@NonNull String code, String message) { + super(message); + this.code = Objects.requireNonNull(code, "code must not be null"); + } + + /** + * Creates an A2A-T processing failure with one specific error code, one message and one root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param cause root cause + * @throws NullPointerException if {@code code} is null + */ + public A2ATError(@NonNull String code, String message, @Nullable Throwable cause) { + super(message, cause); + this.code = Objects.requireNonNull(code, "code must not be null"); + } + + /** + * Returns the machine-readable error code for this failure. + * + * @return error code, never null + */ + public @NonNull String getCode() { + return code; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java index 2e2c9a2f..2ca91459 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java @@ -6,71 +6,97 @@ /** * Centralized machine-readable error code constants shared across A2A-T SDK processing failures. * + * @deprecated superseded by {@link ErrorCatalog}; each constant forwards to the layered {@code domain.semantic} code of + * the catalog entry that replaced it. Kept only while the corpus and sample modules reference the old constant + * names; new code must use {@link ErrorCatalog} directly. * @since 2026-08 */ +@Deprecated(forRemoval = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class A2ATErrorCodes { /** Default code for SDK processing failures without a more specific code. */ - public static final String SDK_INTERNAL_ERROR = "sdk_internal_error"; + @Deprecated(forRemoval = true) + public static final String SDK_INTERNAL_ERROR = ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(); /** A free-text input exceeded the configured maximum length. */ - public static final String INPUT_TEXT_TOO_LONG = "input_text_too_long"; + @Deprecated(forRemoval = true) + public static final String INPUT_TEXT_TOO_LONG = ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(); /** Default code for parameter-extraction failures without a more specific code. */ - public static final String PARAM_EXTRACTION_FAILED = "param_extraction_failed"; + @Deprecated(forRemoval = true) + public static final String PARAM_EXTRACTION_FAILED = ErrorCatalog.SLOT_NOT_PROVIDED.getCode(); /** A referenced template could not be resolved. */ - public static final String TEMPLATE_NOT_FOUND = "template_not_found"; + @Deprecated(forRemoval = true) + public static final String TEMPLATE_NOT_FOUND = ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); /** Structured content could not be extracted from a free-text input. */ - public static final String NEGOTIATION_CONTENT_EXTRACT_FAILED = "negotiation_content_extract_failed"; + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_CONTENT_EXTRACT_FAILED = + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(); /** Semantic validation rejected the input. */ - public static final String NEGOTIATION_SEMANTIC_REJECTED = "negotiation_semantic_rejected"; + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_SEMANTIC_REJECTED = ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(); /** One or more structural rules of the expected template were violated. */ - public static final String NEGOTIATION_RULE_VIOLATION = "negotiation_rule_violation"; + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_RULE_VIOLATION = ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(); /** A required slot is missing from the input. */ - public static final String NEGOTIATION_SLOT_MISSING = "negotiation_slot_missing"; + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_SLOT_MISSING = ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(); /** The input is not valid for the requested operation. */ - public static final String NEGOTIATION_INVALID_INPUT = "negotiation_invalid_input"; + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_INVALID_INPUT = ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(); /** An LLM infrastructure failure prevented the step from completing. */ - public static final String NEGOTIATION_LLM_INFRASTRUCTURE_ERROR = "negotiation_llm_infrastructure_error"; + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_LLM_INFRASTRUCTURE_ERROR = ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); /** The input is not valid for the requested validation operation. */ - public static final String VALIDATION_INVALID_INPUT = "validation_invalid_input"; + @Deprecated(forRemoval = true) + public static final String VALIDATION_INVALID_INPUT = ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(); /** One or more structural rules of the expected content were violated. */ - public static final String VALIDATION_RULE_VIOLATION = "validation_rule_violation"; + @Deprecated(forRemoval = true) + public static final String VALIDATION_RULE_VIOLATION = ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(); /** Semantic validation rejected the content. */ - public static final String VALIDATION_SEMANTIC_REJECTED = "validation_semantic_rejected"; + @Deprecated(forRemoval = true) + public static final String VALIDATION_SEMANTIC_REJECTED = ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(); /** An LLM infrastructure failure prevented the validation step from completing. */ - public static final String VALIDATION_LLM_INFRASTRUCTURE_ERROR = "validation_llm_infrastructure_error"; + @Deprecated(forRemoval = true) + public static final String VALIDATION_LLM_INFRASTRUCTURE_ERROR = ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); /** A referenced prompt resource could not be resolved. */ - public static final String VALIDATION_PROMPT_RESOURCE_NOT_FOUND = "validation_prompt_resource_not_found"; + @Deprecated(forRemoval = true) + public static final String VALIDATION_PROMPT_RESOURCE_NOT_FOUND = ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); /** A prompt resource could not be loaded from its configured source. */ - public static final String PROMPT_RESOURCE_LOAD_ERROR = "prompt_resource_load_error"; + @Deprecated(forRemoval = true) + public static final String PROMPT_RESOURCE_LOAD_ERROR = ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode(); /** The slot schema describing the expected slots could not be resolved. */ - public static final String SLOT_SCHEMA_NOT_FOUND = "slot_schema_not_found"; + @Deprecated(forRemoval = true) + public static final String SLOT_SCHEMA_NOT_FOUND = ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(); /** An LLM invocation failed at the infrastructure level. */ - public static final String LLM_INVOCATION_FAILED = "llm_invocation_failed"; + @Deprecated(forRemoval = true) + public static final String LLM_INVOCATION_FAILED = ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); /** Rendering a template with its slot values failed. */ - public static final String RENDER_FAILED = "render_failed"; + @Deprecated(forRemoval = true) + public static final String RENDER_FAILED = ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode(); /** The slot values extracted or provided for a template failed validation. */ - public static final String SLOT_VALIDATION_ERROR = "slot_validation_error"; + @Deprecated(forRemoval = true) + public static final String SLOT_VALIDATION_ERROR = ErrorCatalog.SLOT_RULE_VIOLATION.getCode(); /** A processed prompt could not be parsed into its structured parts. */ - public static final String PROCESSED_PROMPT_PARSE_ERROR = "processed_prompt_parse_error"; + @Deprecated(forRemoval = true) + public static final String PROCESSED_PROMPT_PARSE_ERROR = ErrorCatalog.SCENARIO_NOT_MATCHED.getCode(); } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java index d3e58f34..50c1b0a6 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java @@ -1,65 +1,64 @@ -package net.openan.a2at.sdk.core.exception; - -import java.util.List; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import org.jspecify.annotations.NonNull; - -/** - * Shared failure type raised when validating a prompt and extracting parameters from it fails. - * - *

        This base type is shared across prompt families so that a caller can handle extraction failures uniformly. The - * machine-readable error code is carried by the root {@link A2ATError} and is never null; subclasses pass a more - * specific code when one is available. - * - * @since 2026-08 - */ -public class A2ATParamExtractionError extends A2ATError { - - /** Structured per-slot validation error details. */ - private final List errors; - - /** - * Creates a parameter-extraction failure with the default error code and no slot details. - * - * @param message failure message - */ - public A2ATParamExtractionError(String message) { - this(A2ATErrorCodes.PARAM_EXTRACTION_FAILED, message, List.of()); - } - - /** - * Creates a parameter-extraction failure with one specific error code and slot details. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param errors structured per-slot validation error details - */ - public A2ATParamExtractionError( - @NonNull String code, String message, @NonNull List errors) { - super(code, message); - this.errors = List.copyOf(errors); - } - - /** - * Creates a parameter-extraction failure with one specific error code, slot details and a root cause. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param errors structured per-slot validation error details - * @param cause root cause of the failure - */ - public A2ATParamExtractionError( - @NonNull String code, String message, @NonNull List errors, Throwable cause) { - super(code, message, cause); - this.errors = List.copyOf(errors); - } - - /** - * Returns the structured per-slot validation error details. - * - * @return immutable list of slot validation errors, never null - */ - public @NonNull List getErrors() { - return errors; - } -} +package net.openan.a2at.sdk.core.exception; + +import java.util.List; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import org.jspecify.annotations.NonNull; + +/** + * Shared failure type raised when validating a prompt and extracting parameters from it fails. + * + *

        This base type is shared across prompt families so that a caller can handle extraction failures uniformly. The + * machine-readable error code is carried by the root {@link A2ATError} and is never null; subclasses pass a more + * specific code when one is available. + * + * @since 2026-08 + */ +public class A2ATParamExtractionError extends A2ATBusinessException { + + /** Structured per-slot validation error details. */ + private final List errors; + + /** + * Creates a parameter-extraction failure with the default error code and no slot details. + * + * @param message failure message + */ + public A2ATParamExtractionError(String message) { + this(ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), message, List.of()); + } + + /** + * Creates a parameter-extraction failure with one specific error code and slot details. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + */ + public A2ATParamExtractionError(@NonNull String code, String message, @NonNull List errors) { + super(code, message); + this.errors = List.copyOf(errors); + } + + /** + * Creates a parameter-extraction failure with one specific error code, slot details and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + * @param cause root cause of the failure + */ + public A2ATParamExtractionError( + @NonNull String code, String message, @NonNull List errors, Throwable cause) { + super(code, message, null, cause); + this.errors = List.copyOf(errors); + } + + /** + * Returns the structured per-slot validation error details. + * + * @return immutable list of slot validation errors, never null + */ + public @NonNull List getErrors() { + return errors; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java index 9fd4423c..371252cf 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java @@ -1,21 +1,27 @@ -package net.openan.a2at.sdk.core.exception; - -import java.nio.file.Path; -import org.jspecify.annotations.NonNull; - -/** - * Thrown when one required configuration file path does not exist. - * - * @since 2026-06 - */ -public final class ConfigFileNotFoundException extends A2ATError { - - /** - * Creates one exception for one missing config file. - * - * @param path missing config file path - */ - public ConfigFileNotFoundException(@NonNull Path path) { - super("Config file does not exist: " + path); - } -} +package net.openan.a2at.sdk.core.exception; + +import java.nio.file.Path; +import java.util.Map; +import org.jspecify.annotations.NonNull; + +/** + * Thrown when one required configuration file path does not exist. + * + * @since 2026-06 + */ +public final class ConfigFileNotFoundException extends A2ATError { + + /** + * Creates one exception for one missing config file. + * + * @param path missing config file path + */ + public ConfigFileNotFoundException(@NonNull Path path) { + super( + ErrorCatalog.INFRA_CONFIG_INVALID.getCode(), + ErrorMessages.render( + ErrorCatalog.INFRA_CONFIG_INVALID, + null, + Map.of("key", String.valueOf(path), "reason", "config file does not exist"))); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorCatalog.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorCatalog.java new file mode 100644 index 00000000..bb13bcb5 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorCatalog.java @@ -0,0 +1,242 @@ +package net.openan.a2at.sdk.core.exception; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Closed catalog of the machine-readable error codes exposed by the A2A-T SDK. + * + *

        Each constant carries the layered {@code domain.semantic} code string (for example {@code content.param_missing}), + * the {@link Category} that decides which exception family carries it, and the names of the fact parameters the message + * template for the code renders. Message templates live in {@code prompt_resources/errors/{language}/errors.json}; use + * {@link ErrorMessages} to render them. + * + *

        The catalog is closed: codes returned by an LLM step that are not in this catalog are mapped to the per-domain + * {@code *.rule_violation} fallback constants by the callers, never surfaced raw. + * + * @since 2026-08 + */ +public enum ErrorCatalog { + + // domain: template (template resources) + + /** The referenced template does not exist for the configured language. */ + TEMPLATE_NOT_FOUND("template.not_found", Category.BUSINESS, "template_uri", "language"), + + /** Rendering a template with its slot values failed. */ + TEMPLATE_RENDER_FAILED("template.render_failed", Category.BUSINESS, "template_uri", "reason"), + + /** A template resource could not be loaded from its configured source. */ + TEMPLATE_LOAD_FAILED("template.load_failed", Category.INFRA, "resource_path"), + + // domain: slot (generation-side slots and generic schemas) + + /** The slot schema describing the expected slots could not be resolved for the template. */ + SLOT_SCHEMA_NOT_FOUND("slot.schema_not_found", Category.BUSINESS, "template_uri", "language"), + + /** A required slot could not be extracted from the caller input. */ + SLOT_NOT_PROVIDED("slot.not_provided", Category.BUSINESS, "slot_label"), + + /** An extracted slot value violates a closed value range or structural constraint. */ + SLOT_CONSTRAINT_VIOLATED("slot.constraint_violated", Category.BUSINESS, "slot_label", "actual"), + + /** A slot value conflicts with the slot definition. */ + SLOT_SEMANTIC_CONFLICT("slot.semantic_conflict", Category.BUSINESS, "slot_label", "reason"), + + /** A slot value is obvious placeholder content rather than a valid value. */ + SLOT_FABRICATED_VALUE("slot.fabricated_value", Category.BUSINESS, "slot_label", "actual"), + + /** A slot value mixes in content from a different scenario. */ + SLOT_CROSS_SCENARIO_POLLUTION("slot.cross_scenario_pollution", Category.BUSINESS, "slot_label"), + + /** A slot value lacks sufficient grounding in the input. */ + SLOT_INSUFFICIENT_GROUNDING("slot.insufficient_grounding", Category.BUSINESS, "slot_label"), + + /** Fallback for unknown slot-domain codes returned by an LLM step. */ + SLOT_RULE_VIOLATION("slot.rule_violation", Category.BUSINESS, "slot_label"), + + // domain: input (caller input) + + /** A free-text input exceeded the configured maximum length. */ + INPUT_TEXT_TOO_LONG("input.text_too_long", Category.BUSINESS, "actual_length", "max_chars"), + + // domain: content (validation-side parameter content, LLM-judged) + + /** A parameter section carries no value at all. */ + CONTENT_PARAM_MISSING("content.param_missing", Category.BUSINESS, "section_label"), + + /** A list entry is missing a required field. */ + CONTENT_ENTRY_FIELD_MISSING( + "content.entry_field_missing", Category.BUSINESS, "section_label", "index", "field_label"), + + /** A value exists but its format is invalid. */ + CONTENT_FORMAT_ERROR("content.format_error", Category.BUSINESS, "section_label", "reason"), + + /** A value exists and is well-formed but is not within the allowed range. */ + CONTENT_VALUE_NOT_ALLOWED("content.value_not_allowed", Category.BUSINESS, "section_label", "actual"), + + /** Values conflict semantically. */ + CONTENT_SEMANTIC_CONFLICT("content.semantic_conflict", Category.BUSINESS, "section_label", "reason"), + + /** Fallback for unknown content-domain codes returned by an LLM step. */ + CONTENT_RULE_VIOLATION("content.rule_violation", Category.BUSINESS, "section_label"), + + // domain: scenario (scenario recognition) + + /** The input does not match any known scenario. */ + SCENARIO_NOT_MATCHED("scenario.not_matched", Category.BUSINESS, "reason"), + + // domain: llm (LLM invocations, retryable by the caller) + + /** No LLM client is configured. */ + LLM_NOT_CONFIGURED("llm.not_configured", Category.BUSINESS), + + /** An LLM invocation failed at the transport level. */ + LLM_INVOCATION_FAILED("llm.invocation_failed", Category.BUSINESS, "provider", "reason"), + + /** An LLM response violates the response contract of the step. */ + LLM_RESPONSE_INVALID("llm.response_invalid", Category.BUSINESS, "step"), + + // domain: negotiation (negotiation flows) + + /** The negotiation input is not valid for the requested operation. */ + NEGOTIATION_INVALID_INPUT("negotiation.invalid_input", Category.BUSINESS, "reason"), + + /** The negotiation context id is not a valid UUID. */ + NEGOTIATION_INVALID_CONTEXT_ID("negotiation.invalid_context_id", Category.BUSINESS, "actual"), + + /** The negotiation round exceeds the configured maximum. */ + NEGOTIATION_ROUND_EXCEEDED("negotiation.round_exceeded", Category.BUSINESS, "round", "max_rounds"), + + /** The message content implies a different negotiation type than the declared template. */ + NEGOTIATION_TYPE_MISMATCH("negotiation.type_mismatch", Category.BUSINESS, "implied", "declared"), + + /** The message phase does not match the declared template phase. */ + NEGOTIATION_PHASE_MISMATCH("negotiation.phase_mismatch", Category.BUSINESS, "implied", "declared"), + + /** The message conclusion does not match the conclusion expected by the called method. */ + NEGOTIATION_CONCLUSION_MISMATCH("negotiation.conclusion_mismatch", Category.BUSINESS, "expected", "actual"), + + /** A negotiation content field passed to a from-data method is invalid. */ + NEGOTIATION_CONTENT_INVALID("negotiation.content_invalid", Category.BUSINESS, "field", "reason"), + + /** The negotiation message is missing a required field. */ + NEGOTIATION_FIELD_MISSING("negotiation.field_missing", Category.BUSINESS, "field"), + + /** Negotiation content could not be extracted from free text. */ + NEGOTIATION_CONTENT_EXTRACT_FAILED("negotiation.content_extract_failed", Category.BUSINESS, "field", "reason"), + + /** The conclusion does not match the content required by the result section. */ + NEGOTIATION_CONCLUSION_CONTENT_MISMATCH( + "negotiation.conclusion_content_mismatch", Category.BUSINESS, "conclusion", "section_label"), + + /** The result section is missing the content required by its conclusion. */ + NEGOTIATION_MISSING_RESULT_CONTENT("negotiation.missing_result_content", Category.BUSINESS, "section_label"), + + /** Mutually exclusive sections appear together. */ + NEGOTIATION_MUTUALLY_EXCLUSIVE_SECTIONS("negotiation.mutually_exclusive_sections", Category.BUSINESS, "sections"), + + /** A section conflicts with existing constraints. */ + NEGOTIATION_CONSTRAINT_CONFLICT("negotiation.constraint_conflict", Category.BUSINESS, "section_label", "reason"), + + /** Fields within one section are inconsistent. */ + NEGOTIATION_FIELD_INCONSISTENCY("negotiation.field_inconsistency", Category.BUSINESS, "section_label", "reason"), + + /** A time interval is invalid, for example start later than end. */ + NEGOTIATION_INVALID_TIME_INTERVAL("negotiation.invalid_time_interval", Category.BUSINESS, "section_label"), + + /** The negotiation message failed semantic validation. */ + NEGOTIATION_SEMANTIC_REJECTED("negotiation.semantic_rejected", Category.BUSINESS), + + /** Fallback for unknown negotiation-domain codes returned by an LLM step. */ + NEGOTIATION_RULE_VIOLATION("negotiation.rule_violation", Category.BUSINESS, "section_label"), + + // domain: infra (infrastructure) + + /** A configuration entry is invalid. */ + INFRA_CONFIG_INVALID("infra.config_invalid", Category.INFRA, "key", "reason"), + + /** A resource could not be read. */ + INFRA_RESOURCE_READ_FAILED("infra.resource_read_failed", Category.INFRA, "resource_path"), + + /** Default code for SDK internal errors. */ + INFRA_INTERNAL_ERROR("infra.internal_error", Category.INFRA); + + /** Category deciding which exception family carries the code. */ + public enum Category { + /** Expected business failure the caller can act on; carried by {@link A2ATBusinessException}. */ + BUSINESS, + + /** Infrastructure failure; carried by {@link A2ATError}. */ + INFRA + } + + private static final Map BY_CODE = + Stream.of(values()).collect(Collectors.toMap(ErrorCatalog::getCode, Function.identity())); + + private final String code; + + private final Category category; + + private final List factParameters; + + ErrorCatalog(String code, Category category, String... factParameters) { + this.code = code; + this.category = category; + this.factParameters = List.of(factParameters); + } + + /** + * Returns the catalog entry for one code string. + * + * @param code layered error code, for example {@code content.param_missing} + * @return catalog entry, or empty when the code is not in the catalog + * @throws NullPointerException if {@code code} is null + */ + public static Optional byCode(@NonNull String code) { + return Optional.ofNullable(BY_CODE.get(code)); + } + + /** + * Returns the layered {@code domain.semantic} code string. + * + * @return error code, never null + */ + public @NonNull String getCode() { + return code; + } + + /** + * Returns the category of the code. + * + * @return category, never null + */ + public @NonNull Category getCategory() { + return category; + } + + /** + * Returns the names of the fact parameters the message template of the code renders. + * + * @return fact parameter names, possibly empty, never null + */ + public @NonNull List getFactParameters() { + return factParameters; + } + + /** + * Returns whether one name is a declared fact parameter of the code. + * + * @param name fact parameter name + * @return true when the name is declared for the code + */ + public boolean hasFactParameter(@Nullable String name) { + return name != null && factParameters.contains(name); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorMessages.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorMessages.java new file mode 100644 index 00000000..e763ce0a --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorMessages.java @@ -0,0 +1,145 @@ +package net.openan.a2at.sdk.core.exception; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Renders error messages from the bundled {@code prompt_resources/errors/{language}/errors.json} templates. + * + *

        The language follows the {@code A2AT_LANGUAGE} configuration resolved by the caller (default {@code en-US}). A + * template missing in the requested language falls back to {@code en-US}; a template missing in both languages renders + * as the bare code string. Placeholders are {@code {name}} tokens; a placeholder without a matching fact value is kept + * literal, matching the template style of the A2A-T prompt resources. + * + * @since 2026-08 + */ +public final class ErrorMessages { + + /** Language used when no language is configured or a template is missing in the requested language. */ + public static final String DEFAULT_LANGUAGE = "en-US"; + + private static final Logger LOGGER = LoggerFactory.getLogger(ErrorMessages.class); + + private static final String RESOURCE_PATTERN = "prompt_resources/errors/%s/errors.json"; + + private static final TypeReference> TEMPLATE_MAP_TYPE = + new TypeReference>() {}; + + private static final Pattern PLACEHOLDER = Pattern.compile("\\{([a-zA-Z][a-zA-Z0-9_]*)\\}"); + + private static final Map> TEMPLATES_BY_LANGUAGE = new ConcurrentHashMap<>(); + + private ErrorMessages() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** + * Renders the message of one catalog entry in one language. + * + * @param entry catalog entry whose message template is rendered + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name; null values render as the literal placeholder + * @return rendered message, never null + * @throws NullPointerException if {@code entry} is null + */ + public static String render( + @NonNull ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { + return render(entry.getCode(), language, facts); + } + + /** + * Renders the message of one error code in one language. + * + * @param code layered error code, for example {@code content.param_missing} + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name; null values render as the literal placeholder + * @return rendered message, never null; the bare code when no template exists in either language + * @throws NullPointerException if {@code code} is null + */ + public static String render(@NonNull String code, @Nullable String language, @Nullable Map facts) { + String template = resolveTemplate(code, language); + if (template == null) { + LOGGER.warn( + "No error message template found for code '{}' in language '{}' or '{}'.", + code, + language, + DEFAULT_LANGUAGE); + return code; + } + return renderTemplate(template, facts); + } + + /** + * Returns the message template of one error code in one language, without rendering it. + * + * @param code layered error code + * @param language message language; null or blank falls back to {@code en-US} + * @return template text, or null when no template exists in the requested language or {@code en-US} + * @throws NullPointerException if {@code code} is null + */ + public static @Nullable String template(@NonNull String code, @Nullable String language) { + return resolveTemplate(code, language); + } + + private static @Nullable String resolveTemplate(String code, String language) { + String normalized = normalizeLanguage(language); + String template = templates(normalized).get(code); + if (template == null && !DEFAULT_LANGUAGE.equals(normalized)) { + template = templates(DEFAULT_LANGUAGE).get(code); + } + return template; + } + + private static Map templates(String language) { + return TEMPLATES_BY_LANGUAGE.computeIfAbsent(language, ErrorMessages::loadTemplates); + } + + private static Map loadTemplates(String language) { + String resourcePath = String.format(RESOURCE_PATTERN, language); + try (InputStream stream = ClasspathResourceStreams.open(resourcePath)) { + if (stream == null) { + LOGGER.warn("Error message resource '{}' not found on the classpath.", resourcePath); + return Map.of(); + } + Map templates = new ObjectMapper().readValue(stream, TEMPLATE_MAP_TYPE); + return Map.copyOf(templates); + } catch (IOException | RuntimeException error) { + LOGGER.warn("Cannot load error message resource '{}': {}", resourcePath, error.getMessage()); + return Map.of(); + } + } + + private static String renderTemplate(String template, Map facts) { + if (facts == null || facts.isEmpty()) { + return template; + } + StringBuilder rendered = new StringBuilder(); + Matcher matcher = PLACEHOLDER.matcher(template); + while (matcher.find()) { + String value = facts.get(matcher.group(1)); + String replacement = value != null ? value : matcher.group(0); + matcher.appendReplacement(rendered, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(rendered); + return rendered.toString(); + } + + private static String normalizeLanguage(String language) { + if (language == null || language.isBlank()) { + return DEFAULT_LANGUAGE; + } + String trimmed = language.trim(); + return trimmed.isEmpty() ? DEFAULT_LANGUAGE : trimmed; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java index d98b3c14..3a16367f 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java @@ -1,68 +1,66 @@ -package net.openan.a2at.sdk.core.exception; - -import java.util.List; +package net.openan.a2at.sdk.core.exception; + +import java.util.List; import net.openan.a2at.sdk.core.model.SlotValidationError; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Unified exception type for MetadataContent pipeline failures. - * - *

        The machine-readable error code is carried by the root {@link A2ATError} and is never null. - * - * @since 2026-08 - */ -public final class PromptGenerationException extends A2ATError { - - private final List failedParameters; - - /** - * Creates an exception with a stable error code and a human-readable message. - * - * @param code stable error code - * @param message human-readable failure description - * @throws NullPointerException if {@code code} is null - */ - public PromptGenerationException(@NonNull String code, String message) { - super(code, message); - this.failedParameters = List.of(); - } - - /** - * Creates an exception with a stable error code, a human-readable message, and - * a list of failed parameters. - * - * @param code stable error code - * @param message human-readable failure description - * @param failedParameters list of failed slot validations - * @throws NullPointerException if {@code code} is null - */ - public PromptGenerationException( - @NonNull String code, String message, @Nullable List failedParameters) { - super(code, message); - this.failedParameters = failedParameters == null ? List.of() : List.copyOf(failedParameters); - } - - /** - * Creates an exception with a stable error code, a human-readable message, and - * a root cause. - * - * @param code stable error code - * @param message human-readable failure description - * @param cause root cause - * @throws NullPointerException if {@code code} is null - */ - public PromptGenerationException(@NonNull String code, String message, @Nullable Throwable cause) { - super(code, message, cause); - this.failedParameters = List.of(); - } - - /** - * Returns the list of failed parameters, or an empty list when not applicable. - * - * @return failed parameters - */ - public @NonNull List failedParameters() { - return failedParameters; - } -} +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Unified exception type for MetadataContent pipeline failures. + * + *

        The machine-readable error code is carried by the root {@link A2ATError} and is never null. + * + * @since 2026-08 + */ +public final class PromptGenerationException extends A2ATBusinessException { + + private final List failedParameters; + + /** + * Creates an exception with a stable error code and a human-readable message. + * + * @param code stable error code + * @param message human-readable failure description + * @throws NullPointerException if {@code code} is null + */ + public PromptGenerationException(@NonNull String code, String message) { + super(code, message); + this.failedParameters = List.of(); + } + + /** + * Creates an exception with a stable error code, a human-readable message, and a list of failed parameters. + * + * @param code stable error code + * @param message human-readable failure description + * @param failedParameters list of failed slot validations + * @throws NullPointerException if {@code code} is null + */ + public PromptGenerationException( + @NonNull String code, String message, @Nullable List failedParameters) { + super(code, message); + this.failedParameters = failedParameters == null ? List.of() : List.copyOf(failedParameters); + } + + /** + * Creates an exception with a stable error code, a human-readable message, and a root cause. + * + * @param code stable error code + * @param message human-readable failure description + * @param cause root cause + * @throws NullPointerException if {@code code} is null + */ + public PromptGenerationException(@NonNull String code, String message, @Nullable Throwable cause) { + super(code, message, null, cause); + this.failedParameters = List.of(); + } + + /** + * Returns the list of failed parameters, or an empty list when not applicable. + * + * @return failed parameters + */ + public @NonNull List failedParameters() { + return failedParameters; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/SlotValidationError.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/SlotValidationError.java index 21f4e8cc..a8e22ba6 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/SlotValidationError.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/SlotValidationError.java @@ -1,14 +1,35 @@ package net.openan.a2at.sdk.core.model; +import java.util.Map; +import org.jspecify.annotations.Nullable; + /** * Structured validation error details for one named slot. * *

        Instances are carried inside parameter-extraction failures so callers can inspect which slot failed, under which - * error code, and why, without parsing exception messages. + * error code, and why, without parsing exception messages. The {@code message} is rendered by the SDK from the code's + * message template; {@code facts} carries the structured fact values that produced the message. * * @param slotName name of the slot the error refers to * @param code machine-readable error code for the error * @param message human-readable explanation of the error + * @param facts fact values keyed by fact parameter name, may be null * @since 2026-08 */ -public record SlotValidationError(String slotName, String code, String message) {} +public record SlotValidationError(String slotName, String code, String message, @Nullable Map facts) { + + public SlotValidationError { + facts = facts == null ? null : Map.copyOf(facts); + } + + /** + * Creates a slot validation error without structured facts. + * + * @param slotName name of the slot the error refers to + * @param code machine-readable error code for the error + * @param message human-readable explanation of the error + */ + public SlotValidationError(String slotName, String code, String message) { + this(slotName, code, message, null); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java index e48d22f4..c400e1ad 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java @@ -1,102 +1,105 @@ -package net.openan.a2at.sdk.core.validation; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.model.SlotValidationError; - -/** - * Failure raised by the content validation pipeline. - * - *

        Carries the machine-readable error code inherited from {@link A2ATError} and optionally structured per-slot - * validation error details so callers can inspect which slot failed, under which error code, and why, without parsing - * exception messages. - * - * @since 2026-08 - */ -public class ContentValidationException extends A2ATError { - - private final List errors; - - private final Map params; - - /** - * Creates a content validation failure with an error code and one message. - * - * @param code machine-readable error code for the failure - * @param message failure message - */ - public ContentValidationException(String code, String message) { - this(code, message, List.of(), Map.of(), null); - } - - /** - * Creates a content validation failure with an error code, one message and slot details. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param errors structured per-slot validation error details - */ - public ContentValidationException(String code, String message, List errors) { - this(code, message, errors, Map.of(), null); - } - - /** - * Creates a content validation failure with an error code, one message and a root cause. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param cause root cause - */ - public ContentValidationException(String code, String message, Throwable cause) { - this(code, message, List.of(), Map.of(), cause); - } - - public ContentValidationException(String code, String message, List errors, Throwable cause) { - this(code, message, errors, Map.of(), cause); - } - - /** - * Creates a content validation failure with an error code, one message, slot details, partial extraction params and - * a root cause. - * - *

        {@code params} carries the extraction result produced before rejection; the semantic validator deliberately - * emits {@code null} values for slots it could not extract, so both the map and its values may be {@code null} and - * must be accepted defensively. The stored copies are unmodifiable and preserve the original ordering. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param errors structured per-slot validation error details - * @param params partial extraction params, may carry {@code null} values - * @param cause root cause - */ - public ContentValidationException( - String code, String message, List errors, Map params, Throwable cause) { - super(code, message, cause); - this.errors = errors == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(errors)); - this.params = params == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(params)); - } - - /** - * Returns the structured per-slot validation error details. - * - * @return immutable list of slot validation errors, never null - */ - public List errors() { - return errors; - } - - /** - * Returns the partial extraction params captured before rejection. - * - * @return unmodifiable map preserving insertion order, never null; values may be {@code null} for slots the - * semantic validator could not extract - */ - public Map params() { - return params; - } -} +package net.openan.a2at.sdk.core.validation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.model.SlotValidationError; + +/** + * Failure raised by the content validation pipeline. + * + *

        Carries the machine-readable error code inherited from {@link A2ATBusinessException} and optionally structured + * per-slot validation error details so callers can inspect which slot failed, under which error code, and why, without + * parsing exception messages. + * + * @since 2026-08 + */ +public class ContentValidationException extends A2ATBusinessException { + + private final List errors; + + private final Map params; + + /** + * Creates a content validation failure with an error code and one message. + * + * @param code machine-readable error code for the failure + * @param message failure message + */ + public ContentValidationException(String code, String message) { + this(code, message, List.of(), Map.of(), null); + } + + /** + * Creates a content validation failure with an error code, one message and slot details. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + */ + public ContentValidationException(String code, String message, List errors) { + this(code, message, errors, Map.of(), null); + } + + /** + * Creates a content validation failure with an error code, one message and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param cause root cause + */ + public ContentValidationException(String code, String message, Throwable cause) { + this(code, message, List.of(), Map.of(), cause); + } + + public ContentValidationException(String code, String message, List errors, Throwable cause) { + this(code, message, errors, Map.of(), cause); + } + + /** + * Creates a content validation failure with an error code, one message, slot details, partial extraction params and + * a root cause. + * + *

        {@code params} carries the extraction result produced before rejection; the semantic validator deliberately + * emits {@code null} values for slots it could not extract, so both the map and its values may be {@code null} and + * must be accepted defensively. The stored copies are unmodifiable and preserve the original ordering. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + * @param params partial extraction params, may carry {@code null} values + * @param cause root cause + */ + public ContentValidationException( + String code, + String message, + List errors, + Map params, + Throwable cause) { + super(code, message, null, cause); + this.errors = errors == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(errors)); + this.params = params == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(params)); + } + + /** + * Returns the structured per-slot validation error details. + * + * @return immutable list of slot validation errors, never null + */ + public List errors() { + return errors; + } + + /** + * Returns the partial extraction params captured before rejection. + * + * @return unmodifiable map preserving insertion order, never null; values may be {@code null} for slots the + * semantic validator could not extract + */ + public Map params() { + return params; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java index 81769c47..fc3cd62b 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java @@ -1,62 +1,73 @@ -package net.openan.a2at.sdk.core.validation; - -import java.util.function.Supplier; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Lightweight retry utility for the content validation pipeline. - * - *

        Only retries on {@link ContentValidationException} failures with the code - * {@link A2ATErrorCodes#VALIDATION_LLM_INFRASTRUCTURE_ERROR}. All other failure codes and unknown runtime - * exceptions are rethrown immediately. - * - * @since 2026-08 - */ -final class RetryUtil { - - private static final Logger LOGGER = LoggerFactory.getLogger(RetryUtil.class); - - private RetryUtil() {} - - /** - * Executes one action with retry on LLM infrastructure errors. - * - * @param result type - * @param maxAttempts maximum number of attempts, must be at least 1 - * @param stepName name of the step for logging purposes - * @param action the action to execute - * @return the result of the action - * @throws ContentValidationException if the action fails on every attempt with a retryable error, or if it fails - * with a non-retryable error - */ - public static T withRetry(int maxAttempts, String stepName, Supplier action) { - if (maxAttempts < 1) { - throw new IllegalArgumentException("maxAttempts must be at least 1"); - } - for (int attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return action.get(); - } catch (ContentValidationException exception) { - if (!A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR.equals(exception.getCode())) { - throw exception; - } - if (attempt == maxAttempts) { - LOGGER.atWarn().log( - "{}_retry_exhausted attempts={} last_error={}", stepName, attempt, exception.getMessage()); - throw exception; - } - LOGGER.atWarn().log( - "{}_retry_attempt attempt={}/{} error={}", - stepName, - attempt, - maxAttempts, - exception.getMessage()); - } catch (RuntimeException exception) { - throw exception; - } - } - throw new IllegalStateException("unreachable"); - } -} \ No newline at end of file +package net.openan.a2at.sdk.core.validation; + +import java.util.function.Supplier; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Lightweight retry utility for the content validation pipeline. + * + *

        Only retries on {@link ContentValidationException} failures carrying an {@code llm.*} catalog code, such as + * {@link ErrorCatalog#LLM_INVOCATION_FAILED} and {@link ErrorCatalog#LLM_RESPONSE_INVALID}. All other failure codes and + * unknown runtime exceptions are rethrown immediately. + * + * @since 2026-08 + */ +final class RetryUtil { + + private static final Logger LOGGER = LoggerFactory.getLogger(RetryUtil.class); + + private static final String LLM_CODE_DOMAIN = "llm."; + + private RetryUtil() {} + + /** + * Executes one action with retry on LLM failures. + * + * @param result type + * @param maxAttempts maximum number of attempts, must be at least 1 + * @param stepName name of the step for logging purposes + * @param action the action to execute + * @return the result of the action + * @throws ContentValidationException if the action fails on every attempt with a retryable error, or if it fails + * with a non-retryable error + */ + public static T withRetry(int maxAttempts, String stepName, Supplier action) { + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be at least 1"); + } + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return action.get(); + } catch (ContentValidationException exception) { + if (!isRetryableLlmFailure(exception)) { + throw exception; + } + if (attempt == maxAttempts) { + LOGGER.atWarn() + .log( + "{}_retry_exhausted attempts={} last_error={}", + stepName, + attempt, + exception.getMessage()); + throw exception; + } + LOGGER.atWarn() + .log( + "{}_retry_attempt attempt={}/{} error={}", + stepName, + attempt, + maxAttempts, + exception.getMessage()); + } catch (RuntimeException exception) { + throw exception; + } + } + throw new IllegalStateException("unreachable"); + } + + private static boolean isRetryableLlmFailure(ContentValidationException exception) { + return exception.getCode().startsWith(LLM_CODE_DOMAIN); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java index 6a57dd3a..c9d00367 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java @@ -5,7 +5,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.SlotValidationError; @@ -30,14 +31,18 @@ public final class ValidationPipeline { private static final Logger LOGGER = LoggerFactory.getLogger(ValidationPipeline.class); + private static final String SEMANTIC_VALIDATION_STEP = "semantic_validation"; + private final RuleChecker ruleChecker; private final SemanticValidator semanticValidator; - private final TemplateContentLoader templateContentLoader; - private final int maxAttempts; + private final String language; + + private final TemplateContentLoader templateContentLoader; + /** * Creates a validation pipeline without a template loading gate. * @@ -48,19 +53,39 @@ public final class ValidationPipeline { */ public ValidationPipeline( @NonNull RuleChecker ruleChecker, @NonNull SemanticValidator semanticValidator, int maxAttempts) { - this(ruleChecker, semanticValidator, maxAttempts, null); + this(ruleChecker, semanticValidator, maxAttempts, null, null); + } + + /** + * Creates a validation pipeline without a template loading gate. + * + * @param ruleChecker rule-level checker used as the entry gate + * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters + * @param maxAttempts maximum number of retry attempts for the semantic validation step + * @param language language used to render failure messages, for example {@code zh-CN}; null falls back to + * {@code en-US} + * @throws NullPointerException if the rule checker or the semantic validator is null + */ + public ValidationPipeline( + @NonNull RuleChecker ruleChecker, + @NonNull SemanticValidator semanticValidator, + int maxAttempts, + @Nullable String language) { + this(ruleChecker, semanticValidator, maxAttempts, language, null); } /** * Creates a validation pipeline with an optional template loading gate. * - *

        The loader, when injected, runs after the rule gate and before semantic validation to resolve the template + *

        The loader, when injected, runs after the input gate and before semantic validation to resolve the template * body for the {@link #validate(String, Map, Object)} variant. A {@code null} loader leaves only the preloaded * {@link #validate(String, Map, Object, String)} variant available. * * @param ruleChecker rule-level checker used as the entry gate * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters * @param maxAttempts maximum number of retry attempts for the semantic validation step + * @param language language used to render failure messages, for example {@code zh-CN}; null falls back to + * {@code en-US} * @param templateContentLoader optional loader resolving the template body, may be null * @throws NullPointerException if the rule checker or the semantic validator is null */ @@ -68,22 +93,24 @@ public ValidationPipeline( @NonNull RuleChecker ruleChecker, @NonNull SemanticValidator semanticValidator, int maxAttempts, + @Nullable String language, @Nullable TemplateContentLoader templateContentLoader) { this.ruleChecker = Objects.requireNonNull(ruleChecker, "ruleChecker"); this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); this.maxAttempts = maxAttempts; + this.language = language; this.templateContentLoader = templateContentLoader; } /** * Validates one content prompt and extracts its filled parameters through the full pipeline, loading the template - * body via the injected template loading gate after the rule gate and before semantic validation. + * body via the injected template loading gate after the input gate and before semantic validation. * * @param prompt content prompt text * @param schema caller-provided parameter JSON schema * @param reference template addressing value the content is validated against * @return filled parameter data carrying the merged parameters - * @throws ContentValidationException with {@code validation_invalid_input} if the prompt is null or blank, the + * @throws ContentValidationException with {@code negotiation.invalid_input} if the prompt is null or blank, the * schema is null, or the reference is null * @throws IllegalStateException if no {@link TemplateContentLoader} was injected into this pipeline * @throws ContentValidationException if the validation fails at any stage @@ -101,8 +128,7 @@ public ValidationPipeline( try { templateContent = templateContentLoader.load(reference); } catch (ResourceNotFoundException exception) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, exception.getMessage(), exception); + throw templateNotFound(exception); } return runSemanticValidationAndMerge(prompt, schema, reference, contextParams, templateContent); @@ -116,7 +142,7 @@ public ValidationPipeline( * @param reference template addressing value the content is validated against * @param templateContent loaded template text used as a reference for structure/completeness checks * @return filled parameter data carrying the merged parameters - * @throws ContentValidationException with {@code validation_invalid_input} if the prompt is null or blank, the + * @throws ContentValidationException with {@code negotiation.invalid_input} if the prompt is null or blank, the * schema is null, or the reference is null * @throws ContentValidationException if the validation fails at any stage */ @@ -132,20 +158,40 @@ public ValidationPipeline( private Map validateInputsAndRunRuleGate( @NonNull String prompt, @NonNull Map schema, @NonNull T reference) { if (schema == null) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, "Parameter schema must not be null."); + throw invalidInput("Parameter schema must not be null."); } if (prompt == null || prompt.isBlank()) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, "Prompt must not be null or blank."); + throw invalidInput("Prompt must not be null or blank."); } if (reference == null) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, "Template reference must not be null."); + throw invalidInput("Template reference must not be null."); } return ruleChecker.check(prompt); } + private ContentValidationException invalidInput(String reason) { + Map facts = Map.of("reason", reason); + return new ContentValidationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), + List.of(new SlotValidationError( + "_input", + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), + facts)), + null); + } + + private ContentValidationException templateNotFound(ResourceNotFoundException exception) { + String effectiveLanguage = language == null ? ErrorMessages.DEFAULT_LANGUAGE : language; + Map facts = + Map.of("template_uri", exception.resourcePath(), "language", effectiveLanguage); + return new ContentValidationException( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorMessages.render(ErrorCatalog.TEMPLATE_NOT_FOUND, language, facts), + exception); + } + private @NonNull FilledParamData runSemanticValidationAndMerge( String prompt, Map schema, @@ -156,24 +202,30 @@ private Map validateInputsAndRunRuleGate( try { semanticResult = RetryUtil.withRetry( maxAttempts, - "semantic_validation", + SEMANTIC_VALIDATION_STEP, () -> semanticValidator.validate(prompt, schema, reference, templateContent)); } catch (ResourceNotFoundException exception) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, exception.getMessage(), exception); + throw templateNotFound(exception); + } catch (ContentValidationException exception) { + // already carries a final catalog code emitted by the semantic validator + throw exception; } catch (RuntimeException exception) { + Map facts = Map.of("step", SEMANTIC_VALIDATION_STEP); throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, - exception.getMessage(), + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, language, facts), List.of(new SlotValidationError( - "_llm", A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getMessage())), + "_llm", + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, language, facts), + facts)), exception); } if (!semanticResult.verdict()) { throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED, - "Semantic validation rejected the content.", + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED, language, null), semanticResult.errors(), semanticResult.params(), null); diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java index 4e35ae13..b9b2aa11 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java @@ -9,30 +9,32 @@ import org.junit.jupiter.api.Test; /** - * Unit tests for {@link A2ATErrorCodes}. + * Unit tests for the deprecated {@link A2ATErrorCodes} forwarding constants. * *

        Tests cover the following scenarios: * *

          - *
        • The exact set of error code constants exposed by the registry - *
        • The exact set of validation error code constants exposed by the registry + *
        • Every deprecated constant forwards to the layered {@code domain.semantic} code of its replacement + * {@link ErrorCatalog} entry *
        * * @since 2026-06 */ +@SuppressWarnings("removal") class A2ATErrorCodesTest { /** - * Verifies that {@link A2ATErrorCodes} declares exactly the twenty-one expected error code constants with the - * expected values. + * Verifies that {@link A2ATErrorCodes} forwards exactly the twenty-one legacy constants to their catalog + * replacements. * - *

        Scenario: Reflection inspects all declared static final String fields of the registry. Expected result: The - * field set contains exactly the twenty-one known constants and no others. + *

        Scenario: Reflection inspects all declared static final String fields of the deprecated registry. Expected + * result: Each field value equals the code of the catalog entry that replaced it. * * @throws IllegalAccessException if a declared field cannot be read */ @Test - void should_declareExactlyTwentyOneConstants_When_reflectingOverDeclaredFields() throws IllegalAccessException { + void should_forwardEveryConstantToItsCatalogReplacement_When_reflectingOverDeclaredFields() + throws IllegalAccessException { Map constants = new TreeMap<>(); for (Field field : A2ATErrorCodes.class.getDeclaredFields()) { if (field.getType() == String.class @@ -45,27 +47,30 @@ void should_declareExactlyTwentyOneConstants_When_reflectingOverDeclaredFields() assertEquals( Map.ofEntries( - Map.entry("SDK_INTERNAL_ERROR", "sdk_internal_error"), - Map.entry("PARAM_EXTRACTION_FAILED", "param_extraction_failed"), - Map.entry("INPUT_TEXT_TOO_LONG", "input_text_too_long"), - Map.entry("TEMPLATE_NOT_FOUND", "template_not_found"), - Map.entry("NEGOTIATION_CONTENT_EXTRACT_FAILED", "negotiation_content_extract_failed"), - Map.entry("NEGOTIATION_SEMANTIC_REJECTED", "negotiation_semantic_rejected"), - Map.entry("NEGOTIATION_RULE_VIOLATION", "negotiation_rule_violation"), - Map.entry("NEGOTIATION_SLOT_MISSING", "negotiation_slot_missing"), - Map.entry("NEGOTIATION_INVALID_INPUT", "negotiation_invalid_input"), - Map.entry("NEGOTIATION_LLM_INFRASTRUCTURE_ERROR", "negotiation_llm_infrastructure_error"), - Map.entry("VALIDATION_INVALID_INPUT", "validation_invalid_input"), - Map.entry("VALIDATION_RULE_VIOLATION", "validation_rule_violation"), - Map.entry("VALIDATION_SEMANTIC_REJECTED", "validation_semantic_rejected"), - Map.entry("VALIDATION_LLM_INFRASTRUCTURE_ERROR", "validation_llm_infrastructure_error"), - Map.entry("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", "validation_prompt_resource_not_found"), - Map.entry("PROMPT_RESOURCE_LOAD_ERROR", "prompt_resource_load_error"), - Map.entry("SLOT_SCHEMA_NOT_FOUND", "slot_schema_not_found"), - Map.entry("LLM_INVOCATION_FAILED", "llm_invocation_failed"), - Map.entry("RENDER_FAILED", "render_failed"), - Map.entry("SLOT_VALIDATION_ERROR", "slot_validation_error"), - Map.entry("PROCESSED_PROMPT_PARSE_ERROR", "processed_prompt_parse_error")), + Map.entry("SDK_INTERNAL_ERROR", ErrorCatalog.INFRA_INTERNAL_ERROR.getCode()), + Map.entry("PARAM_EXTRACTION_FAILED", ErrorCatalog.SLOT_NOT_PROVIDED.getCode()), + Map.entry("INPUT_TEXT_TOO_LONG", ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode()), + Map.entry("TEMPLATE_NOT_FOUND", ErrorCatalog.TEMPLATE_NOT_FOUND.getCode()), + Map.entry( + "NEGOTIATION_CONTENT_EXTRACT_FAILED", + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode()), + Map.entry( + "NEGOTIATION_SEMANTIC_REJECTED", ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode()), + Map.entry("NEGOTIATION_RULE_VIOLATION", ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode()), + Map.entry("NEGOTIATION_SLOT_MISSING", ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode()), + Map.entry("NEGOTIATION_INVALID_INPUT", ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode()), + Map.entry("NEGOTIATION_LLM_INFRASTRUCTURE_ERROR", ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), + Map.entry("VALIDATION_INVALID_INPUT", ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode()), + Map.entry("VALIDATION_RULE_VIOLATION", ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode()), + Map.entry("VALIDATION_SEMANTIC_REJECTED", ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode()), + Map.entry("VALIDATION_LLM_INFRASTRUCTURE_ERROR", ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), + Map.entry("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", ErrorCatalog.TEMPLATE_NOT_FOUND.getCode()), + Map.entry("PROMPT_RESOURCE_LOAD_ERROR", ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode()), + Map.entry("SLOT_SCHEMA_NOT_FOUND", ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode()), + Map.entry("LLM_INVOCATION_FAILED", ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), + Map.entry("RENDER_FAILED", ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode()), + Map.entry("SLOT_VALIDATION_ERROR", ErrorCatalog.SLOT_RULE_VIOLATION.getCode()), + Map.entry("PROCESSED_PROMPT_PARSE_ERROR", ErrorCatalog.SCENARIO_NOT_MATCHED.getCode())), constants); } } diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorTest.java index cfdd4a22..1c726573 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorTest.java @@ -1,70 +1,69 @@ -package net.openan.a2at.sdk.core.exception; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import org.junit.jupiter.api.Test; - -/** - * Unit tests for {@link A2ATError}. - * - *

        Tests cover the following scenarios: - * - *

          - *
        • Default error code when no explicit code is provided - *
        • Explicit error code forwarding - *
        • Null safety for the explicit code - *
        - * - * @since 2026-08 - */ -class A2ATErrorTest { - - /** - * Verifies that {@link A2ATError#A2ATError(String)} and {@link A2ATError#A2ATError(String, Throwable)} fall back to - * {@link A2ATErrorCodes#SDK_INTERNAL_ERROR}. - * - *

        Scenario: Create A2A-T errors without an explicit code. Expected result: getCode() returns - * {@code sdk_internal_error} and is never null. - */ - @Test - void should_defaultToSdkInternalErrorCode_When_createdWithoutExplicitCode() { - assertEquals(A2ATErrorCodes.SDK_INTERNAL_ERROR, new A2ATError("boom").getCode()); - assertEquals( - A2ATErrorCodes.SDK_INTERNAL_ERROR, - new A2ATError("boom", new IllegalStateException("root")).getCode()); - } - - /** - * Verifies that {@link A2ATError#A2ATError(String, String)} and {@link A2ATError#A2ATError(String, String, - * Throwable)} carry the explicitly provided code. - * - *

        Scenario: Create A2A-T errors with an explicit code. Expected result: getCode() returns the explicit code, - * getMessage() returns the message and getCause() returns the cause when provided. - */ - @Test - void should_carryExplicitCode_When_createdWithExplicitCode() { - assertEquals( - A2ATErrorCodes.TEMPLATE_NOT_FOUND, - new A2ATError(A2ATErrorCodes.TEMPLATE_NOT_FOUND, "missing").getCode()); - assertEquals("missing", new A2ATError(A2ATErrorCodes.TEMPLATE_NOT_FOUND, "missing").getMessage()); - assertNull(new A2ATError(A2ATErrorCodes.TEMPLATE_NOT_FOUND, "missing").getCause()); - - IllegalStateException cause = new IllegalStateException("root"); - A2ATError withCause = new A2ATError(A2ATErrorCodes.TEMPLATE_NOT_FOUND, "missing", cause); - assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, withCause.getCode()); - assertEquals(cause, withCause.getCause()); - } - - /** - * Verifies that the explicit-code constructors reject null codes. - * - *

        Scenario: Attempt to create A2A-T errors with a null code. Expected result: NullPointerException is thrown. - */ - @Test - void should_throwNullPointerException_When_createdWithNullCode() { - assertThrows(NullPointerException.class, () -> new A2ATError(null, "missing")); - assertThrows(NullPointerException.class, () -> new A2ATError(null, "missing", new IllegalStateException())); - } -} +package net.openan.a2at.sdk.core.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link A2ATError}. + * + *

        Tests cover the following scenarios: + * + *

          + *
        • Default error code when no explicit code is provided + *
        • Explicit error code forwarding + *
        • Null safety for the explicit code + *
        + * + * @since 2026-08 + */ +class A2ATErrorTest { + + /** + * Verifies that {@link A2ATError#A2ATError(String)} and {@link A2ATError#A2ATError(String, Throwable)} fall back to + * {@link ErrorCatalog#INFRA_INTERNAL_ERROR}. + * + *

        Scenario: Create A2A-T errors without an explicit code. Expected result: getCode() returns + * {@code infra.internal_error} and is never null. + */ + @Test + void should_defaultToInfraInternalErrorCode_When_createdWithoutExplicitCode() { + assertEquals(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), new A2ATError("boom").getCode()); + assertEquals( + ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), + new A2ATError("boom", new IllegalStateException("root")).getCode()); + } + + /** + * Verifies that {@link A2ATError#A2ATError(String, String)} and {@link A2ATError#A2ATError(String, String, + * Throwable)} carry the explicitly provided code. + * + *

        Scenario: Create A2A-T errors with an explicit code. Expected result: getCode() returns the explicit code, + * getMessage() returns the message and getCause() returns the cause when provided. + */ + @Test + void should_carryExplicitCode_When_createdWithExplicitCode() { + String templateNotFound = ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); + assertEquals(templateNotFound, new A2ATError(templateNotFound, "missing").getCode()); + assertEquals("missing", new A2ATError(templateNotFound, "missing").getMessage()); + assertNull(new A2ATError(templateNotFound, "missing").getCause()); + + IllegalStateException cause = new IllegalStateException("root"); + A2ATError withCause = new A2ATError(templateNotFound, "missing", cause); + assertEquals(templateNotFound, withCause.getCode()); + assertEquals(cause, withCause.getCause()); + } + + /** + * Verifies that the explicit-code constructors reject null codes. + * + *

        Scenario: Attempt to create A2A-T errors with a null code. Expected result: NullPointerException is thrown. + */ + @Test + void should_throwNullPointerException_When_createdWithNullCode() { + assertThrows(NullPointerException.class, () -> new A2ATError(null, "missing")); + assertThrows(NullPointerException.class, () -> new A2ATError(null, "missing", new IllegalStateException())); + } +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/errors/en-US/errors.json b/a2a-t-resources/src/main/resources/prompt_resources/errors/en-US/errors.json new file mode 100644 index 00000000..b90a18d2 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/errors/en-US/errors.json @@ -0,0 +1,44 @@ +{ + "template.not_found": "Template '{template_uri}' does not support language '{language}'; check the template URI and language setting", + "template.render_failed": "Failed to render template '{template_uri}': {reason}", + "template.load_failed": "Failed to read template resource '{resource_path}'", + "slot.schema_not_found": "Template '{template_uri}' is missing its slot schema (language '{language}')", + "slot.not_provided": "'{slot_label}' is not provided in the input.", + "slot.constraint_violated": "The value of '{slot_label}' ({actual}) is not within the allowed range", + "slot.semantic_conflict": "The value of '{slot_label}' conflicts with the slot definition: {reason}", + "slot.fabricated_value": "The value of '{slot_label}' ({actual}) is placeholder content, not a valid value", + "slot.cross_scenario_pollution": "The value of '{slot_label}' contains content from a different scenario", + "slot.insufficient_grounding": "The value of '{slot_label}' lacks sufficient grounding", + "slot.rule_violation": "The value of '{slot_label}' violates the validation rules.", + "input.text_too_long": "Input text length {actual_length} exceeds the maximum of {max_chars} (A2AT_INPUT_TEXT_MAX_CHARS)", + "content.param_missing": "'{section_label}' is empty; please provide a value", + "content.entry_field_missing": "Entry {index} of '{section_label}' is missing required field '{field_label}'", + "content.format_error": "The format of '{section_label}' is invalid: {reason}", + "content.value_not_allowed": "The value of '{section_label}' ({actual}) is not allowed", + "content.semantic_conflict": "'{section_label}' has a semantic conflict: {reason}", + "content.rule_violation": "The value of '{section_label}' violates the validation rules.", + "scenario.not_matched": "The input does not match any known scenario: {reason}", + "llm.not_configured": "No LLM client is configured; check the A2AT_LLM_* settings", + "llm.invocation_failed": "LLM invocation failed (provider {provider}): {reason}", + "llm.response_invalid": "The LLM response is invalid (step: {step}); please retry", + "negotiation.invalid_input": "The negotiation input is invalid: {reason}", + "negotiation.invalid_context_id": "The negotiation context id '{actual}' is not a valid UUID", + "negotiation.round_exceeded": "Negotiation round {round} exceeds the maximum of {max_rounds}", + "negotiation.type_mismatch": "The message implies '{implied}' negotiation but the declared template type is '{declared}'", + "negotiation.phase_mismatch": "The message phase does not match the declared template phase ({implied} vs {declared})", + "negotiation.conclusion_mismatch": "The message conclusion is '{actual}' but '{expected}' is expected for this method", + "negotiation.content_invalid": "The negotiation content field '{field}' is invalid: {reason}", + "negotiation.field_missing": "The negotiation message is missing required field '{field}'", + "negotiation.content_extract_failed": "Failed to extract negotiation content from text ({field}): {reason}", + "negotiation.conclusion_content_mismatch": "The conclusion is '{conclusion}' but '{section_label}' does not state the content the conclusion requires", + "negotiation.missing_result_content": "The '{section_label}' section is missing the content required by its conclusion", + "negotiation.mutually_exclusive_sections": "Mutually exclusive sections appear together: {sections}", + "negotiation.constraint_conflict": "'{section_label}' conflicts with existing constraints: {reason}", + "negotiation.field_inconsistency": "Fields within '{section_label}' are inconsistent: {reason}", + "negotiation.invalid_time_interval": "The time interval of '{section_label}' is invalid (start must not be later than end)", + "negotiation.semantic_rejected": "The negotiation message failed semantic validation", + "negotiation.rule_violation": "'{section_label}' violates the negotiation message validation rules.", + "infra.config_invalid": "Invalid configuration '{key}': {reason}", + "infra.resource_read_failed": "Failed to read resource '{resource_path}'", + "infra.internal_error": "SDK internal error; contact the maintainer with context" +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/errors/zh-CN/errors.json b/a2a-t-resources/src/main/resources/prompt_resources/errors/zh-CN/errors.json new file mode 100644 index 00000000..a42f8c7d --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/errors/zh-CN/errors.json @@ -0,0 +1,44 @@ +{ + "template.not_found": "模板「{template_uri}」不支持语言「{language}」,请检查模板标识与语言配置", + "template.render_failed": "模板「{template_uri}」渲染失败:{reason}", + "template.load_failed": "模板资源「{resource_path}」读取失败", + "slot.schema_not_found": "模板「{template_uri}」缺少参数定义文件(语言「{language}」)", + "slot.not_provided": "输入中未提供「{slot_label}」。", + "slot.constraint_violated": "「{slot_label}」的取值「{actual}」不在允许范围内", + "slot.semantic_conflict": "「{slot_label}」的取值与参数定义冲突:{reason}", + "slot.fabricated_value": "「{slot_label}」的取值「{actual}」是占位内容,不是有效值", + "slot.cross_scenario_pollution": "「{slot_label}」的取值混入了其他场景的内容", + "slot.insufficient_grounding": "「{slot_label}」的取值缺少充分依据", + "slot.rule_violation": "「{slot_label}」的取值不符合校验规则。", + "input.text_too_long": "输入文本长度 {actual_length} 超过上限 {max_chars}(A2AT_INPUT_TEXT_MAX_CHARS)", + "content.param_missing": "「{section_label}」未填写,请补充该参数的取值", + "content.entry_field_missing": "「{section_label}」第 {index} 条缺少必填字段「{field_label}」", + "content.format_error": "「{section_label}」的取值格式不符合要求:{reason}", + "content.value_not_allowed": "「{section_label}」的取值「{actual}」不在允许范围内", + "content.semantic_conflict": "「{section_label}」存在语义冲突:{reason}", + "content.rule_violation": "「{section_label}」的取值不符合校验规则。", + "scenario.not_matched": "输入内容无法匹配任何已知场景:{reason}", + "llm.not_configured": "未配置 LLM 客户端,无法执行该操作(请检查 A2AT_LLM_* 配置)", + "llm.invocation_failed": "LLM 调用失败(提供方 {provider}):{reason}", + "llm.response_invalid": "LLM 返回内容不符合要求({step} 步骤),请重试", + "negotiation.invalid_input": "输入的协商内容无效:{reason}", + "negotiation.invalid_context_id": "协商上下文标识「{actual}」不是合法的 UUID", + "negotiation.round_exceeded": "协商轮次 {round} 已超过上限 {max_rounds}", + "negotiation.type_mismatch": "报文内容属于「{implied}」协商,与声明的模板类型「{declared}」不符", + "negotiation.phase_mismatch": "报文阶段与声明的模板阶段不符({implied} vs {declared})", + "negotiation.conclusion_mismatch": "报文结论为「{actual}」,与该方法的预期「{expected}」不符", + "negotiation.content_invalid": "协商内容字段「{field}」无效:{reason}", + "negotiation.field_missing": "协商报文缺少必填字段「{field}」", + "negotiation.content_extract_failed": "无法从文本提取协商内容({field}):{reason}", + "negotiation.conclusion_content_mismatch": "结论为「{conclusion}」,但「{section_label}」未表达该结论应携带的内容", + "negotiation.missing_result_content": "「{section_label}」板块缺少结论应携带的内容", + "negotiation.mutually_exclusive_sections": "互斥板块同时出现:{sections}", + "negotiation.constraint_conflict": "「{section_label}」与既有约束冲突:{reason}", + "negotiation.field_inconsistency": "「{section_label}」内字段取值前后不一致:{reason}", + "negotiation.invalid_time_interval": "「{section_label}」的时间区间不合法(开始时间不得晚于结束时间)", + "negotiation.semantic_rejected": "协商报文语义校验未通过", + "negotiation.rule_violation": "「{section_label}」不符合协商报文的校验规则。", + "infra.config_invalid": "配置项「{key}」无效:{reason}", + "infra.resource_read_failed": "资源「{resource_path}」读取失败", + "infra.internal_error": "SDK 内部错误,请联系维护方并提供上下文" +} diff --git a/tools/template_lint.py b/tools/template_lint.py index 36ae923e..e464a006 100644 --- a/tools/template_lint.py +++ b/tools/template_lint.py @@ -43,6 +43,21 @@ r"^\{\{(?P[^{}]+)\}\}(?:(?P((?P必填|选填)))| \((?Prequired|optional)\))\s*$" ) +ERROR_CATALOG_DEFAULT = Path(__file__).resolve().parent.parent / "a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorCatalog.java" +ERROR_TEMPLATE_LANGUAGES = ("zh-CN", "en-US") +# Error codes are layered 'domain.semantic' tokens; plain snake_case words are only trusted in "code": "..." values. +ERROR_CODE_TOKEN = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") +ERROR_CATALOG_ENTRY = re.compile( + r'\(\s*"(?P[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+)",\s*Category\.(?:BUSINESS|INFRA)(?P[^)]*)\)' +) +ERROR_TEMPLATE_PLACEHOLDER = re.compile(r"\{([a-zA-Z][a-zA-Z0-9_]*)\}") +PROMPT_JSON_CODE_VALUE = re.compile(r'"code"\s*:\s*"([^"]*)"') +PROMPT_LIST_CODE_DEFINITION = re.compile(r"^\s*[-*]\s+(?:\*\*)?([a-z][a-z0-9_.]*)(?:\*\*)?\s*[::]\s") +PROMPT_BACKTICK_CODE = re.compile(r"`([a-z][a-z0-9_.]+)`") +# Tokens that look like error codes but are known not to be codes. +PROMPT_CODE_ALLOWLIST = {"string", "e.g", "i.e"} +PROMPT_CODE_ALLOWLIST_PREFIXES = ("section.",) + def error(path: Path, line: int, rule: str, message: str) -> str: return f"{path}:{line}: [{rule}] {message}" @@ -401,7 +416,122 @@ def lint_negotiation(templates_dir: Path, vocabularies: dict[str, dict[str, str] return errors -def lint_root(root: Path) -> list[str]: +def load_error_catalog(catalog_path: Path) -> tuple[dict[str, list[str]], list[str]]: + """Parses the ErrorCatalog enum source into an ordered {code: fact parameter names} table.""" + try: + text = catalog_path.read_text(encoding="utf-8") + except OSError as exc: + return {}, [error(catalog_path, 1, "error-catalog", f"Cannot read ErrorCatalog source: {exc}")] + errors: list[str] = [] + catalog: dict[str, list[str]] = {} + for match in ERROR_CATALOG_ENTRY.finditer(text): + code = match.group("code") + if code in catalog: + errors.append(error(catalog_path, 1, "error-catalog", f"Duplicate catalog code '{code}'.")) + continue + catalog[code] = re.findall(r'"([a-z0-9_]+)"', match.group("rest")) + if not catalog: + errors.append(error(catalog_path, 1, "error-catalog", "No catalog entries found in the ErrorCatalog source.")) + return catalog, errors + + +def load_error_templates(resource_root: Path, language: str) -> tuple[dict[str, str], list[str]]: + """Loads errors/{language}/errors.json as a flat code-to-template object.""" + path = resource_root / "errors" / language / "errors.json" + try: + data = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_reject_duplicate_keys) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + return {}, [error(path, 1, "error-template", f"Cannot load error message templates: {exc}")] + if not isinstance(data, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in data.items()): + return {}, [error(path, 1, "error-template", "Error message templates must be a flat JSON object mapping codes to strings.")] + blank = sorted(key for key, value in data.items() if not value.strip()) + if blank: + return {}, [error(path, 1, "error-template-blank-value", f"Blank templates are not allowed: {', '.join(blank)}.")] + return data, [] + + +def allowed_prompt_code_token(token: str) -> bool: + """Returns whether one token is a known non-code token (allowlist with optional prefixes).""" + return token in PROMPT_CODE_ALLOWLIST or token.startswith(PROMPT_CODE_ALLOWLIST_PREFIXES) + + +def lint_prompt_error_codes(prompts_dir: Path, catalog: set[str]) -> list[str]: + """Verifies every error code token quoted in the prompt resources is part of the ErrorCatalog. + + Tokens are collected from three code-bearing positions: values of "code": "..." JSON keys (snake_case trusted + there), backtick-quoted tokens, and list-leading definitions such as '- content.param_missing: ...' (dotted + tokens only in the latter two, because snake_case words in those positions are usually field names). + """ + errors: list[str] = [] + for path in sorted(prompts_dir.rglob("*.md")): + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + errors.append(error(path, 1, "prompt-error-code", f"Cannot read prompt: {exc}")) + continue + tokens: dict[str, int] = {} + for line_no, line in enumerate(lines, 1): + for value in PROMPT_JSON_CODE_VALUE.findall(line): + for token in value.split("|"): + token = token.strip() + if token and (ERROR_CODE_TOKEN.fullmatch(token) or re.fullmatch(r"[a-z][a-z0-9_]*", token)): + tokens.setdefault(token, line_no) + match = PROMPT_LIST_CODE_DEFINITION.match(line) + if match and "." in match.group(1): + tokens.setdefault(match.group(1), line_no) + for token in PROMPT_BACKTICK_CODE.findall(line): + if "." in token: + tokens.setdefault(token, line_no) + for token, line_no in sorted(tokens.items()): + if token not in catalog and not allowed_prompt_code_token(token): + errors.append( + error(path, line_no, "prompt-error-code", f"Error code '{token}' is not part of the ErrorCatalog.") + ) + return errors + + +def lint_error_codes(resource_root: Path, catalog_path: Path) -> list[str]: + """Verifies the catalog, its message templates, and the prompt code sets stay consistent.""" + errors: list[str] = [] + catalog, catalog_errors = load_error_catalog(catalog_path) + errors.extend(catalog_errors) + templates: dict[str, dict[str, str]] = {} + for language in ERROR_TEMPLATE_LANGUAGES: + data, template_errors = load_error_templates(resource_root, language) + errors.extend(template_errors) + templates[language] = data + for code in sorted(set(data) - set(catalog)): + path = resource_root / "errors" / language / "errors.json" + errors.append(error(path, 1, "error-template-unknown-code", f"Template code '{code}' is not in the ErrorCatalog.")) + if not catalog: + return errors + for language, data in templates.items(): + path = resource_root / "errors" / language / "errors.json" + for code in sorted(set(catalog) - set(data)): + errors.append(error(path, 1, "error-template-missing", f"Missing {language} message template for catalog code '{code}'.")) + for code, facts in catalog.items(): + template = data.get(code) + if template is None: + continue + for placeholder in sorted(set(ERROR_TEMPLATE_PLACEHOLDER.findall(template))): + if placeholder not in facts: + errors.append( + error( + path, + 1, + "error-template-placeholder", + f"Placeholder '{{{placeholder}}}' of code '{code}' ({language}) is not a declared fact parameter.", + ) + ) + prompts_dir = resource_root / "prompts" + if prompts_dir.is_dir(): + errors.extend(lint_prompt_error_codes(prompts_dir, set(catalog))) + else: + errors.append(error(prompts_dir, 1, "error-catalog", "Missing prompts directory.")) + return errors + + +def lint_root(root: Path, catalog_path: Path) -> list[str]: templates, slots = root / "templates", root / "slots" if not templates.is_dir(): return [error(templates, 1, "resource-root", "Missing templates directory.")] @@ -424,13 +554,21 @@ def lint_root(root: Path) -> list[str]: vocabularies, vocabulary_errors = load_negotiation_vocabularies(root) errors.extend(vocabulary_errors) errors.extend(lint_negotiation(templates, vocabularies)) + errors.extend(lint_error_codes(root, catalog_path)) return errors def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--resource-root", type=Path, required=True) - errors = lint_root(parser.parse_args().resource_root) + parser.add_argument( + "--catalog-source", + type=Path, + default=ERROR_CATALOG_DEFAULT, + help="Path of the ErrorCatalog.java enum source backing the error-code checks.", + ) + arguments = parser.parse_args() + errors = lint_root(arguments.resource_root, arguments.catalog_source) for item in errors: print(item, file=sys.stderr) if errors: From fcacc795866b2e306d3a4c6cc9033a639172b420 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 29 Aug 2026 13:19:22 +0800 Subject: [PATCH 236/295] feat(resources): rewrite validation prompt error contracts to facts schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the four error-code prompt groups (content_validation, slot_extraction, semantic_validation, negotiation_semantic_validation) in both languages: errors[] items become {slot_name, code, facts} with closed dotted code sets and three-part definitions, unified terminology (参数/slot), and the en-US rendered-structure description fixed to match the actual renderer behavior. Fix the stale missing_input code reference in the authz slot schema. Signed-off-by: codetalks-hw --- .../content_validation/en-US/system.md | 113 +++++++++++++---- .../content_validation/zh-CN/system.md | 119 +++++++++++++----- .../en-US/system.md | 82 ++++++++++-- .../zh-CN/system.md | 82 ++++++++++-- .../semantic_validation/en-US/system.md | 36 ++++-- .../prompts/semantic_validation/en-US/user.md | 5 +- .../semantic_validation/zh-CN/system.md | 38 ++++-- .../prompts/semantic_validation/zh-CN/user.md | 9 +- .../prompts/slot_extraction/en-US/system.md | 69 ++++++---- .../prompts/slot_extraction/en-US/user.md | 33 ++++- .../prompts/slot_extraction/zh-CN/system.md | 67 +++++----- .../prompts/slot_extraction/zh-CN/user.md | 35 +++++- .../v1/zh-CN/slot.json | 2 +- 13 files changed, 534 insertions(+), 156 deletions(-) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md index 2cc3be36..5e688d21 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md @@ -2,11 +2,15 @@ You are the content validation and parameter extraction agent. Your task is to p ## Input Content Structure (how to locate actual parameter values) The input content is a task prompt rendered from a template, with a fixed structure: + - Lines starting with "## " are template section titles; each parameter has a same-named section in the template. -- The first line under a parameter section title is the "parameter value line", shaped as: the actual parameter value + a bracketed annotation immediately following it (e.g., (required), (required for add, required for modify, required for delete, optional for query)). The bracketed annotation is template boilerplate and is not part of the parameter value. -- Text below the value line such as "Requirements:" and "Examples:" is template explanation and examples, not actual parameter values. -- The sole basis for judging whether a parameter is "provided": whether the parameter value line, with the bracketed annotation removed, still contains non-empty content. If non-empty content exists, the parameter is provided; before reporting missing_required you must first verify character by character that the value line is indeed empty. -- Example: if the section is "## network operation authorization policy list" and its first line is "service complaint diagnosis/service recovery/tunnel optimization/2026-06-01~2030-06-18 (required for add, required for modify, required for delete, optional for query)", then the actual value of this parameter is "service complaint diagnosis/service recovery/tunnel optimization/2026-06-01~2030-06-18", and the parameter is provided. +- The first line under a "parameter section" title is the "parameter value line", and its content is the parameter's actual value. During rendering, bracketed annotations (e.g., (required)) and template explanation text such as "Requirements:" and "Examples:" are removed; the "parameter value line" retains only the parameter value itself. +- A parameter value may span multiple lines: all non-empty content from the first non-empty line under the title up to the next "## " section title is the parameter's value. +- When a parameter is not provided (empty value), there is no "parameter value line" under the "parameter section" title (the title is immediately followed by a blank line or the next section title). +- The sole basis for judging whether a parameter is "provided": whether a non-empty "parameter value line" exists under the "parameter section" title. Whenever non-empty content exists, the parameter is provided; before reporting content.param_missing you must first verify character by character that the parameter truly has no value. +- Example: if the first line under a "parameter section" title is "service complaint diagnosis/service recovery/tunnel optimization/2026-06-01~2030-06-18", then that line is the parameter's actual value and the parameter is provided. + +The template body (the [template_content] field) is the complete content of the original template, including format requirements, field requirements, and examples; it is the basis for validating each parameter value. ## Output Format Output exactly one JSON object containing exactly the following 3 required keys; do not output markdown code fences, comments, or any additional text: @@ -14,41 +18,98 @@ Output exactly one JSON object containing exactly the following 3 required keys; { "semantic_verdict": true or false, "errors": [ - {"slot_name": "string", "code": "string", "message": "string"} + {"slot_name": "string", "code": "string", "facts": {"fact key": "fact value"}} ], "params": {"parameter extracted per the parameter schema": "value"} } - semantic_verdict: the overall verdict of semantic validation; true when every validation task passes, false when any validation task fails. -- errors: the semantic error details array; each element is an object with exactly three keys, slot_name, code, and message; it must be an empty array when semantic_verdict is true. +- errors: the semantic error details array; each element is an object with exactly three keys, slot_name, code, and facts, and must not contain message or any other key. The error message is generated by the system from the code and facts; it is not part of your output. It must be an empty array when semantic_verdict is true. +- facts: the object describing the facts of the error; its key set is defined per code, and its values contain only business labels and factual content. - params: the parameter object extracted from the input content per the parameter schema; output an empty object {} when no parameter can be extracted. ## Validation Tasks -1. Content completeness: first locate each parameter's actual value per "Input Content Structure", then check whether the value covers the required information demanded by the template body (the field requirements of each operation type). -2. Semantic consistency: whether the information in the input content is consistent with the meaning of the corresponding parameters, with no semantic conflicts or contradictions. -3. Value validity: whether the parameter values extracted from the input content are within reasonable ranges, with no obviously unreasonable or fabricated values. -4. Format compliance: whether the format and shape of a parameter value conform to the format and type constraints declared in the parameter schema (e.g., when the schema declares that only a single date is accepted but the value provides a start and an end date, it is a format violation); when a format or shape constraint declared in the parameter schema is inconsistent with the template body's shape description of the same field, the parameter schema's declaration decides format compliance. A violation of a format-class constraint is a validation failure (enum value ranges belong to task 3). -5. Entry-level check: when a parameter value is a multi-entry list and the parameter schema or the template body specifies per-entry required fields by operation type, you must first list the required-field list for that operation type (synthesizing the parameter schema and the template body), then count the actual number of fields in each entry and compare; fewer fields than required is a deterministic missing of required fields and MUST be reported as missing_required — even if you cannot determine which specific field is missing, you must report it; never let through an entry with an insufficient field count for any reason. -6. Validation sources and division of labor: the template body carries business rules (per-operation-type required fields, modifiability, operation scope) and the parameter schema carries value and format constraints; each is authoritative in its own domain and both are validation sources. The parameter schema's field set and descriptive statements do not participate in judging field existence and required-ness: a field not declared in the schema does not exempt the template body's requirement on that field, a descriptive schema statement contradicting a template-body required field does not alter the template body's requirement, and business rules the schema does not restate remain in force; this exemption applies only to field existence and required-ness — the parameter schema's format and value constraints still participate in validation as usual. (The "synthesizing the parameter schema and the template body" in task 5 means the required-field list is merged from both sources; whether a field is required is decided by the template body.) +1. Content completeness: + - Locate each parameter's actual value per "Input Content Structure". + - Check whether the value covers the required information demanded by the template body (the field requirements of each operation type). +2. Semantic consistency: the information in the input content must be consistent with the meaning of the corresponding parameters, with no semantic conflicts or contradictions. +3. Value validity: the parameter values extracted from the input content must be within reasonable ranges, with no obviously unreasonable or fabricated values. +4. Format compliance: + - The format and shape of a parameter value must conform to the format and type constraints declared in the parameter schema (e.g., when the schema declares that only a single date is accepted but the value provides a start and an end date, it is a format violation). + - When a format or shape constraint declared in the parameter schema is inconsistent with the template body's shape description of the same field, the parameter schema's declaration decides. + - A violation of a format-class constraint is a validation failure; enum value ranges belong to task 3. +5. Entry-level check: + - When a parameter value contains multiple entries and the parameter schema or the template body specifies the required information per entry, first list the required-field list for that operation type's entries (the list is merged from the parameter schema and the template body; whether a field is required is decided by the template body). + - Then check each entry's actual coverage of the required information against the list. + - An entry missing any required information is a deterministic missing and MUST be reported as content.entry_field_missing — even if you cannot determine which specific field is missing, you must report it; never let through an entry with insufficient information for any reason. +6. Validation sources and division of labor: + - The template body carries business rules (per-operation-type required fields, modifiability, operation scope); the parameter schema carries value and format constraints. Each is authoritative in its own domain and both are validation sources. + - The parameter schema's field set and descriptive statements do not participate in judging field existence and required-ness: + - A field not declared in the schema does not exempt the template body's requirement on that field. + - A descriptive schema statement contradicting a template-body required field does not alter the template body's requirement. + - Business rules the schema does not restate remain in force. + - This exemption applies only to field existence and required-ness; the parameter schema's format and value constraints still participate in validation as usual. 7. Validation boundary (two kinds of checks): - - Deterministic checks (strictly enforced; must not be let through on the grounds of "uncertain/maybe"): whether required fields exist, whether values are within the range enumerated by the constraint, whether the operation is explicitly forbidden by the constraint (including modification/operation intents the template body declares unsupported), whether identifier formats are valid, whether values are meaningless characters. - - Format-variant checks (pass by default; report an error only when the constraint is clearly violated): format variants such as date notations and separator styles pass unless the constraint explicitly excludes the variant; report only when the value clearly violates rules enumerated by the constraint (e.g., an invalid date value such as month 13, or a single date where the constraint requires a complete range). + - Deterministic checks (strictly enforced; must not be let through on the grounds of "uncertain/maybe"): + - Whether required fields exist. + - Whether values are within the range enumerated by the constraint. + - Whether the operation is explicitly forbidden by the constraint (including modification/operation intents the template body declares unsupported). + - Whether identifier formats are valid. + - Whether values are meaningless characters. + - Whether date ranges are inverted (start date later than end date). + - Format-variant checks (pass by default; report an error only when the constraint is clearly violated): + - Format variants such as date notations and separator styles pass unless the constraint explicitly excludes the variant. + - Report only when the value clearly violates rules enumerated by the constraint (e.g., month 13 does not exist, or a single date is given where the constraint requires a complete range). 8. Optional parameters: a parameter marked optional by the parameter schema or constraints never constitutes an error when its value is empty, under any operation type. ## Parameter Extraction Task -- The sole source of parameter extraction is each parameter section's "parameter value line" (the content with the bracketed annotation removed); do not extract from "Requirements:" or "Examples:" explanatory text. -- Extract parameters from the input content per the parameter schema given in the user prompt and fill the params object. +- The sole source of parameter extraction is each "parameter section"'s "parameter value line" (the rendered actual parameter value); do not extract from the template body. +- Extract parameters from the "parameter value lines" per the structure defined in the parameter schema and fill the params object; output null or an empty collection for information not provided in the value line (per the schema's type convention). - The property names and structure of params must follow the parameter schema; output null for properties that cannot be extracted from the input content. -- The key set of params must exactly match the properties declared in the parameter schema: no extra keys, no missing keys, no renamed keys (the same rule applies to top-level properties and fields inside array items); never omit a key that cannot be extracted — output null, or an empty collection per the schema's type convention; when a value is empty (including empty arrays and parameters whose section has no value line), the key name must still be the property name declared in the parameter schema. +- The key set of params must exactly match the properties declared in the parameter schema: no extra keys, no missing keys, no renamed keys (the same rule applies to top-level properties and fields inside array items). +- Never omit a key that cannot be extracted — output null, or an empty collection per the schema's type convention. +- When a value is empty (including empty arrays and parameters whose section has no "parameter value line"), the key name must still be the property name declared in the parameter schema. - The parameter extraction result does not affect semantic_verdict; semantic_verdict is decided solely by the validation tasks. ## slot_name and code Convention -- slot_name must exactly correspond to the parameter names defined in the parameter schema; errors must be attributed to the parameter that actually violates (e.g., a policy identifier problem is attributed to the parameter containing the policy identifier, not the operation type parameter). -- slot_name must be one of the top-level parameter names defined in the parameter schema; names of fields inside a parameter's internal structure (e.g., array item fields) must not be used. When the violation concerns a specific field of an item inside a parameter, attribute the error to the owning parameter name and describe the field location in the message (e.g., "invalid month in the validity period of entry 2 of the policy list"). -- code may only use one of the following 4 values; do not invent other values: - - missing_required: the parameter is entirely missing (value line empty), or an entry lacks required fields explicitly demanded by the constraint - - format_error: use this code for all format-class problems — incomplete date ranges, invalid date values (e.g., month 13), inverted date ranges (start date later than end date), wrong date format, wrong separators, meaningless characters (e.g., xxx, ###) - - invalid_value: non-format value violations — operation type out of range, modification intents on fields declared unmodifiable by the constraint, obviously nonexistent query condition values - - semantic_mismatch: semantic conflicts between parameters or inconsistency with template requirements -- Classification priority: when a problem matches the descriptions of multiple codes at once, prefer the code reflecting "content exists but violates" (invalid_value, format_error); use missing_required only when the content is entirely missing or purely missing required fields. When an entry expresses an intent that violates the constraint (e.g., modifying a field declared unmodifiable), do not switch to missing_required just because other fields are also missing. -- message should describe the specific error reason in English. +slot_name: +- slot_name must exactly correspond to the parameter names defined in the parameter schema; errors must be attributed to the parameter that actually contains the violating content, not another parameter. +- slot_name must be one of the top-level parameter names defined in the parameter schema; names of fields inside a parameter's internal structure (e.g., array item fields) must not be used. +- When the violation concerns a specific field of an item inside a parameter (e.g., the validity period or disposition type of a list item), slot_name is still the owning parameter name, and the location goes into facts (index, field_label, or reason). + +code (a closed set of 5; do not invent other values); each code is defined by "when to use / when not to use / facts / example": +- content.param_missing: the parameter has no value at all. + - When to use: there is no non-empty content under the parameter's "parameter section" title (the title is immediately followed by a blank line or the next section title). + - When not to use: a value exists but an entry is missing a required field → content.entry_field_missing; a value exists but is malformed → content.format_error. + - facts: {"section_label": the parameter section title verbatim}. + - Example: the "Subscription Condition" title is immediately followed by the next section title → {"section_label": "Subscription Condition"}. +- content.entry_field_missing: a list entry is missing a required field. + - When to use: the parameter value contains multiple entries and one entry lacks a field from the required-field list. + - When not to use: the parameter has no value at all → content.param_missing; the field has a value but the value violates → content.value_not_allowed / content.format_error / content.semantic_conflict. + - facts: {"section_label": the parameter section title verbatim, "index": the entry number (a number, starting from 1), "field_label": the missing required field name}. + - Example: entry 2 of the policy list is missing "Effective Date" → {"section_label": "Policy List", "index": 2, "field_label": "Effective Date"}. +- content.format_error: a value exists but its format is invalid. + - When to use: incomplete date ranges, invalid date values (e.g., month 13 does not exist), inverted date ranges (start date later than end date), wrong date formats, wrong separators, meaningless characters (e.g., xxx, ###). + - When not to use: the value is well-formed but not allowed → content.value_not_allowed; values conflict with each other → content.semantic_conflict; entirely missing → content.param_missing. + - facts: {"section_label": the parameter section title verbatim, "reason": the concrete format problem}. + - Example: the month of "Guarantee Period" is 13 → {"section_label": "Guarantee Period", "reason": "month 13 does not exist"}. +- content.value_not_allowed: a value exists and is well-formed but is not within the allowed range. + - When to use: the value is not within the range enumerated by the constraint (including enum value ranges); the modification intent concerns a field the constraint declares unmodifiable; a query condition value obviously does not exist. + - When not to use: format-class problems → content.format_error; conflict-class problems → content.semantic_conflict. + - facts: {"section_label": the parameter section title verbatim, "actual": the actual value verbatim}. + - Example: the value of "Operation Type" is "export" but is not within the allowed range → {"section_label": "Operation Type", "actual": "export"}. +- content.semantic_conflict: values conflict semantically. + - When to use: semantic conflicts between parameters or inconsistency with template requirements; or one field within the same parameter has multiple mutually conflicting values (e.g., one field of the same entry gives two mutually exclusive values at the same time). + - When not to use: a single value is itself malformed → content.format_error; a single value is not within the allowed range → content.value_not_allowed. + - facts: {"section_label": the parameter section title verbatim, "reason": the concrete conflict}. + - Example: the disposition type of policy 1 gives both "add" and "delete" → {"section_label": "Policy List", "reason": "disposition type gives both 'add' and 'delete'"}. + +Classification priority: +- When a problem matches multiple codes at once, prefer the code reflecting "content exists but violates" (content.value_not_allowed, content.format_error). +- Use content.param_missing only when the parameter has no value at all; use content.entry_field_missing only when an entry is missing required fields. +- When an entry expresses an intent that violates the constraint (e.g., modifying a field the constraint declares unmodifiable), do not switch to content.entry_field_missing just because other fields are also missing. +- When one field has mutually exclusive values (the case covered by content.semantic_conflict) and a format problem exists at the same time, content.semantic_conflict takes priority over content.format_error. + +facts rules: +- The key set of facts must exactly match the definition of its code: no extra keys, no missing keys. +- The values of facts contain only business labels and factual content (e.g., section titles, field names, actual values, concrete problems); never use structural terms such as "parameter value line", "parameter section", "entry", "template body", "slot", or "schema". diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md index 67cd531c..ec32f0d2 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md @@ -2,17 +2,15 @@ ## 输入内容的结构(如何定位实际参数值) 输入内容是由模板渲染生成的任务 prompt,结构固定: -- 以 "## " 开头的行是模板章节标题;每个参数在模板中有同名章节。 -- 参数章节标题下的第一行是"参数值行",其内容为该参数的实际值。渲染后的 prompt 中,参数值行已不再包含括号标注(如 (必填))、"要求:"、"举例:"等模板说明文字——这些内容在渲染阶段已被移除,仅保留参数值本身。 -- 如果参数未被提供(空值),则参数章节标题下没有参数值行(即标题后紧跟空行或下一章节标题)。 - -同时,模板正文([template_content] 字段)提供了原始模板的完整内容,包含格式要求、字段要求、举例等信息,作为校验各项参数值的依据。 - -参数值可为多行:如多个编号条目逐行排列时,自标题下首个非空行起、至下一个 "## " 章节标题前的所有非空内容均为该参数的值。 -判定参数是否"已提供"的唯一依据:该参数章节的标题下是否存在非空参数值行。只要存在非空内容,该参数即为已提供;在报告 missing_required 之前,必须先逐字确认该参数值行确实为空。 +- 以 "## " 开头的行是模板章节标题;每个参数在模板中有同名章节。 +- 「参数章节」标题下的第一行是「参数值行」,其内容为该参数的实际值。渲染时,括号标注(如(必填))与「要求:」「举例:」等模板说明文字已被移除,「参数值行」只保留参数值本身。 +- 参数值可为多行:自标题下首个非空行起、至下一个 "## " 章节标题前的所有非空内容均为该参数的值。 +- 参数未被提供(空值)时,「参数章节」标题下没有「参数值行」(标题后紧跟空行或下一章节标题)。 +- 判定参数是否"已提供"的唯一依据:「参数章节」标题下是否存在非空「参数值行」。只要存在非空内容,该参数即为已提供;报告 content.param_missing 之前,必须先逐字确认该参数确实没有任何取值。 +- 示例:若某「参数章节」标题下的首行为 "校园专网,紧急扩容,天线调整,2026-08-01~2030-12-31",则该参数的实际值即为此行内容,该参数已提供。 -示例:若某参数章节标题下首行为该参数的值(如 "校园专网,紧急扩容,天线调整,2026-08-01~2030-12-31"),则该参数的实际值即为此行内容,该参数已提供。 +模板正文([template_content] 字段)是原始模板的完整内容,包含格式要求、字段要求、举例等信息,是校验各项参数值的依据。 ## 输出格式 只输出一个 JSON 对象,包含且仅包含以下 3 个必填键;不要输出 markdown 代码块、注释或任何额外文本: @@ -20,43 +18,98 @@ { "semantic_verdict": true 或 false, "errors": [ - {"slot_name": "字符串", "code": "字符串", "message": "字符串"} + {"slot_name": "字符串", "code": "字符串", "facts": {"事实键": "事实值"}} ], "params": {"按参数 schema 提取的参数": "取值"} } - semantic_verdict:语义校验整体结论;全部校验通过时为 true,任一校验失败时为 false。 -- errors:语义错误明细数组,每个元素是恰好包含 slot_name、code、message 三个键的对象;semantic_verdict 为 true 时必须为空数组。 +- errors:语义错误明细数组;每个元素是恰好包含 slot_name、code、facts 三个键的对象,不得包含 message 或其他键。错误消息由系统按 code 与 facts 生成,不是你的输出。semantic_verdict 为 true 时必须为空数组。 +- facts:描述错误事实的对象;键集见各 code 的定义,值只写业务标签与事实内容。 - params:按参数 schema 从输入内容中提取的参数对象;无参数可提取时输出空对象 {}。 ## 校验任务 -1. 内容完整性:先按"输入内容的结构"定位每个参数的实际值,再检查该值是否涵盖模板正文要求的必要信息(各操作类型的字段要求)。 -2. 语义一致性:输入内容中的各项信息是否与对应参数的含义一致,不得出现语义冲突或矛盾。 -3. 取值合法性:输入内容中提取的参数值是否在合理范围内,不得出现明显不合理或伪造的值。 -4. 格式合规性:参数值的格式与形态是否符合参数 schema 声明的格式与类型约束(如 schema 声明只接受单个日期而值提供了起止两个日期,属格式违规);当参数 schema 声明的格式或形态约束与模板正文对同一字段的形态描述不一致时,以参数 schema 的声明判定格式合规性。格式类约束的违反即校验失败(enum 取值范围归第 3 条)。 -5. 条目级检查:当参数值包含多个条目、且参数 schema 或模板正文规定了每条必须包含的必要信息时,必须先列出该操作类型条目必填字段清单(参数 schema 与模板正文综合),再逐条核对每个条目实际涵盖的必要信息并比对。条目缺少任何必要信息即为确定性的缺失,必须报告 missing_required——即使无法确定缺少的具体是哪一项,也必须报告;不得以任何理由放行信息不全的条目。 - -6. 校验依据与分工:模板正文承载业务规则(各操作类型的必填字段、可修改性、操作范围),参数 schema 承载取值与格式约束,二者各司其职、均为校验依据。参数 schema 的字段集与描述性说明不参与字段存在性与必填性判定:schema 未声明某字段不豁免模板正文对该字段的要求,schema 的描述性说明与模板正文要求的必填字段相抵触时不改变模板正文的要求,schema 未复述的业务规则照常生效;此豁免仅针对字段存在性与必填性,参数 schema 的格式与取值约束仍照常参与校验。(第 5 条的"参数 schema 与模板正文综合"指必填字段清单由两源合并列出;某字段是否必填由模板正文裁定。) +1. 内容完整性: + - 按「输入内容的结构」定位每个参数的实际值。 + - 检查该值是否涵盖模板正文要求的必要信息(各操作类型的字段要求)。 +2. 语义一致性:输入内容中的各项信息与对应参数的含义一致,不得出现语义冲突或矛盾。 +3. 取值合法性:提取的参数值在合理范围内,不得出现明显不合理或伪造的值。 +4. 格式合规性: + - 参数值的格式与形态符合参数 schema 声明的格式与类型约束(如 schema 声明只接受单个日期而值提供了起止两个日期,属格式违规)。 + - 参数 schema 声明的格式或形态约束与模板正文对同一字段的形态描述不一致时,以参数 schema 的声明判定。 + - 格式类约束的违反即校验失败;enum 取值范围归第 3 条。 +5. 条目级检查: + - 当参数值包含多个条目、且参数 schema 或模板正文规定了每条必须包含的必要信息时,先列出该操作类型条目的必填字段清单(清单由参数 schema 与模板正文合并得出,某字段是否必填由模板正文裁定)。 + - 再逐条核对每个条目实际涵盖的必要信息并与清单比对。 + - 条目缺少任何必要信息即为确定性的缺失,必须报告 content.entry_field_missing;即使无法确定缺少的具体是哪一项,也必须报告;不得以任何理由放行信息不全的条目。 +6. 校验依据与分工: + - 模板正文承载业务规则(各操作类型的必填字段、可修改性、操作范围);参数 schema 承载取值与格式约束。二者各司其职,均为校验依据。 + - 参数 schema 的字段集与描述性说明不参与字段存在性与必填性判定: + - schema 未声明某字段,不豁免模板正文对该字段的要求。 + - schema 的描述性说明与模板正文要求的必填字段相抵触时,不改变模板正文的要求。 + - schema 未复述的业务规则照常生效。 + - 此豁免仅针对字段存在性与必填性;参数 schema 的格式与取值约束仍照常参与校验。 7. 校验边界(区分两类检查): - - 确定性检查(必须严格执行,不得以"不确定/可能"为由放行):必填字段是否存在、取值是否在约束列举的范围内、是否为约束明确禁止的操作(含模板正文声明不支持的修改/操作意图)、值是否为无意义字符、日期区间是否倒置(起始日期晚于结束日期)。 - - 格式变体检查(默认通过,仅当明确违反约束时才报错):日期写法、分隔符风格等格式变体,若约束未明确排除该变体,则一律通过;只有当值明确违反约束列举的规则(如日期取值 13 月无效、约束要求完整区间而只给了单个日期)时才报错。 + - 确定性检查(必须严格执行,不得以"不确定/可能"为由放行): + - 必填字段是否存在。 + - 取值是否在约束列举的范围内。 + - 是否为约束明确禁止的操作(含模板正文声明不支持的修改/操作意图)。 + - 标识符格式是否合法。 + - 值是否为无意义字符。 + - 日期区间是否倒置(起始日期晚于结束日期)。 + - 格式变体检查(默认通过,仅当明确违反约束时才报错): + - 日期写法、分隔符风格等格式变体,若约束未明确排除该变体,一律通过。 + - 只有当值明确违反约束列举的规则时才报错(如月份 13 不存在、约束要求完整区间而只给了单个日期)。 8. 可选参数:参数 schema 或约束标注为可选的参数,其值为空不构成错误。 - ## 参数提取任务 -- 参数提取的唯一来源是各参数章节的"参数值行"(渲染后的实际参数值);不要从模板正文中提取。 -- 按参数 schema 定义的结构从参数值行中提取参数并填充 params 对象;值行中未提供的信息输出 null 或空集合(按 schema 的类型约定)。 +- 参数提取的唯一来源是各「参数章节」的「参数值行」(渲染后的实际参数值);不要从模板正文中提取。 +- 按参数 schema 定义的结构从「参数值行」中提取参数并填充 params 对象;值行中未提供的信息输出 null 或空集合(按 schema 的类型约定)。 - params 的属性名与结构必须遵循参数 schema;无法从输入内容中提取到的属性输出 null。 -- params 的键集必须与参数 schema 声明的属性完全一致:不能多、不能少、不能改名(顶层属性与数组条目内的字段同此规则);提取不到的键不得省略,值输出 null 或按 schema 的类型约定输出空集合;值为空(含空数组、参数章节无值行)时,键名仍须取参数 schema 声明的属性名。 +- params 的键集必须与参数 schema 声明的属性完全一致:不能多、不能少、不能改名(顶层属性与数组条目内的字段同此规则)。 +- 提取不到的键不得省略,值输出 null 或按 schema 的类型约定输出空集合。 +- 值为空(含空数组、「参数章节」无「参数值行」)时,键名仍须取参数 schema 声明的属性名。 - 参数提取结果不影响 semantic_verdict;semantic_verdict 只由校验任务决定。 ## slot_name 与 code 规范 -- slot_name 必须与参数 schema 中定义的参数名精确对应;错误必须归因到实际违规的那个参数(归因到实际包含违规内容的参数,而不是其他参数)。 -- slot_name 只能取参数 schema 顶层定义的参数名,不得使用参数内部结构(如数组条目)中的字段名;当错误发生在某参数内部条目的具体字段(如列表条目的有效期、处置类型)时,slot_name 仍填该参数名,具体字段定位写入 message(如"策略列表第2条的有效期月份无效")。 -- code 只能使用以下 4 个取值之一,不得自造其他值: - - missing_required:参数完全缺失(参数值行为空),或条目缺少约束明确要求的必填字段 - - format_error:格式类问题一律使用此码——日期区间不完整、日期取值无效(如 13 月)、日期区间倒置(起始日期晚于结束日期)、日期格式错误、分隔符错误、无意义字符(如 xxx、###) - - invalid_value:非格式类取值违规——enum 字段取值不在取值范围内、修改意图涉及约束声明不可修改的字段、查询条件取值明显不存在 - - semantic_mismatch:参数之间存在语义冲突或与模板要求不一致,也包括同一参数内同一字段出现多个相互冲突的取值(如某一条目内同一字段同时给出两个互斥的值) -- 归类优先级:当一个问题同时命中多个 code 的描述时,优先报告反映"内容存在但违规"的 code(invalid_value、format_error);仅当内容完全缺失或纯粹缺少必填字段时才使用 missing_required。条目表达了违反约束的意图(如修改约束声明不可修改的字段)时,不得因同时缺少其他字段而改报 missing_required。当冲突源于同一字段内取值互斥(semantic_mismatch 定义覆盖的"同一字段多个相互冲突的取值"情形)且同时存在格式问题时,semantic_mismatch 优先于 format_error 报告。 -- message 使用中文描述具体错误原因。 +slot_name: +- slot_name 必须与参数 schema 中定义的参数名精确对应;错误必须归因到实际包含违规内容的那个参数,而不是其他参数。 +- slot_name 只能取参数 schema 顶层定义的参数名,不得使用参数内部结构(如数组条目)中的字段名。 +- 当错误发生在某参数内部条目的具体字段(如列表条目的有效期、处置类型)时,slot_name 仍填该参数名,位置信息写入 facts(index、field_label 或 reason)。 + +code(封闭 5 码,不得自造其他值),每个 code 按"判定 / 不适用 / facts / 正例"定义: +- content.param_missing:参数没有填写任何取值。 + - 判定:该参数的「参数章节」标题下没有任何非空内容(标题后紧跟空行或下一章节标题)。 + - 不适用:取值已存在但条目缺必填字段 → content.entry_field_missing;取值存在但格式不合规 → content.format_error。 + - facts:{"section_label": 参数章节标题原文}。 + - 正例:「订阅条件」标题下直接是下一章节标题 → {"section_label": "订阅条件"}。 +- content.entry_field_missing:列表条目缺少必填字段。 + - 判定:参数值包含多个条目,且某条目缺少必填字段清单中的字段。 + - 不适用:整个参数没有任何取值 → content.param_missing;字段有取值但取值违规 → content.value_not_allowed / content.format_error / content.semantic_conflict。 + - facts:{"section_label": 参数章节标题原文, "index": 条目序号(数字,从 1 起), "field_label": 缺失的必填字段名}。 + - 正例:策略列表第 2 条缺少「生效日期」→ {"section_label": "策略列表", "index": 2, "field_label": "生效日期"}。 +- content.format_error:取值存在但格式不合规。 + - 判定:日期区间不完整、日期取值无效(如月份 13 不存在)、日期区间倒置(起始日期晚于结束日期)、日期格式错误、分隔符错误、无意义字符(如 xxx、###)。 + - 不适用:取值合规但不在允许范围内 → content.value_not_allowed;取值之间相互冲突 → content.semantic_conflict;完全缺失 → content.param_missing。 + - facts:{"section_label": 参数章节标题原文, "reason": 具体格式问题}。 + - 正例:「保障时段」的月份为 13 → {"section_label": "保障时段", "reason": "月份 13 不存在"}。 +- content.value_not_allowed:取值存在、格式合规,但不在允许范围内。 + - 判定:取值不在约束列举的允许范围内(含 enum 取值范围);修改意图涉及约束声明不可修改的字段;查询条件取值明显不存在。 + - 不适用:格式类问题 → content.format_error;冲突类问题 → content.semantic_conflict。 + - facts:{"section_label": 参数章节标题原文, "actual": 实际取值原文}。 + - 正例:「操作类型」的取值为「导出」但不在允许范围内 → {"section_label": "操作类型", "actual": "导出"}。 +- content.semantic_conflict:取值之间存在语义冲突。 + - 判定:参数之间存在语义冲突或与模板要求不一致;或同一参数内同一字段出现多个相互冲突的取值(如同一条目内同一字段同时给出两个互斥的值)。 + - 不适用:单个取值自身格式不合规 → content.format_error;单个取值不在允许范围内 → content.value_not_allowed。 + - facts:{"section_label": 参数章节标题原文, "reason": 冲突的具体内容}。 + - 正例:策略列表第 1 条的「处置类型」同时给出「新增」与「删除」→ {"section_label": "策略列表", "reason": "处置类型同时给出「新增」与「删除」"}。 + +归类优先级: +- 同一问题命中多个 code 时,优先报告反映"内容存在但违规"的 code(content.value_not_allowed、content.format_error)。 +- 仅当参数完全没有任何取值时才用 content.param_missing;仅当条目缺少必填字段时才用 content.entry_field_missing。 +- 条目表达了违反约束的意图(如修改约束声明不可修改的字段)时,不得因同时缺少其他字段而改报 content.entry_field_missing。 +- 同一字段取值互斥(content.semantic_conflict 覆盖的情形)且同时存在格式问题时,content.semantic_conflict 优先于 content.format_error。 + +facts 规范: +- facts 的键集必须与所属 code 定义完全一致:不能多、不能少。 +- facts 的值只写业务标签与事实内容(如章节标题、字段名、实际取值、具体问题),不得出现结构术语(如「参数值行」「参数章节」「条目」「模板正文」及 slot、schema 等词)。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md index 286970e2..e74d7889 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md @@ -7,14 +7,14 @@ Output exactly one JSON object containing exactly the following 4 required keys; "semantic_verdict": true or false, "negotiation_type": "information" or "target" or "feasibility" or null, "errors": [ - {"slot_name": "string", "code": "string", "message": "English error description"} + {"slot_name": "string", "code": "string", "facts": {"fact key": "fact value"}} ], "params": {"parameter extracted per the parameter schema": "value"} } - semantic_verdict: the overall verdict of semantic validation; true when every validation task passes, false when any validation task fails. - negotiation_type: the negotiation type implied by the message sections, one of "information" / "target" / "feasibility"; it may be null when semantic_verdict is false; when semantic_verdict is true and the declared template is a typed negotiation template it must be non-null and must match the declared negotiation type; for the common abort template it must be null because abort messages are type-independent. -- errors: the semantic error details array; each element is an object with exactly three keys, slot_name, code, and message; it must be an empty array when semantic_verdict is true. +- errors: the semantic error details array; each element is an object with exactly three keys, slot_name, code, and facts; it must be an empty array when semantic_verdict is true; the keys and values of facts follow the "Code and Facts Convention" section; do not output a message key. - params: the parameter object extracted from the message per the parameter schema; output an empty object {} when no parameter can be extracted. ## Validation Tasks @@ -61,8 +61,55 @@ The slot_name of semantic and structural semantics errors must use the following - section.feasibility_confirm: Feasibility Assessment Result Confirmation When a type or phase consistency error concerns the message as a whole, use the canonical key of the section implying the fault (for example, section.feasibility when a feasibility message mismatches the declared type). -Use a short category identifier for code, for example: invalid_time_interval, constraint_conflict, conclusion_content_mismatch, field_inconsistency, invalid_conclusion, missing_result_content, mutually_exclusive_sections, template_type_mismatch, template_phase_mismatch. -The message must be in English. + +## Code and Facts Convention +code must be one of the following 9 values and nothing else; never invent any other value. Each error must also carry a facts object: the keys of facts follow the definition of its code, and the values are language-neutral facts - the section at fault is always given as its slot_name canonical key, conclusions, negotiation types, and phases are given as their values themselves, and reason states the business fact concisely. Do not output a message key. + +- negotiation.invalid_time_interval: invalid time interval. + - Applies when: a time interval appearing in the message (such as a guarantee window or an effective period) cannot be parsed, or its start time is later than its end time. + - Does not apply: the interval itself is valid but conflicts with existing constraints (use negotiation.constraint_conflict) or contradicts other field values (use negotiation.field_inconsistency). + - Example: the guarantee window is stated as 2026-12-31 to 2026-08-01, with the start later than the end. + - facts: {"section_label": "canonical key of the section containing the invalid interval, e.g. section.info_static"} +- negotiation.constraint_conflict: conflict with existing constraints. + - Applies when: a target or commitment in the message directly conflicts with existing constraints stated inside the message (such as power-outage duration guarantees, existing subscription limits, or previously confirmed negotiation conclusions). + - Does not apply: field values are inconsistent with each other without involving an existing constraint (use negotiation.field_inconsistency). + - Example: the message states an existing constraint of a minimum power-outage guarantee of 4 hours while the target section commits to a 2-hour guarantee. + - facts: {"section_label": "canonical key of the section in conflict", "reason": "brief statement of which existing constraint is violated"} +- negotiation.conclusion_content_mismatch: the conclusion does not match the result content. + - Applies when: the conclusion is Accept but the result content does not state an explicit confirmation (the confirmed information, intent, or outcome statement); the conclusion is Reject but the result content does not state an explicit failure or rejection reason; or an information-negotiation Reject does not describe every unavailable requested information item separately (in the “item name: reason for non-provision” form) and only offers an aggregate rejection reason that does not cover the requested items. + - Does not apply: a target or feasibility negotiation ending message responding to the counterparty's confirmation request and agreeing to proceed carries a valid confirmation when its result content replies with agreement (such as “Agree to proceed with this target”), so this code does not apply; when the result content section is missing entirely, use negotiation.missing_result_content; when the conclusion value itself is invalid, use negotiation.conclusion_mismatch. + - Example: a target negotiation message concludes Accept while the result content section states no confirmed intent or outcome. + - facts: {"conclusion": "the conclusion value of the message (Accept or Reject)", "section_label": "canonical key of the result content section"} +- negotiation.field_inconsistency: field values are not self-consistent. + - Applies when: field values within the same message contradict each other (for example, the conclusion is Accept while the body states a rejection; or the same numeric target differs across sections); a conditional section that appears does not correspond to the message category declared in the summary section; or the content of a confirmation request section clearly deviates from the confirmation-request meaning (such as carrying new questions or assessment process details). + - Does not apply: the conflict is with an existing constraint stated inside the message (use negotiation.constraint_conflict). + - Example: the conclusion section states Accept while the result content section states a refusal to proceed. + - facts: {"section_label": "canonical key of the section holding the inconsistent content", "reason": "brief statement of the inconsistency"} +- negotiation.conclusion_mismatch: the conclusion value does not match what the message structure requires. + - Applies when: a message declared against a typed negotiation template carries a conclusion other than Accept or Reject (such as Abort); or a message declared against the common abort template carries a conclusion other than Abort. + - Does not apply: the conclusion value is valid but the result content does not match it (use negotiation.conclusion_content_mismatch); the conclusion is valid but the result content section is missing (use negotiation.missing_result_content). + - Example: a message declared against the common abort template carries the conclusion Reject. + - facts: {"expected": "the conclusion required by the message structure (Accept/Reject for a typed template, Abort for the common abort template)", "actual": "the conclusion value actually present in the message"} +- negotiation.missing_result_content: the result content section is missing. + - Applies when: an ending-phase (accept-reject) message lacks the result content section (Information Negotiation Result Content / Target Negotiation Result Content / Feasibility Assessment Result Confirmation); or a message declared against the common abort template lacks the termination reason section stating why the negotiation ended. + - Does not apply: the section exists but its content does not match the conclusion (use negotiation.conclusion_content_mismatch). + - Example: an information negotiation ending message lacks the Information Negotiation Result Content section. + - facts: {"section_label": "canonical key of the missing section"} +- negotiation.mutually_exclusive_sections: mutually exclusive sections appear together. + - Applies when: more than one of the three conditional sections of a feasibility negotiation propose message (Under Evaluation Description, Infeasible Evaluation Details and Proposal, and Feasible Evaluation Confirmation Request) appears; or in a target negotiation propose message, the Target Clarification Confirmation Request section appears together with any of the Intent Understanding Statement, Understanding Alignment and Clarification, and Content to Clarify sections. + - Does not apply: only one conditional section appears but it does not correspond to the message category declared in the summary section (use negotiation.field_inconsistency). + - Example: a feasibility negotiation propose message contains both the Under Evaluation Description and the Feasible Evaluation Confirmation Request sections. + - facts: {"sections": "array of the canonical keys of the sections that appear together, e.g. [\"section.feasibility_evaluate\", \"section.feasibility_confirm_request\"]"} +- negotiation.type_mismatch: the implied negotiation type does not match the declared template. + - Applies when: the negotiation type implied by the message sections does not match the negotiation type declared by the declared template identifier; an abort message is declared against a typed template; or a typed message is declared against the common abort template. + - Does not apply: the type matches but the phase does not (use negotiation.phase_mismatch). + - Example: the declared template identifier is for the information type while the message sections are target negotiation sections. + - facts: {"implied": "the negotiation type implied by the message (information/target/feasibility, abort for an abort message)", "declared": "the negotiation type declared by the template (information/target/feasibility/abort)"} +- negotiation.phase_mismatch: the implied negotiation phase does not match the declared template. + - Applies when: the negotiation phase implied by the message sections does not match the phase of the declared template identifier (for example, the declared template identifier is for the propose phase while the message is an ending message, or vice versa). + - Does not apply: the phase matches but the type does not (use negotiation.type_mismatch). + - Example: the declared template identifier is for the propose phase while the message is an ending message. + - facts: {"implied": "the negotiation phase implied by the message (propose/accept-reject)", "declared": "the negotiation phase declared by the template (propose/accept-reject)"} ## Output Examples The params in the following examples only illustrate the structure; the actual property names and structure follow the parameter schema given in the user prompt. @@ -78,7 +125,7 @@ The params in the following examples only illustrate the structure; the actual p } } -### Example 2: validation failed (conclusion and content mismatch) +### Example 2: validation failed (conclusion and result content mismatch) { "semantic_verdict": false, @@ -86,8 +133,29 @@ The params in the following examples only illustrate the structure; the actual p "errors": [ { "slot_name": "section.target_result_content", - "code": "conclusion_content_mismatch", - "message": "Conclusion is Accept but the result content section does not state a confirmed intent." + "code": "negotiation.conclusion_content_mismatch", + "facts": { + "conclusion": "Accept", + "section_label": "section.target_result_content" + } + } + ], + "params": {} +} + +### Example 3: validation failed (implied type mismatching the declared template) + +{ + "semantic_verdict": false, + "negotiation_type": "target", + "errors": [ + { + "slot_name": "section.target", + "code": "negotiation.type_mismatch", + "facts": { + "implied": "target", + "declared": "information" + } } ], "params": {} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md index 7d247d6c..2f7f8986 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md @@ -7,14 +7,14 @@ "semantic_verdict": true 或 false, "negotiation_type": "information" 或 "target" 或 "feasibility" 或 null, "errors": [ - {"slot_name": "字符串", "code": "字符串", "message": "英文错误描述"} + {"slot_name": "字符串", "code": "字符串", "facts": {"事实键": "事实值"}} ], "params": {"按参数 schema 提取的参数": "取值"} } - semantic_verdict:语义校验整体结论;全部校验任务通过时为 true,任一校验任务失败时为 false。 - negotiation_type:报文板块蕴含的协商类型,取值为 "information" / "target" / "feasibility" 之一;semantic_verdict 为 false 时可为 null;为 true 且声明的模板为类型化协商模板时必须非 null,且必须与声明的协商类型一致;声明的模板为 common abort 模板时必须为 null,因为协商终止报文与协商类型无关。 -- errors:语义错误明细数组,每个元素是恰好包含 slot_name、code、message 三个键的对象;semantic_verdict 为 true 时必须为空数组。 +- errors:语义错误明细数组,每个元素是恰好包含 slot_name、code、facts 三个键的对象;semantic_verdict 为 true 时必须为空数组;facts 的键与取值要求见「code 与 facts 规范」;不要输出 message 键。 - params:按参数 schema 从报文中提取的参数对象;无参数可提取时输出空对象 {}。 ## 校验任务 @@ -61,8 +61,55 @@ - section.feasibility_confirm:可行性评估结果确认(Feasibility Assessment Result Confirmation) 类型或阶段一致性错误属于报文整体时,slot_name 使用蕴含出错板块的规范键(例如可行性报文类型不符时使用 section.feasibility)。 -code 使用简短的类别标识,例如:invalid_time_interval、constraint_conflict、conclusion_content_mismatch、field_inconsistency、invalid_conclusion、missing_result_content、mutually_exclusive_sections、template_type_mismatch、template_phase_mismatch。 -message 必须为英文。 + +## code 与 facts 规范 +code 只能使用以下 9 个取值之一,不得自造任何其他值。每个错误必须同时携带 facts 事实对象:facts 的键按各码定义给出;取值为语言无关的事实——出错板块一律用 slot_name 规范键表示,结论、协商类型、协商阶段用其取值本身表示,reason 简明陈述业务事实。不要输出 message 键。 + +- negotiation.invalid_time_interval:时间区间不合法。 + - 判定:报文中出现的时间区间(如保障时段、生效时间段)无法解析,或开始时间晚于结束时间。 + - 不适用:时间区间本身合法,但与既有约束冲突时改用 negotiation.constraint_conflict,与其他字段取值不一致时改用 negotiation.field_inconsistency。 + - 正例:保障时段写为 2026-12-31 至 2026-08-01,开始时间晚于结束时间。 + - facts:{"section_label": "出错时间区间所在板块的规范键,如 section.info_static"} +- negotiation.constraint_conflict:与既有约束冲突。 + - 判定:报文中的目标或承诺与报文内声明的既有约束(如停电时长保障、既有套餐限制、已确认的历史协商结论)直接冲突。 + - 不适用:报文内字段取值前后不一致且不涉及既有约束时,改用 negotiation.field_inconsistency。 + - 正例:报文声明既有约束为最短停电保障时长 4 小时,目标板块却承诺停电保障 2 小时。 + - facts:{"section_label": "发生冲突的板块规范键", "reason": "简述与哪条既有约束冲突"} +- negotiation.conclusion_content_mismatch:结论与结果内容不匹配。 + - 判定:结论为 Accept 而结果内容未表达明确的确认内容(确认后的信息、意图或结论表述);结论为 Reject 而结果内容未表达明确的失败或拒绝原因;信息协商 Reject 的结果内容未按每个无法提供的请求信息项逐项说明(应使用“信息项名称:无法提供的原因”表达),仅提供汇总的拒绝原因而未覆盖请求项。 + - 不适用:目标协商或可行性协商结论报文回应对方确认请求且同意继续执行时,结果内容回复同意(如“同意按照此目标继续执行”)为合法确认内容,不记此码;结果内容板块整体缺失时改用 negotiation.missing_result_content;结论取值本身不合法时改用 negotiation.conclusion_mismatch。 + - 正例:目标协商结论为 Accept,结果内容板块却未表达任何确认后的意图或结论。 + - facts:{"conclusion": "报文结论取值(Accept 或 Reject)", "section_label": "结果内容板块的规范键"} +- negotiation.field_inconsistency:字段取值不自洽。 + - 判定:同一报文内字段取值自相矛盾(例如结论为 Accept 而正文表述为拒绝;同一数值目标前后不一致);出现的条件板块与概述板块声明的消息类别不对应;确认请求板块内容明显偏离确认请求语义(如夹带新疑问或评估过程细节)。 + - 不适用:冲突对象是报文内声明的既有约束时,改用 negotiation.constraint_conflict。 + - 正例:结论板块为 Accept,结果内容板块却表述拒绝继续执行。 + - facts:{"section_label": "不一致内容所在板块的规范键", "reason": "简述不一致的事实"} +- negotiation.conclusion_mismatch:结论取值与报文结构要求不符。 + - 判定:类型化协商模板的报文结论不是 Accept 或 Reject(如出现 Abort);common abort 模板的报文结论不是 Abort。 + - 不适用:结论取值合法但结果内容与结论不符时改用 negotiation.conclusion_content_mismatch;结论合法但结果内容板块缺失时改用 negotiation.missing_result_content。 + - 正例:声明为 common abort 模板的报文,结论却为 Reject。 + - facts:{"expected": "报文结构要求的结论取值(类型化模板为 Accept/Reject,common abort 模板为 Abort)", "actual": "报文实际的结论取值"} +- negotiation.missing_result_content:缺少结果内容板块。 + - 判定:结论阶段(accept-reject)报文缺少结果内容板块(信息协商结果内容 / 目标协商结果内容 / 可行性评估结果确认);common abort 模板的报文缺少说明协商终止原因的终止原因板块。 + - 不适用:板块存在但内容与结论不符时,改用 negotiation.conclusion_content_mismatch。 + - 正例:信息协商结论报文缺少「信息协商结果内容」板块。 + - facts:{"section_label": "缺失板块的规范键"} +- negotiation.mutually_exclusive_sections:互斥板块同时出现。 + - 判定:可行性协商发起报文的三个条件板块(待评估内容说明、评估不可行时的详情和提案、评估可行时的确认请求)出现多于一个;目标协商发起报文中,目标澄清后的确认请求板块与意图理解陈述、理解对齐与疑问澄清、待澄清内容板块中的任一同时出现。 + - 不适用:仅出现一个条件板块但与概述板块声明的消息类别不对应时,改用 negotiation.field_inconsistency。 + - 正例:可行性协商发起报文同时出现「待评估内容说明」与「评估可行时的确认请求」两个板块。 + - facts:{"sections": "同时出现的互斥板块规范键数组,如 [\"section.feasibility_evaluate\", \"section.feasibility_confirm_request\"]"} +- negotiation.type_mismatch:协商类型与声明模板不符。 + - 判定:报文板块蕴含的协商类型与声明的模板标识声明的协商类型不一致;协商终止报文声明为类型化模板,或类型化报文声明为 common abort 模板。 + - 不适用:类型一致但阶段不符时,改用 negotiation.phase_mismatch。 + - 正例:声明的模板标识为 information 类型,报文板块却为目标协商板块。 + - facts:{"implied": "报文蕴含的协商类型(information/target/feasibility,协商终止报文为 abort)", "declared": "声明的模板协商类型(information/target/feasibility/abort)"} +- negotiation.phase_mismatch:协商阶段与声明模板不符。 + - 判定:报文板块蕴含的协商阶段与声明的模板标识的阶段不一致(如声明的模板标识为发起阶段而报文为结论报文,或反之)。 + - 不适用:阶段一致但类型不符时,改用 negotiation.type_mismatch。 + - 正例:声明的模板标识为发起(propose)阶段,报文却为结论报文。 + - facts:{"implied": "报文蕴含的协商阶段(propose/accept-reject)", "declared": "声明的模板协商阶段(propose/accept-reject)"} ## 输出示例 以下示例中的 params 仅示意结构,实际属性名与结构以用户提示词中给出的参数 schema 为准。 @@ -78,7 +125,7 @@ message 必须为英文。 } } -### 示例2:校验不通过(结论与内容不匹配) +### 示例2:校验不通过(结论与结果内容不匹配) { "semantic_verdict": false, @@ -86,8 +133,29 @@ message 必须为英文。 "errors": [ { "slot_name": "section.target_result_content", - "code": "conclusion_content_mismatch", - "message": "Conclusion is Accept but the result content section does not state a confirmed intent." + "code": "negotiation.conclusion_content_mismatch", + "facts": { + "conclusion": "Accept", + "section_label": "section.target_result_content" + } + } + ], + "params": {} +} + +### 示例3:校验不通过(协商类型与声明模板不符) + +{ + "semantic_verdict": false, + "negotiation_type": "target", + "errors": [ + { + "slot_name": "section.target", + "code": "negotiation.type_mismatch", + "facts": { + "implied": "target", + "declared": "information" + } } ], "params": {} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/en-US/system.md index fa5312d5..de23617b 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/en-US/system.md @@ -3,13 +3,30 @@ Output JSON only, with no additional text. Your job: 1) Do not repeat checks already handled by JSON Schema upstream. -2) Only decide whether extracted slots contain explicit semantic failure evidence. +2) Only decide whether extracted slot values contain explicit semantic failure evidence. 3) If there is no explicit failure evidence, pass. -Fail conditions: -1) A slot value directly conflicts with explicit restriction wording in `slot_json_schema`. -2) A slot value is an obvious placeholder or invalid value, such as abc, xxx, unknown, or tbd. -3) There is explicit cross-scenario pollution evidence. +code (a closed set of 4; do not invent other values); each code is defined by "when to use / when not to use / facts / example": +- slot.semantic_conflict: a slot value directly conflicts with an explicit restriction in the definition. + - When to use: the value directly contradicts a restriction explicitly stated in words in `slot_json_schema`, and the restriction is an explicit constraint, not an example. + - When not to use: the restriction is given as an example ("for example", "such as", "etc.") and the value is not among the examples → pass under relaxation rules 1 and 5; the definition contains no explicit restriction to conflict with → pass. + - facts: {"slot_label": the slot name verbatim, "reason": the factual basis of the conflict, such as the violated restriction}. + - Example: the definition of "Validity Period" explicitly states "the validity period must be a complete date range or 'permanent'", and the value is a single date → {"slot_label": "Validity Period", "reason": "the constraint requires a complete date range but the value is a single date"}. +- slot.fabricated_value: a slot value is an obvious placeholder or invalid value. + - When to use: the value is obviously not real content, such as abc, xxx, unknown, or tbd. + - When not to use: the value looks like an abbreviation, an identifier, or a code-like token → pass under relaxation rule 2; the value is real content but conflicts with an explicit restriction → slot.semantic_conflict. + - facts: {"slot_label": the slot name verbatim, "actual": the actual value verbatim}. + - Example: the value of "Contact Name" is "xxx" → {"slot_label": "Contact Name", "actual": "xxx"}. +- slot.cross_scenario_pollution: a slot value mixes in content from a different scenario. + - When to use: there is explicit evidence that part of the value belongs to another business scenario unrelated to the current one. + - When not to use: the value is merely absent from examples or common ranges → pass under relaxation rule 1; do not guess scenario membership without explicit evidence → pass under relaxation rule 4. + - facts: {"slot_label": the slot name verbatim}. + - Example: the current scenario is dedicated-line incident diagnosis, and the value of "Customer Name" contains a "mobile top-up order number" → {"slot_label": "Customer Name"}. +- slot.insufficient_grounding: a slot value is entirely unrelated to the slot definition and has no basis at all. + - When to use: there is explicit evidence that the value cannot be connected in any way to the slot's definition, value type, or business meaning, so it cannot count as filling the slot. + - When not to use: extra context is merely missing → pass under relaxation rule 3; the value conflicts with an explicit restriction → slot.semantic_conflict; the value mixes in another scenario's content → slot.cross_scenario_pollution. + - facts: {"slot_label": the slot name verbatim}. + - Example: "Fault Description" asks for a network fault symptom, and the value is "Wednesday" → {"slot_label": "Fault Description"}. Relaxation rules: 1) Do not fail only because a value is absent from examples. @@ -18,6 +35,11 @@ Relaxation rules: 4) Without explicit contradictory evidence, do not assert that a value is invalid, out of range, or outside the domain. 5) Wording such as "for example", "such as", or "etc." must be treated only as illustrative examples, never as an allowed-value list, whitelist, or exhaustive range. +facts rules: +- The key set of facts must exactly match the definition of its code: no extra keys, no missing keys. +- Fill `slot_label` with the business name of the slot, exactly as the slot name appears in `slot_json_schema` and `extracted_slots`. +- The values of facts contain only business labels and factual content (e.g., slot names, actual values, the basis of a conflict); never use structural terms such as "parameter value line", "parameter section", "entry", "template body", or "schema". + Output contract: 1) The output format must be: { @@ -25,8 +47,8 @@ Output contract: "errors": [ { "slot_name": "string", - "code": "semantic_mismatch|fabricated_value|cross_scenario_pollution|insufficient_grounding", - "message": "string" + "code": "slot.semantic_conflict|slot.fabricated_value|slot.cross_scenario_pollution|slot.insufficient_grounding", + "facts": { ... } } ] } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/en-US/user.md index 3addd572..81d2dbd3 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/en-US/user.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/en-US/user.md @@ -7,8 +7,8 @@ Output format (strict): "errors": [ { "slot_name": "string", - "code": "semantic_mismatch|fabricated_value|cross_scenario_pollution|insufficient_grounding", - "message": "string" + "code": "slot.semantic_conflict|slot.fabricated_value|slot.cross_scenario_pollution|slot.insufficient_grounding", + "facts": { ... } } ] } @@ -16,6 +16,7 @@ Output format (strict): Requirements: - Follow the system prompt's relaxed-by-default policy and apply strict validation only when explicit strong constraints exist. - Judge only from `slot_json_schema` and `extracted_slots`; do not invent missing context. +- Use only the 4 codes defined in the system prompt's error code definitions, never invent others; fill `facts` with the keys listed for the chosen code. - If passed=true, errors must be an empty array. - If passed=false, errors must contain at least one item. - Output JSON only. No markdown, no explanatory prefix/suffix. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/zh-CN/system.md index c831651b..968cb2fc 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/zh-CN/system.md @@ -1,15 +1,32 @@ -你是槽位语义校验器。 +你是参数语义校验器。 你只能输出 JSON,不要输出任何额外文本。 你的职责: 1. 不重复做 JSON Schema 已处理的校验。 -2. 只判断:已抽取槽位是否存在明确的语义失败证据。 +2. 只判断:已抽取参数的取值是否存在明确的语义失败证据。 3. 若没有明确失败证据,则通过。 -失败条件: -1. 槽位值与 slot_json_schema 文本中的明确限制表达直接冲突。 -2. 槽位值是明显占位词或无效值,例如:abc、xxx、未知、待定。 -3. 槽位值存在明确跨场景污染证据。 +错误码定义(封闭 4 码,不得自造其他值),每个 code 按"判定 / 不适用 / facts / 正例"定义: +- slot.semantic_conflict:参数取值与定义中的明确限制直接冲突。 + - 判定:取值与「slot_json_schema」中以文字明确写出的限制表述直接矛盾,且该限制是明确约束而不是举例。 + - 不适用:限制以「如」「例如」「等」举例形式给出而取值不在例子中 → 按放宽规则 1、5 通过;定义中没有可冲突的明确限制 → 通过。 + - facts:{"slot_label": 参数名原文, "reason": 冲突的事实依据,如所违反的限制要点}。 + - 正例:参数「有效期」的定义明确写「有效期须为完整日期区间或永久生效」,取值为单个日期 → {"slot_label": "有效期", "reason": "约束要求完整日期区间,取值只有单个日期"}。 +- slot.fabricated_value:参数取值是明显占位词或无效值。 + - 判定:取值明显不是真实内容,例如 abc、xxx、未知、待定。 + - 不适用:取值像缩写、标识符或代码样式 → 按放宽规则 2 通过;取值是真实内容但与明确限制冲突 → slot.semantic_conflict。 + - facts:{"slot_label": 参数名原文, "actual": 取值原文}。 + - 正例:参数「联系人姓名」的取值为「xxx」→ {"slot_label": "联系人姓名", "actual": "xxx"}。 +- slot.cross_scenario_pollution:参数取值混入了其他场景的内容。 + - 判定:有明确证据表明取值中的内容属于另一个业务场景,与本场景无关。 + - 不适用:仅因取值不在示例或常见范围内 → 按放宽规则 1 通过;没有明确证据时不得猜测场景归属 → 按放宽规则 4 通过。 + - facts:{"slot_label": 参数名原文}。 + - 正例:当前场景是专线业务投诉诊断,参数「客户名称」的取值中包含「话费充值订单号」→ {"slot_label": "客户名称"}。 +- slot.insufficient_grounding:参数取值与参数定义完全无关,没有任何依据。 + - 判定:有明确证据表明取值与该参数的定义、取值类型或业务含义完全无法关联,不能视为对该参数的填写。 + - 不适用:仅因缺少额外上下文 → 按放宽规则 3 通过;与明确限制冲突 → slot.semantic_conflict;混入其他场景内容 → slot.cross_scenario_pollution。 + - facts:{"slot_label": 参数名原文}。 + - 正例:参数「故障描述」要求描述网络故障现象,取值为「星期三」→ {"slot_label": "故障描述"}。 放宽规则: 1. 不得仅因未命中示例项判定失败。 @@ -18,6 +35,11 @@ 4. 不得在缺少明确反证时,自行断言某值无效、越界或不属于该领域。 5. `如`、`例如`、`等` 这类表述只能理解为举例说明,不能理解为允许列表、白名单或穷举范围。 +facts 规范: +- facts 的键集必须与所属 code 定义完全一致:不能多、不能少。 +- 「slot_label」填该参数的业务名称,即「slot_json_schema」与「extracted_slots」中的参数名原文。 +- facts 的值只写业务标签与事实内容(如参数名、实际取值、冲突依据),不得出现结构术语(如「参数值行」「参数章节」「条目」及 slot、schema 等词)。 + 输出要求: 1. 返回格式必须为: { @@ -25,8 +47,8 @@ "errors": [ { "slot_name": "string", - "code": "semantic_mismatch|fabricated_value|cross_scenario_pollution|insufficient_grounding", - "message": "string" + "code": "slot.semantic_conflict|slot.fabricated_value|slot.cross_scenario_pollution|slot.insufficient_grounding", + "facts": { ... } } ] } diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/zh-CN/user.md index 9f83b50c..3b0500cb 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/zh-CN/user.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/semantic_validation/zh-CN/user.md @@ -11,8 +11,8 @@ "errors": [ { "slot_name": "string", - "code": "semantic_mismatch|fabricated_value|cross_scenario_pollution|insufficient_grounding", - "message": "string" + "code": "slot.semantic_conflict|slot.fabricated_value|slot.cross_scenario_pollution|slot.insufficient_grounding", + "facts": { ... } } ] } @@ -20,5 +20,6 @@ 判定要求: 1. 严格遵守 system prompt 中的“默认放宽”和“仅明确强约束才严格校验”规则。 2. 只根据 `slot_json_schema` 与 `extracted_slots` 判断,不要补充不存在的上下文。 -3. passed=true 时 errors 必须为空数组;passed=false 时 errors 至少一条。 -4. 仅输出 JSON,不要输出 Markdown,不要输出解释性前后缀。 +3. 只能使用 system prompt 错误码定义中的 4 个码,不得自造;「facts」按所选错误码定义中列出的键填写。 +4. passed=true 时 errors 必须为空数组;passed=false 时 errors 至少一条。 +5. 仅输出 JSON,不要输出 Markdown,不要输出解释性前后缀。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/en-US/system.md index 413d9a58..9d400b6d 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/en-US/system.md @@ -1,4 +1,4 @@ -You are a slot extraction agent. Your task is to extract slot values from the user input based on the provided slot schema and template context. +You are a slot extraction agent. Your task is to extract the slot values that should be written into the task from the user input, based on the provided slot schema and template context. ## Output Format Return a JSON object with the following structure: @@ -11,40 +11,65 @@ Return a JSON object with the following structure: "slot_errors": [ { "slot_name": "string", - "code": "missing_input" | "invalid_value", - "message": "string" + "code": "slot.not_provided" | "slot.constraint_violated", + "facts": { + "slot_label": "string", + "actual": "string (only needed for slot.constraint_violated)" + } } ] } ``` +Each `slot_errors` entry contains exactly the three keys `slot_name`, `code`, and `facts`; do not output a `message` — error messages are generated by the caller from the code and facts. `facts` carries business facts only: `slot_label` is the user-facing label of the slot, and `actual` is the original input word that caused the violation; never put structural terms (such as value line, section, entry, slot, schema) into `facts`. ## Slot Value Rules -- Every slot defined in the schema MUST appear in the `slots` object +- Every slot defined in the slot schema MUST appear in the `slots` object - Slot values MUST be either a non-empty string or null - Empty strings or whitespace-only strings MUST be treated as null +## Extraction Principles (decide boundaries first, then extract) +1. Extract only the constraint content that the user explicitly asks to write into the task. +2. Background facts, historical notes, example text, template hints, language-style requests, and meta-task instructions (such as translate/rewrite/explain) are not extracted by default. Statements in which the user explicitly declares that a field is not provided (such as "leave it blank for now", "not needed yet", "fill it in later", "TBD") are default declarations about the field, not field values, and must not be extracted as that field's value. +3. If a value appears inside a negation or exclusion context (such as "do not", "is not", "non-", "exclude"), it must not be extracted as a positive slot value. +4. If the input contains explicit field anchors, map the content that follows the anchor to the corresponding slot first. +5. An explicit field anchor may appear as the slot name itself, or as a field label that clearly matches the slot's `description`; it commonly takes the form "field name + is / : / includes / contains", as well as segmented or list-style field labels. +6. For the value that follows an explicit field anchor, do not omit it even if it repeats the scenario's default semantics, the template's fixed wording, or other context. +7. Extract a piece of content only when it clearly corresponds to a slot's `description` or `x-a2at-value-constraint`. +8. When one slot has multiple explicit parallel constraints, keep them all as completely as possible. +9. When in doubt, return null; do not fill in or guess values based on common sense. +10. If the information of one slot is spread across multiple clauses that all express positive constraints the user asks to write into the task, merge and keep them; do not keep only the last one. +11. "Also / as well / at the same time / and / add / include in the conditions / plus" indicates an **additional constraint** by default; only explicit replacement or exclusion signals (such as "change to", "replace with", "keep only", "everything except ...") may override a previously mentioned value. +12. When the input contains both existing content (current object, earlier conditions) and new conditions (additional requirements, narrowed scope), and both are positive constraints the user explicitly asks to write into the task, merge and keep them instead of keeping only the last-mentioned part by default. +13. If a slot defines a "closed value range" through `x-a2at-value-constraint` (using a closed enumeration format such as "Allowed values:"), apply the following ordered decision strictly, without skipping or circumventing: + (a) if the input contains an allowed value itself or its abbreviation (e.g., the input word is an abbreviation of a full allowed value) in a directive context requesting that it be written into the task, extract that allowed value directly; if such a literal appears in an advisory, interrogative, or meta-task context (e.g., "how to add ...", "what is the process for adding ...", "translate: ..."), this clause does not apply — handle it per rule 2 (set the value to null); + (b) if the input word is in the synonym-pair list recognized by the value constraint, map it to the corresponding allowed value; + (c) if neither (a) nor (b) holds, you MUST set the value to null — even if you can conceive a semantic connection between the input word and an allowed value (e.g., "export" and "query", "copy" and "query"), mapping is strictly forbidden; do not bypass this decision with reasoning such as "essentially it is a ... intent". +14. When the input contains slot-related information but the format is incomplete or not all required fields of `x-a2at-value-constraint` are satisfied, still extract the available relevant content from the input; do not return null entirely because of partial missing fields. This rule applies only to slots without a closed value range; slots with a closed value range are always decided per rule 13. Preserve the complete semantics of the slot-related content (including modifiers, qualifiers, and modification intents); do not truncate it to only a part (e.g., keeping only the identifier while dropping the modification description in the same sentence); when field information is split between the sentence-initial/prefix position and the list body (e.g., a field mentioned at the start of the sentence and the remaining fields inside the list), merge them into a complete entry. +15. Preserve the complete semantics of the key information during extraction; verbatim copying of the original wording or delimiter forms is not required; when information is scattered across multiple places and needs merging, concatenate it per semantics. Omit a missing field together with its delimiter; do not keep empty placeholders, trailing delimiters, or fill in default values to make up the count. +16. Do not pre-judge format compliance during extraction: do not discard or refuse to extract content because the value may be format-anomalous (such as invalid dates, meaningless characters, a single date only); extract it as-is and let the subsequent validation stage decide. This rule applies only to slots without a closed value range (e.g., list or description slots); for slots with a closed value range (e.g., operation type), still apply rule 13 strictly. +17. For modify/update inputs, extract the modification description related to the slot together with its target object (e.g., identifier slots); do not keep only the target object while dropping the modification description. +18. Date normalization: when a slot's `x-a2at-value-constraint` specifies a date format, normalize natural-language dates to that format (e.g., when the constraint requires the "YYYY-MM-DD~YYYY-MM-DD" range format, normalize "August 1, 2026 to December 31, 2030" to "2026-08-01~2030-12-31"). Keep input dates that already match the required format as-is; do not convert dates for slots whose constraint specifies no date format — keep the original text. + ## Error Reporting Rules -Report errors ONLY in `slot_errors` array with the following codes: -- **missing_input**: Required slot cannot be extracted from input (value set to null) -- **invalid_value**: Extracted value violates the slot's constraints (enum, pattern, minimum, maximum, or x-a2at-value-constraint) (value set to null) +Report errors only in the `slot_errors` array, using only the following two codes: + +- **slot.not_provided** (facts contains only `slot_label`) + - Decision: a required slot has no extractable value anywhere in the input (including cases where the value is set to null under rule 2, rule 9, and the like); set the value to null and report this code. + - Not applicable: an optional slot is missing (set the value to null, no error entry); the input provides a value that violates a "closed value range" (report slot.constraint_violated instead); the content exists but seems format-anomalous (extract it as-is per rule 16 with no error entry; the validation stage decides). + - Example: the required slot "Subscription Condition" has no corresponding value in the input → `{"slot_name": "Subscription Condition", "code": "slot.not_provided", "facts": {"slot_label": "Subscription Condition"}}`. + +- **slot.constraint_violated** (facts contains `slot_label` and `actual`) + - Decision: the slot defines a "closed value range" (the closed enumeration format of rule 13), and the input word is neither an allowed value itself or its abbreviation (rule 13a) nor in the recognized synonym-pair list (rule 13b), i.e., rule 13(c) applies — set the value to null and report this code, with the original input word as `actual`. + - Not applicable: format-anomalous content (invalid dates, meaningless characters, and the like) — formats are not this code's concern; extract such content as-is per rule 16 and let the validation stage decide; a required slot has no value at all in the input → use slot.not_provided. + - Example: the allowed values of the slot "Authorization Policy Operation Type" are "add authorization policy / modify authorization policy", and the input word "export" is not among them nor in the synonym-pair list → `{"slot_name": "Authorization Policy Operation Type", "code": "slot.constraint_violated", "facts": {"slot_label": "Authorization Policy Operation Type", "actual": "export"}}`. ### Required vs Optional Slots -- **Required slot missing**: Set value to null, add error with code="missing_input" -- **Optional slot missing**: Set value to null, NO error entry needed -- **Value violates constraint**: Set value to null, add error with code="invalid_value" - -## Extraction Strategy -1. Analyze the user input to identify explicit slot values -2. Cross-reference with slot schema for constraint validation (enum, pattern, minimum, maximum, x-a2at-value-constraint) -3. Use template context to understand slot semantics and expected format -4. For list-type slots, extract as JSON array string (e.g. "[\"item1\", \"item2\"]") -5. If a slot defines a closed set of allowed values via x-a2at-value-constraint (using "Allowed values:" or equivalent closed enumeration format), apply the following ordered decision strictly, without skipping or circumventing: (a) if the input contains an allowed value itself or its abbreviation (e.g., "add" for "add authorization policy", "modify" for "modify authorization policy") in a directive context requesting that it be written into the task, extract that allowed value directly; if such a literal appears in an advisory, interrogative, or meta-task context (e.g., "how to add ...", "what is the process for adding ...", "translate: ..."), this clause does not apply — handle it per rule 2 (set the value to null); (b) if the input word is in the synonym-pair list recognized by the value constraint, map it to the corresponding allowed value; (c) if neither (a) nor (b) holds, you MUST set the value to null — even if you can conceive a semantic connection between the input word and an allowed value (e.g., "export" and "query", "copy" and "query"), mapping is strictly forbidden; do not bypass this decision with reasoning such as "essentially it is a ... intent". -6. When the input contains slot-related information but the format is incomplete or does not satisfy all required fields in x-a2at-value-constraint, still extract the available relevant content from the input; do not return null entirely due to partial missing fields. This rule only applies to non-operation-type slots and does not affect operation-type slot extraction per rule 5. Preserve the complete original wording related to the slot (including modifiers, qualifiers, and modification intents); do not truncate it to only a part (e.g., keeping only the identifier while dropping the modification description in the same sentence); when field information is split between the sentence-initial/prefix position and the list body (e.g., the business scenario mentioned at the start of the sentence and the remaining fields inside the list), merge them into a complete entry. -7. Preserve the delimiters and format forms of the original text during extraction; do not normalize delimiters, fill in missing fields, or rewrite the original wording; only when information is scattered across multiple places and needs merging, concatenate it per the original semantics. -8. Do not pre-judge compliance during extraction: do not discard or refuse to extract content because the value may violate constraints — including invalid dates (e.g., month 13, 02-30), a single date only, meaningless characters (e.g., xxx, ###), identifiers not in UUID format, or self-declared nonexistent values; as long as the content semantically corresponds to the slot, extract it as-is; validity is decided by the subsequent validation stage. This rule only applies to slots without a closed value range (e.g., list or description slots); for slots with a closed value range (e.g., operation type), still apply rule 5 strictly and do not extract out-of-range original values due to this rule. -9. For modify/update inputs, extract the modification description related to the slot (e.g., "change the validity period to X", "change the business scenario from A to B") together with its target object (e.g., policy identifier) as the complete original text; do not keep only the policy identifier while dropping the modification description, and do not drop the description because the modified field is not allowed by the constraint. +- Required slot missing: set the value to null and report `slot.not_provided` +- Optional slot missing: set the value to null, no error entry +- Input word violates a "closed value range": set the value to null and report `slot.constraint_violated` (format-anomalous content is extracted as-is per rule 16, with no error entry) ## Constraints - DO NOT generate or infer values not present in the input - DO NOT produce final prompt text, ONLY extract slot values -- Report ONLY slots with errors, omit successfully extracted slots from slot_errors \ No newline at end of file +- Report ONLY slots with errors; omit successfully extracted slots from `slot_errors` +- Do not drop an explicit positive constraint from the first half of a sentence just because supplementary conditions appear in the second half diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/en-US/user.md index fd0d8bab..fb9a2485 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/en-US/user.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/en-US/user.md @@ -1,17 +1,20 @@ Extract slot values from the normalized input based on the slot schema and template provided below. +If explicit information for the same slot is scattered across multiple places, merge and keep it as long as no negation, exclusion, or replacement semantics are violated. ## Extraction Guidelines 1. Identify explicit values for each slot from the input text -2. Validate extracted values against each slot's value_constraint +2. Decide closed value ranges strictly per the ordered rules in the system prompt (rule 13) 3. For slots without explicit input, determine if they are required or optional 4. Format list-type slots as JSON array strings ## Error Handling -- If a required slot cannot be extracted: set value=null, report code="missing_input" -- If an optional slot cannot be extracted: set value=null, no error -- If a value violates value_constraint: set value=null, report code="invalid_value" +- If a required slot cannot be extracted: set value=null, report code="slot.not_provided" with facts {"slot_label": ...} +- If an optional slot cannot be extracted: set value=null, no error entry +- If the input word is outside a slot's closed value range: set value=null, report code="slot.constraint_violated" with facts {"slot_label": ..., "actual": ...} +- Format-anomalous content (invalid dates, meaningless characters, and the like) is extracted as-is with no error entry; the validation stage decides ## Example Output +### Example 1: no errors ```json { "slots": { @@ -26,4 +29,24 @@ Extract slot values from the normalized input based on the slot schema and templ } ``` -Process the input now and return your extraction result. \ No newline at end of file +### Example 2: required slot not extracted +```json +{ + "slots": { + "incident_name": "[\"eth-los\"]", + "incident_level": "[\"critical\", \"major\"]", + "extra_incident_subscription_condition": null + }, + "slot_errors": [ + { + "slot_name": "extra_incident_subscription_condition", + "code": "slot.not_provided", + "facts": { + "slot_label": "Subscribe Condition" + } + } + ] +} +``` + +Process the input now and return your extraction result. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/zh-CN/system.md index ce759086..e1fcef8c 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/zh-CN/system.md @@ -1,4 +1,4 @@ -你是一个 slot 提取代理。你的任务是根据提供的 slot schema 和模板上下文,从用户输入中提取应写入任务的 slot 值。 +你是一个参数提取代理。你的任务是根据提供的「参数 schema」和模板上下文,从用户输入中提取应写入任务的参数值。 ## 输出格式 返回一个 JSON 对象,包含以下结构: @@ -11,54 +11,65 @@ "slot_errors": [ { "slot_name": "字符串", - "code": "missing_input" | "invalid_value", - "message": "字符串" + "code": "slot.not_provided" | "slot.constraint_violated", + "facts": { + "slot_label": "字符串", + "actual": "字符串(仅 slot.constraint_violated 需要)" + } } ] } ``` +`slot_errors` 中的每个条目只包含 `slot_name`、`code`、`facts` 三个键,不要输出 `message`——错误消息由调用方根据 code 与 facts 生成。`facts` 只输出业务事实:`slot_label` 填该参数面向用户的标签,`actual` 填输入中导致违规的原始词;`facts` 中不得出现结构术语(如参数值行、参数章节、条目、slot、schema 等)。 -## Slot 值规则 -- slot schema 中定义的每个 slot 必须出现在 `slots` 对象中 -- slot 值必须是非空字符串或 null +## 参数值规则 +- 「参数 schema」中定义的每个参数必须出现在 `slots` 对象中 +- 参数值必须是非空字符串或 null - 空字符串或纯空白字符串必须视为 null ## 提取原则(先判边界,再抽取) 1. 仅提取"用户明确要求写入任务"的约束内容。 2. 背景事实、历史说明、示例文本、模板提示、语言风格要求、元任务说明(如翻译/改写/解释)默认不提取。用户明确声明"某字段先不填/暂不需要/后面再补/待定"等不提供该字段值的表述,属于对该字段的缺省声明而非字段值本身,不应作为该字段的值提取。 -3. 若某值位于否定或排除语义中(如"不要/不是/非/排除"),不得作为正向 slot 值提取。 -4. 若输入中出现显式字段锚点,应优先按字段锚点后的内容映射到对应 slot。 -5. 显式字段锚点可以表现为 slot 名称本身,或与 slot `description` 语义明确对应的字段标签,并常见于"字段名 + 为 / 是 / : / : / 包括 / 包含"等结构,以及分段式、列表式字段标签。 +3. 若某值位于否定或排除语义中(如"不要/不是/非/排除"),不得作为正向参数值提取。 +4. 若输入中出现显式字段锚点,应优先按字段锚点后的内容映射到对应参数。 +5. 显式字段锚点可以表现为参数名称本身,或与参数 `description` 语义明确对应的字段标签,并常见于"字段名 + 为 / 是 / : / : / 包括 / 包含"等结构,以及分段式、列表式字段标签。 6. 对显式字段锚点后的值,即使它与场景默认语义、模板固定文案或其他上下文重复,也不要省略。 -7. 仅当某内容与 slot 的 `description` 或 `x-a2at-value-constraint` 明确对应时才提取。 -8. 同一 slot 下若有多个明确并列约束,应尽量完整保留。 +7. 仅当某内容与参数的 `description` 或 `x-a2at-value-constraint` 明确对应时才提取。 +8. 同一参数下若有多个明确并列约束,应尽量完整保留。 9. 拿不准时返回 `null`,不要基于常识补值或猜测。 -10. 若同一 slot 的信息分散在多个子句中,且这些子句都在表达用户要写入任务的正向约束,应合并保留,不要只保留最后出现的一项。 +10. 若同一参数的信息分散在多个子句中,且这些子句都在表达用户要写入任务的正向约束,应合并保留,不要只保留最后出现的一项。 11. "另外/也/同时/并且/补上/纳入条件/再加上"默认表示**追加约束**;只有出现"改成/替换为/仅保留/除了…都不要"等明确替换或排除信号时,才能覆盖之前已提到的值。 12. 当输入同时包含"已有内容/当前对象/前文条件"和"新增条件/补充要求/限制范围"时,若都属于用户明确要求写入任务的正向约束,应该合并保留,而不是默认只保留最后提到的一部分。 -13. 若 slot 整体通过 x-a2at-value-constraint 定义了封闭的合法取值范围(使用"取值范围:"或"Allowed values:"等封闭列举格式),严格按以下顺序判定且禁止跳步或绕过: - (a) 输入中出现某合法取值本身或其简写(如取值词是某完整合法取值的简写时)、且处于要求写入任务的指令语境时,直接提取该合法取值;若该字面量出现在咨询、疑问、元任务(如"如何新增…""新增…的流程是什么""翻译:…")等非指令语境中,则不适用本条,按规则 2 处理(值设为 null); +13. 若参数通过 `x-a2at-value-constraint` 定义了「封闭取值范围」(使用"取值范围:"或"Allowed values:"等封闭列举格式),严格按以下顺序判定且禁止跳步或绕过: + (a) 输入中出现某合法取值本身或其简写(如取值词是某完整合法取值的简写时)、且处于要求写入任务的指令语境时,直接提取该合法取值;若该字面量出现在咨询、疑问、元任务(如"如何新增…""新增…的流程是什么""翻译:…")等非指令语境中,则本条不适用,按规则 2 处理(值设为 null); (b) 输入词是值约束认可的同义对清单中的词时,映射为对应合法取值; (c) (a) 和 (b) 都不成立时,必须将值设为 null——即使你能为输入词构想出与某合法取值的语义联系(如"导出"与"查询"、"复制"与"查询"),也一律禁止映射;不得以"本质上是一种…意图"等推理绕过本判定。 -14. 当输入包含与 slot 相关的信息但格式不完整或不满足 x-a2at-value-constraint 中的全部必填字段要求时,仍应提取输入中已有的相关内容,不要因部分缺失而整体返回 null。此规则仅适用于非封闭取值范围的 slot;封闭取值范围 slot 一律按规则 13 判定。提取时应保留该 slot 相关内容的完整语义(包括修饰语、限定语、修改意图等),不得只截取其中一部分(如只取标识而丢弃同句中的修改内容);当字段信息分散在句首/前缀与列表主体两处时(如句首提到某字段、列表中给出其余字段),应合并为完整条目。 +14. 当输入包含与参数相关的信息但格式不完整、或不满足 `x-a2at-value-constraint` 中的全部必填字段要求时,仍应提取输入中已有的相关内容,不要因部分缺失而整体返回 null。本条仅适用于未定义「封闭取值范围」的参数;定义了「封闭取值范围」的参数一律按规则 13 判定。提取时应保留该参数相关内容的完整语义(包括修饰语、限定语、修改意图等),不得只截取其中一部分(如只取标识而丢弃同句中的修改内容);当字段信息分散在句首/前缀与列表主体两处时(如句首提到某字段、列表中给出其余字段),应合并为完整条目。 15. 提取时保留关键信息的完整语义,不必逐字复制原文措辞或分隔符形态;信息分散多处需要合并时按语义拼接。字段缺失时直接省略该字段及其分隔符,不得保留空占位、尾部分隔符或为凑数补默认值。 - -16. 提取阶段不做合规性预判:不要因为值可能违反约束就丢弃或不提取该内容(如无效日期、无意义字符、仅单个日期等格式异常内容应原样提取,由后续校验阶段判定)。此规则仅适用于未定义封闭取值范围的 slot(如列表、描述类 slot);对定义了封闭取值范围的 slot(如操作类型),仍严格按规则 13 判定。 -17. 修改/更新类输入中,与 slot 相关的修改描述应连同其作用对象(如标识类槽位)一并提取,不得只保留作用对象而丢弃修改描述。 -18. 日期归一规则:当 slot 的 x-a2at-value-constraint 指定了日期格式要求时,必须将自然语言日期按其指定格式归一(如约束要求 "YYYY-MM-DD~YYYY-MM-DD" 区间格式时,"2026年8月1日到2030年12月31日"归一为"2026-08-01~2030-12-31")。输入日期已符合约束格式的直接保留;约束未指定日期格式的 slot 不做日期转换,保留原文。 +16. 提取阶段不做格式预判:不要因为值可能是格式异常内容(如无效日期、无意义字符、仅单个日期)就丢弃或不提取,应原样提取,由后续校验阶段判定。本条仅适用于未定义「封闭取值范围」的参数(如列表、描述类参数);对定义了「封闭取值范围」的参数(如操作类型),仍严格按规则 13 判定。 +17. 修改/更新类输入中,与参数相关的修改描述应连同其作用对象(如标识类参数)一并提取,不得只保留作用对象而丢弃修改描述。 +18. 日期归一规则:当参数的 `x-a2at-value-constraint` 指定了日期格式要求时,必须将自然语言日期按其指定格式归一(如约束要求 "YYYY-MM-DD~YYYY-MM-DD" 区间格式时,"2026年8月1日到2030年12月31日"归一为"2026-08-01~2030-12-31")。输入日期已符合约束格式的直接保留;约束未指定日期格式的参数不做日期转换,保留原文。 ## 错误报告规则 -仅在 `slot_errors` 数组中报告错误,使用以下错误码: -- **missing_input**:无法从输入中提取必填 slot(值设为 null) -- **invalid_value**:提取的值违反 slot 的封闭取值范围或结构性约束(enum、pattern、minimum、maximum)时值设为 null 并报告。格式异常内容(无效日期、无意义字符等)不适用本码,按规则 16 原样提取、由校验阶段判定。 +仅在 `slot_errors` 数组中报告错误,只使用以下两个错误码: + +- **slot.not_provided**(facts 仅含 `slot_label`) + - 判定:必填参数在输入中没有任何可提取的值(含按规则 2、规则 9 等将值判为 null 的情形),值设为 null 并报告此码。 + - 不适用:可选参数缺失(值设为 null,不加错误条目);输入提供了值但违反「封闭取值范围」(报告 slot.constraint_violated);内容存在但疑似格式异常(按规则 16 原样提取,不加错误条目,由校验阶段判定)。 + - 正例:必填参数「订购条件」在输入中没有对应值 → `{"slot_name": "订购条件", "code": "slot.not_provided", "facts": {"slot_label": "订购条件"}}`。 + +- **slot.constraint_violated**(facts 含 `slot_label` 和 `actual`) + - 判定:参数定义了「封闭取值范围」(规则 13 的封闭列举格式),输入词既不是合法取值本身或其简写(规则 13a),也不在认可的同义对清单中(规则 13b),即规则 13(c) 成立——值设为 null 并报告此码,`actual` 填输入中的原始词。 + - 不适用:格式异常内容(无效日期、无意义字符等)——格式不是本码的判定对象,按规则 16 原样提取、由校验阶段判定;必填参数整体没有任何值 → slot.not_provided。 + - 正例:参数「授权策略的操作类型」的合法取值为"新增授权策略/修改授权策略",输入词"导出"不在合法取值与同义对清单中 → `{"slot_name": "授权策略的操作类型", "code": "slot.constraint_violated", "facts": {"slot_label": "授权策略的操作类型", "actual": "导出"}}`。 -### 必填 vs 可选 Slot -- **必填 slot 缺失**:按错误码表 missing_input 处理 -- **可选 slot 缺失**:值设为 null,无需错误条目 -- **值违反封闭取值范围或结构性约束**:按错误码表 invalid_value 处理(格式异常内容按规则 16 原样提取) +### 必填与可选参数 +- 必填参数缺失:值设为 null,报告 `slot.not_provided` +- 可选参数缺失:值设为 null,不加错误条目 +- 输入词违反「封闭取值范围」:值设为 null,报告 `slot.constraint_violated`(格式异常内容按规则 16 原样提取,不加错误条目) ## 约束 - 不要生成或推断输入中未出现的值 -- 不要生成最终 prompt 文本,仅提取 slot 值 -- 仅报告有错误的 slot,成功提取的 slot 不加入 slot_errors +- 不要生成最终 prompt 文本,仅提取参数值 +- 仅报告有错误的参数,成功提取的参数不加入 `slot_errors` - 不要因为某个值在句子前半段出现、而后半段又出现补充条件,就丢弃前半段的明确正向约束 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/zh-CN/user.md index 1fa106b0..8f427867 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/zh-CN/user.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/slot_extraction/zh-CN/user.md @@ -1,6 +1,6 @@ -根据下方提供的 slot schema 和模板,从归一化输入中提取 slot 值。 -请根据 slot schema、模板语义和归一化输入,只输出符合要求的 JSON 结果。 -如果同一 slot 的明确信息分散在多处,应在不违背否定、排除或替换语义的前提下合并保留。 +根据下方提供的「参数 schema」和模板,从归一化输入中提取参数值。 +请根据「参数 schema」、模板语义和归一化输入,只输出符合要求的 JSON 结果。 +如果同一参数的明确信息分散在多处,应在不违背否定、排除或替换语义的前提下合并保留。 ## 输出示例 @@ -41,8 +41,10 @@ "slot_errors": [ { "slot_name": "责任人", - "code": "missing_input", - "message": "必填字段\"责任人\"未从输入中提取到" + "code": "slot.not_provided", + "facts": { + "slot_label": "责任人" + } } ] } @@ -62,4 +64,25 @@ - 操作类型:输入含"新增"→映射为"新增授权策略"(规则13a); - 策略列表:提取四个字段的值,按值约束约定的顺序与分隔符组织;日期按约束格式归一(规则18)。 -现在处理输入并返回提取结果。 \ No newline at end of file +### 示例5:授权策略管理场景(取值违反封闭取值范围) +```json +{ + "slots": { + "授权策略的操作类型": null, + "动网操作的授权策略列表": "校园专网,紧急扩容,天线调整,2026-08-01~2030-12-31" + }, + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.constraint_violated", + "facts": { + "slot_label": "授权策略的操作类型", + "actual": "导出" + } + } + ] +} +``` +说明:输入"帮我导出一个授权策略,业务场景是校园专网,处置类型是紧急扩容,做个天线调整,有效期从2026年8月1日到2030年12月31日"——操作类型输入词"导出"不在合法取值范围与同义对清单中(规则13c),值设为 null 并报告 slot.constraint_violated。 + +现在处理输入并返回提取结果。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/zh-CN/slot.json index d3d6d31e..1c89f05d 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/zh-CN/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Authorization-T/authorization-policy-management/v1/zh-CN/slot.json @@ -20,7 +20,7 @@ "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", "1. 业务场景是业务投诉诊断" ], - "x-a2at-value-constraint": "列表文本形态,内容构成:策略标识(UUID 唯一标识)、业务场景、处置类型、操作名称、有效期。当'授权策略的操作类型'为新增授权策略、修改授权策略或删除授权策略时,本字段为必填;为查询授权策略时选填。必填操作类型下未提供列表内容(值为空)应报告 missing_input 错误,不应返回 null。每个条目以编号加'.'开头(如'1.'),编号从 1 起连续递增;条目内各字段以'字段名是值'形式表达,多个字段用全角逗号','分隔;多个条目各自独立成行(换行分隔),每行以编号加'.'开头。字段名固定为:业务场景、处置类型、操作名称、有效期(新增形);策略标识、有效期(修改形);策略标识(删除形,删除条目须提供策略标识,可一条或多条);查询条件按实际使用的字段(如业务场景、处置类型)。日期统一使用 YYYY-MM-DD~YYYY-MM-DD 区间格式(自然语言日期须归一为该格式,如\"2026年8月1日到2030年12月31日\"归一为\"2026-08-01~2030-12-31\")或\"永久生效\"。新增授权策略时:每条须包含'业务场景是X,处置类型是Y,操作名称是Z,有效期是V'四个字段(按此顺序),无需策略标识;有效期须为完整区间或\"永久生效\"。修改授权策略时:条目为'1. 策略标识是X,有效期是V'(策略标识在前、有效期在后);也可仅包含有效期(如'1. 有效期是V',视为批量修改,同样有效);当修改描述中的有效期可以直接确定时,条目优先采用'1. 策略标识是X,有效期是V'的简洁形态(如\"1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效\");描述复杂无法简化时,才保留策略标识与自然语言修改描述的组合(如\"1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期改为永久生效\")或自然语言修改描述(如\"1. 有效期修改为2026-01-01~2028-12-31\",同样有效);有效期须为完整日期区间或\"永久生效\"。删除授权策略时:条目为'1. 策略标识是X';也可用条件方式(如'1. 处置类型是业务抢通'、'1. 业务场景是业务投诉诊断',视为按该条件筛选删除)。查询授权策略时选填:不提供该参数完全合法;查询条件可为业务场景、处置类型、操作名称的一个或多个取值,无需完整条目。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),多个条件用全角逗号分隔,多组条件各自独立成行并编号。" + "x-a2at-value-constraint": "列表文本形态,内容构成:策略标识(UUID 唯一标识)、业务场景、处置类型、操作名称、有效期。当'授权策略的操作类型'为新增授权策略、修改授权策略或删除授权策略时,本字段为必填;为查询授权策略时选填。必填操作类型下未提供列表内容(值为空)应报告 slot.not_provided 错误,不应返回 null。每个条目以编号加'.'开头(如'1.'),编号从 1 起连续递增;条目内各字段以'字段名是值'形式表达,多个字段用全角逗号','分隔;多个条目各自独立成行(换行分隔),每行以编号加'.'开头。字段名固定为:业务场景、处置类型、操作名称、有效期(新增形);策略标识、有效期(修改形);策略标识(删除形,删除条目须提供策略标识,可一条或多条);查询条件按实际使用的字段(如业务场景、处置类型)。日期统一使用 YYYY-MM-DD~YYYY-MM-DD 区间格式(自然语言日期须归一为该格式,如\"2026年8月1日到2030年12月31日\"归一为\"2026-08-01~2030-12-31\")或\"永久生效\"。新增授权策略时:每条须包含'业务场景是X,处置类型是Y,操作名称是Z,有效期是V'四个字段(按此顺序),无需策略标识;有效期须为完整区间或\"永久生效\"。修改授权策略时:条目为'1. 策略标识是X,有效期是V'(策略标识在前、有效期在后);也可仅包含有效期(如'1. 有效期是V',视为批量修改,同样有效);当修改描述中的有效期可以直接确定时,条目优先采用'1. 策略标识是X,有效期是V'的简洁形态(如\"1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效\");描述复杂无法简化时,才保留策略标识与自然语言修改描述的组合(如\"1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期改为永久生效\")或自然语言修改描述(如\"1. 有效期修改为2026-01-01~2028-12-31\",同样有效);有效期须为完整日期区间或\"永久生效\"。删除授权策略时:条目为'1. 策略标识是X';也可用条件方式(如'1. 处置类型是业务抢通'、'1. 业务场景是业务投诉诊断',视为按该条件筛选删除)。查询授权策略时选填:不提供该参数完全合法;查询条件可为业务场景、处置类型、操作名称的一个或多个取值,无需完整条目。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),多个条件用全角逗号分隔,多组条件各自独立成行并编号。" } }, "required": [ From d65e362fde5fbe30ea52d23054958e41dedd00ab Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 29 Aug 2026 13:19:22 +0800 Subject: [PATCH 237/295] refactor(prompt): migrate validators and extractors to catalog codes DefaultSemanticValidator and DefaultContentValidator parse the new facts schema and render messages via ErrorMessages; the structured slot value extractor emits slot.not_provided / slot.constraint_violated; loader failures map to template.* and infra.* codes; the ValidationPipeline emits final catalog codes directly (the ParamExtractor mapping layer is gone). Signed-off-by: codetalks-hw --- ...ultStructuredPromptSlotValueExtractor.java | 428 ++++--- .../model/StructuredSlotValidationError.java | 46 +- .../LocalFilePromptScenarioCatalogLoader.java | 10 +- .../LocalFilePromptSlotSchemaLoader.java | 3 +- .../resources/loader/ResourceReadErrors.java | 34 + .../exception/TaskPromptRenderException.java | 45 +- .../validation/DefaultContentValidator.java | 57 +- .../validation/DefaultSemanticValidator.java | 622 +++++----- ...tructuredPromptSlotValueExtractorTest.java | 664 ++++++----- .../loader/LocalFilePromptLoadersTest.java | 3 +- .../DefaultContentValidatorTest.java | 18 +- .../DefaultSemanticValidatorTest.java | 1010 +++++++++-------- 12 files changed, 1679 insertions(+), 1261 deletions(-) create mode 100644 a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ResourceReadErrors.java diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java index d89ad853..24c83d98 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractor.java @@ -1,184 +1,244 @@ -package net.openan.a2at.sdk.prompt.analysis.impl; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectWriter; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.model.PromptMessage; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; - -/** - * Shared LLM-backed structured slot extractor. - * - * @since 2026-06 - */ -public final class DefaultStructuredPromptSlotValueExtractor implements PromptSlotValueExtractor { - - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - private static final ObjectWriter SLOT_SERIALIZER = new ObjectMapper() - .setSerializationInclusion(JsonInclude.Include.NON_NULL) - .writerWithDefaultPrettyPrinter(); - - private final LLMClient llmClient; - - private final PromptSlotSchemaLoader slotSchemaLoader; - - private final String systemPrompt; - - private final String userPrompt; - - public DefaultStructuredPromptSlotValueExtractor( - LLMClient llmClient, PromptSlotSchemaLoader slotSchemaLoader, String systemPrompt, String userPrompt) { - this.llmClient = llmClient; - this.slotSchemaLoader = slotSchemaLoader; - this.systemPrompt = systemPrompt; - this.userPrompt = userPrompt; - } - - @Override - public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { - return doExtractSlots(userInput, scenarioCode, language, null); - } - - @Override - public StructuredSlotExtractionResult extractSlots( - Object userInput, String scenarioCode, String language, Map dataSchema) { - return doExtractSlots(userInput, scenarioCode, language, dataSchema); - } - - private StructuredSlotExtractionResult doExtractSlots( - Object userInput, String scenarioCode, String language, Map dataSchema) { - PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(scenarioCode, language); - String payload = llmClient - .structured( - toStructuredMessages(buildMessages(userInput, scenarioCode, language, slotSchema, dataSchema)), - buildSchema(slotSchema), - null, - null) - .content(); - return parseExtractionResult(slotSchema, payload); - } - - private List buildMessages( - Object userInput, - String scenarioCode, - String language, - PromptSlotSchema slotSchema, - Map dataSchema) { - String slotLines; - try { - slotLines = SLOT_SERIALIZER.writeValueAsString(slotSchema.slotDefinitions()); - } catch (JsonProcessingException error) { - throw new A2ATError("Failed to serialize slot definitions.", error); - } - String content = userPrompt - + "\n\n[scenario_code]\n" - + scenarioCode - + "\n\n[language]\n" - + language - + "\n\n[input]\n" - + String.valueOf(userInput) - + "\n\n[slots]\n" - + slotLines; - if (dataSchema != null && !dataSchema.isEmpty()) { - content += "\n\n[data_schema]\n"; - try { - content += OBJECT_MAPPER.writeValueAsString(dataSchema); - } catch (JsonProcessingException e) { - throw new A2ATError("Failed to serialize data schema to JSON.", e); - } - } - return List.of(new PromptMessage("system", systemPrompt), new PromptMessage("user", content)); - } - - private Map buildSchema(PromptSlotSchema slotSchema) { - List slotNames = slotSchema.slotDefinitions().stream() - .map(PromptSlotDefinition::name) - .toList(); - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("required", List.of("slots", "slot_errors")); - schema.put("slotNames", slotNames); - return schema; - } - - private static List> toStructuredMessages(List messages) { - return messages.stream() - .map(message -> Map.of("role", message.role(), "content", message.content())) - .toList(); - } - - private StructuredSlotExtractionResult parseExtractionResult(PromptSlotSchema slotSchema, String payload) { - Map response = parseObject(payload); - Map normalized = normalizeSlots(response.get("slots"), slotSchema); - List slotErrors = normalizeSlotErrors(response.get("slot_errors")); - return new StructuredSlotExtractionResult(Map.copyOf(normalized), List.copyOf(slotErrors)); - } - - private static Map parseObject(String payload) { - try { - Map response = - OBJECT_MAPPER.readValue(payload, new TypeReference>() {}); - return response == null ? Map.of() : response; - } catch (JsonProcessingException error) { - throw new A2ATError("Structured LLM payload must be a JSON object.", error); - } - } - - private static Map normalizeSlots(Object slotsValue, PromptSlotSchema slotSchema) { - Map values = new LinkedHashMap<>(); - Map rawSlots = slotsValue instanceof Map mapValue ? normalizeMap(mapValue) : Map.of(); - for (PromptSlotDefinition definition : slotSchema.slotDefinitions()) { - values.put(definition.name(), normalizeSlotValue(rawSlots.get(definition.name()))); - } - return values; - } - - private static List normalizeSlotErrors(Object errorsValue) { - if (!(errorsValue instanceof List errors)) { - return List.of(); - } - List normalized = new ArrayList<>(); - for (Object errorValue : errors) { - if (!(errorValue instanceof Map errorMap)) { - continue; - } - Map fields = normalizeMap(errorMap); - normalized.add(new StructuredSlotValidationError( - asString(fields.get("slot_name")), asString(fields.get("code")), asString(fields.get("message")))); - } - return normalized; - } - - private static Map normalizeMap(Map source) { - Map normalized = new LinkedHashMap<>(); - source.forEach((key, value) -> normalized.put(String.valueOf(key), value)); - return normalized; - } - - private static String normalizeSlotValue(Object value) { - if (value == null) { - return ""; - } - if (value instanceof String text) { - return text; - } - return String.valueOf(value); - } - - private static String asString(Object value) { - return value instanceof String text ? text : ""; - } -} +package net.openan.a2at.sdk.prompt.analysis.impl; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.PromptMessage; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Shared LLM-backed structured slot extractor. + * + *

        The LLM reports each slot error as {@code {slot_name, code, facts}} from the closed {@code slot.*} code set; the + * human-readable message is rendered by the SDK from the code's message template. Codes outside the {@code slot.} + * domain are mapped to the {@code slot.rule_violation} fallback and logged as a warning. + * + * @since 2026-06 + */ +public final class DefaultStructuredPromptSlotValueExtractor implements PromptSlotValueExtractor { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultStructuredPromptSlotValueExtractor.class); + + private static final String SLOT_CODE_DOMAIN = "slot."; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final ObjectWriter SLOT_SERIALIZER = new ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .writerWithDefaultPrettyPrinter(); + + private final LLMClient llmClient; + + private final PromptSlotSchemaLoader slotSchemaLoader; + + private final String systemPrompt; + + private final String userPrompt; + + public DefaultStructuredPromptSlotValueExtractor( + LLMClient llmClient, PromptSlotSchemaLoader slotSchemaLoader, String systemPrompt, String userPrompt) { + this.llmClient = llmClient; + this.slotSchemaLoader = slotSchemaLoader; + this.systemPrompt = systemPrompt; + this.userPrompt = userPrompt; + } + + @Override + public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { + return doExtractSlots(userInput, scenarioCode, language, null); + } + + @Override + public StructuredSlotExtractionResult extractSlots( + Object userInput, String scenarioCode, String language, Map dataSchema) { + return doExtractSlots(userInput, scenarioCode, language, dataSchema); + } + + private StructuredSlotExtractionResult doExtractSlots( + Object userInput, String scenarioCode, String language, Map dataSchema) { + PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(scenarioCode, language); + String payload = llmClient + .structured( + toStructuredMessages(buildMessages(userInput, scenarioCode, language, slotSchema, dataSchema)), + buildSchema(slotSchema), + null, + null) + .content(); + return parseExtractionResult(slotSchema, payload, language); + } + + private List buildMessages( + Object userInput, + String scenarioCode, + String language, + PromptSlotSchema slotSchema, + Map dataSchema) { + String slotLines; + try { + slotLines = SLOT_SERIALIZER.writeValueAsString(slotSchema.slotDefinitions()); + } catch (JsonProcessingException error) { + throw new A2ATError("Failed to serialize slot definitions.", error); + } + String content = userPrompt + + "\n\n[scenario_code]\n" + + scenarioCode + + "\n\n[language]\n" + + language + + "\n\n[input]\n" + + String.valueOf(userInput) + + "\n\n[slots]\n" + + slotLines; + if (dataSchema != null && !dataSchema.isEmpty()) { + content += "\n\n[data_schema]\n"; + try { + content += OBJECT_MAPPER.writeValueAsString(dataSchema); + } catch (JsonProcessingException e) { + throw new A2ATError("Failed to serialize data schema to JSON.", e); + } + } + return List.of(new PromptMessage("system", systemPrompt), new PromptMessage("user", content)); + } + + private Map buildSchema(PromptSlotSchema slotSchema) { + List slotNames = slotSchema.slotDefinitions().stream() + .map(PromptSlotDefinition::name) + .toList(); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("required", List.of("slots", "slot_errors")); + schema.put("slotNames", slotNames); + return schema; + } + + private static List> toStructuredMessages(List messages) { + return messages.stream() + .map(message -> Map.of("role", message.role(), "content", message.content())) + .toList(); + } + + private StructuredSlotExtractionResult parseExtractionResult( + PromptSlotSchema slotSchema, String payload, String language) { + Map response = parseObject(payload); + Map normalized = normalizeSlots(response.get("slots"), slotSchema); + List slotErrors = normalizeSlotErrors(response.get("slot_errors"), language); + return new StructuredSlotExtractionResult(Map.copyOf(normalized), List.copyOf(slotErrors)); + } + + private static Map parseObject(String payload) { + try { + Map response = + OBJECT_MAPPER.readValue(payload, new TypeReference>() {}); + return response == null ? Map.of() : response; + } catch (JsonProcessingException error) { + throw new A2ATError("Structured LLM payload must be a JSON object.", error); + } + } + + private static Map normalizeSlots(Object slotsValue, PromptSlotSchema slotSchema) { + Map values = new LinkedHashMap<>(); + Map rawSlots = slotsValue instanceof Map mapValue ? normalizeMap(mapValue) : Map.of(); + for (PromptSlotDefinition definition : slotSchema.slotDefinitions()) { + values.put(definition.name(), normalizeSlotValue(rawSlots.get(definition.name()))); + } + return values; + } + + private static List normalizeSlotErrors(Object errorsValue, String language) { + if (!(errorsValue instanceof List errors)) { + return List.of(); + } + List normalized = new ArrayList<>(); + for (Object errorValue : errors) { + if (!(errorValue instanceof Map errorMap)) { + continue; + } + Map fields = normalizeMap(errorMap); + String slotName = asString(fields.get("slot_name")); + String code = asString(fields.get("code")); + Map facts = stringFacts(fields.get("facts")); + ErrorCatalog entry = resolveCode(code); + if (!entry.getCode().equals(code)) { + LOGGER.atWarn() + .log("slot_extraction_unknown_code original_code={} fallback_code={}", code, entry.getCode()); + facts = Map.of("slot_label", slotName); + } else if (!facts.containsKey("slot_label") && entry.hasFactParameter("slot_label")) { + facts = withSlotLabel(facts, slotName); + } + normalized.add(new StructuredSlotValidationError( + slotName, entry.getCode(), ErrorMessages.render(entry, language, facts), facts)); + } + return normalized; + } + + /** Resolves one LLM-reported code to its catalog entry, falling back to {@code slot.rule_violation}. */ + private static ErrorCatalog resolveCode(String code) { + Optional entry = ErrorCatalog.byCode(code); + if (entry.isPresent() && entry.get().getCode().startsWith(SLOT_CODE_DOMAIN)) { + return entry.get(); + } + return ErrorCatalog.SLOT_RULE_VIOLATION; + } + + private static Map stringFacts(@Nullable Object factsValue) { + if (!(factsValue instanceof Map rawFacts)) { + return Map.of(); + } + Map facts = new LinkedHashMap<>(); + for (Map.Entry entry : rawFacts.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + continue; + } + Object value = entry.getValue(); + if (value instanceof String text) { + facts.put(key, text); + } else if (value instanceof Number || value instanceof Boolean) { + facts.put(key, String.valueOf(value)); + } + } + return Map.copyOf(facts); + } + + private static Map withSlotLabel(Map facts, String slotName) { + Map enriched = new LinkedHashMap<>(facts); + enriched.put("slot_label", slotName); + return Map.copyOf(enriched); + } + + private static Map normalizeMap(Map source) { + Map normalized = new LinkedHashMap<>(); + source.forEach((key, value) -> normalized.put(String.valueOf(key), value)); + return normalized; + } + + private static String normalizeSlotValue(Object value) { + if (value == null) { + return ""; + } + if (value instanceof String text) { + return text; + } + return String.valueOf(value); + } + + private static String asString(Object value) { + return value instanceof String text ? text : ""; + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/model/StructuredSlotValidationError.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/model/StructuredSlotValidationError.java index 59cea959..f2e7d389 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/model/StructuredSlotValidationError.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/analysis/model/StructuredSlotValidationError.java @@ -1,11 +1,35 @@ -package net.openan.a2at.sdk.prompt.analysis.model; - -/** - * One slot validation error emitted by the structured extraction step. - * - * @param slotName slot name - * @param code validation error code - * @param message validation message - * @since 2026-06 - */ -public record StructuredSlotValidationError(String slotName, String code, String message) {} +package net.openan.a2at.sdk.prompt.analysis.model; + +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * One slot validation error emitted by the structured extraction step. + * + *

        The {@code message} is rendered by the SDK from the code's message template; {@code facts} carries the structured + * fact values that produced the message. + * + * @param slotName slot name + * @param code validation error code + * @param message validation message + * @param facts fact values keyed by fact parameter name, may be null + * @since 2026-06 + */ +public record StructuredSlotValidationError( + String slotName, String code, String message, @Nullable Map facts) { + + public StructuredSlotValidationError { + facts = facts == null ? null : Map.copyOf(facts); + } + + /** + * Creates a slot validation error without structured facts. + * + * @param slotName slot name + * @param code validation error code + * @param message validation message + */ + public StructuredSlotValidationError(String slotName, String code, String message) { + this(slotName, code, message, null); + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java index 6488ccff..39375093 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java @@ -5,7 +5,6 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.resources.PathSegments; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; @@ -44,7 +43,14 @@ public List load(String language) { return PromptResourceJsonParser.parse(snapshot.get(pathKey), ScenarioCatalog.class) .scenarios(); } catch (JsonProcessingException exception) { - throw new A2ATError("Failed to parse scenario catalog for language: " + language, exception); + throw ResourceReadErrors.readFailed( + promptRootDir + .resolve("scenarios") + .resolve(language) + .resolve("scenarios.json") + .toString(), + language, + exception); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java index 13266976..e0205365 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java @@ -4,7 +4,6 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.resources.PathSegments; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; @@ -54,7 +53,7 @@ public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { return PromptResourceJsonParser.parse(snapshot.get(pathKey), PromptSlotJsonSchema.class) .toPromptSlotSchema(scenarioCode); } catch (JsonProcessingException exception) { - throw new A2ATError("Failed to read slot schema resource: " + promptRootDir.resolve(pathKey), exception); + throw ResourceReadErrors.readFailed(promptRootDir.resolve(pathKey).toString(), language, exception); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ResourceReadErrors.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ResourceReadErrors.java new file mode 100644 index 00000000..c0d74a49 --- /dev/null +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ResourceReadErrors.java @@ -0,0 +1,34 @@ +package net.openan.a2at.sdk.prompt.resources.loader; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import org.jspecify.annotations.Nullable; + +/** + * Creates infra failures for prompt resources that cannot be read from their configured source. + * + * @since 2026-08 + */ +final class ResourceReadErrors { + + private ResourceReadErrors() {} + + /** + * Creates an {@code infra.resource_read_failed} failure for one resource path. + * + * @param resourcePath path of the resource that could not be read + * @param language language used to render the failure message, for example {@code zh-CN}; null falls back to + * {@code en-US} + * @param cause root cause, may be null + * @return infra failure carrying the {@code infra.resource_read_failed} code + */ + static A2ATError readFailed(String resourcePath, @Nullable String language, @Nullable Throwable cause) { + return new A2ATError( + ErrorCatalog.INFRA_RESOURCE_READ_FAILED.getCode(), + ErrorMessages.render( + ErrorCatalog.INFRA_RESOURCE_READ_FAILED, language, Map.of("resource_path", resourcePath)), + cause); + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/exception/TaskPromptRenderException.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/exception/TaskPromptRenderException.java index dee0144b..2dd910a7 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/exception/TaskPromptRenderException.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/exception/TaskPromptRenderException.java @@ -1,20 +1,25 @@ -package net.openan.a2at.sdk.prompt.taskrendering.exception; - -import net.openan.a2at.sdk.core.exception.A2ATError; - -/** - * Raised when task prompt template rendering fails. - * - * @since 2026-06 - */ -public final class TaskPromptRenderException extends A2ATError { - - /** - * Creates a task prompt rendering exception. - * - * @param message failure message - */ - public TaskPromptRenderException(String message) { - super(message); - } -} +package net.openan.a2at.sdk.prompt.taskrendering.exception; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; + +/** + * Raised when task prompt template rendering fails. + * + *

        Carries the {@code template.render_failed} code; the {@code reason} fact describes the concrete rendering problem. + * The template URI itself is not known to the renderer and is supplied by the caller that catches this exception. + * + * @since 2026-06 + */ +public final class TaskPromptRenderException extends A2ATBusinessException { + + /** + * Creates a task prompt rendering exception. + * + * @param message failure message describing the rendering problem + */ + public TaskPromptRenderException(String message) { + super(ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode(), message, Map.of("reason", message)); + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java index 19c3ce72..b1b3b416 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -1,7 +1,8 @@ package net.openan.a2at.sdk.prompt.validation; import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.TemplateUri; @@ -19,21 +20,24 @@ * *

        The template referenced by the {@code templateUri} argument of every {@link #validate} call is loaded through a * sourceType-aware template loader before the validation pipeline is entered. A load failure is reported as a - * {@code TEMPLATE_NOT_FOUND} error, distinct from the {@code VALIDATION_PROMPT_RESOURCE_NOT_FOUND} code that signals - * missing content_validation prompt resources. The loaded template text flows into the pipeline for prompt injection. + * {@code template.not_found} error, the same code that signals missing content_validation prompt resources. The loaded + * template text flows into the pipeline for prompt injection. * *

        The validation pipeline and the content_validation prompt resources are assembled eagerly in the constructor: the - * constructor resolves and freezes the prompt resources once, and a missing resource - * fails fast at assembly time instead of on the first {@link #validate} call. The content_validation prompt resources - * are internal LLM instructions loaded from the classpath regardless of the configured prompt source type. + * constructor resolves and freezes the prompt resources once, and a missing resource fails fast at assembly time + * instead of on the first {@link #validate} call. The content_validation prompt resources are internal LLM instructions + * loaded from the classpath regardless of the configured prompt source type. * * @since 2026-08 */ public final class DefaultContentValidator implements ContentValidator { private final String extensionName; + private final String language; + private final PromptTemplateTextLoader templateLoader; + private final ValidationPipeline pipeline; /** @@ -46,8 +50,8 @@ public final class DefaultContentValidator implements ContentValidator { * @param language language code for prompt resource loading and template loading * @param maxAttempts maximum retry attempts for semantic validation * @param llmClient LLM client for semantic validation; may be {@code null}, in which case the first - * {@link #validate} call fails with {@code ContentValidationException} carrying - * {@code VALIDATION_LLM_INFRASTRUCTURE_ERROR}; there is no late injection point + * {@link #validate} call fails with {@code ContentValidationException} carrying {@code llm.not_configured}; + * there is no late injection point * @param templateLoader sourceType-aware template text loader for loading the template referenced by the * {@code templateUri} of every {@link #validate} call * @throws ContentValidationException if the content_validation prompt resources of the given language are missing @@ -73,55 +77,60 @@ public DefaultContentValidator( * @param templateUri URI of the template the content is validated against, such as * {@code Task-T/network-layer/ran-energy-saving/v1} * @return filled parameter data carrying the merged parameters - * @throws ContentValidationException with {@code validation_invalid_input} if the template URI is null, addresses + * @throws ContentValidationException with {@code negotiation.invalid_input} if the template URI is null, addresses * another extension than the one this validator is configured for, or carries an unsupported version; or if the * prompt is null or blank, or the schema is null - * @throws ContentValidationException if the validation fails at any stage, including {@code TEMPLATE_NOT_FOUND} + * @throws ContentValidationException if the validation fails at any stage, including {@code template.not_found} * when the template cannot be loaded */ @Override public FilledParamData validate( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { if (templateUri == null) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, "Template URI must not be null."); + throw invalidInput("Template URI must not be null."); } if (!extensionName.equals(templateUri.extensionName())) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, - "Template URI extension '" + templateUri.extensionName() - + "' does not match expected extension '" + extensionName + "'."); + throw invalidInput("Template URI extension '" + templateUri.extensionName() + + "' does not match expected extension '" + extensionName + "'."); } if (!TemplateUri.DEFAULT_TEMPLATE_VERSION.equals(templateUri.templateVersion())) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, - "Unsupported template URI version: " + templateUri.templateVersion()); + throw invalidInput("Unsupported template URI version: " + templateUri.templateVersion()); } String templateContent; try { templateContent = templateLoader.loadTemplate(templateUri.uri(), language); } catch (ResourceNotFoundException exception) { + Map facts = Map.of("template_uri", templateUri.uri(), "language", language); throw new ContentValidationException( - A2ATErrorCodes.TEMPLATE_NOT_FOUND, - "Template not found for URI " + templateUri.uri() + " and language " + language + ": " - + exception.getMessage(), + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorMessages.render(ErrorCatalog.TEMPLATE_NOT_FOUND, language, facts), exception); } return pipeline.validate(prompt, schema, templateUri, templateContent); } + private ContentValidationException invalidInput(String reason) { + Map facts = Map.of("reason", reason); + return new ContentValidationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts)); + } + private static ValidationPipeline assemblePipeline( String language, int maxAttempts, @Nullable LLMClient llmClient) { try { return new ValidationPipeline<>( - prompt -> Map.of(), new DefaultSemanticValidator(llmClient, language), maxAttempts); + prompt -> Map.of(), new DefaultSemanticValidator(llmClient, language), maxAttempts, language); } catch (ResourceNotFoundException exception) { + Map facts = Map.of("template_uri", exception.resourcePath(), "language", language); throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, exception.getMessage(), exception); + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorMessages.render(ErrorCatalog.TEMPLATE_NOT_FOUND, language, facts), + exception); } } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java index 485198b0..ca9579b8 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -1,273 +1,349 @@ -package net.openan.a2at.sdk.prompt.validation; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.json.JacksonJsonValueParser; -import net.openan.a2at.sdk.core.json.JsonValueParser; -import net.openan.a2at.sdk.core.model.PromptMessage; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.SemanticValidator; -import net.openan.a2at.sdk.core.validation.ValidationResult; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMError; -import net.openan.a2at.sdk.llm.LLMResponse; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Default LLM-backed semantic validator that delegates to a structured LLM call for content validation and parameter - * extraction. - * - *

        The content_validation prompt resources are internal LLM instructions. Like the negotiation semantic validation - * prompts, they are always loaded from the classpath regardless of the configured prompt source type — the local - * resource root only overrides business resources (templates, slots, scenarios). - * - * @since 2026-08 - */ -final class DefaultSemanticValidator implements SemanticValidator { - - private static final Logger LOGGER = LoggerFactory.getLogger(DefaultSemanticValidator.class); - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - private static final String PROMPT_RESOURCE_ROOT = "prompt_resources/prompts/content_validation/"; - - private final LLMClient llmClient; - private final String systemPrompt; - private final String userPromptTemplate; - private final JsonValueParser jsonValueParser; - - /** - * Creates a semantic validator backed by the given LLM client and prompt resources for the specified language. - * - * @param llmClient LLM client for structured calls; may be {@code null}, in which case {@link #validate} fails with - * {@code ContentValidationException} carrying {@code VALIDATION_LLM_INFRASTRUCTURE_ERROR}; there is no late - * injection point - * @param language language code for prompt resource loading - * @throws ResourceNotFoundException if the content_validation prompt resources of the given language are missing on - * the classpath - */ - DefaultSemanticValidator(@Nullable LLMClient llmClient, @NonNull String language) { - this(llmClient, language, new JacksonJsonValueParser()); - } - - /** - * Creates a semantic validator backed by the given LLM client, prompt resources for the specified language and a - * shared JSON value parser abstraction. - * - * @param llmClient LLM client for structured calls; may be {@code null}, in which case {@link #validate} fails with - * {@code ContentValidationException} carrying {@code VALIDATION_LLM_INFRASTRUCTURE_ERROR}; there is no late - * injection point - * @param language language code for prompt resource loading - * @param jsonValueParser shared JSON value parser abstraction - * @throws ResourceNotFoundException if the content_validation prompt resources of the given language are missing on - * the classpath - */ - DefaultSemanticValidator( - @Nullable LLMClient llmClient, @NonNull String language, @NonNull JsonValueParser jsonValueParser) { - this.llmClient = llmClient; - this.systemPrompt = loadPromptResource("system.md", language); - this.userPromptTemplate = loadPromptResource("user.md", language); - this.jsonValueParser = jsonValueParser; - } - - @Override - public ValidationResult validate( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri reference, @NonNull String templateContent) { - if (llmClient == null) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, - "LLM client is not configured for semantic validation."); - } - - String userPrompt = fillUserPrompt(prompt, schema, reference, templateContent); - Map outputSchema = buildOutputSchema(); - List> messages = toStructuredMessages( - List.of(new PromptMessage("system", systemPrompt), new PromptMessage("user", userPrompt))); - - LLMResponse response; - try { - response = llmClient.structured(messages, outputSchema, null, null); - } catch (LLMError error) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, - "Semantic validation LLM invocation failed: " + error.getMessage(), - error); - } - Map parsed; - try { - parsed = jsonValueParser.parseObject(response.content()); - } catch (RuntimeException exception) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, - "Semantic validation LLM response is not valid JSON: " + exception.getMessage(), - List.of(new SlotValidationError( - "_llm", A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getMessage())), - exception); - } - - boolean verdict = parseVerdict(parsed); - List errors = parseErrors(parsed.get("errors")); - Map params = parseParams(parsed.get("params")); - - LOGGER.atDebug().log( - "semantic_validation_completed verdict={} error_count={} param_count={}", - verdict, - errors.size(), - params.size()); - return new ValidationResult(verdict, errors, params); - } - - private static String loadPromptResource(String fileName, String language) { - String classpathPath = PROMPT_RESOURCE_ROOT + language + "/" + fileName; - InputStream stream = ClasspathResourceStreams.open(classpathPath); - if (stream == null) { - throw new ResourceNotFoundException( - "Content validation prompt resource does not exist for language " + language - + "; set A2AT_LANGUAGE to a language with bundled prompt resources (zh-CN or en-US).", - classpathPath); - } - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException exception) { - throw new A2ATError("Failed to read content validation prompt: " + classpathPath, exception); - } - } - - private static final Pattern PLACEHOLDER_PATTERN = - Pattern.compile("\\[(extension_name|input|template_uri|template_content|schema)\\]"); - - private String fillUserPrompt(String prompt, Map schema, TemplateUri reference, String templateContent) { - String schemaJson; - try { - schemaJson = OBJECT_MAPPER.writeValueAsString(schema); - } catch (JsonProcessingException exception) { - throw new A2ATError("Failed to serialize schema to JSON.", exception); - } - - Matcher matcher = PLACEHOLDER_PATTERN.matcher(userPromptTemplate); - StringBuilder sb = new StringBuilder(); - while (matcher.find()) { - String replacement = switch (matcher.group(1)) { - case "extension_name" -> Matcher.quoteReplacement(reference.extensionName()); - case "input" -> Matcher.quoteReplacement(prompt); - case "template_uri" -> Matcher.quoteReplacement(reference.uri()); - case "template_content" -> Matcher.quoteReplacement(templateContent); - case "schema" -> Matcher.quoteReplacement(schemaJson); - default -> Matcher.quoteReplacement(matcher.group()); - }; - matcher.appendReplacement(sb, replacement); - } - matcher.appendTail(sb); - return sb.toString(); - } - - private static Map buildOutputSchema() { - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - - Map properties = new LinkedHashMap<>(); - - Map verdictProp = new LinkedHashMap<>(); - verdictProp.put("type", "boolean"); - properties.put("semantic_verdict", verdictProp); - - Map errorItemProperties = new LinkedHashMap<>(); - errorItemProperties.put("slot_name", Map.of("type", "string")); - errorItemProperties.put("code", Map.of("type", "string")); - errorItemProperties.put("message", Map.of("type", "string")); - Map errorItem = new LinkedHashMap<>(); - errorItem.put("type", "object"); - errorItem.put("properties", errorItemProperties); - errorItem.put("required", List.of("slot_name", "code", "message")); - Map errorsProp = new LinkedHashMap<>(); - errorsProp.put("type", "array"); - errorsProp.put("items", errorItem); - properties.put("errors", errorsProp); - - Map paramsProp = new LinkedHashMap<>(); - paramsProp.put("type", "object"); - properties.put("params", paramsProp); - - schema.put("properties", properties); - schema.put("required", List.of("semantic_verdict", "errors", "params")); - schema.put("additionalProperties", false); - return schema; - } - - private static List> toStructuredMessages(List messages) { - return messages.stream() - .map(message -> Map.of("role", message.role(), "content", message.content())) - .toList(); - } - - private static boolean parseVerdict(Map parsed) { - if (!(parsed.get("semantic_verdict") instanceof Boolean verdict)) { - throw contractViolation("semantic_verdict must be a boolean."); - } - return verdict; - } - - private static List parseErrors(Object errorsValue) { - if (!(errorsValue instanceof List errors)) { - throw contractViolation("errors must be an array."); - } - List normalized = new ArrayList<>(); - for (Object errorValue : errors) { - if (!(errorValue instanceof Map errorMap)) { - throw contractViolation("errors must be objects with slot_name, code and message."); - } - if (!(errorMap.get("slot_name") instanceof String slotName) - || !(errorMap.get("code") instanceof String code) - || !(errorMap.get("message") instanceof String message)) { - throw contractViolation("errors must carry string slot_name, code and message values."); - } - normalized.add(new SlotValidationError(slotName, code, message)); - } - return List.copyOf(normalized); - } - - /** - * Normalizes the LLM-extracted parameter map. Keys with a {@code null} value are preserved: a {@code null} - * parameter is the semantic validator's explicit signal that a schema slot is missing from the content, and - * downstream missing-parameter detection (negotiation triggering) relies on the key being present with a null - * value. Dropping the key would make a missing slot indistinguishable from an absent one. - */ - private static Map parseParams(Object paramsValue) { - if (!(paramsValue instanceof Map params)) { - throw contractViolation("params must be an object."); - } - Map normalized = new LinkedHashMap<>(); - for (Map.Entry entry : params.entrySet()) { - if (!(entry.getKey() instanceof String key)) { - throw contractViolation("params keys must be strings."); - } - normalized.put(key, entry.getValue()); - } - return Collections.unmodifiableMap(normalized); - } - - private static ContentValidationException contractViolation(String message) { - return new ContentValidationException( - A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, - "Semantic validation LLM response violates the output contract: " + message, - List.of(new SlotValidationError("_llm", A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, message))); - } -} +package net.openan.a2at.sdk.prompt.validation; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.json.JacksonJsonValueParser; +import net.openan.a2at.sdk.core.json.JsonValueParser; +import net.openan.a2at.sdk.core.model.PromptMessage; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.SemanticValidator; +import net.openan.a2at.sdk.core.validation.ValidationResult; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default LLM-backed semantic validator that delegates to a structured LLM call for content validation and parameter + * extraction. + * + *

        The LLM reports each error as {@code {slot_name, code, facts}} from the closed {@code content.*} code set; the + * human-readable message is rendered by the SDK from the code's message template. Codes outside the {@code content.} + * domain are mapped to the {@code content.rule_violation} fallback and logged as a warning. + * + *

        The content_validation prompt resources are internal LLM instructions. Like the negotiation semantic validation + * prompts, they are always loaded from the classpath regardless of the configured prompt source type — the local + * resource root only overrides business resources (templates, slots, scenarios). + * + * @since 2026-08 + */ +final class DefaultSemanticValidator implements SemanticValidator { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultSemanticValidator.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String PROMPT_RESOURCE_ROOT = "prompt_resources/prompts/content_validation/"; + + private static final String CONTENT_CODE_DOMAIN = "content."; + + private static final String SEMANTIC_VALIDATION_STEP = "semantic_validation"; + + private final LLMClient llmClient; + + private final String language; + + private final String systemPrompt; + + private final String userPromptTemplate; + + private final JsonValueParser jsonValueParser; + + /** + * Creates a semantic validator backed by the given LLM client and prompt resources for the specified language. + * + * @param llmClient LLM client for structured calls; may be {@code null}, in which case {@link #validate} fails with + * {@code ContentValidationException} carrying {@code llm.not_configured}; there is no late injection point + * @param language language code for prompt resource loading and message rendering + * @throws ResourceNotFoundException if the content_validation prompt resources of the given language are missing on + * the classpath + */ + DefaultSemanticValidator(@Nullable LLMClient llmClient, @NonNull String language) { + this(llmClient, language, new JacksonJsonValueParser()); + } + + /** + * Creates a semantic validator backed by the given LLM client, prompt resources for the specified language and a + * shared JSON value parser abstraction. + * + * @param llmClient LLM client for structured calls; may be {@code null}, in which case {@link #validate} fails with + * {@code ContentValidationException} carrying {@code llm.not_configured}; there is no late injection point + * @param language language code for prompt resource loading and message rendering + * @param jsonValueParser shared JSON value parser abstraction + * @throws ResourceNotFoundException if the content_validation prompt resources of the given language are missing on + * the classpath + */ + DefaultSemanticValidator( + @Nullable LLMClient llmClient, @NonNull String language, @NonNull JsonValueParser jsonValueParser) { + this.llmClient = llmClient; + this.language = Objects.requireNonNull(language, "language"); + this.systemPrompt = loadPromptResource("system.md", language); + this.userPromptTemplate = loadPromptResource("user.md", language); + this.jsonValueParser = jsonValueParser; + } + + @Override + public ValidationResult validate( + @NonNull String prompt, + @NonNull Map schema, + @NonNull TemplateUri reference, + @NonNull String templateContent) { + if (llmClient == null) { + throw new ContentValidationException( + ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), + ErrorMessages.render(ErrorCatalog.LLM_NOT_CONFIGURED, language, null)); + } + + String userPrompt = fillUserPrompt(prompt, schema, reference, templateContent); + Map outputSchema = buildOutputSchema(); + List> messages = toStructuredMessages( + List.of(new PromptMessage("system", systemPrompt), new PromptMessage("user", userPrompt))); + + LLMResponse response; + try { + response = llmClient.structured(messages, outputSchema, null, null); + } catch (LLMError error) { + Map facts = Map.of( + "provider", llmClient.getClass().getSimpleName(), + "reason", String.valueOf(error.getMessage())); + throw new ContentValidationException( + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorMessages.render(ErrorCatalog.LLM_INVOCATION_FAILED, language, facts), + error); + } + Map parsed; + try { + parsed = jsonValueParser.parseObject(response.content()); + } catch (RuntimeException exception) { + throw responseInvalid("Semantic validation LLM response is not valid JSON: " + exception.getMessage()); + } + + boolean verdict = parseVerdict(parsed); + List errors = parseErrors(parsed.get("errors")); + Map params = parseParams(parsed.get("params")); + + LOGGER.atDebug().log( + "semantic_validation_completed verdict={} error_count={} param_count={}", + verdict, + errors.size(), + params.size()); + return new ValidationResult(verdict, errors, params); + } + + private static String loadPromptResource(String fileName, String language) { + String classpathPath = PROMPT_RESOURCE_ROOT + language + "/" + fileName; + InputStream stream = ClasspathResourceStreams.open(classpathPath); + if (stream == null) { + throw new ResourceNotFoundException( + "Content validation prompt resource does not exist for language " + language + + "; set A2AT_LANGUAGE to a language with bundled prompt resources (zh-CN or en-US).", + classpathPath); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new A2ATError( + ErrorCatalog.INFRA_RESOURCE_READ_FAILED.getCode(), + ErrorMessages.render( + ErrorCatalog.INFRA_RESOURCE_READ_FAILED, language, Map.of("resource_path", classpathPath)), + exception); + } + } + + private static final Pattern PLACEHOLDER_PATTERN = + Pattern.compile("\\[(extension_name|input|template_uri|template_content|schema)\\]"); + + private String fillUserPrompt( + String prompt, Map schema, TemplateUri reference, String templateContent) { + String schemaJson; + try { + schemaJson = OBJECT_MAPPER.writeValueAsString(schema); + } catch (JsonProcessingException exception) { + throw new A2ATError("Failed to serialize schema to JSON.", exception); + } + + Matcher matcher = PLACEHOLDER_PATTERN.matcher(userPromptTemplate); + StringBuilder sb = new StringBuilder(); + while (matcher.find()) { + String replacement = + switch (matcher.group(1)) { + case "extension_name" -> Matcher.quoteReplacement(reference.extensionName()); + case "input" -> Matcher.quoteReplacement(prompt); + case "template_uri" -> Matcher.quoteReplacement(reference.uri()); + case "template_content" -> Matcher.quoteReplacement(templateContent); + case "schema" -> Matcher.quoteReplacement(schemaJson); + default -> Matcher.quoteReplacement(matcher.group()); + }; + matcher.appendReplacement(sb, replacement); + } + matcher.appendTail(sb); + return sb.toString(); + } + + private static Map buildOutputSchema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + + Map properties = new LinkedHashMap<>(); + + Map verdictProp = new LinkedHashMap<>(); + verdictProp.put("type", "boolean"); + properties.put("semantic_verdict", verdictProp); + + Map factsProperties = new LinkedHashMap<>(); + factsProperties.put("type", "object"); + factsProperties.put("additionalProperties", Map.of("type", "string")); + Map errorItemProperties = new LinkedHashMap<>(); + errorItemProperties.put("slot_name", Map.of("type", "string")); + errorItemProperties.put("code", Map.of("type", "string")); + errorItemProperties.put("facts", factsProperties); + Map errorItem = new LinkedHashMap<>(); + errorItem.put("type", "object"); + errorItem.put("properties", errorItemProperties); + errorItem.put("required", List.of("slot_name", "code", "facts")); + Map errorsProp = new LinkedHashMap<>(); + errorsProp.put("type", "array"); + errorsProp.put("items", errorItem); + properties.put("errors", errorsProp); + + Map paramsProp = new LinkedHashMap<>(); + paramsProp.put("type", "object"); + properties.put("params", paramsProp); + + schema.put("properties", properties); + schema.put("required", List.of("semantic_verdict", "errors", "params")); + schema.put("additionalProperties", false); + return schema; + } + + private static List> toStructuredMessages(List messages) { + return messages.stream() + .map(message -> Map.of("role", message.role(), "content", message.content())) + .toList(); + } + + private boolean parseVerdict(Map parsed) { + if (!(parsed.get("semantic_verdict") instanceof Boolean verdict)) { + throw contractViolation("semantic_verdict must be a boolean."); + } + return verdict; + } + + private List parseErrors(Object errorsValue) { + if (!(errorsValue instanceof List errors)) { + throw contractViolation("errors must be an array."); + } + List normalized = new ArrayList<>(); + for (Object errorValue : errors) { + if (!(errorValue instanceof Map errorMap)) { + throw contractViolation("errors must be objects with slot_name, code and facts."); + } + if (!(errorMap.get("slot_name") instanceof String slotName) + || !(errorMap.get("code") instanceof String code)) { + throw contractViolation("errors must carry string slot_name and code values."); + } + if (!(errorMap.get("facts") instanceof Map rawFacts)) { + throw contractViolation("errors must carry a facts object."); + } + Map facts = stringFacts(rawFacts); + ErrorCatalog entry = resolveCode(code); + if (!entry.getCode().equals(code)) { + LOGGER.atWarn() + .log( + "semantic_validation_unknown_code original_code={} fallback_code={}", + code, + entry.getCode()); + facts = Map.of("section_label", slotName); + } + normalized.add(new SlotValidationError( + slotName, entry.getCode(), ErrorMessages.render(entry, language, facts), facts)); + } + return List.copyOf(normalized); + } + + /** Resolves one LLM-reported code to its catalog entry, falling back to {@code content.rule_violation}. */ + private static ErrorCatalog resolveCode(String code) { + Optional entry = ErrorCatalog.byCode(code); + if (entry.isPresent() && entry.get().getCode().startsWith(CONTENT_CODE_DOMAIN)) { + return entry.get(); + } + return ErrorCatalog.CONTENT_RULE_VIOLATION; + } + + private static Map stringFacts(Map rawFacts) { + Map facts = new LinkedHashMap<>(); + for (Map.Entry entry : rawFacts.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + continue; + } + Object value = entry.getValue(); + if (value instanceof String text) { + facts.put(key, text); + } else if (value instanceof Number || value instanceof Boolean) { + facts.put(key, String.valueOf(value)); + } + } + return Map.copyOf(facts); + } + + /** + * Normalizes the LLM-extracted parameter map. Keys with a {@code null} value are preserved: a {@code null} + * parameter is the semantic validator's explicit signal that a schema slot is missing from the content, and + * downstream missing-parameter detection (negotiation triggering) relies on the key being present with a null + * value. Dropping the key would make a missing slot indistinguishable from an absent one. + */ + private Map parseParams(Object paramsValue) { + if (!(paramsValue instanceof Map params)) { + throw contractViolation("params must be an object."); + } + Map normalized = new LinkedHashMap<>(); + for (Map.Entry entry : params.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + throw contractViolation("params keys must be strings."); + } + normalized.put(key, entry.getValue()); + } + return Collections.unmodifiableMap(normalized); + } + + private ContentValidationException responseInvalid(String detail) { + Map facts = Map.of("step", SEMANTIC_VALIDATION_STEP); + String message = ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, language, facts); + return new ContentValidationException( + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + message, + List.of(new SlotValidationError("_llm", ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), message, facts)), + new RuntimeException(detail)); + } + + private ContentValidationException contractViolation(String message) { + Map facts = Map.of("step", SEMANTIC_VALIDATION_STEP); + String rendered = ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, language, facts); + return new ContentValidationException( + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + rendered, + List.of(new SlotValidationError("_llm", ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), rendered, facts)), + new RuntimeException(message)); + } +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java index f7f2cf36..927453b6 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java @@ -1,289 +1,375 @@ -package net.openan.a2at.sdk.prompt.analysis.impl; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import org.junit.jupiter.api.Test; - -class DefaultStructuredPromptSlotValueExtractorTest { - - @Test - void extractSlotsParsesFormattedStructuredJsonPayload() { - RecordingClient llmClient = new RecordingClient( - """ - { - "slots": { - "site": "Site A", - "additional_notes": null, - "limit": "5", - "severity": "high" - }, - "slot_errors": [] - } - """); - DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( - llmClient, - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of( - new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null, null), - new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null, null, null), - new PromptSlotDefinition( - "limit", false, "integer", null, 1.0d, 10.0d, null, null, null, null), - new PromptSlotDefinition( - "severity", - false, - "string", - null, - null, - null, - List.of("low", "medium", "high"), - null, - null, - null))), - "Extract slots from the input.", - "Return slots as JSON."); - - StructuredSlotExtractionResult result = - extractor.extractSlots("Analyze Site A with critical severity.", "ran-energy-saving", "en-US"); - - assertEquals( - Map.of( - "site", "Site A", - "additional_notes", "", - "limit", "5", - "severity", "high"), - result.slots()); - assertEquals(List.of(), result.slotErrors()); - } - - @Test - void extractSlotsPreservesSlotTextContainingClosingBrace() { - RecordingClient llmClient = new RecordingClient( - """ - { - "slots": { - "site": "Site A", - "additional_notes": "Need } fallback" - }, - "slot_errors": [] - } - """); - DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( - llmClient, - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of( - new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null, null), - new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null, null, null))), - "Extract slots from the input.", - "Return slots as JSON."); - - StructuredSlotExtractionResult result = - extractor.extractSlots("Analyze Site A with fallback note.", "ran-energy-saving", "en-US"); - - assertEquals(Map.of("site", "Site A", "additional_notes", "Need } fallback"), result.slots()); - assertEquals(List.of(), result.slotErrors()); - } - - @Test - void should_injectDataSchemaSection_When_schemaIsProvided() { - RecordingClient llmClient = new RecordingClient( - """ - { - "slots": { - "site": "Site A" - }, - "slot_errors": [] - } - """); - DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( - llmClient, - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null, null))), - "Extract slots from the input.", - "Return slots as JSON."); - - Map dataSchema = Map.of( - "type", "object", - "properties", Map.of( - "site", Map.of("type", "string", "description", "The target site name"), - "severity", Map.of("type", "string", "description", "The severity level")), - "required", List.of("site")); - - extractor.extractSlots("Analyze Site A.", "ran-energy-saving", "en-US", dataSchema); - - String userMessage = llmClient.lastUserContent(); - assertTrue(userMessage.contains("[data_schema]")); - assertTrue(userMessage.contains("\"type\":\"object\"")); - assertTrue(userMessage.contains("\"properties\"")); - assertTrue(userMessage.contains("\"The target site name\"")); - } - - @Test - void should_notIncludeDataSchemaSection_When_schemaIsNull() { - RecordingClient llmClient = new RecordingClient( - """ - { - "slots": { - "site": "Site A" - }, - "slot_errors": [] - } - """); - DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( - llmClient, - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null, null))), - "Extract slots from the input.", - "Return slots as JSON."); - - extractor.extractSlots("Analyze Site A.", "ran-energy-saving", "en-US", null); - - String userMessage = llmClient.lastUserContent(); - assertFalse(userMessage.contains("[data_schema]")); - } - - @Test - void should_notIncludeDataSchemaSection_When_schemaIsEmpty() { - RecordingClient llmClient = new RecordingClient( - """ - { - "slots": { - "site": "Site A" - }, - "slot_errors": [] - } - """); - DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( - llmClient, - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null, null))), - "Extract slots from the input.", - "Return slots as JSON."); - - extractor.extractSlots("Analyze Site A.", "ran-energy-saving", "en-US", Map.of()); - - String userMessage = llmClient.lastUserContent(); - assertFalse(userMessage.contains("[data_schema]")); - } - - @Test - void buildMessagesIncludesFullSlotDescriptions() { - RecordingClient llmClient = new RecordingClient( - """ - { - "slots": { - "site": "Site A" - }, - "slot_errors": [] - } - """); - DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( - llmClient, - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition( - "site", - true, - "string", - null, - null, - null, - List.of("Site A", "Site B"), - null, - "The target site name", - "The site must be a physical location"))), - "Extract slots from the input.", - "Return slots as JSON."); - - extractor.extractSlots("Analyze Site A.", "ran-energy-saving", "en-US"); - - String userMessage = llmClient.lastUserContent(); - assertTrue(userMessage.contains("[slots]")); - assertTrue(userMessage.contains("\"name\"")); - assertTrue(userMessage.contains("\"site\"")); - assertTrue(userMessage.contains("\"required\"")); - assertTrue(userMessage.contains("\"type\"")); - assertTrue(userMessage.contains("\"string\"")); - assertTrue(userMessage.contains("\"description\"")); - assertTrue(userMessage.contains("\"The target site name\"")); - assertTrue(userMessage.contains("\"enum\"")); - assertTrue(userMessage.contains("\"Site A\"")); - assertTrue(userMessage.contains("\"x-a2at-value-constraint\"")); - assertTrue(userMessage.contains("\"The site must be a physical location\"")); - assertTrue(userMessage.contains("[")); - } - - @Test - void defaultExtractSlotsWithSchemaDelegatesToThreeArg() { - RecordingClient llmClient = new RecordingClient( - """ - { - "slots": { - "site": "Site A" - }, - "slot_errors": [] - } - """); - DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( - llmClient, - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null, null, null))), - "Extract slots from the input.", - "Return slots as JSON."); - - PromptSlotValueExtractor lambdaExtractor = (input, code, lang) -> extractor.extractSlots(input, code, lang); - - StructuredSlotExtractionResult result = - lambdaExtractor.extractSlots("Analyze Site A.", "ran-energy-saving", "en-US", Map.of("site", "desc")); - - String userMessage = llmClient.lastUserContent(); - assertFalse(userMessage.contains("[data_schema]")); - assertEquals(Map.of("site", "Site A"), result.slots()); - } - - private static final class RecordingClient implements LLMClient { - - private final String payload; - - private List> lastMessages; - - private RecordingClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - this.lastMessages = messages; - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - - String lastUserContent() { - for (Map message : lastMessages) { - if ("user".equals(message.get("role"))) { - return message.get("content"); - } - } - return ""; - } - } -} +package net.openan.a2at.sdk.prompt.analysis.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import org.junit.jupiter.api.Test; + +class DefaultStructuredPromptSlotValueExtractorTest { + + @Test + void extractSlotsParsesFormattedStructuredJsonPayload() { + RecordingClient llmClient = new RecordingClient( + """ + { + "slots": { + "site": "Site A", + "additional_notes": null, + "limit": "5", + "severity": "high" + }, + "slot_errors": [] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of( + new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, null, null), + new PromptSlotDefinition( + "additional_notes", false, "string", null, null, null, null, null, null, null), + new PromptSlotDefinition( + "limit", false, "integer", null, 1.0d, 10.0d, null, null, null, null), + new PromptSlotDefinition( + "severity", + false, + "string", + null, + null, + null, + List.of("low", "medium", "high"), + null, + null, + null))), + "Extract slots from the input.", + "Return slots as JSON."); + + StructuredSlotExtractionResult result = + extractor.extractSlots("Analyze Site A with critical severity.", "ran-energy-saving", "en-US"); + + assertEquals( + Map.of( + "site", "Site A", + "additional_notes", "", + "limit", "5", + "severity", "high"), + result.slots()); + assertEquals(List.of(), result.slotErrors()); + } + + @Test + void extractSlotsPreservesSlotTextContainingClosingBrace() { + RecordingClient llmClient = new RecordingClient( + """ + { + "slots": { + "site": "Site A", + "additional_notes": "Need } fallback" + }, + "slot_errors": [] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of( + new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, null, null), + new PromptSlotDefinition( + "additional_notes", + false, + "string", + null, + null, + null, + null, + null, + null, + null))), + "Extract slots from the input.", + "Return slots as JSON."); + + StructuredSlotExtractionResult result = + extractor.extractSlots("Analyze Site A with fallback note.", "ran-energy-saving", "en-US"); + + assertEquals(Map.of("site", "Site A", "additional_notes", "Need } fallback"), result.slots()); + assertEquals(List.of(), result.slotErrors()); + } + + @Test + void should_injectDataSchemaSection_When_schemaIsProvided() { + RecordingClient llmClient = new RecordingClient( + """ + { + "slots": { + "site": "Site A" + }, + "slot_errors": [] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, null, null))), + "Extract slots from the input.", + "Return slots as JSON."); + + Map dataSchema = Map.of( + "type", "object", + "properties", + Map.of( + "site", Map.of("type", "string", "description", "The target site name"), + "severity", Map.of("type", "string", "description", "The severity level")), + "required", List.of("site")); + + extractor.extractSlots("Analyze Site A.", "ran-energy-saving", "en-US", dataSchema); + + String userMessage = llmClient.lastUserContent(); + assertTrue(userMessage.contains("[data_schema]")); + assertTrue(userMessage.contains("\"type\":\"object\"")); + assertTrue(userMessage.contains("\"properties\"")); + assertTrue(userMessage.contains("\"The target site name\"")); + } + + @Test + void should_notIncludeDataSchemaSection_When_schemaIsNull() { + RecordingClient llmClient = new RecordingClient( + """ + { + "slots": { + "site": "Site A" + }, + "slot_errors": [] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, null, null))), + "Extract slots from the input.", + "Return slots as JSON."); + + extractor.extractSlots("Analyze Site A.", "ran-energy-saving", "en-US", null); + + String userMessage = llmClient.lastUserContent(); + assertFalse(userMessage.contains("[data_schema]")); + } + + @Test + void should_notIncludeDataSchemaSection_When_schemaIsEmpty() { + RecordingClient llmClient = new RecordingClient( + """ + { + "slots": { + "site": "Site A" + }, + "slot_errors": [] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, null, null))), + "Extract slots from the input.", + "Return slots as JSON."); + + extractor.extractSlots("Analyze Site A.", "ran-energy-saving", "en-US", Map.of()); + + String userMessage = llmClient.lastUserContent(); + assertFalse(userMessage.contains("[data_schema]")); + } + + @Test + void buildMessagesIncludesFullSlotDescriptions() { + RecordingClient llmClient = new RecordingClient( + """ + { + "slots": { + "site": "Site A" + }, + "slot_errors": [] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", + true, + "string", + null, + null, + null, + List.of("Site A", "Site B"), + null, + "The target site name", + "The site must be a physical location"))), + "Extract slots from the input.", + "Return slots as JSON."); + + extractor.extractSlots("Analyze Site A.", "ran-energy-saving", "en-US"); + + String userMessage = llmClient.lastUserContent(); + assertTrue(userMessage.contains("[slots]")); + assertTrue(userMessage.contains("\"name\"")); + assertTrue(userMessage.contains("\"site\"")); + assertTrue(userMessage.contains("\"required\"")); + assertTrue(userMessage.contains("\"type\"")); + assertTrue(userMessage.contains("\"string\"")); + assertTrue(userMessage.contains("\"description\"")); + assertTrue(userMessage.contains("\"The target site name\"")); + assertTrue(userMessage.contains("\"enum\"")); + assertTrue(userMessage.contains("\"Site A\"")); + assertTrue(userMessage.contains("\"x-a2at-value-constraint\"")); + assertTrue(userMessage.contains("\"The site must be a physical location\"")); + assertTrue(userMessage.contains("[")); + } + + @Test + void defaultExtractSlotsWithSchemaDelegatesToThreeArg() { + RecordingClient llmClient = new RecordingClient( + """ + { + "slots": { + "site": "Site A" + }, + "slot_errors": [] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, null, null))), + "Extract slots from the input.", + "Return slots as JSON."); + + PromptSlotValueExtractor lambdaExtractor = (input, code, lang) -> extractor.extractSlots(input, code, lang); + + StructuredSlotExtractionResult result = + lambdaExtractor.extractSlots("Analyze Site A.", "ran-energy-saving", "en-US", Map.of("site", "desc")); + + String userMessage = llmClient.lastUserContent(); + assertFalse(userMessage.contains("[data_schema]")); + assertEquals(Map.of("site", "Site A"), result.slots()); + } + + @Test + void slotErrorsAreParsedWithFactsAndRenderedMessage() { + RecordingClient llmClient = new RecordingClient( + """ + { + "slots": { + "site": "Site A" + }, + "slot_errors": [ + {"slot_name": "subscriptionCondition", "code": "slot.not_provided", "facts": {"slot_label": "订购条件"}}, + {"slot_name": "operationType", "code": "slot.constraint_violated", "facts": {"slot_label": "操作类型", "actual": "导出"}} + ] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, null, null))), + "Extract slots from the input.", + "Return slots as JSON."); + + StructuredSlotExtractionResult result = extractor.extractSlots("Analyze Site A.", "ran-energy-saving", "zh-CN"); + + assertEquals(2, result.slotErrors().size()); + assertEquals("slot.not_provided", result.slotErrors().get(0).code()); + assertEquals("输入中未提供「订购条件」。", result.slotErrors().get(0).message()); + assertEquals(Map.of("slot_label", "订购条件"), result.slotErrors().get(0).facts()); + assertEquals("slot.constraint_violated", result.slotErrors().get(1).code()); + assertEquals("「操作类型」的取值「导出」不在允许范围内", result.slotErrors().get(1).message()); + assertEquals( + Map.of("slot_label", "操作类型", "actual", "导出"), + result.slotErrors().get(1).facts()); + } + + @Test + void unknownSlotErrorCodeFallsBackToSlotRuleViolation() { + RecordingClient llmClient = new RecordingClient( + """ + { + "slots": { + "site": "Site A" + }, + "slot_errors": [ + {"slot_name": "operationType", "code": "data_problem", "facts": {"slot_label": "Operation Type"}} + ] + } + """); + DefaultStructuredPromptSlotValueExtractor extractor = new DefaultStructuredPromptSlotValueExtractor( + llmClient, + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, null, null))), + "Extract slots from the input.", + "Return slots as JSON."); + + StructuredSlotExtractionResult result = extractor.extractSlots("Analyze Site A.", "ran-energy-saving", "en-US"); + + assertEquals(1, result.slotErrors().size()); + assertEquals("slot.rule_violation", result.slotErrors().get(0).code()); + assertEquals( + "The value of 'operationType' violates the validation rules.", + result.slotErrors().get(0).message()); + assertEquals( + Map.of("slot_label", "operationType"), + result.slotErrors().get(0).facts()); + } + + private static final class RecordingClient implements LLMClient { + + private final String payload; + + private List> lastMessages; + + private RecordingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + this.lastMessages = messages; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + + String lastUserContent() { + for (Map message : lastMessages) { + if ("user".equals(message.get("role"))) { + return message.get("content"); + } + } + return ""; + } + } +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java index ad194a48..ad832bfe 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java @@ -170,7 +170,8 @@ void malformedLocalSlotSchemaIsWrappedAsA2ATError() throws IOException { assertThrows(A2ATError.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) .loadSlotSchema("incident_triage", "en")); - assertTrue(exception.getMessage().startsWith("Failed to read slot schema resource: ")); + assertEquals("infra.resource_read_failed", exception.getCode()); + assertTrue(exception.getMessage().startsWith("Failed to read resource '")); } @Test diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java index a0900992..59105b5d 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java @@ -49,7 +49,7 @@ void rejectsMismatchedExtensionName() { Map.of("type", "object"), TemplateUri.of("Notification-T", "network-layer", "service-recovery"))); - assertEquals("validation_invalid_input", exception.getCode()); + assertEquals("negotiation.invalid_input", exception.getCode()); } @Test @@ -64,7 +64,7 @@ void templateVersionIsUnsupported() { ContentValidationException.class, () -> validator.validate("task prompt", Map.of("type", "object"), unsupportedVersion)); - assertEquals("validation_invalid_input", exception.getCode()); + assertEquals("negotiation.invalid_input", exception.getCode()); } @Test @@ -76,18 +76,18 @@ void templateUriIsNull() { ContentValidationException.class, () -> validator.validate("task prompt", Map.of("type", "object"), null)); - assertEquals("validation_invalid_input", exception.getCode()); + assertEquals("negotiation.invalid_input", exception.getCode()); } @Test - void unsupportedLanguageFailsAtConstructionWithValidationPromptResourceNotFound() { + void unsupportedLanguageFailsAtConstructionWithTemplateNotFound() { ContentValidationException exception = assertThrows( ContentValidationException.class, () -> new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient(), STUB_LOADER)); - assertEquals("validation_prompt_resource_not_found", exception.getCode()); + assertEquals("template.not_found", exception.getCode()); assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); - assertTrue(exception.getMessage().contains("zh-CN or en-US")); + assertTrue(exception.getCause().getMessage().contains("zh-CN or en-US")); } @Test @@ -101,7 +101,7 @@ void constructionSucceedsWithEmptyLocalRootBecausePromptLoadsFromClasspath() { } @Test - void retryableFailureAfterResourceLoadStillMapsLlmInfrastructureError() { + void retryableFailureAfterResourceLoadMapsLlmInvocationFailed() { DefaultContentValidator validator = new DefaultContentValidator("Task-T", "zh-CN", 1, new FailingClient(), STUB_LOADER); @@ -109,7 +109,7 @@ void retryableFailureAfterResourceLoadStillMapsLlmInfrastructureError() { ContentValidationException.class, () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); - assertEquals("validation_llm_infrastructure_error", exception.getCode()); + assertEquals("llm.invocation_failed", exception.getCode()); } @Test @@ -124,7 +124,7 @@ void missingTemplateFailsWithTemplateNotFound() { ContentValidationException.class, () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); - assertEquals("template_not_found", exception.getCode()); + assertEquals("template.not_found", exception.getCode()); assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); } diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java index f6876423..7a7deb98 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java @@ -1,446 +1,564 @@ -package net.openan.a2at.sdk.prompt.validation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -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 java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.ValidationPipeline; -import net.openan.a2at.sdk.core.validation.ValidationResult; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import org.junit.jupiter.api.Test; - -class DefaultSemanticValidatorTest { - - private static final String VALID_RESPONSE = - """ - { - "semantic_verdict": true, - "errors": [], - "params": {"site": "Site A"} - } - """; - - private static final String TEMPLATE_CONTENT = "dummy template content"; - - @Test - void validatesEnUSPromptAndExtractsParams() { - RecordingClient llmClient = new RecordingClient(VALID_RESPONSE); - - DefaultSemanticValidator validator = new DefaultSemanticValidator(llmClient, "en-US"); - - ValidationResult result = validator.validate( - "Check Site A power usage.", Map.of("type", "object"), TemplateUri.of("Task-T", "ran-energy-saving"), TEMPLATE_CONTENT); - - assertTrue(result.verdict()); - assertEquals(Map.of("site", "Site A"), result.params()); - assertTrue(llmClient.lastSystemContent().contains("validation")); - } - - @Test - void loadsZhCnResourcesWithoutException() { - DefaultSemanticValidator validator = new DefaultSemanticValidator(null, "zh-CN"); - - assertNotNull(validator); - } - - @Test - void throwsResourceNotFoundExceptionForMissingLanguage() { - ResourceNotFoundException exception = - assertThrows(ResourceNotFoundException.class, () -> new DefaultSemanticValidator(null, "fr-FR")); - - assertTrue(exception.resourcePath().contains("content_validation")); - assertTrue(exception.getMessage().contains("zh-CN or en-US")); - } - - @Test - void validateRetriesLlmInfrastructureErrorThroughPipeline() { - FlakyClient flakyClient = new FlakyClient(1, VALID_RESPONSE); - - DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US"); - - ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2); - - FilledParamData result = pipeline.validate( - "Check Site A power usage.", Map.of("type", "object"), TemplateUri.of("Task-T", "ran-energy-saving"), TEMPLATE_CONTENT); - - assertEquals(Map.of("site", "Site A"), result.data()); - assertEquals(2, flakyClient.invocations()); - } - - @Test - void validateExhaustsRetriesAndWrapsLlmError() { - FlakyClient flakyClient = new FlakyClient( - Integer.MAX_VALUE, - """ - { - "semantic_verdict": true, - "errors": [], - "params": {} - } - """); - - DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US"); - - ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> pipeline.validate( - "Check Site A power usage.", - Map.of("type", "object"), - TemplateUri.of("Task-T", "ran-energy-saving"), TEMPLATE_CONTENT)); - - assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); - assertInstanceOf(LLMRuntimeError.class, exception.getCause().getCause()); - assertEquals(3, flakyClient.invocations()); - } - - @Test - void pipelineCarriesNullParamsIntoFilledParamData() { - // the semantic validator passed overall, but one schema slot is explicitly null: the merged - // FilledParamData must carry the key with a null value so callers scanning for blank slots can see the - // missing parameter and trigger negotiation for it - String llmJson = - "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; - DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); - - ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2); - - FilledParamData result = - pipeline.validate("task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); - - assertEquals(2, result.data().size()); - assertEquals("端口A", result.data().get("任务对象")); - assertNull(result.data().get("任务上下文")); - } - - @Test - void validatePreservesNullParamValuesAsMissingSlots() { - String llmJson = - "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; - DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); - - ValidationResult result = - validator.validate("task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); - - assertTrue(result.verdict()); - // a null param is the validator's signal that a schema slot is missing from the content; the key must stay - // present with a null value so downstream missing-slot detection (negotiation triggering) can see it - assertEquals(2, result.params().size()); - assertEquals("端口A", result.params().get("任务对象")); - assertTrue(result.params().containsKey("任务上下文")); - assertNull(result.params().get("任务上下文")); - } - - @Test - void missingVerdictKeyFailsWithInfrastructureError() { - DefaultSemanticValidator validator = - new DefaultSemanticValidator(new StubClient("{\"errors\": [], \"params\": {}}"), "en-US"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT)); - - assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); - assertTrue(exception.getMessage().contains("semantic_verdict")); - } - - @Test - void stringVerdictValueFailsWithInfrastructureError() { - DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}"), "en-US"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT)); - - assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); - assertTrue(exception.getMessage().contains("semantic_verdict")); - } - - @Test - void nonListErrorsFailsWithInfrastructureError() { - DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("{\"semantic_verdict\": true, \"errors\": {}, \"params\": {}}"), "en-US"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT)); - - assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); - assertTrue(exception.getMessage().contains("errors")); - } - - @Test - void malformedErrorElementFailsWithInfrastructureError() { - DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("{\"semantic_verdict\": false, \"errors\": [\"oops\"], \"params\": {}}"), "en-US"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT)); - - assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); - assertTrue(exception.getMessage().contains("errors")); - } - - @Test - void nonMapParamsFailsWithInfrastructureError() { - DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": []}"), "en-US"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT)); - - assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); - assertTrue(exception.getMessage().contains("params")); - } - - @Test - void stringParamKeysPassTheContract() { - DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": {\"site\": \"Site A\"}}"), - "en-US"); - - ValidationResult result = - validator.validate("task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); - - assertTrue(result.verdict()); - } - - @Test - void nonStringParamKeyFailsWithInfrastructureError() { - // JSON object keys are strings by definition, so the negative path is driven through a parser - // stub that produces a non-string key - Map rawParams = new LinkedHashMap<>(); - rawParams.put(42, "Site A"); - Map parsed = new LinkedHashMap<>(); - parsed.put("semantic_verdict", Boolean.TRUE); - parsed.put("errors", List.of()); - parsed.put("params", rawParams); - DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("payload content is irrelevant"), "en-US", prompt -> parsed); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT)); - - assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); - assertTrue(exception.getMessage().contains("params keys")); - } - - @Test - void contractViolationRetriesAndRecoversThroughPipeline() { - FlakyPayloadClient client = new FlakyPayloadClient( - "{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", VALID_RESPONSE); - - DefaultSemanticValidator validator = new DefaultSemanticValidator(client, "en-US"); - ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2); - - FilledParamData result = pipeline.validate( - "Check Site A power usage.", Map.of("type", "object"), TemplateUri.of("Task-T", "ran-energy-saving"), TEMPLATE_CONTENT); - - // first attempt violates the output contract (string verdict) → retryable infra error → second attempt - // returns a compliant response and the pipeline succeeds - assertEquals(Map.of("site", "Site A"), result.data()); - assertEquals(2, client.invocations()); - } - - @Test - void contractViolationExhaustsRetriesThroughPipeline() { - FlakyPayloadClient client = - new FlakyPayloadClient("{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", null); - - DefaultSemanticValidator validator = new DefaultSemanticValidator(client, "en-US"); - ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> pipeline.validate( - "Check Site A power usage.", - Map.of("type", "object"), - TemplateUri.of("Task-T", "ran-energy-saving"), TEMPLATE_CONTENT)); - - assertEquals(A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); - assertEquals(3, client.invocations()); - } - - @Test - void templateContentWithPlaceholderLiteralIsNotSecondOrderReplaced() { - String templateContentWithPlaceholder = "Template body with [extension_name] and [input] literals."; - RecordingClient recordingClient = new RecordingClient(VALID_RESPONSE); - DefaultSemanticValidator validator = new DefaultSemanticValidator(recordingClient, "en-US"); - - validator.validate( - "Check Site A power usage.", - Map.of("type", "object"), - TemplateUri.of("Task-T", "network-layer"), - templateContentWithPlaceholder); - - String userContent = recordingClient.userContent(); - assertTrue(userContent.contains("Template body with [extension_name] and [input] literals.")); - assertTrue(userContent.contains("Task-T")); - assertTrue(userContent.contains("Check Site A power usage")); - } - - @Test - void templateContentPlaceholderIsFilled() { - RecordingClient recordingClient = new RecordingClient(VALID_RESPONSE); - DefaultSemanticValidator validator = new DefaultSemanticValidator(recordingClient, "en-US"); - - validator.validate( - "Check Site A power usage.", - Map.of("type", "object"), - TemplateUri.of("Task-T", "network-layer"), - "## Energy Saving Template\n\nCheck the energy saving region."); - - String userContent = recordingClient.userContent(); - assertTrue(userContent.contains("## Energy Saving Template")); - assertTrue(userContent.contains("energy saving region")); - } - - private static final class RecordingClient implements LLMClient { - - private final String payload; - - private List> lastMessages; - - private RecordingClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - this.lastMessages = messages; - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - - String lastSystemContent() { - for (Map message : lastMessages) { - if ("system".equals(message.get("role"))) { - return message.get("content"); - } - } - return ""; - } - - String userContent() { - for (Map message : lastMessages) { - if ("user".equals(message.get("role"))) { - return message.get("content"); - } - } - return ""; - } - } - - private static final class FlakyClient implements LLMClient { - - private final int failuresToSimulate; - - private final String successPayload; - - private final AtomicInteger counter = new AtomicInteger(0); - - private FlakyClient(int failuresToSimulate, String successPayload) { - this.failuresToSimulate = failuresToSimulate; - this.successPayload = successPayload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - int invocation = counter.incrementAndGet(); - if (invocation <= failuresToSimulate) { - throw new LLMRuntimeError("network timeout"); - } - return new LLMResponse( - successPayload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - - int invocations() { - return counter.get(); - } - } - - /** Stub LLM client returning a fixed content payload. */ - private static final class StubClient implements LLMClient { - - private final String content; - - private StubClient(String content) { - this.content = content; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse(content, "stub-llm", Map.of(), Map.of()); - } - } - - /** - * LLM client returning a malformed payload first, then a compliant one (or always malformed when success is null). - */ - private static final class FlakyPayloadClient implements LLMClient { - - private final String malformedPayload; - - private final String successPayload; - - private final AtomicInteger counter = new AtomicInteger(0); - - private FlakyPayloadClient(String malformedPayload, String successPayload) { - this.malformedPayload = malformedPayload; - this.successPayload = successPayload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - int invocation = counter.incrementAndGet(); - if (invocation == 1 || successPayload == null) { - return new LLMResponse(malformedPayload, "test-model", Map.of(), Map.of()); - } - return new LLMResponse(successPayload, "test-model", Map.of(), Map.of()); - } - - int invocations() { - return counter.get(); - } - } -} +package net.openan.a2at.sdk.prompt.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.ValidationPipeline; +import net.openan.a2at.sdk.core.validation.ValidationResult; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.junit.jupiter.api.Test; + +class DefaultSemanticValidatorTest { + + private static final String VALID_RESPONSE = + """ + { + "semantic_verdict": true, + "errors": [], + "params": {"site": "Site A"} + } + """; + + private static final String TEMPLATE_CONTENT = "dummy template content"; + + @Test + void validatesEnUSPromptAndExtractsParams() { + RecordingClient llmClient = new RecordingClient(VALID_RESPONSE); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(llmClient, "en-US"); + + ValidationResult result = validator.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT); + + assertTrue(result.verdict()); + assertEquals(Map.of("site", "Site A"), result.params()); + assertTrue(llmClient.lastSystemContent().contains("validation")); + } + + @Test + void loadsZhCnResourcesWithoutException() { + DefaultSemanticValidator validator = new DefaultSemanticValidator(null, "zh-CN"); + + assertNotNull(validator); + } + + @Test + void throwsResourceNotFoundExceptionForMissingLanguage() { + ResourceNotFoundException exception = + assertThrows(ResourceNotFoundException.class, () -> new DefaultSemanticValidator(null, "fr-FR")); + + assertTrue(exception.resourcePath().contains("content_validation")); + assertTrue(exception.getMessage().contains("zh-CN or en-US")); + } + + @Test + void validateRetriesLlmInfrastructureErrorThroughPipeline() { + FlakyClient flakyClient = new FlakyClient(1, VALID_RESPONSE); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US"); + + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2, "en-US"); + + FilledParamData result = pipeline.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT); + + assertEquals(Map.of("site", "Site A"), result.data()); + assertEquals(2, flakyClient.invocations()); + } + + @Test + void validateExhaustsRetriesAndWrapsLlmError() { + FlakyClient flakyClient = new FlakyClient( + Integer.MAX_VALUE, + """ + { + "semantic_verdict": true, + "errors": [], + "params": {} + } + """); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US"); + + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3, "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> pipeline.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT)); + + assertEquals("llm.invocation_failed", exception.getCode()); + assertInstanceOf(LLMRuntimeError.class, exception.getCause()); + assertEquals(3, flakyClient.invocations()); + } + + @Test + void pipelineCarriesNullParamsIntoFilledParamData() { + // the semantic validator passed overall, but one schema slot is explicitly null: the merged + // FilledParamData must carry the key with a null value so callers scanning for blank slots can see the + // missing parameter and trigger negotiation for it + String llmJson = + "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); + + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2, "en-US"); + + FilledParamData result = pipeline.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertEquals(2, result.data().size()); + assertEquals("端口A", result.data().get("任务对象")); + assertNull(result.data().get("任务上下文")); + } + + @Test + void validatePreservesNullParamValuesAsMissingSlots() { + String llmJson = + "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertTrue(result.verdict()); + // a null param is the validator's signal that a schema slot is missing from the content; the key must stay + // present with a null value so downstream missing-slot detection (negotiation triggering) can see it + assertEquals(2, result.params().size()); + assertEquals("端口A", result.params().get("任务对象")); + assertTrue(result.params().containsKey("任务上下文")); + assertNull(result.params().get("任务上下文")); + } + + @Test + void missingVerdictKeyFailsWithInfrastructureError() { + DefaultSemanticValidator validator = + new DefaultSemanticValidator(new StubClient("{\"errors\": [], \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("semantic_verdict")); + } + + @Test + void stringVerdictValueFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("semantic_verdict")); + } + + @Test + void nonListErrorsFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": true, \"errors\": {}, \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("errors")); + } + + @Test + void malformedErrorElementFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": false, \"errors\": [\"oops\"], \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("errors")); + } + + @Test + void nonMapParamsFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": []}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("params")); + } + + @Test + void stringParamKeysPassTheContract() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": {\"site\": \"Site A\"}}"), + "en-US"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertTrue(result.verdict()); + } + + @Test + void nonStringParamKeyFailsWithInfrastructureError() { + // JSON object keys are strings by definition, so the negative path is driven through a parser + // stub that produces a non-string key + Map rawParams = new LinkedHashMap<>(); + rawParams.put(42, "Site A"); + Map parsed = new LinkedHashMap<>(); + parsed.put("semantic_verdict", Boolean.TRUE); + parsed.put("errors", List.of()); + parsed.put("params", rawParams); + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("payload content is irrelevant"), "en-US", prompt -> parsed); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("params keys")); + } + + @Test + void contractViolationRetriesAndRecoversThroughPipeline() { + FlakyPayloadClient client = new FlakyPayloadClient( + "{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", VALID_RESPONSE); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(client, "en-US"); + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2, "en-US"); + + FilledParamData result = pipeline.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT); + + // first attempt violates the output contract (string verdict) → retryable infra error → second attempt + // returns a compliant response and the pipeline succeeds + assertEquals(Map.of("site", "Site A"), result.data()); + assertEquals(2, client.invocations()); + } + + @Test + void contractViolationExhaustsRetriesThroughPipeline() { + FlakyPayloadClient client = + new FlakyPayloadClient("{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", null); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(client, "en-US"); + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3, "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> pipeline.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertEquals(3, client.invocations()); + } + + @Test + void parsesErrorsWithFactsAndRendersTheMessage() { + String llmJson = "{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"subscriptionCondition\"," + + " \"code\": \"content.param_missing\", \"facts\": {\"section_label\": \"订阅条件\"}}]," + + "\"params\": {}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertFalse(result.verdict()); + assertEquals(1, result.errors().size()); + SlotValidationError error = result.errors().get(0); + assertEquals("subscriptionCondition", error.slotName()); + assertEquals("content.param_missing", error.code()); + assertEquals("「订阅条件」未填写,请补充该参数的取值", error.message()); + assertEquals(Map.of("section_label", "订阅条件"), error.facts()); + } + + @Test + void rendersEntryFieldMissingMessageInEnglish() { + String llmJson = "{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"policyList\"," + + " \"code\": \"content.entry_field_missing\"," + + " \"facts\": {\"section_label\": \"Policy List\", \"index\": 2," + + " \"field_label\": \"Effective Date\"}}], \"params\": {}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "en-US"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertEquals(1, result.errors().size()); + SlotValidationError error = result.errors().get(0); + assertEquals("content.entry_field_missing", error.code()); + assertEquals("Entry 2 of 'Policy List' is missing required field 'Effective Date'", error.message()); + } + + @Test + void unknownErrorCodeFallsBackToContentRuleViolation() { + String llmJson = "{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"policyList\"," + + " \"code\": \"data_problem\", \"facts\": {\"section_label\": \"Policy List\"}}]," + + "\"params\": {}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "en-US"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertEquals(1, result.errors().size()); + SlotValidationError error = result.errors().get(0); + assertEquals("content.rule_violation", error.code()); + assertEquals("The value of 'policyList' violates the validation rules.", error.message()); + assertEquals(Map.of("section_label", "policyList"), error.facts()); + } + + @Test + void missingLlmClientFailsWithLlmNotConfigured() { + DefaultSemanticValidator validator = new DefaultSemanticValidator(null, "zh-CN"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.not_configured", exception.getCode()); + assertEquals("未配置 LLM 客户端,无法执行该操作(请检查 A2AT_LLM_* 配置)", exception.getMessage()); + } + + @Test + void malformedErrorFactsFailsWithResponseInvalid() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"site\", \"code\":" + + " \"content.param_missing\"}], \"params\": {}}"), + "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("facts")); + } + + @Test + void templateContentWithPlaceholderLiteralIsNotSecondOrderReplaced() { + String templateContentWithPlaceholder = "Template body with [extension_name] and [input] literals."; + RecordingClient recordingClient = new RecordingClient(VALID_RESPONSE); + DefaultSemanticValidator validator = new DefaultSemanticValidator(recordingClient, "en-US"); + + validator.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + templateContentWithPlaceholder); + + String userContent = recordingClient.userContent(); + assertTrue(userContent.contains("Template body with [extension_name] and [input] literals.")); + assertTrue(userContent.contains("Task-T")); + assertTrue(userContent.contains("Check Site A power usage")); + } + + @Test + void templateContentPlaceholderIsFilled() { + RecordingClient recordingClient = new RecordingClient(VALID_RESPONSE); + DefaultSemanticValidator validator = new DefaultSemanticValidator(recordingClient, "en-US"); + + validator.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + "## Energy Saving Template\n\nCheck the energy saving region."); + + String userContent = recordingClient.userContent(); + assertTrue(userContent.contains("## Energy Saving Template")); + assertTrue(userContent.contains("energy saving region")); + } + + private static final class RecordingClient implements LLMClient { + + private final String payload; + + private List> lastMessages; + + private RecordingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + this.lastMessages = messages; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + + String lastSystemContent() { + for (Map message : lastMessages) { + if ("system".equals(message.get("role"))) { + return message.get("content"); + } + } + return ""; + } + + String userContent() { + for (Map message : lastMessages) { + if ("user".equals(message.get("role"))) { + return message.get("content"); + } + } + return ""; + } + } + + private static final class FlakyClient implements LLMClient { + + private final int failuresToSimulate; + + private final String successPayload; + + private final AtomicInteger counter = new AtomicInteger(0); + + private FlakyClient(int failuresToSimulate, String successPayload) { + this.failuresToSimulate = failuresToSimulate; + this.successPayload = successPayload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + int invocation = counter.incrementAndGet(); + if (invocation <= failuresToSimulate) { + throw new LLMRuntimeError("network timeout"); + } + return new LLMResponse( + successPayload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + + int invocations() { + return counter.get(); + } + } + + /** Stub LLM client returning a fixed content payload. */ + private static final class StubClient implements LLMClient { + + private final String content; + + private StubClient(String content) { + this.content = content; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(content, "stub-llm", Map.of(), Map.of()); + } + } + + /** + * LLM client returning a malformed payload first, then a compliant one (or always malformed when success is null). + */ + private static final class FlakyPayloadClient implements LLMClient { + + private final String malformedPayload; + + private final String successPayload; + + private final AtomicInteger counter = new AtomicInteger(0); + + private FlakyPayloadClient(String malformedPayload, String successPayload) { + this.malformedPayload = malformedPayload; + this.successPayload = successPayload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + int invocation = counter.incrementAndGet(); + if (invocation == 1 || successPayload == null) { + return new LLMResponse(malformedPayload, "test-model", Map.of(), Map.of()); + } + return new LLMResponse(successPayload, "test-model", Map.of(), Map.of()); + } + + int invocations() { + return counter.get(); + } + } +} From 0a72e47757a3a7a832157857fcfadb94635369cc Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 29 Aug 2026 13:19:28 +0800 Subject: [PATCH 238/295] refactor(negotiation): migrate content and validation errors to catalog codes Migrate generation/validation error codes to the dotted catalog (negotiation.*, llm.*, template.*), re-parent the module business exceptions onto A2ATBusinessException, replace fromData content-data IllegalArguments with coded business exceptions (D7: URI-shape IAEs stay), close the semantic validator code set with domain restriction, and surface LLM-reported slot errors instead of discarding them. Signed-off-by: codetalks-hw --- .../NegotiationGenerationException.java | 34 +- .../NegotiationParamExtractionException.java | 97 +- .../NegotiationProcessingException.java | 113 +- .../generation/AbortGenerator.java | 87 +- .../AbstractNegotiationGenerator.java | 263 +-- .../DefaultNegotiationContentExtractor.java | 222 ++- .../FeasibilityEndingGenerator.java | 95 +- .../FeasibilityProposeGenerator.java | 216 +-- .../InformationEndingGenerator.java | 89 +- .../InformationProposeGenerator.java | 102 +- .../NegotiationContentExtractor.java | 10 +- .../generation/NegotiationContentService.java | 109 +- .../NegotiationGenerationOrchestrator.java | 204 ++- ...otiationGenerationOrchestratorBuilder.java | 16 +- .../NegotiationGeneratorRegistry.java | 36 +- .../generation/TargetEndingGenerator.java | 113 +- .../generation/TargetProposeGenerator.java | 179 +- .../DefaultNegotiationComplianceChecker.java | 199 ++- .../DefaultNegotiationSemanticValidator.java | 143 +- .../NegotiationRuleCheckerAdapter.java | 155 +- .../NegotiationSemanticValidator.java | 138 +- .../validation/ParamExtractor.java | 51 +- .../NegotiationExceptionHierarchyTest.java | 61 +- ...otiationDeliveredResourceContractTest.java | 47 +- .../AbortMessageGenerationTest.java | 576 ++++--- .../FromDataProgrammingErrorMatrixTest.java | 402 ++--- .../generation/FromTextLlmPipelineTest.java | 1527 +++++++++-------- .../generation/InputTextLengthLimitTest.java | 337 ++-- .../NegotiationContentExtractorTest.java | 132 +- ...nerationOrchestratorBuilderWiringTest.java | 6 +- ...NegotiationGenerationOrchestratorTest.java | 12 +- .../NegotiationGeneratorRegistryTest.java | 385 +++-- .../generation/NegotiationGeneratorsTest.java | 154 +- .../TemplateUriEntryMatrixTest.java | 8 +- .../ValidateAndFillingDataPipelineTest.java | 181 +- .../NegotiationErrorCodeUsageMatrixTest.java | 876 +++++----- ...NegotiationExceptionTreeRootCatchTest.java | 24 +- .../NegotiationLogEventContractTest.java | 15 +- ...NegotiationPublicExceptionSurfaceTest.java | 318 ++-- .../validation/ComplianceCheckerTest.java | 257 +-- ...faultNegotiationSemanticValidatorTest.java | 836 ++++----- .../validation/ParamExtractorTest.java | 32 +- 42 files changed, 4729 insertions(+), 4128 deletions(-) diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java index 6744acf6..b0372214 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java @@ -1,5 +1,9 @@ package net.openan.a2at.sdk.negotiation.content; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import org.jspecify.annotations.Nullable; + /** * Raised when generating a negotiation message fails at runtime. * @@ -24,7 +28,35 @@ public NegotiationGenerationException(String code, String message) { * @param message failure message * @param cause root cause */ - public NegotiationGenerationException(String code, String message, Throwable cause) { + public NegotiationGenerationException(String code, String message, @Nullable Throwable cause) { super(code, message, cause); } + + /** + * Creates a negotiation generation failure whose message is rendered from the code's template in one language. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + */ + public NegotiationGenerationException( + ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { + super(entry, language, facts); + } + + /** + * Creates a negotiation generation failure with a rendered message and a root cause. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param cause root cause + */ + public NegotiationGenerationException( + ErrorCatalog entry, + @Nullable String language, + @Nullable Map facts, + @Nullable Throwable cause) { + super(entry, language, facts, cause); + } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationParamExtractionException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationParamExtractionException.java index 7d93ff3b..c7407ab8 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationParamExtractionException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationParamExtractionException.java @@ -1,7 +1,10 @@ package net.openan.a2at.sdk.negotiation.content; import java.util.List; -import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; import net.openan.a2at.sdk.core.model.SlotValidationError; import org.jspecify.annotations.Nullable; @@ -10,38 +13,108 @@ * * @since 2026-08 */ -public class NegotiationParamExtractionException extends A2ATParamExtractionError { +public class NegotiationParamExtractionException extends A2ATBusinessException { + + private final List errors; /** - * Creates a negotiation parameter-extraction failure with the default error code and no slot details. + * Creates a negotiation parameter-extraction failure with one specific error code and slot details. * + * @param code machine-readable error code for the failure * @param message failure message + * @param errors structured per-slot validation error details */ - public NegotiationParamExtractionException(String message) { - super(message); + public NegotiationParamExtractionException(String code, String message, List errors) { + super(code, message); + this.errors = errors == null ? List.of() : List.copyOf(errors); } /** - * Creates a negotiation parameter-extraction failure with one specific error code and slot details. + * Creates a negotiation parameter-extraction failure with one specific error code, slot details and a root cause. * * @param code machine-readable error code for the failure * @param message failure message * @param errors structured per-slot validation error details + * @param cause root cause of the failure */ - public NegotiationParamExtractionException(String code, String message, List errors) { - super(code, message, errors); + public NegotiationParamExtractionException( + String code, String message, List errors, @Nullable Throwable cause) { + super(code, message); + if (cause != null) { + initCause(cause); + } + this.errors = errors == null ? List.of() : List.copyOf(errors); } /** - * Creates a negotiation parameter-extraction failure with one specific error code, slot details and a root cause. + * Creates a negotiation parameter-extraction failure carrying one catalog code, an already rendered message, its + * fact values, slot details and a root cause. + * + *

        Unlike the {@code ErrorCatalog}-rendering constructors, this constructor keeps the message exactly as passed + * in, so a message rendered upstream (for example by the shared validation pipeline) survives the wrap unchanged + * while the facts stay available to callers. * * @param code machine-readable error code for the failure - * @param message failure message + * @param message already rendered failure message + * @param facts fact values keyed by fact parameter name, may be null * @param errors structured per-slot validation error details * @param cause root cause of the failure */ public NegotiationParamExtractionException( - String code, String message, List errors, Throwable cause) { - super(code, message, errors, cause); + String code, + String message, + @Nullable Map facts, + List errors, + @Nullable Throwable cause) { + super(code, message, facts, cause); + this.errors = errors == null ? List.of() : List.copyOf(errors); + } + + /** + * Creates a negotiation parameter-extraction failure whose message is rendered from the code's template in one + * language. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param errors structured per-slot validation error details + */ + public NegotiationParamExtractionException( + ErrorCatalog entry, + @Nullable String language, + @Nullable Map facts, + List errors) { + this(entry, language, facts, errors, null); + } + + /** + * Creates a negotiation parameter-extraction failure with a rendered message, slot details and a root cause. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param errors structured per-slot validation error details + * @param cause root cause of the failure + */ + public NegotiationParamExtractionException( + ErrorCatalog entry, + @Nullable String language, + @Nullable Map facts, + List errors, + @Nullable Throwable cause) { + super(entry.getCode(), ErrorMessages.render(entry, language, facts), facts); + if (cause != null) { + initCause(cause); + } + this.errors = errors == null ? List.of() : List.copyOf(errors); + } + + /** + * Returns the structured per-slot validation error details. + * + * @return immutable list of slot validation errors, never null + */ + public List getErrors() { + return errors; } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java index 7cb3ebfc..ae02cdeb 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java @@ -1,37 +1,76 @@ -package net.openan.a2at.sdk.negotiation.content; - -import net.openan.a2at.sdk.core.exception.A2ATError; - -/** - * Base failure type of the generation branch, raised when a negotiation generation step fails at runtime. - * - *

        Each failure carries a machine-readable error code from {@code A2ATErrorCodes} — inherited from the - * {@link A2ATError} root — so callers can branch on the failure class without parsing messages. The - * parameter-extraction branch ({@code NegotiationParamExtractionException}) is deliberately not a subtype; catch - * {@link A2ATError} for full negotiation failure coverage. - * - * @since 2026-08 - */ -public class NegotiationProcessingException extends A2ATError { - - /** - * Creates a negotiation processing failure with one error code. - * - * @param code machine-readable error code for the failure - * @param message failure message - */ - public NegotiationProcessingException(String code, String message) { - super(code, message); - } - - /** - * Creates a negotiation processing failure with one error code and a root cause. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param cause root cause - */ - public NegotiationProcessingException(String code, String message, Throwable cause) { - super(code, message, cause); - } -} +package net.openan.a2at.sdk.negotiation.content; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import org.jspecify.annotations.Nullable; + +/** + * Base failure type of the generation branch, raised when a negotiation generation step fails at runtime. + * + *

        Each failure carries a machine-readable code from {@link ErrorCatalog} — inherited from the + * {@link A2ATBusinessException} base — and a message rendered from the code's message template, so callers can branch + * on the failure class without parsing messages. The parameter-extraction branch + * ({@code NegotiationParamExtractionException}) is deliberately not a subtype; catch {@link A2ATBusinessException} for + * full negotiation business-failure coverage. + * + * @since 2026-08 + */ +public class NegotiationProcessingException extends A2ATBusinessException { + + /** + * Creates a negotiation processing failure with one error code. + * + * @param code machine-readable error code for the failure + * @param message failure message + */ + public NegotiationProcessingException(String code, String message) { + super(code, message); + } + + /** + * Creates a negotiation processing failure with one error code and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param cause root cause + */ + public NegotiationProcessingException(String code, String message, @Nullable Throwable cause) { + super(code, message); + if (cause != null) { + initCause(cause); + } + } + + /** + * Creates a negotiation processing failure whose message is rendered from the code's template in one language. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + */ + public NegotiationProcessingException( + ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { + super(entry.getCode(), ErrorMessages.render(entry, language, facts), facts); + } + + /** + * Creates a negotiation processing failure with a rendered message and a root cause. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param cause root cause + */ + public NegotiationProcessingException( + ErrorCatalog entry, + @Nullable String language, + @Nullable Map facts, + @Nullable Throwable cause) { + super(entry.getCode(), ErrorMessages.render(entry, language, facts), facts); + if (cause != null) { + initCause(cause); + } + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java index ecf2add1..7a102a5c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java @@ -1,43 +1,44 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Generator for the type-independent abort negotiation message. - * - *

        The common abort template carries the fixed {@code Abort} conclusion section, so this generator fills only the - * negotiation context and the termination reason slots. - * - * @since 2026-08 - */ -final class AbortGenerator extends AbstractNegotiationGenerator { - - /** - * Generates an abort negotiation message. - * - * @param context negotiation context of the message - * @param content abort content carrying the termination reason - * @param template common abort template to render - * @param vocabulary vocabulary of the message language - * @return rendered abort message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - NegotiationAbortContent abortContent = contentOf(content, NegotiationAbortContent.class, "Abort generator"); - Map slots = new LinkedHashMap<>(); - slots.put( - vocabulary.get("slot.termination_reason"), - requiredText( - abortContent.terminationReason(), - "content.terminationReason", - "Termination reason of an abort negotiation message")); - return render(template, slots); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Generator for the type-independent abort negotiation message. + * + *

        The common abort template carries the fixed {@code Abort} conclusion section, so this generator fills only the + * negotiation context and the termination reason slots. + * + * @since 2026-08 + */ +final class AbortGenerator extends AbstractNegotiationGenerator { + + /** + * Generates an abort negotiation message. + * + * @param context negotiation context of the message + * @param content abort content carrying the termination reason + * @param template common abort template to render + * @param vocabulary vocabulary of the message language + * @return rendered abort message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + NegotiationAbortContent abortContent = contentOf(content, NegotiationAbortContent.class, "Abort generator"); + Map slots = new LinkedHashMap<>(); + slots.put( + vocabulary.get("slot.termination_reason"), + requiredText( + abortContent.terminationReason(), + "content.terminationReason", + "Termination reason of an abort negotiation message", + vocabulary)); + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java index 76c76909..48495d5d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java @@ -1,121 +1,142 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Shared plumbing of the six negotiation generators. - * - *

        Subclasses validate their exact content type, assemble the slot map and hand over to the shared renderers. The - * slot map is keyed by the language-specific slot names taken from the vocabulary. - */ -abstract class AbstractNegotiationGenerator implements NegotiationGenerator { - - private final NegotiationPromptRenderer promptRenderer = new NegotiationPromptRenderer(); - - private final NegotiationItemFormatter itemFormatter = new NegotiationItemFormatter(); - - /** - * Casts the content to the exact type this generator serves. - * - * @param expected content type - * @param content content object validated by the registry - * @param expectedType exact content class this generator serves - * @param generatorName human-readable generator name used in the failure message - * @return content cast to the expected type - * @throws IllegalArgumentException if the content is null or of another runtime type - */ - protected static T contentOf( - NegotiationContent content, Class expectedType, String generatorName) { - if (content == null || content.getClass() != expectedType) { - throw new IllegalArgumentException( - generatorName + " requires content of type " + expectedType.getSimpleName() + " but received " - + (content == null ? "null" : content.getClass().getSimpleName()) + "."); - } - return expectedType.cast(content); - } - - /** - * Validates that a conclusion is renderable by a typed ending generator, rejecting null and the abort conclusion. - * - * @param conclusion conclusion carried by ending content - * @return the validated conclusion - * @throws NullPointerException if the conclusion is null - * @throws IllegalArgumentException if the conclusion is abort; the abort message has no typed conclusion slot and - * is generated through the abort phase against the common abort template instead - */ - protected static NegotiationConclusion renderableConclusion(NegotiationConclusion conclusion) { - Objects.requireNonNull( - conclusion, "Negotiation conclusion must not be null; accept and reject are the renderable" - + " conclusions of a typed negotiation."); - if (conclusion == NegotiationConclusion.ABORT) { - throw new IllegalArgumentException( - "Typed negotiation templates carry no Abort conclusion slot; generate the abort message of a" - + " terminated negotiation with generateAbortFromData against the common abort template" - + " instead."); - } - return conclusion; - } - - /** - * Validates that a required text field is present. - * - * @param value field value - * @param field field path used in the failure - * @param description field description used in the failure message - * @return the validated text - * @throws IllegalArgumentException if the value is null or blank - */ - protected static String requiredText(String value, String field, String description) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException(description + " must not be blank."); - } - return value; - } - - /** - * Validates that a required item list is present and non-empty. - * - * @param items item list - * @param field field path used in the failure - * @param description field description used in the failure message - * @return the validated item list - * @throws IllegalArgumentException if the list is null or empty - */ - protected static List requiredItems( - List items, String field, String description) { - if (items == null || items.isEmpty()) { - throw new IllegalArgumentException(description + " must contain at least one item."); - } - return items; - } - - /** - * Formats an item list with the list punctuation of the message language. - * - * @param items items to format; null formats as an empty value - * @param vocabulary vocabulary of the message language - * @return formatted numbered lines, or an empty string when there is no item - */ - protected String formatItems(List items, Vocabulary vocabulary) { - return itemFormatter.format(items, vocabulary.get("punct.list_colon")); - } - - /** - * Renders the template with the assembled slots. - * - * @param template loaded template to render - * @param slots slot values keyed by language-specific slot names - * @return rendered negotiation message text - */ - protected String render(PromptTemplate template, Map slots) { - return promptRenderer.render(template.content(), slots); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Shared plumbing of the six negotiation generators. + * + *

        Subclasses validate their exact content type, assemble the slot map and hand over to the shared renderers. The + * slot map is keyed by the language-specific slot names taken from the vocabulary. + */ +abstract class AbstractNegotiationGenerator implements NegotiationGenerator { + + private final NegotiationPromptRenderer promptRenderer = new NegotiationPromptRenderer(); + + private final NegotiationItemFormatter itemFormatter = new NegotiationItemFormatter(); + + /** + * Casts the content to the exact type this generator serves. + * + * @param expected content type + * @param content content object validated by the registry + * @param expectedType exact content class this generator serves + * @param generatorName human-readable generator name used in the failure message + * @return content cast to the expected type + * @throws IllegalArgumentException if the content is null or of another runtime type + */ + protected static T contentOf( + NegotiationContent content, Class expectedType, String generatorName) { + if (content == null || content.getClass() != expectedType) { + throw new IllegalArgumentException( + generatorName + " requires content of type " + expectedType.getSimpleName() + " but received " + + (content == null ? "null" : content.getClass().getSimpleName()) + "."); + } + return expectedType.cast(content); + } + + /** + * Validates that a conclusion is renderable by a typed ending generator, rejecting null and the abort conclusion. + * + * @param conclusion conclusion carried by ending content + * @return the validated conclusion + * @throws NullPointerException if the conclusion is null + * @throws IllegalArgumentException if the conclusion is abort; the abort message has no typed conclusion slot and + * is generated through the abort phase against the common abort template instead + */ + protected static NegotiationConclusion renderableConclusion(NegotiationConclusion conclusion) { + Objects.requireNonNull( + conclusion, + "Negotiation conclusion must not be null; accept and reject are the renderable" + + " conclusions of a typed negotiation."); + if (conclusion == NegotiationConclusion.ABORT) { + throw new IllegalArgumentException( + "Typed negotiation templates carry no Abort conclusion slot; generate the abort message of a" + + " terminated negotiation with generateAbortFromData against the common abort template" + + " instead."); + } + return conclusion; + } + + /** + * Validates that a required text field is present. + * + * @param value field value + * @param field field path used in the failure facts + * @param description field description used in the failure reason + * @param vocabulary vocabulary of the message language, used to render the failure message + * @return the validated text + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the value is null or + * blank + */ + protected static String requiredText(String value, String field, String description, Vocabulary vocabulary) { + if (value == null || value.isBlank()) { + throw contentInvalid(field, description + " must not be blank.", vocabulary); + } + return value; + } + + /** + * Validates that a required item list is present and non-empty. + * + * @param items item list + * @param field field path used in the failure facts + * @param description field description used in the failure reason + * @param vocabulary vocabulary of the message language, used to render the failure message + * @return the validated item list + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the list is null or + * empty + */ + protected static List requiredItems( + List items, String field, String description, Vocabulary vocabulary) { + if (items == null || items.isEmpty()) { + throw contentInvalid(field, description + " must contain at least one item.", vocabulary); + } + return items; + } + + /** + * Builds the coded failure for invalid negotiation content data of a from-data generation. + * + * @param field content field path + * @param reason failure reason + * @param vocabulary vocabulary of the message language, used to render the failure message + * @return generation failure carrying the code {@code negotiation.content_invalid} + */ + protected static NegotiationGenerationException contentInvalid(String field, String reason, Vocabulary vocabulary) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_CONTENT_INVALID, + vocabulary.language(), + Map.of("field", field, "reason", reason)); + } + + /** + * Formats an item list with the list punctuation of the message language. + * + * @param items items to format; null formats as an empty value + * @param vocabulary vocabulary of the message language + * @return formatted numbered lines, or an empty string when there is no item + */ + protected String formatItems(List items, Vocabulary vocabulary) { + return itemFormatter.format(items, vocabulary.get("punct.list_colon")); + } + + /** + * Renders the template with the assembled slots. + * + * @param template loaded template to render + * @param slots slot values keyed by language-specific slot names + * @return rendered negotiation message text + */ + protected String render(PromptTemplate template, Map slots) { + return promptRenderer.render(template.content(), slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java index 67ec43cc..3534d43c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java @@ -5,17 +5,17 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; import net.openan.a2at.sdk.core.json.JacksonJsonValueParser; import net.openan.a2at.sdk.core.json.JsonValueParser; import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; @@ -34,10 +34,14 @@ * *

        The extractor loads the system and user prompts of the addressed negotiation type from the classpath, asks the LLM * for the snake_case JSON described by the extraction schema, and maps the response onto the typed content records. - * Failure mapping: transport failures map to {@code negotiation_llm_infrastructure_error}, responses that cannot be - * parsed as the expected content map to {@code negotiation_content_extract_failed}, missing required fields map to the - * non-retryable {@code negotiation_slot_missing}, and content that contradicts the addressed phase or action maps to - * {@code negotiation_invalid_input}. + * Failure mapping: a missing LLM client maps to {@code llm.not_configured}, transport failures map to + * {@code llm.invocation_failed}, responses that violate the response contract (empty or not a JSON object) map to the + * retryable {@code llm.response_invalid}, responses that cannot be mapped onto the expected content map to the + * retryable {@code negotiation.content_extract_failed}, missing required fields map to the non-retryable + * {@code negotiation.field_missing}, a conclusion contradicting the addressed phase maps to + * {@code negotiation.conclusion_mismatch}, and content that contradicts the addressed phase or action maps to + * {@code negotiation.invalid_input}. Every message is rendered from the code's message template in the reference + * language. * * @since 2026-08 */ @@ -53,6 +57,10 @@ final class DefaultNegotiationContentExtractor implements NegotiationContentExtr private static final String CATEGORY_ABORT = "abort_negotiation"; + private static final String EXTRACTION_STEP_EN = "negotiation content extraction"; + + private static final String EXTRACTION_STEP_ZH = "协商内容提取"; + private final LLMClient llmClient; private final NegotiationMessageBuilder messageBuilder; @@ -100,17 +108,17 @@ public DefaultNegotiationContentExtractor( * @param reference reference identifying the negotiation type, performative and language to extract for * @return typed negotiation content matching the reference * @throws NullPointerException if the reference is null - * @throws NegotiationGenerationException with one of the codes {@code negotiation_llm_infrastructure_error}, - * {@code negotiation_content_extract_failed}, {@code negotiation_slot_missing} or - * {@code negotiation_invalid_input} + * @throws NegotiationGenerationException with one of the codes {@code llm.not_configured}, + * {@code llm.invocation_failed}, {@code llm.response_invalid}, {@code negotiation.content_extract_failed}, + * {@code negotiation.field_missing}, {@code negotiation.conclusion_mismatch} or + * {@code negotiation.invalid_input} */ @Override public NegotiationContent extract(String text, NegotiationReference reference) { Objects.requireNonNull(reference, "Negotiation reference must not be null."); + String language = reference.language(); if (text == null || text.isBlank()) { - throw new NegotiationGenerationException( - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, - "Input text for negotiation content extraction must not" + " be blank."); + throw invalidInput(language, "Input text for negotiation content extraction must not be blank."); } Map tokens = Map.of( NegotiationMessageBuilder.TOKEN_PHASE, @@ -119,10 +127,9 @@ public NegotiationContent extract(String text, NegotiationReference reference) { text); List> messages = messageBuilder.buildMessages(promptCategory(reference.type()), reference.language(), tokens); - Map schema = - schemaBuilder.buildExtractionSchema(reference.type(), reference.performative()); - Map payload = invokeLlm(messages, schema); - NegotiationContent content = mapContent(payload, reference.type(), reference.performative()); + Map schema = schemaBuilder.buildExtractionSchema(reference.type(), reference.performative()); + Map payload = invokeLlm(messages, schema, language); + NegotiationContent content = mapContent(payload, reference.type(), reference.performative(), language); LOGGER.atInfo().log( "negotiation_content_extraction_completed type={} performative={}", reference.type(), @@ -130,67 +137,72 @@ public NegotiationContent extract(String text, NegotiationReference reference) { return content; } - private Map invokeLlm(List> messages, Map schema) { + private Map invokeLlm( + List> messages, Map schema, String language) { if (llmClient == null) { - throw new NegotiationGenerationException( - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, - "Negotiation content extraction requires an LLM client but none is configured."); + throw new NegotiationGenerationException(ErrorCatalog.LLM_NOT_CONFIGURED, language, null); } LLMResponse response; try { response = llmClient.structured(messages, schema, null, null); } catch (RuntimeException error) { throw new NegotiationGenerationException( - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, - "Negotiation content extraction LLM call failed.", + ErrorCatalog.LLM_INVOCATION_FAILED, + language, + Map.of( + "provider", llmClient.getClass().getSimpleName(), + "reason", String.valueOf(error.getMessage())), error); } String content = response == null ? null : response.content(); if (content == null || content.isBlank()) { - throw new NegotiationGenerationException( - A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, - "Negotiation content extraction returned an empty response."); + throw responseInvalid(language, "Negotiation content extraction returned an empty response."); } try { return parser.parseObject(content); } catch (RuntimeException error) { - throw new NegotiationGenerationException( - A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, - "Negotiation content extraction response is not a JSON object.", - error); + throw responseInvalid(language, "Negotiation content extraction response is not a JSON object."); } } private static NegotiationContent mapContent( - Map payload, @Nullable NegotiationType type, NegotiationPerformative performative) { + Map payload, + @Nullable NegotiationType type, + NegotiationPerformative performative, + String language) { if (performative == NegotiationPerformative.ABORT) { - return new NegotiationAbortContent(requiredString(payload, "termination_reason")); + return new NegotiationAbortContent(requiredString(payload, "termination_reason", language)); } if (performative == NegotiationPerformative.PROPOSE) { - return mapProposeContent(payload, type); + return mapProposeContent(payload, type, language); } - return mapEndingContent(payload, type, performative); + return mapEndingContent(payload, type, performative, language); } - private static NegotiationContent mapProposeContent(Map payload, NegotiationType type) { + private static NegotiationContent mapProposeContent( + Map payload, NegotiationType type, String language) { return switch (type) { case INFORMATION -> new InformationProposeContent( - requiredNonEmptyItems(payload, "items", "information negotiation requested items"), - optionalString(payload, "relationship")); - case TARGET -> mapTargetProposeContent(payload); - case FEASIBILITY -> mapFeasibilityProposeContent(payload); + requiredNonEmptyItems(payload, "items", "information negotiation requested items", language), + optionalString(payload, "relationship", language)); + case TARGET -> mapTargetProposeContent(payload, language); + case FEASIBILITY -> mapFeasibilityProposeContent(payload, language); }; } - private static NegotiationContent mapTargetProposeContent(Map payload) { - String description = requiredString(payload, "target_negotiation_description"); - List intentUnderstanding = optionalItems(payload, "intent_understanding"); - List alignmentAndClarification = optionalItems(payload, "alignment_and_clarification"); - List requestForClarification = optionalItems(payload, "request_for_clarification"); - String confirmRequest = optionalString(payload, "target_confirm_request"); + private static NegotiationContent mapTargetProposeContent(Map payload, String language) { + String description = requiredString(payload, "target_negotiation_description", language); + List intentUnderstanding = optionalItems(payload, "intent_understanding", language); + List alignmentAndClarification = + optionalItems(payload, "alignment_and_clarification", language); + List requestForClarification = optionalItems(payload, "request_for_clarification", language); + String confirmRequest = optionalString(payload, "target_confirm_request", language); if (hasText(confirmRequest) - && (hasItems(intentUnderstanding) || hasItems(alignmentAndClarification) || hasItems(requestForClarification))) { + && (hasItems(intentUnderstanding) + || hasItems(alignmentAndClarification) + || hasItems(requestForClarification))) { throw invalidInput( + language, "Target confirm request extracted together with the intent understanding, alignment and" + " clarification or clarification request sections; a confirm-request round carries only" + " the summary and the confirm request."); @@ -199,15 +211,17 @@ private static NegotiationContent mapTargetProposeContent(Map pa description, intentUnderstanding, alignmentAndClarification, requestForClarification, confirmRequest); } - private static NegotiationContent mapFeasibilityProposeContent(Map payload) { - String description = requiredString(payload, "feasibility_negotiation_description"); - NegotiationAction action = feasibilityAction(payload); - List contentsToEvaluate = optionalItems(payload, "contents_to_evaluate"); - List infeasibilityDetails = optionalItems(payload, "infeasibility_details_and_proposal"); - String confirmRequest = optionalString(payload, "feasibility_confirm_request"); + private static NegotiationContent mapFeasibilityProposeContent(Map payload, String language) { + String description = requiredString(payload, "feasibility_negotiation_description", language); + NegotiationAction action = feasibilityAction(payload, language); + List contentsToEvaluate = optionalItems(payload, "contents_to_evaluate", language); + List infeasibilityDetails = + optionalItems(payload, "infeasibility_details_and_proposal", language); + String confirmRequest = optionalString(payload, "feasibility_confirm_request", language); if (hasText(confirmRequest)) { if (action != NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { throw invalidInput( + language, "Feasibility confirm request requires the REQUEST_FEASIBILITY_EVALUATION action but the" + " extracted action was " + action.name() @@ -215,6 +229,7 @@ private static NegotiationContent mapFeasibilityProposeContent(Map payload, NegotiationType type, NegotiationPerformative performative) { - NegotiationConclusion conclusion = requiredConclusion(payload); - requireConclusionMatchesPhase(conclusion, performative); + Map payload, NegotiationType type, NegotiationPerformative performative, String language) { + NegotiationConclusion conclusion = requiredConclusion(payload, language); + requireConclusionMatchesPhase(conclusion, performative, language); return switch (type) { case INFORMATION -> new InformationEndingContent( conclusion, - requiredNonEmptyItems(payload, "items", "information negotiation result content")); - case TARGET -> mapTargetEndingContent(payload, conclusion); + requiredNonEmptyItems(payload, "items", "information negotiation result content", language)); + case TARGET -> mapTargetEndingContent(payload, conclusion, language); case FEASIBILITY -> new FeasibilityEndingContent( - conclusion, requiredString(payload, "feasibility_summary")); + conclusion, requiredString(payload, "feasibility_summary", language)); }; } private static NegotiationContent mapTargetEndingContent( - Map payload, NegotiationConclusion conclusion) { - String confirmedIntent = optionalString(payload, "confirmed_intent"); - String failureReason = optionalString(payload, "failure_reason"); + Map payload, NegotiationConclusion conclusion, String language) { + String confirmedIntent = optionalString(payload, "confirmed_intent", language); + String failureReason = optionalString(payload, "failure_reason", language); if (conclusion == NegotiationConclusion.ACCEPT && (confirmedIntent == null || confirmedIntent.isBlank())) { - throw slotMissing("confirmed_intent of an accepting target negotiation message"); + throw slotMissing(language, "confirmed_intent"); } if (conclusion == NegotiationConclusion.REJECT && (failureReason == null || failureReason.isBlank())) { - throw slotMissing("failure_reason of a rejecting target negotiation message"); + throw slotMissing(language, "failure_reason"); } return new TargetEndingContent(conclusion, confirmedIntent, failureReason); } private static void requireConclusionMatchesPhase( - NegotiationConclusion conclusion, NegotiationPerformative performative) { + NegotiationConclusion conclusion, NegotiationPerformative performative, String language) { NegotiationConclusion expected = performative == NegotiationPerformative.ACCEPT ? NegotiationConclusion.ACCEPT : NegotiationConclusion.REJECT; if (conclusion != expected) { - throw invalidInput("Extracted conclusion " + conclusion.literal() + " does not match the " + performative - + " performative; the" + " expected conclusion is " + expected.literal() + "."); + throw new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH, + language, + Map.of("expected", expected.literal(), "actual", conclusion.literal())); } } - private static NegotiationAction feasibilityAction(Map payload) { + private static NegotiationAction feasibilityAction(Map payload, String language) { Object value = payload.get("action"); if (value == null) { throw invalidInput( + language, "Feasibility negotiation content extraction produced no action; the action drives the conditional" + " sections of the message."); } @@ -286,13 +306,13 @@ private static NegotiationAction feasibilityAction(Map payload) } } } - throw extractFailed("Feasibility negotiation action must be one of the two action names but was: " + value); + throw extractFailed(language, "action", "Feasibility negotiation action must be one of the two action names"); } - private static NegotiationConclusion requiredConclusion(Map payload) { + private static NegotiationConclusion requiredConclusion(Map payload, String language) { Object value = payload.get("conclusion"); if (value == null) { - throw slotMissing("conclusion of a terminal negotiation message"); + throw slotMissing(language, "conclusion"); } if (value instanceof String conclusion) { for (NegotiationConclusion candidate : NegotiationConclusion.values()) { @@ -301,21 +321,21 @@ private static NegotiationConclusion requiredConclusion(Map payl } } } - throw extractFailed("Negotiation conclusion must be Accept or Reject but was: " + value); + throw extractFailed(language, "conclusion", "Negotiation conclusion must be Accept or Reject"); } - private static String requiredString(Map payload, String field) { + private static String requiredString(Map payload, String field, String language) { Object value = payload.get(field); if (value == null || (value instanceof String text && text.isBlank())) { - throw slotMissing(field); + throw slotMissing(language, field); } if (value instanceof String text) { return text; } - throw extractFailed("Field " + field + " must be a string but was: " + value); + throw extractFailed(language, field, "Field " + field + " must be a string"); } - private static String optionalString(Map payload, String field) { + private static String optionalString(Map payload, String field, String language) { Object value = payload.get(field); if (value == null) { return null; @@ -323,7 +343,7 @@ private static String optionalString(Map payload, String field) if (value instanceof String text) { return text; } - throw extractFailed("Field " + field + " must be a string or null but was: " + value); + throw extractFailed(language, field, "Field " + field + " must be a string or null"); } private static boolean hasText(@Nullable String value) { @@ -334,47 +354,48 @@ private static boolean hasItems(@Nullable List items) { return items != null && !items.isEmpty(); } - private static List requiredItems(Map payload, String field) { + private static List requiredItems(Map payload, String field, String language) { Object value = payload.get(field); if (value == null) { - throw slotMissing(field); + throw slotMissing(language, field); } - return itemsOf(value, field); + return itemsOf(value, field, language); } private static List requiredNonEmptyItems( - Map payload, String field, String description) { - List items = requiredItems(payload, field); + Map payload, String field, String description, String language) { + List items = requiredItems(payload, field, language); if (items.isEmpty()) { - throw slotMissing(description); + throw slotMissing(language, description); } return items; } - private static List optionalItems(Map payload, String field) { + private static List optionalItems(Map payload, String field, String language) { Object value = payload.get(field); if (value == null) { return null; } - return itemsOf(value, field); + return itemsOf(value, field, language); } - private static List itemsOf(Object value, String field) { + private static List itemsOf(Object value, String field, String language) { if (!(value instanceof List entries)) { - throw extractFailed("Field " + field + " must be an array of items but was: " + value); + throw extractFailed(language, field, "Field " + field + " must be an array of items"); } List items = new ArrayList<>(); for (Object entry : entries) { if (!(entry instanceof Map entryMap)) { - throw extractFailed("Field " + field + " must contain item objects but contained: " + entry); + throw extractFailed(language, field, "Field " + field + " must contain item objects"); } Object name = entryMap.get("name"); if (!(name instanceof String itemName) || itemName.isBlank()) { - throw extractFailed("Field " + field + " contained an item without a name: " + entry); + throw extractFailed(language, field, "Field " + field + " contained an item without a name"); } Object itemValue = entryMap.get("value"); if (itemValue != null && !(itemValue instanceof String)) { - throw extractFailed("Field " + field + " contained an item whose value is not a string: " + entry); + throw extractFailed( + language, field, "Field " + field + " contained an item whose value is not a string"); } items.add(new NegotiationItem(itemName, (String) itemValue)); } @@ -396,17 +417,28 @@ private static String promptCategory(@Nullable NegotiationType type) { }; } - private static NegotiationGenerationException slotMissing(String field) { + private static NegotiationGenerationException slotMissing(String language, String field) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_FIELD_MISSING, language, Map.of("field", field)); + } + + private static NegotiationGenerationException extractFailed(String language, String field, String reason) { return new NegotiationGenerationException( - A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, - "Negotiation content extraction response is missing the required field: " + field); + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED, language, Map.of("field", field, "reason", reason)); } - private static NegotiationGenerationException extractFailed(String message) { - return new NegotiationGenerationException(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, message); + private static NegotiationGenerationException invalidInput(String language, String reason) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, Map.of("reason", reason)); + } + + private static NegotiationGenerationException responseInvalid(String language, String detail) { + LOGGER.atWarn().log("negotiation_content_extraction_response_invalid detail={}", detail); + return new NegotiationGenerationException( + ErrorCatalog.LLM_RESPONSE_INVALID, language, Map.of("step", extractionStepLabel(language))); } - private static NegotiationGenerationException invalidInput(String message) { - return new NegotiationGenerationException(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, message); + private static String extractionStepLabel(String language) { + return language != null && language.startsWith("zh") ? EXTRACTION_STEP_ZH : EXTRACTION_STEP_EN; } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java index 5f99dfaa..3410efc5 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java @@ -1,47 +1,48 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Generator for feasibility negotiation terminal messages. - * - *

        The message carries the terminal conclusion literal and the required feasibility summary. The summary slot name - * differs from its section title, so the vocabulary exception key is used. - * - * @since 2026-08 - */ -final class FeasibilityEndingGenerator extends AbstractNegotiationGenerator { - - /** - * Generates a feasibility negotiation accept or reject message. - * - * @param context negotiation context of the message - * @param content feasibility ending content - * @param template feasibility ending template to render - * @param vocabulary vocabulary of the message language - * @return rendered feasibility terminal message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - FeasibilityEndingContent endingContent = - contentOf(content, FeasibilityEndingContent.class, "Feasibility ending generator"); - renderableConclusion(endingContent.conclusion()); - requiredText( - endingContent.feasibilitySummary(), - "content.feasibilitySummary", - "Feasibility summary of a terminal feasibility negotiation message"); - Map slots = new LinkedHashMap<>(); - slots.put( - vocabulary.get("slot.feasibility_conclusion"), - endingContent.conclusion().literal()); - slots.put(vocabulary.get("slot.feasibility_confirm"), endingContent.feasibilitySummary()); - return render(template, slots); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Generator for feasibility negotiation terminal messages. + * + *

        The message carries the terminal conclusion literal and the required feasibility summary. The summary slot name + * differs from its section title, so the vocabulary exception key is used. + * + * @since 2026-08 + */ +final class FeasibilityEndingGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a feasibility negotiation accept or reject message. + * + * @param context negotiation context of the message + * @param content feasibility ending content + * @param template feasibility ending template to render + * @param vocabulary vocabulary of the message language + * @return rendered feasibility terminal message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + FeasibilityEndingContent endingContent = + contentOf(content, FeasibilityEndingContent.class, "Feasibility ending generator"); + renderableConclusion(endingContent.conclusion()); + requiredText( + endingContent.feasibilitySummary(), + "content.feasibilitySummary", + "Feasibility summary of a terminal feasibility negotiation message", + vocabulary); + Map slots = new LinkedHashMap<>(); + slots.put( + vocabulary.get("slot.feasibility_conclusion"), + endingContent.conclusion().literal()); + slots.put(vocabulary.get("slot.feasibility_confirm"), endingContent.feasibilitySummary()); + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java index 808d5ba1..30f62eee 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java @@ -1,101 +1,115 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import org.jspecify.annotations.Nullable; - -/** - * Generator for feasibility negotiation propose messages. - * - *

        The message category selects which conditional section is rendered: requesting a feasibility evaluation renders the - * contents to evaluate, proposing an alternative after an infeasible outcome renders the infeasibility details and - * proposal, and a non-blank confirm request marks the derived "assessed as feasible and requesting confirmation" category - * (action {@code REQUEST_FEASIBILITY_EVALUATION} with both lists empty) that renders only the confirm request. Exactly - * one of the three sections is always present. - * - * @since 2026-08 - */ -final class FeasibilityProposeGenerator extends AbstractNegotiationGenerator { - - /** - * Generates a feasibility negotiation propose message. - * - * @param context negotiation context of the message - * @param content feasibility propose content - * @param template feasibility propose template to render - * @param vocabulary vocabulary of the message language - * @return rendered feasibility propose message text - * @throws NullPointerException if the action is null - * @throws IllegalArgumentException if the summary is blank, the action-selected section is empty, or the confirm - * request is combined with the wrong action or either conditional section - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - FeasibilityProposeContent proposeContent = - contentOf(content, FeasibilityProposeContent.class, "Feasibility propose generator"); - requiredText( - proposeContent.feasibilityNegotiationDescription(), - "content.feasibilityNegotiationDescription", - "Feasibility negotiation description"); - NegotiationAction action = proposeContent.action(); - Objects.requireNonNull( - action, - "Feasibility negotiation action must not be null; it selects the conditional sections of the message."); - String confirmRequest = presentText(proposeContent.feasibilityConfirmRequest()); - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.feasibility"), proposeContent.feasibilityNegotiationDescription()); - if (confirmRequest != null) { - if (action != NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { - throw new IllegalArgumentException( - "Feasibility confirm request requires the REQUEST_FEASIBILITY_EVALUATION action but the content" - + " carries " - + action.name() - + "."); - } - if (hasItems(proposeContent.contentsToEvaluate()) || hasItems(proposeContent.infeasibilityDetailsAndProposal())) { - throw new IllegalArgumentException( - "Feasibility confirm request must not be combined with the contents to evaluate or" - + " infeasibility details and proposal sections; a confirm-request round carries only" - + " the summary and the confirm request."); - } - slots.put(vocabulary.get("slot.feasibility_confirm_request"), confirmRequest); - } else if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { - slots.put( - vocabulary.get("slot.feasibility_evaluate"), - formatItems( - requiredItems( - proposeContent.contentsToEvaluate(), - "content.contentsToEvaluate", - "Contents to evaluate of a feasibility evaluation request"), - vocabulary)); - } else { - slots.put( - vocabulary.get("slot.feasibility_infeasible"), - formatItems( - requiredItems( - proposeContent.infeasibilityDetailsAndProposal(), - "content.infeasibilityDetailsAndProposal", - "Infeasibility details and proposal of an alternative proposal"), - vocabulary)); - } - return render(template, slots); - } - - private static @Nullable String presentText(@Nullable String value) { - return value == null || value.isBlank() ? null : value; - } - - private static boolean hasItems(@Nullable List items) { - return items != null && !items.isEmpty(); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import org.jspecify.annotations.Nullable; + +/** + * Generator for feasibility negotiation propose messages. + * + *

        The message category selects which conditional section is rendered: requesting a feasibility evaluation renders + * the contents to evaluate, proposing an alternative after an infeasible outcome renders the infeasibility details and + * proposal, and a non-blank confirm request marks the derived "assessed as feasible and requesting confirmation" + * category (action {@code REQUEST_FEASIBILITY_EVALUATION} with both lists empty) that renders only the confirm request. + * Exactly one of the three sections is always present. + * + * @since 2026-08 + */ +final class FeasibilityProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a feasibility negotiation propose message. + * + * @param context negotiation context of the message + * @param content feasibility propose content + * @param template feasibility propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered feasibility propose message text + * @throws NullPointerException if the action is null + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the summary is blank + * or the action-selected section is empty, or {@code negotiation.invalid_input} if the confirm request is + * combined with the wrong action or either conditional section + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + FeasibilityProposeContent proposeContent = + contentOf(content, FeasibilityProposeContent.class, "Feasibility propose generator"); + requiredText( + proposeContent.feasibilityNegotiationDescription(), + "content.feasibilityNegotiationDescription", + "Feasibility negotiation description", + vocabulary); + NegotiationAction action = proposeContent.action(); + Objects.requireNonNull( + action, + "Feasibility negotiation action must not be null; it selects the conditional sections of the message."); + String confirmRequest = presentText(proposeContent.feasibilityConfirmRequest()); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.feasibility"), proposeContent.feasibilityNegotiationDescription()); + if (confirmRequest != null) { + if (action != NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + throw invalidInput( + "Feasibility confirm request requires the REQUEST_FEASIBILITY_EVALUATION action but the content" + + " carries " + + action.name() + + ".", + vocabulary); + } + if (hasItems(proposeContent.contentsToEvaluate()) + || hasItems(proposeContent.infeasibilityDetailsAndProposal())) { + throw invalidInput( + "Feasibility confirm request must not be combined with the contents to evaluate or" + + " infeasibility details and proposal sections; a confirm-request round carries only" + + " the summary and the confirm request.", + vocabulary); + } + slots.put(vocabulary.get("slot.feasibility_confirm_request"), confirmRequest); + } else if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + slots.put( + vocabulary.get("slot.feasibility_evaluate"), + formatItems( + requiredItems( + proposeContent.contentsToEvaluate(), + "content.contentsToEvaluate", + "Contents to evaluate of a feasibility evaluation request", + vocabulary), + vocabulary)); + } else { + slots.put( + vocabulary.get("slot.feasibility_infeasible"), + formatItems( + requiredItems( + proposeContent.infeasibilityDetailsAndProposal(), + "content.infeasibilityDetailsAndProposal", + "Infeasibility details and proposal of an alternative proposal", + vocabulary), + vocabulary)); + } + return render(template, slots); + } + + private static @Nullable String presentText(@Nullable String value) { + return value == null || value.isBlank() ? null : value; + } + + private static boolean hasItems(@Nullable List items) { + return items != null && !items.isEmpty(); + } + + private static NegotiationGenerationException invalidInput(String reason, Vocabulary vocabulary) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT, vocabulary.language(), Map.of("reason", reason)); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java index 9f15c46c..6fd26a21 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java @@ -1,45 +1,44 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Generator for information negotiation terminal messages. - * - *

        The message carries the terminal conclusion literal and the information items delivered with it. - * - * @since 2026-08 - */ -final class InformationEndingGenerator extends AbstractNegotiationGenerator { - - /** - * Generates an information negotiation accept or reject message. - * - * @param context negotiation context of the message - * @param content information ending content - * @param template information ending template to render - * @param vocabulary vocabulary of the message language - * @return rendered information terminal message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - InformationEndingContent endingContent = contentOf(content, InformationEndingContent.class, "Information ending generator"); - renderableConclusion(endingContent.conclusion()); - requiredItems( - endingContent.items(), - "items", - "Information negotiation terminal message result content"); - Map slots = new LinkedHashMap<>(); - slots.put( - vocabulary.get("slot.info_conclusion"), - endingContent.conclusion().literal()); - slots.put(vocabulary.get("slot.info_result_content"), formatItems(endingContent.items(), vocabulary)); - return render(template, slots); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Generator for information negotiation terminal messages. + * + *

        The message carries the terminal conclusion literal and the information items delivered with it. + * + * @since 2026-08 + */ +final class InformationEndingGenerator extends AbstractNegotiationGenerator { + + /** + * Generates an information negotiation accept or reject message. + * + * @param context negotiation context of the message + * @param content information ending content + * @param template information ending template to render + * @param vocabulary vocabulary of the message language + * @return rendered information terminal message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + InformationEndingContent endingContent = + contentOf(content, InformationEndingContent.class, "Information ending generator"); + renderableConclusion(endingContent.conclusion()); + requiredItems( + endingContent.items(), "items", "Information negotiation terminal message result content", vocabulary); + Map slots = new LinkedHashMap<>(); + slots.put( + vocabulary.get("slot.info_conclusion"), + endingContent.conclusion().literal()); + slots.put(vocabulary.get("slot.info_result_content"), formatItems(endingContent.items(), vocabulary)); + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java index 081ca1f9..c7baa3e9 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java @@ -1,52 +1,50 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Generator for information negotiation propose messages. - * - *

        The message carries the requested information items and, when present, a free-form line describing how the missing - * items relate to each other, appended after the item list. - * - * @since 2026-08 - */ -final class InformationProposeGenerator extends AbstractNegotiationGenerator { - - /** - * Generates an information negotiation propose message. - * - * @param context negotiation context of the message - * @param content information propose content - * @param template information propose template to render - * @param vocabulary vocabulary of the message language - * @return rendered information propose message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - InformationProposeContent proposeContent = - contentOf(content, InformationProposeContent.class, "Information propose generator"); - requiredItems( - proposeContent.items(), - "items", - "Information negotiation propose message requested items"); - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.info_items"), itemsSlotValue(proposeContent, vocabulary)); - return render(template, slots); - } - - private String itemsSlotValue(InformationProposeContent content, Vocabulary vocabulary) { - String items = formatItems(content.items(), vocabulary); - if (content.relationship() == null || content.relationship().isBlank()) { - return items; - } - String relationshipLine = vocabulary.get("label.relationship") + content.relationship(); - return items.isEmpty() ? relationshipLine : items + "\n" + relationshipLine; - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Generator for information negotiation propose messages. + * + *

        The message carries the requested information items and, when present, a free-form line describing how the missing + * items relate to each other, appended after the item list. + * + * @since 2026-08 + */ +final class InformationProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates an information negotiation propose message. + * + * @param context negotiation context of the message + * @param content information propose content + * @param template information propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered information propose message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + InformationProposeContent proposeContent = + contentOf(content, InformationProposeContent.class, "Information propose generator"); + requiredItems( + proposeContent.items(), "items", "Information negotiation propose message requested items", vocabulary); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.info_items"), itemsSlotValue(proposeContent, vocabulary)); + return render(template, slots); + } + + private String itemsSlotValue(InformationProposeContent content, Vocabulary vocabulary) { + String items = formatItems(content.items(), vocabulary); + if (content.relationship() == null || content.relationship().isBlank()) { + return items; + } + String relationshipLine = vocabulary.get("label.relationship") + content.relationship(); + return items.isEmpty() ? relationshipLine : items + "\n" + relationshipLine; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java index a064efef..8d26d48a 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java @@ -20,10 +20,12 @@ public interface NegotiationContentExtractor { * @param text free-text input describing the message content * @param reference reference identifying the negotiation type, phase and language to extract for * @return typed negotiation content matching the reference - * @throws NegotiationGenerationException with code {@code negotiation_llm_infrastructure_error} when the LLM - * invocation fails, {@code negotiation_content_extract_failed} when the response cannot be parsed as the - * expected content, {@code negotiation_slot_missing} when a required field is missing, or - * {@code negotiation_invalid_input} when the extracted content contradicts the addressed phase or action + * @throws NegotiationGenerationException with code {@code llm.not_configured} when no LLM client is configured, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the LLM invocation fails, + * {@code negotiation.content_extract_failed} when the response cannot be parsed as the expected content, + * {@code negotiation.field_missing} when a required field is missing, {@code negotiation.conclusion_mismatch} + * when the extracted conclusion contradicts the addressed phase, or {@code negotiation.invalid_input} when the + * extracted content contradicts the addressed phase or action */ NegotiationContent extract(String text, NegotiationReference reference); } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index c58844de..cc15354f 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -67,10 +67,13 @@ public static NegotiationGenerationOrchestrator buildOrchestrator( * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content - * type, or the content combines a non-blank confirm request with conditional sections (target: the three - * clarification lists; feasibility: either conditional list or the alternative action) - * @throws NegotiationGenerationException with the code {@code template_not_found} or - * {@code negotiation_slot_missing} when loading or rendering the template fails + * type + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when a required content + * field is blank or empty, or {@code negotiation.invalid_input} when the content combines a non-blank confirm + * request with conditional sections (target: the three clarification lists; feasibility: either conditional + * list or the alternative action) + * @throws NegotiationGenerationException with the code {@code template.not_found} or + * {@code negotiation.field_missing} when loading or rendering the template fails */ public MetadataContent generateProposeFromData( @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { @@ -84,10 +87,12 @@ public MetadataContent generateProposeFromData( * @param templateUri template URI whose performative segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content, or - * the content conclusion is not {@code Accept} - * @throws NegotiationGenerationException with the code {@code template_not_found} or - * {@code negotiation_slot_missing} when loading or rendering the template fails + * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Accept}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} or + * {@code negotiation.field_missing} when loading or rendering the template fails */ public MetadataContent generateAcceptFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { @@ -101,10 +106,12 @@ public MetadataContent generateAcceptFromData( * @param templateUri template URI whose performative segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content, or - * the content conclusion is not {@code Reject} - * @throws NegotiationGenerationException with the code {@code template_not_found} or - * {@code negotiation_slot_missing} when loading or rendering the template fails + * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Reject}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} or + * {@code negotiation.field_missing} when loading or rendering the template fails */ public MetadataContent generateRejectFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { @@ -118,10 +125,11 @@ public MetadataContent generateRejectFromData( * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template or the - * termination reason is blank - * @throws NegotiationGenerationException with the code {@code template_not_found} or - * {@code negotiation_slot_missing} when loading or rendering the template fails + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when the termination + * reason is blank + * @throws NegotiationGenerationException with the code {@code template.not_found} or + * {@code negotiation.field_missing} when loading or rendering the template fails */ public MetadataContent generateAbortFromData( @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { @@ -137,12 +145,12 @@ public MetadataContent generateAbortFromData( * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationGenerationException with the code {@code template_not_found}, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or - * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank, the extracted content contradicts the performative - * or the extracted confirm request is combined with conditional sections or the wrong feasibility action, or - * {@code input_text_too_long} when the text exceeds the configured maximum length + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses a + * required field, or {@code negotiation.invalid_input} when the text is blank, the extracted content + * contradicts the performative or the extracted confirm request is combined with conditional sections or the + * wrong feasibility action, or {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateProposeFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -158,11 +166,12 @@ public MetadataContent generateProposeFromText( * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationGenerationException with the code {@code template_not_found}, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or - * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Accept}, - * or {@code input_text_too_long} when the text exceeds the configured maximum length + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses a + * required field, or {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Accept}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateAcceptFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -178,11 +187,12 @@ public MetadataContent generateAcceptFromText( * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationGenerationException with the code {@code template_not_found}, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or - * extracting fails, {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Reject}, - * or {@code input_text_too_long} when the text exceeds the configured maximum length + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses a + * required field, or {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Reject}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateRejectFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -198,10 +208,11 @@ public MetadataContent generateRejectFromText( * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationGenerationException with the code {@code template_not_found}, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when loading or - * extracting fails, {@code negotiation_slot_missing} when the extracted content misses the termination reason, - * or {@code input_text_too_long} when the text exceeds the configured maximum length + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses the + * termination reason, or {@code negotiation.invalid_input} when the text is blank, or + * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateAbortFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -219,10 +230,10 @@ public MetadataContent generateAbortFromText( * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the schema or the template URI is null * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is - * null or blank, or {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, - * {@code negotiation_llm_infrastructure_error}, {@code input_text_too_long} or {@code template_not_found} - * when the validation pipeline fails + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, + * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or + * {@code template.not_found} when the validation pipeline fails */ public FilledParamData validateProposePromptAndDataFilling( String prompt, @@ -243,10 +254,10 @@ public FilledParamData validateProposePromptAndDataFilling( * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the schema or the template URI is null * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is - * null or blank, or {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, - * {@code negotiation_llm_infrastructure_error}, {@code input_text_too_long} or {@code template_not_found} - * when the validation pipeline fails + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, + * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or + * {@code template.not_found} when the validation pipeline fails */ public FilledParamData validateAcceptPromptAndDataFilling( String prompt, @@ -267,10 +278,10 @@ public FilledParamData validateAcceptPromptAndDataFilling( * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the schema or the template URI is null * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the prompt is - * null or blank, or {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, - * {@code negotiation_llm_infrastructure_error}, {@code input_text_too_long} or {@code template_not_found} - * when the validation pipeline fails + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, + * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or + * {@code template.not_found} when the validation pipeline fails */ public FilledParamData validateRejectPromptAndDataFilling( String prompt, @@ -291,7 +302,7 @@ public FilledParamData validateRejectPromptAndDataFilling( * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the schema or the template URI is null * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationParamExtractionException with the code {@code input_text_too_long} when the prompt exceeds + * @throws NegotiationParamExtractionException with the code {@code input.text_too_long} when the prompt exceeds * the configured maximum length, or another validation pipeline failure code otherwise */ public FilledParamData validateAbortPromptAndDataFilling( diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index c1febe02..65b57ded 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -4,8 +4,7 @@ import java.util.Map; import java.util.Objects; import java.util.function.Supplier; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.core.model.InputLimitConfig; @@ -34,12 +33,12 @@ * the validation-plus-parameter-extraction pipeline. * *

        The orchestrator owns the retry loop of every LLM step. A step failing with one of the retryable codes - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} is re-run up to the - * configured attempt limit; the exhaustion failure rethrows the original error code. Internal exceptions of the - * generation and validation pipelines never bubble out raw: render failures become generation failures carrying - * {@code negotiation_slot_missing}, semantic validation infrastructure failures become parameter-extraction failures - * carrying {@code negotiation_llm_infrastructure_error}, and resource load misses become the code - * {@code template_not_found} on both pipelines. + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} is re-run + * up to the configured attempt limit; the exhaustion failure rethrows the original error code. Internal exceptions of + * the generation and validation pipelines never bubble out raw: render failures become generation failures carrying + * {@code template.render_failed}, semantic validation infrastructure failures become parameter-extraction failures + * carrying {@code llm.invocation_failed} or {@code llm.response_invalid}, and resource load misses become the code + * {@code template.not_found} on both pipelines. * *

        Instances are created through {@link NegotiationGenerationOrchestratorBuilder}; the builder wires the default * collaborators and allows overriding each of them. @@ -105,8 +104,8 @@ public final class NegotiationGenerationOrchestrator { * so it may differ from the context the caller passed in * @throws NullPointerException if the data, its context or the template URI is null * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content type - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the - * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the + * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails */ public MetadataContent generateProposeFromData( @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { @@ -127,10 +126,12 @@ public MetadataContent generateProposeFromData( * the emitted negotiation context is the input context stamped with the performative of the addressed template, * so it may differ from the context the caller passed in * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content, or - * the content conclusion is not {@code Accept} - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the - * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Accept}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the + * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails */ public MetadataContent generateAcceptFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { @@ -151,10 +152,12 @@ public MetadataContent generateAcceptFromData( * the emitted negotiation context is the input context stamped with the performative of the addressed template, * so it may differ from the context the caller passed in * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content, or - * the content conclusion is not {@code Reject} - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the - * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Reject}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the + * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails */ public MetadataContent generateRejectFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { @@ -174,10 +177,11 @@ public MetadataContent generateRejectFromData( * the emitted negotiation context is the input context stamped with the performative of the addressed template, * so it may differ from the context the caller passed in * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template or the - * termination reason is blank - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template exists for the - * URI in any resource root, or the code {@code negotiation_slot_missing} when rendering the template fails + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when the termination + * reason is blank + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the + * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails */ public MetadataContent generateAbortFromData( @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { @@ -191,7 +195,7 @@ public MetadataContent generateAbortFromData( *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. * * @param text free-text input describing the message content * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement @@ -202,12 +206,12 @@ public MetadataContent generateAbortFromData( * so it may differ from the context the caller passed in * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation_content_extract_failed} or - * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, - * {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted content contradicts the phase, - * or {@code input_text_too_long} when the text exceeds the configured maximum length + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after + * exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the + * phase, or {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateProposeFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -230,12 +234,13 @@ public MetadataContent generateProposeFromText( * so it may differ from the context the caller passed in * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation_content_extract_failed} or - * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, - * {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Accept}, - * or {@code input_text_too_long} when the text exceeds the configured maximum length + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after + * exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Accept}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateAcceptFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -258,12 +263,13 @@ public MetadataContent generateAcceptFromText( * so it may differ from the context the caller passed in * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation_content_extract_failed} or - * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, - * {@code negotiation_slot_missing} when the extracted content misses a required field, or - * {@code negotiation_invalid_input} when the text is blank or the extracted conclusion is not {@code Reject}, - * or {@code input_text_too_long} when the text exceeds the configured maximum length + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after + * exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Reject}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateRejectFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -276,7 +282,7 @@ public MetadataContent generateRejectFromText( *

        This variant runs one LLM content-extraction step constrained by the common abort template and then renders * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. * * @param text free-text input stating the termination reason * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement @@ -286,12 +292,12 @@ public MetadataContent generateRejectFromText( * so it may differ from the context the caller passed in * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationGenerationException with the code {@code template_not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation_content_extract_failed} or - * {@code negotiation_llm_infrastructure_error} when the extraction step fails after exhausting its retries, - * {@code negotiation_slot_missing} when the extracted content misses the termination reason, or - * {@code negotiation_invalid_input} when the text is blank, or {@code input_text_too_long} when the text - * exceeds the configured maximum length + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after + * exhausting its retries, {@code negotiation.field_missing} when the extracted content misses the termination + * reason, or {@code negotiation.invalid_input} when the text is blank, or {@code input.text_too_long} when + * the text exceeds the configured maximum length */ public MetadataContent generateAbortFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -313,13 +319,13 @@ public MetadataContent generateAbortFromText( * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the schema or the template URI is null * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the context is - * null and the message is therefore not a negotiation message, {@code negotiation_rule_violation} when the - * negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing, or - * {@code input_text_too_long} when the prompt exceeds the configured maximum length + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length */ public FilledParamData validateProposePromptAndDataFilling( String prompt, @@ -345,13 +351,13 @@ public FilledParamData validateProposePromptAndDataFilling( * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the schema or the template URI is null * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the context is - * null and the message is therefore not a negotiation message, {@code negotiation_rule_violation} when the - * negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing, or - * {@code input_text_too_long} when the prompt exceeds the configured maximum length + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length */ public FilledParamData validateAcceptPromptAndDataFilling( String prompt, @@ -377,13 +383,13 @@ public FilledParamData validateAcceptPromptAndDataFilling( * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the schema or the template URI is null * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the context is - * null and the message is therefore not a negotiation message, {@code negotiation_rule_violation} when the - * negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing, or - * {@code input_text_too_long} when the prompt exceeds the configured maximum length + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length */ public FilledParamData validateRejectPromptAndDataFilling( String prompt, @@ -408,13 +414,13 @@ public FilledParamData validateRejectPromptAndDataFilling( * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the schema or the template URI is null * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input} when the context is - * null and the message is therefore not a negotiation message, {@code negotiation_rule_violation} when the - * negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing, or - * {@code input_text_too_long} when the prompt exceeds the configured maximum length + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length */ public FilledParamData validateAbortPromptAndDataFilling( String prompt, @@ -463,8 +469,7 @@ private PromptTemplate loadTemplate(NegotiationReference reference) { try { return templateLoader.load(reference); } catch (ResourceNotFoundException exception) { - throw new NegotiationGenerationException( - A2ATErrorCodes.TEMPLATE_NOT_FOUND, exception.getMessage(), exception); + throw templateNotFound(reference, exception); } } @@ -472,23 +477,35 @@ private NegotiationContent extractContent(String text, NegotiationReference refe try { return withRetry(STEP_CONTENT_EXTRACT, () -> contentExtractor.extract(text, reference)); } catch (ResourceNotFoundException exception) { - throw new NegotiationGenerationException( - A2ATErrorCodes.TEMPLATE_NOT_FOUND, exception.getMessage(), exception); + throw templateNotFound(reference, exception); } } + private static NegotiationGenerationException templateNotFound( + NegotiationReference reference, ResourceNotFoundException cause) { + return new NegotiationGenerationException( + ErrorCatalog.TEMPLATE_NOT_FOUND, + reference.language(), + Map.of("template_uri", reference.uri(), "language", reference.language()), + cause); + } + private String renderMessage( NegotiationContext context, NegotiationContent content, NegotiationReference reference, PromptTemplate template) { - NegotiationGenerator generator = generatorRegistry.resolve(reference.type(), reference.performative(), content); + NegotiationGenerator generator = + generatorRegistry.resolve(reference.type(), reference.performative(), content, reference.language()); try { return generator.generate(context, content, template, vocabulary); } catch (NegotiationRenderException exception) { throw new NegotiationGenerationException( - A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, - "Rendering the negotiation template failed: " + exception.getMessage(), + ErrorCatalog.TEMPLATE_RENDER_FAILED, + reference.language(), + Map.of( + "template_uri", reference.uri(), + "reason", String.valueOf(exception.getMessage())), exception); } } @@ -594,14 +611,16 @@ private static String retryableCode(RuntimeException failure) { String code = null; if (failure instanceof NegotiationGenerationException generation) { code = generation.getCode(); - } else if (failure instanceof A2ATParamExtractionError extraction) { + } else if (failure instanceof NegotiationParamExtractionException extraction) { code = extraction.getCode(); } if (code == null) { return null; } - boolean retryable = A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED.equals(code) - || A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR.equals(code); + boolean retryable = + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode().equals(code) + || ErrorCatalog.LLM_INVOCATION_FAILED.getCode().equals(code) + || ErrorCatalog.LLM_RESPONSE_INVALID.getCode().equals(code); return retryable ? code : null; } @@ -611,17 +630,20 @@ private static void requireContext(NegotiationContext context) { private void requireTextWithinLimit(String text) { if (InputLimitConfig.isTooLong(text, maxTextChars)) { - throw new NegotiationGenerationException( - A2ATErrorCodes.INPUT_TEXT_TOO_LONG, InputLimitConfig.violationMessage(text, maxTextChars)); + throw new NegotiationGenerationException(ErrorCatalog.INPUT_TEXT_TOO_LONG, language, inputTooLongFacts(text)); } } private void requirePromptWithinLimit(String prompt) { if (InputLimitConfig.isTooLong(prompt, maxTextChars)) { throw new NegotiationParamExtractionException( - A2ATErrorCodes.INPUT_TEXT_TOO_LONG, - InputLimitConfig.violationMessage(prompt, maxTextChars), - List.of()); + ErrorCatalog.INPUT_TEXT_TOO_LONG, language, inputTooLongFacts(prompt), List.of()); } } + + private Map inputTooLongFacts(String text) { + return Map.of( + "actual_length", String.valueOf(text == null ? 0 : text.length()), + "max_chars", String.valueOf(maxTextChars)); + } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index 23eedbda..e87dd9cf 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -5,6 +5,7 @@ import net.openan.a2at.sdk.core.model.LlmConfig; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMConfigError; import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; @@ -12,7 +13,6 @@ import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationSemanticValidator; import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; -import net.openan.a2at.sdk.negotiation.validation.NegotiationValidationException; import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; import org.slf4j.Logger; @@ -20,10 +20,9 @@ * Fluent builder assembling one {@link NegotiationGenerationOrchestrator}. * *

        The language is required. The LLM client is optional: without one, the from-data generation still works, while - * the LLM steps of the from-text generation and the validation pipeline fail with the - * {@code negotiation_llm_infrastructure_error} code. Every collaborator has a default implementation wired from the - * language (classpath-fixed templates and negotiation vocabulary), and each of them can be overridden for testing or - * customization. + * the LLM steps of the from-text generation and the validation pipeline fail with the {@code llm.not_configured} + * code. Every collaborator has a default implementation wired from the language (classpath-fixed templates and + * negotiation vocabulary), and each of them can be overridden for testing or customization. * * @since 2026-08 */ @@ -95,7 +94,7 @@ public NegotiationGenerationOrchestratorBuilder maxAttempts(int maxAttempts) { * Configures the maximum length in characters accepted for free-text inputs before they reach an LLM step. * * @param maxTextChars maximum accepted length in characters, at least 1; oversized inputs fail fast with the code - * {@code input_text_too_long} instead of overflowing the LLM context + * {@code input.text_too_long} instead of overflowing the LLM context * @return current builder */ public NegotiationGenerationOrchestratorBuilder maxTextChars(int maxTextChars) { @@ -183,7 +182,7 @@ public NegotiationGenerationOrchestrator build() { NegotiationContentExtractor effectiveContentExtractor = contentExtractor != null ? contentExtractor : new DefaultNegotiationContentExtractor(llmClient); NegotiationComplianceChecker effectiveComplianceChecker = - complianceChecker != null ? complianceChecker : new DefaultNegotiationComplianceChecker(); + complianceChecker != null ? complianceChecker : new DefaultNegotiationComplianceChecker(language); NegotiationSemanticValidator effectiveSemanticValidator = semanticValidator != null ? semanticValidator : defaultSemanticValidator(); ParamExtractor paramExtractor = new ParamExtractor( @@ -222,8 +221,7 @@ public NegotiationGenerationOrchestrator build() { private NegotiationSemanticValidator defaultSemanticValidator() { if (llmClient == null) { return (prompt, callerSchema, reference, templateContent) -> { - throw new NegotiationValidationException( - "Semantic validation requires an LLM client but none is configured."); + throw new LLMConfigError("Semantic validation requires an LLM client but none is configured."); }; } return new DefaultNegotiationSemanticValidator(llmClient); diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java index 1966731c..0141e54c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java @@ -3,15 +3,17 @@ import java.util.EnumMap; import java.util.Map; import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.NegotiationContent; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; @@ -57,13 +59,19 @@ public NegotiationGeneratorRegistry() { * @param type negotiation type addressed by the template URI; null only for the type-independent abort phase * @param phase API-level phase addressed by the calling method * @param content typed content of the message + * @param language message language used to render the failure message of a conclusion mismatch * @return generator registered for the exact (type, phase) pair * @throws NullPointerException if the phase or content is null, or the type is null on a typed phase - * @throws IllegalArgumentException if the content family does not match the phase, the content runtime type does - * not match the negotiation type, or an ending content carries a conclusion that does not match the phase + * @throws IllegalArgumentException if the content family does not match the phase or the content runtime type does + * not match the negotiation type + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} if an ending content + * carries a conclusion that does not match the phase */ public NegotiationGenerator resolve( - @Nullable NegotiationType type, NegotiationPerformative phase, NegotiationContent content) { + @Nullable NegotiationType type, + NegotiationPerformative phase, + NegotiationContent content, + String language) { Objects.requireNonNull(phase, "Negotiation phase must not be null."); Objects.requireNonNull(content, "Negotiation content must not be null."); if (phase == NegotiationPerformative.ABORT) { @@ -72,12 +80,11 @@ public NegotiationGenerator resolve( "The ABORT phase is type-independent and must not carry a type but carried " + type + "."); } if (content.getClass() != NegotiationAbortContent.class) { - throw new IllegalArgumentException( - "The ABORT phase requires abort content but received " + content.getClass().getSimpleName() - + "."); + throw new IllegalArgumentException("The ABORT phase requires abort content but received " + + content.getClass().getSimpleName() + "."); } - LOGGER.atDebug() - .log("negotiation_generator_dispatched generator=AbortGenerator type=common performative=ABORT"); + LOGGER.atDebug().log( + "negotiation_generator_dispatched generator=AbortGenerator type=common performative=ABORT"); return abortGenerator; } Objects.requireNonNull(type, "Negotiation type must not be null for the " + phase + " phase."); @@ -97,7 +104,7 @@ public NegotiationGenerator resolve( + " but received " + content.getClass().getSimpleName() + "."); } if (!proposePhase) { - requireConclusionMatchesPhase((NegotiationEndingContent) content, phase); + requireConclusionMatchesPhase((NegotiationEndingContent) content, phase, language); } NegotiationGenerator generator = (proposePhase ? proposeGenerators : endingGenerators).get(type); if (generator == null) { @@ -113,16 +120,17 @@ public NegotiationGenerator resolve( } private static void requireConclusionMatchesPhase( - NegotiationEndingContent content, NegotiationPerformative phase) { + NegotiationEndingContent content, NegotiationPerformative phase, String language) { NegotiationConclusion conclusion = Objects.requireNonNull( content.conclusion(), "Negotiation conclusion must not be null; the " + phase + " phase requires a conclusion."); NegotiationConclusion expected = phase == NegotiationPerformative.ACCEPT ? NegotiationConclusion.ACCEPT : NegotiationConclusion.REJECT; if (conclusion != expected) { - throw new IllegalArgumentException( - "The " + phase + " phase requires conclusion " + expected.literal() + " but the content carries " - + conclusion.literal() + "."); + throw new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH, + language, + Map.of("expected", expected.literal(), "actual", conclusion.literal())); } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java index a6916cf1..586219f7 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java @@ -1,54 +1,59 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Generator for target negotiation terminal messages. - * - *

        The result content slot carries the confirmed intent for an accept conclusion or the failure reason for a reject - * conclusion; the other field is ignored. - * - * @since 2026-08 - */ -final class TargetEndingGenerator extends AbstractNegotiationGenerator { - - /** - * Generates a target negotiation accept or reject message. - * - * @param context negotiation context of the message - * @param content target ending content - * @param template target ending template to render - * @param vocabulary vocabulary of the message language - * @return rendered target terminal message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - TargetEndingContent endingContent = contentOf(content, TargetEndingContent.class, "Target ending generator"); - NegotiationConclusion conclusion = renderableConclusion(endingContent.conclusion()); - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.target_conclusion"), conclusion.literal()); - slots.put(vocabulary.get("slot.target_result_content"), resultContentSlotValue(endingContent, conclusion)); - return render(template, slots); - } - - private static String resultContentSlotValue(TargetEndingContent content, NegotiationConclusion conclusion) { - if (conclusion == NegotiationConclusion.ACCEPT) { - return requiredText( - content.confirmedIntent(), - "content.confirmedIntent", - "Confirmed intent of an accepting target negotiation message"); - } - return requiredText( - content.failureReason(), - "content.failureReason", - "Failure reason of a rejecting target negotiation" + " message"); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Generator for target negotiation terminal messages. + * + *

        The result content slot carries the confirmed intent for an accept conclusion or the failure reason for a reject + * conclusion; the other field is ignored. + * + * @since 2026-08 + */ +final class TargetEndingGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a target negotiation accept or reject message. + * + * @param context negotiation context of the message + * @param content target ending content + * @param template target ending template to render + * @param vocabulary vocabulary of the message language + * @return rendered target terminal message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + TargetEndingContent endingContent = contentOf(content, TargetEndingContent.class, "Target ending generator"); + NegotiationConclusion conclusion = renderableConclusion(endingContent.conclusion()); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.target_conclusion"), conclusion.literal()); + slots.put( + vocabulary.get("slot.target_result_content"), + resultContentSlotValue(endingContent, conclusion, vocabulary)); + return render(template, slots); + } + + private static String resultContentSlotValue( + TargetEndingContent content, NegotiationConclusion conclusion, Vocabulary vocabulary) { + if (conclusion == NegotiationConclusion.ACCEPT) { + return requiredText( + content.confirmedIntent(), + "content.confirmedIntent", + "Confirmed intent of an accepting target negotiation message", + vocabulary); + } + return requiredText( + content.failureReason(), + "content.failureReason", + "Failure reason of a rejecting target negotiation" + " message", + vocabulary); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java index bf886fe3..893e4e23 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java @@ -1,84 +1,95 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import org.jspecify.annotations.Nullable; - -/** - * Generator for target negotiation propose messages. - * - *

        Besides the required target negotiation summary, the message renders round-driven conditional sections: the intent - * understanding section appears only on the first round, the alignment and clarification section only on later rounds, - * and the clarification request section only when clarification items are present. A non-blank confirm request marks the - * "target clarified and requesting confirmation" category instead; the three conditional lists must then be empty, so - * only the summary and the confirm request section remain. - * - * @since 2026-08 - */ -final class TargetProposeGenerator extends AbstractNegotiationGenerator { - - /** - * Generates a target negotiation propose message. - * - * @param context negotiation context of the message - * @param content target propose content - * @param template target propose template to render - * @param vocabulary vocabulary of the message language - * @return rendered target propose message text - * @throws IllegalArgumentException if the summary is blank or the confirm request is combined with any of the three - * conditional sections - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - TargetProposeContent proposeContent = - contentOf(content, TargetProposeContent.class, "Target propose generator"); - requiredText( - proposeContent.targetNegotiationDescription(), - "content.targetNegotiationDescription", - "Target negotiation description"); - String confirmRequest = presentText(proposeContent.targetConfirmRequest()); - if (confirmRequest != null - && (hasItems(proposeContent.intentUnderstanding()) - || hasItems(proposeContent.alignmentAndClarification()) - || hasItems(proposeContent.requestForClarification()))) { - throw new IllegalArgumentException( - "Target confirm request must not be combined with the intent understanding, alignment and" - + " clarification or clarification request sections; a confirm-request round carries only" - + " the summary and the confirm request."); - } - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.target"), proposeContent.targetNegotiationDescription()); - if (context.round() == 1) { - slots.put( - vocabulary.get("slot.target_intent"), - formatItems(proposeContent.intentUnderstanding(), vocabulary)); - } else { - slots.put( - vocabulary.get("slot.target_alignment"), - formatItems(proposeContent.alignmentAndClarification(), vocabulary)); - } - slots.put( - vocabulary.get("slot.target_clarification"), - formatItems(proposeContent.requestForClarification(), vocabulary)); - if (confirmRequest != null) { - slots.put(vocabulary.get("slot.target_confirm_request"), confirmRequest); - } - return render(template, slots); - } - - private static @Nullable String presentText(@Nullable String value) { - return value == null || value.isBlank() ? null : value; - } - - private static boolean hasItems(@Nullable List items) { - return items != null && !items.isEmpty(); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import org.jspecify.annotations.Nullable; + +/** + * Generator for target negotiation propose messages. + * + *

        Besides the required target negotiation summary, the message renders round-driven conditional sections: the intent + * understanding section appears only on the first round, the alignment and clarification section only on later rounds, + * and the clarification request section only when clarification items are present. A non-blank confirm request marks + * the "target clarified and requesting confirmation" category instead; the three conditional lists must then be empty, + * so only the summary and the confirm request section remain. + * + * @since 2026-08 + */ +final class TargetProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a target negotiation propose message. + * + * @param context negotiation context of the message + * @param content target propose content + * @param template target propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered target propose message text + * @throws IllegalArgumentException if the content is of another runtime type + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the summary is blank, + * or {@code negotiation.invalid_input} if the confirm request is combined with any of the three conditional + * sections + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + TargetProposeContent proposeContent = + contentOf(content, TargetProposeContent.class, "Target propose generator"); + requiredText( + proposeContent.targetNegotiationDescription(), + "content.targetNegotiationDescription", + "Target negotiation description", + vocabulary); + String confirmRequest = presentText(proposeContent.targetConfirmRequest()); + if (confirmRequest != null + && (hasItems(proposeContent.intentUnderstanding()) + || hasItems(proposeContent.alignmentAndClarification()) + || hasItems(proposeContent.requestForClarification()))) { + throw invalidInput( + "Target confirm request must not be combined with the intent understanding, alignment and" + + " clarification or clarification request sections; a confirm-request round carries only" + + " the summary and the confirm request.", + vocabulary); + } + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.target"), proposeContent.targetNegotiationDescription()); + if (context.round() == 1) { + slots.put( + vocabulary.get("slot.target_intent"), + formatItems(proposeContent.intentUnderstanding(), vocabulary)); + } else { + slots.put( + vocabulary.get("slot.target_alignment"), + formatItems(proposeContent.alignmentAndClarification(), vocabulary)); + } + slots.put( + vocabulary.get("slot.target_clarification"), + formatItems(proposeContent.requestForClarification(), vocabulary)); + if (confirmRequest != null) { + slots.put(vocabulary.get("slot.target_confirm_request"), confirmRequest); + } + return render(template, slots); + } + + private static @Nullable String presentText(@Nullable String value) { + return value == null || value.isBlank() ? null : value; + } + + private static NegotiationGenerationException invalidInput(String reason, Vocabulary vocabulary) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT, vocabulary.language(), Map.of("reason", reason)); + } + + private static boolean hasItems(@Nullable List items) { + return items != null && !items.isEmpty(); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java index e307563c..ba613460 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java @@ -1,87 +1,112 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.regex.Pattern; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Default rule-level compliance checker for negotiation messages. - * - *

        The checker validates the negotiation context carried alongside the message in the A2A-T metadata: the id must be - * a UUID of 36 characters in 8-4-4-4-12 hexadecimal form and the round must not exceed the round budget. The - * positive-integer shape of the round fields is already guaranteed by the {@link NegotiationContext} constructor. No - * other rules are applied: the checker does not infer the negotiation type, does not validate conclusion values, does - * not require ending result sections and does not check conditional-section exclusivity. - * - * @since 2026-08 - */ -public final class DefaultNegotiationComplianceChecker implements NegotiationComplianceChecker { - - private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationComplianceChecker.class); - - private static final Pattern UUID_PATTERN = - Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); - - private static final String ID_SLOT = "id"; - - private static final String ROUND_SLOT = "round"; - - /** Creates the default checker. */ - public DefaultNegotiationComplianceChecker() {} - - /** - * Runs the rule-level compliance check of one negotiation context. - * - * @param context negotiation context carried alongside the message in the A2A-T metadata - * @return rule check outcome with no errors when every context rule holds, otherwise the context rule errors - * @throws NullPointerException if the context is null - */ - @Override - public NegotiationRuleCheckResult check(NegotiationContext context) { - Objects.requireNonNull(context, "context"); - List errors = new ArrayList<>(); - collectIdErrors(context.id(), errors); - if (context.round() > context.maxRounds()) { - errors.add(new SlotValidationError( - ROUND_SLOT, - "out_of_range", - "Negotiation context round " + context.round() + " must not exceed maxRounds " - + context.maxRounds() + ".")); - } - return logResult(new NegotiationRuleCheckResult(errors.isEmpty(), List.copyOf(errors))); - } - - private static void collectIdErrors(String id, List errors) { - if (!UUID_PATTERN.matcher(id).matches()) { - errors.add(new SlotValidationError( - ID_SLOT, - "invalid_uuid", - "Negotiation context id must be a 36-character UUID in 8-4-4-4-12 hexadecimal form but was '" + id - + "'.")); - } - } - - /** - * Emits the rule-check completion event of one check outcome. - * - * @param result rule check outcome to log - * @return the unchanged outcome - */ - private static NegotiationRuleCheckResult logResult(NegotiationRuleCheckResult result) { - if (result.errors().isEmpty()) { - LOGGER.atDebug().log("negotiation_rule_checks_completed passed={} error_count=0", result.passed()); - } else { - LOGGER.atWarn() - .log( - "negotiation_rule_checks_completed passed={} error_count={}", - result.passed(), - result.errors().size()); - } - return result; - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default rule-level compliance checker for negotiation messages. + * + *

        The checker validates the negotiation context carried alongside the message in the A2A-T metadata: the id must be + * a UUID of 36 characters in 8-4-4-4-12 hexadecimal form and the round must not exceed the round budget. Error messages + * are rendered from the {@link ErrorCatalog} message templates in the configured language. The positive-integer shape + * of the round fields is already guaranteed by the {@link NegotiationContext} constructor. No other rules are applied: + * the checker does not infer the negotiation type, does not validate conclusion values, does not require ending result + * sections and does not check conditional-section exclusivity. + * + * @since 2026-08 + */ +public final class DefaultNegotiationComplianceChecker implements NegotiationComplianceChecker { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationComplianceChecker.class); + + private static final Pattern UUID_PATTERN = + Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + + private static final String ID_SLOT = "id"; + + private static final String ROUND_SLOT = "round"; + + private final String language; + + /** Creates the default checker rendering messages in {@code en-US}. */ + public DefaultNegotiationComplianceChecker() { + this(null); + } + + /** + * Creates the default checker rendering messages in one language. + * + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + */ + public DefaultNegotiationComplianceChecker(@Nullable String language) { + this.language = language; + } + + /** + * Runs the rule-level compliance check of one negotiation context. + * + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @return rule check outcome with no errors when every context rule holds, otherwise the context rule errors + * @throws NullPointerException if the context is null + */ + @Override + public NegotiationRuleCheckResult check(NegotiationContext context) { + Objects.requireNonNull(context, "context"); + List errors = new ArrayList<>(); + collectIdErrors(context.id(), errors); + if (context.round() > context.maxRounds()) { + errors.add(new SlotValidationError( + ROUND_SLOT, + ErrorCatalog.NEGOTIATION_ROUND_EXCEEDED.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_ROUND_EXCEEDED, language, roundFacts(context)), + roundFacts(context))); + } + return logResult(new NegotiationRuleCheckResult(errors.isEmpty(), List.copyOf(errors))); + } + + private static Map roundFacts(NegotiationContext context) { + return Map.of( + "round", String.valueOf(context.round()), + "max_rounds", String.valueOf(context.maxRounds())); + } + + private void collectIdErrors(String id, List errors) { + if (!UUID_PATTERN.matcher(id).matches()) { + Map facts = Map.of("actual", id); + errors.add(new SlotValidationError( + ID_SLOT, + ErrorCatalog.NEGOTIATION_INVALID_CONTEXT_ID.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_CONTEXT_ID, language, facts), + facts)); + } + } + + /** + * Emits the rule-check completion event of one check outcome. + * + * @param result rule check outcome to log + * @return the unchanged outcome + */ + private static NegotiationRuleCheckResult logResult(NegotiationRuleCheckResult result) { + if (result.errors().isEmpty()) { + LOGGER.atDebug().log("negotiation_rule_checks_completed passed={} error_count=0", result.passed()); + } else { + LOGGER.atWarn() + .log( + "negotiation_rule_checks_completed passed={} error_count={}", + result.passed(), + result.errors().size()); + } + return result; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java index 1bc3db9d..180f8933 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java @@ -13,15 +13,17 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.function.UnaryOperator; import java.util.regex.Matcher; import java.util.regex.Pattern; import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMError; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.content.NegotiationType; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; @@ -38,9 +40,14 @@ * *

        After the call the validator enforces the output contract in code: the response must contain the four required * keys with the expected shapes, otherwise the internal {@link NegotiationValidationException} is thrown for the - * orchestration layer to map to the retryable LLM infrastructure error code. When the verdict is true, the reported - * negotiation type must be present and must match the type declared by the template reference; a null or mismatching - * type turns the outcome into a semantic rejection carrying a {@code section.*} error. Phase consistency between the + * orchestration layer to map to the retryable {@code llm.response_invalid} code; a transport failure of the LLM call + * propagates as {@link net.openan.a2at.sdk.llm.LLMError} for the orchestration layer to map to + * {@code llm.invocation_failed}. The LLM reports each error as {@code {slot_name, code, facts}}; the human-readable + * message is rendered from the code's message template in the reference language, never taken from the LLM response, + * and a code outside the closed {@code negotiation.*} set of the catalog is mapped to the fallback + * {@code negotiation.rule_violation} and logged as a warning. When the verdict is true, the reported negotiation type + * must be present and must match the type declared by the template reference; a null or mismatching type turns the + * outcome into a semantic rejection carrying a {@code negotiation.type_mismatch} error. Phase consistency between the * declared template and the message sections is part of the semantic tasks performed by the LLM and surfaces through * the returned errors. * @@ -66,6 +73,14 @@ public final class DefaultNegotiationSemanticValidator implements NegotiationSem private static final String KEY_PARAMS = "params"; + private static final String KEY_SLOT_NAME = "slot_name"; + + private static final String KEY_CODE = "code"; + + private static final String KEY_FACTS = "facts"; + + private static final String NEGOTIATION_CODE_DOMAIN = "negotiation."; + private final LLMClient llmClient; private static final List NEGOTIATION_TYPE_ENUM = @@ -126,14 +141,18 @@ static Map buildSemanticValidationSchema(Map cal } private static Map errorsSchema() { + Map factsProperties = new LinkedHashMap<>(); + factsProperties.put("type", "object"); + factsProperties.put("additionalProperties", Map.of("type", "string")); Map errorProperties = new LinkedHashMap<>(); - errorProperties.put("slot_name", Map.of("type", "string")); - errorProperties.put("code", Map.of("type", "string")); - errorProperties.put("message", Map.of("type", "string")); + errorProperties.put(KEY_SLOT_NAME, Map.of("type", "string")); + errorProperties.put(KEY_CODE, Map.of("type", "string")); + errorProperties.put(KEY_FACTS, factsProperties); Map errorItem = new LinkedHashMap<>(); errorItem.put("type", "object"); errorItem.put("properties", errorProperties); - errorItem.put("required", List.of("slot_name", "code", "message")); + errorItem.put("required", List.of(KEY_SLOT_NAME, KEY_CODE, KEY_FACTS)); + errorItem.put("additionalProperties", false); Map errors = new LinkedHashMap<>(); errors.put("type", "array"); errors.put("items", errorItem); @@ -159,8 +178,8 @@ private static Map wrapCallerSchema(Map callerSc * and language * @return semantic validation outcome carrying the verdict, the implied negotiation type, the semantic errors and * the extracted parameters - * @throws NegotiationValidationException if the LLM invocation fails or the response misses a required key or has - * the wrong shape + * @throws NegotiationValidationException if the response misses a required key or has the wrong shape + * @throws net.openan.a2at.sdk.llm.LLMError if the LLM invocation fails at the transport level * @throws ResourceNotFoundException if the semantic validation prompt resources of the reference language are * missing */ @@ -171,13 +190,7 @@ public SemanticValidationResult validateNegotiation( Objects.requireNonNull(reference, "reference"); List> messages = buildMessages(prompt, callerSchema, reference, templateContent); Map mergedSchema = schemaBuilder.apply(callerSchema); - LLMResponse response; - try { - response = llmClient.structured(messages, mergedSchema, null, null); - } catch (LLMError error) { - throw new NegotiationValidationException( - "Semantic validation LLM invocation failed: " + error.getMessage(), error); - } + LLMResponse response = llmClient.structured(messages, mergedSchema, null, null); SemanticValidationResult result = interpret(parseResponse(response.content()), reference); LOGGER.atInfo().log( "negotiation_semantic_validation_completed verdict={} error_count={}", @@ -197,15 +210,17 @@ private static List> buildMessages( Matcher matcher = USER_PROMPT_PLACEHOLDER_PATTERN.matcher(userPrompt); StringBuilder sb = new StringBuilder(); while (matcher.find()) { - String replacement = switch (matcher.group(1)) { - case "phase" -> Matcher.quoteReplacement(reference.performative().name().toLowerCase(Locale.ROOT)); - case "input" -> Matcher.quoteReplacement(prompt); - case "template_uri" -> Matcher.quoteReplacement(reference.uri()); - case "negotiation_type" -> Matcher.quoteReplacement(declaredTypeName(reference)); - case "template_content" -> Matcher.quoteReplacement(templateContent); - case "schema" -> Matcher.quoteReplacement(toJson(callerSchema)); - default -> Matcher.quoteReplacement(matcher.group()); - }; + String replacement = + switch (matcher.group(1)) { + case "phase" -> Matcher.quoteReplacement( + reference.performative().name().toLowerCase(Locale.ROOT)); + case "input" -> Matcher.quoteReplacement(prompt); + case "template_uri" -> Matcher.quoteReplacement(reference.uri()); + case "negotiation_type" -> Matcher.quoteReplacement(declaredTypeName(reference)); + case "template_content" -> Matcher.quoteReplacement(templateContent); + case "schema" -> Matcher.quoteReplacement(toJson(callerSchema)); + default -> Matcher.quoteReplacement(matcher.group()); + }; matcher.appendReplacement(sb, replacement); } matcher.appendTail(sb); @@ -226,8 +241,7 @@ private static String loadPromptResource(String fileName, String language) { try (stream) { return new String(stream.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException exception) { - throw new A2ATError( - "Failed to read negotiation semantic validation prompt: " + classpathPath, exception); + throw new A2ATError("Failed to read negotiation semantic validation prompt: " + classpathPath, exception); } } @@ -273,7 +287,7 @@ private static SemanticValidationResult interpret(Map response, throw new NegotiationValidationException("Semantic validation response key params must be an object."); } - List errors = parseErrors(rawErrors); + List errors = parseErrors(rawErrors, reference.language()); Map params = parseParams(rawParams); String negotiationType = (String) typeValue; @@ -281,9 +295,7 @@ private static SemanticValidationResult interpret(Map response, if (negotiationType == null) { List rejectionErrors = new ArrayList<>(errors); rejectionErrors.add(typeConsistencyError( - reference.type(), - "Semantic verdict is true but negotiation_type is null; the implied type must match the" - + " declared template.")); + reference.type(), "unknown", declaredTypeName(reference), reference.language())); return new SemanticValidationResult(false, null, rejectionErrors, params); } NegotiationType impliedType = parseImpliedType(negotiationType); @@ -291,33 +303,70 @@ private static SemanticValidationResult interpret(Map response, NegotiationType sectionType = impliedType == null ? reference.type() : impliedType; List rejectionErrors = new ArrayList<>(errors); rejectionErrors.add(typeConsistencyError( - sectionType, - "Message implies negotiation type " + negotiationType - + " but the declared template URI expects " + declaredTypeName(reference) + ".")); + sectionType, negotiationType, declaredTypeName(reference), reference.language())); return new SemanticValidationResult(false, negotiationType, rejectionErrors, params); } } return new SemanticValidationResult(verdict, negotiationType, errors, params); } - private static List parseErrors(List rawErrors) { + private static List parseErrors(List rawErrors, String language) { List errors = new ArrayList<>(); for (Object rawError : rawErrors) { if (!(rawError instanceof Map errorMap)) { throw new NegotiationValidationException( - "Semantic validation response errors must be objects with slot_name, code and message."); + "Semantic validation response errors must be objects with slot_name, code and facts."); } - if (!(errorMap.get("slot_name") instanceof String slotName) - || !(errorMap.get("code") instanceof String code) - || !(errorMap.get("message") instanceof String message)) { + if (!(errorMap.get(KEY_SLOT_NAME) instanceof String slotName) + || !(errorMap.get(KEY_CODE) instanceof String code)) { throw new NegotiationValidationException( - "Semantic validation response errors must carry string slot_name, code and message values."); + "Semantic validation response errors must carry string slot_name and code values."); + } + if (!(errorMap.get(KEY_FACTS) instanceof Map rawFacts)) { + throw new NegotiationValidationException( + "Semantic validation response errors must carry a facts object."); + } + Map facts = stringFacts(rawFacts); + ErrorCatalog entry = resolveCode(code); + if (!entry.getCode().equals(code)) { + LOGGER.atWarn() + .log( + "negotiation_semantic_validation_unknown_code original_code={} fallback_code={}", + code, + entry.getCode()); + facts = Map.of("section_label", slotName); } - errors.add(new SlotValidationError(slotName, code, message)); + errors.add(new SlotValidationError( + slotName, entry.getCode(), ErrorMessages.render(entry, language, facts), facts)); } return errors; } + /** Resolves one LLM-reported code to its catalog entry, falling back to {@code negotiation.rule_violation}. */ + private static ErrorCatalog resolveCode(String code) { + Optional entry = ErrorCatalog.byCode(code); + if (entry.isPresent() && entry.get().getCode().startsWith(NEGOTIATION_CODE_DOMAIN)) { + return entry.get(); + } + return ErrorCatalog.NEGOTIATION_RULE_VIOLATION; + } + + private static Map stringFacts(Map rawFacts) { + Map facts = new LinkedHashMap<>(); + for (Map.Entry entry : rawFacts.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + continue; + } + Object value = entry.getValue(); + if (value instanceof String text) { + facts.put(key, text); + } else if (value instanceof Number || value instanceof Boolean) { + facts.put(key, String.valueOf(value)); + } + } + return Map.copyOf(facts); + } + private static Map parseParams(Map rawParams) { Map params = new LinkedHashMap<>(); for (Map.Entry entry : rawParams.entrySet()) { @@ -329,8 +378,14 @@ private static Map parseParams(Map rawParams) { return params; } - private static SlotValidationError typeConsistencyError(NegotiationType sectionType, String message) { - return new SlotValidationError(declaredTypeSectionKey(sectionType), "template_type_mismatch", message); + private static SlotValidationError typeConsistencyError( + NegotiationType sectionType, String implied, String declared, String language) { + Map facts = Map.of("implied", implied, "declared", declared); + return new SlotValidationError( + declaredTypeSectionKey(sectionType), + ErrorCatalog.NEGOTIATION_TYPE_MISMATCH.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_TYPE_MISMATCH, language, facts), + facts); } /** Parses the negotiation type reported for the message; null when the reported type is none of the known types. */ diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java index bad6540f..f16fb614 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java @@ -1,58 +1,97 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.RuleChecker; - -/** - * Adapter that bridges {@link NegotiationComplianceChecker} to the {@link RuleChecker} contract for one validation - * call. - * - *

        The adapter holds a {@link NegotiationComplianceChecker} and the negotiation context carried alongside the - * message in the A2A-T metadata, delegating the {@link RuleChecker#check(String)} call to the context-aware - * {@link NegotiationComplianceChecker#check(NegotiationContext)} method. A null context is reported as not being a - * negotiation message. The resulting {@link NegotiationRuleCheckResult} is converted into either a map of context - * parameters ({@code id}, {@code round}, {@code maxRounds}) or a {@link ContentValidationException}. - * - * @since 2026-08 - */ -public final class NegotiationRuleCheckerAdapter implements RuleChecker { - - private static final String NON_NEGOTIATION_MESSAGE = - "missing negotiation context; for Task-T compliance use checkTaskPrompt"; - - private final NegotiationComplianceChecker checker; - - private final NegotiationContext context; - - public NegotiationRuleCheckerAdapter(NegotiationComplianceChecker checker, NegotiationContext context) { - this.checker = Objects.requireNonNull(checker, "checker"); - this.context = context; - } - - @Override - public Map check(String prompt) { - if (context == null) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, NON_NEGOTIATION_MESSAGE, List.of()); - } - NegotiationRuleCheckResult result = checker.check(context); - if (!result.passed()) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_RULE_VIOLATION, - "Negotiation context rule validation failed.", - result.errors()); - } - Map params = new LinkedHashMap<>(); - params.put("id", context.id()); - params.put("round", context.round()); - params.put("maxRounds", context.maxRounds()); - return Collections.unmodifiableMap(params); - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.RuleChecker; +import org.jspecify.annotations.Nullable; + +/** + * Adapter that bridges {@link NegotiationComplianceChecker} to the {@link RuleChecker} contract for one validation + * call. + * + *

        The adapter holds a {@link NegotiationComplianceChecker} and the negotiation context carried alongside the message + * in the A2A-T metadata, delegating the {@link RuleChecker#check(String)} call to the context-aware + * {@link NegotiationComplianceChecker#check(NegotiationContext)} method. A null context is reported as not being a + * negotiation message. The resulting {@link NegotiationRuleCheckResult} is converted into either a map of context + * parameters ({@code id}, {@code round}, {@code maxRounds}) or a {@link ContentValidationException} carrying the + * catalog codes {@code negotiation.invalid_input} (no negotiation context) or {@code negotiation.rule_violation} + * (context rule violation) with messages rendered in the configured language. + * + * @since 2026-08 + */ +public final class NegotiationRuleCheckerAdapter implements RuleChecker { + + private static final String MISSING_CONTEXT_REASON_EN = + "the negotiation context is missing (the message is not a negotiation message)"; + + private static final String MISSING_CONTEXT_REASON_ZH = "缺少协商上下文(该报文不是协商报文)"; + + private final NegotiationComplianceChecker checker; + + private final NegotiationContext context; + + private final String language; + + /** + * Creates an adapter for one compliance checker and negotiation context rendering messages in {@code en-US}. + * + * @param checker compliance checker validating the negotiation context + * @param context negotiation context carried alongside the message; null is reported as not being a negotiation + * message + */ + public NegotiationRuleCheckerAdapter(NegotiationComplianceChecker checker, @Nullable NegotiationContext context) { + this(checker, context, null); + } + + /** + * Creates an adapter for one compliance checker, negotiation context and message language. + * + * @param checker compliance checker validating the negotiation context + * @param context negotiation context carried alongside the message; null is reported as not being a negotiation + * message + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + */ + public NegotiationRuleCheckerAdapter( + NegotiationComplianceChecker checker, @Nullable NegotiationContext context, @Nullable String language) { + this.checker = Objects.requireNonNull(checker, "checker"); + this.context = context; + this.language = language; + } + + @Override + public Map check(String prompt) { + if (context == null) { + Map facts = Map.of("reason", missingContextReason()); + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), + List.of(), + Map.of(), + null); + } + NegotiationRuleCheckResult result = checker.check(context); + if (!result.passed()) { + String message = result.errors().isEmpty() + ? ErrorMessages.render(ErrorCatalog.NEGOTIATION_RULE_VIOLATION, language, null) + : result.errors().get(0).message(); + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), message, result.errors()); + } + Map params = new LinkedHashMap<>(); + params.put("id", context.id()); + params.put("round", context.round()); + params.put("maxRounds", context.maxRounds()); + return Collections.unmodifiableMap(params); + } + + private String missingContextReason() { + return language != null && language.startsWith("zh") ? MISSING_CONTEXT_REASON_ZH : MISSING_CONTEXT_REASON_EN; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java index 074aa3ab..b6da0951 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java @@ -1,58 +1,80 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.SemanticValidator; -import net.openan.a2at.sdk.core.validation.ValidationResult; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; - -/** - * LLM-backed semantic validator for negotiation messages. - * - *

        The validator performs a single structured LLM call that combines semantic validation with parameter extraction - * and then enforces the declared type consistency in code: when the verdict is true, the negotiation type reported for - * the message must be present and must match the type declared by the template reference. A response that misses one of - * the four required keys or has the wrong shape is a validation infrastructure failure signalled through the internal - * {@link NegotiationValidationException}. - * - * @since 2026-08 - */ -public interface NegotiationSemanticValidator extends SemanticValidator { - - /** - * Validates one rendered negotiation message semantically and extracts its parameters. - * - * @param prompt rendered negotiation message text - * @param callerSchema caller-provided parameter JSON schema embedded into the structured-call output contract - * @param reference negotiation reference the message is validated against, carrying the declared type, phase and - * language - * @param templateContent loaded template text used as a reference for structure/completeness checks - * @return semantic validation outcome carrying the verdict, the implied negotiation type, the semantic errors and - * the extracted parameters - * @throws NegotiationValidationException if the LLM invocation fails or the response misses a required key or has - * the wrong shape - * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the semantic validation prompt resources - * of the reference language are missing - */ - SemanticValidationResult validateNegotiation( - String prompt, Map callerSchema, NegotiationReference reference, String templateContent); - - @Override - default ValidationResult validate( - String prompt, Map schema, NegotiationReference reference, String templateContent) { - try { - SemanticValidationResult result = validateNegotiation(prompt, schema, reference, templateContent); - return new ValidationResult(result.verdict(), result.errors(), result.params()); - } catch (NegotiationValidationException exception) { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, - "Semantic validation LLM step failed: " + exception.getMessage(), - List.of(new SlotValidationError( - "_llm", A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, exception.getMessage())), - exception); - } - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.SemanticValidator; +import net.openan.a2at.sdk.core.validation.ValidationResult; +import net.openan.a2at.sdk.llm.LLMConfigError; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; + +/** + * LLM-backed semantic validator for negotiation messages. + * + *

        The validator performs a single structured LLM call that combines semantic validation with parameter extraction + * and then enforces the declared type consistency in code: when the verdict is true, the negotiation type reported for + * the message must be present and must match the type declared by the template reference. A response that misses one of + * the four required keys or has the wrong shape is a validation infrastructure failure signalled through the internal + * {@link NegotiationValidationException}. + * + * @since 2026-08 + */ +public interface NegotiationSemanticValidator extends SemanticValidator { + + /** + * Validates one rendered negotiation message semantically and extracts its parameters. + * + * @param prompt rendered negotiation message text + * @param callerSchema caller-provided parameter JSON schema embedded into the structured-call output contract + * @param reference negotiation reference the message is validated against, carrying the declared type, phase and + * language + * @param templateContent loaded template text used as a reference for structure/completeness checks + * @return semantic validation outcome carrying the verdict, the implied negotiation type, the semantic errors and + * the extracted parameters + * @throws NegotiationValidationException if the response misses a required key or has the wrong shape + * @throws net.openan.a2at.sdk.llm.LLMError if the LLM invocation fails at the transport level + * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the semantic validation prompt resources + * of the reference language are missing + */ + SemanticValidationResult validateNegotiation( + String prompt, Map callerSchema, NegotiationReference reference, String templateContent); + + @Override + default ValidationResult validate( + String prompt, Map schema, NegotiationReference reference, String templateContent) { + try { + SemanticValidationResult result = validateNegotiation(prompt, schema, reference, templateContent); + return new ValidationResult(result.verdict(), result.errors(), result.params()); + } catch (LLMConfigError exception) { + // A missing LLM configuration never recovers within this call. + throw new ContentValidationException( + ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), + ErrorMessages.render(ErrorCatalog.LLM_NOT_CONFIGURED, reference.language(), null), + exception); + } catch (LLMError exception) { + Map facts = Map.of( + "provider", exception.getClass().getSimpleName(), + "reason", String.valueOf(exception.getMessage())); + String message = ErrorMessages.render(ErrorCatalog.LLM_INVOCATION_FAILED, reference.language(), facts); + throw new ContentValidationException( + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + message, + List.of(new SlotValidationError( + "_llm", ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), message, facts)), + exception); + } catch (NegotiationValidationException exception) { + Map facts = Map.of("step", "semantic_validation"); + String message = ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, reference.language(), facts); + throw new ContentValidationException( + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + message, + List.of( + new SlotValidationError("_llm", ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), message, facts)), + exception); + } + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java index 710e8dea..f50c7df3 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java @@ -2,15 +2,18 @@ import java.util.Map; import java.util.Objects; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import java.util.Optional; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.validation.SemanticValidator; import net.openan.a2at.sdk.core.validation.TemplateContentLoader; import net.openan.a2at.sdk.core.validation.ValidationPipeline; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.jspecify.annotations.Nullable; /** * Orchestrates the validation of a negotiation message and the extraction of its parameters. @@ -20,6 +23,9 @@ * rule-level gate is a per-call {@link NegotiationRuleCheckerAdapter} bridging the negotiation context carried * alongside the message to the core rule checker contract; the injected {@link TemplateContentLoader} resolves the * template body after the rule gate and before semantic validation, so the template is never preloaded by the caller. + * The pipeline emits the final {@link net.openan.a2at.sdk.core.exception.ErrorCatalog} codes with messages rendered in + * the language of the negotiation reference; there is no code mapping layer between the pipeline steps and the + * surfaced exception. * * @since 2026-08 */ @@ -62,42 +68,43 @@ public ParamExtractor( * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param reference template reference the message is validated against * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NegotiationParamExtractionException with the code {@code negotiation_invalid_input}, - * {@code negotiation_rule_violation}, {@code negotiation_semantic_rejected}, - * {@code negotiation_llm_infrastructure_error} or {@code template_not_found} when the validation pipeline - * fails + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input}, + * {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, {@code llm.invocation_failed}, + * {@code llm.response_invalid} or {@code template.not_found} when the validation pipeline fails */ public FilledParamData extract( String prompt, - NegotiationContext context, + @Nullable NegotiationContext context, Map schema, NegotiationReference reference) { ValidationPipeline pipeline = new ValidationPipeline<>( - new NegotiationRuleCheckerAdapter(complianceChecker, context), + new NegotiationRuleCheckerAdapter(complianceChecker, context, reference.language()), semanticValidator, maxAttempts, + reference.language(), templateContentLoader); try { return pipeline.validate(prompt, schema, reference); } catch (ContentValidationException e) { - throw new NegotiationParamExtractionException(mapCode(e.getCode()), e.getMessage(), e.errors(), e); + throw negotiationFailure(e, reference.language()); } } - private static String mapCode(String code) { - switch (code) { - case A2ATErrorCodes.VALIDATION_INVALID_INPUT: - return A2ATErrorCodes.NEGOTIATION_INVALID_INPUT; - case A2ATErrorCodes.VALIDATION_RULE_VIOLATION: - return A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION; - case A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED: - return A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED; - case A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR: - return A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR; - case A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND: - return A2ATErrorCodes.TEMPLATE_NOT_FOUND; - default: - return code; + private static NegotiationParamExtractionException negotiationFailure( + ContentValidationException failure, String language) { + // The pipeline renders the final message itself; only the structured facts are reconstructed from the first + // slot detail that carries them, so callers keep the rendered message and gain the facts map. + ErrorCatalog entry = ErrorCatalog.byCode(failure.getCode()).orElse(null); + if (entry == null) { + throw new NegotiationParamExtractionException( + failure.getCode(), failure.getMessage(), failure.errors(), failure); } + Map facts = failure.errors().stream() + .map(SlotValidationError::facts) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + return new NegotiationParamExtractionException( + entry.getCode(), failure.getMessage(), facts, failure.errors(), failure); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java index 0c6b4aef..f40a4fe5 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java @@ -6,9 +6,9 @@ import java.util.Arrays; import java.util.List; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; import org.junit.jupiter.api.Test; class NegotiationExceptionHierarchyTest { @@ -20,51 +20,78 @@ class NegotiationExceptionHierarchyTest { }; @Test - void processingExceptionIsAProcessingErrorWithCode() { + void processingExceptionIsABusinessFailureWithCode() { NegotiationProcessingException exception = - new NegotiationProcessingException(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, "invalid input"); + new NegotiationProcessingException(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), "invalid input"); + assertTrue(exception instanceof A2ATBusinessException); assertTrue(exception instanceof A2ATError); - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, exception.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); + } + + @Test + void processingExceptionRendersItsMessageFromTheCatalogTemplate() { + NegotiationProcessingException exception = new NegotiationProcessingException( + ErrorCatalog.NEGOTIATION_CONTENT_INVALID, + "zh-CN", + java.util.Map.of("field", "content.terminationReason", "reason", "blank")); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals("协商内容字段「content.terminationReason」无效:blank", exception.getMessage()); + assertEquals("content.terminationReason", exception.getFacts().get("field")); } @Test void processingExceptionInheritsGetCodeFromTheA2ATErrorRoot() { NegotiationProcessingException exception = - new NegotiationProcessingException(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, "invalid input"); + new NegotiationProcessingException(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), "invalid input"); assertEquals( A2ATError.class, findGetMethod(NegotiationProcessingException.class).getDeclaringClass(), "the code accessor must come from the A2ATError root so every negotiation failure shares one code" + " contract"); - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, exception.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); } @Test void generationExceptionExtendsProcessingException() { NegotiationGenerationException exception = - new NegotiationGenerationException(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, "missing slot"); + new NegotiationGenerationException(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), "missing field"); assertTrue(exception instanceof NegotiationProcessingException); + assertTrue(exception instanceof A2ATBusinessException); assertTrue(exception instanceof A2ATError); - assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, exception.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); } @Test - void paramExtractionExceptionExtendsSharedParamExtractionError() { - NegotiationParamExtractionException defaultCodedException = - new NegotiationParamExtractionException("extraction failed"); + void paramExtractionExceptionIsABusinessFailureCarryingSlotDetails() { NegotiationParamExtractionException codedException = new NegotiationParamExtractionException( - A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, "rule violated", List.of()); + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), "rule violated", List.of()); - assertTrue(defaultCodedException instanceof A2ATParamExtractionError); - assertTrue(codedException instanceof A2ATParamExtractionError); - assertEquals(A2ATErrorCodes.PARAM_EXTRACTION_FAILED, defaultCodedException.getCode()); - assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, codedException.getCode()); + assertTrue(codedException instanceof A2ATBusinessException); + assertTrue(codedException instanceof A2ATError); + assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), codedException.getCode()); assertTrue(codedException.getErrors().isEmpty()); } + @Test + void paramExtractionExceptionRendersItsMessageAndKeepsTheCause() { + IllegalStateException cause = new IllegalStateException("endpoint unavailable"); + NegotiationParamExtractionException exception = new NegotiationParamExtractionException( + ErrorCatalog.LLM_INVOCATION_FAILED, + "zh-CN", + java.util.Map.of("provider", "OpenAIClient", "reason", "endpoint unavailable"), + List.of(), + cause); + + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), exception.getCode()); + assertEquals("LLM 调用失败(提供方 OpenAIClient):endpoint unavailable", exception.getMessage()); + assertEquals("endpoint unavailable", exception.getFacts().get("reason")); + assertEquals(cause, exception.getCause()); + } + @Test void noNegotiationExceptionExposesAStageProperty() { for (Class exceptionType : NEGOTIATION_EXCEPTION_TYPES) { diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java index 92f88c7c..fe24f6f3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java @@ -21,7 +21,8 @@ * *

        The twelve templates resolved from the test classpath must be byte-identical to the delivered template files of * the working tree, so that no packaging or formatting step can silently rewrite the authoritative template bytes. The - * error-code constants must stay exactly the pinned eight-constant registry. + * deprecated error-code constants must stay exactly the pinned forwarding registry: each constant forwards to the + * layered {@code domain.semantic} code of its {@link net.openan.a2at.sdk.core.exception.ErrorCatalog} replacement. */ class NegotiationDeliveredResourceContractTest { @@ -67,27 +68,27 @@ void everyPackagedTemplateIsByteIdenticalToTheDeliveredFile() throws IOException @Test void errorCodeConstantsStayExactlyThePinnedRegistry() { java.util.Map expected = new java.util.LinkedHashMap<>(); - expected.put("SDK_INTERNAL_ERROR", "sdk_internal_error"); - expected.put("INPUT_TEXT_TOO_LONG", "input_text_too_long"); - expected.put("PARAM_EXTRACTION_FAILED", "param_extraction_failed"); - expected.put("TEMPLATE_NOT_FOUND", "template_not_found"); - expected.put("NEGOTIATION_CONTENT_EXTRACT_FAILED", "negotiation_content_extract_failed"); - expected.put("NEGOTIATION_SEMANTIC_REJECTED", "negotiation_semantic_rejected"); - expected.put("NEGOTIATION_RULE_VIOLATION", "negotiation_rule_violation"); - expected.put("NEGOTIATION_SLOT_MISSING", "negotiation_slot_missing"); - expected.put("NEGOTIATION_INVALID_INPUT", "negotiation_invalid_input"); - expected.put("NEGOTIATION_LLM_INFRASTRUCTURE_ERROR", "negotiation_llm_infrastructure_error"); - expected.put("VALIDATION_INVALID_INPUT", "validation_invalid_input"); - expected.put("VALIDATION_RULE_VIOLATION", "validation_rule_violation"); - expected.put("VALIDATION_SEMANTIC_REJECTED", "validation_semantic_rejected"); - expected.put("VALIDATION_LLM_INFRASTRUCTURE_ERROR", "validation_llm_infrastructure_error"); - expected.put("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", "validation_prompt_resource_not_found"); - expected.put("PROMPT_RESOURCE_LOAD_ERROR", "prompt_resource_load_error"); - expected.put("SLOT_SCHEMA_NOT_FOUND", "slot_schema_not_found"); - expected.put("LLM_INVOCATION_FAILED", "llm_invocation_failed"); - expected.put("RENDER_FAILED", "render_failed"); - expected.put("SLOT_VALIDATION_ERROR", "slot_validation_error"); - expected.put("PROCESSED_PROMPT_PARSE_ERROR", "processed_prompt_parse_error"); + expected.put("SDK_INTERNAL_ERROR", "infra.internal_error"); + expected.put("INPUT_TEXT_TOO_LONG", "input.text_too_long"); + expected.put("PARAM_EXTRACTION_FAILED", "slot.not_provided"); + expected.put("TEMPLATE_NOT_FOUND", "template.not_found"); + expected.put("NEGOTIATION_CONTENT_EXTRACT_FAILED", "negotiation.content_extract_failed"); + expected.put("NEGOTIATION_SEMANTIC_REJECTED", "negotiation.semantic_rejected"); + expected.put("NEGOTIATION_RULE_VIOLATION", "negotiation.rule_violation"); + expected.put("NEGOTIATION_SLOT_MISSING", "negotiation.field_missing"); + expected.put("NEGOTIATION_INVALID_INPUT", "negotiation.invalid_input"); + expected.put("NEGOTIATION_LLM_INFRASTRUCTURE_ERROR", "llm.invocation_failed"); + expected.put("VALIDATION_INVALID_INPUT", "negotiation.invalid_input"); + expected.put("VALIDATION_RULE_VIOLATION", "negotiation.rule_violation"); + expected.put("VALIDATION_SEMANTIC_REJECTED", "negotiation.semantic_rejected"); + expected.put("VALIDATION_LLM_INFRASTRUCTURE_ERROR", "llm.invocation_failed"); + expected.put("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", "template.not_found"); + expected.put("PROMPT_RESOURCE_LOAD_ERROR", "template.load_failed"); + expected.put("SLOT_SCHEMA_NOT_FOUND", "slot.schema_not_found"); + expected.put("LLM_INVOCATION_FAILED", "llm.invocation_failed"); + expected.put("RENDER_FAILED", "template.render_failed"); + expected.put("SLOT_VALIDATION_ERROR", "slot.rule_violation"); + expected.put("PROCESSED_PROMPT_PARSE_ERROR", "scenario.not_matched"); java.util.Map actual = new java.util.LinkedHashMap<>(); for (java.lang.reflect.Field field : A2ATErrorCodes.class.getDeclaredFields()) { @@ -104,7 +105,7 @@ void errorCodeConstantsStayExactlyThePinnedRegistry() { assertEquals( expected, actual, - "the error-code registry is locked; adding or changing codes must update this" + " test"); + "the deprecated forwarding registry is locked; adding or changing constants must update this" + " test"); assertTrue( !actual.containsValue("negotiation_type_unrecognized"), "the removed type-recognition code must not reappear"); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java index 39a698bf..642854a8 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java @@ -1,282 +1,294 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import org.junit.jupiter.api.Test; - -/** - * Verifies abort message generation from typed data through the full orchestrator pipeline. - * - *

        The abort message is type-independent: one common template, one content type carrying only the termination - * reason. The rendered message must keep the fixed Abort conclusion section, carry the negotiation context bullets and - * never leak an unreplaced placeholder. The from-data variant must stay deterministic — no LLM call. - */ -class AbortMessageGenerationTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri ABORT_URI = StandardTemplates.NEGOTIATION_ABORT; - - private final CountingClient llm = new CountingClient(); - - private NegotiationGenerationOrchestrator orchestrator(String language) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .llmClient(llm) - .build(); - } - - @Test - void generatesAChineseAbortMessageFromDataWithoutAnyLlmCall() { - MetadataContent result = orchestrator("zh-CN") - .generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - ABORT_URI); - - assertEquals(ABORT_URI.uri(), result.templateUri()); - String text = result.promptText(); - assertFalse(text.contains("## 协商上下文"), "the context section must not be rendered"); - assertTrue(text.contains("## 协商结果\nAbort"), "the fixed Abort conclusion must be kept"); - assertTrue(text.contains("## 协商终止原因"), "the termination reason section must be rendered"); - assertTrue(text.contains("达到协商轮次上限,本次协商确认结束。")); - assertEquals(new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), result.negotiationContext()); - assertFalse(text.contains("{{"), "no unreplaced placeholder may remain"); - assertEquals(0, llm.calls, "the from-data variant must never call the LLM"); - } - - @Test - void generatesAnEnglishAbortMessageFromData() { - MetadataContent result = orchestrator("en-US") - .generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 3, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent("Reached the negotiation round limit. This negotiation is confirmed and ended.")), - ABORT_URI); - - assertEquals(ABORT_URI.uri(), result.templateUri()); - String text = result.promptText(); - assertFalse(text.contains("## Negotiation Context"), "the context section must not be rendered"); - assertTrue(text.contains("## Negotiation Result\nAbort")); - assertTrue(text.contains("## Negotiation Termination Reason")); - assertTrue(text.contains("Reached the negotiation round limit.")); - assertEquals(new NegotiationContext(SESSION_ID, 3, 5, NegotiationPerformative.ABORT), result.negotiationContext()); - assertFalse(text.contains("{{")); - assertEquals(0, llm.calls); - } - - @Test - void blankTerminationReasonFailsAsAProgrammingErrorBeforeAnyLlmCall() { - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> orchestrator("zh-CN") - .generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.ABORT), new NegotiationAbortContent(" ")), - ABORT_URI)); - assertTrue(failure.getMessage().contains("Termination reason"), "message must point at the offending field"); - assertEquals(0, llm.calls); - } - - @Test - void abortGenerationRejectsTypedTemplateUris() { - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> orchestrator("zh-CN") - .generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent("达到协商轮次上限。")), - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT)); - assertTrue(failure.getMessage().contains("abort")); - } - - @Test - void typedEndingGenerationRejectsTheAbortTemplateUri() { - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> orchestrator("zh-CN") - .generateRejectFromData( - new net.openan.a2at.sdk.negotiation.content.NegotiationEndingData( - new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.REJECT), - new net.openan.a2at.sdk.negotiation.content.TargetEndingContent( - net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.REJECT, - null, - "预算耗尽")), - ABORT_URI)); - assertTrue(failure.getMessage().contains("reject")); - } - - @Test - void singleQueryResolvesTheCommonAbortTemplateUri() { - assertTrue(new TemplateQueryService("zh-CN", "classpath", null).getPrompt(ABORT_URI).isPresent()); - assertTrue(new TemplateQueryService("en-US", "classpath", null).getPrompt(ABORT_URI).isPresent()); - } - - @Test - void serviceMirrorsTheAbortGenerationEntryPoint() { - NegotiationContentService service = - new NegotiationContentService(NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build()); - - MetadataContent result = service.generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.ABORT), new NegotiationAbortContent("token 消耗超限,协商终止。")), - ABORT_URI); - - assertEquals(ABORT_URI.uri(), result.templateUri()); - assertTrue(result.promptText().contains("token 消耗超限")); - assertEquals(0, llm.calls); - } - - @Test - void generatesAnAbortMessageFromFreeTextThroughOneExtractionStep() { - ScriptedClient extractionLlm = new ScriptedClient("{\"termination_reason\":\"达到协商轮次上限,协商终止。\"}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(extractionLlm) - .build(); - - MetadataContent result = orchestrator.generateAbortFromText( - "协商已达到轮次上限,无法继续推进,本次协商终止。", new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), ABORT_URI); - - assertEquals(ABORT_URI.uri(), result.templateUri()); - assertTrue(result.promptText().contains("## 协商结果\nAbort")); - assertTrue(result.promptText().contains("达到协商轮次上限,协商终止。")); - assertFalse(result.promptText().contains("{{")); - assertEquals(1, extractionLlm.calls, "the from-text variant runs exactly one extraction step"); - } - - @Test - void peerValidatesAReceivedAbortMessageAndExtractsItsParameters() { - NegotiationGenerationOrchestrator agent = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - ScriptedClient peerLlm = - new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator peer = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(peerLlm) - .build(); - - MetadataContent abortMessage = agent.generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - ABORT_URI); - - FilledParamData parameters = - peer.validateAbortPromptAndDataFilling( - abortMessage.promptText(), new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), Map.of("type", "object"), ABORT_URI); - - assertEquals(SESSION_ID, parameters.data().get("id")); - assertEquals(5, parameters.data().get("round")); - assertEquals(5, parameters.data().get("maxRounds")); - assertEquals(1, peerLlm.calls, "the validation pipeline runs exactly one semantic step"); - } - - @Test - void abortValidationFailsOnBeyondBudgetRoundsBeforeAnyLlmCall() { - NegotiationGenerationOrchestrator agent = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - ScriptedClient peerLlm = - new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator peer = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(peerLlm) - .build(); - - MetadataContent beyondBudget = agent.generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.ABORT), new NegotiationAbortContent("轮次预算耗尽。")), ABORT_URI); - - NegotiationParamExtractionException failure = assertThrows( - NegotiationParamExtractionException.class, - () -> peer.validateAbortPromptAndDataFilling( - beyondBudget.promptText(), - new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.ABORT), - Map.of("type", "object"), - ABORT_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, failure.getCode()); - assertTrue( - failure.getErrors().stream().anyMatch(error -> "round".equals(error.slotName())), - "the beyond-budget round must be reported on the round slot"); - assertEquals(0, peerLlm.calls, "the rule gate must fail before any semantic LLM call"); - } - - @Test - void abortValidationRejectsTypedTemplateUris() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> orchestrator.validateAbortPromptAndDataFilling("任意文本", null, Map.of("type", "object"), - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE)); - assertTrue(failure.getMessage().contains("abort")); - assertEquals(0, llm.calls); - } - - private static final class CountingClient implements LLMClient { - - private int calls; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - throw new AssertionError("The from-data abort variant must never call the LLM"); - } - } - - private static final class ScriptedClient implements LLMClient { - - private final String payload; - - private final List>> recordedMessages = new ArrayList<>(); - - private int calls; - - private ScriptedClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - recordedMessages.add(List.copyOf(messages)); - return new LLMResponse(payload, "scripted-abort-model", Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import org.junit.jupiter.api.Test; + +/** + * Verifies abort message generation from typed data through the full orchestrator pipeline. + * + *

        The abort message is type-independent: one common template, one content type carrying only the termination reason. + * The rendered message must keep the fixed Abort conclusion section, carry the negotiation context bullets and never + * leak an unreplaced placeholder. The from-data variant must stay deterministic — no LLM call. + */ +class AbortMessageGenerationTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri ABORT_URI = StandardTemplates.NEGOTIATION_ABORT; + + private final CountingClient llm = new CountingClient(); + + private NegotiationGenerationOrchestrator orchestrator(String language) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .llmClient(llm) + .build(); + } + + @Test + void generatesAChineseAbortMessageFromDataWithoutAnyLlmCall() { + MetadataContent result = orchestrator("zh-CN") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + String text = result.promptText(); + assertFalse(text.contains("## 协商上下文"), "the context section must not be rendered"); + assertTrue(text.contains("## 协商结果\nAbort"), "the fixed Abort conclusion must be kept"); + assertTrue(text.contains("## 协商终止原因"), "the termination reason section must be rendered"); + assertTrue(text.contains("达到协商轮次上限,本次协商确认结束。")); + assertEquals( + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), result.negotiationContext()); + assertFalse(text.contains("{{"), "no unreplaced placeholder may remain"); + assertEquals(0, llm.calls, "the from-data variant must never call the LLM"); + } + + @Test + void generatesAnEnglishAbortMessageFromData() { + MetadataContent result = orchestrator("en-US") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 3, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent( + "Reached the negotiation round limit. This negotiation is confirmed and ended.")), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + String text = result.promptText(); + assertFalse(text.contains("## Negotiation Context"), "the context section must not be rendered"); + assertTrue(text.contains("## Negotiation Result\nAbort")); + assertTrue(text.contains("## Negotiation Termination Reason")); + assertTrue(text.contains("Reached the negotiation round limit.")); + assertEquals( + new NegotiationContext(SESSION_ID, 3, 5, NegotiationPerformative.ABORT), result.negotiationContext()); + assertFalse(text.contains("{{")); + assertEquals(0, llm.calls); + } + + @Test + void blankTerminationReasonFailsWithTheCodedContentInvalidFailureBeforeAnyLlmCall() { + NegotiationGenerationException failure = + assertThrows(NegotiationGenerationException.class, () -> orchestrator("zh-CN") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent(" ")), + ABORT_URI)); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), failure.getCode()); + assertEquals("content.terminationReason", failure.getFacts().get("field")); + assertTrue( + failure.getMessage().contains("content.terminationReason"), + "message must point at the offending field"); + assertEquals(0, llm.calls); + } + + @Test + void abortGenerationRejectsTypedTemplateUris() { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> orchestrator("zh-CN") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限。")), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT)); + assertTrue(failure.getMessage().contains("abort")); + } + + @Test + void typedEndingGenerationRejectsTheAbortTemplateUri() { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> orchestrator("zh-CN") + .generateRejectFromData( + new net.openan.a2at.sdk.negotiation.content.NegotiationEndingData( + new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.REJECT), + new net.openan.a2at.sdk.negotiation.content.TargetEndingContent( + net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.REJECT, + null, + "预算耗尽")), + ABORT_URI)); + assertTrue(failure.getMessage().contains("reject")); + } + + @Test + void singleQueryResolvesTheCommonAbortTemplateUri() { + assertTrue(new TemplateQueryService("zh-CN", "classpath", null).getPrompt(ABORT_URI).isPresent()); + assertTrue(new TemplateQueryService("en-US", "classpath", null).getPrompt(ABORT_URI).isPresent()); + } + + @Test + void serviceMirrorsTheAbortGenerationEntryPoint() { + NegotiationContentService service = + new NegotiationContentService(NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build()); + + MetadataContent result = service.generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("token 消耗超限,协商终止。")), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + assertTrue(result.promptText().contains("token 消耗超限")); + assertEquals(0, llm.calls); + } + + @Test + void generatesAnAbortMessageFromFreeTextThroughOneExtractionStep() { + ScriptedClient extractionLlm = new ScriptedClient("{\"termination_reason\":\"达到协商轮次上限,协商终止。\"}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(extractionLlm) + .build(); + + MetadataContent result = orchestrator.generateAbortFromText( + "协商已达到轮次上限,无法继续推进,本次协商终止。", + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("达到协商轮次上限,协商终止。")); + assertFalse(result.promptText().contains("{{")); + assertEquals(1, extractionLlm.calls, "the from-text variant runs exactly one extraction step"); + } + + @Test + void peerValidatesAReceivedAbortMessageAndExtractsItsParameters() { + NegotiationGenerationOrchestrator agent = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + ScriptedClient peerLlm = + new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator peer = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(peerLlm) + .build(); + + MetadataContent abortMessage = agent.generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + ABORT_URI); + + FilledParamData parameters = peer.validateAbortPromptAndDataFilling( + abortMessage.promptText(), + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), + Map.of("type", "object"), + ABORT_URI); + + assertEquals(SESSION_ID, parameters.data().get("id")); + assertEquals(5, parameters.data().get("round")); + assertEquals(5, parameters.data().get("maxRounds")); + assertEquals(1, peerLlm.calls, "the validation pipeline runs exactly one semantic step"); + } + + @Test + void abortValidationFailsOnBeyondBudgetRoundsBeforeAnyLlmCall() { + NegotiationGenerationOrchestrator agent = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + ScriptedClient peerLlm = + new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator peer = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(peerLlm) + .build(); + + MetadataContent beyondBudget = agent.generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("轮次预算耗尽。")), + ABORT_URI); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> peer.validateAbortPromptAndDataFilling( + beyondBudget.promptText(), + new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.ABORT), + Map.of("type", "object"), + ABORT_URI)); + assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), failure.getCode()); + assertTrue( + failure.getErrors().stream().anyMatch(error -> "round".equals(error.slotName())), + "the beyond-budget round must be reported on the round slot"); + assertEquals(0, peerLlm.calls, "the rule gate must fail before any semantic LLM call"); + } + + @Test + void abortValidationRejectsTypedTemplateUris() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.validateAbortPromptAndDataFilling( + "任意文本", null, Map.of("type", "object"), StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE)); + assertTrue(failure.getMessage().contains("abort")); + assertEquals(0, llm.calls); + } + + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new AssertionError("The from-data abort variant must never call the LLM"); + } + } + + private static final class ScriptedClient implements LLMClient { + + private final String payload; + + private final List>> recordedMessages = new ArrayList<>(); + + private int calls; + + private ScriptedClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + recordedMessages.add(List.copyOf(messages)); + return new LLMResponse(payload, "scripted-abort-model", Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java index 7dffd1ef..50ead996 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java @@ -1,193 +1,209 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import java.util.function.Supplier; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import org.junit.jupiter.api.Test; - -/** - * Verifies the corpus-inexpressible rows of the programming-error matrix of the from-data generation (design §6 Q8). - * - *

        The remaining rows of the matrix — null context, phase-conclusion mismatch, blank conditional fields of the - * ending family, empty evaluation/alternative lists and every template-URI phase violation — are absorbed by the - * {@code FD-PROG} batch of the test corpus ({@code negotiation-cases/from-data/programming-errors.json}), which - * asserts the same exception type, message fragment and zero-LLM guarantee per case. Only the rows the corpus cannot - * express stay here: null propose/ending data (a corpus record always carries {@code input.data}), a null conclusion - * and a missing feasibility action (the typed-input assembler rejects both as authoring defects), blank propose - * descriptions and ending summaries (the assembler demands non-blank text), and a template URI whose type contradicts - * the content type (the assembler derives the content type from the URI, so the contradiction cannot be authored). - * Every row fails with a standard {@link NullPointerException} (pure null arguments) or - * {@link IllegalArgumentException} (blank and semantic-contract violations) that is not part of the SDK - * processing-error hierarchy and carries an English message pointing at the offending input. No row ever reaches the - * LLM. Structural URI malformation is impossible by construction of {@link TemplateUri} and is therefore not a row. - */ -class FromDataProgrammingErrorMatrixTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - - private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; - - private static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; - - private static final TemplateUri FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; - - private static final TemplateUri TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; - - private final CountingClient llm = new CountingClient(); - - private final NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - - @Test - void everyMatrixRowFailsWithAStandardJavaException() { - List matrix = matrix(); - - // The propose-versus-ending family mismatch of UT-GEN-002 is deliberately absent here: the typed data records - // make that mismatch a compile-time error at the facade, so it cannot occur at runtime. - assertEquals(8, matrix.size(), "exactly the corpus-inexpressible rows must stay (FD-PROG absorbs the rest)"); - for (MatrixRow row : matrix) { - RuntimeException failure = assertThrows( - row.expectedException(), () -> row.call().get(), "row must fail: " + row.label()); - assertFalse( - A2ATError.class.isInstance(failure), - "a programming error must not be part of the processing-error hierarchy: " + row.label()); - assertTrue( - failure.getMessage() != null && !failure.getMessage().isBlank(), - "failure message must be present: " + row.label()); - assertTrue( - isAsciiText(failure.getMessage()), - "failure message must be English (ASCII): " + failure.getMessage()); - assertTrue( - failure.getMessage().contains(row.expectedMessageFragment()), - "failure message must point at the problem of row " + row.label() + " but was: " - + failure.getMessage()); - } - assertEquals(0, llm.calls, "no matrix row may call the LLM"); - } - - /** - * Returns the corpus-inexpressible rows of the programming-error matrix of the from-data variants (design §6 Q8: - * FD-PROG-01..14 absorb the other fourteen rows). See the class javadoc for why each row below cannot be expressed - * as a corpus record. - */ - private List matrix() { - NegotiationContext context = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE); - NegotiationContext acceptContext = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.ACCEPT); - return List.of( - new MatrixRow( - "null propose data", - () -> orchestrator.generateProposeFromData(null, INFORMATION_PROPOSE_URI), - NullPointerException.class, - "Negotiation propose data must not be null."), - new MatrixRow( - "null ending data", - () -> orchestrator.generateAcceptFromData(null, INFORMATION_ACCEPT_URI), - NullPointerException.class, - "Negotiation ending data must not be null."), - new MatrixRow( - "accept method with null conclusion", - () -> orchestrator.generateAcceptFromData( - new NegotiationEndingData(acceptContext, new TargetEndingContent(null, "intent", null)), - TARGET_ACCEPT_URI), - NullPointerException.class, - "conclusion must not be null"), - new MatrixRow( - "target propose with blank description", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, new TargetProposeContent(" ", null, null, null, null)), - TARGET_PROPOSE_URI), - IllegalArgumentException.class, - "Target negotiation description must not be blank."), - new MatrixRow( - "feasibility propose with blank description", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData( - context, - new FeasibilityProposeContent( - " ", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("目标", "2Mbps")), - null, - null)), - FEASIBILITY_PROPOSE_URI), - IllegalArgumentException.class, - "Feasibility negotiation description must not be blank."), - new MatrixRow( - "feasibility propose without action", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData( - context, - new FeasibilityProposeContent( - "请评估。", null, List.of(new NegotiationItem("目标", "2Mbps")), null, - null)), - FEASIBILITY_PROPOSE_URI), - NullPointerException.class, - "Feasibility negotiation action must not be null"), - new MatrixRow( - "feasibility ending with blank summary", - () -> orchestrator.generateAcceptFromData( - new NegotiationEndingData( - acceptContext, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " ")), - FEASIBILITY_ACCEPT_URI), - IllegalArgumentException.class, - "must not be blank"), - new MatrixRow( - "template URI type contradicts the content type", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData( - context, new TargetProposeContent("目标协商概述。", null, null, null, null)), - INFORMATION_PROPOSE_URI), - IllegalArgumentException.class, - "Negotiation type INFORMATION requires content of type InformationProposeContent")); - } - - private static boolean isAsciiText(String message) { - return message.chars().allMatch(codePoint -> codePoint < 128); - } - - /** One row of the programming-error matrix: a failing call, the expected exception type and message fragment. */ - private record MatrixRow( - String label, Supplier call, Class expectedException, String expectedMessageFragment) {} - - private static final class CountingClient implements LLMClient { - - private int calls; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - throw new AssertionError("A from-data programming error must fail before any LLM call"); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.junit.jupiter.api.Test; + +/** + * Verifies the corpus-inexpressible rows of the programming-error matrix of the from-data generation (design §6 Q8). + * + *

        The remaining rows of the matrix — null context, phase-conclusion mismatch, blank conditional fields of the ending + * family, empty evaluation/alternative lists and every template-URI phase violation — are absorbed by the + * {@code FD-PROG} batch of the test corpus ({@code negotiation-cases/from-data/programming-errors.json}), which asserts + * the same exception type, message fragment and zero-LLM guarantee per case. Only the rows the corpus cannot express + * stay here: null propose/ending data (a corpus record always carries {@code input.data}), a null conclusion and a + * missing feasibility action (the typed-input assembler rejects both as authoring defects), blank propose descriptions + * and ending summaries (the assembler demands non-blank text), and a template URI whose type contradicts the content + * type (the assembler derives the content type from the URI, so the contradiction cannot be authored). Every row fails + * with a standard {@link NullPointerException} (pure null arguments) or {@link IllegalArgumentException} (URI-shape and + * constructor-constraint violations) that is not part of the SDK business-failure hierarchy and carries an English + * message pointing at the offending input. Blank content fields are coded business failures carrying + * {@code negotiation.content_invalid} and are covered by a dedicated test below. No row ever reaches the LLM. + * Structural URI malformation is impossible by construction of {@link TemplateUri} and is therefore not a row. + */ +class FromDataProgrammingErrorMatrixTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + + private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; + + private static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; + + private static final TemplateUri FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; + + private static final TemplateUri TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; + + private final CountingClient llm = new CountingClient(); + + private final NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + @Test + void everyMatrixRowFailsWithAStandardJavaException() { + List matrix = matrix(); + + // The propose-versus-ending family mismatch of UT-GEN-002 is deliberately absent here: the typed data records + // make that mismatch a compile-time error at the facade, so it cannot occur at runtime. + assertEquals(5, matrix.size(), "exactly the corpus-inexpressible rows must stay (FD-PROG absorbs the rest)"); + for (MatrixRow row : matrix) { + RuntimeException failure = + assertThrows(row.expectedException(), () -> row.call().get(), "row must fail: " + row.label()); + assertFalse( + A2ATError.class.isInstance(failure), + "a programming error must not be part of the processing-error hierarchy: " + row.label()); + assertTrue( + failure.getMessage() != null && !failure.getMessage().isBlank(), + "failure message must be present: " + row.label()); + assertTrue( + isAsciiText(failure.getMessage()), + "failure message must be English (ASCII): " + failure.getMessage()); + assertTrue( + failure.getMessage().contains(row.expectedMessageFragment()), + "failure message must point at the problem of row " + row.label() + " but was: " + + failure.getMessage()); + } + assertEquals(0, llm.calls, "no matrix row may call the LLM"); + } + + /** + * Returns the corpus-inexpressible rows of the programming-error matrix of the from-data variants (design §6 Q8: + * FD-PROG-01..14 absorb the other fourteen rows). See the class javadoc for why each row below cannot be expressed + * as a corpus record. + */ + private List matrix() { + NegotiationContext context = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE); + NegotiationContext acceptContext = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.ACCEPT); + return List.of( + new MatrixRow( + "null propose data", + () -> orchestrator.generateProposeFromData(null, INFORMATION_PROPOSE_URI), + NullPointerException.class, + "Negotiation propose data must not be null."), + new MatrixRow( + "null ending data", + () -> orchestrator.generateAcceptFromData(null, INFORMATION_ACCEPT_URI), + NullPointerException.class, + "Negotiation ending data must not be null."), + new MatrixRow( + "accept method with null conclusion", + () -> orchestrator.generateAcceptFromData( + new NegotiationEndingData(acceptContext, new TargetEndingContent(null, "intent", null)), + TARGET_ACCEPT_URI), + NullPointerException.class, + "conclusion must not be null"), + new MatrixRow( + "feasibility propose without action", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, + new FeasibilityProposeContent( + "请评估。", null, List.of(new NegotiationItem("目标", "2Mbps")), null, null)), + FEASIBILITY_PROPOSE_URI), + NullPointerException.class, + "Feasibility negotiation action must not be null"), + new MatrixRow( + "template URI type contradicts the content type", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, new TargetProposeContent("目标协商概述。", null, null, null, null)), + INFORMATION_PROPOSE_URI), + IllegalArgumentException.class, + "Negotiation type INFORMATION requires content of type InformationProposeContent")); + } + + @Test + void blankRequiredContentFieldsFailWithTheCodedContentInvalidFailure() { + NegotiationContext context = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE); + NegotiationContext acceptContext = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.ACCEPT); + List blankFieldCalls = List.of( + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData(context, new TargetProposeContent(" ", null, null, null, null)), + TARGET_PROPOSE_URI), + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, + new FeasibilityProposeContent( + " ", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("目标", "2Mbps")), + null, + null)), + FEASIBILITY_PROPOSE_URI), + () -> orchestrator.generateAcceptFromData( + new NegotiationEndingData( + acceptContext, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " ")), + FEASIBILITY_ACCEPT_URI)); + List expectedFields = List.of( + "content.targetNegotiationDescription", + "content.feasibilityNegotiationDescription", + "content.feasibilitySummary"); + + for (int index = 0; index < blankFieldCalls.size(); index++) { + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + blankFieldCalls.get(index)::run, + "blank content field " + expectedFields.get(index) + " must fail"); + assertEquals( + ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), failure.getCode(), expectedFields.get(index)); + assertEquals(expectedFields.get(index), failure.getFacts().get("field")); + assertTrue(failure.getMessage().contains(expectedFields.get(index)), failure.getMessage()); + } + assertEquals(0, llm.calls, "no blank-content failure may call the LLM"); + } + + private static boolean isAsciiText(String message) { + return message.chars().allMatch(codePoint -> codePoint < 128); + } + + /** One row of the programming-error matrix: a failing call, the expected exception type and message fragment. */ + private record MatrixRow( + String label, + Supplier call, + Class expectedException, + String expectedMessageFragment) {} + + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new AssertionError("A from-data programming error must fail before any LLM call"); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index e869ac45..92044d5e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -1,759 +1,768 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.model.LlmConfig; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.slf4j.LoggerFactory; - -/** - * Integration tests of the from-text generation pipeline against the real built-in resources. - * - *

        The LLM is replaced by a scripted client that returns queued payloads or failures; every other collaborator is the - * production wiring. The suite covers the single-step content extraction (one LLM call, no type-recognition step), the - * byte-equality of the from-text output with the from-data output and the golden fixtures, the step-level retry policy - * with its warning logs, the infrastructure failure paths, the non-retryable failure codes that must stop after one LLM - * call, the max-attempts configuration semantics and the default en-US end-to-end chain. - */ -class FromTextLlmPipelineTest { - - private static final String ZH_CN = NegotiationGoldenCases.ZH_CN; - - private static final String EN_US = NegotiationGoldenCases.EN_US; - - private static final TemplateUri INFORMATION_PROPOSE_URI = GoldenCase.INFORMATION_PROPOSE.template(); - - private final ListAppender orchestratorAppender = new ListAppender<>(); - - private final ListAppender extractorAppender = new ListAppender<>(); - - private final ListAppender llmConfigAppender = new ListAppender<>(); - - private final Logger orchestratorLogger = (Logger) LoggerFactory.getLogger(NegotiationGenerationOrchestrator.class); - - private final Logger extractorLogger = (Logger) LoggerFactory.getLogger(DefaultNegotiationContentExtractor.class); - - private final Logger llmConfigLogger = (Logger) LoggerFactory.getLogger(LlmConfig.class); - - @BeforeEach - void attachLogAppenders() { - orchestratorAppender.start(); - orchestratorLogger.addAppender(orchestratorAppender); - extractorAppender.start(); - extractorLogger.addAppender(extractorAppender); - llmConfigAppender.start(); - llmConfigLogger.addAppender(llmConfigAppender); - } - - @AfterEach - void detachLogAppenders() { - orchestratorLogger.detachAppender(orchestratorAppender); - extractorLogger.detachAppender(extractorAppender); - llmConfigLogger.detachAppender(llmConfigAppender); - } - - /** - * IT-B-001: the from-text chain runs exactly one content-extraction LLM call and then renders deterministically: - * the output equals both the from-data generation of the same content and the committed golden fixture byte for - * byte, the metadata contract holds, and no type-recognition step exists anywhere in the pipeline. - */ - @Test - void fromTextMatchesFromDataAndTheGoldenFixtureAfterOneExtractionCall() { - GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; - ScriptedExtractionClient llm = - new ScriptedExtractionClient(extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - MetadataContent fromText = orchestrator.generateProposeFromText( - "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", - goldenCase.context(), - goldenCase.template()); - - assertEquals(1, llm.calls, "from-text generation must run exactly one content extraction call"); - assertEquals(goldenCase.templateUri(), fromText.templateUri()); - - MetadataContent fromData = goldenCase.generate(orchestrator, ZH_CN); - assertEquals(1, llm.calls, "the from-data variant must not add any LLM call"); - assertEquals(fromData.promptText(), fromText.promptText()); - assertEquals(goldenCase.goldenText(ZH_CN), fromText.promptText()); - - Map metadata = fromText.buildMetadataContent(); - assertEquals(3, metadata.size()); - assertEquals(fromText.promptText(), metadata.get(fromText.extensionUri())); - assertEquals(fromText.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - assertEquals(goldenCase.context().withPerformative(goldenCase.performative()), fromText.negotiationContext()); - @SuppressWarnings("unchecked") - Map nestedContext = - (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); - assertEquals(goldenCase.context().id(), nestedContext.get("id")); - assertEquals(goldenCase.context().round(), nestedContext.get("round")); - assertEquals(goldenCase.context().maxRounds(), nestedContext.get("maxRounds")); - - assertEquals( - 2, llm.lastMessages.size(), "the extraction step must be a single system-plus-user message exchange"); - assertTrue( - logMessages(extractorAppender, Level.INFO).stream() - .anyMatch(message -> message.startsWith("negotiation_content_extraction_completed")), - "the completed extraction step must emit its info event"); - assertTrue( - logMessages(orchestratorAppender, Level.INFO).stream() - .noneMatch(message -> message.contains("negotiation_type_recognition")) - && logMessages(extractorAppender, Level.INFO).stream() - .noneMatch(message -> message.contains("negotiation_type_recognition")), - "the removed type-recognition step must never appear in the events"); - } - - /** - * IT-B-002: every from-text method (propose, accept, reject, abort) succeeds for every negotiation type with - * exactly one LLM call per generation, the caller-supplied template URI is echoed, the output matches the golden - * fixture, the phase token reaches the LLM so the accept and reject phases can be distinguished, and the abort - * method addresses the termination-specific extraction prompt. - */ - @ParameterizedTest(name = "from-text succeeds for {0}") - @EnumSource(GoldenCase.class) - void fromTextSucceedsForEveryMethodAndTypeCombination(GoldenCase goldenCase) { - ScriptedExtractionClient llm = - new ScriptedExtractionClient(extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - MetadataContent result = generateFromText(orchestrator, goldenCase, "请根据以下专线投诉诊断协商文本生成协商报文。"); - - assertEquals(1, llm.calls); - assertEquals(goldenCase.templateUri(), result.templateUri()); - assertEquals(goldenCase.goldenText(ZH_CN), result.promptText()); - String userPrompt = llm.lastMessages.get(1).get("content"); - if (goldenCase.performative() == NegotiationPerformative.ABORT) { - assertTrue( - userPrompt.contains("终止"), - "the abort user prompt must address the termination content but was: " + userPrompt); - } else { - assertTrue( - userPrompt.contains(phaseToken(goldenCase.performative())), - "the user prompt must carry the phase token of the addressed method"); - } - } - - /** - * IT-B-002: the accept and reject methods share the same accept-reject template URI; both load that one template - * and differ only in the rendered conclusion literal. - */ - @Test - void acceptAndRejectShareTheSameAcceptRejectTemplate() { - assertEquals( - GoldenCase.INFORMATION_ACCEPT.templateUri(), - GoldenCase.INFORMATION_REJECT.templateUri(), - "accept and reject must address the same accept-reject template"); - - ScriptedExtractionClient acceptLlm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_ACCEPT.content(ZH_CN), NegotiationPerformative.ACCEPT)); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, acceptLlm, 3); - MetadataContent acceptResult = - generateFromText(orchestrator, GoldenCase.INFORMATION_ACCEPT, "请确认补充接入端口名称与投诉分类。"); - assertEquals(1, acceptLlm.calls); - - ScriptedExtractionClient rejectLlm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_REJECT.content(ZH_CN), NegotiationPerformative.REJECT)); - MetadataContent rejectResult = - generateFromText(orchestrator(ZH_CN, rejectLlm, 3), GoldenCase.INFORMATION_REJECT, "请拒绝。"); - assertEquals(1, rejectLlm.calls); - - assertTrue( - acceptResult.promptText().contains("\nAccept\n"), "the accept output must render the Accept literal"); - assertTrue( - rejectResult.promptText().contains("\nReject\n"), "the reject output must render the Reject literal"); - assertEquals(acceptResult.templateUri(), rejectResult.templateUri()); - } - - /** - * IT-B-003: a retryable extraction failure is retried until it succeeds; every failed attempt logs a warning with - * the step, attempt, attempt limit and failure code, and the successful output still matches the golden fixture. - */ - @Test - void retriesContentExtractionUntilItSucceeds() { - GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; - ScriptedExtractionClient llm = - new ScriptedExtractionClient("", "", extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - MetadataContent result = - orchestrator.generateProposeFromText("请提供接入端口名称。", goldenCase.context(), goldenCase.template()); - - assertEquals(3, llm.calls, "two failures followed by one success must result in three calls"); - assertEquals(goldenCase.goldenText(ZH_CN), result.promptText()); - - List retries = warningMessages("negotiation_llm_retry "); - assertEquals(2, retries.size()); - for (int attempt = 1; attempt <= 2; attempt++) { - String retry = retries.get(attempt - 1); - assertTrue(retry.contains("step=negotiation_content_extract"), retry); - assertTrue(retry.contains("attempt=" + attempt), retry); - assertTrue(retry.contains("max_attempts=3"), retry); - assertTrue(retry.contains("code=" + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED), retry); - } - assertTrue( - warningMessages("negotiation_llm_retry_exhausted").isEmpty(), - "a successful retry sequence must not log the exhaustion event"); - } - - /** - * IT-B-003: when every attempt fails with the retryable extraction failure code, the original error code is - * rethrown after the exhaustion warning has been logged. - */ - @Test - void rethrowsTheOriginalCodeWhenRetriesAreExhausted() { - ScriptedExtractionClient llm = new ScriptedExtractionClient("", "", ""); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); - assertEquals(3, llm.calls); - - List exhausted = warningMessages("negotiation_llm_retry_exhausted"); - assertEquals(1, exhausted.size()); - assertTrue(exhausted.get(0).contains("step=negotiation_content_extract"), exhausted.get(0)); - assertTrue(exhausted.get(0).contains("max_attempts=3"), exhausted.get(0)); - assertTrue( - exhausted.get(0).contains("code=" + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED), - exhausted.get(0)); - assertEquals(2, warningMessages("negotiation_llm_retry ").size()); - assertTrue( - warningMessages("negotiation_generation_failed").stream() - .anyMatch(message -> - message.contains("code=" + A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED)), - "the surfaced generation failure must be logged with its code"); - } - - /** - * IT-B-004: persistent transport failures of the LLM client are retried and surface as the infrastructure error - * code on the generation side. - */ - @Test - void retriesInfrastructureFailuresUntilExhaustion() { - ScriptedExtractionClient llm = new ScriptedExtractionClient(new IllegalStateException("connection refused")); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); - assertEquals(2, llm.calls); - assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); - assertTrue(warningMessages("negotiation_llm_retry_exhausted") - .get(0) - .contains("code=" + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR)); - } - - /** - * IT-B-004: a persistently unparseable LLM response fails with the retryable extraction failure code after the - * retries are exhausted, rethrowing the original code. - */ - @Test - void retriesUnparseableResponsesUntilExhaustion() { - ScriptedExtractionClient llm = new ScriptedExtractionClient(""); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); - assertEquals(2, llm.calls); - assertEquals(1, warningMessages("negotiation_llm_retry ").size()); - assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); - } - - /** - * IT-B-005 (1): an extracted conclusion that contradicts the method phase fails with the non-retryable - * invalid-input code after exactly one LLM call and without any retry event. - */ - @Test - void phaseContentMismatchFailsFastWithInvalidInput() { - ScriptedExtractionClient llm = new ScriptedExtractionClient( - "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}]}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateAcceptFromText( - "请接受。", GoldenCase.INFORMATION_ACCEPT.context(), GoldenCase.INFORMATION_ACCEPT.template())); - - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, failure.getCode()); - assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); - assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); - assertTrue(warningMessages("negotiation_llm_retry_exhausted").isEmpty()); - } - - /** - * IT-B-005 (2): an extraction response missing a required field fails with the non-retryable slot-missing code - * after exactly one LLM call. - */ - @Test - void missingRequiredFieldFailsFastWithSlotMissing() { - ScriptedExtractionClient llm = new ScriptedExtractionClient("{\"relationship\":null}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, failure.getCode()); - assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); - assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); - assertTrue(warningMessages("negotiation_llm_retry_exhausted").isEmpty()); - } - - /** - * IT-B-005 (3): a template URI without any loadable template fails with template-not-found before the LLM is - * touched at all. - */ - @Test - void missingTemplateFailsBeforeAnyLlmCall() { - ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPerformative.PROPOSE)); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language(ZH_CN) - .llmClient(llm) - .templateLoader(missingTemplateLoader()) - .build(); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, failure.getCode()); - assertEquals(0, llm.calls, "the template must be loaded before the LLM step is started"); - } - - /** - * IT-B-005 (4): a template URI whose phase segment contradicts the method is a programming error with an - * {@link IllegalArgumentException} pointing at the template URI and never reaches the LLM. - */ - @Test - void phaseMismatchedTemplateUriFailsBeforeAnyLlmCall() { - ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPerformative.PROPOSE)); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", - GoldenCase.INFORMATION_PROPOSE.context(), - GoldenCase.INFORMATION_ACCEPT.template())); - - assertTrue( - failure.getMessage().contains( - "Template URI does not address a negotiation template of the expected performative PROPOSE"), - "the failure must point at the template URI but was: " + failure.getMessage()); - assertEquals(0, llm.calls); - } - - /** - * IT-B-006 (1): a single allowed attempt makes the retryable extraction failure surface after exactly one call, - * with the exhaustion warning but without any per-attempt retry warning. - */ - @Test - void singleAttemptFailsFast() { - ScriptedExtractionClient llm = new ScriptedExtractionClient(""); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 1); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); - assertEquals(1, llm.calls); - assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); - assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); - } - - /** IT-B-006 (2): an attempt limit of two allows exactly one retry of a retryable failure. */ - @Test - void twoAttemptsRetryExactlyOnce() { - ScriptedExtractionClient llm = new ScriptedExtractionClient("", ""); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); - assertEquals(2, llm.calls); - assertEquals(1, warningMessages("negotiation_llm_retry ").size()); - assertTrue(warningMessages("negotiation_llm_retry ").get(0).contains("max_attempts=2")); - assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); - } - - /** - * IT-B-006 (3): the clamped attempt limits of the unified LLM config drive the retry loop: the value 99 clamps to - * 10 with a warning and allows nine failed attempts before a success, and the value 0 clamps to 1 with a warning - * and fails fast. - */ - @Test - void clampedConfigValuesDriveTheRetryLoop() { - LlmConfig large = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "99")); - assertEquals(10, large.maxAttempts()); - assertTrue( - warningMessagesOf(llmConfigAppender).stream() - .anyMatch(message -> message.contains("raw_value=99") && message.contains("clamped_value=10")), - "the clamp of the value 99 must be warned about"); - - GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; - ScriptedExtractionClient recovering = new ScriptedExtractionClient( - "", "", "", "", "", "", "", "", "", extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); - NegotiationGenerationOrchestrator recoveringOrchestrator = orchestrator(ZH_CN, recovering, large.maxAttempts()); - - MetadataContent result = recoveringOrchestrator.generateProposeFromText( - "请提供接入端口名称。", goldenCase.context(), goldenCase.template()); - - assertEquals(10, recovering.calls, "nine failed attempts plus one success must consume the clamped limit"); - assertEquals(goldenCase.goldenText(ZH_CN), result.promptText()); - assertEquals(9, warningMessages("negotiation_llm_retry ").size()); - - LlmConfig small = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "0")); - assertEquals(1, small.maxAttempts()); - assertTrue( - warningMessagesOf(llmConfigAppender).stream() - .anyMatch(message -> message.contains("raw_value=0") && message.contains("clamped_value=1")), - "the clamp of the value 0 must be warned about"); - - ScriptedExtractionClient failing = new ScriptedExtractionClient(""); - NegotiationGenerationOrchestrator failingOrchestrator = orchestrator(ZH_CN, failing, small.maxAttempts()); - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> failingOrchestrator.generateProposeFromText( - "请提供接入端口名称。", goldenCase.context(), goldenCase.template())); - - assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, failure.getCode()); - assertEquals(1, failing.calls); - } - - /** - * IT-B-007: with no language configured the unified config resolves the default en-US, and the whole chain works - * against the real en-US resources: one extraction call for the from-text generation, prompt resources that are - * really loaded from the classpath, and a second single LLM call for the validation pipeline. - */ - @Test - void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=local_rule - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """); - A2ATConfig config = A2ATConfig.load(envFile); - assertEquals(EN_US, config.prompt().language(), "the default language without A2AT_LANGUAGE is en-US"); - assertEquals(3, config.llm().maxAttempts(), "the default attempt limit is 3"); - - GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; - String inputText = "Please provide the access port name and the complaint category of the private line."; - Map bizSchema = Map.of( - "type", "object", - "properties", - Map.of( - "accessPort", Map.of("type", "string"), - "bizScenario", Map.of("type", "string")), - "required", - List.of("accessPort", "bizScenario")); - ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(goldenCase.content(EN_US), goldenCase.performative()), - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"accessPort\":\"P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\"," - + "\"bizScenario\":\"dedicated-line quality degradation\"}}"); - NegotiationGenerationOrchestrator orchestrator = - orchestrator(config.prompt().language(), llm, config.llm().maxAttempts()); - - MetadataContent message = - orchestrator.generateProposeFromText(inputText, goldenCase.context(), goldenCase.template()); - - assertEquals(1, llm.calls, "the en-US from-text chain needs exactly one extraction call"); - assertEquals(goldenCase.goldenText(EN_US), message.promptText()); - assertEquals(2, llm.lastMessages.size()); - assertFalse(llm.lastMessages.get(0).get("content").isBlank(), "the en-US system prompt must be loadable"); - assertTrue(llm.lastMessages.get(1).get("content").contains(inputText)); - assertTrue( - llm.lastMessages.get(1).get("content").contains("propose"), - "the user prompt must carry the phase token"); - - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message.promptText(), goldenCase.context(), bizSchema, goldenCase.template()); - - assertEquals(2, llm.calls, "the validation pipeline adds exactly one semantic validation call"); - assertEquals(goldenCase.context().id(), filled.data().get("id")); - assertEquals(goldenCase.context().round(), filled.data().get("round")); - assertEquals(goldenCase.context().maxRounds(), filled.data().get("maxRounds")); - assertEquals("P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); - assertEquals("dedicated-line quality degradation", filled.data().get("bizScenario")); - } - - /** - * IT-B-008: the negotiation context of the generated message comes from the caller-supplied context: the - * extraction schema sent to the LLM has no context fields and the metadata carries exactly the caller's context. - */ - @Test - void contextIsInjectedFromTheCallerWithoutAnyLlmInvolvement() { - NegotiationContext context = new NegotiationContext(NegotiationGoldenCases.SESSION_ID, 4, 7, NegotiationPerformative.PROPOSE); - ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPerformative.PROPOSE)); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - MetadataContent result = orchestrator.generateProposeFromText("请提供接入端口名称。", context, INFORMATION_PROPOSE_URI); - - assertEquals(1, llm.calls); - Map schemaProperties = schemaProperties(llm.lastSchema); - assertFalse(schemaProperties.containsKey("id"), "the extraction schema must not ask for the context id"); - assertFalse(schemaProperties.containsKey("round"), "the extraction schema must not ask for the round"); - assertFalse(schemaProperties.containsKey("maxRounds"), "the extraction schema must not ask for the limit"); - assertEquals( - context.withPerformative(NegotiationPerformative.PROPOSE), - result.negotiationContext(), - "the caller context travels in the metadata, stamped with the addressed performative"); - assertFalse( - result.promptText().contains("- id: " + NegotiationGoldenCases.SESSION_ID), - "the context lines must not be rendered into the message"); - } - - private static NegotiationGenerationOrchestrator orchestrator( - String language, LLMClient llmClient, int maxAttempts) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .llmClient(llmClient) - .maxAttempts(maxAttempts) - .build(); - } - - private static MetadataContent generateFromText( - NegotiationGenerationOrchestrator orchestrator, GoldenCase goldenCase, String text) { - return switch (goldenCase.performative()) { - case PROPOSE -> orchestrator.generateProposeFromText(text, goldenCase.context(), goldenCase.template()); - case ACCEPT -> orchestrator.generateAcceptFromText(text, goldenCase.context(), goldenCase.template()); - case REJECT -> orchestrator.generateRejectFromText(text, goldenCase.context(), goldenCase.template()); - case ABORT -> orchestrator.generateAbortFromText(text, goldenCase.context(), goldenCase.template()); - }; - } - - private static String phaseToken(NegotiationPerformative performative) { - return switch (performative) { - case PROPOSE -> "propose"; - case ACCEPT -> "accept"; - case REJECT -> "reject"; - case ABORT -> "abort"; - }; - } - - private static NegotiationTemplateLoader missingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - }; - } - - @SuppressWarnings("unchecked") - private static Map schemaProperties(Map jsonSchema) { - return (Map) jsonSchema.get("properties"); - } - - - private static List logMessages(ListAppender appender, Level level) { - List messages = new ArrayList<>(); - for (ILoggingEvent event : appender.list) { - if (event.getLevel() == level) { - messages.add(event.getFormattedMessage()); - } - } - return messages; - } - - private static List warningMessagesOf(ListAppender appender) { - return logMessages(appender, Level.WARN); - } - - private List warningMessages(String prefix) { - List matches = new ArrayList<>(); - for (String message : warningMessagesOf(orchestratorAppender)) { - if (message.contains(prefix)) { - matches.add(message); - } - } - return matches; - } - - /** - * Builds the snake_case extraction JSON of one typed content so the scripted LLM response maps back to exactly that - * content. - */ - private static String extractionJson(NegotiationContent content, NegotiationPerformative performative) { - if (performative == NegotiationPerformative.PROPOSE) { - if (content instanceof InformationProposeContent info) { - return "{\"items\":" + itemsJson(info.items()) + ",\"relationship\":" - + stringOrNull(info.relationship()) + "}"; - } - if (content instanceof TargetProposeContent target) { - return "{\"target_negotiation_description\":" - + quote(target.targetNegotiationDescription()) - + ",\"intent_understanding\":" - + itemsJson(target.intentUnderstanding()) - + ",\"alignment_and_clarification\":" - + itemsJson(target.alignmentAndClarification()) - + ",\"request_for_clarification\":" - + itemsJson(target.requestForClarification()) - + ",\"target_confirm_request\":" - + stringOrNull(target.targetConfirmRequest()) - + "}"; - } - FeasibilityProposeContent feasibility = (FeasibilityProposeContent) content; - return "{\"feasibility_negotiation_description\":" - + quote(feasibility.feasibilityNegotiationDescription()) - + ",\"action\":\"" - + feasibility.action().name() - + "\",\"contents_to_evaluate\":" - + itemsJson(feasibility.contentsToEvaluate()) - + ",\"infeasibility_details_and_proposal\":" - + itemsJson(feasibility.infeasibilityDetailsAndProposal()) - + ",\"feasibility_confirm_request\":" - + stringOrNull(feasibility.feasibilityConfirmRequest()) - + "}"; - } - if (content instanceof NegotiationAbortContent abort) { - return "{\"termination_reason\":" + quote(abort.terminationReason()) + "}"; - } - if (content instanceof InformationEndingContent info) { - return "{\"conclusion\":" + quote(info.conclusion().literal()) + ",\"items\":" + itemsJson(info.items()) - + "}"; - } - if (content instanceof TargetEndingContent target) { - return "{\"conclusion\":" - + quote(target.conclusion().literal()) - + ",\"confirmed_intent\":" - + stringOrNull(target.confirmedIntent()) - + ",\"failure_reason\":" - + stringOrNull(target.failureReason()) - + "}"; - } - FeasibilityEndingContent feasibility = (FeasibilityEndingContent) content; - return "{\"conclusion\":" - + quote(feasibility.conclusion().literal()) - + ",\"feasibility_summary\":" - + stringOrNull(feasibility.feasibilitySummary()) - + "}"; - } - - private static String itemsJson(List items) { - if (items == null) { - return "null"; - } - List entries = new ArrayList<>(); - for (NegotiationItem item : items) { - entries.add("{\"name\":" + quote(item.name()) + ",\"value\":" + stringOrNull(item.value()) + "}"); - } - return "[" + String.join(",", entries) + "]"; - } - - private static String stringOrNull(String value) { - return value == null ? "null" : quote(value); - } - - private static String quote(String value) { - return "\"" - + value.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - + "\""; - } - - /** - * Scripted LLM client: returns the queued payloads in order, throws the queued failure, and repeats the last entry - * once the queue is exhausted. Every call records the request for later assertions. - */ - private static final class ScriptedExtractionClient implements LLMClient { - - private final List script; - - private int calls; - - private List> lastMessages; - - private Map lastSchema; - - private ScriptedExtractionClient(Object... entries) { - this.script = List.of(entries); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - Object next = script.get(Math.min(calls, script.size() - 1)); - calls++; - lastMessages = messages; - lastSchema = jsonSchema; - if (next instanceof RuntimeException failure) { - throw failure; - } - return new LLMResponse( - (String) next, "scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.LlmConfig; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.slf4j.LoggerFactory; + +/** + * Integration tests of the from-text generation pipeline against the real built-in resources. + * + *

        The LLM is replaced by a scripted client that returns queued payloads or failures; every other collaborator is the + * production wiring. The suite covers the single-step content extraction (one LLM call, no type-recognition step), the + * byte-equality of the from-text output with the from-data output and the golden fixtures, the step-level retry policy + * with its warning logs, the infrastructure failure paths, the non-retryable failure codes that must stop after one LLM + * call, the max-attempts configuration semantics and the default en-US end-to-end chain. + */ +class FromTextLlmPipelineTest { + + private static final String ZH_CN = NegotiationGoldenCases.ZH_CN; + + private static final String EN_US = NegotiationGoldenCases.EN_US; + + private static final TemplateUri INFORMATION_PROPOSE_URI = GoldenCase.INFORMATION_PROPOSE.template(); + + private final ListAppender orchestratorAppender = new ListAppender<>(); + + private final ListAppender extractorAppender = new ListAppender<>(); + + private final ListAppender llmConfigAppender = new ListAppender<>(); + + private final Logger orchestratorLogger = (Logger) LoggerFactory.getLogger(NegotiationGenerationOrchestrator.class); + + private final Logger extractorLogger = (Logger) LoggerFactory.getLogger(DefaultNegotiationContentExtractor.class); + + private final Logger llmConfigLogger = (Logger) LoggerFactory.getLogger(LlmConfig.class); + + @BeforeEach + void attachLogAppenders() { + orchestratorAppender.start(); + orchestratorLogger.addAppender(orchestratorAppender); + extractorAppender.start(); + extractorLogger.addAppender(extractorAppender); + llmConfigAppender.start(); + llmConfigLogger.addAppender(llmConfigAppender); + } + + @AfterEach + void detachLogAppenders() { + orchestratorLogger.detachAppender(orchestratorAppender); + extractorLogger.detachAppender(extractorAppender); + llmConfigLogger.detachAppender(llmConfigAppender); + } + + /** + * IT-B-001: the from-text chain runs exactly one content-extraction LLM call and then renders deterministically: + * the output equals both the from-data generation of the same content and the committed golden fixture byte for + * byte, the metadata contract holds, and no type-recognition step exists anywhere in the pipeline. + */ + @Test + void fromTextMatchesFromDataAndTheGoldenFixtureAfterOneExtractionCall() { + GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; + ScriptedExtractionClient llm = + new ScriptedExtractionClient(extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + MetadataContent fromText = orchestrator.generateProposeFromText( + "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", + goldenCase.context(), + goldenCase.template()); + + assertEquals(1, llm.calls, "from-text generation must run exactly one content extraction call"); + assertEquals(goldenCase.templateUri(), fromText.templateUri()); + + MetadataContent fromData = goldenCase.generate(orchestrator, ZH_CN); + assertEquals(1, llm.calls, "the from-data variant must not add any LLM call"); + assertEquals(fromData.promptText(), fromText.promptText()); + assertEquals(goldenCase.goldenText(ZH_CN), fromText.promptText()); + + Map metadata = fromText.buildMetadataContent(); + assertEquals(3, metadata.size()); + assertEquals(fromText.promptText(), metadata.get(fromText.extensionUri())); + assertEquals(fromText.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(goldenCase.context().withPerformative(goldenCase.performative()), fromText.negotiationContext()); + @SuppressWarnings("unchecked") + Map nestedContext = + (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + assertEquals(goldenCase.context().id(), nestedContext.get("id")); + assertEquals(goldenCase.context().round(), nestedContext.get("round")); + assertEquals(goldenCase.context().maxRounds(), nestedContext.get("maxRounds")); + + assertEquals( + 2, llm.lastMessages.size(), "the extraction step must be a single system-plus-user message exchange"); + assertTrue( + logMessages(extractorAppender, Level.INFO).stream() + .anyMatch(message -> message.startsWith("negotiation_content_extraction_completed")), + "the completed extraction step must emit its info event"); + assertTrue( + logMessages(orchestratorAppender, Level.INFO).stream() + .noneMatch(message -> message.contains("negotiation_type_recognition")) + && logMessages(extractorAppender, Level.INFO).stream() + .noneMatch(message -> message.contains("negotiation_type_recognition")), + "the removed type-recognition step must never appear in the events"); + } + + /** + * IT-B-002: every from-text method (propose, accept, reject, abort) succeeds for every negotiation type with + * exactly one LLM call per generation, the caller-supplied template URI is echoed, the output matches the golden + * fixture, the phase token reaches the LLM so the accept and reject phases can be distinguished, and the abort + * method addresses the termination-specific extraction prompt. + */ + @ParameterizedTest(name = "from-text succeeds for {0}") + @EnumSource(GoldenCase.class) + void fromTextSucceedsForEveryMethodAndTypeCombination(GoldenCase goldenCase) { + ScriptedExtractionClient llm = + new ScriptedExtractionClient(extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + MetadataContent result = generateFromText(orchestrator, goldenCase, "请根据以下专线投诉诊断协商文本生成协商报文。"); + + assertEquals(1, llm.calls); + assertEquals(goldenCase.templateUri(), result.templateUri()); + assertEquals(goldenCase.goldenText(ZH_CN), result.promptText()); + String userPrompt = llm.lastMessages.get(1).get("content"); + if (goldenCase.performative() == NegotiationPerformative.ABORT) { + assertTrue( + userPrompt.contains("终止"), + "the abort user prompt must address the termination content but was: " + userPrompt); + } else { + assertTrue( + userPrompt.contains(phaseToken(goldenCase.performative())), + "the user prompt must carry the phase token of the addressed method"); + } + } + + /** + * IT-B-002: the accept and reject methods share the same accept-reject template URI; both load that one template + * and differ only in the rendered conclusion literal. + */ + @Test + void acceptAndRejectShareTheSameAcceptRejectTemplate() { + assertEquals( + GoldenCase.INFORMATION_ACCEPT.templateUri(), + GoldenCase.INFORMATION_REJECT.templateUri(), + "accept and reject must address the same accept-reject template"); + + ScriptedExtractionClient acceptLlm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_ACCEPT.content(ZH_CN), NegotiationPerformative.ACCEPT)); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, acceptLlm, 3); + MetadataContent acceptResult = + generateFromText(orchestrator, GoldenCase.INFORMATION_ACCEPT, "请确认补充接入端口名称与投诉分类。"); + assertEquals(1, acceptLlm.calls); + + ScriptedExtractionClient rejectLlm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_REJECT.content(ZH_CN), NegotiationPerformative.REJECT)); + MetadataContent rejectResult = + generateFromText(orchestrator(ZH_CN, rejectLlm, 3), GoldenCase.INFORMATION_REJECT, "请拒绝。"); + assertEquals(1, rejectLlm.calls); + + assertTrue( + acceptResult.promptText().contains("\nAccept\n"), "the accept output must render the Accept literal"); + assertTrue( + rejectResult.promptText().contains("\nReject\n"), "the reject output must render the Reject literal"); + assertEquals(acceptResult.templateUri(), rejectResult.templateUri()); + } + + /** + * IT-B-003: a retryable extraction failure is retried until it succeeds; every failed attempt logs a warning with + * the step, attempt, attempt limit and failure code, and the successful output still matches the golden fixture. + */ + @Test + void retriesContentExtractionUntilItSucceeds() { + GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; + ScriptedExtractionClient llm = new ScriptedExtractionClient( + "", "", extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + MetadataContent result = + orchestrator.generateProposeFromText("请提供接入端口名称。", goldenCase.context(), goldenCase.template()); + + assertEquals(3, llm.calls, "two failures followed by one success must result in three calls"); + assertEquals(goldenCase.goldenText(ZH_CN), result.promptText()); + + List retries = warningMessages("negotiation_llm_retry "); + assertEquals(2, retries.size()); + for (int attempt = 1; attempt <= 2; attempt++) { + String retry = retries.get(attempt - 1); + assertTrue(retry.contains("step=negotiation_content_extract"), retry); + assertTrue(retry.contains("attempt=" + attempt), retry); + assertTrue(retry.contains("max_attempts=3"), retry); + assertTrue(retry.contains("code=" + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()), retry); + } + assertTrue( + warningMessages("negotiation_llm_retry_exhausted").isEmpty(), + "a successful retry sequence must not log the exhaustion event"); + } + + /** + * IT-B-003: when every attempt fails with the retryable extraction failure code, the original error code is + * rethrown after the exhaustion warning has been logged. + */ + @Test + void rethrowsTheOriginalCodeWhenRetriesAreExhausted() { + ScriptedExtractionClient llm = new ScriptedExtractionClient("", "", ""); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); + assertEquals(3, llm.calls); + + List exhausted = warningMessages("negotiation_llm_retry_exhausted"); + assertEquals(1, exhausted.size()); + assertTrue(exhausted.get(0).contains("step=negotiation_content_extract"), exhausted.get(0)); + assertTrue(exhausted.get(0).contains("max_attempts=3"), exhausted.get(0)); + assertTrue(exhausted.get(0).contains("code=" + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()), exhausted.get(0)); + assertEquals(2, warningMessages("negotiation_llm_retry ").size()); + assertTrue( + warningMessages("negotiation_generation_failed").stream() + .anyMatch(message -> message.contains("code=" + ErrorCatalog.LLM_RESPONSE_INVALID.getCode())), + "the surfaced generation failure must be logged with its code"); + } + + /** + * IT-B-004: persistent transport failures of the LLM client are retried and surface as the infrastructure error + * code on the generation side. + */ + @Test + void retriesInfrastructureFailuresUntilExhaustion() { + ScriptedExtractionClient llm = new ScriptedExtractionClient(new IllegalStateException("connection refused")); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), failure.getCode()); + assertEquals(2, llm.calls); + assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); + assertTrue(warningMessages("negotiation_llm_retry_exhausted") + .get(0) + .contains("code=" + ErrorCatalog.LLM_INVOCATION_FAILED.getCode())); + } + + /** + * IT-B-004: a persistently unparseable LLM response fails with the retryable extraction failure code after the + * retries are exhausted, rethrowing the original code. + */ + @Test + void retriesUnparseableResponsesUntilExhaustion() { + ScriptedExtractionClient llm = new ScriptedExtractionClient(""); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); + assertEquals(2, llm.calls); + assertEquals(1, warningMessages("negotiation_llm_retry ").size()); + assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); + } + + /** + * IT-B-005 (1): an extracted conclusion that contradicts the method phase fails with the non-retryable + * invalid-input code after exactly one LLM call and without any retry event. + */ + @Test + void phaseConclusionMismatchFailsFastWithConclusionMismatch() { + ScriptedExtractionClient llm = new ScriptedExtractionClient( + "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}]}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateAcceptFromText( + "请接受。", GoldenCase.INFORMATION_ACCEPT.context(), GoldenCase.INFORMATION_ACCEPT.template())); + + assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), failure.getCode()); + assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); + assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); + assertTrue(warningMessages("negotiation_llm_retry_exhausted").isEmpty()); + } + + /** + * IT-B-005 (2): an extraction response missing a required field fails with the non-retryable slot-missing code + * after exactly one LLM call. + */ + @Test + void missingRequiredFieldFailsFastWithSlotMissing() { + ScriptedExtractionClient llm = new ScriptedExtractionClient("{\"relationship\":null}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), failure.getCode()); + assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); + assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); + assertTrue(warningMessages("negotiation_llm_retry_exhausted").isEmpty()); + } + + /** + * IT-B-005 (3): a template URI without any loadable template fails with template-not-found before the LLM is + * touched at all. + */ + @Test + void missingTemplateFailsBeforeAnyLlmCall() { + ScriptedExtractionClient llm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPerformative.PROPOSE)); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .llmClient(llm) + .templateLoader(missingTemplateLoader()) + .build(); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), failure.getCode()); + assertEquals(0, llm.calls, "the template must be loaded before the LLM step is started"); + } + + /** + * IT-B-005 (4): a template URI whose phase segment contradicts the method is a programming error with an + * {@link IllegalArgumentException} pointing at the template URI and never reaches the LLM. + */ + @Test + void phaseMismatchedTemplateUriFailsBeforeAnyLlmCall() { + ScriptedExtractionClient llm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPerformative.PROPOSE)); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", + GoldenCase.INFORMATION_PROPOSE.context(), + GoldenCase.INFORMATION_ACCEPT.template())); + + assertTrue( + failure.getMessage() + .contains( + "Template URI does not address a negotiation template of the expected performative PROPOSE"), + "the failure must point at the template URI but was: " + failure.getMessage()); + assertEquals(0, llm.calls); + } + + /** + * IT-B-006 (1): a single allowed attempt makes the retryable extraction failure surface after exactly one call, + * with the exhaustion warning but without any per-attempt retry warning. + */ + @Test + void singleAttemptFailsFast() { + ScriptedExtractionClient llm = new ScriptedExtractionClient(""); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 1); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); + assertEquals(1, llm.calls); + assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); + assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); + } + + /** IT-B-006 (2): an attempt limit of two allows exactly one retry of a retryable failure. */ + @Test + void twoAttemptsRetryExactlyOnce() { + ScriptedExtractionClient llm = new ScriptedExtractionClient("", ""); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); + assertEquals(2, llm.calls); + assertEquals(1, warningMessages("negotiation_llm_retry ").size()); + assertTrue(warningMessages("negotiation_llm_retry ").get(0).contains("max_attempts=2")); + assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); + } + + /** + * IT-B-006 (3): the clamped attempt limits of the unified LLM config drive the retry loop: the value 99 clamps to + * 10 with a warning and allows nine failed attempts before a success, and the value 0 clamps to 1 with a warning + * and fails fast. + */ + @Test + void clampedConfigValuesDriveTheRetryLoop() { + LlmConfig large = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "99")); + assertEquals(10, large.maxAttempts()); + assertTrue( + warningMessagesOf(llmConfigAppender).stream() + .anyMatch(message -> message.contains("raw_value=99") && message.contains("clamped_value=10")), + "the clamp of the value 99 must be warned about"); + + GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; + ScriptedExtractionClient recovering = new ScriptedExtractionClient( + "", + "", + "", + "", + "", + "", + "", + "", + "", + extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); + NegotiationGenerationOrchestrator recoveringOrchestrator = orchestrator(ZH_CN, recovering, large.maxAttempts()); + + MetadataContent result = recoveringOrchestrator.generateProposeFromText( + "请提供接入端口名称。", goldenCase.context(), goldenCase.template()); + + assertEquals(10, recovering.calls, "nine failed attempts plus one success must consume the clamped limit"); + assertEquals(goldenCase.goldenText(ZH_CN), result.promptText()); + assertEquals(9, warningMessages("negotiation_llm_retry ").size()); + + LlmConfig small = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "0")); + assertEquals(1, small.maxAttempts()); + assertTrue( + warningMessagesOf(llmConfigAppender).stream() + .anyMatch(message -> message.contains("raw_value=0") && message.contains("clamped_value=1")), + "the clamp of the value 0 must be warned about"); + + ScriptedExtractionClient failing = new ScriptedExtractionClient(""); + NegotiationGenerationOrchestrator failingOrchestrator = orchestrator(ZH_CN, failing, small.maxAttempts()); + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> failingOrchestrator.generateProposeFromText( + "请提供接入端口名称。", goldenCase.context(), goldenCase.template())); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); + assertEquals(1, failing.calls); + } + + /** + * IT-B-007: with no language configured the unified config resolves the default en-US, and the whole chain works + * against the real en-US resources: one extraction call for the from-text generation, prompt resources that are + * really loaded from the classpath, and a second single LLM call for the validation pipeline. + */ + @Test + void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=local_rule + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """); + A2ATConfig config = A2ATConfig.load(envFile); + assertEquals(EN_US, config.prompt().language(), "the default language without A2AT_LANGUAGE is en-US"); + assertEquals(3, config.llm().maxAttempts(), "the default attempt limit is 3"); + + GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; + String inputText = "Please provide the access port name and the complaint category of the private line."; + Map bizSchema = Map.of( + "type", + "object", + "properties", + Map.of( + "accessPort", Map.of("type", "string"), + "bizScenario", Map.of("type", "string")), + "required", + List.of("accessPort", "bizScenario")); + ScriptedExtractionClient llm = new ScriptedExtractionClient( + extractionJson(goldenCase.content(EN_US), goldenCase.performative()), + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"accessPort\":\"P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\"," + + "\"bizScenario\":\"dedicated-line quality degradation\"}}"); + NegotiationGenerationOrchestrator orchestrator = + orchestrator(config.prompt().language(), llm, config.llm().maxAttempts()); + + MetadataContent message = + orchestrator.generateProposeFromText(inputText, goldenCase.context(), goldenCase.template()); + + assertEquals(1, llm.calls, "the en-US from-text chain needs exactly one extraction call"); + assertEquals(goldenCase.goldenText(EN_US), message.promptText()); + assertEquals(2, llm.lastMessages.size()); + assertFalse(llm.lastMessages.get(0).get("content").isBlank(), "the en-US system prompt must be loadable"); + assertTrue(llm.lastMessages.get(1).get("content").contains(inputText)); + assertTrue( + llm.lastMessages.get(1).get("content").contains("propose"), + "the user prompt must carry the phase token"); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message.promptText(), goldenCase.context(), bizSchema, goldenCase.template()); + + assertEquals(2, llm.calls, "the validation pipeline adds exactly one semantic validation call"); + assertEquals(goldenCase.context().id(), filled.data().get("id")); + assertEquals(goldenCase.context().round(), filled.data().get("round")); + assertEquals(goldenCase.context().maxRounds(), filled.data().get("maxRounds")); + assertEquals( + "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); + assertEquals("dedicated-line quality degradation", filled.data().get("bizScenario")); + } + + /** + * IT-B-008: the negotiation context of the generated message comes from the caller-supplied context: the extraction + * schema sent to the LLM has no context fields and the metadata carries exactly the caller's context. + */ + @Test + void contextIsInjectedFromTheCallerWithoutAnyLlmInvolvement() { + NegotiationContext context = + new NegotiationContext(NegotiationGoldenCases.SESSION_ID, 4, 7, NegotiationPerformative.PROPOSE); + ScriptedExtractionClient llm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPerformative.PROPOSE)); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + MetadataContent result = orchestrator.generateProposeFromText("请提供接入端口名称。", context, INFORMATION_PROPOSE_URI); + + assertEquals(1, llm.calls); + Map schemaProperties = schemaProperties(llm.lastSchema); + assertFalse(schemaProperties.containsKey("id"), "the extraction schema must not ask for the context id"); + assertFalse(schemaProperties.containsKey("round"), "the extraction schema must not ask for the round"); + assertFalse(schemaProperties.containsKey("maxRounds"), "the extraction schema must not ask for the limit"); + assertEquals( + context.withPerformative(NegotiationPerformative.PROPOSE), + result.negotiationContext(), + "the caller context travels in the metadata, stamped with the addressed performative"); + assertFalse( + result.promptText().contains("- id: " + NegotiationGoldenCases.SESSION_ID), + "the context lines must not be rendered into the message"); + } + + private static NegotiationGenerationOrchestrator orchestrator( + String language, LLMClient llmClient, int maxAttempts) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .llmClient(llmClient) + .maxAttempts(maxAttempts) + .build(); + } + + private static MetadataContent generateFromText( + NegotiationGenerationOrchestrator orchestrator, GoldenCase goldenCase, String text) { + return switch (goldenCase.performative()) { + case PROPOSE -> orchestrator.generateProposeFromText(text, goldenCase.context(), goldenCase.template()); + case ACCEPT -> orchestrator.generateAcceptFromText(text, goldenCase.context(), goldenCase.template()); + case REJECT -> orchestrator.generateRejectFromText(text, goldenCase.context(), goldenCase.template()); + case ABORT -> orchestrator.generateAbortFromText(text, goldenCase.context(), goldenCase.template()); + }; + } + + private static String phaseToken(NegotiationPerformative performative) { + return switch (performative) { + case PROPOSE -> "propose"; + case ACCEPT -> "accept"; + case REJECT -> "reject"; + case ABORT -> "abort"; + }; + } + + private static NegotiationTemplateLoader missingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + }; + } + + @SuppressWarnings("unchecked") + private static Map schemaProperties(Map jsonSchema) { + return (Map) jsonSchema.get("properties"); + } + + private static List logMessages(ListAppender appender, Level level) { + List messages = new ArrayList<>(); + for (ILoggingEvent event : appender.list) { + if (event.getLevel() == level) { + messages.add(event.getFormattedMessage()); + } + } + return messages; + } + + private static List warningMessagesOf(ListAppender appender) { + return logMessages(appender, Level.WARN); + } + + private List warningMessages(String prefix) { + List matches = new ArrayList<>(); + for (String message : warningMessagesOf(orchestratorAppender)) { + if (message.contains(prefix)) { + matches.add(message); + } + } + return matches; + } + + /** + * Builds the snake_case extraction JSON of one typed content so the scripted LLM response maps back to exactly that + * content. + */ + private static String extractionJson(NegotiationContent content, NegotiationPerformative performative) { + if (performative == NegotiationPerformative.PROPOSE) { + if (content instanceof InformationProposeContent info) { + return "{\"items\":" + itemsJson(info.items()) + ",\"relationship\":" + + stringOrNull(info.relationship()) + "}"; + } + if (content instanceof TargetProposeContent target) { + return "{\"target_negotiation_description\":" + + quote(target.targetNegotiationDescription()) + + ",\"intent_understanding\":" + + itemsJson(target.intentUnderstanding()) + + ",\"alignment_and_clarification\":" + + itemsJson(target.alignmentAndClarification()) + + ",\"request_for_clarification\":" + + itemsJson(target.requestForClarification()) + + ",\"target_confirm_request\":" + + stringOrNull(target.targetConfirmRequest()) + + "}"; + } + FeasibilityProposeContent feasibility = (FeasibilityProposeContent) content; + return "{\"feasibility_negotiation_description\":" + + quote(feasibility.feasibilityNegotiationDescription()) + + ",\"action\":\"" + + feasibility.action().name() + + "\",\"contents_to_evaluate\":" + + itemsJson(feasibility.contentsToEvaluate()) + + ",\"infeasibility_details_and_proposal\":" + + itemsJson(feasibility.infeasibilityDetailsAndProposal()) + + ",\"feasibility_confirm_request\":" + + stringOrNull(feasibility.feasibilityConfirmRequest()) + + "}"; + } + if (content instanceof NegotiationAbortContent abort) { + return "{\"termination_reason\":" + quote(abort.terminationReason()) + "}"; + } + if (content instanceof InformationEndingContent info) { + return "{\"conclusion\":" + quote(info.conclusion().literal()) + ",\"items\":" + itemsJson(info.items()) + + "}"; + } + if (content instanceof TargetEndingContent target) { + return "{\"conclusion\":" + + quote(target.conclusion().literal()) + + ",\"confirmed_intent\":" + + stringOrNull(target.confirmedIntent()) + + ",\"failure_reason\":" + + stringOrNull(target.failureReason()) + + "}"; + } + FeasibilityEndingContent feasibility = (FeasibilityEndingContent) content; + return "{\"conclusion\":" + + quote(feasibility.conclusion().literal()) + + ",\"feasibility_summary\":" + + stringOrNull(feasibility.feasibilitySummary()) + + "}"; + } + + private static String itemsJson(List items) { + if (items == null) { + return "null"; + } + List entries = new ArrayList<>(); + for (NegotiationItem item : items) { + entries.add("{\"name\":" + quote(item.name()) + ",\"value\":" + stringOrNull(item.value()) + "}"); + } + return "[" + String.join(",", entries) + "]"; + } + + private static String stringOrNull(String value) { + return value == null ? "null" : quote(value); + } + + private static String quote(String value) { + return "\"" + + value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + + "\""; + } + + /** + * Scripted LLM client: returns the queued payloads in order, throws the queued failure, and repeats the last entry + * once the queue is exhausted. Every call records the request for later assertions. + */ + private static final class ScriptedExtractionClient implements LLMClient { + + private final List script; + + private int calls; + + private List> lastMessages; + + private Map lastSchema; + + private ScriptedExtractionClient(Object... entries) { + this.script = List.of(entries); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + Object next = script.get(Math.min(calls, script.size() - 1)); + calls++; + lastMessages = messages; + lastSchema = jsonSchema; + if (next instanceof RuntimeException failure) { + throw failure; + } + return new LLMResponse( + (String) next, "scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/InputTextLengthLimitTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/InputTextLengthLimitTest.java index 9365b685..8bbe6686 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/InputTextLengthLimitTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/InputTextLengthLimitTest.java @@ -1,169 +1,168 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.InputLimitConfig; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; -import org.junit.jupiter.api.Test; - -/** - * Tests of the free-text input length limit of the negotiation pipelines. - * - *

        Oversized inputs fail fast with the code {@code input_text_too_long} before any LLM call, inputs at exactly the - * limit pass the gate and reach the LLM, and the limit is configurable through the builder. - */ -class InputTextLengthLimitTest { - - private static final String ZH_CN = NegotiationGoldenCases.ZH_CN; - - private static final GoldenCase PROPOSE = GoldenCase.INFORMATION_PROPOSE; - - /** A text one character over the default limit must be rejected with the dedicated code before any LLM call. */ - @Test - void rejectsFromTextOverTheDefaultLimitBeforeAnyLlmCall() { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1), - PROPOSE.context(), - PROPOSE.template())); - - assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, failure.getCode()); - assertTrue( - failure.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1)), - "the violation message must state the actual length but was: " + failure.getMessage()); - assertTrue( - failure.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS)), - "the violation message must state the limit but was: " + failure.getMessage()); - assertEquals(0, llm.calls, "an oversized text must never reach the LLM"); - } - - /** A text at exactly the default limit passes the gate and reaches the extraction LLM call. */ - @Test - void acceptsFromTextAtExactlyTheDefaultLimit() { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), - PROPOSE.context(), - PROPOSE.template())); - - assertNotEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, failure.getCode()); - assertTrue(llm.calls >= 1, "a text at exactly the limit must reach the LLM"); - } - - /** An oversized prompt of the validation pipeline is rejected with the dedicated code before any LLM call. */ - @Test - void rejectsValidationPromptOverTheDefaultLimitBeforeAnyLlmCall() { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); - - NegotiationParamExtractionException failure = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1), - PROPOSE.context(), - Map.of("type", "object"), - PROPOSE.template())); - - assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, failure.getCode()); - assertEquals(0, llm.calls, "an oversized prompt must never reach the LLM"); - } - - /** A validation prompt at exactly the default limit passes the gate and reaches the semantic validation LLM call. */ - @Test - void acceptsValidationPromptAtExactlyTheDefaultLimit() { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); - - NegotiationParamExtractionException failure = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), - PROPOSE.context(), - Map.of("type", "object"), - PROPOSE.template())); - - assertNotEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, failure.getCode()); - assertTrue(llm.calls >= 1, "a prompt at exactly the limit must reach the LLM"); - } - - /** A configured limit below the default drives the rejection: a text one character over it fails fast. */ - @Test - void customLimitDrivesTheRejection() { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language(ZH_CN) - .llmClient(llm) - .maxTextChars(10) - .build(); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText("01234567890", PROPOSE.context(), PROPOSE.template())); - - assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, failure.getCode()); - assertEquals(0, llm.calls); - } - - /** The builder rejects a non-positive limit as a programming error. */ - @Test - void builderRejectsNonPositiveLimit() { - IllegalStateException failure = assertThrows( - IllegalStateException.class, - () -> NegotiationGenerationOrchestratorBuilder.builder() - .language(ZH_CN) - .maxTextChars(0) - .build()); - - assertTrue( - failure.getMessage().contains("max text chars"), - "the failure must point at the limit but was: " + failure.getMessage()); - } - - private static NegotiationGenerationOrchestrator defaultOrchestrator(LLMClient llmClient) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(ZH_CN) - .llmClient(llmClient) - .build(); - } - - private static String textOfLength(int length) { - return "x".repeat(length); - } - - /** - * LLM client stub recording every call and returning an empty payload, so the tests can assert both that a call - * happened and that the pipeline fails downstream of the length gate instead of inside it. - */ - private static final class CountingClient implements LLMClient { - - private int calls; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - return new LLMResponse("", "counting-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; +import org.junit.jupiter.api.Test; + +/** + * Tests of the free-text input length limit of the negotiation pipelines. + * + *

        Oversized inputs fail fast with the code {@code input.text_too_long} before any LLM call, inputs at exactly the + * limit pass the gate and reach the LLM, and the limit is configurable through the builder. + */ +class InputTextLengthLimitTest { + + private static final String ZH_CN = NegotiationGoldenCases.ZH_CN; + + private static final GoldenCase PROPOSE = GoldenCase.INFORMATION_PROPOSE; + + /** A text one character over the default limit must be rejected with the dedicated code before any LLM call. */ + @Test + void rejectsFromTextOverTheDefaultLimitBeforeAnyLlmCall() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1), + PROPOSE.context(), + PROPOSE.template())); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertTrue( + failure.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1)), + "the violation message must state the actual length but was: " + failure.getMessage()); + assertTrue( + failure.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS)), + "the violation message must state the limit but was: " + failure.getMessage()); + assertEquals(0, llm.calls, "an oversized text must never reach the LLM"); + } + + /** A text at exactly the default limit passes the gate and reaches the extraction LLM call. */ + @Test + void acceptsFromTextAtExactlyTheDefaultLimit() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), PROPOSE.context(), PROPOSE.template())); + + assertNotEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertTrue(llm.calls >= 1, "a text at exactly the limit must reach the LLM"); + } + + /** An oversized prompt of the validation pipeline is rejected with the dedicated code before any LLM call. */ + @Test + void rejectsValidationPromptOverTheDefaultLimitBeforeAnyLlmCall() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1), + PROPOSE.context(), + Map.of("type", "object"), + PROPOSE.template())); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertEquals(0, llm.calls, "an oversized prompt must never reach the LLM"); + } + + /** + * A validation prompt at exactly the default limit passes the gate and reaches the semantic validation LLM call. + */ + @Test + void acceptsValidationPromptAtExactlyTheDefaultLimit() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), + PROPOSE.context(), + Map.of("type", "object"), + PROPOSE.template())); + + assertNotEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertTrue(llm.calls >= 1, "a prompt at exactly the limit must reach the LLM"); + } + + /** A configured limit below the default drives the rejection: a text one character over it fails fast. */ + @Test + void customLimitDrivesTheRejection() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .llmClient(llm) + .maxTextChars(10) + .build(); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText("01234567890", PROPOSE.context(), PROPOSE.template())); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertEquals(0, llm.calls); + } + + /** The builder rejects a non-positive limit as a programming error. */ + @Test + void builderRejectsNonPositiveLimit() { + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .maxTextChars(0) + .build()); + + assertTrue( + failure.getMessage().contains("max text chars"), + "the failure must point at the limit but was: " + failure.getMessage()); + } + + private static NegotiationGenerationOrchestrator defaultOrchestrator(LLMClient llmClient) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .llmClient(llmClient) + .build(); + } + + private static String textOfLength(int length) { + return "x".repeat(length); + } + + /** + * LLM client stub recording every call and returning an empty payload, so the tests can assert both that a call + * happened and that the pipeline fails downstream of the length gate instead of inside it. + */ + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + return new LLMResponse("", "counting-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java index b36cf8f3..966702a2 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java @@ -8,7 +8,7 @@ import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; @@ -99,8 +99,8 @@ void extractsFeasibilityProposeContentForBothActions() { @Test void extractsTargetProposeConfirmRequestContent() { - NegotiationContent content = new DefaultNegotiationContentExtractor(new RecordingClient( - "{\"target_negotiation_description\":\"任务目标澄清完成,请答复<目标澄清后的确认请求>。\"," + NegotiationContent content = new DefaultNegotiationContentExtractor( + new RecordingClient("{\"target_negotiation_description\":\"任务目标澄清完成,请答复<目标澄清后的确认请求>。\"," + "\"intent_understanding\":null," + "\"alignment_and_clarification\":null," + "\"request_for_clarification\":null," @@ -117,14 +117,15 @@ void extractsTargetProposeConfirmRequestContent() { @Test void extractsFeasibilityProposeConfirmRequestContent() { - NegotiationContent content = new DefaultNegotiationContentExtractor( - new RecordingClient( - "{\"feasibility_negotiation_description\":\"针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。\"," - + "\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," - + "\"contents_to_evaluate\":null," - + "\"infeasibility_details_and_proposal\":null," - + "\"feasibility_confirm_request\":\"评估目标可行,是否同意按照此目标继续执行?\"}")) - .extract("评估目标可行,请确认。", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN")); + NegotiationContent content = new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。\"," + + "\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + + "\"contents_to_evaluate\":null," + + "\"infeasibility_details_and_proposal\":null," + + "\"feasibility_confirm_request\":\"评估目标可行,是否同意按照此目标继续执行?\"}")) + .extract( + "评估目标可行,请确认。", + reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN")); FeasibilityProposeContent proposeContent = assertInstanceOf(FeasibilityProposeContent.class, content); assertEquals(NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, proposeContent.action()); @@ -150,7 +151,9 @@ void passesConfirmRequestWordingThroughLeniently() { "{\"feasibility_negotiation_description\":\"描述\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + "\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null," + "\"feasibility_confirm_request\":\"方案可行,望确认\"}")) - .extract("文本", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); + .extract( + "文本", + reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); assertEquals("方案可行,望确认", feasibility.feasibilityConfirmRequest()); } @@ -189,49 +192,60 @@ void extractsEndingContentForEveryTypeAndBothPhases() { } @Test - void mapsTransportFailuresToTheInfrastructureCode() { + void mapsTransportFailuresToTheInvocationFailedCode() { LLMClient failing = (messages, jsonSchema, temperature, maxTokens) -> { throw new IllegalStateException("connection reset"); }; NegotiationGenerationException exception = assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(failing) - .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), exception.getCode()); } @Test - void mapsMissingLlmClientToTheInfrastructureCode() { + void mapsMissingLlmClientToTheNotConfiguredCode() { NegotiationGenerationException exception = assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(null) - .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + assertEquals(ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), exception.getCode()); } @Test - void mapsUnparseableResponsesToTheExtractFailedCode() { + void mapsResponseContractViolationsAndShapeFailures() { NegotiationGenerationException unparseable = assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( new RecordingClient("这不是 JSON")) - .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, unparseable.getCode()); + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), unparseable.getCode()); NegotiationGenerationException wrongShape = assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( new RecordingClient("{\"items\":\"不是数组\"}")) - .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, wrongShape.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), wrongShape.getCode()); + assertEquals("items", wrongShape.getFacts().get("field")); NegotiationGenerationException emptyResponse = assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( new RecordingClient(" ")) - .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, emptyResponse.getCode()); + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), emptyResponse.getCode()); } @Test @@ -239,9 +253,11 @@ void mapsMissingRequiredFieldsToTheSlotMissingCode() { NegotiationGenerationException missingItems = assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( new RecordingClient("{\"relationship\":null}")) - .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, missingItems.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingItems.getCode()); assertTrue(missingItems.getMessage().contains("items")); NegotiationGenerationException missingDescription = assertThrows( @@ -250,15 +266,16 @@ void mapsMissingRequiredFieldsToTheSlotMissingCode() { + "\"request_for_clarification\":null}")) .extract("文本", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, missingDescription.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingDescription.getCode()); assertTrue(missingDescription.getMessage().contains("target_negotiation_description")); NegotiationGenerationException missingConclusion = assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( new RecordingClient("{\"items\":[]}")) - .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, "zh-CN"))); + .extract( + "文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, missingConclusion.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingConclusion.getCode()); assertTrue(missingConclusion.getMessage().contains("conclusion")); NegotiationGenerationException missingConfirmedIntent = assertThrows( @@ -266,47 +283,54 @@ void mapsMissingRequiredFieldsToTheSlotMissingCode() { "{\"conclusion\":\"Accept\",\"confirmed_intent\":null,\"failure_reason\":null}")) .extract("文本", reference(NegotiationType.TARGET, NegotiationPerformative.ACCEPT, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, missingConfirmedIntent.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingConfirmedIntent.getCode()); assertTrue(missingConfirmedIntent.getMessage().contains("confirmed_intent")); } @Test - void mapsConclusionPhaseMismatchesToTheInvalidInputCode() { + void mapsConclusionPhaseMismatchesToTheConclusionMismatchCode() { NegotiationGenerationException rejectInAccept = assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( new RecordingClient("{\"conclusion\":\"Reject\",\"items\":[]}")) - .extract("文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, "zh-CN"))); + .extract( + "文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, rejectInAccept.getCode()); - assertTrue(rejectInAccept.getMessage().contains("Reject")); + assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), rejectInAccept.getCode()); + assertEquals("Accept", rejectInAccept.getFacts().get("expected")); + assertEquals("Reject", rejectInAccept.getFacts().get("actual")); NegotiationGenerationException acceptInReject = assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( new RecordingClient("{\"conclusion\":\"Accept\",\"feasibility_summary\":\"同意。\"}")) - .extract("文本", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.REJECT, "zh-CN"))); + .extract( + "文本", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.REJECT, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, acceptInReject.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), acceptInReject.getCode()); } @Test void rejectsEmptyInformationEndingItemsBeforeTemplateRendering() { - NegotiationGenerationException exception = assertThrows( - NegotiationGenerationException.class, - () -> new DefaultNegotiationContentExtractor(new RecordingClient("{\"conclusion\":\"Reject\",\"items\":[]}")) - .extract("拒绝,资源查询服务正在检修。", reference(NegotiationType.INFORMATION, NegotiationPerformative.REJECT, "zh-CN"))); + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"conclusion\":\"Reject\",\"items\":[]}")) + .extract( + "拒绝,资源查询服务正在检修。", + reference(NegotiationType.INFORMATION, NegotiationPerformative.REJECT, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, exception.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); assertTrue(exception.getMessage().contains("result content")); } @Test void rejectsEmptyInformationProposeItemsBeforeTemplateRendering() { - NegotiationGenerationException exception = assertThrows( - NegotiationGenerationException.class, - () -> new DefaultNegotiationContentExtractor(new RecordingClient("{\"items\":[]}")) - .extract("请补充缺失信息。", reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"items\":[]}")) + .extract( + "请补充缺失信息。", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, exception.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); assertTrue(exception.getMessage().contains("requested items")); } @@ -315,17 +339,21 @@ void mapsFeasibilityActionProblemsToTheInvalidInputCode() { NegotiationGenerationException missingAction = assertThrows( NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( "{\"feasibility_negotiation_description\":\"描述\",\"contents_to_evaluate\":[]}")) - .extract("文本", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); + .extract( + "文本", + reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, missingAction.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), missingAction.getCode()); NegotiationGenerationException emptyDrivenContent = assertThrows( NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( "{\"feasibility_negotiation_description\":\"描述\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + "\"contents_to_evaluate\":[],\"infeasibility_details_and_proposal\":null}")) - .extract("文本", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); + .extract( + "文本", + reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, emptyDrivenContent.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), emptyDrivenContent.getCode()); } @Test @@ -338,7 +366,7 @@ void rejectsBlankInputTextAndNullReference() { () -> extractor.extract( " ", reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, blank.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), blank.getCode()); assertEquals( "Negotiation reference must not be null.", diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java index 0fa109c5..32f46482 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java @@ -16,7 +16,7 @@ import java.util.concurrent.atomic.AtomicInteger; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMError; import net.openan.a2at.sdk.llm.LLMResponse; @@ -157,7 +157,7 @@ void nullTemplateContentFromCustomLoaderIsReportedAsTemplateNotFoundNotNpe() { Map.of("type", "object"), INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, exception.getCode()); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), exception.getCode()); } @Test @@ -172,7 +172,7 @@ void maxAttemptsReachesBothRetryChains() { NegotiationGenerationException.class, () -> generationOrchestrator.generateProposeFromText( "请提供节能区域。", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, generationFailure.getCode()); + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), generationFailure.getCode()); assertEquals(2, generationClient.calls.get(), "generation chain must retry up to the limit"); CountingFailingClient semanticClient = new CountingFailingClient(); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 8638ad88..50db27b9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -9,7 +9,7 @@ import java.util.Map; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; @@ -178,7 +178,7 @@ void rethrowsOriginalCodeWhenRetriesAreExhausted() { "请提供区域。", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE)); assertEquals(2, llm.calls); - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), failure.getCode()); } @Test @@ -195,7 +195,7 @@ void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), INFORMATION_PROPOSE)); - assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, generationFailure.getCode()); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), generationFailure.getCode()); NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() .language("zh-CN") @@ -212,7 +212,7 @@ void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), Map.of("type", "object"), INFORMATION_PROPOSE)); - assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, extractionFailure.getCode()); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), extractionFailure.getCode()); } @Test @@ -230,7 +230,7 @@ void validationTemplateLoadFailurePreservesTheOriginalCause() { Map.of("type", "object"), INFORMATION_PROPOSE)); - assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, failure.getCode()); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), failure.getCode()); assertTrue( failure.getCause() instanceof ContentValidationException, "the template-load failure must preserve the mapped pipeline failure as cause for debugging"); @@ -256,7 +256,7 @@ void rejectsNonNegotiationPromptInParameterExtraction() { Map.of("type", "object"), INFORMATION_PROPOSE)); - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, failure.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), failure.getCode()); assertEquals(0, llm.calls); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java index 1b0feabe..8994b076 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java @@ -1,189 +1,196 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import org.junit.jupiter.api.Test; - -class NegotiationGeneratorRegistryTest { - - private final NegotiationGeneratorRegistry registry = new NegotiationGeneratorRegistry(); - - @Test - void dispatchesEveryTypeAndPhaseCombination() { - assertInstanceOf( - InformationProposeGenerator.class, - registry.resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.PROPOSE, - new InformationProposeContent(List.of(), null))); - assertInstanceOf( - TargetProposeGenerator.class, - registry.resolve( - NegotiationType.TARGET, - NegotiationPerformative.PROPOSE, - new TargetProposeContent("描述", null, null, null, null))); - assertInstanceOf( - FeasibilityProposeGenerator.class, - registry.resolve( - NegotiationType.FEASIBILITY, - NegotiationPerformative.PROPOSE, - new FeasibilityProposeContent( - "描述", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("名称", "值")), - null, - null))); - assertInstanceOf( - InformationEndingGenerator.class, - registry.resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.ACCEPT, - new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); - assertInstanceOf( - InformationEndingGenerator.class, - registry.resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.REJECT, - new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); - assertInstanceOf( - TargetEndingGenerator.class, - registry.resolve( - NegotiationType.TARGET, - NegotiationPerformative.ACCEPT, - new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); - assertInstanceOf( - TargetEndingGenerator.class, - registry.resolve( - NegotiationType.TARGET, - NegotiationPerformative.REJECT, - new TargetEndingContent(NegotiationConclusion.REJECT, null, "失败原因"))); - assertInstanceOf( - FeasibilityEndingGenerator.class, - registry.resolve( - NegotiationType.FEASIBILITY, - NegotiationPerformative.ACCEPT, - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "评估结论"))); - assertInstanceOf( - FeasibilityEndingGenerator.class, - registry.resolve( - NegotiationType.FEASIBILITY, - NegotiationPerformative.REJECT, - new FeasibilityEndingContent(NegotiationConclusion.REJECT, "评估结论"))); - } - - @Test - void rejectsProposeContentInTerminalPhaseAndEndingContentInProposePhase() { - IllegalArgumentException proposeInEnding = assertThrows( - IllegalArgumentException.class, - () -> registry.resolve( - NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, new InformationProposeContent(List.of(), null))); - assertTrue(proposeInEnding - .getMessage() - .contains("ACCEPT phase requires ending content but received propose content of type" - + " InformationProposeContent")); - - IllegalArgumentException endingInPropose = assertThrows( - IllegalArgumentException.class, - () -> registry.resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.PROPOSE, - new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); - assertTrue(endingInPropose - .getMessage() - .contains("PROPOSE phase requires propose content but received ending content of type" - + " InformationEndingContent")); - } - - @Test - void rejectsContentRuntimeTypeNotMatchingTheNegotiationType() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> registry.resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.PROPOSE, - new TargetProposeContent("描述", null, null, null, null))); - - assertTrue(exception - .getMessage() - .contains("Negotiation type INFORMATION requires content of type InformationProposeContent but received" - + " TargetProposeContent")); - - assertThrows( - IllegalArgumentException.class, - () -> registry.resolve( - NegotiationType.TARGET, - NegotiationPerformative.ACCEPT, - new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); - } - - @Test - void rejectsEndingConclusionNotMatchingThePhase() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> registry.resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.ACCEPT, - new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); - - assertTrue(exception - .getMessage() - .contains("ACCEPT phase requires conclusion Accept but the content carries Reject")); - - assertThrows( - IllegalArgumentException.class, - () -> registry.resolve( - NegotiationType.TARGET, - NegotiationPerformative.REJECT, - new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); - } - - @Test - void rejectsEndingContentWithoutConclusion() { - NullPointerException exception = assertThrows( - NullPointerException.class, - () -> registry.resolve( - NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, new InformationEndingContent(null, List.of()))); - - assertTrue(exception.getMessage().contains("conclusion must not be null")); - } - - @Test - void rejectsNullArguments() { - NegotiationContent content = new InformationProposeContent(List.of(), null); - - assertEquals( - "Negotiation type must not be null for the PROPOSE phase.", - assertThrows( - NullPointerException.class, - () -> registry.resolve(null, NegotiationPerformative.PROPOSE, content)) - .getMessage()); - assertEquals( - "Negotiation phase must not be null.", - assertThrows( - NullPointerException.class, - () -> registry.resolve(NegotiationType.INFORMATION, null, content)) - .getMessage()); - assertEquals( - "Negotiation content must not be null.", - assertThrows( - NullPointerException.class, - () -> registry.resolve(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, null)) - .getMessage()); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.junit.jupiter.api.Test; + +class NegotiationGeneratorRegistryTest { + + private final NegotiationGeneratorRegistry registry = new NegotiationGeneratorRegistry(); + + private NegotiationGenerator resolve( + NegotiationType type, NegotiationPerformative phase, NegotiationContent content) { + return registry.resolve(type, phase, content, "zh-CN"); + } + + @Test + void dispatchesEveryTypeAndPhaseCombination() { + assertInstanceOf( + InformationProposeGenerator.class, + resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.PROPOSE, + new InformationProposeContent(List.of(), null))); + assertInstanceOf( + TargetProposeGenerator.class, + resolve( + NegotiationType.TARGET, + NegotiationPerformative.PROPOSE, + new TargetProposeContent("描述", null, null, null, null))); + assertInstanceOf( + FeasibilityProposeGenerator.class, + resolve( + NegotiationType.FEASIBILITY, + NegotiationPerformative.PROPOSE, + new FeasibilityProposeContent( + "描述", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("名称", "值")), + null, + null))); + assertInstanceOf( + InformationEndingGenerator.class, + resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + assertInstanceOf( + InformationEndingGenerator.class, + resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.REJECT, + new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); + assertInstanceOf( + TargetEndingGenerator.class, + resolve( + NegotiationType.TARGET, + NegotiationPerformative.ACCEPT, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); + assertInstanceOf( + TargetEndingGenerator.class, + resolve( + NegotiationType.TARGET, + NegotiationPerformative.REJECT, + new TargetEndingContent(NegotiationConclusion.REJECT, null, "失败原因"))); + assertInstanceOf( + FeasibilityEndingGenerator.class, + resolve( + NegotiationType.FEASIBILITY, + NegotiationPerformative.ACCEPT, + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "评估结论"))); + assertInstanceOf( + FeasibilityEndingGenerator.class, + resolve( + NegotiationType.FEASIBILITY, + NegotiationPerformative.REJECT, + new FeasibilityEndingContent(NegotiationConclusion.REJECT, "评估结论"))); + } + + @Test + void rejectsProposeContentInTerminalPhaseAndEndingContentInProposePhase() { + IllegalArgumentException proposeInEnding = assertThrows( + IllegalArgumentException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationProposeContent(List.of(), null))); + assertTrue(proposeInEnding + .getMessage() + .contains("ACCEPT phase requires ending content but received propose content of type" + + " InformationProposeContent")); + + IllegalArgumentException endingInPropose = assertThrows( + IllegalArgumentException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.PROPOSE, + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + assertTrue(endingInPropose + .getMessage() + .contains("PROPOSE phase requires propose content but received ending content of type" + + " InformationEndingContent")); + } + + @Test + void rejectsContentRuntimeTypeNotMatchingTheNegotiationType() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.PROPOSE, + new TargetProposeContent("描述", null, null, null, null))); + + assertTrue(exception + .getMessage() + .contains("Negotiation type INFORMATION requires content of type InformationProposeContent but received" + + " TargetProposeContent")); + + assertThrows( + IllegalArgumentException.class, + () -> resolve( + NegotiationType.TARGET, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + } + + @Test + void rejectsEndingConclusionNotMatchingThePhase() { + NegotiationGenerationException exception = assertThrows( + NegotiationGenerationException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); + + assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), exception.getCode()); + assertEquals("Accept", exception.getFacts().get("expected")); + assertEquals("Reject", exception.getFacts().get("actual")); + + assertThrows( + NegotiationGenerationException.class, + () -> resolve( + NegotiationType.TARGET, + NegotiationPerformative.REJECT, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); + } + + @Test + void rejectsEndingContentWithoutConclusion() { + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(null, List.of()))); + + assertTrue(exception.getMessage().contains("conclusion must not be null")); + } + + @Test + void rejectsNullArguments() { + NegotiationContent content = new InformationProposeContent(List.of(), null); + + assertEquals( + "Negotiation type must not be null for the PROPOSE phase.", + assertThrows(NullPointerException.class, () -> resolve(null, NegotiationPerformative.PROPOSE, content)) + .getMessage()); + assertEquals( + "Negotiation phase must not be null.", + assertThrows(NullPointerException.class, () -> resolve(NegotiationType.INFORMATION, null, content)) + .getMessage()); + assertEquals( + "Negotiation content must not be null.", + assertThrows( + NullPointerException.class, + () -> resolve(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, null)) + .getMessage()); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java index af9e8e10..3ce57491 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -6,6 +6,10 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; @@ -14,15 +18,13 @@ import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationAction; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; import net.openan.a2at.sdk.negotiation.content.Vocabulary; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.Test; class NegotiationGeneratorsTest { @@ -278,11 +280,10 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { assertTrue(nullActionError.getMessage().contains("action must not be null")); FeasibilityProposeContent emptyEvaluation = - new FeasibilityProposeContent( - "描述", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null, null); + new FeasibilityProposeContent("描述", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null, null); - IllegalArgumentException emptyEvaluationError = - assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() + NegotiationGenerationException emptyEvaluationError = + assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() .generate( context(1), emptyEvaluation, @@ -290,16 +291,15 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), emptyEvaluationError.getCode()); assertEquals( - "Contents to evaluate of a feasibility evaluation request must contain at least one item.", - emptyEvaluationError.getMessage()); + "content.contentsToEvaluate", emptyEvaluationError.getFacts().get("field")); - FeasibilityProposeContent emptyAlternative = - new FeasibilityProposeContent( + FeasibilityProposeContent emptyAlternative = new FeasibilityProposeContent( "描述", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, List.of(), null); - IllegalArgumentException emptyAlternativeError = - assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() + NegotiationGenerationException emptyAlternativeError = + assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() .generate( context(1), emptyAlternative, @@ -307,34 +307,37 @@ void feasibilityNullActionOrEmptyDrivenContentIsRejected() { StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), zhVocabulary)); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), emptyAlternativeError.getCode()); assertEquals( - "Infeasibility details and proposal of an alternative proposal must contain at least one item.", - emptyAlternativeError.getMessage()); + "content.infeasibilityDetailsAndProposal", + emptyAlternativeError.getFacts().get("field")); FeasibilityProposeContent blankDescription = new FeasibilityProposeContent( - " ", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, List.of(new NegotiationItem("名称", "值")), null, null); + " ", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("名称", "值")), + null, + null); + NegotiationGenerationException blankDescriptionError = + assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + blankDescription, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), blankDescriptionError.getCode()); assertEquals( - "Feasibility negotiation description must not be blank.", - assertThrows(IllegalArgumentException.class, () -> new FeasibilityProposeGenerator() - .generate( - context(1), - blankDescription, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary)) - .getMessage()); + "content.feasibilityNegotiationDescription", + blankDescriptionError.getFacts().get("field")); } @Test void targetConfirmRequestRendersOnlyTheSummaryAndConfirmRequestSections() { - TargetProposeContent content = new TargetProposeContent( - "任务目标澄清完成,请答复<目标澄清后的确认请求>。", - null, - null, - null, - "目标已经澄清,是否同意按照此目标继续执行?"); + TargetProposeContent content = + new TargetProposeContent("任务目标澄清完成,请答复<目标澄清后的确认请求>。", null, null, null, "目标已经澄清,是否同意按照此目标继续执行?"); String rendered = new TargetProposeGenerator() .generate( @@ -395,11 +398,12 @@ void feasibilityConfirmRequestRendersOnlyTheSummaryAndConfirmRequestSections() { .generate( context(2), content, - template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_CONFIRM_TEMPLATE), + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_CONFIRM_TEMPLATE), zhVocabulary); - assertTrue(rendered.contains( - "## 可行性协商\n针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。")); + assertTrue(rendered.contains("## 可行性协商\n针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。")); assertTrue(rendered.contains("## 评估可行时的确认请求\n评估目标可行,是否同意按照此目标继续执行?")); assertFalse(rendered.contains("## 待评估内容说明")); assertFalse(rendered.contains("## 评估不可行时的详情和提案")); @@ -485,15 +489,17 @@ void targetLaterRoundRendersAlignmentSectionAndDropsIntentSection() { void targetBlankDescriptionIsRejected() { TargetProposeContent content = new TargetProposeContent(" ", null, null, null, null); - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> new TargetProposeGenerator() + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new TargetProposeGenerator() .generate( context(1), content, template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), zhVocabulary)); - assertEquals("Target negotiation description must not be blank.", exception.getMessage()); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals( + "content.targetNegotiationDescription", exception.getFacts().get("field")); } @Test @@ -584,34 +590,32 @@ void endingGeneratorsRenderConclusionLiterals() { @Test void informationEndingRejectsEmptyResultItems() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new InformationEndingGenerator() + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new InformationEndingGenerator() .generate( context(2), new InformationEndingContent(NegotiationConclusion.REJECT, List.of()), - template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), + template( + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, + ZH_INFO_ENDING_TEMPLATE), zhVocabulary)); - assertEquals( - "Information negotiation terminal message result content must contain at least one item.", - exception.getMessage()); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals("items", exception.getFacts().get("field")); } @Test void informationProposeRejectsEmptyRequestedItems() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new InformationProposeGenerator() + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new InformationProposeGenerator() .generate( context(1), new InformationProposeContent(List.of(), null), template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), zhVocabulary)); - assertEquals( - "Information negotiation propose message requested items must contain at least one item.", - exception.getMessage()); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals("items", exception.getFacts().get("field")); } @Test @@ -640,52 +644,45 @@ void abortConclusionIsRejectedByEveryEndingGenerator() { @Test void nullConclusionIsRejected() { - NullPointerException exception = - assertThrows(NullPointerException.class, () -> new InformationEndingGenerator() - .generate( - context(1), - new InformationEndingContent(null, List.of()), - template( - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, - ZH_INFO_ENDING_TEMPLATE), - zhVocabulary)); + NullPointerException exception = assertThrows(NullPointerException.class, () -> new InformationEndingGenerator() + .generate( + context(1), + new InformationEndingContent(null, List.of()), + template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), + zhVocabulary)); assertTrue(exception.getMessage().contains("conclusion must not be null")); } @Test void targetEndingRequiresTheFieldMatchingTheConclusion() { - IllegalArgumentException acceptWithoutIntent = - assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() + NegotiationGenerationException acceptWithoutIntent = + assertThrows(NegotiationGenerationException.class, () -> new TargetEndingGenerator() .generate( context(1), new TargetEndingContent(NegotiationConclusion.ACCEPT, null, "失败原因"), - template( - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), zhVocabulary)); - assertEquals( - "Confirmed intent of an accepting target negotiation message must not be blank.", - acceptWithoutIntent.getMessage()); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), acceptWithoutIntent.getCode()); + assertEquals("content.confirmedIntent", acceptWithoutIntent.getFacts().get("field")); - IllegalArgumentException rejectWithoutReason = - assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() + NegotiationGenerationException rejectWithoutReason = + assertThrows(NegotiationGenerationException.class, () -> new TargetEndingGenerator() .generate( context(1), new TargetEndingContent(NegotiationConclusion.REJECT, " ", null), - template( - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), zhVocabulary)); - assertEquals( - "Failure reason of a rejecting target negotiation message must not be blank.", - rejectWithoutReason.getMessage()); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), rejectWithoutReason.getCode()); + assertEquals("content.failureReason", rejectWithoutReason.getFacts().get("field")); } @Test void feasibilityEndingRequiresTheSummary() { - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> new FeasibilityEndingGenerator() + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new FeasibilityEndingGenerator() .generate( context(1), new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " "), @@ -694,9 +691,8 @@ void feasibilityEndingRequiresTheSummary() { ZH_FEASIBILITY_ENDING_TEMPLATE), zhVocabulary)); - assertEquals( - "Feasibility summary of a terminal feasibility negotiation message must not be blank.", - exception.getMessage()); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals("content.feasibilitySummary", exception.getFacts().get("field")); } @Test diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index 4b1099ed..60217255 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -10,7 +10,7 @@ import java.util.Map; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; @@ -34,7 +34,7 @@ * *

        The seven built-in URIs (six typed negotiation templates plus the common abort template) address their templates * and render outputs identical to the golden fixtures; a well-formed - * URI that resolves to no template fails with the code {@code template_not_found} before any LLM call; a typed URI + * URI that resolves to no template fails with the code {@code template.not_found} before any LLM call; a typed URI * that does not address a negotiation template of the expected performative (wrong extension name, version, type * segment, performative segment or separator, including the underscore misspelling of the type segment) fails as a * programming error pointing at {@code templateUri}, while structural malformation is impossible by construction of @@ -75,7 +75,7 @@ void everyBuiltInUriRendersItsGoldenFixture() { /** * Entry (b): a well-formed URI that resolves to no template in any resource root fails with the code - * {@code template_not_found} before any LLM call. With the bundled resources every well-formed v1 URI resolves in + * {@code template.not_found} before any LLM call. With the bundled resources every well-formed v1 URI resolves in * both bundled languages, so the always-miss condition is realized by a loader whose every load misses. */ @Test @@ -91,7 +91,7 @@ void wellFormedUriMissingInBothRootsFailsWithTemplateNotFound() { NegotiationGenerationException.class, () -> orchestrator.generateProposeFromData(informationProposeData(), INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, failure.getCode()); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), failure.getCode()); assertTrue( failure.getMessage() != null && !failure.getMessage().isBlank(), "the load failure message must be surfaced"); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java index 8fcd5b6a..f70a638b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java @@ -1,29 +1,29 @@ package net.openan.a2at.sdk.negotiation.generation; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; @@ -64,7 +64,8 @@ class ValidateAndFillingDataPipelineTest { private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; private static final Map ACCESS_PORT_SCHEMA = Map.of( - "type", "object", + "type", + "object", "properties", Map.of( "accessPort", Map.of("type", "string"), @@ -81,8 +82,8 @@ void legalProposeMessageRunsTheFullChainWithASingleLlmCall() { NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); String message = informationProposeMessage(orchestrator); - FilledParamData filled = - orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI); + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI); assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); @@ -134,51 +135,59 @@ void legalRejectMessageRunsTheFullChainWithASingleLlmCall() { // The rule-violation table of this suite (non-uuid id, round above maxRounds, 0 LLM calls) is absorbed by the // VAL-RULE batch of the test corpus (negotiation-cases/validate/rule-gate.json): every row there asserts the same - // negotiation_rule_violation code, the exact slot error pairs and the zero-LLM guarantee, plus the boundary and + // negotiation.rule_violation code, the exact slot error pairs and the zero-LLM guarantee, plus the boundary and // combined-error rows the hand-written table never carried (design §6 Q8). @Test void semanticRejectionIsNotRetriedAndPassesTheErrorsThrough() { + Map conclusionFacts = + Map.of("conclusion", "Accept", "section_label", "section.info_conclusion"); + Map resultFacts = Map.of("section_label", "section.info_items"); List semanticErrors = List.of( new SlotValidationError( "section.info_conclusion", - "invalid_conclusion", - "Conclusion Abort is not one of Accept or Reject."), + ErrorCatalog.NEGOTIATION_CONCLUSION_CONTENT_MISMATCH.getCode(), + ErrorMessages.render( + ErrorCatalog.NEGOTIATION_CONCLUSION_CONTENT_MISMATCH, "zh-CN", conclusionFacts), + conclusionFacts), new SlotValidationError( - "section.info_items", "missing_section", "Required information items are missing.")); + "section.info_items", + ErrorCatalog.NEGOTIATION_MISSING_RESULT_CONTENT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_MISSING_RESULT_CONTENT, "zh-CN", resultFacts), + resultFacts)); ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":null," - + "\"errors\":[{\"slot_name\":\"section.info_conclusion\",\"code\":\"invalid_conclusion\"," - + "\"message\":\"Conclusion Abort is not one of Accept or Reject.\"}," - + "{\"slot_name\":\"section.info_items\",\"code\":\"missing_section\"," - + "\"message\":\"Required information items are missing.\"}],\"params\":{}}"); + + "\"errors\":[{\"slot_name\":\"section.info_conclusion\"," + + "\"code\":\"negotiation.conclusion_content_mismatch\"," + + "\"facts\":{\"conclusion\":\"Accept\",\"section_label\":\"section.info_conclusion\"}}," + + "{\"slot_name\":\"section.info_items\",\"code\":\"negotiation.missing_result_content\"," + + "\"facts\":{\"section_label\":\"section.info_items\"}}],\"params\":{}}"); NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); String message = informationProposeMessage(orchestrator); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); assertEquals(semanticErrors, exception.getErrors()); assertEquals(1, llm.calls, "a negative verdict is a decision, not a failure, and must not be retried"); } @ParameterizedTest @MethodSource("taskTMessages") - void taskTMessagesAreRejectedAsNonNegotiationInputWithALanguageNeutralMessage( - String caseName, String taskTMessage) { + void taskTMessagesAreRejectedAsNonNegotiationInputWithARenderedMessage(String caseName, String taskTMessage) { ScriptedLlmClient llm = new ScriptedLlmClient( "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(taskTMessage, null, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling( + taskTMessage, null, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, exception.getCode(), caseName); - assertTrue(exception.getMessage().contains("checkTaskPrompt"), caseName); - assertFalse(exception.getMessage().contains("协商上下文"), caseName); - assertFalse(exception.getMessage().contains("Negotiation Context"), caseName); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode(), caseName); + assertEquals("输入的协商内容无效:缺少协商上下文(该报文不是协商报文)", exception.getMessage(), caseName); assertEquals(List.of(), exception.getErrors(), caseName); assertEquals(0, llm.calls, caseName); } @@ -224,7 +233,8 @@ void nestedArrayParamsAreExtractedThroughTheMergedSchema() { NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); String message = targetProposeMessage(orchestrator); - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling(message, TARGET_CONTEXT, callerSchema, TARGET_PROPOSE_URI); + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message, TARGET_CONTEXT, callerSchema, TARGET_PROPOSE_URI); assertEquals(1, llm.calls); assertEquals(SESSION_ID, filled.data().get("id")); @@ -244,19 +254,16 @@ void nestedArrayParamsAreExtractedThroughTheMergedSchema() { @Test void callerSchemaWithoutATypeKeywordIsWrappedAndTheChainSucceeds() { - Map schemaWithoutType = Map.of( - "properties", - Map.of("accessPort", Map.of("type", "string")), - "required", - List.of("accessPort")); + Map schemaWithoutType = + Map.of("properties", Map.of("accessPort", Map.of("type", "string")), "required", List.of("accessPort")); ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + "\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"); NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); String message = informationProposeMessage(orchestrator); - FilledParamData filled = - orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, schemaWithoutType, INFORMATION_PROPOSE_URI); + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, schemaWithoutType, INFORMATION_PROPOSE_URI); assertEquals(1, llm.calls); assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); @@ -278,13 +285,15 @@ void missingNegotiationTypeKeyIsRetriedThenFailsAsAnInfrastructureError() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), exception.getCode()); assertEquals(3, llm.calls, "a shape-invalid response is a retryable infrastructure failure"); assertEquals(1, exception.getErrors().size()); assertEquals("_llm", exception.getErrors().get(0).slotName()); - assertTrue(exception.getMessage().contains("negotiation_type")); + assertEquals("semantic_validation", exception.getFacts().get("step")); + assertTrue(exception.getCause().getCause().getMessage().contains("negotiation_type")); } @Test @@ -296,13 +305,18 @@ void declaredTypeMismatchIsASemanticRejection() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); assertEquals(1, llm.calls, "a type mismatch is a semantic decision and must not be retried"); assertEquals(1, exception.getErrors().size()); assertEquals("section.target", exception.getErrors().get(0).slotName()); - assertEquals("template_type_mismatch", exception.getErrors().get(0).code()); + assertEquals( + ErrorCatalog.NEGOTIATION_TYPE_MISMATCH.getCode(), + exception.getErrors().get(0).code()); + assertEquals("target", exception.getErrors().get(0).facts().get("implied")); + assertEquals("information", exception.getErrors().get(0).facts().get("declared")); } @ParameterizedTest @@ -315,45 +329,57 @@ void structuralSemanticChecksSurfaceThroughTheSemanticErrors( NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode(), caseName); + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode(), caseName); assertEquals(List.of(expectedError), exception.getErrors(), caseName); assertEquals(1, llm.calls, caseName); } static List structuralSemanticCases() { + Map conclusionFacts = Map.of("expected", "Accept", "actual", "Abort"); + Map resultFacts = Map.of("section_label", "section.target_result_content"); + Map conflictFacts = + Map.of("section_label", "section.info_static", "reason", "the static section coexists with the items"); return List.of( new Object[] { "conclusion outside accept and reject", "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" - + "{\"slot_name\":\"section.target_conclusion\",\"code\":\"invalid_conclusion\"," - + "\"message\":\"Conclusion Abort is not one of Accept or Reject.\"}],\"params\":{}}", + + "{\"slot_name\":\"section.target_conclusion\"," + + "\"code\":\"negotiation.conclusion_mismatch\"," + + "\"facts\":{\"expected\":\"Accept\",\"actual\":\"Abort\"}}],\"params\":{}}", new SlotValidationError( "section.target_conclusion", - "invalid_conclusion", - "Conclusion Abort is not one of Accept or Reject.") + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), + ErrorMessages.render( + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH, "zh-CN", conclusionFacts), + conclusionFacts) }, new Object[] { "ending result content section missing", "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" - + "{\"slot_name\":\"section.target_result_content\",\"code\":\"missing_section\"," - + "\"message\":\"The ending result content section is missing.\"}],\"params\":{}}", + + "{\"slot_name\":\"section.target_result_content\"," + + "\"code\":\"negotiation.missing_result_content\"," + + "\"facts\":{\"section_label\":\"section.target_result_content\"}}],\"params\":{}}", new SlotValidationError( "section.target_result_content", - "missing_section", - "The ending result content section is missing.") + ErrorCatalog.NEGOTIATION_MISSING_RESULT_CONTENT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_MISSING_RESULT_CONTENT, "zh-CN", resultFacts), + resultFacts) }, new Object[] { "information propose carries both conditional sections", "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" - + "{\"slot_name\":\"section.info_static\",\"code\":\"conflicting_sections\"," - + "\"message\":\"The static section and the required information items coexist.\"}]," - + "\"params\":{}}", + + "{\"slot_name\":\"section.info_static\"," + + "\"code\":\"negotiation.field_inconsistency\"," + + "\"facts\":{\"section_label\":\"section.info_static\",\"reason\":" + + "\"the static section coexists with the items\"}}],\"params\":{}}", new SlotValidationError( "section.info_static", - "conflicting_sections", - "The static section and the required information items coexist.") + ErrorCatalog.NEGOTIATION_FIELD_INCONSISTENCY.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_FIELD_INCONSISTENCY, "zh-CN", conflictFacts), + conflictFacts) }); } @@ -361,17 +387,21 @@ static List structuralSemanticCases() { void falseVerdictWithNullTypeIsAShapeLegalOutcome() { ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":null," + "\"errors\":[{\"slot_name\":\"section.context\",\"code\":\"inconsistent_context\"," - + "\"message\":\"Context contradicts the message body.\"}],\"params\":{}}"); + + "\"facts\":{\"reason\":\"Context contradicts the message body.\"}}],\"params\":{}}"); NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); String message = informationProposeMessage(orchestrator); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); assertEquals(1, exception.getErrors().size()); assertEquals("section.context", exception.getErrors().get(0).slotName()); + assertEquals( + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), + exception.getErrors().get(0).code()); assertEquals(1, llm.calls, "verdict false with a null type is shape-legal and must not be retried"); } @@ -384,13 +414,16 @@ void trueVerdictWithNullTypeIsASemanticRejection() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); assertEquals(1, llm.calls, "a null type with a true verdict is a semantic rejection, not a retryable failure"); assertEquals(1, exception.getErrors().size()); assertEquals("section.info_static", exception.getErrors().get(0).slotName()); - assertEquals("template_type_mismatch", exception.getErrors().get(0).code()); + assertEquals( + ErrorCatalog.NEGOTIATION_TYPE_MISMATCH.getCode(), + exception.getErrors().get(0).code()); } @Test @@ -406,9 +439,10 @@ void semanticLlmFailureIsRetriedAndExhaustsWithTheLlmPseudoSlot() { NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling(message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, exception.getCode()); + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), exception.getCode()); assertEquals(2, llm.calls); assertEquals(1, exception.getErrors().size()); assertEquals("_llm", exception.getErrors().get(0).slotName()); @@ -436,7 +470,7 @@ void missingPromptResourcesCloseAsTemplateNotFoundWithoutBubblingTheResourceExce ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.TEMPLATE_NOT_FOUND, exception.getCode()); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), exception.getCode()); assertEquals( NegotiationParamExtractionException.class, exception.getClass(), @@ -447,15 +481,18 @@ void missingPromptResourcesCloseAsTemplateNotFoundWithoutBubblingTheResourceExce @Test void proposeUriValidatingAResultMessageIsASemanticRejection() { + Map phaseFacts = Map.of("implied", "accept-reject", "declared", "propose"); SlotValidationError phaseError = new SlotValidationError( "section.info_result_content", - "phase_mismatch", - "The message is a terminal accept-reject message but the template URI declares the propose phase."); + ErrorCatalog.NEGOTIATION_PHASE_MISMATCH.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_PHASE_MISMATCH, "zh-CN", phaseFacts), + phaseFacts); ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" - + "{\"slot_name\":\"section.info_result_content\",\"code\":\"phase_mismatch\"," - + "\"message\":\"The message is a terminal accept-reject message but the template URI" - + " declares the propose phase.\"}],\"params\":{}}"); + + "{\"slot_name\":\"section.info_result_content\"," + + "\"code\":\"negotiation.phase_mismatch\"," + + "\"facts\":{\"implied\":\"accept-reject\",\"declared\":\"propose\"}}]," + + "\"params\":{}}"); NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); String rejectMessage = informationEndingMessage(orchestrator, NegotiationConclusion.REJECT); @@ -464,7 +501,7 @@ void proposeUriValidatingAResultMessageIsASemanticRejection() { () -> orchestrator.validateProposePromptAndDataFilling( rejectMessage, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, exception.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); assertEquals(List.of(phaseError), exception.getErrors()); assertEquals(1, llm.calls); } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java index c09b42d0..1aabf569 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationErrorCodeUsageMatrixTest.java @@ -1,433 +1,443 @@ -package net.openan.a2at.sdk.negotiation.observability; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMError; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; -import org.junit.jupiter.api.DynamicTest; -import org.junit.jupiter.api.TestFactory; - -/** - * Drives every row of the negotiation error-code usage matrix through the real pipelines. - * - *

        Each row triggers one failure condition of the documented matrix and asserts the exception type, the public error - * code, the number of LLM calls (encoding retryability: a retryable failure consumes all attempts, a non-retryable - * failure exactly one or zero), and that a structured detail identifying the problem field or slot is carried. - */ -class NegotiationErrorCodeUsageMatrixTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final TemplateUri INFORMATION_ENDING_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - - private static final String VALID_CONTEXT_PROMPT = "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5"; - - private static final Map SCHEMA = Map.of("type", "object"); - - private static final NegotiationContext CONTEXT = - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE); - - @TestFactory - Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { - return Stream.of( - row( - "generation_template_missing", - "not-a-json", - 0, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .templateLoader(missingTemplateLoader()) - .build() - .generateProposeFromData(proposeData(), INFORMATION_PROPOSE_URI), - NegotiationGenerationException.class, - A2ATErrorCodes.TEMPLATE_NOT_FOUND), - row( - "generation_content_extract_failed_is_retryable", - "not-a-json-object", - 2, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(2) - .build() - .generateProposeFromText( - "请提供节能区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI), - NegotiationGenerationException.class, - A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED), - row( - "generation_slot_missing_is_not_retryable", - "{}", - 1, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build() - .generateProposeFromText( - "请提供节能区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI), - NegotiationGenerationException.class, - A2ATErrorCodes.NEGOTIATION_SLOT_MISSING), - row( - "generation_phase_content_mismatch_is_invalid_input", - "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}", - 1, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build() - .generateAcceptFromText( - "确认提供区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.ACCEPT), - INFORMATION_ENDING_URI), - NegotiationGenerationException.class, - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), - row( - "generation_target_confirm_request_round_with_conditional_sections_is_invalid_input", - "{\"target_negotiation_description\":\"目标已经澄清,请确认。\",\"intent_understanding\":" - + "[{\"name\":\"发起方理解\",\"value\":\"对方希望降低节能力度\"}]," - + "\"target_confirm_request\":\"目标已经澄清,是否同意按照此目标继续执行?\"}", - 1, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build() - .generateProposeFromText( - "目标已经澄清,请确认。", - new NegotiationContext(UUID, 3, 5, NegotiationPerformative.PROPOSE), - StandardTemplates.TARGET_NEGOTIATION_PROPOSE), - NegotiationGenerationException.class, - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), - row( - "generation_feasibility_confirm_request_round_with_infeasibility_details_is_invalid_input", - "{\"feasibility_negotiation_description\":\"评估完成,结论可行,请确认。\",\"action\":" - + "\"REQUEST_FEASIBILITY_EVALUATION\",\"infeasibility_details_and_proposal\":" - + "[{\"name\":\"不应出现\",\"value\":\"值\"}],\"feasibility_confirm_request\":" - + "\"目标评估为可行,是否同意按照此目标继续执行?\"}", - 1, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build() - .generateProposeFromText( - "评估完成,结论可行,请确认。", - new NegotiationContext(UUID, 3, 5, NegotiationPerformative.PROPOSE), - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE), - NegotiationGenerationException.class, - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), - row( - "generation_feasibility_confirm_request_round_with_alternative_action_is_invalid_input", - "{\"feasibility_negotiation_description\":\"评估完成,结论可行,请确认。\",\"action\":" - + "\"PROPOSE_ALTERNATIVE_ON_FAILURE\",\"feasibility_confirm_request\":" - + "\"目标评估为可行,是否同意按照此目标继续执行?\"}", - 1, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build() - .generateProposeFromText( - "评估完成,结论可行,请确认。", - new NegotiationContext(UUID, 3, 5, NegotiationPerformative.PROPOSE), - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE), - NegotiationGenerationException.class, - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), - row( - "generation_llm_infrastructure_failure_is_retryable", - null, - 2, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(2) - .build() - .generateProposeFromText( - "请提供节能区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI), - NegotiationGenerationException.class, - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), - row( - "param_non_negotiation_input", - semanticPayload("information"), - 0, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build() - .validateProposePromptAndDataFilling( - "plain text without any negotiation section", null, SCHEMA, INFORMATION_PROPOSE_URI), - NegotiationParamExtractionException.class, - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT), - row( - "param_rule_violation", - semanticPayload("information"), - 0, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build() - .validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 9, 5, NegotiationPerformative.PROPOSE), - SCHEMA, - INFORMATION_PROPOSE_URI), - NegotiationParamExtractionException.class, - A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION), - row( - "param_semantic_rejection", - "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" - + "\"section.info_static\",\"code\":\"template_type_mismatch\",\"message\":" - + "\"inconsistent\"}],\"params\":{}}", - 1, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build() - .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, CONTEXT, SCHEMA, INFORMATION_PROPOSE_URI), - NegotiationParamExtractionException.class, - A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED), - row( - "param_llm_infrastructure_failure_is_retryable", - null, - 2, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(2) - .build() - .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, CONTEXT, SCHEMA, INFORMATION_PROPOSE_URI), - NegotiationParamExtractionException.class, - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), - row( - "entry_programming_error_carries_no_code_but_the_offending_argument", - "not-a-json", - 0, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build() - .generateProposeFromData(proposeData(), StandardTemplates.ENERGY_SAVING), - IllegalArgumentException.class, - null), - row( - "internal_render_failure_is_wrapped_as_slot_missing", - "not-a-json", - 0, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .templateLoader(nullContentTemplateLoader()) - .build() - .generateProposeFromData(proposeData(), INFORMATION_PROPOSE_URI), - NegotiationGenerationException.class, - A2ATErrorCodes.NEGOTIATION_SLOT_MISSING), - row( - "internal_semantic_shape_violation_is_retryable_infrastructure", - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{}}", - 2, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(2) - .build() - .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, CONTEXT, SCHEMA, INFORMATION_PROPOSE_URI), - NegotiationParamExtractionException.class, - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR), - row( - "param_prompt_resource_missing_is_template_not_found", - semanticPayload("information"), - 0, - llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .semanticValidator(throwingSemanticValidator()) - .build() - .validateProposePromptAndDataFilling(VALID_CONTEXT_PROMPT, CONTEXT, SCHEMA, INFORMATION_PROPOSE_URI), - NegotiationParamExtractionException.class, - A2ATErrorCodes.TEMPLATE_NOT_FOUND)); - } - - private static DynamicTest row( - String name, - String llmPayload, - int expectedLlmCalls, - Scenario scenario, - Class expectedType, - String expectedCode) { - return DynamicTest.dynamicTest( - name, () -> verify(name, llmPayload, expectedLlmCalls, scenario, expectedType, expectedCode)); - } - - private static void verify( - String name, - String llmPayload, - int expectedLlmCalls, - Scenario scenario, - Class expectedType, - String expectedCode) { - CountingClient llm = new CountingClient(llmPayload); - RuntimeException failure = runExpectingFailure(scenario.action(llm), name, expectedType); - - assertTrue( - expectedType.isInstance(failure), - "row " + name + " must fail with " + expectedType.getSimpleName() + " but failed with " - + failure.getClass().getSimpleName()); - assertEquals(expectedLlmCalls, llm.calls.get(), "row " + name + " LLM call count"); - assertTrue( - failure.getMessage() != null && !failure.getMessage().isBlank(), - "row " + name + " must carry a non-blank message"); - - if (failure instanceof IllegalArgumentException argumentFailure) { - assertFalse( - A2ATError.class.isInstance(argumentFailure), - "row " + name + " must stay outside A2ATError because it carries no code"); - assertNullCode(expectedCode, name); - assertTrue( - argumentFailure.getMessage().contains("Template URI does not address"), - "row " + name + " must name the offending argument in its message but was: " - + argumentFailure.getMessage()); - return; - } - if (failure instanceof NegotiationGenerationException generationFailure) { - assertEquals(expectedCode, generationFailure.getCode(), "row " + name + " public code"); - return; - } - if (failure instanceof NegotiationParamExtractionException extractionFailure) { - assertEquals(expectedCode, extractionFailure.getCode(), "row " + name + " public code"); - boolean expectsSlotDetails = A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION.equals(expectedCode) - || A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED.equals(expectedCode) - || A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR.equals(expectedCode); - if (expectsSlotDetails) { - assertFalse( - extractionFailure.getErrors().isEmpty(), - "row " + name + " must carry non-empty structured error details"); - assertTrue( - extractionFailure.getErrors().stream() - .allMatch(error -> error.slotName() != null - && !error.slotName().isBlank()), - "row " + name + " error details must carry slot names"); - } - return; - } - fail("row " + name + " failed with an unexpected exception type: " - + failure.getClass().getName()); - } - - private static void assertNullCode(String expectedCode, String name) { - assertNull(expectedCode, "row " + name + " carries no public code by design"); - } - - private static RuntimeException runExpectingFailure( - Runnable action, String name, Class expectedType) { - try { - action.run(); - } catch (RuntimeException failure) { - return failure; - } - fail("row " + name + " was expected to fail with " + expectedType.getSimpleName() + " but completed"); - return null; - } - - private static NegotiationProposeData proposeData() { - return new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)); - } - - private static String semanticPayload(String negotiationType) { - return "{\"semantic_verdict\":true,\"negotiation_type\":\"" + negotiationType + "\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}"; - } - - private static NegotiationTemplateLoader missingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - }; - } - - private static NegotiationTemplateLoader nullContentTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - return new PromptTemplate(reference.templateUri(), "broken template", null, "classpath"); - } - }; - } - - private static NegotiationSemanticValidator throwingSemanticValidator() { - return (prompt, callerSchema, reference, templateContent) -> { - throw new ResourceNotFoundException( - "Semantic validation prompt does not exist.", "prompt_resources/prompts"); - }; - } - - @FunctionalInterface - private interface Scenario { - - Runnable action(CountingClient llm); - } - - private static final class CountingClient implements LLMClient { - - private final AtomicInteger calls = new AtomicInteger(); - - private final String payload; - - private CountingClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls.incrementAndGet(); - if (payload == null) { - throw new LLMError("LLM endpoint unavailable."); - } - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.negotiation.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +/** + * Drives every row of the negotiation error-code usage matrix through the real pipelines. + * + *

        Each row triggers one failure condition of the documented matrix and asserts the exception type, the public error + * code, the number of LLM calls (encoding retryability: a retryable failure consumes all attempts, a non-retryable + * failure exactly one or zero), and that a structured detail identifying the problem field or slot is carried. + */ +class NegotiationErrorCodeUsageMatrixTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final TemplateUri INFORMATION_ENDING_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + + private static final String VALID_CONTEXT_PROMPT = "## 协商上下文\n- id: " + UUID + "\n- round: 1\n- maxRounds: 5"; + + private static final Map SCHEMA = Map.of("type", "object"); + + private static final NegotiationContext CONTEXT = + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE); + + @TestFactory + Stream everyErrorCodeRowOfTheMatrixBehavesAsPinned() { + return Stream.of( + row( + "generation_template_missing", + "not-a-json", + 0, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .templateLoader(missingTemplateLoader()) + .build() + .generateProposeFromData(proposeData(), INFORMATION_PROPOSE_URI), + NegotiationGenerationException.class, + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode()), + row( + "generation_response_invalid_is_retryable", + "not-a-json-object", + 2, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build() + .generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI), + NegotiationGenerationException.class, + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()), + row( + "generation_slot_missing_is_not_retryable", + "{}", + 1, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build() + .generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI), + NegotiationGenerationException.class, + ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode()), + row( + "generation_phase_conclusion_mismatch_is_conclusion_mismatch", + "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}", + 1, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build() + .generateAcceptFromText( + "确认提供区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.ACCEPT), + INFORMATION_ENDING_URI), + NegotiationGenerationException.class, + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode()), + row( + "generation_target_confirm_request_round_with_conditional_sections_is_invalid_input", + "{\"target_negotiation_description\":\"目标已经澄清,请确认。\",\"intent_understanding\":" + + "[{\"name\":\"发起方理解\",\"value\":\"对方希望降低节能力度\"}]," + + "\"target_confirm_request\":\"目标已经澄清,是否同意按照此目标继续执行?\"}", + 1, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build() + .generateProposeFromText( + "目标已经澄清,请确认。", + new NegotiationContext(UUID, 3, 5, NegotiationPerformative.PROPOSE), + StandardTemplates.TARGET_NEGOTIATION_PROPOSE), + NegotiationGenerationException.class, + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode()), + row( + "generation_feasibility_confirm_request_round_with_infeasibility_details_is_invalid_input", + "{\"feasibility_negotiation_description\":\"评估完成,结论可行,请确认。\",\"action\":" + + "\"REQUEST_FEASIBILITY_EVALUATION\",\"infeasibility_details_and_proposal\":" + + "[{\"name\":\"不应出现\",\"value\":\"值\"}],\"feasibility_confirm_request\":" + + "\"目标评估为可行,是否同意按照此目标继续执行?\"}", + 1, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build() + .generateProposeFromText( + "评估完成,结论可行,请确认。", + new NegotiationContext(UUID, 3, 5, NegotiationPerformative.PROPOSE), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE), + NegotiationGenerationException.class, + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode()), + row( + "generation_feasibility_confirm_request_round_with_alternative_action_is_invalid_input", + "{\"feasibility_negotiation_description\":\"评估完成,结论可行,请确认。\",\"action\":" + + "\"PROPOSE_ALTERNATIVE_ON_FAILURE\",\"feasibility_confirm_request\":" + + "\"目标评估为可行,是否同意按照此目标继续执行?\"}", + 1, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build() + .generateProposeFromText( + "评估完成,结论可行,请确认。", + new NegotiationContext(UUID, 3, 5, NegotiationPerformative.PROPOSE), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE), + NegotiationGenerationException.class, + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode()), + row( + "generation_llm_infrastructure_failure_is_retryable", + null, + 2, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build() + .generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI), + NegotiationGenerationException.class, + ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), + row( + "param_non_negotiation_input", + semanticPayload("information"), + 0, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build() + .validateProposePromptAndDataFilling( + "plain text without any negotiation section", + null, + SCHEMA, + INFORMATION_PROPOSE_URI), + NegotiationParamExtractionException.class, + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode()), + row( + "param_rule_violation", + semanticPayload("information"), + 0, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build() + .validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 9, 5, NegotiationPerformative.PROPOSE), + SCHEMA, + INFORMATION_PROPOSE_URI), + NegotiationParamExtractionException.class, + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode()), + row( + "param_semantic_rejection", + "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" + + "\"section.info_static\",\"code\":\"negotiation.type_mismatch\",\"facts\":" + + "{\"implied\":\"information\",\"declared\":\"information\"}}],\"params\":{}}", + 1, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build() + .validateProposePromptAndDataFilling( + VALID_CONTEXT_PROMPT, CONTEXT, SCHEMA, INFORMATION_PROPOSE_URI), + NegotiationParamExtractionException.class, + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode()), + row( + "param_llm_infrastructure_failure_is_retryable", + null, + 2, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build() + .validateProposePromptAndDataFilling( + VALID_CONTEXT_PROMPT, CONTEXT, SCHEMA, INFORMATION_PROPOSE_URI), + NegotiationParamExtractionException.class, + ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), + row( + "entry_programming_error_carries_no_code_but_the_offending_argument", + "not-a-json", + 0, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build() + .generateProposeFromData(proposeData(), StandardTemplates.ENERGY_SAVING), + IllegalArgumentException.class, + null), + row( + "internal_render_failure_is_wrapped_as_render_failed", + "not-a-json", + 0, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .templateLoader(nullContentTemplateLoader()) + .build() + .generateProposeFromData(proposeData(), INFORMATION_PROPOSE_URI), + NegotiationGenerationException.class, + ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode()), + row( + "internal_semantic_shape_violation_is_retryable_infrastructure", + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{}}", + 2, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build() + .validateProposePromptAndDataFilling( + VALID_CONTEXT_PROMPT, CONTEXT, SCHEMA, INFORMATION_PROPOSE_URI), + NegotiationParamExtractionException.class, + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()), + row( + "param_prompt_resource_missing_is_template_not_found", + semanticPayload("information"), + 0, + llm -> () -> NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .semanticValidator(throwingSemanticValidator()) + .build() + .validateProposePromptAndDataFilling( + VALID_CONTEXT_PROMPT, CONTEXT, SCHEMA, INFORMATION_PROPOSE_URI), + NegotiationParamExtractionException.class, + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode())); + } + + private static DynamicTest row( + String name, + String llmPayload, + int expectedLlmCalls, + Scenario scenario, + Class expectedType, + String expectedCode) { + return DynamicTest.dynamicTest( + name, () -> verify(name, llmPayload, expectedLlmCalls, scenario, expectedType, expectedCode)); + } + + private static void verify( + String name, + String llmPayload, + int expectedLlmCalls, + Scenario scenario, + Class expectedType, + String expectedCode) { + CountingClient llm = new CountingClient(llmPayload); + RuntimeException failure = runExpectingFailure(scenario.action(llm), name, expectedType); + + assertTrue( + expectedType.isInstance(failure), + "row " + name + " must fail with " + expectedType.getSimpleName() + " but failed with " + + failure.getClass().getSimpleName()); + assertEquals(expectedLlmCalls, llm.calls.get(), "row " + name + " LLM call count"); + assertTrue( + failure.getMessage() != null && !failure.getMessage().isBlank(), + "row " + name + " must carry a non-blank message"); + + if (failure instanceof IllegalArgumentException argumentFailure) { + assertFalse( + A2ATError.class.isInstance(argumentFailure), + "row " + name + " must stay outside A2ATError because it carries no code"); + assertNullCode(expectedCode, name); + assertTrue( + argumentFailure.getMessage().contains("Template URI does not address"), + "row " + name + " must name the offending argument in its message but was: " + + argumentFailure.getMessage()); + return; + } + if (failure instanceof NegotiationGenerationException generationFailure) { + assertEquals(expectedCode, generationFailure.getCode(), "row " + name + " public code"); + return; + } + if (failure instanceof NegotiationParamExtractionException extractionFailure) { + assertEquals(expectedCode, extractionFailure.getCode(), "row " + name + " public code"); + boolean expectsSlotDetails = ErrorCatalog.NEGOTIATION_RULE_VIOLATION + .getCode() + .equals(expectedCode) + || ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode().equals(expectedCode) + || ErrorCatalog.LLM_INVOCATION_FAILED.getCode().equals(expectedCode) + || ErrorCatalog.LLM_RESPONSE_INVALID.getCode().equals(expectedCode); + if (expectsSlotDetails) { + assertFalse( + extractionFailure.getErrors().isEmpty(), + "row " + name + " must carry non-empty structured error details"); + assertTrue( + extractionFailure.getErrors().stream() + .allMatch(error -> error.slotName() != null + && !error.slotName().isBlank()), + "row " + name + " error details must carry slot names"); + } + return; + } + fail("row " + name + " failed with an unexpected exception type: " + + failure.getClass().getName()); + } + + private static void assertNullCode(String expectedCode, String name) { + assertNull(expectedCode, "row " + name + " carries no public code by design"); + } + + private static RuntimeException runExpectingFailure( + Runnable action, String name, Class expectedType) { + try { + action.run(); + } catch (RuntimeException failure) { + return failure; + } + fail("row " + name + " was expected to fail with " + expectedType.getSimpleName() + " but completed"); + return null; + } + + private static NegotiationProposeData proposeData() { + return new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)); + } + + private static String semanticPayload(String negotiationType) { + return "{\"semantic_verdict\":true,\"negotiation_type\":\"" + negotiationType + "\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"; + } + + private static NegotiationTemplateLoader missingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + }; + } + + private static NegotiationTemplateLoader nullContentTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + return new PromptTemplate(reference.templateUri(), "broken template", null, "classpath"); + } + }; + } + + private static NegotiationSemanticValidator throwingSemanticValidator() { + return (prompt, callerSchema, reference, templateContent) -> { + throw new ResourceNotFoundException( + "Semantic validation prompt does not exist.", "prompt_resources/prompts"); + }; + } + + @FunctionalInterface + private interface Scenario { + + Runnable action(CountingClient llm); + } + + private static final class CountingClient implements LLMClient { + + private final AtomicInteger calls = new AtomicInteger(); + + private final String payload; + + private CountingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls.incrementAndGet(); + if (payload == null) { + throw new LLMError("LLM endpoint unavailable."); + } + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java index 5f498ef3..20cc64ea 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java @@ -11,7 +11,7 @@ import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMError; import net.openan.a2at.sdk.llm.LLMResponse; @@ -55,7 +55,7 @@ void exhaustedGenerationLlmFailureIsCatchableThroughTheCommonRoot() { assertTrue(failure instanceof NegotiationGenerationException); assertEquals( - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), ((NegotiationGenerationException) failure).getCode()); } @@ -73,7 +73,8 @@ void ruleViolationFailureIsCatchableThroughTheCommonRoot() { assertTrue(failure instanceof NegotiationParamExtractionException); assertEquals( - A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, ((NegotiationParamExtractionException) failure).getCode()); + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), + ((NegotiationParamExtractionException) failure).getCode()); } @Test @@ -82,8 +83,8 @@ void semanticRejectionFailureIsCatchableThroughTheCommonRoot() { .language("zh-CN") .llmClient(new ScriptedClient( "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" - + "\"section.info_static\",\"code\":\"template_type_mismatch\",\"message\":" - + "\"inconsistent\"}],\"params\":{}}")) + + "\"section.info_static\",\"code\":\"negotiation.type_mismatch\",\"facts\":" + + "{\"implied\":\"information\",\"declared\":\"information\"}}],\"params\":{}}")) .build(); RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( @@ -94,7 +95,7 @@ void semanticRejectionFailureIsCatchableThroughTheCommonRoot() { assertTrue(failure instanceof NegotiationParamExtractionException); assertEquals( - A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), ((NegotiationParamExtractionException) failure).getCode()); } @@ -114,7 +115,7 @@ void paramExtractionLlmFailureIsCatchableThroughTheCommonRoot() { assertTrue(failure instanceof NegotiationParamExtractionException); assertEquals( - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), ((NegotiationParamExtractionException) failure).getCode()); } @@ -129,7 +130,8 @@ void nonNegotiationInputFailureIsCatchableThroughTheCommonRoot() { assertTrue(failure instanceof NegotiationParamExtractionException); assertEquals( - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, ((NegotiationParamExtractionException) failure).getCode()); + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ((NegotiationParamExtractionException) failure).getCode()); } @Test @@ -180,7 +182,8 @@ void nullPromptOnValidateAndFillingMapsToInvalidInput() { assertTrue(failure instanceof NegotiationParamExtractionException); assertEquals( - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, ((NegotiationParamExtractionException) failure).getCode()); + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ((NegotiationParamExtractionException) failure).getCode()); } @Test @@ -197,7 +200,8 @@ void blankPromptOnValidateAndFillingMapsToInvalidInput() { assertTrue(failure instanceof NegotiationParamExtractionException); assertEquals( - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, ((NegotiationParamExtractionException) failure).getCode()); + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ((NegotiationParamExtractionException) failure).getCode()); } /** diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index 178e6026..588c3b25 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -16,7 +16,7 @@ import java.util.stream.Collectors; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.core.model.FilledParamData; @@ -54,6 +54,7 @@ class NegotiationLogEventContractTest { Map.entry("negotiation_template_loaded", Set.of(Level.DEBUG)), Map.entry("negotiation_generator_dispatched", Set.of(Level.DEBUG)), Map.entry("negotiation_content_extraction_completed", Set.of(Level.INFO)), + Map.entry("negotiation_content_extraction_response_invalid", Set.of(Level.WARN)), Map.entry("negotiation_semantic_validation_completed", Set.of(Level.INFO)), Map.entry("negotiation_generation_completed", Set.of(Level.INFO)), Map.entry("negotiation_llm_retry", Set.of(Level.WARN)), @@ -134,7 +135,7 @@ void retryEventsCarryTheInternalStepNameThatNeverAppearsOnExceptions() { assertTrue(retryMessages.get(0).contains("step=")); assertTrue(retryMessages.get(0).contains("attempt=1")); assertTrue(retryMessages.get(0).contains("max_attempts=")); - assertTrue(retryMessages.get(0).contains("code=" + A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR)); + assertTrue(retryMessages.get(0).contains("code=" + ErrorCatalog.LLM_INVOCATION_FAILED.getCode())); assertTrue(exhaustedMessages.get(0).contains("step=")); NegotiationGenerationException failure = generationFailureOfExhaustedExtraction(); @@ -259,7 +260,7 @@ private NegotiationGenerationException generationFailureOfExhaustedExtraction() new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE_URI); } catch (NegotiationGenerationException failure) { - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), failure.getCode()); return failure; } return null; @@ -288,8 +289,8 @@ private void driveSemanticRejection() { .language("zh-CN") .llmClient(new ScriptedClient( "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" - + "\"section.info_static\",\"code\":\"template_type_mismatch\",\"message\":" - + "\"inconsistent\"}],\"params\":{}}")) + + "\"section.info_static\",\"code\":\"negotiation.type_mismatch\",\"facts\":" + + "{\"implied\":\"information\",\"declared\":\"information\"}}],\"params\":{}}")) .build(); try { orchestrator.validateProposePromptAndDataFilling( @@ -298,7 +299,7 @@ private void driveSemanticRejection() { Map.of("type", "object"), INFORMATION_PROPOSE_URI); } catch (NegotiationParamExtractionException expected) { - assertEquals(A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, expected.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), expected.getCode()); } } @@ -314,7 +315,7 @@ private void driveRuleViolation() { Map.of("type", "object"), INFORMATION_PROPOSE_URI); } catch (NegotiationParamExtractionException expected) { - assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, expected.getCode()); + assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), expected.getCode()); } } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java index 4e9d2f24..f607dc77 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationPublicExceptionSurfaceTest.java @@ -1,159 +1,159 @@ -package net.openan.a2at.sdk.negotiation.observability; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMError; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProcessingException; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Test; - -/** - * Verifies that the public failure surface of the negotiation content layer never exposes internal pipeline stages. - * - *

        Internal step names exist as diagnostics in the logs only: no public exception type carries a stage property, and - * no failure message mentions the word stage. The tests inspect the exception types reflectively and drive the real - * failure paths to lock the message behavior. - */ -class NegotiationPublicExceptionSurfaceTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final List> PUBLIC_EXCEPTION_TYPES = List.of( - A2ATError.class, - A2ATParamExtractionError.class, - NegotiationProcessingException.class, - NegotiationGenerationException.class, - NegotiationParamExtractionException.class); - - @Test - void noPublicExceptionTypeExposesAStageProperty() { - for (Class type : PUBLIC_EXCEPTION_TYPES) { - for (Method method : type.getMethods()) { - String name = method.getName().toLowerCase(Locale.ROOT); - assertFalse( - name.contains("stage"), - type.getSimpleName() + " must not expose a stage property but declares " + method.getName()); - } - for (Field field : type.getDeclaredFields()) { - String name = field.getName().toLowerCase(Locale.ROOT); - assertFalse( - name.contains("stage"), - type.getSimpleName() + " must not carry a stage field but declares " + field.getName()); - } - } - } - - @Test - void noFailureMessageOfTheRealPipelinesMentionsAStage() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new FailingClient()) - .maxAttempts(2) - .build(); - - List messages = List.of( - failureMessageOf(() -> orchestrator.generateProposeFromText( - "请提供节能区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI)), - failureMessageOf(() -> orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)), - failureMessageOf(() -> new NegotiationContext(" ", 1, 5, NegotiationPerformative.PROPOSE)), - failureMessageOf(() -> orchestrator.generateProposeFromText( - "text", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - StandardTemplates.ENERGY_SAVING))); - - for (String message : messages) { - assertTrue(message != null && !message.isBlank(), "failure messages must not be blank"); - assertFalse( - message.toLowerCase(Locale.ROOT).contains("stage"), - "failure message must not mention a stage but was: " + message); - } - } - - @Test - void publicFailuresStillCarryTheirStructuredDetails() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new FailingClient()) - .maxAttempts(2) - .build(); - - NegotiationGenerationException generationFailure = catchFailure( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供节能区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI)); - assertTrue(generationFailure.getCode().equals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR)); - - NegotiationParamExtractionException extractionFailure = catchFailure( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - assertTrue(extractionFailure.getCode().equals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR)); - assertFalse(extractionFailure.getErrors().isEmpty()); - assertTrue(extractionFailure.getErrors().stream() - .allMatch(error -> error.slotName() != null && !error.slotName().isBlank())); - } - - private static String failureMessageOf(Runnable action) { - try { - action.run(); - } catch (RuntimeException failure) { - return failure.getMessage(); - } - return null; - } - - private static T catchFailure(Class type, Runnable action) { - try { - action.run(); - } catch (RuntimeException failure) { - if (type.isInstance(failure)) { - return type.cast(failure); - } - throw failure; - } - throw new AssertionError("the action was expected to fail with " + type.getSimpleName()); - } - - private static final class FailingClient implements LLMClient { - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - throw new LLMError("LLM endpoint unavailable."); - } - } -} +package net.openan.a2at.sdk.negotiation.observability; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProcessingException; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import org.junit.jupiter.api.Test; + +/** + * Verifies that the public failure surface of the negotiation content layer never exposes internal pipeline stages. + * + *

        Internal step names exist as diagnostics in the logs only: no public exception type carries a stage property, and + * no failure message mentions the word stage. The tests inspect the exception types reflectively and drive the real + * failure paths to lock the message behavior. + */ +class NegotiationPublicExceptionSurfaceTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final List> PUBLIC_EXCEPTION_TYPES = List.of( + A2ATError.class, + A2ATParamExtractionError.class, + NegotiationProcessingException.class, + NegotiationGenerationException.class, + NegotiationParamExtractionException.class); + + @Test + void noPublicExceptionTypeExposesAStageProperty() { + for (Class type : PUBLIC_EXCEPTION_TYPES) { + for (Method method : type.getMethods()) { + String name = method.getName().toLowerCase(Locale.ROOT); + assertFalse( + name.contains("stage"), + type.getSimpleName() + " must not expose a stage property but declares " + method.getName()); + } + for (Field field : type.getDeclaredFields()) { + String name = field.getName().toLowerCase(Locale.ROOT); + assertFalse( + name.contains("stage"), + type.getSimpleName() + " must not carry a stage field but declares " + field.getName()); + } + } + } + + @Test + void noFailureMessageOfTheRealPipelinesMentionsAStage() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new FailingClient()) + .maxAttempts(2) + .build(); + + List messages = List.of( + failureMessageOf(() -> orchestrator.generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI)), + failureMessageOf(() -> orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)), + failureMessageOf(() -> new NegotiationContext(" ", 1, 5, NegotiationPerformative.PROPOSE)), + failureMessageOf(() -> orchestrator.generateProposeFromText( + "text", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + StandardTemplates.ENERGY_SAVING))); + + for (String message : messages) { + assertTrue(message != null && !message.isBlank(), "failure messages must not be blank"); + assertFalse( + message.toLowerCase(Locale.ROOT).contains("stage"), + "failure message must not mention a stage but was: " + message); + } + } + + @Test + void publicFailuresStillCarryTheirStructuredDetails() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new FailingClient()) + .maxAttempts(2) + .build(); + + NegotiationGenerationException generationFailure = catchFailure( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI)); + assertTrue(generationFailure.getCode().equals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode())); + + NegotiationParamExtractionException extractionFailure = catchFailure( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + assertTrue(extractionFailure.getCode().equals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode())); + assertFalse(extractionFailure.getErrors().isEmpty()); + assertTrue(extractionFailure.getErrors().stream() + .allMatch(error -> error.slotName() != null && !error.slotName().isBlank())); + } + + private static String failureMessageOf(Runnable action) { + try { + action.run(); + } catch (RuntimeException failure) { + return failure.getMessage(); + } + return null; + } + + private static T catchFailure(Class type, Runnable action) { + try { + action.run(); + } catch (RuntimeException failure) { + if (type.isInstance(failure)) { + return type.cast(failure); + } + throw failure; + } + throw new AssertionError("the action was expected to fail with " + type.getSimpleName()); + } + + private static final class FailingClient implements LLMClient { + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new LLMError("LLM endpoint unavailable."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java index 1ed734d0..13da676c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ComplianceCheckerTest.java @@ -1,127 +1,130 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -class ComplianceCheckerTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private final DefaultNegotiationComplianceChecker checker = new DefaultNegotiationComplianceChecker(); - - static List violationCases() { - return List.of( - new Object[] { - "non-uuid id", new NegotiationContext("not-a-uuid", 1, 5, NegotiationPerformative.PROPOSE), List.of("id") - }, - new Object[] { - "short id", new NegotiationContext("short", 1, 5, NegotiationPerformative.PROPOSE), List.of("id") - }, - new Object[] { - "non-hexadecimal id", - new NegotiationContext(SESSION_ID.replace('b', 'z'), 1, 5, NegotiationPerformative.PROPOSE), - List.of("id") - }, - new Object[] { - "round above maxRounds", - new NegotiationContext(SESSION_ID, 3, 2, NegotiationPerformative.PROPOSE), - List.of("round") - }); - } - - @Test - void validContextPasses() { - NegotiationRuleCheckResult result = - checker.check(new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.PROPOSE)); - - assertTrue(result.passed()); - assertEquals(List.of(), result.errors()); - } - - @Test - void roundAtTheBudgetBoundaryPasses() { - NegotiationRuleCheckResult result = - checker.check(new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.PROPOSE)); - - assertTrue(result.passed()); - assertEquals(List.of(), result.errors()); - } - - @ParameterizedTest - @MethodSource("violationCases") - void contextViolationsFailWithTheExpectedSlotNames(String caseName, NegotiationContext context, List expectedSlots) { - NegotiationRuleCheckResult result = checker.check(context); - - assertFalse(result.passed(), caseName); - assertEquals(expectedSlots.size(), result.errors().size(), caseName); - for (int index = 0; index < expectedSlots.size(); index++) { - assertEquals(expectedSlots.get(index), result.errors().get(index).slotName(), caseName); - } - } - - @Test - void uuidFormatIsEnforcedBeyondLength() { - NegotiationRuleCheckResult result = checker.check( - new NegotiationContext(SESSION_ID.replace('b', 'z'), 1, 5, NegotiationPerformative.PROPOSE)); - - assertFalse(result.passed()); - assertEquals("id", result.errors().get(0).slotName()); - assertEquals("invalid_uuid", result.errors().get(0).code()); - } - - @Test - void uppercaseHexUuidIsAccepted() { - NegotiationRuleCheckResult result = checker.check( - new NegotiationContext(SESSION_ID.toUpperCase(java.util.Locale.ROOT), 1, 5, NegotiationPerformative.PROPOSE)); - - assertTrue(result.passed()); - assertEquals(0, result.errors().size()); - } - - @Test - void roundAboveMaxRoundsCarriesTheOutOfRangeCode() { - NegotiationRuleCheckResult result = - checker.check(new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.PROPOSE)); - - assertFalse(result.passed()); - SlotValidationError error = result.errors().get(0); - assertEquals("round", error.slotName()); - assertEquals("out_of_range", error.code()); - assertTrue(error.message().contains("maxRounds")); - } - - @Test - void nullContextIsRejected() { - assertThrows(NullPointerException.class, () -> checker.check(null)); - } - - @Test - void resultRecordHasExactlyTheTwoPinnedComponents() { - String[] componentNames = java.util.Arrays.stream(NegotiationRuleCheckResult.class.getRecordComponents()) - .map(component -> component.getName()) - .toArray(String[]::new); - - assertEquals(List.of("passed", "errors"), List.of(componentNames)); - } - - @Test - void ruleErrorsCarryStructuredDetails() { - NegotiationRuleCheckResult result = - checker.check(new NegotiationContext("short", 1, 5, NegotiationPerformative.PROPOSE)); - - SlotValidationError error = result.errors().get(0); - assertEquals("id", error.slotName()); - assertEquals("invalid_uuid", error.code()); - assertTrue(error.message().contains("UUID")); - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class ComplianceCheckerTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private final DefaultNegotiationComplianceChecker checker = new DefaultNegotiationComplianceChecker(); + + static List violationCases() { + return List.of( + new Object[] { + "non-uuid id", + new NegotiationContext("not-a-uuid", 1, 5, NegotiationPerformative.PROPOSE), + List.of("id") + }, + new Object[] { + "short id", new NegotiationContext("short", 1, 5, NegotiationPerformative.PROPOSE), List.of("id") + }, + new Object[] { + "non-hexadecimal id", + new NegotiationContext(SESSION_ID.replace('b', 'z'), 1, 5, NegotiationPerformative.PROPOSE), + List.of("id") + }, + new Object[] { + "round above maxRounds", + new NegotiationContext(SESSION_ID, 3, 2, NegotiationPerformative.PROPOSE), + List.of("round") + }); + } + + @Test + void validContextPasses() { + NegotiationRuleCheckResult result = + checker.check(new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.PROPOSE)); + + assertTrue(result.passed()); + assertEquals(List.of(), result.errors()); + } + + @Test + void roundAtTheBudgetBoundaryPasses() { + NegotiationRuleCheckResult result = + checker.check(new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.PROPOSE)); + + assertTrue(result.passed()); + assertEquals(List.of(), result.errors()); + } + + @ParameterizedTest + @MethodSource("violationCases") + void contextViolationsFailWithTheExpectedSlotNames( + String caseName, NegotiationContext context, List expectedSlots) { + NegotiationRuleCheckResult result = checker.check(context); + + assertFalse(result.passed(), caseName); + assertEquals(expectedSlots.size(), result.errors().size(), caseName); + for (int index = 0; index < expectedSlots.size(); index++) { + assertEquals(expectedSlots.get(index), result.errors().get(index).slotName(), caseName); + } + } + + @Test + void uuidFormatIsEnforcedBeyondLength() { + NegotiationRuleCheckResult result = checker.check( + new NegotiationContext(SESSION_ID.replace('b', 'z'), 1, 5, NegotiationPerformative.PROPOSE)); + + assertFalse(result.passed()); + assertEquals("id", result.errors().get(0).slotName()); + assertEquals("negotiation.invalid_context_id", result.errors().get(0).code()); + } + + @Test + void uppercaseHexUuidIsAccepted() { + NegotiationRuleCheckResult result = checker.check(new NegotiationContext( + SESSION_ID.toUpperCase(java.util.Locale.ROOT), 1, 5, NegotiationPerformative.PROPOSE)); + + assertTrue(result.passed()); + assertEquals(0, result.errors().size()); + } + + @Test + void roundAboveMaxRoundsCarriesTheOutOfRangeCode() { + NegotiationRuleCheckResult result = + checker.check(new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.PROPOSE)); + + assertFalse(result.passed()); + SlotValidationError error = result.errors().get(0); + assertEquals("round", error.slotName()); + assertEquals("negotiation.round_exceeded", error.code()); + assertTrue(error.message().contains("maximum")); + } + + @Test + void nullContextIsRejected() { + assertThrows(NullPointerException.class, () -> checker.check(null)); + } + + @Test + void resultRecordHasExactlyTheTwoPinnedComponents() { + String[] componentNames = java.util.Arrays.stream(NegotiationRuleCheckResult.class.getRecordComponents()) + .map(component -> component.getName()) + .toArray(String[]::new); + + assertEquals(List.of("passed", "errors"), List.of(componentNames)); + } + + @Test + void ruleErrorsCarryStructuredDetails() { + NegotiationRuleCheckResult result = + checker.check(new NegotiationContext("short", 1, 5, NegotiationPerformative.PROPOSE)); + + SlotValidationError error = result.errors().get(0); + assertEquals("id", error.slotName()); + assertEquals("negotiation.invalid_context_id", error.code()); + assertTrue(error.message().contains("UUID")); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java index b7642740..77767f7d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidatorTest.java @@ -1,399 +1,437 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -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 java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.junit.jupiter.api.Test; - -class DefaultNegotiationSemanticValidatorTest { - - private static final String VALID_PROMPT = "## Negotiation Context\n" - + "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n" - + "- round: 1\n" - + "- maxRounds: 5\n\n" - + "## Required Information Items\n" - + "1. energy saving region: provide a real region\n"; - - private static final String TEMPLATE_CONTENT = "dummy template content"; - - private final RecordingSchemaBuilder schemaBuilder = new RecordingSchemaBuilder(); - - private final RecordingLLMClient llmClient = new RecordingLLMClient(); - - private final DefaultNegotiationSemanticValidator validator = - new DefaultNegotiationSemanticValidator(llmClient, schemaBuilder::buildSemanticValidationSchema); - - private final NegotiationReference informationReference = - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US"); - - private final Map callerSchema = - Map.of("type", "object", "properties", Map.of("confirmed_rate_mbps", Map.of("type", "integer"))); - - @Test - void validResponseYieldsVerdictTypeErrorsAndParams() { - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," - + "\"errors\":[],\"params\":{\"confirmed_rate_mbps\":2}}"; - - SemanticValidationResult result = validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT); - - assertTrue(result.verdict()); - assertEquals("information", result.negotiationType()); - assertEquals(List.of(), result.errors()); - assertEquals(Map.of("confirmed_rate_mbps", 2), result.params()); - assertEquals(1, llmClient.invocations); - assertEquals(callerSchema, schemaBuilder.lastCallerSchema); - } - - @Test - void rejectMessageParamsCarryTheReasonsOfNonProvision() { - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," - + "\"errors\":[],\"params\":{\"energy saving region\":\"site inventory unavailable, cannot provide\"}}"; - Map rejectSchema = Map.of( - "type", "object", - "properties", Map.of("energy saving region", Map.of("type", "string"))); - NegotiationReference rejectReference = - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.REJECT, "en-US"); - - SemanticValidationResult result = - validator.validateNegotiation(VALID_PROMPT, rejectSchema, rejectReference, TEMPLATE_CONTENT); - - assertTrue(result.verdict()); - assertEquals( - Map.of("energy saving region", "site inventory unavailable, cannot provide"), - result.params()); - } - - @Test - void englishSystemPromptCarriesTheRejectReasonExtractionRule() { - llmClient.payload = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; - NegotiationReference rejectReference = - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.REJECT, "en-US"); - - validator.validateNegotiation(VALID_PROMPT, callerSchema, rejectReference, TEMPLATE_CONTENT); - - String systemPrompt = llmClient.lastMessages.get(0).get("content"); - assertTrue(systemPrompt.contains("reason of non-provision stated for that field")); - } - - @Test - void chineseSystemPromptCarriesTheRejectReasonExtractionRule() { - llmClient.payload = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; - NegotiationReference rejectReference = - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.REJECT, "zh-CN"); - - validator.validateNegotiation(VALID_PROMPT, callerSchema, rejectReference, TEMPLATE_CONTENT); - - String systemPrompt = llmClient.lastMessages.get(0).get("content"); - assertTrue(systemPrompt.contains("无法提供的原因文本")); - } - - @Test - void proposeMessageParamsCarryTheFullExpectationText() { - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," - + "\"errors\":[],\"params\":{\"energy saving region\":\"energy saving area information, e.g. Songshanhu\"}}"; - Map proposeSchema = Map.of( - "type", "object", - "properties", Map.of("energy saving region", Map.of("type", "string"))); - NegotiationReference proposeReference = - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US"); - - SemanticValidationResult result = - validator.validateNegotiation(VALID_PROMPT, proposeSchema, proposeReference, TEMPLATE_CONTENT); - - assertTrue(result.verdict()); - assertEquals( - Map.of("energy saving region", "energy saving area information, e.g. Songshanhu"), - result.params()); - } - - @Test - void englishSystemPromptCarriesTheProposeExpectationExtractionRule() { - llmClient.payload = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; - - validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT); - - String systemPrompt = llmClient.lastMessages.get(0).get("content"); - assertTrue(systemPrompt.contains("the full expectation text stated for that field")); - assertTrue(systemPrompt.contains("never treat a sample as the field's supplied value")); - } - - @Test - void chineseSystemPromptCarriesTheProposeExpectationExtractionRule() { - llmClient.payload = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; - NegotiationReference proposeReference = - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"); - - validator.validateNegotiation(VALID_PROMPT, callerSchema, proposeReference, TEMPLATE_CONTENT); - - String systemPrompt = llmClient.lastMessages.get(0).get("content"); - assertTrue(systemPrompt.contains("完整期望内容原文")); - assertTrue(systemPrompt.contains("不得将样例当作该字段已提供的真实值")); - } - - @Test - void singleStructuredCallReceivesMergedSchemaAndFilledUserPrompt() { - llmClient.payload = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":{}}"; - - validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT); - - List> messages = llmClient.lastMessages; - assertEquals(2, messages.size()); - assertEquals("system", messages.get(0).get("role")); - assertTrue(messages.get(0).get("content").contains("semantic validation")); - assertEquals("user", messages.get(1).get("role")); - String userPrompt = messages.get(1).get("content"); -assertTrue(userPrompt.contains("information")); - assertTrue(userPrompt.contains("propose")); - assertTrue(userPrompt.contains(TEMPLATE_CONTENT)); - assertTrue(userPrompt.contains(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri())); - assertTrue(userPrompt.contains("confirmed_rate_mbps")); - assertTrue(userPrompt.contains("energy saving region")); - assertEquals(Map.of("merged", true), llmClient.lastSchema); - assertNull(llmClient.lastTemperature); - assertNull(llmClient.lastMaxTokens); - } - - @Test - void missingNegotiationTypeKeyIsAShapeViolation() { - llmClient.payload = "{\"semantic_verdict\":true,\"errors\":[],\"params\":{}}"; - - NegotiationValidationException exception = assertThrows( - NegotiationValidationException.class, - () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); - - assertTrue(exception.getMessage().contains("negotiation_type")); - } - - @Test - void missingOtherRequiredKeysAreShapeViolations() { - llmClient.payload = "{\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); - - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"params\":{}}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); - - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); - } - - @Test - void wrongShapesAreShapeViolations() { - llmClient.payload = - "{\"semantic_verdict\":\"yes\",\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":{}}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); - - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," - + "\"errors\":\"none\",\"params\":{}}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); - - llmClient.payload = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":[]}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); - - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," - + "\"errors\":[{\"slot_name\":\"section.context\"}],\"params\":{}}"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); - - llmClient.payload = "not json at all"; - assertThrows( - NegotiationValidationException.class, - () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); - } - - @Test - void nullTypeWithTrueVerdictIsTurnedIntoSemanticRejection() { - llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"; - - SemanticValidationResult result = validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT); - - assertFalse(result.verdict()); - assertNull(result.negotiationType()); - assertEquals(1, result.errors().size()); - assertEquals("section.info_static", result.errors().get(0).slotName()); - assertEquals("template_type_mismatch", result.errors().get(0).code()); - } - - @Test - void typeMismatchWithTrueVerdictIsTurnedIntoSemanticRejection() { - llmClient.payload = - "{\"semantic_verdict\":true,\"negotiation_type\":\"target\"," + "\"errors\":[],\"params\":{}}"; - - SemanticValidationResult result = validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT); - - assertFalse(result.verdict()); - assertEquals("target", result.negotiationType()); - assertEquals(1, result.errors().size()); - assertEquals("section.target", result.errors().get(0).slotName()); - assertEquals("template_type_mismatch", result.errors().get(0).code()); - assertTrue(result.errors().get(0).message().contains("information")); - } - - @Test - void negativeVerdictPassesThroughWithTypeNullAndErrors() { - llmClient.payload = "{\"semantic_verdict\":false,\"negotiation_type\":null," - + "\"errors\":[{\"slot_name\":\"section.target_result_content\"," - + "\"code\":\"conclusion_content_mismatch\",\"message\":\"Mismatch\"}],\"params\":{}}"; - - SemanticValidationResult result = validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT); - - assertFalse(result.verdict()); - assertNull(result.negotiationType()); - assertEquals(1, result.errors().size()); - assertEquals("section.target_result_content", result.errors().get(0).slotName()); - } - - @Test - void llmProviderFailureIsWrappedAsInternalValidationException() { - llmClient.failure = new LLMRuntimeError("invocation failed"); - - NegotiationValidationException exception = assertThrows( - NegotiationValidationException.class, - () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); - - assertTrue(exception.getMessage().contains("invocation failed")); - } - - @Test - void missingPromptResourceForLanguageSurfacesResourceNotFound() { - NegotiationReference unsupportedLanguageReference = - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "fr-FR"); - - assertThrows( - ResourceNotFoundException.class, - () -> validator.validateNegotiation(VALID_PROMPT, callerSchema, unsupportedLanguageReference, TEMPLATE_CONTENT)); - assertEquals(0, llmClient.invocations); - } - - private static final class RecordingLLMClient implements LLMClient { - - private String payload = "{}"; - - private RuntimeException failure; - - private int invocations; - - private List> lastMessages; - - private Map lastSchema; - - private Double lastTemperature; - - private Integer lastMaxTokens; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - invocations++; - lastMessages = messages; - lastSchema = jsonSchema; - lastTemperature = temperature; - lastMaxTokens = maxTokens; - if (failure != null) { - throw failure; - } - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class RecordingSchemaBuilder { - - private Map lastCallerSchema; - - public Map buildSemanticValidationSchema(Map callerSchema) { - lastCallerSchema = callerSchema; - return Map.of("merged", true); - } - @Test - @SuppressWarnings("unchecked") - void semanticValidationSchemaMergesTheCallerSchemaIntoAFourKeyContract() { - Map callerSchema = - Map.of("type", "object", "properties", Map.of("energy_rate", Map.of("type", "number"))); - - Map schema = DefaultNegotiationSemanticValidator.buildSemanticValidationSchema(callerSchema); - - assertEquals("object", schema.get("type")); - Map properties = (Map) schema.get("properties"); - assertEquals( - List.of("semantic_verdict", "negotiation_type", "errors", "params"), List.copyOf(properties.keySet())); - assertEquals(List.of("semantic_verdict", "negotiation_type", "errors", "params"), schema.get("required")); - assertEquals(Boolean.FALSE, schema.get("additionalProperties")); - - assertEquals(Map.of("type", "boolean"), properties.get("semantic_verdict")); - - Map negotiationType = (Map) properties.get("negotiation_type"); - assertEquals(List.of("string", "null"), negotiationType.get("type")); - List typeEnum = (List) negotiationType.get("enum"); - assertEquals(4, typeEnum.size()); - assertTrue(typeEnum.contains("information")); - assertTrue(typeEnum.contains("target")); - assertTrue(typeEnum.contains("feasibility")); - assertTrue(typeEnum.contains(null)); - - Map errors = (Map) properties.get("errors"); - assertEquals("array", errors.get("type")); - Map errorItem = (Map) errors.get("items"); - assertEquals( - List.of("slot_name", "code", "message"), - List.copyOf(((Map) errorItem.get("properties")).keySet())); - assertEquals(List.of("slot_name", "code", "message"), errorItem.get("required")); - - assertEquals(callerSchema, properties.get("params")); - } - - @Test - void semanticValidationSchemaWrapsCallerSchemaWithoutTypeKeyword() { - Map callerSchema = new java.util.LinkedHashMap<>(); - callerSchema.put("properties", Map.of("id", Map.of("type", "string"))); - - Map schema = DefaultNegotiationSemanticValidator.buildSemanticValidationSchema(callerSchema); - - Map params = (Map) ((Map) schema.get("properties")).get("params"); - assertEquals("object", params.get("type")); - assertEquals(Map.of("id", Map.of("type", "string")), params.get("properties")); - assertNull(params.get("additionalProperties")); - } - - @Test - void rejectsNullCallerSchema() { - assertEquals( - "Caller parameter schema must not be null.", - assertThrows(NullPointerException.class, () -> DefaultNegotiationSemanticValidator.buildSemanticValidationSchema(null)) - .getMessage()); - } -} -} +package net.openan.a2at.sdk.negotiation.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +class DefaultNegotiationSemanticValidatorTest { + + private static final String VALID_PROMPT = "## Negotiation Context\n" + + "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n" + + "- round: 1\n" + + "- maxRounds: 5\n\n" + + "## Required Information Items\n" + + "1. energy saving region: provide a real region\n"; + + private static final String TEMPLATE_CONTENT = "dummy template content"; + + private final RecordingSchemaBuilder schemaBuilder = new RecordingSchemaBuilder(); + + private final RecordingLLMClient llmClient = new RecordingLLMClient(); + + private final DefaultNegotiationSemanticValidator validator = + new DefaultNegotiationSemanticValidator(llmClient, schemaBuilder::buildSemanticValidationSchema); + + private final NegotiationReference informationReference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US"); + + private final Map callerSchema = + Map.of("type", "object", "properties", Map.of("confirmed_rate_mbps", Map.of("type", "integer"))); + + @Test + void validResponseYieldsVerdictTypeErrorsAndParams() { + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + + "\"errors\":[],\"params\":{\"confirmed_rate_mbps\":2}}"; + + SemanticValidationResult result = + validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT); + + assertTrue(result.verdict()); + assertEquals("information", result.negotiationType()); + assertEquals(List.of(), result.errors()); + assertEquals(Map.of("confirmed_rate_mbps", 2), result.params()); + assertEquals(1, llmClient.invocations); + assertEquals(callerSchema, schemaBuilder.lastCallerSchema); + } + + @Test + void rejectMessageParamsCarryTheReasonsOfNonProvision() { + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + + "\"errors\":[],\"params\":{\"energy saving region\":\"site inventory unavailable, cannot provide\"}}"; + Map rejectSchema = + Map.of("type", "object", "properties", Map.of("energy saving region", Map.of("type", "string"))); + NegotiationReference rejectReference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.REJECT, "en-US"); + + SemanticValidationResult result = + validator.validateNegotiation(VALID_PROMPT, rejectSchema, rejectReference, TEMPLATE_CONTENT); + + assertTrue(result.verdict()); + assertEquals(Map.of("energy saving region", "site inventory unavailable, cannot provide"), result.params()); + } + + @Test + void englishSystemPromptCarriesTheRejectReasonExtractionRule() { + llmClient.payload = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; + NegotiationReference rejectReference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.REJECT, "en-US"); + + validator.validateNegotiation(VALID_PROMPT, callerSchema, rejectReference, TEMPLATE_CONTENT); + + String systemPrompt = llmClient.lastMessages.get(0).get("content"); + assertTrue(systemPrompt.contains("reason of non-provision stated for that field")); + } + + @Test + void chineseSystemPromptCarriesTheRejectReasonExtractionRule() { + llmClient.payload = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; + NegotiationReference rejectReference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.REJECT, "zh-CN"); + + validator.validateNegotiation(VALID_PROMPT, callerSchema, rejectReference, TEMPLATE_CONTENT); + + String systemPrompt = llmClient.lastMessages.get(0).get("content"); + assertTrue(systemPrompt.contains("无法提供的原因文本")); + } + + @Test + void proposeMessageParamsCarryTheFullExpectationText() { + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + + "\"errors\":[],\"params\":{\"energy saving region\":\"energy saving area information, e.g. Songshanhu\"}}"; + Map proposeSchema = + Map.of("type", "object", "properties", Map.of("energy saving region", Map.of("type", "string"))); + NegotiationReference proposeReference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US"); + + SemanticValidationResult result = + validator.validateNegotiation(VALID_PROMPT, proposeSchema, proposeReference, TEMPLATE_CONTENT); + + assertTrue(result.verdict()); + assertEquals( + Map.of("energy saving region", "energy saving area information, e.g. Songshanhu"), result.params()); + } + + @Test + void englishSystemPromptCarriesTheProposeExpectationExtractionRule() { + llmClient.payload = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; + + validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT); + + String systemPrompt = llmClient.lastMessages.get(0).get("content"); + assertTrue(systemPrompt.contains("the full expectation text stated for that field")); + assertTrue(systemPrompt.contains("never treat a sample as the field's supplied value")); + } + + @Test + void chineseSystemPromptCarriesTheProposeExpectationExtractionRule() { + llmClient.payload = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; + NegotiationReference proposeReference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"); + + validator.validateNegotiation(VALID_PROMPT, callerSchema, proposeReference, TEMPLATE_CONTENT); + + String systemPrompt = llmClient.lastMessages.get(0).get("content"); + assertTrue(systemPrompt.contains("完整期望内容原文")); + assertTrue(systemPrompt.contains("不得将样例当作该字段已提供的真实值")); + } + + @Test + void singleStructuredCallReceivesMergedSchemaAndFilledUserPrompt() { + llmClient.payload = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":{}}"; + + validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT); + + List> messages = llmClient.lastMessages; + assertEquals(2, messages.size()); + assertEquals("system", messages.get(0).get("role")); + assertTrue(messages.get(0).get("content").contains("semantic validation")); + assertEquals("user", messages.get(1).get("role")); + String userPrompt = messages.get(1).get("content"); + assertTrue(userPrompt.contains("information")); + assertTrue(userPrompt.contains("propose")); + assertTrue(userPrompt.contains(TEMPLATE_CONTENT)); + assertTrue(userPrompt.contains(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri())); + assertTrue(userPrompt.contains("confirmed_rate_mbps")); + assertTrue(userPrompt.contains("energy saving region")); + assertEquals(Map.of("merged", true), llmClient.lastSchema); + assertNull(llmClient.lastTemperature); + assertNull(llmClient.lastMaxTokens); + } + + @Test + void missingNegotiationTypeKeyIsAShapeViolation() { + llmClient.payload = "{\"semantic_verdict\":true,\"errors\":[],\"params\":{}}"; + + NegotiationValidationException exception = assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation( + VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); + + assertTrue(exception.getMessage().contains("negotiation_type")); + } + + @Test + void missingOtherRequiredKeysAreShapeViolations() { + llmClient.payload = "{\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation( + VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); + + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation( + VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); + + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation( + VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); + } + + @Test + void wrongShapesAreShapeViolations() { + llmClient.payload = + "{\"semantic_verdict\":\"yes\",\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation( + VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); + + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + + "\"errors\":\"none\",\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation( + VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); + + llmClient.payload = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + "\"errors\":[],\"params\":[]}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation( + VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); + + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + + "\"errors\":[{\"slot_name\":\"section.context\"}],\"params\":{}}"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation( + VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); + + llmClient.payload = "not json at all"; + assertThrows( + NegotiationValidationException.class, + () -> validator.validateNegotiation( + VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); + } + + @Test + void nullTypeWithTrueVerdictIsTurnedIntoSemanticRejection() { + llmClient.payload = "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"; + + SemanticValidationResult result = + validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT); + + assertFalse(result.verdict()); + assertNull(result.negotiationType()); + assertEquals(1, result.errors().size()); + assertEquals("section.info_static", result.errors().get(0).slotName()); + assertEquals("negotiation.type_mismatch", result.errors().get(0).code()); + } + + @Test + void typeMismatchWithTrueVerdictIsTurnedIntoSemanticRejection() { + llmClient.payload = + "{\"semantic_verdict\":true,\"negotiation_type\":\"target\"," + "\"errors\":[],\"params\":{}}"; + + SemanticValidationResult result = + validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT); + + assertFalse(result.verdict()); + assertEquals("target", result.negotiationType()); + assertEquals(1, result.errors().size()); + assertEquals("section.target", result.errors().get(0).slotName()); + assertEquals("negotiation.type_mismatch", result.errors().get(0).code()); + assertTrue(result.errors().get(0).message().contains("information")); + } + + @Test + void negativeVerdictPassesThroughWithTypeNullAndErrors() { + llmClient.payload = "{\"semantic_verdict\":false,\"negotiation_type\":null," + + "\"errors\":[{\"slot_name\":\"section.target_result_content\"," + + "\"code\":\"negotiation.conclusion_content_mismatch\",\"facts\":{\"conclusion\":\"Accept\"," + + "\"section_label\":\"section.target_result_content\"}}],\"params\":{}}"; + + SemanticValidationResult result = + validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT); + + assertFalse(result.verdict()); + assertNull(result.negotiationType()); + assertEquals(1, result.errors().size()); + assertEquals("section.target_result_content", result.errors().get(0).slotName()); + assertEquals( + "negotiation.conclusion_content_mismatch", + result.errors().get(0).code()); + assertEquals("Accept", result.errors().get(0).facts().get("conclusion")); + assertTrue(result.errors().get(0).message().contains("Accept")); + } + + @Test + void unknownCodeIsMappedToTheNegotiationFallbackCode() { + llmClient.payload = "{\"semantic_verdict\":false,\"negotiation_type\":null," + + "\"errors\":[{\"slot_name\":\"section.info_static\",\"code\":\"data_problem\"," + + "\"facts\":{\"reason\":\"irrelevant\"}}],\"params\":{}}"; + + SemanticValidationResult result = + validator.validateNegotiation(VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT); + + assertFalse(result.verdict()); + assertEquals(1, result.errors().size()); + assertEquals("negotiation.rule_violation", result.errors().get(0).code()); + assertEquals("section.info_static", result.errors().get(0).facts().get("section_label")); + assertTrue(result.errors().get(0).message().contains("section.info_static")); + } + + @Test + void llmTransportFailurePropagatesForTheOrchestrationLayerToMap() { + llmClient.failure = new LLMRuntimeError("invocation failed"); + + LLMRuntimeError error = assertThrows( + LLMRuntimeError.class, + () -> validator.validateNegotiation( + VALID_PROMPT, callerSchema, informationReference, TEMPLATE_CONTENT)); + + assertTrue(error.getMessage().contains("invocation failed")); + } + + @Test + void missingPromptResourceForLanguageSurfacesResourceNotFound() { + NegotiationReference unsupportedLanguageReference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "fr-FR"); + + assertThrows( + ResourceNotFoundException.class, + () -> validator.validateNegotiation( + VALID_PROMPT, callerSchema, unsupportedLanguageReference, TEMPLATE_CONTENT)); + assertEquals(0, llmClient.invocations); + } + + private static final class RecordingLLMClient implements LLMClient { + + private String payload = "{}"; + + private RuntimeException failure; + + private int invocations; + + private List> lastMessages; + + private Map lastSchema; + + private Double lastTemperature; + + private Integer lastMaxTokens; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + invocations++; + lastMessages = messages; + lastSchema = jsonSchema; + lastTemperature = temperature; + lastMaxTokens = maxTokens; + if (failure != null) { + throw failure; + } + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class RecordingSchemaBuilder { + + private Map lastCallerSchema; + + public Map buildSemanticValidationSchema(Map callerSchema) { + lastCallerSchema = callerSchema; + return Map.of("merged", true); + } + + @Test + @SuppressWarnings("unchecked") + void semanticValidationSchemaMergesTheCallerSchemaIntoAFourKeyContract() { + Map callerSchema = + Map.of("type", "object", "properties", Map.of("energy_rate", Map.of("type", "number"))); + + Map schema = + DefaultNegotiationSemanticValidator.buildSemanticValidationSchema(callerSchema); + + assertEquals("object", schema.get("type")); + Map properties = (Map) schema.get("properties"); + assertEquals( + List.of("semantic_verdict", "negotiation_type", "errors", "params"), + List.copyOf(properties.keySet())); + assertEquals(List.of("semantic_verdict", "negotiation_type", "errors", "params"), schema.get("required")); + assertEquals(Boolean.FALSE, schema.get("additionalProperties")); + + assertEquals(Map.of("type", "boolean"), properties.get("semantic_verdict")); + + Map negotiationType = (Map) properties.get("negotiation_type"); + assertEquals(List.of("string", "null"), negotiationType.get("type")); + List typeEnum = (List) negotiationType.get("enum"); + assertEquals(4, typeEnum.size()); + assertTrue(typeEnum.contains("information")); + assertTrue(typeEnum.contains("target")); + assertTrue(typeEnum.contains("feasibility")); + assertTrue(typeEnum.contains(null)); + + Map errors = (Map) properties.get("errors"); + assertEquals("array", errors.get("type")); + Map errorItem = (Map) errors.get("items"); + assertEquals( + List.of("slot_name", "code", "facts"), + List.copyOf(((Map) errorItem.get("properties")).keySet())); + assertEquals(List.of("slot_name", "code", "facts"), errorItem.get("required")); + + assertEquals(callerSchema, properties.get("params")); + } + + @Test + void semanticValidationSchemaWrapsCallerSchemaWithoutTypeKeyword() { + Map callerSchema = new java.util.LinkedHashMap<>(); + callerSchema.put("properties", Map.of("id", Map.of("type", "string"))); + + Map schema = + DefaultNegotiationSemanticValidator.buildSemanticValidationSchema(callerSchema); + + Map params = (Map) ((Map) schema.get("properties")).get("params"); + assertEquals("object", params.get("type")); + assertEquals(Map.of("id", Map.of("type", "string")), params.get("properties")); + assertNull(params.get("additionalProperties")); + } + + @Test + void rejectsNullCallerSchema() { + assertEquals( + "Caller parameter schema must not be null.", + assertThrows( + NullPointerException.class, + () -> DefaultNegotiationSemanticValidator.buildSemanticValidationSchema(null)) + .getMessage()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java index 1dd15998..891baf44 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java @@ -1,7 +1,6 @@ package net.openan.a2at.sdk.negotiation.validation; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -95,13 +94,13 @@ void extractRequiresIntegerRoundAndMaxRoundsInMergedData() { @Test void ruleFailureSkipsTemplateLoadingAndTheSemanticValidationCall() { complianceChecker.result = new NegotiationRuleCheckResult( - false, List.of(new SlotValidationError("round", "out_of_range", "round exceeds maxRounds"))); + false, List.of(new SlotValidationError("round", "negotiation.round_exceeded", "round exceeds maxRounds"))); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); - assertEquals("negotiation_rule_violation", exception.getCode()); + assertEquals("negotiation.rule_violation", exception.getCode()); assertEquals(1, exception.getErrors().size()); assertEquals("round", exception.getErrors().get(0).slotName()); assertEquals(0, semanticValidator.invocations); @@ -111,30 +110,28 @@ void ruleFailureSkipsTemplateLoadingAndTheSemanticValidationCall() { @Test void extractFailsOnRuleViolationWithoutTouchingTheValidator() { complianceChecker.result = - new NegotiationRuleCheckResult(false, List.of(new SlotValidationError("id", "invalid_uuid", "not a uuid"))); + new NegotiationRuleCheckResult(false, List.of(new SlotValidationError("id", "negotiation.invalid_context_id", "not a uuid"))); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); - assertEquals("negotiation_rule_violation", exception.getCode()); + assertEquals("negotiation.rule_violation", exception.getCode()); assertEquals("id", exception.getErrors().get(0).slotName()); assertEquals(0, semanticValidator.invocations); assertEquals(0, templateContentLoader.invocations); } @Test - void nullContextFailsAsNonNegotiationInputWithLanguageNeutralMessage() { + void nullContextFailsAsNonNegotiationInputWithARenderedMessage() { complianceChecker.result = new NegotiationRuleCheckResult(false, List.of()); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, () -> extractor.extract("## 任务目标\n诊断\n", null, Map.of(), REFERENCE)); - assertEquals("negotiation_invalid_input", exception.getCode()); - assertEquals( - "missing negotiation context; for Task-T compliance use checkTaskPrompt", exception.getMessage()); - assertFalse(exception.getMessage().contains("协商上下文")); + assertEquals("negotiation.invalid_input", exception.getCode()); + assertEquals("输入的协商内容无效:缺少协商上下文(该报文不是协商报文)", exception.getMessage()); assertEquals(List.of(), exception.getErrors()); assertEquals(0, semanticValidator.invocations); assertEquals(0, complianceChecker.invocations); @@ -145,15 +142,15 @@ void nullContextFailsAsNonNegotiationInputWithLanguageNeutralMessage() { void semanticRejectionPassesErrorsThrough() { complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); List semanticErrors = List.of( - new SlotValidationError("section.target_result_content", "conclusion_content_mismatch", "Mismatch"), - new SlotValidationError("section.context", "invalid_conclusion", "Abort is reserved")); + new SlotValidationError("section.target_result_content", "negotiation.conclusion_content_mismatch", "Mismatch"), + new SlotValidationError("section.context", "negotiation.conclusion_mismatch", "Abort is reserved")); semanticValidator.result = new SemanticValidationResult(false, "target", semanticErrors, Map.of()); NegotiationParamExtractionException exception = assertThrows( NegotiationParamExtractionException.class, () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); - assertEquals("negotiation_semantic_rejected", exception.getCode()); + assertEquals("negotiation.semantic_rejected", exception.getCode()); assertEquals(semanticErrors, exception.getErrors()); } @@ -178,10 +175,11 @@ void internalValidationFailureIsMappedToRetryableInfrastructureError() { NegotiationParamExtractionException.class, () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); - assertEquals("negotiation_llm_infrastructure_error", exception.getCode()); + assertEquals("llm.response_invalid", exception.getCode()); assertEquals(1, exception.getErrors().size()); assertEquals("_llm", exception.getErrors().get(0).slotName()); - assertTrue(exception.getMessage().contains("negotiation_type")); + assertEquals("semantic_validation", exception.getErrors().get(0).facts().get("step")); + assertTrue(exception.getCause().getCause().getMessage().contains("negotiation_type")); } @Test @@ -194,7 +192,7 @@ void promptResourceMissIsMappedToTemplateNotFound() { NegotiationParamExtractionException.class, () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); - assertEquals("template_not_found", exception.getCode()); + assertEquals("template.not_found", exception.getCode()); assertEquals(List.of(), exception.getErrors()); } @@ -209,7 +207,7 @@ void templateLoadFailureIsMappedToTemplateNotFoundWithoutTouchingTheValidator() NegotiationParamExtractionException.class, () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); - assertEquals("template_not_found", exception.getCode()); + assertEquals("template.not_found", exception.getCode()); assertEquals(List.of(), exception.getErrors()); assertEquals(1, templateContentLoader.invocations); assertEquals(REFERENCE, templateContentLoader.lastReference); From 3318dc17e8289775a69f84edbcc1bb12c77a686d Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 29 Aug 2026 13:19:29 +0800 Subject: [PATCH 239/295] refactor(client,server): migrate facades to catalog codes and stop leaking raw errors Client orchestrator failure codes move to scenario.not_matched / template.* / slot.* with rendered messages and the legacy path now catches and maps previously-uncaught exceptions into result failures; server compliance and semantic validators adopt the facts schema, the slot.* domain restriction and input.text_too_long; both facades' javadocs state the new error contract. Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 197 +- .../ClientPromptGenerationOrchestrator.java | 208 +- ...ultClientPromptGenerationOrchestrator.java | 294 ++- .../a2at/sdk/client/api/A2ATClientTest.java | 1598 ++++++------ ...lientPromptGenerationOrchestratorTest.java | 2287 +++++++++-------- .../openan/a2at/sdk/server/A2ATServer.java | 258 +- .../assembly/DefaultA2ATServerBuilder.java | 11 +- ...ultServerPromptComplianceOrchestrator.java | 174 +- .../PromptComplianceCheckException.java | 94 +- .../LlmBackedPromptMetadataExtractor.java | 378 +-- .../InputLimitedContentValidator.java | 112 +- .../LlmBackedPromptSemanticValidator.java | 363 +-- .../server/api/InputTextLengthLimitTest.java | 290 +-- .../TaskPromptNegotiationCoexistenceTest.java | 311 ++- ...verNegotiationOrchestratorBuilderTest.java | 188 +- ...erverPromptComplianceOrchestratorTest.java | 292 +-- .../LlmBackedPromptMetadataExtractorTest.java | 428 +-- .../InputLimitedContentValidatorTest.java | 106 +- .../LlmBackedPromptSemanticValidatorTest.java | 389 +-- 19 files changed, 4327 insertions(+), 3651 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 18ac6793..51165020 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -65,7 +65,7 @@ public A2ATClient(Path envPath) { * @param userInput user-provided task description (text or structured input map); a String input longer than the * configured maximum length ({@code A2AT_INPUT_TEXT_MAX_CHARS}) fails fast without any LLM call * @return prompt generation result containing either rendered prompt text or failure details; a String input over - * the limit yields the failure code {@code input_text_too_long} + * the limit yields the failure code {@code input.text_too_long} */ public PromptGenerationResult generateTaskPrompt(Object userInput) { return promptGenerationOrchestrator.generateTaskPrompt(userInput); @@ -79,10 +79,10 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, - * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails, or the code - * {@code input_text_too_long} when the text exceeds the configured maximum length + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { Objects.requireNonNull(text, "text"); @@ -100,9 +100,9 @@ public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI, data or schema is null * @throws IllegalArgumentException if the schema is empty - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, - * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails */ public MetadataContent generateTaskPromptFromDataWithSchema( @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { @@ -123,10 +123,10 @@ public MetadataContent generateTaskPromptFromDataWithSchema( * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, - * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails, or the code - * {@code input_text_too_long} when the text exceeds the configured maximum length + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { Objects.requireNonNull(text, "text"); @@ -147,9 +147,9 @@ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI, data or schema is null * @throws IllegalArgumentException if the schema is empty - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, - * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails */ public MetadataContent generateAuthPromptFromDataWithSchema( @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { @@ -167,10 +167,10 @@ public MetadataContent generateAuthPromptFromDataWithSchema( * @param templateUri template URI identifying the target template * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, - * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails, or the code - * {@code input_text_too_long} when the text exceeds the configured maximum length + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { Objects.requireNonNull(text, "text"); @@ -188,9 +188,9 @@ public MetadataContent generateNotificationPromptFromText(@NonNull String text, * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI, data or schema is null * @throws IllegalArgumentException if the schema is empty - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template_not_found}, - * {@code prompt_resource_load_error}, {@code slot_schema_not_found}, {@code llm_invocation_failed}, - * {@code render_failed} or {@code slot_validation_error} when generating the prompt fails + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails */ public MetadataContent generateNotificationPromptFromDataWithSchema( @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { @@ -249,8 +249,8 @@ public Map continueNegotiation( * @throws NullPointerException if the data or its context is null * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationProposePromptFromData( @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { @@ -271,8 +271,8 @@ public MetadataContent generateNegotiationProposePromptFromData( * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the * content conclusion is not {@code Accept} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationAcceptPromptFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { @@ -293,8 +293,8 @@ public MetadataContent generateNegotiationAcceptPromptFromData( * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the * content conclusion is not {@code Reject} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationRejectPromptFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { @@ -314,8 +314,8 @@ public MetadataContent generateNegotiationRejectPromptFromData( * @throws IllegalArgumentException if the template URI does not address the common abort template or the * termination reason is blank * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationAbortPromptFromData( @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { @@ -328,23 +328,22 @@ public MetadataContent generateNegotiationAbortPromptFromData( *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. + * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. * * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment * must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context is null * @throws IllegalArgumentException if the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * content contradicts the phase, - * or the code {@code input_text_too_long} when the text exceeds the configured - * maximum length + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the + * phase, or the code {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, @@ -361,20 +360,19 @@ public MetadataContent generateNegotiationProposePromptFromText( * conclusion must be {@code Accept}. * * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context is null * @throws IllegalArgumentException if the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * conclusion is not {@code Accept}, - * or the code {@code input_text_too_long} when the text exceeds the configured - * maximum length + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank or the extracted conclusion is not + * {@code Accept}, or the code {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, @@ -391,20 +389,19 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * conclusion must be {@code Reject}. * * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context is null * @throws IllegalArgumentException if the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, or {@code negotiation_invalid_input} when the text is blank or the extracted - * conclusion is not {@code Reject}, - * or the code {@code input_text_too_long} when the text exceeds the configured - * maximum length + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank or the extracted conclusion is not + * {@code Reject}, or the code {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, @@ -419,21 +416,21 @@ public MetadataContent generateNegotiationRejectPromptFromText( *

        This variant runs one LLM content-extraction step constrained by the common abort template and then renders * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. + * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. * * @param text free-text input stating the termination reason - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context is null * @throws IllegalArgumentException if the template URI does not address the common abort template * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses the termination reason, or {@code negotiation_invalid_input} when the text is blank, - * or the code {@code input_text_too_long} when the text exceeds the configured - * maximum length + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses the + * termination reason, or {@code negotiation.invalid_input} when the text is blank, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, @@ -480,8 +477,8 @@ public Optional getPrompt(@NonNull TemplateUri templateUri) { * failure code. * * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code propose} @@ -489,11 +486,11 @@ public Optional getPrompt(@NonNull TemplateUri templateUri) { * @throws NullPointerException if the prompt or schema is null * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or + * {@code template.not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateProposePromptAndDataFilling( @NonNull String prompt, @@ -506,13 +503,13 @@ public FilledParamData validateProposePromptAndDataFilling( /** * Validates an accept-phase negotiation message and extracts its parameters. * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected - * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must - * satisfy the accept-phase semantic constraints. + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * accept-phase semantic constraints. * * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} @@ -520,11 +517,11 @@ public FilledParamData validateProposePromptAndDataFilling( * @throws NullPointerException if the prompt or schema is null * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or + * {@code template.not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateAcceptPromptAndDataFilling( @NonNull String prompt, @@ -537,13 +534,13 @@ public FilledParamData validateAcceptPromptAndDataFilling( /** * Validates a reject-phase negotiation message and extracts its parameters. * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected - * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must - * satisfy the reject-phase semantic constraints. + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * reject-phase semantic constraints. * * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} @@ -551,11 +548,11 @@ public FilledParamData validateAcceptPromptAndDataFilling( * @throws NullPointerException if the prompt or schema is null * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or + * {@code template.not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateRejectPromptAndDataFilling( @NonNull String prompt, @@ -568,13 +565,13 @@ public FilledParamData validateRejectPromptAndDataFilling( /** * Validates an abort negotiation message and extracts its parameters. * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected - * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the - * abort-phase semantic constraints. + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * abort: the template URI must address the common abort template and the message must satisfy the abort-phase + * semantic constraints. * * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} * @return filled parameter data carrying the context parameters and the extracted parameters @@ -582,11 +579,11 @@ public FilledParamData validateRejectPromptAndDataFilling( * @throws IllegalArgumentException if the prompt is blank, or the template URI does not address the common abort * template * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or + * {@code template.not_found} when the semantic validation prompt resources are missing */ public FilledParamData validateAbortPromptAndDataFilling( @NonNull String prompt, diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java index 420683d6..b73b028f 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java @@ -1,104 +1,104 @@ -package net.openan.a2at.sdk.client.prompt.orchestration; - -import java.util.Map; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; - -/** - * Internal prompt-generation orchestration contract used by the client facade. - * - * @since 2026-06 - */ -public interface ClientPromptGenerationOrchestrator { - - /** - * Generates a processed task prompt from user input. - * - * @param userInput raw or structured user input; a String input longer than the configured maximum length fails - * fast with the code {@code input_text_too_long} - * @return prompt-generation result - */ - PromptGenerationResult generateTaskPrompt(Object userInput); - - /** - * Generates a task prompt with metadata from natural-language input using the template identified by the template - * URI, bypassing scenario recognition. - * - * @param text natural-language task input - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input_text_too_long} - * when the text exceeds the configured maximum length - */ - MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri); - - /** - * Generates a task prompt with metadata from structured input and a data schema using the template identified by - * the template URI, bypassing scenario recognition. - * - * @param data structured task input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty - */ - MetadataContent generateTaskPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri); - - /** - * Generates an authorization prompt with metadata from natural-language input using the template identified by the - * template URI, bypassing scenario recognition. - * - * @param text natural-language authorization input - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input_text_too_long} - * when the text exceeds the configured maximum length - */ - MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri); - - /** - * Generates an authorization prompt with metadata from structured input and a data schema using the template - * identified by the template URI, bypassing scenario recognition. - * - * @param data structured authorization input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty - */ - MetadataContent generateAuthPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri); - - /** - * Generates a notification prompt with metadata from natural-language input using the template identified by the - * template URI, bypassing scenario recognition. - * - * @param text natural-language notification input - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input_text_too_long} - * when the text exceeds the configured maximum length - */ - MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri); - - /** - * Generates a notification prompt with metadata from structured input and a data schema using the template - * identified by the template URI, bypassing scenario recognition. - * - * @param data structured notification input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty - */ - MetadataContent generateNotificationPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri); -} +package net.openan.a2at.sdk.client.prompt.orchestration; + +import java.util.Map; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; + +/** + * Internal prompt-generation orchestration contract used by the client facade. + * + * @since 2026-06 + */ +public interface ClientPromptGenerationOrchestrator { + + /** + * Generates a processed task prompt from user input. + * + * @param userInput raw or structured user input; a String input longer than the configured maximum length fails + * fast with the code {@code input.text_too_long} + * @return prompt-generation result + */ + PromptGenerationResult generateTaskPrompt(Object userInput); + + /** + * Generates a task prompt with metadata from natural-language input using the template identified by the template + * URI, bypassing scenario recognition. + * + * @param text natural-language task input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input.text_too_long} + * when the text exceeds the configured maximum length + */ + MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri); + + /** + * Generates a task prompt with metadata from structured input and a data schema using the template identified by + * the template URI, bypassing scenario recognition. + * + * @param data structured task input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + */ + MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri); + + /** + * Generates an authorization prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + * @param text natural-language authorization input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input.text_too_long} + * when the text exceeds the configured maximum length + */ + MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri); + + /** + * Generates an authorization prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + * @param data structured authorization input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + */ + MetadataContent generateAuthPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri); + + /** + * Generates a notification prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + * @param text natural-language notification input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input.text_too_long} + * when the text exceeds the configured maximum length + */ + MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri); + + /** + * Generates a notification prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + * @param data structured notification input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + */ + MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri); +} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java index 7597a331..5118c867 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.function.Function; @@ -9,7 +10,8 @@ import net.openan.a2at.sdk.client.model.PromptGenerationFailure; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.ExtensionUriConstants; @@ -17,10 +19,14 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMConfigError; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.prompt.analysis.exception.ScenarioRecognitionException; import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; @@ -28,6 +34,9 @@ import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; import net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Minimal runnable client prompt generation orchestrator. @@ -36,6 +45,23 @@ */ public final class DefaultClientPromptGenerationOrchestrator implements ClientPromptGenerationOrchestrator { + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultClientPromptGenerationOrchestrator.class); + + /** Old slot-extraction error code for a required slot that could not be extracted. */ + private static final String LEGACY_CODE_MISSING_INPUT = "missing_input"; + + /** Old slot-extraction error code for a value violating a closed constraint. */ + private static final String LEGACY_CODE_INVALID_VALUE = "invalid_value"; + + /** Step label used when the scenario-recognition LLM call fails. */ + private static final String STEP_SCENARIO_RECOGNITION = "scenario recognition"; + + /** Step label used when the slot-extraction LLM call fails. */ + private static final String STEP_SLOT_EXTRACTION = "slot extraction"; + + private static final List RESPONSE_CONTRACT_MARKERS = List.of( + "returned invalid json", "returned empty content", "must return a json object", "response did not include"); + private final ScenarioRecognizer scenarioRecognizer; private final List scenarios; @@ -132,10 +158,10 @@ public DefaultClientPromptGenerationOrchestrator( @Override public PromptGenerationResult generateTaskPrompt(Object userInput) { if (userInput instanceof String text && InputLimitConfig.isTooLong(text, maxTextChars)) { - return PromptGenerationResult.failure(new PromptGenerationFailure( - A2ATErrorCodes.INPUT_TEXT_TOO_LONG, - InputLimitConfig.violationMessage(text, maxTextChars), - "input")); + return failure( + ErrorCatalog.INPUT_TEXT_TOO_LONG, + Map.of("actual_length", String.valueOf(text.length()), "max_chars", String.valueOf(maxTextChars)), + "input"); } String normalizedInput = String.valueOf(userInput); @@ -143,35 +169,65 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { try { recognition = scenarioRecognizer.recognize(normalizedInput, scenarios, systemPrompt, userPrompt); } catch (ResourceNotFoundException error) { - return PromptGenerationResult.failure(new PromptGenerationFailure( - A2ATErrorCodes.PROMPT_RESOURCE_LOAD_ERROR, error.getMessage(), "generation")); + return failure( + ErrorCatalog.TEMPLATE_LOAD_FAILED, Map.of("resource_path", error.resourcePath()), "generation"); + } catch (ScenarioRecognitionException error) { + return failure(ErrorCatalog.SCENARIO_NOT_MATCHED, Map.of("reason", error.getMessage()), "scenario"); + } catch (LLMError error) { + return PromptGenerationResult.failure(llmFailure(error, STEP_SCENARIO_RECOGNITION)); } if (!recognition.matched() || recognition.scenarioCode() == null || recognition.scenarioCode().isBlank()) { - return PromptGenerationResult.failure(new PromptGenerationFailure( - "scenario_not_matched", - recognition.errorMessage() == null ? "Scenario recognition failed." : recognition.errorMessage(), - "scenario")); + return failure( + ErrorCatalog.SCENARIO_NOT_MATCHED, + Map.of( + "reason", + recognition.errorMessage() == null + ? "Scenario recognition failed." + : recognition.errorMessage()), + "scenario"); } final String templateText; try { templateText = templateLoader.loadTemplate(recognition.scenarioCode(), language); } catch (ResourceNotFoundException error) { - return PromptGenerationResult.failure( - new PromptGenerationFailure(A2ATErrorCodes.TEMPLATE_NOT_FOUND, error.getMessage(), "generation")); + return failure( + ErrorCatalog.TEMPLATE_NOT_FOUND, + Map.of("template_uri", recognition.scenarioCode(), "language", language), + "generation"); + } catch (A2ATError error) { + return failure( + ErrorCatalog.TEMPLATE_LOAD_FAILED, + Map.of("resource_path", recognition.scenarioCode()), + "generation"); } try { StructuredSlotExtractionResult extractionResult = slotValueExtractor.extractSlots(userInput, recognition.scenarioCode(), language); + PromptGenerationFailure slotFailure = slotExtractionFailure(extractionResult.slotErrors()); + if (slotFailure != null) { + return PromptGenerationResult.failure(slotFailure); + } Map slots = extractionResult.slots(); String renderedPrompt = renderer.render(templateText, slots); return PromptGenerationResult.success(renderedPrompt); + } catch (ResourceNotFoundException error) { + return failure( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, + Map.of("template_uri", recognition.scenarioCode(), "language", language), + "generation"); + } catch (LLMError error) { + return PromptGenerationResult.failure(llmFailure(error, STEP_SLOT_EXTRACTION)); } catch (TaskPromptRenderException error) { - return PromptGenerationResult.failure( - new PromptGenerationFailure(A2ATErrorCodes.RENDER_FAILED, error.getMessage(), "generation")); + return failure( + ErrorCatalog.TEMPLATE_RENDER_FAILED, + Map.of("template_uri", recognition.scenarioCode(), "reason", error.getMessage()), + "generation"); + } catch (A2ATError error) { + return failure(ErrorCatalog.LLM_INVOCATION_FAILED, Map.of("reason", error.getMessage()), "generation"); } } @@ -217,12 +273,19 @@ private MetadataContent generateFromTemplateUriWithMetadata( Objects.requireNonNull(userInput, "userInput"); if (InputLimitConfig.isTooLong(userInput, maxTextChars)) { throw new PromptGenerationException( - A2ATErrorCodes.INPUT_TEXT_TOO_LONG, - InputLimitConfig.violationMessage(userInput, maxTextChars)); + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), + render( + ErrorCatalog.INPUT_TEXT_TOO_LONG, + Map.of( + "actual_length", + String.valueOf(userInput.length()), + "max_chars", + String.valueOf(maxTextChars)))); } - return generateWithMetadata(templateUri, extensionUri, templateIdentifier -> slotValueExtractor - .extractSlots(userInput, templateIdentifier, language) - .slots()); + return generateWithMetadata( + templateUri, + extensionUri, + templateIdentifier -> slotValueExtractor.extractSlots(userInput, templateIdentifier, language)); } private MetadataContent generateFromDataWithSchema( @@ -233,37 +296,72 @@ private MetadataContent generateFromDataWithSchema( throw new IllegalArgumentException( "Data schema must not be empty; it describes the meaning of each input field."); } - return generateWithMetadata(templateUri, extensionUri, templateIdentifier -> slotValueExtractor - .extractSlots(data, templateIdentifier, language, schema) - .slots()); + return generateWithMetadata( + templateUri, + extensionUri, + templateIdentifier -> slotValueExtractor.extractSlots(data, templateIdentifier, language, schema)); } private MetadataContent generateWithMetadata( - TemplateUri templateUri, String extensionUri, Function> slotExtractor) { + TemplateUri templateUri, + String extensionUri, + Function slotExtractor) { Objects.requireNonNull(templateUri, "templateUri"); String templateIdentifier = templateUri.uri(); final String templateText; try { templateText = templateLoader.loadTemplate(templateIdentifier, language); } catch (ResourceNotFoundException e) { - throw new PromptGenerationException(A2ATErrorCodes.TEMPLATE_NOT_FOUND, e.getMessage(), e); + throw new PromptGenerationException( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + render( + ErrorCatalog.TEMPLATE_NOT_FOUND, + Map.of("template_uri", templateIdentifier, "language", language)), + e); } catch (A2ATError e) { - throw new PromptGenerationException(A2ATErrorCodes.PROMPT_RESOURCE_LOAD_ERROR, e.getMessage(), e); + throw new PromptGenerationException( + ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode(), + render(ErrorCatalog.TEMPLATE_LOAD_FAILED, Map.of("resource_path", templateIdentifier)), + e); } - final Map slots; + final StructuredSlotExtractionResult extractionResult; try { - slots = slotExtractor.apply(templateIdentifier); + extractionResult = slotExtractor.apply(templateIdentifier); } catch (ResourceNotFoundException e) { - throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, e.getMessage(), e); + throw new PromptGenerationException( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), + render( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, + Map.of("template_uri", templateIdentifier, "language", language)), + e); + } catch (LLMConfigError e) { + throw new PromptGenerationException( + ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), render(ErrorCatalog.LLM_NOT_CONFIGURED, Map.of()), e); + } catch (LLMError e) { + throw llmException(e, STEP_SLOT_EXTRACTION); } catch (A2ATError e) { - throw new PromptGenerationException(A2ATErrorCodes.LLM_INVOCATION_FAILED, e.getMessage(), e); + throw new PromptGenerationException( + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + render(ErrorCatalog.LLM_INVOCATION_FAILED, Map.of("reason", e.getMessage())), + e); + } + List extractionErrors = mapSlotErrors(extractionResult.slotErrors()); + if (!extractionErrors.isEmpty()) { + throw new PromptGenerationException( + extractionErrors.get(0).code(), joinMessages(extractionErrors), extractionErrors); } + Map slots = extractionResult.slots(); validateRequiredSlots(slots, templateIdentifier); final String renderedPrompt; try { renderedPrompt = renderer.render(templateText, slots); } catch (TaskPromptRenderException e) { - throw new PromptGenerationException(A2ATErrorCodes.RENDER_FAILED, e.getMessage(), e); + throw new PromptGenerationException( + ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode(), + render( + ErrorCatalog.TEMPLATE_RENDER_FAILED, + Map.of("template_uri", templateIdentifier, "reason", e.getMessage())), + e); } return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); } @@ -273,11 +371,19 @@ private void validateRequiredSlots(Map slots, String templateIde try { schema = slotSchemaLoader.loadSlotSchema(templateIdentifier, language); } catch (A2ATError e) { - throw new PromptGenerationException(A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, e.getMessage(), e); + throw new PromptGenerationException( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), + render( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, + Map.of("template_uri", templateIdentifier, "language", language)), + e); } if (schema == null) { throw new PromptGenerationException( - A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, "Slot schema not found for template: " + templateIdentifier); + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), + render( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, + Map.of("template_uri", templateIdentifier, "language", language))); } List defs = schema.slotDefinitions(); if (defs == null) { @@ -299,16 +405,126 @@ private void validateRequiredSlots(Map slots, String templateIde } String value = effectiveSlots.get(name); if (value == null || value.trim().isEmpty()) { - failed.add(new SlotValidationError(name, "missing_required", "Required slot is missing or empty")); + failed.add(notProvidedError(name, slotLabel(def))); } } } if (!failed.isEmpty()) { - throw new PromptGenerationException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - "Required slots are missing or empty: " - + failed.stream().map(SlotValidationError::slotName).collect(Collectors.joining(", ")), - failed); + throw new PromptGenerationException(failed.get(0).code(), joinMessages(failed), failed); + } + } + + /** + * Maps the extraction-time slot errors reported by the slot-extraction step to catalog errors. + * + *

        Unknown codes returned by the LLM step are mapped to the {@code slot.rule_violation} fallback and logged. + * + * @param slotErrors extraction-time slot errors, may be null + * @return mapped slot validation errors, possibly empty, never null + */ + private List mapSlotErrors(@Nullable List slotErrors) { + if (slotErrors == null || slotErrors.isEmpty()) { + return List.of(); + } + List mapped = new ArrayList<>(); + for (StructuredSlotValidationError error : slotErrors) { + if (error == null) { + continue; + } + mapped.add(mapSlotError(error)); } + return mapped; + } + + private SlotValidationError mapSlotError(StructuredSlotValidationError error) { + String label = error.slotName() == null ? "" : error.slotName(); + ErrorCatalog entry; + if (ErrorCatalog.SLOT_NOT_PROVIDED.getCode().equals(error.code()) + || LEGACY_CODE_MISSING_INPUT.equals(error.code())) { + entry = ErrorCatalog.SLOT_NOT_PROVIDED; + } else if (ErrorCatalog.SLOT_CONSTRAINT_VIOLATED.getCode().equals(error.code()) + || LEGACY_CODE_INVALID_VALUE.equals(error.code())) { + entry = ErrorCatalog.SLOT_CONSTRAINT_VIOLATED; + } else { + LOGGER.warn( + "Unknown slot-extraction error code '{}' mapped to '{}'.", + error.code(), + ErrorCatalog.SLOT_RULE_VIOLATION.getCode()); + entry = ErrorCatalog.SLOT_RULE_VIOLATION; + } + Map facts = Map.of("slot_label", label); + return new SlotValidationError(label, entry.getCode(), render(entry, facts), facts); + } + + private SlotValidationError notProvidedError(String slotName, String slotLabel) { + Map facts = Map.of("slot_label", slotLabel); + return new SlotValidationError( + slotName, + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + render(ErrorCatalog.SLOT_NOT_PROVIDED, facts), + facts); + } + + private String slotLabel(PromptSlotDefinition definition) { + if (definition.description() != null && !definition.description().isBlank()) { + return definition.description(); + } + return definition.name() == null ? "" : definition.name(); + } + + private @Nullable PromptGenerationFailure slotExtractionFailure( + @Nullable List slotErrors) { + List mapped = mapSlotErrors(slotErrors); + if (mapped.isEmpty()) { + return null; + } + return new PromptGenerationFailure(mapped.get(0).code(), joinMessages(mapped), "generation"); + } + + private PromptGenerationFailure llmFailure(LLMError error, String step) { + if (error instanceof LLMConfigError) { + return new PromptGenerationFailure( + ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), + render(ErrorCatalog.LLM_NOT_CONFIGURED, Map.of()), + "generation"); + } + if (isResponseContractViolation(error)) { + return new PromptGenerationFailure( + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + render(ErrorCatalog.LLM_RESPONSE_INVALID, Map.of("step", step)), + "generation"); + } + return new PromptGenerationFailure( + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + render(ErrorCatalog.LLM_INVOCATION_FAILED, Map.of("reason", error.getMessage())), + "generation"); + } + + private PromptGenerationException llmException(LLMError error, String step) { + PromptGenerationFailure failure = llmFailure(error, step); + return new PromptGenerationException(failure.code(), failure.message(), error); + } + + private boolean isResponseContractViolation(LLMError error) { + String message = error.getMessage() == null ? "" : error.getMessage().toLowerCase(Locale.ROOT); + for (String marker : RESPONSE_CONTRACT_MARKERS) { + if (message.contains(marker)) { + return true; + } + } + return false; + } + + private String joinMessages(List errors) { + return errors.stream().map(SlotValidationError::message).collect(Collectors.joining("; ")); + } + + private String render(ErrorCatalog entry, Map facts) { + return ErrorMessages.render(entry, language, facts); + } + + private PromptGenerationResult failure(ErrorCatalog entry, Map facts, String stage) { + return PromptGenerationResult.failure( + new PromptGenerationFailure(entry.getCode(), render(entry, facts), stage)); } } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index 94835508..e56b0037 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -1,800 +1,798 @@ -package net.openan.a2at.sdk.client; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; -import net.openan.a2at.sdk.core.model.ExtensionUriConstants; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class A2ATClientTest { - - private static final String TEST_MOCK_PROVIDER = "test-a2at-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); - } - } - - @BeforeEach - void resetRecordingClientState() { - RecordingClient.SLOT_OVERRIDES.clear(); - RecordingClient.REQUEST_COUNT.set(0); - } - - @Test - void onlyExposesPathBasedPublicConstructor() throws NoSuchMethodException { - assertNotNull(A2ATClient.class.getConstructor(Path.class)); - assertEquals(1, A2ATClient.class.getConstructors().length); - assertEquals(1, A2ATClient.class.getDeclaredConstructors().length); - } - - @Test - void promptGenerationResultDoesNotExposeScenarioValidationOrSlotsFields() { - assertThrows(NoSuchMethodException.class, () -> PromptGenerationResult.class.getMethod("scenarioCode")); - assertThrows(NoSuchMethodException.class, () -> PromptGenerationResult.class.getMethod("validation")); - assertThrows(NoSuchMethodException.class, () -> PromptGenerationResult.class.getMethod("slots")); - } - - @Test - void noLongerExposesLowLevelPromptAndNegotiationConstructor() { - assertThrows( - NoSuchMethodException.class, - () -> A2ATClient.class.getDeclaredConstructor( - ClientPromptGenerationOrchestrator.class, - net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator.class)); - } - - @Test - void noLongerExposesLowLevelPromptGenerationAssemblyConstructors() { - assertThrows( - NoSuchMethodException.class, - () -> A2ATClient.class.getDeclaredConstructor( - net.openan.a2at.sdk.llm.LLMClient.class, - net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator.class, - java.util.List.class, - String.class, - String.class, - String.class, - String.class, - String.class)); - assertThrows( - NoSuchMethodException.class, - () -> A2ATClient.class.getDeclaredConstructor( - net.openan.a2at.sdk.llm.LLMClient.class, - net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator.class, - java.util.List.class, - net.openan.a2at.sdk.core.model.PromptGenerationConfig.class)); - } - - @Test - void doesNotKeepStaticAssemblyHelpersInsideFacade() { - long staticMethodCount = Arrays.stream(A2ATClient.class.getDeclaredMethods()) - .filter(method -> java.lang.reflect.Modifier.isStatic(method.getModifiers())) - .count(); - - assertEquals(0, staticMethodCount); - } - - @Test - void pathBasedConstructorLoadsLocalPromptResources() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); - RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); - A2ATClient client = new A2ATClient(envFile); - - PromptGenerationResult result = - client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); - - assertTrue(result.success()); - assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); - assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); - } - - @Test - void scenarioRecognitionShortCircuitsWhenSingleScenarioIsConfigured() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); - RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); - A2ATClient client = new A2ATClient(envFile); - - PromptGenerationResult result = - client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); - - assertTrue(result.success()); - assertEquals( - 1, - RecordingClient.REQUEST_COUNT.get(), - "Single scenario must short-circuit recognition — only extraction LLM call expected"); - } - - @Test - void scenarioRecognitionDelegatesToWrappedRecognizerWhenMultipleScenariosConfigured() throws IOException { - Path envFile = writeMinimalMultiScenarioClientEnv(); - RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); - RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); - A2ATClient client = new A2ATClient(envFile); - - PromptGenerationResult result = - client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); - - assertTrue(result.success()); - assertEquals( - 2, - RecordingClient.REQUEST_COUNT.get(), - "Multiple scenarios must delegate to wrapped recognizer — recognition + extraction calls expected"); - } - - @Test - void pathBasedConstructorAcceptsRelativeEnvPath() throws IOException { - Path targetDir = Files.createDirectories(Path.of("target")); - Path tempDir = Files.createTempDirectory(targetDir, "a2at-client-relative-env"); - writeMinimalClientEnv(tempDir, TEST_MOCK_PROVIDER); - Path relativeEnvPath = - targetDir.getFileName().resolve(tempDir.getFileName()).resolve("client.env"); - - RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); - RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); - A2ATClient client = new A2ATClient(relativeEnvPath); - PromptGenerationResult result = - client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); - - assertTrue(result.success()); - assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); - assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); - } - - @Test - void pathBasedConstructorLoadsClasspathPromptResources() throws IOException { - Path envFile = writeMinimalClasspathClientEnv(); - - A2ATClient client = new A2ATClient(envFile); - - assertNotNull(client); - } - - @Test - void pathBasedConstructorAcceptsOpenAiProvider() throws IOException { - Path envFile = writeMinimalClientEnv(TEST_MOCK_PROVIDER); - - RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); - RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); - A2ATClient client = new A2ATClient(envFile); - PromptGenerationResult result = - client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); - - assertTrue(result.success()); - assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); - assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); - } - - @Test - void pathBasedConstructorBuildsDefaultNegotiationRuntime() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - - Map startResult = - client.startNegotiation(NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); - @SuppressWarnings("unchecked") - Map startData = (Map) - startResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_T_URI_NL); - - Map continueResult = client.continueNegotiation( - new NegotiationContext( - NegotiationType.TARGET, - String.valueOf(startData.get("negotiationId")), - 1, - NegotiationStatus.IN_PROGRESS), - NegotiationStatus.IN_PROGRESS, - "Site A"); - @SuppressWarnings("unchecked") - Map continueData = (Map) - continueResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_T_URI_NL); - - assertEquals("Please clarify the target.", startData.get("message")); - assertEquals("Site A", continueData.get("message")); - } - - @Test - void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-client-private-line"); - Path promptRoot = tempDir.resolve("prompt_resources"); - Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); - Path templatesDir = promptRoot - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("private-line-complaint") - .resolve("v1") - .resolve("zh-CN"); - Path slotsDir = promptRoot - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("private-line-complaint") - .resolve("v1") - .resolve("zh-CN"); - Files.createDirectories(scenariosDir); - Files.createDirectories(templatesDir); - Files.createDirectories(slotsDir); - - Path scenarioPromptDir = - promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); - Path slotPromptDir = - promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); - Files.createDirectories(scenarioPromptDir); - Files.createDirectories(slotPromptDir); - Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); - Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); - Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); - Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); - - Files.writeString( - scenariosDir.resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "private-line-complaint", - "scenario_name": "Private Line Complaint", - "description": "Complaint analysis", - "example": "Analyze private line fault" - } - ] - } - """); - Files.writeString(templatesDir.resolve("template.md"), "Line: {line_id}\\nFault: {fault_id}"); - Files.writeString( - slotsDir.resolve("slot.json"), - """ - { - "required": ["line_id", "fault_id"], - "properties": { - "line_id": { - "type": "string" - }, - "fault_id": { - "type": "string" - } - } - } - """); - - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=local_file - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources -A2AT_LLM_PROVIDER=%s -A2AT_LLM_MODEL=example-model -A2AT_LLM_BASE_URL=https://llm.example.test/v1 -A2AT_LLM_API_KEY=test-key -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(TEST_MOCK_PROVIDER)); - - RecordingClient.SLOT_OVERRIDES.put("line_id", "line-1"); - RecordingClient.SLOT_OVERRIDES.put("fault_id", "fault-9"); - A2ATClient client = new A2ATClient(envFile); - PromptGenerationResult result = client.generateTaskPrompt(Map.of("line_id", "line-1", "fault_id", "fault-9")); - - assertTrue(result.success()); - assertEquals("Line: line-1\\nFault: fault-9", result.promptText()); - assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); - } - - @Test - void fromTextEntryPointsReturnMetadataContent() throws IOException { - Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); - A2ATClient client = new A2ATClient(envFile); - TemplateUri unconstrained = TemplateUri.of("Task-T", "network-layer", "unconstrained"); - - MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); - MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); - MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", unconstrained); - - assertNotNull(taskResult); - assertEquals("Task-T/network-layer/unconstrained/v1", taskResult.templateUri()); - assertNotNull(taskResult.promptText()); - assertNotNull(taskResult.extensionUri()); - - assertNotNull(authResult); - assertEquals("Task-T/network-layer/unconstrained/v1", authResult.templateUri()); - assertNotNull(authResult.promptText()); - assertNotNull(authResult.extensionUri()); - - assertNotNull(notificationResult); - assertEquals("Task-T/network-layer/unconstrained/v1", notificationResult.templateUri()); - assertNotNull(notificationResult.promptText()); - assertNotNull(notificationResult.extensionUri()); - } - - @Test - void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); - Map schema = Map.of("type", "object"); - - MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); - MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); - MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); - - assertNotNull(taskResult); - assertEquals(StandardTemplates.ENERGY_SAVING.uri(), taskResult.templateUri()); - assertNotNull(taskResult.promptText()); - assertNotNull(taskResult.extensionUri()); - - assertNotNull(authResult); - assertEquals(StandardTemplates.ENERGY_SAVING.uri(), authResult.templateUri()); - assertNotNull(authResult.promptText()); - assertNotNull(authResult.extensionUri()); - - assertNotNull(notificationResult); - assertEquals(StandardTemplates.ENERGY_SAVING.uri(), notificationResult.templateUri()); - assertNotNull(notificationResult.promptText()); - assertNotNull(notificationResult.extensionUri()); - } - - @Test - void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { - Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); - A2ATClient client = new A2ATClient(envFile); - TemplateUri unconstrained = TemplateUri.of("Task-T", "network-layer", "unconstrained"); - - MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); - MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); - MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", unconstrained); - - assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); - assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); - assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notificationResult.extensionUri()); - } - - @Test - void fromDataWithSchemaReturnsCorrectExtensionUriPerContentType() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); - Map schema = Map.of("type", "object"); - - MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); - MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); - MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); - - assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); - assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); - assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notificationResult.extensionUri()); - } - - @Test - void fromTextThrowsOnNullTemplateUri() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - - NullPointerException taskEx = assertThrows( - NullPointerException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", null)); - assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); - NullPointerException authEx = assertThrows( - NullPointerException.class, () -> client.generateAuthPromptFromText("Authorize access.", null)); - assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); - NullPointerException notifEx = assertThrows( - NullPointerException.class, () -> client.generateNotificationPromptFromText("Report finished.", null)); - assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); - } - - @Test - void fromDataWithSchemaThrowsOnNullTemplateUri() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - Map data = Map.of("site", "Site A"); - Map schema = Map.of("type", "object"); - - NullPointerException taskEx = assertThrows( - NullPointerException.class, () -> client.generateTaskPromptFromDataWithSchema(data, schema, null)); - assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); - NullPointerException authEx = assertThrows( - NullPointerException.class, () -> client.generateAuthPromptFromDataWithSchema(data, schema, null)); - assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); - NullPointerException notifEx = assertThrows( - NullPointerException.class, - () -> client.generateNotificationPromptFromDataWithSchema(data, schema, null)); - assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); - } - - @Test - void fromTextRejectsOverLimitTextWhenConfiguredWithSmallLimit() throws IOException { - Path envFile = writeMinimalClientEnvWithInputLimit(TEST_MOCK_PROVIDER, 100); - A2ATClient client = new A2ATClient(envFile); - String overLimitText = "a".repeat(101); - - PromptGenerationException taskEx = assertThrows( - PromptGenerationException.class, - () -> client.generateTaskPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); - PromptGenerationException authEx = assertThrows( - PromptGenerationException.class, - () -> client.generateAuthPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); - PromptGenerationException notificationEx = assertThrows( - PromptGenerationException.class, - () -> client.generateNotificationPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); - - assertEquals("input_text_too_long", taskEx.getCode()); - assertEquals("input_text_too_long", authEx.getCode()); - assertEquals("input_text_too_long", notificationEx.getCode()); - assertEquals(0, RecordingClient.REQUEST_COUNT.get(), "over-limit input must fail before any LLM call"); - } - - @Test - void generateTaskPromptRejectsOverLimitStringInputWithoutLlmCall() throws IOException { - Path envFile = writeMinimalClientEnvWithInputLimit(TEST_MOCK_PROVIDER, 100); - A2ATClient client = new A2ATClient(envFile); - - PromptGenerationResult result = client.generateTaskPrompt("a".repeat(101)); - - assertFalse(result.success()); - assertEquals("input_text_too_long", result.failure().code()); - assertEquals(0, RecordingClient.REQUEST_COUNT.get(), "over-limit input must fail before any LLM call"); - } - - private static Path writeMinimalClientEnvWithInputLimit(String provider, int maxTextChars) throws IOException { - Path envFile = writeMinimalClientEnv(provider); - Files.writeString( - envFile, - "A2AT_INPUT_TEXT_MAX_CHARS=" + maxTextChars + "\n", - java.nio.file.StandardOpenOption.APPEND); - return envFile; - } - - private static Path writeMinimalClientEnvWithoutRequiredSlots(String provider) throws IOException { - Path tempDir = Files.createTempDirectory("a2at-client-env-no-required"); - Path promptRoot = tempDir.resolve("prompt_resources"); - Path scenarioPromptDir = - promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); - Path slotPromptDir = - promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); - Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); - Path templatesDir = promptRoot - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("unconstrained") - .resolve("v1") - .resolve("zh-CN"); - Path slotsDir = promptRoot - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("unconstrained") - .resolve("v1") - .resolve("zh-CN"); - Files.createDirectories(scenarioPromptDir); - Files.createDirectories(slotPromptDir); - Files.createDirectories(scenariosDir); - Files.createDirectories(templatesDir); - Files.createDirectories(slotsDir); - - Files.writeString( - scenariosDir.resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "unconstrained", - "scenario_name": "Unconstrained", - "description": "No required slots", - "example": "Analyze unconstrained" - } - ] - } - """); - Files.writeString(templatesDir.resolve("template.md"), "Notes: {notes}\\nFault: {fault_description}"); - Files.writeString( - slotsDir.resolve("slot.json"), - """ - { - "required": [], - "properties": { - "notes": { - "type": "string" - }, - "fault_description": { - "type": "string" - } - } - } - """); - Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); - Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); - Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); - Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); - - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=local_file - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=%s - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(provider)); - return envFile; - } - - private static Path writeMinimalLocalClientEnv() throws IOException { - return writeMinimalClientEnv(TEST_MOCK_PROVIDER); - } - - private static Path writeMinimalClasspathClientEnv() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-client-classpath-env"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=example-model -A2AT_LLM_BASE_URL=https://llm.example.test/v1 -A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """); - return envFile; - } - - private static Path writeMinimalClientEnv(String provider) throws IOException { - return writeMinimalClientEnv(Files.createTempDirectory("a2at-client-env"), provider); - } - - private static Path writeMinimalClientEnv(Path tempDir, String provider) throws IOException { - Path promptRoot = tempDir.resolve("prompt_resources"); - Path scenarioPromptDir = - promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); - Path slotPromptDir = - promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); - Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); - Path templatesDir = promptRoot - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("ran-energy-saving") - .resolve("v1") - .resolve("zh-CN"); - Path slotsDir = promptRoot - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("ran-energy-saving") - .resolve("v1") - .resolve("zh-CN"); - Files.createDirectories(scenarioPromptDir); - Files.createDirectories(slotPromptDir); - Files.createDirectories(scenariosDir); - Files.createDirectories(templatesDir); - Files.createDirectories(slotsDir); - - Files.writeString( - scenariosDir.resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "ran-energy-saving", - "scenario_name": "Energy Saving", - "description": "Energy analysis", - "example": "Analyze site power" - } - ] - } - """); - Files.writeString(templatesDir.resolve("template.md"), "Site: {site}\\nTarget: {target}"); - Files.writeString( - slotsDir.resolve("slot.json"), - """ - { - "required": ["site", "target"], - "properties": { - "site": { - "type": "string" - }, - "target": { - "type": "string" - } - } - } - """); - Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); - Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); - Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); - Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); - - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=local_file - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=%s - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(provider)); - return envFile; - } - - private static Path writeMinimalMultiScenarioClientEnv() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-client-multi-scenario"); - Path promptRoot = tempDir.resolve("prompt_resources"); - Path scenarioPromptDir = - promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); - Path slotPromptDir = - promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); - Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); - Path templatesDir = promptRoot - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("ran-energy-saving") - .resolve("v1") - .resolve("zh-CN"); - Path slotsDir = promptRoot - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("ran-energy-saving") - .resolve("v1") - .resolve("zh-CN"); - Files.createDirectories(scenarioPromptDir); - Files.createDirectories(slotPromptDir); - Files.createDirectories(scenariosDir); - Files.createDirectories(templatesDir); - Files.createDirectories(slotsDir); - - Files.writeString( - scenariosDir.resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "ran-energy-saving", - "scenario_name": "Energy Saving", - "description": "Energy analysis", - "example": "Analyze site power" - }, - { - "scenario_code": "data-report", - "scenario_name": "Data Report", - "description": "Report generation", - "example": "Generate report" - } - ] - } - """); - Files.writeString(templatesDir.resolve("template.md"), "Site: {site}\\nTarget: {target}"); - Files.writeString( - slotsDir.resolve("slot.json"), - """ - { - "required": ["site", "target"], - "properties": { - "site": { - "type": "string" - }, - "target": { - "type": "string" - } - } - } - """); - Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); - Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); - Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); - Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); - - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=local_file - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=%s - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(TEST_MOCK_PROVIDER)); - return envFile; - } - - public static final class RecordingClient implements LLMClient { - - public static final Map SLOT_OVERRIDES = new ConcurrentHashMap<>(); - public static final AtomicInteger REQUEST_COUNT = new AtomicInteger(0); - - private final LLMClientConfig config; - - public RecordingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - REQUEST_COUNT.incrementAndGet(); - return new LLMResponse(buildResponse(jsonSchema), config.model(), Map.of(), Map.of()); - } - - private static String buildResponse(Map jsonSchema) { - Object slotNames = jsonSchema.get("slotNames"); - if (slotNames instanceof List names) { - StringBuilder slots = new StringBuilder("{"); - for (int i = 0; i < names.size(); i++) { - if (i > 0) { - slots.append(","); - } - String name = String.valueOf(names.get(i)); - String value = SLOT_OVERRIDES.getOrDefault(name, "placeholder"); - slots.append("\"") - .append(name) - .append("\":\"") - .append(value) - .append("\""); - } - slots.append("}"); - return "{\"slots\":" + slots + ",\"slot_errors\":[]}"; - } - return "{\"matched\":true,\"scenario_code\":\"ran-energy-saving\",\"error_message\":null}"; - } - } -} +package net.openan.a2at.sdk.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class A2ATClientTest { + + private static final String TEST_MOCK_PROVIDER = "test-a2at-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + @BeforeEach + void resetRecordingClientState() { + RecordingClient.SLOT_OVERRIDES.clear(); + RecordingClient.REQUEST_COUNT.set(0); + } + + @Test + void onlyExposesPathBasedPublicConstructor() throws NoSuchMethodException { + assertNotNull(A2ATClient.class.getConstructor(Path.class)); + assertEquals(1, A2ATClient.class.getConstructors().length); + assertEquals(1, A2ATClient.class.getDeclaredConstructors().length); + } + + @Test + void promptGenerationResultDoesNotExposeScenarioValidationOrSlotsFields() { + assertThrows(NoSuchMethodException.class, () -> PromptGenerationResult.class.getMethod("scenarioCode")); + assertThrows(NoSuchMethodException.class, () -> PromptGenerationResult.class.getMethod("validation")); + assertThrows(NoSuchMethodException.class, () -> PromptGenerationResult.class.getMethod("slots")); + } + + @Test + void noLongerExposesLowLevelPromptAndNegotiationConstructor() { + assertThrows( + NoSuchMethodException.class, + () -> A2ATClient.class.getDeclaredConstructor( + ClientPromptGenerationOrchestrator.class, + net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator.class)); + } + + @Test + void noLongerExposesLowLevelPromptGenerationAssemblyConstructors() { + assertThrows( + NoSuchMethodException.class, + () -> A2ATClient.class.getDeclaredConstructor( + net.openan.a2at.sdk.llm.LLMClient.class, + net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator.class, + java.util.List.class, + String.class, + String.class, + String.class, + String.class, + String.class)); + assertThrows( + NoSuchMethodException.class, + () -> A2ATClient.class.getDeclaredConstructor( + net.openan.a2at.sdk.llm.LLMClient.class, + net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator.class, + java.util.List.class, + net.openan.a2at.sdk.core.model.PromptGenerationConfig.class)); + } + + @Test + void doesNotKeepStaticAssemblyHelpersInsideFacade() { + long staticMethodCount = Arrays.stream(A2ATClient.class.getDeclaredMethods()) + .filter(method -> java.lang.reflect.Modifier.isStatic(method.getModifiers())) + .count(); + + assertEquals(0, staticMethodCount); + } + + @Test + void pathBasedConstructorLoadsLocalPromptResources() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult result = + client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); + + assertTrue(result.success()); + assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); + assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); + } + + @Test + void scenarioRecognitionShortCircuitsWhenSingleScenarioIsConfigured() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult result = + client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); + + assertTrue(result.success()); + assertEquals( + 1, + RecordingClient.REQUEST_COUNT.get(), + "Single scenario must short-circuit recognition — only extraction LLM call expected"); + } + + @Test + void scenarioRecognitionDelegatesToWrappedRecognizerWhenMultipleScenariosConfigured() throws IOException { + Path envFile = writeMinimalMultiScenarioClientEnv(); + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult result = + client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); + + assertTrue(result.success()); + assertEquals( + 2, + RecordingClient.REQUEST_COUNT.get(), + "Multiple scenarios must delegate to wrapped recognizer — recognition + extraction calls expected"); + } + + @Test + void pathBasedConstructorAcceptsRelativeEnvPath() throws IOException { + Path targetDir = Files.createDirectories(Path.of("target")); + Path tempDir = Files.createTempDirectory(targetDir, "a2at-client-relative-env"); + writeMinimalClientEnv(tempDir, TEST_MOCK_PROVIDER); + Path relativeEnvPath = + targetDir.getFileName().resolve(tempDir.getFileName()).resolve("client.env"); + + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); + A2ATClient client = new A2ATClient(relativeEnvPath); + PromptGenerationResult result = + client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); + + assertTrue(result.success()); + assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); + assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); + } + + @Test + void pathBasedConstructorLoadsClasspathPromptResources() throws IOException { + Path envFile = writeMinimalClasspathClientEnv(); + + A2ATClient client = new A2ATClient(envFile); + + assertNotNull(client); + } + + @Test + void pathBasedConstructorAcceptsOpenAiProvider() throws IOException { + Path envFile = writeMinimalClientEnv(TEST_MOCK_PROVIDER); + + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); + A2ATClient client = new A2ATClient(envFile); + PromptGenerationResult result = + client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); + + assertTrue(result.success()); + assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); + assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); + } + + @Test + void pathBasedConstructorBuildsDefaultNegotiationRuntime() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + Map startResult = + client.startNegotiation(NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); + @SuppressWarnings("unchecked") + Map startData = (Map) + startResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_T_URI_NL); + + Map continueResult = client.continueNegotiation( + new NegotiationContext( + NegotiationType.TARGET, + String.valueOf(startData.get("negotiationId")), + 1, + NegotiationStatus.IN_PROGRESS), + NegotiationStatus.IN_PROGRESS, + "Site A"); + @SuppressWarnings("unchecked") + Map continueData = (Map) + continueResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_T_URI_NL); + + assertEquals("Please clarify the target.", startData.get("message")); + assertEquals("Site A", continueData.get("message")); + } + + @Test + void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-private-line"); + Path promptRoot = tempDir.resolve("prompt_resources"); + Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); + Path templatesDir = promptRoot + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("private-line-complaint") + .resolve("v1") + .resolve("zh-CN"); + Path slotsDir = promptRoot + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("private-line-complaint") + .resolve("v1") + .resolve("zh-CN"); + Files.createDirectories(scenariosDir); + Files.createDirectories(templatesDir); + Files.createDirectories(slotsDir); + + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); + Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); + Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); + + Files.writeString( + scenariosDir.resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "private-line-complaint", + "scenario_name": "Private Line Complaint", + "description": "Complaint analysis", + "example": "Analyze private line fault" + } + ] + } + """); + Files.writeString(templatesDir.resolve("template.md"), "Line: {line_id}\\nFault: {fault_id}"); + Files.writeString( + slotsDir.resolve("slot.json"), + """ + { + "required": ["line_id", "fault_id"], + "properties": { + "line_id": { + "type": "string" + }, + "fault_id": { + "type": "string" + } + } + } + """); + + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources +A2AT_LLM_PROVIDER=%s +A2AT_LLM_MODEL=example-model +A2AT_LLM_BASE_URL=https://llm.example.test/v1 +A2AT_LLM_API_KEY=test-key +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(TEST_MOCK_PROVIDER)); + + RecordingClient.SLOT_OVERRIDES.put("line_id", "line-1"); + RecordingClient.SLOT_OVERRIDES.put("fault_id", "fault-9"); + A2ATClient client = new A2ATClient(envFile); + PromptGenerationResult result = client.generateTaskPrompt(Map.of("line_id", "line-1", "fault_id", "fault-9")); + + assertTrue(result.success()); + assertEquals("Line: line-1\\nFault: fault-9", result.promptText()); + assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); + } + + @Test + void fromTextEntryPointsReturnMetadataContent() throws IOException { + Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); + A2ATClient client = new A2ATClient(envFile); + TemplateUri unconstrained = TemplateUri.of("Task-T", "network-layer", "unconstrained"); + + MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); + MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); + MetadataContent notificationResult = + client.generateNotificationPromptFromText("Report finished.", unconstrained); + + assertNotNull(taskResult); + assertEquals("Task-T/network-layer/unconstrained/v1", taskResult.templateUri()); + assertNotNull(taskResult.promptText()); + assertNotNull(taskResult.extensionUri()); + + assertNotNull(authResult); + assertEquals("Task-T/network-layer/unconstrained/v1", authResult.templateUri()); + assertNotNull(authResult.promptText()); + assertNotNull(authResult.extensionUri()); + + assertNotNull(notificationResult); + assertEquals("Task-T/network-layer/unconstrained/v1", notificationResult.templateUri()); + assertNotNull(notificationResult.promptText()); + assertNotNull(notificationResult.extensionUri()); + } + + @Test + void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); + Map schema = Map.of("type", "object"); + + MetadataContent taskResult = + client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); + MetadataContent authResult = + client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); + MetadataContent notificationResult = + client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); + + assertNotNull(taskResult); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), taskResult.templateUri()); + assertNotNull(taskResult.promptText()); + assertNotNull(taskResult.extensionUri()); + + assertNotNull(authResult); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), authResult.templateUri()); + assertNotNull(authResult.promptText()); + assertNotNull(authResult.extensionUri()); + + assertNotNull(notificationResult); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), notificationResult.templateUri()); + assertNotNull(notificationResult.promptText()); + assertNotNull(notificationResult.extensionUri()); + } + + @Test + void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { + Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); + A2ATClient client = new A2ATClient(envFile); + TemplateUri unconstrained = TemplateUri.of("Task-T", "network-layer", "unconstrained"); + + MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); + MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); + MetadataContent notificationResult = + client.generateNotificationPromptFromText("Report finished.", unconstrained); + + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notificationResult.extensionUri()); + } + + @Test + void fromDataWithSchemaReturnsCorrectExtensionUriPerContentType() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); + Map schema = Map.of("type", "object"); + + MetadataContent taskResult = + client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); + MetadataContent authResult = + client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); + MetadataContent notificationResult = + client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); + + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notificationResult.extensionUri()); + } + + @Test + void fromTextThrowsOnNullTemplateUri() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + NullPointerException taskEx = assertThrows( + NullPointerException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", null)); + assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException authEx = assertThrows( + NullPointerException.class, () -> client.generateAuthPromptFromText("Authorize access.", null)); + assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException notifEx = assertThrows( + NullPointerException.class, () -> client.generateNotificationPromptFromText("Report finished.", null)); + assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); + } + + @Test + void fromDataWithSchemaThrowsOnNullTemplateUri() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A"); + Map schema = Map.of("type", "object"); + + NullPointerException taskEx = assertThrows( + NullPointerException.class, () -> client.generateTaskPromptFromDataWithSchema(data, schema, null)); + assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException authEx = assertThrows( + NullPointerException.class, () -> client.generateAuthPromptFromDataWithSchema(data, schema, null)); + assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException notifEx = assertThrows( + NullPointerException.class, + () -> client.generateNotificationPromptFromDataWithSchema(data, schema, null)); + assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); + } + + @Test + void fromTextRejectsOverLimitTextWhenConfiguredWithSmallLimit() throws IOException { + Path envFile = writeMinimalClientEnvWithInputLimit(TEST_MOCK_PROVIDER, 100); + A2ATClient client = new A2ATClient(envFile); + String overLimitText = "a".repeat(101); + + PromptGenerationException taskEx = assertThrows( + PromptGenerationException.class, + () -> client.generateTaskPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + PromptGenerationException authEx = assertThrows( + PromptGenerationException.class, + () -> client.generateAuthPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + PromptGenerationException notificationEx = assertThrows( + PromptGenerationException.class, + () -> client.generateNotificationPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + + assertEquals("input.text_too_long", taskEx.getCode()); + assertEquals("input.text_too_long", authEx.getCode()); + assertEquals("input.text_too_long", notificationEx.getCode()); + assertEquals(0, RecordingClient.REQUEST_COUNT.get(), "over-limit input must fail before any LLM call"); + } + + @Test + void generateTaskPromptRejectsOverLimitStringInputWithoutLlmCall() throws IOException { + Path envFile = writeMinimalClientEnvWithInputLimit(TEST_MOCK_PROVIDER, 100); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult result = client.generateTaskPrompt("a".repeat(101)); + + assertFalse(result.success()); + assertEquals("input.text_too_long", result.failure().code()); + assertEquals(0, RecordingClient.REQUEST_COUNT.get(), "over-limit input must fail before any LLM call"); + } + + private static Path writeMinimalClientEnvWithInputLimit(String provider, int maxTextChars) throws IOException { + Path envFile = writeMinimalClientEnv(provider); + Files.writeString( + envFile, "A2AT_INPUT_TEXT_MAX_CHARS=" + maxTextChars + "\n", java.nio.file.StandardOpenOption.APPEND); + return envFile; + } + + private static Path writeMinimalClientEnvWithoutRequiredSlots(String provider) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-env-no-required"); + Path promptRoot = tempDir.resolve("prompt_resources"); + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); + Path templatesDir = promptRoot + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("unconstrained") + .resolve("v1") + .resolve("zh-CN"); + Path slotsDir = promptRoot + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("unconstrained") + .resolve("v1") + .resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.createDirectories(scenariosDir); + Files.createDirectories(templatesDir); + Files.createDirectories(slotsDir); + + Files.writeString( + scenariosDir.resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "unconstrained", + "scenario_name": "Unconstrained", + "description": "No required slots", + "example": "Analyze unconstrained" + } + ] + } + """); + Files.writeString(templatesDir.resolve("template.md"), "Notes: {notes}\\nFault: {fault_description}"); + Files.writeString( + slotsDir.resolve("slot.json"), + """ + { + "required": [], + "properties": { + "notes": { + "type": "string" + }, + "fault_description": { + "type": "string" + } + } + } + """); + Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); + Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); + Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); + + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(provider)); + return envFile; + } + + private static Path writeMinimalLocalClientEnv() throws IOException { + return writeMinimalClientEnv(TEST_MOCK_PROVIDER); + } + + private static Path writeMinimalClasspathClientEnv() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-classpath-env"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=example-model +A2AT_LLM_BASE_URL=https://llm.example.test/v1 +A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """); + return envFile; + } + + private static Path writeMinimalClientEnv(String provider) throws IOException { + return writeMinimalClientEnv(Files.createTempDirectory("a2at-client-env"), provider); + } + + private static Path writeMinimalClientEnv(Path tempDir, String provider) throws IOException { + Path promptRoot = tempDir.resolve("prompt_resources"); + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); + Path templatesDir = promptRoot + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("ran-energy-saving") + .resolve("v1") + .resolve("zh-CN"); + Path slotsDir = promptRoot + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("ran-energy-saving") + .resolve("v1") + .resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.createDirectories(scenariosDir); + Files.createDirectories(templatesDir); + Files.createDirectories(slotsDir); + + Files.writeString( + scenariosDir.resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "ran-energy-saving", + "scenario_name": "Energy Saving", + "description": "Energy analysis", + "example": "Analyze site power" + } + ] + } + """); + Files.writeString(templatesDir.resolve("template.md"), "Site: {site}\\nTarget: {target}"); + Files.writeString( + slotsDir.resolve("slot.json"), + """ + { + "required": ["site", "target"], + "properties": { + "site": { + "type": "string" + }, + "target": { + "type": "string" + } + } + } + """); + Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); + Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); + Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); + + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(provider)); + return envFile; + } + + private static Path writeMinimalMultiScenarioClientEnv() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-multi-scenario"); + Path promptRoot = tempDir.resolve("prompt_resources"); + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); + Path templatesDir = promptRoot + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("ran-energy-saving") + .resolve("v1") + .resolve("zh-CN"); + Path slotsDir = promptRoot + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("ran-energy-saving") + .resolve("v1") + .resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.createDirectories(scenariosDir); + Files.createDirectories(templatesDir); + Files.createDirectories(slotsDir); + + Files.writeString( + scenariosDir.resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "ran-energy-saving", + "scenario_name": "Energy Saving", + "description": "Energy analysis", + "example": "Analyze site power" + }, + { + "scenario_code": "data-report", + "scenario_name": "Data Report", + "description": "Report generation", + "example": "Generate report" + } + ] + } + """); + Files.writeString(templatesDir.resolve("template.md"), "Site: {site}\\nTarget: {target}"); + Files.writeString( + slotsDir.resolve("slot.json"), + """ + { + "required": ["site", "target"], + "properties": { + "site": { + "type": "string" + }, + "target": { + "type": "string" + } + } + } + """); + Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); + Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); + Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); + + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(TEST_MOCK_PROVIDER)); + return envFile; + } + + public static final class RecordingClient implements LLMClient { + + public static final Map SLOT_OVERRIDES = new ConcurrentHashMap<>(); + public static final AtomicInteger REQUEST_COUNT = new AtomicInteger(0); + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + REQUEST_COUNT.incrementAndGet(); + return new LLMResponse(buildResponse(jsonSchema), config.model(), Map.of(), Map.of()); + } + + private static String buildResponse(Map jsonSchema) { + Object slotNames = jsonSchema.get("slotNames"); + if (slotNames instanceof List names) { + StringBuilder slots = new StringBuilder("{"); + for (int i = 0; i < names.size(); i++) { + if (i > 0) { + slots.append(","); + } + String name = String.valueOf(names.get(i)); + String value = SLOT_OVERRIDES.getOrDefault(name, "placeholder"); + slots.append("\"") + .append(name) + .append("\":\"") + .append(value) + .append("\""); + } + slots.append("}"); + return "{\"slots\":" + slots + ",\"slot_errors\":[]}"; + } + return "{\"matched\":true,\"scenario_code\":\"ran-energy-saving\",\"error_message\":null}"; + } + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 5478c66d..1d109212 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -1,1097 +1,1190 @@ -package net.openan.a2at.sdk.client.prompt.orchestration; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.ExtensionUriConstants; -import net.openan.a2at.sdk.core.model.InputLimitConfig; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; -import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; -import org.junit.jupiter.api.Test; - -class DefaultClientPromptGenerationOrchestratorTest { - - private static final PromptSlotSchemaLoader EMPTY_SCHEMA_LOADER = - (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()); - - private static final TemplateUri AUTH_DATABASE_READ = TemplateUri.of("Authorization-T", "database_read"); - - @Test - void generateTaskPromptLoadsTemplateAndRendersExtractedSlotsWhenScenarioIsMatched() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}\nNotes: {additional_notes}"); - FakeSlotValueExtractor slotValueExtractor = - new FakeSlotValueExtractor(Map.of("site", "Site A", "additional_notes", "critical")); - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - recognizer, - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - templateLoader, - slotValueExtractor, - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertTrue(result.success()); - assertEquals("Site: Site A\nNotes: critical", result.promptText()); - assertEquals("Analyze Site A.", recognizer.lastNormalizedInput); - assertEquals("ran-energy-saving", templateLoader.lastScenarioCode); - assertEquals("en-US", templateLoader.lastLanguage); - assertEquals("Analyze Site A.", slotValueExtractor.lastUserInput); - assertEquals("ran-energy-saving", slotValueExtractor.lastScenarioCode); - assertEquals("en-US", slotValueExtractor.lastLanguage); - } - - @Test - void generateTaskPromptReturnsFailureWhenScenarioIsNotMatched() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(false, null, "No scenario matched."), - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( - Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("scenario_not_matched", result.failure().code()); - assertEquals("scenario", result.failure().stage()); - } - - @Test - void generateTaskPromptReturnsFailureWhenTemplateIsMissing() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "ran-energy-saving", null), - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - (scenarioCode, language) -> { - throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); - }, - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( - Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("template_not_found", result.failure().code()); - assertEquals("generation", result.failure().stage()); - } - - @Test - void generateTaskPromptReturnsFailureWhenRenderingFails() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "ran-energy-saving", null), - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - (scenarioCode, language) -> "Scenario: {scenario}\nMissing: {{missing_slot}}", - (userInput, scenarioCode, language) -> - new StructuredSlotExtractionResult(Map.of("scenario", scenarioCode), List.of()), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("render_failed", result.failure().code()); - assertEquals("generation", result.failure().stage()); - } - - @Test - void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissingForRequestedLanguage() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> { - throw new ResourceNotFoundException( - "Prompt resource file does not exist.", - "prompt_resources/prompts/scenario_recognition/zh-CN/system.md"); - }, - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "zh-CN", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( - Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("prompt_resource_load_error", result.failure().code()); - assertEquals("generation", result.failure().stage()); - } - - private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestrator( - ScenarioRecognizer recognizer, - PromptTemplateTextLoader templateLoader, - PromptSlotValueExtractor slotValueExtractor) { - return new DefaultClientPromptGenerationOrchestrator( - recognizer, - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - templateLoader, - slotValueExtractor, - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - } - - private static final class RecordingScenarioRecognizer implements ScenarioRecognizer { - private int invocationCount; - private String lastNormalizedInput; - - @Override - public ScenarioRecognitionResult recognize( - String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { - this.invocationCount++; - this.lastNormalizedInput = normalizedInput; - return new ScenarioRecognitionResult(true, "ran-energy-saving", null); - } - } - - private static final class CountingFailingTemplateLoader implements PromptTemplateTextLoader { - private int loadCount; - - @Override - public String loadTemplate(String scenarioCode, String language) { - this.loadCount++; - throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); - } - } - - private static final class FakeTemplateLoader implements PromptTemplateTextLoader { - private final String templateText; - private String lastScenarioCode; - private String lastLanguage; - - private FakeTemplateLoader(String templateText) { - this.templateText = templateText; - } - - @Override - public String loadTemplate(String scenarioCode, String language) { - this.lastScenarioCode = scenarioCode; - this.lastLanguage = language; - return templateText; - } - } - - private static final class FakeSlotValueExtractor implements PromptSlotValueExtractor { - private final Map slots; - private Object lastUserInput; - private String lastScenarioCode; - private String lastLanguage; - - private FakeSlotValueExtractor(Map slots) { - this.slots = slots; - } - - @Override - public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { - this.lastUserInput = userInput; - this.lastScenarioCode = scenarioCode; - this.lastLanguage = language; - return new StructuredSlotExtractionResult(slots, List.of()); - } - } - - private static final class FakeSlotValueExtractorWithSchema implements PromptSlotValueExtractor { - private final Map slots; - private Object lastUserInput; - private String lastScenarioCode; - private String lastLanguage; - private Map lastDataSchema; - - private FakeSlotValueExtractorWithSchema(Map slots) { - this.slots = slots; - } - - @Override - public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { - this.lastUserInput = userInput; - this.lastScenarioCode = scenarioCode; - this.lastLanguage = language; - return new StructuredSlotExtractionResult(slots, List.of()); - } - - @Override - public StructuredSlotExtractionResult extractSlots( - Object userInput, String scenarioCode, String language, Map dataSchema) { - this.lastUserInput = userInput; - this.lastScenarioCode = scenarioCode; - this.lastLanguage = language; - this.lastDataSchema = dataSchema; - return new StructuredSlotExtractionResult(slots, List.of()); - } - } - - private static final class FailingSlotValueExtractor implements PromptSlotValueExtractor { - private final RuntimeException exception; - - private FailingSlotValueExtractor(RuntimeException exception) { - this.exception = exception; - } - - @Override - public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { - return new StructuredSlotExtractionResult(Map.of(), List.of()); - } - - @Override - public StructuredSlotExtractionResult extractSlots( - Object userInput, String scenarioCode, String language, Map dataSchema) { - throw exception; - } - } - - // --- MetadataContent pipeline tests (generateFromTemplateUriWithMetadata / FromText) --- - - @Test - void generateFromTemplateUriWithMetadataReturnsMetadataContentOnSuccess() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractor slotValueExtractor = new FakeSlotValueExtractor(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - MetadataContent result = - orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); - - assertEquals(StandardTemplates.ENERGY_SAVING.uri(), result.templateUri()); - assertEquals("Site: Site A", result.promptText()); - assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); - } - - @Test - void generateFromTemplateUriWithMetadataScenarioRecognizerIsNotInvoked() { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - recognizer, templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); - - orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); - - assertEquals(0, recognizer.invocationCount); - } - - @Test - void generateFromTemplateUriWithMetadataThrowsOnNullTemplateUri() { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); - - NullPointerException ex = assertThrows( - NullPointerException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", null)); - assertFalse(A2ATError.class.isInstance(ex), "null template URI must stay outside the A2ATError tree"); - assertEquals(0, recognizer.invocationCount); - assertEquals(0, templateLoader.loadCount); - } - - @Test - void generateTaskPromptFromTextRejectsNullText() { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); - - assertThrows( - NullPointerException.class, - () -> orchestrator.generateTaskPromptFromText(null, StandardTemplates.ENERGY_SAVING)); - } - - @Test - void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - (scenarioCode, language) -> { - throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); - }, - new FakeSlotValueExtractor(Map.of("site", "Site A"))); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("template_not_found", ex.getCode()); - } - - @Test - void generateFromTemplateUriWithMetadataPropagatesRenderException() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}\nMissing: {{missing_slot}}"), - new FakeSlotValueExtractor(Map.of("site", "Site A"))); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("render_failed", ex.getCode()); - } - - @Test - void generateFromTemplateUriWithMetadataPropagatesLlmRuntimeError() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}"), - (userInput, scenarioCode, language) -> { - throw new LLMRuntimeError("LLM invocation failed."); - }); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("llm_invocation_failed", ex.getCode()); - } - - // --- MetadataContent pipeline tests (generateFromDataWithSchema) --- - - @Test - void generateFromDataWithSchemaReturnsMetadataContentOnSuccess() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); - - assertEquals(StandardTemplates.ENERGY_SAVING.uri(), result.templateUri()); - assertEquals("Site: Site A", result.promptText()); - assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); - } - - @Test - void generateFromDataWithSchemaPassesSchemaToExtractor() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - Map schema = Map.of("site", "string", "count", "number"); - orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), schema, StandardTemplates.ENERGY_SAVING); - - assertEquals(schema, slotValueExtractor.lastDataSchema); - assertEquals(StandardTemplates.ENERGY_SAVING.uri(), slotValueExtractor.lastScenarioCode); - } - - @Test - void generateTaskPromptFromDataWithSchemaRejectsNullData() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - NullPointerException ex = assertThrows( - NullPointerException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - null, Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); - assertTrue(ex.getMessage().contains("data")); - } - - @Test - void generateTaskPromptFromDataWithSchemaRejectsNullSchema() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - NullPointerException ex = assertThrows( - NullPointerException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), null, StandardTemplates.ENERGY_SAVING)); - assertEquals("schema", ex.getMessage()); - } - - @Test - void generateTaskPromptFromDataWithSchemaRejectsEmptySchema() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - IllegalArgumentException ex = assertThrows( - IllegalArgumentException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING)); - assertTrue(ex.getMessage().contains("Data schema must not be empty")); - } - - @Test - void generateFromDataWithSchemaPropagatesResourceNotFoundException() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - (scenarioCode, language) -> { - throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); - }, - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); - assertEquals("template_not_found", ex.getCode()); - } - - @Test - void generateFromDataWithSchemaPropagatesRenderException() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}\nMissing: {{missing_slot}}"), - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); - assertEquals("render_failed", ex.getCode()); - } - - @Test - void generateFromDataWithSchemaPropagatesLlmRuntimeError() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}"), - new FailingSlotValueExtractor(new LLMRuntimeError("LLM invocation failed."))); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); - assertEquals("llm_invocation_failed", ex.getCode()); - } - - @Test - void generateFromDataWithSchemaPropagatesResourceNotFoundExceptionFromExtractor() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}"), - new FailingSlotValueExtractor( - new ResourceNotFoundException("Slot schema file does not exist.", "ran-energy-saving"))); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); - assertEquals("slot_schema_not_found", ex.getCode()); - } - - // --- Public entry point tests --- - - @Test - void eachMetadataEntryPointReturnsCorrectMetadataContent() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - MetadataContent taskText = - orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); - MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); - MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", AUTH_DATABASE_READ); - MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), AUTH_DATABASE_READ); - MetadataContent notifText = - orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); - MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); - - assertTrue(taskText.promptText().contains("Site A")); - assertTrue(taskData.promptText().contains("Site A")); - assertTrue(authText.promptText().contains("Site A")); - assertTrue(authData.promptText().contains("Site A")); - assertTrue(notifText.promptText().contains("Site A")); - assertTrue(notifData.promptText().contains("Site A")); - } - - @Test - void eachMetadataEntryPointUsesCorrectExtensionUri() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - templateLoader, - new FakeSlotValueExtractor(Map.of("site", "Site A"))); - - MetadataContent taskText = - orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); - MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); - MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", AUTH_DATABASE_READ); - MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), AUTH_DATABASE_READ); - MetadataContent notifText = - orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); - MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); - - assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskText.extensionUri()); - assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskData.extensionUri()); - assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authText.extensionUri()); - assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authData.extensionUri()); - assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notifText.extensionUri()); - assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notifData.extensionUri()); - } - - @Test - void authorizationMetadataEntryPointsPassTemplateUriIdentifier() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Access: {scope}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("scope", "read")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - MetadataContent nlResult = orchestrator.generateAuthPromptFromText("Grant read access.", AUTH_DATABASE_READ); - - assertEquals("Authorization-T/database_read/v1", nlResult.templateUri()); - assertEquals("Access: read", nlResult.promptText()); - assertEquals("Authorization-T/database_read/v1", templateLoader.lastScenarioCode); - assertEquals("Authorization-T/database_read/v1", slotValueExtractor.lastScenarioCode); - assertEquals("Grant read access.", slotValueExtractor.lastUserInput); - - MetadataContent jsonResult = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("scope", "read"), Map.of("scope", "string"), AUTH_DATABASE_READ); - - assertEquals("Authorization-T/database_read/v1", jsonResult.templateUri()); - assertEquals("Authorization-T/database_read/v1", templateLoader.lastScenarioCode); - assertEquals(Map.of("scope", "read"), slotValueExtractor.lastUserInput); - assertEquals(Map.of("scope", "string"), slotValueExtractor.lastDataSchema); - } - - // --- slot validation tests --- - - @Test - void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null, "Site name", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of()), - new TaskPromptRenderer(), - schemaLoader); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot_validation_error", ex.getCode()); - List failed = ex.failedParameters(); - assertFalse(failed.isEmpty()); - assertEquals("site", failed.get(0).slotName()); - assertEquals("missing_required", failed.get(0).code()); - } - - @Test - void validateRequiredSlotsThrowsSlotValidationErrorWhenMultipleRequiredSlotsMissing() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of( - new PromptSlotDefinition("site", true, "string", null, null, null, null, null, "Site name", null), - new PromptSlotDefinition("target", true, "string", null, null, null, null, null, "Target", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}\nTarget: {target}"), - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - schemaLoader); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot_validation_error", ex.getCode()); - assertEquals(1, ex.failedParameters().size()); - assertEquals("target", ex.failedParameters().get(0).slotName()); - } - - @Test - void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundWhenSchemaLoadingFails() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - (scenarioCode, language) -> { - throw new A2ATError("Schema file is corrupt."); - }); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot_schema_not_found", ex.getCode()); - } - - @Test - void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundFromExtractor() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - (userInput, scenarioCode, language) -> { - throw new ResourceNotFoundException("Slot schema file does not exist.", scenarioCode); - }, - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot_schema_not_found", ex.getCode()); - } - - @Test - void generateFromTemplateUriWithMetadataThrowsPromptResourceLoadError() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - (scenarioCode, language) -> { - throw new A2ATError("Template file is corrupt."); - }, - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("prompt_resource_load_error", ex.getCode()); - } - - @Test - void generateFromTemplateUriWithMetadataThrowsLlmInvocationFailedFromA2ATError() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - (userInput, scenarioCode, language) -> { - throw new A2ATError("Unparseable LLM response."); - }, - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("llm_invocation_failed", ex.getCode()); - } - - @Test - void validateRequiredSlotsThrowsSlotValidationErrorWhenSlotValueIsEmptyString() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null, "Site name", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "")), - new TaskPromptRenderer(), - schemaLoader); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot_validation_error", ex.getCode()); - assertEquals(1, ex.failedParameters().size()); - assertEquals("site", ex.failedParameters().get(0).slotName()); - assertEquals("missing_required", ex.failedParameters().get(0).code()); - } - - @Test - void validateRequiredSlotsThrowsSlotValidationErrorWhenSlotValueIsBlankString() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null, "Site name", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", " ")), - new TaskPromptRenderer(), - schemaLoader); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot_validation_error", ex.getCode()); - assertEquals(1, ex.failedParameters().size()); - assertEquals("site", ex.failedParameters().get(0).slotName()); - } - - @Test - void validateRequiredSlotsSucceedsWhenSchemaHasNoRequiredSlotsAndAllSlotsAreEmpty() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition("site", false, "string", null, null, null, null, null, "Site name", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of()), - new TaskPromptRenderer(), - schemaLoader); - - MetadataContent result = - orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); - assertNotNull(result); - assertNotNull(result.promptText()); - } - - @Test - void validateRequiredSlotsIsNotInvokedInGenerateTaskPromptPipeline() { - PromptSlotSchemaLoader failingSchemaLoader = (scenarioCode, language) -> { - throw new A2ATError("Schema loading should not be triggered in generateTaskPrompt pipeline."); - }; - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "energy_saving", null), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - (scenarioCode, language) -> "Site: {site}", - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - failingSchemaLoader); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - assertTrue(result.success()); - assertEquals("Site: Site A", result.promptText()); - } - - @Test - void generateTaskPromptNormalizesNullInputToString() { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - recognizer, - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt(null); - - assertTrue(result.success()); - assertEquals("null", recognizer.lastNormalizedInput); - assertEquals("Site: Site A", result.promptText()); - } - - @Test - void generateTaskPromptReturnsFailureWithEmptyScenarioList() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> { - assertEquals(0, scenarios.size()); - return new ScenarioRecognitionResult(false, null, "No scenarios available."); - }, - List.of(), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of()), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("scenario_not_matched", result.failure().code()); - assertEquals("scenario", result.failure().stage()); - } - - @Test - void generateTaskPromptEscapesLlmRuntimeErrorFromExtractor() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "ran-energy-saving", null), - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - (scenarioCode, language) -> "Site: {site}", - (userInput, scenarioCode, language) -> { - throw new LLMRuntimeError("LLM invocation failed."); - }, - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - assertThrows(LLMRuntimeError.class, () -> orchestrator.generateTaskPrompt("Analyze Site A.")); - } - - @Test - void generateTaskPromptSucceedsWhenExtractionHasErrors() { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - recognizer, - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - new FakeTemplateLoader("Site: {site}"), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( - Map.of("site", "Site A"), - List.of(new StructuredSlotValidationError( - "extra_field", "unexpected_field", "Extra field ignored."))), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertTrue(result.success()); - assertEquals("Site: Site A", result.promptText()); - } - - @Test - void generateFromTemplateUriWithMetadataEmptyInputProducesSlotValidationError() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null, "Site name", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of()), - new TaskPromptRenderer(), - schemaLoader); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot_validation_error", ex.getCode()); - assertEquals(1, ex.failedParameters().size()); - assertEquals("site", ex.failedParameters().get(0).slotName()); - } - - @Test - void validateRequiredSlotsTreatsNullSlotValueAsMissing() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition("site", true, "string", null, null, null, null, null, "Site name", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Collections.singletonMap("site", null)), - new TaskPromptRenderer(), - schemaLoader); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot_validation_error", ex.getCode()); - assertEquals(1, ex.failedParameters().size()); - assertEquals("site", ex.failedParameters().get(0).slotName()); - assertEquals("missing_required", ex.failedParameters().get(0).code()); - } - - // --- free-text input length limit tests --- - - @Test - void fromTextEntryPointsRejectTextOverTheDefaultLimitBeforeAnyLlmCall() { - String overLimitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1); - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); - - PromptGenerationException taskEx = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); - PromptGenerationException authEx = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateAuthPromptFromText(overLimitText, AUTH_DATABASE_READ)); - PromptGenerationException notificationEx = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateNotificationPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); - - assertEquals("input_text_too_long", taskEx.getCode()); - assertEquals("input_text_too_long", authEx.getCode()); - assertEquals("input_text_too_long", notificationEx.getCode()); - assertTrue(taskEx.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1))); - assertEquals(0, recognizer.invocationCount, "over-limit input must fail before any LLM call"); - assertEquals(0, templateLoader.loadCount, "over-limit input must fail before template loading"); - } - - @Test - void fromTextAcceptsTextAtExactlyTheDefaultLimit() { - String limitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "Site A"))); - - MetadataContent result = - orchestrator.generateTaskPromptFromText(limitText, StandardTemplates.ENERGY_SAVING); - - assertEquals("Site: Site A", result.promptText()); - } - - @Test - void fromTextRejectsTextOverOneConfiguredLimit() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER, - 10); - - PromptGenerationException overEx = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("a".repeat(11), StandardTemplates.ENERGY_SAVING)); - assertEquals("input_text_too_long", overEx.getCode()); - - MetadataContent atLimit = - orchestrator.generateTaskPromptFromText("a".repeat(10), StandardTemplates.ENERGY_SAVING); - assertEquals("Site: Site A", atLimit.promptText()); - } - - @Test - void generateTaskPromptReturnsFailureWhenStringInputExceedsTheDefaultLimit() { - String overLimitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1); - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - recognizer, - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt(overLimitText); - - assertFalse(result.success()); - assertEquals("input_text_too_long", result.failure().code()); - assertEquals("input", result.failure().stage()); - assertEquals(0, recognizer.invocationCount, "over-limit input must fail before any LLM call"); - } - - @Test - void generateTaskPromptDoesNotCheckLengthForMapInput() { - Map mapInputWithLongStringValue = - Map.of("notes", "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1)); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt(mapInputWithLongStringValue); - - assertTrue(result.success(), "map input must not be length-checked"); - assertEquals("Site: Site A", result.promptText()); - } -} +package net.openan.a2at.sdk.client.prompt.orchestration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; +import org.junit.jupiter.api.Test; + +class DefaultClientPromptGenerationOrchestratorTest { + + private static final PromptSlotSchemaLoader EMPTY_SCHEMA_LOADER = + (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()); + + private static final TemplateUri AUTH_DATABASE_READ = TemplateUri.of("Authorization-T", "database_read"); + + @Test + void generateTaskPromptLoadsTemplateAndRendersExtractedSlotsWhenScenarioIsMatched() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}\nNotes: {additional_notes}"); + FakeSlotValueExtractor slotValueExtractor = + new FakeSlotValueExtractor(Map.of("site", "Site A", "additional_notes", "critical")); + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + templateLoader, + slotValueExtractor, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertTrue(result.success()); + assertEquals("Site: Site A\nNotes: critical", result.promptText()); + assertEquals("Analyze Site A.", recognizer.lastNormalizedInput); + assertEquals("ran-energy-saving", templateLoader.lastScenarioCode); + assertEquals("en-US", templateLoader.lastLanguage); + assertEquals("Analyze Site A.", slotValueExtractor.lastUserInput); + assertEquals("ran-energy-saving", slotValueExtractor.lastScenarioCode); + assertEquals("en-US", slotValueExtractor.lastLanguage); + } + + @Test + void generateTaskPromptReturnsFailureWhenScenarioIsNotMatched() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(false, null, "No scenario matched."), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("scenario.not_matched", result.failure().code()); + assertEquals("scenario", result.failure().stage()); + } + + @Test + void generateTaskPromptReturnsFailureWhenTemplateIsMissing() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(true, "ran-energy-saving", null), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> { + throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); + }, + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("template.not_found", result.failure().code()); + assertEquals("generation", result.failure().stage()); + } + + @Test + void generateTaskPromptReturnsFailureWhenRenderingFails() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(true, "ran-energy-saving", null), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Scenario: {scenario}\nMissing: {{missing_slot}}", + (userInput, scenarioCode, language) -> + new StructuredSlotExtractionResult(Map.of("scenario", scenarioCode), List.of()), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("template.render_failed", result.failure().code()); + assertEquals("generation", result.failure().stage()); + } + + @Test + void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissingForRequestedLanguage() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> { + throw new ResourceNotFoundException( + "Prompt resource file does not exist.", + "prompt_resources/prompts/scenario_recognition/zh-CN/system.md"); + }, + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "zh-CN", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("template.load_failed", result.failure().code()); + assertEquals("generation", result.failure().stage()); + } + + private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestrator( + ScenarioRecognizer recognizer, + PromptTemplateTextLoader templateLoader, + PromptSlotValueExtractor slotValueExtractor) { + return new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + templateLoader, + slotValueExtractor, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + } + + private static final class RecordingScenarioRecognizer implements ScenarioRecognizer { + private int invocationCount; + private String lastNormalizedInput; + + @Override + public ScenarioRecognitionResult recognize( + String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { + this.invocationCount++; + this.lastNormalizedInput = normalizedInput; + return new ScenarioRecognitionResult(true, "ran-energy-saving", null); + } + } + + private static final class CountingFailingTemplateLoader implements PromptTemplateTextLoader { + private int loadCount; + + @Override + public String loadTemplate(String scenarioCode, String language) { + this.loadCount++; + throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); + } + } + + private static final class FakeTemplateLoader implements PromptTemplateTextLoader { + private final String templateText; + private String lastScenarioCode; + private String lastLanguage; + + private FakeTemplateLoader(String templateText) { + this.templateText = templateText; + } + + @Override + public String loadTemplate(String scenarioCode, String language) { + this.lastScenarioCode = scenarioCode; + this.lastLanguage = language; + return templateText; + } + } + + private static final class FakeSlotValueExtractor implements PromptSlotValueExtractor { + private final Map slots; + private Object lastUserInput; + private String lastScenarioCode; + private String lastLanguage; + + private FakeSlotValueExtractor(Map slots) { + this.slots = slots; + } + + @Override + public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { + this.lastUserInput = userInput; + this.lastScenarioCode = scenarioCode; + this.lastLanguage = language; + return new StructuredSlotExtractionResult(slots, List.of()); + } + } + + private static final class FakeSlotValueExtractorWithSchema implements PromptSlotValueExtractor { + private final Map slots; + private Object lastUserInput; + private String lastScenarioCode; + private String lastLanguage; + private Map lastDataSchema; + + private FakeSlotValueExtractorWithSchema(Map slots) { + this.slots = slots; + } + + @Override + public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { + this.lastUserInput = userInput; + this.lastScenarioCode = scenarioCode; + this.lastLanguage = language; + return new StructuredSlotExtractionResult(slots, List.of()); + } + + @Override + public StructuredSlotExtractionResult extractSlots( + Object userInput, String scenarioCode, String language, Map dataSchema) { + this.lastUserInput = userInput; + this.lastScenarioCode = scenarioCode; + this.lastLanguage = language; + this.lastDataSchema = dataSchema; + return new StructuredSlotExtractionResult(slots, List.of()); + } + } + + private static final class FailingSlotValueExtractor implements PromptSlotValueExtractor { + private final RuntimeException exception; + + private FailingSlotValueExtractor(RuntimeException exception) { + this.exception = exception; + } + + @Override + public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { + return new StructuredSlotExtractionResult(Map.of(), List.of()); + } + + @Override + public StructuredSlotExtractionResult extractSlots( + Object userInput, String scenarioCode, String language, Map dataSchema) { + throw exception; + } + } + + // --- MetadataContent pipeline tests (generateFromTemplateUriWithMetadata / FromText) --- + + @Test + void generateFromTemplateUriWithMetadataReturnsMetadataContentOnSuccess() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractor slotValueExtractor = new FakeSlotValueExtractor(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent result = + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), result.templateUri()); + assertEquals("Site: Site A", result.promptText()); + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); + } + + @Test + void generateFromTemplateUriWithMetadataScenarioRecognizerIsNotInvoked() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + recognizer, templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + + assertEquals(0, recognizer.invocationCount); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsOnNullTemplateUri() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); + + NullPointerException ex = assertThrows( + NullPointerException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", null)); + assertFalse(A2ATError.class.isInstance(ex), "null template URI must stay outside the A2ATError tree"); + assertEquals(0, recognizer.invocationCount); + assertEquals(0, templateLoader.loadCount); + } + + @Test + void generateTaskPromptFromTextRejectsNullText() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); + + assertThrows( + NullPointerException.class, + () -> orchestrator.generateTaskPromptFromText(null, StandardTemplates.ENERGY_SAVING)); + } + + @Test + void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + (scenarioCode, language) -> { + throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); + }, + new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("template.not_found", ex.getCode()); + } + + @Test + void generateFromTemplateUriWithMetadataPropagatesRenderException() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}\nMissing: {{missing_slot}}"), + new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("template.render_failed", ex.getCode()); + } + + @Test + void generateFromTemplateUriWithMetadataPropagatesLlmRuntimeError() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language) -> { + throw new LLMRuntimeError("LLM invocation failed."); + }); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("llm.invocation_failed", ex.getCode()); + } + + // --- MetadataContent pipeline tests (generateFromDataWithSchema) --- + + @Test + void generateFromDataWithSchemaReturnsMetadataContentOnSuccess() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); + + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), result.templateUri()); + assertEquals("Site: Site A", result.promptText()); + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); + } + + @Test + void generateFromDataWithSchemaPassesSchemaToExtractor() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + Map schema = Map.of("site", "string", "count", "number"); + orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), schema, StandardTemplates.ENERGY_SAVING); + + assertEquals(schema, slotValueExtractor.lastDataSchema); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), slotValueExtractor.lastScenarioCode); + } + + @Test + void generateTaskPromptFromDataWithSchemaRejectsNullData() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + NullPointerException ex = assertThrows( + NullPointerException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + null, Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); + assertTrue(ex.getMessage().contains("data")); + } + + @Test + void generateTaskPromptFromDataWithSchemaRejectsNullSchema() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + NullPointerException ex = assertThrows( + NullPointerException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), null, StandardTemplates.ENERGY_SAVING)); + assertEquals("schema", ex.getMessage()); + } + + @Test + void generateTaskPromptFromDataWithSchemaRejectsEmptySchema() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING)); + assertTrue(ex.getMessage().contains("Data schema must not be empty")); + } + + @Test + void generateFromDataWithSchemaPropagatesResourceNotFoundException() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + (scenarioCode, language) -> { + throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); + }, + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); + assertEquals("template.not_found", ex.getCode()); + } + + @Test + void generateFromDataWithSchemaPropagatesRenderException() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}\nMissing: {{missing_slot}}"), + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); + assertEquals("template.render_failed", ex.getCode()); + } + + @Test + void generateFromDataWithSchemaPropagatesLlmRuntimeError() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}"), + new FailingSlotValueExtractor(new LLMRuntimeError("LLM invocation failed."))); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); + assertEquals("llm.invocation_failed", ex.getCode()); + } + + @Test + void generateFromDataWithSchemaPropagatesResourceNotFoundExceptionFromExtractor() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}"), + new FailingSlotValueExtractor( + new ResourceNotFoundException("Slot schema file does not exist.", "ran-energy-saving"))); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.schema_not_found", ex.getCode()); + } + + // --- Public entry point tests --- + + @Test + void eachMetadataEntryPointReturnsCorrectMetadataContent() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent taskText = + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); + MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", AUTH_DATABASE_READ); + MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), AUTH_DATABASE_READ); + MetadataContent notifText = + orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); + MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); + + assertTrue(taskText.promptText().contains("Site A")); + assertTrue(taskData.promptText().contains("Site A")); + assertTrue(authText.promptText().contains("Site A")); + assertTrue(authData.promptText().contains("Site A")); + assertTrue(notifText.promptText().contains("Site A")); + assertTrue(notifData.promptText().contains("Site A")); + } + + @Test + void eachMetadataEntryPointUsesCorrectExtensionUri() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + templateLoader, + new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + MetadataContent taskText = + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); + MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", AUTH_DATABASE_READ); + MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), AUTH_DATABASE_READ); + MetadataContent notifText = + orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); + MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); + + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskText.extensionUri()); + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskData.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authText.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authData.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notifText.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notifData.extensionUri()); + } + + @Test + void authorizationMetadataEntryPointsPassTemplateUriIdentifier() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Access: {scope}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("scope", "read")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent nlResult = orchestrator.generateAuthPromptFromText("Grant read access.", AUTH_DATABASE_READ); + + assertEquals("Authorization-T/database_read/v1", nlResult.templateUri()); + assertEquals("Access: read", nlResult.promptText()); + assertEquals("Authorization-T/database_read/v1", templateLoader.lastScenarioCode); + assertEquals("Authorization-T/database_read/v1", slotValueExtractor.lastScenarioCode); + assertEquals("Grant read access.", slotValueExtractor.lastUserInput); + + MetadataContent jsonResult = orchestrator.generateAuthPromptFromDataWithSchema( + Map.of("scope", "read"), Map.of("scope", "string"), AUTH_DATABASE_READ); + + assertEquals("Authorization-T/database_read/v1", jsonResult.templateUri()); + assertEquals("Authorization-T/database_read/v1", templateLoader.lastScenarioCode); + assertEquals(Map.of("scope", "read"), slotValueExtractor.lastUserInput); + assertEquals(Map.of("scope", "string"), slotValueExtractor.lastDataSchema); + } + + // --- slot validation tests --- + + @Test + void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of()), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.not_provided", ex.getCode()); + List failed = ex.failedParameters(); + assertFalse(failed.isEmpty()); + assertEquals("site", failed.get(0).slotName()); + assertEquals("slot.not_provided", failed.get(0).code()); + } + + @Test + void validateRequiredSlotsThrowsSlotValidationErrorWhenMultipleRequiredSlotsMissing() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of( + new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, "Site name", null), + new PromptSlotDefinition( + "target", true, "string", null, null, null, null, null, "Target", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}\nTarget: {target}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.not_provided", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("target", ex.failedParameters().get(0).slotName()); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundWhenSchemaLoadingFails() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + (scenarioCode, language) -> { + throw new A2ATError("Schema file is corrupt."); + }); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.schema_not_found", ex.getCode()); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundFromExtractor() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language) -> { + throw new ResourceNotFoundException("Slot schema file does not exist.", scenarioCode); + }, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.schema_not_found", ex.getCode()); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsPromptResourceLoadError() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + (scenarioCode, language) -> { + throw new A2ATError("Template file is corrupt."); + }, + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("template.load_failed", ex.getCode()); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsLlmInvocationFailedFromA2ATError() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language) -> { + throw new A2ATError("Unparseable LLM response."); + }, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("llm.invocation_failed", ex.getCode()); + } + + @Test + void validateRequiredSlotsThrowsSlotValidationErrorWhenSlotValueIsEmptyString() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "")), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.not_provided", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("site", ex.failedParameters().get(0).slotName()); + assertEquals("slot.not_provided", ex.failedParameters().get(0).code()); + } + + @Test + void validateRequiredSlotsThrowsSlotValidationErrorWhenSlotValueIsBlankString() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", " ")), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.not_provided", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("site", ex.failedParameters().get(0).slotName()); + } + + @Test + void validateRequiredSlotsSucceedsWhenSchemaHasNoRequiredSlotsAndAllSlotsAreEmpty() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", false, "string", null, null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of()), + new TaskPromptRenderer(), + schemaLoader); + + MetadataContent result = + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + assertNotNull(result); + assertNotNull(result.promptText()); + } + + @Test + void validateRequiredSlotsIsNotInvokedInGenerateTaskPromptPipeline() { + PromptSlotSchemaLoader failingSchemaLoader = (scenarioCode, language) -> { + throw new A2ATError("Schema loading should not be triggered in generateTaskPrompt pipeline."); + }; + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(true, "energy_saving", null), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + (scenarioCode, language) -> "Site: {site}", + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + failingSchemaLoader); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + assertTrue(result.success()); + assertEquals("Site: Site A", result.promptText()); + } + + @Test + void generateTaskPromptNormalizesNullInputToString() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt(null); + + assertTrue(result.success()); + assertEquals("null", recognizer.lastNormalizedInput); + assertEquals("Site: Site A", result.promptText()); + } + + @Test + void generateTaskPromptReturnsFailureWithEmptyScenarioList() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> { + assertEquals(0, scenarios.size()); + return new ScenarioRecognitionResult(false, null, "No scenarios available."); + }, + List.of(), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of()), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("scenario.not_matched", result.failure().code()); + assertEquals("scenario", result.failure().stage()); + } + + @Test + void generateTaskPromptMapsLlmRuntimeErrorFromExtractorToResultFailure() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(true, "ran-energy-saving", null), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Site: {site}", + (userInput, scenarioCode, language) -> { + throw new LLMRuntimeError("LLM invocation failed."); + }, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("llm.invocation_failed", result.failure().code()); + assertEquals("generation", result.failure().stage()); + } + + @Test + void generateTaskPromptMapsLlmResponseContractErrorToResultFailure() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(true, "ran-energy-saving", null), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Site: {site}", + (userInput, scenarioCode, language) -> { + throw new LLMRuntimeError("openai returned invalid json: unexpected token"); + }, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("llm.response_invalid", result.failure().code()); + assertEquals( + "The LLM response is invalid (step: slot extraction); please retry", + result.failure().message()); + } + + @Test + void generateTaskPromptSurfacesExtractionSlotErrorsAsFailure() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("site", "Site A"), + List.of(new StructuredSlotValidationError( + "subscriptionCondition", "slot.not_provided", null))), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("slot.not_provided", result.failure().code()); + assertEquals( + "'subscriptionCondition' is not provided in the input.", + result.failure().message()); + assertEquals("generation", result.failure().stage()); + } + + @Test + void generateTaskPromptMapsUnknownExtractionSlotErrorCodeToFallback() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("site", "Site A"), + List.of(new StructuredSlotValidationError("extra_field", "unexpected_field", null))), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("slot.rule_violation", result.failure().code()); + } + + @Test + void generateTaskPromptMapsLegacyExtractionSlotErrorCodes() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("site", "Site A"), + List.of( + new StructuredSlotValidationError("site", "missing_input", null), + new StructuredSlotValidationError("site", "invalid_value", null))), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("slot.not_provided", result.failure().code()); + assertTrue(result.failure().message().contains("; "), "both mapped messages must be reported"); + } + + @Test + void generateFromTemplateUriWithMetadataEmptyInputProducesSlotValidationError() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of()), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.not_provided", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("site", ex.failedParameters().get(0).slotName()); + } + + @Test + void validateRequiredSlotsTreatsNullSlotValueAsMissing() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Collections.singletonMap("site", null)), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.not_provided", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("site", ex.failedParameters().get(0).slotName()); + assertEquals("slot.not_provided", ex.failedParameters().get(0).code()); + } + + // --- free-text input length limit tests --- + + @Test + void fromTextEntryPointsRejectTextOverTheDefaultLimitBeforeAnyLlmCall() { + String overLimitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1); + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); + + PromptGenerationException taskEx = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + PromptGenerationException authEx = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateAuthPromptFromText(overLimitText, AUTH_DATABASE_READ)); + PromptGenerationException notificationEx = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateNotificationPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + + assertEquals("input.text_too_long", taskEx.getCode()); + assertEquals("input.text_too_long", authEx.getCode()); + assertEquals("input.text_too_long", notificationEx.getCode()); + assertTrue(taskEx.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1))); + assertEquals(0, recognizer.invocationCount, "over-limit input must fail before any LLM call"); + assertEquals(0, templateLoader.loadCount, "over-limit input must fail before template loading"); + } + + @Test + void fromTextAcceptsTextAtExactlyTheDefaultLimit() { + String limitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + MetadataContent result = orchestrator.generateTaskPromptFromText(limitText, StandardTemplates.ENERGY_SAVING); + + assertEquals("Site: Site A", result.promptText()); + } + + @Test + void fromTextRejectsTextOverOneConfiguredLimit() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER, + 10); + + PromptGenerationException overEx = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("a".repeat(11), StandardTemplates.ENERGY_SAVING)); + assertEquals("input.text_too_long", overEx.getCode()); + + MetadataContent atLimit = + orchestrator.generateTaskPromptFromText("a".repeat(10), StandardTemplates.ENERGY_SAVING); + assertEquals("Site: Site A", atLimit.promptText()); + } + + @Test + void generateTaskPromptReturnsFailureWhenStringInputExceedsTheDefaultLimit() { + String overLimitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1); + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt(overLimitText); + + assertFalse(result.success()); + assertEquals("input.text_too_long", result.failure().code()); + assertEquals("input", result.failure().stage()); + assertEquals(0, recognizer.invocationCount, "over-limit input must fail before any LLM call"); + } + + @Test + void generateTaskPromptDoesNotCheckLengthForMapInput() { + Map mapInputWithLongStringValue = + Map.of("notes", "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1)); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt(mapInputWithLongStringValue); + + assertTrue(result.success(), "map input must not be length-checked"); + assertEquals("Site: Site A", result.promptText()); + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index cdb69381..15d040ad 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -8,12 +8,12 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.validation.ContentValidator; import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.core.validation.ContentValidator; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; @@ -59,7 +59,8 @@ public A2ATServer(Path envPath) { DefaultA2ATServerBuilder.builder().envPath(resolvedEnvPath).config(config); this.promptComplianceOrchestrator = builder.buildPromptComplianceOrchestrator(); this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); - this.negotiationContentService = new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.negotiationContentService = + new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); this.templateQueryService = builder.buildTemplateQueryService(); this.taskContentValidator = builder.buildTaskContentValidator(); this.notificationContentValidator = builder.buildNotificationContentValidator(); @@ -70,7 +71,7 @@ public A2ATServer(Path envPath) { * Checks one processed task prompt for server-side compliance. * *

        An input longer than {@code A2AT_INPUT_TEXT_MAX_CHARS} characters (default 16384) fails fast with the code - * {@code input_text_too_long} before any LLM call. + * {@code input.text_too_long} before any LLM call. * * @param processedPromptText processed task prompt text * @return compliance result containing only success state or failure details @@ -129,8 +130,8 @@ public Map continueNegotiation( * @throws NullPointerException if the data, its context or the template URI is null * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationProposePromptFromData( @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { @@ -144,15 +145,15 @@ public MetadataContent generateNegotiationProposePromptFromData( * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its - * phase segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the * content conclusion is not {@code Accept} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationAcceptPromptFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { @@ -166,15 +167,15 @@ public MetadataContent generateNegotiationAcceptPromptFromData( * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its - * phase segment must be {@code accept-reject} + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the * content conclusion is not {@code Reject} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationRejectPromptFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { @@ -194,8 +195,8 @@ public MetadataContent generateNegotiationRejectPromptFromData( * @throws IllegalArgumentException if the template URI does not address the common abort template or the * termination reason is blank * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation_slot_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationAbortPromptFromData( @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { @@ -208,22 +209,23 @@ public MetadataContent generateNegotiationAbortPromptFromData( *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation_content_extract_failed} and {@code negotiation_llm_infrastructure_error}. + * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. * * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment * must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, {@code negotiation_invalid_input} when the text is blank or the extracted - * content contradicts the phase, or {@code input_text_too_long} when the text exceeds the configured maximum - * length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the + * extracted content contradicts the phase, or {@code input.text_too_long} when the text exceeds the configured + * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, @@ -240,19 +242,20 @@ public MetadataContent generateNegotiationProposePromptFromText( * conclusion must be {@code Accept}. * * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its - * phase segment must be {@code accept-reject} + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, {@code negotiation_invalid_input} when the text is blank or the extracted - * conclusion is not {@code Accept}, or {@code input_text_too_long} when the text exceeds the configured - * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the + * extracted conclusion is not {@code Accept}, or {@code input.text_too_long} when the text exceeds the + * configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, @@ -269,19 +272,20 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * conclusion must be {@code Reject}. * * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its - * phase segment must be {@code accept-reject} + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses a required field, {@code negotiation_invalid_input} when the text is blank or the extracted - * conclusion is not {@code Reject}, or {@code input_text_too_long} when the text exceeds the configured - * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the + * extracted conclusion is not {@code Reject}, or {@code input.text_too_long} when the text exceeds the + * configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, @@ -297,18 +301,19 @@ public MetadataContent generateNegotiationRejectPromptFromText( * deterministically like the from-data variant. * * @param text free-text input stating the termination reason - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI does not address the common abort template * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template_not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation_content_extract_failed} or {@code negotiation_llm_infrastructure_error} when the - * extraction step fails after exhausting its retries, {@code negotiation_slot_missing} when the extracted - * content misses the termination reason, {@code negotiation_invalid_input} when the text is blank, or - * {@code input_text_too_long} when the text exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses the termination reason, {@code negotiation.invalid_input} when the text is blank, or + * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, @@ -321,8 +326,8 @@ public MetadataContent generateNegotiationAbortPromptFromText( * Lists every template available for the configured language across all A2A-T extensions. * *

        This query never throws: the extension directories are discovered from the bundled resource tree itself, so - * templates of extensions added later are included automatically. Templates that exist nowhere for the language - * are skipped and an empty list is returned when no template can be loaded at all. + * templates of extensions added later are included automatically. Templates that exist nowhere for the language are + * skipped and an empty list is returned when no template can be loaded at all. * * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when * none can be loaded @@ -345,6 +350,8 @@ public Optional getPrompt(@NonNull TemplateUri templateUri) { return templateQueryService.getPrompt(templateUri); } + + /** * Validates a propose-phase negotiation message and extracts its parameters. * @@ -353,8 +360,8 @@ public Optional getPrompt(@NonNull TemplateUri templateUri) { * parameters with the rule-level context parameters; context parameters win on conflict. * * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code propose} @@ -362,13 +369,15 @@ public Optional getPrompt(@NonNull TemplateUri templateUri) { * @throws NullPointerException if the prompt, schema or template URI is null * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing, or - * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context + * violates a rule, one of the closed {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateProposePromptAndDataFilling( @NonNull String prompt, @@ -381,13 +390,13 @@ public FilledParamData validateProposePromptAndDataFilling( /** * Validates an accept-phase negotiation message and extracts its parameters. * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected - * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must - * satisfy the accept-phase semantic constraints. + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * accept-phase semantic constraints. * * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} @@ -395,13 +404,15 @@ public FilledParamData validateProposePromptAndDataFilling( * @throws NullPointerException if the prompt, schema or template URI is null * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing, or - * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context + * violates a rule, one of the closed {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateAcceptPromptAndDataFilling( @NonNull String prompt, @@ -414,13 +425,13 @@ public FilledParamData validateAcceptPromptAndDataFilling( /** * Validates a reject-phase negotiation message and extracts its parameters. * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected - * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must - * satisfy the reject-phase semantic constraints. + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * reject-phase semantic constraints. * * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be * {@code accept-reject} @@ -428,13 +439,15 @@ public FilledParamData validateAcceptPromptAndDataFilling( * @throws NullPointerException if the prompt, schema or template URI is null * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing, or - * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context + * violates a rule, one of the closed {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateRejectPromptAndDataFilling( @NonNull String prompt, @@ -447,26 +460,28 @@ public FilledParamData validateRejectPromptAndDataFilling( /** * Validates an abort negotiation message and extracts its parameters. * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected - * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the - * abort-phase semantic constraints. + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * abort: the template URI must address the common abort template and the message must satisfy the abort-phase + * semantic constraints. * * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} * @return filled parameter data carrying the context parameters and the extracted parameters * @throws IllegalArgumentException if the prompt is blank or the template URI does not address the common abort * template * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation_invalid_input} when the prompt is not a negotiation message, - * {@code negotiation_rule_violation} when the negotiation context violates a rule, - * {@code negotiation_semantic_rejected} when the semantic validation rejects the message, - * {@code negotiation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code template_not_found} when the semantic validation prompt resources are missing, or - * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context + * violates a rule, one of the closed {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateAbortPromptAndDataFilling( @NonNull String prompt, @@ -480,21 +495,20 @@ public FilledParamData validateAbortPromptAndDataFilling( * Validates a task prompt and extracts its parameters. * * @param prompt rendered task prompt text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected task template; its prefix segment must be - * {@code Task-T} + * @param templateUri template URI declaring the expected task template; its prefix segment must be {@code Task-T} * @return filled parameter data carrying the merged parameters * @throws NullPointerException if the prompt or schema is null * @throws IllegalArgumentException if the prompt is blank * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code validation_invalid_input} when the template URI is null, - * {@code validation_semantic_rejected} when the semantic validation rejects the content, - * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing, or - * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code negotiation.invalid_input} when the template URI is null, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateTaskPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { @@ -505,8 +519,8 @@ public FilledParamData validateTaskPromptAndDataFilling( * Validates a notification prompt and extracts its parameters. * * @param prompt rendered notification prompt text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected notification template; its prefix segment must be * {@code Notification-T} @@ -514,12 +528,12 @@ public FilledParamData validateTaskPromptAndDataFilling( * @throws NullPointerException if the prompt or schema is null * @throws IllegalArgumentException if the prompt is blank * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code validation_invalid_input} when the template URI is null, - * {@code validation_semantic_rejected} when the semantic validation rejects the content, - * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing, or - * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code negotiation.invalid_input} when the template URI is null, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateNotificationPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { @@ -530,8 +544,8 @@ public FilledParamData validateNotificationPromptAndDataFilling( * Validates an authorization prompt and extracts its parameters. * * @param prompt rendered authorization prompt text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI declaring the expected authorization template; its prefix segment must be * {@code Authorization-T} @@ -539,12 +553,12 @@ public FilledParamData validateNotificationPromptAndDataFilling( * @throws NullPointerException if the prompt or schema is null * @throws IllegalArgumentException if the prompt is blank * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code validation_invalid_input} when the template URI is null, - * {@code validation_semantic_rejected} when the semantic validation rejects the content, - * {@code validation_llm_infrastructure_error} when the semantic step fails after exhausting its retries, or - * {@code validation_prompt_resource_not_found} when the validation prompt resources are missing, or - * {@code input_text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code negotiation.invalid_input} when the template URI is null, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateAuthPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index 4153c660..bfe286bd 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -133,8 +133,10 @@ public synchronized DefaultServerPromptComplianceOrchestrator buildPromptComplia slotSchemaLoader, new DefaultStructuredPromptSlotValueExtractor( client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)), - new LlmBackedPromptSemanticValidator(client, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt), - config.inputLimits().maxTextChars()); + new LlmBackedPromptSemanticValidator( + client, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt), + config.inputLimits().maxTextChars(), + language); return promptComplianceOrchestrator; } @@ -229,7 +231,8 @@ private ContentValidator buildContentValidator(String extensionName) { config.llm().maxAttempts(), llmClient(), promptResourceAccess().templateLoader()), - config.inputLimits().maxTextChars()); + config.inputLimits().maxTextChars(), + config.prompt().language()); } private static void require(Object value, String message) { @@ -268,4 +271,4 @@ private synchronized PromptResourceAccess promptResourceAccess() { } return promptResourceAccess; } -} +} \ No newline at end of file diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java index 9af14893..f05c3000 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java @@ -1,74 +1,100 @@ -package net.openan.a2at.sdk.server.compliance; - -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.InputLimitConfig; -import net.openan.a2at.sdk.server.metadata.ServerPromptMetadataExtractor; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import net.openan.a2at.sdk.server.model.PromptComplianceFailure; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.validation.ServerPromptSemanticValidator; - -/** - * Minimal runnable server-side prompt compliance orchestrator. - * - * @since 2026-06 - */ -public final class DefaultServerPromptComplianceOrchestrator implements ServerPromptComplianceOrchestrator { - - /** Compliance stage reported when the input gate rejects an oversized prompt before any LLM call. */ - private static final String INPUT_GATE_STAGE = "input_gate"; - - private final ServerPromptMetadataExtractor metadataExtractor; - - private final ServerPromptSemanticValidator semanticValidator; - - private final int maxTextChars; - - /** - * Creates a compliance orchestrator. - * - * @param metadataExtractor prompt metadata extractor - * @param semanticValidator semantic validator - */ - public DefaultServerPromptComplianceOrchestrator( - ServerPromptMetadataExtractor metadataExtractor, ServerPromptSemanticValidator semanticValidator) { - this(metadataExtractor, semanticValidator, InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); - } - - /** - * Creates a compliance orchestrator with an explicit free-text input limit. - * - * @param metadataExtractor prompt metadata extractor - * @param semanticValidator semantic validator - * @param maxTextChars maximum length in characters accepted for the processed prompt text - */ - public DefaultServerPromptComplianceOrchestrator( - ServerPromptMetadataExtractor metadataExtractor, - ServerPromptSemanticValidator semanticValidator, - int maxTextChars) { - this.metadataExtractor = metadataExtractor; - this.semanticValidator = semanticValidator; - this.maxTextChars = maxTextChars; - } - - @Override - public PromptComplianceResult checkTaskPrompt(String processedPromptText) { - if (InputLimitConfig.isTooLong(processedPromptText, maxTextChars)) { - return new PromptComplianceResult( - false, - new PromptComplianceFailure( - A2ATErrorCodes.INPUT_TEXT_TOO_LONG, - InputLimitConfig.violationMessage(processedPromptText, maxTextChars), - INPUT_GATE_STAGE)); - } - try { - ProcessedPromptMetadata metadata = metadataExtractor.extract(processedPromptText); - semanticValidator.validate(processedPromptText, metadata); - return new PromptComplianceResult(true, null); - } catch (PromptComplianceCheckException error) { - return new PromptComplianceResult( - false, new PromptComplianceFailure(error.getCode(), error.getMessage(), error.getStage())); - } - } -} +package net.openan.a2at.sdk.server.compliance; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; +import net.openan.a2at.sdk.server.metadata.ServerPromptMetadataExtractor; +import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; +import net.openan.a2at.sdk.server.model.PromptComplianceFailure; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import net.openan.a2at.sdk.server.validation.ServerPromptSemanticValidator; +import org.jspecify.annotations.Nullable; + +/** + * Minimal runnable server-side prompt compliance orchestrator. + * + * @since 2026-06 + */ +public final class DefaultServerPromptComplianceOrchestrator implements ServerPromptComplianceOrchestrator { + + /** Compliance stage reported when the input gate rejects an oversized prompt before any LLM call. */ + private static final String INPUT_GATE_STAGE = "input_gate"; + + private final ServerPromptMetadataExtractor metadataExtractor; + + private final ServerPromptSemanticValidator semanticValidator; + + private final int maxTextChars; + + private final String language; + + /** + * Creates a compliance orchestrator. + * + * @param metadataExtractor prompt metadata extractor + * @param semanticValidator semantic validator + */ + public DefaultServerPromptComplianceOrchestrator( + ServerPromptMetadataExtractor metadataExtractor, ServerPromptSemanticValidator semanticValidator) { + this(metadataExtractor, semanticValidator, InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); + } + + /** + * Creates a compliance orchestrator with an explicit free-text input limit. + * + * @param metadataExtractor prompt metadata extractor + * @param semanticValidator semantic validator + * @param maxTextChars maximum length in characters accepted for the processed prompt text + */ + public DefaultServerPromptComplianceOrchestrator( + ServerPromptMetadataExtractor metadataExtractor, + ServerPromptSemanticValidator semanticValidator, + int maxTextChars) { + this(metadataExtractor, semanticValidator, maxTextChars, null); + } + + /** + * Creates a compliance orchestrator with an explicit free-text input limit and message language. + * + * @param metadataExtractor prompt metadata extractor + * @param semanticValidator semantic validator + * @param maxTextChars maximum length in characters accepted for the processed prompt text + * @param language language used to render failure messages, for example {@code zh-CN}; null falls back to + * {@code en-US} + */ + public DefaultServerPromptComplianceOrchestrator( + ServerPromptMetadataExtractor metadataExtractor, + ServerPromptSemanticValidator semanticValidator, + int maxTextChars, + @Nullable String language) { + this.metadataExtractor = metadataExtractor; + this.semanticValidator = semanticValidator; + this.maxTextChars = maxTextChars; + this.language = language; + } + + @Override + public PromptComplianceResult checkTaskPrompt(String processedPromptText) { + if (InputLimitConfig.isTooLong(processedPromptText, maxTextChars)) { + Map facts = Map.of( + "actual_length", String.valueOf(processedPromptText.length()), + "max_chars", String.valueOf(maxTextChars)); + return new PromptComplianceResult( + false, + new PromptComplianceFailure( + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), + ErrorMessages.render(ErrorCatalog.INPUT_TEXT_TOO_LONG, language, facts), + INPUT_GATE_STAGE)); + } + try { + ProcessedPromptMetadata metadata = metadataExtractor.extract(processedPromptText); + semanticValidator.validate(processedPromptText, metadata); + return new PromptComplianceResult(true, null); + } catch (PromptComplianceCheckException error) { + return new PromptComplianceResult( + false, new PromptComplianceFailure(error.getCode(), error.getMessage(), error.getStage())); + } + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java index e477f7b4..e74d81b2 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java @@ -1,37 +1,57 @@ -package net.openan.a2at.sdk.server.exception; - -import net.openan.a2at.sdk.core.exception.A2ATError; - -/** - * Compliance-check failure carrying a stable error code and the compliance stage where the failure occurred. - * - *

        Part of the {@link A2ATError} tree: callers can catch the {@link A2ATError} root to handle any A2A-T processing - * failure and dispatch on its machine-readable error code. - * - * @since 2026-06 - */ -public final class PromptComplianceCheckException extends A2ATError { - - private final String stage; - - /** - * Creates a standardized compliance-check exception. - * - * @param code stable error code - * @param message human-readable failure message - * @param stage compliance stage where the failure occurred - */ - public PromptComplianceCheckException(String code, String message, String stage) { - super(code, message); - this.stage = stage; - } - - /** - * Returns the compliance stage where the failure occurred. - * - * @return failure stage - */ - public String getStage() { - return stage; - } -} +package net.openan.a2at.sdk.server.exception; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import org.jspecify.annotations.Nullable; + +/** + * Compliance-check failure carrying a stable error code and the compliance stage where the failure occurred. + * + *

        Part of the {@link A2ATBusinessException} tree: callers can catch the {@link A2ATError} root to handle any A2A-T + * processing failure and dispatch on its machine-readable error code. Messages are rendered from the code's message + * template via {@link ErrorMessages}; the fact values that produced the message travel in {@link #getFacts()}. + * + * @since 2026-06 + */ +public final class PromptComplianceCheckException extends A2ATBusinessException { + + private final String stage; + + /** + * Creates a standardized compliance-check exception. + * + * @param code stable error code + * @param message human-readable failure message + * @param stage compliance stage where the failure occurred + */ + public PromptComplianceCheckException(String code, String message, String stage) { + super(code, message); + this.stage = stage; + } + + /** + * Creates a standardized compliance-check exception whose message is rendered from the code's template. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param stage compliance stage where the failure occurred + */ + public PromptComplianceCheckException( + ErrorCatalog entry, @Nullable String language, @Nullable Map facts, String stage) { + super(entry, ErrorMessages.render(entry, language, facts), facts); + this.stage = stage; + } + + /** + * Returns the compliance stage where the failure occurred. + * + * @return failure stage + */ + public String getStage() { + return stage; + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java index 32c65f77..7acd72e7 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java @@ -1,169 +1,209 @@ -package net.openan.a2at.sdk.server.metadata; - -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.prompt.analysis.exception.ScenarioRecognitionException; -import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; -import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; - -/** - * Server-side metadata extractor that mirrors the Python flow by resolving scenario and slots from - * the processed prompt text with LLM-backed analysis steps. - * - * @since 2026-06 - */ -public final class LlmBackedPromptMetadataExtractor implements ServerPromptMetadataExtractor { - - private final ScenarioRecognizer scenarioRecognizer; - - private final List scenarios; - - private final String language; - - private final String systemPrompt; - - private final String userPrompt; - - private final PromptTemplateTextLoader templateLoader; - - private final PromptSlotSchemaLoader slotSchemaLoader; - - private final PromptSlotValueExtractor slotValueExtractor; - - public LlmBackedPromptMetadataExtractor( - ScenarioRecognizer scenarioRecognizer, - List scenarios, - String language, - String systemPrompt, - String userPrompt, - PromptTemplateTextLoader templateLoader, - PromptSlotSchemaLoader slotSchemaLoader, - PromptSlotValueExtractor slotValueExtractor) { - this.scenarioRecognizer = scenarioRecognizer; - this.scenarios = List.copyOf(scenarios); - this.language = language; - this.systemPrompt = systemPrompt; - this.userPrompt = userPrompt; - this.templateLoader = templateLoader; - this.slotSchemaLoader = slotSchemaLoader; - this.slotValueExtractor = slotValueExtractor; - } - - @Override - public ProcessedPromptMetadata extract(String processedPromptText) { - ScenarioRecognitionResult recognitionResult = resolveScenario(processedPromptText); - String scenarioCode = recognitionResult.scenarioCode(); - String templateText = loadTemplate(scenarioCode); - PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(scenarioCode, language); - StructuredSlotExtractionResult extractionResult = - slotValueExtractor.extractSlots(processedPromptText, scenarioCode, language); - validateExtractionResult(extractionResult, slotSchema); - return new ProcessedPromptMetadata(scenarioCode, language, templateText, Map.copyOf(extractionResult.slots())); - } - - private ScenarioRecognitionResult resolveScenario(String processedPromptText) { - try { - ScenarioRecognitionResult result = - scenarioRecognizer.recognize(processedPromptText, scenarios, systemPrompt, userPrompt); - if (!result.matched() || result.scenarioCode() == null || result.scenarioCode().isBlank()) { - throw new PromptComplianceCheckException( - A2ATErrorCodes.PROCESSED_PROMPT_PARSE_ERROR, - result.errorMessage() == null ? "Scenario recognition failed." : result.errorMessage(), - "prompt_parse"); - } - return result; - } catch (ScenarioRecognitionException error) { - throw new PromptComplianceCheckException(A2ATErrorCodes.PROCESSED_PROMPT_PARSE_ERROR, error.getMessage(), "prompt_parse"); - } - } - - private String loadTemplate(String scenarioCode) { - try { - return templateLoader.loadTemplate(scenarioCode, language); - } catch (ResourceNotFoundException error) { - throw new PromptComplianceCheckException(A2ATErrorCodes.TEMPLATE_NOT_FOUND, error.getMessage(), "generation"); - } - } - - private static void validateExtractionResult( - StructuredSlotExtractionResult extractionResult, PromptSlotSchema slotSchema) { - if (!extractionResult.slotErrors().isEmpty()) { - throw new PromptComplianceCheckException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - extractionResult.slotErrors().stream() - .map(StructuredSlotValidationError::message) - .filter(message -> message != null && !message.isBlank()) - .collect(Collectors.joining("; ")), - "slot_validation"); - } - - for (PromptSlotDefinition definition : slotSchema.slotDefinitions()) { - String value = extractionResult.slots().get(definition.name()); - if (definition.required() && (value == null || value.isBlank())) { - throw new PromptComplianceCheckException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - "Required slot '" + definition.name() + "' is missing.", - "slot_validation"); - } - if (value == null || value.isBlank()) { - continue; - } - if (definition.allowedValues() != null - && !definition.allowedValues().isEmpty() - && !definition.allowedValues().contains(value.trim())) { - throw new PromptComplianceCheckException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - "Slot '" + definition.name() + "' violates allowed values.", - "slot_validation"); - } - if (definition.pattern() != null - && !definition.pattern().isBlank() - && !value.trim().matches(definition.pattern())) { - throw new PromptComplianceCheckException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - "Slot '" + definition.name() + "' violates pattern constraint.", - "slot_validation"); - } - validateNumericConstraint(definition, value.trim()); - } - } - - private static void validateNumericConstraint(PromptSlotDefinition definition, String value) { - if (!"integer".equalsIgnoreCase(definition.jsonType()) && !"number".equalsIgnoreCase(definition.jsonType())) { - return; - } - try { - double numericValue = Double.parseDouble(value); - if (definition.minimum() != null && numericValue < definition.minimum()) { - throw new PromptComplianceCheckException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - "Slot '" + definition.name() + "' is smaller than minimum.", - "slot_validation"); - } - if (definition.maximum() != null && numericValue > definition.maximum()) { - throw new PromptComplianceCheckException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - "Slot '" + definition.name() + "' is larger than maximum.", - "slot_validation"); - } - } catch (NumberFormatException error) { - throw new PromptComplianceCheckException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - "Slot '" + definition.name() + "' is not numeric.", - "slot_validation"); - } - } -} +package net.openan.a2at.sdk.server.metadata; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.prompt.analysis.exception.ScenarioRecognitionException; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; +import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Server-side metadata extractor that mirrors the Python flow by resolving scenario and slots from the processed prompt + * text with LLM-backed analysis steps. + * + * @since 2026-06 + */ +public final class LlmBackedPromptMetadataExtractor implements ServerPromptMetadataExtractor { + + private static final Logger LOGGER = LoggerFactory.getLogger(LlmBackedPromptMetadataExtractor.class); + + /** Default reason rendered into {@code scenario.not_matched} when the recognizer reports none. */ + private static final String DEFAULT_SCENARIO_REASON = "scenario recognition failed"; + + private final ScenarioRecognizer scenarioRecognizer; + + private final List scenarios; + + private final String language; + + private final String systemPrompt; + + private final String userPrompt; + + private final PromptTemplateTextLoader templateLoader; + + private final PromptSlotSchemaLoader slotSchemaLoader; + + private final PromptSlotValueExtractor slotValueExtractor; + + public LlmBackedPromptMetadataExtractor( + ScenarioRecognizer scenarioRecognizer, + List scenarios, + String language, + String systemPrompt, + String userPrompt, + PromptTemplateTextLoader templateLoader, + PromptSlotSchemaLoader slotSchemaLoader, + PromptSlotValueExtractor slotValueExtractor) { + this.scenarioRecognizer = scenarioRecognizer; + this.scenarios = List.copyOf(scenarios); + this.language = language; + this.systemPrompt = systemPrompt; + this.userPrompt = userPrompt; + this.templateLoader = templateLoader; + this.slotSchemaLoader = slotSchemaLoader; + this.slotValueExtractor = slotValueExtractor; + } + + @Override + public ProcessedPromptMetadata extract(String processedPromptText) { + ScenarioRecognitionResult recognitionResult = resolveScenario(processedPromptText); + String scenarioCode = recognitionResult.scenarioCode(); + String templateText = loadTemplate(scenarioCode); + PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(scenarioCode, language); + StructuredSlotExtractionResult extractionResult = + slotValueExtractor.extractSlots(processedPromptText, scenarioCode, language); + validateExtractionResult(extractionResult, slotSchema); + return new ProcessedPromptMetadata(scenarioCode, language, templateText, Map.copyOf(extractionResult.slots())); + } + + private ScenarioRecognitionResult resolveScenario(String processedPromptText) { + try { + ScenarioRecognitionResult result = + scenarioRecognizer.recognize(processedPromptText, scenarios, systemPrompt, userPrompt); + if (!result.matched() + || result.scenarioCode() == null + || result.scenarioCode().isBlank()) { + throw scenarioNotMatched(result.errorMessage()); + } + return result; + } catch (ScenarioRecognitionException error) { + throw scenarioNotMatched(error.getMessage()); + } + } + + private PromptComplianceCheckException scenarioNotMatched(String reason) { + String effectiveReason = reason == null || reason.isBlank() ? DEFAULT_SCENARIO_REASON : reason; + return new PromptComplianceCheckException( + ErrorCatalog.SCENARIO_NOT_MATCHED, language, Map.of("reason", effectiveReason), "prompt_parse"); + } + + private String loadTemplate(String scenarioCode) { + try { + return templateLoader.loadTemplate(scenarioCode, language); + } catch (ResourceNotFoundException error) { + throw new PromptComplianceCheckException( + ErrorCatalog.TEMPLATE_NOT_FOUND, + language, + Map.of("template_uri", scenarioCode, "language", language), + "generation"); + } catch (A2ATError error) { + LOGGER.warn( + "Template load failed for scenario '{}' and language '{}': {}", + scenarioCode, + language, + error.getMessage()); + throw new PromptComplianceCheckException( + ErrorCatalog.INFRA_RESOURCE_READ_FAILED, + language, + Map.of("resource_path", scenarioCode), + "generation"); + } + } + + private void validateExtractionResult( + StructuredSlotExtractionResult extractionResult, PromptSlotSchema slotSchema) { + if (!extractionResult.slotErrors().isEmpty()) { + throw slotFailure(extractionResult.slotErrors().get(0)); + } + + for (PromptSlotDefinition definition : slotSchema.slotDefinitions()) { + String value = extractionResult.slots().get(definition.name()); + if (definition.required() && (value == null || value.isBlank())) { + throw new PromptComplianceCheckException( + ErrorCatalog.SLOT_NOT_PROVIDED, + language, + Map.of("slot_label", definition.name()), + "slot_validation"); + } + if (value == null || value.isBlank()) { + continue; + } + String trimmedValue = value.trim(); + if (definition.allowedValues() != null + && !definition.allowedValues().isEmpty() + && !definition.allowedValues().contains(trimmedValue)) { + throw constraintViolation(definition, trimmedValue); + } + if (definition.pattern() != null + && !definition.pattern().isBlank() + && !trimmedValue.matches(definition.pattern())) { + throw constraintViolation(definition, trimmedValue); + } + validateNumericConstraint(definition, trimmedValue); + } + } + + private PromptComplianceCheckException slotFailure(StructuredSlotValidationError error) { + String slotName = error.slotName() == null ? "" : error.slotName(); + return new PromptComplianceCheckException( + resolveSlotErrorCode(error.code(), slotName), + language, + Map.of("slot_label", slotName), + "slot_validation"); + } + + private static ErrorCatalog resolveSlotErrorCode(String code, String slotName) { + Optional entry = ErrorCatalog.byCode(code); + if (entry.isPresent()) { + return entry.get(); + } + LOGGER.warn( + "Unknown slot validation error code '{}' for slot '{}'; falling back to '{}'.", + code, + slotName, + ErrorCatalog.SLOT_RULE_VIOLATION.getCode()); + return ErrorCatalog.SLOT_RULE_VIOLATION; + } + + private PromptComplianceCheckException constraintViolation(PromptSlotDefinition definition, String actual) { + return new PromptComplianceCheckException( + ErrorCatalog.SLOT_CONSTRAINT_VIOLATED, + language, + Map.of("slot_label", definition.name(), "actual", actual), + "slot_validation"); + } + + private void validateNumericConstraint(PromptSlotDefinition definition, String value) { + if (!"integer".equalsIgnoreCase(definition.jsonType()) && !"number".equalsIgnoreCase(definition.jsonType())) { + return; + } + double numericValue; + try { + numericValue = Double.parseDouble(value); + } catch (NumberFormatException error) { + throw constraintViolation(definition, value); + } + if (definition.minimum() != null && numericValue < definition.minimum()) { + throw constraintViolation(definition, value); + } + if (definition.maximum() != null && numericValue > definition.maximum()) { + throw constraintViolation(definition, value); + } + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidator.java index 337bf66b..6710f096 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidator.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidator.java @@ -1,46 +1,66 @@ -package net.openan.a2at.sdk.server.validation; - -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.InputLimitConfig; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.ContentValidator; - -/** - * Free-text input gate wrapped around one delegated content validator. - * - *

        The gate rejects an oversized prompt with the code {@code input_text_too_long} before the delegated pipeline - * starts, so no LLM call is made for an input that could not fit the LLM context anyway. The limit is configured - * through {@code A2AT_INPUT_TEXT_MAX_CHARS} and defaults to {@link InputLimitConfig#DEFAULT_MAX_TEXT_CHARS} - * characters. - * - * @since 2026-08 - */ -public final class InputLimitedContentValidator implements ContentValidator { - - private final ContentValidator delegate; - - private final int maxTextChars; - - /** - * Creates one gating validator around the given delegate. - * - * @param delegate content validator carrying the actual validation pipeline - * @param maxTextChars maximum length in characters accepted for the prompt text - */ - public InputLimitedContentValidator(ContentValidator delegate, int maxTextChars) { - this.delegate = delegate; - this.maxTextChars = maxTextChars; - } - - @Override - public FilledParamData validate(String prompt, Map schema, TemplateUri templateUri) { - if (InputLimitConfig.isTooLong(prompt, maxTextChars)) { - throw new ContentValidationException( - A2ATErrorCodes.INPUT_TEXT_TOO_LONG, InputLimitConfig.violationMessage(prompt, maxTextChars)); - } - return delegate.validate(prompt, schema, templateUri); - } -} +package net.openan.a2at.sdk.server.validation; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import org.jspecify.annotations.Nullable; + +/** + * Free-text input gate wrapped around one delegated content validator. + * + *

        The gate rejects an oversized prompt with the code {@code input.text_too_long} before the delegated pipeline + * starts, so no LLM call is made for an input that could not fit the LLM context anyway. The limit is configured + * through {@code A2AT_INPUT_TEXT_MAX_CHARS} and defaults to {@link InputLimitConfig#DEFAULT_MAX_TEXT_CHARS} characters. + * + * @since 2026-08 + */ +public final class InputLimitedContentValidator implements ContentValidator { + + private final ContentValidator delegate; + + private final int maxTextChars; + + private final String language; + + /** + * Creates one gating validator around the given delegate. + * + * @param delegate content validator carrying the actual validation pipeline + * @param maxTextChars maximum length in characters accepted for the prompt text + */ + public InputLimitedContentValidator(ContentValidator delegate, int maxTextChars) { + this(delegate, maxTextChars, null); + } + + /** + * Creates one gating validator around the given delegate with an explicit message language. + * + * @param delegate content validator carrying the actual validation pipeline + * @param maxTextChars maximum length in characters accepted for the prompt text + * @param language language used to render the rejection message, for example {@code zh-CN}; null falls back to + * {@code en-US} + */ + public InputLimitedContentValidator(ContentValidator delegate, int maxTextChars, @Nullable String language) { + this.delegate = delegate; + this.maxTextChars = maxTextChars; + this.language = language; + } + + @Override + public FilledParamData validate(String prompt, Map schema, TemplateUri templateUri) { + if (InputLimitConfig.isTooLong(prompt, maxTextChars)) { + Map facts = Map.of( + "actual_length", String.valueOf(prompt.length()), + "max_chars", String.valueOf(maxTextChars)); + throw new ContentValidationException( + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), + ErrorMessages.render(ErrorCatalog.INPUT_TEXT_TOO_LONG, language, facts)); + } + return delegate.validate(prompt, schema, templateUri); + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java index 78399cf2..59529d64 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java @@ -1,153 +1,210 @@ -package net.openan.a2at.sdk.server.validation; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import net.openan.a2at.sdk.core.model.PromptMessage; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; - -/** - * LLM-backed semantic validator aligned with the Python server-side compliance flow. - * - * @since 2026-06 - */ -public final class LlmBackedPromptSemanticValidator implements ServerPromptSemanticValidator { - - private static final ObjectMapper OBJECT_MAPPER = - new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); - - private final LLMClient llmClient; - - private final PromptSlotSchemaLoader slotSchemaLoader; - - private final String systemPrompt; - - private final String userPrompt; - - /** - * Creates an LLM-backed semantic validator. - * - * @param llmClient LLM client for structured generation - * @param slotSchemaLoader loader for slot schemas - * @param systemPrompt system prompt for the LLM - * @param userPrompt user prompt template for the LLM - */ - public LlmBackedPromptSemanticValidator( - LLMClient llmClient, PromptSlotSchemaLoader slotSchemaLoader, String systemPrompt, String userPrompt) { - this.llmClient = llmClient; - this.slotSchemaLoader = slotSchemaLoader; - this.systemPrompt = systemPrompt; - this.userPrompt = userPrompt; - } - - @Override - public void validate(String processedPromptText, ProcessedPromptMetadata metadata) { - PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(metadata.scenarioCode(), metadata.language()); - String payload = llmClient - .structured( - toStructuredMessages(List.of( - new PromptMessage("system", systemPrompt), - new PromptMessage("user", buildUserPrompt(slotSchema, metadata.slots())))), - schema(), - null, - null) - .content(); - validateResponse(payload); - } - - private String buildUserPrompt(PromptSlotSchema slotSchema, Map extractedSlots) { - try { - return userPrompt - + "\n\n{\n" - + " \"slot_json_schema\": " - + OBJECT_MAPPER.writeValueAsString(slotSchema) - + ",\n" - + " \"extracted_slots\": " - + OBJECT_MAPPER.writeValueAsString(extractedSlots) - + "\n}"; - } catch (JsonProcessingException error) { - throw new PromptComplianceCheckException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, "Failed to serialize slot schema", "slot_validation"); - } - } - - private static Map schema() { - Map itemSchema = new LinkedHashMap<>(); - itemSchema.put("type", "object"); - itemSchema.put("additionalProperties", false); - itemSchema.put("required", List.of("slot_name", "code", "message")); - itemSchema.put( - "properties", - Map.of( - "slot_name", Map.of("type", "string"), - "code", Map.of("type", "string"), - "message", Map.of("type", "string"))); - - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("additionalProperties", false); - schema.put("required", List.of("passed", "errors")); - schema.put( - "properties", - Map.of( - "passed", Map.of("type", "boolean"), - "errors", Map.of("type", "array", "items", itemSchema))); - return schema; - } - - private static List> toStructuredMessages(List messages) { - return messages.stream() - .map(message -> Map.of("role", message.role(), "content", message.content())) - .toList(); - } - - private void validateResponse(String payload) { - Map response = parseResponse(payload); - boolean passed = Boolean.TRUE.equals(response.get("passed")); - boolean hasErrors = response.get("errors") instanceof List errors && !errors.isEmpty(); - if (passed && !hasErrors) { - return; - } - - Optional message = extractFirstMessage(response.get("errors")); - throw new PromptComplianceCheckException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - message.filter(text -> !text.isBlank()).orElse("Slot semantic validation failed."), - "slot_validation"); - } - - private static Map parseResponse(String payload) { - try { - Map response = - OBJECT_MAPPER.readValue(payload, new TypeReference>() {}); - return Optional.ofNullable(response).orElseGet(Map::of); - } catch (JsonProcessingException error) { - throw new PromptComplianceCheckException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - "semantic validation returned invalid JSON", - "slot_validation"); - } - } - - private static Optional extractFirstMessage(Object errorsValue) { - if (!(errorsValue instanceof List errors) || errors.isEmpty()) { - return Optional.empty(); - } - Object firstError = errors.get(0); - if (!(firstError instanceof Map errorMap)) { - return Optional.empty(); - } - Object message = errorMap.get("message"); - return message instanceof String text ? Optional.of(text) : Optional.empty(); - } -} +package net.openan.a2at.sdk.server.validation; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.PromptMessage; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; +import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * LLM-backed semantic validator aligned with the Python server-side compliance flow. + * + *

        The LLM reports semantic failures as {@code {slot_name, code, facts}} entries; the human-readable message is + * rendered from the code's message template, never taken from the LLM response. An error code outside the catalog is + * mapped to the slot-domain fallback {@code slot.rule_violation} and logged as a warning. + * + * @since 2026-06 + */ +public final class LlmBackedPromptSemanticValidator implements ServerPromptSemanticValidator { + + private static final Logger LOGGER = LoggerFactory.getLogger(LlmBackedPromptSemanticValidator.class); + + /** Step name reported in {@code llm.response_invalid} when the LLM response violates the response contract. */ + private static final String SEMANTIC_VALIDATION_STEP = "semantic_validation"; + + private static final String SLOT_VALIDATION_STAGE = "slot_validation"; + + private static final String SLOT_CODE_DOMAIN = "slot."; + + private static final ObjectMapper OBJECT_MAPPER = + new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); + + private final LLMClient llmClient; + + private final PromptSlotSchemaLoader slotSchemaLoader; + + private final String systemPrompt; + + private final String userPrompt; + + /** + * Creates an LLM-backed semantic validator. + * + * @param llmClient LLM client for structured generation + * @param slotSchemaLoader loader for slot schemas + * @param systemPrompt system prompt for the LLM + * @param userPrompt user prompt template for the LLM + */ + public LlmBackedPromptSemanticValidator( + LLMClient llmClient, PromptSlotSchemaLoader slotSchemaLoader, String systemPrompt, String userPrompt) { + this.llmClient = llmClient; + this.slotSchemaLoader = slotSchemaLoader; + this.systemPrompt = systemPrompt; + this.userPrompt = userPrompt; + } + + @Override + public void validate(String processedPromptText, ProcessedPromptMetadata metadata) { + PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(metadata.scenarioCode(), metadata.language()); + String payload = llmClient + .structured( + toStructuredMessages(List.of( + new PromptMessage("system", systemPrompt), + new PromptMessage("user", buildUserPrompt(slotSchema, metadata.slots())))), + schema(), + null, + null) + .content(); + validateResponse(payload, metadata.language()); + } + + private String buildUserPrompt(PromptSlotSchema slotSchema, Map extractedSlots) { + try { + return userPrompt + + "\n\n{\n" + + " \"slot_json_schema\": " + + OBJECT_MAPPER.writeValueAsString(slotSchema) + + ",\n" + + " \"extracted_slots\": " + + OBJECT_MAPPER.writeValueAsString(extractedSlots) + + "\n}"; + } catch (JsonProcessingException error) { + throw internalError(error); + } + } + + private static Map schema() { + Map factsSchema = new LinkedHashMap<>(); + factsSchema.put("type", "object"); + factsSchema.put("additionalProperties", Map.of("type", "string")); + + Map itemSchema = new LinkedHashMap<>(); + itemSchema.put("type", "object"); + itemSchema.put("additionalProperties", false); + itemSchema.put("required", List.of("slot_name", "code", "facts")); + itemSchema.put( + "properties", + Map.of( + "slot_name", Map.of("type", "string"), + "code", Map.of("type", "string"), + "facts", factsSchema)); + + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("additionalProperties", false); + schema.put("required", List.of("passed", "errors")); + schema.put( + "properties", + Map.of( + "passed", Map.of("type", "boolean"), + "errors", Map.of("type", "array", "items", itemSchema))); + return schema; + } + + private static List> toStructuredMessages(List messages) { + return messages.stream() + .map(message -> Map.of("role", message.role(), "content", message.content())) + .toList(); + } + + private void validateResponse(String payload, String language) { + Map response = parseResponse(payload, language); + boolean passed = Boolean.TRUE.equals(response.get("passed")); + List errors = response.get("errors") instanceof List list ? list : List.of(); + if (passed && errors.isEmpty()) { + return; + } + + SemanticError firstError = firstError(errors); + if (firstError == null) { + throw responseInvalid(language); + } + throw new PromptComplianceCheckException( + resolveErrorCode(firstError.code()), language, firstError.facts(), SLOT_VALIDATION_STAGE); + } + + private static Map parseResponse(String payload, String language) { + try { + Map response = + OBJECT_MAPPER.readValue(payload, new TypeReference>() {}); + return Optional.ofNullable(response).orElseGet(Map::of); + } catch (JsonProcessingException error) { + throw responseInvalid(language); + } + } + + private static SemanticError firstError(List errors) { + for (Object item : errors) { + if (!(item instanceof Map errorMap) + || !(errorMap.get("slot_name") instanceof String slotName) + || !(errorMap.get("code") instanceof String code)) { + continue; + } + return new SemanticError(slotName, code, stringFacts(errorMap.get("facts"))); + } + return null; + } + + private static Map stringFacts(Object factsValue) { + if (!(factsValue instanceof Map facts)) { + return Map.of(); + } + Map rendered = new LinkedHashMap<>(); + for (Map.Entry entry : facts.entrySet()) { + if (entry.getKey() instanceof String key && entry.getValue() instanceof String value) { + rendered.put(key, value); + } + } + return rendered; + } + + private static ErrorCatalog resolveErrorCode(String code) { + Optional entry = ErrorCatalog.byCode(code); + if (entry.isPresent() && entry.get().getCode().startsWith(SLOT_CODE_DOMAIN)) { + return entry.get(); + } + LOGGER.warn( + "Unknown semantic validation error code '{}'; falling back to '{}'.", + code, + ErrorCatalog.SLOT_RULE_VIOLATION.getCode()); + return ErrorCatalog.SLOT_RULE_VIOLATION; + } + + private static PromptComplianceCheckException responseInvalid(String language) { + return new PromptComplianceCheckException( + ErrorCatalog.LLM_RESPONSE_INVALID, + language, + Map.of("step", SEMANTIC_VALIDATION_STEP), + SLOT_VALIDATION_STAGE); + } + + private static PromptComplianceCheckException internalError(JsonProcessingException cause) { + LOGGER.warn("Failed to serialize slot schema for the semantic validation prompt.", cause); + return new PromptComplianceCheckException(ErrorCatalog.INFRA_INTERNAL_ERROR, null, null, SLOT_VALIDATION_STAGE); + } + + /** One error entry reported by the semantic validation LLM step. */ + private record SemanticError(String slotName, String code, Map facts) {} +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java index 77d8684e..76dc666d 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java @@ -1,144 +1,146 @@ -package net.openan.a2at.sdk.server.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class InputTextLengthLimitTest { - - private static final String TEST_MOCK_PROVIDER = "test-input-limit-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, CountingClient.class); - } - } - - @BeforeEach - void resetCounter() { - CountingClient.resetCallCount(); - } - - @TempDir - Path tempDir; - - @Test - void checkTaskPromptFailsFastWithoutLlmCallWhenInputExceedsConfiguredLimit() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("10")); - String oversizedInput = "a".repeat(11); - - PromptComplianceResult result = server.checkTaskPrompt(oversizedInput); - - assertEquals(false, result.success()); - assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, result.failure().code()); - assertEquals("input_gate", result.failure().stage()); - assertEquals(0, CountingClient.callCount(), "No LLM call may happen for an oversized input"); - } - - @Test - void checkTaskPromptDoesNotFailForLengthWhenInputIsExactlyAtDefaultLimit() throws IOException { - A2ATServer server = new A2ATServer(writeEnv(null)); - String boundaryInput = "a".repeat(16384); - - PromptComplianceResult result = server.checkTaskPrompt(boundaryInput); - - assertNotEquals( - A2ATErrorCodes.INPUT_TEXT_TOO_LONG, - result.success() ? null : result.failure().code(), - "An input exactly at the default limit must not be rejected for its length"); - } - - @Test - void validateContentPromptAndDataFillingFailsFastWithoutLlmCallWhenPromptExceedsConfiguredLimit() - throws IOException { - A2ATServer server = new A2ATServer(writeEnv("10")); - String oversizedPrompt = "a".repeat(11); - Map schema = Map.of(); - - ContentValidationException taskError = assertThrows( - ContentValidationException.class, - () -> server.validateTaskPromptAndDataFilling(oversizedPrompt, schema, StandardTemplates.ENERGY_SAVING)); - ContentValidationException notificationError = assertThrows( - ContentValidationException.class, - () -> server.validateNotificationPromptAndDataFilling( - oversizedPrompt, schema, StandardTemplates.SUBSCRIBE_INCIDENT)); - ContentValidationException authError = assertThrows( - ContentValidationException.class, - () -> server.validateAuthPromptAndDataFilling( - oversizedPrompt, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT)); - - assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, taskError.getCode()); - assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, notificationError.getCode()); - assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, authError.getCode()); - assertEquals(0, CountingClient.callCount(), "No LLM call may happen for an oversized prompt"); - } - - private Path writeEnv(String maxTextChars) throws IOException { - Path envFile = tempDir.resolve("server.env"); - String limitEntry = maxTextChars == null ? "" : "A2AT_INPUT_TEXT_MAX_CHARS=" + maxTextChars + "\n"; - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=test-input-limit-mock - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - + limitEntry); - return envFile; - } - - public static final class CountingClient implements LLMClient { - - private static final AtomicInteger CALL_COUNT = new AtomicInteger(0); - - private final LLMClientConfig config; - - public CountingClient(LLMClientConfig config) { - this.config = config; - } - - static void resetCallCount() { - CALL_COUNT.set(0); - } - - static int callCount() { - return CALL_COUNT.get(); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - CALL_COUNT.incrementAndGet(); - return new LLMResponse("{}", config.model(), Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class InputTextLengthLimitTest { + + private static final String TEST_MOCK_PROVIDER = "test-input-limit-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, CountingClient.class); + } + } + + @BeforeEach + void resetCounter() { + CountingClient.resetCallCount(); + } + + @TempDir + Path tempDir; + + @Test + void checkTaskPromptFailsFastWithoutLlmCallWhenInputExceedsConfiguredLimit() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("10")); + String oversizedInput = "a".repeat(11); + + PromptComplianceResult result = server.checkTaskPrompt(oversizedInput); + + assertEquals(false, result.success()); + assertEquals( + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), result.failure().code()); + assertEquals("input_gate", result.failure().stage()); + assertEquals(0, CountingClient.callCount(), "No LLM call may happen for an oversized input"); + } + + @Test + void checkTaskPromptDoesNotFailForLengthWhenInputIsExactlyAtDefaultLimit() throws IOException { + A2ATServer server = new A2ATServer(writeEnv(null)); + String boundaryInput = "a".repeat(16384); + + PromptComplianceResult result = server.checkTaskPrompt(boundaryInput); + + assertNotEquals( + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), + result.success() ? null : result.failure().code(), + "An input exactly at the default limit must not be rejected for its length"); + } + + @Test + void validateContentPromptAndDataFillingFailsFastWithoutLlmCallWhenPromptExceedsConfiguredLimit() + throws IOException { + A2ATServer server = new A2ATServer(writeEnv("10")); + String oversizedPrompt = "a".repeat(11); + Map schema = Map.of(); + + ContentValidationException taskError = assertThrows( + ContentValidationException.class, + () -> server.validateTaskPromptAndDataFilling( + oversizedPrompt, schema, StandardTemplates.ENERGY_SAVING)); + ContentValidationException notificationError = assertThrows( + ContentValidationException.class, + () -> server.validateNotificationPromptAndDataFilling( + oversizedPrompt, schema, StandardTemplates.SUBSCRIBE_INCIDENT)); + ContentValidationException authError = assertThrows( + ContentValidationException.class, + () -> server.validateAuthPromptAndDataFilling( + oversizedPrompt, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT)); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), taskError.getCode()); + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), notificationError.getCode()); + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), authError.getCode()); + assertEquals(0, CountingClient.callCount(), "No LLM call may happen for an oversized prompt"); + } + + private Path writeEnv(String maxTextChars) throws IOException { + Path envFile = tempDir.resolve("server.env"); + String limitEntry = maxTextChars == null ? "" : "A2AT_INPUT_TEXT_MAX_CHARS=" + maxTextChars + "\n"; + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-input-limit-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + + limitEntry); + return envFile; + } + + public static final class CountingClient implements LLMClient { + + private static final AtomicInteger CALL_COUNT = new AtomicInteger(0); + + private final LLMClientConfig config; + + public CountingClient(LLMClientConfig config) { + this.config = config; + } + + static void resetCallCount() { + CALL_COUNT.set(0); + } + + static int callCount() { + return CALL_COUNT.get(); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + CALL_COUNT.incrementAndGet(); + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index 5cf05fe6..3fdc456e 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -1,156 +1,155 @@ -package net.openan.a2at.sdk.server.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Task-T and Negotiation-T coexistence regression: one server instance answers both the Task-T compliance API and the - * negotiation content-layer APIs without interference, and the pre-existing negotiation state machine keeps working - * next to the new APIs. - */ -class TaskPromptNegotiationCoexistenceTest { - - private static final String TEST_MOCK_PROVIDER = "test-coexistence-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); - } - } - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_TEMPLATE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE_TEMPLATE.uri(); - - private static final String TASK_T_MESSAGE = "## 任务类型(Task Type)\n" - + "无线能效优化\n\n" - + "## 任务对象(Task Object)\n" - + "松山湖管委会\n\n" - + "## 任务目标(Task Target)\n" - + "总功耗降低30%\n"; - - @TempDir - Path tempDir; - - @Test - void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - PromptComplianceResult complianceResult = server.checkTaskPrompt(TASK_T_MESSAGE); - - assertFalse(complianceResult.success()); - assertEquals("processed_prompt_parse_error", complianceResult.failure().code()); - assertEquals("prompt_parse", complianceResult.failure().stage()); - - NegotiationParamExtractionException negotiationFailure = assertThrows( - NegotiationParamExtractionException.class, - () -> server.validateProposePromptAndDataFilling( - TASK_T_MESSAGE, - null, - Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - INFORMATION_PROPOSE_TEMPLATE)); - - assertEquals("negotiation_invalid_input", negotiationFailure.getCode()); - assertTrue(negotiationFailure.getMessage().contains("checkTaskPrompt")); - assertFalse(negotiationFailure.getMessage().contains("协商上下文")); - - MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_TEMPLATE); - assertEquals(INFORMATION_PROPOSE_URI, negotiationMessage.templateUri()); - assertEquals(UUID, negotiationMessage.negotiationContext().id()); - } - - @Test - void oldNegotiationStateMachineKeepsWorkingNextToTheContentLayerApis() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - Map started = server.startNegotiation( - NegotiationType.INFORMATION, "需要完整的任务提示词。", Map.of("source", "coexistence-test")); - Map context = (Map) started.get(NegotiationHandler.NEGOTIATION_T_URI_NL); - assertEquals("information", context.get("negotiationType")); - assertEquals("in-progress", context.get("status")); - assertEquals("coexistence-test", ((Map) started.get("facts")).get("source")); - - MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_TEMPLATE); - assertEquals(2, negotiationMessage.negotiationContext().round()); - - Map startedAgain = server.startNegotiation(NegotiationType.TARGET, "请澄清目标。", Map.of()); - assertEquals( - "target", - ((Map) startedAgain.get(NegotiationHandler.NEGOTIATION_T_URI_NL)).get("negotiationType")); - } - - private Path writeEnv() throws IOException { - Path envFile = tempDir.resolve("server.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=test-coexistence-mock - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted()); - return envFile; - } - - public static final class RecordingClient implements LLMClient { - - private final LLMClientConfig config; - - public RecordingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse("{}", config.model(), Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Task-T and Negotiation-T coexistence regression: one server instance answers both the Task-T compliance API and the + * negotiation content-layer APIs without interference, and the pre-existing negotiation state machine keeps working + * next to the new APIs. + */ +class TaskPromptNegotiationCoexistenceTest { + + private static final String TEST_MOCK_PROVIDER = "test-coexistence-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_TEMPLATE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE_TEMPLATE.uri(); + + private static final String TASK_T_MESSAGE = "## 任务类型(Task Type)\n" + + "无线能效优化\n\n" + + "## 任务对象(Task Object)\n" + + "松山湖管委会\n\n" + + "## 任务目标(Task Target)\n" + + "总功耗降低30%\n"; + + @TempDir + Path tempDir; + + @Test + void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + PromptComplianceResult complianceResult = server.checkTaskPrompt(TASK_T_MESSAGE); + + assertFalse(complianceResult.success()); + assertEquals("scenario.not_matched", complianceResult.failure().code()); + assertEquals("prompt_parse", complianceResult.failure().stage()); + + NegotiationParamExtractionException negotiationFailure = assertThrows( + NegotiationParamExtractionException.class, + () -> server.validateProposePromptAndDataFilling( + TASK_T_MESSAGE, + null, + Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), + INFORMATION_PROPOSE_TEMPLATE)); + + assertEquals("negotiation.invalid_input", negotiationFailure.getCode()); + assertEquals("输入的协商内容无效:缺少协商上下文(该报文不是协商报文)", negotiationFailure.getMessage()); + + MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_TEMPLATE); + assertEquals(INFORMATION_PROPOSE_URI, negotiationMessage.templateUri()); + assertEquals(UUID, negotiationMessage.negotiationContext().id()); + } + + @Test + void oldNegotiationStateMachineKeepsWorkingNextToTheContentLayerApis() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + Map started = server.startNegotiation( + NegotiationType.INFORMATION, "需要完整的任务提示词。", Map.of("source", "coexistence-test")); + Map context = (Map) started.get(NegotiationHandler.NEGOTIATION_T_URI_NL); + assertEquals("information", context.get("negotiationType")); + assertEquals("in-progress", context.get("status")); + assertEquals("coexistence-test", ((Map) started.get("facts")).get("source")); + + MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_TEMPLATE); + assertEquals(2, negotiationMessage.negotiationContext().round()); + + Map startedAgain = server.startNegotiation(NegotiationType.TARGET, "请澄清目标。", Map.of()); + assertEquals( + "target", + ((Map) startedAgain.get(NegotiationHandler.NEGOTIATION_T_URI_NL)).get("negotiationType")); + } + + private Path writeEnv() throws IOException { + Path envFile = tempDir.resolve("server.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-coexistence-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted()); + return envFile; + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java index 7daaa153..4641752a 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java @@ -1,94 +1,94 @@ -package net.openan.a2at.sdk.server.assembly; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Map; -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import net.openan.a2at.sdk.server.compliance.ServerPromptComplianceOrchestrator; -import net.openan.a2at.sdk.server.model.PromptComplianceFailure; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import org.junit.jupiter.api.Test; - -class ServerNegotiationOrchestratorBuilderTest { - - @Test - void buildCreatesServerRoleOrchestratorThatUsesPromptComplianceForInformationNegotiation() { - RecordingPromptComplianceOrchestrator complianceOrchestrator = - new RecordingPromptComplianceOrchestrator(new PromptComplianceResult( - false, - new PromptComplianceFailure( - "slot_validation_error", "Need more information", "slot_validation"))); - - RoleBoundNegotiationOrchestrator orchestrator = new ServerNegotiationOrchestratorBuilder() - .promptComplianceOrchestrator(complianceOrchestrator) - .store(new InMemoryNegotiationStore()) - .build(); - - Map started = - orchestrator.startNegotiation(NegotiationType.INFORMATION, "Need full task prompt.", Map.of()); - Map context = cast(started.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); - - Map received = orchestrator.receiveNegotiation("latest full task prompt", context); - - assertEquals("latest full task prompt", complianceOrchestrator.lastProcessedPromptText); - assertEquals("Need more information", received.get("message")); - assertTrue(booleanValue(received.get("needResponse"))); - } - - @Test - void buildUsesServerRoleInStartPayload() { - RoleBoundNegotiationOrchestrator orchestrator = new ServerNegotiationOrchestratorBuilder() - .promptComplianceOrchestrator(processedPromptText -> new PromptComplianceResult(true, null)) - .store(new InMemoryNegotiationStore()) - .build(); - - Map result = orchestrator.startNegotiation( - NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); - Map context = cast(result.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); - - assertEquals("target", context.get("negotiationType")); - assertEquals("in-progress", context.get("status")); - } - - private static final class RecordingPromptComplianceOrchestrator implements ServerPromptComplianceOrchestrator { - private final PromptComplianceResult result; - private String lastProcessedPromptText; - - private RecordingPromptComplianceOrchestrator(PromptComplianceResult result) { - this.result = result; - } - - @Override - public PromptComplianceResult checkTaskPrompt(String processedPromptText) { - this.lastProcessedPromptText = processedPromptText; - return result; - } - } - - private static Map cast(Object value) { - if (value instanceof Map map) { - return map.entrySet().stream() - .collect(java.util.stream.Collectors.toMap( - entry -> stringValue(entry.getKey()), Map.Entry::getValue)); - } - throw new AssertionError("Expected map value but was: " + value); - } - - private static String stringValue(Object value) { - if (value instanceof String text) { - return text; - } - throw new AssertionError("Expected string value but was: " + value); - } - - private static boolean booleanValue(Object value) { - if (value instanceof Boolean booleanValue) { - return booleanValue; - } - throw new AssertionError("Expected boolean value but was: " + value); - } -} +package net.openan.a2at.sdk.server.assembly; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.server.compliance.ServerPromptComplianceOrchestrator; +import net.openan.a2at.sdk.server.model.PromptComplianceFailure; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.junit.jupiter.api.Test; + +class ServerNegotiationOrchestratorBuilderTest { + + @Test + void buildCreatesServerRoleOrchestratorThatUsesPromptComplianceForInformationNegotiation() { + RecordingPromptComplianceOrchestrator complianceOrchestrator = + new RecordingPromptComplianceOrchestrator(new PromptComplianceResult( + false, + new PromptComplianceFailure( + "slot.semantic_conflict", "Need more information", "slot_validation"))); + + RoleBoundNegotiationOrchestrator orchestrator = new ServerNegotiationOrchestratorBuilder() + .promptComplianceOrchestrator(complianceOrchestrator) + .store(new InMemoryNegotiationStore()) + .build(); + + Map started = + orchestrator.startNegotiation(NegotiationType.INFORMATION, "Need full task prompt.", Map.of()); + Map context = cast(started.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); + + Map received = orchestrator.receiveNegotiation("latest full task prompt", context); + + assertEquals("latest full task prompt", complianceOrchestrator.lastProcessedPromptText); + assertEquals("Need more information", received.get("message")); + assertTrue(booleanValue(received.get("needResponse"))); + } + + @Test + void buildUsesServerRoleInStartPayload() { + RoleBoundNegotiationOrchestrator orchestrator = new ServerNegotiationOrchestratorBuilder() + .promptComplianceOrchestrator(processedPromptText -> new PromptComplianceResult(true, null)) + .store(new InMemoryNegotiationStore()) + .build(); + + Map result = orchestrator.startNegotiation( + NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); + Map context = cast(result.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); + + assertEquals("target", context.get("negotiationType")); + assertEquals("in-progress", context.get("status")); + } + + private static final class RecordingPromptComplianceOrchestrator implements ServerPromptComplianceOrchestrator { + private final PromptComplianceResult result; + private String lastProcessedPromptText; + + private RecordingPromptComplianceOrchestrator(PromptComplianceResult result) { + this.result = result; + } + + @Override + public PromptComplianceResult checkTaskPrompt(String processedPromptText) { + this.lastProcessedPromptText = processedPromptText; + return result; + } + } + + private static Map cast(Object value) { + if (value instanceof Map map) { + return map.entrySet().stream() + .collect(java.util.stream.Collectors.toMap( + entry -> stringValue(entry.getKey()), Map.Entry::getValue)); + } + throw new AssertionError("Expected map value but was: " + value); + } + + private static String stringValue(Object value) { + if (value instanceof String text) { + return text; + } + throw new AssertionError("Expected string value but was: " + value); + } + + private static boolean booleanValue(Object value) { + if (value instanceof Boolean booleanValue) { + return booleanValue; + } + throw new AssertionError("Expected boolean value but was: " + value); + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java index 6f24a1c6..3c553581 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java @@ -1,145 +1,147 @@ -package net.openan.a2at.sdk.server.compliance; - -import net.openan.a2at.sdk.server.metadata.ServerPromptMetadataExtractor; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.validation.ServerPromptSemanticValidator; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Map; -import org.junit.jupiter.api.Test; - -class DefaultServerPromptComplianceOrchestratorTest { - - @Test - void checkTaskPromptReturnsSuccessWhenMetadataExtractionAndValidationPass() { - ProcessedPromptMetadata metadata = - new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A")); - RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor(metadata); - RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); - DefaultServerPromptComplianceOrchestrator orchestrator = - new DefaultServerPromptComplianceOrchestrator(extractor, validator); - - PromptComplianceResult result = orchestrator.checkTaskPrompt("Site: Site A"); - - assertTrue(result.success()); - assertThrows(NoSuchMethodException.class, () -> PromptComplianceResult.class.getMethod("metadata")); - assertEquals(null, result.failure()); - assertEquals("Site: Site A", extractor.lastProcessedPromptText); - assertEquals("Site: Site A", validator.lastProcessedPromptText); - assertEquals(metadata, validator.lastMetadata); - } - - @Test - void checkTaskPromptReturnsFailureWhenMetadataExtractionFails() { - DefaultServerPromptComplianceOrchestrator orchestrator = new DefaultServerPromptComplianceOrchestrator( - new RecordingPromptMetadataExtractor(new PromptComplianceCheckException( - "processed_prompt_parse_error", "Prompt does not match any known template.", "prompt_parse")), - new RecordingPromptSemanticValidator(null)); - - PromptComplianceResult result = orchestrator.checkTaskPrompt("Unknown"); - - assertEquals(false, result.success()); - assertEquals("processed_prompt_parse_error", result.failure().code()); - assertEquals("prompt_parse", result.failure().stage()); - } - - @Test - void checkTaskPromptReturnsFailureWhenSemanticValidationFails() { - ProcessedPromptMetadata metadata = - new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A")); - DefaultServerPromptComplianceOrchestrator orchestrator = new DefaultServerPromptComplianceOrchestrator( - new RecordingPromptMetadataExtractor(metadata), - new RecordingPromptSemanticValidator(new PromptComplianceCheckException( - "slot_validation_error", - "Prompt is not semantically consistent with extracted slots.", - "slot_validation"))); - - PromptComplianceResult result = orchestrator.checkTaskPrompt("Site: Site A"); - - assertEquals(false, result.success()); - assertEquals("slot_validation_error", result.failure().code()); - assertEquals("slot_validation", result.failure().stage()); - } - - @Test - void checkTaskPromptReturnsInputTooLongFailureWithoutRunningThePipelineWhenOverLimit() { - RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor( - new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A"))); - RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); - DefaultServerPromptComplianceOrchestrator orchestrator = - new DefaultServerPromptComplianceOrchestrator(extractor, validator, 5); - String oversizedInput = "a".repeat(6); - - PromptComplianceResult result = orchestrator.checkTaskPrompt(oversizedInput); - - assertEquals(false, result.success()); - assertEquals("input_text_too_long", result.failure().code()); - assertEquals("input_gate", result.failure().stage()); - assertEquals(null, extractor.lastProcessedPromptText, "Extractor must not run for an oversized input"); - assertEquals(null, validator.lastProcessedPromptText, "Validator must not run for an oversized input"); - } - - @Test - void checkTaskPromptRunsThePipelineWhenInputIsExactlyAtLimit() { - RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor( - new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A"))); - RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); - DefaultServerPromptComplianceOrchestrator orchestrator = - new DefaultServerPromptComplianceOrchestrator(extractor, validator, 5); - String boundaryInput = "a".repeat(5); - - PromptComplianceResult result = orchestrator.checkTaskPrompt(boundaryInput); - - assertTrue(result.success()); - assertEquals(boundaryInput, extractor.lastProcessedPromptText); - } - - private static final class RecordingPromptMetadataExtractor implements ServerPromptMetadataExtractor { - private final ProcessedPromptMetadata metadata; - private final PromptComplianceCheckException exception; - private String lastProcessedPromptText; - - private RecordingPromptMetadataExtractor(ProcessedPromptMetadata metadata) { - this.metadata = metadata; - this.exception = null; - } - - private RecordingPromptMetadataExtractor(PromptComplianceCheckException exception) { - this.metadata = null; - this.exception = exception; - } - - @Override - public ProcessedPromptMetadata extract(String processedPromptText) { - this.lastProcessedPromptText = processedPromptText; - if (exception != null) { - throw exception; - } - return metadata; - } - } - - private static final class RecordingPromptSemanticValidator implements ServerPromptSemanticValidator { - private final PromptComplianceCheckException exception; - private String lastProcessedPromptText; - private ProcessedPromptMetadata lastMetadata; - - private RecordingPromptSemanticValidator(PromptComplianceCheckException exception) { - this.exception = exception; - } - - @Override - public void validate(String processedPromptText, ProcessedPromptMetadata metadata) { - this.lastProcessedPromptText = processedPromptText; - this.lastMetadata = metadata; - if (exception != null) { - throw exception; - } - } - } -} +package net.openan.a2at.sdk.server.compliance; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; +import net.openan.a2at.sdk.server.metadata.ServerPromptMetadataExtractor; +import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import net.openan.a2at.sdk.server.validation.ServerPromptSemanticValidator; +import org.junit.jupiter.api.Test; + +class DefaultServerPromptComplianceOrchestratorTest { + + @Test + void checkTaskPromptReturnsSuccessWhenMetadataExtractionAndValidationPass() { + ProcessedPromptMetadata metadata = + new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A")); + RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor(metadata); + RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); + DefaultServerPromptComplianceOrchestrator orchestrator = + new DefaultServerPromptComplianceOrchestrator(extractor, validator); + + PromptComplianceResult result = orchestrator.checkTaskPrompt("Site: Site A"); + + assertTrue(result.success()); + assertThrows(NoSuchMethodException.class, () -> PromptComplianceResult.class.getMethod("metadata")); + assertEquals(null, result.failure()); + assertEquals("Site: Site A", extractor.lastProcessedPromptText); + assertEquals("Site: Site A", validator.lastProcessedPromptText); + assertEquals(metadata, validator.lastMetadata); + } + + @Test + void checkTaskPromptReturnsFailureWhenMetadataExtractionFails() { + DefaultServerPromptComplianceOrchestrator orchestrator = new DefaultServerPromptComplianceOrchestrator( + new RecordingPromptMetadataExtractor(new PromptComplianceCheckException( + "scenario.not_mapped", "Prompt does not match any known template.", "prompt_parse")), + new RecordingPromptSemanticValidator(null)); + + PromptComplianceResult result = orchestrator.checkTaskPrompt("Unknown"); + + assertEquals(false, result.success()); + assertEquals("scenario.not_mapped", result.failure().code()); + assertEquals("prompt_parse", result.failure().stage()); + } + + @Test + void checkTaskPromptReturnsFailureWhenSemanticValidationFails() { + ProcessedPromptMetadata metadata = + new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A")); + DefaultServerPromptComplianceOrchestrator orchestrator = new DefaultServerPromptComplianceOrchestrator( + new RecordingPromptMetadataExtractor(metadata), + new RecordingPromptSemanticValidator(new PromptComplianceCheckException( + "slot.semantic_conflict", + "The value of 'site' conflicts with the slot definition.", + "slot_validation"))); + + PromptComplianceResult result = orchestrator.checkTaskPrompt("Site: Site A"); + + assertEquals(false, result.success()); + assertEquals("slot.semantic_conflict", result.failure().code()); + assertEquals("slot_validation", result.failure().stage()); + } + + @Test + void checkTaskPromptReturnsInputTooLongFailureWithoutRunningThePipelineWhenOverLimit() { + RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor( + new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A"))); + RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); + DefaultServerPromptComplianceOrchestrator orchestrator = + new DefaultServerPromptComplianceOrchestrator(extractor, validator, 5); + String oversizedInput = "a".repeat(6); + + PromptComplianceResult result = orchestrator.checkTaskPrompt(oversizedInput); + + assertEquals(false, result.success()); + assertEquals("input.text_too_long", result.failure().code()); + assertEquals("input_gate", result.failure().stage()); + assertEquals( + "Input text length 6 exceeds the maximum of 5 (A2AT_INPUT_TEXT_MAX_CHARS)", + result.failure().message()); + assertEquals(null, extractor.lastProcessedPromptText, "Extractor must not run for an oversized input"); + assertEquals(null, validator.lastProcessedPromptText, "Validator must not run for an oversized input"); + } + + @Test + void checkTaskPromptRunsThePipelineWhenInputIsExactlyAtLimit() { + RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor( + new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A"))); + RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); + DefaultServerPromptComplianceOrchestrator orchestrator = + new DefaultServerPromptComplianceOrchestrator(extractor, validator, 5); + String boundaryInput = "a".repeat(5); + + PromptComplianceResult result = orchestrator.checkTaskPrompt(boundaryInput); + + assertTrue(result.success()); + assertEquals(boundaryInput, extractor.lastProcessedPromptText); + } + + private static final class RecordingPromptMetadataExtractor implements ServerPromptMetadataExtractor { + private final ProcessedPromptMetadata metadata; + private final PromptComplianceCheckException exception; + private String lastProcessedPromptText; + + private RecordingPromptMetadataExtractor(ProcessedPromptMetadata metadata) { + this.metadata = metadata; + this.exception = null; + } + + private RecordingPromptMetadataExtractor(PromptComplianceCheckException exception) { + this.metadata = null; + this.exception = exception; + } + + @Override + public ProcessedPromptMetadata extract(String processedPromptText) { + this.lastProcessedPromptText = processedPromptText; + if (exception != null) { + throw exception; + } + return metadata; + } + } + + private static final class RecordingPromptSemanticValidator implements ServerPromptSemanticValidator { + private final PromptComplianceCheckException exception; + private String lastProcessedPromptText; + private ProcessedPromptMetadata lastMetadata; + + private RecordingPromptSemanticValidator(PromptComplianceCheckException exception) { + this.exception = exception; + } + + @Override + public void validate(String processedPromptText, ProcessedPromptMetadata metadata) { + this.lastProcessedPromptText = processedPromptText; + this.lastMetadata = metadata; + if (exception != null) { + throw exception; + } + } + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java index 967e89a9..844b2ae3 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java @@ -1,147 +1,281 @@ -package net.openan.a2at.sdk.server.metadata; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import org.junit.jupiter.api.Test; - -class LlmBackedPromptMetadataExtractorTest { - - private static final String SLOT_NAME = "notification_topic"; - - @Test - void extractResolvesScenarioLoadsTemplateAndReturnsExtractedSlots() { - LLMClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); - PromptTemplateTextLoader templateLoader = - (scenarioCode, language) -> "## notification_topic\n{{notification_topic}}\n"; - PromptSlotSchemaLoader slotSchemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition(SLOT_NAME, true, "string", null, null, null, null, null, null, null))); - PromptSlotValueExtractor slotValueExtractor = (userInput, scenarioCode, language) -> - new StructuredSlotExtractionResult(Map.of(SLOT_NAME, "Incident"), List.of()); - LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(llmClient), - List.of(new ScenarioDefinition( - "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), - "zh-CN", - "Identify scenario.", - "Choose scenario.", - templateLoader, - slotSchemaLoader, - slotValueExtractor); - - ProcessedPromptMetadata metadata = extractor.extract("## notification_topic\nIncident\n"); - - assertEquals("subscribe-incident", metadata.scenarioCode()); - assertEquals("zh-CN", metadata.language()); - assertEquals("## notification_topic\n{{notification_topic}}\n", metadata.templateText()); - assertEquals(Map.of(SLOT_NAME, "Incident"), metadata.slots()); - } - - @Test - void extractReturnsPromptParseErrorWhenScenarioRecognitionDoesNotMatch() { - LLMClient llmClient = new RecordingClient( - "{\"matched\":false,\"scenario_code\":null,\"error_message\":\"No scenario matched.\"}"); - LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(llmClient), - List.of(new ScenarioDefinition( - "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), - "zh-CN", - "Identify scenario.", - "Choose scenario.", - (scenarioCode, language) -> "", - (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("unknown prompt")); - - assertEquals("processed_prompt_parse_error", error.getCode()); - assertEquals("prompt_parse", error.getStage()); - } - - @Test - void extractReturnsSlotValidationErrorWhenStructuredExtractionReportsSlotErrors() { - LLMClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); - LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(llmClient), - List.of(new ScenarioDefinition( - "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), - "zh-CN", - "Identify scenario.", - "Choose scenario.", - (scenarioCode, language) -> "template", - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition( - SLOT_NAME, true, "string", null, null, null, null, null, null, null))), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( - Map.of(SLOT_NAME, ""), - List.of(new StructuredSlotValidationError(SLOT_NAME, "missing_input", "topic is missing")))); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); - - assertEquals("slot_validation_error", error.getCode()); - assertEquals("slot_validation", error.getStage()); - } - - @Test - void extractPropagatesTemplateLoadErrorsAsGenerationFailures() { - LLMClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); - LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(llmClient), - List.of(new ScenarioDefinition( - "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), - "zh-CN", - "Identify scenario.", - "Choose scenario.", - (scenarioCode, language) -> { - throw new ResourceNotFoundException("Prompt resource file does not exist.", "template.md"); - }, - (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("prompt")); - - assertEquals("template_not_found", error.getCode()); - assertEquals("generation", error.getStage()); - } - - private static final class RecordingClient implements LLMClient { - private final String payload; - - private RecordingClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.server.metadata; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; +import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; +import org.junit.jupiter.api.Test; + +class LlmBackedPromptMetadataExtractorTest { + + private static final String SLOT_NAME = "notification_topic"; + + @Test + void extractResolvesScenarioLoadsTemplateAndReturnsExtractedSlots() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + PromptTemplateTextLoader templateLoader = + (scenarioCode, language) -> "## notification_topic\n{{notification_topic}}\n"; + PromptSlotSchemaLoader slotSchemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition(SLOT_NAME, true, "string", null, null, null, null, null, null, null))); + PromptSlotValueExtractor slotValueExtractor = (userInput, scenarioCode, language) -> + new StructuredSlotExtractionResult(Map.of(SLOT_NAME, "Incident"), List.of()); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + templateLoader, + slotSchemaLoader, + slotValueExtractor); + + ProcessedPromptMetadata metadata = extractor.extract("## notification_topic\nIncident\n"); + + assertEquals("subscribe-incident", metadata.scenarioCode()); + assertEquals("zh-CN", metadata.language()); + assertEquals("## notification_topic\n{{notification_topic}}\n", metadata.templateText()); + assertEquals(Map.of(SLOT_NAME, "Incident"), metadata.slots()); + } + + @Test + void extractReturnsScenarioNotMatchedWhenScenarioRecognitionDoesNotMatch() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":false,\"scenario_code\":null,\"error_message\":\"No scenario matched.\"}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> "", + (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("unknown prompt")); + + assertEquals("scenario.not_matched", error.getCode()); + assertEquals("prompt_parse", error.getStage()); + assertTrue(error.getMessage().contains("No scenario matched."), "message must render the recognition reason"); + } + + @Test + void extractReturnsScenarioNotMatchedWhenRecognitionFailsWithoutReason() { + LLMClient llmClient = new RecordingClient("{\"matched\":false,\"scenario_code\":null,\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> "", + (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("unknown prompt")); + + assertEquals("scenario.not_matched", error.getCode()); + assertEquals("prompt_parse", error.getStage()); + } + + @Test + void extractReturnsCatalogSlotCodeWhenStructuredExtractionReportsSlotErrors() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> "template", + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + SLOT_NAME, true, "string", null, null, null, null, null, null, null))), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of(SLOT_NAME, ""), + List.of(new StructuredSlotValidationError( + SLOT_NAME, "slot.not_provided", "topic is missing")))); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); + + assertEquals("slot.not_provided", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertTrue(error.getMessage().contains(SLOT_NAME), "message must render the slot label"); + assertEquals(Map.of("slot_label", SLOT_NAME), error.getFacts()); + } + + @Test + void extractFallsBackToSlotRuleViolationForUnknownExtractionErrorCode() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> "template", + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + SLOT_NAME, true, "string", null, null, null, null, null, null, null))), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of(SLOT_NAME, ""), + List.of(new StructuredSlotValidationError(SLOT_NAME, "data_problem", "whatever")))); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); + + assertEquals("slot.rule_violation", error.getCode()); + assertEquals("slot_validation", error.getStage()); + } + + @Test + void extractReturnsSlotNotExtractedWhenRequiredSlotIsMissing() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> "template", + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + SLOT_NAME, true, "string", null, null, null, null, null, null, null))), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); + + assertEquals("slot.not_provided", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertTrue(error.getMessage().contains(SLOT_NAME), "message must render the slot label"); + } + + @Test + void extractReturnsSlotConstraintViolatedWhenValueIsOutsideAllowedValues() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> "template", + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + SLOT_NAME, true, "string", null, null, null, List.of("Incident"), null, null, null))), + (userInput, scenarioCode, language) -> + new StructuredSlotExtractionResult(Map.of(SLOT_NAME, "Outage"), List.of())); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); + + assertEquals("slot.constraint_violated", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertTrue(error.getMessage().contains("Outage"), "message must render the offending value"); + assertEquals(Map.of("slot_label", SLOT_NAME, "actual", "Outage"), error.getFacts()); + } + + @Test + void extractPropagatesTemplateLoadErrorsAsGenerationFailures() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> { + throw new ResourceNotFoundException("Prompt resource file does not exist.", "template.md"); + }, + (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("prompt")); + + assertEquals("template.not_found", error.getCode()); + assertEquals("generation", error.getStage()); + assertTrue(error.getMessage().contains("subscribe-incident"), "message must render the template URI"); + assertTrue(error.getMessage().contains("zh-CN"), "message must render the language"); + } + + @Test + void extractWrapsUnexpectedTemplateLoadErrorsAsResourceReadFailures() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> { + throw new A2ATError("boom"); + }, + (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("prompt")); + + assertEquals("infra.resource_read_failed", error.getCode()); + assertEquals("generation", error.getStage()); + } + + private static final class RecordingClient implements LLMClient { + private final String payload; + + private RecordingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidatorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidatorTest.java index f61a64ae..7d7971bb 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidatorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidatorTest.java @@ -1,52 +1,54 @@ -package net.openan.a2at.sdk.server.validation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import org.junit.jupiter.api.Test; - -class InputLimitedContentValidatorTest { - - @Test - void validateThrowsInputTooLongWithoutDelegationWhenPromptExceedsLimit() { - RecordingContentValidator delegate = new RecordingContentValidator(); - InputLimitedContentValidator validator = new InputLimitedContentValidator(delegate, 5); - String oversizedPrompt = "a".repeat(6); - - ContentValidationException error = assertThrows( - ContentValidationException.class, - () -> validator.validate(oversizedPrompt, Map.of(), StandardTemplates.ENERGY_SAVING)); - - assertEquals(A2ATErrorCodes.INPUT_TEXT_TOO_LONG, error.getCode()); - assertEquals(null, delegate.lastPrompt, "Delegate must not run for an oversized prompt"); - } - - @Test - void validateDelegatesWhenPromptIsExactlyAtLimit() { - RecordingContentValidator delegate = new RecordingContentValidator(); - InputLimitedContentValidator validator = new InputLimitedContentValidator(delegate, 5); - String boundaryPrompt = "a".repeat(5); - - FilledParamData result = validator.validate(boundaryPrompt, Map.of(), StandardTemplates.ENERGY_SAVING); - - assertEquals(boundaryPrompt, delegate.lastPrompt); - assertEquals(FilledParamData.class, result.getClass()); - } - - private static final class RecordingContentValidator implements ContentValidator { - - private String lastPrompt; - - @Override - public FilledParamData validate(String prompt, Map schema, net.openan.a2at.sdk.core.model.TemplateUri templateUri) { - this.lastPrompt = prompt; - return new FilledParamData(Map.of()); - } - } -} +package net.openan.a2at.sdk.server.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import org.junit.jupiter.api.Test; + +class InputLimitedContentValidatorTest { + + @Test + void validateThrowsInputTooLongWithoutDelegationWhenPromptExceedsLimit() { + RecordingContentValidator delegate = new RecordingContentValidator(); + InputLimitedContentValidator validator = new InputLimitedContentValidator(delegate, 5); + String oversizedPrompt = "a".repeat(6); + + ContentValidationException error = assertThrows( + ContentValidationException.class, + () -> validator.validate(oversizedPrompt, Map.of(), StandardTemplates.ENERGY_SAVING)); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), error.getCode()); + assertEquals("Input text length 6 exceeds the maximum of 5 (A2AT_INPUT_TEXT_MAX_CHARS)", error.getMessage()); + assertEquals(null, delegate.lastPrompt, "Delegate must not run for an oversized prompt"); + } + + @Test + void validateDelegatesWhenPromptIsExactlyAtLimit() { + RecordingContentValidator delegate = new RecordingContentValidator(); + InputLimitedContentValidator validator = new InputLimitedContentValidator(delegate, 5); + String boundaryPrompt = "a".repeat(5); + + FilledParamData result = validator.validate(boundaryPrompt, Map.of(), StandardTemplates.ENERGY_SAVING); + + assertEquals(boundaryPrompt, delegate.lastPrompt); + assertEquals(FilledParamData.class, result.getClass()); + } + + private static final class RecordingContentValidator implements ContentValidator { + + private String lastPrompt; + + @Override + public FilledParamData validate( + String prompt, Map schema, net.openan.a2at.sdk.core.model.TemplateUri templateUri) { + this.lastPrompt = prompt; + return new FilledParamData(Map.of()); + } + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java index c3fc1df7..31ad3a26 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java @@ -1,168 +1,221 @@ -package net.openan.a2at.sdk.server.validation; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import org.junit.jupiter.api.Test; - -class LlmBackedPromptSemanticValidatorTest { - - private static final String SLOT_NAME = "notification_topic"; - - private static final PromptSlotSchemaLoader SLOT_SCHEMA_LOADER = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition(SLOT_NAME, true, "string", null, null, null, null, null, null, null))); - - @Test - void validatePassesWhenSemanticValidatorApprovesSlots() { - LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":[]}"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - assertDoesNotThrow(() -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - } - - @Test - void validatePassesWhenSemanticValidatorReturnsFormattedJson() { - LLMClient llmClient = new RecordingClient( - """ - { - "passed": true, - "errors": [] - } - """); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - assertDoesNotThrow(() -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - } - - @Test - void validateReturnsSlotValidationErrorWhenSemanticValidatorRejectsSlots() { - LLMClient llmClient = new RecordingClient("{\"passed\":false,\"errors\":[{\"slot_name\":\"notification_topic\"," - + "\"code\":\"semantic_mismatch\",\"message\":\"topic does not match\"}]}"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - PromptComplianceCheckException error = assertThrows( - PromptComplianceCheckException.class, - () -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - - assertEquals("slot_validation_error", error.getCode()); - assertEquals("slot_validation", error.getStage()); - } - - @Test - void validateRejectsWhenPassedTrueButErrorsNonEmpty() { - LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":[{\"slot_name\":\"notification_topic\"," - + "\"code\":\"semantic_mismatch\",\"message\":\"topic does not match\"}]}"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - PromptComplianceCheckException error = assertThrows( - PromptComplianceCheckException.class, - () -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - - assertEquals("slot_validation_error", error.getCode()); - assertEquals("slot_validation", error.getStage()); - assertEquals("topic does not match", error.getMessage()); - } - - @Test - void validatePassesWhenPassedTrueAndErrorsMissing() { - LLMClient llmClient = new RecordingClient("{\"passed\":true}"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - assertDoesNotThrow(() -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - } - - @Test - void validatePassesWhenPassedTrueAndErrorsNull() { - LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":null}"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - assertDoesNotThrow(() -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - } - - @Test - void validateRejectsWhenPassedFalseWithEmptyErrors() { - LLMClient llmClient = new RecordingClient("{\"passed\":false,\"errors\":[]}"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - assertThrows( - PromptComplianceCheckException.class, - () -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - } - - private static final class RecordingClient implements LLMClient { - private final String payload; - - private RecordingClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.server.validation; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; +import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; +import org.junit.jupiter.api.Test; + +class LlmBackedPromptSemanticValidatorTest { + + private static final String SLOT_NAME = "notification_topic"; + + private static final PromptSlotSchemaLoader SLOT_SCHEMA_LOADER = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition(SLOT_NAME, true, "string", null, null, null, null, null, null, null))); + + @Test + void validatePassesWhenSemanticValidatorApprovesSlots() { + LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":[]}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + assertDoesNotThrow(() -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + } + + @Test + void validatePassesWhenSemanticValidatorReturnsFormattedJson() { + LLMClient llmClient = new RecordingClient( + """ + { + "passed": true, + "errors": [] + } + """); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + assertDoesNotThrow(() -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + } + + @Test + void validateReturnsCatalogCodeWhenSemanticValidatorRejectsSlots() { + LLMClient llmClient = new RecordingClient("{\"passed\":false,\"errors\":[{\"slot_name\":\"notification_topic\"," + + "\"code\":\"slot.semantic_conflict\"," + + "\"facts\":{\"slot_label\":\"notification_topic\",\"reason\":\"topic does not match\"}}]}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + PromptComplianceCheckException error = assertThrows( + PromptComplianceCheckException.class, + () -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + + assertEquals("slot.semantic_conflict", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertTrue(error.getMessage().contains("notification_topic"), "message must render the slot label"); + assertTrue(error.getMessage().contains("topic does not match"), "message must render the conflict reason"); + assertEquals(Map.of("slot_label", "notification_topic", "reason", "topic does not match"), error.getFacts()); + } + + @Test + void validateMapsUnknownCodeToSlotRuleViolationFallback() { + LLMClient llmClient = new RecordingClient("{\"passed\":false,\"errors\":[{\"slot_name\":\"notification_topic\"," + + "\"code\":\"data_problem\"," + + "\"facts\":{\"slot_label\":\"notification_topic\"}}]}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + PromptComplianceCheckException error = assertThrows( + PromptComplianceCheckException.class, + () -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + + assertEquals("slot.rule_violation", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertTrue(error.getMessage().contains("notification_topic"), "message must render the slot label"); + } + + @Test + void validateRejectsWhenPassedTrueButErrorsNonEmpty() { + LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":[{\"slot_name\":\"notification_topic\"," + + "\"code\":\"slot.semantic_conflict\"," + + "\"facts\":{\"slot_label\":\"notification_topic\",\"reason\":\"topic does not match\"}}]}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + PromptComplianceCheckException error = assertThrows( + PromptComplianceCheckException.class, + () -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + + assertEquals("slot.semantic_conflict", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertTrue(error.getMessage().contains("topic does not match"), "message must render the conflict reason"); + } + + @Test + void validatePassesWhenPassedTrueAndErrorsMissing() { + LLMClient llmClient = new RecordingClient("{\"passed\":true}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + assertDoesNotThrow(() -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + } + + @Test + void validatePassesWhenPassedTrueAndErrorsNull() { + LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":null}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + assertDoesNotThrow(() -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + } + + @Test + void validateRejectsWithResponseInvalidWhenPassedFalseWithEmptyErrors() { + LLMClient llmClient = new RecordingClient("{\"passed\":false,\"errors\":[]}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + PromptComplianceCheckException error = assertThrows( + PromptComplianceCheckException.class, + () -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + + assertEquals("llm.response_invalid", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertEquals(Map.of("step", "semantic_validation"), error.getFacts()); + } + + @Test + void validateRejectsWithResponseInvalidWhenPayloadIsNotJson() { + LLMClient llmClient = new RecordingClient("not-json"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + PromptComplianceCheckException error = assertThrows( + PromptComplianceCheckException.class, + () -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + + assertEquals("llm.response_invalid", error.getCode()); + assertEquals("slot_validation", error.getStage()); + } + + private static final class RecordingClient implements LLMClient { + private final String payload; + + private RecordingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} From 615ced138fe314f7c1576e742709540e8b0532a2 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 29 Aug 2026 13:19:35 +0800 Subject: [PATCH 240/295] test(corpus,sample): migrate expectations to the catalog error contract Rewrite corpus golden expectations and injected LLM fixtures to the {slot_name, code, facts} schema and dotted codes, migrate the authz sample scenario sets and evaluators, and update corpus_review.py code dictionary to the new codes. Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/corpus/CaseEngine.java | 1865 +++++++------- .../a2at/sdk/corpus/CaseEngineTest.java | 1728 ++++++------- .../a2at/sdk/corpus/CorpusContractTest.java | 1141 ++++----- .../sdk/corpus/CorpusSensitivitySelfTest.java | 950 ++++---- .../openan/a2at/sdk/corpus/Expectation.java | 256 +- .../a2at/sdk/corpus/LiveCaseEngine.java | 955 ++++---- .../a2at/sdk/corpus/LiveCaseEngineTest.java | 571 +++-- .../sdk/corpus/NegotiationCaseLoaderTest.java | 2133 +++++++++-------- .../a2at/sdk/corpus/ScenarioEngineTest.java | 1048 ++++---- .../ErrorCodePartitionPropertyTest.java | 521 ++-- .../NegotiationErrorPathPropertyTest.java | 659 ++--- .../test/resources/negotiation-cases/INDEX.md | 188 +- .../negotiation-cases/corpus-schema.json | 2 +- .../from-data/programming-errors.json | 46 +- .../from-text/extraction-failures.json | 34 +- .../from-text/programming-errors.json | 4 +- .../negotiation-cases/from-text/retry.json | 35 +- .../from-text/template-resolution.json | 16 +- .../negotiation-cases/live/task-apis.json | 2 +- .../scenarios/boundary-flows.json | 24 +- .../shared/llm-responses.json | 6 +- .../validate/error-code-mapping.json | 34 +- .../validate/llm-shape-and-retry.json | 40 +- .../validate/merge-and-schema.json | 24 +- .../validate/programming-errors.json | 56 +- .../negotiation-cases/validate/rule-gate.json | 22 +- .../validate/semantic-verdicts.json | 46 +- .../sample/authz_policy/AuthzSampleMain.java | 2 +- .../sample/authz_policy/AuthzScenario.java | 321 +-- .../authz_policy/AuthzScenarioExecutor.java | 10 +- .../authz_policy/AuthzScenarioRunner.java | 8 +- .../negotiation/eval/NegotiationEvalApp.java | 2 +- .../NegotiationAbnormalEvaluationMain.java | 34 +- .../a2at/sample/task_t/TaskTDemoMain.java | 2 +- .../TaskTPrivateLineComplaintSamples.java | 2 +- .../resources/sample/authz-policy/README.md | 8 +- .../sample/authz-policy/scenarios-100.json | 203 +- .../authz-policy/scenarios-holdout-100.json | 170 +- .../sample/authz-policy/scenarios.json | 30 +- .../authz-policy/smoke-io-transcript.md | 87 +- .../evaluation/abnormal-cases.json | 26 +- .../evaluation/business-abnormal-cases.json | 24 +- .../AuthzSampleMainConcurrencyTest.java | 455 ++-- .../authz_policy/AuthzSampleMainTest.java | 460 ++-- .../AuthzScenarioExecutorTest.java | 399 ++- .../authz_policy/AuthzScenarioLoaderTest.java | 327 +-- .../authz_policy/AuthzScenarioRunnerTest.java | 1170 +++++---- .../authz_policy/AuthzScenarioTest.java | 478 ++-- .../accuracy/NotificationTAccuracyTest.java | 619 ++--- a2a-t-sample/task-t-test-cases.md | 4 +- tools/corpus_review.py | 35 +- 51 files changed, 8739 insertions(+), 8543 deletions(-) diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java index f0b63c75..5016a0d3 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java @@ -1,921 +1,944 @@ -package net.openan.a2at.sdk.corpus; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; -import org.jspecify.annotations.Nullable; - -/** - * Executes one expanded corpus case against the production negotiation content wiring and asserts its expectation - * block. - * - *

        Everything except the LLM client is production assembly: the case engine builds a real - * {@link NegotiationGenerationOrchestrator} through {@link NegotiationGenerationOrchestratorBuilder} for the language of - * the case, wires the {@code inject} hooks onto their real builder injection points ({@code failingTemplateLoader} for - * the generation template-not-found matrix, {@code failingSemanticValidator} for the validate-family - * prompt-resource-not-found mapping) and dispatches on the {@link NegotiationApi} enum at compile time, so a misspelled API - * name fails at corpus load time and a renamed service method fails this compilation. The three task-family APIs run - * through the {@link TaskApiAssembler}, the real facade builders' assembly of the closed loop (Q21) with the scripted LLM - * client injected at the builders' LLM seam. - * - *

        Result normalization: a success run returns the produced {@link MetadataContent} or {@link FilledParamData}, a - * failure run captures the thrown exception. The expectation comparison covers outcome, exception name, error code, - * message fragments, slot errors (slot plus code pairs, exact but order-insensitive), the exact LLM call count, the - * golden fixture text (per language, byte equality after CRLF normalization), the metadata echoes, the expected merged - * parameter map, the P0 behavior contracts and the Q17 C+ differential double run - * (fromText == fromData == golden, from-data side proven zero-call with an assertion-only client). Every mismatch fails - * with the case id, the JSON path of the expectation and the expected-versus-actual pair. - * - * @since 2026-08 - */ -public final class CaseEngine { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private static final String INJECT_FAILING_TEMPLATE_LOADER = "failingTemplateLoader"; - - private static final String INJECT_FAILING_SEMANTIC_VALIDATOR = "failingSemanticValidator"; - - private static final int MAX_REPORTED_TEXT_LENGTH = 400; - - /** - * Runs one case without a prompt override. - * - * @param testCase expanded corpus case - * @return the normalized outcome of the run - * @throws AssertionError when any expectation mismatches - */ - public CaseOutcome run(NegotiationCase testCase) { - return run(testCase, null); - } - - /** - * Runs one case, optionally overriding the prompt input of the validate family. - * - *

        The {@link ScenarioEngine} resolves {@code prompt.fromStep} references into prompt texts and hands them in - * through this parameter; a standalone validate case resolves its golden or inline prompt itself. - * - * @param testCase expanded corpus case - * @param promptOverride prompt text resolved from an earlier scenario step, or null - * @return the normalized outcome of the run - * @throws AssertionError when any expectation mismatches - */ - public CaseOutcome run(NegotiationCase testCase, @Nullable String promptOverride) { - return run(testCase, promptOverride, false); - } - - /** - * Runs one case, optionally overriding the prompt input of the validate family and optionally running as a - * scenario step. - * - *

        The {@link ScenarioEngine} resolves {@code prompt.fromStep} references into prompt texts and hands them in - * through the prompt parameter; a standalone validate case resolves its golden or inline prompt itself. Scenario - * mode defers the {@code expect.paramsFromStep} check to the scenario engine, which resolves the cross-step - * reference after the step ran. - * - * @param testCase expanded corpus case - * @param promptOverride prompt text resolved from an earlier scenario step, or null - * @param inScenario true inside the ScenarioEngine, false for a standalone run - * @return the normalized outcome of the run - * @throws AssertionError when any expectation mismatches - */ - CaseOutcome run(NegotiationCase testCase, @Nullable String promptOverride, boolean inScenario) { - ScriptedNegotiationLlmClient llmClient = llmClientFor(testCase); - NegotiationContentService service = new NegotiationContentService(orchestratorFor(testCase, llmClient)); - TaskApiAssembler taskApi = - testCase.api().family() == NegotiationApi.Family.TASK ? taskApiFor(testCase, llmClient) : null; - Object value = null; - Throwable failure = null; - try { - value = invoke(service, taskApi, testCase, promptOverride); - } catch (RuntimeException | AssertionError error) { - failure = error; - } - CaseOutcome outcome = new CaseOutcome(testCase, value, failure, llmClient.callCount(), llmClient); - assertExpectations(outcome, inScenario); - return outcome; - } - - // ------------------------------------------------------------------ outcome record - - /** - * The normalized outcome of one executed case. - * - * @param testCase the executed expanded corpus case - * @param value the returned {@code MetadataContent} or {@code FilledParamData} on success, or null on failure - * @param failure the captured exception on failure, or null on success - * @param llmCalls exact number of LLM calls the run made - * @param llmClient the scripted client the run used - */ - public record CaseOutcome( - NegotiationCase testCase, - @Nullable Object value, - @Nullable Throwable failure, - int llmCalls, - ScriptedNegotiationLlmClient llmClient) { - - /** - * Returns the generated message of a successful generation run. - * - * @return the metadata content, or null when the run failed or produced filled parameters - */ - public @Nullable MetadataContent message() { - return value instanceof MetadataContent metadata ? metadata : null; - } - - /** - * Returns the filled parameters of a successful validation run. - * - * @return the filled parameter data, or null when the run failed or produced a message - */ - public @Nullable FilledParamData filledParams() { - return value instanceof FilledParamData filled ? filled : null; - } - } - - // ------------------------------------------------------------------ expectation comparison - - private void assertExpectations(CaseOutcome outcome, boolean inScenario) { - NegotiationCase testCase = outcome.testCase(); - Expectation expect = testCase.expect(); - if (expect.success()) { - if (outcome.failure() != null) { - fail(testCase, "$.expect.outcome", "success", "failure (" + outcome.failure() + ")"); - } - assertSuccessFields(outcome, inScenario); - } else { - if (outcome.failure() == null) { - fail( - testCase, - "$.expect.outcome", - "failure", - "success (" + typeName(outcome.value()) + ": " + outcome.value() + ")"); - } - assertFailureFields(outcome); - } - if (expect.llmCalls() != null && expect.llmCalls() != outcome.llmCalls()) { - fail( - testCase, - "$.expect.llmCalls", - String.valueOf(expect.llmCalls()), - String.valueOf(outcome.llmCalls())); - } - for (String contractName : expect.contracts()) { - assertContract(outcome, contractName); - } - if (expect.differential()) { - runDifferential(outcome); - } - } - - private void assertFailureFields(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - Expectation expect = testCase.expect(); - Throwable failure = outcome.failure(); - if (expect.exception() != null) { - String actual = failure.getClass().getSimpleName(); - if (!expect.exception().equals(actual)) { - fail(testCase, "$.expect.exception", expect.exception(), actual); - } - } - if (expect.code() != null) { - if (!(failure instanceof A2ATError error)) { - throw fail( - testCase, - "$.expect.code", - expect.code(), - "no error code (exception " + failure.getClass().getSimpleName() + ")"); - } - if (!expect.code().equals(error.getCode())) { - fail(testCase, "$.expect.code", expect.code(), error.getCode()); - } - } - for (String fragment : expect.messageContains()) { - String message = failure.getMessage(); - if (message == null - || !(message.contains(fragment) || message.contains(performativeWording(fragment)))) { - fail( - testCase, - "$.expect.messageContains", - "a message containing '" + fragment + "'", - "message " + quoted(message)); - } - } - if (!expect.slotErrors().isEmpty()) { - if (!(failure instanceof A2ATParamExtractionError extraction)) { - throw fail( - testCase, - "$.expect.slotErrors", - renderSlotErrors(expect.slotErrors()), - "exception " + failure.getClass().getSimpleName() + " carries no slot errors"); - } - assertSlotErrors(testCase, expect.slotErrors(), extraction.getErrors()); - } - } - - private void assertSlotErrors( - NegotiationCase testCase, List expected, List actual) { - Map expectedCounts = new LinkedHashMap<>(); - for (Expectation.SlotError slotError : expected) { - expectedCounts.merge(slotError.slot() + ":" + slotError.code(), 1, Integer::sum); - } - Map actualCounts = new LinkedHashMap<>(); - List actualPairs = new ArrayList<>(); - for (SlotValidationError error : actual) { - String pair = error.slotName() + ":" + error.code(); - actualCounts.merge(pair, 1, Integer::sum); - actualPairs.add(pair); - } - if (!expectedCounts.equals(actualCounts)) { - fail( - testCase, - "$.expect.slotErrors", - renderSlotErrors(expected), - String.join(", ", actualPairs.isEmpty() ? List.of("(none)") : actualPairs)); - } - } - - private void assertSuccessFields(CaseOutcome outcome, boolean inScenario) { - NegotiationCase testCase = outcome.testCase(); - Expectation expect = testCase.expect(); - if (expect.promptTextEqualsGolden() != null) { - MetadataContent message = requireMessage(outcome, "$.expect.promptTextEqualsGolden"); - String golden = readGoldenFixture(testCase, expect.promptTextEqualsGolden()); - if (!normalize(message.promptText()).equals(golden)) { - fail( - testCase, - "$.expect.promptTextEqualsGolden", - quoted(truncate(golden)), - quoted(truncate(normalize(message.promptText())))); - } - } - if (expect.metadata() != null) { - MetadataContent message = requireMessage(outcome, "$.expect.metadata"); - if (expect.metadata().templateUriEcho() != null - && !expect.metadata().templateUriEcho().equals(message.templateUri())) { - fail( - testCase, - "$.expect.metadata.templateUriEcho", - expect.metadata().templateUriEcho(), - message.templateUri()); - } - if (Boolean.TRUE.equals(expect.metadata().contextEcho())) { - // The input context is built with the performative of the case's API, so it equals the emitted context - // the generation pipeline stamped with the performative of the addressed template. - NegotiationContext expectedContext = toContext(testCase.context(), testCase.api()); - if (!Objects.equals(expectedContext, message.negotiationContext())) { - fail( - testCase, - "$.expect.metadata.contextEcho", - String.valueOf(expectedContext), - String.valueOf(message.negotiationContext())); - } - } - } - if (!expect.params().isEmpty() || expect.missingParams() != null) { - if (!(outcome.value() instanceof FilledParamData filled)) { - throw fail( - testCase, - expect.missingParams() != null && expect.params().isEmpty() - ? "$.expect.missingParams" - : "$.expect.params", - expect.missingParams() != null - ? "filled parameter data with the missing-parameter set " + expect.missingParams() - : render(expect.params()), - "no filled parameter data (" + typeName(outcome.value()) + ")"); - } - if (expect.missingParams() != null) { - // Task semantics (Q21): the expected params are a subset check with exact values, and the missing - // parameter set — the null-valued entries of the filled data — is asserted exactly. - for (Map.Entry entry : expect.params().entrySet()) { - if (!filled.data().containsKey(entry.getKey()) - || !Objects.equals(entry.getValue(), filled.data().get(entry.getKey()))) { - fail( - testCase, - "$.expect.params", - entry.getKey() + "=" + entry.getValue(), - entry.getKey() + "=" + filled.data().get(entry.getKey())); - } - } - List actualMissing = filled.data().entrySet().stream() - .filter(entry -> entry.getValue() == null) - .map(Map.Entry::getKey) - .toList(); - Set expectedMissing = new LinkedHashSet<>(expect.missingParams()); - if (!expectedMissing.equals(new LinkedHashSet<>(actualMissing))) { - fail( - testCase, - "$.expect.missingParams", - String.join(", ", expectedMissing), - actualMissing.isEmpty() ? "(none)" : String.join(", ", actualMissing)); - } - } else if (!Objects.equals(expect.params(), filled.data())) { - fail(testCase, "$.expect.params", render(expect.params()), render(filled.data())); - } - } - for (String fragment : expect.promptTextContains()) { - MetadataContent message = requireMessage(outcome, "$.expect.promptTextContains"); - String promptText = message.promptText() == null ? "" : message.promptText(); - if (!promptText.contains(fragment)) { - fail( - testCase, - "$.expect.promptTextContains", - "a prompt text containing '" + fragment + "'", - quoted(truncate(promptText))); - } - } - if (expect.paramsFromStep() != null && !inScenario) { - throw new IllegalStateException( - testCase.errorPrefix() + " expect.paramsFromStep " + expect.paramsFromStep() - + " is resolved by the ScenarioEngine; run the enclosing scenario through the" - + " ScenarioEngine"); - } - } - - // ------------------------------------------------------------------ contracts (P0) - - private void assertContract(CaseOutcome outcome, String contractName) { - NegotiationCase testCase = outcome.testCase(); - Contract contract = Contract.fromJsonName(contractName); - if (contract == null) { - fail( - testCase, - "$.expect.contracts", - "a registered contract name (one of " + knownContractNames() + ")", - "'" + contractName + "'"); - } - if (!contract.isP0()) { - fail( - testCase, - "$.expect.contracts", - "a P0 contract the engine asserts today", - "'" + contractName + "' is registered as a P1 expectation and is not yet lit"); - } - switch (contract) { - case CONCLUSION_LITERAL_PRESENT -> assertConclusionLiteralPresent(outcome); - case CONTEXT_KEYS_IN_MERGED_PARAMS -> assertContextKeysInMergedParams(outcome); - case NO_LLM_LEAK_IN_USER_MESSAGE -> assertNoLlmLeakInUserMessage(outcome); - case METADATA_TRIPLE_SHAPE -> assertMetadataTripleShape(outcome); - default -> fail( - testCase, - "$.expect.contracts", - "an implemented contract", - "'" + contractName + "' is flagged P0 but has no implementation"); - } - } - - private void assertConclusionLiteralPresent(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - String literal = switch (testCase.api()) { - case GENERATE_ACCEPT_FROM_TEXT, GENERATE_ACCEPT_FROM_DATA -> "Accept"; - case GENERATE_REJECT_FROM_TEXT, GENERATE_REJECT_FROM_DATA -> "Reject"; - case GENERATE_ABORT_FROM_TEXT, GENERATE_ABORT_FROM_DATA -> "Abort"; - default -> null; - }; - if (literal == null) { - fail( - testCase, - "$.expect.contracts[conclusionLiteralPresent]", - "a terminal or abort generation API", - testCase.api().jsonName()); - } - MetadataContent message = requireMessage(outcome, "$.expect.contracts[conclusionLiteralPresent]"); - if (!String.valueOf(message.promptText()).contains(literal)) { - fail( - testCase, - "$.expect.contracts[conclusionLiteralPresent]", - "a prompt text containing the literal '" + literal + "'", - quoted(truncate(message.promptText()))); - } - } - - private void assertContextKeysInMergedParams(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - if (!(outcome.value() instanceof FilledParamData filled)) { - throw fail( - testCase, - "$.expect.contracts[contextKeysInMergedParams]", - "filled parameter data carrying the context keys", - typeName(outcome.value())); - } - for (String key : List.of("id", "round", "maxRounds")) { - if (!filled.data().containsKey(key)) { - fail( - testCase, - "$.expect.contracts[contextKeysInMergedParams]", - "merged params containing '" + key + "'", - render(filled.data())); - } - } - } - - private void assertNoLlmLeakInUserMessage(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - List details = outcome.llmClient().leakedFailureDetails(); - if (details.isEmpty()) { - return; - } - String visible = outcome.failure() != null - ? outcome.failure().getMessage() - : outcome.message() != null ? outcome.message().promptText() : null; - if (visible == null) { - fail( - testCase, - "$.expect.contracts[noLlmLeakInUserMessage]", - "a user-visible message to inspect", - "neither a failure message nor a generated message was produced"); - } - for (String detail : details) { - if (visible.contains(detail)) { - fail( - testCase, - "$.expect.contracts[noLlmLeakInUserMessage]", - "a message free of the raw LLM failure detail '" + detail + "'", - quoted(truncate(visible))); - } - } - } - - private void assertMetadataTripleShape(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - MetadataContent message = requireMessage(outcome, "$.expect.contracts[metadataTripleShape]"); - Map metadata = message.buildMetadataContent(); - if (metadata.size() != 3 - || !metadata.containsKey(message.extensionUri()) - || !metadata.containsKey(MetadataContent.TEMPLATE_URI_METADATA_KEY) - || !metadata.containsKey(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY)) { - fail( - testCase, - "$.expect.contracts[metadataTripleShape]", - "exactly the three entries ", - String.valueOf(metadata.keySet())); - } - NegotiationPerformative performative = performativeOf(testCase.api()); - Object nested = metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); - if (!(nested instanceof Map context) - || context.size() != 4 - || !context.containsKey("id") - || !context.containsKey("round") - || !context.containsKey("maxRounds") - || !performative.name().equals(context.get("performative"))) { - fail( - testCase, - "$.expect.contracts[metadataTripleShape]", - "the nested negotiation context with exactly ", - String.valueOf(nested)); - } - } - - // ------------------------------------------------------------------ differential (Q17 C+) - - private void runDifferential(CaseOutcome main) { - NegotiationCase testCase = main.testCase(); - if (testCase.api().family() != NegotiationApi.Family.FROM_TEXT) { - fail( - testCase, - "$.expect.differential", - "a from-text family API", - testCase.api().jsonName()); - } - if (testCase.inputData() == null) { - fail(testCase, "$.expect.differential", "input.data alongside input.text", "no input.data"); - } - if (testCase.expect().promptTextEqualsGolden() == null) { - fail( - testCase, - "$.expect.differential", - "promptTextEqualsGolden as the third comparison leg", - "no golden name"); - } - MetadataContent fromText = requireMessage(main, "$.expect.differential"); - ScriptedNegotiationLlmClient zeroCallClient = ScriptedNegotiationLlmClient.assertionOnly(); - NegotiationContentService service = new NegotiationContentService(orchestratorFor(testCase, zeroCallClient)); - MetadataContent fromData; - try { - fromData = generateFromData(service, testCase); - } catch (RuntimeException | AssertionError error) { - fail( - testCase, - "$.expect.differential", - "a successful from-data message", - "failure (" + error + ")"); - return; - } - if (!Objects.equals(fromText.promptText(), fromData.promptText())) { - fail( - testCase, - "$.expect.differential", - "fromText == fromData (" + quoted(truncate(fromText.promptText())) + ")", - quoted(truncate(fromData.promptText()))); - } - String golden = readGoldenFixture(testCase, testCase.expect().promptTextEqualsGolden()); - if (!normalize(fromData.promptText()).equals(golden)) { - fail( - testCase, - "$.expect.differential", - "fromData == golden " + quoted(truncate(golden)), - quoted(truncate(normalize(fromData.promptText())))); - } - if (zeroCallClient.callCount() != 0) { - fail( - testCase, - "$.expect.differential", - "0 LLM calls on the from-data leg", - String.valueOf(zeroCallClient.callCount())); - } - } - - private MetadataContent generateFromData(NegotiationContentService service, NegotiationCase testCase) { - TemplateUri templateUri = parseTemplateUri(testCase); - NegotiationContext context = toContext(testCase.context(), testCase.api()); - JsonNode data = testCase.inputData(); - return switch (testCase.api()) { - case GENERATE_PROPOSE_FROM_TEXT -> service.generateProposeFromData( - (NegotiationProposeData) TypedInputAssembler.assemble( - data, context, templateUri, NegotiationPerformative.PROPOSE, testCase.language()), - templateUri); - case GENERATE_ACCEPT_FROM_TEXT -> service.generateAcceptFromData( - (NegotiationEndingData) TypedInputAssembler.assemble( - data, context, templateUri, NegotiationPerformative.ACCEPT, testCase.language()), - templateUri); - case GENERATE_REJECT_FROM_TEXT -> service.generateRejectFromData( - (NegotiationEndingData) TypedInputAssembler.assemble( - data, context, templateUri, NegotiationPerformative.REJECT, testCase.language()), - templateUri); - case GENERATE_ABORT_FROM_TEXT -> service.generateAbortFromData( - (NegotiationAbortData) TypedInputAssembler.assemble( - data, context, templateUri, NegotiationPerformative.ABORT, testCase.language()), - templateUri); - default -> throw new IllegalStateException( - "The differential run pairs a from-text API with its from-data twin but got " - + testCase.api().jsonName() + "."); - }; - } - - // ------------------------------------------------------------------ production wiring - - private static ScriptedNegotiationLlmClient llmClientFor(NegotiationCase testCase) { - if (testCase.api().family() == NegotiationApi.Family.FROM_DATA || testCase.llm() == null) { - return ScriptedNegotiationLlmClient.assertionOnly(); - } - return new ScriptedNegotiationLlmClient(testCase.llm().steps()); - } - - private static NegotiationGenerationOrchestrator orchestratorFor( - NegotiationCase testCase, ScriptedNegotiationLlmClient llmClient) { - NegotiationGenerationOrchestratorBuilder builder = NegotiationGenerationOrchestratorBuilder.builder() - .language(testCase.language()) - .llmClient(llmClient); - if (testCase.llm() != null && testCase.llm().maxAttempts() != null) { - builder.maxAttempts(testCase.llm().maxAttempts()); - } - if (INJECT_FAILING_TEMPLATE_LOADER.equals(testCase.inject())) { - builder.templateLoader(failingTemplateLoader()); - } - if (INJECT_FAILING_SEMANTIC_VALIDATOR.equals(testCase.inject())) { - builder.semanticValidator(failingSemanticValidator()); - } - return builder.build(); - } - - /** - * Assembles the task API wiring of the closed loop for one case through the real facade builders: the retry limit - * comes from the case's LLM script or falls back to the builder default of 3. - * - * @param testCase expanded corpus case of the task family - * @param llmClient scripted LLM client injected at the facade builders' LLM seam - * @return the task API assembly of the case's language - */ - private static TaskApiAssembler taskApiFor(NegotiationCase testCase, ScriptedNegotiationLlmClient llmClient) { - int maxAttempts = testCase.llm() != null && testCase.llm().maxAttempts() != null - ? testCase.llm().maxAttempts() - : 3; - return new TaskApiAssembler(testCase.language(), maxAttempts, llmClient); - } - - private static NegotiationTemplateLoader failingTemplateLoader() { return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - }; - } - - /** - * Replays the semantic validator of a language without bundled semantic validation prompt resources: the pipeline - * maps the resource failure onto the public {@code template_not_found} code without bubbling the raw exception - * (the validate-family leg of the mapCode matrix; mirrors the hand-written pipeline suite). - * - * @return semantic validator failing every call with a resource-not-found exception - */ - private static NegotiationSemanticValidator failingSemanticValidator() { - return (prompt, callerSchema, reference, templateContent) -> { - throw new ResourceNotFoundException( - "Negotiation semantic validation prompt resource does not exist for language " - + reference.language() - + "; set A2AT_LANGUAGE to a language with bundled prompt resources (zh-CN or en-US).", - "prompt_resources/prompts/negotiation_semantic_validation/" + reference.language() - + "/system.md"); - }; - } - - // ------------------------------------------------------------------ API dispatch - - private Object invoke( - NegotiationContentService service, - @Nullable TaskApiAssembler taskApi, - NegotiationCase testCase, - @Nullable String promptOverride) { - TemplateUri templateUri = parseTemplateUri(testCase); - NegotiationContext context = toContext(testCase.context(), testCase.api()); - return switch (testCase.api()) { - case GENERATE_PROPOSE_FROM_TEXT -> - service.generateProposeFromText(testCase.inputText(), context, templateUri); - case GENERATE_ACCEPT_FROM_TEXT -> - service.generateAcceptFromText(testCase.inputText(), context, templateUri); - case GENERATE_REJECT_FROM_TEXT -> - service.generateRejectFromText(testCase.inputText(), context, templateUri); - case GENERATE_ABORT_FROM_TEXT -> - service.generateAbortFromText(testCase.inputText(), context, templateUri); - case GENERATE_PROPOSE_FROM_DATA -> service.generateProposeFromData( - (NegotiationProposeData) TypedInputAssembler.assemble( - requireInputData(testCase), context, templateUri, NegotiationPerformative.PROPOSE, - testCase.language()), - templateUri); - case GENERATE_ACCEPT_FROM_DATA -> service.generateAcceptFromData( - (NegotiationEndingData) TypedInputAssembler.assemble( - requireInputData(testCase), context, templateUri, NegotiationPerformative.ACCEPT, - testCase.language()), - templateUri); - case GENERATE_REJECT_FROM_DATA -> service.generateRejectFromData( - (NegotiationEndingData) TypedInputAssembler.assemble( - requireInputData(testCase), context, templateUri, NegotiationPerformative.REJECT, - testCase.language()), - templateUri); - case GENERATE_ABORT_FROM_DATA -> service.generateAbortFromData( - (NegotiationAbortData) TypedInputAssembler.assemble( - requireInputData(testCase), context, templateUri, NegotiationPerformative.ABORT, - testCase.language()), - templateUri); - case VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING -> service.validateProposePromptAndDataFilling( - promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); - case VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING -> service.validateAcceptPromptAndDataFilling( - promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); - case VALIDATE_REJECT_PROMPT_AND_DATA_FILLING -> service.validateRejectPromptAndDataFilling( - promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); - case VALIDATE_ABORT_PROMPT_AND_DATA_FILLING -> service.validateAbortPromptAndDataFilling( - promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); - case GENERATE_TASK_PROMPT_FROM_TEXT -> - requireTaskApi(taskApi, testCase).generateTaskPromptFromText( - requireInputText(testCase), templateUri); - case GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA -> - requireTaskApi(taskApi, testCase).generateTaskPromptFromDataWithSchema( - dataOf(testCase), requireSchema(testCase), templateUri); - case VALIDATE_TASK_PROMPT_AND_DATA_FILLING -> - requireTaskApi(taskApi, testCase).validateTaskPromptAndDataFilling( - promptOf(testCase, promptOverride), requireSchema(testCase), templateUri); - }; - } - - private static TaskApiAssembler requireTaskApi( - @Nullable TaskApiAssembler taskApi, NegotiationCase testCase) { - if (taskApi == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " the task API assembly is only wired for the task family but got " - + testCase.api().jsonName()); - } - return taskApi; - } - - private static String requireInputText(NegotiationCase testCase) { - String text = testCase.inputText(); - if (text == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " input.text: the task from-text API requires a text input"); - } - return text; - } - - private static Map requireSchema(NegotiationCase testCase) { - Map schema = schemaOf(testCase); - if (schema == null) { - throw new IllegalStateException(testCase.errorPrefix() + " schema: the task APIs require a schema"); - } - return schema; - } - - private static Map dataOf(NegotiationCase testCase) { - JsonNode data = testCase.inputData(); - if (data == null || !data.isObject()) { - throw new IllegalStateException( - testCase.errorPrefix() + " input.data: the task from-data API requires typed input data"); - } - return MAPPER.convertValue(data, new TypeReference>() {}); - } - - private static TemplateUri parseTemplateUri(NegotiationCase testCase) { - String raw = testCase.templateUri(); - if (raw == null) { - return null; - } - return TemplateUri.parse(raw) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); - } - - private static JsonNode requireInputData(NegotiationCase testCase) { - JsonNode data = testCase.inputData(); - if (data == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " input.data: the from-data family requires typed input data"); - } - return data; - } - - private static String promptOf(NegotiationCase testCase, @Nullable String promptOverride) { - if (promptOverride != null) { - return promptOverride; - } - PromptSource source = testCase.prompt(); - if (source == null) { - return null; - } - if (source instanceof PromptSource.Text text) { - return text.text(); - } - if (source instanceof PromptSource.Golden golden) { - return readGoldenFixture(testCase, golden.golden()); - } - PromptSource.FromStep fromStep = (PromptSource.FromStep) source; - throw new IllegalStateException( - testCase.errorPrefix() + " prompt.fromStep " + fromStep.step() - + " is resolved by the ScenarioEngine; run the enclosing scenario through the ScenarioEngine"); - } - - private static @Nullable Map schemaOf(NegotiationCase testCase) { - JsonNode schema = testCase.schema(); - if (schema == null) { - return null; - } - return MAPPER.convertValue(schema, new TypeReference>() {}); - } - - /** - * Builds the input context of a case, stamped with the performative of the case's API. - * - *

        The generation pipeline stamps the performative of the addressed template onto the emitted context, so an - * input context built with the API's performative echoes back unchanged; a validate API receives the message of - * exactly that performative, so its input context carries it as well. - * - * @param spec context spec of the case, or null - * @param api API of the case - * @return the negotiation context of the spec stamped with the API's performative, or null when the case carries - * no context spec or the task-family API takes no negotiation context - */ - private static @Nullable NegotiationContext toContext(@Nullable ContextSpec spec, NegotiationApi api) { - NegotiationPerformative performative = performativeOf(api); - if (spec == null || performative == null) { - return null; - } - return new NegotiationContext(spec.id(), spec.round(), spec.maxRounds(), performative); - } - - /** - * Returns the performative an API addresses: each generation API emits the message of one performative and each - * validate API checks the message of one performative, while the task family produces no negotiation message. - * - * @param api API of the case - * @return the performative of the generation and validate families, or null for the task family - */ - private static @Nullable NegotiationPerformative performativeOf(NegotiationApi api) { - return switch (api) { - case GENERATE_PROPOSE_FROM_TEXT, GENERATE_PROPOSE_FROM_DATA, VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING -> - NegotiationPerformative.PROPOSE; - case GENERATE_ACCEPT_FROM_TEXT, GENERATE_ACCEPT_FROM_DATA, VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING -> - NegotiationPerformative.ACCEPT; - case GENERATE_REJECT_FROM_TEXT, GENERATE_REJECT_FROM_DATA, VALIDATE_REJECT_PROMPT_AND_DATA_FILLING -> - NegotiationPerformative.REJECT; - case GENERATE_ABORT_FROM_TEXT, GENERATE_ABORT_FROM_DATA, VALIDATE_ABORT_PROMPT_AND_DATA_FILLING -> - NegotiationPerformative.ABORT; - default -> null; - }; - } - - /** - * Rewrites the pre-rename wording of the frozen corpus fixtures: the case JSON files still say - * {@code expected phase}, while the orchestrator's reference-resolution error has said - * {@code expected performative} since the {@code phase} to {@code performative} rename. The corpus case JSON - * files are frozen by policy, so the harness translates the legacy fragment instead of the fixtures. - * - * @param fragment message fragment declared by a frozen case fixture - * @return the fragment with the legacy wording translated to the current one - */ - private static String performativeWording(String fragment) { - return fragment.replace("expected phase", "expected performative"); - } - - // ------------------------------------------------------------------ helpers - - private static MetadataContent requireMessage(CaseOutcome outcome, String jsonPath) { - NegotiationCase testCase = outcome.testCase(); - MetadataContent message = outcome.message(); - if (message == null) { - fail( - testCase, - jsonPath, - "a generated negotiation message", - "failure (" + outcome.failure() + ") or " + typeName(outcome.value())); - } - return message; - } - - private static String readGoldenFixture(NegotiationCase testCase, String goldenName) { - String resourcePath = "/golden/" + testCase.language() + "/" + goldenName + ".md"; - InputStream stream = CaseEngine.class.getResourceAsStream(resourcePath); - if (stream == null) { - throw new AssertionError( - testCase.errorPrefix() + " golden fixture '" + goldenName + "' does not exist on the test" - + " classpath: " + resourcePath); - } - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } catch (IOException exception) { - throw new AssertionError( - testCase.errorPrefix() + " failed to read the golden fixture " + resourcePath, exception); - } - } - - private static String normalize(@Nullable String text) { - return text == null ? null : text.replace("\r\n", "\n"); - } - - private static String typeName(@Nullable Object value) { - return value == null ? "null" : value.getClass().getSimpleName(); - } - - private static String render(Map values) { - try { - return MAPPER.writeValueAsString(values); - } catch (IOException exception) { - return String.valueOf(values); - } - } - - private static String renderSlotErrors(List slotErrors) { - List pairs = new ArrayList<>(); - for (Expectation.SlotError slotError : slotErrors) { - pairs.add(slotError.slot() + ":" + slotError.code()); - } - return String.join(", ", pairs); - } - - private static String knownContractNames() { - List names = new ArrayList<>(); - for (Contract contract : Contract.values()) { - names.add(contract.jsonName()); - } - return String.join(", ", names); - } - - private static String truncate(String text) { - return text.length() <= MAX_REPORTED_TEXT_LENGTH ? text : text.substring(0, MAX_REPORTED_TEXT_LENGTH) + "..."; - } - - private static String quoted(@Nullable String text) { - return text == null ? "null" : "<" + text + ">"; - } - - private static AssertionError fail(NegotiationCase testCase, String jsonPath, String expected, String actual) { - throw new AssertionError( - testCase.errorPrefix() + " " + jsonPath + ": expected " + expected + " but was " + actual); - } -} +package net.openan.a2at.sdk.corpus; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; +import org.jspecify.annotations.Nullable; + +/** + * Executes one expanded corpus case against the production negotiation content wiring and asserts its expectation + * block. + * + *

        Everything except the LLM client is production assembly: the case engine builds a real + * {@link NegotiationGenerationOrchestrator} through {@link NegotiationGenerationOrchestratorBuilder} for the language + * of the case, wires the {@code inject} hooks onto their real builder injection points ({@code failingTemplateLoader} + * for the generation template-not-found matrix, {@code failingSemanticValidator} for the validate-family + * prompt-resource-not-found mapping) and dispatches on the {@link NegotiationApi} enum at compile time, so a misspelled + * API name fails at corpus load time and a renamed service method fails this compilation. The three task-family APIs + * run through the {@link TaskApiAssembler}, the real facade builders' assembly of the closed loop (Q21) with the + * scripted LLM client injected at the builders' LLM seam. + * + *

        Result normalization: a success run returns the produced {@link MetadataContent} or {@link FilledParamData}, a + * failure run captures the thrown exception. The expectation comparison covers outcome, exception name, error code, + * message fragments, slot errors (slot plus code pairs, exact but order-insensitive), the exact LLM call count, the + * golden fixture text (per language, byte equality after CRLF normalization), the metadata echoes, the expected merged + * parameter map, the P0 behavior contracts and the Q17 C+ differential double run (fromText == fromData == golden, + * from-data side proven zero-call with an assertion-only client). Every mismatch fails with the case id, the JSON path + * of the expectation and the expected-versus-actual pair. + * + * @since 2026-08 + */ +public final class CaseEngine { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String INJECT_FAILING_TEMPLATE_LOADER = "failingTemplateLoader"; + + private static final String INJECT_FAILING_SEMANTIC_VALIDATOR = "failingSemanticValidator"; + + private static final int MAX_REPORTED_TEXT_LENGTH = 400; + + /** + * Runs one case without a prompt override. + * + * @param testCase expanded corpus case + * @return the normalized outcome of the run + * @throws AssertionError when any expectation mismatches + */ + public CaseOutcome run(NegotiationCase testCase) { + return run(testCase, null); + } + + /** + * Runs one case, optionally overriding the prompt input of the validate family. + * + *

        The {@link ScenarioEngine} resolves {@code prompt.fromStep} references into prompt texts and hands them in + * through this parameter; a standalone validate case resolves its golden or inline prompt itself. + * + * @param testCase expanded corpus case + * @param promptOverride prompt text resolved from an earlier scenario step, or null + * @return the normalized outcome of the run + * @throws AssertionError when any expectation mismatches + */ + public CaseOutcome run(NegotiationCase testCase, @Nullable String promptOverride) { + return run(testCase, promptOverride, false); + } + + /** + * Runs one case, optionally overriding the prompt input of the validate family and optionally running as a scenario + * step. + * + *

        The {@link ScenarioEngine} resolves {@code prompt.fromStep} references into prompt texts and hands them in + * through the prompt parameter; a standalone validate case resolves its golden or inline prompt itself. Scenario + * mode defers the {@code expect.paramsFromStep} check to the scenario engine, which resolves the cross-step + * reference after the step ran. + * + * @param testCase expanded corpus case + * @param promptOverride prompt text resolved from an earlier scenario step, or null + * @param inScenario true inside the ScenarioEngine, false for a standalone run + * @return the normalized outcome of the run + * @throws AssertionError when any expectation mismatches + */ + CaseOutcome run(NegotiationCase testCase, @Nullable String promptOverride, boolean inScenario) { + ScriptedNegotiationLlmClient llmClient = llmClientFor(testCase); + NegotiationContentService service = new NegotiationContentService(orchestratorFor(testCase, llmClient)); + TaskApiAssembler taskApi = + testCase.api().family() == NegotiationApi.Family.TASK ? taskApiFor(testCase, llmClient) : null; + Object value = null; + Throwable failure = null; + try { + value = invoke(service, taskApi, testCase, promptOverride); + } catch (RuntimeException | AssertionError error) { + failure = error; + } + CaseOutcome outcome = new CaseOutcome(testCase, value, failure, llmClient.callCount(), llmClient); + assertExpectations(outcome, inScenario); + return outcome; + } + + // ------------------------------------------------------------------ outcome record + + /** + * The normalized outcome of one executed case. + * + * @param testCase the executed expanded corpus case + * @param value the returned {@code MetadataContent} or {@code FilledParamData} on success, or null on failure + * @param failure the captured exception on failure, or null on success + * @param llmCalls exact number of LLM calls the run made + * @param llmClient the scripted client the run used + */ + public record CaseOutcome( + NegotiationCase testCase, + @Nullable Object value, + @Nullable Throwable failure, + int llmCalls, + ScriptedNegotiationLlmClient llmClient) { + + /** + * Returns the generated message of a successful generation run. + * + * @return the metadata content, or null when the run failed or produced filled parameters + */ + public @Nullable MetadataContent message() { + return value instanceof MetadataContent metadata ? metadata : null; + } + + /** + * Returns the filled parameters of a successful validation run. + * + * @return the filled parameter data, or null when the run failed or produced a message + */ + public @Nullable FilledParamData filledParams() { + return value instanceof FilledParamData filled ? filled : null; + } + } + + // ------------------------------------------------------------------ expectation comparison + + private void assertExpectations(CaseOutcome outcome, boolean inScenario) { + NegotiationCase testCase = outcome.testCase(); + Expectation expect = testCase.expect(); + if (expect.success()) { + if (outcome.failure() != null) { + fail(testCase, "$.expect.outcome", "success", "failure (" + outcome.failure() + ")"); + } + assertSuccessFields(outcome, inScenario); + } else { + if (outcome.failure() == null) { + fail( + testCase, + "$.expect.outcome", + "failure", + "success (" + typeName(outcome.value()) + ": " + outcome.value() + ")"); + } + assertFailureFields(outcome); + } + if (expect.llmCalls() != null && expect.llmCalls() != outcome.llmCalls()) { + fail(testCase, "$.expect.llmCalls", String.valueOf(expect.llmCalls()), String.valueOf(outcome.llmCalls())); + } + for (String contractName : expect.contracts()) { + assertContract(outcome, contractName); + } + if (expect.differential()) { + runDifferential(outcome); + } + } + + private void assertFailureFields(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + Expectation expect = testCase.expect(); + Throwable failure = outcome.failure(); + if (expect.exception() != null) { + String actual = failure.getClass().getSimpleName(); + if (!expect.exception().equals(actual)) { + fail(testCase, "$.expect.exception", expect.exception(), actual); + } + } + if (expect.code() != null) { + if (!(failure instanceof A2ATError error)) { + throw fail( + testCase, + "$.expect.code", + expect.code(), + "no error code (exception " + failure.getClass().getSimpleName() + ")"); + } + if (!expect.code().equals(error.getCode())) { + fail(testCase, "$.expect.code", expect.code(), error.getCode()); + } + } + for (String fragment : expect.messageContains()) { + String message = failure.getMessage(); + if (message == null || !(message.contains(fragment) || message.contains(performativeWording(fragment)))) { + fail( + testCase, + "$.expect.messageContains", + "a message containing '" + fragment + "'", + "message " + quoted(message)); + } + } + if (!expect.slotErrors().isEmpty()) { + List slotErrors = slotErrorsOf(failure); + if (slotErrors == null) { + throw fail( + testCase, + "$.expect.slotErrors", + renderSlotErrors(expect.slotErrors()), + "exception " + failure.getClass().getSimpleName() + " carries no slot errors"); + } + assertSlotErrors(testCase, expect.slotErrors(), slotErrors); + } + } + + /** + * Returns the slot errors the failure carries, or null when the failure type carries none. + * + *

        The negotiation content layer surfaces its failures as {@link NegotiationParamExtractionException} (an + * {@code A2ATBusinessException} subtype since the ErrorCatalog migration) while the prompt families still use + * {@link A2ATParamExtractionError}; the engine accepts both carriers. + * + * @param failure captured failure of the run + * @return the slot validation errors of the failure, or null when the failure carries none + */ + private static @Nullable List slotErrorsOf(Throwable failure) { + if (failure instanceof A2ATParamExtractionError extraction) { + return extraction.getErrors(); + } + if (failure instanceof NegotiationParamExtractionException negotiation) { + return negotiation.getErrors(); + } + return null; + } + + private void assertSlotErrors( + NegotiationCase testCase, List expected, List actual) { + Map expectedCounts = new LinkedHashMap<>(); + for (Expectation.SlotError slotError : expected) { + expectedCounts.merge(slotError.slot() + ":" + slotError.code(), 1, Integer::sum); + } + Map actualCounts = new LinkedHashMap<>(); + List actualPairs = new ArrayList<>(); + for (SlotValidationError error : actual) { + String pair = error.slotName() + ":" + error.code(); + actualCounts.merge(pair, 1, Integer::sum); + actualPairs.add(pair); + } + if (!expectedCounts.equals(actualCounts)) { + fail( + testCase, + "$.expect.slotErrors", + renderSlotErrors(expected), + String.join(", ", actualPairs.isEmpty() ? List.of("(none)") : actualPairs)); + } + } + + private void assertSuccessFields(CaseOutcome outcome, boolean inScenario) { + NegotiationCase testCase = outcome.testCase(); + Expectation expect = testCase.expect(); + if (expect.promptTextEqualsGolden() != null) { + MetadataContent message = requireMessage(outcome, "$.expect.promptTextEqualsGolden"); + String golden = readGoldenFixture(testCase, expect.promptTextEqualsGolden()); + if (!normalize(message.promptText()).equals(golden)) { + fail( + testCase, + "$.expect.promptTextEqualsGolden", + quoted(truncate(golden)), + quoted(truncate(normalize(message.promptText())))); + } + } + if (expect.metadata() != null) { + MetadataContent message = requireMessage(outcome, "$.expect.metadata"); + if (expect.metadata().templateUriEcho() != null + && !expect.metadata().templateUriEcho().equals(message.templateUri())) { + fail( + testCase, + "$.expect.metadata.templateUriEcho", + expect.metadata().templateUriEcho(), + message.templateUri()); + } + if (Boolean.TRUE.equals(expect.metadata().contextEcho())) { + // The input context is built with the performative of the case's API, so it equals the emitted context + // the generation pipeline stamped with the performative of the addressed template. + NegotiationContext expectedContext = toContext(testCase.context(), testCase.api()); + if (!Objects.equals(expectedContext, message.negotiationContext())) { + fail( + testCase, + "$.expect.metadata.contextEcho", + String.valueOf(expectedContext), + String.valueOf(message.negotiationContext())); + } + } + } + if (!expect.params().isEmpty() || expect.missingParams() != null) { + if (!(outcome.value() instanceof FilledParamData filled)) { + throw fail( + testCase, + expect.missingParams() != null && expect.params().isEmpty() + ? "$.expect.missingParams" + : "$.expect.params", + expect.missingParams() != null + ? "filled parameter data with the missing-parameter set " + expect.missingParams() + : render(expect.params()), + "no filled parameter data (" + typeName(outcome.value()) + ")"); + } + if (expect.missingParams() != null) { + // Task semantics (Q21): the expected params are a subset check with exact values, and the missing + // parameter set — the null-valued entries of the filled data — is asserted exactly. + for (Map.Entry entry : expect.params().entrySet()) { + if (!filled.data().containsKey(entry.getKey()) + || !Objects.equals(entry.getValue(), filled.data().get(entry.getKey()))) { + fail( + testCase, + "$.expect.params", + entry.getKey() + "=" + entry.getValue(), + entry.getKey() + "=" + filled.data().get(entry.getKey())); + } + } + List actualMissing = filled.data().entrySet().stream() + .filter(entry -> entry.getValue() == null) + .map(Map.Entry::getKey) + .toList(); + Set expectedMissing = new LinkedHashSet<>(expect.missingParams()); + if (!expectedMissing.equals(new LinkedHashSet<>(actualMissing))) { + fail( + testCase, + "$.expect.missingParams", + String.join(", ", expectedMissing), + actualMissing.isEmpty() ? "(none)" : String.join(", ", actualMissing)); + } + } else if (!Objects.equals(expect.params(), filled.data())) { + fail(testCase, "$.expect.params", render(expect.params()), render(filled.data())); + } + } + for (String fragment : expect.promptTextContains()) { + MetadataContent message = requireMessage(outcome, "$.expect.promptTextContains"); + String promptText = message.promptText() == null ? "" : message.promptText(); + if (!promptText.contains(fragment)) { + fail( + testCase, + "$.expect.promptTextContains", + "a prompt text containing '" + fragment + "'", + quoted(truncate(promptText))); + } + } + if (expect.paramsFromStep() != null && !inScenario) { + throw new IllegalStateException(testCase.errorPrefix() + " expect.paramsFromStep " + expect.paramsFromStep() + + " is resolved by the ScenarioEngine; run the enclosing scenario through the" + + " ScenarioEngine"); + } + } + + // ------------------------------------------------------------------ contracts (P0) + + private void assertContract(CaseOutcome outcome, String contractName) { + NegotiationCase testCase = outcome.testCase(); + Contract contract = Contract.fromJsonName(contractName); + if (contract == null) { + fail( + testCase, + "$.expect.contracts", + "a registered contract name (one of " + knownContractNames() + ")", + "'" + contractName + "'"); + } + if (!contract.isP0()) { + fail( + testCase, + "$.expect.contracts", + "a P0 contract the engine asserts today", + "'" + contractName + "' is registered as a P1 expectation and is not yet lit"); + } + switch (contract) { + case CONCLUSION_LITERAL_PRESENT -> assertConclusionLiteralPresent(outcome); + case CONTEXT_KEYS_IN_MERGED_PARAMS -> assertContextKeysInMergedParams(outcome); + case NO_LLM_LEAK_IN_USER_MESSAGE -> assertNoLlmLeakInUserMessage(outcome); + case METADATA_TRIPLE_SHAPE -> assertMetadataTripleShape(outcome); + default -> fail( + testCase, + "$.expect.contracts", + "an implemented contract", + "'" + contractName + "' is flagged P0 but has no implementation"); + } + } + + private void assertConclusionLiteralPresent(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + String literal = + switch (testCase.api()) { + case GENERATE_ACCEPT_FROM_TEXT, GENERATE_ACCEPT_FROM_DATA -> "Accept"; + case GENERATE_REJECT_FROM_TEXT, GENERATE_REJECT_FROM_DATA -> "Reject"; + case GENERATE_ABORT_FROM_TEXT, GENERATE_ABORT_FROM_DATA -> "Abort"; + default -> null; + }; + if (literal == null) { + fail( + testCase, + "$.expect.contracts[conclusionLiteralPresent]", + "a terminal or abort generation API", + testCase.api().jsonName()); + } + MetadataContent message = requireMessage(outcome, "$.expect.contracts[conclusionLiteralPresent]"); + if (!String.valueOf(message.promptText()).contains(literal)) { + fail( + testCase, + "$.expect.contracts[conclusionLiteralPresent]", + "a prompt text containing the literal '" + literal + "'", + quoted(truncate(message.promptText()))); + } + } + + private void assertContextKeysInMergedParams(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + if (!(outcome.value() instanceof FilledParamData filled)) { + throw fail( + testCase, + "$.expect.contracts[contextKeysInMergedParams]", + "filled parameter data carrying the context keys", + typeName(outcome.value())); + } + for (String key : List.of("id", "round", "maxRounds")) { + if (!filled.data().containsKey(key)) { + fail( + testCase, + "$.expect.contracts[contextKeysInMergedParams]", + "merged params containing '" + key + "'", + render(filled.data())); + } + } + } + + private void assertNoLlmLeakInUserMessage(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + List details = outcome.llmClient().leakedFailureDetails(); + if (details.isEmpty()) { + return; + } + String visible = outcome.failure() != null + ? outcome.failure().getMessage() + : outcome.message() != null ? outcome.message().promptText() : null; + if (visible == null) { + fail( + testCase, + "$.expect.contracts[noLlmLeakInUserMessage]", + "a user-visible message to inspect", + "neither a failure message nor a generated message was produced"); + } + for (String detail : details) { + if (visible.contains(detail)) { + fail( + testCase, + "$.expect.contracts[noLlmLeakInUserMessage]", + "a message free of the raw LLM failure detail '" + detail + "'", + quoted(truncate(visible))); + } + } + } + + private void assertMetadataTripleShape(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + MetadataContent message = requireMessage(outcome, "$.expect.contracts[metadataTripleShape]"); + Map metadata = message.buildMetadataContent(); + if (metadata.size() != 3 + || !metadata.containsKey(message.extensionUri()) + || !metadata.containsKey(MetadataContent.TEMPLATE_URI_METADATA_KEY) + || !metadata.containsKey(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY)) { + fail( + testCase, + "$.expect.contracts[metadataTripleShape]", + "exactly the three entries ", + String.valueOf(metadata.keySet())); + } + NegotiationPerformative performative = performativeOf(testCase.api()); + Object nested = metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + if (!(nested instanceof Map context) + || context.size() != 4 + || !context.containsKey("id") + || !context.containsKey("round") + || !context.containsKey("maxRounds") + || !performative.name().equals(context.get("performative"))) { + fail( + testCase, + "$.expect.contracts[metadataTripleShape]", + "the nested negotiation context with exactly ", + String.valueOf(nested)); + } + } + + // ------------------------------------------------------------------ differential (Q17 C+) + + private void runDifferential(CaseOutcome main) { + NegotiationCase testCase = main.testCase(); + if (testCase.api().family() != NegotiationApi.Family.FROM_TEXT) { + fail( + testCase, + "$.expect.differential", + "a from-text family API", + testCase.api().jsonName()); + } + if (testCase.inputData() == null) { + fail(testCase, "$.expect.differential", "input.data alongside input.text", "no input.data"); + } + if (testCase.expect().promptTextEqualsGolden() == null) { + fail( + testCase, + "$.expect.differential", + "promptTextEqualsGolden as the third comparison leg", + "no golden name"); + } + MetadataContent fromText = requireMessage(main, "$.expect.differential"); + ScriptedNegotiationLlmClient zeroCallClient = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = new NegotiationContentService(orchestratorFor(testCase, zeroCallClient)); + MetadataContent fromData; + try { + fromData = generateFromData(service, testCase); + } catch (RuntimeException | AssertionError error) { + fail(testCase, "$.expect.differential", "a successful from-data message", "failure (" + error + ")"); + return; + } + if (!Objects.equals(fromText.promptText(), fromData.promptText())) { + fail( + testCase, + "$.expect.differential", + "fromText == fromData (" + quoted(truncate(fromText.promptText())) + ")", + quoted(truncate(fromData.promptText()))); + } + String golden = readGoldenFixture(testCase, testCase.expect().promptTextEqualsGolden()); + if (!normalize(fromData.promptText()).equals(golden)) { + fail( + testCase, + "$.expect.differential", + "fromData == golden " + quoted(truncate(golden)), + quoted(truncate(normalize(fromData.promptText())))); + } + if (zeroCallClient.callCount() != 0) { + fail( + testCase, + "$.expect.differential", + "0 LLM calls on the from-data leg", + String.valueOf(zeroCallClient.callCount())); + } + } + + private MetadataContent generateFromData(NegotiationContentService service, NegotiationCase testCase) { + TemplateUri templateUri = parseTemplateUri(testCase); + NegotiationContext context = toContext(testCase.context(), testCase.api()); + JsonNode data = testCase.inputData(); + return switch (testCase.api()) { + case GENERATE_PROPOSE_FROM_TEXT -> service.generateProposeFromData( + (NegotiationProposeData) TypedInputAssembler.assemble( + data, context, templateUri, NegotiationPerformative.PROPOSE, testCase.language()), + templateUri); + case GENERATE_ACCEPT_FROM_TEXT -> service.generateAcceptFromData( + (NegotiationEndingData) TypedInputAssembler.assemble( + data, context, templateUri, NegotiationPerformative.ACCEPT, testCase.language()), + templateUri); + case GENERATE_REJECT_FROM_TEXT -> service.generateRejectFromData( + (NegotiationEndingData) TypedInputAssembler.assemble( + data, context, templateUri, NegotiationPerformative.REJECT, testCase.language()), + templateUri); + case GENERATE_ABORT_FROM_TEXT -> service.generateAbortFromData( + (NegotiationAbortData) TypedInputAssembler.assemble( + data, context, templateUri, NegotiationPerformative.ABORT, testCase.language()), + templateUri); + default -> throw new IllegalStateException( + "The differential run pairs a from-text API with its from-data twin but got " + + testCase.api().jsonName() + "."); + }; + } + + // ------------------------------------------------------------------ production wiring + + private static ScriptedNegotiationLlmClient llmClientFor(NegotiationCase testCase) { + if (testCase.api().family() == NegotiationApi.Family.FROM_DATA || testCase.llm() == null) { + return ScriptedNegotiationLlmClient.assertionOnly(); + } + return new ScriptedNegotiationLlmClient(testCase.llm().steps()); + } + + private static NegotiationGenerationOrchestrator orchestratorFor( + NegotiationCase testCase, ScriptedNegotiationLlmClient llmClient) { + NegotiationGenerationOrchestratorBuilder builder = NegotiationGenerationOrchestratorBuilder.builder() + .language(testCase.language()) + .llmClient(llmClient); + if (testCase.llm() != null && testCase.llm().maxAttempts() != null) { + builder.maxAttempts(testCase.llm().maxAttempts()); + } + if (INJECT_FAILING_TEMPLATE_LOADER.equals(testCase.inject())) { + builder.templateLoader(failingTemplateLoader()); + } + if (INJECT_FAILING_SEMANTIC_VALIDATOR.equals(testCase.inject())) { + builder.semanticValidator(failingSemanticValidator()); + } + return builder.build(); + } + + /** + * Assembles the task API wiring of the closed loop for one case through the real facade builders: the retry limit + * comes from the case's LLM script or falls back to the builder default of 3. + * + * @param testCase expanded corpus case of the task family + * @param llmClient scripted LLM client injected at the facade builders' LLM seam + * @return the task API assembly of the case's language + */ + private static TaskApiAssembler taskApiFor(NegotiationCase testCase, ScriptedNegotiationLlmClient llmClient) { + int maxAttempts = testCase.llm() != null && testCase.llm().maxAttempts() != null + ? testCase.llm().maxAttempts() + : 3; + return new TaskApiAssembler(testCase.language(), maxAttempts, llmClient); + } + + private static NegotiationTemplateLoader failingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + }; + } + + /** + * Replays the semantic validator of a language without bundled semantic validation prompt resources: the pipeline + * maps the resource failure onto the public {@code template.not_found} code without bubbling the raw exception (the + * validate-family leg of the template-resolution matrix; mirrors the hand-written pipeline suite). + * + * @return semantic validator failing every call with a resource-not-found exception + */ + private static NegotiationSemanticValidator failingSemanticValidator() { + return (prompt, callerSchema, reference, templateContent) -> { + throw new ResourceNotFoundException( + "Negotiation semantic validation prompt resource does not exist for language " + + reference.language() + + "; set A2AT_LANGUAGE to a language with bundled prompt resources (zh-CN or en-US).", + "prompt_resources/prompts/negotiation_semantic_validation/" + reference.language() + "/system.md"); + }; + } + + // ------------------------------------------------------------------ API dispatch + + private Object invoke( + NegotiationContentService service, + @Nullable TaskApiAssembler taskApi, + NegotiationCase testCase, + @Nullable String promptOverride) { + TemplateUri templateUri = parseTemplateUri(testCase); + NegotiationContext context = toContext(testCase.context(), testCase.api()); + return switch (testCase.api()) { + case GENERATE_PROPOSE_FROM_TEXT -> service.generateProposeFromText( + testCase.inputText(), context, templateUri); + case GENERATE_ACCEPT_FROM_TEXT -> service.generateAcceptFromText( + testCase.inputText(), context, templateUri); + case GENERATE_REJECT_FROM_TEXT -> service.generateRejectFromText( + testCase.inputText(), context, templateUri); + case GENERATE_ABORT_FROM_TEXT -> service.generateAbortFromText(testCase.inputText(), context, templateUri); + case GENERATE_PROPOSE_FROM_DATA -> service.generateProposeFromData( + (NegotiationProposeData) TypedInputAssembler.assemble( + requireInputData(testCase), + context, + templateUri, + NegotiationPerformative.PROPOSE, + testCase.language()), + templateUri); + case GENERATE_ACCEPT_FROM_DATA -> service.generateAcceptFromData( + (NegotiationEndingData) TypedInputAssembler.assemble( + requireInputData(testCase), + context, + templateUri, + NegotiationPerformative.ACCEPT, + testCase.language()), + templateUri); + case GENERATE_REJECT_FROM_DATA -> service.generateRejectFromData( + (NegotiationEndingData) TypedInputAssembler.assemble( + requireInputData(testCase), + context, + templateUri, + NegotiationPerformative.REJECT, + testCase.language()), + templateUri); + case GENERATE_ABORT_FROM_DATA -> service.generateAbortFromData( + (NegotiationAbortData) TypedInputAssembler.assemble( + requireInputData(testCase), + context, + templateUri, + NegotiationPerformative.ABORT, + testCase.language()), + templateUri); + case VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING -> service.validateProposePromptAndDataFilling( + promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); + case VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING -> service.validateAcceptPromptAndDataFilling( + promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); + case VALIDATE_REJECT_PROMPT_AND_DATA_FILLING -> service.validateRejectPromptAndDataFilling( + promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); + case VALIDATE_ABORT_PROMPT_AND_DATA_FILLING -> service.validateAbortPromptAndDataFilling( + promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); + case GENERATE_TASK_PROMPT_FROM_TEXT -> requireTaskApi(taskApi, testCase) + .generateTaskPromptFromText(requireInputText(testCase), templateUri); + case GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA -> requireTaskApi(taskApi, testCase) + .generateTaskPromptFromDataWithSchema(dataOf(testCase), requireSchema(testCase), templateUri); + case VALIDATE_TASK_PROMPT_AND_DATA_FILLING -> requireTaskApi(taskApi, testCase) + .validateTaskPromptAndDataFilling( + promptOf(testCase, promptOverride), requireSchema(testCase), templateUri); + }; + } + + private static TaskApiAssembler requireTaskApi(@Nullable TaskApiAssembler taskApi, NegotiationCase testCase) { + if (taskApi == null) { + throw new IllegalStateException( + testCase.errorPrefix() + " the task API assembly is only wired for the task family but got " + + testCase.api().jsonName()); + } + return taskApi; + } + + private static String requireInputText(NegotiationCase testCase) { + String text = testCase.inputText(); + if (text == null) { + throw new IllegalStateException( + testCase.errorPrefix() + " input.text: the task from-text API requires a text input"); + } + return text; + } + + private static Map requireSchema(NegotiationCase testCase) { + Map schema = schemaOf(testCase); + if (schema == null) { + throw new IllegalStateException(testCase.errorPrefix() + " schema: the task APIs require a schema"); + } + return schema; + } + + private static Map dataOf(NegotiationCase testCase) { + JsonNode data = testCase.inputData(); + if (data == null || !data.isObject()) { + throw new IllegalStateException( + testCase.errorPrefix() + " input.data: the task from-data API requires typed input data"); + } + return MAPPER.convertValue(data, new TypeReference>() {}); + } + + private static TemplateUri parseTemplateUri(NegotiationCase testCase) { + String raw = testCase.templateUri(); + if (raw == null) { + return null; + } + return TemplateUri.parse(raw) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); + } + + private static JsonNode requireInputData(NegotiationCase testCase) { + JsonNode data = testCase.inputData(); + if (data == null) { + throw new IllegalStateException( + testCase.errorPrefix() + " input.data: the from-data family requires typed input data"); + } + return data; + } + + private static String promptOf(NegotiationCase testCase, @Nullable String promptOverride) { + if (promptOverride != null) { + return promptOverride; + } + PromptSource source = testCase.prompt(); + if (source == null) { + return null; + } + if (source instanceof PromptSource.Text text) { + return text.text(); + } + if (source instanceof PromptSource.Golden golden) { + return readGoldenFixture(testCase, golden.golden()); + } + PromptSource.FromStep fromStep = (PromptSource.FromStep) source; + throw new IllegalStateException(testCase.errorPrefix() + " prompt.fromStep " + fromStep.step() + + " is resolved by the ScenarioEngine; run the enclosing scenario through the ScenarioEngine"); + } + + private static @Nullable Map schemaOf(NegotiationCase testCase) { + JsonNode schema = testCase.schema(); + if (schema == null) { + return null; + } + return MAPPER.convertValue(schema, new TypeReference>() {}); + } + + /** + * Builds the input context of a case, stamped with the performative of the case's API. + * + *

        The generation pipeline stamps the performative of the addressed template onto the emitted context, so an + * input context built with the API's performative echoes back unchanged; a validate API receives the message of + * exactly that performative, so its input context carries it as well. + * + * @param spec context spec of the case, or null + * @param api API of the case + * @return the negotiation context of the spec stamped with the API's performative, or null when the case carries no + * context spec or the task-family API takes no negotiation context + */ + private static @Nullable NegotiationContext toContext(@Nullable ContextSpec spec, NegotiationApi api) { + NegotiationPerformative performative = performativeOf(api); + if (spec == null || performative == null) { + return null; + } + return new NegotiationContext(spec.id(), spec.round(), spec.maxRounds(), performative); + } + + /** + * Returns the performative an API addresses: each generation API emits the message of one performative and each + * validate API checks the message of one performative, while the task family produces no negotiation message. + * + * @param api API of the case + * @return the performative of the generation and validate families, or null for the task family + */ + private static @Nullable NegotiationPerformative performativeOf(NegotiationApi api) { + return switch (api) { + case GENERATE_PROPOSE_FROM_TEXT, + GENERATE_PROPOSE_FROM_DATA, + VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.PROPOSE; + case GENERATE_ACCEPT_FROM_TEXT, + GENERATE_ACCEPT_FROM_DATA, + VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.ACCEPT; + case GENERATE_REJECT_FROM_TEXT, + GENERATE_REJECT_FROM_DATA, + VALIDATE_REJECT_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.REJECT; + case GENERATE_ABORT_FROM_TEXT, + GENERATE_ABORT_FROM_DATA, + VALIDATE_ABORT_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.ABORT; + default -> null; + }; + } + + /** + * Rewrites the pre-rename wording of the frozen corpus fixtures: the case JSON files still say {@code expected + * phase}, while the orchestrator's reference-resolution error has said {@code expected performative} since the + * {@code phase} to {@code performative} rename. The corpus case JSON files are frozen by policy, so the harness + * translates the legacy fragment instead of the fixtures. + * + * @param fragment message fragment declared by a frozen case fixture + * @return the fragment with the legacy wording translated to the current one + */ + private static String performativeWording(String fragment) { + return fragment.replace("expected phase", "expected performative"); + } + + // ------------------------------------------------------------------ helpers + + private static MetadataContent requireMessage(CaseOutcome outcome, String jsonPath) { + NegotiationCase testCase = outcome.testCase(); + MetadataContent message = outcome.message(); + if (message == null) { + fail( + testCase, + jsonPath, + "a generated negotiation message", + "failure (" + outcome.failure() + ") or " + typeName(outcome.value())); + } + return message; + } + + private static String readGoldenFixture(NegotiationCase testCase, String goldenName) { + String resourcePath = "/golden/" + testCase.language() + "/" + goldenName + ".md"; + InputStream stream = CaseEngine.class.getResourceAsStream(resourcePath); + if (stream == null) { + throw new AssertionError(testCase.errorPrefix() + " golden fixture '" + goldenName + + "' does not exist on the test" + " classpath: " + resourcePath); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); + } catch (IOException exception) { + throw new AssertionError( + testCase.errorPrefix() + " failed to read the golden fixture " + resourcePath, exception); + } + } + + private static String normalize(@Nullable String text) { + return text == null ? null : text.replace("\r\n", "\n"); + } + + private static String typeName(@Nullable Object value) { + return value == null ? "null" : value.getClass().getSimpleName(); + } + + private static String render(Map values) { + try { + return MAPPER.writeValueAsString(values); + } catch (IOException exception) { + return String.valueOf(values); + } + } + + private static String renderSlotErrors(List slotErrors) { + List pairs = new ArrayList<>(); + for (Expectation.SlotError slotError : slotErrors) { + pairs.add(slotError.slot() + ":" + slotError.code()); + } + return String.join(", ", pairs); + } + + private static String knownContractNames() { + List names = new ArrayList<>(); + for (Contract contract : Contract.values()) { + names.add(contract.jsonName()); + } + return String.join(", ", names); + } + + private static String truncate(String text) { + return text.length() <= MAX_REPORTED_TEXT_LENGTH ? text : text.substring(0, MAX_REPORTED_TEXT_LENGTH) + "..."; + } + + private static String quoted(@Nullable String text) { + return text == null ? "null" : "<" + text + ">"; + } + + private static AssertionError fail(NegotiationCase testCase, String jsonPath, String expected, String actual) { + throw new AssertionError( + testCase.errorPrefix() + " " + jsonPath + ": expected " + expected + " but was " + actual); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java index e4e46b5e..b2c618ff 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java @@ -1,858 +1,870 @@ -package net.openan.a2at.sdk.corpus; - -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 java.util.List; -import java.util.Map; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import org.junit.jupiter.api.BeforeEach; -import net.openan.a2at.sdk.corpus.Expectation.Metadata; -import org.junit.jupiter.api.Test; -import org.jspecify.annotations.Nullable; - -/** - * Direct unit tests of the {@link CaseEngine} against inline case objects: the four P0 contracts, the Q17 C+ - * differential double run, the inject hook, the fail-on-overconsumption calibration through {@code llmCalls}, and the - * red paths proving the engine is not a rubber stamp (every flipped expectation fails with the case id and the JSON path - * of the expectation). - * - *

        The engine runs against the real production wiring (builders, classpath resources, renderers, vocabulary, rule gate, - * semantic validator); the golden comparisons use the committed golden fixtures of the test resources. - */ -class CaseEngineTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String ZH_CN = "zh-CN"; - - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; - - private static final String INFORMATION_ACCEPT_REJECT_URI = - "Negotiation-T/information-negotiation/accept-reject/v1"; - - private static final String PRIVATE_LINE_COMPLAINT_URI = - "Task-T/network-layer/private-line-complaint/v1"; - - /** - * The workbench raw complaint of the five-step closed loop (样例步骤1): deliberately lacking the access port name - * and the complaint category — the causal starting point of the information negotiation. - */ - private static final String COMPLAINT_TEXT = - "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢," - + "柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; - - /** Slot-extraction payload of the closed loop's step 1: the task object stays empty (no port name in the text). */ - private static final String TASK_SLOTS_OBJECT_EMPTY = - "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" - + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** - * Slot-extraction payload of the from-text success path since the task_object slot became required upstream: the - * task object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays - * portless. - */ - private static final String TASK_SLOTS_OBJECT_PORTLESS = - "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" - + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** Slot-extraction payload of the filled variant: the port name and the complaint category are present. */ - private static final String TASK_SLOTS_FILLED = - "{\"slots\": {\"任务对象\": \"接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\", \"任务上下文\":" - + " \"投诉分类:专线质差;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:" - + "event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** - * Semantic-validation payload of the closed loop's step 2: the prompt is acceptable, but the access port name and - * the complaint category are missing — the two null-valued parameters the OMC then negotiates for. - */ - private static final String TASK_SEMANTIC_MISSING_PARAMS = - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," - + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; - - /** Semantic payload rejecting the task prompt (rejection-sample shape: key slots missing). */ - private static final String TASK_SEMANTIC_REJECTED = - "{\"semantic_verdict\":false,\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\"," - + "\"message\":\"The access port parameter is missing.\"}],\"params\":{}}"; - - /** The task parameter schema of the closed loop (server-side keys, dictionary §10). */ - private static final String TASK_PARAM_SCHEMA = - "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" - + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" - + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; - - /** The rendered task prompt the OMC receives (样例步骤1, shortened): the negotiation-causing message. */ - private static final String TASK_PROMPT_MISSING_PARAMS = """ - ## 任务类型(Task Type) - 传输专线业务投诉诊断 - - ## 任务对象(Task Object) - 接入端口名称: - - ## 任务上下文(Task Context) - 1. 投诉分类: - 2. 问题发生时间: "2026-05-11T08:21:46Z" - 3. OSS侧事件流水号:"event-id-20260511-09013" - 4. 投诉详情:"深圳访问广州的响应时延从平均12ms骤升至320ms" - """; - - /** Extraction payload that maps back to the typed content of the information accept golden fixture. */ - private static final String ACCEPT_PAYLOAD = - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" - + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; - - /** Extraction payload that maps back to the typed content of the information propose golden fixture. */ - private static final String PROPOSE_PAYLOAD = - "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}," - + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," - + "\"value\":null}],\"relationship\":\"OR\"}"; - - /** The same content as typed data, disagreeing in one item value for the differential red path. */ - private static final String PROPOSE_DATA_DISAGREEING = - "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P781-珠江新城-PTN7900-23-TPA1EG24-17\"}," - + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," - + "\"value\":null}],\"relationship\":\"OR\"}"; - - private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" - + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; - - private static final String SEMANTIC_REJECT_PAYLOAD = - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" - + "\"accessPort\",\"code\":\"missing\",\"message\":\"The access port parameter is" - + " missing.\"}],\"params\":{}}"; - - private final CaseEngine engine = new CaseEngine(); - - private final ObjectMapper mapper = new ObjectMapper(); - - private JsonNode schema; - - @BeforeEach - void readSchema() throws Exception { - schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); - } - - // ------------------------------------------------------------------ green paths - - @Test - void runsASuccessfulFromTextCaseAgainstTheGoldenFixture() { - NegotiationCase testCase = acceptFromTextCase(ok(1, "information_accept", metadata(), false), null); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - MetadataContent message = outcome.message(); - assertNotNull(message); - assertEquals(1, outcome.llmCalls()); - assertEquals(INFORMATION_ACCEPT_REJECT_URI, message.templateUri()); - assertEquals(new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.ACCEPT), - message.negotiationContext()); - } - - @Test - void runsFromDataCasesDeterministicallyWithoutAnyLlmCall() throws Exception { - NegotiationCase testCase = new NegotiationCase( - "FD-HAPPY-01/zh-CN", - "FD-HAPPY-01", - "from-data/happy.json", - NegotiationApi.GENERATE_ACCEPT_FROM_DATA, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - null, - mapper.readTree(ACCEPT_PAYLOAD), - null, - null, - null, - null, - ok(0, "information_accept", metadata(), false)); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - assertNotNull(outcome.message()); - assertEquals(0, outcome.llmCalls(), "the from-data run must not call the LLM (assertion-only client)"); - } - - @Test - void injectsTheFailingTemplateLoaderForTheTemplateNotFoundMatrix() { - NegotiationCase testCase = new NegotiationCase( - "FT-TPL-01/zh-CN", - "FT-TPL-01", - "from-text/template-resolution.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(ACCEPT_PAYLOAD))), - null, - null, - "failingTemplateLoader", - failed("NegotiationGenerationException", "template_not_found", 0, List.of(), List.of())); - - engine.run(testCase); - } - - @Test - void injectsTheFailingSemanticValidatorForTheValidateTemplateNotFoundMapping() { - NegotiationCase testCase = new NegotiationCase( - "VAL-MAP-05/zh-CN", - "VAL-MAP-05", - "validate/error-code-mapping.json", - NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_PROPOSE_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.ASSERTION))), - new PromptSource.Golden("information_propose"), - schema, - "failingSemanticValidator", - failed("NegotiationParamExtractionException", "template_not_found", 0, List.of(), List.of())); - - engine.run(testCase); - } - - // ------------------------------------------------------------------ P0 contracts - - @Test - void assertConclusionLiteralPresentContract() { - NegotiationCase testCase = - acceptFromTextCase(okContracts(1, "information_accept", List.of("conclusionLiteralPresent")), null); - - engine.run(testCase); - } - - @Test - void conclusionLiteralPresentContractRejectsNonTerminalApis() { - NegotiationCase testCase = - proposeFromTextCase(okContracts(1, "information_propose", List.of("conclusionLiteralPresent")), null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("conclusionLiteralPresent") - && failure.getMessage().contains("a terminal or abort generation API"), - "the contract must reject a propose API but was: " + failure.getMessage()); - } - - @Test - void assertContextKeysInMergedParamsContract() { - NegotiationCase testCase = validateProposeCase( - okParams( - 1, - Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P533-珠江旧城-PTN3900-23-TPA1EG24-1"), - List.of("contextKeysInMergedParams")), - SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - FilledParamData filled = outcome.filledParams(); - assertNotNull(filled); - assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); - } - - @Test - void assertNoLlmLeakInUserMessageContract() { - NegotiationCase testCase = new NegotiationCase( - "FT-RETRY-01/zh-CN", - "FT-RETRY-01", - "from-text/retry.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - new LlmScript( - null, - List.of( - new LlmScriptStep.Fail(LlmFailMarker.RUNTIME_EXCEPTION), - new LlmScriptStep.Fail(LlmFailMarker.RUNTIME_EXCEPTION), - new LlmScriptStep.Fail(LlmFailMarker.RUNTIME_EXCEPTION))), - null, - null, - null, - failed( - "NegotiationGenerationException", - "negotiation_llm_infrastructure_error", - 3, - List.of(), - List.of("noLlmLeakInUserMessage"))); - - engine.run(testCase); - } - - @Test - void assertMetadataTripleShapeContract() { - NegotiationCase testCase = - acceptFromTextCase(okContracts(1, "information_accept", List.of("metadataTripleShape")), null); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - assertEquals( - 3, - outcome.message().buildMetadataContent().size(), - "the contract must have verified the triple metadata shape"); - } - - @Test - void rejectsUnknownAndNotYetLitContractNames() { - NegotiationCase unknown = - acceptFromTextCase(okContracts(1, "information_accept", List.of("not-a-contract")), null); - AssertionError unknownFailure = assertThrows(AssertionError.class, () -> engine.run(unknown)); - assertTrue( - unknownFailure.getMessage().contains("a registered contract name"), - "an unknown contract name must fail but was: " + unknownFailure.getMessage()); - - NegotiationCase notYetLit = - acceptFromTextCase(okContracts(1, "information_accept", List.of("noRenderSlotLeak")), null); - AssertionError notYetLitFailure = assertThrows(AssertionError.class, () -> engine.run(notYetLit)); - assertTrue( - notYetLitFailure.getMessage().contains("not yet lit"), - "a registered P1 contract must fail as not yet lit but was: " + notYetLitFailure.getMessage()); - } - - // ------------------------------------------------------------------ differential (Q17 C+) - - @Test - void runsTheDifferentialDoubleRun() throws Exception { - NegotiationCase testCase = proposeFromTextCase( - ok(1, "information_propose", new Expectation.Metadata(INFORMATION_PROPOSE_URI, Boolean.TRUE), true), - mapper.readTree(PROPOSE_PAYLOAD)); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - assertNotNull(outcome.message()); - assertEquals(1, outcome.llmCalls(), "only the from-text leg may call the LLM"); - } - - @Test - void differentialFailsWhenTheTypedDataDisagreesWithTheText() throws Exception { - NegotiationCase testCase = proposeFromTextCase( - ok(1, "information_propose", new Expectation.Metadata(INFORMATION_PROPOSE_URI, Boolean.TRUE), true), - mapper.readTree(PROPOSE_DATA_DISAGREEING)); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.differential") - && failure.getMessage().contains("fromText == fromData"), - "the differential must compare both legs but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ validate expectations - - @Test - void comparesSlotErrorsOrderInsensitively() { - NegotiationCase testCase = validateProposeCase( - failed( - "NegotiationParamExtractionException", - "negotiation_semantic_rejected", - 1, - List.of(new Expectation.SlotError("accessPort", "missing")), - List.of()), - SEMANTIC_REJECT_PAYLOAD); - - engine.run(testCase); - } - - @Test - void slotErrorsFailOnAnExtraExpectedPair() { - NegotiationCase testCase = validateProposeCase( - failed( - "NegotiationParamExtractionException", - "negotiation_semantic_rejected", - 1, - List.of( - new Expectation.SlotError("accessPort", "missing"), - new Expectation.SlotError("round", "out_of_range")), - List.of()), - SEMANTIC_REJECT_PAYLOAD); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.slotErrors"), - "an extra expected slot error must fail but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ red paths (not a rubber stamp) - - @Test - void failsWithCaseIdAndJsonPathWhenTheExpectedCodeMismatches() { - NegotiationCase testCase = new NegotiationCase( - "FT-EXTRACT-01/zh-CN", - "FT-EXTRACT-01", - "from-text/extraction-failures.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload("{\"relationship\":null}"))), - null, - null, - null, - failed(null, "negotiation_invalid_input", 1, List.of(), List.of())); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("FT-EXTRACT-01/zh-CN") - && failure.getMessage().contains("$.expect.code") - && failure.getMessage().contains("negotiation_slot_missing"), - "the failure must carry the case id, the JSON path and both codes but was: " + failure.getMessage()); - } - - @Test - void failsWithCaseIdAndJsonPathWhenTheLlmCallCountMismatches() { - NegotiationCase testCase = acceptFromTextCase(ok(2, "information_accept", null, false), null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.llmCalls") && failure.getMessage().contains("2"), - "a wrong llmCalls expectation must fail but was: " + failure.getMessage()); - } - - @Test - void failsWithCaseIdAndJsonPathWhenTheGoldenNameMismatches() { - NegotiationCase testCase = acceptFromTextCase(ok(1, "information_reject", null, false), null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.promptTextEqualsGolden"), - "a wrong golden name must fail but was: " + failure.getMessage()); - } - - @Test - void failsWhenASuccessCaseActuallyFails() { - NegotiationCase testCase = new NegotiationCase( - "FT-HAPPY-99/zh-CN", - "FT-HAPPY-99", - "from-text/happy.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), - null, - null, - null, - ok(1, "information_accept", null, false)); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.outcome") && failure.getMessage().contains("failure"), - "an unexpected failure must flip the outcome expectation but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ task API dispatch family (Q21) - - @Test - void runsATaskFromTextCaseThroughTheMirroredClientWiring() { - NegotiationCase testCase = taskFromTextCase(okTask( - 1, - List.of("## 任务类型", "## 任务对象", "## 任务上下文", "event-id-20260511-09013"), - null, - Map.of())); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - MetadataContent message = outcome.message(); - assertNotNull(message); - assertEquals(1, outcome.llmCalls(), "the task from-text pipeline makes exactly one slot-extraction call"); - assertEquals(PRIVATE_LINE_COMPLAINT_URI, message.templateUri()); - assertTrue( - message.promptText().contains("## 任务对象(Task Object)"), - "the task object section header must be rendered: " + message.promptText()); - assertTrue(!message.promptText().contains("P533"), "no port name may leak into the incomplete task prompt"); - } - - @Test - void runsATaskFromDataWithSchemaCaseThroughTheMirroredClientWiring() throws Exception { - NegotiationCase testCase = taskFromDataCase(okTask( - 1, - List.of("## 任务类型", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差"), - null, - Map.of())); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - MetadataContent message = outcome.message(); - assertNotNull(message); - assertEquals(1, outcome.llmCalls(), "the from-data-with-schema pipeline also extracts slots through the LLM"); - assertEquals(PRIVATE_LINE_COMPLAINT_URI, message.templateUri()); - } - - @Test - void validateTaskPromptReportsTheMissingParametersAsNullValuedParams() throws Exception { - NegotiationCase testCase = taskValidateCase( - okTask(1, null, List.of("accessPort", "bizScenario"), Map.of( - "faultTime", "2026-05-11T08:21:46Z", - "eventSerialNo", "event-id-20260511-09013")), - TASK_SEMANTIC_MISSING_PARAMS); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - FilledParamData filled = outcome.filledParams(); - assertNotNull(filled); - assertEquals(1, outcome.llmCalls(), "the task validation pipeline makes exactly one semantic call"); - assertEquals("2026-05-11T08:21:46Z", filled.data().get("faultTime")); - assertEquals("event-id-20260511-09013", filled.data().get("eventSerialNo")); - assertTrue(filled.data().containsKey("accessPort") && filled.data().get("accessPort") == null, - "a missing parameter must surface as a null-valued entry, not as an absent key"); - } - - @Test - void taskValidateMissingParamsFlipFails() throws Exception { - NegotiationCase testCase = taskValidateCase( - okTask(1, null, List.of("accessPort", "bizScenario", "faultDetail"), Map.of()), - TASK_SEMANTIC_MISSING_PARAMS); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.missingParams") - && failure.getMessage().contains("faultDetail"), - "an extra expected missing parameter must fail but was: " + failure.getMessage()); - } - - @Test - void taskValidateSemanticRejectionCarriesTheValidationCode() throws Exception { - NegotiationCase testCase = taskValidateCase( - failed("ContentValidationException", "validation_semantic_rejected", 1, List.of(), List.of()), - TASK_SEMANTIC_REJECTED); - - engine.run(testCase); - } - - @Test - void taskFromTextFailsOnAMissingRequiredSlot() throws Exception { - NegotiationCase testCase = new NegotiationCase( - "TASK-ERR-01/zh-CN", - "TASK-ERR-01", - "task/programming-errors.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - COMPLAINT_TEXT, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload( - "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"\"}, \"slot_errors\": []}"))), - null, - null, - null, - failed("PromptGenerationException", "slot_validation_error", 1, List.of(), List.of())); - - engine.run(testCase); - } - - @Test - void taskPromptTextContainsFailsOnAnAbsentFragment() { - NegotiationCase testCase = taskFromTextCase( - okTask(1, List.of("## 不存在的节头"), null, Map.of())); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.promptTextContains") - && failure.getMessage().contains("不存在的节头"), - "an absent structural fragment must fail but was: " + failure.getMessage()); - } - - @Test - void paramsFromStepIsReservedForTheScenarioEngine() throws Exception { - NegotiationCase testCase = taskValidateCase( - okTask(1, null, null, Map.of()), - TASK_SEMANTIC_MISSING_PARAMS); - NegotiationCase withFromStep = new NegotiationCase( - testCase.id(), - testCase.baseId(), - testCase.sourceFile(), - testCase.api(), - testCase.language(), - testCase.priority(), - testCase.tags(), - testCase.summary(), - testCase.context(), - testCase.templateUri(), - testCase.inputText(), - testCase.inputData(), - testCase.llm(), - testCase.prompt(), - testCase.schema(), - testCase.inject(), - new Expectation( - testCase.expect().success(), - testCase.expect().exception(), - testCase.expect().code(), - testCase.expect().messageContains(), - testCase.expect().slotErrors(), - testCase.expect().llmCalls(), - testCase.expect().promptTextEqualsGolden(), - testCase.expect().metadata(), - testCase.expect().params(), - testCase.expect().contracts(), - testCase.expect().differential(), - testCase.expect().promptTextContains(), - testCase.expect().missingParams(), - 1)); - - IllegalStateException failure = - assertThrows(IllegalStateException.class, () -> engine.run(withFromStep)); - - assertTrue( - failure.getMessage().contains("paramsFromStep 1") - && failure.getMessage().contains("ScenarioEngine"), - "a standalone case cannot resolve paramsFromStep but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ expectation helpers - - private static Expectation ok( - @Nullable Integer llmCalls, - @Nullable String promptTextEqualsGolden, - @Nullable Metadata metadata, - boolean differential) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - promptTextEqualsGolden, - metadata, - Map.of(), - List.of(), - differential); - } - - private static Expectation okContracts( - @Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden, List contracts) { - return new Expectation( - true, null, null, List.of(), List.of(), llmCalls, promptTextEqualsGolden, null, Map.of(), contracts, - false); - } - - private static Expectation okParams( - @Nullable Integer llmCalls, Map params, List contracts) { - return new Expectation( - true, null, null, List.of(), List.of(), llmCalls, null, null, params, contracts, false); - } - - private static Expectation failed( - @Nullable String exception, - @Nullable String code, - @Nullable Integer llmCalls, - List slotErrors, - List contracts) { - return new Expectation( - false, exception, code, List.of(), slotErrors, llmCalls, null, null, Map.of(), contracts, false); - } - - // ------------------------------------------------------------------ case builders - - private NegotiationCase acceptFromTextCase(Expectation expect, @Nullable JsonNode inputData) { - return new NegotiationCase( - "FT-HAPPY-01/zh-CN", - "FT-HAPPY-01", - "from-text/happy.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "我确认第一阶段的信息。", - inputData, - new LlmScript(null, List.of(new LlmScriptStep.Payload(ACCEPT_PAYLOAD))), - null, - null, - null, - expect); - } - - private NegotiationCase proposeFromTextCase(Expectation expect, @Nullable JsonNode inputData) { - return new NegotiationCase( - "FT-HAPPY-02/zh-CN", - "FT-HAPPY-02", - "from-text/happy.json", - NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_PROPOSE_URI, - "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", - inputData, - new LlmScript(null, List.of(new LlmScriptStep.Payload(PROPOSE_PAYLOAD))), - null, - null, - null, - expect); - } - - private NegotiationCase validateProposeCase(Expectation expect, String semanticPayload) { - return new NegotiationCase( - "VAL-HAPPY-01/zh-CN", - "VAL-HAPPY-01", - "validate/happy.json", - NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_PROPOSE_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), - new PromptSource.Golden("information_propose"), - schema, - null, - expect); - } - - private static Expectation.Metadata metadata() { - return new Expectation.Metadata(INFORMATION_ACCEPT_REJECT_URI, Boolean.TRUE); - } - - // ------------------------------------------------------------------ task family helpers - - private static Expectation okTask( - @Nullable Integer llmCalls, - @Nullable List promptTextContains, - @Nullable List missingParams, - Map params) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - null, - null, - params, - List.of(), - false, - promptTextContains == null ? List.of() : promptTextContains, - missingParams, - null); - } - - private NegotiationCase taskFromTextCase(Expectation expect) { - return new NegotiationCase( - "TASK-FT-01/zh-CN", - "TASK-FT-01", - "task/from-text.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - COMPLAINT_TEXT, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_OBJECT_PORTLESS))), - null, - null, - null, - expect); - } - - private NegotiationCase taskFromDataCase(Expectation expect) throws Exception { - return new NegotiationCase( - "TASK-FD-01/zh-CN", - "TASK-FD-01", - "task/from-data.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA, - ZH_CN, - "P0", - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - null, - mapper.readTree( - "{\"portName\": \"P533-珠江旧城-PTN3900-23-TPA1EG24-1\", \"complaintScenario\": \"专线质差\"," - + " \"faultStartTime\": \"2026-05-11T08:21:46Z\", \"ticketNo\":" - + " \"event-id-20260511-09013\", \"faultDetailText\": \"深圳访问广州的响应时延从平均12ms" - + "骤升至320ms\"}"), - new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_FILLED))), - null, - mapper.readTree( - "{\"type\": \"object\", \"properties\": {\"portName\": {\"type\": \"string\"}," - + " \"complaintScenario\": {\"type\": \"string\"}, \"faultStartTime\":" - + " {\"type\": \"string\"}, \"ticketNo\": {\"type\": \"string\"}," - + " \"faultDetailText\": {\"type\": \"string\"}}}"), - null, - expect); - } - - private NegotiationCase taskValidateCase(Expectation expect, String semanticPayload) throws Exception { - return new NegotiationCase( - "TASK-VAL-01/zh-CN", - "TASK-VAL-01", - "task/validate.json", - NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - ZH_CN, - "P0", - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), - new PromptSource.Text(TASK_PROMPT_MISSING_PARAMS), - mapper.readTree(TASK_PARAM_SCHEMA), - null, - expect); - } -} +package net.openan.a2at.sdk.corpus; + +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.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.corpus.Expectation.Metadata; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Direct unit tests of the {@link CaseEngine} against inline case objects: the four P0 contracts, the Q17 C+ + * differential double run, the inject hook, the fail-on-overconsumption calibration through {@code llmCalls}, and the + * red paths proving the engine is not a rubber stamp (every flipped expectation fails with the case id and the JSON + * path of the expectation). + * + *

        The engine runs against the real production wiring (builders, classpath resources, renderers, vocabulary, rule + * gate, semantic validator); the golden comparisons use the committed golden fixtures of the test resources. + */ +class CaseEngineTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String ZH_CN = "zh-CN"; + + private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; + + private static final String INFORMATION_ACCEPT_REJECT_URI = + "Negotiation-T/information-negotiation/accept-reject/v1"; + + private static final String PRIVATE_LINE_COMPLAINT_URI = "Task-T/network-layer/private-line-complaint/v1"; + + /** + * The workbench raw complaint of the five-step closed loop (样例步骤1): deliberately lacking the access port name and + * the complaint category — the causal starting point of the information negotiation. + */ + private static final String COMPLAINT_TEXT = "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢," + + "柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; + + /** Slot-extraction payload of the closed loop's step 1: the task object stays empty (no port name in the text). */ + private static final String TASK_SLOTS_OBJECT_EMPTY = + "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" + + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** + * Slot-extraction payload of the from-text success path since the task_object slot became required upstream: the + * task object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays + * portless. + */ + private static final String TASK_SLOTS_OBJECT_PORTLESS = + "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" + + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** Slot-extraction payload of the filled variant: the port name and the complaint category are present. */ + private static final String TASK_SLOTS_FILLED = + "{\"slots\": {\"任务对象\": \"接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\", \"任务上下文\":" + + " \"投诉分类:专线质差;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:" + + "event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** + * Semantic-validation payload of the closed loop's step 2: the prompt is acceptable, but the access port name and + * the complaint category are missing — the two null-valued parameters the OMC then negotiates for. + */ + private static final String TASK_SEMANTIC_MISSING_PARAMS = + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," + + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; + + /** Semantic payload rejecting the task prompt (rejection-sample shape: key slots missing). */ + private static final String TASK_SEMANTIC_REJECTED = + "{\"semantic_verdict\":false,\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"content.param_missing\"," + + "\"facts\":{\"section_label\":\"接入端口名称\"}}],\"params\":{}}"; + + /** The task parameter schema of the closed loop (server-side keys, dictionary §10). */ + private static final String TASK_PARAM_SCHEMA = + "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" + + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" + + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; + + /** The rendered task prompt the OMC receives (样例步骤1, shortened): the negotiation-causing message. */ + private static final String TASK_PROMPT_MISSING_PARAMS = + """ + ## 任务类型(Task Type) + 传输专线业务投诉诊断 + + ## 任务对象(Task Object) + 接入端口名称: + + ## 任务上下文(Task Context) + 1. 投诉分类: + 2. 问题发生时间: "2026-05-11T08:21:46Z" + 3. OSS侧事件流水号:"event-id-20260511-09013" + 4. 投诉详情:"深圳访问广州的响应时延从平均12ms骤升至320ms" + """; + + /** Extraction payload that maps back to the typed content of the information accept golden fixture. */ + private static final String ACCEPT_PAYLOAD = + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" + + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; + + /** Extraction payload that maps back to the typed content of the information propose golden fixture. */ + private static final String PROPOSE_PAYLOAD = + "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}," + + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," + + "\"value\":null}],\"relationship\":\"OR\"}"; + + /** The same content as typed data, disagreeing in one item value for the differential red path. */ + private static final String PROPOSE_DATA_DISAGREEING = + "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P781-珠江新城-PTN7900-23-TPA1EG24-17\"}," + + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," + + "\"value\":null}],\"relationship\":\"OR\"}"; + + private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" + + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; + + private static final String SEMANTIC_REJECT_PAYLOAD = + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" + + "\"accessPort\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":" + + "\"接入端口名称\"}}],\"params\":{}}"; + + private final CaseEngine engine = new CaseEngine(); + + private final ObjectMapper mapper = new ObjectMapper(); + + private JsonNode schema; + + @BeforeEach + void readSchema() throws Exception { + schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); + } + + // ------------------------------------------------------------------ green paths + + @Test + void runsASuccessfulFromTextCaseAgainstTheGoldenFixture() { + NegotiationCase testCase = acceptFromTextCase(ok(1, "information_accept", metadata(), false), null); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + MetadataContent message = outcome.message(); + assertNotNull(message); + assertEquals(1, outcome.llmCalls()); + assertEquals(INFORMATION_ACCEPT_REJECT_URI, message.templateUri()); + assertEquals( + new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.ACCEPT), message.negotiationContext()); + } + + @Test + void runsFromDataCasesDeterministicallyWithoutAnyLlmCall() throws Exception { + NegotiationCase testCase = new NegotiationCase( + "FD-HAPPY-01/zh-CN", + "FD-HAPPY-01", + "from-data/happy.json", + NegotiationApi.GENERATE_ACCEPT_FROM_DATA, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + null, + mapper.readTree(ACCEPT_PAYLOAD), + null, + null, + null, + null, + ok(0, "information_accept", metadata(), false)); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + assertNotNull(outcome.message()); + assertEquals(0, outcome.llmCalls(), "the from-data run must not call the LLM (assertion-only client)"); + } + + @Test + void injectsTheFailingTemplateLoaderForTheTemplateNotFoundMatrix() { + NegotiationCase testCase = new NegotiationCase( + "FT-TPL-01/zh-CN", + "FT-TPL-01", + "from-text/template-resolution.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(ACCEPT_PAYLOAD))), + null, + null, + "failingTemplateLoader", + failed("NegotiationGenerationException", "template.not_found", 0, List.of(), List.of())); + + engine.run(testCase); + } + + @Test + void injectsTheFailingSemanticValidatorForTheValidateTemplateNotFoundMapping() { + NegotiationCase testCase = new NegotiationCase( + "VAL-MAP-05/zh-CN", + "VAL-MAP-05", + "validate/error-code-mapping.json", + NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_PROPOSE_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.ASSERTION))), + new PromptSource.Golden("information_propose"), + schema, + "failingSemanticValidator", + failed("NegotiationParamExtractionException", "template.not_found", 0, List.of(), List.of())); + + engine.run(testCase); + } + + // ------------------------------------------------------------------ P0 contracts + + @Test + void assertConclusionLiteralPresentContract() { + NegotiationCase testCase = + acceptFromTextCase(okContracts(1, "information_accept", List.of("conclusionLiteralPresent")), null); + + engine.run(testCase); + } + + @Test + void conclusionLiteralPresentContractRejectsNonTerminalApis() { + NegotiationCase testCase = + proposeFromTextCase(okContracts(1, "information_propose", List.of("conclusionLiteralPresent")), null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("conclusionLiteralPresent") + && failure.getMessage().contains("a terminal or abort generation API"), + "the contract must reject a propose API but was: " + failure.getMessage()); + } + + @Test + void assertContextKeysInMergedParamsContract() { + NegotiationCase testCase = validateProposeCase( + okParams( + 1, + Map.of( + "id", + SESSION_ID, + "round", + 2, + "maxRounds", + 5, + "accessPort", + "P533-珠江旧城-PTN3900-23-TPA1EG24-1"), + List.of("contextKeysInMergedParams")), + SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + FilledParamData filled = outcome.filledParams(); + assertNotNull(filled); + assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); + } + + @Test + void assertNoLlmLeakInUserMessageContract() { + NegotiationCase testCase = new NegotiationCase( + "FT-RETRY-01/zh-CN", + "FT-RETRY-01", + "from-text/retry.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + new LlmScript( + null, + List.of( + new LlmScriptStep.Fail(LlmFailMarker.NON_JSON), + new LlmScriptStep.Fail(LlmFailMarker.NON_JSON), + new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), + null, + null, + null, + failed( + "NegotiationGenerationException", + "llm.response_invalid", + 3, + List.of(), + List.of("noLlmLeakInUserMessage"))); + + engine.run(testCase); + } + + @Test + void assertMetadataTripleShapeContract() { + NegotiationCase testCase = + acceptFromTextCase(okContracts(1, "information_accept", List.of("metadataTripleShape")), null); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + assertEquals( + 3, + outcome.message().buildMetadataContent().size(), + "the contract must have verified the triple metadata shape"); + } + + @Test + void rejectsUnknownAndNotYetLitContractNames() { + NegotiationCase unknown = + acceptFromTextCase(okContracts(1, "information_accept", List.of("not-a-contract")), null); + AssertionError unknownFailure = assertThrows(AssertionError.class, () -> engine.run(unknown)); + assertTrue( + unknownFailure.getMessage().contains("a registered contract name"), + "an unknown contract name must fail but was: " + unknownFailure.getMessage()); + + NegotiationCase notYetLit = + acceptFromTextCase(okContracts(1, "information_accept", List.of("noRenderSlotLeak")), null); + AssertionError notYetLitFailure = assertThrows(AssertionError.class, () -> engine.run(notYetLit)); + assertTrue( + notYetLitFailure.getMessage().contains("not yet lit"), + "a registered P1 contract must fail as not yet lit but was: " + notYetLitFailure.getMessage()); + } + + // ------------------------------------------------------------------ differential (Q17 C+) + + @Test + void runsTheDifferentialDoubleRun() throws Exception { + NegotiationCase testCase = proposeFromTextCase( + ok(1, "information_propose", new Expectation.Metadata(INFORMATION_PROPOSE_URI, Boolean.TRUE), true), + mapper.readTree(PROPOSE_PAYLOAD)); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + assertNotNull(outcome.message()); + assertEquals(1, outcome.llmCalls(), "only the from-text leg may call the LLM"); + } + + @Test + void differentialFailsWhenTheTypedDataDisagreesWithTheText() throws Exception { + NegotiationCase testCase = proposeFromTextCase( + ok(1, "information_propose", new Expectation.Metadata(INFORMATION_PROPOSE_URI, Boolean.TRUE), true), + mapper.readTree(PROPOSE_DATA_DISAGREEING)); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.differential") + && failure.getMessage().contains("fromText == fromData"), + "the differential must compare both legs but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ validate expectations + + @Test + void comparesSlotErrorsOrderInsensitively() { + NegotiationCase testCase = validateProposeCase( + failed( + "NegotiationParamExtractionException", + "negotiation.semantic_rejected", + 1, + List.of(new Expectation.SlotError("accessPort", "negotiation.field_missing")), + List.of()), + SEMANTIC_REJECT_PAYLOAD); + + engine.run(testCase); + } + + @Test + void slotErrorsFailOnAnExtraExpectedPair() { + NegotiationCase testCase = validateProposeCase( + failed( + "NegotiationParamExtractionException", + "negotiation.semantic_rejected", + 1, + List.of( + new Expectation.SlotError("accessPort", "negotiation.field_missing"), + new Expectation.SlotError("round", "negotiation.round_exceeded")), + List.of()), + SEMANTIC_REJECT_PAYLOAD); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.slotErrors"), + "an extra expected slot error must fail but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ red paths (not a rubber stamp) + + @Test + void failsWithCaseIdAndJsonPathWhenTheExpectedCodeMismatches() { + NegotiationCase testCase = new NegotiationCase( + "FT-EXTRACT-01/zh-CN", + "FT-EXTRACT-01", + "from-text/extraction-failures.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload("{\"relationship\":null}"))), + null, + null, + null, + failed(null, "negotiation.invalid_input", 1, List.of(), List.of())); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("FT-EXTRACT-01/zh-CN") + && failure.getMessage().contains("$.expect.code") + && failure.getMessage().contains("negotiation.field_missing"), + "the failure must carry the case id, the JSON path and both codes but was: " + failure.getMessage()); + } + + @Test + void failsWithCaseIdAndJsonPathWhenTheLlmCallCountMismatches() { + NegotiationCase testCase = acceptFromTextCase(ok(2, "information_accept", null, false), null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.llmCalls") + && failure.getMessage().contains("2"), + "a wrong llmCalls expectation must fail but was: " + failure.getMessage()); + } + + @Test + void failsWithCaseIdAndJsonPathWhenTheGoldenNameMismatches() { + NegotiationCase testCase = acceptFromTextCase(ok(1, "information_reject", null, false), null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.promptTextEqualsGolden"), + "a wrong golden name must fail but was: " + failure.getMessage()); + } + + @Test + void failsWhenASuccessCaseActuallyFails() { + NegotiationCase testCase = new NegotiationCase( + "FT-HAPPY-99/zh-CN", + "FT-HAPPY-99", + "from-text/happy.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), + null, + null, + null, + ok(1, "information_accept", null, false)); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.outcome") + && failure.getMessage().contains("failure"), + "an unexpected failure must flip the outcome expectation but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ task API dispatch family (Q21) + + @Test + void runsATaskFromTextCaseThroughTheMirroredClientWiring() { + NegotiationCase testCase = taskFromTextCase( + okTask(1, List.of("## 任务类型", "## 任务对象", "## 任务上下文", "event-id-20260511-09013"), null, Map.of())); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + MetadataContent message = outcome.message(); + assertNotNull(message); + assertEquals(1, outcome.llmCalls(), "the task from-text pipeline makes exactly one slot-extraction call"); + assertEquals(PRIVATE_LINE_COMPLAINT_URI, message.templateUri()); + assertTrue( + message.promptText().contains("## 任务对象(Task Object)"), + "the task object section header must be rendered: " + message.promptText()); + assertTrue(!message.promptText().contains("P533"), "no port name may leak into the incomplete task prompt"); + } + + @Test + void runsATaskFromDataWithSchemaCaseThroughTheMirroredClientWiring() throws Exception { + NegotiationCase testCase = taskFromDataCase( + okTask(1, List.of("## 任务类型", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差"), null, Map.of())); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + MetadataContent message = outcome.message(); + assertNotNull(message); + assertEquals(1, outcome.llmCalls(), "the from-data-with-schema pipeline also extracts slots through the LLM"); + assertEquals(PRIVATE_LINE_COMPLAINT_URI, message.templateUri()); + } + + @Test + void validateTaskPromptReportsTheMissingParametersAsNullValuedParams() throws Exception { + NegotiationCase testCase = taskValidateCase( + okTask( + 1, + null, + List.of("accessPort", "bizScenario"), + Map.of( + "faultTime", "2026-05-11T08:21:46Z", + "eventSerialNo", "event-id-20260511-09013")), + TASK_SEMANTIC_MISSING_PARAMS); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + FilledParamData filled = outcome.filledParams(); + assertNotNull(filled); + assertEquals(1, outcome.llmCalls(), "the task validation pipeline makes exactly one semantic call"); + assertEquals("2026-05-11T08:21:46Z", filled.data().get("faultTime")); + assertEquals("event-id-20260511-09013", filled.data().get("eventSerialNo")); + assertTrue( + filled.data().containsKey("accessPort") && filled.data().get("accessPort") == null, + "a missing parameter must surface as a null-valued entry, not as an absent key"); + } + + @Test + void taskValidateMissingParamsFlipFails() throws Exception { + NegotiationCase testCase = taskValidateCase( + okTask(1, null, List.of("accessPort", "bizScenario", "faultDetail"), Map.of()), + TASK_SEMANTIC_MISSING_PARAMS); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.missingParams") + && failure.getMessage().contains("faultDetail"), + "an extra expected missing parameter must fail but was: " + failure.getMessage()); + } + + @Test + void taskValidateSemanticRejectionCarriesTheValidationCode() throws Exception { + NegotiationCase testCase = taskValidateCase( + failed("ContentValidationException", "negotiation.semantic_rejected", 1, List.of(), List.of()), + TASK_SEMANTIC_REJECTED); + + engine.run(testCase); + } + + @Test + void taskFromTextFailsOnAMissingRequiredSlot() throws Exception { + NegotiationCase testCase = new NegotiationCase( + "TASK-ERR-01/zh-CN", + "TASK-ERR-01", + "task/programming-errors.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + COMPLAINT_TEXT, + null, + new LlmScript( + null, + List.of(new LlmScriptStep.Payload( + "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"\"}, \"slot_errors\": []}"))), + null, + null, + null, + failed("PromptGenerationException", "slot.not_provided", 1, List.of(), List.of())); + + engine.run(testCase); + } + + @Test + void taskPromptTextContainsFailsOnAnAbsentFragment() { + NegotiationCase testCase = taskFromTextCase(okTask(1, List.of("## 不存在的节头"), null, Map.of())); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.promptTextContains") + && failure.getMessage().contains("不存在的节头"), + "an absent structural fragment must fail but was: " + failure.getMessage()); + } + + @Test + void paramsFromStepIsReservedForTheScenarioEngine() throws Exception { + NegotiationCase testCase = taskValidateCase(okTask(1, null, null, Map.of()), TASK_SEMANTIC_MISSING_PARAMS); + NegotiationCase withFromStep = new NegotiationCase( + testCase.id(), + testCase.baseId(), + testCase.sourceFile(), + testCase.api(), + testCase.language(), + testCase.priority(), + testCase.tags(), + testCase.summary(), + testCase.context(), + testCase.templateUri(), + testCase.inputText(), + testCase.inputData(), + testCase.llm(), + testCase.prompt(), + testCase.schema(), + testCase.inject(), + new Expectation( + testCase.expect().success(), + testCase.expect().exception(), + testCase.expect().code(), + testCase.expect().messageContains(), + testCase.expect().slotErrors(), + testCase.expect().llmCalls(), + testCase.expect().promptTextEqualsGolden(), + testCase.expect().metadata(), + testCase.expect().params(), + testCase.expect().contracts(), + testCase.expect().differential(), + testCase.expect().promptTextContains(), + testCase.expect().missingParams(), + 1)); + + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> engine.run(withFromStep)); + + assertTrue( + failure.getMessage().contains("paramsFromStep 1") + && failure.getMessage().contains("ScenarioEngine"), + "a standalone case cannot resolve paramsFromStep but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ expectation helpers + + private static Expectation ok( + @Nullable Integer llmCalls, + @Nullable String promptTextEqualsGolden, + @Nullable Metadata metadata, + boolean differential) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + promptTextEqualsGolden, + metadata, + Map.of(), + List.of(), + differential); + } + + private static Expectation okContracts( + @Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden, List contracts) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + promptTextEqualsGolden, + null, + Map.of(), + contracts, + false); + } + + private static Expectation okParams( + @Nullable Integer llmCalls, Map params, List contracts) { + return new Expectation(true, null, null, List.of(), List.of(), llmCalls, null, null, params, contracts, false); + } + + private static Expectation failed( + @Nullable String exception, + @Nullable String code, + @Nullable Integer llmCalls, + List slotErrors, + List contracts) { + return new Expectation( + false, exception, code, List.of(), slotErrors, llmCalls, null, null, Map.of(), contracts, false); + } + + // ------------------------------------------------------------------ case builders + + private NegotiationCase acceptFromTextCase(Expectation expect, @Nullable JsonNode inputData) { + return new NegotiationCase( + "FT-HAPPY-01/zh-CN", + "FT-HAPPY-01", + "from-text/happy.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "我确认第一阶段的信息。", + inputData, + new LlmScript(null, List.of(new LlmScriptStep.Payload(ACCEPT_PAYLOAD))), + null, + null, + null, + expect); + } + + private NegotiationCase proposeFromTextCase(Expectation expect, @Nullable JsonNode inputData) { + return new NegotiationCase( + "FT-HAPPY-02/zh-CN", + "FT-HAPPY-02", + "from-text/happy.json", + NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_PROPOSE_URI, + "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", + inputData, + new LlmScript(null, List.of(new LlmScriptStep.Payload(PROPOSE_PAYLOAD))), + null, + null, + null, + expect); + } + + private NegotiationCase validateProposeCase(Expectation expect, String semanticPayload) { + return new NegotiationCase( + "VAL-HAPPY-01/zh-CN", + "VAL-HAPPY-01", + "validate/happy.json", + NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_PROPOSE_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), + new PromptSource.Golden("information_propose"), + schema, + null, + expect); + } + + private static Expectation.Metadata metadata() { + return new Expectation.Metadata(INFORMATION_ACCEPT_REJECT_URI, Boolean.TRUE); + } + + // ------------------------------------------------------------------ task family helpers + + private static Expectation okTask( + @Nullable Integer llmCalls, + @Nullable List promptTextContains, + @Nullable List missingParams, + Map params) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + null, + null, + params, + List.of(), + false, + promptTextContains == null ? List.of() : promptTextContains, + missingParams, + null); + } + + private NegotiationCase taskFromTextCase(Expectation expect) { + return new NegotiationCase( + "TASK-FT-01/zh-CN", + "TASK-FT-01", + "task/from-text.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + COMPLAINT_TEXT, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_OBJECT_PORTLESS))), + null, + null, + null, + expect); + } + + private NegotiationCase taskFromDataCase(Expectation expect) throws Exception { + return new NegotiationCase( + "TASK-FD-01/zh-CN", + "TASK-FD-01", + "task/from-data.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA, + ZH_CN, + "P0", + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + null, + mapper.readTree("{\"portName\": \"P533-珠江旧城-PTN3900-23-TPA1EG24-1\", \"complaintScenario\": \"专线质差\"," + + " \"faultStartTime\": \"2026-05-11T08:21:46Z\", \"ticketNo\":" + + " \"event-id-20260511-09013\", \"faultDetailText\": \"深圳访问广州的响应时延从平均12ms" + + "骤升至320ms\"}"), + new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_FILLED))), + null, + mapper.readTree("{\"type\": \"object\", \"properties\": {\"portName\": {\"type\": \"string\"}," + + " \"complaintScenario\": {\"type\": \"string\"}, \"faultStartTime\":" + + " {\"type\": \"string\"}, \"ticketNo\": {\"type\": \"string\"}," + + " \"faultDetailText\": {\"type\": \"string\"}}}"), + null, + expect); + } + + private NegotiationCase taskValidateCase(Expectation expect, String semanticPayload) throws Exception { + return new NegotiationCase( + "TASK-VAL-01/zh-CN", + "TASK-VAL-01", + "task/validate.json", + NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, + ZH_CN, + "P0", + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), + new PromptSource.Text(TASK_PROMPT_MISSING_PARAMS), + mapper.readTree(TASK_PARAM_SCHEMA), + null, + expect); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java index 2d1de1ba..8b95d83f 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java @@ -1,565 +1,576 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.Test; -import org.jspecify.annotations.Nullable; - -/** - * Meta test of the negotiation test corpus (design document §6 Q6 and §7): the corpus is checked against its own - * contracts instead of the production code, so a hole in the corpus is caught before it silently narrows the suites. - * - *

        Checked today, in full strictness: global id uniqueness, expectation-block completeness, every expected error code - * being one of the seven negotiation error codes, the seven-code failure coverage, the five mapCode mapping targets, the - * four operational definitions of the bilingual parity (§7), and the per-record uniform language expansion. The - * business-alignment decisions add three more (design §10): the three closed-loop task APIs are exercised by scenario - * steps only and are role-bound (A=workbench generates, B=OMC validates), and every VAL-DRIFT drift probe is a - * compliant peer text expecting success. The Q19 business-review soft gate (§8.7) is opt-in: P0 cases must be 100% - * approved in review-status.json when {@code -Dcorpus.review.gate=true} is set and the status file exists, and is - * skipped otherwise. - * - *

        The full corpus has landed (design §8.4): a missing error-code or mapCode dimension is a hard failure, not a TODO - * line — a hole in the corpus silently narrows the suites, so it must block the build. - * - * @since 2026-08 - */ -class CorpusContractTest { - - /** - * Full strictness (design Q6/§8.4): every one of the seven negotiation error codes must be covered by at least one - * failure case of the corpus; a missing code fails the build. - */ - private static final boolean REQUIRE_FULL_ERROR_CODE_COVERAGE = true; - - /** - * Full strictness (design Q6/§8.4): every one of the five mapCode mapping targets must be covered by at least one - * validate-family failure case (the VAL-MAP batch). The unknown-code passthrough (default branch) cannot be - * expressed through the public surface and stays a hand-written-suite concern (ParamExtractorTest). - */ - private static final boolean REQUIRE_FULL_MAP_CODE_COVERAGE = true; - - /** The seven negotiation error codes of the production exception surface (design §7: 7, not 10). */ - private static final List NEGOTIATION_ERROR_CODES = List.of( - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, - A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, - A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, - A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, - A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, - A2ATErrorCodes.TEMPLATE_NOT_FOUND); - - /** - * The five {@code ParamExtractor.mapCode()} mappings of the production code: internal {@code validation_*} codes to - * the public {@code negotiation_*} codes. Corpus cases carry the public code, so a mapping is covered when a - * validate-family failure case expects its target code. The unknown-code passthrough (default branch) cannot be - * expressed through the public surface and stays a hand-written-suite concern (ParamExtractorTest). - */ - private static final Map MAP_CODE_TARGETS = Map.of( - A2ATErrorCodes.VALIDATION_INVALID_INPUT, - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, - A2ATErrorCodes.VALIDATION_RULE_VIOLATION, - A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, - A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED, - A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, - A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, - A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, - A2ATErrorCodes.TEMPLATE_NOT_FOUND); - - private static final LoadedCorpus CORPUS = CorpusSuites.loadCorpus(); - - // ------------------------------------------------------------------ ids and expectation blocks - - @Test - void idsAreGloballyUniqueAcrossCasesAndScenarios() { - Set expandedIds = new LinkedHashSet<>(); - int expectedCount = 0; - for (NegotiationCase testCase : allCaseRecords()) { - assertTrue(expandedIds.add(testCase.id()), "duplicate expanded id " + testCase.id()); - expectedCount++; - } - for (ScenarioCase scenario : CORPUS.scenarios()) { - assertTrue(expandedIds.add(scenario.id()), "duplicate expanded id " + scenario.id()); - expectedCount++; - } - for (LiveCase liveCase : CORPUS.liveCases()) { - assertTrue(expandedIds.add(liveCase.id()), "duplicate expanded id " + liveCase.id()); - expectedCount++; - } - assertEquals(expectedCount, expandedIds.size(), "the id is the primary key of the whole corpus"); - - // One base id belongs to exactly one record: all its language expansions come from the same file, and every - // expansion carries a distinct language (the loader already fails fast on duplicates at parse time; this - // re-asserts the invariant on the loaded corpus). Scenario step cases are derived records of a scenario and are - // covered by the scenario check below. - Map> caseGroups = new LinkedHashMap<>(); - for (NegotiationCase testCase : allCaseRecords()) { - if (testCase.id().contains("#step-")) { - continue; - } - caseGroups.computeIfAbsent(testCase.baseId(), key -> new ArrayList<>()).add(testCase); - } - for (List group : caseGroups.values()) { - Set languages = new LinkedHashSet<>(); - String sourceFile = group.get(0).sourceFile(); - for (NegotiationCase expansion : group) { - assertEquals( - sourceFile, - expansion.sourceFile(), - "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); - assertTrue( - languages.add(expansion.language()), - "duplicate language expansion " + expansion.id()); - } - } - Map> scenarioGroups = new LinkedHashMap<>(); - for (ScenarioCase scenario : CORPUS.scenarios()) { - scenarioGroups.computeIfAbsent(scenario.baseId(), key -> new ArrayList<>()).add(scenario); - } - for (List group : scenarioGroups.values()) { - Set languages = new LinkedHashSet<>(); - String sourceFile = group.get(0).sourceFile(); - for (ScenarioCase expansion : group) { - assertEquals( - sourceFile, - expansion.sourceFile(), - "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); - assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); - } - } - Map> liveGroups = new LinkedHashMap<>(); - for (LiveCase liveCase : CORPUS.liveCases()) { - liveGroups.computeIfAbsent(liveCase.baseId(), key -> new ArrayList<>()).add(liveCase); - } - for (List group : liveGroups.values()) { - Set languages = new LinkedHashSet<>(); - String sourceFile = group.get(0).sourceFile(); - for (LiveCase expansion : group) { - assertEquals( - sourceFile, - expansion.sourceFile(), - "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); - assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); - } - } - } - - @Test - void expectationBlocksAreComplete() { - for (NegotiationCase testCase : allCaseRecords()) { - Expectation expect = testCase.expect(); - if (expect.success()) { - assertTrue( - expect.exception() == null && expect.code() == null, - testCase.errorPrefix() + ": a success expectation must not carry an exception or an error code"); - } else { - assertTrue( - expect.exception() != null || expect.code() != null, - testCase.errorPrefix() - + ": a failure expectation must name the expected exception or the expected error code"); - } - } - } - - @Test - void everyExpectedErrorCodeIsAKnownNegotiationCode() { - for (NegotiationCase testCase : allCaseRecords()) { - String code = testCase.expect().code(); - if (code != null) { - assertTrue( - NEGOTIATION_ERROR_CODES.contains(code), - testCase.errorPrefix() + ": the expected code '" + code - + "' is not one of the seven negotiation error codes"); - } - } - } - - // ------------------------------------------------------------------ error-code coverage (7 codes) - - @Test - void everyNegotiationErrorCodeIsCoveredByAFailureCase() { - Set covered = new LinkedHashSet<>(); - for (NegotiationCase testCase : allCaseRecords()) { - if (!testCase.expect().success() && testCase.expect().code() != null) { - covered.add(testCase.expect().code()); - } - } - List missing = new ArrayList<>(); - for (String code : NEGOTIATION_ERROR_CODES) { - if (covered.contains(code)) { - continue; - } - if (REQUIRE_FULL_ERROR_CODE_COVERAGE) { - fail("no failure case of the corpus expects the error code '" + code + "' yet"); - } - missing.add(code); - } - if (!missing.isEmpty()) { - System.out.println("error codes not yet covered by any failure case: " + missing); - } - } - - // ------------------------------------------------------------------ mapCode coverage (5 mappings) - - @Test - void everyMapCodeTargetIsCoveredByAValidateFailureCase() { - Set covered = new LinkedHashSet<>(); - for (NegotiationCase testCase : allCaseRecords()) { - if (testCase.api().family() == NegotiationApi.Family.VALIDATE - && !testCase.expect().success() - && testCase.expect().code() != null) { - covered.add(testCase.expect().code()); - } - } - List missing = new ArrayList<>(); - for (Map.Entry mapping : MAP_CODE_TARGETS.entrySet()) { - if (covered.contains(mapping.getValue())) { - continue; - } - if (REQUIRE_FULL_MAP_CODE_COVERAGE) { - fail( - "no validate-family failure case exercises the mapCode mapping " + mapping.getKey() + " -> " - + mapping.getValue() + " yet"); - } - missing.add(mapping.getKey() + " -> " + mapping.getValue()); - } - if (!missing.isEmpty()) { - System.out.println("mapCode mappings not yet covered by any validate failure case: " + missing); - } - } - - // ------------------------------------------------------------------ closed-loop task APIs (Q20-Q23) - - /** - * Q21 full strictness: every one of the three closed-loop task APIs must be exercised by at least one scenario - * step, and the task APIs must never appear as standalone case-file records — the closed loop (task prompt - * generation, peer validation, then negotiation) only has a business meaning inside a scenario, so a standalone - * task case would bypass the 缺参 → 协商 → 补参 → 提取 causal chain the corpus exists to model. - */ - @Test - void everyTaskApiIsExercisedByScenarioStepsOnly() { - List taskApis = new ArrayList<>(); - for (ScenarioCase scenario : CORPUS.scenarios()) { - for (ScenarioCase.ScenarioStep step : scenario.steps()) { - NegotiationApi api = step.caseData().api(); - if (api.family() == NegotiationApi.Family.TASK) { - taskApis.add(api); - } - } - } - for (NegotiationApi api : NegotiationApi.values()) { - if (api.family() != NegotiationApi.Family.TASK) { - continue; - } - assertTrue( - taskApis.contains(api), - "no scenario step exercises the closed-loop task API '" + api.jsonName() + "' yet"); - } - for (NegotiationCase testCase : CORPUS.cases()) { - assertTrue( - testCase.api().family() != NegotiationApi.Family.TASK, - testCase.errorPrefix() - + ": a task API must not appear as a standalone case record, only as a scenario step" - + " (the closed-loop causal chain lives in scenarios)"); - } - } - - /** - * Q21/Q23 role semantics: the workbench (A) generates the task prompt and the OMC (B) validates the peer message, - * so every task-generation step must be acted by role A and every task-validation step by role B (the dual-session - * scenario numbers its roles A1/A2/B1/B2 — the letter prefix decides). - */ - @Test - void taskStepsAreBoundToTheirClosedLoopRoles() { - for (ScenarioCase scenario : CORPUS.scenarios()) { - for (ScenarioCase.ScenarioStep step : scenario.steps()) { - NegotiationApi api = step.caseData().api(); - if (api.family() != NegotiationApi.Family.TASK) { - continue; - } - String role = step.role() == null ? "" : step.role(); - String expectedSide = api.jsonName().startsWith("generate") ? "A" : "B"; - assertTrue( - role.startsWith(expectedSide), - scenario.id() + " step " + step.step() + " (" + api.jsonName() - + ") must be acted by the " + (expectedSide.equals("A") ? "workbench (A)" : "OMC (B)") - + " side, but the role is '" + step.role() + "'"); - } - } - } - - // ------------------------------------------------------------------ drift probes (Q22) - - /** - * Q22 full strictness: a drift probe is a compliant-but-reworded peer message, so it must (a) live in the validate - * family, (b) expect success — a drift probe that fails would reward exact-wording coupling instead of semantic - * compliance — and (c) take the peer message as inline text, never a golden fixture (golden is the regression - * equality anchor, not the validate mainline input). - */ - @Test - void driftProbesAreCompliantPeerTextExpectingSuccess() { - boolean seen = false; - for (NegotiationCase testCase : CORPUS.cases()) { - if (!testCase.baseId().startsWith("VAL-DRIFT-")) { - continue; - } - seen = true; - assertTrue( - testCase.api().family() == NegotiationApi.Family.VALIDATE, - testCase.errorPrefix() + ": a drift probe must be a validate-family case"); - assertTrue( - testCase.expect().success(), - testCase.errorPrefix() + ": a compliant-but-reworded peer message must pass validation"); - assertTrue( - testCase.prompt() instanceof PromptSource.Text, - testCase.errorPrefix() - + ": a drift probe must carry the peer message as inline text (prompt.text)," - + " not a golden fixture or a fromStep reference"); - } - assertTrue(seen, "the corpus must carry at least one VAL-DRIFT drift probe"); - } - - // ------------------------------------------------------------------ live family (live design §2.2) - - /** - * The live records live in their own {@code liveCases} list and are invisible to the six offline contracts above; - * this contract pins their phase-1 scope (Q5/Q6): the {@code LIVE-} id prefix, zh-CN only, and the two task APIs — - * a record outside the scope could never run through the live engine's dispatch. - */ - @Test - void liveRecordsStayInThePhase1Scope() { - assertFalse(CORPUS.liveCases().isEmpty(), "the corpus must carry at least one live record"); - for (LiveCase liveCase : CORPUS.liveCases()) { - assertTrue( - liveCase.baseId().startsWith("LIVE-"), - liveCase.errorPrefix() + ": the live family ids carry the 'LIVE-' prefix"); - assertEquals( - "zh-CN", - liveCase.language(), - liveCase.errorPrefix() + ": live phase 1 covers zh-CN only (Q6)"); - assertTrue( - liveCase.api() == NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT - || liveCase.api() == NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - liveCase.errorPrefix() + ": live phase 1 covers the two task APIs (Q5) but the record declares " - + liveCase.api().jsonName()); - } - } - - /** - * Live expectation completeness: the loose block still has to pin something checkable — {@code paramsContains} - * values non-null (a null expected value would never match, the engine asserts non-null extraction), non-blank - * {@code paramsAbsent} slots and {@code promptTextContains} fragments, and a positive {@code maxLlmCalls} bound. - */ - @Test - void liveExpectationBlocksAreComplete() { - for (LiveCase liveCase : CORPUS.liveCases()) { - LiveExpectation expect = liveCase.liveExpect(); - for (Map.Entry entry : expect.paramsContains().entrySet()) { - assertTrue( - entry.getValue() != null, - liveCase.errorPrefix() + ": paramsContains." + entry.getKey() - + " must pin a non-null value"); - } - for (String slot : expect.paramsAbsent()) { - assertFalse(slot.isBlank(), liveCase.errorPrefix() + ": paramsAbsent entries must not be blank"); - } - for (String fragment : expect.promptTextContains()) { - assertFalse( - fragment.isBlank(), liveCase.errorPrefix() + ": promptTextContains fragments must not be blank"); - } - if (expect.maxLlmCalls() != null) { - assertTrue( - expect.maxLlmCalls() > 0, - liveCase.errorPrefix() + ": maxLlmCalls must be a positive upper bound"); - } - } - } - - // ------------------------------------------------------------------ bilingual parity (§7, four definitions) - - /** Parity ①: every happy case of the case files declares both languages, so both expansions run and must succeed. */ - @Test - void happyCasesDeclareBothLanguages() { - for (List group : caseFileGroupsByBaseId().values()) { - if (!group.get(0).expect().success()) { - continue; - } - Set languages = languagesOf(group); - assertEquals( - Set.of("zh-CN", "en-US"), - languages, - group.get(0).errorPrefix() - + ": a happy case must run in both languages (the suite executes every expansion)"); - } - } - - /** Parity ②: all language expansions of one record share the identical expectation block, so a failure has one code. */ - @Test - void failureCasesShareTheIdenticalExpectationAcrossLanguages() { - for (List group : caseFileGroupsByBaseId().values()) { - Expectation reference = group.get(0).expect(); - for (NegotiationCase expansion : group) { - assertEquals( - reference, - expansion.expect(), - expansion.errorPrefix() - + ": every language expansion of one record must carry the identical expectation" - + " block, so a failure case fails with the same error code in both languages"); - } - } - } - - /** Parity ③: the golden fixture directories of the two languages carry the same number of fixtures. */ - @Test - void goldenFixtureCountsMatchAcrossLanguages() { - assertEquals( - countGoldenFixtures("zh-CN"), - countGoldenFixtures("en-US"), - "the golden fixtures must exist in equal numbers for zh-CN and en-US"); - } - - /** Parity ④: every record expands exactly once per declared language — no language is silently dropped or doubled. */ - @Test - void recordsExpandExactlyOncePerDeclaredLanguage() { - for (List group : caseFileGroupsByBaseId().values()) { - Set languages = languagesOf(group); - assertEquals( - languages.size(), - group.size(), - group.get(0).errorPrefix() + ": every declared language must expand into exactly one case"); - } - Map> scenarioGroups = new LinkedHashMap<>(); - for (ScenarioCase scenario : CORPUS.scenarios()) { - scenarioGroups.computeIfAbsent(scenario.baseId(), key -> new ArrayList<>()).add(scenario); - } - for (List group : scenarioGroups.values()) { - Set languages = new LinkedHashSet<>(); - for (ScenarioCase scenario : group) { - assertTrue(languages.add(scenario.language()), "duplicate language expansion of " + scenario.id()); - } - assertEquals(languages.size(), group.size(), "every declared language must expand into exactly one scenario"); - } - } - - // ------------------------------------------------------------------ business review soft gate (Q19, §8.7) - - /** - * Q19 soft gate (design §8.7): when enabled, every P0 case of the corpus must carry the review status 通过 in the - * {@code docs-local/review/review-status.json} produced by {@code tools/corpus_review.py collect} — the P0 - * review-approval rate must be 100% before a release. - * - *

        The gate is strictly opt-in so it never blocks daily development: it runs only when the system property - * {@code corpus.review.gate} is set to {@code true} AND the status file exists; otherwise the test is skipped via - * {@link Assumptions#assumeTrue(boolean, String)}. Scenario records carry no priority and are therefore out of the - * P0 gate's scope (they stay tracked through the review dashboard itself). - */ - @Test - void p0CasesAreFullyApprovedByTheBusinessReview() throws IOException { - Assumptions.assumeTrue( - Boolean.getBoolean("corpus.review.gate"), - "the business review gate is opt-in (enable with -Dcorpus.review.gate=true)"); - Path statusFile = findReviewStatusFile(); - Assumptions.assumeTrue( - statusFile != null, - "docs-local/review/review-status.json not found (run tools/corpus_review.py collect to produce it)"); - - JsonNode reviewedCases = new ObjectMapper().readTree(Files.readString(statusFile)).path("cases"); - List notApproved = new ArrayList<>(); - for (List group : caseFileGroupsByBaseId().values()) { - if (!"P0".equals(group.get(0).priority())) { - continue; - } - String baseId = group.get(0).baseId(); - String status = reviewedCases.path(baseId).path("status").asText(""); - if (!APPROVED_STATUS.equals(status)) { - notApproved.add(baseId + " (status: '" + (status.isEmpty() ? "unreviewed" : status) + "')"); - } - } - assertTrue( - notApproved.isEmpty(), - "the P0 review-approval rate must be 100% before a release, not approved yet: " + notApproved); - } - - /** The single review status that counts as approved ({@code tools/corpus_review.py}: 通过 / 有疑问 / 否决). */ - private static final String APPROVED_STATUS = "通过"; - - /** Locates {@code docs-local/review/review-status.json} by walking up from the working directory to the repo root. */ - private static @Nullable Path findReviewStatusFile() { - Path directory = Path.of(".").toAbsolutePath(); - while (directory != null) { - Path candidate = directory.resolve("docs-local").resolve("review").resolve("review-status.json"); - if (Files.isRegularFile(candidate)) { - return candidate; - } - directory = directory.getParent(); - } - return null; - } - - // ------------------------------------------------------------------ helpers - - /** All case records of the corpus: the case-file records plus the scenario step cases. */ - private static List allCaseRecords() { - List records = new ArrayList<>(CORPUS.cases()); - for (ScenarioCase scenario : CORPUS.scenarios()) { - for (ScenarioCase.ScenarioStep step : scenario.steps()) { - records.add(step.caseData()); - } - } - return records; - } - - /** Case-file records grouped by base id (the loader puts scenario step cases into the scenarios, not the cases). */ - private static Map> caseFileGroupsByBaseId() { - Map> groups = new LinkedHashMap<>(); - for (NegotiationCase testCase : CORPUS.cases()) { - groups.computeIfAbsent(testCase.baseId(), key -> new ArrayList<>()).add(testCase); - } - assertFalse(groups.isEmpty(), "the corpus must carry at least one case-file record"); - return groups; - } - - private static Set languagesOf(List group) { - Set languages = new LinkedHashSet<>(); - for (NegotiationCase expansion : group) { - assertTrue( - languages.add(expansion.language()), - "duplicate language expansion of " + expansion.errorPrefix()); - } - return languages; - } - - private static int countGoldenFixtures(String language) { - URL url = CorpusContractTest.class.getClassLoader().getResource("golden/" + language); - if (url == null) { - return 0; - } - try { - Path directory = Path.of(url.toURI()); - try (Stream files = Files.list(directory)) { - return (int) files.filter(file -> file.getFileName().toString().endsWith(".md")).count(); - } - } catch (URISyntaxException | IOException exception) { - throw new IllegalStateException("Failed to list the golden fixtures of " + language, exception); - } - } -} +package net.openan.a2at.sdk.corpus; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +/** + * Meta test of the negotiation test corpus (design document §6 Q6 and §7): the corpus is checked against its own + * contracts instead of the production code, so a hole in the corpus is caught before it silently narrows the suites. + * + *

        Checked today, in full strictness: global id uniqueness, expectation-block completeness, every expected error code + * being one of the content-layer error codes, the content-layer failure coverage, the validate-family code coverage, + * the four operational definitions of the bilingual parity (§7), and the per-record uniform language expansion. The + * business-alignment decisions add three more (design §10): the three closed-loop task APIs are exercised by scenario + * steps only and are role-bound (A=workbench generates, B=OMC validates), and every VAL-DRIFT drift probe is a + * compliant peer text expecting success. The Q19 business-review soft gate (§8.7) is opt-in: P0 cases must be 100% + * approved in review-status.json when {@code -Dcorpus.review.gate=true} is set and the status file exists, and is + * skipped otherwise. + * + *

        The full corpus has landed (design §8.4): a missing error-code or mapCode dimension is a hard failure, not a TODO + * line — a hole in the corpus silently narrows the suites, so it must block the build. + * + * @since 2026-08 + */ +class CorpusContractTest { + + /** + * Full strictness (design Q6/§8.4): every one of the content-layer error codes must be covered by at least one + * failure case of the corpus; a missing code fails the build. + */ + private static final boolean REQUIRE_FULL_ERROR_CODE_COVERAGE = true; + + /** + * Full strictness (design Q6/§8.4): every code the validate family can fail with must be covered by at least one + * validate-family failure case (the VAL-MAP/VAL-RULE/VAL-RETRY batches). The unknown-code fallback of the LLM + * response parsing (negotiation.rule_violation / content.rule_violation) cannot be expressed through the public + * exception surface and stays a hand-written-suite concern (ParamExtractorTest). + */ + private static final boolean REQUIRE_FULL_VALIDATE_CODE_COVERAGE = true; + + /** The content-layer error codes of the production exception surface (ErrorCatalog codes). */ + private static final List CONTENT_LAYER_ERROR_CODES = List.of( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), + ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); + + /** + * The codes the validate family fails with: the pipeline surfaces these directly (there is no code mapping layer + * since the ErrorCatalog migration), so a validate-family failure case must expect each of them. + */ + private static final List VALIDATE_FAMILY_ERROR_CODES = List.of( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); + + private static final LoadedCorpus CORPUS = CorpusSuites.loadCorpus(); + + // ------------------------------------------------------------------ ids and expectation blocks + + @Test + void idsAreGloballyUniqueAcrossCasesAndScenarios() { + Set expandedIds = new LinkedHashSet<>(); + int expectedCount = 0; + for (NegotiationCase testCase : allCaseRecords()) { + assertTrue(expandedIds.add(testCase.id()), "duplicate expanded id " + testCase.id()); + expectedCount++; + } + for (ScenarioCase scenario : CORPUS.scenarios()) { + assertTrue(expandedIds.add(scenario.id()), "duplicate expanded id " + scenario.id()); + expectedCount++; + } + for (LiveCase liveCase : CORPUS.liveCases()) { + assertTrue(expandedIds.add(liveCase.id()), "duplicate expanded id " + liveCase.id()); + expectedCount++; + } + assertEquals(expectedCount, expandedIds.size(), "the id is the primary key of the whole corpus"); + + // One base id belongs to exactly one record: all its language expansions come from the same file, and every + // expansion carries a distinct language (the loader already fails fast on duplicates at parse time; this + // re-asserts the invariant on the loaded corpus). Scenario step cases are derived records of a scenario and are + // covered by the scenario check below. + Map> caseGroups = new LinkedHashMap<>(); + for (NegotiationCase testCase : allCaseRecords()) { + if (testCase.id().contains("#step-")) { + continue; + } + caseGroups + .computeIfAbsent(testCase.baseId(), key -> new ArrayList<>()) + .add(testCase); + } + for (List group : caseGroups.values()) { + Set languages = new LinkedHashSet<>(); + String sourceFile = group.get(0).sourceFile(); + for (NegotiationCase expansion : group) { + assertEquals( + sourceFile, + expansion.sourceFile(), + "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); + assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); + } + } + Map> scenarioGroups = new LinkedHashMap<>(); + for (ScenarioCase scenario : CORPUS.scenarios()) { + scenarioGroups + .computeIfAbsent(scenario.baseId(), key -> new ArrayList<>()) + .add(scenario); + } + for (List group : scenarioGroups.values()) { + Set languages = new LinkedHashSet<>(); + String sourceFile = group.get(0).sourceFile(); + for (ScenarioCase expansion : group) { + assertEquals( + sourceFile, + expansion.sourceFile(), + "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); + assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); + } + } + Map> liveGroups = new LinkedHashMap<>(); + for (LiveCase liveCase : CORPUS.liveCases()) { + liveGroups + .computeIfAbsent(liveCase.baseId(), key -> new ArrayList<>()) + .add(liveCase); + } + for (List group : liveGroups.values()) { + Set languages = new LinkedHashSet<>(); + String sourceFile = group.get(0).sourceFile(); + for (LiveCase expansion : group) { + assertEquals( + sourceFile, + expansion.sourceFile(), + "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); + assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); + } + } + } + + @Test + void expectationBlocksAreComplete() { + for (NegotiationCase testCase : allCaseRecords()) { + Expectation expect = testCase.expect(); + if (expect.success()) { + assertTrue( + expect.exception() == null && expect.code() == null, + testCase.errorPrefix() + + ": a success expectation must not carry an exception or an error code"); + } else { + assertTrue( + expect.exception() != null || expect.code() != null, + testCase.errorPrefix() + + ": a failure expectation must name the expected exception or the expected error code"); + } + } + } + + @Test + void everyExpectedErrorCodeIsAKnownNegotiationCode() { + for (NegotiationCase testCase : allCaseRecords()) { + String code = testCase.expect().code(); + if (code != null) { + assertTrue( + CONTENT_LAYER_ERROR_CODES.contains(code), + testCase.errorPrefix() + ": the expected code '" + code + + "' is not one of the content-layer error codes"); + } + } + } + + // ------------------------------------------------------------------ error-code coverage + + @Test + void everyNegotiationErrorCodeIsCoveredByAFailureCase() { + Set covered = new LinkedHashSet<>(); + for (NegotiationCase testCase : allCaseRecords()) { + if (!testCase.expect().success() && testCase.expect().code() != null) { + covered.add(testCase.expect().code()); + } + } + List missing = new ArrayList<>(); + for (String code : CONTENT_LAYER_ERROR_CODES) { + if (covered.contains(code)) { + continue; + } + if (REQUIRE_FULL_ERROR_CODE_COVERAGE) { + fail("no failure case of the corpus expects the error code '" + code + "' yet"); + } + missing.add(code); + } + if (!missing.isEmpty()) { + System.out.println("error codes not yet covered by any failure case: " + missing); + } + } + + // ------------------------------------------------------------------ validate-family code coverage + + @Test + void everyValidateFamilyCodeIsCoveredByAValidateFailureCase() { + Set covered = new LinkedHashSet<>(); + for (NegotiationCase testCase : allCaseRecords()) { + if (testCase.api().family() == NegotiationApi.Family.VALIDATE + && !testCase.expect().success() + && testCase.expect().code() != null) { + covered.add(testCase.expect().code()); + } + } + List missing = new ArrayList<>(); + for (String code : VALIDATE_FAMILY_ERROR_CODES) { + if (covered.contains(code)) { + continue; + } + if (REQUIRE_FULL_VALIDATE_CODE_COVERAGE) { + fail("no validate-family failure case expects the error code '" + code + "' yet"); + } + missing.add(code); + } + if (!missing.isEmpty()) { + System.out.println("validate-family codes not yet covered by any validate failure case: " + missing); + } + } + + // ------------------------------------------------------------------ closed-loop task APIs (Q20-Q23) + + /** + * Q21 full strictness: every one of the three closed-loop task APIs must be exercised by at least one scenario + * step, and the task APIs must never appear as standalone case-file records — the closed loop (task prompt + * generation, peer validation, then negotiation) only has a business meaning inside a scenario, so a standalone + * task case would bypass the 缺参 → 协商 → 补参 → 提取 causal chain the corpus exists to model. + */ + @Test + void everyTaskApiIsExercisedByScenarioStepsOnly() { + List taskApis = new ArrayList<>(); + for (ScenarioCase scenario : CORPUS.scenarios()) { + for (ScenarioCase.ScenarioStep step : scenario.steps()) { + NegotiationApi api = step.caseData().api(); + if (api.family() == NegotiationApi.Family.TASK) { + taskApis.add(api); + } + } + } + for (NegotiationApi api : NegotiationApi.values()) { + if (api.family() != NegotiationApi.Family.TASK) { + continue; + } + assertTrue( + taskApis.contains(api), + "no scenario step exercises the closed-loop task API '" + api.jsonName() + "' yet"); + } + for (NegotiationCase testCase : CORPUS.cases()) { + assertTrue( + testCase.api().family() != NegotiationApi.Family.TASK, + testCase.errorPrefix() + + ": a task API must not appear as a standalone case record, only as a scenario step" + + " (the closed-loop causal chain lives in scenarios)"); + } + } + + /** + * Q21/Q23 role semantics: the workbench (A) generates the task prompt and the OMC (B) validates the peer message, + * so every task-generation step must be acted by role A and every task-validation step by role B (the dual-session + * scenario numbers its roles A1/A2/B1/B2 — the letter prefix decides). + */ + @Test + void taskStepsAreBoundToTheirClosedLoopRoles() { + for (ScenarioCase scenario : CORPUS.scenarios()) { + for (ScenarioCase.ScenarioStep step : scenario.steps()) { + NegotiationApi api = step.caseData().api(); + if (api.family() != NegotiationApi.Family.TASK) { + continue; + } + String role = step.role() == null ? "" : step.role(); + String expectedSide = api.jsonName().startsWith("generate") ? "A" : "B"; + assertTrue( + role.startsWith(expectedSide), + scenario.id() + " step " + step.step() + " (" + api.jsonName() + + ") must be acted by the " + (expectedSide.equals("A") ? "workbench (A)" : "OMC (B)") + + " side, but the role is '" + step.role() + "'"); + } + } + } + + // ------------------------------------------------------------------ drift probes (Q22) + + /** + * Q22 full strictness: a drift probe is a compliant-but-reworded peer message, so it must (a) live in the validate + * family, (b) expect success — a drift probe that fails would reward exact-wording coupling instead of semantic + * compliance — and (c) take the peer message as inline text, never a golden fixture (golden is the regression + * equality anchor, not the validate mainline input). + */ + @Test + void driftProbesAreCompliantPeerTextExpectingSuccess() { + boolean seen = false; + for (NegotiationCase testCase : CORPUS.cases()) { + if (!testCase.baseId().startsWith("VAL-DRIFT-")) { + continue; + } + seen = true; + assertTrue( + testCase.api().family() == NegotiationApi.Family.VALIDATE, + testCase.errorPrefix() + ": a drift probe must be a validate-family case"); + assertTrue( + testCase.expect().success(), + testCase.errorPrefix() + ": a compliant-but-reworded peer message must pass validation"); + assertTrue( + testCase.prompt() instanceof PromptSource.Text, + testCase.errorPrefix() + + ": a drift probe must carry the peer message as inline text (prompt.text)," + + " not a golden fixture or a fromStep reference"); + } + assertTrue(seen, "the corpus must carry at least one VAL-DRIFT drift probe"); + } + + // ------------------------------------------------------------------ live family (live design §2.2) + + /** + * The live records live in their own {@code liveCases} list and are invisible to the six offline contracts above; + * this contract pins their phase-1 scope (Q5/Q6): the {@code LIVE-} id prefix, zh-CN only, and the two task APIs — + * a record outside the scope could never run through the live engine's dispatch. + */ + @Test + void liveRecordsStayInThePhase1Scope() { + assertFalse(CORPUS.liveCases().isEmpty(), "the corpus must carry at least one live record"); + for (LiveCase liveCase : CORPUS.liveCases()) { + assertTrue( + liveCase.baseId().startsWith("LIVE-"), + liveCase.errorPrefix() + ": the live family ids carry the 'LIVE-' prefix"); + assertEquals( + "zh-CN", liveCase.language(), liveCase.errorPrefix() + ": live phase 1 covers zh-CN only (Q6)"); + assertTrue( + liveCase.api() == NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT + || liveCase.api() == NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, + liveCase.errorPrefix() + ": live phase 1 covers the two task APIs (Q5) but the record declares " + + liveCase.api().jsonName()); + } + } + + /** + * Live expectation completeness: the loose block still has to pin something checkable — {@code paramsContains} + * values non-null (a null expected value would never match, the engine asserts non-null extraction), non-blank + * {@code paramsAbsent} slots and {@code promptTextContains} fragments, and a positive {@code maxLlmCalls} bound. + */ + @Test + void liveExpectationBlocksAreComplete() { + for (LiveCase liveCase : CORPUS.liveCases()) { + LiveExpectation expect = liveCase.liveExpect(); + for (Map.Entry entry : expect.paramsContains().entrySet()) { + assertTrue( + entry.getValue() != null, + liveCase.errorPrefix() + ": paramsContains." + entry.getKey() + " must pin a non-null value"); + } + for (String slot : expect.paramsAbsent()) { + assertFalse(slot.isBlank(), liveCase.errorPrefix() + ": paramsAbsent entries must not be blank"); + } + for (String fragment : expect.promptTextContains()) { + assertFalse( + fragment.isBlank(), + liveCase.errorPrefix() + ": promptTextContains fragments must not be blank"); + } + if (expect.maxLlmCalls() != null) { + assertTrue( + expect.maxLlmCalls() > 0, + liveCase.errorPrefix() + ": maxLlmCalls must be a positive upper bound"); + } + } + } + + // ------------------------------------------------------------------ bilingual parity (§7, four definitions) + + /** + * Parity ①: every happy case of the case files declares both languages, so both expansions run and must succeed. + */ + @Test + void happyCasesDeclareBothLanguages() { + for (List group : caseFileGroupsByBaseId().values()) { + if (!group.get(0).expect().success()) { + continue; + } + Set languages = languagesOf(group); + assertEquals( + Set.of("zh-CN", "en-US"), + languages, + group.get(0).errorPrefix() + + ": a happy case must run in both languages (the suite executes every expansion)"); + } + } + + /** + * Parity ②: all language expansions of one record share the identical expectation block, so a failure has one code. + */ + @Test + void failureCasesShareTheIdenticalExpectationAcrossLanguages() { + for (List group : caseFileGroupsByBaseId().values()) { + Expectation reference = group.get(0).expect(); + for (NegotiationCase expansion : group) { + assertEquals( + reference, + expansion.expect(), + expansion.errorPrefix() + + ": every language expansion of one record must carry the identical expectation" + + " block, so a failure case fails with the same error code in both languages"); + } + } + } + + /** Parity ③: the golden fixture directories of the two languages carry the same number of fixtures. */ + @Test + void goldenFixtureCountsMatchAcrossLanguages() { + assertEquals( + countGoldenFixtures("zh-CN"), + countGoldenFixtures("en-US"), + "the golden fixtures must exist in equal numbers for zh-CN and en-US"); + } + + /** + * Parity ④: every record expands exactly once per declared language — no language is silently dropped or doubled. + */ + @Test + void recordsExpandExactlyOncePerDeclaredLanguage() { + for (List group : caseFileGroupsByBaseId().values()) { + Set languages = languagesOf(group); + assertEquals( + languages.size(), + group.size(), + group.get(0).errorPrefix() + ": every declared language must expand into exactly one case"); + } + Map> scenarioGroups = new LinkedHashMap<>(); + for (ScenarioCase scenario : CORPUS.scenarios()) { + scenarioGroups + .computeIfAbsent(scenario.baseId(), key -> new ArrayList<>()) + .add(scenario); + } + for (List group : scenarioGroups.values()) { + Set languages = new LinkedHashSet<>(); + for (ScenarioCase scenario : group) { + assertTrue(languages.add(scenario.language()), "duplicate language expansion of " + scenario.id()); + } + assertEquals( + languages.size(), group.size(), "every declared language must expand into exactly one scenario"); + } + } + + // ------------------------------------------------------------------ business review soft gate (Q19, §8.7) + + /** + * Q19 soft gate (design §8.7): when enabled, every P0 case of the corpus must carry the review status 通过 in the + * {@code docs-local/review/review-status.json} produced by {@code tools/corpus_review.py collect} — the P0 + * review-approval rate must be 100% before a release. + * + *

        The gate is strictly opt-in so it never blocks daily development: it runs only when the system property + * {@code corpus.review.gate} is set to {@code true} AND the status file exists; otherwise the test is skipped via + * {@link Assumptions#assumeTrue(boolean, String)}. Scenario records carry no priority and are therefore out of the + * P0 gate's scope (they stay tracked through the review dashboard itself). + */ + @Test + void p0CasesAreFullyApprovedByTheBusinessReview() throws IOException { + Assumptions.assumeTrue( + Boolean.getBoolean("corpus.review.gate"), + "the business review gate is opt-in (enable with -Dcorpus.review.gate=true)"); + Path statusFile = findReviewStatusFile(); + Assumptions.assumeTrue( + statusFile != null, + "docs-local/review/review-status.json not found (run tools/corpus_review.py collect to produce it)"); + + JsonNode reviewedCases = + new ObjectMapper().readTree(Files.readString(statusFile)).path("cases"); + List notApproved = new ArrayList<>(); + for (List group : caseFileGroupsByBaseId().values()) { + if (!"P0".equals(group.get(0).priority())) { + continue; + } + String baseId = group.get(0).baseId(); + String status = reviewedCases.path(baseId).path("status").asText(""); + if (!APPROVED_STATUS.equals(status)) { + notApproved.add(baseId + " (status: '" + (status.isEmpty() ? "unreviewed" : status) + "')"); + } + } + assertTrue( + notApproved.isEmpty(), + "the P0 review-approval rate must be 100% before a release, not approved yet: " + notApproved); + } + + /** The single review status that counts as approved ({@code tools/corpus_review.py}: 通过 / 有疑问 / 否决). */ + private static final String APPROVED_STATUS = "通过"; + + /** + * Locates {@code docs-local/review/review-status.json} by walking up from the working directory to the repo root. + */ + private static @Nullable Path findReviewStatusFile() { + Path directory = Path.of(".").toAbsolutePath(); + while (directory != null) { + Path candidate = directory.resolve("docs-local").resolve("review").resolve("review-status.json"); + if (Files.isRegularFile(candidate)) { + return candidate; + } + directory = directory.getParent(); + } + return null; + } + + // ------------------------------------------------------------------ helpers + + /** All case records of the corpus: the case-file records plus the scenario step cases. */ + private static List allCaseRecords() { + List records = new ArrayList<>(CORPUS.cases()); + for (ScenarioCase scenario : CORPUS.scenarios()) { + for (ScenarioCase.ScenarioStep step : scenario.steps()) { + records.add(step.caseData()); + } + } + return records; + } + + /** Case-file records grouped by base id (the loader puts scenario step cases into the scenarios, not the cases). */ + private static Map> caseFileGroupsByBaseId() { + Map> groups = new LinkedHashMap<>(); + for (NegotiationCase testCase : CORPUS.cases()) { + groups.computeIfAbsent(testCase.baseId(), key -> new ArrayList<>()).add(testCase); + } + assertFalse(groups.isEmpty(), "the corpus must carry at least one case-file record"); + return groups; + } + + private static Set languagesOf(List group) { + Set languages = new LinkedHashSet<>(); + for (NegotiationCase expansion : group) { + assertTrue( + languages.add(expansion.language()), "duplicate language expansion of " + expansion.errorPrefix()); + } + return languages; + } + + private static int countGoldenFixtures(String language) { + URL url = CorpusContractTest.class.getClassLoader().getResource("golden/" + language); + if (url == null) { + return 0; + } + try { + Path directory = Path.of(url.toURI()); + try (Stream files = Files.list(directory)) { + return (int) files.filter(file -> file.getFileName().toString().endsWith(".md")) + .count(); + } + } catch (URISyntaxException | IOException exception) { + throw new IllegalStateException("Failed to list the golden fixtures of " + language, exception); + } + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java index f4fdee33..f2770c6f 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java @@ -1,475 +1,475 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; - -import java.util.List; -import java.util.Map; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.jspecify.annotations.Nullable; - -/** - * Meta-meta test of the corpus engine (design document §8.6, Q16): it guards against the deadliest failure mode of a - * data-driven suite — an always-green rubber-stamp engine whose bug lets every case pass silently. - * - *

        Every class of expectation assertion is proven sensitive on a minimal inline sample case: the sample runs green - * with its true expectation, then one flipped expectation value must make the {@link CaseEngine} red, with the case id - * and the JSON path of the flipped expectation in the failure message. The samples are built inline in Java and never - * touch the corpus files. - * - *

        The second guard, {@code engineMustNotCrashOnAnyCorpusCase}, walks the whole loaded corpus: the engine may report - * red (an AssertionError is the engine correctly rejecting a mismatch — that is what the suites turn into test - * failures), but it must never crash with an unexpected exception such as an NPE or a ClassCastException escaping the - * run. A crash is an engine bug; a red assertion is the engine working. - * - * @since 2026-08 - */ -class CorpusSensitivitySelfTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String ZH_CN = "zh-CN"; - - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; - - private static final String INFORMATION_ACCEPT_REJECT_URI = - "Negotiation-T/information-negotiation/accept-reject/v1"; - - private static final String PRIVATE_LINE_COMPLAINT_URI = - "Task-T/network-layer/private-line-complaint/v1"; - - /** The workbench raw complaint of the closed loop (样例步骤1): no port name and no complaint category. */ - private static final String COMPLAINT_TEXT = - "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报" - + "“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; - - /** - * Slot-extraction payload of the closed loop's step 1 since the task_object slot became required upstream: the - * task object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays - * portless. - */ - private static final String TASK_SLOTS_OBJECT_PORTLESS = - "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" - + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** Semantic payload of the closed loop's step 2: the port name and the complaint category are missing. */ - private static final String TASK_SEMANTIC_MISSING_PARAMS = - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," - + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; - - /** The rendered task prompt the OMC receives (样例步骤1, shortened): the negotiation-causing message. */ - private static final String TASK_PROMPT_MISSING_PARAMS = """ - ## 任务类型(Task Type) - 传输专线业务投诉诊断 - - ## 任务对象(Task Object) - 接入端口名称: - - ## 任务上下文(Task Context) - 1. 投诉分类: - 2. 问题发生时间: "2026-05-11T08:21:46Z" - 3. OSS侧事件流水号:"event-id-20260511-09013" - 4. 投诉详情:"深圳访问广州的响应时延从平均12ms骤升至320ms" - """; - - /** The task parameter schema of the closed loop (server-side keys, dictionary §10). */ - private static final String TASK_PARAM_SCHEMA = - "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" - + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" - + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; - - /** Extraction payload mapping to the typed content of the information accept golden fixture. */ - private static final String ACCEPT_PAYLOAD = - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" - + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; - - /** Extraction payload whose mapped content lacks every required slot (fails with negotiation_slot_missing). */ - private static final String SLOTS_MISSING_PAYLOAD = "{\"relationship\":null}"; - - /** Semantic verdict payload of a successful validation carrying one business parameter. */ - private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" - + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; - - /** Semantic verdict payload rejecting the message with two slot errors. */ - private static final String SEMANTIC_REJECT_PAYLOAD_TWO_ERRORS = - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" - + "\"accessPort\",\"code\":\"missing\",\"message\":\"The access port parameter is" - + " missing.\"},{\"slot_name\":\"latency_target\",\"code\":\"out_of_range\",\"message\":" - + "\"The latency target parameter is out of range.\"}],\"params\":{}}"; - - private final CaseEngine engine = new CaseEngine(); - - private final ObjectMapper mapper = new ObjectMapper(); - - private JsonNode schema; - - @BeforeEach - void readSchema() throws Exception { - schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); - } - - // ------------------------------------------------------------------ flipped expectations must be red - - @Test - void flippedErrorCodeMustFailTheEngine() { - NegotiationCase trueExpectation = fromTextCase( - "FT-SENS-CODE", - SLOTS_MISSING_PAYLOAD, - failed("negotiation_slot_missing", 1, null, null)); - engine.run(trueExpectation); - - NegotiationCase flipped = fromTextCase( - "FT-SENS-CODE-FLIP", - SLOTS_MISSING_PAYLOAD, - failed("negotiation_invalid_input", 1, null, null)); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); - - assertTrue( - failure.getMessage().contains("FT-SENS-CODE-FLIP") && failure.getMessage().contains("$.expect.code"), - "a swapped error code must fail with the case id and the JSON path but was: " + failure.getMessage()); - } - - @Test - void flippedLlmCallCountMustFailTheEngine() { - engine.run(fromTextCase("FT-SENS-CALLS", ACCEPT_PAYLOAD, ok(1, "information_accept"))); - - AssertionError tooHigh = assertThrows( - AssertionError.class, - () -> engine.run(fromTextCase("FT-SENS-CALLS-HIGH", ACCEPT_PAYLOAD, ok(2, "information_accept")))); - assertTrue( - tooHigh.getMessage().contains("FT-SENS-CALLS-HIGH") - && tooHigh.getMessage().contains("$.expect.llmCalls"), - "llmCalls + 1 must fail but was: " + tooHigh.getMessage()); - - AssertionError tooLow = assertThrows( - AssertionError.class, - () -> engine.run(fromTextCase("FT-SENS-CALLS-LOW", ACCEPT_PAYLOAD, ok(0, "information_accept")))); - assertTrue( - tooLow.getMessage().contains("FT-SENS-CALLS-LOW") && tooLow.getMessage().contains("$.expect.llmCalls"), - "llmCalls - 1 must fail but was: " + tooLow.getMessage()); - } - - @Test - void flippedGoldenNameMustFailTheEngine() { - engine.run(fromTextCase("FT-SENS-GOLDEN", ACCEPT_PAYLOAD, ok(1, "information_accept"))); - - NegotiationCase flipped = - fromTextCase("FT-SENS-GOLDEN-FLIP", ACCEPT_PAYLOAD, ok(1, "information_reject")); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); - - assertTrue( - failure.getMessage().contains("FT-SENS-GOLDEN-FLIP") - && failure.getMessage().contains("$.expect.promptTextEqualsGolden"), - "a swapped golden fixture must fail but was: " + failure.getMessage()); - } - - @Test - void tamperedParamsMustFailTheEngine() { - engine.run(validateCase( - "VAL-SENS-PARAMS", Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P533-珠江旧城-PTN3900-23-TPA1EG24-1"))); - - NegotiationCase tampered = validateCase( - "VAL-SENS-PARAMS-FLIP", - Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P781-珠江新城-PTN7900-23-TPA1EG24-17")); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(tampered)); - - assertTrue( - failure.getMessage().contains("VAL-SENS-PARAMS-FLIP") - && failure.getMessage().contains("$.expect.params") - && failure.getMessage().contains("P781-珠江新城-PTN7900-23-TPA1EG24-17"), - "a tampered params value must fail but was: " + failure.getMessage()); - } - - @Test - void flippedOutcomeMustFailTheEngine() { - NegotiationCase successReadAsFailure = fromTextCase( - "FT-SENS-OUTCOME-SF", - ACCEPT_PAYLOAD, - failed("negotiation_invalid_input", 1, null, null)); - AssertionError successFlipped = assertThrows( - AssertionError.class, () -> engine.run(successReadAsFailure)); - assertTrue( - successFlipped.getMessage().contains("FT-SENS-OUTCOME-SF") - && successFlipped.getMessage().contains("$.expect.outcome"), - "a success run expected to fail must be red but was: " + successFlipped.getMessage()); - - NegotiationCase failureReadAsSuccess = fromTextCase( - "FT-SENS-OUTCOME-FS", - null, - ok(1, "information_accept")); - AssertionError failureFlipped = assertThrows(AssertionError.class, () -> engine.run(failureReadAsSuccess)); - assertTrue( - failureFlipped.getMessage().contains("FT-SENS-OUTCOME-FS") - && failureFlipped.getMessage().contains("$.expect.outcome"), - "a failing run expected to succeed must be red but was: " + failureFlipped.getMessage()); - } - - @Test - void flippedSlotErrorsMustFailTheEngine() { - List both = List.of( - new Expectation.SlotError("accessPort", "missing"), - new Expectation.SlotError("latency_target", "out_of_range")); - engine.run(validateCase("VAL-SENS-SLOTS", null, both)); - - NegotiationCase removed = validateCase( - "VAL-SENS-SLOTS-DEL", null, List.of(new Expectation.SlotError("accessPort", "missing"))); - AssertionError removalFailure = assertThrows(AssertionError.class, () -> engine.run(removed)); - assertTrue( - removalFailure.getMessage().contains("VAL-SENS-SLOTS-DEL") - && removalFailure.getMessage().contains("$.expect.slotErrors"), - "a removed slot error must fail but was: " + removalFailure.getMessage()); - - List three = List.of( - new Expectation.SlotError("accessPort", "missing"), - new Expectation.SlotError("latency_target", "out_of_range"), - new Expectation.SlotError("round", "out_of_range")); - NegotiationCase added = validateCase("VAL-SENS-SLOTS-ADD", null, three); - AssertionError additionFailure = assertThrows(AssertionError.class, () -> engine.run(added)); - assertTrue( - additionFailure.getMessage().contains("VAL-SENS-SLOTS-ADD") - && additionFailure.getMessage().contains("$.expect.slotErrors"), - "an added slot error must fail but was: " + additionFailure.getMessage()); - } - - // ------------------------------------------------------------------ task-family expectations must be sensitive - - @Test - void flippedMissingParamsMustFailTheEngine() throws Exception { - NegotiationCase trueExpectation = taskValidateCase( - "VAL-SENS-MISSING", - taskOk(1, null, List.of("accessPort", "bizScenario"), Map.of("faultTime", "2026-05-11T08:21:46Z")), - TASK_SEMANTIC_MISSING_PARAMS); - engine.run(trueExpectation); - - NegotiationCase flipped = taskValidateCase( - "VAL-SENS-MISSING-FLIP", - taskOk(1, null, List.of("accessPort"), Map.of("faultTime", "2026-05-11T08:21:46Z")), - TASK_SEMANTIC_MISSING_PARAMS); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); - - assertTrue( - failure.getMessage().contains("VAL-SENS-MISSING-FLIP") - && failure.getMessage().contains("$.expect.missingParams") - && failure.getMessage().contains("bizScenario"), - "a shrunken missing-parameter set must fail but was: " + failure.getMessage()); - } - - @Test - void flippedPromptTextContainsMustFailTheEngine() throws Exception { - NegotiationCase trueExpectation = - taskFromTextCase("VAL-SENS-HEADERS", taskOk(1, List.of("## 任务类型"), null, Map.of())); - engine.run(trueExpectation); - - NegotiationCase flipped = taskFromTextCase( - "VAL-SENS-HEADERS-FLIP", taskOk(1, List.of("## 所需信息项"), null, Map.of())); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); - - assertTrue( - failure.getMessage().contains("VAL-SENS-HEADERS-FLIP") - && failure.getMessage().contains("$.expect.promptTextContains"), - "a wrong structural fragment must fail but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ engine crash guard - - @Test - void engineMustNotCrashOnAnyCorpusCase() { - LoadedCorpus corpus = CorpusSuites.loadCorpus(); - CaseEngine caseEngine = new CaseEngine(); - for (NegotiationCase testCase : corpus.cases()) { - try { - caseEngine.run(testCase); - } catch (AssertionError redIsTheEngineWorking) { - // A red assertion is the engine rejecting a mismatch; the suites turn it into a test failure. - } catch (Throwable crash) { - fail("The case engine crashed on " + testCase.errorPrefix() + ": " + crash); - } - } - ScenarioEngine scenarioEngine = new ScenarioEngine(); - for (ScenarioCase scenario : corpus.scenarios()) { - try { - scenarioEngine.runScenario(scenario); - } catch (AssertionError redIsTheEngineWorking) { - // See above: red is not a crash. - } catch (Throwable crash) { - fail("The scenario engine crashed on " + scenario.id() + ": " + crash); - } - } - } - - // ------------------------------------------------------------------ inline sample cases - - private NegotiationCase fromTextCase( - String id, @Nullable String payload, Expectation expect) { - LlmScript script = new LlmScript( - null, - List.of(payload == null - ? new LlmScriptStep.Fail(LlmFailMarker.NON_JSON) - : new LlmScriptStep.Payload(payload))); - return new NegotiationCase( - id + "/zh-CN", - id, - "from-text/sensitivity-probes.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - null, - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - script, - null, - null, - null, - expect); - } - - private NegotiationCase validateCase(String id, @Nullable Map params) { - return validateCase(id, params, null); - } - - private NegotiationCase validateCase( - String id, @Nullable Map params, @Nullable List slotErrors) { - Expectation expect; - if (params == null) { - expect = new Expectation( - false, - null, - "negotiation_semantic_rejected", - List.of(), - slotErrors == null ? List.of() : slotErrors, - 1, - null, - null, - Map.of(), - List.of(), - false); - } else { - expect = new Expectation( - true, null, null, List.of(), List.of(), 1, null, null, params, List.of(), false); - } - String payload = params == null ? SEMANTIC_REJECT_PAYLOAD_TWO_ERRORS : SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS; - return new NegotiationCase( - id + "/zh-CN", - id, - "validate/sensitivity-probes.json", - NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, - ZH_CN, - null, - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_PROPOSE_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(payload))), - new PromptSource.Golden("information_propose"), - schema, - null, - expect); - } - - private static Expectation ok(@Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden) { - return new Expectation( - true, null, null, List.of(), List.of(), llmCalls, promptTextEqualsGolden, null, Map.of(), List.of(), - false); - } - - /** Task-family success expectation with the prompt-text fragments and the missing-parameter set. */ - private static Expectation taskOk( - @Nullable Integer llmCalls, - @Nullable List promptTextContains, - @Nullable List missingParams, - Map params) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - null, - null, - params, - List.of(), - false, - promptTextContains == null ? List.of() : promptTextContains, - missingParams, - null); - } - - private NegotiationCase taskFromTextCase(String id, Expectation expect) { - return new NegotiationCase( - id + "/zh-CN", - id, - "task/sensitivity-probes.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - ZH_CN, - null, - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - COMPLAINT_TEXT, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_OBJECT_PORTLESS))), - null, - null, - null, - expect); - } - - private NegotiationCase taskValidateCase(String id, Expectation expect, String semanticPayload) - throws Exception { - return new NegotiationCase( - id + "/zh-CN", - id, - "task/sensitivity-probes.json", - NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - ZH_CN, - null, - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), - new PromptSource.Text(TASK_PROMPT_MISSING_PARAMS), - new ObjectMapper().readTree(TASK_PARAM_SCHEMA), - null, - expect); - } - - private static Expectation failed( - @Nullable String code, - @Nullable Integer llmCalls, - @Nullable Map params, - @Nullable List slotErrors) { - return new Expectation( - false, - null, - code, - List.of(), - slotErrors == null ? List.of() : slotErrors, - llmCalls, - null, - null, - params == null ? Map.of() : params, - List.of(), - false); - } -} +package net.openan.a2at.sdk.corpus; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Meta-meta test of the corpus engine (design document §8.6, Q16): it guards against the deadliest failure mode of a + * data-driven suite — an always-green rubber-stamp engine whose bug lets every case pass silently. + * + *

        Every class of expectation assertion is proven sensitive on a minimal inline sample case: the sample runs green + * with its true expectation, then one flipped expectation value must make the {@link CaseEngine} red, with the case id + * and the JSON path of the flipped expectation in the failure message. The samples are built inline in Java and never + * touch the corpus files. + * + *

        The second guard, {@code engineMustNotCrashOnAnyCorpusCase}, walks the whole loaded corpus: the engine may report + * red (an AssertionError is the engine correctly rejecting a mismatch — that is what the suites turn into test + * failures), but it must never crash with an unexpected exception such as an NPE or a ClassCastException escaping the + * run. A crash is an engine bug; a red assertion is the engine working. + * + * @since 2026-08 + */ +class CorpusSensitivitySelfTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String ZH_CN = "zh-CN"; + + private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; + + private static final String INFORMATION_ACCEPT_REJECT_URI = + "Negotiation-T/information-negotiation/accept-reject/v1"; + + private static final String PRIVATE_LINE_COMPLAINT_URI = "Task-T/network-layer/private-line-complaint/v1"; + + /** The workbench raw complaint of the closed loop (样例步骤1): no port name and no complaint category. */ + private static final String COMPLAINT_TEXT = + "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报" + "“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; + + /** + * Slot-extraction payload of the closed loop's step 1 since the task_object slot became required upstream: the task + * object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays portless. + */ + private static final String TASK_SLOTS_OBJECT_PORTLESS = + "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" + + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** Semantic payload of the closed loop's step 2: the port name and the complaint category are missing. */ + private static final String TASK_SEMANTIC_MISSING_PARAMS = + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," + + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; + + /** The rendered task prompt the OMC receives (样例步骤1, shortened): the negotiation-causing message. */ + private static final String TASK_PROMPT_MISSING_PARAMS = + """ + ## 任务类型(Task Type) + 传输专线业务投诉诊断 + + ## 任务对象(Task Object) + 接入端口名称: + + ## 任务上下文(Task Context) + 1. 投诉分类: + 2. 问题发生时间: "2026-05-11T08:21:46Z" + 3. OSS侧事件流水号:"event-id-20260511-09013" + 4. 投诉详情:"深圳访问广州的响应时延从平均12ms骤升至320ms" + """; + + /** The task parameter schema of the closed loop (server-side keys, dictionary §10). */ + private static final String TASK_PARAM_SCHEMA = + "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" + + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" + + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; + + /** Extraction payload mapping to the typed content of the information accept golden fixture. */ + private static final String ACCEPT_PAYLOAD = + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" + + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; + + /** Extraction payload whose mapped content lacks every required slot (fails with negotiation.field_missing). */ + private static final String SLOTS_MISSING_PAYLOAD = "{\"relationship\":null}"; + + /** Semantic verdict payload of a successful validation carrying one business parameter. */ + private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" + + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; + + /** Semantic verdict payload rejecting the message with two slot errors. */ + private static final String SEMANTIC_REJECT_PAYLOAD_TWO_ERRORS = + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" + + "\"accessPort\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":" + + "\"接入端口名称\"}},{\"slot_name\":\"latency_target\",\"code\":" + + "\"negotiation.constraint_conflict\",\"facts\":{\"section_label\":\"时延目标\"," + + "\"reason\":\"latency target is out of range\"}}],\"params\":{}}"; + + private final CaseEngine engine = new CaseEngine(); + + private final ObjectMapper mapper = new ObjectMapper(); + + private JsonNode schema; + + @BeforeEach + void readSchema() throws Exception { + schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); + } + + // ------------------------------------------------------------------ flipped expectations must be red + + @Test + void flippedErrorCodeMustFailTheEngine() { + NegotiationCase trueExpectation = + fromTextCase("FT-SENS-CODE", SLOTS_MISSING_PAYLOAD, failed("negotiation.field_missing", 1, null, null)); + engine.run(trueExpectation); + + NegotiationCase flipped = fromTextCase( + "FT-SENS-CODE-FLIP", SLOTS_MISSING_PAYLOAD, failed("negotiation.invalid_input", 1, null, null)); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); + + assertTrue( + failure.getMessage().contains("FT-SENS-CODE-FLIP") + && failure.getMessage().contains("$.expect.code"), + "a swapped error code must fail with the case id and the JSON path but was: " + failure.getMessage()); + } + + @Test + void flippedLlmCallCountMustFailTheEngine() { + engine.run(fromTextCase("FT-SENS-CALLS", ACCEPT_PAYLOAD, ok(1, "information_accept"))); + + AssertionError tooHigh = assertThrows( + AssertionError.class, + () -> engine.run(fromTextCase("FT-SENS-CALLS-HIGH", ACCEPT_PAYLOAD, ok(2, "information_accept")))); + assertTrue( + tooHigh.getMessage().contains("FT-SENS-CALLS-HIGH") + && tooHigh.getMessage().contains("$.expect.llmCalls"), + "llmCalls + 1 must fail but was: " + tooHigh.getMessage()); + + AssertionError tooLow = assertThrows( + AssertionError.class, + () -> engine.run(fromTextCase("FT-SENS-CALLS-LOW", ACCEPT_PAYLOAD, ok(0, "information_accept")))); + assertTrue( + tooLow.getMessage().contains("FT-SENS-CALLS-LOW") + && tooLow.getMessage().contains("$.expect.llmCalls"), + "llmCalls - 1 must fail but was: " + tooLow.getMessage()); + } + + @Test + void flippedGoldenNameMustFailTheEngine() { + engine.run(fromTextCase("FT-SENS-GOLDEN", ACCEPT_PAYLOAD, ok(1, "information_accept"))); + + NegotiationCase flipped = fromTextCase("FT-SENS-GOLDEN-FLIP", ACCEPT_PAYLOAD, ok(1, "information_reject")); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); + + assertTrue( + failure.getMessage().contains("FT-SENS-GOLDEN-FLIP") + && failure.getMessage().contains("$.expect.promptTextEqualsGolden"), + "a swapped golden fixture must fail but was: " + failure.getMessage()); + } + + @Test + void tamperedParamsMustFailTheEngine() { + engine.run(validateCase( + "VAL-SENS-PARAMS", + Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P533-珠江旧城-PTN3900-23-TPA1EG24-1"))); + + NegotiationCase tampered = validateCase( + "VAL-SENS-PARAMS-FLIP", + Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P781-珠江新城-PTN7900-23-TPA1EG24-17")); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(tampered)); + + assertTrue( + failure.getMessage().contains("VAL-SENS-PARAMS-FLIP") + && failure.getMessage().contains("$.expect.params") + && failure.getMessage().contains("P781-珠江新城-PTN7900-23-TPA1EG24-17"), + "a tampered params value must fail but was: " + failure.getMessage()); + } + + @Test + void flippedOutcomeMustFailTheEngine() { + NegotiationCase successReadAsFailure = + fromTextCase("FT-SENS-OUTCOME-SF", ACCEPT_PAYLOAD, failed("negotiation.invalid_input", 1, null, null)); + AssertionError successFlipped = assertThrows(AssertionError.class, () -> engine.run(successReadAsFailure)); + assertTrue( + successFlipped.getMessage().contains("FT-SENS-OUTCOME-SF") + && successFlipped.getMessage().contains("$.expect.outcome"), + "a success run expected to fail must be red but was: " + successFlipped.getMessage()); + + NegotiationCase failureReadAsSuccess = fromTextCase("FT-SENS-OUTCOME-FS", null, ok(1, "information_accept")); + AssertionError failureFlipped = assertThrows(AssertionError.class, () -> engine.run(failureReadAsSuccess)); + assertTrue( + failureFlipped.getMessage().contains("FT-SENS-OUTCOME-FS") + && failureFlipped.getMessage().contains("$.expect.outcome"), + "a failing run expected to succeed must be red but was: " + failureFlipped.getMessage()); + } + + @Test + void flippedSlotErrorsMustFailTheEngine() { + List both = List.of( + new Expectation.SlotError("accessPort", "negotiation.field_missing"), + new Expectation.SlotError("latency_target", "negotiation.constraint_conflict")); + engine.run(validateCase("VAL-SENS-SLOTS", null, both)); + + NegotiationCase removed = validateCase( + "VAL-SENS-SLOTS-DEL", + null, + List.of(new Expectation.SlotError("accessPort", "negotiation.field_missing"))); + AssertionError removalFailure = assertThrows(AssertionError.class, () -> engine.run(removed)); + assertTrue( + removalFailure.getMessage().contains("VAL-SENS-SLOTS-DEL") + && removalFailure.getMessage().contains("$.expect.slotErrors"), + "a removed slot error must fail but was: " + removalFailure.getMessage()); + + List three = List.of( + new Expectation.SlotError("accessPort", "negotiation.field_missing"), + new Expectation.SlotError("latency_target", "negotiation.constraint_conflict"), + new Expectation.SlotError("round", "negotiation.round_exceeded")); + NegotiationCase added = validateCase("VAL-SENS-SLOTS-ADD", null, three); + AssertionError additionFailure = assertThrows(AssertionError.class, () -> engine.run(added)); + assertTrue( + additionFailure.getMessage().contains("VAL-SENS-SLOTS-ADD") + && additionFailure.getMessage().contains("$.expect.slotErrors"), + "an added slot error must fail but was: " + additionFailure.getMessage()); + } + + // ------------------------------------------------------------------ task-family expectations must be sensitive + + @Test + void flippedMissingParamsMustFailTheEngine() throws Exception { + NegotiationCase trueExpectation = taskValidateCase( + "VAL-SENS-MISSING", + taskOk(1, null, List.of("accessPort", "bizScenario"), Map.of("faultTime", "2026-05-11T08:21:46Z")), + TASK_SEMANTIC_MISSING_PARAMS); + engine.run(trueExpectation); + + NegotiationCase flipped = taskValidateCase( + "VAL-SENS-MISSING-FLIP", + taskOk(1, null, List.of("accessPort"), Map.of("faultTime", "2026-05-11T08:21:46Z")), + TASK_SEMANTIC_MISSING_PARAMS); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); + + assertTrue( + failure.getMessage().contains("VAL-SENS-MISSING-FLIP") + && failure.getMessage().contains("$.expect.missingParams") + && failure.getMessage().contains("bizScenario"), + "a shrunken missing-parameter set must fail but was: " + failure.getMessage()); + } + + @Test + void flippedPromptTextContainsMustFailTheEngine() throws Exception { + NegotiationCase trueExpectation = + taskFromTextCase("VAL-SENS-HEADERS", taskOk(1, List.of("## 任务类型"), null, Map.of())); + engine.run(trueExpectation); + + NegotiationCase flipped = + taskFromTextCase("VAL-SENS-HEADERS-FLIP", taskOk(1, List.of("## 所需信息项"), null, Map.of())); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); + + assertTrue( + failure.getMessage().contains("VAL-SENS-HEADERS-FLIP") + && failure.getMessage().contains("$.expect.promptTextContains"), + "a wrong structural fragment must fail but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ engine crash guard + + @Test + void engineMustNotCrashOnAnyCorpusCase() { + LoadedCorpus corpus = CorpusSuites.loadCorpus(); + CaseEngine caseEngine = new CaseEngine(); + for (NegotiationCase testCase : corpus.cases()) { + try { + caseEngine.run(testCase); + } catch (AssertionError redIsTheEngineWorking) { + // A red assertion is the engine rejecting a mismatch; the suites turn it into a test failure. + } catch (Throwable crash) { + fail("The case engine crashed on " + testCase.errorPrefix() + ": " + crash); + } + } + ScenarioEngine scenarioEngine = new ScenarioEngine(); + for (ScenarioCase scenario : corpus.scenarios()) { + try { + scenarioEngine.runScenario(scenario); + } catch (AssertionError redIsTheEngineWorking) { + // See above: red is not a crash. + } catch (Throwable crash) { + fail("The scenario engine crashed on " + scenario.id() + ": " + crash); + } + } + } + + // ------------------------------------------------------------------ inline sample cases + + private NegotiationCase fromTextCase(String id, @Nullable String payload, Expectation expect) { + LlmScript script = new LlmScript( + null, + List.of( + payload == null + ? new LlmScriptStep.Fail(LlmFailMarker.NON_JSON) + : new LlmScriptStep.Payload(payload))); + return new NegotiationCase( + id + "/zh-CN", + id, + "from-text/sensitivity-probes.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + null, + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + script, + null, + null, + null, + expect); + } + + private NegotiationCase validateCase(String id, @Nullable Map params) { + return validateCase(id, params, null); + } + + private NegotiationCase validateCase( + String id, @Nullable Map params, @Nullable List slotErrors) { + Expectation expect; + if (params == null) { + expect = new Expectation( + false, + null, + "negotiation.semantic_rejected", + List.of(), + slotErrors == null ? List.of() : slotErrors, + 1, + null, + null, + Map.of(), + List.of(), + false); + } else { + expect = new Expectation(true, null, null, List.of(), List.of(), 1, null, null, params, List.of(), false); + } + String payload = params == null ? SEMANTIC_REJECT_PAYLOAD_TWO_ERRORS : SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS; + return new NegotiationCase( + id + "/zh-CN", + id, + "validate/sensitivity-probes.json", + NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, + ZH_CN, + null, + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_PROPOSE_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(payload))), + new PromptSource.Golden("information_propose"), + schema, + null, + expect); + } + + private static Expectation ok(@Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + promptTextEqualsGolden, + null, + Map.of(), + List.of(), + false); + } + + /** Task-family success expectation with the prompt-text fragments and the missing-parameter set. */ + private static Expectation taskOk( + @Nullable Integer llmCalls, + @Nullable List promptTextContains, + @Nullable List missingParams, + Map params) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + null, + null, + params, + List.of(), + false, + promptTextContains == null ? List.of() : promptTextContains, + missingParams, + null); + } + + private NegotiationCase taskFromTextCase(String id, Expectation expect) { + return new NegotiationCase( + id + "/zh-CN", + id, + "task/sensitivity-probes.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + ZH_CN, + null, + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + COMPLAINT_TEXT, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_OBJECT_PORTLESS))), + null, + null, + null, + expect); + } + + private NegotiationCase taskValidateCase(String id, Expectation expect, String semanticPayload) throws Exception { + return new NegotiationCase( + id + "/zh-CN", + id, + "task/sensitivity-probes.json", + NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, + ZH_CN, + null, + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), + new PromptSource.Text(TASK_PROMPT_MISSING_PARAMS), + new ObjectMapper().readTree(TASK_PARAM_SCHEMA), + null, + expect); + } + + private static Expectation failed( + @Nullable String code, + @Nullable Integer llmCalls, + @Nullable Map params, + @Nullable List slotErrors) { + return new Expectation( + false, + null, + code, + List.of(), + slotErrors == null ? List.of() : slotErrors, + llmCalls, + null, + null, + params == null ? Map.of() : params, + List.of(), + false); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java index c8365380..d9ae7998 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java @@ -1,128 +1,128 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.List; -import java.util.Map; -import org.jspecify.annotations.Nullable; - -/** - * The validated expectation block of one corpus record, already checked for completeness by the loader. - * - *

        The success and failure shapes are a discriminated union in the corpus JSON; on the loaded record both live on - * one object and the loader guarantees the field combinations that the outcome allows: a failure expectation always - * carries an exception name or an error code, and success-only fields never appear on a failure expectation or the - * other way round. - * - *

        The task-family block (Q21–Q23) adds three success-only fields: {@code promptTextContains} asserts structural - * fragments of the generated task prompt (the {@code ## 任务类型(Task Type)} style section headers), {@code - * missingParams} asserts the exact set of schema parameters the validation found missing — a missing parameter is a - * null-valued entry of the filled parameter data — and {@code paramsFromStep} ties this validation step to the earlier - * task-validation step whose missing parameters this step must have filled. - * - * @param success true for the success outcome, false for the failure outcome - * @param exception expected exception simple name, failure expectations only - * @param code expected error code, failure expectations only - * @param messageContains substrings the failure message must contain, failure expectations only - * @param slotErrors expected slot errors as slot and code pairs, failure expectations only - * @param llmCalls expected exact LLM call count, or null when the record does not pin it - * @param promptTextEqualsGolden golden fixture name the generated prompt text must equal, success expectations only - * @param metadata expected metadata entries, success expectations only - * @param params expected merged parameter map, success expectations only; when {@code missingParams} is present the - * entries are a subset check instead of full-map equality (the missing-parameter set carries the rest) - * @param contracts behavior contract names to assert, both outcomes - * @param differential true when the engine runs the from-text and from-data double assertion, success expectations - * @param promptTextContains substrings the generated task prompt text must contain, success expectations of the task - * family, empty when the record states none - * @param missingParams exact set of parameter names expected to be missing (null-valued) in the filled parameter - * data, success expectations of the task validation; null when the record does not assert the missing set - * @param paramsFromStep number of the earlier scenario step whose missing parameters this step must have filled; - * resolved by the ScenarioEngine, null on standalone cases - * @since 2026-08 - */ -public record Expectation( - boolean success, - @Nullable String exception, - @Nullable String code, - List messageContains, - List slotErrors, - @Nullable Integer llmCalls, - @Nullable String promptTextEqualsGolden, - @Nullable Metadata metadata, - Map params, - List contracts, - boolean differential, - List promptTextContains, - @Nullable List missingParams, - @Nullable Integer paramsFromStep) { - - /** - * Creates an expectation without any task-family assertions; kept for the negotiation-family records and the - * inline sample cases of the engine tests. - * - * @param success true for the success outcome, false for the failure outcome - * @param exception expected exception simple name, failure expectations only - * @param code expected error code, failure expectations only - * @param messageContains substrings the failure message must contain, failure expectations only - * @param slotErrors expected slot errors as slot and code pairs, failure expectations only - * @param llmCalls expected exact LLM call count, or null when the record does not pin it - * @param promptTextEqualsGolden golden fixture name the generated prompt text must equal, success expectations only - * @param metadata expected metadata entries, success expectations only - * @param params expected merged parameter map, success expectations only - * @param contracts behavior contract names to assert, both outcomes - * @param differential true when the engine runs the from-text and from-data double assertion - */ - public Expectation( - boolean success, - @Nullable String exception, - @Nullable String code, - List messageContains, - List slotErrors, - @Nullable Integer llmCalls, - @Nullable String promptTextEqualsGolden, - @Nullable Metadata metadata, - Map params, - List contracts, - boolean differential) { - this( - success, - exception, - code, - messageContains, - slotErrors, - llmCalls, - promptTextEqualsGolden, - metadata, - params, - contracts, - differential, - List.of(), - null, - null); - } - - public Expectation { - messageContains = List.copyOf(messageContains); - slotErrors = List.copyOf(slotErrors); - params = Map.copyOf(params); - contracts = List.copyOf(contracts); - promptTextContains = List.copyOf(promptTextContains); - if (missingParams != null) { - missingParams = List.copyOf(missingParams); - } - } - - /** - * One expected slot error as a slot and code pair. - * - * @param slot slot name such as {@code round} - * @param code expected slot error code such as {@code out_of_range} - */ - public record SlotError(String slot, String code) {} - - /** - * The expected metadata entries of a success expectation. - * - * @param templateUriEcho expected template URI metadata echo, or null when not asserted - * @param contextEcho true when the negotiation context must travel in the metadata, or null when not asserted - */ - public record Metadata(@Nullable String templateUriEcho, @Nullable Boolean contextEcho) {} -} +package net.openan.a2at.sdk.corpus; + +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * The validated expectation block of one corpus record, already checked for completeness by the loader. + * + *

        The success and failure shapes are a discriminated union in the corpus JSON; on the loaded record both live on one + * object and the loader guarantees the field combinations that the outcome allows: a failure expectation always carries + * an exception name or an error code, and success-only fields never appear on a failure expectation or the other way + * round. + * + *

        The task-family block (Q21–Q23) adds three success-only fields: {@code promptTextContains} asserts structural + * fragments of the generated task prompt (the {@code ## 任务类型(Task Type)} style section headers), {@code missingParams} + * asserts the exact set of schema parameters the validation found missing — a missing parameter is a null-valued entry + * of the filled parameter data — and {@code paramsFromStep} ties this validation step to the earlier task-validation + * step whose missing parameters this step must have filled. + * + * @param success true for the success outcome, false for the failure outcome + * @param exception expected exception simple name, failure expectations only + * @param code expected error code, failure expectations only + * @param messageContains substrings the failure message must contain, failure expectations only + * @param slotErrors expected slot errors as slot and code pairs, failure expectations only + * @param llmCalls expected exact LLM call count, or null when the record does not pin it + * @param promptTextEqualsGolden golden fixture name the generated prompt text must equal, success expectations only + * @param metadata expected metadata entries, success expectations only + * @param params expected merged parameter map, success expectations only; when {@code missingParams} is present the + * entries are a subset check instead of full-map equality (the missing-parameter set carries the rest) + * @param contracts behavior contract names to assert, both outcomes + * @param differential true when the engine runs the from-text and from-data double assertion, success expectations + * @param promptTextContains substrings the generated task prompt text must contain, success expectations of the task + * family, empty when the record states none + * @param missingParams exact set of parameter names expected to be missing (null-valued) in the filled parameter data, + * success expectations of the task validation; null when the record does not assert the missing set + * @param paramsFromStep number of the earlier scenario step whose missing parameters this step must have filled; + * resolved by the ScenarioEngine, null on standalone cases + * @since 2026-08 + */ +public record Expectation( + boolean success, + @Nullable String exception, + @Nullable String code, + List messageContains, + List slotErrors, + @Nullable Integer llmCalls, + @Nullable String promptTextEqualsGolden, + @Nullable Metadata metadata, + Map params, + List contracts, + boolean differential, + List promptTextContains, + @Nullable List missingParams, + @Nullable Integer paramsFromStep) { + + /** + * Creates an expectation without any task-family assertions; kept for the negotiation-family records and the inline + * sample cases of the engine tests. + * + * @param success true for the success outcome, false for the failure outcome + * @param exception expected exception simple name, failure expectations only + * @param code expected error code, failure expectations only + * @param messageContains substrings the failure message must contain, failure expectations only + * @param slotErrors expected slot errors as slot and code pairs, failure expectations only + * @param llmCalls expected exact LLM call count, or null when the record does not pin it + * @param promptTextEqualsGolden golden fixture name the generated prompt text must equal, success expectations only + * @param metadata expected metadata entries, success expectations only + * @param params expected merged parameter map, success expectations only + * @param contracts behavior contract names to assert, both outcomes + * @param differential true when the engine runs the from-text and from-data double assertion + */ + public Expectation( + boolean success, + @Nullable String exception, + @Nullable String code, + List messageContains, + List slotErrors, + @Nullable Integer llmCalls, + @Nullable String promptTextEqualsGolden, + @Nullable Metadata metadata, + Map params, + List contracts, + boolean differential) { + this( + success, + exception, + code, + messageContains, + slotErrors, + llmCalls, + promptTextEqualsGolden, + metadata, + params, + contracts, + differential, + List.of(), + null, + null); + } + + public Expectation { + messageContains = List.copyOf(messageContains); + slotErrors = List.copyOf(slotErrors); + params = Map.copyOf(params); + contracts = List.copyOf(contracts); + promptTextContains = List.copyOf(promptTextContains); + if (missingParams != null) { + missingParams = List.copyOf(missingParams); + } + } + + /** + * One expected slot error as a slot and code pair. + * + * @param slot slot name such as {@code round} + * @param code expected slot error code such as {@code negotiation.round_exceeded} + */ + public record SlotError(String slot, String code) {} + + /** + * The expected metadata entries of a success expectation. + * + * @param templateUriEcho expected template URI metadata echo, or null when not asserted + * @param contextEcho true when the negotiation context must travel in the metadata, or null when not asserted + */ + public record Metadata(@Nullable String templateUriEcho, @Nullable Boolean contextEcho) {} +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngine.java index e5afb23e..aa48e445 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngine.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngine.java @@ -1,475 +1,480 @@ -package net.openan.a2at.sdk.corpus; - -import java.io.IOException; -import java.nio.file.Path; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import org.jspecify.annotations.Nullable; - -/** - * Executes one {@link LiveCase} against the real LLM endpoint of the live family (design document §4) and asserts its - * {@link LiveExpectation}. - * - *

        Everything except the model itself is production assembly: the engine wraps the real provider client built from - * {@link LiveLlmConfig} in a {@link RecordingLLMClient} and hands the wrapper to the {@link TaskApiAssembler} — the - * real facade builders' assembly of the closed loop — driven by the {@code LiveLlmEnvWriter} bridge, so the pipeline - * retry limit comes from the bridge's {@code A2AT_LLM_MAX_ATTEMPTS=3} ([R6]/[R7], not a per-record value); the offline - * engine-test seam keeps the scripted minimal env instead. A generation case runs the whole mini closed loop its - * records assert against: the generated prompt is fed straight into {@code validateTaskPromptAndDataFilling} with the - * record's schema, so {@code paramsContains}/{@code paramsAbsent} judge the validate-step parameter keys and the two - * calls together make up the {@code maxLlmCalls} budget. {@code scenarioCode} compares the scenario segment of the - * template URI the pipeline echoes — the from-text task API resolves the template from the caller's URI and reports no - * separate recognition result. - * - *

        Retry semantics (Q7): only infrastructure failures retry — LLM runtime errors and IO/timeout exceptions, raw or - * wrapped by the pipeline's LLM-infrastructure error codes — at most {@code -Dcorpus.live.infraRetries} times (default - * 2), each attempt on a fresh recording client; an assertion mismatch fails immediately. Every case verdict is - * appended to the run's {@link LiveTranscript} before it surfaces: the transcript keeps the recorded LLM calls of the - * failed attempts too, while {@code maxLlmCalls} bounds only the final, judged attempt. - * - * @since 2026-08 - */ -public final class LiveCaseEngine { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - /** System property overriding the infra-retry limit (live design document §4, same property channel as case.filter). */ - static final String INFRA_RETRIES_PROPERTY = "corpus.live.infraRetries"; - - /** Default of the infra-retry limit: at most two retries, three attempts in total. */ - private static final int DEFAULT_INFRA_RETRIES = 2; - - /** Fixed pipeline retry limit of the live env bridge ([R6]: the record does not declare it). */ - private static final int PIPELINE_MAX_ATTEMPTS = 3; - - private static final int MAX_REPORTED_TEXT_LENGTH = 400; - - private final LLMClient llmClient; - - private final int infraRetries; - - /** The {@code LiveLlmEnvWriter} bridge of the configured run; null on the offline engine-test seam. */ - private final @Nullable Path envFile; - - private final LiveTranscript.Run transcript; - - /** - * Creates the engine for one suite run: the real provider client of the given configuration, the default - * infra-retry limit, the live {@code .env} bridge of the configuration and the run the verdicts are appended to. - * - * @param config resolved live test configuration - * @param transcript run handle the case verdicts are appended to - */ - public LiveCaseEngine(LiveLlmConfig config, LiveTranscript.Run transcript) { - this( - LiveLlmConfig.createLlmClient(config), - infraRetryLimit(), - LiveLlmEnvWriter.envFileFor(config), - transcript); - } - - /** - * Creates the engine around an explicit LLM client, env file and infra-retry limit — the seam of the offline engine - * tests, which drive the assertion logic with a stub client and the scripted minimal env instead of a real endpoint - * and the live env bridge. - * - * @param llmClient the LLM client every recording wrapper delegates to - * @param infraRetries number of infrastructure retries after the first attempt (negative values clamp to 0) - * @param envFile the {@code .env} file the task assembly loads (the {@code LiveLlmEnvWriter} bridge), or null to - * fall back to the scripted minimal env with the fixed pipeline retry limit - * @param transcript run handle the case verdicts are appended to - */ - LiveCaseEngine( - LLMClient llmClient, int infraRetries, @Nullable Path envFile, LiveTranscript.Run transcript) { - this.llmClient = Objects.requireNonNull(llmClient, "llmClient"); - this.infraRetries = Math.max(0, infraRetries); - this.envFile = envFile; - this.transcript = Objects.requireNonNull(transcript, "transcript"); - } - - /** - * Runs one live case, appends its verdict to the transcript and returns it. - * - * @param testCase expanded live corpus case - * @return the verdict of the executed case - * @throws AssertionError when a live expectation mismatches (the transcript carries the same diff) - * @throws RuntimeException when the engine itself failed, or an infrastructure failure survived all retries - */ - public LiveCaseResult run(LiveCase testCase) { - long startNanos = System.nanoTime(); - int attempts = infraRetries + 1; - List recordedCalls = new ArrayList<>(); - for (int attempt = 1; attempt <= attempts; attempt++) { - RecordingLLMClient recording = new RecordingLLMClient(llmClient); - TaskApiAssembler assembler = envFile == null - ? new TaskApiAssembler(testCase.language(), PIPELINE_MAX_ATTEMPTS, recording) - : new TaskApiAssembler(envFile, recording); - try { - Judged judged = judge(assembler, recording, testCase); - recordedCalls.addAll(recording.snapshot()); - boolean passed = judged.failure() == null; - LiveCaseResult result = asResult( - testCase, - judged, - passed ? LiveCaseResult.Outcome.PASS : LiveCaseResult.Outcome.FAIL, - recordedCalls, - Duration.ofNanos(System.nanoTime() - startNanos).toMillis(), - passed ? null : judged.failure().getMessage()); - transcript.appendCase(result); - if (!passed) { - throw judged.failure(); - } - return result; - } catch (RuntimeException error) { - recordedCalls.addAll(recording.snapshot()); - if (isInfrastructureFailure(error) && attempt < attempts) { - continue; - } - // A surviving failure is recorded as ERROR and rethrown, so the transcript verdict and the build - // verdict agree on the same red instead of the summary counting a skip the build reports as an error. - String failureDiff = isInfrastructureFailure(error) - ? "infrastructure failure after " + attempts + " attempts: " + error - : error.toString(); - LiveCaseResult result = asResult( - testCase, - null, - LiveCaseResult.Outcome.ERROR, - recordedCalls, - Duration.ofNanos(System.nanoTime() - startNanos).toMillis(), - failureDiff); - transcript.appendCase(result); - throw error; - } - } - throw new IllegalStateException(testCase.errorPrefix() + " the live engine left the retry loop without a verdict"); - } - - // ------------------------------------------------------------------ judging - - /** - * Judges one attempt without throwing for an expectation mismatch: the returned {@link Judged} carries the - * pipeline output plus the first failed assertion, so the transcript can embed what the pipeline actually - * produced even for a failed case. Only infrastructure failures propagate — they are retried by {@link #run}. - */ - private static Judged judge(TaskApiAssembler assembler, RecordingLLMClient recording, LiveCase testCase) { - LiveExpectation expect = testCase.liveExpect(); - Judgement judgement = null; - RuntimeException pipelineFailure = null; - try { - judgement = invoke(assembler, testCase); - } catch (RuntimeException error) { - if (isInfrastructureFailure(error)) { - throw error; - } - pipelineFailure = error; - } - @Nullable AssertionError failure = null; - if (expect.success()) { - failure = pipelineFailure != null - ? fail(testCase, "$.expect.success", "success", "failure (" + pipelineFailure + ")") - : assertValueExpectations(testCase, judgement); - } else if (pipelineFailure == null) { - failure = fail( - testCase, - "$.expect.success", - "failure", - "success (" + render(judgement == null ? null : judgement.params()) + ")"); - } - if (failure == null && expect.maxLlmCalls() != null && recording.callCount() > expect.maxLlmCalls()) { - failure = fail( - testCase, - "$.expect.maxLlmCalls", - "at most " + expect.maxLlmCalls() + " LLM calls", - String.valueOf(recording.callCount())); - } - return new Judged(judgement, failure); - } - - /** - * Asserts the value-level live expectations; returns the first mismatch instead of throwing so the caller keeps - * the pipeline output for the transcript. - */ - private static @Nullable AssertionError assertValueExpectations(LiveCase testCase, Judgement judgement) { - LiveExpectation expect = testCase.liveExpect(); - if (expect.scenarioCode() != null && !expect.scenarioCode().equals(judgement.scenarioCode())) { - return fail( - testCase, - "$.expect.scenarioCode", - expect.scenarioCode(), - String.valueOf(judgement.scenarioCode())); - } - Map params = judgement.params() == null ? Map.of() : judgement.params().data(); - for (Map.Entry entry : expect.paramsContains().entrySet()) { - Object actual = params.get(entry.getKey()); - if (actual == null) { - return fail( - testCase, - "$.expect.paramsContains", - entry.getKey() + "=" + entry.getValue(), - entry.getKey() + "=" + (params.containsKey(entry.getKey()) ? "null" : "(missing)")); - } - if (!valueMatches(entry.getValue(), actual)) { - return fail( - testCase, - "$.expect.paramsContains", - entry.getKey() + "=" + entry.getValue(), - entry.getKey() + "=" + actual); - } - } - for (String slot : expect.paramsAbsent()) { - if (params.get(slot) != null) { - return fail(testCase, "$.expect.paramsAbsent", slot + " null or missing", slot + "=" + params.get(slot)); - } - } - for (String fragment : expect.promptTextContains()) { - String promptText = judgement.message() == null || judgement.message().promptText() == null - ? "" - : judgement.message().promptText(); - if (!normalize(promptText).contains(normalize(fragment))) { - return fail( - testCase, - "$.expect.promptTextContains", - "a prompt text containing '" + fragment + "'", - quoted(truncate(normalize(promptText)))); - } - } - return null; - } - - // ------------------------------------------------------------------ API dispatch - - /** - * Invokes the case's API on the real task assembly: the generation API runs the mini closed loop (generate, then - * validate the generated prompt against the record's schema), the validation API validates the record's prompt. - */ - private static Judgement invoke(TaskApiAssembler assembler, LiveCase testCase) { - TemplateUri templateUri = parseTemplateUri(testCase); - Map schema = requireSchema(testCase); - return switch (testCase.api()) { - case GENERATE_TASK_PROMPT_FROM_TEXT -> { - MetadataContent message = assembler.generateTaskPromptFromText(requireInputText(testCase), templateUri); - FilledParamData params = - assembler.validateTaskPromptAndDataFilling(message.promptText(), schema, templateUri); - yield new Judgement(message, params, scenarioOf(message.templateUri())); - } - case VALIDATE_TASK_PROMPT_AND_DATA_FILLING -> { - FilledParamData params = assembler.validateTaskPromptAndDataFilling( - requirePromptText(testCase), schema, templateUri); - yield new Judgement(null, params, scenarioOf(templateUri.uri())); - } - default -> throw new IllegalStateException( - testCase.errorPrefix() + " the live engine covers the two phase-1 task APIs but got " - + testCase.api().jsonName()); - }; - } - - // ------------------------------------------------------------------ result assembly - - private static LiveCaseResult asResult( - LiveCase testCase, - @Nullable Judged judged, - LiveCaseResult.Outcome outcome, - List recordedCalls, - long durationMs, - @Nullable String failureDiff) { - Judgement judgement = judged == null ? null : judged.judgement(); - return new LiveCaseResult( - testCase.id(), - outcome, - assertionSummary(testCase, recordedCalls.size()), - inputSummaryOf(testCase), - judgement == null ? null : judgement.scenarioCode(), - judgement == null || judgement.params() == null ? null : judgement.params().data(), - recordedCalls, - durationMs, - failureDiff); - } - - /** - * Excerpt of what the case fed the pipeline (design document §5: the transcript's input summary): the natural-language - * text of a generation case, or the prompt text a validation case validates, truncated to the reporting length. - */ - private static @Nullable String inputSummaryOf(LiveCase testCase) { - String input = testCase.api() == NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT - ? testCase.inputText() - : testCase.prompt() instanceof PromptSource.Text text ? text.text() : null; - return input == null ? null : truncate(normalize(input)); - } - - /** One-line summary of what the case asserted, with the actual LLM call count for quick budget reading. */ - private static String assertionSummary(LiveCase testCase, int llmCalls) { - LiveExpectation expect = testCase.liveExpect(); - StringBuilder summary = new StringBuilder("expect.success=").append(expect.success()); - if (expect.scenarioCode() != null) { - summary.append(" scenarioCode=").append(expect.scenarioCode()); - } - if (!expect.paramsContains().isEmpty()) { - summary.append(" paramsContains=").append(expect.paramsContains().keySet()); - } - if (!expect.paramsAbsent().isEmpty()) { - summary.append(" paramsAbsent=").append(expect.paramsAbsent()); - } - if (!expect.promptTextContains().isEmpty()) { - summary.append(" promptTextContains=").append(expect.promptTextContains().size()); - } - if (expect.maxLlmCalls() != null) { - summary.append(" maxLlmCalls<=").append(expect.maxLlmCalls()); - } - return summary.append(" llmCalls=").append(llmCalls).toString(); - } - - // ------------------------------------------------------------------ classification - - /** - * Classifies one pipeline exception as an infrastructure failure (Q7): the LLM client's runtime errors and - * IO/timeout exceptions, raw or anywhere in the pipeline's wrapping cause chain, or an error carrying one of the - * two LLM-infrastructure codes. Semantic rejections and rule violations are verdicts, not outages, and never - * retry. - */ - private static boolean isInfrastructureFailure(Throwable error) { - for (Throwable current = error; current != null; - current = current.getCause() == current ? null : current.getCause()) { - if (current instanceof LLMRuntimeError || current instanceof IOException) { - return true; - } - if (current instanceof A2ATError a2atError - && (A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR.equals(a2atError.getCode()) - || A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR.equals(a2atError.getCode()))) { - return true; - } - } - return false; - } - - private static int infraRetryLimit() { - Integer limit = Integer.getInteger(INFRA_RETRIES_PROPERTY, DEFAULT_INFRA_RETRIES); - return limit == null ? DEFAULT_INFRA_RETRIES : Math.max(0, limit); - } - - // ------------------------------------------------------------------ helpers - - /** - * Compares one expected slot value with the extracted one: strings compare trimmed (model outputs may pad), and a - * number matches its JSON string form so a schema-declared number and its quoted extraction stay equivalent. - */ - private static boolean valueMatches(Object expected, Object actual) { - Double expectedNumber = toDoubleOrNull(expected); - Double actualNumber = toDoubleOrNull(actual); - if (expectedNumber != null && actualNumber != null - && (expected instanceof Number || actual instanceof Number)) { - return Double.compare(expectedNumber, actualNumber) == 0; - } - return String.valueOf(expected).trim().equals(String.valueOf(actual).trim()); - } - - private static @Nullable Double toDoubleOrNull(Object value) { - if (value instanceof Number number) { - return number.doubleValue(); - } - if (value instanceof String text) { - try { - return Double.valueOf(text.trim()); - } catch (NumberFormatException error) { - return null; - } - } - return null; - } - - /** - * Returns the scenario segment of a template URI — the last path segment of the Task-T layout ({@code - * Task-T/network-layer/private-line-complaint/v1} names the scenario {@code private-line-complaint}); null when - * the URI does not parse. - */ - private static @Nullable String scenarioOf(@Nullable String templateUri) { - return TemplateUri.parse(templateUri) - .map(uri -> uri.pathSegments().get(uri.pathSegments().size() - 1)) - .orElse(null); - } - - private static TemplateUri parseTemplateUri(LiveCase testCase) { - String raw = testCase.templateUri(); - if (raw == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " templateUri: the live task APIs require a template URI"); - } - return TemplateUri.parse(raw) - .orElseThrow(() -> new IllegalStateException( - testCase.errorPrefix() + " templateUri: unparseable template URI " + raw)); - } - - private static String requireInputText(LiveCase testCase) { - String text = testCase.inputText(); - if (text == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " input.text: the task from-text API requires a text input"); - } - return text; - } - - private static String requirePromptText(LiveCase testCase) { - if (testCase.prompt() instanceof PromptSource.Text text) { - return text.text(); - } - throw new IllegalStateException( - testCase.errorPrefix() + " prompt.text: the live validate API requires an inline prompt text"); - } - - private static Map requireSchema(LiveCase testCase) { - JsonNode schema = testCase.schema(); - if (schema == null) { - throw new IllegalStateException(testCase.errorPrefix() + " schema: the live task APIs require a schema"); - } - return MAPPER.convertValue(schema, new TypeReference>() {}); - } - - private static String render(@Nullable FilledParamData params) { - return params == null ? "(no parameter data)" : String.valueOf(params.data()); - } - - private static String normalize(@Nullable String text) { - return text == null ? "" : text.replace("\r\n", "\n"); - } - - private static String truncate(String text) { - return text.length() <= MAX_REPORTED_TEXT_LENGTH ? text : text.substring(0, MAX_REPORTED_TEXT_LENGTH) + "..."; - } - - private static String quoted(@Nullable String text) { - return text == null ? "null" : "<" + text + ">"; - } - - private static AssertionError fail(LiveCase testCase, String jsonPath, String expected, String actual) { - return new AssertionError( - testCase.errorPrefix() + " " + jsonPath + ": expected " + expected + " but was " + actual); - } - - // ------------------------------------------------------------------ internal records - - /** - * The judgement of one attempt: the pipeline output plus the first failed assertion, or null when the attempt - * passed. - */ - private record Judged(@Nullable Judgement judgement, @Nullable AssertionError failure) {} - - /** - * What the pipeline produced for one case: the generated message of a generation case (null for a pure validation - * case or an expected failure), the filled parameter data of the validate step, and the scenario code the - * pipeline reported. - */ - private record Judgement( - @Nullable MetadataContent message, @Nullable FilledParamData params, @Nullable String scenarioCode) {} -} +package net.openan.a2at.sdk.corpus; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.jspecify.annotations.Nullable; + +/** + * Executes one {@link LiveCase} against the real LLM endpoint of the live family (design document §4) and asserts its + * {@link LiveExpectation}. + * + *

        Everything except the model itself is production assembly: the engine wraps the real provider client built from + * {@link LiveLlmConfig} in a {@link RecordingLLMClient} and hands the wrapper to the {@link TaskApiAssembler} — the + * real facade builders' assembly of the closed loop — driven by the {@code LiveLlmEnvWriter} bridge, so the pipeline + * retry limit comes from the bridge's {@code A2AT_LLM_MAX_ATTEMPTS=3} ([R6]/[R7], not a per-record value); the offline + * engine-test seam keeps the scripted minimal env instead. A generation case runs the whole mini closed loop its + * records assert against: the generated prompt is fed straight into {@code validateTaskPromptAndDataFilling} with the + * record's schema, so {@code paramsContains}/{@code paramsAbsent} judge the validate-step parameter keys and the two + * calls together make up the {@code maxLlmCalls} budget. {@code scenarioCode} compares the scenario segment of the + * template URI the pipeline echoes — the from-text task API resolves the template from the caller's URI and reports no + * separate recognition result. + * + *

        Retry semantics (Q7): only infrastructure failures retry — LLM runtime errors and IO/timeout exceptions, raw or + * wrapped by the pipeline's LLM-infrastructure error codes — at most {@code -Dcorpus.live.infraRetries} times (default + * 2), each attempt on a fresh recording client; an assertion mismatch fails immediately. Every case verdict is appended + * to the run's {@link LiveTranscript} before it surfaces: the transcript keeps the recorded LLM calls of the failed + * attempts too, while {@code maxLlmCalls} bounds only the final, judged attempt. + * + * @since 2026-08 + */ +public final class LiveCaseEngine { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * System property overriding the infra-retry limit (live design document §4, same property channel as case.filter). + */ + static final String INFRA_RETRIES_PROPERTY = "corpus.live.infraRetries"; + + /** Default of the infra-retry limit: at most two retries, three attempts in total. */ + private static final int DEFAULT_INFRA_RETRIES = 2; + + /** Fixed pipeline retry limit of the live env bridge ([R6]: the record does not declare it). */ + private static final int PIPELINE_MAX_ATTEMPTS = 3; + + private static final int MAX_REPORTED_TEXT_LENGTH = 400; + + private final LLMClient llmClient; + + private final int infraRetries; + + /** The {@code LiveLlmEnvWriter} bridge of the configured run; null on the offline engine-test seam. */ + private final @Nullable Path envFile; + + private final LiveTranscript.Run transcript; + + /** + * Creates the engine for one suite run: the real provider client of the given configuration, the default + * infra-retry limit, the live {@code .env} bridge of the configuration and the run the verdicts are appended to. + * + * @param config resolved live test configuration + * @param transcript run handle the case verdicts are appended to + */ + public LiveCaseEngine(LiveLlmConfig config, LiveTranscript.Run transcript) { + this(LiveLlmConfig.createLlmClient(config), infraRetryLimit(), LiveLlmEnvWriter.envFileFor(config), transcript); + } + + /** + * Creates the engine around an explicit LLM client, env file and infra-retry limit — the seam of the offline engine + * tests, which drive the assertion logic with a stub client and the scripted minimal env instead of a real endpoint + * and the live env bridge. + * + * @param llmClient the LLM client every recording wrapper delegates to + * @param infraRetries number of infrastructure retries after the first attempt (negative values clamp to 0) + * @param envFile the {@code .env} file the task assembly loads (the {@code LiveLlmEnvWriter} bridge), or null to + * fall back to the scripted minimal env with the fixed pipeline retry limit + * @param transcript run handle the case verdicts are appended to + */ + LiveCaseEngine(LLMClient llmClient, int infraRetries, @Nullable Path envFile, LiveTranscript.Run transcript) { + this.llmClient = Objects.requireNonNull(llmClient, "llmClient"); + this.infraRetries = Math.max(0, infraRetries); + this.envFile = envFile; + this.transcript = Objects.requireNonNull(transcript, "transcript"); + } + + /** + * Runs one live case, appends its verdict to the transcript and returns it. + * + * @param testCase expanded live corpus case + * @return the verdict of the executed case + * @throws AssertionError when a live expectation mismatches (the transcript carries the same diff) + * @throws RuntimeException when the engine itself failed, or an infrastructure failure survived all retries + */ + public LiveCaseResult run(LiveCase testCase) { + long startNanos = System.nanoTime(); + int attempts = infraRetries + 1; + List recordedCalls = new ArrayList<>(); + for (int attempt = 1; attempt <= attempts; attempt++) { + RecordingLLMClient recording = new RecordingLLMClient(llmClient); + TaskApiAssembler assembler = envFile == null + ? new TaskApiAssembler(testCase.language(), PIPELINE_MAX_ATTEMPTS, recording) + : new TaskApiAssembler(envFile, recording); + try { + Judged judged = judge(assembler, recording, testCase); + recordedCalls.addAll(recording.snapshot()); + boolean passed = judged.failure() == null; + LiveCaseResult result = asResult( + testCase, + judged, + passed ? LiveCaseResult.Outcome.PASS : LiveCaseResult.Outcome.FAIL, + recordedCalls, + Duration.ofNanos(System.nanoTime() - startNanos).toMillis(), + passed ? null : judged.failure().getMessage()); + transcript.appendCase(result); + if (!passed) { + throw judged.failure(); + } + return result; + } catch (RuntimeException error) { + recordedCalls.addAll(recording.snapshot()); + if (isInfrastructureFailure(error) && attempt < attempts) { + continue; + } + // A surviving failure is recorded as ERROR and rethrown, so the transcript verdict and the build + // verdict agree on the same red instead of the summary counting a skip the build reports as an error. + String failureDiff = isInfrastructureFailure(error) + ? "infrastructure failure after " + attempts + " attempts: " + error + : error.toString(); + LiveCaseResult result = asResult( + testCase, + null, + LiveCaseResult.Outcome.ERROR, + recordedCalls, + Duration.ofNanos(System.nanoTime() - startNanos).toMillis(), + failureDiff); + transcript.appendCase(result); + throw error; + } + } + throw new IllegalStateException( + testCase.errorPrefix() + " the live engine left the retry loop without a verdict"); + } + + // ------------------------------------------------------------------ judging + + /** + * Judges one attempt without throwing for an expectation mismatch: the returned {@link Judged} carries the pipeline + * output plus the first failed assertion, so the transcript can embed what the pipeline actually produced even for + * a failed case. Only infrastructure failures propagate — they are retried by {@link #run}. + */ + private static Judged judge(TaskApiAssembler assembler, RecordingLLMClient recording, LiveCase testCase) { + LiveExpectation expect = testCase.liveExpect(); + Judgement judgement = null; + RuntimeException pipelineFailure = null; + try { + judgement = invoke(assembler, testCase); + } catch (RuntimeException error) { + if (isInfrastructureFailure(error)) { + throw error; + } + pipelineFailure = error; + } + @Nullable AssertionError failure = null; + if (expect.success()) { + failure = pipelineFailure != null + ? fail(testCase, "$.expect.success", "success", "failure (" + pipelineFailure + ")") + : assertValueExpectations(testCase, judgement); + } else if (pipelineFailure == null) { + failure = fail( + testCase, + "$.expect.success", + "failure", + "success (" + render(judgement == null ? null : judgement.params()) + ")"); + } + if (failure == null && expect.maxLlmCalls() != null && recording.callCount() > expect.maxLlmCalls()) { + failure = fail( + testCase, + "$.expect.maxLlmCalls", + "at most " + expect.maxLlmCalls() + " LLM calls", + String.valueOf(recording.callCount())); + } + return new Judged(judgement, failure); + } + + /** + * Asserts the value-level live expectations; returns the first mismatch instead of throwing so the caller keeps the + * pipeline output for the transcript. + */ + private static @Nullable AssertionError assertValueExpectations(LiveCase testCase, Judgement judgement) { + LiveExpectation expect = testCase.liveExpect(); + if (expect.scenarioCode() != null && !expect.scenarioCode().equals(judgement.scenarioCode())) { + return fail( + testCase, "$.expect.scenarioCode", expect.scenarioCode(), String.valueOf(judgement.scenarioCode())); + } + Map params = + judgement.params() == null ? Map.of() : judgement.params().data(); + for (Map.Entry entry : expect.paramsContains().entrySet()) { + Object actual = params.get(entry.getKey()); + if (actual == null) { + return fail( + testCase, + "$.expect.paramsContains", + entry.getKey() + "=" + entry.getValue(), + entry.getKey() + "=" + (params.containsKey(entry.getKey()) ? "null" : "(missing)")); + } + if (!valueMatches(entry.getValue(), actual)) { + return fail( + testCase, + "$.expect.paramsContains", + entry.getKey() + "=" + entry.getValue(), + entry.getKey() + "=" + actual); + } + } + for (String slot : expect.paramsAbsent()) { + if (params.get(slot) != null) { + return fail( + testCase, "$.expect.paramsAbsent", slot + " null or missing", slot + "=" + params.get(slot)); + } + } + for (String fragment : expect.promptTextContains()) { + String promptText = + judgement.message() == null || judgement.message().promptText() == null + ? "" + : judgement.message().promptText(); + if (!normalize(promptText).contains(normalize(fragment))) { + return fail( + testCase, + "$.expect.promptTextContains", + "a prompt text containing '" + fragment + "'", + quoted(truncate(normalize(promptText)))); + } + } + return null; + } + + // ------------------------------------------------------------------ API dispatch + + /** + * Invokes the case's API on the real task assembly: the generation API runs the mini closed loop (generate, then + * validate the generated prompt against the record's schema), the validation API validates the record's prompt. + */ + private static Judgement invoke(TaskApiAssembler assembler, LiveCase testCase) { + TemplateUri templateUri = parseTemplateUri(testCase); + Map schema = requireSchema(testCase); + return switch (testCase.api()) { + case GENERATE_TASK_PROMPT_FROM_TEXT -> { + MetadataContent message = assembler.generateTaskPromptFromText(requireInputText(testCase), templateUri); + FilledParamData params = + assembler.validateTaskPromptAndDataFilling(message.promptText(), schema, templateUri); + yield new Judgement(message, params, scenarioOf(message.templateUri())); + } + case VALIDATE_TASK_PROMPT_AND_DATA_FILLING -> { + FilledParamData params = + assembler.validateTaskPromptAndDataFilling(requirePromptText(testCase), schema, templateUri); + yield new Judgement(null, params, scenarioOf(templateUri.uri())); + } + default -> throw new IllegalStateException( + testCase.errorPrefix() + " the live engine covers the two phase-1 task APIs but got " + + testCase.api().jsonName()); + }; + } + + // ------------------------------------------------------------------ result assembly + + private static LiveCaseResult asResult( + LiveCase testCase, + @Nullable Judged judged, + LiveCaseResult.Outcome outcome, + List recordedCalls, + long durationMs, + @Nullable String failureDiff) { + Judgement judgement = judged == null ? null : judged.judgement(); + return new LiveCaseResult( + testCase.id(), + outcome, + assertionSummary(testCase, recordedCalls.size()), + inputSummaryOf(testCase), + judgement == null ? null : judgement.scenarioCode(), + judgement == null || judgement.params() == null + ? null + : judgement.params().data(), + recordedCalls, + durationMs, + failureDiff); + } + + /** + * Excerpt of what the case fed the pipeline (design document §5: the transcript's input summary): the + * natural-language text of a generation case, or the prompt text a validation case validates, truncated to the + * reporting length. + */ + private static @Nullable String inputSummaryOf(LiveCase testCase) { + String input = testCase.api() == NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT + ? testCase.inputText() + : testCase.prompt() instanceof PromptSource.Text text ? text.text() : null; + return input == null ? null : truncate(normalize(input)); + } + + /** One-line summary of what the case asserted, with the actual LLM call count for quick budget reading. */ + private static String assertionSummary(LiveCase testCase, int llmCalls) { + LiveExpectation expect = testCase.liveExpect(); + StringBuilder summary = new StringBuilder("expect.success=").append(expect.success()); + if (expect.scenarioCode() != null) { + summary.append(" scenarioCode=").append(expect.scenarioCode()); + } + if (!expect.paramsContains().isEmpty()) { + summary.append(" paramsContains=").append(expect.paramsContains().keySet()); + } + if (!expect.paramsAbsent().isEmpty()) { + summary.append(" paramsAbsent=").append(expect.paramsAbsent()); + } + if (!expect.promptTextContains().isEmpty()) { + summary.append(" promptTextContains=") + .append(expect.promptTextContains().size()); + } + if (expect.maxLlmCalls() != null) { + summary.append(" maxLlmCalls<=").append(expect.maxLlmCalls()); + } + return summary.append(" llmCalls=").append(llmCalls).toString(); + } + + // ------------------------------------------------------------------ classification + + /** + * Classifies one pipeline exception as an infrastructure failure (Q7): the LLM client's runtime errors and + * IO/timeout exceptions, raw or anywhere in the pipeline's wrapping cause chain, or an error carrying one of the + * retryable {@code llm.*} codes. Semantic rejections and rule violations are verdicts, not outages, and never + * retry. + */ + private static boolean isInfrastructureFailure(Throwable error) { + for (Throwable current = error; + current != null; + current = current.getCause() == current ? null : current.getCause()) { + if (current instanceof LLMRuntimeError || current instanceof IOException) { + return true; + } + if (current instanceof A2ATError a2atError + && (ErrorCatalog.LLM_INVOCATION_FAILED.getCode().equals(a2atError.getCode()) + || ErrorCatalog.LLM_RESPONSE_INVALID.getCode().equals(a2atError.getCode()) + || ErrorCatalog.LLM_NOT_CONFIGURED.getCode().equals(a2atError.getCode()))) { + return true; + } + } + return false; + } + + private static int infraRetryLimit() { + Integer limit = Integer.getInteger(INFRA_RETRIES_PROPERTY, DEFAULT_INFRA_RETRIES); + return limit == null ? DEFAULT_INFRA_RETRIES : Math.max(0, limit); + } + + // ------------------------------------------------------------------ helpers + + /** + * Compares one expected slot value with the extracted one: strings compare trimmed (model outputs may pad), and a + * number matches its JSON string form so a schema-declared number and its quoted extraction stay equivalent. + */ + private static boolean valueMatches(Object expected, Object actual) { + Double expectedNumber = toDoubleOrNull(expected); + Double actualNumber = toDoubleOrNull(actual); + if (expectedNumber != null + && actualNumber != null + && (expected instanceof Number || actual instanceof Number)) { + return Double.compare(expectedNumber, actualNumber) == 0; + } + return String.valueOf(expected).trim().equals(String.valueOf(actual).trim()); + } + + private static @Nullable Double toDoubleOrNull(Object value) { + if (value instanceof Number number) { + return number.doubleValue(); + } + if (value instanceof String text) { + try { + return Double.valueOf(text.trim()); + } catch (NumberFormatException error) { + return null; + } + } + return null; + } + + /** + * Returns the scenario segment of a template URI — the last path segment of the Task-T layout + * ({@code Task-T/network-layer/private-line-complaint/v1} names the scenario {@code private-line-complaint}); null + * when the URI does not parse. + */ + private static @Nullable String scenarioOf(@Nullable String templateUri) { + return TemplateUri.parse(templateUri) + .map(uri -> uri.pathSegments().get(uri.pathSegments().size() - 1)) + .orElse(null); + } + + private static TemplateUri parseTemplateUri(LiveCase testCase) { + String raw = testCase.templateUri(); + if (raw == null) { + throw new IllegalStateException( + testCase.errorPrefix() + " templateUri: the live task APIs require a template URI"); + } + return TemplateUri.parse(raw) + .orElseThrow(() -> new IllegalStateException( + testCase.errorPrefix() + " templateUri: unparseable template URI " + raw)); + } + + private static String requireInputText(LiveCase testCase) { + String text = testCase.inputText(); + if (text == null) { + throw new IllegalStateException( + testCase.errorPrefix() + " input.text: the task from-text API requires a text input"); + } + return text; + } + + private static String requirePromptText(LiveCase testCase) { + if (testCase.prompt() instanceof PromptSource.Text text) { + return text.text(); + } + throw new IllegalStateException( + testCase.errorPrefix() + " prompt.text: the live validate API requires an inline prompt text"); + } + + private static Map requireSchema(LiveCase testCase) { + JsonNode schema = testCase.schema(); + if (schema == null) { + throw new IllegalStateException(testCase.errorPrefix() + " schema: the live task APIs require a schema"); + } + return MAPPER.convertValue(schema, new TypeReference>() {}); + } + + private static String render(@Nullable FilledParamData params) { + return params == null ? "(no parameter data)" : String.valueOf(params.data()); + } + + private static String normalize(@Nullable String text) { + return text == null ? "" : text.replace("\r\n", "\n"); + } + + private static String truncate(String text) { + return text.length() <= MAX_REPORTED_TEXT_LENGTH ? text : text.substring(0, MAX_REPORTED_TEXT_LENGTH) + "..."; + } + + private static String quoted(@Nullable String text) { + return text == null ? "null" : "<" + text + ">"; + } + + private static AssertionError fail(LiveCase testCase, String jsonPath, String expected, String actual) { + return new AssertionError( + testCase.errorPrefix() + " " + jsonPath + ": expected " + expected + " but was " + actual); + } + + // ------------------------------------------------------------------ internal records + + /** + * The judgement of one attempt: the pipeline output plus the first failed assertion, or null when the attempt + * passed. + */ + private record Judged(@Nullable Judgement judgement, @Nullable AssertionError failure) {} + + /** + * What the pipeline produced for one case: the generated message of a generation case (null for a pure validation + * case or an expected failure), the filled parameter data of the validate step, and the scenario code the pipeline + * reported. + */ + private record Judgement( + @Nullable MetadataContent message, @Nullable FilledParamData params, @Nullable String scenarioCode) {} +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngineTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngineTest.java index aa945288..69976023 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngineTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngineTest.java @@ -1,289 +1,282 @@ -package net.openan.a2at.sdk.corpus; - -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 java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Offline unit tests of the {@link LiveCaseEngine} judging logic: a step-playing stub client drives the real - * production assembly exactly the way a live endpoint would (the scripted responses reuse the response shapes the - * offline task scenarios script), so the expectation assertions, the infra-only retry and the transcript verdicts are - * verified without any endpoint. - * - * @since 2026-08 - */ -class LiveCaseEngineTest { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - /** Slot-extraction response of the generate step, the shape the offline task scenarios script. */ - private static final String SLOT_EXTRACTION_RESPONSE = - """ - {"slots":{"任务对象":"P533-珠江旧城-PTN3900-23-TPA1EG24-1",\ - "任务上下文":"投诉分类:专线质差;问题发生时间:2026-05-11T08:21:46Z;\ - OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的专线时延从平均12ms骤升至320ms"},\ - "slot_errors":[]}"""; - - /** Validate-step response of a semantically compliant prompt with every parameter filled. */ - private static final String VALIDATE_PASS_RESPONSE = - """ - {"semantic_verdict":true,"errors":[],"params":{"accessPort":"P533-珠江旧城-PTN3900-23-TPA1EG24-1",\ - "bizScenario":"专线质差","faultTime":"2026-05-11T08:21:46Z","eventSerialNo":"event-id-20260511-09013"}}"""; - - /** Validate-step response whose two schema-required parameters stay null (the paramsAbsent probes). */ - private static final String VALIDATE_PARTIAL_RESPONSE = - """ - {"semantic_verdict":true,"errors":[],"params":{"accessPort":null,"bizScenario":null,\ - "faultTime":"2026-05-11T08:21:46Z","eventSerialNo":"event-id-20260511-09013"}}"""; - - /** Validate-step response of a semantically contradictory prompt (the semantic-reject probes). */ - private static final String VALIDATE_REJECT_RESPONSE = - """ - {"semantic_verdict":false,"errors":[{"slot_name":"bizScenario","code":"semantic_mismatch",\ - "message":"投诉分类声明为专线质差,投诉详情却描述完全中断"}],"params":{}}"""; - - /** Hand-authored prompt following the Task-T private-line template structure (the validate family's input). */ - private static final String VALIDATE_PROMPT = - """ - ## 任务类型(Task Type) - 传输专线业务投诉诊断 - - ## 任务描述(Task Description) - 基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。 - - ## 任务目标(Task Target) - 对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。 - - ## 任务对象(Task Object) - 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1 - - ## 任务上下文(Task Context) - 1. 投诉分类:“专线质差” - 2. 问题发生时间:“2026-05-11T08:21:46Z” - 3. OSS侧事件流水号:“event-id-20260511-09013” - 4. 投诉详情:“从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报‘连接超时’。” - - ## 预期输出(Expected Output) - 要求投诉诊断任务的结果包含如下信息: - 1. 诊断结果;参数的取值范围包括:成功、失败;(必选) - 2. 诊断结果详细信息; (必选) - 3. 修复建议; (可选)"""; - - @TempDir - Path tempDir; - - @Test - void generateCaseRunsTheClosedLoopAndAssertsTheKeyFields() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine( - new StepStubClient(List.of(SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), 2, null, run); - LiveCase testCase = generateCase(new LiveExpectation( - true, - "private-line-complaint", - Map.of("accessPort", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "bizScenario", "专线质差"), - List.of(), - List.of("## 任务类型", "event-id-20260511-09013"), - 4)); - - LiveCaseResult result = engine.run(testCase); - - assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); - assertEquals("private-line-complaint", result.scenarioCode()); - assertTrue(result.inputSummary().contains("深圳访问广州的政企专线"), "the verdict carries the input summary"); - assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", result.params().get("accessPort")); - // The generation case is a mini closed loop: one slot-extraction call plus one validate call. - assertEquals(2, result.llmCalls().size()); - LiveRunSummary summary = run.summary(); - assertEquals(1, summary.totalCases()); - assertEquals(1, summary.passCount()); - assertEquals(2, summary.totalLlmCalls()); - } - - @Test - void validateCaseAssertsParamsContainsAndParamsAbsent() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine(new StepStubClient(List.of(VALIDATE_PARTIAL_RESPONSE)), 2, null, run); - LiveCase testCase = validateCase(new LiveExpectation( - true, - null, - Map.of("faultTime", "2026-05-11T08:21:46Z", "eventSerialNo", "event-id-20260511-09013"), - List.of("accessPort", "bizScenario"), - List.of(), - 3)); - - LiveCaseResult result = engine.run(testCase); - - assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); - assertEquals(1, result.llmCalls().size()); - assertNull(result.params().get("accessPort")); - assertEquals("event-id-20260511-09013", result.params().get("eventSerialNo")); - assertEquals(1, run.summary().passCount()); - } - - @Test - void expectedFailureVerdictPassesWhenThePipelineRejects() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine(new StepStubClient(List.of(VALIDATE_REJECT_RESPONSE)), 2, null, run); - LiveCase testCase = validateCase(new LiveExpectation(false, null, Map.of(), List.of(), List.of(), 3)); - - LiveCaseResult result = engine.run(testCase); - - assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); - assertEquals(1, result.llmCalls().size()); - assertNull(result.params()); - assertEquals(1, run.summary().passCount()); - } - - @Test - void assertionMismatchFailsWithTheCaseDiff() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine( - new StepStubClient(List.of(SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), 2, null, run); - LiveCase testCase = generateCase(new LiveExpectation( - true, - null, - Map.of("accessPort", "P999-不存在-PTN0000-0-TPA1EG24-0"), - List.of(), - List.of(), - 4)); - - AssertionError failure = - assertThrows(AssertionError.class, () -> engine.run(testCase), "a paramsContains mismatch is a red"); - - assertTrue(failure.getMessage().contains(testCase.errorPrefix()), failure.getMessage()); - assertTrue(failure.getMessage().contains("$.expect.paramsContains"), failure.getMessage()); - assertEquals(1, run.summary().failCount(), "the failed verdict still lands in the transcript"); - } - - @Test - void infrastructureFailuresRetryOnFreshClients() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine( - new StepStubClient(List.of( - new LLMRuntimeError("endpoint unreachable"), - SLOT_EXTRACTION_RESPONSE, - VALIDATE_PASS_RESPONSE)), - 2, - null, - run); - LiveCase testCase = generateCase( - new LiveExpectation(true, null, Map.of("bizScenario", "专线质差"), List.of(), List.of(), 4)); - - LiveCaseResult result = engine.run(testCase); - - assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); - // The failed attempt's call is kept in the transcript while the judged attempt made the two closed-loop calls. - assertEquals(3, result.llmCalls().size()); - assertEquals(1, run.summary().passCount()); - assertEquals(3, run.summary().totalLlmCalls()); - } - - @Test - void exhaustedInfrastructureRetriesRecordAnErrorAndStayRed() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = - new LiveCaseEngine(new StepStubClient(List.of(new LLMRuntimeError("endpoint unreachable"))), 0, null, run); - LiveCase testCase = generateCase( - new LiveExpectation(true, null, Map.of("bizScenario", "专线质差"), List.of(), List.of(), 4)); - - assertThrows(RuntimeException.class, () -> engine.run(testCase), "a surviving infra failure stays a red"); - - // The transcript verdict matches the build verdict: the rethrown failure is an ERROR, not a skip. - assertEquals(1, run.summary().errorCount(), "the given-up case is recorded as ERROR"); - assertEquals(1, run.summary().totalLlmCalls()); - } - - // ------------------------------------------------------------------ fixtures - - private static LiveCase generateCase(LiveExpectation expect) { - return new LiveCase( - "LIVE-TEST-GEN/zh-CN", - "LIVE-TEST-GEN", - "live/task-apis.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - "zh-CN", - "P0", - List.of("live"), - "engine test case of the generate API", - null, - "Task-T/network-layer/private-line-complaint/v1", - "深圳访问广州的政企专线自5月11日起时延骤升,柜面交易频繁超时。" - + "接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1;投诉分类:专线质差。", - null, - complaintParamsSchema(), - expect); - } - - private static LiveCase validateCase(LiveExpectation expect) { - return new LiveCase( - "LIVE-TEST-VAL/zh-CN", - "LIVE-TEST-VAL", - "live/task-apis.json", - NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - "zh-CN", - "P0", - List.of("live"), - "engine test case of the validate API", - null, - "Task-T/network-layer/private-line-complaint/v1", - null, - new PromptSource.Text(VALIDATE_PROMPT), - complaintParamsSchema(), - expect); - } - - /** The real shared complaint schema of the corpus, so the engine tests judge against the actual validate step. */ - private static JsonNode complaintParamsSchema() { - try (InputStream stream = - LiveCaseEngineTest.class.getResourceAsStream("/negotiation-cases/shared/schemas.json")) { - assertNotNull(stream, "the shared schemas of the corpus must be on the test classpath"); - return MAPPER.readTree(stream).path("biz.complaint.params"); - } catch (IOException exception) { - throw new UncheckedIOException("Failed to read the shared corpus schemas", exception); - } - } - - /** - * Step-playing stub client: it answers the canned response (or rethrows the canned exception) of the next script - * step and repeats the last step once the script is exhausted — the offline stand-in for the live endpoint. - */ - private static final class StepStubClient implements LLMClient { - - private final List steps; - - private int cursor; - - StepStubClient(List steps) { - this.steps = List.copyOf(steps); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - Object step = steps.get(Math.min(cursor++, steps.size() - 1)); - if (step instanceof RuntimeException error) { - throw error; - } - return new LLMResponse((String) step, "stub-model", Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sdk.corpus; + +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.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Offline unit tests of the {@link LiveCaseEngine} judging logic: a step-playing stub client drives the real production + * assembly exactly the way a live endpoint would (the scripted responses reuse the response shapes the offline task + * scenarios script), so the expectation assertions, the infra-only retry and the transcript verdicts are verified + * without any endpoint. + * + * @since 2026-08 + */ +class LiveCaseEngineTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** Slot-extraction response of the generate step, the shape the offline task scenarios script. */ + private static final String SLOT_EXTRACTION_RESPONSE = + """ + {"slots":{"任务对象":"P533-珠江旧城-PTN3900-23-TPA1EG24-1",\ + "任务上下文":"投诉分类:专线质差;问题发生时间:2026-05-11T08:21:46Z;\ + OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的专线时延从平均12ms骤升至320ms"},\ + "slot_errors":[]}"""; + + /** Validate-step response of a semantically compliant prompt with every parameter filled. */ + private static final String VALIDATE_PASS_RESPONSE = + """ + {"semantic_verdict":true,"errors":[],"params":{"accessPort":"P533-珠江旧城-PTN3900-23-TPA1EG24-1",\ + "bizScenario":"专线质差","faultTime":"2026-05-11T08:21:46Z","eventSerialNo":"event-id-20260511-09013"}}"""; + + /** Validate-step response whose two schema-required parameters stay null (the paramsAbsent probes). */ + private static final String VALIDATE_PARTIAL_RESPONSE = + """ + {"semantic_verdict":true,"errors":[],"params":{"accessPort":null,"bizScenario":null,\ + "faultTime":"2026-05-11T08:21:46Z","eventSerialNo":"event-id-20260511-09013"}}"""; + + /** Validate-step response of a semantically contradictory prompt (the semantic-reject probes). */ + private static final String VALIDATE_REJECT_RESPONSE = + """ + {"semantic_verdict":false,"errors":[{"slot_name":"bizScenario","code":"content.semantic_conflict",\ + "facts":{"section_label":"投诉分类","reason":"投诉分类声明为专线质差,投诉详情却描述完全中断"}}],"params":{}}"""; + + /** Hand-authored prompt following the Task-T private-line template structure (the validate family's input). */ + private static final String VALIDATE_PROMPT = + """ + ## 任务类型(Task Type) + 传输专线业务投诉诊断 + + ## 任务描述(Task Description) + 基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。 + + ## 任务目标(Task Target) + 对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。 + + ## 任务对象(Task Object) + 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1 + + ## 任务上下文(Task Context) + 1. 投诉分类:“专线质差” + 2. 问题发生时间:“2026-05-11T08:21:46Z” + 3. OSS侧事件流水号:“event-id-20260511-09013” + 4. 投诉详情:“从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报‘连接超时’。” + + ## 预期输出(Expected Output) + 要求投诉诊断任务的结果包含如下信息: + 1. 诊断结果;参数的取值范围包括:成功、失败;(必选) + 2. 诊断结果详细信息; (必选) + 3. 修复建议; (可选)"""; + + @TempDir + Path tempDir; + + @Test + void generateCaseRunsTheClosedLoopAndAssertsTheKeyFields() { + LiveTranscript.Run run = LiveTranscript.createRun(tempDir); + LiveCaseEngine engine = new LiveCaseEngine( + new StepStubClient(List.of(SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), 2, null, run); + LiveCase testCase = generateCase(new LiveExpectation( + true, + "private-line-complaint", + Map.of("accessPort", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "bizScenario", "专线质差"), + List.of(), + List.of("## 任务类型", "event-id-20260511-09013"), + 4)); + + LiveCaseResult result = engine.run(testCase); + + assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); + assertEquals("private-line-complaint", result.scenarioCode()); + assertTrue(result.inputSummary().contains("深圳访问广州的政企专线"), "the verdict carries the input summary"); + assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", result.params().get("accessPort")); + // The generation case is a mini closed loop: one slot-extraction call plus one validate call. + assertEquals(2, result.llmCalls().size()); + LiveRunSummary summary = run.summary(); + assertEquals(1, summary.totalCases()); + assertEquals(1, summary.passCount()); + assertEquals(2, summary.totalLlmCalls()); + } + + @Test + void validateCaseAssertsParamsContainsAndParamsAbsent() { + LiveTranscript.Run run = LiveTranscript.createRun(tempDir); + LiveCaseEngine engine = + new LiveCaseEngine(new StepStubClient(List.of(VALIDATE_PARTIAL_RESPONSE)), 2, null, run); + LiveCase testCase = validateCase(new LiveExpectation( + true, + null, + Map.of("faultTime", "2026-05-11T08:21:46Z", "eventSerialNo", "event-id-20260511-09013"), + List.of("accessPort", "bizScenario"), + List.of(), + 3)); + + LiveCaseResult result = engine.run(testCase); + + assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); + assertEquals(1, result.llmCalls().size()); + assertNull(result.params().get("accessPort")); + assertEquals("event-id-20260511-09013", result.params().get("eventSerialNo")); + assertEquals(1, run.summary().passCount()); + } + + @Test + void expectedFailureVerdictPassesWhenThePipelineRejects() { + LiveTranscript.Run run = LiveTranscript.createRun(tempDir); + LiveCaseEngine engine = new LiveCaseEngine(new StepStubClient(List.of(VALIDATE_REJECT_RESPONSE)), 2, null, run); + LiveCase testCase = validateCase(new LiveExpectation(false, null, Map.of(), List.of(), List.of(), 3)); + + LiveCaseResult result = engine.run(testCase); + + assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); + assertEquals(1, result.llmCalls().size()); + assertNull(result.params()); + assertEquals(1, run.summary().passCount()); + } + + @Test + void assertionMismatchFailsWithTheCaseDiff() { + LiveTranscript.Run run = LiveTranscript.createRun(tempDir); + LiveCaseEngine engine = new LiveCaseEngine( + new StepStubClient(List.of(SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), 2, null, run); + LiveCase testCase = generateCase(new LiveExpectation( + true, null, Map.of("accessPort", "P999-不存在-PTN0000-0-TPA1EG24-0"), List.of(), List.of(), 4)); + + AssertionError failure = + assertThrows(AssertionError.class, () -> engine.run(testCase), "a paramsContains mismatch is a red"); + + assertTrue(failure.getMessage().contains(testCase.errorPrefix()), failure.getMessage()); + assertTrue(failure.getMessage().contains("$.expect.paramsContains"), failure.getMessage()); + assertEquals(1, run.summary().failCount(), "the failed verdict still lands in the transcript"); + } + + @Test + void infrastructureFailuresRetryOnFreshClients() { + LiveTranscript.Run run = LiveTranscript.createRun(tempDir); + LiveCaseEngine engine = new LiveCaseEngine( + new StepStubClient(List.of( + new LLMRuntimeError("endpoint unreachable"), SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), + 2, + null, + run); + LiveCase testCase = + generateCase(new LiveExpectation(true, null, Map.of("bizScenario", "专线质差"), List.of(), List.of(), 4)); + + LiveCaseResult result = engine.run(testCase); + + assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); + // The failed attempt's call is kept in the transcript while the judged attempt made the two closed-loop calls. + assertEquals(3, result.llmCalls().size()); + assertEquals(1, run.summary().passCount()); + assertEquals(3, run.summary().totalLlmCalls()); + } + + @Test + void exhaustedInfrastructureRetriesRecordAnErrorAndStayRed() { + LiveTranscript.Run run = LiveTranscript.createRun(tempDir); + LiveCaseEngine engine = new LiveCaseEngine( + new StepStubClient(List.of(new LLMRuntimeError("endpoint unreachable"))), 0, null, run); + LiveCase testCase = + generateCase(new LiveExpectation(true, null, Map.of("bizScenario", "专线质差"), List.of(), List.of(), 4)); + + assertThrows(RuntimeException.class, () -> engine.run(testCase), "a surviving infra failure stays a red"); + + // The transcript verdict matches the build verdict: the rethrown failure is an ERROR, not a skip. + assertEquals(1, run.summary().errorCount(), "the given-up case is recorded as ERROR"); + assertEquals(1, run.summary().totalLlmCalls()); + } + + // ------------------------------------------------------------------ fixtures + + private static LiveCase generateCase(LiveExpectation expect) { + return new LiveCase( + "LIVE-TEST-GEN/zh-CN", + "LIVE-TEST-GEN", + "live/task-apis.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + "zh-CN", + "P0", + List.of("live"), + "engine test case of the generate API", + null, + "Task-T/network-layer/private-line-complaint/v1", + "深圳访问广州的政企专线自5月11日起时延骤升,柜面交易频繁超时。" + "接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1;投诉分类:专线质差。", + null, + complaintParamsSchema(), + expect); + } + + private static LiveCase validateCase(LiveExpectation expect) { + return new LiveCase( + "LIVE-TEST-VAL/zh-CN", + "LIVE-TEST-VAL", + "live/task-apis.json", + NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, + "zh-CN", + "P0", + List.of("live"), + "engine test case of the validate API", + null, + "Task-T/network-layer/private-line-complaint/v1", + null, + new PromptSource.Text(VALIDATE_PROMPT), + complaintParamsSchema(), + expect); + } + + /** The real shared complaint schema of the corpus, so the engine tests judge against the actual validate step. */ + private static JsonNode complaintParamsSchema() { + try (InputStream stream = + LiveCaseEngineTest.class.getResourceAsStream("/negotiation-cases/shared/schemas.json")) { + assertNotNull(stream, "the shared schemas of the corpus must be on the test classpath"); + return MAPPER.readTree(stream).path("biz.complaint.params"); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to read the shared corpus schemas", exception); + } + } + + /** + * Step-playing stub client: it answers the canned response (or rethrows the canned exception) of the next script + * step and repeats the last step once the script is exhausted — the offline stand-in for the live endpoint. + */ + private static final class StepStubClient implements LLMClient { + + private final List steps; + + private int cursor; + + StepStubClient(List steps) { + this.steps = List.copyOf(steps); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + Object step = steps.get(Math.min(cursor++, steps.size() - 1)); + if (step instanceof RuntimeException error) { + throw error; + } + return new LLMResponse((String) step, "stub-model", Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java index bed67d3d..3e4abee4 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java @@ -1,1003 +1,1130 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -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 java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Smoke test of {@link NegotiationCaseLoader}: a good corpus loads and expands correctly and every format violation - * fails fast with an error naming the file, the record id and the defect. - * - *

        All corpora are written into a temporary directory, so the test does not depend on any corpus file existing. - */ -class NegotiationCaseLoaderTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String SHARED_RESPONSES = """ - { - "extract.information.accept.full": "{\\"conclusion\\": \\"Accept\\", \\"items\\": []}", - "extract.information.propose.full": "{\\"items\\": [{\\"name\\": \\"节能区域信息\\", \\"value\\": \\"松山湖\\"}]}" - } - """; - - private static final String SHARED_SCHEMAS = """ - { - "flat": {"type": "object", "properties": {"id": {"type": "string"}}} - } - """; - - // ------------------------------------------------------------------ happy path - - @Test - void loadsAndExpandsAGoodCorpus(@TempDir Path root) throws IOException { - writeGoodCorpus(root); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - assertEquals(3, corpus.cases().size(), "FT-HAPPY-01 expands to two languages, VAL-HAPPY-01 to one"); - assertEquals(1, corpus.scenarios().size()); - - NegotiationCase acceptZh = caseById(corpus, "FT-HAPPY-01/zh-CN"); - NegotiationCase acceptEn = caseById(corpus, "FT-HAPPY-01/en-US"); - NegotiationCase validateZh = caseById(corpus, "VAL-HAPPY-01/zh-CN"); - - assertEquals("FT-HAPPY-01", acceptZh.baseId()); - assertEquals("from-text/happy.json", acceptZh.sourceFile()); - assertEquals(NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, acceptZh.api()); - assertEquals("zh-CN", acceptZh.language()); - assertEquals("P1", acceptZh.priority()); - assertEquals("我确认第一阶段的信息。", acceptZh.inputText()); - assertEquals("I confirm the first-stage information.", acceptEn.inputText()); - assertEquals(new ContextSpec(SESSION_ID, 2, 5), acceptZh.context()); - assertTrue(acceptZh.inputData().isObject(), "the differential typed input data travels as a JSON node"); - assertTrue(acceptZh.expect().success()); - assertTrue(acceptZh.expect().differential()); - assertEquals(2, acceptZh.expect().llmCalls()); - assertEquals("information_accept", acceptZh.expect().promptTextEqualsGolden()); - assertEquals("Negotiation-T/information-negotiation/accept-reject/v1", - acceptZh.expect().metadata().templateUriEcho()); - assertEquals(Boolean.TRUE, acceptZh.expect().metadata().contextEcho()); - - LlmScriptStep firstStep = acceptZh.llm().steps().get(0); - LlmScriptStep.Fail failStep = assertInstanceOf(LlmScriptStep.Fail.class, firstStep); - assertEquals(LlmFailMarker.NON_JSON, failStep.marker()); - LlmScriptStep.Payload payloadStep = assertInstanceOf(LlmScriptStep.Payload.class, acceptZh.llm().steps().get(1)); - assertEquals("{\"conclusion\": \"Accept\", \"items\": []}", payloadStep.json()); - assertEquals(3, acceptZh.llm().maxAttempts()); - - assertEquals(NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, validateZh.api()); - PromptSource.Golden golden = assertInstanceOf(PromptSource.Golden.class, validateZh.prompt()); - assertEquals("information_propose", golden.golden()); - assertEquals(corpus.sharedSchemas().get("flat"), validateZh.schema()); - assertEquals(Map.of("id", SESSION_ID, "round", 1, "maxRounds", 5), validateZh.expect().params()); - - ScenarioCase scenario = corpus.scenarios().get(0); - assertEquals("SC-INFO-01/zh-CN", scenario.id()); - assertEquals("SC-INFO-01", scenario.baseId()); - assertEquals(2, scenario.steps().size()); - assertEquals(1, scenario.steps().get(0).step()); - assertEquals("A", scenario.steps().get(0).role()); - assertEquals("SC-INFO-01/zh-CN#step-1", scenario.steps().get(0).caseData().id()); - assertEquals(NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, scenario.steps().get(0).caseData().api()); - PromptSource.FromStep fromStep = assertInstanceOf(PromptSource.FromStep.class, scenario.steps().get(1).caseData().prompt()); - assertEquals(1, fromStep.step()); - assertEquals("reject", scenario.expectFlow().terminalCondition()); - assertEquals(1, scenario.expectFlow().roundsUsed()); - assertEquals(Boolean.TRUE, scenario.expectFlow().distinctMessages()); - - NegotiationCase failedStep = scenario.steps().get(1).caseData(); - assertFalse(failedStep.expect().success()); - assertEquals("negotiation_semantic_rejected", failedStep.expect().code()); - assertEquals(1, failedStep.expect().llmCalls()); - LlmScriptStep.Fail llmErrorStep = assertInstanceOf(LlmScriptStep.Fail.class, failedStep.llm().steps().get(0)); - assertEquals(LlmFailMarker.LLM_ERROR, llmErrorStep.marker()); - } - - @Test - void skipsTheSchemaFileAndLoadsAnEmptyCorpus(@TempDir Path root) throws IOException { - write(root, "corpus-schema.json", "{\"formatVersion\": 1}"); - write(root, "shared/llm-responses.json", SHARED_RESPONSES); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - assertTrue(corpus.cases().isEmpty()); - assertTrue(corpus.scenarios().isEmpty()); - assertEquals(2, corpus.sharedResponses().size(), "the shared files load even without any case file"); - } - - @Test - void rejectsAMissingCorpusRoot(@TempDir Path root) { - CorpusLoadException exception = - assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root.resolve("nope"))); - - assertTrue(exception.getMessage().contains("not an existing directory")); - } - - // ------------------------------------------------------------------ fail-fast violations - - @Test - void failsFastOnAnUnknownKey(@TempDir Path root) throws IOException { - write(root, "from-text/bad.json", """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "expect": {"outcome": "success", "expection": "Oops"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("from-text/bad.json"), exception.getMessage()); - assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); - assertTrue(exception.getMessage().contains("expection"), exception.getMessage()); - assertTrue(exception.getMessage().contains("expect"), exception.getMessage()); - } - - @Test - void failsFastOnADanglingRef(@TempDir Path root) throws IOException { - write(root, "from-text/bad.json", """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "llm": {"script": [{"$ref": "responses/missing.payload"}]}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("dangling"), exception.getMessage()); - assertTrue(exception.getMessage().contains("missing.payload"), exception.getMessage()); - assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); - } - - @Test - void failsFastOnAnOutOfScopeRef(@TempDir Path root) throws IOException { - write(root, "shared/schemas.json", SHARED_SCHEMAS); - write(root, "from-text/bad.json", """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "llm": {"script": [{"$ref": "schemas/flat"}]}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("out of scope"), exception.getMessage()); - assertTrue(exception.getMessage().contains("responses/"), exception.getMessage()); - } - - @Test - void failsFastOnANestedRef(@TempDir Path root) throws IOException { - write(root, "shared/schemas.json", """ - { - "a": {"$ref": "schemas/b"}, - "b": {"type": "object"} - } - """); - write(root, "validate/bad.json", """ - [ - { - "id": "VAL-BAD-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": {"text": "## 提议"}, - "schema": {"$ref": "schemas/a"}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("nested $ref"), exception.getMessage()); - assertTrue(exception.getMessage().contains("VAL-BAD-01"), exception.getMessage()); - } - - @Test - void failsFastOnACircularRef(@TempDir Path root) throws IOException { - write(root, "shared/schemas.json", """ - { - "a": {"$ref": "schemas/a"} - } - """); - write(root, "validate/bad.json", """ - [ - { - "id": "VAL-BAD-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": {"text": "## 提议"}, - "schema": {"$ref": "schemas/a"}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("circular"), exception.getMessage()); - } - - @Test - void failsFastOnADuplicateId(@TempDir Path root) throws IOException { - write(root, "from-text/one.json", minimalCase("FT-DUP-01")); - write(root, "from-text/two.json", minimalCase("FT-DUP-01")); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("duplicate id 'FT-DUP-01'"), exception.getMessage()); - assertTrue(exception.getMessage().contains("from-text/one.json"), exception.getMessage()); - } - - @Test - void failsFastOnADuplicateExpandedId(@TempDir Path root) throws IOException { - write(root, "from-text/bad.json", """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "zh-CN"], - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("duplicate language"), exception.getMessage()); - } - - @Test - void failsFastOnAnIncompleteFailureExpectation(@TempDir Path root) throws IOException { - write(root, "from-text/bad.json", """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "expect": {"outcome": "failure"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("failure expectation must name"), exception.getMessage()); - assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); - } - - @Test - void failsFastOnFailureOnlyFieldsOnASuccessExpectation(@TempDir Path root) throws IOException { - write(root, "from-text/bad.json", """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "expect": {"outcome": "success", "code": "negotiation_invalid_input"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("success expectation must not carry failure-only fields"), - exception.getMessage()); - } - - @Test - void failsFastOnAnUnknownApi(@TempDir Path root) throws IOException { - write(root, "from-text/bad.json", """ - [ - { - "id": "FT-BAD-01", - "api": "generateWhateverFromText", - "languages": ["zh-CN"], - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("unknown api 'generateWhateverFromText'"), exception.getMessage()); - assertTrue(exception.getMessage().contains("generateAcceptFromText"), exception.getMessage()); - } - - @Test - void failsFastOnAnUnknownFailMarker(@TempDir Path root) throws IOException { - write(root, "from-text/bad.json", """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "llm": {"script": [{"$fail": "explodes"}]}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("unknown $fail marker 'explodes'"), exception.getMessage()); - assertTrue(exception.getMessage().contains("non-json"), exception.getMessage()); - } - - @Test - void failsFastOnAnUnsupportedLanguage(@TempDir Path root) throws IOException { - write(root, "from-text/bad.json", """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh_CN"], - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("unsupported language 'zh_CN'"), exception.getMessage()); - } - - @Test - void failsFastOnAMissingLanguageTextEntry(@TempDir Path root) throws IOException { - write(root, "from-text/bad.json", """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "en-US"], - "input": {"text": {"zh-CN": "我确认"}}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("missing the 'en-US' text entry"), exception.getMessage()); - } - - @Test - void failsFastOnNonConsecutiveScenarioSteps(@TempDir Path root) throws IOException { - write(root, "scenarios/bad.json", """ - [ - { - "id": "SC-BAD-01", - "languages": ["zh-CN"], - "steps": [ - { - "step": 2, - "api": "generateProposeFromText", - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "expect": {"outcome": "success"} - } - ] - } - ] - """.formatted(SESSION_ID)); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("consecutively from 1"), exception.getMessage()); - assertTrue(exception.getMessage().contains("SC-BAD-01"), exception.getMessage()); - } - - @Test - void failsFastOnAnUnknownKeyInsideAScenarioStep(@TempDir Path root) throws IOException { - write(root, "scenarios/bad.json", """ - [ - { - "id": "SC-BAD-01", - "languages": ["zh-CN"], - "steps": [ - { - "step": 1, - "api": "generateProposeFromText", - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "rol": "A", - "expect": {"outcome": "success"} - } - ] - } - ] - """.formatted(SESSION_ID)); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("rol"), exception.getMessage()); - assertTrue(exception.getMessage().contains("SC-BAD-01"), exception.getMessage()); - } - - // ------------------------------------------------------------------ task family format (Q21–Q23) - - @Test - void loadsTaskFamilyRecordsWithTheClosedLoopExpectationFields(@TempDir Path root) throws IOException { - write(root, "shared/llm-responses.json", SHARED_RESPONSES); - write(root, "task/happy.json", """ - [ - { - "id": "TASK-FT-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "summary": "工作台从原始投诉文本生成缺参任务报文", - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"} - }, - "llm": {"script": ["{\\"slots\\": {\\"任务对象\\": \\"\\", \\"任务上下文\\": \\"投诉分类:待补充\\"}, \\"slot_errors\\": []}"]}, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["## 任务类型", "## 任务对象"] - } - } - ] - """); - write(root, "scenarios/task-closed-loop.json", """ - [ - { - "id": "SC-TASK-01", - "languages": ["zh-CN"], - "summary": "任务缺参发现与补参提取的因果闭环", - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateTaskPromptFromText", - "context": null, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"} - }, - "llm": {"script": ["{\\"slots\\": {\\"任务对象\\": \\"\\", \\"任务上下文\\": \\"投诉分类:待补充\\"}, \\"slot_errors\\": []}"]}, - "expect": {"outcome": "success", "llmCalls": 1} - }, - { - "step": 2, - "role": "B", - "api": "validateTaskPromptAndDataFilling", - "context": null, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": {"fromStep": 1}, - "schema": {"type": "object", "properties": {"accessPort": {"type": "string"}}}, - "llm": {"script": ["{\\"semantic_verdict\\":true,\\"errors\\":[],\\"params\\":{\\"accessPort\\":null}}"]}, - "expect": { - "outcome": "success", - "llmCalls": 1, - "missingParams": ["accessPort"], - "params": {"faultTime": "2026-05-11T08:21:46Z"} - } - }, - { - "step": 3, - "role": "B", - "api": "validateAcceptPromptAndDataFilling", - "context": {"id": "%s", "round": 2, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": {"fromStep": 1}, - "schema": {"type": "object"}, - "llm": {"script": ["{\\"semantic_verdict\\":true,\\"errors\\":[],\\"params\\":{}}"]}, - "expect": {"outcome": "success", "llmCalls": 1, "paramsFromStep": 2} - } - ], - "expectFlow": {"terminalCondition": "accept", "missingParamsFilled": 2} - } - ] - """.formatted(SESSION_ID)); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - NegotiationCase taskCase = caseById(corpus, "TASK-FT-01/zh-CN"); - assertEquals(NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, taskCase.api()); - assertEquals(List.of("## 任务类型", "## 任务对象"), taskCase.expect().promptTextContains()); - - ScenarioCase scenario = corpus.scenarios().get(0); - assertEquals( - Map.of("A", "工作台(client,任务发起/补数方)", "B", "OMC(server,执行/要数方,协商发起方)"), - scenario.rolesDesc()); - assertEquals("A=工作台(client,任务发起/补数方)", scenario.describeRole("A")); - NegotiationCase validationStep = scenario.steps().get(1).caseData(); - assertEquals(NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, validationStep.api()); - assertEquals(List.of("accessPort"), validationStep.expect().missingParams()); - assertEquals(Map.of("faultTime", "2026-05-11T08:21:46Z"), validationStep.expect().params()); - assertEquals(2, scenario.steps().get(2).caseData().expect().paramsFromStep()); - assertEquals(2, scenario.expectFlow().missingParamsFilled()); - } - - @Test - void failsFastOnTaskSuccessFieldsOnAFailureExpectation(@TempDir Path root) throws IOException { - write(root, "task/bad.json", """ - [ - { - "id": "TASK-BAD-01", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": {"text": "## 任务类型(Task Type)"}, - "schema": {"type": "object"}, - "llm": {"script": ["{}"]}, - "expect": { - "outcome": "failure", - "code": "validation_semantic_rejected", - "missingParams": ["accessPort"] - } - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("success-only fields"), exception.getMessage()); - assertTrue(exception.getMessage().contains("missingParams"), exception.getMessage()); - } - - @Test - void failsFastOnAJsonNullInsideParams(@TempDir Path root) throws IOException { - write(root, "task/bad.json", """ - [ - { - "id": "TASK-BAD-02", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": {"text": "## 任务类型(Task Type)"}, - "schema": {"type": "object"}, - "llm": {"script": ["{}"]}, - "expect": { - "outcome": "success", - "params": {"accessPort": null} - } - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue( - exception.getMessage().contains("accessPort") - && exception.getMessage().contains("missingParams"), - "a JSON null param must point at missingParams but was: " + exception.getMessage()); - } - - @Test - void failsFastOnParamsFromStepBelowOne(@TempDir Path root) throws IOException { - write(root, "task/bad.json", """ - [ - { - "id": "TASK-BAD-03", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": {"text": "## 任务类型(Task Type)"}, - "schema": {"type": "object"}, - "llm": {"script": ["{}"]}, - "expect": { - "outcome": "success", - "paramsFromStep": 0 - } - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("paramsFromStep"), exception.getMessage()); - } - - @Test - void failsFastOnARolesDescEntryOutsideTheDeclaredRoles(@TempDir Path root) throws IOException { - write(root, "scenarios/bad.json", """ - [ - { - "id": "SC-BAD-02", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": {"C": "OSS(第三方)"}, - "steps": [ - { - "step": 1, - "api": "generateProposeFromText", - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "expect": {"outcome": "success"} - } - ] - } - ] - """.formatted(SESSION_ID)); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue( - exception.getMessage().contains("rolesDesc") - && exception.getMessage().contains("'C'"), - exception.getMessage()); - } - - // ------------------------------------------------------------------ live family (live LLM phase 1) - - @Test - void loadsLiveRecordsIntoTheDedicatedLiveCasesList(@TempDir Path root) throws IOException { - write(root, "live/generate.json", """ - [ - { - "id": "LIVE-GEN-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["live", "scenario-recognition"], - "summary": "明确场景的典型投诉文本", - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"}}, - "expect": { - "success": true, - "scenarioCode": "private-line-complaint", - "paramsContains": {"accessPort": "P533-01"}, - "paramsAbsent": ["faultTime"], - "promptTextContains": ["## instruction", "event-id-20260511-09013"], - "maxLlmCalls": 4 - } - } - ] - """); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - assertEquals(1, corpus.liveCases().size(), "the live record expands once and lands in liveCases"); - assertTrue(corpus.cases().isEmpty(), "a live record never mixes into the offline cases list"); - LiveCase live = corpus.liveCases().get(0); - assertEquals("LIVE-GEN-01/zh-CN", live.id()); - assertEquals("LIVE-GEN-01", live.baseId()); - assertEquals("live/generate.json", live.sourceFile()); - assertEquals(NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, live.api()); - assertEquals("zh-CN", live.language()); - assertEquals("P0", live.priority()); - assertEquals(List.of("live", "scenario-recognition"), live.tags()); - assertEquals("深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。", live.inputText()); - assertTrue(live.liveExpect().success()); - assertEquals("private-line-complaint", live.liveExpect().scenarioCode()); - assertEquals(Map.of("accessPort", "P533-01"), live.liveExpect().paramsContains()); - assertEquals(List.of("faultTime"), live.liveExpect().paramsAbsent()); - assertEquals(List.of("## instruction", "event-id-20260511-09013"), live.liveExpect().promptTextContains()); - assertEquals(4, live.liveExpect().maxLlmCalls()); - assertEquals("live/generate.json [LIVE-GEN-01/zh-CN]", live.errorPrefix()); - } - - @Test - void failsFastOnALiveRecordWithoutTheLiveIdPrefix(@TempDir Path root) throws IOException { - write(root, "live/bad.json", """ - [ - { - "id": "TASK-LIVE-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("LIVE-"), exception.getMessage()); - assertTrue(exception.getMessage().contains("TASK-LIVE-01"), exception.getMessage()); - } - - @Test - void failsFastOnALiveRecordOutsideThePhaseOneLanguages(@TempDir Path root) throws IOException { - write(root, "live/bad.json", """ - [ - { - "id": "LIVE-BAD-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN", "en-US"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("exactly the languages [zh-CN]"), exception.getMessage()); - } - - @Test - void failsFastOnALiveRecordOutsideTheTaskFamily(@TempDir Path root) throws IOException { - write(root, "live/bad.json", """ - [ - { - "id": "LIVE-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("live phase 1 supports only"), exception.getMessage()); - assertTrue(exception.getMessage().contains("generateTaskPromptFromText"), exception.getMessage()); - assertTrue(exception.getMessage().contains("validateTaskPromptAndDataFilling"), exception.getMessage()); - } - - @Test - void failsFastOnALiveExpectationWithoutSuccess(@TempDir Path root) throws IOException { - write(root, "live/bad.json", """ - [ - { - "id": "LIVE-BAD-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升。"}}, - "expect": {"scenarioCode": "private-line-complaint"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("missing required field 'success'"), exception.getMessage()); - } - - @Test - void failsFastOnALiveGenerateRecordWithoutInputText(@TempDir Path root) throws IOException { - write(root, "live/bad.json", """ - [ - { - "id": "LIVE-BAD-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("input.text"), exception.getMessage()); - assertTrue(exception.getMessage().contains("generateTaskPromptFromText"), exception.getMessage()); - } - - @Test - void failsFastOnALiveRecordWithAGoldenOrFromStepPrompt(@TempDir Path root) throws IOException { - write(root, "live/bad.json", """ - [ - { - "id": "LIVE-BAD-01", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "prompt": {"golden": "task-happy-zh-CN"}, - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("only the inline prompt.text"), exception.getMessage()); - assertTrue(exception.getMessage().contains("golden"), exception.getMessage()); - } - - @Test - void failsFastOnALiveValidateRecordDeclaringPromptTextContains(@TempDir Path root) throws IOException { - write(root, "live/bad.json", """ - [ - { - "id": "LIVE-BAD-01", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "prompt": {"text": "## 任务类型(Task Type)"}, - "expect": {"success": true, "promptTextContains": ["## 任务类型"]} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("promptTextContains"), exception.getMessage()); - assertTrue(exception.getMessage().contains("no generated prompt"), exception.getMessage()); - } - - @Test - void defaultsTheLiveMaxLlmCallsToFourWhenTheRecordOmitsIt(@TempDir Path root) throws IOException { - write(root, "live/generate.json", """ - [ - { - "id": "LIVE-GEN-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升。"}}, - "expect": {"success": true} - } - ] - """); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - assertEquals(4, corpus.liveCases().get(0).liveExpect().maxLlmCalls(), "maxLlmCalls defaults to 4"); - } - - @Test - void failsFastOnALiveIdCollidingWithAnOfflineCase(@TempDir Path root) throws IOException { - write(root, "from-text/dup.json", minimalCase("LIVE-DUP-01")); - write(root, "live/dup.json", """ - [ - { - "id": "LIVE-DUP-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("duplicate id 'LIVE-DUP-01'"), exception.getMessage()); - assertTrue(exception.getMessage().contains("from-text/dup.json"), exception.getMessage()); - } - - // ------------------------------------------------------------------ helpers - - private static void write(Path root, String relative, String content) throws IOException { - Path file = root.resolve(relative); - if (file.getParent() != null) { - Files.createDirectories(file.getParent()); - } - Files.writeString(file, content, StandardCharsets.UTF_8); - } - - private static void writeGoodCorpus(Path root) throws IOException { - write(root, "shared/llm-responses.json", SHARED_RESPONSES); - write(root, "shared/schemas.json", SHARED_SCHEMAS); - write(root, "from-text/happy.json", """ - [ - { - "id": "FT-HAPPY-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["happy", "accept-reject"], - "summary": "信息确认后接受,成功", - "context": {"id": "%s", "round": 2, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": {"zh-CN": "我确认第一阶段的信息。", "en-US": "I confirm the first-stage information."}, - "data": {"items": [], "relationship": null} - }, - "llm": { - "maxAttempts": 3, - "script": [{"$fail": "non-json"}, {"$ref": "responses/extract.information.accept.full"}] - }, - "expect": { - "outcome": "success", - "llmCalls": 2, - "promptTextEqualsGolden": "information_accept", - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent"], - "differential": true - } - } - ] - """.formatted(SESSION_ID)); - write(root, "validate/happy.json", """ - [ - { - "id": "VAL-HAPPY-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": {"golden": "information_propose"}, - "schema": {"$ref": "schemas/flat"}, - "llm": {"script": ["{\\"verdict\\": true}"]}, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": {"id": "%s", "round": 1, "maxRounds": 5} - } - } - ] - """.formatted(SESSION_ID, SESSION_ID)); - write(root, "scenarios/flows.json", """ - [ - { - "id": "SC-INFO-01", - "summary": "提议后语义拒绝", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateProposeFromText", - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": {"text": {"zh-CN": "请提供节能区域信息"}}, - "llm": {"script": [{"$ref": "responses/extract.information.propose.full"}]}, - "expect": {"outcome": "success", "llmCalls": 1} - }, - { - "step": 2, - "role": "B", - "api": "validateProposePromptAndDataFilling", - "prompt": {"fromStep": 1}, - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": {"$ref": "schemas/flat"}, - "llm": {"script": [{"$fail": "llm-error"}]}, - "expect": {"outcome": "failure", "code": "negotiation_semantic_rejected", "llmCalls": 1} - } - ], - "expectFlow": {"terminalCondition": "reject", "roundsUsed": 1, "distinctMessages": true} - } - ] - """.formatted(SESSION_ID, SESSION_ID)); - } - - private static String minimalCase(String id) { - return """ - [ - { - "id": "%s", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "context": {"id": "%s", "round": 2, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": {"text": {"zh-CN": "我确认"}}, - "llm": {"script": ["{}"]}, - "expect": {"outcome": "success"} - } - ] - """.formatted(id, SESSION_ID); - } - - private static NegotiationCase caseById(LoadedCorpus corpus, String id) { - NegotiationCase found = null; - for (NegotiationCase candidate : corpus.cases()) { - if (candidate.id().equals(id)) { - found = candidate; - } - } - assertNotNull(found, "the corpus must expand the case " + id); - return found; - } -} +package net.openan.a2at.sdk.corpus; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Smoke test of {@link NegotiationCaseLoader}: a good corpus loads and expands correctly and every format violation + * fails fast with an error naming the file, the record id and the defect. + * + *

        All corpora are written into a temporary directory, so the test does not depend on any corpus file existing. + */ +class NegotiationCaseLoaderTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String SHARED_RESPONSES = + """ + { + "extract.information.accept.full": "{\\"conclusion\\": \\"Accept\\", \\"items\\": []}", + "extract.information.propose.full": "{\\"items\\": [{\\"name\\": \\"节能区域信息\\", \\"value\\": \\"松山湖\\"}]}" + } + """; + + private static final String SHARED_SCHEMAS = + """ + { + "flat": {"type": "object", "properties": {"id": {"type": "string"}}} + } + """; + + // ------------------------------------------------------------------ happy path + + @Test + void loadsAndExpandsAGoodCorpus(@TempDir Path root) throws IOException { + writeGoodCorpus(root); + + LoadedCorpus corpus = NegotiationCaseLoader.load(root); + + assertEquals(3, corpus.cases().size(), "FT-HAPPY-01 expands to two languages, VAL-HAPPY-01 to one"); + assertEquals(1, corpus.scenarios().size()); + + NegotiationCase acceptZh = caseById(corpus, "FT-HAPPY-01/zh-CN"); + NegotiationCase acceptEn = caseById(corpus, "FT-HAPPY-01/en-US"); + NegotiationCase validateZh = caseById(corpus, "VAL-HAPPY-01/zh-CN"); + + assertEquals("FT-HAPPY-01", acceptZh.baseId()); + assertEquals("from-text/happy.json", acceptZh.sourceFile()); + assertEquals(NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, acceptZh.api()); + assertEquals("zh-CN", acceptZh.language()); + assertEquals("P1", acceptZh.priority()); + assertEquals("我确认第一阶段的信息。", acceptZh.inputText()); + assertEquals("I confirm the first-stage information.", acceptEn.inputText()); + assertEquals(new ContextSpec(SESSION_ID, 2, 5), acceptZh.context()); + assertTrue(acceptZh.inputData().isObject(), "the differential typed input data travels as a JSON node"); + assertTrue(acceptZh.expect().success()); + assertTrue(acceptZh.expect().differential()); + assertEquals(2, acceptZh.expect().llmCalls()); + assertEquals("information_accept", acceptZh.expect().promptTextEqualsGolden()); + assertEquals( + "Negotiation-T/information-negotiation/accept-reject/v1", + acceptZh.expect().metadata().templateUriEcho()); + assertEquals(Boolean.TRUE, acceptZh.expect().metadata().contextEcho()); + + LlmScriptStep firstStep = acceptZh.llm().steps().get(0); + LlmScriptStep.Fail failStep = assertInstanceOf(LlmScriptStep.Fail.class, firstStep); + assertEquals(LlmFailMarker.NON_JSON, failStep.marker()); + LlmScriptStep.Payload payloadStep = assertInstanceOf( + LlmScriptStep.Payload.class, acceptZh.llm().steps().get(1)); + assertEquals("{\"conclusion\": \"Accept\", \"items\": []}", payloadStep.json()); + assertEquals(3, acceptZh.llm().maxAttempts()); + + assertEquals(NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, validateZh.api()); + PromptSource.Golden golden = assertInstanceOf(PromptSource.Golden.class, validateZh.prompt()); + assertEquals("information_propose", golden.golden()); + assertEquals(corpus.sharedSchemas().get("flat"), validateZh.schema()); + assertEquals( + Map.of("id", SESSION_ID, "round", 1, "maxRounds", 5), + validateZh.expect().params()); + + ScenarioCase scenario = corpus.scenarios().get(0); + assertEquals("SC-INFO-01/zh-CN", scenario.id()); + assertEquals("SC-INFO-01", scenario.baseId()); + assertEquals(2, scenario.steps().size()); + assertEquals(1, scenario.steps().get(0).step()); + assertEquals("A", scenario.steps().get(0).role()); + assertEquals( + "SC-INFO-01/zh-CN#step-1", scenario.steps().get(0).caseData().id()); + assertEquals( + NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, + scenario.steps().get(0).caseData().api()); + PromptSource.FromStep fromStep = assertInstanceOf( + PromptSource.FromStep.class, scenario.steps().get(1).caseData().prompt()); + assertEquals(1, fromStep.step()); + assertEquals("reject", scenario.expectFlow().terminalCondition()); + assertEquals(1, scenario.expectFlow().roundsUsed()); + assertEquals(Boolean.TRUE, scenario.expectFlow().distinctMessages()); + + NegotiationCase failedStep = scenario.steps().get(1).caseData(); + assertFalse(failedStep.expect().success()); + assertEquals("negotiation.semantic_rejected", failedStep.expect().code()); + assertEquals(1, failedStep.expect().llmCalls()); + LlmScriptStep.Fail llmErrorStep = assertInstanceOf( + LlmScriptStep.Fail.class, failedStep.llm().steps().get(0)); + assertEquals(LlmFailMarker.LLM_ERROR, llmErrorStep.marker()); + } + + @Test + void skipsTheSchemaFileAndLoadsAnEmptyCorpus(@TempDir Path root) throws IOException { + write(root, "corpus-schema.json", "{\"formatVersion\": 1}"); + write(root, "shared/llm-responses.json", SHARED_RESPONSES); + + LoadedCorpus corpus = NegotiationCaseLoader.load(root); + + assertTrue(corpus.cases().isEmpty()); + assertTrue(corpus.scenarios().isEmpty()); + assertEquals(2, corpus.sharedResponses().size(), "the shared files load even without any case file"); + } + + @Test + void rejectsAMissingCorpusRoot(@TempDir Path root) { + CorpusLoadException exception = + assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root.resolve("nope"))); + + assertTrue(exception.getMessage().contains("not an existing directory")); + } + + // ------------------------------------------------------------------ fail-fast violations + + @Test + void failsFastOnAnUnknownKey(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "expect": {"outcome": "success", "expection": "Oops"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("from-text/bad.json"), exception.getMessage()); + assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); + assertTrue(exception.getMessage().contains("expection"), exception.getMessage()); + assertTrue(exception.getMessage().contains("expect"), exception.getMessage()); + } + + @Test + void failsFastOnADanglingRef(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "llm": {"script": [{"$ref": "responses/missing.payload"}]}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("dangling"), exception.getMessage()); + assertTrue(exception.getMessage().contains("missing.payload"), exception.getMessage()); + assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); + } + + @Test + void failsFastOnAnOutOfScopeRef(@TempDir Path root) throws IOException { + write(root, "shared/schemas.json", SHARED_SCHEMAS); + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "llm": {"script": [{"$ref": "schemas/flat"}]}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("out of scope"), exception.getMessage()); + assertTrue(exception.getMessage().contains("responses/"), exception.getMessage()); + } + + @Test + void failsFastOnANestedRef(@TempDir Path root) throws IOException { + write( + root, + "shared/schemas.json", + """ + { + "a": {"$ref": "schemas/b"}, + "b": {"type": "object"} + } + """); + write( + root, + "validate/bad.json", + """ + [ + { + "id": "VAL-BAD-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": {"text": "## 提议"}, + "schema": {"$ref": "schemas/a"}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("nested $ref"), exception.getMessage()); + assertTrue(exception.getMessage().contains("VAL-BAD-01"), exception.getMessage()); + } + + @Test + void failsFastOnACircularRef(@TempDir Path root) throws IOException { + write( + root, + "shared/schemas.json", + """ + { + "a": {"$ref": "schemas/a"} + } + """); + write( + root, + "validate/bad.json", + """ + [ + { + "id": "VAL-BAD-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": {"text": "## 提议"}, + "schema": {"$ref": "schemas/a"}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("circular"), exception.getMessage()); + } + + @Test + void failsFastOnADuplicateId(@TempDir Path root) throws IOException { + write(root, "from-text/one.json", minimalCase("FT-DUP-01")); + write(root, "from-text/two.json", minimalCase("FT-DUP-01")); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("duplicate id 'FT-DUP-01'"), exception.getMessage()); + assertTrue(exception.getMessage().contains("from-text/one.json"), exception.getMessage()); + } + + @Test + void failsFastOnADuplicateExpandedId(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "zh-CN"], + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("duplicate language"), exception.getMessage()); + } + + @Test + void failsFastOnAnIncompleteFailureExpectation(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "expect": {"outcome": "failure"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("failure expectation must name"), exception.getMessage()); + assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); + } + + @Test + void failsFastOnFailureOnlyFieldsOnASuccessExpectation(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "expect": {"outcome": "success", "code": "negotiation.invalid_input"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue( + exception.getMessage().contains("success expectation must not carry failure-only fields"), + exception.getMessage()); + } + + @Test + void failsFastOnAnUnknownApi(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateWhateverFromText", + "languages": ["zh-CN"], + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("unknown api 'generateWhateverFromText'"), exception.getMessage()); + assertTrue(exception.getMessage().contains("generateAcceptFromText"), exception.getMessage()); + } + + @Test + void failsFastOnAnUnknownFailMarker(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "llm": {"script": [{"$fail": "explodes"}]}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("unknown $fail marker 'explodes'"), exception.getMessage()); + assertTrue(exception.getMessage().contains("non-json"), exception.getMessage()); + } + + @Test + void failsFastOnAnUnsupportedLanguage(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh_CN"], + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("unsupported language 'zh_CN'"), exception.getMessage()); + } + + @Test + void failsFastOnAMissingLanguageTextEntry(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "en-US"], + "input": {"text": {"zh-CN": "我确认"}}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("missing the 'en-US' text entry"), exception.getMessage()); + } + + @Test + void failsFastOnNonConsecutiveScenarioSteps(@TempDir Path root) throws IOException { + write( + root, + "scenarios/bad.json", + """ + [ + { + "id": "SC-BAD-01", + "languages": ["zh-CN"], + "steps": [ + { + "step": 2, + "api": "generateProposeFromText", + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "expect": {"outcome": "success"} + } + ] + } + ] + """ + .formatted(SESSION_ID)); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("consecutively from 1"), exception.getMessage()); + assertTrue(exception.getMessage().contains("SC-BAD-01"), exception.getMessage()); + } + + @Test + void failsFastOnAnUnknownKeyInsideAScenarioStep(@TempDir Path root) throws IOException { + write( + root, + "scenarios/bad.json", + """ + [ + { + "id": "SC-BAD-01", + "languages": ["zh-CN"], + "steps": [ + { + "step": 1, + "api": "generateProposeFromText", + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "rol": "A", + "expect": {"outcome": "success"} + } + ] + } + ] + """ + .formatted(SESSION_ID)); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("rol"), exception.getMessage()); + assertTrue(exception.getMessage().contains("SC-BAD-01"), exception.getMessage()); + } + + // ------------------------------------------------------------------ task family format (Q21–Q23) + + @Test + void loadsTaskFamilyRecordsWithTheClosedLoopExpectationFields(@TempDir Path root) throws IOException { + write(root, "shared/llm-responses.json", SHARED_RESPONSES); + write( + root, + "task/happy.json", + """ + [ + { + "id": "TASK-FT-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "summary": "工作台从原始投诉文本生成缺参任务报文", + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "input": { + "text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"} + }, + "llm": {"script": ["{\\"slots\\": {\\"任务对象\\": \\"\\", \\"任务上下文\\": \\"投诉分类:待补充\\"}, \\"slot_errors\\": []}"]}, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextContains": ["## 任务类型", "## 任务对象"] + } + } + ] + """); + write( + root, + "scenarios/task-closed-loop.json", + """ + [ + { + "id": "SC-TASK-01", + "languages": ["zh-CN"], + "summary": "任务缺参发现与补参提取的因果闭环", + "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateTaskPromptFromText", + "context": null, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "input": { + "text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"} + }, + "llm": {"script": ["{\\"slots\\": {\\"任务对象\\": \\"\\", \\"任务上下文\\": \\"投诉分类:待补充\\"}, \\"slot_errors\\": []}"]}, + "expect": {"outcome": "success", "llmCalls": 1} + }, + { + "step": 2, + "role": "B", + "api": "validateTaskPromptAndDataFilling", + "context": null, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": {"fromStep": 1}, + "schema": {"type": "object", "properties": {"accessPort": {"type": "string"}}}, + "llm": {"script": ["{\\"semantic_verdict\\":true,\\"errors\\":[],\\"params\\":{\\"accessPort\\":null}}"]}, + "expect": { + "outcome": "success", + "llmCalls": 1, + "missingParams": ["accessPort"], + "params": {"faultTime": "2026-05-11T08:21:46Z"} + } + }, + { + "step": 3, + "role": "B", + "api": "validateAcceptPromptAndDataFilling", + "context": {"id": "%s", "round": 2, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": {"fromStep": 1}, + "schema": {"type": "object"}, + "llm": {"script": ["{\\"semantic_verdict\\":true,\\"errors\\":[],\\"params\\":{}}"]}, + "expect": {"outcome": "success", "llmCalls": 1, "paramsFromStep": 2} + } + ], + "expectFlow": {"terminalCondition": "accept", "missingParamsFilled": 2} + } + ] + """ + .formatted(SESSION_ID)); + + LoadedCorpus corpus = NegotiationCaseLoader.load(root); + + NegotiationCase taskCase = caseById(corpus, "TASK-FT-01/zh-CN"); + assertEquals(NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, taskCase.api()); + assertEquals(List.of("## 任务类型", "## 任务对象"), taskCase.expect().promptTextContains()); + + ScenarioCase scenario = corpus.scenarios().get(0); + assertEquals(Map.of("A", "工作台(client,任务发起/补数方)", "B", "OMC(server,执行/要数方,协商发起方)"), scenario.rolesDesc()); + assertEquals("A=工作台(client,任务发起/补数方)", scenario.describeRole("A")); + NegotiationCase validationStep = scenario.steps().get(1).caseData(); + assertEquals(NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, validationStep.api()); + assertEquals(List.of("accessPort"), validationStep.expect().missingParams()); + assertEquals( + Map.of("faultTime", "2026-05-11T08:21:46Z"), + validationStep.expect().params()); + assertEquals(2, scenario.steps().get(2).caseData().expect().paramsFromStep()); + assertEquals(2, scenario.expectFlow().missingParamsFilled()); + } + + @Test + void failsFastOnTaskSuccessFieldsOnAFailureExpectation(@TempDir Path root) throws IOException { + write( + root, + "task/bad.json", + """ + [ + { + "id": "TASK-BAD-01", + "api": "validateTaskPromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": {"text": "## 任务类型(Task Type)"}, + "schema": {"type": "object"}, + "llm": {"script": ["{}"]}, + "expect": { + "outcome": "failure", + "code": "negotiation.semantic_rejected", + "missingParams": ["accessPort"] + } + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("success-only fields"), exception.getMessage()); + assertTrue(exception.getMessage().contains("missingParams"), exception.getMessage()); + } + + @Test + void failsFastOnAJsonNullInsideParams(@TempDir Path root) throws IOException { + write( + root, + "task/bad.json", + """ + [ + { + "id": "TASK-BAD-02", + "api": "validateTaskPromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": {"text": "## 任务类型(Task Type)"}, + "schema": {"type": "object"}, + "llm": {"script": ["{}"]}, + "expect": { + "outcome": "success", + "params": {"accessPort": null} + } + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue( + exception.getMessage().contains("accessPort") + && exception.getMessage().contains("missingParams"), + "a JSON null param must point at missingParams but was: " + exception.getMessage()); + } + + @Test + void failsFastOnParamsFromStepBelowOne(@TempDir Path root) throws IOException { + write( + root, + "task/bad.json", + """ + [ + { + "id": "TASK-BAD-03", + "api": "validateTaskPromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": {"text": "## 任务类型(Task Type)"}, + "schema": {"type": "object"}, + "llm": {"script": ["{}"]}, + "expect": { + "outcome": "success", + "paramsFromStep": 0 + } + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("paramsFromStep"), exception.getMessage()); + } + + @Test + void failsFastOnARolesDescEntryOutsideTheDeclaredRoles(@TempDir Path root) throws IOException { + write( + root, + "scenarios/bad.json", + """ + [ + { + "id": "SC-BAD-02", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "rolesDesc": {"C": "OSS(第三方)"}, + "steps": [ + { + "step": 1, + "api": "generateProposeFromText", + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "expect": {"outcome": "success"} + } + ] + } + ] + """ + .formatted(SESSION_ID)); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue( + exception.getMessage().contains("rolesDesc") + && exception.getMessage().contains("'C'"), + exception.getMessage()); + } + + // ------------------------------------------------------------------ live family (live LLM phase 1) + + @Test + void loadsLiveRecordsIntoTheDedicatedLiveCasesList(@TempDir Path root) throws IOException { + write( + root, + "live/generate.json", + """ + [ + { + "id": "LIVE-GEN-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["live", "scenario-recognition"], + "summary": "明确场景的典型投诉文本", + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"}}, + "expect": { + "success": true, + "scenarioCode": "private-line-complaint", + "paramsContains": {"accessPort": "P533-01"}, + "paramsAbsent": ["faultTime"], + "promptTextContains": ["## instruction", "event-id-20260511-09013"], + "maxLlmCalls": 4 + } + } + ] + """); + + LoadedCorpus corpus = NegotiationCaseLoader.load(root); + + assertEquals(1, corpus.liveCases().size(), "the live record expands once and lands in liveCases"); + assertTrue(corpus.cases().isEmpty(), "a live record never mixes into the offline cases list"); + LiveCase live = corpus.liveCases().get(0); + assertEquals("LIVE-GEN-01/zh-CN", live.id()); + assertEquals("LIVE-GEN-01", live.baseId()); + assertEquals("live/generate.json", live.sourceFile()); + assertEquals(NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, live.api()); + assertEquals("zh-CN", live.language()); + assertEquals("P0", live.priority()); + assertEquals(List.of("live", "scenario-recognition"), live.tags()); + assertEquals("深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。", live.inputText()); + assertTrue(live.liveExpect().success()); + assertEquals("private-line-complaint", live.liveExpect().scenarioCode()); + assertEquals(Map.of("accessPort", "P533-01"), live.liveExpect().paramsContains()); + assertEquals(List.of("faultTime"), live.liveExpect().paramsAbsent()); + assertEquals( + List.of("## instruction", "event-id-20260511-09013"), + live.liveExpect().promptTextContains()); + assertEquals(4, live.liveExpect().maxLlmCalls()); + assertEquals("live/generate.json [LIVE-GEN-01/zh-CN]", live.errorPrefix()); + } + + @Test + void failsFastOnALiveRecordWithoutTheLiveIdPrefix(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "TASK-LIVE-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "expect": {"success": true} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("LIVE-"), exception.getMessage()); + assertTrue(exception.getMessage().contains("TASK-LIVE-01"), exception.getMessage()); + } + + @Test + void failsFastOnALiveRecordOutsideThePhaseOneLanguages(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "LIVE-BAD-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN", "en-US"], + "expect": {"success": true} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("exactly the languages [zh-CN]"), exception.getMessage()); + } + + @Test + void failsFastOnALiveRecordOutsideTheTaskFamily(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "LIVE-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "expect": {"success": true} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("live phase 1 supports only"), exception.getMessage()); + assertTrue(exception.getMessage().contains("generateTaskPromptFromText"), exception.getMessage()); + assertTrue(exception.getMessage().contains("validateTaskPromptAndDataFilling"), exception.getMessage()); + } + + @Test + void failsFastOnALiveExpectationWithoutSuccess(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "LIVE-BAD-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升。"}}, + "expect": {"scenarioCode": "private-line-complaint"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("missing required field 'success'"), exception.getMessage()); + } + + @Test + void failsFastOnALiveGenerateRecordWithoutInputText(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "LIVE-BAD-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "expect": {"success": true} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("input.text"), exception.getMessage()); + assertTrue(exception.getMessage().contains("generateTaskPromptFromText"), exception.getMessage()); + } + + @Test + void failsFastOnALiveRecordWithAGoldenOrFromStepPrompt(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "LIVE-BAD-01", + "api": "validateTaskPromptAndDataFilling", + "languages": ["zh-CN"], + "prompt": {"golden": "task-happy-zh-CN"}, + "expect": {"success": true} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("only the inline prompt.text"), exception.getMessage()); + assertTrue(exception.getMessage().contains("golden"), exception.getMessage()); + } + + @Test + void failsFastOnALiveValidateRecordDeclaringPromptTextContains(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "LIVE-BAD-01", + "api": "validateTaskPromptAndDataFilling", + "languages": ["zh-CN"], + "prompt": {"text": "## 任务类型(Task Type)"}, + "expect": {"success": true, "promptTextContains": ["## 任务类型"]} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("promptTextContains"), exception.getMessage()); + assertTrue(exception.getMessage().contains("no generated prompt"), exception.getMessage()); + } + + @Test + void defaultsTheLiveMaxLlmCallsToFourWhenTheRecordOmitsIt(@TempDir Path root) throws IOException { + write( + root, + "live/generate.json", + """ + [ + { + "id": "LIVE-GEN-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升。"}}, + "expect": {"success": true} + } + ] + """); + + LoadedCorpus corpus = NegotiationCaseLoader.load(root); + + assertEquals(4, corpus.liveCases().get(0).liveExpect().maxLlmCalls(), "maxLlmCalls defaults to 4"); + } + + @Test + void failsFastOnALiveIdCollidingWithAnOfflineCase(@TempDir Path root) throws IOException { + write(root, "from-text/dup.json", minimalCase("LIVE-DUP-01")); + write( + root, + "live/dup.json", + """ + [ + { + "id": "LIVE-DUP-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "expect": {"success": true} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("duplicate id 'LIVE-DUP-01'"), exception.getMessage()); + assertTrue(exception.getMessage().contains("from-text/dup.json"), exception.getMessage()); + } + + // ------------------------------------------------------------------ helpers + + private static void write(Path root, String relative, String content) throws IOException { + Path file = root.resolve(relative); + if (file.getParent() != null) { + Files.createDirectories(file.getParent()); + } + Files.writeString(file, content, StandardCharsets.UTF_8); + } + + private static void writeGoodCorpus(Path root) throws IOException { + write(root, "shared/llm-responses.json", SHARED_RESPONSES); + write(root, "shared/schemas.json", SHARED_SCHEMAS); + write( + root, + "from-text/happy.json", + """ + [ + { + "id": "FT-HAPPY-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["happy", "accept-reject"], + "summary": "信息确认后接受,成功", + "context": {"id": "%s", "round": 2, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": {"zh-CN": "我确认第一阶段的信息。", "en-US": "I confirm the first-stage information."}, + "data": {"items": [], "relationship": null} + }, + "llm": { + "maxAttempts": 3, + "script": [{"$fail": "non-json"}, {"$ref": "responses/extract.information.accept.full"}] + }, + "expect": { + "outcome": "success", + "llmCalls": 2, + "promptTextEqualsGolden": "information_accept", + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent"], + "differential": true + } + } + ] + """ + .formatted(SESSION_ID)); + write( + root, + "validate/happy.json", + """ + [ + { + "id": "VAL-HAPPY-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": {"golden": "information_propose"}, + "schema": {"$ref": "schemas/flat"}, + "llm": {"script": ["{\\"verdict\\": true}"]}, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": {"id": "%s", "round": 1, "maxRounds": 5} + } + } + ] + """ + .formatted(SESSION_ID, SESSION_ID)); + write( + root, + "scenarios/flows.json", + """ + [ + { + "id": "SC-INFO-01", + "summary": "提议后语义拒绝", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": {"text": {"zh-CN": "请提供节能区域信息"}}, + "llm": {"script": [{"$ref": "responses/extract.information.propose.full"}]}, + "expect": {"outcome": "success", "llmCalls": 1} + }, + { + "step": 2, + "role": "B", + "api": "validateProposePromptAndDataFilling", + "prompt": {"fromStep": 1}, + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "schema": {"$ref": "schemas/flat"}, + "llm": {"script": [{"$fail": "llm-error"}]}, + "expect": {"outcome": "failure", "code": "negotiation.semantic_rejected", "llmCalls": 1} + } + ], + "expectFlow": {"terminalCondition": "reject", "roundsUsed": 1, "distinctMessages": true} + } + ] + """ + .formatted(SESSION_ID, SESSION_ID)); + } + + private static String minimalCase(String id) { + return """ + [ + { + "id": "%s", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "context": {"id": "%s", "round": 2, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": {"text": {"zh-CN": "我确认"}}, + "llm": {"script": ["{}"]}, + "expect": {"outcome": "success"} + } + ] + """ + .formatted(id, SESSION_ID); + } + + private static NegotiationCase caseById(LoadedCorpus corpus, String id) { + NegotiationCase found = null; + for (NegotiationCase candidate : corpus.cases()) { + if (candidate.id().equals(id)) { + found = candidate; + } + } + assertNotNull(found, "the corpus must expand the case " + id); + return found; + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java index 9a33fa93..0ff04653 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java @@ -1,531 +1,517 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import net.openan.a2at.sdk.corpus.ScenarioCase.ExpectFlow; -import org.junit.jupiter.api.Test; -import org.jspecify.annotations.Nullable; - -/** - * Direct unit tests of the {@link ScenarioEngine} against inline scenario objects: the {@code prompt.fromStep} - * resolution, the fail-fast step execution, and the three {@code expectFlow} fields (terminal condition, rounds used, - * pairwise distinct messages). - */ -class ScenarioEngineTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String ZH_CN = "zh-CN"; - - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; - - private static final String INFORMATION_ACCEPT_REJECT_URI = - "Negotiation-T/information-negotiation/accept-reject/v1"; - - private static final String ACCEPT_PAYLOAD = - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" - + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; - - private static final String PROPOSE_PAYLOAD = - "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}," - + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," - + "\"value\":null}],\"relationship\":\"OR\"}"; - - private static final String SEMANTIC_ACCEPT_PAYLOAD = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; - - private static final String PRIVATE_LINE_COMPLAINT_URI = - "Task-T/network-layer/private-line-complaint/v1"; - - private static final Map CLOSED_LOOP_ROLES = Map.of( - "A", "工作台(client,任务发起/补数方)", "B", "OMC(server,执行/要数方,协商发起方)"); - - /** The workbench raw complaint (样例步骤1): no access port name and no complaint category in the text. */ - private static final String COMPLAINT_TEXT = - "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报" - + "“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; - - /** - * Slot-extraction payload of the closed loop's step 1 since the task_object slot became required upstream: the - * task object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays - * portless and the two schema parameters stay missing for the negotiation to fill. - */ - private static final String TASK_SLOTS_OBJECT_PORTLESS = - "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" - + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** - * Semantic payload of the closed loop's step 2: the OMC finds the access port name and the complaint category - * missing — the two null-valued parameters driving the information negotiation. - */ - private static final String TASK_SEMANTIC_MISSING_PARAMS = - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," - + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; - - /** Semantic payload of the closed loop's final step: the accept message carries both previously missing values. */ - private static final String SEMANTIC_ACCEPT_WITH_FILLED_PARAMS = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" - + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\",\"bizScenario\":\"专线质差\"}}"; - - /** Semantic payload filling only one of the two missing parameters (the unfilled-parameter red path). */ - private static final String SEMANTIC_ACCEPT_PARTIALLY_FILLED = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" - + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; - - private static final String TASK_PARAM_SCHEMA = - "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" - + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" - + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; - - private final ScenarioEngine engine = new ScenarioEngine(); - - private final ObjectMapper mapper = new ObjectMapper(); - - private JsonNode schema; - - @BeforeEach - void readSchema() throws Exception { - schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); - } - - // ------------------------------------------------------------------ fromStep resolution - - @Test - void runsATwoStepScenarioResolvingTheFromStepPrompt() { - ScenarioCase scenario = scenario( - "SC-INFO-01", - List.of( - step(1, "A", acceptStep("SC-INFO-01", ok(1, "information_accept"))), - step( - 2, - "B", - validateAcceptStep( - "SC-INFO-01", - 1, - ok(1, null)))), - new ScenarioCase.ExpectFlow("accept", 2, null)); - - engine.runScenario(scenario); - } - - @Test - void failsWhenAFromStepReferencesAnUnknownStep() { - ScenarioCase scenario = scenario( - "SC-INFO-02", - List.of(step(1, "A", validateAcceptStep("SC-INFO-02", 3, ok(1, null)))), - null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("SC-INFO-02/zh-CN") - && failure.getMessage().contains("prompt.fromStep 3") - && failure.getMessage().contains("produced no prompt text"), - "the unknown fromStep reference must fail with the scenario id and the step number but was: " - + failure.getMessage()); - } - - // ------------------------------------------------------------------ fail-fast - - @Test - void failsFastWhenAStepFails() { - NegotiationCase failingStep = stepCase( - "SC-ERR-01", - 1, - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), - null, - null, - failed("NegotiationGenerationException", "negotiation_invalid_input", null)); - ScenarioCase scenario = scenario( - "SC-ERR-01", - List.of( - step(1, "A", failingStep), - step(2, "B", validateAcceptStep("SC-ERR-01", 1, ok(1, null)))), - null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("#step-1") && failure.getMessage().contains("$.expect.code"), - "the scenario must abort on the failing step 1 but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ expectFlow - - @Test - void assertsDistinctMessagesAndRoundsUsedAcrossSteps() { - ScenarioCase scenario = scenario( - "SC-INFO-03", - List.of( - step(1, "A", proposeStep("SC-INFO-03", 2, ok(1, "information_propose"))), - step(2, "B", acceptStep("SC-INFO-03", 3, ok(1, "information_accept")))), - new ScenarioCase.ExpectFlow("accept", 3, Boolean.TRUE)); - - engine.runScenario(scenario); - } - - @Test - void distinctMessagesFailsOnDuplicateMessages() { - ScenarioCase scenario = scenario( - "SC-INFO-04", - List.of( - step(1, "A", acceptStep("SC-INFO-04", 2, ok(1, "information_accept"))), - step(2, "B", acceptStep("SC-INFO-04", 3, ok(1, "information_accept")))), - new ScenarioCase.ExpectFlow("accept", 3, Boolean.TRUE)); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("$.expectFlow.distinctMessages"), - "duplicate messages must fail the distinctMessages expectation but was: " + failure.getMessage()); - } - - @Test - void exhaustedRequiresTheRoundLimitToBeReached() { - ScenarioCase exhausted = scenario( - "SC-EXH-01", - List.of(step(1, "A", acceptStep("SC-EXH-01", 5, ok(1, "information_accept")))), - new ScenarioCase.ExpectFlow("exhausted", 5, null)); - engine.runScenario(exhausted); - - ScenarioCase notExhausted = scenario( - "SC-EXH-02", - List.of(step(1, "A", acceptStep("SC-EXH-02", 2, ok(1, "information_accept")))), - new ScenarioCase.ExpectFlow("exhausted", 2, null)); - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(notExhausted)); - - assertTrue( - failure.getMessage().contains("$.expectFlow.terminalCondition"), - "a round below the limit must fail the exhausted expectation but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ closed loop: 缺参 -> 补参 (Q21) - - @Test - void runsTheClosedLoopScenarioLinkingMissingParamsToTheFilledParams() throws Exception { - ScenarioCase scenario = closedLoopScenario( - SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, - new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 2), - 2); - - engine.runScenario(scenario); - } - - @Test - void paramsFromStepFailsWhenTheReferencedStepProducedNoFilledParams() throws Exception { - ScenarioCase scenario = closedLoopScenario( - SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, - null, - 1); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("expect.paramsFromStep 1") - && failure.getMessage().contains("produced no filled parameter data") - && failure.getMessage().contains("B=OMC(server,执行/要数方,协商发起方)"), - "the dangling paramsFromStep reference must fail with role semantics but was: " - + failure.getMessage()); - } - - @Test - void paramsFromStepFailsWhenAParameterStaysUnfilled() throws Exception { - ScenarioCase scenario = closedLoopScenario( - SEMANTIC_ACCEPT_PARTIALLY_FILLED, - null, - 2); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("expect.paramsFromStep 2") - && failure.getMessage().contains("still missing: bizScenario") - && failure.getMessage().contains("#step-4"), - "an unfilled missing parameter must fail the causal assertion but was: " + failure.getMessage()); - } - - @Test - void missingParamsFilledFailsWhenNoLaterStepFillsTheParameters() throws Exception { - ScenarioCase scenario = closedLoopScenario( - SEMANTIC_ACCEPT_PARTIALLY_FILLED, - new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 2), - null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("$.expectFlow.missingParamsFilled") - && failure.getMessage().contains("still missing after step 2: bizScenario"), - "the flow-level causal expectation must fail on an unfilled parameter but was: " - + failure.getMessage()); - } - - @Test - void missingParamsFilledFailsWhenTheReferencedStepFoundNothingMissing() throws Exception { - ScenarioCase scenario = closedLoopScenario( - SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, - new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 4), - 2); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("$.expectFlow.missingParamsFilled") - && failure.getMessage().contains("no missing parameters"), - "a referenced step without missing parameters must fail the flow expectation but was: " - + failure.getMessage()); - } - - // ------------------------------------------------------------------ builders - - private static ScenarioCase scenario( - String id, List steps, @Nullable ExpectFlow expectFlow) { - return new ScenarioCase( - id + "/zh-CN", id, "scenarios/information-flows.json", ZH_CN, null, List.of("A", "B"), steps, - expectFlow); - } - - /** - * Builds the four-step closed loop of the private-line complaint diagnosis (Q21): the workbench generates the - * incomplete task prompt (step 1), the OMC validates it and finds the two missing parameters (step 2), the - * workbench accepts the information negotiation and supplies the values (step 3), and the OMC validates the accept - * message and extracts the filled parameters (step 4). - */ - private ScenarioCase closedLoopScenario( - String acceptSemanticPayload, @Nullable ExpectFlow expectFlow, @Nullable Integer paramsFromStep) - throws Exception { - NegotiationCase taskGeneration = stepCase( - "SC-TASK-01", - 1, - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - null, - PRIVATE_LINE_COMPLAINT_URI, - COMPLAINT_TEXT, - script(TASK_SLOTS_OBJECT_PORTLESS), - null, - null, - ok(1, null)); - NegotiationCase taskValidation = stepCase( - "SC-TASK-01", - 2, - NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - null, - PRIVATE_LINE_COMPLAINT_URI, - null, - script(TASK_SEMANTIC_MISSING_PARAMS), - new PromptSource.FromStep(1), - mapper.readTree(TASK_PARAM_SCHEMA), - okTask( - 1, - null, - List.of("accessPort", "bizScenario"), - Map.of("faultTime", "2026-05-11T08:21:46Z"))); - NegotiationCase acceptGeneration = stepCase( - "SC-TASK-01", - 3, - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "我方补充:接入端口名称P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。", - script(ACCEPT_PAYLOAD), - null, - null, - ok(1, null)); - NegotiationCase acceptValidation = stepCase( - "SC-TASK-01", - 4, - NegotiationApi.VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - null, - script(acceptSemanticPayload), - new PromptSource.FromStep(3), - schema, - okFromStep(1, paramsFromStep)); - return new ScenarioCase( - "SC-TASK-01/zh-CN", - "SC-TASK-01", - "scenarios/task-closed-loop.json", - ZH_CN, - null, - List.of("A", "B"), - List.of( - new ScenarioCase.ScenarioStep(1, "A", taskGeneration), - new ScenarioCase.ScenarioStep(2, "B", taskValidation), - new ScenarioCase.ScenarioStep(3, "A", acceptGeneration), - new ScenarioCase.ScenarioStep(4, "B", acceptValidation)), - expectFlow, - CLOSED_LOOP_ROLES); - } - - private static ScenarioCase.ScenarioStep step(int number, String role, NegotiationCase caseData) { - return new ScenarioCase.ScenarioStep(number, role, caseData); - } - - private NegotiationCase acceptStep(String scenarioId, Expectation expect) { - return stepCase( - scenarioId, - 1, - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "我确认第一阶段的信息。", - script(ACCEPT_PAYLOAD), - null, - null, - expect); - } - - private NegotiationCase acceptStep(String scenarioId, int round, Expectation expect) { - return stepCase( - scenarioId, - 1, - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - new ContextSpec(SESSION_ID, round, 5), - INFORMATION_ACCEPT_REJECT_URI, - "我确认第一阶段的信息。", - script(ACCEPT_PAYLOAD), - null, - null, - expect); - } - - private NegotiationCase proposeStep(String scenarioId, int round, Expectation expect) { - return stepCase( - scenarioId, - 1, - NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, - new ContextSpec(SESSION_ID, round, 5), - INFORMATION_PROPOSE_URI, - "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", - script(PROPOSE_PAYLOAD), - null, - null, - expect); - } - - private NegotiationCase validateAcceptStep(String scenarioId, int fromStep, Expectation expect) { - return stepCase( - scenarioId, - 2, - NegotiationApi.VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - null, - script(SEMANTIC_ACCEPT_PAYLOAD), - new PromptSource.FromStep(fromStep), - schema, - expect); - } - - private NegotiationCase stepCase( - String scenarioId, - int stepNumber, - NegotiationApi api, - ContextSpec context, - String templateUri, - @Nullable String inputText, - LlmScript llmScript, - @Nullable PromptSource prompt, - @Nullable JsonNode schema, - Expectation expect) { - return new NegotiationCase( - scenarioId + "/zh-CN#step-" + stepNumber, - scenarioId, - "scenarios/information-flows.json", - api, - ZH_CN, - null, - List.of(), - null, - context, - templateUri, - inputText, - null, - llmScript, - prompt, - schema, - null, - expect); - } - - private static LlmScript script(String payload) { - return new LlmScript(null, List.of(new LlmScriptStep.Payload(payload))); - } - - // ------------------------------------------------------------------ expectation helpers - - private static Expectation ok(@Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - promptTextEqualsGolden, - null, - java.util.Map.of(), - List.of(), - false); - } - - private static Expectation failed(String exception, String code, @Nullable Integer llmCalls) { - return new Expectation( - false, exception, code, List.of(), List.of(), llmCalls, null, null, java.util.Map.of(), List.of(), - false); - } - - /** Task-family success expectation with the missing-parameter set of the task validation. */ - private static Expectation okTask( - @Nullable Integer llmCalls, - @Nullable List promptTextContains, - @Nullable List missingParams, - Map params) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - null, - null, - params, - List.of(), - false, - promptTextContains == null ? List.of() : promptTextContains, - missingParams, - null); - } - - /** Success expectation with the step-level causal reference {@code expect.paramsFromStep}. */ - private static Expectation okFromStep(@Nullable Integer llmCalls, @Nullable Integer paramsFromStep) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - null, - null, - java.util.Map.of(), - List.of(), - false, - List.of(), - null, - paramsFromStep); - } -} +package net.openan.a2at.sdk.corpus; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.corpus.ScenarioCase.ExpectFlow; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Direct unit tests of the {@link ScenarioEngine} against inline scenario objects: the {@code prompt.fromStep} + * resolution, the fail-fast step execution, and the three {@code expectFlow} fields (terminal condition, rounds used, + * pairwise distinct messages). + */ +class ScenarioEngineTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String ZH_CN = "zh-CN"; + + private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; + + private static final String INFORMATION_ACCEPT_REJECT_URI = + "Negotiation-T/information-negotiation/accept-reject/v1"; + + private static final String ACCEPT_PAYLOAD = + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" + + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; + + private static final String PROPOSE_PAYLOAD = + "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}," + + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," + + "\"value\":null}],\"relationship\":\"OR\"}"; + + private static final String SEMANTIC_ACCEPT_PAYLOAD = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; + + private static final String PRIVATE_LINE_COMPLAINT_URI = "Task-T/network-layer/private-line-complaint/v1"; + + private static final Map CLOSED_LOOP_ROLES = + Map.of("A", "工作台(client,任务发起/补数方)", "B", "OMC(server,执行/要数方,协商发起方)"); + + /** The workbench raw complaint (样例步骤1): no access port name and no complaint category in the text. */ + private static final String COMPLAINT_TEXT = + "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报" + "“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; + + /** + * Slot-extraction payload of the closed loop's step 1 since the task_object slot became required upstream: the task + * object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays portless and + * the two schema parameters stay missing for the negotiation to fill. + */ + private static final String TASK_SLOTS_OBJECT_PORTLESS = + "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" + + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** + * Semantic payload of the closed loop's step 2: the OMC finds the access port name and the complaint category + * missing — the two null-valued parameters driving the information negotiation. + */ + private static final String TASK_SEMANTIC_MISSING_PARAMS = + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," + + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; + + /** Semantic payload of the closed loop's final step: the accept message carries both previously missing values. */ + private static final String SEMANTIC_ACCEPT_WITH_FILLED_PARAMS = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" + + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\",\"bizScenario\":\"专线质差\"}}"; + + /** Semantic payload filling only one of the two missing parameters (the unfilled-parameter red path). */ + private static final String SEMANTIC_ACCEPT_PARTIALLY_FILLED = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" + + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; + + private static final String TASK_PARAM_SCHEMA = + "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" + + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" + + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; + + private final ScenarioEngine engine = new ScenarioEngine(); + + private final ObjectMapper mapper = new ObjectMapper(); + + private JsonNode schema; + + @BeforeEach + void readSchema() throws Exception { + schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); + } + + // ------------------------------------------------------------------ fromStep resolution + + @Test + void runsATwoStepScenarioResolvingTheFromStepPrompt() { + ScenarioCase scenario = scenario( + "SC-INFO-01", + List.of( + step(1, "A", acceptStep("SC-INFO-01", ok(1, "information_accept"))), + step(2, "B", validateAcceptStep("SC-INFO-01", 1, ok(1, null)))), + new ScenarioCase.ExpectFlow("accept", 2, null)); + + engine.runScenario(scenario); + } + + @Test + void failsWhenAFromStepReferencesAnUnknownStep() { + ScenarioCase scenario = + scenario("SC-INFO-02", List.of(step(1, "A", validateAcceptStep("SC-INFO-02", 3, ok(1, null)))), null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("SC-INFO-02/zh-CN") + && failure.getMessage().contains("prompt.fromStep 3") + && failure.getMessage().contains("produced no prompt text"), + "the unknown fromStep reference must fail with the scenario id and the step number but was: " + + failure.getMessage()); + } + + // ------------------------------------------------------------------ fail-fast + + @Test + void failsFastWhenAStepFails() { + NegotiationCase failingStep = stepCase( + "SC-ERR-01", + 1, + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), + null, + null, + failed("NegotiationGenerationException", "llm.response_invalid", null)); + ScenarioCase scenario = scenario( + "SC-ERR-01", + List.of(step(1, "A", failingStep), step(2, "B", validateAcceptStep("SC-ERR-01", 1, ok(1, null)))), + null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("#step-1") && failure.getMessage().contains("$.expect.code"), + "the scenario must abort on the failing step 1 but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ expectFlow + + @Test + void assertsDistinctMessagesAndRoundsUsedAcrossSteps() { + ScenarioCase scenario = scenario( + "SC-INFO-03", + List.of( + step(1, "A", proposeStep("SC-INFO-03", 2, ok(1, "information_propose"))), + step(2, "B", acceptStep("SC-INFO-03", 3, ok(1, "information_accept")))), + new ScenarioCase.ExpectFlow("accept", 3, Boolean.TRUE)); + + engine.runScenario(scenario); + } + + @Test + void distinctMessagesFailsOnDuplicateMessages() { + ScenarioCase scenario = scenario( + "SC-INFO-04", + List.of( + step(1, "A", acceptStep("SC-INFO-04", 2, ok(1, "information_accept"))), + step(2, "B", acceptStep("SC-INFO-04", 3, ok(1, "information_accept")))), + new ScenarioCase.ExpectFlow("accept", 3, Boolean.TRUE)); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("$.expectFlow.distinctMessages"), + "duplicate messages must fail the distinctMessages expectation but was: " + failure.getMessage()); + } + + @Test + void exhaustedRequiresTheRoundLimitToBeReached() { + ScenarioCase exhausted = scenario( + "SC-EXH-01", + List.of(step(1, "A", acceptStep("SC-EXH-01", 5, ok(1, "information_accept")))), + new ScenarioCase.ExpectFlow("exhausted", 5, null)); + engine.runScenario(exhausted); + + ScenarioCase notExhausted = scenario( + "SC-EXH-02", + List.of(step(1, "A", acceptStep("SC-EXH-02", 2, ok(1, "information_accept")))), + new ScenarioCase.ExpectFlow("exhausted", 2, null)); + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(notExhausted)); + + assertTrue( + failure.getMessage().contains("$.expectFlow.terminalCondition"), + "a round below the limit must fail the exhausted expectation but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ closed loop: 缺参 -> 补参 (Q21) + + @Test + void runsTheClosedLoopScenarioLinkingMissingParamsToTheFilledParams() throws Exception { + ScenarioCase scenario = closedLoopScenario( + SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 2), 2); + + engine.runScenario(scenario); + } + + @Test + void paramsFromStepFailsWhenTheReferencedStepProducedNoFilledParams() throws Exception { + ScenarioCase scenario = closedLoopScenario(SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, null, 1); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("expect.paramsFromStep 1") + && failure.getMessage().contains("produced no filled parameter data") + && failure.getMessage().contains("B=OMC(server,执行/要数方,协商发起方)"), + "the dangling paramsFromStep reference must fail with role semantics but was: " + failure.getMessage()); + } + + @Test + void paramsFromStepFailsWhenAParameterStaysUnfilled() throws Exception { + ScenarioCase scenario = closedLoopScenario(SEMANTIC_ACCEPT_PARTIALLY_FILLED, null, 2); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("expect.paramsFromStep 2") + && failure.getMessage().contains("still missing: bizScenario") + && failure.getMessage().contains("#step-4"), + "an unfilled missing parameter must fail the causal assertion but was: " + failure.getMessage()); + } + + @Test + void missingParamsFilledFailsWhenNoLaterStepFillsTheParameters() throws Exception { + ScenarioCase scenario = closedLoopScenario( + SEMANTIC_ACCEPT_PARTIALLY_FILLED, new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 2), null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("$.expectFlow.missingParamsFilled") + && failure.getMessage().contains("still missing after step 2: bizScenario"), + "the flow-level causal expectation must fail on an unfilled parameter but was: " + + failure.getMessage()); + } + + @Test + void missingParamsFilledFailsWhenTheReferencedStepFoundNothingMissing() throws Exception { + ScenarioCase scenario = closedLoopScenario( + SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 4), 2); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("$.expectFlow.missingParamsFilled") + && failure.getMessage().contains("no missing parameters"), + "a referenced step without missing parameters must fail the flow expectation but was: " + + failure.getMessage()); + } + + // ------------------------------------------------------------------ builders + + private static ScenarioCase scenario( + String id, List steps, @Nullable ExpectFlow expectFlow) { + return new ScenarioCase( + id + "/zh-CN", + id, + "scenarios/information-flows.json", + ZH_CN, + null, + List.of("A", "B"), + steps, + expectFlow); + } + + /** + * Builds the four-step closed loop of the private-line complaint diagnosis (Q21): the workbench generates the + * incomplete task prompt (step 1), the OMC validates it and finds the two missing parameters (step 2), the + * workbench accepts the information negotiation and supplies the values (step 3), and the OMC validates the accept + * message and extracts the filled parameters (step 4). + */ + private ScenarioCase closedLoopScenario( + String acceptSemanticPayload, @Nullable ExpectFlow expectFlow, @Nullable Integer paramsFromStep) + throws Exception { + NegotiationCase taskGeneration = stepCase( + "SC-TASK-01", + 1, + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + null, + PRIVATE_LINE_COMPLAINT_URI, + COMPLAINT_TEXT, + script(TASK_SLOTS_OBJECT_PORTLESS), + null, + null, + ok(1, null)); + NegotiationCase taskValidation = stepCase( + "SC-TASK-01", + 2, + NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, + null, + PRIVATE_LINE_COMPLAINT_URI, + null, + script(TASK_SEMANTIC_MISSING_PARAMS), + new PromptSource.FromStep(1), + mapper.readTree(TASK_PARAM_SCHEMA), + okTask(1, null, List.of("accessPort", "bizScenario"), Map.of("faultTime", "2026-05-11T08:21:46Z"))); + NegotiationCase acceptGeneration = stepCase( + "SC-TASK-01", + 3, + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "我方补充:接入端口名称P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。", + script(ACCEPT_PAYLOAD), + null, + null, + ok(1, null)); + NegotiationCase acceptValidation = stepCase( + "SC-TASK-01", + 4, + NegotiationApi.VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + null, + script(acceptSemanticPayload), + new PromptSource.FromStep(3), + schema, + okFromStep(1, paramsFromStep)); + return new ScenarioCase( + "SC-TASK-01/zh-CN", + "SC-TASK-01", + "scenarios/task-closed-loop.json", + ZH_CN, + null, + List.of("A", "B"), + List.of( + new ScenarioCase.ScenarioStep(1, "A", taskGeneration), + new ScenarioCase.ScenarioStep(2, "B", taskValidation), + new ScenarioCase.ScenarioStep(3, "A", acceptGeneration), + new ScenarioCase.ScenarioStep(4, "B", acceptValidation)), + expectFlow, + CLOSED_LOOP_ROLES); + } + + private static ScenarioCase.ScenarioStep step(int number, String role, NegotiationCase caseData) { + return new ScenarioCase.ScenarioStep(number, role, caseData); + } + + private NegotiationCase acceptStep(String scenarioId, Expectation expect) { + return stepCase( + scenarioId, + 1, + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "我确认第一阶段的信息。", + script(ACCEPT_PAYLOAD), + null, + null, + expect); + } + + private NegotiationCase acceptStep(String scenarioId, int round, Expectation expect) { + return stepCase( + scenarioId, + 1, + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + new ContextSpec(SESSION_ID, round, 5), + INFORMATION_ACCEPT_REJECT_URI, + "我确认第一阶段的信息。", + script(ACCEPT_PAYLOAD), + null, + null, + expect); + } + + private NegotiationCase proposeStep(String scenarioId, int round, Expectation expect) { + return stepCase( + scenarioId, + 1, + NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, + new ContextSpec(SESSION_ID, round, 5), + INFORMATION_PROPOSE_URI, + "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", + script(PROPOSE_PAYLOAD), + null, + null, + expect); + } + + private NegotiationCase validateAcceptStep(String scenarioId, int fromStep, Expectation expect) { + return stepCase( + scenarioId, + 2, + NegotiationApi.VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + null, + script(SEMANTIC_ACCEPT_PAYLOAD), + new PromptSource.FromStep(fromStep), + schema, + expect); + } + + private NegotiationCase stepCase( + String scenarioId, + int stepNumber, + NegotiationApi api, + ContextSpec context, + String templateUri, + @Nullable String inputText, + LlmScript llmScript, + @Nullable PromptSource prompt, + @Nullable JsonNode schema, + Expectation expect) { + return new NegotiationCase( + scenarioId + "/zh-CN#step-" + stepNumber, + scenarioId, + "scenarios/information-flows.json", + api, + ZH_CN, + null, + List.of(), + null, + context, + templateUri, + inputText, + null, + llmScript, + prompt, + schema, + null, + expect); + } + + private static LlmScript script(String payload) { + return new LlmScript(null, List.of(new LlmScriptStep.Payload(payload))); + } + + // ------------------------------------------------------------------ expectation helpers + + private static Expectation ok(@Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + promptTextEqualsGolden, + null, + java.util.Map.of(), + List.of(), + false); + } + + private static Expectation failed(String exception, String code, @Nullable Integer llmCalls) { + return new Expectation( + false, + exception, + code, + List.of(), + List.of(), + llmCalls, + null, + null, + java.util.Map.of(), + List.of(), + false); + } + + /** Task-family success expectation with the missing-parameter set of the task validation. */ + private static Expectation okTask( + @Nullable Integer llmCalls, + @Nullable List promptTextContains, + @Nullable List missingParams, + Map params) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + null, + null, + params, + List.of(), + false, + promptTextContains == null ? List.of() : promptTextContains, + missingParams, + null); + } + + /** Success expectation with the step-level causal reference {@code expect.paramsFromStep}. */ + private static Expectation okFromStep(@Nullable Integer llmCalls, @Nullable Integer paramsFromStep) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + null, + null, + java.util.Map.of(), + List.of(), + false, + List.of(), + null, + paramsFromStep); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/ErrorCodePartitionPropertyTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/ErrorCodePartitionPropertyTest.java index 06ce3818..1de48153 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/ErrorCodePartitionPropertyTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/ErrorCodePartitionPropertyTest.java @@ -1,259 +1,262 @@ -package net.openan.a2at.sdk.corpus.property; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Map; -import java.util.Set; -import net.jqwik.api.Arbitrary; -import net.jqwik.api.Arbitraries; -import net.jqwik.api.ForAll; -import net.jqwik.api.Property; -import net.jqwik.api.Provide; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.corpus.LlmFailMarker; -import net.openan.a2at.sdk.corpus.ScriptedNegotiationLlmClient; - -/** - * Error-code partition property layer (design §8.3): the public error-code universe partitions into exactly the seven - * negotiation codes, and the retryable partition is exactly the two LLM-step codes. - * - *

        Every trigger runs with the attempt limit fixed at {@link PropertyHarness#MAX_ATTEMPTS}, so the retryable - * partition has an operational definition observable from the outside: a failure code is retryable if and only if the - * scripted LLM client was consumed to the attempt limit. Pre-LLM failures (0 calls) and non-retryable in-step failures - * (1 call) never reach the limit. - * - * @since 2026-08 - */ -class ErrorCodePartitionPropertyTest { - - /** The complete public negotiation error-code set of the content layer. */ - private static final Set NEGOTIATION_ERROR_CODES = Set.of( - A2ATErrorCodes.TEMPLATE_NOT_FOUND, - A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, - A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED, - A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, - A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, - A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR); - - /** The retryable partition: exactly the two LLM-step codes of the design document. */ - private static final Set RETRYABLE_ERROR_CODES = Set.of( - A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED, - A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR); - - @Property(tries = 100, seed = "20260920") - void everyFailureCarriesACodeFromTheSevenCodeSet( - @ForAll("languages") String language, - @ForAll("contexts") NegotiationContext context, - @ForAll("failureTriggers") FailureTrigger trigger) { - FailureOutcome outcome = trigger.run(language, context); - assertTrue( - NEGOTIATION_ERROR_CODES.contains(outcome.code()), - "code '" + outcome.code() + "' is outside the seven negotiation error codes"); - assertEquals(trigger.expectedCode(), outcome.code()); - assertEquals( - RETRYABLE_ERROR_CODES.contains(outcome.code()), - outcome.llmCalls() == PropertyHarness.MAX_ATTEMPTS, - "a failure is retried to the attempt limit if and only if its code is retryable, but code " - + outcome.code() + " made " + outcome.llmCalls() + " LLM call(s)"); - } - - /** The normalized outcome of one triggered failure: its public error code and its exact LLM call count. */ - private record FailureOutcome(String code, int llmCalls) {} - - /** One deterministic trigger of one public failure code. */ - private enum FailureTrigger { - BLANK_TEXT { - @Override - String expectedCode() { - return A2ATErrorCodes.NEGOTIATION_INVALID_INPUT; - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationGenerationException.class, - () -> service.generateProposeFromText( - " ", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - CONCLUSION_MISMATCH { - @Override - String expectedCode() { - return A2ATErrorCodes.NEGOTIATION_INVALID_INPUT; - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = PropertyHarness.scripted( - "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"no agreement\"}"); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationGenerationException.class, - () -> service.generateAcceptFromText( - "I must refuse the current offer.", - context, - PropertyHarness.templateUri("Negotiation-T/target-negotiation/accept-reject/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - MISSING_REQUIRED_FIELD { - @Override - String expectedCode() { - return A2ATErrorCodes.NEGOTIATION_SLOT_MISSING; - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = PropertyHarness.scripted("{\"relationship\":null}"); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationGenerationException.class, - () -> service.generateProposeFromText( - "Please provide the missing information.", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - ROUND_ABOVE_BUDGET { - @Override - String expectedCode() { - return A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION; - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - // The over-budget context is the incoming context of the propose message being validated. - NegotiationContext overBudget = new NegotiationContext( - context.id(), context.maxRounds() + 1, context.maxRounds(), NegotiationPerformative.PROPOSE); - ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationParamExtractionException.class, - () -> service.validateProposePromptAndDataFilling( - "Rendered negotiation message text.", - overBudget, - PropertyHarness.objectSchema(Map.of()), - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - SEMANTIC_REJECTED { - @Override - String expectedCode() { - return A2ATErrorCodes.NEGOTIATION_SEMANTIC_REJECTED; - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = PropertyHarness.scripted( - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\"," - + "\"errors\":[{\"slot_name\":\"region\",\"code\":\"missing\"," - + "\"message\":\"The region parameter is missing.\"}],\"params\":{}}"); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationParamExtractionException.class, - () -> service.validateProposePromptAndDataFilling( - "Rendered negotiation message text.", - context, - PropertyHarness.objectSchema(Map.of()), - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - EXTRACT_FAILED { - @Override - String expectedCode() { - return A2ATErrorCodes.NEGOTIATION_CONTENT_EXTRACT_FAILED; - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = PropertyHarness.failing(LlmFailMarker.NON_JSON); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationGenerationException.class, - () -> service.generateProposeFromText( - "Please provide the missing information.", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - LLM_INFRASTRUCTURE_ERROR { - @Override - String expectedCode() { - return A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR; - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = PropertyHarness.failing(LlmFailMarker.RUNTIME_EXCEPTION); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationGenerationException.class, - () -> service.generateProposeFromText( - "Please provide the missing information.", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - TEMPLATE_NOT_FOUND { - @Override - String expectedCode() { - return A2ATErrorCodes.TEMPLATE_NOT_FOUND; - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); - NegotiationContentService service = - PropertyHarness.serviceWithFailingTemplateLoader(language, llm); - A2ATError error = assertThrows( - NegotiationGenerationException.class, - () -> service.generateProposeFromText( - "Please provide the missing information.", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }; - - abstract String expectedCode(); - - abstract FailureOutcome run(String language, NegotiationContext context); - } - - // ------------------------------------------------------------------ providers - - @Provide - Arbitrary languages() { - return PropertyArbitraries.languages(); - } - - @Provide - Arbitrary contexts() { - return PropertyArbitraries.contexts(); - } - - @Provide - Arbitrary failureTriggers() { - return Arbitraries.of(FailureTrigger.values()); - } -} +package net.openan.a2at.sdk.corpus.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.Set; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.corpus.LlmFailMarker; +import net.openan.a2at.sdk.corpus.ScriptedNegotiationLlmClient; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; + +/** + * Error-code partition property layer (design §8.3): the public error-code universe partitions into exactly the + * content-layer {@link ErrorCatalog} codes, and the retryable partition is exactly the three retryable LLM-step codes. + * + *

        Every trigger runs with the attempt limit fixed at {@link PropertyHarness#MAX_ATTEMPTS}, so the retryable + * partition has an operational definition observable from the outside: a failure code is retryable if and only if the + * scripted LLM client was consumed to the attempt limit. Pre-LLM failures (0 calls) and non-retryable in-step failures + * (1 call) never reach the limit. + * + * @since 2026-08 + */ +class ErrorCodePartitionPropertyTest { + + /** The complete public error-code set of the content layer (ErrorCatalog codes). */ + private static final Set CONTENT_LAYER_ERROR_CODES = Set.of( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), + ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), + ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); + + /** The retryable partition: exactly the three retryable LLM-step codes of the content layer. */ + private static final Set RETRYABLE_ERROR_CODES = Set.of( + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); + + @Property(tries = 100, seed = "20260920") + void everyFailureCarriesACodeFromTheContentLayerCodeSet( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("failureTriggers") FailureTrigger trigger) { + FailureOutcome outcome = trigger.run(language, context); + assertTrue( + CONTENT_LAYER_ERROR_CODES.contains(outcome.code()), + "code '" + outcome.code() + "' is outside the content-layer error codes"); + assertEquals(trigger.expectedCode(), outcome.code()); + assertEquals( + RETRYABLE_ERROR_CODES.contains(outcome.code()), + outcome.llmCalls() == PropertyHarness.MAX_ATTEMPTS, + "a failure is retried to the attempt limit if and only if its code is retryable, but code " + + outcome.code() + " made " + outcome.llmCalls() + " LLM call(s)"); + } + + /** The normalized outcome of one triggered failure: its public error code and its exact LLM call count. */ + private record FailureOutcome(String code, int llmCalls) {} + + /** One deterministic trigger of one public failure code. */ + private enum FailureTrigger { + BLANK_TEXT { + @Override + String expectedCode() { + return ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + " ", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + CONCLUSION_MISMATCH { + @Override + String expectedCode() { + return ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = PropertyHarness.scripted( + "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"no agreement\"}"); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateAcceptFromText( + "I must refuse the current offer.", + context, + PropertyHarness.templateUri("Negotiation-T/target-negotiation/accept-reject/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + MISSING_REQUIRED_FIELD { + @Override + String expectedCode() { + return ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = PropertyHarness.scripted("{\"relationship\":null}"); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + ROUND_ABOVE_BUDGET { + @Override + String expectedCode() { + return ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + // The over-budget context is the incoming context of the propose message being validated. + NegotiationContext overBudget = new NegotiationContext( + context.id(), context.maxRounds() + 1, context.maxRounds(), NegotiationPerformative.PROPOSE); + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationParamExtractionException.class, + () -> service.validateProposePromptAndDataFilling( + "Rendered negotiation message text.", + overBudget, + PropertyHarness.objectSchema(Map.of()), + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + SEMANTIC_REJECTED { + @Override + String expectedCode() { + return ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = + PropertyHarness.scripted("{\"semantic_verdict\":false,\"negotiation_type\":\"information\"," + + "\"errors\":[{\"slot_name\":\"region\",\"code\":" + + "\"negotiation.field_missing\",\"facts\":{\"field\":\"区域\"}}]," + + "\"params\":{}}"); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationParamExtractionException.class, + () -> service.validateProposePromptAndDataFilling( + "Rendered negotiation message text.", + context, + PropertyHarness.objectSchema(Map.of()), + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + RESPONSE_INVALID { + @Override + String expectedCode() { + return ErrorCatalog.LLM_RESPONSE_INVALID.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = PropertyHarness.failing(LlmFailMarker.NON_JSON); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + LLM_INFRASTRUCTURE_ERROR { + @Override + String expectedCode() { + return ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = PropertyHarness.failing(LlmFailMarker.RUNTIME_EXCEPTION); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + TEMPLATE_NOT_FOUND { + @Override + String expectedCode() { + return ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.serviceWithFailingTemplateLoader(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }; + + abstract String expectedCode(); + + abstract FailureOutcome run(String language, NegotiationContext context); + } + + // ------------------------------------------------------------------ providers + + @Provide + Arbitrary languages() { + return PropertyArbitraries.languages(); + } + + @Provide + Arbitrary contexts() { + return PropertyArbitraries.contexts(); + } + + @Provide + Arbitrary failureTriggers() { + return Arbitraries.of(FailureTrigger.values()); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/NegotiationErrorPathPropertyTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/NegotiationErrorPathPropertyTest.java index b6592894..0b3f1c05 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/NegotiationErrorPathPropertyTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/NegotiationErrorPathPropertyTest.java @@ -1,327 +1,332 @@ -package net.openan.a2at.sdk.corpus.property; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import net.jqwik.api.Arbitrary; -import net.jqwik.api.Arbitraries; -import net.jqwik.api.ForAll; -import net.jqwik.api.Property; -import net.jqwik.api.Provide; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.corpus.ScriptedNegotiationLlmClient; - -/** - * Error-path property layer (design §8.3): arbitraries specifically designed to trigger one error code each. - * - *

        Every property samples ~100 inputs built to violate exactly one rule — blank free text, a conclusion that - * contradicts the addressed phase, a payload missing one required field, or a context round above the budget — and - * asserts the exact public error code plus the exact LLM call count (0 when the failure precedes the LLM step, 1 when - * the mapped failure is non-retryable). - * - * @since 2026-08 - */ -class NegotiationErrorPathPropertyTest { - - private static final Map FLAT_SCHEMA = PropertyHarness.objectSchema(Map.of()); - - @Property(tries = 100, seed = "20260910") - void blankTextFailsWithInvalidInput( - @ForAll("languages") String language, - @ForAll("contexts") NegotiationContext context, - @ForAll("blankTexts") String text) { - ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); - NegotiationContentService service = PropertyHarness.service(language, llm); - NegotiationGenerationException propose = assertThrows( - NegotiationGenerationException.class, - () -> service.generateProposeFromText( - text, - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, propose.getCode()); - NegotiationGenerationException abort = assertThrows( - NegotiationGenerationException.class, - () -> service.generateAbortFromText( - text, context, PropertyHarness.templateUri("Negotiation-T/common/abort/v1"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, abort.getCode()); - assertEquals(0, llm.callCount()); - } - - @Property(tries = 100, seed = "20260911") - void conclusionMismatchingThePhaseFailsWithInvalidInput( - @ForAll("languages") String language, - @ForAll("contexts") NegotiationContext context, - @ForAll("negotiationTypes") String typeSegment, - @ForAll("swappedConclusions") boolean swapped) { - String rawUri = "Negotiation-T/" + typeSegment + "/accept-reject/v1"; - TemplateUri templateUri = PropertyHarness.templateUri(rawUri); - // An accept call receives a Reject payload and vice versa; all other required fields are present. - String conclusion = swapped ? "Reject" : "Accept"; - ScriptedNegotiationLlmClient llm = PropertyHarness.scripted(endingPayload(conclusion)); - NegotiationContentService service = PropertyHarness.service(language, llm); - NegotiationGenerationException exception; - if (swapped) { - exception = assertThrows( - NegotiationGenerationException.class, - () -> service.generateAcceptFromText("I must refuse the current offer.", context, templateUri)); - } else { - exception = assertThrows( - NegotiationGenerationException.class, - () -> service.generateRejectFromText("I cannot agree with the current offer.", context, templateUri)); - } - assertEquals(A2ATErrorCodes.NEGOTIATION_INVALID_INPUT, exception.getCode()); - assertEquals(1, llm.callCount()); - } - - @Property(tries = 100, seed = "20260912") - void payloadMissingOneRequiredFieldFailsWithSlotMissing( - @ForAll("languages") String language, - @ForAll("contexts") NegotiationContext context, - @ForAll("missingRequiredFields") MissingRequiredField field) { - ScriptedNegotiationLlmClient llm = PropertyHarness.scripted(PropertyHarness.json(field.payload())); - NegotiationContentService service = PropertyHarness.service(language, llm); - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> field.run(service, context)); - assertEquals(A2ATErrorCodes.NEGOTIATION_SLOT_MISSING, exception.getCode()); - assertEquals(1, llm.callCount()); - } - - @Property(tries = 100, seed = "20260913") - void roundAboveTheBudgetFailsWithRuleViolation( - @ForAll("languages") String language, - @ForAll("maxRoundsValues") int maxRounds, - @ForAll("budgetOvershoots") int overshoot) { - // Each leg validates the message of one performative, so its incoming context carries that performative. - NegotiationContext proposeContext = new NegotiationContext( - PropertyArbitraries.anySessionId(), maxRounds + overshoot, maxRounds, NegotiationPerformative.PROPOSE); - NegotiationContext abortContext = new NegotiationContext( - PropertyArbitraries.anySessionId(), maxRounds + overshoot, maxRounds, NegotiationPerformative.ABORT); - ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); - NegotiationContentService service = PropertyHarness.service(language, llm); - NegotiationParamExtractionException propose = assertThrows( - NegotiationParamExtractionException.class, - () -> service.validateProposePromptAndDataFilling( - "Rendered negotiation message text.", - proposeContext, - FLAT_SCHEMA, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, propose.getCode()); - NegotiationParamExtractionException abort = assertThrows( - NegotiationParamExtractionException.class, - () -> service.validateAbortPromptAndDataFilling( - "Rendered negotiation message text.", - abortContext, - FLAT_SCHEMA, - PropertyHarness.templateUri("Negotiation-T/common/abort/v1"))); - assertEquals(A2ATErrorCodes.NEGOTIATION_RULE_VIOLATION, abort.getCode()); - assertEquals(0, llm.callCount()); - } - - // ------------------------------------------------------------------ missing-field matrix - - /** - * One required extraction field dropped from an otherwise valid payload, paired with the API whose payload it is. - */ - private enum MissingRequiredField { - INFORMATION_PROPOSE_ITEMS { - @Override - Map payload() { - return withoutKey(validInformationProposePayload(), "items"); - } - - @Override - Object run(NegotiationContentService service, NegotiationContext context) { - return service.generateProposeFromText( - "Please provide the missing information.", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1")); - } - }, - TARGET_PROPOSE_DESCRIPTION { - @Override - Map payload() { - return withoutKey(validTargetProposePayload(), "target_negotiation_description"); - } - - @Override - Object run(NegotiationContentService service, NegotiationContext context) { - return service.generateProposeFromText( - "I want to negotiate the coverage target.", - context, - PropertyHarness.templateUri("Negotiation-T/target-negotiation/propose/v1")); - } - }, - FEASIBILITY_PROPOSE_DESCRIPTION { - @Override - Map payload() { - return withoutKey(validFeasibilityProposePayload(), "feasibility_negotiation_description"); - } - - @Override - Object run(NegotiationContentService service, NegotiationContext context) { - return service.generateProposeFromText( - "Please evaluate whether the upgrade is feasible.", - context, - PropertyHarness.templateUri("Negotiation-T/feasibility-negotiation/propose/v1")); - } - }, - ENDING_CONCLUSION { - @Override - Map payload() { - return withoutKey(validInformationEndingPayload(), "conclusion"); - } - - @Override - Object run(NegotiationContentService service, NegotiationContext context) { - return service.generateAcceptFromText( - "I accept the proposed information set.", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/accept-reject/v1")); - } - }, - TARGET_ACCEPT_CONFIRMED_INTENT { - @Override - Map payload() { - return withoutKey(validTargetEndingPayload(), "confirmed_intent"); - } - - @Override - Object run(NegotiationContentService service, NegotiationContext context) { - return service.generateAcceptFromText( - "I confirm the negotiated intent.", - context, - PropertyHarness.templateUri("Negotiation-T/target-negotiation/accept-reject/v1")); - } - }, - ABORT_TERMINATION_REASON { - @Override - Map payload() { - return withoutKey(validAbortPayload(), "termination_reason"); - } - - @Override - Object run(NegotiationContentService service, NegotiationContext context) { - return service.generateAbortFromText( - "The round limit is reached.", - context, - PropertyHarness.templateUri("Negotiation-T/common/abort/v1")); - } - }; - - abstract Map payload(); - - abstract Object run(NegotiationContentService service, NegotiationContext context); - - private static Map withoutKey(Map payload, String key) { - Map reduced = new LinkedHashMap<>(payload); - reduced.remove(key); - return reduced; - } - } - - // ------------------------------------------------------------------ payload builders - - private static String endingPayload(String conclusion) { - Map payload = new LinkedHashMap<>(validTargetEndingPayload()); - payload.put("conclusion", conclusion); - return PropertyHarness.json(payload); - } - - private static Map validInformationProposePayload() { - Map payload = new LinkedHashMap<>(); - payload.put("items", List.of(Map.of("name", "access_port", "value", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"))); - payload.put("relationship", null); - return payload; - } - - private static Map validTargetProposePayload() { - Map payload = new LinkedHashMap<>(); - payload.put("target_negotiation_description", "Latency repair target of the quality degradation complaint"); - payload.put("intent_understanding", List.of()); - payload.put("alignment_and_clarification", List.of()); - payload.put("request_for_clarification", List.of()); - return payload; - } - - private static Map validFeasibilityProposePayload() { - Map payload = new LinkedHashMap<>(); - payload.put("feasibility_negotiation_description", "Access port expansion feasibility within the cutover window"); - payload.put("action", "REQUEST_FEASIBILITY_EVALUATION"); - payload.put( - "contents_to_evaluate", List.of(Map.of("name", "latency_target", "value", "within 20ms"))); - payload.put("infeasibility_details_and_proposal", List.of()); - return payload; - } - - private static Map validInformationEndingPayload() { - Map payload = new LinkedHashMap<>(); - payload.put("conclusion", "Accept"); - payload.put("items", List.of(Map.of("name", "access_port", "value", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"))); - return payload; - } - - private static Map validTargetEndingPayload() { - Map payload = new LinkedHashMap<>(); - payload.put("conclusion", "Accept"); - payload.put("confirmed_intent", "Confirmed latency repair intent within 20ms"); - payload.put("failure_reason", null); - return payload; - } - - private static Map validAbortPayload() { - Map payload = new LinkedHashMap<>(); - payload.put("termination_reason", "The negotiation round limit is reached."); - return payload; - } - - // ------------------------------------------------------------------ providers - - @Provide - Arbitrary languages() { - return PropertyArbitraries.languages(); - } - - @Provide - Arbitrary contexts() { - return PropertyArbitraries.contexts(); - } - - @Provide - Arbitrary blankTexts() { - return Arbitraries.strings().withChars(" \t\r\n").ofMinLength(0).ofMaxLength(12); - } - - @Provide - Arbitrary negotiationTypes() { - return Arbitraries.of("information-negotiation", "target-negotiation", "feasibility-negotiation"); - } - - @Provide - Arbitrary swappedConclusions() { - return Arbitraries.of(true, false); - } - - @Provide - Arbitrary missingRequiredFields() { - return Arbitraries.of(MissingRequiredField.values()); - } - - @Provide - Arbitrary maxRoundsValues() { - return Arbitraries.integers().between(1, 10); - } - - @Provide - Arbitrary budgetOvershoots() { - return Arbitraries.integers().between(1, 20); - } -} +package net.openan.a2at.sdk.corpus.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.corpus.ScriptedNegotiationLlmClient; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; + +/** + * Error-path property layer (design §8.3): arbitraries specifically designed to trigger one error code each. + * + *

        Every property samples ~100 inputs built to violate exactly one rule — blank free text, a conclusion that + * contradicts the addressed phase, a payload missing one required field, or a context round above the budget — and + * asserts the exact public error code plus the exact LLM call count (0 when the failure precedes the LLM step, 1 when + * the mapped failure is non-retryable). + * + * @since 2026-08 + */ +class NegotiationErrorPathPropertyTest { + + private static final Map FLAT_SCHEMA = PropertyHarness.objectSchema(Map.of()); + + @Property(tries = 100, seed = "20260910") + void blankTextFailsWithInvalidInput( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("blankTexts") String text) { + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.service(language, llm); + NegotiationGenerationException propose = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + text, + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), propose.getCode()); + NegotiationGenerationException abort = assertThrows( + NegotiationGenerationException.class, + () -> service.generateAbortFromText( + text, context, PropertyHarness.templateUri("Negotiation-T/common/abort/v1"))); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), abort.getCode()); + assertEquals(0, llm.callCount()); + } + + @Property(tries = 100, seed = "20260911") + void conclusionMismatchingThePhaseFailsWithConclusionMismatch( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("negotiationTypes") String typeSegment, + @ForAll("swappedConclusions") boolean swapped) { + String rawUri = "Negotiation-T/" + typeSegment + "/accept-reject/v1"; + TemplateUri templateUri = PropertyHarness.templateUri(rawUri); + // An accept call receives a Reject payload and vice versa; all other required fields are present. + String conclusion = swapped ? "Reject" : "Accept"; + ScriptedNegotiationLlmClient llm = PropertyHarness.scripted(endingPayload(conclusion)); + NegotiationContentService service = PropertyHarness.service(language, llm); + NegotiationGenerationException exception; + if (swapped) { + exception = assertThrows( + NegotiationGenerationException.class, + () -> service.generateAcceptFromText("I must refuse the current offer.", context, templateUri)); + } else { + exception = assertThrows( + NegotiationGenerationException.class, + () -> service.generateRejectFromText( + "I cannot agree with the current offer.", context, templateUri)); + } + assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), exception.getCode()); + assertEquals(1, llm.callCount()); + } + + @Property(tries = 100, seed = "20260912") + void payloadMissingOneRequiredFieldFailsWithSlotMissing( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("missingRequiredFields") MissingRequiredField field) { + ScriptedNegotiationLlmClient llm = PropertyHarness.scripted(PropertyHarness.json(field.payload())); + NegotiationContentService service = PropertyHarness.service(language, llm); + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> field.run(service, context)); + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); + assertEquals(1, llm.callCount()); + } + + @Property(tries = 100, seed = "20260913") + void roundAboveTheBudgetFailsWithRuleViolation( + @ForAll("languages") String language, + @ForAll("maxRoundsValues") int maxRounds, + @ForAll("budgetOvershoots") int overshoot) { + // Each leg validates the message of one performative, so its incoming context carries that performative. + NegotiationContext proposeContext = new NegotiationContext( + PropertyArbitraries.anySessionId(), maxRounds + overshoot, maxRounds, NegotiationPerformative.PROPOSE); + NegotiationContext abortContext = new NegotiationContext( + PropertyArbitraries.anySessionId(), maxRounds + overshoot, maxRounds, NegotiationPerformative.ABORT); + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.service(language, llm); + NegotiationParamExtractionException propose = assertThrows( + NegotiationParamExtractionException.class, + () -> service.validateProposePromptAndDataFilling( + "Rendered negotiation message text.", + proposeContext, + FLAT_SCHEMA, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), propose.getCode()); + NegotiationParamExtractionException abort = assertThrows( + NegotiationParamExtractionException.class, + () -> service.validateAbortPromptAndDataFilling( + "Rendered negotiation message text.", + abortContext, + FLAT_SCHEMA, + PropertyHarness.templateUri("Negotiation-T/common/abort/v1"))); + assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), abort.getCode()); + assertEquals(0, llm.callCount()); + } + + // ------------------------------------------------------------------ missing-field matrix + + /** + * One required extraction field dropped from an otherwise valid payload, paired with the API whose payload it is. + */ + private enum MissingRequiredField { + INFORMATION_PROPOSE_ITEMS { + @Override + Map payload() { + return withoutKey(validInformationProposePayload(), "items"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1")); + } + }, + TARGET_PROPOSE_DESCRIPTION { + @Override + Map payload() { + return withoutKey(validTargetProposePayload(), "target_negotiation_description"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateProposeFromText( + "I want to negotiate the coverage target.", + context, + PropertyHarness.templateUri("Negotiation-T/target-negotiation/propose/v1")); + } + }, + FEASIBILITY_PROPOSE_DESCRIPTION { + @Override + Map payload() { + return withoutKey(validFeasibilityProposePayload(), "feasibility_negotiation_description"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateProposeFromText( + "Please evaluate whether the upgrade is feasible.", + context, + PropertyHarness.templateUri("Negotiation-T/feasibility-negotiation/propose/v1")); + } + }, + ENDING_CONCLUSION { + @Override + Map payload() { + return withoutKey(validInformationEndingPayload(), "conclusion"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateAcceptFromText( + "I accept the proposed information set.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/accept-reject/v1")); + } + }, + TARGET_ACCEPT_CONFIRMED_INTENT { + @Override + Map payload() { + return withoutKey(validTargetEndingPayload(), "confirmed_intent"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateAcceptFromText( + "I confirm the negotiated intent.", + context, + PropertyHarness.templateUri("Negotiation-T/target-negotiation/accept-reject/v1")); + } + }, + ABORT_TERMINATION_REASON { + @Override + Map payload() { + return withoutKey(validAbortPayload(), "termination_reason"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateAbortFromText( + "The round limit is reached.", + context, + PropertyHarness.templateUri("Negotiation-T/common/abort/v1")); + } + }; + + abstract Map payload(); + + abstract Object run(NegotiationContentService service, NegotiationContext context); + + private static Map withoutKey(Map payload, String key) { + Map reduced = new LinkedHashMap<>(payload); + reduced.remove(key); + return reduced; + } + } + + // ------------------------------------------------------------------ payload builders + + private static String endingPayload(String conclusion) { + Map payload = new LinkedHashMap<>(validTargetEndingPayload()); + payload.put("conclusion", conclusion); + return PropertyHarness.json(payload); + } + + private static Map validInformationProposePayload() { + Map payload = new LinkedHashMap<>(); + payload.put( + "items", + List.of(Map.of("name", "access_port", "value", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"))); + payload.put("relationship", null); + return payload; + } + + private static Map validTargetProposePayload() { + Map payload = new LinkedHashMap<>(); + payload.put("target_negotiation_description", "Latency repair target of the quality degradation complaint"); + payload.put("intent_understanding", List.of()); + payload.put("alignment_and_clarification", List.of()); + payload.put("request_for_clarification", List.of()); + return payload; + } + + private static Map validFeasibilityProposePayload() { + Map payload = new LinkedHashMap<>(); + payload.put( + "feasibility_negotiation_description", "Access port expansion feasibility within the cutover window"); + payload.put("action", "REQUEST_FEASIBILITY_EVALUATION"); + payload.put("contents_to_evaluate", List.of(Map.of("name", "latency_target", "value", "within 20ms"))); + payload.put("infeasibility_details_and_proposal", List.of()); + return payload; + } + + private static Map validInformationEndingPayload() { + Map payload = new LinkedHashMap<>(); + payload.put("conclusion", "Accept"); + payload.put( + "items", + List.of(Map.of("name", "access_port", "value", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"))); + return payload; + } + + private static Map validTargetEndingPayload() { + Map payload = new LinkedHashMap<>(); + payload.put("conclusion", "Accept"); + payload.put("confirmed_intent", "Confirmed latency repair intent within 20ms"); + payload.put("failure_reason", null); + return payload; + } + + private static Map validAbortPayload() { + Map payload = new LinkedHashMap<>(); + payload.put("termination_reason", "The negotiation round limit is reached."); + return payload; + } + + // ------------------------------------------------------------------ providers + + @Provide + Arbitrary languages() { + return PropertyArbitraries.languages(); + } + + @Provide + Arbitrary contexts() { + return PropertyArbitraries.contexts(); + } + + @Provide + Arbitrary blankTexts() { + return Arbitraries.strings().withChars(" \t\r\n").ofMinLength(0).ofMaxLength(12); + } + + @Provide + Arbitrary negotiationTypes() { + return Arbitraries.of("information-negotiation", "target-negotiation", "feasibility-negotiation"); + } + + @Provide + Arbitrary swappedConclusions() { + return Arbitraries.of(true, false); + } + + @Provide + Arbitrary missingRequiredFields() { + return Arbitraries.of(MissingRequiredField.values()); + } + + @Provide + Arbitrary maxRoundsValues() { + return Arbitraries.integers().between(1, 10); + } + + @Provide + Arbitrary budgetOvershoots() { + return Arbitraries.integers().between(1, 20); + } +} diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/INDEX.md b/a2a-t-corpus/src/test/resources/negotiation-cases/INDEX.md index b802ce82..ac24189a 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/INDEX.md +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/INDEX.md @@ -18,8 +18,8 @@ language expansion is listed per record and totaled in the statistics. | Language-expanded units | 234 | | Shared LLM payloads | 35 | | Shared schema variants | 8 | -| Error codes covered | 7 (negotiation_content_extract_failed, negotiation_invalid_input, negotiation_llm_infrastructure_error, negotiation_rule_violation, negotiation_semantic_rejected, negotiation_slot_missing, template_not_found) | -| Exception-only failures | 33 (IllegalArgumentException, NullPointerException) | +| Error codes covered | 10 (llm.invocation_failed, llm.response_invalid, negotiation.conclusion_mismatch, negotiation.content_extract_failed, negotiation.content_invalid, negotiation.field_missing, negotiation.invalid_input, negotiation.rule_violation, negotiation.semantic_rejected, template.not_found) | +| Exception-only failures | 25 (IllegalArgumentException, NullPointerException) | Priority distribution (case records; scenarios carry no priority): @@ -86,23 +86,23 @@ Cells count base records (case records plus scenario steps) per API and category Cells count base records per API and expectation; the `exception` column aggregates failures that assert only an exception type (failures carrying both an exception and a code are counted under their code). -| API | `success` | `negotiation_content_extract_failed` | `negotiation_invalid_input` | `negotiation_llm_infrastructure_error` | `negotiation_rule_violation` | `negotiation_semantic_rejected` | `negotiation_slot_missing` | `template_not_found` | `exception` | Total | -|---|---|---|---|---|---|---|---|---|---|---| -| `generateProposeFromText` | 32 | 7 | 3 | 1 | - | - | 1 | 1 | 2 | 47 | -| `generateAcceptFromText` | 20 | 2 | 3 | 1 | - | - | 3 | 1 | 2 | 32 | -| `generateRejectFromText` | 10 | - | 1 | 1 | - | - | 1 | 1 | 1 | 15 | -| `generateAbortFromText` | 6 | - | - | - | - | - | 2 | 1 | 1 | 10 | -| `generateProposeFromData` | 4 | - | - | - | - | - | - | - | 8 | 12 | -| `generateAcceptFromData` | 3 | - | - | - | - | - | - | - | 4 | 7 | -| `generateRejectFromData` | 3 | - | - | - | - | - | - | - | 2 | 5 | -| `generateAbortFromData` | 1 | - | - | - | - | - | - | - | - | 1 | -| `validateProposePromptAndDataFilling` | 17 | - | 3 | 3 | 7 | 10 | - | 1 | 4 | 45 | -| `validateAcceptPromptAndDataFilling` | 23 | - | 2 | 3 | - | 2 | - | - | 3 | 33 | -| `validateRejectPromptAndDataFilling` | 6 | - | 2 | - | - | 1 | - | - | 3 | 12 | -| `validateAbortPromptAndDataFilling` | 6 | - | 2 | - | - | 1 | - | - | 3 | 12 | -| `generateTaskPromptFromText` | 7 | - | - | - | - | - | - | - | - | 7 | -| `generateTaskPromptFromDataWithSchema` | 1 | - | - | - | - | - | - | - | - | 1 | -| `validateTaskPromptAndDataFilling` | 8 | - | - | - | - | - | - | - | - | 8 | +| API | `success` | `llm.invocation_failed` | `llm.response_invalid` | `negotiation.conclusion_mismatch` | `negotiation.content_extract_failed` | `negotiation.content_invalid` | `negotiation.field_missing` | `negotiation.invalid_input` | `negotiation.rule_violation` | `negotiation.semantic_rejected` | `template.not_found` | `exception` | Total | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| `generateProposeFromText` | 32 | 1 | 3 | - | 4 | - | 1 | 3 | - | - | 1 | 2 | 47 | +| `generateAcceptFromText` | 20 | 1 | 1 | 2 | 1 | - | 3 | 1 | - | - | 1 | 2 | 32 | +| `generateRejectFromText` | 10 | 1 | - | 1 | - | - | 1 | - | - | - | 1 | 1 | 15 | +| `generateAbortFromText` | 6 | - | - | - | - | - | 2 | - | - | - | 1 | 1 | 10 | +| `generateProposeFromData` | 4 | - | - | - | - | 2 | - | - | - | - | - | 6 | 12 | +| `generateAcceptFromData` | 3 | - | - | 2 | - | 2 | - | - | - | - | - | - | 7 | +| `generateRejectFromData` | 3 | - | - | 1 | - | 1 | - | - | - | - | - | - | 5 | +| `generateAbortFromData` | 1 | - | - | - | - | - | - | - | - | - | - | - | 1 | +| `validateProposePromptAndDataFilling` | 17 | 1 | 2 | - | - | - | - | 3 | 7 | 10 | 1 | 4 | 45 | +| `validateAcceptPromptAndDataFilling` | 23 | 1 | 2 | - | - | - | - | 2 | - | 2 | - | 3 | 33 | +| `validateRejectPromptAndDataFilling` | 6 | - | - | - | - | - | - | 2 | - | 1 | - | 3 | 12 | +| `validateAbortPromptAndDataFilling` | 6 | - | - | - | - | - | - | 2 | - | 1 | - | 3 | 12 | +| `generateTaskPromptFromText` | 7 | - | - | - | - | - | - | - | - | - | - | - | 7 | +| `generateTaskPromptFromDataWithSchema` | 1 | - | - | - | - | - | - | - | - | - | - | - | 1 | +| `validateTaskPromptAndDataFilling` | 8 | - | - | - | - | - | - | - | - | - | - | - | 8 | ## Case index @@ -110,21 +110,21 @@ Cells count base records per API and expectation; the `exception` column aggrega | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| FT-EXTRACT-01 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 补数接受消息抽取结果缺少结论字段时生成失败并返回槽位缺失错误码,不发起重试 | -| FT-EXTRACT-02 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 补数接受消息抽取出的结论为拒绝时判定为内容与阶段矛盾,立即失败并返回输入无效错误码 | -| FT-EXTRACT-03 | `generateRejectFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 无法提供端口名的拒绝消息抽取出的结论为接受时判定为内容与阶段矛盾,立即失败并返回输入无效错误码 | -| FT-EXTRACT-04 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 要数提议消息抽取结果缺少信息项列表时生成失败并返回槽位缺失错误码 | -| FT-EXTRACT-05 | `generateProposeFromText` | en-US | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 要数信息项列表被抽取为字符串而非数组时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码 | -| FT-EXTRACT-06 | `generateProposeFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation_content_extract_failed) | 要数信息项缺少名称字段时内容抽取失败,返回抽取失败错误码 | -| FT-EXTRACT-07 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 结论字面量大小写错误(ACCEPT 而非 Accept)时内容抽取失败,返回抽取失败错误码 | -| FT-EXTRACT-08 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 修复目标接受消息抽取结果缺少最终确认意图时生成失败并返回槽位缺失错误码 | -| FT-EXTRACT-09 | `generateRejectFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 修复目标拒绝消息抽取结果缺少拒绝原因时生成失败并返回槽位缺失错误码 | -| FT-EXTRACT-10 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 端口扩容可行性提议消息抽取结果缺少动作字段时判定为内容矛盾,立即失败并返回输入无效错误码 | -| FT-EXTRACT-11 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 动作字段为未知枚举值时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码 | -| FT-EXTRACT-12 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 请求可行性评估却未抽取到任何待评估内容时判定为内容矛盾,立即失败并返回输入无效错误码 | -| FT-EXTRACT-13 | `generateAbortFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 协商终止消息抽取结果缺少终止原因时生成失败并返回槽位缺失错误码 | -| FT-EXTRACT-14 | `generateAbortFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation_slot_missing) | 终止原因为空白字符串时等同于缺失,生成失败并返回槽位缺失错误码 | -| FT-EXTRACT-15 | `generateProposeFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation_content_extract_failed) | 信息项取值为数字而非字符串时内容抽取失败,返回抽取失败错误码 | +| FT-EXTRACT-01 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.field_missing) | 补数接受消息抽取结果缺少结论字段时生成失败并返回槽位缺失错误码,不发起重试 | +| FT-EXTRACT-02 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.conclusion_mismatch) | 补数接受消息抽取出的结论为拒绝时判定为结论与阶段不符,立即失败并返回结论不符错误码 | +| FT-EXTRACT-03 | `generateRejectFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.conclusion_mismatch) | 无法提供端口名的拒绝消息抽取出的结论为接受时判定为结论与阶段不符,立即失败并返回结论不符错误码 | +| FT-EXTRACT-04 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.field_missing) | 要数提议消息抽取结果缺少信息项列表时生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-05 | `generateProposeFromText` | en-US | P1 | (NegotiationGenerationException, negotiation.content_extract_failed) | 要数信息项列表被抽取为字符串而非数组时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码 | +| FT-EXTRACT-06 | `generateProposeFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation.content_extract_failed) | 要数信息项缺少名称字段时内容抽取失败,返回抽取失败错误码 | +| FT-EXTRACT-07 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_extract_failed) | 结论字面量大小写错误(ACCEPT 而非 Accept)时内容抽取失败,返回抽取失败错误码 | +| FT-EXTRACT-08 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.field_missing) | 修复目标接受消息抽取结果缺少最终确认意图时生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-09 | `generateRejectFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.field_missing) | 修复目标拒绝消息抽取结果缺少拒绝原因时生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-10 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.invalid_input) | 端口扩容可行性提议消息抽取结果缺少动作字段时判定为内容矛盾,立即失败并返回输入无效错误码 | +| FT-EXTRACT-11 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_extract_failed) | 动作字段为未知枚举值时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码 | +| FT-EXTRACT-12 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.invalid_input) | 请求可行性评估却未抽取到任何待评估内容时判定为内容矛盾,立即失败并返回输入无效错误码 | +| FT-EXTRACT-13 | `generateAbortFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.field_missing) | 协商终止消息抽取结果缺少终止原因时生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-14 | `generateAbortFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation.field_missing) | 终止原因为空白字符串时等同于缺失,生成失败并返回槽位缺失错误码 | +| FT-EXTRACT-15 | `generateProposeFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation.content_extract_failed) | 信息项取值为数字而非字符串时内容抽取失败,返回抽取失败错误码 | ### FT-HAPPY -- from-text/happy.json @@ -150,8 +150,8 @@ Cells count base records per API and expectation; the `exception` column aggrega |---|---|---|---|---|---| | FT-PROG-01 | `generateProposeFromText` | zh-CN | P0 | (NullPointerException) | 协商上下文缺失时生成投诉诊断的信息协商提议在管线入口直接失败,抛出空指针异常且不发起任何大模型调用 | | FT-PROG-02 | `generateAcceptFromText` | zh-CN | P0 | (NullPointerException) | 模板地址缺失时生成信息协商接受消息在地址解析入口直接失败,抛出空指针异常且不发起任何大模型调用 | -| FT-PROG-03 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 调用方未传入任何投诉诊断自然语言内容时生成提议消息被拒绝,返回无效输入错误码且不发起任何大模型调用 | -| FT-PROG-04 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_invalid_input) | 投诉诊断补充内容仅含空白字符时生成接受消息被拒绝,返回无效输入错误码且不发起任何大模型调用 | +| FT-PROG-03 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.invalid_input) | 调用方未传入任何投诉诊断自然语言内容时生成提议消息被拒绝,返回无效输入错误码且不发起任何大模型调用 | +| FT-PROG-04 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation.invalid_input) | 投诉诊断补充内容仅含空白字符时生成接受消息被拒绝,返回无效输入错误码且不发起任何大模型调用 | | FT-PROG-05 | `generateProposeFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址误用未发布的 v2 版本段时生成信息协商提议在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | | FT-PROG-06 | `generateAcceptFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址误用 Task-T 扩展名时生成信息协商接受消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | | FT-PROG-07 | `generateRejectFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址中协商类型拼写错误时生成信息协商拒绝消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | @@ -161,28 +161,28 @@ Cells count base records per API and expectation; the `exception` column aggrega | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| FT-RETRY-01 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 最大尝试次数为1时大模型返回不可解析内容,不发起任何重试即以抽取失败错误码失败 | -| FT-RETRY-02 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 最大尝试次数为2时大模型连续返回空响应与空白内容,尝试耗尽后仍以原始的抽取失败错误码失败 | -| FT-RETRY-03 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_content_extract_failed) | 最大尝试次数为3时三种不可解析形态依次出现,三次尝试耗尽后以抽取失败错误码失败 | -| FT-RETRY-04 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_llm_infrastructure_error) | 最大尝试次数为1时底层通道抛出运行时异常,不重试即以基础设施错误码失败且异常细节不外泄 | -| FT-RETRY-05 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_llm_infrastructure_error) | 最大尝试次数为2时底层先后出现传输异常与错误响应,尝试耗尽后仍以基础设施错误码失败 | -| FT-RETRY-06 | `generateRejectFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation_llm_infrastructure_error) | 最大尝试次数为3时基础设施错误持续出现,三次尝试耗尽后以基础设施错误码失败 | +| FT-RETRY-01 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, llm.response_invalid) | 最大尝试次数为1时大模型返回不可解析内容,不发起任何重试即以 LLM 响应无效错误码失败 | +| FT-RETRY-02 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, llm.response_invalid) | 最大尝试次数为2时大模型连续返回空响应与空白内容,尝试耗尽后仍以原始的 LLM 响应无效错误码失败 | +| FT-RETRY-03 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, llm.response_invalid) | 最大尝试次数为3时三种不可解析形态依次出现,三次尝试耗尽后以 LLM 响应无效错误码失败 | +| FT-RETRY-04 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, llm.invocation_failed) | 最大尝试次数为1时底层通道抛出运行时异常,不重试即以 llm.invocation_failed 失败 | +| FT-RETRY-05 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, llm.invocation_failed) | 最大尝试次数为2时底层先后出现传输异常与错误响应,尝试耗尽后仍以 llm.invocation_failed 失败 | +| FT-RETRY-06 | `generateRejectFromText` | zh-CN | P1 | (NegotiationGenerationException, llm.invocation_failed) | 最大尝试次数为3时基础设施错误持续出现,三次尝试耗尽后以 llm.invocation_failed 失败 | | FT-RETRY-07 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 首次返回不可解析内容后第二次返回合法补数接受内容,重试后成功生成携带接入端口名称与投诉分类的接受消息(共享应答单一语种,双语 golden 等值断言降级为内容包含) | | FT-RETRY-08 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 首次底层传输异常后第二次抽取成功,重试后生成向工作台补充三项缺失信息的要数提议消息且异常细节不外泄(共享应答单一语种,双语 golden 等值断言降级为内容包含) | | FT-RETRY-09 | `generateAcceptFromText` | en-US, zh-CN | P1 | success | 前两次分别出现基础设施错误与不可解析内容,第三次抽取成功,在最后一次尝试额度内恢复生成修复目标接受消息(共享应答单一语种,双语 golden 等值断言降级为内容包含) | | FT-RETRY-10 | `generateAbortFromText` | en-US, zh-CN | P1 | success | 协商终止消息首次抽取到空白内容后第二次成功,重试后生成含终止原因的协商终止消息(共享应答单一语种,双语 golden 等值断言降级为内容包含) | -| FT-RETRY-11 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_slot_missing) | 抽取结果缺少结论字段属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败 | -| FT-RETRY-12 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_invalid_input) | 结论与阶段矛盾属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败 | -| FT-RETRY-13 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation_content_extract_failed) | 两次失败形态不同(基础设施错误后接不可解析内容)时,耗尽后重抛最后一次失败的原始错误码而非统一的耗尽错误码 | +| FT-RETRY-11 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.field_missing) | 抽取结果缺少结论字段属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败 | +| FT-RETRY-12 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.conclusion_mismatch) | 结论与阶段矛盾属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败 | +| FT-RETRY-13 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, llm.response_invalid) | 两次失败形态不同(基础设施错误后接不可解析内容)时,耗尽后重抛最后一次失败的原始错误码(llm.response_invalid)而非统一的耗尽错误码 | ### FT-TPL -- from-text/template-resolution.json | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| FT-TPL-01 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成投诉诊断信息协商接受消息失败并返回 template_not_found 错误码,且不发起任何 LLM 调用 | -| FT-TPL-02 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成要数提议消息失败并返回 template_not_found 错误码,模板加载发生在重试循环之外,即使配置了重试次数也不重试且不发起任何 LLM 调用 | -| FT-TPL-03 | `generateRejectFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成信息协商拒绝消息失败并返回 template_not_found 错误码,且不发起任何 LLM 调用 | -| FT-TPL-04 | `generateAbortFromText` | zh-CN | P1 | (NegotiationGenerationException, template_not_found) | 模板加载器不可用时生成协商终止消息失败并返回 template_not_found 错误码,通用终止模板同样无法加载且不发起任何 LLM 调用 | +| FT-TPL-01 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, template.not_found) | 模板加载器不可用时生成投诉诊断信息协商接受消息失败并返回 template.not_found 错误码,且不发起任何 LLM 调用 | +| FT-TPL-02 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, template.not_found) | 模板加载器不可用时生成要数提议消息失败并返回 template.not_found 错误码,模板加载发生在重试循环之外,即使配置了重试次数也不重试且不发起任何 LLM 调用 | +| FT-TPL-03 | `generateRejectFromText` | zh-CN | P1 | (NegotiationGenerationException, template.not_found) | 模板加载器不可用时生成信息协商拒绝消息失败并返回 template.not_found 错误码,且不发起任何 LLM 调用 | +| FT-TPL-04 | `generateAbortFromText` | zh-CN | P1 | (NegotiationGenerationException, template.not_found) | 模板加载器不可用时生成协商终止消息失败并返回 template.not_found 错误码,通用终止模板同样无法加载且不发起任何 LLM 调用 | ### FD-HAPPY -- from-data/happy.json @@ -205,14 +205,14 @@ Cells count base records per API and expectation; the `exception` column aggrega | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| | FD-PROG-01 | `generateProposeFromData` | zh-CN | P1 | (NullPointerException) | 结构化提议数据缺少协商上下文时生成立即以空指针异常失败,提示上下文必填且不发起任何 LLM 调用 | -| FD-PROG-02 | `generateAcceptFromData` | zh-CN | P1 | (IllegalArgumentException) | 接受方法收到携带 Reject 结论的结构化数据时生成以参数异常失败,提示结论与接受阶段不符且不发起任何 LLM 调用 | -| FD-PROG-03 | `generateRejectFromData` | zh-CN | P1 | (IllegalArgumentException) | 拒绝方法收到携带 Accept 结论的结构化数据时生成以参数异常失败,提示结论与拒绝阶段不符且不发起任何 LLM 调用 | -| FD-PROG-04 | `generateAcceptFromData` | zh-CN | P1 | (IllegalArgumentException) | 接受方法收到携带 Abort 结论的结构化数据时生成以参数异常失败,提示终止结论不属于类型化模板且不发起任何 LLM 调用 | -| FD-PROG-05 | `generateAcceptFromData` | zh-CN | P1 | (IllegalArgumentException) | 目标协商接受数据缺少最终确认意图时生成以参数异常失败,提示接受消息必须携带确认意图且不发起任何 LLM 调用 | -| FD-PROG-06 | `generateAcceptFromData` | zh-CN | P1 | (IllegalArgumentException) | 目标协商接受数据的最终确认意图为空白字符串时生成以参数异常失败,提示确认意图不能为空白且不发起任何 LLM 调用 | -| FD-PROG-07 | `generateRejectFromData` | zh-CN | P1 | (IllegalArgumentException) | 目标协商拒绝数据缺少拒绝原因时生成以参数异常失败,提示拒绝消息必须携带失败原因且不发起任何 LLM 调用 | -| FD-PROG-08 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 可行性评估请求的结构化提议数据缺少待评估内容时生成以参数异常失败,提示至少需要一条评估项且不发起任何 LLM 调用 | -| FD-PROG-09 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 不可行替代方案的结构化提议数据缺少不可行细节与方案时生成以参数异常失败,提示至少需要一条方案项且不发起任何 LLM 调用 | +| FD-PROG-02 | `generateAcceptFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.conclusion_mismatch) | 接受方法收到携带 Reject 结论的结构化数据时生成以结论不符错误码失败,提示结论与接受方法预期不符且不发起任何 LLM 调用 | +| FD-PROG-03 | `generateRejectFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.conclusion_mismatch) | 拒绝方法收到携带 Accept 结论的结构化数据时生成以结论不符错误码失败,提示结论与拒绝方法预期不符且不发起任何 LLM 调用 | +| FD-PROG-04 | `generateAcceptFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.conclusion_mismatch) | 接受方法收到携带 Abort 结论的结构化数据时生成以结论不符错误码失败,提示终止结论不属于类型化模板且不发起任何 LLM 调用 | +| FD-PROG-05 | `generateAcceptFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_invalid) | 目标协商接受数据缺少最终确认意图时生成以协商内容无效错误码失败,提示接受消息必须携带确认意图且不发起任何 LLM 调用 | +| FD-PROG-06 | `generateAcceptFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_invalid) | 目标协商接受数据的最终确认意图为空白字符串时生成以协商内容无效错误码失败,提示确认意图不能为空白且不发起任何 LLM 调用 | +| FD-PROG-07 | `generateRejectFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_invalid) | 目标协商拒绝数据缺少拒绝原因时生成以协商内容无效错误码失败,提示拒绝消息必须携带失败原因且不发起任何 LLM 调用 | +| FD-PROG-08 | `generateProposeFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_invalid) | 可行性评估请求的结构化提议数据缺少待评估内容时生成以协商内容无效错误码失败,提示至少需要一条评估项且不发起任何 LLM 调用 | +| FD-PROG-09 | `generateProposeFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_invalid) | 不可行替代方案的结构化提议数据缺少不可行细节与方案时生成以协商内容无效错误码失败,提示至少需要一条方案项且不发起任何 LLM 调用 | | FD-PROG-10 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 模板 URI 前缀指向 Task-T 而非 Negotiation-T 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用 | | FD-PROG-11 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 模板 URI 版本段为 v2 而非内置 v1 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用 | | FD-PROG-12 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 模板 URI 类型段使用下划线拼写 information_negotiation 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用 | @@ -247,11 +247,11 @@ Cells count base records per API and expectation; the `exception` column aggrega | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| VAL-MAP-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 消息未携带协商上下文元数据时校验判定其不是协商消息,内部无效输入错误码对外映射为协商无效输入错误且不发起任何大模型调用 | -| VAL-MAP-02 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商轮次超限触发的内部规则违反错误码对外映射为协商规则违反错误码,并保留轮次越界槽位错误 | -| VAL-MAP-03 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型语义校验否决要数提议消息时内部语义拒绝错误码对外映射为协商语义拒绝错误码,并保留大模型报告的接入端口缺失槽位错误 | -| VAL-MAP-04 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | 大模型服务连续两次不可用并耗尽重试后,内部基础设施错误码对外映射为协商大模型基础设施错误码,错误详情以 _llm 伪槽位保留内部错误码 | -| VAL-MAP-05 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, template_not_found) | 语义校验提示词资源缺失时内部资源未找到错误码对外映射为 template_not_found 错误码,原始资源异常不透出且不发起任何大模型调用 | +| VAL-MAP-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.invalid_input) | 消息未携带协商上下文元数据时校验判定其不是协商消息,按非协商消息拒绝并返回协商无效输入错误码且不发起任何大模型调用 | +| VAL-MAP-02 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.rule_violation) | 协商轮次超限触发协商规则违反错误码,并保留轮次越界槽位错误 | +| VAL-MAP-03 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 大模型语义校验否决要数提议消息时返回协商语义拒绝错误码,并保留大模型报告的接入端口缺失槽位错误 | +| VAL-MAP-04 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, llm.invocation_failed) | 大模型服务连续两次不可用并耗尽重试后,以 llm.invocation_failed 错误码失败,错误以 _llm 伪槽位保留 | +| VAL-MAP-05 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, template.not_found) | 语义校验提示词资源缺失时映射为 template.not_found 错误码,原始资源异常不透出且不发起任何大模型调用 | ### VAL-MERGE -- validate/merge-and-schema.json @@ -259,10 +259,10 @@ Cells count base records per API and expectation; the `exception` column aggrega |---|---|---|---|---|---| | VAL-MERGE-01 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 业务参数与上下文 id、round 键冲突时上下文参数优先,大模型提取的错误取值不覆盖规则层结果 | | VAL-MERGE-02 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 调用方未声明 type 关键字的参数 schema 被自动包装为对象 schema,补数接受报文的语义校验与投诉参数提取不受影响 | -| VAL-MERGE-03 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验要数提议消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | -| VAL-MERGE-04 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验补数接受消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | -| VAL-MERGE-05 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验拒绝消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | -| VAL-MERGE-06 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验终止消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用 | +| VAL-MERGE-03 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验要数提议消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用 | +| VAL-MERGE-04 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验补数接受消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用 | +| VAL-MERGE-05 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验拒绝消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用 | +| VAL-MERGE-06 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验终止消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用 | ### VAL-PROG -- validate/programming-errors.json @@ -276,10 +276,10 @@ Cells count base records per API and expectation; the `exception` column aggrega | VAL-PROG-06 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验修复目标拒绝消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针) | | VAL-PROG-07 | `validateAbortPromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验终止消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | | VAL-PROG-08 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验终止消息时消息文本为空白,抛出参数异常(abort 方法的 javadoc 未声明该行为,doc-gap 探针) | -| VAL-PROG-09 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验要数提议消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | -| VAL-PROG-10 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验补数接受消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | -| VAL-PROG-11 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验修复目标拒绝消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | -| VAL-PROG-12 | `validateAbortPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_invalid_input) | 校验终止消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input | +| VAL-PROG-09 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验要数提议消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input | +| VAL-PROG-10 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验补数接受消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input | +| VAL-PROG-11 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验修复目标拒绝消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input | +| VAL-PROG-12 | `validateAbortPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验终止消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input | | VAL-PROG-13 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验要数提议消息时模板 URI 声明的是接受-拒绝阶段,阶段不匹配抛出参数异常且不调用大模型 | | VAL-PROG-14 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验补数接受消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型 | | VAL-PROG-15 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验修复目标拒绝消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型 | @@ -292,24 +292,24 @@ Cells count base records per API and expectation; the `exception` column aggrega |---|---|---|---|---|---| | VAL-RETRY-01 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 工作台补数接受报文的语义校验第一次遭遇 LLM 基础设施错误后自动重试,第二次返回合法判定并成功提取投诉参数 | | VAL-RETRY-02 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 要数提议报文的语义返回缺少 negotiation_type 键的无效结构时触发重试,第二次结构合法并成功提取投诉参数 | -| VAL-RETRY-03 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | 语义返回连续三种无效结构时重试至耗尽,最终携带 _llm 伪槽位以 LLM 基础设施错误失败 | -| VAL-RETRY-04 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | maxAttempts 配置为 1 时语义结构错误不获重试机会,单次调用即以 LLM 基础设施错误失败 | -| VAL-RETRY-05 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | LLM 基础设施错误连续出现时按 maxAttempts=2 重试两次后耗尽,携带 _llm 伪槽位失败 | +| VAL-RETRY-03 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, llm.response_invalid) | 语义返回连续三种无效结构时重试至耗尽,最终携带 _llm 伪槽位以 llm.response_invalid 错误失败 | +| VAL-RETRY-04 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, llm.response_invalid) | maxAttempts 配置为 1 时语义结构错误不获重试机会,单次调用即以 llm.response_invalid 错误失败 | +| VAL-RETRY-05 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, llm.invocation_failed) | LLM 传输错误连续出现时按 maxAttempts=2 重试两次后耗尽,携带 _llm 伪槽位以 llm.invocation_failed 失败 | | VAL-RETRY-06 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P1 | success | 工作台拒绝报文的语义返回无法解析的非 JSON 内容后按 maxAttempts=2 重试,第二次返回合法判定并成功 | | VAL-RETRY-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 要数提议报文的语义连续两次空内容与非 JSON 应答后第三次成功,重试预算 maxAttempts=3 恰好用满 | -| VAL-RETRY-08 | `validateAcceptPromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | 语义步骤抛出非 LLMError 的原始运行时异常时不重试,单次调用即包装为 LLM 基础设施错误失败 | -| VAL-RETRY-09 | `validateAcceptPromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_llm_infrastructure_error) | 语义步骤返回空响应(null)时不重试,单次调用即包装为 LLM 基础设施错误失败 | +| VAL-RETRY-08 | `validateAcceptPromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, llm.response_invalid) | 语义步骤抛出非 LLMError 的原始运行时异常时以 llm.response_invalid 错误失败(原始运行时异常现已与其他 LLM 步骤失败一样按预算重试,本探针以 maxAttempts=1 固化单次调用形态) | +| VAL-RETRY-09 | `validateAcceptPromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, llm.response_invalid) | 语义步骤返回空响应(null)时以 llm.response_invalid 错误失败(空响应现已按预算重试,本探针以 maxAttempts=1 固化单次调用形态) | ### VAL-RULE -- validate/rule-gate.json | Id | API | Languages | Priority | Expectation | Summary | |---|---|---|---|---|---| -| VAL-RULE-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商会话标识仅 8 位而非 36 位 UUID 时,消息在规则校验阶段即被拒绝并报告标识格式槽位错误,不发起任何大模型调用 | -| VAL-RULE-02 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商会话标识为 35 位(末段缺少一位十六进制位)时同样被规则门拒绝,UUID 长度边界从严执行 | -| VAL-RULE-03 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商会话标识长度与 8-4-4-4-12 分段均正确但首段含非十六进制字符 g 时,规则门仍按 UUID 格式拒绝 | +| VAL-RULE-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.rule_violation) | 协商会话标识仅 8 位而非 36 位 UUID 时,消息在规则校验阶段即被拒绝并报告标识格式槽位错误,不发起任何大模型调用 | +| VAL-RULE-02 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.rule_violation) | 协商会话标识为 35 位(末段缺少一位十六进制位)时同样被规则门拒绝,UUID 长度边界从严执行 | +| VAL-RULE-03 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation.rule_violation) | 协商会话标识长度与 8-4-4-4-12 分段均正确但首段含非十六进制字符 g 时,规则门仍按 UUID 格式拒绝 | | VAL-RULE-04 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 当前轮次等于最大轮次预算(第 5 轮即最后一轮)时OMC要数提议报文仍可通过规则校验并正常提取投诉参数,只有超出预算才被拒绝 | -| VAL-RULE-05 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_rule_violation) | 协商轮次超出最大轮次预算(第 6 轮对 5 轮上限)时规则门拒绝消息并报告轮次越界槽位错误,不发起任何大模型调用 | -| VAL-RULE-06 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_rule_violation) | 会话标识非法且轮次超限同时发生时,规则门一次性报告标识格式与轮次越界两条槽位错误,不发起任何大模型调用 | +| VAL-RULE-05 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.rule_violation) | 协商轮次超出最大轮次预算(第 6 轮对 5 轮上限)时规则门拒绝消息并报告轮次越界槽位错误,不发起任何大模型调用 | +| VAL-RULE-06 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.rule_violation) | 会话标识非法且轮次超限同时发生时,规则门一次性报告标识格式与轮次越界两条槽位错误,不发起任何大模型调用 | | VAL-RULE-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 单轮预算(最大轮次为 1)的第 1 轮要数提议报文可通过规则校验并正常提取投诉参数,轮次下限边界同样放行 | | VAL-RULE-08 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P2 | success | 协商会话标识使用大写十六进制 UUID 时规则门同样放行,要数提议报文正常校验并提取投诉参数,合并结果原样保留大写标识 | @@ -323,16 +323,16 @@ Cells count base records per API and expectation; the `exception` column aggrega | VAL-SEM-04 | `validateAbortPromptAndDataFilling` | en-US, zh-CN | P0 | success | 轮次耗尽终止报文语义判定通过且未上报协商类型时校验成功,合并出终止原因参数 | | VAL-SEM-05 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P1 | success | 工作台无法提供端口名的拒绝报文语义判定通过且类型一致时校验成功,类型一致性不区分接受或拒绝动作 | | VAL-SEM-06 | `validateAbortPromptAndDataFilling` | en-US, zh-CN | P2 | success | 公共终止模板未声明协商类型,判定通过时即使大模型上报了类型也照常校验成功 | -| VAL-SEM-07 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型判定通过但上报成目标协商类型时,信息协商要数提议被语义拒绝并指出类型不一致 | -| VAL-SEM-08 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型判定通过但未上报协商类型时,信息协商要数提议被语义拒绝并按模板类型报类型不一致 | -| VAL-SEM-09 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型判定通过但上报未知协商类型字符串时,信息协商要数提议被语义拒绝 | -| VAL-SEM-10 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型上报的协商类型仅大小写不同也被视为类型不一致,信息协商要数提议被语义拒绝 | -| VAL-SEM-11 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 大模型语义判定不通过时,信息协商要数提议被拒绝并返回缺失的接入端口参数槽位错误 | -| VAL-SEM-12 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 语义判定不通过时不再核对协商类型,拒绝侧消息只返回大模型报告的槽位错误 | -| VAL-SEM-13 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 语义判定不通过且未报告任何槽位错误时,校验仍以语义拒绝失败且槽位错误为空 | -| VAL-SEM-14 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 语义判定不通过时已提取的投诉参数被丢弃,校验以语义拒绝失败且不合并任何业务参数 | -| VAL-SEM-15 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 终止消息语义判定不通过时被拒绝并返回缺失终止原因的槽位错误 | -| VAL-SEM-16 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation_semantic_rejected) | 语义拒绝不触发重试:脚本备有第二次通过的应答也不会被消费,LLM 调用数恒为 1 | +| VAL-SEM-07 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 大模型判定通过但上报成目标协商类型时,信息协商要数提议被语义拒绝并指出类型不一致 | +| VAL-SEM-08 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 大模型判定通过但未上报协商类型时,信息协商要数提议被语义拒绝并按模板类型报类型不一致 | +| VAL-SEM-09 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 大模型判定通过但上报未知协商类型字符串时,信息协商要数提议被语义拒绝 | +| VAL-SEM-10 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 大模型上报的协商类型仅大小写不同也被视为类型不一致,信息协商要数提议被语义拒绝 | +| VAL-SEM-11 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 大模型语义判定不通过时,信息协商要数提议被拒绝并返回缺失的接入端口参数槽位错误 | +| VAL-SEM-12 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 语义判定不通过时不再核对协商类型,拒绝侧消息只返回大模型报告的槽位错误 | +| VAL-SEM-13 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 语义判定不通过且未报告任何槽位错误时,校验仍以语义拒绝失败且槽位错误为空 | +| VAL-SEM-14 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 语义判定不通过时已提取的投诉参数被丢弃,校验以语义拒绝失败且不合并任何业务参数 | +| VAL-SEM-15 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 终止消息语义判定不通过时被拒绝并返回缺失终止原因的槽位错误 | +| VAL-SEM-16 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 语义拒绝不触发重试:脚本备有第二次通过的应答也不会被消费,LLM 调用数恒为 1 | | VAL-SEM-17 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P2 | success | 语义判定通过时大模型附带的槽位错误被忽略,要数提议报文校验成功且只合并上下文三键 | ## Scenario index @@ -342,11 +342,11 @@ Cells count base records per API and expectation; the `exception` column aggrega | Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | |---|---|---|---|---|---|---| | SC-ERR-01 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 闭环中错误恢复:工作台首次生成任务报文成功,OMC 校验任务报文时语义校验首次返回不可解析内容,重试第二次成功后闭环继续推进,直至缺参被补齐且接受报文提取成功(不可解析探针挂语义校验步骤:任务槽位抽取路径无重试,maxAttempts 仅作用于语义校验) | -| SC-ERR-02 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | (negotiation_semantic_rejected) | 闭环中错误恢复:工作台首轮补数仅提供投诉分类缺接入端口名称,被 OMC 语义校验拒绝,第2轮补充端口名后重新接受并提取成功,缺参最终补齐 | +| SC-ERR-02 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | (negotiation.semantic_rejected) | 闭环中错误恢复:工作台首轮补数仅提供投诉分类缺接入端口名称,被 OMC 语义校验拒绝,第2轮补充端口名后重新接受并提取成功,缺参最终补齐 | | SC-ERR-03 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2 | - | 重传幂等:同一条接受补数报文被 OMC 重复校验两次,两次提取的 FilledParamData(接入端口名称与投诉分类)完全一致,重传不产生副作用 | | SC-ERR-04 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=2 | - | 乱序失同步探针:工作台以滞后的 round 1 上下文校验 OMC 在 round 2 生成的要数提议,规则门不拦截失同步(doc-gap 探针) | -| SC-ERR-05 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=1 | (negotiation_semantic_rejected) | 截断消息探针:传输中被截断的要数提议缺失后两项信息,工作台侧语义校验以槽位缺失为由拒绝 | -| SC-ERR-06 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2 | (negotiation_semantic_rejected) | 编码边界探针:带 UTF-8 BOM 前缀或全角标点的接受补数报文可正常校验提取,GBK 乱码形态的报文被语义校验拒绝 | +| SC-ERR-05 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=1 | (negotiation.semantic_rejected) | 截断消息探针:传输中被截断的要数提议缺失后两项信息,工作台侧语义校验以槽位缺失为由拒绝 | +| SC-ERR-06 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2 | (negotiation.semantic_rejected) | 编码边界探针:带 UTF-8 BOM 前缀或全角标点的接受补数报文可正常校验提取,GBK 乱码形态的报文被语义校验拒绝 | | SC-ERR-07 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 双会话交错:event-id-20260511-09013 与 fault-id-1-017-20260516-11234 两条投诉工单的缺参协商闭环步骤交替执行,各自提取的上下文与业务参数互不串扰 | ### SC-EXH -- scenarios/boundary-flows.json @@ -354,7 +354,7 @@ Cells count base records per API and expectation; the `exception` column aggrega | Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | |---|---|---|---|---|---|---| | SC-EXH-01 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText`, `validateAbortPromptAndDataFilling` | zh-CN | - | exhausted, rounds=3 | - | 轮次预算耗尽闭环:投诉诊断任务缺参后两轮要数均因工作台端口台账不可查被拒,round 3 达到 maxRounds 上限后 OMC 生成终止消息结束协商 | -| SC-EXH-02 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=6 | (negotiation_rule_violation) | 过期重放边界:工作台以 round 6 超出轮次预算的上下文重放校验 OMC 的要数提议,被规则门直接拦截且不调用大模型,修正为 round 5 后重放校验通过 | +| SC-EXH-02 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=6 | (negotiation.rule_violation) | 过期重放边界:工作台以 round 6 超出轮次预算的上下文重放校验 OMC 的要数提议,被规则门直接拦截且不调用大模型,修正为 round 5 后重放校验通过 | ### SC-FSB -- scenarios/feasibility-flows.json diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/corpus-schema.json b/a2a-t-corpus/src/test/resources/negotiation-cases/corpus-schema.json index 1e425ecd..9a9e1fca 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/corpus-schema.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/corpus-schema.json @@ -40,7 +40,7 @@ }, "inject": { "title": "Harness injection hook", - "description": "Maps a corpus case onto a real builder injection point; failingTemplateLoader triggers template_not_found on the generation family, failingSemanticValidator replays the missing semantic validation prompt resources of the validate family (mapped onto template_not_found by the validation pipeline).", + "description": "Maps a corpus case onto a real builder injection point; failingTemplateLoader triggers template.not_found on the generation family, failingSemanticValidator replays the missing semantic validation prompt resources of the validate family (mapped onto template.not_found by the validation pipeline).", "type": "string", "enum": ["failingTemplateLoader", "failingSemanticValidator"] }, diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/from-data/programming-errors.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-data/programming-errors.json index 8f21323e..0534323b 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/from-data/programming-errors.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/from-data/programming-errors.json @@ -29,7 +29,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "conclusion-mismatch", "information", "accept"], - "summary": "接受方法收到携带 Reject 结论的结构化数据时生成以参数异常失败,提示结论与接受阶段不符且不发起任何 LLM 调用", + "summary": "接受方法收到携带 Reject 结论的结构化数据时生成以结论不符错误码失败,提示结论与接受方法预期不符且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { @@ -42,8 +42,9 @@ }, "expect": { "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["The ACCEPT phase requires conclusion Accept but the content carries Reject."], + "exception": "NegotiationGenerationException", + "code": "negotiation.conclusion_mismatch", + "messageContains": ["与该方法的预期"], "llmCalls": 0 } }, @@ -53,7 +54,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "conclusion-mismatch", "information", "reject"], - "summary": "拒绝方法收到携带 Accept 结论的结构化数据时生成以参数异常失败,提示结论与拒绝阶段不符且不发起任何 LLM 调用", + "summary": "拒绝方法收到携带 Accept 结论的结构化数据时生成以结论不符错误码失败,提示结论与拒绝方法预期不符且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { @@ -66,8 +67,9 @@ }, "expect": { "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["The REJECT phase requires conclusion Reject but the content carries Accept."], + "exception": "NegotiationGenerationException", + "code": "negotiation.conclusion_mismatch", + "messageContains": ["与该方法的预期"], "llmCalls": 0 } }, @@ -77,7 +79,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "conclusion-mismatch", "information", "accept", "abort-conclusion"], - "summary": "接受方法收到携带 Abort 结论的结构化数据时生成以参数异常失败,提示终止结论不属于类型化模板且不发起任何 LLM 调用", + "summary": "接受方法收到携带 Abort 结论的结构化数据时生成以结论不符错误码失败,提示终止结论不属于类型化模板且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { @@ -90,8 +92,9 @@ }, "expect": { "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["The ACCEPT phase requires conclusion Accept but the content carries Abort."], + "exception": "NegotiationGenerationException", + "code": "negotiation.conclusion_mismatch", + "messageContains": ["与该方法的预期"], "llmCalls": 0 } }, @@ -101,7 +104,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "missing-field", "target", "accept"], - "summary": "目标协商接受数据缺少最终确认意图时生成以参数异常失败,提示接受消息必须携带确认意图且不发起任何 LLM 调用", + "summary": "目标协商接受数据缺少最终确认意图时生成以协商内容无效错误码失败,提示接受消息必须携带确认意图且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "input": { @@ -113,7 +116,8 @@ }, "expect": { "outcome": "failure", - "exception": "IllegalArgumentException", + "exception": "NegotiationGenerationException", + "code": "negotiation.content_invalid", "messageContains": ["Confirmed intent of an accepting target negotiation message must not be blank."], "llmCalls": 0 } @@ -124,7 +128,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "blank-field", "target", "accept"], - "summary": "目标协商接受数据的最终确认意图为空白字符串时生成以参数异常失败,提示确认意图不能为空白且不发起任何 LLM 调用", + "summary": "目标协商接受数据的最终确认意图为空白字符串时生成以协商内容无效错误码失败,提示确认意图不能为空白且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "input": { @@ -136,7 +140,8 @@ }, "expect": { "outcome": "failure", - "exception": "IllegalArgumentException", + "exception": "NegotiationGenerationException", + "code": "negotiation.content_invalid", "messageContains": ["Confirmed intent of an accepting target negotiation message must not be blank."], "llmCalls": 0 } @@ -147,7 +152,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "missing-field", "target", "reject"], - "summary": "目标协商拒绝数据缺少拒绝原因时生成以参数异常失败,提示拒绝消息必须携带失败原因且不发起任何 LLM 调用", + "summary": "目标协商拒绝数据缺少拒绝原因时生成以协商内容无效错误码失败,提示拒绝消息必须携带失败原因且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "input": { @@ -159,7 +164,8 @@ }, "expect": { "outcome": "failure", - "exception": "IllegalArgumentException", + "exception": "NegotiationGenerationException", + "code": "negotiation.content_invalid", "messageContains": ["Failure reason of a rejecting target negotiation message must not be blank."], "llmCalls": 0 } @@ -170,7 +176,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "missing-field", "feasibility", "propose", "evaluation-request"], - "summary": "可行性评估请求的结构化提议数据缺少待评估内容时生成以参数异常失败,提示至少需要一条评估项且不发起任何 LLM 调用", + "summary": "可行性评估请求的结构化提议数据缺少待评估内容时生成以协商内容无效错误码失败,提示至少需要一条评估项且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", "input": { @@ -183,7 +189,8 @@ }, "expect": { "outcome": "failure", - "exception": "IllegalArgumentException", + "exception": "NegotiationGenerationException", + "code": "negotiation.content_invalid", "messageContains": ["Contents to evaluate of a feasibility evaluation request must contain at least one item."], "llmCalls": 0 } @@ -194,7 +201,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "missing-field", "feasibility", "propose", "alternative-proposal"], - "summary": "不可行替代方案的结构化提议数据缺少不可行细节与方案时生成以参数异常失败,提示至少需要一条方案项且不发起任何 LLM 调用", + "summary": "不可行替代方案的结构化提议数据缺少不可行细节与方案时生成以协商内容无效错误码失败,提示至少需要一条方案项且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", "input": { @@ -207,7 +214,8 @@ }, "expect": { "outcome": "failure", - "exception": "IllegalArgumentException", + "exception": "NegotiationGenerationException", + "code": "negotiation.content_invalid", "messageContains": [ "Infeasibility details and proposal of an alternative proposal must contain at least one item." ], diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/extraction-failures.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/extraction-failures.json index 0e2ffd47..05ea1644 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/extraction-failures.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/extraction-failures.json @@ -13,7 +13,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_slot_missing", + "code": "negotiation.field_missing", "llmCalls": 1 } }, @@ -23,7 +23,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["extraction-failure", "information", "accept", "conclusion-mismatch"], - "summary": "补数接受消息抽取出的结论为拒绝时判定为内容与阶段矛盾,立即失败并返回输入无效错误码", + "summary": "补数接受消息抽取出的结论为拒绝时判定为结论与阶段不符,立即失败并返回结论不符错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, @@ -31,7 +31,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_invalid_input", + "code": "negotiation.conclusion_mismatch", "llmCalls": 1 } }, @@ -41,7 +41,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["extraction-failure", "information", "reject", "conclusion-mismatch"], - "summary": "无法提供端口名的拒绝消息抽取出的结论为接受时判定为内容与阶段矛盾,立即失败并返回输入无效错误码", + "summary": "无法提供端口名的拒绝消息抽取出的结论为接受时判定为结论与阶段不符,立即失败并返回结论不符错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { "zh-CN": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮协商拒绝。" } }, @@ -49,7 +49,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_invalid_input", + "code": "negotiation.conclusion_mismatch", "llmCalls": 1 } }, @@ -67,7 +67,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_slot_missing", + "code": "negotiation.field_missing", "llmCalls": 1 } }, @@ -85,7 +85,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_content_extract_failed", + "code": "negotiation.content_extract_failed", "llmCalls": 1 } }, @@ -103,7 +103,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_content_extract_failed", + "code": "negotiation.content_extract_failed", "llmCalls": 1 } }, @@ -121,7 +121,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_content_extract_failed", + "code": "negotiation.content_extract_failed", "llmCalls": 1 } }, @@ -139,7 +139,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_slot_missing", + "code": "negotiation.field_missing", "llmCalls": 1 } }, @@ -157,7 +157,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_slot_missing", + "code": "negotiation.field_missing", "llmCalls": 1 } }, @@ -175,7 +175,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_invalid_input", + "code": "negotiation.invalid_input", "llmCalls": 1 } }, @@ -193,7 +193,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_content_extract_failed", + "code": "negotiation.content_extract_failed", "llmCalls": 1 } }, @@ -211,7 +211,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_invalid_input", + "code": "negotiation.invalid_input", "llmCalls": 1 } }, @@ -229,7 +229,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_slot_missing", + "code": "negotiation.field_missing", "llmCalls": 1 } }, @@ -247,7 +247,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_slot_missing", + "code": "negotiation.field_missing", "llmCalls": 1 } }, @@ -265,7 +265,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_content_extract_failed", + "code": "negotiation.content_extract_failed", "llmCalls": 1 } } diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/programming-errors.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/programming-errors.json index 725289a6..3c43e12a 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/programming-errors.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/programming-errors.json @@ -46,7 +46,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_invalid_input", + "code": "negotiation.invalid_input", "messageContains": ["must not be blank"], "llmCalls": 0 } @@ -65,7 +65,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_invalid_input", + "code": "negotiation.invalid_input", "messageContains": ["must not be blank"], "llmCalls": 0 } diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/retry.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/retry.json index 6e99ce34..39b06f6f 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/retry.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/retry.json @@ -5,7 +5,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["retry", "extract-failed", "exhaustion", "max-attempts-1"], - "summary": "最大尝试次数为1时大模型返回不可解析内容,不发起任何重试即以抽取失败错误码失败", + "summary": "最大尝试次数为1时大模型返回不可解析内容,不发起任何重试即以 LLM 响应无效错误码失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, @@ -13,7 +13,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_content_extract_failed", + "code": "llm.response_invalid", "llmCalls": 1 } }, @@ -23,7 +23,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["retry", "extract-failed", "exhaustion", "max-attempts-2"], - "summary": "最大尝试次数为2时大模型连续返回空响应与空白内容,尝试耗尽后仍以原始的抽取失败错误码失败", + "summary": "最大尝试次数为2时大模型连续返回空响应与空白内容,尝试耗尽后仍以原始的 LLM 响应无效错误码失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, @@ -31,7 +31,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_content_extract_failed", + "code": "llm.response_invalid", "llmCalls": 2 } }, @@ -41,7 +41,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["retry", "extract-failed", "exhaustion", "max-attempts-3"], - "summary": "最大尝试次数为3时三种不可解析形态依次出现,三次尝试耗尽后以抽取失败错误码失败", + "summary": "最大尝试次数为3时三种不可解析形态依次出现,三次尝试耗尽后以 LLM 响应无效错误码失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, @@ -49,7 +49,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_content_extract_failed", + "code": "llm.response_invalid", "llmCalls": 3 } }, @@ -59,7 +59,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["retry", "infrastructure-error", "exhaustion", "max-attempts-1"], - "summary": "最大尝试次数为1时底层通道抛出运行时异常,不重试即以基础设施错误码失败且异常细节不外泄", + "summary": "最大尝试次数为1时底层通道抛出运行时异常,不重试即以 llm.invocation_failed 失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, @@ -67,8 +67,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_llm_infrastructure_error", - "contracts": ["noLlmLeakInUserMessage"], + "code": "llm.invocation_failed", "llmCalls": 1 } }, @@ -78,7 +77,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["retry", "infrastructure-error", "exhaustion", "max-attempts-2"], - "summary": "最大尝试次数为2时底层先后出现传输异常与错误响应,尝试耗尽后仍以基础设施错误码失败", + "summary": "最大尝试次数为2时底层先后出现传输异常与错误响应,尝试耗尽后仍以 llm.invocation_failed 失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, @@ -86,8 +85,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_llm_infrastructure_error", - "contracts": ["noLlmLeakInUserMessage"], + "code": "llm.invocation_failed", "llmCalls": 2 } }, @@ -97,7 +95,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["retry", "infrastructure-error", "exhaustion", "max-attempts-3"], - "summary": "最大尝试次数为3时基础设施错误持续出现,三次尝试耗尽后以基础设施错误码失败", + "summary": "最大尝试次数为3时基础设施错误持续出现,三次尝试耗尽后以 llm.invocation_failed 失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { "zh-CN": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮协商拒绝。" } }, @@ -105,8 +103,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_llm_infrastructure_error", - "contracts": ["noLlmLeakInUserMessage"], + "code": "llm.invocation_failed", "llmCalls": 3 } }, @@ -248,7 +245,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_slot_missing", + "code": "negotiation.field_missing", "llmCalls": 1 } }, @@ -266,7 +263,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_invalid_input", + "code": "negotiation.conclusion_mismatch", "llmCalls": 1 } }, @@ -276,7 +273,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["retry", "exhaustion", "original-code"], - "summary": "两次失败形态不同(基础设施错误后接不可解析内容)时,耗尽后重抛最后一次失败的原始错误码而非统一的耗尽错误码", + "summary": "两次失败形态不同(基础设施错误后接不可解析内容)时,耗尽后重抛最后一次失败的原始错误码(llm.response_invalid)而非统一的耗尽错误码", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, @@ -284,7 +281,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "negotiation_content_extract_failed", + "code": "llm.response_invalid", "llmCalls": 2 } } diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/template-resolution.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/template-resolution.json index 3a94c9c7..f842ce70 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/template-resolution.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/template-resolution.json @@ -5,7 +5,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["template-resolution", "template-not-found", "inject"], - "summary": "模板加载器不可用时生成投诉诊断信息协商接受消息失败并返回 template_not_found 错误码,且不发起任何 LLM 调用", + "summary": "模板加载器不可用时生成投诉诊断信息协商接受消息失败并返回 template.not_found 错误码,且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, @@ -14,7 +14,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "template_not_found", + "code": "template.not_found", "llmCalls": 0 } }, @@ -24,7 +24,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["template-resolution", "template-not-found", "inject", "propose", "no-retry"], - "summary": "模板加载器不可用时生成要数提议消息失败并返回 template_not_found 错误码,模板加载发生在重试循环之外,即使配置了重试次数也不重试且不发起任何 LLM 调用", + "summary": "模板加载器不可用时生成要数提议消息失败并返回 template.not_found 错误码,模板加载发生在重试循环之外,即使配置了重试次数也不重试且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, @@ -33,7 +33,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "template_not_found", + "code": "template.not_found", "llmCalls": 0 } }, @@ -43,7 +43,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["template-resolution", "template-not-found", "inject", "reject"], - "summary": "模板加载器不可用时生成信息协商拒绝消息失败并返回 template_not_found 错误码,且不发起任何 LLM 调用", + "summary": "模板加载器不可用时生成信息协商拒绝消息失败并返回 template.not_found 错误码,且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "input": { "text": { "zh-CN": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮协商拒绝。" } }, @@ -52,7 +52,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "template_not_found", + "code": "template.not_found", "llmCalls": 0 } }, @@ -62,7 +62,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["template-resolution", "template-not-found", "inject", "abort", "common"], - "summary": "模板加载器不可用时生成协商终止消息失败并返回 template_not_found 错误码,通用终止模板同样无法加载且不发起任何 LLM 调用", + "summary": "模板加载器不可用时生成协商终止消息失败并返回 template.not_found 错误码,通用终止模板同样无法加载且不发起任何 LLM 调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", "input": { "text": { "zh-CN": "已达到协商轮次上限,本次协商确认终止。" } }, @@ -71,7 +71,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationGenerationException", - "code": "template_not_found", + "code": "template.not_found", "llmCalls": 0 } } diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/live/task-apis.json b/a2a-t-corpus/src/test/resources/negotiation-cases/live/task-apis.json index db28e869..0d17f3f7 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/live/task-apis.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/live/task-apis.json @@ -185,7 +185,7 @@ "task", "from-text" ], - "summary": "槽位值归一化探针:问题发生时间以口语化中文(“5月16日上午10点20分左右”)给出,同时提供了归一化后的 ISO-8601 时间字段锚点;qwen3-32b 对 faultTime 的提取不稳定,validate 步骤可能因 format_error 拒绝,期望 success=false 时仅断言结果成败", + "summary": "槽位值归一化探针:问题发生时间以口语化中文(“5月16日上午10点20分左右”)给出,同时提供了归一化后的 ISO-8601 时间字段锚点;qwen3-32b 对 faultTime 的提取不稳定,validate 步骤可能因 content.format_error 拒绝,期望 success=false 时仅断言结果成败", "templateUri": "Task-T/network-layer/private-line-complaint/v1", "input": { "text": { diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/boundary-flows.json b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/boundary-flows.json index 2e2e2d5f..e5b9ec56 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/boundary-flows.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/boundary-flows.json @@ -219,8 +219,8 @@ "schema": { "$ref": "schemas/biz.complaint.params" }, "expect": { "outcome": "failure", - "code": "negotiation_rule_violation", - "slotErrors": [ { "slot": "round", "code": "out_of_range" } ], + "code": "negotiation.rule_violation", + "slotErrors": [ { "slot": "round", "code": "negotiation.round_exceeded" } ], "llmCalls": 0 } }, @@ -486,8 +486,8 @@ "llm": { "script": [ { "$ref": "responses/semantic.information.reject" } ] }, "expect": { "outcome": "failure", - "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "accessPort", "code": "missing" } ], + "code": "negotiation.semantic_rejected", + "slotErrors": [ { "slot": "accessPort", "code": "negotiation.field_missing" } ], "llmCalls": 1 } }, @@ -709,15 +709,15 @@ "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"投诉分类\",\"code\":\"missing\",\"message\":\"The message is truncated and the item is missing.\"},{\"slot_name\":\"专线业务标识\",\"code\":\"missing\",\"message\":\"The message is truncated and the item is missing.\"}],\"params\":{}}" + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"投诉分类\",\"code\":\"missing\",\"facts\":{\"section_label\":\"投诉分类\"}},{\"slot_name\":\"专线业务标识\",\"code\":\"missing\",\"facts\":{\"section_label\":\"专线业务标识\"}}],\"params\":{}}" ] }, "expect": { "outcome": "failure", - "code": "negotiation_semantic_rejected", + "code": "negotiation.semantic_rejected", "slotErrors": [ - { "slot": "投诉分类", "code": "missing" }, - { "slot": "专线业务标识", "code": "missing" } + { "slot": "投诉分类", "code": "negotiation.rule_violation" }, + { "slot": "专线业务标识", "code": "negotiation.rule_violation" } ], "llmCalls": 1 } @@ -815,15 +815,15 @@ "schema": { "$ref": "schemas/biz.complaint.params" }, "llm": { "script": [ - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"接入端口名称\",\"code\":\"unreadable\",\"message\":\"The item value is garbled and unreadable.\"},{\"slot_name\":\"投诉分类\",\"code\":\"unreadable\",\"message\":\"The item value is garbled and unreadable.\"}],\"params\":{}}" + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"接入端口名称\",\"code\":\"unreadable\",\"facts\":{\"section_label\":\"接入端口名称\"}},{\"slot_name\":\"投诉分类\",\"code\":\"unreadable\",\"facts\":{\"section_label\":\"投诉分类\"}}],\"params\":{}}" ] }, "expect": { "outcome": "failure", - "code": "negotiation_semantic_rejected", + "code": "negotiation.semantic_rejected", "slotErrors": [ - { "slot": "接入端口名称", "code": "unreadable" }, - { "slot": "投诉分类", "code": "unreadable" } + { "slot": "接入端口名称", "code": "negotiation.rule_violation" }, + { "slot": "投诉分类", "code": "negotiation.rule_violation" } ], "llmCalls": 1 } diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json b/a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json index 5e4baf0c..b3d0eedb 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json @@ -24,11 +24,11 @@ "extract.feasibility.propose.evaluation_without_contents": "{\"feasibility_negotiation_description\":\"请评估专线接入端口扩容方案能否在割接窗口内完成。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null}", "extract.common.abort.missing_termination_reason": "{}", "semantic.information.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\",\"bizScenario\":\"专线质差\"}}", - "semantic.information.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"message\":\"The access port parameter is missing.\"}],\"params\":{}}", + "semantic.information.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":\"接入端口名称\"}}],\"params\":{}}", "semantic.target.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[],\"params\":{\"latency_target\":\"within 20ms\",\"repair_deadline\":\"2026-05-15\"}}", - "semantic.target.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"target\",\"errors\":[{\"slot_name\":\"confirmed_intent\",\"code\":\"missing\",\"message\":\"The confirmed intent is missing.\"}],\"params\":{}}", + "semantic.target.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"target\",\"errors\":[{\"slot_name\":\"confirmed_intent\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":\"最终确认的意图\"}}],\"params\":{}}", "semantic.feasibility.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"feasibility\",\"errors\":[],\"params\":{\"port_expansion\":\"feasible\"}}", - "semantic.feasibility.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"feasibility\",\"errors\":[{\"slot_name\":\"feasibility_summary\",\"code\":\"missing\",\"message\":\"The feasibility summary is missing.\"}],\"params\":{}}", + "semantic.feasibility.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"feasibility\",\"errors\":[{\"slot_name\":\"feasibility_summary\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":\"可行性总结\"}}],\"params\":{}}", "semantic.common.abort.full": "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{\"termination_reason\":\"已达到协商轮次上限\"}}", "semantic.information.type_mismatch": "{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[],\"params\":{}}", "semantic.information.null_type": "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}", diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/error-code-mapping.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/error-code-mapping.json index b1caab02..b3446ebd 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/error-code-mapping.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/error-code-mapping.json @@ -5,7 +5,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["error-code-mapping", "map-code", "null-context", "invalid-input"], - "summary": "消息未携带协商上下文元数据时校验判定其不是协商消息,内部无效输入错误码对外映射为协商无效输入错误且不发起任何大模型调用", + "summary": "消息未携带协商上下文元数据时校验判定其不是协商消息,按非协商消息拒绝并返回协商无效输入错误码且不发起任何大模型调用", "context": null, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, @@ -14,8 +14,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", - "messageContains": ["missing negotiation context"], + "code": "negotiation.invalid_input", + "messageContains": ["缺少协商上下文"], "llmCalls": 0 } }, @@ -25,7 +25,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["error-code-mapping", "map-code", "rule-violation", "round"], - "summary": "协商轮次超限触发的内部规则违反错误码对外映射为协商规则违反错误码,并保留轮次越界槽位错误", + "summary": "协商轮次超限触发协商规则违反错误码,并保留轮次越界槽位错误", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 6, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, @@ -34,8 +34,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_rule_violation", - "slotErrors": [ { "slot": "round", "code": "out_of_range" } ], + "code": "negotiation.rule_violation", + "slotErrors": [ { "slot": "round", "code": "negotiation.round_exceeded" } ], "llmCalls": 0 } }, @@ -45,7 +45,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["error-code-mapping", "map-code", "semantic-rejected"], - "summary": "大模型语义校验否决要数提议消息时内部语义拒绝错误码对外映射为协商语义拒绝错误码,并保留大模型报告的接入端口缺失槽位错误", + "summary": "大模型语义校验否决要数提议消息时返回协商语义拒绝错误码,并保留大模型报告的接入端口缺失槽位错误", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, @@ -54,8 +54,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "accessPort", "code": "missing" } ], + "code": "negotiation.semantic_rejected", + "slotErrors": [ { "slot": "accessPort", "code": "negotiation.field_missing" } ], "llmCalls": 1 } }, @@ -64,8 +64,8 @@ "api": "validateProposePromptAndDataFilling", "languages": ["zh-CN"], "priority": "P0", - "tags": ["error-code-mapping", "map-code", "llm-infrastructure-error", "retry-exhausted"], - "summary": "大模型服务连续两次不可用并耗尽重试后,内部基础设施错误码对外映射为协商大模型基础设施错误码,错误详情以 _llm 伪槽位保留内部错误码", + "tags": ["error-code-mapping", "llm-invocation-failed", "retry-exhausted"], + "summary": "大模型服务连续两次不可用并耗尽重试后,以 llm.invocation_failed 错误码失败,错误以 _llm 伪槽位保留", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, @@ -74,9 +74,9 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_llm_infrastructure_error", - "messageContains": ["Semantic validation LLM step failed"], - "slotErrors": [ { "slot": "_llm", "code": "validation_llm_infrastructure_error" } ], + "code": "llm.invocation_failed", + "messageContains": ["LLM 调用失败"], + "slotErrors": [ { "slot": "_llm", "code": "llm.invocation_failed" } ], "llmCalls": 2 } }, @@ -86,7 +86,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["error-code-mapping", "map-code", "template-not-found", "inject"], - "summary": "语义校验提示词资源缺失时内部资源未找到错误码对外映射为 template_not_found 错误码,原始资源异常不透出且不发起任何大模型调用", + "summary": "语义校验提示词资源缺失时映射为 template.not_found 错误码,原始资源异常不透出且不发起任何大模型调用", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, @@ -96,8 +96,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "template_not_found", - "messageContains": ["Negotiation semantic validation prompt resource does not exist"], + "code": "template.not_found", + "messageContains": ["不支持语言"], "llmCalls": 0 } } diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json index 6026bc3b..b9be78bf 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json @@ -64,7 +64,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["retry-exhausted", "shape-invalid", "llm-pseudo-slot", "propose", "information"], - "summary": "语义返回连续三种无效结构时重试至耗尽,最终携带 _llm 伪槽位以 LLM 基础设施错误失败", + "summary": "语义返回连续三种无效结构时重试至耗尽,最终携带 _llm 伪槽位以 llm.response_invalid 错误失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, @@ -80,8 +80,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_llm_infrastructure_error", - "slotErrors": [ { "slot": "_llm", "code": "validation_llm_infrastructure_error" } ], + "code": "llm.response_invalid", + "slotErrors": [ { "slot": "_llm", "code": "llm.response_invalid" } ], "llmCalls": 3 } }, @@ -91,7 +91,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["retry", "max-attempts-1", "shape-invalid", "llm-pseudo-slot", "propose"], - "summary": "maxAttempts 配置为 1 时语义结构错误不获重试机会,单次调用即以 LLM 基础设施错误失败", + "summary": "maxAttempts 配置为 1 时语义结构错误不获重试机会,单次调用即以 llm.response_invalid 错误失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, @@ -103,8 +103,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_llm_infrastructure_error", - "slotErrors": [ { "slot": "_llm", "code": "validation_llm_infrastructure_error" } ], + "code": "llm.response_invalid", + "slotErrors": [ { "slot": "_llm", "code": "llm.response_invalid" } ], "llmCalls": 1 } }, @@ -114,7 +114,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["retry-exhausted", "llm-infrastructure-error", "max-attempts-2", "llm-pseudo-slot", "accept"], - "summary": "LLM 基础设施错误连续出现时按 maxAttempts=2 重试两次后耗尽,携带 _llm 伪槽位失败", + "summary": "LLM 传输错误连续出现时按 maxAttempts=2 重试两次后耗尽,携带 _llm 伪槽位以 llm.invocation_failed 失败", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "golden": "information_accept" }, @@ -126,8 +126,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_llm_infrastructure_error", - "slotErrors": [ { "slot": "_llm", "code": "validation_llm_infrastructure_error" } ], + "code": "llm.invocation_failed", + "slotErrors": [ { "slot": "_llm", "code": "llm.invocation_failed" } ], "llmCalls": 2 } }, @@ -197,20 +197,17 @@ "languages": ["zh-CN"], "priority": "P2", "tags": ["no-retry", "runtime-exception", "llm-infrastructure-error", "accept", "information"], - "summary": "语义步骤抛出非 LLMError 的原始运行时异常时不重试,单次调用即包装为 LLM 基础设施错误失败", + "summary": "语义步骤抛出非 LLMError 的原始运行时异常时以 llm.response_invalid 错误失败(原始运行时异常现已与其他 LLM 步骤失败一样按预算重试,本探针以 maxAttempts=1 固化单次调用形态)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "golden": "information_accept" }, "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "maxAttempts": 3, - "script": [ { "$fail": "runtime-exception" }, { "$ref": "responses/semantic.information.accept.full" } ] - }, + "llm": { "maxAttempts": 1, "script": [ { "$fail": "runtime-exception" } ] }, "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_llm_infrastructure_error", - "slotErrors": [ { "slot": "_llm", "code": "validation_llm_infrastructure_error" } ], + "code": "llm.response_invalid", + "slotErrors": [ { "slot": "_llm", "code": "llm.response_invalid" } ], "llmCalls": 1 } }, @@ -220,20 +217,17 @@ "languages": ["zh-CN"], "priority": "P2", "tags": ["no-retry", "null-response", "llm-infrastructure-error", "accept", "information"], - "summary": "语义步骤返回空响应(null)时不重试,单次调用即包装为 LLM 基础设施错误失败", + "summary": "语义步骤返回空响应(null)时以 llm.response_invalid 错误失败(空响应现已按预算重试,本探针以 maxAttempts=1 固化单次调用形态)", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "golden": "information_accept" }, "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "maxAttempts": 3, - "script": [ { "$fail": "null-response" }, { "$ref": "responses/semantic.information.accept.full" } ] - }, + "llm": { "maxAttempts": 1, "script": [ { "$fail": "null-response" } ] }, "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_llm_infrastructure_error", - "slotErrors": [ { "slot": "_llm", "code": "validation_llm_infrastructure_error" } ], + "code": "llm.response_invalid", + "slotErrors": [ { "slot": "_llm", "code": "llm.response_invalid" } ], "llmCalls": 1 } } diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/merge-and-schema.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/merge-and-schema.json index 39299b7e..c393519d 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/merge-and-schema.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/merge-and-schema.json @@ -58,7 +58,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["null-context", "invalid-input", "rule-gate", "propose"], - "summary": "校验要数提议消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", + "summary": "校验要数提议消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用", "context": null, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "text": "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识三项信息,三项之间为或的关系。" }, @@ -66,8 +66,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", - "messageContains": ["missing negotiation context"], + "code": "negotiation.invalid_input", + "messageContains": ["缺少协商上下文"], "llmCalls": 0 } }, @@ -77,7 +77,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["null-context", "invalid-input", "rule-gate", "accept"], - "summary": "校验补数接受消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", + "summary": "校验补数接受消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用", "context": null, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "text": "我确认补充的信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" }, @@ -85,8 +85,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", - "messageContains": ["missing negotiation context"], + "code": "negotiation.invalid_input", + "messageContains": ["缺少协商上下文"], "llmCalls": 0 } }, @@ -96,7 +96,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["null-context", "invalid-input", "rule-gate", "reject"], - "summary": "校验拒绝消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", + "summary": "校验拒绝消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用", "context": null, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "text": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮信息协商拒绝。" }, @@ -104,8 +104,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", - "messageContains": ["missing negotiation context"], + "code": "negotiation.invalid_input", + "messageContains": ["缺少协商上下文"], "llmCalls": 0 } }, @@ -115,7 +115,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["null-context", "invalid-input", "rule-gate", "abort", "common"], - "summary": "校验终止消息时未携带协商上下文,判定为非协商消息并以 negotiation_invalid_input 失败,不发起 LLM 调用", + "summary": "校验终止消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用", "context": null, "templateUri": "Negotiation-T/common/abort/v1", "prompt": { "text": "已达到协商轮次上限,本次协商确认终止。" }, @@ -123,8 +123,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", - "messageContains": ["missing negotiation context"], + "code": "negotiation.invalid_input", + "messageContains": ["缺少协商上下文"], "llmCalls": 0 } } diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json index e0c41602..46dde12c 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json @@ -5,7 +5,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-prompt", "propose"], - "summary": "校验要数提议消息时未传入消息文本,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", + "summary": "校验要数提议消息时未传入消息文本,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "schema": { "$ref": "schemas/biz.complaint.params" }, @@ -13,7 +13,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", + "code": "negotiation.invalid_input", "llmCalls": 0 } }, @@ -23,7 +23,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "blank-prompt", "propose"], - "summary": "校验要数提议消息时消息文本为空白,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", + "summary": "校验要数提议消息时消息文本为空白,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "text": " " }, @@ -32,7 +32,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", + "code": "negotiation.invalid_input", "messageContains": ["Prompt must not be null or blank."], "llmCalls": 0 } @@ -43,7 +43,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-prompt", "accept"], - "summary": "校验补数接受消息时未传入消息文本,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", + "summary": "校验补数接受消息时未传入消息文本,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "schema": { "$ref": "schemas/biz.complaint.params" }, @@ -51,7 +51,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", + "code": "negotiation.invalid_input", "llmCalls": 0 } }, @@ -61,7 +61,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "blank-prompt", "accept"], - "summary": "校验补数接受消息时消息文本为空白,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", + "summary": "校验补数接受消息时消息文本为空白,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "text": " " }, @@ -70,7 +70,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", + "code": "negotiation.invalid_input", "messageContains": ["Prompt must not be null or blank."], "llmCalls": 0 } @@ -81,7 +81,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-prompt", "reject"], - "summary": "校验修复目标拒绝消息时未传入消息文本,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", + "summary": "校验修复目标拒绝消息时未传入消息文本,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "schema": { "$ref": "schemas/empty" }, @@ -89,7 +89,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", + "code": "negotiation.invalid_input", "llmCalls": 0 } }, @@ -99,7 +99,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "blank-prompt", "reject"], - "summary": "校验修复目标拒绝消息时消息文本为空白,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", + "summary": "校验修复目标拒绝消息时消息文本为空白,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "prompt": { "text": " " }, @@ -108,7 +108,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", + "code": "negotiation.invalid_input", "messageContains": ["Prompt must not be null or blank."], "llmCalls": 0 } @@ -119,7 +119,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-prompt", "abort"], - "summary": "校验终止消息时未传入消息文本,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", + "summary": "校验终止消息时未传入消息文本,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", "context": { "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", "schema": { "$ref": "schemas/empty" }, @@ -127,7 +127,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", + "code": "negotiation.invalid_input", "llmCalls": 0 } }, @@ -137,7 +137,7 @@ "languages": ["zh-CN"], "priority": "P1", "tags": ["programming-error", "blank-prompt", "abort"], - "summary": "校验终止消息时消息文本为空白,按无效输入拒绝并返回 negotiation_invalid_input,不调用大模型", + "summary": "校验终止消息时消息文本为空白,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", "context": { "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5 }, "templateUri": "Negotiation-T/common/abort/v1", "prompt": { "text": " " }, @@ -146,7 +146,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", + "code": "negotiation.invalid_input", "messageContains": ["Prompt must not be null or blank."], "llmCalls": 0 } @@ -157,7 +157,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-context", "propose"], - "summary": "校验要数提议消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", + "summary": "校验要数提议消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input", "context": null, "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, @@ -166,8 +166,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", - "messageContains": ["missing negotiation context"], + "code": "negotiation.invalid_input", + "messageContains": ["缺少协商上下文"], "llmCalls": 0 } }, @@ -177,7 +177,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-context", "accept"], - "summary": "校验补数接受消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", + "summary": "校验补数接受消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input", "context": null, "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "golden": "information_accept" }, @@ -186,8 +186,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", - "messageContains": ["missing negotiation context"], + "code": "negotiation.invalid_input", + "messageContains": ["缺少协商上下文"], "llmCalls": 0 } }, @@ -197,7 +197,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-context", "reject"], - "summary": "校验修复目标拒绝消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", + "summary": "校验修复目标拒绝消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input", "context": null, "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", "prompt": { "golden": "target_reject" }, @@ -206,8 +206,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", - "messageContains": ["missing negotiation context"], + "code": "negotiation.invalid_input", + "messageContains": ["缺少协商上下文"], "llmCalls": 0 } }, @@ -217,7 +217,7 @@ "languages": ["zh-CN"], "priority": "P0", "tags": ["programming-error", "null-context", "abort"], - "summary": "校验终止消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation_invalid_input", + "summary": "校验终止消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input", "context": null, "templateUri": "Negotiation-T/common/abort/v1", "prompt": { "golden": "abort" }, @@ -226,8 +226,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_invalid_input", - "messageContains": ["missing negotiation context"], + "code": "negotiation.invalid_input", + "messageContains": ["缺少协商上下文"], "llmCalls": 0 } }, diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/rule-gate.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/rule-gate.json index 788c579e..e51241ea 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/rule-gate.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/rule-gate.json @@ -14,8 +14,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_rule_violation", - "slotErrors": [ { "slot": "id", "code": "invalid_uuid" } ], + "code": "negotiation.rule_violation", + "slotErrors": [ { "slot": "id", "code": "negotiation.invalid_context_id" } ], "llmCalls": 0 } }, @@ -34,8 +34,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_rule_violation", - "slotErrors": [ { "slot": "id", "code": "invalid_uuid" } ], + "code": "negotiation.rule_violation", + "slotErrors": [ { "slot": "id", "code": "negotiation.invalid_context_id" } ], "llmCalls": 0 } }, @@ -54,8 +54,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_rule_violation", - "slotErrors": [ { "slot": "id", "code": "invalid_uuid" } ], + "code": "negotiation.rule_violation", + "slotErrors": [ { "slot": "id", "code": "negotiation.invalid_context_id" } ], "llmCalls": 0 } }, @@ -99,8 +99,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_rule_violation", - "slotErrors": [ { "slot": "round", "code": "out_of_range" } ], + "code": "negotiation.rule_violation", + "slotErrors": [ { "slot": "round", "code": "negotiation.round_exceeded" } ], "llmCalls": 0 } }, @@ -119,10 +119,10 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_rule_violation", + "code": "negotiation.rule_violation", "slotErrors": [ - { "slot": "id", "code": "invalid_uuid" }, - { "slot": "round", "code": "out_of_range" } + { "slot": "id", "code": "negotiation.invalid_context_id" }, + { "slot": "round", "code": "negotiation.round_exceeded" } ], "llmCalls": 0 } diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/semantic-verdicts.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/semantic-verdicts.json index 013af0c5..6d75589e 100644 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/semantic-verdicts.json +++ b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/semantic-verdicts.json @@ -160,8 +160,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "section.target", "code": "template_type_mismatch" } ], + "code": "negotiation.semantic_rejected", + "slotErrors": [ { "slot": "section.target", "code": "negotiation.type_mismatch" } ], "llmCalls": 1 } }, @@ -180,8 +180,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "section.info_static", "code": "template_type_mismatch" } ], + "code": "negotiation.semantic_rejected", + "slotErrors": [ { "slot": "section.info_static", "code": "negotiation.type_mismatch" } ], "llmCalls": 1 } }, @@ -200,8 +200,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "section.info_static", "code": "template_type_mismatch" } ], + "code": "negotiation.semantic_rejected", + "slotErrors": [ { "slot": "section.info_static", "code": "negotiation.type_mismatch" } ], "llmCalls": 1 } }, @@ -220,8 +220,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "section.info_static", "code": "template_type_mismatch" } ], + "code": "negotiation.semantic_rejected", + "slotErrors": [ { "slot": "section.info_static", "code": "negotiation.type_mismatch" } ], "llmCalls": 1 } }, @@ -240,8 +240,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "accessPort", "code": "missing" } ], + "code": "negotiation.semantic_rejected", + "slotErrors": [ { "slot": "accessPort", "code": "negotiation.field_missing" } ], "llmCalls": 1 } }, @@ -256,12 +256,12 @@ "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", "prompt": { "golden": "information_reject" }, "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"target\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"message\":\"The access port parameter is missing.\"}],\"params\":{}}" ] }, + "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"target\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"facts\":{\"section_label\":\"接入端口名称\"}}],\"params\":{}}" ] }, "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "accessPort", "code": "missing" } ], + "code": "negotiation.semantic_rejected", + "slotErrors": [ { "slot": "accessPort", "code": "negotiation.rule_violation" } ], "llmCalls": 1 } }, @@ -280,7 +280,7 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_semantic_rejected", + "code": "negotiation.semantic_rejected", "llmCalls": 1 } }, @@ -295,12 +295,12 @@ "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "golden": "information_propose" }, "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"message\":\"The access port parameter is missing.\"}],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}" ] }, + "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"facts\":{\"section_label\":\"接入端口名称\"}}],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}" ] }, "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "accessPort", "code": "missing" } ], + "code": "negotiation.semantic_rejected", + "slotErrors": [ { "slot": "accessPort", "code": "negotiation.rule_violation" } ], "llmCalls": 1 } }, @@ -315,12 +315,12 @@ "templateUri": "Negotiation-T/common/abort/v1", "prompt": { "golden": "abort" }, "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":\"termination_reason\",\"code\":\"missing\",\"message\":\"The termination reason is missing.\"}],\"params\":{}}" ] }, + "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":\"termination_reason\",\"code\":\"missing\",\"facts\":{\"section_label\":\"协商终止原因\"}}],\"params\":{}}" ] }, "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "termination_reason", "code": "missing" } ], + "code": "negotiation.semantic_rejected", + "slotErrors": [ { "slot": "termination_reason", "code": "negotiation.rule_violation" } ], "llmCalls": 1 } }, @@ -345,8 +345,8 @@ "expect": { "outcome": "failure", "exception": "NegotiationParamExtractionException", - "code": "negotiation_semantic_rejected", - "slotErrors": [ { "slot": "accessPort", "code": "missing" } ], + "code": "negotiation.semantic_rejected", + "slotErrors": [ { "slot": "accessPort", "code": "negotiation.field_missing" } ], "llmCalls": 1 } }, @@ -361,7 +361,7 @@ "templateUri": "Negotiation-T/information-negotiation/propose/v1", "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"message\":\"The access port parameter is missing.\"}],\"params\":{}}" ] }, + "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"facts\":{\"section_label\":\"接入端口名称\"}}],\"params\":{}}" ] }, "expect": { "outcome": "success", "llmCalls": 1, diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java index 8e1cd8d7..adb30492 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java @@ -22,7 +22,6 @@ import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; @@ -426,6 +425,7 @@ private static List> formatActualSlotErrors(ListThe expected outcome is staged: {@link ClientExpected} asserts the client SDK behaviour (generation - * failure with slot errors, or the exact rendered prompt text), {@link ServerExpected} asserts the server - * SDK behaviour (validation outcome, slot-level error details and the extracted parameters). - * - * @param label human-readable scenario label - * @param entry client entry point ({@code from_text} or {@code from_data_with_schema}) - * @param input scenario input data - * @param expected staged expected outcome - * @param validateSchema optional scenario-level validation schema that overrides the suite-level - * default parameter schema; {@code null} when the suite-level default applies - * @since 2026-08 - */ -public record AuthzScenario( - String label, String entry, Map input, AuthzExpected expected, - Map validateSchema) { - - /** - * Convenience constructor for scenarios without a scenario-level validation schema. - * - * @param label human-readable scenario label - * @param entry client entry point ({@code from_text} or {@code from_data_with_schema}) - * @param input scenario input data - * @param expected staged expected outcome - */ - public AuthzScenario(String label, String entry, Map input, AuthzExpected expected) { - this(label, entry, input, expected, null); - } - - static final String FROM_TEXT = "from_text"; - static final String FROM_DATA_WITH_SCHEMA = "from_data_with_schema"; - static final String EXPECTED_SUCCESS = "success"; - - static final Set VALID_SERVER_OUTCOMES = Set.of( - EXPECTED_SUCCESS, - A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED, - A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, - A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, - A2ATErrorCodes.LLM_INVOCATION_FAILED); - - static final Set VALID_CLIENT_OUTCOMES = Set.of( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, - A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, - A2ATErrorCodes.LLM_INVOCATION_FAILED, - A2ATErrorCodes.SLOT_SCHEMA_NOT_FOUND, - A2ATErrorCodes.TEMPLATE_NOT_FOUND, - A2ATErrorCodes.RENDER_FAILED, - A2ATErrorCodes.PROMPT_RESOURCE_LOAD_ERROR); - - /** - * Staged expected outcome of one scenario. - * - * @param client client-stage expectation; a non-null client outcome declares an expected generation - * failure (in which case {@code server} must be {@code null}), a null client outcome declares an - * expected successful generation whose {@code promptText} is asserted - * @param server server-stage expectation; {@code null} when the client stage is expected to fail - */ - public record AuthzExpected(ClientExpected client, ServerExpected server) {} - - /** - * Client-stage expectation. - * - * @param outcome expected client failure code; {@code null} means generation is expected to succeed - * @param promptText expected rendered prompt text; asserted with trailing-whitespace-insensitive - * equality when {@code outcome} is {@code null} - * @param slotErrors expected per-slot error details of the failed generation; may be {@code null} or - * empty when only the outcome code matters - */ - public record ClientExpected(String outcome, String promptText, List slotErrors) {} - - /** - * Server-stage expectation. - * - * @param outcome expected server outcome code ({@code success} or {@code validation_semantic_rejected}) - * @param slotErrors expected per-slot error details; may be {@code null} or empty when only the - * outcome code matters - * @param params expected extracted parameters; asserted as a subset match (recursive over maps and - * ordered lists) and only meaningful when {@code outcome} is {@code success} - */ - public record ServerExpected(String outcome, List slotErrors, Map params) {} - - /** - * Expected error detail for a single slot. - * - * @param slotName name of the slot expected to fail - * @param code machine-readable error code expected for this slot - */ - public record SlotErrorExpectation(String slotName, String code) {} - - /** - * Validates that the scenario configuration is well-formed. - * - * @param scenario the scenario to validate - * @throws IllegalArgumentException if the configuration is invalid - */ - public static void validate(AuthzScenario scenario) { - if (scenario == null) { - throw new IllegalArgumentException("scenario must not be null"); - } - if (scenario.input == null) { - throw new IllegalArgumentException("input must not be null"); - } - if (scenario.validateSchema != null && scenario.validateSchema.isEmpty()) { - throw new IllegalArgumentException("validateSchema must be non-empty when set"); - } - if (scenario.expected == null || scenario.expected.client() == null) { - throw new IllegalArgumentException("expected.client must not be null"); - } - ClientExpected client = scenario.expected.client(); - ServerExpected server = scenario.expected.server(); - if (client.outcome() != null) { - if (!VALID_CLIENT_OUTCOMES.contains(client.outcome())) { - throw new IllegalArgumentException("Invalid expected client outcome: " + client.outcome()); - } - if (server != null) { - throw new IllegalArgumentException("expected.server must be null when expected.client.outcome is set"); - } - } else { - if (client.promptText() == null || client.promptText().isBlank()) { - throw new IllegalArgumentException("expected.client.promptText must be non-blank when client outcome is not set"); - } - if (server == null || server.outcome() == null || !VALID_SERVER_OUTCOMES.contains(server.outcome())) { - throw new IllegalArgumentException( - "expected.server.outcome must be one of " + VALID_SERVER_OUTCOMES - + " when client succeeds"); - } - // server.params on a non-success outcome is documentation-only (not asserted), mirroring the probe. - } - if (!FROM_TEXT.equals(scenario.entry) && !FROM_DATA_WITH_SCHEMA.equals(scenario.entry)) { - throw new IllegalArgumentException("Invalid entry: " + scenario.entry); - } - if (FROM_TEXT.equals(scenario.entry)) { - Object text = scenario.input.get("text"); - if (!(text instanceof String) || ((String) text).isBlank()) { - throw new IllegalArgumentException("from_text scenario requires a non-blank input.text"); - } - } - if (FROM_DATA_WITH_SCHEMA.equals(scenario.entry)) { - Object data = scenario.input.get("data"); - if (!(data instanceof Map) || ((Map) data).isEmpty()) { - throw new IllegalArgumentException("from_data_with_schema scenario requires a non-empty input.data"); - } - Object schema = scenario.input.get("schema"); - if (!(schema instanceof Map) || ((Map) schema).isEmpty()) { - throw new IllegalArgumentException("from_data_with_schema scenario requires a non-empty input.schema"); - } - } - } -} +package net.openan.a2at.sample.authz_policy; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; + +/** + * Immutable configuration item for a single demo scenario. + * + *

        The expected outcome is staged: {@link ClientExpected} asserts the client SDK behaviour (generation failure with + * slot errors, or the exact rendered prompt text), {@link ServerExpected} asserts the server SDK behaviour (validation + * outcome, slot-level error details and the extracted parameters). + * + * @param label human-readable scenario label + * @param entry client entry point ({@code from_text} or {@code from_data_with_schema}) + * @param input scenario input data + * @param expected staged expected outcome + * @param validateSchema optional scenario-level validation schema that overrides the suite-level default parameter + * schema; {@code null} when the suite-level default applies + * @since 2026-08 + */ +public record AuthzScenario( + String label, + String entry, + Map input, + AuthzExpected expected, + Map validateSchema) { + + /** + * Convenience constructor for scenarios without a scenario-level validation schema. + * + * @param label human-readable scenario label + * @param entry client entry point ({@code from_text} or {@code from_data_with_schema}) + * @param input scenario input data + * @param expected staged expected outcome + */ + public AuthzScenario(String label, String entry, Map input, AuthzExpected expected) { + this(label, entry, input, expected, null); + } + + static final String FROM_TEXT = "from_text"; + static final String FROM_DATA_WITH_SCHEMA = "from_data_with_schema"; + static final String EXPECTED_SUCCESS = "success"; + + static final Set VALID_SERVER_OUTCOMES = Set.of( + EXPECTED_SUCCESS, + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode()); + + static final Set VALID_CLIENT_OUTCOMES = Set.of( + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + ErrorCatalog.SLOT_CONSTRAINT_VIOLATED.getCode(), + ErrorCatalog.SLOT_RULE_VIOLATION.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), + ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode(), + ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode()); + + /** + * Staged expected outcome of one scenario. + * + * @param client client-stage expectation; a non-null client outcome declares an expected generation failure (in + * which case {@code server} must be {@code null}), a null client outcome declares an expected successful + * generation whose {@code promptText} is asserted + * @param server server-stage expectation; {@code null} when the client stage is expected to fail + */ + public record AuthzExpected(ClientExpected client, ServerExpected server) {} + + /** + * Client-stage expectation. + * + * @param outcome expected client failure code; {@code null} means generation is expected to succeed + * @param promptText expected rendered prompt text; asserted with trailing-whitespace-insensitive equality when + * {@code outcome} is {@code null} + * @param slotErrors expected per-slot error details of the failed generation; may be {@code null} or empty when + * only the outcome code matters + */ + public record ClientExpected(String outcome, String promptText, List slotErrors) {} + + /** + * Server-stage expectation. + * + * @param outcome expected server outcome code ({@code success} or {@code negotiation.semantic_rejected}) + * @param slotErrors expected per-slot error details; may be {@code null} or empty when only the outcome code + * matters + * @param params expected extracted parameters; asserted as a subset match (recursive over maps and ordered lists) + * and only meaningful when {@code outcome} is {@code success} + */ + public record ServerExpected(String outcome, List slotErrors, Map params) {} + + /** + * Expected error detail for a single slot. + * + * @param slotName name of the slot expected to fail + * @param code machine-readable error code expected for this slot + */ + public record SlotErrorExpectation(String slotName, String code) {} + + /** + * Validates that the scenario configuration is well-formed. + * + * @param scenario the scenario to validate + * @throws IllegalArgumentException if the configuration is invalid + */ + public static void validate(AuthzScenario scenario) { + if (scenario == null) { + throw new IllegalArgumentException("scenario must not be null"); + } + if (scenario.input == null) { + throw new IllegalArgumentException("input must not be null"); + } + if (scenario.validateSchema != null && scenario.validateSchema.isEmpty()) { + throw new IllegalArgumentException("validateSchema must be non-empty when set"); + } + if (scenario.expected == null || scenario.expected.client() == null) { + throw new IllegalArgumentException("expected.client must not be null"); + } + ClientExpected client = scenario.expected.client(); + ServerExpected server = scenario.expected.server(); + if (client.outcome() != null) { + if (!VALID_CLIENT_OUTCOMES.contains(client.outcome())) { + throw new IllegalArgumentException("Invalid expected client outcome: " + client.outcome()); + } + if (server != null) { + throw new IllegalArgumentException("expected.server must be null when expected.client.outcome is set"); + } + } else { + if (client.promptText() == null || client.promptText().isBlank()) { + throw new IllegalArgumentException( + "expected.client.promptText must be non-blank when client outcome is not set"); + } + if (server == null || server.outcome() == null || !VALID_SERVER_OUTCOMES.contains(server.outcome())) { + throw new IllegalArgumentException( + "expected.server.outcome must be one of " + VALID_SERVER_OUTCOMES + " when client succeeds"); + } + // server.params on a non-success outcome is documentation-only (not asserted), mirroring the probe. + } + if (!FROM_TEXT.equals(scenario.entry) && !FROM_DATA_WITH_SCHEMA.equals(scenario.entry)) { + throw new IllegalArgumentException("Invalid entry: " + scenario.entry); + } + if (FROM_TEXT.equals(scenario.entry)) { + Object text = scenario.input.get("text"); + if (!(text instanceof String) || ((String) text).isBlank()) { + throw new IllegalArgumentException("from_text scenario requires a non-blank input.text"); + } + } + if (FROM_DATA_WITH_SCHEMA.equals(scenario.entry)) { + Object data = scenario.input.get("data"); + if (!(data instanceof Map) || ((Map) data).isEmpty()) { + throw new IllegalArgumentException("from_data_with_schema scenario requires a non-empty input.data"); + } + Object schema = scenario.input.get("schema"); + if (!(schema instanceof Map) || ((Map) schema).isEmpty()) { + throw new IllegalArgumentException("from_data_with_schema scenario requires a non-empty input.schema"); + } + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java index 78c8d57c..2a7dec65 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java @@ -12,7 +12,7 @@ import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.model.TemplateUri; @@ -21,7 +21,7 @@ * using a fixed thread pool, while preserving input order in the returned result list. * *

        Each task runs the complete client→server flow for a single scenario. Internal failures - * are caught per-task and converted to {@code sdk_internal_error} outcomes, isolating the + * are caught per-task and converted to {@code infra.internal_error} outcomes, isolating the * remaining scenarios from a single failure. * *

        An optional {@link ProgressListener} receives per-completion callbacks (synchronized, @@ -203,11 +203,11 @@ public static int resolveWorkers() { private ScenarioOutcome buildInternalErrorOutcome(AuthzScenario scenario, Throwable t) { return new ScenarioOutcome( new ScenarioResult( - A2ATErrorCodes.SDK_INTERNAL_ERROR, + ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), false, - new A2ATError(A2ATErrorCodes.SDK_INTERNAL_ERROR, t.getMessage(), t), + new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), t.getMessage(), t), List.of(new SlotValidationError( - "_llm", A2ATErrorCodes.SDK_INTERNAL_ERROR, t.getMessage())), + "_llm", ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), t.getMessage())), null, null, null, diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java index e7bfcfd1..525d4989 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java @@ -9,7 +9,7 @@ import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; @@ -50,7 +50,7 @@ public ScenarioOutcome run(AuthzScenario scenario, Map paramSche } catch (A2ATError e) { return buildClientErrorOutcome(scenario, e, null); } catch (RuntimeException e) { - A2ATError wrapped = new A2ATError(A2ATErrorCodes.SDK_INTERNAL_ERROR, e.getMessage(), e); + A2ATError wrapped = new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), e.getMessage(), e); return buildClientErrorOutcome(scenario, wrapped, null); } @@ -82,7 +82,7 @@ public ScenarioOutcome run(AuthzScenario scenario, Map paramSche } catch (A2ATError e) { return buildServerErrorOutcome(scenario, e, metadata, clientPromptMatch, warnings); } catch (RuntimeException e) { - A2ATError wrapped = new A2ATError(A2ATErrorCodes.SDK_INTERNAL_ERROR, e.getMessage(), e); + A2ATError wrapped = new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), e.getMessage(), e); return buildServerErrorOutcome(scenario, wrapped, metadata, clientPromptMatch, warnings); } @@ -266,7 +266,7 @@ static List detectWarnings(String promptText) { /** * Result of one scenario run. * - * @param outcome actual outcome code ({@code success} or an {@code A2ATErrorCodes} value) + * @param outcome actual outcome code ({@code success} or an {@code ErrorCatalog} code) * @param match whether the actual behaviour satisfies all staged expectations * @param error the raised error, if any * @param slotErrors actual per-slot error details diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java index 1843e89a..aecdb7cc 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java @@ -46,7 +46,7 @@ * ({@code generateTaskPromptFromDataWithSchema}: every data key maps to one slot value, the schema is passed * as a call parameter) or natural language ({@code generateTaskPromptFromText}, LLM slot extraction); *

      • server validation — {@code validateTaskPromptAndDataFilling} extracts the parameters against the passed - * JSON schema; the missing-slot set (blank slots plus semantic {@code missing_required} errors) decides + * JSON schema; the missing-slot set (blank slots plus semantic {@code content.param_missing} errors) decides * whether negotiation is triggered; *
      • propose generation — for the detected missing slots, {@code generateNegotiationProposePromptFromData} / * {@code FromText} renders the Negotiation-T information-propose message; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java index 5d13008f..623623a0 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java @@ -44,13 +44,14 @@ public final class NegotiationAbnormalEvaluationMain { new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); private static final List NEGOTIATION_ERROR_CODES = List.of( - "template_not_found", - "negotiation_content_extract_failed", - "negotiation_semantic_rejected", - "negotiation_slot_missing", - "negotiation_invalid_input", - "negotiation_rule_violation", - "negotiation_llm_infrastructure_error"); + "template.not_found", + "negotiation.semantic_rejected", + "negotiation.field_missing", + "negotiation.invalid_input", + "negotiation.invalid_context_id", + "negotiation.round_exceeded", + "llm.invocation_failed", + "llm.response_invalid"); private NegotiationAbnormalEvaluationMain() { } @@ -186,34 +187,35 @@ private static List scriptedCases() { return List.of( new ScriptedAbnormalCase("G-PROPOSE-LLM-MALFORMED", "generate_propose", "请补充接入端口名称和投诉分类。", "not-json", false, - "NegotiationGenerationException", "negotiation_content_extract_failed"), + "NegotiationGenerationException", "llm.response_invalid"), new ScriptedAbnormalCase("G-ACCEPT-LLM-INFRA", "generate_accept", "接受协商,端口为 PE1-GZ-ETH-0/0/0.200,投诉分类为专线中断。", "throw", false, - "NegotiationGenerationException", "negotiation_llm_infrastructure_error"), + "NegotiationGenerationException", "llm.invocation_failed"), new ScriptedAbnormalCase("G-REJECT-SLOT-MISSING", "generate_reject", "拒绝协商,原因是资源系统暂时无法查询。", "{\"conclusion\":\"Reject\",\"items\":null}", false, - "NegotiationGenerationException", "negotiation_slot_missing"), + "NegotiationGenerationException", "negotiation.field_missing"), new ScriptedAbnormalCase("V-PROPOSE-SEMANTIC-REJECTED", "validate_propose", "## 信息协商\n请根据<所需信息项>补充相关内容。", semanticRejectedPayload(), false, - "NegotiationParamExtractionException", "negotiation_semantic_rejected"), + "NegotiationParamExtractionException", "negotiation.semantic_rejected"), new ScriptedAbnormalCase("V-ACCEPT-LLM-INFRA", "validate_accept", "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", "throw", false, - "NegotiationParamExtractionException", "negotiation_llm_infrastructure_error"), + "NegotiationParamExtractionException", "llm.invocation_failed"), new ScriptedAbnormalCase("V-REJECT-SEMANTIC-REJECTED", "validate_reject", "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", semanticRejectedPayload(), false, - "NegotiationParamExtractionException", "negotiation_semantic_rejected"), + "NegotiationParamExtractionException", "negotiation.semantic_rejected"), new ScriptedAbnormalCase("G-PROPOSE-TEMPLATE-NOT-FOUND", "generate_propose", "请补充接入端口名称和投诉分类。", "unused", true, - "NegotiationGenerationException", "template_not_found"), + "NegotiationGenerationException", "template.not_found"), new ScriptedAbnormalCase("V-PROPOSE-TEMPLATE-NOT-FOUND", "validate_propose", "## 信息协商\n请根据<所需信息项>补充相关内容。", "unused", true, - "NegotiationParamExtractionException", "template_not_found")); + "NegotiationParamExtractionException", "template.not_found")); } private static String semanticRejectedPayload() { return "{\"semantic_verdict\":false,\"negotiation_type\":\"information\"," + "\"errors\":[{\"slot_name\":\"section.info_result_content\"," - + "\"code\":\"missing_result_content\",\"message\":\"The information result content is missing.\"}]," + + "\"code\":\"negotiation.missing_result_content\"," + + "\"facts\":{\"section_label\":\"信息协商结果内容\"}}]," + "\"params\":{}}"; } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java index b4d73ad7..fbb6526d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java @@ -192,7 +192,7 @@ private static List runDataWithSchemaCase(A2 /** * Runs the rejection case over {@link TaskTPrivateLineComplaintSamples#rejectionSamples()}: each sample * deliberately omits one or more key slots (or carries an invalid slot value), so the server-side semantic - * validation is expected to fail with {@code validation_semantic_rejected}. The sample set mixes a text variant + * validation is expected to fail with {@code negotiation.semantic_rejected}. The sample set mixes a text variant * (fed to {@code generateTaskPromptFromText}) and a data variant (fed to * {@code generateTaskPromptFromDataWithSchema}); a sample is counted as "server-rejected" only when the * validation throws {@link ContentValidationException} — the structured slot errors are printed per sample — while diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java index ee1af2d0..1c9a692a 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java @@ -120,7 +120,7 @@ public static List dataWithSchemaSamples() { * {@code enum}. Time and serial are optional slots and are deliberately not used as rejection criteria — * their absence is exercised by the positive samples {@code text-optional-slots-missing} / * {@code data-optional-slots-missing} instead. The server-side semantic validation is expected to reject these - * samples with {@code validation_semantic_rejected}; they are scored separately from the accuracy samples and are + * samples with {@code negotiation.semantic_rejected}; they are scored separately from the accuracy samples and are * never fed into the field-accuracy evaluation. * * @return immutable rejection sample list diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/README.md b/a2a-t-sample/src/main/resources/sample/authz-policy/README.md index 69d0f3fe..ed201c4e 100644 --- a/a2a-t-sample/src/main/resources/sample/authz-policy/README.md +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/README.md @@ -83,12 +83,12 @@ scenarios[]: client_prompt # 客户端 promptText 比对:true/false=DRIFT(漂移,不计分)/null=客户端失败路径 server_outcome # 服务端 outcome 断言(null=未到服务端) server_params # 服务端 params 子集断言 - actual_outcome # 实际结果码(success / slot_validation_error / validation_semantic_rejected / ...) - actual_slot_errors[] # 服务端实际错误明细(slot_name + code + message) + actual_outcome # 实际结果码(success / slot.not_provided / negotiation.semantic_rejected / ...) + actual_slot_errors[] # 服务端实际错误明细(slot_name + code + message + facts,message 由 SDK 按码模板渲染) actual_params # 服务端实际提取参数 prompt_text # 客户端渲染产物(DRIFT 判读依据) warnings[] # 结构性告警(如 empty_policy_list_section:增删改操作下策略列表章节为空),不计分 - error # 异常信息 {code, message}(失败归因用,如 sdk_internal_error) + error # 异常信息 {code, message}(失败归因用,如 infra.internal_error) ``` **断言语义(重要)**:`match` 仅由客户端 outcome+错误码、服务端 outcome+错误码、params 子集匹配决定; @@ -147,7 +147,7 @@ for c in cs: | 模式 | 题集 | 通过线 | |---|---|---| -| 冒烟 | `scenarios.json`(15 例) | ≥ 13/15,且无 `sdk_internal_error` 类异常 | +| 冒烟 | `scenarios.json`(15 例) | ≥ 13/15,且无 `infra.internal_error` 类异常 | | 全量 | `scenarios-100.json`(100 例) | 记录基线分数,失败例逐例归因(LLM 概率性 / 判据缺口 / 题集期望错误) | | 留出集 | `scenarios-holdout-100.json`(100 例) | 与全量分差 ≤5 视为无过拟合;分差扩大时优先排查 prompt/约束对原题集的过拟合 | diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-100.json b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-100.json index 5716731e..b06c5e45 100644 --- a/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-100.json +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-100.json @@ -24,11 +24,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -58,11 +58,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -76,11 +76,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -94,11 +94,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -112,11 +112,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -133,12 +133,11 @@ "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是除政企专网之外的其他场景\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required", - "message": "删除授权策略时必填参数包括动网操作的授权策略标识,条目缺少策略标识" + "code": "content.entry_field_missing" } ], "params": {} @@ -153,11 +152,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -171,11 +170,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -189,11 +188,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -207,11 +206,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -228,11 +227,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -249,11 +248,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -353,11 +352,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是业务恢复,操作名称是隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -409,11 +408,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是业务恢复,操作名称是隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略明细", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -488,11 +487,11 @@ "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required" + "code": "content.param_missing" } ] } @@ -523,11 +522,11 @@ "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policies", - "code": "missing_required" + "code": "content.param_missing" } ] } @@ -630,12 +629,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是信号增强\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required", - "message": "新增条目缺少必填的有效期" + "code": "content.entry_field_missing" } ], "params": {} @@ -695,12 +693,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是信号增强\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required", - "message": "新增条目缺少必填的有效期" + "code": "content.entry_field_missing" } ], "params": {} @@ -754,11 +751,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error" + "code": "content.format_error" } ] } @@ -775,11 +772,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是网络出了问题要赶紧恢复,操作名称是频段调整,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "invalid_value" + "code": "content.value_not_allowed" } ] } @@ -813,7 +810,7 @@ }, "actionType": { "type": "string", - "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 invalid_value" + "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 content.value_not_allowed" }, "operationName": { "type": "string", @@ -840,11 +837,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是车联网,处置类型是业务优化,操作名称是频段调整,有效期是2030-01-01~2026-01-01\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error" + "code": "content.format_error" } ] } @@ -861,11 +858,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是车联网,处置类型是业务优化,操作名称是频段调整,有效期是2030-01-01~2026-01-01\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error" + "code": "content.format_error" } ] } @@ -926,11 +923,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是业务恢复,操作名称是路由调优,有效期是2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error" + "code": "content.format_error" } ] } @@ -947,11 +944,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是业务恢复,操作名称是路由调优,有效期是2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略明细", - "code": "format_error" + "code": "content.format_error" } ] } @@ -1048,11 +1045,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error" + "code": "content.format_error" } ] } @@ -1105,11 +1102,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error" + "code": "content.format_error" } ] } @@ -1162,11 +1159,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "invalid_value" + "code": "content.value_not_allowed" } ], "params": { @@ -1192,11 +1189,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略明细", - "code": "invalid_value" + "code": "content.value_not_allowed" } ], "params": { @@ -1266,11 +1263,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是2027-01-01~2030-12-31,有效期是2028-01-01~2029-06-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "semantic_mismatch" + "code": "content.semantic_conflict" } ] } @@ -1287,11 +1284,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是2027-01-01~2030-12-31,有效期是2028-01-01~2029-06-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略明细", - "code": "semantic_mismatch" + "code": "content.semantic_conflict" } ] } @@ -1524,12 +1521,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是12345,有效期是2027-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error", - "message": "第1条的策略标识\"12345\"不符合UUID格式" + "code": "content.format_error" } ], "params": {} @@ -1591,11 +1587,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -1612,11 +1608,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "newValidityPeriod", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -1656,11 +1652,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效,到2030-12-31为止\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "semantic_mismatch" + "code": "content.semantic_conflict" } ] } @@ -1805,12 +1801,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务调整,操作名称是隧道调优,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error", - "message": "有效期须为单值 YYYY-MM-DD,实际为区间" + "code": "content.format_error" } ], "params": {} @@ -2401,12 +2396,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是无人机回传,处置类型是业务调整,操作名称是隧道调优,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error", - "message": "有效期须为 ISO 8601 时间戳,实际为区间 YYYY-MM-DD~YYYY-MM-DD" + "code": "content.format_error" } ], "params": {} @@ -3216,12 +3210,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是2026-06-01~2030-06-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error", - "message": "有效期须为 ISO 8601 时间戳,实际为区间 YYYY-MM-DD~YYYY-MM-DD" + "code": "content.format_error" } ], "params": {} @@ -3562,12 +3555,11 @@ "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是容量规划\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required", - "message": "删除授权策略时必填参数包括动网操作的授权策略标识,条目缺少策略标识" + "code": "content.entry_field_missing" } ], "params": {} @@ -3607,12 +3599,11 @@ "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是容量规划\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required", - "message": "删除授权策略时必填参数包括动网操作的授权策略标识,条目缺少策略标识" + "code": "content.entry_field_missing" } ], "params": {} @@ -3730,12 +3721,11 @@ "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是载波调度\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required", - "message": "删除授权策略时必填参数包括动网操作的授权策略标识,条目缺少策略标识" + "code": "content.entry_field_missing" } ], "params": {} @@ -3753,12 +3743,11 @@ "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是载波调度\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required", - "message": "删除授权策略时必填参数包括动网操作的授权策略标识,条目缺少策略标识" + "code": "content.entry_field_missing" } ], "params": {} @@ -3808,12 +3797,11 @@ "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是载波调度\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required", - "message": "删除授权策略时必填参数包括动网操作的授权策略标识,条目缺少策略标识" + "code": "content.entry_field_missing" } ], "params": {} @@ -4566,11 +4554,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error" + "code": "content.format_error" } ] } @@ -4587,11 +4575,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policies", - "code": "format_error" + "code": "content.format_error" } ] } @@ -4801,12 +4789,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是紧急扩容,操作名称是天线调整,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error", - "message": "有效期须为单值 YYYY-MM-DD,实际为区间" + "code": "content.format_error" } ], "params": {} @@ -5452,17 +5439,15 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,有效期是2030-12-31\n2. 业务场景是无人机回传,处置类型是业务调整,操作名称是天线调整,有效期是2027-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required", - "message": "动网操作的授权策略列表第1条缺少必填字段:处置类型、操作名称" + "code": "content.entry_field_missing" }, { "slot_name": "policyList", - "code": "format_error", - "message": "动网操作的授权策略列表第2条的有效期月份无效(13月)" + "code": "content.format_error" } ], "params": {} diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-holdout-100.json b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-holdout-100.json index bb0fb107..9d402e2d 100644 --- a/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-holdout-100.json +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-holdout-100.json @@ -8,11 +8,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -26,11 +26,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -44,11 +44,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -62,11 +62,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -80,11 +80,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -98,11 +98,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -116,11 +116,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -134,11 +134,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -152,11 +152,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -170,11 +170,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -188,11 +188,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -206,11 +206,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -224,11 +224,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -242,11 +242,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -260,11 +260,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -278,11 +278,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -296,11 +296,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -317,11 +317,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是专线保障,处置类型是紧急扩容,操作名称是故障切换\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -338,11 +338,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是物联监测,处置类型是降级服务,操作名称是流量调度\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -359,11 +359,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急广播,处置类型是优先保障,操作名称是参数核查\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -380,11 +380,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是卫星回传,处置类型是现场处理,操作名称是故障切换\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -401,11 +401,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是远程医疗,处置类型是紧急扩容,操作名称是流量调度\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -457,11 +457,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是智慧港口,处置类型是降级服务,操作名称是故障切换\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -520,12 +520,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是远程医疗,处置类型是紧急扩容,操作名称是天线校准\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "动网操作的授权策略列表", - "code": "missing_required", - "message": "新增条目缺少必填的有效期" + "code": "content.entry_field_missing" } ], "params": {} @@ -583,12 +582,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是智能电网\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "动网操作的授权策略列表", - "code": "missing_required", - "message": "新增条目缺少必填的处置类型、操作名称、有效期" + "code": "content.entry_field_missing" } ], "params": {} @@ -606,11 +604,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是专线保障,处置类型是紧急扩容,操作名称是故障切换,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "format_error" + "code": "content.format_error" } ] } @@ -627,11 +625,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是智能电网,处置类型是优先保障,操作名称是流量调度,有效期是2030-01-01~2026-01-01\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "format_error" + "code": "content.format_error" } ] } @@ -648,11 +646,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是智慧港口,处置类型是降级服务,操作名称是路由调优,有效期是2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "format_error" + "code": "content.format_error" } ] } @@ -705,11 +703,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是远程医疗,处置类型是紧急扩容,操作名称是流量调度,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "format_error" + "code": "content.format_error" } ] } @@ -752,11 +750,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是###~###\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "format_error" + "code": "content.format_error" } ] } @@ -773,11 +771,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是智慧工厂专网,处置类型是重路由收敛,操作名称是载波重配置,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "format_error" + "code": "content.format_error" } ] } @@ -794,11 +792,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成专线保障\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "invalid_value" + "code": "content.value_not_allowed" } ], "params": { @@ -824,11 +822,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,处置类型改为紧急扩容\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "invalid_value" + "code": "content.value_not_allowed" } ], "params": { @@ -854,11 +852,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210,操作名称改成故障切换\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "invalid_value" + "code": "content.value_not_allowed" } ], "params": { @@ -884,11 +882,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景更新为物联监测\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "invalid_value" + "code": "content.value_not_allowed" } ], "params": { @@ -990,11 +988,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -1011,11 +1009,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是专线保障,处置类型是紧急扩容,操作名称是故障切换,有效期是永久生效,到2030-12-31为止\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "semantic_mismatch" + "code": "content.semantic_conflict" } ] } @@ -1059,11 +1057,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是专线保障,处置类型是出问题了得马上处理恢复业务,操作名称是流量调度,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "invalid_value" + "code": "content.value_not_allowed" } ] } @@ -1083,7 +1081,7 @@ }, "策略列表": { "type": "array", - "description": "动网操作的授权策略列表,按输入中的出现顺序排列。参数值中每条条目以编号加'.'开头(如'1.'),条目内各字段以'字段名是值'形式表达并用逗号分隔,多个条目各自独立成行(换行分隔)。该参数未提供时输出空数组 []。各操作类型对每条的字段要求不同:新增条目须包含业务场景、处置类型、操作名称、有效期;删除条目可为策略标识,也可仅为条件字段(业务场景、处置类型、操作名称之一或多个),无需其他字段,不得按新增的必填字段要求检查删除条目。修改授权策略时,每条须包含策略标识与新有效期,缺少新有效期判 missing_required。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),带标签时按标签归因字段。", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列。参数值中每条条目以编号加'.'开头(如'1.'),条目内各字段以'字段名是值'形式表达并用逗号分隔,多个条目各自独立成行(换行分隔)。该参数未提供时输出空数组 []。各操作类型对每条的字段要求不同:新增条目须包含业务场景、处置类型、操作名称、有效期;删除条目可为策略标识,也可仅为条件字段(业务场景、处置类型、操作名称之一或多个),无需其他字段,不得按新增的必填字段要求检查删除条目。修改授权策略时,每条须包含策略标识与新有效期,缺少新有效期判 content.entry_field_missing。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),带标签时按标签归因字段。", "items": { "type": "object", "properties": { @@ -1097,7 +1095,7 @@ }, "处置类型": { "type": "string", - "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 invalid_value" + "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 content.value_not_allowed" }, "操作名称": { "type": "string", @@ -1154,7 +1152,7 @@ }, "策略列表": { "type": "array", - "description": "动网操作的授权策略列表,按输入中的出现顺序排列。参数值中每条条目以编号加'.'开头(如'1.'),条目内各字段以'字段名是值'形式表达并用逗号分隔,多个条目各自独立成行(换行分隔)。该参数未提供时输出空数组 []。各操作类型对每条的字段要求不同:新增条目须包含业务场景、处置类型、操作名称、有效期;删除条目可为策略标识,也可仅为条件字段(业务场景、处置类型、操作名称之一或多个),无需其他字段,不得按新增的必填字段要求检查删除条目。修改授权策略时,每条须包含策略标识与新有效期,缺少新有效期判 missing_required。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),带标签时按标签归因字段。", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列。参数值中每条条目以编号加'.'开头(如'1.'),条目内各字段以'字段名是值'形式表达并用逗号分隔,多个条目各自独立成行(换行分隔)。该参数未提供时输出空数组 []。各操作类型对每条的字段要求不同:新增条目须包含业务场景、处置类型、操作名称、有效期;删除条目可为策略标识,也可仅为条件字段(业务场景、处置类型、操作名称之一或多个),无需其他字段,不得按新增的必填字段要求检查删除条目。修改授权策略时,每条须包含策略标识与新有效期,缺少新有效期判 content.entry_field_missing。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),带标签时按标签归因字段。", "items": { "type": "object", "properties": { @@ -1168,7 +1166,7 @@ }, "处置类型": { "type": "string", - "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 invalid_value" + "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 content.value_not_allowed" }, "操作名称": { "type": "string", @@ -3536,11 +3534,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是专线保障,处置类型是紧急扩容,操作名称是故障切换,有效期是永久生效\n2. 业务场景是物联监测,处置类型是降级服务,操作名称是流量调度,有效期是2026-13-01~2030-12-31\n3. 业务场景是应急广播,处置类型是优先保障,操作名称是参数核查,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "策略列表", - "code": "format_error" + "code": "content.format_error" } ] } @@ -3600,11 +3598,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210,有效期是永久生效\n2. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是到2028-12-31为止\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "有效期", - "code": "format_error" + "code": "content.format_error" } ] } diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios.json b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios.json index 344754ca..35392d1e 100644 --- a/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios.json +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios.json @@ -318,11 +318,11 @@ "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改成校园专网\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "invalid_value" + "code": "content.value_not_allowed" } ], "params": { @@ -348,11 +348,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error" + "code": "content.format_error" } ] } @@ -369,11 +369,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是网络出了问题要赶紧恢复,操作名称是频段调整,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "invalid_value" + "code": "content.value_not_allowed" } ] } @@ -407,7 +407,7 @@ }, "actionType": { "type": "string", - "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 invalid_value" + "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 content.value_not_allowed" }, "operationName": { "type": "string", @@ -434,11 +434,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "missing_required" + "code": "content.entry_field_missing" } ] } @@ -455,11 +455,11 @@ "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效\n2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" }, "server": { - "outcome": "validation_semantic_rejected", + "outcome": "negotiation.semantic_rejected", "slot_errors": [ { "slot_name": "policyList", - "code": "format_error" + "code": "content.format_error" } ] } @@ -489,11 +489,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } @@ -507,11 +507,11 @@ }, "expected": { "client": { - "outcome": "slot_validation_error", + "outcome": "slot.not_provided", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required" + "code": "slot.not_provided" } ] } diff --git a/a2a-t-sample/src/main/resources/sample/authz-policy/smoke-io-transcript.md b/a2a-t-sample/src/main/resources/sample/authz-policy/smoke-io-transcript.md index fc25532e..99a703cd 100644 --- a/a2a-t-sample/src/main/resources/sample/authz-policy/smoke-io-transcript.md +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/smoke-io-transcript.md @@ -141,7 +141,7 @@ 3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; ``` -**提参理由(LLM reasoning)**:输入中出现“新增”且处于新增授权策略的指令语境,按封闭取值范围规则13a映射为“新增授权策略”。策略列表按新增形字段顺序提取:业务场景、处置类型、操作名称、有效期,并将日期区间归一化为YYYY-MM-DD~YYYY-MM-DD格式;操作类型为新增,列表已提供,因此不报告missing_input。 +**提参理由(LLM reasoning)**:输入中出现“新增”且处于新增授权策略的指令语境,按封闭取值范围规则13a映射为“新增授权策略”。策略列表按新增形字段顺序提取:业务场景、处置类型、操作名称、有效期,并将日期区间归一化为YYYY-MM-DD~YYYY-MM-DD格式;操作类型为新增,列表已提供,因此不报告slot.not_provided。 ### 服务端(校验 + 提参) @@ -950,19 +950,23 @@ (输入 prompt 即上方客户端渲染结果,此处不重复。) -**校验与提参结果**:`validation_semantic_rejected` +**校验与提参结果**:`negotiation.semantic_rejected` ```json [ { "slot_name": "policyList", - "code": "invalid_value", - "message": "修改授权策略仅支持修改有效期,但动网操作的授权策略列表第1条试图将业务场景改为校园专网,属于修改不允许修改的字段。" + "code": "content.value_not_allowed", + "message": "「动网操作的授权策略列表」的取值「业务场景改为校园专网」不在允许范围内", + "facts": { + "section_label": "动网操作的授权策略列表", + "actual": "业务场景改为校园专网" + } } ] ``` -**校验理由(LLM reasoning)**:操作类型为“修改授权策略”,已提取。策略列表第1条提供策略标识和将业务场景改为校园专网的意图。模板正文规定修改授权策略仅支持修改有效期,因此该业务场景修改意图超出可修改范围,判定为invalid_value,semantic_verdict=false。params按参数schema提取:policyId取自策略标识,scene取值为校园专网,其余未提供字段置null。 +**校验理由(LLM reasoning)**:操作类型为“修改授权策略”,已提取。策略列表第1条提供策略标识和将业务场景改为校园专网的意图。模板正文规定修改授权策略仅支持修改有效期,因此该业务场景修改意图超出可修改范围,判定为content.value_not_allowed,semantic_verdict=false。params按参数schema提取:policyId取自策略标识,scene取值为校园专网,其余未提供字段置null。 **键名泄漏扫描**:无泄漏 @@ -1051,14 +1055,18 @@ (输入 prompt 即上方客户端渲染结果,此处不重复。) -**校验与提参结果**:`validation_semantic_rejected` +**校验与提参结果**:`negotiation.semantic_rejected` ```json [ { "slot_name": "policyList", - "code": "format_error", - "message": "动网操作的授权策略列表第1条的有效期“2026-13-01~2030-12-31”中月份“13”无效,日期格式不合法。" + "code": "content.format_error", + "message": "「动网操作的授权策略列表」的取值格式不符合要求:第1条的有效期“2026-13-01~2030-12-31”中月份“13”无效", + "facts": { + "section_label": "动网操作的授权策略列表", + "reason": "第1条的有效期“2026-13-01~2030-12-31”中月份“13”无效" + } } ] ``` @@ -1134,7 +1142,7 @@ }, "actionType": { "type": "string", - "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 invalid_value" + "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 content.value_not_allowed" }, "operationName": { "type": "string", @@ -1153,19 +1161,23 @@ (输入 prompt 即上方客户端渲染结果,此处不重复。) -**校验与提参结果**:`validation_semantic_rejected` +**校验与提参结果**:`negotiation.semantic_rejected` ```json [ { "slot_name": "policyList", - "code": "invalid_value", - "message": "策略列表第1条的处置类型为整句描述“网络出了问题要赶紧恢复”,应使用简短的处置动作短语(如业务抢通、业务优化、路由调整)" + "code": "content.value_not_allowed", + "message": "「动网操作的授权策略列表」的取值「网络出了问题要赶紧恢复」不在允许范围内", + "facts": { + "section_label": "动网操作的授权策略列表", + "actual": "网络出了问题要赶紧恢复" + } } ] ``` -**校验理由(LLM reasoning)**:操作类型“新增授权策略”在枚举范围内;动网操作的授权策略列表包含1条,新增必填的业务场景、处置类型、操作名称、有效期均已提供,有效期格式为完整区间且起始早于结束;但处置类型“网络出了问题要赶紧恢复”为整句描述,不符合参数 schema 要求的简短处置动作短语,故判定 invalid_value,整体校验不通过。 +**校验理由(LLM reasoning)**:操作类型“新增授权策略”在枚举范围内;动网操作的授权策略列表包含1条,新增必填的业务场景、处置类型、操作名称、有效期均已提供,有效期格式为完整区间且起始早于结束;但处置类型“网络出了问题要赶紧恢复”为整句描述,不符合参数 schema 要求的简短处置动作短语,故判定 content.value_not_allowed,整体校验不通过。 **键名泄漏扫描**:无泄漏 @@ -1254,19 +1266,24 @@ (输入 prompt 即上方客户端渲染结果,此处不重复。) -**校验与提参结果**:`validation_semantic_rejected` +**校验与提参结果**:`negotiation.semantic_rejected` ```json [ { "slot_name": "policyList", - "code": "missing_required", - "message": "动网操作的授权策略列表第1条缺少创建授权策略时必填的“有效期”字段。" + "code": "content.entry_field_missing", + "message": "「动网操作的授权策略列表」第 1 条缺少必填字段「有效期」", + "facts": { + "section_label": "动网操作的授权策略列表", + "index": "1", + "field_label": "有效期" + } } ] ``` -**校验理由(LLM reasoning)**:操作类型为“新增授权策略”,在参数 schema 枚举范围内;模板正文要求新增授权策略列表条目必填业务场景、处置类型、操作名称、有效期。输入列表第1条提供了业务场景、处置类型和操作名称,但未提供有效期,因此校验失败并报告 missing_required。参数提取按 schema 结构进行,缺失字段置 null。 +**校验理由(LLM reasoning)**:操作类型为“新增授权策略”,在参数 schema 枚举范围内;模板正文要求新增授权策略列表条目必填业务场景、处置类型、操作名称、有效期。输入列表第1条提供了业务场景、处置类型和操作名称,但未提供有效期,因此校验失败并报告 content.entry_field_missing。参数提取按 schema 结构进行,缺失字段置 null。 **键名泄漏扫描**:无泄漏 @@ -1357,14 +1374,18 @@ (输入 prompt 即上方客户端渲染结果,此处不重复。) -**校验与提参结果**:`validation_semantic_rejected` +**校验与提参结果**:`negotiation.semantic_rejected` ```json [ { "slot_name": "policyList", - "code": "format_error", - "message": "策略列表第2条的有效期“2026-13-01~2030-12-31”存在无效月份(13月),请提供有效日期。" + "code": "content.format_error", + "message": "「动网操作的授权策略列表」的取值格式不符合要求:第2条的有效期“2026-13-01~2030-12-31”存在无效月份(13月)", + "facts": { + "section_label": "动网操作的授权策略列表", + "reason": "第2条的有效期“2026-13-01~2030-12-31”存在无效月份(13月)" + } } ] ``` @@ -1411,19 +1432,22 @@ input.schema: ```json { - "code": "slot_validation_error", - "message": "Required slots are missing or empty: 授权策略的操作类型", + "code": "slot.not_provided", + "message": "输入中未提供「授权策略的操作类型」。", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required", - "message": "Required slot is missing or empty" + "code": "slot.not_provided", + "message": "输入中未提供「授权策略的操作类型」。", + "facts": { + "slot_label": "授权策略的操作类型" + } } ] } ``` -**提参理由(LLM reasoning)**:输入中仅有 scene=校园专网、actionType=业务抢通,未出现“新增、修改、删除、查询”等操作类型合法取值或其认可同义词,按封闭取值范围规则不能推断,故“授权策略的操作类型”置为 null 并报告 missing_input。scene 对应“业务场景”,actionType 对应“处置类型”,这两项属于动网操作授权策略列表中允许的查询条件字段,因此按“字段名是值”格式合并为一条编号列表。 +**提参理由(LLM reasoning)**:输入中仅有 scene=校园专网、actionType=业务抢通,未出现“新增、修改、删除、查询”等操作类型合法取值或其认可同义词,按封闭取值范围规则不能推断,故“授权策略的操作类型”置为 null 并报告 slot.not_provided。scene 对应“业务场景”,actionType 对应“处置类型”,这两项属于动网操作授权策略列表中允许的查询条件字段,因此按“字段名是值”格式合并为一条编号列表。 ### 服务端(校验 + 提参) @@ -1445,19 +1469,22 @@ input.schema: ```json { - "code": "slot_validation_error", - "message": "Required slots are missing or empty: 授权策略的操作类型", + "code": "slot.not_provided", + "message": "输入中未提供「授权策略的操作类型」。", "slot_errors": [ { "slot_name": "授权策略的操作类型", - "code": "missing_required", - "message": "Required slot is missing or empty" + "code": "slot.not_provided", + "message": "输入中未提供「授权策略的操作类型」。", + "facts": { + "slot_label": "授权策略的操作类型" + } } ] } ``` -**提参理由(LLM reasoning)**:输入中虽然出现“新增”,但位于“不要新增……我还没准备好”的否定/排除语义中,依据规则3不得作为正向slot值提取,因此“授权策略的操作类型”视为缺失并报告missing_input;“动网操作的授权策略列表”中的“校园专网”同样处于否定语义中,不提取,且操作类型未确定,不触发该列表的必填条件,故返回null。 +**提参理由(LLM reasoning)**:输入中虽然出现“新增”,但位于“不要新增……我还没准备好”的否定/排除语义中,依据规则3不得作为正向slot值提取,因此“授权策略的操作类型”视为缺失并报告slot.not_provided;“动网操作的授权策略列表”中的“校园专网”同样处于否定语义中,不提取,且操作类型未确定,不触发该列表的必填条件,故返回null。 ### 服务端(校验 + 提参) diff --git a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/abnormal-cases.json b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/abnormal-cases.json index a37c6991..11cbae67 100644 --- a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/abnormal-cases.json +++ b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/abnormal-cases.json @@ -6,7 +6,7 @@ "template": "propose", "performative": "PROPOSE", "expected_exception": "NegotiationGenerationException", - "expected_code": "negotiation_invalid_input", + "expected_code": "negotiation.invalid_input", "description": "生成协商发起报文时输入为空" }, { @@ -66,7 +66,7 @@ "template": "propose", "performative": "PROPOSE", "expected_exception": "NegotiationGenerationException", - "expected_code": "negotiation_invalid_input", + "expected_code": "negotiation.invalid_input", "description": "生成协商发起报文时文本为 null" }, { @@ -107,7 +107,7 @@ "template": "ending", "performative": "ACCEPT", "expected_exception": "NegotiationGenerationException", - "expected_code": "negotiation_invalid_input", + "expected_code": "negotiation.invalid_input", "description": "接受生成接口输入为空" }, { @@ -148,7 +148,7 @@ "template": "ending", "performative": "REJECT", "expected_exception": "NegotiationGenerationException", - "expected_code": "negotiation_invalid_input", + "expected_code": "negotiation.invalid_input", "description": "拒绝生成接口输入为空" }, { @@ -200,7 +200,7 @@ "performative": "PROPOSE", "context_mode": "null", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation_invalid_input", + "expected_code": "negotiation.invalid_input", "description": "校验协商发起报文时上下文为 null" }, { @@ -242,7 +242,7 @@ "performative": "PROPOSE", "context_mode": "invalid_id", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation_rule_violation", + "expected_code": "negotiation.invalid_context_id", "description": "校验协商发起报文时上下文 id 不是 UUID" }, { @@ -253,7 +253,7 @@ "performative": "PROPOSE", "context_mode": "exhausted", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation_rule_violation", + "expected_code": "negotiation.round_exceeded", "description": "校验协商发起报文时 round 超过 maxRounds" }, { @@ -284,7 +284,7 @@ "performative": "ACCEPT", "context_mode": "null", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation_invalid_input", + "expected_code": "negotiation.invalid_input", "description": "接受校验接口上下文为 null" }, { @@ -326,7 +326,7 @@ "performative": "ACCEPT", "context_mode": "invalid_id", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation_rule_violation", + "expected_code": "negotiation.invalid_context_id", "description": "接受校验接口上下文 id 不是 UUID" }, { @@ -337,7 +337,7 @@ "performative": "ACCEPT", "context_mode": "exhausted", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation_rule_violation", + "expected_code": "negotiation.round_exceeded", "description": "接受校验接口 round 超过 maxRounds" }, { @@ -368,7 +368,7 @@ "performative": "REJECT", "context_mode": "null", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation_invalid_input", + "expected_code": "negotiation.invalid_input", "description": "拒绝校验接口上下文为 null" }, { @@ -410,7 +410,7 @@ "performative": "REJECT", "context_mode": "invalid_id", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation_rule_violation", + "expected_code": "negotiation.invalid_context_id", "description": "拒绝校验接口上下文 id 不是 UUID" }, { @@ -421,7 +421,7 @@ "performative": "REJECT", "context_mode": "exhausted", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation_rule_violation", + "expected_code": "negotiation.round_exceeded", "description": "拒绝校验接口 round 超过 maxRounds" } ] diff --git a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json index 6249be96..ecbd751b 100644 --- a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json +++ b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json @@ -4,83 +4,83 @@ "api": "generate_propose", "input": "当前只需要继续处理这条专线投诉,请直接给出处理建议,不需要列出待补充信息。", "description": "发起协商文本没有明确的待补充信息项", - "expectedCodes": ["negotiation_slot_missing", "negotiation_content_extract_failed"] + "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] }, { "id": "B-G-PROPOSE-FREEFORM", "api": "generate_propose", "input": "请用一段自然语言概述对端需要提供的资料,不要使用信息项列表或逐项结构。", "description": "发起协商文本明确要求非模板列表结构", - "expectedCodes": ["negotiation_slot_missing", "negotiation_content_extract_failed"] + "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] }, { "id": "B-G-ACCEPT-NO-RESULT", "api": "generate_accept", "input": "接受本次协商,后续再补充具体信息。", "description": "接受文本没有提供任何结果内容", - "expectedCodes": ["negotiation_slot_missing", "negotiation_content_extract_failed"] + "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] }, { "id": "B-G-ACCEPT-VAGUE-RESULT", "api": "generate_accept", "input": "同意,信息按之前说的处理即可。", "description": "接受文本只有结论,没有可渲染的逐项结果", - "expectedCodes": ["negotiation_slot_missing", "negotiation_content_extract_failed"] + "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] }, { "id": "B-G-REJECT-NO-REASON", "api": "generate_reject", "input": "拒绝本次协商。", "description": "拒绝文本没有说明拒绝原因或结果项", - "expectedCodes": ["negotiation_slot_missing", "negotiation_content_extract_failed"] + "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] }, { "id": "B-G-REJECT-FREEFORM", "api": "generate_reject", "input": "这次不方便提供资料,请用一段话说明即可,不需要逐项列出拒绝内容。", "description": "拒绝文本要求非逐项结果结构", - "expectedCodes": ["negotiation_slot_missing", "negotiation_content_extract_failed"] + "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] }, { "id": "B-V-PROPOSE-MISSING-SECTION", "api": "validate_propose", "input": "## 信息协商\n请继续处理当前投诉。", "description": "发起报文缺少所需信息项段落", - "expectedCodes": ["negotiation_rule_violation", "negotiation_semantic_rejected"] + "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] }, { "id": "B-V-PROPOSE-FREEFORM-SECTION", "api": "validate_propose", "input": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n请提供相关资料即可。", "description": "发起报文的所需信息项不是逐项列表", - "expectedCodes": ["negotiation_rule_violation", "negotiation_semantic_rejected"] + "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] }, { "id": "B-V-ACCEPT-MISSING-RESULT", "api": "validate_accept", "input": "## 信息协商结果\nAccept", "description": "接受报文缺少结果内容段落", - "expectedCodes": ["negotiation_rule_violation", "negotiation_semantic_rejected"] + "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] }, { "id": "B-V-ACCEPT-FREEFORM-RESULT", "api": "validate_accept", "input": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n相关信息保持不变。", "description": "接受结果内容不是逐项信息", - "expectedCodes": ["negotiation_rule_violation", "negotiation_semantic_rejected"] + "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] }, { "id": "B-V-REJECT-MISSING-RESULT", "api": "validate_reject", "input": "## 信息协商结果\nReject", "description": "拒绝报文缺少结果内容段落", - "expectedCodes": ["negotiation_rule_violation", "negotiation_semantic_rejected"] + "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] }, { "id": "B-V-REJECT-FREEFORM-RESULT", "api": "validate_reject", "input": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n由于当前情况不允许,暂时无法提供。", "description": "拒绝结果内容没有按请求信息项逐项说明", - "expectedCodes": ["negotiation_rule_violation", "negotiation_semantic_rejected"] + "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] } ] diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainConcurrencyTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainConcurrencyTest.java index 400489a0..2d322238 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainConcurrencyTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainConcurrencyTest.java @@ -1,229 +1,226 @@ -package net.openan.a2at.sample.authz_policy; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class AuthzSampleMainConcurrencyTest { - - private static final String SLOT_EXTRACTION_SYSTEM = - "/prompt_resources/prompts/slot_extraction/zh-CN/system.md"; - private static final String CONTENT_VALIDATION_SYSTEM = - "/prompt_resources/prompts/content_validation/zh-CN/system.md"; - - private static final TemplateUri TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; - - private static final String ENV_CONTENT = - "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-test\n" - + "A2AT_LANGUAGE=zh-CN\nA2AT_PROMPT_SOURCE_TYPE=classpath\n" - + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=\nA2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory\n"; - - private static String loadResource(String path) { - try (InputStream in = AuthzSampleMainConcurrencyTest.class.getResourceAsStream(path)) { - if (in == null) { - throw new IllegalStateException("Resource not found: " + path); - } - return new String(in.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException e) { - throw new IllegalStateException("Failed to read resource: " + path, e); - } - } - - private static List> messagesWithSystem(String systemContent) { - List> messages = new ArrayList<>(); - messages.add(Map.of("role", "system", "content", systemContent)); - messages.add(Map.of("role", "user", "content", "dummy")); - return messages; - } - - private static AuthzScenario successScenario(String label) { - return new AuthzScenario( - label, - "from_text", - Map.of("text", "hello"), - new AuthzExpected( - new ClientExpected(null, "prompt text", null), - new ServerExpected("success", null, null))); - } - - private static ScenarioOutcome successOutcome() { - MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); - return new ScenarioOutcome( - new ScenarioResult("success", true, null, List.of(), true, true, null, List.of()), - metadata, - new FilledParamData(Map.of())); - } - - @Test - void should_buildGenerator_withoutCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, ENV_CONTENT); - - AuthzPromptGenerator generator = AuthzSampleMain.buildGenerator(envFile, TEMPLATE_URI, null); - assertNotNull(generator); - } - - @Test - void should_buildValidator_withoutCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, ENV_CONTENT); - - AuthzPromptValidator validator = AuthzSampleMain.buildValidator(envFile, null); - assertNotNull(validator); - } - - @Test - void should_buildGenerator_withCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, ENV_CONTENT); - - AuthzReasoningCapture capture = new AuthzReasoningCapture(); - AuthzPromptGenerator generator = AuthzSampleMain.buildGenerator(envFile, TEMPLATE_URI, capture); - assertNotNull(generator); - } - - @Test - void should_buildValidator_withCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, ENV_CONTENT); - - AuthzReasoningCapture capture = new AuthzReasoningCapture(); - AuthzPromptValidator validator = AuthzSampleMain.buildValidator(envFile, capture); - assertNotNull(validator); - } - - @Test - void should_writeReport_includeReasoningFields_whenReasoningEnabled(@TempDir Path tempDir) throws IOException { - AuthzScenario s = successScenario("test"); - ScenarioOutcome outcome = successOutcome(); - - AuthzReasoningCapture capture = new AuthzReasoningCapture(); - String[] content = {"unused"}; - LLMClient fake = (messages, jsonSchema, temperature, maxTokens) -> - new LLMResponse(content[0], "test-model", Map.of(), Map.of()); - LLMClient wrapped = capture.wrap(fake); - capture.beginScenario("test"); - content[0] = "{\"slots\":{},\"reasoning\":\"client-reason\"}"; - wrapped.structured(messagesWithSystem(loadResource(SLOT_EXTRACTION_SYSTEM)), Map.of(), null, null); - content[0] = "{\"semantic_verdict\":true,\"errors\":[],\"params\":{},\"reasoning\":\"server-reason\"}"; - wrapped.structured(messagesWithSystem(loadResource(CONTENT_VALIDATION_SYSTEM)), Map.of(), null, null); - capture.endScenario(); - - Path reportPath = AuthzSampleMain.writeReport( - List.of(s), List.of(outcome), tempDir, 8, 42.5, "sample/scenarios.json", true, capture); - - String reportContent = Files.readString(reportPath); - assertTrue(reportContent.contains("\"meta\"")); - assertTrue(reportContent.contains("\"workers\"")); - assertTrue(reportContent.contains("\"wallTimeSeconds\"")); - assertTrue(reportContent.contains("\"scenariosResource\"")); - assertTrue(reportContent.contains("\"reasoning\"")); - assertTrue(reportContent.contains("\"client_reasoning\"")); - assertTrue(reportContent.contains("\"server_reasoning\"")); - assertTrue(reportContent.contains("client-reason")); - assertTrue(reportContent.contains("server-reason")); - } - - @Test - void should_writeReport_omitReasoningFields_whenReasoningDisabled(@TempDir Path tempDir) throws IOException { - AuthzScenario s = successScenario("test"); - ScenarioOutcome outcome = successOutcome(); - - Path reportPath = AuthzSampleMain.writeReport( - List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); - - String reportContent = Files.readString(reportPath); - assertTrue(reportContent.contains("\"meta\"")); - assertTrue(reportContent.contains("\"reasoning\"")); - assertFalse(reportContent.contains("client_reasoning")); - assertFalse(reportContent.contains("server_reasoning")); - } - - @Test - void should_writeReport_includeClientReasoningOnly_whenServerMissing(@TempDir Path tempDir) throws IOException { - AuthzScenario s = new AuthzScenario( - "a-only", - "from_text", - Map.of("text", "hello"), - new AuthzExpected( - new ClientExpected("slot_validation_error", null, null), - null)); - ScenarioOutcome outcome = new ScenarioOutcome( - new ScenarioResult("slot_validation_error", false, null, List.of(), null, null, null, List.of()), - null, - null); - - AuthzReasoningCapture capture = new AuthzReasoningCapture(); - String[] content = {"unused"}; - LLMClient fake = (messages, jsonSchema, temperature, maxTokens) -> - new LLMResponse(content[0], "test-model", Map.of(), Map.of()); - LLMClient wrapped = capture.wrap(fake); - capture.beginScenario("a-only"); - content[0] = "{\"slots\":{},\"reasoning\":\"only-client\"}"; - wrapped.structured(messagesWithSystem(loadResource(SLOT_EXTRACTION_SYSTEM)), Map.of(), null, null); - capture.endScenario(); - - Path reportPath = AuthzSampleMain.writeReport( - List.of(s), List.of(outcome), tempDir, 8, 5.0, "sample/scenarios.json", true, capture); - - String reportContent = Files.readString(reportPath); - assertTrue(reportContent.contains("\"client_reasoning\"")); - assertTrue(reportContent.contains("only-client")); - assertFalse(reportContent.contains("server_reasoning")); - } - - @Test - void should_writeReport_useTimestampedFilename(@TempDir Path tempDir) throws IOException { - AuthzScenario s = successScenario("test"); - ScenarioOutcome outcome = successOutcome(); - - Path reportPath = AuthzSampleMain.writeReport( - List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); - - String fileName = reportPath.getFileName().toString(); - assertTrue(fileName.startsWith("authz-report-")); - assertTrue(fileName.endsWith(".json")); - String timestamp = fileName.substring("authz-report-".length(), fileName.length() - ".json".length()); - assertEquals(15, timestamp.length()); - } - - @Test - void should_writeReport_notOverwrite_WhenCalledTwice(@TempDir Path tempDir) throws IOException, InterruptedException { - AuthzScenario s = successScenario("test"); - ScenarioOutcome outcome = successOutcome(); - - Path report1 = AuthzSampleMain.writeReport( - List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); - Thread.sleep(1100); - Path report2 = AuthzSampleMain.writeReport( - List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); - - assertNotEquals(report1, report2); - assertTrue(Files.exists(report1)); - assertTrue(Files.exists(report2)); - } -} \ No newline at end of file +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AuthzSampleMainConcurrencyTest { + + private static final String SLOT_EXTRACTION_SYSTEM = "/prompt_resources/prompts/slot_extraction/zh-CN/system.md"; + private static final String CONTENT_VALIDATION_SYSTEM = + "/prompt_resources/prompts/content_validation/zh-CN/system.md"; + + private static final TemplateUri TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; + + private static final String ENV_CONTENT = + "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-test\n" + + "A2AT_LANGUAGE=zh-CN\nA2AT_PROMPT_SOURCE_TYPE=classpath\n" + + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=\nA2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory\n"; + + private static String loadResource(String path) { + try (InputStream in = AuthzSampleMainConcurrencyTest.class.getResourceAsStream(path)) { + if (in == null) { + throw new IllegalStateException("Resource not found: " + path); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("Failed to read resource: " + path, e); + } + } + + private static List> messagesWithSystem(String systemContent) { + List> messages = new ArrayList<>(); + messages.add(Map.of("role", "system", "content", systemContent)); + messages.add(Map.of("role", "user", "content", "dummy")); + return messages; + } + + private static AuthzScenario successScenario(String label) { + return new AuthzScenario( + label, + "from_text", + Map.of("text", "hello"), + new AuthzExpected( + new ClientExpected(null, "prompt text", null), new ServerExpected("success", null, null))); + } + + private static ScenarioOutcome successOutcome() { + MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); + return new ScenarioOutcome( + new ScenarioResult("success", true, null, List.of(), true, true, null, List.of()), + metadata, + new FilledParamData(Map.of())); + } + + @Test + void should_buildGenerator_withoutCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, ENV_CONTENT); + + AuthzPromptGenerator generator = AuthzSampleMain.buildGenerator(envFile, TEMPLATE_URI, null); + assertNotNull(generator); + } + + @Test + void should_buildValidator_withoutCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, ENV_CONTENT); + + AuthzPromptValidator validator = AuthzSampleMain.buildValidator(envFile, null); + assertNotNull(validator); + } + + @Test + void should_buildGenerator_withCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, ENV_CONTENT); + + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + AuthzPromptGenerator generator = AuthzSampleMain.buildGenerator(envFile, TEMPLATE_URI, capture); + assertNotNull(generator); + } + + @Test + void should_buildValidator_withCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, ENV_CONTENT); + + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + AuthzPromptValidator validator = AuthzSampleMain.buildValidator(envFile, capture); + assertNotNull(validator); + } + + @Test + void should_writeReport_includeReasoningFields_whenReasoningEnabled(@TempDir Path tempDir) throws IOException { + AuthzScenario s = successScenario("test"); + ScenarioOutcome outcome = successOutcome(); + + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + String[] content = {"unused"}; + LLMClient fake = (messages, jsonSchema, temperature, maxTokens) -> + new LLMResponse(content[0], "test-model", Map.of(), Map.of()); + LLMClient wrapped = capture.wrap(fake); + capture.beginScenario("test"); + content[0] = "{\"slots\":{},\"reasoning\":\"client-reason\"}"; + wrapped.structured(messagesWithSystem(loadResource(SLOT_EXTRACTION_SYSTEM)), Map.of(), null, null); + content[0] = "{\"semantic_verdict\":true,\"errors\":[],\"params\":{},\"reasoning\":\"server-reason\"}"; + wrapped.structured(messagesWithSystem(loadResource(CONTENT_VALIDATION_SYSTEM)), Map.of(), null, null); + capture.endScenario(); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 42.5, "sample/scenarios.json", true, capture); + + String reportContent = Files.readString(reportPath); + assertTrue(reportContent.contains("\"meta\"")); + assertTrue(reportContent.contains("\"workers\"")); + assertTrue(reportContent.contains("\"wallTimeSeconds\"")); + assertTrue(reportContent.contains("\"scenariosResource\"")); + assertTrue(reportContent.contains("\"reasoning\"")); + assertTrue(reportContent.contains("\"client_reasoning\"")); + assertTrue(reportContent.contains("\"server_reasoning\"")); + assertTrue(reportContent.contains("client-reason")); + assertTrue(reportContent.contains("server-reason")); + } + + @Test + void should_writeReport_omitReasoningFields_whenReasoningDisabled(@TempDir Path tempDir) throws IOException { + AuthzScenario s = successScenario("test"); + ScenarioOutcome outcome = successOutcome(); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); + + String reportContent = Files.readString(reportPath); + assertTrue(reportContent.contains("\"meta\"")); + assertTrue(reportContent.contains("\"reasoning\"")); + assertFalse(reportContent.contains("client_reasoning")); + assertFalse(reportContent.contains("server_reasoning")); + } + + @Test + void should_writeReport_includeClientReasoningOnly_whenServerMissing(@TempDir Path tempDir) throws IOException { + AuthzScenario s = new AuthzScenario( + "a-only", + "from_text", + Map.of("text", "hello"), + new AuthzExpected(new ClientExpected("slot.rule_violation", null, null), null)); + ScenarioOutcome outcome = new ScenarioOutcome( + new ScenarioResult("slot.rule_violation", false, null, List.of(), null, null, null, List.of()), + null, + null); + + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + String[] content = {"unused"}; + LLMClient fake = (messages, jsonSchema, temperature, maxTokens) -> + new LLMResponse(content[0], "test-model", Map.of(), Map.of()); + LLMClient wrapped = capture.wrap(fake); + capture.beginScenario("a-only"); + content[0] = "{\"slots\":{},\"reasoning\":\"only-client\"}"; + wrapped.structured(messagesWithSystem(loadResource(SLOT_EXTRACTION_SYSTEM)), Map.of(), null, null); + capture.endScenario(); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 5.0, "sample/scenarios.json", true, capture); + + String reportContent = Files.readString(reportPath); + assertTrue(reportContent.contains("\"client_reasoning\"")); + assertTrue(reportContent.contains("only-client")); + assertFalse(reportContent.contains("server_reasoning")); + } + + @Test + void should_writeReport_useTimestampedFilename(@TempDir Path tempDir) throws IOException { + AuthzScenario s = successScenario("test"); + ScenarioOutcome outcome = successOutcome(); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); + + String fileName = reportPath.getFileName().toString(); + assertTrue(fileName.startsWith("authz-report-")); + assertTrue(fileName.endsWith(".json")); + String timestamp = fileName.substring("authz-report-".length(), fileName.length() - ".json".length()); + assertEquals(15, timestamp.length()); + } + + @Test + void should_writeReport_notOverwrite_WhenCalledTwice(@TempDir Path tempDir) + throws IOException, InterruptedException { + AuthzScenario s = successScenario("test"); + ScenarioOutcome outcome = successOutcome(); + + Path report1 = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); + Thread.sleep(1100); + Path report2 = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); + + assertNotEquals(report1, report2); + assertTrue(Files.exists(report1)); + assertTrue(Files.exists(report2)); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java index 735bf8e8..49961d20 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java @@ -1,223 +1,237 @@ -package net.openan.a2at.sample.authz_policy; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.PrintStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; -import net.openan.a2at.sdk.core.model.MetadataContent; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class AuthzSampleMainTest { - - private static final AuthzExpected SUCCESS = new AuthzExpected( - new ClientExpected(null, "## rendered prompt", null), new ServerExpected("success", null, null)); - private static final ScenarioResult MATCH_RESULT = - new ScenarioResult("success", true, null, List.of(), true, true, true, List.of()); - private static final ScenarioResult MISMATCH_RESULT = - new ScenarioResult("slot_validation_error", false, null, List.of(), null, null, null, List.of()); - - private PrintStream originalOut; - private ByteArrayOutputStream outContent; - - @BeforeEach - void setUp() { - originalOut = System.out; - outContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); - } - - @AfterEach - void tearDown() { - System.setOut(originalOut); - } - - @Test - void should_returnZeroExitCode_WhenAllScenariosMatch() { - assertEquals(0, AuthzSampleMain.exitCode(List.of(MATCH_RESULT, MATCH_RESULT, MATCH_RESULT))); - } - - @Test - void should_returnNonZeroExitCode_WhenAnyMismatch() { - assertTrue(AuthzSampleMain.exitCode(List.of(MATCH_RESULT, MISMATCH_RESULT, MATCH_RESULT)) != 0); - } - - @Test - void should_resolveEnvPath_ArgsFirst() { - Path result = AuthzSampleMain.resolveEnvPath(new String[] {"custom.env"}); - assertEquals(Path.of("custom.env"), result); - } - - @Test - void should_resolveEnvPath_WorkingDirFallback() throws IOException { - Path cwdEnv = Path.of("authz.env"); - try { - Files.writeString(cwdEnv, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-test\n"); - Path result = AuthzSampleMain.resolveEnvPath(new String[0]); - assertEquals(cwdEnv.toAbsolutePath(), result.toAbsolutePath()); - } finally { - Files.deleteIfExists(cwdEnv); - } - } - - @Test - void should_resolveEnvPath_ClasspathFallback() { - Path result = AuthzSampleMain.resolveEnvPath(new String[0]); - assertTrue( - result.toString().contains("authz-policy") && result.toString().endsWith("authz.env")); - } - - @Test - void should_hasRequiredLlmKeys_ReturnTrue_WhenAllKeysPresent(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-12345\n"); - assertTrue(AuthzSampleMain.hasRequiredLlmKeys(envFile)); - } - - @Test - void should_hasRequiredLlmKeys_ReturnFalse_WhenKeyMissing(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\n"); - assertFalse(AuthzSampleMain.hasRequiredLlmKeys(envFile)); - } - - @Test - void should_hasRequiredLlmKeys_ReturnFalse_WhenKeyBlank(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=\n"); - assertFalse(AuthzSampleMain.hasRequiredLlmKeys(envFile)); - } - - @Test - void should_hasRequiredLlmKeys_ReturnFalse_WhenFileNotFound() { - assertFalse(AuthzSampleMain.hasRequiredLlmKeys(Path.of("nonexistent.env"))); - } - - @Test - void should_loadParamSchema_ReturnBusinessLevelSchema() { - Map schema = AuthzSampleMain.loadParamSchema(); - - assertNotNull(schema); - assertTrue(schema.containsKey("properties")); - Map properties = (Map) schema.get("properties"); - assertTrue(properties.containsKey("operationType")); - assertTrue(properties.containsKey("policyList")); - } - - @Test - void should_printScenarioReport_WhenMetadataNull() { - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - ScenarioOutcome outcome = new ScenarioOutcome(MISMATCH_RESULT, null, null); - AuthzSampleMain.printScenarioReport(scenario, outcome); - String output = outContent.toString(); - assertTrue(output.contains("--- Scenario: test ---")); - assertTrue(output.contains("[Client]")); - assertTrue(output.contains("slot_validation_error")); - assertTrue(output.contains("(跳过 - 客户端生成失败)")); - } - - @Test - void should_printScenarioReport_WhenFilledNull() { - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); - ScenarioResult result = new ScenarioResult( - "validation_semantic_rejected", false, null, List.of(), true, false, null, List.of()); - ScenarioOutcome outcome = new ScenarioOutcome(result, metadata, null); - AuthzSampleMain.printScenarioReport(scenario, outcome); - String output = outContent.toString(); - assertTrue(output.contains("[Server]")); - assertTrue(output.contains("validation_semantic_rejected")); - assertTrue(output.contains("校验结果")); - } - - @Test - void should_printSummary() { - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - AuthzSampleMain.printSummary(List.of(scenario), List.of(MATCH_RESULT, MISMATCH_RESULT, MISMATCH_RESULT)); - String output = outContent.toString(); - assertTrue(output.contains("Match")); - assertTrue(output.contains("Mismatch")); - } - - @Test - void should_writeReport_WithStagedExpectationsAndAssertions(@TempDir Path tempDir) throws IOException { - AuthzScenario scenario = new AuthzScenario( - "test", - "from_text", - Map.of("text", "hello"), - new AuthzExpected( - new ClientExpected(null, "prompt text", null), - new ServerExpected("success", null, Map.of("slot1", "expected_value")))); - MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); - ScenarioOutcome outcome = new ScenarioOutcome( - MATCH_RESULT, - metadata, - new net.openan.a2at.sdk.core.model.FilledParamData(Map.of("slot1", "actual_value"))); - - Path reportPath = AuthzSampleMain.writeReport( - List.of(scenario), List.of(outcome), tempDir, 8, 12.5, "sample/authz-policy/scenarios.json", false, null); - - assertTrue(Files.exists(reportPath)); - String content = Files.readString(reportPath); - assertTrue(content.contains("\"expected_client\"")); - assertTrue(content.contains("\"expected_server\"")); - assertTrue(content.contains("\"actual_outcome\"")); - assertTrue(content.contains("\"success\"")); - assertTrue(content.contains("\"match\"")); - assertTrue(content.contains("\"expected_value\"")); - assertTrue(content.contains("\"actual_params\"")); - assertTrue(content.contains("\"actual_value\"")); - assertTrue(content.contains("\"prompt_text\"")); - assertTrue(content.contains("\"prompt text\"")); - assertTrue(content.contains("\"assertions\"")); - assertTrue(content.contains("\"actual_slot_errors\"")); - } - - @Test - void should_writeReport_WithSlotErrors(@TempDir Path tempDir) throws IOException { - AuthzScenario scenario = new AuthzScenario( - "test", - "from_text", - Map.of("text", "hello"), - new AuthzExpected( - new ClientExpected( - "slot_validation_error", - null, - List.of(new AuthzScenario.SlotErrorExpectation("授权策略的操作类型", "missing_required"))), - null)); - ScenarioResult result = new ScenarioResult( - "slot_validation_error", - true, - null, - List.of(new net.openan.a2at.sdk.core.model.SlotValidationError( - "授权策略的操作类型", "missing_required", "Required slot is missing or empty")), - null, - null, - null, - List.of()); - ScenarioOutcome outcome = new ScenarioOutcome(result, null, null); - - Path reportPath = AuthzSampleMain.writeReport( - List.of(scenario), List.of(outcome), tempDir, 8, 12.5, "sample/authz-policy/scenarios.json", false, null); - - String content = Files.readString(reportPath); - assertTrue(content.contains("授权策略的操作类型")); - assertTrue(content.contains("missing_required")); - assertTrue(content.contains("Required slot is missing or empty")); - } -} +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AuthzSampleMainTest { + + private static final AuthzExpected SUCCESS = new AuthzExpected( + new ClientExpected(null, "## rendered prompt", null), new ServerExpected("success", null, null)); + private static final ScenarioResult MATCH_RESULT = + new ScenarioResult("success", true, null, List.of(), true, true, true, List.of()); + private static final ScenarioResult MISMATCH_RESULT = + new ScenarioResult("slot.rule_violation", false, null, List.of(), null, null, null, List.of()); + + private PrintStream originalOut; + private ByteArrayOutputStream outContent; + + @BeforeEach + void setUp() { + originalOut = System.out; + outContent = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outContent)); + } + + @AfterEach + void tearDown() { + System.setOut(originalOut); + } + + @Test + void should_returnZeroExitCode_WhenAllScenariosMatch() { + assertEquals(0, AuthzSampleMain.exitCode(List.of(MATCH_RESULT, MATCH_RESULT, MATCH_RESULT))); + } + + @Test + void should_returnNonZeroExitCode_WhenAnyMismatch() { + assertTrue(AuthzSampleMain.exitCode(List.of(MATCH_RESULT, MISMATCH_RESULT, MATCH_RESULT)) != 0); + } + + @Test + void should_resolveEnvPath_ArgsFirst() { + Path result = AuthzSampleMain.resolveEnvPath(new String[] {"custom.env"}); + assertEquals(Path.of("custom.env"), result); + } + + @Test + void should_resolveEnvPath_WorkingDirFallback() throws IOException { + Path cwdEnv = Path.of("authz.env"); + try { + Files.writeString(cwdEnv, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-test\n"); + Path result = AuthzSampleMain.resolveEnvPath(new String[0]); + assertEquals(cwdEnv.toAbsolutePath(), result.toAbsolutePath()); + } finally { + Files.deleteIfExists(cwdEnv); + } + } + + @Test + void should_resolveEnvPath_ClasspathFallback() { + Path result = AuthzSampleMain.resolveEnvPath(new String[0]); + assertTrue( + result.toString().contains("authz-policy") && result.toString().endsWith("authz.env")); + } + + @Test + void should_hasRequiredLlmKeys_ReturnTrue_WhenAllKeysPresent(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-12345\n"); + assertTrue(AuthzSampleMain.hasRequiredLlmKeys(envFile)); + } + + @Test + void should_hasRequiredLlmKeys_ReturnFalse_WhenKeyMissing(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\n"); + assertFalse(AuthzSampleMain.hasRequiredLlmKeys(envFile)); + } + + @Test + void should_hasRequiredLlmKeys_ReturnFalse_WhenKeyBlank(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=\n"); + assertFalse(AuthzSampleMain.hasRequiredLlmKeys(envFile)); + } + + @Test + void should_hasRequiredLlmKeys_ReturnFalse_WhenFileNotFound() { + assertFalse(AuthzSampleMain.hasRequiredLlmKeys(Path.of("nonexistent.env"))); + } + + @Test + void should_loadParamSchema_ReturnBusinessLevelSchema() { + Map schema = AuthzSampleMain.loadParamSchema(); + + assertNotNull(schema); + assertTrue(schema.containsKey("properties")); + Map properties = (Map) schema.get("properties"); + assertTrue(properties.containsKey("operationType")); + assertTrue(properties.containsKey("policyList")); + } + + @Test + void should_printScenarioReport_WhenMetadataNull() { + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + ScenarioOutcome outcome = new ScenarioOutcome(MISMATCH_RESULT, null, null); + AuthzSampleMain.printScenarioReport(scenario, outcome); + String output = outContent.toString(); + assertTrue(output.contains("--- Scenario: test ---")); + assertTrue(output.contains("[Client]")); + assertTrue(output.contains("slot.rule_violation")); + assertTrue(output.contains("(跳过 - 客户端生成失败)")); + } + + @Test + void should_printScenarioReport_WhenFilledNull() { + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); + ScenarioResult result = new ScenarioResult( + "negotiation.semantic_rejected", false, null, List.of(), true, false, null, List.of()); + ScenarioOutcome outcome = new ScenarioOutcome(result, metadata, null); + AuthzSampleMain.printScenarioReport(scenario, outcome); + String output = outContent.toString(); + assertTrue(output.contains("[Server]")); + assertTrue(output.contains("negotiation.semantic_rejected")); + assertTrue(output.contains("校验结果")); + } + + @Test + void should_printSummary() { + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + AuthzSampleMain.printSummary(List.of(scenario), List.of(MATCH_RESULT, MISMATCH_RESULT, MISMATCH_RESULT)); + String output = outContent.toString(); + assertTrue(output.contains("Match")); + assertTrue(output.contains("Mismatch")); + } + + @Test + void should_writeReport_WithStagedExpectationsAndAssertions(@TempDir Path tempDir) throws IOException { + AuthzScenario scenario = new AuthzScenario( + "test", + "from_text", + Map.of("text", "hello"), + new AuthzExpected( + new ClientExpected(null, "prompt text", null), + new ServerExpected("success", null, Map.of("slot1", "expected_value")))); + MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); + ScenarioOutcome outcome = new ScenarioOutcome( + MATCH_RESULT, + metadata, + new net.openan.a2at.sdk.core.model.FilledParamData(Map.of("slot1", "actual_value"))); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(scenario), + List.of(outcome), + tempDir, + 8, + 12.5, + "sample/authz-policy/scenarios.json", + false, + null); + + assertTrue(Files.exists(reportPath)); + String content = Files.readString(reportPath); + assertTrue(content.contains("\"expected_client\"")); + assertTrue(content.contains("\"expected_server\"")); + assertTrue(content.contains("\"actual_outcome\"")); + assertTrue(content.contains("\"success\"")); + assertTrue(content.contains("\"match\"")); + assertTrue(content.contains("\"expected_value\"")); + assertTrue(content.contains("\"actual_params\"")); + assertTrue(content.contains("\"actual_value\"")); + assertTrue(content.contains("\"prompt_text\"")); + assertTrue(content.contains("\"prompt text\"")); + assertTrue(content.contains("\"assertions\"")); + assertTrue(content.contains("\"actual_slot_errors\"")); + } + + @Test + void should_writeReport_WithSlotErrors(@TempDir Path tempDir) throws IOException { + AuthzScenario scenario = new AuthzScenario( + "test", + "from_text", + Map.of("text", "hello"), + new AuthzExpected( + new ClientExpected( + "slot.not_provided", + null, + List.of(new AuthzScenario.SlotErrorExpectation("授权策略的操作类型", "slot.not_provided"))), + null)); + ScenarioResult result = new ScenarioResult( + "slot.not_provided", + true, + null, + List.of(new net.openan.a2at.sdk.core.model.SlotValidationError( + "授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。")), + null, + null, + null, + List.of()); + ScenarioOutcome outcome = new ScenarioOutcome(result, null, null); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(scenario), + List.of(outcome), + tempDir, + 8, + 12.5, + "sample/authz-policy/scenarios.json", + false, + null); + + String content = Files.readString(reportPath); + assertTrue(content.contains("授权策略的操作类型")); + assertTrue(content.contains("slot.not_provided")); + assertTrue(content.contains("输入中未提供「授权策略的操作类型」。")); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutorTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutorTest.java index e192b1be..4cd3bf50 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutorTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutorTest.java @@ -1,201 +1,198 @@ -package net.openan.a2at.sample.authz_policy; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -class AuthzScenarioExecutorTest { - - private static final TemplateUri TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; - private static final Map PARAM_SCHEMA = Map.of("properties", Map.of(), "required", Map.of()); - private static final AuthzExpected SUCCESS = new AuthzExpected( - new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, null)); - - @AfterEach - void clearProperty() { - System.clearProperty("authz.workers"); - } - - private static AuthzScenario scenario(String label) { - return new AuthzScenario(label, "from_text", Map.of("text", "hello"), SUCCESS); - } - - @Test - void should_preserveInputOrder_WhenSlowerScenarioFinishesLast() throws Exception { - CountDownLatch gate = new CountDownLatch(2); - AuthzPromptGenerator generator = scenario -> { - if ("c0".equals(scenario.label())) { - try { - gate.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } else { - gate.countDown(); - } - return new MetadataContent(TEMPLATE_URI.uri(), scenario.label(), "Authorization-T/v1"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - List scenarios = List.of(scenario("c0"), scenario("c1"), scenario("c2")); - List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 3, null); - - assertEquals(3, outcomes.size()); - assertEquals("c0", outcomes.get(0).metadata().promptText()); - assertEquals("c1", outcomes.get(1).metadata().promptText()); - assertEquals("c2", outcomes.get(2).metadata().promptText()); - } - - @Test - void should_isolateFailures_WhenTaskThrows() { - AuthzPromptGenerator generator = scenario -> { - switch (scenario.label()) { - case "boom-runtime": - throw new RuntimeException("runtime failure"); - case "boom-assert": - throw new AssertionError("assertion failure"); - default: - return new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - } - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - List scenarios = List.of( - scenario("ok1"), scenario("boom-runtime"), scenario("ok2"), scenario("boom-assert")); - List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 4, null); - - assertEquals(4, outcomes.size()); - assertTrue(outcomes.get(0).result().match()); - assertEquals("sdk_internal_error", outcomes.get(1).result().outcome()); - assertFalse(outcomes.get(1).result().match()); - assertTrue(outcomes.get(2).result().match()); - assertEquals("sdk_internal_error", outcomes.get(3).result().outcome()); - assertFalse(outcomes.get(3).result().match()); - assertNotNull(outcomes.get(3).result().slotErrors()); - assertEquals(1, outcomes.get(3).result().slotErrors().size()); - assertEquals("_llm", outcomes.get(3).result().slotErrors().get(0).slotName()); - } - - @Test - void should_deliverProgressCallbacks_WithDistinctCompletionIndices() { - AuthzPromptGenerator generator = scenario -> - new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - ConcurrentHashMap seen = new ConcurrentHashMap<>(); - List scenarios = List.of(scenario("a"), scenario("b"), scenario("c"), scenario("d")); - - List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 4, - (index, outcome, elapsed) -> { - assertTrue(elapsed >= 0.0); - seen.put(index, elapsed); - }); - - assertEquals(4, outcomes.size()); - assertEquals(Set.of(1, 2, 3, 4), seen.keySet()); - } - - @Test - void should_runAllScenariosSerially_WhenSingleWorker() { - AuthzPromptGenerator generator = scenario -> - new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - List scenarios = List.of(scenario("a"), scenario("b"), scenario("c")); - List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 1, null); - - assertEquals(3, outcomes.size()); - for (ScenarioOutcome outcome : outcomes) { - assertTrue(outcome.result().match()); - } - } - - @Test - void should_returnEmptyList_WhenNoScenarios() { - AuthzScenarioRunner runner = new AuthzScenarioRunner( - s -> new MetadataContent("uri", "prompt", "ext"), - (p, s, t) -> new FilledParamData(Map.of())); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - AtomicInteger callbackCalls = new AtomicInteger(); - List outcomes = executor.executeAll( - List.of(), PARAM_SCHEMA, TEMPLATE_URI, 8, - (index, outcome, elapsed) -> callbackCalls.incrementAndGet()); - - assertTrue(outcomes.isEmpty()); - assertEquals(0, callbackCalls.get()); - } - - @Test - void should_notThrow_WhenProgressListenerIsNull() { - AuthzPromptGenerator generator = scenario -> - new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - List outcomes = executor.executeAll( - List.of(scenario("a")), PARAM_SCHEMA, TEMPLATE_URI, 1, null); - - assertEquals(1, outcomes.size()); - assertTrue(outcomes.get(0).result().match()); - } - - @Test - void should_resolveWorkers_DefaultTo8() { - System.clearProperty("authz.workers"); - assertEquals(8, AuthzScenarioExecutor.resolveWorkers()); - } - - @Test - void should_resolveWorkers_FromSystemProperty() { - System.setProperty("authz.workers", "3"); - assertEquals(3, AuthzScenarioExecutor.resolveWorkers()); - } - - @Test - void should_resolveWorkers_ThrowOnNonPositive() { - for (String value : List.of("0", "-1")) { - System.setProperty("authz.workers", value); - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, AuthzScenarioExecutor::resolveWorkers); - assertTrue(ex.getMessage().contains("authz.workers")); - } - } - - @Test - void should_resolveWorkers_ThrowOnNonNumeric() { - System.setProperty("authz.workers", "abc"); - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, AuthzScenarioExecutor::resolveWorkers); - assertTrue(ex.getMessage().contains("authz.workers")); - } -} \ No newline at end of file +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class AuthzScenarioExecutorTest { + + private static final TemplateUri TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; + private static final Map PARAM_SCHEMA = Map.of("properties", Map.of(), "required", Map.of()); + private static final AuthzExpected SUCCESS = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, null)); + + @AfterEach + void clearProperty() { + System.clearProperty("authz.workers"); + } + + private static AuthzScenario scenario(String label) { + return new AuthzScenario(label, "from_text", Map.of("text", "hello"), SUCCESS); + } + + @Test + void should_preserveInputOrder_WhenSlowerScenarioFinishesLast() throws Exception { + CountDownLatch gate = new CountDownLatch(2); + AuthzPromptGenerator generator = scenario -> { + if ("c0".equals(scenario.label())) { + try { + gate.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } else { + gate.countDown(); + } + return new MetadataContent(TEMPLATE_URI.uri(), scenario.label(), "Authorization-T/v1"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + List scenarios = List.of(scenario("c0"), scenario("c1"), scenario("c2")); + List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 3, null); + + assertEquals(3, outcomes.size()); + assertEquals("c0", outcomes.get(0).metadata().promptText()); + assertEquals("c1", outcomes.get(1).metadata().promptText()); + assertEquals("c2", outcomes.get(2).metadata().promptText()); + } + + @Test + void should_isolateFailures_WhenTaskThrows() { + AuthzPromptGenerator generator = scenario -> { + switch (scenario.label()) { + case "boom-runtime": + throw new RuntimeException("runtime failure"); + case "boom-assert": + throw new AssertionError("assertion failure"); + default: + return new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + } + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + List scenarios = + List.of(scenario("ok1"), scenario("boom-runtime"), scenario("ok2"), scenario("boom-assert")); + List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 4, null); + + assertEquals(4, outcomes.size()); + assertTrue(outcomes.get(0).result().match()); + assertEquals("infra.internal_error", outcomes.get(1).result().outcome()); + assertFalse(outcomes.get(1).result().match()); + assertTrue(outcomes.get(2).result().match()); + assertEquals("infra.internal_error", outcomes.get(3).result().outcome()); + assertFalse(outcomes.get(3).result().match()); + assertNotNull(outcomes.get(3).result().slotErrors()); + assertEquals(1, outcomes.get(3).result().slotErrors().size()); + assertEquals("_llm", outcomes.get(3).result().slotErrors().get(0).slotName()); + } + + @Test + void should_deliverProgressCallbacks_WithDistinctCompletionIndices() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + ConcurrentHashMap seen = new ConcurrentHashMap<>(); + List scenarios = List.of(scenario("a"), scenario("b"), scenario("c"), scenario("d")); + + List outcomes = + executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 4, (index, outcome, elapsed) -> { + assertTrue(elapsed >= 0.0); + seen.put(index, elapsed); + }); + + assertEquals(4, outcomes.size()); + assertEquals(Set.of(1, 2, 3, 4), seen.keySet()); + } + + @Test + void should_runAllScenariosSerially_WhenSingleWorker() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + List scenarios = List.of(scenario("a"), scenario("b"), scenario("c")); + List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 1, null); + + assertEquals(3, outcomes.size()); + for (ScenarioOutcome outcome : outcomes) { + assertTrue(outcome.result().match()); + } + } + + @Test + void should_returnEmptyList_WhenNoScenarios() { + AuthzScenarioRunner runner = new AuthzScenarioRunner( + s -> new MetadataContent("uri", "prompt", "ext"), (p, s, t) -> new FilledParamData(Map.of())); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + AtomicInteger callbackCalls = new AtomicInteger(); + List outcomes = executor.executeAll( + List.of(), PARAM_SCHEMA, TEMPLATE_URI, 8, (index, outcome, elapsed) -> callbackCalls.incrementAndGet()); + + assertTrue(outcomes.isEmpty()); + assertEquals(0, callbackCalls.get()); + } + + @Test + void should_notThrow_WhenProgressListenerIsNull() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + List outcomes = + executor.executeAll(List.of(scenario("a")), PARAM_SCHEMA, TEMPLATE_URI, 1, null); + + assertEquals(1, outcomes.size()); + assertTrue(outcomes.get(0).result().match()); + } + + @Test + void should_resolveWorkers_DefaultTo8() { + System.clearProperty("authz.workers"); + assertEquals(8, AuthzScenarioExecutor.resolveWorkers()); + } + + @Test + void should_resolveWorkers_FromSystemProperty() { + System.setProperty("authz.workers", "3"); + assertEquals(3, AuthzScenarioExecutor.resolveWorkers()); + } + + @Test + void should_resolveWorkers_ThrowOnNonPositive() { + for (String value : List.of("0", "-1")) { + System.setProperty("authz.workers", value); + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, AuthzScenarioExecutor::resolveWorkers); + assertTrue(ex.getMessage().contains("authz.workers")); + } + } + + @Test + void should_resolveWorkers_ThrowOnNonNumeric() { + System.setProperty("authz.workers", "abc"); + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, AuthzScenarioExecutor::resolveWorkers); + assertTrue(ex.getMessage().contains("authz.workers")); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java index c1694fa6..5aee1726 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java @@ -1,158 +1,169 @@ -package net.openan.a2at.sample.authz_policy; - -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 java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class AuthzScenarioLoaderTest { - - @Test - void should_loadAndValidateAllScenarios() { - List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios.json"); - - assertEquals(15, scenarios.size()); - // 预期成功在前(index 0-7),预期拒绝在中(index 8-12),客户端拦截在末尾(index 13-14) - assertEquals("c1-nl-add-01", scenarios.get(0).label()); - assertEquals("from_text", scenarios.get(0).entry()); - assertEquals("success", scenarios.get(0).expected().server().outcome()); - assertEquals("c1-nl-add-01-varname", scenarios.get(1).label()); - assertEquals("from_text", scenarios.get(1).entry()); - assertEquals("success", scenarios.get(1).expected().server().outcome()); - assertNotNull(scenarios.get(1).validateSchema()); - assertEquals("c3-nl-mod-06", scenarios.get(3).label()); - assertEquals("success", scenarios.get(3).expected().server().outcome()); - assertEquals("c3-nl-mod-06-varfields", scenarios.get(4).label()); - assertEquals("success", scenarios.get(4).expected().server().outcome()); - assertNotNull(scenarios.get(4).validateSchema()); - assertEquals("b4-nl-bad-id-01", scenarios.get(7).label()); - assertEquals("success", scenarios.get(7).expected().server().outcome()); - assertEquals("b3-nl-invalid-mod-01", scenarios.get(8).label()); - assertEquals("validation_semantic_rejected", scenarios.get(8).expected().server().outcome()); - assertEquals("b2-nl-format-01", scenarios.get(9).label()); - assertEquals("validation_semantic_rejected", scenarios.get(9).expected().server().outcome()); - assertEquals("b2-nl-format-01-varreq", scenarios.get(10).label()); - assertEquals("validation_semantic_rejected", scenarios.get(10).expected().server().outcome()); - assertNotNull(scenarios.get(10).validateSchema()); - assertEquals("b1-nl-missing-01", scenarios.get(11).label()); - assertEquals("validation_semantic_rejected", scenarios.get(11).expected().server().outcome()); - assertEquals("c6-nl-mixed-07", scenarios.get(12).label()); - assertEquals("validation_semantic_rejected", scenarios.get(12).expected().server().outcome()); - assertEquals("a-data-starve-01", scenarios.get(13).label()); - assertEquals("from_data_with_schema", scenarios.get(13).entry()); - assertEquals("slot_validation_error", scenarios.get(13).expected().client().outcome()); - assertNull(scenarios.get(13).expected().server()); - assertEquals("a-nl-neg-01", scenarios.get(14).label()); - assertEquals("slot_validation_error", scenarios.get(14).expected().client().outcome()); - assertNull(scenarios.get(14).expected().server()); - } - - @Test - void should_loadAndValidateFullSuiteScenarios() { - List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios-100.json"); - - assertEquals(102, scenarios.size()); - long variants = scenarios.stream() - .filter(s -> s.label().endsWith("-varname") - || s.label().endsWith("-varfields") - || s.label().endsWith("-varflat") - || s.label().endsWith("-varreq") - || s.label().endsWith("-varsch") - || s.label().endsWith("-varuuid") - || s.label().endsWith("-vardesc") - || s.label().endsWith("-dual")) - .count(); - assertEquals(47, variants); - long clientInterceptions = scenarios.stream() - .filter(s -> s.expected().client() != null && s.expected().client().outcome() != null) - .count(); - assertEquals(9, clientInterceptions); - long serverRejections = scenarios.stream() - .filter(s -> s.expected().server() != null - && "validation_semantic_rejected".equals(s.expected().server().outcome())) - .count(); - assertEquals(37, serverRejections); - long validateSchemas = scenarios.stream().filter(s -> s.validateSchema() != null).count(); - assertEquals(43, validateSchemas); - } - - @Test - void should_throwIllegalStateException_WhenResourceNotFound() { - IllegalStateException ex = assertThrows( - IllegalStateException.class, () -> AuthzScenarioLoader.load("sample/authz-policy/nonexistent.json")); - - assertTrue(ex.getMessage().contains("not found")); - } - - @Test - void should_throwIllegalStateException_WhenJsonMalformed() { - IllegalStateException ex = assertThrows( - IllegalStateException.class, () -> AuthzScenarioLoader.load("sample/authz-policy/test/malformed.json")); - - assertTrue(ex.getMessage().contains("malformed") || ex.getMessage().contains("parse")); - } - - @Test - void should_throwIllegalArgumentException_WhenScenarioHasInvalidEntry() { - IllegalArgumentException ex = assertThrows( - IllegalArgumentException.class, - () -> AuthzScenarioLoader.load("sample/authz-policy/test/invalid-scenarios.json")); - - assertTrue(ex.getMessage().contains("bad-scenario")); - } - - @Test - void should_throwIllegalStateException_WhenScenariosIsNotAnArray() { - IllegalStateException ex = assertThrows( - IllegalStateException.class, - () -> AuthzScenarioLoader.load("sample/authz-policy/test/scenarios-as-string.json")); - - assertTrue(ex.getMessage().contains("scenarios")); - } - - @Test - void should_throwIllegalStateException_WhenScenarioLabelIsNotAString() { - IllegalStateException ex = assertThrows( - IllegalStateException.class, - () -> AuthzScenarioLoader.load("sample/authz-policy/test/scenario-label-non-string.json")); - - assertTrue(ex.getMessage().contains("label")); - } - - @Test - void should_parseValidateSchema_WhenPresent() { - List scenarios = - AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-scenarios.json"); - - assertEquals(2, scenarios.size()); - AuthzScenario present = scenarios.get(0); - assertEquals("vs-present", present.label()); - assertNotNull(present.validateSchema()); - assertEquals( - "应为简短的处置动作短语", - ((Map) present.validateSchema().get("动网操作的授权策略列表")).get("处置类型")); - } - - @Test - void should_haveNullValidateSchema_WhenAbsent() { - List scenarios = - AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-scenarios.json"); - - assertEquals("vs-missing", scenarios.get(1).label()); - assertNull(scenarios.get(1).validateSchema()); - } - - @Test - void should_throwIllegalStateException_WhenValidateSchemaIsEmpty() { - IllegalStateException ex = assertThrows( - IllegalStateException.class, - () -> AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-empty.json")); - - assertTrue(ex.getMessage().contains("validate_schema")); - } -} +package net.openan.a2at.sample.authz_policy; + +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 java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AuthzScenarioLoaderTest { + + @Test + void should_loadAndValidateAllScenarios() { + List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios.json"); + + assertEquals(15, scenarios.size()); + // 预期成功在前(index 0-7),预期拒绝在中(index 8-12),客户端拦截在末尾(index 13-14) + assertEquals("c1-nl-add-01", scenarios.get(0).label()); + assertEquals("from_text", scenarios.get(0).entry()); + assertEquals("success", scenarios.get(0).expected().server().outcome()); + assertEquals("c1-nl-add-01-varname", scenarios.get(1).label()); + assertEquals("from_text", scenarios.get(1).entry()); + assertEquals("success", scenarios.get(1).expected().server().outcome()); + assertNotNull(scenarios.get(1).validateSchema()); + assertEquals("c3-nl-mod-06", scenarios.get(3).label()); + assertEquals("success", scenarios.get(3).expected().server().outcome()); + assertEquals("c3-nl-mod-06-varfields", scenarios.get(4).label()); + assertEquals("success", scenarios.get(4).expected().server().outcome()); + assertNotNull(scenarios.get(4).validateSchema()); + assertEquals("b4-nl-bad-id-01", scenarios.get(7).label()); + assertEquals("success", scenarios.get(7).expected().server().outcome()); + assertEquals("b3-nl-invalid-mod-01", scenarios.get(8).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(8).expected().server().outcome()); + assertEquals("b2-nl-format-01", scenarios.get(9).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(9).expected().server().outcome()); + assertEquals("b2-nl-format-01-varreq", scenarios.get(10).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(10).expected().server().outcome()); + assertNotNull(scenarios.get(10).validateSchema()); + assertEquals("b1-nl-missing-01", scenarios.get(11).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(11).expected().server().outcome()); + assertEquals("c6-nl-mixed-07", scenarios.get(12).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(12).expected().server().outcome()); + assertEquals("a-data-starve-01", scenarios.get(13).label()); + assertEquals("from_data_with_schema", scenarios.get(13).entry()); + assertEquals("slot.not_provided", scenarios.get(13).expected().client().outcome()); + assertNull(scenarios.get(13).expected().server()); + assertEquals("a-nl-neg-01", scenarios.get(14).label()); + assertEquals("slot.not_provided", scenarios.get(14).expected().client().outcome()); + assertNull(scenarios.get(14).expected().server()); + } + + @Test + void should_loadAndValidateFullSuiteScenarios() { + List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios-100.json"); + + assertEquals(102, scenarios.size()); + long variants = scenarios.stream() + .filter(s -> s.label().endsWith("-varname") + || s.label().endsWith("-varfields") + || s.label().endsWith("-varflat") + || s.label().endsWith("-varreq") + || s.label().endsWith("-varsch") + || s.label().endsWith("-varuuid") + || s.label().endsWith("-vardesc") + || s.label().endsWith("-dual")) + .count(); + assertEquals(47, variants); + long clientInterceptions = scenarios.stream() + .filter(s -> + s.expected().client() != null && s.expected().client().outcome() != null) + .count(); + assertEquals(9, clientInterceptions); + long serverRejections = scenarios.stream() + .filter(s -> s.expected().server() != null + && "negotiation.semantic_rejected" + .equals(s.expected().server().outcome())) + .count(); + assertEquals(37, serverRejections); + long validateSchemas = + scenarios.stream().filter(s -> s.validateSchema() != null).count(); + assertEquals(43, validateSchemas); + } + + @Test + void should_throwIllegalStateException_WhenResourceNotFound() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, () -> AuthzScenarioLoader.load("sample/authz-policy/nonexistent.json")); + + assertTrue(ex.getMessage().contains("not found")); + } + + @Test + void should_throwIllegalStateException_WhenJsonMalformed() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, () -> AuthzScenarioLoader.load("sample/authz-policy/test/malformed.json")); + + assertTrue(ex.getMessage().contains("malformed") || ex.getMessage().contains("parse")); + } + + @Test + void should_throwIllegalArgumentException_WhenScenarioHasInvalidEntry() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/invalid-scenarios.json")); + + assertTrue(ex.getMessage().contains("bad-scenario")); + } + + @Test + void should_throwIllegalStateException_WhenScenariosIsNotAnArray() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/scenarios-as-string.json")); + + assertTrue(ex.getMessage().contains("scenarios")); + } + + @Test + void should_throwIllegalStateException_WhenScenarioLabelIsNotAString() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/scenario-label-non-string.json")); + + assertTrue(ex.getMessage().contains("label")); + } + + @Test + void should_parseValidateSchema_WhenPresent() { + List scenarios = + AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-scenarios.json"); + + assertEquals(2, scenarios.size()); + AuthzScenario present = scenarios.get(0); + assertEquals("vs-present", present.label()); + assertNotNull(present.validateSchema()); + assertEquals("应为简短的处置动作短语", ((Map) present.validateSchema().get("动网操作的授权策略列表")).get("处置类型")); + } + + @Test + void should_haveNullValidateSchema_WhenAbsent() { + List scenarios = + AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-scenarios.json"); + + assertEquals("vs-missing", scenarios.get(1).label()); + assertNull(scenarios.get(1).validateSchema()); + } + + @Test + void should_throwIllegalStateException_WhenValidateSchemaIsEmpty() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-empty.json")); + + assertTrue(ex.getMessage().contains("validate_schema")); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java index 440fa0b9..875ef801 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java @@ -1,590 +1,580 @@ -package net.openan.a2at.sample.authz_policy; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import org.junit.jupiter.api.Test; - -class AuthzScenarioRunnerTest { - - private static final TemplateUri TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; - private static final Map PARAM_SCHEMA = Map.of("properties", Map.of(), "required", Map.of()); - private static final AuthzExpected SUCCESS = new AuthzExpected( - new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, null)); - private static final AuthzExpected EXPECTED_SLOT_ERROR = new AuthzExpected( - new ClientExpected("slot_validation_error", null, null), null); - private static final AuthzExpected EXPECTED_SEMANTIC_REJECTED = new AuthzExpected( - new ClientExpected(null, "generated prompt", null), - new ServerExpected("validation_semantic_rejected", null, null)); - - @Test - void should_dispatchToFromTextGenerator() { - AtomicReference calledEntry = new AtomicReference<>(); - AuthzPromptGenerator generator = scenario -> { - calledEntry.set(scenario.entry()); - return new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertEquals("from_text", calledEntry.get()); - } - - @Test - void should_dispatchToFromDataWithSchemaGenerator() { - AtomicReference calledEntry = new AtomicReference<>(); - AuthzPromptGenerator generator = scenario -> { - calledEntry.set(scenario.entry()); - return new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario( - "test", "from_data_with_schema", Map.of("data", Map.of("k", "v"), "schema", Map.of("k", "d")), SUCCESS); - - runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertEquals("from_data_with_schema", calledEntry.get()); - } - - @Test - void should_match_WhenExpectedSuccessAndValidationPasses() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals("success", outcome.result().outcome()); - assertNotNull(outcome.metadata()); - assertNotNull(outcome.filled()); - } - - @Test - void should_match_WhenExpectedSemanticRejectedAndSemanticRejected() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - throw new ContentValidationException(A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED, "semantic rejected"); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = - new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SEMANTIC_REJECTED); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals("validation_semantic_rejected", outcome.result().outcome()); - assertNotNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_notMatch_WhenExpectedSuccessAndValidationRejected() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - throw new ContentValidationException(A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED, "semantic rejected"); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("validation_semantic_rejected", outcome.result().outcome()); - assertNotNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_notMatch_WhenExpectedSemanticRejectedAndValidationPasses() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = - new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SEMANTIC_REJECTED); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("success", outcome.result().outcome()); - assertNotNull(outcome.metadata()); - assertNotNull(outcome.filled()); - } - - @Test - void should_match_WhenPromptTextDiffersFromExpectation() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "different prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals(Boolean.FALSE, outcome.result().clientPromptMatch()); - assertEquals(Boolean.TRUE, outcome.result().serverOutcomeMatch()); - assertTrue(outcome.result().serverParamsMatch()); - } - - @Test - void should_match_WhenPromptTextDiffersAndServerMatches() { - AuthzExpected expected = new AuthzExpected( - new ClientExpected(null, "expected prompt", null), - new ServerExpected("success", null, Map.of("操作类型", "新增授权策略"))); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "drifted prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = - (prompt, schema, templateUri) -> new FilledParamData(Map.of("操作类型", "新增授权策略")); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals(Boolean.FALSE, outcome.result().clientPromptMatch()); - assertEquals(Boolean.TRUE, outcome.result().serverParamsMatch()); - } - - @Test - void should_match_WhenPromptTextDiffersOnlyInTrailingWhitespace() { - AuthzExpected expected = new AuthzExpected( - new ClientExpected(null, "generated prompt \n", null), - new ServerExpected("success", null, null)); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), " generated prompt\n", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - } - - @Test - void should_notMatch_WhenExpectedSuccessAndGeneratorThrowsPromptGenerationException() { - AuthzPromptGenerator generator = scenario -> { - throw new PromptGenerationException("slot_schema_not_found", "schema not found"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("slot_schema_not_found", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_match_WhenExpectedSlotValidationErrorAndGeneratorThrowsSlotValidationError() { - AuthzPromptGenerator generator = scenario -> { - throw new PromptGenerationException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, "Required slots are missing or empty: operation_type"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = - new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SLOT_ERROR); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals("slot_validation_error", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_notMatch_WhenExpectedSlotValidationErrorAndGeneratorThrowsInfrastructureError() { - AuthzPromptGenerator generator = scenario -> { - throw new PromptGenerationException( - A2ATErrorCodes.LLM_INVOCATION_FAILED, "LLM invocation failed"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = - new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SLOT_ERROR); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("llm_invocation_failed", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_notMatch_WhenExpectedSuccessAndValidatorThrowsInfrastructureError() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_LLM_INFRASTRUCTURE_ERROR, "infrastructure error"); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("validation_llm_infrastructure_error", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNotNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_notMatch_WhenExpectedSuccessAndValidatorThrowsResourceNotFound() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - throw new ContentValidationException( - A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, "resource not found"); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("validation_prompt_resource_not_found", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNotNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_notMatch_WhenExpectedSuccessAndValidatorThrowsUnknownCode() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - throw new ContentValidationException("unknown_code", "unknown error"); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("unknown_code", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNotNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_matchSlotErrors_WhenExpectedSlotErrorsMatchActual() { - AuthzPromptGenerator generator = scenario -> { - throw new PromptGenerationException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - "Required slots are missing or empty: 授权策略的操作类型", - List.of(new SlotValidationError("授权策略的操作类型", "missing_required", "Required slot is missing or empty"))); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzExpected expected = new AuthzExpected( - new ClientExpected( - "slot_validation_error", - null, - List.of(new SlotErrorExpectation("授权策略的操作类型", "missing_required"))), - null); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals(1, outcome.result().slotErrors().size()); - assertEquals("授权策略的操作类型", outcome.result().slotErrors().get(0).slotName()); - assertEquals("missing_required", outcome.result().slotErrors().get(0).code()); - } - - @Test - void should_notMatchSlotErrors_WhenExpectedSlotErrorsDontMatchActual() { - AuthzPromptGenerator generator = scenario -> { - throw new PromptGenerationException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - "Required slots are missing or empty: 授权策略的操作类型", - List.of(new SlotValidationError("授权策略的操作类型", "missing_required", "Required slot is missing or empty"))); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzExpected expected = new AuthzExpected( - new ClientExpected( - "slot_validation_error", - null, - List.of(new SlotErrorExpectation("动网操作的授权策略列表", "missing_required"))), - null); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("slot_validation_error", outcome.result().outcome()); - } - - @Test - void should_notMatchSlotErrors_WhenSlotNameMatchesButCodeDiffers() { - AuthzPromptGenerator generator = scenario -> { - throw new PromptGenerationException( - A2ATErrorCodes.SLOT_VALIDATION_ERROR, - "Required slots are missing or empty: 授权策略的操作类型", - List.of(new SlotValidationError("授权策略的操作类型", "missing_required", "Required slot is missing or empty"))); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzExpected expected = new AuthzExpected( - new ClientExpected( - "slot_validation_error", - null, - List.of(new SlotErrorExpectation("授权策略的操作类型", "invalid_value"))), - null); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - } - - @Test - void should_matchParams_WhenActualIsSupersetOfExpected() { - AuthzExpected expected = new AuthzExpected( - new ClientExpected(null, "generated prompt", null), - new ServerExpected( - "success", - null, - Map.of("操作类型", "新增授权策略", "策略列表", List.of(Map.of("业务场景", "校园专网"))))); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - Map actualEntry = new java.util.LinkedHashMap<>(); - actualEntry.put("策略标识", null); - actualEntry.put("业务场景", "校园专网"); - actualEntry.put("处置类型", "紧急扩容"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of( - "操作类型", "新增授权策略", - "策略列表", List.of(actualEntry))); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals(Boolean.TRUE, outcome.result().serverParamsMatch()); - } - - @Test - void should_notMatchParams_WhenListItemValueDiffers() { - AuthzExpected expected = new AuthzExpected( - new ClientExpected(null, "generated prompt", null), - new ServerExpected("success", null, Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"))))); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of( - "策略列表", List.of(Map.of("业务场景", "医疗专线")))); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals(Boolean.FALSE, outcome.result().serverParamsMatch()); - } - - @Test - void should_notMatchParams_WhenListLengthDiffers() { - AuthzExpected expected = new AuthzExpected( - new ClientExpected(null, "generated prompt", null), - new ServerExpected( - "success", null, Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"))))); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of( - "策略列表", List.of(Map.of("业务场景", "校园专网"), Map.of("业务场景", "医疗专线")))); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals(Boolean.FALSE, outcome.result().serverParamsMatch()); - } - - @Test - void should_notMatch_WhenExpectedSuccessAndGeneratorThrowsRuntimeException() { - AuthzPromptGenerator generator = scenario -> { - throw new NullPointerException("unexpected NPE"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("sdk_internal_error", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_warn_WhenMutationAndEmptyPolicyListSection() { - String promptText = "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n\n\n## 预期输出\n输出格式"; - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); - } - - @Test - void should_warn_WhenModifyAndEmptyPolicyListSection() { - String promptText = "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); - } - - @Test - void should_notWarn_WhenQueryAndEmptyPolicyListSection() { - String promptText = "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertTrue(outcome.result().warnings().isEmpty()); - } - - @Test - void should_notWarn_WhenMutationAndNonEmptyPolicyListSection() { - String promptText = "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n校园专网,紧急扩容\n\n## 预期输出\n输出格式"; - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertTrue(outcome.result().warnings().isEmpty()); - } - - @Test - void should_notWarn_WhenPromptTextHasUnknownSectionOrder() { - String promptText = "## 授权策略的操作描述\n描述\n\n## 授权策略的操作类型\n新增授权策略\n\n## 预期输出\n输出格式\n\n## 动网操作的授权策略列表\n\n"; - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); - } - - @Test - void should_notWarn_WhenNoOperationTypeSection() { - String promptText = "## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertTrue(outcome.result().warnings().isEmpty()); - } - - @Test - void should_useScenarioSchema_WhenValidateSchemaPresent() { - Map scenarioSchema = Map.of("处置规则", "应为短语"); - AtomicReference> receivedSchema = new AtomicReference<>(); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - receivedSchema.set(schema); - return new FilledParamData(Map.of()); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = - new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS, scenarioSchema); - - runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertSame(scenarioSchema, receivedSchema.get()); - } - - @Test - void should_useDefaultSchema_WhenValidateSchemaAbsent() { - AtomicReference> receivedSchema = new AtomicReference<>(); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - receivedSchema.set(schema); - return new FilledParamData(Map.of()); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertSame(PARAM_SCHEMA, receivedSchema.get()); - } -} +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import org.junit.jupiter.api.Test; + +class AuthzScenarioRunnerTest { + + private static final TemplateUri TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; + private static final Map PARAM_SCHEMA = Map.of("properties", Map.of(), "required", Map.of()); + private static final AuthzExpected SUCCESS = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, null)); + private static final AuthzExpected EXPECTED_SLOT_ERROR = + new AuthzExpected(new ClientExpected("slot.rule_violation", null, null), null); + private static final AuthzExpected EXPECTED_SEMANTIC_REJECTED = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), + new ServerExpected("negotiation.semantic_rejected", null, null)); + + @Test + void should_dispatchToFromTextGenerator() { + AtomicReference calledEntry = new AtomicReference<>(); + AuthzPromptGenerator generator = scenario -> { + calledEntry.set(scenario.entry()); + return new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertEquals("from_text", calledEntry.get()); + } + + @Test + void should_dispatchToFromDataWithSchemaGenerator() { + AtomicReference calledEntry = new AtomicReference<>(); + AuthzPromptGenerator generator = scenario -> { + calledEntry.set(scenario.entry()); + return new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario( + "test", "from_data_with_schema", Map.of("data", Map.of("k", "v"), "schema", Map.of("k", "d")), SUCCESS); + + runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertEquals("from_data_with_schema", calledEntry.get()); + } + + @Test + void should_match_WhenExpectedSuccessAndValidationPasses() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals("success", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNotNull(outcome.filled()); + } + + @Test + void should_match_WhenExpectedSemanticRejectedAndSemanticRejected() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), "semantic rejected"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = + new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SEMANTIC_REJECTED); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals("negotiation.semantic_rejected", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidationRejected() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), "semantic rejected"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("negotiation.semantic_rejected", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSemanticRejectedAndValidationPasses() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = + new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SEMANTIC_REJECTED); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("success", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNotNull(outcome.filled()); + } + + @Test + void should_match_WhenPromptTextDiffersFromExpectation() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "different prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals(Boolean.FALSE, outcome.result().clientPromptMatch()); + assertEquals(Boolean.TRUE, outcome.result().serverOutcomeMatch()); + assertTrue(outcome.result().serverParamsMatch()); + } + + @Test + void should_match_WhenPromptTextDiffersAndServerMatches() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "expected prompt", null), + new ServerExpected("success", null, Map.of("操作类型", "新增授权策略"))); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "drifted prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("操作类型", "新增授权策略")); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals(Boolean.FALSE, outcome.result().clientPromptMatch()); + assertEquals(Boolean.TRUE, outcome.result().serverParamsMatch()); + } + + @Test + void should_match_WhenPromptTextDiffersOnlyInTrailingWhitespace() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "generated prompt \n", null), new ServerExpected("success", null, null)); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), " generated prompt\n", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndGeneratorThrowsPromptGenerationException() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException("slot.schema_not_found", "schema not found"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("slot.schema_not_found", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_match_WhenExpectedSlotValidationErrorAndGeneratorThrowsSlotValidationError() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + ErrorCatalog.SLOT_RULE_VIOLATION.getCode(), "Required slots are missing or empty: operation_type"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SLOT_ERROR); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals("slot.rule_violation", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSlotValidationErrorAndGeneratorThrowsInfrastructureError() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), "LLM invocation failed"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SLOT_ERROR); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("llm.invocation_failed", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidatorThrowsInfrastructureError() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), "infrastructure error"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("llm.invocation_failed", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidatorThrowsResourceNotFound() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), "resource not found"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("template.not_found", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidatorThrowsUnknownCode() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException("unknown_code", "unknown error"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("unknown_code", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_matchSlotErrors_WhenExpectedSlotErrorsMatchActual() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + "输入中未提供「授权策略的操作类型」。", + List.of(new SlotValidationError("授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。"))); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzExpected expected = new AuthzExpected( + new ClientExpected( + "slot.not_provided", + null, + List.of(new SlotErrorExpectation("授权策略的操作类型", "slot.not_provided"))), + null); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals(1, outcome.result().slotErrors().size()); + assertEquals("授权策略的操作类型", outcome.result().slotErrors().get(0).slotName()); + assertEquals("slot.not_provided", outcome.result().slotErrors().get(0).code()); + } + + @Test + void should_notMatchSlotErrors_WhenExpectedSlotErrorsDontMatchActual() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + "输入中未提供「授权策略的操作类型」。", + List.of(new SlotValidationError("授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。"))); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzExpected expected = new AuthzExpected( + new ClientExpected( + "slot.not_provided", + null, + List.of(new SlotErrorExpectation("动网操作的授权策略列表", "slot.not_provided"))), + null); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("slot.not_provided", outcome.result().outcome()); + } + + @Test + void should_notMatchSlotErrors_WhenSlotNameMatchesButCodeDiffers() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + "输入中未提供「授权策略的操作类型」。", + List.of(new SlotValidationError("授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。"))); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzExpected expected = new AuthzExpected( + new ClientExpected( + "slot.not_provided", + null, + List.of(new SlotErrorExpectation("授权策略的操作类型", "slot.constraint_violated"))), + null); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + } + + @Test + void should_matchParams_WhenActualIsSupersetOfExpected() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), + new ServerExpected("success", null, Map.of("操作类型", "新增授权策略", "策略列表", List.of(Map.of("业务场景", "校园专网"))))); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + Map actualEntry = new java.util.LinkedHashMap<>(); + actualEntry.put("策略标识", null); + actualEntry.put("业务场景", "校园专网"); + actualEntry.put("处置类型", "紧急扩容"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> + new FilledParamData(Map.of("操作类型", "新增授权策略", "策略列表", List.of(actualEntry))); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals(Boolean.TRUE, outcome.result().serverParamsMatch()); + } + + @Test + void should_notMatchParams_WhenListItemValueDiffers() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), + new ServerExpected("success", null, Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"))))); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = + (prompt, schema, templateUri) -> new FilledParamData(Map.of("策略列表", List.of(Map.of("业务场景", "医疗专线")))); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals(Boolean.FALSE, outcome.result().serverParamsMatch()); + } + + @Test + void should_notMatchParams_WhenListLengthDiffers() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), + new ServerExpected("success", null, Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"))))); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> + new FilledParamData(Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"), Map.of("业务场景", "医疗专线")))); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals(Boolean.FALSE, outcome.result().serverParamsMatch()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndGeneratorThrowsRuntimeException() { + AuthzPromptGenerator generator = scenario -> { + throw new NullPointerException("unexpected NPE"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("infra.internal_error", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_warn_WhenMutationAndEmptyPolicyListSection() { + String promptText = "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); + } + + @Test + void should_warn_WhenModifyAndEmptyPolicyListSection() { + String promptText = "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); + } + + @Test + void should_notWarn_WhenQueryAndEmptyPolicyListSection() { + String promptText = "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().isEmpty()); + } + + @Test + void should_notWarn_WhenMutationAndNonEmptyPolicyListSection() { + String promptText = "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n校园专网,紧急扩容\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().isEmpty()); + } + + @Test + void should_notWarn_WhenPromptTextHasUnknownSectionOrder() { + String promptText = "## 授权策略的操作描述\n描述\n\n## 授权策略的操作类型\n新增授权策略\n\n## 预期输出\n输出格式\n\n## 动网操作的授权策略列表\n\n"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); + } + + @Test + void should_notWarn_WhenNoOperationTypeSection() { + String promptText = "## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().isEmpty()); + } + + @Test + void should_useScenarioSchema_WhenValidateSchemaPresent() { + Map scenarioSchema = Map.of("处置规则", "应为短语"); + AtomicReference> receivedSchema = new AtomicReference<>(); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + receivedSchema.set(schema); + return new FilledParamData(Map.of()); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = + new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS, scenarioSchema); + + runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertSame(scenarioSchema, receivedSchema.get()); + } + + @Test + void should_useDefaultSchema_WhenValidateSchemaAbsent() { + AtomicReference> receivedSchema = new AtomicReference<>(); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + receivedSchema.set(schema); + return new FilledParamData(Map.of()); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertSame(PARAM_SCHEMA, receivedSchema.get()); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioTest.java index 492ef3ed..39bbb2b9 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioTest.java @@ -1,240 +1,238 @@ -package net.openan.a2at.sample.authz_policy; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -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 java.util.Map; -import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; -import org.junit.jupiter.api.Test; - -class AuthzScenarioTest { - - private static AuthzExpected success() { - return new AuthzExpected( - new ClientExpected(null, "## rendered prompt", null), - new ServerExpected("success", null, null)); - } - - @Test - void should_acceptValidFromTextScenario() { - AuthzScenario scenario = new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success()); - - assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); - } - - @Test - void should_acceptValidFromDataWithSchemaScenario() { - AuthzScenario scenario = new AuthzScenario( - "add-from-data", - "from_data_with_schema", - Map.of("data", Map.of("k", "v"), "schema", Map.of("k", "description")), - success()); - - assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); - } - - @Test - void should_rejectInvalidEntry() { - AuthzScenario scenario = new AuthzScenario("bad", "invalid_entry", Map.of("text", "test"), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("Invalid entry: invalid_entry", ex.getMessage()); - } - - @Test - void should_rejectInvalidClientOutcome() { - AuthzScenario scenario = new AuthzScenario( - "bad", - "from_text", - Map.of("text", "test"), - new AuthzExpected(new ClientExpected("invalid_outcome", null, null), null)); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("Invalid expected client outcome: invalid_outcome", ex.getMessage()); - } - - @Test - void should_rejectClientOutcomeWithServerExpectation() { - AuthzScenario scenario = new AuthzScenario( - "bad", - "from_text", - Map.of("text", "test"), - new AuthzExpected( - new ClientExpected("slot_validation_error", null, null), - new ServerExpected("success", null, null))); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("expected.server must be null when expected.client.outcome is set", ex.getMessage()); - } - - @Test - void should_rejectClientSuccessWithoutPromptText() { - AuthzScenario scenario = new AuthzScenario( - "bad", - "from_text", - Map.of("text", "test"), - new AuthzExpected(new ClientExpected(null, " ", null), new ServerExpected("success", null, null))); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals( - "expected.client.promptText must be non-blank when client outcome is not set", ex.getMessage()); - } - - @Test - void should_rejectClientSuccessWithoutServerExpectation() { - AuthzScenario scenario = new AuthzScenario( - "bad", "from_text", Map.of("text", "test"), - new AuthzExpected(new ClientExpected(null, "prompt", null), null)); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertTrue(ex.getMessage().startsWith("expected.server.outcome must be one of")); - assertTrue(ex.getMessage().contains("when client succeeds")); - } - - @Test - void should_acceptSlotValidationErrorOutcome() { - AuthzScenario scenario = new AuthzScenario( - "reject", - "from_text", - Map.of("text", "test"), - new AuthzExpected(new ClientExpected("slot_validation_error", null, null), null)); - - assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); - } - - @Test - void should_acceptSemanticRejectedServerOutcome() { - AuthzScenario scenario = new AuthzScenario( - "reject", - "from_text", - Map.of("text", "test"), - new AuthzExpected( - new ClientExpected(null, "prompt", null), - new ServerExpected("validation_semantic_rejected", null, null))); - - assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); - } - - @Test - void should_rejectNullExpected() { - AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of("text", "test"), null); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("expected.client must not be null", ex.getMessage()); - } - - @Test - void should_rejectFromTextMissingText() { - AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of(), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("from_text scenario requires a non-blank input.text", ex.getMessage()); - } - - @Test - void should_rejectFromTextBlankText() { - AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of("text", " "), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("from_text scenario requires a non-blank input.text", ex.getMessage()); - } - - @Test - void should_rejectFromDataMissingData() { - AuthzScenario scenario = - new AuthzScenario("bad", "from_data_with_schema", Map.of("schema", Map.of("k", "d")), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("from_data_with_schema scenario requires a non-empty input.data", ex.getMessage()); - } - - @Test - void should_rejectFromDataMissingSchema() { - AuthzScenario scenario = - new AuthzScenario("bad", "from_data_with_schema", Map.of("data", Map.of("k", "v")), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("from_data_with_schema scenario requires a non-empty input.schema", ex.getMessage()); - } - - @Test - void should_rejectFromDataWithSchema_EmptyData() { - AuthzScenario scenario = new AuthzScenario( - "bad", "from_data_with_schema", Map.of("data", Map.of(), "schema", Map.of("k", "d")), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("from_data_with_schema scenario requires a non-empty input.data", ex.getMessage()); - } - - @Test - void should_rejectFromDataWithSchema_EmptySchema() { - AuthzScenario scenario = new AuthzScenario( - "bad", "from_data_with_schema", Map.of("data", Map.of("k", "v"), "schema", Map.of()), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("from_data_with_schema scenario requires a non-empty input.schema", ex.getMessage()); - } - - @Test - void should_rejectNullInput() { - AuthzScenario scenario = new AuthzScenario("bad", "from_text", null, success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("input must not be null", ex.getMessage()); - } - - @Test - void should_rejectNullScenario() { - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(null)); - assertEquals("scenario must not be null", ex.getMessage()); - } - - @Test - void should_haveNullValidateSchema_WhenFourArgConstructor() { - AuthzScenario scenario = new AuthzScenario( - "add-from-text", "from_text", Map.of("text", "test"), success()); - - assertNull(scenario.validateSchema()); - } - - @Test - void should_acceptNonEmptyValidateSchema() { - AuthzScenario scenario = new AuthzScenario( - "add-from-text", - "from_text", - Map.of("text", "test"), - success(), - Map.of("动网操作的授权策略列表", Map.of("处置类型", "应为简短的处置动作短语"))); - - assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); - } - - @Test - void should_rejectEmptyValidateSchema() { - AuthzScenario scenario = new AuthzScenario( - "add-from-text", "from_text", Map.of("text", "test"), success(), Map.of()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("validateSchema must be non-empty when set", ex.getMessage()); - } -} +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +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 java.util.Map; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import org.junit.jupiter.api.Test; + +class AuthzScenarioTest { + + private static AuthzExpected success() { + return new AuthzExpected( + new ClientExpected(null, "## rendered prompt", null), new ServerExpected("success", null, null)); + } + + @Test + void should_acceptValidFromTextScenario() { + AuthzScenario scenario = new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success()); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_acceptValidFromDataWithSchemaScenario() { + AuthzScenario scenario = new AuthzScenario( + "add-from-data", + "from_data_with_schema", + Map.of("data", Map.of("k", "v"), "schema", Map.of("k", "description")), + success()); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_rejectInvalidEntry() { + AuthzScenario scenario = new AuthzScenario("bad", "invalid_entry", Map.of("text", "test"), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("Invalid entry: invalid_entry", ex.getMessage()); + } + + @Test + void should_rejectInvalidClientOutcome() { + AuthzScenario scenario = new AuthzScenario( + "bad", + "from_text", + Map.of("text", "test"), + new AuthzExpected(new ClientExpected("invalid_outcome", null, null), null)); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("Invalid expected client outcome: invalid_outcome", ex.getMessage()); + } + + @Test + void should_rejectClientOutcomeWithServerExpectation() { + AuthzScenario scenario = new AuthzScenario( + "bad", + "from_text", + Map.of("text", "test"), + new AuthzExpected( + new ClientExpected("slot.rule_violation", null, null), + new ServerExpected("success", null, null))); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("expected.server must be null when expected.client.outcome is set", ex.getMessage()); + } + + @Test + void should_rejectClientSuccessWithoutPromptText() { + AuthzScenario scenario = new AuthzScenario( + "bad", + "from_text", + Map.of("text", "test"), + new AuthzExpected(new ClientExpected(null, " ", null), new ServerExpected("success", null, null))); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("expected.client.promptText must be non-blank when client outcome is not set", ex.getMessage()); + } + + @Test + void should_rejectClientSuccessWithoutServerExpectation() { + AuthzScenario scenario = new AuthzScenario( + "bad", + "from_text", + Map.of("text", "test"), + new AuthzExpected(new ClientExpected(null, "prompt", null), null)); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertTrue(ex.getMessage().startsWith("expected.server.outcome must be one of")); + assertTrue(ex.getMessage().contains("when client succeeds")); + } + + @Test + void should_acceptSlotValidationErrorOutcome() { + AuthzScenario scenario = new AuthzScenario( + "reject", + "from_text", + Map.of("text", "test"), + new AuthzExpected(new ClientExpected("slot.rule_violation", null, null), null)); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_acceptSemanticRejectedServerOutcome() { + AuthzScenario scenario = new AuthzScenario( + "reject", + "from_text", + Map.of("text", "test"), + new AuthzExpected( + new ClientExpected(null, "prompt", null), + new ServerExpected("negotiation.semantic_rejected", null, null))); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_rejectNullExpected() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of("text", "test"), null); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("expected.client must not be null", ex.getMessage()); + } + + @Test + void should_rejectFromTextMissingText() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of(), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_text scenario requires a non-blank input.text", ex.getMessage()); + } + + @Test + void should_rejectFromTextBlankText() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of("text", " "), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_text scenario requires a non-blank input.text", ex.getMessage()); + } + + @Test + void should_rejectFromDataMissingData() { + AuthzScenario scenario = + new AuthzScenario("bad", "from_data_with_schema", Map.of("schema", Map.of("k", "d")), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.data", ex.getMessage()); + } + + @Test + void should_rejectFromDataMissingSchema() { + AuthzScenario scenario = + new AuthzScenario("bad", "from_data_with_schema", Map.of("data", Map.of("k", "v")), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.schema", ex.getMessage()); + } + + @Test + void should_rejectFromDataWithSchema_EmptyData() { + AuthzScenario scenario = new AuthzScenario( + "bad", "from_data_with_schema", Map.of("data", Map.of(), "schema", Map.of("k", "d")), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.data", ex.getMessage()); + } + + @Test + void should_rejectFromDataWithSchema_EmptySchema() { + AuthzScenario scenario = new AuthzScenario( + "bad", "from_data_with_schema", Map.of("data", Map.of("k", "v"), "schema", Map.of()), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.schema", ex.getMessage()); + } + + @Test + void should_rejectNullInput() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", null, success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("input must not be null", ex.getMessage()); + } + + @Test + void should_rejectNullScenario() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(null)); + assertEquals("scenario must not be null", ex.getMessage()); + } + + @Test + void should_haveNullValidateSchema_WhenFourArgConstructor() { + AuthzScenario scenario = new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success()); + + assertNull(scenario.validateSchema()); + } + + @Test + void should_acceptNonEmptyValidateSchema() { + AuthzScenario scenario = new AuthzScenario( + "add-from-text", + "from_text", + Map.of("text", "test"), + success(), + Map.of("动网操作的授权策略列表", Map.of("处置类型", "应为简短的处置动作短语"))); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_rejectEmptyValidateSchema() { + AuthzScenario scenario = + new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success(), Map.of()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("validateSchema must be non-empty when set", ex.getMessage()); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java index f0ae6e0f..82765c63 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java @@ -1,302 +1,317 @@ -package net.openan.a2at.sample.service_recovery.accuracy; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.json.JsonMapper; -import java.io.IOException; -import java.io.InputStream; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.server.A2ATServer; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; - -@Tag("integration") -class NotificationTAccuracyTest { - - private static final ObjectMapper MAPPER = JsonMapper.builder() - .enable(SerializationFeature.INDENT_OUTPUT) - .build(); - - private static final String TEMPLATE_URI_STR = "Notification-T/network-layer/service-recovery/v1"; - private static final String DATA_SCHEMA_RESOURCE = "sample/service-recovery/client/schema.json"; - private static final String VALIDATION_SCHEMA_RESOURCE = "sample/service-recovery/server/schema.json"; - - private static A2ATClient client; - private static A2ATServer server; - private static TemplateUri templateUri; - private static Map dataSchema; - private static Map validationSchema; - private static boolean apiKeyConfigured; - private static Map envInfo; - - private static final List> fromTextResults = new ArrayList<>(); - private static final List> fromDataResults = new ArrayList<>(); - private static final List> validateResults = new ArrayList<>(); - - @BeforeAll - static void setup() throws Exception { - templateUri = TemplateUri.parse(TEMPLATE_URI_STR) - .orElseThrow(() -> new IllegalStateException("Invalid template URI: " + TEMPLATE_URI_STR)); - - dataSchema = loadJsonResource(DATA_SCHEMA_RESOURCE); - validationSchema = loadJsonResource(VALIDATION_SCHEMA_RESOURCE); - - Path env = resolveEnvFile("service-recovery-accuracy-test/client.env"); - envInfo = readEnvInfo(env); - - String apiKey = envInfo.get("A2AT_LLM_API_KEY"); - apiKeyConfigured = apiKey != null && !apiKey.isBlank() && !apiKey.contains("${"); - - if (apiKeyConfigured) { - client = new A2ATClient(env); - server = new A2ATServer(env); - } - } - - @AfterAll - static void writeReport() throws Exception { - Map report = new LinkedHashMap<>(); - - Map testRun = new LinkedHashMap<>(); - testRun.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); - testRun.put("env", envInfo); - testRun.put("apiKeyConfigured", apiKeyConfigured); - report.put("testRun", testRun); - - report.put("generateNotificationPromptFromText", Map.of("testCases", fromTextResults)); - report.put("generateNotificationPromptFromDataWithSchema", Map.of("testCases", fromDataResults)); - report.put("validateNotificationPromptAndDataFilling", Map.of("testCases", validateResults)); - - Path reportDir = Path.of("target"); - Files.createDirectories(reportDir); - Path reportFile = reportDir.resolve("service-recovery-accuracy-report.json"); - MAPPER.writeValue(reportFile.toFile(), report); - System.out.println("Report written to: " + reportFile.toAbsolutePath().normalize()); - } - - @Test - void generateNotificationPromptFromText() throws Exception { - if (!apiKeyConfigured) { - System.out.println("SKIP: generateNotificationPromptFromText - API key not configured"); - return; - } - List cases = loadTextCases("service-recovery-accuracy-test/test-cases-generate-from-text.json"); - System.out.println("== generateNotificationPromptFromText =="); - boolean first = true; - for (TextCase tc : cases) { - if (!first) System.out.println("---"); - first = false; - Map entry = new LinkedHashMap<>(); - entry.put("name", tc.name); - entry.put("input", Map.of("text", tc.text)); - - try { - MetadataContent result = client.generateNotificationPromptFromText(tc.text, templateUri); - entry.put("output", metadataOutput(result)); - System.out.println(" " + tc.name + ": " + compact(result.promptText())); - } catch (Exception e) { - entry.put("output", Map.of("error", e.getClass().getName() + ": " + e.getMessage())); - System.out.println(" " + tc.name + ": ERROR - " + e.getMessage()); - } - fromTextResults.add(entry); - } - } - - @Test - void generateNotificationPromptFromDataWithSchema() throws Exception { - if (!apiKeyConfigured) { - System.out.println("SKIP: generateNotificationPromptFromDataWithSchema - API key not configured"); - return; - } - List cases = loadDataCases("service-recovery-accuracy-test/test-cases-generate-from-data.json"); - System.out.println("== generateNotificationPromptFromDataWithSchema =="); - boolean first = true; - for (DataCase dc : cases) { - if (!first) System.out.println("---"); - first = false; - Map entry = new LinkedHashMap<>(); - entry.put("name", dc.name); - entry.put("input", Map.of("data", dc.data)); - - try { - MetadataContent result = client.generateNotificationPromptFromDataWithSchema(dc.data, dataSchema, templateUri); - entry.put("output", metadataOutput(result)); - System.out.println(" " + dc.name + ": " + compact(result.promptText())); - } catch (Exception e) { - entry.put("output", Map.of("error", e.getClass().getName() + ": " + e.getMessage())); - System.out.println(" " + dc.name + ": ERROR - " + e.getMessage()); - } - fromDataResults.add(entry); - } - } - - private static Map validate(String promptText) { - try { - FilledParamData result = server.validateNotificationPromptAndDataFilling(promptText, validationSchema, templateUri); - Map output = new LinkedHashMap<>(); - output.put("semantic_verdict", true); - output.put("params", result.data()); - return output; - } catch (ContentValidationException e) { - Map output = new LinkedHashMap<>(); - output.put("semantic_verdict", false); - output.put("code", e.getCode()); - output.put("message", e.getMessage()); - if (!e.errors().isEmpty()) { - output.put("errors", e.errors().stream() - .map(err -> Map.of("slot_name", err.slotName(), "code", err.code(), "message", err.message())) - .toList()); - } - return output; - } catch (Exception e) { - return Map.of("error", e.getClass().getName() + ": " + e.getMessage()); - } - } - - @Test - void validateNotificationPromptAndDataFilling() throws Exception { - if (!apiKeyConfigured) { - System.out.println("SKIP: validateNotificationPromptAndDataFilling - API key not configured"); - return; - } - List cases = loadValidateCases("service-recovery-accuracy-test/test-cases-validate-and-fill.json"); - System.out.println("== validateNotificationPromptAndDataFilling =="); - boolean first = true; - for (ValidateCase vc : cases) { - if (!first) System.out.println("---"); - first = false; - Map entry = new LinkedHashMap<>(); - entry.put("name", vc.name); - entry.put("input", Map.of("promptText", vc.promptText)); - Map output = validate(vc.promptText); - entry.put("output", output); - System.out.println(" " + vc.name + ": " + MAPPER.writeValueAsString(output)); - validateResults.add(entry); - } - } - - private static Map metadataOutput(MetadataContent result) { - Map output = new LinkedHashMap<>(); - output.put("templateUri", result.templateUri()); - output.put("extensionUri", result.extensionUri()); - output.put("promptText", result.promptText()); - return output; - } - - private static String compact(String text) { - if (text == null || text.isEmpty()) { - return text; - } - return text.replaceAll("(?m)^[ \t]*\\r?\\n", ""); - } - - private static Path resolveEnvFile(String resourcePath) throws IOException, URISyntaxException { - URL url = NotificationTAccuracyTest.class.getClassLoader().getResource(resourcePath); - Objects.requireNonNull(url, "Resource not found: " + resourcePath); - Path envPath = Path.of(url.toURI()); - Path projectRoot = findProjectRoot(envPath.getParent()); - - String content = Files.readString(envPath, StandardCharsets.UTF_8); - String resolvedContent = resolveRootDir(content, projectRoot); - - Path tempFile = Files.createTempFile("notification-t-test-", ".env"); - Files.writeString(tempFile, resolvedContent, StandardCharsets.UTF_8); - tempFile.toFile().deleteOnExit(); - return tempFile; - } - - private static Path findProjectRoot(Path start) { - Path dir = start; - while (dir != null) { - if (Files.isDirectory(dir.resolve("a2a-t-resources"))) { - return dir; - } - dir = dir.getParent(); - } - throw new IllegalStateException("Project root (containing a2a-t-resources) not found above " + start); - } - - private static String resolveRootDir(String content, Path projectRoot) { - String prefix = "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR="; - Path promptRoot = projectRoot.resolve("a2a-t-resources/src/main/resources/prompt_resources").normalize(); - String absolutePath = promptRoot.toString(); - for (String line : content.split("\n")) { - String trimmed = line.strip(); - if (trimmed.startsWith(prefix)) { - return content.replace(trimmed, prefix + absolutePath); - } - } - return content; - } - - private static Map readEnvInfo(Path envPath) throws IOException { - Map info = new LinkedHashMap<>(); - for (String line : Files.readAllLines(envPath, StandardCharsets.UTF_8)) { - String trimmed = line.strip(); - if (trimmed.isEmpty() || trimmed.startsWith("#")) { - continue; - } - int eqIdx = trimmed.indexOf('='); - if (eqIdx > 0) { - String key = trimmed.substring(0, eqIdx).strip(); - String value = trimmed.substring(eqIdx + 1).strip(); - if (key.equals("A2AT_LLM_API_KEY")) { - value = value.isBlank() ? "" : "***"; - } - info.put(key, value); - } - } - return info; - } - - private static Map loadJsonResource(String resourcePath) throws IOException { - try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { - Objects.requireNonNull(is, "Resource not found: " + resourcePath); - return MAPPER.readValue(is, new TypeReference>() {}); - } - } - - private static List loadTextCases(String resourcePath) throws IOException { - try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { - Objects.requireNonNull(is, "Resource not found: " + resourcePath); - return MAPPER.readValue(is, new TypeReference>() {}); - } - } - - private static List loadDataCases(String resourcePath) throws IOException { - try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { - Objects.requireNonNull(is, "Resource not found: " + resourcePath); - return MAPPER.readValue(is, new TypeReference>() {}); - } - } - - private static List loadValidateCases(String resourcePath) throws IOException { - try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { - Objects.requireNonNull(is, "Resource not found: " + resourcePath); - return MAPPER.readValue(is, new TypeReference>() {}); - } - } - - private record TextCase(String name, String text) {} - private record DataCase(String name, Map data) {} - private record ValidateCase(String name, String promptText) {} -} \ No newline at end of file +package net.openan.a2at.sample.service_recovery.accuracy; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("integration") +class NotificationTAccuracyTest { + + private static final ObjectMapper MAPPER = + JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + + private static final String TEMPLATE_URI_STR = "Notification-T/network-layer/service-recovery/v1"; + private static final String DATA_SCHEMA_RESOURCE = "sample/service-recovery/client/schema.json"; + private static final String VALIDATION_SCHEMA_RESOURCE = "sample/service-recovery/server/schema.json"; + + private static A2ATClient client; + private static A2ATServer server; + private static TemplateUri templateUri; + private static Map dataSchema; + private static Map validationSchema; + private static boolean apiKeyConfigured; + private static Map envInfo; + + private static final List> fromTextResults = new ArrayList<>(); + private static final List> fromDataResults = new ArrayList<>(); + private static final List> validateResults = new ArrayList<>(); + + @BeforeAll + static void setup() throws Exception { + templateUri = TemplateUri.parse(TEMPLATE_URI_STR) + .orElseThrow(() -> new IllegalStateException("Invalid template URI: " + TEMPLATE_URI_STR)); + + dataSchema = loadJsonResource(DATA_SCHEMA_RESOURCE); + validationSchema = loadJsonResource(VALIDATION_SCHEMA_RESOURCE); + + Path env = resolveEnvFile("service-recovery-accuracy-test/client.env"); + envInfo = readEnvInfo(env); + + String apiKey = envInfo.get("A2AT_LLM_API_KEY"); + apiKeyConfigured = apiKey != null && !apiKey.isBlank() && !apiKey.contains("${"); + + if (apiKeyConfigured) { + client = new A2ATClient(env); + server = new A2ATServer(env); + } + } + + @AfterAll + static void writeReport() throws Exception { + Map report = new LinkedHashMap<>(); + + Map testRun = new LinkedHashMap<>(); + testRun.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + testRun.put("env", envInfo); + testRun.put("apiKeyConfigured", apiKeyConfigured); + report.put("testRun", testRun); + + report.put("generateNotificationPromptFromText", Map.of("testCases", fromTextResults)); + report.put("generateNotificationPromptFromDataWithSchema", Map.of("testCases", fromDataResults)); + report.put("validateNotificationPromptAndDataFilling", Map.of("testCases", validateResults)); + + Path reportDir = Path.of("target"); + Files.createDirectories(reportDir); + Path reportFile = reportDir.resolve("service-recovery-accuracy-report.json"); + MAPPER.writeValue(reportFile.toFile(), report); + System.out.println("Report written to: " + reportFile.toAbsolutePath().normalize()); + } + + @Test + void generateNotificationPromptFromText() throws Exception { + if (!apiKeyConfigured) { + System.out.println("SKIP: generateNotificationPromptFromText - API key not configured"); + return; + } + List cases = loadTextCases("service-recovery-accuracy-test/test-cases-generate-from-text.json"); + System.out.println("== generateNotificationPromptFromText =="); + boolean first = true; + for (TextCase tc : cases) { + if (!first) System.out.println("---"); + first = false; + Map entry = new LinkedHashMap<>(); + entry.put("name", tc.name); + entry.put("input", Map.of("text", tc.text)); + + try { + MetadataContent result = client.generateNotificationPromptFromText(tc.text, templateUri); + entry.put("output", metadataOutput(result)); + System.out.println(" " + tc.name + ": " + compact(result.promptText())); + } catch (Exception e) { + entry.put("output", Map.of("error", e.getClass().getName() + ": " + e.getMessage())); + System.out.println(" " + tc.name + ": ERROR - " + e.getMessage()); + } + fromTextResults.add(entry); + } + } + + @Test + void generateNotificationPromptFromDataWithSchema() throws Exception { + if (!apiKeyConfigured) { + System.out.println("SKIP: generateNotificationPromptFromDataWithSchema - API key not configured"); + return; + } + List cases = loadDataCases("service-recovery-accuracy-test/test-cases-generate-from-data.json"); + System.out.println("== generateNotificationPromptFromDataWithSchema =="); + boolean first = true; + for (DataCase dc : cases) { + if (!first) System.out.println("---"); + first = false; + Map entry = new LinkedHashMap<>(); + entry.put("name", dc.name); + entry.put("input", Map.of("data", dc.data)); + + try { + MetadataContent result = + client.generateNotificationPromptFromDataWithSchema(dc.data, dataSchema, templateUri); + entry.put("output", metadataOutput(result)); + System.out.println(" " + dc.name + ": " + compact(result.promptText())); + } catch (Exception e) { + entry.put("output", Map.of("error", e.getClass().getName() + ": " + e.getMessage())); + System.out.println(" " + dc.name + ": ERROR - " + e.getMessage()); + } + fromDataResults.add(entry); + } + } + + private static Map validate(String promptText) { + try { + FilledParamData result = + server.validateNotificationPromptAndDataFilling(promptText, validationSchema, templateUri); + Map output = new LinkedHashMap<>(); + output.put("semantic_verdict", true); + output.put("params", result.data()); + return output; + } catch (ContentValidationException e) { + Map output = new LinkedHashMap<>(); + output.put("semantic_verdict", false); + output.put("code", e.getCode()); + output.put("message", e.getMessage()); + if (!e.errors().isEmpty()) { + output.put( + "errors", + e.errors().stream() + .map(err -> { + Map item = new LinkedHashMap(); + item.put("slot_name", err.slotName()); + item.put("code", err.code()); + item.put("message", err.message()); + item.put("facts", err.facts()); + return item; + }) + .toList()); + } + return output; + } catch (Exception e) { + return Map.of("error", e.getClass().getName() + ": " + e.getMessage()); + } + } + + @Test + void validateNotificationPromptAndDataFilling() throws Exception { + if (!apiKeyConfigured) { + System.out.println("SKIP: validateNotificationPromptAndDataFilling - API key not configured"); + return; + } + List cases = + loadValidateCases("service-recovery-accuracy-test/test-cases-validate-and-fill.json"); + System.out.println("== validateNotificationPromptAndDataFilling =="); + boolean first = true; + for (ValidateCase vc : cases) { + if (!first) System.out.println("---"); + first = false; + Map entry = new LinkedHashMap<>(); + entry.put("name", vc.name); + entry.put("input", Map.of("promptText", vc.promptText)); + Map output = validate(vc.promptText); + entry.put("output", output); + System.out.println(" " + vc.name + ": " + MAPPER.writeValueAsString(output)); + validateResults.add(entry); + } + } + + private static Map metadataOutput(MetadataContent result) { + Map output = new LinkedHashMap<>(); + output.put("templateUri", result.templateUri()); + output.put("extensionUri", result.extensionUri()); + output.put("promptText", result.promptText()); + return output; + } + + private static String compact(String text) { + if (text == null || text.isEmpty()) { + return text; + } + return text.replaceAll("(?m)^[ \t]*\\r?\\n", ""); + } + + private static Path resolveEnvFile(String resourcePath) throws IOException, URISyntaxException { + URL url = NotificationTAccuracyTest.class.getClassLoader().getResource(resourcePath); + Objects.requireNonNull(url, "Resource not found: " + resourcePath); + Path envPath = Path.of(url.toURI()); + Path projectRoot = findProjectRoot(envPath.getParent()); + + String content = Files.readString(envPath, StandardCharsets.UTF_8); + String resolvedContent = resolveRootDir(content, projectRoot); + + Path tempFile = Files.createTempFile("notification-t-test-", ".env"); + Files.writeString(tempFile, resolvedContent, StandardCharsets.UTF_8); + tempFile.toFile().deleteOnExit(); + return tempFile; + } + + private static Path findProjectRoot(Path start) { + Path dir = start; + while (dir != null) { + if (Files.isDirectory(dir.resolve("a2a-t-resources"))) { + return dir; + } + dir = dir.getParent(); + } + throw new IllegalStateException("Project root (containing a2a-t-resources) not found above " + start); + } + + private static String resolveRootDir(String content, Path projectRoot) { + String prefix = "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR="; + Path promptRoot = projectRoot + .resolve("a2a-t-resources/src/main/resources/prompt_resources") + .normalize(); + String absolutePath = promptRoot.toString(); + for (String line : content.split("\n")) { + String trimmed = line.strip(); + if (trimmed.startsWith(prefix)) { + return content.replace(trimmed, prefix + absolutePath); + } + } + return content; + } + + private static Map readEnvInfo(Path envPath) throws IOException { + Map info = new LinkedHashMap<>(); + for (String line : Files.readAllLines(envPath, StandardCharsets.UTF_8)) { + String trimmed = line.strip(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int eqIdx = trimmed.indexOf('='); + if (eqIdx > 0) { + String key = trimmed.substring(0, eqIdx).strip(); + String value = trimmed.substring(eqIdx + 1).strip(); + if (key.equals("A2AT_LLM_API_KEY")) { + value = value.isBlank() ? "" : "***"; + } + info.put(key, value); + } + } + return info; + } + + private static Map loadJsonResource(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private static List loadTextCases(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private static List loadDataCases(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private static List loadValidateCases(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private record TextCase(String name, String text) {} + + private record DataCase(String name, Map data) {} + + private record ValidateCase(String name, String promptText) {} +} diff --git a/a2a-t-sample/task-t-test-cases.md b/a2a-t-sample/task-t-test-cases.md index e0d45e7b..94cc1e5e 100644 --- a/a2a-t-sample/task-t-test-cases.md +++ b/a2a-t-sample/task-t-test-cases.md @@ -62,7 +62,7 @@ ## 用例三:缺少关键槽位拒绝用例(期望 rejected) 正向样本评测的是提取准确率;本组用例验证语义校验的**兜底能力**——输入内容故意缺失**必填槽位**(接入端口、 -投诉分类)或携带**枚举外取值**,服务端应抛出 `validation_semantic_rejected`。与用例一/二对应,本组同时覆盖 +投诉分类)或携带**枚举外取值**,服务端应抛出 `negotiation.semantic_rejected`。与用例一/二对应,本组同时覆盖 **文本变体**(`generateTaskPromptFromText`)与**数据变体**(`generateTaskPromptFromDataWithSchema`)。 被拦截样本数占样本总数的比例即**拦截率**(服务端语义拒绝 + 客户端生成阶段拦截均计为拦截);意外通过时打印 实际提取参数以便排查。本组用例独立于正向样本计分,不进入字段准确率统计。 @@ -72,7 +72,7 @@ > 时间、流水号均为**可选槽位**,不在拒绝集内;若需观察可选槽位缺失的行为,应作为正向观察用例而非拒绝断言, > 避免依赖未契约化的语义规则造成拦截率波动。 > -> 拦截结果按**双口径**报告:**服务端校验点拒绝率**(仅统计 `validation_semantic_rejected`,衡量服务端校验点本身的 +> 拦截结果按**双口径**报告:**服务端校验点拒绝率**(仅统计 `negotiation.semantic_rejected`,衡量服务端校验点本身的 > 拦截能力)与**总拦截率**(服务端拒绝 + 客户端生成阶段拦截,衡量整条链路的兜底能力)——客户端拦截发生在模板 > 槽位填充阶段,与服务端语义校验是两套判断,分开统计才能定位到具体层。 diff --git a/tools/corpus_review.py b/tools/corpus_review.py index 3b32f561..47942788 100644 --- a/tools/corpus_review.py +++ b/tools/corpus_review.py @@ -82,23 +82,19 @@ } ERROR_CODE_ZH = { - "negotiation_invalid_input": "协商输入无效(如消息不是协商消息、缺少协商上下文)", - "negotiation_slot_missing": "协商消息缺少必填槽位", - "negotiation_content_extract_failed": "大模型返回内容无法解析为协商结构", - "negotiation_llm_infrastructure_error": "大模型基础设施错误(调用失败)", - "negotiation_semantic_rejected": "语义校验判定不通过", - "negotiation_rule_violation": "协商上下文违反规则(如轮次超限、标识格式非法)", - "template_not_found": "协商模板不存在或不可用", - # internal validation_* codes and slot-level codes surfaced through slotErrors - "validation_invalid_input": "(内部码)校验输入无效", - "validation_rule_violation": "(内部码)校验规则违反", - "validation_semantic_rejected": "(内部码)语义判定不通过", - "validation_llm_infrastructure_error": "(内部码)大模型基础设施错误", - "validation_prompt_resource_not_found": "(内部码)模板资源不存在", - "invalid_uuid": "会话标识不是合法 UUID", - "missing": "槽位缺失", - "out_of_range": "数值超出允许范围", - "template_type_mismatch": "模板类型不匹配", + "negotiation.invalid_input": "协商输入无效(如消息不是协商消息、缺少协商上下文)", + "negotiation.field_missing": "协商消息缺少必填字段", + "negotiation.content_extract_failed": "无法从文本提取协商内容", + "negotiation.content_invalid": "from-data 协商内容字段无效(如空列表、空白描述)", + "negotiation.conclusion_mismatch": "报文结论与该方法预期不符", + "negotiation.semantic_rejected": "语义校验判定不通过", + "negotiation.rule_violation": "协商报文违反校验规则(未知码兜底,含轮次超限、标识格式非法)", + "negotiation.invalid_context_id": "协商上下文标识不是合法 UUID", + "negotiation.round_exceeded": "协商轮次超过上限", + "negotiation.type_mismatch": "报文内容与声明的模板类型不符", + "template.not_found": "协商模板不存在或不可用", + "llm.invocation_failed": "大模型调用失败(传输/网络层)", + "llm.response_invalid": "大模型返回内容不符合要求(响应契约违反)", } EXCEPTION_ZH = { @@ -519,10 +515,9 @@ def render_html(records: list[dict], statuses: dict) -> str: w(f"
      • ") w("
        步骤A(工作台,发起/补数方)B(OMC,执行/要数方)
        {esc(domain)}{gen}{val}{e2e}{len(subset)}
        ") - w("

        协商错误码覆盖(7 个,CorpusContractTest 全量严格模式)

        ") + w(f"

        协商错误码覆盖({len(ERROR_CODE_ZH)} 个,CorpusContractTest 全量严格模式)

        " + "
        错误码业务含义失败用例数
        ") for code, zh in ERROR_CODE_ZH.items(): - if code.startswith("validation_"): - continue count = sum(1 for r in records if (r.get("expect") or {}).get("code") == code) marker = " style='color:#b3261e;font-weight:700'" if count == 0 else "" w(f"{count}") From bba912da7b4ddac925fe0ac7ace1835899fb0298 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 29 Aug 2026 13:19:36 +0800 Subject: [PATCH 241/295] docs(en,zh): document the new error catalog and facts schema Update API references and developer guides in both languages with the new code table, the errors[] facts schema, rendered-message behavior (language follows A2AT_LANGUAGE) and the old-to-new code mapping. Signed-off-by: codetalks-hw --- docs/en/API_Reference.md | 269 +++++++++++++----- docs/en/developer_guide.md | 2 +- "docs/zh/API\345\217\202\350\200\203.md" | 269 +++++++++++++----- ...00\345\217\221\346\214\207\345\215\227.md" | 2 +- 4 files changed, 398 insertions(+), 144 deletions(-) diff --git a/docs/en/API_Reference.md b/docs/en/API_Reference.md index 8bc0a5f7..b8a47f47 100644 --- a/docs/en/API_Reference.md +++ b/docs/en/API_Reference.md @@ -33,14 +33,115 @@ The public APIs of the A2A-T SDK converge on two facades: the client facade `A2A - **TemplateUri:** the template URI value type. Prefer building it with the `net.openan.a2at.sdk.core.model.StandardTemplates` constants, e.g. `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE` (URI `Negotiation-T/information-negotiation/propose/v1`); for strings coming from outside the code, parse them with `TemplateUri.parse(String)`, which returns an `Optional` and never throws. - **Negotiation session context:** `NegotiationContext(id, round, maxRounds)` (id in UUID form, round starting at 1, default round budget `DEFAULT_MAX_ROUNDS = 5`); it travels with the message metadata and never goes through the LLM. `NegotiationContext.of(id, round)` uses the default budget, and `nextRound()` advances the round. -- **Exception hierarchy:** every SDK processing failure is a subclass of `A2ATError` — generation failures throw `PromptGenerationException` (Task-T / Notification-T / Authorization-T) or `NegotiationGenerationException` (Negotiation-T); validation-plus-extraction failures throw `ContentValidationException` (Task-T / Notification-T / Authorization-T, carrying the `errors()` slot error details and the `params()` partial extraction parameters) or `NegotiationParamExtractionException` (Negotiation-T). Catching `A2ATError` covers all processing failures, and `getCode()` returns the machine-readable error code. +- **Exception hierarchy:** every SDK processing failure is a subclass of `A2ATError` — generation failures throw `PromptGenerationException` (Task-T / Notification-T / Authorization-T) or `NegotiationGenerationException` (Negotiation-T); validation-plus-extraction failures throw `ContentValidationException` (Task-T / Notification-T / Authorization-T, carrying the `errors()` slot error details and the `params()` partial extraction parameters) or `NegotiationParamExtractionException` (Negotiation-T). Catching `A2ATError` covers all processing failures, and `getCode()` returns the machine-readable error code from the error-code catalog below. Expected business failures extend `A2ATBusinessException` and additionally expose `getFacts()` carrying the structured fact values behind the rendered message; all messages are rendered by the SDK from the code's template and follow `A2AT_LANGUAGE`. - **SlotValidationError:** per-slot validation error details, returned with validation-failure exceptions or failure payloads; the failure structures in the "Output" sections of each API all reference this definition: | Field | Type | Description | | ---- | ---- | ---- | | slotName | String | Name of the slot the error refers to | -| code | String | Slot-level error code, e.g. `missing_required` (required value missing), `invalid_value` (invalid value), `format_error` (format error) | -| message | String | Slot-level error description | +| code | String | Slot-level error code from the error-code catalog below, e.g. `content.param_missing` (a parameter section carries no value), `content.entry_field_missing` (a list entry is missing a required field), `content.format_error` (value format error) | +| message | String | Human-readable error description rendered by the SDK from the code's message template; the language follows `A2AT_LANGUAGE` (default `en-US`) | +| facts | Map<String, String> | Structured fact values that produced the message, keyed by fact parameter name (e.g. `section_label`, `index`, `field_label`); may be null | + +**Error-code catalog** + +The SDK exposes a closed catalog of layered `domain.semantic` error codes; one code always carries one message. Message texts are rendered by the SDK from the per-language templates bundled in `prompt_resources/errors/{language}/errors.json` - they are never free text produced by the LLM. The message language follows the `A2AT_LANGUAGE` setting (default `en-US`); `{x}` tokens in a template are filled from the `facts` of the error. Internally, LLM steps only report `{slot_name, code, facts}` entries; a code returned by an LLM outside the catalog is mapped to the per-domain `*.rule_violation` fallback code and the original code is logged. + +Category legend: BUSINESS = expected business failure carried by an `A2ATBusinessException` subclass the caller can act on; INFRA = infrastructure failure carried by a plain `A2ATError`. + +| Code | Category | Message (en-US) | Message (zh-CN) | +| ---- | -------- | ---------------- | --------------- | +| `template.not_found` | BUSINESS | Template '{template_uri}' does not support language '{language}'; check the template URI and language setting | 模板「{template_uri}」不支持语言「{language}」,请检查模板标识与语言配置 | +| `template.render_failed` | BUSINESS | Failed to render template '{template_uri}': {reason} | 模板「{template_uri}」渲染失败:{reason} | +| `template.load_failed` | INFRA | Failed to read template resource '{resource_path}' | 模板资源「{resource_path}」读取失败 | +| `slot.schema_not_found` | BUSINESS | Template '{template_uri}' is missing its slot schema (language '{language}') | 模板「{template_uri}」缺少参数定义文件(语言「{language}」) | +| `slot.not_provided` | BUSINESS | '{slot_label}' is not provided in the input. | 输入中未提供「{slot_label}」。 | +| `slot.constraint_violated` | BUSINESS | The value of '{slot_label}' ({actual}) is not within the allowed range | 「{slot_label}」的取值「{actual}」不在允许范围内 | +| `slot.semantic_conflict` | BUSINESS | The value of '{slot_label}' conflicts with the slot definition: {reason} | 「{slot_label}」的取值与参数定义冲突:{reason} | +| `slot.fabricated_value` | BUSINESS | The value of '{slot_label}' ({actual}) is placeholder content, not a valid value | 「{slot_label}」的取值「{actual}」是占位内容,不是有效值 | +| `slot.cross_scenario_pollution` | BUSINESS | The value of '{slot_label}' contains content from a different scenario | 「{slot_label}」的取值混入了其他场景的内容 | +| `slot.insufficient_grounding` | BUSINESS | The value of '{slot_label}' lacks sufficient grounding | 「{slot_label}」的取值缺少充分依据 | +| `slot.rule_violation` | BUSINESS | The value of '{slot_label}' violates the validation rules. | 「{slot_label}」的取值不符合校验规则。 | +| `input.text_too_long` | BUSINESS | Input text length {actual_length} exceeds the maximum of {max_chars} (A2AT_INPUT_TEXT_MAX_CHARS) | 输入文本长度 {actual_length} 超过上限 {max_chars}(A2AT_INPUT_TEXT_MAX_CHARS) | +| `content.param_missing` | BUSINESS | '{section_label}' is empty; please provide a value | 「{section_label}」未填写,请补充该参数的取值 | +| `content.entry_field_missing` | BUSINESS | Entry {index} of '{section_label}' is missing required field '{field_label}' | 「{section_label}」第 {index} 条缺少必填字段「{field_label}」 | +| `content.format_error` | BUSINESS | The format of '{section_label}' is invalid: {reason} | 「{section_label}」的取值格式不符合要求:{reason} | +| `content.value_not_allowed` | BUSINESS | The value of '{section_label}' ({actual}) is not allowed | 「{section_label}」的取值「{actual}」不在允许范围内 | +| `content.semantic_conflict` | BUSINESS | '{section_label}' has a semantic conflict: {reason} | 「{section_label}」存在语义冲突:{reason} | +| `content.rule_violation` | BUSINESS | The value of '{section_label}' violates the validation rules. | 「{section_label}」的取值不符合校验规则。 | +| `scenario.not_matched` | BUSINESS | The input does not match any known scenario: {reason} | 输入内容无法匹配任何已知场景:{reason} | +| `llm.not_configured` | BUSINESS | No LLM client is configured; check the A2AT_LLM_* settings | 未配置 LLM 客户端,无法执行该操作(请检查 A2AT_LLM_* 配置) | +| `llm.invocation_failed` | BUSINESS | LLM invocation failed (provider {provider}): {reason} | LLM 调用失败(提供方 {provider}):{reason} | +| `llm.response_invalid` | BUSINESS | The LLM response is invalid (step: {step}); please retry | LLM 返回内容不符合要求({step} 步骤),请重试 | +| `negotiation.invalid_input` | BUSINESS | The negotiation input is invalid: {reason} | 输入的协商内容无效:{reason} | +| `negotiation.invalid_context_id` | BUSINESS | The negotiation context id '{actual}' is not a valid UUID | 协商上下文标识「{actual}」不是合法的 UUID | +| `negotiation.round_exceeded` | BUSINESS | Negotiation round {round} exceeds the maximum of {max_rounds} | 协商轮次 {round} 已超过上限 {max_rounds} | +| `negotiation.type_mismatch` | BUSINESS | The message implies '{implied}' negotiation but the declared template type is '{declared}' | 报文内容属于「{implied}」协商,与声明的模板类型「{declared}」不符 | +| `negotiation.phase_mismatch` | BUSINESS | The message phase does not match the declared template phase ({implied} vs {declared}) | 报文阶段与声明的模板阶段不符({implied} vs {declared}) | +| `negotiation.conclusion_mismatch` | BUSINESS | The message conclusion is '{actual}' but '{expected}' is expected for this method | 报文结论为「{actual}」,与该方法的预期「{expected}」不符 | +| `negotiation.content_invalid` | BUSINESS | The negotiation content field '{field}' is invalid: {reason} | 协商内容字段「{field}」无效:{reason} | +| `negotiation.field_missing` | BUSINESS | The negotiation message is missing required field '{field}' | 协商报文缺少必填字段「{field}」 | +| `negotiation.content_extract_failed` | BUSINESS | Failed to extract negotiation content from text ({field}): {reason} | 无法从文本提取协商内容({field}):{reason} | +| `negotiation.conclusion_content_mismatch` | BUSINESS | The conclusion is '{conclusion}' but '{section_label}' does not state the content the conclusion requires | 结论为「{conclusion}」,但「{section_label}」未表达该结论应携带的内容 | +| `negotiation.missing_result_content` | BUSINESS | The '{section_label}' section is missing the content required by its conclusion | 「{section_label}」板块缺少结论应携带的内容 | +| `negotiation.mutually_exclusive_sections` | BUSINESS | Mutually exclusive sections appear together: {sections} | 互斥板块同时出现:{sections} | +| `negotiation.constraint_conflict` | BUSINESS | '{section_label}' conflicts with existing constraints: {reason} | 「{section_label}」与既有约束冲突:{reason} | +| `negotiation.field_inconsistency` | BUSINESS | Fields within '{section_label}' are inconsistent: {reason} | 「{section_label}」内字段取值前后不一致:{reason} | +| `negotiation.invalid_time_interval` | BUSINESS | The time interval of '{section_label}' is invalid (start must not be later than end) | 「{section_label}」的时间区间不合法(开始时间不得晚于结束时间) | +| `negotiation.semantic_rejected` | BUSINESS | The negotiation message failed semantic validation | 协商报文语义校验未通过 | +| `negotiation.rule_violation` | BUSINESS | '{section_label}' violates the negotiation message validation rules. | 「{section_label}」不符合协商报文的校验规则。 | +| `infra.config_invalid` | INFRA | Invalid configuration '{key}': {reason} | 配置项「{key}」无效:{reason} | +| `infra.resource_read_failed` | INFRA | Failed to read resource '{resource_path}' | 资源「{resource_path}」读取失败 | +| `infra.internal_error` | INFRA | SDK internal error; contact the maintainer with context | SDK 内部错误,请联系维护方并提供上下文 | + +**Old-to-new code mapping** + +The layered codes above replace the previous flat error codes. Use this mapping when migrating callers that branch on `getCode()` values: + +| Old code | New code | +| -------- | -------- | +| `template_not_found` | `template.not_found` | +| `render_failed` | `template.render_failed` | +| `prompt_resource_load_error` | `template.load_failed` | +| `slot_schema_not_found` | `slot.schema_not_found` | +| `missing_input` / `param_extraction_failed` (slot extraction) | `slot.not_provided` | +| `invalid_value` (slot extraction) | `slot.constraint_violated` | +| `semantic_mismatch` (semantic validation) | `slot.semantic_conflict` | +| `fabricated_value` | `slot.fabricated_value` | +| `cross_scenario_pollution` | `slot.cross_scenario_pollution` | +| `insufficient_grounding` | `slot.insufficient_grounding` | +| `input_text_too_long` | `input.text_too_long` | +| `missing_required` (parameter level, content validation) | `content.param_missing` | +| `missing_required` (list-entry level, content validation) | `content.entry_field_missing` | +| `format_error` | `content.format_error` | +| `invalid_value` (content validation) | `content.value_not_allowed` | +| `semantic_mismatch` (content validation) | `content.semantic_conflict` | +| `scenario_not_matched` / `processed_prompt_parse_error` | `scenario.not_matched` | +| `llm_invocation_failed` (transport failures) | `llm.invocation_failed` | +| `llm_invocation_failed` (response-contract failures) | `llm.response_invalid` | +| "LLM client is not configured" family | `llm.not_configured` | +| `negotiation_invalid_input` / `validation_invalid_input` | `negotiation.invalid_input` | +| `invalid_uuid` | `negotiation.invalid_context_id` | +| `out_of_range` (round) | `negotiation.round_exceeded` | +| `template_type_mismatch` | `negotiation.type_mismatch` | +| `template_phase_mismatch` | `negotiation.phase_mismatch` | +| `invalid_conclusion` / registry conclusion gate | `negotiation.conclusion_mismatch` | +| fromData required-items/required-text `IllegalArgumentException` | `negotiation.content_invalid` | +| `negotiation_slot_missing` | `negotiation.field_missing` | +| `negotiation_content_extract_failed` | `negotiation.content_extract_failed` | +| `conclusion_content_mismatch` | `negotiation.conclusion_content_mismatch` | +| `missing_result_content` | `negotiation.missing_result_content` | +| `mutually_exclusive_sections` | `negotiation.mutually_exclusive_sections` | +| `constraint_conflict` | `negotiation.constraint_conflict` | +| `field_inconsistency` | `negotiation.field_inconsistency` | +| `invalid_time_interval` | `negotiation.invalid_time_interval` | +| `negotiation_semantic_rejected` / `validation_semantic_rejected` | `negotiation.semantic_rejected` | +| `negotiation_rule_violation` / `validation_rule_violation` | `negotiation.rule_violation` | +| `negotiation_llm_infrastructure_error` / `validation_llm_infrastructure_error` | `llm.invocation_failed` / `llm.response_invalid` | +| `validation_prompt_resource_not_found` | `template.not_found` | +| `sdk_internal_error` | `infra.internal_error` | +| `LLMConfigError` | `infra.config_invalid` | +| bare `A2ATError` resource-read failures | `infra.resource_read_failed` | ## 1.2 Constraints and Limitations @@ -118,15 +219,17 @@ On failure, throws `NegotiationGenerationException` (an `A2ATError` subclass, a Error codes: -- `template_not_found` (template or prompt resource missing) +- `template.not_found` (template or prompt resource missing) -- `negotiation_content_extract_failed` (failed to extract structured content from the text, retryable) +- `negotiation.content_extract_failed` (failed to extract structured content from the text, retryable) -- `negotiation_llm_infrastructure_error` (LLM infrastructure failure, retryable) +- `llm.invocation_failed` (LLM transport failure, retryable) -- `negotiation_invalid_input` (text is blank, the extracted content contradicts the phase, or the confirm request contradicts the other sections) +- `llm.response_invalid` (LLM response violates the step contract, retryable) -- `negotiation_slot_missing` (a required slot is missing) +- `negotiation.invalid_input` (text is blank, the extracted content contradicts the phase, or the confirm request contradicts the other sections) + +- `negotiation.field_missing` (a required field is missing) A null argument throws `NullPointerException`; a templateUri whose phase segment is not `propose` throws `IllegalArgumentException`. @@ -157,7 +260,7 @@ public MetadataContent generateNegotiationAcceptPromptFromText( **Typical scenarios**: after receiving the peer's information-negotiation request, the negotiation responder (usually the client agent) supplements/delivers the requested information in natural language and generates an accept message to return, e.g. confirming that diagnosis can start after supplementing the access port name and complaint category. -**Function Description**: generates a negotiation accept message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `ACCEPT`, otherwise it is rejected with `negotiation_invalid_input`) plus deterministic rendering. Applicable to the negotiation responder supplementing/delivering information. The `templateUri` phase segment must be `accept-reject`. +**Function Description**: generates a negotiation accept message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `ACCEPT`, otherwise it is rejected with `negotiation.invalid_input`) plus deterministic rendering. Applicable to the negotiation responder supplementing/delivering information. The `templateUri` phase segment must be `accept-reject`. **Input** @@ -184,15 +287,17 @@ On success, returns `MetadataContent` (structure same as [1.3.1](#131-generatene On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). Error codes: -- `template_not_found` (template or prompt resource missing) +- `template.not_found` (template or prompt resource missing) + +- `negotiation.content_extract_failed` (failed to extract structured content from the text, retryable) -- `negotiation_content_extract_failed` (failed to extract structured content from the text, retryable) +- `llm.invocation_failed` (LLM transport failure, retryable) -- `negotiation_llm_infrastructure_error` (LLM infrastructure failure, retryable) +- `llm.response_invalid` (LLM response violates the step contract, retryable) -- `negotiation_invalid_input` (text is blank, or the extracted conclusion is not `ACCEPT`) +- `negotiation.invalid_input` (text is blank, or the extracted conclusion is not `ACCEPT`) -- `negotiation_slot_missing` (a required slot is missing) +- `negotiation.field_missing` (a required field is missing) **Response Example** @@ -219,7 +324,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( **Typical scenarios**: when the negotiation responder (usually the client agent) cannot satisfy the peer's negotiation request, it generates a reject message in natural language to return and end the current negotiation round, e.g. the access port name cannot be provided because the site inventory is unavailable. -**Function Description**: generates a negotiation reject message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `REJECT`) plus deterministic rendering. The `templateUri` phase segment must be `accept-reject`. +**Function Description**: generates a negotiation reject message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `REJECT`, otherwise it is rejected with `negotiation.invalid_input`) plus deterministic rendering. The `templateUri` phase segment must be `accept-reject`. **Input**: same as [generateNegotiationAcceptPromptFromText](#132-generatenegotiationacceptpromptfromtext), where text is natural language describing the rejection reason. @@ -238,15 +343,17 @@ On success, returns `MetadataContent` (structure same as [1.3.1](#131-generatene On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). Error codes: -- `template_not_found` (template or prompt resource missing) +- `template.not_found` (template or prompt resource missing) -- `negotiation_content_extract_failed` (failed to extract structured content from the text, retryable) +- `negotiation.content_extract_failed` (failed to extract structured content from the text, retryable) -- `negotiation_llm_infrastructure_error` (LLM infrastructure failure, retryable) +- `llm.invocation_failed` (LLM transport failure, retryable) -- `negotiation_invalid_input` (text is blank, or the extracted conclusion is not `REJECT`) +- `llm.response_invalid` (LLM response violates the step contract, retryable) -- `negotiation_slot_missing` (a required slot is missing) +- `negotiation.invalid_input` (text is blank, or the extracted conclusion is not `REJECT`) + +- `negotiation.field_missing` (a required field is missing) **Response Example** @@ -320,9 +427,11 @@ On success, returns `MetadataContent` (structure same as [1.3.1](#131-generatene On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). Error codes: -- `template_not_found` (template missing) +- `template.not_found` (template missing) + +- `negotiation.content_invalid` (a typed content field is invalid, e.g. blank required items or a blank required description) -- `negotiation_slot_missing` (a required slot is missing during rendering) +- `negotiation.field_missing` (a required field is missing during rendering) There are also two categories of programming errors (outside the `A2ATError` tree, standard JDK exceptions): a null argument or a null context within it throws `NullPointerException`; a content type that does not match the template's negotiation type, or a phase segment that is not `propose`, throws `IllegalArgumentException`. @@ -388,11 +497,13 @@ On success, returns `MetadataContent` (structure same as [1.3.1](#131-generatene On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). Error codes: -- `template_not_found` (template missing) +- `template.not_found` (template missing) -- `negotiation_slot_missing` (a required slot is missing during rendering) +- `negotiation.content_invalid` (a typed content field is invalid, e.g. blank required items or a blank required description) -Programming errors: a null argument or a null context within it throws `NullPointerException`; a `conclusion` that is not `ACCEPT`, a phase segment that is not `accept-reject`, or a mismatched content type throws `IllegalArgumentException`. +- `negotiation.field_missing` (a required field is missing during rendering) + +Programming errors: a null argument or a null context within it throws `NullPointerException`; a mismatched content type or a phase segment that is not `accept-reject` throws `IllegalArgumentException`; a `conclusion` that is not `ACCEPT` is rejected with the `negotiation.conclusion_mismatch` business error. **Response Example** @@ -441,11 +552,13 @@ On success, returns `MetadataContent` (structure same as [1.3.1](#131-generatene On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). Error codes: -- `template_not_found` (template missing) +- `template.not_found` (template missing) + +- `negotiation.content_invalid` (a typed content field is invalid, e.g. blank required items or a blank required description) -- `negotiation_slot_missing` (a required slot is missing during rendering) +- `negotiation.field_missing` (a required field is missing during rendering) -Programming errors: a null argument or a null context within it throws `NullPointerException`; a `conclusion` that is not `REJECT`, a phase segment that is not `accept-reject`, or a mismatched content type throws `IllegalArgumentException`. +Programming errors: a null argument or a null context within it throws `NullPointerException`; a mismatched content type or a phase segment that is not `accept-reject` throws `IllegalArgumentException`; a `conclusion` that is not `REJECT` is rejected with the `negotiation.conclusion_mismatch` business error. **Response Example** @@ -471,14 +584,14 @@ public FilledParamData validateProposePromptAndDataFilling( **Typical scenarios**: an outbound self-check by the initiator (server agent) before sending a negotiation request; or the receiver (client agent) validating an inbound negotiation request and extracting the list of slots to supplement, driving the subsequent parameter filling. -**Function Description**: validates whether a negotiation propose message is a properly formatted negotiation message, and extracts parameters from it per the caller-provided JSON Schema. Pipeline: template-URI phase-segment check → deterministic rule gate (the context id must be in UUID form and the round must not exceed the budget; a null context is reported as `negotiation_invalid_input`) → one LLM semantic-validation call (which also performs the parameter extraction, retried up to the configured limit on retryable error codes) → parameter merge (the negotiation-context parameters `id` / `round` / `maxRounds` are merged with the extracted parameters; on key conflicts, **the context parameters win**). +**Function Description**: validates whether a negotiation propose message is a properly formatted negotiation message, and extracts parameters from it per the caller-provided JSON Schema. Pipeline: template-URI phase-segment check → deterministic rule gate (a null context is reported as `negotiation.invalid_input`, a non-UUID context id as `negotiation.invalid_context_id`, and a round beyond the budget as `negotiation.round_exceeded`) → one LLM semantic-validation call (which also performs the parameter extraction, retried up to the configured limit on retryable error codes) → parameter merge (the negotiation-context parameters `id` / `round` / `maxRounds` are merged with the extracted parameters; on key conflicts, **the context parameters win**). **Input** | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | | prompt | String | Yes | Negotiation propose message text to validate (`MetadataContent.promptText()`) | -| context | NegotiationContext | No | Negotiation context traveling with the message; null is reported as `negotiation_invalid_input` | +| context | NegotiationContext | No | Negotiation context traveling with the message; null is reported as `negotiation.invalid_input` | | schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema declaring the parameters to extract | | templateUri | TemplateUri | Yes | Propose template | @@ -528,15 +641,17 @@ On failure, throws `NegotiationParamExtractionException` (an `A2ATError` subclas Error codes: -- `negotiation_invalid_input` (the message is not a negotiation message, or the context is null) +- `negotiation.invalid_input` (the message is not a negotiation message, or the context is null) + +- `negotiation.invalid_context_id` (the context id is not a valid UUID) -- `negotiation_rule_violation` (the negotiation context violates a structural rule, e.g. a non-UUID id or a round beyond the budget) +- `negotiation.round_exceeded` (the context round exceeds the configured maximum) -- `negotiation_semantic_rejected` (semantic validation rejected the message) +- `negotiation.semantic_rejected` (semantic validation rejected the message; the per-slot details in `getErrors()` use the closed `negotiation.*` code set, e.g. `negotiation.conclusion_content_mismatch`, `negotiation.missing_result_content`, `negotiation.field_inconsistency`) -- `negotiation_llm_infrastructure_error` (LLM infrastructure failure, retryable) +- `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) -- `template_not_found` (validation prompt resources missing) +- `template.not_found` (validation prompt resources missing) Programming errors: a null prompt or schema throws `NullPointerException`; a blank prompt or a mismatched phase segment throws `IllegalArgumentException`. @@ -587,15 +702,15 @@ On success, returns `FilledParamData` (structure same as [1.3.7](#137-validatepr On failure, throws `NegotiationParamExtractionException` (structure same as 1.3.7). Error codes: -- `negotiation_invalid_input` (the message is not an accept negotiation message, or the context is null) +- `negotiation.invalid_input` (the message is not an accept negotiation message, or the context is null) -- `negotiation_rule_violation` (the negotiation context violates a structural rule) +- `negotiation.invalid_context_id` / `negotiation.round_exceeded` (the negotiation context violates a structural rule) -- `negotiation_semantic_rejected` (the conclusion is not Accept, or the content does not satisfy the accept-phase constraints) +- `negotiation.semantic_rejected` (the conclusion is not Accept, or the content does not satisfy the accept-phase constraints) -- `negotiation_llm_infrastructure_error` (LLM infrastructure failure, retryable) +- `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) -- `template_not_found` (validation prompt resources missing) +- `template.not_found` (validation prompt resources missing) Programming errors: a null prompt or schema throws `NullPointerException`; a blank prompt or a phase segment that is not `accept-reject` throws `IllegalArgumentException`. @@ -640,15 +755,15 @@ On success, returns `FilledParamData` (structure same as [1.3.7](#137-validatepr On failure, throws `NegotiationParamExtractionException` (structure same as 1.3.7). Error codes: -- `negotiation_invalid_input` (the message is not a reject negotiation message, or the context is null) +- `negotiation.invalid_input` (the message is not a reject negotiation message, or the context is null) -- `negotiation_rule_violation` (the negotiation context violates a structural rule) +- `negotiation.invalid_context_id` / `negotiation.round_exceeded` (the negotiation context violates a structural rule) -- `negotiation_semantic_rejected` (the conclusion is not Reject, or the content does not satisfy the reject-phase constraints) +- `negotiation.semantic_rejected` (the conclusion is not Reject, or the content does not satisfy the reject-phase constraints) -- `negotiation_llm_infrastructure_error` (LLM infrastructure failure, retryable) +- `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) -- `template_not_found` (validation prompt resources missing) +- `template.not_found` (validation prompt resources missing) Programming errors: a null prompt or schema throws `NullPointerException`; a blank prompt or a phase segment that is not `accept-reject` throws `IllegalArgumentException`. @@ -674,7 +789,7 @@ public MetadataContent generateTaskPromptFromText(String text, TemplateUri templ **Typical scenarios**: the client agent converts the user's natural-language task description (e.g. a private-line complaint diagnosis request) into a Task-T protocol message for a specified scenario; suitable when the target template is already known and scenario recognition should be skipped. -**Function Description**: generates a task prompt message from natural-language text with the specified Task-T template, **skipping scenario recognition** (the template is explicitly specified by the caller). One LLM slot-extraction step runs, then the template is rendered deterministically. The built-in slot schema is validated during generation (a missing or invalid required slot fails fast with `slot_validation_error`). +**Function Description**: generates a task prompt message from natural-language text with the specified Task-T template, **skipping scenario recognition** (the template is explicitly specified by the caller). One LLM slot-extraction step runs, then the template is rendered deterministically. The built-in slot schema is validated during generation (a missing or invalid required slot fails fast with a `slot.*` error code such as `slot.not_provided`). **Input** @@ -719,21 +834,27 @@ On failure, throws `PromptGenerationException` (an `A2ATError` subclass, a runti | ---- | ---- | ---- | | getCode() | String | Machine-readable error code, see below | | getMessage() | String | Human-readable failure description | -| failedParameters() | List<SlotValidationError> | Details of failed slot validations (non-empty on `slot_validation_error`); structure defined in the common conventions | +| failedParameters() | List<SlotValidationError> | Details of failed slot validations (non-empty on slot-domain failure codes such as `slot.not_provided`); structure defined in the common conventions | Error codes: -- `template_not_found` (template missing) +- `template.not_found` (template missing) -- `prompt_resource_load_error` (prompt resource load failure) +- `template.load_failed` (prompt resource load failure) -- `slot_schema_not_found` (slot schema missing) +- `slot.schema_not_found` (slot schema missing) -- `llm_invocation_failed` (LLM call failure) +- `llm.invocation_failed` / `llm.response_invalid` (LLM call failures, retryable) -- `slot_validation_error` (required slot missing or invalid value) +- `slot.not_provided` (a required slot is not provided in the input) -- `render_failed` (template rendering failure) +- `slot.constraint_violated` (a slot value is outside the allowed range) + +- `slot.rule_violation` (fallback for other slot-validation rule violations) + +- `template.render_failed` (template rendering failure) + +- `input.text_too_long` (input longer than `A2AT_INPUT_TEXT_MAX_CHARS`) Programming errors: a null text or templateUri throws `NullPointerException`. @@ -780,7 +901,7 @@ public MetadataContent generateTaskPromptFromDataWithSchema( **Typical scenarios**: the client agent converts structured task parameters from an upstream system (field names may differ from the template slots; the schema describes the field semantics) into a Task-T protocol message; suitable when the task parameters are already available in structured form. -**Function Description**: generates a task prompt from structured data plus a semantic schema with the specified Task-T template, **skipping scenario recognition**. The `schema` describes the business meaning of each input field (description / examples / enum, etc.), guiding slot filling and value constraints; each key of data corresponds to one slot value. The built-in slot schema is validated during generation as well (a missing or invalid required slot fails fast with `slot_validation_error`). +**Function Description**: generates a task prompt from structured data plus a semantic schema with the specified Task-T template, **skipping scenario recognition**. The `schema` describes the business meaning of each input field (description / examples / enum, etc.), guiding slot filling and value constraints; each key of data corresponds to one slot value. The built-in slot schema is validated during generation as well (a missing or invalid required slot fails fast with a `slot.*` error code such as `slot.not_provided`). **Input** @@ -924,16 +1045,18 @@ On failure, throws `ContentValidationException` (an `A2ATError` subclass): | ---- | ---- | ---- | | getCode() | String | Machine-readable error code, see below | | getMessage() | String | Human-readable failure description | -| errors() | List<SlotValidationError> | Per-slot error details (slot-level error codes such as `missing_required`, `invalid_value`, `format_error`); structure defined in the common conventions | +| errors() | List<SlotValidationError> | Per-slot error details (slot-level error codes such as `content.param_missing`, `content.entry_field_missing`, `content.format_error`); structure defined in the common conventions | | params() | Map<String, Object> | Partial extraction parameters captured before the rejection (slots that could not be extracted have null values) | Error codes: -- `validation_semantic_rejected` (semantic validation rejected, including missing or invalid required parameters) +- `negotiation.semantic_rejected` (semantic validation rejected, including missing or invalid required parameters; the per-slot details in `errors()` use the `content.*` codes, e.g. `content.param_missing`, `content.entry_field_missing`, `content.format_error`, `content.value_not_allowed`) -- `validation_llm_infrastructure_error` (LLM infrastructure failure, retryable) +- `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) -- `validation_prompt_resource_not_found` (validation prompt resources missing) +- `template.not_found` (validation prompt resources missing) + +- `input.text_too_long` (prompt longer than `A2AT_INPUT_TEXT_MAX_CHARS`) Programming errors: a null prompt / schema / templateUri throws `NullPointerException`; a blank prompt throws `IllegalArgumentException`. @@ -953,8 +1076,8 @@ extracted = On validation rejection (a negative sample with a missing key slot, from case 3 of `TaskTDemoMain`): ```text -ContentValidationException: [validation_semantic_rejected] ... - slot=task_object code=missing_required message=... +ContentValidationException: [negotiation.semantic_rejected] ... + slot=task_object code=content.param_missing message=... facts={section_label=任务对象} ``` ### 1.3.13 generateNotificationPromptFromText @@ -1177,11 +1300,13 @@ On success, returns `FilledParamData` (structure same as [1.3.12](#1312-validate On failure, throws `ContentValidationException` (structure same as 1.3.12). Error codes: -- `validation_semantic_rejected` (required parameter missing or invalid value) +- `negotiation.semantic_rejected` (required parameter missing or invalid value; the per-slot details in `errors()` use the `content.*` codes) + +- `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) -- `validation_llm_infrastructure_error` (LLM infrastructure failure, retryable) +- `template.not_found` (validation prompt resources missing) -- `validation_prompt_resource_not_found` (validation prompt resources missing) +- `input.text_too_long` (prompt longer than `A2AT_INPUT_TEXT_MAX_CHARS`) Programming errors: a null prompt / schema / templateUri throws `NullPointerException`; a blank prompt throws `IllegalArgumentException`. @@ -1234,7 +1359,7 @@ MetadataContent result = client.generateAuthPromptFromText( On success, returns `MetadataContent` (structure same as [1.3.10](#1310-generatetaskpromptfromtext); `extensionUri` is `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`). -On failure, throws `PromptGenerationException` (structure same as 1.3.10); for example, an operation type outside the "add/modify/delete/query authorization policy" range is rejected with `slot_validation_error`. Programming errors: a null text or templateUri throws `NullPointerException`. +On failure, throws `PromptGenerationException` (structure same as 1.3.10); for example, an operation type outside the "add/modify/delete/query authorization policy" range is rejected with `slot.constraint_violated`. Programming errors: a null text or templateUri throws `NullPointerException`. **Response Example** @@ -1385,11 +1510,13 @@ On success, returns `FilledParamData` (structure same as [1.3.12](#1312-validate On failure, throws `ContentValidationException` (structure same as 1.3.12). Error codes: -- `validation_semantic_rejected` (required parameter missing or invalid value, e.g. an add entry missing required fields or a validity period format error) +- `negotiation.semantic_rejected` (required parameter missing or invalid value, e.g. an add entry missing required fields (`content.entry_field_missing`) or a validity period format error (`content.format_error`)) + +- `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) -- `validation_llm_infrastructure_error` (LLM infrastructure failure, retryable) +- `template.not_found` (validation prompt resources missing) -- `validation_prompt_resource_not_found` (validation prompt resources missing) +- `input.text_too_long` (prompt longer than `A2AT_INPUT_TEXT_MAX_CHARS`) Programming errors: a null prompt / schema / templateUri throws `NullPointerException`; a blank prompt throws `IllegalArgumentException`. @@ -1463,7 +1590,7 @@ On failure (`success()` is `false`; no exception is thrown, the failure payload | Field | Type | Description | | ---- | ---- | ---- | -| code | String | Machine-readable error code: `scenario_not_matched` (scenario recognition missed), `prompt_resource_load_error` (prompt resource load failure), `template_not_found` (template missing), `render_failed` (rendering failure) | +| code | String | Machine-readable error code from the error-code catalog, e.g. `scenario.not_matched` (scenario recognition missed), `input.text_too_long` (input length guard), `template.load_failed` (prompt resource load failure), `template.not_found` (template missing), `slot.schema_not_found` (slot schema missing), `slot.not_provided` (required slot missing), `template.render_failed` (rendering failure), `llm.invocation_failed` / `llm.response_invalid` (LLM failures) | | message | String | Human-readable failure description | | stage | String | Stage where the failure occurred: `scenario` (scenario recognition), `generation` (template loading/rendering) | @@ -1496,7 +1623,7 @@ On failure (the input does not match any built-in scenario): ```text result.success() = false result.failure() = -{ code=scenario_not_matched, message=Scenario recognition failed., stage=scenario } +{ code=scenario.not_matched, message=The input does not match any known scenario: , stage=scenario } ``` ### 1.3.20 checkTaskPrompt @@ -1554,7 +1681,7 @@ On failure (`success()` is `false`; no exception is thrown, the failure payload | Field | Type | Description | | ---- | ---- | ---- | -| code | String | Machine-readable error code: `processed_prompt_parse_error` (message parsing/scenario recognition failure), `template_not_found` (template missing), `slot_validation_error` (slot validation failure, including missing required values, out-of-range values, and format mismatches) | +| code | String | Machine-readable error code from the error-code catalog: `scenario.not_matched` (message parsing/scenario recognition failure), `template.not_found` (template missing), slot-domain codes such as `slot.not_provided` (missing required value), `slot.constraint_violated` (out-of-range value) and `slot.rule_violation` (other slot rule violations), `input.text_too_long` (input length guard) | | message | String | Human-readable failure description | | stage | String | Stage where the failure occurred: `prompt_parse` (message parsing), `generation` (template loading), `slot_validation` (slot validation) | @@ -1571,7 +1698,7 @@ On failure (the message is missing a required slot; the error code and stage com ```text result.success() = false result.failure() = -{ code=slot_validation_error, message=Required slot 'task_object' is missing., stage=slot_validation } +{ code=slot.not_provided, message='Task Object' is not provided in the input., stage=slot_validation } ``` diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 01c505d4..298212e9 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -336,7 +336,7 @@ A2AT_INPUT_TEXT_MAX_CHARS=16384 > > The prompt resource source is controlled by `A2AT_PROMPT_SOURCE_TYPE`: the default `classpath` loads the resources bundled in the `a2a-t-resources` jar; when set to `local_file`, specify the local resource root directory with `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` (relative paths are resolved against the directory of the `.env` file). > -> Free-text inputs are length-guarded before any LLM call: every facade entry point that accepts a natural-language `String` (the `FromText` generation methods, `generateTaskPrompt` with a text input, and the prompt-validation entry points such as `checkTaskPrompt` and `validate*PromptAndDataFilling`) rejects an input longer than `A2AT_INPUT_TEXT_MAX_CHARS` characters (`String.length()`) with the error code `input_text_too_long`, so oversized inputs fail fast instead of overflowing the LLM context. The key defaults to `16384` (16×1024); invalid or non-positive values fall back to the default with a warning log. The guard never truncates: the caller keeps full control over how to shorten the input. Structured (`Map`) inputs are not checked. +> Free-text inputs are length-guarded before any LLM call: every facade entry point that accepts a natural-language `String` (the `FromText` generation methods, `generateTaskPrompt` with a text input, and the prompt-validation entry points such as `checkTaskPrompt` and `validate*PromptAndDataFilling`) rejects an input longer than `A2AT_INPUT_TEXT_MAX_CHARS` characters (`String.length()`) with the error code `input.text_too_long`, so oversized inputs fail fast instead of overflowing the LLM context. The key defaults to `16384` (16×1024); invalid or non-positive values fall back to the default with a warning log. The guard never truncates: the caller keeps full control over how to shorten the input. Structured (`Map`) inputs are not checked. #### Step3 Initialize the AgentCard diff --git "a/docs/zh/API\345\217\202\350\200\203.md" "b/docs/zh/API\345\217\202\350\200\203.md" index 92e3f85f..0fc60e0c 100644 --- "a/docs/zh/API\345\217\202\350\200\203.md" +++ "b/docs/zh/API\345\217\202\350\200\203.md" @@ -33,14 +33,115 @@ A2A-T SDK 的对外 API 收敛在两个门面上:客户端门面 `A2ATClient` - **TemplateUri:**模板 URI 值类型,推荐用 `net.openan.a2at.sdk.core.model.StandardTemplates` 常量构造,如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`(URI 为 `Negotiation-T/information-negotiation/propose/v1`);来自外部的字符串用 `TemplateUri.parse(String)` 解析,返回 `Optional` 且不抛异常。 - **协商会话上下文:**`NegotiationContext(id, round, maxRounds)`(id 为 UUID 形态、round 从 1 起、默认轮次预算 `DEFAULT_MAX_ROUNDS = 5`),随消息 metadata 传输、不经 LLM;`NegotiationContext.of(id, round)` 使用默认预算,`nextRound()` 推进轮次。 -- **异常体系:**所有 SDK 处理失败均为 `A2ATError` 子类——生成失败抛 `PromptGenerationException`(Task-T / Notification-T / Authorization-T)或 `NegotiationGenerationException`(Negotiation-T),校验+提参失败抛 `ContentValidationException`(Task-T / Notification-T / Authorization-T,携带 `errors()` 槽位错误明细与 `params()` 部分提取参数)或 `NegotiationParamExtractionException`(Negotiation-T)。捕获 `A2ATError` 即可覆盖全部处理失败,`getCode()` 获取机器可读错误码。 +- **异常体系:**所有 SDK 处理失败均为 `A2ATError` 子类——生成失败抛 `PromptGenerationException`(Task-T / Notification-T / Authorization-T)或 `NegotiationGenerationException`(Negotiation-T),校验+提参失败抛 `ContentValidationException`(Task-T / Notification-T / Authorization-T,携带 `errors()` 槽位错误明细与 `params()` 部分提取参数)或 `NegotiationParamExtractionException`(Negotiation-T)。捕获 `A2ATError` 即可覆盖全部处理失败,`getCode()` 获取下方错误码目录中的机器可读错误码。预期业务失败均继承 `A2ATBusinessException`,额外提供 `getFacts()` 返回渲染消息所依据的结构化事实值;所有消息均由 SDK 按错误码模板渲染,语言跟随 `A2AT_LANGUAGE`。 - **SlotValidationError:**逐槽位校验错误明细,随校验失败异常或失败负载返回,各节“输出说明”中的失败结构均引用此定义: | 字段 | 类型 | 说明 | | ---- | ---- | ---- | | slotName | String | 出错槽位名 | -| code | String | 槽位级错误码,如 `missing_required`(必填缺失)、`invalid_value`(取值非法)、`format_error`(格式错误) | -| message | String | 槽位级错误说明 | +| code | String | 槽位级错误码,取值来自下方错误码目录,如 `content.param_missing`(参数章节未填写)、`content.entry_field_missing`(条目缺少必填字段)、`content.format_error`(取值格式错误) | +| message | String | 人类可读的错误说明,由 SDK 按错误码的消息模板渲染;语言跟随 `A2AT_LANGUAGE`(默认 `en-US`) | +| facts | Map<String, String> | 渲染消息所依据的结构化事实值,按事实参数名(如 `section_label`、`index`、`field_label`)为键;可为 null | + +**错误码目录** + +SDK 对外暴露封闭的分层式 `domain.semantic` 错误码目录,同一错误码始终对应同一消息。消息文本由 SDK 从 `prompt_resources/errors/{language}/errors.json` 中的分语言模板渲染生成,不采用 LLM 生成的自由文本。消息语言跟随 `A2AT_LANGUAGE` 配置(默认 `en-US`);模板中的 `{x}` 占位符由错误携带的 `facts` 填充。SDK 内部 LLM 步骤只输出 `{slot_name, code, facts}` 条目;LLM 返回目录外的错误码时映射到所在域的 `*.rule_violation` 兜底码,并记录原始码日志。 + +类别图例:BUSINESS = 调用方可行动的预期业务失败,由 `A2ATBusinessException` 子类携带;INFRA = 基础设施失败,由普通 `A2ATError` 携带。 + +| 错误码 | 类别 | 消息(zh-CN) | 消息(en-US) | +| ---- | -------- | ---------------- | --------------- | +| `template.not_found` | BUSINESS | 模板「{template_uri}」不支持语言「{language}」,请检查模板标识与语言配置 | Template '{template_uri}' does not support language '{language}'; check the template URI and language setting | +| `template.render_failed` | BUSINESS | 模板「{template_uri}」渲染失败:{reason} | Failed to render template '{template_uri}': {reason} | +| `template.load_failed` | INFRA | 模板资源「{resource_path}」读取失败 | Failed to read template resource '{resource_path}' | +| `slot.schema_not_found` | BUSINESS | 模板「{template_uri}」缺少参数定义文件(语言「{language}」) | Template '{template_uri}' is missing its slot schema (language '{language}') | +| `slot.not_provided` | BUSINESS | 输入中未提供「{slot_label}」。 | '{slot_label}' is not provided in the input. | +| `slot.constraint_violated` | BUSINESS | 「{slot_label}」的取值「{actual}」不在允许范围内 | The value of '{slot_label}' ({actual}) is not within the allowed range | +| `slot.semantic_conflict` | BUSINESS | 「{slot_label}」的取值与参数定义冲突:{reason} | The value of '{slot_label}' conflicts with the slot definition: {reason} | +| `slot.fabricated_value` | BUSINESS | 「{slot_label}」的取值「{actual}」是占位内容,不是有效值 | The value of '{slot_label}' ({actual}) is placeholder content, not a valid value | +| `slot.cross_scenario_pollution` | BUSINESS | 「{slot_label}」的取值混入了其他场景的内容 | The value of '{slot_label}' contains content from a different scenario | +| `slot.insufficient_grounding` | BUSINESS | 「{slot_label}」的取值缺少充分依据 | The value of '{slot_label}' lacks sufficient grounding | +| `slot.rule_violation` | BUSINESS | 「{slot_label}」的取值不符合校验规则。 | The value of '{slot_label}' violates the validation rules. | +| `input.text_too_long` | BUSINESS | 输入文本长度 {actual_length} 超过上限 {max_chars}(A2AT_INPUT_TEXT_MAX_CHARS) | Input text length {actual_length} exceeds the maximum of {max_chars} (A2AT_INPUT_TEXT_MAX_CHARS) | +| `content.param_missing` | BUSINESS | 「{section_label}」未填写,请补充该参数的取值 | '{section_label}' is empty; please provide a value | +| `content.entry_field_missing` | BUSINESS | 「{section_label}」第 {index} 条缺少必填字段「{field_label}」 | Entry {index} of '{section_label}' is missing required field '{field_label}' | +| `content.format_error` | BUSINESS | 「{section_label}」的取值格式不符合要求:{reason} | The format of '{section_label}' is invalid: {reason} | +| `content.value_not_allowed` | BUSINESS | 「{section_label}」的取值「{actual}」不在允许范围内 | The value of '{section_label}' ({actual}) is not allowed | +| `content.semantic_conflict` | BUSINESS | 「{section_label}」存在语义冲突:{reason} | '{section_label}' has a semantic conflict: {reason} | +| `content.rule_violation` | BUSINESS | 「{section_label}」的取值不符合校验规则。 | The value of '{section_label}' violates the validation rules. | +| `scenario.not_matched` | BUSINESS | 输入内容无法匹配任何已知场景:{reason} | The input does not match any known scenario: {reason} | +| `llm.not_configured` | BUSINESS | 未配置 LLM 客户端,无法执行该操作(请检查 A2AT_LLM_* 配置) | No LLM client is configured; check the A2AT_LLM_* settings | +| `llm.invocation_failed` | BUSINESS | LLM 调用失败(提供方 {provider}):{reason} | LLM invocation failed (provider {provider}): {reason} | +| `llm.response_invalid` | BUSINESS | LLM 返回内容不符合要求({step} 步骤),请重试 | The LLM response is invalid (step: {step}); please retry | +| `negotiation.invalid_input` | BUSINESS | 输入的协商内容无效:{reason} | The negotiation input is invalid: {reason} | +| `negotiation.invalid_context_id` | BUSINESS | 协商上下文标识「{actual}」不是合法的 UUID | The negotiation context id '{actual}' is not a valid UUID | +| `negotiation.round_exceeded` | BUSINESS | 协商轮次 {round} 已超过上限 {max_rounds} | Negotiation round {round} exceeds the maximum of {max_rounds} | +| `negotiation.type_mismatch` | BUSINESS | 报文内容属于「{implied}」协商,与声明的模板类型「{declared}」不符 | The message implies '{implied}' negotiation but the declared template type is '{declared}' | +| `negotiation.phase_mismatch` | BUSINESS | 报文阶段与声明的模板阶段不符({implied} vs {declared}) | The message phase does not match the declared template phase ({implied} vs {declared}) | +| `negotiation.conclusion_mismatch` | BUSINESS | 报文结论为「{actual}」,与该方法的预期「{expected}」不符 | The message conclusion is '{actual}' but '{expected}' is expected for this method | +| `negotiation.content_invalid` | BUSINESS | 协商内容字段「{field}」无效:{reason} | The negotiation content field '{field}' is invalid: {reason} | +| `negotiation.field_missing` | BUSINESS | 协商报文缺少必填字段「{field}」 | The negotiation message is missing required field '{field}' | +| `negotiation.content_extract_failed` | BUSINESS | 无法从文本提取协商内容({field}):{reason} | Failed to extract negotiation content from text ({field}): {reason} | +| `negotiation.conclusion_content_mismatch` | BUSINESS | 结论为「{conclusion}」,但「{section_label}」未表达该结论应携带的内容 | The conclusion is '{conclusion}' but '{section_label}' does not state the content the conclusion requires | +| `negotiation.missing_result_content` | BUSINESS | 「{section_label}」板块缺少结论应携带的内容 | The '{section_label}' section is missing the content required by its conclusion | +| `negotiation.mutually_exclusive_sections` | BUSINESS | 互斥板块同时出现:{sections} | Mutually exclusive sections appear together: {sections} | +| `negotiation.constraint_conflict` | BUSINESS | 「{section_label}」与既有约束冲突:{reason} | '{section_label}' conflicts with existing constraints: {reason} | +| `negotiation.field_inconsistency` | BUSINESS | 「{section_label}」内字段取值前后不一致:{reason} | Fields within '{section_label}' are inconsistent: {reason} | +| `negotiation.invalid_time_interval` | BUSINESS | 「{section_label}」的时间区间不合法(开始时间不得晚于结束时间) | The time interval of '{section_label}' is invalid (start must not be later than end) | +| `negotiation.semantic_rejected` | BUSINESS | 协商报文语义校验未通过 | The negotiation message failed semantic validation | +| `negotiation.rule_violation` | BUSINESS | 「{section_label}」不符合协商报文的校验规则。 | '{section_label}' violates the negotiation message validation rules. | +| `infra.config_invalid` | INFRA | 配置项「{key}」无效:{reason} | Invalid configuration '{key}': {reason} | +| `infra.resource_read_failed` | INFRA | 资源「{resource_path}」读取失败 | Failed to read resource '{resource_path}' | +| `infra.internal_error` | INFRA | SDK 内部错误,请联系维护方并提供上下文 | SDK internal error; contact the maintainer with context | + +**旧码到新码映射** + +上述分层错误码取代了先前的扁平错误码。迁移按 `getCode()` 值做分支的调用方代码时,请使用下表映射: + +| 旧错误码 | 新错误码 | +| -------- | -------- | +| `template_not_found` | `template.not_found` | +| `render_failed` | `template.render_failed` | +| `prompt_resource_load_error` | `template.load_failed` | +| `slot_schema_not_found` | `slot.schema_not_found` | +| `missing_input` / `param_extraction_failed`(槽位提取) | `slot.not_provided` | +| `invalid_value`(槽位提取) | `slot.constraint_violated` | +| `semantic_mismatch`(语义校验) | `slot.semantic_conflict` | +| `fabricated_value` | `slot.fabricated_value` | +| `cross_scenario_pollution` | `slot.cross_scenario_pollution` | +| `insufficient_grounding` | `slot.insufficient_grounding` | +| `input_text_too_long` | `input.text_too_long` | +| `missing_required`(参数级,内容校验) | `content.param_missing` | +| `missing_required`(条目级,内容校验) | `content.entry_field_missing` | +| `format_error` | `content.format_error` | +| `invalid_value`(内容校验) | `content.value_not_allowed` | +| `semantic_mismatch`(内容校验) | `content.semantic_conflict` | +| `scenario_not_matched` / `processed_prompt_parse_error` | `scenario.not_matched` | +| `llm_invocation_failed`(传输类失败) | `llm.invocation_failed` | +| `llm_invocation_failed`(响应契约类失败) | `llm.response_invalid` | +| "LLM client is not configured" 系列 | `llm.not_configured` | +| `negotiation_invalid_input` / `validation_invalid_input` | `negotiation.invalid_input` | +| `invalid_uuid` | `negotiation.invalid_context_id` | +| `out_of_range`(轮次) | `negotiation.round_exceeded` | +| `template_type_mismatch` | `negotiation.type_mismatch` | +| `template_phase_mismatch` | `negotiation.phase_mismatch` | +| `invalid_conclusion` / Registry 结论门禁 | `negotiation.conclusion_mismatch` | +| fromData 必填 items/text 的 `IllegalArgumentException` | `negotiation.content_invalid` | +| `negotiation_slot_missing` | `negotiation.field_missing` | +| `negotiation_content_extract_failed` | `negotiation.content_extract_failed` | +| `conclusion_content_mismatch` | `negotiation.conclusion_content_mismatch` | +| `missing_result_content` | `negotiation.missing_result_content` | +| `mutually_exclusive_sections` | `negotiation.mutually_exclusive_sections` | +| `constraint_conflict` | `negotiation.constraint_conflict` | +| `field_inconsistency` | `negotiation.field_inconsistency` | +| `invalid_time_interval` | `negotiation.invalid_time_interval` | +| `negotiation_semantic_rejected` / `validation_semantic_rejected` | `negotiation.semantic_rejected` | +| `negotiation_rule_violation` / `validation_rule_violation` | `negotiation.rule_violation` | +| `negotiation_llm_infrastructure_error` / `validation_llm_infrastructure_error` | `llm.invocation_failed` / `llm.response_invalid` | +| `validation_prompt_resource_not_found` | `template.not_found` | +| `sdk_internal_error` | `infra.internal_error` | +| `LLMConfigError` | `infra.config_invalid` | +| 裸 `A2ATError` 资源读取失败 | `infra.resource_read_failed` | ## 1.2 约束和限制 @@ -117,15 +218,17 @@ Map metadata = propose.buildMetadataContent(); 错误码: -- `template_not_found`(模板或提示词资源缺失) +- `template.not_found`(模板或提示词资源缺失) -- `negotiation_content_extract_failed`(无法从文本抽取结构化内容,可重试) +- `negotiation.content_extract_failed`(无法从文本抽取结构化内容,可重试) -- `negotiation_llm_infrastructure_error`(LLM 基础设施故障,可重试) +- `llm.invocation_failed`(LLM 传输失败,可重试) -- `negotiation_invalid_input`(文本为空、抽取内容与阶段不符、确认请求与其它板块组合矛盾) +- `llm.response_invalid`(LLM 返回内容不符合步骤要求,可重试) -- `negotiation_slot_missing`(缺少必需槽位) +- `negotiation.invalid_input`(文本为空、抽取内容与阶段不符、确认请求与其它板块组合矛盾) + +- `negotiation.field_missing`(缺少必填字段) 入参为 null 抛 `NullPointerException`,templateUri 的 phase 段不是 `propose` 抛 `IllegalArgumentException`。 @@ -156,7 +259,7 @@ public MetadataContent generateNegotiationAcceptPromptFromText( **典型场景**:协商响应方(通常是客户端Agent)收到对端的信息协商请求后,用自然语言补充/交付所请求的信息并生成接受报文回传,如补齐接入端口名称与投诉分类后确认启动诊断。 -**功能说明**:从自然语言文本生成协商接受(accept)报文。一次 LLM 内容抽取(抽取出的结论必须为 `ACCEPT`,否则以 `negotiation_invalid_input` 拒绝)+ 确定性渲染。适用于协商响应方补充/交付信息。`templateUri` 的 phase 段必须为 `accept-reject`。 +**功能说明**:从自然语言文本生成协商接受(accept)报文。一次 LLM 内容抽取(抽取出的结论必须为 `ACCEPT`,否则以 `negotiation.invalid_input` 拒绝)+ 确定性渲染。适用于协商响应方补充/交付信息。`templateUri` 的 phase 段必须为 `accept-reject`。 **输入说明** @@ -182,15 +285,17 @@ MetadataContent accept = client.generateNegotiationAcceptPromptFromText( 失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: -- `template_not_found`(模板或提示词资源缺失) +- `template.not_found`(模板或提示词资源缺失) + +- `negotiation.content_extract_failed`(无法从文本抽取结构化内容,可重试) -- `negotiation_content_extract_failed`(无法从文本抽取结构化内容,可重试) +- `llm.invocation_failed`(LLM 传输失败,可重试) -- `negotiation_llm_infrastructure_error`(LLM 基础设施故障,可重试) +- `llm.response_invalid`(LLM 返回内容不符合步骤要求,可重试) -- `negotiation_invalid_input`(文本为空或抽取结论不是 `ACCEPT`) +- `negotiation.invalid_input`(文本为空或抽取结论不是 `ACCEPT`) -- `negotiation_slot_missing`(缺少必需槽位) +- `negotiation.field_missing`(缺少必填字段) **响应样例** @@ -217,7 +322,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( **典型场景**:协商响应方(通常是客户端Agent)无法满足对端的协商请求时,用自然语言生成拒绝报文回传并结束本轮协商,如因站点清单不可用无法提供接入端口名称。 -**功能说明**:从自然语言文本生成协商拒绝(reject)报文。一次 LLM 内容抽取(抽取出的结论必须为 `REJECT`)+ 确定性渲染。`templateUri` 的 phase 段必须为 `accept-reject`。 +**功能说明**:从自然语言文本生成协商拒绝(reject)报文。一次 LLM 内容抽取(抽取出的结论必须为 `REJECT`,否则以 `negotiation.invalid_input` 拒绝)+ 确定性渲染。`templateUri` 的 phase 段必须为 `accept-reject`。 **输入说明**:同 [generateNegotiationAcceptPromptFromText](#132-generatenegotiationacceptpromptfromtext),text 为描述拒绝原因的自然语言文本。 @@ -236,15 +341,17 @@ MetadataContent reject = client.generateNegotiationRejectPromptFromText( 失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: -- `template_not_found`(模板或提示词资源缺失) +- `template.not_found`(模板或提示词资源缺失) -- `negotiation_content_extract_failed`(无法从文本抽取结构化内容,可重试) +- `negotiation.content_extract_failed`(无法从文本抽取结构化内容,可重试) -- `negotiation_llm_infrastructure_error`(LLM 基础设施故障,可重试) +- `llm.invocation_failed`(LLM 传输失败,可重试) -- `negotiation_invalid_input`(文本为空或抽取结论不是 `REJECT`) +- `llm.response_invalid`(LLM 返回内容不符合步骤要求,可重试) -- `negotiation_slot_missing`(缺少必需槽位) +- `negotiation.invalid_input`(文本为空或抽取结论不是 `REJECT`) + +- `negotiation.field_missing`(缺少必填字段) **响应样例** @@ -318,9 +425,11 @@ MetadataContent propose = client.generateNegotiationProposePromptFromData( 失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: -- `template_not_found`(模板缺失) +- `template.not_found`(模板缺失) + +- `negotiation.content_invalid`(类型化内容字段无效,如必填 items 为空、必填描述为空白) -- `negotiation_slot_missing`(渲染缺少必需槽位) +- `negotiation.field_missing`(渲染缺少必填字段) 另有两类编程错误(`A2ATError` 树外,标准 JDK 异常):入参或其 context 为 null 抛 `NullPointerException`;内容类型与模板协商类型不符、phase 段不是 `propose` 抛 `IllegalArgumentException`。 @@ -386,11 +495,13 @@ MetadataContent accept = client.generateNegotiationAcceptPromptFromData( 失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: -- `template_not_found`(模板缺失) +- `template.not_found`(模板缺失) -- `negotiation_slot_missing`(渲染缺少必需槽位) +- `negotiation.content_invalid`(类型化内容字段无效,如必填 items 为空、必填描述为空白) -编程错误:入参或其 context 为 null 抛 `NullPointerException`;`conclusion` 不是 `ACCEPT`、phase 段不是 `accept-reject` 或内容类型不符抛 `IllegalArgumentException`。 +- `negotiation.field_missing`(渲染缺少必填字段) + +编程错误:入参或其 context 为 null 抛 `NullPointerException`;内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;`conclusion` 不是 `ACCEPT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 **响应样例** @@ -439,11 +550,13 @@ MetadataContent reject = client.generateNegotiationRejectPromptFromData( 失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: -- `template_not_found`(模板缺失) +- `template.not_found`(模板缺失) + +- `negotiation.content_invalid`(类型化内容字段无效,如必填 items 为空、必填描述为空白) -- `negotiation_slot_missing`(渲染缺少必需槽位) +- `negotiation.field_missing`(渲染缺少必填字段) -编程错误:入参或其 context 为 null 抛 `NullPointerException`;`conclusion` 不是 `REJECT`、phase 段不是 `accept-reject` 或内容类型不符抛 `IllegalArgumentException`。 +编程错误:入参或其 context 为 null 抛 `NullPointerException`;内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;`conclusion` 不是 `REJECT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 **响应样例** @@ -469,14 +582,14 @@ public FilledParamData validateProposePromptAndDataFilling( **典型场景**:发起方(服务端Agent)发送协商请求前的出站自检;或接收方(客户端Agent)校验收到的协商请求并提取需补充的槽位清单,驱动后续补参。 -**功能说明**:校验一条协商发起(propose)报文是否为格式正确的协商消息,并按调用方提供的 JSON Schema 从中提取参数。流水线:模板 URI phase 段检查 → 确定性规则门禁(context 的 id 为 UUID 形态、轮次不超预算;context 为 null 报 `negotiation_invalid_input`)→ 一次 LLM 语义校验调用(同时完成参数提取,可重试错误码上最多重试至配置上限)→ 参数合并(协商上下文参数 `id` / `round` / `maxRounds` 与提取参数合并,键冲突时**上下文参数优先**)。 +**功能说明**:校验一条协商发起(propose)报文是否为格式正确的协商消息,并按调用方提供的 JSON Schema 从中提取参数。流水线:模板 URI phase 段检查 → 确定性规则门禁(context 为 null 报 `negotiation.invalid_input`,id 非 UUID 形态报 `negotiation.invalid_context_id`,轮次超预算报 `negotiation.round_exceeded`)→ 一次 LLM 语义校验调用(同时完成参数提取,可重试错误码上最多重试至配置上限)→ 参数合并(协商上下文参数 `id` / `round` / `maxRounds` 与提取参数合并,键冲突时**上下文参数优先**)。 **输入说明** | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | | prompt | String | 是 | 待校验的协商发起报文文本(`MetadataContent.promptText()`) | -| context | NegotiationContext | 否 | 随报文传输的协商上下文;null 报 `negotiation_invalid_input` | +| context | NegotiationContext | 否 | 随报文传输的协商上下文;null 报 `negotiation.invalid_input` | | schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema,声明要提取的参数 | | templateUri | TemplateUri | 是 | propose 模板 | @@ -525,15 +638,17 @@ requested.data().keySet().removeAll(List.of("id", "round", "maxRounds")); 错误码: -- `negotiation_invalid_input`(报文不是协商消息或 context 为 null) +- `negotiation.invalid_input`(报文不是协商消息或 context 为 null) + +- `negotiation.invalid_context_id`(context 的 id 不是合法 UUID) -- `negotiation_rule_violation`(协商上下文违反结构规则,如 id 非 UUID 形态、轮次超预算) +- `negotiation.round_exceeded`(context 轮次超过配置上限) -- `negotiation_semantic_rejected`(语义校验拒绝) +- `negotiation.semantic_rejected`(语义校验拒绝;`getErrors()` 中的逐槽位明细使用封闭的 `negotiation.*` 码集,如 `negotiation.conclusion_content_mismatch`、`negotiation.missing_result_content`、`negotiation.field_inconsistency`) -- `negotiation_llm_infrastructure_error`(LLM 基础设施故障,可重试) +- `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) -- `template_not_found`(校验提示词资源缺失) +- `template.not_found`(校验提示词资源缺失) 编程错误:prompt 或 schema 为 null 抛 `NullPointerException`,prompt 为空白或 phase 段不匹配抛 `IllegalArgumentException`。 @@ -583,15 +698,15 @@ FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( 失败时抛 `NegotiationParamExtractionException`(结构同 1.3.7)。错误码: -- `negotiation_invalid_input`(报文不是 accept 协商消息或 context 为 null) +- `negotiation.invalid_input`(报文不是 accept 协商消息或 context 为 null) -- `negotiation_rule_violation`(协商上下文违反结构规则) +- `negotiation.invalid_context_id` / `negotiation.round_exceeded`(协商上下文违反结构规则) -- `negotiation_semantic_rejected`(结论不是 Accept 或内容不满足 accept 阶段约束) +- `negotiation.semantic_rejected`(结论不是 Accept 或内容不满足 accept 阶段约束) -- `negotiation_llm_infrastructure_error`(LLM 基础设施故障,可重试) +- `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) -- `template_not_found`(校验提示词资源缺失) +- `template.not_found`(校验提示词资源缺失) 编程错误:prompt 或 schema 为 null 抛 `NullPointerException`,prompt 为空白或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。 @@ -636,15 +751,15 @@ FilledParamData rejectParams = server.validateRejectPromptAndDataFilling( 失败时抛 `NegotiationParamExtractionException`(结构同 1.3.7)。错误码: -- `negotiation_invalid_input`(报文不是 reject 协商消息或 context 为 null) +- `negotiation.invalid_input`(报文不是 reject 协商消息或 context 为 null) -- `negotiation_rule_violation`(协商上下文违反结构规则) +- `negotiation.invalid_context_id` / `negotiation.round_exceeded`(协商上下文违反结构规则) -- `negotiation_semantic_rejected`(结论不是 Reject 或内容不满足 reject 阶段约束) +- `negotiation.semantic_rejected`(结论不是 Reject 或内容不满足 reject 阶段约束) -- `negotiation_llm_infrastructure_error`(LLM 基础设施故障,可重试) +- `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) -- `template_not_found`(校验提示词资源缺失) +- `template.not_found`(校验提示词资源缺失) 编程错误:prompt 或 schema 为 null 抛 `NullPointerException`,prompt 为空白或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。 @@ -670,7 +785,7 @@ public MetadataContent generateTaskPromptFromText(String text, TemplateUri templ **典型场景**:客户端Agent将用户的自然语言任务描述(如专线投诉诊断诉求)转换为指定场景的Task-T协议报文,适合已确定目标模板、需跳过场景识别的场景。 -**功能说明**:从自然语言文本按指定 Task-T 模板生成任务提示词报文,**跳过场景识别**(模板由调用方显式指定)。执行一次 LLM 槽位提取后确定性渲染模板。生成阶段即校验内置槽位 Schema(必填槽缺失或取值非法时以 `slot_validation_error` 快速失败)。 +**功能说明**:从自然语言文本按指定 Task-T 模板生成任务提示词报文,**跳过场景识别**(模板由调用方显式指定)。执行一次 LLM 槽位提取后确定性渲染模板。生成阶段即校验内置槽位 Schema(必填槽缺失或取值非法时以 `slot.*` 错误码(如 `slot.not_provided`)快速失败)。 **输入说明** @@ -714,21 +829,27 @@ MetadataContent metadata = client.generateTaskPromptFromText( | ---- | ---- | ---- | | getCode() | String | 机器可读错误码,取值见下 | | getMessage() | String | 人类可读的失败描述 | -| failedParameters() | List<SlotValidationError> | 槽位校验失败的明细(`slot_validation_error` 时非空),结构见公共约定 | +| failedParameters() | List<SlotValidationError> | 槽位校验失败的明细(`slot.not_provided` 等槽位域失败码时非空),结构见公共约定 | 错误码: -- `template_not_found`(模板缺失) +- `template.not_found`(模板缺失) -- `prompt_resource_load_error`(提示词资源加载失败) +- `template.load_failed`(提示词资源加载失败) -- `slot_schema_not_found`(槽位 Schema 缺失) +- `slot.schema_not_found`(槽位 Schema 缺失) -- `llm_invocation_failed`(LLM 调用失败) +- `llm.invocation_failed` / `llm.response_invalid`(LLM 调用失败,可重试) -- `slot_validation_error`(必填槽缺失或取值非法) +- `slot.not_provided`(必填槽在输入中未提供) -- `render_failed`(模板渲染失败) +- `slot.constraint_violated`(槽位取值不在允许范围内) + +- `slot.rule_violation`(其它槽位校验规则违规的兜底码) + +- `template.render_failed`(模板渲染失败) + +- `input.text_too_long`(输入超过 `A2AT_INPUT_TEXT_MAX_CHARS`) 编程错误:text 或 templateUri 为 null 抛 `NullPointerException`。 @@ -775,7 +896,7 @@ public MetadataContent generateTaskPromptFromDataWithSchema( **典型场景**:客户端Agent将上游系统的结构化任务参数(字段名可与模板槽位不同,由Schema描述字段语义)转换为Task-T协议报文,适合任务参数已结构化持有的场景。 -**功能说明**:从结构化数据 + 语义 Schema 按指定 Task-T 模板生成任务提示词,**跳过场景识别**。`schema` 描述每个输入字段的业务含义(description / examples / enum 等),指导槽位填充与取值约束;每个 data 的 key 对应一个槽位值。生成阶段同样执行内置槽位 Schema 校验(必填槽缺失或取值非法时以 `slot_validation_error` 快速失败)。 +**功能说明**:从结构化数据 + 语义 Schema 按指定 Task-T 模板生成任务提示词,**跳过场景识别**。`schema` 描述每个输入字段的业务含义(description / examples / enum 等),指导槽位填充与取值约束;每个 data 的 key 对应一个槽位值。生成阶段同样执行内置槽位 Schema 校验(必填槽缺失或取值非法时以 `slot.*` 错误码(如 `slot.not_provided`)快速失败)。 **输入说明** @@ -918,16 +1039,18 @@ Map extracted = server | ---- | ---- | ---- | | getCode() | String | 机器可读错误码,取值见下 | | getMessage() | String | 人类可读的失败描述 | -| errors() | List<SlotValidationError> | 逐槽位错误明细(槽位级错误码如 `missing_required`、`invalid_value`、`format_error`),结构见公共约定 | +| errors() | List<SlotValidationError> | 逐槽位错误明细(槽位级错误码如 `content.param_missing`、`content.entry_field_missing`、`content.format_error`),结构见公共约定 | | params() | Map<String, Object> | 被拒前的部分提取参数(无法提取的槽位值为 null) | 错误码: -- `validation_semantic_rejected`(语义校验拒绝,含 required 参数缺失或取值非法) +- `negotiation.semantic_rejected`(语义校验拒绝,含 required 参数缺失或取值非法;`errors()` 中的逐槽位明细使用 `content.*` 码集,如 `content.param_missing`、`content.entry_field_missing`、`content.format_error`、`content.value_not_allowed`) -- `validation_llm_infrastructure_error`(LLM 基础设施故障,可重试) +- `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) -- `validation_prompt_resource_not_found`(校验提示词资源缺失) +- `template.not_found`(校验提示词资源缺失) + +- `input.text_too_long`(提示词超过 `A2AT_INPUT_TEXT_MAX_CHARS`) 编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白抛 `IllegalArgumentException`。 @@ -947,8 +1070,8 @@ extracted = 校验拒绝时(缺少关键槽位的负例,来自 `TaskTDemoMain` 用例三): ```text -ContentValidationException: [validation_semantic_rejected] ... - slot=任务对象 code=missing_required message=... +ContentValidationException: [negotiation.semantic_rejected] ... + slot=任务对象 code=content.param_missing message=... facts={section_label=任务对象} ``` ### 1.3.13 generateNotificationPromptFromText @@ -1170,11 +1293,13 @@ FilledParamData result = server.validateNotificationPromptAndDataFilling( 失败时抛 `ContentValidationException`(结构同 1.3.12)。错误码: -- `validation_semantic_rejected`(必选参数缺失或取值非法) +- `negotiation.semantic_rejected`(必选参数缺失或取值非法;`errors()` 中的逐槽位明细使用 `content.*` 码集) + +- `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) -- `validation_llm_infrastructure_error`(LLM 基础设施故障,可重试) +- `template.not_found`(校验提示词资源缺失) -- `validation_prompt_resource_not_found`(校验提示词资源缺失) +- `input.text_too_long`(提示词超过 `A2AT_INPUT_TEXT_MAX_CHARS`) 编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白抛 `IllegalArgumentException`。 @@ -1227,7 +1352,7 @@ MetadataContent result = client.generateAuthPromptFromText( 成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`)。 -失败时抛 `PromptGenerationException`(结构同 1.3.10),如操作类型不在"新增/修改/删除/查询授权策略"范围内时以 `slot_validation_error` 拒绝。编程错误:text 或 templateUri 为 null 抛 `NullPointerException`。 +失败时抛 `PromptGenerationException`(结构同 1.3.10),如操作类型不在"新增/修改/删除/查询授权策略"范围内时以 `slot.constraint_violated` 拒绝。编程错误:text 或 templateUri 为 null 抛 `NullPointerException`。 **响应样例** @@ -1378,11 +1503,13 @@ FilledParamData result = server.validateAuthPromptAndDataFilling( 失败时抛 `ContentValidationException`(结构同 1.3.12)。错误码: -- `validation_semantic_rejected`(必选参数缺失或取值非法,如新增条目缺少必填字段、有效期格式错误) +- `negotiation.semantic_rejected`(必选参数缺失或取值非法,如新增条目缺少必填字段(`content.entry_field_missing`)、有效期格式错误(`content.format_error`)) + +- `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) -- `validation_llm_infrastructure_error`(LLM 基础设施故障,可重试) +- `template.not_found`(校验提示词资源缺失) -- `validation_prompt_resource_not_found`(校验提示词资源缺失) +- `input.text_too_long`(提示词超过 `A2AT_INPUT_TEXT_MAX_CHARS`) 编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白抛 `IllegalArgumentException`。 @@ -1455,7 +1582,7 @@ if (result.success()) { | 字段 | 类型 | 说明 | | ---- | ---- | ---- | -| code | String | 机器可读错误码:`scenario_not_matched`(场景识别未命中)、`prompt_resource_load_error`(提示词资源加载失败)、`template_not_found`(模板缺失)、`render_failed`(渲染失败) | +| code | String | 机器可读错误码,取值来自错误码目录,如 `scenario.not_matched`(场景识别未命中)、`input.text_too_long`(输入长度防护)、`template.load_failed`(提示词资源加载失败)、`template.not_found`(模板缺失)、`slot.schema_not_found`(槽位 Schema 缺失)、`slot.not_provided`(必填槽缺失)、`template.render_failed`(渲染失败)、`llm.invocation_failed` / `llm.response_invalid`(LLM 失败) | | message | String | 人类可读的失败描述 | | stage | String | 失败发生的阶段:`scenario`(场景识别)、`generation`(模板加载/渲染) | @@ -1488,7 +1615,7 @@ result.promptText() = ```text result.success() = false result.failure() = -{ code=scenario_not_matched, message=Scenario recognition failed., stage=scenario } +{ code=scenario.not_matched, message=输入内容无法匹配任何已知场景:<原因>, stage=scenario } ``` ### 1.3.20 checkTaskPrompt @@ -1546,7 +1673,7 @@ if (result.success()) { | 字段 | 类型 | 说明 | | ---- | ---- | ---- | -| code | String | 机器可读错误码:`processed_prompt_parse_error`(报文解析/场景识别失败)、`template_not_found`(模板缺失)、`slot_validation_error`(槽位校验失败,含必填缺失、取值越界、格式不匹配) | +| code | String | 机器可读错误码,取值来自错误码目录:`scenario.not_matched`(报文解析/场景识别失败)、`template.not_found`(模板缺失)、槽位域码如 `slot.not_provided`(必填缺失)、`slot.constraint_violated`(取值越界)、`slot.rule_violation`(其它槽位规则违规)、`input.text_too_long`(输入长度防护) | | message | String | 人类可读的失败描述 | | stage | String | 失败发生的阶段:`prompt_parse`(报文解析)、`generation`(模板加载)、`slot_validation`(槽位校验) | @@ -1563,7 +1690,7 @@ prompt check passed ```text result.success() = false result.failure() = -{ code=slot_validation_error, message=Required slot '任务对象' is missing., stage=slot_validation } +{ code=slot.not_provided, message=输入中未提供「任务对象」。, stage=slot_validation } ``` diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 4ef75c37..3a374106 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -336,7 +336,7 @@ A2AT_INPUT_TEXT_MAX_CHARS=16384 > > 提示词资源来源由 `A2AT_PROMPT_SOURCE_TYPE` 控制:默认 `classpath` 从 `a2a-t-resources` jar 内置资源加载;配置为 `local_file` 时,通过 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 指定本地资源根目录(相对路径相对于 `.env` 文件所在目录解析)。 > -> 自由文本输入在任何 LLM 调用之前都会做长度防护:所有接受自然语言 `String` 的 Facade 入口(`FromText` 生成类方法、文本形式的 `generateTaskPrompt`,以及 `checkTaskPrompt`、`validate*PromptAndDataFilling` 等校验入口)在输入超过 `A2AT_INPUT_TEXT_MAX_CHARS` 字符(按 `String.length()` 计)时,会以错误码 `input_text_too_long` 快速失败,避免超长输入撑爆 LLM 上下文。该键默认 `16384`(16×1024);非法或非正值回退默认值并输出警告日志。防护不做截断:是否以及如何缩短输入完全由调用方决定。结构化(`Map`)输入不做该检查。 +> 自由文本输入在任何 LLM 调用之前都会做长度防护:所有接受自然语言 `String` 的 Facade 入口(`FromText` 生成类方法、文本形式的 `generateTaskPrompt`,以及 `checkTaskPrompt`、`validate*PromptAndDataFilling` 等校验入口)在输入超过 `A2AT_INPUT_TEXT_MAX_CHARS` 字符(按 `String.length()` 计)时,会以错误码 `input.text_too_long` 快速失败,避免超长输入撑爆 LLM 上下文。该键默认 `16384`(16×1024);非法或非正值回退默认值并输出警告日志。防护不做截断:是否以及如何缩短输入完全由调用方决定。结构化(`Map`)输入不做该检查。 #### Step3 初始化AgentCard From 23c068d7b06fbcab2d470da10d3f1dc6dd0322a5 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 29 Aug 2026 15:58:51 +0800 Subject: [PATCH 242/295] fix(core): align pipeline test with the loader-gated constructor and catalog codes Signed-off-by: codetalks-hw --- .../validation/ValidationPipelineTest.java | 270 +++++++++--------- 1 file changed, 135 insertions(+), 135 deletions(-) diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ValidationPipelineTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ValidationPipelineTest.java index 167ed972..6eedbd08 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ValidationPipelineTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ValidationPipelineTest.java @@ -1,135 +1,135 @@ -package net.openan.a2at.sdk.core.validation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import org.junit.jupiter.api.Test; - -/** - * Guards the optional template loading gate of {@link ValidationPipeline}: the gate must run after the rule gate and - * before semantic validation, a loader failure must map to {@code validation_prompt_resource_not_found}, and the - * loader-less default {@code validate} variant must reject callers that did not provide the template content. - */ -class ValidationPipelineTest { - - @Test - void should_loadTemplateBetweenRuleGateAndSemanticValidation() { - List events = new ArrayList<>(); - RuleChecker ruleChecker = prompt -> { - events.add("rule"); - return Map.of(); - }; - SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> { - events.add("semantic"); - return new ValidationResult(true, List.of(), Map.of("param", "value")); - }; - TemplateContentLoader loader = reference -> { - events.add("load"); - return "template-content"; - }; - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, loader); - - pipeline.validate("prompt", Map.of(), "ref"); - - assertEquals(List.of("rule", "load", "semantic"), events); - } - - @Test - void should_mapLoaderFailureToPromptResourceNotFound() { - RuleChecker ruleChecker = prompt -> Map.of(); - SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> - new ValidationResult(true, List.of(), Map.of()); - TemplateContentLoader loader = reference -> { - throw new ResourceNotFoundException("missing template", reference); - }; - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, loader); - - ContentValidationException exception = - assertThrows(ContentValidationException.class, () -> pipeline.validate("prompt", Map.of(), "ref")); - assertEquals(A2ATErrorCodes.VALIDATION_PROMPT_RESOURCE_NOT_FOUND, exception.getCode()); - } - - @Test - void should_throwValidationInvalidInput_When_schemaIsNull() { - RuleChecker ruleChecker = prompt -> Map.of(); - SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> - new ValidationResult(true, List.of(), Map.of()); - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); - - ContentValidationException exception = - assertThrows(ContentValidationException.class, () -> pipeline.validate("prompt", null, "ref", "content")); - assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, exception.getCode()); - } - - @Test - void should_throwValidationInvalidInput_When_promptIsNullOrBlank() { - RuleChecker ruleChecker = prompt -> Map.of(); - SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> - new ValidationResult(true, List.of(), Map.of()); - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); - - ContentValidationException nullException = assertThrows( - ContentValidationException.class, () -> pipeline.validate(null, Map.of(), "ref", "content")); - assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, nullException.getCode()); - - ContentValidationException blankException = assertThrows( - ContentValidationException.class, () -> pipeline.validate(" ", Map.of(), "ref", "content")); - assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, blankException.getCode()); - } - - @Test - void should_throwValidationInvalidInput_When_referenceIsNull() { - RuleChecker ruleChecker = prompt -> Map.of(); - SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> - new ValidationResult(true, List.of(), Map.of()); - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, () -> pipeline.validate("prompt", Map.of(), null, "content")); - assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, exception.getCode()); - } - - @Test - void should_throwIllegalStateException_When_noLoaderInjected() { - RuleChecker ruleChecker = prompt -> Map.of(); - SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> - new ValidationResult(true, List.of(), Map.of()); - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); - - assertThrows(IllegalStateException.class, () -> pipeline.validate("prompt", Map.of(), "ref")); - } - - @Test - void should_notInvokeLoader_When_templateContentProvidedExplicitly() { - List events = new ArrayList<>(); - RuleChecker ruleChecker = prompt -> { - events.add("rule"); - return Map.of(); - }; - SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> { - events.add("semantic"); - return new ValidationResult(true, List.of(), Map.of()); - }; - TemplateContentLoader loader = reference -> { - events.add("load"); - return "template-content"; - }; - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, loader); - - pipeline.validate("prompt", Map.of(), "ref", "explicit-content"); - - assertEquals(List.of("rule", "semantic"), events); - } -} \ No newline at end of file +package net.openan.a2at.sdk.core.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import org.junit.jupiter.api.Test; + +/** + * Guards the optional template loading gate of {@link ValidationPipeline}: the gate must run after the rule gate and + * before semantic validation, a loader failure must map to {@code template.not_found}, and the loader-less default + * {@code validate} variant must reject callers that did not provide the template content. + */ +class ValidationPipelineTest { + + @Test + void should_loadTemplateBetweenRuleGateAndSemanticValidation() { + List events = new ArrayList<>(); + RuleChecker ruleChecker = prompt -> { + events.add("rule"); + return Map.of(); + }; + SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> { + events.add("semantic"); + return new ValidationResult(true, List.of(), Map.of("param", "value")); + }; + TemplateContentLoader loader = reference -> { + events.add("load"); + return "template-content"; + }; + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, null, loader); + + pipeline.validate("prompt", Map.of(), "ref"); + + assertEquals(List.of("rule", "load", "semantic"), events); + } + + @Test + void should_mapLoaderFailureToPromptResourceNotFound() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + TemplateContentLoader loader = reference -> { + throw new ResourceNotFoundException("missing template", reference); + }; + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, null, loader); + + ContentValidationException exception = + assertThrows(ContentValidationException.class, () -> pipeline.validate("prompt", Map.of(), "ref")); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), exception.getCode()); + } + + @Test + void should_throwValidationInvalidInput_When_schemaIsNull() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, () -> pipeline.validate("prompt", null, "ref", "content")); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); + } + + @Test + void should_throwValidationInvalidInput_When_promptIsNullOrBlank() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + ContentValidationException nullException = assertThrows( + ContentValidationException.class, () -> pipeline.validate(null, Map.of(), "ref", "content")); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), nullException.getCode()); + + ContentValidationException blankException = assertThrows( + ContentValidationException.class, () -> pipeline.validate(" ", Map.of(), "ref", "content")); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), blankException.getCode()); + } + + @Test + void should_throwValidationInvalidInput_When_referenceIsNull() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, () -> pipeline.validate("prompt", Map.of(), null, "content")); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); + } + + @Test + void should_throwIllegalStateException_When_noLoaderInjected() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + assertThrows(IllegalStateException.class, () -> pipeline.validate("prompt", Map.of(), "ref")); + } + + @Test + void should_notInvokeLoader_When_templateContentProvidedExplicitly() { + List events = new ArrayList<>(); + RuleChecker ruleChecker = prompt -> { + events.add("rule"); + return Map.of(); + }; + SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> { + events.add("semantic"); + return new ValidationResult(true, List.of(), Map.of()); + }; + TemplateContentLoader loader = reference -> { + events.add("load"); + return "template-content"; + }; + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, null, loader); + + pipeline.validate("prompt", Map.of(), "ref", "explicit-content"); + + assertEquals(List.of("rule", "semantic"), events); + } +} From 4e1cda19ef4c1a6ab5aea7e9695fc7bbf6430020 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 29 Aug 2026 15:58:52 +0800 Subject: [PATCH 243/295] style: apply palantir format to branch-touched files Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 1190 ++++---- .../assembly/DefaultA2ATClientBuilder.java | 475 +-- .../api/A2ATClientNegotiationApiTest.java | 384 +-- .../A2ATClientNegotiationEnvConfigTest.java | 480 +-- ...lientPromptGenerationOrchestratorTest.java | 3 +- .../a2at/sdk/core/model/A2ATConfig.java | 146 +- .../sdk/core/model/PromptRuntimeConfig.java | 70 +- .../a2at/sdk/core/model/PromptTemplate.java | 56 +- .../a2at/sdk/core/resources/PathSegments.java | 133 +- .../sdk/core/validation/ContentValidator.java | 64 +- .../validation/TemplateContentLoader.java | 53 +- .../core/validation/ValidationPipeline.java | 509 ++-- .../a2at/sdk/core/model/A2ATConfigTest.java | 562 ++-- .../core/model/PromptRuntimeConfigTest.java | 124 +- .../sdk/core/model/PromptTemplateTest.java | 105 +- .../sdk/core/resources/PathSegmentsTest.java | 262 +- .../a2at/sdk/corpus/TaskApiAssembler.java | 327 +-- .../sdk/corpus/property/PropertyHarness.java | 359 ++- .../sdk/negotiation/content/Vocabulary.java | 462 +-- .../generation/NegotiationContentService.java | 633 ++-- .../NegotiationGenerationOrchestrator.java | 1299 ++++----- ...otiationGenerationOrchestratorBuilder.java | 456 ++- .../DefaultNegotiationTemplateLoader.java | 17 +- .../resources/NegotiationTemplateLoader.java | 2 +- .../NegotiationSemanticValidator.java | 4 +- .../validation/ParamExtractor.java | 219 +- .../negotiation/content/VocabularyTest.java | 328 +-- ...otiationDeliveredResourceContractTest.java | 3 +- .../AbortMessageGenerationTest.java | 8 +- .../CustomRootPromptsIgnoredTest.java | 264 +- ...nerationOrchestratorBuilderWiringTest.java | 494 ++-- ...NegotiationGenerationOrchestratorTest.java | 715 ++--- .../generation/NegotiationGeneratorsTest.java | 1496 +++++----- ...otiationPromptRendererConcurrencyTest.java | 117 +- .../NegotiationResourceIntegrityTest.java | 16 +- .../TemplateUriEntryMatrixTest.java | 337 +-- .../ValidateAndFillingDataPipelineTest.java | 1182 ++++---- .../NegotiationLanguageSwitchTest.java | 8 +- .../UnsupportedLanguageBehaviorTest.java | 7 +- ...NegotiationExceptionTreeRootCatchTest.java | 500 ++-- .../NegotiationLogEventContractTest.java | 794 ++--- .../DefaultNegotiationTemplateLoaderTest.java | 348 +-- ...rdTemplatesNegotiationConsistencyTest.java | 195 +- .../validation/ParamExtractorTest.java | 640 ++-- .../catalog/PromptTemplateCatalog.java | 753 +++-- .../catalog/TemplateQueryService.java | 196 +- .../LocalFilePromptScenarioCatalogLoader.java | 124 +- .../LocalFilePromptSlotSchemaLoader.java | 168 +- .../loader/LocalFilePromptTemplateLoader.java | 154 +- .../loader/LocalFileResourceSnapshot.java | 172 +- .../loader/PromptResourceAccess.java | 398 +-- .../validation/DefaultContentValidator.java | 272 +- .../catalog/TemplateQueryServiceTest.java | 477 +-- .../loader/LocalFilePromptLoadersTest.java | 662 +++-- .../loader/PromptResourceAccessTest.java | 470 +-- .../DefaultContentValidatorTest.java | 327 ++- .../ClasspathPromptResourceLoader.java | 102 +- .../ClasspathPromptResourceLoaderTest.java | 209 +- .../sample/authz_policy/AuthzSampleMain.java | 886 +++--- .../authz_policy/AuthzScenarioExecutor.java | 431 ++- .../authz_policy/AuthzScenarioRunner.java | 571 ++-- .../negotiation/eval/NegotiationEvalApp.java | 2594 +++++++++-------- .../NegotiationAbnormalEvaluationMain.java | 1046 +++---- .../a2at/sample/task_t/TaskTDemoMain.java | 833 +++--- .../TaskTPrivateLineComplaintSamples.java | 465 +-- .../NegotiationFacadeOutputSymmetryTest.java | 577 ++-- .../authz_policy/AuthzScenarioRunnerTest.java | 4 +- .../api/NegotiationV3ApiSurfaceTest.java | 322 +- .../openan/a2at/sdk/server/A2ATServer.java | 1129 ++++--- .../assembly/DefaultA2ATServerBuilder.java | 550 ++-- .../A2ATServerContentValidationApiTest.java | 192 +- .../api/A2ATServerNegotiationApiTest.java | 288 +- .../TaskPromptNegotiationCoexistenceTest.java | 1 - 73 files changed, 15193 insertions(+), 15026 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 51165020..87ef5086 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -1,595 +1,595 @@ -package net.openan.a2at.sdk.client; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; -import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import org.jspecify.annotations.NonNull; - -/** - * High-level client facade for prompt generation and negotiation APIs. The caller provides the {@code .env} file path - * explicitly, typically after copying the repository {@code env.example}. - * - * @since 2026-06 - */ -public final class A2ATClient { - - private final ClientPromptGenerationOrchestrator promptGenerationOrchestrator; - - private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; - - private final NegotiationContentService negotiationContentService; - - private final TemplateQueryService templateQueryService; - - /** - * Creates a client facade from one user-supplied {@code .env} path. - * - * @param envPath user-supplied {@code .env} file path - */ - public A2ATClient(Path envPath) { - Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); - A2ATConfig config = - A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); - DefaultA2ATClientBuilder builder = - DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); - this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); - this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); - this.negotiationContentService = - new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); - this.templateQueryService = builder.buildTemplateQueryService(); - } - - /** - * Generates a processed task prompt from raw user input. Both text and structured input ({@code Map}) are unified - * through LLM extraction; there is no zero-LLM rule shortcut. - * - * @param userInput user-provided task description (text or structured input map); a String input longer than the - * configured maximum length ({@code A2AT_INPUT_TEXT_MAX_CHARS}) fails fast without any LLM call - * @return prompt generation result containing either rendered prompt text or failure details; a String input over - * the limit yields the failure code {@code input.text_too_long} - */ - public PromptGenerationResult generateTaskPrompt(Object userInput) { - return promptGenerationOrchestrator.generateTaskPrompt(userInput); - } - - /** - * Generates a task prompt with metadata from natural-language input using the template identified by the template - * URI, bypassing scenario recognition. - * - * @param text natural-language task input - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, - * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, - * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(text, "text"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateTaskPromptFromText(text, templateUri); - } - - /** - * Generates a task prompt with metadata from structured input and a data schema using the template identified by - * the template URI, bypassing scenario recognition. - * - * @param data structured task input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, - * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, - * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails - */ - public MetadataContent generateTaskPromptFromDataWithSchema( - @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, templateUri); - } - - /** - * Generates an authorization prompt with metadata from natural-language input using the template identified by the - * template URI, bypassing scenario recognition. - * - *

        Authorization-T slot schemas are bundled with a2a-t-resources, so the entry point works out of the box with - * classpath resource source. - * - * @param text natural-language authorization input - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, - * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, - * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(text, "text"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateAuthPromptFromText(text, templateUri); - } - - /** - * Generates an authorization prompt with metadata from structured input and a data schema using the template - * identified by the template URI, bypassing scenario recognition. - * - *

        Authorization-T slot schemas are bundled with a2a-t-resources, so the entry point works out of the box with - * classpath resource source. - * - * @param data structured authorization input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, - * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, - * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails - */ - public MetadataContent generateAuthPromptFromDataWithSchema( - @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, templateUri); - } - - /** - * Generates a notification prompt with metadata from natural-language input using the template identified by the - * template URI, bypassing scenario recognition. - * - * @param text natural-language notification input - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, - * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, - * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(text, "text"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateNotificationPromptFromText(text, templateUri); - } - - /** - * Generates a notification prompt with metadata from structured input and a data schema using the template - * identified by the template URI, bypassing scenario recognition. - * - * @param data structured notification input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, - * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, - * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails - */ - public MetadataContent generateNotificationPromptFromDataWithSchema( - @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, templateUri); - } - - /** - * Starts a new negotiation payload for the requested negotiation type. - * - * @param type negotiation type to initiate - * @param contentText human-readable negotiation message - * @param facts structured facts attached to the payload - * @return transport payload representing the initial negotiation turn - */ - public Map startNegotiation(NegotiationType type, String contentText, Map facts) { - return negotiationOrchestrator.startNegotiation(type, contentText, facts); - } - - /** - * Processes a received negotiation message using its transport context payload. - * - * @param message received negotiation message - * @param context transport context payload associated with the message - * @return normalized payload describing the receive result - */ - public Map receiveNegotiation(String message, Map context) { - return negotiationOrchestrator.receiveNegotiation(message, context); - } - - /** - * Continues an existing negotiation with a locally stored context snapshot. - * - * @param context current negotiation context - * @param status next status to emit - * @param contentText continuation message content - * @return transport payload representing the next negotiation turn - */ - public Map continueNegotiation( - NegotiationContext context, NegotiationStatus status, String contentText) { - return negotiationOrchestrator.continueNegotiation(context, status, contentText); - } - - /** - * Generates a propose-phase negotiation message from typed data. - * - *

        This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the - * generator of the negotiation type addressed by the template URI and rendered from that template. - * - * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase - * segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationProposePromptFromData( - @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateProposeFromData(data, templateUri); - } - - /** - * Generates an accept-phase negotiation message from typed data. - * - *

        This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the - * content conclusion is not {@code Accept} - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationAcceptPromptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAcceptFromData(data, templateUri); - } - - /** - * Generates a reject-phase negotiation message from typed data. - * - *

        This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the - * content conclusion is not {@code Reject} - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationRejectPromptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateRejectFromData(data, templateUri); - } - - /** - * Generates an abort negotiation message from typed data. - * - *

        This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed - * template must be the common abort template and the content carries only the termination reason. - * - * @param data typed abort input carrying the negotiation context and the termination reason - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI does not address the common abort template or the - * termination reason is blank - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationAbortPromptFromData( - @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAbortFromData(data, templateUri); - } - - /** - * Generates a propose-phase negotiation message from free text. - * - *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment - * must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails - * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required - * field, or {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the - * phase, or the code {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateNegotiationProposePromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateProposeFromText(text, context, templateUri); - } - - /** - * Generates an accept-phase negotiation message from free text. - * - *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Accept}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails - * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required - * field, or {@code negotiation.invalid_input} when the text is blank or the extracted conclusion is not - * {@code Accept}, or the code {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateNegotiationAcceptPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAcceptFromText(text, context, templateUri); - } - - /** - * Generates a reject-phase negotiation message from free text. - * - *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Reject}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails - * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required - * field, or {@code negotiation.invalid_input} when the text is blank or the extracted conclusion is not - * {@code Reject}, or the code {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateNegotiationRejectPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateRejectFromText(text, context, templateUri); - } - - /** - * Generates an abort negotiation message from free text. - * - *

        This variant runs one LLM content-extraction step constrained by the common abort template and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. - * - * @param text free-text input stating the termination reason - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails - * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses the - * termination reason, or {@code negotiation.invalid_input} when the text is blank, or the code - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateNegotiationAbortPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAbortFromText(text, context, templateUri); - } - - /** - * Lists every template available for the configured language across all A2A-T extensions. - * - *

        This query never throws: the extension directories are discovered from the bundled resource tree itself, so - * templates of extensions added later are included automatically. Templates that exist nowhere for the language are - * skipped and an empty list is returned when no template can be loaded at all. - * - * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when - * none can be loaded - */ - public List getPrompts() { - return templateQueryService.getPrompts(); - } - - /** - * Loads one template by its URI, regardless of the extension. - * - *

        This query never throws: a missing template yields an empty result together with a warning log instead of a - * failure. - * - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} or - * {@code Task-T/network-layer/ran-energy-saving/v1} - * @return the addressed template, or an empty optional when no template exists for it in the configured language - */ - public Optional getPrompt(@NonNull TemplateUri templateUri) { - return templateQueryService.getPrompt(templateUri); - } - - /** - * Validates a propose-phase negotiation message and extracts its parameters. - * - *

        The pipeline checks the template URI before any LLM call, runs the deterministic rule gate on the negotiation - * context carried in the {@code negotiationContext} metadata entry, then performs one LLM semantic validation call - * that also extracts the parameters, and finally merges the parameters with the context parameters taking - * precedence. The semantic step is retried up to the configured attempt limit on the retryable LLM infrastructure - * failure code. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code propose} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or - * {@code template.not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateProposePromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates an accept-phase negotiation message and extracts its parameters. - * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to - * accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * accept-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or - * {@code template.not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateAcceptPromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates a reject-phase negotiation message and extracts its parameters. - * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to - * reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * reject-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or - * {@code template.not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateRejectPromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates an abort negotiation message and extracts its parameters. - * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to - * abort: the template URI must address the common abort template and the message must satisfy the abort-phase - * semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI does not address the common abort - * template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or - * {@code template.not_found} when the semantic validation prompt resources are missing - */ - public FilledParamData validateAbortPromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); - } -} +package net.openan.a2at.sdk.client; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import org.jspecify.annotations.NonNull; + +/** + * High-level client facade for prompt generation and negotiation APIs. The caller provides the {@code .env} file path + * explicitly, typically after copying the repository {@code env.example}. + * + * @since 2026-06 + */ +public final class A2ATClient { + + private final ClientPromptGenerationOrchestrator promptGenerationOrchestrator; + + private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; + + private final NegotiationContentService negotiationContentService; + + private final TemplateQueryService templateQueryService; + + /** + * Creates a client facade from one user-supplied {@code .env} path. + * + * @param envPath user-supplied {@code .env} file path + */ + public A2ATClient(Path envPath) { + Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); + A2ATConfig config = + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + DefaultA2ATClientBuilder builder = + DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); + this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); + this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); + this.negotiationContentService = + new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.templateQueryService = builder.buildTemplateQueryService(); + } + + /** + * Generates a processed task prompt from raw user input. Both text and structured input ({@code Map}) are unified + * through LLM extraction; there is no zero-LLM rule shortcut. + * + * @param userInput user-provided task description (text or structured input map); a String input longer than the + * configured maximum length ({@code A2AT_INPUT_TEXT_MAX_CHARS}) fails fast without any LLM call + * @return prompt generation result containing either rendered prompt text or failure details; a String input over + * the limit yields the failure code {@code input.text_too_long} + */ + public PromptGenerationResult generateTaskPrompt(Object userInput) { + return promptGenerationOrchestrator.generateTaskPrompt(userInput); + } + + /** + * Generates a task prompt with metadata from natural-language input using the template identified by the template + * URI, bypassing scenario recognition. + * + * @param text natural-language task input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(text, "text"); + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateTaskPromptFromText(text, templateUri); + } + + /** + * Generates a task prompt with metadata from structured input and a data schema using the template identified by + * the template URI, bypassing scenario recognition. + * + * @param data structured task input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails + */ + public MetadataContent generateTaskPromptFromDataWithSchema( + @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, templateUri); + } + + /** + * Generates an authorization prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + *

        Authorization-T slot schemas are bundled with a2a-t-resources, so the entry point works out of the box with + * classpath resource source. + * + * @param text natural-language authorization input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(text, "text"); + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateAuthPromptFromText(text, templateUri); + } + + /** + * Generates an authorization prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + *

        Authorization-T slot schemas are bundled with a2a-t-resources, so the entry point works out of the box with + * classpath resource source. + * + * @param data structured authorization input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails + */ + public MetadataContent generateAuthPromptFromDataWithSchema( + @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, templateUri); + } + + /** + * Generates a notification prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + * @param text natural-language notification input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(text, "text"); + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateNotificationPromptFromText(text, templateUri); + } + + /** + * Generates a notification prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + * @param data structured notification input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails + */ + public MetadataContent generateNotificationPromptFromDataWithSchema( + @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + Objects.requireNonNull(templateUri, "templateUri"); + return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, templateUri); + } + + /** + * Starts a new negotiation payload for the requested negotiation type. + * + * @param type negotiation type to initiate + * @param contentText human-readable negotiation message + * @param facts structured facts attached to the payload + * @return transport payload representing the initial negotiation turn + */ + public Map startNegotiation(NegotiationType type, String contentText, Map facts) { + return negotiationOrchestrator.startNegotiation(type, contentText, facts); + } + + /** + * Processes a received negotiation message using its transport context payload. + * + * @param message received negotiation message + * @param context transport context payload associated with the message + * @return normalized payload describing the receive result + */ + public Map receiveNegotiation(String message, Map context) { + return negotiationOrchestrator.receiveNegotiation(message, context); + } + + /** + * Continues an existing negotiation with a locally stored context snapshot. + * + * @param context current negotiation context + * @param status next status to emit + * @param contentText continuation message content + * @return transport payload representing the next negotiation turn + */ + public Map continueNegotiation( + NegotiationContext context, NegotiationStatus status, String contentText) { + return negotiationOrchestrator.continueNegotiation(context, status, contentText); + } + + /** + * Generates a propose-phase negotiation message from typed data. + * + *

        This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the + * generator of the negotiation type addressed by the template URI and rendered from that template. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase + * segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationProposePromptFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { + return negotiationContentService.generateProposeFromData(data, templateUri); + } + + /** + * Generates an accept-phase negotiation message from typed data. + * + *

        This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the + * content conclusion is not {@code Accept} + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationAcceptPromptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + return negotiationContentService.generateAcceptFromData(data, templateUri); + } + + /** + * Generates a reject-phase negotiation message from typed data. + * + *

        This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the + * content conclusion is not {@code Reject} + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationRejectPromptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + return negotiationContentService.generateRejectFromData(data, templateUri); + } + + /** + * Generates an abort negotiation message from typed data. + * + *

        This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed + * template must be the common abort template and the content carries only the termination reason. + * + * @param data typed abort input carrying the negotiation context and the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data or its context is null + * @throws IllegalArgumentException if the template URI does not address the common abort template or the + * termination reason is blank + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationAbortPromptFromData( + @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + return negotiationContentService.generateAbortFromData(data, templateUri); + } + + /** + * Generates a propose-phase negotiation message from free text. + * + *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment + * must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the + * phase, or the code {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateNegotiationProposePromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + return negotiationContentService.generateProposeFromText(text, context, templateUri); + } + + /** + * Generates an accept-phase negotiation message from free text. + * + *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Accept}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank or the extracted conclusion is not + * {@code Accept}, or the code {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateNegotiationAcceptPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + return negotiationContentService.generateAcceptFromText(text, context, templateUri); + } + + /** + * Generates a reject-phase negotiation message from free text. + * + *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Reject}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank or the extracted conclusion is not + * {@code Reject}, or the code {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateNegotiationRejectPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + return negotiationContentService.generateRejectFromText(text, context, templateUri); + } + + /** + * Generates an abort negotiation message from free text. + * + *

        This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. + * + * @param text free-text input stating the termination reason + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses the + * termination reason, or {@code negotiation.invalid_input} when the text is blank, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateNegotiationAbortPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + return negotiationContentService.generateAbortFromText(text, context, templateUri); + } + + /** + * Lists every template available for the configured language across all A2A-T extensions. + * + *

        This query never throws: the extension directories are discovered from the bundled resource tree itself, so + * templates of extensions added later are included automatically. Templates that exist nowhere for the language are + * skipped and an empty list is returned when no template can be loaded at all. + * + * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when + * none can be loaded + */ + public List getPrompts() { + return templateQueryService.getPrompts(); + } + + /** + * Loads one template by its URI, regardless of the extension. + * + *

        This query never throws: a missing template yields an empty result together with a warning log instead of a + * failure. + * + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} or + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return the addressed template, or an empty optional when no template exists for it in the configured language + */ + public Optional getPrompt(@NonNull TemplateUri templateUri) { + return templateQueryService.getPrompt(templateUri); + } + + /** + * Validates a propose-phase negotiation message and extracts its parameters. + * + *

        The pipeline checks the template URI before any LLM call, runs the deterministic rule gate on the negotiation + * context carried in the {@code negotiationContext} metadata entry, then performs one LLM semantic validation call + * that also extracts the parameters, and finally merges the parameters with the context parameters taking + * precedence. The semantic step is retried up to the configured attempt limit on the retryable LLM infrastructure + * failure code. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or + * {@code template.not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateProposePromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return negotiationContentService.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates an accept-phase negotiation message and extracts its parameters. + * + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * accept-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or + * {@code template.not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateAcceptPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates a reject-phase negotiation message and extracts its parameters. + * + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * reject-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or + * {@code template.not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateRejectPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * abort: the template URI must address the common abort template and the message must satisfy the abort-phase + * semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or + * {@code template.not_found} when the semantic validation prompt resources are missing + */ + public FilledParamData validateAbortPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); + } +} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index d563c579..72eccd55 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -1,237 +1,238 @@ -package net.openan.a2at.sdk.client.prompt.assembly; - -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationRole; -import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; -import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; -import org.jspecify.annotations.Nullable; - -/** - * Default builder that assembles one high-level A2AT client runtime from unified config. - * - * @since 2026-06 - */ -public final class DefaultA2ATClientBuilder { - - private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; - - private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; - - private A2ATConfig config; - - private Path envPath; - - private LLMClient llmClient; - - /** - * Creates one new builder instance. - * - * @return empty client builder - */ - public static DefaultA2ATClientBuilder builder() { - return new DefaultA2ATClientBuilder(); - } - - /** - * Configures the unified SDK config consumed by the high-level client facade. - * - * @param config unified SDK config - * @return current builder - */ - public DefaultA2ATClientBuilder config(A2ATConfig config) { - this.config = config; - return this; - } - - /** - * Configures the `.env` file path used to assemble downstream facades. - * - * @param envPath caller-supplied `.env` path - * @return current builder - */ - public DefaultA2ATClientBuilder envPath(Path envPath) { - this.envPath = envPath; - return this; - } - - /** - * Injects an explicit LLM client that fully replaces the factory default. - * - *

        The injection point exists for testability and custom LLM assemblies: when set, every orchestrator built by - * this builder reuses the given client and no client is created from the `.env` LLM config. When unset, the - * builder keeps creating its default client and the behavior is unchanged. - * - * @param llmClient LLM client to inject; {@code null} keeps the factory default built from the `.env` LLM config - * @return current builder - * @since 2026-08 - */ - public DefaultA2ATClientBuilder llmClient(@Nullable LLMClient llmClient) { - this.llmClient = llmClient; - return this; - } - - /** - * Builds the default prompt-generation orchestrator from the configured unified SDK config. - * - * @return assembled prompt-generation orchestrator - */ - public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { - require(config, "Unified SDK config must be configured."); - require(envPath, "Unified SDK env path must be configured."); - requireSupportedConfig(); - - PromptResourceAccess resources = PromptResourceAccess.create(config.prompt()); - List scenarios = - resources.loadScenarios(config.prompt().language()); - - PromptSlotSchemaLoader slotSchemaLoader = resources.slotSchemaLoader(); - PromptTemplateTextLoader templateLoader = resources.templateLoader(); - String language = config.prompt().language(); - - LLMClient client = llmClient(); - - String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); - String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); - String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); - String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); - - ScenarioRecognizer scenarioRecognizer = - new SingleScenarioAwareRecognizer(scenarios, new LlmScenarioRecognizer(client)::recognize); - PromptSlotValueExtractor slotValueExtractor = new DefaultStructuredPromptSlotValueExtractor( - client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt); - - return ClientPromptGenerationOrchestratorBuilder.builder() - .llmClient(client) - .scenarios(scenarios) - .language(language) - .scenarioSystemPrompt(scenarioSystemPrompt) - .scenarioUserPrompt(scenarioUserPrompt) - .slotSystemPrompt(slotSystemPrompt) - .slotUserPrompt(slotUserPrompt) - .scenarioRecognizer(scenarioRecognizer) - .templateLoader(templateLoader) - .slotSchemaLoader(slotSchemaLoader) - .slotValueExtractor(slotValueExtractor) - .renderer(new TaskPromptRenderer()) - .maxTextChars(config.inputLimits().maxTextChars()) - .build(); - } - - /** - * Builds the default negotiation orchestrator from the configured unified SDK config. - * - * @return assembled negotiation orchestrator - */ - public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - return new RoleBoundNegotiationOrchestrator( - NegotiationHandler.builder() - .store(new InMemoryNegotiationStore()) - .build(), - NegotiationRole.CLIENT); - } - - /** - * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. - * - *

        The wiring is shared with the server side through - * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local - * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config. - * - * @return assembled negotiation generation orchestrator - */ - public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - require(envPath, "Unified SDK env path must be configured."); - return NegotiationContentService.buildOrchestrator(config, llmClient()); - } - - /** - * Builds the generic template query service from the configured unified SDK config. - * - *

        The service answers the extension-agnostic template queries: the message language and the local template root - * come from the prompt runtime config, exactly like the negotiation generation orchestrator wiring. - * - * @return assembled template query service - */ - public TemplateQueryService buildTemplateQueryService() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - return new TemplateQueryService( - config.prompt().language(), config.prompt().sourceType(), config.prompt().localRootDir()); - } - - private void requireSupportedConfig() { - if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) - && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals( - config.prompt().sourceType())) { - throw new UnsupportedOperationException( - "Unsupported prompt source type: " + config.prompt().sourceType()); - } - if (!LLMClientFactory.availableProviders().contains(config.llm().provider())) { - throw new UnsupportedOperationException( - "Unsupported LLM provider: " + config.llm().provider()); - } - if (!"in_memory".equals(config.negotiation().stateStoreType())) { - throw new UnsupportedOperationException("Unsupported negotiation state store type: " - + config.negotiation().stateStoreType()); - } - } - - private synchronized LLMClient llmClient() { - if (llmClient == null) { - llmClient = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); - } - return llmClient; - } - - private static void require(Object value, String message) { - if (value == null) { - throw new IllegalStateException(message); - } - } - - private static final class SingleScenarioAwareRecognizer implements ScenarioRecognizer { - - private final List scenarios; - - private final ScenarioRecognizer delegate; - - private SingleScenarioAwareRecognizer( - List scenarios, ScenarioRecognizer delegate) { - this.scenarios = scenarios; - this.delegate = delegate; - } - - @Override - public ScenarioRecognitionResult recognize( - String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { - if (this.scenarios.size() == 1) { - return new ScenarioRecognitionResult(true, this.scenarios.get(0).scenarioCode(), null); - } - return delegate.recognize(normalizedInput, this.scenarios, systemPrompt, userPrompt); - } - } -} +package net.openan.a2at.sdk.client.prompt.assembly; + +import java.nio.file.Path; +import java.util.List; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationRole; +import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; +import org.jspecify.annotations.Nullable; + +/** + * Default builder that assembles one high-level A2AT client runtime from unified config. + * + * @since 2026-06 + */ +public final class DefaultA2ATClientBuilder { + + private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; + + private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; + + private A2ATConfig config; + + private Path envPath; + + private LLMClient llmClient; + + /** + * Creates one new builder instance. + * + * @return empty client builder + */ + public static DefaultA2ATClientBuilder builder() { + return new DefaultA2ATClientBuilder(); + } + + /** + * Configures the unified SDK config consumed by the high-level client facade. + * + * @param config unified SDK config + * @return current builder + */ + public DefaultA2ATClientBuilder config(A2ATConfig config) { + this.config = config; + return this; + } + + /** + * Configures the `.env` file path used to assemble downstream facades. + * + * @param envPath caller-supplied `.env` path + * @return current builder + */ + public DefaultA2ATClientBuilder envPath(Path envPath) { + this.envPath = envPath; + return this; + } + + /** + * Injects an explicit LLM client that fully replaces the factory default. + * + *

        The injection point exists for testability and custom LLM assemblies: when set, every orchestrator built by + * this builder reuses the given client and no client is created from the `.env` LLM config. When unset, the builder + * keeps creating its default client and the behavior is unchanged. + * + * @param llmClient LLM client to inject; {@code null} keeps the factory default built from the `.env` LLM config + * @return current builder + * @since 2026-08 + */ + public DefaultA2ATClientBuilder llmClient(@Nullable LLMClient llmClient) { + this.llmClient = llmClient; + return this; + } + + /** + * Builds the default prompt-generation orchestrator from the configured unified SDK config. + * + * @return assembled prompt-generation orchestrator + */ + public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { + require(config, "Unified SDK config must be configured."); + require(envPath, "Unified SDK env path must be configured."); + requireSupportedConfig(); + + PromptResourceAccess resources = PromptResourceAccess.create(config.prompt()); + List scenarios = + resources.loadScenarios(config.prompt().language()); + + PromptSlotSchemaLoader slotSchemaLoader = resources.slotSchemaLoader(); + PromptTemplateTextLoader templateLoader = resources.templateLoader(); + String language = config.prompt().language(); + + LLMClient client = llmClient(); + + String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); + String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); + String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); + String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); + + ScenarioRecognizer scenarioRecognizer = + new SingleScenarioAwareRecognizer(scenarios, new LlmScenarioRecognizer(client)::recognize); + PromptSlotValueExtractor slotValueExtractor = new DefaultStructuredPromptSlotValueExtractor( + client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt); + + return ClientPromptGenerationOrchestratorBuilder.builder() + .llmClient(client) + .scenarios(scenarios) + .language(language) + .scenarioSystemPrompt(scenarioSystemPrompt) + .scenarioUserPrompt(scenarioUserPrompt) + .slotSystemPrompt(slotSystemPrompt) + .slotUserPrompt(slotUserPrompt) + .scenarioRecognizer(scenarioRecognizer) + .templateLoader(templateLoader) + .slotSchemaLoader(slotSchemaLoader) + .slotValueExtractor(slotValueExtractor) + .renderer(new TaskPromptRenderer()) + .maxTextChars(config.inputLimits().maxTextChars()) + .build(); + } + + /** + * Builds the default negotiation orchestrator from the configured unified SDK config. + * + * @return assembled negotiation orchestrator + */ + public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + return new RoleBoundNegotiationOrchestrator( + NegotiationHandler.builder() + .store(new InMemoryNegotiationStore()) + .build(), + NegotiationRole.CLIENT); + } + + /** + * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. + * + *

        The wiring is shared with the server side through + * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local + * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config. + * + * @return assembled negotiation generation orchestrator + */ + public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + require(envPath, "Unified SDK env path must be configured."); + return NegotiationContentService.buildOrchestrator(config, llmClient()); + } + + /** + * Builds the generic template query service from the configured unified SDK config. + * + *

        The service answers the extension-agnostic template queries: the message language and the local template root + * come from the prompt runtime config, exactly like the negotiation generation orchestrator wiring. + * + * @return assembled template query service + */ + public TemplateQueryService buildTemplateQueryService() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + return new TemplateQueryService( + config.prompt().language(), + config.prompt().sourceType(), + config.prompt().localRootDir()); + } + + private void requireSupportedConfig() { + if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) + && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals( + config.prompt().sourceType())) { + throw new UnsupportedOperationException( + "Unsupported prompt source type: " + config.prompt().sourceType()); + } + if (!LLMClientFactory.availableProviders().contains(config.llm().provider())) { + throw new UnsupportedOperationException( + "Unsupported LLM provider: " + config.llm().provider()); + } + if (!"in_memory".equals(config.negotiation().stateStoreType())) { + throw new UnsupportedOperationException("Unsupported negotiation state store type: " + + config.negotiation().stateStoreType()); + } + } + + private synchronized LLMClient llmClient() { + if (llmClient == null) { + llmClient = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); + } + return llmClient; + } + + private static void require(Object value, String message) { + if (value == null) { + throw new IllegalStateException(message); + } + } + + private static final class SingleScenarioAwareRecognizer implements ScenarioRecognizer { + + private final List scenarios; + + private final ScenarioRecognizer delegate; + + private SingleScenarioAwareRecognizer(List scenarios, ScenarioRecognizer delegate) { + this.scenarios = scenarios; + this.delegate = delegate; + } + + @Override + public ScenarioRecognitionResult recognize( + String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { + if (this.scenarios.size() == 1) { + return new ScenarioRecognitionResult(true, this.scenarios.get(0).scenarioCode(), null); + } + return delegate.recognize(normalizedInput, this.scenarios, systemPrompt, userPrompt); + } + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index 1eed9906..3c55e8ee 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -1,192 +1,192 @@ -package net.openan.a2at.sdk.client.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class A2ATClientNegotiationApiTest { - - private static final String TEST_MOCK_PROVIDER = "test-negotiation-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); - } - } - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); - - @Test - void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - MetadataContent result = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); - Map metadata = result.buildMetadataContent(); - assertEquals(3, metadata.size()); - assertEquals(result.promptText(), metadata.get(result.extensionUri())); - assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); - } - - @Test - void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("en-US")); - - MetadataContent result = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); - assertTrue(result.promptText().contains("Required Information Items")); - } - - @Test - void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { - assertEquals(7, negotiationPrompts(new A2ATClient(writeEnv("zh-CN"))).size()); - assertEquals(7, negotiationPrompts(new A2ATClient(writeEnv("en-US"))).size()); - } - - @Test - void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - MetadataContent result = client.generateNegotiationAbortPromptFromData( - new NegotiationAbortData( - new NegotiationContext(UUID, 5, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - StandardTemplates.NEGOTIATION_ABORT); - - assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); - assertTrue(result.promptText().contains("## 协商结果\nAbort")); - assertTrue(result.promptText().contains("## 协商终止原因")); - assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); - assertEquals(5, result.negotiationContext().round()); - } - - @Test - void generatesAbortPromptFromDataWithBuiltinEnglishTemplates() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("en-US")); - - MetadataContent result = client.generateNegotiationAbortPromptFromData( - new NegotiationAbortData( - new NegotiationContext(UUID, 3, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent("Reached the negotiation round limit. This negotiation is confirmed and ended.")), - StandardTemplates.NEGOTIATION_ABORT); - - assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); - assertTrue(result.promptText().contains("## Negotiation Result\nAbort")); - assertTrue(result.promptText().contains("## Negotiation Termination Reason")); - assertTrue(result.promptText().contains("Reached the negotiation round limit.")); - } - - @Test - void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - PromptTemplate template = - client.getPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); - assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); - assertFalse(template.content().isBlank()); - } - - @Test - void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - assertTrue(client.getPrompt(INFORMATION_PROPOSE).isPresent()); - assertTrue(client - .getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) - .isPresent()); - assertFalse(client - .getPrompt(TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v9")) - .isPresent()); - PromptTemplate template = client.getPrompt(INFORMATION_PROPOSE).orElseThrow(); - assertEquals(INFORMATION_PROPOSE, template.templateUri()); - assertFalse(template.content().isBlank()); - } - - private static List negotiationPrompts(A2ATClient client) { - return client.getPrompts().stream() - .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( - template.templateUri().extensionName())) - .toList(); - } - - private static Path writeEnv(String language) throws IOException { - Path tempDir = Files.createTempDirectory("a2at-client-negotiation-env"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=%s - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=test-negotiation-mock - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(language)); - return envFile; - } - - public static final class RecordingClient implements LLMClient { - - private final LLMClientConfig config; - - public RecordingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse("{}", config.model(), Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sdk.client.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class A2ATClientNegotiationApiTest { + + private static final String TEST_MOCK_PROVIDER = "test-negotiation-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + @Test + void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); + Map metadata = result.buildMetadataContent(); + assertEquals(3, metadata.size()); + assertEquals(result.promptText(), metadata.get(result.extensionUri())); + assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); + } + + @Test + void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("en-US")); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); + assertTrue(result.promptText().contains("Required Information Items")); + } + + @Test + void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { + assertEquals(7, negotiationPrompts(new A2ATClient(writeEnv("zh-CN"))).size()); + assertEquals(7, negotiationPrompts(new A2ATClient(writeEnv("en-US"))).size()); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + MetadataContent result = client.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 5, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + StandardTemplates.NEGOTIATION_ABORT); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("## 协商终止原因")); + assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); + assertEquals(5, result.negotiationContext().round()); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinEnglishTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("en-US")); + + MetadataContent result = client.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 3, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent( + "Reached the negotiation round limit. This negotiation is confirmed and ended.")), + StandardTemplates.NEGOTIATION_ABORT); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## Negotiation Result\nAbort")); + assertTrue(result.promptText().contains("## Negotiation Termination Reason")); + assertTrue(result.promptText().contains("Reached the negotiation round limit.")); + } + + @Test + void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + PromptTemplate template = + client.getPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + @Test + void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + assertTrue(client.getPrompt(INFORMATION_PROPOSE).isPresent()); + assertTrue(client.getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .isPresent()); + assertFalse( + client.getPrompt(TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v9")) + .isPresent()); + PromptTemplate template = client.getPrompt(INFORMATION_PROPOSE).orElseThrow(); + assertEquals(INFORMATION_PROPOSE, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + private static List negotiationPrompts(A2ATClient client) { + return client.getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + + private static Path writeEnv(String language) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-negotiation-env"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=%s + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-negotiation-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(language)); + return envFile; + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index aedac85e..96400f77 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -1,240 +1,240 @@ -package net.openan.a2at.sdk.client.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Drives the env-configured negotiation behavior of the client facade end to end. - * - *

        The negotiation-relevant env keys must become observable in the facade behavior: the language selects the Chinese - * templates, and the LLM attempt limit bounds the retry loop of the from-text generation. The negotiation - * templates are classpath-fixed, so a configured local resource root is ignored in {@code classpath} mode: the built-in - * template wins over any local override. A second test proves the zero-configuration defaults (English templates, three - * attempts, built-in resources) work out of the box. - */ -class A2ATClientNegotiationEnvConfigTest { - - private static final String TEST_MOCK_PROVIDER = "test-negotiation-failing-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, FailingClient.class); - } - } - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); - - private static final String CUSTOM_TEMPLATE_MARKER = "CUSTOM-TEMPLATE-MARKER-7d31"; - - @TempDir - Path tempDir; - - private Logger rootLogger; - - private ListAppender appender; - - @BeforeEach - void attachAppender() { - rootLogger = - (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); - appender = new ListAppender<>(); - appender.start(); - rootLogger.addAppender(appender); - } - - @AfterEach - void detachAppender() { - rootLogger.detachAppender(appender); - appender.stop(); - } - - @Test - void languageAndMaxAttemptsAreObservableAndClasspathIgnoresLocalRoot() throws IOException { - Path customRoot = writeCustomRootWithMarkeredTemplate(); - Path envFile = writeEnvFile( - "A2AT_LANGUAGE=zh-CN", - "A2AT_PROMPT_SOURCE_TYPE=classpath", - "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=" + customRoot.toString().replace('\\', '/'), - "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, - "A2AT_LLM_MODEL=example-model", - "A2AT_LLM_BASE_URL=https://llm.example.test/v1", - "A2AT_LLM_API_KEY=test-key", - "A2AT_LLM_MAX_ATTEMPTS=2", - "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory"); - A2ATClient client = new A2ATClient(envFile); - - MetadataContent result = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); - - assertTrue(result.promptText().contains("所需信息项"), "the zh-CN language must select the Chinese templates"); - assertFalse( - result.promptText().contains(CUSTOM_TEMPLATE_MARKER), - "the configured local resource root must be ignored in classpath mode and the built-in template used"); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> client.generateNegotiationProposePromptFromText( - "请提供节能区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE)); - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); - assertEquals( - 1, - retryEventCount(), - "A2AT_LLM_MAX_ATTEMPTS=2 must allow exactly one retry before surfacing the failure"); - assertEquals(1, exhaustedEventCount(), "the retry loop must report exhaustion exactly once"); - } - - @Test - void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { - Path envFile = writeEnvFile( - "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, - "A2AT_LLM_MODEL=example-model", - "A2AT_LLM_BASE_URL=https://llm.example.test/v1", - "A2AT_LLM_API_KEY=test-key"); - A2ATClient client = new A2ATClient(envFile); - - MetadataContent result = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE); - - assertTrue(result.promptText().contains("## Information Negotiation"), "the default language must be en-US"); - assertTrue(result.promptText().contains("Required Information Items")); - assertEquals(7, negotiationPrompts(client).size(), "the built-in resources must be used by default"); - assertTrue(client.getPrompt(INFORMATION_PROPOSE).isPresent()); - - assertThrows( - NegotiationGenerationException.class, - () -> client.generateNegotiationProposePromptFromText( - "Provide the ran-energy-saving region.", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE)); - assertEquals( - 2, - retryEventCount(), - "the default attempt limit of 3 must allow exactly two retries before surfacing the failure"); - assertEquals(1, exhaustedEventCount()); - } - - private Path writeEnvFile(String... lines) throws IOException { - Path envFile = tempDir.resolve("client.env"); - Files.writeString(envFile, String.join("\n", lines) + "\n", StandardCharsets.UTF_8); - return envFile; - } - - private static List negotiationPrompts(A2ATClient client) { - return client.getPrompts().stream() - .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( - template.templateUri().extensionName())) - .toList(); - } - - private Path writeCustomRootWithMarkeredTemplate() throws IOException { - Path customTemplate = tempDir.resolve("custom-root") - .resolve("templates") - .resolve("Negotiation-T") - .resolve("information-negotiation") - .resolve("propose") - .resolve("v1") - .resolve("zh-CN") - .resolve("template.md"); - Files.createDirectories(customTemplate.getParent()); - Files.writeString( - customTemplate, - builtinTemplate(INFORMATION_PROPOSE_URI, "zh-CN") + "\n\n## 自定义标记\n" + CUSTOM_TEMPLATE_MARKER + "\n", - StandardCharsets.UTF_8); - return tempDir.resolve("custom-root"); - } - - private static String builtinTemplate(String templateUri, String language) throws IOException { - String[] segments = templateUri.split("/"); - String typeSegment = segments[1]; - String phaseSegment = segments[2]; - String classpathPath = "prompt_resources/templates/Negotiation-T/" + typeSegment + "/" + phaseSegment + "/" - + TemplateUri.DEFAULT_TEMPLATE_VERSION + "/" + language + "/template.md"; - InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(classpathPath); - assertFalse(stream == null, "the built-in template must exist on the classpath: " + classpathPath); - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } - } - - private long retryEventCount() { - return countEvents("negotiation_llm_retry"); - } - - private long exhaustedEventCount() { - return countEvents("negotiation_llm_retry_exhausted"); - } - - private long countEvents(String eventName) { - return appender.list.stream() - .filter(event -> event.getLevel() == Level.WARN) - .map(ILoggingEvent::getFormattedMessage) - .filter(message -> message.startsWith(eventName + " ")) - .count(); - } - - public static final class FailingClient implements LLMClient { - - private final LLMClientConfig config; - - public FailingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - throw new LLMRuntimeError("test mock failure"); - } - } -} +package net.openan.a2at.sdk.client.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Drives the env-configured negotiation behavior of the client facade end to end. + * + *

        The negotiation-relevant env keys must become observable in the facade behavior: the language selects the Chinese + * templates, and the LLM attempt limit bounds the retry loop of the from-text generation. The negotiation templates are + * classpath-fixed, so a configured local resource root is ignored in {@code classpath} mode: the built-in template wins + * over any local override. A second test proves the zero-configuration defaults (English templates, three attempts, + * built-in resources) work out of the box. + */ +class A2ATClientNegotiationEnvConfigTest { + + private static final String TEST_MOCK_PROVIDER = "test-negotiation-failing-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, FailingClient.class); + } + } + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + private static final String CUSTOM_TEMPLATE_MARKER = "CUSTOM-TEMPLATE-MARKER-7d31"; + + @TempDir + Path tempDir; + + private Logger rootLogger; + + private ListAppender appender; + + @BeforeEach + void attachAppender() { + rootLogger = + (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + appender = new ListAppender<>(); + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + rootLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void languageAndMaxAttemptsAreObservableAndClasspathIgnoresLocalRoot() throws IOException { + Path customRoot = writeCustomRootWithMarkeredTemplate(); + Path envFile = writeEnvFile( + "A2AT_LANGUAGE=zh-CN", + "A2AT_PROMPT_SOURCE_TYPE=classpath", + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=" + customRoot.toString().replace('\\', '/'), + "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, + "A2AT_LLM_MODEL=example-model", + "A2AT_LLM_BASE_URL=https://llm.example.test/v1", + "A2AT_LLM_API_KEY=test-key", + "A2AT_LLM_MAX_ATTEMPTS=2", + "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory"); + A2ATClient client = new A2ATClient(envFile); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + assertTrue(result.promptText().contains("所需信息项"), "the zh-CN language must select the Chinese templates"); + assertFalse( + result.promptText().contains(CUSTOM_TEMPLATE_MARKER), + "the configured local resource root must be ignored in classpath mode and the built-in template used"); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> client.generateNegotiationProposePromptFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE)); + assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); + assertEquals( + 1, + retryEventCount(), + "A2AT_LLM_MAX_ATTEMPTS=2 must allow exactly one retry before surfacing the failure"); + assertEquals(1, exhaustedEventCount(), "the retry loop must report exhaustion exactly once"); + } + + @Test + void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { + Path envFile = writeEnvFile( + "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, + "A2AT_LLM_MODEL=example-model", + "A2AT_LLM_BASE_URL=https://llm.example.test/v1", + "A2AT_LLM_API_KEY=test-key"); + A2ATClient client = new A2ATClient(envFile); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE); + + assertTrue(result.promptText().contains("## Information Negotiation"), "the default language must be en-US"); + assertTrue(result.promptText().contains("Required Information Items")); + assertEquals(7, negotiationPrompts(client).size(), "the built-in resources must be used by default"); + assertTrue(client.getPrompt(INFORMATION_PROPOSE).isPresent()); + + assertThrows( + NegotiationGenerationException.class, + () -> client.generateNegotiationProposePromptFromText( + "Provide the ran-energy-saving region.", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE)); + assertEquals( + 2, + retryEventCount(), + "the default attempt limit of 3 must allow exactly two retries before surfacing the failure"); + assertEquals(1, exhaustedEventCount()); + } + + private Path writeEnvFile(String... lines) throws IOException { + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, String.join("\n", lines) + "\n", StandardCharsets.UTF_8); + return envFile; + } + + private static List negotiationPrompts(A2ATClient client) { + return client.getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + + private Path writeCustomRootWithMarkeredTemplate() throws IOException { + Path customTemplate = tempDir.resolve("custom-root") + .resolve("templates") + .resolve("Negotiation-T") + .resolve("information-negotiation") + .resolve("propose") + .resolve("v1") + .resolve("zh-CN") + .resolve("template.md"); + Files.createDirectories(customTemplate.getParent()); + Files.writeString( + customTemplate, + builtinTemplate(INFORMATION_PROPOSE_URI, "zh-CN") + "\n\n## 自定义标记\n" + CUSTOM_TEMPLATE_MARKER + "\n", + StandardCharsets.UTF_8); + return tempDir.resolve("custom-root"); + } + + private static String builtinTemplate(String templateUri, String language) throws IOException { + String[] segments = templateUri.split("/"); + String typeSegment = segments[1]; + String phaseSegment = segments[2]; + String classpathPath = "prompt_resources/templates/Negotiation-T/" + typeSegment + "/" + phaseSegment + "/" + + TemplateUri.DEFAULT_TEMPLATE_VERSION + "/" + language + "/template.md"; + InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(classpathPath); + assertFalse(stream == null, "the built-in template must exist on the classpath: " + classpathPath); + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private long retryEventCount() { + return countEvents("negotiation_llm_retry"); + } + + private long exhaustedEventCount() { + return countEvents("negotiation_llm_retry_exhausted"); + } + + private long countEvents(String eventName) { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.startsWith(eventName + " ")) + .count(); + } + + public static final class FailingClient implements LLMClient { + + private final LLMClientConfig config; + + public FailingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new LLMRuntimeError("test mock failure"); + } + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 1d109212..c2fe16d3 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -963,8 +963,7 @@ void generateTaskPromptSurfacesExtractionSlotErrorsAsFailure() { new FakeTemplateLoader("Site: {site}"), (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( Map.of("site", "Site A"), - List.of(new StructuredSlotValidationError( - "subscriptionCondition", "slot.not_provided", null))), + List.of(new StructuredSlotValidationError("subscriptionCondition", "slot.not_provided", null))), new TaskPromptRenderer(), EMPTY_SCHEMA_LOADER); diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java index 0646b27d..dc89c0e1 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java @@ -1,73 +1,73 @@ -package net.openan.a2at.sdk.core.model; - -import java.nio.file.Path; -import java.util.Map; -import org.jspecify.annotations.NonNull; - -/** - * Unified SDK configuration entry point loaded from one caller-supplied `.env` file. - * Users are expected to copy the repository `env.example` into their own application and pass that file path in. - * - * @since 2026-06 - */ -public record A2ATConfig( - PromptRuntimeConfig prompt, - LlmConfig llm, - InputLimitConfig inputLimits, - NegotiationConfig negotiation, - PromptComplianceConfig promptCompliance) { - - /** - * Loads one unified SDK config from one `.env` file path. - * - * @param envPath caller-supplied `.env` file path - * @return unified SDK config - */ - public static @NonNull A2ATConfig load(@NonNull Path envPath) { - Map values = DotEnvConfigSource.load(envPath); - return new A2ATConfig( - PromptRuntimeConfig.fromMap(values), - LlmConfig.fromMap(values), - InputLimitConfig.fromMap(values), - NegotiationConfig.fromMap(values), - PromptComplianceConfig.fromMap(values)); - } - - /** - * Resolves the relative prompt resource local root directory against the `.env` file location. - * - *

        The returned config carries the local root as an absolute normalized path: a relative configured root is - * resolved against the `.env` file parent, an absolute configured root is only normalized. An unset (null or - * blank) local root is preserved as-is, since only {@code local_file} mode consumes the value (and enforces its - * presence at assembly time). - * - * @param config unified SDK config as loaded from the `.env` file - * @param envPath resolved `.env` file path the config was loaded from; a relative path is absolved against the - * current working directory before its parent is derived - * @return config with the local root resolved to an absolute normalized path, or unchanged when the local root - * is unset (null or blank) - */ - public static @NonNull A2ATConfig resolvePromptResourceLocalRootDir( - @NonNull A2ATConfig config, @NonNull Path envPath) { - String localRootDir = config.prompt().localRootDir(); - if (localRootDir == null || localRootDir.isBlank()) { - return config; - } - Path localRootPath = Path.of(localRootDir); - Path resolvedLocalRootPath = localRootPath.isAbsolute() - ? localRootPath.normalize() - : envBaseDir(envPath).resolve(localRootPath).toAbsolutePath().normalize(); - return new A2ATConfig( - new PromptRuntimeConfig( - config.prompt().language(), config.prompt().sourceType(), resolvedLocalRootPath.toString()), - config.llm(), - config.inputLimits(), - config.negotiation(), - config.promptCompliance()); - } - - private static Path envBaseDir(Path envPath) { - Path parent = envPath.toAbsolutePath().normalize().getParent(); - return parent != null ? parent : Path.of("").toAbsolutePath(); - } -} +package net.openan.a2at.sdk.core.model; + +import java.nio.file.Path; +import java.util.Map; +import org.jspecify.annotations.NonNull; + +/** + * Unified SDK configuration entry point loaded from one caller-supplied `.env` file. Users are expected to copy the + * repository `env.example` into their own application and pass that file path in. + * + * @since 2026-06 + */ +public record A2ATConfig( + PromptRuntimeConfig prompt, + LlmConfig llm, + InputLimitConfig inputLimits, + NegotiationConfig negotiation, + PromptComplianceConfig promptCompliance) { + + /** + * Loads one unified SDK config from one `.env` file path. + * + * @param envPath caller-supplied `.env` file path + * @return unified SDK config + */ + public static @NonNull A2ATConfig load(@NonNull Path envPath) { + Map values = DotEnvConfigSource.load(envPath); + return new A2ATConfig( + PromptRuntimeConfig.fromMap(values), + LlmConfig.fromMap(values), + InputLimitConfig.fromMap(values), + NegotiationConfig.fromMap(values), + PromptComplianceConfig.fromMap(values)); + } + + /** + * Resolves the relative prompt resource local root directory against the `.env` file location. + * + *

        The returned config carries the local root as an absolute normalized path: a relative configured root is + * resolved against the `.env` file parent, an absolute configured root is only normalized. An unset (null or blank) + * local root is preserved as-is, since only {@code local_file} mode consumes the value (and enforces its presence + * at assembly time). + * + * @param config unified SDK config as loaded from the `.env` file + * @param envPath resolved `.env` file path the config was loaded from; a relative path is absolved against the + * current working directory before its parent is derived + * @return config with the local root resolved to an absolute normalized path, or unchanged when the local root is + * unset (null or blank) + */ + public static @NonNull A2ATConfig resolvePromptResourceLocalRootDir( + @NonNull A2ATConfig config, @NonNull Path envPath) { + String localRootDir = config.prompt().localRootDir(); + if (localRootDir == null || localRootDir.isBlank()) { + return config; + } + Path localRootPath = Path.of(localRootDir); + Path resolvedLocalRootPath = localRootPath.isAbsolute() + ? localRootPath.normalize() + : envBaseDir(envPath).resolve(localRootPath).toAbsolutePath().normalize(); + return new A2ATConfig( + new PromptRuntimeConfig( + config.prompt().language(), config.prompt().sourceType(), resolvedLocalRootPath.toString()), + config.llm(), + config.inputLimits(), + config.negotiation(), + config.promptCompliance()); + } + + private static Path envBaseDir(Path envPath) { + Path parent = envPath.toAbsolutePath().normalize().getParent(); + return parent != null ? parent : Path.of("").toAbsolutePath(); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfig.java index e61c0f77..427b94a9 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfig.java @@ -1,35 +1,35 @@ -package net.openan.a2at.sdk.core.model; - -import java.util.Map; -import org.apache.commons.lang3.StringUtils; - -/** - * Prompt runtime configuration resolved from unified SDK config. - * - * @since 2026-06 - */ -public record PromptRuntimeConfig(String language, String sourceType, String localRootDir) { - - private static final String DEFAULT_LANGUAGE = "en-US"; - - /** Source-type selector value for loading business content from the built-in classpath. */ - public static final String SOURCE_TYPE_CLASSPATH = "classpath"; - - /** Source-type selector value for loading business content from the configured local root. */ - public static final String SOURCE_TYPE_LOCAL_FILE = "local_file"; - - private static final String DEFAULT_SOURCE_TYPE = SOURCE_TYPE_CLASSPATH; - - /** - * Builds one prompt runtime config from raw `.env` values. - * - * @param values raw config values - * @return resolved prompt runtime config - */ - public static PromptRuntimeConfig fromMap(Map values) { - return new PromptRuntimeConfig( - StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.PromptRuntime.LANGUAGE), DEFAULT_LANGUAGE), - StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.PromptRuntime.SOURCE_TYPE), DEFAULT_SOURCE_TYPE), - values.get(A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR)); - } -} +package net.openan.a2at.sdk.core.model; + +import java.util.Map; +import org.apache.commons.lang3.StringUtils; + +/** + * Prompt runtime configuration resolved from unified SDK config. + * + * @since 2026-06 + */ +public record PromptRuntimeConfig(String language, String sourceType, String localRootDir) { + + private static final String DEFAULT_LANGUAGE = "en-US"; + + /** Source-type selector value for loading business content from the built-in classpath. */ + public static final String SOURCE_TYPE_CLASSPATH = "classpath"; + + /** Source-type selector value for loading business content from the configured local root. */ + public static final String SOURCE_TYPE_LOCAL_FILE = "local_file"; + + private static final String DEFAULT_SOURCE_TYPE = SOURCE_TYPE_CLASSPATH; + + /** + * Builds one prompt runtime config from raw `.env` values. + * + * @param values raw config values + * @return resolved prompt runtime config + */ + public static PromptRuntimeConfig fromMap(Map values) { + return new PromptRuntimeConfig( + StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.PromptRuntime.LANGUAGE), DEFAULT_LANGUAGE), + StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.PromptRuntime.SOURCE_TYPE), DEFAULT_SOURCE_TYPE), + values.get(A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR)); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java index 483fd733..5b240563 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java @@ -1,28 +1,28 @@ -package net.openan.a2at.sdk.core.model; - -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * One loadable prompt template of any A2A-T extension. - * - * @param templateUri typed template URI such as {@code Task-T/network-layer/ran-energy-saving/v1} or - * {@code Negotiation-T/information-negotiation/propose/v1} - * @param description template description taken from the leading HTML comment; empty string when the template has no - * comment - * @param content full template file text; null when the template content is unavailable - * @param source effective origin of the loaded template, either {@code classpath} or {@code local} - * @since 2026-08 - */ -public record PromptTemplate( - @NonNull TemplateUri templateUri, - @NonNull String description, - @Nullable String content, - @NonNull String source) { - - /** Source marker for templates loaded from the built-in classpath resources. */ - public static final String SOURCE_CLASSPATH = "classpath"; - - /** Source marker for templates loaded from the configured local root. */ - public static final String SOURCE_LOCAL = "local"; -} +package net.openan.a2at.sdk.core.model; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * One loadable prompt template of any A2A-T extension. + * + * @param templateUri typed template URI such as {@code Task-T/network-layer/ran-energy-saving/v1} or + * {@code Negotiation-T/information-negotiation/propose/v1} + * @param description template description taken from the leading HTML comment; empty string when the template has no + * comment + * @param content full template file text; null when the template content is unavailable + * @param source effective origin of the loaded template, either {@code classpath} or {@code local} + * @since 2026-08 + */ +public record PromptTemplate( + @NonNull TemplateUri templateUri, + @NonNull String description, + @Nullable String content, + @NonNull String source) { + + /** Source marker for templates loaded from the built-in classpath resources. */ + public static final String SOURCE_CLASSPATH = "classpath"; + + /** Source marker for templates loaded from the configured local root. */ + public static final String SOURCE_LOCAL = "local"; +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java index db144716..dc2b8083 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java @@ -1,67 +1,66 @@ -package net.openan.a2at.sdk.core.resources; - -import org.jspecify.annotations.Nullable; - -/** - * Validates path segment values used to compose classpath or filesystem resource paths. - * - *

        A simple segment is non-null, non-blank and free of slashes, backslashes and {@code ..} sequences, so that it - * can never escape the resource root it is resolved against. - * - * @since 2026-08 - */ -public final class PathSegments { - - private PathSegments() { - throw new UnsupportedOperationException("Utility class - do not instantiate"); - } - - /** - * Checks whether the value is a non-blank simple path segment. - * - * @param value candidate path segment such as a language or category identifier - * @return true when the value is non-null, non-blank and contains no slash, backslash or {@code ..} sequence - */ - public static boolean isSimpleSegment(@Nullable String value) { - return value != null - && !value.isBlank() - && !value.contains("/") - && !value.contains("\\") - && !value.contains(".."); - } - - /** - * Requires the value to be a non-blank simple path segment, throwing an {@link IllegalArgumentException} - * otherwise. - * - * @param value candidate path segment such as a language or category identifier; may be {@code null} - * @param label human-readable label describing the segment, used in the failure message - * @throws IllegalArgumentException when the value is not a simple path segment - */ - public static void requireSimpleSegment(@Nullable String value, String label) { - if (!isSimpleSegment(value)) { - throw new IllegalArgumentException( - label + " must be a non-blank simple path segment but was " + value + "."); - } - } - - /** - * Requires the value to be a non-blank relative path whose slash-separated segments are each simple path - * segments, throwing an {@link IllegalArgumentException} otherwise. - * - * @param value candidate relative path such as a scenario code; may be {@code null} - * @param label human-readable label describing the path, used in the failure message - * @throws IllegalArgumentException when the value is null, blank or contains a non-simple path segment - */ - public static void requireSimpleRelativePath(@Nullable String value, String label) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException(label + " must be a non-blank relative path but was " + value + "."); - } - for (String segment : value.split("/", -1)) { - if (!isSimpleSegment(segment)) { - throw new IllegalArgumentException( - label + " must contain only simple path segments but was " + value + "."); - } - } - } -} +package net.openan.a2at.sdk.core.resources; + +import org.jspecify.annotations.Nullable; + +/** + * Validates path segment values used to compose classpath or filesystem resource paths. + * + *

        A simple segment is non-null, non-blank and free of slashes, backslashes and {@code ..} sequences, so that it can + * never escape the resource root it is resolved against. + * + * @since 2026-08 + */ +public final class PathSegments { + + private PathSegments() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** + * Checks whether the value is a non-blank simple path segment. + * + * @param value candidate path segment such as a language or category identifier + * @return true when the value is non-null, non-blank and contains no slash, backslash or {@code ..} sequence + */ + public static boolean isSimpleSegment(@Nullable String value) { + return value != null + && !value.isBlank() + && !value.contains("/") + && !value.contains("\\") + && !value.contains(".."); + } + + /** + * Requires the value to be a non-blank simple path segment, throwing an {@link IllegalArgumentException} otherwise. + * + * @param value candidate path segment such as a language or category identifier; may be {@code null} + * @param label human-readable label describing the segment, used in the failure message + * @throws IllegalArgumentException when the value is not a simple path segment + */ + public static void requireSimpleSegment(@Nullable String value, String label) { + if (!isSimpleSegment(value)) { + throw new IllegalArgumentException( + label + " must be a non-blank simple path segment but was " + value + "."); + } + } + + /** + * Requires the value to be a non-blank relative path whose slash-separated segments are each simple path segments, + * throwing an {@link IllegalArgumentException} otherwise. + * + * @param value candidate relative path such as a scenario code; may be {@code null} + * @param label human-readable label describing the path, used in the failure message + * @throws IllegalArgumentException when the value is null, blank or contains a non-simple path segment + */ + public static void requireSimpleRelativePath(@Nullable String value, String label) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(label + " must be a non-blank relative path but was " + value + "."); + } + for (String segment : value.split("/", -1)) { + if (!isSimpleSegment(segment)) { + throw new IllegalArgumentException( + label + " must contain only simple path segments but was " + value + "."); + } + } + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java index ab764865..7f95ee72 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java @@ -1,32 +1,32 @@ -package net.openan.a2at.sdk.core.validation; - -import java.util.Map; -import net.openan.a2at.sdk.core.model.FilledParamData; -import org.jspecify.annotations.NonNull; -import net.openan.a2at.sdk.core.model.TemplateUri; - -/** - * Entry point for validating content and extracting filled parameters. - * - *

        Implementations orchestrate the full validation pipeline: input validation, rule-level gate, semantic validation - * and parameter merging. - * - * @since 2026-08 - */ -public interface ContentValidator { - - /** - * Validates one content prompt and extracts its filled parameters. - * - * @param prompt content prompt text - * @param schema caller-provided parameter JSON schema - * @param templateUri URI of the template the content is validated against, such as - * {@code Task-T/network-layer/ran-energy-saving/v1} - * @return filled parameter data carrying the merged parameters - * @throws ContentValidationException with {@code validation_invalid_input} if the template URI is null or malformed, - * the prompt is null or blank, or the schema is null - * @throws ContentValidationException if the validation fails at any stage - */ - FilledParamData validate( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri); -} +package net.openan.a2at.sdk.core.validation; + +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.jspecify.annotations.NonNull; + +/** + * Entry point for validating content and extracting filled parameters. + * + *

        Implementations orchestrate the full validation pipeline: input validation, rule-level gate, semantic validation + * and parameter merging. + * + * @since 2026-08 + */ +public interface ContentValidator { + + /** + * Validates one content prompt and extracts its filled parameters. + * + * @param prompt content prompt text + * @param schema caller-provided parameter JSON schema + * @param templateUri URI of the template the content is validated against, such as + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return filled parameter data carrying the merged parameters + * @throws ContentValidationException with {@code validation_invalid_input} if the template URI is null or + * malformed, the prompt is null or blank, or the schema is null + * @throws ContentValidationException if the validation fails at any stage + */ + FilledParamData validate( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri); +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java index 370b6ac1..cb8b1a3f 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java @@ -1,26 +1,27 @@ -package net.openan.a2at.sdk.core.validation; - -import org.jspecify.annotations.NonNull; - -/** - * Loads the template text for a template reference during validation. - * - *

        Injected into {@link ValidationPipeline} as an optional gate that runs after the rule gate and before semantic - * validation. Loaders resolve the template the content is validated against, so the semantic validator can compare the - * content against the authoritative template body. - * - * @param template addressing type the loader resolves - * @since 2026-08 - */ -@FunctionalInterface -public interface TemplateContentLoader { - - /** - * Loads the template text for the given template reference. - * - * @param reference template addressing value to resolve - * @return loaded template text - * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the template cannot be resolved - */ - @NonNull String load(@NonNull T reference); -} +package net.openan.a2at.sdk.core.validation; + +import org.jspecify.annotations.NonNull; + +/** + * Loads the template text for a template reference during validation. + * + *

        Injected into {@link ValidationPipeline} as an optional gate that runs after the rule gate and before semantic + * validation. Loaders resolve the template the content is validated against, so the semantic validator can compare the + * content against the authoritative template body. + * + * @param template addressing type the loader resolves + * @since 2026-08 + */ +@FunctionalInterface +public interface TemplateContentLoader { + + /** + * Loads the template text for the given template reference. + * + * @param reference template addressing value to resolve + * @return loaded template text + * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the template cannot be resolved + */ + @NonNull + String load(@NonNull T reference); +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java index c9d00367..4ba6bc57 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java @@ -1,255 +1,254 @@ -package net.openan.a2at.sdk.core.validation; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Orchestrates the content validation pipeline: input validation, rule-level gate, retryable semantic validation and - * deterministic parameter merging. - * - *

        Parameter merging writes the context parameters first and the semantically extracted parameters second; on a key - * conflict the context parameter wins and a warning is logged, so the LLM output can never override the rule-level - * parsed values. - * - * @param template addressing type the validation is performed against, such as {@link TemplateUri} - * @since 2026-08 - */ -public final class ValidationPipeline { - - private static final Logger LOGGER = LoggerFactory.getLogger(ValidationPipeline.class); - - private static final String SEMANTIC_VALIDATION_STEP = "semantic_validation"; - - private final RuleChecker ruleChecker; - - private final SemanticValidator semanticValidator; - - private final int maxAttempts; - - private final String language; - - private final TemplateContentLoader templateContentLoader; - - /** - * Creates a validation pipeline without a template loading gate. - * - * @param ruleChecker rule-level checker used as the entry gate - * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters - * @param maxAttempts maximum number of retry attempts for the semantic validation step - * @throws NullPointerException if the rule checker or the semantic validator is null - */ - public ValidationPipeline( - @NonNull RuleChecker ruleChecker, @NonNull SemanticValidator semanticValidator, int maxAttempts) { - this(ruleChecker, semanticValidator, maxAttempts, null, null); - } - - /** - * Creates a validation pipeline without a template loading gate. - * - * @param ruleChecker rule-level checker used as the entry gate - * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters - * @param maxAttempts maximum number of retry attempts for the semantic validation step - * @param language language used to render failure messages, for example {@code zh-CN}; null falls back to - * {@code en-US} - * @throws NullPointerException if the rule checker or the semantic validator is null - */ - public ValidationPipeline( - @NonNull RuleChecker ruleChecker, - @NonNull SemanticValidator semanticValidator, - int maxAttempts, - @Nullable String language) { - this(ruleChecker, semanticValidator, maxAttempts, language, null); - } - - /** - * Creates a validation pipeline with an optional template loading gate. - * - *

        The loader, when injected, runs after the input gate and before semantic validation to resolve the template - * body for the {@link #validate(String, Map, Object)} variant. A {@code null} loader leaves only the preloaded - * {@link #validate(String, Map, Object, String)} variant available. - * - * @param ruleChecker rule-level checker used as the entry gate - * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters - * @param maxAttempts maximum number of retry attempts for the semantic validation step - * @param language language used to render failure messages, for example {@code zh-CN}; null falls back to - * {@code en-US} - * @param templateContentLoader optional loader resolving the template body, may be null - * @throws NullPointerException if the rule checker or the semantic validator is null - */ - public ValidationPipeline( - @NonNull RuleChecker ruleChecker, - @NonNull SemanticValidator semanticValidator, - int maxAttempts, - @Nullable String language, - @Nullable TemplateContentLoader templateContentLoader) { - this.ruleChecker = Objects.requireNonNull(ruleChecker, "ruleChecker"); - this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); - this.maxAttempts = maxAttempts; - this.language = language; - this.templateContentLoader = templateContentLoader; - } - - /** - * Validates one content prompt and extracts its filled parameters through the full pipeline, loading the template - * body via the injected template loading gate after the input gate and before semantic validation. - * - * @param prompt content prompt text - * @param schema caller-provided parameter JSON schema - * @param reference template addressing value the content is validated against - * @return filled parameter data carrying the merged parameters - * @throws ContentValidationException with {@code negotiation.invalid_input} if the prompt is null or blank, the - * schema is null, or the reference is null - * @throws IllegalStateException if no {@link TemplateContentLoader} was injected into this pipeline - * @throws ContentValidationException if the validation fails at any stage - */ - public @NonNull FilledParamData validate( - @NonNull String prompt, @NonNull Map schema, @NonNull T reference) { - if (templateContentLoader == null) { - throw new IllegalStateException( - "Template content loader is not configured; provide the template content explicitly."); - } - - Map contextParams = validateInputsAndRunRuleGate(prompt, schema, reference); - - String templateContent; - try { - templateContent = templateContentLoader.load(reference); - } catch (ResourceNotFoundException exception) { - throw templateNotFound(exception); - } - - return runSemanticValidationAndMerge(prompt, schema, reference, contextParams, templateContent); - } - - /** - * Validates one content prompt and extracts its filled parameters through the full pipeline. - * - * @param prompt content prompt text - * @param schema caller-provided parameter JSON schema - * @param reference template addressing value the content is validated against - * @param templateContent loaded template text used as a reference for structure/completeness checks - * @return filled parameter data carrying the merged parameters - * @throws ContentValidationException with {@code negotiation.invalid_input} if the prompt is null or blank, the - * schema is null, or the reference is null - * @throws ContentValidationException if the validation fails at any stage - */ - public @NonNull FilledParamData validate( - @NonNull String prompt, - @NonNull Map schema, - @NonNull T reference, - @NonNull String templateContent) { - Map contextParams = validateInputsAndRunRuleGate(prompt, schema, reference); - return runSemanticValidationAndMerge(prompt, schema, reference, contextParams, templateContent); - } - - private Map validateInputsAndRunRuleGate( - @NonNull String prompt, @NonNull Map schema, @NonNull T reference) { - if (schema == null) { - throw invalidInput("Parameter schema must not be null."); - } - if (prompt == null || prompt.isBlank()) { - throw invalidInput("Prompt must not be null or blank."); - } - if (reference == null) { - throw invalidInput("Template reference must not be null."); - } - return ruleChecker.check(prompt); - } - - private ContentValidationException invalidInput(String reason) { - Map facts = Map.of("reason", reason); - return new ContentValidationException( - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), - List.of(new SlotValidationError( - "_input", - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), - facts)), - null); - } - - private ContentValidationException templateNotFound(ResourceNotFoundException exception) { - String effectiveLanguage = language == null ? ErrorMessages.DEFAULT_LANGUAGE : language; - Map facts = - Map.of("template_uri", exception.resourcePath(), "language", effectiveLanguage); - return new ContentValidationException( - ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), - ErrorMessages.render(ErrorCatalog.TEMPLATE_NOT_FOUND, language, facts), - exception); - } - - private @NonNull FilledParamData runSemanticValidationAndMerge( - String prompt, - Map schema, - T reference, - Map contextParams, - String templateContent) { - ValidationResult semanticResult; - try { - semanticResult = RetryUtil.withRetry( - maxAttempts, - SEMANTIC_VALIDATION_STEP, - () -> semanticValidator.validate(prompt, schema, reference, templateContent)); - } catch (ResourceNotFoundException exception) { - throw templateNotFound(exception); - } catch (ContentValidationException exception) { - // already carries a final catalog code emitted by the semantic validator - throw exception; - } catch (RuntimeException exception) { - Map facts = Map.of("step", SEMANTIC_VALIDATION_STEP); - throw new ContentValidationException( - ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), - ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, language, facts), - List.of(new SlotValidationError( - "_llm", - ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), - ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, language, facts), - facts)), - exception); - } - - if (!semanticResult.verdict()) { - throw new ContentValidationException( - ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED, language, null), - semanticResult.errors(), - semanticResult.params(), - null); - } - - Map merged = mergeParams(contextParams, semanticResult.params()); - FilledParamData filledParamData = new FilledParamData(merged); - LOGGER.atInfo().log( - "content_validation_completed param_keys={}", - filledParamData.data().keySet()); - return filledParamData; - } - - private static Map mergeParams( - Map contextParams, Map semanticParams) { - Map merged = new LinkedHashMap<>(contextParams); - for (Map.Entry entry : semanticParams.entrySet()) { - String key = entry.getKey(); - if (merged.containsKey(key)) { - LOGGER.atWarn().log("content_param_merge_conflict key={} resolution=context_param_wins", key); - continue; - } - merged.put(key, entry.getValue()); - } - return Collections.unmodifiableMap(merged); - } -} +package net.openan.a2at.sdk.core.validation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Orchestrates the content validation pipeline: input validation, rule-level gate, retryable semantic validation and + * deterministic parameter merging. + * + *

        Parameter merging writes the context parameters first and the semantically extracted parameters second; on a key + * conflict the context parameter wins and a warning is logged, so the LLM output can never override the rule-level + * parsed values. + * + * @param template addressing type the validation is performed against, such as {@link TemplateUri} + * @since 2026-08 + */ +public final class ValidationPipeline { + + private static final Logger LOGGER = LoggerFactory.getLogger(ValidationPipeline.class); + + private static final String SEMANTIC_VALIDATION_STEP = "semantic_validation"; + + private final RuleChecker ruleChecker; + + private final SemanticValidator semanticValidator; + + private final int maxAttempts; + + private final String language; + + private final TemplateContentLoader templateContentLoader; + + /** + * Creates a validation pipeline without a template loading gate. + * + * @param ruleChecker rule-level checker used as the entry gate + * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters + * @param maxAttempts maximum number of retry attempts for the semantic validation step + * @throws NullPointerException if the rule checker or the semantic validator is null + */ + public ValidationPipeline( + @NonNull RuleChecker ruleChecker, @NonNull SemanticValidator semanticValidator, int maxAttempts) { + this(ruleChecker, semanticValidator, maxAttempts, null, null); + } + + /** + * Creates a validation pipeline without a template loading gate. + * + * @param ruleChecker rule-level checker used as the entry gate + * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters + * @param maxAttempts maximum number of retry attempts for the semantic validation step + * @param language language used to render failure messages, for example {@code zh-CN}; null falls back to + * {@code en-US} + * @throws NullPointerException if the rule checker or the semantic validator is null + */ + public ValidationPipeline( + @NonNull RuleChecker ruleChecker, + @NonNull SemanticValidator semanticValidator, + int maxAttempts, + @Nullable String language) { + this(ruleChecker, semanticValidator, maxAttempts, language, null); + } + + /** + * Creates a validation pipeline with an optional template loading gate. + * + *

        The loader, when injected, runs after the input gate and before semantic validation to resolve the template + * body for the {@link #validate(String, Map, Object)} variant. A {@code null} loader leaves only the preloaded + * {@link #validate(String, Map, Object, String)} variant available. + * + * @param ruleChecker rule-level checker used as the entry gate + * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters + * @param maxAttempts maximum number of retry attempts for the semantic validation step + * @param language language used to render failure messages, for example {@code zh-CN}; null falls back to + * {@code en-US} + * @param templateContentLoader optional loader resolving the template body, may be null + * @throws NullPointerException if the rule checker or the semantic validator is null + */ + public ValidationPipeline( + @NonNull RuleChecker ruleChecker, + @NonNull SemanticValidator semanticValidator, + int maxAttempts, + @Nullable String language, + @Nullable TemplateContentLoader templateContentLoader) { + this.ruleChecker = Objects.requireNonNull(ruleChecker, "ruleChecker"); + this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); + this.maxAttempts = maxAttempts; + this.language = language; + this.templateContentLoader = templateContentLoader; + } + + /** + * Validates one content prompt and extracts its filled parameters through the full pipeline, loading the template + * body via the injected template loading gate after the input gate and before semantic validation. + * + * @param prompt content prompt text + * @param schema caller-provided parameter JSON schema + * @param reference template addressing value the content is validated against + * @return filled parameter data carrying the merged parameters + * @throws ContentValidationException with {@code negotiation.invalid_input} if the prompt is null or blank, the + * schema is null, or the reference is null + * @throws IllegalStateException if no {@link TemplateContentLoader} was injected into this pipeline + * @throws ContentValidationException if the validation fails at any stage + */ + public @NonNull FilledParamData validate( + @NonNull String prompt, @NonNull Map schema, @NonNull T reference) { + if (templateContentLoader == null) { + throw new IllegalStateException( + "Template content loader is not configured; provide the template content explicitly."); + } + + Map contextParams = validateInputsAndRunRuleGate(prompt, schema, reference); + + String templateContent; + try { + templateContent = templateContentLoader.load(reference); + } catch (ResourceNotFoundException exception) { + throw templateNotFound(exception); + } + + return runSemanticValidationAndMerge(prompt, schema, reference, contextParams, templateContent); + } + + /** + * Validates one content prompt and extracts its filled parameters through the full pipeline. + * + * @param prompt content prompt text + * @param schema caller-provided parameter JSON schema + * @param reference template addressing value the content is validated against + * @param templateContent loaded template text used as a reference for structure/completeness checks + * @return filled parameter data carrying the merged parameters + * @throws ContentValidationException with {@code negotiation.invalid_input} if the prompt is null or blank, the + * schema is null, or the reference is null + * @throws ContentValidationException if the validation fails at any stage + */ + public @NonNull FilledParamData validate( + @NonNull String prompt, + @NonNull Map schema, + @NonNull T reference, + @NonNull String templateContent) { + Map contextParams = validateInputsAndRunRuleGate(prompt, schema, reference); + return runSemanticValidationAndMerge(prompt, schema, reference, contextParams, templateContent); + } + + private Map validateInputsAndRunRuleGate( + @NonNull String prompt, @NonNull Map schema, @NonNull T reference) { + if (schema == null) { + throw invalidInput("Parameter schema must not be null."); + } + if (prompt == null || prompt.isBlank()) { + throw invalidInput("Prompt must not be null or blank."); + } + if (reference == null) { + throw invalidInput("Template reference must not be null."); + } + return ruleChecker.check(prompt); + } + + private ContentValidationException invalidInput(String reason) { + Map facts = Map.of("reason", reason); + return new ContentValidationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), + List.of(new SlotValidationError( + "_input", + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), + facts)), + null); + } + + private ContentValidationException templateNotFound(ResourceNotFoundException exception) { + String effectiveLanguage = language == null ? ErrorMessages.DEFAULT_LANGUAGE : language; + Map facts = Map.of("template_uri", exception.resourcePath(), "language", effectiveLanguage); + return new ContentValidationException( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorMessages.render(ErrorCatalog.TEMPLATE_NOT_FOUND, language, facts), + exception); + } + + private @NonNull FilledParamData runSemanticValidationAndMerge( + String prompt, + Map schema, + T reference, + Map contextParams, + String templateContent) { + ValidationResult semanticResult; + try { + semanticResult = RetryUtil.withRetry( + maxAttempts, + SEMANTIC_VALIDATION_STEP, + () -> semanticValidator.validate(prompt, schema, reference, templateContent)); + } catch (ResourceNotFoundException exception) { + throw templateNotFound(exception); + } catch (ContentValidationException exception) { + // already carries a final catalog code emitted by the semantic validator + throw exception; + } catch (RuntimeException exception) { + Map facts = Map.of("step", SEMANTIC_VALIDATION_STEP); + throw new ContentValidationException( + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, language, facts), + List.of(new SlotValidationError( + "_llm", + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, language, facts), + facts)), + exception); + } + + if (!semanticResult.verdict()) { + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED, language, null), + semanticResult.errors(), + semanticResult.params(), + null); + } + + Map merged = mergeParams(contextParams, semanticResult.params()); + FilledParamData filledParamData = new FilledParamData(merged); + LOGGER.atInfo().log( + "content_validation_completed param_keys={}", + filledParamData.data().keySet()); + return filledParamData; + } + + private static Map mergeParams( + Map contextParams, Map semanticParams) { + Map merged = new LinkedHashMap<>(contextParams); + for (Map.Entry entry : semanticParams.entrySet()) { + String key = entry.getKey(); + if (merged.containsKey(key)) { + LOGGER.atWarn().log("content_param_merge_conflict key={} resolution=context_param_wins", key); + continue; + } + merged.put(key, entry.getValue()); + } + return Collections.unmodifiableMap(merged); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java index 1d2b14cd..30e8d217 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java @@ -1,281 +1,281 @@ -package net.openan.a2at.sdk.core.model; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import net.openan.a2at.sdk.core.exception.ConfigFileNotFoundException; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for {@link A2ATConfig}. - * - *

        Tests cover the following scenarios: - *

          - *
        • Loading unified SDK configuration from .env files
        • - *
        • Default values for missing configuration keys
        • - *
        • Error handling for missing configuration files
        • - *
        - * - * @since 2026-06 - */ -class A2ATConfigTest { - - /** - * Verifies that {@link A2ATConfig#load(Path)} correctly builds a unified configuration - * from a complete .env file with all keys specified. - * - *

        Scenario: A .env file contains all configuration keys for prompt, LLM, and negotiation. - * Expected result: All configuration values are correctly parsed and accessible via - * {@link A2ATConfig#prompt()}, {@link A2ATConfig#llm()}, and {@link A2ATConfig#negotiation()}. - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_buildUnifiedConfig_When_envFileContainsAllKeys() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-config"); - Path promptRoot = tempDir.resolve("prompt_resources"); - Files.createDirectories(promptRoot); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=local_file - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=gpt-4.1 - A2AT_LLM_API_KEY=test-key - A2AT_LLM_BASE_URL=https://api.openai.com/v1 - A2AT_LLM_HISTORY_WINDOW=12 - A2AT_LLM_MAX_TOKENS=1024 - A2AT_LLM_TEMPERATURE=0.3 - A2AT_LLM_TIMEOUT_SECONDS=15 - A2AT_LLM_SESSION_MAX_TOTAL=300 - A2AT_LLM_SESSION_MAX_PER_PROVIDER=100 - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """); - - A2ATConfig config = A2ATConfig.load(envFile); - - assertEquals("zh-CN", config.prompt().language()); - assertEquals("local_file", config.prompt().sourceType()); - assertEquals("prompt_resources", config.prompt().localRootDir()); - assertEquals("openai", config.llm().provider()); - assertEquals("gpt-4.1", config.llm().model()); - assertEquals("test-key", config.llm().apiKey()); - assertEquals("https://api.openai.com/v1", config.llm().baseUrl()); - assertEquals(12, config.llm().historyWindow()); - assertEquals(1024, config.llm().maxTokens()); - assertEquals(0.3d, config.llm().temperature()); - assertEquals(15.0d, config.llm().timeoutSeconds()); - assertEquals(300, config.llm().sessionMaxTotal()); - assertEquals(100, config.llm().sessionMaxPerProvider()); - assertEquals("in_memory", config.negotiation().stateStoreType()); - } - - /** - * Verifies that {@link A2ATConfig#load(Path)} applies default values for missing - * configuration keys. - * - *

        Scenario: A .env file contains only minimal required keys (LLM provider and negotiation). - * Expected result: Default values are applied for prompt configuration (classpath source type, - * unset local root directory). - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_useClasspathDefaults_When_promptKeysAreMissing() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-config-default"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LLM_PROVIDER=openai - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """); - - A2ATConfig config = A2ATConfig.load(envFile); - - assertEquals("classpath", config.prompt().sourceType()); - assertNull(config.prompt().localRootDir()); - } - - /** - * Verifies that {@link A2ATConfig#load(Path)} throws {@link ConfigFileNotFoundException} - * when the specified file does not exist. - * - *

        Scenario: Attempt to load configuration from a non-existent file path. - * Expected result: ConfigFileNotFoundException is thrown. - */ - @Test - void should_throwConfigFileNotFoundException_When_envFileDoesNotExist() { - Path missing = Path.of("build", "missing", "a2at.env"); - - assertThrows(ConfigFileNotFoundException.class, () -> A2ATConfig.load(missing)); - } - - /** - * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} resolves - * a relative local root directory to an absolute normalized path against the environment file parent. - * - *

        Scenario: A .env file configures a relative local root directory. Expected result: the returned config - * carries the local root resolved to an absolute normalized path relative to the .env file parent. - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_resolveRelativeLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-resolve-relative"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_PROMPT_SOURCE_TYPE=local_file - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=openai - """); - - A2ATConfig resolved = - A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); - - assertEquals( - tempDir.resolve("prompt_resources").toAbsolutePath().normalize().toString(), - resolved.prompt().localRootDir()); - } - - /** - * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} normalizes - * an already-absolute local root directory without re-resolving it against the environment file parent. - * - *

        Scenario: A .env file configures an absolute local root directory. Expected result: the returned config - * carries the normalized absolute path unchanged. - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_normalizeAbsoluteLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-resolve-absolute"); - Path absoluteRoot = tempDir.resolve(".").resolve("prompt_resources").toAbsolutePath(); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - "A2AT_PROMPT_SOURCE_TYPE=local_file\n" - + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=" + absoluteRoot + "\n" - + "A2AT_LLM_PROVIDER=openai\n"); - - A2ATConfig resolved = - A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); - - assertEquals(absoluteRoot.normalize().toString(), resolved.prompt().localRootDir()); - } - - /** - * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} preserves - * an unset (null) local root directory instead of throwing. - * - *

        Scenario: A .env file does not configure a local root directory (classpath source). Expected result: the - * returned config keeps the local root as null. - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_preserveNullLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-resolve-null"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LLM_PROVIDER=openai - """); - - A2ATConfig resolved = - A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); - - assertNull(resolved.prompt().localRootDir()); - } - - /** - * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} preserves - * a blank local root directory instead of resolving it to a legal absolute path. - * - *

        Scenario: A config carries a blank local root directory. Expected result: the method returns the original - * config unchanged, treating a blank root like an unset root (matching the downstream - * {@code PromptResourceAccess} and {@code PromptTemplateCatalog} "blank == unset" convention). - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_preserveBlankLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-resolve-blank"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\n"); - - A2ATConfig loaded = A2ATConfig.load(envFile); - A2ATConfig config = new A2ATConfig( - new PromptRuntimeConfig(loaded.prompt().language(), loaded.prompt().sourceType(), " "), - loaded.llm(), - loaded.inputLimits(), - loaded.negotiation(), - loaded.promptCompliance()); - - A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(config, envFile); - - assertSame(config, resolved); - assertEquals(" ", resolved.prompt().localRootDir()); - } - - /** - * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} does not throw when the - * {@code envPath} is a single-segment relative path with no parent, resolving the relative local root against the - * current working directory instead of a missing parent. - * - *

        Scenario: A single-segment relative {@code envPath} (e.g. {@code Path.of(".env")}) has no parent. Expected - * result: the returned config carries the local root resolved against the current working directory. - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_resolveRelativeLocalRootAgainstCwd_When_envPathIsSingleSegment() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-resolve-single-segment"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\n"); - - A2ATConfig loaded = A2ATConfig.load(envFile); - A2ATConfig config = new A2ATConfig( - new PromptRuntimeConfig(loaded.prompt().language(), "local_file", "prompt_resources"), - loaded.llm(), - loaded.inputLimits(), - loaded.negotiation(), - loaded.promptCompliance()); - - A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(config, Path.of(".env")); - - assertEquals( - Path.of("").toAbsolutePath().normalize().resolve("prompt_resources").normalize().toString(), - resolved.prompt().localRootDir()); - } - - /** - * Verifies that the repository's env.example file exists and optionally matches - * an upstream template. - * - *

        Scenario: Check for the existence of env.example in the repository root. - * If an upstream template exists, verify they match (ignoring line ending differences). - * - * @throws IOException if file reading fails - */ - @Test - void should_matchUpstreamTemplate_When_envExampleExists() throws IOException { - Path repoEnvExample = Path.of("..", "env.example").normalize(); - Path upstreamEnvExample = Path.of("..", ".upstream-src", "package_data", "env.example").normalize(); - - assertTrue(Files.exists(repoEnvExample), "repo root env.example should exist"); - if (Files.exists(upstreamEnvExample)) { - assertEquals(Files.readString(upstreamEnvExample).replace("\r\n", "\n"), - Files.readString(repoEnvExample).replace("\r\n", "\n")); - } - } - -} +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import net.openan.a2at.sdk.core.exception.ConfigFileNotFoundException; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link A2ATConfig}. + * + *

        Tests cover the following scenarios: + * + *

          + *
        • Loading unified SDK configuration from .env files + *
        • Default values for missing configuration keys + *
        • Error handling for missing configuration files + *
        + * + * @since 2026-06 + */ +class A2ATConfigTest { + + /** + * Verifies that {@link A2ATConfig#load(Path)} correctly builds a unified configuration from a complete .env file + * with all keys specified. + * + *

        Scenario: A .env file contains all configuration keys for prompt, LLM, and negotiation. Expected result: All + * configuration values are correctly parsed and accessible via {@link A2ATConfig#prompt()}, + * {@link A2ATConfig#llm()}, and {@link A2ATConfig#negotiation()}. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_buildUnifiedConfig_When_envFileContainsAllKeys() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-config"); + Path promptRoot = tempDir.resolve("prompt_resources"); + Files.createDirectories(promptRoot); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=gpt-4.1 + A2AT_LLM_API_KEY=test-key + A2AT_LLM_BASE_URL=https://api.openai.com/v1 + A2AT_LLM_HISTORY_WINDOW=12 + A2AT_LLM_MAX_TOKENS=1024 + A2AT_LLM_TEMPERATURE=0.3 + A2AT_LLM_TIMEOUT_SECONDS=15 + A2AT_LLM_SESSION_MAX_TOTAL=300 + A2AT_LLM_SESSION_MAX_PER_PROVIDER=100 + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """); + + A2ATConfig config = A2ATConfig.load(envFile); + + assertEquals("zh-CN", config.prompt().language()); + assertEquals("local_file", config.prompt().sourceType()); + assertEquals("prompt_resources", config.prompt().localRootDir()); + assertEquals("openai", config.llm().provider()); + assertEquals("gpt-4.1", config.llm().model()); + assertEquals("test-key", config.llm().apiKey()); + assertEquals("https://api.openai.com/v1", config.llm().baseUrl()); + assertEquals(12, config.llm().historyWindow()); + assertEquals(1024, config.llm().maxTokens()); + assertEquals(0.3d, config.llm().temperature()); + assertEquals(15.0d, config.llm().timeoutSeconds()); + assertEquals(300, config.llm().sessionMaxTotal()); + assertEquals(100, config.llm().sessionMaxPerProvider()); + assertEquals("in_memory", config.negotiation().stateStoreType()); + } + + /** + * Verifies that {@link A2ATConfig#load(Path)} applies default values for missing configuration keys. + * + *

        Scenario: A .env file contains only minimal required keys (LLM provider and negotiation). Expected result: + * Default values are applied for prompt configuration (classpath source type, unset local root directory). + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_useClasspathDefaults_When_promptKeysAreMissing() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-config-default"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LLM_PROVIDER=openai + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """); + + A2ATConfig config = A2ATConfig.load(envFile); + + assertEquals("classpath", config.prompt().sourceType()); + assertNull(config.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#load(Path)} throws {@link ConfigFileNotFoundException} when the specified file + * does not exist. + * + *

        Scenario: Attempt to load configuration from a non-existent file path. Expected result: + * ConfigFileNotFoundException is thrown. + */ + @Test + void should_throwConfigFileNotFoundException_When_envFileDoesNotExist() { + Path missing = Path.of("build", "missing", "a2at.env"); + + assertThrows(ConfigFileNotFoundException.class, () -> A2ATConfig.load(missing)); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} resolves a relative local + * root directory to an absolute normalized path against the environment file parent. + * + *

        Scenario: A .env file configures a relative local root directory. Expected result: the returned config carries + * the local root resolved to an absolute normalized path relative to the .env file parent. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_resolveRelativeLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-relative"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=openai + """); + + A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); + + assertEquals( + tempDir.resolve("prompt_resources").toAbsolutePath().normalize().toString(), + resolved.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} normalizes an + * already-absolute local root directory without re-resolving it against the environment file parent. + * + *

        Scenario: A .env file configures an absolute local root directory. Expected result: the returned config + * carries the normalized absolute path unchanged. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_normalizeAbsoluteLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-absolute"); + Path absoluteRoot = tempDir.resolve(".").resolve("prompt_resources").toAbsolutePath(); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + "A2AT_PROMPT_SOURCE_TYPE=local_file\n" + + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=" + absoluteRoot + "\n" + + "A2AT_LLM_PROVIDER=openai\n"); + + A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); + + assertEquals(absoluteRoot.normalize().toString(), resolved.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} preserves an unset (null) + * local root directory instead of throwing. + * + *

        Scenario: A .env file does not configure a local root directory (classpath source). Expected result: the + * returned config keeps the local root as null. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_preserveNullLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-null"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, """ + A2AT_LLM_PROVIDER=openai + """); + + A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); + + assertNull(resolved.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} preserves a blank local root + * directory instead of resolving it to a legal absolute path. + * + *

        Scenario: A config carries a blank local root directory. Expected result: the method returns the original + * config unchanged, treating a blank root like an unset root (matching the downstream {@code PromptResourceAccess} + * and {@code PromptTemplateCatalog} "blank == unset" convention). + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_preserveBlankLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-blank"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\n"); + + A2ATConfig loaded = A2ATConfig.load(envFile); + A2ATConfig config = new A2ATConfig( + new PromptRuntimeConfig( + loaded.prompt().language(), loaded.prompt().sourceType(), " "), + loaded.llm(), + loaded.inputLimits(), + loaded.negotiation(), + loaded.promptCompliance()); + + A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(config, envFile); + + assertSame(config, resolved); + assertEquals(" ", resolved.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} does not throw when the + * {@code envPath} is a single-segment relative path with no parent, resolving the relative local root against the + * current working directory instead of a missing parent. + * + *

        Scenario: A single-segment relative {@code envPath} (e.g. {@code Path.of(".env")}) has no parent. Expected + * result: the returned config carries the local root resolved against the current working directory. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_resolveRelativeLocalRootAgainstCwd_When_envPathIsSingleSegment() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-single-segment"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\n"); + + A2ATConfig loaded = A2ATConfig.load(envFile); + A2ATConfig config = new A2ATConfig( + new PromptRuntimeConfig(loaded.prompt().language(), "local_file", "prompt_resources"), + loaded.llm(), + loaded.inputLimits(), + loaded.negotiation(), + loaded.promptCompliance()); + + A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(config, Path.of(".env")); + + assertEquals( + Path.of("") + .toAbsolutePath() + .normalize() + .resolve("prompt_resources") + .normalize() + .toString(), + resolved.prompt().localRootDir()); + } + + /** + * Verifies that the repository's env.example file exists and optionally matches an upstream template. + * + *

        Scenario: Check for the existence of env.example in the repository root. If an upstream template exists, + * verify they match (ignoring line ending differences). + * + * @throws IOException if file reading fails + */ + @Test + void should_matchUpstreamTemplate_When_envExampleExists() throws IOException { + Path repoEnvExample = Path.of("..", "env.example").normalize(); + Path upstreamEnvExample = + Path.of("..", ".upstream-src", "package_data", "env.example").normalize(); + + assertTrue(Files.exists(repoEnvExample), "repo root env.example should exist"); + if (Files.exists(upstreamEnvExample)) { + assertEquals( + Files.readString(upstreamEnvExample).replace("\r\n", "\n"), + Files.readString(repoEnvExample).replace("\r\n", "\n")); + } + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java index 3250ee1d..c507dbd8 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java @@ -1,63 +1,61 @@ -package net.openan.a2at.sdk.core.model; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -import java.util.Map; -import org.junit.jupiter.api.Test; - -/** - * Unit tests for {@link PromptRuntimeConfig}. - * - *

        Tests cover the following scenarios: - *

          - *
        • Default values when configuration keys are missing
        • - *
        • Overriding defaults with environment variable values
        • - *
        - * - * @since 2026-06 - */ -class PromptRuntimeConfigTest { - - /** - * Verifies that {@link PromptRuntimeConfig#fromMap(Map)} applies default values - * when no configuration keys are provided. - * - *

        Scenario: An empty map is passed to fromMap(). - * Expected result: All fields use predefined defaults: - * - language: "en-US" - * - sourceType: "classpath" - * - localRootDir: null (unset) - */ - @Test - void should_useDefaults_When_keysAreMissing() { - Map values = Map.of(); - - PromptRuntimeConfig config = PromptRuntimeConfig.fromMap(values); - - assertEquals("en-US", config.language()); - assertEquals("classpath", config.sourceType()); - assertNull(config.localRootDir()); - } - - /** - * Verifies that {@link PromptRuntimeConfig#fromMap(Map)} overrides default values - * with values from the provided map. - * - *

        Scenario: A map containing language, source type, and local root directory. - * Expected result: All fields use the values from the map instead of defaults. - */ - @Test - void should_overrideDefaults_When_keysAreProvided() { - Map values = Map.of( - "A2AT_LANGUAGE", "zh-CN", - "A2AT_PROMPT_SOURCE_TYPE", "local_file", - "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR", "/path/to/prompts"); - - PromptRuntimeConfig config = PromptRuntimeConfig.fromMap(values); - - assertEquals("zh-CN", config.language()); - assertEquals("local_file", config.sourceType()); - assertEquals("/path/to/prompts", config.localRootDir()); - } -} \ No newline at end of file +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link PromptRuntimeConfig}. + * + *

        Tests cover the following scenarios: + * + *

          + *
        • Default values when configuration keys are missing + *
        • Overriding defaults with environment variable values + *
        + * + * @since 2026-06 + */ +class PromptRuntimeConfigTest { + + /** + * Verifies that {@link PromptRuntimeConfig#fromMap(Map)} applies default values when no configuration keys are + * provided. + * + *

        Scenario: An empty map is passed to fromMap(). Expected result: All fields use predefined defaults: - + * language: "en-US" - sourceType: "classpath" - localRootDir: null (unset) + */ + @Test + void should_useDefaults_When_keysAreMissing() { + Map values = Map.of(); + + PromptRuntimeConfig config = PromptRuntimeConfig.fromMap(values); + + assertEquals("en-US", config.language()); + assertEquals("classpath", config.sourceType()); + assertNull(config.localRootDir()); + } + + /** + * Verifies that {@link PromptRuntimeConfig#fromMap(Map)} overrides default values with values from the provided + * map. + * + *

        Scenario: A map containing language, source type, and local root directory. Expected result: All fields use + * the values from the map instead of defaults. + */ + @Test + void should_overrideDefaults_When_keysAreProvided() { + Map values = Map.of( + "A2AT_LANGUAGE", "zh-CN", + "A2AT_PROMPT_SOURCE_TYPE", "local_file", + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR", "/path/to/prompts"); + + PromptRuntimeConfig config = PromptRuntimeConfig.fromMap(values); + + assertEquals("zh-CN", config.language()); + assertEquals("local_file", config.sourceType()); + assertEquals("/path/to/prompts", config.localRootDir()); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java index 9628b7f1..0070d0c0 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java @@ -1,53 +1,52 @@ -package net.openan.a2at.sdk.core.model; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -import org.junit.jupiter.api.Test; - -/** - * Unit tests for {@link PromptTemplate}. - * - *

        Tests cover the four-component record shape and the {@code source()} accessor that identifies the effective - * resource origin ({@code classpath} / {@code local}). - * - * @since 2026-08 - */ -class PromptTemplateTest { - - /** - * Verifies that {@link PromptTemplate} exposes all four components. - * - *

        Scenario: Construct a template with a URI, description, content and a {@code classpath} source. - * Expected result: templateUri(), description(), content() and source() each return the supplied value. - */ - @Test - void should_exposeAllFourComponents_When_templateConstructedWithClasspathSource() { - TemplateUri templateUri = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); - - PromptTemplate template = new PromptTemplate(templateUri, "energy saving", "# template", "classpath"); - - assertEquals(templateUri, template.templateUri()); - assertEquals("energy saving", template.description()); - assertEquals("# template", template.content()); - assertEquals("classpath", template.source()); - } - - /** - * Verifies that {@link PromptTemplate#source()} carries the {@code local} source value. - * - *

        Scenario: Construct a template with a {@code local} source. - * Expected result: source() returns {@code local}. - */ - @Test - void should_exposeLocalSource_When_templateConstructedWithLocalSource() { - TemplateUri templateUri = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); - - PromptTemplate template = new PromptTemplate(templateUri, "", null, "local"); - - assertEquals(templateUri, template.templateUri()); - assertEquals("", template.description()); - assertNull(template.content()); - assertEquals("local", template.source()); - } -} +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link PromptTemplate}. + * + *

        Tests cover the four-component record shape and the {@code source()} accessor that identifies the effective + * resource origin ({@code classpath} / {@code local}). + * + * @since 2026-08 + */ +class PromptTemplateTest { + + /** + * Verifies that {@link PromptTemplate} exposes all four components. + * + *

        Scenario: Construct a template with a URI, description, content and a {@code classpath} source. Expected + * result: templateUri(), description(), content() and source() each return the supplied value. + */ + @Test + void should_exposeAllFourComponents_When_templateConstructedWithClasspathSource() { + TemplateUri templateUri = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); + + PromptTemplate template = new PromptTemplate(templateUri, "energy saving", "# template", "classpath"); + + assertEquals(templateUri, template.templateUri()); + assertEquals("energy saving", template.description()); + assertEquals("# template", template.content()); + assertEquals("classpath", template.source()); + } + + /** + * Verifies that {@link PromptTemplate#source()} carries the {@code local} source value. + * + *

        Scenario: Construct a template with a {@code local} source. Expected result: source() returns {@code local}. + */ + @Test + void should_exposeLocalSource_When_templateConstructedWithLocalSource() { + TemplateUri templateUri = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); + + PromptTemplate template = new PromptTemplate(templateUri, "", null, "local"); + + assertEquals(templateUri, template.templateUri()); + assertEquals("", template.description()); + assertNull(template.content()); + assertEquals("local", template.source()); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java index 428d04b5..52206c42 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java @@ -1,131 +1,131 @@ -package net.openan.a2at.sdk.core.resources; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -class PathSegmentsTest { - - @Test - void should_rejectNull_When_valueIsNull() { - assertFalse(PathSegments.isSimpleSegment(null)); - } - - @Test - void should_rejectBlank_When_valueIsBlank() { - assertFalse(PathSegments.isSimpleSegment("")); - assertFalse(PathSegments.isSimpleSegment(" ")); - } - - @Test - void should_rejectForwardSlash_When_valueContainsSlash() { - assertFalse(PathSegments.isSimpleSegment("a/b")); - assertFalse(PathSegments.isSimpleSegment("/")); - } - - @Test - void should_rejectBackslash_When_valueContainsBackslash() { - assertFalse(PathSegments.isSimpleSegment("a\\b")); - assertFalse(PathSegments.isSimpleSegment("\\")); - } - - @Test - void should_rejectTraversal_When_valueContainsDotDot() { - assertFalse(PathSegments.isSimpleSegment("..")); - assertFalse(PathSegments.isSimpleSegment("a/../b")); - } - - @Test - void should_acceptValidSegment_When_valueIsSimple() { - assertTrue(PathSegments.isSimpleSegment("ran-energy-saving")); - assertTrue(PathSegments.isSimpleSegment("Task-T")); - assertTrue(PathSegments.isSimpleSegment("zh-CN")); - assertTrue(PathSegments.isSimpleSegment("a.b")); - } - - @Test - void requireSimpleSegment_rejectsNullWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment(null, "the label")); - - assertTrue(exception.getMessage().contains("the label")); - } - - @Test - void requireSimpleSegment_rejectsBlankWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment(" ", "the label")); - - assertTrue(exception.getMessage().contains("the label")); - } - - @Test - void requireSimpleSegment_rejectsSlashWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("a/b", "the label")); - - assertTrue(exception.getMessage().contains("the label")); - } - - @Test - void requireSimpleSegment_rejectsBackslashWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("a\\b", "the label")); - - assertTrue(exception.getMessage().contains("the label")); - } - - @Test - void requireSimpleSegment_rejectsTraversalWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("..", "the label")); - - assertTrue(exception.getMessage().contains("the label")); - } - - @Test - void requireSimpleSegment_acceptsValidValues() { - assertDoesNotThrow(() -> { - PathSegments.requireSimpleSegment("ran-energy-saving", "segment"); - PathSegments.requireSimpleSegment("Task-T", "segment"); - PathSegments.requireSimpleSegment("zh-CN", "segment"); - PathSegments.requireSimpleSegment("a.b", "segment"); - }); - } - - @Test - void requireSimpleRelativePath_rejectsEmptySegmentsWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath("a//b", "the path")); - - assertTrue(exception.getMessage().contains("the path")); - } - - @Test - void requireSimpleRelativePath_rejectsTraversalSegmentWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath("../x", "the path")); - - assertTrue(exception.getMessage().contains("the path")); - } - - @Test - void requireSimpleRelativePath_rejectsNullWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath(null, "the path")); - - assertTrue(exception.getMessage().contains("the path")); - } - - @Test - void requireSimpleRelativePath_acceptsValidValues() { - assertDoesNotThrow(() -> { - PathSegments.requireSimpleRelativePath("a/b/c", "path"); - PathSegments.requireSimpleRelativePath("Task-T/network-layer/ran-energy-saving/v1", "path"); - PathSegments.requireSimpleRelativePath("zh-CN", "path"); - }); - } -} +package net.openan.a2at.sdk.core.resources; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class PathSegmentsTest { + + @Test + void should_rejectNull_When_valueIsNull() { + assertFalse(PathSegments.isSimpleSegment(null)); + } + + @Test + void should_rejectBlank_When_valueIsBlank() { + assertFalse(PathSegments.isSimpleSegment("")); + assertFalse(PathSegments.isSimpleSegment(" ")); + } + + @Test + void should_rejectForwardSlash_When_valueContainsSlash() { + assertFalse(PathSegments.isSimpleSegment("a/b")); + assertFalse(PathSegments.isSimpleSegment("/")); + } + + @Test + void should_rejectBackslash_When_valueContainsBackslash() { + assertFalse(PathSegments.isSimpleSegment("a\\b")); + assertFalse(PathSegments.isSimpleSegment("\\")); + } + + @Test + void should_rejectTraversal_When_valueContainsDotDot() { + assertFalse(PathSegments.isSimpleSegment("..")); + assertFalse(PathSegments.isSimpleSegment("a/../b")); + } + + @Test + void should_acceptValidSegment_When_valueIsSimple() { + assertTrue(PathSegments.isSimpleSegment("ran-energy-saving")); + assertTrue(PathSegments.isSimpleSegment("Task-T")); + assertTrue(PathSegments.isSimpleSegment("zh-CN")); + assertTrue(PathSegments.isSimpleSegment("a.b")); + } + + @Test + void requireSimpleSegment_rejectsNullWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment(null, "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_rejectsBlankWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment(" ", "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_rejectsSlashWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("a/b", "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_rejectsBackslashWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("a\\b", "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_rejectsTraversalWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("..", "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_acceptsValidValues() { + assertDoesNotThrow(() -> { + PathSegments.requireSimpleSegment("ran-energy-saving", "segment"); + PathSegments.requireSimpleSegment("Task-T", "segment"); + PathSegments.requireSimpleSegment("zh-CN", "segment"); + PathSegments.requireSimpleSegment("a.b", "segment"); + }); + } + + @Test + void requireSimpleRelativePath_rejectsEmptySegmentsWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath("a//b", "the path")); + + assertTrue(exception.getMessage().contains("the path")); + } + + @Test + void requireSimpleRelativePath_rejectsTraversalSegmentWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath("../x", "the path")); + + assertTrue(exception.getMessage().contains("the path")); + } + + @Test + void requireSimpleRelativePath_rejectsNullWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath(null, "the path")); + + assertTrue(exception.getMessage().contains("the path")); + } + + @Test + void requireSimpleRelativePath_acceptsValidValues() { + assertDoesNotThrow(() -> { + PathSegments.requireSimpleRelativePath("a/b/c", "path"); + PathSegments.requireSimpleRelativePath("Task-T/network-layer/ran-energy-saving/v1", "path"); + PathSegments.requireSimpleRelativePath("zh-CN", "path"); + }); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java index 22b0ee03..4071e191 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java @@ -1,163 +1,164 @@ -package net.openan.a2at.sdk.corpus; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; -import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; - -/** - * Real-builder assembly of the three closed-loop task APIs (Q21): the corpus harness drives exactly the production - * wiring the {@code A2ATClient} and {@code A2ATServer} facades drive internally, so a change of the production - * assembly cannot drift past the corpus. - * - *

          - *
        • {@code generateTaskPromptFromText} and {@code generateTaskPromptFromDataWithSchema} run through the - * {@link ClientPromptGenerationOrchestrator} built by {@link DefaultA2ATClientBuilder#buildPromptGenerationOrchestrator()} - * — the same build method the client facade constructor calls;
        • - *
        • {@code validateTaskPromptAndDataFilling} runs through the {@link ContentValidator} built by - * {@link DefaultA2ATServerBuilder#buildTaskContentValidator()} — the same build method the server facade - * constructor calls.
        • - *
        - * - *

        Both builders are driven exactly like the facades drive them: a minimal classpath-source {@code .env} (written once - * per language and retry limit into a temporary directory, following the facade test precedent) is loaded through - * {@link A2ATConfig#load(Path)} plus {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)}, - * and the {@link ScriptedNegotiationLlmClient} is injected through the builders' {@code llmClient(...)} seam — the same - * instance feeds the client-side and the server-side components, so the {@code llmCalls} expectation counts the whole - * closed loop. The facade constructors are locked to the {@code Path}-only signature by the API-surface guard tests, so - * the builders are the direct route to the identical assembly. - * - * @since 2026-08 - */ -final class TaskApiAssembler { - - private static final Map ENV_FILES = new ConcurrentHashMap<>(); - - private final ClientPromptGenerationOrchestrator promptGeneration; - - private final ContentValidator taskValidator; - - /** - * Assembles the task API wiring for one language. - * - * @param language language of the generated and validated task prompts, such as {@code zh-CN} - * @param maxAttempts retry limit of the LLM steps, mirroring the builder default of 3 - * @param llmClient scripted LLM client injected at the same seam the facade builders inject their real client - */ - TaskApiAssembler(String language, int maxAttempts, LLMClient llmClient) { - this(minimalEnvFor(language, maxAttempts), llmClient); - } - - /** - * Assembles the task API wiring around an explicit {@code .env} file — the seam of the live family, whose harness - * hands in the {@code LiveLlmEnvWriter} bridge (real test-endpoint values plus the explicit stability knobs and the - * pipeline retry limit) instead of the scripted minimal env. - * - * @param envPath the {@code .env} file the config is loaded from, carrying the language and retry limit - * @param llmClient LLM client injected at the same seam the facade builders inject their real client - */ - TaskApiAssembler(Path envPath, LLMClient llmClient) { - A2ATConfig config = - A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); - this.promptGeneration = DefaultA2ATClientBuilder.builder() - .config(config) - .envPath(envPath) - .llmClient(llmClient) - .buildPromptGenerationOrchestrator(); - this.taskValidator = DefaultA2ATServerBuilder.builder() - .config(config) - .envPath(envPath) - .llmClient(llmClient) - .buildTaskContentValidator(); - } - - /** - * Generates a task prompt with metadata from natural-language input through the real client orchestrator, the - * pipeline behind {@code A2ATClient.generateTaskPromptFromText}. - * - * @param text natural-language task input - * @param templateUri template URI identifying the target task template - * @return metadata content carrying the resolved template URI, rendered prompt text and Task-T extension URI - */ - MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) { - return promptGeneration.generateTaskPromptFromText(text, templateUri); - } - - /** - * Generates a task prompt with metadata from structured input and a data schema through the real client - * orchestrator, the pipeline behind {@code A2ATClient.generateTaskPromptFromDataWithSchema}. - * - * @param data structured task input as a string-to-object map - * @param schema data schema map describing the meaning of each input field - * @param templateUri template URI identifying the target task template - * @return metadata content carrying the resolved template URI, rendered prompt text and Task-T extension URI - */ - MetadataContent generateTaskPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) { - return promptGeneration.generateTaskPromptFromDataWithSchema(data, schema, templateUri); - } - - /** - * Validates a task prompt and extracts its filled parameters through the real server-side task content validator, - * the pipeline behind {@code A2ATServer.validateTaskPromptAndDataFilling}. - * - *

        A schema slot the prompt misses surfaces as a null-valued entry of the returned parameter data — that set of - * null-valued keys is the missing-parameter set the negotiation loop then fills. - * - * @param prompt rendered task prompt text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected task template - * @return filled parameter data carrying the extracted parameters; null values mark missing parameters - */ - FilledParamData validateTaskPromptAndDataFilling(String prompt, Map schema, TemplateUri templateUri) { - return taskValidator.validate(prompt, schema, templateUri); - } - - // ------------------------------------------------------------------ minimal facade env - - /** - * Writes (once per language and retry limit) the minimal classpath-source {@code .env} the facades would be handed, - * following the {@code A2ATClientTest} minimal-env precedent; the LLM entries are inert because the scripted client - * is injected at the builders' LLM seam. - * - * @param language language of the corpus case - * @param maxAttempts retry limit of the LLM steps from the case's LLM script - * @return path of the written {@code .env} file - */ - private static Path minimalEnvFor(String language, int maxAttempts) { - return ENV_FILES.computeIfAbsent(language + "/" + maxAttempts, key -> { - try { - Path envFile = Files.createTempDirectory("a2at-corpus-task-env").resolve("client.env"); - Files.writeString( - envFile, - ("A2AT_LANGUAGE=%s%n" - + "A2AT_PROMPT_SOURCE_TYPE=classpath%n" - + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=%n" - + "A2AT_LLM_PROVIDER=openai%n" - + "A2AT_LLM_MODEL=scripted-model%n" - + "A2AT_LLM_BASE_URL=https://llm.example.test/v1%n" - + "A2AT_LLM_API_KEY=corpus-scripted%n" - + "A2AT_LLM_MAX_ATTEMPTS=%d%n" - + "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory%n") - .formatted(language, maxAttempts), - StandardCharsets.UTF_8); - return envFile; - } catch (IOException exception) { - throw new UncheckedIOException( - "Failed to write the minimal corpus .env for language " + language, exception); - } - }); - } -} +package net.openan.a2at.sdk.corpus; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; + +/** + * Real-builder assembly of the three closed-loop task APIs (Q21): the corpus harness drives exactly the production + * wiring the {@code A2ATClient} and {@code A2ATServer} facades drive internally, so a change of the production assembly + * cannot drift past the corpus. + * + *

          + *
        • {@code generateTaskPromptFromText} and {@code generateTaskPromptFromDataWithSchema} run through the + * {@link ClientPromptGenerationOrchestrator} built by + * {@link DefaultA2ATClientBuilder#buildPromptGenerationOrchestrator()} — the same build method the client facade + * constructor calls; + *
        • {@code validateTaskPromptAndDataFilling} runs through the {@link ContentValidator} built by + * {@link DefaultA2ATServerBuilder#buildTaskContentValidator()} — the same build method the server facade + * constructor calls. + *
        + * + *

        Both builders are driven exactly like the facades drive them: a minimal classpath-source {@code .env} (written + * once per language and retry limit into a temporary directory, following the facade test precedent) is loaded through + * {@link A2ATConfig#load(Path)} plus {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)}, and the + * {@link ScriptedNegotiationLlmClient} is injected through the builders' {@code llmClient(...)} seam — the same + * instance feeds the client-side and the server-side components, so the {@code llmCalls} expectation counts the whole + * closed loop. The facade constructors are locked to the {@code Path}-only signature by the API-surface guard tests, so + * the builders are the direct route to the identical assembly. + * + * @since 2026-08 + */ +final class TaskApiAssembler { + + private static final Map ENV_FILES = new ConcurrentHashMap<>(); + + private final ClientPromptGenerationOrchestrator promptGeneration; + + private final ContentValidator taskValidator; + + /** + * Assembles the task API wiring for one language. + * + * @param language language of the generated and validated task prompts, such as {@code zh-CN} + * @param maxAttempts retry limit of the LLM steps, mirroring the builder default of 3 + * @param llmClient scripted LLM client injected at the same seam the facade builders inject their real client + */ + TaskApiAssembler(String language, int maxAttempts, LLMClient llmClient) { + this(minimalEnvFor(language, maxAttempts), llmClient); + } + + /** + * Assembles the task API wiring around an explicit {@code .env} file — the seam of the live family, whose harness + * hands in the {@code LiveLlmEnvWriter} bridge (real test-endpoint values plus the explicit stability knobs and the + * pipeline retry limit) instead of the scripted minimal env. + * + * @param envPath the {@code .env} file the config is loaded from, carrying the language and retry limit + * @param llmClient LLM client injected at the same seam the facade builders inject their real client + */ + TaskApiAssembler(Path envPath, LLMClient llmClient) { + A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); + this.promptGeneration = DefaultA2ATClientBuilder.builder() + .config(config) + .envPath(envPath) + .llmClient(llmClient) + .buildPromptGenerationOrchestrator(); + this.taskValidator = DefaultA2ATServerBuilder.builder() + .config(config) + .envPath(envPath) + .llmClient(llmClient) + .buildTaskContentValidator(); + } + + /** + * Generates a task prompt with metadata from natural-language input through the real client orchestrator, the + * pipeline behind {@code A2ATClient.generateTaskPromptFromText}. + * + * @param text natural-language task input + * @param templateUri template URI identifying the target task template + * @return metadata content carrying the resolved template URI, rendered prompt text and Task-T extension URI + */ + MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) { + return promptGeneration.generateTaskPromptFromText(text, templateUri); + } + + /** + * Generates a task prompt with metadata from structured input and a data schema through the real client + * orchestrator, the pipeline behind {@code A2ATClient.generateTaskPromptFromDataWithSchema}. + * + * @param data structured task input as a string-to-object map + * @param schema data schema map describing the meaning of each input field + * @param templateUri template URI identifying the target task template + * @return metadata content carrying the resolved template URI, rendered prompt text and Task-T extension URI + */ + MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) { + return promptGeneration.generateTaskPromptFromDataWithSchema(data, schema, templateUri); + } + + /** + * Validates a task prompt and extracts its filled parameters through the real server-side task content validator, + * the pipeline behind {@code A2ATServer.validateTaskPromptAndDataFilling}. + * + *

        A schema slot the prompt misses surfaces as a null-valued entry of the returned parameter data — that set of + * null-valued keys is the missing-parameter set the negotiation loop then fills. + * + * @param prompt rendered task prompt text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected task template + * @return filled parameter data carrying the extracted parameters; null values mark missing parameters + */ + FilledParamData validateTaskPromptAndDataFilling( + String prompt, Map schema, TemplateUri templateUri) { + return taskValidator.validate(prompt, schema, templateUri); + } + + // ------------------------------------------------------------------ minimal facade env + + /** + * Writes (once per language and retry limit) the minimal classpath-source {@code .env} the facades would be handed, + * following the {@code A2ATClientTest} minimal-env precedent; the LLM entries are inert because the scripted client + * is injected at the builders' LLM seam. + * + * @param language language of the corpus case + * @param maxAttempts retry limit of the LLM steps from the case's LLM script + * @return path of the written {@code .env} file + */ + private static Path minimalEnvFor(String language, int maxAttempts) { + return ENV_FILES.computeIfAbsent(language + "/" + maxAttempts, key -> { + try { + Path envFile = Files.createTempDirectory("a2at-corpus-task-env").resolve("client.env"); + Files.writeString( + envFile, + ("A2AT_LANGUAGE=%s%n" + + "A2AT_PROMPT_SOURCE_TYPE=classpath%n" + + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=%n" + + "A2AT_LLM_PROVIDER=openai%n" + + "A2AT_LLM_MODEL=scripted-model%n" + + "A2AT_LLM_BASE_URL=https://llm.example.test/v1%n" + + "A2AT_LLM_API_KEY=corpus-scripted%n" + + "A2AT_LLM_MAX_ATTEMPTS=%d%n" + + "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory%n") + .formatted(language, maxAttempts), + StandardCharsets.UTF_8); + return envFile; + } catch (IOException exception) { + throw new UncheckedIOException( + "Failed to write the minimal corpus .env for language " + language, exception); + } + }); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java index 0281848c..8ae4103e 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java @@ -1,180 +1,179 @@ -package net.openan.a2at.sdk.corpus.property; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.corpus.LlmFailMarker; -import net.openan.a2at.sdk.corpus.LlmScriptStep; -import net.openan.a2at.sdk.corpus.ScriptedNegotiationLlmClient; - -/** - * Shared plumbing of the jqwik property layer: production-wired service assembly through the real builder, JSON - * payload construction and the scripted-LLM seam reused from the corpus testdata package. - * - *

        Mirroring the {@code CaseEngine} discipline, everything except the LLM client is production assembly; the - * property layer only decides inputs and expected invariants. - * - * @since 2026-08 - */ -final class PropertyHarness { - - /** Default attempt limit of the property runs; the retry partition property depends on this exact value. */ - static final int MAX_ATTEMPTS = 3; - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private PropertyHarness() {} - - /** - * Assembles the production negotiation content service for one property run. - * - * @param language message language such as {@code zh-CN} - * @param llmClient scripted LLM client of the run - * @return production-wired negotiation content service - */ - static NegotiationContentService service(String language, LLMClient llmClient) { - return new NegotiationContentService(orchestrator(language, llmClient)); - } - - /** - * Assembles the production service with a template loader that fails every load, the property-layer stand-in of - * the corpus {@code inject: failingTemplateLoader} hook. - * - * @param language message language - * @param llmClient scripted LLM client of the run - * @return production-wired service whose template loader always misses - */ - static NegotiationContentService serviceWithFailingTemplateLoader(String language, LLMClient llmClient) { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .llmClient(llmClient) - .maxAttempts(MAX_ATTEMPTS) - .templateLoader(failingTemplateLoader()) - .build(); - return new NegotiationContentService(orchestrator); - } - - private static NegotiationGenerationOrchestrator orchestrator(String language, LLMClient llmClient) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .llmClient(llmClient) - .maxAttempts(MAX_ATTEMPTS) - .build(); - } - - private static NegotiationTemplateLoader failingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - }; - } - - /** - * Parses one raw template URI string. - * - * @param raw template URI such as {@code Negotiation-T/information-negotiation/propose/v1} - * @return parsed template URI - */ - static TemplateUri templateUri(String raw) { - return TemplateUri.parse(raw) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); - } - - /** - * Serializes one value into the JSON payload text of a scripted LLM step. - * - * @param value payload value - * @return JSON text - */ - static String json(Object value) { - try { - return MAPPER.writeValueAsString(value); - } catch (JsonProcessingException exception) { - throw new IllegalStateException("The property payload could not be serialized.", exception); - } - } - - /** - * Builds the semantic-validation payload of an accepting verdict carrying the given extracted parameters. - * - * @param negotiationType negotiation type name the verdict reports, or null for the type-independent abort phase - * @param params extracted parameters the scripted validator returns - * @return JSON payload text of the scripted semantic-validation answer - */ - static String semanticVerdict(String negotiationType, Map params) { - Map payload = new LinkedHashMap<>(); - payload.put("semantic_verdict", true); - payload.put("negotiation_type", negotiationType); - payload.put("errors", List.of()); - payload.put("params", params); - return json(payload); - } - - /** - * Creates a scripted client answering exactly one payload step. - * - * @param payloadJson payload text of the single answer - * @return strictly consuming scripted client - */ - static ScriptedNegotiationLlmClient scripted(String payloadJson) { - return new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Payload(payloadJson))); - } - - /** - * Creates a scripted client replaying one failure marker for every attempt of the run. - * - * @param marker failure marker to replay - * @return strictly consuming scripted client that always fails - */ - static ScriptedNegotiationLlmClient failing(LlmFailMarker marker) { - return new ScriptedNegotiationLlmClient( - java.util.stream.IntStream.rangeClosed(1, MAX_ATTEMPTS) - .mapToObj(ignored -> (LlmScriptStep) new LlmScriptStep.Fail(marker)) - .toList()); - } - - /** - * Builds a flat object JSON Schema over the given properties. - * - * @param properties per-key type schemas keyed by parameter name - * @return caller parameter schema of a validate run - */ - static Map objectSchema(Map properties) { - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - return schema; - } - - /** - * Derives the JSON Schema type of one parameter value. - * - * @param value parameter value generated by a property - * @return single-key type schema - */ - static Map typeSchema(Object value) { - if (value instanceof String) { - return Map.of("type", "string"); - } - if (value instanceof Boolean) { - return Map.of("type", "boolean"); - } - if (value instanceof Double) { - return Map.of("type", "number"); - } - return Map.of("type", "integer"); - } -} +package net.openan.a2at.sdk.corpus.property; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.corpus.LlmFailMarker; +import net.openan.a2at.sdk.corpus.LlmScriptStep; +import net.openan.a2at.sdk.corpus.ScriptedNegotiationLlmClient; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; + +/** + * Shared plumbing of the jqwik property layer: production-wired service assembly through the real builder, JSON payload + * construction and the scripted-LLM seam reused from the corpus testdata package. + * + *

        Mirroring the {@code CaseEngine} discipline, everything except the LLM client is production assembly; the property + * layer only decides inputs and expected invariants. + * + * @since 2026-08 + */ +final class PropertyHarness { + + /** Default attempt limit of the property runs; the retry partition property depends on this exact value. */ + static final int MAX_ATTEMPTS = 3; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private PropertyHarness() {} + + /** + * Assembles the production negotiation content service for one property run. + * + * @param language message language such as {@code zh-CN} + * @param llmClient scripted LLM client of the run + * @return production-wired negotiation content service + */ + static NegotiationContentService service(String language, LLMClient llmClient) { + return new NegotiationContentService(orchestrator(language, llmClient)); + } + + /** + * Assembles the production service with a template loader that fails every load, the property-layer stand-in of the + * corpus {@code inject: failingTemplateLoader} hook. + * + * @param language message language + * @param llmClient scripted LLM client of the run + * @return production-wired service whose template loader always misses + */ + static NegotiationContentService serviceWithFailingTemplateLoader(String language, LLMClient llmClient) { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .llmClient(llmClient) + .maxAttempts(MAX_ATTEMPTS) + .templateLoader(failingTemplateLoader()) + .build(); + return new NegotiationContentService(orchestrator); + } + + private static NegotiationGenerationOrchestrator orchestrator(String language, LLMClient llmClient) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .llmClient(llmClient) + .maxAttempts(MAX_ATTEMPTS) + .build(); + } + + private static NegotiationTemplateLoader failingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + }; + } + + /** + * Parses one raw template URI string. + * + * @param raw template URI such as {@code Negotiation-T/information-negotiation/propose/v1} + * @return parsed template URI + */ + static TemplateUri templateUri(String raw) { + return TemplateUri.parse(raw) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); + } + + /** + * Serializes one value into the JSON payload text of a scripted LLM step. + * + * @param value payload value + * @return JSON text + */ + static String json(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("The property payload could not be serialized.", exception); + } + } + + /** + * Builds the semantic-validation payload of an accepting verdict carrying the given extracted parameters. + * + * @param negotiationType negotiation type name the verdict reports, or null for the type-independent abort phase + * @param params extracted parameters the scripted validator returns + * @return JSON payload text of the scripted semantic-validation answer + */ + static String semanticVerdict(String negotiationType, Map params) { + Map payload = new LinkedHashMap<>(); + payload.put("semantic_verdict", true); + payload.put("negotiation_type", negotiationType); + payload.put("errors", List.of()); + payload.put("params", params); + return json(payload); + } + + /** + * Creates a scripted client answering exactly one payload step. + * + * @param payloadJson payload text of the single answer + * @return strictly consuming scripted client + */ + static ScriptedNegotiationLlmClient scripted(String payloadJson) { + return new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Payload(payloadJson))); + } + + /** + * Creates a scripted client replaying one failure marker for every attempt of the run. + * + * @param marker failure marker to replay + * @return strictly consuming scripted client that always fails + */ + static ScriptedNegotiationLlmClient failing(LlmFailMarker marker) { + return new ScriptedNegotiationLlmClient(java.util.stream.IntStream.rangeClosed(1, MAX_ATTEMPTS) + .mapToObj(ignored -> (LlmScriptStep) new LlmScriptStep.Fail(marker)) + .toList()); + } + + /** + * Builds a flat object JSON Schema over the given properties. + * + * @param properties per-key type schemas keyed by parameter name + * @return caller parameter schema of a validate run + */ + static Map objectSchema(Map properties) { + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + return schema; + } + + /** + * Derives the JSON Schema type of one parameter value. + * + * @param value parameter value generated by a property + * @return single-key type schema + */ + static Map typeSchema(Object value) { + if (value instanceof String) { + return Map.of("type", "string"); + } + if (value instanceof Boolean) { + return Map.of("type", "boolean"); + } + if (value instanceof Double) { + return Map.of("type", "number"); + } + return Map.of("type", "integer"); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java index f65757e4..08f0c357 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java @@ -1,231 +1,231 @@ -package net.openan.a2at.sdk.negotiation.content; - -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; -import net.openan.a2at.sdk.core.resources.PathSegments; -import org.jspecify.annotations.Nullable; - -/** - * Language-specific text constants for negotiation templates. - * - *

        A vocabulary is the single source of the section titles, slot names, appended line labels and list punctuation - * used when rendering negotiation messages and when recognising sections inside received messages. The canonical keys - * are language-neutral; every supported language exposes exactly the same key set, while the values match the bundled - * template bytes of that language verbatim. - * - *

        The values are file-driven: each language resolves {@code negotiation-vocabulary/{language}/vocabulary.json} from - * the built-in classpath resources — negotiation vocabularies are classpath-fixed and never configurable. - * The bundled bytes are validated against the pinned {@link #CANONICAL_KEYS} set, and a vocabulary that does not exist, - * cannot be read, is malformed (including duplicate JSON keys or blank values) or drifts from the canonical key set - * fails fast instead of silently degrading. - * - *

        The built-in classpath vocabulary is resolved once per JVM and context classloader and frozen as an assembly-time - * snapshot (jar bytes are immutable). - * - * @since 2026-08 - */ -public final class Vocabulary { - - private static final String VOCABULARY_DIRECTORY = "negotiation-vocabulary"; - - private static final String VOCABULARY_FILE_NAME = "vocabulary.json"; - - private static final String CLASSPATH_ROOT = "prompt_resources/" + VOCABULARY_DIRECTORY + "/"; - - private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() - .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) - .build(); - - private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); - - /** The language-neutral canonical key set every vocabulary file must define exactly, in a fixed order. */ - public static final List CANONICAL_KEYS = List.of( - "section.termination_reason", - "section.info_items", - "section.info_static", - "section.info_conclusion", - "section.info_result_content", - "section.target", - "section.target_intent", - "section.target_alignment", - "section.target_clarification", - "section.target_confirm_request", - "section.target_conclusion", - "section.target_result_content", - "section.feasibility", - "section.feasibility_evaluate", - "section.feasibility_infeasible", - "section.feasibility_confirm_request", - "section.feasibility_conclusion", - "section.feasibility_confirm", - "slot.termination_reason", - "slot.info_items", - "slot.info_conclusion", - "slot.info_result_content", - "slot.target", - "slot.target_intent", - "slot.target_alignment", - "slot.target_clarification", - "slot.target_confirm_request", - "slot.target_conclusion", - "slot.target_result_content", - "slot.feasibility", - "slot.feasibility_evaluate", - "slot.feasibility_infeasible", - "slot.feasibility_confirm_request", - "slot.feasibility_conclusion", - "slot.feasibility_confirm", - "label.relationship", - "punct.list_colon"); - - private static final Set CANONICAL_KEY_SET = Set.copyOf(CANONICAL_KEYS); - - private final String language; - - private final Map entries; - - private Vocabulary(String language, Map entries) { - this.language = language; - this.entries = entries; - } - - /** - * Returns the vocabulary for one language, resolved and frozen from the built-in classpath resources. - * - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @return vocabulary holding the text constants of that language - * @throws IllegalArgumentException if the language has no bundled vocabulary, or the bundled file is unreadable, - * malformed or does not define exactly the canonical key set - */ - public static Vocabulary forLanguage(String language) { - if (!PathSegments.isSimpleSegment(language)) { - throw new IllegalArgumentException( - "Negotiation vocabulary language must be a non-blank simple path segment but was " + language - + "."); - } - CacheKey cacheKey = new CacheKey(language, Thread.currentThread().getContextClassLoader()); - return CACHE.computeIfAbsent(cacheKey, key -> load(key.language)); - } - - /** - * Returns the text constant registered under one canonical key. - * - * @param canonicalKey canonical vocabulary key such as {@code section.termination_reason} or - * {@code punct.list_colon} - * @return language-specific text constant - * @throws IllegalArgumentException if the key is not part of the vocabulary - */ - public String get(String canonicalKey) { - String value = entries.get(canonicalKey); - if (value == null) { - throw new IllegalArgumentException( - "Unknown negotiation vocabulary key " + canonicalKey + " for language " + language + "."); - } - return value; - } - - /** - * Returns all canonical keys exposed by this vocabulary. - * - * @return immutable set of canonical keys, identical for every supported language - */ - public Set canonicalKeys() { - return entries.keySet(); - } - - /** - * Returns the language this vocabulary is bound to. - * - * @return locale identifier such as {@code zh-CN} - */ - public String language() { - return language; - } - - private static Vocabulary load(String language) { - String classpathPath = CLASSPATH_ROOT + language + "/" + VOCABULARY_FILE_NAME; - InputStream stream = ClasspathResourceStreams.open(classpathPath); - if (stream == null) { - throw new IllegalArgumentException("Negotiation vocabulary does not exist for the configured language " - + language + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly."); - } - try (stream) { - return parse(language, new String(stream.readAllBytes(), StandardCharsets.UTF_8), classpathPath); - } catch (IOException exception) { - throw new IllegalArgumentException( - "Failed to read the negotiation vocabulary " + classpathPath + " for language " + language - + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly.", - exception); - } - } - - private static Vocabulary parse(String language, String payload, String origin) { - JsonNode root; - try { - root = OBJECT_MAPPER.readTree(payload); - } catch (JsonProcessingException exception) { - String detail = - exception.getMessage() != null && exception.getMessage().contains("Duplicate field") - ? "it contains duplicate keys" - : "it is not valid JSON"; - throw malformed(language, origin, detail, exception); - } - if (root == null || !root.isObject()) { - throw malformed(language, origin, "it is not a flat JSON object of string values", null); - } - Map entries = new LinkedHashMap<>(); - root.fields().forEachRemaining(field -> { - JsonNode value = field.getValue(); - if (!value.isTextual()) { - throw malformed(language, origin, "the value of key '" + field.getKey() + "' is not a string", null); - } - String text = value.textValue(); - if (text.isBlank()) { - throw malformed(language, origin, "the value of key '" + field.getKey() + "' is blank", null); - } - entries.put(field.getKey(), text); - }); - validateCanonicalKeys(language, entries, origin); - return new Vocabulary(language, Collections.unmodifiableMap(entries)); - } - - private static void validateCanonicalKeys(String language, Map entries, String origin) { - Set missing = new LinkedHashSet<>(CANONICAL_KEYS); - missing.removeAll(entries.keySet()); - Set unexpected = new LinkedHashSet<>(entries.keySet()); - unexpected.removeAll(CANONICAL_KEY_SET); - if (missing.isEmpty() && unexpected.isEmpty()) { - return; - } - throw new IllegalArgumentException("Negotiation vocabulary " + origin + " for language " + language - + " must define exactly the canonical vocabulary keys; missing keys: " + missing - + ", unexpected keys: " + unexpected + "."); - } - - private static IllegalArgumentException malformed( - String language, String origin, String detail, @Nullable Throwable cause) { - IllegalArgumentException exception = new IllegalArgumentException( - "Negotiation vocabulary " + origin + " for language " + language + " is malformed: " + detail - + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly."); - if (cause != null) { - exception.initCause(cause); - } - return exception; - } - - private record CacheKey(String language, @Nullable ClassLoader contextClassLoader) {} -} +package net.openan.a2at.sdk.negotiation.content; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.core.resources.PathSegments; +import org.jspecify.annotations.Nullable; + +/** + * Language-specific text constants for negotiation templates. + * + *

        A vocabulary is the single source of the section titles, slot names, appended line labels and list punctuation + * used when rendering negotiation messages and when recognising sections inside received messages. The canonical keys + * are language-neutral; every supported language exposes exactly the same key set, while the values match the bundled + * template bytes of that language verbatim. + * + *

        The values are file-driven: each language resolves {@code negotiation-vocabulary/{language}/vocabulary.json} from + * the built-in classpath resources — negotiation vocabularies are classpath-fixed and never configurable. The bundled + * bytes are validated against the pinned {@link #CANONICAL_KEYS} set, and a vocabulary that does not exist, cannot be + * read, is malformed (including duplicate JSON keys or blank values) or drifts from the canonical key set fails fast + * instead of silently degrading. + * + *

        The built-in classpath vocabulary is resolved once per JVM and context classloader and frozen as an assembly-time + * snapshot (jar bytes are immutable). + * + * @since 2026-08 + */ +public final class Vocabulary { + + private static final String VOCABULARY_DIRECTORY = "negotiation-vocabulary"; + + private static final String VOCABULARY_FILE_NAME = "vocabulary.json"; + + private static final String CLASSPATH_ROOT = "prompt_resources/" + VOCABULARY_DIRECTORY + "/"; + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() + .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .build(); + + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + /** The language-neutral canonical key set every vocabulary file must define exactly, in a fixed order. */ + public static final List CANONICAL_KEYS = List.of( + "section.termination_reason", + "section.info_items", + "section.info_static", + "section.info_conclusion", + "section.info_result_content", + "section.target", + "section.target_intent", + "section.target_alignment", + "section.target_clarification", + "section.target_confirm_request", + "section.target_conclusion", + "section.target_result_content", + "section.feasibility", + "section.feasibility_evaluate", + "section.feasibility_infeasible", + "section.feasibility_confirm_request", + "section.feasibility_conclusion", + "section.feasibility_confirm", + "slot.termination_reason", + "slot.info_items", + "slot.info_conclusion", + "slot.info_result_content", + "slot.target", + "slot.target_intent", + "slot.target_alignment", + "slot.target_clarification", + "slot.target_confirm_request", + "slot.target_conclusion", + "slot.target_result_content", + "slot.feasibility", + "slot.feasibility_evaluate", + "slot.feasibility_infeasible", + "slot.feasibility_confirm_request", + "slot.feasibility_conclusion", + "slot.feasibility_confirm", + "label.relationship", + "punct.list_colon"); + + private static final Set CANONICAL_KEY_SET = Set.copyOf(CANONICAL_KEYS); + + private final String language; + + private final Map entries; + + private Vocabulary(String language, Map entries) { + this.language = language; + this.entries = entries; + } + + /** + * Returns the vocabulary for one language, resolved and frozen from the built-in classpath resources. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @return vocabulary holding the text constants of that language + * @throws IllegalArgumentException if the language has no bundled vocabulary, or the bundled file is unreadable, + * malformed or does not define exactly the canonical key set + */ + public static Vocabulary forLanguage(String language) { + if (!PathSegments.isSimpleSegment(language)) { + throw new IllegalArgumentException( + "Negotiation vocabulary language must be a non-blank simple path segment but was " + language + + "."); + } + CacheKey cacheKey = new CacheKey(language, Thread.currentThread().getContextClassLoader()); + return CACHE.computeIfAbsent(cacheKey, key -> load(key.language)); + } + + /** + * Returns the text constant registered under one canonical key. + * + * @param canonicalKey canonical vocabulary key such as {@code section.termination_reason} or + * {@code punct.list_colon} + * @return language-specific text constant + * @throws IllegalArgumentException if the key is not part of the vocabulary + */ + public String get(String canonicalKey) { + String value = entries.get(canonicalKey); + if (value == null) { + throw new IllegalArgumentException( + "Unknown negotiation vocabulary key " + canonicalKey + " for language " + language + "."); + } + return value; + } + + /** + * Returns all canonical keys exposed by this vocabulary. + * + * @return immutable set of canonical keys, identical for every supported language + */ + public Set canonicalKeys() { + return entries.keySet(); + } + + /** + * Returns the language this vocabulary is bound to. + * + * @return locale identifier such as {@code zh-CN} + */ + public String language() { + return language; + } + + private static Vocabulary load(String language) { + String classpathPath = CLASSPATH_ROOT + language + "/" + VOCABULARY_FILE_NAME; + InputStream stream = ClasspathResourceStreams.open(classpathPath); + if (stream == null) { + throw new IllegalArgumentException("Negotiation vocabulary does not exist for the configured language " + + language + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly."); + } + try (stream) { + return parse(language, new String(stream.readAllBytes(), StandardCharsets.UTF_8), classpathPath); + } catch (IOException exception) { + throw new IllegalArgumentException( + "Failed to read the negotiation vocabulary " + classpathPath + " for language " + language + + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly.", + exception); + } + } + + private static Vocabulary parse(String language, String payload, String origin) { + JsonNode root; + try { + root = OBJECT_MAPPER.readTree(payload); + } catch (JsonProcessingException exception) { + String detail = + exception.getMessage() != null && exception.getMessage().contains("Duplicate field") + ? "it contains duplicate keys" + : "it is not valid JSON"; + throw malformed(language, origin, detail, exception); + } + if (root == null || !root.isObject()) { + throw malformed(language, origin, "it is not a flat JSON object of string values", null); + } + Map entries = new LinkedHashMap<>(); + root.fields().forEachRemaining(field -> { + JsonNode value = field.getValue(); + if (!value.isTextual()) { + throw malformed(language, origin, "the value of key '" + field.getKey() + "' is not a string", null); + } + String text = value.textValue(); + if (text.isBlank()) { + throw malformed(language, origin, "the value of key '" + field.getKey() + "' is blank", null); + } + entries.put(field.getKey(), text); + }); + validateCanonicalKeys(language, entries, origin); + return new Vocabulary(language, Collections.unmodifiableMap(entries)); + } + + private static void validateCanonicalKeys(String language, Map entries, String origin) { + Set missing = new LinkedHashSet<>(CANONICAL_KEYS); + missing.removeAll(entries.keySet()); + Set unexpected = new LinkedHashSet<>(entries.keySet()); + unexpected.removeAll(CANONICAL_KEY_SET); + if (missing.isEmpty() && unexpected.isEmpty()) { + return; + } + throw new IllegalArgumentException("Negotiation vocabulary " + origin + " for language " + language + + " must define exactly the canonical vocabulary keys; missing keys: " + missing + + ", unexpected keys: " + unexpected + "."); + } + + private static IllegalArgumentException malformed( + String language, String origin, String detail, @Nullable Throwable cause) { + IllegalArgumentException exception = new IllegalArgumentException( + "Negotiation vocabulary " + origin + " for language " + language + " is malformed: " + detail + + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly."); + if (cause != null) { + exception.initCause(cause); + } + return exception; + } + + private record CacheKey(String language, @Nullable ClassLoader contextClassLoader) {} +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index cc15354f..3924b2fc 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -1,315 +1,318 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Shared service for the negotiation content layer consumed by both the client and the server facade. - * - *

        Each facade method delegates to exactly one service method, so the negotiation content-layer surface is defined - * once instead of being copy-pasted per facade. The service itself is a thin typing over the - * {@link NegotiationGenerationOrchestrator} pipeline and adds no behavior. - * - * @since 2026-08 - */ -public final class NegotiationContentService { - - private final NegotiationGenerationOrchestrator orchestrator; - - /** - * Creates one service over the given negotiation generation orchestrator. - * - * @param orchestrator negotiation generation orchestrator carrying the actual pipelines - */ - public NegotiationContentService(@NonNull NegotiationGenerationOrchestrator orchestrator) { - this.orchestrator = Objects.requireNonNull(orchestrator, "Negotiation orchestrator must not be null."); - } - - /** - * Assembles the default negotiation generation orchestrator from the unified SDK config. - * - *

        The wiring is shared by the client and the server builder: the message language comes from the prompt runtime - * config (negotiation templates and vocabularies are classpath-fixed), the retry attempt limit comes from the LLM - * config, and the LLM client is passed by the caller and may be null when the provider is {@code local_rule}. - * - * @param config unified SDK config - * @param llmClient LLM client for the LLM-backed steps; null keeps those steps unavailable - * @return assembled negotiation generation orchestrator - */ - public static NegotiationGenerationOrchestrator buildOrchestrator( - @NonNull A2ATConfig config, @Nullable LLMClient llmClient) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(config.prompt().language()) - .llmClient(llmClient) - .maxAttempts(config.llm().maxAttempts()) - .maxTextChars(config.inputLimits().maxTextChars()) - .build(); - } - - /** - * Generates a propose negotiation message from typed data, deterministically without any LLM call. - * - * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI whose performative segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content - * type - * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when a required content - * field is blank or empty, or {@code negotiation.invalid_input} when the content combines a non-blank confirm - * request with conditional sections (target: the three clarification lists; feasibility: either conditional - * list or the alternative action) - * @throws NegotiationGenerationException with the code {@code template.not_found} or - * {@code negotiation.field_missing} when loading or rendering the template fails - */ - public MetadataContent generateProposeFromData( - @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { - return orchestrator.generateProposeFromData(data, templateUri); - } - - /** - * Generates an accept negotiation message from typed data, deterministically without any LLM call. - * - * @param data typed terminal input whose content conclusion must be {@code Accept} - * @param templateUri template URI whose performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content - * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content - * conclusion is not {@code Accept}, or {@code negotiation.content_invalid} when a required content field is - * blank or empty - * @throws NegotiationGenerationException with the code {@code template.not_found} or - * {@code negotiation.field_missing} when loading or rendering the template fails - */ - public MetadataContent generateAcceptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - return orchestrator.generateAcceptFromData(data, templateUri); - } - - /** - * Generates a reject negotiation message from typed data, deterministically without any LLM call. - * - * @param data typed terminal input whose content conclusion must be {@code Reject} - * @param templateUri template URI whose performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content - * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content - * conclusion is not {@code Reject}, or {@code negotiation.content_invalid} when a required content field is - * blank or empty - * @throws NegotiationGenerationException with the code {@code template.not_found} or - * {@code negotiation.field_missing} when loading or rendering the template fails - */ - public MetadataContent generateRejectFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - return orchestrator.generateRejectFromData(data, templateUri); - } - - /** - * Generates an abort negotiation message from typed data, deterministically without any LLM call. - * - * @param data typed abort input carrying the termination reason - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when the termination - * reason is blank - * @throws NegotiationGenerationException with the code {@code template.not_found} or - * {@code negotiation.field_missing} when loading or rendering the template fails - */ - public MetadataContent generateAbortFromData( - @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { - return orchestrator.generateAbortFromData(data, templateUri); - } - - /** - * Generates a propose negotiation message from free text through one LLM content-extraction step. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement - * @param templateUri template URI whose performative segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationGenerationException with the code {@code template.not_found}, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} - * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses a - * required field, or {@code negotiation.invalid_input} when the text is blank, the extracted content - * contradicts the performative or the extracted confirm request is combined with conditional sections or the - * wrong feasibility action, or {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateProposeFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return orchestrator.generateProposeFromText(text, context, templateUri); - } - - /** - * Generates an accept negotiation message from free text through one LLM content-extraction step. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement - * @param templateUri template URI whose performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationGenerationException with the code {@code template.not_found}, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} - * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses a - * required field, or {@code negotiation.invalid_input} when the text is blank, - * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Accept}, or - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateAcceptFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return orchestrator.generateAcceptFromText(text, context, templateUri); - } - - /** - * Generates a reject negotiation message from free text through one LLM content-extraction step. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement - * @param templateUri template URI whose performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationGenerationException with the code {@code template.not_found}, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} - * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses a - * required field, or {@code negotiation.invalid_input} when the text is blank, - * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Reject}, or - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateRejectFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return orchestrator.generateRejectFromText(text, context, templateUri); - } - - /** - * Generates an abort negotiation message from free text through one LLM content-extraction step. - * - * @param text free-text input stating the termination reason - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationGenerationException with the code {@code template.not_found}, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} - * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses the - * termination reason, or {@code negotiation.invalid_input} when the text is blank, or - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateAbortFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return orchestrator.generateAbortFromText(text, context, templateUri); - } - - /** - * Validates a propose negotiation message and extracts its parameters. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI whose performative segment must be {@code propose} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, - * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or - * {@code template.not_found} when the validation pipeline fails - */ - public FilledParamData validateProposePromptAndDataFilling( - String prompt, - @Nullable NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return orchestrator.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates an accept negotiation message and extracts its parameters. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI whose performative segment must be {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, - * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or - * {@code template.not_found} when the validation pipeline fails - */ - public FilledParamData validateAcceptPromptAndDataFilling( - String prompt, - @Nullable NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return orchestrator.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates a reject negotiation message and extracts its parameters. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI whose performative segment must be {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, - * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or - * {@code template.not_found} when the validation pipeline fails - */ - public FilledParamData validateRejectPromptAndDataFilling( - String prompt, - @Nullable NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return orchestrator.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates an abort negotiation message and extracts its parameters. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is - * reported as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationParamExtractionException with the code {@code input.text_too_long} when the prompt exceeds - * the configured maximum length, or another validation pipeline failure code otherwise - */ - public FilledParamData validateAbortPromptAndDataFilling( - String prompt, - @Nullable NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return orchestrator.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Shared service for the negotiation content layer consumed by both the client and the server facade. + * + *

        Each facade method delegates to exactly one service method, so the negotiation content-layer surface is defined + * once instead of being copy-pasted per facade. The service itself is a thin typing over the + * {@link NegotiationGenerationOrchestrator} pipeline and adds no behavior. + * + * @since 2026-08 + */ +public final class NegotiationContentService { + + private final NegotiationGenerationOrchestrator orchestrator; + + /** + * Creates one service over the given negotiation generation orchestrator. + * + * @param orchestrator negotiation generation orchestrator carrying the actual pipelines + */ + public NegotiationContentService(@NonNull NegotiationGenerationOrchestrator orchestrator) { + this.orchestrator = Objects.requireNonNull(orchestrator, "Negotiation orchestrator must not be null."); + } + + /** + * Assembles the default negotiation generation orchestrator from the unified SDK config. + * + *

        The wiring is shared by the client and the server builder: the message language comes from the prompt runtime + * config (negotiation templates and vocabularies are classpath-fixed), the retry attempt limit comes from the LLM + * config, and the LLM client is passed by the caller and may be null when the provider is {@code local_rule}. + * + * @param config unified SDK config + * @param llmClient LLM client for the LLM-backed steps; null keeps those steps unavailable + * @return assembled negotiation generation orchestrator + */ + public static NegotiationGenerationOrchestrator buildOrchestrator( + @NonNull A2ATConfig config, @Nullable LLMClient llmClient) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(config.prompt().language()) + .llmClient(llmClient) + .maxAttempts(config.llm().maxAttempts()) + .maxTextChars(config.inputLimits().maxTextChars()) + .build(); + } + + /** + * Generates a propose negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri template URI whose performative segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content + * type + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when a required content + * field is blank or empty, or {@code negotiation.invalid_input} when the content combines a non-blank confirm + * request with conditional sections (target: the three clarification lists; feasibility: either conditional + * list or the alternative action) + * @throws NegotiationGenerationException with the code {@code template.not_found} or + * {@code negotiation.field_missing} when loading or rendering the template fails + */ + public MetadataContent generateProposeFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { + return orchestrator.generateProposeFromData(data, templateUri); + } + + /** + * Generates an accept negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed terminal input whose content conclusion must be {@code Accept} + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Accept}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} or + * {@code negotiation.field_missing} when loading or rendering the template fails + */ + public MetadataContent generateAcceptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + return orchestrator.generateAcceptFromData(data, templateUri); + } + + /** + * Generates a reject negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed terminal input whose content conclusion must be {@code Reject} + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Reject}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} or + * {@code negotiation.field_missing} when loading or rendering the template fails + */ + public MetadataContent generateRejectFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + return orchestrator.generateRejectFromData(data, templateUri); + } + + /** + * Generates an abort negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed abort input carrying the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when the termination + * reason is blank + * @throws NegotiationGenerationException with the code {@code template.not_found} or + * {@code negotiation.field_missing} when loading or rendering the template fails + */ + public MetadataContent generateAbortFromData(@NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + return orchestrator.generateAbortFromData(data, templateUri); + } + + /** + * Generates a propose negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI whose performative segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses a + * required field, or {@code negotiation.invalid_input} when the text is blank, the extracted content + * contradicts the performative or the extracted confirm request is combined with conditional sections or the + * wrong feasibility action, or {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateProposeFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return orchestrator.generateProposeFromText(text, context, templateUri); + } + + /** + * Generates an accept negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses a + * required field, or {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Accept}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateAcceptFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return orchestrator.generateAcceptFromText(text, context, templateUri); + } + + /** + * Generates a reject negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses a + * required field, or {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Reject}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateRejectFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return orchestrator.generateRejectFromText(text, context, templateUri); + } + + /** + * Generates an abort negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input stating the termination reason + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses the + * termination reason, or {@code negotiation.invalid_input} when the text is blank, or + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateAbortFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return orchestrator.generateAbortFromText(text, context, templateUri); + } + + /** + * Validates a propose negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI whose performative segment must be {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, + * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or + * {@code template.not_found} when the validation pipeline fails + */ + public FilledParamData validateProposePromptAndDataFilling( + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates an accept negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, + * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or + * {@code template.not_found} when the validation pipeline fails + */ + public FilledParamData validateAcceptPromptAndDataFilling( + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates a reject negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, + * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or + * {@code template.not_found} when the validation pipeline fails + */ + public FilledParamData validateRejectPromptAndDataFilling( + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationParamExtractionException with the code {@code input.text_too_long} when the prompt exceeds the + * configured maximum length, or another validation pipeline failure code otherwise + */ + public FilledParamData validateAbortPromptAndDataFilling( + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 65b57ded..fce467b0 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -1,649 +1,650 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.function.Supplier; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.ExtensionUriConstants; -import net.openan.a2at.sdk.core.model.InputLimitConfig; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; -import org.jspecify.annotations.NonNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Orchestrates the negotiation content layer: deterministic message generation, single-step LLM content extraction and - * the validation-plus-parameter-extraction pipeline. - * - *

        The orchestrator owns the retry loop of every LLM step. A step failing with one of the retryable codes - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} is re-run - * up to the configured attempt limit; the exhaustion failure rethrows the original error code. Internal exceptions of - * the generation and validation pipelines never bubble out raw: render failures become generation failures carrying - * {@code template.render_failed}, semantic validation infrastructure failures become parameter-extraction failures - * carrying {@code llm.invocation_failed} or {@code llm.response_invalid}, and resource load misses become the code - * {@code template.not_found} on both pipelines. - * - *

        Instances are created through {@link NegotiationGenerationOrchestratorBuilder}; the builder wires the default - * collaborators and allows overriding each of them. - * - * @since 2026-08 - */ -public final class NegotiationGenerationOrchestrator { - - private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(NegotiationGenerationOrchestrator.class); - - private static final String STEP_CONTENT_EXTRACT = "negotiation_content_extract"; - - private final String language; - - private final int maxAttempts; - - private final int maxTextChars; - - private final NegotiationTemplateLoader templateLoader; - - private final NegotiationContentExtractor contentExtractor; - - private final ParamExtractor paramExtractor; - - private final NegotiationGeneratorRegistry generatorRegistry; - - private final Vocabulary vocabulary; - - private final Logger logger; - - NegotiationGenerationOrchestrator( - String language, - int maxAttempts, - int maxTextChars, - NegotiationTemplateLoader templateLoader, - NegotiationContentExtractor contentExtractor, - ParamExtractor paramExtractor, - NegotiationGeneratorRegistry generatorRegistry, - Vocabulary vocabulary, - Logger logger) { - this.language = language; - this.maxAttempts = maxAttempts; - this.maxTextChars = maxTextChars; - this.templateLoader = templateLoader; - this.contentExtractor = contentExtractor; - this.paramExtractor = paramExtractor; - this.generatorRegistry = generatorRegistry; - this.vocabulary = vocabulary; - this.logger = logger == null ? DEFAULT_LOGGER : logger; - } - - /** - * Generates a propose-phase negotiation message from typed data. - * - *

        This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the - * generator of the negotiation type addressed by the template URI and rendered from that template. - * - * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase - * segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content type - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the - * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateProposeFromData( - @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "Negotiation propose data must not be null."); - return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.PROPOSE); - } - - /** - * Generates an accept-phase negotiation message from typed data. - * - *

        This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content - * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content - * conclusion is not {@code Accept}, or {@code negotiation.content_invalid} when a required content field is - * blank or empty - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the - * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateAcceptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "Negotiation ending data must not be null."); - return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.ACCEPT); - } - - /** - * Generates a reject-phase negotiation message from typed data. - * - *

        This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content - * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content - * conclusion is not {@code Reject}, or {@code negotiation.content_invalid} when a required content field is - * blank or empty - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the - * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateRejectFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "Negotiation ending data must not be null."); - return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.REJECT); - } - - /** - * Generates an abort negotiation message from typed data. - * - *

        This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed - * template must be the common abort template and the content carries only the termination reason. - * - * @param data typed abort input carrying the negotiation context and the termination reason - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when the termination - * reason is blank - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the - * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateAbortFromData( - @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "Negotiation abort data must not be null."); - return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.ABORT); - } - - /** - * Generates a propose-phase negotiation message from free text. - * - *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment - * must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation.content_extract_failed} or - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after - * exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required - * field, or {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the - * phase, or {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateProposeFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return generateFromText(text, context, templateUri, NegotiationPerformative.PROPOSE); - } - - /** - * Generates an accept-phase negotiation message from free text. - * - *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Accept}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation.content_extract_failed} or - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after - * exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required - * field, or {@code negotiation.invalid_input} when the text is blank, - * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Accept}, or - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateAcceptFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return generateFromText(text, context, templateUri, NegotiationPerformative.ACCEPT); - } - - /** - * Generates a reject-phase negotiation message from free text. - * - *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Reject}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation.content_extract_failed} or - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after - * exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required - * field, or {@code negotiation.invalid_input} when the text is blank, - * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Reject}, or - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateRejectFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return generateFromText(text, context, templateUri, NegotiationPerformative.REJECT); - } - - /** - * Generates an abort negotiation message from free text. - * - *

        This variant runs one LLM content-extraction step constrained by the common abort template and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. - * - * @param text free-text input stating the termination reason - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated message without any LLM involvement - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation.content_extract_failed} or - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after - * exhausting its retries, {@code negotiation.field_missing} when the extracted content misses the termination - * reason, or {@code negotiation.invalid_input} when the text is blank, or {@code input.text_too_long} when - * the text exceeds the configured maximum length - */ - public MetadataContent generateAbortFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return generateFromText(text, context, templateUri, NegotiationPerformative.ABORT); - } - - /** - * Validates a propose-phase negotiation message and extracts its parameters. - * - *

        The pipeline checks the template URI before any LLM call, runs the deterministic rule gate, then performs one - * semantic validation LLM call (retried on the retryable failure codes) and merges the extracted parameters with - * the rule-level context parameters; context parameters win on conflict. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code propose} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or when the context is null and the message is therefore not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length - */ - public FilledParamData validateProposePromptAndDataFilling( - String prompt, - NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.PROPOSE); - } - - /** - * Validates an accept-phase negotiation message and extracts its parameters. - * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, - * TemplateUri)} with the expected - * phase fixed to accept: the template URI must declare the {@code accept-reject} segment and the message must - * satisfy the accept-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or when the context is null and the message is therefore not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length - */ - public FilledParamData validateAcceptPromptAndDataFilling( - String prompt, - NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.ACCEPT); - } - - /** - * Validates a reject-phase negotiation message and extracts its parameters. - * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, - * TemplateUri)} with the expected - * phase fixed to reject: the template URI must declare the {@code accept-reject} segment and the message must - * satisfy the reject-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or when the context is null and the message is therefore not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length - */ - public FilledParamData validateRejectPromptAndDataFilling( - String prompt, - NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.REJECT); - } - - /** - * Validates an abort negotiation message and extracts its parameters. - * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, - * TemplateUri)} with the expected - * phase fixed to abort: the template URI must address the common abort template and the message must satisfy the - * abort-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or when the context is null and the message is therefore not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length - */ - public FilledParamData validateAbortPromptAndDataFilling( - String prompt, - NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.ABORT); - } - - private MetadataContent generateFromData( - NegotiationContext context, - NegotiationContent content, - TemplateUri templateUri, - NegotiationPerformative performative) { - requireContext(context); - try { - NegotiationReference reference = requireReference(templateUri, performative); - PromptTemplate template = loadTemplate(reference); - String promptText = renderMessage(context, content, reference, template); - return completeGeneration(reference, promptText, context); - } catch (NegotiationGenerationException failure) { - logger.atWarn() - .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); - throw failure; - } - } - - private MetadataContent generateFromText( - String text, NegotiationContext context, TemplateUri templateUri, NegotiationPerformative performative) { - requireContext(context); - requireTextWithinLimit(text); - try { - NegotiationReference reference = requireReference(templateUri, performative); - PromptTemplate template = loadTemplate(reference); - NegotiationContent content = extractContent(text, reference); - String promptText = renderMessage(context, content, reference, template); - return completeGeneration(reference, promptText, context); - } catch (NegotiationGenerationException failure) { - logger.atWarn() - .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); - throw failure; - } - } - - private PromptTemplate loadTemplate(NegotiationReference reference) { - try { - return templateLoader.load(reference); - } catch (ResourceNotFoundException exception) { - throw templateNotFound(reference, exception); - } - } - - private NegotiationContent extractContent(String text, NegotiationReference reference) { - try { - return withRetry(STEP_CONTENT_EXTRACT, () -> contentExtractor.extract(text, reference)); - } catch (ResourceNotFoundException exception) { - throw templateNotFound(reference, exception); - } - } - - private static NegotiationGenerationException templateNotFound( - NegotiationReference reference, ResourceNotFoundException cause) { - return new NegotiationGenerationException( - ErrorCatalog.TEMPLATE_NOT_FOUND, - reference.language(), - Map.of("template_uri", reference.uri(), "language", reference.language()), - cause); - } - - private String renderMessage( - NegotiationContext context, - NegotiationContent content, - NegotiationReference reference, - PromptTemplate template) { - NegotiationGenerator generator = - generatorRegistry.resolve(reference.type(), reference.performative(), content, reference.language()); - try { - return generator.generate(context, content, template, vocabulary); - } catch (NegotiationRenderException exception) { - throw new NegotiationGenerationException( - ErrorCatalog.TEMPLATE_RENDER_FAILED, - reference.language(), - Map.of( - "template_uri", reference.uri(), - "reason", String.valueOf(exception.getMessage())), - exception); - } - } - - private MetadataContent completeGeneration( - NegotiationReference reference, String promptText, NegotiationContext context) { - // The operation is the source of truth for the performative: the emitted context is the input context stamped - // with the performative of the addressed template, overriding whatever the caller passed in. - NegotiationContext stampedContext = context.withPerformative(reference.performative()); - logger.atInfo().log( - "negotiation_generation_completed uri={} type={} performative={} round={} id={}", - reference.uri(), - reference.type(), - reference.performative(), - stampedContext.round(), - stampedContext.id()); - return new MetadataContent( - reference.uri(), promptText, ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, stampedContext); - } - - private FilledParamData validatePromptAndDataFilling( - String prompt, - NegotiationContext context, - Map schema, - TemplateUri templateUri, - NegotiationPerformative performative) { - Objects.requireNonNull(schema, "Parameter schema must not be null."); - requirePromptWithinLimit(prompt); - NegotiationReference reference = requireReference(templateUri, performative); - try { - return paramExtractor.extract(prompt, context, schema, reference); - } catch (NegotiationParamExtractionException failure) { - logger.atWarn() - .log( - "negotiation_param_extraction_failed code={} error_count={}", - failure.getCode(), - failure.getErrors().size()); - throw failure; - } - } - - /** - * Derives the reference of a typed template URI against one expected performative. - * - * @param templateUri template URI declared by the caller - * @param performative performative the calling method operates on - * @return reference addressed by the URI carrying the expected performative - * @throws IllegalArgumentException if the URI does not address a negotiation template of the expected performative - */ - private NegotiationReference requireReference(TemplateUri templateUri, NegotiationPerformative performative) { - return NegotiationReference.fromTemplateUri(templateUri, performative, language) - .orElseThrow(() -> new IllegalArgumentException( - "Template URI does not address a negotiation template of the expected performative " - + performative + " (" + NegotiationReference.uriSegmentOf(performative) + "): " - + templateUri.uri() + ".")); - } - - /** - * Runs one LLM step with the retry policy of the negotiation content layer. - * - *

        A failure carrying a retryable code is re-run up to the configured attempt limit; a failure carrying any other - * code is rethrown immediately. When the attempts are exhausted, the original failure is rethrown with its original - * error code. - * - * @param result type of the step - * @param step internal diagnostic step name used in the retry logs - * @param action step implementation performing exactly one LLM call - * @return step result - */ - private T withRetry(String step, Supplier action) { - RuntimeException lastFailure = null; - for (int attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return action.get(); - } catch (RuntimeException failure) { - String code = retryableCode(failure); - if (code == null) { - throw failure; - } - lastFailure = failure; - if (attempt == maxAttempts) { - logger.atWarn() - .log( - "negotiation_llm_retry_exhausted step={} max_attempts={} code={}", - step, - maxAttempts, - code); - throw failure; - } - logger.atWarn() - .log( - "negotiation_llm_retry step={} attempt={} max_attempts={} code={}", - step, - attempt, - maxAttempts, - code); - } - } - throw lastFailure; - } - - private static String retryableCode(RuntimeException failure) { - String code = null; - if (failure instanceof NegotiationGenerationException generation) { - code = generation.getCode(); - } else if (failure instanceof NegotiationParamExtractionException extraction) { - code = extraction.getCode(); - } - if (code == null) { - return null; - } - boolean retryable = - ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode().equals(code) - || ErrorCatalog.LLM_INVOCATION_FAILED.getCode().equals(code) - || ErrorCatalog.LLM_RESPONSE_INVALID.getCode().equals(code); - return retryable ? code : null; - } - - private static void requireContext(NegotiationContext context) { - Objects.requireNonNull(context, "Negotiation context must not be null."); - } - - private void requireTextWithinLimit(String text) { - if (InputLimitConfig.isTooLong(text, maxTextChars)) { - throw new NegotiationGenerationException(ErrorCatalog.INPUT_TEXT_TOO_LONG, language, inputTooLongFacts(text)); - } - } - - private void requirePromptWithinLimit(String prompt) { - if (InputLimitConfig.isTooLong(prompt, maxTextChars)) { - throw new NegotiationParamExtractionException( - ErrorCatalog.INPUT_TEXT_TOO_LONG, language, inputTooLongFacts(prompt), List.of()); - } - } - - private Map inputTooLongFacts(String text) { - return Map.of( - "actual_length", String.valueOf(text == null ? 0 : text.length()), - "max_chars", String.valueOf(maxTextChars)); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Orchestrates the negotiation content layer: deterministic message generation, single-step LLM content extraction and + * the validation-plus-parameter-extraction pipeline. + * + *

        The orchestrator owns the retry loop of every LLM step. A step failing with one of the retryable codes + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} is re-run + * up to the configured attempt limit; the exhaustion failure rethrows the original error code. Internal exceptions of + * the generation and validation pipelines never bubble out raw: render failures become generation failures carrying + * {@code template.render_failed}, semantic validation infrastructure failures become parameter-extraction failures + * carrying {@code llm.invocation_failed} or {@code llm.response_invalid}, and resource load misses become the code + * {@code template.not_found} on both pipelines. + * + *

        Instances are created through {@link NegotiationGenerationOrchestratorBuilder}; the builder wires the default + * collaborators and allows overriding each of them. + * + * @since 2026-08 + */ +public final class NegotiationGenerationOrchestrator { + + private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(NegotiationGenerationOrchestrator.class); + + private static final String STEP_CONTENT_EXTRACT = "negotiation_content_extract"; + + private final String language; + + private final int maxAttempts; + + private final int maxTextChars; + + private final NegotiationTemplateLoader templateLoader; + + private final NegotiationContentExtractor contentExtractor; + + private final ParamExtractor paramExtractor; + + private final NegotiationGeneratorRegistry generatorRegistry; + + private final Vocabulary vocabulary; + + private final Logger logger; + + NegotiationGenerationOrchestrator( + String language, + int maxAttempts, + int maxTextChars, + NegotiationTemplateLoader templateLoader, + NegotiationContentExtractor contentExtractor, + ParamExtractor paramExtractor, + NegotiationGeneratorRegistry generatorRegistry, + Vocabulary vocabulary, + Logger logger) { + this.language = language; + this.maxAttempts = maxAttempts; + this.maxTextChars = maxTextChars; + this.templateLoader = templateLoader; + this.contentExtractor = contentExtractor; + this.paramExtractor = paramExtractor; + this.generatorRegistry = generatorRegistry; + this.vocabulary = vocabulary; + this.logger = logger == null ? DEFAULT_LOGGER : logger; + } + + /** + * Generates a propose-phase negotiation message from typed data. + * + *

        This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the + * generator of the negotiation type addressed by the template URI and rendered from that template. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase + * segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content type + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the + * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateProposeFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "Negotiation propose data must not be null."); + return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.PROPOSE); + } + + /** + * Generates an accept-phase negotiation message from typed data. + * + *

        This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Accept}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the + * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateAcceptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "Negotiation ending data must not be null."); + return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.ACCEPT); + } + + /** + * Generates a reject-phase negotiation message from typed data. + * + *

        This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Reject}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the + * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateRejectFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "Negotiation ending data must not be null."); + return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.REJECT); + } + + /** + * Generates an abort negotiation message from typed data. + * + *

        This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed + * template must be the common abort template and the content carries only the termination reason. + * + * @param data typed abort input carrying the negotiation context and the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when the termination + * reason is blank + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the + * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateAbortFromData(@NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "Negotiation abort data must not be null."); + return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.ABORT); + } + + /** + * Generates a propose-phase negotiation message from free text. + * + *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment + * must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting + * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or + * {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the phase, or + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateProposeFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPerformative.PROPOSE); + } + + /** + * Generates an accept-phase negotiation message from free text. + * + *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Accept}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting + * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or + * {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} when the + * extracted conclusion is not {@code Accept}, or {@code input.text_too_long} when the text exceeds the + * configured maximum length + */ + public MetadataContent generateAcceptFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPerformative.ACCEPT); + } + + /** + * Generates a reject-phase negotiation message from free text. + * + *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Reject}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting + * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or + * {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} when the + * extracted conclusion is not {@code Reject}, or {@code input.text_too_long} when the text exceeds the + * configured maximum length + */ + public MetadataContent generateRejectFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPerformative.REJECT); + } + + /** + * Generates an abort negotiation message from free text. + * + *

        This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. + * + * @param text free-text input stating the termination reason + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting + * its retries, {@code negotiation.field_missing} when the extracted content misses the termination reason, or + * {@code negotiation.invalid_input} when the text is blank, or {@code input.text_too_long} when the text + * exceeds the configured maximum length + */ + public MetadataContent generateAbortFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPerformative.ABORT); + } + + /** + * Validates a propose-phase negotiation message and extracts its parameters. + * + *

        The pipeline checks the template URI before any LLM call, runs the deterministic rule gate, then performs one + * semantic validation LLM call (retried on the retryable failure codes) and merges the extracted parameters with + * the rule-level context parameters; context parameters win on conflict. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length + */ + public FilledParamData validateProposePromptAndDataFilling( + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.PROPOSE); + } + + /** + * Validates an accept-phase negotiation message and extracts its parameters. + * + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, + * TemplateUri)} with the expected phase fixed to accept: the template URI must declare the {@code accept-reject} + * segment and the message must satisfy the accept-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length + */ + public FilledParamData validateAcceptPromptAndDataFilling( + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.ACCEPT); + } + + /** + * Validates a reject-phase negotiation message and extracts its parameters. + * + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, + * TemplateUri)} with the expected phase fixed to reject: the template URI must declare the {@code accept-reject} + * segment and the message must satisfy the reject-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length + */ + public FilledParamData validateRejectPromptAndDataFilling( + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.REJECT); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, + * TemplateUri)} with the expected phase fixed to abort: the template URI must address the common abort template and + * the message must satisfy the abort-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length + */ + public FilledParamData validateAbortPromptAndDataFilling( + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.ABORT); + } + + private MetadataContent generateFromData( + NegotiationContext context, + NegotiationContent content, + TemplateUri templateUri, + NegotiationPerformative performative) { + requireContext(context); + try { + NegotiationReference reference = requireReference(templateUri, performative); + PromptTemplate template = loadTemplate(reference); + String promptText = renderMessage(context, content, reference, template); + return completeGeneration(reference, promptText, context); + } catch (NegotiationGenerationException failure) { + logger.atWarn() + .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); + throw failure; + } + } + + private MetadataContent generateFromText( + String text, NegotiationContext context, TemplateUri templateUri, NegotiationPerformative performative) { + requireContext(context); + requireTextWithinLimit(text); + try { + NegotiationReference reference = requireReference(templateUri, performative); + PromptTemplate template = loadTemplate(reference); + NegotiationContent content = extractContent(text, reference); + String promptText = renderMessage(context, content, reference, template); + return completeGeneration(reference, promptText, context); + } catch (NegotiationGenerationException failure) { + logger.atWarn() + .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); + throw failure; + } + } + + private PromptTemplate loadTemplate(NegotiationReference reference) { + try { + return templateLoader.load(reference); + } catch (ResourceNotFoundException exception) { + throw templateNotFound(reference, exception); + } + } + + private NegotiationContent extractContent(String text, NegotiationReference reference) { + try { + return withRetry(STEP_CONTENT_EXTRACT, () -> contentExtractor.extract(text, reference)); + } catch (ResourceNotFoundException exception) { + throw templateNotFound(reference, exception); + } + } + + private static NegotiationGenerationException templateNotFound( + NegotiationReference reference, ResourceNotFoundException cause) { + return new NegotiationGenerationException( + ErrorCatalog.TEMPLATE_NOT_FOUND, + reference.language(), + Map.of("template_uri", reference.uri(), "language", reference.language()), + cause); + } + + private String renderMessage( + NegotiationContext context, + NegotiationContent content, + NegotiationReference reference, + PromptTemplate template) { + NegotiationGenerator generator = + generatorRegistry.resolve(reference.type(), reference.performative(), content, reference.language()); + try { + return generator.generate(context, content, template, vocabulary); + } catch (NegotiationRenderException exception) { + throw new NegotiationGenerationException( + ErrorCatalog.TEMPLATE_RENDER_FAILED, + reference.language(), + Map.of( + "template_uri", reference.uri(), + "reason", String.valueOf(exception.getMessage())), + exception); + } + } + + private MetadataContent completeGeneration( + NegotiationReference reference, String promptText, NegotiationContext context) { + // The operation is the source of truth for the performative: the emitted context is the input context stamped + // with the performative of the addressed template, overriding whatever the caller passed in. + NegotiationContext stampedContext = context.withPerformative(reference.performative()); + logger.atInfo().log( + "negotiation_generation_completed uri={} type={} performative={} round={} id={}", + reference.uri(), + reference.type(), + reference.performative(), + stampedContext.round(), + stampedContext.id()); + return new MetadataContent( + reference.uri(), promptText, ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, stampedContext); + } + + private FilledParamData validatePromptAndDataFilling( + String prompt, + NegotiationContext context, + Map schema, + TemplateUri templateUri, + NegotiationPerformative performative) { + Objects.requireNonNull(schema, "Parameter schema must not be null."); + requirePromptWithinLimit(prompt); + NegotiationReference reference = requireReference(templateUri, performative); + try { + return paramExtractor.extract(prompt, context, schema, reference); + } catch (NegotiationParamExtractionException failure) { + logger.atWarn() + .log( + "negotiation_param_extraction_failed code={} error_count={}", + failure.getCode(), + failure.getErrors().size()); + throw failure; + } + } + + /** + * Derives the reference of a typed template URI against one expected performative. + * + * @param templateUri template URI declared by the caller + * @param performative performative the calling method operates on + * @return reference addressed by the URI carrying the expected performative + * @throws IllegalArgumentException if the URI does not address a negotiation template of the expected performative + */ + private NegotiationReference requireReference(TemplateUri templateUri, NegotiationPerformative performative) { + return NegotiationReference.fromTemplateUri(templateUri, performative, language) + .orElseThrow(() -> new IllegalArgumentException( + "Template URI does not address a negotiation template of the expected performative " + + performative + " (" + NegotiationReference.uriSegmentOf(performative) + "): " + + templateUri.uri() + ".")); + } + + /** + * Runs one LLM step with the retry policy of the negotiation content layer. + * + *

        A failure carrying a retryable code is re-run up to the configured attempt limit; a failure carrying any other + * code is rethrown immediately. When the attempts are exhausted, the original failure is rethrown with its original + * error code. + * + * @param result type of the step + * @param step internal diagnostic step name used in the retry logs + * @param action step implementation performing exactly one LLM call + * @return step result + */ + private T withRetry(String step, Supplier action) { + RuntimeException lastFailure = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return action.get(); + } catch (RuntimeException failure) { + String code = retryableCode(failure); + if (code == null) { + throw failure; + } + lastFailure = failure; + if (attempt == maxAttempts) { + logger.atWarn() + .log( + "negotiation_llm_retry_exhausted step={} max_attempts={} code={}", + step, + maxAttempts, + code); + throw failure; + } + logger.atWarn() + .log( + "negotiation_llm_retry step={} attempt={} max_attempts={} code={}", + step, + attempt, + maxAttempts, + code); + } + } + throw lastFailure; + } + + private static String retryableCode(RuntimeException failure) { + String code = null; + if (failure instanceof NegotiationGenerationException generation) { + code = generation.getCode(); + } else if (failure instanceof NegotiationParamExtractionException extraction) { + code = extraction.getCode(); + } + if (code == null) { + return null; + } + boolean retryable = + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode().equals(code) + || ErrorCatalog.LLM_INVOCATION_FAILED.getCode().equals(code) + || ErrorCatalog.LLM_RESPONSE_INVALID.getCode().equals(code); + return retryable ? code : null; + } + + private static void requireContext(NegotiationContext context) { + Objects.requireNonNull(context, "Negotiation context must not be null."); + } + + private void requireTextWithinLimit(String text) { + if (InputLimitConfig.isTooLong(text, maxTextChars)) { + throw new NegotiationGenerationException( + ErrorCatalog.INPUT_TEXT_TOO_LONG, language, inputTooLongFacts(text)); + } + } + + private void requirePromptWithinLimit(String prompt) { + if (InputLimitConfig.isTooLong(prompt, maxTextChars)) { + throw new NegotiationParamExtractionException( + ErrorCatalog.INPUT_TEXT_TOO_LONG, language, inputTooLongFacts(prompt), List.of()); + } + } + + private Map inputTooLongFacts(String text) { + return Map.of( + "actual_length", String.valueOf(text == null ? 0 : text.length()), + "max_chars", String.valueOf(maxTextChars)); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index e87dd9cf..da91f805 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -1,229 +1,227 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.InputLimitConfig; -import net.openan.a2at.sdk.core.model.LlmConfig; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMConfigError; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; -import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationSemanticValidator; -import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; -import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; -import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; -import org.slf4j.Logger; - -/** - * Fluent builder assembling one {@link NegotiationGenerationOrchestrator}. - * - *

        The language is required. The LLM client is optional: without one, the from-data generation still works, while - * the LLM steps of the from-text generation and the validation pipeline fail with the {@code llm.not_configured} - * code. Every collaborator has a default implementation wired from the language (classpath-fixed templates and - * negotiation vocabulary), and each of them can be overridden for testing or customization. - * - * @since 2026-08 - */ -public final class NegotiationGenerationOrchestratorBuilder { - - private String language; - - private LLMClient llmClient; - - private int maxAttempts = LlmConfig.DEFAULT_MAX_ATTEMPTS; - - private int maxTextChars = InputLimitConfig.DEFAULT_MAX_TEXT_CHARS; - - private NegotiationTemplateLoader templateLoader; - - private NegotiationContentExtractor contentExtractor; - - private NegotiationComplianceChecker complianceChecker; - - private NegotiationSemanticValidator semanticValidator; - - private Logger logger; - - private NegotiationGenerationOrchestratorBuilder() {} - - /** - * Creates one new builder instance. - * - * @return empty orchestrator builder - */ - public static NegotiationGenerationOrchestratorBuilder builder() { - return new NegotiationGenerationOrchestratorBuilder(); - } - - /** - * Configures the language of the generated and validated messages. - * - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder language(String language) { - this.language = language; - return this; - } - - /** - * Configures the LLM client used by the LLM steps of the pipelines. - * - * @param llmClient LLM client; null keeps the LLM steps unavailable while the deterministic steps keep working - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder llmClient(LLMClient llmClient) { - this.llmClient = llmClient; - return this; - } - - /** - * Configures how often one LLM step is attempted before its failure is surfaced. - * - * @param maxAttempts maximum number of attempts per LLM step, at least 1 - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder maxAttempts(int maxAttempts) { - this.maxAttempts = maxAttempts; - return this; - } - - /** - * Configures the maximum length in characters accepted for free-text inputs before they reach an LLM step. - * - * @param maxTextChars maximum accepted length in characters, at least 1; oversized inputs fail fast with the code - * {@code input.text_too_long} instead of overflowing the LLM context - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder maxTextChars(int maxTextChars) { - this.maxTextChars = maxTextChars; - return this; - } - - /** - * Overrides the negotiation template loader. - * - * @param templateLoader loader resolving template references to templates - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder templateLoader(NegotiationTemplateLoader templateLoader) { - this.templateLoader = templateLoader; - return this; - } - - /** - * Overrides the content extractor used by the from-text generation. - * - * @param contentExtractor extractor turning free text into typed negotiation content - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder contentExtractor(NegotiationContentExtractor contentExtractor) { - this.contentExtractor = contentExtractor; - return this; - } - - /** - * Overrides the rule-level compliance checker used by the validation pipeline. - * - * @param complianceChecker deterministic rule gate of the validation pipeline - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder complianceChecker(NegotiationComplianceChecker complianceChecker) { - this.complianceChecker = complianceChecker; - return this; - } - - /** - * Overrides the LLM-backed semantic validator used by the validation pipeline. - * - * @param semanticValidator semantic validator producing the verdict and the extracted parameters - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder semanticValidator(NegotiationSemanticValidator semanticValidator) { - this.semanticValidator = semanticValidator; - return this; - } - - /** - * Overrides the logger of the assembled orchestrator. - * - * @param logger logger receiving the pipeline events; null falls back to the default class logger - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder logger(Logger logger) { - this.logger = logger; - return this; - } - - /** - * Assembles the orchestrator from the configured inputs. - * - * @return assembled negotiation generation orchestrator - * @throws IllegalStateException if the language is missing or the attempt limit is below 1 - * @throws IllegalArgumentException if the language has no bundled vocabulary - */ - public NegotiationGenerationOrchestrator build() { - if (language == null || language.isBlank()) { - throw new IllegalStateException("Negotiation language must be configured."); - } - if (maxAttempts < 1) { - throw new IllegalStateException( - "Negotiation LLM max attempts must be at least 1 but was " + maxAttempts + "."); - } - if (maxTextChars < 1) { - throw new IllegalStateException( - "Negotiation max text chars must be at least 1 but was " + maxTextChars + "."); - } - Vocabulary vocabulary = Vocabulary.forLanguage(language); - NegotiationTemplateLoader effectiveTemplateLoader = - templateLoader != null ? templateLoader : new DefaultNegotiationTemplateLoader(language); - NegotiationContentExtractor effectiveContentExtractor = - contentExtractor != null ? contentExtractor : new DefaultNegotiationContentExtractor(llmClient); - NegotiationComplianceChecker effectiveComplianceChecker = - complianceChecker != null ? complianceChecker : new DefaultNegotiationComplianceChecker(language); - NegotiationSemanticValidator effectiveSemanticValidator = - semanticValidator != null ? semanticValidator : defaultSemanticValidator(); - ParamExtractor paramExtractor = new ParamExtractor( - effectiveComplianceChecker, - effectiveSemanticValidator, - maxAttempts, - reference -> { - PromptTemplate template = effectiveTemplateLoader.load(reference); - String content = template.content(); - if (content == null) { - throw new ResourceNotFoundException( - "Negotiation template body is missing for " + template.templateUri().uri() + ".", - template.templateUri().uri()); - } - return content; - }); - return new NegotiationGenerationOrchestrator( - language, - maxAttempts, - maxTextChars, - effectiveTemplateLoader, - effectiveContentExtractor, - paramExtractor, - new NegotiationGeneratorRegistry(), - vocabulary, - logger); - } - - /** - * Builds the default semantic validator, wiring the schema builder of the generation package into the - * validation-package schema seam. - * - * @return default LLM-backed semantic validator, or a validator that fails every call when no LLM client is - * configured - */ - private NegotiationSemanticValidator defaultSemanticValidator() { - if (llmClient == null) { - return (prompt, callerSchema, reference, templateContent) -> { - throw new LLMConfigError("Semantic validation requires an LLM client but none is configured."); - }; - } - return new DefaultNegotiationSemanticValidator(llmClient); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.LlmConfig; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMConfigError; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; +import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationSemanticValidator; +import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; +import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; +import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; +import org.slf4j.Logger; + +/** + * Fluent builder assembling one {@link NegotiationGenerationOrchestrator}. + * + *

        The language is required. The LLM client is optional: without one, the from-data generation still works, while the + * LLM steps of the from-text generation and the validation pipeline fail with the {@code llm.not_configured} code. + * Every collaborator has a default implementation wired from the language (classpath-fixed templates and negotiation + * vocabulary), and each of them can be overridden for testing or customization. + * + * @since 2026-08 + */ +public final class NegotiationGenerationOrchestratorBuilder { + + private String language; + + private LLMClient llmClient; + + private int maxAttempts = LlmConfig.DEFAULT_MAX_ATTEMPTS; + + private int maxTextChars = InputLimitConfig.DEFAULT_MAX_TEXT_CHARS; + + private NegotiationTemplateLoader templateLoader; + + private NegotiationContentExtractor contentExtractor; + + private NegotiationComplianceChecker complianceChecker; + + private NegotiationSemanticValidator semanticValidator; + + private Logger logger; + + private NegotiationGenerationOrchestratorBuilder() {} + + /** + * Creates one new builder instance. + * + * @return empty orchestrator builder + */ + public static NegotiationGenerationOrchestratorBuilder builder() { + return new NegotiationGenerationOrchestratorBuilder(); + } + + /** + * Configures the language of the generated and validated messages. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder language(String language) { + this.language = language; + return this; + } + + /** + * Configures the LLM client used by the LLM steps of the pipelines. + * + * @param llmClient LLM client; null keeps the LLM steps unavailable while the deterministic steps keep working + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder llmClient(LLMClient llmClient) { + this.llmClient = llmClient; + return this; + } + + /** + * Configures how often one LLM step is attempted before its failure is surfaced. + * + * @param maxAttempts maximum number of attempts per LLM step, at least 1 + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder maxAttempts(int maxAttempts) { + this.maxAttempts = maxAttempts; + return this; + } + + /** + * Configures the maximum length in characters accepted for free-text inputs before they reach an LLM step. + * + * @param maxTextChars maximum accepted length in characters, at least 1; oversized inputs fail fast with the code + * {@code input.text_too_long} instead of overflowing the LLM context + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder maxTextChars(int maxTextChars) { + this.maxTextChars = maxTextChars; + return this; + } + + /** + * Overrides the negotiation template loader. + * + * @param templateLoader loader resolving template references to templates + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder templateLoader(NegotiationTemplateLoader templateLoader) { + this.templateLoader = templateLoader; + return this; + } + + /** + * Overrides the content extractor used by the from-text generation. + * + * @param contentExtractor extractor turning free text into typed negotiation content + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder contentExtractor(NegotiationContentExtractor contentExtractor) { + this.contentExtractor = contentExtractor; + return this; + } + + /** + * Overrides the rule-level compliance checker used by the validation pipeline. + * + * @param complianceChecker deterministic rule gate of the validation pipeline + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder complianceChecker(NegotiationComplianceChecker complianceChecker) { + this.complianceChecker = complianceChecker; + return this; + } + + /** + * Overrides the LLM-backed semantic validator used by the validation pipeline. + * + * @param semanticValidator semantic validator producing the verdict and the extracted parameters + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder semanticValidator(NegotiationSemanticValidator semanticValidator) { + this.semanticValidator = semanticValidator; + return this; + } + + /** + * Overrides the logger of the assembled orchestrator. + * + * @param logger logger receiving the pipeline events; null falls back to the default class logger + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder logger(Logger logger) { + this.logger = logger; + return this; + } + + /** + * Assembles the orchestrator from the configured inputs. + * + * @return assembled negotiation generation orchestrator + * @throws IllegalStateException if the language is missing or the attempt limit is below 1 + * @throws IllegalArgumentException if the language has no bundled vocabulary + */ + public NegotiationGenerationOrchestrator build() { + if (language == null || language.isBlank()) { + throw new IllegalStateException("Negotiation language must be configured."); + } + if (maxAttempts < 1) { + throw new IllegalStateException( + "Negotiation LLM max attempts must be at least 1 but was " + maxAttempts + "."); + } + if (maxTextChars < 1) { + throw new IllegalStateException( + "Negotiation max text chars must be at least 1 but was " + maxTextChars + "."); + } + Vocabulary vocabulary = Vocabulary.forLanguage(language); + NegotiationTemplateLoader effectiveTemplateLoader = + templateLoader != null ? templateLoader : new DefaultNegotiationTemplateLoader(language); + NegotiationContentExtractor effectiveContentExtractor = + contentExtractor != null ? contentExtractor : new DefaultNegotiationContentExtractor(llmClient); + NegotiationComplianceChecker effectiveComplianceChecker = + complianceChecker != null ? complianceChecker : new DefaultNegotiationComplianceChecker(language); + NegotiationSemanticValidator effectiveSemanticValidator = + semanticValidator != null ? semanticValidator : defaultSemanticValidator(); + ParamExtractor paramExtractor = + new ParamExtractor(effectiveComplianceChecker, effectiveSemanticValidator, maxAttempts, reference -> { + PromptTemplate template = effectiveTemplateLoader.load(reference); + String content = template.content(); + if (content == null) { + throw new ResourceNotFoundException( + "Negotiation template body is missing for " + + template.templateUri().uri() + ".", + template.templateUri().uri()); + } + return content; + }); + return new NegotiationGenerationOrchestrator( + language, + maxAttempts, + maxTextChars, + effectiveTemplateLoader, + effectiveContentExtractor, + paramExtractor, + new NegotiationGeneratorRegistry(), + vocabulary, + logger); + } + + /** + * Builds the default semantic validator, wiring the schema builder of the generation package into the + * validation-package schema seam. + * + * @return default LLM-backed semantic validator, or a validator that fails every call when no LLM client is + * configured + */ + private NegotiationSemanticValidator defaultSemanticValidator() { + if (llmClient == null) { + return (prompt, callerSchema, reference, templateContent) -> { + throw new LLMConfigError("Semantic validation requires an LLM client but none is configured."); + }; + } + return new DefaultNegotiationSemanticValidator(llmClient); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java index e329a8b2..bdad754d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -6,10 +6,10 @@ import java.util.concurrent.ConcurrentHashMap; import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.prompt.resources.catalog.TemplateDescriptions; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; @@ -21,9 +21,9 @@ *

        Templates are resolved from the built-in classpath resources bundled with the SDK — negotiation templates are * classpath-fixed and never configurable. Local template copies are not consulted. * - *

        Classpath resources are frozen at assembly time: each template is resolved once and cached at the JVM - * level, so every subsequent load returns the cached snapshot. Missing templates throw on every call and are never - * cached. The loader is thread-safe. + *

        Classpath resources are frozen at assembly time: each template is resolved once and cached at the JVM level, so + * every subsequent load returns the cached snapshot. Missing templates throw on every call and are never cached. The + * loader is thread-safe. * * @since 2026-08 */ @@ -67,7 +67,10 @@ public PromptTemplate load(NegotiationReference reference) { CacheKey cacheKey = new CacheKey(classpathPath, Thread.currentThread().getContextClassLoader()); String content = CACHE.computeIfAbsent(cacheKey, ignored -> readTemplate(classpathPath)); PromptTemplate template = new PromptTemplate( - reference.templateUri(), TemplateDescriptions.extract(content), content, PromptTemplate.SOURCE_CLASSPATH); + reference.templateUri(), + TemplateDescriptions.extract(content), + content, + PromptTemplate.SOURCE_CLASSPATH); LOGGER.atDebug().log("negotiation_template_loaded uri={} language={}", reference.uri(), reference.language()); return template; } @@ -101,5 +104,3 @@ private static String readTemplate(String classpathPath) { private record CacheKey(String classpathPath, @Nullable ClassLoader contextClassLoader) {} } - - diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java index b3ff9479..c2cd9373 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java @@ -18,4 +18,4 @@ public interface NegotiationTemplateLoader { * built-in classpath resources */ PromptTemplate load(NegotiationReference reference); -} \ No newline at end of file +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java index b6da0951..6d9512cd 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java @@ -72,8 +72,8 @@ default ValidationResult validate( throw new ContentValidationException( ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), message, - List.of( - new SlotValidationError("_llm", ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), message, facts)), + List.of(new SlotValidationError( + "_llm", ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), message, facts)), exception); } } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java index f50c7df3..07650a17 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java @@ -1,110 +1,109 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.SemanticValidator; -import net.openan.a2at.sdk.core.validation.TemplateContentLoader; -import net.openan.a2at.sdk.core.validation.ValidationPipeline; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.jspecify.annotations.Nullable; - -/** - * Orchestrates the validation of a negotiation message and the extraction of its parameters. - * - *

        The extractor delegates to the shared {@link ValidationPipeline} from the core module, which runs the rule-level - * gate, the template loading gate, retryable semantic validation and deterministic parameter merging in one pass. The - * rule-level gate is a per-call {@link NegotiationRuleCheckerAdapter} bridging the negotiation context carried - * alongside the message to the core rule checker contract; the injected {@link TemplateContentLoader} resolves the - * template body after the rule gate and before semantic validation, so the template is never preloaded by the caller. - * The pipeline emits the final {@link net.openan.a2at.sdk.core.exception.ErrorCatalog} codes with messages rendered in - * the language of the negotiation reference; there is no code mapping layer between the pipeline steps and the - * surfaced exception. - * - * @since 2026-08 - */ -public final class ParamExtractor { - - private final NegotiationComplianceChecker complianceChecker; - - private final SemanticValidator semanticValidator; - - private final int maxAttempts; - - private final TemplateContentLoader templateContentLoader; - - /** - * Creates a parameter extractor. - * - * @param complianceChecker rule-level checker used as the entry gate - * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters - * @param maxAttempts maximum number of retry attempts for the semantic validation step - * @param templateContentLoader template loading gate resolving the template body after the rule gate - * @throws NullPointerException if any collaborator is null - */ - public ParamExtractor( - NegotiationComplianceChecker complianceChecker, - SemanticValidator semanticValidator, - int maxAttempts, - TemplateContentLoader templateContentLoader) { - this.complianceChecker = Objects.requireNonNull(complianceChecker, "complianceChecker"); - this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); - this.maxAttempts = maxAttempts; - this.templateContentLoader = Objects.requireNonNull(templateContentLoader, "templateContentLoader"); - } - - /** - * Validates one negotiation message and extracts its parameters through the full pipeline. - * - * @param prompt rendered negotiation message text - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param reference template reference the message is validated against - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input}, - * {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, {@code llm.invocation_failed}, - * {@code llm.response_invalid} or {@code template.not_found} when the validation pipeline fails - */ - public FilledParamData extract( - String prompt, - @Nullable NegotiationContext context, - Map schema, - NegotiationReference reference) { - ValidationPipeline pipeline = new ValidationPipeline<>( - new NegotiationRuleCheckerAdapter(complianceChecker, context, reference.language()), - semanticValidator, - maxAttempts, - reference.language(), - templateContentLoader); - try { - return pipeline.validate(prompt, schema, reference); - } catch (ContentValidationException e) { - throw negotiationFailure(e, reference.language()); - } - } - - private static NegotiationParamExtractionException negotiationFailure( - ContentValidationException failure, String language) { - // The pipeline renders the final message itself; only the structured facts are reconstructed from the first - // slot detail that carries them, so callers keep the rendered message and gain the facts map. - ErrorCatalog entry = ErrorCatalog.byCode(failure.getCode()).orElse(null); - if (entry == null) { - throw new NegotiationParamExtractionException( - failure.getCode(), failure.getMessage(), failure.errors(), failure); - } - Map facts = failure.errors().stream() - .map(SlotValidationError::facts) - .filter(Objects::nonNull) - .findFirst() - .orElse(null); - return new NegotiationParamExtractionException( - entry.getCode(), failure.getMessage(), facts, failure.errors(), failure); - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.SemanticValidator; +import net.openan.a2at.sdk.core.validation.TemplateContentLoader; +import net.openan.a2at.sdk.core.validation.ValidationPipeline; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.jspecify.annotations.Nullable; + +/** + * Orchestrates the validation of a negotiation message and the extraction of its parameters. + * + *

        The extractor delegates to the shared {@link ValidationPipeline} from the core module, which runs the rule-level + * gate, the template loading gate, retryable semantic validation and deterministic parameter merging in one pass. The + * rule-level gate is a per-call {@link NegotiationRuleCheckerAdapter} bridging the negotiation context carried + * alongside the message to the core rule checker contract; the injected {@link TemplateContentLoader} resolves the + * template body after the rule gate and before semantic validation, so the template is never preloaded by the caller. + * The pipeline emits the final {@link net.openan.a2at.sdk.core.exception.ErrorCatalog} codes with messages rendered in + * the language of the negotiation reference; there is no code mapping layer between the pipeline steps and the surfaced + * exception. + * + * @since 2026-08 + */ +public final class ParamExtractor { + + private final NegotiationComplianceChecker complianceChecker; + + private final SemanticValidator semanticValidator; + + private final int maxAttempts; + + private final TemplateContentLoader templateContentLoader; + + /** + * Creates a parameter extractor. + * + * @param complianceChecker rule-level checker used as the entry gate + * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters + * @param maxAttempts maximum number of retry attempts for the semantic validation step + * @param templateContentLoader template loading gate resolving the template body after the rule gate + * @throws NullPointerException if any collaborator is null + */ + public ParamExtractor( + NegotiationComplianceChecker complianceChecker, + SemanticValidator semanticValidator, + int maxAttempts, + TemplateContentLoader templateContentLoader) { + this.complianceChecker = Objects.requireNonNull(complianceChecker, "complianceChecker"); + this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); + this.maxAttempts = maxAttempts; + this.templateContentLoader = Objects.requireNonNull(templateContentLoader, "templateContentLoader"); + } + + /** + * Validates one negotiation message and extracts its parameters through the full pipeline. + * + * @param prompt rendered negotiation message text + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param reference template reference the message is validated against + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input}, + * {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, {@code llm.invocation_failed}, + * {@code llm.response_invalid} or {@code template.not_found} when the validation pipeline fails + */ + public FilledParamData extract( + String prompt, + @Nullable NegotiationContext context, + Map schema, + NegotiationReference reference) { + ValidationPipeline pipeline = new ValidationPipeline<>( + new NegotiationRuleCheckerAdapter(complianceChecker, context, reference.language()), + semanticValidator, + maxAttempts, + reference.language(), + templateContentLoader); + try { + return pipeline.validate(prompt, schema, reference); + } catch (ContentValidationException e) { + throw negotiationFailure(e, reference.language()); + } + } + + private static NegotiationParamExtractionException negotiationFailure( + ContentValidationException failure, String language) { + // The pipeline renders the final message itself; only the structured facts are reconstructed from the first + // slot detail that carries them, so callers keep the rendered message and gain the facts map. + ErrorCatalog entry = ErrorCatalog.byCode(failure.getCode()).orElse(null); + if (entry == null) { + throw new NegotiationParamExtractionException( + failure.getCode(), failure.getMessage(), failure.errors(), failure); + } + Map facts = failure.errors().stream() + .map(SlotValidationError::facts) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + return new NegotiationParamExtractionException( + entry.getCode(), failure.getMessage(), facts, failure.errors(), failure); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java index 26f7d24f..6437a067 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java @@ -1,164 +1,164 @@ -package net.openan.a2at.sdk.negotiation.content; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Set; -import java.util.TreeSet; -import org.junit.jupiter.api.Test; - -class VocabularyTest { - - private static final Set CANONICAL_KEYS = Set.of( - "section.termination_reason", - "section.info_items", - "section.info_static", - "section.info_conclusion", - "section.info_result_content", - "section.target", - "section.target_intent", - "section.target_alignment", - "section.target_clarification", - "section.target_confirm_request", - "section.target_conclusion", - "section.target_result_content", - "section.feasibility", - "section.feasibility_evaluate", - "section.feasibility_infeasible", - "section.feasibility_confirm_request", - "section.feasibility_conclusion", - "section.feasibility_confirm", - "slot.termination_reason", - "slot.info_items", - "slot.info_conclusion", - "slot.info_result_content", - "slot.feasibility", - "slot.target", - "slot.target_intent", - "slot.target_alignment", - "slot.target_clarification", - "slot.target_confirm_request", - "slot.target_conclusion", - "slot.target_result_content", - "slot.feasibility_evaluate", - "slot.feasibility_infeasible", - "slot.feasibility_confirm_request", - "slot.feasibility_conclusion", - "slot.feasibility_confirm", - "label.relationship", - "punct.list_colon"); - - @Test - void bothLanguagesExposeExactlyTheCanonicalKeys() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - assertEquals(CANONICAL_KEYS, zhCn.canonicalKeys()); - assertEquals(CANONICAL_KEYS, enUs.canonicalKeys()); - assertEquals(Set.copyOf(Vocabulary.CANONICAL_KEYS), zhCn.canonicalKeys()); - assertEquals(new TreeSet<>(zhCn.canonicalKeys()), new TreeSet<>(enUs.canonicalKeys())); - assertEquals(37, zhCn.canonicalKeys().size()); - assertEquals(37, Vocabulary.CANONICAL_KEYS.size()); - } - - @Test - void terminationReasonSlotMatchesTheCommonAbortTemplatePlaceholder() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - assertEquals("协商终止原因", zhCn.get("slot.termination_reason")); - assertEquals("negotiation_termination_reason", enUs.get("slot.termination_reason")); - assertEquals("协商终止原因", zhCn.get("section.termination_reason")); - assertEquals("Negotiation Termination Reason", enUs.get("section.termination_reason")); - } - - @Test - void slotPlaceholdersAreSnakeCaseForEnglishAndCjkForChinese() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - // The placeholders are snake_case for en-US and the matching CJK section title for zh-CN. - assertEquals("意图理解陈述", zhCn.get("slot.target_intent")); - assertEquals("intent_understanding_statement", enUs.get("slot.target_intent")); - } - - @Test - void summaryAndConfirmSlotsDifferFromTheirSectionTitle() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - assertEquals("可行性协商概述", zhCn.get("slot.feasibility")); - assertEquals("feasibility_negotiation_summary", enUs.get("slot.feasibility")); - assertEquals("目标协商概述", zhCn.get("slot.target")); - assertEquals("target_negotiation_summary", enUs.get("slot.target")); - assertEquals("评估结果确认", zhCn.get("slot.feasibility_confirm")); - assertEquals("evaluation_result_confirmation", enUs.get("slot.feasibility_confirm")); - } - - @Test - void sectionValuesMatchTemplateSectionTitles() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - assertEquals("目标协商结果内容", zhCn.get("section.target_result_content")); - assertEquals("Target Negotiation Result Content", enUs.get("section.target_result_content")); - assertEquals("可行性评估结果确认", zhCn.get("section.feasibility_confirm")); - assertEquals("Feasibility Assessment Result Confirmation", enUs.get("section.feasibility_confirm")); - } - - @Test - void confirmRequestSectionAndSlotKeysMatchTheNewTemplates() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - assertEquals("目标澄清后的确认请求", zhCn.get("section.target_confirm_request")); - assertEquals("Target Clarification Confirmation Request", enUs.get("section.target_confirm_request")); - assertEquals("目标澄清后的确认请求", zhCn.get("slot.target_confirm_request")); - assertEquals("target_confirm_request", enUs.get("slot.target_confirm_request")); - assertEquals("评估可行时的确认请求", zhCn.get("section.feasibility_confirm_request")); - assertEquals("Feasible Evaluation Confirmation Request", enUs.get("section.feasibility_confirm_request")); - assertEquals("评估可行时的确认请求", zhCn.get("slot.feasibility_confirm_request")); - assertEquals("feasibility_confirm_request", enUs.get("slot.feasibility_confirm_request")); - } - - @Test - void englishRelationshipLabelCarriesOneTrailingSpace() { - String englishLabel = Vocabulary.forLanguage("en-US").get("label.relationship"); - String chineseLabel = Vocabulary.forLanguage("zh-CN").get("label.relationship"); - - assertEquals("Relationship between missing items: ", englishLabel); - assertTrue(englishLabel.endsWith(" ")); - assertEquals("缺失项之间的关系:", chineseLabel); - } - - @Test - void listColonPunctuationDiffersPerLanguage() { - assertEquals(":", Vocabulary.forLanguage("zh-CN").get("punct.list_colon")); - assertEquals(": ", Vocabulary.forLanguage("en-US").get("punct.list_colon")); - } - - @Test - void unsupportedLanguageThrows() { - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("fr-FR")); - - assertTrue(exception.getMessage().contains("fr-FR")); - assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); - } - - @Test - void unknownKeyThrows() { - Vocabulary vocabulary = Vocabulary.forLanguage("zh-CN"); - - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> vocabulary.get("section.unknown")); - - assertTrue(exception.getMessage().contains("section.unknown")); - } - - @Test - void languageThatIsNotASimplePathSegmentThrows() { - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("../escape")); - } -} +package net.openan.a2at.sdk.negotiation.content; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; +import java.util.TreeSet; +import org.junit.jupiter.api.Test; + +class VocabularyTest { + + private static final Set CANONICAL_KEYS = Set.of( + "section.termination_reason", + "section.info_items", + "section.info_static", + "section.info_conclusion", + "section.info_result_content", + "section.target", + "section.target_intent", + "section.target_alignment", + "section.target_clarification", + "section.target_confirm_request", + "section.target_conclusion", + "section.target_result_content", + "section.feasibility", + "section.feasibility_evaluate", + "section.feasibility_infeasible", + "section.feasibility_confirm_request", + "section.feasibility_conclusion", + "section.feasibility_confirm", + "slot.termination_reason", + "slot.info_items", + "slot.info_conclusion", + "slot.info_result_content", + "slot.feasibility", + "slot.target", + "slot.target_intent", + "slot.target_alignment", + "slot.target_clarification", + "slot.target_confirm_request", + "slot.target_conclusion", + "slot.target_result_content", + "slot.feasibility_evaluate", + "slot.feasibility_infeasible", + "slot.feasibility_confirm_request", + "slot.feasibility_conclusion", + "slot.feasibility_confirm", + "label.relationship", + "punct.list_colon"); + + @Test + void bothLanguagesExposeExactlyTheCanonicalKeys() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals(CANONICAL_KEYS, zhCn.canonicalKeys()); + assertEquals(CANONICAL_KEYS, enUs.canonicalKeys()); + assertEquals(Set.copyOf(Vocabulary.CANONICAL_KEYS), zhCn.canonicalKeys()); + assertEquals(new TreeSet<>(zhCn.canonicalKeys()), new TreeSet<>(enUs.canonicalKeys())); + assertEquals(37, zhCn.canonicalKeys().size()); + assertEquals(37, Vocabulary.CANONICAL_KEYS.size()); + } + + @Test + void terminationReasonSlotMatchesTheCommonAbortTemplatePlaceholder() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("协商终止原因", zhCn.get("slot.termination_reason")); + assertEquals("negotiation_termination_reason", enUs.get("slot.termination_reason")); + assertEquals("协商终止原因", zhCn.get("section.termination_reason")); + assertEquals("Negotiation Termination Reason", enUs.get("section.termination_reason")); + } + + @Test + void slotPlaceholdersAreSnakeCaseForEnglishAndCjkForChinese() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + // The placeholders are snake_case for en-US and the matching CJK section title for zh-CN. + assertEquals("意图理解陈述", zhCn.get("slot.target_intent")); + assertEquals("intent_understanding_statement", enUs.get("slot.target_intent")); + } + + @Test + void summaryAndConfirmSlotsDifferFromTheirSectionTitle() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("可行性协商概述", zhCn.get("slot.feasibility")); + assertEquals("feasibility_negotiation_summary", enUs.get("slot.feasibility")); + assertEquals("目标协商概述", zhCn.get("slot.target")); + assertEquals("target_negotiation_summary", enUs.get("slot.target")); + assertEquals("评估结果确认", zhCn.get("slot.feasibility_confirm")); + assertEquals("evaluation_result_confirmation", enUs.get("slot.feasibility_confirm")); + } + + @Test + void sectionValuesMatchTemplateSectionTitles() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("目标协商结果内容", zhCn.get("section.target_result_content")); + assertEquals("Target Negotiation Result Content", enUs.get("section.target_result_content")); + assertEquals("可行性评估结果确认", zhCn.get("section.feasibility_confirm")); + assertEquals("Feasibility Assessment Result Confirmation", enUs.get("section.feasibility_confirm")); + } + + @Test + void confirmRequestSectionAndSlotKeysMatchTheNewTemplates() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("目标澄清后的确认请求", zhCn.get("section.target_confirm_request")); + assertEquals("Target Clarification Confirmation Request", enUs.get("section.target_confirm_request")); + assertEquals("目标澄清后的确认请求", zhCn.get("slot.target_confirm_request")); + assertEquals("target_confirm_request", enUs.get("slot.target_confirm_request")); + assertEquals("评估可行时的确认请求", zhCn.get("section.feasibility_confirm_request")); + assertEquals("Feasible Evaluation Confirmation Request", enUs.get("section.feasibility_confirm_request")); + assertEquals("评估可行时的确认请求", zhCn.get("slot.feasibility_confirm_request")); + assertEquals("feasibility_confirm_request", enUs.get("slot.feasibility_confirm_request")); + } + + @Test + void englishRelationshipLabelCarriesOneTrailingSpace() { + String englishLabel = Vocabulary.forLanguage("en-US").get("label.relationship"); + String chineseLabel = Vocabulary.forLanguage("zh-CN").get("label.relationship"); + + assertEquals("Relationship between missing items: ", englishLabel); + assertTrue(englishLabel.endsWith(" ")); + assertEquals("缺失项之间的关系:", chineseLabel); + } + + @Test + void listColonPunctuationDiffersPerLanguage() { + assertEquals(":", Vocabulary.forLanguage("zh-CN").get("punct.list_colon")); + assertEquals(": ", Vocabulary.forLanguage("en-US").get("punct.list_colon")); + } + + @Test + void unsupportedLanguageThrows() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("fr-FR")); + + assertTrue(exception.getMessage().contains("fr-FR")); + assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); + } + + @Test + void unknownKeyThrows() { + Vocabulary vocabulary = Vocabulary.forLanguage("zh-CN"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> vocabulary.get("section.unknown")); + + assertTrue(exception.getMessage().contains("section.unknown")); + } + + @Test + void languageThatIsNotASimplePathSegmentThrows() { + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("../escape")); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java index fe24f6f3..a1c91063 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java @@ -105,7 +105,8 @@ void errorCodeConstantsStayExactlyThePinnedRegistry() { assertEquals( expected, actual, - "the deprecated forwarding registry is locked; adding or changing constants must update this" + " test"); + "the deprecated forwarding registry is locked; adding or changing constants must update this" + + " test"); assertTrue( !actual.containsValue("negotiation_type_unrecognized"), "the removed type-recognition code must not reappear"); diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java index 642854a8..4cace135 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java @@ -133,8 +133,12 @@ void typedEndingGenerationRejectsTheAbortTemplateUri() { @Test void singleQueryResolvesTheCommonAbortTemplateUri() { - assertTrue(new TemplateQueryService("zh-CN", "classpath", null).getPrompt(ABORT_URI).isPresent()); - assertTrue(new TemplateQueryService("en-US", "classpath", null).getPrompt(ABORT_URI).isPresent()); + assertTrue(new TemplateQueryService("zh-CN", "classpath", null) + .getPrompt(ABORT_URI) + .isPresent()); + assertTrue(new TemplateQueryService("en-US", "classpath", null) + .getPrompt(ABORT_URI) + .isPresent()); } @Test diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java index 58c0d4a7..d97d6f0c 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java @@ -1,131 +1,133 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import org.junit.jupiter.api.Test; - -/** - * Locks the classpath-only loading of the negotiation LLM prompt resources. - * - *

        The LLM prompt resources are deliberately not configurable: the system prompt sent to the LLM is always the - * built-in classpath prompt of the configured language. Both prompt-consuming steps are covered: the content extraction - * of the from-text generation and the semantic validation of the parameter extraction pipeline. - */ -class CustomRootPromptsIgnoredTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String EXTRACTION_RESPONSE = - "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; - - private static final String SEMANTIC_RESPONSE = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}"; - - @Test - void contentExtractionKeepsUsingTheBuiltInSystemPrompt() { - RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - NegotiationContext context = new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE); - - MetadataContent result = orchestrator.generateProposeFromText("请提供节能区域。", context, INFORMATION_PROPOSE_URI); - - assertEquals(1, llm.callCount()); - String builtinSystemPrompt = - new NegotiationPromptResourceLoader().loadSystem("information_negotiation", "zh-CN"); - assertEquals( - builtinSystemPrompt, - llm.systemContentOfLastCall(), - "the system prompt sent to the LLM must be the built-in classpath prompt"); - assertTrue(llm.userContentOfLastCall().contains("请提供节能区域。"), "the user prompt must carry the input text"); - assertTrue(result.promptText().contains("节能区域")); - } - - @Test - void semanticValidationKeepsUsingTheBuiltInSystemPrompt() { - RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE, SEMANTIC_RESPONSE); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - MetadataContent message = orchestrator.generateProposeFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE_URI); - - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message.promptText(), - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - INFORMATION_PROPOSE_URI); - - assertEquals(2, llm.callCount()); - String builtinSystemPrompt = - new NegotiationPromptResourceLoader().loadSystem("negotiation_semantic_validation", "zh-CN"); - assertEquals( - builtinSystemPrompt, - llm.systemContentOfLastCall(), - "the semantic validation system prompt must be the built-in classpath prompt"); - assertEquals("松山湖", filled.data().get("region")); - } - - private NegotiationGenerationOrchestrator orchestrator(RecordingLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static final class RecordingLlmClient implements LLMClient { - - private final List payloads; - - private final ArrayList>> recordedMessages = new ArrayList<>(); - - private RecordingLlmClient(String... payloads) { - this.payloads = List.of(payloads); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - recordedMessages.add(messages); - String payload = payloads.get(Math.min(recordedMessages.size() - 1, payloads.size() - 1)); - return new LLMResponse( - payload, "recording-test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - - int callCount() { - return recordedMessages.size(); - } - - String systemContentOfLastCall() { - return roleContentOfLastCall("system"); - } - - String userContentOfLastCall() { - return roleContentOfLastCall("user"); - } - - private String roleContentOfLastCall(String role) { - List> messages = recordedMessages.get(recordedMessages.size() - 1); - return messages.stream() - .filter(message -> role.equals(message.get("role"))) - .map(message -> message.get("content")) - .findFirst() - .orElseThrow(); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.Test; + +/** + * Locks the classpath-only loading of the negotiation LLM prompt resources. + * + *

        The LLM prompt resources are deliberately not configurable: the system prompt sent to the LLM is always the + * built-in classpath prompt of the configured language. Both prompt-consuming steps are covered: the content extraction + * of the from-text generation and the semantic validation of the parameter extraction pipeline. + */ +class CustomRootPromptsIgnoredTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String EXTRACTION_RESPONSE = + "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; + + private static final String SEMANTIC_RESPONSE = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"; + + @Test + void contentExtractionKeepsUsingTheBuiltInSystemPrompt() { + RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + NegotiationContext context = new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE); + + MetadataContent result = orchestrator.generateProposeFromText("请提供节能区域。", context, INFORMATION_PROPOSE_URI); + + assertEquals(1, llm.callCount()); + String builtinSystemPrompt = + new NegotiationPromptResourceLoader().loadSystem("information_negotiation", "zh-CN"); + assertEquals( + builtinSystemPrompt, + llm.systemContentOfLastCall(), + "the system prompt sent to the LLM must be the built-in classpath prompt"); + assertTrue(llm.userContentOfLastCall().contains("请提供节能区域。"), "the user prompt must carry the input text"); + assertTrue(result.promptText().contains("节能区域")); + } + + @Test + void semanticValidationKeepsUsingTheBuiltInSystemPrompt() { + RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE, SEMANTIC_RESPONSE); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + MetadataContent message = orchestrator.generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message.promptText(), + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), + INFORMATION_PROPOSE_URI); + + assertEquals(2, llm.callCount()); + String builtinSystemPrompt = + new NegotiationPromptResourceLoader().loadSystem("negotiation_semantic_validation", "zh-CN"); + assertEquals( + builtinSystemPrompt, + llm.systemContentOfLastCall(), + "the semantic validation system prompt must be the built-in classpath prompt"); + assertEquals("松山湖", filled.data().get("region")); + } + + private NegotiationGenerationOrchestrator orchestrator(RecordingLlmClient llm) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + } + + private static final class RecordingLlmClient implements LLMClient { + + private final List payloads; + + private final ArrayList>> recordedMessages = new ArrayList<>(); + + private RecordingLlmClient(String... payloads) { + this.payloads = List.of(payloads); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + recordedMessages.add(messages); + String payload = payloads.get(Math.min(recordedMessages.size() - 1, payloads.size() - 1)); + return new LLMResponse( + payload, "recording-test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + + int callCount() { + return recordedMessages.size(); + } + + String systemContentOfLastCall() { + return roleContentOfLastCall("system"); + } + + String userContentOfLastCall() { + return roleContentOfLastCall("user"); + } + + private String roleContentOfLastCall(String role) { + List> messages = recordedMessages.get(recordedMessages.size() - 1); + return messages.stream() + .filter(message -> role.equals(message.get("role"))) + .map(message -> message.get("content")) + .findFirst() + .orElseThrow(); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java index 32f46482..c8df8c87 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java @@ -1,247 +1,247 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.lang.reflect.Method; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMError; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; -import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckResult; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; - -/** - * Verifies the wiring defaults and injection seams of {@link NegotiationGenerationOrchestratorBuilder}. - * - *

        The builder falls back to the module logger when no logger is injected, honors an injected compliance checker, - * propagates the attempt limit to both retry chains (content extraction and semantic validation), and exposes no - * negotiation-type recognizer injection point. - */ -class NegotiationGenerationOrchestratorBuilderWiringTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private Logger rootLogger; - - private ListAppender appender; - - @BeforeEach - void attachAppender() { - rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); - appender = new ListAppender<>(); - appender.start(); - rootLogger.addAppender(appender); - } - - @AfterEach - void detachAppender() { - rootLogger.detachAppender(appender); - appender.stop(); - } - - @Test - void absentLoggerFallsBackToTheModuleLogger() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - - MetadataContent result = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - - assertFalse(result.promptText().isBlank()); - ILoggingEvent completed = appender.list.stream() - .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) - .findFirst() - .orElseThrow(); - assertEquals(NegotiationGenerationOrchestrator.class.getName(), completed.getLoggerName()); - assertEquals(Level.INFO, completed.getLevel()); - } - - @Test - void injectedLoggerReceivesThePipelineEvents() { - Logger customLogger = (Logger) LoggerFactory.getLogger("negotiation.wiring.custom-logger"); - ListAppender customAppender = new ListAppender<>(); - customAppender.start(); - customLogger.addAppender(customAppender); - try { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .logger(customLogger) - .build(); - - orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - - ILoggingEvent completed = customAppender.list.stream() - .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) - .findFirst() - .orElseThrow(); - assertEquals("negotiation.wiring.custom-logger", completed.getLoggerName()); - } finally { - customLogger.detachAppender(customAppender); - customAppender.stop(); - } - } - - @Test - void injectedComplianceCheckerIsUsedByTheValidationPipeline() { - RecordingComplianceChecker checker = new RecordingComplianceChecker(); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient( - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}")) - .complianceChecker(checker) - .build(); - - orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI); - - assertTrue(checker.calls.get() >= 1, "the injected compliance checker must be used"); - } - - @Test - void nullTemplateContentFromCustomLoaderIsReportedAsTemplateNotFoundNotNpe() { - NegotiationTemplateLoader nullContentLoader = reference -> new PromptTemplate( - reference.templateUri(), "", null, PromptTemplate.SOURCE_CLASSPATH); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .templateLoader(nullContentLoader) - .build(); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), exception.getCode()); - } - - @Test - void maxAttemptsReachesBothRetryChains() { - CountingFailingClient generationClient = new CountingFailingClient(); - NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(generationClient) - .maxAttempts(2) - .build(); - NegotiationGenerationException generationFailure = org.junit.jupiter.api.Assertions.assertThrows( - NegotiationGenerationException.class, - () -> generationOrchestrator.generateProposeFromText( - "请提供节能区域。", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE_URI)); - assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), generationFailure.getCode()); - assertEquals(2, generationClient.calls.get(), "generation chain must retry up to the limit"); - - CountingFailingClient semanticClient = new CountingFailingClient(); - NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(semanticClient) - .maxAttempts(2) - .build(); - org.junit.jupiter.api.Assertions.assertThrows( - net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException.class, - () -> validationOrchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - assertEquals(2, semanticClient.calls.get(), "semantic validation chain must retry up to the limit"); - } - - @Test - void builderExposesNoTypeRecognizerInjectionPoint() { - for (Method method : NegotiationGenerationOrchestratorBuilder.class.getDeclaredMethods()) { - String name = method.getName().toLowerCase(Locale.ROOT); - assertFalse( - name.contains("recogn"), - "the builder must not expose a type recognizer injection point but declares " + method.getName()); - } - } - - private static final class RecordingComplianceChecker implements NegotiationComplianceChecker { - - private final AtomicInteger calls = new AtomicInteger(); - - @Override - public NegotiationRuleCheckResult check(net.openan.a2at.sdk.core.model.NegotiationContext context) { - calls.incrementAndGet(); - return new NegotiationRuleCheckResult(true, List.of()); - } - } - - private static final class ScriptedClient implements LLMClient { - - private final String payload; - - private ScriptedClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class CountingFailingClient implements LLMClient { - - private final AtomicInteger calls = new AtomicInteger(); - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls.incrementAndGet(); - throw new LLMError("LLM endpoint unavailable."); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; +import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * Verifies the wiring defaults and injection seams of {@link NegotiationGenerationOrchestratorBuilder}. + * + *

        The builder falls back to the module logger when no logger is injected, honors an injected compliance checker, + * propagates the attempt limit to both retry chains (content extraction and semantic validation), and exposes no + * negotiation-type recognizer injection point. + */ +class NegotiationGenerationOrchestratorBuilderWiringTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private Logger rootLogger; + + private ListAppender appender; + + @BeforeEach + void attachAppender() { + rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + appender = new ListAppender<>(); + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + rootLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void absentLoggerFallsBackToTheModuleLogger() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + MetadataContent result = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + assertFalse(result.promptText().isBlank()); + ILoggingEvent completed = appender.list.stream() + .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) + .findFirst() + .orElseThrow(); + assertEquals(NegotiationGenerationOrchestrator.class.getName(), completed.getLoggerName()); + assertEquals(Level.INFO, completed.getLevel()); + } + + @Test + void injectedLoggerReceivesThePipelineEvents() { + Logger customLogger = (Logger) LoggerFactory.getLogger("negotiation.wiring.custom-logger"); + ListAppender customAppender = new ListAppender<>(); + customAppender.start(); + customLogger.addAppender(customAppender); + try { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .logger(customLogger) + .build(); + + orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + ILoggingEvent completed = customAppender.list.stream() + .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) + .findFirst() + .orElseThrow(); + assertEquals("negotiation.wiring.custom-logger", completed.getLoggerName()); + } finally { + customLogger.detachAppender(customAppender); + customAppender.stop(); + } + } + + @Test + void injectedComplianceCheckerIsUsedByTheValidationPipeline() { + RecordingComplianceChecker checker = new RecordingComplianceChecker(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient( + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}")) + .complianceChecker(checker) + .build(); + + orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI); + + assertTrue(checker.calls.get() >= 1, "the injected compliance checker must be used"); + } + + @Test + void nullTemplateContentFromCustomLoaderIsReportedAsTemplateNotFoundNotNpe() { + NegotiationTemplateLoader nullContentLoader = + reference -> new PromptTemplate(reference.templateUri(), "", null, PromptTemplate.SOURCE_CLASSPATH); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(nullContentLoader) + .build(); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), exception.getCode()); + } + + @Test + void maxAttemptsReachesBothRetryChains() { + CountingFailingClient generationClient = new CountingFailingClient(); + NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(generationClient) + .maxAttempts(2) + .build(); + NegotiationGenerationException generationFailure = org.junit.jupiter.api.Assertions.assertThrows( + NegotiationGenerationException.class, + () -> generationOrchestrator.generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI)); + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), generationFailure.getCode()); + assertEquals(2, generationClient.calls.get(), "generation chain must retry up to the limit"); + + CountingFailingClient semanticClient = new CountingFailingClient(); + NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(semanticClient) + .maxAttempts(2) + .build(); + org.junit.jupiter.api.Assertions.assertThrows( + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException.class, + () -> validationOrchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + assertEquals(2, semanticClient.calls.get(), "semantic validation chain must retry up to the limit"); + } + + @Test + void builderExposesNoTypeRecognizerInjectionPoint() { + for (Method method : NegotiationGenerationOrchestratorBuilder.class.getDeclaredMethods()) { + String name = method.getName().toLowerCase(Locale.ROOT); + assertFalse( + name.contains("recogn"), + "the builder must not expose a type recognizer injection point but declares " + method.getName()); + } + } + + private static final class RecordingComplianceChecker implements NegotiationComplianceChecker { + + private final AtomicInteger calls = new AtomicInteger(); + + @Override + public NegotiationRuleCheckResult check(net.openan.a2at.sdk.core.model.NegotiationContext context) { + calls.incrementAndGet(); + return new NegotiationRuleCheckResult(true, List.of()); + } + } + + private static final class ScriptedClient implements LLMClient { + + private final String payload; + + private ScriptedClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class CountingFailingClient implements LLMClient { + + private final AtomicInteger calls = new AtomicInteger(); + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls.incrementAndGet(); + throw new LLMError("LLM endpoint unavailable."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 50db27b9..1d92a22d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -1,356 +1,359 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import org.junit.jupiter.api.Test; - -class NegotiationGenerationOrchestratorTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); - - @Test - void generatesInformationProposeFromDataInChinese() { - MetadataContent result = zhOrchestrator() - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertEquals(NegotiationHandler.NEGOTIATION_T_URI, result.extensionUri()); - assertFalse(result.promptText().isBlank()); - assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); - assertTrue(result.promptText().contains("所需信息项")); - - Map metadata = result.buildMetadataContent(); - assertEquals(3, metadata.size()); - assertEquals(result.promptText(), metadata.get(NegotiationHandler.NEGOTIATION_T_URI)); - assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); - @SuppressWarnings("unchecked") - Map nestedContext = - (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); - assertEquals(UUID, nestedContext.get("id")); - assertEquals(1, nestedContext.get("round")); - assertEquals(5, nestedContext.get("maxRounds")); - assertEquals("PROPOSE", nestedContext.get("performative")); - } - - @Test - void generatesInformationProposeFromDataInEnglish() { - MetadataContent result = orchestrator("en-US") - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); - assertEquals(new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); - assertTrue(result.promptText().contains("Required Information Items")); - } - - @Test - void generatesProposeFromTextWithScriptedExtraction() { - ScriptedClient llm = - new ScriptedClient("{\"items\":[{\"name\":\"故障发生时间\",\"value\":\"精确到分钟的时间点\"}],\"relationship\":null}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - - MetadataContent result = orchestrator.generateProposeFromText( - "请提供故障发生时间。", new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE); - - assertEquals(1, llm.calls); - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertEquals(2, result.negotiationContext().round()); - assertTrue(result.promptText().contains("故障发生时间")); - } - - @Test - void validatesAndFillsProposeDataWithScriptedSemanticResponse() { - ScriptedClient llm = - new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - MetadataContent message = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); - - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message.promptText(), - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - INFORMATION_PROPOSE); - - assertEquals(1, llm.calls); - assertEquals(UUID, filled.data().get("id")); - assertEquals(1, filled.data().get("round")); - assertEquals(5, filled.data().get("maxRounds")); - assertEquals("松山湖", filled.data().get("region")); - } - - @Test - void listsAllSevenTemplatesPerLanguage() { - assertEquals(7, negotiationPrompts("zh-CN").size()); - assertEquals(7, negotiationPrompts("en-US").size()); - } - - @Test - void queriesSingleTemplateByUri() { - assertTrue(queryService("zh-CN").getPrompt(INFORMATION_PROPOSE).isPresent()); - assertTrue(queryService("zh-CN") - .getPrompt(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) - .isPresent()); - assertFalse(queryService("zh-CN") - .getPrompt(TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")) - .isPresent()); - } - - @Test - void retriesContentExtractionUntilItSucceeds() { - ScriptedClient llm = new ScriptedClient("", "", "{\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build(); - - MetadataContent result = orchestrator.generateProposeFromText( - "请提供区域。", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE); - - assertEquals(3, llm.calls); - assertFalse(result.promptText().isBlank()); - } - - @Test - void rethrowsOriginalCodeWhenRetriesAreExhausted() { - FailingClient llm = new FailingClient(); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(2) - .build(); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供区域。", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE)); - - assertEquals(2, llm.calls); - assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), failure.getCode()); - } - - @Test - void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { - NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .templateLoader(missingTemplateLoader()) - .build(); - - NegotiationGenerationException generationFailure = assertThrows( - NegotiationGenerationException.class, - () -> generationOrchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), - INFORMATION_PROPOSE)); - assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), generationFailure.getCode()); - - NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .semanticValidator((prompt, callerSchema, reference, templateContent) -> { - throw new ResourceNotFoundException( - "Semantic validation prompt does not exist.", "prompt_resources/prompts"); - }) - .build(); - - NegotiationParamExtractionException extractionFailure = assertThrows( - NegotiationParamExtractionException.class, - () -> validationOrchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE)); - assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), extractionFailure.getCode()); - } - - @Test - void validationTemplateLoadFailurePreservesTheOriginalCause() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .templateLoader(missingTemplateLoader()) - .build(); - - NegotiationParamExtractionException failure = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE)); - - assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), failure.getCode()); - assertTrue( - failure.getCause() instanceof ContentValidationException, - "the template-load failure must preserve the mapped pipeline failure as cause for debugging"); - assertTrue( - failure.getCause().getCause() instanceof ResourceNotFoundException, - "the original resource failure stays reachable through the cause chain"); - } - - @Test - void rejectsNonNegotiationPromptInParameterExtraction() { - ScriptedClient llm = new ScriptedClient( - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - - NegotiationParamExtractionException failure = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - "plain text without any negotiation section", - null, - Map.of("type", "object"), - INFORMATION_PROPOSE)); - - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), failure.getCode()); - assertEquals(0, llm.calls); - } - - @Test - void rejectsTemplateUriThatDoesNotAddressTheExpectedPerformative() { - IllegalArgumentException performativeMismatchFailure = - assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT)); - assertTrue(performativeMismatchFailure - .getMessage() - .contains("Template URI does not address a negotiation template of the expected performative" - + " PROPOSE (propose)")); - - IllegalArgumentException wrongExtensionFailure = - assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), - StandardTemplates.ENERGY_SAVING)); - assertTrue(wrongExtensionFailure - .getMessage() - .contains("Template URI does not address a negotiation template of the expected performative" - + " PROPOSE (propose)")); - } - - private static NegotiationGenerationOrchestrator zhOrchestrator() { - return orchestrator("zh-CN"); - } - - private static NegotiationGenerationOrchestrator orchestrator(String language) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .build(); - } - - private static TemplateQueryService queryService(String language) { - return new TemplateQueryService(language, "classpath", null); - } - - private static List negotiationPrompts(String language) { - return queryService(language).getPrompts().stream() - .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( - template.templateUri().extensionName())) - .toList(); - } - - private static NegotiationTemplateLoader missingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - }; - } - - private static final class ScriptedClient implements LLMClient { - - private final List payloads; - - private int calls; - - private ScriptedClient(String... payloads) { - this.payloads = List.of(payloads); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - String payload = payloads.get(Math.min(calls, payloads.size() - 1)); - calls++; - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class FailingClient implements LLMClient { - - private int calls; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - throw new IllegalStateException("LLM endpoint unavailable."); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import org.junit.jupiter.api.Test; + +class NegotiationGenerationOrchestratorTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + @Test + void generatesInformationProposeFromDataInChinese() { + MetadataContent result = zhOrchestrator() + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertEquals(NegotiationHandler.NEGOTIATION_T_URI, result.extensionUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); + assertTrue(result.promptText().contains("所需信息项")); + + Map metadata = result.buildMetadataContent(); + assertEquals(3, metadata.size()); + assertEquals(result.promptText(), metadata.get(NegotiationHandler.NEGOTIATION_T_URI)); + assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); + @SuppressWarnings("unchecked") + Map nestedContext = + (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + assertEquals(UUID, nestedContext.get("id")); + assertEquals(1, nestedContext.get("round")); + assertEquals(5, nestedContext.get("maxRounds")); + assertEquals("PROPOSE", nestedContext.get("performative")); + } + + @Test + void generatesInformationProposeFromDataInEnglish() { + MetadataContent result = orchestrator("en-US") + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent( + List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); + assertEquals(new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); + assertTrue(result.promptText().contains("Required Information Items")); + } + + @Test + void generatesProposeFromTextWithScriptedExtraction() { + ScriptedClient llm = + new ScriptedClient("{\"items\":[{\"name\":\"故障发生时间\",\"value\":\"精确到分钟的时间点\"}],\"relationship\":null}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + MetadataContent result = orchestrator.generateProposeFromText( + "请提供故障发生时间。", new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE); + + assertEquals(1, llm.calls); + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertEquals(2, result.negotiationContext().round()); + assertTrue(result.promptText().contains("故障发生时间")); + } + + @Test + void validatesAndFillsProposeDataWithScriptedSemanticResponse() { + ScriptedClient llm = + new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + MetadataContent message = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message.promptText(), + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), + INFORMATION_PROPOSE); + + assertEquals(1, llm.calls); + assertEquals(UUID, filled.data().get("id")); + assertEquals(1, filled.data().get("round")); + assertEquals(5, filled.data().get("maxRounds")); + assertEquals("松山湖", filled.data().get("region")); + } + + @Test + void listsAllSevenTemplatesPerLanguage() { + assertEquals(7, negotiationPrompts("zh-CN").size()); + assertEquals(7, negotiationPrompts("en-US").size()); + } + + @Test + void queriesSingleTemplateByUri() { + assertTrue(queryService("zh-CN").getPrompt(INFORMATION_PROPOSE).isPresent()); + assertTrue(queryService("zh-CN") + .getPrompt(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) + .isPresent()); + assertFalse(queryService("zh-CN") + .getPrompt(TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")) + .isPresent()); + } + + @Test + void retriesContentExtractionUntilItSucceeds() { + ScriptedClient llm = new ScriptedClient("", "", "{\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build(); + + MetadataContent result = orchestrator.generateProposeFromText( + "请提供区域。", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE); + + assertEquals(3, llm.calls); + assertFalse(result.promptText().isBlank()); + } + + @Test + void rethrowsOriginalCodeWhenRetriesAreExhausted() { + FailingClient llm = new FailingClient(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build(); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE)); + + assertEquals(2, llm.calls); + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), failure.getCode()); + } + + @Test + void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { + NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(missingTemplateLoader()) + .build(); + + NegotiationGenerationException generationFailure = assertThrows( + NegotiationGenerationException.class, + () -> generationOrchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + INFORMATION_PROPOSE)); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), generationFailure.getCode()); + + NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .semanticValidator((prompt, callerSchema, reference, templateContent) -> { + throw new ResourceNotFoundException( + "Semantic validation prompt does not exist.", "prompt_resources/prompts"); + }) + .build(); + + NegotiationParamExtractionException extractionFailure = assertThrows( + NegotiationParamExtractionException.class, + () -> validationOrchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE)); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), extractionFailure.getCode()); + } + + @Test + void validationTemplateLoadFailurePreservesTheOriginalCause() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(missingTemplateLoader()) + .build(); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE)); + + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), failure.getCode()); + assertTrue( + failure.getCause() instanceof ContentValidationException, + "the template-load failure must preserve the mapped pipeline failure as cause for debugging"); + assertTrue( + failure.getCause().getCause() instanceof ResourceNotFoundException, + "the original resource failure stays reachable through the cause chain"); + } + + @Test + void rejectsNonNegotiationPromptInParameterExtraction() { + ScriptedClient llm = new ScriptedClient( + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "plain text without any negotiation section", + null, + Map.of("type", "object"), + INFORMATION_PROPOSE)); + + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), failure.getCode()); + assertEquals(0, llm.calls); + } + + @Test + void rejectsTemplateUriThatDoesNotAddressTheExpectedPerformative() { + IllegalArgumentException performativeMismatchFailure = + assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT)); + assertTrue(performativeMismatchFailure + .getMessage() + .contains("Template URI does not address a negotiation template of the expected performative" + + " PROPOSE (propose)")); + + IllegalArgumentException wrongExtensionFailure = + assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + StandardTemplates.ENERGY_SAVING)); + assertTrue(wrongExtensionFailure + .getMessage() + .contains("Template URI does not address a negotiation template of the expected performative" + + " PROPOSE (propose)")); + } + + private static NegotiationGenerationOrchestrator zhOrchestrator() { + return orchestrator("zh-CN"); + } + + private static NegotiationGenerationOrchestrator orchestrator(String language) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .build(); + } + + private static TemplateQueryService queryService(String language) { + return new TemplateQueryService(language, "classpath", null); + } + + private static List negotiationPrompts(String language) { + return queryService(language).getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + + private static NegotiationTemplateLoader missingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + }; + } + + private static final class ScriptedClient implements LLMClient { + + private final List payloads; + + private int calls; + + private ScriptedClient(String... payloads) { + this.payloads = List.of(payloads); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + String payload = payloads.get(Math.min(calls, payloads.size() - 1)); + calls++; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class FailingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new IllegalStateException("LLM endpoint unavailable."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java index 3ce57491..9b4810f0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -1,748 +1,748 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.junit.jupiter.api.Test; - -class NegotiationGeneratorsTest { - - private static final String CONTEXT_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String ZH_TARGET_PROPOSE_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 目标协商 - {{目标协商概述}}(必填) - 要求: - 概述要求。 - - ## 意图理解陈述 - {{意图理解陈述}}(选填) - 要求: - 意图要求。 - - ## 理解对齐与疑问澄清 - {{理解对齐与疑问澄清}}(选填) - 要求: - 对齐要求。 - - ## 待澄清内容 - {{待澄清内容}}(选填) - 要求: - 澄清要求。 - """; - - private static final String ZH_FEASIBILITY_PROPOSE_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 可行性协商 - {{可行性协商概述}}(必填) - 要求: - 概述要求。 - - ## 待评估内容说明 - {{待评估内容说明}}(选填) - 要求: - 评估要求。 - - ## 评估不可行时的详情和提案 - {{评估不可行时的详情和提案}}(选填) - 要求: - 提案要求。 - """; - - private static final String ZH_TARGET_PROPOSE_CONFIRM_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 目标协商 - {{目标协商概述}}(必填) - 要求: - 概述要求。 - - ## 意图理解陈述 - {{意图理解陈述}}(选填) - 要求: - 意图要求。 - - ## 理解对齐与疑问澄清 - {{理解对齐与疑问澄清}}(选填) - 要求: - 对齐要求。 - - ## 待澄清内容 - {{待澄清内容}}(选填) - 要求: - 澄清要求。 - - ## 目标澄清后的确认请求 - {{目标澄清后的确认请求}}(选填) - 要求: - 确认要求。 - """; - - private static final String ZH_FEASIBILITY_PROPOSE_CONFIRM_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 可行性协商 - {{可行性协商概述}}(必填) - 要求: - 概述要求。 - - ## 待评估内容说明 - {{待评估内容说明}}(选填) - 要求: - 评估要求。 - - ## 评估不可行时的详情和提案 - {{评估不可行时的详情和提案}}(选填) - 要求: - 提案要求。 - - ## 评估可行时的确认请求 - {{评估可行时的确认请求}}(选填) - 要求: - 确认要求。 - """; - - private static final String ZH_INFO_PROPOSE_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 信息协商 - 请根据<所需信息项>补充相关内容。 - - ## 所需信息项 - {{所需信息项}}(必填) - 要求: - 信息项要求。 - """; - - private static final String ZH_INFO_ENDING_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 信息协商结果 - {{信息协商结果}}(必填) - 要求: - 结论要求。 - - ## 信息协商结果内容 - {{信息协商结果内容}}(必填) - 要求: - 内容要求。 - """; - - private static final String ZH_TARGET_ENDING_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 目标协商结果 - {{目标协商结果}}(必填) - 要求: - 结论要求。 - - ## 目标协商结果内容 - {{目标协商结果内容}}(必填) - 要求: - 内容要求。 - """; - - private static final String ZH_FEASIBILITY_ENDING_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 可行性协商结果 - {{可行性协商结果}}(必填) - 要求: - 结论要求。 - - ## 可行性评估结果确认 - {{评估结果确认}}(必填) - 要求: - 确认要求。 - """; - - private final Vocabulary zhVocabulary = Vocabulary.forLanguage("zh-CN"); - - private final Vocabulary enVocabulary = Vocabulary.forLanguage("en-US"); - - @Test - void feasibilityRequestActionRendersExactlyTheEvaluationSection() { - FeasibilityProposeContent content = new FeasibilityProposeContent( - "请协助评估该节能目标能否达成。", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("评估对象", "停电8小时期间核心用户的速率保障")), - List.of(new NegotiationItem("不应出现", "值")), - null); - - String rendered = new FeasibilityProposeGenerator() - .generate( - context(1), - content, - template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 可行性协商\n请协助评估该节能目标能否达成。")); - assertTrue(rendered.contains("## 待评估内容说明\n1. 评估对象:停电8小时期间核心用户的速率保障")); - assertFalse(rendered.contains("## 评估不可行时的详情和提案")); - assertFalse(rendered.contains("要求:")); - } - - @Test - void feasibilityAlternativeActionRendersExactlyTheInfeasibilitySection() { - FeasibilityProposeContent content = new FeasibilityProposeContent( - "当前速率目标不可行,提出下调方案。", - NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, - List.of(new NegotiationItem("不应出现", "值")), - List.of( - new NegotiationItem("不可行原因", "蓄电池仅能支撑8小时2Mbps的保障能力"), - new NegotiationItem("替代提案", "停电期间将速率保障目标下调至2Mbps")), - null); - - String rendered = new FeasibilityProposeGenerator() - .generate( - context(2), - content, - template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 可行性协商\n当前速率目标不可行,提出下调方案。")); - assertTrue(rendered.contains("## 评估不可行时的详情和提案\n1. 不可行原因:蓄电池仅能支撑8小时2Mbps的保障能力")); - assertTrue(rendered.contains("2. 替代提案:停电期间将速率保障目标下调至2Mbps")); - assertFalse(rendered.contains("## 待评估内容说明")); - } - - @Test - void feasibilityNullActionOrEmptyDrivenContentIsRejected() { - FeasibilityProposeContent nullAction = - new FeasibilityProposeContent("描述", null, List.of(new NegotiationItem("名称", "值")), null, null); - - NullPointerException nullActionError = - assertThrows(NullPointerException.class, () -> new FeasibilityProposeGenerator() - .generate( - context(1), - nullAction, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary)); - assertTrue(nullActionError.getMessage().contains("action must not be null")); - - FeasibilityProposeContent emptyEvaluation = - new FeasibilityProposeContent("描述", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null, null); - - NegotiationGenerationException emptyEvaluationError = - assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() - .generate( - context(1), - emptyEvaluation, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary)); - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), emptyEvaluationError.getCode()); - assertEquals( - "content.contentsToEvaluate", emptyEvaluationError.getFacts().get("field")); - - FeasibilityProposeContent emptyAlternative = new FeasibilityProposeContent( - "描述", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, List.of(), null); - - NegotiationGenerationException emptyAlternativeError = - assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() - .generate( - context(1), - emptyAlternative, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary)); - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), emptyAlternativeError.getCode()); - assertEquals( - "content.infeasibilityDetailsAndProposal", - emptyAlternativeError.getFacts().get("field")); - - FeasibilityProposeContent blankDescription = new FeasibilityProposeContent( - " ", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("名称", "值")), - null, - null); - - NegotiationGenerationException blankDescriptionError = - assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() - .generate( - context(1), - blankDescription, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary)); - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), blankDescriptionError.getCode()); - assertEquals( - "content.feasibilityNegotiationDescription", - blankDescriptionError.getFacts().get("field")); - } - - @Test - void targetConfirmRequestRendersOnlyTheSummaryAndConfirmRequestSections() { - TargetProposeContent content = - new TargetProposeContent("任务目标澄清完成,请答复<目标澄清后的确认请求>。", null, null, null, "目标已经澄清,是否同意按照此目标继续执行?"); - - String rendered = new TargetProposeGenerator() - .generate( - context(2), - content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_CONFIRM_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 目标协商\n任务目标澄清完成,请答复<目标澄清后的确认请求>。")); - assertTrue(rendered.contains("## 目标澄清后的确认请求\n目标已经澄清,是否同意按照此目标继续执行?")); - assertFalse(rendered.contains("## 意图理解陈述")); - assertFalse(rendered.contains("## 理解对齐与疑问澄清")); - assertFalse(rendered.contains("## 待澄清内容")); - assertFalse(rendered.contains("要求:")); - - String renderedEn = new TargetProposeGenerator() - .generate( - context(2), - new TargetProposeContent( - "The clarification of the task target has been completed. Please reply to .", - null, - null, - null, - "The target has been clarified. Do you agree to proceed with this target?"), - template( - StandardTemplates.TARGET_NEGOTIATION_PROPOSE, - """ - ## Target Negotiation - {{target_negotiation_summary}} (required) - Requirement: summary. - - ## Intent Understanding Statement - {{intent_understanding_statement}} (optional) - Requirement: intent. - - ## Target Clarification Confirmation Request - {{target_confirm_request}} (optional) - Requirement: confirm. - """), - enVocabulary); - - assertTrue(renderedEn.contains("## Target Clarification Confirmation Request\nThe target has been clarified." - + " Do you agree to proceed with this target?")); - assertFalse(renderedEn.contains("## Intent Understanding Statement")); - } - - @Test - void feasibilityConfirmRequestRendersOnlyTheSummaryAndConfirmRequestSections() { - FeasibilityProposeContent content = new FeasibilityProposeContent( - "针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - null, - null, - "评估目标可行,是否同意按照此目标继续执行?"); - - String rendered = new FeasibilityProposeGenerator() - .generate( - context(2), - content, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_CONFIRM_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 可行性协商\n针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。")); - assertTrue(rendered.contains("## 评估可行时的确认请求\n评估目标可行,是否同意按照此目标继续执行?")); - assertFalse(rendered.contains("## 待评估内容说明")); - assertFalse(rendered.contains("## 评估不可行时的详情和提案")); - assertFalse(rendered.contains("要求:")); - - String renderedEn = new FeasibilityProposeGenerator() - .generate( - context(2), - new FeasibilityProposeContent( - "Regarding the adjusted rate guarantee target, the feasibility assessment has been" - + " completed and the conclusion is feasible. Please reply to .", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - null, - null, - "The target is assessed as feasible. Do you agree to proceed with this target?"), - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - """ - ## Feasibility Negotiation - {{feasibility_negotiation_summary}} (required) - Requirement: summary. - - ## Under Evaluation Description - {{under_evaluation_description}} (optional) - Requirement: evaluate. - - ## Feasible Evaluation Confirmation Request - {{feasibility_confirm_request}} (optional) - Requirement: confirm. - """), - enVocabulary); - - assertTrue(renderedEn.contains("## Feasible Evaluation Confirmation Request\nThe target is assessed as" - + " feasible. Do you agree to proceed with this target?")); - assertFalse(renderedEn.contains("## Under Evaluation Description")); - } - - @Test - void targetFirstRoundRendersIntentSectionAndDropsAlignmentSection() { - TargetProposeContent content = new TargetProposeContent( - "对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。", - List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), - List.of(new NegotiationItem("对齐项", "不应在首轮渲染")), - List.of(new NegotiationItem("节能时间范围", "需要澄清")), - null); - - String rendered = new TargetProposeGenerator() - .generate( - context(1), - content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 意图理解陈述\n1. 发起方理解:对方希望在体验无损的前提下降低节能力度")); - assertFalse(rendered.contains("## 理解对齐与疑问澄清")); - assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); - assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。")); - } - - @Test - void targetLaterRoundRendersAlignmentSectionAndDropsIntentSection() { - TargetProposeContent content = new TargetProposeContent( - "已提供疑问澄清,请确认。", - List.of(new NegotiationItem("意图项", "不应在非首轮渲染")), - List.of(new NegotiationItem("确认结果", "节能时间范围确认为08:00~18:00")), - null, - null); - - String rendered = new TargetProposeGenerator() - .generate( - context(3), - content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 理解对齐与疑问澄清\n1. 确认结果:节能时间范围确认为08:00~18:00")); - assertFalse(rendered.contains("## 意图理解陈述")); - assertFalse(rendered.contains("## 待澄清内容")); - } - - @Test - void targetBlankDescriptionIsRejected() { - TargetProposeContent content = new TargetProposeContent(" ", null, null, null, null); - - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> new TargetProposeGenerator() - .generate( - context(1), - content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), - zhVocabulary)); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); - assertEquals( - "content.targetNegotiationDescription", exception.getFacts().get("field")); - } - - @Test - void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { - InformationProposeContent withRelationship = new InformationProposeContent( - List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点"), new NegotiationItem("受影响小区标识", "CGI 或小区名称")), - "故障发生时间与受影响小区标识需逐小区对应"); - - String renderedZh = new InformationProposeGenerator() - .generate( - context(1), - withRelationship, - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(renderedZh.contains("## 所需信息项\n1. 故障发生时间:精确到分钟的时间点\n2. 受影响小区标识:CGI 或小区名称")); - assertTrue(renderedZh.endsWith("缺失项之间的关系:故障发生时间与受影响小区标识需逐小区对应")); - - InformationProposeContent withoutRelationship = - new InformationProposeContent(List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点")), null); - - String renderedWithout = new InformationProposeGenerator() - .generate( - context(1), - withoutRelationship, - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), - zhVocabulary); - - assertFalse(renderedWithout.contains("缺失项之间的关系")); - } - - @Test - void informationProposeRendersEnglishRelationshipLabelWithTrailingSpace() { - InformationProposeContent content = new InformationProposeContent( - List.of(new NegotiationItem("Failure time", "minute precision")), - "failure time and cell identity must correspond per cell"); - - PromptTemplate enTemplate = template( - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, - """ - ## Required Information Items - {{required_information_items}} (required) - Requirement: items. - """); - - String rendered = new InformationProposeGenerator().generate(context(1), content, enTemplate, enVocabulary); - - assertTrue(rendered.endsWith( - "Relationship between missing items: failure time and cell identity must correspond per cell")); - } - - @Test - void endingGeneratorsRenderConclusionLiterals() { - String infoRendered = new InformationEndingGenerator() - .generate( - context(2, NegotiationPerformative.ACCEPT), - new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30"))), - template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), - zhVocabulary); - - assertTrue(infoRendered.contains("## 信息协商结果\nAccept")); - assertTrue(infoRendered.contains("## 信息协商结果内容\n1. 故障发生时间:2026-08-19 10:30")); - - String targetRendered = new TargetEndingGenerator() - .generate( - context(2, NegotiationPerformative.REJECT), - new TargetEndingContent(NegotiationConclusion.REJECT, null, "双方未就速率保障下限达成一致。"), - template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), - zhVocabulary); - - assertTrue(targetRendered.contains("## 目标协商结果\nReject")); - assertTrue(targetRendered.contains("## 目标协商结果内容\n双方未就速率保障下限达成一致。")); - - String feasibilityRendered = new FeasibilityEndingGenerator() - .generate( - context(2), - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "同意将速率保障目标由5Mbps下调至2Mbps。"), - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - ZH_FEASIBILITY_ENDING_TEMPLATE), - zhVocabulary); - - assertTrue(feasibilityRendered.contains("## 可行性协商结果\nAccept")); - assertTrue(feasibilityRendered.contains("## 可行性评估结果确认\n同意将速率保障目标由5Mbps下调至2Mbps。")); - } - - @Test - void informationEndingRejectsEmptyResultItems() { - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> new InformationEndingGenerator() - .generate( - context(2), - new InformationEndingContent(NegotiationConclusion.REJECT, List.of()), - template( - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, - ZH_INFO_ENDING_TEMPLATE), - zhVocabulary)); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); - assertEquals("items", exception.getFacts().get("field")); - } - - @Test - void informationProposeRejectsEmptyRequestedItems() { - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> new InformationProposeGenerator() - .generate( - context(1), - new InformationProposeContent(List.of(), null), - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), - zhVocabulary)); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); - assertEquals("items", exception.getFacts().get("field")); - } - - @Test - void abortConclusionIsRejectedByEveryEndingGenerator() { - assertThrows(IllegalArgumentException.class, () -> new InformationEndingGenerator() - .generate( - context(1), - new InformationEndingContent(NegotiationConclusion.ABORT, List.of()), - template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), - zhVocabulary)); - assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() - .generate( - context(1), - new TargetEndingContent(NegotiationConclusion.ABORT, "意图", null), - template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), - zhVocabulary)); - assertThrows(IllegalArgumentException.class, () -> new FeasibilityEndingGenerator() - .generate( - context(1), - new FeasibilityEndingContent(NegotiationConclusion.ABORT, "结论"), - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - ZH_FEASIBILITY_ENDING_TEMPLATE), - zhVocabulary)); - } - - @Test - void nullConclusionIsRejected() { - NullPointerException exception = assertThrows(NullPointerException.class, () -> new InformationEndingGenerator() - .generate( - context(1), - new InformationEndingContent(null, List.of()), - template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), - zhVocabulary)); - - assertTrue(exception.getMessage().contains("conclusion must not be null")); - } - - @Test - void targetEndingRequiresTheFieldMatchingTheConclusion() { - NegotiationGenerationException acceptWithoutIntent = - assertThrows(NegotiationGenerationException.class, () -> new TargetEndingGenerator() - .generate( - context(1), - new TargetEndingContent(NegotiationConclusion.ACCEPT, null, "失败原因"), - template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), - zhVocabulary)); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), acceptWithoutIntent.getCode()); - assertEquals("content.confirmedIntent", acceptWithoutIntent.getFacts().get("field")); - - NegotiationGenerationException rejectWithoutReason = - assertThrows(NegotiationGenerationException.class, () -> new TargetEndingGenerator() - .generate( - context(1), - new TargetEndingContent(NegotiationConclusion.REJECT, " ", null), - template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), - zhVocabulary)); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), rejectWithoutReason.getCode()); - assertEquals("content.failureReason", rejectWithoutReason.getFacts().get("field")); - } - - @Test - void feasibilityEndingRequiresTheSummary() { - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> new FeasibilityEndingGenerator() - .generate( - context(1), - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " "), - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - ZH_FEASIBILITY_ENDING_TEMPLATE), - zhVocabulary)); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); - assertEquals("content.feasibilitySummary", exception.getFacts().get("field")); - } - - @Test - void generatorRejectsContentOfAnotherRuntimeType() { - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> new InformationProposeGenerator() - .generate( - context(1), - new TargetProposeContent("描述", null, null, null, null), - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), - zhVocabulary)); - - assertTrue(exception.getMessage().contains("requires content of type InformationProposeContent")); - } - - @Test - void generatorsRenderAgainstTheBundledTemplates() { - NegotiationReference reference = NegotiationReference.tryParse( - StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), NegotiationPerformative.PROPOSE, "zh-CN") - .orElseThrow(() -> new AssertionError("expected the bundled target propose template to resolve")); - PromptTemplate loaded = new DefaultNegotiationTemplateLoader("zh-CN").load(reference); - - TargetProposeContent content = new TargetProposeContent( - "对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。", - List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), - null, - List.of(new NegotiationItem("节能时间范围", "需要澄清")), - null); - - String rendered = new TargetProposeGenerator().generate(context(1), content, loaded, zhVocabulary); - - assertTrue(rendered.startsWith("## 目标协商\n")); - assertFalse(rendered.contains("协商上下文"), "the context section must not be rendered"); - assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。")); - assertTrue(rendered.contains("## 意图理解陈述")); - assertFalse(rendered.contains("## 理解对齐与疑问澄清")); - assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); - assertFalse(rendered.contains("要求:")); - assertFalse(rendered.endsWith("\n")); - } - - private static NegotiationContext context(int round) { - return context(round, NegotiationPerformative.PROPOSE); - } - - private static NegotiationContext context(int round, NegotiationPerformative performative) { - return new NegotiationContext(CONTEXT_ID, round, 5, performative); - } - - private static PromptTemplate template(TemplateUri templateUri, String content) { - return new PromptTemplate(templateUri, "", content, "classpath"); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +class NegotiationGeneratorsTest { + + private static final String CONTEXT_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String ZH_TARGET_PROPOSE_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 目标协商 + {{目标协商概述}}(必填) + 要求: + 概述要求。 + + ## 意图理解陈述 + {{意图理解陈述}}(选填) + 要求: + 意图要求。 + + ## 理解对齐与疑问澄清 + {{理解对齐与疑问澄清}}(选填) + 要求: + 对齐要求。 + + ## 待澄清内容 + {{待澄清内容}}(选填) + 要求: + 澄清要求。 + """; + + private static final String ZH_FEASIBILITY_PROPOSE_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 可行性协商 + {{可行性协商概述}}(必填) + 要求: + 概述要求。 + + ## 待评估内容说明 + {{待评估内容说明}}(选填) + 要求: + 评估要求。 + + ## 评估不可行时的详情和提案 + {{评估不可行时的详情和提案}}(选填) + 要求: + 提案要求。 + """; + + private static final String ZH_TARGET_PROPOSE_CONFIRM_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 目标协商 + {{目标协商概述}}(必填) + 要求: + 概述要求。 + + ## 意图理解陈述 + {{意图理解陈述}}(选填) + 要求: + 意图要求。 + + ## 理解对齐与疑问澄清 + {{理解对齐与疑问澄清}}(选填) + 要求: + 对齐要求。 + + ## 待澄清内容 + {{待澄清内容}}(选填) + 要求: + 澄清要求。 + + ## 目标澄清后的确认请求 + {{目标澄清后的确认请求}}(选填) + 要求: + 确认要求。 + """; + + private static final String ZH_FEASIBILITY_PROPOSE_CONFIRM_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 可行性协商 + {{可行性协商概述}}(必填) + 要求: + 概述要求。 + + ## 待评估内容说明 + {{待评估内容说明}}(选填) + 要求: + 评估要求。 + + ## 评估不可行时的详情和提案 + {{评估不可行时的详情和提案}}(选填) + 要求: + 提案要求。 + + ## 评估可行时的确认请求 + {{评估可行时的确认请求}}(选填) + 要求: + 确认要求。 + """; + + private static final String ZH_INFO_PROPOSE_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 信息协商 + 请根据<所需信息项>补充相关内容。 + + ## 所需信息项 + {{所需信息项}}(必填) + 要求: + 信息项要求。 + """; + + private static final String ZH_INFO_ENDING_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 信息协商结果 + {{信息协商结果}}(必填) + 要求: + 结论要求。 + + ## 信息协商结果内容 + {{信息协商结果内容}}(必填) + 要求: + 内容要求。 + """; + + private static final String ZH_TARGET_ENDING_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 目标协商结果 + {{目标协商结果}}(必填) + 要求: + 结论要求。 + + ## 目标协商结果内容 + {{目标协商结果内容}}(必填) + 要求: + 内容要求。 + """; + + private static final String ZH_FEASIBILITY_ENDING_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 可行性协商结果 + {{可行性协商结果}}(必填) + 要求: + 结论要求。 + + ## 可行性评估结果确认 + {{评估结果确认}}(必填) + 要求: + 确认要求。 + """; + + private final Vocabulary zhVocabulary = Vocabulary.forLanguage("zh-CN"); + + private final Vocabulary enVocabulary = Vocabulary.forLanguage("en-US"); + + @Test + void feasibilityRequestActionRendersExactlyTheEvaluationSection() { + FeasibilityProposeContent content = new FeasibilityProposeContent( + "请协助评估该节能目标能否达成。", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("评估对象", "停电8小时期间核心用户的速率保障")), + List.of(new NegotiationItem("不应出现", "值")), + null); + + String rendered = new FeasibilityProposeGenerator() + .generate( + context(1), + content, + template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 可行性协商\n请协助评估该节能目标能否达成。")); + assertTrue(rendered.contains("## 待评估内容说明\n1. 评估对象:停电8小时期间核心用户的速率保障")); + assertFalse(rendered.contains("## 评估不可行时的详情和提案")); + assertFalse(rendered.contains("要求:")); + } + + @Test + void feasibilityAlternativeActionRendersExactlyTheInfeasibilitySection() { + FeasibilityProposeContent content = new FeasibilityProposeContent( + "当前速率目标不可行,提出下调方案。", + NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, + List.of(new NegotiationItem("不应出现", "值")), + List.of( + new NegotiationItem("不可行原因", "蓄电池仅能支撑8小时2Mbps的保障能力"), + new NegotiationItem("替代提案", "停电期间将速率保障目标下调至2Mbps")), + null); + + String rendered = new FeasibilityProposeGenerator() + .generate( + context(2), + content, + template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 可行性协商\n当前速率目标不可行,提出下调方案。")); + assertTrue(rendered.contains("## 评估不可行时的详情和提案\n1. 不可行原因:蓄电池仅能支撑8小时2Mbps的保障能力")); + assertTrue(rendered.contains("2. 替代提案:停电期间将速率保障目标下调至2Mbps")); + assertFalse(rendered.contains("## 待评估内容说明")); + } + + @Test + void feasibilityNullActionOrEmptyDrivenContentIsRejected() { + FeasibilityProposeContent nullAction = + new FeasibilityProposeContent("描述", null, List.of(new NegotiationItem("名称", "值")), null, null); + + NullPointerException nullActionError = + assertThrows(NullPointerException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + nullAction, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertTrue(nullActionError.getMessage().contains("action must not be null")); + + FeasibilityProposeContent emptyEvaluation = + new FeasibilityProposeContent("描述", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null, null); + + NegotiationGenerationException emptyEvaluationError = + assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + emptyEvaluation, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), emptyEvaluationError.getCode()); + assertEquals( + "content.contentsToEvaluate", emptyEvaluationError.getFacts().get("field")); + + FeasibilityProposeContent emptyAlternative = new FeasibilityProposeContent( + "描述", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, List.of(), null); + + NegotiationGenerationException emptyAlternativeError = + assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + emptyAlternative, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), emptyAlternativeError.getCode()); + assertEquals( + "content.infeasibilityDetailsAndProposal", + emptyAlternativeError.getFacts().get("field")); + + FeasibilityProposeContent blankDescription = new FeasibilityProposeContent( + " ", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("名称", "值")), + null, + null); + + NegotiationGenerationException blankDescriptionError = + assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + blankDescription, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), blankDescriptionError.getCode()); + assertEquals( + "content.feasibilityNegotiationDescription", + blankDescriptionError.getFacts().get("field")); + } + + @Test + void targetConfirmRequestRendersOnlyTheSummaryAndConfirmRequestSections() { + TargetProposeContent content = + new TargetProposeContent("任务目标澄清完成,请答复<目标澄清后的确认请求>。", null, null, null, "目标已经澄清,是否同意按照此目标继续执行?"); + + String rendered = new TargetProposeGenerator() + .generate( + context(2), + content, + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_CONFIRM_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 目标协商\n任务目标澄清完成,请答复<目标澄清后的确认请求>。")); + assertTrue(rendered.contains("## 目标澄清后的确认请求\n目标已经澄清,是否同意按照此目标继续执行?")); + assertFalse(rendered.contains("## 意图理解陈述")); + assertFalse(rendered.contains("## 理解对齐与疑问澄清")); + assertFalse(rendered.contains("## 待澄清内容")); + assertFalse(rendered.contains("要求:")); + + String renderedEn = new TargetProposeGenerator() + .generate( + context(2), + new TargetProposeContent( + "The clarification of the task target has been completed. Please reply to .", + null, + null, + null, + "The target has been clarified. Do you agree to proceed with this target?"), + template( + StandardTemplates.TARGET_NEGOTIATION_PROPOSE, + """ + ## Target Negotiation + {{target_negotiation_summary}} (required) + Requirement: summary. + + ## Intent Understanding Statement + {{intent_understanding_statement}} (optional) + Requirement: intent. + + ## Target Clarification Confirmation Request + {{target_confirm_request}} (optional) + Requirement: confirm. + """), + enVocabulary); + + assertTrue(renderedEn.contains("## Target Clarification Confirmation Request\nThe target has been clarified." + + " Do you agree to proceed with this target?")); + assertFalse(renderedEn.contains("## Intent Understanding Statement")); + } + + @Test + void feasibilityConfirmRequestRendersOnlyTheSummaryAndConfirmRequestSections() { + FeasibilityProposeContent content = new FeasibilityProposeContent( + "针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + null, + null, + "评估目标可行,是否同意按照此目标继续执行?"); + + String rendered = new FeasibilityProposeGenerator() + .generate( + context(2), + content, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_CONFIRM_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 可行性协商\n针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。")); + assertTrue(rendered.contains("## 评估可行时的确认请求\n评估目标可行,是否同意按照此目标继续执行?")); + assertFalse(rendered.contains("## 待评估内容说明")); + assertFalse(rendered.contains("## 评估不可行时的详情和提案")); + assertFalse(rendered.contains("要求:")); + + String renderedEn = new FeasibilityProposeGenerator() + .generate( + context(2), + new FeasibilityProposeContent( + "Regarding the adjusted rate guarantee target, the feasibility assessment has been" + + " completed and the conclusion is feasible. Please reply to .", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + null, + null, + "The target is assessed as feasible. Do you agree to proceed with this target?"), + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + """ + ## Feasibility Negotiation + {{feasibility_negotiation_summary}} (required) + Requirement: summary. + + ## Under Evaluation Description + {{under_evaluation_description}} (optional) + Requirement: evaluate. + + ## Feasible Evaluation Confirmation Request + {{feasibility_confirm_request}} (optional) + Requirement: confirm. + """), + enVocabulary); + + assertTrue(renderedEn.contains("## Feasible Evaluation Confirmation Request\nThe target is assessed as" + + " feasible. Do you agree to proceed with this target?")); + assertFalse(renderedEn.contains("## Under Evaluation Description")); + } + + @Test + void targetFirstRoundRendersIntentSectionAndDropsAlignmentSection() { + TargetProposeContent content = new TargetProposeContent( + "对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。", + List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), + List.of(new NegotiationItem("对齐项", "不应在首轮渲染")), + List.of(new NegotiationItem("节能时间范围", "需要澄清")), + null); + + String rendered = new TargetProposeGenerator() + .generate( + context(1), + content, + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 意图理解陈述\n1. 发起方理解:对方希望在体验无损的前提下降低节能力度")); + assertFalse(rendered.contains("## 理解对齐与疑问澄清")); + assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); + assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。")); + } + + @Test + void targetLaterRoundRendersAlignmentSectionAndDropsIntentSection() { + TargetProposeContent content = new TargetProposeContent( + "已提供疑问澄清,请确认。", + List.of(new NegotiationItem("意图项", "不应在非首轮渲染")), + List.of(new NegotiationItem("确认结果", "节能时间范围确认为08:00~18:00")), + null, + null); + + String rendered = new TargetProposeGenerator() + .generate( + context(3), + content, + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 理解对齐与疑问澄清\n1. 确认结果:节能时间范围确认为08:00~18:00")); + assertFalse(rendered.contains("## 意图理解陈述")); + assertFalse(rendered.contains("## 待澄清内容")); + } + + @Test + void targetBlankDescriptionIsRejected() { + TargetProposeContent content = new TargetProposeContent(" ", null, null, null, null); + + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new TargetProposeGenerator() + .generate( + context(1), + content, + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), + zhVocabulary)); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals( + "content.targetNegotiationDescription", exception.getFacts().get("field")); + } + + @Test + void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { + InformationProposeContent withRelationship = new InformationProposeContent( + List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点"), new NegotiationItem("受影响小区标识", "CGI 或小区名称")), + "故障发生时间与受影响小区标识需逐小区对应"); + + String renderedZh = new InformationProposeGenerator() + .generate( + context(1), + withRelationship, + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(renderedZh.contains("## 所需信息项\n1. 故障发生时间:精确到分钟的时间点\n2. 受影响小区标识:CGI 或小区名称")); + assertTrue(renderedZh.endsWith("缺失项之间的关系:故障发生时间与受影响小区标识需逐小区对应")); + + InformationProposeContent withoutRelationship = + new InformationProposeContent(List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点")), null); + + String renderedWithout = new InformationProposeGenerator() + .generate( + context(1), + withoutRelationship, + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary); + + assertFalse(renderedWithout.contains("缺失项之间的关系")); + } + + @Test + void informationProposeRendersEnglishRelationshipLabelWithTrailingSpace() { + InformationProposeContent content = new InformationProposeContent( + List.of(new NegotiationItem("Failure time", "minute precision")), + "failure time and cell identity must correspond per cell"); + + PromptTemplate enTemplate = template( + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, + """ + ## Required Information Items + {{required_information_items}} (required) + Requirement: items. + """); + + String rendered = new InformationProposeGenerator().generate(context(1), content, enTemplate, enVocabulary); + + assertTrue(rendered.endsWith( + "Relationship between missing items: failure time and cell identity must correspond per cell")); + } + + @Test + void endingGeneratorsRenderConclusionLiterals() { + String infoRendered = new InformationEndingGenerator() + .generate( + context(2, NegotiationPerformative.ACCEPT), + new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30"))), + template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), + zhVocabulary); + + assertTrue(infoRendered.contains("## 信息协商结果\nAccept")); + assertTrue(infoRendered.contains("## 信息协商结果内容\n1. 故障发生时间:2026-08-19 10:30")); + + String targetRendered = new TargetEndingGenerator() + .generate( + context(2, NegotiationPerformative.REJECT), + new TargetEndingContent(NegotiationConclusion.REJECT, null, "双方未就速率保障下限达成一致。"), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary); + + assertTrue(targetRendered.contains("## 目标协商结果\nReject")); + assertTrue(targetRendered.contains("## 目标协商结果内容\n双方未就速率保障下限达成一致。")); + + String feasibilityRendered = new FeasibilityEndingGenerator() + .generate( + context(2), + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "同意将速率保障目标由5Mbps下调至2Mbps。"), + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + ZH_FEASIBILITY_ENDING_TEMPLATE), + zhVocabulary); + + assertTrue(feasibilityRendered.contains("## 可行性协商结果\nAccept")); + assertTrue(feasibilityRendered.contains("## 可行性评估结果确认\n同意将速率保障目标由5Mbps下调至2Mbps。")); + } + + @Test + void informationEndingRejectsEmptyResultItems() { + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new InformationEndingGenerator() + .generate( + context(2), + new InformationEndingContent(NegotiationConclusion.REJECT, List.of()), + template( + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, + ZH_INFO_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals("items", exception.getFacts().get("field")); + } + + @Test + void informationProposeRejectsEmptyRequestedItems() { + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new InformationProposeGenerator() + .generate( + context(1), + new InformationProposeContent(List.of(), null), + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary)); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals("items", exception.getFacts().get("field")); + } + + @Test + void abortConclusionIsRejectedByEveryEndingGenerator() { + assertThrows(IllegalArgumentException.class, () -> new InformationEndingGenerator() + .generate( + context(1), + new InformationEndingContent(NegotiationConclusion.ABORT, List.of()), + template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), + zhVocabulary)); + assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() + .generate( + context(1), + new TargetEndingContent(NegotiationConclusion.ABORT, "意图", null), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary)); + assertThrows(IllegalArgumentException.class, () -> new FeasibilityEndingGenerator() + .generate( + context(1), + new FeasibilityEndingContent(NegotiationConclusion.ABORT, "结论"), + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + ZH_FEASIBILITY_ENDING_TEMPLATE), + zhVocabulary)); + } + + @Test + void nullConclusionIsRejected() { + NullPointerException exception = assertThrows(NullPointerException.class, () -> new InformationEndingGenerator() + .generate( + context(1), + new InformationEndingContent(null, List.of()), + template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), + zhVocabulary)); + + assertTrue(exception.getMessage().contains("conclusion must not be null")); + } + + @Test + void targetEndingRequiresTheFieldMatchingTheConclusion() { + NegotiationGenerationException acceptWithoutIntent = + assertThrows(NegotiationGenerationException.class, () -> new TargetEndingGenerator() + .generate( + context(1), + new TargetEndingContent(NegotiationConclusion.ACCEPT, null, "失败原因"), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), acceptWithoutIntent.getCode()); + assertEquals("content.confirmedIntent", acceptWithoutIntent.getFacts().get("field")); + + NegotiationGenerationException rejectWithoutReason = + assertThrows(NegotiationGenerationException.class, () -> new TargetEndingGenerator() + .generate( + context(1), + new TargetEndingContent(NegotiationConclusion.REJECT, " ", null), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), rejectWithoutReason.getCode()); + assertEquals("content.failureReason", rejectWithoutReason.getFacts().get("field")); + } + + @Test + void feasibilityEndingRequiresTheSummary() { + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new FeasibilityEndingGenerator() + .generate( + context(1), + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " "), + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + ZH_FEASIBILITY_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals("content.feasibilitySummary", exception.getFacts().get("field")); + } + + @Test + void generatorRejectsContentOfAnotherRuntimeType() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new InformationProposeGenerator() + .generate( + context(1), + new TargetProposeContent("描述", null, null, null, null), + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary)); + + assertTrue(exception.getMessage().contains("requires content of type InformationProposeContent")); + } + + @Test + void generatorsRenderAgainstTheBundledTemplates() { + NegotiationReference reference = NegotiationReference.tryParse( + StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), NegotiationPerformative.PROPOSE, "zh-CN") + .orElseThrow(() -> new AssertionError("expected the bundled target propose template to resolve")); + PromptTemplate loaded = new DefaultNegotiationTemplateLoader("zh-CN").load(reference); + + TargetProposeContent content = new TargetProposeContent( + "对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。", + List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), + null, + List.of(new NegotiationItem("节能时间范围", "需要澄清")), + null); + + String rendered = new TargetProposeGenerator().generate(context(1), content, loaded, zhVocabulary); + + assertTrue(rendered.startsWith("## 目标协商\n")); + assertFalse(rendered.contains("协商上下文"), "the context section must not be rendered"); + assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。")); + assertTrue(rendered.contains("## 意图理解陈述")); + assertFalse(rendered.contains("## 理解对齐与疑问澄清")); + assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); + assertFalse(rendered.contains("要求:")); + assertFalse(rendered.endsWith("\n")); + } + + private static NegotiationContext context(int round) { + return context(round, NegotiationPerformative.PROPOSE); + } + + private static NegotiationContext context(int round, NegotiationPerformative performative) { + return new NegotiationContext(CONTEXT_ID, round, 5, performative); + } + + private static PromptTemplate template(TemplateUri templateUri, String content) { + return new PromptTemplate(templateUri, "", content, "classpath"); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java index ac7bdffe..cd029a7e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java @@ -1,57 +1,60 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.junit.jupiter.api.Test; - -/** - * Verifies that the package-private {@link NegotiationPromptRenderer} is a pure function: repeated concurrent calls - * with the same input must return the same result. - */ -class NegotiationPromptRendererConcurrencyTest { - - private static final int THREADS = 8; - - private static final int ITERATIONS = 200; - - @Test - void promptRendererIsPureUnderConcurrentCalls() throws Exception { - String template = new DefaultNegotiationTemplateLoader("zh-CN") - .load(new NegotiationReference( - net.openan.a2at.sdk.negotiation.content.NegotiationType.INFORMATION, - net.openan.a2at.sdk.core.model.NegotiationPerformative.PROPOSE, - "zh-CN")) - .content(); - Map slots = - Map.of("协商上下文", "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 1\n- maxRounds: 5", "所需信息项", "1. 节能区域:松山湖"); - NegotiationPromptRenderer renderer = new NegotiationPromptRenderer(); - String baseline = renderer.render(template, slots); - - ExecutorService pool = Executors.newFixedThreadPool(THREADS); - try { - List> futures = new ArrayList<>(); - for (int index = 0; index < THREADS; index++) { - futures.add(pool.submit(() -> { - for (int iteration = 0; iteration < ITERATIONS; iteration++) { - assertEquals(baseline, renderer.render(template, slots), "renderer output must be stable"); - } - return null; - })); - } - for (Future future : futures) { - future.get(30, TimeUnit.SECONDS); - } - } finally { - pool.shutdownNow(); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +/** + * Verifies that the package-private {@link NegotiationPromptRenderer} is a pure function: repeated concurrent calls + * with the same input must return the same result. + */ +class NegotiationPromptRendererConcurrencyTest { + + private static final int THREADS = 8; + + private static final int ITERATIONS = 200; + + @Test + void promptRendererIsPureUnderConcurrentCalls() throws Exception { + String template = new DefaultNegotiationTemplateLoader("zh-CN") + .load(new NegotiationReference( + net.openan.a2at.sdk.negotiation.content.NegotiationType.INFORMATION, + net.openan.a2at.sdk.core.model.NegotiationPerformative.PROPOSE, + "zh-CN")) + .content(); + Map slots = Map.of( + "协商上下文", + "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 1\n- maxRounds: 5", + "所需信息项", + "1. 节能区域:松山湖"); + NegotiationPromptRenderer renderer = new NegotiationPromptRenderer(); + String baseline = renderer.render(template, slots); + + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + try { + List> futures = new ArrayList<>(); + for (int index = 0; index < THREADS; index++) { + futures.add(pool.submit(() -> { + for (int iteration = 0; iteration < ITERATIONS; iteration++) { + assertEquals(baseline, renderer.render(template, slots), "renderer output must be stable"); + } + return null; + })); + } + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } finally { + pool.shutdownNow(); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java index e8ae1b29..ab3a03e9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationResourceIntegrityTest.java @@ -16,12 +16,12 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.core.model.PromptTemplate; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -64,8 +64,7 @@ void allTwelveTemplatesExistAndAreNonEmptyOnTheClasspath() { for (NegotiationType type : NegotiationType.values()) { for (NegotiationPerformative performative : List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT)) { - PromptTemplate template = - loader.load(new NegotiationReference(type, performative, language)); + PromptTemplate template = loader.load(new NegotiationReference(type, performative, language)); assertFalse(template.content().isBlank()); templateCount++; } @@ -98,12 +97,12 @@ void everySlotMarkerResolvesAgainstTheVocabularyOfItsLanguage(String language) { String slotName = matcher.group(1); assertTrue( valuesToKeys.containsKey(slotName), - "the slot name " + slotName + " of " + template.templateUri().uri() + " must be a vocabulary value of " - + language); + "the slot name " + slotName + " of " + + template.templateUri().uri() + " must be a vocabulary value of " + language); assertTrue( valuesToKeys.containsKey(section.title), - "the section title " + section.title + " of " + template.templateUri().uri() - + " must be a vocabulary value of " + language); + "the section title " + section.title + " of " + + template.templateUri().uri() + " must be a vocabulary value of " + language); allSlotNames.add(slotName); if (!slotName.equals(section.title)) { slotsDifferingFromTheirTitle.add(slotName); @@ -234,7 +233,6 @@ private static List

        sectionsOf(String templateContent) { return sections; } - private static final class Section { private final String title; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java index 60217255..872c1914 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/TemplateUriEntryMatrixTest.java @@ -1,168 +1,169 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -/** - * Verifies the template-URI entry matrix of the from-data generation. - * - *

        The seven built-in URIs (six typed negotiation templates plus the common abort template) address their templates - * and render outputs identical to the golden fixtures; a well-formed - * URI that resolves to no template fails with the code {@code template.not_found} before any LLM call; a typed URI - * that does not address a negotiation template of the expected performative (wrong extension name, version, type - * segment, performative segment or separator, including the underscore misspelling of the type segment) fails as a - * programming error pointing at {@code templateUri}, while structural malformation is impossible by construction of - * {@link TemplateUri}. - */ -class TemplateUriEntryMatrixTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - /** - * Entry (a): every one of the seven built-in URIs (six typed templates plus the common abort template) reaches its - * template and renders output byte-identical to the golden fixtures locked by the comparison test. - */ - @Test - void everyBuiltInUriRendersItsGoldenFixture() { - for (String language : NegotiationGoldenCases.LANGUAGES) { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .build(); - Map> casesByUri = new LinkedHashMap<>(); - for (GoldenCase goldenCase : GoldenCase.values()) { - casesByUri - .computeIfAbsent(goldenCase.templateUri(), uri -> new ArrayList<>()) - .add(goldenCase); - } - assertEquals(7, casesByUri.size(), "the built-in template set has exactly seven URIs"); - for (Map.Entry> entry : casesByUri.entrySet()) { - for (GoldenCase goldenCase : entry.getValue()) { - MetadataContent result = goldenCase.generate(orchestrator, language); - assertEquals(entry.getKey(), result.templateUri()); - assertEquals(goldenCase.goldenText(language), result.promptText()); - } - } - } - } - - /** - * Entry (b): a well-formed URI that resolves to no template in any resource root fails with the code - * {@code template.not_found} before any LLM call. With the bundled resources every well-formed v1 URI resolves in - * both bundled languages, so the always-miss condition is realized by a loader whose every load misses. - */ - @Test - void wellFormedUriMissingInBothRootsFailsWithTemplateNotFound() { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .templateLoader(new MissingTemplateLoader()) - .llmClient(llm) - .build(); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromData(informationProposeData(), INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), failure.getCode()); - assertTrue( - failure.getMessage() != null && !failure.getMessage().isBlank(), - "the load failure message must be surfaced"); - assertEquals(0, llm.calls, "template loading happens before any LLM call"); - } - - /** - * Entry (c): every typed URI that does not address a negotiation template of the expected performative — wrong - * extension name, version, type segment, performative segment and separator (underscore misspelling), plus unknown - * types and unknown performative segments — fails as a programming error with an - * {@link IllegalArgumentException} pointing at the template URI, without any LLM call. Structurally malformed URIs - * cannot exist as {@link TemplateUri} values. - */ - @ParameterizedTest(name = "non-addressing URI [{0}] is rejected as a templateUri programming error") - @MethodSource("nonAddressingTemplateUris") - void nonAddressingUriIsRejectedAsATemplateUriProgrammingError(TemplateUri templateUri) { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> orchestrator.generateProposeFromData(informationProposeData(), templateUri)); - - assertTrue( - failure.getMessage().contains( - "Template URI does not address a negotiation template of the expected performative PROPOSE"), - "the failure must point at the template URI but was: " + failure.getMessage()); - assertEquals(0, llm.calls); - } - - static List nonAddressingTemplateUris() { - return List.of( - TemplateUri.of("Task-T", "information-negotiation", "propose"), - TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v2"), - TemplateUri.of("Negotiation-T", "information", "propose"), - TemplateUri.of("Negotiation-T", "information_negotiation", "propose"), - TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose"), - TemplateUri.of("Negotiation-T", "information-negotiation", "propose-x"), - TemplateUri.of("Negotiation-T", "information-negotiation", "accept")); - } - - private static NegotiationProposeData informationProposeData() { - return new NegotiationProposeData( - new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1")), null)); - } - - private static final class MissingTemplateLoader implements NegotiationTemplateLoader { - - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException( - "Negotiation template does not exist in any resource root.", reference.uri()); - } - } - - private static final class CountingClient implements LLMClient { - - private int calls; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - throw new AssertionError("The entry matrix must fail before any LLM call"); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Verifies the template-URI entry matrix of the from-data generation. + * + *

        The seven built-in URIs (six typed negotiation templates plus the common abort template) address their templates + * and render outputs identical to the golden fixtures; a well-formed URI that resolves to no template fails with the + * code {@code template.not_found} before any LLM call; a typed URI that does not address a negotiation template of the + * expected performative (wrong extension name, version, type segment, performative segment or separator, including the + * underscore misspelling of the type segment) fails as a programming error pointing at {@code templateUri}, while + * structural malformation is impossible by construction of {@link TemplateUri}. + */ +class TemplateUriEntryMatrixTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + /** + * Entry (a): every one of the seven built-in URIs (six typed templates plus the common abort template) reaches its + * template and renders output byte-identical to the golden fixtures locked by the comparison test. + */ + @Test + void everyBuiltInUriRendersItsGoldenFixture() { + for (String language : NegotiationGoldenCases.LANGUAGES) { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .build(); + Map> casesByUri = new LinkedHashMap<>(); + for (GoldenCase goldenCase : GoldenCase.values()) { + casesByUri + .computeIfAbsent(goldenCase.templateUri(), uri -> new ArrayList<>()) + .add(goldenCase); + } + assertEquals(7, casesByUri.size(), "the built-in template set has exactly seven URIs"); + for (Map.Entry> entry : casesByUri.entrySet()) { + for (GoldenCase goldenCase : entry.getValue()) { + MetadataContent result = goldenCase.generate(orchestrator, language); + assertEquals(entry.getKey(), result.templateUri()); + assertEquals(goldenCase.goldenText(language), result.promptText()); + } + } + } + } + + /** + * Entry (b): a well-formed URI that resolves to no template in any resource root fails with the code + * {@code template.not_found} before any LLM call. With the bundled resources every well-formed v1 URI resolves in + * both bundled languages, so the always-miss condition is realized by a loader whose every load misses. + */ + @Test + void wellFormedUriMissingInBothRootsFailsWithTemplateNotFound() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(new MissingTemplateLoader()) + .llmClient(llm) + .build(); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromData(informationProposeData(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), failure.getCode()); + assertTrue( + failure.getMessage() != null && !failure.getMessage().isBlank(), + "the load failure message must be surfaced"); + assertEquals(0, llm.calls, "template loading happens before any LLM call"); + } + + /** + * Entry (c): every typed URI that does not address a negotiation template of the expected performative — wrong + * extension name, version, type segment, performative segment and separator (underscore misspelling), plus unknown + * types and unknown performative segments — fails as a programming error with an {@link IllegalArgumentException} + * pointing at the template URI, without any LLM call. Structurally malformed URIs cannot exist as + * {@link TemplateUri} values. + */ + @ParameterizedTest(name = "non-addressing URI [{0}] is rejected as a templateUri programming error") + @MethodSource("nonAddressingTemplateUris") + void nonAddressingUriIsRejectedAsATemplateUriProgrammingError(TemplateUri templateUri) { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.generateProposeFromData(informationProposeData(), templateUri)); + + assertTrue( + failure.getMessage() + .contains( + "Template URI does not address a negotiation template of the expected performative PROPOSE"), + "the failure must point at the template URI but was: " + failure.getMessage()); + assertEquals(0, llm.calls); + } + + static List nonAddressingTemplateUris() { + return List.of( + TemplateUri.of("Task-T", "information-negotiation", "propose"), + TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v2"), + TemplateUri.of("Negotiation-T", "information", "propose"), + TemplateUri.of("Negotiation-T", "information_negotiation", "propose"), + TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose"), + TemplateUri.of("Negotiation-T", "information-negotiation", "propose-x"), + TemplateUri.of("Negotiation-T", "information-negotiation", "accept")); + } + + private static NegotiationProposeData informationProposeData() { + return new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent( + List.of(new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1")), null)); + } + + private static final class MissingTemplateLoader implements NegotiationTemplateLoader { + + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException( + "Negotiation template does not exist in any resource root.", reference.uri()); + } + } + + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new AssertionError("The entry matrix must fail before any LLM call"); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java index f70a638b..43f9463d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ValidateAndFillingDataPipelineTest.java @@ -1,591 +1,591 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -/** - * Integration tests of the three validate-and-filling pipelines of the negotiation content layer. - * - *

        Every test drives the real orchestrator with the built-in zh-CN templates and prompt resources; only the LLM - * boundary is scripted, so each test exercises the full chain from the rendered message through the rule gate, the - * merged semantic validation schema and the deterministic parameter merge. - */ -class ValidateAndFillingDataPipelineTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final NegotiationContext CONTEXT = - new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.PROPOSE); - - private static final NegotiationContext TARGET_CONTEXT = - new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.PROPOSE); - - private static final NegotiationContext ACCEPT_CONTEXT = - new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.ACCEPT); - - private static final NegotiationContext REJECT_CONTEXT = - new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.REJECT); - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final TemplateUri INFORMATION_ACCEPT_REJECT_URI = - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - - private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; - - private static final Map ACCESS_PORT_SCHEMA = Map.of( - "type", - "object", - "properties", - Map.of( - "accessPort", Map.of("type", "string"), - "bizScenario", Map.of("type", "string")), - "required", - List.of("accessPort", "bizScenario")); - - @Test - void legalProposeMessageRunsTheFullChainWithASingleLlmCall() { - ScriptedLlmClient llm = - new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"," - + "\"bizScenario\":\"专线质差\"}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI); - - assertEquals(1, llm.calls); - assertEquals(SESSION_ID, filled.data().get("id")); - assertEquals(2, filled.data().get("round")); - assertEquals(5, filled.data().get("maxRounds")); - assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); - assertEquals("专线质差", filled.data().get("bizScenario")); - assertEquals(5, filled.data().size()); - assertInstanceOf(String.class, filled.data().get("id"), "context id must be a string"); - assertInstanceOf(Integer.class, filled.data().get("round"), "context round must be a number"); - assertInstanceOf(Integer.class, filled.data().get("maxRounds"), "context maxRounds must be a number"); - } - - @Test - void legalAcceptMessageRunsTheFullChainWithASingleLlmCall() { - ScriptedLlmClient llm = - new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationEndingMessage(orchestrator, NegotiationConclusion.ACCEPT); - - FilledParamData filled = orchestrator.validateAcceptPromptAndDataFilling( - message, ACCEPT_CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); - - assertEquals(1, llm.calls); - assertEquals(SESSION_ID, filled.data().get("id")); - assertEquals(2, filled.data().get("round")); - assertEquals(5, filled.data().get("maxRounds")); - assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); - assertEquals(4, filled.data().size()); - } - - @Test - void legalRejectMessageRunsTheFullChainWithASingleLlmCall() { - ScriptedLlmClient llm = - new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"unavailable_item\":\"接入端口名称\"}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationEndingMessage(orchestrator, NegotiationConclusion.REJECT); - - FilledParamData filled = orchestrator.validateRejectPromptAndDataFilling( - message, REJECT_CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); - - assertEquals(1, llm.calls); - assertEquals(SESSION_ID, filled.data().get("id")); - assertEquals("接入端口名称", filled.data().get("unavailable_item")); - assertEquals(4, filled.data().size()); - } - - // The rule-violation table of this suite (non-uuid id, round above maxRounds, 0 LLM calls) is absorbed by the - // VAL-RULE batch of the test corpus (negotiation-cases/validate/rule-gate.json): every row there asserts the same - // negotiation.rule_violation code, the exact slot error pairs and the zero-LLM guarantee, plus the boundary and - // combined-error rows the hand-written table never carried (design §6 Q8). - - @Test - void semanticRejectionIsNotRetriedAndPassesTheErrorsThrough() { - Map conclusionFacts = - Map.of("conclusion", "Accept", "section_label", "section.info_conclusion"); - Map resultFacts = Map.of("section_label", "section.info_items"); - List semanticErrors = List.of( - new SlotValidationError( - "section.info_conclusion", - ErrorCatalog.NEGOTIATION_CONCLUSION_CONTENT_MISMATCH.getCode(), - ErrorMessages.render( - ErrorCatalog.NEGOTIATION_CONCLUSION_CONTENT_MISMATCH, "zh-CN", conclusionFacts), - conclusionFacts), - new SlotValidationError( - "section.info_items", - ErrorCatalog.NEGOTIATION_MISSING_RESULT_CONTENT.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_MISSING_RESULT_CONTENT, "zh-CN", resultFacts), - resultFacts)); - ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":null," - + "\"errors\":[{\"slot_name\":\"section.info_conclusion\"," - + "\"code\":\"negotiation.conclusion_content_mismatch\"," - + "\"facts\":{\"conclusion\":\"Accept\",\"section_label\":\"section.info_conclusion\"}}," - + "{\"slot_name\":\"section.info_items\",\"code\":\"negotiation.missing_result_content\"," - + "\"facts\":{\"section_label\":\"section.info_items\"}}],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); - assertEquals(semanticErrors, exception.getErrors()); - assertEquals(1, llm.calls, "a negative verdict is a decision, not a failure, and must not be retried"); - } - - @ParameterizedTest - @MethodSource("taskTMessages") - void taskTMessagesAreRejectedAsNonNegotiationInputWithARenderedMessage(String caseName, String taskTMessage) { - ScriptedLlmClient llm = new ScriptedLlmClient( - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - taskTMessage, null, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode(), caseName); - assertEquals("输入的协商内容无效:缺少协商上下文(该报文不是协商报文)", exception.getMessage(), caseName); - assertEquals(List.of(), exception.getErrors(), caseName); - assertEquals(0, llm.calls, caseName); - } - - static List taskTMessages() { - return List.of( - new Object[] { - "chinese task prompt", - "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n## 任务对象(Task Object)\n接入端口名称:P533-珠江旧城" - + "-PTN3900-23-TPA1EG24-1\n\n## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和" - + "修复建议等诊断结果信息。\n" - }, - new Object[] { - "english task prompt", - "## Task Type\nTransport private line service complaint diagnosis\n\n## Task Object\nAccess" - + " Port Name: P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\n\n## Task Target\nDiagnose" - + " network-side faults and return diagnostic result information.\n" - }); - } - - @Test - void nestedArrayParamsAreExtractedThroughTheMergedSchema() { - Map repairTargetItemSchema = Map.of( - "type", - "object", - "properties", - Map.of( - "latencyTarget", Map.of("type", "string"), - "completionDeadline", Map.of("type", "string"))); - Map callerSchema = Map.of( - "type", - "object", - "properties", - Map.of("repairTargets", Map.of("type", "array", "items", repairTargetItemSchema)), - "required", - List.of("repairTargets"), - "additionalProperties", - false); - ScriptedLlmClient llm = - new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[]," - + "\"params\":{\"repairTargets\":[{\"latencyTarget\":\"within 20ms\"," - + "\"completionDeadline\":\"48 hours\"}]}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = targetProposeMessage(orchestrator); - - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message, TARGET_CONTEXT, callerSchema, TARGET_PROPOSE_URI); - - assertEquals(1, llm.calls); - assertEquals(SESSION_ID, filled.data().get("id")); - assertEquals( - List.of(Map.of("latencyTarget", "within 20ms", "completionDeadline", "48 hours")), - filled.data().get("repairTargets")); - - Map mergedSchema = llm.lastSchema; - assertEquals(List.of("semantic_verdict", "negotiation_type", "errors", "params"), mergedSchema.get("required")); - assertEquals(false, mergedSchema.get("additionalProperties")); - Map properties = (Map) mergedSchema.get("properties"); - Map paramsSchema = (Map) properties.get("params"); - assertEquals("object", paramsSchema.get("type")); - Map repairTargets = (Map) ((Map) paramsSchema.get("properties")).get("repairTargets"); - assertEquals("array", repairTargets.get("type")); - } - - @Test - void callerSchemaWithoutATypeKeywordIsWrappedAndTheChainSucceeds() { - Map schemaWithoutType = - Map.of("properties", Map.of("accessPort", Map.of("type", "string")), "required", List.of("accessPort")); - ScriptedLlmClient llm = - new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message, CONTEXT, schemaWithoutType, INFORMATION_PROPOSE_URI); - - assertEquals(1, llm.calls); - assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); - Map paramsSchema = (Map) ((Map) llm.lastSchema.get("properties")).get("params"); - assertEquals("object", paramsSchema.get("type")); - assertEquals(Map.of("accessPort", Map.of("type", "string")), paramsSchema.get("properties")); - assertEquals(List.of("accessPort"), paramsSchema.get("required")); - } - - @Test - void missingNegotiationTypeKeyIsRetriedThenFailsAsAnInfrastructureError() { - ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":true,\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build(); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), exception.getCode()); - assertEquals(3, llm.calls, "a shape-invalid response is a retryable infrastructure failure"); - assertEquals(1, exception.getErrors().size()); - assertEquals("_llm", exception.getErrors().get(0).slotName()); - assertEquals("semantic_validation", exception.getFacts().get("step")); - assertTrue(exception.getCause().getCause().getMessage().contains("negotiation_type")); - } - - @Test - void declaredTypeMismatchIsASemanticRejection() { - ScriptedLlmClient llm = new ScriptedLlmClient( - "{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); - assertEquals(1, llm.calls, "a type mismatch is a semantic decision and must not be retried"); - assertEquals(1, exception.getErrors().size()); - assertEquals("section.target", exception.getErrors().get(0).slotName()); - assertEquals( - ErrorCatalog.NEGOTIATION_TYPE_MISMATCH.getCode(), - exception.getErrors().get(0).code()); - assertEquals("target", exception.getErrors().get(0).facts().get("implied")); - assertEquals("information", exception.getErrors().get(0).facts().get("declared")); - } - - @ParameterizedTest - @MethodSource("structuralSemanticCases") - void structuralSemanticChecksSurfaceThroughTheSemanticErrors( - String caseName, String payload, SlotValidationError expectedError) { - ScriptedLlmClient llm = new ScriptedLlmClient(payload); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode(), caseName); - assertEquals(List.of(expectedError), exception.getErrors(), caseName); - assertEquals(1, llm.calls, caseName); - } - - static List structuralSemanticCases() { - Map conclusionFacts = Map.of("expected", "Accept", "actual", "Abort"); - Map resultFacts = Map.of("section_label", "section.target_result_content"); - Map conflictFacts = - Map.of("section_label", "section.info_static", "reason", "the static section coexists with the items"); - return List.of( - new Object[] { - "conclusion outside accept and reject", - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" - + "{\"slot_name\":\"section.target_conclusion\"," - + "\"code\":\"negotiation.conclusion_mismatch\"," - + "\"facts\":{\"expected\":\"Accept\",\"actual\":\"Abort\"}}],\"params\":{}}", - new SlotValidationError( - "section.target_conclusion", - ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), - ErrorMessages.render( - ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH, "zh-CN", conclusionFacts), - conclusionFacts) - }, - new Object[] { - "ending result content section missing", - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" - + "{\"slot_name\":\"section.target_result_content\"," - + "\"code\":\"negotiation.missing_result_content\"," - + "\"facts\":{\"section_label\":\"section.target_result_content\"}}],\"params\":{}}", - new SlotValidationError( - "section.target_result_content", - ErrorCatalog.NEGOTIATION_MISSING_RESULT_CONTENT.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_MISSING_RESULT_CONTENT, "zh-CN", resultFacts), - resultFacts) - }, - new Object[] { - "information propose carries both conditional sections", - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" - + "{\"slot_name\":\"section.info_static\"," - + "\"code\":\"negotiation.field_inconsistency\"," - + "\"facts\":{\"section_label\":\"section.info_static\",\"reason\":" - + "\"the static section coexists with the items\"}}],\"params\":{}}", - new SlotValidationError( - "section.info_static", - ErrorCatalog.NEGOTIATION_FIELD_INCONSISTENCY.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_FIELD_INCONSISTENCY, "zh-CN", conflictFacts), - conflictFacts) - }); - } - - @Test - void falseVerdictWithNullTypeIsAShapeLegalOutcome() { - ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":null," - + "\"errors\":[{\"slot_name\":\"section.context\",\"code\":\"inconsistent_context\"," - + "\"facts\":{\"reason\":\"Context contradicts the message body.\"}}],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); - assertEquals(1, exception.getErrors().size()); - assertEquals("section.context", exception.getErrors().get(0).slotName()); - assertEquals( - ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), - exception.getErrors().get(0).code()); - assertEquals(1, llm.calls, "verdict false with a null type is shape-legal and must not be retried"); - } - - @Test - void trueVerdictWithNullTypeIsASemanticRejection() { - ScriptedLlmClient llm = new ScriptedLlmClient( - "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); - assertEquals(1, llm.calls, "a null type with a true verdict is a semantic rejection, not a retryable failure"); - assertEquals(1, exception.getErrors().size()); - assertEquals("section.info_static", exception.getErrors().get(0).slotName()); - assertEquals( - ErrorCatalog.NEGOTIATION_TYPE_MISMATCH.getCode(), - exception.getErrors().get(0).code()); - } - - @Test - void semanticLlmFailureIsRetriedAndExhaustsWithTheLlmPseudoSlot() { - ScriptedLlmClient llm = new ScriptedLlmClient("unused"); - llm.failure = new LLMRuntimeError("LLM endpoint unavailable"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(2) - .build(); - String message = informationProposeMessage(orchestrator); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), exception.getCode()); - assertEquals(2, llm.calls); - assertEquals(1, exception.getErrors().size()); - assertEquals("_llm", exception.getErrors().get(0).slotName()); - assertTrue(exception.getMessage().contains("endpoint unavailable")); - } - - @Test - void missingPromptResourcesCloseAsTemplateNotFoundWithoutBubblingTheResourceException() { - ScriptedLlmClient llm = new ScriptedLlmClient("unused"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .semanticValidator((prompt, callerSchema, reference, templateContent) -> { - throw new ResourceNotFoundException( - "Negotiation semantic validation prompt resource does not exist.", - "prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md"); - }) - .build(); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 接入端口名称\n", - new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.PROPOSE), - ACCESS_PORT_SCHEMA, - INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), exception.getCode()); - assertEquals( - NegotiationParamExtractionException.class, - exception.getClass(), - "the raw resource exception must not bubble out of the pipeline"); - assertTrue(exception instanceof A2ATError, "the mapped failure stays catchable through the A2ATError root"); - assertEquals(0, llm.calls); - } - - @Test - void proposeUriValidatingAResultMessageIsASemanticRejection() { - Map phaseFacts = Map.of("implied", "accept-reject", "declared", "propose"); - SlotValidationError phaseError = new SlotValidationError( - "section.info_result_content", - ErrorCatalog.NEGOTIATION_PHASE_MISMATCH.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_PHASE_MISMATCH, "zh-CN", phaseFacts), - phaseFacts); - ScriptedLlmClient llm = - new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" - + "{\"slot_name\":\"section.info_result_content\"," - + "\"code\":\"negotiation.phase_mismatch\"," - + "\"facts\":{\"implied\":\"accept-reject\",\"declared\":\"propose\"}}]," - + "\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - String rejectMessage = informationEndingMessage(orchestrator, NegotiationConclusion.REJECT); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - rejectMessage, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); - assertEquals(List.of(phaseError), exception.getErrors()); - assertEquals(1, llm.calls); - } - - private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static String informationProposeMessage(NegotiationGenerationOrchestrator orchestrator) { - MetadataContent content = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent( - List.of(new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1")), null)), - INFORMATION_PROPOSE_URI); - return content.promptText(); - } - - private static String informationEndingMessage( - NegotiationGenerationOrchestrator orchestrator, NegotiationConclusion conclusion) { - NegotiationEndingData data = new NegotiationEndingData( - new NegotiationContext( - SESSION_ID, - 2, - 5, - conclusion == NegotiationConclusion.ACCEPT - ? NegotiationPerformative.ACCEPT - : NegotiationPerformative.REJECT), - new InformationEndingContent( - conclusion, List.of(new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1")))); - MetadataContent content = conclusion == NegotiationConclusion.ACCEPT - ? orchestrator.generateAcceptFromData(data, INFORMATION_ACCEPT_REJECT_URI) - : orchestrator.generateRejectFromData(data, INFORMATION_ACCEPT_REJECT_URI); - return content.promptText(); - } - - private static String targetProposeMessage(NegotiationGenerationOrchestrator orchestrator) { - MetadataContent content = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.PROPOSE), - new TargetProposeContent( - "确认专线质差投诉的时延修复目标调整方案", - List.of(new NegotiationItem("修复意图", "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内")), - null, - null, - null)), - TARGET_PROPOSE_URI); - return content.promptText(); - } - - /** LLM boundary fake replaying scripted payloads and recording every structured call. */ - private static final class ScriptedLlmClient implements LLMClient { - - private final List payloads; - - private int calls; - - private RuntimeException failure; - - private List> lastMessages; - - private Map lastSchema; - - private ScriptedLlmClient(String... payloads) { - this.payloads = List.of(payloads); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - lastMessages = messages; - lastSchema = jsonSchema; - if (failure != null) { - throw failure; - } - String payload = payloads.get(Math.min(calls - 1, payloads.size() - 1)); - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Integration tests of the three validate-and-filling pipelines of the negotiation content layer. + * + *

        Every test drives the real orchestrator with the built-in zh-CN templates and prompt resources; only the LLM + * boundary is scripted, so each test exercises the full chain from the rendered message through the rule gate, the + * merged semantic validation schema and the deterministic parameter merge. + */ +class ValidateAndFillingDataPipelineTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final NegotiationContext CONTEXT = + new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.PROPOSE); + + private static final NegotiationContext TARGET_CONTEXT = + new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.PROPOSE); + + private static final NegotiationContext ACCEPT_CONTEXT = + new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.ACCEPT); + + private static final NegotiationContext REJECT_CONTEXT = + new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.REJECT); + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final TemplateUri INFORMATION_ACCEPT_REJECT_URI = + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + + private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; + + private static final Map ACCESS_PORT_SCHEMA = Map.of( + "type", + "object", + "properties", + Map.of( + "accessPort", Map.of("type", "string"), + "bizScenario", Map.of("type", "string")), + "required", + List.of("accessPort", "bizScenario")); + + @Test + void legalProposeMessageRunsTheFullChainWithASingleLlmCall() { + ScriptedLlmClient llm = + new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"," + + "\"bizScenario\":\"专线质差\"}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI); + + assertEquals(1, llm.calls); + assertEquals(SESSION_ID, filled.data().get("id")); + assertEquals(2, filled.data().get("round")); + assertEquals(5, filled.data().get("maxRounds")); + assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); + assertEquals("专线质差", filled.data().get("bizScenario")); + assertEquals(5, filled.data().size()); + assertInstanceOf(String.class, filled.data().get("id"), "context id must be a string"); + assertInstanceOf(Integer.class, filled.data().get("round"), "context round must be a number"); + assertInstanceOf(Integer.class, filled.data().get("maxRounds"), "context maxRounds must be a number"); + } + + @Test + void legalAcceptMessageRunsTheFullChainWithASingleLlmCall() { + ScriptedLlmClient llm = + new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationEndingMessage(orchestrator, NegotiationConclusion.ACCEPT); + + FilledParamData filled = orchestrator.validateAcceptPromptAndDataFilling( + message, ACCEPT_CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); + + assertEquals(1, llm.calls); + assertEquals(SESSION_ID, filled.data().get("id")); + assertEquals(2, filled.data().get("round")); + assertEquals(5, filled.data().get("maxRounds")); + assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); + assertEquals(4, filled.data().size()); + } + + @Test + void legalRejectMessageRunsTheFullChainWithASingleLlmCall() { + ScriptedLlmClient llm = + new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"unavailable_item\":\"接入端口名称\"}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationEndingMessage(orchestrator, NegotiationConclusion.REJECT); + + FilledParamData filled = orchestrator.validateRejectPromptAndDataFilling( + message, REJECT_CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_ACCEPT_REJECT_URI); + + assertEquals(1, llm.calls); + assertEquals(SESSION_ID, filled.data().get("id")); + assertEquals("接入端口名称", filled.data().get("unavailable_item")); + assertEquals(4, filled.data().size()); + } + + // The rule-violation table of this suite (non-uuid id, round above maxRounds, 0 LLM calls) is absorbed by the + // VAL-RULE batch of the test corpus (negotiation-cases/validate/rule-gate.json): every row there asserts the same + // negotiation.rule_violation code, the exact slot error pairs and the zero-LLM guarantee, plus the boundary and + // combined-error rows the hand-written table never carried (design §6 Q8). + + @Test + void semanticRejectionIsNotRetriedAndPassesTheErrorsThrough() { + Map conclusionFacts = + Map.of("conclusion", "Accept", "section_label", "section.info_conclusion"); + Map resultFacts = Map.of("section_label", "section.info_items"); + List semanticErrors = List.of( + new SlotValidationError( + "section.info_conclusion", + ErrorCatalog.NEGOTIATION_CONCLUSION_CONTENT_MISMATCH.getCode(), + ErrorMessages.render( + ErrorCatalog.NEGOTIATION_CONCLUSION_CONTENT_MISMATCH, "zh-CN", conclusionFacts), + conclusionFacts), + new SlotValidationError( + "section.info_items", + ErrorCatalog.NEGOTIATION_MISSING_RESULT_CONTENT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_MISSING_RESULT_CONTENT, "zh-CN", resultFacts), + resultFacts)); + ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":null," + + "\"errors\":[{\"slot_name\":\"section.info_conclusion\"," + + "\"code\":\"negotiation.conclusion_content_mismatch\"," + + "\"facts\":{\"conclusion\":\"Accept\",\"section_label\":\"section.info_conclusion\"}}," + + "{\"slot_name\":\"section.info_items\",\"code\":\"negotiation.missing_result_content\"," + + "\"facts\":{\"section_label\":\"section.info_items\"}}],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); + assertEquals(semanticErrors, exception.getErrors()); + assertEquals(1, llm.calls, "a negative verdict is a decision, not a failure, and must not be retried"); + } + + @ParameterizedTest + @MethodSource("taskTMessages") + void taskTMessagesAreRejectedAsNonNegotiationInputWithARenderedMessage(String caseName, String taskTMessage) { + ScriptedLlmClient llm = new ScriptedLlmClient( + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + taskTMessage, null, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode(), caseName); + assertEquals("输入的协商内容无效:缺少协商上下文(该报文不是协商报文)", exception.getMessage(), caseName); + assertEquals(List.of(), exception.getErrors(), caseName); + assertEquals(0, llm.calls, caseName); + } + + static List taskTMessages() { + return List.of( + new Object[] { + "chinese task prompt", + "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n## 任务对象(Task Object)\n接入端口名称:P533-珠江旧城" + + "-PTN3900-23-TPA1EG24-1\n\n## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和" + + "修复建议等诊断结果信息。\n" + }, + new Object[] { + "english task prompt", + "## Task Type\nTransport private line service complaint diagnosis\n\n## Task Object\nAccess" + + " Port Name: P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\n\n## Task Target\nDiagnose" + + " network-side faults and return diagnostic result information.\n" + }); + } + + @Test + void nestedArrayParamsAreExtractedThroughTheMergedSchema() { + Map repairTargetItemSchema = Map.of( + "type", + "object", + "properties", + Map.of( + "latencyTarget", Map.of("type", "string"), + "completionDeadline", Map.of("type", "string"))); + Map callerSchema = Map.of( + "type", + "object", + "properties", + Map.of("repairTargets", Map.of("type", "array", "items", repairTargetItemSchema)), + "required", + List.of("repairTargets"), + "additionalProperties", + false); + ScriptedLlmClient llm = + new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[]," + + "\"params\":{\"repairTargets\":[{\"latencyTarget\":\"within 20ms\"," + + "\"completionDeadline\":\"48 hours\"}]}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = targetProposeMessage(orchestrator); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message, TARGET_CONTEXT, callerSchema, TARGET_PROPOSE_URI); + + assertEquals(1, llm.calls); + assertEquals(SESSION_ID, filled.data().get("id")); + assertEquals( + List.of(Map.of("latencyTarget", "within 20ms", "completionDeadline", "48 hours")), + filled.data().get("repairTargets")); + + Map mergedSchema = llm.lastSchema; + assertEquals(List.of("semantic_verdict", "negotiation_type", "errors", "params"), mergedSchema.get("required")); + assertEquals(false, mergedSchema.get("additionalProperties")); + Map properties = (Map) mergedSchema.get("properties"); + Map paramsSchema = (Map) properties.get("params"); + assertEquals("object", paramsSchema.get("type")); + Map repairTargets = (Map) ((Map) paramsSchema.get("properties")).get("repairTargets"); + assertEquals("array", repairTargets.get("type")); + } + + @Test + void callerSchemaWithoutATypeKeywordIsWrappedAndTheChainSucceeds() { + Map schemaWithoutType = + Map.of("properties", Map.of("accessPort", Map.of("type", "string")), "required", List.of("accessPort")); + ScriptedLlmClient llm = + new ScriptedLlmClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, schemaWithoutType, INFORMATION_PROPOSE_URI); + + assertEquals(1, llm.calls); + assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); + Map paramsSchema = (Map) ((Map) llm.lastSchema.get("properties")).get("params"); + assertEquals("object", paramsSchema.get("type")); + assertEquals(Map.of("accessPort", Map.of("type", "string")), paramsSchema.get("properties")); + assertEquals(List.of("accessPort"), paramsSchema.get("required")); + } + + @Test + void missingNegotiationTypeKeyIsRetriedThenFailsAsAnInfrastructureError() { + ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":true,\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build(); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), exception.getCode()); + assertEquals(3, llm.calls, "a shape-invalid response is a retryable infrastructure failure"); + assertEquals(1, exception.getErrors().size()); + assertEquals("_llm", exception.getErrors().get(0).slotName()); + assertEquals("semantic_validation", exception.getFacts().get("step")); + assertTrue(exception.getCause().getCause().getMessage().contains("negotiation_type")); + } + + @Test + void declaredTypeMismatchIsASemanticRejection() { + ScriptedLlmClient llm = new ScriptedLlmClient( + "{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); + assertEquals(1, llm.calls, "a type mismatch is a semantic decision and must not be retried"); + assertEquals(1, exception.getErrors().size()); + assertEquals("section.target", exception.getErrors().get(0).slotName()); + assertEquals( + ErrorCatalog.NEGOTIATION_TYPE_MISMATCH.getCode(), + exception.getErrors().get(0).code()); + assertEquals("target", exception.getErrors().get(0).facts().get("implied")); + assertEquals("information", exception.getErrors().get(0).facts().get("declared")); + } + + @ParameterizedTest + @MethodSource("structuralSemanticCases") + void structuralSemanticChecksSurfaceThroughTheSemanticErrors( + String caseName, String payload, SlotValidationError expectedError) { + ScriptedLlmClient llm = new ScriptedLlmClient(payload); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode(), caseName); + assertEquals(List.of(expectedError), exception.getErrors(), caseName); + assertEquals(1, llm.calls, caseName); + } + + static List structuralSemanticCases() { + Map conclusionFacts = Map.of("expected", "Accept", "actual", "Abort"); + Map resultFacts = Map.of("section_label", "section.target_result_content"); + Map conflictFacts = + Map.of("section_label", "section.info_static", "reason", "the static section coexists with the items"); + return List.of( + new Object[] { + "conclusion outside accept and reject", + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" + + "{\"slot_name\":\"section.target_conclusion\"," + + "\"code\":\"negotiation.conclusion_mismatch\"," + + "\"facts\":{\"expected\":\"Accept\",\"actual\":\"Abort\"}}],\"params\":{}}", + new SlotValidationError( + "section.target_conclusion", + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), + ErrorMessages.render( + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH, "zh-CN", conclusionFacts), + conclusionFacts) + }, + new Object[] { + "ending result content section missing", + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" + + "{\"slot_name\":\"section.target_result_content\"," + + "\"code\":\"negotiation.missing_result_content\"," + + "\"facts\":{\"section_label\":\"section.target_result_content\"}}],\"params\":{}}", + new SlotValidationError( + "section.target_result_content", + ErrorCatalog.NEGOTIATION_MISSING_RESULT_CONTENT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_MISSING_RESULT_CONTENT, "zh-CN", resultFacts), + resultFacts) + }, + new Object[] { + "information propose carries both conditional sections", + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" + + "{\"slot_name\":\"section.info_static\"," + + "\"code\":\"negotiation.field_inconsistency\"," + + "\"facts\":{\"section_label\":\"section.info_static\",\"reason\":" + + "\"the static section coexists with the items\"}}],\"params\":{}}", + new SlotValidationError( + "section.info_static", + ErrorCatalog.NEGOTIATION_FIELD_INCONSISTENCY.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_FIELD_INCONSISTENCY, "zh-CN", conflictFacts), + conflictFacts) + }); + } + + @Test + void falseVerdictWithNullTypeIsAShapeLegalOutcome() { + ScriptedLlmClient llm = new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":null," + + "\"errors\":[{\"slot_name\":\"section.context\",\"code\":\"inconsistent_context\"," + + "\"facts\":{\"reason\":\"Context contradicts the message body.\"}}],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); + assertEquals(1, exception.getErrors().size()); + assertEquals("section.context", exception.getErrors().get(0).slotName()); + assertEquals( + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), + exception.getErrors().get(0).code()); + assertEquals(1, llm.calls, "verdict false with a null type is shape-legal and must not be retried"); + } + + @Test + void trueVerdictWithNullTypeIsASemanticRejection() { + ScriptedLlmClient llm = new ScriptedLlmClient( + "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); + assertEquals(1, llm.calls, "a null type with a true verdict is a semantic rejection, not a retryable failure"); + assertEquals(1, exception.getErrors().size()); + assertEquals("section.info_static", exception.getErrors().get(0).slotName()); + assertEquals( + ErrorCatalog.NEGOTIATION_TYPE_MISMATCH.getCode(), + exception.getErrors().get(0).code()); + } + + @Test + void semanticLlmFailureIsRetriedAndExhaustsWithTheLlmPseudoSlot() { + ScriptedLlmClient llm = new ScriptedLlmClient("unused"); + llm.failure = new LLMRuntimeError("LLM endpoint unavailable"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build(); + String message = informationProposeMessage(orchestrator); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + message, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), exception.getCode()); + assertEquals(2, llm.calls); + assertEquals(1, exception.getErrors().size()); + assertEquals("_llm", exception.getErrors().get(0).slotName()); + assertTrue(exception.getMessage().contains("endpoint unavailable")); + } + + @Test + void missingPromptResourcesCloseAsTemplateNotFoundWithoutBubblingTheResourceException() { + ScriptedLlmClient llm = new ScriptedLlmClient("unused"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .semanticValidator((prompt, callerSchema, reference, templateContent) -> { + throw new ResourceNotFoundException( + "Negotiation semantic validation prompt resource does not exist.", + "prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md"); + }) + .build(); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 接入端口名称\n", + new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.PROPOSE), + ACCESS_PORT_SCHEMA, + INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), exception.getCode()); + assertEquals( + NegotiationParamExtractionException.class, + exception.getClass(), + "the raw resource exception must not bubble out of the pipeline"); + assertTrue(exception instanceof A2ATError, "the mapped failure stays catchable through the A2ATError root"); + assertEquals(0, llm.calls); + } + + @Test + void proposeUriValidatingAResultMessageIsASemanticRejection() { + Map phaseFacts = Map.of("implied", "accept-reject", "declared", "propose"); + SlotValidationError phaseError = new SlotValidationError( + "section.info_result_content", + ErrorCatalog.NEGOTIATION_PHASE_MISMATCH.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_PHASE_MISMATCH, "zh-CN", phaseFacts), + phaseFacts); + ScriptedLlmClient llm = + new ScriptedLlmClient("{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[" + + "{\"slot_name\":\"section.info_result_content\"," + + "\"code\":\"negotiation.phase_mismatch\"," + + "\"facts\":{\"implied\":\"accept-reject\",\"declared\":\"propose\"}}]," + + "\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + String rejectMessage = informationEndingMessage(orchestrator, NegotiationConclusion.REJECT); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + rejectMessage, CONTEXT, ACCESS_PORT_SCHEMA, INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), exception.getCode()); + assertEquals(List.of(phaseError), exception.getErrors()); + assertEquals(1, llm.calls); + } + + private static NegotiationGenerationOrchestrator orchestrator(ScriptedLlmClient llm) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + } + + private static String informationProposeMessage(NegotiationGenerationOrchestrator orchestrator) { + MetadataContent content = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent( + List.of(new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1")), null)), + INFORMATION_PROPOSE_URI); + return content.promptText(); + } + + private static String informationEndingMessage( + NegotiationGenerationOrchestrator orchestrator, NegotiationConclusion conclusion) { + NegotiationEndingData data = new NegotiationEndingData( + new NegotiationContext( + SESSION_ID, + 2, + 5, + conclusion == NegotiationConclusion.ACCEPT + ? NegotiationPerformative.ACCEPT + : NegotiationPerformative.REJECT), + new InformationEndingContent( + conclusion, List.of(new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1")))); + MetadataContent content = conclusion == NegotiationConclusion.ACCEPT + ? orchestrator.generateAcceptFromData(data, INFORMATION_ACCEPT_REJECT_URI) + : orchestrator.generateRejectFromData(data, INFORMATION_ACCEPT_REJECT_URI); + return content.promptText(); + } + + private static String targetProposeMessage(NegotiationGenerationOrchestrator orchestrator) { + MetadataContent content = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.PROPOSE), + new TargetProposeContent( + "确认专线质差投诉的时延修复目标调整方案", + List.of(new NegotiationItem("修复意图", "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内")), + null, + null, + null)), + TARGET_PROPOSE_URI); + return content.promptText(); + } + + /** LLM boundary fake replaying scripted payloads and recording every structured call. */ + private static final class ScriptedLlmClient implements LLMClient { + + private final List payloads; + + private int calls; + + private RuntimeException failure; + + private List> lastMessages; + + private Map lastSchema; + + private ScriptedLlmClient(String... payloads) { + this.payloads = List.of(payloads); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + lastMessages = messages; + lastSchema = jsonSchema; + if (failure != null) { + throw failure; + } + String payload = payloads.get(Math.min(calls - 1, payloads.size() - 1)); + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java index e2d4fd00..9575608a 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/NegotiationLanguageSwitchTest.java @@ -6,14 +6,14 @@ import java.util.List; import java.util.stream.Stream; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases; import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; -import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -70,7 +70,8 @@ void templateQueriesReturnTheTemplatesOfTheConfiguredLanguage(String language) { template.templateUri().uri() + " must not leak the other language"); } - PromptTemplate queriedTemplate = queryService(language).getPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); + PromptTemplate queriedTemplate = + queryService(language).getPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); assertTrue(queriedTemplate.content().contains(requirementLabel)); } @@ -111,5 +112,4 @@ private static NegotiationGenerationOrchestrator orchestrator(String language) { .language(language) .build(); } - } diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java index 8aa40caa..cebe26a3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/language/UnsupportedLanguageBehaviorTest.java @@ -50,7 +50,8 @@ void templateQueriesOfAnUnsupportedLanguageNeverFallBackToAnotherLanguage() { for (String bundledLanguage : List.of("zh-CN", "en-US")) { assertEquals( 7, - negotiationPrompts(new TemplateQueryService(bundledLanguage, "classpath", null)).size(), + negotiationPrompts(new TemplateQueryService(bundledLanguage, "classpath", null)) + .size(), "the bundled languages keep their full template set"); } } @@ -69,8 +70,8 @@ void loadingASingleTemplateOfAnUnsupportedLanguageFailsWithTheLanguageHint() { ResourceNotFoundException exception = assertThrows( ResourceNotFoundException.class, - () -> loader.load( - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "fr-FR"))); + () -> loader.load(new NegotiationReference( + NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "fr-FR"))); assertTrue( exception.getMessage().contains("A2AT_LANGUAGE"), diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java index 20cc64ea..5f0ec910 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationExceptionTreeRootCatchTest.java @@ -1,250 +1,250 @@ -package net.openan.a2at.sdk.negotiation.observability; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMError; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.Test; - -/** - * Verifies the single-root failure contract of the negotiation content layer. - * - *

        Every runtime failure of the generation and parameter-extraction pipelines is catchable through the common - * {@link A2ATError} root, while programming errors of the content layer (standard {@link IllegalArgumentException} - * and {@link NullPointerException} of argument validation) stay outside that tree so that callers cannot accidentally - * swallow them with a generic processing-failure handler. - */ -class NegotiationExceptionTreeRootCatchTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - @Test - void exhaustedGenerationLlmFailureIsCatchableThroughTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new FailingClient()) - .maxAttempts(2) - .build(); - - RuntimeException failure = catchThroughRoot(() -> orchestrator.generateProposeFromText( - "请提供节能区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI)); - - assertTrue(failure instanceof NegotiationGenerationException); - assertEquals( - ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), - ((NegotiationGenerationException) failure).getCode()); - } - - @Test - void ruleViolationFailureIsCatchableThroughTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 9, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - - assertTrue(failure instanceof NegotiationParamExtractionException); - assertEquals( - ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), - ((NegotiationParamExtractionException) failure).getCode()); - } - - @Test - void semanticRejectionFailureIsCatchableThroughTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient( - "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" - + "\"section.info_static\",\"code\":\"negotiation.type_mismatch\",\"facts\":" - + "{\"implied\":\"information\",\"declared\":\"information\"}}],\"params\":{}}")) - .build(); - - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - - assertTrue(failure instanceof NegotiationParamExtractionException); - assertEquals( - ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), - ((NegotiationParamExtractionException) failure).getCode()); - } - - @Test - void paramExtractionLlmFailureIsCatchableThroughTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new FailingClient()) - .maxAttempts(2) - .build(); - - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - - assertTrue(failure instanceof NegotiationParamExtractionException); - assertEquals( - ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), - ((NegotiationParamExtractionException) failure).getCode()); - } - - @Test - void nonNegotiationInputFailureIsCatchableThroughTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - "plain text without any negotiation section", null, Map.of("type", "object"), INFORMATION_PROPOSE_URI)); - - assertTrue(failure instanceof NegotiationParamExtractionException); - assertEquals( - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ((NegotiationParamExtractionException) failure).getCode()); - } - - @Test - void contentProgrammingErrorsAreNotCatchableThroughTheCommonRoot() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - - try { - orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - StandardTemplates.ENERGY_SAVING); - } catch (A2ATError caughtByRoot) { - fail("content programming errors must stay outside the A2ATError tree but were caught: " - + caughtByRoot.getMessage()); - } catch (IllegalArgumentException expected) { - assertFalse( - A2ATError.class.isInstance(expected), - "content programming errors must stay outside the A2ATError tree"); - assertTrue( - expected.getMessage().contains("Template URI does not address"), - "the failure must point at the template URI but was: " + expected.getMessage()); - } - } - - @Test - void contentProgrammingErrorsOfTheContextStayOutsideTheCommonRoot() { - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, () -> new NegotiationContext(" ", 1, 5, NegotiationPerformative.PROPOSE)); - - assertFalse( - A2ATError.class.isInstance(failure), "content programming errors must stay outside the A2ATError tree"); - } - - @Test - void nullPromptOnValidateAndFillingMapsToInvalidInput() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - null, - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - - assertTrue(failure instanceof NegotiationParamExtractionException); - assertEquals( - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ((NegotiationParamExtractionException) failure).getCode()); - } - - @Test - void blankPromptOnValidateAndFillingMapsToInvalidInput() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - - RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( - " ", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - - assertTrue(failure instanceof NegotiationParamExtractionException); - assertEquals( - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ((NegotiationParamExtractionException) failure).getCode()); - } - - /** - * Runs one pipeline action inside a {@code catch (A2ATError)} block and returns the caught failure, failing when - * nothing was caught. - */ - private static RuntimeException catchThroughRoot(Runnable action) { - try { - action.run(); - } catch (A2ATError failure) { - return failure; - } - fail("the pipeline action was expected to fail with an A2ATError but completed"); - return null; - } - - private static final class ScriptedClient implements LLMClient { - - private final String payload; - - private ScriptedClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class FailingClient implements LLMClient { - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - throw new LLMError("LLM endpoint unavailable."); - } - } -} +package net.openan.a2at.sdk.negotiation.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import org.junit.jupiter.api.Test; + +/** + * Verifies the single-root failure contract of the negotiation content layer. + * + *

        Every runtime failure of the generation and parameter-extraction pipelines is catchable through the common + * {@link A2ATError} root, while programming errors of the content layer (standard {@link IllegalArgumentException} and + * {@link NullPointerException} of argument validation) stay outside that tree so that callers cannot accidentally + * swallow them with a generic processing-failure handler. + */ +class NegotiationExceptionTreeRootCatchTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + @Test + void exhaustedGenerationLlmFailureIsCatchableThroughTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new FailingClient()) + .maxAttempts(2) + .build(); + + RuntimeException failure = catchThroughRoot(() -> orchestrator.generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI)); + + assertTrue(failure instanceof NegotiationGenerationException); + assertEquals( + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), ((NegotiationGenerationException) failure).getCode()); + } + + @Test + void ruleViolationFailureIsCatchableThroughTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 9, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + + assertTrue(failure instanceof NegotiationParamExtractionException); + assertEquals( + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), + ((NegotiationParamExtractionException) failure).getCode()); + } + + @Test + void semanticRejectionFailureIsCatchableThroughTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient( + "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" + + "\"section.info_static\",\"code\":\"negotiation.type_mismatch\",\"facts\":" + + "{\"implied\":\"information\",\"declared\":\"information\"}}],\"params\":{}}")) + .build(); + + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + + assertTrue(failure instanceof NegotiationParamExtractionException); + assertEquals( + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), + ((NegotiationParamExtractionException) failure).getCode()); + } + + @Test + void paramExtractionLlmFailureIsCatchableThroughTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new FailingClient()) + .maxAttempts(2) + .build(); + + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + + assertTrue(failure instanceof NegotiationParamExtractionException); + assertEquals( + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ((NegotiationParamExtractionException) failure).getCode()); + } + + @Test + void nonNegotiationInputFailureIsCatchableThroughTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( + "plain text without any negotiation section", null, Map.of("type", "object"), INFORMATION_PROPOSE_URI)); + + assertTrue(failure instanceof NegotiationParamExtractionException); + assertEquals( + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ((NegotiationParamExtractionException) failure).getCode()); + } + + @Test + void contentProgrammingErrorsAreNotCatchableThroughTheCommonRoot() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + try { + orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + StandardTemplates.ENERGY_SAVING); + } catch (A2ATError caughtByRoot) { + fail("content programming errors must stay outside the A2ATError tree but were caught: " + + caughtByRoot.getMessage()); + } catch (IllegalArgumentException expected) { + assertFalse( + A2ATError.class.isInstance(expected), + "content programming errors must stay outside the A2ATError tree"); + assertTrue( + expected.getMessage().contains("Template URI does not address"), + "the failure must point at the template URI but was: " + expected.getMessage()); + } + } + + @Test + void contentProgrammingErrorsOfTheContextStayOutsideTheCommonRoot() { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> new NegotiationContext(" ", 1, 5, NegotiationPerformative.PROPOSE)); + + assertFalse( + A2ATError.class.isInstance(failure), "content programming errors must stay outside the A2ATError tree"); + } + + @Test + void nullPromptOnValidateAndFillingMapsToInvalidInput() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( + null, + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + + assertTrue(failure instanceof NegotiationParamExtractionException); + assertEquals( + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ((NegotiationParamExtractionException) failure).getCode()); + } + + @Test + void blankPromptOnValidateAndFillingMapsToInvalidInput() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + RuntimeException failure = catchThroughRoot(() -> orchestrator.validateProposePromptAndDataFilling( + " ", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + + assertTrue(failure instanceof NegotiationParamExtractionException); + assertEquals( + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ((NegotiationParamExtractionException) failure).getCode()); + } + + /** + * Runs one pipeline action inside a {@code catch (A2ATError)} block and returns the caught failure, failing when + * nothing was caught. + */ + private static RuntimeException catchThroughRoot(Runnable action) { + try { + action.run(); + } catch (A2ATError failure) { + return failure; + } + fail("the pipeline action was expected to fail with an A2ATError but completed"); + return null; + } + + private static final class ScriptedClient implements LLMClient { + + private final String payload; + + private ScriptedClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class FailingClient implements LLMClient { + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new LLMError("LLM endpoint unavailable."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java index 588c3b25..0d5683ce 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/observability/NegotiationLogEventContractTest.java @@ -1,397 +1,397 @@ -package net.openan.a2at.sdk.negotiation.observability; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; - -/** - * Locks the log instrumentation contract of the negotiation content layer. - * - *

        Every pipeline path is driven through the real default collaborators with a scripted LLM client and the emitted - * events are captured with a logback {@link ListAppender}. The test asserts that exactly the eleven contract event - * names appear, each at its configured level, that every message is an English snake_case event followed by - * {@code key=value} fields, that internal step diagnostics live in the logs only, and that neither the message text, - * the free-text input nor the raw LLM response ever leaks into an INFO-or-higher event. - */ -class NegotiationLogEventContractTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final Map> EXPECTED_EVENT_LEVELS = Map.ofEntries( - Map.entry("negotiation_template_loaded", Set.of(Level.DEBUG)), - Map.entry("negotiation_generator_dispatched", Set.of(Level.DEBUG)), - Map.entry("negotiation_content_extraction_completed", Set.of(Level.INFO)), - Map.entry("negotiation_content_extraction_response_invalid", Set.of(Level.WARN)), - Map.entry("negotiation_semantic_validation_completed", Set.of(Level.INFO)), - Map.entry("negotiation_generation_completed", Set.of(Level.INFO)), - Map.entry("negotiation_llm_retry", Set.of(Level.WARN)), - Map.entry("negotiation_llm_retry_exhausted", Set.of(Level.WARN)), - Map.entry("negotiation_generation_failed", Set.of(Level.WARN)), - Map.entry("negotiation_param_extraction_failed", Set.of(Level.WARN)), - Map.entry("negotiation_rule_checks_completed", Set.of(Level.DEBUG, Level.WARN))); - - private Logger rootLogger; - - private ListAppender appender; - - @BeforeEach - void attachAppender() { - rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); - appender = new ListAppender<>(); - appender.start(); - rootLogger.addAppender(appender); - } - - @AfterEach - void detachAppender() { - rootLogger.detachAppender(appender); - appender.stop(); - } - - @Test - void allNegotiationEventNamesFireAtTheirConfiguredLevelsAcrossThePipelines() { - driveSuccessfulGenerationFromData(); - driveSuccessfulGenerationFromText(); - driveRetryThenSuccessOnContentExtraction(); - driveRetryExhaustionOnContentExtraction(); - driveSuccessfulParamExtraction(); - driveSemanticRejection(); - driveRuleViolation(); - - Map> observedLevels = negotiationEventLevels(); - assertEquals(EXPECTED_EVENT_LEVELS.keySet(), observedLevels.keySet()); - EXPECTED_EVENT_LEVELS.forEach((eventName, expectedLevels) -> - assertEquals(expectedLevels, observedLevels.get(eventName), "levels of event " + eventName)); - } - - @Test - void everyEventMessageIsAnEnglishSnakeCaseEventWithKeyValueFields() { - driveSuccessfulGenerationFromData(); - driveSuccessfulGenerationFromText(); - driveSuccessfulParamExtraction(); - - List events = negotiationEvents(); - assertFalse(events.isEmpty()); - for (ILoggingEvent event : events) { - String message = event.getFormattedMessage(); - String eventName = message.split(" ")[0]; - assertTrue( - eventName.matches("negotiation_[a-z0-9]+(_[a-z0-9]+)*"), - "event name must be snake_case but was: " + eventName); - assertTrue( - message.chars().allMatch(character -> character >= 32 && character < 127), - "event message must be printable ASCII but was: " + message); - List keyTokens = keyTokens(message); - assertFalse(keyTokens.isEmpty(), "event message must carry key=value fields: " + message); - for (String key : keyTokens) { - assertTrue( - key.matches("[a-z][a-z0-9_]*"), - "field key must be snake_case but was: " + key + " in " + message); - } - } - } - - @Test - void retryEventsCarryTheInternalStepNameThatNeverAppearsOnExceptions() { - driveRetryExhaustionOnContentExtraction(); - - List retryMessages = messagesOf("negotiation_llm_retry"); - List exhaustedMessages = messagesOf("negotiation_llm_retry_exhausted"); - assertEquals(1, retryMessages.size()); - assertEquals(1, exhaustedMessages.size()); - assertTrue(retryMessages.get(0).contains("step=")); - assertTrue(retryMessages.get(0).contains("attempt=1")); - assertTrue(retryMessages.get(0).contains("max_attempts=")); - assertTrue(retryMessages.get(0).contains("code=" + ErrorCatalog.LLM_INVOCATION_FAILED.getCode())); - assertTrue(exhaustedMessages.get(0).contains("step=")); - - NegotiationGenerationException failure = generationFailureOfExhaustedExtraction(); - assertNotNull(failure); - assertFalse(failure.getMessage().toLowerCase().contains("stage")); - assertFalse(failure.getMessage().contains("step=")); - } - - @Test - void infoAndHigherEventsNeverCarryMessageTextInputOrResponseContent() { - String inputMarker = "SECRET-INPUT-MARKER-7f3a"; - String promptMarker = "SECRET-PROMPT-MARKER-9b1c"; - String responseMarker = "SECRET-RESPONSE-MARKER-5d2e"; - String paramValueMarker = "SECRET-PARAM-VALUE-MARKER-3c8d"; - - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient( - "{\"items\":[{\"name\":\"item\",\"value\":\"" + responseMarker + "\"}],\"relationship\":null}", - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"" + paramValueMarker + "\"}}")) - .build(); - - MetadataContent generated = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem(promptMarker, "value")), null)), - INFORMATION_PROPOSE_URI); - assertTrue(generated.promptText().contains(promptMarker)); - orchestrator.generateProposeFromText( - "free text containing " + inputMarker, - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI); - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - generated.promptText(), - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI); - assertTrue(filled.data().containsValue(paramValueMarker)); - - List infoAndHigher = appender.list.stream() - .filter(event -> event.getLevel().isGreaterOrEqual(Level.INFO)) - .toList(); - assertFalse(infoAndHigher.isEmpty()); - for (ILoggingEvent event : infoAndHigher) { - String message = event.getFormattedMessage(); - assertFalse(message.contains(inputMarker), "input text leaked at " + event.getLevel() + ": " + message); - assertFalse(message.contains(promptMarker), "prompt text leaked at " + event.getLevel() + ": " + message); - assertFalse( - message.contains(responseMarker), - "response content leaked at " + event.getLevel() + ": " + message); - assertFalse( - message.contains(paramValueMarker), - "extracted parameter value leaked at " + event.getLevel() + ": " + message); - assertFalse( - message.contains("prompt_text=") || message.contains("text=") || message.contains("response="), - "raw content field must not be logged at " + event.getLevel() + ": " + message); - } - } - - @Test - void removedTypeRecognitionEventNeverAppears() { - driveSuccessfulGenerationFromText(); - driveSuccessfulParamExtraction(); - - assertTrue(appender.list.stream() - .noneMatch(event -> event.getFormattedMessage().startsWith("negotiation_type_recognition"))); - } - - private void driveSuccessfulGenerationFromData() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient(validExtractionPayload(), validSemanticPayload())) - .build(); - MetadataContent result = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); - } - - private void driveSuccessfulGenerationFromText() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient(validExtractionPayload(), validSemanticPayload())) - .build(); - MetadataContent result = orchestrator.generateProposeFromText( - "请提供节能区域。", - new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI); - assertTrue(result.promptText().contains("所需信息项")); - } - - private void driveRetryThenSuccessOnContentExtraction() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient("", validExtractionPayload())) - .maxAttempts(2) - .build(); - MetadataContent result = orchestrator.generateProposeFromText( - "请提供节能区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI); - assertTrue(result.promptText().contains("所需信息项")); - } - - private void driveRetryExhaustionOnContentExtraction() { - NegotiationGenerationException failure = generationFailureOfExhaustedExtraction(); - assertNotNull(failure); - } - - private NegotiationGenerationException generationFailureOfExhaustedExtraction() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new FailingClient()) - .maxAttempts(2) - .build(); - try { - orchestrator.generateProposeFromText( - "请提供节能区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI); - } catch (NegotiationGenerationException failure) { - assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), failure.getCode()); - return failure; - } - return null; - } - - private void driveSuccessfulParamExtraction() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient(validSemanticPayload())) - .build(); - MetadataContent message = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message.promptText(), - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI); - assertEquals(UUID, filled.data().get("id")); - } - - private void driveSemanticRejection() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient( - "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" - + "\"section.info_static\",\"code\":\"negotiation.type_mismatch\",\"facts\":" - + "{\"implied\":\"information\",\"declared\":\"information\"}}],\"params\":{}}")) - .build(); - try { - orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI); - } catch (NegotiationParamExtractionException expected) { - assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), expected.getCode()); - } - } - - private void driveRuleViolation() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient(validExtractionPayload(), validSemanticPayload())) - .build(); - try { - orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 9, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI); - } catch (NegotiationParamExtractionException expected) { - assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), expected.getCode()); - } - } - - private static String validExtractionPayload() { - return "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; - } - - private static String validSemanticPayload() { - return "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}"; - } - - private Map> negotiationEventLevels() { - Map> levels = new LinkedHashMap<>(); - for (ILoggingEvent event : negotiationEvents()) { - levels.computeIfAbsent(eventName(event), ignored -> new java.util.HashSet<>()) - .add(event.getLevel()); - } - return levels; - } - - private List negotiationEvents() { - return appender.list.stream() - .filter(event -> event.getFormattedMessage().startsWith("negotiation_")) - .toList(); - } - - private List messagesOf(String eventName) { - return appender.list.stream() - .map(ILoggingEvent::getFormattedMessage) - .filter(message -> message.startsWith(eventName + " ") || message.equals(eventName)) - .collect(Collectors.toList()); - } - - private static String eventName(ILoggingEvent event) { - return event.getFormattedMessage().split(" ")[0]; - } - - private static List keyTokens(String message) { - return java.util.Arrays.stream(message.split(" ")) - .filter(token -> token.contains("=")) - .map(token -> token.substring(0, token.indexOf('='))) - .collect(Collectors.toList()); - } - - private static final class ScriptedClient implements LLMClient { - - private final List payloads; - - private int calls; - - private ScriptedClient(String... payloads) { - this.payloads = List.of(payloads); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - String payload = payloads.get(Math.min(calls, payloads.size() - 1)); - calls++; - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class FailingClient implements LLMClient { - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - throw new IllegalStateException("LLM endpoint unavailable."); - } - } -} +package net.openan.a2at.sdk.negotiation.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * Locks the log instrumentation contract of the negotiation content layer. + * + *

        Every pipeline path is driven through the real default collaborators with a scripted LLM client and the emitted + * events are captured with a logback {@link ListAppender}. The test asserts that exactly the eleven contract event + * names appear, each at its configured level, that every message is an English snake_case event followed by + * {@code key=value} fields, that internal step diagnostics live in the logs only, and that neither the message text, + * the free-text input nor the raw LLM response ever leaks into an INFO-or-higher event. + */ +class NegotiationLogEventContractTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final Map> EXPECTED_EVENT_LEVELS = Map.ofEntries( + Map.entry("negotiation_template_loaded", Set.of(Level.DEBUG)), + Map.entry("negotiation_generator_dispatched", Set.of(Level.DEBUG)), + Map.entry("negotiation_content_extraction_completed", Set.of(Level.INFO)), + Map.entry("negotiation_content_extraction_response_invalid", Set.of(Level.WARN)), + Map.entry("negotiation_semantic_validation_completed", Set.of(Level.INFO)), + Map.entry("negotiation_generation_completed", Set.of(Level.INFO)), + Map.entry("negotiation_llm_retry", Set.of(Level.WARN)), + Map.entry("negotiation_llm_retry_exhausted", Set.of(Level.WARN)), + Map.entry("negotiation_generation_failed", Set.of(Level.WARN)), + Map.entry("negotiation_param_extraction_failed", Set.of(Level.WARN)), + Map.entry("negotiation_rule_checks_completed", Set.of(Level.DEBUG, Level.WARN))); + + private Logger rootLogger; + + private ListAppender appender; + + @BeforeEach + void attachAppender() { + rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + appender = new ListAppender<>(); + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + rootLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void allNegotiationEventNamesFireAtTheirConfiguredLevelsAcrossThePipelines() { + driveSuccessfulGenerationFromData(); + driveSuccessfulGenerationFromText(); + driveRetryThenSuccessOnContentExtraction(); + driveRetryExhaustionOnContentExtraction(); + driveSuccessfulParamExtraction(); + driveSemanticRejection(); + driveRuleViolation(); + + Map> observedLevels = negotiationEventLevels(); + assertEquals(EXPECTED_EVENT_LEVELS.keySet(), observedLevels.keySet()); + EXPECTED_EVENT_LEVELS.forEach((eventName, expectedLevels) -> + assertEquals(expectedLevels, observedLevels.get(eventName), "levels of event " + eventName)); + } + + @Test + void everyEventMessageIsAnEnglishSnakeCaseEventWithKeyValueFields() { + driveSuccessfulGenerationFromData(); + driveSuccessfulGenerationFromText(); + driveSuccessfulParamExtraction(); + + List events = negotiationEvents(); + assertFalse(events.isEmpty()); + for (ILoggingEvent event : events) { + String message = event.getFormattedMessage(); + String eventName = message.split(" ")[0]; + assertTrue( + eventName.matches("negotiation_[a-z0-9]+(_[a-z0-9]+)*"), + "event name must be snake_case but was: " + eventName); + assertTrue( + message.chars().allMatch(character -> character >= 32 && character < 127), + "event message must be printable ASCII but was: " + message); + List keyTokens = keyTokens(message); + assertFalse(keyTokens.isEmpty(), "event message must carry key=value fields: " + message); + for (String key : keyTokens) { + assertTrue( + key.matches("[a-z][a-z0-9_]*"), + "field key must be snake_case but was: " + key + " in " + message); + } + } + } + + @Test + void retryEventsCarryTheInternalStepNameThatNeverAppearsOnExceptions() { + driveRetryExhaustionOnContentExtraction(); + + List retryMessages = messagesOf("negotiation_llm_retry"); + List exhaustedMessages = messagesOf("negotiation_llm_retry_exhausted"); + assertEquals(1, retryMessages.size()); + assertEquals(1, exhaustedMessages.size()); + assertTrue(retryMessages.get(0).contains("step=")); + assertTrue(retryMessages.get(0).contains("attempt=1")); + assertTrue(retryMessages.get(0).contains("max_attempts=")); + assertTrue(retryMessages.get(0).contains("code=" + ErrorCatalog.LLM_INVOCATION_FAILED.getCode())); + assertTrue(exhaustedMessages.get(0).contains("step=")); + + NegotiationGenerationException failure = generationFailureOfExhaustedExtraction(); + assertNotNull(failure); + assertFalse(failure.getMessage().toLowerCase().contains("stage")); + assertFalse(failure.getMessage().contains("step=")); + } + + @Test + void infoAndHigherEventsNeverCarryMessageTextInputOrResponseContent() { + String inputMarker = "SECRET-INPUT-MARKER-7f3a"; + String promptMarker = "SECRET-PROMPT-MARKER-9b1c"; + String responseMarker = "SECRET-RESPONSE-MARKER-5d2e"; + String paramValueMarker = "SECRET-PARAM-VALUE-MARKER-3c8d"; + + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient( + "{\"items\":[{\"name\":\"item\",\"value\":\"" + responseMarker + "\"}],\"relationship\":null}", + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"" + paramValueMarker + "\"}}")) + .build(); + + MetadataContent generated = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem(promptMarker, "value")), null)), + INFORMATION_PROPOSE_URI); + assertTrue(generated.promptText().contains(promptMarker)); + orchestrator.generateProposeFromText( + "free text containing " + inputMarker, + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI); + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + generated.promptText(), + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI); + assertTrue(filled.data().containsValue(paramValueMarker)); + + List infoAndHigher = appender.list.stream() + .filter(event -> event.getLevel().isGreaterOrEqual(Level.INFO)) + .toList(); + assertFalse(infoAndHigher.isEmpty()); + for (ILoggingEvent event : infoAndHigher) { + String message = event.getFormattedMessage(); + assertFalse(message.contains(inputMarker), "input text leaked at " + event.getLevel() + ": " + message); + assertFalse(message.contains(promptMarker), "prompt text leaked at " + event.getLevel() + ": " + message); + assertFalse( + message.contains(responseMarker), + "response content leaked at " + event.getLevel() + ": " + message); + assertFalse( + message.contains(paramValueMarker), + "extracted parameter value leaked at " + event.getLevel() + ": " + message); + assertFalse( + message.contains("prompt_text=") || message.contains("text=") || message.contains("response="), + "raw content field must not be logged at " + event.getLevel() + ": " + message); + } + } + + @Test + void removedTypeRecognitionEventNeverAppears() { + driveSuccessfulGenerationFromText(); + driveSuccessfulParamExtraction(); + + assertTrue(appender.list.stream() + .noneMatch(event -> event.getFormattedMessage().startsWith("negotiation_type_recognition"))); + } + + private void driveSuccessfulGenerationFromData() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient(validExtractionPayload(), validSemanticPayload())) + .build(); + MetadataContent result = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); + } + + private void driveSuccessfulGenerationFromText() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient(validExtractionPayload(), validSemanticPayload())) + .build(); + MetadataContent result = orchestrator.generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI); + assertTrue(result.promptText().contains("所需信息项")); + } + + private void driveRetryThenSuccessOnContentExtraction() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient("", validExtractionPayload())) + .maxAttempts(2) + .build(); + MetadataContent result = orchestrator.generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI); + assertTrue(result.promptText().contains("所需信息项")); + } + + private void driveRetryExhaustionOnContentExtraction() { + NegotiationGenerationException failure = generationFailureOfExhaustedExtraction(); + assertNotNull(failure); + } + + private NegotiationGenerationException generationFailureOfExhaustedExtraction() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new FailingClient()) + .maxAttempts(2) + .build(); + try { + orchestrator.generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI); + } catch (NegotiationGenerationException failure) { + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), failure.getCode()); + return failure; + } + return null; + } + + private void driveSuccessfulParamExtraction() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient(validSemanticPayload())) + .build(); + MetadataContent message = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message.promptText(), + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI); + assertEquals(UUID, filled.data().get("id")); + } + + private void driveSemanticRejection() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient( + "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":" + + "\"section.info_static\",\"code\":\"negotiation.type_mismatch\",\"facts\":" + + "{\"implied\":\"information\",\"declared\":\"information\"}}],\"params\":{}}")) + .build(); + try { + orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI); + } catch (NegotiationParamExtractionException expected) { + assertEquals(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), expected.getCode()); + } + } + + private void driveRuleViolation() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient(validExtractionPayload(), validSemanticPayload())) + .build(); + try { + orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 9, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI); + } catch (NegotiationParamExtractionException expected) { + assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), expected.getCode()); + } + } + + private static String validExtractionPayload() { + return "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; + } + + private static String validSemanticPayload() { + return "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"; + } + + private Map> negotiationEventLevels() { + Map> levels = new LinkedHashMap<>(); + for (ILoggingEvent event : negotiationEvents()) { + levels.computeIfAbsent(eventName(event), ignored -> new java.util.HashSet<>()) + .add(event.getLevel()); + } + return levels; + } + + private List negotiationEvents() { + return appender.list.stream() + .filter(event -> event.getFormattedMessage().startsWith("negotiation_")) + .toList(); + } + + private List messagesOf(String eventName) { + return appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.startsWith(eventName + " ") || message.equals(eventName)) + .collect(Collectors.toList()); + } + + private static String eventName(ILoggingEvent event) { + return event.getFormattedMessage().split(" ")[0]; + } + + private static List keyTokens(String message) { + return java.util.Arrays.stream(message.split(" ")) + .filter(token -> token.contains("=")) + .map(token -> token.substring(0, token.indexOf('='))) + .collect(Collectors.toList()); + } + + private static final class ScriptedClient implements LLMClient { + + private final List payloads; + + private int calls; + + private ScriptedClient(String... payloads) { + this.payloads = List.of(payloads); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + String payload = payloads.get(Math.min(calls, payloads.size() - 1)); + calls++; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class FailingClient implements LLMClient { + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new IllegalStateException("LLM endpoint unavailable."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java index 5334a78c..f87d5fc0 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoaderTest.java @@ -1,174 +1,174 @@ -package net.openan.a2at.sdk.negotiation.resources; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.net.URL; -import java.net.URLClassLoader; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import org.junit.jupiter.api.Test; - -class DefaultNegotiationTemplateLoaderTest { - - private static final List EXPECTED_LOAD_ALL_URIS = List.of( - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), - StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), - StandardTemplates.NEGOTIATION_ABORT.uri()); - - @Test - void loadReturnsAllSevenBuiltinTemplatesPerLanguage() { - DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN"); - DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US"); - - List zhCnTemplates = loadAllOf(zhCnLoader, "zh-CN"); - List enUsTemplates = loadAllOf(enUsLoader, "en-US"); - - assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(zhCnTemplates)); - assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(enUsTemplates)); - assertEquals(7, zhCnTemplates.size()); - assertEquals(7, enUsTemplates.size()); - assertEquals(14, zhCnTemplates.size() + enUsTemplates.size()); - } - - @Test - void loadReturnsTheCommonAbortTemplateForTheAbortPerformative() { - DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US"); - DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN"); - - PromptTemplate englishTemplate = - enUsLoader.load(new NegotiationReference(null, NegotiationPerformative.ABORT, "en-US")); - PromptTemplate chineseTemplate = - zhCnLoader.load(new NegotiationReference(null, NegotiationPerformative.ABORT, "zh-CN")); - - assertEquals(StandardTemplates.NEGOTIATION_ABORT, englishTemplate.templateUri()); - assertTrue(englishTemplate.content().startsWith("## Negotiation Result")); - assertTrue(englishTemplate.content().contains("## Negotiation Termination Reason")); - assertTrue(englishTemplate.content().contains("{{negotiation_termination_reason}}")); - assertEquals(StandardTemplates.NEGOTIATION_ABORT, chineseTemplate.templateUri()); - assertTrue(chineseTemplate.content().contains("## 协商终止原因")); - assertTrue(chineseTemplate.content().contains("{{协商终止原因}}")); - } - - @Test - void loadReturnsFullTemplateContentFromTheClasspath() { - DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("en-US"); - - PromptTemplate template = - loader.load(new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US")); - - assertEquals(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, template.templateUri()); - assertTrue(template.content().startsWith("## Information Negotiation")); - } - - @Test - void builtinTemplatesCarryNoDescriptionCommentForEitherLanguage() { - DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN"); - DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US"); - - PromptTemplate englishTemplate = enUsLoader.load( - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US")); - PromptTemplate chineseTemplate = zhCnLoader.load( - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN")); - - assertEquals("", englishTemplate.description()); - assertEquals("", chineseTemplate.description()); - } - - @Test - void missingLanguageThrowsOnLoad() { - DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("fr-FR"); - - ResourceNotFoundException exception = assertThrows( - ResourceNotFoundException.class, - () -> loader.load( - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "fr-FR"))); - - assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); - } - - @Test - void doesNotCacheMissingTemplatesAndKeepsThrowing() { - DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("fr-FR"); - NegotiationReference reference = - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "fr-FR"); - - assertThrows(ResourceNotFoundException.class, () -> loader.load(reference)); - assertThrows(ResourceNotFoundException.class, () -> loader.load(reference)); - } - - @Test - void servesTheCachedValueAfterTheTemplateFileIsAlteredOnDisk() throws Exception { - Path tempRoot = Files.createTempDirectory("negotiation-template-cache-test"); - Path templateFile = tempRoot.resolve( - "prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/en-US/template.md"); - Files.createDirectories(templateFile.getParent()); - Files.writeString(templateFile, "original content", StandardCharsets.UTF_8); - - ClassLoader originalContextClassLoader = Thread.currentThread().getContextClassLoader(); - try (URLClassLoader shadowingClassLoader = - new URLClassLoader(new URL[] {tempRoot.toUri().toURL()}, ClassLoader.getPlatformClassLoader())) { - Thread.currentThread().setContextClassLoader(shadowingClassLoader); - - DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("en-US"); - NegotiationReference reference = - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US"); - - String first = loader.load(reference).content(); - - Files.writeString(templateFile, "changed on disk", StandardCharsets.UTF_8); - - assertEquals(first, loader.load(reference).content()); - } finally { - Thread.currentThread().setContextClassLoader(originalContextClassLoader); - deleteRecursively(tempRoot); - } - } - - private static void deleteRecursively(Path root) throws IOException { - if (!Files.exists(root)) { - return; - } - try (var paths = Files.walk(root)) { - paths.sorted(Comparator.reverseOrder()).forEach(path -> { - try { - Files.delete(path); - } catch (IOException exception) { - throw new UncheckedIOException(exception); - } - }); - } - } - - private static List loadAllOf(DefaultNegotiationTemplateLoader loader, String language) { - List templates = new ArrayList<>(); - for (NegotiationType type : NegotiationType.values()) { - for (NegotiationPerformative performative : - List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT)) { - templates.add(loader.load(new NegotiationReference(type, performative, language))); - } - } - templates.add(loader.load(new NegotiationReference(null, NegotiationPerformative.ABORT, language))); - return templates; - } - - private static List urisOf(List templates) { - return templates.stream().map(template -> template.templateUri().uri()).toList(); - } -} +package net.openan.a2at.sdk.negotiation.resources; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import org.junit.jupiter.api.Test; + +class DefaultNegotiationTemplateLoaderTest { + + private static final List EXPECTED_LOAD_ALL_URIS = List.of( + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.NEGOTIATION_ABORT.uri()); + + @Test + void loadReturnsAllSevenBuiltinTemplatesPerLanguage() { + DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN"); + DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US"); + + List zhCnTemplates = loadAllOf(zhCnLoader, "zh-CN"); + List enUsTemplates = loadAllOf(enUsLoader, "en-US"); + + assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(zhCnTemplates)); + assertEquals(EXPECTED_LOAD_ALL_URIS, urisOf(enUsTemplates)); + assertEquals(7, zhCnTemplates.size()); + assertEquals(7, enUsTemplates.size()); + assertEquals(14, zhCnTemplates.size() + enUsTemplates.size()); + } + + @Test + void loadReturnsTheCommonAbortTemplateForTheAbortPerformative() { + DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US"); + DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN"); + + PromptTemplate englishTemplate = + enUsLoader.load(new NegotiationReference(null, NegotiationPerformative.ABORT, "en-US")); + PromptTemplate chineseTemplate = + zhCnLoader.load(new NegotiationReference(null, NegotiationPerformative.ABORT, "zh-CN")); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT, englishTemplate.templateUri()); + assertTrue(englishTemplate.content().startsWith("## Negotiation Result")); + assertTrue(englishTemplate.content().contains("## Negotiation Termination Reason")); + assertTrue(englishTemplate.content().contains("{{negotiation_termination_reason}}")); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, chineseTemplate.templateUri()); + assertTrue(chineseTemplate.content().contains("## 协商终止原因")); + assertTrue(chineseTemplate.content().contains("{{协商终止原因}}")); + } + + @Test + void loadReturnsFullTemplateContentFromTheClasspath() { + DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("en-US"); + + PromptTemplate template = loader.load( + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US")); + + assertEquals(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, template.templateUri()); + assertTrue(template.content().startsWith("## Information Negotiation")); + } + + @Test + void builtinTemplatesCarryNoDescriptionCommentForEitherLanguage() { + DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN"); + DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US"); + + PromptTemplate englishTemplate = enUsLoader.load( + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US")); + PromptTemplate chineseTemplate = zhCnLoader.load( + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN")); + + assertEquals("", englishTemplate.description()); + assertEquals("", chineseTemplate.description()); + } + + @Test + void missingLanguageThrowsOnLoad() { + DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("fr-FR"); + + ResourceNotFoundException exception = assertThrows( + ResourceNotFoundException.class, + () -> loader.load(new NegotiationReference( + NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "fr-FR"))); + + assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); + } + + @Test + void doesNotCacheMissingTemplatesAndKeepsThrowing() { + DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("fr-FR"); + NegotiationReference reference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "fr-FR"); + + assertThrows(ResourceNotFoundException.class, () -> loader.load(reference)); + assertThrows(ResourceNotFoundException.class, () -> loader.load(reference)); + } + + @Test + void servesTheCachedValueAfterTheTemplateFileIsAlteredOnDisk() throws Exception { + Path tempRoot = Files.createTempDirectory("negotiation-template-cache-test"); + Path templateFile = tempRoot.resolve( + "prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/en-US/template.md"); + Files.createDirectories(templateFile.getParent()); + Files.writeString(templateFile, "original content", StandardCharsets.UTF_8); + + ClassLoader originalContextClassLoader = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader shadowingClassLoader = + new URLClassLoader(new URL[] {tempRoot.toUri().toURL()}, ClassLoader.getPlatformClassLoader())) { + Thread.currentThread().setContextClassLoader(shadowingClassLoader); + + DefaultNegotiationTemplateLoader loader = new DefaultNegotiationTemplateLoader("en-US"); + NegotiationReference reference = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "en-US"); + + String first = loader.load(reference).content(); + + Files.writeString(templateFile, "changed on disk", StandardCharsets.UTF_8); + + assertEquals(first, loader.load(reference).content()); + } finally { + Thread.currentThread().setContextClassLoader(originalContextClassLoader); + deleteRecursively(tempRoot); + } + } + + private static void deleteRecursively(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + try (var paths = Files.walk(root)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.delete(path); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + }); + } + } + + private static List loadAllOf(DefaultNegotiationTemplateLoader loader, String language) { + List templates = new ArrayList<>(); + for (NegotiationType type : NegotiationType.values()) { + for (NegotiationPerformative performative : + List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT)) { + templates.add(loader.load(new NegotiationReference(type, performative, language))); + } + } + templates.add(loader.load(new NegotiationReference(null, NegotiationPerformative.ABORT, language))); + return templates; + } + + private static List urisOf(List templates) { + return templates.stream().map(template -> template.templateUri().uri()).toList(); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java index a639988f..543879fd 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/resources/StandardTemplatesNegotiationConsistencyTest.java @@ -1,97 +1,98 @@ -package net.openan.a2at.sdk.negotiation.resources; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import org.junit.jupiter.api.Test; - -/** - * Locks the two sources of negotiation template URI spelling together: the constants in {@link StandardTemplates} - * (a2a-t-core, cannot depend on this module) and the compositional logic in {@link NegotiationReference} backed by - * the {@link NegotiationType}/{@link NegotiationPerformative} enums. Any drift turns this test red. - */ -class StandardTemplatesNegotiationConsistencyTest { - - private static final List TYPED_PERFORMATIVES = List.of( - NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT, NegotiationPerformative.REJECT); - - @Test - void negotiationConstantsMatchReferenceComposition() { - for (NegotiationType type : NegotiationType.values()) { - for (NegotiationPerformative performative : TYPED_PERFORMATIVES) { - NegotiationReference reference = new NegotiationReference(type, performative, "en-US"); - TemplateUri expected = findConstant(reference.uri()); - assertEquals( - reference.uri(), - expected.uri(), - "StandardTemplates has no constant matching the composed URI of " + type + "/" - + performative); - } - } - NegotiationReference abort = new NegotiationReference(null, NegotiationPerformative.ABORT, "en-US"); - TemplateUri abortConstant = findConstant(abort.uri()); - assertEquals( - abort.uri(), - abortConstant.uri(), - "StandardTemplates has no constant matching the composed URI of the common abort template"); - } - - @Test - void negotiationGroupCoversExactlyTheComposedUris() { - List composed = - StandardTemplates.NEGOTIATION.stream().map(TemplateUri::uri).sorted().toList(); - List expected = new ArrayList<>(); - // Accept and reject share the accept-reject template, so the group carries one URI per type for them. - for (NegotiationType type : NegotiationType.values()) { - for (NegotiationPerformative performative : - List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT)) { - expected.add(new NegotiationReference(type, performative, "en-US").uri()); - } - } - expected.add(new NegotiationReference(null, NegotiationPerformative.ABORT, "en-US").uri()); - assertEquals(expected.stream().sorted().toList(), composed); - } - - @Test - void everyNegotiationGroupUriIsLoadableByTheNegotiationLayer() { - DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN"); - DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US"); - - for (TemplateUri templateUri : StandardTemplates.NEGOTIATION) { - assertTrue(zhCnLoader.load(zhCnReference(templateUri)).content().length() > 0, templateUri.uri()); - assertTrue(enUsLoader.load(enUsReference(templateUri)).content().length() > 0, templateUri.uri()); - } - } - - private static NegotiationReference zhCnReference(TemplateUri templateUri) { - return referenceOf(templateUri, "zh-CN"); - } - - private static NegotiationReference enUsReference(TemplateUri templateUri) { - return referenceOf(templateUri, "en-US"); - } - - private static NegotiationReference referenceOf(TemplateUri templateUri, String language) { - for (NegotiationPerformative performative : NegotiationPerformative.values()) { - java.util.Optional reference = - NegotiationReference.fromTemplateUri(templateUri, performative, language); - if (reference.isPresent()) { - return reference.get(); - } - } - throw new AssertionError("No performative parses the advertised negotiation URI " + templateUri.uri()); - } - - private static TemplateUri findConstant(String uri) { - return StandardTemplates.NEGOTIATION.stream() - .filter(template -> template.uri().equals(uri)) - .findFirst() - .orElseThrow(() -> new AssertionError("No StandardTemplates constant for URI " + uri)); - } -} +package net.openan.a2at.sdk.negotiation.resources; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import org.junit.jupiter.api.Test; + +/** + * Locks the two sources of negotiation template URI spelling together: the constants in {@link StandardTemplates} + * (a2a-t-core, cannot depend on this module) and the compositional logic in {@link NegotiationReference} backed by the + * {@link NegotiationType}/{@link NegotiationPerformative} enums. Any drift turns this test red. + */ +class StandardTemplatesNegotiationConsistencyTest { + + private static final List TYPED_PERFORMATIVES = + List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT, NegotiationPerformative.REJECT); + + @Test + void negotiationConstantsMatchReferenceComposition() { + for (NegotiationType type : NegotiationType.values()) { + for (NegotiationPerformative performative : TYPED_PERFORMATIVES) { + NegotiationReference reference = new NegotiationReference(type, performative, "en-US"); + TemplateUri expected = findConstant(reference.uri()); + assertEquals( + reference.uri(), + expected.uri(), + "StandardTemplates has no constant matching the composed URI of " + type + "/" + performative); + } + } + NegotiationReference abort = new NegotiationReference(null, NegotiationPerformative.ABORT, "en-US"); + TemplateUri abortConstant = findConstant(abort.uri()); + assertEquals( + abort.uri(), + abortConstant.uri(), + "StandardTemplates has no constant matching the composed URI of the common abort template"); + } + + @Test + void negotiationGroupCoversExactlyTheComposedUris() { + List composed = StandardTemplates.NEGOTIATION.stream() + .map(TemplateUri::uri) + .sorted() + .toList(); + List expected = new ArrayList<>(); + // Accept and reject share the accept-reject template, so the group carries one URI per type for them. + for (NegotiationType type : NegotiationType.values()) { + for (NegotiationPerformative performative : + List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT)) { + expected.add(new NegotiationReference(type, performative, "en-US").uri()); + } + } + expected.add(new NegotiationReference(null, NegotiationPerformative.ABORT, "en-US").uri()); + assertEquals(expected.stream().sorted().toList(), composed); + } + + @Test + void everyNegotiationGroupUriIsLoadableByTheNegotiationLayer() { + DefaultNegotiationTemplateLoader zhCnLoader = new DefaultNegotiationTemplateLoader("zh-CN"); + DefaultNegotiationTemplateLoader enUsLoader = new DefaultNegotiationTemplateLoader("en-US"); + + for (TemplateUri templateUri : StandardTemplates.NEGOTIATION) { + assertTrue(zhCnLoader.load(zhCnReference(templateUri)).content().length() > 0, templateUri.uri()); + assertTrue(enUsLoader.load(enUsReference(templateUri)).content().length() > 0, templateUri.uri()); + } + } + + private static NegotiationReference zhCnReference(TemplateUri templateUri) { + return referenceOf(templateUri, "zh-CN"); + } + + private static NegotiationReference enUsReference(TemplateUri templateUri) { + return referenceOf(templateUri, "en-US"); + } + + private static NegotiationReference referenceOf(TemplateUri templateUri, String language) { + for (NegotiationPerformative performative : NegotiationPerformative.values()) { + java.util.Optional reference = + NegotiationReference.fromTemplateUri(templateUri, performative, language); + if (reference.isPresent()) { + return reference.get(); + } + } + throw new AssertionError("No performative parses the advertised negotiation URI " + templateUri.uri()); + } + + private static TemplateUri findConstant(String uri) { + return StandardTemplates.NEGOTIATION.stream() + .filter(template -> template.uri().equals(uri)) + .findFirst() + .orElseThrow(() -> new AssertionError("No StandardTemplates constant for URI " + uri)); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java index 891baf44..80180b0b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractorTest.java @@ -1,318 +1,322 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.validation.SemanticValidator; -import net.openan.a2at.sdk.core.validation.TemplateContentLoader; -import net.openan.a2at.sdk.core.validation.ValidationPipeline; -import net.openan.a2at.sdk.core.validation.ValidationResult; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; - -class ParamExtractorTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final NegotiationContext CONTEXT = - new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.PROPOSE); - - private static final String VALID_ZH_PROMPT = "## 所需信息项\n" - + "1. 节能区域信息:请提供真实存在的区域\n"; - - private static final NegotiationReference REFERENCE = - new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"); - - private static final int MAX_ATTEMPTS = 1; - - private static final String TEMPLATE_CONTENT = "dummy template content"; - - private final StubComplianceChecker complianceChecker = new StubComplianceChecker(); - - private final StubSemanticValidator semanticValidator = new StubSemanticValidator(); - - private final StubTemplateContentLoader templateContentLoader = new StubTemplateContentLoader(); - - private final ParamExtractor extractor = - new ParamExtractor(complianceChecker, semanticValidator, MAX_ATTEMPTS, templateContentLoader); - - @Test - void happyPathMergesContextParamsFirstAndLetsContextWinOnConflict() { - complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); - semanticValidator.result = new SemanticValidationResult( - true, - "information", - List.of(), - Map.of("id", "llm-value", "confirmed_rate_mbps", 2, "nested", Map.of("a", 1))); - - FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); - - assertEquals(SESSION_ID, filled.data().get("id")); - assertEquals(2, filled.data().get("round")); - assertEquals(5, filled.data().get("maxRounds")); - assertEquals(2, filled.data().get("confirmed_rate_mbps")); - assertEquals(Map.of("a", 1), filled.data().get("nested")); - assertEquals(5, filled.data().size()); - assertEquals(1, semanticValidator.invocations); - assertEquals(1, templateContentLoader.invocations); - assertEquals(TEMPLATE_CONTENT, semanticValidator.lastTemplateContent); - } - - @Test - void extractRequiresIntegerRoundAndMaxRoundsInMergedData() { - complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); - semanticValidator.result = new SemanticValidationResult(true, "information", List.of(), Map.of()); - - FilledParamData filled = extractor.extract( - VALID_ZH_PROMPT, - new NegotiationContext(SESSION_ID, 3, 7, NegotiationPerformative.PROPOSE), - Map.of(), - REFERENCE); - - assertTrue(filled.data().get("round") instanceof Integer); - assertTrue(filled.data().get("maxRounds") instanceof Integer); - assertEquals(3, filled.data().get("round")); - assertEquals(7, filled.data().get("maxRounds")); - } - - @Test - void ruleFailureSkipsTemplateLoadingAndTheSemanticValidationCall() { - complianceChecker.result = new NegotiationRuleCheckResult( - false, List.of(new SlotValidationError("round", "negotiation.round_exceeded", "round exceeds maxRounds"))); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); - - assertEquals("negotiation.rule_violation", exception.getCode()); - assertEquals(1, exception.getErrors().size()); - assertEquals("round", exception.getErrors().get(0).slotName()); - assertEquals(0, semanticValidator.invocations); - assertEquals(0, templateContentLoader.invocations); - } - - @Test - void extractFailsOnRuleViolationWithoutTouchingTheValidator() { - complianceChecker.result = - new NegotiationRuleCheckResult(false, List.of(new SlotValidationError("id", "negotiation.invalid_context_id", "not a uuid"))); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); - - assertEquals("negotiation.rule_violation", exception.getCode()); - assertEquals("id", exception.getErrors().get(0).slotName()); - assertEquals(0, semanticValidator.invocations); - assertEquals(0, templateContentLoader.invocations); - } - - @Test - void nullContextFailsAsNonNegotiationInputWithARenderedMessage() { - complianceChecker.result = new NegotiationRuleCheckResult(false, List.of()); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> extractor.extract("## 任务目标\n诊断\n", null, Map.of(), REFERENCE)); - - assertEquals("negotiation.invalid_input", exception.getCode()); - assertEquals("输入的协商内容无效:缺少协商上下文(该报文不是协商报文)", exception.getMessage()); - assertEquals(List.of(), exception.getErrors()); - assertEquals(0, semanticValidator.invocations); - assertEquals(0, complianceChecker.invocations); - assertEquals(0, templateContentLoader.invocations); - } - - @Test - void semanticRejectionPassesErrorsThrough() { - complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); - List semanticErrors = List.of( - new SlotValidationError("section.target_result_content", "negotiation.conclusion_content_mismatch", "Mismatch"), - new SlotValidationError("section.context", "negotiation.conclusion_mismatch", "Abort is reserved")); - semanticValidator.result = new SemanticValidationResult(false, "target", semanticErrors, Map.of()); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); - - assertEquals("negotiation.semantic_rejected", exception.getCode()); - assertEquals(semanticErrors, exception.getErrors()); - } - - @Test - void extractReturnsTheValidatorOutcome() { - complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); - semanticValidator.result = - new SemanticValidationResult(true, "information", List.of(), Map.of("confirmed_rate_mbps", 2)); - - FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); - - assertEquals(1, semanticValidator.invocations); - assertEquals(2, filled.data().get("confirmed_rate_mbps")); - } - - @Test - void internalValidationFailureIsMappedToRetryableInfrastructureError() { - complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); - semanticValidator.failure = new NegotiationValidationException("response is missing negotiation_type"); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); - - assertEquals("llm.response_invalid", exception.getCode()); - assertEquals(1, exception.getErrors().size()); - assertEquals("_llm", exception.getErrors().get(0).slotName()); - assertEquals("semantic_validation", exception.getErrors().get(0).facts().get("step")); - assertTrue(exception.getCause().getCause().getMessage().contains("negotiation_type")); - } - - @Test - void promptResourceMissIsMappedToTemplateNotFound() { - complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); - semanticValidator.failure = - new ResourceNotFoundException("prompt resource missing", "prompt_resources/prompts/x"); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); - - assertEquals("template.not_found", exception.getCode()); - assertEquals(List.of(), exception.getErrors()); - } - - @Test - void templateLoadFailureIsMappedToTemplateNotFoundWithoutTouchingTheValidator() { - complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); - semanticValidator.result = new SemanticValidationResult(true, "information", List.of(), Map.of()); - templateContentLoader.failure = - new ResourceNotFoundException("template missing", "templates/Negotiation-T/information-negotiation"); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); - - assertEquals("template.not_found", exception.getCode()); - assertEquals(List.of(), exception.getErrors()); - assertEquals(1, templateContentLoader.invocations); - assertEquals(REFERENCE, templateContentLoader.lastReference); - assertEquals(0, semanticValidator.invocations); - } - - @Test - void paramMergeConflictLogsContentParamMergeConflictWarning() { - complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); - semanticValidator.result = - new SemanticValidationResult(true, "information", List.of(), Map.of("id", "semantic-id")); - - ListAppender appender = new ListAppender<>(); - appender.start(); - Logger pipelineLogger = (Logger) LoggerFactory.getLogger(ValidationPipeline.class); - pipelineLogger.addAppender(appender); - try { - extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); - } finally { - pipelineLogger.detachAppender(appender); - appender.stop(); - } - - assertTrue( - appender.list.stream() - .anyMatch(event -> event.getLevel() == Level.WARN - && event.getFormattedMessage().startsWith("content_param_merge_conflict")), - "expected a WARN for a context-over-semantic parameter merge conflict"); - } - - @Test - void templateIsLoadedBetweenTheRuleGateAndSemanticValidation() { - List order = new ArrayList<>(); - NegotiationComplianceChecker checker = context -> { - order.add("rule"); - return new NegotiationRuleCheckResult(true, List.of()); - }; - SemanticValidator semantic = (prompt, schema, reference, templateContent) -> { - order.add("semantic"); - return new ValidationResult(true, List.of(), Map.of()); - }; - TemplateContentLoader loader = reference -> { - order.add("load"); - return TEMPLATE_CONTENT; - }; - ParamExtractor sut = new ParamExtractor(checker, semantic, MAX_ATTEMPTS, loader); - - sut.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); - - assertEquals(List.of("rule", "load", "semantic"), order); - } - - private static final class StubComplianceChecker implements NegotiationComplianceChecker { - - private NegotiationRuleCheckResult result = new NegotiationRuleCheckResult(false, List.of()); - - private int invocations; - - @Override - public NegotiationRuleCheckResult check(NegotiationContext context) { - invocations++; - return result; - } - } - - private static final class StubSemanticValidator implements NegotiationSemanticValidator { - - private SemanticValidationResult result = new SemanticValidationResult(false, null, List.of(), Map.of()); - - private RuntimeException failure; - - private int invocations; - - private String lastTemplateContent; - - @Override - public SemanticValidationResult validateNegotiation( - String prompt, Map callerSchema, NegotiationReference reference, String templateContent) { - invocations++; - lastTemplateContent = templateContent; - if (failure != null) { - throw failure; - } - return result; - } - } - - private static final class StubTemplateContentLoader implements TemplateContentLoader { - - private String content = TEMPLATE_CONTENT; - - private RuntimeException failure; - - private int invocations; - - private NegotiationReference lastReference; - - @Override - public String load(NegotiationReference reference) { - invocations++; - lastReference = reference; - if (failure != null) { - throw failure; - } - return content; - } - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.SemanticValidator; +import net.openan.a2at.sdk.core.validation.TemplateContentLoader; +import net.openan.a2at.sdk.core.validation.ValidationPipeline; +import net.openan.a2at.sdk.core.validation.ValidationResult; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +class ParamExtractorTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final NegotiationContext CONTEXT = + new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.PROPOSE); + + private static final String VALID_ZH_PROMPT = "## 所需信息项\n" + "1. 节能区域信息:请提供真实存在的区域\n"; + + private static final NegotiationReference REFERENCE = + new NegotiationReference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"); + + private static final int MAX_ATTEMPTS = 1; + + private static final String TEMPLATE_CONTENT = "dummy template content"; + + private final StubComplianceChecker complianceChecker = new StubComplianceChecker(); + + private final StubSemanticValidator semanticValidator = new StubSemanticValidator(); + + private final StubTemplateContentLoader templateContentLoader = new StubTemplateContentLoader(); + + private final ParamExtractor extractor = + new ParamExtractor(complianceChecker, semanticValidator, MAX_ATTEMPTS, templateContentLoader); + + @Test + void happyPathMergesContextParamsFirstAndLetsContextWinOnConflict() { + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); + semanticValidator.result = new SemanticValidationResult( + true, + "information", + List.of(), + Map.of("id", "llm-value", "confirmed_rate_mbps", 2, "nested", Map.of("a", 1))); + + FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); + + assertEquals(SESSION_ID, filled.data().get("id")); + assertEquals(2, filled.data().get("round")); + assertEquals(5, filled.data().get("maxRounds")); + assertEquals(2, filled.data().get("confirmed_rate_mbps")); + assertEquals(Map.of("a", 1), filled.data().get("nested")); + assertEquals(5, filled.data().size()); + assertEquals(1, semanticValidator.invocations); + assertEquals(1, templateContentLoader.invocations); + assertEquals(TEMPLATE_CONTENT, semanticValidator.lastTemplateContent); + } + + @Test + void extractRequiresIntegerRoundAndMaxRoundsInMergedData() { + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); + semanticValidator.result = new SemanticValidationResult(true, "information", List.of(), Map.of()); + + FilledParamData filled = extractor.extract( + VALID_ZH_PROMPT, + new NegotiationContext(SESSION_ID, 3, 7, NegotiationPerformative.PROPOSE), + Map.of(), + REFERENCE); + + assertTrue(filled.data().get("round") instanceof Integer); + assertTrue(filled.data().get("maxRounds") instanceof Integer); + assertEquals(3, filled.data().get("round")); + assertEquals(7, filled.data().get("maxRounds")); + } + + @Test + void ruleFailureSkipsTemplateLoadingAndTheSemanticValidationCall() { + complianceChecker.result = new NegotiationRuleCheckResult( + false, + List.of(new SlotValidationError("round", "negotiation.round_exceeded", "round exceeds maxRounds"))); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); + + assertEquals("negotiation.rule_violation", exception.getCode()); + assertEquals(1, exception.getErrors().size()); + assertEquals("round", exception.getErrors().get(0).slotName()); + assertEquals(0, semanticValidator.invocations); + assertEquals(0, templateContentLoader.invocations); + } + + @Test + void extractFailsOnRuleViolationWithoutTouchingTheValidator() { + complianceChecker.result = new NegotiationRuleCheckResult( + false, List.of(new SlotValidationError("id", "negotiation.invalid_context_id", "not a uuid"))); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); + + assertEquals("negotiation.rule_violation", exception.getCode()); + assertEquals("id", exception.getErrors().get(0).slotName()); + assertEquals(0, semanticValidator.invocations); + assertEquals(0, templateContentLoader.invocations); + } + + @Test + void nullContextFailsAsNonNegotiationInputWithARenderedMessage() { + complianceChecker.result = new NegotiationRuleCheckResult(false, List.of()); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract("## 任务目标\n诊断\n", null, Map.of(), REFERENCE)); + + assertEquals("negotiation.invalid_input", exception.getCode()); + assertEquals("输入的协商内容无效:缺少协商上下文(该报文不是协商报文)", exception.getMessage()); + assertEquals(List.of(), exception.getErrors()); + assertEquals(0, semanticValidator.invocations); + assertEquals(0, complianceChecker.invocations); + assertEquals(0, templateContentLoader.invocations); + } + + @Test + void semanticRejectionPassesErrorsThrough() { + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); + List semanticErrors = List.of( + new SlotValidationError( + "section.target_result_content", "negotiation.conclusion_content_mismatch", "Mismatch"), + new SlotValidationError("section.context", "negotiation.conclusion_mismatch", "Abort is reserved")); + semanticValidator.result = new SemanticValidationResult(false, "target", semanticErrors, Map.of()); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); + + assertEquals("negotiation.semantic_rejected", exception.getCode()); + assertEquals(semanticErrors, exception.getErrors()); + } + + @Test + void extractReturnsTheValidatorOutcome() { + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); + semanticValidator.result = + new SemanticValidationResult(true, "information", List.of(), Map.of("confirmed_rate_mbps", 2)); + + FilledParamData filled = extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); + + assertEquals(1, semanticValidator.invocations); + assertEquals(2, filled.data().get("confirmed_rate_mbps")); + } + + @Test + void internalValidationFailureIsMappedToRetryableInfrastructureError() { + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); + semanticValidator.failure = new NegotiationValidationException("response is missing negotiation_type"); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertEquals(1, exception.getErrors().size()); + assertEquals("_llm", exception.getErrors().get(0).slotName()); + assertEquals("semantic_validation", exception.getErrors().get(0).facts().get("step")); + assertTrue(exception.getCause().getCause().getMessage().contains("negotiation_type")); + } + + @Test + void promptResourceMissIsMappedToTemplateNotFound() { + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); + semanticValidator.failure = + new ResourceNotFoundException("prompt resource missing", "prompt_resources/prompts/x"); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); + + assertEquals("template.not_found", exception.getCode()); + assertEquals(List.of(), exception.getErrors()); + } + + @Test + void templateLoadFailureIsMappedToTemplateNotFoundWithoutTouchingTheValidator() { + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); + semanticValidator.result = new SemanticValidationResult(true, "information", List.of(), Map.of()); + templateContentLoader.failure = + new ResourceNotFoundException("template missing", "templates/Negotiation-T/information-negotiation"); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE)); + + assertEquals("template.not_found", exception.getCode()); + assertEquals(List.of(), exception.getErrors()); + assertEquals(1, templateContentLoader.invocations); + assertEquals(REFERENCE, templateContentLoader.lastReference); + assertEquals(0, semanticValidator.invocations); + } + + @Test + void paramMergeConflictLogsContentParamMergeConflictWarning() { + complianceChecker.result = new NegotiationRuleCheckResult(true, List.of()); + semanticValidator.result = + new SemanticValidationResult(true, "information", List.of(), Map.of("id", "semantic-id")); + + ListAppender appender = new ListAppender<>(); + appender.start(); + Logger pipelineLogger = (Logger) LoggerFactory.getLogger(ValidationPipeline.class); + pipelineLogger.addAppender(appender); + try { + extractor.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); + } finally { + pipelineLogger.detachAppender(appender); + appender.stop(); + } + + assertTrue( + appender.list.stream() + .anyMatch(event -> event.getLevel() == Level.WARN + && event.getFormattedMessage().startsWith("content_param_merge_conflict")), + "expected a WARN for a context-over-semantic parameter merge conflict"); + } + + @Test + void templateIsLoadedBetweenTheRuleGateAndSemanticValidation() { + List order = new ArrayList<>(); + NegotiationComplianceChecker checker = context -> { + order.add("rule"); + return new NegotiationRuleCheckResult(true, List.of()); + }; + SemanticValidator semantic = (prompt, schema, reference, templateContent) -> { + order.add("semantic"); + return new ValidationResult(true, List.of(), Map.of()); + }; + TemplateContentLoader loader = reference -> { + order.add("load"); + return TEMPLATE_CONTENT; + }; + ParamExtractor sut = new ParamExtractor(checker, semantic, MAX_ATTEMPTS, loader); + + sut.extract(VALID_ZH_PROMPT, CONTEXT, Map.of(), REFERENCE); + + assertEquals(List.of("rule", "load", "semantic"), order); + } + + private static final class StubComplianceChecker implements NegotiationComplianceChecker { + + private NegotiationRuleCheckResult result = new NegotiationRuleCheckResult(false, List.of()); + + private int invocations; + + @Override + public NegotiationRuleCheckResult check(NegotiationContext context) { + invocations++; + return result; + } + } + + private static final class StubSemanticValidator implements NegotiationSemanticValidator { + + private SemanticValidationResult result = new SemanticValidationResult(false, null, List.of(), Map.of()); + + private RuntimeException failure; + + private int invocations; + + private String lastTemplateContent; + + @Override + public SemanticValidationResult validateNegotiation( + String prompt, + Map callerSchema, + NegotiationReference reference, + String templateContent) { + invocations++; + lastTemplateContent = templateContent; + if (failure != null) { + throw failure; + } + return result; + } + } + + private static final class StubTemplateContentLoader implements TemplateContentLoader { + + private String content = TEMPLATE_CONTENT; + + private RuntimeException failure; + + private int invocations; + + private NegotiationReference lastReference; + + @Override + public String load(NegotiationReference reference) { + invocations++; + lastReference = reference; + if (failure != null) { + throw failure; + } + return content; + } + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java index c4fb6637..25c87910 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java @@ -1,377 +1,376 @@ -package net.openan.a2at.sdk.prompt.resources.catalog; - -import java.io.IOException; -import java.io.InputStream; -import java.net.JarURLConnection; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.Enumeration; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.model.A2ATConfigKeys; -import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; -import net.openan.a2at.sdk.core.resources.PathSegments; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Directory-driven catalog over the prompt template tree of every A2A-T extension. - * - *

        The catalog enumerates the {@code templates/} tree of the configured source by walking its directories, so every - * extension directory that appears under {@code prompt_resources/templates/} — Task-T, Notification-T, Authorization-T, - * Negotiation-T and any extension added later — is picked up without a hardcoded extension list. A template file lives - * at {@code templates/////template.md} and is addressed by the - * URI formed from the segments before the language, for example - * {@code Negotiation-T/information-negotiation/propose/v1} or {@code Task-T/network-layer/ran-energy-saving/v1}. - * - *

        The catalog builds the effective set at assembly time according to the configured {@code sourceType}: - * the {@code classpath} mode enumerates the full built-in template tree; the {@code local_file} mode enumerates only - * the local business content (Task-T, Notification-T and Authorization-T) plus the classpath-fixed Negotiation-T - * templates, without unioning the built-in business content. Every template record carries its effective origin - * ({@code classpath} or {@code local}). Negotiation-T templates are filtered to the closed set of seven shapes; a - * Negotiation-T template outside the closed set is ignored with a warning. Both query methods never throw: a template - * or a root that cannot be loaded is skipped or answered with an empty result and a warning log. - * - * @since 2026-08 - */ -final class PromptTemplateCatalog { - - private static final Logger LOGGER = LoggerFactory.getLogger(PromptTemplateCatalog.class); - - private static final String CLASSPATH_ROOT = "prompt_resources/"; - - private static final String CLASSPATH_TEMPLATE_ROOT = CLASSPATH_ROOT + "templates/"; - - private static final String TEMPLATE_DIRECTORY = "templates"; - - private static final String TEMPLATE_FILE_NAME = "template.md"; - - private static final int MINIMUM_URI_SEGMENTS = 3; - - private static final String NEGOTIATION_EXTENSION = "Negotiation-T"; - - private static final Set NEGOTIATION_TYPES = - Set.of("information-negotiation", "target-negotiation", "feasibility-negotiation"); - - private static final Set NEGOTIATION_PHASES = Set.of("propose", "accept-reject"); - - private final String language; - - private final String sourceType; - - private final Path localRootDir; - - private final Map classpathSnapshot; - - private final Map localSnapshot; - - /** - * Creates a catalog for one language, two source types and an optional local template root. - * - *

        The constructor captures the classpath template tree and, in {@code local_file} mode, the local - * {@code templates/} tree once as immutable snapshots: the catalog never re-reads the classpath or the - * local filesystem after construction, so a template that is added or removed after assembly is not visible to - * {@link #loadAll()} or {@link #load(TemplateUri)}. - * - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @param sourceType resource source selector, {@code classpath} or {@code local_file} - * @param localRootDir local prompt resource root containing the {@code templates/} tree; required in - * {@code local_file} mode and ignored otherwise - * @throws IllegalArgumentException if the language is not a simple path segment, the source type is unsupported, or - * {@code local_file} mode is selected without a local root directory or with one that does not exist - */ - PromptTemplateCatalog(@NonNull String language, @NonNull String sourceType, @Nullable String localRootDir) { - if (!PathSegments.isSimpleSegment(language)) { - throw new IllegalArgumentException( - "Prompt template catalog language must be a non-blank simple path segment but was " + language - + "."); - } - this.language = language; - if (PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH.equals(sourceType)) { - this.sourceType = PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH; - } else if (PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(sourceType)) { - this.sourceType = PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE; - } else { - throw new IllegalArgumentException("Unsupported prompt source type: " + sourceType); - } - if (PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(this.sourceType) - && (localRootDir == null || localRootDir.isBlank())) { - throw new IllegalArgumentException( - "Prompt resource local root directory is required for sourceType '" - + PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE - + "' but is not set; configure " + A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR - + " to the local prompt resource root."); - } - this.localRootDir = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); - if (this.localRootDir != null && !Files.isDirectory(this.localRootDir)) { - throw new IllegalArgumentException( - "Prompt resource local root directory configured via " - + A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR - + " does not exist or is not a directory: " - + this.localRootDir); - } - this.classpathSnapshot = captureClasspathTemplates(); - this.localSnapshot = PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(this.sourceType) && this.localRootDir != null - ? Map.copyOf(localTemplates()) - : Map.of(); - } - - private Map captureClasspathTemplates() { - try { - return Map.copyOf(classpathTemplates()); - } catch (IOException | URISyntaxException exception) { - LOGGER.atWarn().log("prompt_template_catalog_unavailable root=classpath reason={}", exception.getMessage()); - return Map.of(); - } - } - - /** - * Lists every loadable template of the configured language across all extensions of the effective set. - * - *

        The effective set depends on the configured {@code sourceType}: {@code classpath} lists the full built-in - * tree, {@code local_file} lists the local business content plus the classpath-fixed Negotiation-T templates. - * Negotiation-T templates outside the closed set are ignored with a warning. The result is sorted by template URI. - * - * @return loadable templates of the configured language sorted by URI; empty when none can be loaded - */ - public @NonNull List loadAll() { - Map entriesByPath = new LinkedHashMap<>(); - collectClasspathEntries(entriesByPath); - collectLocalEntries(entriesByPath); - List templates = new ArrayList<>(); - String languageSuffix = "/" + language + "/" + TEMPLATE_FILE_NAME; - for (Map.Entry entry : entriesByPath.entrySet()) { - String path = entry.getKey(); - if (!path.endsWith(languageSuffix)) { - continue; - } - String uri = path.substring(CLASSPATH_TEMPLATE_ROOT.length(), path.length() - languageSuffix.length()); - if (!isCatalogableUri(uri)) { - LOGGER.atDebug().log("prompt_template_skipped path={} reason=not_a_template_uri", path); - continue; - } - if (isNegotiationUri(uri) && !isClosedSetNegotiationUri(uri)) { - LOGGER.atWarn().log("negotiation_template_outside_closed_set uri={}", uri); - continue; - } - String content = entry.getValue().content(); - TemplateUri templateUri = TemplateUri.parse(uri) - .orElseThrow(() -> new IllegalStateException( - "Catalogable template URI could not be parsed: " + uri + " (path: " + path + ")")); - templates.add(new PromptTemplate( - templateUri, - TemplateDescriptions.extract(content), - content, - entry.getValue().source())); - } - templates.sort(Comparator.comparing(template -> template.templateUri().uri())); - LOGGER.atDebug().log("prompt_templates_listed count={} language={}", templates.size(), language); - return List.copyOf(templates); - } - - /** - * Loads one template of the configured language by its URI, regardless of the extension. - * - *

        A Negotiation-T template outside the closed set is answered with an empty result and a warning. Business - * content follows the configured {@code sourceType}: {@code local_file} reads the addressed template from the local - * root only (no classpath fallback), {@code classpath} reads it from the classpath. - * - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1} or - * {@code Task-T/network-layer/ran-energy-saving/v1} - * @return the addressed template, or an empty optional when no template exists for it in the configured language - * @throws NullPointerException if the template URI is null - */ - public Optional load(@NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - String uri = templateUri.uri(); - boolean negotiation = NEGOTIATION_EXTENSION.equals(templateUri.extensionName()); - if (negotiation && !isClosedSetNegotiationUri(uri)) { - return Optional.empty(); - } - String classpathPath = CLASSPATH_ROOT + String.join("/", TEMPLATE_DIRECTORY, uri, language, TEMPLATE_FILE_NAME); - String source; - String content; - if (negotiation || PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH.equals(sourceType)) { - source = PromptTemplate.SOURCE_CLASSPATH; - content = classpathSnapshot.get(classpathPath); - } else { - source = PromptTemplate.SOURCE_LOCAL; - content = localSnapshot.get(classpathPath); - } - if (content == null) { - return Optional.empty(); - } - return Optional.of(new PromptTemplate(templateUri, TemplateDescriptions.extract(content), content, source)); - } - - private void collectClasspathEntries(Map entriesByPath) { - for (Map.Entry entry : classpathSnapshot.entrySet()) { - if (PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(sourceType) && !isNegotiationPath(entry.getKey())) { - continue; - } - entriesByPath.put(entry.getKey(), new TemplateSource(entry.getValue(), PromptTemplate.SOURCE_CLASSPATH)); - } - } - - private void collectLocalEntries(Map entriesByPath) { - if (!PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(sourceType) || localRootDir == null) { - return; - } - for (Map.Entry entry : localSnapshot.entrySet()) { - if (isNegotiationPath(entry.getKey())) { - continue; - } - entriesByPath.put(entry.getKey(), new TemplateSource(entry.getValue(), PromptTemplate.SOURCE_LOCAL)); - } - } - - private Map classpathTemplates() throws IOException, URISyntaxException { - Map templates = new LinkedHashMap<>(); - ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); - if (classLoader == null) { - classLoader = PromptTemplateCatalog.class.getClassLoader(); - } - Enumeration roots = classLoader.getResources(CLASSPATH_TEMPLATE_ROOT); - while (roots.hasMoreElements()) { - URL root = roots.nextElement(); - if ("file".equals(root.getProtocol())) { - collectFilesystemTemplates(Path.of(root.toURI()), templates); - } else if ("jar".equals(root.getProtocol())) { - collectJarTemplates((JarURLConnection) root.openConnection(), templates); - } else { - LOGGER.atDebug().log("prompt_template_root_skipped url={} reason=unsupported_protocol", root); - } - } - return templates; - } - - private static void collectFilesystemTemplates(Path rootDirectory, Map templates) - throws IOException { - try (Stream paths = Files.walk(rootDirectory)) { - paths.filter(Files::isRegularFile) - .filter(path -> path.getFileName().toString().equals(TEMPLATE_FILE_NAME)) - .forEach(path -> collectTemplate( - templates, - CLASSPATH_TEMPLATE_ROOT - + rootDirectory.relativize(path).toString().replace('\\', '/'), - path)); - } - } - - private static void collectJarTemplates(JarURLConnection connection, Map templates) - throws IOException { - try (java.util.jar.JarFile jarFile = connection.getJarFile()) { - jarFile.stream() - .filter(entry -> !entry.isDirectory()) - .filter(entry -> entry.getName().startsWith(CLASSPATH_TEMPLATE_ROOT)) - .filter(entry -> entry.getName().endsWith(TEMPLATE_FILE_NAME)) - .forEach(entry -> collectTemplate(templates, entry.getName(), null)); - } - } - - private static void collectTemplate(Map templates, String classpathPath, Path filesystemPath) { - try { - String content; - if (filesystemPath != null) { - content = Files.readString(filesystemPath, StandardCharsets.UTF_8); - } else { - InputStream stream = ClasspathResourceStreams.open(classpathPath); - if (stream == null) { - LOGGER.atDebug().log( - "prompt_template_skipped path={} reason=classpath_entry_unreadable", classpathPath); - return; - } - try (stream) { - content = new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } - } - templates.put(classpathPath, content); - } catch (IOException exception) { - LOGGER.atDebug().log("prompt_template_skipped path={} reason=read_failed", classpathPath); - } - } - - private Map localTemplates() { - Map templates = new LinkedHashMap<>(); - Path templateRoot = localRootDir.resolve(TEMPLATE_DIRECTORY); - if (!Files.isDirectory(templateRoot)) { - return templates; - } - try (Stream paths = Files.walk(templateRoot)) { - paths.filter(Files::isRegularFile) - .filter(path -> path.getFileName().toString().equals(TEMPLATE_FILE_NAME)) - .forEach(path -> collectTemplate( - templates, - CLASSPATH_TEMPLATE_ROOT - + templateRoot.relativize(path).toString().replace('\\', '/'), - path)); - } catch (IOException exception) { - LOGGER.atWarn() - .log("prompt_template_catalog_unavailable root={} reason={}", templateRoot, exception.getMessage()); - } - return templates; - } - - private static boolean isNegotiationPath(String path) { - if (!path.startsWith(CLASSPATH_TEMPLATE_ROOT)) { - return false; - } - String relative = path.substring(CLASSPATH_TEMPLATE_ROOT.length()); - int slash = relative.indexOf('/'); - return slash >= 0 && NEGOTIATION_EXTENSION.equals(relative.substring(0, slash)); - } - - private static boolean isNegotiationUri(String uri) { - int slash = uri.indexOf('/'); - return slash >= 0 && NEGOTIATION_EXTENSION.equals(uri.substring(0, slash)); - } - - private static boolean isClosedSetNegotiationUri(String uri) { - String[] segments = uri.split("/"); - if (segments.length != 4) { - return false; - } - if (!NEGOTIATION_EXTENSION.equals(segments[0]) || !TemplateUri.DEFAULT_TEMPLATE_VERSION.equals(segments[3])) { - return false; - } - String type = segments[1]; - String phase = segments[2]; - if ("common".equals(type)) { - return "abort".equals(phase); - } - return NEGOTIATION_TYPES.contains(type) && NEGOTIATION_PHASES.contains(phase); - } - - private static boolean isCatalogableUri(String uri) { - if (uri == null || uri.isBlank()) { - return false; - } - String[] segments = uri.split("/"); - if (segments.length < MINIMUM_URI_SEGMENTS) { - return false; - } - for (String segment : segments) { - if (!PathSegments.isSimpleSegment(segment)) { - return false; - } - } - return true; - } - - private record TemplateSource(String content, String source) {} -} +package net.openan.a2at.sdk.prompt.resources.catalog; + +import java.io.IOException; +import java.io.InputStream; +import java.net.JarURLConnection; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.model.A2ATConfigKeys; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.core.resources.PathSegments; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Directory-driven catalog over the prompt template tree of every A2A-T extension. + * + *

        The catalog enumerates the {@code templates/} tree of the configured source by walking its directories, so every + * extension directory that appears under {@code prompt_resources/templates/} — Task-T, Notification-T, Authorization-T, + * Negotiation-T and any extension added later — is picked up without a hardcoded extension list. A template file lives + * at {@code templates/////template.md} and is addressed by the + * URI formed from the segments before the language, for example + * {@code Negotiation-T/information-negotiation/propose/v1} or {@code Task-T/network-layer/ran-energy-saving/v1}. + * + *

        The catalog builds the effective set at assembly time according to the configured {@code sourceType}: the + * {@code classpath} mode enumerates the full built-in template tree; the {@code local_file} mode enumerates only the + * local business content (Task-T, Notification-T and Authorization-T) plus the classpath-fixed Negotiation-T templates, + * without unioning the built-in business content. Every template record carries its effective origin ({@code classpath} + * or {@code local}). Negotiation-T templates are filtered to the closed set of seven shapes; a Negotiation-T template + * outside the closed set is ignored with a warning. Both query methods never throw: a template or a root that cannot be + * loaded is skipped or answered with an empty result and a warning log. + * + * @since 2026-08 + */ +final class PromptTemplateCatalog { + + private static final Logger LOGGER = LoggerFactory.getLogger(PromptTemplateCatalog.class); + + private static final String CLASSPATH_ROOT = "prompt_resources/"; + + private static final String CLASSPATH_TEMPLATE_ROOT = CLASSPATH_ROOT + "templates/"; + + private static final String TEMPLATE_DIRECTORY = "templates"; + + private static final String TEMPLATE_FILE_NAME = "template.md"; + + private static final int MINIMUM_URI_SEGMENTS = 3; + + private static final String NEGOTIATION_EXTENSION = "Negotiation-T"; + + private static final Set NEGOTIATION_TYPES = + Set.of("information-negotiation", "target-negotiation", "feasibility-negotiation"); + + private static final Set NEGOTIATION_PHASES = Set.of("propose", "accept-reject"); + + private final String language; + + private final String sourceType; + + private final Path localRootDir; + + private final Map classpathSnapshot; + + private final Map localSnapshot; + + /** + * Creates a catalog for one language, two source types and an optional local template root. + * + *

        The constructor captures the classpath template tree and, in {@code local_file} mode, the local + * {@code templates/} tree once as immutable snapshots: the catalog never re-reads the classpath or the local + * filesystem after construction, so a template that is added or removed after assembly is not visible to + * {@link #loadAll()} or {@link #load(TemplateUri)}. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @param sourceType resource source selector, {@code classpath} or {@code local_file} + * @param localRootDir local prompt resource root containing the {@code templates/} tree; required in + * {@code local_file} mode and ignored otherwise + * @throws IllegalArgumentException if the language is not a simple path segment, the source type is unsupported, or + * {@code local_file} mode is selected without a local root directory or with one that does not exist + */ + PromptTemplateCatalog(@NonNull String language, @NonNull String sourceType, @Nullable String localRootDir) { + if (!PathSegments.isSimpleSegment(language)) { + throw new IllegalArgumentException( + "Prompt template catalog language must be a non-blank simple path segment but was " + language + + "."); + } + this.language = language; + if (PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH.equals(sourceType)) { + this.sourceType = PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH; + } else if (PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(sourceType)) { + this.sourceType = PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE; + } else { + throw new IllegalArgumentException("Unsupported prompt source type: " + sourceType); + } + if (PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(this.sourceType) + && (localRootDir == null || localRootDir.isBlank())) { + throw new IllegalArgumentException("Prompt resource local root directory is required for sourceType '" + + PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE + + "' but is not set; configure " + A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR + + " to the local prompt resource root."); + } + this.localRootDir = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); + if (this.localRootDir != null && !Files.isDirectory(this.localRootDir)) { + throw new IllegalArgumentException("Prompt resource local root directory configured via " + + A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR + + " does not exist or is not a directory: " + + this.localRootDir); + } + this.classpathSnapshot = captureClasspathTemplates(); + this.localSnapshot = + PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(this.sourceType) && this.localRootDir != null + ? Map.copyOf(localTemplates()) + : Map.of(); + } + + private Map captureClasspathTemplates() { + try { + return Map.copyOf(classpathTemplates()); + } catch (IOException | URISyntaxException exception) { + LOGGER.atWarn().log("prompt_template_catalog_unavailable root=classpath reason={}", exception.getMessage()); + return Map.of(); + } + } + + /** + * Lists every loadable template of the configured language across all extensions of the effective set. + * + *

        The effective set depends on the configured {@code sourceType}: {@code classpath} lists the full built-in + * tree, {@code local_file} lists the local business content plus the classpath-fixed Negotiation-T templates. + * Negotiation-T templates outside the closed set are ignored with a warning. The result is sorted by template URI. + * + * @return loadable templates of the configured language sorted by URI; empty when none can be loaded + */ + public @NonNull List loadAll() { + Map entriesByPath = new LinkedHashMap<>(); + collectClasspathEntries(entriesByPath); + collectLocalEntries(entriesByPath); + List templates = new ArrayList<>(); + String languageSuffix = "/" + language + "/" + TEMPLATE_FILE_NAME; + for (Map.Entry entry : entriesByPath.entrySet()) { + String path = entry.getKey(); + if (!path.endsWith(languageSuffix)) { + continue; + } + String uri = path.substring(CLASSPATH_TEMPLATE_ROOT.length(), path.length() - languageSuffix.length()); + if (!isCatalogableUri(uri)) { + LOGGER.atDebug().log("prompt_template_skipped path={} reason=not_a_template_uri", path); + continue; + } + if (isNegotiationUri(uri) && !isClosedSetNegotiationUri(uri)) { + LOGGER.atWarn().log("negotiation_template_outside_closed_set uri={}", uri); + continue; + } + String content = entry.getValue().content(); + TemplateUri templateUri = TemplateUri.parse(uri) + .orElseThrow(() -> new IllegalStateException( + "Catalogable template URI could not be parsed: " + uri + " (path: " + path + ")")); + templates.add(new PromptTemplate( + templateUri, + TemplateDescriptions.extract(content), + content, + entry.getValue().source())); + } + templates.sort(Comparator.comparing(template -> template.templateUri().uri())); + LOGGER.atDebug().log("prompt_templates_listed count={} language={}", templates.size(), language); + return List.copyOf(templates); + } + + /** + * Loads one template of the configured language by its URI, regardless of the extension. + * + *

        A Negotiation-T template outside the closed set is answered with an empty result and a warning. Business + * content follows the configured {@code sourceType}: {@code local_file} reads the addressed template from the local + * root only (no classpath fallback), {@code classpath} reads it from the classpath. + * + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1} or + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return the addressed template, or an empty optional when no template exists for it in the configured language + * @throws NullPointerException if the template URI is null + */ + public Optional load(@NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + String uri = templateUri.uri(); + boolean negotiation = NEGOTIATION_EXTENSION.equals(templateUri.extensionName()); + if (negotiation && !isClosedSetNegotiationUri(uri)) { + return Optional.empty(); + } + String classpathPath = CLASSPATH_ROOT + String.join("/", TEMPLATE_DIRECTORY, uri, language, TEMPLATE_FILE_NAME); + String source; + String content; + if (negotiation || PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH.equals(sourceType)) { + source = PromptTemplate.SOURCE_CLASSPATH; + content = classpathSnapshot.get(classpathPath); + } else { + source = PromptTemplate.SOURCE_LOCAL; + content = localSnapshot.get(classpathPath); + } + if (content == null) { + return Optional.empty(); + } + return Optional.of(new PromptTemplate(templateUri, TemplateDescriptions.extract(content), content, source)); + } + + private void collectClasspathEntries(Map entriesByPath) { + for (Map.Entry entry : classpathSnapshot.entrySet()) { + if (PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(sourceType) && !isNegotiationPath(entry.getKey())) { + continue; + } + entriesByPath.put(entry.getKey(), new TemplateSource(entry.getValue(), PromptTemplate.SOURCE_CLASSPATH)); + } + } + + private void collectLocalEntries(Map entriesByPath) { + if (!PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(sourceType) || localRootDir == null) { + return; + } + for (Map.Entry entry : localSnapshot.entrySet()) { + if (isNegotiationPath(entry.getKey())) { + continue; + } + entriesByPath.put(entry.getKey(), new TemplateSource(entry.getValue(), PromptTemplate.SOURCE_LOCAL)); + } + } + + private Map classpathTemplates() throws IOException, URISyntaxException { + Map templates = new LinkedHashMap<>(); + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + if (classLoader == null) { + classLoader = PromptTemplateCatalog.class.getClassLoader(); + } + Enumeration roots = classLoader.getResources(CLASSPATH_TEMPLATE_ROOT); + while (roots.hasMoreElements()) { + URL root = roots.nextElement(); + if ("file".equals(root.getProtocol())) { + collectFilesystemTemplates(Path.of(root.toURI()), templates); + } else if ("jar".equals(root.getProtocol())) { + collectJarTemplates((JarURLConnection) root.openConnection(), templates); + } else { + LOGGER.atDebug().log("prompt_template_root_skipped url={} reason=unsupported_protocol", root); + } + } + return templates; + } + + private static void collectFilesystemTemplates(Path rootDirectory, Map templates) + throws IOException { + try (Stream paths = Files.walk(rootDirectory)) { + paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().equals(TEMPLATE_FILE_NAME)) + .forEach(path -> collectTemplate( + templates, + CLASSPATH_TEMPLATE_ROOT + + rootDirectory.relativize(path).toString().replace('\\', '/'), + path)); + } + } + + private static void collectJarTemplates(JarURLConnection connection, Map templates) + throws IOException { + try (java.util.jar.JarFile jarFile = connection.getJarFile()) { + jarFile.stream() + .filter(entry -> !entry.isDirectory()) + .filter(entry -> entry.getName().startsWith(CLASSPATH_TEMPLATE_ROOT)) + .filter(entry -> entry.getName().endsWith(TEMPLATE_FILE_NAME)) + .forEach(entry -> collectTemplate(templates, entry.getName(), null)); + } + } + + private static void collectTemplate(Map templates, String classpathPath, Path filesystemPath) { + try { + String content; + if (filesystemPath != null) { + content = Files.readString(filesystemPath, StandardCharsets.UTF_8); + } else { + InputStream stream = ClasspathResourceStreams.open(classpathPath); + if (stream == null) { + LOGGER.atDebug().log( + "prompt_template_skipped path={} reason=classpath_entry_unreadable", classpathPath); + return; + } + try (stream) { + content = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + templates.put(classpathPath, content); + } catch (IOException exception) { + LOGGER.atDebug().log("prompt_template_skipped path={} reason=read_failed", classpathPath); + } + } + + private Map localTemplates() { + Map templates = new LinkedHashMap<>(); + Path templateRoot = localRootDir.resolve(TEMPLATE_DIRECTORY); + if (!Files.isDirectory(templateRoot)) { + return templates; + } + try (Stream paths = Files.walk(templateRoot)) { + paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().equals(TEMPLATE_FILE_NAME)) + .forEach(path -> collectTemplate( + templates, + CLASSPATH_TEMPLATE_ROOT + + templateRoot.relativize(path).toString().replace('\\', '/'), + path)); + } catch (IOException exception) { + LOGGER.atWarn() + .log("prompt_template_catalog_unavailable root={} reason={}", templateRoot, exception.getMessage()); + } + return templates; + } + + private static boolean isNegotiationPath(String path) { + if (!path.startsWith(CLASSPATH_TEMPLATE_ROOT)) { + return false; + } + String relative = path.substring(CLASSPATH_TEMPLATE_ROOT.length()); + int slash = relative.indexOf('/'); + return slash >= 0 && NEGOTIATION_EXTENSION.equals(relative.substring(0, slash)); + } + + private static boolean isNegotiationUri(String uri) { + int slash = uri.indexOf('/'); + return slash >= 0 && NEGOTIATION_EXTENSION.equals(uri.substring(0, slash)); + } + + private static boolean isClosedSetNegotiationUri(String uri) { + String[] segments = uri.split("/"); + if (segments.length != 4) { + return false; + } + if (!NEGOTIATION_EXTENSION.equals(segments[0]) || !TemplateUri.DEFAULT_TEMPLATE_VERSION.equals(segments[3])) { + return false; + } + String type = segments[1]; + String phase = segments[2]; + if ("common".equals(type)) { + return "abort".equals(phase); + } + return NEGOTIATION_TYPES.contains(type) && NEGOTIATION_PHASES.contains(phase); + } + + private static boolean isCatalogableUri(String uri) { + if (uri == null || uri.isBlank()) { + return false; + } + String[] segments = uri.split("/"); + if (segments.length < MINIMUM_URI_SEGMENTS) { + return false; + } + for (String segment : segments) { + if (!PathSegments.isSimpleSegment(segment)) { + return false; + } + } + return true; + } + + private record TemplateSource(String content, String source) {} +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java index 0e955f61..43446a3d 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java @@ -1,98 +1,98 @@ -package net.openan.a2at.sdk.prompt.resources.catalog; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Generic template query service consumed by the client and the server facade. - * - *

        The service exposes the extension-agnostic template queries of the design document over a - * {@link PromptTemplateCatalog}: {@link #getPrompts()} lists every loadable template of the configured language across - * all A2A-T extensions and {@link #getPrompt(TemplateUri)} loads one template by its URI. Both queries never throw; a - * missing template is answered with an empty result and an actionable warning log. - * - * @since 2026-08 - */ -public final class TemplateQueryService { - - private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(TemplateQueryService.class); - - private static final String LANGUAGE_HINT = - "set A2AT_LANGUAGE to a language with bundled templates (zh-CN or en-US)"; - - private final PromptTemplateCatalog templateCatalog; - - private final String language; - - private final Logger logger; - - /** - * Creates one service over a catalog for the given language. - * - * @param templateCatalog directory-driven catalog over the template tree of every extension - * @param language locale identifier the catalog was created for; only used in log messages - */ - public TemplateQueryService(@NonNull PromptTemplateCatalog templateCatalog, @NonNull String language) { - this.templateCatalog = Objects.requireNonNull(templateCatalog, "Prompt template catalog must not be null."); - this.language = Objects.requireNonNull(language, "Language must not be null."); - this.logger = DEFAULT_LOGGER; - } - - /** - * Creates one service for one language, one source type and an optional local template root. - * - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @param sourceType resource source selector, {@code classpath} or {@code local_file} - * @param localRootDir local prompt resource root containing the {@code templates/} tree; only consulted in - * {@code local_file} mode and may be null otherwise - */ - public TemplateQueryService(@NonNull String language, @NonNull String sourceType, @Nullable String localRootDir) { - this(new PromptTemplateCatalog(language, sourceType, localRootDir), language); - } - - /** - * Lists every template available for the configured language across all A2A-T extensions. - * - *

        This query never throws: the extension directories are discovered from the resource tree itself, templates - * that exist nowhere for the language are skipped, and an empty list is returned when no template can be loaded at - * all. The result is sorted by template URI, which orders by extension first. - * - * @return loadable templates of the configured language across all extensions, sorted by URI; empty when none can - * be loaded - */ - public @NonNull List getPrompts() { - return templateCatalog.loadAll(); - } - - /** - * Loads one template by its URI, regardless of the extension. - * - *

        This query never throws: a template that exists nowhere for the configured language returns an empty result - * and logs an actionable warning. - * - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1} or - * {@code Task-T/network-layer/ran-energy-saving/v1} - * @return the addressed template, or an empty result when the template does not exist for the configured language - * @throws NullPointerException if the template URI is null - */ - public Optional getPrompt(@NonNull TemplateUri templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - Optional template = templateCatalog.load(templateUri); - if (template.isEmpty()) { - logger.atWarn() - .log( - "prompt_template_not_found uri={} language={} hint={}", - templateUri.uri(), - language, - LANGUAGE_HINT); - } - return template; - } -} +package net.openan.a2at.sdk.prompt.resources.catalog; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Generic template query service consumed by the client and the server facade. + * + *

        The service exposes the extension-agnostic template queries of the design document over a + * {@link PromptTemplateCatalog}: {@link #getPrompts()} lists every loadable template of the configured language across + * all A2A-T extensions and {@link #getPrompt(TemplateUri)} loads one template by its URI. Both queries never throw; a + * missing template is answered with an empty result and an actionable warning log. + * + * @since 2026-08 + */ +public final class TemplateQueryService { + + private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(TemplateQueryService.class); + + private static final String LANGUAGE_HINT = + "set A2AT_LANGUAGE to a language with bundled templates (zh-CN or en-US)"; + + private final PromptTemplateCatalog templateCatalog; + + private final String language; + + private final Logger logger; + + /** + * Creates one service over a catalog for the given language. + * + * @param templateCatalog directory-driven catalog over the template tree of every extension + * @param language locale identifier the catalog was created for; only used in log messages + */ + public TemplateQueryService(@NonNull PromptTemplateCatalog templateCatalog, @NonNull String language) { + this.templateCatalog = Objects.requireNonNull(templateCatalog, "Prompt template catalog must not be null."); + this.language = Objects.requireNonNull(language, "Language must not be null."); + this.logger = DEFAULT_LOGGER; + } + + /** + * Creates one service for one language, one source type and an optional local template root. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @param sourceType resource source selector, {@code classpath} or {@code local_file} + * @param localRootDir local prompt resource root containing the {@code templates/} tree; only consulted in + * {@code local_file} mode and may be null otherwise + */ + public TemplateQueryService(@NonNull String language, @NonNull String sourceType, @Nullable String localRootDir) { + this(new PromptTemplateCatalog(language, sourceType, localRootDir), language); + } + + /** + * Lists every template available for the configured language across all A2A-T extensions. + * + *

        This query never throws: the extension directories are discovered from the resource tree itself, templates + * that exist nowhere for the language are skipped, and an empty list is returned when no template can be loaded at + * all. The result is sorted by template URI, which orders by extension first. + * + * @return loadable templates of the configured language across all extensions, sorted by URI; empty when none can + * be loaded + */ + public @NonNull List getPrompts() { + return templateCatalog.loadAll(); + } + + /** + * Loads one template by its URI, regardless of the extension. + * + *

        This query never throws: a template that exists nowhere for the configured language returns an empty result + * and logs an actionable warning. + * + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1} or + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return the addressed template, or an empty result when the template does not exist for the configured language + * @throws NullPointerException if the template URI is null + */ + public Optional getPrompt(@NonNull TemplateUri templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + Optional template = templateCatalog.load(templateUri); + if (template.isEmpty()) { + logger.atWarn() + .log( + "prompt_template_not_found uri={} language={} hint={}", + templateUri.uri(), + language, + LANGUAGE_HINT); + } + return template; + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java index 39375093..e30e7d96 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java @@ -1,62 +1,62 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.JsonProcessingException; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; - -/** - * Loads shared scenario catalogs from one local prompt resource root. - * - *

        Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch - * the filesystem, so changes to the local files only take effect after the SDK is restarted. - * - * @since 2026-06 - */ -public final class LocalFilePromptScenarioCatalogLoader { - - private final Map snapshot; - private final Path promptRootDir; - - public LocalFilePromptScenarioCatalogLoader(Map snapshot, Path promptRootDir) { - this.snapshot = snapshot; - this.promptRootDir = promptRootDir; - } - - public List load(String language) { - PathSegments.requireSimpleSegment(language, "Prompt scenario catalog language"); - String pathKey = "scenarios/" + language + "/scenarios.json"; - if (!snapshot.containsKey(pathKey)) { - throw new ResourceNotFoundException( - "Prompt resource file does not exist.", - promptRootDir - .resolve("scenarios") - .resolve(language) - .resolve("scenarios.json") - .toString()); - } - try { - return PromptResourceJsonParser.parse(snapshot.get(pathKey), ScenarioCatalog.class) - .scenarios(); - } catch (JsonProcessingException exception) { - throw ResourceReadErrors.readFailed( - promptRootDir - .resolve("scenarios") - .resolve(language) - .resolve("scenarios.json") - .toString(), - language, - exception); - } - } - - private record ScenarioCatalog(@JsonProperty("scenarios") List scenarios) { - ScenarioCatalog { - scenarios = scenarios == null ? List.of() : List.copyOf(scenarios); - } - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.resources.PathSegments; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; + +/** + * Loads shared scenario catalogs from one local prompt resource root. + * + *

        Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch the + * filesystem, so changes to the local files only take effect after the SDK is restarted. + * + * @since 2026-06 + */ +public final class LocalFilePromptScenarioCatalogLoader { + + private final Map snapshot; + private final Path promptRootDir; + + public LocalFilePromptScenarioCatalogLoader(Map snapshot, Path promptRootDir) { + this.snapshot = snapshot; + this.promptRootDir = promptRootDir; + } + + public List load(String language) { + PathSegments.requireSimpleSegment(language, "Prompt scenario catalog language"); + String pathKey = "scenarios/" + language + "/scenarios.json"; + if (!snapshot.containsKey(pathKey)) { + throw new ResourceNotFoundException( + "Prompt resource file does not exist.", + promptRootDir + .resolve("scenarios") + .resolve(language) + .resolve("scenarios.json") + .toString()); + } + try { + return PromptResourceJsonParser.parse(snapshot.get(pathKey), ScenarioCatalog.class) + .scenarios(); + } catch (JsonProcessingException exception) { + throw ResourceReadErrors.readFailed( + promptRootDir + .resolve("scenarios") + .resolve(language) + .resolve("scenarios.json") + .toString(), + language, + exception); + } + } + + private record ScenarioCatalog(@JsonProperty("scenarios") List scenarios) { + ScenarioCatalog { + scenarios = scenarios == null ? List.of() : List.copyOf(scenarios); + } + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java index e0205365..fe8abb6c 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java @@ -1,84 +1,84 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import com.fasterxml.jackson.core.JsonProcessingException; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; - -/** - * Loads shared slot schemas from one local prompt resource root. - * - *

        Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch - * the filesystem, so changes to the local files only take effect after the SDK is restarted. - * - * @since 2026-06 - */ -public final class LocalFilePromptSlotSchemaLoader implements PromptSlotSchemaLoader { - - private final Map snapshot; - private final Path promptRootDir; - private final List slotTypes; - - public LocalFilePromptSlotSchemaLoader(Map snapshot, Path promptRootDir) { - this.snapshot = snapshot; - this.promptRootDir = promptRootDir; - this.slotTypes = LocalFileResourceSnapshot.typeDirectories(snapshot, "slots"); - } - - @Override - public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { - PathSegments.requireSimpleRelativePath(scenarioCode, "Prompt slot schema scenario code"); - PathSegments.requireSimpleSegment(language, "Prompt slot schema language"); - String pathKey; - if (scenarioCode.contains("/")) { - pathKey = "slots/" + scenarioCode + "/" + language + "/slot.json"; - } else { - pathKey = null; - for (String slotType : slotTypes) { - String candidate = resolveBareCode(slotType, scenarioCode, language); - if (snapshot.containsKey(candidate)) { - pathKey = candidate; - break; - } - } - } - if (pathKey == null || !snapshot.containsKey(pathKey)) { - throw notFound(scenarioCode, language); - } - try { - return PromptResourceJsonParser.parse(snapshot.get(pathKey), PromptSlotJsonSchema.class) - .toPromptSlotSchema(scenarioCode); - } catch (JsonProcessingException exception) { - throw ResourceReadErrors.readFailed(promptRootDir.resolve(pathKey).toString(), language, exception); - } - } - - /** - * Resolves a bare scenario code under one slot type directory, preferring the {@code network-layer} domain layout - * over the plain layout. - */ - private String resolveBareCode(String slotType, String scenarioCode, String language) { - String networkLayer = "slots/" + slotType + "/network-layer/" + scenarioCode + "/v1/" + language + "/slot.json"; - if (snapshot.containsKey(networkLayer)) { - return networkLayer; - } - return "slots/" + slotType + "/" + scenarioCode + "/v1/" + language + "/slot.json"; - } - - private ResourceNotFoundException notFound(String scenarioCode, String language) { - String pathHint = scenarioCode.contains("/") - ? promptRootDir - .resolve("slots") - .resolve(scenarioCode) - .resolve(language) - .resolve("slot.json") - .toString() - : promptRootDir.resolve("slots") + "/*/network-layer/" + scenarioCode + "/v1/" + language - + "/slot.json (or the layout without the network-layer segment)"; - return new ResourceNotFoundException("Prompt resource file does not exist.", pathHint); - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import com.fasterxml.jackson.core.JsonProcessingException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.resources.PathSegments; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; + +/** + * Loads shared slot schemas from one local prompt resource root. + * + *

        Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch the + * filesystem, so changes to the local files only take effect after the SDK is restarted. + * + * @since 2026-06 + */ +public final class LocalFilePromptSlotSchemaLoader implements PromptSlotSchemaLoader { + + private final Map snapshot; + private final Path promptRootDir; + private final List slotTypes; + + public LocalFilePromptSlotSchemaLoader(Map snapshot, Path promptRootDir) { + this.snapshot = snapshot; + this.promptRootDir = promptRootDir; + this.slotTypes = LocalFileResourceSnapshot.typeDirectories(snapshot, "slots"); + } + + @Override + public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { + PathSegments.requireSimpleRelativePath(scenarioCode, "Prompt slot schema scenario code"); + PathSegments.requireSimpleSegment(language, "Prompt slot schema language"); + String pathKey; + if (scenarioCode.contains("/")) { + pathKey = "slots/" + scenarioCode + "/" + language + "/slot.json"; + } else { + pathKey = null; + for (String slotType : slotTypes) { + String candidate = resolveBareCode(slotType, scenarioCode, language); + if (snapshot.containsKey(candidate)) { + pathKey = candidate; + break; + } + } + } + if (pathKey == null || !snapshot.containsKey(pathKey)) { + throw notFound(scenarioCode, language); + } + try { + return PromptResourceJsonParser.parse(snapshot.get(pathKey), PromptSlotJsonSchema.class) + .toPromptSlotSchema(scenarioCode); + } catch (JsonProcessingException exception) { + throw ResourceReadErrors.readFailed(promptRootDir.resolve(pathKey).toString(), language, exception); + } + } + + /** + * Resolves a bare scenario code under one slot type directory, preferring the {@code network-layer} domain layout + * over the plain layout. + */ + private String resolveBareCode(String slotType, String scenarioCode, String language) { + String networkLayer = "slots/" + slotType + "/network-layer/" + scenarioCode + "/v1/" + language + "/slot.json"; + if (snapshot.containsKey(networkLayer)) { + return networkLayer; + } + return "slots/" + slotType + "/" + scenarioCode + "/v1/" + language + "/slot.json"; + } + + private ResourceNotFoundException notFound(String scenarioCode, String language) { + String pathHint = scenarioCode.contains("/") + ? promptRootDir + .resolve("slots") + .resolve(scenarioCode) + .resolve(language) + .resolve("slot.json") + .toString() + : promptRootDir.resolve("slots") + "/*/network-layer/" + scenarioCode + "/v1/" + language + + "/slot.json (or the layout without the network-layer segment)"; + return new ResourceNotFoundException("Prompt resource file does not exist.", pathHint); + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java index 340a49e3..756c95ff 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java @@ -1,77 +1,77 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.resources.PathSegments; - -/** - * Loads shared prompt templates from one local prompt resource root. - * - *

        Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch - * the filesystem, so changes to the local files only take effect after the SDK is restarted. - * - * @since 2026-06 - */ -public final class LocalFilePromptTemplateLoader implements PromptTemplateTextLoader { - - private final Map snapshot; - private final Path promptRootDir; - private final List templateTypes; - - public LocalFilePromptTemplateLoader(Map snapshot, Path promptRootDir) { - this.snapshot = snapshot; - this.promptRootDir = promptRootDir; - this.templateTypes = LocalFileResourceSnapshot.typeDirectories(snapshot, "templates"); - } - - @Override - public String loadTemplate(String scenarioCode, String language) { - PathSegments.requireSimpleRelativePath(scenarioCode, "Prompt template scenario code"); - PathSegments.requireSimpleSegment(language, "Prompt template language"); - String pathKey; - if (scenarioCode.contains("/")) { - pathKey = "templates/" + scenarioCode + "/" + language + "/template.md"; - } else { - pathKey = null; - for (String templateType : templateTypes) { - String candidate = resolveBareCode(templateType, scenarioCode, language); - if (snapshot.containsKey(candidate)) { - pathKey = candidate; - break; - } - } - } - if (pathKey == null || !snapshot.containsKey(pathKey)) { - throw notFound(scenarioCode, language); - } - return snapshot.get(pathKey); - } - - /** - * Resolves a bare scenario code under one template type directory, preferring the {@code network-layer} domain - * layout over the plain layout. - */ - private String resolveBareCode(String templateType, String scenarioCode, String language) { - String networkLayer = - "templates/" + templateType + "/network-layer/" + scenarioCode + "/v1/" + language + "/template.md"; - if (snapshot.containsKey(networkLayer)) { - return networkLayer; - } - return "templates/" + templateType + "/" + scenarioCode + "/v1/" + language + "/template.md"; - } - - private ResourceNotFoundException notFound(String scenarioCode, String language) { - String pathHint = scenarioCode.contains("/") - ? promptRootDir - .resolve("templates") - .resolve(scenarioCode) - .resolve(language) - .resolve("template.md") - .toString() - : promptRootDir.resolve("templates") + "/*/network-layer/" + scenarioCode + "/v1/" + language - + "/template.md (or the layout without the network-layer segment)"; - return new ResourceNotFoundException("Prompt resource file does not exist.", pathHint); - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.resources.PathSegments; + +/** + * Loads shared prompt templates from one local prompt resource root. + * + *

        Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch the + * filesystem, so changes to the local files only take effect after the SDK is restarted. + * + * @since 2026-06 + */ +public final class LocalFilePromptTemplateLoader implements PromptTemplateTextLoader { + + private final Map snapshot; + private final Path promptRootDir; + private final List templateTypes; + + public LocalFilePromptTemplateLoader(Map snapshot, Path promptRootDir) { + this.snapshot = snapshot; + this.promptRootDir = promptRootDir; + this.templateTypes = LocalFileResourceSnapshot.typeDirectories(snapshot, "templates"); + } + + @Override + public String loadTemplate(String scenarioCode, String language) { + PathSegments.requireSimpleRelativePath(scenarioCode, "Prompt template scenario code"); + PathSegments.requireSimpleSegment(language, "Prompt template language"); + String pathKey; + if (scenarioCode.contains("/")) { + pathKey = "templates/" + scenarioCode + "/" + language + "/template.md"; + } else { + pathKey = null; + for (String templateType : templateTypes) { + String candidate = resolveBareCode(templateType, scenarioCode, language); + if (snapshot.containsKey(candidate)) { + pathKey = candidate; + break; + } + } + } + if (pathKey == null || !snapshot.containsKey(pathKey)) { + throw notFound(scenarioCode, language); + } + return snapshot.get(pathKey); + } + + /** + * Resolves a bare scenario code under one template type directory, preferring the {@code network-layer} domain + * layout over the plain layout. + */ + private String resolveBareCode(String templateType, String scenarioCode, String language) { + String networkLayer = + "templates/" + templateType + "/network-layer/" + scenarioCode + "/v1/" + language + "/template.md"; + if (snapshot.containsKey(networkLayer)) { + return networkLayer; + } + return "templates/" + templateType + "/" + scenarioCode + "/v1/" + language + "/template.md"; + } + + private ResourceNotFoundException notFound(String scenarioCode, String language) { + String pathHint = scenarioCode.contains("/") + ? promptRootDir + .resolve("templates") + .resolve(scenarioCode) + .resolve(language) + .resolve("template.md") + .toString() + : promptRootDir.resolve("templates") + "/*/network-layer/" + scenarioCode + "/v1/" + language + + "/template.md (or the layout without the network-layer segment)"; + return new ResourceNotFoundException("Prompt resource file does not exist.", pathHint); + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java index cb76ca7a..8c09b49a 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java @@ -1,86 +1,86 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.exception.A2ATError; - -/** - * Immutable in-memory snapshot of a local prompt resource root, captured once at assembly time. - * - *

        The snapshot freezes the {@code templates/}, {@code slots/} and {@code scenarios/} subtrees read from the local - * root once at assembly time and never re-read. Runtime lookups resolve against this map rather than the - * filesystem, so local file changes only take effect after the SDK is restarted. - */ -final class LocalFileResourceSnapshot { - - private static final String SEPARATOR = "/"; - - private LocalFileResourceSnapshot() {} - - /** - * Captures the whole {@code templates/}, {@code slots/} and {@code scenarios/} subtrees under the local root into - * an immutable map keyed by forward-slash relative path. - * - * @param localRootDir local prompt resource root - * @return immutable snapshot mapping {@code templates/..., slots/..., scenarios/...} to file content - * @throws A2ATError if a file cannot be read or the subtree cannot be enumerated - */ - static Map capture(Path localRootDir) { - Map content = new TreeMap<>(); - captureTree(localRootDir.resolve("templates"), "templates", content); - captureTree(localRootDir.resolve("slots"), "slots", content); - captureTree(localRootDir.resolve("scenarios"), "scenarios", content); - return Map.copyOf(content); - } - - /** - * Returns the sorted first-level directory names found under one category prefix in the snapshot. - * - * @param snapshot snapshot content map - * @param category category prefix (templates or slots) - * @return sorted type directory names - */ - static List typeDirectories(Map snapshot, String category) { - Set types = new TreeSet<>(); - String prefix = category + SEPARATOR; - for (String key : snapshot.keySet()) { - if (!key.startsWith(prefix)) { - continue; - } - String rest = key.substring(prefix.length()); - int separator = rest.indexOf('/'); - if (separator > 0) { - types.add(rest.substring(0, separator)); - } - } - return List.copyOf(types); - } - - private static void captureTree(Path root, String category, Map content) { - if (!Files.isDirectory(root)) { - return; - } - List files; - try (Stream paths = Files.walk(root)) { - files = paths.filter(Files::isRegularFile).toList(); - } catch (IOException exception) { - throw new A2ATError("Failed to enumerate prompt resources under: " + root, exception); - } - for (Path file : files) { - String relativeKey = - category + SEPARATOR + root.relativize(file).toString().replace('\\', '/'); - try { - content.put(relativeKey, Files.readString(file)); - } catch (IOException exception) { - throw new A2ATError("Failed to read prompt resource: " + file, exception); - } - } - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.exception.A2ATError; + +/** + * Immutable in-memory snapshot of a local prompt resource root, captured once at assembly time. + * + *

        The snapshot freezes the {@code templates/}, {@code slots/} and {@code scenarios/} subtrees read from the local + * root once at assembly time and never re-read. Runtime lookups resolve against this map rather than the filesystem, so + * local file changes only take effect after the SDK is restarted. + */ +final class LocalFileResourceSnapshot { + + private static final String SEPARATOR = "/"; + + private LocalFileResourceSnapshot() {} + + /** + * Captures the whole {@code templates/}, {@code slots/} and {@code scenarios/} subtrees under the local root into + * an immutable map keyed by forward-slash relative path. + * + * @param localRootDir local prompt resource root + * @return immutable snapshot mapping {@code templates/..., slots/..., scenarios/...} to file content + * @throws A2ATError if a file cannot be read or the subtree cannot be enumerated + */ + static Map capture(Path localRootDir) { + Map content = new TreeMap<>(); + captureTree(localRootDir.resolve("templates"), "templates", content); + captureTree(localRootDir.resolve("slots"), "slots", content); + captureTree(localRootDir.resolve("scenarios"), "scenarios", content); + return Map.copyOf(content); + } + + /** + * Returns the sorted first-level directory names found under one category prefix in the snapshot. + * + * @param snapshot snapshot content map + * @param category category prefix (templates or slots) + * @return sorted type directory names + */ + static List typeDirectories(Map snapshot, String category) { + Set types = new TreeSet<>(); + String prefix = category + SEPARATOR; + for (String key : snapshot.keySet()) { + if (!key.startsWith(prefix)) { + continue; + } + String rest = key.substring(prefix.length()); + int separator = rest.indexOf('/'); + if (separator > 0) { + types.add(rest.substring(0, separator)); + } + } + return List.copyOf(types); + } + + private static void captureTree(Path root, String category, Map content) { + if (!Files.isDirectory(root)) { + return; + } + List files; + try (Stream paths = Files.walk(root)) { + files = paths.filter(Files::isRegularFile).toList(); + } catch (IOException exception) { + throw new A2ATError("Failed to enumerate prompt resources under: " + root, exception); + } + for (Path file : files) { + String relativeKey = + category + SEPARATOR + root.relativize(file).toString().replace('\\', '/'); + try { + content.put(relativeKey, Files.readString(file)); + } catch (IOException exception) { + throw new A2ATError("Failed to read prompt resource: " + file, exception); + } + } + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java index f0c3e0ff..0465119c 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java @@ -1,199 +1,199 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.A2ATConfigKeys; -import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; -import net.openan.a2at.sdk.resources.PromptResourceKey; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Shared access point for prompt resources backed either by local files or classpath resources. - * - *

        The factory routes resources by origin: the business content (tasks' templates, - * slot schemas and scenario catalog) follows the configured source type; the LLM instruction prompts, negotiation - * templates and negotiation vocabulary are always loaded from the classpath. - * - * @since 2026-06 - */ -public interface PromptResourceAccess { - - String CLASSPATH_SOURCE_TYPE = PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH; - - String LOCAL_FILE_SOURCE_TYPE = PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE; - - static PromptResourceAccess create(PromptRuntimeConfig config) { - if (CLASSPATH_SOURCE_TYPE.equals(config.sourceType())) { - if (hasConfiguredLocalRoot(config.localRootDir())) { - warnIgnoredClasspathLocalRoot(config.localRootDir()); - } - return new ClasspathAccess(new ClasspathPromptResourceLoader()); - } - if (LOCAL_FILE_SOURCE_TYPE.equals(config.sourceType())) { - Path localRootDir = requireLocalRoot(config.localRootDir()); - warnIgnoredLocalDirectories(localRootDir); - return new LocalFileAccess(localRootDir, new ClasspathPromptResourceLoader()); - } - throw new UnsupportedOperationException("Unsupported prompt source type: " + config.sourceType()); - } - - boolean classpath(); - - ClasspathPromptResourceLoader classpathResourceLoader(); - - Path localRootDir(); - - List loadScenarios(String language); - - PromptTemplateTextLoader templateLoader(); - - PromptSlotSchemaLoader slotSchemaLoader(); - - String loadPrompt(String promptCategory, String language, String fileName); - - final class ClasspathAccess implements PromptResourceAccess { - private final ClasspathPromptResourceLoader resourceLoader; - - private ClasspathAccess(ClasspathPromptResourceLoader resourceLoader) { - this.resourceLoader = resourceLoader; - } - - @Override - public boolean classpath() { - return true; - } - - @Override - public ClasspathPromptResourceLoader classpathResourceLoader() { - return resourceLoader; - } - - @Override - public Path localRootDir() { - throw new UnsupportedOperationException("Classpath prompt resources do not have a local root directory."); - } - - @Override - public List loadScenarios(String language) { - return new ClasspathPromptScenarioCatalogLoader(resourceLoader).load(language); - } - - @Override - public PromptTemplateTextLoader templateLoader() { - return new ClasspathPromptTemplateLoader(resourceLoader); - } - - @Override - public PromptSlotSchemaLoader slotSchemaLoader() { - return new ClasspathPromptSlotSchemaLoader(resourceLoader); - } - - @Override - public String loadPrompt(String promptCategory, String language, String fileName) { - return resourceLoader.loadText(PromptResourceKey.prompt(promptCategory, language, fileName)); - } - } - - final class LocalFileAccess implements PromptResourceAccess { - private final Path promptRootDir; - private final ClasspathPromptResourceLoader resourceLoader; - private final Map snapshot; - - private LocalFileAccess(Path promptRootDir, ClasspathPromptResourceLoader resourceLoader) { - this.promptRootDir = promptRootDir; - this.resourceLoader = resourceLoader; - this.snapshot = LocalFileResourceSnapshot.capture(promptRootDir); - } - - @Override - public boolean classpath() { - return false; - } - - @Override - public ClasspathPromptResourceLoader classpathResourceLoader() { - return resourceLoader; - } - - @Override - public Path localRootDir() { - return promptRootDir; - } - - @Override - public List loadScenarios(String language) { - return new LocalFilePromptScenarioCatalogLoader(snapshot, promptRootDir).load(language); - } - - @Override - public PromptTemplateTextLoader templateLoader() { - return new LocalFilePromptTemplateLoader(snapshot, promptRootDir); - } - - @Override - public PromptSlotSchemaLoader slotSchemaLoader() { - return new LocalFilePromptSlotSchemaLoader(snapshot, promptRootDir); - } - - @Override - public String loadPrompt(String promptCategory, String language, String fileName) { - return resourceLoader.loadText(PromptResourceKey.prompt(promptCategory, language, fileName)); - } - } - - private static boolean hasConfiguredLocalRoot(String localRootDir) { - return localRootDir != null && !localRootDir.isBlank(); - } - - private static Path requireLocalRoot(String localRootDir) { - String key = A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR; - if (localRootDir == null || localRootDir.isBlank()) { - throw new IllegalArgumentException( - "Prompt resource local root directory is required for sourceType '" + LOCAL_FILE_SOURCE_TYPE - + "' but is not set; configure " + key + " to the local prompt resource root."); - } - Path root = Path.of(localRootDir); - if (!Files.isDirectory(root)) { - throw new IllegalArgumentException("Prompt resource local root directory configured via " + key - + " does not exist or is not a directory: " + root); - } - return root; - } - - private static void warnIgnoredClasspathLocalRoot(String localRootDir) { - logger().atWarn() - .log( - "prompt_resource_local_root_ignored root={} source=classpath reason=source_type_is_classpath", - localRootDir); - } - - private static void warnIgnoredLocalDirectories(Path localRootDir) { - List ignoredDirectories = new ArrayList<>(); - if (Files.isDirectory(localRootDir.resolve("prompts"))) { - ignoredDirectories.add("prompts"); - } - if (Files.isDirectory(localRootDir.resolve("templates").resolve("Negotiation-T"))) { - ignoredDirectories.add("templates/Negotiation-T"); - } - if (Files.isDirectory(localRootDir.resolve("negotiation-vocabulary"))) { - ignoredDirectories.add("negotiation-vocabulary"); - } - if (!ignoredDirectories.isEmpty()) { - logger().atWarn() - .log( - "prompt_resource_local_directories_ignored root={} directories=[{}] reason=classpath_fixed", - localRootDir, - String.join(", ", ignoredDirectories)); - } - } - - private static Logger logger() { - return LoggerFactory.getLogger(PromptResourceAccess.class); - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.A2ATConfigKeys; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; +import net.openan.a2at.sdk.resources.PromptResourceKey; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Shared access point for prompt resources backed either by local files or classpath resources. + * + *

        The factory routes resources by origin: the business content (tasks' templates, slot schemas and scenario catalog) + * follows the configured source type; the LLM instruction prompts, negotiation templates and negotiation vocabulary are + * always loaded from the classpath. + * + * @since 2026-06 + */ +public interface PromptResourceAccess { + + String CLASSPATH_SOURCE_TYPE = PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH; + + String LOCAL_FILE_SOURCE_TYPE = PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE; + + static PromptResourceAccess create(PromptRuntimeConfig config) { + if (CLASSPATH_SOURCE_TYPE.equals(config.sourceType())) { + if (hasConfiguredLocalRoot(config.localRootDir())) { + warnIgnoredClasspathLocalRoot(config.localRootDir()); + } + return new ClasspathAccess(new ClasspathPromptResourceLoader()); + } + if (LOCAL_FILE_SOURCE_TYPE.equals(config.sourceType())) { + Path localRootDir = requireLocalRoot(config.localRootDir()); + warnIgnoredLocalDirectories(localRootDir); + return new LocalFileAccess(localRootDir, new ClasspathPromptResourceLoader()); + } + throw new UnsupportedOperationException("Unsupported prompt source type: " + config.sourceType()); + } + + boolean classpath(); + + ClasspathPromptResourceLoader classpathResourceLoader(); + + Path localRootDir(); + + List loadScenarios(String language); + + PromptTemplateTextLoader templateLoader(); + + PromptSlotSchemaLoader slotSchemaLoader(); + + String loadPrompt(String promptCategory, String language, String fileName); + + final class ClasspathAccess implements PromptResourceAccess { + private final ClasspathPromptResourceLoader resourceLoader; + + private ClasspathAccess(ClasspathPromptResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } + + @Override + public boolean classpath() { + return true; + } + + @Override + public ClasspathPromptResourceLoader classpathResourceLoader() { + return resourceLoader; + } + + @Override + public Path localRootDir() { + throw new UnsupportedOperationException("Classpath prompt resources do not have a local root directory."); + } + + @Override + public List loadScenarios(String language) { + return new ClasspathPromptScenarioCatalogLoader(resourceLoader).load(language); + } + + @Override + public PromptTemplateTextLoader templateLoader() { + return new ClasspathPromptTemplateLoader(resourceLoader); + } + + @Override + public PromptSlotSchemaLoader slotSchemaLoader() { + return new ClasspathPromptSlotSchemaLoader(resourceLoader); + } + + @Override + public String loadPrompt(String promptCategory, String language, String fileName) { + return resourceLoader.loadText(PromptResourceKey.prompt(promptCategory, language, fileName)); + } + } + + final class LocalFileAccess implements PromptResourceAccess { + private final Path promptRootDir; + private final ClasspathPromptResourceLoader resourceLoader; + private final Map snapshot; + + private LocalFileAccess(Path promptRootDir, ClasspathPromptResourceLoader resourceLoader) { + this.promptRootDir = promptRootDir; + this.resourceLoader = resourceLoader; + this.snapshot = LocalFileResourceSnapshot.capture(promptRootDir); + } + + @Override + public boolean classpath() { + return false; + } + + @Override + public ClasspathPromptResourceLoader classpathResourceLoader() { + return resourceLoader; + } + + @Override + public Path localRootDir() { + return promptRootDir; + } + + @Override + public List loadScenarios(String language) { + return new LocalFilePromptScenarioCatalogLoader(snapshot, promptRootDir).load(language); + } + + @Override + public PromptTemplateTextLoader templateLoader() { + return new LocalFilePromptTemplateLoader(snapshot, promptRootDir); + } + + @Override + public PromptSlotSchemaLoader slotSchemaLoader() { + return new LocalFilePromptSlotSchemaLoader(snapshot, promptRootDir); + } + + @Override + public String loadPrompt(String promptCategory, String language, String fileName) { + return resourceLoader.loadText(PromptResourceKey.prompt(promptCategory, language, fileName)); + } + } + + private static boolean hasConfiguredLocalRoot(String localRootDir) { + return localRootDir != null && !localRootDir.isBlank(); + } + + private static Path requireLocalRoot(String localRootDir) { + String key = A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR; + if (localRootDir == null || localRootDir.isBlank()) { + throw new IllegalArgumentException( + "Prompt resource local root directory is required for sourceType '" + LOCAL_FILE_SOURCE_TYPE + + "' but is not set; configure " + key + " to the local prompt resource root."); + } + Path root = Path.of(localRootDir); + if (!Files.isDirectory(root)) { + throw new IllegalArgumentException("Prompt resource local root directory configured via " + key + + " does not exist or is not a directory: " + root); + } + return root; + } + + private static void warnIgnoredClasspathLocalRoot(String localRootDir) { + logger().atWarn() + .log( + "prompt_resource_local_root_ignored root={} source=classpath reason=source_type_is_classpath", + localRootDir); + } + + private static void warnIgnoredLocalDirectories(Path localRootDir) { + List ignoredDirectories = new ArrayList<>(); + if (Files.isDirectory(localRootDir.resolve("prompts"))) { + ignoredDirectories.add("prompts"); + } + if (Files.isDirectory(localRootDir.resolve("templates").resolve("Negotiation-T"))) { + ignoredDirectories.add("templates/Negotiation-T"); + } + if (Files.isDirectory(localRootDir.resolve("negotiation-vocabulary"))) { + ignoredDirectories.add("negotiation-vocabulary"); + } + if (!ignoredDirectories.isEmpty()) { + logger().atWarn() + .log( + "prompt_resource_local_directories_ignored root={} directories=[{}] reason=classpath_fixed", + localRootDir, + String.join(", ", ignoredDirectories)); + } + } + + private static Logger logger() { + return LoggerFactory.getLogger(PromptResourceAccess.class); + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java index b1b3b416..d1701687 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -1,136 +1,136 @@ -package net.openan.a2at.sdk.prompt.validation; - -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.core.validation.ValidationPipeline; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Default content validator that orchestrates the full validation pipeline with a no-op rule checker and an LLM-backed - * semantic validator. - * - *

        The template referenced by the {@code templateUri} argument of every {@link #validate} call is loaded through a - * sourceType-aware template loader before the validation pipeline is entered. A load failure is reported as a - * {@code template.not_found} error, the same code that signals missing content_validation prompt resources. The loaded - * template text flows into the pipeline for prompt injection. - * - *

        The validation pipeline and the content_validation prompt resources are assembled eagerly in the constructor: the - * constructor resolves and freezes the prompt resources once, and a missing resource fails fast at assembly time - * instead of on the first {@link #validate} call. The content_validation prompt resources are internal LLM instructions - * loaded from the classpath regardless of the configured prompt source type. - * - * @since 2026-08 - */ -public final class DefaultContentValidator implements ContentValidator { - - private final String extensionName; - - private final String language; - - private final PromptTemplateTextLoader templateLoader; - - private final ValidationPipeline pipeline; - - /** - * Creates a content validator for the given extension name and language. - * - *

        The constructor assembles the validation pipeline and loads the content_validation prompt resources of the - * configured language from the classpath. - * - * @param extensionName extension name used for template URI validation - * @param language language code for prompt resource loading and template loading - * @param maxAttempts maximum retry attempts for semantic validation - * @param llmClient LLM client for semantic validation; may be {@code null}, in which case the first - * {@link #validate} call fails with {@code ContentValidationException} carrying {@code llm.not_configured}; - * there is no late injection point - * @param templateLoader sourceType-aware template text loader for loading the template referenced by the - * {@code templateUri} of every {@link #validate} call - * @throws ContentValidationException if the content_validation prompt resources of the given language are missing - * on the classpath - */ - public DefaultContentValidator( - @NonNull String extensionName, - @NonNull String language, - int maxAttempts, - @Nullable LLMClient llmClient, - @NonNull PromptTemplateTextLoader templateLoader) { - this.extensionName = extensionName; - this.language = language; - this.templateLoader = templateLoader; - this.pipeline = assemblePipeline(language, maxAttempts, llmClient); - } - - /** - * Validates one content prompt and extracts its filled parameters. - * - * @param prompt content prompt text - * @param schema caller-provided parameter JSON schema - * @param templateUri URI of the template the content is validated against, such as - * {@code Task-T/network-layer/ran-energy-saving/v1} - * @return filled parameter data carrying the merged parameters - * @throws ContentValidationException with {@code negotiation.invalid_input} if the template URI is null, addresses - * another extension than the one this validator is configured for, or carries an unsupported version; or if the - * prompt is null or blank, or the schema is null - * @throws ContentValidationException if the validation fails at any stage, including {@code template.not_found} - * when the template cannot be loaded - */ - @Override - public FilledParamData validate( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - if (templateUri == null) { - throw invalidInput("Template URI must not be null."); - } - - if (!extensionName.equals(templateUri.extensionName())) { - throw invalidInput("Template URI extension '" + templateUri.extensionName() - + "' does not match expected extension '" + extensionName + "'."); - } - - if (!TemplateUri.DEFAULT_TEMPLATE_VERSION.equals(templateUri.templateVersion())) { - throw invalidInput("Unsupported template URI version: " + templateUri.templateVersion()); - } - - String templateContent; - try { - templateContent = templateLoader.loadTemplate(templateUri.uri(), language); - } catch (ResourceNotFoundException exception) { - Map facts = Map.of("template_uri", templateUri.uri(), "language", language); - throw new ContentValidationException( - ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), - ErrorMessages.render(ErrorCatalog.TEMPLATE_NOT_FOUND, language, facts), - exception); - } - - return pipeline.validate(prompt, schema, templateUri, templateContent); - } - - private ContentValidationException invalidInput(String reason) { - Map facts = Map.of("reason", reason); - return new ContentValidationException( - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts)); - } - - private static ValidationPipeline assemblePipeline( - String language, int maxAttempts, @Nullable LLMClient llmClient) { - try { - return new ValidationPipeline<>( - prompt -> Map.of(), new DefaultSemanticValidator(llmClient, language), maxAttempts, language); - } catch (ResourceNotFoundException exception) { - Map facts = Map.of("template_uri", exception.resourcePath(), "language", language); - throw new ContentValidationException( - ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), - ErrorMessages.render(ErrorCatalog.TEMPLATE_NOT_FOUND, language, facts), - exception); - } - } -} +package net.openan.a2at.sdk.prompt.validation; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.core.validation.ValidationPipeline; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Default content validator that orchestrates the full validation pipeline with a no-op rule checker and an LLM-backed + * semantic validator. + * + *

        The template referenced by the {@code templateUri} argument of every {@link #validate} call is loaded through a + * sourceType-aware template loader before the validation pipeline is entered. A load failure is reported as a + * {@code template.not_found} error, the same code that signals missing content_validation prompt resources. The loaded + * template text flows into the pipeline for prompt injection. + * + *

        The validation pipeline and the content_validation prompt resources are assembled eagerly in the constructor: the + * constructor resolves and freezes the prompt resources once, and a missing resource fails fast at assembly time + * instead of on the first {@link #validate} call. The content_validation prompt resources are internal LLM instructions + * loaded from the classpath regardless of the configured prompt source type. + * + * @since 2026-08 + */ +public final class DefaultContentValidator implements ContentValidator { + + private final String extensionName; + + private final String language; + + private final PromptTemplateTextLoader templateLoader; + + private final ValidationPipeline pipeline; + + /** + * Creates a content validator for the given extension name and language. + * + *

        The constructor assembles the validation pipeline and loads the content_validation prompt resources of the + * configured language from the classpath. + * + * @param extensionName extension name used for template URI validation + * @param language language code for prompt resource loading and template loading + * @param maxAttempts maximum retry attempts for semantic validation + * @param llmClient LLM client for semantic validation; may be {@code null}, in which case the first + * {@link #validate} call fails with {@code ContentValidationException} carrying {@code llm.not_configured}; + * there is no late injection point + * @param templateLoader sourceType-aware template text loader for loading the template referenced by the + * {@code templateUri} of every {@link #validate} call + * @throws ContentValidationException if the content_validation prompt resources of the given language are missing + * on the classpath + */ + public DefaultContentValidator( + @NonNull String extensionName, + @NonNull String language, + int maxAttempts, + @Nullable LLMClient llmClient, + @NonNull PromptTemplateTextLoader templateLoader) { + this.extensionName = extensionName; + this.language = language; + this.templateLoader = templateLoader; + this.pipeline = assemblePipeline(language, maxAttempts, llmClient); + } + + /** + * Validates one content prompt and extracts its filled parameters. + * + * @param prompt content prompt text + * @param schema caller-provided parameter JSON schema + * @param templateUri URI of the template the content is validated against, such as + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return filled parameter data carrying the merged parameters + * @throws ContentValidationException with {@code negotiation.invalid_input} if the template URI is null, addresses + * another extension than the one this validator is configured for, or carries an unsupported version; or if the + * prompt is null or blank, or the schema is null + * @throws ContentValidationException if the validation fails at any stage, including {@code template.not_found} + * when the template cannot be loaded + */ + @Override + public FilledParamData validate( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + if (templateUri == null) { + throw invalidInput("Template URI must not be null."); + } + + if (!extensionName.equals(templateUri.extensionName())) { + throw invalidInput("Template URI extension '" + templateUri.extensionName() + + "' does not match expected extension '" + extensionName + "'."); + } + + if (!TemplateUri.DEFAULT_TEMPLATE_VERSION.equals(templateUri.templateVersion())) { + throw invalidInput("Unsupported template URI version: " + templateUri.templateVersion()); + } + + String templateContent; + try { + templateContent = templateLoader.loadTemplate(templateUri.uri(), language); + } catch (ResourceNotFoundException exception) { + Map facts = Map.of("template_uri", templateUri.uri(), "language", language); + throw new ContentValidationException( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorMessages.render(ErrorCatalog.TEMPLATE_NOT_FOUND, language, facts), + exception); + } + + return pipeline.validate(prompt, schema, templateUri, templateContent); + } + + private ContentValidationException invalidInput(String reason) { + Map facts = Map.of("reason", reason); + return new ContentValidationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts)); + } + + private static ValidationPipeline assemblePipeline( + String language, int maxAttempts, @Nullable LLMClient llmClient) { + try { + return new ValidationPipeline<>( + prompt -> Map.of(), new DefaultSemanticValidator(llmClient, language), maxAttempts, language); + } catch (ResourceNotFoundException exception) { + Map facts = Map.of("template_uri", exception.resourcePath(), "language", language); + throw new ContentValidationException( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorMessages.render(ErrorCatalog.TEMPLATE_NOT_FOUND, language, facts), + exception); + } + } +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java index d0c77360..390836a4 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java @@ -1,237 +1,240 @@ -package net.openan.a2at.sdk.prompt.resources.catalog; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.slf4j.LoggerFactory; - -class TemplateQueryServiceTest { - - private static final String LANGUAGE = "en-US"; - - private static final String CLASSPATH = "classpath"; - - private static final String LOCAL = "local"; - - private static final List NEGOTIATION_CLOSED_SET = List.of( - "Negotiation-T/common/abort/v1", - "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "Negotiation-T/feasibility-negotiation/propose/v1", - "Negotiation-T/information-negotiation/accept-reject/v1", - "Negotiation-T/information-negotiation/propose/v1", - "Negotiation-T/target-negotiation/accept-reject/v1", - "Negotiation-T/target-negotiation/propose/v1"); - - @TempDir - Path localRootDir; - - private final ListAppender appender = new ListAppender<>(); - - private final Logger rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); - - @BeforeEach - void attachAppender() { - appender.start(); - rootLogger.addAppender(appender); - } - - @AfterEach - void detachAppender() { - rootLogger.detachAppender(appender); - appender.stop(); - } - - @Test - void classpathSourceTypeListsBuiltInTemplatesWithClasspathSource() { - TemplateQueryService service = new TemplateQueryService(LANGUAGE, "classpath", null); - - List templates = service.getPrompts(); - - assertFalse(templates.isEmpty()); - assertTrue(templates.stream().allMatch(t -> CLASSPATH.equals(t.source()))); - assertSortedByUri(templates); - Set uris = templates.stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); - assertTrue(uris.containsAll(NEGOTIATION_CLOSED_SET)); - } - - @Test - void localFileSourceTypeListsLocalBusinessContentUnionClasspathNegotiationOnly() throws IOException { - writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); - - TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); - - List templates = service.getPrompts(); - Set uris = templates.stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); - - assertTrue(uris.contains("Task-T/network-layer/custom-planning/v1")); - assertFalse(uris.contains("Task-T/network-layer/ran-energy-saving/v1")); - assertFalse(uris.contains("Notification-T/network-layer/subscribe-incident/v1")); - assertFalse(uris.contains("Authorization-T/authorization-policy-management/v1")); - for (String negotiationUri : NEGOTIATION_CLOSED_SET) { - assertTrue(uris.contains(negotiationUri)); - } - } - - @Test - void localFileSourceTypeAnnotatesLocalAndClasspathSources() throws IOException { - writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); - - TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); - - List templates = service.getPrompts(); - PromptTemplate localTask = templates.stream() - .filter(t -> t.templateUri().uri().equals("Task-T/network-layer/custom-planning/v1")) - .findFirst() - .orElseThrow(); - assertEquals(LOCAL, localTask.source()); - - assertTrue( - templates.stream() - .filter(t -> t.templateUri().extensionName().equals("Negotiation-T")) - .allMatch(t -> CLASSPATH.equals(t.source())), - "all negotiation templates must be sourced from the classpath"); - } - - @Test - void closedSetFilterExcludesOutOfSetNegotiationTemplates() throws IOException { - writeTemplate(localRootDir, "Negotiation-T/feasibility-negotiation/revise/v1", "\nbody"); - writeTemplate( - localRootDir, "Negotiation-T/information-negotiation/propose/v1", "\nbody"); - - TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); - - Set uris = - service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); - - assertFalse(uris.contains("Negotiation-T/feasibility-negotiation/revise/v1")); - assertTrue(uris.contains("Negotiation-T/information-negotiation/propose/v1")); - assertTrue( - service.getPrompts().stream() - .filter(t -> t.templateUri().uri().equals("Negotiation-T/information-negotiation/propose/v1")) - .allMatch(t -> CLASSPATH.equals(t.source())), - "the in-closed-set negotiation template must come from the classpath, not the local override"); - - assertTrue(service.getPrompt(TemplateUri.of("Negotiation-T", "feasibility-negotiation", "revise")) - .isEmpty()); - } - - @Test - void localFileSourceTypeFreezesCatalogSnapshotIgnoringRuntimeFileChanges() throws IOException { - writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); - - TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); - Set before = service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); - - writeTemplate(localRootDir, "Notification-T/network-layer/added-after-construction/v1", "\nbody"); - Files.delete(localRootDir.resolve("templates") - .resolve("Task-T/network-layer/custom-planning/v1") - .resolve(LANGUAGE) - .resolve("template.md")); - - Set after = service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); - - assertEquals(before, after); - } - - @Test - void closedSetMissLogsSinglePromptTemplateNotFoundWarning() { - new TemplateQueryService(LANGUAGE, "classpath", null) - .getPrompt(TemplateUri.of("Negotiation-T", "feasibility-negotiation", "revise")); - - List warnings = warningEvents(); - assertEquals(1, warnings.size(), "expected exactly one WARN for querying a closed-set-outside negotiation URI"); - assertTrue( - warnings.get(0).startsWith("prompt_template_not_found"), - "expected a prompt_template_not_found WARN, not a negotiation_template_outside_closed_set WARN"); - } - - @Test - void unsupportedSourceTypeFailsFast() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new TemplateQueryService(LANGUAGE, "database", localRootDir.toString())); - - assertEquals("Unsupported prompt source type: database", exception.getMessage()); - } - - @Test - void localFileSourceTypeWithoutRootFailsFast() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> new TemplateQueryService(LANGUAGE, "local_file", null)); - - assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); - } - - @Test - void localFileSourceTypeWithBlankRootFailsFast() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> new TemplateQueryService(LANGUAGE, "local_file", " ")); - - assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); - } - - @Test - void localFileSourceTypeWithNonexistentRootFailsFast() { - Path missing = localRootDir.resolve("missing"); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new TemplateQueryService(LANGUAGE, "local_file", missing.toString())); - - assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); - } - - @Test - void singleQueryMissLogsPromptTemplateNotFoundWarning() { - new TemplateQueryService(LANGUAGE, "classpath", null) - .getPrompt(TemplateUri.of("Task-T", "network-layer", "does-not-exist")); - - assertTrue( - warningEvents().stream().anyMatch(message -> message.startsWith("prompt_template_not_found")), - "expected a WARN for a missing single template query"); - } - - private List warningEvents() { - return appender.list.stream() - .filter(event -> event.getLevel() == Level.WARN) - .map(ILoggingEvent::getFormattedMessage) - .toList(); - } - - private static void writeTemplate(Path root, String uri, String content) throws IOException { - Path file = root.resolve("templates").resolve(uri).resolve(LANGUAGE).resolve("template.md"); - Files.createDirectories(file.getParent()); - Files.writeString(file, content); - } - - private static void assertSortedByUri(List templates) { - for (int i = 1; i < templates.size(); i++) { - assertTrue( - templates - .get(i - 1) - .templateUri() - .uri() - .compareTo(templates.get(i).templateUri().uri()) - <= 0, - "templates must be sorted by URI"); - } - } -} +package net.openan.a2at.sdk.prompt.resources.catalog; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +class TemplateQueryServiceTest { + + private static final String LANGUAGE = "en-US"; + + private static final String CLASSPATH = "classpath"; + + private static final String LOCAL = "local"; + + private static final List NEGOTIATION_CLOSED_SET = List.of( + "Negotiation-T/common/abort/v1", + "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "Negotiation-T/feasibility-negotiation/propose/v1", + "Negotiation-T/information-negotiation/accept-reject/v1", + "Negotiation-T/information-negotiation/propose/v1", + "Negotiation-T/target-negotiation/accept-reject/v1", + "Negotiation-T/target-negotiation/propose/v1"); + + @TempDir + Path localRootDir; + + private final ListAppender appender = new ListAppender<>(); + + private final Logger rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + + @BeforeEach + void attachAppender() { + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + rootLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void classpathSourceTypeListsBuiltInTemplatesWithClasspathSource() { + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "classpath", null); + + List templates = service.getPrompts(); + + assertFalse(templates.isEmpty()); + assertTrue(templates.stream().allMatch(t -> CLASSPATH.equals(t.source()))); + assertSortedByUri(templates); + Set uris = templates.stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + assertTrue(uris.containsAll(NEGOTIATION_CLOSED_SET)); + } + + @Test + void localFileSourceTypeListsLocalBusinessContentUnionClasspathNegotiationOnly() throws IOException { + writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + List templates = service.getPrompts(); + Set uris = templates.stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + + assertTrue(uris.contains("Task-T/network-layer/custom-planning/v1")); + assertFalse(uris.contains("Task-T/network-layer/ran-energy-saving/v1")); + assertFalse(uris.contains("Notification-T/network-layer/subscribe-incident/v1")); + assertFalse(uris.contains("Authorization-T/authorization-policy-management/v1")); + for (String negotiationUri : NEGOTIATION_CLOSED_SET) { + assertTrue(uris.contains(negotiationUri)); + } + } + + @Test + void localFileSourceTypeAnnotatesLocalAndClasspathSources() throws IOException { + writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + List templates = service.getPrompts(); + PromptTemplate localTask = templates.stream() + .filter(t -> t.templateUri().uri().equals("Task-T/network-layer/custom-planning/v1")) + .findFirst() + .orElseThrow(); + assertEquals(LOCAL, localTask.source()); + + assertTrue( + templates.stream() + .filter(t -> t.templateUri().extensionName().equals("Negotiation-T")) + .allMatch(t -> CLASSPATH.equals(t.source())), + "all negotiation templates must be sourced from the classpath"); + } + + @Test + void closedSetFilterExcludesOutOfSetNegotiationTemplates() throws IOException { + writeTemplate(localRootDir, "Negotiation-T/feasibility-negotiation/revise/v1", "\nbody"); + writeTemplate( + localRootDir, "Negotiation-T/information-negotiation/propose/v1", "\nbody"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + Set uris = + service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + + assertFalse(uris.contains("Negotiation-T/feasibility-negotiation/revise/v1")); + assertTrue(uris.contains("Negotiation-T/information-negotiation/propose/v1")); + assertTrue( + service.getPrompts().stream() + .filter(t -> t.templateUri().uri().equals("Negotiation-T/information-negotiation/propose/v1")) + .allMatch(t -> CLASSPATH.equals(t.source())), + "the in-closed-set negotiation template must come from the classpath, not the local override"); + + assertTrue(service.getPrompt(TemplateUri.of("Negotiation-T", "feasibility-negotiation", "revise")) + .isEmpty()); + } + + @Test + void localFileSourceTypeFreezesCatalogSnapshotIgnoringRuntimeFileChanges() throws IOException { + writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + Set before = + service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + + writeTemplate(localRootDir, "Notification-T/network-layer/added-after-construction/v1", "\nbody"); + Files.delete(localRootDir + .resolve("templates") + .resolve("Task-T/network-layer/custom-planning/v1") + .resolve(LANGUAGE) + .resolve("template.md")); + + Set after = + service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + + assertEquals(before, after); + } + + @Test + void closedSetMissLogsSinglePromptTemplateNotFoundWarning() { + new TemplateQueryService(LANGUAGE, "classpath", null) + .getPrompt(TemplateUri.of("Negotiation-T", "feasibility-negotiation", "revise")); + + List warnings = warningEvents(); + assertEquals(1, warnings.size(), "expected exactly one WARN for querying a closed-set-outside negotiation URI"); + assertTrue( + warnings.get(0).startsWith("prompt_template_not_found"), + "expected a prompt_template_not_found WARN, not a negotiation_template_outside_closed_set WARN"); + } + + @Test + void unsupportedSourceTypeFailsFast() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new TemplateQueryService(LANGUAGE, "database", localRootDir.toString())); + + assertEquals("Unsupported prompt source type: database", exception.getMessage()); + } + + @Test + void localFileSourceTypeWithoutRootFailsFast() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> new TemplateQueryService(LANGUAGE, "local_file", null)); + + assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); + } + + @Test + void localFileSourceTypeWithBlankRootFailsFast() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> new TemplateQueryService(LANGUAGE, "local_file", " ")); + + assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); + } + + @Test + void localFileSourceTypeWithNonexistentRootFailsFast() { + Path missing = localRootDir.resolve("missing"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new TemplateQueryService(LANGUAGE, "local_file", missing.toString())); + + assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); + } + + @Test + void singleQueryMissLogsPromptTemplateNotFoundWarning() { + new TemplateQueryService(LANGUAGE, "classpath", null) + .getPrompt(TemplateUri.of("Task-T", "network-layer", "does-not-exist")); + + assertTrue( + warningEvents().stream().anyMatch(message -> message.startsWith("prompt_template_not_found")), + "expected a WARN for a missing single template query"); + } + + private List warningEvents() { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void writeTemplate(Path root, String uri, String content) throws IOException { + Path file = root.resolve("templates").resolve(uri).resolve(LANGUAGE).resolve("template.md"); + Files.createDirectories(file.getParent()); + Files.writeString(file, content); + } + + private static void assertSortedByUri(List templates) { + for (int i = 1; i < templates.size(); i++) { + assertTrue( + templates + .get(i - 1) + .templateUri() + .uri() + .compareTo(templates.get(i).templateUri().uri()) + <= 0, + "templates must be sorted by URI"); + } + } +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java index ad832bfe..36e08404 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java @@ -1,335 +1,327 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class LocalFilePromptLoadersTest { - - @TempDir - Path promptRootDir; - - @Test - void loadScenarioCatalogMapsJacksonAnnotatedRecords() throws IOException { - write( - promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "incident_triage", - "scenario_name": "Incident Triage", - "description": "Classify and route a production incident.", - "example": "Investigate elevated API latency." - }, - { - "scenario_code": "release_review", - "scenario_name": "Release Review", - "description": "Review a release candidate before rollout.", - "example": "Check the payment service release." - } - ] - } - """); - - List scenarios = - new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en"); - - assertEquals(2, scenarios.size()); - assertEquals("incident_triage", scenarios.get(0).scenarioCode()); - assertEquals("Incident Triage", scenarios.get(0).scenarioName()); - } - - @Test - void loadScenarioCatalogTreatsMissingScenariosArrayAsEmptyCatalog() throws IOException { - write(promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), "{}"); - - assertEquals(List.of(), new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en")); - } - - @Test - void loadTemplateReadsExactLocalMarkdownText() throws IOException { - write( - promptRootDir - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("template.md"), - """ - # Incident Triage - - Severity: {{severity}} - Summary: {{summary}} - """); - - String template = - new LocalFilePromptTemplateLoader(snapshot(), promptRootDir).loadTemplate("incident_triage", "en"); - - assertEquals( - """ - # Incident Triage - - Severity: {{severity}} - Summary: {{summary}} - """, - template); - } - - @Test - void loadSlotSchemaMapsJacksonAnnotatedRecords() throws IOException { - write( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - """ - { - "required": ["severity", "service"], - "properties": { - "service": { - "type": "string", - "pattern": "^[a-z0-9-]+$", - "description": "Affected service name" - }, - "severity": { - "type": "integer", - "minimum": 1, - "maximum": 5, - "enum": ["1", "2", "3", "4", "5"], - "x-a2at-value-constraint": "Severity from 1 to 5" - }, - "note": null - } - } - """); - - PromptSlotSchema schema = - new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir).loadSlotSchema("incident_triage", "en"); - - assertEquals("incident_triage", schema.scenarioCode()); - assertEquals(3, schema.slotDefinitions().size()); - assertEquals("service", schema.slotDefinitions().get(0).name()); - assertEquals(true, schema.slotDefinitions().get(0).required()); - assertEquals("^[a-z0-9-]+$", schema.slotDefinitions().get(0).pattern()); - assertEquals( - List.of("1", "2", "3", "4", "5"), - schema.slotDefinitions().get(1).allowedValues()); - assertEquals("Severity from 1 to 5", schema.slotDefinitions().get(1).valueConstraint()); - assertEquals("note", schema.slotDefinitions().get(2).name()); - } - - @Test - void missingTemplateIncludesResolvedLocalPathInResourceNotFoundException() { - ResourceNotFoundException exception = assertThrows( - ResourceNotFoundException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) - .loadTemplate("incident_triage", "en")); - - String expected = (promptRootDir.resolve("templates").toString() - + "/*/network-layer/incident_triage/v1/en/template.md" - + " (or the layout without the network-layer segment)") - .replace('\\', '/'); - assertEquals(expected, exception.resourcePath().replace('\\', '/')); - } - - @Test - void malformedLocalSlotSchemaIsWrappedAsA2ATError() throws IOException { - write( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - "{ \"required\": [\"severity\"], \"properties\": "); - - A2ATError exception = - assertThrows(A2ATError.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) - .loadSlotSchema("incident_triage", "en")); - - assertEquals("infra.resource_read_failed", exception.getCode()); - assertTrue(exception.getMessage().startsWith("Failed to read resource '")); - } - - @Test - void snapshotFreezesResourcesAgainstRuntimeFileChanges() throws IOException { - write( - promptRootDir - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("template.md"), - "Original template {{service}}."); - write( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - """ - { - "required": ["service"], - "properties": { - "service": {"type": "string", "description": "Affected service name"} - } - } - """); - write( - promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "incident_triage", - "scenario_name": "Incident Triage", - "description": "Classify and route a production incident.", - "example": "Investigate elevated API latency." - } - ] - } - """); - - PromptResourceAccess access = createAccess(); - - Files.writeString( - promptRootDir - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("template.md"), - "Mutated template."); - Files.writeString( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - "{\"properties\":{}}"); - write( - promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "mutated", - "scenario_name": "Mutated", - "description": "Mutated after snapshot.", - "example": "Mutated example." - } - ] - } - """); - - assertEquals("Original template {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); - assertEquals( - "service", - access.slotSchemaLoader() - .loadSlotSchema("incident_triage", "en") - .slotDefinitions() - .get(0) - .name()); - assertEquals("incident_triage", access.loadScenarios("en").get(0).scenarioCode()); - } - - @Test - void loadTemplateRejectsTraversalOrBlankScenarioPathSegments() { - assertThrows( - IllegalArgumentException.class, - () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) - .loadTemplate("../etc/passwd", "en")); - assertThrows( - IllegalArgumentException.class, - () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) - .loadTemplate("Task-T//network-layer/x", "en")); - assertThrows( - IllegalArgumentException.class, - () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) - .loadTemplate("incident_triage", "en/../admin")); - } - - @Test - void loadSlotSchemaRejectsNonSimpleLanguage() { - assertThrows( - IllegalArgumentException.class, - () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) - .loadSlotSchema("incident_triage", "../en")); - assertThrows( - IllegalArgumentException.class, - () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) - .loadSlotSchema("Task-T/network-layer/..", "en")); - } - - @Test - void loadScenarioCatalogRejectsNonSimpleLanguage() { - assertThrows( - IllegalArgumentException.class, - () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en/../admin")); - assertThrows( - IllegalArgumentException.class, - () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load(" ")); - } - - @Test - void localSnapshotKeepsPromptsOnClasspath() throws IOException { - write( - promptRootDir - .resolve("prompts") - .resolve("scenario_recognition") - .resolve("en-US") - .resolve("system.md"), - "Local prompt copy."); - - PromptResourceAccess access = createAccess(); - - assertFalse(access.classpath()); - assertNotEquals("Local prompt copy.", access.loadPrompt("scenario_recognition", "en-US", "system.md")); - } - - private PromptResourceAccess createAccess() { - return PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - } - - private Map snapshot() { - return LocalFileResourceSnapshot.capture(promptRootDir); - } - - private static void write(Path file, String content) throws IOException { - Files.createDirectories(file.getParent()); - Files.writeString(file, content); - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LocalFilePromptLoadersTest { + + @TempDir + Path promptRootDir; + + @Test + void loadScenarioCatalogMapsJacksonAnnotatedRecords() throws IOException { + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "incident_triage", + "scenario_name": "Incident Triage", + "description": "Classify and route a production incident.", + "example": "Investigate elevated API latency." + }, + { + "scenario_code": "release_review", + "scenario_name": "Release Review", + "description": "Review a release candidate before rollout.", + "example": "Check the payment service release." + } + ] + } + """); + + List scenarios = + new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en"); + + assertEquals(2, scenarios.size()); + assertEquals("incident_triage", scenarios.get(0).scenarioCode()); + assertEquals("Incident Triage", scenarios.get(0).scenarioName()); + } + + @Test + void loadScenarioCatalogTreatsMissingScenariosArrayAsEmptyCatalog() throws IOException { + write(promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), "{}"); + + assertEquals(List.of(), new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en")); + } + + @Test + void loadTemplateReadsExactLocalMarkdownText() throws IOException { + write( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + """ + # Incident Triage + + Severity: {{severity}} + Summary: {{summary}} + """); + + String template = + new LocalFilePromptTemplateLoader(snapshot(), promptRootDir).loadTemplate("incident_triage", "en"); + + assertEquals( + """ + # Incident Triage + + Severity: {{severity}} + Summary: {{summary}} + """, + template); + } + + @Test + void loadSlotSchemaMapsJacksonAnnotatedRecords() throws IOException { + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + """ + { + "required": ["severity", "service"], + "properties": { + "service": { + "type": "string", + "pattern": "^[a-z0-9-]+$", + "description": "Affected service name" + }, + "severity": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "enum": ["1", "2", "3", "4", "5"], + "x-a2at-value-constraint": "Severity from 1 to 5" + }, + "note": null + } + } + """); + + PromptSlotSchema schema = + new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir).loadSlotSchema("incident_triage", "en"); + + assertEquals("incident_triage", schema.scenarioCode()); + assertEquals(3, schema.slotDefinitions().size()); + assertEquals("service", schema.slotDefinitions().get(0).name()); + assertEquals(true, schema.slotDefinitions().get(0).required()); + assertEquals("^[a-z0-9-]+$", schema.slotDefinitions().get(0).pattern()); + assertEquals( + List.of("1", "2", "3", "4", "5"), + schema.slotDefinitions().get(1).allowedValues()); + assertEquals("Severity from 1 to 5", schema.slotDefinitions().get(1).valueConstraint()); + assertEquals("note", schema.slotDefinitions().get(2).name()); + } + + @Test + void missingTemplateIncludesResolvedLocalPathInResourceNotFoundException() { + ResourceNotFoundException exception = assertThrows( + ResourceNotFoundException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) + .loadTemplate("incident_triage", "en")); + + String expected = (promptRootDir.resolve("templates").toString() + + "/*/network-layer/incident_triage/v1/en/template.md" + + " (or the layout without the network-layer segment)") + .replace('\\', '/'); + assertEquals(expected, exception.resourcePath().replace('\\', '/')); + } + + @Test + void malformedLocalSlotSchemaIsWrappedAsA2ATError() throws IOException { + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + "{ \"required\": [\"severity\"], \"properties\": "); + + A2ATError exception = + assertThrows(A2ATError.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) + .loadSlotSchema("incident_triage", "en")); + + assertEquals("infra.resource_read_failed", exception.getCode()); + assertTrue(exception.getMessage().startsWith("Failed to read resource '")); + } + + @Test + void snapshotFreezesResourcesAgainstRuntimeFileChanges() throws IOException { + write( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + "Original template {{service}}."); + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + """ + { + "required": ["service"], + "properties": { + "service": {"type": "string", "description": "Affected service name"} + } + } + """); + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "incident_triage", + "scenario_name": "Incident Triage", + "description": "Classify and route a production incident.", + "example": "Investigate elevated API latency." + } + ] + } + """); + + PromptResourceAccess access = createAccess(); + + Files.writeString( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + "Mutated template."); + Files.writeString( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + "{\"properties\":{}}"); + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "mutated", + "scenario_name": "Mutated", + "description": "Mutated after snapshot.", + "example": "Mutated example." + } + ] + } + """); + + assertEquals("Original template {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); + assertEquals( + "service", + access.slotSchemaLoader() + .loadSlotSchema("incident_triage", "en") + .slotDefinitions() + .get(0) + .name()); + assertEquals("incident_triage", access.loadScenarios("en").get(0).scenarioCode()); + } + + @Test + void loadTemplateRejectsTraversalOrBlankScenarioPathSegments() { + assertThrows(IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) + .loadTemplate("../etc/passwd", "en")); + assertThrows(IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) + .loadTemplate("Task-T//network-layer/x", "en")); + assertThrows(IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) + .loadTemplate("incident_triage", "en/../admin")); + } + + @Test + void loadSlotSchemaRejectsNonSimpleLanguage() { + assertThrows( + IllegalArgumentException.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) + .loadSlotSchema("incident_triage", "../en")); + assertThrows( + IllegalArgumentException.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) + .loadSlotSchema("Task-T/network-layer/..", "en")); + } + + @Test + void loadScenarioCatalogRejectsNonSimpleLanguage() { + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en/../admin")); + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load(" ")); + } + + @Test + void localSnapshotKeepsPromptsOnClasspath() throws IOException { + write( + promptRootDir + .resolve("prompts") + .resolve("scenario_recognition") + .resolve("en-US") + .resolve("system.md"), + "Local prompt copy."); + + PromptResourceAccess access = createAccess(); + + assertFalse(access.classpath()); + assertNotEquals("Local prompt copy.", access.loadPrompt("scenario_recognition", "en-US", "system.md")); + } + + private PromptResourceAccess createAccess() { + return PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + } + + private Map snapshot() { + return LocalFileResourceSnapshot.capture(promptRootDir); + } + + private static void write(Path file, String content) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, content); + } +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java index 5e424bb5..b2526c62 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java @@ -1,235 +1,235 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.slf4j.LoggerFactory; - -class PromptResourceAccessTest { - - @TempDir - Path promptRootDir; - - private final ListAppender appender = new ListAppender<>(); - - private final Logger logger = (Logger) LoggerFactory.getLogger(PromptResourceAccess.class); - - @BeforeEach - void attachAppender() { - appender.start(); - logger.addAppender(appender); - } - - @AfterEach - void detachAppender() { - logger.detachAppender(appender); - } - - @Test - void classpathModeIgnoresConfiguredLocalRootWithSingleWarning() { - PromptResourceAccess access = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", promptRootDir.toString())); - - assertTrue(access.classpath()); - List warnings = warningMessages(); - assertEquals(1, warnings.size()); - assertContains( - warnings.get(0), "prompt_resource_local_root_ignored", "root=" + promptRootDir, "source=classpath"); - } - - @Test - void localFileModeFailsFastWhenLocalRootIsNull() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", null))); - - assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); - } - - @Test - void localFileModeFailsFastWhenLocalRootIsBlank() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", " "))); - - assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); - } - - @Test - void localFileModeFailsFastWhenLocalRootDoesNotExist() { - Path missing = promptRootDir.resolve("missing"); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", missing.toString()))); - - assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); - } - - @Test - void localFileModeLoadsPromptsFromClasspath() throws IOException { - write( - promptRootDir - .resolve("prompts") - .resolve("scenario_recognition") - .resolve("en-US") - .resolve("system.md"), - "Local prompt copy."); - - PromptResourceAccess access = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - - assertFalse(access.classpath()); - String prompt = access.loadPrompt("scenario_recognition", "en-US", "system.md"); - assertTrue(prompt.startsWith("You are a scenario recognition agent.")); - assertFalse(prompt.equals("Local prompt copy.")); - } - - @Test - void localFileModeLoadsBusinessContentFromLocalRoot() throws IOException { - write( - promptRootDir - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("template.md"), - "Local template for {{service}}."); - write( - promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "incident_triage", - "scenario_name": "Incident Triage", - "description": "Classify and route a production incident.", - "example": "Investigate elevated API latency." - } - ] - } - """); - write( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - """ - { - "required": ["service"], - "properties": { - "service": {"type": "string", "description": "Affected service name"} - } - } - """); - - PromptResourceAccess access = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - - assertFalse(access.classpath()); - assertEquals(promptRootDir, access.localRootDir()); - assertEquals("Local template for {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); - List scenarios = access.loadScenarios("en"); - PromptSlotSchema slotSchema = access.slotSchemaLoader().loadSlotSchema("incident_triage", "en"); - assertEquals("incident_triage", scenarios.get(0).scenarioCode()); - assertEquals("service", slotSchema.slotDefinitions().get(0).name()); - assertEquals(true, slotSchema.slotDefinitions().get(0).required()); - } - - @Test - void localFileModeIgnoresUnsupportedLocalDirectoriesWithSingleWarning() throws IOException { - write( - promptRootDir - .resolve("prompts") - .resolve("scenario_recognition") - .resolve("en-US") - .resolve("system.md"), - "Local prompt copy."); - write( - promptRootDir - .resolve("templates") - .resolve("Negotiation-T") - .resolve("information-negotiation") - .resolve("propose") - .resolve("v1") - .resolve("en-US") - .resolve("template.md"), - "Local negotiation template copy."); - write(promptRootDir.resolve("negotiation-vocabulary").resolve("en-US").resolve("vocabulary.json"), "{}"); - - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - - List warnings = warningMessages(); - assertEquals(1, warnings.size()); - assertContains( - warnings.get(0), - "prompt_resource_local_directories_ignored", - "prompts", - "templates/Negotiation-T", - "negotiation-vocabulary", - "reason=classpath_fixed"); - } - - @Test - void localFileModePromptLoadUsesClasspathPathForMissingResource() { - PromptResourceAccess access = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - - ResourceNotFoundException exception = - assertThrows(ResourceNotFoundException.class, () -> access.loadPrompt("analysis", "en", "missing.md")); - - assertEquals("prompt_resources/prompts/analysis/en/missing.md", exception.resourcePath()); - } - - @Test - void unsupportedSourceTypeFailsFast() { - UnsupportedOperationException exception = assertThrows( - UnsupportedOperationException.class, - () -> PromptResourceAccess.create( - new PromptRuntimeConfig("en-US", "database", promptRootDir.toString()))); - - assertEquals("Unsupported prompt source type: database", exception.getMessage()); - } - - private List warningMessages() { - return appender.list.stream() - .filter(event -> event.getLevel() == Level.WARN) - .map(ILoggingEvent::getFormattedMessage) - .toList(); - } - - private static void assertContains(String message, String... fragments) { - for (String fragment : fragments) { - assertTrue(message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); - } - } - - private static void write(Path file, String content) throws IOException { - Files.createDirectories(file.getParent()); - Files.writeString(file, content); - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +class PromptResourceAccessTest { + + @TempDir + Path promptRootDir; + + private final ListAppender appender = new ListAppender<>(); + + private final Logger logger = (Logger) LoggerFactory.getLogger(PromptResourceAccess.class); + + @BeforeEach + void attachAppender() { + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + logger.detachAppender(appender); + } + + @Test + void classpathModeIgnoresConfiguredLocalRootWithSingleWarning() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", promptRootDir.toString())); + + assertTrue(access.classpath()); + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), "prompt_resource_local_root_ignored", "root=" + promptRootDir, "source=classpath"); + } + + @Test + void localFileModeFailsFastWhenLocalRootIsNull() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", null))); + + assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + } + + @Test + void localFileModeFailsFastWhenLocalRootIsBlank() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", " "))); + + assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + } + + @Test + void localFileModeFailsFastWhenLocalRootDoesNotExist() { + Path missing = promptRootDir.resolve("missing"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", missing.toString()))); + + assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + } + + @Test + void localFileModeLoadsPromptsFromClasspath() throws IOException { + write( + promptRootDir + .resolve("prompts") + .resolve("scenario_recognition") + .resolve("en-US") + .resolve("system.md"), + "Local prompt copy."); + + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + assertFalse(access.classpath()); + String prompt = access.loadPrompt("scenario_recognition", "en-US", "system.md"); + assertTrue(prompt.startsWith("You are a scenario recognition agent.")); + assertFalse(prompt.equals("Local prompt copy.")); + } + + @Test + void localFileModeLoadsBusinessContentFromLocalRoot() throws IOException { + write( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + "Local template for {{service}}."); + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "incident_triage", + "scenario_name": "Incident Triage", + "description": "Classify and route a production incident.", + "example": "Investigate elevated API latency." + } + ] + } + """); + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + """ + { + "required": ["service"], + "properties": { + "service": {"type": "string", "description": "Affected service name"} + } + } + """); + + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + assertFalse(access.classpath()); + assertEquals(promptRootDir, access.localRootDir()); + assertEquals("Local template for {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); + List scenarios = access.loadScenarios("en"); + PromptSlotSchema slotSchema = access.slotSchemaLoader().loadSlotSchema("incident_triage", "en"); + assertEquals("incident_triage", scenarios.get(0).scenarioCode()); + assertEquals("service", slotSchema.slotDefinitions().get(0).name()); + assertEquals(true, slotSchema.slotDefinitions().get(0).required()); + } + + @Test + void localFileModeIgnoresUnsupportedLocalDirectoriesWithSingleWarning() throws IOException { + write( + promptRootDir + .resolve("prompts") + .resolve("scenario_recognition") + .resolve("en-US") + .resolve("system.md"), + "Local prompt copy."); + write( + promptRootDir + .resolve("templates") + .resolve("Negotiation-T") + .resolve("information-negotiation") + .resolve("propose") + .resolve("v1") + .resolve("en-US") + .resolve("template.md"), + "Local negotiation template copy."); + write(promptRootDir.resolve("negotiation-vocabulary").resolve("en-US").resolve("vocabulary.json"), "{}"); + + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), + "prompt_resource_local_directories_ignored", + "prompts", + "templates/Negotiation-T", + "negotiation-vocabulary", + "reason=classpath_fixed"); + } + + @Test + void localFileModePromptLoadUsesClasspathPathForMissingResource() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + ResourceNotFoundException exception = + assertThrows(ResourceNotFoundException.class, () -> access.loadPrompt("analysis", "en", "missing.md")); + + assertEquals("prompt_resources/prompts/analysis/en/missing.md", exception.resourcePath()); + } + + @Test + void unsupportedSourceTypeFailsFast() { + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> PromptResourceAccess.create( + new PromptRuntimeConfig("en-US", "database", promptRootDir.toString()))); + + assertEquals("Unsupported prompt source type: database", exception.getMessage()); + } + + private List warningMessages() { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void assertContains(String message, String... fragments) { + for (String fragment : fragments) { + assertTrue(message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); + } + } + + private static void write(Path file, String content) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, content); + } +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java index 59105b5d..fe550fc1 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java @@ -1,164 +1,163 @@ -package net.openan.a2at.sdk.prompt.validation; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -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 java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import org.junit.jupiter.api.Test; - -class DefaultContentValidatorTest { - - private static final TemplateUri TASK_URI = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); - - private static final PromptTemplateTextLoader STUB_LOADER = (scenarioCode, language) -> "dummy template content"; - - @Test - void validatesSuccessfullyOnFirstCall() { - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); - - FilledParamData result = - assertDoesNotThrow(() -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); - - assertNotNull(result); - assertEquals(Map.of("site", "Site A"), result.data()); - } - - @Test - void rejectsMismatchedExtensionName() { - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", - Map.of("type", "object"), - TemplateUri.of("Notification-T", "network-layer", "service-recovery"))); - - assertEquals("negotiation.invalid_input", exception.getCode()); - } - - @Test - void templateVersionIsUnsupported() { - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); - - TemplateUri unsupportedVersion = - TemplateUri.of("Task-T", List.of("network-layer", "ran-energy-saving"), "v2"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate("task prompt", Map.of("type", "object"), unsupportedVersion)); - - assertEquals("negotiation.invalid_input", exception.getCode()); - } - - @Test - void templateUriIsNull() { - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate("task prompt", Map.of("type", "object"), null)); - - assertEquals("negotiation.invalid_input", exception.getCode()); - } - - @Test - void unsupportedLanguageFailsAtConstructionWithTemplateNotFound() { - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient(), STUB_LOADER)); - - assertEquals("template.not_found", exception.getCode()); - assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); - assertTrue(exception.getCause().getMessage().contains("zh-CN or en-US")); - } - - @Test - void constructionSucceedsWithEmptyLocalRootBecausePromptLoadsFromClasspath() { - PromptTemplateTextLoader emptyLocalRootLoader = (scenarioCode, language) -> { - throw new ResourceNotFoundException("No template in the local root for " + scenarioCode, scenarioCode); - }; - - assertDoesNotThrow( - () -> new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), emptyLocalRootLoader)); - } - - @Test - void retryableFailureAfterResourceLoadMapsLlmInvocationFailed() { - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 1, new FailingClient(), STUB_LOADER); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); - - assertEquals("llm.invocation_failed", exception.getCode()); - } - - @Test - void missingTemplateFailsWithTemplateNotFound() { - PromptTemplateTextLoader failingLoader = (scenarioCode, language) -> { - throw new ResourceNotFoundException("Template does not exist.", scenarioCode); - }; - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), failingLoader); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); - - assertEquals("template.not_found", exception.getCode()); - assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); - } - - private static final class StubClient implements LLMClient { - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse( - """ - { - "semantic_verdict": true, - "errors": [], - "params": {"site": "Site A"} - } - """, - "stub-llm", - Map.of(), - Map.of()); - } - } - - private static final class FailingClient implements LLMClient { - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - throw new LLMRuntimeError("network timeout"); - } - } -} +package net.openan.a2at.sdk.prompt.validation; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import org.junit.jupiter.api.Test; + +class DefaultContentValidatorTest { + + private static final TemplateUri TASK_URI = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); + + private static final PromptTemplateTextLoader STUB_LOADER = (scenarioCode, language) -> "dummy template content"; + + @Test + void validatesSuccessfullyOnFirstCall() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); + + FilledParamData result = + assertDoesNotThrow(() -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + + assertNotNull(result); + assertEquals(Map.of("site", "Site A"), result.data()); + } + + @Test + void rejectsMismatchedExtensionName() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Notification-T", "network-layer", "service-recovery"))); + + assertEquals("negotiation.invalid_input", exception.getCode()); + } + + @Test + void templateVersionIsUnsupported() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); + + TemplateUri unsupportedVersion = TemplateUri.of("Task-T", List.of("network-layer", "ran-energy-saving"), "v2"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate("task prompt", Map.of("type", "object"), unsupportedVersion)); + + assertEquals("negotiation.invalid_input", exception.getCode()); + } + + @Test + void templateUriIsNull() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate("task prompt", Map.of("type", "object"), null)); + + assertEquals("negotiation.invalid_input", exception.getCode()); + } + + @Test + void unsupportedLanguageFailsAtConstructionWithTemplateNotFound() { + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient(), STUB_LOADER)); + + assertEquals("template.not_found", exception.getCode()); + assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); + assertTrue(exception.getCause().getMessage().contains("zh-CN or en-US")); + } + + @Test + void constructionSucceedsWithEmptyLocalRootBecausePromptLoadsFromClasspath() { + PromptTemplateTextLoader emptyLocalRootLoader = (scenarioCode, language) -> { + throw new ResourceNotFoundException("No template in the local root for " + scenarioCode, scenarioCode); + }; + + assertDoesNotThrow( + () -> new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), emptyLocalRootLoader)); + } + + @Test + void retryableFailureAfterResourceLoadMapsLlmInvocationFailed() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 1, new FailingClient(), STUB_LOADER); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + + assertEquals("llm.invocation_failed", exception.getCode()); + } + + @Test + void missingTemplateFailsWithTemplateNotFound() { + PromptTemplateTextLoader failingLoader = (scenarioCode, language) -> { + throw new ResourceNotFoundException("Template does not exist.", scenarioCode); + }; + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), failingLoader); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + + assertEquals("template.not_found", exception.getCode()); + assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); + } + + private static final class StubClient implements LLMClient { + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse( + """ + { + "semantic_verdict": true, + "errors": [], + "params": {"site": "Site A"} + } + """, + "stub-llm", + Map.of(), + Map.of()); + } + } + + private static final class FailingClient implements LLMClient { + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new LLMRuntimeError("network timeout"); + } + } +} diff --git a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java index 1357511a..f7a460d3 100644 --- a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java +++ b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java @@ -1,51 +1,51 @@ -package net.openan.a2at.sdk.resources; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.ConcurrentHashMap; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; -import org.jspecify.annotations.Nullable; - -/** - * Loads prompt resources from the classpath bundle packaged with the SDK. - * - *

        Classpath resources are frozen at assembly time: each resource path is resolved once and cached at the - * JVM level, so every subsequent load returns the cached copy. Missing resources throw on every call and are never - * cached. The loader is thread-safe. - * - * @since 2026-06 - */ -public final class ClasspathPromptResourceLoader { - - private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); - - /** - * Loads one UTF-8 text resource from the packaged prompt resource tree. - * - * @param key resource key to resolve - * @return loaded text payload - */ - public String loadText(PromptResourceKey key) { - String relativePath = key.relativePath(); - CacheKey cacheKey = new CacheKey(relativePath, Thread.currentThread().getContextClassLoader()); - return CACHE.computeIfAbsent(cacheKey, ignored -> read(relativePath)); - } - - private static String read(String relativePath) { - InputStream stream = ClasspathResourceStreams.open(relativePath); - if (stream == null) { - throw new ResourceNotFoundException("Prompt resource file does not exist.", relativePath); - } - - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException error) { - throw new A2ATError("Failed to read prompt resource: " + relativePath, error); - } - } - - private record CacheKey(String relativePath, @Nullable ClassLoader contextClassLoader) {} -} +package net.openan.a2at.sdk.resources; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import org.jspecify.annotations.Nullable; + +/** + * Loads prompt resources from the classpath bundle packaged with the SDK. + * + *

        Classpath resources are frozen at assembly time: each resource path is resolved once and cached at the JVM level, + * so every subsequent load returns the cached copy. Missing resources throw on every call and are never cached. The + * loader is thread-safe. + * + * @since 2026-06 + */ +public final class ClasspathPromptResourceLoader { + + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + /** + * Loads one UTF-8 text resource from the packaged prompt resource tree. + * + * @param key resource key to resolve + * @return loaded text payload + */ + public String loadText(PromptResourceKey key) { + String relativePath = key.relativePath(); + CacheKey cacheKey = new CacheKey(relativePath, Thread.currentThread().getContextClassLoader()); + return CACHE.computeIfAbsent(cacheKey, ignored -> read(relativePath)); + } + + private static String read(String relativePath) { + InputStream stream = ClasspathResourceStreams.open(relativePath); + if (stream == null) { + throw new ResourceNotFoundException("Prompt resource file does not exist.", relativePath); + } + + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException error) { + throw new A2ATError("Failed to read prompt resource: " + relativePath, error); + } + } + + private record CacheKey(String relativePath, @Nullable ClassLoader contextClassLoader) {} +} diff --git a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java index c12896ec..5809ec68 100644 --- a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java +++ b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java @@ -1,106 +1,103 @@ -package net.openan.a2at.sdk.resources; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.TemplateUri; -import org.junit.jupiter.api.Test; - -class ClasspathPromptResourceLoaderTest { - - private final ClasspathPromptResourceLoader loader = new ClasspathPromptResourceLoader(); - - @Test - void loadsTextResourceUsingPromptResourceKey() { - PromptResourceKey key = PromptResourceKey.prompt("slot_extraction", "en-US", "system.md"); - - String text = loader.loadText(key); - - assertEquals("system prompt", text.trim()); - } - - @Test - void rejectsResourceTraversalSegments() { - assertThrows( - IllegalArgumentException.class, - () -> new PromptResourceKey("prompts", List.of("../escape"), "en-US", "system.md")); - } - - @Test - void raisesTypedErrorWhenResourceIsMissing() { - PromptResourceKey key = PromptResourceKey.template( - TemplateUri.of("Task-T", "network-layer", "missing_scenario"), "en-US", "template.md"); - - ResourceNotFoundException error = assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); - - assertEquals( - "prompt_resources/templates/Task-T/network-layer/missing_scenario/v1/en-US/template.md", - error.resourcePath().replace('\\', '/')); - } - - @Test - void loadsPackagedScenarioCatalogForZhCn() { - String text = loader.loadText(PromptResourceKey.scenario("zh-CN", "scenarios.json")); - - assertTrue(text.contains("subscribe-incident")); - assertTrue(text.contains("ran-energy-saving")); - } - - @Test - void loadsPackagedSubscribeIncidentSlotSchemaWithSemanticHint() { - PromptResourceKey key = - PromptResourceKey.slotSchema( - TemplateUri.of("Notification-T", "network-layer", "subscribe-incident"), - "zh-CN", - "slot.json"); - - String text = loader.loadText(key); - - assertTrue(text.contains("\"required\": []")); - assertTrue(text.contains("x-a2at-value-constraint")); - assertTrue(text.contains("critical")); - assertTrue(text.contains("high")); - assertTrue(text.contains("medium")); - assertTrue(text.contains("low")); - } - - @Test - void servesTheCachedValueAfterTheClasspathResourceIsAlteredOnDisk() throws Exception { - PromptResourceKey key = PromptResourceKey.prompt("slot_extraction", "en-US", "system.md"); - String first = loader.loadText(key); - - Path resourceFile = testResourceFile("prompt_resources/prompts/slot_extraction/en-US/system.md"); - String original = Files.readString(resourceFile, StandardCharsets.UTF_8); - Files.writeString(resourceFile, "changed on disk", StandardCharsets.UTF_8); - try { - assertEquals(first, loader.loadText(key)); - } finally { - Files.writeString(resourceFile, original, StandardCharsets.UTF_8); - } - } - - @Test - void doesNotCacheMissingResourcesAndKeepsThrowing() { - PromptResourceKey key = PromptResourceKey.template( - TemplateUri.of("Task-T", "network-layer", "missing_scenario"), "en-US", "template.md"); - - assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); - assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); - } - - private static Path testResourceFile(String classpathPath) throws Exception { - URL resource = ClasspathPromptResourceLoaderTest.class.getClassLoader().getResource(classpathPath); - if (resource == null || !"file".equals(resource.getProtocol())) { - throw new IllegalStateException( - "test resource not reachable as a file URL: " + classpathPath + " -> " + resource); - } - return Path.of(resource.toURI()); - } -} +package net.openan.a2at.sdk.resources; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.junit.jupiter.api.Test; + +class ClasspathPromptResourceLoaderTest { + + private final ClasspathPromptResourceLoader loader = new ClasspathPromptResourceLoader(); + + @Test + void loadsTextResourceUsingPromptResourceKey() { + PromptResourceKey key = PromptResourceKey.prompt("slot_extraction", "en-US", "system.md"); + + String text = loader.loadText(key); + + assertEquals("system prompt", text.trim()); + } + + @Test + void rejectsResourceTraversalSegments() { + assertThrows( + IllegalArgumentException.class, + () -> new PromptResourceKey("prompts", List.of("../escape"), "en-US", "system.md")); + } + + @Test + void raisesTypedErrorWhenResourceIsMissing() { + PromptResourceKey key = PromptResourceKey.template( + TemplateUri.of("Task-T", "network-layer", "missing_scenario"), "en-US", "template.md"); + + ResourceNotFoundException error = assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); + + assertEquals( + "prompt_resources/templates/Task-T/network-layer/missing_scenario/v1/en-US/template.md", + error.resourcePath().replace('\\', '/')); + } + + @Test + void loadsPackagedScenarioCatalogForZhCn() { + String text = loader.loadText(PromptResourceKey.scenario("zh-CN", "scenarios.json")); + + assertTrue(text.contains("subscribe-incident")); + assertTrue(text.contains("ran-energy-saving")); + } + + @Test + void loadsPackagedSubscribeIncidentSlotSchemaWithSemanticHint() { + PromptResourceKey key = PromptResourceKey.slotSchema( + TemplateUri.of("Notification-T", "network-layer", "subscribe-incident"), "zh-CN", "slot.json"); + + String text = loader.loadText(key); + + assertTrue(text.contains("\"required\": []")); + assertTrue(text.contains("x-a2at-value-constraint")); + assertTrue(text.contains("critical")); + assertTrue(text.contains("high")); + assertTrue(text.contains("medium")); + assertTrue(text.contains("low")); + } + + @Test + void servesTheCachedValueAfterTheClasspathResourceIsAlteredOnDisk() throws Exception { + PromptResourceKey key = PromptResourceKey.prompt("slot_extraction", "en-US", "system.md"); + String first = loader.loadText(key); + + Path resourceFile = testResourceFile("prompt_resources/prompts/slot_extraction/en-US/system.md"); + String original = Files.readString(resourceFile, StandardCharsets.UTF_8); + Files.writeString(resourceFile, "changed on disk", StandardCharsets.UTF_8); + try { + assertEquals(first, loader.loadText(key)); + } finally { + Files.writeString(resourceFile, original, StandardCharsets.UTF_8); + } + } + + @Test + void doesNotCacheMissingResourcesAndKeepsThrowing() { + PromptResourceKey key = PromptResourceKey.template( + TemplateUri.of("Task-T", "network-layer", "missing_scenario"), "en-US", "template.md"); + + assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); + assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); + } + + private static Path testResourceFile(String classpathPath) throws Exception { + URL resource = ClasspathPromptResourceLoaderTest.class.getClassLoader().getResource(classpathPath); + if (resource == null || !"file".equals(resource.getProtocol())) { + throw new IllegalStateException( + "test resource not reachable as a file URL: " + classpathPath + " -> " + resource); + } + return Path.of(resource.toURI()); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java index adb30492..321f9f63 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java @@ -1,433 +1,453 @@ -package net.openan.a2at.sample.authz_policy; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import net.openan.a2at.sample.authz_policy.AuthzReasoningCapture.StageCapture; -import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; -import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; - -/** - * Entry point for the Authorization-T demo. - * - *

        Loads scenarios from the resource configured via {@code authz.scenarios} (default: - * {@code sample/authz-policy/scenarios.json}), generates authorization prompts through - * {@link A2ATClient}, validates them through {@link A2ATServer} against the business-level parameter - * schema ({@code sample/authz-policy/param-schema.json}), and writes a JSON report with staged - * client/server expectations, actual behaviour and per-assertion match details. - * - * @since 2026-08 - */ -public final class AuthzSampleMain { - - private static final String DEFAULT_ENV_FILE = "authz.env"; - - private static final String BUNDLED_ENV_FILE = Path.of( - "a2a-t-sample", "src", "main", "resources", "sample", "authz-policy", "authz.env") - .toString(); - - private static final String SCENARIOS_RESOURCE = - System.getProperty("authz.scenarios", "sample/authz-policy/scenarios.json"); - - private static final String PARAM_SCHEMA_RESOURCE = "sample/authz-policy/param-schema.json"; - - private static final String DEFAULT_OUTPUT_DIR = "eval-results/authz-demo"; - - private static final String OUTDIR_PROPERTY = "authz.outdir"; - - private static final DateTimeFormatter REPORT_TIMESTAMP = - DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); - - private static final List REQUIRED_LLM_KEYS = - List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY"); - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private AuthzSampleMain() {} - - public static void main(String[] args) { - Path envPath = resolveEnvPath(args); - if (!hasRequiredLlmKeys(envPath)) { - System.err.println("Required LLM keys not configured in env file: " + envPath); - System.exit(1); - } - - int workers; - try { - workers = AuthzScenarioExecutor.resolveWorkers(); - } catch (IllegalArgumentException e) { - System.err.println( - "Usage: -Dauthz.workers= (integer >= 1, default 8). " + e.getMessage()); - System.exit(1); - return; - } - - boolean reasoning; - try { - reasoning = AuthzReasoningCapture.resolveReasoningFlag(); - } catch (IllegalArgumentException e) { - System.err.println( - "Usage: -Dauthz.reasoning=true|false (default false). " + e.getMessage()); - System.exit(1); - return; - } - - AuthzReasoningCapture capture = reasoning ? new AuthzReasoningCapture() : null; - - Map paramSchema = loadParamSchema(); - - List scenarios = AuthzScenarioLoader.load(SCENARIOS_RESOURCE); - TemplateUri templateUri = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; - - AuthzPromptGenerator generator = buildGenerator(envPath, templateUri, capture); - AuthzPromptValidator validator = buildValidator(envPath, capture); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - long startMillis = System.currentTimeMillis(); - - List outcomes = executor.executeAll( - scenarios, paramSchema, templateUri, workers, - (completedIndex, outcome, elapsedSeconds) -> { - String matchStr = outcome.result().match() ? "PASS" : "FAIL"; - System.out.printf("[%d/%d] %s %s (%s, %.1fs)%n", - completedIndex, scenarios.size(), matchStr, - scenarios.get(completedIndex - 1).label(), - outcome.result().outcome(), elapsedSeconds); - }, - capture); - - double wallTimeSeconds = (System.currentTimeMillis() - startMillis) / 1000.0; - - for (int i = 0; i < scenarios.size(); i++) { - printScenarioReport(scenarios.get(i), outcomes.get(i)); - } - - Path outDir = Path.of(System.getProperty(OUTDIR_PROPERTY, DEFAULT_OUTPUT_DIR)); - Path reportPath = writeReport(scenarios, outcomes, outDir, workers, wallTimeSeconds, - SCENARIOS_RESOURCE, reasoning, capture); - System.out.println("Report written to: " + reportPath.toAbsolutePath().normalize()); - System.out.println(); - - List results = outcomes.stream().map(ScenarioOutcome::result).toList(); - printSummary(scenarios, results); - System.exit(exitCode(results)); - } - - static Path resolveEnvPath(String[] args) { - if (args.length > 0) { - return Path.of(args[0]); - } - Path cwdEnv = Path.of(DEFAULT_ENV_FILE); - if (Files.exists(cwdEnv) && hasRequiredLlmKeys(cwdEnv)) { - return cwdEnv; - } - return Path.of(BUNDLED_ENV_FILE); - } - - static boolean hasRequiredLlmKeys(Path envFile) { - Map entries = new HashMap<>(); - try { - for (String line : Files.readAllLines(envFile)) { - String trimmed = line.trim(); - if (trimmed.isEmpty() || trimmed.startsWith("#")) { - continue; - } - int separator = trimmed.indexOf('='); - if (separator <= 0) { - continue; - } - entries.put( - trimmed.substring(0, separator).trim(), - trimmed.substring(separator + 1).trim()); - } - } catch (IOException exception) { - return false; - } - for (String key : REQUIRED_LLM_KEYS) { - if (entries.getOrDefault(key, "").isBlank()) { - return false; - } - } - return true; - } - - static Map loadParamSchema() { - try (InputStream stream = AuthzSampleMain.class.getClassLoader().getResourceAsStream(PARAM_SCHEMA_RESOURCE)) { - if (stream == null) { - throw new IllegalStateException("Param schema resource not found: " + PARAM_SCHEMA_RESOURCE); - } - return MAPPER.readValue(stream, new TypeReference>() {}); - } catch (IOException e) { - throw new IllegalStateException("Failed to load param schema: " + PARAM_SCHEMA_RESOURCE, e); - } - } - - static AuthzPromptGenerator buildGenerator( - Path envPath, TemplateUri templateUri, AuthzReasoningCapture capture) { - if (capture == null) { - A2ATClient client = new A2ATClient(envPath); - return scenario -> { - if (AuthzScenario.FROM_TEXT.equals(scenario.entry())) { - String text = (String) scenario.input().get("text"); - return client.generateAuthPromptFromText(text, templateUri); - } - @SuppressWarnings("unchecked") - Map data = (Map) scenario.input().get("data"); - @SuppressWarnings("unchecked") - Map schema = (Map) scenario.input().get("schema"); - return client.generateAuthPromptFromDataWithSchema(data, schema, templateUri); - }; - } - A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir( - A2ATConfig.load(envPath), envPath); - LLMClient real = LLMClientFactory.create( - config.llm().provider(), LLMClientConfig.from(config.llm())); - LLMClient decorated = capture.wrap(real); - DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder() - .envPath(envPath).config(config).llmClient(decorated); - ClientPromptGenerationOrchestrator orchestrator = builder.buildPromptGenerationOrchestrator(); - return scenario -> { - if (AuthzScenario.FROM_TEXT.equals(scenario.entry())) { - String text = (String) scenario.input().get("text"); - return orchestrator.generateAuthPromptFromText(text, templateUri); - } - @SuppressWarnings("unchecked") - Map data = (Map) scenario.input().get("data"); - @SuppressWarnings("unchecked") - Map schema = (Map) scenario.input().get("schema"); - return orchestrator.generateAuthPromptFromDataWithSchema(data, schema, templateUri); - }; - } - - static AuthzPromptValidator buildValidator(Path envPath, AuthzReasoningCapture capture) { - if (capture == null) { - A2ATServer server = new A2ATServer(envPath); - return server::validateAuthPromptAndDataFilling; - } - A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir( - A2ATConfig.load(envPath), envPath); - LLMClient real = LLMClientFactory.create( - config.llm().provider(), LLMClientConfig.from(config.llm())); - LLMClient decorated = capture.wrap(real); - DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder() - .envPath(envPath).config(config).llmClient(decorated); - ContentValidator contentValidator = builder.buildAuthContentValidator(); - return contentValidator::validate; - } - - static void printScenarioReport(AuthzScenario scenario, ScenarioOutcome outcome) { - System.out.println("--- Scenario: " + scenario.label() + " ---"); - System.out.println(" Entry: " + scenario.entry()); - System.out.println("[Client]"); - AuthzExpected expected = scenario.expected(); - if (outcome.metadata() == null) { - System.out.println(" 生成结果: " + outcome.result().outcome()); - System.out.println(" 错误明细: " + outcome.result().slotErrors()); - } else { - System.out.println(" 生成结果: generated"); - System.out.println(" Prompt:"); - System.out.println(indent(outcome.metadata().promptText(), " ")); - } - System.out.println("[Server]"); - if (outcome.metadata() == null) { - System.out.println(" (跳过 - 客户端生成失败)"); - } else if (outcome.filled() == null) { - System.out.println(" 校验结果: " + outcome.result().outcome()); - System.out.println(" 校验明细: " + outcome.result().slotErrors()); - } else { - System.out.println(" 校验结果: success"); - System.out.println(" 校验明细: []"); - System.out.println(" 提参结果: " + outcome.filled().data()); - } - System.out.println("[判定] " + verdictLine(outcome.result())); - System.out.println(); - } - - static String verdictLine(ScenarioResult result) { - StringBuilder sb = new StringBuilder("match=" + result.match()); - if (result.clientPromptMatch() != null) { - sb.append(" (client: prompt=").append(result.clientPromptMatch() ? "OK" : "DRIFT"); - if (result.serverOutcomeMatch() != null) { - sb.append(" | server: outcome=").append(result.serverOutcomeMatch() ? "OK" : "FAIL"); - sb.append(" slot_errors=").append(result.serverOutcomeMatch() ? "OK" : "FAIL"); - } - if (result.serverParamsMatch() != null) { - sb.append(" params=").append(result.serverParamsMatch() ? "OK" : "FAIL"); - } - sb.append(")"); - } else { - sb.append(" (client: outcome=").append(result.match() ? "OK" : "FAIL").append(")"); - } - return sb.toString(); - } - - static String indent(String text, String prefix) { - return text.lines().map(line -> prefix + line).reduce((a, b) -> a + "\n" + b).orElse(""); - } - - static void printSummary(List scenarios, List results) { - long matchCount = results.stream().filter(ScenarioResult::match).count(); - long mismatchCount = results.size() - matchCount; - System.out.println("========== Summary =========="); - System.out.println(" Total: " + results.size() + ", Match: " + matchCount + ", Mismatch: " + mismatchCount); - System.out.println(" Exit Code: " + exitCode(results)); - System.out.println(); - } - - static int exitCode(List results) { - boolean allMatch = results.stream().allMatch(ScenarioResult::match); - return allMatch ? 0 : 1; - } - - static Path writeReport( - List scenarios, - List outcomes, - Path reportDir, - int workers, - double wallTimeSeconds, - String scenariosResource, - boolean reasoning, - AuthzReasoningCapture capture) { - Map report = new LinkedHashMap<>(); - report.put("meta", Map.of( - "workers", workers, - "wallTimeSeconds", wallTimeSeconds, - "scenariosResource", scenariosResource, - "reasoning", reasoning)); - long matchCount = outcomes.stream().filter(o -> o.result().match()).count(); - long mismatchCount = outcomes.size() - matchCount; - report.put("summary", Map.of( - "total", scenarios.size(), - "match", matchCount, - "mismatch", mismatchCount)); - List> scenarioEntries = new ArrayList<>(); - for (int i = 0; i < scenarios.size(); i++) { - AuthzScenario scenario = scenarios.get(i); - ScenarioOutcome outcome = outcomes.get(i); - Map entry = new LinkedHashMap<>(); - entry.put("label", scenario.label()); - entry.put("entry", scenario.entry()); - entry.put("expected_client", formatClientExpectation(scenario)); - entry.put("expected_server", formatServerExpectation(scenario)); - entry.put("actual_outcome", outcome.result().outcome()); - entry.put("match", outcome.result().match()); - entry.put("actual_slot_errors", formatActualSlotErrors(outcome.result().slotErrors())); - entry.put("prompt_text", outcome.metadata() != null ? outcome.metadata().promptText() : null); - entry.put("actual_params", outcome.filled() != null ? outcome.filled().data() : null); - Map assertions = new LinkedHashMap<>(); - assertions.put("client_prompt", outcome.result().clientPromptMatch()); - assertions.put("server_outcome", outcome.result().serverOutcomeMatch()); - assertions.put("server_params", outcome.result().serverParamsMatch()); - entry.put("assertions", assertions); - entry.put("error", outcome.result().error() != null - ? Map.of( - "code", outcome.result().error().getCode(), - "message", outcome.result().error().getMessage()) - : null); - entry.put("warnings", outcome.result().warnings()); - if (reasoning && capture != null) { - Optional captured = capture.capture(scenario.label()); - if (captured.isPresent()) { - StageCapture sc = captured.get(); - if (sc.clientReasoning() != null) { - entry.put("client_reasoning", sc.clientReasoning()); - } - if (sc.serverReasoning() != null) { - entry.put("server_reasoning", sc.serverReasoning()); - } - } - } - scenarioEntries.add(entry); - } - report.put("scenarios", scenarioEntries); - try { - Files.createDirectories(reportDir); - String timestamp = LocalDateTime.now().format(REPORT_TIMESTAMP); - Path reportFile = reportDir.resolve("authz-report-" + timestamp + ".json"); - MAPPER.writerWithDefaultPrettyPrinter().writeValue(reportFile.toFile(), report); - return reportFile; - } catch (IOException e) { - throw new IllegalStateException("Failed to write report to: " + reportDir, e); - } - } - - private static Map formatClientExpectation(AuthzScenario scenario) { - Map m = new LinkedHashMap<>(); - var client = scenario.expected().client(); - m.put("outcome", client.outcome()); - m.put("prompt_text", client.promptText()); - m.put("slot_errors", formatExpectedSlotErrors(client.slotErrors())); - return m; - } - - private static Map formatServerExpectation(AuthzScenario scenario) { - var server = scenario.expected().server(); - if (server == null) { - return null; - } - Map m = new LinkedHashMap<>(); - m.put("outcome", server.outcome()); - m.put("slot_errors", formatExpectedSlotErrors(server.slotErrors())); - m.put("params", server.params()); - return m; - } - - private static List> formatExpectedSlotErrors(List slotErrors) { - if (slotErrors == null || slotErrors.isEmpty()) { - return List.of(); - } - return slotErrors.stream() - .map(e -> { - Map m = new LinkedHashMap<>(); - m.put("slot_name", e.slotName()); - m.put("code", e.code()); - return m; - }) - .toList(); - } - - private static List> formatActualSlotErrors(List slotErrors) { - if (slotErrors == null || slotErrors.isEmpty()) { - return List.of(); - } - return slotErrors.stream() - .map(e -> { - Map m = new LinkedHashMap<>(); - m.put("slot_name", e.slotName()); - m.put("code", e.code()); - m.put("message", e.message()); - m.put("facts", e.facts()); - return m; - }) - .toList(); - } -} +package net.openan.a2at.sample.authz_policy; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.openan.a2at.sample.authz_policy.AuthzReasoningCapture.StageCapture; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; + +/** + * Entry point for the Authorization-T demo. + * + *

        Loads scenarios from the resource configured via {@code authz.scenarios} (default: + * {@code sample/authz-policy/scenarios.json}), generates authorization prompts through {@link A2ATClient}, validates + * them through {@link A2ATServer} against the business-level parameter schema + * ({@code sample/authz-policy/param-schema.json}), and writes a JSON report with staged client/server expectations, + * actual behaviour and per-assertion match details. + * + * @since 2026-08 + */ +public final class AuthzSampleMain { + + private static final String DEFAULT_ENV_FILE = "authz.env"; + + private static final String BUNDLED_ENV_FILE = Path.of( + "a2a-t-sample", "src", "main", "resources", "sample", "authz-policy", "authz.env") + .toString(); + + private static final String SCENARIOS_RESOURCE = + System.getProperty("authz.scenarios", "sample/authz-policy/scenarios.json"); + + private static final String PARAM_SCHEMA_RESOURCE = "sample/authz-policy/param-schema.json"; + + private static final String DEFAULT_OUTPUT_DIR = "eval-results/authz-demo"; + + private static final String OUTDIR_PROPERTY = "authz.outdir"; + + private static final DateTimeFormatter REPORT_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + private static final List REQUIRED_LLM_KEYS = + List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY"); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private AuthzSampleMain() {} + + public static void main(String[] args) { + Path envPath = resolveEnvPath(args); + if (!hasRequiredLlmKeys(envPath)) { + System.err.println("Required LLM keys not configured in env file: " + envPath); + System.exit(1); + } + + int workers; + try { + workers = AuthzScenarioExecutor.resolveWorkers(); + } catch (IllegalArgumentException e) { + System.err.println("Usage: -Dauthz.workers= (integer >= 1, default 8). " + e.getMessage()); + System.exit(1); + return; + } + + boolean reasoning; + try { + reasoning = AuthzReasoningCapture.resolveReasoningFlag(); + } catch (IllegalArgumentException e) { + System.err.println("Usage: -Dauthz.reasoning=true|false (default false). " + e.getMessage()); + System.exit(1); + return; + } + + AuthzReasoningCapture capture = reasoning ? new AuthzReasoningCapture() : null; + + Map paramSchema = loadParamSchema(); + + List scenarios = AuthzScenarioLoader.load(SCENARIOS_RESOURCE); + TemplateUri templateUri = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; + + AuthzPromptGenerator generator = buildGenerator(envPath, templateUri, capture); + AuthzPromptValidator validator = buildValidator(envPath, capture); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + long startMillis = System.currentTimeMillis(); + + List outcomes = executor.executeAll( + scenarios, + paramSchema, + templateUri, + workers, + (completedIndex, outcome, elapsedSeconds) -> { + String matchStr = outcome.result().match() ? "PASS" : "FAIL"; + System.out.printf( + "[%d/%d] %s %s (%s, %.1fs)%n", + completedIndex, + scenarios.size(), + matchStr, + scenarios.get(completedIndex - 1).label(), + outcome.result().outcome(), + elapsedSeconds); + }, + capture); + + double wallTimeSeconds = (System.currentTimeMillis() - startMillis) / 1000.0; + + for (int i = 0; i < scenarios.size(); i++) { + printScenarioReport(scenarios.get(i), outcomes.get(i)); + } + + Path outDir = Path.of(System.getProperty(OUTDIR_PROPERTY, DEFAULT_OUTPUT_DIR)); + Path reportPath = writeReport( + scenarios, outcomes, outDir, workers, wallTimeSeconds, SCENARIOS_RESOURCE, reasoning, capture); + System.out.println("Report written to: " + reportPath.toAbsolutePath().normalize()); + System.out.println(); + + List results = + outcomes.stream().map(ScenarioOutcome::result).toList(); + printSummary(scenarios, results); + System.exit(exitCode(results)); + } + + static Path resolveEnvPath(String[] args) { + if (args.length > 0) { + return Path.of(args[0]); + } + Path cwdEnv = Path.of(DEFAULT_ENV_FILE); + if (Files.exists(cwdEnv) && hasRequiredLlmKeys(cwdEnv)) { + return cwdEnv; + } + return Path.of(BUNDLED_ENV_FILE); + } + + static boolean hasRequiredLlmKeys(Path envFile) { + Map entries = new HashMap<>(); + try { + for (String line : Files.readAllLines(envFile)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int separator = trimmed.indexOf('='); + if (separator <= 0) { + continue; + } + entries.put( + trimmed.substring(0, separator).trim(), + trimmed.substring(separator + 1).trim()); + } + } catch (IOException exception) { + return false; + } + for (String key : REQUIRED_LLM_KEYS) { + if (entries.getOrDefault(key, "").isBlank()) { + return false; + } + } + return true; + } + + static Map loadParamSchema() { + try (InputStream stream = AuthzSampleMain.class.getClassLoader().getResourceAsStream(PARAM_SCHEMA_RESOURCE)) { + if (stream == null) { + throw new IllegalStateException("Param schema resource not found: " + PARAM_SCHEMA_RESOURCE); + } + return MAPPER.readValue(stream, new TypeReference>() {}); + } catch (IOException e) { + throw new IllegalStateException("Failed to load param schema: " + PARAM_SCHEMA_RESOURCE, e); + } + } + + static AuthzPromptGenerator buildGenerator(Path envPath, TemplateUri templateUri, AuthzReasoningCapture capture) { + if (capture == null) { + A2ATClient client = new A2ATClient(envPath); + return scenario -> { + if (AuthzScenario.FROM_TEXT.equals(scenario.entry())) { + String text = (String) scenario.input().get("text"); + return client.generateAuthPromptFromText(text, templateUri); + } + @SuppressWarnings("unchecked") + Map data = + (Map) scenario.input().get("data"); + @SuppressWarnings("unchecked") + Map schema = + (Map) scenario.input().get("schema"); + return client.generateAuthPromptFromDataWithSchema(data, schema, templateUri); + }; + } + A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); + LLMClient real = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); + LLMClient decorated = capture.wrap(real); + DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder() + .envPath(envPath) + .config(config) + .llmClient(decorated); + ClientPromptGenerationOrchestrator orchestrator = builder.buildPromptGenerationOrchestrator(); + return scenario -> { + if (AuthzScenario.FROM_TEXT.equals(scenario.entry())) { + String text = (String) scenario.input().get("text"); + return orchestrator.generateAuthPromptFromText(text, templateUri); + } + @SuppressWarnings("unchecked") + Map data = (Map) scenario.input().get("data"); + @SuppressWarnings("unchecked") + Map schema = (Map) scenario.input().get("schema"); + return orchestrator.generateAuthPromptFromDataWithSchema(data, schema, templateUri); + }; + } + + static AuthzPromptValidator buildValidator(Path envPath, AuthzReasoningCapture capture) { + if (capture == null) { + A2ATServer server = new A2ATServer(envPath); + return server::validateAuthPromptAndDataFilling; + } + A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); + LLMClient real = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); + LLMClient decorated = capture.wrap(real); + DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder() + .envPath(envPath) + .config(config) + .llmClient(decorated); + ContentValidator contentValidator = builder.buildAuthContentValidator(); + return contentValidator::validate; + } + + static void printScenarioReport(AuthzScenario scenario, ScenarioOutcome outcome) { + System.out.println("--- Scenario: " + scenario.label() + " ---"); + System.out.println(" Entry: " + scenario.entry()); + System.out.println("[Client]"); + AuthzExpected expected = scenario.expected(); + if (outcome.metadata() == null) { + System.out.println(" 生成结果: " + outcome.result().outcome()); + System.out.println(" 错误明细: " + outcome.result().slotErrors()); + } else { + System.out.println(" 生成结果: generated"); + System.out.println(" Prompt:"); + System.out.println(indent(outcome.metadata().promptText(), " ")); + } + System.out.println("[Server]"); + if (outcome.metadata() == null) { + System.out.println(" (跳过 - 客户端生成失败)"); + } else if (outcome.filled() == null) { + System.out.println(" 校验结果: " + outcome.result().outcome()); + System.out.println(" 校验明细: " + outcome.result().slotErrors()); + } else { + System.out.println(" 校验结果: success"); + System.out.println(" 校验明细: []"); + System.out.println(" 提参结果: " + outcome.filled().data()); + } + System.out.println("[判定] " + verdictLine(outcome.result())); + System.out.println(); + } + + static String verdictLine(ScenarioResult result) { + StringBuilder sb = new StringBuilder("match=" + result.match()); + if (result.clientPromptMatch() != null) { + sb.append(" (client: prompt=").append(result.clientPromptMatch() ? "OK" : "DRIFT"); + if (result.serverOutcomeMatch() != null) { + sb.append(" | server: outcome=").append(result.serverOutcomeMatch() ? "OK" : "FAIL"); + sb.append(" slot_errors=").append(result.serverOutcomeMatch() ? "OK" : "FAIL"); + } + if (result.serverParamsMatch() != null) { + sb.append(" params=").append(result.serverParamsMatch() ? "OK" : "FAIL"); + } + sb.append(")"); + } else { + sb.append(" (client: outcome=") + .append(result.match() ? "OK" : "FAIL") + .append(")"); + } + return sb.toString(); + } + + static String indent(String text, String prefix) { + return text.lines() + .map(line -> prefix + line) + .reduce((a, b) -> a + "\n" + b) + .orElse(""); + } + + static void printSummary(List scenarios, List results) { + long matchCount = results.stream().filter(ScenarioResult::match).count(); + long mismatchCount = results.size() - matchCount; + System.out.println("========== Summary =========="); + System.out.println(" Total: " + results.size() + ", Match: " + matchCount + ", Mismatch: " + mismatchCount); + System.out.println(" Exit Code: " + exitCode(results)); + System.out.println(); + } + + static int exitCode(List results) { + boolean allMatch = results.stream().allMatch(ScenarioResult::match); + return allMatch ? 0 : 1; + } + + static Path writeReport( + List scenarios, + List outcomes, + Path reportDir, + int workers, + double wallTimeSeconds, + String scenariosResource, + boolean reasoning, + AuthzReasoningCapture capture) { + Map report = new LinkedHashMap<>(); + report.put( + "meta", + Map.of( + "workers", workers, + "wallTimeSeconds", wallTimeSeconds, + "scenariosResource", scenariosResource, + "reasoning", reasoning)); + long matchCount = outcomes.stream().filter(o -> o.result().match()).count(); + long mismatchCount = outcomes.size() - matchCount; + report.put( + "summary", + Map.of( + "total", scenarios.size(), + "match", matchCount, + "mismatch", mismatchCount)); + List> scenarioEntries = new ArrayList<>(); + for (int i = 0; i < scenarios.size(); i++) { + AuthzScenario scenario = scenarios.get(i); + ScenarioOutcome outcome = outcomes.get(i); + Map entry = new LinkedHashMap<>(); + entry.put("label", scenario.label()); + entry.put("entry", scenario.entry()); + entry.put("expected_client", formatClientExpectation(scenario)); + entry.put("expected_server", formatServerExpectation(scenario)); + entry.put("actual_outcome", outcome.result().outcome()); + entry.put("match", outcome.result().match()); + entry.put( + "actual_slot_errors", + formatActualSlotErrors(outcome.result().slotErrors())); + entry.put( + "prompt_text", + outcome.metadata() != null ? outcome.metadata().promptText() : null); + entry.put( + "actual_params", outcome.filled() != null ? outcome.filled().data() : null); + Map assertions = new LinkedHashMap<>(); + assertions.put("client_prompt", outcome.result().clientPromptMatch()); + assertions.put("server_outcome", outcome.result().serverOutcomeMatch()); + assertions.put("server_params", outcome.result().serverParamsMatch()); + entry.put("assertions", assertions); + entry.put( + "error", + outcome.result().error() != null + ? Map.of( + "code", outcome.result().error().getCode(), + "message", outcome.result().error().getMessage()) + : null); + entry.put("warnings", outcome.result().warnings()); + if (reasoning && capture != null) { + Optional captured = capture.capture(scenario.label()); + if (captured.isPresent()) { + StageCapture sc = captured.get(); + if (sc.clientReasoning() != null) { + entry.put("client_reasoning", sc.clientReasoning()); + } + if (sc.serverReasoning() != null) { + entry.put("server_reasoning", sc.serverReasoning()); + } + } + } + scenarioEntries.add(entry); + } + report.put("scenarios", scenarioEntries); + try { + Files.createDirectories(reportDir); + String timestamp = LocalDateTime.now().format(REPORT_TIMESTAMP); + Path reportFile = reportDir.resolve("authz-report-" + timestamp + ".json"); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(reportFile.toFile(), report); + return reportFile; + } catch (IOException e) { + throw new IllegalStateException("Failed to write report to: " + reportDir, e); + } + } + + private static Map formatClientExpectation(AuthzScenario scenario) { + Map m = new LinkedHashMap<>(); + var client = scenario.expected().client(); + m.put("outcome", client.outcome()); + m.put("prompt_text", client.promptText()); + m.put("slot_errors", formatExpectedSlotErrors(client.slotErrors())); + return m; + } + + private static Map formatServerExpectation(AuthzScenario scenario) { + var server = scenario.expected().server(); + if (server == null) { + return null; + } + Map m = new LinkedHashMap<>(); + m.put("outcome", server.outcome()); + m.put("slot_errors", formatExpectedSlotErrors(server.slotErrors())); + m.put("params", server.params()); + return m; + } + + private static List> formatExpectedSlotErrors(List slotErrors) { + if (slotErrors == null || slotErrors.isEmpty()) { + return List.of(); + } + return slotErrors.stream() + .map(e -> { + Map m = new LinkedHashMap<>(); + m.put("slot_name", e.slotName()); + m.put("code", e.code()); + return m; + }) + .toList(); + } + + private static List> formatActualSlotErrors(List slotErrors) { + if (slotErrors == null || slotErrors.isEmpty()) { + return List.of(); + } + return slotErrors.stream() + .map(e -> { + Map m = new LinkedHashMap<>(); + m.put("slot_name", e.slotName()); + m.put("code", e.code()); + m.put("message", e.message()); + m.put("facts", e.facts()); + return m; + }) + .toList(); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java index 2a7dec65..c602a308 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java @@ -1,218 +1,213 @@ -package net.openan.a2at.sample.authz_policy; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; - -/** - * Concurrent scenario executor that runs multiple {@link AuthzScenario} instances in parallel - * using a fixed thread pool, while preserving input order in the returned result list. - * - *

        Each task runs the complete client→server flow for a single scenario. Internal failures - * are caught per-task and converted to {@code infra.internal_error} outcomes, isolating the - * remaining scenarios from a single failure. - * - *

        An optional {@link ProgressListener} receives per-completion callbacks (synchronized, - * index 1-based) and can be {@code null} when no progress reporting is needed. An optional - * {@link AuthzReasoningCapture} records LLM reasoning for each scenario when enabled. - * - * @since 2026-08 - */ -public final class AuthzScenarioExecutor { - - private static final int DEFAULT_WORKERS = 8; - - private final AuthzScenarioRunner runner; - - /** - * @param runner scenario runner (stateless, safe for concurrent reuse) - */ - public AuthzScenarioExecutor(AuthzScenarioRunner runner) { - this.runner = runner; - } - - /** - * Progress listener invoked after each scenario completes. - * - *

        Calls are serialized with a lock so the listener implementation does not need to - * be thread-safe. The callback runs on the worker thread and must return quickly. - * - * @param completedIndex 1-based completion order (not input index) - * @param outcome the scenario outcome - * @param elapsedSeconds wall-clock time for this scenario in seconds, non-negative - * @since 2026-08 - */ - @FunctionalInterface - public interface ProgressListener { - void onCompleted(int completedIndex, ScenarioOutcome outcome, double elapsedSeconds); - } - - /** - * Executes all scenarios concurrently and returns outcomes in the same order as the input list. - * - * @param scenarios scenarios to execute - * @param paramSchema business-level parameter schema for validation - * @param templateUri template URI for prompt generation - * @param workers maximum number of concurrent worker threads - * @param onCompleted optional progress callback; may be {@code null} - * @return outcomes in input order - * @throws IllegalArgumentException if {@code workers < 1} - * @throws IllegalStateException if the calling thread is interrupted while waiting - * @throws java.util.concurrent.RejectedExecutionException if a task cannot be accepted - * @since 2026-08 - */ - public List executeAll( - List scenarios, - Map paramSchema, - TemplateUri templateUri, - int workers, - ProgressListener onCompleted) { - return executeAll(scenarios, paramSchema, templateUri, workers, onCompleted, null); - } - - /** - * Executes all scenarios concurrently with optional reasoning capture. - * - * @param scenarios scenarios to execute - * @param paramSchema business-level parameter schema for validation - * @param templateUri template URI for prompt generation - * @param workers maximum number of concurrent worker threads - * @param onCompleted optional progress callback; may be {@code null} - * @param capture optional reasoning capture; may be {@code null} - * @return outcomes in input order - * @throws IllegalArgumentException if {@code workers < 1} - * @throws IllegalStateException if the calling thread is interrupted while waiting - * @throws java.util.concurrent.RejectedExecutionException if a task cannot be accepted - * @since 2026-08 - */ - public List executeAll( - List scenarios, - Map paramSchema, - TemplateUri templateUri, - int workers, - ProgressListener onCompleted, - AuthzReasoningCapture capture) { - int size = scenarios.size(); - if (size == 0) { - return List.of(); - } - int poolSize = Math.min(workers, size); - AtomicInteger nameCounter = new AtomicInteger(0); - ExecutorService pool = Executors.newFixedThreadPool(poolSize, r -> { - Thread t = new Thread(r, "authz-worker-" + nameCounter.incrementAndGet()); - t.setDaemon(false); - return t; - }); - AtomicInteger completedCounter = new AtomicInteger(0); - Object callbackLock = new Object(); - List> futures = new ArrayList<>(size); - for (int i = 0; i < size; i++) { - final int index = i; - futures.add(pool.submit(() -> { - String label = scenarios.get(index).label(); - if (capture != null) { - capture.beginScenario(label); - } - long startNanos = System.nanoTime(); - ScenarioOutcome outcome; - try { - outcome = runner.run(scenarios.get(index), paramSchema, templateUri); - } catch (Throwable t) { - outcome = buildInternalErrorOutcome(scenarios.get(index), t); - } finally { - if (capture != null) { - capture.endScenario(); - } - } - double elapsed = (System.nanoTime() - startNanos) / 1e9; - if (onCompleted != null) { - synchronized (callbackLock) { - onCompleted.onCompleted(completedCounter.incrementAndGet(), outcome, elapsed); - } - } - return outcome; - })); - } - - List outcomes = new ArrayList<>(size); - try { - for (int i = 0; i < size; i++) { - outcomes.add(futures.get(i).get()); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - pool.shutdownNow(); - throw new IllegalStateException("Scenario execution interrupted", e); - } catch (ExecutionException e) { - pool.shutdownNow(); - throw new IllegalStateException("Unexpected execution exception", e); - } finally { - pool.shutdown(); - try { - if (!pool.awaitTermination(10, TimeUnit.SECONDS)) { - pool.shutdownNow(); - } - } catch (InterruptedException e) { - pool.shutdownNow(); - Thread.currentThread().interrupt(); - } - } - return outcomes; - } - - /** - * Resolves the worker count from the {@code authz.workers} system property. - * - * @return worker count, defaulting to {@value DEFAULT_WORKERS} - * @throws IllegalArgumentException if the property is set to a non-integer or value < 1 - * @since 2026-08 - */ - public static int resolveWorkers() { - String raw = System.getProperty("authz.workers"); - if (raw == null || raw.isBlank()) { - return DEFAULT_WORKERS; - } - int workers; - try { - workers = Integer.parseInt(raw.trim()); - } catch (NumberFormatException e) { - throw new IllegalArgumentException( - "Invalid authz.workers value: '" + raw + "'. Expected an integer >= 1 (default " - + DEFAULT_WORKERS + ")."); - } - if (workers < 1) { - throw new IllegalArgumentException( - "Invalid authz.workers value: " + workers + ". Expected an integer >= 1 (default " - + DEFAULT_WORKERS + ")."); - } - return workers; - } - - private ScenarioOutcome buildInternalErrorOutcome(AuthzScenario scenario, Throwable t) { - return new ScenarioOutcome( - new ScenarioResult( - ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), - false, - new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), t.getMessage(), t), - List.of(new SlotValidationError( - "_llm", ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), t.getMessage())), - null, - null, - null, - List.of()), - null, - null); - } -} \ No newline at end of file +package net.openan.a2at.sample.authz_policy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; + +/** + * Concurrent scenario executor that runs multiple {@link AuthzScenario} instances in parallel using a fixed thread + * pool, while preserving input order in the returned result list. + * + *

        Each task runs the complete client→server flow for a single scenario. Internal failures are caught per-task and + * converted to {@code infra.internal_error} outcomes, isolating the remaining scenarios from a single failure. + * + *

        An optional {@link ProgressListener} receives per-completion callbacks (synchronized, index 1-based) and can be + * {@code null} when no progress reporting is needed. An optional {@link AuthzReasoningCapture} records LLM reasoning + * for each scenario when enabled. + * + * @since 2026-08 + */ +public final class AuthzScenarioExecutor { + + private static final int DEFAULT_WORKERS = 8; + + private final AuthzScenarioRunner runner; + + /** @param runner scenario runner (stateless, safe for concurrent reuse) */ + public AuthzScenarioExecutor(AuthzScenarioRunner runner) { + this.runner = runner; + } + + /** + * Progress listener invoked after each scenario completes. + * + *

        Calls are serialized with a lock so the listener implementation does not need to be thread-safe. The callback + * runs on the worker thread and must return quickly. + * + * @param completedIndex 1-based completion order (not input index) + * @param outcome the scenario outcome + * @param elapsedSeconds wall-clock time for this scenario in seconds, non-negative + * @since 2026-08 + */ + @FunctionalInterface + public interface ProgressListener { + void onCompleted(int completedIndex, ScenarioOutcome outcome, double elapsedSeconds); + } + + /** + * Executes all scenarios concurrently and returns outcomes in the same order as the input list. + * + * @param scenarios scenarios to execute + * @param paramSchema business-level parameter schema for validation + * @param templateUri template URI for prompt generation + * @param workers maximum number of concurrent worker threads + * @param onCompleted optional progress callback; may be {@code null} + * @return outcomes in input order + * @throws IllegalArgumentException if {@code workers < 1} + * @throws IllegalStateException if the calling thread is interrupted while waiting + * @throws java.util.concurrent.RejectedExecutionException if a task cannot be accepted + * @since 2026-08 + */ + public List executeAll( + List scenarios, + Map paramSchema, + TemplateUri templateUri, + int workers, + ProgressListener onCompleted) { + return executeAll(scenarios, paramSchema, templateUri, workers, onCompleted, null); + } + + /** + * Executes all scenarios concurrently with optional reasoning capture. + * + * @param scenarios scenarios to execute + * @param paramSchema business-level parameter schema for validation + * @param templateUri template URI for prompt generation + * @param workers maximum number of concurrent worker threads + * @param onCompleted optional progress callback; may be {@code null} + * @param capture optional reasoning capture; may be {@code null} + * @return outcomes in input order + * @throws IllegalArgumentException if {@code workers < 1} + * @throws IllegalStateException if the calling thread is interrupted while waiting + * @throws java.util.concurrent.RejectedExecutionException if a task cannot be accepted + * @since 2026-08 + */ + public List executeAll( + List scenarios, + Map paramSchema, + TemplateUri templateUri, + int workers, + ProgressListener onCompleted, + AuthzReasoningCapture capture) { + int size = scenarios.size(); + if (size == 0) { + return List.of(); + } + int poolSize = Math.min(workers, size); + AtomicInteger nameCounter = new AtomicInteger(0); + ExecutorService pool = Executors.newFixedThreadPool(poolSize, r -> { + Thread t = new Thread(r, "authz-worker-" + nameCounter.incrementAndGet()); + t.setDaemon(false); + return t; + }); + AtomicInteger completedCounter = new AtomicInteger(0); + Object callbackLock = new Object(); + List> futures = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + final int index = i; + futures.add(pool.submit(() -> { + String label = scenarios.get(index).label(); + if (capture != null) { + capture.beginScenario(label); + } + long startNanos = System.nanoTime(); + ScenarioOutcome outcome; + try { + outcome = runner.run(scenarios.get(index), paramSchema, templateUri); + } catch (Throwable t) { + outcome = buildInternalErrorOutcome(scenarios.get(index), t); + } finally { + if (capture != null) { + capture.endScenario(); + } + } + double elapsed = (System.nanoTime() - startNanos) / 1e9; + if (onCompleted != null) { + synchronized (callbackLock) { + onCompleted.onCompleted(completedCounter.incrementAndGet(), outcome, elapsed); + } + } + return outcome; + })); + } + + List outcomes = new ArrayList<>(size); + try { + for (int i = 0; i < size; i++) { + outcomes.add(futures.get(i).get()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + pool.shutdownNow(); + throw new IllegalStateException("Scenario execution interrupted", e); + } catch (ExecutionException e) { + pool.shutdownNow(); + throw new IllegalStateException("Unexpected execution exception", e); + } finally { + pool.shutdown(); + try { + if (!pool.awaitTermination(10, TimeUnit.SECONDS)) { + pool.shutdownNow(); + } + } catch (InterruptedException e) { + pool.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + return outcomes; + } + + /** + * Resolves the worker count from the {@code authz.workers} system property. + * + * @return worker count, defaulting to {@value DEFAULT_WORKERS} + * @throws IllegalArgumentException if the property is set to a non-integer or value < 1 + * @since 2026-08 + */ + public static int resolveWorkers() { + String raw = System.getProperty("authz.workers"); + if (raw == null || raw.isBlank()) { + return DEFAULT_WORKERS; + } + int workers; + try { + workers = Integer.parseInt(raw.trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid authz.workers value: '" + raw + + "'. Expected an integer >= 1 (default " + DEFAULT_WORKERS + ")."); + } + if (workers < 1) { + throw new IllegalArgumentException("Invalid authz.workers value: " + workers + + ". Expected an integer >= 1 (default " + DEFAULT_WORKERS + ")."); + } + return workers; + } + + private ScenarioOutcome buildInternalErrorOutcome(AuthzScenario scenario, Throwable t) { + return new ScenarioOutcome( + new ScenarioResult( + ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), + false, + new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), t.getMessage(), t), + List.of(new SlotValidationError( + "_llm", ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), t.getMessage())), + null, + null, + null, + List.of()), + null, + null); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java index 525d4989..e3c738a9 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java @@ -1,291 +1,280 @@ -package net.openan.a2at.sample.authz_policy; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidationException; - -/** - * Scenario execution engine that runs a single scenario through prompt generation and validation, - * comparing the staged client/server expectations against the actual behaviour. - * - *

        Assertion semantics mirror the Python probe: client {@code promptText} is compared with - * trailing-whitespace-insensitive equality only for recording ({@code clientPromptMatch}); it does - * not participate in the {@code match} verdict. Server {@code slot_errors} are subset-matched - * (each expected error must appear with the same slot name and code); server {@code params} - * are subset-matched recursively (maps by key, ordered lists element-wise, trimmed strings) - * and only asserted for expected server outcome {@code success}. Structural warnings (such as an - * empty policy-list section for a mutation operation) are recorded in {@code warnings} but never - * affect the {@code match} verdict. - * - * @since 2026-08 - */ -public final class AuthzScenarioRunner { - - private final AuthzPromptGenerator generator; - - private final AuthzPromptValidator validator; - - public AuthzScenarioRunner(AuthzPromptGenerator generator, AuthzPromptValidator validator) { - this.generator = generator; - this.validator = validator; - } - - public ScenarioOutcome run(AuthzScenario scenario, Map paramSchema, TemplateUri templateUri) { - MetadataContent metadata; - try { - metadata = generator.generate(scenario); - } catch (A2ATError e) { - return buildClientErrorOutcome(scenario, e, null); - } catch (RuntimeException e) { - A2ATError wrapped = new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), e.getMessage(), e); - return buildClientErrorOutcome(scenario, wrapped, null); - } - - ClientExpected expectedClient = scenario.expected().client(); - boolean clientPromptMatch = expectedClient.outcome() == null - && promptTextMatch(expectedClient.promptText(), metadata.promptText()); - List warnings = detectWarnings(metadata.promptText()); - if (expectedClient.outcome() != null) { - // generation unexpectedly succeeded — no server-stage expectation applies - return new ScenarioOutcome( - new ScenarioResult( - AuthzScenario.EXPECTED_SUCCESS, - false, - null, - List.of(), - clientPromptMatch, - null, - null, - warnings), - metadata, - null); - } - - FilledParamData filled; - try { - Map effectiveSchema = - scenario.validateSchema() != null ? scenario.validateSchema() : paramSchema; - filled = validator.validate(metadata.promptText(), effectiveSchema, templateUri); - } catch (A2ATError e) { - return buildServerErrorOutcome(scenario, e, metadata, clientPromptMatch, warnings); - } catch (RuntimeException e) { - A2ATError wrapped = new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), e.getMessage(), e); - return buildServerErrorOutcome(scenario, wrapped, metadata, clientPromptMatch, warnings); - } - - ServerExpected expectedServer = scenario.expected().server(); - List noErrors = List.of(); - boolean serverOutcomeMatch = - expectedServer != null && AuthzScenario.EXPECTED_SUCCESS.equals(expectedServer.outcome()); - boolean serverParamsMatch = - serverOutcomeMatch - && (expectedServer.params() == null || paramsMatch(expectedServer.params(), filled.data())); - boolean match = serverOutcomeMatch && serverParamsMatch; - return new ScenarioOutcome( - new ScenarioResult( - AuthzScenario.EXPECTED_SUCCESS, - match, - null, - noErrors, - clientPromptMatch, - serverOutcomeMatch, - serverParamsMatch, - warnings), - metadata, - filled); - } - - private ScenarioOutcome buildClientErrorOutcome( - AuthzScenario scenario, A2ATError error, MetadataContent metadata) { - String actualOutcome = error.getCode(); - List actualSlotErrors = extractSlotErrors(error); - ClientExpected expectedClient = scenario.expected().client(); - boolean outcomeMatch = expectedClient.outcome() != null && expectedClient.outcome().equals(actualOutcome); - boolean slotErrorsMatch = outcomeMatch && slotErrorsMatch(expectedClient.slotErrors(), actualSlotErrors); - boolean match = outcomeMatch && slotErrorsMatch; - return new ScenarioOutcome( - new ScenarioResult(actualOutcome, match, error, actualSlotErrors, null, null, null, List.of()), - metadata, - null); - } - - private ScenarioOutcome buildServerErrorOutcome( - AuthzScenario scenario, - A2ATError error, - MetadataContent metadata, - boolean clientPromptMatch, - List warnings) { - String actualOutcome = error.getCode(); - List actualSlotErrors = extractSlotErrors(error); - ServerExpected expectedServer = scenario.expected().server(); - boolean outcomeMatch = expectedServer != null && expectedServer.outcome() != null - && expectedServer.outcome().equals(actualOutcome); - boolean slotErrorsMatch = outcomeMatch && slotErrorsMatch(expectedServer.slotErrors(), actualSlotErrors); - boolean match = outcomeMatch && slotErrorsMatch; - return new ScenarioOutcome( - new ScenarioResult( - actualOutcome, - match, - error, - actualSlotErrors, - clientPromptMatch, - outcomeMatch, - null, - warnings), - metadata, - null); - } - - private static List extractSlotErrors(A2ATError error) { - if (error instanceof PromptGenerationException pge) { - return pge.failedParameters(); - } - if (error instanceof ContentValidationException cve) { - return cve.errors(); - } - return List.of(); - } - - /** Trailing-whitespace-insensitive equality for the rendered prompt text. */ - static boolean promptTextMatch(String expected, String actual) { - if (expected == null || actual == null) { - return expected == actual; - } - return expected.strip().equals(actual.strip()); - } - - /** Subset match: every expected slot error must appear in the actual errors with the same name and code. */ - static boolean slotErrorsMatch(List expected, List actual) { - if (expected == null || expected.isEmpty()) { - return true; - } - for (SlotErrorExpectation expectedError : expected) { - boolean found = actual.stream() - .anyMatch(actualError -> - actualError.slotName().equals(expectedError.slotName()) - && actualError.code().equals(expectedError.code())); - if (!found) { - return false; - } - } - return true; - } - - /** - * Recursive subset match for extracted parameters: maps match when every expected key is present with a - * recursively-equal value; lists match when of equal length and element-wise recursively equal; strings - * match after trimming; other values match via {@link Objects#equals}. - */ - static boolean paramsMatch(Object expected, Object actual) { - if (expected instanceof Map expectedMap) { - if (!(actual instanceof Map actualMap)) { - return false; - } - for (Map.Entry entry : expectedMap.entrySet()) { - if (!actualMap.containsKey(entry.getKey())) { - return false; - } - if (!paramsMatch(entry.getValue(), actualMap.get(entry.getKey()))) { - return false; - } - } - return true; - } - if (expected instanceof List expectedList) { - if (!(actual instanceof List actualList) || expectedList.size() != actualList.size()) { - return false; - } - for (int i = 0; i < expectedList.size(); i++) { - if (!paramsMatch(expectedList.get(i), actualList.get(i))) { - return false; - } - } - return true; - } - if (expected instanceof String expectedText) { - return actual instanceof String actualText && expectedText.strip().equals(actualText.strip()); - } - return Objects.equals(expected, actual); - } - - /** - * Extracts the content of a {@code ## heading} section from the prompt text. - * Returns the text between the heading line and the next heading (or end of string), stripped. - * Returns {@code null} if the heading is not found. - */ - static String extractSectionContent(String promptText, String heading) { - String marker = heading + "\n"; - int start = promptText.indexOf(marker); - if (start < 0) { - return null; - } - int contentStart = start + marker.length(); - int nextHeading = promptText.indexOf("\n## ", contentStart); - if (nextHeading < 0) { - return promptText.substring(contentStart).strip(); - } - return promptText.substring(contentStart, nextHeading).strip(); - } - - /** - * Detects structural warnings in the generated prompt text. Warnings are informational only - * and never affect the {@code match} verdict. - */ - static List detectWarnings(String promptText) { - if (promptText == null) { - return List.of(); - } - List warnings = new ArrayList<>(); - String operationTypeContent = extractSectionContent(promptText, "## 授权策略的操作类型"); - String policyListContent = extractSectionContent(promptText, "## 动网操作的授权策略列表"); - String operationType = operationTypeContent != null - ? operationTypeContent.lines().findFirst().map(String::strip).orElse("") - : ""; - boolean isMutation = operationType.contains("新增") || operationType.contains("修改") - || operationType.contains("删除"); - boolean policyListEmpty = policyListContent == null || policyListContent.isEmpty(); - if (isMutation && policyListEmpty) { - warnings.add("empty_policy_list_section"); - } - return warnings; - } - - /** - * Result of one scenario run. - * - * @param outcome actual outcome code ({@code success} or an {@code ErrorCatalog} code) - * @param match whether the actual behaviour satisfies all staged expectations - * @param error the raised error, if any - * @param slotErrors actual per-slot error details - * @param clientPromptMatch whether the client-generated prompt text matched; {@code null} when the - * client stage was expected to fail. Recorded for diagnostics only; does not affect {@code match}. - * @param serverOutcomeMatch whether the server outcome matched; {@code null} when no server stage ran - * @param serverParamsMatch whether the extracted parameters matched; {@code null} when not asserted - * @param warnings structural warnings detected during the run (e.g. empty policy-list section for - * a mutation operation); never affects {@code match} - */ - public record ScenarioResult( - String outcome, - boolean match, - A2ATError error, - List slotErrors, - Boolean clientPromptMatch, - Boolean serverOutcomeMatch, - Boolean serverParamsMatch, - List warnings) {} - - public record ScenarioOutcome(ScenarioResult result, MetadataContent metadata, FilledParamData filled) {} -} +package net.openan.a2at.sample.authz_policy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; + +/** + * Scenario execution engine that runs a single scenario through prompt generation and validation, comparing the staged + * client/server expectations against the actual behaviour. + * + *

        Assertion semantics mirror the Python probe: client {@code promptText} is compared with + * trailing-whitespace-insensitive equality only for recording ({@code clientPromptMatch}); it does not participate in + * the {@code match} verdict. Server {@code slot_errors} are subset-matched (each expected error must appear with the + * same slot name and code); server {@code params} are subset-matched recursively (maps by key, ordered lists + * element-wise, trimmed strings) and only asserted for expected server outcome {@code success}. Structural warnings + * (such as an empty policy-list section for a mutation operation) are recorded in {@code warnings} but never affect the + * {@code match} verdict. + * + * @since 2026-08 + */ +public final class AuthzScenarioRunner { + + private final AuthzPromptGenerator generator; + + private final AuthzPromptValidator validator; + + public AuthzScenarioRunner(AuthzPromptGenerator generator, AuthzPromptValidator validator) { + this.generator = generator; + this.validator = validator; + } + + public ScenarioOutcome run(AuthzScenario scenario, Map paramSchema, TemplateUri templateUri) { + MetadataContent metadata; + try { + metadata = generator.generate(scenario); + } catch (A2ATError e) { + return buildClientErrorOutcome(scenario, e, null); + } catch (RuntimeException e) { + A2ATError wrapped = new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), e.getMessage(), e); + return buildClientErrorOutcome(scenario, wrapped, null); + } + + ClientExpected expectedClient = scenario.expected().client(); + boolean clientPromptMatch = + expectedClient.outcome() == null && promptTextMatch(expectedClient.promptText(), metadata.promptText()); + List warnings = detectWarnings(metadata.promptText()); + if (expectedClient.outcome() != null) { + // generation unexpectedly succeeded — no server-stage expectation applies + return new ScenarioOutcome( + new ScenarioResult( + AuthzScenario.EXPECTED_SUCCESS, + false, + null, + List.of(), + clientPromptMatch, + null, + null, + warnings), + metadata, + null); + } + + FilledParamData filled; + try { + Map effectiveSchema = + scenario.validateSchema() != null ? scenario.validateSchema() : paramSchema; + filled = validator.validate(metadata.promptText(), effectiveSchema, templateUri); + } catch (A2ATError e) { + return buildServerErrorOutcome(scenario, e, metadata, clientPromptMatch, warnings); + } catch (RuntimeException e) { + A2ATError wrapped = new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), e.getMessage(), e); + return buildServerErrorOutcome(scenario, wrapped, metadata, clientPromptMatch, warnings); + } + + ServerExpected expectedServer = scenario.expected().server(); + List noErrors = List.of(); + boolean serverOutcomeMatch = + expectedServer != null && AuthzScenario.EXPECTED_SUCCESS.equals(expectedServer.outcome()); + boolean serverParamsMatch = serverOutcomeMatch + && (expectedServer.params() == null || paramsMatch(expectedServer.params(), filled.data())); + boolean match = serverOutcomeMatch && serverParamsMatch; + return new ScenarioOutcome( + new ScenarioResult( + AuthzScenario.EXPECTED_SUCCESS, + match, + null, + noErrors, + clientPromptMatch, + serverOutcomeMatch, + serverParamsMatch, + warnings), + metadata, + filled); + } + + private ScenarioOutcome buildClientErrorOutcome(AuthzScenario scenario, A2ATError error, MetadataContent metadata) { + String actualOutcome = error.getCode(); + List actualSlotErrors = extractSlotErrors(error); + ClientExpected expectedClient = scenario.expected().client(); + boolean outcomeMatch = + expectedClient.outcome() != null && expectedClient.outcome().equals(actualOutcome); + boolean slotErrorsMatch = outcomeMatch && slotErrorsMatch(expectedClient.slotErrors(), actualSlotErrors); + boolean match = outcomeMatch && slotErrorsMatch; + return new ScenarioOutcome( + new ScenarioResult(actualOutcome, match, error, actualSlotErrors, null, null, null, List.of()), + metadata, + null); + } + + private ScenarioOutcome buildServerErrorOutcome( + AuthzScenario scenario, + A2ATError error, + MetadataContent metadata, + boolean clientPromptMatch, + List warnings) { + String actualOutcome = error.getCode(); + List actualSlotErrors = extractSlotErrors(error); + ServerExpected expectedServer = scenario.expected().server(); + boolean outcomeMatch = expectedServer != null + && expectedServer.outcome() != null + && expectedServer.outcome().equals(actualOutcome); + boolean slotErrorsMatch = outcomeMatch && slotErrorsMatch(expectedServer.slotErrors(), actualSlotErrors); + boolean match = outcomeMatch && slotErrorsMatch; + return new ScenarioOutcome( + new ScenarioResult( + actualOutcome, match, error, actualSlotErrors, clientPromptMatch, outcomeMatch, null, warnings), + metadata, + null); + } + + private static List extractSlotErrors(A2ATError error) { + if (error instanceof PromptGenerationException pge) { + return pge.failedParameters(); + } + if (error instanceof ContentValidationException cve) { + return cve.errors(); + } + return List.of(); + } + + /** Trailing-whitespace-insensitive equality for the rendered prompt text. */ + static boolean promptTextMatch(String expected, String actual) { + if (expected == null || actual == null) { + return expected == actual; + } + return expected.strip().equals(actual.strip()); + } + + /** Subset match: every expected slot error must appear in the actual errors with the same name and code. */ + static boolean slotErrorsMatch(List expected, List actual) { + if (expected == null || expected.isEmpty()) { + return true; + } + for (SlotErrorExpectation expectedError : expected) { + boolean found = actual.stream() + .anyMatch(actualError -> actualError.slotName().equals(expectedError.slotName()) + && actualError.code().equals(expectedError.code())); + if (!found) { + return false; + } + } + return true; + } + + /** + * Recursive subset match for extracted parameters: maps match when every expected key is present with a + * recursively-equal value; lists match when of equal length and element-wise recursively equal; strings match after + * trimming; other values match via {@link Objects#equals}. + */ + static boolean paramsMatch(Object expected, Object actual) { + if (expected instanceof Map expectedMap) { + if (!(actual instanceof Map actualMap)) { + return false; + } + for (Map.Entry entry : expectedMap.entrySet()) { + if (!actualMap.containsKey(entry.getKey())) { + return false; + } + if (!paramsMatch(entry.getValue(), actualMap.get(entry.getKey()))) { + return false; + } + } + return true; + } + if (expected instanceof List expectedList) { + if (!(actual instanceof List actualList) || expectedList.size() != actualList.size()) { + return false; + } + for (int i = 0; i < expectedList.size(); i++) { + if (!paramsMatch(expectedList.get(i), actualList.get(i))) { + return false; + } + } + return true; + } + if (expected instanceof String expectedText) { + return actual instanceof String actualText && expectedText.strip().equals(actualText.strip()); + } + return Objects.equals(expected, actual); + } + + /** + * Extracts the content of a {@code ## heading} section from the prompt text. Returns the text between the heading + * line and the next heading (or end of string), stripped. Returns {@code null} if the heading is not found. + */ + static String extractSectionContent(String promptText, String heading) { + String marker = heading + "\n"; + int start = promptText.indexOf(marker); + if (start < 0) { + return null; + } + int contentStart = start + marker.length(); + int nextHeading = promptText.indexOf("\n## ", contentStart); + if (nextHeading < 0) { + return promptText.substring(contentStart).strip(); + } + return promptText.substring(contentStart, nextHeading).strip(); + } + + /** + * Detects structural warnings in the generated prompt text. Warnings are informational only and never affect the + * {@code match} verdict. + */ + static List detectWarnings(String promptText) { + if (promptText == null) { + return List.of(); + } + List warnings = new ArrayList<>(); + String operationTypeContent = extractSectionContent(promptText, "## 授权策略的操作类型"); + String policyListContent = extractSectionContent(promptText, "## 动网操作的授权策略列表"); + String operationType = operationTypeContent != null + ? operationTypeContent.lines().findFirst().map(String::strip).orElse("") + : ""; + boolean isMutation = + operationType.contains("新增") || operationType.contains("修改") || operationType.contains("删除"); + boolean policyListEmpty = policyListContent == null || policyListContent.isEmpty(); + if (isMutation && policyListEmpty) { + warnings.add("empty_policy_list_section"); + } + return warnings; + } + + /** + * Result of one scenario run. + * + * @param outcome actual outcome code ({@code success} or an {@code ErrorCatalog} code) + * @param match whether the actual behaviour satisfies all staged expectations + * @param error the raised error, if any + * @param slotErrors actual per-slot error details + * @param clientPromptMatch whether the client-generated prompt text matched; {@code null} when the client stage was + * expected to fail. Recorded for diagnostics only; does not affect {@code match}. + * @param serverOutcomeMatch whether the server outcome matched; {@code null} when no server stage ran + * @param serverParamsMatch whether the extracted parameters matched; {@code null} when not asserted + * @param warnings structural warnings detected during the run (e.g. empty policy-list section for a mutation + * operation); never affects {@code match} + */ + public record ScenarioResult( + String outcome, + boolean match, + A2ATError error, + List slotErrors, + Boolean clientPromptMatch, + Boolean serverOutcomeMatch, + Boolean serverParamsMatch, + List warnings) {} + + public record ScenarioOutcome(ScenarioResult result, MetadataContent metadata, FilledParamData filled) {} +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java index aecdb7cc..502e3f8a 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java @@ -1,1245 +1,1349 @@ -package net.openan.a2at.sample.negotiation.eval; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; -import java.util.UUID; -import java.util.function.Consumer; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.server.A2ATServer; - -/** - * Closed-loop evaluator for the negotiation prompt interfaces of the private-line-complaint scenario. - * - *

        Each case of the bundled suite ({@code sample/negotiation/eval/eval-suite.json}) is driven through the - * negotiation interfaces in message order, exactly the way the negotiation demo wires them: - * - *

          - *
        1. Task-T generation — the client value from the Task-T extension metadata is either structured JSON - * ({@code generateTaskPromptFromDataWithSchema}: every data key maps to one slot value, the schema is passed - * as a call parameter) or natural language ({@code generateTaskPromptFromText}, LLM slot extraction); - *
        2. server validation — {@code validateTaskPromptAndDataFilling} extracts the parameters against the passed - * JSON schema; the missing-slot set (blank slots plus semantic {@code content.param_missing} errors) decides - * whether negotiation is triggered; - *
        3. propose generation — for the detected missing slots, {@code generateNegotiationProposePromptFromData} / - * {@code FromText} renders the Negotiation-T information-propose message; - *
        4. outbound propose validation — the server self-checks the generated propose via - * {@code validateProposePromptAndDataFilling} before sending it (expected to pass); - *
        5. inbound propose validation — the client validates the received propose through the symmetric client-side - * API {@code validateProposePromptAndDataFilling} and reads out which slots it is asked to supplement; - * the fill round is driven by this client-side view; - *
        6. client fill + accept — the client merges the round-1 parameters with its fill values, RE-RENDERS the full - * Task-T prompt from the complete parameter map (there is no incremental merge API; the supplement is - * incorporated by re-generating the whole prompt) and renders the Negotiation-T accept; - *
        7. inbound accept validation — {@code validateAcceptPromptAndDataFilling} checks the accept message and extracts - * the supplemented values (expected to pass with the values the client sent); - *
        8. second Task-T validation — {@code validateTaskPromptAndDataFilling} re-validates the filled Task-T prompt; - * no missing slot means the negotiation completed the flow (COMPLETED), any rejection keeps it in - * INPUT_REQUIRED. - *
        - * - *

        Every intermediate artifact is recorded per case in a JSON report: the generated Task-T prompt, both - * negotiation messages, each validation verdict with extracted parameters and per-slot errors, the client's fill - * values — and, for every SDK call, an {@code api} entry carrying the exact method name plus its input parameters - * (including the full JSON schema passed to generate/validate), so a reviewer can verify from the report alone - * which interface ran with which arguments. A single case can be replayed and audited without re-running it. The - * report is rewritten after every case, so an interrupted run keeps the cases already finished. - * - *

        Usage: - * - *

        {@code
        - * java @a2a-t-sample/target/eval.javaargs.txt [--out eval-report.json] [--case PLC-04] /path/to/.env
        - * }
        - * - * @since 2026-08 - */ -public final class NegotiationEvalApp { - - private static final ObjectMapper MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); - - private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - - private NegotiationEvalApp() {} - - /** - * Entry point. - * - * @param args options ({@code --out } report file, {@code --case } repeatable case filter), then the - * {@code .env} path carrying a real LLM API key - */ - public static void main(String[] args) { - Path envPath = null; - Path outPath = Path.of("eval-report.json"); - List caseFilter = new ArrayList<>(); - String negotiationChannelOverride = null; - String channelFilter = null; - for (int i = 0; i < args.length; i++) { - String arg = args[i]; - if ("--out".equals(arg) && i + 1 < args.length) { - outPath = Path.of(args[++i]); - } else if ("--case".equals(arg) && i + 1 < args.length) { - caseFilter.add(args[++i]); - } else if ("--negotiation-channel".equals(arg) && i + 1 < args.length) { - negotiationChannelOverride = args[++i]; - } else if ("--channel".equals(arg) && i + 1 < args.length) { - channelFilter = args[++i]; - } else if (!arg.startsWith("--")) { - envPath = Path.of(arg); - } - } - if (envPath == null) { - System.err.println( - "Usage: java @a2a-t-sample/target/eval.javaargs.txt [--out eval-report.json] [--case PLC-04]" - + " [--channel fromData|fromText] [--negotiation-channel fromData|fromText]" - + " /path/to/.env"); - System.exit(1); - } - - EvalLlmCaptureClient.install(); - Map suite = loadSuite(); - if (negotiationChannelOverride != null) { - // run the whole suite with one negotiation generation channel without duplicating cases - for (Map testCase : cases(suite)) { - testCase.put("negotiation_channel", negotiationChannelOverride); - } - } - - Map report = new LinkedHashMap<>(); - report.put("suite", suite.get("suite")); - report.put("description", suite.get("description")); - report.put("scenario", suite.get("scenario")); - report.put("generated_at", LocalDateTime.now().format(TIMESTAMP)); - report.put("llm", llmInfo(envPath)); - report.put("case_filter", caseFilter); - report.put("channel_filter", channelFilter == null ? "all" : channelFilter); - report.put("negotiation_channel", negotiationChannelOverride == null - ? "per-case" - : negotiationChannelOverride); - - List> cases = new ArrayList<>(); - report.put("cases", cases); - int index = 0; - int total = countCases(suite, caseFilter, channelFilter); - for (Map testCase : cases(suite)) { - String caseId = String.valueOf(testCase.get("id")); - if (!caseFilter.isEmpty() && !caseFilter.contains(caseId)) { - continue; - } - // evaluate one task-generation channel in isolation: only its cases run, so fromData and - // fromText can be measured (and reported) separately - if (channelFilter != null && !channelFilter.equals(String.valueOf(testCase.get("channel")))) { - continue; - } - index++; - emit("\n[eval] ===== [" + index + "/" + total + "] case " + caseId + ": " + testCase.get("intent") - + " ====="); - long startNanos = System.nanoTime(); - Map record = runCase(envPath, suite, testCase); - record.put("duration_seconds", round1((System.nanoTime() - startNanos) / 1_000_000_000.0)); - cases.add(record); - report.put("metrics", metrics(cases)); - writeReport(report, outPath); - } - - report.put("metrics", metrics(cases)); - writeReport(report, outPath); - Map metrics = metrics(cases); - emit("\n[eval] ===== Negotiation interface correctness metrics ====="); - emit("[eval] cases: " + metrics.get("cases")); - for (String key : List.of("trigger_accuracy", "missing_slot_exact_match", "propose_outbound_valid", - "accept_fill_value_match", "closed_loop_completion")) { - emit("[eval] " + metrics.get(key)); - } - emit("[eval] report written to " + outPath.toAbsolutePath()); - } - - // -- one case: the negotiation interface closed loop with full evidence capture -- - - /** Runs one case end to end and returns its replayable trace record. */ - private static Map runCase(Path envPath, Map suite, Map testCase) { - EvalLlmCaptureClient.reset(); - String caseId = String.valueOf(testCase.get("id")); - String channel = String.valueOf(testCase.get("channel")); - boolean fromText = "fromText".equals(channel); - String negotiationChannel = String.valueOf(testCase.getOrDefault("negotiation_channel", "fromData")); - boolean negotiationFromText = "fromText".equals(negotiationChannel); - String inputText = String.valueOf(testCase.get("input_text")); - Map inputData = asMap(testCase.get("input_data")); - - TemplateUri taskTemplate = parseTemplate(String.valueOf(suite.get("template_uri"))); - Map taskSchema = asMap(suite.get("task_schema")); - Map phrasing = stringMap(suite.get("negotiation_phrasing")); - Map properties = asMap(taskSchema.get("properties")); - Set requiredSlots = new TreeSet<>(stringList(taskSchema.get("required"))); - Map fillValues = mergeFills(stringMap(suite.get("client_fill_values")), - stringMap(testCase.get("fill_override"))); - - Map expect = asMap(testCase.get("expect")); - boolean expectNegotiation = Boolean.TRUE.equals(expect.get("negotiation_needed")); - Set expectMissing = new TreeSet<>(stringList(expect.get("missing_slots"))); - Boolean expectFillCompletes = (Boolean) expect.get("fill_completes"); - boolean expectGenerationFails = Boolean.TRUE.equals(expect.get("generation_fails")); - - Map record = new LinkedHashMap<>(); - record.put("case", caseId); - record.put("channel", channel); - record.put("negotiation_channel", negotiationChannel); - record.put("intent", testCase.get("intent")); - Map input = new LinkedHashMap<>(); - input.put("fromText", fromText); - if (fromText) { - input.put("text", inputText); - } else { - input.put("data", inputData); - } - record.put("input", input); - record.put("task_schema", taskSchema); - Map expectJson = new LinkedHashMap<>(); - expectJson.put("negotiation_needed", expectNegotiation); - expectJson.put("missing_slots", new ArrayList<>(expectMissing)); - expectJson.put("fill_completes", expectFillCompletes); - expectJson.put("generation_fails", expectGenerationFails); - record.put("expect", expectJson); - List> steps = new ArrayList<>(); - record.put("steps", steps); - - // -- step 1: Task-T prompt generation from the extension metadata value -- - Map taskGenerationInput = new LinkedHashMap<>(); - if (fromText) { - taskGenerationInput.put("text", inputText); - } else { - taskGenerationInput.put("data", inputData); - taskGenerationInput.put("schema", taskSchema); - } - taskGenerationInput.put("template_uri", taskTemplate.uri()); - String taskGenerationApi = fromText - ? "A2ATClient.generateTaskPromptFromText" - : "A2ATClient.generateTaskPromptFromDataWithSchema"; - String taskPrompt; - try { - long nanos = System.nanoTime(); - A2ATClient client = new A2ATClient(envPath); - MetadataContent taskContent = fromText - ? client.generateTaskPromptFromText(inputText, taskTemplate) - : client.generateTaskPromptFromDataWithSchema(inputData, taskSchema, taskTemplate); - taskPrompt = taskContent.promptText(); - Map step = step("1. Task-T generation (" + channel + ")", "client"); - api(step, apiCall(taskGenerationApi, taskGenerationInput)); - step.put("generated_prompt", taskPrompt); - step.put("template_uri", taskContent.templateUri()); - steps.add(step); - emit("[eval] [1] Task-T prompt generated (" + channel + ", " + secs(nanos) + "s)"); - } catch (A2ATError error) { - steps.add(errorStep("1. Task-T generation (" + channel + ")", "task_generation", error, - apiCall(taskGenerationApi, taskGenerationInput))); - emit("[eval] [1] Task-T generation FAILED: " + error.getCode() + " " + error.getMessage()); - if (expectGenerationFails) { - // generation-time fail-fast is the expected outcome (e.g. a required slot missing from the - // natural-language input): the task never exists, so negotiation cannot and must not trigger - Map verdict = new LinkedHashMap<>(); - verdict.put("trigger_correct", !expectNegotiation); - verdict.put("slots_correct", expectMissing.isEmpty()); - verdict.put("propose_outbound_valid", null); - verdict.put("accept_fill_value_match", null); - verdict.put("fill_completes_correct", null); - verdict.put("pass", !expectNegotiation && expectMissing.isEmpty()); - verdict.put("reason", "generation failed fast as expected: " + error.getCode() + " " - + error.getMessage()); - record.put("verdict", verdict); - emit("[eval] verdict: PASS (generation fail-fast as expected)"); - return record; - } - return finish(record, steps, null, null, null, null, null, expectNegotiation, expectMissing, - expectFillCompletes); - } - - // -- step 2: server validation of the Task-T prompt -> missing-slot detection -- - Map taskValidationInput = Map.of( - "prompt", taskPrompt, - "schema", taskSchema, - "template_uri", taskTemplate.uri()); - Set actualMissing; - Map extractedRound1 = new LinkedHashMap<>(); - try { - long nanos = System.nanoTime(); - A2ATServer server = new A2ATServer(envPath); - FilledParamData params = server.validateTaskPromptAndDataFilling(taskPrompt, taskSchema, taskTemplate); - actualMissing = requiredMissing(blankSlots(params), requiredSlots); - if (params.data() != null) { - extractedRound1.putAll(params.data()); - } - Map step = stepOf( - "2. server Task-T validation (missing-slot detection)", - "server", - validationJson("passed", null, null, params, actualMissing)); - api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", taskValidationInput)); - steps.add(step); - emit("[eval] [2] validation passed, missing slots: " + actualMissing + " (" + secs(nanos) + "s)"); - } catch (ContentValidationException error) { - actualMissing = requiredMissing(errorSlots(error), requiredSlots); - Map step = stepOf( - "2. server Task-T validation (missing-slot detection)", - "server", - validationJson("rejected", error.getCode(), error.errors(), null, actualMissing)); - api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", taskValidationInput)); - steps.add(step); - emit("[eval] [2] validation rejected (" + error.getCode() + "), missing slots: " + actualMissing); - } - - boolean negotiationTriggered = !actualMissing.isEmpty(); - if (!negotiationTriggered) { - Map done = step("3. no negotiation needed -> flow completes directly", "server"); - done.put("task_state", "COMPLETED"); - steps.add(done); - emit("[eval] [3] no missing parameters -> COMPLETED without negotiation"); - return finish(record, steps, negotiationTriggered, actualMissing, null, null, null, expectNegotiation, - expectMissing, expectFillCompletes); - } - - // -- step 3: Negotiation-T propose generation from the detected missing slots -- - List missingItems = new ArrayList<>(); - for (String slot : actualMissing) { - missingItems.add(new NegotiationItem(slot, missingHint(phrasing, properties, slot))); - } - String proposeRelationship = missingItems.size() > 1 ? phrasing.get("propose_relationship") : null; - NegotiationContext proposeContext = new NegotiationContext( - UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.PROPOSE); - String proposeInputText = itemsToProposeText(missingItems, phrasing); - Map proposeGenerationInput = new LinkedHashMap<>(); - if (negotiationFromText) { - proposeGenerationInput.put("text", proposeInputText); - proposeGenerationInput.put("context", contextJson(proposeContext)); - } else { - Map proposeData = new LinkedHashMap<>(); - proposeData.put("context", contextJson(proposeContext)); - proposeData.put("items", itemsJson(missingItems)); - if (proposeRelationship != null) { - proposeData.put("relationship", proposeRelationship); - } - proposeGenerationInput.put("data", proposeData); - } - proposeGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()); - String proposeGenerationApi = negotiationFromText - ? "A2ATServer.generateNegotiationProposePromptFromText" - : "A2ATServer.generateNegotiationProposePromptFromData"; - String proposePrompt; - try { - A2ATServer server = new A2ATServer(envPath); - MetadataContent propose = negotiationFromText - ? server.generateNegotiationProposePromptFromText( - proposeInputText, proposeContext, DemoTemplates.NEGOTIATION_PROPOSE) - : server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - proposeContext, - new InformationProposeContent(missingItems, proposeRelationship)), - DemoTemplates.NEGOTIATION_PROPOSE); - proposePrompt = propose.promptText(); - Map step = - step("3. Negotiation-T propose generation (" + negotiationChannel + ")", "server"); - api(step, apiCall(proposeGenerationApi, proposeGenerationInput)); - step.put("negotiation_items", itemsJson(missingItems)); - if (negotiationFromText) { - step.put("input_text", proposeInputText); - } - step.put("generated_prompt", proposePrompt); - step.put("template_uri", propose.templateUri()); - step.put("task_state", "INPUT_REQUIRED"); - steps.add(step); - emit("[eval] [3] propose rendered for " + actualMissing + " (" + negotiationChannel - + ") -> INPUT_REQUIRED"); - } catch (A2ATError error) { - steps.add(errorStep("3. Negotiation-T propose generation (" + negotiationChannel + ")", - "propose_generation", error, apiCall(proposeGenerationApi, proposeGenerationInput))); - emit("[eval] [3] propose generation FAILED: " + error.getCode() + " " + error.getMessage()); - return finish(record, steps, negotiationTriggered, actualMissing, null, null, null, expectNegotiation, - expectMissing, expectFillCompletes); - } - - // -- step 4: outbound validation of the generated propose -- - Map proposeValidationInput = Map.of( - "prompt", proposePrompt, - "context", contextJson(proposeContext), - "schema", negotiationSchema(actualMissing, taskSchema), - "template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()); - Boolean proposeValid = null; - try { - long nanos = System.nanoTime(); - A2ATServer server = new A2ATServer(envPath); - FilledParamData proposeParams = server.validateProposePromptAndDataFilling( - proposePrompt, proposeContext, negotiationSchema(actualMissing, taskSchema), - DemoTemplates.NEGOTIATION_PROPOSE); - proposeValid = true; - Map step = stepOf( - "4. outbound propose validation", - "server", - validationJson("passed", null, null, proposeParams, Set.of())); - api(step, apiCall("A2ATServer.validateProposePromptAndDataFilling", proposeValidationInput)); - steps.add(step); - emit("[eval] [4] outbound propose validation passed (" + secs(nanos) + "s)"); - } catch (A2ATError error) { - proposeValid = false; - List slotErrors = error instanceof - net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException extraction - ? extraction.getErrors() - : List.of(); - Map step = stepOf( - "4. outbound propose validation", - "server", - validationJson("rejected", error.getCode(), slotErrors, null, Set.of())); - api(step, apiCall("A2ATServer.validateProposePromptAndDataFilling", proposeValidationInput)); - steps.add(step); - emit("[eval] [4] outbound propose validation REJECTED: " + error.getCode() + " " + error.getMessage()); - } - - // -- step 5: client validates the INBOUND propose and reads out the requested slots -- - // The SDK exposes the validate* API symmetrically on client and server: the receiving client re-validates - // the negotiation message it got and extracts which slots the server asked it to supplement. The fill round - // below is driven by this client-side view (not by the server's internal missing-slot set), so the report - // shows what a real client would learn from the propose message alone. - Set clientRequestedSlots = new TreeSet<>(); - try { - long nanos = System.nanoTime(); - A2ATClient client = new A2ATClient(envPath); - FilledParamData requested = client.validateProposePromptAndDataFilling( - proposePrompt, proposeContext, negotiationSchema(actualMissing, taskSchema), - DemoTemplates.NEGOTIATION_PROPOSE); - if (requested.data() != null) { - for (String key : requested.data().keySet()) { - // the SDK merges the negotiation context (id/round/maxRounds) into the extracted data; only the - // real slot names tell the client what to supplement - if (!"id".equals(key) && !"round".equals(key) && !"maxRounds".equals(key)) { - clientRequestedSlots.add(key); - } - } - } - Map step = stepOf( - "5. client inbound propose validation (requested-slot extraction)", - "client", - validationJson("passed", null, null, requested, Set.of())); - api(step, apiCall("A2ATClient.validateProposePromptAndDataFilling", Map.of( - "prompt", proposePrompt, - "context", contextJson(proposeContext), - "schema", negotiationSchema(actualMissing, taskSchema), - "template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()))); - step.put("client_requested_slots", new ArrayList<>(clientRequestedSlots)); - steps.add(step); - emit("[eval] [5] client extracted requested slots: " + clientRequestedSlots + " (" + secs(nanos) - + "s)"); - } catch (A2ATError error) { - List slotErrors = error instanceof - net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException extraction - ? extraction.getErrors() - : List.of(); - Map step = stepOf( - "5. client inbound propose validation (requested-slot extraction)", - "client", - validationJson("rejected", error.getCode(), slotErrors, null, Set.of())); - api(step, apiCall("A2ATClient.validateProposePromptAndDataFilling", Map.of( - "prompt", proposePrompt, - "context", contextJson(proposeContext), - "schema", negotiationSchema(actualMissing, taskSchema), - "template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()))); - step.put("client_requested_slots", List.of()); - steps.add(step); - emit("[eval] [5] client propose validation REJECTED: " + error.getCode() + " " + error.getMessage() - + " -> falling back to the server-side missing-slot set for the fill"); - } - Set slotsToFill = clientRequestedSlots.isEmpty() ? actualMissing : clientRequestedSlots; - - // -- step 6: client fills the requested slots and renders the Negotiation-T accept -- - // the filled Task-T prompt is RE-GENERATED from the complete parameter map, exactly like the demo renders - // message 3's Task-T from the filled params — not text-appended. There is no incremental merge API: the - // client holds the full parameter set (the fromData input, or for fromText the parameters the server - // extracted in round 1), completed with the fill values for everything still missing, and re-renders the - // whole Task-T prompt from that map. - Map fills = fills(slotsToFill, fillValues); - // the fill round re-renders from the CLIENT's own knowledge: for fromData the structured input, for - // fromText the client_data the case carries (what its text quoted) — falling back to the server's - // round-1 extraction, which is only available when that validation passed - Map baseData = fromText ? clientBaseData(testCase, extractedRound1) : inputData; - // only the negotiated slots' fill values enter the merge: round-1 base values are preserved verbatim, - // so the re-rendered prompt carries exactly what the client knew plus what it supplemented - Map filledData = mergeInputData(baseData, taskSchema, fills); - String filledPrompt; - Map filledGenerationInput = Map.of( - "data", filledData, - "schema", taskSchema, - "template_uri", taskTemplate.uri()); - try { - A2ATClient client = new A2ATClient(envPath); - filledPrompt = client.generateTaskPromptFromDataWithSchema(filledData, taskSchema, taskTemplate) - .promptText(); - } catch (A2ATError error) { - // a fill value violates a slot constraint: fromData generation fails fast — the closed loop cannot - // complete, which is the expected negative outcome for constraint-violating fills - filledPrompt = appendSupplements(taskPrompt, fills); - Map step = step("6. client fill + Negotiation-T accept generation (fromData)", "client"); - api(step, apiCall("A2ATClient.generateTaskPromptFromDataWithSchema", filledGenerationInput)); - step.put("client_fill", fills); - step.put("fill_generation_rejected", Map.of( - "code", error.getCode(), - "message", String.valueOf(error.getMessage()))); - steps.add(step); - emit("[eval] [6] filled Task-T generation REJECTED (" + error.getCode() + "): " - + error.getMessage()); - return finish(record, steps, negotiationTriggered, actualMissing, proposeValid, null, false, - expectNegotiation, expectMissing, expectFillCompletes); - } - String acceptPrompt; - NegotiationContext acceptContext = new NegotiationContext( - UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.ACCEPT); - String acceptInputText = itemsToAcceptText(filledItems(fills), phrasing); - Map acceptGenerationInput = new LinkedHashMap<>(); - if (negotiationFromText) { - acceptGenerationInput.put("text", acceptInputText); - acceptGenerationInput.put("context", contextJson(acceptContext)); - } else { - acceptGenerationInput.put("data", Map.of( - "context", contextJson(acceptContext), - "conclusion", NegotiationConclusion.ACCEPT.toString(), - "items", itemsJson(filledItems(fills)))); - } - acceptGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_ACCEPT.uri()); - String acceptGenerationApi = negotiationFromText - ? "A2ATClient.generateNegotiationAcceptPromptFromText" - : "A2ATClient.generateNegotiationAcceptPromptFromData"; - try { - A2ATClient client = new A2ATClient(envPath); - List filledItems = filledItems(fills); - MetadataContent accept = negotiationFromText - ? client.generateNegotiationAcceptPromptFromText(acceptInputText, acceptContext, - DemoTemplates.NEGOTIATION_ACCEPT) - : client.generateNegotiationAcceptPromptFromData( - new NegotiationEndingData( - acceptContext, - new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems)), - DemoTemplates.NEGOTIATION_ACCEPT); - acceptPrompt = accept.promptText(); - Map step = - step("6. client fill + Negotiation-T accept generation (" + negotiationChannel + ")", "client"); - api(step, - apiCall("A2ATClient.generateTaskPromptFromDataWithSchema", filledGenerationInput), - apiCall(acceptGenerationApi, acceptGenerationInput)); - step.put("client_fill", fills); - step.put("filled_task_prompt", filledPrompt); - step.put("filled_params", filledData); - if (negotiationFromText) { - step.put("input_text", acceptInputText); - } - step.put("generated_prompt", acceptPrompt); - step.put("template_uri", accept.templateUri()); - steps.add(step); - emit("[eval] [6] client fills " + fills.keySet() + ", accept rendered (" + negotiationChannel + ")"); - } catch (A2ATError error) { - steps.add(errorStep("6. client fill + Negotiation-T accept generation (" + negotiationChannel + ")", - "accept_generation", error, apiCall(acceptGenerationApi, acceptGenerationInput))); - emit("[eval] [6] accept generation FAILED: " + error.getCode() + " " + error.getMessage()); - return finish(record, steps, negotiationTriggered, actualMissing, proposeValid, null, null, - expectNegotiation, expectMissing, expectFillCompletes); - } - - // -- step 7: inbound validation of the accept message; extracted values must match the client fill -- - Map acceptValidationInput = Map.of( - "prompt", acceptPrompt, - "context", contextJson(acceptContext), - "schema", negotiationSchema(slotsToFill, taskSchema), - "template_uri", DemoTemplates.NEGOTIATION_ACCEPT.uri()); - Boolean acceptValid = null; - Boolean fillValueMatch = null; - Map extractedAcceptParams = null; - try { - long nanos = System.nanoTime(); - A2ATServer server = new A2ATServer(envPath); - FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( - acceptPrompt, acceptContext, negotiationSchema(slotsToFill, taskSchema), - DemoTemplates.NEGOTIATION_ACCEPT); - acceptValid = true; - extractedAcceptParams = acceptParams.data() == null ? Map.of() : new LinkedHashMap<>(acceptParams.data()); - fillValueMatch = fillValuesMatch(extractedAcceptParams, fills); - Map validation = validationJson("passed", null, null, acceptParams, Set.of()); - validation.put("expected_fill", fills); - validation.put("fill_value_match", fillValueMatch); - Map step = stepOf("7. inbound accept validation", "server", validation); - api(step, apiCall("A2ATServer.validateAcceptPromptAndDataFilling", acceptValidationInput)); - steps.add(step); - emit("[eval] [7] accept validation passed, fill value match: " + fillValueMatch + " (" + secs(nanos) - + "s)"); - } catch (A2ATError error) { - acceptValid = false; - fillValueMatch = false; - List slotErrors = error instanceof - net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException extraction - ? extraction.getErrors() - : List.of(); - Map validation = validationJson("rejected", error.getCode(), slotErrors, null, Set.of()); - validation.put("expected_fill", fills); - validation.put("fill_value_match", false); - Map step = stepOf("7. inbound accept validation", "server", validation); - api(step, apiCall("A2ATServer.validateAcceptPromptAndDataFilling", acceptValidationInput)); - steps.add(step); - emit("[eval] [7] accept validation REJECTED: " + error.getCode() + " " + error.getMessage()); - } - - // -- step 8: second Task-T validation of the filled prompt -> COMPLETED or stays INPUT_REQUIRED -- - Map secondValidationInput = Map.of( - "prompt", filledPrompt, - "schema", taskSchema, - "template_uri", taskTemplate.uri()); - Boolean fillCompleted = null; - try { - long nanos = System.nanoTime(); - A2ATServer server = new A2ATServer(envPath); - FilledParamData filled = server.validateTaskPromptAndDataFilling(filledPrompt, taskSchema, taskTemplate); - Set stillMissing = requiredMissing(blankSlots(filled), requiredSlots); - fillCompleted = stillMissing.isEmpty(); - Map step = stepOf( - "8. second Task-T validation (fill round)", - "server", - validationJson(fillCompleted ? "passed" : "rejected", - fillCompleted ? null : "slots_still_missing", - fillCompleted ? null : stillMissingErrors(stillMissing), - filled, - stillMissing)); - api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", secondValidationInput)); - step.put("task_state", fillCompleted ? "COMPLETED" : "INPUT_REQUIRED"); - steps.add(step); - emit("[eval] [8] second Task-T validation " + (fillCompleted ? "-> COMPLETED" : "still missing: " - + stillMissing) + " (" + secs(nanos) + "s)"); - } catch (ContentValidationException error) { - fillCompleted = false; - Map step = stepOf( - "8. second Task-T validation (fill round)", - "server", - validationJson("rejected", error.getCode(), error.errors(), null, errorSlots(error))); - api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", secondValidationInput)); - steps.add(step); - emit("[eval] [8] second Task-T validation REJECTED (" + error.getCode() + "): " + errorSlots(error)); - } - - return finish(record, steps, negotiationTriggered, actualMissing, proposeValid, fillValueMatch, - fillCompleted, expectNegotiation, expectMissing, expectFillCompletes); - } - - // -- record assembly -- - - private static Map finish( - Map record, - List> steps, - Boolean triggered, - Set actualMissing, - Boolean proposeValid, - Boolean fillValueMatch, - Boolean fillCompleted, - boolean expectNegotiation, - Set expectMissing, - Boolean expectFillCompletes) { - boolean error = triggered == null; - boolean triggerCorrect = !error && triggered == expectNegotiation; - boolean slotsCorrect = !error && actualMissing != null && actualMissing.equals(expectMissing); - Boolean proposeValidCorrect = error || proposeValid == null ? null : proposeValid; - // value match is only meaningful for cases whose fill round is expected to succeed; a negative fill case - // (expectFillCompletes=false) is judged by the fill round NOT completing — the accept validation rejecting - // the constraint-violating fill value is exactly the expected outcome, not a mismatch - Boolean fillMatchCorrect = - error || fillValueMatch == null || Boolean.FALSE.equals(expectFillCompletes) ? null : fillValueMatch; - Boolean fillCorrect; - if (error) { - fillCorrect = false; - } else if (expectFillCompletes == null) { - fillCorrect = null; - } else if (Boolean.TRUE.equals(triggered)) { - fillCorrect = expectFillCompletes.equals(fillCompleted); - } else { - fillCorrect = !expectFillCompletes; - } - boolean pass = triggerCorrect && slotsCorrect - && (proposeValidCorrect == null || proposeValidCorrect) - && (fillMatchCorrect == null || fillMatchCorrect) - && (fillCorrect == null || fillCorrect); - - Map actual = new LinkedHashMap<>(); - actual.put("negotiation_triggered", triggered); - actual.put("missing_slots", actualMissing == null ? null : new ArrayList<>(actualMissing)); - actual.put("propose_outbound_valid", proposeValid); - actual.put("accept_fill_value_match", fillValueMatch); - actual.put("closed_loop_completed", fillCompleted); - record.put("actual", actual); - - Map verdict = new LinkedHashMap<>(); - verdict.put("trigger_correct", triggerCorrect); - verdict.put("slots_correct", slotsCorrect); - verdict.put("propose_outbound_valid", proposeValidCorrect); - verdict.put("accept_fill_value_match", fillMatchCorrect); - verdict.put("fill_completes_correct", fillCorrect); - verdict.put("pass", pass); - verdict.put("reason", reason(error, triggerCorrect, slotsCorrect, fillMatchCorrect, fillCorrect, - actualMissing, expectMissing, triggered, expectNegotiation, proposeValid)); - record.put("verdict", verdict); - - emit("[eval] verdict: " + (pass ? "PASS" : "FAIL") - + " | trigger=" + triggerCorrect + " slots=" + slotsCorrect - + " proposeValid=" + proposeValidCorrect + " fillMatch=" + fillMatchCorrect + " fill=" + fillCorrect); - return record; - } - - private static String reason( - boolean error, - boolean triggerCorrect, - boolean slotsCorrect, - Boolean fillMatchCorrect, - Boolean fillCorrect, - Set actualMissing, - Set expectMissing, - Boolean triggered, - boolean expectNegotiation, - Boolean proposeValid) { - if (error) { - return "evaluation aborted by an error (see steps)"; - } - List notes = new ArrayList<>(); - if (!triggerCorrect) { - notes.add("negotiation expected=" + expectNegotiation + " actual=" + triggered); - } - if (!slotsCorrect && actualMissing != null) { - Set missed = new TreeSet<>(expectMissing); - missed.removeAll(actualMissing); - Set extra = new TreeSet<>(actualMissing); - extra.removeAll(expectMissing); - notes.add("missing-slot set mismatch: expected=" + expectMissing + " actual=" + actualMissing - + (missed.isEmpty() ? "" : " (not reported: " + missed + ")") - + (extra.isEmpty() ? "" : " (unexpected: " + extra + ")")); - } - if (proposeValid != null && !proposeValid) { - notes.add("generated propose failed outbound validation (step 4)"); - } - if (fillMatchCorrect != null && !fillMatchCorrect) { - notes.add("server-side extracted fill values differ from what the client sent (step 7)"); - } - if (fillCorrect != null && !fillCorrect) { - notes.add("fill round did not complete as expected (step 8)"); - } - return String.join("; ", notes); - } - - // -- trace step builders -- - - private static Map step(String name, String role) { - Map step = new LinkedHashMap<>(); - step.put("step", name); - step.put("role", role); - attachLlmCalls(step); - return step; - } - - /** - * Attaches the LLM calls recorded since the previous step was built: the exact messages (system + user - * prompt) and JSON schema fed to the model, plus the response or the failure. When a case fails, this is - * what makes the cause traceable — the recorded prompt shows which slot description, constraint or - * system instruction the model actually saw, so the prompt resources can be tuned in reverse. - */ - private static void attachLlmCalls(Map step) { - List> calls = EvalLlmCaptureClient.drain(); - if (!calls.isEmpty()) { - step.put("llm_calls", calls); - } - } - - /** Attaches the SDK API call evidence (method name + input parameters) to a step; one step may make several calls. */ - @SafeVarargs - private static void api(Map step, Map... calls) { - step.put("api_calls", List.of(calls)); - } - - /** One SDK API invocation record: the exact facade method plus the parameters it was called with. */ - private static Map apiCall(String method, Map input) { - Map call = new LinkedHashMap<>(); - call.put("method", method); - call.put("input", input); - return call; - } - - /** Negotiation context as report JSON (the same object handed to the generate/validate calls). */ - private static Map contextJson(NegotiationContext context) { - Map json = new LinkedHashMap<>(); - json.put("id", context.id()); - json.put("round", context.round()); - json.put("maxRounds", context.maxRounds()); - json.put("performative", context.performative().name()); - return json; - } - - private static Map stepOf(String name, String role, Map validation) { - Map step = step(name, role); - step.put("validation", validation); - return step; - } - - private static Map validationJson( - String outcome, - String errorCode, - List slotErrors, - FilledParamData params, - Set missingSlots) { - Map validation = new LinkedHashMap<>(); - validation.put("outcome", outcome); - if (errorCode != null) { - validation.put("error_code", errorCode); - } - if (slotErrors != null && !slotErrors.isEmpty()) { - List> errors = new ArrayList<>(); - for (SlotValidationError slotError : slotErrors) { - errors.add(Map.of( - "slot", nullToEmpty(slotError.slotName()), - "code", nullToEmpty(slotError.code()), - "message", nullToEmpty(slotError.message()))); - } - validation.put("slot_errors", errors); - } - if (params != null) { - validation.put("extracted_params", - params.data() == null ? Map.of() : new LinkedHashMap<>(params.data())); - } - validation.put("missing_slots", missingSlots == null ? List.of() : new ArrayList<>(missingSlots)); - return validation; - } - - private static List stillMissingErrors(Set stillMissing) { - List errors = new ArrayList<>(); - for (String slot : stillMissing) { - errors.add(new SlotValidationError(slot, "slots_still_missing", "slot still missing after the fill round")); - } - return errors; - } - - private static Map errorStep( - String name, String stage, A2ATError error, Map... calls) { - Map step = step(name, "n/a"); - step.put("error", Map.of( - "stage", stage, - "code", error.getCode(), - "message", String.valueOf(error.getMessage()))); - if (calls.length > 0) { - api(step, calls); - } - return step; - } - private static List> itemsJson(List items) { - List> list = new ArrayList<>(); - for (NegotiationItem item : items) { - list.add(Map.of("name", item.name(), "value", nullToEmpty(item.value()))); - } - return list; - } - - // -- negotiation helpers -- - - /** Missing-item hint from the phrasing template ({@code {slot}}/{@code {description}} placeholders). */ - private static String missingHint(Map phrasing, Map properties, String slot) { - String template = phrasing.getOrDefault("missing_item_hint", "{slot}"); - Object description = null; - Object property = properties.get(slot); - if (property instanceof Map slotSchema - && slotSchema.get("description") instanceof String text - && !text.isBlank()) { - description = text; - } - return template.replace("{slot}", slot).replace("{description}", description == null ? "" : String.valueOf(description)); - } - - /** - * Assembles the natural-language propose input for the fromText negotiation channel, mirroring the demo's - * {@code FromTextStrategy}: scenario-configured prefix, numbered item list, relationship sentence. - */ - private static String itemsToProposeText(List items, Map phrasing) { - StringBuilder text = new StringBuilder(phrasing.getOrDefault("from_text_propose_prefix", "")); - appendNumberedItems(text, items); - String relationship = items.size() > 1 ? phrasing.get("propose_relationship") : null; - if (relationship != null && !relationship.isBlank()) { - text.append(relationship); - } - return text.toString(); - } - - /** - * Assembles the natural-language accept input for the fromText negotiation channel: scenario-configured prefix, - * numbered item list, suffix sentence. - */ - private static String itemsToAcceptText(List items, Map phrasing) { - StringBuilder text = new StringBuilder(phrasing.getOrDefault("from_text_accept_prefix", "")); - appendNumberedItems(text, items); - text.append(phrasing.getOrDefault("from_text_accept_suffix", "")); - return text.toString(); - } - - /** Generic numbered-list rule: {@code N. name:value;} per item, value omitted when blank. */ - private static void appendNumberedItems(StringBuilder text, List items) { - for (int i = 0; i < items.size(); i++) { - NegotiationItem item = items.get(i); - text.append(i + 1).append(". ").append(item.name()); - if (item.value() != null && !item.value().isBlank()) { - text.append(":").append(item.value()); - } - text.append(";"); - } - } - - /** - * The client's own structured knowledge for the fill round of a fromText case: the fields its input text - * quoted. The server's round-1 extraction is the fallback when the case carries no client data — a lossy - * fallback, because those params are only produced when round-1 validation passed. - */ - private static Map clientBaseData( - Map testCase, Map extractedRound1) { - Map clientData = asMap(testCase.get("client_data")); - return clientData.isEmpty() ? extractedRound1 : clientData; - } - - /** The supplement values the simulated client supplies, keyed by slot. */ - private static Map fills(Set missingSlots, Map fillValues) { - Map fills = new LinkedHashMap<>(); - for (String slot : missingSlots) { - String value = fillValues.get(slot); - if (value != null && !value.isBlank()) { - fills.put(slot, value); - } - } - return fills; - } - - private static Map mergeFills(Map base, Map override) { - Map merged = new LinkedHashMap<>(base); - merged.putAll(override); - return merged; - } - - private static List filledItems(Map fills) { - List items = new ArrayList<>(); - for (Map.Entry entry : fills.entrySet()) { - items.add(new NegotiationItem(entry.getKey(), entry.getValue())); - } - return items; - } - - /** - * Builds the complete parameter map for the fill round: every slot declared by the task schema carries either the - * round-1 input value or the client's fill value, so the filled Task-T prompt can be regenerated from data. - */ - private static Map mergeInputData( - Map inputData, Map taskSchema, Map fills) { - Map merged = new LinkedHashMap<>(); - Map properties = asMap(taskSchema.get("properties")); - for (String slot : properties.keySet()) { - Object base = inputData.get(slot); - Object value = base == null || (base instanceof String s && s.isBlank()) ? fills.get(slot) : base; - merged.put(slot, value == null ? "" : value); - } - merged.putAll(fills); - return merged; - } - - /** Appends the supplement values to a prompt verbatim; fallback when regeneration is impossible. */ - private static String appendSupplements(String taskPrompt, Map fills) { - StringBuilder filled = new StringBuilder(taskPrompt); - for (Map.Entry entry : fills.entrySet()) { - filled.append(System.lineSeparator()).append(entry.getKey()).append(":").append(entry.getValue()); - } - return filled.toString(); - } - - /** - * Parameter schema for the negotiation-message validations (steps 4/5/7): one property per negotiated slot, - * derived from the task schema so the description and {@code x-a2at-value-constraint} of each slot flow into the - * validation prompt — the extraction surface is exactly the supplemented information, with the same semantic - * constraints the Task-T validation enforces. - */ - private static Map negotiationSchema(Set slots, Map taskSchema) { - Map taskProperties = asMap(taskSchema.get("properties")); - Map properties = new LinkedHashMap<>(); - for (String slot : slots) { - Map property = new LinkedHashMap<>(asMap(taskProperties.get(slot))); - if (property.isEmpty()) { - property.put("type", "string"); - } - properties.put(slot, property); - } - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - schema.put("required", new ArrayList<>(slots)); - return schema; - } - - /** - * Whether every server-extracted fill value matches the value the client sent for that slot. The comparison is - * containment in either direction after whitespace removal: the extracted value may carry the slot's field label - * (e.g. 接入端口名称:P781-…) or only the bare value (e.g. P781-…), both of which faithfully transport the fill. - */ - private static boolean fillValuesMatch(Map extracted, Map fills) { - for (Map.Entry entry : fills.entrySet()) { - Object value = extracted.get(entry.getKey()); - if (value == null) { - return false; - } - String extractedText = normalize(String.valueOf(value)); - String sentText = normalize(entry.getValue()); - if (!extractedText.contains(sentText) && !sentText.contains(extractedText)) { - return false; - } - } - return true; - } - - private static String normalize(String value) { - return value == null ? "" : value.replaceAll("\\s+", ""); - } - - /** Slots whose extracted value is null/blank (negotiation candidates). */ - private static Set blankSlots(FilledParamData params) { - Set blank = new TreeSet<>(); - if (params != null && params.data() != null) { - for (Map.Entry entry : params.data().entrySet()) { - Object value = entry.getValue(); - if (value == null || (value instanceof String s && s.isBlank())) { - blank.add(entry.getKey()); - } - } - } - return blank; - } - - /** - * Restricts missing-slot candidates to the task schema's required slots: an empty optional slot is a valid - * state and never a negotiation trigger — only a required slot that is blank (or flagged by the semantic - * validator) means information the negotiation must ask for. - */ - private static Set requiredMissing(Set candidates, Set requiredSlots) { - Set missing = new TreeSet<>(candidates); - missing.retainAll(requiredSlots); - return missing; - } - - /** Slots reported as errors by the semantic validation. */ - private static Set errorSlots(ContentValidationException error) { - Set slots = new TreeSet<>(); - if (error.errors() != null) { - for (SlotValidationError validationError : error.errors()) { - if (validationError.slotName() != null && !validationError.slotName().isBlank()) { - slots.add(validationError.slotName()); - } - } - } - return slots; - } - - // -- metrics -- - - private static Map metrics(List> cases) { - int total = cases.size(); - int triggerOk = 0; - int slotsOk = 0; - int proposeEvaluated = 0; - int proposeOk = 0; - int fillMatchEvaluated = 0; - int fillMatchOk = 0; - int loopEvaluated = 0; - int loopOk = 0; - int pass = 0; - for (Map record : cases) { - Map verdict = (Map) record.get("verdict"); - if (Boolean.TRUE.equals(verdict.get("trigger_correct"))) { - triggerOk++; - } - if (Boolean.TRUE.equals(verdict.get("slots_correct"))) { - slotsOk++; - } - if (verdict.get("propose_outbound_valid") != null) { - proposeEvaluated++; - if (Boolean.TRUE.equals(verdict.get("propose_outbound_valid"))) { - proposeOk++; - } - } - if (verdict.get("accept_fill_value_match") != null) { - fillMatchEvaluated++; - if (Boolean.TRUE.equals(verdict.get("accept_fill_value_match"))) { - fillMatchOk++; - } - } - if (verdict.get("fill_completes_correct") != null) { - loopEvaluated++; - if (Boolean.TRUE.equals(verdict.get("fill_completes_correct"))) { - loopOk++; - } - } - if (Boolean.TRUE.equals(verdict.get("pass"))) { - pass++; - } - } - Map metrics = new LinkedHashMap<>(); - metrics.put("cases", total); - metrics.put("trigger_accuracy", ratio("negotiation correctly triggered/held", triggerOk, total)); - metrics.put("missing_slot_exact_match", ratio("missing-slot set exact match", slotsOk, total)); - metrics.put("propose_outbound_valid", ratio("generated propose passes outbound validation", proposeOk, - proposeEvaluated)); - metrics.put("accept_fill_value_match", ratio("extracted fill values match client fill", fillMatchOk, - fillMatchEvaluated)); - metrics.put("closed_loop_completion", ratio("closed loop completes as expected", loopOk, loopEvaluated)); - metrics.put("full_pass", ratio("all dimensions pass", pass, total)); - return metrics; - } - - private static Map ratio(String label, int part, int total) { - Map ratio = new LinkedHashMap<>(); - ratio.put("label", label); - ratio.put("passed", part); - ratio.put("total", total); - ratio.put("pct", total == 0 ? 0.0 : round1(100.0 * part / total)); - return ratio; - } - - // -- suite loading and small utilities -- - - private static Map loadSuite() { - try (InputStream stream = NegotiationEvalApp.class - .getClassLoader() - .getResourceAsStream("sample/negotiation/eval/eval-suite.json")) { - if (stream == null) { - throw new IllegalStateException("Eval suite resource not found"); - } - return MAPPER.readValue(stream, new TypeReference>() {}); - } catch (IOException exception) { - throw new UncheckedIOException("Failed to read eval suite", exception); - } - } - - /** LLM run configuration from the env file, without the API key. */ - private static Map llmInfo(Path envPath) { - Map values = readEnv(envPath); - Map info = new LinkedHashMap<>(); - info.put("provider", values.getOrDefault("A2AT_LLM_PROVIDER", "")); - info.put("model", values.getOrDefault("A2AT_LLM_MODEL", "")); - info.put("base_url", values.getOrDefault("A2AT_LLM_BASE_URL", "")); - return info; - } - - private static Map readEnv(Path envPath) { - Map values = new LinkedHashMap<>(); - if (envPath == null || !Files.exists(envPath)) { - return values; - } - try { - for (String rawLine : Files.readAllLines(envPath)) { - String line = rawLine.trim(); - if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { - continue; - } - int separator = line.indexOf('='); - values.put(line.substring(0, separator).trim(), line.substring(separator + 1).trim()); - } - } catch (IOException exception) { - throw new UncheckedIOException("Failed to read env file: " + envPath, exception); - } - return values; - } - - private static void writeReport(Map report, Path outPath) { - try { - MAPPER.writeValue(outPath.toFile(), report); - } catch (IOException exception) { - throw new UncheckedIOException("Failed to write eval report: " + outPath, exception); - } - } - - private static int countCases(Map suite, List filter, String channelFilter) { - return (int) cases(suite).stream() - .filter(testCase -> filter.isEmpty() - || filter.contains(String.valueOf(testCase.get("id")))) - .filter(testCase -> channelFilter == null - || channelFilter.equals(String.valueOf(testCase.get("channel")))) - .count(); - } - - private static TemplateUri parseTemplate(String templateUri) { - return TemplateUri.parse(templateUri) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); - } - - /** Negotiation template URIs used by the closed loop; kept local so the evaluator stays self-contained. */ - private static final class DemoTemplates { - - static final TemplateUri NEGOTIATION_PROPOSE = parseTemplate("Negotiation-T/information-negotiation/propose/v1"); - - static final TemplateUri NEGOTIATION_ACCEPT = - parseTemplate("Negotiation-T/information-negotiation/accept-reject/v1"); - - private DemoTemplates() {} - } - - @SuppressWarnings("unchecked") - private static List> cases(Map suite) { - Object raw = suite.get("cases"); - return raw instanceof List list ? (List>) raw : List.of(); - } - - @SuppressWarnings("unchecked") - private static Map asMap(Object value) { - return value instanceof Map map ? (Map) map : Map.of(); - } - - private static Map stringMap(Object value) { - Map result = new LinkedHashMap<>(); - if (value instanceof Map map) { - map.forEach((key, val) -> result.put(String.valueOf(key), String.valueOf(val))); - } - return result; - } - - private static List stringList(Object value) { - if (value instanceof List list) { - return list.stream().map(String::valueOf).toList(); - } - return List.of(); - } - - private static String nullToEmpty(Object value) { - return value == null ? "" : String.valueOf(value); - } - - private static double round1(double value) { - return Math.round(value * 10.0) / 10.0; - } - - private static String secs(long startNanos) { - return String.valueOf(round1((System.nanoTime() - startNanos) / 1_000_000_000.0)); - } - - private static void emit(String message) { - System.out.println(message); - } -} +package net.openan.a2at.sample.negotiation.eval; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.server.A2ATServer; + +/** + * Closed-loop evaluator for the negotiation prompt interfaces of the private-line-complaint scenario. + * + *

        Each case of the bundled suite ({@code sample/negotiation/eval/eval-suite.json}) is driven through the negotiation + * interfaces in message order, exactly the way the negotiation demo wires them: + * + *

          + *
        1. Task-T generation — the client value from the Task-T extension metadata is either structured JSON + * ({@code generateTaskPromptFromDataWithSchema}: every data key maps to one slot value, the schema is passed as a + * call parameter) or natural language ({@code generateTaskPromptFromText}, LLM slot extraction); + *
        2. server validation — {@code validateTaskPromptAndDataFilling} extracts the parameters against the passed JSON + * schema; the missing-slot set (blank slots plus semantic {@code content.param_missing} errors) decides whether + * negotiation is triggered; + *
        3. propose generation — for the detected missing slots, {@code generateNegotiationProposePromptFromData} / + * {@code FromText} renders the Negotiation-T information-propose message; + *
        4. outbound propose validation — the server self-checks the generated propose via + * {@code validateProposePromptAndDataFilling} before sending it (expected to pass); + *
        5. inbound propose validation — the client validates the received propose through the symmetric client-side API + * {@code validateProposePromptAndDataFilling} and reads out which slots it is asked to supplement; the fill round + * is driven by this client-side view; + *
        6. client fill + accept — the client merges the round-1 parameters with its fill values, RE-RENDERS the full + * Task-T prompt from the complete parameter map (there is no incremental merge API; the supplement is + * incorporated by re-generating the whole prompt) and renders the Negotiation-T accept; + *
        7. inbound accept validation — {@code validateAcceptPromptAndDataFilling} checks the accept message and extracts + * the supplemented values (expected to pass with the values the client sent); + *
        8. second Task-T validation — {@code validateTaskPromptAndDataFilling} re-validates the filled Task-T prompt; no + * missing slot means the negotiation completed the flow (COMPLETED), any rejection keeps it in INPUT_REQUIRED. + *
        + * + *

        Every intermediate artifact is recorded per case in a JSON report: the generated Task-T prompt, both negotiation + * messages, each validation verdict with extracted parameters and per-slot errors, the client's fill values — and, for + * every SDK call, an {@code api} entry carrying the exact method name plus its input parameters (including the full + * JSON schema passed to generate/validate), so a reviewer can verify from the report alone which interface ran with + * which arguments. A single case can be replayed and audited without re-running it. The report is rewritten after every + * case, so an interrupted run keeps the cases already finished. + * + *

        Usage: + * + *

        {@code
        + * java @a2a-t-sample/target/eval.javaargs.txt [--out eval-report.json] [--case PLC-04] /path/to/.env
        + * }
        + * + * @since 2026-08 + */ +public final class NegotiationEvalApp { + + private static final ObjectMapper MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private NegotiationEvalApp() {} + + /** + * Entry point. + * + * @param args options ({@code --out } report file, {@code --case } repeatable case filter), then the + * {@code .env} path carrying a real LLM API key + */ + public static void main(String[] args) { + Path envPath = null; + Path outPath = Path.of("eval-report.json"); + List caseFilter = new ArrayList<>(); + String negotiationChannelOverride = null; + String channelFilter = null; + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + if ("--out".equals(arg) && i + 1 < args.length) { + outPath = Path.of(args[++i]); + } else if ("--case".equals(arg) && i + 1 < args.length) { + caseFilter.add(args[++i]); + } else if ("--negotiation-channel".equals(arg) && i + 1 < args.length) { + negotiationChannelOverride = args[++i]; + } else if ("--channel".equals(arg) && i + 1 < args.length) { + channelFilter = args[++i]; + } else if (!arg.startsWith("--")) { + envPath = Path.of(arg); + } + } + if (envPath == null) { + System.err.println( + "Usage: java @a2a-t-sample/target/eval.javaargs.txt [--out eval-report.json] [--case PLC-04]" + + " [--channel fromData|fromText] [--negotiation-channel fromData|fromText]" + + " /path/to/.env"); + System.exit(1); + } + + EvalLlmCaptureClient.install(); + Map suite = loadSuite(); + if (negotiationChannelOverride != null) { + // run the whole suite with one negotiation generation channel without duplicating cases + for (Map testCase : cases(suite)) { + testCase.put("negotiation_channel", negotiationChannelOverride); + } + } + + Map report = new LinkedHashMap<>(); + report.put("suite", suite.get("suite")); + report.put("description", suite.get("description")); + report.put("scenario", suite.get("scenario")); + report.put("generated_at", LocalDateTime.now().format(TIMESTAMP)); + report.put("llm", llmInfo(envPath)); + report.put("case_filter", caseFilter); + report.put("channel_filter", channelFilter == null ? "all" : channelFilter); + report.put("negotiation_channel", negotiationChannelOverride == null ? "per-case" : negotiationChannelOverride); + + List> cases = new ArrayList<>(); + report.put("cases", cases); + int index = 0; + int total = countCases(suite, caseFilter, channelFilter); + for (Map testCase : cases(suite)) { + String caseId = String.valueOf(testCase.get("id")); + if (!caseFilter.isEmpty() && !caseFilter.contains(caseId)) { + continue; + } + // evaluate one task-generation channel in isolation: only its cases run, so fromData and + // fromText can be measured (and reported) separately + if (channelFilter != null && !channelFilter.equals(String.valueOf(testCase.get("channel")))) { + continue; + } + index++; + emit("\n[eval] ===== [" + index + "/" + total + "] case " + caseId + ": " + testCase.get("intent") + + " ====="); + long startNanos = System.nanoTime(); + Map record = runCase(envPath, suite, testCase); + record.put("duration_seconds", round1((System.nanoTime() - startNanos) / 1_000_000_000.0)); + cases.add(record); + report.put("metrics", metrics(cases)); + writeReport(report, outPath); + } + + report.put("metrics", metrics(cases)); + writeReport(report, outPath); + Map metrics = metrics(cases); + emit("\n[eval] ===== Negotiation interface correctness metrics ====="); + emit("[eval] cases: " + metrics.get("cases")); + for (String key : List.of( + "trigger_accuracy", + "missing_slot_exact_match", + "propose_outbound_valid", + "accept_fill_value_match", + "closed_loop_completion")) { + emit("[eval] " + metrics.get(key)); + } + emit("[eval] report written to " + outPath.toAbsolutePath()); + } + + // -- one case: the negotiation interface closed loop with full evidence capture -- + + /** Runs one case end to end and returns its replayable trace record. */ + private static Map runCase(Path envPath, Map suite, Map testCase) { + EvalLlmCaptureClient.reset(); + String caseId = String.valueOf(testCase.get("id")); + String channel = String.valueOf(testCase.get("channel")); + boolean fromText = "fromText".equals(channel); + String negotiationChannel = String.valueOf(testCase.getOrDefault("negotiation_channel", "fromData")); + boolean negotiationFromText = "fromText".equals(negotiationChannel); + String inputText = String.valueOf(testCase.get("input_text")); + Map inputData = asMap(testCase.get("input_data")); + + TemplateUri taskTemplate = parseTemplate(String.valueOf(suite.get("template_uri"))); + Map taskSchema = asMap(suite.get("task_schema")); + Map phrasing = stringMap(suite.get("negotiation_phrasing")); + Map properties = asMap(taskSchema.get("properties")); + Set requiredSlots = new TreeSet<>(stringList(taskSchema.get("required"))); + Map fillValues = + mergeFills(stringMap(suite.get("client_fill_values")), stringMap(testCase.get("fill_override"))); + + Map expect = asMap(testCase.get("expect")); + boolean expectNegotiation = Boolean.TRUE.equals(expect.get("negotiation_needed")); + Set expectMissing = new TreeSet<>(stringList(expect.get("missing_slots"))); + Boolean expectFillCompletes = (Boolean) expect.get("fill_completes"); + boolean expectGenerationFails = Boolean.TRUE.equals(expect.get("generation_fails")); + + Map record = new LinkedHashMap<>(); + record.put("case", caseId); + record.put("channel", channel); + record.put("negotiation_channel", negotiationChannel); + record.put("intent", testCase.get("intent")); + Map input = new LinkedHashMap<>(); + input.put("fromText", fromText); + if (fromText) { + input.put("text", inputText); + } else { + input.put("data", inputData); + } + record.put("input", input); + record.put("task_schema", taskSchema); + Map expectJson = new LinkedHashMap<>(); + expectJson.put("negotiation_needed", expectNegotiation); + expectJson.put("missing_slots", new ArrayList<>(expectMissing)); + expectJson.put("fill_completes", expectFillCompletes); + expectJson.put("generation_fails", expectGenerationFails); + record.put("expect", expectJson); + List> steps = new ArrayList<>(); + record.put("steps", steps); + + // -- step 1: Task-T prompt generation from the extension metadata value -- + Map taskGenerationInput = new LinkedHashMap<>(); + if (fromText) { + taskGenerationInput.put("text", inputText); + } else { + taskGenerationInput.put("data", inputData); + taskGenerationInput.put("schema", taskSchema); + } + taskGenerationInput.put("template_uri", taskTemplate.uri()); + String taskGenerationApi = + fromText ? "A2ATClient.generateTaskPromptFromText" : "A2ATClient.generateTaskPromptFromDataWithSchema"; + String taskPrompt; + try { + long nanos = System.nanoTime(); + A2ATClient client = new A2ATClient(envPath); + MetadataContent taskContent = fromText + ? client.generateTaskPromptFromText(inputText, taskTemplate) + : client.generateTaskPromptFromDataWithSchema(inputData, taskSchema, taskTemplate); + taskPrompt = taskContent.promptText(); + Map step = step("1. Task-T generation (" + channel + ")", "client"); + api(step, apiCall(taskGenerationApi, taskGenerationInput)); + step.put("generated_prompt", taskPrompt); + step.put("template_uri", taskContent.templateUri()); + steps.add(step); + emit("[eval] [1] Task-T prompt generated (" + channel + ", " + secs(nanos) + "s)"); + } catch (A2ATError error) { + steps.add(errorStep( + "1. Task-T generation (" + channel + ")", + "task_generation", + error, + apiCall(taskGenerationApi, taskGenerationInput))); + emit("[eval] [1] Task-T generation FAILED: " + error.getCode() + " " + error.getMessage()); + if (expectGenerationFails) { + // generation-time fail-fast is the expected outcome (e.g. a required slot missing from the + // natural-language input): the task never exists, so negotiation cannot and must not trigger + Map verdict = new LinkedHashMap<>(); + verdict.put("trigger_correct", !expectNegotiation); + verdict.put("slots_correct", expectMissing.isEmpty()); + verdict.put("propose_outbound_valid", null); + verdict.put("accept_fill_value_match", null); + verdict.put("fill_completes_correct", null); + verdict.put("pass", !expectNegotiation && expectMissing.isEmpty()); + verdict.put( + "reason", "generation failed fast as expected: " + error.getCode() + " " + error.getMessage()); + record.put("verdict", verdict); + emit("[eval] verdict: PASS (generation fail-fast as expected)"); + return record; + } + return finish( + record, steps, null, null, null, null, null, expectNegotiation, expectMissing, expectFillCompletes); + } + + // -- step 2: server validation of the Task-T prompt -> missing-slot detection -- + Map taskValidationInput = Map.of( + "prompt", taskPrompt, + "schema", taskSchema, + "template_uri", taskTemplate.uri()); + Set actualMissing; + Map extractedRound1 = new LinkedHashMap<>(); + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData params = server.validateTaskPromptAndDataFilling(taskPrompt, taskSchema, taskTemplate); + actualMissing = requiredMissing(blankSlots(params), requiredSlots); + if (params.data() != null) { + extractedRound1.putAll(params.data()); + } + Map step = stepOf( + "2. server Task-T validation (missing-slot detection)", + "server", + validationJson("passed", null, null, params, actualMissing)); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", taskValidationInput)); + steps.add(step); + emit("[eval] [2] validation passed, missing slots: " + actualMissing + " (" + secs(nanos) + "s)"); + } catch (ContentValidationException error) { + actualMissing = requiredMissing(errorSlots(error), requiredSlots); + Map step = stepOf( + "2. server Task-T validation (missing-slot detection)", + "server", + validationJson("rejected", error.getCode(), error.errors(), null, actualMissing)); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", taskValidationInput)); + steps.add(step); + emit("[eval] [2] validation rejected (" + error.getCode() + "), missing slots: " + actualMissing); + } + + boolean negotiationTriggered = !actualMissing.isEmpty(); + if (!negotiationTriggered) { + Map done = step("3. no negotiation needed -> flow completes directly", "server"); + done.put("task_state", "COMPLETED"); + steps.add(done); + emit("[eval] [3] no missing parameters -> COMPLETED without negotiation"); + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + null, + null, + null, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + + // -- step 3: Negotiation-T propose generation from the detected missing slots -- + List missingItems = new ArrayList<>(); + for (String slot : actualMissing) { + missingItems.add(new NegotiationItem(slot, missingHint(phrasing, properties, slot))); + } + String proposeRelationship = missingItems.size() > 1 ? phrasing.get("propose_relationship") : null; + NegotiationContext proposeContext = new NegotiationContext( + UUID.randomUUID().toString(), + 1, + NegotiationContext.DEFAULT_MAX_ROUNDS, + NegotiationPerformative.PROPOSE); + String proposeInputText = itemsToProposeText(missingItems, phrasing); + Map proposeGenerationInput = new LinkedHashMap<>(); + if (negotiationFromText) { + proposeGenerationInput.put("text", proposeInputText); + proposeGenerationInput.put("context", contextJson(proposeContext)); + } else { + Map proposeData = new LinkedHashMap<>(); + proposeData.put("context", contextJson(proposeContext)); + proposeData.put("items", itemsJson(missingItems)); + if (proposeRelationship != null) { + proposeData.put("relationship", proposeRelationship); + } + proposeGenerationInput.put("data", proposeData); + } + proposeGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()); + String proposeGenerationApi = negotiationFromText + ? "A2ATServer.generateNegotiationProposePromptFromText" + : "A2ATServer.generateNegotiationProposePromptFromData"; + String proposePrompt; + try { + A2ATServer server = new A2ATServer(envPath); + MetadataContent propose = negotiationFromText + ? server.generateNegotiationProposePromptFromText( + proposeInputText, proposeContext, DemoTemplates.NEGOTIATION_PROPOSE) + : server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + proposeContext, new InformationProposeContent(missingItems, proposeRelationship)), + DemoTemplates.NEGOTIATION_PROPOSE); + proposePrompt = propose.promptText(); + Map step = + step("3. Negotiation-T propose generation (" + negotiationChannel + ")", "server"); + api(step, apiCall(proposeGenerationApi, proposeGenerationInput)); + step.put("negotiation_items", itemsJson(missingItems)); + if (negotiationFromText) { + step.put("input_text", proposeInputText); + } + step.put("generated_prompt", proposePrompt); + step.put("template_uri", propose.templateUri()); + step.put("task_state", "INPUT_REQUIRED"); + steps.add(step); + emit("[eval] [3] propose rendered for " + actualMissing + " (" + negotiationChannel + + ") -> INPUT_REQUIRED"); + } catch (A2ATError error) { + steps.add(errorStep( + "3. Negotiation-T propose generation (" + negotiationChannel + ")", + "propose_generation", + error, + apiCall(proposeGenerationApi, proposeGenerationInput))); + emit("[eval] [3] propose generation FAILED: " + error.getCode() + " " + error.getMessage()); + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + null, + null, + null, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + + // -- step 4: outbound validation of the generated propose -- + Map proposeValidationInput = Map.of( + "prompt", proposePrompt, + "context", contextJson(proposeContext), + "schema", negotiationSchema(actualMissing, taskSchema), + "template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()); + Boolean proposeValid = null; + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData proposeParams = server.validateProposePromptAndDataFilling( + proposePrompt, + proposeContext, + negotiationSchema(actualMissing, taskSchema), + DemoTemplates.NEGOTIATION_PROPOSE); + proposeValid = true; + Map step = stepOf( + "4. outbound propose validation", + "server", + validationJson("passed", null, null, proposeParams, Set.of())); + api(step, apiCall("A2ATServer.validateProposePromptAndDataFilling", proposeValidationInput)); + steps.add(step); + emit("[eval] [4] outbound propose validation passed (" + secs(nanos) + "s)"); + } catch (A2ATError error) { + proposeValid = false; + List slotErrors = error + instanceof + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException + extraction + ? extraction.getErrors() + : List.of(); + Map step = stepOf( + "4. outbound propose validation", + "server", + validationJson("rejected", error.getCode(), slotErrors, null, Set.of())); + api(step, apiCall("A2ATServer.validateProposePromptAndDataFilling", proposeValidationInput)); + steps.add(step); + emit("[eval] [4] outbound propose validation REJECTED: " + error.getCode() + " " + error.getMessage()); + } + + // -- step 5: client validates the INBOUND propose and reads out the requested slots -- + // The SDK exposes the validate* API symmetrically on client and server: the receiving client re-validates + // the negotiation message it got and extracts which slots the server asked it to supplement. The fill round + // below is driven by this client-side view (not by the server's internal missing-slot set), so the report + // shows what a real client would learn from the propose message alone. + Set clientRequestedSlots = new TreeSet<>(); + try { + long nanos = System.nanoTime(); + A2ATClient client = new A2ATClient(envPath); + FilledParamData requested = client.validateProposePromptAndDataFilling( + proposePrompt, + proposeContext, + negotiationSchema(actualMissing, taskSchema), + DemoTemplates.NEGOTIATION_PROPOSE); + if (requested.data() != null) { + for (String key : requested.data().keySet()) { + // the SDK merges the negotiation context (id/round/maxRounds) into the extracted data; only the + // real slot names tell the client what to supplement + if (!"id".equals(key) && !"round".equals(key) && !"maxRounds".equals(key)) { + clientRequestedSlots.add(key); + } + } + } + Map step = stepOf( + "5. client inbound propose validation (requested-slot extraction)", + "client", + validationJson("passed", null, null, requested, Set.of())); + api( + step, + apiCall( + "A2ATClient.validateProposePromptAndDataFilling", + Map.of( + "prompt", proposePrompt, + "context", contextJson(proposeContext), + "schema", negotiationSchema(actualMissing, taskSchema), + "template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()))); + step.put("client_requested_slots", new ArrayList<>(clientRequestedSlots)); + steps.add(step); + emit("[eval] [5] client extracted requested slots: " + clientRequestedSlots + " (" + secs(nanos) + "s)"); + } catch (A2ATError error) { + List slotErrors = error + instanceof + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException + extraction + ? extraction.getErrors() + : List.of(); + Map step = stepOf( + "5. client inbound propose validation (requested-slot extraction)", + "client", + validationJson("rejected", error.getCode(), slotErrors, null, Set.of())); + api( + step, + apiCall( + "A2ATClient.validateProposePromptAndDataFilling", + Map.of( + "prompt", proposePrompt, + "context", contextJson(proposeContext), + "schema", negotiationSchema(actualMissing, taskSchema), + "template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()))); + step.put("client_requested_slots", List.of()); + steps.add(step); + emit("[eval] [5] client propose validation REJECTED: " + error.getCode() + " " + error.getMessage() + + " -> falling back to the server-side missing-slot set for the fill"); + } + Set slotsToFill = clientRequestedSlots.isEmpty() ? actualMissing : clientRequestedSlots; + + // -- step 6: client fills the requested slots and renders the Negotiation-T accept -- + // the filled Task-T prompt is RE-GENERATED from the complete parameter map, exactly like the demo renders + // message 3's Task-T from the filled params — not text-appended. There is no incremental merge API: the + // client holds the full parameter set (the fromData input, or for fromText the parameters the server + // extracted in round 1), completed with the fill values for everything still missing, and re-renders the + // whole Task-T prompt from that map. + Map fills = fills(slotsToFill, fillValues); + // the fill round re-renders from the CLIENT's own knowledge: for fromData the structured input, for + // fromText the client_data the case carries (what its text quoted) — falling back to the server's + // round-1 extraction, which is only available when that validation passed + Map baseData = fromText ? clientBaseData(testCase, extractedRound1) : inputData; + // only the negotiated slots' fill values enter the merge: round-1 base values are preserved verbatim, + // so the re-rendered prompt carries exactly what the client knew plus what it supplemented + Map filledData = mergeInputData(baseData, taskSchema, fills); + String filledPrompt; + Map filledGenerationInput = Map.of( + "data", filledData, + "schema", taskSchema, + "template_uri", taskTemplate.uri()); + try { + A2ATClient client = new A2ATClient(envPath); + filledPrompt = client.generateTaskPromptFromDataWithSchema(filledData, taskSchema, taskTemplate) + .promptText(); + } catch (A2ATError error) { + // a fill value violates a slot constraint: fromData generation fails fast — the closed loop cannot + // complete, which is the expected negative outcome for constraint-violating fills + filledPrompt = appendSupplements(taskPrompt, fills); + Map step = step("6. client fill + Negotiation-T accept generation (fromData)", "client"); + api(step, apiCall("A2ATClient.generateTaskPromptFromDataWithSchema", filledGenerationInput)); + step.put("client_fill", fills); + step.put( + "fill_generation_rejected", + Map.of( + "code", error.getCode(), + "message", String.valueOf(error.getMessage()))); + steps.add(step); + emit("[eval] [6] filled Task-T generation REJECTED (" + error.getCode() + "): " + error.getMessage()); + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + proposeValid, + null, + false, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + String acceptPrompt; + NegotiationContext acceptContext = new NegotiationContext( + UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.ACCEPT); + String acceptInputText = itemsToAcceptText(filledItems(fills), phrasing); + Map acceptGenerationInput = new LinkedHashMap<>(); + if (negotiationFromText) { + acceptGenerationInput.put("text", acceptInputText); + acceptGenerationInput.put("context", contextJson(acceptContext)); + } else { + acceptGenerationInput.put( + "data", + Map.of( + "context", contextJson(acceptContext), + "conclusion", NegotiationConclusion.ACCEPT.toString(), + "items", itemsJson(filledItems(fills)))); + } + acceptGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_ACCEPT.uri()); + String acceptGenerationApi = negotiationFromText + ? "A2ATClient.generateNegotiationAcceptPromptFromText" + : "A2ATClient.generateNegotiationAcceptPromptFromData"; + try { + A2ATClient client = new A2ATClient(envPath); + List filledItems = filledItems(fills); + MetadataContent accept = negotiationFromText + ? client.generateNegotiationAcceptPromptFromText( + acceptInputText, acceptContext, DemoTemplates.NEGOTIATION_ACCEPT) + : client.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData( + acceptContext, + new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems)), + DemoTemplates.NEGOTIATION_ACCEPT); + acceptPrompt = accept.promptText(); + Map step = + step("6. client fill + Negotiation-T accept generation (" + negotiationChannel + ")", "client"); + api( + step, + apiCall("A2ATClient.generateTaskPromptFromDataWithSchema", filledGenerationInput), + apiCall(acceptGenerationApi, acceptGenerationInput)); + step.put("client_fill", fills); + step.put("filled_task_prompt", filledPrompt); + step.put("filled_params", filledData); + if (negotiationFromText) { + step.put("input_text", acceptInputText); + } + step.put("generated_prompt", acceptPrompt); + step.put("template_uri", accept.templateUri()); + steps.add(step); + emit("[eval] [6] client fills " + fills.keySet() + ", accept rendered (" + negotiationChannel + ")"); + } catch (A2ATError error) { + steps.add(errorStep( + "6. client fill + Negotiation-T accept generation (" + negotiationChannel + ")", + "accept_generation", + error, + apiCall(acceptGenerationApi, acceptGenerationInput))); + emit("[eval] [6] accept generation FAILED: " + error.getCode() + " " + error.getMessage()); + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + proposeValid, + null, + null, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + + // -- step 7: inbound validation of the accept message; extracted values must match the client fill -- + Map acceptValidationInput = Map.of( + "prompt", acceptPrompt, + "context", contextJson(acceptContext), + "schema", negotiationSchema(slotsToFill, taskSchema), + "template_uri", DemoTemplates.NEGOTIATION_ACCEPT.uri()); + Boolean acceptValid = null; + Boolean fillValueMatch = null; + Map extractedAcceptParams = null; + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( + acceptPrompt, + acceptContext, + negotiationSchema(slotsToFill, taskSchema), + DemoTemplates.NEGOTIATION_ACCEPT); + acceptValid = true; + extractedAcceptParams = acceptParams.data() == null ? Map.of() : new LinkedHashMap<>(acceptParams.data()); + fillValueMatch = fillValuesMatch(extractedAcceptParams, fills); + Map validation = validationJson("passed", null, null, acceptParams, Set.of()); + validation.put("expected_fill", fills); + validation.put("fill_value_match", fillValueMatch); + Map step = stepOf("7. inbound accept validation", "server", validation); + api(step, apiCall("A2ATServer.validateAcceptPromptAndDataFilling", acceptValidationInput)); + steps.add(step); + emit("[eval] [7] accept validation passed, fill value match: " + fillValueMatch + " (" + secs(nanos) + + "s)"); + } catch (A2ATError error) { + acceptValid = false; + fillValueMatch = false; + List slotErrors = error + instanceof + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException + extraction + ? extraction.getErrors() + : List.of(); + Map validation = validationJson("rejected", error.getCode(), slotErrors, null, Set.of()); + validation.put("expected_fill", fills); + validation.put("fill_value_match", false); + Map step = stepOf("7. inbound accept validation", "server", validation); + api(step, apiCall("A2ATServer.validateAcceptPromptAndDataFilling", acceptValidationInput)); + steps.add(step); + emit("[eval] [7] accept validation REJECTED: " + error.getCode() + " " + error.getMessage()); + } + + // -- step 8: second Task-T validation of the filled prompt -> COMPLETED or stays INPUT_REQUIRED -- + Map secondValidationInput = Map.of( + "prompt", filledPrompt, + "schema", taskSchema, + "template_uri", taskTemplate.uri()); + Boolean fillCompleted = null; + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData filled = server.validateTaskPromptAndDataFilling(filledPrompt, taskSchema, taskTemplate); + Set stillMissing = requiredMissing(blankSlots(filled), requiredSlots); + fillCompleted = stillMissing.isEmpty(); + Map step = stepOf( + "8. second Task-T validation (fill round)", + "server", + validationJson( + fillCompleted ? "passed" : "rejected", + fillCompleted ? null : "slots_still_missing", + fillCompleted ? null : stillMissingErrors(stillMissing), + filled, + stillMissing)); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", secondValidationInput)); + step.put("task_state", fillCompleted ? "COMPLETED" : "INPUT_REQUIRED"); + steps.add(step); + emit("[eval] [8] second Task-T validation " + + (fillCompleted ? "-> COMPLETED" : "still missing: " + stillMissing) + " (" + secs(nanos) + "s)"); + } catch (ContentValidationException error) { + fillCompleted = false; + Map step = stepOf( + "8. second Task-T validation (fill round)", + "server", + validationJson("rejected", error.getCode(), error.errors(), null, errorSlots(error))); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", secondValidationInput)); + steps.add(step); + emit("[eval] [8] second Task-T validation REJECTED (" + error.getCode() + "): " + errorSlots(error)); + } + + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + proposeValid, + fillValueMatch, + fillCompleted, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + + // -- record assembly -- + + private static Map finish( + Map record, + List> steps, + Boolean triggered, + Set actualMissing, + Boolean proposeValid, + Boolean fillValueMatch, + Boolean fillCompleted, + boolean expectNegotiation, + Set expectMissing, + Boolean expectFillCompletes) { + boolean error = triggered == null; + boolean triggerCorrect = !error && triggered == expectNegotiation; + boolean slotsCorrect = !error && actualMissing != null && actualMissing.equals(expectMissing); + Boolean proposeValidCorrect = error || proposeValid == null ? null : proposeValid; + // value match is only meaningful for cases whose fill round is expected to succeed; a negative fill case + // (expectFillCompletes=false) is judged by the fill round NOT completing — the accept validation rejecting + // the constraint-violating fill value is exactly the expected outcome, not a mismatch + Boolean fillMatchCorrect = + error || fillValueMatch == null || Boolean.FALSE.equals(expectFillCompletes) ? null : fillValueMatch; + Boolean fillCorrect; + if (error) { + fillCorrect = false; + } else if (expectFillCompletes == null) { + fillCorrect = null; + } else if (Boolean.TRUE.equals(triggered)) { + fillCorrect = expectFillCompletes.equals(fillCompleted); + } else { + fillCorrect = !expectFillCompletes; + } + boolean pass = triggerCorrect + && slotsCorrect + && (proposeValidCorrect == null || proposeValidCorrect) + && (fillMatchCorrect == null || fillMatchCorrect) + && (fillCorrect == null || fillCorrect); + + Map actual = new LinkedHashMap<>(); + actual.put("negotiation_triggered", triggered); + actual.put("missing_slots", actualMissing == null ? null : new ArrayList<>(actualMissing)); + actual.put("propose_outbound_valid", proposeValid); + actual.put("accept_fill_value_match", fillValueMatch); + actual.put("closed_loop_completed", fillCompleted); + record.put("actual", actual); + + Map verdict = new LinkedHashMap<>(); + verdict.put("trigger_correct", triggerCorrect); + verdict.put("slots_correct", slotsCorrect); + verdict.put("propose_outbound_valid", proposeValidCorrect); + verdict.put("accept_fill_value_match", fillMatchCorrect); + verdict.put("fill_completes_correct", fillCorrect); + verdict.put("pass", pass); + verdict.put( + "reason", + reason( + error, + triggerCorrect, + slotsCorrect, + fillMatchCorrect, + fillCorrect, + actualMissing, + expectMissing, + triggered, + expectNegotiation, + proposeValid)); + record.put("verdict", verdict); + + emit("[eval] verdict: " + (pass ? "PASS" : "FAIL") + + " | trigger=" + triggerCorrect + " slots=" + slotsCorrect + + " proposeValid=" + proposeValidCorrect + " fillMatch=" + fillMatchCorrect + " fill=" + fillCorrect); + return record; + } + + private static String reason( + boolean error, + boolean triggerCorrect, + boolean slotsCorrect, + Boolean fillMatchCorrect, + Boolean fillCorrect, + Set actualMissing, + Set expectMissing, + Boolean triggered, + boolean expectNegotiation, + Boolean proposeValid) { + if (error) { + return "evaluation aborted by an error (see steps)"; + } + List notes = new ArrayList<>(); + if (!triggerCorrect) { + notes.add("negotiation expected=" + expectNegotiation + " actual=" + triggered); + } + if (!slotsCorrect && actualMissing != null) { + Set missed = new TreeSet<>(expectMissing); + missed.removeAll(actualMissing); + Set extra = new TreeSet<>(actualMissing); + extra.removeAll(expectMissing); + notes.add("missing-slot set mismatch: expected=" + expectMissing + " actual=" + actualMissing + + (missed.isEmpty() ? "" : " (not reported: " + missed + ")") + + (extra.isEmpty() ? "" : " (unexpected: " + extra + ")")); + } + if (proposeValid != null && !proposeValid) { + notes.add("generated propose failed outbound validation (step 4)"); + } + if (fillMatchCorrect != null && !fillMatchCorrect) { + notes.add("server-side extracted fill values differ from what the client sent (step 7)"); + } + if (fillCorrect != null && !fillCorrect) { + notes.add("fill round did not complete as expected (step 8)"); + } + return String.join("; ", notes); + } + + // -- trace step builders -- + + private static Map step(String name, String role) { + Map step = new LinkedHashMap<>(); + step.put("step", name); + step.put("role", role); + attachLlmCalls(step); + return step; + } + + /** + * Attaches the LLM calls recorded since the previous step was built: the exact messages (system + user prompt) and + * JSON schema fed to the model, plus the response or the failure. When a case fails, this is what makes the cause + * traceable — the recorded prompt shows which slot description, constraint or system instruction the model actually + * saw, so the prompt resources can be tuned in reverse. + */ + private static void attachLlmCalls(Map step) { + List> calls = EvalLlmCaptureClient.drain(); + if (!calls.isEmpty()) { + step.put("llm_calls", calls); + } + } + + /** + * Attaches the SDK API call evidence (method name + input parameters) to a step; one step may make several calls. + */ + @SafeVarargs + private static void api(Map step, Map... calls) { + step.put("api_calls", List.of(calls)); + } + + /** One SDK API invocation record: the exact facade method plus the parameters it was called with. */ + private static Map apiCall(String method, Map input) { + Map call = new LinkedHashMap<>(); + call.put("method", method); + call.put("input", input); + return call; + } + + /** Negotiation context as report JSON (the same object handed to the generate/validate calls). */ + private static Map contextJson(NegotiationContext context) { + Map json = new LinkedHashMap<>(); + json.put("id", context.id()); + json.put("round", context.round()); + json.put("maxRounds", context.maxRounds()); + json.put("performative", context.performative().name()); + return json; + } + + private static Map stepOf(String name, String role, Map validation) { + Map step = step(name, role); + step.put("validation", validation); + return step; + } + + private static Map validationJson( + String outcome, + String errorCode, + List slotErrors, + FilledParamData params, + Set missingSlots) { + Map validation = new LinkedHashMap<>(); + validation.put("outcome", outcome); + if (errorCode != null) { + validation.put("error_code", errorCode); + } + if (slotErrors != null && !slotErrors.isEmpty()) { + List> errors = new ArrayList<>(); + for (SlotValidationError slotError : slotErrors) { + errors.add(Map.of( + "slot", nullToEmpty(slotError.slotName()), + "code", nullToEmpty(slotError.code()), + "message", nullToEmpty(slotError.message()))); + } + validation.put("slot_errors", errors); + } + if (params != null) { + validation.put("extracted_params", params.data() == null ? Map.of() : new LinkedHashMap<>(params.data())); + } + validation.put("missing_slots", missingSlots == null ? List.of() : new ArrayList<>(missingSlots)); + return validation; + } + + private static List stillMissingErrors(Set stillMissing) { + List errors = new ArrayList<>(); + for (String slot : stillMissing) { + errors.add(new SlotValidationError(slot, "slots_still_missing", "slot still missing after the fill round")); + } + return errors; + } + + private static Map errorStep( + String name, String stage, A2ATError error, Map... calls) { + Map step = step(name, "n/a"); + step.put( + "error", + Map.of( + "stage", stage, + "code", error.getCode(), + "message", String.valueOf(error.getMessage()))); + if (calls.length > 0) { + api(step, calls); + } + return step; + } + + private static List> itemsJson(List items) { + List> list = new ArrayList<>(); + for (NegotiationItem item : items) { + list.add(Map.of("name", item.name(), "value", nullToEmpty(item.value()))); + } + return list; + } + + // -- negotiation helpers -- + + /** Missing-item hint from the phrasing template ({@code {slot}}/{@code {description}} placeholders). */ + private static String missingHint(Map phrasing, Map properties, String slot) { + String template = phrasing.getOrDefault("missing_item_hint", "{slot}"); + Object description = null; + Object property = properties.get(slot); + if (property instanceof Map slotSchema + && slotSchema.get("description") instanceof String text + && !text.isBlank()) { + description = text; + } + return template.replace("{slot}", slot) + .replace("{description}", description == null ? "" : String.valueOf(description)); + } + + /** + * Assembles the natural-language propose input for the fromText negotiation channel, mirroring the demo's + * {@code FromTextStrategy}: scenario-configured prefix, numbered item list, relationship sentence. + */ + private static String itemsToProposeText(List items, Map phrasing) { + StringBuilder text = new StringBuilder(phrasing.getOrDefault("from_text_propose_prefix", "")); + appendNumberedItems(text, items); + String relationship = items.size() > 1 ? phrasing.get("propose_relationship") : null; + if (relationship != null && !relationship.isBlank()) { + text.append(relationship); + } + return text.toString(); + } + + /** + * Assembles the natural-language accept input for the fromText negotiation channel: scenario-configured prefix, + * numbered item list, suffix sentence. + */ + private static String itemsToAcceptText(List items, Map phrasing) { + StringBuilder text = new StringBuilder(phrasing.getOrDefault("from_text_accept_prefix", "")); + appendNumberedItems(text, items); + text.append(phrasing.getOrDefault("from_text_accept_suffix", "")); + return text.toString(); + } + + /** Generic numbered-list rule: {@code N. name:value;} per item, value omitted when blank. */ + private static void appendNumberedItems(StringBuilder text, List items) { + for (int i = 0; i < items.size(); i++) { + NegotiationItem item = items.get(i); + text.append(i + 1).append(". ").append(item.name()); + if (item.value() != null && !item.value().isBlank()) { + text.append(":").append(item.value()); + } + text.append(";"); + } + } + + /** + * The client's own structured knowledge for the fill round of a fromText case: the fields its input text quoted. + * The server's round-1 extraction is the fallback when the case carries no client data — a lossy fallback, because + * those params are only produced when round-1 validation passed. + */ + private static Map clientBaseData( + Map testCase, Map extractedRound1) { + Map clientData = asMap(testCase.get("client_data")); + return clientData.isEmpty() ? extractedRound1 : clientData; + } + + /** The supplement values the simulated client supplies, keyed by slot. */ + private static Map fills(Set missingSlots, Map fillValues) { + Map fills = new LinkedHashMap<>(); + for (String slot : missingSlots) { + String value = fillValues.get(slot); + if (value != null && !value.isBlank()) { + fills.put(slot, value); + } + } + return fills; + } + + private static Map mergeFills(Map base, Map override) { + Map merged = new LinkedHashMap<>(base); + merged.putAll(override); + return merged; + } + + private static List filledItems(Map fills) { + List items = new ArrayList<>(); + for (Map.Entry entry : fills.entrySet()) { + items.add(new NegotiationItem(entry.getKey(), entry.getValue())); + } + return items; + } + + /** + * Builds the complete parameter map for the fill round: every slot declared by the task schema carries either the + * round-1 input value or the client's fill value, so the filled Task-T prompt can be regenerated from data. + */ + private static Map mergeInputData( + Map inputData, Map taskSchema, Map fills) { + Map merged = new LinkedHashMap<>(); + Map properties = asMap(taskSchema.get("properties")); + for (String slot : properties.keySet()) { + Object base = inputData.get(slot); + Object value = base == null || (base instanceof String s && s.isBlank()) ? fills.get(slot) : base; + merged.put(slot, value == null ? "" : value); + } + merged.putAll(fills); + return merged; + } + + /** Appends the supplement values to a prompt verbatim; fallback when regeneration is impossible. */ + private static String appendSupplements(String taskPrompt, Map fills) { + StringBuilder filled = new StringBuilder(taskPrompt); + for (Map.Entry entry : fills.entrySet()) { + filled.append(System.lineSeparator()) + .append(entry.getKey()) + .append(":") + .append(entry.getValue()); + } + return filled.toString(); + } + + /** + * Parameter schema for the negotiation-message validations (steps 4/5/7): one property per negotiated slot, derived + * from the task schema so the description and {@code x-a2at-value-constraint} of each slot flow into the validation + * prompt — the extraction surface is exactly the supplemented information, with the same semantic constraints the + * Task-T validation enforces. + */ + private static Map negotiationSchema(Set slots, Map taskSchema) { + Map taskProperties = asMap(taskSchema.get("properties")); + Map properties = new LinkedHashMap<>(); + for (String slot : slots) { + Map property = new LinkedHashMap<>(asMap(taskProperties.get(slot))); + if (property.isEmpty()) { + property.put("type", "string"); + } + properties.put(slot, property); + } + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + schema.put("required", new ArrayList<>(slots)); + return schema; + } + + /** + * Whether every server-extracted fill value matches the value the client sent for that slot. The comparison is + * containment in either direction after whitespace removal: the extracted value may carry the slot's field label + * (e.g. 接入端口名称:P781-…) or only the bare value (e.g. P781-…), both of which faithfully transport the fill. + */ + private static boolean fillValuesMatch(Map extracted, Map fills) { + for (Map.Entry entry : fills.entrySet()) { + Object value = extracted.get(entry.getKey()); + if (value == null) { + return false; + } + String extractedText = normalize(String.valueOf(value)); + String sentText = normalize(entry.getValue()); + if (!extractedText.contains(sentText) && !sentText.contains(extractedText)) { + return false; + } + } + return true; + } + + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("\\s+", ""); + } + + /** Slots whose extracted value is null/blank (negotiation candidates). */ + private static Set blankSlots(FilledParamData params) { + Set blank = new TreeSet<>(); + if (params != null && params.data() != null) { + for (Map.Entry entry : params.data().entrySet()) { + Object value = entry.getValue(); + if (value == null || (value instanceof String s && s.isBlank())) { + blank.add(entry.getKey()); + } + } + } + return blank; + } + + /** + * Restricts missing-slot candidates to the task schema's required slots: an empty optional slot is a valid state + * and never a negotiation trigger — only a required slot that is blank (or flagged by the semantic validator) means + * information the negotiation must ask for. + */ + private static Set requiredMissing(Set candidates, Set requiredSlots) { + Set missing = new TreeSet<>(candidates); + missing.retainAll(requiredSlots); + return missing; + } + + /** Slots reported as errors by the semantic validation. */ + private static Set errorSlots(ContentValidationException error) { + Set slots = new TreeSet<>(); + if (error.errors() != null) { + for (SlotValidationError validationError : error.errors()) { + if (validationError.slotName() != null + && !validationError.slotName().isBlank()) { + slots.add(validationError.slotName()); + } + } + } + return slots; + } + + // -- metrics -- + + private static Map metrics(List> cases) { + int total = cases.size(); + int triggerOk = 0; + int slotsOk = 0; + int proposeEvaluated = 0; + int proposeOk = 0; + int fillMatchEvaluated = 0; + int fillMatchOk = 0; + int loopEvaluated = 0; + int loopOk = 0; + int pass = 0; + for (Map record : cases) { + Map verdict = (Map) record.get("verdict"); + if (Boolean.TRUE.equals(verdict.get("trigger_correct"))) { + triggerOk++; + } + if (Boolean.TRUE.equals(verdict.get("slots_correct"))) { + slotsOk++; + } + if (verdict.get("propose_outbound_valid") != null) { + proposeEvaluated++; + if (Boolean.TRUE.equals(verdict.get("propose_outbound_valid"))) { + proposeOk++; + } + } + if (verdict.get("accept_fill_value_match") != null) { + fillMatchEvaluated++; + if (Boolean.TRUE.equals(verdict.get("accept_fill_value_match"))) { + fillMatchOk++; + } + } + if (verdict.get("fill_completes_correct") != null) { + loopEvaluated++; + if (Boolean.TRUE.equals(verdict.get("fill_completes_correct"))) { + loopOk++; + } + } + if (Boolean.TRUE.equals(verdict.get("pass"))) { + pass++; + } + } + Map metrics = new LinkedHashMap<>(); + metrics.put("cases", total); + metrics.put("trigger_accuracy", ratio("negotiation correctly triggered/held", triggerOk, total)); + metrics.put("missing_slot_exact_match", ratio("missing-slot set exact match", slotsOk, total)); + metrics.put( + "propose_outbound_valid", + ratio("generated propose passes outbound validation", proposeOk, proposeEvaluated)); + metrics.put( + "accept_fill_value_match", + ratio("extracted fill values match client fill", fillMatchOk, fillMatchEvaluated)); + metrics.put("closed_loop_completion", ratio("closed loop completes as expected", loopOk, loopEvaluated)); + metrics.put("full_pass", ratio("all dimensions pass", pass, total)); + return metrics; + } + + private static Map ratio(String label, int part, int total) { + Map ratio = new LinkedHashMap<>(); + ratio.put("label", label); + ratio.put("passed", part); + ratio.put("total", total); + ratio.put("pct", total == 0 ? 0.0 : round1(100.0 * part / total)); + return ratio; + } + + // -- suite loading and small utilities -- + + private static Map loadSuite() { + try (InputStream stream = NegotiationEvalApp.class + .getClassLoader() + .getResourceAsStream("sample/negotiation/eval/eval-suite.json")) { + if (stream == null) { + throw new IllegalStateException("Eval suite resource not found"); + } + return MAPPER.readValue(stream, new TypeReference>() {}); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to read eval suite", exception); + } + } + + /** LLM run configuration from the env file, without the API key. */ + private static Map llmInfo(Path envPath) { + Map values = readEnv(envPath); + Map info = new LinkedHashMap<>(); + info.put("provider", values.getOrDefault("A2AT_LLM_PROVIDER", "")); + info.put("model", values.getOrDefault("A2AT_LLM_MODEL", "")); + info.put("base_url", values.getOrDefault("A2AT_LLM_BASE_URL", "")); + return info; + } + + private static Map readEnv(Path envPath) { + Map values = new LinkedHashMap<>(); + if (envPath == null || !Files.exists(envPath)) { + return values; + } + try { + for (String rawLine : Files.readAllLines(envPath)) { + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { + continue; + } + int separator = line.indexOf('='); + values.put( + line.substring(0, separator).trim(), + line.substring(separator + 1).trim()); + } + } catch (IOException exception) { + throw new UncheckedIOException("Failed to read env file: " + envPath, exception); + } + return values; + } + + private static void writeReport(Map report, Path outPath) { + try { + MAPPER.writeValue(outPath.toFile(), report); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to write eval report: " + outPath, exception); + } + } + + private static int countCases(Map suite, List filter, String channelFilter) { + return (int) cases(suite).stream() + .filter(testCase -> filter.isEmpty() || filter.contains(String.valueOf(testCase.get("id")))) + .filter(testCase -> + channelFilter == null || channelFilter.equals(String.valueOf(testCase.get("channel")))) + .count(); + } + + private static TemplateUri parseTemplate(String templateUri) { + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); + } + + /** Negotiation template URIs used by the closed loop; kept local so the evaluator stays self-contained. */ + private static final class DemoTemplates { + + static final TemplateUri NEGOTIATION_PROPOSE = + parseTemplate("Negotiation-T/information-negotiation/propose/v1"); + + static final TemplateUri NEGOTIATION_ACCEPT = + parseTemplate("Negotiation-T/information-negotiation/accept-reject/v1"); + + private DemoTemplates() {} + } + + @SuppressWarnings("unchecked") + private static List> cases(Map suite) { + Object raw = suite.get("cases"); + return raw instanceof List list ? (List>) raw : List.of(); + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object value) { + return value instanceof Map map ? (Map) map : Map.of(); + } + + private static Map stringMap(Object value) { + Map result = new LinkedHashMap<>(); + if (value instanceof Map map) { + map.forEach((key, val) -> result.put(String.valueOf(key), String.valueOf(val))); + } + return result; + } + + private static List stringList(Object value) { + if (value instanceof List list) { + return list.stream().map(String::valueOf).toList(); + } + return List.of(); + } + + private static String nullToEmpty(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static double round1(double value) { + return Math.round(value * 10.0) / 10.0; + } + + private static String secs(long startNanos) { + return String.valueOf(round1((System.nanoTime() - startNanos) / 1_000_000_000.0)); + } + + private static void emit(String message) { + System.out.println(message); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java index 623623a0..1b825e92 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java @@ -1,495 +1,551 @@ -package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; -import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleEnvironment; -import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; - -/** Runs deterministic abnormal-input checks against all six Negotiation-T facade APIs and core pipelines. */ -public final class NegotiationAbnormalEvaluationMain { - - private static final ObjectMapper OBJECT_MAPPER = - new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); - private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); - private static final List NEGOTIATION_ERROR_CODES = List.of( - "template.not_found", - "negotiation.semantic_rejected", - "negotiation.field_missing", - "negotiation.invalid_input", - "negotiation.invalid_context_id", - "negotiation.round_exceeded", - "llm.invocation_failed", - "llm.response_invalid"); - - private NegotiationAbnormalEvaluationMain() { - } - - public static void main(String[] args) throws IOException { - Path envPath = args.length > 0 - ? Path.of(args[0]) - : Path.of("a2a-t-sample", "src", "main", "resources", "sample", - "private-line-complaint-negotiation", "qwen.env"); - String suffix = LocalDateTime.now(ZoneId.systemDefault()).format(FILE_TIMESTAMP); - Path reportPath = args.length > 1 - ? Path.of(args[1]) - : Path.of("a2a-t-sample", "target", "negotiation-abnormal-report-" + suffix + ".json"); - Path processLogPath = args.length > 2 - ? Path.of(args[2]) - : Path.of("a2a-t-sample", "target", "negotiation-abnormal-process-log-" + suffix + ".jsonl"); - - A2ATClient client = new A2ATClient(envPath); - A2ATServer server = new A2ATServer(envPath); - String runId = UUID.randomUUID().toString(); - List> results = new ArrayList<>(); - try (NegotiationEvaluationProcessLogger logger = - new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, processLogPath)) { - logger.write(event("run_started", runId, null, Map.of( - "env_path", envPath.toAbsolutePath().toString(), - "case_count", NegotiationAbnormalEvaluationCaseLoader.load().size() + scriptedCases().size()), null, null)); - for (NegotiationAbnormalEvaluationCase testCase : NegotiationAbnormalEvaluationCaseLoader.load()) { - results.add(runCase(client, server, testCase, runId, logger)); - } - for (ScriptedAbnormalCase testCase : scriptedCases()) { - results.add(runScriptedCase(testCase, runId, logger)); - } - } - - long passed = results.stream().filter(result -> Boolean.TRUE.equals(result.get("passed"))).count(); - Map report = new LinkedHashMap<>(); - report.put("generated_at", Instant.now().toString()); - report.put("run_id", runId); - report.put("git_revision", gitRevision()); - report.put("total", results.size()); - report.put("passed", passed); - report.put("failed", results.size() - passed); - report.put("success_rate", results.isEmpty() ? 1.0 : (double) passed / results.size()); - report.put("definition", "异常用例在预期 SDK 异常发生且错误类型/错误码匹配时判定通过;门面用例使用边界输入,脚本化用例注入固定 LLM 响应,不访问外部 LLM。"); - report.put("error_code_coverage", errorCodeCoverage(results)); - report.put("process_log", processLogPath.toAbsolutePath().toString()); - report.put("cases", results); - Path parent = reportPath.toAbsolutePath().getParent(); - if (parent != null) { - Files.createDirectories(parent); - } - OBJECT_MAPPER.writeValue(reportPath.toFile(), report); - System.out.printf("Abnormal negotiation evaluation complete: %d/%d passed; report=%s; process-log=%s%n", - passed, results.size(), reportPath.toAbsolutePath(), processLogPath.toAbsolutePath()); - } - - private static Map errorCodeCoverage(List> results) { - Map counts = new LinkedHashMap<>(); - NEGOTIATION_ERROR_CODES.forEach(code -> counts.put(code, 0)); - Set observed = new LinkedHashSet<>(); - for (Map result : results) { - Object error = result.get("actual_error"); - if (error instanceof Map errorMap && errorMap.get("code") instanceof String code) { - observed.add(code); - if (counts.containsKey(code)) { - counts.computeIfPresent(code, (ignored, count) -> count + 1); - } - } - } - List missing = NEGOTIATION_ERROR_CODES.stream() - .filter(code -> counts.get(code) == 0) - .toList(); - List unexpected = observed.stream() - .filter(code -> !counts.containsKey(code)) - .toList(); - Map coverage = new LinkedHashMap<>(); - coverage.put("expected_codes", NEGOTIATION_ERROR_CODES); - coverage.put("observed_counts", counts); - coverage.put("missing_codes", missing); - coverage.put("unexpected_codes", unexpected); - coverage.put("all_expected_codes_covered", missing.isEmpty()); - return coverage; - } - - private static Map runCase( - A2ATClient client, - A2ATServer server, - NegotiationAbnormalEvaluationCase testCase, - String runId, - NegotiationEvaluationProcessLogger logger) throws IOException { - long startedAt = System.nanoTime(); - NegotiationPerformative performative = NegotiationPerformative.valueOf(testCase.performative()); - NegotiationContext context = contextFor(testCase.contextMode(), performative); - TemplateUri templateUri = templateFor(testCase.template()); - Map request = new LinkedHashMap<>(); - if (testCase.api().startsWith("generate_")) { - request.put("text", testCase.input()); - } else { - request.put("prompt", testCase.input()); - request.put("schema", schemaFor(testCase.api(), testCase.schemaMode())); - } - request.put("template_uri", templateUri == null ? null : templateUri.uri()); - request.put("context", context == null ? null : contextPayload(context)); - Map result = new LinkedHashMap<>(); - result.put("id", testCase.id()); - result.put("description", testCase.description()); - result.put("api", apiMethod(testCase.api())); - result.put("expected_exception", testCase.expectedException()); - result.put("expected_code", testCase.expectedCode()); - result.put("request", request); - try { - Object response = invoke(client, server, testCase, context, templateUri); - result.put("outcome", "unexpected_success"); - result.put("response", responseSummary(response)); - result.put("passed", false); - logger.write(event("abnormal_case", runId, testCase.id(), request, - Map.of("outcome", "unexpected_success"), result)); - } catch (RuntimeException exception) { - Map actualError = errorDetails(exception); - boolean classMatched = testCase.expectedException().equals(actualError.get("class")); - boolean codeMatched = testCase.expectedCode() == null - || testCase.expectedCode().equals(actualError.get("code")); - result.put("actual_error", actualError); - result.put("passed", classMatched && codeMatched); - result.put("outcome", classMatched && codeMatched ? "expected_failure" : "unexpected_failure"); - logger.write(event("abnormal_case", runId, testCase.id(), request, null, result)); - } - result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); - return result; - } - - private static List scriptedCases() { - return List.of( - new ScriptedAbnormalCase("G-PROPOSE-LLM-MALFORMED", "generate_propose", - "请补充接入端口名称和投诉分类。", "not-json", false, - "NegotiationGenerationException", "llm.response_invalid"), - new ScriptedAbnormalCase("G-ACCEPT-LLM-INFRA", "generate_accept", - "接受协商,端口为 PE1-GZ-ETH-0/0/0.200,投诉分类为专线中断。", "throw", false, - "NegotiationGenerationException", "llm.invocation_failed"), - new ScriptedAbnormalCase("G-REJECT-SLOT-MISSING", "generate_reject", - "拒绝协商,原因是资源系统暂时无法查询。", "{\"conclusion\":\"Reject\",\"items\":null}", false, - "NegotiationGenerationException", "negotiation.field_missing"), - new ScriptedAbnormalCase("V-PROPOSE-SEMANTIC-REJECTED", "validate_propose", - "## 信息协商\n请根据<所需信息项>补充相关内容。", semanticRejectedPayload(), false, - "NegotiationParamExtractionException", "negotiation.semantic_rejected"), - new ScriptedAbnormalCase("V-ACCEPT-LLM-INFRA", "validate_accept", - "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", "throw", false, - "NegotiationParamExtractionException", "llm.invocation_failed"), - new ScriptedAbnormalCase("V-REJECT-SEMANTIC-REJECTED", "validate_reject", - "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", semanticRejectedPayload(), false, - "NegotiationParamExtractionException", "negotiation.semantic_rejected"), - new ScriptedAbnormalCase("G-PROPOSE-TEMPLATE-NOT-FOUND", "generate_propose", - "请补充接入端口名称和投诉分类。", "unused", true, - "NegotiationGenerationException", "template.not_found"), - new ScriptedAbnormalCase("V-PROPOSE-TEMPLATE-NOT-FOUND", "validate_propose", - "## 信息协商\n请根据<所需信息项>补充相关内容。", "unused", true, - "NegotiationParamExtractionException", "template.not_found")); - } - - private static String semanticRejectedPayload() { - return "{\"semantic_verdict\":false,\"negotiation_type\":\"information\"," - + "\"errors\":[{\"slot_name\":\"section.info_result_content\"," - + "\"code\":\"negotiation.missing_result_content\"," - + "\"facts\":{\"section_label\":\"信息协商结果内容\"}}]," - + "\"params\":{}}"; - } - - private static Map runScriptedCase( - ScriptedAbnormalCase testCase, - String runId, - NegotiationEvaluationProcessLogger logger) throws IOException { - long startedAt = System.nanoTime(); - NegotiationPerformative performative = testCase.api().contains("accept") - ? NegotiationPerformative.ACCEPT - : testCase.api().contains("reject") ? NegotiationPerformative.REJECT : NegotiationPerformative.PROPOSE; - NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); - TemplateUri template = testCase.api().contains("propose") - ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI : NegotiationSampleFlow.ENDING_TEMPLATE_URI; - ScriptedLlmClient llm = new ScriptedLlmClient(testCase.payload()); - NegotiationGenerationOrchestratorBuilder builder = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN").llmClient(llm).maxAttempts(1); - if (testCase.missingTemplate()) { - builder.templateLoader(new MissingTemplateLoader()); - } - NegotiationGenerationOrchestrator orchestrator = builder.build(); - Map request = new LinkedHashMap<>(); - if (testCase.api().startsWith("generate_")) { - request.put("text", testCase.input()); - } else { - request.put("prompt", testCase.input()); - request.put("schema", schemaFor(testCase.api(), null)); - } - request.put("context", contextPayload(context)); - request.put("template_uri", template.uri()); - Map result = new LinkedHashMap<>(); - result.put("id", testCase.id()); - result.put("api", scriptedApiMethod(testCase.api())); - result.put("request", request); - result.put("expected_exception", testCase.expectedException()); - result.put("expected_code", testCase.expectedCode()); - try { - Object response = invoke(orchestrator, testCase, context, template); - result.put("response", responseSummary(response)); - result.put("outcome", "unexpected_success"); - result.put("passed", false); - logger.write(event("abnormal_scripted_case", runId, testCase.id(), request, responseSummary(response), result)); - } catch (RuntimeException exception) { - Map actualError = errorDetails(exception); - boolean matched = testCase.expectedException().equals(actualError.get("class")) - && testCase.expectedCode().equals(actualError.get("code")); - result.put("actual_error", actualError); - result.put("outcome", matched ? "expected_failure" : "unexpected_failure"); - result.put("passed", matched); - logger.write(event("abnormal_scripted_case", runId, testCase.id(), request, null, result)); - } - result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); - return result; - } - - private static Object invoke( - NegotiationGenerationOrchestrator orchestrator, - ScriptedAbnormalCase testCase, - NegotiationContext context, - TemplateUri template) { - return switch (testCase.api()) { - case "generate_propose" -> orchestrator.generateProposeFromText(testCase.input(), context, template); - case "generate_accept" -> orchestrator.generateAcceptFromText(testCase.input(), context, template); - case "generate_reject" -> orchestrator.generateRejectFromText(testCase.input(), context, template); - case "validate_propose" -> orchestrator.validateProposePromptAndDataFilling( - testCase.input(), context, InformationNegotiationSchemas.propose(), template); - case "validate_accept" -> orchestrator.validateAcceptPromptAndDataFilling( - testCase.input(), context, InformationNegotiationSchemas.accept(), template); - case "validate_reject" -> orchestrator.validateRejectPromptAndDataFilling( - testCase.input(), context, InformationNegotiationSchemas.reject(), template); - default -> throw new IllegalArgumentException("Unknown scripted abnormal API: " + testCase.api()); - }; - } - - private static Object invoke( - A2ATClient client, - A2ATServer server, - NegotiationAbnormalEvaluationCase testCase, - NegotiationContext context, - TemplateUri templateUri) { - return switch (testCase.api()) { - case "generate_propose" -> client.generateNegotiationProposePromptFromText( - testCase.input(), context, templateUri); - case "validate_propose" -> server.validateProposePromptAndDataFilling( - testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); - case "generate_accept" -> server.generateNegotiationAcceptPromptFromText( - testCase.input(), context, templateUri); - case "validate_accept" -> client.validateAcceptPromptAndDataFilling( - testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); - case "generate_reject" -> server.generateNegotiationRejectPromptFromText( - testCase.input(), context, templateUri); - case "validate_reject" -> client.validateRejectPromptAndDataFilling( - testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); - default -> throw new IllegalArgumentException("Unknown abnormal evaluation API: " + testCase.api()); - }; - } - - private static Map schemaFor(String api, String mode) { - if ("null".equalsIgnoreCase(mode)) { - return null; - } - return switch (api) { - case "validate_propose" -> InformationNegotiationSchemas.propose(); - case "validate_accept" -> InformationNegotiationSchemas.accept(); - case "validate_reject" -> InformationNegotiationSchemas.reject(); - default -> Map.of(); - }; - } - - private static NegotiationContext contextFor(String mode, NegotiationPerformative performative) { - if ("null".equalsIgnoreCase(mode)) { - return null; - } - if ("invalid_id".equalsIgnoreCase(mode)) { - return new NegotiationContext("not-a-uuid", 1, 3, performative); - } - if ("exhausted".equalsIgnoreCase(mode)) { - return new NegotiationContext(UUID.randomUUID().toString(), 4, 3, performative); - } - return new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); - } - - private static TemplateUri templateFor(String template) { - return switch (template == null ? "null" : template.toLowerCase(java.util.Locale.ROOT)) { - case "null" -> null; - case "propose" -> NegotiationSampleFlow.PROPOSE_TEMPLATE_URI; - case "ending" -> NegotiationSampleFlow.ENDING_TEMPLATE_URI; - case "unknown_propose" -> TemplateUri.of( - "Negotiation-T", List.of("information-negotiation", "propose"), "v999"); - case "unknown_ending" -> TemplateUri.of( - "Negotiation-T", List.of("information-negotiation", "accept-reject"), "v999"); - default -> throw new IllegalArgumentException("Unknown abnormal evaluation template: " + template); - }; - } - - private static String apiMethod(String api) { - return switch (api) { - case "generate_propose" -> "A2ATClient.generateNegotiationProposePromptFromText"; - case "validate_propose" -> "A2ATServer.validateProposePromptAndDataFilling"; - case "generate_accept" -> "A2ATServer.generateNegotiationAcceptPromptFromText"; - case "validate_accept" -> "A2ATClient.validateAcceptPromptAndDataFilling"; - case "generate_reject" -> "A2ATServer.generateNegotiationRejectPromptFromText"; - case "validate_reject" -> "A2ATClient.validateRejectPromptAndDataFilling"; - default -> api; - }; - } - - private static Map responseSummary(Object response) { - Map summary = new LinkedHashMap<>(); - if (response instanceof MetadataContent metadata) { - summary.put("type", "MetadataContent"); - summary.put("prompt", metadata.promptText()); - summary.put("template_uri", metadata.templateUri()); - } else if (response instanceof FilledParamData filled) { - summary.put("type", "FilledParamData"); - summary.put("data", filled.data()); - } else { - summary.put("type", response == null ? "null" : response.getClass().getName()); - } - return summary; - } - - private static Map event( - String stage, - String runId, - String caseId, - Map request, - Map response, - Map result) { - Map event = new LinkedHashMap<>(); - event.put("timestamp", Instant.now().toString()); - event.put("run_id", runId); - event.put("stage", stage); - if (result != null && result.get("api") != null) { - event.put("api", result.get("api")); - } - if (caseId != null) { - event.put("case_id", caseId); - } - event.put("request", request); - if (response != null) { - event.put("response", response); - } - if (result != null) { - event.put("result", result); - } - return event; - } - - private static Map errorDetails(RuntimeException exception) { - Map error = new LinkedHashMap<>(); - error.put("class", exception.getClass().getSimpleName()); - error.put("message", exception.getMessage()); - if (exception instanceof A2ATError a2atError) { - error.put("code", a2atError.getCode()); - } - return error; - } - - private static Map contextPayload(NegotiationContext context) { - return Map.of( - "id", context.id(), - "round", context.round(), - "maxRounds", context.maxRounds(), - "performative", context.performative().name()); - } - - private static String scriptedApiMethod(String api) { - return switch (api) { - case "generate_propose" -> "NegotiationGenerationOrchestrator.generateProposeFromText"; - case "generate_accept" -> "NegotiationGenerationOrchestrator.generateAcceptFromText"; - case "generate_reject" -> "NegotiationGenerationOrchestrator.generateRejectFromText"; - case "validate_propose" -> "NegotiationGenerationOrchestrator.validateProposePromptAndDataFilling"; - case "validate_accept" -> "NegotiationGenerationOrchestrator.validateAcceptPromptAndDataFilling"; - case "validate_reject" -> "NegotiationGenerationOrchestrator.validateRejectPromptAndDataFilling"; - default -> api; - }; - } - - private record ScriptedAbnormalCase( - String id, - String api, - String input, - String payload, - boolean missingTemplate, - String expectedException, - String expectedCode) { - } - - private static final class ScriptedLlmClient implements LLMClient { - - private final String payload; - - private ScriptedLlmClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - if ("throw".equals(payload)) { - throw new IllegalStateException("scripted LLM infrastructure failure"); - } - return new LLMResponse(payload, "sample-scripted-model", - Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class MissingTemplateLoader implements NegotiationTemplateLoader { - - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - } - - private static String gitRevision() { - try { - Process process = new ProcessBuilder("git", "rev-parse", "HEAD") - .redirectErrorStream(true) - .start(); - String revision; - try (var input = process.getInputStream()) { - revision = new String(input.readAllBytes(), StandardCharsets.UTF_8).trim(); - } - return process.waitFor() == 0 && !revision.isBlank() ? revision : "unknown"; - } catch (IOException exception) { - return "unknown"; - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - return "unknown"; - } - } -} +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.server.A2ATServer; + +/** Runs deterministic abnormal-input checks against all six Negotiation-T facade APIs and core pipelines. */ +public final class NegotiationAbnormalEvaluationMain { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + private static final List NEGOTIATION_ERROR_CODES = List.of( + "template.not_found", + "negotiation.semantic_rejected", + "negotiation.field_missing", + "negotiation.invalid_input", + "negotiation.invalid_context_id", + "negotiation.round_exceeded", + "llm.invocation_failed", + "llm.response_invalid"); + + private NegotiationAbnormalEvaluationMain() {} + + public static void main(String[] args) throws IOException { + Path envPath = args.length > 0 + ? Path.of(args[0]) + : Path.of( + "a2a-t-sample", + "src", + "main", + "resources", + "sample", + "private-line-complaint-negotiation", + "qwen.env"); + String suffix = LocalDateTime.now(ZoneId.systemDefault()).format(FILE_TIMESTAMP); + Path reportPath = args.length > 1 + ? Path.of(args[1]) + : Path.of("a2a-t-sample", "target", "negotiation-abnormal-report-" + suffix + ".json"); + Path processLogPath = args.length > 2 + ? Path.of(args[2]) + : Path.of("a2a-t-sample", "target", "negotiation-abnormal-process-log-" + suffix + ".jsonl"); + + A2ATClient client = new A2ATClient(envPath); + A2ATServer server = new A2ATServer(envPath); + String runId = UUID.randomUUID().toString(); + List> results = new ArrayList<>(); + try (NegotiationEvaluationProcessLogger logger = + new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, processLogPath)) { + logger.write(event( + "run_started", + runId, + null, + Map.of( + "env_path", + envPath.toAbsolutePath().toString(), + "case_count", + NegotiationAbnormalEvaluationCaseLoader.load().size() + + scriptedCases().size()), + null, + null)); + for (NegotiationAbnormalEvaluationCase testCase : NegotiationAbnormalEvaluationCaseLoader.load()) { + results.add(runCase(client, server, testCase, runId, logger)); + } + for (ScriptedAbnormalCase testCase : scriptedCases()) { + results.add(runScriptedCase(testCase, runId, logger)); + } + } + + long passed = results.stream() + .filter(result -> Boolean.TRUE.equals(result.get("passed"))) + .count(); + Map report = new LinkedHashMap<>(); + report.put("generated_at", Instant.now().toString()); + report.put("run_id", runId); + report.put("git_revision", gitRevision()); + report.put("total", results.size()); + report.put("passed", passed); + report.put("failed", results.size() - passed); + report.put("success_rate", results.isEmpty() ? 1.0 : (double) passed / results.size()); + report.put("definition", "异常用例在预期 SDK 异常发生且错误类型/错误码匹配时判定通过;门面用例使用边界输入,脚本化用例注入固定 LLM 响应,不访问外部 LLM。"); + report.put("error_code_coverage", errorCodeCoverage(results)); + report.put("process_log", processLogPath.toAbsolutePath().toString()); + report.put("cases", results); + Path parent = reportPath.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + OBJECT_MAPPER.writeValue(reportPath.toFile(), report); + System.out.printf( + "Abnormal negotiation evaluation complete: %d/%d passed; report=%s; process-log=%s%n", + passed, results.size(), reportPath.toAbsolutePath(), processLogPath.toAbsolutePath()); + } + + private static Map errorCodeCoverage(List> results) { + Map counts = new LinkedHashMap<>(); + NEGOTIATION_ERROR_CODES.forEach(code -> counts.put(code, 0)); + Set observed = new LinkedHashSet<>(); + for (Map result : results) { + Object error = result.get("actual_error"); + if (error instanceof Map errorMap && errorMap.get("code") instanceof String code) { + observed.add(code); + if (counts.containsKey(code)) { + counts.computeIfPresent(code, (ignored, count) -> count + 1); + } + } + } + List missing = NEGOTIATION_ERROR_CODES.stream() + .filter(code -> counts.get(code) == 0) + .toList(); + List unexpected = + observed.stream().filter(code -> !counts.containsKey(code)).toList(); + Map coverage = new LinkedHashMap<>(); + coverage.put("expected_codes", NEGOTIATION_ERROR_CODES); + coverage.put("observed_counts", counts); + coverage.put("missing_codes", missing); + coverage.put("unexpected_codes", unexpected); + coverage.put("all_expected_codes_covered", missing.isEmpty()); + return coverage; + } + + private static Map runCase( + A2ATClient client, + A2ATServer server, + NegotiationAbnormalEvaluationCase testCase, + String runId, + NegotiationEvaluationProcessLogger logger) + throws IOException { + long startedAt = System.nanoTime(); + NegotiationPerformative performative = NegotiationPerformative.valueOf(testCase.performative()); + NegotiationContext context = contextFor(testCase.contextMode(), performative); + TemplateUri templateUri = templateFor(testCase.template()); + Map request = new LinkedHashMap<>(); + if (testCase.api().startsWith("generate_")) { + request.put("text", testCase.input()); + } else { + request.put("prompt", testCase.input()); + request.put("schema", schemaFor(testCase.api(), testCase.schemaMode())); + } + request.put("template_uri", templateUri == null ? null : templateUri.uri()); + request.put("context", context == null ? null : contextPayload(context)); + Map result = new LinkedHashMap<>(); + result.put("id", testCase.id()); + result.put("description", testCase.description()); + result.put("api", apiMethod(testCase.api())); + result.put("expected_exception", testCase.expectedException()); + result.put("expected_code", testCase.expectedCode()); + result.put("request", request); + try { + Object response = invoke(client, server, testCase, context, templateUri); + result.put("outcome", "unexpected_success"); + result.put("response", responseSummary(response)); + result.put("passed", false); + logger.write(event( + "abnormal_case", runId, testCase.id(), request, Map.of("outcome", "unexpected_success"), result)); + } catch (RuntimeException exception) { + Map actualError = errorDetails(exception); + boolean classMatched = testCase.expectedException().equals(actualError.get("class")); + boolean codeMatched = + testCase.expectedCode() == null || testCase.expectedCode().equals(actualError.get("code")); + result.put("actual_error", actualError); + result.put("passed", classMatched && codeMatched); + result.put("outcome", classMatched && codeMatched ? "expected_failure" : "unexpected_failure"); + logger.write(event("abnormal_case", runId, testCase.id(), request, null, result)); + } + result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); + return result; + } + + private static List scriptedCases() { + return List.of( + new ScriptedAbnormalCase( + "G-PROPOSE-LLM-MALFORMED", + "generate_propose", + "请补充接入端口名称和投诉分类。", + "not-json", + false, + "NegotiationGenerationException", + "llm.response_invalid"), + new ScriptedAbnormalCase( + "G-ACCEPT-LLM-INFRA", + "generate_accept", + "接受协商,端口为 PE1-GZ-ETH-0/0/0.200,投诉分类为专线中断。", + "throw", + false, + "NegotiationGenerationException", + "llm.invocation_failed"), + new ScriptedAbnormalCase( + "G-REJECT-SLOT-MISSING", + "generate_reject", + "拒绝协商,原因是资源系统暂时无法查询。", + "{\"conclusion\":\"Reject\",\"items\":null}", + false, + "NegotiationGenerationException", + "negotiation.field_missing"), + new ScriptedAbnormalCase( + "V-PROPOSE-SEMANTIC-REJECTED", + "validate_propose", + "## 信息协商\n请根据<所需信息项>补充相关内容。", + semanticRejectedPayload(), + false, + "NegotiationParamExtractionException", + "negotiation.semantic_rejected"), + new ScriptedAbnormalCase( + "V-ACCEPT-LLM-INFRA", + "validate_accept", + "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", + "throw", + false, + "NegotiationParamExtractionException", + "llm.invocation_failed"), + new ScriptedAbnormalCase( + "V-REJECT-SEMANTIC-REJECTED", + "validate_reject", + "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", + semanticRejectedPayload(), + false, + "NegotiationParamExtractionException", + "negotiation.semantic_rejected"), + new ScriptedAbnormalCase( + "G-PROPOSE-TEMPLATE-NOT-FOUND", + "generate_propose", + "请补充接入端口名称和投诉分类。", + "unused", + true, + "NegotiationGenerationException", + "template.not_found"), + new ScriptedAbnormalCase( + "V-PROPOSE-TEMPLATE-NOT-FOUND", + "validate_propose", + "## 信息协商\n请根据<所需信息项>补充相关内容。", + "unused", + true, + "NegotiationParamExtractionException", + "template.not_found")); + } + + private static String semanticRejectedPayload() { + return "{\"semantic_verdict\":false,\"negotiation_type\":\"information\"," + + "\"errors\":[{\"slot_name\":\"section.info_result_content\"," + + "\"code\":\"negotiation.missing_result_content\"," + + "\"facts\":{\"section_label\":\"信息协商结果内容\"}}]," + + "\"params\":{}}"; + } + + private static Map runScriptedCase( + ScriptedAbnormalCase testCase, String runId, NegotiationEvaluationProcessLogger logger) throws IOException { + long startedAt = System.nanoTime(); + NegotiationPerformative performative = testCase.api().contains("accept") + ? NegotiationPerformative.ACCEPT + : testCase.api().contains("reject") ? NegotiationPerformative.REJECT : NegotiationPerformative.PROPOSE; + NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); + TemplateUri template = testCase.api().contains("propose") + ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI + : NegotiationSampleFlow.ENDING_TEMPLATE_URI; + ScriptedLlmClient llm = new ScriptedLlmClient(testCase.payload()); + NegotiationGenerationOrchestratorBuilder builder = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(1); + if (testCase.missingTemplate()) { + builder.templateLoader(new MissingTemplateLoader()); + } + NegotiationGenerationOrchestrator orchestrator = builder.build(); + Map request = new LinkedHashMap<>(); + if (testCase.api().startsWith("generate_")) { + request.put("text", testCase.input()); + } else { + request.put("prompt", testCase.input()); + request.put("schema", schemaFor(testCase.api(), null)); + } + request.put("context", contextPayload(context)); + request.put("template_uri", template.uri()); + Map result = new LinkedHashMap<>(); + result.put("id", testCase.id()); + result.put("api", scriptedApiMethod(testCase.api())); + result.put("request", request); + result.put("expected_exception", testCase.expectedException()); + result.put("expected_code", testCase.expectedCode()); + try { + Object response = invoke(orchestrator, testCase, context, template); + result.put("response", responseSummary(response)); + result.put("outcome", "unexpected_success"); + result.put("passed", false); + logger.write( + event("abnormal_scripted_case", runId, testCase.id(), request, responseSummary(response), result)); + } catch (RuntimeException exception) { + Map actualError = errorDetails(exception); + boolean matched = testCase.expectedException().equals(actualError.get("class")) + && testCase.expectedCode().equals(actualError.get("code")); + result.put("actual_error", actualError); + result.put("outcome", matched ? "expected_failure" : "unexpected_failure"); + result.put("passed", matched); + logger.write(event("abnormal_scripted_case", runId, testCase.id(), request, null, result)); + } + result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); + return result; + } + + private static Object invoke( + NegotiationGenerationOrchestrator orchestrator, + ScriptedAbnormalCase testCase, + NegotiationContext context, + TemplateUri template) { + return switch (testCase.api()) { + case "generate_propose" -> orchestrator.generateProposeFromText(testCase.input(), context, template); + case "generate_accept" -> orchestrator.generateAcceptFromText(testCase.input(), context, template); + case "generate_reject" -> orchestrator.generateRejectFromText(testCase.input(), context, template); + case "validate_propose" -> orchestrator.validateProposePromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.propose(), template); + case "validate_accept" -> orchestrator.validateAcceptPromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.accept(), template); + case "validate_reject" -> orchestrator.validateRejectPromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.reject(), template); + default -> throw new IllegalArgumentException("Unknown scripted abnormal API: " + testCase.api()); + }; + } + + private static Object invoke( + A2ATClient client, + A2ATServer server, + NegotiationAbnormalEvaluationCase testCase, + NegotiationContext context, + TemplateUri templateUri) { + return switch (testCase.api()) { + case "generate_propose" -> client.generateNegotiationProposePromptFromText( + testCase.input(), context, templateUri); + case "validate_propose" -> server.validateProposePromptAndDataFilling( + testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); + case "generate_accept" -> server.generateNegotiationAcceptPromptFromText( + testCase.input(), context, templateUri); + case "validate_accept" -> client.validateAcceptPromptAndDataFilling( + testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); + case "generate_reject" -> server.generateNegotiationRejectPromptFromText( + testCase.input(), context, templateUri); + case "validate_reject" -> client.validateRejectPromptAndDataFilling( + testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); + default -> throw new IllegalArgumentException("Unknown abnormal evaluation API: " + testCase.api()); + }; + } + + private static Map schemaFor(String api, String mode) { + if ("null".equalsIgnoreCase(mode)) { + return null; + } + return switch (api) { + case "validate_propose" -> InformationNegotiationSchemas.propose(); + case "validate_accept" -> InformationNegotiationSchemas.accept(); + case "validate_reject" -> InformationNegotiationSchemas.reject(); + default -> Map.of(); + }; + } + + private static NegotiationContext contextFor(String mode, NegotiationPerformative performative) { + if ("null".equalsIgnoreCase(mode)) { + return null; + } + if ("invalid_id".equalsIgnoreCase(mode)) { + return new NegotiationContext("not-a-uuid", 1, 3, performative); + } + if ("exhausted".equalsIgnoreCase(mode)) { + return new NegotiationContext(UUID.randomUUID().toString(), 4, 3, performative); + } + return new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); + } + + private static TemplateUri templateFor(String template) { + return switch (template == null ? "null" : template.toLowerCase(java.util.Locale.ROOT)) { + case "null" -> null; + case "propose" -> NegotiationSampleFlow.PROPOSE_TEMPLATE_URI; + case "ending" -> NegotiationSampleFlow.ENDING_TEMPLATE_URI; + case "unknown_propose" -> TemplateUri.of( + "Negotiation-T", List.of("information-negotiation", "propose"), "v999"); + case "unknown_ending" -> TemplateUri.of( + "Negotiation-T", List.of("information-negotiation", "accept-reject"), "v999"); + default -> throw new IllegalArgumentException("Unknown abnormal evaluation template: " + template); + }; + } + + private static String apiMethod(String api) { + return switch (api) { + case "generate_propose" -> "A2ATClient.generateNegotiationProposePromptFromText"; + case "validate_propose" -> "A2ATServer.validateProposePromptAndDataFilling"; + case "generate_accept" -> "A2ATServer.generateNegotiationAcceptPromptFromText"; + case "validate_accept" -> "A2ATClient.validateAcceptPromptAndDataFilling"; + case "generate_reject" -> "A2ATServer.generateNegotiationRejectPromptFromText"; + case "validate_reject" -> "A2ATClient.validateRejectPromptAndDataFilling"; + default -> api; + }; + } + + private static Map responseSummary(Object response) { + Map summary = new LinkedHashMap<>(); + if (response instanceof MetadataContent metadata) { + summary.put("type", "MetadataContent"); + summary.put("prompt", metadata.promptText()); + summary.put("template_uri", metadata.templateUri()); + } else if (response instanceof FilledParamData filled) { + summary.put("type", "FilledParamData"); + summary.put("data", filled.data()); + } else { + summary.put("type", response == null ? "null" : response.getClass().getName()); + } + return summary; + } + + private static Map event( + String stage, + String runId, + String caseId, + Map request, + Map response, + Map result) { + Map event = new LinkedHashMap<>(); + event.put("timestamp", Instant.now().toString()); + event.put("run_id", runId); + event.put("stage", stage); + if (result != null && result.get("api") != null) { + event.put("api", result.get("api")); + } + if (caseId != null) { + event.put("case_id", caseId); + } + event.put("request", request); + if (response != null) { + event.put("response", response); + } + if (result != null) { + event.put("result", result); + } + return event; + } + + private static Map errorDetails(RuntimeException exception) { + Map error = new LinkedHashMap<>(); + error.put("class", exception.getClass().getSimpleName()); + error.put("message", exception.getMessage()); + if (exception instanceof A2ATError a2atError) { + error.put("code", a2atError.getCode()); + } + return error; + } + + private static Map contextPayload(NegotiationContext context) { + return Map.of( + "id", context.id(), + "round", context.round(), + "maxRounds", context.maxRounds(), + "performative", context.performative().name()); + } + + private static String scriptedApiMethod(String api) { + return switch (api) { + case "generate_propose" -> "NegotiationGenerationOrchestrator.generateProposeFromText"; + case "generate_accept" -> "NegotiationGenerationOrchestrator.generateAcceptFromText"; + case "generate_reject" -> "NegotiationGenerationOrchestrator.generateRejectFromText"; + case "validate_propose" -> "NegotiationGenerationOrchestrator.validateProposePromptAndDataFilling"; + case "validate_accept" -> "NegotiationGenerationOrchestrator.validateAcceptPromptAndDataFilling"; + case "validate_reject" -> "NegotiationGenerationOrchestrator.validateRejectPromptAndDataFilling"; + default -> api; + }; + } + + private record ScriptedAbnormalCase( + String id, + String api, + String input, + String payload, + boolean missingTemplate, + String expectedException, + String expectedCode) {} + + private static final class ScriptedLlmClient implements LLMClient { + + private final String payload; + + private ScriptedLlmClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + if ("throw".equals(payload)) { + throw new IllegalStateException("scripted LLM infrastructure failure"); + } + return new LLMResponse( + payload, "sample-scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class MissingTemplateLoader implements NegotiationTemplateLoader { + + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + } + + private static String gitRevision() { + try { + Process process = new ProcessBuilder("git", "rev-parse", "HEAD") + .redirectErrorStream(true) + .start(); + String revision; + try (var input = process.getInputStream()) { + revision = new String(input.readAllBytes(), StandardCharsets.UTF_8).trim(); + } + return process.waitFor() == 0 && !revision.isBlank() ? revision : "unknown"; + } catch (IOException exception) { + return "unknown"; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return "unknown"; + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java index fbb6526d..b2c17cc2 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java @@ -1,415 +1,418 @@ -package net.openan.a2at.sample.task_t; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.server.A2ATServer; - -/** - * End-to-end accuracy demo for the {@code Task-T} extension. - * - *

        The demo closes the loop between the client facade and the server facade over the - * {@code Task-T/network-layer/private-line-complaint/v1} template (transfer / private-line business complaint - * diagnosis) in two independent cases: - * - *

          - *
        1. {@code A2ATClient#generateTaskPromptFromText} fed with natural language;
        2. - *
        3. {@code A2ATClient#generateTaskPromptFromDataWithSchema} fed with a slot-value map plus a semantics - * schema;
        4. - *
        5. {@code generateTaskPromptFromText} fed with key-slot-missing content, expected to be rejected by the - * server-side semantic validation.
        6. - *
        - * - *

        Each generated prompt is passed to {@code A2ATServer#validateTaskPromptAndDataFilling}; for cases one and two the - * extracted parameters are compared against the sample ground truth and the results are aggregated into a per-case - * field accuracy and sample pass rate, while case three reports a server-side validation rejection rate and an overall - * interception rate (server rejection plus client-side generation block) over the negative samples. - * - *

        Run with {@code java ... TaskTDemoMain [env-file-path] [case]}. The env file resolves as follows: an explicit first - * argument wins; otherwise a {@code client.env} in the working directory (repo root) that carries the required LLM - * keys; otherwise the bundled sample template {@code a2a-t-sample/src/main/resources/sample/task_t/client.env}. - * A working-directory {@code client.env} is only honored when it defines non-blank {@code A2AT_LLM_PROVIDER}, - * {@code A2AT_LLM_MODEL} and {@code A2AT_LLM_API_KEY} (cf. the leftover {@code subscribe_incident} template in newer - * checkouts would otherwise shadow the Task-T sample); otherwise the bundled template is used. The env must configure - * a reachable OpenAI-compatible LLM ({@code A2AT_LLM_API_KEY}, {@code A2AT_LLM_BASE_URL}, {@code A2AT_LLM_MODEL}, - * {@code A2AT_LLM_PROVIDER=openai}) — the server-side semantic validation performs one LLM call per sample. - * - *

        The second optional argument selects which case to run: {@code text} (case one), {@code data} (case two), - * {@code rejection} (case three) or {@code all} (default); when a single case is selected only its summary is printed.

        - */ -public final class TaskTDemoMain { - - private static final String DEFAULT_ENV_FILE = "client.env"; - - private static final String BUNDLED_ENV_FILE = - Path.of("a2a-t-sample", "src", "main", "resources", "sample", "task_t", "client.env").toString(); - - private static final List REQUIRED_LLM_KEYS = - List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY"); - - private static final String CASE_TEXT = "text"; - - private static final String CASE_DATA = "data"; - - private static final String CASE_REJECTION = "rejection"; - - private static final String CASE_ALL = "all"; - - private TaskTDemoMain() { - } - - /** - * Runs one or all client-API cases against the built-in private-line complaint diagnosis samples. - * - * @param args first optional argument is the {@code .env} file path; second optional argument selects the case - * ({@code text}, {@code data}, {@code rejection} or {@code all}) - */ - public static void main(String[] args) { - Path envPath = resolveEnvPath(args); - String caseSelection = resolveCaseSelection(args); - println("Task-T 准确率验证样例,env: " + envPath.toAbsolutePath() + (Files.exists(envPath) ? "" : " (不存在,请先配置)")); - println("模板: " + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); - println("用例: " + caseLabel(caseSelection)); - println(); - - A2ATClient client = new A2ATClient(envPath); - A2ATServer server = new A2ATServer(envPath); - - if (CASE_TEXT.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { - List textScores = runTextCase(client, server); - println(); - System.out.println("══════════════════════════════════════════════════════"); - printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromText", textScores)); - println(); - } - if (CASE_DATA.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { - List dataScores = runDataWithSchemaCase(client, server); - println(); - System.out.println("══════════════════════════════════════════════════════"); - printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromDataWithSchema", dataScores)); - println(); - } - if (CASE_REJECTION.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { - RejectionSummary rejectionSummary = runRejectionCase(client, server); - println(); - System.out.println("══════════════════════════════════════════════════════"); - printRejectionSummary(rejectionSummary); - println(); - } - } - - private static String resolveCaseSelection(String[] args) { - if (args.length > 1) { - String selection = args[1].toLowerCase(Locale.ROOT); - if (CASE_TEXT.equals(selection) - || CASE_DATA.equals(selection) - || CASE_REJECTION.equals(selection) - || CASE_ALL.equals(selection)) { - return selection; - } - System.err.println("未知用例参数: " + args[1] + ",可选值: text | data | rejection | all,将运行全部用例"); - return CASE_ALL; - } - return CASE_ALL; - } - - private static String caseLabel(String caseSelection) { - return switch (caseSelection) { - case CASE_TEXT -> "用例一:generateTaskPromptFromText"; - case CASE_DATA -> "用例二:generateTaskPromptFromDataWithSchema"; - case CASE_REJECTION -> "用例三:缺少关键槽位拒绝用例"; - default -> "全部(用例一 + 用例二 + 用例三)"; - }; - } - - private static List runTextCase(A2ATClient client, A2ATServer server) { - println("================ 用例一:generateTaskPromptFromText ================"); - List samples = TaskTPrivateLineComplaintSamples.textSamples(); - List scores = new ArrayList<>(); - for (int i = 0; i < samples.size(); i++) { - TaskTSample sample = samples.get(i); - printSampleHeader("用例一", i, samples.size(), sample.name()); - println("[输入] 自然语言文本:"); - println(sample.text()); - println(); - try { - MetadataContent metadata = client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT); - printGeneratedMetadata(metadata); - TaskTAccuracyEvaluator.SampleScore score = - validateAndScore(server, sample, metadata, "用例一"); - scores.add(score); - } catch (PromptGenerationException exception) { - printFailure("生成失败", exception); - scores.add(new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of())); - } - println(); - } - return scores; - } - - private static List runDataWithSchemaCase(A2ATClient client, A2ATServer server) { - println("========== 用例二:generateTaskPromptFromDataWithSchema =========="); - List samples = TaskTPrivateLineComplaintSamples.dataWithSchemaSamples(); - List scores = new ArrayList<>(); - for (int i = 0; i < samples.size(); i++) { - TaskTSample sample = samples.get(i); - printSampleHeader("用例二", i, samples.size(), sample.name()); - println("[输入] 数据(data):"); - println(pretty(sample.data())); - println("[输入] 语义schema(schema):"); - println(pretty(sample.semanticsSchema())); - println(); - try { - MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( - sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT); - printGeneratedMetadata(metadata); - TaskTAccuracyEvaluator.SampleScore score = - validateAndScore(server, sample, metadata, "用例二"); - scores.add(score); - } catch (PromptGenerationException exception) { - printFailure("生成失败", exception); - scores.add(new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of())); - } - println(); - } - return scores; - } - - /** - * Runs the rejection case over {@link TaskTPrivateLineComplaintSamples#rejectionSamples()}: each sample - * deliberately omits one or more key slots (or carries an invalid slot value), so the server-side semantic - * validation is expected to fail with {@code negotiation.semantic_rejected}. The sample set mixes a text variant - * (fed to {@code generateTaskPromptFromText}) and a data variant (fed to - * {@code generateTaskPromptFromDataWithSchema}); a sample is counted as "server-rejected" only when the - * validation throws {@link ContentValidationException} — the structured slot errors are printed per sample — while - * a failure already at prompt generation counts as a client-side block. The run aggregates a server-side validation - * rejection rate (server-rejected over total) side by side with an overall interception rate (server-rejected plus - * client-blocked over total), so the validation point itself can be read independently of client-side blocks. - */ - private static RejectionSummary runRejectionCase(A2ATClient client, A2ATServer server) { - println("========== 用例三:缺少关键槽位拒绝用例(期望 rejected) =========="); - List samples = TaskTPrivateLineComplaintSamples.rejectionSamples(); - int serverRejected = 0; - int clientBlocked = 0; - int unexpectedlyPassed = 0; - for (int i = 0; i < samples.size(); i++) { - TaskTRejectionSample sample = samples.get(i); - printSampleHeader("用例三", i, samples.size(), sample.name()); - try { - MetadataContent metadata; - if (sample.data() != null) { - println("[输入] 数据(data,客户端 key,缺关键槽位):"); - println(pretty(sample.data())); - println("[输入] 语义schema(schema):"); - println(pretty(sample.semanticsSchema())); - println(); - println("[生成] generateTaskPromptFromDataWithSchema:"); - metadata = client.generateTaskPromptFromDataWithSchema( - sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT); - } else { - println("[输入] 自然语言文本 (缺少关键槽位):"); - println(sample.text()); - println(); - println("[生成] generateTaskPromptFromText:"); - metadata = client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT); - } - System.out.println(" templateUri : " + metadata.templateUri()); - System.out.println(" promptText : " + metadata.promptText()); - System.out.println(" extensionUri : " + metadata.extensionUri()); - println(); - Map extracted = server - .validateTaskPromptAndDataFilling( - metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT) - .data(); - unexpectedlyPassed++; - println("[意外通过] 应被拒绝却校验通过,提取参数:"); - println(pretty(extracted)); - } catch (ContentValidationException exception) { - serverRejected++; - System.out.println("[正确拒绝] [" + exception.getCode() + "] " + exception.getMessage()); - printValidationErrors(exception); - } catch (PromptGenerationException exception) { - clientBlocked++; - System.out.println("[生成阶段拦截] 客户端生成即失败,[" + exception.getCode() + "] " + exception.getMessage()); - } - println(); - } - return new RejectionSummary(samples.size(), serverRejected, clientBlocked, unexpectedlyPassed); - } - - private static void printValidationErrors(ContentValidationException exception) { - List errors = exception.errors(); - if (errors == null || errors.isEmpty()) { - println(" (无结构化错误明细)"); - return; - } - for (SlotValidationError error : errors) { - println(" slot=" + error.slotName() + " code=" + error.code() + " message=" + error.message()); - } - } - - private static TaskTAccuracyEvaluator.SampleScore validateAndScore( - A2ATServer server, TaskTSample sample, MetadataContent metadata, String caseLabel) { - try { - Map extracted = - server.validateTaskPromptAndDataFilling(metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT) - .data(); - println("[服务端] " + caseLabel + " validateTaskPromptAndDataFilling 通过,提取参数:"); - println(pretty(extracted)); - List fields = TaskTAccuracyEvaluator.scoreFields(sample, extracted); - printFieldScores(fields); - return new TaskTAccuracyEvaluator.SampleScore(sample.name(), true, fields); - } catch (ContentValidationException exception) { - printFailure("校验不通过", exception); - return new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of()); - } - } - - private static void printSampleHeader(String caseLabel, int index, int total, String name) { - System.out.println("──────────── " + caseLabel + " 样本[" + (index + 1) + "/" + total + "] " + name + " ────────────"); - } - - private static void printGeneratedMetadata(MetadataContent metadata) { - println("[生成] MetadataContent:"); - println(" extensionUri: " + metadata.extensionUri()); - println(" templateUri : " + metadata.templateUri()); - println(" promptText : "); - println(metadata.promptText()); - } - - private static void printFieldScores(List fields) { - println("[比对] 字段级命中 (命中规则: 归一化后相同或互相包含):"); - for (TaskTAccuracyEvaluator.FieldScore field : fields) { - String mark = field.matched() ? "✔ 命中" : "✘ 未命中"; - String detail = field.matched() ? "" : " " + field.detail(); - println(" " + mark + " " + field.slot() + detail); - } - } - - private static void printFailure(String stage, A2ATError error) { - println("[失败] " + stage + ": [" + error.getCode() + "] " + error.getMessage()); - if (error instanceof ContentValidationException validationException) { - List errors = validationException.errors(); - if (!errors.isEmpty()) { - println(" 详细原因:"); - for (SlotValidationError slotError : errors) { - println(" slot: " + slotError.slotName() - + ", code: " + slotError.code() - + ", message: " + slotError.message()); - } - } - Map partialParams = validationException.params(); - if (!partialParams.isEmpty()) { - println(" LLM部分提取参数:"); - println(pretty(partialParams)); - } - } - Throwable cause = error.getCause(); - if (cause != null) { - println(" 根因异常: " + cause.getClass().getName() + ": " + cause.getMessage()); - } - } - - private static void printSummary(TaskTAccuracyEvaluator.Summary summary) { - System.out.println("──────────── 汇总: " + summary.api() + " ────────────"); - println(" 样本数: " + summary.sampleCount() - + " 通过样本: " + summary.passedSamples() - + " 字段命中: " + summary.matchedFields() + "/" + summary.expectedFields()); - println(" 字段准确率: " + percent(summary.fieldAccuracyPercent()) - + " 样本通过率: " + percent(summary.samplePassRatePercent())); - } - - private static void printRejectionSummary(RejectionSummary summary) { - System.out.println("──────────── 汇总: 缺少关键槽位拒绝用例 ────────────"); - println(" 样本数: " + summary.total() - + " 服务端拒绝: " + summary.serverRejected() - + " 生成阶段拦截: " + summary.clientBlocked() - + " 意外通过: " + summary.unexpectedlyPassed()); - println(" 服务端校验点拒绝率: " + percent(rejectionRate(summary.serverRejected(), summary.total())) - + " 总拦截率(服务端+客户端): " + percent(rejectionRate( - summary.serverRejected() + summary.clientBlocked(), summary.total()))); - } - - /** Rate guarded against a zero sample set. */ - private static double rejectionRate(int numerator, int total) { - return total == 0 ? 0d : numerator * 100.0 / total; - } - - /** - * Aggregated result of the rejection case over the key-slot-missing negative samples: how many were intercepted - * (either server-side semantic rejection or client-side generation failure) vs unexpectedly passed. - */ - private record RejectionSummary(int total, int serverRejected, int clientBlocked, int unexpectedlyPassed) {} - - private static String percent(double value) { - return String.format(Locale.ROOT, "%.2f%%", value); - } - - private static String pretty(Map map) { - if (map == null) { - return " "; - } - String body = map.entrySet().stream() - .map(entry -> entry.getKey() + " = " + entry.getValue()) - .collect(Collectors.joining("\n ", " ", "")); - return body; - } - - private static void println(Object message) { - System.out.println(message); - } - - private static void println() { - System.out.println(); - } - - private static Path resolveEnvPath(String[] args) { - if (args.length > 0) { - return Path.of(args[0]); - } - Path cwdEnv = Path.of(DEFAULT_ENV_FILE); - if (Files.exists(cwdEnv) && hasRequiredLlmKeys(cwdEnv)) { - return cwdEnv; - } - return Path.of(BUNDLED_ENV_FILE); - } - - private static boolean hasRequiredLlmKeys(Path envFile) { - Map entries = new HashMap<>(); - try { - for (String line : Files.readAllLines(envFile)) { - String trimmed = line.trim(); - if (trimmed.isEmpty() || trimmed.startsWith("#")) { - continue; - } - int separator = trimmed.indexOf('='); - if (separator <= 0) { - continue; - } - entries.put(trimmed.substring(0, separator).trim(), trimmed.substring(separator + 1).trim()); - } - } catch (IOException exception) { - return false; - } - for (String key : REQUIRED_LLM_KEYS) { - if (entries.getOrDefault(key, "").isBlank()) { - return false; - } - } - return true; - } -} \ No newline at end of file +package net.openan.a2at.sample.task_t; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.server.A2ATServer; + +/** + * End-to-end accuracy demo for the {@code Task-T} extension. + * + *

        The demo closes the loop between the client facade and the server facade over the + * {@code Task-T/network-layer/private-line-complaint/v1} template (transfer / private-line business complaint + * diagnosis) in two independent cases: + * + *

          + *
        1. {@code A2ATClient#generateTaskPromptFromText} fed with natural language; + *
        2. {@code A2ATClient#generateTaskPromptFromDataWithSchema} fed with a slot-value map plus a semantics schema; + *
        3. {@code generateTaskPromptFromText} fed with key-slot-missing content, expected to be rejected by the + * server-side semantic validation. + *
        + * + *

        Each generated prompt is passed to {@code A2ATServer#validateTaskPromptAndDataFilling}; for cases one and two the + * extracted parameters are compared against the sample ground truth and the results are aggregated into a per-case + * field accuracy and sample pass rate, while case three reports a server-side validation rejection rate and an overall + * interception rate (server rejection plus client-side generation block) over the negative samples. + * + *

        Run with {@code java ... TaskTDemoMain [env-file-path] [case]}. The env file resolves as follows: an explicit + * first argument wins; otherwise a {@code client.env} in the working directory (repo root) that carries the required + * LLM keys; otherwise the bundled sample template {@code a2a-t-sample/src/main/resources/sample/task_t/client.env}. A + * working-directory {@code client.env} is only honored when it defines non-blank {@code A2AT_LLM_PROVIDER}, + * {@code A2AT_LLM_MODEL} and {@code A2AT_LLM_API_KEY} (cf. the leftover {@code subscribe_incident} template in newer + * checkouts would otherwise shadow the Task-T sample); otherwise the bundled template is used. The env must configure a + * reachable OpenAI-compatible LLM ({@code A2AT_LLM_API_KEY}, {@code A2AT_LLM_BASE_URL}, {@code A2AT_LLM_MODEL}, + * {@code A2AT_LLM_PROVIDER=openai}) — the server-side semantic validation performs one LLM call per sample. + * + *

        The second optional argument selects which case to run: {@code text} (case one), {@code data} (case two), + * {@code rejection} (case three) or {@code all} (default); when a single case is selected only its summary is printed. + */ +public final class TaskTDemoMain { + + private static final String DEFAULT_ENV_FILE = "client.env"; + + private static final String BUNDLED_ENV_FILE = Path.of( + "a2a-t-sample", "src", "main", "resources", "sample", "task_t", "client.env") + .toString(); + + private static final List REQUIRED_LLM_KEYS = + List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY"); + + private static final String CASE_TEXT = "text"; + + private static final String CASE_DATA = "data"; + + private static final String CASE_REJECTION = "rejection"; + + private static final String CASE_ALL = "all"; + + private TaskTDemoMain() {} + + /** + * Runs one or all client-API cases against the built-in private-line complaint diagnosis samples. + * + * @param args first optional argument is the {@code .env} file path; second optional argument selects the case + * ({@code text}, {@code data}, {@code rejection} or {@code all}) + */ + public static void main(String[] args) { + Path envPath = resolveEnvPath(args); + String caseSelection = resolveCaseSelection(args); + println("Task-T 准确率验证样例,env: " + envPath.toAbsolutePath() + (Files.exists(envPath) ? "" : " (不存在,请先配置)")); + println("模板: " + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); + println("用例: " + caseLabel(caseSelection)); + println(); + + A2ATClient client = new A2ATClient(envPath); + A2ATServer server = new A2ATServer(envPath); + + if (CASE_TEXT.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { + List textScores = runTextCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromText", textScores)); + println(); + } + if (CASE_DATA.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { + List dataScores = runDataWithSchemaCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromDataWithSchema", dataScores)); + println(); + } + if (CASE_REJECTION.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { + RejectionSummary rejectionSummary = runRejectionCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + printRejectionSummary(rejectionSummary); + println(); + } + } + + private static String resolveCaseSelection(String[] args) { + if (args.length > 1) { + String selection = args[1].toLowerCase(Locale.ROOT); + if (CASE_TEXT.equals(selection) + || CASE_DATA.equals(selection) + || CASE_REJECTION.equals(selection) + || CASE_ALL.equals(selection)) { + return selection; + } + System.err.println("未知用例参数: " + args[1] + ",可选值: text | data | rejection | all,将运行全部用例"); + return CASE_ALL; + } + return CASE_ALL; + } + + private static String caseLabel(String caseSelection) { + return switch (caseSelection) { + case CASE_TEXT -> "用例一:generateTaskPromptFromText"; + case CASE_DATA -> "用例二:generateTaskPromptFromDataWithSchema"; + case CASE_REJECTION -> "用例三:缺少关键槽位拒绝用例"; + default -> "全部(用例一 + 用例二 + 用例三)"; + }; + } + + private static List runTextCase(A2ATClient client, A2ATServer server) { + println("================ 用例一:generateTaskPromptFromText ================"); + List samples = TaskTPrivateLineComplaintSamples.textSamples(); + List scores = new ArrayList<>(); + for (int i = 0; i < samples.size(); i++) { + TaskTSample sample = samples.get(i); + printSampleHeader("用例一", i, samples.size(), sample.name()); + println("[输入] 自然语言文本:"); + println(sample.text()); + println(); + try { + MetadataContent metadata = + client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + printGeneratedMetadata(metadata); + TaskTAccuracyEvaluator.SampleScore score = validateAndScore(server, sample, metadata, "用例一"); + scores.add(score); + } catch (PromptGenerationException exception) { + printFailure("生成失败", exception); + scores.add(new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of())); + } + println(); + } + return scores; + } + + private static List runDataWithSchemaCase( + A2ATClient client, A2ATServer server) { + println("========== 用例二:generateTaskPromptFromDataWithSchema =========="); + List samples = TaskTPrivateLineComplaintSamples.dataWithSchemaSamples(); + List scores = new ArrayList<>(); + for (int i = 0; i < samples.size(); i++) { + TaskTSample sample = samples.get(i); + printSampleHeader("用例二", i, samples.size(), sample.name()); + println("[输入] 数据(data):"); + println(pretty(sample.data())); + println("[输入] 语义schema(schema):"); + println(pretty(sample.semanticsSchema())); + println(); + try { + MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + printGeneratedMetadata(metadata); + TaskTAccuracyEvaluator.SampleScore score = validateAndScore(server, sample, metadata, "用例二"); + scores.add(score); + } catch (PromptGenerationException exception) { + printFailure("生成失败", exception); + scores.add(new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of())); + } + println(); + } + return scores; + } + + /** + * Runs the rejection case over {@link TaskTPrivateLineComplaintSamples#rejectionSamples()}: each sample + * deliberately omits one or more key slots (or carries an invalid slot value), so the server-side semantic + * validation is expected to fail with {@code negotiation.semantic_rejected}. The sample set mixes a text variant + * (fed to {@code generateTaskPromptFromText}) and a data variant (fed to + * {@code generateTaskPromptFromDataWithSchema}); a sample is counted as "server-rejected" only when the validation + * throws {@link ContentValidationException} — the structured slot errors are printed per sample — while a failure + * already at prompt generation counts as a client-side block. The run aggregates a server-side validation rejection + * rate (server-rejected over total) side by side with an overall interception rate (server-rejected plus + * client-blocked over total), so the validation point itself can be read independently of client-side blocks. + */ + private static RejectionSummary runRejectionCase(A2ATClient client, A2ATServer server) { + println("========== 用例三:缺少关键槽位拒绝用例(期望 rejected) =========="); + List samples = TaskTPrivateLineComplaintSamples.rejectionSamples(); + int serverRejected = 0; + int clientBlocked = 0; + int unexpectedlyPassed = 0; + for (int i = 0; i < samples.size(); i++) { + TaskTRejectionSample sample = samples.get(i); + printSampleHeader("用例三", i, samples.size(), sample.name()); + try { + MetadataContent metadata; + if (sample.data() != null) { + println("[输入] 数据(data,客户端 key,缺关键槽位):"); + println(pretty(sample.data())); + println("[输入] 语义schema(schema):"); + println(pretty(sample.semanticsSchema())); + println(); + println("[生成] generateTaskPromptFromDataWithSchema:"); + metadata = client.generateTaskPromptFromDataWithSchema( + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + } else { + println("[输入] 自然语言文本 (缺少关键槽位):"); + println(sample.text()); + println(); + println("[生成] generateTaskPromptFromText:"); + metadata = + client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + } + System.out.println(" templateUri : " + metadata.templateUri()); + System.out.println(" promptText : " + metadata.promptText()); + System.out.println(" extensionUri : " + metadata.extensionUri()); + println(); + Map extracted = server.validateTaskPromptAndDataFilling( + metadata.promptText(), + sample.validationSchema(), + StandardTemplates.PRIVATE_LINE_COMPLAINT) + .data(); + unexpectedlyPassed++; + println("[意外通过] 应被拒绝却校验通过,提取参数:"); + println(pretty(extracted)); + } catch (ContentValidationException exception) { + serverRejected++; + System.out.println("[正确拒绝] [" + exception.getCode() + "] " + exception.getMessage()); + printValidationErrors(exception); + } catch (PromptGenerationException exception) { + clientBlocked++; + System.out.println("[生成阶段拦截] 客户端生成即失败,[" + exception.getCode() + "] " + exception.getMessage()); + } + println(); + } + return new RejectionSummary(samples.size(), serverRejected, clientBlocked, unexpectedlyPassed); + } + + private static void printValidationErrors(ContentValidationException exception) { + List errors = exception.errors(); + if (errors == null || errors.isEmpty()) { + println(" (无结构化错误明细)"); + return; + } + for (SlotValidationError error : errors) { + println(" slot=" + error.slotName() + " code=" + error.code() + " message=" + error.message()); + } + } + + private static TaskTAccuracyEvaluator.SampleScore validateAndScore( + A2ATServer server, TaskTSample sample, MetadataContent metadata, String caseLabel) { + try { + Map extracted = server.validateTaskPromptAndDataFilling( + metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT) + .data(); + println("[服务端] " + caseLabel + " validateTaskPromptAndDataFilling 通过,提取参数:"); + println(pretty(extracted)); + List fields = TaskTAccuracyEvaluator.scoreFields(sample, extracted); + printFieldScores(fields); + return new TaskTAccuracyEvaluator.SampleScore(sample.name(), true, fields); + } catch (ContentValidationException exception) { + printFailure("校验不通过", exception); + return new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of()); + } + } + + private static void printSampleHeader(String caseLabel, int index, int total, String name) { + System.out.println( + "──────────── " + caseLabel + " 样本[" + (index + 1) + "/" + total + "] " + name + " ────────────"); + } + + private static void printGeneratedMetadata(MetadataContent metadata) { + println("[生成] MetadataContent:"); + println(" extensionUri: " + metadata.extensionUri()); + println(" templateUri : " + metadata.templateUri()); + println(" promptText : "); + println(metadata.promptText()); + } + + private static void printFieldScores(List fields) { + println("[比对] 字段级命中 (命中规则: 归一化后相同或互相包含):"); + for (TaskTAccuracyEvaluator.FieldScore field : fields) { + String mark = field.matched() ? "✔ 命中" : "✘ 未命中"; + String detail = field.matched() ? "" : " " + field.detail(); + println(" " + mark + " " + field.slot() + detail); + } + } + + private static void printFailure(String stage, A2ATError error) { + println("[失败] " + stage + ": [" + error.getCode() + "] " + error.getMessage()); + if (error instanceof ContentValidationException validationException) { + List errors = validationException.errors(); + if (!errors.isEmpty()) { + println(" 详细原因:"); + for (SlotValidationError slotError : errors) { + println(" slot: " + slotError.slotName() + + ", code: " + slotError.code() + + ", message: " + slotError.message()); + } + } + Map partialParams = validationException.params(); + if (!partialParams.isEmpty()) { + println(" LLM部分提取参数:"); + println(pretty(partialParams)); + } + } + Throwable cause = error.getCause(); + if (cause != null) { + println(" 根因异常: " + cause.getClass().getName() + ": " + cause.getMessage()); + } + } + + private static void printSummary(TaskTAccuracyEvaluator.Summary summary) { + System.out.println("──────────── 汇总: " + summary.api() + " ────────────"); + println(" 样本数: " + summary.sampleCount() + + " 通过样本: " + summary.passedSamples() + + " 字段命中: " + summary.matchedFields() + "/" + summary.expectedFields()); + println(" 字段准确率: " + percent(summary.fieldAccuracyPercent()) + " 样本通过率: " + + percent(summary.samplePassRatePercent())); + } + + private static void printRejectionSummary(RejectionSummary summary) { + System.out.println("──────────── 汇总: 缺少关键槽位拒绝用例 ────────────"); + println(" 样本数: " + summary.total() + + " 服务端拒绝: " + summary.serverRejected() + + " 生成阶段拦截: " + summary.clientBlocked() + + " 意外通过: " + summary.unexpectedlyPassed()); + println(" 服务端校验点拒绝率: " + percent(rejectionRate(summary.serverRejected(), summary.total())) + + " 总拦截率(服务端+客户端): " + + percent(rejectionRate(summary.serverRejected() + summary.clientBlocked(), summary.total()))); + } + + /** Rate guarded against a zero sample set. */ + private static double rejectionRate(int numerator, int total) { + return total == 0 ? 0d : numerator * 100.0 / total; + } + + /** + * Aggregated result of the rejection case over the key-slot-missing negative samples: how many were intercepted + * (either server-side semantic rejection or client-side generation failure) vs unexpectedly passed. + */ + private record RejectionSummary(int total, int serverRejected, int clientBlocked, int unexpectedlyPassed) {} + + private static String percent(double value) { + return String.format(Locale.ROOT, "%.2f%%", value); + } + + private static String pretty(Map map) { + if (map == null) { + return " "; + } + String body = map.entrySet().stream() + .map(entry -> entry.getKey() + " = " + entry.getValue()) + .collect(Collectors.joining("\n ", " ", "")); + return body; + } + + private static void println(Object message) { + System.out.println(message); + } + + private static void println() { + System.out.println(); + } + + private static Path resolveEnvPath(String[] args) { + if (args.length > 0) { + return Path.of(args[0]); + } + Path cwdEnv = Path.of(DEFAULT_ENV_FILE); + if (Files.exists(cwdEnv) && hasRequiredLlmKeys(cwdEnv)) { + return cwdEnv; + } + return Path.of(BUNDLED_ENV_FILE); + } + + private static boolean hasRequiredLlmKeys(Path envFile) { + Map entries = new HashMap<>(); + try { + for (String line : Files.readAllLines(envFile)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int separator = trimmed.indexOf('='); + if (separator <= 0) { + continue; + } + entries.put( + trimmed.substring(0, separator).trim(), + trimmed.substring(separator + 1).trim()); + } + } catch (IOException exception) { + return false; + } + for (String key : REQUIRED_LLM_KEYS) { + if (entries.getOrDefault(key, "").isBlank()) { + return false; + } + } + return true; + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java index 1c9a692a..03cbcaf4 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java @@ -1,232 +1,233 @@ -package net.openan.a2at.sample.task_t; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Built-in evaluation samples for the {@code Task-T/network-layer/private-line-complaint/v1} template (transfer / - * private-line business complaint diagnosis), loaded from the packaged JSON file - * {@code a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json}. - * - *

        The sample set is split into two groups matching the two client APIs under test: - * - *

          - *
        • {@link #textSamples()} feed {@code A2ATClient#generateTaskPromptFromText} with concise colloquial natural - * language — deliberately not field-listed, so a run exercises the SDK natural-language parsing - * capability;
        • - *
        • {@link #dataWithSchemaSamples()} feed {@code A2ATClient#generateTaskPromptFromDataWithSchema} with a - * structured input whose {@code data} carries English business fields — not the template slot names.
        • - *
        • {@link #rejectionSamples()} feed {@code generateTaskPromptFromText} with content that deliberately omits key - * slots; server-side semantic validation is expected to reject them — scored separately from the accuracy - * samples.
        • - *
        - * - *

        Per design, the client-side keys and the server-side keys intentionally diverge: the client submits its - * business fields under its own key names, the SDK renders them into the template prompt, and the server re-extracts - * them from the rendered prompt under the server-side key names. This cross-key round trip exercises the a2a-t - * SDK adaptation capability — neither side may assume the other's key layout. - * - *

        错误码业务含义失败用例数
        {esc(code)}{esc(zh)}
        - * - * - * - * - * - * - * - *
        near semantics, distinct keys
        client key (data / semantics schema)server key (validation schema / extracted params)meaning
        {@code portName}{@code accessPort}接入端口名称
        {@code complaintScenario}{@code bizScenario}投诉分类(专线中断/专线质差)
        {@code faultStartTime}{@code faultTime}问题发生时间
        {@code ticketNo}{@code eventSerialNo}OSS 侧事件流水号
        {@code faultDetailText}{@code faultDetail}投诉/故障详情
        - * - *

        Expected values ({@link TaskTSample#expectedParams()}) are keyed by the server field names, the keys the - * accuracy evaluator actually reads out of {@code validateTaskPromptAndDataFilling}; they hold canonical key facts that - * always appear in the input. Structured fields (port, scenario, time, serial) hit only when equal after - * whitespace-and-case normalization; the free-text detail field hits on containment. - */ -public final class TaskTPrivateLineComplaintSamples { - - /** Client key: the addressed access port name, e.g. {@code P781-珠江新城-PTN7900-23-TPA1EG24-17}. */ - public static final String CLIENT_PORT = "portName"; - - /** Client key: complaint category scenario, {@code 专线中断} or {@code 专线质差}. */ - public static final String CLIENT_SCENARIO = "complaintScenario"; - - /** Client key: problem occurrence time (ISO-8601). */ - public static final String CLIENT_TIME = "faultStartTime"; - - /** Client key: OSS-side complaint/event ticket number. */ - public static final String CLIENT_TICKET = "ticketNo"; - - /** Client key: free-text complaint detail. */ - public static final String CLIENT_DETAIL = "faultDetailText"; - - /** Server key (extracted parameter): access port name; near-semantics twin of {@link #CLIENT_PORT}. */ - public static final String SERVER_PORT = "accessPort"; - - /** Server key (extracted parameter): complaint category; near-semantics twin of {@link #CLIENT_SCENARIO}. */ - public static final String SERVER_SCENARIO = "bizScenario"; - - /** Server key (extracted parameter): problem occurrence time; near-semantics twin of {@link #CLIENT_TIME}. */ - public static final String SERVER_TIME = "faultTime"; - - /** Server key (extracted parameter): event serial number; near-semantics twin of {@link #CLIENT_TICKET}. */ - public static final String SERVER_TICKET = "eventSerialNo"; - - /** Server key (extracted parameter): complaint detail; near-semantics twin of {@link #CLIENT_DETAIL}. */ - public static final String SERVER_DETAIL = "faultDetail"; - - private static final String SAMPLE_RESOURCE = "sample/task_t/private-line-complaint-samples.json"; - - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - private static final Loaded LOADED = load(); - - private TaskTPrivateLineComplaintSamples() { - } - - /** - * Natural-language samples for {@code generateTaskPromptFromText}. - * - * @return immutable text sample list - */ - public static List textSamples() { - return LOADED.samples().stream().filter(sample -> sample.text() != null).toList(); - } - - /** - * Data-plus-schema samples for {@code generateTaskPromptFromDataWithSchema}. - * - *

        Each sample passes business fields under the client keys in {@code data} plus a client-side - * semantics {@code schema}; the server-side validation schema re-extracts the parameters under the server - * keys, so a run exercises the cross-key adaptation of the SDK. - * - * @return immutable data sample list - */ - public static List dataWithSchemaSamples() { - return LOADED.samples().stream().filter(sample -> sample.data() != null).toList(); - } - - /** - * Rejection samples that deliberately omit one or more key slots. - * - *

        Two variants mirror the two client APIs under test: {@code text} samples are colloquial natural-language - * complaints missing a required fact (access port, complaint scenario), while {@code data} samples are structured - * inputs under the client field names that omit a required field or carry a complaint category outside the contract - * {@code enum}. Time and serial are optional slots and are deliberately not used as rejection criteria — - * their absence is exercised by the positive samples {@code text-optional-slots-missing} / - * {@code data-optional-slots-missing} instead. The server-side semantic validation is expected to reject these - * samples with {@code negotiation.semantic_rejected}; they are scored separately from the accuracy samples and are - * never fed into the field-accuracy evaluation. - * - * @return immutable rejection sample list - */ - public static List rejectionSamples() { - return LOADED.rejections(); - } - - private static Loaded load() { - try (InputStream in = TaskTPrivateLineComplaintSamples.class.getResourceAsStream("/" + SAMPLE_RESOURCE)) { - if (in == null) { - throw new IllegalStateException("sample resource not found on classpath: " + SAMPLE_RESOURCE); - } - JsonNode root = OBJECT_MAPPER.readTree(in); - Map semanticsSchema = toObjectMap(root.get("semanticsSchema")); - Map validationSchema = toObjectMap(root.get("validationSchema")); - List samples = new ArrayList<>(); - for (JsonNode node : root.get("samples")) { - String name = node.get("name").asText(); - JsonNode text = node.get("text"); - JsonNode data = node.get("data"); - Map expected = toStringMap(node.get("expected")); - if (data == null) { - samples.add(new TaskTSample(name, text.asText(), null, null, expected, validationSchema)); - } else { - samples.add(new TaskTSample( - name, null, toObjectMap(data), semanticsSchema, expected, validationSchema)); - } - } - List rejections = new ArrayList<>(); - JsonNode rejectionNodes = root.get("rejectionSamples"); - if (rejectionNodes != null) { - for (JsonNode node : rejectionNodes) { - String name = node.get("name").asText(); - JsonNode data = node.get("data"); - if (data == null) { - rejections.add(new TaskTRejectionSample(name, node.get("text").asText(), null, null, validationSchema)); - } else { - rejections.add(new TaskTRejectionSample(name, null, toObjectMap(data), semanticsSchema, validationSchema)); - } - } - } - return new Loaded(List.copyOf(samples), List.copyOf(rejections)); - } catch (IOException error) { - throw new IllegalStateException("failed to load task-t samples from " + SAMPLE_RESOURCE, error); - } - } - - /** Loaded evaluation sample set split into accuracy samples and rejection samples. */ - private record Loaded(List samples, List rejections) {} - - private static Map toObjectMap(JsonNode node) { - return OBJECT_MAPPER.convertValue(node, new TypeReference>() {}); - } - - private static Map toStringMap(JsonNode node) { - return OBJECT_MAPPER.convertValue(node, new TypeReference>() {}); - } -} - -/** - * One private-line complaint diagnosis evaluation sample. - * - * @param name sample identifier shown in the accuracy report - * @param text natural-language input for the FromText case; {@code null} when the sample only targets the data case - * @param data structured business-field input for the FromDataWithSchema case, keyed by the client field - * names; {@code null} when the sample only targets the text case - * @param semanticsSchema client-side field-semantics schema, keyed by the client field names; may be {@code null} - * when {@code data} is {@code null} - * @param expectedParams ground truth business values, keyed by the server field names (the keys read out of - * {@code validateTaskPromptAndDataFilling}); these are the fields actually scored - * @param validationSchema caller-provided JSON parameter schema passed to {@code validateTaskPromptAndDataFilling}, - * keyed by the server field names - * @since 2026-08 - */ -record TaskTSample( - String name, - String text, - Map data, - Map semanticsSchema, - Map expectedParams, - Map validationSchema) {} - -/** - * One negative sample for the rejection check: content that deliberately omits one or more key slots (or carries an - * invalid slot value) and is expected to be rejected by the server-side semantic validation. Two variants exist, - * mirroring the two client APIs under test: - * - *

          - *
        • {@code text} variant feeds {@code A2ATClient#generateTaskPromptFromText} with colloquial natural language;
        • - *
        • {@code data} variant feeds {@code A2ATClient#generateTaskPromptFromDataWithSchema} with structured input keyed - * by the client field names plus the client-side semantics schema.
        • - *
        - * - * @param name sample identifier shown in the rejection report - * @param text natural-language input for the FromText variant; {@code null} when the sample only targets the data case - * @param data structured business-field input for the FromDataWithSchema variant, keyed by the client field - * names; {@code null} when the sample only targets the text case - * @param semanticsSchema client-side field-semantics schema, keyed by the client field names; may be {@code null} - * when {@code data} is {@code null} - * @param validationSchema caller-provided JSON parameter schema passed to {@code validateTaskPromptAndDataFilling} - * @since 2026-08 - */ -record TaskTRejectionSample( - String name, - String text, - Map data, - Map semanticsSchema, - Map validationSchema) {} \ No newline at end of file +package net.openan.a2at.sample.task_t; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Built-in evaluation samples for the {@code Task-T/network-layer/private-line-complaint/v1} template (transfer / + * private-line business complaint diagnosis), loaded from the packaged JSON file + * {@code a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json}. + * + *

        The sample set is split into two groups matching the two client APIs under test: + * + *

          + *
        • {@link #textSamples()} feed {@code A2ATClient#generateTaskPromptFromText} with concise colloquial natural + * language — deliberately not field-listed, so a run exercises the SDK natural-language parsing + * capability; + *
        • {@link #dataWithSchemaSamples()} feed {@code A2ATClient#generateTaskPromptFromDataWithSchema} with a structured + * input whose {@code data} carries English business fields — not the template slot names. + *
        • {@link #rejectionSamples()} feed {@code generateTaskPromptFromText} with content that deliberately omits key + * slots; server-side semantic validation is expected to reject them — scored separately from the accuracy + * samples. + *
        + * + *

        Per design, the client-side keys and the server-side keys intentionally diverge: the client submits its + * business fields under its own key names, the SDK renders them into the template prompt, and the server re-extracts + * them from the rendered prompt under the server-side key names. This cross-key round trip exercises the a2a-t + * SDK adaptation capability — neither side may assume the other's key layout. + * + * + * + * + * + * + * + * + * + *
        near semantics, distinct keys
        client key (data / semantics schema)server key (validation schema / extracted params)meaning
        {@code portName}{@code accessPort}接入端口名称
        {@code complaintScenario}{@code bizScenario}投诉分类(专线中断/专线质差)
        {@code faultStartTime}{@code faultTime}问题发生时间
        {@code ticketNo}{@code eventSerialNo}OSS 侧事件流水号
        {@code faultDetailText}{@code faultDetail}投诉/故障详情
        + * + *

        Expected values ({@link TaskTSample#expectedParams()}) are keyed by the server field names, the keys the + * accuracy evaluator actually reads out of {@code validateTaskPromptAndDataFilling}; they hold canonical key facts that + * always appear in the input. Structured fields (port, scenario, time, serial) hit only when equal after + * whitespace-and-case normalization; the free-text detail field hits on containment. + */ +public final class TaskTPrivateLineComplaintSamples { + + /** Client key: the addressed access port name, e.g. {@code P781-珠江新城-PTN7900-23-TPA1EG24-17}. */ + public static final String CLIENT_PORT = "portName"; + + /** Client key: complaint category scenario, {@code 专线中断} or {@code 专线质差}. */ + public static final String CLIENT_SCENARIO = "complaintScenario"; + + /** Client key: problem occurrence time (ISO-8601). */ + public static final String CLIENT_TIME = "faultStartTime"; + + /** Client key: OSS-side complaint/event ticket number. */ + public static final String CLIENT_TICKET = "ticketNo"; + + /** Client key: free-text complaint detail. */ + public static final String CLIENT_DETAIL = "faultDetailText"; + + /** Server key (extracted parameter): access port name; near-semantics twin of {@link #CLIENT_PORT}. */ + public static final String SERVER_PORT = "accessPort"; + + /** Server key (extracted parameter): complaint category; near-semantics twin of {@link #CLIENT_SCENARIO}. */ + public static final String SERVER_SCENARIO = "bizScenario"; + + /** Server key (extracted parameter): problem occurrence time; near-semantics twin of {@link #CLIENT_TIME}. */ + public static final String SERVER_TIME = "faultTime"; + + /** Server key (extracted parameter): event serial number; near-semantics twin of {@link #CLIENT_TICKET}. */ + public static final String SERVER_TICKET = "eventSerialNo"; + + /** Server key (extracted parameter): complaint detail; near-semantics twin of {@link #CLIENT_DETAIL}. */ + public static final String SERVER_DETAIL = "faultDetail"; + + private static final String SAMPLE_RESOURCE = "sample/task_t/private-line-complaint-samples.json"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final Loaded LOADED = load(); + + private TaskTPrivateLineComplaintSamples() {} + + /** + * Natural-language samples for {@code generateTaskPromptFromText}. + * + * @return immutable text sample list + */ + public static List textSamples() { + return LOADED.samples().stream().filter(sample -> sample.text() != null).toList(); + } + + /** + * Data-plus-schema samples for {@code generateTaskPromptFromDataWithSchema}. + * + *

        Each sample passes business fields under the client keys in {@code data} plus a client-side semantics + * {@code schema}; the server-side validation schema re-extracts the parameters under the server keys, so a + * run exercises the cross-key adaptation of the SDK. + * + * @return immutable data sample list + */ + public static List dataWithSchemaSamples() { + return LOADED.samples().stream().filter(sample -> sample.data() != null).toList(); + } + + /** + * Rejection samples that deliberately omit one or more key slots. + * + *

        Two variants mirror the two client APIs under test: {@code text} samples are colloquial natural-language + * complaints missing a required fact (access port, complaint scenario), while {@code data} samples are structured + * inputs under the client field names that omit a required field or carry a complaint category outside the contract + * {@code enum}. Time and serial are optional slots and are deliberately not used as rejection criteria — + * their absence is exercised by the positive samples {@code text-optional-slots-missing} / + * {@code data-optional-slots-missing} instead. The server-side semantic validation is expected to reject these + * samples with {@code negotiation.semantic_rejected}; they are scored separately from the accuracy samples and are + * never fed into the field-accuracy evaluation. + * + * @return immutable rejection sample list + */ + public static List rejectionSamples() { + return LOADED.rejections(); + } + + private static Loaded load() { + try (InputStream in = TaskTPrivateLineComplaintSamples.class.getResourceAsStream("/" + SAMPLE_RESOURCE)) { + if (in == null) { + throw new IllegalStateException("sample resource not found on classpath: " + SAMPLE_RESOURCE); + } + JsonNode root = OBJECT_MAPPER.readTree(in); + Map semanticsSchema = toObjectMap(root.get("semanticsSchema")); + Map validationSchema = toObjectMap(root.get("validationSchema")); + List samples = new ArrayList<>(); + for (JsonNode node : root.get("samples")) { + String name = node.get("name").asText(); + JsonNode text = node.get("text"); + JsonNode data = node.get("data"); + Map expected = toStringMap(node.get("expected")); + if (data == null) { + samples.add(new TaskTSample(name, text.asText(), null, null, expected, validationSchema)); + } else { + samples.add(new TaskTSample( + name, null, toObjectMap(data), semanticsSchema, expected, validationSchema)); + } + } + List rejections = new ArrayList<>(); + JsonNode rejectionNodes = root.get("rejectionSamples"); + if (rejectionNodes != null) { + for (JsonNode node : rejectionNodes) { + String name = node.get("name").asText(); + JsonNode data = node.get("data"); + if (data == null) { + rejections.add(new TaskTRejectionSample( + name, node.get("text").asText(), null, null, validationSchema)); + } else { + rejections.add(new TaskTRejectionSample( + name, null, toObjectMap(data), semanticsSchema, validationSchema)); + } + } + } + return new Loaded(List.copyOf(samples), List.copyOf(rejections)); + } catch (IOException error) { + throw new IllegalStateException("failed to load task-t samples from " + SAMPLE_RESOURCE, error); + } + } + + /** Loaded evaluation sample set split into accuracy samples and rejection samples. */ + private record Loaded(List samples, List rejections) {} + + private static Map toObjectMap(JsonNode node) { + return OBJECT_MAPPER.convertValue(node, new TypeReference>() {}); + } + + private static Map toStringMap(JsonNode node) { + return OBJECT_MAPPER.convertValue(node, new TypeReference>() {}); + } +} + +/** + * One private-line complaint diagnosis evaluation sample. + * + * @param name sample identifier shown in the accuracy report + * @param text natural-language input for the FromText case; {@code null} when the sample only targets the data case + * @param data structured business-field input for the FromDataWithSchema case, keyed by the client field names; + * {@code null} when the sample only targets the text case + * @param semanticsSchema client-side field-semantics schema, keyed by the client field names; may be {@code null} when + * {@code data} is {@code null} + * @param expectedParams ground truth business values, keyed by the server field names (the keys read out of + * {@code validateTaskPromptAndDataFilling}); these are the fields actually scored + * @param validationSchema caller-provided JSON parameter schema passed to {@code validateTaskPromptAndDataFilling}, + * keyed by the server field names + * @since 2026-08 + */ +record TaskTSample( + String name, + String text, + Map data, + Map semanticsSchema, + Map expectedParams, + Map validationSchema) {} + +/** + * One negative sample for the rejection check: content that deliberately omits one or more key slots (or carries an + * invalid slot value) and is expected to be rejected by the server-side semantic validation. Two variants exist, + * mirroring the two client APIs under test: + * + *

          + *
        • {@code text} variant feeds {@code A2ATClient#generateTaskPromptFromText} with colloquial natural language; + *
        • {@code data} variant feeds {@code A2ATClient#generateTaskPromptFromDataWithSchema} with structured input keyed + * by the client field names plus the client-side semantics schema. + *
        + * + * @param name sample identifier shown in the rejection report + * @param text natural-language input for the FromText variant; {@code null} when the sample only targets the data case + * @param data structured business-field input for the FromDataWithSchema variant, keyed by the client field + * names; {@code null} when the sample only targets the text case + * @param semanticsSchema client-side field-semantics schema, keyed by the client field names; may be {@code null} when + * {@code data} is {@code null} + * @param validationSchema caller-provided JSON parameter schema passed to {@code validateTaskPromptAndDataFilling} + * @since 2026-08 + */ +record TaskTRejectionSample( + String name, + String text, + Map data, + Map semanticsSchema, + Map validationSchema) {} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index 0fd5e355..37c78476 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -1,288 +1,289 @@ -package net.openan.a2at.sample.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import java.io.IOException; -import java.lang.reflect.Method; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.server.A2ATServer; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Verifies the output symmetry of the negotiation content layer across the client and the server facade. - * - *

        Both facades expose the twelve negotiation API methods as one symmetric surface and produce, for identical inputs, - * identical generation results: the whole MetadataContent record and the metadata map built from it are equal on both - * sides. The inputs are the fixed golden fixture inputs of the negotiation module, so this test also proves that both - * facades are wired to the same built-in resources. - */ -class NegotiationFacadeOutputSymmetryTest { - - private static final String TEST_MOCK_PROVIDER = "test-symmetry-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); - } - } - - private static final List NEGOTIATION_API_METHODS = List.of( - "generateNegotiationProposePromptFromData", - "generateNegotiationAcceptPromptFromData", - "generateNegotiationRejectPromptFromData", - "generateNegotiationAbortPromptFromData", - "generateNegotiationProposePromptFromText", - "generateNegotiationAcceptPromptFromText", - "generateNegotiationRejectPromptFromText", - "generateNegotiationAbortPromptFromText", - "validateProposePromptAndDataFilling", - "validateAcceptPromptAndDataFilling", - "validateRejectPromptAndDataFilling", - "validateAbortPromptAndDataFilling"); - - @TempDir - Path tempDir; - - @Test - void bothFacadesExposeTheSameTwelveMethodSignatures() { - Map clientSurface = negotiationApiSurface(A2ATClient.class); - Map serverSurface = negotiationApiSurface(A2ATServer.class); - - assertEquals(NEGOTIATION_API_METHODS.stream().sorted().toList(), sortedKeys(clientSurface)); - assertEquals(clientSurface, serverSurface); - } - - @Test - void bothFacadesProduceIdenticalMetadataContentForTheSameInput() throws IOException { - for (String language : List.of("zh-CN", "en-US")) { - A2ATClient client = new A2ATClient(envFile(language, "client.env")); - A2ATServer server = new A2ATServer(envFile(language, "server.env")); - - for (SymmetryCase symmetryCase : symmetryCases()) { - MetadataContent clientResult = symmetryCase.generate( - client::generateNegotiationProposePromptFromData, - client::generateNegotiationAcceptPromptFromData, - client::generateNegotiationRejectPromptFromData); - MetadataContent serverResult = symmetryCase.generate( - server::generateNegotiationProposePromptFromData, - server::generateNegotiationAcceptPromptFromData, - server::generateNegotiationRejectPromptFromData); - - assertNotNull(clientResult.promptText()); - assertEquals(clientResult, serverResult, "case " + symmetryCase.label() + " [" + language + "]"); - assertEquals(clientResult.buildMetadataContent(), serverResult.buildMetadataContent()); - assertEquals(symmetryCase.templateUri().uri(), clientResult.templateUri()); - assertEquals( - symmetryCase.performative(), - clientResult.negotiationContext().performative(), - "stamped performative of case " + symmetryCase.label() + " [" + language + "]"); - } - } - } - - private Path envFile(String language, String fileName) throws IOException { - Path envFile = tempDir.resolve(language + "-" + fileName); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=%s - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=test-symmetry-mock - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(language)); - return envFile; - } - - private static List sortedKeys(Map surface) { - return surface.keySet().stream().sorted().collect(Collectors.toList()); - } - - private static Map negotiationApiSurface(Class facade) { - Map surface = new LinkedHashMap<>(); - for (Method method : facade.getMethods()) { - if (NEGOTIATION_API_METHODS.contains(method.getName())) { - String parameters = Arrays.stream(method.getParameterTypes()) - .map(Class::getName) - .collect(Collectors.joining(",")); - surface.put( - method.getName(), - parameters + "->" + method.getReturnType().getName()); - } - } - return surface; - } - - /** - * Builds the nine golden fixture cases of the negotiation module: one fixed typed content, context and template URI - * per negotiation type and performative, identical on the client and the server side. - */ - private static List symmetryCases() { - return List.of( - new SymmetryCase( - "information_propose", - NegotiationPerformative.PROPOSE, - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, - new NegotiationProposeData( - new NegotiationContext( - "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent( - List.of( - new NegotiationItem( - "ran-energy-saving area information", "e.g. Songshan Lake"), - new NegotiationItem("VLANId", null)), - "OR"))), - new SymmetryCase( - "target_propose", - NegotiationPerformative.PROPOSE, - StandardTemplates.TARGET_NEGOTIATION_PROPOSE, - new NegotiationProposeData( - new NegotiationContext( - "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5, NegotiationPerformative.PROPOSE), - new TargetProposeContent( - "Clarify the intent of the ran-energy-saving task.", - List.of(new NegotiationItem("task intent", "ran-energy-saving optimization")), - null, - List.of(new NegotiationItem("area", "which site is covered")), - null))), - new SymmetryCase( - "feasibility_propose", - NegotiationPerformative.PROPOSE, - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - new NegotiationProposeData( - new NegotiationContext( - "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, NegotiationPerformative.PROPOSE), - new FeasibilityProposeContent( - "Please assess the adjusted rate target.", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("adjusted target", "rate lowered to 2Mbps")), - null, - null))), - endingCase( - "information_accept", - NegotiationPerformative.ACCEPT, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, - new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of(new NegotiationItem("area information", "Songshan Lake")))), - endingCase( - "target_accept", - NegotiationPerformative.ACCEPT, - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, - new TargetEndingContent(NegotiationConclusion.ACCEPT, "The confirmed intent.", null)), - endingCase( - "feasibility_accept", - NegotiationPerformative.ACCEPT, - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "The target is achievable.")), - endingCase( - "information_reject", - NegotiationPerformative.REJECT, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, - new InformationEndingContent( - NegotiationConclusion.REJECT, - List.of(new NegotiationItem("area information", "not available")))), - endingCase( - "target_reject", - NegotiationPerformative.REJECT, - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, - new TargetEndingContent(NegotiationConclusion.REJECT, null, "The intent is unclear.")), - endingCase( - "feasibility_reject", - NegotiationPerformative.REJECT, - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - new FeasibilityEndingContent(NegotiationConclusion.REJECT, "The target is not achievable."))); - } - - private static SymmetryCase endingCase( - String label, - NegotiationPerformative performative, - TemplateUri templateUri, - NegotiationEndingContent content) { - return new SymmetryCase( - label, - performative, - templateUri, - new NegotiationEndingData( - new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, performative), content)); - } - - /** One symmetry case: a fixed input addressed to one of the three from-data generation methods. */ - private record SymmetryCase(String label, NegotiationPerformative performative, TemplateUri templateUri, Object data) { - - MetadataContent generate(ProposeGenerator propose, EndingGenerator accept, EndingGenerator reject) { - return switch (performative) { - case PROPOSE -> propose.generate((NegotiationProposeData) data, templateUri); - case ACCEPT -> accept.generate((NegotiationEndingData) data, templateUri); - case REJECT -> reject.generate((NegotiationEndingData) data, templateUri); - case ABORT -> throw new IllegalArgumentException("The symmetry cases carry no abort performative."); - }; - } - } - - @FunctionalInterface - private interface ProposeGenerator { - - MetadataContent generate(NegotiationProposeData data, TemplateUri templateUri); - } - - @FunctionalInterface - private interface EndingGenerator { - - MetadataContent generate(NegotiationEndingData data, TemplateUri templateUri); - } - - public static final class RecordingClient implements LLMClient { - - private final LLMClientConfig config; - - public RecordingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse("{}", config.model(), Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sample.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies the output symmetry of the negotiation content layer across the client and the server facade. + * + *

        Both facades expose the twelve negotiation API methods as one symmetric surface and produce, for identical inputs, + * identical generation results: the whole MetadataContent record and the metadata map built from it are equal on both + * sides. The inputs are the fixed golden fixture inputs of the negotiation module, so this test also proves that both + * facades are wired to the same built-in resources. + */ +class NegotiationFacadeOutputSymmetryTest { + + private static final String TEST_MOCK_PROVIDER = "test-symmetry-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + private static final List NEGOTIATION_API_METHODS = List.of( + "generateNegotiationProposePromptFromData", + "generateNegotiationAcceptPromptFromData", + "generateNegotiationRejectPromptFromData", + "generateNegotiationAbortPromptFromData", + "generateNegotiationProposePromptFromText", + "generateNegotiationAcceptPromptFromText", + "generateNegotiationRejectPromptFromText", + "generateNegotiationAbortPromptFromText", + "validateProposePromptAndDataFilling", + "validateAcceptPromptAndDataFilling", + "validateRejectPromptAndDataFilling", + "validateAbortPromptAndDataFilling"); + + @TempDir + Path tempDir; + + @Test + void bothFacadesExposeTheSameTwelveMethodSignatures() { + Map clientSurface = negotiationApiSurface(A2ATClient.class); + Map serverSurface = negotiationApiSurface(A2ATServer.class); + + assertEquals(NEGOTIATION_API_METHODS.stream().sorted().toList(), sortedKeys(clientSurface)); + assertEquals(clientSurface, serverSurface); + } + + @Test + void bothFacadesProduceIdenticalMetadataContentForTheSameInput() throws IOException { + for (String language : List.of("zh-CN", "en-US")) { + A2ATClient client = new A2ATClient(envFile(language, "client.env")); + A2ATServer server = new A2ATServer(envFile(language, "server.env")); + + for (SymmetryCase symmetryCase : symmetryCases()) { + MetadataContent clientResult = symmetryCase.generate( + client::generateNegotiationProposePromptFromData, + client::generateNegotiationAcceptPromptFromData, + client::generateNegotiationRejectPromptFromData); + MetadataContent serverResult = symmetryCase.generate( + server::generateNegotiationProposePromptFromData, + server::generateNegotiationAcceptPromptFromData, + server::generateNegotiationRejectPromptFromData); + + assertNotNull(clientResult.promptText()); + assertEquals(clientResult, serverResult, "case " + symmetryCase.label() + " [" + language + "]"); + assertEquals(clientResult.buildMetadataContent(), serverResult.buildMetadataContent()); + assertEquals(symmetryCase.templateUri().uri(), clientResult.templateUri()); + assertEquals( + symmetryCase.performative(), + clientResult.negotiationContext().performative(), + "stamped performative of case " + symmetryCase.label() + " [" + language + "]"); + } + } + } + + private Path envFile(String language, String fileName) throws IOException { + Path envFile = tempDir.resolve(language + "-" + fileName); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=%s + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-symmetry-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(language)); + return envFile; + } + + private static List sortedKeys(Map surface) { + return surface.keySet().stream().sorted().collect(Collectors.toList()); + } + + private static Map negotiationApiSurface(Class facade) { + Map surface = new LinkedHashMap<>(); + for (Method method : facade.getMethods()) { + if (NEGOTIATION_API_METHODS.contains(method.getName())) { + String parameters = Arrays.stream(method.getParameterTypes()) + .map(Class::getName) + .collect(Collectors.joining(",")); + surface.put( + method.getName(), + parameters + "->" + method.getReturnType().getName()); + } + } + return surface; + } + + /** + * Builds the nine golden fixture cases of the negotiation module: one fixed typed content, context and template URI + * per negotiation type and performative, identical on the client and the server side. + */ + private static List symmetryCases() { + return List.of( + new SymmetryCase( + "information_propose", + NegotiationPerformative.PROPOSE, + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, + new NegotiationProposeData( + new NegotiationContext( + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent( + List.of( + new NegotiationItem( + "ran-energy-saving area information", "e.g. Songshan Lake"), + new NegotiationItem("VLANId", null)), + "OR"))), + new SymmetryCase( + "target_propose", + NegotiationPerformative.PROPOSE, + StandardTemplates.TARGET_NEGOTIATION_PROPOSE, + new NegotiationProposeData( + new NegotiationContext( + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5, NegotiationPerformative.PROPOSE), + new TargetProposeContent( + "Clarify the intent of the ran-energy-saving task.", + List.of(new NegotiationItem("task intent", "ran-energy-saving optimization")), + null, + List.of(new NegotiationItem("area", "which site is covered")), + null))), + new SymmetryCase( + "feasibility_propose", + NegotiationPerformative.PROPOSE, + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + new NegotiationProposeData( + new NegotiationContext( + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, NegotiationPerformative.PROPOSE), + new FeasibilityProposeContent( + "Please assess the adjusted rate target.", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("adjusted target", "rate lowered to 2Mbps")), + null, + null))), + endingCase( + "information_accept", + NegotiationPerformative.ACCEPT, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, + new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of(new NegotiationItem("area information", "Songshan Lake")))), + endingCase( + "target_accept", + NegotiationPerformative.ACCEPT, + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "The confirmed intent.", null)), + endingCase( + "feasibility_accept", + NegotiationPerformative.ACCEPT, + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "The target is achievable.")), + endingCase( + "information_reject", + NegotiationPerformative.REJECT, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, + new InformationEndingContent( + NegotiationConclusion.REJECT, + List.of(new NegotiationItem("area information", "not available")))), + endingCase( + "target_reject", + NegotiationPerformative.REJECT, + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, + new TargetEndingContent(NegotiationConclusion.REJECT, null, "The intent is unclear.")), + endingCase( + "feasibility_reject", + NegotiationPerformative.REJECT, + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + new FeasibilityEndingContent(NegotiationConclusion.REJECT, "The target is not achievable."))); + } + + private static SymmetryCase endingCase( + String label, + NegotiationPerformative performative, + TemplateUri templateUri, + NegotiationEndingContent content) { + return new SymmetryCase( + label, + performative, + templateUri, + new NegotiationEndingData( + new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, performative), content)); + } + + /** One symmetry case: a fixed input addressed to one of the three from-data generation methods. */ + private record SymmetryCase( + String label, NegotiationPerformative performative, TemplateUri templateUri, Object data) { + + MetadataContent generate(ProposeGenerator propose, EndingGenerator accept, EndingGenerator reject) { + return switch (performative) { + case PROPOSE -> propose.generate((NegotiationProposeData) data, templateUri); + case ACCEPT -> accept.generate((NegotiationEndingData) data, templateUri); + case REJECT -> reject.generate((NegotiationEndingData) data, templateUri); + case ABORT -> throw new IllegalArgumentException("The symmetry cases carry no abort performative."); + }; + } + } + + @FunctionalInterface + private interface ProposeGenerator { + + MetadataContent generate(NegotiationProposeData data, TemplateUri templateUri); + } + + @FunctionalInterface + private interface EndingGenerator { + + MetadataContent generate(NegotiationEndingData data, TemplateUri templateUri); + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java index 875ef801..9624c29d 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java @@ -315,9 +315,7 @@ void should_matchSlotErrors_WhenExpectedSlotErrorsMatchActual() { AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzExpected expected = new AuthzExpected( new ClientExpected( - "slot.not_provided", - null, - List.of(new SlotErrorExpectation("授权策略的操作类型", "slot.not_provided"))), + "slot.not_provided", null, List.of(new SlotErrorExpectation("授权策略的操作类型", "slot.not_provided"))), null); AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java index ab561d15..c14c764b 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java @@ -1,161 +1,161 @@ -package net.openan.a2at.sdk.sample.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.lang.reflect.Method; -import java.lang.reflect.RecordComponent; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.server.A2ATServer; -import org.junit.jupiter.api.Test; - -/** - * Locks the v3 negotiation API surface of both facades and the field faces of the result records. - * - *

        The client and the server expose exactly the twelve camelCase negotiation methods with their pinned parameter - * counts, their surface carries none of the removed v2-shape method names nor the removed negotiation query methods, and - * the result records expose exactly their documented components. - */ -class NegotiationV3ApiSurfaceTest { - - private static final Map EXPECTED_NEGOTIATION_METHOD_PARAMETERS = Map.ofEntries( - Map.entry("generateNegotiationProposePromptFromData", 2), - Map.entry("generateNegotiationAcceptPromptFromData", 2), - Map.entry("generateNegotiationRejectPromptFromData", 2), - Map.entry("generateNegotiationAbortPromptFromData", 2), - Map.entry("generateNegotiationProposePromptFromText", 3), - Map.entry("generateNegotiationAcceptPromptFromText", 3), - Map.entry("generateNegotiationRejectPromptFromText", 3), - Map.entry("generateNegotiationAbortPromptFromText", 3), - Map.entry("validateProposePromptAndDataFilling", 4), - Map.entry("validateAcceptPromptAndDataFilling", 4), - Map.entry("validateRejectPromptAndDataFilling", 4), - Map.entry("validateAbortPromptAndDataFilling", 4)); - - private static final List REMOVED_NEGOTIATION_QUERY_METHODS = - List.of("getNegotiationPrompts", "getNegotiationPrompt"); - - private static final List CROSS_EXTENSION_QUERY_METHODS = List.of("getPrompts", "getPrompt"); - - private static final List REMOVED_V2_METHOD_NAME_FRAGMENTS = - List.of("FromNl", "FromJsonData", "validateAndExtractParams"); - - @Test - void bothFacadesExposeExactlyTheTwelveV3NegotiationMethods() { - assertExactNegotiationSurface(A2ATClient.class); - assertExactNegotiationSurface(A2ATServer.class); - } - - @Test - void removedNegotiationQueryMethodsAreAbsentWhileCrossExtensionQueriesRemain() { - for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { - for (String removedMethod : REMOVED_NEGOTIATION_QUERY_METHODS) { - assertFalse( - Arrays.stream(facade.getMethods()) - .anyMatch(method -> method.getName().equals(removedMethod)), - facade.getSimpleName() + " must not expose the removed " + removedMethod); - } - for (String queryMethod : CROSS_EXTENSION_QUERY_METHODS) { - assertTrue( - Arrays.stream(facade.getMethods()) - .anyMatch(method -> method.getName().equals(queryMethod)), - facade.getSimpleName() + " must expose the cross-extension query " + queryMethod); - } - } - } - - @Test - void neitherFacadeCarriesRemovedV2ShapeMethodNames() { - assertNoV2ShapeNames(A2ATClient.class); - assertNoV2ShapeNames(A2ATServer.class); - } - - @Test - void resultRecordsExposeTheirPinnedFieldFaces() { - List metadataComponents = componentNames(MetadataContent.class); - assertEquals(List.of("templateUri", "promptText", "extensionUri", "negotiationContext"), metadataComponents); - List contextComponents = componentNames(NegotiationContext.class); - assertEquals(List.of("id", "round", "maxRounds", "performative"), contextComponents); - List filledComponents = componentNames(FilledParamData.class); - assertEquals(List.of("data"), filledComponents); - assertTrue( - Arrays.stream(MetadataContent.class.getMethods()) - .anyMatch(method -> method.getName().equals("buildMetadataContent")), - "MetadataContent must expose buildMetadataContent"); - } - - @Test - void sixTargetApisUseStructuredTemplateUri() { - Set targetMethods = Set.of( - "generateNegotiationProposePromptFromText", - "generateNegotiationAcceptPromptFromText", - "generateNegotiationRejectPromptFromText", - "validateProposePromptAndDataFilling", - "validateAcceptPromptAndDataFilling", - "validateRejectPromptAndDataFilling"); - for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { - Arrays.stream(facade.getMethods()) - .filter(method -> targetMethods.contains(method.getName())) - .forEach(method -> assertEquals( - TemplateUri.class, - method.getParameterTypes()[method.getParameterCount() - 1], - "last parameter of " + facade.getSimpleName() + "." + method.getName())); - } - } - - private static void assertExactNegotiationSurface(Class facade) { - Method[] allMethods = facade.getMethods(); - List negotiationMethods = Arrays.stream(allMethods) - .filter(method -> method.getName().startsWith("generateNegotiation") - || method.getName().startsWith("getNegotiation") - || "validateProposePromptAndDataFilling".equals(method.getName()) - || "validateAcceptPromptAndDataFilling".equals(method.getName()) - || "validateRejectPromptAndDataFilling".equals(method.getName()) - || "validateAbortPromptAndDataFilling".equals(method.getName())) - .toList(); - Set names = negotiationMethods.stream().map(Method::getName).collect(Collectors.toSet()); - assertEquals( - EXPECTED_NEGOTIATION_METHOD_PARAMETERS.keySet(), - names, - facade.getSimpleName() + " must expose exactly the twelve v3 negotiation methods"); - assertEquals( - EXPECTED_NEGOTIATION_METHOD_PARAMETERS.size(), - negotiationMethods.size(), - facade.getSimpleName() + " must not overload any negotiation method"); - for (Method method : negotiationMethods) { - assertEquals( - EXPECTED_NEGOTIATION_METHOD_PARAMETERS.get(method.getName()), - method.getParameterCount(), - "parameter count of " + facade.getSimpleName() + "." + method.getName()); - } - } - - private static void assertNoV2ShapeNames(Class facade) { - for (Method method : facade.getMethods()) { - String name = method.getName(); - for (String fragment : REMOVED_V2_METHOD_NAME_FRAGMENTS) { - assertFalse( - name.toLowerCase(Locale.ROOT).contains(fragment.toLowerCase(Locale.ROOT)), - facade.getSimpleName() + " must not expose the removed v2-shape method name fragment " - + fragment + " but declares " + name); - } - } - } - - private static List componentNames(Class recordType) { - return Arrays.stream(recordType.getRecordComponents()) - .map(RecordComponent::getName) - .collect(Collectors.toList()); - } -} +package net.openan.a2at.sdk.sample.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; +import java.lang.reflect.RecordComponent; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.Test; + +/** + * Locks the v3 negotiation API surface of both facades and the field faces of the result records. + * + *

        The client and the server expose exactly the twelve camelCase negotiation methods with their pinned parameter + * counts, their surface carries none of the removed v2-shape method names nor the removed negotiation query methods, + * and the result records expose exactly their documented components. + */ +class NegotiationV3ApiSurfaceTest { + + private static final Map EXPECTED_NEGOTIATION_METHOD_PARAMETERS = Map.ofEntries( + Map.entry("generateNegotiationProposePromptFromData", 2), + Map.entry("generateNegotiationAcceptPromptFromData", 2), + Map.entry("generateNegotiationRejectPromptFromData", 2), + Map.entry("generateNegotiationAbortPromptFromData", 2), + Map.entry("generateNegotiationProposePromptFromText", 3), + Map.entry("generateNegotiationAcceptPromptFromText", 3), + Map.entry("generateNegotiationRejectPromptFromText", 3), + Map.entry("generateNegotiationAbortPromptFromText", 3), + Map.entry("validateProposePromptAndDataFilling", 4), + Map.entry("validateAcceptPromptAndDataFilling", 4), + Map.entry("validateRejectPromptAndDataFilling", 4), + Map.entry("validateAbortPromptAndDataFilling", 4)); + + private static final List REMOVED_NEGOTIATION_QUERY_METHODS = + List.of("getNegotiationPrompts", "getNegotiationPrompt"); + + private static final List CROSS_EXTENSION_QUERY_METHODS = List.of("getPrompts", "getPrompt"); + + private static final List REMOVED_V2_METHOD_NAME_FRAGMENTS = + List.of("FromNl", "FromJsonData", "validateAndExtractParams"); + + @Test + void bothFacadesExposeExactlyTheTwelveV3NegotiationMethods() { + assertExactNegotiationSurface(A2ATClient.class); + assertExactNegotiationSurface(A2ATServer.class); + } + + @Test + void removedNegotiationQueryMethodsAreAbsentWhileCrossExtensionQueriesRemain() { + for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { + for (String removedMethod : REMOVED_NEGOTIATION_QUERY_METHODS) { + assertFalse( + Arrays.stream(facade.getMethods()) + .anyMatch(method -> method.getName().equals(removedMethod)), + facade.getSimpleName() + " must not expose the removed " + removedMethod); + } + for (String queryMethod : CROSS_EXTENSION_QUERY_METHODS) { + assertTrue( + Arrays.stream(facade.getMethods()) + .anyMatch(method -> method.getName().equals(queryMethod)), + facade.getSimpleName() + " must expose the cross-extension query " + queryMethod); + } + } + } + + @Test + void neitherFacadeCarriesRemovedV2ShapeMethodNames() { + assertNoV2ShapeNames(A2ATClient.class); + assertNoV2ShapeNames(A2ATServer.class); + } + + @Test + void resultRecordsExposeTheirPinnedFieldFaces() { + List metadataComponents = componentNames(MetadataContent.class); + assertEquals(List.of("templateUri", "promptText", "extensionUri", "negotiationContext"), metadataComponents); + List contextComponents = componentNames(NegotiationContext.class); + assertEquals(List.of("id", "round", "maxRounds", "performative"), contextComponents); + List filledComponents = componentNames(FilledParamData.class); + assertEquals(List.of("data"), filledComponents); + assertTrue( + Arrays.stream(MetadataContent.class.getMethods()) + .anyMatch(method -> method.getName().equals("buildMetadataContent")), + "MetadataContent must expose buildMetadataContent"); + } + + @Test + void sixTargetApisUseStructuredTemplateUri() { + Set targetMethods = Set.of( + "generateNegotiationProposePromptFromText", + "generateNegotiationAcceptPromptFromText", + "generateNegotiationRejectPromptFromText", + "validateProposePromptAndDataFilling", + "validateAcceptPromptAndDataFilling", + "validateRejectPromptAndDataFilling"); + for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { + Arrays.stream(facade.getMethods()) + .filter(method -> targetMethods.contains(method.getName())) + .forEach(method -> assertEquals( + TemplateUri.class, + method.getParameterTypes()[method.getParameterCount() - 1], + "last parameter of " + facade.getSimpleName() + "." + method.getName())); + } + } + + private static void assertExactNegotiationSurface(Class facade) { + Method[] allMethods = facade.getMethods(); + List negotiationMethods = Arrays.stream(allMethods) + .filter(method -> method.getName().startsWith("generateNegotiation") + || method.getName().startsWith("getNegotiation") + || "validateProposePromptAndDataFilling".equals(method.getName()) + || "validateAcceptPromptAndDataFilling".equals(method.getName()) + || "validateRejectPromptAndDataFilling".equals(method.getName()) + || "validateAbortPromptAndDataFilling".equals(method.getName())) + .toList(); + Set names = negotiationMethods.stream().map(Method::getName).collect(Collectors.toSet()); + assertEquals( + EXPECTED_NEGOTIATION_METHOD_PARAMETERS.keySet(), + names, + facade.getSimpleName() + " must expose exactly the twelve v3 negotiation methods"); + assertEquals( + EXPECTED_NEGOTIATION_METHOD_PARAMETERS.size(), + negotiationMethods.size(), + facade.getSimpleName() + " must not overload any negotiation method"); + for (Method method : negotiationMethods) { + assertEquals( + EXPECTED_NEGOTIATION_METHOD_PARAMETERS.get(method.getName()), + method.getParameterCount(), + "parameter count of " + facade.getSimpleName() + "." + method.getName()); + } + } + + private static void assertNoV2ShapeNames(Class facade) { + for (Method method : facade.getMethods()) { + String name = method.getName(); + for (String fragment : REMOVED_V2_METHOD_NAME_FRAGMENTS) { + assertFalse( + name.toLowerCase(Locale.ROOT).contains(fragment.toLowerCase(Locale.ROOT)), + facade.getSimpleName() + " must not expose the removed v2-shape method name fragment " + + fragment + " but declares " + name); + } + } + } + + private static List componentNames(Class recordType) { + return Arrays.stream(recordType.getRecordComponents()) + .map(RecordComponent::getName) + .collect(Collectors.toList()); + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 15d040ad..6f91f9e2 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -1,567 +1,562 @@ -package net.openan.a2at.sdk.server; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; -import net.openan.a2at.sdk.server.compliance.ServerPromptComplianceOrchestrator; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import org.jspecify.annotations.NonNull; - -/** - * High-level server facade for prompt compliance and negotiation APIs. The caller provides the `.env` file path - * explicitly, typically after copying the repository `env.example`. - * - * @since 2026-06 - */ -public final class A2ATServer { - - private final ServerPromptComplianceOrchestrator promptComplianceOrchestrator; - - private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; - - private final NegotiationContentService negotiationContentService; - - private final TemplateQueryService templateQueryService; - - private final ContentValidator taskContentValidator; - - private final ContentValidator notificationContentValidator; - - private final ContentValidator authContentValidator; - - /** - * Creates a server facade from one user-supplied `.env` path. - * - * @param envPath user-supplied `.env` file path - */ - public A2ATServer(Path envPath) { - Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); - A2ATConfig config = - A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); - DefaultA2ATServerBuilder builder = - DefaultA2ATServerBuilder.builder().envPath(resolvedEnvPath).config(config); - this.promptComplianceOrchestrator = builder.buildPromptComplianceOrchestrator(); - this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); - this.negotiationContentService = - new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); - this.templateQueryService = builder.buildTemplateQueryService(); - this.taskContentValidator = builder.buildTaskContentValidator(); - this.notificationContentValidator = builder.buildNotificationContentValidator(); - this.authContentValidator = builder.buildAuthContentValidator(); - } - - /** - * Checks one processed task prompt for server-side compliance. - * - *

        An input longer than {@code A2AT_INPUT_TEXT_MAX_CHARS} characters (default 16384) fails fast with the code - * {@code input.text_too_long} before any LLM call. - * - * @param processedPromptText processed task prompt text - * @return compliance result containing only success state or failure details - */ - public PromptComplianceResult checkTaskPrompt(@NonNull String processedPromptText) { - return promptComplianceOrchestrator.checkTaskPrompt(processedPromptText); - } - - /** - * Starts a new negotiation payload for the requested negotiation type. - * - * @param type negotiation type to initiate - * @param contentText human-readable negotiation message - * @param facts structured facts attached to the payload - * @return transport payload representing the initial negotiation turn - */ - public Map startNegotiation( - @NonNull NegotiationType type, @NonNull String contentText, @NonNull Map facts) { - return negotiationOrchestrator.startNegotiation(type, contentText, facts); - } - - /** - * Processes a received negotiation message using its transport context payload. - * - * @param message received negotiation message - * @param context transport context payload associated with the message - * @return normalized payload describing the receive result - */ - public Map receiveNegotiation(@NonNull String message, @NonNull Map context) { - return negotiationOrchestrator.receiveNegotiation(message, context); - } - - /** - * Continues an existing negotiation with a locally stored context snapshot. - * - * @param context current negotiation context - * @param status next status to emit - * @param contentText continuation message content - * @return transport payload representing the next negotiation turn - */ - public Map continueNegotiation( - @NonNull NegotiationContext context, @NonNull NegotiationStatus status, @NonNull String contentText) { - return negotiationOrchestrator.continueNegotiation(context, status, contentText); - } - - /** - * Generates a propose-phase negotiation message from typed data. - * - *

        This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the - * generator of the negotiation type addressed by the template URI and rendered from that template. - * - * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase - * segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationProposePromptFromData( - @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateProposeFromData(data, templateUri); - } - - /** - * Generates an accept-phase negotiation message from typed data. - * - *

        This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the - * content conclusion is not {@code Accept} - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationAcceptPromptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAcceptFromData(data, templateUri); - } - - /** - * Generates a reject-phase negotiation message from typed data. - * - *

        This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the - * content conclusion is not {@code Reject} - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationRejectPromptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateRejectFromData(data, templateUri); - } - - /** - * Generates an abort negotiation message from typed data. - * - *

        This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed - * template must be the common abort template and the content carries only the termination reason. - * - * @param data typed abort input carrying the negotiation context and the termination reason - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template or the - * termination reason is blank - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails - */ - public MetadataContent generateNegotiationAbortPromptFromData( - @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAbortFromData(data, templateUri); - } - - /** - * Generates a propose-phase negotiation message from free text. - * - *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment - * must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} - * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the - * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the - * extracted content contradicts the phase, or {@code input.text_too_long} when the text exceeds the configured - * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) - */ - public MetadataContent generateNegotiationProposePromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateProposeFromText(text, context, templateUri); - } - - /** - * Generates an accept-phase negotiation message from free text. - * - *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Accept}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} - * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the - * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the - * extracted conclusion is not {@code Accept}, or {@code input.text_too_long} when the text exceeds the - * configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) - */ - public MetadataContent generateNegotiationAcceptPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAcceptFromText(text, context, templateUri); - } - - /** - * Generates a reject-phase negotiation message from free text. - * - *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Reject}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} - * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the - * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the - * extracted conclusion is not {@code Reject}, or {@code input.text_too_long} when the text exceeds the - * configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) - */ - public MetadataContent generateNegotiationRejectPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateRejectFromText(text, context, templateUri); - } - - /** - * Generates an abort negotiation message from free text. - * - *

        This variant runs one LLM content-extraction step constrained by the common abort template and then renders - * deterministically like the from-data variant. - * - * @param text free-text input stating the termination reason - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} - * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the - * extracted content misses the termination reason, {@code negotiation.invalid_input} when the text is blank, or - * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public MetadataContent generateNegotiationAbortPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAbortFromText(text, context, templateUri); - } - - /** - * Lists every template available for the configured language across all A2A-T extensions. - * - *

        This query never throws: the extension directories are discovered from the bundled resource tree itself, so - * templates of extensions added later are included automatically. Templates that exist nowhere for the language are - * skipped and an empty list is returned when no template can be loaded at all. - * - * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when - * none can be loaded - */ - public List getPrompts() { - return templateQueryService.getPrompts(); - } - - /** - * Loads one template by its URI, regardless of the extension. - * - *

        This query never throws: a template that exists nowhere for the configured language returns an empty result - * and logs an actionable warning. - * - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} or - * {@code Task-T/network-layer/ran-energy-saving/v1} - * @return the addressed template, or an empty result when the template does not exist for the configured language - */ - public Optional getPrompt(@NonNull TemplateUri templateUri) { - return templateQueryService.getPrompt(templateUri); - } - - - - /** - * Validates a propose-phase negotiation message and extracts its parameters. - * - *

        The pipeline checks the template URI format before any LLM call, runs the deterministic rule gate, then - * performs one semantic validation LLM call (retried on the retryable failure codes) and merges the extracted - * parameters with the rule-level context parameters; context parameters win on conflict. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code propose} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context - * violates a rule, one of the closed {@code negotiation.*} code set (for example - * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateProposePromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates an accept-phase negotiation message and extracts its parameters. - * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to - * accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * accept-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context - * violates a rule, one of the closed {@code negotiation.*} code set (for example - * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateAcceptPromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates a reject-phase negotiation message and extracts its parameters. - * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to - * reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * reject-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context - * violates a rule, one of the closed {@code negotiation.*} code set (for example - * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateRejectPromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates an abort negotiation message and extracts its parameters. - * - *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to - * abort: the template URI must address the common abort template and the message must satisfy the abort-phase - * semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws IllegalArgumentException if the prompt is blank or the template URI does not address the common abort - * template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context - * violates a rule, one of the closed {@code negotiation.*} code set (for example - * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateAbortPromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates a task prompt and extracts its parameters. - * - * @param prompt rendered task prompt text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected task template; its prefix segment must be {@code Task-T} - * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank - * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code negotiation.invalid_input} when the template URI is null, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateTaskPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return taskContentValidator.validate(prompt, schema, templateUri); - } - - /** - * Validates a notification prompt and extracts its parameters. - * - * @param prompt rendered notification prompt text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected notification template; its prefix segment must be - * {@code Notification-T} - * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank - * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code negotiation.invalid_input} when the template URI is null, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateNotificationPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return notificationContentValidator.validate(prompt, schema, templateUri); - } - - /** - * Validates an authorization prompt and extracts its parameters. - * - * @param prompt rendered authorization prompt text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected authorization template; its prefix segment must be - * {@code Authorization-T} - * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank - * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code negotiation.invalid_input} when the template URI is null, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateAuthPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return authContentValidator.validate(prompt, schema, templateUri); - } -} +package net.openan.a2at.sdk.server; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; +import net.openan.a2at.sdk.server.compliance.ServerPromptComplianceOrchestrator; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.jspecify.annotations.NonNull; + +/** + * High-level server facade for prompt compliance and negotiation APIs. The caller provides the `.env` file path + * explicitly, typically after copying the repository `env.example`. + * + * @since 2026-06 + */ +public final class A2ATServer { + + private final ServerPromptComplianceOrchestrator promptComplianceOrchestrator; + + private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; + + private final NegotiationContentService negotiationContentService; + + private final TemplateQueryService templateQueryService; + + private final ContentValidator taskContentValidator; + + private final ContentValidator notificationContentValidator; + + private final ContentValidator authContentValidator; + + /** + * Creates a server facade from one user-supplied `.env` path. + * + * @param envPath user-supplied `.env` file path + */ + public A2ATServer(Path envPath) { + Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); + A2ATConfig config = + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + DefaultA2ATServerBuilder builder = + DefaultA2ATServerBuilder.builder().envPath(resolvedEnvPath).config(config); + this.promptComplianceOrchestrator = builder.buildPromptComplianceOrchestrator(); + this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); + this.negotiationContentService = + new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.templateQueryService = builder.buildTemplateQueryService(); + this.taskContentValidator = builder.buildTaskContentValidator(); + this.notificationContentValidator = builder.buildNotificationContentValidator(); + this.authContentValidator = builder.buildAuthContentValidator(); + } + + /** + * Checks one processed task prompt for server-side compliance. + * + *

        An input longer than {@code A2AT_INPUT_TEXT_MAX_CHARS} characters (default 16384) fails fast with the code + * {@code input.text_too_long} before any LLM call. + * + * @param processedPromptText processed task prompt text + * @return compliance result containing only success state or failure details + */ + public PromptComplianceResult checkTaskPrompt(@NonNull String processedPromptText) { + return promptComplianceOrchestrator.checkTaskPrompt(processedPromptText); + } + + /** + * Starts a new negotiation payload for the requested negotiation type. + * + * @param type negotiation type to initiate + * @param contentText human-readable negotiation message + * @param facts structured facts attached to the payload + * @return transport payload representing the initial negotiation turn + */ + public Map startNegotiation( + @NonNull NegotiationType type, @NonNull String contentText, @NonNull Map facts) { + return negotiationOrchestrator.startNegotiation(type, contentText, facts); + } + + /** + * Processes a received negotiation message using its transport context payload. + * + * @param message received negotiation message + * @param context transport context payload associated with the message + * @return normalized payload describing the receive result + */ + public Map receiveNegotiation(@NonNull String message, @NonNull Map context) { + return negotiationOrchestrator.receiveNegotiation(message, context); + } + + /** + * Continues an existing negotiation with a locally stored context snapshot. + * + * @param context current negotiation context + * @param status next status to emit + * @param contentText continuation message content + * @return transport payload representing the next negotiation turn + */ + public Map continueNegotiation( + @NonNull NegotiationContext context, @NonNull NegotiationStatus status, @NonNull String contentText) { + return negotiationOrchestrator.continueNegotiation(context, status, contentText); + } + + /** + * Generates a propose-phase negotiation message from typed data. + * + *

        This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the + * generator of the negotiation type addressed by the template URI and rendered from that template. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase + * segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationProposePromptFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { + return negotiationContentService.generateProposeFromData(data, templateUri); + } + + /** + * Generates an accept-phase negotiation message from typed data. + * + *

        This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the + * content conclusion is not {@code Accept} + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationAcceptPromptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + return negotiationContentService.generateAcceptFromData(data, templateUri); + } + + /** + * Generates a reject-phase negotiation message from typed data. + * + *

        This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the + * content conclusion is not {@code Reject} + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationRejectPromptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + return negotiationContentService.generateRejectFromData(data, templateUri); + } + + /** + * Generates an abort negotiation message from typed data. + * + *

        This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed + * template must be the common abort template and the content carries only the termination reason. + * + * @param data typed abort input carrying the negotiation context and the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template or the + * termination reason is blank + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, or the code + * {@code negotiation.field_missing} when rendering the template fails + */ + public MetadataContent generateNegotiationAbortPromptFromData( + @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + return negotiationContentService.generateAbortFromData(data, templateUri); + } + + /** + * Generates a propose-phase negotiation message from free text. + * + *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment + * must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the + * extracted content contradicts the phase, or {@code input.text_too_long} when the text exceeds the configured + * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public MetadataContent generateNegotiationProposePromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + return negotiationContentService.generateProposeFromText(text, context, templateUri); + } + + /** + * Generates an accept-phase negotiation message from free text. + * + *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Accept}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the + * extracted conclusion is not {@code Accept}, or {@code input.text_too_long} when the text exceeds the + * configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public MetadataContent generateNegotiationAcceptPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + return negotiationContentService.generateAcceptFromText(text, context, templateUri); + } + + /** + * Generates a reject-phase negotiation message from free text. + * + *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Reject}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the + * extracted conclusion is not {@code Reject}, or {@code input.text_too_long} when the text exceeds the + * configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public MetadataContent generateNegotiationRejectPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + return negotiationContentService.generateRejectFromText(text, context, templateUri); + } + + /** + * Generates an abort negotiation message from free text. + * + *

        This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. + * + * @param text free-text input stating the termination reason + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses the termination reason, {@code negotiation.invalid_input} when the text is blank, or + * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public MetadataContent generateNegotiationAbortPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull TemplateUri templateUri) { + return negotiationContentService.generateAbortFromText(text, context, templateUri); + } + + /** + * Lists every template available for the configured language across all A2A-T extensions. + * + *

        This query never throws: the extension directories are discovered from the bundled resource tree itself, so + * templates of extensions added later are included automatically. Templates that exist nowhere for the language are + * skipped and an empty list is returned when no template can be loaded at all. + * + * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when + * none can be loaded + */ + public List getPrompts() { + return templateQueryService.getPrompts(); + } + + /** + * Loads one template by its URI, regardless of the extension. + * + *

        This query never throws: a template that exists nowhere for the configured language returns an empty result + * and logs an actionable warning. + * + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} or + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return the addressed template, or an empty result when the template does not exist for the configured language + */ + public Optional getPrompt(@NonNull TemplateUri templateUri) { + return templateQueryService.getPrompt(templateUri); + } + + /** + * Validates a propose-phase negotiation message and extracts its parameters. + * + *

        The pipeline checks the template URI format before any LLM call, runs the deterministic rule gate, then + * performs one semantic validation LLM call (retried on the retryable failure codes) and merges the extracted + * parameters with the rule-level context parameters; context parameters win on conflict. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context + * violates a rule, one of the closed {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateProposePromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return negotiationContentService.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates an accept-phase negotiation message and extracts its parameters. + * + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * accept-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context + * violates a rule, one of the closed {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateAcceptPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates a reject-phase negotiation message and extracts its parameters. + * + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * reject-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context + * violates a rule, one of the closed {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateRejectPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

        The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * abort: the template URI must address the common abort template and the message must satisfy the abort-phase + * semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws IllegalArgumentException if the prompt is blank or the template URI does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is not a negotiation message, + * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context + * violates a rule, one of the closed {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateAbortPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates a task prompt and extracts its parameters. + * + * @param prompt rendered task prompt text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected task template; its prefix segment must be {@code Task-T} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code negotiation.invalid_input} when the template URI is null, {@code negotiation.semantic_rejected} when + * the semantic validation rejects the content, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the semantic step fails after exhausting its retries, or {@code template.not_found} when the validation + * prompt resources are missing, or {@code input.text_too_long} when the prompt exceeds the configured maximum + * length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public FilledParamData validateTaskPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + return taskContentValidator.validate(prompt, schema, templateUri); + } + + /** + * Validates a notification prompt and extracts its parameters. + * + * @param prompt rendered notification prompt text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected notification template; its prefix segment must be + * {@code Notification-T} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code negotiation.invalid_input} when the template URI is null, {@code negotiation.semantic_rejected} when + * the semantic validation rejects the content, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the semantic step fails after exhausting its retries, or {@code template.not_found} when the validation + * prompt resources are missing, or {@code input.text_too_long} when the prompt exceeds the configured maximum + * length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public FilledParamData validateNotificationPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + return notificationContentValidator.validate(prompt, schema, templateUri); + } + + /** + * Validates an authorization prompt and extracts its parameters. + * + * @param prompt rendered authorization prompt text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected authorization template; its prefix segment must be + * {@code Authorization-T} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the prompt or schema is null + * @throws IllegalArgumentException if the prompt is blank + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code negotiation.invalid_input} when the template URI is null, {@code negotiation.semantic_rejected} when + * the semantic validation rejects the content, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the semantic step fails after exhausting its retries, or {@code template.not_found} when the validation + * prompt resources are missing, or {@code input.text_too_long} when the prompt exceeds the configured maximum + * length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public FilledParamData validateAuthPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { + return authContentValidator.validate(prompt, schema, templateUri); + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index bfe286bd..edd14d1c 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -1,274 +1,276 @@ -package net.openan.a2at.sdk.server.assembly; - -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.validation.DefaultContentValidator; -import net.openan.a2at.sdk.server.compliance.DefaultServerPromptComplianceOrchestrator; -import net.openan.a2at.sdk.server.metadata.LlmBackedPromptMetadataExtractor; -import net.openan.a2at.sdk.server.validation.InputLimitedContentValidator; -import net.openan.a2at.sdk.server.validation.LlmBackedPromptSemanticValidator; -import org.jspecify.annotations.Nullable; - -/** - * Default builder that assembles one high-level A2AT server runtime from unified config. - * - * @since 2026-06 - */ -public final class DefaultA2ATServerBuilder { - - private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; - - private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; - - private static final String SEMANTIC_VALIDATION_PROMPT = "semantic_validation"; - - private A2ATConfig config; - - private Path envPath; - - private LLMClient llmClient; - - private PromptResourceAccess promptResourceAccess; - - private DefaultServerPromptComplianceOrchestrator promptComplianceOrchestrator; - - /** - * Creates one new builder instance. - * - * @return empty server builder - */ - public static DefaultA2ATServerBuilder builder() { - return new DefaultA2ATServerBuilder(); - } - - /** - * Configures the unified SDK config consumed by the high-level server facade. - * - * @param config unified SDK config - * @return current builder - */ - public DefaultA2ATServerBuilder config(A2ATConfig config) { - this.config = config; - return this; - } - - /** - * Configures the `.env` file path used to assemble downstream facades. - * - * @param envPath caller-supplied `.env` path - * @return current builder - */ - public DefaultA2ATServerBuilder envPath(Path envPath) { - this.envPath = envPath; - return this; - } - - /** - * Injects an explicit LLM client that fully replaces the factory default. - * - *

        The injection point exists for testability and custom LLM assemblies: when set, every orchestrator and - * validator built by this builder reuses the given client and no client is created from the `.env` LLM config. - * When unset, the builder keeps creating its default client and the behavior is unchanged. - * - * @param llmClient LLM client to inject; {@code null} keeps the factory default built from the `.env` LLM config - * @return current builder - * @since 2026-08 - */ - public DefaultA2ATServerBuilder llmClient(@Nullable LLMClient llmClient) { - this.llmClient = llmClient; - return this; - } - - /** - * Builds the default prompt-compliance orchestrator from the configured unified SDK config. - * - * @return assembled prompt-compliance orchestrator - */ - public synchronized DefaultServerPromptComplianceOrchestrator buildPromptComplianceOrchestrator() { - require(config, "Unified SDK config must be configured."); - require(envPath, "Unified SDK env path must be configured."); - requireSupportedConfig(); - if (promptComplianceOrchestrator != null) { - return promptComplianceOrchestrator; - } - - PromptResourceAccess resources = promptResourceAccess(); - String language = config.prompt().language(); - List scenarios = resources.loadScenarios(language); - PromptTemplateTextLoader templateLoader = resources.templateLoader(); - PromptSlotSchemaLoader slotSchemaLoader = resources.slotSchemaLoader(); - LLMClient client = llmClient(); - - String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); - String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); - String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); - String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); - String semanticSystemPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "system.md"); - String semanticUserPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "user.md"); - - promptComplianceOrchestrator = new DefaultServerPromptComplianceOrchestrator( - new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(client), - scenarios, - language, - scenarioSystemPrompt, - scenarioUserPrompt, - templateLoader, - slotSchemaLoader, - new DefaultStructuredPromptSlotValueExtractor( - client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)), - new LlmBackedPromptSemanticValidator( - client, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt), - config.inputLimits().maxTextChars(), - language); - return promptComplianceOrchestrator; - } - - /** - * Builds the default negotiation orchestrator from the configured unified SDK config. - * - * @return assembled negotiation orchestrator - */ - public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - return new ServerNegotiationOrchestratorBuilder() - .promptComplianceOrchestrator(buildPromptComplianceOrchestrator()) - .build(); - } - - /** - * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. - * - *

        The wiring is shared with the client side through - * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local - * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config. - * - * @return assembled negotiation generation orchestrator - */ - public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - require(envPath, "Unified SDK env path must be configured."); - return NegotiationContentService.buildOrchestrator(config, llmClient()); - } - - /** - * Builds the generic template query service from the configured unified SDK config. - * - *

        The service answers the extension-agnostic template queries: the message language and the local template root - * come from the prompt runtime config, exactly like the negotiation generation orchestrator wiring. - * - * @return assembled template query service - */ - public TemplateQueryService buildTemplateQueryService() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - return new TemplateQueryService( - config.prompt().language(), config.prompt().sourceType(), config.prompt().localRootDir()); - } - - /** - * Builds the task content validator from the configured unified SDK config. - * - *

        The validator enforces the {@code Task-T} extension prefix and reuses the configured language, LLM retry - * attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. - * - * @return assembled task content validator - */ - public ContentValidator buildTaskContentValidator() { - return buildContentValidator(StandardTemplates.TASK_EXTENSION_NAME); - } - - /** - * Builds the notification content validator from the configured unified SDK config. - * - *

        The validator enforces the {@code Notification-T} extension prefix and reuses the configured language, LLM - * retry attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. - * - * @return assembled notification content validator - */ - public ContentValidator buildNotificationContentValidator() { - return buildContentValidator(StandardTemplates.NOTIFICATION_EXTENSION_NAME); - } - - /** - * Builds the authorization content validator from the configured unified SDK config. - * - *

        The validator enforces the {@code Authorization-T} extension prefix and reuses the configured language, LLM - * retry attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. - * - * @return assembled authorization content validator - */ - public ContentValidator buildAuthContentValidator() { - return buildContentValidator(StandardTemplates.AUTHORIZATION_EXTENSION_NAME); - } - - private ContentValidator buildContentValidator(String extensionName) { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - require(envPath, "Unified SDK env path must be configured."); - return new InputLimitedContentValidator( - new DefaultContentValidator( - extensionName, - config.prompt().language(), - config.llm().maxAttempts(), - llmClient(), - promptResourceAccess().templateLoader()), - config.inputLimits().maxTextChars(), - config.prompt().language()); - } - - private static void require(Object value, String message) { - if (value == null) { - throw new IllegalStateException(message); - } - } - - private void requireSupportedConfig() { - if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) - && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals( - config.prompt().sourceType())) { - throw new UnsupportedOperationException( - "Unsupported prompt source type: " + config.prompt().sourceType()); - } - if (!LLMClientFactory.availableProviders().contains(config.llm().provider())) { - throw new UnsupportedOperationException( - "Unsupported LLM provider: " + config.llm().provider()); - } - if (!"in_memory".equals(config.negotiation().stateStoreType())) { - throw new UnsupportedOperationException("Unsupported negotiation state store type: " - + config.negotiation().stateStoreType()); - } - } - - private synchronized LLMClient llmClient() { - if (llmClient == null) { - llmClient = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); - } - return llmClient; - } - - private synchronized PromptResourceAccess promptResourceAccess() { - if (promptResourceAccess == null) { - promptResourceAccess = PromptResourceAccess.create(config.prompt()); - } - return promptResourceAccess; - } -} \ No newline at end of file +package net.openan.a2at.sdk.server.assembly; + +import java.nio.file.Path; +import java.util.List; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.prompt.validation.DefaultContentValidator; +import net.openan.a2at.sdk.server.compliance.DefaultServerPromptComplianceOrchestrator; +import net.openan.a2at.sdk.server.metadata.LlmBackedPromptMetadataExtractor; +import net.openan.a2at.sdk.server.validation.InputLimitedContentValidator; +import net.openan.a2at.sdk.server.validation.LlmBackedPromptSemanticValidator; +import org.jspecify.annotations.Nullable; + +/** + * Default builder that assembles one high-level A2AT server runtime from unified config. + * + * @since 2026-06 + */ +public final class DefaultA2ATServerBuilder { + + private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; + + private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; + + private static final String SEMANTIC_VALIDATION_PROMPT = "semantic_validation"; + + private A2ATConfig config; + + private Path envPath; + + private LLMClient llmClient; + + private PromptResourceAccess promptResourceAccess; + + private DefaultServerPromptComplianceOrchestrator promptComplianceOrchestrator; + + /** + * Creates one new builder instance. + * + * @return empty server builder + */ + public static DefaultA2ATServerBuilder builder() { + return new DefaultA2ATServerBuilder(); + } + + /** + * Configures the unified SDK config consumed by the high-level server facade. + * + * @param config unified SDK config + * @return current builder + */ + public DefaultA2ATServerBuilder config(A2ATConfig config) { + this.config = config; + return this; + } + + /** + * Configures the `.env` file path used to assemble downstream facades. + * + * @param envPath caller-supplied `.env` path + * @return current builder + */ + public DefaultA2ATServerBuilder envPath(Path envPath) { + this.envPath = envPath; + return this; + } + + /** + * Injects an explicit LLM client that fully replaces the factory default. + * + *

        The injection point exists for testability and custom LLM assemblies: when set, every orchestrator and + * validator built by this builder reuses the given client and no client is created from the `.env` LLM config. When + * unset, the builder keeps creating its default client and the behavior is unchanged. + * + * @param llmClient LLM client to inject; {@code null} keeps the factory default built from the `.env` LLM config + * @return current builder + * @since 2026-08 + */ + public DefaultA2ATServerBuilder llmClient(@Nullable LLMClient llmClient) { + this.llmClient = llmClient; + return this; + } + + /** + * Builds the default prompt-compliance orchestrator from the configured unified SDK config. + * + * @return assembled prompt-compliance orchestrator + */ + public synchronized DefaultServerPromptComplianceOrchestrator buildPromptComplianceOrchestrator() { + require(config, "Unified SDK config must be configured."); + require(envPath, "Unified SDK env path must be configured."); + requireSupportedConfig(); + if (promptComplianceOrchestrator != null) { + return promptComplianceOrchestrator; + } + + PromptResourceAccess resources = promptResourceAccess(); + String language = config.prompt().language(); + List scenarios = resources.loadScenarios(language); + PromptTemplateTextLoader templateLoader = resources.templateLoader(); + PromptSlotSchemaLoader slotSchemaLoader = resources.slotSchemaLoader(); + LLMClient client = llmClient(); + + String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); + String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); + String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); + String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); + String semanticSystemPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "system.md"); + String semanticUserPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "user.md"); + + promptComplianceOrchestrator = new DefaultServerPromptComplianceOrchestrator( + new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(client), + scenarios, + language, + scenarioSystemPrompt, + scenarioUserPrompt, + templateLoader, + slotSchemaLoader, + new DefaultStructuredPromptSlotValueExtractor( + client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)), + new LlmBackedPromptSemanticValidator( + client, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt), + config.inputLimits().maxTextChars(), + language); + return promptComplianceOrchestrator; + } + + /** + * Builds the default negotiation orchestrator from the configured unified SDK config. + * + * @return assembled negotiation orchestrator + */ + public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + return new ServerNegotiationOrchestratorBuilder() + .promptComplianceOrchestrator(buildPromptComplianceOrchestrator()) + .build(); + } + + /** + * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. + * + *

        The wiring is shared with the client side through + * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local + * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config. + * + * @return assembled negotiation generation orchestrator + */ + public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + require(envPath, "Unified SDK env path must be configured."); + return NegotiationContentService.buildOrchestrator(config, llmClient()); + } + + /** + * Builds the generic template query service from the configured unified SDK config. + * + *

        The service answers the extension-agnostic template queries: the message language and the local template root + * come from the prompt runtime config, exactly like the negotiation generation orchestrator wiring. + * + * @return assembled template query service + */ + public TemplateQueryService buildTemplateQueryService() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + return new TemplateQueryService( + config.prompt().language(), + config.prompt().sourceType(), + config.prompt().localRootDir()); + } + + /** + * Builds the task content validator from the configured unified SDK config. + * + *

        The validator enforces the {@code Task-T} extension prefix and reuses the configured language, LLM retry + * attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. + * + * @return assembled task content validator + */ + public ContentValidator buildTaskContentValidator() { + return buildContentValidator(StandardTemplates.TASK_EXTENSION_NAME); + } + + /** + * Builds the notification content validator from the configured unified SDK config. + * + *

        The validator enforces the {@code Notification-T} extension prefix and reuses the configured language, LLM + * retry attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. + * + * @return assembled notification content validator + */ + public ContentValidator buildNotificationContentValidator() { + return buildContentValidator(StandardTemplates.NOTIFICATION_EXTENSION_NAME); + } + + /** + * Builds the authorization content validator from the configured unified SDK config. + * + *

        The validator enforces the {@code Authorization-T} extension prefix and reuses the configured language, LLM + * retry attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. + * + * @return assembled authorization content validator + */ + public ContentValidator buildAuthContentValidator() { + return buildContentValidator(StandardTemplates.AUTHORIZATION_EXTENSION_NAME); + } + + private ContentValidator buildContentValidator(String extensionName) { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + require(envPath, "Unified SDK env path must be configured."); + return new InputLimitedContentValidator( + new DefaultContentValidator( + extensionName, + config.prompt().language(), + config.llm().maxAttempts(), + llmClient(), + promptResourceAccess().templateLoader()), + config.inputLimits().maxTextChars(), + config.prompt().language()); + } + + private static void require(Object value, String message) { + if (value == null) { + throw new IllegalStateException(message); + } + } + + private void requireSupportedConfig() { + if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) + && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals( + config.prompt().sourceType())) { + throw new UnsupportedOperationException( + "Unsupported prompt source type: " + config.prompt().sourceType()); + } + if (!LLMClientFactory.availableProviders().contains(config.llm().provider())) { + throw new UnsupportedOperationException( + "Unsupported LLM provider: " + config.llm().provider()); + } + if (!"in_memory".equals(config.negotiation().stateStoreType())) { + throw new UnsupportedOperationException("Unsupported negotiation state store type: " + + config.negotiation().stateStoreType()); + } + } + + private synchronized LLMClient llmClient() { + if (llmClient == null) { + llmClient = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); + } + return llmClient; + } + + private synchronized PromptResourceAccess promptResourceAccess() { + if (promptResourceAccess == null) { + promptResourceAccess = PromptResourceAccess.create(config.prompt()); + } + return promptResourceAccess; + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java index 970ca124..0b713361 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java @@ -1,96 +1,96 @@ -package net.openan.a2at.sdk.server.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.server.A2ATServer; -import org.junit.jupiter.api.Test; - -class A2ATServerContentValidationApiTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - @Test - void validateTaskPromptRejectsNullTemplateUriWithValidationInvalidInput() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - ContentValidationException error = assertThrows( - ContentValidationException.class, - () -> server.validateTaskPromptAndDataFilling("test prompt", Map.of(), null)); - - assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, error.getCode()); - } - - @Test - void validateNotificationPromptRejectsNullTemplateUriWithValidationInvalidInput() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - ContentValidationException error = assertThrows( - ContentValidationException.class, - () -> server.validateNotificationPromptAndDataFilling("test prompt", Map.of(), null)); - - assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, error.getCode()); - } - - @Test - void validateAuthPromptRejectsNullTemplateUriWithValidationInvalidInput() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - ContentValidationException error = assertThrows( - ContentValidationException.class, - () -> server.validateAuthPromptAndDataFilling("test prompt", Map.of(), null)); - - assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, error.getCode()); - } - - @Test - void generateNegotiationProposePromptFromDataStillThrowsOnNullTemplateUri() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - assertThrows( - NullPointerException.class, - () -> server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - null)); - } - - @Test - void getPromptStillThrowsOnNullTemplateUri() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - assertThrows(NullPointerException.class, () -> server.getPrompt(null)); - } - - private static Path writeEnv() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-server-content-validation-env"); - Path envFile = tempDir.resolve("server.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - A2AT_PROMPT_COMPLIANCE_ENABLED=false - """); - return envFile; - } -} +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.Test; + +class A2ATServerContentValidationApiTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + @Test + void validateTaskPromptRejectsNullTemplateUriWithValidationInvalidInput() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + ContentValidationException error = assertThrows( + ContentValidationException.class, + () -> server.validateTaskPromptAndDataFilling("test prompt", Map.of(), null)); + + assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, error.getCode()); + } + + @Test + void validateNotificationPromptRejectsNullTemplateUriWithValidationInvalidInput() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + ContentValidationException error = assertThrows( + ContentValidationException.class, + () -> server.validateNotificationPromptAndDataFilling("test prompt", Map.of(), null)); + + assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, error.getCode()); + } + + @Test + void validateAuthPromptRejectsNullTemplateUriWithValidationInvalidInput() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + ContentValidationException error = assertThrows( + ContentValidationException.class, + () -> server.validateAuthPromptAndDataFilling("test prompt", Map.of(), null)); + + assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, error.getCode()); + } + + @Test + void generateNegotiationProposePromptFromDataStillThrowsOnNullTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows( + NullPointerException.class, + () -> server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + null)); + } + + @Test + void getPromptStillThrowsOnNullTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows(NullPointerException.class, () -> server.getPrompt(null)); + } + + private static Path writeEnv() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-server-content-validation-env"); + Path envFile = tempDir.resolve("server.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_PROMPT_COMPLIANCE_ENABLED=false + """); + return envFile; + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index 2c797f2b..24e42761 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -1,145 +1,143 @@ -package net.openan.a2at.sdk.server.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.server.A2ATServer; -import org.junit.jupiter.api.Test; - -class A2ATServerNegotiationApiTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); - - @Test - void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - - MetadataContent result = server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); - Map metadata = result.buildMetadataContent(); - assertEquals(3, metadata.size()); - assertEquals(result.promptText(), metadata.get(result.extensionUri())); - assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); - } - - @Test - void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("en-US")); - - MetadataContent result = server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); - assertTrue(result.promptText().contains("Required Information Items")); - } - - @Test - void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { - assertEquals(7, negotiationPrompts(new A2ATServer(writeEnv("zh-CN"))).size()); - assertEquals(7, negotiationPrompts(new A2ATServer(writeEnv("en-US"))).size()); - } - - @Test - void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - - MetadataContent result = server.generateNegotiationAbortPromptFromData( - new NegotiationAbortData( - new NegotiationContext(UUID, 5, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - StandardTemplates.NEGOTIATION_ABORT); - - assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); - assertTrue(result.promptText().contains("## 协商结果\nAbort")); - assertTrue(result.promptText().contains("## 协商终止原因")); - assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); - } - - @Test - void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - - PromptTemplate template = - server.getPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); - assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); - assertFalse(template.content().isBlank()); - } - - @Test - void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - - assertTrue(server.getPrompt(INFORMATION_PROPOSE).isPresent()); - assertTrue(server - .getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) - .isPresent()); - assertFalse(server - .getPrompt(TemplateUri.of(StandardTemplates.NEGOTIATION_EXTENSION_NAME, "unknown-negotiation", - "propose")) - .isPresent()); - PromptTemplate template = server.getPrompt(INFORMATION_PROPOSE).orElseThrow(); - assertEquals(INFORMATION_PROPOSE, template.templateUri()); - assertFalse(template.content().isBlank()); - } - - private static List negotiationPrompts(A2ATServer server) { - return server.getPrompts().stream() - .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( - template.templateUri().extensionName())) - .toList(); - } - - private static Path writeEnv(String language) throws IOException { - Path tempDir = Files.createTempDirectory("a2at-server-negotiation-env"); - Path envFile = tempDir.resolve("server.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=%s - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - A2AT_PROMPT_COMPLIANCE_ENABLED=false - """ - .formatted(language)); - return envFile; - } -} +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.Test; + +class A2ATServerNegotiationApiTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + @Test + void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + MetadataContent result = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); + Map metadata = result.buildMetadataContent(); + assertEquals(3, metadata.size()); + assertEquals(result.promptText(), metadata.get(result.extensionUri())); + assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); + } + + @Test + void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("en-US")); + + MetadataContent result = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); + assertTrue(result.promptText().contains("Required Information Items")); + } + + @Test + void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { + assertEquals(7, negotiationPrompts(new A2ATServer(writeEnv("zh-CN"))).size()); + assertEquals(7, negotiationPrompts(new A2ATServer(writeEnv("en-US"))).size()); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + MetadataContent result = server.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 5, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + StandardTemplates.NEGOTIATION_ABORT); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("## 协商终止原因")); + assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); + } + + @Test + void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + PromptTemplate template = + server.getPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + @Test + void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + assertTrue(server.getPrompt(INFORMATION_PROPOSE).isPresent()); + assertTrue(server.getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .isPresent()); + assertFalse(server.getPrompt( + TemplateUri.of(StandardTemplates.NEGOTIATION_EXTENSION_NAME, "unknown-negotiation", "propose")) + .isPresent()); + PromptTemplate template = server.getPrompt(INFORMATION_PROPOSE).orElseThrow(); + assertEquals(INFORMATION_PROPOSE, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + private static List negotiationPrompts(A2ATServer server) { + return server.getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + + private static Path writeEnv(String language) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-server-negotiation-env"); + Path envFile = tempDir.resolve("server.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=%s + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_PROMPT_COMPLIANCE_ENABLED=false + """ + .formatted(language)); + return envFile; + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index 3fdc456e..d0534e03 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -3,7 +3,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.nio.file.Files; From f28b50b32b026e78c555b6ed196ec694b4cf0977 Mon Sep 17 00:00:00 2001 From: qinyanhao01 Date: Sat, 29 Aug 2026 16:04:08 +0800 Subject: [PATCH 244/295] refactor(sample): optimize faultTime matching and disable LLM-dependent tests Signed-off-by: qinyanhao01 --- .../private-line-complaint/v1/zh-CN/slot.json | 2 +- .../sample/task_t/TaskTAccuracyEvaluator.java | 71 +++++++++++++++--- .../private-line-complaint-samples.json | 40 +++++------ .../task_t/TaskTAccuracyEvaluatorTest.java | 72 ++++++++++++++----- .../sample/task_t/TaskTSingleSampleTest.java | 52 ++++++-------- 5 files changed, 157 insertions(+), 80 deletions(-) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json index 67c875dc..d2b397a9 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json @@ -9,7 +9,7 @@ "examples": [ "专线业务名称:惠州江北汇聚专线业务_100M_SPN" ], - "x-a2at-value-constraint": "专线业务名称:网络侧的专线业务名称。专线业务标识:网络侧的专线业务唯一标识。接入端口名称:业务的接入端口名称,可以是物理端口或者逻辑端口(子接口),物理端口格式为:网元名称+单板号+单板型号+端口号,逻辑端口格式为:网元名称+单板号+单板型号+端口号(cvlan=接入vlanid)。" + "x-a2at-value-constraint": "提取格式为:对象类型:对应的值,专线业务名称:网络侧的专线业务名称。专线业务标识:网络侧的专线业务唯一标识。接入端口名称:业务的接入端口名称,可以是物理端口或者逻辑端口(子接口),物理端口格式为:网元名称+单板号+单板型号+端口号,逻辑端口格式为:网元名称+单板号+单板型号+端口号(cvlan=接入vlanid)。" }, "任务上下文": { "type": "string", diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java index 73149278..9454f1f0 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java @@ -4,6 +4,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Field-level and sample-level accuracy scoring for the {@code Task-T} demo. @@ -99,7 +101,7 @@ static List scoreFields(TaskTSample sample, Map extr .forEach((slot, expected) -> { Object extracted = extractedParams.get(slot); String extractedText = extracted == null ? null : String.valueOf(extracted); - boolean matched = matches(extractedText, expected, matchMode(slot)); + boolean matched = matches(extractedText, expected, slot); String detail = matched ? "" : "expected=" + expected + ", extracted=" + (extractedText == null ? "" : extractedText); @@ -109,30 +111,47 @@ static List scoreFields(TaskTSample sample, Map extr } /** - * Picks the match mode for one slot: the free-text complaint detail is matched by containment, every structured - * field is matched exactly. + * Picks the match mode for one slot: the free-text complaint detail and fault time are matched by containment, + * every other structured field is matched exactly. + * + *

        Fault time uses containment because the LLM may return the time in various formats (ISO with/without + * timezone, Chinese natural language, etc.) while the expected value is a canonical subset. * * @param slot expected slot name keyed by the server field names - * @return containment mode for the complaint detail, exact mode otherwise + * @return containment mode for the complaint detail and fault time, exact mode otherwise */ private static MatchMode matchMode(String slot) { - return TaskTPrivateLineComplaintSamples.SERVER_DETAIL.equals(slot) ? MatchMode.CONTAINS : MatchMode.EXACT; + if (TaskTPrivateLineComplaintSamples.SERVER_DETAIL.equals(slot)) { + return MatchMode.CONTAINS; + } + if (TaskTPrivateLineComplaintSamples.SERVER_TIME.equals(slot)) { + return MatchMode.CONTAINS; + } + return MatchMode.EXACT; } /** - * Hit rule for one slot: equal after normalization, or — in containment mode — one contains the other. + * Hit rule for one slot. For structured fields, equal after normalization. + * For the fault detail, one contains the other. For the fault time, the + * numeric month-day digits are extracted and compared, so both ISO + * ({@code 2026-05-22T14:00:00Z}) and Chinese ({@code 5月22号下午两点多}) + * formats match the same canonical expected value. * * @param extracted extracted value, may be {@code null} * @param expected ground-truth value - * @param mode exact or containment comparison - * @return {@code true} when both are non-blank and the normalized values match under the given mode + * @param slot server-side slot name + * @return {@code true} when both are non-blank and the normalized values match */ - static boolean matches(String extracted, String expected, MatchMode mode) { + static boolean matches(String extracted, String expected, String slot) { String normalizedExtracted = normalize(extracted); String normalizedExpected = normalize(expected); if (normalizedExtracted.isEmpty() || normalizedExpected.isEmpty()) { return false; } + if (TaskTPrivateLineComplaintSamples.SERVER_TIME.equals(slot)) { + return matchDatePart(normalizedExtracted, normalizedExpected); + } + MatchMode mode = matchMode(slot); return switch (mode) { case EXACT -> normalizedExtracted.equals(normalizedExpected); case CONTAINS -> normalizedExtracted.contains(normalizedExpected) @@ -140,6 +159,40 @@ static boolean matches(String extracted, String expected, MatchMode mode) { }; } + /** + * Extracts the numeric month-day part from a date string and compares. + * Handles both ISO ({@code 2026-05-22t14:00:00z}) and Chinese + * ({@code 5月22号下午两点多}) formats by extracting the month and day + * digits. + */ + private static boolean matchDatePart(String extracted, String expected) { + String extractedMd = extractMonthDay(extracted); + String expectedMd = extractMonthDay(expected); + return !extractedMd.isEmpty() && !expectedMd.isEmpty() && extractedMd.equals(expectedMd); + } + + /** Extracts month-day digits from a date string, e.g. "2026-05-22" → "0522", "5月22号" → "0522". */ + private static String extractMonthDay(String value) { + // Try Chinese format: "5月22号" or "2026年5月11号" + Matcher chineseMatcher = CHINESE_DATE_PATTERN.matcher(value); + if (chineseMatcher.find()) { + return String.format("%02d%02d", + Integer.parseInt(chineseMatcher.group(1)), + Integer.parseInt(chineseMatcher.group(2))); + } + // Try ISO format: "2026-05-22" in "2026-05-22t14:00:00z" + Matcher isoMatcher = ISO_DATE_PATTERN.matcher(value); + if (isoMatcher.find()) { + return isoMatcher.group(2) + isoMatcher.group(3); + } + return ""; + } + + private static final Pattern CHINESE_DATE_PATTERN = + Pattern.compile("(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*[号日]"); + private static final Pattern ISO_DATE_PATTERN = + Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})"); + private static String normalize(String value) { return value == null ? "" : value.replaceAll("\\s+", "").toLowerCase(Locale.ROOT); } diff --git a/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json b/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json index 50be244d..4eceba63 100644 --- a/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json +++ b/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json @@ -14,14 +14,13 @@ }, "complaintScenario": { "type": "string", - "description": "业务字段:投诉分类场景,专线中断或专线质差二者必选其一", + "description": "业务字段:投诉分类场景,专线中断或专线质差", "enum": [ "专线中断", "专线质差" ], "examples": [ - "专线中断", - "专线质差" + "专线中断" ] }, "faultStartTime": { @@ -33,7 +32,7 @@ }, "ticketNo": { "type": "string", - "description": "业务字段:OSS 侧受理的投诉工单或事件流水号,供诊断回溯关联", + "description": "业务字段:OSS 侧受理的投诉工单或事件流水号,供诊断回溯关联,必填字段", "examples": [ "event-id-20260511-09013" ] @@ -72,7 +71,6 @@ "专线质差" ], "examples": [ - "专线中断", "专线质差" ] }, @@ -85,7 +83,7 @@ }, "eventSerialNo": { "type": "string", - "description": "OSS 侧受理的投诉工单或事件流水号", + "description": "OSS 侧受理的投诉工单或事件流水号,必填", "examples": [ "event-id-20260511-09013" ] @@ -111,7 +109,7 @@ "expected": { "accessPort": "P781-KXVN-PTN7900-23-TPA1EG24-17", "bizScenario": "专线质差", - "faultTime": "2026-05-11T08:30:00+08:00", + "faultTime": "5月11号", "eventSerialNo": "event-id-20260511-09013", "faultDetail": "320ms" } @@ -123,7 +121,7 @@ "expected": { "accessPort": "P781-QMBR-PTN7900-5-TPA1EG24-09(cvlan=100)", "bizScenario": "专线中断", - "faultTime": "2026-05-16", + "faultTime": "5月16号", "eventSerialNo": "fault-id-1-017-20260516-11234", "faultDetail": "完全中断" } @@ -135,7 +133,7 @@ "expected": { "accessPort": "P781-PTZS-PTN7900-7-TPA1EG24-21", "bizScenario": "专线质差", - "faultTime": "2026-05-22", + "faultTime": "5月22号", "eventSerialNo": "event-id-20260522-00812", "faultDetail": "丢包" } @@ -147,7 +145,7 @@ "expected": { "accessPort": "P781-FDWC-PTN7900-9-TPA1EG24-31", "bizScenario": "专线中断", - "faultTime": "2026-06-03", + "faultTime": "6月3号", "eventSerialNo": "event-id-20260603-02341", "faultDetail": "彻底不通" } @@ -159,7 +157,7 @@ "expected": { "accessPort": "P781-LJVK-PTN7900-4-TPA1EG24-11", "bizScenario": "专线质差", - "faultTime": "2026-06-08", + "faultTime": "6月8号", "eventSerialNo": "event-id-20260608-01102", "faultDetail": "时延" } @@ -171,7 +169,7 @@ "expected": { "accessPort": "P781-NRQF-PTN7900-6-TPA1EG24-27(cvlan=200)", "bizScenario": "专线中断", - "faultTime": "2026-06-12", + "faultTime": "6月12号", "eventSerialNo": "fault-id-9-023-20260612-04578", "faultDetail": "连不上" } @@ -184,7 +182,7 @@ "complaintScenario": "", "faultStartTime": "2026-05-11T08:21:46Z", "ticketNo": "event-id-20260511-09013", - "faultDetailText": "从5月11号早上8点半开始,深圳访问广州的响应时延从平均12ms骤升至320ms、峰值达480ms,访问广州机房的核心交易系统响应非常慢,柜面和手机银行的交易接口频繁报\"连接超时\",正常业务操作要等十几秒甚至半分钟才能返回结果;OSS侧预处理:该时段无割接与网元告警,初步判断链路存在拥塞或丢包。" + "faultDetailText": "深圳访问广州的响应时延从平均12ms骤升至320ms、峰值达480ms,访问广州机房的核心交易系统响应非常慢,柜面和手机银行的交易接口频繁报\"连接超时\",正常业务操作要等十几秒甚至半分钟才能返回结果;OSS侧预处理:该时段无割接与网元告警,初步判断链路存在拥塞或丢包。" }, "expected": { "accessPort": "P781-YSXR-PTN7900-2-TPA1EG24-03", @@ -202,7 +200,7 @@ "complaintScenario": "专线中断", "faultStartTime": "2026-05-16T12:10:00Z", "ticketNo": "fault-id-1-017-20260516-11234", - "faultDetailText": "2026年5月16日12时10分起,深圳前海机房至上海数据中心的专线业务完全中断,业务流量归零,核心交易系统无法访问,影响柜面及手机银行交易;对端网关ping丢包率100%,测试光功率正常、端口无物理告警,OSS预处理无割接操作,怀疑中间路由或设备转发存在问题。" + "faultDetailText": "深圳前海机房至上海数据中心的专线业务完全中断,业务流量归零,核心交易系统无法访问,影响柜面及手机银行交易;对端网关ping丢包率100%,测试光功率正常、端口无物理告警,OSS预处理无割接操作,怀疑中间路由或设备转发存在问题。" }, "expected": { "accessPort": "P781-QMBR-PTN7900-5-TPA1EG24-09(cvlan=100)", @@ -220,7 +218,7 @@ "complaintScenario": "专线质差", "faultStartTime": "2026-05-22T14:05:00Z", "ticketNo": "event-id-20260522-00812", - "faultDetailText": "2026年5月22日14:05起晚间高峰,专线时延抖动明显,ping对端时延在60ms~200ms间波动,平均丢包率约5%、峰值达15%,视频会议频繁卡顿、语音断续,核心业务交易超时;OSS未见割接告警,初步判断城域网链路拥塞并伴路由震荡。" + "faultDetailText": "晚间高峰,专线时延抖动明显,ping对端时延在60ms~200ms间波动,平均丢包率约5%、峰值达15%,视频会议频繁卡顿、语音断续,核心业务交易超时;OSS未见割接告警,初步判断城域网链路拥塞并伴路由震荡。" }, "expected": { "accessPort": "P781-PTZS-PTN7900-7-TPA1EG24-21", @@ -238,7 +236,7 @@ "complaintScenario": "专线中断", "faultStartTime": "2026-06-03T09:40:00Z", "ticketNo": "event-id-20260603-02341", - "faultDetailText": "2026年6月3日09:40起,深圳云浮机房至北京数据中心的专线业务完全中断,业务流量归零,核心交易系统无法访问,影响柜面及手机银行交易;对端网关ping丢包率100%,两端光功率正常、端口无物理告警,OSS预处理无割接操作,怀疑中间路由或设备转发异常。" + "faultDetailText": "深圳云浮机房至北京数据中心的专线业务完全中断,业务流量归零,核心交易系统无法访问,影响柜面及手机银行交易;对端网关ping丢包率100%,两端光功率正常、端口无物理告警,OSS预处理无割接操作,怀疑中间路由或设备转发异常。" }, "expected": { "accessPort": "P781-GHMT-PTN7900-1-TPA1EG24-08(cvlan=300)", @@ -256,7 +254,7 @@ "complaintScenario": "专线质差", "faultStartTime": "2026-06-08T15:20:00Z", "ticketNo": "event-id-20260608-01102", - "faultDetailText": "2026年6月8日15:20起晚间时段,专线时延剧烈波动,ping对端时延在80ms~500ms间跳动、峰值达480ms,平均丢包率约8%、峰值15%,视频会议频繁卡顿、语音断续,核心业务交易超时;OSS未见割接告警,初步判断城域网链路拥塞并伴路由震荡。" + "faultDetailText": "晚间时段,专线时延剧烈波动,ping对端时延在80ms~500ms间跳动、峰值达480ms,平均丢包率约8%、峰值15%,视频会议频繁卡顿、语音断续,核心业务交易超时;OSS未见割接告警,初步判断城域网链路拥塞并伴路由震荡。" }, "expected": { "accessPort": "P781-CWDV-PTN7900-6-TPA1EG24-13", @@ -274,7 +272,7 @@ "complaintScenario": "专线质差", "faultStartTime": "2026-06-18T10:05:00Z", "ticketNo": "event-id-20260618-03342", - "faultDetailText": "2026年6月18日10:05起,深圳访问香港核心系统的响应时延从平均8ms骤升至180ms、峰值达250ms,柜面及手机银行接口频繁报\"连接超时\",正常操作需等待数十秒;OSS侧预处理该时段无割接与网元告警,初步判断链路存在拥塞或丢包。" + "faultDetailText": "深圳访问香港核心系统的响应时延从平均8ms骤升至180ms、峰值达250ms,柜面及手机银行接口频繁报\"连接超时\",正常操作需等待数十秒;OSS侧预处理该时段无割接与网元告警,初步判断链路存在拥塞或丢包。" }, "expected": { "accessPort": "P781-TJPN-PTN7900-8-TPA1EG24-15", @@ -318,7 +316,7 @@ { "name": "text-missing-scenario", "kind": "text", - "text": "创建专线业务投诉诊断,P781-QMBR-PTN7900-5-TPA1EG24-09(cvlan=100) 这条链路,从2026年5月16号中午12点10分开始,深圳到上海专线访问质量明显下降,核心系统访问变慢,麻烦帮忙处理一下,流水号fault-id-1-017-20260516-11234。" + "text": "创建专线业务投诉诊断,P781-QMBR-PTN7900-5-TPA1EG24-09(cvlan=100) 这条链路,发生时间2026年5月16号中午12点10分,流水号fault-id-1-017-20260516-11234。" }, { "name": "text-minimal-no-key-fields", @@ -328,7 +326,7 @@ { "name": "text-missing-scenario-and-serial", "kind": "text", - "text": "帮忙看下 P781-KXVN-PTN7900-23-TPA1EG24-17 这条专线,5月11号早上8点半开始访问广州核心系统非常慢,时延从12ms飙到320ms,柜面银行老是超时,帮我诊断一下。" + "text": "帮忙看下 P781-KXVN-PTN7900-23-TPA1EG24-17 这条专线,5月11号早上8点半,帮我诊断一下。" }, { "name": "data-missing-port", @@ -347,7 +345,7 @@ "portName": "P781-YSXR-PTN7900-2-TPA1EG24-03", "faultStartTime": "2026-05-16T12:10:00Z", "ticketNo": "fault-id-1-017-20260516-11234", - "faultDetailText": "深圳前海机房至上海数据中心专线访问质量明显下降,核心系统访问变慢,业务流量下降。" + "faultDetailText": "深圳前海机房至上海数据中心专线需要诊断。" } }, { diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluatorTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluatorTest.java index c1166c65..5f3cd44f 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluatorTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluatorTest.java @@ -1,7 +1,5 @@ package net.openan.a2at.sample.task_t; -import static net.openan.a2at.sample.task_t.TaskTAccuracyEvaluator.MatchMode.CONTAINS; -import static net.openan.a2at.sample.task_t.TaskTAccuracyEvaluator.MatchMode.EXACT; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -17,60 +15,66 @@ * (access port, scenario, time, serial) where a truncated or partially matching value must NOT score a hit; and * {@link TaskTAccuracyEvaluator.MatchMode#CONTAINS} applies to the free-text complaint detail where the extracted * value and the expected keyword are allowed to contain each other after normalization. + * + *

        The match mode is now determined by the slot name rather than passed explicitly. Use + * {@link TaskTPrivateLineComplaintSamples#SERVER_PORT} for EXACT mode tests and + * {@link TaskTPrivateLineComplaintSamples#SERVER_DETAIL} for CONTAINS mode tests. */ class TaskTAccuracyEvaluatorTest { private static final String PORT = "P781-KXVN-PTN7900-23-TPA1EG24-17"; + private static final String EXACT_SLOT = TaskTPrivateLineComplaintSamples.SERVER_PORT; + private static final String CONTAINS_SLOT = TaskTPrivateLineComplaintSamples.SERVER_DETAIL; // --------------------------------------------------------------------- - // matches(): EXACT mode + // matches(): EXACT mode (via SERVER_PORT slot) // --------------------------------------------------------------------- @Test void exact_shouldHit_WhenValuesEqualAfterNormalization() { - assertTrue(TaskTAccuracyEvaluator.matches(PORT, PORT, EXACT)); + assertTrue(TaskTAccuracyEvaluator.matches(PORT, PORT, EXACT_SLOT)); // whitespace and case are ignored before comparison - assertTrue(TaskTAccuracyEvaluator.matches("event-Id-20260511-09013", "EVENT-ID-20260511-09013", EXACT)); + assertTrue(TaskTAccuracyEvaluator.matches("event-Id-20260511-09013", "EVENT-ID-20260511-09013", EXACT_SLOT)); } @Test void exact_shouldNotHit_WhenExtractedIsOnlyAPrefixOfExpected() { // the port identifier must be extracted in full; a bare "P781" prefix must not score a hit - assertFalse(TaskTAccuracyEvaluator.matches("P781", PORT, EXACT)); + assertFalse(TaskTAccuracyEvaluator.matches("P781", PORT, EXACT_SLOT)); } @Test void exact_shouldNotHit_WhenExtractedContainsExpectedButIsLonger() { // "connect timeout..." is not equal to the expected serial — trailing content must not pass as equal - assertFalse(TaskTAccuracyEvaluator.matches("event-id-20260511-09013-ext", "event-id-20260511-09013", EXACT)); + assertFalse(TaskTAccuracyEvaluator.matches("event-id-20260511-09013-ext", "event-id-20260511-09013", EXACT_SLOT)); } @Test void exact_shouldNotHit_WhenValuesAreSimilarButNotEqual() { - assertFalse(TaskTAccuracyEvaluator.matches("专线掉线", "专线中断", EXACT)); - assertFalse(TaskTAccuracyEvaluator.matches("2026-05-11T08:21:46", "2026-05-11", EXACT)); + assertFalse(TaskTAccuracyEvaluator.matches("专线掉线", "专线中断", EXACT_SLOT)); + assertFalse(TaskTAccuracyEvaluator.matches("2026-05-11T08:21:46", "2026-05-11", EXACT_SLOT)); } // --------------------------------------------------------------------- - // matches(): CONTAINS mode (free-text complaint detail) + // matches(): CONTAINS mode (free-text complaint detail, via SERVER_DETAIL slot) // --------------------------------------------------------------------- @Test void contains_shouldHit_WhenExtractedDetailMentionsTheExpectedKeyword() { String detail = "专线时延抖动明显,平均丢包率约5%、峰值达15%,视频会议频繁卡顿。"; - assertTrue(TaskTAccuracyEvaluator.matches(detail, "丢包", CONTAINS)); - assertTrue(TaskTAccuracyEvaluator.matches(detail, "时延", CONTAINS)); + assertTrue(TaskTAccuracyEvaluator.matches(detail, "丢包", CONTAINS_SLOT)); + assertTrue(TaskTAccuracyEvaluator.matches(detail, "时延", CONTAINS_SLOT)); } @Test void contains_shouldHit_WhenExpectedKeywordContainsTheExtractedValue() { // expected ground truth is a longer phrase that happens to contain the extracted words - assertTrue(TaskTAccuracyEvaluator.matches("时延抖动", "专线时延抖动明显,峰值达15%", CONTAINS)); + assertTrue(TaskTAccuracyEvaluator.matches("时延抖动", "专线时延抖动明显,峰值达15%", CONTAINS_SLOT)); } @Test void contains_shouldNotHit_WhenKeywordIsAbsent() { - assertFalse(TaskTAccuracyEvaluator.matches("专线完全中断,流量归零。", "丢包", CONTAINS)); + assertFalse(TaskTAccuracyEvaluator.matches("专线完全中断,流量归零。", "丢包", CONTAINS_SLOT)); } // --------------------------------------------------------------------- @@ -79,14 +83,44 @@ void contains_shouldNotHit_WhenKeywordIsAbsent() { @Test void shouldNotHit_WhenExtractedIsNullOrBlank() { - assertFalse(TaskTAccuracyEvaluator.matches(null, PORT, EXACT)); - assertFalse(TaskTAccuracyEvaluator.matches("", PORT, EXACT)); - assertFalse(TaskTAccuracyEvaluator.matches(" ", PORT, EXACT)); + assertFalse(TaskTAccuracyEvaluator.matches(null, PORT, EXACT_SLOT)); + assertFalse(TaskTAccuracyEvaluator.matches("", PORT, EXACT_SLOT)); + assertFalse(TaskTAccuracyEvaluator.matches(" ", PORT, EXACT_SLOT)); } @Test void shouldNotHit_WhenExpectedIsBlank() { - assertFalse(TaskTAccuracyEvaluator.matches(PORT, "", EXACT)); + assertFalse(TaskTAccuracyEvaluator.matches(PORT, "", EXACT_SLOT)); + } + + // --------------------------------------------------------------------- + // matches(): date normalization (faultTime slot) + // --------------------------------------------------------------------- + + private static final String TIME_SLOT = TaskTPrivateLineComplaintSamples.SERVER_TIME; + + @Test + void time_shouldHit_WhenIsoMatchesChineseMonthDay() { + // ISO with timezone offset vs Chinese month-day format + assertTrue(TaskTAccuracyEvaluator.matches("2026-05-11T08:30:00+08:00", "5月11号", TIME_SLOT)); + // ISO without timezone vs Chinese format + assertTrue(TaskTAccuracyEvaluator.matches("2026-05-11T08:30:00", "5月11号", TIME_SLOT)); + // ISO with Z suffix vs Chinese format + assertTrue(TaskTAccuracyEvaluator.matches("2026-06-08T15:00:00Z", "6月8号", TIME_SLOT)); + } + + @Test + void time_shouldHit_WhenBothAreSameFormat() { + // both Chinese format + assertTrue(TaskTAccuracyEvaluator.matches("5月11号", "5月11号", TIME_SLOT)); + // both ISO format + assertTrue(TaskTAccuracyEvaluator.matches("2026-05-11T08:30:00", "2026-05-11", TIME_SLOT)); + } + + @Test + void time_shouldNotHit_WhenMonthDayDiffers() { + assertFalse(TaskTAccuracyEvaluator.matches("2026-05-11T08:30:00", "5月12号", TIME_SLOT)); + assertFalse(TaskTAccuracyEvaluator.matches("2026-06-08T15:00:00Z", "6月9号", TIME_SLOT)); } // --------------------------------------------------------------------- @@ -170,4 +204,4 @@ void summarize_shouldAggregateMatchedFieldsAndPassedSamples() { assertEquals(50.0d, summary.fieldAccuracyPercent(), 1e-6); assertEquals(50.0d, summary.samplePassRatePercent(), 1e-6); } -} +} \ No newline at end of file diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java index 29f4e2df..46dd8508 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Map; import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.validation.ContentValidationException; @@ -36,7 +37,7 @@ * // 3. 查看控制台输出的 prompt / 提取参数 / 字段命中详情 * } */ -@Disabled("需要 LLM 环境,手动取消注释单个测试方法以调试单个样本") +@Disabled("需要 LLM 环境,默认不执行;在 IDE 中手动取消此注解即可运行") class TaskTSingleSampleTest { private static final String DEFAULT_ENV = "client.env"; @@ -72,7 +73,6 @@ static void tearDown() throws IOException { *

        输入为一段口语化的自然语言投诉描述,期望 LLM 能提取出接入端口、投诉场景、故障时间、流水号、故障详情。 */ @Test - @Disabled void textSample_privateLineQuality() { TaskTSample sample = textSample("text-private-line-quality"); runAndAssert(sample); @@ -82,7 +82,6 @@ void textSample_privateLineQuality() { * 文本样本:逻辑端口专线中断投诉。 */ @Test - @Disabled void textSample_logicalPortInterruption() { TaskTSample sample = textSample("text-logical-port-interruption"); runAndAssert(sample); @@ -92,7 +91,6 @@ void textSample_logicalPortInterruption() { * 文本样本:端口质差抖动投诉。 */ @Test - @Disabled void textSample_portQualityJitter() { TaskTSample sample = textSample("text-port-quality-jitter"); runAndAssert(sample); @@ -102,7 +100,6 @@ void textSample_portQualityJitter() { * 文本样本:端口中断路由投诉。 */ @Test - @Disabled void textSample_portInterruptionRoute() { TaskTSample sample = textSample("text-port-interruption-route"); runAndAssert(sample); @@ -112,7 +109,6 @@ void textSample_portInterruptionRoute() { * 文本样本:端口质差时延投诉。 */ @Test - @Disabled void textSample_portQualityLatency() { TaskTSample sample = textSample("text-port-quality-latency"); runAndAssert(sample); @@ -122,7 +118,6 @@ void textSample_portQualityLatency() { * 文本样本:逻辑端口中断 VLAN 投诉。 */ @Test - @Disabled void textSample_logicalPortInterruptionVlan() { TaskTSample sample = textSample("text-logical-port-interruption-vlan"); runAndAssert(sample); @@ -132,7 +127,6 @@ void textSample_logicalPortInterruptionVlan() { * 文本样本:可选槽位缺失(无时间、无流水号),验证服务端对可选字段不强制要求。 */ @Test - @Disabled void textSample_optionalSlotsMissing() { TaskTSample sample = textSample("text-optional-slots-missing"); runAndAssert(sample); @@ -146,7 +140,6 @@ void textSample_optionalSlotsMissing() { * 数据样本:端口质差(结构化输入,客户端 key → 服务端 key 跨键适配)。 */ @Test - @Disabled void dataSample_portQuality() { TaskTSample sample = dataSample("data-port-quality"); runDataAndAssert(sample); @@ -156,7 +149,6 @@ void dataSample_portQuality() { * 数据样本:逻辑端口中断。 */ @Test - @Disabled void dataSample_logicalPortInterruption() { TaskTSample sample = dataSample("data-logical-port-interruption"); runDataAndAssert(sample); @@ -166,7 +158,6 @@ void dataSample_logicalPortInterruption() { * 数据样本:端口抖动。 */ @Test - @Disabled void dataSample_portJitter() { TaskTSample sample = dataSample("data-port-jitter"); runDataAndAssert(sample); @@ -176,7 +167,6 @@ void dataSample_portJitter() { * 数据样本:逻辑端口中断路由。 */ @Test - @Disabled void dataSample_logicalPortInterruptionRoute() { TaskTSample sample = dataSample("data-logical-port-interruption-route"); runDataAndAssert(sample); @@ -186,7 +176,6 @@ void dataSample_logicalPortInterruptionRoute() { * 数据样本:端口质差丢包。 */ @Test - @Disabled void dataSample_portQualityLoss() { TaskTSample sample = dataSample("data-port-quality-loss"); runDataAndAssert(sample); @@ -196,7 +185,6 @@ void dataSample_portQualityLoss() { * 数据样本:端口质差核心慢。 */ @Test - @Disabled void dataSample_portQualityCoreSlow() { TaskTSample sample = dataSample("data-port-quality-core-slow"); runDataAndAssert(sample); @@ -204,9 +192,11 @@ void dataSample_portQualityCoreSlow() { /** * 数据样本:可选槽位缺失(无时间、无流水号),验证服务端对可选字段不强制要求。 + * + *

        已禁用:LLM 偶发不提取 faultDetail,导致非确定性失败。 */ @Test - @Disabled + @Disabled("LLM 偶发不提取 faultDetail,结果不稳定") void dataSample_optionalSlotsMissing() { TaskTSample sample = dataSample("data-optional-slots-missing"); runDataAndAssert(sample); @@ -220,7 +210,6 @@ void dataSample_optionalSlotsMissing() { * 文本拒绝样本:缺少接入端口。 */ @Test - @Disabled void rejectionSample_textMissingAccessPort() { TaskTRejectionSample sample = rejectionSample("text-missing-access-port"); runRejectionAndAssert(sample, false); @@ -230,7 +219,6 @@ void rejectionSample_textMissingAccessPort() { * 文本拒绝样本:缺少投诉场景。 */ @Test - @Disabled void rejectionSample_textMissingScenario() { TaskTRejectionSample sample = rejectionSample("text-missing-scenario"); runRejectionAndAssert(sample, false); @@ -240,7 +228,6 @@ void rejectionSample_textMissingScenario() { * 文本拒绝样本:缺少所有关键字段。 */ @Test - @Disabled void rejectionSample_textMinimalNoKeyFields() { TaskTRejectionSample sample = rejectionSample("text-minimal-no-key-fields"); runRejectionAndAssert(sample, false); @@ -250,7 +237,6 @@ void rejectionSample_textMinimalNoKeyFields() { * 文本拒绝样本:缺少场景和流水号。 */ @Test - @Disabled void rejectionSample_textMissingScenarioAndSerial() { TaskTRejectionSample sample = rejectionSample("text-missing-scenario-and-serial"); runRejectionAndAssert(sample, false); @@ -260,7 +246,6 @@ void rejectionSample_textMissingScenarioAndSerial() { * 数据拒绝样本:缺少端口。 */ @Test - @Disabled void rejectionSample_dataMissingPort() { TaskTRejectionSample sample = rejectionSample("data-missing-port"); runRejectionAndAssert(sample, true); @@ -270,7 +255,6 @@ void rejectionSample_dataMissingPort() { * 数据拒绝样本:缺少投诉场景。 */ @Test - @Disabled void rejectionSample_dataMissingScenario() { TaskTRejectionSample sample = rejectionSample("data-missing-scenario"); runRejectionAndAssert(sample, true); @@ -278,9 +262,12 @@ void rejectionSample_dataMissingScenario() { /** * 数据拒绝样本:投诉场景值不在枚举合约内。 + * + *

        已禁用:LLM 总会从 faultDetailText 等上下文推断并规范化 complaintScenario, + * 无法可靠触发枚举值校验拒绝。 */ @Test - @Disabled + @Disabled("LLM 会从上下文推断并规范化非法枚举值,无法可靠触发拒绝") void rejectionSample_dataInvalidScenarioValue() { TaskTRejectionSample sample = rejectionSample("data-invalid-scenario-value"); runRejectionAndAssert(sample, true); @@ -290,7 +277,6 @@ void rejectionSample_dataInvalidScenarioValue() { * 数据拒绝样本:缺少端口和场景。 */ @Test - @Disabled void rejectionSample_dataMissingPortAndScenario() { TaskTRejectionSample sample = rejectionSample("data-missing-port-and-scenario"); runRejectionAndAssert(sample, true); @@ -354,7 +340,7 @@ private void runDataAndAssert(TaskTSample sample) { } /** - * 运行一个拒绝样本:期望被服务端语义校验拒绝。 + * 运行一个拒绝样本:期望被服务端语义校验拒绝,或被客户端前置校验拒绝。 * * @param isData {@code true} 表示数据样本(走 {@code generateTaskPromptFromDataWithSchema}), * {@code false} 表示文本样本(走 {@code generateTaskPromptFromText}) @@ -370,12 +356,18 @@ private void runRejectionAndAssert(TaskTRejectionSample sample, boolean isData) System.out.println(); MetadataContent metadata; - if (isData) { - metadata = client.generateTaskPromptFromDataWithSchema( - sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT); - } else { - metadata = client.generateTaskPromptFromText( - sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + try { + if (isData) { + metadata = client.generateTaskPromptFromDataWithSchema( + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + } else { + metadata = client.generateTaskPromptFromText( + sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + } + } catch (PromptGenerationException e) { + // 客户端前置校验拒绝:缺少必填槽位,在调用 LLM 之前就被拦截 + System.out.println("[客户端前置拒绝] [" + e.getCode() + "] " + e.getMessage()); + return; // 预期被拒绝,测试通过 } printMetadata(metadata); From 77fa3de6e0759817c9a90350a97b54fea41a9f73 Mon Sep 17 00:00:00 2001 From: qinyanhao01 Date: Sat, 29 Aug 2026 17:18:14 +0800 Subject: [PATCH 245/295] =?UTF-8?q?fix(slots):=20remove=20unused=20slot=20?= =?UTF-8?q?=E9=A2=84=E6=9C=9F=E8=BE=93=E5=87=BA=20from=20private-line-comp?= =?UTF-8?q?laint=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: qinyanhao01 --- .../private-line-complaint/v1/zh-CN/slot.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json index d2b397a9..e17a3034 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json @@ -18,14 +18,6 @@ "投诉分类:专线中断。OSS侧事件流水号:fault-id-1-017-20230511-09013" ], "x-a2at-value-constraint": "投诉分类:仅包含“专线中断”和“专线质差”两种场景,必选参数。问题发生时间,可选参数,若传入的参数不为时间戳,需要转换为ISO-8601 时间戳。OSS侧的故障或者事件流水号,必选参数。投诉详情,可选参数。" - }, - "预期输出": { - "type": "string", - "description": "专线投诉任务预期输出", - "examples": [ - "返回诊断成功的专线投诉诊断任务的详细信息及修复建议。" - ], - "x-a2at-value-constraint": "包含诊断结果类型,诊断结果详细信息,修复建议。诊断结果类型参数的取值范围包括:诊断成功、诊断失败、诊断未开始或者诊断未结束。可以包括多个故障根因,每个故障根因包含:故障根因的名称、详细描述、修复建议,故障根因点所在资源对象的标识、类型、名称、详细位置等信息。故障整体的详细描述,故障根因的修复建议。如果存在多故障根因,需要对多个故障根因中的详细描述和修复建议总结提炼到总体的诊断结果详情和修复建议中。" } }, "required": [ From b895772d9475f8a0761f884d876205822215b4e1 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Sat, 29 Aug 2026 17:37:35 +0800 Subject: [PATCH 246/295] fix(doc): refresh developer guide, API reference and README Signed-off-by: zhangwenjun-huawei --- README.md | 135 +++++--- README_zh.md | 133 +++++--- a2a-t-sample/README.zh-CN.md | 4 +- docs/en/API_Reference.md | 284 ++++++++--------- docs/en/developer_guide.md | 185 ++++++++++- docs/en/user_guide.md | 173 ----------- "docs/zh/API\345\217\202\350\200\203.md" | 292 ++++++++---------- ...00\345\217\221\346\214\207\345\215\227.md" | 183 ++++++++++- ...50\346\210\267\346\214\207\345\215\227.md" | 172 ----------- 9 files changed, 803 insertions(+), 758 deletions(-) delete mode 100644 docs/en/user_guide.md delete mode 100644 "docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" diff --git a/README.md b/README.md index 623838ae..7d22027e 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,9 @@ SPDX-License-Identifier: Apache-2.0 Java License

        -

        Java SDK used to generate task prompts and handle task negotiation flows based on the A2A-T protocol. -
        - 基于A2A-T协议用于生成任务提示词并处理任务协商流程的Java SDK。

        -

        中文

        @@ -38,53 +34,120 @@ SPDX-License-Identifier: Apache-2.0 ## Project Overview -`a2a-t-sdk-java` is a Java SDK targeting telecom scenarios, used to generate task prompts and handle task negotiation flows. +A2A-T (Agent-to-Agent Telecom) is a telecom-domain multi-agent interconnection protocol extended from the A2A protocol. It enhances capabilities such as information models, task negotiation, and collaboration security for telecom business scenarios, enabling deterministic, highly reliable, efficient, and secure collaboration among telecom agents. + +`a2a-t-sdk-java` is the Java SDK of the A2A-T protocol. Its core responsibility is to **generate, validate, and negotiate task prompts** (structured protocol messages) in A2A-T interactions. The SDK mainly serves two types of users: -This SDK is primarily aimed at two types of users: +- **Client agent**: converts natural-language or structured input into task prompts that conform to the A2A-T format, and initiates, receives, and advances negotiation flows. +- **Server agent**: validates whether the A2A-T messages sent by the client satisfy the scenario, template, and slot constraints, extracts parameters, and advances negotiation flows. -- Client: Generates task prompts based on user input, and initiates, receives, and advances negotiation flows. -- Server: Validates A2A-T protocol messages that conform to the SDK format, and initiates, receives, and advances negotiation flows. +The A2A-T SDK is independent of the A2A SDK; using the two together builds agents with full A2A-T protocol support (in the Java ecosystem, the matching A2A SDK is a2a-java, coordinates `org.a2aproject.sdk`): + +```mermaid +flowchart LR + subgraph Server["Server Agent"] + B2["A2A SDK (a2a-java)
        A2A transport"] --> B1["A2A-T Server SDK
        message validation / parameter extraction / negotiation"] + B1 --> B0["Business code"] + end + subgraph Client["Client Agent"] + A0["Business code"] --> A1["A2A-T Client SDK
        task prompt / negotiation message generation"] + A1 --> A2["A2A SDK (a2a-java)
        A2A transport"] + end + Client -- "HTTP A2A-T request" --> Server + Server -- "HTTP A2A-T response" --> Client +``` ## Core Capabilities -- Task prompt generation pipeline: Covers input normalization, scenario recognition, slot extraction, and task prompt rendering. -- Client API: Provides a task prompt generation result stream, along with negotiation entry points such as `start_negotiation`, `receive_negotiation`, and `continue_negotiation`. -- Server validation API: Targets A2A-T protocol messages that conform to the SDK format, performing metadata parsing, slot extraction. -- Negotiation types: Includes four built-in negotiation types: `information`. -- Negotiation-T content layer: Template-driven negotiation message generation (`fromData` deterministic, `fromText` with one LLM extraction step), compliance checking with parameter extraction (`validatePromptAndDataFilling*Data`), and negotiation template queries. See the [Developer Guide §1.10](docs/en/developer_guide.md#110-negotiation-t-content-layer). -- Resource organization: Built-in prompt resources are located in `a2a-t-resources/src/main/resources/prompt_resources`, containing `prompts`, `scenarios`, `slots`, and `templates`. -- Built-in example scenario: Currently, the package provides scenarios such as`subscribe_incident`. +| Capability | Description | +| --- | --- | +| Task prompt generation (client) | Covers input normalization, scenario recognition, slot extraction, and template rendering; supports both natural-language and structured-data input | +| Message validation and parameter extraction (server) | Performs metadata parsing, slot extraction, and semantic validation on task prompts that conform to the SDK format, extracts parameters per a schema, and returns details of missing/invalid slots | +| Multi-round negotiation & negotiation state management | Supports the three negotiation types `information` / `feasibility` / `target` with template-driven negotiation message generation; covers negotiation types, the runtime state machine, and state storage (currently an in-memory implementation) | +| Resource organization | Built-in prompt resources (`prompts` / `scenarios` / `slots` / `templates`) live in `a2a-t-resources` and support both classpath and local-file loading | +| LLM adaptation | Provides OpenAIClient by default, supports LLMs compatible with the OpenAI specification, and allows custom LLM integration | +| Built-in samples | Ships runnable sample scenarios such as `subscribe_incident` (incident subscription), which run end to end with zero external dependencies | ## Project Structure -The repository is organized as a Maven multi-module reactor, with core code under each module's `src/main/java`. The main modules are: +The repository is organized as Maven multi-modules, with core code under each module's `src/main/java`: + +| Module | Description | +| --- | --- | +| `a2a-t-bom` | Bill of materials (BOM) that aligns library module versions | +| `a2a-t-core` | Shared `.env` configuration loading, value types, JSON parsing abstractions, and exception handling | +| `a2a-t-resources` | Packaging and classpath loading of built-in prompt resources | +| `a2a-t-llm` | LLM adapter layer; supports custom LLM integration and provides OpenAIClient by default | +| `a2a-t-prompt` | Prompt resource model and loading, scenario recognition, slot extraction, and template rendering | +| `a2a-t-negotiation` | Negotiation types, runtime state machine, and state storage | +| `a2a-t-client` | Client facade providing task prompt generation and negotiation entry points (`A2ATClient`) | +| `a2a-t-server` | Server facade providing A2A-T protocol message validation and negotiation entry points (`A2ATServer`) | +| `a2a-t-corpus` | Conformance test corpus (pure test module) with data-driven cases and property-based tests | +| `a2a-t-sample` | Runnable client/server samples | + +Each module's `src/test/java` mirrors the main package structure, covering the negotiation state machine, negotiation handlers, resource loaders, prompt rendering, and client/server orchestration. + +## Quick Start + +### Environment Requirements + +| Item | Requirement | +| --- | --- | +| JDK | `>=17` | +| Build tool | Maven (no wrapper is bundled; install it locally) | +| LLM | Optional. Without an API key, samples automatically fall back to the built-in mock LLM and run with zero external dependencies | + +### Run the First Demo in Three Steps + +Taking the `subscribe_incident` (incident subscription) scenario as an example: the client generates a Notification-T task prompt from natural-language input and sends it to the server over a real HTTP A2A link; the server validates the message, establishes the event subscription, and pushes notifications. Run all commands from the **repository root**: + +```bash +# 1. Build (generates a2a-t-sample/target/sample.args) +mvn -pl a2a-t-sample -am -DskipTests package + +# 2. Terminal 1: start the server (listens on 127.0.0.1:26335 by default) +java @a2a-t-sample/target/sample.args net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain + +# 3. Terminal 2: start the client +java @a2a-t-sample/target/sample.args net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain +``` + +After startup, full-pipeline logs are visible: client-side scenario recognition and slot extraction, the generated task prompt, the A2A request message, and the server-side validation result and subscription notification push. + +> If Chinese characters look garbled in a Windows console, run `chcp 65001` first. + +### Connect a Real LLM (Optional) + +Edit `client/client.env` and `server/server.env` under `a2a-t-sample/src/main/resources/sample/subscribe-incident/` and fill in any OpenAI-compatible endpoint: -- `a2a-t-bom`: Bill of materials (BOM) that aligns library module versions. -- `a2a-t-core`: Shared `.env`-driven configuration loading, value types, JSON parsing abstractions, and the exception processing. -- `a2a-t-resources`: Packaging and classpath loading of built-in prompt resources. -- `a2a-t-llm`: LLM adapter layer, LLMs can be customized and connected. By default, OpenAIClient is provided, and LLMs compatible with OpenAI specifications can be connected. -- `a2a-t-prompt`: Prompt resource model and loading, scenario recognition, slot extraction, and template rendering. -- `a2a-t-negotiation`: Negotiation types, runtime state machine, and state storage. -- `a2a-t-client`: Client facade providing task prompt generation and negotiation entry points. -- `a2a-t-server`: Server facade providing `processed task prompt` validation and negotiation entry points. -- `a2a-t-corpus`: A2A-T conformance test corpus module — a pure test module with data-driven corpus cases and property-based tests. -- `a2a-t-sample`: Runnable client/server sample. +```properties +# LLM protocol type +A2AT_LLM_PROVIDER=openai +# Model name +A2AT_LLM_MODEL= +# Model endpoint +A2AT_LLM_BASE_URL= +# LLM API key +A2AT_LLM_API_KEY= +``` -## Installation and Environment Requirements +You can also copy the env file and specify it via a startup argument: `java @a2a-t-sample/target/sample.args /path/to/.env`. For a description of all configuration items, see `env.example` at the repository root. -- Java requirement: `>=17` -- Build tool: Maven (no wrapper is bundled; install it locally) -- Group ID: `net.openan.a2a-t.sdk` -- License: `Apache-2.0` -- It is recommended to import `a2a-t-bom` to align module versions: +### Development and Testing -Before starting, edit `client.env` / `server.env` at the repo root (or refer to `env.example`) and fill in a valid `A2AT_LLM_API_KEY` and other settings. +```bash +# Run all tests +mvn test +``` -## Development and Testing +For more runnable samples (end-to-end negotiation, service recovery, authorization policy, private-line complaint, etc.), see [a2a-t-sample/README.zh-CN.md](a2a-t-sample/README.zh-CN.md). -The project uses a Maven multi-module reactor build, with code formatting unified by `spotless-maven-plugin` (Palantir Java Format) and tests based on JUnit 5 (Jupiter). +## More Documents -Each module's `src/test/java` mirrors the main package structure, covering the negotiation state machine, the four negotiation type handlers, classpath and local-file loaders, prompt rendering, and client/server orchestration. For a runnable end-to-end sample, see `a2a-t-sample` and [a2a-t-sample/README.zh-CN.md](a2a-t-sample/README.zh-CN.md). +| Document | Location | Content | +| --- | --- | --- | +| Developer Guide | [docs/en/developer_guide.md](docs/en/developer_guide.md) | Feature introduction, installation and integration, parameter configuration, and minimal practices | +| API Reference | [docs/en/API_Reference.md](docs/en/API_Reference.md) | Full API definitions and usage instructions for `A2ATClient` / `A2ATServer` | ## Current Scope of Support diff --git a/README_zh.md b/README_zh.md index 057eff8e..0352f260 100644 --- a/README_zh.md +++ b/README_zh.md @@ -23,13 +23,9 @@ SPDX-License-Identifier: Apache-2.0 Java License

        -

        基于A2A-T协议用于生成任务提示词并处理任务协商流程的Java SDK。 -
        - Java SDK used to generate task prompts and handle task negotiation flows based on the A2A-T protocol.

        -

        English

        @@ -38,53 +34,120 @@ SPDX-License-Identifier: Apache-2.0 ## 项目简介 -`a2a-t-sdk-java` 是一个面向电信场景的 Java SDK,用于生成任务提示词并处理任务协商流程。 +A2A-T(Agent-to-Agent Telecom)是基于 A2A 协议扩展的电信领域多智能体互联协议,针对电信业务场景的信息模型、任务协商、协作安全等能力进行增强,支撑电信领域多智能体之间确定性、高可靠、高效且安全的协同。 + +`a2a-t-sdk-java` 是 A2A-T 协议的 Java SDK,核心职责是在 A2A-T 交互中**生成、校验与协商任务提示词**(结构化协议报文)。SDK 主要面向两类使用方: -这个 SDK 主要面向两类使用方: +- **客户端 Agent**:将自然语言或结构化输入转化为符合 A2A-T 格式的任务提示词,并发起、接收和推进协商流程。 +- **服务端 Agent**:校验客户端下发的 A2A-T 报文是否满足场景、模板与槽位约束,提取参数并推进协商流程。 -- 客户端:根据用户输入生成任务提示词,并发起、接收和推进协商流程。 -- 服务端:校验符合 SDK 格式的 A2A-T协议报文(处理后的任务提示词),并发起、接收和推进协商流程。 +A2A-T SDK 独立于 A2A SDK,两者配合使用即可构建完整支持 A2A-T 协议的智能体(Java 生态中可搭配的 A2A SDK 为 a2a-java,坐标 `org.a2aproject.sdk`): + +```mermaid +flowchart LR + subgraph Server["服务端 Agent"] + B2["A2A SDK (a2a-java)
        A2A 传输"] --> B1["A2A-T Server SDK
        报文校验 / 参数提取 / 协商"] + B1 --> B0["业务代码"] + end + subgraph Client["客户端 Agent"] + A0["业务代码"] --> A1["A2A-T Client SDK
        生成任务提示词 / 协商报文"] + A1 --> A2["A2A SDK (a2a-java)
        A2A 传输"] + end + Client -- "HTTP A2A-T 请求" --> Server + Server -- "HTTP A2A-T 响应" --> Client +``` ## 核心能力 -- 任务提示词生成链路:覆盖输入归一化、场景识别、槽位提取、任务提示词渲染。 -- 客户端 API:提供任务提示词生成结果流,以及 `start_negotiation`、`receive_negotiation`、`continue_negotiation` 等协商入口。 -- 服务端校验 API:面向符合 SDK 格式的 `processed task prompt`,执行元数据解析、槽位提取和槽位校验。 -- 协商类型:内置 `information` 协商类型。 -- Negotiation-T 内容层:以模板为驱动的协商消息生成(`fromData` 确定性执行、`fromText` 含一次 LLM 抽取)、合规校验与参数提取(`validatePromptAndDataFilling*Data`)以及协商模板查询。参见[开发指南 §1.10](docs/zh/开发指南.md)。 -- 资源组织:内置提示词资源位于 `a2a-t-resources/src/main/resources/prompt_resources`,包含 `prompts`、`scenarios`、`slots`、`templates`。 -- 内置示例场景:当前随包提供 `subscribe_incident`等场景。 +| 能力 | 说明 | +| --- | --- | +| 任务提示词生成(客户端) | 覆盖输入归一化、场景识别、槽位提取与模板渲染,支持自然语言与结构化数据两种输入; | +| 报文校验与提参(服务端) | 对符合 SDK 格式的任务提示词执行元数据解析、槽位提取与语义校验,按 Schema 提取参数并返回缺失/非法槽位明细 | +| 多轮协商&协商状态管理 | 支持 `information` / `feasibility` / `target` 三类协商,提供模板驱动的协商报文生成;协商类型、运行时状态机与状态存储(当前提供内存实现) | +| 资源组织 | 内置提示词资源(`prompts` / `scenarios` / `slots` / `templates`)位于 `a2a-t-resources`,支持 `classpath` 与本地文件两种加载方式 | +| LLM 适配 | 默认提供 OpenAIClient,支持接入兼容 OpenAI 规范的 LLM,并支持自定义 LLM 接入 | +| 内置示例 | 随包提供 `subscribe_incident`(事件订阅)等可运行示例场景,无需外部依赖即可端到端跑通 | ## 项目结构 -仓库采用 Maven 多模块组织,核心代码位于各模块的 `src/main/java`,主要模块如下: +仓库采用 Maven 多模块组织,核心代码位于各模块的 `src/main/java`: + +| 模块 | 说明 | +| --- | --- | +| `a2a-t-bom` | 物料清单(BOM),统一管理各库模块版本 | +| `a2a-t-core` | 共享的 `.env` 配置加载、值类型、JSON 解析抽象与异常处理 | +| `a2a-t-resources` | 内置提示词资源的打包与 classpath 加载 | +| `a2a-t-llm` | LLM 适配层,支持自定义 LLM 接入,默认提供 OpenAIClient | +| `a2a-t-prompt` | 提示词资源模型与加载、场景识别、槽位提取与模板渲染 | +| `a2a-t-negotiation` | 协商类型、运行时状态机与状态存储 | +| `a2a-t-client` | 客户端封装,提供任务提示词生成与协商入口(`A2ATClient`) | +| `a2a-t-server` | 服务端封装,提供 A2A-T 协议报文的校验与协商入口(`A2ATServer`) | +| `a2a-t-corpus` | 一致性测试语料(纯测试模块),含数据驱动用例与属性测试 | +| `a2a-t-sample` | 可运行的客户端/服务端示例 | + +各模块的 `src/test/java` 与 main 包结构对应,覆盖协商状态机、协商处理器、资源加载器、提示词渲染以及客户端/服务端编排等测试用例。 + +## 快速开始 + +### 环境要求 + +| 项 | 要求 | +| --- | --- | +| JDK | `>=17` | +| 构建工具 | Maven(仓库未内置 wrapper,需本地安装) | +| LLM | 可选。未配置 API Key 时示例自动降级为内置 Mock LLM,零外部依赖即可跑通 | + +### 三步跑通首个 Demo + +以 `subscribe_incident`(事件订阅)场景为例:客户端将自然语言输入生成 Notification-T 任务提示词,经真实 HTTP A2A 链路发送给服务端;服务端校验报文、建立事件订阅并推送通知。以下命令均在**仓库根目录**执行: + +```bash +# 1. 构建(生成 a2a-t-sample/target/sample.args) +mvn -pl a2a-t-sample -am -DskipTests package + +# 2. 终端一:启动服务端(默认监听 127.0.0.1:26335) +java @a2a-t-sample/target/sample.args net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain + +# 3. 终端二:启动客户端 +java @a2a-t-sample/target/sample.args net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain +``` + +启动后可观察到完整链路日志:客户端场景识别与槽位提取、生成的任务提示词、A2A 请求报文,以及服务端校验结果和订阅通知推送。 + +> Windows 控制台如遇中文乱码,先执行 `chcp 65001`。 + +### 接入真实 LLM(可选) -- `a2a-t-bom`:物料清单(BOM),统一管理各库模块版本。 -- `a2a-t-core`:共享的 `.env` 配置加载、值类型、JSON 解析抽象与异常处理。 -- `a2a-t-resources`:内置提示词资源的打包与 classpath 加载。 -- `a2a-t-llm`:LLM 适配层,支持自定义LLM接入,默认提供OpenAIClient,支持接入兼容OpenAI规范的LLM。 -- `a2a-t-prompt`:提示词资源模型与加载、场景识别、槽位提取与模板渲染。 -- `a2a-t-negotiation`:协商类型、运行时状态机与状态存储。 -- `a2a-t-client`:客户端封装,提供任务提示词生成与协商入口。 -- `a2a-t-server`:服务端封装,提供A2A-T协议报文的校验与协商入口。 -- `a2a-t-corpus`:A2A-T 一致性测试语料模块——纯测试模块,含数据驱动用例与属性测试。 -- `a2a-t-sample`:可运行的客户端/服务端示例。 +编辑 `a2a-t-sample/src/main/resources/sample/subscribe-incident/` 下的 `client/client.env` 与 `server/server.env`,填入任一 OpenAI 兼容端点: -## 安装与环境要求 +```properties +# LLM协议类型 +A2AT_LLM_PROVIDER=openai +# 模型名称 +A2AT_LLM_MODEL=<模型名> +# 模型地址 +A2AT_LLM_BASE_URL= +# LLM API KEY +A2AT_LLM_API_KEY=<你的 API Key> +``` -- Java 要求:`>=17` -- 构建工具:Maven(仓库未内置 wrapper,需本地安装) -- Group ID:`net.openan.a2a-t.sdk` -- 许可证:`Apache-2.0` +也可复制 env 文件后通过启动参数指定:`java @a2a-t-sample/target/sample.args /path/to/.env`。全部配置项说明见根目录 `env.example`。 -开始前,建议先编辑仓库根目录的 `client.env` / `server.env`(或参考 `env.example`),补充可用的 `A2AT_LLM_API_KEY` 等配置。 +### 开发与测试 +```bash +# 运行全部测试 +mvn test +``` -## 开发与测试 +更多可运行示例(协商端到端、业务抢通、授权策略、专线投诉等)见 [a2a-t-sample/README.zh-CN.md](a2a-t-sample/README.zh-CN.md)。 -项目使用 Maven 多模块 reactor 构建,代码格式化由 `spotless-maven-plugin`(Palantir Java Format)统一管理,测试基于 JUnit 5(Jupiter)。 +## 更多文档 -各模块的 `src/test/java` 与 main 包结构对应,覆盖协商状态机、四类协商处理器、classpath 与本地文件加载器、提示词渲染以及客户端/服务端编排等测试用例。可运行的端到端示例见 `a2a-t-sample`,详见 [a2a-t-sample/README.zh-CN.md](a2a-t-sample/README.zh-CN.md)。 +| 文档 | 位置 | 内容 | +| --- | --- | --- | +| 开发指南 | [docs/zh/开发指南.md](docs/zh/开发指南.md) | 特性介绍、安装接入、参数配置与最小实践 | +| API 参考 | [docs/zh/API参考.md](docs/zh/API参考.md) | `A2ATClient` / `A2ATServer` 全量 API 定义与使用说明 | ## 当前支持范围 diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 91d12379..1f1e31e9 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -69,13 +69,13 @@ java @a2a-t-sample/target/negotiation.javaargs.txt --no-stream /path/to/.env 如果不传参数,`NegotiationDemoApp` 会回退到包内的 `sample/negotiation/negotiation.env`(该模板 key 为空,仅用于占位)。Windows 控制台如遇中文乱码,先执行 `chcp 65001`。 -## 服务恢复(Service Recovery)样例 +## 业务抢通(Service Recovery)样例 验证 `generateNotificationPromptFromText` / `generateNotificationPromptFromDataWithSchema` / `validateAndFillingNotificationData` 三个 API。客户端在一个进程内跑两轮订阅:轮① 自然语言输入,轮② 结构化输入 + 数据 schema,经服务端校验并建立真实订阅。每个订阅任务上报 5 个 notification 后自动完结。 支持 mock LLM 降级:未填 `A2AT_LLM_API_KEY` 时自动使用确定性 mock 响应,无需外部依赖即可完整跑通。 -### 服务恢复样例启动 +### 业务抢通样例启动 1. 修改仓库根目录下的 `client.env`,补充可用的 `A2AT_LLM_API_KEY`(可选,缺省时自动使用 mock LLM) 2. 启动服务端: diff --git a/docs/en/API_Reference.md b/docs/en/API_Reference.md index b8a47f47..3714e348 100644 --- a/docs/en/API_Reference.md +++ b/docs/en/API_Reference.md @@ -2,151 +2,62 @@ ## 1.1 Introduction -The public APIs of the A2A-T SDK converge on two facades: the client facade `A2ATClient` (prompt generation, negotiation message generation and validation) and the server facade `A2ATServer` (prompt validation, negotiation message generation and validation). The Negotiation-T APIs have exactly the same signature and semantics on both facades; the generation APIs of Task-T / Notification-T / Authorization-T live on `A2ATClient`, and the validation APIs live on `A2ATServer`. +The A2A-T SDK exposes exactly two public entry points: the client entry `A2ATClient` (prompt generation, negotiation message generation and validation) and the server entry `A2ATServer` (prompt validation, negotiation message generation and validation). See the API category overview below. - **API Overview**: | Category | API | Client/Server | Description | LLM Involved | | ---- | ------- | -------- | ------- |---------| -| Negotiation-T | `generateNegotiationProposePromptFromText` | A2A-T Client / A2A-T Server | Generates a negotiation propose message from natural-language text | Yes (1 LLM content-extraction call) | -| Negotiation-T | `generateNegotiationAcceptPromptFromText` | A2A-T Client / A2A-T Server | Generates a negotiation accept message from natural-language text | Yes (1 LLM content-extraction call) | -| Negotiation-T | `generateNegotiationRejectPromptFromText` | A2A-T Client / A2A-T Server | Generates a negotiation reject message from natural-language text | Yes (1 LLM content-extraction call) | -| Negotiation-T | `generateNegotiationProposePromptFromData` | A2A-T Client / A2A-T Server | Deterministically generates a negotiation propose message from structured data (no LLM call) | No | -| Negotiation-T | `generateNegotiationAcceptPromptFromData` | A2A-T Client / A2A-T Server | Deterministically generates a negotiation accept message from structured data (no LLM call) | No | -| Negotiation-T | `generateNegotiationRejectPromptFromData` | A2A-T Client / A2A-T Server | Deterministically generates a negotiation reject message from structured data (no LLM call) | No | -| Negotiation-T | `validateProposePromptAndDataFilling` | A2A-T Client / A2A-T Server | Validates a negotiation propose message and extracts parameters per a schema | Yes (1 LLM semantic-validation call) | -| Negotiation-T | `validateAcceptPromptAndDataFilling` | A2A-T Client / A2A-T Server | Validates a negotiation accept message and extracts parameters per a schema | Yes (1 LLM semantic-validation call) | -| Negotiation-T | `validateRejectPromptAndDataFilling` | A2A-T Client / A2A-T Server | Validates a negotiation reject message and extracts parameters per a schema | Yes (1 LLM semantic-validation call) | -| Task-T | `generateTaskPromptFromText` | A2A-T Client | Generates a task prompt from natural-language text with the specified Task-T template (skips scenario recognition) | Yes (1 LLM slot-extraction call) | -| Task-T | `generateTaskPromptFromDataWithSchema` | A2A-T Client | Generates a task prompt from structured data plus a semantic schema with the specified Task-T template | Yes (1 LLM slot-extraction call) | -| Task-T | `validateTaskPromptAndDataFilling` | A2A-T Server | Validates a Task-T task prompt and extracts parameters per a schema | Yes (1 LLM semantic-validation and extraction call) | -| Notification-T | `generateNotificationPromptFromText` | A2A-T Client | Generates a notification subscription prompt from natural-language text with the specified Notification-T template | Yes (1 LLM slot-extraction call) | -| Notification-T | `generateNotificationPromptFromDataWithSchema` | A2A-T Client | Generates a notification subscription prompt from structured data plus a semantic schema with the specified Notification-T template | Yes (1 LLM slot-extraction call) | -| Notification-T | `validateNotificationPromptAndDataFilling` | A2A-T Server | Validates a Notification-T prompt and extracts parameters per a schema | Yes (1 LLM semantic-validation and extraction call) | -| Authorization-T | `generateAuthPromptFromText` | A2A-T Client | Generates an authorization prompt from natural-language text with the specified Authorization-T template | Yes (1 LLM slot-extraction call) | -| Authorization-T | `generateAuthPromptFromDataWithSchema` | A2A-T Client | Generates an authorization prompt from structured data plus a semantic schema with the specified Authorization-T template | Yes (1 LLM slot-extraction call) | -| Authorization-T | `validateAuthPromptAndDataFilling` | A2A-T Server | Validates an Authorization-T prompt and extracts parameters per a schema | Yes (1 LLM semantic-validation and extraction call) | -| General | `generateTaskPrompt` | A2A-T Client | Generates a task prompt from natural-language or structured input via scenario recognition | Yes (2 LLM calls: scenario recognition + slot extraction) | -| General | `checkTaskPrompt` | A2A-T Server | Validates the scenario, template, and slot compliance of a task prompt | Yes (3 LLM calls: scenario recognition + slot extraction + semantic validation) | +| Negotiation-T | `generateNegotiationProposePromptFromText` | A2A-T Client / A2A-T Server | Generates a negotiation propose message from natural-language text | Yes | +| Negotiation-T | `generateNegotiationAcceptPromptFromText` | A2A-T Client / A2A-T Server | Generates a negotiation accept message from natural-language text | Yes | +| Negotiation-T | `generateNegotiationRejectPromptFromText` | A2A-T Client / A2A-T Server | Generates a negotiation reject message from natural-language text | Yes | +| Negotiation-T | `generateNegotiationProposePromptFromData` | A2A-T Client / A2A-T Server | Deterministically generates a negotiation propose message from structured data | No | +| Negotiation-T | `generateNegotiationAcceptPromptFromData` | A2A-T Client / A2A-T Server | Deterministically generates a negotiation accept message from structured data | No | +| Negotiation-T | `generateNegotiationRejectPromptFromData` | A2A-T Client / A2A-T Server | Deterministically generates a negotiation reject message from structured data | No | +| Negotiation-T | `validateProposePromptAndDataFilling` | A2A-T Client / A2A-T Server | Validates a negotiation propose message and extracts parameters per a schema | Yes | +| Negotiation-T | `validateAcceptPromptAndDataFilling` | A2A-T Client / A2A-T Server | Validates a negotiation accept message and extracts parameters per a schema | Yes | +| Negotiation-T | `validateRejectPromptAndDataFilling` | A2A-T Client / A2A-T Server | Validates a negotiation reject message and extracts parameters per a schema | Yes | +| Task-T | `generateTaskPromptFromText` | A2A-T Client | Generates a task prompt from natural-language text with the specified Task-T template (skips scenario recognition) | Yes | +| Task-T | `generateTaskPromptFromDataWithSchema` | A2A-T Client | Generates a task prompt from structured data plus a semantic schema with the specified Task-T template | Yes | +| Task-T | `validateTaskPromptAndDataFilling` | A2A-T Server | Validates a Task-T task prompt and extracts parameters per a schema | Yes | +| Notification-T | `generateNotificationPromptFromText` | A2A-T Client | Generates a notification subscription prompt from natural-language text with the specified Notification-T template | Yes | +| Notification-T | `generateNotificationPromptFromDataWithSchema` | A2A-T Client | Generates a notification subscription prompt from structured data plus a semantic schema with the specified Notification-T template | Yes | +| Notification-T | `validateNotificationPromptAndDataFilling` | A2A-T Server | Validates a Notification-T prompt and extracts parameters per a schema | Yes | +| Authorization-T | `generateAuthPromptFromText` | A2A-T Client | Generates an authorization prompt from natural-language text with the specified Authorization-T template | Yes | +| Authorization-T | `generateAuthPromptFromDataWithSchema` | A2A-T Client | Generates an authorization prompt from structured data plus a semantic schema with the specified Authorization-T template | Yes | +| Authorization-T | `validateAuthPromptAndDataFilling` | A2A-T Server | Validates an Authorization-T prompt and extracts parameters per a schema | Yes | +| General | `generateTaskPrompt` | A2A-T Client | Generates a task prompt from natural-language or structured input via scenario recognition | Yes | +| General | `checkTaskPrompt` | A2A-T Server | Validates the scenario, template, and slot compliance of a task prompt | Yes | **Common Data Types and Conventions** -- **TemplateUri:** the template URI value type. Prefer building it with the `net.openan.a2at.sdk.core.model.StandardTemplates` constants, e.g. `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE` (URI `Negotiation-T/information-negotiation/propose/v1`); for strings coming from outside the code, parse them with `TemplateUri.parse(String)`, which returns an `Optional` and never throws. - **Negotiation session context:** `NegotiationContext(id, round, maxRounds)` (id in UUID form, round starting at 1, default round budget `DEFAULT_MAX_ROUNDS = 5`); it travels with the message metadata and never goes through the LLM. `NegotiationContext.of(id, round)` uses the default budget, and `nextRound()` advances the round. -- **Exception hierarchy:** every SDK processing failure is a subclass of `A2ATError` — generation failures throw `PromptGenerationException` (Task-T / Notification-T / Authorization-T) or `NegotiationGenerationException` (Negotiation-T); validation-plus-extraction failures throw `ContentValidationException` (Task-T / Notification-T / Authorization-T, carrying the `errors()` slot error details and the `params()` partial extraction parameters) or `NegotiationParamExtractionException` (Negotiation-T). Catching `A2ATError` covers all processing failures, and `getCode()` returns the machine-readable error code from the error-code catalog below. Expected business failures extend `A2ATBusinessException` and additionally expose `getFacts()` carrying the structured fact values behind the rendered message; all messages are rendered by the SDK from the code's template and follow `A2AT_LANGUAGE`. -- **SlotValidationError:** per-slot validation error details, returned with validation-failure exceptions or failure payloads; the failure structures in the "Output" sections of each API all reference this definition: +- **Exception hierarchy:** every SDK processing failure is a subclass of `A2ATError`; catching `A2ATError` covers all processing failures, and `getCode()` returns the machine-readable error code. All messages are rendered by the SDK from per-code message templates and follow `A2AT_LANGUAGE`. The following business exceptions extend `A2ATBusinessException` and additionally expose `getFacts()` carrying the structured fact values behind the rendered message: + - Generation failures throw `PromptGenerationException` (Task-T / Notification-T / Authorization-T) or `NegotiationGenerationException` (Negotiation-T); + - Validation-plus-extraction failures throw `ContentValidationException` (Task-T / Notification-T / Authorization-T) or `NegotiationParamExtractionException` (Negotiation-T). +- **TemplateUri:** the template URI value type. Prefer building it with the `net.openan.a2at.sdk.core.model.StandardTemplates` constants; for strings coming from outside the code, parse them with `TemplateUri.parse(String)`, which returns an `Optional` and never throws. The currently supported TemplateUri constants are listed below: -| Field | Type | Description | +| Constant | Description | Template URI | | ---- | ---- | ---- | -| slotName | String | Name of the slot the error refers to | -| code | String | Slot-level error code from the error-code catalog below, e.g. `content.param_missing` (a parameter section carries no value), `content.entry_field_missing` (a list entry is missing a required field), `content.format_error` (value format error) | -| message | String | Human-readable error description rendered by the SDK from the code's message template; the language follows `A2AT_LANGUAGE` (default `en-US`) | -| facts | Map<String, String> | Structured fact values that produced the message, keyed by fact parameter name (e.g. `section_label`, `index`, `field_label`); may be null | - -**Error-code catalog** - -The SDK exposes a closed catalog of layered `domain.semantic` error codes; one code always carries one message. Message texts are rendered by the SDK from the per-language templates bundled in `prompt_resources/errors/{language}/errors.json` - they are never free text produced by the LLM. The message language follows the `A2AT_LANGUAGE` setting (default `en-US`); `{x}` tokens in a template are filled from the `facts` of the error. Internally, LLM steps only report `{slot_name, code, facts}` entries; a code returned by an LLM outside the catalog is mapped to the per-domain `*.rule_violation` fallback code and the original code is logged. - -Category legend: BUSINESS = expected business failure carried by an `A2ATBusinessException` subclass the caller can act on; INFRA = infrastructure failure carried by a plain `A2ATError`. - -| Code | Category | Message (en-US) | Message (zh-CN) | -| ---- | -------- | ---------------- | --------------- | -| `template.not_found` | BUSINESS | Template '{template_uri}' does not support language '{language}'; check the template URI and language setting | 模板「{template_uri}」不支持语言「{language}」,请检查模板标识与语言配置 | -| `template.render_failed` | BUSINESS | Failed to render template '{template_uri}': {reason} | 模板「{template_uri}」渲染失败:{reason} | -| `template.load_failed` | INFRA | Failed to read template resource '{resource_path}' | 模板资源「{resource_path}」读取失败 | -| `slot.schema_not_found` | BUSINESS | Template '{template_uri}' is missing its slot schema (language '{language}') | 模板「{template_uri}」缺少参数定义文件(语言「{language}」) | -| `slot.not_provided` | BUSINESS | '{slot_label}' is not provided in the input. | 输入中未提供「{slot_label}」。 | -| `slot.constraint_violated` | BUSINESS | The value of '{slot_label}' ({actual}) is not within the allowed range | 「{slot_label}」的取值「{actual}」不在允许范围内 | -| `slot.semantic_conflict` | BUSINESS | The value of '{slot_label}' conflicts with the slot definition: {reason} | 「{slot_label}」的取值与参数定义冲突:{reason} | -| `slot.fabricated_value` | BUSINESS | The value of '{slot_label}' ({actual}) is placeholder content, not a valid value | 「{slot_label}」的取值「{actual}」是占位内容,不是有效值 | -| `slot.cross_scenario_pollution` | BUSINESS | The value of '{slot_label}' contains content from a different scenario | 「{slot_label}」的取值混入了其他场景的内容 | -| `slot.insufficient_grounding` | BUSINESS | The value of '{slot_label}' lacks sufficient grounding | 「{slot_label}」的取值缺少充分依据 | -| `slot.rule_violation` | BUSINESS | The value of '{slot_label}' violates the validation rules. | 「{slot_label}」的取值不符合校验规则。 | -| `input.text_too_long` | BUSINESS | Input text length {actual_length} exceeds the maximum of {max_chars} (A2AT_INPUT_TEXT_MAX_CHARS) | 输入文本长度 {actual_length} 超过上限 {max_chars}(A2AT_INPUT_TEXT_MAX_CHARS) | -| `content.param_missing` | BUSINESS | '{section_label}' is empty; please provide a value | 「{section_label}」未填写,请补充该参数的取值 | -| `content.entry_field_missing` | BUSINESS | Entry {index} of '{section_label}' is missing required field '{field_label}' | 「{section_label}」第 {index} 条缺少必填字段「{field_label}」 | -| `content.format_error` | BUSINESS | The format of '{section_label}' is invalid: {reason} | 「{section_label}」的取值格式不符合要求:{reason} | -| `content.value_not_allowed` | BUSINESS | The value of '{section_label}' ({actual}) is not allowed | 「{section_label}」的取值「{actual}」不在允许范围内 | -| `content.semantic_conflict` | BUSINESS | '{section_label}' has a semantic conflict: {reason} | 「{section_label}」存在语义冲突:{reason} | -| `content.rule_violation` | BUSINESS | The value of '{section_label}' violates the validation rules. | 「{section_label}」的取值不符合校验规则。 | -| `scenario.not_matched` | BUSINESS | The input does not match any known scenario: {reason} | 输入内容无法匹配任何已知场景:{reason} | -| `llm.not_configured` | BUSINESS | No LLM client is configured; check the A2AT_LLM_* settings | 未配置 LLM 客户端,无法执行该操作(请检查 A2AT_LLM_* 配置) | -| `llm.invocation_failed` | BUSINESS | LLM invocation failed (provider {provider}): {reason} | LLM 调用失败(提供方 {provider}):{reason} | -| `llm.response_invalid` | BUSINESS | The LLM response is invalid (step: {step}); please retry | LLM 返回内容不符合要求({step} 步骤),请重试 | -| `negotiation.invalid_input` | BUSINESS | The negotiation input is invalid: {reason} | 输入的协商内容无效:{reason} | -| `negotiation.invalid_context_id` | BUSINESS | The negotiation context id '{actual}' is not a valid UUID | 协商上下文标识「{actual}」不是合法的 UUID | -| `negotiation.round_exceeded` | BUSINESS | Negotiation round {round} exceeds the maximum of {max_rounds} | 协商轮次 {round} 已超过上限 {max_rounds} | -| `negotiation.type_mismatch` | BUSINESS | The message implies '{implied}' negotiation but the declared template type is '{declared}' | 报文内容属于「{implied}」协商,与声明的模板类型「{declared}」不符 | -| `negotiation.phase_mismatch` | BUSINESS | The message phase does not match the declared template phase ({implied} vs {declared}) | 报文阶段与声明的模板阶段不符({implied} vs {declared}) | -| `negotiation.conclusion_mismatch` | BUSINESS | The message conclusion is '{actual}' but '{expected}' is expected for this method | 报文结论为「{actual}」,与该方法的预期「{expected}」不符 | -| `negotiation.content_invalid` | BUSINESS | The negotiation content field '{field}' is invalid: {reason} | 协商内容字段「{field}」无效:{reason} | -| `negotiation.field_missing` | BUSINESS | The negotiation message is missing required field '{field}' | 协商报文缺少必填字段「{field}」 | -| `negotiation.content_extract_failed` | BUSINESS | Failed to extract negotiation content from text ({field}): {reason} | 无法从文本提取协商内容({field}):{reason} | -| `negotiation.conclusion_content_mismatch` | BUSINESS | The conclusion is '{conclusion}' but '{section_label}' does not state the content the conclusion requires | 结论为「{conclusion}」,但「{section_label}」未表达该结论应携带的内容 | -| `negotiation.missing_result_content` | BUSINESS | The '{section_label}' section is missing the content required by its conclusion | 「{section_label}」板块缺少结论应携带的内容 | -| `negotiation.mutually_exclusive_sections` | BUSINESS | Mutually exclusive sections appear together: {sections} | 互斥板块同时出现:{sections} | -| `negotiation.constraint_conflict` | BUSINESS | '{section_label}' conflicts with existing constraints: {reason} | 「{section_label}」与既有约束冲突:{reason} | -| `negotiation.field_inconsistency` | BUSINESS | Fields within '{section_label}' are inconsistent: {reason} | 「{section_label}」内字段取值前后不一致:{reason} | -| `negotiation.invalid_time_interval` | BUSINESS | The time interval of '{section_label}' is invalid (start must not be later than end) | 「{section_label}」的时间区间不合法(开始时间不得晚于结束时间) | -| `negotiation.semantic_rejected` | BUSINESS | The negotiation message failed semantic validation | 协商报文语义校验未通过 | -| `negotiation.rule_violation` | BUSINESS | '{section_label}' violates the negotiation message validation rules. | 「{section_label}」不符合协商报文的校验规则。 | -| `infra.config_invalid` | INFRA | Invalid configuration '{key}': {reason} | 配置项「{key}」无效:{reason} | -| `infra.resource_read_failed` | INFRA | Failed to read resource '{resource_path}' | 资源「{resource_path}」读取失败 | -| `infra.internal_error` | INFRA | SDK internal error; contact the maintainer with context | SDK 内部错误,请联系维护方并提供上下文 | +| StandardTemplates.ENERGY_SAVING | Task-T energy-saving task template | Task-T/network-layer/ran-energy-saving/v1 | +| StandardTemplates.PRIVATE_LINE_COMPLAINT | Task-T private-line complaint task template | Task-T/network-layer/private-line-complaint/v1 | +| StandardTemplates.SUBSCRIBE_INCIDENT | Notification-T incident subscription notification template | Notification-T/network-layer/subscribe-incident/v1 | +| StandardTemplates.SERVICE_RECOVERY | Notification-T service recovery notification template | Notification-T/network-layer/service-recovery/v1 | +| StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT | Authorization-T authorization policy management template | Authorization-T/authorization-policy-management/v1 | +| StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE | Negotiation-T information negotiation propose template | Negotiation-T/information-negotiation/propose/v1 | +| StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT | Negotiation-T information negotiation accept/reject template | Negotiation-T/information-negotiation/accept-reject/v1 | +| StandardTemplates.TARGET_NEGOTIATION_PROPOSE | Negotiation-T target negotiation propose template | Negotiation-T/target-negotiation/propose/v1 | +| StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT | Negotiation-T target negotiation accept/reject template | Negotiation-T/target-negotiation/accept-reject/v1 | +| StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE | Negotiation-T feasibility negotiation propose template | Negotiation-T/feasibility-negotiation/propose/v1 | +| StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT | Negotiation-T feasibility negotiation accept/reject template | Negotiation-T/feasibility-negotiation/accept-reject/v1 | +| StandardTemplates.NEGOTIATION_ABORT | Negotiation-T common negotiation abort template | Negotiation-T/common/abort/v1 | -**Old-to-new code mapping** - -The layered codes above replace the previous flat error codes. Use this mapping when migrating callers that branch on `getCode()` values: - -| Old code | New code | -| -------- | -------- | -| `template_not_found` | `template.not_found` | -| `render_failed` | `template.render_failed` | -| `prompt_resource_load_error` | `template.load_failed` | -| `slot_schema_not_found` | `slot.schema_not_found` | -| `missing_input` / `param_extraction_failed` (slot extraction) | `slot.not_provided` | -| `invalid_value` (slot extraction) | `slot.constraint_violated` | -| `semantic_mismatch` (semantic validation) | `slot.semantic_conflict` | -| `fabricated_value` | `slot.fabricated_value` | -| `cross_scenario_pollution` | `slot.cross_scenario_pollution` | -| `insufficient_grounding` | `slot.insufficient_grounding` | -| `input_text_too_long` | `input.text_too_long` | -| `missing_required` (parameter level, content validation) | `content.param_missing` | -| `missing_required` (list-entry level, content validation) | `content.entry_field_missing` | -| `format_error` | `content.format_error` | -| `invalid_value` (content validation) | `content.value_not_allowed` | -| `semantic_mismatch` (content validation) | `content.semantic_conflict` | -| `scenario_not_matched` / `processed_prompt_parse_error` | `scenario.not_matched` | -| `llm_invocation_failed` (transport failures) | `llm.invocation_failed` | -| `llm_invocation_failed` (response-contract failures) | `llm.response_invalid` | -| "LLM client is not configured" family | `llm.not_configured` | -| `negotiation_invalid_input` / `validation_invalid_input` | `negotiation.invalid_input` | -| `invalid_uuid` | `negotiation.invalid_context_id` | -| `out_of_range` (round) | `negotiation.round_exceeded` | -| `template_type_mismatch` | `negotiation.type_mismatch` | -| `template_phase_mismatch` | `negotiation.phase_mismatch` | -| `invalid_conclusion` / registry conclusion gate | `negotiation.conclusion_mismatch` | -| fromData required-items/required-text `IllegalArgumentException` | `negotiation.content_invalid` | -| `negotiation_slot_missing` | `negotiation.field_missing` | -| `negotiation_content_extract_failed` | `negotiation.content_extract_failed` | -| `conclusion_content_mismatch` | `negotiation.conclusion_content_mismatch` | -| `missing_result_content` | `negotiation.missing_result_content` | -| `mutually_exclusive_sections` | `negotiation.mutually_exclusive_sections` | -| `constraint_conflict` | `negotiation.constraint_conflict` | -| `field_inconsistency` | `negotiation.field_inconsistency` | -| `invalid_time_interval` | `negotiation.invalid_time_interval` | -| `negotiation_semantic_rejected` / `validation_semantic_rejected` | `negotiation.semantic_rejected` | -| `negotiation_rule_violation` / `validation_rule_violation` | `negotiation.rule_violation` | -| `negotiation_llm_infrastructure_error` / `validation_llm_infrastructure_error` | `llm.invocation_failed` / `llm.response_invalid` | -| `validation_prompt_resource_not_found` | `template.not_found` | -| `sdk_internal_error` | `infra.internal_error` | -| `LLMConfigError` | `infra.config_invalid` | -| bare `A2ATError` resource-read failures | `infra.resource_read_failed` | ## 1.2 Constraints and Limitations -Some APIs involve LLM calls. Control the call rate and concurrency according to the concurrency capacity of the connected model service. +- Some APIs involve LLM calls. Control the call rate and concurrency of these APIs according to the concurrency capacity of the connected model service and your business time-limit requirements. +- APIs that involve LLM calls guard the length of text `text` inputs: when the input exceeds the `A2AT_INPUT_TEXT_MAX_CHARS` characters configured in `client.env` or `server.env`, the error code `input_text_too_long` is reported; the default value of this configuration item is 16384 (16×1024). Structured data that involves no LLM calls is not subject to this limit. ## 1.3 API Description @@ -161,13 +72,13 @@ public MetadataContent generateNegotiationProposePromptFromText( **Typical scenarios**: after receiving a Task-T task message with missing parameters, the server agent uses natural language to send a "supplement the missing information" negotiation request to the client agent; also applicable when the client agent initiates a target-clarification or feasibility-evaluation request to the server. -**Function Description**: generates a structured negotiation propose-phase message from natural-language text. Execution flow: the template is loaded first (a missing template fails fast without consuming an LLM request), then one LLM content-extraction step runs (extracting typed content from the free text under the template-URI constraint, retried up to the `A2AT_LLM_MAX_ATTEMPTS` limit on retryable error codes), and finally the template is rendered deterministically. Applicable to the initiator of information/target/feasibility negotiations. The `templateUri` phase segment must be `propose`. +**Function Description**: generates a structured negotiation propose-phase message from natural-language text. Execution flow: the template is loaded first, then one LLM content-extraction step runs, and finally the template is rendered deterministically. Applicable to the initiator of information/target/feasibility negotiations. **Input** | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | -| text | String | Yes | Natural-language text describing the negotiation proposal (e.g. the list of missing information items to request) | +| text | String | Yes | Natural-language text describing the negotiation proposal (e.g. the list of missing information items to request); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | context | NegotiationContext | Yes | Negotiation session context, injected directly into the `negotiationContext` metadata of the generated message without going through the LLM | | templateUri | TemplateUri | Yes | Propose template, e.g. `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE` | @@ -260,13 +171,13 @@ public MetadataContent generateNegotiationAcceptPromptFromText( **Typical scenarios**: after receiving the peer's information-negotiation request, the negotiation responder (usually the client agent) supplements/delivers the requested information in natural language and generates an accept message to return, e.g. confirming that diagnosis can start after supplementing the access port name and complaint category. -**Function Description**: generates a negotiation accept message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `ACCEPT`, otherwise it is rejected with `negotiation.invalid_input`) plus deterministic rendering. Applicable to the negotiation responder supplementing/delivering information. The `templateUri` phase segment must be `accept-reject`. +**Function Description**: generates a negotiation accept message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `ACCEPT`, otherwise it is rejected with `negotiation.invalid_input`) plus deterministic rendering. Applicable to the negotiation responder supplementing/delivering information. **Input** | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | -| text | String | Yes | Natural-language text describing the acceptance (e.g. the list of supplemented/delivered information items) | +| text | String | Yes | Natural-language text describing the acceptance (e.g. the list of supplemented/delivered information items); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | context | NegotiationContext | Yes | Negotiation session context | | templateUri | TemplateUri | Yes | Accept-reject template, e.g. `StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT` | @@ -324,7 +235,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( **Typical scenarios**: when the negotiation responder (usually the client agent) cannot satisfy the peer's negotiation request, it generates a reject message in natural language to return and end the current negotiation round, e.g. the access port name cannot be provided because the site inventory is unavailable. -**Function Description**: generates a negotiation reject message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `REJECT`, otherwise it is rejected with `negotiation.invalid_input`) plus deterministic rendering. The `templateUri` phase segment must be `accept-reject`. +**Function Description**: generates a negotiation reject message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `REJECT`, otherwise it is rejected with `negotiation.invalid_input`) plus deterministic rendering. **Input**: same as [generateNegotiationAcceptPromptFromText](#132-generatenegotiationacceptpromptfromtext), where text is natural language describing the rejection reason. @@ -379,7 +290,7 @@ public MetadataContent generateNegotiationProposePromptFromData( **Typical scenarios**: the same initiation scenarios as the fromText variant, but the input is structured data constructed by the business system (e.g. the server agent automatically generates the negotiation-request items from the missing-slot list detected by `validateTaskPromptAndDataFilling`), suitable for scenarios that require deterministic message content and want to avoid the nondeterminism of LLM extraction. -**Function Description**: deterministically generates a negotiation propose message from typed data, **without calling the LLM**: the typed content is validated first, then dispatched to the generator of the negotiation type addressed by the template URI for rendering. The `content` must match the negotiation type of `templateUri` (information / target / feasibility), and the phase segment must be `propose`. +**Function Description**: deterministically generates a negotiation propose message from structured-data input, **without calling the LLM**. **Input** @@ -462,7 +373,7 @@ public MetadataContent generateNegotiationAcceptPromptFromData( **Typical scenarios**: the negotiation responder (usually the client agent) programmatically fills in the parameters per the peer's requested slot list and generates an accept message from structured items to return. -**Function Description**: deterministically generates a negotiation accept message from typed data, **without calling the LLM**. `content.conclusion()` must be `ACCEPT`; any other conclusion (including `ABORT`) is rejected with `IllegalArgumentException`. +**Function Description**: deterministically generates a negotiation accept message from structured-data input, **without calling the LLM**. `content.conclusion()` must be `ACCEPT`; any other conclusion (including `ABORT`) is rejected with `IllegalArgumentException`. **Input** @@ -530,7 +441,7 @@ public MetadataContent generateNegotiationRejectPromptFromData( **Typical scenarios**: the negotiation responder, after programmatically determining that the peer's request cannot be satisfied, generates a reject message from structured items (the items that cannot be provided and the reasons) to return. -**Function Description**: deterministically generates a negotiation reject message from typed data, **without calling the LLM**. `content.conclusion()` must be `REJECT`; any other conclusion is rejected with `IllegalArgumentException`. +**Function Description**: deterministically generates a negotiation reject message from structured-data input, **without calling the LLM**. `content.conclusion()` must be `REJECT`; any other conclusion is rejected with `IllegalArgumentException`. **Input**: same as [generateNegotiationAcceptPromptFromData](#135-generatenegotiationacceptpromptfromdata), but with the conclusion `REJECT`. Reject content: `InformationEndingContent(REJECT, items)` (items that cannot be provided and the reasons), `TargetEndingContent(REJECT, null, failureReason)` (rejection reason), `FeasibilityEndingContent(REJECT, feasibilitySummary)` (infeasibility conclusion summary). @@ -584,13 +495,13 @@ public FilledParamData validateProposePromptAndDataFilling( **Typical scenarios**: an outbound self-check by the initiator (server agent) before sending a negotiation request; or the receiver (client agent) validating an inbound negotiation request and extracting the list of slots to supplement, driving the subsequent parameter filling. -**Function Description**: validates whether a negotiation propose message is a properly formatted negotiation message, and extracts parameters from it per the caller-provided JSON Schema. Pipeline: template-URI phase-segment check → deterministic rule gate (a null context is reported as `negotiation.invalid_input`, a non-UUID context id as `negotiation.invalid_context_id`, and a round beyond the budget as `negotiation.round_exceeded`) → one LLM semantic-validation call (which also performs the parameter extraction, retried up to the configured limit on retryable error codes) → parameter merge (the negotiation-context parameters `id` / `round` / `maxRounds` are merged with the extracted parameters; on key conflicts, **the context parameters win**). +**Function Description**: validates whether a negotiation propose message is a properly formatted negotiation message, and extracts parameters from it per the caller-provided JSON Schema. **Input** | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | -| prompt | String | Yes | Negotiation propose message text to validate (`MetadataContent.promptText()`) | +| prompt | String | Yes | Negotiation propose message text to validate (`MetadataContent.promptText()`); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | context | NegotiationContext | No | Negotiation context traveling with the message; null is reported as `negotiation.invalid_input` | | schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema declaring the parameters to extract | | templateUri | TemplateUri | Yes | Propose template | @@ -679,7 +590,7 @@ public FilledParamData validateAcceptPromptAndDataFilling( **Typical scenarios**: the initiator (server agent) validates the accept message returned by the peer, extracts the delivered parameter values, and cross-checks them against the expected fill values before continuing task execution. -**Function Description**: validates a negotiation accept message and extracts parameters per the schema. The pipeline is the same as [validateProposePromptAndDataFilling](#137-validateproposepromptanddatafilling), except that the `templateUri` phase segment must be `accept-reject`, and the message must satisfy the accept-phase semantic constraints (the conclusion is Accept and delivered content is carried). +**Function Description**: validates a negotiation accept message and extracts parameters per the schema. **Input**: same as 1.3.7, where prompt is the accept message text and templateUri is the accept-reject template. @@ -738,7 +649,7 @@ public FilledParamData validateRejectPromptAndDataFilling( **Typical scenarios**: the initiator (server agent) validates the reject message returned by the peer, extracts the rejection reason, and terminates the task or escalates to manual handling accordingly. -**Function Description**: validates a negotiation reject message and extracts parameters per the schema. The pipeline is the same as 1.3.7, except that the message must satisfy the reject-phase semantic constraints (the conclusion is Reject and a rejection reason is carried). +**Function Description**: validates a negotiation reject message and extracts parameters per the schema. **Input**: same as 1.3.7, where prompt is the reject message text and templateUri is the accept-reject template. @@ -789,13 +700,13 @@ public MetadataContent generateTaskPromptFromText(String text, TemplateUri templ **Typical scenarios**: the client agent converts the user's natural-language task description (e.g. a private-line complaint diagnosis request) into a Task-T protocol message for a specified scenario; suitable when the target template is already known and scenario recognition should be skipped. -**Function Description**: generates a task prompt message from natural-language text with the specified Task-T template, **skipping scenario recognition** (the template is explicitly specified by the caller). One LLM slot-extraction step runs, then the template is rendered deterministically. The built-in slot schema is validated during generation (a missing or invalid required slot fails fast with a `slot.*` error code such as `slot.not_provided`). +**Function Description**: generates a task prompt message from natural-language text with the specified Task-T template, **skipping scenario recognition** (the template is explicitly specified by the caller). One LLM slot-extraction step runs, then the template is rendered deterministically. **Input** | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | -| text | String | Yes | Natural-language task description | +| text | String | Yes | Natural-language task description; the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | templateUri | TemplateUri | Yes | Task-T template, e.g. `StandardTemplates.PRIVATE_LINE_COMPLAINT` (`Task-T/network-layer/private-line-complaint/v1`) | **Request Example** @@ -901,7 +812,7 @@ public MetadataContent generateTaskPromptFromDataWithSchema( **Typical scenarios**: the client agent converts structured task parameters from an upstream system (field names may differ from the template slots; the schema describes the field semantics) into a Task-T protocol message; suitable when the task parameters are already available in structured form. -**Function Description**: generates a task prompt from structured data plus a semantic schema with the specified Task-T template, **skipping scenario recognition**. The `schema` describes the business meaning of each input field (description / examples / enum, etc.), guiding slot filling and value constraints; each key of data corresponds to one slot value. The built-in slot schema is validated during generation as well (a missing or invalid required slot fails fast with a `slot.*` error code such as `slot.not_provided`). +**Function Description**: generates a task prompt from structured data plus a semantic schema with the specified Task-T template, **skipping scenario recognition**. The `schema` describes the business meaning of each input field (description / examples / enum, etc.), guiding slot filling and value constraints; each key of data corresponds to one slot value. **Input** @@ -991,13 +902,13 @@ public FilledParamData validateTaskPromptAndDataFilling( **Typical scenarios**: the parameter validation and extraction entry point for the server agent after receiving a Task-T message and before business execution; also the decision point for "missing-slot detection" in the negotiation flow — when a required parameter is missing, the caller decides whether to initiate a negotiation to supplement it. -**Function Description**: validates whether a Task-T task prompt message matches the template and slot constraints, and extracts parameters per the caller-provided JSON Schema. Pipeline: deterministic rule gate (built-in slot validation of the template) → one LLM semantic-validation call (which also performs the parameter extraction, retried up to the configured limit on retryable error codes). +**Function Description**: validates whether a Task-T task prompt message matches the template and slot constraints, and extracts parameters per the caller-provided JSON Schema. **Input** | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | -| prompt | String | Yes (non-blank) | Task prompt message text to validate (`MetadataContent.promptText()`) | +| prompt | String | Yes (non-blank) | Task prompt message text to validate (`MetadataContent.promptText()`); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema declaring the parameters to extract/validate and the required constraints | | templateUri | TemplateUri | Yes | Task-T template (the prefix segment must be `Task-T`) | @@ -1090,13 +1001,13 @@ public MetadataContent generateNotificationPromptFromText(String text, TemplateU **Typical scenarios**: the client agent converts a natural-language subscription requirement (e.g. a service recovery event subscription) into a Notification-T subscription message for a specified scenario. -**Function Description**: generates a notification subscription prompt message from natural-language text with the specified Notification-T template, skipping scenario recognition. One LLM slot-extraction step runs, then the template is rendered deterministically, and the built-in slot schema is validated during generation. +**Function Description**: generates a notification subscription prompt message from natural-language text with the specified Notification-T template. One LLM slot-extraction step runs, then the template is rendered deterministically, and the built-in slot schema is validated during generation. **Input** | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | -| text | String | Yes | Natural-language subscription description (notification topic, subscribe condition, notification data format, etc.) | +| text | String | Yes | Natural-language subscription description (notification topic, subscribe condition, notification data format, etc.); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | templateUri | TemplateUri | Yes | Notification-T template, e.g. `StandardTemplates.SUBSCRIBE_INCIDENT`, `StandardTemplates.SERVICE_RECOVERY` | **Request Example** @@ -1168,7 +1079,7 @@ public MetadataContent generateNotificationPromptFromDataWithSchema( **Typical scenarios**: the client agent converts structured subscription parameters submitted by an upstream system or UI into a Notification-T subscription message; suitable when the subscription parameters are already available in structured form. -**Function Description**: generates a notification subscription prompt from structured data plus a semantic schema with the specified Notification-T template, skipping scenario recognition. The semantic constraints are the same as 1.3.11. +**Function Description**: generates a notification subscription prompt from structured data plus a semantic schema with the specified Notification-T template. **Input** @@ -1262,13 +1173,13 @@ public FilledParamData validateNotificationPromptAndDataFilling( **Typical scenarios**: the server agent validates a received Notification-T subscription message, extracts the subscription parameters (topic/condition/report format), and establishes the subscription accordingly. -**Function Description**: validates whether a Notification-T notification subscription prompt message matches the template and slot constraints, and extracts parameters per the caller's schema (subscription topic, subscribe condition, notification data format, etc.). The pipeline and exception semantics are the same as [validateTaskPromptAndDataFilling](#1312-validatetaskpromptanddatafilling). +**Function Description**: validates whether a Notification-T notification subscription prompt message matches the template and slot constraints, and extracts parameters per the caller's schema (subscription topic, subscribe condition, notification data format, etc.). **Input** | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | -| prompt | String | Yes (non-blank) | Notification subscription prompt message text to validate | +| prompt | String | Yes (non-blank) | Notification subscription prompt message text to validate; the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema | | templateUri | TemplateUri | Yes | Notification-T template (the prefix segment must be `Notification-T`) | @@ -1331,13 +1242,13 @@ public MetadataContent generateAuthPromptFromText(String text, TemplateUri templ **Typical scenarios**: the client agent converts a natural-language authorization request (add/modify/delete/query network operation authorization policies) into an Authorization-T message. -**Function Description**: generates an authorization policy operation prompt message from natural-language text with the specified Authorization-T template, skipping scenario recognition. One LLM slot-extraction step runs, then the template is rendered deterministically. The Authorization-T slot schema is bundled with `a2a-t-resources` and works out of the box under the classpath resource source. +**Function Description**: generates an authorization policy operation prompt message from natural-language text with the specified Authorization-T template. One LLM slot-extraction step runs, then the template is rendered deterministically. **Input** | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | -| text | String | Yes | Natural-language authorization description (operation type + network operation authorization policy content) | +| text | String | Yes | Natural-language authorization description (operation type + network operation authorization policy content); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | templateUri | TemplateUri | Yes | Authorization-T template: `StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT` (`Authorization-T/authorization-policy-management/v1`) | **Request Example** @@ -1478,13 +1389,13 @@ public FilledParamData validateAuthPromptAndDataFilling( **Typical scenarios**: the server agent validates a received Authorization-T message, extracts the operation type and policy list, and performs the authorization policy management action accordingly. -**Function Description**: validates whether an Authorization-T authorization prompt message matches the template and slot constraints, and extracts parameters per the caller's schema (operation type, policy list, etc.). The pipeline and exception semantics are the same as 1.3.12. Field requirements are validated differently per operation type: an add entry must carry business scenario / handling type / operation name / validity period; a modify entry must carry the policy identifier and the new validity period; a delete entry may be a policy identifier or condition fields. +**Function Description**: validates whether an Authorization-T authorization prompt message matches the template and slot constraints, and extracts parameters per the caller's schema (operation type, policy list, etc.). Field requirements are validated differently per operation type: an add entry must carry business scenario / handling type / operation name / validity period; a modify entry must carry the policy identifier and the new validity period; a delete entry may be a policy identifier or condition fields. **Input** | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | -| prompt | String | Yes (non-blank) | Authorization prompt message text to validate | +| prompt | String | Yes (non-blank) | Authorization prompt message text to validate; the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema (containing `operationType`, `policyList`, etc.) | | templateUri | TemplateUri | Yes | Authorization-T template (the prefix segment must be `Authorization-T`) | @@ -1543,13 +1454,13 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) **Typical scenarios**: the scenario-auto-routing entry point of the client agent: without specifying a template, the SDK recognizes the business scenario of the user input and generates the corresponding message; suitable when the scenario set is known and a simplified integration is desired. -**Function Description**: the general task prompt generation entry point, which locates the template **via scenario recognition**: the natural-language or structured input first goes through LLM scenario recognition, then slot extraction and rendering are completed with the built-in template of the recognized scenario. To specify a template explicitly, use the FromText / FromDataWithSchema API families in 1.3.1x. +**Function Description**: the general task prompt generation entry point, which locates the template via scenario recognition: the natural-language or structured input first goes through LLM scenario recognition, then slot extraction and rendering are completed with the built-in template of the recognized scenario. **Input** | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | -| userInput | Object | Yes | Task description: a `String` (natural language) or a `Map` (structured input); both go through LLM extraction — there is no zero-LLM shortcut | +| userInput | Object | Yes | Task description: a `String` (natural language) or a `Map` (structured input); both go through LLM extraction. When the input is a `String`, its length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | **Request Example** @@ -1634,15 +1545,15 @@ result.failure() = public PromptComplianceResult checkTaskPrompt(String processedPromptText) ``` -**Typical scenarios**: the server agent performs a protocol-completeness gate check (scenario/template/slot compliance) on a received task message, in scenarios where only a pass/fail conclusion is needed and no parameters must be extracted. +**Typical scenarios**: the server agent performs a protocol-completeness validation (scenario/template/slot compliance) on a received task message, in scenarios where only a pass/fail conclusion is needed and no parameters must be extracted. -**Function Description**: the general task prompt compliance-check entry point (server side): it performs scenario matching, template compliance validation, and slot validation on the processed task prompt sent by the client (whether it is enabled is controlled by `A2AT_PROMPT_COMPLIANCE_ENABLED`). Difference from `validateTaskPromptAndDataFilling`: this API extracts no parameters and takes no caller-provided schema; it returns only the standardized pass/fail conclusion, suitable for scenarios that only need a protocol-completeness gate. +**Function Description**: the general task prompt compliance-check entry point (server side): it performs scenario matching, template compliance validation, and slot validation on the processed task prompt sent by the client. Difference from `validateTaskPromptAndDataFilling`: this API extracts no parameters and takes no caller-provided schema; it returns only the standardized pass/fail conclusion. **Input** | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | -| processedPromptText | String | Yes | A2A-T protocol message text sent by the client (the value keyed by the extension URI in `Message.metadata`) | +| processedPromptText | String | Yes | A2A-T protocol message text sent by the client (the value keyed by the extension URI in `Message.metadata`); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | **Request Example** @@ -1690,10 +1601,10 @@ On failure (`success()` is `false`; no exception is thrown, the failure payload On success: ```text -prompt check passed +result.success() = true ``` -On failure (the message is missing a required slot; the error code and stage come from the server-side compliance pipeline): +On failure: ```text result.success() = false @@ -1701,6 +1612,55 @@ result.failure() = { code=slot.not_provided, message='Task Object' is not provided in the input., stage=slot_validation } ``` +## 1.4 Error Code Catalog + +**Error code categories**: BUSINESS = expected business failures the caller can act on, carried by `A2ATBusinessException` subclasses; INFRA = infrastructure failures, carried by a plain `A2ATError`. + +| Code | Category | Message (en-US) | Message (zh-CN) | +| ---- | -------- | ---------------- | --------------- | +| `template.not_found` | BUSINESS | Template '{template_uri}' does not support language '{language}'; check the template URI and language setting | 模板「{template_uri}」不支持语言「{language}」,请检查模板标识与语言配置 | +| `template.render_failed` | BUSINESS | Failed to render template '{template_uri}': {reason} | 模板「{template_uri}」渲染失败:{reason} | +| `template.load_failed` | INFRA | Failed to read template resource '{resource_path}' | 模板资源「{resource_path}」读取失败 | +| `slot.schema_not_found` | BUSINESS | Template '{template_uri}' is missing its slot schema (language '{language}') | 模板「{template_uri}」缺少参数定义文件(语言「{language}」) | +| `slot.not_provided` | BUSINESS | '{slot_label}' is not provided in the input. | 输入中未提供「{slot_label}」。 | +| `slot.constraint_violated` | BUSINESS | The value of '{slot_label}' ({actual}) is not within the allowed range | 「{slot_label}」的取值「{actual}」不在允许范围内 | +| `slot.semantic_conflict` | BUSINESS | The value of '{slot_label}' conflicts with the slot definition: {reason} | 「{slot_label}」的取值与参数定义冲突:{reason} | +| `slot.fabricated_value` | BUSINESS | The value of '{slot_label}' ({actual}) is placeholder content, not a valid value | 「{slot_label}」的取值「{actual}」是占位内容,不是有效值 | +| `slot.cross_scenario_pollution` | BUSINESS | The value of '{slot_label}' contains content from a different scenario | 「{slot_label}」的取值混入了其他场景的内容 | +| `slot.insufficient_grounding` | BUSINESS | The value of '{slot_label}' lacks sufficient grounding | 「{slot_label}」的取值缺少充分依据 | +| `slot.rule_violation` | BUSINESS | The value of '{slot_label}' violates the validation rules. | 「{slot_label}」的取值不符合校验规则。 | +| `input.text_too_long` | BUSINESS | Input text length {actual_length} exceeds the maximum of {max_chars} (A2AT_INPUT_TEXT_MAX_CHARS) | 输入文本长度 {actual_length} 超过上限 {max_chars}(A2AT_INPUT_TEXT_MAX_CHARS) | +| `content.param_missing` | BUSINESS | '{section_label}' is empty; please provide a value | 「{section_label}」未填写,请补充该参数的取值 | +| `content.entry_field_missing` | BUSINESS | Entry {index} of '{section_label}' is missing required field '{field_label}' | 「{section_label}」第 {index} 条缺少必填字段「{field_label}」 | +| `content.format_error` | BUSINESS | The format of '{section_label}' is invalid: {reason} | 「{section_label}」的取值格式不符合要求:{reason} | +| `content.value_not_allowed` | BUSINESS | The value of '{section_label}' ({actual}) is not allowed | 「{section_label}」的取值「{actual}」不在允许范围内 | +| `content.semantic_conflict` | BUSINESS | '{section_label}' has a semantic conflict: {reason} | 「{section_label}」存在语义冲突:{reason} | +| `content.rule_violation` | BUSINESS | The value of '{section_label}' violates the validation rules. | 「{section_label}」的取值不符合校验规则。 | +| `scenario.not_matched` | BUSINESS | The input does not match any known scenario: {reason} | 输入内容无法匹配任何已知场景:{reason} | +| `llm.not_configured` | BUSINESS | No LLM client is configured; check the A2AT_LLM_* settings | 未配置 LLM 客户端,无法执行该操作(请检查 A2AT_LLM_* 配置) | +| `llm.invocation_failed` | BUSINESS | LLM invocation failed (provider {provider}): {reason} | LLM 调用失败(提供方 {provider}):{reason} | +| `llm.response_invalid` | BUSINESS | The LLM response is invalid (step: {step}); please retry | LLM 返回内容不符合要求({step} 步骤),请重试 | +| `negotiation.invalid_input` | BUSINESS | The negotiation input is invalid: {reason} | 输入的协商内容无效:{reason} | +| `negotiation.invalid_context_id` | BUSINESS | The negotiation context id '{actual}' is not a valid UUID | 协商上下文标识「{actual}」不是合法的 UUID | +| `negotiation.round_exceeded` | BUSINESS | Negotiation round {round} exceeds the maximum of {max_rounds} | 协商轮次 {round} 已超过上限 {max_rounds} | +| `negotiation.type_mismatch` | BUSINESS | The message implies '{implied}' negotiation but the declared template type is '{declared}' | 报文内容属于「{implied}」协商,与声明的模板类型「{declared}」不符 | +| `negotiation.phase_mismatch` | BUSINESS | The message phase does not match the declared template phase ({implied} vs {declared}) | 报文阶段与声明的模板阶段不符({implied} vs {declared}) | +| `negotiation.conclusion_mismatch` | BUSINESS | The message conclusion is '{actual}' but '{expected}' is expected for this method | 报文结论为「{actual}」,与该方法的预期「{expected}」不符 | +| `negotiation.content_invalid` | BUSINESS | The negotiation content field '{field}' is invalid: {reason} | 协商内容字段「{field}」无效:{reason} | +| `negotiation.field_missing` | BUSINESS | The negotiation message is missing required field '{field}' | 协商报文缺少必填字段「{field}」 | +| `negotiation.content_extract_failed` | BUSINESS | Failed to extract negotiation content from text ({field}): {reason} | 无法从文本提取协商内容({field}):{reason} | +| `negotiation.conclusion_content_mismatch` | BUSINESS | The conclusion is '{conclusion}' but '{section_label}' does not state the content the conclusion requires | 结论为「{conclusion}」,但「{section_label}」未表达该结论应携带的内容 | +| `negotiation.missing_result_content` | BUSINESS | The '{section_label}' section is missing the content required by its conclusion | 「{section_label}」板块缺少结论应携带的内容 | +| `negotiation.mutually_exclusive_sections` | BUSINESS | Mutually exclusive sections appear together: {sections} | 互斥板块同时出现:{sections} | +| `negotiation.constraint_conflict` | BUSINESS | '{section_label}' conflicts with existing constraints: {reason} | 「{section_label}」与既有约束冲突:{reason} | +| `negotiation.field_inconsistency` | BUSINESS | Fields within '{section_label}' are inconsistent: {reason} | 「{section_label}」内字段取值前后不一致:{reason} | +| `negotiation.invalid_time_interval` | BUSINESS | The time interval of '{section_label}' is invalid (start must not be later than end) | 「{section_label}」的时间区间不合法(开始时间不得晚于结束时间) | +| `negotiation.semantic_rejected` | BUSINESS | The negotiation message failed semantic validation | 协商报文语义校验未通过 | +| `negotiation.rule_violation` | BUSINESS | '{section_label}' violates the negotiation message validation rules. | 「{section_label}」不符合协商报文的校验规则。 | +| `infra.config_invalid` | INFRA | Invalid configuration '{key}': {reason} | 配置项「{key}」无效:{reason} | +| `infra.resource_read_failed` | INFRA | Failed to read resource '{resource_path}' | 资源「{resource_path}」读取失败 | +| `infra.internal_error` | INFRA | SDK internal error; contact the maintainer with context | SDK 内部错误,请联系维护方并提供上下文 | + diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 298212e9..0d78d4e4 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -318,25 +318,37 @@ Client agent dependencies: Copy the content of the `env.example` file in the repository root into `client.env` (`server.env` on the server side) and configure it as follows: ```properties +# Controls the language used by prompt runtime components. A2AT_LANGUAGE=en-US + +# Selects the prompt source backend (classpath | local_file). A2AT_PROMPT_SOURCE_TYPE=classpath + +# Required when A2AT_PROMPT_SOURCE_TYPE=local_file A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_PROMPT_COMPLIANCE_ENABLED=true + +# Selects the LLM provider used by the shared client. A2AT_LLM_PROVIDER=openai + +# Selects the default model name for the configured provider. A2AT_LLM_MODEL=deepseek-chat + +# API key for the configured LLM provider. A2AT_LLM_API_KEY={your_llm_api_key} + +# Base URL for the configured LLM provider. A2AT_LLM_BASE_URL=https://api.deepseek.com + +# Selects the negotiation state store implementation. A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + +# Input limits A2AT_INPUT_TEXT_MAX_CHARS=16384 ``` > `A2AT_LLM_API_KEY` is the key used to **call the external large model**. Keep it safe. > > The SDK connects to external large models through OpenAI-compatible interfaces. `A2AT_LLM_PROVIDER` currently supports only `openai`; when connecting to OpenAI-compatible services such as DeepSeek, specify the service address with `A2AT_LLM_BASE_URL` and the model name with `A2AT_LLM_MODEL`. -> -> The prompt resource source is controlled by `A2AT_PROMPT_SOURCE_TYPE`: the default `classpath` loads the resources bundled in the `a2a-t-resources` jar; when set to `local_file`, specify the local resource root directory with `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` (relative paths are resolved against the directory of the `.env` file). -> -> Free-text inputs are length-guarded before any LLM call: every facade entry point that accepts a natural-language `String` (the `FromText` generation methods, `generateTaskPrompt` with a text input, and the prompt-validation entry points such as `checkTaskPrompt` and `validate*PromptAndDataFilling`) rejects an input longer than `A2AT_INPUT_TEXT_MAX_CHARS` characters (`String.length()`) with the error code `input.text_too_long`, so oversized inputs fail fast instead of overflowing the LLM context. The key defaults to `16384` (16×1024); invalid or non-positive values fall back to the default with a warning log. The guard never truncates: the caller keeps full control over how to shorten the input. Structured (`Map`) inputs are not checked. #### Step3 Initialize the AgentCard @@ -1426,5 +1438,168 @@ After startup, the server will: > > This sample is hosted by the official a2a-java REST reference server (Quarkus). The `a2a-t-sample` module of this repository also provides a Quarkus-free embedded end-to-end sample (a real HTTP+JSON/REST streaming pipeline, plus negotiation and authorization scenarios); see [a2a-t-sample/README.zh-CN.md](../../a2a-t-sample/README.zh-CN.md). +## 1.5 Loading Custom Templates + +### 1.5.1 Background + +When generating and validating A2A-T prompts, the SDK relies on three kinds of prompt resources: the scenario catalog (scenarios), slot definitions (slots), and template bodies (templates). The built-in resources are packaged in the `a2a-t-resources` jar and loaded from the classpath by default. When a business needs its own scenario templates (for example, adding business scenarios, or adjusting template wording or slot constraints), it can switch the resource source to `local_file` and load custom templates from a local directory — no SDK repackaging required. + +**The capability boundary of custom templates is as follows**: + +| Resource | `local_file` mode | `classpath` mode | +| --- | --- | --- | +| Business templates/slots/scenarios (templates/slots/scenarios of Task-T, Notification-T, Authorization-T) | Read from the local root directory | Built-in resources | +| Negotiation templates (fixed Negotiation-T set) and the negotiation vocabulary (negotiation-vocabulary) | Loaded from the classpath; not customizable | Built-in resources | +| LLM instruction prompts (prompts directory) | Loaded from the classpath; not customizable | Built-in resources | + +**Key constraints**: + +1. **Construction-time validation**: `A2ATClient` and `A2ATServer` validate the prompt-resource configuration at construction time. In `local_file` mode, construction fails immediately with a clear error message when `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` is unset, the path does not exist, or the path is not a directory. +2. **Initialization loading**: at construction time, the SDK reads all content under `templates/`, `slots/`, and `scenarios/` of the local root directory once into a read-only snapshot and no longer accesses the file system at runtime; after modifying local files, the SDK process must be restarted for the changes to take effect. + +### 1.5.2 Implementation Steps + +#### Step1 Prepare the local resource root directory + +Following the directory structure of `a2a-t-resources/src/main/resources/prompt_resources`, place resource files under the local root directory as needed: + +```text +/ +├── templates/ +│ └── //v1//template.md +│ e.g. templates/Task-T/network-layer/ran-energy-saving/v1/en-US/template.md +├── slots/ +│ └── //v1//slot.json +└── scenarios/ + └── /scenarios.json +``` + +Notes: + +1. `` supports `Task-T`, `Notification-T`, and `Authorization-T`; the template version segment is fixed at `v1`; the built-in languages are `zh-CN` and `en-US`. +2. `` supports two forms: + - With a domain prefix (recommended, consistent with the built-in resources): `network-layer/`, e.g. `network-layer/ran-energy-saving`; + - Without a domain prefix: ``, e.g. `authorization-policy-management`. + + When business code looks up a template by the bare scenario code (e.g. `ran-energy-saving`), the SDK first searches under `/network-layer//v1//` and then falls back to `//v1//`; you can also look up directly with the full scenario path containing `/` (e.g. `network-layer/ran-energy-saving`). +3. If the local root directory contains `prompts/`, `templates/Negotiation-T/`, or `negotiation-vocabulary/` directories, they are ignored at construction time with a warning log (these resources are always loaded from the classpath). + +#### Step2 Write the resource files + +**Template body template.md**: a Markdown file that references slots with `{{slot name}}` placeholders, for example: + +```markdown +## Operation Type + +{{Operation Type}} (required) +``` + +**Slot definition slot.json**: in JSON Schema (draft 2020-12) format; declare the type, `description`, and `examples` of each slot via `properties`, and optionally use the extension field `x-a2at-value-constraint` to add value constraints (used by LLM extraction and semantic validation), for example: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "Operation Type": { + "type": "string", + "description": "Provide the operation type. Allowed values: create, modify", + "examples": ["create"], + "x-a2at-value-constraint": "Allowed values: create, modify" + } + } +} +``` + +**Scenario catalog scenarios.json**: the top level is a `scenarios` array; each entry contains `scenario_code`, `scenario_name`, `description`, and `example` fields, for example: + +```json +{ + "scenarios": [ + { + "scenario_code": "ran-energy-saving", + "scenario_name": "Energy-efficiency optimization task dispatch", + "description": "Used to generate A2A-T task requests for energy-efficiency optimization tasks in telecom network management systems.", + "example": "Reduce the energy consumption of the Songshan Lake campus by 30% while ensuring a rate guarantee of no less than 10Mbps." + } + ] +} +``` + +#### Step3 Configure the resource source + +Edit `client.env` (`server.env` on the server side) to switch the resource source to local files and specify the root directory: + +```properties +A2AT_LANGUAGE=en-US +A2AT_PROMPT_SOURCE_TYPE=local_file +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=/opt/a2at/prompt_resources +``` + +Notes: + +1. `A2AT_PROMPT_SOURCE_TYPE` takes `classpath` (default) or `local_file`; any other value reports `Unsupported prompt source type` at construction time. +2. In `local_file` mode, `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` is required: when unset, an error is reported prompting you to set `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR`; assembly also fails when the path does not exist or is not a directory. +3. Relative paths of the local root directory are resolved against the directory of the `.env` file; absolute paths are recommended. +4. In `classpath` mode, a configured local root directory is ignored with a warning log. + +**templateUri mapping** + +The `generateTaskPromptFromText` API skips scenario recognition; the template is explicitly specified by the caller via `templateUri`. `templateUri` maps one-to-one to the local directory, with the following mapping rule: + +```text +Local file: /templates/////template.md +templateUri: // +``` + +For example, when the local file is `/templates/Task-T/network-layer/site-inspection/v1/en-US/template.md`, the corresponding `templateUri` is `Task-T/network-layer/site-inspection/v1`. + +```java +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; + +// Construction: parse the raw URI string +TemplateUri templateUri = TemplateUri.parse("Task-T/network-layer/site-inspection/v1").orElseThrow(); + +MetadataContent metadata = client.generateTaskPromptFromText( + "Please perform an on-site inspection of station xx, focusing on alarms and performance metrics", templateUri); +``` + +To override a built-in template (e.g. `StandardTemplates.PRIVATE_LINE_COMPLAINT`), place `template.md` and `slot.json` at the same relative path under the local root directory and keep using the original constant in the code. Note that in `local_file` mode business templates are read only from the local root directory without classpath fallback; when the file for a `templateUri` is missing, the API throws `PromptGenerationException` with the error code `template_not_found`. + +#### Step4 Verification and troubleshooting + +After starting the client or server, confirm and troubleshoot as follows: + +1. **Missing resources**: when a template is not found, a `ResourceNotFoundException` is thrown (error code `validation_prompt_resource_not_found` on the validation path); the exception message contains the expected file path — complete the directory structure as prompted. +2. **Content not updated**: changes to local files do not take effect without restarting the process; restart the SDK process and construct again. +3. **Negotiation and LLM resources are not customizable**: `Negotiation-T` templates support only the built-in fixed set; negotiation template URIs outside the set are skipped with a warning. + +## 1.6 Configuration Item List + +The sample configuration file provided by the SDK is `env.example`. When constructing the A2A-T Client and the A2A-T Server, copy the file to the corresponding `client.env` / `server.env`. The configuration items are described below: + +| Configuration Item | Description | +| --- | --- | +| `A2AT_LANGUAGE` | Prompt resource language; built-in `zh-CN` and `en-US`, default `en-US` | +| `A2AT_PROMPT_SOURCE_TYPE` | Prompt resource source; supports `classpath` and `local_file`, default `classpath` | +| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | Local prompt resource root directory; required in `local_file` mode — unset or non-existent paths fail fast at assembly time; only business content (templates/slots/scenarios of Task-T/Notification-T/Authorization-T) is read from this root, while negotiation resources and LLM prompts are always loaded from the classpath | +| `A2AT_INPUT_TEXT_MAX_CHARS` | Maximum number of characters for free-text inputs (FromText generation and message validation entry points); exceeding the limit fails fast with error code `input_text_too_long`, default `16384`; structured data that involves no LLM calls is not subject to this limit | +| `A2AT_LLM_PROVIDER` | LLM protocol type; currently only `openai` is supported | +| `A2AT_LLM_MODEL` | Model name | +| `A2AT_LLM_API_KEY` | LLM API key | +| `A2AT_LLM_BASE_URL` | LLM service address (OpenAI-compatible) | +| `A2AT_LLM_MAX_TOKENS` | Maximum number of generated tokens for completion calls; sample value `2000`, provider default when left empty | +| `A2AT_LLM_TEMPERATURE` | Sampling temperature; sample value `0`, provider default when left empty | +| `A2AT_LLM_TIMEOUT_SECONDS` | LLM request timeout in seconds; sample value `60`, provider default when left empty | +| `A2AT_LLM_HISTORY_WINDOW` | Number of session history messages to keep, default `10` | +| `A2AT_LLM_REASONING_EFFORT` | Reasoning effort level; one of `none`/`minimal`/`low`/`medium`/`high`/`xhigh`, not set when left empty | +| `A2AT_LLM_DISABLE_SYSTEM_PROXY` | Whether to bypass the system HTTP proxy when accessing the LLM, default `false` | +| `A2AT_LLM_SESSION_MAX_TOTAL` | Maximum total number of tracked sessions, default `300` | +| `A2AT_LLM_SESSION_MAX_PER_PROVIDER` | Maximum number of tracked sessions per provider, default `100` | +| `A2AT_LLM_MAX_ATTEMPTS` | Maximum number of attempts for retryable LLM steps; range 1-10 (out-of-range values are clamped), default `3` | +| `A2AT_NEGOTIATION_STATE_STORE_TYPE` | Negotiation state storage; currently supports `in_memory` | + diff --git a/docs/en/user_guide.md b/docs/en/user_guide.md deleted file mode 100644 index 4384dfc5..00000000 --- a/docs/en/user_guide.md +++ /dev/null @@ -1,173 +0,0 @@ -# 1 a2a-t-sdk-java User Guide - -## 1.1 Feature Introduction -### 1.1.1 What is A2A-T? -A2A-T (Agent-to-Agent Telecom) is a multi-agent interconnection protocol for the telecom domain based on the A2A protocol, designed specifically for complex collaboration scenarios in the telecom domain. -Industry-generic agent interconnection protocols primarily focus on agent interconnection and interaction frameworks, with insufficient attention to business scenarios and specific interaction content, resulting in low task completion success rates. Business scenarios in the telecom domain are complex and demanding; operations agent interconnection and collaboration require dedicated protocol support. The A2A-T solution is based on the A2A protocol, with focused application extensions for telecom domain business flow-related information models, task negotiation, and collaboration security enhancement capabilities. - -### 1.1.2 Relationship Between A2A-T SDK and A2A SDK - -The A2A-T protocol is an extension based on the A2A protocol. The A2A-T SDK is provided for the protocol extension content, supporting rapid construction of agents for complex collaboration scenarios in the telecom domain. The A2A-T SDK is independent of the A2A SDK; by integrating both the A2A-T SDK and the A2A SDK simultaneously, you can build agents that support the A2A-T protocol, enabling deterministic, highly reliable, efficient, and secure collaboration among multiple agents in the telecom domain. - -### 1.1.3 Capability Introduction - -a2a-t-sdk-java is the Java SDK implementation of the A2A-T protocol, providing client prompt generation, server prompt validation, and negotiation runtime capabilities. - -Main capabilities include: - -- **Client prompt generation**: Convert natural language or structured input into processed task prompt via `A2ATClient`. -- **Server prompt validation**: Validate the scenario, slot, and semantic consistency of processed task prompt via `A2ATServer`. -- **Negotiation flow**: Support negotiation type: `information`. -- **Modular Maven project**: Organized by layers: core, resources, llm, prompt, negotiation, client, server, sample. -- **A2A Java integration sample**: `a2a-t-sample` demonstrates real HTTP+JSON/REST links based on `a2a-java v1.0.0.Beta1`. - -## 1.2 Application Scenarios - -The Java SDK is typically used in the following scenarios: - -1. A Java client Agent receives user intent and generates an A2A-T processed task prompt. -2. A Java server Agent receives an A2A request, validates the prompt, and proceeds to business execution. -3. Client and server perform multi-round information supplementation or task feasibility confirmation through negotiation context. -4. Work with the registry-center to implement AgentCard registration, query, and A2A invocation address resolution. - -## 1.3 Environment Requirements - -| Item | Requirement | -| --- | --- | -| JDK | 17+ | -| Build Tool | Maven 3.8+ | -| LLM | Accessible OpenAI service and API Key | -| Registry Center | registry-center must be started before running Java sample | -| A2A Java | sample based on `a2a-java v1.0.0.Beta1` | - -## 1.4 Build and Install - -Execute in the repository root directory: - -```bash -cd {project_path}/a2a-t-sdk-java -mvn -DskipTests package -``` - -To use a local Maven repository directory: - -```bash -mvn "-Dmaven.repo.local=.mvn/repository" -DskipTests package -``` - -Run tests: - -```bash -mvn test -``` - -## 1.5 Prepare Configuration - -The repository root directory provides `client.env` and `server.env`, which can be modified according to your local environment. - -Client configuration example: - -```properties -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=classpath -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY={your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -A2AT_SAMPLE_HOST=127.0.0.1 -A2AT_SAMPLE_PORT=26335 -REGISTRY_CENTER_HOST=127.0.0.1 -REGISTRY_CENTER_PORT=5001 -``` - -Server configuration example: - -```properties -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=classpath -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY={your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -A2AT_SAMPLE_HOST=127.0.0.1 -A2AT_SAMPLE_PORT=26335 -REGISTRY_CENTER_HOST=127.0.0.1 -REGISTRY_CENTER_PORT=5001 -``` - -## 1.6 Quick Run Example - -The Java sample module is located at `a2a-t-sdk-java/a2a-t-sample`. - -### 1.6.1 Prerequisites - -1. Fill in `A2AT_LLM_API_KEY` in `client.env` and `server.env`. -2. Start the sample server before running the sample client. -3. To query AgentCard directly from the sample server, make sure `A2AT_SAMPLE_HOST` and `A2AT_SAMPLE_PORT` in `client.env` match the server listen address. -4. To query AgentCard through registry-center instead, start registry-center and confirm that `REGISTRY_CENTER_HOST` and `REGISTRY_CENTER_PORT` in `client.env` and `server.env` point to the same registry-center. - -### 1.6.2 Build sample - -```bash -cd {project_path}/a2a-t-sdk-java -mvn "-Dmaven.repo.local=.mvn/repository" -pl a2a-t-sample -am -DskipTests package -``` - -### 1.6.3 Start Server - -```bash -java @a2a-t-sample/target/server.javaargs.txt -``` - -After the server starts, it will: - -- Construct a sample AgentCard. -- Register the AgentCard with the registry-center. -- Start the local A2A HTTP service. -- Wait for client requests and validate the A2A-T prompt. - -### 1.6.4 Start Client - -Open another terminal and execute: - -```bash -java @a2a-t-sample/target/client.javaargs.txt -``` - -The client will: - -1. Read sample input from `sample/subscribe_incident/client/scenario.json`. -2. Query the target AgentCard directly from the sample server root path `GET /` when `A2AT_SAMPLE_HOST` and `A2AT_SAMPLE_PORT` are configured; otherwise query it from registry-center. -3. Generate processed task prompt using `A2ATClient`. -4. Construct an A2A HTTP+JSON/REST request. -5. Receive server task status, message, and artifact events. - - -## 1.7 Configuration File Quick Reference - -| Configuration Item | Description | -| --- | --- | -| `A2AT_LANGUAGE` | Prompt resource language, commonly `zh-CN` | -| `A2AT_PROMPT_SOURCE_TYPE` | Prompt resource source, supports `classpath` and `local_file`; default is `classpath` | -| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | Local prompt resource root directory; required in `local_file` mode (assembly-time fail-fast if unset or the path does not exist); only business content (templates/slots/scenarios of Task-T/Notification-T/Authorization-T) is read from this root, while negotiation resources and LLM prompts are always loaded from the classpath | -| `A2AT_LLM_PROVIDER` | LLM provider, sample uses `openai` | -| `A2AT_LLM_MODEL` | Model name | -| `A2AT_LLM_BASE_URL` | LLM service address | -| `A2AT_LLM_API_KEY` | LLM API Key | -| `A2AT_NEGOTIATION_STATE_STORE_TYPE` | Negotiation state storage, currently supports `in_memory` | -| `A2AT_SAMPLE_HOST` | Sample server listen address; the client also uses it to query AgentCard directly from the server root | -| `A2AT_SAMPLE_PORT` | Sample server listen port; the client also uses it to query AgentCard directly from the server root | -| `REGISTRY_CENTER_HOST` | Registry-center address | -| `REGISTRY_CENTER_PORT` | Registry-center port | - -## 1.8 Constraints and Limitations - -1. The current version is Alpha; interfaces and module organization may change with version evolution. -2. Negotiation state currently uses in-memory storage; cross-process persistence is not provided. -3. `A2ATClient` and `A2ATServer` do not automatically discover `.env`; the caller must explicitly pass in the configuration file path. -4. The SDK does not provide authentication, key management, or AgentCard registry-center service capabilities. -5. The sample client can query AgentCard directly from the sample server root path when `A2AT_SAMPLE_HOST` and `A2AT_SAMPLE_PORT` are configured. Registry-center is only required when using registry-center AgentCard query mode. diff --git "a/docs/zh/API\345\217\202\350\200\203.md" "b/docs/zh/API\345\217\202\350\200\203.md" index 0fc60e0c..6b69a962 100644 --- "a/docs/zh/API\345\217\202\350\200\203.md" +++ "b/docs/zh/API\345\217\202\350\200\203.md" @@ -2,151 +2,61 @@ ## 1.1 简介 -A2A-T SDK 的对外 API 收敛在两个门面上:客户端门面 `A2ATClient`(提示词生成、协商消息生成与校验)与服务端门面 `A2ATServer`(提示词校验、协商消息生成与校验)。Negotiation-T 接口在两个门面上签名与语义完全对称;Task-T / Notification-T / Authorization-T 的生成接口位于 `A2ATClient`,校验接口位于 `A2ATServer`。 +A2A-T SDK 的对外 仅提供两个入口:客户端入口 `A2ATClient`(提示词生成、协商消息生成与校验)与服务端入口 `A2ATServer`(提示词校验、协商消息生成与校验),API分类总览见下 -- **API 总览**: +- **API 总览** | 分类 | API定义 | Client/Server | API含义 | 是否涉及LLM | | ---- | ------- | -------- | ------- |---------| -| Negotiation-T | `generateNegotiationProposePromptFromText` | A2A-T Client / A2A-T Server | 从自然语言文本生成协商发起(propose)报文 | 是(1次LLM内容抽取) | -| Negotiation-T | `generateNegotiationAcceptPromptFromText` | A2A-T Client / A2A-T Server | 从自然语言文本生成协商接受(accept)报文 | 是(1次LLM内容抽取) | -| Negotiation-T | `generateNegotiationRejectPromptFromText` | A2A-T Client / A2A-T Server | 从自然语言文本生成协商拒绝(reject)报文 | 是(1次LLM内容抽取) | -| Negotiation-T | `generateNegotiationProposePromptFromData` | A2A-T Client / A2A-T Server | 从结构化数据确定性生成协商发起报文(不调用LLM) | 否 | -| Negotiation-T | `generateNegotiationAcceptPromptFromData` | A2A-T Client / A2A-T Server | 从结构化数据确定性生成协商接受报文(不调用LLM) | 否 | -| Negotiation-T | `generateNegotiationRejectPromptFromData` | A2A-T Client / A2A-T Server | 从结构化数据确定性生成协商拒绝报文(不调用LLM) | 否 | -| Negotiation-T | `validateProposePromptAndDataFilling` | A2A-T Client / A2A-T Server | 校验协商发起报文合规性并按Schema提取参数 | 是(1次LLM语义校验) | -| Negotiation-T | `validateAcceptPromptAndDataFilling` | A2A-T Client / A2A-T Server | 校验协商接受报文合规性并按Schema提取参数 | 是(1次LLM语义校验) | -| Negotiation-T | `validateRejectPromptAndDataFilling` | A2A-T Client / A2A-T Server | 校验协商拒绝报文合规性并按Schema提取参数 | 是(1次LLM语义校验) | -| Task-T | `generateTaskPromptFromText` | A2A-T Client | 从自然语言文本按指定Task-T模板生成任务提示词(跳过场景识别) | 是(1次LLM槽位提取) | -| Task-T | `generateTaskPromptFromDataWithSchema` | A2A-T Client | 从结构化数据+语义Schema按指定Task-T模板生成任务提示词 | 是(1次LLM槽位提取) | -| Task-T | `validateTaskPromptAndDataFilling` | A2A-T Server | 校验Task-T任务提示词合规性并按Schema提取参数 | 是(1次LLM语义校验与提参) | -| Notification-T | `generateNotificationPromptFromText` | A2A-T Client | 从自然语言文本按指定Notification-T模板生成通知订阅提示词 | 是(1次LLM槽位提取) | -| Notification-T | `generateNotificationPromptFromDataWithSchema` | A2A-T Client | 从结构化数据+语义Schema按指定Notification-T模板生成通知订阅提示词 | 是(1次LLM槽位提取) | -| Notification-T | `validateNotificationPromptAndDataFilling` | A2A-T Server | 校验Notification-T提示词合规性并按Schema提取参数 | 是(1次LLM语义校验与提参) | -| Authorization-T | `generateAuthPromptFromText` | A2A-T Client | 从自然语言文本按指定Authorization-T模板生成授权提示词 | 是(1次LLM槽位提取) | -| Authorization-T | `generateAuthPromptFromDataWithSchema` | A2A-T Client | 从结构化数据+语义Schema按指定Authorization-T模板生成授权提示词 | 是(1次LLM槽位提取) | -| Authorization-T | `validateAuthPromptAndDataFilling` | A2A-T Server | 校验Authorization-T提示词合规性并按Schema提取参数 | 是(1次LLM语义校验与提参) | -| 通用接口 | `generateTaskPrompt` | A2A-T Client | 从自然语言或结构化输入经场景识别生成任务提示词 | 是(2次LLM调用:场景识别+槽位提取) | -| 通用接口 | `checkTaskPrompt` | A2A-T Server | 校验任务提示词的场景、模板与槽位合规性 | 是(3次LLM调用:场景识别+槽位提取+语义校验) | +| Negotiation-T | `generateNegotiationProposePromptFromText` | A2A-T Client / A2A-T Server | 从自然语言文本生成协商发起(propose)报文 | 是 | +| Negotiation-T | `generateNegotiationAcceptPromptFromText` | A2A-T Client / A2A-T Server | 从自然语言文本生成协商接受(accept)报文 | 是 | +| Negotiation-T | `generateNegotiationRejectPromptFromText` | A2A-T Client / A2A-T Server | 从自然语言文本生成协商拒绝(reject)报文 | 是 | +| Negotiation-T | `generateNegotiationProposePromptFromData` | A2A-T Client / A2A-T Server | 从结构化数据确定性生成协商发起报文 | 否 | +| Negotiation-T | `generateNegotiationAcceptPromptFromData` | A2A-T Client / A2A-T Server | 从结构化数据确定性生成协商接受报文 | 否 | +| Negotiation-T | `generateNegotiationRejectPromptFromData` | A2A-T Client / A2A-T Server | 从结构化数据确定性生成协商拒绝报文 | 否 | +| Negotiation-T | `validateProposePromptAndDataFilling` | A2A-T Client / A2A-T Server | 校验协商发起报文合规性并按Schema提取参数 | 是 | +| Negotiation-T | `validateAcceptPromptAndDataFilling` | A2A-T Client / A2A-T Server | 校验协商接受报文合规性并按Schema提取参数 | 是 | +| Negotiation-T | `validateRejectPromptAndDataFilling` | A2A-T Client / A2A-T Server | 校验协商拒绝报文合规性并按Schema提取参数 | 是 | +| Task-T | `generateTaskPromptFromText` | A2A-T Client | 从自然语言文本按指定Task-T模板生成任务提示词(跳过场景识别) | 是 | +| Task-T | `generateTaskPromptFromDataWithSchema` | A2A-T Client | 从结构化数据+语义Schema按指定Task-T模板生成任务提示词 | 是 | +| Task-T | `validateTaskPromptAndDataFilling` | A2A-T Server | 校验Task-T任务提示词合规性并按Schema提取参数 | 是 | +| Notification-T | `generateNotificationPromptFromText` | A2A-T Client | 从自然语言文本按指定Notification-T模板生成通知订阅提示词 | 是 | +| Notification-T | `generateNotificationPromptFromDataWithSchema` | A2A-T Client | 从结构化数据+语义Schema按指定Notification-T模板生成通知订阅提示词 | 是 | +| Notification-T | `validateNotificationPromptAndDataFilling` | A2A-T Server | 校验Notification-T提示词合规性并按Schema提取参数 | 是 | +| Authorization-T | `generateAuthPromptFromText` | A2A-T Client | 从自然语言文本按指定Authorization-T模板生成授权提示词 | 是 | +| Authorization-T | `generateAuthPromptFromDataWithSchema` | A2A-T Client | 从结构化数据+语义Schema按指定Authorization-T模板生成授权提示词 | 是 | +| Authorization-T | `validateAuthPromptAndDataFilling` | A2A-T Server | 校验Authorization-T提示词合规性并按Schema提取参数 | 是 | +| 通用接口 | `generateTaskPrompt` | A2A-T Client | 从自然语言或结构化输入经场景识别生成任务提示词 | 是 | +| 通用接口 | `checkTaskPrompt` | A2A-T Server | 校验任务提示词的场景、模板与槽位合规性 | 是 | **公共数据类型与约定** -- **TemplateUri:**模板 URI 值类型,推荐用 `net.openan.a2at.sdk.core.model.StandardTemplates` 常量构造,如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`(URI 为 `Negotiation-T/information-negotiation/propose/v1`);来自外部的字符串用 `TemplateUri.parse(String)` 解析,返回 `Optional` 且不抛异常。 -- **协商会话上下文:**`NegotiationContext(id, round, maxRounds)`(id 为 UUID 形态、round 从 1 起、默认轮次预算 `DEFAULT_MAX_ROUNDS = 5`),随消息 metadata 传输、不经 LLM;`NegotiationContext.of(id, round)` 使用默认预算,`nextRound()` 推进轮次。 -- **异常体系:**所有 SDK 处理失败均为 `A2ATError` 子类——生成失败抛 `PromptGenerationException`(Task-T / Notification-T / Authorization-T)或 `NegotiationGenerationException`(Negotiation-T),校验+提参失败抛 `ContentValidationException`(Task-T / Notification-T / Authorization-T,携带 `errors()` 槽位错误明细与 `params()` 部分提取参数)或 `NegotiationParamExtractionException`(Negotiation-T)。捕获 `A2ATError` 即可覆盖全部处理失败,`getCode()` 获取下方错误码目录中的机器可读错误码。预期业务失败均继承 `A2ATBusinessException`,额外提供 `getFacts()` 返回渲染消息所依据的结构化事实值;所有消息均由 SDK 按错误码模板渲染,语言跟随 `A2AT_LANGUAGE`。 -- **SlotValidationError:**逐槽位校验错误明细,随校验失败异常或失败负载返回,各节“输出说明”中的失败结构均引用此定义: +- **协商会话上下文:**`NegotiationContext(id, round, maxRounds)`(id 为 UUID 形态、round 从 1 起、默认轮次 `DEFAULT_MAX_ROUNDS = 5`),随消息 metadata 传输、不经 LLM;`NegotiationContext.of(id, round)` 使用默认轮次,`nextRound()` 推进轮次。 +- **异常体系:**所有 SDK 处理失败均为 `A2ATError` 子类,捕获 `A2ATError` 即可覆盖全部处理失败,`getCode()` 获取机器可读错误码。所有消息均由 SDK 按错误码模板渲染,语言跟随 `A2AT_LANGUAGE`。如下业务异常均继承 `A2ATBusinessException`,额外提供 `getFacts()` 返回渲染消息所依据的结构化事实值; + - 模板生成失败抛 `PromptGenerationException`(Task-T / Notification-T / Authorization-T)或 `NegotiationGenerationException`(Negotiation-T)。 + - 模板校验+提参失败抛 `ContentValidationException`(Task-T / Notification-T / Authorization-T)或 `NegotiationParamExtractionException`(Negotiation-T)。 +- **TemplateUri:**模板 URI 值类型,推荐用 `net.openan.a2at.sdk.core.model.StandardTemplates` 常量构造;来自外部的字符串用 `TemplateUri.parse(String)` 解析,返回 `Optional` 且不抛异常,当前支持的TemplateUri常量如下: -| 字段 | 类型 | 说明 | +| 常量名称 | 含义 | TemplateURI | | ---- | ---- | ---- | -| slotName | String | 出错槽位名 | -| code | String | 槽位级错误码,取值来自下方错误码目录,如 `content.param_missing`(参数章节未填写)、`content.entry_field_missing`(条目缺少必填字段)、`content.format_error`(取值格式错误) | -| message | String | 人类可读的错误说明,由 SDK 按错误码的消息模板渲染;语言跟随 `A2AT_LANGUAGE`(默认 `en-US`) | -| facts | Map<String, String> | 渲染消息所依据的结构化事实值,按事实参数名(如 `section_label`、`index`、`field_label`)为键;可为 null | - -**错误码目录** - -SDK 对外暴露封闭的分层式 `domain.semantic` 错误码目录,同一错误码始终对应同一消息。消息文本由 SDK 从 `prompt_resources/errors/{language}/errors.json` 中的分语言模板渲染生成,不采用 LLM 生成的自由文本。消息语言跟随 `A2AT_LANGUAGE` 配置(默认 `en-US`);模板中的 `{x}` 占位符由错误携带的 `facts` 填充。SDK 内部 LLM 步骤只输出 `{slot_name, code, facts}` 条目;LLM 返回目录外的错误码时映射到所在域的 `*.rule_violation` 兜底码,并记录原始码日志。 - -类别图例:BUSINESS = 调用方可行动的预期业务失败,由 `A2ATBusinessException` 子类携带;INFRA = 基础设施失败,由普通 `A2ATError` 携带。 - -| 错误码 | 类别 | 消息(zh-CN) | 消息(en-US) | -| ---- | -------- | ---------------- | --------------- | -| `template.not_found` | BUSINESS | 模板「{template_uri}」不支持语言「{language}」,请检查模板标识与语言配置 | Template '{template_uri}' does not support language '{language}'; check the template URI and language setting | -| `template.render_failed` | BUSINESS | 模板「{template_uri}」渲染失败:{reason} | Failed to render template '{template_uri}': {reason} | -| `template.load_failed` | INFRA | 模板资源「{resource_path}」读取失败 | Failed to read template resource '{resource_path}' | -| `slot.schema_not_found` | BUSINESS | 模板「{template_uri}」缺少参数定义文件(语言「{language}」) | Template '{template_uri}' is missing its slot schema (language '{language}') | -| `slot.not_provided` | BUSINESS | 输入中未提供「{slot_label}」。 | '{slot_label}' is not provided in the input. | -| `slot.constraint_violated` | BUSINESS | 「{slot_label}」的取值「{actual}」不在允许范围内 | The value of '{slot_label}' ({actual}) is not within the allowed range | -| `slot.semantic_conflict` | BUSINESS | 「{slot_label}」的取值与参数定义冲突:{reason} | The value of '{slot_label}' conflicts with the slot definition: {reason} | -| `slot.fabricated_value` | BUSINESS | 「{slot_label}」的取值「{actual}」是占位内容,不是有效值 | The value of '{slot_label}' ({actual}) is placeholder content, not a valid value | -| `slot.cross_scenario_pollution` | BUSINESS | 「{slot_label}」的取值混入了其他场景的内容 | The value of '{slot_label}' contains content from a different scenario | -| `slot.insufficient_grounding` | BUSINESS | 「{slot_label}」的取值缺少充分依据 | The value of '{slot_label}' lacks sufficient grounding | -| `slot.rule_violation` | BUSINESS | 「{slot_label}」的取值不符合校验规则。 | The value of '{slot_label}' violates the validation rules. | -| `input.text_too_long` | BUSINESS | 输入文本长度 {actual_length} 超过上限 {max_chars}(A2AT_INPUT_TEXT_MAX_CHARS) | Input text length {actual_length} exceeds the maximum of {max_chars} (A2AT_INPUT_TEXT_MAX_CHARS) | -| `content.param_missing` | BUSINESS | 「{section_label}」未填写,请补充该参数的取值 | '{section_label}' is empty; please provide a value | -| `content.entry_field_missing` | BUSINESS | 「{section_label}」第 {index} 条缺少必填字段「{field_label}」 | Entry {index} of '{section_label}' is missing required field '{field_label}' | -| `content.format_error` | BUSINESS | 「{section_label}」的取值格式不符合要求:{reason} | The format of '{section_label}' is invalid: {reason} | -| `content.value_not_allowed` | BUSINESS | 「{section_label}」的取值「{actual}」不在允许范围内 | The value of '{section_label}' ({actual}) is not allowed | -| `content.semantic_conflict` | BUSINESS | 「{section_label}」存在语义冲突:{reason} | '{section_label}' has a semantic conflict: {reason} | -| `content.rule_violation` | BUSINESS | 「{section_label}」的取值不符合校验规则。 | The value of '{section_label}' violates the validation rules. | -| `scenario.not_matched` | BUSINESS | 输入内容无法匹配任何已知场景:{reason} | The input does not match any known scenario: {reason} | -| `llm.not_configured` | BUSINESS | 未配置 LLM 客户端,无法执行该操作(请检查 A2AT_LLM_* 配置) | No LLM client is configured; check the A2AT_LLM_* settings | -| `llm.invocation_failed` | BUSINESS | LLM 调用失败(提供方 {provider}):{reason} | LLM invocation failed (provider {provider}): {reason} | -| `llm.response_invalid` | BUSINESS | LLM 返回内容不符合要求({step} 步骤),请重试 | The LLM response is invalid (step: {step}); please retry | -| `negotiation.invalid_input` | BUSINESS | 输入的协商内容无效:{reason} | The negotiation input is invalid: {reason} | -| `negotiation.invalid_context_id` | BUSINESS | 协商上下文标识「{actual}」不是合法的 UUID | The negotiation context id '{actual}' is not a valid UUID | -| `negotiation.round_exceeded` | BUSINESS | 协商轮次 {round} 已超过上限 {max_rounds} | Negotiation round {round} exceeds the maximum of {max_rounds} | -| `negotiation.type_mismatch` | BUSINESS | 报文内容属于「{implied}」协商,与声明的模板类型「{declared}」不符 | The message implies '{implied}' negotiation but the declared template type is '{declared}' | -| `negotiation.phase_mismatch` | BUSINESS | 报文阶段与声明的模板阶段不符({implied} vs {declared}) | The message phase does not match the declared template phase ({implied} vs {declared}) | -| `negotiation.conclusion_mismatch` | BUSINESS | 报文结论为「{actual}」,与该方法的预期「{expected}」不符 | The message conclusion is '{actual}' but '{expected}' is expected for this method | -| `negotiation.content_invalid` | BUSINESS | 协商内容字段「{field}」无效:{reason} | The negotiation content field '{field}' is invalid: {reason} | -| `negotiation.field_missing` | BUSINESS | 协商报文缺少必填字段「{field}」 | The negotiation message is missing required field '{field}' | -| `negotiation.content_extract_failed` | BUSINESS | 无法从文本提取协商内容({field}):{reason} | Failed to extract negotiation content from text ({field}): {reason} | -| `negotiation.conclusion_content_mismatch` | BUSINESS | 结论为「{conclusion}」,但「{section_label}」未表达该结论应携带的内容 | The conclusion is '{conclusion}' but '{section_label}' does not state the content the conclusion requires | -| `negotiation.missing_result_content` | BUSINESS | 「{section_label}」板块缺少结论应携带的内容 | The '{section_label}' section is missing the content required by its conclusion | -| `negotiation.mutually_exclusive_sections` | BUSINESS | 互斥板块同时出现:{sections} | Mutually exclusive sections appear together: {sections} | -| `negotiation.constraint_conflict` | BUSINESS | 「{section_label}」与既有约束冲突:{reason} | '{section_label}' conflicts with existing constraints: {reason} | -| `negotiation.field_inconsistency` | BUSINESS | 「{section_label}」内字段取值前后不一致:{reason} | Fields within '{section_label}' are inconsistent: {reason} | -| `negotiation.invalid_time_interval` | BUSINESS | 「{section_label}」的时间区间不合法(开始时间不得晚于结束时间) | The time interval of '{section_label}' is invalid (start must not be later than end) | -| `negotiation.semantic_rejected` | BUSINESS | 协商报文语义校验未通过 | The negotiation message failed semantic validation | -| `negotiation.rule_violation` | BUSINESS | 「{section_label}」不符合协商报文的校验规则。 | '{section_label}' violates the negotiation message validation rules. | -| `infra.config_invalid` | INFRA | 配置项「{key}」无效:{reason} | Invalid configuration '{key}': {reason} | -| `infra.resource_read_failed` | INFRA | 资源「{resource_path}」读取失败 | Failed to read resource '{resource_path}' | -| `infra.internal_error` | INFRA | SDK 内部错误,请联系维护方并提供上下文 | SDK internal error; contact the maintainer with context | - -**旧码到新码映射** - -上述分层错误码取代了先前的扁平错误码。迁移按 `getCode()` 值做分支的调用方代码时,请使用下表映射: - -| 旧错误码 | 新错误码 | -| -------- | -------- | -| `template_not_found` | `template.not_found` | -| `render_failed` | `template.render_failed` | -| `prompt_resource_load_error` | `template.load_failed` | -| `slot_schema_not_found` | `slot.schema_not_found` | -| `missing_input` / `param_extraction_failed`(槽位提取) | `slot.not_provided` | -| `invalid_value`(槽位提取) | `slot.constraint_violated` | -| `semantic_mismatch`(语义校验) | `slot.semantic_conflict` | -| `fabricated_value` | `slot.fabricated_value` | -| `cross_scenario_pollution` | `slot.cross_scenario_pollution` | -| `insufficient_grounding` | `slot.insufficient_grounding` | -| `input_text_too_long` | `input.text_too_long` | -| `missing_required`(参数级,内容校验) | `content.param_missing` | -| `missing_required`(条目级,内容校验) | `content.entry_field_missing` | -| `format_error` | `content.format_error` | -| `invalid_value`(内容校验) | `content.value_not_allowed` | -| `semantic_mismatch`(内容校验) | `content.semantic_conflict` | -| `scenario_not_matched` / `processed_prompt_parse_error` | `scenario.not_matched` | -| `llm_invocation_failed`(传输类失败) | `llm.invocation_failed` | -| `llm_invocation_failed`(响应契约类失败) | `llm.response_invalid` | -| "LLM client is not configured" 系列 | `llm.not_configured` | -| `negotiation_invalid_input` / `validation_invalid_input` | `negotiation.invalid_input` | -| `invalid_uuid` | `negotiation.invalid_context_id` | -| `out_of_range`(轮次) | `negotiation.round_exceeded` | -| `template_type_mismatch` | `negotiation.type_mismatch` | -| `template_phase_mismatch` | `negotiation.phase_mismatch` | -| `invalid_conclusion` / Registry 结论门禁 | `negotiation.conclusion_mismatch` | -| fromData 必填 items/text 的 `IllegalArgumentException` | `negotiation.content_invalid` | -| `negotiation_slot_missing` | `negotiation.field_missing` | -| `negotiation_content_extract_failed` | `negotiation.content_extract_failed` | -| `conclusion_content_mismatch` | `negotiation.conclusion_content_mismatch` | -| `missing_result_content` | `negotiation.missing_result_content` | -| `mutually_exclusive_sections` | `negotiation.mutually_exclusive_sections` | -| `constraint_conflict` | `negotiation.constraint_conflict` | -| `field_inconsistency` | `negotiation.field_inconsistency` | -| `invalid_time_interval` | `negotiation.invalid_time_interval` | -| `negotiation_semantic_rejected` / `validation_semantic_rejected` | `negotiation.semantic_rejected` | -| `negotiation_rule_violation` / `validation_rule_violation` | `negotiation.rule_violation` | -| `negotiation_llm_infrastructure_error` / `validation_llm_infrastructure_error` | `llm.invocation_failed` / `llm.response_invalid` | -| `validation_prompt_resource_not_found` | `template.not_found` | -| `sdk_internal_error` | `infra.internal_error` | -| `LLMConfigError` | `infra.config_invalid` | -| 裸 `A2ATError` 资源读取失败 | `infra.resource_read_failed` | +| StandardTemplates.ENERGY_SAVING | Task-T 节能任务模板 | Task-T/network-layer/ran-energy-saving/v1 | +| StandardTemplates.PRIVATE_LINE_COMPLAINT | Task-T 专线投诉任务模板 | Task-T/network-layer/private-line-complaint/v1 | +| StandardTemplates.SUBSCRIBE_INCIDENT | Notification-T 事件订阅通知模板 | Notification-T/network-layer/subscribe-incident/v1 | +| StandardTemplates.SERVICE_RECOVERY | Notification-T 业务抢通通知模板 | Notification-T/network-layer/service-recovery/v1 | +| StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT | Authorization-T 授权策略管理模板 | Authorization-T/authorization-policy-management/v1 | +| StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE | Negotiation-T 信息协商意图发起模板 | Negotiation-T/information-negotiation/propose/v1 | +| StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT | Negotiation-T 信息协商接受/拒绝模板 | Negotiation-T/information-negotiation/accept-reject/v1 | +| StandardTemplates.TARGET_NEGOTIATION_PROPOSE | Negotiation-T 目标协商意图发起模板 | Negotiation-T/target-negotiation/propose/v1 | +| StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT | Negotiation-T 目标协商接受/拒绝模板 | Negotiation-T/target-negotiation/accept-reject/v1 | +| StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE | Negotiation-T 可行性协商意图发起模板 | Negotiation-T/feasibility-negotiation/propose/v1 | +| StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT | Negotiation-T 可行性协商接受/拒绝模板 | Negotiation-T/feasibility-negotiation/accept-reject/v1 | +| StandardTemplates.NEGOTIATION_ABORT | Negotiation-T 协商中止通用模板 | Negotiation-T/common/abort/v1 | ## 1.2 约束和限制 -部分接口涉及到LLM调用,使用时需根据对接模型服务可提供的并发能力控制使用时的调用频率和并发 +- 部分接口涉及到LLM调用,使用时需根据对所接模型服务可提供的并发能力,结合业务时限要求,控制使用API时的调用频率和并发。 +- 涉及到LLM调用的API针对文本`text`输入会做长度防护,在输入超过`client.env`或`server.env`中配置的 `A2AT_INPUT_TEXT_MAX_CHARS` 字符时,会报错误码 `input_text_too_long` ,该配置项默认配置为 16384(16×1024);不涉及LLM调用的结构化数据不受此限制。 ## 1.3 API说明 @@ -161,13 +71,13 @@ public MetadataContent generateNegotiationProposePromptFromText( **典型场景**:服务端Agent收到参数不全的Task-T任务报文后,用自然语言向客户端Agent发起"补充缺失信息"的协商请求;也适用于客户端Agent向服务端发起目标澄清或可行性评估请求。 -**功能说明**:从自然语言文本生成协商发起(propose)阶段的结构化协商报文。执行流程:先加载模板(缺失快速失败,不消耗 LLM 请求),再执行一次 LLM 内容抽取(在模板 URI 约束下将自由文本抽取为类型化内容,可重试错误码上最多重试至 `A2AT_LLM_MAX_ATTEMPTS` 配置的上限),最后确定性渲染模板。适用于信息/目标/可行性协商的发起方。`templateUri` 的 phase 段必须为 `propose`。 +**功能说明**:从自然语言文本生成协商发起(propose)阶段的结构化协商报文。执行流程:先加载模板,再执行一次 LLM 内容抽取,最后确定性渲染模板。适用于信息/目标/可行性协商的发起方。 **输入说明** | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | -| text | String | 是 | 描述协商发起内容的自然语言文本(如请求补充的缺失信息清单) | +| text | String | 是 | 描述协商发起内容的自然语言文本(如请求补充的缺失信息清单);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | context | NegotiationContext | 是 | 协商会话上下文,不经 LLM 直接注入生成消息的 `negotiationContext` metadata | | templateUri | TemplateUri | 是 | propose 模板,如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE` | @@ -259,13 +169,13 @@ public MetadataContent generateNegotiationAcceptPromptFromText( **典型场景**:协商响应方(通常是客户端Agent)收到对端的信息协商请求后,用自然语言补充/交付所请求的信息并生成接受报文回传,如补齐接入端口名称与投诉分类后确认启动诊断。 -**功能说明**:从自然语言文本生成协商接受(accept)报文。一次 LLM 内容抽取(抽取出的结论必须为 `ACCEPT`,否则以 `negotiation.invalid_input` 拒绝)+ 确定性渲染。适用于协商响应方补充/交付信息。`templateUri` 的 phase 段必须为 `accept-reject`。 +**功能说明**:从自然语言文本生成协商接受(accept)报文。一次 LLM 内容抽取(抽取出的结论必须为 `ACCEPT`,否则以 `negotiation.invalid_input` 拒绝)+ 确定性渲染。适用于协商响应方补充/交付信息。 **输入说明** | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | -| text | String | 是 | 描述接受内容的自然语言文本(如补充交付的信息清单) | +| text | String | 是 | 描述接受内容的自然语言文本(如补充交付的信息清单);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | context | NegotiationContext | 是 | 协商会话上下文 | | templateUri | TemplateUri | 是 | accept-reject 模板,如 `StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT` | @@ -322,7 +232,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( **典型场景**:协商响应方(通常是客户端Agent)无法满足对端的协商请求时,用自然语言生成拒绝报文回传并结束本轮协商,如因站点清单不可用无法提供接入端口名称。 -**功能说明**:从自然语言文本生成协商拒绝(reject)报文。一次 LLM 内容抽取(抽取出的结论必须为 `REJECT`,否则以 `negotiation.invalid_input` 拒绝)+ 确定性渲染。`templateUri` 的 phase 段必须为 `accept-reject`。 +**功能说明**:从自然语言文本生成协商拒绝(reject)报文。一次 LLM 内容抽取(抽取出的结论必须为 `REJECT`,否则以 `negotiation.invalid_input` 拒绝)+ 确定性渲染。 **输入说明**:同 [generateNegotiationAcceptPromptFromText](#132-generatenegotiationacceptpromptfromtext),text 为描述拒绝原因的自然语言文本。 @@ -377,7 +287,7 @@ public MetadataContent generateNegotiationProposePromptFromData( **典型场景**:与 fromText 相同的发起场景,但输入为业务系统构造的结构化数据(如服务端Agent根据 `validateTaskPromptAndDataFilling` 检出的缺失槽位清单自动生成协商请求条目),适合对报文内容有确定性要求、不希望引入 LLM 抽取不确定性的场景。 -**功能说明**:从类型化数据确定性生成协商发起报文,**不调用 LLM**:类型化内容先经校验,再分发给模板 URI 所指协商类型的生成器渲染。`content` 必须与 `templateUri` 的协商类型匹配(information / target / feasibility),phase 段必须为 `propose`。 +**功能说明**:从结构化数据输入确定性生成协商发起报文,**不调用 LLM**。 **输入说明** @@ -460,7 +370,7 @@ public MetadataContent generateNegotiationAcceptPromptFromData( **典型场景**:协商响应方(通常是客户端Agent)按对端请求的槽位清单程序化补参后,以结构化条目生成接受报文回传。 -**功能说明**:从类型化数据确定性生成协商接受报文,**不调用 LLM**。`content.conclusion()` 必须为 `ACCEPT`,其他结论(含 `ABORT`)以 `IllegalArgumentException` 拒绝。 +**功能说明**:从结构化数据输入确定性生成协商接受报文,**不调用 LLM**。`content.conclusion()` 必须为 `ACCEPT`,其他结论(含 `ABORT`)以 `IllegalArgumentException` 拒绝。 **输入说明** @@ -528,7 +438,7 @@ public MetadataContent generateNegotiationRejectPromptFromData( **典型场景**:协商响应方程序化判定无法满足对端请求后,以结构化条目(无法提供的项及原因)生成拒绝报文回传。 -**功能说明**:从类型化数据确定性生成协商拒绝报文,**不调用 LLM**。`content.conclusion()` 必须为 `REJECT`,其他结论以 `IllegalArgumentException` 拒绝。 +**功能说明**:从结构化数据输入确定性生成协商拒绝报文,**不调用 LLM**。`content.conclusion()` 必须为 `REJECT`,其他结论以 `IllegalArgumentException` 拒绝。 **输入说明**:同 [generateNegotiationAcceptPromptFromData](#135-generatenegotiationacceptpromptfromdata),但结论为 `REJECT`。拒绝内容:`InformationEndingContent(REJECT, items)`(无法提供的项及原因)、`TargetEndingContent(REJECT, null, failureReason)`(拒绝原因)、`FeasibilityEndingContent(REJECT, feasibilitySummary)`(不可行结论摘要)。 @@ -582,13 +492,13 @@ public FilledParamData validateProposePromptAndDataFilling( **典型场景**:发起方(服务端Agent)发送协商请求前的出站自检;或接收方(客户端Agent)校验收到的协商请求并提取需补充的槽位清单,驱动后续补参。 -**功能说明**:校验一条协商发起(propose)报文是否为格式正确的协商消息,并按调用方提供的 JSON Schema 从中提取参数。流水线:模板 URI phase 段检查 → 确定性规则门禁(context 为 null 报 `negotiation.invalid_input`,id 非 UUID 形态报 `negotiation.invalid_context_id`,轮次超预算报 `negotiation.round_exceeded`)→ 一次 LLM 语义校验调用(同时完成参数提取,可重试错误码上最多重试至配置上限)→ 参数合并(协商上下文参数 `id` / `round` / `maxRounds` 与提取参数合并,键冲突时**上下文参数优先**)。 +**功能说明**:校验一条协商发起(propose)报文是否为格式正确的协商消息,并按调用方提供的 JSON Schema 从中提取参数。 **输入说明** | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | -| prompt | String | 是 | 待校验的协商发起报文文本(`MetadataContent.promptText()`) | +| prompt | String | 是 | 待校验的协商发起报文文本(`MetadataContent.promptText()`);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | context | NegotiationContext | 否 | 随报文传输的协商上下文;null 报 `negotiation.invalid_input` | | schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema,声明要提取的参数 | | templateUri | TemplateUri | 是 | propose 模板 | @@ -676,7 +586,7 @@ public FilledParamData validateAcceptPromptAndDataFilling( **典型场景**:发起方(服务端Agent)校验对端回传的接受报文,提取交付的参数值并与期望补齐值核对,确认无误后继续任务执行。 -**功能说明**:校验一条协商接受(accept)报文并按 Schema 提取参数。流水线同 [validateProposePromptAndDataFilling](#137-validateproposepromptanddatafilling),区别仅在于:`templateUri` 的 phase 段必须为 `accept-reject`,且报文须满足 accept 阶段的语义约束(结论为 Accept、携带交付内容)。 +**功能说明**:校验一条协商接受(accept)报文并按 Schema 提取参数。 **输入说明**:同 1.3.7,prompt 为 accept 报文文本,templateUri 为 accept-reject 模板。 @@ -734,7 +644,7 @@ public FilledParamData validateRejectPromptAndDataFilling( **典型场景**:发起方(服务端Agent)校验对端回传的拒绝报文,提取拒绝原因,据此终止任务或转入人工处理。 -**功能说明**:校验一条协商拒绝(reject)报文并按 Schema 提取参数。流水线同 1.3.7,区别仅在于:报文须满足 reject 阶段的语义约束(结论为 Reject、携带拒绝原因)。 +**功能说明**:校验一条协商拒绝(reject)报文并按 Schema 提取参数。 **输入说明**:同 1.3.7,prompt 为 reject 报文文本,templateUri 为 accept-reject 模板。 @@ -785,13 +695,13 @@ public MetadataContent generateTaskPromptFromText(String text, TemplateUri templ **典型场景**:客户端Agent将用户的自然语言任务描述(如专线投诉诊断诉求)转换为指定场景的Task-T协议报文,适合已确定目标模板、需跳过场景识别的场景。 -**功能说明**:从自然语言文本按指定 Task-T 模板生成任务提示词报文,**跳过场景识别**(模板由调用方显式指定)。执行一次 LLM 槽位提取后确定性渲染模板。生成阶段即校验内置槽位 Schema(必填槽缺失或取值非法时以 `slot.*` 错误码(如 `slot.not_provided`)快速失败)。 +**功能说明**:从自然语言文本按指定 Task-T 模板生成任务提示词报文,**跳过场景识别**(模板由调用方显式指定)。执行一次 LLM 槽位提取后确定性渲染模板。 **输入说明** | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | -| text | String | 是 | 自然语言任务描述 | +| text | String | 是 | 自然语言任务描述;输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | templateUri | TemplateUri | 是 | Task-T 模板,如 `StandardTemplates.PRIVATE_LINE_COMPLAINT`(`Task-T/network-layer/private-line-complaint/v1`) | **请求样例** @@ -896,7 +806,7 @@ public MetadataContent generateTaskPromptFromDataWithSchema( **典型场景**:客户端Agent将上游系统的结构化任务参数(字段名可与模板槽位不同,由Schema描述字段语义)转换为Task-T协议报文,适合任务参数已结构化持有的场景。 -**功能说明**:从结构化数据 + 语义 Schema 按指定 Task-T 模板生成任务提示词,**跳过场景识别**。`schema` 描述每个输入字段的业务含义(description / examples / enum 等),指导槽位填充与取值约束;每个 data 的 key 对应一个槽位值。生成阶段同样执行内置槽位 Schema 校验(必填槽缺失或取值非法时以 `slot.*` 错误码(如 `slot.not_provided`)快速失败)。 +**功能说明**:从结构化数据 + 语义 Schema 按指定 Task-T 模板生成任务提示词,**跳过场景识别**。`schema` 描述每个输入字段的业务含义(description / examples / enum 等),指导槽位填充与取值约束;每个 data 的 key 对应一个槽位值。 **输入说明** @@ -986,13 +896,13 @@ public FilledParamData validateTaskPromptAndDataFilling( **典型场景**:服务端Agent收到Task-T报文后、进入业务执行前的参数校验与提取入口;也是协商流程中"缺槽检测"的判定点——required参数缺失时由调用方决定是否发起协商补参。 -**功能说明**:校验一条 Task-T 任务提示词报文是否匹配模板与槽位约束,并按调用方提供的 JSON Schema 提取参数。流水线:确定性规则门禁(模板内置槽位校验)→ 一次 LLM 语义校验调用(同时完成参数提取,可重试错误码上最多重试至配置上限)。 +**功能说明**:校验一条 Task-T 任务提示词报文是否匹配模板与槽位约束,并按调用方提供的 JSON Schema 提取参数。 **输入说明** | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | -| prompt | String | 是(非空) | 待校验的任务提示词报文文本(`MetadataContent.promptText()`) | +| prompt | String | 是(非空) | 待校验的任务提示词报文文本(`MetadataContent.promptText()`);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema,声明要提取/校验的参数及 required 约束 | | templateUri | TemplateUri | 是 | Task-T 模板(前缀段必须为 `Task-T`) | @@ -1084,13 +994,13 @@ public MetadataContent generateNotificationPromptFromText(String text, TemplateU **典型场景**:客户端Agent将自然语言订阅需求(如业务抢通事件订阅)转换为指定场景的Notification-T订阅报文。 -**功能说明**:从自然语言文本按指定 Notification-T 模板生成通知订阅提示词报文,跳过场景识别。执行一次 LLM 槽位提取后确定性渲染模板,生成阶段执行内置槽位 Schema 校验。 +**功能说明**:从自然语言文本按指定 Notification-T 模板生成通知订阅提示词报文。执行一次 LLM 槽位提取后确定性渲染模板,生成阶段执行内置槽位 Schema 校验。 **输入说明** | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | -| text | String | 是 | 自然语言订阅描述(通知主题、订阅条件、上报通知数据格式等) | +| text | String | 是 | 自然语言订阅描述(通知主题、订阅条件、上报通知数据格式等);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | templateUri | TemplateUri | 是 | Notification-T 模板,如 `StandardTemplates.SUBSCRIBE_INCIDENT`、`StandardTemplates.SERVICE_RECOVERY` | **请求样例** @@ -1161,7 +1071,7 @@ public MetadataContent generateNotificationPromptFromDataWithSchema( **典型场景**:客户端Agent将上游系统/界面提交的结构化订阅参数转换为Notification-T订阅报文,适合订阅参数已结构化持有的场景。 -**功能说明**:从结构化数据 + 语义 Schema 按指定 Notification-T 模板生成通知订阅提示词,跳过场景识别。语义约束同 1.3.11。 +**功能说明**:从结构化数据 + 语义 Schema 按指定 Notification-T 模板生成通知订阅提示词。 **输入说明** @@ -1255,13 +1165,13 @@ public FilledParamData validateNotificationPromptAndDataFilling( **典型场景**:服务端Agent收到Notification-T订阅报文后校验合规性并提取订阅参数(主题/条件/上报格式),据此建立订阅关系。 -**功能说明**:校验一条 Notification-T 通知订阅提示词报文是否匹配模板与槽位约束,并按调用方 Schema 提取参数(订阅主题、订阅条件、上报通知数据格式等)。流水线与异常语义同 [validateTaskPromptAndDataFilling](#1312-validatetaskpromptanddatafilling)。 +**功能说明**:校验一条 Notification-T 通知订阅提示词报文是否匹配模板与槽位约束,并按调用方 Schema 提取参数(订阅主题、订阅条件、上报通知数据格式等)。 **输入说明** | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | -| prompt | String | 是(非空) | 待校验的通知订阅提示词报文文本 | +| prompt | String | 是(非空) | 待校验的通知订阅提示词报文文本;输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema | | templateUri | TemplateUri | 是 | Notification-T 模板(前缀段必须为 `Notification-T`) | @@ -1324,13 +1234,13 @@ public MetadataContent generateAuthPromptFromText(String text, TemplateUri templ **典型场景**:客户端Agent将自然语言授权诉求(新增/修改/删除/查询动网操作授权策略)转换为Authorization-T报文。 -**功能说明**:从自然语言文本按指定 Authorization-T 模板生成授权策略操作提示词报文,跳过场景识别。执行一次 LLM 槽位提取后确定性渲染模板。Authorization-T 槽位 Schema 随 `a2a-t-resources` 内置,classpath 资源源下开箱可用。 +**功能说明**:从自然语言文本按指定 Authorization-T 模板生成授权策略操作提示词报文。执行一次 LLM 槽位提取后确定性渲染模板。 **输入说明** | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | -| text | String | 是 | 自然语言授权描述(操作类型 + 动网操作的授权策略内容) | +| text | String | 是 | 自然语言授权描述(操作类型 + 动网操作的授权策略内容);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | templateUri | TemplateUri | 是 | Authorization-T 模板:`StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT`(`Authorization-T/authorization-policy-management/v1`) | **请求样例** @@ -1471,13 +1381,13 @@ public FilledParamData validateAuthPromptAndDataFilling( **典型场景**:服务端Agent收到Authorization-T报文后校验合规性并提取操作类型与策略列表,据此执行授权策略管理动作。 -**功能说明**:校验一条 Authorization-T 授权提示词报文是否匹配模板与槽位约束,并按调用方 Schema 提取参数(操作类型、策略列表等)。流水线与异常语义同 1.3.12。按各操作类型的字段要求做差异化校验:新增条目须含业务场景/处置类型/操作名称/有效期,修改条目须含策略标识与新有效期,删除条目可为策略标识或条件字段。 +**功能说明**:校验一条 Authorization-T 授权提示词报文是否匹配模板与槽位约束,并按调用方 Schema 提取参数(操作类型、策略列表等)。按各操作类型的字段要求做差异化校验:新增条目须含业务场景/处置类型/操作名称/有效期,修改条目须含策略标识与新有效期,删除条目可为策略标识或条件字段。 **输入说明** | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | -| prompt | String | 是(非空) | 待校验的授权提示词报文文本 | +| prompt | String | 是(非空) | 待校验的授权提示词报文文本;输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema(含 `操作类型`、`策略列表` 等) | | templateUri | TemplateUri | 是 | Authorization-T 模板(前缀段必须为 `Authorization-T`) | @@ -1536,13 +1446,13 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) **典型场景**:客户端Agent的场景自动路由入口:不指定模板,由SDK识别用户输入所属业务场景并生成对应报文,适合场景集合已知、希望简化接入的场景。 -**功能说明**:通用任务提示词生成入口,**经场景识别**自动定位模板:自然语言或结构化输入先由 LLM 识别业务场景,再按场景对应的内置模板完成槽位提取与渲染。需要显式指定模板时使用 1.3.1x 的 FromText / FromDataWithSchema 系列接口。 +**功能说明**:通用任务提示词生成入口,经场景识别自动定位模板:自然语言或结构化输入先由 LLM 识别业务场景,再按场景对应的内置模板完成槽位提取与渲染。 **输入说明** | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | -| userInput | Object | 是 | 任务描述:`String`(自然语言)或 `Map`(结构化输入),统一经 LLM 抽取,无零 LLM 捷径 | +| userInput | Object | 是 | 任务描述:`String`(自然语言)或 `Map`(结构化输入),统一经 LLM 抽取;入参为 `String` 时输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | **请求样例** @@ -1626,15 +1536,15 @@ result.failure() = public PromptComplianceResult checkTaskPrompt(String processedPromptText) ``` -**典型场景**:服务端Agent对收到的任务报文做协议完备性把关(场景/模板/槽位合规),只需通过与失败结论、无需提取参数的场景。 +**典型场景**:服务端Agent对收到的任务报文做协议完备性校验(场景/模板/槽位合规),只需通过与失败结论、无需提取参数的场景。 -**功能说明**:通用任务提示词合规校验入口(服务端):对客户端下发的 processed task prompt 执行场景匹配、模板遵从性校验与槽位校验(是否启用由 `A2AT_PROMPT_COMPLIANCE_ENABLED` 控制)。与 `validateTaskPromptAndDataFilling` 的区别:本接口不提取参数、不接收调用方 Schema,仅给出通过与失败的标准化结论。 +**功能说明**:通用任务提示词合规校验入口(服务端):对客户端下发的 processed task prompt 执行场景匹配、模板遵从性校验与槽位校验。与 `validateTaskPromptAndDataFilling` 的区别:本接口不提取参数、不接收调用方 Schema,仅给出通过与失败的标准化结论。 **输入说明** | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | -| processedPromptText | String | 是 | 客户端下发的 A2A-T 协议报文文本(`Message.metadata` 中以扩展 URI 为 key 的值) | +| processedPromptText | String | 是 | 客户端下发的 A2A-T 协议报文文本(`Message.metadata` 中以扩展 URI 为 key 的值);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | **请求样例** @@ -1682,10 +1592,10 @@ if (result.success()) { 成功时: ```text -prompt check passed +result.success() = true ``` -失败时(报文缺少必填槽位,错误码与阶段取自服务端合规流水线): +失败时: ```text result.success() = false @@ -1693,7 +1603,51 @@ result.failure() = { code=slot.not_provided, message=输入中未提供「任务对象」。, stage=slot_validation } ``` - - - - +## 1.4 错误码目录 + +**错误码分类**:BUSINESS = 调用方可行动的预期业务失败,由 `A2ATBusinessException` 子类携带;INFRA = 基础设施失败,由普通 `A2ATError` 携带。 + +| 错误码 | 类别 | 消息(zh-CN) | 消息(en-US) | +| ----------------------------------------- | -------- | ------------------------------------------------------------ | ------------------------------------------------------------ | +| `template.not_found` | BUSINESS | 模板「{template_uri}」不支持语言「{language}」,请检查模板标识与语言配置 | Template '{template_uri}' does not support language '{language}'; check the template URI and language setting | +| `template.render_failed` | BUSINESS | 模板「{template_uri}」渲染失败:{reason} | Failed to render template '{template_uri}': {reason} | +| `template.load_failed` | INFRA | 模板资源「{resource_path}」读取失败 | Failed to read template resource '{resource_path}' | +| `slot.schema_not_found` | BUSINESS | 模板「{template_uri}」缺少参数定义文件(语言「{language}」) | Template '{template_uri}' is missing its slot schema (language '{language}') | +| `slot.not_provided` | BUSINESS | 输入中未提供「{slot_label}」。 | '{slot_label}' is not provided in the input. | +| `slot.constraint_violated` | BUSINESS | 「{slot_label}」的取值「{actual}」不在允许范围内 | The value of '{slot_label}' ({actual}) is not within the allowed range | +| `slot.semantic_conflict` | BUSINESS | 「{slot_label}」的取值与参数定义冲突:{reason} | The value of '{slot_label}' conflicts with the slot definition: {reason} | +| `slot.fabricated_value` | BUSINESS | 「{slot_label}」的取值「{actual}」是占位内容,不是有效值 | The value of '{slot_label}' ({actual}) is placeholder content, not a valid value | +| `slot.cross_scenario_pollution` | BUSINESS | 「{slot_label}」的取值混入了其他场景的内容 | The value of '{slot_label}' contains content from a different scenario | +| `slot.insufficient_grounding` | BUSINESS | 「{slot_label}」的取值缺少充分依据 | The value of '{slot_label}' lacks sufficient grounding | +| `slot.rule_violation` | BUSINESS | 「{slot_label}」的取值不符合校验规则。 | The value of '{slot_label}' violates the validation rules. | +| `input.text_too_long` | BUSINESS | 输入文本长度 {actual_length} 超过上限 {max_chars}(A2AT_INPUT_TEXT_MAX_CHARS) | Input text length {actual_length} exceeds the maximum of {max_chars} (A2AT_INPUT_TEXT_MAX_CHARS) | +| `content.param_missing` | BUSINESS | 「{section_label}」未填写,请补充该参数的取值 | '{section_label}' is empty; please provide a value | +| `content.entry_field_missing` | BUSINESS | 「{section_label}」第 {index} 条缺少必填字段「{field_label}」 | Entry {index} of '{section_label}' is missing required field '{field_label}' | +| `content.format_error` | BUSINESS | 「{section_label}」的取值格式不符合要求:{reason} | The format of '{section_label}' is invalid: {reason} | +| `content.value_not_allowed` | BUSINESS | 「{section_label}」的取值「{actual}」不在允许范围内 | The value of '{section_label}' ({actual}) is not allowed | +| `content.semantic_conflict` | BUSINESS | 「{section_label}」存在语义冲突:{reason} | '{section_label}' has a semantic conflict: {reason} | +| `content.rule_violation` | BUSINESS | 「{section_label}」的取值不符合校验规则。 | The value of '{section_label}' violates the validation rules. | +| `scenario.not_matched` | BUSINESS | 输入内容无法匹配任何已知场景:{reason} | The input does not match any known scenario: {reason} | +| `llm.not_configured` | BUSINESS | 未配置 LLM 客户端,无法执行该操作(请检查 A2AT_LLM_* 配置) | No LLM client is configured; check the A2AT_LLM_* settings | +| `llm.invocation_failed` | BUSINESS | LLM 调用失败(提供方 {provider}):{reason} | LLM invocation failed (provider {provider}): {reason} | +| `llm.response_invalid` | BUSINESS | LLM 返回内容不符合要求({step} 步骤),请重试 | The LLM response is invalid (step: {step}); please retry | +| `negotiation.invalid_input` | BUSINESS | 输入的协商内容无效:{reason} | The negotiation input is invalid: {reason} | +| `negotiation.invalid_context_id` | BUSINESS | 协商上下文标识「{actual}」不是合法的 UUID | The negotiation context id '{actual}' is not a valid UUID | +| `negotiation.round_exceeded` | BUSINESS | 协商轮次 {round} 已超过上限 {max_rounds} | Negotiation round {round} exceeds the maximum of {max_rounds} | +| `negotiation.type_mismatch` | BUSINESS | 报文内容属于「{implied}」协商,与声明的模板类型「{declared}」不符 | The message implies '{implied}' negotiation but the declared template type is '{declared}' | +| `negotiation.phase_mismatch` | BUSINESS | 报文阶段与声明的模板阶段不符({implied} vs {declared}) | The message phase does not match the declared template phase ({implied} vs {declared}) | +| `negotiation.conclusion_mismatch` | BUSINESS | 报文结论为「{actual}」,与该方法的预期「{expected}」不符 | The message conclusion is '{actual}' but '{expected}' is expected for this method | +| `negotiation.content_invalid` | BUSINESS | 协商内容字段「{field}」无效:{reason} | The negotiation content field '{field}' is invalid: {reason} | +| `negotiation.field_missing` | BUSINESS | 协商报文缺少必填字段「{field}」 | The negotiation message is missing required field '{field}' | +| `negotiation.content_extract_failed` | BUSINESS | 无法从文本提取协商内容({field}):{reason} | Failed to extract negotiation content from text ({field}): {reason} | +| `negotiation.conclusion_content_mismatch` | BUSINESS | 结论为「{conclusion}」,但「{section_label}」未表达该结论应携带的内容 | The conclusion is '{conclusion}' but '{section_label}' does not state the content the conclusion requires | +| `negotiation.missing_result_content` | BUSINESS | 「{section_label}」板块缺少结论应携带的内容 | The '{section_label}' section is missing the content required by its conclusion | +| `negotiation.mutually_exclusive_sections` | BUSINESS | 互斥板块同时出现:{sections} | Mutually exclusive sections appear together: {sections} | +| `negotiation.constraint_conflict` | BUSINESS | 「{section_label}」与既有约束冲突:{reason} | '{section_label}' conflicts with existing constraints: {reason} | +| `negotiation.field_inconsistency` | BUSINESS | 「{section_label}」内字段取值前后不一致:{reason} | Fields within '{section_label}' are inconsistent: {reason} | +| `negotiation.invalid_time_interval` | BUSINESS | 「{section_label}」的时间区间不合法(开始时间不得晚于结束时间) | The time interval of '{section_label}' is invalid (start must not be later than end) | +| `negotiation.semantic_rejected` | BUSINESS | 协商报文语义校验未通过 | The negotiation message failed semantic validation | +| `negotiation.rule_violation` | BUSINESS | 「{section_label}」不符合协商报文的校验规则。 | '{section_label}' violates the negotiation message validation rules. | +| `infra.config_invalid` | INFRA | 配置项「{key}」无效:{reason} | Invalid configuration '{key}': {reason} | +| `infra.resource_read_failed` | INFRA | 资源「{resource_path}」读取失败 | Failed to read resource '{resource_path}' | +| `infra.internal_error` | INFRA | SDK 内部错误,请联系维护方并提供上下文 | SDK internal error; contact the maintainer with context | diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 3a374106..c06f35bc 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -318,15 +318,31 @@ flowchart TD 复制仓库根目录`env.example`文件内容到`client.env`中(服务端对应`server.env`),配置如下内容 ```properties +# Controls the language used by prompt runtime components. A2AT_LANGUAGE=zh-CN + +# Selects the prompt source backend (classpath | local_file). A2AT_PROMPT_SOURCE_TYPE=classpath + +# Required when A2AT_PROMPT_SOURCE_TYPE=local_file A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_PROMPT_COMPLIANCE_ENABLED=true + +# Selects the LLM provider used by the shared client. A2AT_LLM_PROVIDER=openai + +# Selects the default model name for the configured provider. A2AT_LLM_MODEL=deepseek-chat + +# API key for the configured LLM provider. A2AT_LLM_API_KEY={your_llm_api_key} + +# Base URL for the configured LLM provider. A2AT_LLM_BASE_URL=https://api.deepseek.com + +# Selects the negotiation state store implementation. A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + +# Input limits A2AT_INPUT_TEXT_MAX_CHARS=16384 ``` @@ -334,9 +350,6 @@ A2AT_INPUT_TEXT_MAX_CHARS=16384 > > SDK 通过 OpenAI-compatible 接入外部大模型,`A2AT_LLM_PROVIDER` 当前仅支持 `openai`;接入 DeepSeek 等 OpenAI 兼容服务时,通过 `A2AT_LLM_BASE_URL` 指定服务地址、`A2AT_LLM_MODEL` 指定模型名。 > -> 提示词资源来源由 `A2AT_PROMPT_SOURCE_TYPE` 控制:默认 `classpath` 从 `a2a-t-resources` jar 内置资源加载;配置为 `local_file` 时,通过 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 指定本地资源根目录(相对路径相对于 `.env` 文件所在目录解析)。 -> -> 自由文本输入在任何 LLM 调用之前都会做长度防护:所有接受自然语言 `String` 的 Facade 入口(`FromText` 生成类方法、文本形式的 `generateTaskPrompt`,以及 `checkTaskPrompt`、`validate*PromptAndDataFilling` 等校验入口)在输入超过 `A2AT_INPUT_TEXT_MAX_CHARS` 字符(按 `String.length()` 计)时,会以错误码 `input.text_too_long` 快速失败,避免超长输入撑爆 LLM 上下文。该键默认 `16384`(16×1024);非法或非正值回退默认值并输出警告日志。防护不做截断:是否以及如何缩短输入完全由调用方决定。结构化(`Map`)输入不做该检查。 #### Step3 初始化AgentCard @@ -1424,3 +1437,165 @@ java -jar target/quarkus-app/quarkus-run.jar > 本例在单次 `execute()` 内完结任务(`complete()` 后任务进入终态):`message:send` 同步模式下,客户端一次 HTTP 响应拿到聚合后的最终 Task(中间状态仅持久化在服务端);`message:stream` 流式模式下,客户端逐帧收到 WORKING、artifact、COMPLETED 事件。持续订阅上报的长驻任务写法(循环 `addArtifact`、不调用 `complete`)可参考 `a2a-t-sample` 的 `subscribe_incident` 用例。 > > 本样例基于 a2a-java 官方 REST 参考服务(Quarkus)承载。本仓库 `a2a-t-sample` 模块另提供了不依赖 Quarkus 的嵌入式端到端样例(真实 HTTP+JSON/REST 流式链路,以及协商、授权等场景),详见 [a2a-t-sample/README.zh-CN.md](../../a2a-t-sample/README.zh-CN.md)。 + +## 1.5 加载自定义模板 + +### 1.5.1 背景 + +SDK 在生成和校验 A2A-T 提示词时依赖三类提示词资源:场景目录(scenarios)、槽位定义(slots)和模板正文(templates)。内置资源打包在 `a2a-t-resources` jar 中,默认从 classpath 加载。当业务方需要使用自己的业务场景模板(例如新增业务场景、调整模板措辞或槽位约束)时,可以将资源来源切换为 `local_file`,从本地目录加载自定义模板,无需重新打包 SDK。 + +**自定义模板的能力边界如下**: + +| 资源 | `local_file` 模式 | `classpath` 模式 | +| --- | --- | --- | +| 业务模板/槽位/场景(Task-T、Notification-T、Authorization-T 的 templates/slots/scenarios) | 从本地根目录读取 | 内置资源 | +| 协商模板(Negotiation-T 固定集合)与协商词表(negotiation-vocabulary) | 从 classpath 加载,不可自定义 | 内置资源 | +| LLM 指令提示词(prompts 目录) | 从 classpath 加载,不可自定义 | 内置资源 | + +**关键约束**: + +1. **构造时校验**:`A2ATClient` 与 `A2ATServer` 在构造时即校验提示词资源配置。`local_file` 模式下 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 未设置、路径不存在或不是目录时,构造时直接失败并给出明确的错误信息。 +2. **初始化载入**:SDK 在构造时一次性读取本地根目录下 `templates/`、`slots/`、`scenarios/` 的全部内容形成只读快照,运行期不再访问文件系统;修改本地文件后需重启 SDK 进程才能生效。 + +### 1.5.2 实施步骤 + +#### Step1 准备本地资源根目录 + +参考 `a2a-t-resources/src/main/resources/prompt_resources` 的目录结构,在本地根目录下按需放置资源文件: + +```text +<本地资源根目录>/ +├── templates/ +│ └── /<场景路径>/v1/<语言>/template.md +│ 例如 templates/Task-T/network-layer/ran-energy-saving/v1/zh-CN/template.md +├── slots/ +│ └── /<场景路径>/v1/<语言>/slot.json +└── scenarios/ + └── <语言>/scenarios.json +``` + +说明: + +1. `` 支持 `Task-T`、`Notification-T`、`Authorization-T`;模板版本段固定为 `v1`;内置语言为 `zh-CN` 与 `en-US`。 +2. `<场景路径>` 支持两种形态: + - 带领域前缀(推荐,与内置资源一致):`network-layer/<场景编码>`,例如 `network-layer/ran-energy-saving`; + - 无领域前缀:`<场景编码>`,例如 `authorization-policy-management`。 + + 业务代码按裸场景编码(如 `ran-energy-saving`)检索模板时,SDK 优先在 `/network-layer/<编码>/v1/<语言>/` 下查找,再回退到 `/<编码>/v1/<语言>/`;也可以直接使用带 `/` 的完整场景路径(如 `network-layer/ran-energy-saving`)检索。 +3. 本地根目录中若存在 `prompts/`、`templates/Negotiation-T/`、`negotiation-vocabulary/` 目录,构造时会被忽略并输出告警日志(这些资源恒从 classpath 加载)。 + +#### Step2 编写资源文件 + +**模板正文 template.md**:Markdown 文件,使用 `{{槽位名}}` 占位符引用槽位,例如: + +```markdown +## 操作类型(Operation Type) + +{{操作类型}}(必填) +``` + +**槽位定义 slot.json**:JSON Schema(draft 2020-12)格式,通过 `properties` 声明各槽位的类型、`description`、`examples`,可用扩展字段 `x-a2at-value-constraint` 补充取值约束(供 LLM 抽取与语义校验使用),例如: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "操作类型": { + "type": "string", + "description": "请提供操作类型,取值范围:创建、修改", + "examples": ["创建"], + "x-a2at-value-constraint": "取值范围:创建、修改" + } + } +} +``` + +**场景目录 scenarios.json**:顶层为 `scenarios` 数组,每项包含 `scenario_code`、`scenario_name`、`description`、`example` 字段,例如: + +```json +{ + "scenarios": [ + { + "scenario_code": "ran-energy-saving", + "scenario_name": "能效优化任务下发", + "description": "用于生成电信网管系统中的能效优化任务的 A2A-T 任务请求。", + "example": "实现松山湖园区 30%的能耗降低,同时确保速率保障不低于 10Mbps。" + } + ] +} +``` + +#### Step3 配置资源来源 + +修改 `client.env`(服务端对应 `server.env`),将资源来源切换为本地文件并指定根目录: + +```properties +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=local_file +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=/opt/a2at/prompt_resources +``` + +说明: + +1. `A2AT_PROMPT_SOURCE_TYPE` 取值为 `classpath`(默认)或 `local_file`,其他取值在构造时报 `Unsupported prompt source type`。 +2. `local_file` 模式下 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 必填:未设置时报错并提示设置 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR`;路径不存在或不是目录时同样装配失败。 +3. 本地根目录的相对路径按 `.env` 文件所在目录解析,建议使用绝对路径。 +4. `classpath` 模式下即使配置了本地根目录也会被忽略,并输出告警日志。 + +**templateUri 回填** + +`generateTaskPromptFromText` 接口跳过场景识别,模板由调用方通过 `templateUri` 显式指定。`templateUri` 与本地目录一一对应,回填规则如下: + +```text +本地文件:<本地资源根目录>/templates////<语言>/template.md +templateUri:// +``` + +例如本地文件为 `<本地资源根目录>/templates/Task-T/network-layer/site-inspection/v1/zh-CN/template.md` 时,对应的 `templateUri` 为 `Task-T/network-layer/site-inspection/v1`。 + +```java +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; + +// 构造方式:解析原始 URI 字符串 +TemplateUri templateUri = TemplateUri.parse("Task-T/network-layer/site-inspection/v1").orElseThrow(); + +MetadataContent metadata = client.generateTaskPromptFromText("请对xx基站开展现场巡检,重点检查告警和性能指标", templateUri); +``` + +如需覆盖内置模板(如 `StandardTemplates.PRIVATE_LINE_COMPLAINT`),在本地根目录下按相同相对路径放置 `template.md` 与 `slot.json`,代码中继续使用原有常量即可。注意 `local_file` 模式下业务模板只从本地根目录读取、不回退 classpath,`templateUri` 对应的文件缺失时接口抛出 `PromptGenerationException`,错误码为 `template_not_found`。 + +#### Step4 验证与排障 + +启动客户端或服务端后,可按以下方式确认与排障: + +1. **资源缺失**:模板未找到时抛出 `ResourceNotFoundException`(校验链路错误码为 `validation_prompt_resource_not_found`),异常提示中包含期望的文件路径,按提示补齐目录结构即可。 +2. **内容未更新**:修改本地文件后不重启进程不生效,重启 SDK 进程后重新构造。 +3. **协商与 LLM 资源不可定制**:`Negotiation-T` 模板只支持内置的固定集合,集合之外的协商模板 URI 会被跳过并输出告警。 + +## 1.6 配置项列表 + +SDK提供的样例配置文件为`env.example`,A2A-T Client和A2A-T Server构造时均需拷贝文件至对应的`client.env`、`server.env`,配置项含义如下: + +| 配置项 | 说明 | +| ------------------------------------- | ------------------------------------------------------------ | +| `A2AT_LANGUAGE` | 提示词资源语言,内置 `zh-CN` 与 `en-US`,默认 `en-US` | +| `A2AT_PROMPT_SOURCE_TYPE` | 提示词资源来源,支持 `classpath` 和 `local_file`,默认 `classpath` | +| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | 本地提示词资源根目录;`local_file` 模式下必填,未设置或路径不存在时装配期快速失败;仅业务内容(Task-T/Notification-T/Authorization-T 的 templates/slots/scenarios)从该根读取,协商资源与 LLM prompt 恒从 classpath 加载 | +| `A2AT_INPUT_TEXT_MAX_CHARS` | 自由文本输入(FromText 生成与报文校验入口)的最大字符数,超限快速失败并报错误码 `input_text_too_long`,默认 `16384`;不涉及 LLM 调用的结构化数据不受此限制 | +| `A2AT_LLM_PROVIDER` | LLM 协议支持类型,当前仅支持 `openai` | +| `A2AT_LLM_MODEL` | 模型名称 | +| `A2AT_LLM_API_KEY` | LLM API Key | +| `A2AT_LLM_BASE_URL` | LLM 服务地址(OpenAI-compatible) | +| `A2AT_LLM_MAX_TOKENS` | 补全调用的最大生成 token 数,示例值 `2000`,留空时使用提供方默认 | +| `A2AT_LLM_TEMPERATURE` | 采样温度,示例值 `0`,留空时使用提供方默认 | +| `A2AT_LLM_TIMEOUT_SECONDS` | LLM 请求超时时间(秒),示例值 `60`,留空时使用提供方默认 | +| `A2AT_LLM_HISTORY_WINDOW` | 保留的会话历史消息条数,默认 `10` | +| `A2AT_LLM_REASONING_EFFORT` | 推理力度,取值 `none`/`minimal`/`low`/`medium`/`high`/`xhigh`,留空时不设置 | +| `A2AT_LLM_DISABLE_SYSTEM_PROXY` | 是否绕过系统 HTTP 代理访问 LLM,默认 `false` | +| `A2AT_LLM_SESSION_MAX_TOTAL` | 会话跟踪总数上限,默认 `300` | +| `A2AT_LLM_SESSION_MAX_PER_PROVIDER` | 单提供方会话跟踪数上限,默认 `100` | +| `A2AT_LLM_MAX_ATTEMPTS` | 可重试 LLM 步骤的最大尝试次数,取值范围 1-10(越界自动裁剪),默认 `3` | +| `A2AT_NEGOTIATION_STATE_STORE_TYPE` | 协商状态存储,当前支持 `in_memory` | diff --git "a/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" "b/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" deleted file mode 100644 index 23c7eb8e..00000000 --- "a/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" +++ /dev/null @@ -1,172 +0,0 @@ -# 1 a2a-t-sdk-java 用户指南 - -## 1.1 特性介绍 -### 1.1.1 什么是A2A-T? -A2A-T(Agent-to-Agent Telecom)是基于A2A协议推出的电信领域多智能体互联协议,专为电信领域复杂协同场景设计。 -业界通用智能体互联协议主要关注智能体互联和交互框架,对业务场景和具体交互内容关注不足,导致任务完成的成功率不高。电信领域的业务场景复杂、要求高,运维智能体互联和协同需要有专门的协议进行支撑。A2A-T方案基于A2A协议,重点针对电信领域业务流相关的信息模型、任务协商、协作安全等增强能力进行应用扩展。 - -### 1.1.2 A2A-T SDK与A2A SDK的关系 - -A2A-T协议是基于A2A协议的扩展,针对协议扩展的内容提供了A2A-T SDK,支撑快速构建满用于电信领域复杂协同场景的智能体。A2A-T SDK独立于A2A SDK,通过同时集成A2A-T SDK与A2A SDK可以构建支持A2A-T协议的agent,支持电信领域多agent之间的确定性、高可靠、高效及安全协同。 - -### 1.1.3 能力介绍 - -a2a-t-sdk-java 是 A2A-T 协议的 Java SDK 实现,提供客户端提示词生成、服务端提示词校验和协商运行时能力。 - -主要能力包括: - -- **客户端提示词生成**:通过 `A2ATClient` 将自然语言或结构化输入转换为A2A-T协议报文。 -- **服务端提示词校验**:通过 `A2ATServer` 校验A2A-T协议报文 的场景、槽位和语义一致性。 -- **协商流程**:支持 `information`协商。 -- **模块化 Maven 工程**:按 core、resources、llm、prompt、negotiation、client、server、sample 分层组织。 -- **A2A Java 集成样例**:`a2a-t-sample` 基于 `a2a-java v1.0.0.Beta1` 演示真实 HTTP+JSON/REST 链路。 - -## 1.2 应用场景 - -Java SDK 通常用于以下场景: - -1. Java 客户端 Agent 接收用户意图,生成 A2A-T 协议报文。 -2. Java 服务端 Agent 接收 A2A 请求,校验 prompt 后进入业务执行。 -3. 客户端和服务端通过协商上下文进行多轮信息补充或任务可行性确认。 -4. 与注册中心配合,实现 AgentCard 注册、查询和 A2A 调用地址解析。 - -## 1.3 环境要求 - -| 项目 | 要求 | -| --- | --- | -| JDK | 17+ | -| 构建工具 | Maven 3.8+ | -| LLM | 可访问的 OpenAI 服务及 API Key | -| 注册中心 | 运行 Java sample 时需要先启动 registry-center | -| A2A Java | sample 基于 `a2a-java v1.0.0.Beta1` | - -## 1.4 编译安装 - -在仓库根目录执行: - -```bash -cd {项目路径}/a2a-t-sdk-java -mvn -DskipTests package -``` - -如需使用本地 Maven 仓库目录: - -```bash -mvn "-Dmaven.repo.local=.mvn/repository" -DskipTests package -``` - -运行测试: - -```bash -mvn test -``` - -## 1.5 准备配置 - -仓库根目录提供 `client.env` 和 `server.env`,可直接按本地环境修改。 - -客户端配置示例: - -```properties -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=classpath -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY={your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -REGISTRY_CENTER_HOST=127.0.0.1 -REGISTRY_CENTER_PORT=5001 -``` - -服务端配置示例: - -```properties -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=classpath -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY={your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -A2AT_SAMPLE_HOST=127.0.0.1 -A2AT_SAMPLE_PORT=26335 -REGISTRY_CENTER_HOST=127.0.0.1 -REGISTRY_CENTER_PORT=5001 -``` - -## 1.6 快速运行示例 - -Java 示例模块位于 `a2a-t-sdk-java/a2a-t-sample`。 - -### 1.6.1 前置条件 - -1. 启动注册中心服务。 -2. 确认 `server.env` 中的 `REGISTRY_CENTER_HOST` 和 `REGISTRY_CENTER_PORT` 指向注册中心。 -3. 确认 `client.env` 中的注册中心地址与服务端一致。 -4. 填写 `client.env` 和 `server.env` 中的 `A2AT_LLM_API_KEY`。 - -### 1.6.2 编译 sample - -```bash -cd {项目路径}/a2a-t-sdk-java -mvn "-Dmaven.repo.local=.mvn/repository" -pl a2a-t-sample -am -DskipTests package -``` - -### 1.6.3 启动服务端 - -```bash -java @a2a-t-sample/target/server.javaargs.txt -``` - -服务端启动后会: - -- 构造示例 AgentCard。 -- 向注册中心注册 AgentCard。 -- 启动本地 A2A HTTP 服务。 -- 等待客户端请求并校验 A2A-T prompt。 - -### 1.6.4 启动客户端 - -另开终端执行: - -```bash -java @a2a-t-sample/target/client.javaargs.txt -``` - -客户端会: - -1. 从 `sample/subscribe_incident/client/scenario.json` 读取示例输入。 -2. 根据配置查询注册中心中的目标 AgentCard。 -3. 使用 `A2ATClient` 生成 processed task prompt。 -4. 构造 A2A HTTP+JSON/REST 请求。 -5. 接收服务端任务状态、消息和 artifact 事件。 - - -## 1.7 配置文件速查 - -| 配置项 | 说明 | -| --- | --- | -| `A2AT_LANGUAGE` | 提示词资源语言,常用 `zh-CN` | -| `A2AT_PROMPT_SOURCE_TYPE` | 提示词资源来源,支持 `classpath` 和 `local_file`,默认 `classpath` | -| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | 本地提示词资源根目录;`local_file` 模式下必填,未设置或路径不存在时装配期快速失败;仅业务内容(Task-T/Notification-T/Authorization-T 的 templates/slots/scenarios)从该根读取,协商资源与 LLM prompt 恒从 classpath 加载 | -| `A2AT_LLM_PROVIDER` | LLM 提供方,sample 使用 `openai` | -| `A2AT_LLM_MODEL` | 模型名称 | -| `A2AT_LLM_BASE_URL` | LLM 服务地址 | -| `A2AT_LLM_API_KEY` | LLM API Key | -| `A2AT_NEGOTIATION_STATE_STORE_TYPE` | 协商状态存储,当前支持 `in_memory` | -| `A2AT_SAMPLE_HOST` | sample 服务端监听地址 | -| `A2AT_SAMPLE_PORT` | sample 服务端监听端口 | -| `REGISTRY_CENTER_HOST` | 注册中心地址 | -| `REGISTRY_CENTER_PORT` | 注册中心端口 | - -## 1.8 约束与限制 - -1. 当前版本为 Alpha,接口和模块组织可能随版本演进调整。 -2. 协商状态当前使用内存存储,不提供跨进程持久化。 -3. `A2ATClient` 和 `A2ATServer` 不自动查找 `.env`,调用方必须显式传入配置文件路径。 -4. SDK 不提供认证鉴权、密钥管理和 AgentCard 注册中心服务能力。 -5. sample 依赖注册中心完成 AgentCard 注册和查询,单独运行客户端前需要先启动服务端并完成注册。 - From e85c41cb35a3d094fb6631ebdfbc83637301ed0a Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Sat, 29 Aug 2026 18:00:32 +0800 Subject: [PATCH 247/295] fix(doc): refresh style of API reference Signed-off-by: zhangwenjun-huawei --- "docs/zh/API\345\217\202\350\200\203.md" | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git "a/docs/zh/API\345\217\202\350\200\203.md" "b/docs/zh/API\345\217\202\350\200\203.md" index 6b69a962..b0e509e9 100644 --- "a/docs/zh/API\345\217\202\350\200\203.md" +++ "b/docs/zh/API\345\217\202\350\200\203.md" @@ -32,10 +32,10 @@ A2A-T SDK 的对外 仅提供两个入口:客户端入口 `A2ATClient`(提 **公共数据类型与约定** - **协商会话上下文:**`NegotiationContext(id, round, maxRounds)`(id 为 UUID 形态、round 从 1 起、默认轮次 `DEFAULT_MAX_ROUNDS = 5`),随消息 metadata 传输、不经 LLM;`NegotiationContext.of(id, round)` 使用默认轮次,`nextRound()` 推进轮次。 -- **异常体系:**所有 SDK 处理失败均为 `A2ATError` 子类,捕获 `A2ATError` 即可覆盖全部处理失败,`getCode()` 获取机器可读错误码。所有消息均由 SDK 按错误码模板渲染,语言跟随 `A2AT_LANGUAGE`。如下业务异常均继承 `A2ATBusinessException`,额外提供 `getFacts()` 返回渲染消息所依据的结构化事实值; +- **异常体系:** 所有 SDK 处理失败均为 `A2ATError` 子类,捕获 `A2ATError` 即可覆盖全部处理失败,`getCode()` 获取机器可读错误码。所有消息均由 SDK 按错误码模板渲染,语言跟随 `A2AT_LANGUAGE`。如下业务异常均继承 `A2ATBusinessException`,额外提供 `getFacts()` 返回渲染消息所依据的结构化事实值; - 模板生成失败抛 `PromptGenerationException`(Task-T / Notification-T / Authorization-T)或 `NegotiationGenerationException`(Negotiation-T)。 - 模板校验+提参失败抛 `ContentValidationException`(Task-T / Notification-T / Authorization-T)或 `NegotiationParamExtractionException`(Negotiation-T)。 -- **TemplateUri:**模板 URI 值类型,推荐用 `net.openan.a2at.sdk.core.model.StandardTemplates` 常量构造;来自外部的字符串用 `TemplateUri.parse(String)` 解析,返回 `Optional` 且不抛异常,当前支持的TemplateUri常量如下: +- **TemplateUri:** 模板 URI 值类型,推荐用 `net.openan.a2at.sdk.core.model.StandardTemplates` 常量构造;来自外部的字符串用 `TemplateUri.parse(String)` 解析,返回 `Optional` 且不抛异常,当前支持的TemplateUri常量如下: | 常量名称 | 含义 | TemplateURI | | ---- | ---- | ---- | From dbcf8b46ce6348da4361432500b390b1366cc96f Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Sun, 30 Aug 2026 01:18:30 +0800 Subject: [PATCH 248/295] fix(doc): optimize the content of the document Signed-off-by: zhangwenjun-huawei --- README.md | 4 +- README_zh.md | 6 +- docs/en/API_Reference.md | 37 +- docs/en/developer_guide.md | 374 +++++++++++------ "docs/zh/API\345\217\202\350\200\203.md" | 39 +- ...00\345\217\221\346\214\207\345\215\227.md" | 389 +++++++++++------- 6 files changed, 527 insertions(+), 322 deletions(-) diff --git a/README.md b/README.md index 7d22027e..af0cb48c 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Each module's `src/test/java` mirrors the main package structure, covering the n ### Run the First Demo in Three Steps -Taking the `subscribe_incident` (incident subscription) scenario as an example: the client generates a Notification-T task prompt from natural-language input and sends it to the server over a real HTTP A2A link; the server validates the message, establishes the event subscription, and pushes notifications. Run all commands from the **repository root**: +Taking the `subscribe_incident` (incident subscription) scenario as an example: the client generates a Notification-T task prompt based on natural-language input and sends it to the server over a real HTTP A2A link; the server validates the message, establishes the event subscription, and pushes notifications. Run all commands from the **repository root**: ```bash # 1. Build (generates a2a-t-sample/target/sample.args) @@ -154,7 +154,7 @@ For more runnable samples (end-to-end negotiation, service recovery, authorizati Before use, it is recommended to confirm the following limitations: - The built-in LLM invocation chain is unified externally as an OpenAI adaptation layer. -- Prompt resources currently only support local files. +- LLM prompt resources are built in and do not support customization or extension. - Negotiation state storage currently only provides an in-memory implementation and does not guarantee persistence. - The bundled resources and language coverage are limited, and do not include remote resource loading capabilities such as `registry-center`. - This document primarily introduces the SDK itself, and does not cover the CLI, hosted services, deployment processes, or ready-to-use application solutions. diff --git a/README_zh.md b/README_zh.md index 0352f260..9cc4bcd3 100644 --- a/README_zh.md +++ b/README_zh.md @@ -61,7 +61,7 @@ flowchart LR | 能力 | 说明 | | --- | --- | -| 任务提示词生成(客户端) | 覆盖输入归一化、场景识别、槽位提取与模板渲染,支持自然语言与结构化数据两种输入; | +| 任务提示词生成(客户端) | 覆盖输入归一化、场景识别、槽位提取与模板渲染,支持自然语言与结构化数据两种输入 | | 报文校验与提参(服务端) | 对符合 SDK 格式的任务提示词执行元数据解析、槽位提取与语义校验,按 Schema 提取参数并返回缺失/非法槽位明细 | | 多轮协商&协商状态管理 | 支持 `information` / `feasibility` / `target` 三类协商,提供模板驱动的协商报文生成;协商类型、运行时状态机与状态存储(当前提供内存实现) | | 资源组织 | 内置提示词资源(`prompts` / `scenarios` / `slots` / `templates`)位于 `a2a-t-resources`,支持 `classpath` 与本地文件两种加载方式 | @@ -99,7 +99,7 @@ flowchart LR ### 三步跑通首个 Demo -以 `subscribe_incident`(事件订阅)场景为例:客户端将自然语言输入生成 Notification-T 任务提示词,经真实 HTTP A2A 链路发送给服务端;服务端校验报文、建立事件订阅并推送通知。以下命令均在**仓库根目录**执行: +以 `subscribe_incident`(事件订阅)场景为例:客户端基于自然语言输入生成 Notification-T 任务提示词,经真实 HTTP A2A 链路发送给服务端;服务端校验报文、建立事件订阅并推送通知。以下命令均在**仓库根目录**执行: ```bash # 1. 构建(生成 a2a-t-sample/target/sample.args) @@ -154,7 +154,7 @@ mvn test 使用前建议先确认以下限制: - 内置 LLM 调用链对外统一为 OpenAI 适配层。 -- 提示词资源目前仅支持本地文件。 +- 大模型提示词资源已内置,不支持自定义扩展。 - 协商状态存储目前仅提供内存实现,不保证持久化。 - 随包资源与语言覆盖有限,不包含 `registry-center`(注册中心)等远程资源加载能力。 - 本文档主要介绍 SDK 本身,不涉及 CLI、托管服务、部署流程或可直接使用的应用方案。 diff --git a/docs/en/API_Reference.md b/docs/en/API_Reference.md index 3714e348..e934867c 100644 --- a/docs/en/API_Reference.md +++ b/docs/en/API_Reference.md @@ -35,9 +35,18 @@ The A2A-T SDK exposes exactly two public entry points: the client entry `A2ATCli - **Exception hierarchy:** every SDK processing failure is a subclass of `A2ATError`; catching `A2ATError` covers all processing failures, and `getCode()` returns the machine-readable error code. All messages are rendered by the SDK from per-code message templates and follow `A2AT_LANGUAGE`. The following business exceptions extend `A2ATBusinessException` and additionally expose `getFacts()` carrying the structured fact values behind the rendered message: - Generation failures throw `PromptGenerationException` (Task-T / Notification-T / Authorization-T) or `NegotiationGenerationException` (Negotiation-T); - Validation-plus-extraction failures throw `ContentValidationException` (Task-T / Notification-T / Authorization-T) or `NegotiationParamExtractionException` (Negotiation-T). +- **SlotValidationError:** per-slot validation error details, returned with validation-failure exceptions or failure payloads (the `getErrors()` / `failedParameters()` / `errors()` mentioned in the output descriptions of the APIs all refer to this definition): + +| Field | Type | Description | +| ---- | ---- | ---- | +| slotName | String | Name of the slot in error | +| code | String | Slot-level error code, taken from the 1.4 error code list, e.g. `slot.not_provided`, `content.param_missing`, `content.entry_field_missing`, `content.format_error` | +| message | String | Human-readable error description, rendered by the SDK from the message template of the error code and following `A2AT_LANGUAGE` | +| facts | Map<String, String> | Structured fact values behind the rendered message (e.g. `section_label`, `index`, `field_label`), may be null | + - **TemplateUri:** the template URI value type. Prefer building it with the `net.openan.a2at.sdk.core.model.StandardTemplates` constants; for strings coming from outside the code, parse them with `TemplateUri.parse(String)`, which returns an `Optional` and never throws. The currently supported TemplateUri constants are listed below: -| Constant | Description | Template URI | +| Constant | Description | TemplateUri | | ---- | ---- | ---- | | StandardTemplates.ENERGY_SAVING | Task-T energy-saving task template | Task-T/network-layer/ran-energy-saving/v1 | | StandardTemplates.PRIVATE_LINE_COMPLAINT | Task-T private-line complaint task template | Task-T/network-layer/private-line-complaint/v1 | @@ -57,7 +66,7 @@ The A2A-T SDK exposes exactly two public entry points: the client entry `A2ATCli ## 1.2 Constraints and Limitations - Some APIs involve LLM calls. Control the call rate and concurrency of these APIs according to the concurrency capacity of the connected model service and your business time-limit requirements. -- APIs that involve LLM calls guard the length of text `text` inputs: when the input exceeds the `A2AT_INPUT_TEXT_MAX_CHARS` characters configured in `client.env` or `server.env`, the error code `input_text_too_long` is reported; the default value of this configuration item is 16384 (16×1024). Structured data that involves no LLM calls is not subject to this limit. +- APIs that involve LLM calls guard the length of text `text` inputs: when the input exceeds the `A2AT_INPUT_TEXT_MAX_CHARS` characters configured in `client.env` or `server.env`, the error code `input.text_too_long` is reported; the default value of this configuration item is 16384 (16×1024). Structured data that involves no LLM calls is not subject to this limit. ## 1.3 API Description @@ -98,8 +107,7 @@ NegotiationContext ctx = new NegotiationContext( "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, NegotiationContext.DEFAULT_MAX_ROUNDS); MetadataContent propose = client.generateNegotiationProposePromptFromText( - "Please provide the following missing information: 1. Access port name: please provide the service access port name; " - + "2. Complaint category: private line interruption or poor private line quality. " + "Please provide the following missing information: complaint category: private line interruption or poor private line quality. " + "Both parameters are required; diagnosis cannot start without them.", ctx, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); @@ -154,10 +162,7 @@ promptText : Please supplement the relevant content based on . ## Required Information Items -1. Access Port Name: e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1 -2. Complaint Category: e.g. dedicated-line quality degradation -3. Private Line Service Identifier -Relationship between missing items: OR +1. Complaint Category: e.g. dedicated-line quality degradation ``` ### 1.3.2 generateNegotiationAcceptPromptFromText @@ -373,7 +378,7 @@ public MetadataContent generateNegotiationAcceptPromptFromData( **Typical scenarios**: the negotiation responder (usually the client agent) programmatically fills in the parameters per the peer's requested slot list and generates an accept message from structured items to return. -**Function Description**: deterministically generates a negotiation accept message from structured-data input, **without calling the LLM**. `content.conclusion()` must be `ACCEPT`; any other conclusion (including `ABORT`) is rejected with `IllegalArgumentException`. +**Function Description**: deterministically generates a negotiation accept message from structured-data input, **without calling the LLM**. `content.conclusion()` must be `ACCEPT`; any other conclusion (including `ABORT`) is rejected with the `negotiation.conclusion_mismatch` business error. **Input** @@ -414,7 +419,7 @@ On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). E - `negotiation.field_missing` (a required field is missing during rendering) -Programming errors: a null argument or a null context within it throws `NullPointerException`; a mismatched content type or a phase segment that is not `accept-reject` throws `IllegalArgumentException`; a `conclusion` that is not `ACCEPT` is rejected with the `negotiation.conclusion_mismatch` business error. +Programming errors: a null argument or a null context within it throws `NullPointerException`; a mismatched content type or a phase segment that is not `accept-reject` throws `IllegalArgumentException`. A `conclusion` that is not `ACCEPT` is rejected with the `negotiation.conclusion_mismatch` business error. **Response Example** @@ -441,7 +446,7 @@ public MetadataContent generateNegotiationRejectPromptFromData( **Typical scenarios**: the negotiation responder, after programmatically determining that the peer's request cannot be satisfied, generates a reject message from structured items (the items that cannot be provided and the reasons) to return. -**Function Description**: deterministically generates a negotiation reject message from structured-data input, **without calling the LLM**. `content.conclusion()` must be `REJECT`; any other conclusion is rejected with `IllegalArgumentException`. +**Function Description**: deterministically generates a negotiation reject message from structured-data input, **without calling the LLM**. `content.conclusion()` must be `REJECT`; any other conclusion is rejected with the `negotiation.conclusion_mismatch` business error. **Input**: same as [generateNegotiationAcceptPromptFromData](#135-generatenegotiationacceptpromptfromdata), but with the conclusion `REJECT`. Reject content: `InformationEndingContent(REJECT, items)` (items that cannot be provided and the reasons), `TargetEndingContent(REJECT, null, failureReason)` (rejection reason), `FeasibilityEndingContent(REJECT, feasibilitySummary)` (infeasibility conclusion summary). @@ -469,7 +474,7 @@ On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). E - `negotiation.field_missing` (a required field is missing during rendering) -Programming errors: a null argument or a null context within it throws `NullPointerException`; a mismatched content type or a phase segment that is not `accept-reject` throws `IllegalArgumentException`; a `conclusion` that is not `REJECT` is rejected with the `negotiation.conclusion_mismatch` business error. +Programming errors: a null argument or a null context within it throws `NullPointerException`; a mismatched content type or a phase segment that is not `accept-reject` throws `IllegalArgumentException`. A `conclusion` that is not `REJECT` is rejected with the `negotiation.conclusion_mismatch` business error. **Response Example** @@ -502,7 +507,7 @@ public FilledParamData validateProposePromptAndDataFilling( | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | | prompt | String | Yes | Negotiation propose message text to validate (`MetadataContent.promptText()`); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | -| context | NegotiationContext | No | Negotiation context traveling with the message; null is reported as `negotiation.invalid_input` | +| context | NegotiationContext | Yes | Negotiation context traveling with the message; a null value is reported as `negotiation.invalid_input` | | schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema declaring the parameters to extract | | templateUri | TemplateUri | Yes | Propose template | @@ -1501,7 +1506,7 @@ On failure (`success()` is `false`; no exception is thrown, the failure payload | Field | Type | Description | | ---- | ---- | ---- | -| code | String | Machine-readable error code from the error-code catalog, e.g. `scenario.not_matched` (scenario recognition missed), `input.text_too_long` (input length guard), `template.load_failed` (prompt resource load failure), `template.not_found` (template missing), `slot.schema_not_found` (slot schema missing), `slot.not_provided` (required slot missing), `template.render_failed` (rendering failure), `llm.invocation_failed` / `llm.response_invalid` (LLM failures) | +| code | String | Machine-readable error code from the error-code list, e.g. `scenario.not_matched` (scenario recognition missed), `input.text_too_long` (input length guard), `template.load_failed` (prompt resource load failure), `template.not_found` (template missing), `slot.schema_not_found` (slot schema missing), `slot.not_provided` (required slot missing), `template.render_failed` (rendering failure), `llm.invocation_failed` / `llm.response_invalid` (LLM failures) | | message | String | Human-readable failure description | | stage | String | Stage where the failure occurred: `scenario` (scenario recognition), `generation` (template loading/rendering) | @@ -1592,7 +1597,7 @@ On failure (`success()` is `false`; no exception is thrown, the failure payload | Field | Type | Description | | ---- | ---- | ---- | -| code | String | Machine-readable error code from the error-code catalog: `scenario.not_matched` (message parsing/scenario recognition failure), `template.not_found` (template missing), slot-domain codes such as `slot.not_provided` (missing required value), `slot.constraint_violated` (out-of-range value) and `slot.rule_violation` (other slot rule violations), `input.text_too_long` (input length guard) | +| code | String | Machine-readable error code from the error-code list: `scenario.not_matched` (message parsing/scenario recognition failure), `template.not_found` (template missing), slot-domain codes such as `slot.not_provided` (missing required value), `slot.constraint_violated` (out-of-range value) and `slot.rule_violation` (other slot rule violations), `input.text_too_long` (input length guard) | | message | String | Human-readable failure description | | stage | String | Stage where the failure occurred: `prompt_parse` (message parsing), `generation` (template loading), `slot_validation` (slot validation) | @@ -1612,7 +1617,7 @@ result.failure() = { code=slot.not_provided, message='Task Object' is not provided in the input., stage=slot_validation } ``` -## 1.4 Error Code Catalog +## 1.4 Error Code List **Error code categories**: BUSINESS = expected business failures the caller can act on, carried by `A2ATBusinessException` subclasses; INFRA = infrastructure failures, carried by a plain `A2ATError`. diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 0d78d4e4..cd7507b9 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -11,7 +11,7 @@ ### 1.1.1 A2A-T Capabilities A2A-T (Agent-to-Agent Telecom) is a telecom-domain multi-agent interconnection protocol built on the A2A protocol, designed for complex collaboration scenarios in the telecom domain. -Industry-generic agent interconnection protocols focus mainly on agent interconnection and interaction frameworks and pay insufficient attention to business scenarios and concrete interaction content, which results in a low task completion success rate. Business scenarios in the telecom domain are complex and demanding; the interconnection and collaboration of O&M agents require dedicated protocol support. Building on the A2A protocol, the A2A-T solution applies extensions focused on the information models, task negotiation, and collaboration security related to telecom-domain service flows. +Business scenarios in the telecom domain are complex and demanding; the interconnection and collaboration of O&M agents require dedicated protocol support. Building on the A2A protocol, the A2A-T solution applies extensions focused on the information models, task negotiation, and collaboration security related to telecom-domain service flows. a2a-t-sdk-java is a Java SDK for telecom agent collaboration scenarios. It generates, validates, and negotiates task prompts in A2A-T interactions. The SDK is suitable for integration by client agents, server agents, and upper-layer orchestration systems. @@ -63,13 +63,13 @@ The relationships between the components are shown below: ```mermaid flowchart TD subgraph Client["Client Agent"] - A1["A2A-T Client SDK

        Prompt template generation: generateTaskPrompt"] + A1["A2A-T Client SDK

        Prompt template generation: generate*PromptFromText"] A2["A2A Client SDK

        Sends requests over HTTP/REST"] A1 --> A2 end subgraph Server["Server Agent"] - B1["A2A-T Server SDK

        Compliance validation: checkTaskPrompt"] + B1["A2A-T Server SDK

        Compliance validation and parameter extraction: validate*AndDataFilling"] B2["A2A Server SDK

        Receives requests"] B2 --> B1 end @@ -144,72 +144,101 @@ This basic development sample mainly uses the following two A2A-T SDK APIs. In a **1. A2A-T Client SDK** -API definition and function description: recognizes the scenario from the input content and generates the corresponding prompt template +API definition and function description: generates a notification subscription prompt message from natural-language text with the specified Notification-T template (skipping scenario recognition). It runs one LLM slot-extraction step and then renders the template deterministically; the generation phase performs built-in slot schema validation. ```java -public PromptGenerationResult generateTaskPrompt(Object userInput) +public MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri) ``` -`PromptGenerationResult` is a record type with three accessors — `success()`, `promptText()`, and `failure()`; `failure` carries `code`, `message`, and `stage`. - Sample invocation: ```java import java.nio.file.Path; import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; A2ATClient client = new A2ATClient(Path.of("client.env")); -PromptGenerationResult result = client.generateTaskPrompt("Generate an Incident event subscription task: the notification topic is Incident, the subscription levels are critical, medium, high, and low, and the notification data format is DataPart"); +TemplateUri templateUri = TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); -if (result.success()) { - System.out.println(result.promptText()); -} +MetadataContent result = client.generateNotificationPromptFromText( + "I would like to subscribe to service recovery events. The notification data format is as follows: " + + "1. Service recovery plan execution status, possible values: not started, finished; " + + "2. Complaint diagnosis task serial number; 3. OSS-side event serial number; 4. Access port name; " + + "5. Whether OMC automatic recovery is authorized, possible values: yes, no; 6. Service recovery plan name; 7. Service recovery plan details", + templateUri); -/* Output of the generated prompt template +/* Output of the generated notification subscription prompt message (result.promptText(); the actual text varies with the LLM slot-extraction result) ## Subscription Description -Based on the following , , , and information, complete the network-side intelligent fault Incident subscription and reporting task. +Based on the following , , , and information, complete the subscription to and reporting of network-side service recovery events. ## Notification Topic -The name of this topic is "incident" +Service recovery event ## Subscribe Condition -Fault levels are "critical", "medium", "high", "low" +(Optional) +1. Subnet name; e.g. xx subnet; ## Notification Data Format -Report Incident data via DataPart +1. Service recovery plan execution status, possible values: not started, finished +2. Complaint diagnosis task serial number +3. OSS-side event serial number +4. Access port name +5. Whether OMC automatic recovery is authorized, possible values: yes, no +6. Service recovery plan name +7. Service recovery plan details ## Expected Output -1. Subscription result, success or failure +1. Subscription result, possible values: success, failure 2. Reason for subscription failure (optional) +3. After a successful subscription, report messages per the */ ``` **2. A2A-T Server SDK** -API definition and function description: +API definition and function description: validates whether a Notification-T notification subscription prompt message matches the template and slot constraints, and extracts parameters (subscription topic, subscription condition, notification data format, etc.) per the caller's schema. ```java -public PromptComplianceResult checkTaskPrompt(String processedPromptText) +public FilledParamData validateNotificationPromptAndDataFilling( + String prompt, Map schema, TemplateUri templateUri) ``` -`PromptComplianceResult` is a record type with two accessors — `success()` and `failure()` (on validation failure, `failure` carries `code`, `message`, and `stage`). - -Sample invocation: validates the completeness of an A2A-T protocol message +Sample invocation: validates the notification subscription message sent by the client and extracts the subscription parameters ```java import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; A2ATServer server = new A2ATServer(Path.of("server.env")); -PromptComplianceResult result = server.checkTaskPrompt("## Subscription DescriptionBased on the following , , , and information, complete the network-side intelligent fault Incident subscription and reporting task.## Notification TopicThe name of this topic is \"incident\"## Subscribe Condition \n\n Fault levels are \"critical\", \"medium\", \"high\", \"low\" \n\n ## Notification Data Format \n\nReport Incident data via DataPart## Expected Output1. Subscription result, success or failure2. Reason for subscription failure (optional)"); - -if (result.success()) { - System.out.println("prompt check passed"); -} else { - System.out.println(result.failure().message()); +TemplateUri templateUri = TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); + +Map validationSchema = Map.of( + "type", "object", + "properties", Map.of( + "topic", Map.of("type", "string", "description", "Subscription topic (required). The name of the event topic to subscribe to."), + "subscriptionCondition", Map.of( + "type", "string", "description", "Subscription condition (optional). The description of the condition to subscribe to."), + "notificationDataFormat", Map.of( + "type", "string", "description", "Notification data format (required). The description of the notification data format to report.")), + "required", List.of("topic", "notificationDataFormat")); + +// promptText is the text of the notification subscription prompt message generated by the client (see the client sample invocation above) +FilledParamData result = server.validateNotificationPromptAndDataFilling( + promptText, validationSchema, templateUri); + +/* Subscription parameters extracted per the schema (result.data(); an empty subscription condition + means the input in this example carries no subscription condition) +{ + topic=Service recovery event, + subscriptionCondition=, + notificationDataFormat=Service recovery event data includes: service recovery plan execution status (not started, finished), complaint diagnosis task serial number, OSS-side event serial number, access port name, whether OMC automatic recovery is authorized (yes, no), service recovery plan name, service recovery plan details. } +*/ ``` ### 1.4.2 Development Workflow @@ -365,9 +394,9 @@ import org.a2aproject.sdk.spec.AgentInterface; import org.a2aproject.sdk.spec.AgentProvider; import org.a2aproject.sdk.spec.AgentSkill; -private static final String TASK_T_EXT = +private static final String TASK_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; -private static final String NOTIFICATION_T_EXT = +private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; private static AgentCard buildClientAgentCard() { @@ -382,11 +411,11 @@ private static AgentCard buildClientAgentCard() { .extendedAgentCard(false) .extensions(List.of( AgentExtension.builder() - .uri(TASK_T_EXT) + .uri(TASK_T_EXT_URI) .description("Extension of structured prompt Task-T requests.") .build(), AgentExtension.builder() - .uri(NOTIFICATION_T_EXT) + .uri(NOTIFICATION_T_EXT_URI) .description("Extension of structured prompt Notification-T requests.") .build())) .build()) @@ -665,22 +694,29 @@ Both `A2ATClient` and `A2ATServer` take the `.env` file path explicitly at const #### Step6 Generate the A2A-T Template Message -The client uses the A2A-T Client SDK API `generateTaskPrompt` to generate the processed_prompt, and then sends it to the target agent as part of an A2A message. +The client uses the A2A-T Client SDK API `generateNotificationPromptFromText` to generate the Notification-T subscription message (processed_prompt), and then sends it to the target agent as part of an A2A message. ```java import java.nio.file.Path; import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; A2ATClient client = new A2ATClient(Path.of("client.env")); -// Generate the A2A-T prompt -PromptGenerationResult result = client.generateTaskPrompt("Generate an Incident event subscription task: the notification topic is Incident, the subscription levels are critical, medium, high, and low, and the notification data format is DataPart"); -if (!result.success()) { - throw new IllegalStateException(result.failure().code() + ": " + result.failure().message()); -} +// Generate the A2A-T notification subscription prompt message +// (on generation failure a PromptGenerationException is thrown, carrying code and message) +TemplateUri templateUri = + TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); +MetadataContent result = client.generateNotificationPromptFromText( + "I would like to subscribe to service recovery events. The notification data format is as follows: " + + "1. Service recovery plan execution status, possible values: not started, finished; " + + "2. Complaint diagnosis task serial number; 3. OSS-side event serial number; 4. Access port name; " + + "5. Whether OMC automatic recovery is authorized, possible values: yes, no; 6. Service recovery plan name; 7. Service recovery plan details", + templateUri); String processedPrompt = result.promptText(); +String extensionUri = result.extensionUri(); // A2A-T extension URI, used for message metadata and request headers ``` #### Step7 Activate the A2A-T Extension in Request Headers @@ -690,7 +726,7 @@ The A2A protocol conveys the protocol version and extension declarations through | Header | Direction | Required | Value | | ------ | --------- | -------- | ----- | | `A2A-Version` | Request header | Yes | Protocol version, e.g. `1.0` (the client must carry it with every request) | -| `A2A-Extensions` | Request header | No (recommended when using extensions) | Comma-separated list of extension URIs, declaring the extensions used by this request | +| `A2A-Extensions` | Request header | Required when using A2A-T | Comma-separated list of extension URIs, declaring the extensions used by this request | Sample of filling in client request headers: when sending requests with a2a-java, `A2A-Extensions` is passed in through the headers of `ClientCallContext` (protocol headers such as the protocol version are carried by the a2a-java transport layer as required by the protocol) @@ -715,7 +751,8 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.CountDownLatch; import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; import org.a2aproject.sdk.client.Client; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; @@ -732,30 +769,34 @@ import org.a2aproject.sdk.spec.TextPart; String notificationPromptExt = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; -// 1) Generate the A2A-T prompt (see Step6) +// 1) Generate the A2A-T template message (see 1.4.3 Step6) A2ATClient client = new A2ATClient(Path.of("client.env")); -PromptGenerationResult result = client.generateTaskPrompt("Generate an Incident event subscription task: the notification topic is Incident, the subscription levels are critical, medium, high, and low, and the notification data format is DataPart"); -if (!result.success()) { - throw new IllegalStateException(result.failure().code() + ": " + result.failure().message()); -} +TemplateUri templateUri = + TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); +MetadataContent result = client.generateNotificationPromptFromText( + "I would like to subscribe to service recovery events. The notification data format is as follows: " + + "1. Service recovery plan execution status, possible values: not started, finished; " + + "2. Complaint diagnosis task serial number; 3. OSS-side event serial number; 4. Access port name; " + + "5. Whether OMC automatic recovery is authorized, possible values: yes, no; 6. Service recovery plan name; 7. Service recovery plan details", + templateUri); String processedPrompt = result.promptText(); // 2) Build the A2A message carrying the A2A-T extension (the prompt goes into metadata, keyed by the extension URI) Message message = Message.builder() .messageId(UUID.randomUUID().toString()) .role(Message.Role.ROLE_USER) - .parts(new TextPart("Create an intelligent fault incident reporting task")) + .parts(new TextPart("I would like to subscribe to service recovery events")) .metadata(Map.of(notificationPromptExt, processedPrompt)) .build(); MessageSendParams params = MessageSendParams.builder().message(message).build(); -// 3) Declare the A2A-T extension request header through ClientCallContext (see Step7) +// 3) Declare the A2A-T extension request header through ClientCallContext (see 1.4.3 Step7) ClientCallContext callContext = new ClientCallContext(Map.of(), Map.of("A2A-Extensions", notificationPromptExt)); // 4) Send the request with the a2a-java Client and consume task events CountDownLatch done = new CountDownLatch(1); -try (Client a2aClient = Client.builder(agentCard) // org.a2aproject.sdk.spec.AgentCard retrieved and converted in Step4 +try (Client a2aClient = Client.builder(agentCard) .withTransport(RestTransport.class, new RestTransportConfig()) .build()) { a2aClient.sendMessage( @@ -804,7 +845,8 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; import org.a2aproject.sdk.client.Client; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; @@ -820,7 +862,7 @@ import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; import org.a2aproject.sdk.spec.TextPart; public final class ClientSample { - private static final String NOTIFICATION_PROMPT_EXT = + private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; private static final ObjectMapper MAPPER = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); @@ -828,28 +870,32 @@ public final class ClientSample { public static void main(String[] args) throws Exception { // 1) Register the client AgentCard and discover the server AgentCard - registerAgentCard("http://{ip:port}", buildClientAgentCard()); // the client AgentCard defined in Step3 - AgentCard serverAgentCard = toAgentCard( // the conversion defined in Step4 + registerAgentCard("http://{ip:port}", buildClientAgentCard()); // the client AgentCard defined in 1.4.3 Step3 + AgentCard serverAgentCard = toAgentCard( discoverAgent("http://{ip:port}", "Huawei", "RAN Domain Agent")); - // 2) Use the SDK capability to generate the A2A-T prompt + // 2) Use the SDK capability to generate the A2A-T notification subscription prompt message A2ATClient client = new A2ATClient(Path.of("client.env")); - PromptGenerationResult result = client.generateTaskPrompt("Generate an Incident event subscription task: the notification topic is Incident, the subscription levels are critical, medium, high, and low, and the notification data format is DataPart"); - if (!result.success()) { - throw new IllegalStateException(result.failure().code() + ": " + result.failure().message()); - } + TemplateUri templateUri = + TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); + MetadataContent result = client.generateNotificationPromptFromText( + "I would like to subscribe to service recovery events. The notification data format is as follows: " + + "1. Service recovery plan execution status, possible values: not started, finished; " + + "2. Complaint diagnosis task serial number; 3. OSS-side event serial number; 4. Access port name; " + + "5. Whether OMC automatic recovery is authorized, possible values: yes, no; 6. Service recovery plan name; 7. Service recovery plan details", + templateUri); String processedPrompt = result.promptText(); // 3) Build the A2A message carrying the A2A-T extension (request headers declare the extension; the message body carries the A2A-T extension field) Message message = Message.builder() .messageId(UUID.randomUUID().toString()) .role(Message.Role.ROLE_USER) - .parts(new TextPart("Create an intelligent fault incident reporting task")) - .metadata(Map.of(NOTIFICATION_PROMPT_EXT, processedPrompt)) + .parts(new TextPart("I would like to subscribe to service recovery events")) + .metadata(Map.of(NOTIFICATION_T_EXT_URI, processedPrompt)) .build(); MessageSendParams params = MessageSendParams.builder().message(message).build(); ClientCallContext callContext = - new ClientCallContext(Map.of(), Map.of("A2A-Extensions", NOTIFICATION_PROMPT_EXT)); + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", NOTIFICATION_T_EXT_URI)); // 4) Send the request with the a2a-java Client and consume task events CountDownLatch done = new CountDownLatch(1); @@ -921,7 +967,7 @@ public final class ClientSample { #### Step1-Step5 Prerequisites -Steps such as installing dependencies and configuring the LLM can all reference the [client implementation](#step1-install-dependencies). The differences are as follows: +Steps such as installing dependencies and configuring the LLM can all reference the [client implementation](#143-sample-client-development-steps). The differences are as follows: It is recommended that the server agent use the official a2a-java REST reference server (based on Quarkus, which automatically assembles the REST transport, task management, and event queues) to host A2A requests. Add the following dependencies: @@ -997,7 +1043,7 @@ import net.openan.a2at.sdk.server.A2ATServer; A2ATServer server = new A2ATServer(Path.of("server.env")); ``` -- Initialize the AgentCard: a2a-java officially recommends exposing the AgentCard qualified with `@PublicAgentCard` through a CDI producer (built with the Builder; see the complete server code). Reference server AgentCard definition: +- Initialize the AgentCard: a2a-java officially recommends exposing the AgentCard qualified with `@PublicAgentCard` through a CDI producer (built with the Builder; see 1.4.3 Step3 for the construction approach). Reference server AgentCard definition: ```java import java.util.List; @@ -1008,9 +1054,9 @@ import org.a2aproject.sdk.spec.AgentInterface; import org.a2aproject.sdk.spec.AgentProvider; import org.a2aproject.sdk.spec.AgentSkill; -private static final String TASK_T_EXT = +private static final String TASK_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; -private static final String NOTIFICATION_T_EXT = +private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; private static AgentCard buildServerAgentCard() { @@ -1025,12 +1071,12 @@ private static AgentCard buildServerAgentCard() { .extendedAgentCard(false) .extensions(List.of( AgentExtension.builder() - .uri(TASK_T_EXT) + .uri(TASK_T_EXT_URI) .description("Extension of structured prompt TASK-T requests.") .required(false) .build(), AgentExtension.builder() - .uri(NOTIFICATION_T_EXT) + .uri(NOTIFICATION_T_EXT_URI) .description("Extension of structured prompt Notification-T requests.") .required(false) .build())) @@ -1099,7 +1145,7 @@ private static AgentCard buildServerAgentCard() { "Incident Reporting" ], "examples": [ - "## Subscription Description\\nBased on the following \\u003cNotification Topic\\u003e, \\u003cSubscribe Condition\\u003e, \\u003cNotification Data Format\\u003e, and \\u003cExpected Output\\u003e information, complete the network-side intelligent fault Incident subscription and reporting task.\\n## Notification Topic\\nThe name of this topic is \\\"incident\\\"\\n## Subscribe Condition\\nFault level is \\\"high\\\"\\n## Notification Data Format\\nReport Incident data via DataPart\\n## Expected Output\\n1. Subscription result, success or failure\\n2. Reason for subscription failure (optional)" + "## Subscription Description\\nBased on the following \, \, \, and \ information, complete the network-side intelligent fault Incident subscription and reporting task.\\n## Notification Topic\\nThe name of this topic is \\\"incident\\\"\\n## Subscribe Condition\\nFault level is \\\"high\\\"\\n## Notification Data Format\\nReport Incident data via DataPart\\n## Expected Output\\n1. Subscription result, success or failure\\n2. Reason for subscription failure (optional)" ], "inputModes": [ "application/json", @@ -1141,15 +1187,16 @@ private static AgentCard buildServerAgentCard() { #### Step6-Step7 Receive and Validate the Message on the Server -When the server is hosted by a2a-java, protocol parsing, task creation, and event queue management are handled by the SDK. The business side implements the official `AgentExecutor` interface: in the `execute` callback, take the processed task prompt out of the message `metadata` field (keyed by the extension URI), pass it to `A2ATServer.checkTaskPrompt` for validation, and push task status and result events through `AgentEmitter`: +When the server is hosted by a2a-java, protocol parsing, task creation, and event queue management are handled by the SDK. The business side implements the official `AgentExecutor` interface: in the `execute` callback, take the Notification-T subscription message out of the message `metadata` field (keyed by the extension URI), pass it to `A2ATServer.validateNotificationPromptAndDataFilling` for validation and subscription-parameter extraction, and push task status and result events through `AgentEmitter`: ```java -import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.UUID; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; @@ -1158,14 +1205,27 @@ import org.a2aproject.sdk.spec.DataPart; import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.TextPart; -public final class IncidentSubscriptionExecutor implements AgentExecutor { +public final class ServiceRecoverySubscriptionExecutor implements AgentExecutor { - private static final String NOTIFICATION_PROMPT_EXT = + private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + private static final TemplateUri NOTIFICATION_TEMPLATE_URI = + TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); + + private static final Map NOTIFICATION_PARAM_SCHEMA = Map.of( + "type", "object", + "properties", Map.of( + "topic", Map.of("type", "string", "description", "Subscription topic (required). The name of the event topic to subscribe to."), + "subscriptionCondition", Map.of( + "type", "string", "description", "Subscription condition (optional). The description of the condition to subscribe to."), + "notificationDataFormat", Map.of( + "type", "string", "description", "Notification data format (required). The description of the notification data format to report.")), + "required", List.of("topic", "notificationDataFormat")); + private final A2ATServer server; - public IncidentSubscriptionExecutor(A2ATServer server) { + public ServiceRecoverySubscriptionExecutor(A2ATServer server) { this.server = server; } @@ -1174,34 +1234,46 @@ public final class IncidentSubscriptionExecutor implements AgentExecutor { // 1) Take the A2A-T extension message out of the message metadata (keyed by the extension URI) String processedPrompt = extractPrompt(requestContext.getMessage()); if (processedPrompt.isBlank()) { - agentEmitter.reject(statusMessage(requestContext, "missing A2A-T task prompt")); + agentEmitter.reject(statusMessage(requestContext, "missing A2A-T notification prompt")); return; } - agentEmitter.submit(statusMessage(requestContext, "Subscription accepted, starting Incident reporting task")); - - // 2) Use the SDK capability to validate the completeness of the A2A-T protocol message - PromptComplianceResult checkResult = server.checkTaskPrompt(processedPrompt); - if (!checkResult.success()) { - // Validation failed; failure carries code, message, and stage + agentEmitter.submit(statusMessage(requestContext, + "Subscription accepted, starting service recovery event reporting task")); + + // 2) Use the SDK capability to validate the Notification-T subscription message and extract the subscription parameters per the schema + Map filledParams; + try { + FilledParamData checkResult = server.validateNotificationPromptAndDataFilling( + processedPrompt, NOTIFICATION_PARAM_SCHEMA, NOTIFICATION_TEMPLATE_URI); + filledParams = checkResult.data(); + } catch (ContentValidationException error) { + // Validation failed; the exception carries code, message, and the per-slot details from errors() agentEmitter.reject(statusMessage(requestContext, - "Prompt validation failed: " + checkResult.failure().message())); + "Notification prompt validation failed: " + error.getCode() + ": " + error.getMessage())); return; } - // 3) Validation passed; execute the business and push task events through AgentEmitter - agentEmitter.startWork(statusMessage(requestContext, "Incident reporting task in progress")); + // 3) Validation passed; establish the subscription per the extracted subscription parameters + // (topic/subscriptionCondition/notificationDataFormat) and report service recovery events + // (event fields taken from the sample a2a-t-sample/.../service-recovery/server/mock-event-data.json; + // the "service recovery plan details" field is long and omitted here) + agentEmitter.startWork(statusMessage(requestContext, + "Subscribed to [" + filledParams.get("topic") + "], service recovery event reporting in progress")); agentEmitter.addArtifact( List.of(new DataPart(Map.of( - "faultManagement.Incident", Map.of( - "csn", 1673735459373056L, - "name", "LASER_MOD_ERR", - "domain", "PTN", - "priority", "high", - "status", "unacknowledged-and-uncleared")))), - "faultManagement.Incident", - "Incident artifact", + "serviceManagement.ServiceRecoveryEvent", Map.of( + "service recovery plan execution status", "finished", + "complaint diagnosis task serial number", "9de168c0-6179-4778-8b72-4279582c0a3f", + "OSS-side event serial number", "event-id-202606250128", + "access port name", "P781-SZ-PTN7900-23-TPA1EG24-17", + "whether OMC automatic recovery is authorized", "yes", + "service recovery plan name", "tunnel tuning", + "service recovery plan execution end time", "2026-05-11T08:21:46Z")))), + "serviceManagement.ServiceRecoveryEvent", + "Service recovery event artifact", null); - agentEmitter.complete(statusMessage(requestContext, "Incident reporting completed")); + agentEmitter.complete(statusMessage(requestContext, + "Service recovery notification reporting finished, subscription task completed")); } @Override @@ -1213,7 +1285,7 @@ public final class IncidentSubscriptionExecutor implements AgentExecutor { if (message == null || message.metadata() == null) { return ""; } - Object prompt = message.metadata().get(NOTIFICATION_PROMPT_EXT); + Object prompt = message.metadata().get(NOTIFICATION_T_EXT_URI); return prompt == null ? "" : String.valueOf(prompt); } @@ -1233,7 +1305,7 @@ public final class IncidentSubscriptionExecutor implements AgentExecutor { | Header | Direction | Required | Value | | ------ | --------- | -------- | ----- | -| `A2A-Extensions` | Response header | No (recommended when using extensions) | List of extension URIs (comma-separated) actually engaged by the server | +| `A2A-Extensions` | Response header | Required when using A2A-T | List of extension URIs (comma-separated) actually engaged by the server | When the server is hosted by the a2a-java REST transport, the protocol layer (validation of the protocol version and extension declarations in requests, task and event management, and SSE streaming responses) is handled by the SDK transport layer; the business side only needs to push task status and result events through `AgentEmitter`. If the business system implements its own HTTP hosting, it must fill in the `A2A-Extensions` header in responses as shown in the table above. @@ -1255,8 +1327,10 @@ import io.quarkus.runtime.StartupEvent; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import jakarta.enterprise.inject.Produces; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; import org.a2aproject.sdk.server.PublicAgentCard; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.agentexecution.RequestContext; @@ -1275,14 +1349,14 @@ import org.a2aproject.sdk.spec.TextPart; @ApplicationScoped public class ServerSample { - private static final String TASK_T_EXT = + private static final String TASK_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; - private static final String NOTIFICATION_PROMPT_EXT = + private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; private static final ObjectMapper MAPPER = new ObjectMapper(); - // 1) CDI producer: expose the server AgentCard to a2a-java (same definition as in Step3) + // 1) CDI producer: expose the server AgentCard to a2a-java (same definition as the server sample AgentCard in the 1.4.4 prerequisites) @Produces @PublicAgentCard public AgentCard agentCard() { @@ -1297,12 +1371,12 @@ public class ServerSample { .extendedAgentCard(false) .extensions(List.of( AgentExtension.builder() - .uri(TASK_T_EXT) + .uri(TASK_T_EXT_URI) .description("Extension of structured prompt TASK-T requests.") .required(false) .build(), AgentExtension.builder() - .uri(NOTIFICATION_PROMPT_EXT) + .uri(NOTIFICATION_T_EXT_URI) .description("Extension of structured prompt Notification-T requests.") .required(false) .build())) @@ -1326,7 +1400,7 @@ public class ServerSample { // 2) CDI producer: initialize the A2A-T server and wire it into the AgentExecutor @Produces public AgentExecutor agentExecutor() { - return new IncidentSubscriptionExecutor(new A2ATServer(Path.of("server.env"))); + return new ServiceRecoverySubscriptionExecutor(new A2ATServer(Path.of("server.env"))); } // 3) Register the server AgentCard with the registry center at startup (the address depends on the actual deployment) @@ -1344,12 +1418,30 @@ public class ServerSample { } } - // 4) A2A-T task executor: receive and validate A2A-T messages (same as Step6-Step7) - static final class IncidentSubscriptionExecutor implements AgentExecutor { + // 4) A2A-T task executor: receive the Notification-T subscription message, validate it, and extract parameters + // (same implementation as 1.4.4 Step6-Step7) + static final class ServiceRecoverySubscriptionExecutor implements AgentExecutor { + + private static final String NOTIFICATION_T_EXT_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final TemplateUri NOTIFICATION_TEMPLATE_URI = + TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); + + // Parameter extraction schema (same as a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json) + private static final Map NOTIFICATION_PARAM_SCHEMA = Map.of( + "type", "object", + "properties", Map.of( + "topic", Map.of("type", "string", "description", "Subscription topic (required). The name of the event topic to subscribe to."), + "subscriptionCondition", Map.of( + "type", "string", "description", "Subscription condition (optional). The description of the condition to subscribe to."), + "notificationDataFormat", Map.of( + "type", "string", "description", "Notification data format (required). The description of the notification data format to report.")), + "required", List.of("topic", "notificationDataFormat")); private final A2ATServer server; - IncidentSubscriptionExecutor(A2ATServer server) { + ServiceRecoverySubscriptionExecutor(A2ATServer server) { this.server = server; } @@ -1358,34 +1450,46 @@ public class ServerSample { // 4.1) Take the A2A-T extension message out of the message metadata (keyed by the extension URI) String processedPrompt = extractPrompt(requestContext.getMessage()); if (processedPrompt.isBlank()) { - agentEmitter.reject(statusMessage(requestContext, "missing A2A-T task prompt")); + agentEmitter.reject(statusMessage(requestContext, "missing A2A-T notification prompt")); return; } - agentEmitter.submit(statusMessage(requestContext, "Subscription accepted, starting Incident reporting task")); - - // 4.2) Use the SDK capability to validate the completeness of the A2A-T protocol message - PromptComplianceResult checkResult = server.checkTaskPrompt(processedPrompt); - if (!checkResult.success()) { - // Validation failed; failure carries code, message, and stage + agentEmitter.submit(statusMessage(requestContext, + "Subscription accepted, starting service recovery event reporting task")); + + // 4.2) Use the SDK capability to validate the Notification-T subscription message and extract the subscription parameters per the schema + Map filledParams; + try { + FilledParamData checkResult = server.validateNotificationPromptAndDataFilling( + processedPrompt, NOTIFICATION_PARAM_SCHEMA, NOTIFICATION_TEMPLATE_URI); + filledParams = checkResult.data(); + } catch (ContentValidationException error) { + // Validation failed; the exception carries code, message, and the per-slot details from errors() agentEmitter.reject(statusMessage(requestContext, - "Prompt validation failed: " + checkResult.failure().message())); + "Notification prompt validation failed: " + error.getCode() + ": " + error.getMessage())); return; } - // 4.3) Validation passed; execute the business and push task events through AgentEmitter - agentEmitter.startWork(statusMessage(requestContext, "Incident reporting task in progress")); + // 4.3) Validation passed; establish the subscription per the extracted subscription parameters + // (topic/subscriptionCondition/notificationDataFormat) and report service recovery events + // (event fields taken from the sample a2a-t-sample/.../service-recovery/server/mock-event-data.json; + // the "service recovery plan details" field is long and omitted here) + agentEmitter.startWork(statusMessage(requestContext, + "Subscribed to [" + filledParams.get("topic") + "], service recovery event reporting in progress")); agentEmitter.addArtifact( List.of(new DataPart(Map.of( - "faultManagement.Incident", Map.of( - "csn", 1673735459373056L, - "name", "LASER_MOD_ERR", - "domain", "PTN", - "priority", "high", - "status", "unacknowledged-and-uncleared")))), - "faultManagement.Incident", - "Incident artifact", + "serviceManagement.ServiceRecoveryEvent", Map.of( + "service recovery plan execution status", "finished", + "complaint diagnosis task serial number", "9de168c0-6179-4778-8b72-4279582c0a3f", + "OSS-side event serial number", "event-id-202606250128", + "access port name", "P781-SZ-PTN7900-23-TPA1EG24-17", + "whether OMC automatic recovery is authorized", "yes", + "service recovery plan name", "tunnel tuning", + "service recovery plan execution end time", "2026-05-11T08:21:46Z")))), + "serviceManagement.ServiceRecoveryEvent", + "Service recovery event artifact", null); - agentEmitter.complete(statusMessage(requestContext, "Incident reporting completed")); + agentEmitter.complete(statusMessage(requestContext, + "Service recovery notification reporting finished, subscription task completed")); } @Override @@ -1397,7 +1501,7 @@ public class ServerSample { if (message == null || message.metadata() == null) { return ""; } - Object prompt = message.metadata().get(NOTIFICATION_PROMPT_EXT); + Object prompt = message.metadata().get(NOTIFICATION_T_EXT_URI); return prompt == null ? "" : String.valueOf(prompt); } @@ -1477,12 +1581,8 @@ Following the directory structure of `a2a-t-resources/src/main/resources/prompt_ Notes: 1. `` supports `Task-T`, `Notification-T`, and `Authorization-T`; the template version segment is fixed at `v1`; the built-in languages are `zh-CN` and `en-US`. -2. `` supports two forms: - - With a domain prefix (recommended, consistent with the built-in resources): `network-layer/`, e.g. `network-layer/ran-energy-saving`; - - Without a domain prefix: ``, e.g. `authorization-policy-management`. - - When business code looks up a template by the bare scenario code (e.g. `ran-energy-saving`), the SDK first searches under `/network-layer//v1//` and then falls back to `//v1//`; you can also look up directly with the full scenario path containing `/` (e.g. `network-layer/ran-energy-saving`). -3. If the local root directory contains `prompts/`, `templates/Negotiation-T/`, or `negotiation-vocabulary/` directories, they are ignored at construction time with a warning log (these resources are always loaded from the classpath). +2. `` is configured as `network-layer/`, e.g. `network-layer/ran-energy-saving`; +3. If the local root directory contains `prompts/`, `templates/Negotiation-T/`, or `negotiation-vocabulary/` directories, they are ignored at construction time with a warning log (these resources are loaded from the classpath). #### Step2 Write the resource files @@ -1566,13 +1666,13 @@ MetadataContent metadata = client.generateTaskPromptFromText( "Please perform an on-site inspection of station xx, focusing on alarms and performance metrics", templateUri); ``` -To override a built-in template (e.g. `StandardTemplates.PRIVATE_LINE_COMPLAINT`), place `template.md` and `slot.json` at the same relative path under the local root directory and keep using the original constant in the code. Note that in `local_file` mode business templates are read only from the local root directory without classpath fallback; when the file for a `templateUri` is missing, the API throws `PromptGenerationException` with the error code `template_not_found`. +To override a built-in template (e.g. `StandardTemplates.PRIVATE_LINE_COMPLAINT`), place `template.md` and `slot.json` at the same relative path under the local root directory and keep using the original constant in the code. Note that in `local_file` mode business templates are read only from the local root directory without classpath fallback; when the file for a `templateUri` is missing, the API throws `PromptGenerationException` with the error code `template.not_found`. #### Step4 Verification and troubleshooting After starting the client or server, confirm and troubleshoot as follows: -1. **Missing resources**: when a template is not found, a `ResourceNotFoundException` is thrown (error code `validation_prompt_resource_not_found` on the validation path); the exception message contains the expected file path — complete the directory structure as prompted. +1. **Missing resources**: when a template is not found, the generation path throws `PromptGenerationException` and the validation path throws `ContentValidationException`, both with the error code `template.not_found`; the exception message contains the expected file path — complete the directory structure as prompted. 2. **Content not updated**: changes to local files do not take effect without restarting the process; restart the SDK process and construct again. 3. **Negotiation and LLM resources are not customizable**: `Negotiation-T` templates support only the built-in fixed set; negotiation template URIs outside the set are skipped with a warning. @@ -1584,8 +1684,8 @@ The sample configuration file provided by the SDK is `env.example`. When constru | --- | --- | | `A2AT_LANGUAGE` | Prompt resource language; built-in `zh-CN` and `en-US`, default `en-US` | | `A2AT_PROMPT_SOURCE_TYPE` | Prompt resource source; supports `classpath` and `local_file`, default `classpath` | -| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | Local prompt resource root directory; required in `local_file` mode — unset or non-existent paths fail fast at assembly time; only business content (templates/slots/scenarios of Task-T/Notification-T/Authorization-T) is read from this root, while negotiation resources and LLM prompts are always loaded from the classpath | -| `A2AT_INPUT_TEXT_MAX_CHARS` | Maximum number of characters for free-text inputs (FromText generation and message validation entry points); exceeding the limit fails fast with error code `input_text_too_long`, default `16384`; structured data that involves no LLM calls is not subject to this limit | +| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | Local prompt resource root directory; required in `local_file` mode — unset or non-existent paths fail fast at assembly time; only business content (templates/slots/scenarios of Task-T/Notification-T/Authorization-T) is read from this root, while negotiation resources and LLM prompts are loaded from the classpath | +| `A2AT_INPUT_TEXT_MAX_CHARS` | Maximum number of characters for free-text inputs (FromText generation and message validation entry points); exceeding the limit fails fast with error code `input.text_too_long`, default `16384`; structured data that involves no LLM calls is not subject to this limit | | `A2AT_LLM_PROVIDER` | LLM protocol type; currently only `openai` is supported | | `A2AT_LLM_MODEL` | Model name | | `A2AT_LLM_API_KEY` | LLM API key | diff --git "a/docs/zh/API\345\217\202\350\200\203.md" "b/docs/zh/API\345\217\202\350\200\203.md" index b0e509e9..6299f6bd 100644 --- "a/docs/zh/API\345\217\202\350\200\203.md" +++ "b/docs/zh/API\345\217\202\350\200\203.md" @@ -2,7 +2,7 @@ ## 1.1 简介 -A2A-T SDK 的对外 仅提供两个入口:客户端入口 `A2ATClient`(提示词生成、协商消息生成与校验)与服务端入口 `A2ATServer`(提示词校验、协商消息生成与校验),API分类总览见下 +A2A-T SDK 对外仅提供两个入口:客户端入口 `A2ATClient`(提示词生成、协商消息生成与校验)与服务端入口 `A2ATServer`(提示词校验、协商消息生成与校验),API 分类总览见下。 - **API 总览** @@ -35,9 +35,18 @@ A2A-T SDK 的对外 仅提供两个入口:客户端入口 `A2ATClient`(提 - **异常体系:** 所有 SDK 处理失败均为 `A2ATError` 子类,捕获 `A2ATError` 即可覆盖全部处理失败,`getCode()` 获取机器可读错误码。所有消息均由 SDK 按错误码模板渲染,语言跟随 `A2AT_LANGUAGE`。如下业务异常均继承 `A2ATBusinessException`,额外提供 `getFacts()` 返回渲染消息所依据的结构化事实值; - 模板生成失败抛 `PromptGenerationException`(Task-T / Notification-T / Authorization-T)或 `NegotiationGenerationException`(Negotiation-T)。 - 模板校验+提参失败抛 `ContentValidationException`(Task-T / Notification-T / Authorization-T)或 `NegotiationParamExtractionException`(Negotiation-T)。 +- **SlotValidationError:** 逐槽位校验错误明细,随校验失败异常或失败负载返回(各接口输出说明中的 `getErrors()` / `failedParameters()` / `errors()` 均引用此定义): + +| 字段 | 类型 | 说明 | +| ---- | ---- |--------------------------------------------------------------------------------------------------------------------------| +| slotName | String | 出错槽位名 | +| code | String | 槽位级错误码,取值来自 1.4 错误码列表,如 `slot.not_provided`、`content.param_missing`、`content.entry_field_missing`、`content.format_error` | +| message | String | 人类可读的错误说明,由 SDK 按错误码的消息模板渲染,语言跟随 `A2AT_LANGUAGE` | +| facts | Map<String, String> | 渲染消息所依据的结构化事实值(如 `section_label`、`index`、`field_label`),可为 null | + - **TemplateUri:** 模板 URI 值类型,推荐用 `net.openan.a2at.sdk.core.model.StandardTemplates` 常量构造;来自外部的字符串用 `TemplateUri.parse(String)` 解析,返回 `Optional` 且不抛异常,当前支持的TemplateUri常量如下: -| 常量名称 | 含义 | TemplateURI | +| 常量名称 | 含义 | TemplateUri | | ---- | ---- | ---- | | StandardTemplates.ENERGY_SAVING | Task-T 节能任务模板 | Task-T/network-layer/ran-energy-saving/v1 | | StandardTemplates.PRIVATE_LINE_COMPLAINT | Task-T 专线投诉任务模板 | Task-T/network-layer/private-line-complaint/v1 | @@ -56,7 +65,7 @@ A2A-T SDK 的对外 仅提供两个入口:客户端入口 `A2ATClient`(提 ## 1.2 约束和限制 - 部分接口涉及到LLM调用,使用时需根据对所接模型服务可提供的并发能力,结合业务时限要求,控制使用API时的调用频率和并发。 -- 涉及到LLM调用的API针对文本`text`输入会做长度防护,在输入超过`client.env`或`server.env`中配置的 `A2AT_INPUT_TEXT_MAX_CHARS` 字符时,会报错误码 `input_text_too_long` ,该配置项默认配置为 16384(16×1024);不涉及LLM调用的结构化数据不受此限制。 +- 涉及到LLM调用的API针对文本`text`输入会做长度防护,在输入超过`client.env`或`server.env`中配置的 `A2AT_INPUT_TEXT_MAX_CHARS` 字符时,会报错误码 `input.text_too_long` ,该配置项默认配置为 16384(16×1024);不涉及LLM调用的结构化数据不受此限制。 ## 1.3 API说明 @@ -97,8 +106,7 @@ NegotiationContext ctx = new NegotiationContext( "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, NegotiationContext.DEFAULT_MAX_ROUNDS); MetadataContent propose = client.generateNegotiationProposePromptFromText( - "请提供以下缺失信息:1. 接入端口名称:请提供业务接入端口名称;" - + "2. 投诉分类:专线中断或专线质差。两个参数均为必选,缺少无法启动诊断。", + "请提供以下缺失信息:投诉分类:专线中断或专线质差。两个参数均为必选,缺少无法启动诊断。", ctx, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); @@ -152,10 +160,7 @@ promptText : 请根据<所需信息项>补充相关内容。 ## 所需信息项 -1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1 -2. 投诉分类:举例:专线质差 -3. 专线业务标识 -缺失项之间的关系:OR +1. 投诉分类:举例:专线质差 ``` ### 1.3.2 generateNegotiationAcceptPromptFromText @@ -370,7 +375,7 @@ public MetadataContent generateNegotiationAcceptPromptFromData( **典型场景**:协商响应方(通常是客户端Agent)按对端请求的槽位清单程序化补参后,以结构化条目生成接受报文回传。 -**功能说明**:从结构化数据输入确定性生成协商接受报文,**不调用 LLM**。`content.conclusion()` 必须为 `ACCEPT`,其他结论(含 `ABORT`)以 `IllegalArgumentException` 拒绝。 +**功能说明**:从结构化数据输入确定性生成协商接受报文,**不调用 LLM**。`content.conclusion()` 必须为 `ACCEPT`,其他结论(含 `ABORT`)以 `negotiation.conclusion_mismatch` 业务错误拒绝。 **输入说明** @@ -411,7 +416,7 @@ MetadataContent accept = client.generateNegotiationAcceptPromptFromData( - `negotiation.field_missing`(渲染缺少必填字段) -编程错误:入参或其 context 为 null 抛 `NullPointerException`;内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;`conclusion` 不是 `ACCEPT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 +编程错误:入参或其 context 为 null 抛 `NullPointerException`;内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。`conclusion` 不是 `ACCEPT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 **响应样例** @@ -438,7 +443,7 @@ public MetadataContent generateNegotiationRejectPromptFromData( **典型场景**:协商响应方程序化判定无法满足对端请求后,以结构化条目(无法提供的项及原因)生成拒绝报文回传。 -**功能说明**:从结构化数据输入确定性生成协商拒绝报文,**不调用 LLM**。`content.conclusion()` 必须为 `REJECT`,其他结论以 `IllegalArgumentException` 拒绝。 +**功能说明**:从结构化数据输入确定性生成协商拒绝报文,**不调用 LLM**。`content.conclusion()` 必须为 `REJECT`,其他结论以 `negotiation.conclusion_mismatch` 业务错误拒绝。 **输入说明**:同 [generateNegotiationAcceptPromptFromData](#135-generatenegotiationacceptpromptfromdata),但结论为 `REJECT`。拒绝内容:`InformationEndingContent(REJECT, items)`(无法提供的项及原因)、`TargetEndingContent(REJECT, null, failureReason)`(拒绝原因)、`FeasibilityEndingContent(REJECT, feasibilitySummary)`(不可行结论摘要)。 @@ -466,7 +471,7 @@ MetadataContent reject = client.generateNegotiationRejectPromptFromData( - `negotiation.field_missing`(渲染缺少必填字段) -编程错误:入参或其 context 为 null 抛 `NullPointerException`;内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;`conclusion` 不是 `REJECT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 +编程错误:入参或其 context 为 null 抛 `NullPointerException`;内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。`conclusion` 不是 `REJECT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 **响应样例** @@ -499,7 +504,7 @@ public FilledParamData validateProposePromptAndDataFilling( | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | | prompt | String | 是 | 待校验的协商发起报文文本(`MetadataContent.promptText()`);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | -| context | NegotiationContext | 否 | 随报文传输的协商上下文;null 报 `negotiation.invalid_input` | +| context | NegotiationContext | 是 | 随报文传输的协商上下文;为 null 时报 `negotiation.invalid_input` | | schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema,声明要提取的参数 | | templateUri | TemplateUri | 是 | propose 模板 | @@ -1492,7 +1497,7 @@ if (result.success()) { | 字段 | 类型 | 说明 | | ---- | ---- | ---- | -| code | String | 机器可读错误码,取值来自错误码目录,如 `scenario.not_matched`(场景识别未命中)、`input.text_too_long`(输入长度防护)、`template.load_failed`(提示词资源加载失败)、`template.not_found`(模板缺失)、`slot.schema_not_found`(槽位 Schema 缺失)、`slot.not_provided`(必填槽缺失)、`template.render_failed`(渲染失败)、`llm.invocation_failed` / `llm.response_invalid`(LLM 失败) | +| code | String | 机器可读错误码,取值来自错误码列表,如 `scenario.not_matched`(场景识别未命中)、`input.text_too_long`(输入长度防护)、`template.load_failed`(提示词资源加载失败)、`template.not_found`(模板缺失)、`slot.schema_not_found`(槽位 Schema 缺失)、`slot.not_provided`(必填槽缺失)、`template.render_failed`(渲染失败)、`llm.invocation_failed` / `llm.response_invalid`(LLM 失败) | | message | String | 人类可读的失败描述 | | stage | String | 失败发生的阶段:`scenario`(场景识别)、`generation`(模板加载/渲染) | @@ -1583,7 +1588,7 @@ if (result.success()) { | 字段 | 类型 | 说明 | | ---- | ---- | ---- | -| code | String | 机器可读错误码,取值来自错误码目录:`scenario.not_matched`(报文解析/场景识别失败)、`template.not_found`(模板缺失)、槽位域码如 `slot.not_provided`(必填缺失)、`slot.constraint_violated`(取值越界)、`slot.rule_violation`(其它槽位规则违规)、`input.text_too_long`(输入长度防护) | +| code | String | 机器可读错误码,取值来自错误码列表:`scenario.not_matched`(报文解析/场景识别失败)、`template.not_found`(模板缺失)、槽位域码如 `slot.not_provided`(必填缺失)、`slot.constraint_violated`(取值越界)、`slot.rule_violation`(其它槽位规则违规)、`input.text_too_long`(输入长度防护) | | message | String | 人类可读的失败描述 | | stage | String | 失败发生的阶段:`prompt_parse`(报文解析)、`generation`(模板加载)、`slot_validation`(槽位校验) | @@ -1603,7 +1608,7 @@ result.failure() = { code=slot.not_provided, message=输入中未提供「任务对象」。, stage=slot_validation } ``` -## 1.4 错误码目录 +## 1.4 错误码列表 **错误码分类**:BUSINESS = 调用方可行动的预期业务失败,由 `A2ATBusinessException` 子类携带;INFRA = 基础设施失败,由普通 `A2ATError` 携带。 diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index c06f35bc..b80c025d 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -11,7 +11,7 @@ ### 1.1.1 A2A-T能力介绍 A2A-T(Agent-to-Agent Telecom)是基于A2A协议推出的电信领域多智能体互联协议,专为电信领域复杂协同场景设计。 -业界通用智能体互联协议主要关注智能体互联和交互框架,对业务场景和具体交互内容关注不足,导致任务完成的成功率不高。电信领域的业务场景复杂、要求高,运维智能体互联和协同需要有专门的协议进行支撑。A2A-T方案基于A2A协议,重点针对电信领域业务流相关的信息模型、任务协商、协作安全等增强能力进行应用扩展。 +电信领域的业务场景复杂、要求高,运维智能体互联和协同需要有专门的协议进行支撑。A2A-T方案基于A2A协议,重点针对电信领域业务流相关的信息模型、任务协商、协作安全等增强能力进行应用扩展。 a2a-t-sdk-java是面向电信智能体协作场景的 Java SDK,用于在 A2A-T 交互中生成、校验和协商任务提示词。SDK 适合客户端 Agent、服务端 Agent 以及上层编排系统集成使用。 @@ -63,13 +63,13 @@ flowchart LR ```mermaid flowchart TD subgraph Client["客户端 Agent"] - A1["A2A-T Client SDK

        Prompt模板生成:generateTaskPrompt"] + A1["A2A-T Client SDK

        Prompt模板生成:generate*PromptFromText"] A2["A2A Client SDK

        使用HTTP/REST协议发送请求"] A1 --> A2 end subgraph Server["服务端 Agent"] - B1["A2A-T Server SDK

        遵从性校验:checkTaskPrompt"] + B1["A2A-T Server SDK

        遵从性校验和提参:validate*AndDataFilling"] B2["A2A Server SDK

        接收请求"] B2 --> B1 end @@ -108,7 +108,7 @@ flowchart TD **安装JDK 17** -1. 官网下载链接:https://www.oracle.com/java/technologies/downloads/#java17(也可使用 Adoptium Temurin 等发行版:https://adoptium.net/temurin/releases/?version=17) +1. 官网下载链接:https://www.oracle.com/java/technologies/downloads/#java17 (也可使用 Adoptium Temurin 等发行版:https://adoptium.net/temurin/releases/?version=17 ) 2. 下载 Windows x64 Installer(`.msi` 或 `.exe`)并以管理员身份执行 @@ -123,7 +123,7 @@ flowchart TD **安装Maven** -1. 官网下载链接:https://maven.apache.org/download.cgi,下载 `apache-maven-3.9.x-bin.zip` 并解压到本地目录 +1. 官网下载链接:https://maven.apache.org/download.cgi ,下载 `apache-maven-3.9.x-bin.zip` 并解压到本地目录 2. 配置环境变量:`MAVEN_HOME` 指向解压目录,并将 `%MAVEN_HOME%\bin` 加入 `PATH` @@ -144,72 +144,100 @@ flowchart TD **1、A2A-T Client SDK** -接口定义及功能描述:根据输入内容识别场景生成对应的Prompt模板 +接口定义及功能描述:从自然语言文本按指定 Notification-T 模板生成通知订阅提示词报文(跳过场景识别)。执行一次 LLM 槽位提取后确定性渲染模板,生成阶段执行内置槽位 Schema 校验。 ```java -public PromptGenerationResult generateTaskPrompt(Object userInput) +public MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri) ``` -`PromptGenerationResult` 为 record 类型,包含 `success()`、`promptText()`、`failure()` 三个访问器,`failure` 携带 `code`、`message`、`stage`。 - 接口调用样例: ```java import java.nio.file.Path; import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; A2ATClient client = new A2ATClient(Path.of("client.env")); -PromptGenerationResult result = client.generateTaskPrompt("请生成一个Incident事件订阅任务:通知主题为Incident,订阅级别为critical、medium、high、low,上报通知数据格式为DataPart"); +TemplateUri templateUri = TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); -if (result.success()) { - System.out.println(result.promptText()); -} +MetadataContent result = client.generateNotificationPromptFromText( + "我想订阅一下业务抢通事件。上报通知数据格式如下:" + + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;" + + "2. 投诉诊断任务流水号;3. OSS侧事件流水号;4. 接入端口名称;" + + "5. 是否已授权OMC自动抢通,取值范围:是、否;6. 业务抢通方案名称;7. 业务抢通方案详情", + templateUri); -/*输出生成的prompt模板 +/*输出生成的通知订阅提示词报文(result.promptText(),实际文本随 LLM 槽位提取结果变化) ## 订阅描述 -请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式>及<预期输出> 信息,完成网络侧智能故障Incident订阅与上报任务。 +请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。 ## 通知主题 -通知主题该主题的名称是“incident” +业务抢通事件 ## 订阅条件 -故障级别为“critical”、“medium”、“high”、“low” +(可选) +1. 子网名称;举例:xx子网; ## 上报通知数据格式 -通过DataPart上报Incident数据 +1. 业务抢通方案执行状态,取值范围:未启动、已结束 +2. 投诉诊断任务流水号 +3. OSS侧事件流水号 +4. 接入端口名称 +5. 是否已授权OMC自动抢通,取值范围:是、否 +6. 业务抢通方案名称 +7. 业务抢通方案详情 ## 预期输出 -1、订阅结果,成功或失败 -2、订阅失败原因(可选) +1. 订阅结果,取值范围:成功、失败 +2. 订阅失败原因(可选) +3. 订阅成功后,按照<上报通知数据格式>上报消息 */ ``` **2、A2A-T Server SDK** -接口定义及功能描述: +接口定义及功能描述:校验一条 Notification-T 通知订阅提示词报文是否匹配模板与槽位约束,并按调用方 Schema 提取参数(订阅主题、订阅条件、上报通知数据格式等)。 ```java -public PromptComplianceResult checkTaskPrompt(String processedPromptText) +public FilledParamData validateNotificationPromptAndDataFilling( + String prompt, Map schema, TemplateUri templateUri) ``` -`PromptComplianceResult` 为 record 类型,包含 `success()` 和 `failure()` 两个访问器(校验失败时 `failure` 携带 `code`、`message`、`stage`)。 - -接口调用样例:校验A2A-T协议报文的完备性 +接口调用样例:校验客户端发来的通知订阅报文并提取订阅参数 ```java import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; A2ATServer server = new A2ATServer(Path.of("server.env")); -PromptComplianceResult result = server.checkTaskPrompt("## 订阅描述请根据以下 <通知主题>、<订阅 条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧智能故障Incident的订阅与上报任务。## 通知主题该主题的名称是“incident”## 订阅条件 \n\n 故障级别为“critical”、“medium”、“high”、“low” \n\n ## 上报通知数据格式 \n\n通过DataPart上报Incident数据## 预期输出1、订阅结果,成功或失败2、订阅失 败原因(可选)"); - -if (result.success()) { - System.out.println("prompt check passed"); -} else { - System.out.println(result.failure().message()); +TemplateUri templateUri = TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); + +Map validationSchema = Map.of( + "type", "object", + "properties", Map.of( + "topic", Map.of("type", "string", "description", "订阅主题(必选)。订阅的事件主题名称。"), + "subscriptionCondition", Map.of( + "type", "string", "description", "订阅条件(可选)。待订阅的条件描述。"), + "notificationDataFormat", Map.of( + "type", "string", "description", "上报通知数据格式(必选)。待上报的通知数据格式描述。")), + "required", List.of("topic", "notificationDataFormat")); + +// promptText 为客户端生成的通知订阅提示词报文文本(见上文客户端调用样例) +FilledParamData result = server.validateNotificationPromptAndDataFilling( + promptText, validationSchema, templateUri); + +/*按 Schema 提取的订阅参数(result.data(),订阅条件为空表示本例输入未携带订阅条件) +{ + topic=业务抢通事件, + subscriptionCondition=, + notificationDataFormat=业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、业务抢通方案名称、业务抢通方案详情。 } +*/ ``` ### 1.4.2 开发流程 @@ -366,9 +394,9 @@ import org.a2aproject.sdk.spec.AgentInterface; import org.a2aproject.sdk.spec.AgentProvider; import org.a2aproject.sdk.spec.AgentSkill; -private static final String TASK_T_EXT = +private static final String TASK_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; -private static final String NOTIFICATION_T_EXT = +private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; private static AgentCard buildClientAgentCard() { @@ -383,11 +411,11 @@ private static AgentCard buildClientAgentCard() { .extendedAgentCard(false) .extensions(List.of( AgentExtension.builder() - .uri(TASK_T_EXT) + .uri(TASK_T_EXT_URI) .description("Extension of structured prompt Task-T requests.") .build(), AgentExtension.builder() - .uri(NOTIFICATION_T_EXT) + .uri(NOTIFICATION_T_EXT_URI) .description("Extension of structured prompt Notification-T requests.") .build())) .build()) @@ -666,32 +694,38 @@ A2ATClient client = new A2ATClient(Path.of("client.env")); #### Step6 生成A2A-T模板报文 -客户端使用A2A-T Client SDK API `generateTaskPrompt`生成 processed_prompt,再将其作为 A2A 消息的一部分发送给目标 agent。 +客户端使用A2A-T Client SDK API `generateNotificationPromptFromText`生成 Notification-T 订阅报文(processed_prompt),再将其作为 A2A 消息的一部分发送给目标 agent。 ```java import java.nio.file.Path; import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; A2ATClient client = new A2ATClient(Path.of("client.env")); -// 生成 A2A-T 提示词 -PromptGenerationResult result = client.generateTaskPrompt("请生成一个Incident事件订阅任务:通知主题为Incident,订阅级别为critical、medium、high、low,上报通知数据格式为DataPart"); -if (!result.success()) { - throw new IllegalStateException(result.failure().code() + ": " + result.failure().message()); -} +// 生成 A2A-T 通知订阅提示词报文(生成失败时抛出PromptGenerationException,携带code、message) +TemplateUri templateUri = + TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); +MetadataContent result = client.generateNotificationPromptFromText( + "我想订阅一下业务抢通事件。上报通知数据格式如下:" + + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;" + + "2. 投诉诊断任务流水号;3. OSS侧事件流水号;4. 接入端口名称;" + + "5. 是否已授权OMC自动抢通,取值范围:是、否;6. 业务抢通方案名称;7. 业务抢通方案详情", + templateUri); String processedPrompt = result.promptText(); +String extensionUri = result.extensionUri(); // A2A-T扩展URI,用于消息metadata与请求头 ``` #### Step7 请求头激活A2A-T扩展 A2A 协议通过 HTTP Header 传递协议版本与扩展声明,需使用如下Header -| Header | 方向 | 必填 | 取值 | -| ---------------- | ------ | -------------------- | ------------------------------------------------- | -| `A2A-Version` | 请求头 | 是 | 协议版本,如 `1.0`(客户端必须随每个请求携带) | -| `A2A-Extensions` | 请求头 | 否(使用扩展时建议) | 逗号分隔的扩展 URI 列表,声明本次请求要使用的扩展 | +| Header | 方向 | 必填 | 取值 | +| ---------------- | ------ | --------------- | ------------------------------------------------- | +| `A2A-Version` | 请求头 | 是 | 协议版本,如 `1.0`(客户端必须随每个请求携带) | +| `A2A-Extensions` | 请求头 | 使用A2A-T时必填 | 逗号分隔的扩展 URI 列表,声明本次请求要使用的扩展 | 客户端请求头填写样例:使用 a2a-java 发送请求时,`A2A-Extensions` 通过 `ClientCallContext` 的 headers 传入(协议版本等协议头由 a2a-java 传输层按协议要求携带) @@ -716,7 +750,8 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.CountDownLatch; import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; import org.a2aproject.sdk.client.Client; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; @@ -733,30 +768,34 @@ import org.a2aproject.sdk.spec.TextPart; String notificationPromptExt = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; -// 1) 生成 A2A-T 提示词(见1.4.4.6) + // 1) 生成 A2A-T 模板报文(见 1.4.3 Step6) A2ATClient client = new A2ATClient(Path.of("client.env")); -PromptGenerationResult result = client.generateTaskPrompt("请生成一个Incident事件订阅任务:通知主题为Incident,订阅级别为critical、medium、high、low,上报通知数据格式为DataPart"); -if (!result.success()) { - throw new IllegalStateException(result.failure().code() + ": " + result.failure().message()); -} +TemplateUri templateUri = + TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); +MetadataContent result = client.generateNotificationPromptFromText( + "我想订阅一下业务抢通事件。上报通知数据格式如下:" + + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;" + + "2. 投诉诊断任务流水号;3. OSS侧事件流水号;4. 接入端口名称;" + + "5. 是否已授权OMC自动抢通,取值范围:是、否;6. 业务抢通方案名称;7. 业务抢通方案详情", + templateUri); String processedPrompt = result.promptText(); // 2) 构造携带A2A-T扩展的A2A消息(prompt放入metadata,以扩展URI为key) Message message = Message.builder() .messageId(UUID.randomUUID().toString()) .role(Message.Role.ROLE_USER) - .parts(new TextPart("创建智能故障incident上报任务")) + .parts(new TextPart("我想订阅一下业务抢通事件")) .metadata(Map.of(notificationPromptExt, processedPrompt)) .build(); MessageSendParams params = MessageSendParams.builder().message(message).build(); -// 3) 通过 ClientCallContext 声明A2A-T扩展请求头(见1.4.4.7) + // 3) 通过 ClientCallContext 声明A2A-T扩展请求头(见 1.4.3 Step7) ClientCallContext callContext = new ClientCallContext(Map.of(), Map.of("A2A-Extensions", notificationPromptExt)); // 4) 使用 a2a-java Client 发送请求并消费任务事件 CountDownLatch done = new CountDownLatch(1); -try (Client a2aClient = Client.builder(agentCard) // 1.4.4.5 检索并转换后的 org.a2aproject.sdk.spec.AgentCard + try (Client a2aClient = Client.builder(agentCard) .withTransport(RestTransport.class, new RestTransportConfig()) .build()) { a2aClient.sendMessage( @@ -804,7 +843,8 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; import org.a2aproject.sdk.client.Client; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; @@ -820,7 +860,7 @@ import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; import org.a2aproject.sdk.spec.TextPart; public final class ClientSample { - private static final String NOTIFICATION_PROMPT_EXT = + private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; private static final ObjectMapper MAPPER = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); @@ -828,28 +868,32 @@ public final class ClientSample { public static void main(String[] args) throws Exception { // 1) 注册客户端AgentCard,检索服务端AgentCard - registerAgentCard("http://{ip:port}", buildClientAgentCard()); // 1.4.4.4 定义的客户端AgentCard - AgentCard serverAgentCard = toAgentCard( // 1.4.4.5 定义的转换 + registerAgentCard("http://{ip:port}", buildClientAgentCard()); // 1.4.3 Step3 定义的客户端AgentCard + AgentCard serverAgentCard = toAgentCard( discoverAgent("http://{ip:port}", "Huawei", "RAN Domain Agent")); - // 2) 使用SDK能力生成 A2A-T 提示词 + // 2) 使用SDK能力生成 A2A-T 通知订阅提示词报文 A2ATClient client = new A2ATClient(Path.of("client.env")); - PromptGenerationResult result = client.generateTaskPrompt("请生成一个Incident事件订阅任务:通知主题为Incident,订阅级别为critical、medium、high、low,上报通知数据格式为DataPart"); - if (!result.success()) { - throw new IllegalStateException(result.failure().code() + ": " + result.failure().message()); - } + TemplateUri templateUri = + TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); + MetadataContent result = client.generateNotificationPromptFromText( + "我想订阅一下业务抢通事件。上报通知数据格式如下:" + + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;" + + "2. 投诉诊断任务流水号;3. OSS侧事件流水号;4. 接入端口名称;" + + "5. 是否已授权OMC自动抢通,取值范围:是、否;6. 业务抢通方案名称;7. 业务抢通方案详情", + templateUri); String processedPrompt = result.promptText(); // 3) 构造携带A2A-T扩展的A2A消息(请求头声明扩展,消息体携带 A2A-T 扩展字段) Message message = Message.builder() .messageId(UUID.randomUUID().toString()) .role(Message.Role.ROLE_USER) - .parts(new TextPart("创建智能故障incident上报任务")) - .metadata(Map.of(NOTIFICATION_PROMPT_EXT, processedPrompt)) + .parts(new TextPart("我想订阅一下业务抢通事件")) + .metadata(Map.of(NOTIFICATION_T_EXT_URI, processedPrompt)) .build(); MessageSendParams params = MessageSendParams.builder().message(message).build(); ClientCallContext callContext = - new ClientCallContext(Map.of(), Map.of("A2A-Extensions", NOTIFICATION_PROMPT_EXT)); + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", NOTIFICATION_T_EXT_URI)); // 4) 通过 a2a-java Client 发送请求并消费任务事件 CountDownLatch done = new CountDownLatch(1); @@ -915,13 +959,13 @@ public final class ClientSample { } ``` -> `buildClientAgentCard()`、`toAgentCard(Map)` 及 `string`/`stringList` 辅助方法见 1.4.4.4 与 1.4.4.5。 +> `buildClientAgentCard()`、`toAgentCard(Map)` 及 `string`/`stringList` 辅助方法见 1.4.3 Step3 与 Step4。 ### 1.4.4 样例服务端开发步骤 #### Step1-Step5 前置步骤 -安装依赖、配置大模型等步骤均可以参考[客户端实现](#1.4.4.1 安装依赖),差异化的内容如下: +安装依赖、配置大模型等步骤均可以参考[客户端实现](#143-样例客户端开发步骤),差异化的内容如下: 服务端 Agent 推荐使用 a2a-java 官方的 REST 参考服务(基于 Quarkus,自动装配 REST 传输、任务管理与事件队列)承载 A2A 请求,引用如下依赖: @@ -956,7 +1000,7 @@ Quarkus 工程还需引入 Quarkus 平台 BOM 与构建插件(完整工程脚 - + io.quarkus.platform quarkus-bom @@ -997,7 +1041,7 @@ import net.openan.a2at.sdk.server.A2ATServer; A2ATServer server = new A2ATServer(Path.of("server.env")); ``` -- 初始化AgentCard:a2a-java 官方推荐通过 CDI Producer 暴露 `@PublicAgentCard` 限定的 AgentCard(使用 Builder 构造,见1.4.5.4),服务端样例AgentCard定义参考: +- 初始化AgentCard:a2a-java 官方推荐通过 CDI Producer 暴露 `@PublicAgentCard` 限定的 AgentCard(使用 Builder 构造,构造方式参考 1.4.3 Step3),服务端样例AgentCard定义参考: ```java import java.util.List; @@ -1008,9 +1052,9 @@ import org.a2aproject.sdk.spec.AgentInterface; import org.a2aproject.sdk.spec.AgentProvider; import org.a2aproject.sdk.spec.AgentSkill; -private static final String TASK_T_EXT = +private static final String TASK_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; -private static final String NOTIFICATION_T_EXT = +private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; private static AgentCard buildServerAgentCard() { @@ -1025,12 +1069,12 @@ private static AgentCard buildServerAgentCard() { .extendedAgentCard(false) .extensions(List.of( AgentExtension.builder() - .uri(TASK_T_EXT) + .uri(TASK_T_EXT_URI) .description("Extension of structured prompt TASK-T requests.") .required(false) .build(), AgentExtension.builder() - .uri(NOTIFICATION_T_EXT) + .uri(NOTIFICATION_T_EXT_URI) .description("Extension of structured prompt Notification-T requests.") .required(false) .build())) @@ -1099,7 +1143,7 @@ private static AgentCard buildServerAgentCard() { "Incident上报" ], "examples": [ - "## 订阅描述\\n请根据以下 \\u003c通知主题\\u003e、\\u003c订阅条件\\u003e、\\u003c上报通知数据格式\\u003e 及 \\u003c预期输出\\u003e 信息,完成网络侧智能故障Incident的订阅与上报任务。\\n## 通知主题\\n该主题的名称是“incident”\\n## 订阅条件\\n故障级别为“高”\\n## 上报通知数据格式\\n通过DataPart上报Incident数据\\n## 预期输出\\n1、订阅结果,成功或失败\\n2、订阅失败原因(可选)" + "## 订阅描述\\n请根据以下 \<通知主题\>、\<订阅条件\>、\<上报通知数据格式\> 及 \<预期输出\> 信息,完成网络侧智能故障Incident的订阅与上报任务。\\n## 通知主题\\n该主题的名称是“incident”\\n## 订阅条件\\n故障级别为“高”\\n## 上报通知数据格式\\n通过DataPart上报Incident数据\\n## 预期输出\\n1、订阅结果,成功或失败\\n2、订阅失败原因(可选)" ], "inputModes": [ "application/json", @@ -1141,15 +1185,16 @@ private static AgentCard buildServerAgentCard() { #### Step6-Step7 服务端接收报文并校验 -使用 a2a-java 承载服务端时,协议解析、任务创建与事件队列管理由 SDK 完成,业务侧实现官方的 `AgentExecutor` 接口:在 `execute` 回调中,从消息 `metadata` 字段(以扩展 URI 为 key)取出 processed task prompt,传递给 `A2ATServer.checkTaskPrompt` 校验,并通过 `AgentEmitter` 推送任务状态与结果事件: +使用 a2a-java 承载服务端时,协议解析、任务创建与事件队列管理由 SDK 完成,业务侧实现官方的 `AgentExecutor` 接口:在 `execute` 回调中,从消息 `metadata` 字段(以扩展 URI 为 key)取出 Notification-T 订阅报文,传递给 `A2ATServer.validateNotificationPromptAndDataFilling` 校验并提取订阅参数,并通过 `AgentEmitter` 推送任务状态与结果事件: ```java -import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.UUID; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; @@ -1158,14 +1203,27 @@ import org.a2aproject.sdk.spec.DataPart; import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.TextPart; -public final class IncidentSubscriptionExecutor implements AgentExecutor { +public final class ServiceRecoverySubscriptionExecutor implements AgentExecutor { - private static final String NOTIFICATION_PROMPT_EXT = + private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + private static final TemplateUri NOTIFICATION_TEMPLATE_URI = + TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); + + private static final Map NOTIFICATION_PARAM_SCHEMA = Map.of( + "type", "object", + "properties", Map.of( + "topic", Map.of("type", "string", "description", "订阅主题(必选)。订阅的事件主题名称。"), + "subscriptionCondition", Map.of( + "type", "string", "description", "订阅条件(可选)。待订阅的条件描述。"), + "notificationDataFormat", Map.of( + "type", "string", "description", "上报通知数据格式(必选)。待上报的通知数据格式描述。")), + "required", List.of("topic", "notificationDataFormat")); + private final A2ATServer server; - public IncidentSubscriptionExecutor(A2ATServer server) { + public ServiceRecoverySubscriptionExecutor(A2ATServer server) { this.server = server; } @@ -1174,34 +1232,45 @@ public final class IncidentSubscriptionExecutor implements AgentExecutor { // 1) 从消息metadata取出A2A-T扩展的报文(以扩展URI为key) String processedPrompt = extractPrompt(requestContext.getMessage()); if (processedPrompt.isBlank()) { - agentEmitter.reject(statusMessage(requestContext, "missing A2A-T task prompt")); + agentEmitter.reject(statusMessage(requestContext, "missing A2A-T notification prompt")); return; } - agentEmitter.submit(statusMessage(requestContext, "Subscription accepted, starting Incident reporting task")); - - // 2) 使用SDK能力校验A2A-T协议报文的完备性 - PromptComplianceResult checkResult = server.checkTaskPrompt(processedPrompt); - if (!checkResult.success()) { - // 校验失败,failure 携带 code、message、stage + agentEmitter.submit(statusMessage(requestContext, + "Subscription accepted, starting service recovery event reporting task")); + + // 2) 使用SDK能力校验Notification-T订阅报文并按Schema提取订阅参数 + Map filledParams; + try { + FilledParamData checkResult = server.validateNotificationPromptAndDataFilling( + processedPrompt, NOTIFICATION_PARAM_SCHEMA, NOTIFICATION_TEMPLATE_URI); + filledParams = checkResult.data(); + } catch (ContentValidationException error) { + // 校验失败,exception 携带 code、message 与 errors() 逐槽位明细 agentEmitter.reject(statusMessage(requestContext, - "Prompt validation failed: " + checkResult.failure().message())); + "Notification prompt validation failed: " + error.getCode() + ": " + error.getMessage())); return; } - // 3) 校验通过,执行业务并通过AgentEmitter推送任务事件 - agentEmitter.startWork(statusMessage(requestContext, "Incident reporting task in progress")); + // 3) 校验通过,按提取的订阅参数(topic/subscriptionCondition/notificationDataFormat)建立订阅并上报 + // 业务抢通事件(事件字段取自样例 a2a-t-sample/.../service-recovery/server/mock-event-data.json, + // “业务抢通方案详情”字段较长,此处省略) + agentEmitter.startWork(statusMessage(requestContext, + "Subscribed to [" + filledParams.get("topic") + "], service recovery event reporting in progress")); agentEmitter.addArtifact( List.of(new DataPart(Map.of( - "faultManagement.Incident", Map.of( - "csn", 1673735459373056L, - "name", "LASER_MOD_ERR", - "domain", "PTN", - "priority", "high", - "status", "unacknowledged-and-uncleared")))), - "faultManagement.Incident", - "Incident artifact", + "serviceManagement.ServiceRecoveryEvent", Map.of( + "业务抢通方案执行状态", "已结束", + "投诉诊断任务流水号", "9de168c0-6179-4778-8b72-4279582c0a3f", + "OSS侧事件流水号", "event-id-202606250128", + "接入端口名称", "P781-SZ-PTN7900-23-TPA1EG24-17", + "是否已授权OMC自动抢通", "是", + "业务抢通方案名称", "隧道调优", + "业务抢通方案执行结束时间", "2026-05-11T08:21:46Z")))), + "serviceManagement.ServiceRecoveryEvent", + "Service recovery event artifact", null); - agentEmitter.complete(statusMessage(requestContext, "Incident reporting completed")); + agentEmitter.complete(statusMessage(requestContext, + "Service recovery notification reporting finished, subscription task completed")); } @Override @@ -1213,7 +1282,7 @@ public final class IncidentSubscriptionExecutor implements AgentExecutor { if (message == null || message.metadata() == null) { return ""; } - Object prompt = message.metadata().get(NOTIFICATION_PROMPT_EXT); + Object prompt = message.metadata().get(NOTIFICATION_T_EXT_URI); return prompt == null ? "" : String.valueOf(prompt); } @@ -1231,9 +1300,9 @@ public final class IncidentSubscriptionExecutor implements AgentExecutor { #### Step8 填充A2A响应头,返回响应 -| Header | 方向 | 必填 | 取值 | -| ---------------- | ------ | -------------------- | ----------------------------------------- | -| `A2A-Extensions` | 响应头 | 否(使用扩展时建议) | 服务端实际参与的扩展 URI 列表(逗号分隔) | +| Header | 方向 | 必填 | 取值 | +| ---------------- | ------ | --------------- | ----------------------------------------- | +| `A2A-Extensions` | 响应头 | 使用A2A-T时必填 | 服务端实际参与的扩展 URI 列表(逗号分隔) | 使用 a2a-java REST 传输承载服务端时,协议层(请求的协议版本与扩展声明校验、任务与事件管理、SSE 流式响应)由 SDK 传输层处理,业务侧通过 `AgentEmitter` 推送任务状态与结果事件即可;若业务系统自行实现 HTTP 承载,需按上表在响应中回填 `A2A-Extensions` 头。 @@ -1255,8 +1324,10 @@ import io.quarkus.runtime.StartupEvent; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import jakarta.enterprise.inject.Produces; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; import org.a2aproject.sdk.server.PublicAgentCard; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.agentexecution.RequestContext; @@ -1275,14 +1346,14 @@ import org.a2aproject.sdk.spec.TextPart; @ApplicationScoped public class ServerSample { - private static final String TASK_T_EXT = + private static final String TASK_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; - private static final String NOTIFICATION_PROMPT_EXT = + private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; private static final ObjectMapper MAPPER = new ObjectMapper(); - // 1) CDI Producer:向 a2a-java 暴露服务端AgentCard(定义与1.4.5.1一致) + // 1) CDI Producer:向 a2a-java 暴露服务端AgentCard(定义同 1.4.4 前置步骤中的服务端样例 AgentCard) @Produces @PublicAgentCard public AgentCard agentCard() { @@ -1297,12 +1368,12 @@ public class ServerSample { .extendedAgentCard(false) .extensions(List.of( AgentExtension.builder() - .uri(TASK_T_EXT) + .uri(TASK_T_EXT_URI) .description("Extension of structured prompt TASK-T requests.") .required(false) .build(), AgentExtension.builder() - .uri(NOTIFICATION_PROMPT_EXT) + .uri(NOTIFICATION_T_EXT_URI) .description("Extension of structured prompt Notification-T requests.") .required(false) .build())) @@ -1326,7 +1397,7 @@ public class ServerSample { // 2) CDI Producer:初始化A2A-T服务端并装配到AgentExecutor @Produces public AgentExecutor agentExecutor() { - return new IncidentSubscriptionExecutor(new A2ATServer(Path.of("server.env"))); + return new ServiceRecoverySubscriptionExecutor(new A2ATServer(Path.of("server.env"))); } // 3) 启动时将服务端AgentCard注册到注册中心(地址以实际部署情况为准) @@ -1344,12 +1415,29 @@ public class ServerSample { } } - // 4) A2A-T任务执行器:接收A2A-T报文并校验(与1.4.5.2一致) - static final class IncidentSubscriptionExecutor implements AgentExecutor { + // 4) A2A-T任务执行器:接收Notification-T订阅报文并校验提参(实现同 1.4.4 Step6-Step7) + static final class ServiceRecoverySubscriptionExecutor implements AgentExecutor { + + private static final String NOTIFICATION_T_EXT_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final TemplateUri NOTIFICATION_TEMPLATE_URI = + TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); + + // 参数提取Schema(与 a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json 一致) + private static final Map NOTIFICATION_PARAM_SCHEMA = Map.of( + "type", "object", + "properties", Map.of( + "topic", Map.of("type", "string", "description", "订阅主题(必选)。订阅的事件主题名称。"), + "subscriptionCondition", Map.of( + "type", "string", "description", "订阅条件(可选)。待订阅的条件描述。"), + "notificationDataFormat", Map.of( + "type", "string", "description", "上报通知数据格式(必选)。待上报的通知数据格式描述。")), + "required", List.of("topic", "notificationDataFormat")); private final A2ATServer server; - IncidentSubscriptionExecutor(A2ATServer server) { + ServiceRecoverySubscriptionExecutor(A2ATServer server) { this.server = server; } @@ -1358,34 +1446,45 @@ public class ServerSample { // 4.1) 从消息metadata取出A2A-T扩展的报文(以扩展URI为key) String processedPrompt = extractPrompt(requestContext.getMessage()); if (processedPrompt.isBlank()) { - agentEmitter.reject(statusMessage(requestContext, "missing A2A-T task prompt")); + agentEmitter.reject(statusMessage(requestContext, "missing A2A-T notification prompt")); return; } - agentEmitter.submit(statusMessage(requestContext, "Subscription accepted, starting Incident reporting task")); - - // 4.2) 使用SDK能力校验A2A-T协议报文的完备性 - PromptComplianceResult checkResult = server.checkTaskPrompt(processedPrompt); - if (!checkResult.success()) { - // 校验失败,failure 携带 code、message、stage + agentEmitter.submit(statusMessage(requestContext, + "Subscription accepted, starting service recovery event reporting task")); + + // 4.2) 使用SDK能力校验Notification-T订阅报文并按Schema提取订阅参数 + Map filledParams; + try { + FilledParamData checkResult = server.validateNotificationPromptAndDataFilling( + processedPrompt, NOTIFICATION_PARAM_SCHEMA, NOTIFICATION_TEMPLATE_URI); + filledParams = checkResult.data(); + } catch (ContentValidationException error) { + // 校验失败,exception 携带 code、message 与 errors() 逐槽位明细 agentEmitter.reject(statusMessage(requestContext, - "Prompt validation failed: " + checkResult.failure().message())); + "Notification prompt validation failed: " + error.getCode() + ": " + error.getMessage())); return; } - // 4.3) 校验通过,执行业务并通过AgentEmitter推送任务事件 - agentEmitter.startWork(statusMessage(requestContext, "Incident reporting task in progress")); + // 4.3) 校验通过,按提取的订阅参数(topic/subscriptionCondition/notificationDataFormat)建立订阅并上报 + // 业务抢通事件(事件字段取自样例 a2a-t-sample/.../service-recovery/server/mock-event-data.json, + // “业务抢通方案详情”字段较长,此处省略) + agentEmitter.startWork(statusMessage(requestContext, + "Subscribed to [" + filledParams.get("topic") + "], service recovery event reporting in progress")); agentEmitter.addArtifact( List.of(new DataPart(Map.of( - "faultManagement.Incident", Map.of( - "csn", 1673735459373056L, - "name", "LASER_MOD_ERR", - "domain", "PTN", - "priority", "high", - "status", "unacknowledged-and-uncleared")))), - "faultManagement.Incident", - "Incident artifact", + "serviceManagement.ServiceRecoveryEvent", Map.of( + "业务抢通方案执行状态", "已结束", + "投诉诊断任务流水号", "9de168c0-6179-4778-8b72-4279582c0a3f", + "OSS侧事件流水号", "event-id-202606250128", + "接入端口名称", "P781-SZ-PTN7900-23-TPA1EG24-17", + "是否已授权OMC自动抢通", "是", + "业务抢通方案名称", "隧道调优", + "业务抢通方案执行结束时间", "2026-05-11T08:21:46Z")))), + "serviceManagement.ServiceRecoveryEvent", + "Service recovery event artifact", null); - agentEmitter.complete(statusMessage(requestContext, "Incident reporting completed")); + agentEmitter.complete(statusMessage(requestContext, + "Service recovery notification reporting finished, subscription task completed")); } @Override @@ -1397,7 +1496,7 @@ public class ServerSample { if (message == null || message.metadata() == null) { return ""; } - Object prompt = message.metadata().get(NOTIFICATION_PROMPT_EXT); + Object prompt = message.metadata().get(NOTIFICATION_T_EXT_URI); return prompt == null ? "" : String.valueOf(prompt); } @@ -1477,12 +1576,8 @@ SDK 在生成和校验 A2A-T 提示词时依赖三类提示词资源:场景目 说明: 1. `` 支持 `Task-T`、`Notification-T`、`Authorization-T`;模板版本段固定为 `v1`;内置语言为 `zh-CN` 与 `en-US`。 -2. `<场景路径>` 支持两种形态: - - 带领域前缀(推荐,与内置资源一致):`network-layer/<场景编码>`,例如 `network-layer/ran-energy-saving`; - - 无领域前缀:`<场景编码>`,例如 `authorization-policy-management`。 - - 业务代码按裸场景编码(如 `ran-energy-saving`)检索模板时,SDK 优先在 `/network-layer/<编码>/v1/<语言>/` 下查找,再回退到 `/<编码>/v1/<语言>/`;也可以直接使用带 `/` 的完整场景路径(如 `network-layer/ran-energy-saving`)检索。 -3. 本地根目录中若存在 `prompts/`、`templates/Negotiation-T/`、`negotiation-vocabulary/` 目录,构造时会被忽略并输出告警日志(这些资源恒从 classpath 加载)。 +2. `<场景路径>` 配置方式为`network-layer/<场景编码>`,例如 `network-layer/ran-energy-saving`; +3. 本地根目录中若存在 `prompts/`、`templates/Negotiation-T/`、`negotiation-vocabulary/` 目录,构造时会被忽略并输出告警日志(这些资源从 classpath 加载)。 #### Step2 编写资源文件 @@ -1565,13 +1660,13 @@ TemplateUri templateUri = TemplateUri.parse("Task-T/network-layer/site-inspectio MetadataContent metadata = client.generateTaskPromptFromText("请对xx基站开展现场巡检,重点检查告警和性能指标", templateUri); ``` -如需覆盖内置模板(如 `StandardTemplates.PRIVATE_LINE_COMPLAINT`),在本地根目录下按相同相对路径放置 `template.md` 与 `slot.json`,代码中继续使用原有常量即可。注意 `local_file` 模式下业务模板只从本地根目录读取、不回退 classpath,`templateUri` 对应的文件缺失时接口抛出 `PromptGenerationException`,错误码为 `template_not_found`。 +如需覆盖内置模板(如 `StandardTemplates.PRIVATE_LINE_COMPLAINT`),在本地根目录下按相同相对路径放置 `template.md` 与 `slot.json`,代码中继续使用原有常量即可。注意 `local_file` 模式下业务模板只从本地根目录读取、不回退 classpath,`templateUri` 对应的文件缺失时接口抛出 `PromptGenerationException`,错误码为 `template.not_found`。 #### Step4 验证与排障 启动客户端或服务端后,可按以下方式确认与排障: -1. **资源缺失**:模板未找到时抛出 `ResourceNotFoundException`(校验链路错误码为 `validation_prompt_resource_not_found`),异常提示中包含期望的文件路径,按提示补齐目录结构即可。 +1. **资源缺失**:模板未找到时,生成链路抛出 `PromptGenerationException`、校验链路抛出 `ContentValidationException`,错误码均为 `template.not_found`,异常提示中包含期望的文件路径,按提示补齐目录结构即可。 2. **内容未更新**:修改本地文件后不重启进程不生效,重启 SDK 进程后重新构造。 3. **协商与 LLM 资源不可定制**:`Negotiation-T` 模板只支持内置的固定集合,集合之外的协商模板 URI 会被跳过并输出告警。 @@ -1583,8 +1678,8 @@ SDK提供的样例配置文件为`env.example`,A2A-T Client和A2A-T Server构 | ------------------------------------- | ------------------------------------------------------------ | | `A2AT_LANGUAGE` | 提示词资源语言,内置 `zh-CN` 与 `en-US`,默认 `en-US` | | `A2AT_PROMPT_SOURCE_TYPE` | 提示词资源来源,支持 `classpath` 和 `local_file`,默认 `classpath` | -| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | 本地提示词资源根目录;`local_file` 模式下必填,未设置或路径不存在时装配期快速失败;仅业务内容(Task-T/Notification-T/Authorization-T 的 templates/slots/scenarios)从该根读取,协商资源与 LLM prompt 恒从 classpath 加载 | -| `A2AT_INPUT_TEXT_MAX_CHARS` | 自由文本输入(FromText 生成与报文校验入口)的最大字符数,超限快速失败并报错误码 `input_text_too_long`,默认 `16384`;不涉及 LLM 调用的结构化数据不受此限制 | +| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | 本地提示词资源根目录;`local_file` 模式下必填,未设置或路径不存在时装配期快速失败;仅业务内容(Task-T/Notification-T/Authorization-T 的 templates/slots/scenarios)从该根读取,协商资源与 LLM prompt 从 classpath 加载 | +| `A2AT_INPUT_TEXT_MAX_CHARS` | 自由文本输入(FromText 生成与报文校验入口)的最大字符数,超限快速失败并报错误码 `input.text_too_long`,默认 `16384`;不涉及 LLM 调用的结构化数据不受此限制 | | `A2AT_LLM_PROVIDER` | LLM 协议支持类型,当前仅支持 `openai` | | `A2AT_LLM_MODEL` | 模型名称 | | `A2AT_LLM_API_KEY` | LLM API Key | From d0686ccd73ee1e2447dcb6d60b463a8ce644aae2 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sat, 29 Aug 2026 23:56:05 +0800 Subject: [PATCH 249/295] feat(core): add parallel string template uri constants to StandardTemplates Signed-off-by: codetalks-hw --- .../sdk/core/model/StandardTemplates.java | 86 ++++++++++++++++++- .../sdk/core/validation/ContentValidator.java | 2 +- .../StandardTemplatesStringConstantsTest.java | 78 +++++++++++++++++ 3 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/StandardTemplatesStringConstantsTest.java diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java index 84037cb9..5f339850 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java @@ -6,11 +6,14 @@ * Constants for the built-in content templates shipped with the SDK, in the spirit of * {@code java.nio.charset.StandardCharsets}. * - *

        Each constant is a language-neutral {@link TemplateUri}; the language is global prompt runtime context and is - * bound by the SDK, not by the caller. Use these constants instead of hand-written URI strings to get compile-time - * safety against spelling drift. + *

        Each template is published in two spellings: a language-neutral {@link TemplateUri} and its raw {@code uri()} + * string (constant name suffixed {@code _URI}). The typed form is the identity representation used for template + * comparisons (for example against {@link PromptTemplate#templateUri()}) and by internal seams; the string form is + * what the {@code A2ATClient}/{@code A2ATServer} facades take as their {@code templateUri} parameter. The language is + * global prompt runtime context and is bound by the SDK, not by the caller. Use these constants instead of hand-written + * URI strings to keep the spelling centralized — a consistency test pins the two forms together. * - *

        Example: {@code StandardTemplates.ENERGY_SAVING.uri()} is {@code Task-T/network-layer/ran-energy-saving/v1}. The + *

        Example: {@code StandardTemplates.ENERGY_SAVING_URI} is {@code Task-T/network-layer/ran-energy-saving/v1}. The * Task-T and Notification-T templates carry the {@code network-layer} domain segment; Authorization-T and * Negotiation-T templates do not. * @@ -43,59 +46,124 @@ private StandardTemplates() { public static final TemplateUri ENERGY_SAVING = TemplateUri.of(TASK_EXTENSION_NAME, NETWORK_LAYER_SEGMENT, "ran-energy-saving"); + /** Raw template URI of {@link #ENERGY_SAVING}: {@code Task-T/network-layer/ran-energy-saving/v1}. */ + public static final String ENERGY_SAVING_URI = ENERGY_SAVING.uri(); + /** Task-T template for the private-line-complaint scenario. */ public static final TemplateUri PRIVATE_LINE_COMPLAINT = TemplateUri.of(TASK_EXTENSION_NAME, NETWORK_LAYER_SEGMENT, "private-line-complaint"); + /** Raw template URI of {@link #PRIVATE_LINE_COMPLAINT}: {@code Task-T/network-layer/private-line-complaint/v1}. */ + public static final String PRIVATE_LINE_COMPLAINT_URI = PRIVATE_LINE_COMPLAINT.uri(); + /** Notification-T template for the subscribe-incident scenario. */ public static final TemplateUri SUBSCRIBE_INCIDENT = TemplateUri.of(NOTIFICATION_EXTENSION_NAME, NETWORK_LAYER_SEGMENT, "subscribe-incident"); + /** Raw template URI of {@link #SUBSCRIBE_INCIDENT}: {@code Notification-T/network-layer/subscribe-incident/v1}. */ + public static final String SUBSCRIBE_INCIDENT_URI = SUBSCRIBE_INCIDENT.uri(); + /** Notification-T template for the service-recovery scenario. */ public static final TemplateUri SERVICE_RECOVERY = TemplateUri.of(NOTIFICATION_EXTENSION_NAME, NETWORK_LAYER_SEGMENT, "service-recovery"); + /** Raw template URI of {@link #SERVICE_RECOVERY}: {@code Notification-T/network-layer/service-recovery/v1}. */ + public static final String SERVICE_RECOVERY_URI = SERVICE_RECOVERY.uri(); + /** Authorization-T template for the authorization-policy-management scenario. */ public static final TemplateUri AUTHORIZATION_POLICY_MANAGEMENT = TemplateUri.of(AUTHORIZATION_EXTENSION_NAME, "authorization-policy-management"); + /** + * Raw template URI of {@link #AUTHORIZATION_POLICY_MANAGEMENT}: + * {@code Authorization-T/authorization-policy-management/v1}. + */ + public static final String AUTHORIZATION_POLICY_MANAGEMENT_URI = AUTHORIZATION_POLICY_MANAGEMENT.uri(); + /** Negotiation-T propose template for information negotiation. */ public static final TemplateUri INFORMATION_NEGOTIATION_PROPOSE = TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "information-negotiation", "propose"); + /** + * Raw template URI of {@link #INFORMATION_NEGOTIATION_PROPOSE}: + * {@code Negotiation-T/information-negotiation/propose/v1}. + */ + public static final String INFORMATION_NEGOTIATION_PROPOSE_URI = INFORMATION_NEGOTIATION_PROPOSE.uri(); + /** Negotiation-T accept-reject template for information negotiation. */ public static final TemplateUri INFORMATION_NEGOTIATION_ACCEPT_REJECT = TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "information-negotiation", "accept-reject"); + /** + * Raw template URI of {@link #INFORMATION_NEGOTIATION_ACCEPT_REJECT}: + * {@code Negotiation-T/information-negotiation/accept-reject/v1}. + */ + public static final String INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI = + INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(); + /** Negotiation-T propose template for target negotiation. */ public static final TemplateUri TARGET_NEGOTIATION_PROPOSE = TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "target-negotiation", "propose"); + /** Raw template URI of {@link #TARGET_NEGOTIATION_PROPOSE}: {@code Negotiation-T/target-negotiation/propose/v1}. */ + public static final String TARGET_NEGOTIATION_PROPOSE_URI = TARGET_NEGOTIATION_PROPOSE.uri(); + /** Negotiation-T accept-reject template for target negotiation. */ public static final TemplateUri TARGET_NEGOTIATION_ACCEPT_REJECT = TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "target-negotiation", "accept-reject"); + /** + * Raw template URI of {@link #TARGET_NEGOTIATION_ACCEPT_REJECT}: + * {@code Negotiation-T/target-negotiation/accept-reject/v1}. + */ + public static final String TARGET_NEGOTIATION_ACCEPT_REJECT_URI = TARGET_NEGOTIATION_ACCEPT_REJECT.uri(); + /** Negotiation-T propose template for feasibility negotiation. */ public static final TemplateUri FEASIBILITY_NEGOTIATION_PROPOSE = TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "feasibility-negotiation", "propose"); + /** + * Raw template URI of {@link #FEASIBILITY_NEGOTIATION_PROPOSE}: + * {@code Negotiation-T/feasibility-negotiation/propose/v1}. + */ + public static final String FEASIBILITY_NEGOTIATION_PROPOSE_URI = FEASIBILITY_NEGOTIATION_PROPOSE.uri(); + /** Negotiation-T accept-reject template for feasibility negotiation. */ public static final TemplateUri FEASIBILITY_NEGOTIATION_ACCEPT_REJECT = TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "feasibility-negotiation", "accept-reject"); + /** + * Raw template URI of {@link #FEASIBILITY_NEGOTIATION_ACCEPT_REJECT}: + * {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}. + */ + public static final String FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI = + FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(); + /** Negotiation-T common abort template. */ public static final TemplateUri NEGOTIATION_ABORT = TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "common", "abort"); + /** Raw template URI of {@link #NEGOTIATION_ABORT}: {@code Negotiation-T/common/abort/v1}. */ + public static final String NEGOTIATION_ABORT_URI = NEGOTIATION_ABORT.uri(); + /** All built-in Task-T templates. */ public static final List TASK = List.of(ENERGY_SAVING, PRIVATE_LINE_COMPLAINT); + /** Raw template URIs of all built-in Task-T templates. */ + public static final List TASK_URIS = List.of(ENERGY_SAVING_URI, PRIVATE_LINE_COMPLAINT_URI); + /** All built-in Notification-T templates. */ public static final List NOTIFICATION = List.of(SUBSCRIBE_INCIDENT, SERVICE_RECOVERY); + /** Raw template URIs of all built-in Notification-T templates. */ + public static final List NOTIFICATION_URIS = List.of(SUBSCRIBE_INCIDENT_URI, SERVICE_RECOVERY_URI); + /** All built-in Authorization-T templates. */ public static final List AUTHORIZATION = List.of(AUTHORIZATION_POLICY_MANAGEMENT); + /** Raw template URIs of all built-in Authorization-T templates. */ + public static final List AUTHORIZATION_URIS = List.of(AUTHORIZATION_POLICY_MANAGEMENT_URI); + /** All built-in Negotiation-T templates. */ public static final List NEGOTIATION = List.of( INFORMATION_NEGOTIATION_PROPOSE, @@ -105,4 +173,14 @@ private StandardTemplates() { FEASIBILITY_NEGOTIATION_PROPOSE, FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, NEGOTIATION_ABORT); + + /** Raw template URIs of all built-in Negotiation-T templates. */ + public static final List NEGOTIATION_URIS = List.of( + INFORMATION_NEGOTIATION_PROPOSE_URI, + INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, + TARGET_NEGOTIATION_PROPOSE_URI, + TARGET_NEGOTIATION_ACCEPT_REJECT_URI, + FEASIBILITY_NEGOTIATION_PROPOSE_URI, + FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, + NEGOTIATION_ABORT_URI); } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java index 7f95ee72..41f18f24 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java @@ -23,7 +23,7 @@ public interface ContentValidator { * @param templateUri URI of the template the content is validated against, such as * {@code Task-T/network-layer/ran-energy-saving/v1} * @return filled parameter data carrying the merged parameters - * @throws ContentValidationException with {@code validation_invalid_input} if the template URI is null or + * @throws ContentValidationException with {@code negotiation.invalid_input} if the template URI is null or * malformed, the prompt is null or blank, or the schema is null * @throws ContentValidationException if the validation fails at any stage */ diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/StandardTemplatesStringConstantsTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/StandardTemplatesStringConstantsTest.java new file mode 100644 index 00000000..c9629494 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/StandardTemplatesStringConstantsTest.java @@ -0,0 +1,78 @@ +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the parallel string constant set of {@link StandardTemplates}. + * + *

        Pins the typed {@link TemplateUri} constants and their raw {@code _URI} string twins in lockstep so the two + * spellings of the same template cannot drift apart, and mirrors the family aggregates the same way. + * + * @since 2026-08 + */ +class StandardTemplatesStringConstantsTest { + + @Test + void should_keepTypedAndStringConstantsInLockstep() { + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), StandardTemplates.ENERGY_SAVING_URI); + assertEquals(StandardTemplates.PRIVATE_LINE_COMPLAINT.uri(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + assertEquals(StandardTemplates.SUBSCRIBE_INCIDENT.uri(), StandardTemplates.SUBSCRIBE_INCIDENT_URI); + assertEquals(StandardTemplates.SERVICE_RECOVERY.uri(), StandardTemplates.SERVICE_RECOVERY_URI); + assertEquals( + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT.uri(), + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI); + assertEquals( + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI); + assertEquals( + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); + assertEquals(StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), StandardTemplates.TARGET_NEGOTIATION_PROPOSE_URI); + assertEquals( + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI); + assertEquals( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE_URI); + assertEquals( + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI); + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), StandardTemplates.NEGOTIATION_ABORT_URI); + } + + @Test + void should_keepFamilyAggregatesInLockstep() { + assertEquals(uriList(StandardTemplates.TASK), StandardTemplates.TASK_URIS); + assertEquals(uriList(StandardTemplates.NOTIFICATION), StandardTemplates.NOTIFICATION_URIS); + assertEquals(uriList(StandardTemplates.AUTHORIZATION), StandardTemplates.AUTHORIZATION_URIS); + assertEquals(uriList(StandardTemplates.NEGOTIATION), StandardTemplates.NEGOTIATION_URIS); + } + + @Test + void should_exposeOneStringConstantPerTypedConstant() { + List typed = Arrays.stream(StandardTemplates.class.getDeclaredFields()) + .filter(field -> field.getType() == TemplateUri.class) + .map(Field::getName) + .sorted() + .toList(); + List strings = Arrays.stream(StandardTemplates.class.getDeclaredFields()) + .filter(field -> field.getType() == String.class) + .map(Field::getName) + .filter(name -> name.endsWith("_URI")) + .sorted() + .toList(); + // every typed constant ENERGY_SAVING must have exactly one string twin ENERGY_SAVING_URI, and no other + // *_URI string constant may exist + List expected = typed.stream().map(name -> name + "_URI").sorted().toList(); + assertEquals(expected, strings); + } + + private static List uriList(List uris) { + return uris.stream().map(TemplateUri::uri).toList(); + } +} From 9f6146e765ca20c3135c3689a32b6c641c039eac Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sun, 30 Aug 2026 00:14:00 +0800 Subject: [PATCH 250/295] feat(client): accept string template uris on A2ATClient facade Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 252 ++++++++++-------- .../api/A2ATClientNegotiationApiTest.java | 49 +++- .../A2ATClientNegotiationEnvConfigTest.java | 10 +- .../a2at/sdk/client/api/A2ATClientTest.java | 66 ++++- .../AuthorizationEntryPointMetadataTest.java | 4 +- 5 files changed, 243 insertions(+), 138 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 87ef5086..ca5d403f 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -76,18 +76,20 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { * URI, bypassing scenario recognition. * * @param text natural-language task input - * @param templateUri template URI identifying the target template + * @param templateUri template URI string identifying the target template, such as + * {@code Task-T/network-layer/ran-energy-saving/v1} * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null + * @throws NullPointerException if the text or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code * {@code input.text_too_long} when the text exceeds the configured maximum length */ - public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull String templateUri) { Objects.requireNonNull(text, "text"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateTaskPromptFromText(text, templateUri); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateTaskPromptFromText(text, parsedTemplateUri); } /** @@ -96,20 +98,21 @@ public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull * * @param data structured task input as a string-to-object map * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template + * @param templateUri template URI string identifying the target template, such as + * {@code Task-T/network-layer/ran-energy-saving/v1} * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty + * @throws IllegalArgumentException if the template URI is blank or malformed, or the schema is empty * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails */ public MetadataContent generateTaskPromptFromDataWithSchema( - @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { + @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { Objects.requireNonNull(data, "data"); Objects.requireNonNull(schema, "schema"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, templateUri); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, parsedTemplateUri); } /** @@ -120,18 +123,20 @@ public MetadataContent generateTaskPromptFromDataWithSchema( * classpath resource source. * * @param text natural-language authorization input - * @param templateUri template URI identifying the target template + * @param templateUri template URI string identifying the target template, such as + * {@code Authorization-T/authorization-policy-management/v1} * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null + * @throws NullPointerException if the text or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code * {@code input.text_too_long} when the text exceeds the configured maximum length */ - public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull String templateUri) { Objects.requireNonNull(text, "text"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateAuthPromptFromText(text, templateUri); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateAuthPromptFromText(text, parsedTemplateUri); } /** @@ -143,20 +148,21 @@ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull * * @param data structured authorization input as a string-to-object map * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template + * @param templateUri template URI string identifying the target template, such as + * {@code Authorization-T/authorization-policy-management/v1} * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty + * @throws IllegalArgumentException if the template URI is blank or malformed, or the schema is empty * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails */ public MetadataContent generateAuthPromptFromDataWithSchema( - @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { + @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { Objects.requireNonNull(data, "data"); Objects.requireNonNull(schema, "schema"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, templateUri); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, parsedTemplateUri); } /** @@ -164,18 +170,20 @@ public MetadataContent generateAuthPromptFromDataWithSchema( * template URI, bypassing scenario recognition. * * @param text natural-language notification input - * @param templateUri template URI identifying the target template + * @param templateUri template URI string identifying the target template, such as + * {@code Notification-T/network-layer/subscribe-incident/v1} * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null + * @throws NullPointerException if the text or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code * {@code input.text_too_long} when the text exceeds the configured maximum length */ - public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull TemplateUri templateUri) { + public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull String templateUri) { Objects.requireNonNull(text, "text"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateNotificationPromptFromText(text, templateUri); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateNotificationPromptFromText(text, parsedTemplateUri); } /** @@ -184,20 +192,21 @@ public MetadataContent generateNotificationPromptFromText(@NonNull String text, * * @param data structured notification input as a string-to-object map * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template + * @param templateUri template URI string identifying the target template, such as + * {@code Notification-T/network-layer/subscribe-incident/v1} * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty + * @throws IllegalArgumentException if the template URI is blank or malformed, or the schema is empty * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails */ public MetadataContent generateNotificationPromptFromDataWithSchema( - @NonNull Map data, @NonNull Map schema, @NonNull TemplateUri templateUri) { + @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { Objects.requireNonNull(data, "data"); Objects.requireNonNull(schema, "schema"); - Objects.requireNonNull(templateUri, "templateUri"); - return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, templateUri); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, parsedTemplateUri); } /** @@ -243,18 +252,20 @@ public Map continueNegotiation( * generator of the negotiation type addressed by the template URI and rendered from that template. * * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase - * segment must be {@code propose} + * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/propose/v1}; its + * phase segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content type * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationProposePromptFromData( - @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateProposeFromData(data, templateUri); + @NonNull NegotiationProposeData data, @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateProposeFromData(data, parsedTemplateUri); } /** @@ -264,19 +275,20 @@ public MetadataContent generateNegotiationProposePromptFromData( * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} + * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; + * its phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the - * content conclusion is not {@code Accept} + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content, or the content conclusion is not {@code Accept} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationAcceptPromptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAcceptFromData(data, templateUri); + @NonNull NegotiationEndingData data, @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAcceptFromData(data, parsedTemplateUri); } /** @@ -286,19 +298,20 @@ public MetadataContent generateNegotiationAcceptPromptFromData( * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} + * @param templateUri template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; + * its phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the - * content conclusion is not {@code Reject} + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content, or the content conclusion is not {@code Reject} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationRejectPromptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateRejectFromData(data, templateUri); + @NonNull NegotiationEndingData data, @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateRejectFromData(data, parsedTemplateUri); } /** @@ -308,18 +321,19 @@ public MetadataContent generateNegotiationRejectPromptFromData( * template must be the common abort template and the content carries only the termination reason. * * @param data typed abort input carrying the negotiation context and the termination reason - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @param templateUri template URI string of the common abort template {@code Negotiation-T/common/abort/v1} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data or its context is null - * @throws IllegalArgumentException if the template URI does not address the common abort template or the - * termination reason is blank + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or does not address the common abort + * template, or the termination reason is blank * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationAbortPromptFromData( - @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAbortFromData(data, templateUri); + @NonNull NegotiationAbortData data, @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAbortFromData(data, parsedTemplateUri); } /** @@ -333,11 +347,11 @@ public MetadataContent generateNegotiationAbortPromptFromData( * @param text free-text input describing the message content * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment - * must be {@code propose} + * @param templateUri template URI string such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase + * segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails @@ -348,8 +362,9 @@ public MetadataContent generateNegotiationAbortPromptFromData( public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateProposeFromText(text, context, templateUri); + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateProposeFromText(text, context, parsedTemplateUri); } /** @@ -362,11 +377,11 @@ public MetadataContent generateNegotiationProposePromptFromText( * @param text free-text input describing the message content * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} + * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; + * its phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails @@ -377,8 +392,9 @@ public MetadataContent generateNegotiationProposePromptFromText( public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAcceptFromText(text, context, templateUri); + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAcceptFromText(text, context, parsedTemplateUri); } /** @@ -391,11 +407,11 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * @param text free-text input describing the message content * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} + * @param templateUri template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; + * its phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails @@ -406,8 +422,9 @@ public MetadataContent generateNegotiationAcceptPromptFromText( public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateRejectFromText(text, context, templateUri); + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateRejectFromText(text, context, parsedTemplateUri); } /** @@ -421,10 +438,11 @@ public MetadataContent generateNegotiationRejectPromptFromText( * @param text free-text input stating the termination reason * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @param templateUri template URI string of the common abort template {@code Negotiation-T/common/abort/v1} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context is null - * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or does not address the common abort + * template * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails @@ -435,8 +453,9 @@ public MetadataContent generateNegotiationRejectPromptFromText( public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAbortFromText(text, context, templateUri); + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAbortFromText(text, context, parsedTemplateUri); } /** @@ -456,15 +475,18 @@ public List getPrompts() { /** * Loads one template by its URI, regardless of the extension. * - *

        This query never throws: a missing template yields an empty result together with a warning log instead of a - * failure. + *

        This query never throws for a well-formed template URI: a missing template yields an empty result together + * with a warning log instead of a failure. * - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} or + * @param templateUri template URI string such as {@code Negotiation-T/target-negotiation/propose/v1} or * {@code Task-T/network-layer/ran-energy-saving/v1} * @return the addressed template, or an empty optional when no template exists for it in the configured language + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed */ - public Optional getPrompt(@NonNull TemplateUri templateUri) { - return templateQueryService.getPrompt(templateUri); + public Optional getPrompt(@NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return templateQueryService.getPrompt(parsedTemplateUri); } /** @@ -480,11 +502,12 @@ public Optional getPrompt(@NonNull TemplateUri templateUri) { * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code propose} + * @param templateUri template URI string declaring the expected negotiation type and phase; its phase segment must + * be {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or the template + * URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation.invalid_input} when the prompt is not a negotiation message, * {@code negotiation.rule_violation} when the negotiation context violates a rule, @@ -496,8 +519,10 @@ public FilledParamData validateProposePromptAndDataFilling( @NonNull String prompt, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateProposePromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); } /** @@ -511,11 +536,12 @@ public FilledParamData validateProposePromptAndDataFilling( * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} + * @param templateUri template URI string declaring the expected negotiation type and phase; its phase segment must + * be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or the template + * URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation.invalid_input} when the prompt is not a negotiation message, * {@code negotiation.rule_violation} when the negotiation context violates a rule, @@ -527,8 +553,9 @@ public FilledParamData validateAcceptPromptAndDataFilling( @NonNull String prompt, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); } /** @@ -542,11 +569,12 @@ public FilledParamData validateAcceptPromptAndDataFilling( * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} + * @param templateUri template URI string declaring the expected negotiation type and phase; its phase segment must + * be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI phase contradicts the method + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or the template + * URI phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation.invalid_input} when the prompt is not a negotiation message, * {@code negotiation.rule_violation} when the negotiation context violates a rule, @@ -558,8 +586,9 @@ public FilledParamData validateRejectPromptAndDataFilling( @NonNull String prompt, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); } /** @@ -573,11 +602,11 @@ public FilledParamData validateRejectPromptAndDataFilling( * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @param templateUri template URI string of the common abort template {@code Negotiation-T/common/abort/v1} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI does not address the common abort - * template + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or the template + * URI does not address the common abort template * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation.invalid_input} when the prompt is not a negotiation message, * {@code negotiation.rule_violation} when the negotiation context violates a rule, @@ -589,7 +618,14 @@ public FilledParamData validateAbortPromptAndDataFilling( @NonNull String prompt, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); + } + + private static TemplateUri parseTemplateUri(String templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); } } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index 3c55e8ee..d404229a 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -53,7 +54,7 @@ void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOE new NegotiationProposeData( new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); + INFORMATION_PROPOSE_URI); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); @@ -73,7 +74,7 @@ void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOE new NegotiationProposeData( new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE); + INFORMATION_PROPOSE_URI); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); @@ -95,7 +96,7 @@ void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOExceptio new NegotiationAbortData( new NegotiationContext(UUID, 5, 5, NegotiationPerformative.ABORT), new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - StandardTemplates.NEGOTIATION_ABORT); + StandardTemplates.NEGOTIATION_ABORT_URI); assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); assertTrue(result.promptText().contains("## 协商结果\nAbort")); @@ -113,7 +114,7 @@ void generatesAbortPromptFromDataWithBuiltinEnglishTemplates() throws IOExceptio new NegotiationContext(UUID, 3, 5, NegotiationPerformative.ABORT), new NegotiationAbortContent( "Reached the negotiation round limit. This negotiation is confirmed and ended.")), - StandardTemplates.NEGOTIATION_ABORT); + StandardTemplates.NEGOTIATION_ABORT_URI); assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); assertTrue(result.promptText().contains("## Negotiation Result\nAbort")); @@ -126,7 +127,7 @@ void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { A2ATClient client = new A2ATClient(writeEnv("zh-CN")); PromptTemplate template = - client.getPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); + client.getPrompt(StandardTemplates.NEGOTIATION_ABORT_URI).orElseThrow(); assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); assertFalse(template.content().isBlank()); } @@ -135,17 +136,43 @@ void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - assertTrue(client.getPrompt(INFORMATION_PROPOSE).isPresent()); - assertTrue(client.getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + assertTrue(client.getPrompt(INFORMATION_PROPOSE_URI).isPresent()); + assertTrue(client.getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI) .isPresent()); - assertFalse( - client.getPrompt(TemplateUri.of("Negotiation-T", List.of("information-negotiation", "propose"), "v9")) - .isPresent()); - PromptTemplate template = client.getPrompt(INFORMATION_PROPOSE).orElseThrow(); + assertFalse(client.getPrompt("Negotiation-T/information-negotiation/propose/v9").isPresent()); + PromptTemplate template = client.getPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); assertEquals(INFORMATION_PROPOSE, template.templateUri()); assertFalse(template.content().isBlank()); } + @Test + void generateNegotiationPromptFromDataThrowsOnMalformedTemplateUriString() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + "Task-T/only-one-segment")); + assertTrue(ex.getMessage().contains("Unparseable template URI"), "message was: " + ex.getMessage()); + } + + @Test + void validateNegotiationPromptThrowsOnBlankTemplateUriString() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> client.validateProposePromptAndDataFilling( + "rendered negotiation message", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + " ")); + assertTrue(ex.getMessage().contains("Unparseable template URI"), "message was: " + ex.getMessage()); + } + private static List negotiationPrompts(A2ATClient client) { return client.getPrompts().stream() .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index 96400f77..326dcf43 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -107,7 +107,7 @@ void languageAndMaxAttemptsAreObservableAndClasspathIgnoresLocalRoot() throws IO new NegotiationProposeData( new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); + INFORMATION_PROPOSE_URI); assertTrue(result.promptText().contains("所需信息项"), "the zh-CN language must select the Chinese templates"); assertFalse( @@ -119,7 +119,7 @@ void languageAndMaxAttemptsAreObservableAndClasspathIgnoresLocalRoot() throws IO () -> client.generateNegotiationProposePromptFromText( "请提供节能区域。", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE)); + INFORMATION_PROPOSE_URI)); assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); assertEquals( 1, @@ -141,19 +141,19 @@ void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { new NegotiationProposeData( new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE); + INFORMATION_PROPOSE_URI); assertTrue(result.promptText().contains("## Information Negotiation"), "the default language must be en-US"); assertTrue(result.promptText().contains("Required Information Items")); assertEquals(7, negotiationPrompts(client).size(), "the built-in resources must be used by default"); - assertTrue(client.getPrompt(INFORMATION_PROPOSE).isPresent()); + assertTrue(client.getPrompt(INFORMATION_PROPOSE_URI).isPresent()); assertThrows( NegotiationGenerationException.class, () -> client.generateNegotiationProposePromptFromText( "Provide the ran-energy-saving region.", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE)); + INFORMATION_PROPOSE_URI)); assertEquals( 2, retryEventCount(), diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index e56b0037..62ca5d2e 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -21,7 +21,6 @@ import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; @@ -99,6 +98,7 @@ void noLongerExposesLowLevelPromptGenerationAssemblyConstructors() { void doesNotKeepStaticAssemblyHelpersInsideFacade() { long staticMethodCount = Arrays.stream(A2ATClient.class.getDeclaredMethods()) .filter(method -> java.lang.reflect.Modifier.isStatic(method.getModifiers())) + .filter(method -> !java.lang.reflect.Modifier.isPrivate(method.getModifiers())) .count(); assertEquals(0, staticMethodCount); @@ -317,7 +317,7 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO void fromTextEntryPointsReturnMetadataContent() throws IOException { Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); A2ATClient client = new A2ATClient(envFile); - TemplateUri unconstrained = TemplateUri.of("Task-T", "network-layer", "unconstrained"); + String unconstrained = "Task-T/network-layer/unconstrained/v1"; MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); @@ -348,11 +348,11 @@ void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { Map schema = Map.of("type", "object"); MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); + client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); + client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); + client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); assertNotNull(taskResult); assertEquals(StandardTemplates.ENERGY_SAVING.uri(), taskResult.templateUri()); @@ -374,7 +374,7 @@ void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); A2ATClient client = new A2ATClient(envFile); - TemplateUri unconstrained = TemplateUri.of("Task-T", "network-layer", "unconstrained"); + String unconstrained = "Task-T/network-layer/unconstrained/v1"; MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); @@ -394,11 +394,11 @@ void fromDataWithSchemaReturnsCorrectExtensionUriPerContentType() throws IOExcep Map schema = Map.of("type", "object"); MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); + client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); + client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING); + client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); @@ -440,6 +440,48 @@ void fromDataWithSchemaThrowsOnNullTemplateUri() throws IOException { assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); } + @Test + void fromTextThrowsOnMalformedTemplateUriString() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + for (String malformed : new String[] {"not-a-uri", "Task-T/only-one-segment", "Task-T/../v1"}) { + IllegalArgumentException taskEx = assertThrows( + IllegalArgumentException.class, + () -> client.generateTaskPromptFromText("Please analyze Site A.", malformed)); + assertTrue(taskEx.getMessage().contains("Unparseable template URI"), "message was: " + taskEx.getMessage()); + IllegalArgumentException authEx = assertThrows( + IllegalArgumentException.class, () -> client.generateAuthPromptFromText("Authorize access.", malformed)); + assertTrue(authEx.getMessage().contains("Unparseable template URI"), "message was: " + authEx.getMessage()); + IllegalArgumentException notifEx = assertThrows( + IllegalArgumentException.class, + () -> client.generateNotificationPromptFromText("Report finished.", malformed)); + assertTrue(notifEx.getMessage().contains("Unparseable template URI"), "message was: " + notifEx.getMessage()); + } + } + + @Test + void fromTextThrowsOnBlankTemplateUriString() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + IllegalArgumentException taskEx = assertThrows( + IllegalArgumentException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", " ")); + assertTrue(taskEx.getMessage().contains("Unparseable template URI"), "message was: " + taskEx.getMessage()); + } + + @Test + void getPromptThrowsOnMalformedAndBlankTemplateUriString() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + IllegalArgumentException malformedEx = + assertThrows(IllegalArgumentException.class, () -> client.getPrompt("not-a-uri")); + assertTrue(malformedEx.getMessage().contains("Unparseable template URI"), "message was: " + malformedEx.getMessage()); + IllegalArgumentException blankEx = assertThrows(IllegalArgumentException.class, () -> client.getPrompt(" ")); + assertTrue(blankEx.getMessage().contains("Unparseable template URI"), "message was: " + blankEx.getMessage()); + } + @Test void fromTextRejectsOverLimitTextWhenConfiguredWithSmallLimit() throws IOException { Path envFile = writeMinimalClientEnvWithInputLimit(TEST_MOCK_PROVIDER, 100); @@ -448,13 +490,13 @@ void fromTextRejectsOverLimitTextWhenConfiguredWithSmallLimit() throws IOExcepti PromptGenerationException taskEx = assertThrows( PromptGenerationException.class, - () -> client.generateTaskPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + () -> client.generateTaskPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING_URI)); PromptGenerationException authEx = assertThrows( PromptGenerationException.class, - () -> client.generateAuthPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + () -> client.generateAuthPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING_URI)); PromptGenerationException notificationEx = assertThrows( PromptGenerationException.class, - () -> client.generateNotificationPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + () -> client.generateNotificationPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING_URI)); assertEquals("input.text_too_long", taskEx.getCode()); assertEquals("input.text_too_long", authEx.getCode()); diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationEntryPointMetadataTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationEntryPointMetadataTest.java index 284f14a5..68f382fa 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationEntryPointMetadataTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationEntryPointMetadataTest.java @@ -42,7 +42,7 @@ void should_generateAuthPromptFromText_ProducesMetadataContentWithAuthorizationE MetadataContent result = client.generateAuthPromptFromText( "新增两条动网操作授权策略:业务投诉诊断/业务抢通/隧道调优/限期生效(2026-06-01~2030-06-18);载波调度/业务抢通/载波调度/永久生效", - StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI); assertNotNull(result); assertEquals( @@ -61,7 +61,7 @@ void should_generateAuthPromptFromDataWithSchema_ProducesMetadataContentWithAuth Map schema = Map.of("操作类型", "授权策略操作类型,取值范围:新增/修改/删除/查询授权策略"); MetadataContent result = client.generateAuthPromptFromDataWithSchema( - data, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + data, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI); assertNotNull(result); assertEquals( From df20191712fd70b0045b875c3f6f24cd1caafde1 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sun, 30 Aug 2026 00:14:00 +0800 Subject: [PATCH 251/295] feat(server): accept string template uris on A2ATServer facade Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/server/A2ATServer.java | 222 ++++++++-------- .../A2ATServerContentValidationApiTest.java | 240 +++++++++++------- .../api/A2ATServerNegotiationApiTest.java | 16 +- .../server/api/InputTextLengthLimitTest.java | 6 +- .../TaskPromptNegotiationCoexistenceTest.java | 11 +- .../AuthorizationContentValidatorTest.java | 2 +- 6 files changed, 278 insertions(+), 219 deletions(-) diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 6f91f9e2..05b534c6 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -3,6 +3,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.model.FilledParamData; @@ -124,18 +125,19 @@ public Map continueNegotiation( * generator of the negotiation type addressed by the template URI and rendered from that template. * * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase - * segment must be {@code propose} + * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/propose/v1}; + * its phase segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content type + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content type * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationProposePromptFromData( - @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateProposeFromData(data, templateUri); + @NonNull NegotiationProposeData data, @NonNull String templateUri) { + return negotiationContentService.generateProposeFromData(data, parseTemplateUri(templateUri)); } /** @@ -145,19 +147,19 @@ public MetadataContent generateNegotiationProposePromptFromData( * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} + * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; + * its phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the - * content conclusion is not {@code Accept} + * @throws IllegalArgumentException if the template URI is blank or malformed, its phase or type contradicts the + * method or the content, or the content conclusion is not {@code Accept} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationAcceptPromptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAcceptFromData(data, templateUri); + @NonNull NegotiationEndingData data, @NonNull String templateUri) { + return negotiationContentService.generateAcceptFromData(data, parseTemplateUri(templateUri)); } /** @@ -167,19 +169,19 @@ public MetadataContent generateNegotiationAcceptPromptFromData( * mismatched conclusion is a content error. * * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} + * @param templateUri raw template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; + * its phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI phase or type contradicts the method or the content, or the - * content conclusion is not {@code Reject} + * @throws IllegalArgumentException if the template URI is blank or malformed, its phase or type contradicts the + * method or the content, or the content conclusion is not {@code Reject} * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationRejectPromptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateRejectFromData(data, templateUri); + @NonNull NegotiationEndingData data, @NonNull String templateUri) { + return negotiationContentService.generateRejectFromData(data, parseTemplateUri(templateUri)); } /** @@ -189,18 +191,18 @@ public MetadataContent generateNegotiationRejectPromptFromData( * template must be the common abort template and the content carries only the termination reason. * * @param data typed abort input carrying the negotiation context and the termination reason - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @param templateUri raw template URI string of the common abort template {@code Negotiation-T/common/abort/v1} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template or the - * termination reason is blank + * @throws IllegalArgumentException if the template URI is blank, malformed or does not address the common abort + * template, or the termination reason is blank * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template exists for the URI in any resource root, or the code * {@code negotiation.field_missing} when rendering the template fails */ public MetadataContent generateNegotiationAbortPromptFromData( - @NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAbortFromData(data, templateUri); + @NonNull NegotiationAbortData data, @NonNull String templateUri) { + return negotiationContentService.generateAbortFromData(data, parseTemplateUri(templateUri)); } /** @@ -214,11 +216,11 @@ public MetadataContent generateNegotiationAbortPromptFromData( * @param text free-text input describing the message content * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment - * must be {@code propose} + * @param templateUri raw template URI string such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase + * segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} @@ -230,8 +232,8 @@ public MetadataContent generateNegotiationAbortPromptFromData( public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateProposeFromText(text, context, templateUri); + @NonNull String templateUri) { + return negotiationContentService.generateProposeFromText(text, context, parseTemplateUri(templateUri)); } /** @@ -244,11 +246,11 @@ public MetadataContent generateNegotiationProposePromptFromText( * @param text free-text input describing the message content * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} + * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; + * its phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} @@ -260,8 +262,8 @@ public MetadataContent generateNegotiationProposePromptFromText( public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAcceptFromText(text, context, templateUri); + @NonNull String templateUri) { + return negotiationContentService.generateAcceptFromText(text, context, parseTemplateUri(templateUri)); } /** @@ -274,11 +276,11 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * @param text free-text input describing the message content * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} + * @param templateUri raw template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; + * its phase segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI phase contradicts the method + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} @@ -290,8 +292,8 @@ public MetadataContent generateNegotiationAcceptPromptFromText( public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateRejectFromText(text, context, templateUri); + @NonNull String templateUri) { + return negotiationContentService.generateRejectFromText(text, context, parseTemplateUri(templateUri)); } /** @@ -303,10 +305,11 @@ public MetadataContent generateNegotiationRejectPromptFromText( * @param text free-text input stating the termination reason * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @param templateUri raw template URI string of the common abort template {@code Negotiation-T/common/abort/v1} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws IllegalArgumentException if the template URI is blank, malformed or does not address the common abort + * template * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} @@ -318,8 +321,8 @@ public MetadataContent generateNegotiationRejectPromptFromText( public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull TemplateUri templateUri) { - return negotiationContentService.generateAbortFromText(text, context, templateUri); + @NonNull String templateUri) { + return negotiationContentService.generateAbortFromText(text, context, parseTemplateUri(templateUri)); } /** @@ -339,15 +342,17 @@ public List getPrompts() { /** * Loads one template by its URI, regardless of the extension. * - *

        This query never throws: a template that exists nowhere for the configured language returns an empty result - * and logs an actionable warning. + *

        This query never throws for a well-formed template URI: a template that exists nowhere for the configured + * language returns an empty result and logs an actionable warning. * - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1} or + * @param templateUri raw template URI string such as {@code Negotiation-T/target-negotiation/propose/v1} or * {@code Task-T/network-layer/ran-energy-saving/v1} * @return the addressed template, or an empty result when the template does not exist for the configured language + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed */ - public Optional getPrompt(@NonNull TemplateUri templateUri) { - return templateQueryService.getPrompt(templateUri); + public Optional getPrompt(@NonNull String templateUri) { + return templateQueryService.getPrompt(parseTemplateUri(templateUri)); } /** @@ -361,11 +366,12 @@ public Optional getPrompt(@NonNull TemplateUri templateUri) { * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code propose} + * @param templateUri raw template URI string declaring the expected negotiation type and phase; its phase segment + * must be {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method + * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or its phase + * contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation.invalid_input} when the prompt is not a negotiation message, * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context @@ -381,8 +387,9 @@ public FilledParamData validateProposePromptAndDataFilling( @NonNull String prompt, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); + @NonNull String templateUri) { + return negotiationContentService.validateProposePromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri)); } /** @@ -396,11 +403,12 @@ public FilledParamData validateProposePromptAndDataFilling( * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} + * @param templateUri raw template URI string declaring the expected negotiation type and phase; its phase segment + * must be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method + * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or its phase + * contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation.invalid_input} when the prompt is not a negotiation message, * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context @@ -416,8 +424,9 @@ public FilledParamData validateAcceptPromptAndDataFilling( @NonNull String prompt, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); + @NonNull String templateUri) { + return negotiationContentService.validateAcceptPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri)); } /** @@ -431,11 +440,12 @@ public FilledParamData validateAcceptPromptAndDataFilling( * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} + * @param templateUri raw template URI string declaring the expected negotiation type and phase; its phase segment + * must be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank or the template URI phase contradicts the method + * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or its phase + * contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation.invalid_input} when the prompt is not a negotiation message, * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context @@ -451,8 +461,9 @@ public FilledParamData validateRejectPromptAndDataFilling( @NonNull String prompt, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); + @NonNull String templateUri) { + return negotiationContentService.validateRejectPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri)); } /** @@ -466,10 +477,11 @@ public FilledParamData validateRejectPromptAndDataFilling( * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @param templateUri raw template URI string of the common abort template {@code Negotiation-T/common/abort/v1} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws IllegalArgumentException if the prompt is blank or the template URI does not address the common abort - * template + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank, the template URI is blank, malformed or does not address + * the common abort template * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code * {@code negotiation.invalid_input} when the prompt is not a negotiation message, * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context @@ -485,78 +497,80 @@ public FilledParamData validateAbortPromptAndDataFilling( @NonNull String prompt, net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); + @NonNull String templateUri) { + return negotiationContentService.validateAbortPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri)); } /** * Validates a task prompt and extracts its parameters. * * @param prompt rendered task prompt text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected task template; its prefix segment must be {@code Task-T} + * @param templateUri raw template URI string declaring the expected task template; its prefix segment must be + * {@code Task-T} * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI is blank or malformed * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code negotiation.invalid_input} when the template URI is null, {@code negotiation.semantic_rejected} when - * the semantic validation rejects the content, {@code llm.invocation_failed} or {@code llm.response_invalid} - * when the semantic step fails after exhausting its retries, or {@code template.not_found} when the validation - * prompt resources are missing, or {@code input.text_too_long} when the prompt exceeds the configured maximum - * length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateTaskPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return taskContentValidator.validate(prompt, schema, templateUri); + @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { + return taskContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); } /** * Validates a notification prompt and extracts its parameters. * * @param prompt rendered notification prompt text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected notification template; its prefix segment must be - * {@code Notification-T} + * @param templateUri raw template URI string declaring the expected notification template; its prefix segment must + * be {@code Notification-T} * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI is blank or malformed * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code negotiation.invalid_input} when the template URI is null, {@code negotiation.semantic_rejected} when - * the semantic validation rejects the content, {@code llm.invocation_failed} or {@code llm.response_invalid} - * when the semantic step fails after exhausting its retries, or {@code template.not_found} when the validation - * prompt resources are missing, or {@code input.text_too_long} when the prompt exceeds the configured maximum - * length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateNotificationPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return notificationContentValidator.validate(prompt, schema, templateUri); + @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { + return notificationContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); } /** * Validates an authorization prompt and extracts its parameters. * * @param prompt rendered authorization prompt text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected authorization template; its prefix segment must be - * {@code Authorization-T} + * @param templateUri raw template URI string declaring the expected authorization template; its prefix segment + * must be {@code Authorization-T} * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt or schema is null - * @throws IllegalArgumentException if the prompt is blank + * @throws NullPointerException if the prompt, schema or template URI is null + * @throws IllegalArgumentException if the prompt is blank, or the template URI is blank or malformed * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code negotiation.invalid_input} when the template URI is null, {@code negotiation.semantic_rejected} when - * the semantic validation rejects the content, {@code llm.invocation_failed} or {@code llm.response_invalid} - * when the semantic step fails after exhausting its retries, or {@code template.not_found} when the validation - * prompt resources are missing, or {@code input.text_too_long} when the prompt exceeds the configured maximum - * length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateAuthPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri) { - return authContentValidator.validate(prompt, schema, templateUri); + @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { + return authContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + } + + private static TemplateUri parseTemplateUri(String templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); } } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java index 0b713361..d5b45403 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java @@ -1,96 +1,144 @@ -package net.openan.a2at.sdk.server.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.server.A2ATServer; -import org.junit.jupiter.api.Test; - -class A2ATServerContentValidationApiTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - @Test - void validateTaskPromptRejectsNullTemplateUriWithValidationInvalidInput() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - ContentValidationException error = assertThrows( - ContentValidationException.class, - () -> server.validateTaskPromptAndDataFilling("test prompt", Map.of(), null)); - - assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, error.getCode()); - } - - @Test - void validateNotificationPromptRejectsNullTemplateUriWithValidationInvalidInput() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - ContentValidationException error = assertThrows( - ContentValidationException.class, - () -> server.validateNotificationPromptAndDataFilling("test prompt", Map.of(), null)); - - assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, error.getCode()); - } - - @Test - void validateAuthPromptRejectsNullTemplateUriWithValidationInvalidInput() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - ContentValidationException error = assertThrows( - ContentValidationException.class, - () -> server.validateAuthPromptAndDataFilling("test prompt", Map.of(), null)); - - assertEquals(A2ATErrorCodes.VALIDATION_INVALID_INPUT, error.getCode()); - } - - @Test - void generateNegotiationProposePromptFromDataStillThrowsOnNullTemplateUri() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - assertThrows( - NullPointerException.class, - () -> server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - null)); - } - - @Test - void getPromptStillThrowsOnNullTemplateUri() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - assertThrows(NullPointerException.class, () -> server.getPrompt(null)); - } - - private static Path writeEnv() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-server-content-validation-env"); - Path envFile = tempDir.resolve("server.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - A2AT_PROMPT_COMPLIANCE_ENABLED=false - """); - return envFile; - } -} +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.Test; + +class A2ATServerContentValidationApiTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + @Test + void validateTaskPromptRejectsNullTemplateUriWithNullPointerException() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows( + NullPointerException.class, + () -> server.validateTaskPromptAndDataFilling("test prompt", Map.of(), null)); + } + + @Test + void validateNotificationPromptRejectsNullTemplateUriWithNullPointerException() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows( + NullPointerException.class, + () -> server.validateNotificationPromptAndDataFilling("test prompt", Map.of(), null)); + } + + @Test + void validateAuthPromptRejectsNullTemplateUriWithNullPointerException() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows( + NullPointerException.class, + () -> server.validateAuthPromptAndDataFilling("test prompt", Map.of(), null)); + } + + @Test + void generateNegotiationProposePromptFromDataStillThrowsOnNullTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows( + NullPointerException.class, + () -> server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + null)); + } + + @Test + void getPromptStillThrowsOnNullTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows(NullPointerException.class, () -> server.getPrompt(null)); + } + + @Test + void generateNegotiationProposePromptFromDataRejectsMalformedTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + "not-a-template-uri")); + + assertTrue(error.getMessage().contains("Unparseable template URI")); + } + + @Test + void validateProposePromptAndDataFillingRejectsMalformedTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> server.validateProposePromptAndDataFilling("test prompt", null, Map.of(), "not-a-template-uri")); + + assertTrue(error.getMessage().contains("Unparseable template URI")); + } + + @Test + void validateTaskPromptAndDataFillingRejectsMalformedTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> server.validateTaskPromptAndDataFilling("test prompt", Map.of(), "Task-T/network-layer")); + + assertTrue(error.getMessage().contains("Unparseable template URI")); + } + + @Test + void getPromptRejectsMalformedTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> server.getPrompt("not-a-template-uri")); + + assertTrue(error.getMessage().contains("Unparseable template URI")); + } + + @Test + void getPromptRejectsBlankTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> server.getPrompt(" ")); + + assertTrue(error.getMessage().contains("Unparseable template URI")); + } + + private static Path writeEnv() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-server-content-validation-env"); + Path envFile = tempDir.resolve("server.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_PROMPT_COMPLIANCE_ENABLED=false + """); + return envFile; + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index 24e42761..c93b522d 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -39,7 +39,7 @@ void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOE new NegotiationProposeData( new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); + INFORMATION_PROPOSE_URI); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); @@ -59,7 +59,7 @@ void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOE new NegotiationProposeData( new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE); + INFORMATION_PROPOSE_URI); assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); assertFalse(result.promptText().isBlank()); @@ -81,7 +81,7 @@ void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOExceptio new NegotiationAbortData( new NegotiationContext(UUID, 5, 5, NegotiationPerformative.ABORT), new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - StandardTemplates.NEGOTIATION_ABORT); + StandardTemplates.NEGOTIATION_ABORT_URI); assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); assertTrue(result.promptText().contains("## 协商结果\nAbort")); @@ -94,7 +94,7 @@ void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { A2ATServer server = new A2ATServer(writeEnv("zh-CN")); PromptTemplate template = - server.getPrompt(StandardTemplates.NEGOTIATION_ABORT).orElseThrow(); + server.getPrompt(StandardTemplates.NEGOTIATION_ABORT_URI).orElseThrow(); assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); assertFalse(template.content().isBlank()); } @@ -103,13 +103,13 @@ void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - assertTrue(server.getPrompt(INFORMATION_PROPOSE).isPresent()); - assertTrue(server.getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + assertTrue(server.getPrompt(INFORMATION_PROPOSE_URI).isPresent()); + assertTrue(server.getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI) .isPresent()); assertFalse(server.getPrompt( - TemplateUri.of(StandardTemplates.NEGOTIATION_EXTENSION_NAME, "unknown-negotiation", "propose")) + StandardTemplates.NEGOTIATION_EXTENSION_NAME + "/unknown-negotiation/propose/v1") .isPresent()); - PromptTemplate template = server.getPrompt(INFORMATION_PROPOSE).orElseThrow(); + PromptTemplate template = server.getPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); assertEquals(INFORMATION_PROPOSE, template.templateUri()); assertFalse(template.content().isBlank()); } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java index 76dc666d..4b43dc5a 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java @@ -80,15 +80,15 @@ void validateContentPromptAndDataFillingFailsFastWithoutLlmCallWhenPromptExceeds ContentValidationException taskError = assertThrows( ContentValidationException.class, () -> server.validateTaskPromptAndDataFilling( - oversizedPrompt, schema, StandardTemplates.ENERGY_SAVING)); + oversizedPrompt, schema, StandardTemplates.ENERGY_SAVING_URI)); ContentValidationException notificationError = assertThrows( ContentValidationException.class, () -> server.validateNotificationPromptAndDataFilling( - oversizedPrompt, schema, StandardTemplates.SUBSCRIBE_INCIDENT)); + oversizedPrompt, schema, StandardTemplates.SUBSCRIBE_INCIDENT_URI)); ContentValidationException authError = assertThrows( ContentValidationException.class, () -> server.validateAuthPromptAndDataFilling( - oversizedPrompt, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT)); + oversizedPrompt, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI)); assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), taskError.getCode()); assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), notificationError.getCode()); diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index d0534e03..0138d72f 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -13,7 +13,6 @@ import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; @@ -48,9 +47,7 @@ static void registerMockProvider() { private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - private static final TemplateUri INFORMATION_PROPOSE_TEMPLATE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE_TEMPLATE.uri(); + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI; private static final String TASK_T_MESSAGE = "## 任务类型(Task Type)\n" + "无线能效优化\n\n" @@ -78,7 +75,7 @@ void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOE TASK_T_MESSAGE, null, Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - INFORMATION_PROPOSE_TEMPLATE)); + INFORMATION_PROPOSE_URI)); assertEquals("negotiation.invalid_input", negotiationFailure.getCode()); assertEquals("输入的协商内容无效:缺少协商上下文(该报文不是协商报文)", negotiationFailure.getMessage()); @@ -87,7 +84,7 @@ void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOE new NegotiationProposeData( new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_TEMPLATE); + INFORMATION_PROPOSE_URI); assertEquals(INFORMATION_PROPOSE_URI, negotiationMessage.templateUri()); assertEquals(UUID, negotiationMessage.negotiationContext().id()); } @@ -107,7 +104,7 @@ void oldNegotiationStateMachineKeepsWorkingNextToTheContentLayerApis() throws IO new NegotiationProposeData( new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_TEMPLATE); + INFORMATION_PROPOSE_URI); assertEquals(2, negotiationMessage.negotiationContext().round()); Map startedAgain = server.startNegotiation(NegotiationType.TARGET, "请澄清目标。", Map.of()); diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/AuthorizationContentValidatorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/AuthorizationContentValidatorTest.java index d553717b..39f49e1b 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/AuthorizationContentValidatorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/AuthorizationContentValidatorTest.java @@ -43,7 +43,7 @@ void should_validateAuthPromptAndDataFilling_WithAuthorizationTemplateUri() thro String prompt = "新增两条动网操作授权策略:业务投诉诊断/业务抢通/隧道调优/限期生效"; FilledParamData result = assertDoesNotThrow(() -> server.validateAuthPromptAndDataFilling( - prompt, schemaMap, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT)); + prompt, schemaMap, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI)); assertNotNull(result); assertEquals(Map.of("授权策略的操作类型", "新增授权策略"), result.data()); From a9c13a0dfa15dc30216a80f513cb467888b57d7a Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sun, 30 Aug 2026 00:35:08 +0800 Subject: [PATCH 252/295] docs(api): repair stale throws clauses on facade javadocs Signed-off-by: codetalks-hw --- .../openan/a2at/sdk/client/A2ATClient.java | 153 +++++++++------- .../generation/NegotiationContentService.java | 28 +-- .../NegotiationGenerationOrchestrator.java | 15 +- .../openan/a2at/sdk/server/A2ATServer.java | 166 ++++++++++-------- 4 files changed, 206 insertions(+), 156 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index ca5d403f..6744e96f 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -253,14 +253,16 @@ public Map continueNegotiation( * * @param data typed propose input carrying the negotiation context and the typed content * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/propose/v1}; its - * phase segment must be {@code propose} + * performative segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the * method or the content type * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, the code + * {@code negotiation.invalid_input} when the content combines a non-blank confirm request with conditional + * sections, or the code {@code template.render_failed} when rendering the template fails */ public MetadataContent generateNegotiationProposePromptFromData( @NonNull NegotiationProposeData data, @NonNull String templateUri) { @@ -276,14 +278,16 @@ public MetadataContent generateNegotiationProposePromptFromData( * * @param data typed terminal input carrying the negotiation context and the typed ending content * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; - * its phase segment must be {@code accept-reject} + * its performative segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the - * method or the content, or the content conclusion is not {@code Accept} + * method or the content * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.conclusion_mismatch} when the content conclusion is not {@code Accept}, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, or the code + * {@code template.render_failed} when rendering the template fails */ public MetadataContent generateNegotiationAcceptPromptFromData( @NonNull NegotiationEndingData data, @NonNull String templateUri) { @@ -299,14 +303,16 @@ public MetadataContent generateNegotiationAcceptPromptFromData( * * @param data typed terminal input carrying the negotiation context and the typed ending content * @param templateUri template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; - * its phase segment must be {@code accept-reject} + * its performative segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the - * method or the content, or the content conclusion is not {@code Reject} + * method or the content * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.conclusion_mismatch} when the content conclusion is not {@code Reject}, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, or the code + * {@code template.render_failed} when rendering the template fails */ public MetadataContent generateNegotiationRejectPromptFromData( @NonNull NegotiationEndingData data, @NonNull String templateUri) { @@ -325,10 +331,11 @@ public MetadataContent generateNegotiationRejectPromptFromData( * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null * @throws IllegalArgumentException if the template URI is blank or malformed, or does not address the common abort - * template, or the termination reason is blank + * template * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.content_invalid} when the termination reason is blank, or the code + * {@code template.render_failed} when rendering the template fails */ public MetadataContent generateNegotiationAbortPromptFromData( @NonNull NegotiationAbortData data, @NonNull String templateUri) { @@ -342,26 +349,26 @@ public MetadataContent generateNegotiationAbortPromptFromData( *

        This variant runs one LLM content-extraction step constrained by the template URI and then renders * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. * * @param text free-text input describing the message content * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement - * @param templateUri template URI string such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase + * @param templateUri template URI string such as {@code Negotiation-T/target-negotiation/propose/v1}; its performative * segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required * field, or {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the * phase, or the code {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); return negotiationContentService.generateProposeFromText(text, context, parsedTemplateUri); @@ -378,20 +385,21 @@ public MetadataContent generateNegotiationProposePromptFromText( * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; - * its phase segment must be {@code accept-reject} + * its performative segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required - * field, or {@code negotiation.invalid_input} when the text is blank or the extracted conclusion is not - * {@code Accept}, or the code {@code input.text_too_long} when the text exceeds the configured maximum length + * field, or {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} + * when the extracted conclusion is not {@code Accept}, or the code {@code input.text_too_long} when the text + * exceeds the configured maximum length */ public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); return negotiationContentService.generateAcceptFromText(text, context, parsedTemplateUri); @@ -408,20 +416,21 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement * @param templateUri template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; - * its phase segment must be {@code accept-reject} + * its performative segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required - * field, or {@code negotiation.invalid_input} when the text is blank or the extracted conclusion is not - * {@code Reject}, or the code {@code input.text_too_long} when the text exceeds the configured maximum length + * field, or {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} + * when the extracted conclusion is not {@code Reject}, or the code {@code input.text_too_long} when the text + * exceeds the configured maximum length */ public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); return negotiationContentService.generateRejectFromText(text, context, parsedTemplateUri); @@ -433,7 +442,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( *

        This variant runs one LLM content-extraction step constrained by the common abort template and then renders * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. * * @param text free-text input stating the termination reason * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated @@ -445,14 +454,14 @@ public MetadataContent generateNegotiationRejectPromptFromText( * template * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} when the extraction step fails + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses the * termination reason, or {@code negotiation.invalid_input} when the text is blank, or the code * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); return negotiationContentService.generateAbortFromText(text, context, parsedTemplateUri); @@ -502,18 +511,23 @@ public Optional getPrompt(@NonNull String templateUri) { * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI string declaring the expected negotiation type and phase; its phase segment must + * @param templateUri template URI string declaring the expected negotiation type and phase; its performative segment must * be {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or the template - * URI phase contradicts the method + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the template URI phase contradicts + * the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or - * {@code template.not_found} when the semantic validation prompt resources are missing + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateProposePromptAndDataFilling( @NonNull String prompt, @@ -536,18 +550,23 @@ public FilledParamData validateProposePromptAndDataFilling( * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI string declaring the expected negotiation type and phase; its phase segment must + * @param templateUri template URI string declaring the expected negotiation type and phase; its performative segment must * be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or the template - * URI phase contradicts the method + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the template URI phase contradicts + * the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or - * {@code template.not_found} when the semantic validation prompt resources are missing + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateAcceptPromptAndDataFilling( @NonNull String prompt, @@ -569,18 +588,23 @@ public FilledParamData validateAcceptPromptAndDataFilling( * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI string declaring the expected negotiation type and phase; its phase segment must + * @param templateUri template URI string declaring the expected negotiation type and phase; its performative segment must * be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or the template - * URI phase contradicts the method + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the template URI phase contradicts + * the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or - * {@code template.not_found} when the semantic validation prompt resources are missing + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateRejectPromptAndDataFilling( @NonNull String prompt, @@ -604,15 +628,20 @@ public FilledParamData validateRejectPromptAndDataFilling( * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri template URI string of the common abort template {@code Negotiation-T/common/abort/v1} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or the template - * URI does not address the common abort template + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or does not address the common abort + * template * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} when the semantic step fails after exhausting its retries, or - * {@code template.not_found} when the semantic validation prompt resources are missing + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public FilledParamData validateAbortPromptAndDataFilling( @NonNull String prompt, diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index 3924b2fc..05be2d3f 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -72,8 +72,8 @@ public static NegotiationGenerationOrchestrator buildOrchestrator( * field is blank or empty, or {@code negotiation.invalid_input} when the content combines a non-blank confirm * request with conditional sections (target: the three clarification lists; feasibility: either conditional * list or the alternative action) - * @throws NegotiationGenerationException with the code {@code template.not_found} or - * {@code negotiation.field_missing} when loading or rendering the template fails + * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, + * or the code {@code template.render_failed} when rendering the template fails */ public MetadataContent generateProposeFromData( @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { @@ -91,8 +91,8 @@ public MetadataContent generateProposeFromData( * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content * conclusion is not {@code Accept}, or {@code negotiation.content_invalid} when a required content field is * blank or empty - * @throws NegotiationGenerationException with the code {@code template.not_found} or - * {@code negotiation.field_missing} when loading or rendering the template fails + * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, + * or the code {@code template.render_failed} when rendering the template fails */ public MetadataContent generateAcceptFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { @@ -110,8 +110,8 @@ public MetadataContent generateAcceptFromData( * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content * conclusion is not {@code Reject}, or {@code negotiation.content_invalid} when a required content field is * blank or empty - * @throws NegotiationGenerationException with the code {@code template.not_found} or - * {@code negotiation.field_missing} when loading or rendering the template fails + * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, + * or the code {@code template.render_failed} when rendering the template fails */ public MetadataContent generateRejectFromData( @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { @@ -128,8 +128,8 @@ public MetadataContent generateRejectFromData( * @throws IllegalArgumentException if the template URI does not address the common abort template * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when the termination * reason is blank - * @throws NegotiationGenerationException with the code {@code template.not_found} or - * {@code negotiation.field_missing} when loading or rendering the template fails + * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, + * or the code {@code template.render_failed} when rendering the template fails */ public MetadataContent generateAbortFromData(@NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { return orchestrator.generateAbortFromData(data, templateUri); @@ -147,7 +147,8 @@ public MetadataContent generateAbortFromData(@NonNull NegotiationAbortData data, * @throws IllegalArgumentException if the template URI contradicts the method * @throws NegotiationGenerationException with the code {@code template.not_found}, * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} - * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses a + * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, + * {@code negotiation.field_missing} when the extracted content misses a * required field, or {@code negotiation.invalid_input} when the text is blank, the extracted content * contradicts the performative or the extracted confirm request is combined with conditional sections or the * wrong feasibility action, or {@code input.text_too_long} when the text exceeds the configured maximum length @@ -169,7 +170,8 @@ public MetadataContent generateProposeFromText( * @throws IllegalArgumentException if the template URI contradicts the method * @throws NegotiationGenerationException with the code {@code template.not_found}, * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} - * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses a + * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, + * {@code negotiation.field_missing} when the extracted content misses a * required field, or {@code negotiation.invalid_input} when the text is blank, * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Accept}, or * {@code input.text_too_long} when the text exceeds the configured maximum length @@ -191,7 +193,8 @@ public MetadataContent generateAcceptFromText( * @throws IllegalArgumentException if the template URI contradicts the method * @throws NegotiationGenerationException with the code {@code template.not_found}, * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} - * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses a + * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, + * {@code negotiation.field_missing} when the extracted content misses a * required field, or {@code negotiation.invalid_input} when the text is blank, * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Reject}, or * {@code input.text_too_long} when the text exceeds the configured maximum length @@ -213,7 +216,8 @@ public MetadataContent generateRejectFromText( * @throws IllegalArgumentException if the template URI does not address the common abort template * @throws NegotiationGenerationException with the code {@code template.not_found}, * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} - * when loading or extracting fails, {@code negotiation.field_missing} when the extracted content misses the + * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, + * {@code negotiation.field_missing} when the extracted content misses the * termination reason, or {@code negotiation.invalid_input} when the text is blank, or * {@code input.text_too_long} when the text exceeds the configured maximum length */ diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index fce467b0..6cf02522 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -210,7 +210,8 @@ public MetadataContent generateAbortFromData(@NonNull NegotiationAbortData data, * resource exists for the URI and language, {@code negotiation.content_extract_failed} or * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or - * {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the phase, or + * {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the phase, + * {@code template.render_failed} when rendering the template fails, or * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateProposeFromText( @@ -240,8 +241,8 @@ public MetadataContent generateProposeFromText( * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or * {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} when the - * extracted conclusion is not {@code Accept}, or {@code input.text_too_long} when the text exceeds the - * configured maximum length + * extracted conclusion is not {@code Accept}, {@code template.render_failed} when rendering the template + * fails, or {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateAcceptFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -270,8 +271,8 @@ public MetadataContent generateAcceptFromText( * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or * {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} when the - * extracted conclusion is not {@code Reject}, or {@code input.text_too_long} when the text exceeds the - * configured maximum length + * extracted conclusion is not {@code Reject}, {@code template.render_failed} when rendering the template + * fails, or {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateRejectFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { @@ -299,8 +300,8 @@ public MetadataContent generateRejectFromText( * resource exists for the URI and language, {@code negotiation.content_extract_failed} or * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting * its retries, {@code negotiation.field_missing} when the extracted content misses the termination reason, or - * {@code negotiation.invalid_input} when the text is blank, or {@code input.text_too_long} when the text - * exceeds the configured maximum length + * {@code negotiation.invalid_input} when the text is blank, {@code template.render_failed} when rendering the + * template fails, or {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateAbortFromText( String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 05b534c6..5b5848b0 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -126,14 +126,16 @@ public Map continueNegotiation( * * @param data typed propose input carrying the negotiation context and the typed content * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/propose/v1}; - * its phase segment must be {@code propose} + * its performative segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the * method or the content type * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, the code + * {@code negotiation.invalid_input} when the content combines a non-blank confirm request with conditional + * sections, or the code {@code template.render_failed} when rendering the template fails */ public MetadataContent generateNegotiationProposePromptFromData( @NonNull NegotiationProposeData data, @NonNull String templateUri) { @@ -148,14 +150,16 @@ public MetadataContent generateNegotiationProposePromptFromData( * * @param data typed terminal input carrying the negotiation context and the typed ending content * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; - * its phase segment must be {@code accept-reject} + * its performative segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, its phase or type contradicts the - * method or the content, or the content conclusion is not {@code Accept} + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.conclusion_mismatch} when the content conclusion is not {@code Accept}, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, or the code + * {@code template.render_failed} when rendering the template fails */ public MetadataContent generateNegotiationAcceptPromptFromData( @NonNull NegotiationEndingData data, @NonNull String templateUri) { @@ -170,14 +174,16 @@ public MetadataContent generateNegotiationAcceptPromptFromData( * * @param data typed terminal input carrying the negotiation context and the typed ending content * @param templateUri raw template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; - * its phase segment must be {@code accept-reject} + * its performative segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, its phase or type contradicts the - * method or the content, or the content conclusion is not {@code Reject} + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.conclusion_mismatch} when the content conclusion is not {@code Reject}, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, or the code + * {@code template.render_failed} when rendering the template fails */ public MetadataContent generateNegotiationRejectPromptFromData( @NonNull NegotiationEndingData data, @NonNull String templateUri) { @@ -195,10 +201,11 @@ public MetadataContent generateNegotiationRejectPromptFromData( * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the data, its context or the template URI is null * @throws IllegalArgumentException if the template URI is blank, malformed or does not address the common abort - * template, or the termination reason is blank + * template * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, or the code - * {@code negotiation.field_missing} when rendering the template fails + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.content_invalid} when the termination reason is blank, or the code + * {@code template.render_failed} when rendering the template fails */ public MetadataContent generateNegotiationAbortPromptFromData( @NonNull NegotiationAbortData data, @NonNull String templateUri) { @@ -216,8 +223,8 @@ public MetadataContent generateNegotiationAbortPromptFromData( * @param text free-text input describing the message content * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement - * @param templateUri raw template URI string such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase - * segment must be {@code propose} + * @param templateUri raw template URI string such as {@code Negotiation-T/target-negotiation/propose/v1}; its + * performative segment must be {@code propose} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method @@ -231,7 +238,7 @@ public MetadataContent generateNegotiationAbortPromptFromData( */ public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { return negotiationContentService.generateProposeFromText(text, context, parseTemplateUri(templateUri)); } @@ -247,7 +254,7 @@ public MetadataContent generateNegotiationProposePromptFromText( * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; - * its phase segment must be {@code accept-reject} + * its performative segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method @@ -255,13 +262,14 @@ public MetadataContent generateNegotiationProposePromptFromText( * {@code template.not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the - * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the - * extracted conclusion is not {@code Accept}, or {@code input.text_too_long} when the text exceeds the - * configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Accept}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { return negotiationContentService.generateAcceptFromText(text, context, parseTemplateUri(templateUri)); } @@ -277,7 +285,7 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated * message without any LLM involvement * @param templateUri raw template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; - * its phase segment must be {@code accept-reject} + * its performative segment must be {@code accept-reject} * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI * @throws NullPointerException if the context or the template URI is null * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method @@ -285,13 +293,14 @@ public MetadataContent generateNegotiationAcceptPromptFromText( * {@code template.not_found} when no template or prompt resource exists for the URI and language, * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the - * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the - * extracted conclusion is not {@code Reject}, or {@code input.text_too_long} when the text exceeds the - * configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Reject}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) */ public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { return negotiationContentService.generateRejectFromText(text, context, parseTemplateUri(templateUri)); } @@ -320,7 +329,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( */ public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, - net.openan.a2at.sdk.core.model.NegotiationContext context, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { return negotiationContentService.generateAbortFromText(text, context, parseTemplateUri(templateUri)); } @@ -366,16 +375,16 @@ public Optional getPrompt(@NonNull String templateUri) { * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri raw template URI string declaring the expected negotiation type and phase; its phase segment + * @param templateUri raw template URI string declaring the expected negotiation type and phase; its performative segment * must be {@code propose} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or its phase - * contradicts the method + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context - * violates a rule, one of the closed {@code negotiation.*} code set (for example + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting @@ -403,16 +412,16 @@ public FilledParamData validateProposePromptAndDataFilling( * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri raw template URI string declaring the expected negotiation type and phase; its phase segment + * @param templateUri raw template URI string declaring the expected negotiation type and phase; its performative segment * must be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or its phase - * contradicts the method + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context - * violates a rule, one of the closed {@code negotiation.*} code set (for example + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting @@ -440,16 +449,16 @@ public FilledParamData validateAcceptPromptAndDataFilling( * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported * as not being a negotiation message * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri raw template URI string declaring the expected negotiation type and phase; its phase segment + * @param templateUri raw template URI string declaring the expected negotiation type and phase; its performative segment * must be {@code accept-reject} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank, the template URI is blank or malformed, or its phase - * contradicts the method + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context - * violates a rule, one of the closed {@code negotiation.*} code set (for example + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting @@ -479,13 +488,14 @@ public FilledParamData validateRejectPromptAndDataFilling( * @param schema caller-provided parameter JSON schema describing the parameters to extract * @param templateUri raw template URI string of the common abort template {@code Negotiation-T/common/abort/v1} * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank, the template URI is blank, malformed or does not address - * the common abort template + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank, malformed or does not address the common abort + * template * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is not a negotiation message, - * {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded} when the negotiation context - * violates a rule, one of the closed {@code negotiation.*} code set (for example + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting @@ -510,14 +520,16 @@ public FilledParamData validateAbortPromptAndDataFilling( * @param templateUri raw template URI string declaring the expected task template; its prefix segment must be * {@code Task-T} * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI is blank or malformed + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, + * {@code negotiation.invalid_input} when the prompt is null or blank, the schema is null, or the template URI + * addresses another extension or carries an unsupported version, the code + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, the code * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) + * its retries, the code {@code template.not_found} when the template or the validation prompt resources are + * missing, or the code {@code input.text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public FilledParamData validateTaskPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { @@ -532,14 +544,16 @@ public FilledParamData validateTaskPromptAndDataFilling( * @param templateUri raw template URI string declaring the expected notification template; its prefix segment must * be {@code Notification-T} * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI is blank or malformed + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, + * {@code negotiation.invalid_input} when the prompt is null or blank, the schema is null, or the template URI + * addresses another extension or carries an unsupported version, the code + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, the code * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) + * its retries, the code {@code template.not_found} when the template or the validation prompt resources are + * missing, or the code {@code input.text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public FilledParamData validateNotificationPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { @@ -554,14 +568,16 @@ public FilledParamData validateNotificationPromptAndDataFilling( * @param templateUri raw template URI string declaring the expected authorization template; its prefix segment * must be {@code Authorization-T} * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the prompt, schema or template URI is null - * @throws IllegalArgumentException if the prompt is blank, or the template URI is blank or malformed + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, + * {@code negotiation.invalid_input} when the prompt is null or blank, the schema is null, or the template URI + * addresses another extension or carries an unsupported version, the code + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, the code * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) + * its retries, the code {@code template.not_found} when the template or the validation prompt resources are + * missing, or the code {@code input.text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) */ public FilledParamData validateAuthPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { From 008f970573e52b10d8fab6de570d9232bb872292 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sun, 30 Aug 2026 00:56:42 +0800 Subject: [PATCH 253/295] feat(sample): pass string template uris and drop parse helpers Signed-off-by: codetalks-hw --- .../authz_policy/AuthzPromptValidator.java | 3 +- .../sample/authz_policy/AuthzSampleMain.java | 17 +++++-- .../authz_policy/AuthzScenarioExecutor.java | 5 +- .../authz_policy/AuthzScenarioRunner.java | 3 +- .../negotiation/client/NegotiationClient.java | 12 ++--- .../negotiation/eval/NegotiationEvalApp.java | 34 +++++-------- .../eval/NegotiationFromDataApiEvalApp.java | 20 +++----- .../server/NegotiationAgentExecutor.java | 12 ++--- .../negotiation/shared/DemoConstants.java | 7 ++- .../negotiation/shared/FromDataStrategy.java | 7 ++- .../negotiation/shared/FromTextStrategy.java | 7 ++- .../shared/NegotiationMessage.java | 7 +-- .../shared/NegotiationStrategy.java | 7 ++- .../NegotiationAbnormalEvaluationMain.java | 26 +++++----- ...tiationBusinessAbnormalEvaluationMain.java | 4 +- .../NegotiationQwenEvaluationMain.java | 16 +++--- .../shared/NegotiationMetadataReader.java | 7 ++- .../shared/NegotiationSampleFlow.java | 5 +- .../service_recovery/FromTextProbe.java | 4 +- .../prompt/A2ATNotificationPromptClient.java | 10 +--- .../runtime/DefaultSampleServerRuntime.java | 5 +- .../a2at/sample/task_t/TaskTDemoMain.java | 14 +++--- .../NegotiationFacadeOutputSymmetryTest.java | 29 ++++++----- .../AuthzSampleMainConcurrencyTest.java | 3 +- .../AuthzScenarioExecutorTest.java | 13 +++-- .../authz_policy/AuthzScenarioRunnerTest.java | 49 +++++++++---------- .../NegotiationMetadataReaderTest.java | 8 +-- .../NegotiationSampleFlowTest.java | 4 +- .../accuracy/NotificationTAccuracyTest.java | 6 +-- .../sample/task_t/TaskTSingleSampleTest.java | 12 ++--- .../api/NegotiationV3ApiSurfaceTest.java | 33 +++++++------ 31 files changed, 174 insertions(+), 215 deletions(-) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java index f0b9cad2..af880b53 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java @@ -2,7 +2,6 @@ import java.util.Map; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.TemplateUri; /** * Functional interface for validating an authorization prompt and extracting filled slot data. @@ -12,5 +11,5 @@ @FunctionalInterface public interface AuthzPromptValidator { - FilledParamData validate(String prompt, Map schema, TemplateUri templateUri); + FilledParamData validate(String prompt, Map schema, String templateUri); } \ No newline at end of file diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java index 321f9f63..e4606506 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java @@ -100,7 +100,7 @@ public static void main(String[] args) { Map paramSchema = loadParamSchema(); List scenarios = AuthzScenarioLoader.load(SCENARIOS_RESOURCE); - TemplateUri templateUri = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; + String templateUri = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; AuthzPromptGenerator generator = buildGenerator(envPath, templateUri, capture); AuthzPromptValidator validator = buildValidator(envPath, capture); @@ -194,7 +194,7 @@ static Map loadParamSchema() { } } - static AuthzPromptGenerator buildGenerator(Path envPath, TemplateUri templateUri, AuthzReasoningCapture capture) { + static AuthzPromptGenerator buildGenerator(Path envPath, String templateUri, AuthzReasoningCapture capture) { if (capture == null) { A2ATClient client = new A2ATClient(envPath); return scenario -> { @@ -219,16 +219,17 @@ static AuthzPromptGenerator buildGenerator(Path envPath, TemplateUri templateUri .config(config) .llmClient(decorated); ClientPromptGenerationOrchestrator orchestrator = builder.buildPromptGenerationOrchestrator(); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); return scenario -> { if (AuthzScenario.FROM_TEXT.equals(scenario.entry())) { String text = (String) scenario.input().get("text"); - return orchestrator.generateAuthPromptFromText(text, templateUri); + return orchestrator.generateAuthPromptFromText(text, parsedTemplateUri); } @SuppressWarnings("unchecked") Map data = (Map) scenario.input().get("data"); @SuppressWarnings("unchecked") Map schema = (Map) scenario.input().get("schema"); - return orchestrator.generateAuthPromptFromDataWithSchema(data, schema, templateUri); + return orchestrator.generateAuthPromptFromDataWithSchema(data, schema, parsedTemplateUri); }; } @@ -245,7 +246,13 @@ static AuthzPromptValidator buildValidator(Path envPath, AuthzReasoningCapture c .config(config) .llmClient(decorated); ContentValidator contentValidator = builder.buildAuthContentValidator(); - return contentValidator::validate; + return (prompt, schema, templateUri) -> contentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + } + + /** Parses a template URI string for the typed orchestrator/content-validator seams. */ + private static TemplateUri parseTemplateUri(String templateUri) { + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); } static void printScenarioReport(AuthzScenario scenario, ScenarioOutcome outcome) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java index c602a308..58f40fbc 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java @@ -14,7 +14,6 @@ import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ErrorCatalog; import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; /** * Concurrent scenario executor that runs multiple {@link AuthzScenario} instances in parallel using a fixed thread @@ -73,7 +72,7 @@ public interface ProgressListener { public List executeAll( List scenarios, Map paramSchema, - TemplateUri templateUri, + String templateUri, int workers, ProgressListener onCompleted) { return executeAll(scenarios, paramSchema, templateUri, workers, onCompleted, null); @@ -97,7 +96,7 @@ public List executeAll( public List executeAll( List scenarios, Map paramSchema, - TemplateUri templateUri, + String templateUri, int workers, ProgressListener onCompleted, AuthzReasoningCapture capture) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java index e3c738a9..cbef4259 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java @@ -13,7 +13,6 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; /** @@ -41,7 +40,7 @@ public AuthzScenarioRunner(AuthzPromptGenerator generator, AuthzPromptValidator this.validator = validator; } - public ScenarioOutcome run(AuthzScenario scenario, Map paramSchema, TemplateUri templateUri) { + public ScenarioOutcome run(AuthzScenario scenario, Map paramSchema, String templateUri) { MetadataContent metadata; try { metadata = generator.generate(scenario); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java index 018ed0ab..99fce636 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java @@ -146,7 +146,7 @@ public Map runFourMessageFlow(NegotiationServerRuntime serverRun Map mergedMetadata = new LinkedHashMap<>(); mergedMetadata.put(DemoConstants.TASK_T_URI, taskFilled.promptText()); mergedMetadata.put(DemoConstants.NEGOTIATION_T_URI, acceptPrompt.promptText()); - mergedMetadata.put(DemoConstants.TEMPLATE_URI_KEY, DemoConstants.TASK_TEMPLATE.uri()); + mergedMetadata.put(DemoConstants.TEMPLATE_URI_KEY, DemoConstants.TASK_TEMPLATE); mergedMetadata.put(DemoConstants.NEGOTIATION_CONTEXT_KEY, NegotiationMessage.toJson(negotiationContext)); EventKind reply2 = sendRaw(agentCard, mergedMetadata, taskFilled.promptText(), negotiationContext); @@ -166,8 +166,8 @@ public Map runFourMessageFlow(NegotiationServerRuntime serverRun /** Sends one extension's prompt as an A2A message and returns the raw reply event. */ private EventKind send( AgentCard agentCard, String extensionUri, MetadataContent content, Map contextMap) { - Map metadata = NegotiationMessage.buildMetadata( - extensionUri, content.promptText(), parseTemplateUri(content.templateUri()), contextMap); + Map metadata = + NegotiationMessage.buildMetadata(extensionUri, content.promptText(), content.templateUri(), contextMap); return sendRaw(agentCard, metadata, content.promptText(), contextMap); } @@ -289,12 +289,6 @@ EventKind awaitReply() throws InterruptedException { } } - /** Parses a rendered template URI string back into a {@code TemplateUri} for metadata building. */ - private static net.openan.a2at.sdk.core.model.TemplateUri parseTemplateUri(String templateUri) { - return net.openan.a2at.sdk.core.model.TemplateUri.parse(templateUri) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); - } - private static Message extractReplyMessage(EventKind reply) { if (reply instanceof Message directMessage) { return directMessage; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java index 502e3f8a..31e43414 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java @@ -24,7 +24,6 @@ import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; @@ -193,7 +192,7 @@ private static Map runCase(Path envPath, Map sui String inputText = String.valueOf(testCase.get("input_text")); Map inputData = asMap(testCase.get("input_data")); - TemplateUri taskTemplate = parseTemplate(String.valueOf(suite.get("template_uri"))); + String taskTemplate = String.valueOf(suite.get("template_uri")); Map taskSchema = asMap(suite.get("task_schema")); Map phrasing = stringMap(suite.get("negotiation_phrasing")); Map properties = asMap(taskSchema.get("properties")); @@ -238,7 +237,7 @@ private static Map runCase(Path envPath, Map sui taskGenerationInput.put("data", inputData); taskGenerationInput.put("schema", taskSchema); } - taskGenerationInput.put("template_uri", taskTemplate.uri()); + taskGenerationInput.put("template_uri", taskTemplate); String taskGenerationApi = fromText ? "A2ATClient.generateTaskPromptFromText" : "A2ATClient.generateTaskPromptFromDataWithSchema"; String taskPrompt; @@ -286,7 +285,7 @@ private static Map runCase(Path envPath, Map sui Map taskValidationInput = Map.of( "prompt", taskPrompt, "schema", taskSchema, - "template_uri", taskTemplate.uri()); + "template_uri", taskTemplate); Set actualMissing; Map extractedRound1 = new LinkedHashMap<>(); try { @@ -359,7 +358,7 @@ private static Map runCase(Path envPath, Map sui } proposeGenerationInput.put("data", proposeData); } - proposeGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()); + proposeGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_PROPOSE); String proposeGenerationApi = negotiationFromText ? "A2ATServer.generateNegotiationProposePromptFromText" : "A2ATServer.generateNegotiationProposePromptFromData"; @@ -412,7 +411,7 @@ proposeContext, new InformationProposeContent(missingItems, proposeRelationship) "prompt", proposePrompt, "context", contextJson(proposeContext), "schema", negotiationSchema(actualMissing, taskSchema), - "template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()); + "template_uri", DemoTemplates.NEGOTIATION_PROPOSE); Boolean proposeValid = null; try { long nanos = System.nanoTime(); @@ -482,7 +481,7 @@ proposeContext, new InformationProposeContent(missingItems, proposeRelationship) "prompt", proposePrompt, "context", contextJson(proposeContext), "schema", negotiationSchema(actualMissing, taskSchema), - "template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()))); + "template_uri", DemoTemplates.NEGOTIATION_PROPOSE))); step.put("client_requested_slots", new ArrayList<>(clientRequestedSlots)); steps.add(step); emit("[eval] [5] client extracted requested slots: " + clientRequestedSlots + " (" + secs(nanos) + "s)"); @@ -505,7 +504,7 @@ proposeContext, new InformationProposeContent(missingItems, proposeRelationship) "prompt", proposePrompt, "context", contextJson(proposeContext), "schema", negotiationSchema(actualMissing, taskSchema), - "template_uri", DemoTemplates.NEGOTIATION_PROPOSE.uri()))); + "template_uri", DemoTemplates.NEGOTIATION_PROPOSE))); step.put("client_requested_slots", List.of()); steps.add(step); emit("[eval] [5] client propose validation REJECTED: " + error.getCode() + " " + error.getMessage() @@ -531,7 +530,7 @@ proposeContext, new InformationProposeContent(missingItems, proposeRelationship) Map filledGenerationInput = Map.of( "data", filledData, "schema", taskSchema, - "template_uri", taskTemplate.uri()); + "template_uri", taskTemplate); try { A2ATClient client = new A2ATClient(envPath); filledPrompt = client.generateTaskPromptFromDataWithSchema(filledData, taskSchema, taskTemplate) @@ -578,7 +577,7 @@ proposeContext, new InformationProposeContent(missingItems, proposeRelationship) "conclusion", NegotiationConclusion.ACCEPT.toString(), "items", itemsJson(filledItems(fills)))); } - acceptGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_ACCEPT.uri()); + acceptGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_ACCEPT); String acceptGenerationApi = negotiationFromText ? "A2ATClient.generateNegotiationAcceptPromptFromText" : "A2ATClient.generateNegotiationAcceptPromptFromData"; @@ -635,7 +634,7 @@ proposeContext, new InformationProposeContent(missingItems, proposeRelationship) "prompt", acceptPrompt, "context", contextJson(acceptContext), "schema", negotiationSchema(slotsToFill, taskSchema), - "template_uri", DemoTemplates.NEGOTIATION_ACCEPT.uri()); + "template_uri", DemoTemplates.NEGOTIATION_ACCEPT); Boolean acceptValid = null; Boolean fillValueMatch = null; Map extractedAcceptParams = null; @@ -680,7 +679,7 @@ proposeContext, new InformationProposeContent(missingItems, proposeRelationship) Map secondValidationInput = Map.of( "prompt", filledPrompt, "schema", taskSchema, - "template_uri", taskTemplate.uri()); + "template_uri", taskTemplate); Boolean fillCompleted = null; try { long nanos = System.nanoTime(); @@ -1288,19 +1287,12 @@ private static int countCases(Map suite, List filter, St .count(); } - private static TemplateUri parseTemplate(String templateUri) { - return TemplateUri.parse(templateUri) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); - } - /** Negotiation template URIs used by the closed loop; kept local so the evaluator stays self-contained. */ private static final class DemoTemplates { - static final TemplateUri NEGOTIATION_PROPOSE = - parseTemplate("Negotiation-T/information-negotiation/propose/v1"); + static final String NEGOTIATION_PROPOSE = "Negotiation-T/information-negotiation/propose/v1"; - static final TemplateUri NEGOTIATION_ACCEPT = - parseTemplate("Negotiation-T/information-negotiation/accept-reject/v1"); + static final String NEGOTIATION_ACCEPT = "Negotiation-T/information-negotiation/accept-reject/v1"; private DemoTemplates() {} } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java index 1722c1ae..8ae4ddb1 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java @@ -26,7 +26,6 @@ import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; @@ -137,7 +136,7 @@ private static Map runCase(Path envPath, Map tes String relationship = input.containsKey("relationship") ? String.valueOf(input.get("relationship")) : null; - TemplateUri template = resolveTemplate(api, input); + String template = resolveTemplate(api, input); NegotiationPerformative performative = "propose".equals(api) ? NegotiationPerformative.PROPOSE @@ -168,7 +167,7 @@ private static Map runCase(Path envPath, Map tes if (input.containsKey("relationship")) { generateInput.put("relationship", relationship); } - generateInput.put("template_uri", template.uri()); + generateInput.put("template_uri", template); Map record = new LinkedHashMap<>(); record.put("case", caseId); @@ -253,7 +252,7 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), validateInput.put("prompt", prompt); validateInput.put("context", contextJson(context)); validateInput.put("schema", extractionSchema); - validateInput.put("template_uri", template.uri()); + validateInput.put("template_uri", template); Map validateStep = step("2. validate + extract " + api + " (fromData)", validateRole); api(validateStep, apiCall(validateMethod, validateInput)); try { @@ -377,13 +376,13 @@ private static Map errorJson(RuntimeException error) { return failure; } - private static TemplateUri resolveTemplate(String api, Map input) { + private static String resolveTemplate(String api, Map input) { if (input.containsKey("template")) { - return parseTemplate(String.valueOf(input.get("template"))); + return String.valueOf(input.get("template")); } return "propose".equals(api) - ? StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE - : StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + ? StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI + : StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI; } private static void check(List> checks, String name, boolean pass) { @@ -441,11 +440,6 @@ private static Map loadSuite() { } } - private static TemplateUri parseTemplate(String templateUri) { - return TemplateUri.parse(templateUri) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); - } - private static Map step(String name, String role) { Map step = new LinkedHashMap<>(); step.put("step", name); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java index 60cd2b16..30934294 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java @@ -162,7 +162,7 @@ private void handleFilledParams( List.>of(new DataPart(Map.of(DemoConstants.TASK_T_URI, diagnosis))), "faultManagement.Diagnosis", "SPN private-line diagnosis result", - Map.of(DemoConstants.TEMPLATE_URI_KEY, DemoConstants.TASK_TEMPLATE.uri()), + Map.of(DemoConstants.TEMPLATE_URI_KEY, DemoConstants.TASK_TEMPLATE), false, true); emit("[server] diagnosis artifact emitted"); @@ -258,16 +258,10 @@ private static boolean isMissing(Object value) { return value == null || (value instanceof String s && s.isBlank()); } - /** Parses a rendered template URI string back into a {@code TemplateUri} for metadata building. */ - private static net.openan.a2at.sdk.core.model.TemplateUri parseTemplateUri(String templateUri) { - return net.openan.a2at.sdk.core.model.TemplateUri.parse(templateUri) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); - } - private static Message buildReplyMessage( RequestContext ctx, MetadataContent content, Map contextMap, String extensionUri) { - Map replyMetadata = NegotiationMessage.buildMetadata( - extensionUri, content.promptText(), parseTemplateUri(content.templateUri()), contextMap); + Map replyMetadata = + NegotiationMessage.buildMetadata(extensionUri, content.promptText(), content.templateUri(), contextMap); return Message.builder() .messageId(UUID.randomUUID().toString()) .contextId(ctx.getContextId()) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/DemoConstants.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/DemoConstants.java index 2259e038..43c84d20 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/DemoConstants.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/DemoConstants.java @@ -1,7 +1,6 @@ package net.openan.a2at.sample.negotiation.shared; import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; /** * Extension URIs and metadata keys used by the negotiation end-to-end demo. @@ -24,13 +23,13 @@ private DemoConstants() {} "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/NL/v1"; /** Task-T template URI for the SPN private-line-complaint diagnosis scenario. */ - public static final TemplateUri TASK_TEMPLATE = StandardTemplates.PRIVATE_LINE_COMPLAINT; + public static final String TASK_TEMPLATE = StandardTemplates.PRIVATE_LINE_COMPLAINT_URI; /** Negotiation-T information propose template (request missing information). */ - public static final TemplateUri NEGOTIATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + public static final String NEGOTIATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI; /** Negotiation-T information accept-reject template (accept after params filled). */ - public static final TemplateUri NEGOTIATION_ACCEPT = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + public static final String NEGOTIATION_ACCEPT = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI; /** Metadata key carrying the JSON-serialised negotiation context map. */ public static final String NEGOTIATION_CONTEXT_KEY = "negotiation_context"; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java index 3602ff7d..2e035ee9 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java @@ -3,7 +3,6 @@ import java.util.List; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; @@ -29,7 +28,7 @@ public MetadataContent generatePropose( NegotiationContext ctx, List missingItems, String relationship, - TemplateUri templateUri) { + String templateUri) { return server.generateNegotiationProposePromptFromData( new NegotiationProposeData(ctx, new InformationProposeContent(missingItems, relationship)), templateUri); @@ -37,7 +36,7 @@ public MetadataContent generatePropose( @Override public MetadataContent generateAccept( - A2ATClient facade, NegotiationContext ctx, List filledItems, TemplateUri templateUri) { + A2ATClient facade, NegotiationContext ctx, List filledItems, String templateUri) { return facade.generateNegotiationAcceptPromptFromData( new NegotiationEndingData(ctx, new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems)), templateUri); @@ -45,7 +44,7 @@ public MetadataContent generateAccept( @Override public MetadataContent generateAcceptServer( - A2ATServer server, NegotiationContext ctx, List filledItems, TemplateUri templateUri) { + A2ATServer server, NegotiationContext ctx, List filledItems, String templateUri) { return server.generateNegotiationAcceptPromptFromData( new NegotiationEndingData(ctx, new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems)), templateUri); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java index 5c408ab0..3f575dd8 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java @@ -4,7 +4,6 @@ import java.util.Map; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.server.A2ATServer; @@ -27,21 +26,21 @@ public MetadataContent generatePropose( NegotiationContext ctx, List missingItems, String relationship, - TemplateUri templateUri) { + String templateUri) { String text = itemsToProposeText(missingItems, relationship); return server.generateNegotiationProposePromptFromText(text, ctx, templateUri); } @Override public MetadataContent generateAccept( - A2ATClient facade, NegotiationContext ctx, List filledItems, TemplateUri templateUri) { + A2ATClient facade, NegotiationContext ctx, List filledItems, String templateUri) { String text = itemsToAcceptText(filledItems); return facade.generateNegotiationAcceptPromptFromText(text, ctx, templateUri); } @Override public MetadataContent generateAcceptServer( - A2ATServer server, NegotiationContext ctx, List filledItems, TemplateUri templateUri) { + A2ATServer server, NegotiationContext ctx, List filledItems, String templateUri) { String text = itemsToAcceptText(filledItems); return server.generateNegotiationAcceptPromptFromText(text, ctx, templateUri); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationMessage.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationMessage.java index 5a794dfb..aa9582a4 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationMessage.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationMessage.java @@ -32,13 +32,10 @@ private NegotiationMessage() {} * @return A2A metadata map */ public static Map buildMetadata( - String extensionUri, - String promptText, - net.openan.a2at.sdk.core.model.TemplateUri templateUri, - Map contextMap) { + String extensionUri, String promptText, String templateUri, Map contextMap) { Map metadata = new LinkedHashMap<>(); metadata.put(extensionUri, promptText); - metadata.put(DemoConstants.TEMPLATE_URI_KEY, templateUri.uri()); + metadata.put(DemoConstants.TEMPLATE_URI_KEY, templateUri); if (contextMap != null && !contextMap.isEmpty()) { metadata.put(DemoConstants.NEGOTIATION_CONTEXT_KEY, toJson(contextMap)); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java index abf6c516..8b0d8e06 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java @@ -3,7 +3,6 @@ import java.util.List; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.server.A2ATServer; @@ -36,7 +35,7 @@ MetadataContent generatePropose( NegotiationContext ctx, List missingItems, String relationship, - TemplateUri templateUri); + String templateUri); /** * Generates an accept-phase negotiation message from the filled information items (client facade). @@ -48,7 +47,7 @@ MetadataContent generatePropose( * @return generated metadata content */ MetadataContent generateAccept( - A2ATClient facade, NegotiationContext ctx, List filledItems, TemplateUri templateUri); + A2ATClient facade, NegotiationContext ctx, List filledItems, String templateUri); /** * Generates an accept-phase negotiation message from the filled information items (server facade). @@ -60,5 +59,5 @@ MetadataContent generateAccept( * @return generated metadata content */ MetadataContent generateAcceptServer( - A2ATServer server, NegotiationContext ctx, List filledItems, TemplateUri templateUri); + A2ATServer server, NegotiationContext ctx, List filledItems, String templateUri); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java index 1b825e92..b5745376 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java @@ -160,7 +160,7 @@ private static Map runCase( long startedAt = System.nanoTime(); NegotiationPerformative performative = NegotiationPerformative.valueOf(testCase.performative()); NegotiationContext context = contextFor(testCase.contextMode(), performative); - TemplateUri templateUri = templateFor(testCase.template()); + String templateUri = templateFor(testCase.template()); Map request = new LinkedHashMap<>(); if (testCase.api().startsWith("generate_")) { request.put("text", testCase.input()); @@ -168,7 +168,7 @@ private static Map runCase( request.put("prompt", testCase.input()); request.put("schema", schemaFor(testCase.api(), testCase.schemaMode())); } - request.put("template_uri", templateUri == null ? null : templateUri.uri()); + request.put("template_uri", templateUri); request.put("context", context == null ? null : contextPayload(context)); Map result = new LinkedHashMap<>(); result.put("id", testCase.id()); @@ -281,7 +281,7 @@ private static Map runScriptedCase( ? NegotiationPerformative.ACCEPT : testCase.api().contains("reject") ? NegotiationPerformative.REJECT : NegotiationPerformative.PROPOSE; NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); - TemplateUri template = testCase.api().contains("propose") + String template = testCase.api().contains("propose") ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI : NegotiationSampleFlow.ENDING_TEMPLATE_URI; ScriptedLlmClient llm = new ScriptedLlmClient(testCase.payload()); @@ -301,7 +301,7 @@ private static Map runScriptedCase( request.put("schema", schemaFor(testCase.api(), null)); } request.put("context", contextPayload(context)); - request.put("template_uri", template.uri()); + request.put("template_uri", template); Map result = new LinkedHashMap<>(); result.put("id", testCase.id()); result.put("api", scriptedApiMethod(testCase.api())); @@ -309,7 +309,7 @@ private static Map runScriptedCase( result.put("expected_exception", testCase.expectedException()); result.put("expected_code", testCase.expectedCode()); try { - Object response = invoke(orchestrator, testCase, context, template); + Object response = invoke(orchestrator, testCase, context, parseTemplateUri(template)); result.put("response", responseSummary(response)); result.put("outcome", "unexpected_success"); result.put("passed", false); @@ -352,7 +352,7 @@ private static Object invoke( A2ATServer server, NegotiationAbnormalEvaluationCase testCase, NegotiationContext context, - TemplateUri templateUri) { + String templateUri) { return switch (testCase.api()) { case "generate_propose" -> client.generateNegotiationProposePromptFromText( testCase.input(), context, templateUri); @@ -395,19 +395,23 @@ private static NegotiationContext contextFor(String mode, NegotiationPerformativ return new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); } - private static TemplateUri templateFor(String template) { + private static String templateFor(String template) { return switch (template == null ? "null" : template.toLowerCase(java.util.Locale.ROOT)) { case "null" -> null; case "propose" -> NegotiationSampleFlow.PROPOSE_TEMPLATE_URI; case "ending" -> NegotiationSampleFlow.ENDING_TEMPLATE_URI; - case "unknown_propose" -> TemplateUri.of( - "Negotiation-T", List.of("information-negotiation", "propose"), "v999"); - case "unknown_ending" -> TemplateUri.of( - "Negotiation-T", List.of("information-negotiation", "accept-reject"), "v999"); + case "unknown_propose" -> "Negotiation-T/information-negotiation/propose/v999"; + case "unknown_ending" -> "Negotiation-T/information-negotiation/accept-reject/v999"; default -> throw new IllegalArgumentException("Unknown abnormal evaluation template: " + template); }; } + /** Parses a template URI string for the typed {@code NegotiationGenerationOrchestrator} seam. */ + private static TemplateUri parseTemplateUri(String templateUri) { + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); + } + private static String apiMethod(String api) { return switch (api) { case "generate_propose" -> "A2ATClient.generateNegotiationProposePromptFromText"; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java index 18341d0f..7af4e11b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java @@ -168,8 +168,8 @@ private static Map requestFor( } request.put("context", contextPayload(context)); request.put("template_uri", testCase.api().contains("propose") - ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri() - : NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()); + ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI + : NegotiationSampleFlow.ENDING_TEMPLATE_URI); return request; } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java index 0302c9c9..962bb077 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -208,8 +208,8 @@ private static MetadataContent generateProposeWithLog( NegotiationEvaluationCase proposeCase = testCase.proposeCase(); boolean fromData = "fromData".equals(channel); Map request = fromData - ? Map.of("data", proposeCase.data(), "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()) - : Map.of("text", proposeCase.text(), "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()); + ? Map.of("data", proposeCase.data(), "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI) + : Map.of("text", proposeCase.text(), "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); try { MetadataContent content = fromData ? server.generateNegotiationProposePromptFromData( @@ -247,13 +247,13 @@ private static FilledParamData validateProposeWithLog( writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_propose_and_fill", "propose", channel, null, Map.of( "prompt", prompt, "schema", schema, - "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), Map.of("filled_data", filled.data()), startedAt, null)); + "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI), Map.of("filled_data", filled.data()), startedAt, null)); return filled; } catch (RuntimeException exception) { writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_propose_and_fill", "propose", channel, null, Map.of( "prompt", prompt, "schema", schema, - "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), null, startedAt, exception)); + "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI), null, startedAt, exception)); throw exception; } } @@ -283,9 +283,9 @@ private static MetadataContent generateEndingWithLog( ? Map.of("data", Map.of( "items", itemsJson(endingItems), "conclusion", accept ? NegotiationConclusion.ACCEPT.toString() : NegotiationConclusion.REJECT.toString()), - "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()) + "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI) : Map.of("text", endingInputText, - "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()); + "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI); try { MetadataContent content = fromData ? accept @@ -334,13 +334,13 @@ private static FilledParamData validateEndingWithLog( prompt, context, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI); writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_" + testCase.decision() + "_and_fill", testCase.decision(), channel, null, Map.of("prompt", prompt, "schema", schema, - "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), + "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI), Map.of("filled_data", filled.data()), startedAt, null)); return filled; } catch (RuntimeException exception) { writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_" + testCase.decision() + "_and_fill", testCase.decision(), channel, null, Map.of("prompt", prompt, "schema", schema, - "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), null, startedAt, exception)); + "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI), null, startedAt, exception)); throw exception; } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java index e4b04d84..d2d3a367 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java @@ -5,7 +5,6 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.TemplateUri; /** Reads and validates Negotiation-T metadata received over A2A. */ public final class NegotiationMetadataReader { @@ -13,14 +12,14 @@ public final class NegotiationMetadataReader { private NegotiationMetadataReader() { } - public static String readPrompt(Map metadata, TemplateUri expectedTemplateUri) { + public static String readPrompt(Map metadata, String expectedTemplateUri) { if (metadata == null) { throw new IllegalArgumentException("Negotiation metadata is required"); } String templateUri = stringValue(metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - if (!expectedTemplateUri.uri().equals(templateUri)) { + if (!expectedTemplateUri.equals(templateUri)) { throw new IllegalArgumentException( - "Unexpected negotiation template URI: expected=" + expectedTemplateUri.uri() + ", actual=" + templateUri); + "Unexpected negotiation template URI: expected=" + expectedTemplateUri + ", actual=" + templateUri); } String prompt = stringValue(metadata.get(ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI)); if (prompt.isBlank()) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java index 4d244f31..2d781fe9 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java @@ -6,7 +6,6 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.server.A2ATServer; @@ -15,9 +14,9 @@ /** Runs the six Negotiation-T APIs used by the private-line complaint sample. */ public final class NegotiationSampleFlow { - public static final TemplateUri PROPOSE_TEMPLATE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + public static final String PROPOSE_TEMPLATE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI; - public static final TemplateUri ENDING_TEMPLATE_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + public static final String ENDING_TEMPLATE_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI; private NegotiationSampleFlow() { } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java index 361bfbd8..362b76cd 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java @@ -4,7 +4,6 @@ import java.util.Map; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; /** * One-shot diagnostic entry: runs {@code generateNotificationPromptFromText} alone with the @@ -58,8 +57,7 @@ private static void run(String[] args) { System.out.println(); A2ATClient client = new A2ATClient(envPath); - TemplateUri templateUri = TemplateUri.parse(ServiceRecoverySampleInputs.TEMPLATE_URI) - .orElseThrow(() -> new IllegalArgumentException("Malformed template URI")); + String templateUri = ServiceRecoverySampleInputs.TEMPLATE_URI; System.out.println("=== [probe] calling generateNotificationPromptFromText ==="); long start = System.currentTimeMillis(); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/A2ATNotificationPromptClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/A2ATNotificationPromptClient.java index 5b447cbb..1acb610d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/A2ATNotificationPromptClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/A2ATNotificationPromptClient.java @@ -3,7 +3,6 @@ import java.util.Map; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; /** * {@link SamplePromptClient} adapter delegating to the real {@link A2ATClient} facade. @@ -28,17 +27,12 @@ public A2ATNotificationPromptClient(A2ATClient delegate) { @Override public MetadataContent generateNotificationPromptFromText(String text, String templateUri) { - return delegate.generateNotificationPromptFromText(text, parse(templateUri)); + return delegate.generateNotificationPromptFromText(text, templateUri); } @Override public MetadataContent generateNotificationPromptFromDataWithSchema( Map data, Map schema, String templateUri) { - return delegate.generateNotificationPromptFromDataWithSchema(data, schema, parse(templateUri)); - } - - private static TemplateUri parse(String templateUri) { - return TemplateUri.parse(templateUri) - .orElseThrow(() -> new IllegalArgumentException("Malformed template URI: " + templateUri)); + return delegate.generateNotificationPromptFromDataWithSchema(data, schema, templateUri); } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java index 814d4d32..bdddd9cb 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java @@ -20,7 +20,6 @@ import net.openan.a2at.sample.service_recovery.shared.env.SampleEnvironmentPathResolver; import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; import net.openan.a2at.sample.service_recovery.shared.registry.RegistryAgentCardMapper; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.server.A2ATServer; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.events.InMemoryQueueManager; @@ -96,9 +95,7 @@ public Map buildAgentCard(String host, int port) { @Override public NotificationPromptValidator buildNotificationValidator(Path envPath) { A2ATServer server = new A2ATServer(envPath); - TemplateUri templateUri = TemplateUri.parse(ServiceRecoverySampleInputs.TEMPLATE_URI) - .orElseThrow(() -> new IllegalArgumentException( - "Malformed template URI: " + ServiceRecoverySampleInputs.TEMPLATE_URI)); + String templateUri = ServiceRecoverySampleInputs.TEMPLATE_URI; return promptText -> server.validateNotificationPromptAndDataFilling( promptText, ServiceRecoverySampleInputs.validationParamSchema(), diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java index b2c17cc2..13c9a843 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java @@ -80,7 +80,7 @@ public static void main(String[] args) { Path envPath = resolveEnvPath(args); String caseSelection = resolveCaseSelection(args); println("Task-T 准确率验证样例,env: " + envPath.toAbsolutePath() + (Files.exists(envPath) ? "" : " (不存在,请先配置)")); - println("模板: " + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); + println("模板: " + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); println("用例: " + caseLabel(caseSelection)); println(); @@ -146,7 +146,7 @@ private static List runTextCase(A2ATClient c println(); try { MetadataContent metadata = - client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); printGeneratedMetadata(metadata); TaskTAccuracyEvaluator.SampleScore score = validateAndScore(server, sample, metadata, "用例一"); scores.add(score); @@ -174,7 +174,7 @@ private static List runDataWithSchemaCase( println(); try { MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( - sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); printGeneratedMetadata(metadata); TaskTAccuracyEvaluator.SampleScore score = validateAndScore(server, sample, metadata, "用例二"); scores.add(score); @@ -217,14 +217,14 @@ private static RejectionSummary runRejectionCase(A2ATClient client, A2ATServer s println(); println("[生成] generateTaskPromptFromDataWithSchema:"); metadata = client.generateTaskPromptFromDataWithSchema( - sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); } else { println("[输入] 自然语言文本 (缺少关键槽位):"); println(sample.text()); println(); println("[生成] generateTaskPromptFromText:"); metadata = - client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); } System.out.println(" templateUri : " + metadata.templateUri()); System.out.println(" promptText : " + metadata.promptText()); @@ -233,7 +233,7 @@ private static RejectionSummary runRejectionCase(A2ATClient client, A2ATServer s Map extracted = server.validateTaskPromptAndDataFilling( metadata.promptText(), sample.validationSchema(), - StandardTemplates.PRIVATE_LINE_COMPLAINT) + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) .data(); unexpectedlyPassed++; println("[意外通过] 应被拒绝却校验通过,提取参数:"); @@ -266,7 +266,7 @@ private static TaskTAccuracyEvaluator.SampleScore validateAndScore( A2ATServer server, TaskTSample sample, MetadataContent metadata, String caseLabel) { try { Map extracted = server.validateTaskPromptAndDataFilling( - metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT) + metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) .data(); println("[服务端] " + caseLabel + " validateTaskPromptAndDataFilling 通过,提取参数:"); println(pretty(extracted)); diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index 37c78476..ddeb461d 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -17,7 +17,6 @@ import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; @@ -103,7 +102,7 @@ void bothFacadesProduceIdenticalMetadataContentForTheSameInput() throws IOExcept assertNotNull(clientResult.promptText()); assertEquals(clientResult, serverResult, "case " + symmetryCase.label() + " [" + language + "]"); assertEquals(clientResult.buildMetadataContent(), serverResult.buildMetadataContent()); - assertEquals(symmetryCase.templateUri().uri(), clientResult.templateUri()); + assertEquals(symmetryCase.templateUri(), clientResult.templateUri()); assertEquals( symmetryCase.performative(), clientResult.negotiationContext().performative(), @@ -158,7 +157,7 @@ private static List symmetryCases() { new SymmetryCase( "information_propose", NegotiationPerformative.PROPOSE, - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI, new NegotiationProposeData( new NegotiationContext( "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, NegotiationPerformative.PROPOSE), @@ -171,7 +170,7 @@ private static List symmetryCases() { new SymmetryCase( "target_propose", NegotiationPerformative.PROPOSE, - StandardTemplates.TARGET_NEGOTIATION_PROPOSE, + StandardTemplates.TARGET_NEGOTIATION_PROPOSE_URI, new NegotiationProposeData( new NegotiationContext( "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5, NegotiationPerformative.PROPOSE), @@ -184,7 +183,7 @@ private static List symmetryCases() { new SymmetryCase( "feasibility_propose", NegotiationPerformative.PROPOSE, - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE_URI, new NegotiationProposeData( new NegotiationContext( "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, NegotiationPerformative.PROPOSE), @@ -197,43 +196,43 @@ private static List symmetryCases() { endingCase( "information_accept", NegotiationPerformative.ACCEPT, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, new InformationEndingContent( NegotiationConclusion.ACCEPT, List.of(new NegotiationItem("area information", "Songshan Lake")))), endingCase( "target_accept", NegotiationPerformative.ACCEPT, - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI, new TargetEndingContent(NegotiationConclusion.ACCEPT, "The confirmed intent.", null)), endingCase( "feasibility_accept", NegotiationPerformative.ACCEPT, - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "The target is achievable.")), endingCase( "information_reject", NegotiationPerformative.REJECT, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, new InformationEndingContent( NegotiationConclusion.REJECT, List.of(new NegotiationItem("area information", "not available")))), endingCase( "target_reject", NegotiationPerformative.REJECT, - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI, new TargetEndingContent(NegotiationConclusion.REJECT, null, "The intent is unclear.")), endingCase( "feasibility_reject", NegotiationPerformative.REJECT, - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, new FeasibilityEndingContent(NegotiationConclusion.REJECT, "The target is not achievable."))); } private static SymmetryCase endingCase( String label, NegotiationPerformative performative, - TemplateUri templateUri, + String templateUri, NegotiationEndingContent content) { return new SymmetryCase( label, @@ -245,7 +244,7 @@ private static SymmetryCase endingCase( /** One symmetry case: a fixed input addressed to one of the three from-data generation methods. */ private record SymmetryCase( - String label, NegotiationPerformative performative, TemplateUri templateUri, Object data) { + String label, NegotiationPerformative performative, String templateUri, Object data) { MetadataContent generate(ProposeGenerator propose, EndingGenerator accept, EndingGenerator reject) { return switch (performative) { @@ -260,13 +259,13 @@ MetadataContent generate(ProposeGenerator propose, EndingGenerator accept, Endin @FunctionalInterface private interface ProposeGenerator { - MetadataContent generate(NegotiationProposeData data, TemplateUri templateUri); + MetadataContent generate(NegotiationProposeData data, String templateUri); } @FunctionalInterface private interface EndingGenerator { - MetadataContent generate(NegotiationEndingData data, TemplateUri templateUri); + MetadataContent generate(NegotiationEndingData data, String templateUri); } public static final class RecordingClient implements LLMClient { diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainConcurrencyTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainConcurrencyTest.java index 2d322238..5654c5e4 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainConcurrencyTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainConcurrencyTest.java @@ -22,7 +22,6 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; import org.junit.jupiter.api.Test; @@ -34,7 +33,7 @@ class AuthzSampleMainConcurrencyTest { private static final String CONTENT_VALIDATION_SYSTEM = "/prompt_resources/prompts/content_validation/zh-CN/system.md"; - private static final TemplateUri TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; + private static final String TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; private static final String ENV_CONTENT = "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-test\n" diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutorTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutorTest.java index 4cd3bf50..61e906cd 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutorTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutorTest.java @@ -19,13 +19,12 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; class AuthzScenarioExecutorTest { - private static final TemplateUri TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; + private static final String TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; private static final Map PARAM_SCHEMA = Map.of("properties", Map.of(), "required", Map.of()); private static final AuthzExpected SUCCESS = new AuthzExpected( new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, null)); @@ -52,7 +51,7 @@ void should_preserveInputOrder_WhenSlowerScenarioFinishesLast() throws Exception } else { gate.countDown(); } - return new MetadataContent(TEMPLATE_URI.uri(), scenario.label(), "Authorization-T/v1"); + return new MetadataContent(TEMPLATE_URI, scenario.label(), "Authorization-T/v1"); }; AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); @@ -76,7 +75,7 @@ void should_isolateFailures_WhenTaskThrows() { case "boom-assert": throw new AssertionError("assertion failure"); default: - return new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + return new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); } }; AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); @@ -102,7 +101,7 @@ void should_isolateFailures_WhenTaskThrows() { @Test void should_deliverProgressCallbacks_WithDistinctCompletionIndices() { AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); @@ -123,7 +122,7 @@ void should_deliverProgressCallbacks_WithDistinctCompletionIndices() { @Test void should_runAllScenariosSerially_WhenSingleWorker() { AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); @@ -154,7 +153,7 @@ void should_returnEmptyList_WhenNoScenarios() { @Test void should_notThrow_WhenProgressListenerIsNull() { AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java index 9624c29d..eaeab23f 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java @@ -21,13 +21,12 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.SlotValidationError; import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; import org.junit.jupiter.api.Test; class AuthzScenarioRunnerTest { - private static final TemplateUri TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT; + private static final String TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; private static final Map PARAM_SCHEMA = Map.of("properties", Map.of(), "required", Map.of()); private static final AuthzExpected SUCCESS = new AuthzExpected( new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, null)); @@ -42,7 +41,7 @@ void should_dispatchToFromTextGenerator() { AtomicReference calledEntry = new AtomicReference<>(); AuthzPromptGenerator generator = scenario -> { calledEntry.set(scenario.entry()); - return new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + return new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); }; AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); @@ -58,7 +57,7 @@ void should_dispatchToFromDataWithSchemaGenerator() { AtomicReference calledEntry = new AtomicReference<>(); AuthzPromptGenerator generator = scenario -> { calledEntry.set(scenario.entry()); - return new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + return new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); }; AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); @@ -73,7 +72,7 @@ void should_dispatchToFromDataWithSchemaGenerator() { @Test void should_match_WhenExpectedSuccessAndValidationPasses() { AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); @@ -89,7 +88,7 @@ void should_match_WhenExpectedSuccessAndValidationPasses() { @Test void should_match_WhenExpectedSemanticRejectedAndSemanticRejected() { AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> { throw new ContentValidationException( ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), "semantic rejected"); @@ -109,7 +108,7 @@ void should_match_WhenExpectedSemanticRejectedAndSemanticRejected() { @Test void should_notMatch_WhenExpectedSuccessAndValidationRejected() { AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> { throw new ContentValidationException( ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), "semantic rejected"); @@ -128,7 +127,7 @@ void should_notMatch_WhenExpectedSuccessAndValidationRejected() { @Test void should_notMatch_WhenExpectedSemanticRejectedAndValidationPasses() { AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenario scenario = @@ -145,7 +144,7 @@ void should_notMatch_WhenExpectedSemanticRejectedAndValidationPasses() { @Test void should_match_WhenPromptTextDiffersFromExpectation() { AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "different prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "different prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); @@ -164,7 +163,7 @@ void should_match_WhenPromptTextDiffersAndServerMatches() { new ClientExpected(null, "expected prompt", null), new ServerExpected("success", null, Map.of("操作类型", "新增授权策略"))); AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "drifted prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "drifted prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("操作类型", "新增授权策略")); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); @@ -181,7 +180,7 @@ void should_match_WhenPromptTextDiffersOnlyInTrailingWhitespace() { AuthzExpected expected = new AuthzExpected( new ClientExpected(null, "generated prompt \n", null), new ServerExpected("success", null, null)); AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), " generated prompt\n", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, " generated prompt\n", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); @@ -249,7 +248,7 @@ void should_notMatch_WhenExpectedSlotValidationErrorAndGeneratorThrowsInfrastruc @Test void should_notMatch_WhenExpectedSuccessAndValidatorThrowsInfrastructureError() { AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> { throw new ContentValidationException(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), "infrastructure error"); }; @@ -268,7 +267,7 @@ void should_notMatch_WhenExpectedSuccessAndValidatorThrowsInfrastructureError() @Test void should_notMatch_WhenExpectedSuccessAndValidatorThrowsResourceNotFound() { AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> { throw new ContentValidationException(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), "resource not found"); }; @@ -287,7 +286,7 @@ void should_notMatch_WhenExpectedSuccessAndValidatorThrowsResourceNotFound() { @Test void should_notMatch_WhenExpectedSuccessAndValidatorThrowsUnknownCode() { AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> { throw new ContentValidationException("unknown_code", "unknown error"); }; @@ -380,7 +379,7 @@ void should_matchParams_WhenActualIsSupersetOfExpected() { new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, Map.of("操作类型", "新增授权策略", "策略列表", List.of(Map.of("业务场景", "校园专网"))))); AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); Map actualEntry = new java.util.LinkedHashMap<>(); actualEntry.put("策略标识", null); actualEntry.put("业务场景", "校园专网"); @@ -402,7 +401,7 @@ void should_notMatchParams_WhenListItemValueDiffers() { new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"))))); AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("策略列表", List.of(Map.of("业务场景", "医疗专线")))); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); @@ -420,7 +419,7 @@ void should_notMatchParams_WhenListLengthDiffers() { new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"))))); AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"), Map.of("业务场景", "医疗专线")))); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); @@ -454,7 +453,7 @@ void should_notMatch_WhenExpectedSuccessAndGeneratorThrowsRuntimeException() { void should_warn_WhenMutationAndEmptyPolicyListSection() { String promptText = "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n\n\n## 预期输出\n输出格式"; AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); @@ -469,7 +468,7 @@ void should_warn_WhenMutationAndEmptyPolicyListSection() { void should_warn_WhenModifyAndEmptyPolicyListSection() { String promptText = "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); @@ -484,7 +483,7 @@ void should_warn_WhenModifyAndEmptyPolicyListSection() { void should_notWarn_WhenQueryAndEmptyPolicyListSection() { String promptText = "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); @@ -499,7 +498,7 @@ void should_notWarn_WhenQueryAndEmptyPolicyListSection() { void should_notWarn_WhenMutationAndNonEmptyPolicyListSection() { String promptText = "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n校园专网,紧急扩容\n\n## 预期输出\n输出格式"; AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); @@ -514,7 +513,7 @@ void should_notWarn_WhenMutationAndNonEmptyPolicyListSection() { void should_notWarn_WhenPromptTextHasUnknownSectionOrder() { String promptText = "## 授权策略的操作描述\n描述\n\n## 授权策略的操作类型\n新增授权策略\n\n## 预期输出\n输出格式\n\n## 动网操作的授权策略列表\n\n"; AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); @@ -529,7 +528,7 @@ void should_notWarn_WhenPromptTextHasUnknownSectionOrder() { void should_notWarn_WhenNoOperationTypeSection() { String promptText = "## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), promptText, "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); @@ -545,7 +544,7 @@ void should_useScenarioSchema_WhenValidateSchemaPresent() { Map scenarioSchema = Map.of("处置规则", "应为短语"); AtomicReference> receivedSchema = new AtomicReference<>(); AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> { receivedSchema.set(schema); return new FilledParamData(Map.of()); @@ -563,7 +562,7 @@ void should_useScenarioSchema_WhenValidateSchemaPresent() { void should_useDefaultSchema_WhenValidateSchemaAbsent() { AtomicReference> receivedSchema = new AtomicReference<>(); AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI.uri(), "generated prompt", "Authorization-T/v1"); + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); AuthzPromptValidator validator = (prompt, schema, templateUri) -> { receivedSchema.set(schema); return new FilledParamData(Map.of()); diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java index 21bcc140..278cce77 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java @@ -20,7 +20,7 @@ void readsCanonicalNegotiationMetadata() { ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, "prompt", MetadataContent.TEMPLATE_URI_METADATA_KEY, - NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()); + NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); assertEquals( "prompt", @@ -33,7 +33,7 @@ void readsTheNegotiationContextFromMetadata() { ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, "prompt", MetadataContent.TEMPLATE_URI_METADATA_KEY, - NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri(), + NegotiationSampleFlow.PROPOSE_TEMPLATE_URI, MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY, Map.of("id", "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round", 1, "maxRounds", 5, "performative", "PROPOSE")); @@ -91,7 +91,7 @@ void rejectsMissingPromptAndWrongTemplateOrExtension() { () -> NegotiationMetadataReader.readPrompt( Map.of( MetadataContent.TEMPLATE_URI_METADATA_KEY, - NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri()), + NegotiationSampleFlow.PROPOSE_TEMPLATE_URI), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI)); assertThrows( IllegalArgumentException.class, @@ -100,7 +100,7 @@ void rejectsMissingPromptAndWrongTemplateOrExtension() { ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, "prompt", MetadataContent.TEMPLATE_URI_METADATA_KEY, - NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri()), + NegotiationSampleFlow.ENDING_TEMPLATE_URI), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI)); assertThrows(IllegalArgumentException.class, () -> NegotiationMetadataReader.requireExtension("Task-T/v1")); } diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java index 29edc746..30a0717c 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java @@ -35,8 +35,8 @@ void acceptFlowCoversFourApis() throws IOException { NegotiationSampleFlow.NegotiationFlowResult result = run(NegotiationDecision.ACCEPT); assertEquals(NegotiationDecision.ACCEPT, result.decision()); - assertEquals(NegotiationSampleFlow.PROPOSE_TEMPLATE_URI.uri(), result.propose().templateUri()); - assertEquals(NegotiationSampleFlow.ENDING_TEMPLATE_URI.uri(), result.ending().templateUri()); + assertEquals(NegotiationSampleFlow.PROPOSE_TEMPLATE_URI, result.propose().templateUri()); + assertEquals(NegotiationSampleFlow.ENDING_TEMPLATE_URI, result.ending().templateUri()); assertEquals(ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, result.propose().extensionUri()); assertContextIsShared(result); assertEquals( diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java index 82765c63..d4b80f1a 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java @@ -21,7 +21,6 @@ import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.server.A2ATServer; import org.junit.jupiter.api.AfterAll; @@ -41,7 +40,7 @@ class NotificationTAccuracyTest { private static A2ATClient client; private static A2ATServer server; - private static TemplateUri templateUri; + private static String templateUri; private static Map dataSchema; private static Map validationSchema; private static boolean apiKeyConfigured; @@ -53,8 +52,7 @@ class NotificationTAccuracyTest { @BeforeAll static void setup() throws Exception { - templateUri = TemplateUri.parse(TEMPLATE_URI_STR) - .orElseThrow(() -> new IllegalStateException("Invalid template URI: " + TEMPLATE_URI_STR)); + templateUri = TEMPLATE_URI_STR; dataSchema = loadJsonResource(DATA_SCHEMA_RESOURCE); validationSchema = loadJsonResource(VALIDATION_SCHEMA_RESOURCE); diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java index 46dd8508..3fe657e0 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java @@ -295,7 +295,7 @@ private void runAndAssert(TaskTSample sample) { System.out.println(); MetadataContent metadata = client.generateTaskPromptFromText( - sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); printMetadata(metadata); Map extracted = validate(server, sample, metadata); @@ -322,7 +322,7 @@ private void runDataAndAssert(TaskTSample sample) { System.out.println(); MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( - sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); printMetadata(metadata); Map extracted = validate(server, sample, metadata); @@ -359,10 +359,10 @@ private void runRejectionAndAssert(TaskTRejectionSample sample, boolean isData) try { if (isData) { metadata = client.generateTaskPromptFromDataWithSchema( - sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); } else { metadata = client.generateTaskPromptFromText( - sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT); + sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); } } catch (PromptGenerationException e) { // 客户端前置校验拒绝:缺少必填槽位,在调用 LLM 之前就被拦截 @@ -373,7 +373,7 @@ private void runRejectionAndAssert(TaskTRejectionSample sample, boolean isData) try { Map extracted = server.validateTaskPromptAndDataFilling( - metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT) + metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) .data(); System.out.println("[意外通过] 应被拒绝却校验通过,提取参数: " + extracted); // 拒绝样本应被拒绝,意外通过视为失败 @@ -394,7 +394,7 @@ private void runRejectionAndAssert(TaskTRejectionSample sample, boolean isData) private static Map validate(A2ATServer server, TaskTSample sample, MetadataContent metadata) { return server.validateTaskPromptAndDataFilling( - metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT) + metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) .data(); } diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java index c14c764b..89716458 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java @@ -16,7 +16,6 @@ import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.server.A2ATServer; import org.junit.jupiter.api.Test; @@ -96,21 +95,25 @@ void resultRecordsExposeTheirPinnedFieldFaces() { } @Test - void sixTargetApisUseStructuredTemplateUri() { - Set targetMethods = Set.of( - "generateNegotiationProposePromptFromText", - "generateNegotiationAcceptPromptFromText", - "generateNegotiationRejectPromptFromText", - "validateProposePromptAndDataFilling", - "validateAcceptPromptAndDataFilling", - "validateRejectPromptAndDataFilling"); + void allFacadeTemplateUriParamsAreStrings() { for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { - Arrays.stream(facade.getMethods()) - .filter(method -> targetMethods.contains(method.getName())) - .forEach(method -> assertEquals( - TemplateUri.class, - method.getParameterTypes()[method.getParameterCount() - 1], - "last parameter of " + facade.getSimpleName() + "." + method.getName())); + List methodsWithTemplateUri = Arrays.stream(facade.getDeclaredMethods()) + .filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers()) + && !java.lang.reflect.Modifier.isStatic(method.getModifiers())) + .filter(method -> Arrays.stream(method.getParameters()) + .anyMatch(parameter -> "templateUri".equals(parameter.getName()))) + .toList(); + assertFalse(methodsWithTemplateUri.isEmpty(), facade.getSimpleName() + " must declare templateUri methods"); + for (Method method : methodsWithTemplateUri) { + for (java.lang.reflect.Parameter parameter : method.getParameters()) { + if ("templateUri".equals(parameter.getName())) { + assertEquals( + String.class, + parameter.getType(), + "templateUri parameter of " + facade.getSimpleName() + "." + method.getName()); + } + } + } } } From 10b07ffc1f09e91f79a3ef9b182bc5d7367c45cd Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Sun, 30 Aug 2026 00:56:42 +0800 Subject: [PATCH 254/295] docs: document string template uri facade parameters Signed-off-by: codetalks-hw --- docs/en/API_Reference.md | 171 +++++++++--------- docs/en/developer_guide.md | 12 +- "docs/zh/API\345\217\202\350\200\203.md" | 171 +++++++++--------- ...00\345\217\221\346\214\207\345\215\227.md" | 11 +- 4 files changed, 181 insertions(+), 184 deletions(-) diff --git a/docs/en/API_Reference.md b/docs/en/API_Reference.md index e934867c..8efaf9f9 100644 --- a/docs/en/API_Reference.md +++ b/docs/en/API_Reference.md @@ -44,22 +44,24 @@ The A2A-T SDK exposes exactly two public entry points: the client entry `A2ATCli | message | String | Human-readable error description, rendered by the SDK from the message template of the error code and following `A2AT_LANGUAGE` | | facts | Map<String, String> | Structured fact values behind the rendered message (e.g. `section_label`, `index`, `field_label`), may be null | -- **TemplateUri:** the template URI value type. Prefer building it with the `net.openan.a2at.sdk.core.model.StandardTemplates` constants; for strings coming from outside the code, parse them with `TemplateUri.parse(String)`, which returns an `Optional` and never throws. The currently supported TemplateUri constants are listed below: +- **Template URI:** the `A2ATClient` and `A2ATServer` facades declare the template selection of every template-specific method as a plain `String templateUri` (e.g. `Task-T/network-layer/private-line-complaint/v1`). Prefer passing the `net.openan.a2at.sdk.core.model.StandardTemplates` String constants (constant name suffixed `_URI`, each with a typed `TemplateUri` twin named without the suffix); strings coming from outside the code are passed directly. The typed value type `TemplateUri` is still used by the lower-level service layer (e.g. `NegotiationContentService`), where it can be obtained with `TemplateUri.parse(String)`, which returns an `Optional` and never throws. The currently supported template URI constants (String form) are listed below: | Constant | Description | TemplateUri | | ---- | ---- | ---- | -| StandardTemplates.ENERGY_SAVING | Task-T energy-saving task template | Task-T/network-layer/ran-energy-saving/v1 | -| StandardTemplates.PRIVATE_LINE_COMPLAINT | Task-T private-line complaint task template | Task-T/network-layer/private-line-complaint/v1 | -| StandardTemplates.SUBSCRIBE_INCIDENT | Notification-T incident subscription notification template | Notification-T/network-layer/subscribe-incident/v1 | -| StandardTemplates.SERVICE_RECOVERY | Notification-T service recovery notification template | Notification-T/network-layer/service-recovery/v1 | -| StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT | Authorization-T authorization policy management template | Authorization-T/authorization-policy-management/v1 | -| StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE | Negotiation-T information negotiation propose template | Negotiation-T/information-negotiation/propose/v1 | -| StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT | Negotiation-T information negotiation accept/reject template | Negotiation-T/information-negotiation/accept-reject/v1 | -| StandardTemplates.TARGET_NEGOTIATION_PROPOSE | Negotiation-T target negotiation propose template | Negotiation-T/target-negotiation/propose/v1 | -| StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT | Negotiation-T target negotiation accept/reject template | Negotiation-T/target-negotiation/accept-reject/v1 | -| StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE | Negotiation-T feasibility negotiation propose template | Negotiation-T/feasibility-negotiation/propose/v1 | -| StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT | Negotiation-T feasibility negotiation accept/reject template | Negotiation-T/feasibility-negotiation/accept-reject/v1 | -| StandardTemplates.NEGOTIATION_ABORT | Negotiation-T common negotiation abort template | Negotiation-T/common/abort/v1 | +| StandardTemplates.ENERGY_SAVING_URI | Task-T energy-saving task template | Task-T/network-layer/ran-energy-saving/v1 | +| StandardTemplates.PRIVATE_LINE_COMPLAINT_URI | Task-T private-line complaint task template | Task-T/network-layer/private-line-complaint/v1 | +| StandardTemplates.SUBSCRIBE_INCIDENT_URI | Notification-T incident subscription notification template | Notification-T/network-layer/subscribe-incident/v1 | +| StandardTemplates.SERVICE_RECOVERY_URI | Notification-T service recovery notification template | Notification-T/network-layer/service-recovery/v1 | +| StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI | Authorization-T authorization policy management template | Authorization-T/authorization-policy-management/v1 | +| StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI | Negotiation-T information negotiation propose template | Negotiation-T/information-negotiation/propose/v1 | +| StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T information negotiation accept/reject template | Negotiation-T/information-negotiation/accept-reject/v1 | +| StandardTemplates.TARGET_NEGOTIATION_PROPOSE_URI | Negotiation-T target negotiation propose template | Negotiation-T/target-negotiation/propose/v1 | +| StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T target negotiation accept/reject template | Negotiation-T/target-negotiation/accept-reject/v1 | +| StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE_URI | Negotiation-T feasibility negotiation propose template | Negotiation-T/feasibility-negotiation/propose/v1 | +| StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T feasibility negotiation accept/reject template | Negotiation-T/feasibility-negotiation/accept-reject/v1 | +| StandardTemplates.NEGOTIATION_ABORT_URI | Negotiation-T common negotiation abort template | Negotiation-T/common/abort/v1 | + +- **Facade template URI failure policy (both `A2ATClient` and `A2ATServer`):** every facade method that takes a `templateUri` parameter (including `getPrompt`) accepts a raw URI string. A null template URI throws `NullPointerException`; a blank or malformed URI (fewer than three segments, or a segment that is not simple) throws `IllegalArgumentException` with the message `Unparseable template URI: `. This replaces the previous behavior where a null typed template URI on the server task/notification/authorization validate trio threw `ContentValidationException` with the code `negotiation.invalid_input` — such programming errors are now plain JDK exceptions, not part of the `A2ATError` tree. @@ -76,7 +78,7 @@ The A2A-T SDK exposes exactly two public entry points: the client entry `A2ATCli ```java public MetadataContent generateNegotiationProposePromptFromText( - String text, NegotiationContext context, TemplateUri templateUri) + String text, NegotiationContext context, String templateUri) ``` **Typical scenarios**: after receiving a Task-T task message with missing parameters, the server agent uses natural language to send a "supplement the missing information" negotiation request to the client agent; also applicable when the client agent initiates a target-clarification or feasibility-evaluation request to the server. @@ -89,7 +91,7 @@ public MetadataContent generateNegotiationProposePromptFromText( | ---- | ---- | ---- | ---- | | text | String | Yes | Natural-language text describing the negotiation proposal (e.g. the list of missing information items to request); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | context | NegotiationContext | Yes | Negotiation session context, injected directly into the `negotiationContext` metadata of the generated message without going through the LLM | -| templateUri | TemplateUri | Yes | Propose template, e.g. `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE` | +| templateUri | String | Yes | Propose template, e.g. `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI` | **Request Example** @@ -110,7 +112,7 @@ MetadataContent propose = client.generateNegotiationProposePromptFromText( "Please provide the following missing information: complaint category: private line interruption or poor private line quality. " + "Both parameters are required; diagnosis cannot start without them.", ctx, - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI); // The generated metadata travels with the A2A message Map metadata = propose.buildMetadataContent(); @@ -150,7 +152,7 @@ Error codes: - `negotiation.field_missing` (a required field is missing) -A null argument throws `NullPointerException`; a templateUri whose phase segment is not `propose` throws `IllegalArgumentException`. +A null argument throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or a templateUri whose phase segment is not `propose` throws `IllegalArgumentException`. **Response Example** @@ -171,7 +173,7 @@ Please supplement the relevant content based on . ```java public MetadataContent generateNegotiationAcceptPromptFromText( - String text, NegotiationContext context, TemplateUri templateUri) + String text, NegotiationContext context, String templateUri) ``` **Typical scenarios**: after receiving the peer's information-negotiation request, the negotiation responder (usually the client agent) supplements/delivers the requested information in natural language and generates an accept message to return, e.g. confirming that diagnosis can start after supplementing the access port name and complaint category. @@ -184,7 +186,7 @@ public MetadataContent generateNegotiationAcceptPromptFromText( | ---- | ---- | ---- | ---- | | text | String | Yes | Natural-language text describing the acceptance (e.g. the list of supplemented/delivered information items); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | context | NegotiationContext | Yes | Negotiation session context | -| templateUri | TemplateUri | Yes | Accept-reject template, e.g. `StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT` | +| templateUri | String | Yes | Accept-reject template, e.g. `StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI` | **Request Example** @@ -194,7 +196,7 @@ MetadataContent accept = client.generateNegotiationAcceptPromptFromText( + "2. Complaint category: dedicated-line quality degradation. " + "The information is complete and diagnosis can start.", ctx, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); ``` **Output** @@ -235,7 +237,7 @@ Accept ```java public MetadataContent generateNegotiationRejectPromptFromText( - String text, NegotiationContext context, TemplateUri templateUri) + String text, NegotiationContext context, String templateUri) ``` **Typical scenarios**: when the negotiation responder (usually the client agent) cannot satisfy the peer's negotiation request, it generates a reject message in natural language to return and end the current negotiation round, e.g. the access port name cannot be provided because the site inventory is unavailable. @@ -250,7 +252,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( MetadataContent reject = client.generateNegotiationRejectPromptFromText( "I refuse to supplement the information: the access port name cannot be provided because the site inventory is unavailable. This negotiation is over.", ctx, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); ``` **Output** @@ -290,7 +292,7 @@ Reject ```java public MetadataContent generateNegotiationProposePromptFromData( - NegotiationProposeData data, TemplateUri templateUri) + NegotiationProposeData data, String templateUri) ``` **Typical scenarios**: the same initiation scenarios as the fromText variant, but the input is structured data constructed by the business system (e.g. the server agent automatically generates the negotiation-request items from the missing-slot list detected by `validateTaskPromptAndDataFilling`), suitable for scenarios that require deterministic message content and want to avoid the nondeterminism of LLM extraction. @@ -302,7 +304,7 @@ public MetadataContent generateNegotiationProposePromptFromData( | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | | data | `NegotiationProposeData(context, content)` | Yes | Negotiation context plus typed propose content; the content type depends on the negotiation type | -| templateUri | TemplateUri | Yes | Propose template | +| templateUri | String | Yes | Propose template | Propose content of the three negotiation types: @@ -334,7 +336,7 @@ MetadataContent propose = client.generateNegotiationProposePromptFromData( new NegotiationItem("Complaint Category", "e.g. dedicated-line quality degradation"), new NegotiationItem("Private Line Service Identifier", null)), "OR")), - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI); ``` **Output** @@ -347,9 +349,9 @@ On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). E - `negotiation.content_invalid` (a typed content field is invalid, e.g. blank required items or a blank required description) -- `negotiation.field_missing` (a required field is missing during rendering) +- `template.render_failed` (template rendering failure) -There are also two categories of programming errors (outside the `A2ATError` tree, standard JDK exceptions): a null argument or a null context within it throws `NullPointerException`; a content type that does not match the template's negotiation type, or a phase segment that is not `propose`, throws `IllegalArgumentException`. +There are also two categories of programming errors (outside the `A2ATError` tree, standard JDK exceptions): a null argument or a null context within it throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1), a content type that does not match the template's negotiation type, or a phase segment that is not `propose`, throws `IllegalArgumentException`. **Response Example** @@ -373,7 +375,7 @@ Relationship between missing items: OR ```java public MetadataContent generateNegotiationAcceptPromptFromData( - NegotiationEndingData data, TemplateUri templateUri) + NegotiationEndingData data, String templateUri) ``` **Typical scenarios**: the negotiation responder (usually the client agent) programmatically fills in the parameters per the peer's requested slot list and generates an accept message from structured items to return. @@ -385,7 +387,7 @@ public MetadataContent generateNegotiationAcceptPromptFromData( | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | | data | `NegotiationEndingData(context, content)` | Yes | Negotiation context plus typed accept content (conclusion is `ACCEPT`) | -| templateUri | TemplateUri | Yes | Accept-reject template | +| templateUri | String | Yes | Accept-reject template | Accept content of the three negotiation types: `InformationEndingContent(ACCEPT, items)` (list of delivered information items), `TargetEndingContent(ACCEPT, confirmedIntent, null)` (finally confirmed intent), `FeasibilityEndingContent(ACCEPT, feasibilitySummary)` (feasibility assessment conclusion summary). @@ -404,7 +406,7 @@ MetadataContent accept = client.generateNegotiationAcceptPromptFromData( List.of( new NegotiationItem("Access Port Name", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"), new NegotiationItem("Complaint Category", "dedicated-line quality degradation")))), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); ``` **Output** @@ -417,9 +419,9 @@ On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). E - `negotiation.content_invalid` (a typed content field is invalid, e.g. blank required items or a blank required description) -- `negotiation.field_missing` (a required field is missing during rendering) +- `template.render_failed` (template rendering failure) -Programming errors: a null argument or a null context within it throws `NullPointerException`; a mismatched content type or a phase segment that is not `accept-reject` throws `IllegalArgumentException`. A `conclusion` that is not `ACCEPT` is rejected with the `negotiation.conclusion_mismatch` business error. +Programming errors: a null argument or a null context within it throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1), a mismatched content type or a phase segment that is not `accept-reject` throws `IllegalArgumentException`; a `conclusion` that is not `ACCEPT` is rejected with the `negotiation.conclusion_mismatch` business error. **Response Example** @@ -441,7 +443,7 @@ Accept ```java public MetadataContent generateNegotiationRejectPromptFromData( - NegotiationEndingData data, TemplateUri templateUri) + NegotiationEndingData data, String templateUri) ``` **Typical scenarios**: the negotiation responder, after programmatically determining that the peer's request cannot be satisfied, generates a reject message from structured items (the items that cannot be provided and the reasons) to return. @@ -459,7 +461,7 @@ MetadataContent reject = client.generateNegotiationRejectPromptFromData( new InformationEndingContent( NegotiationConclusion.REJECT, List.of(new NegotiationItem("Access Port Name", "cannot be provided because the port inventory is temporarily unavailable on the workbench side")))), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); ``` **Output** @@ -472,9 +474,9 @@ On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). E - `negotiation.content_invalid` (a typed content field is invalid, e.g. blank required items or a blank required description) -- `negotiation.field_missing` (a required field is missing during rendering) +- `template.render_failed` (template rendering failure) -Programming errors: a null argument or a null context within it throws `NullPointerException`; a mismatched content type or a phase segment that is not `accept-reject` throws `IllegalArgumentException`. A `conclusion` that is not `REJECT` is rejected with the `negotiation.conclusion_mismatch` business error. +Programming errors: a null argument or a null context within it throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1), a mismatched content type or a phase segment that is not `accept-reject` throws `IllegalArgumentException`; a `conclusion` that is not `REJECT` is rejected with the `negotiation.conclusion_mismatch` business error. **Response Example** @@ -495,7 +497,7 @@ Reject ```java public FilledParamData validateProposePromptAndDataFilling( - String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) + String prompt, NegotiationContext context, Map schema, String templateUri) ``` **Typical scenarios**: an outbound self-check by the initiator (server agent) before sending a negotiation request; or the receiver (client agent) validating an inbound negotiation request and extracting the list of slots to supplement, driving the subsequent parameter filling. @@ -509,7 +511,7 @@ public FilledParamData validateProposePromptAndDataFilling( | prompt | String | Yes | Negotiation propose message text to validate (`MetadataContent.promptText()`); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | context | NegotiationContext | Yes | Negotiation context traveling with the message; a null value is reported as `negotiation.invalid_input` | | schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema declaring the parameters to extract | -| templateUri | TemplateUri | Yes | Propose template | +| templateUri | String | Yes | Propose template | **Request Example** @@ -532,7 +534,7 @@ Map schema = Map.of( // proposePrompt is the negotiation propose message text sent by the peer FilledParamData requested = client.validateProposePromptAndDataFilling( - proposePrompt, ctx, schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); + proposePrompt, ctx, schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI); // After filtering out the context parameters (id/round/maxRounds), the remaining keys // are the slots to supplement @@ -559,9 +561,7 @@ Error codes: - `negotiation.invalid_input` (the message is not a negotiation message, or the context is null) -- `negotiation.invalid_context_id` (the context id is not a valid UUID) - -- `negotiation.round_exceeded` (the context round exceeds the configured maximum) +- `negotiation.rule_violation` (the negotiation context violates a rule; the nested slot error codes in `getErrors()` identify the concrete rule, e.g. `negotiation.invalid_context_id`, `negotiation.round_exceeded`) - `negotiation.semantic_rejected` (semantic validation rejected the message; the per-slot details in `getErrors()` use the closed `negotiation.*` code set, e.g. `negotiation.conclusion_content_mismatch`, `negotiation.missing_result_content`, `negotiation.field_inconsistency`) @@ -569,7 +569,7 @@ Error codes: - `template.not_found` (validation prompt resources missing) -Programming errors: a null prompt or schema throws `NullPointerException`; a blank prompt or a mismatched phase segment throws `IllegalArgumentException`. +Programming errors: a null prompt, schema or templateUri throws `NullPointerException`; a blank prompt, a blank or malformed templateUri (see the facade failure policy in 1.1), or a mismatched phase segment throws `IllegalArgumentException`. **Response Example** @@ -590,7 +590,7 @@ requested.data() = ```java public FilledParamData validateAcceptPromptAndDataFilling( - String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) + String prompt, NegotiationContext context, Map schema, String templateUri) ``` **Typical scenarios**: the initiator (server agent) validates the accept message returned by the peer, extracts the delivered parameter values, and cross-checks them against the expected fill values before continuing task execution. @@ -609,7 +609,7 @@ A2ATServer server = new A2ATServer(Path.of("server.env")); // acceptPrompt is the accept message text returned by the client; // acceptContext is its negotiation context FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( - acceptPrompt, acceptContext, schema, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + acceptPrompt, acceptContext, schema, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); ``` **Output** @@ -620,7 +620,7 @@ On failure, throws `NegotiationParamExtractionException` (structure same as 1.3. - `negotiation.invalid_input` (the message is not an accept negotiation message, or the context is null) -- `negotiation.invalid_context_id` / `negotiation.round_exceeded` (the negotiation context violates a structural rule) +- `negotiation.rule_violation` (the negotiation context violates a rule; the nested slot error codes in `getErrors()` identify the concrete rule, e.g. `negotiation.invalid_context_id`, `negotiation.round_exceeded`) - `negotiation.semantic_rejected` (the conclusion is not Accept, or the content does not satisfy the accept-phase constraints) @@ -628,7 +628,7 @@ On failure, throws `NegotiationParamExtractionException` (structure same as 1.3. - `template.not_found` (validation prompt resources missing) -Programming errors: a null prompt or schema throws `NullPointerException`; a blank prompt or a phase segment that is not `accept-reject` throws `IllegalArgumentException`. +Programming errors: a null prompt, schema or templateUri throws `NullPointerException`; a blank prompt, a blank or malformed templateUri (see the facade failure policy in 1.1), or a phase segment that is not `accept-reject` throws `IllegalArgumentException`. **Response Example** @@ -649,7 +649,7 @@ acceptParams.data() = ```java public FilledParamData validateRejectPromptAndDataFilling( - String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) + String prompt, NegotiationContext context, Map schema, String templateUri) ``` **Typical scenarios**: the initiator (server agent) validates the reject message returned by the peer, extracts the rejection reason, and terminates the task or escalates to manual handling accordingly. @@ -662,7 +662,7 @@ public FilledParamData validateRejectPromptAndDataFilling( ```java FilledParamData rejectParams = server.validateRejectPromptAndDataFilling( - rejectPrompt, ctx, schema, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + rejectPrompt, ctx, schema, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); ``` **Output** @@ -673,7 +673,7 @@ On failure, throws `NegotiationParamExtractionException` (structure same as 1.3. - `negotiation.invalid_input` (the message is not a reject negotiation message, or the context is null) -- `negotiation.invalid_context_id` / `negotiation.round_exceeded` (the negotiation context violates a structural rule) +- `negotiation.rule_violation` (the negotiation context violates a rule; the nested slot error codes in `getErrors()` identify the concrete rule, e.g. `negotiation.invalid_context_id`, `negotiation.round_exceeded`) - `negotiation.semantic_rejected` (the conclusion is not Reject, or the content does not satisfy the reject-phase constraints) @@ -681,7 +681,7 @@ On failure, throws `NegotiationParamExtractionException` (structure same as 1.3. - `template.not_found` (validation prompt resources missing) -Programming errors: a null prompt or schema throws `NullPointerException`; a blank prompt or a phase segment that is not `accept-reject` throws `IllegalArgumentException`. +Programming errors: a null prompt, schema or templateUri throws `NullPointerException`; a blank prompt, a blank or malformed templateUri (see the facade failure policy in 1.1), or a phase segment that is not `accept-reject` throws `IllegalArgumentException`. **Response Example** @@ -700,7 +700,7 @@ rejectParams.data() = **API Definition** ```java -public MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) +public MetadataContent generateTaskPromptFromText(String text, String templateUri) ``` **Typical scenarios**: the client agent converts the user's natural-language task description (e.g. a private-line complaint diagnosis request) into a Task-T protocol message for a specified scenario; suitable when the target template is already known and scenario recognition should be skipped. @@ -712,7 +712,7 @@ public MetadataContent generateTaskPromptFromText(String text, TemplateUri templ | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | | text | String | Yes | Natural-language task description; the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | -| templateUri | TemplateUri | Yes | Task-T template, e.g. `StandardTemplates.PRIVATE_LINE_COMPLAINT` (`Task-T/network-layer/private-line-complaint/v1`) | +| templateUri | String | Yes | Task-T template, e.g. `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI` (`Task-T/network-layer/private-line-complaint/v1`) | **Request Example** @@ -729,7 +729,7 @@ MetadataContent metadata = client.generateTaskPromptFromText( + "the customer reports poor private line quality. Starting from 8:30 AM on May 11, 2026, accessing the core " + "systems in Guangzhou from Shenzhen has been extremely slow, latency jumped from 12ms to 320ms, the counter " + "and mobile banking keep reporting connection timeouts, and the OSS sequence number is event-id-20260511-09013.", - StandardTemplates.PRIVATE_LINE_COMPLAINT); + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); ``` **Output** @@ -772,7 +772,7 @@ Error codes: - `input.text_too_long` (input longer than `A2AT_INPUT_TEXT_MAX_CHARS`) -Programming errors: a null text or templateUri throws `NullPointerException`. +Programming errors: a null text or templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`. **Response Example** @@ -812,7 +812,7 @@ Requirement: The complaint diagnosis task result should include the following in ```java public MetadataContent generateTaskPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) + Map data, Map schema, String templateUri) ``` **Typical scenarios**: the client agent converts structured task parameters from an upstream system (field names may differ from the template slots; the schema describes the field semantics) into a Task-T protocol message; suitable when the task parameters are already available in structured form. @@ -825,7 +825,7 @@ public MetadataContent generateTaskPromptFromDataWithSchema( | ---- | ---- | ---- | ---- | | data | Map<String, Object> | Yes | Structured business-field input; keys are business field names and values are field values | | schema | Map<String, Object> | Yes (non-empty) | Field-semantics JSON Schema describing the meaning and constraints of each field | -| templateUri | TemplateUri | Yes | Task-T template | +| templateUri | String | Yes | Task-T template | **Request Example** @@ -855,14 +855,14 @@ Map semanticsSchema = Map.of( "required", List.of("portName", "complaintScenario")); MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( - data, semanticsSchema, StandardTemplates.PRIVATE_LINE_COMPLAINT); + data, semanticsSchema, StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); ``` **Output** On success, returns `MetadataContent` (structure same as [1.3.10](#1310-generatetaskpromptfromtext)). -On failure, throws `PromptGenerationException` (structure same as 1.3.10). Programming errors: a null argument throws `NullPointerException`; an empty schema map throws `IllegalArgumentException`. +On failure, throws `PromptGenerationException` (structure same as 1.3.10). Programming errors: a null argument throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or an empty schema map throws `IllegalArgumentException`. **Response Example** (same template as 1.3.10; slot values come from the structured input, and `faultDetail` is a truncated sample value) @@ -902,7 +902,7 @@ Requirement: The complaint diagnosis task result should include the following in ```java public FilledParamData validateTaskPromptAndDataFilling( - String prompt, Map schema, TemplateUri templateUri) + String prompt, Map schema, String templateUri) ``` **Typical scenarios**: the parameter validation and extraction entry point for the server agent after receiving a Task-T message and before business execution; also the decision point for "missing-slot detection" in the negotiation flow — when a required parameter is missing, the caller decides whether to initiate a negotiation to supplement it. @@ -915,7 +915,7 @@ public FilledParamData validateTaskPromptAndDataFilling( | ---- | ---- | ---- | ---- | | prompt | String | Yes (non-blank) | Task prompt message text to validate (`MetadataContent.promptText()`); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema declaring the parameters to extract/validate and the required constraints | -| templateUri | TemplateUri | Yes | Task-T template (the prefix segment must be `Task-T`) | +| templateUri | String | Yes | Task-T template (the prefix segment must be `Task-T`) | **Request Example** @@ -943,7 +943,7 @@ Map validationSchema = Map.of( Map extracted = server .validateTaskPromptAndDataFilling( - metadata.promptText(), validationSchema, StandardTemplates.PRIVATE_LINE_COMPLAINT) + metadata.promptText(), validationSchema, StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) .data(); ``` @@ -974,7 +974,7 @@ Error codes: - `input.text_too_long` (prompt longer than `A2AT_INPUT_TEXT_MAX_CHARS`) -Programming errors: a null prompt / schema / templateUri throws `NullPointerException`; a blank prompt throws `IllegalArgumentException`. +Programming errors: a null prompt / schema / templateUri throws `NullPointerException`; a blank prompt or a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`. **Response Example** @@ -1001,7 +1001,7 @@ ContentValidationException: [negotiation.semantic_rejected] ... **API Definition** ```java -public MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri) +public MetadataContent generateNotificationPromptFromText(String text, String templateUri) ``` **Typical scenarios**: the client agent converts a natural-language subscription requirement (e.g. a service recovery event subscription) into a Notification-T subscription message for a specified scenario. @@ -1013,7 +1013,7 @@ public MetadataContent generateNotificationPromptFromText(String text, TemplateU | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | | text | String | Yes | Natural-language subscription description (notification topic, subscribe condition, notification data format, etc.); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | -| templateUri | TemplateUri | Yes | Notification-T template, e.g. `StandardTemplates.SUBSCRIBE_INCIDENT`, `StandardTemplates.SERVICE_RECOVERY` | +| templateUri | String | Yes | Notification-T template, e.g. `StandardTemplates.SUBSCRIBE_INCIDENT_URI`, `StandardTemplates.SERVICE_RECOVERY_URI` | **Request Example** @@ -1022,10 +1022,9 @@ import java.nio.file.Path; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; A2ATClient client = new A2ATClient(Path.of("client.env")); -TemplateUri templateUri = TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); +String templateUri = StandardTemplates.SERVICE_RECOVERY_URI; MetadataContent result = client.generateNotificationPromptFromText( "I want to subscribe to service recovery events. The notification data format is as follows: " @@ -1040,7 +1039,7 @@ MetadataContent result = client.generateNotificationPromptFromText( On success, returns `MetadataContent` (structure same as [1.3.10](#1310-generatetaskpromptfromtext); `extensionUri` is `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1`). -On failure, throws `PromptGenerationException` (structure same as 1.3.10). Programming errors: a null text or templateUri throws `NullPointerException`. +On failure, throws `PromptGenerationException` (structure same as 1.3.10). Programming errors: a null text or templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`. **Response Example** (rendered per the template; the actual text varies with the LLM slot-extraction result. This example input does not specify a subscribe condition, so that slot is left empty) @@ -1079,7 +1078,7 @@ Service recovery event ```java public MetadataContent generateNotificationPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) + Map data, Map schema, String templateUri) ``` **Typical scenarios**: the client agent converts structured subscription parameters submitted by an upstream system or UI into a Notification-T subscription message; suitable when the subscription parameters are already available in structured form. @@ -1092,7 +1091,7 @@ public MetadataContent generateNotificationPromptFromDataWithSchema( | ---- | ---- | ---- | ---- | | data | Map<String, Object> | Yes | Structured subscription input (e.g. the subscribe condition and the notification data format field list) | | schema | Map<String, Object> | Yes (non-empty) | Field-semantics JSON Schema | -| templateUri | TemplateUri | Yes | Notification-T template | +| templateUri | String | Yes | Notification-T template | **Request Example** @@ -1133,7 +1132,7 @@ MetadataContent result = client.generateNotificationPromptFromDataWithSchema( On success, returns `MetadataContent` (structure same as [1.3.10](#1310-generatetaskpromptfromtext); `extensionUri` is `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1`). -On failure, throws `PromptGenerationException` (structure same as 1.3.10). Programming errors: a null argument throws `NullPointerException`; an empty schema map throws `IllegalArgumentException`. +On failure, throws `PromptGenerationException` (structure same as 1.3.10). Programming errors: a null argument throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or an empty schema map throws `IllegalArgumentException`. **Response Example** (same template as 1.3.13; slot values come from the structured input: `Subscribe Condition` is filled with `condition`, and `Notification Data Format` is rendered per the `reportFormat` list — field layout is illustrative) @@ -1173,7 +1172,7 @@ Subnetwork name: xx subnetwork (optional) ```java public FilledParamData validateNotificationPromptAndDataFilling( - String prompt, Map schema, TemplateUri templateUri) + String prompt, Map schema, String templateUri) ``` **Typical scenarios**: the server agent validates a received Notification-T subscription message, extracts the subscription parameters (topic/condition/report format), and establishes the subscription accordingly. @@ -1186,7 +1185,7 @@ public FilledParamData validateNotificationPromptAndDataFilling( | ---- | ---- | ---- | ---- | | prompt | String | Yes (non-blank) | Notification subscription prompt message text to validate; the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema | -| templateUri | TemplateUri | Yes | Notification-T template (the prefix segment must be `Notification-T`) | +| templateUri | String | Yes | Notification-T template (the prefix segment must be `Notification-T`) | **Request Example** @@ -1224,7 +1223,7 @@ On failure, throws `ContentValidationException` (structure same as 1.3.12). Erro - `input.text_too_long` (prompt longer than `A2AT_INPUT_TEXT_MAX_CHARS`) -Programming errors: a null prompt / schema / templateUri throws `NullPointerException`; a blank prompt throws `IllegalArgumentException`. +Programming errors: a null prompt / schema / templateUri throws `NullPointerException`; a blank prompt or a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`. **Response Example** @@ -1242,7 +1241,7 @@ result.data() = **API Definition** ```java -public MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri) +public MetadataContent generateAuthPromptFromText(String text, String templateUri) ``` **Typical scenarios**: the client agent converts a natural-language authorization request (add/modify/delete/query network operation authorization policies) into an Authorization-T message. @@ -1254,7 +1253,7 @@ public MetadataContent generateAuthPromptFromText(String text, TemplateUri templ | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | | text | String | Yes | Natural-language authorization description (operation type + network operation authorization policy content); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | -| templateUri | TemplateUri | Yes | Authorization-T template: `StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT` (`Authorization-T/authorization-policy-management/v1`) | +| templateUri | String | Yes | Authorization-T template: `StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI` (`Authorization-T/authorization-policy-management/v1`) | **Request Example** @@ -1268,14 +1267,14 @@ A2ATClient client = new A2ATClient(Path.of("client.env")); MetadataContent result = client.generateAuthPromptFromText( "Add an authorization for the campus private network, use service recovery for handling, do a tunnel optimization, and leave the validity period to be filled in later", - StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI); ``` **Output** On success, returns `MetadataContent` (structure same as [1.3.10](#1310-generatetaskpromptfromtext); `extensionUri` is `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`). -On failure, throws `PromptGenerationException` (structure same as 1.3.10); for example, an operation type outside the "add/modify/delete/query authorization policy" range is rejected with `slot.constraint_violated`. Programming errors: a null text or templateUri throws `NullPointerException`. +On failure, throws `PromptGenerationException` (structure same as 1.3.10); for example, an operation type outside the "add/modify/delete/query authorization policy" range is rejected with `slot.constraint_violated`. Programming errors: a null text or templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`. **Response Example** @@ -1304,7 +1303,7 @@ campus private network, service recovery, tunnel optimization ```java public MetadataContent generateAuthPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) + Map data, Map schema, String templateUri) ``` **Typical scenarios**: the client agent converts structured authorization policy data submitted field by field by an authorization management UI or system into an Authorization-T message. @@ -1317,7 +1316,7 @@ public MetadataContent generateAuthPromptFromDataWithSchema( | ---- | ---- | ---- | ---- | | data | Map<String, Object> | Yes | Structured authorization input (operation type, policy count, policy detail list, etc.) | | schema | Map<String, Object> | Yes (non-empty) | Field-semantics JSON Schema | -| templateUri | TemplateUri | Yes | Authorization-T template | +| templateUri | String | Yes | Authorization-T template | **Request Example** @@ -1353,14 +1352,14 @@ Map schema = Map.of( "validityPeriod", Map.of("type", "string")))))); MetadataContent result = client.generateAuthPromptFromDataWithSchema( - data, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + data, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI); ``` **Output** On success, returns `MetadataContent` (structure same as [1.3.10](#1310-generatetaskpromptfromtext); `extensionUri` is `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`). -On failure, throws `PromptGenerationException` (structure same as 1.3.10). Programming errors: a null argument throws `NullPointerException`; an empty schema map throws `IllegalArgumentException`. +On failure, throws `PromptGenerationException` (structure same as 1.3.10). Programming errors: a null argument throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or an empty schema map throws `IllegalArgumentException`. **Response Example** @@ -1389,7 +1388,7 @@ campus private network, service recovery, tunnel optimization, permanently valid ```java public FilledParamData validateAuthPromptAndDataFilling( - String prompt, Map schema, TemplateUri templateUri) + String prompt, Map schema, String templateUri) ``` **Typical scenarios**: the server agent validates a received Authorization-T message, extracts the operation type and policy list, and performs the authorization policy management action accordingly. @@ -1402,7 +1401,7 @@ public FilledParamData validateAuthPromptAndDataFilling( | ---- | ---- | ---- | ---- | | prompt | String | Yes (non-blank) | Authorization prompt message text to validate; the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | | schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema (containing `operationType`, `policyList`, etc.) | -| templateUri | TemplateUri | Yes | Authorization-T template (the prefix segment must be `Authorization-T`) | +| templateUri | String | Yes | Authorization-T template (the prefix segment must be `Authorization-T`) | **Request Example** @@ -1417,7 +1416,7 @@ Map paramSchema = MAPPER.readValue( // (array whose entries carry policyId/businessScenario/handlingType/operationName/validityPeriod) FilledParamData result = server.validateAuthPromptAndDataFilling( - metadata.promptText(), paramSchema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + metadata.promptText(), paramSchema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI); ``` **Output** @@ -1434,7 +1433,7 @@ On failure, throws `ContentValidationException` (structure same as 1.3.12). Erro - `input.text_too_long` (prompt longer than `A2AT_INPUT_TEXT_MAX_CHARS`) -Programming errors: a null prompt / schema / templateUri throws `NullPointerException`; a blank prompt throws `IllegalArgumentException`. +Programming errors: a null prompt / schema / templateUri throws `NullPointerException`; a blank prompt or a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`. **Response Example** diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index cd7507b9..d6c3e9d1 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -1657,16 +1657,16 @@ For example, when the local file is `/templates/Task-T/netw ```java import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; - -// Construction: parse the raw URI string -TemplateUri templateUri = TemplateUri.parse("Task-T/network-layer/site-inspection/v1").orElseThrow(); +// The facade takes the raw URI string directly MetadataContent metadata = client.generateTaskPromptFromText( - "Please perform an on-site inspection of station xx, focusing on alarms and performance metrics", templateUri); + "Please perform an on-site inspection of station xx, focusing on alarms and performance metrics", + "Task-T/network-layer/site-inspection/v1"); ``` -To override a built-in template (e.g. `StandardTemplates.PRIVATE_LINE_COMPLAINT`), place `template.md` and `slot.json` at the same relative path under the local root directory and keep using the original constant in the code. Note that in `local_file` mode business templates are read only from the local root directory without classpath fallback; when the file for a `templateUri` is missing, the API throws `PromptGenerationException` with the error code `template.not_found`. +Facade failure policy for `templateUri`: a null template URI throws `NullPointerException`; a blank or malformed URI (fewer than three segments, or a segment that is not simple) throws `IllegalArgumentException` with the message `Unparseable template URI: `. + +To override a built-in template (e.g. `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI`), place `template.md` and `slot.json` at the same relative path under the local root directory and keep using the original constant in the code. Note that in `local_file` mode business templates are read only from the local root directory without classpath fallback; when the file for a `templateUri` is missing, the API throws `PromptGenerationException` with the error code `template.not_found`. #### Step4 Verification and troubleshooting diff --git "a/docs/zh/API\345\217\202\350\200\203.md" "b/docs/zh/API\345\217\202\350\200\203.md" index 6299f6bd..0daf3d64 100644 --- "a/docs/zh/API\345\217\202\350\200\203.md" +++ "b/docs/zh/API\345\217\202\350\200\203.md" @@ -44,22 +44,24 @@ A2A-T SDK 对外仅提供两个入口:客户端入口 `A2ATClient`(提示词 | message | String | 人类可读的错误说明,由 SDK 按错误码的消息模板渲染,语言跟随 `A2AT_LANGUAGE` | | facts | Map<String, String> | 渲染消息所依据的结构化事实值(如 `section_label`、`index`、`field_label`),可为 null | -- **TemplateUri:** 模板 URI 值类型,推荐用 `net.openan.a2at.sdk.core.model.StandardTemplates` 常量构造;来自外部的字符串用 `TemplateUri.parse(String)` 解析,返回 `Optional` 且不抛异常,当前支持的TemplateUri常量如下: +- **模板 URI:** `A2ATClient` 与 `A2ATServer` 门面上所有指定模板的方法均以 `String templateUri`(如 `Task-T/network-layer/private-line-complaint/v1`)声明模板选择,推荐直接传 `net.openan.a2at.sdk.core.model.StandardTemplates` 的 String 常量(常量名以 `_URI` 结尾,每个都有去掉后缀的同名 `TemplateUri` 类型化孪生常量);来自外部的字符串直接传入即可。类型 `TemplateUri` 仍用于底层服务层(如 `NegotiationContentService`),可用 `TemplateUri.parse(String)` 解析,返回 `Optional` 且不抛异常。当前支持的模板 URI 常量(String 形态)如下: | 常量名称 | 含义 | TemplateUri | | ---- | ---- | ---- | -| StandardTemplates.ENERGY_SAVING | Task-T 节能任务模板 | Task-T/network-layer/ran-energy-saving/v1 | -| StandardTemplates.PRIVATE_LINE_COMPLAINT | Task-T 专线投诉任务模板 | Task-T/network-layer/private-line-complaint/v1 | -| StandardTemplates.SUBSCRIBE_INCIDENT | Notification-T 事件订阅通知模板 | Notification-T/network-layer/subscribe-incident/v1 | -| StandardTemplates.SERVICE_RECOVERY | Notification-T 业务抢通通知模板 | Notification-T/network-layer/service-recovery/v1 | -| StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT | Authorization-T 授权策略管理模板 | Authorization-T/authorization-policy-management/v1 | -| StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE | Negotiation-T 信息协商意图发起模板 | Negotiation-T/information-negotiation/propose/v1 | -| StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT | Negotiation-T 信息协商接受/拒绝模板 | Negotiation-T/information-negotiation/accept-reject/v1 | -| StandardTemplates.TARGET_NEGOTIATION_PROPOSE | Negotiation-T 目标协商意图发起模板 | Negotiation-T/target-negotiation/propose/v1 | -| StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT | Negotiation-T 目标协商接受/拒绝模板 | Negotiation-T/target-negotiation/accept-reject/v1 | -| StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE | Negotiation-T 可行性协商意图发起模板 | Negotiation-T/feasibility-negotiation/propose/v1 | -| StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT | Negotiation-T 可行性协商接受/拒绝模板 | Negotiation-T/feasibility-negotiation/accept-reject/v1 | -| StandardTemplates.NEGOTIATION_ABORT | Negotiation-T 协商中止通用模板 | Negotiation-T/common/abort/v1 | +| StandardTemplates.ENERGY_SAVING_URI | Task-T 节能任务模板 | Task-T/network-layer/ran-energy-saving/v1 | +| StandardTemplates.PRIVATE_LINE_COMPLAINT_URI | Task-T 专线投诉任务模板 | Task-T/network-layer/private-line-complaint/v1 | +| StandardTemplates.SUBSCRIBE_INCIDENT_URI | Notification-T 事件订阅通知模板 | Notification-T/network-layer/subscribe-incident/v1 | +| StandardTemplates.SERVICE_RECOVERY_URI | Notification-T 业务抢通通知模板 | Notification-T/network-layer/service-recovery/v1 | +| StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI | Authorization-T 授权策略管理模板 | Authorization-T/authorization-policy-management/v1 | +| StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI | Negotiation-T 信息协商意图发起模板 | Negotiation-T/information-negotiation/propose/v1 | +| StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T 信息协商接受/拒绝模板 | Negotiation-T/information-negotiation/accept-reject/v1 | +| StandardTemplates.TARGET_NEGOTIATION_PROPOSE_URI | Negotiation-T 目标协商意图发起模板 | Negotiation-T/target-negotiation/propose/v1 | +| StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T 目标协商接受/拒绝模板 | Negotiation-T/target-negotiation/accept-reject/v1 | +| StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE_URI | Negotiation-T 可行性协商意图发起模板 | Negotiation-T/feasibility-negotiation/propose/v1 | +| StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T 可行性协商接受/拒绝模板 | Negotiation-T/feasibility-negotiation/accept-reject/v1 | +| StandardTemplates.NEGOTIATION_ABORT_URI | Negotiation-T 协商中止通用模板 | Negotiation-T/common/abort/v1 | + +- **门面模板 URI 失败策略(`A2ATClient` 与 `A2ATServer` 均适用):** 门面上所有带 `templateUri` 参数的方法(含 `getPrompt`)均接收原始 URI 字符串。templateUri 为 null 抛 `NullPointerException`;为空白或非法 URI(少于三段,或某一段不是简单段)抛 `IllegalArgumentException`,消息为 `Unparseable template URI: <输入>`。此前服务端任务/通知/授权校验三件套传入 null 类型化模板 URI 时抛 `ContentValidationException`(错误码 `negotiation.invalid_input`);String 化后这类编程错误统一为标准 JDK 异常,不在 `A2ATError` 树内。 ## 1.2 约束和限制 @@ -75,7 +77,7 @@ A2A-T SDK 对外仅提供两个入口:客户端入口 `A2ATClient`(提示词 ```java public MetadataContent generateNegotiationProposePromptFromText( - String text, NegotiationContext context, TemplateUri templateUri) + String text, NegotiationContext context, String templateUri) ``` **典型场景**:服务端Agent收到参数不全的Task-T任务报文后,用自然语言向客户端Agent发起"补充缺失信息"的协商请求;也适用于客户端Agent向服务端发起目标澄清或可行性评估请求。 @@ -88,7 +90,7 @@ public MetadataContent generateNegotiationProposePromptFromText( | ---- | ---- | ---- | ---- | | text | String | 是 | 描述协商发起内容的自然语言文本(如请求补充的缺失信息清单);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | context | NegotiationContext | 是 | 协商会话上下文,不经 LLM 直接注入生成消息的 `negotiationContext` metadata | -| templateUri | TemplateUri | 是 | propose 模板,如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE` | +| templateUri | String | 是 | propose 模板,如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI` | **请求样例** @@ -108,7 +110,7 @@ NegotiationContext ctx = new NegotiationContext( MetadataContent propose = client.generateNegotiationProposePromptFromText( "请提供以下缺失信息:投诉分类:专线中断或专线质差。两个参数均为必选,缺少无法启动诊断。", ctx, - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI); // 生成的metadata随A2A消息传输 Map metadata = propose.buildMetadataContent(); @@ -148,7 +150,7 @@ Map metadata = propose.buildMetadataContent(); - `negotiation.field_missing`(缺少必填字段) -入参为 null 抛 `NullPointerException`,templateUri 的 phase 段不是 `propose` 抛 `IllegalArgumentException`。 +入参为 null 抛 `NullPointerException`,templateUri 为空白或非法(见 1.1 门面失败策略)、或 phase 段不是 `propose` 抛 `IllegalArgumentException`。 **响应样例** @@ -169,7 +171,7 @@ promptText : ```java public MetadataContent generateNegotiationAcceptPromptFromText( - String text, NegotiationContext context, TemplateUri templateUri) + String text, NegotiationContext context, String templateUri) ``` **典型场景**:协商响应方(通常是客户端Agent)收到对端的信息协商请求后,用自然语言补充/交付所请求的信息并生成接受报文回传,如补齐接入端口名称与投诉分类后确认启动诊断。 @@ -182,7 +184,7 @@ public MetadataContent generateNegotiationAcceptPromptFromText( | ---- | ---- | ---- | ---- | | text | String | 是 | 描述接受内容的自然语言文本(如补充交付的信息清单);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | context | NegotiationContext | 是 | 协商会话上下文 | -| templateUri | TemplateUri | 是 | accept-reject 模板,如 `StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT` | +| templateUri | String | 是 | accept-reject 模板,如 `StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI` | **请求样例** @@ -191,7 +193,7 @@ MetadataContent accept = client.generateNegotiationAcceptPromptFromText( "同意补充以下信息:1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1;" + "2. 投诉分类:专线质差。信息已完整,可以启动诊断。", ctx, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); ``` **输出说明** @@ -232,7 +234,7 @@ Accept ```java public MetadataContent generateNegotiationRejectPromptFromText( - String text, NegotiationContext context, TemplateUri templateUri) + String text, NegotiationContext context, String templateUri) ``` **典型场景**:协商响应方(通常是客户端Agent)无法满足对端的协商请求时,用自然语言生成拒绝报文回传并结束本轮协商,如因站点清单不可用无法提供接入端口名称。 @@ -247,7 +249,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( MetadataContent reject = client.generateNegotiationRejectPromptFromText( "拒绝补充信息:接入端口名称因站点清单不可用而无法提供,本次协商结束。", ctx, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); ``` **输出说明** @@ -287,7 +289,7 @@ Reject ```java public MetadataContent generateNegotiationProposePromptFromData( - NegotiationProposeData data, TemplateUri templateUri) + NegotiationProposeData data, String templateUri) ``` **典型场景**:与 fromText 相同的发起场景,但输入为业务系统构造的结构化数据(如服务端Agent根据 `validateTaskPromptAndDataFilling` 检出的缺失槽位清单自动生成协商请求条目),适合对报文内容有确定性要求、不希望引入 LLM 抽取不确定性的场景。 @@ -299,7 +301,7 @@ public MetadataContent generateNegotiationProposePromptFromData( | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | | data | `NegotiationProposeData(context, content)` | 是 | 协商上下文 + 类型化发起内容;内容类型按协商类型选择 | -| templateUri | TemplateUri | 是 | propose 模板 | +| templateUri | String | 是 | propose 模板 | 三种协商类型的发起内容: @@ -331,7 +333,7 @@ MetadataContent propose = client.generateNegotiationProposePromptFromData( new NegotiationItem("投诉分类", "举例:专线质差"), new NegotiationItem("专线业务标识", null)), "OR")), - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI); ``` **输出说明** @@ -344,9 +346,9 @@ MetadataContent propose = client.generateNegotiationProposePromptFromData( - `negotiation.content_invalid`(类型化内容字段无效,如必填 items 为空、必填描述为空白) -- `negotiation.field_missing`(渲染缺少必填字段) +- `template.render_failed`(模板渲染失败) -另有两类编程错误(`A2ATError` 树外,标准 JDK 异常):入参或其 context 为 null 抛 `NullPointerException`;内容类型与模板协商类型不符、phase 段不是 `propose` 抛 `IllegalArgumentException`。 +另有两类编程错误(`A2ATError` 树外,标准 JDK 异常):入参或其 context 为 null 抛 `NullPointerException`;templateUri 为空白或非法(见 1.1 门面失败策略)、内容类型与模板协商类型不符、phase 段不是 `propose` 抛 `IllegalArgumentException`。 **响应样例** @@ -370,7 +372,7 @@ promptText : ```java public MetadataContent generateNegotiationAcceptPromptFromData( - NegotiationEndingData data, TemplateUri templateUri) + NegotiationEndingData data, String templateUri) ``` **典型场景**:协商响应方(通常是客户端Agent)按对端请求的槽位清单程序化补参后,以结构化条目生成接受报文回传。 @@ -382,7 +384,7 @@ public MetadataContent generateNegotiationAcceptPromptFromData( | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | | data | `NegotiationEndingData(context, content)` | 是 | 协商上下文 + 类型化接受内容(结论为 `ACCEPT`) | -| templateUri | TemplateUri | 是 | accept-reject 模板 | +| templateUri | String | 是 | accept-reject 模板 | 三种协商类型的接受内容:`InformationEndingContent(ACCEPT, items)`(交付的信息项清单)、`TargetEndingContent(ACCEPT, confirmedIntent, null)`(最终确认的意图)、`FeasibilityEndingContent(ACCEPT, feasibilitySummary)`(可行性评估结论摘要)。 @@ -401,7 +403,7 @@ MetadataContent accept = client.generateNegotiationAcceptPromptFromData( List.of( new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1"), new NegotiationItem("投诉分类", "专线质差")))), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); ``` **输出说明** @@ -414,9 +416,9 @@ MetadataContent accept = client.generateNegotiationAcceptPromptFromData( - `negotiation.content_invalid`(类型化内容字段无效,如必填 items 为空、必填描述为空白) -- `negotiation.field_missing`(渲染缺少必填字段) +- `template.render_failed`(模板渲染失败) -编程错误:入参或其 context 为 null 抛 `NullPointerException`;内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。`conclusion` 不是 `ACCEPT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 +编程错误:入参或其 context 为 null 抛 `NullPointerException`;templateUri 为空白或非法(见 1.1 门面失败策略)、内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;`conclusion` 不是 `ACCEPT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 **响应样例** @@ -438,7 +440,7 @@ Accept ```java public MetadataContent generateNegotiationRejectPromptFromData( - NegotiationEndingData data, TemplateUri templateUri) + NegotiationEndingData data, String templateUri) ``` **典型场景**:协商响应方程序化判定无法满足对端请求后,以结构化条目(无法提供的项及原因)生成拒绝报文回传。 @@ -456,7 +458,7 @@ MetadataContent reject = client.generateNegotiationRejectPromptFromData( new InformationEndingContent( NegotiationConclusion.REJECT, List.of(new NegotiationItem("接入端口名称", "无法提供,工作台侧端口资源台账暂不可查")))), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); ``` **输出说明** @@ -469,9 +471,9 @@ MetadataContent reject = client.generateNegotiationRejectPromptFromData( - `negotiation.content_invalid`(类型化内容字段无效,如必填 items 为空、必填描述为空白) -- `negotiation.field_missing`(渲染缺少必填字段) +- `template.render_failed`(模板渲染失败) -编程错误:入参或其 context 为 null 抛 `NullPointerException`;内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。`conclusion` 不是 `REJECT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 +编程错误:入参或其 context 为 null 抛 `NullPointerException`;templateUri 为空白或非法(见 1.1 门面失败策略)、内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;`conclusion` 不是 `REJECT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 **响应样例** @@ -492,7 +494,7 @@ Reject ```java public FilledParamData validateProposePromptAndDataFilling( - String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) + String prompt, NegotiationContext context, Map schema, String templateUri) ``` **典型场景**:发起方(服务端Agent)发送协商请求前的出站自检;或接收方(客户端Agent)校验收到的协商请求并提取需补充的槽位清单,驱动后续补参。 @@ -506,7 +508,7 @@ public FilledParamData validateProposePromptAndDataFilling( | prompt | String | 是 | 待校验的协商发起报文文本(`MetadataContent.promptText()`);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | context | NegotiationContext | 是 | 随报文传输的协商上下文;为 null 时报 `negotiation.invalid_input` | | schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema,声明要提取的参数 | -| templateUri | TemplateUri | 是 | propose 模板 | +| templateUri | String | 是 | propose 模板 | **请求样例** @@ -529,7 +531,7 @@ Map schema = Map.of( // proposePrompt 为对端发来的协商发起报文文本 FilledParamData requested = client.validateProposePromptAndDataFilling( - proposePrompt, ctx, schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); + proposePrompt, ctx, schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI); // 过滤掉上下文参数(id/round/maxRounds)后即为需补充的槽位清单 requested.data().keySet().removeAll(List.of("id", "round", "maxRounds")); @@ -555,9 +557,7 @@ requested.data().keySet().removeAll(List.of("id", "round", "maxRounds")); - `negotiation.invalid_input`(报文不是协商消息或 context 为 null) -- `negotiation.invalid_context_id`(context 的 id 不是合法 UUID) - -- `negotiation.round_exceeded`(context 轮次超过配置上限) +- `negotiation.rule_violation`(协商上下文违反规则;`getErrors()` 中的嵌套槽位错误码标识具体规则,如 `negotiation.invalid_context_id`、`negotiation.round_exceeded`) - `negotiation.semantic_rejected`(语义校验拒绝;`getErrors()` 中的逐槽位明细使用封闭的 `negotiation.*` 码集,如 `negotiation.conclusion_content_mismatch`、`negotiation.missing_result_content`、`negotiation.field_inconsistency`) @@ -565,7 +565,7 @@ requested.data().keySet().removeAll(List.of("id", "round", "maxRounds")); - `template.not_found`(校验提示词资源缺失) -编程错误:prompt 或 schema 为 null 抛 `NullPointerException`,prompt 为空白或 phase 段不匹配抛 `IllegalArgumentException`。 +编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白、templateUri 为空白或非法(见 1.1 门面失败策略)、phase 段不匹配抛 `IllegalArgumentException`。 **响应样例** @@ -586,7 +586,7 @@ requested.data() = ```java public FilledParamData validateAcceptPromptAndDataFilling( - String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) + String prompt, NegotiationContext context, Map schema, String templateUri) ``` **典型场景**:发起方(服务端Agent)校验对端回传的接受报文,提取交付的参数值并与期望补齐值核对,确认无误后继续任务执行。 @@ -604,7 +604,7 @@ A2ATServer server = new A2ATServer(Path.of("server.env")); // acceptPrompt 为客户端回传的接受报文文本,acceptContext 为其协商上下文 FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( - acceptPrompt, acceptContext, schema, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + acceptPrompt, acceptContext, schema, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); ``` **输出说明** @@ -615,7 +615,7 @@ FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( - `negotiation.invalid_input`(报文不是 accept 协商消息或 context 为 null) -- `negotiation.invalid_context_id` / `negotiation.round_exceeded`(协商上下文违反结构规则) +- `negotiation.rule_violation`(协商上下文违反规则;`getErrors()` 中的嵌套槽位错误码标识具体规则,如 `negotiation.invalid_context_id`、`negotiation.round_exceeded`) - `negotiation.semantic_rejected`(结论不是 Accept 或内容不满足 accept 阶段约束) @@ -623,7 +623,7 @@ FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( - `template.not_found`(校验提示词资源缺失) -编程错误:prompt 或 schema 为 null 抛 `NullPointerException`,prompt 为空白或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。 +编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白、templateUri 为空白或非法(见 1.1 门面失败策略)、phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。 **响应样例** @@ -644,7 +644,7 @@ acceptParams.data() = ```java public FilledParamData validateRejectPromptAndDataFilling( - String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) + String prompt, NegotiationContext context, Map schema, String templateUri) ``` **典型场景**:发起方(服务端Agent)校验对端回传的拒绝报文,提取拒绝原因,据此终止任务或转入人工处理。 @@ -657,7 +657,7 @@ public FilledParamData validateRejectPromptAndDataFilling( ```java FilledParamData rejectParams = server.validateRejectPromptAndDataFilling( - rejectPrompt, ctx, schema, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + rejectPrompt, ctx, schema, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); ``` **输出说明** @@ -668,7 +668,7 @@ FilledParamData rejectParams = server.validateRejectPromptAndDataFilling( - `negotiation.invalid_input`(报文不是 reject 协商消息或 context 为 null) -- `negotiation.invalid_context_id` / `negotiation.round_exceeded`(协商上下文违反结构规则) +- `negotiation.rule_violation`(协商上下文违反规则;`getErrors()` 中的嵌套槽位错误码标识具体规则,如 `negotiation.invalid_context_id`、`negotiation.round_exceeded`) - `negotiation.semantic_rejected`(结论不是 Reject 或内容不满足 reject 阶段约束) @@ -676,7 +676,7 @@ FilledParamData rejectParams = server.validateRejectPromptAndDataFilling( - `template.not_found`(校验提示词资源缺失) -编程错误:prompt 或 schema 为 null 抛 `NullPointerException`,prompt 为空白或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。 +编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白、templateUri 为空白或非法(见 1.1 门面失败策略)、phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。 **响应样例** @@ -695,7 +695,7 @@ rejectParams.data() = **API定义** ```java -public MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) +public MetadataContent generateTaskPromptFromText(String text, String templateUri) ``` **典型场景**:客户端Agent将用户的自然语言任务描述(如专线投诉诊断诉求)转换为指定场景的Task-T协议报文,适合已确定目标模板、需跳过场景识别的场景。 @@ -707,7 +707,7 @@ public MetadataContent generateTaskPromptFromText(String text, TemplateUri templ | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | | text | String | 是 | 自然语言任务描述;输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | -| templateUri | TemplateUri | 是 | Task-T 模板,如 `StandardTemplates.PRIVATE_LINE_COMPLAINT`(`Task-T/network-layer/private-line-complaint/v1`) | +| templateUri | String | 是 | Task-T 模板,如 `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI`(`Task-T/network-layer/private-line-complaint/v1`) | **请求样例** @@ -723,7 +723,7 @@ MetadataContent metadata = client.generateTaskPromptFromText( "帮我创建专线投诉诊断任务,P781-珠江新城-PTN7900-23-TPA1EG24-17 这个端口," + "客户报的是专线质差,从2026年5月11号早上8点半开始,深圳访问广州的核心系统非常慢," + "时延从12ms飙到320ms,柜面和手机银行老是报连接超时,OSS流水号是event-id-20260511-09013。", - StandardTemplates.PRIVATE_LINE_COMPLAINT); + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); ``` **输出说明** @@ -766,7 +766,7 @@ MetadataContent metadata = client.generateTaskPromptFromText( - `input.text_too_long`(输入超过 `A2AT_INPUT_TEXT_MAX_CHARS`) -编程错误:text 或 templateUri 为 null 抛 `NullPointerException`。 +编程错误:text 或 templateUri 为 null 抛 `NullPointerException`;templateUri 为空白或非法(见 1.1 门面失败策略)抛 `IllegalArgumentException`。 **响应样例** @@ -806,7 +806,7 @@ promptText : ```java public MetadataContent generateTaskPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) + Map data, Map schema, String templateUri) ``` **典型场景**:客户端Agent将上游系统的结构化任务参数(字段名可与模板槽位不同,由Schema描述字段语义)转换为Task-T协议报文,适合任务参数已结构化持有的场景。 @@ -819,7 +819,7 @@ public MetadataContent generateTaskPromptFromDataWithSchema( | ---- | ---- | ---- | ---- | | data | Map<String, Object> | 是 | 结构化业务字段输入,key 为业务字段名,value 为字段值 | | schema | Map<String, Object> | 是(非空) | 字段语义 JSON Schema,描述各字段含义与约束 | -| templateUri | TemplateUri | 是 | Task-T 模板 | +| templateUri | String | 是 | Task-T 模板 | **请求样例** @@ -849,14 +849,14 @@ Map semanticsSchema = Map.of( "required", List.of("portName", "complaintScenario")); MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( - data, semanticsSchema, StandardTemplates.PRIVATE_LINE_COMPLAINT); + data, semanticsSchema, StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); ``` **输出说明** 成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext))。 -失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,schema 为空 Map 抛 `IllegalArgumentException`。 +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,templateUri 为空白或非法(见 1.1 门面失败策略)或 schema 为空 Map 抛 `IllegalArgumentException`。 **响应样例**(与 1.3.10 同模板,槽位值来自结构化输入,`faultDetail` 为示例截断值) @@ -896,7 +896,7 @@ promptText : ```java public FilledParamData validateTaskPromptAndDataFilling( - String prompt, Map schema, TemplateUri templateUri) + String prompt, Map schema, String templateUri) ``` **典型场景**:服务端Agent收到Task-T报文后、进入业务执行前的参数校验与提取入口;也是协商流程中"缺槽检测"的判定点——required参数缺失时由调用方决定是否发起协商补参。 @@ -909,7 +909,7 @@ public FilledParamData validateTaskPromptAndDataFilling( | ---- | ---- | ---- | ---- | | prompt | String | 是(非空) | 待校验的任务提示词报文文本(`MetadataContent.promptText()`);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema,声明要提取/校验的参数及 required 约束 | -| templateUri | TemplateUri | 是 | Task-T 模板(前缀段必须为 `Task-T`) | +| templateUri | String | 是 | Task-T 模板(前缀段必须为 `Task-T`) | **请求样例** @@ -936,7 +936,7 @@ Map validationSchema = Map.of( Map extracted = server .validateTaskPromptAndDataFilling( - metadata.promptText(), validationSchema, StandardTemplates.PRIVATE_LINE_COMPLAINT) + metadata.promptText(), validationSchema, StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) .data(); ``` @@ -967,7 +967,7 @@ Map extracted = server - `input.text_too_long`(提示词超过 `A2AT_INPUT_TEXT_MAX_CHARS`) -编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白抛 `IllegalArgumentException`。 +编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白或 templateUri 为空白/非法(见 1.1 门面失败策略)抛 `IllegalArgumentException`。 **响应样例** @@ -994,7 +994,7 @@ ContentValidationException: [negotiation.semantic_rejected] ... **API定义** ```java -public MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri) +public MetadataContent generateNotificationPromptFromText(String text, String templateUri) ``` **典型场景**:客户端Agent将自然语言订阅需求(如业务抢通事件订阅)转换为指定场景的Notification-T订阅报文。 @@ -1006,7 +1006,7 @@ public MetadataContent generateNotificationPromptFromText(String text, TemplateU | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | | text | String | 是 | 自然语言订阅描述(通知主题、订阅条件、上报通知数据格式等);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | -| templateUri | TemplateUri | 是 | Notification-T 模板,如 `StandardTemplates.SUBSCRIBE_INCIDENT`、`StandardTemplates.SERVICE_RECOVERY` | +| templateUri | String | 是 | Notification-T 模板,如 `StandardTemplates.SUBSCRIBE_INCIDENT_URI`、`StandardTemplates.SERVICE_RECOVERY_URI` | **请求样例** @@ -1015,10 +1015,9 @@ import java.nio.file.Path; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; A2ATClient client = new A2ATClient(Path.of("client.env")); -TemplateUri templateUri = TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); +String templateUri = StandardTemplates.SERVICE_RECOVERY_URI; MetadataContent result = client.generateNotificationPromptFromText( "我想订阅一下业务抢通事件。上报通知数据格式如下:" @@ -1032,7 +1031,7 @@ MetadataContent result = client.generateNotificationPromptFromText( 成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1`)。 -失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:text 或 templateUri 为 null 抛 `NullPointerException`。 +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:text 或 templateUri 为 null 抛 `NullPointerException`;templateUri 为空白或非法(见 1.1 门面失败策略)抛 `IllegalArgumentException`。 **响应样例**(按模板渲染,实际文本随 LLM 槽位提取结果变化;本例输入未指定订阅条件,对应槽位留空) @@ -1071,7 +1070,7 @@ promptText : ```java public MetadataContent generateNotificationPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) + Map data, Map schema, String templateUri) ``` **典型场景**:客户端Agent将上游系统/界面提交的结构化订阅参数转换为Notification-T订阅报文,适合订阅参数已结构化持有的场景。 @@ -1084,7 +1083,7 @@ public MetadataContent generateNotificationPromptFromDataWithSchema( | ---- | ---- | ---- | ---- | | data | Map<String, Object> | 是 | 结构化订阅输入(如订阅条件、上报数据格式字段列表) | | schema | Map<String, Object> | 是(非空) | 字段语义 JSON Schema | -| templateUri | TemplateUri | 是 | Notification-T 模板 | +| templateUri | String | 是 | Notification-T 模板 | **请求样例** @@ -1125,7 +1124,7 @@ MetadataContent result = client.generateNotificationPromptFromDataWithSchema( 成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1`)。 -失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,schema 为空 Map 抛 `IllegalArgumentException`。 +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,templateUri 为空白或非法(见 1.1 门面失败策略)或 schema 为空 Map 抛 `IllegalArgumentException`。 **响应样例**(与 1.3.13 同模板,槽位值来自结构化输入:`订阅条件` 填充 `condition`,`上报通知数据格式` 按 `reportFormat` 列表渲染,字段排列样式示意) @@ -1165,7 +1164,7 @@ promptText : ```java public FilledParamData validateNotificationPromptAndDataFilling( - String prompt, Map schema, TemplateUri templateUri) + String prompt, Map schema, String templateUri) ``` **典型场景**:服务端Agent收到Notification-T订阅报文后校验合规性并提取订阅参数(主题/条件/上报格式),据此建立订阅关系。 @@ -1178,7 +1177,7 @@ public FilledParamData validateNotificationPromptAndDataFilling( | ---- | ---- | ---- | ---- | | prompt | String | 是(非空) | 待校验的通知订阅提示词报文文本;输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema | -| templateUri | TemplateUri | 是 | Notification-T 模板(前缀段必须为 `Notification-T`) | +| templateUri | String | 是 | Notification-T 模板(前缀段必须为 `Notification-T`) | **请求样例** @@ -1216,7 +1215,7 @@ FilledParamData result = server.validateNotificationPromptAndDataFilling( - `input.text_too_long`(提示词超过 `A2AT_INPUT_TEXT_MAX_CHARS`) -编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白抛 `IllegalArgumentException`。 +编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白或 templateUri 为空白/非法(见 1.1 门面失败策略)抛 `IllegalArgumentException`。 **响应样例** @@ -1234,7 +1233,7 @@ result.data() = **API定义** ```java -public MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri) +public MetadataContent generateAuthPromptFromText(String text, String templateUri) ``` **典型场景**:客户端Agent将自然语言授权诉求(新增/修改/删除/查询动网操作授权策略)转换为Authorization-T报文。 @@ -1246,7 +1245,7 @@ public MetadataContent generateAuthPromptFromText(String text, TemplateUri templ | 参数 | 类型 | 必填 | 说明 | | ---- | ---- | ---- | ---- | | text | String | 是 | 自然语言授权描述(操作类型 + 动网操作的授权策略内容);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | -| templateUri | TemplateUri | 是 | Authorization-T 模板:`StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT`(`Authorization-T/authorization-policy-management/v1`) | +| templateUri | String | 是 | Authorization-T 模板:`StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI`(`Authorization-T/authorization-policy-management/v1`) | **请求样例** @@ -1260,14 +1259,14 @@ A2ATClient client = new A2ATClient(Path.of("client.env")); MetadataContent result = client.generateAuthPromptFromText( "加个校园专网的授权,处置用业务抢通,做个隧道调优,有效期先不填后面补", - StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI); ``` **输出说明** 成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`)。 -失败时抛 `PromptGenerationException`(结构同 1.3.10),如操作类型不在"新增/修改/删除/查询授权策略"范围内时以 `slot.constraint_violated` 拒绝。编程错误:text 或 templateUri 为 null 抛 `NullPointerException`。 +失败时抛 `PromptGenerationException`(结构同 1.3.10),如操作类型不在"新增/修改/删除/查询授权策略"范围内时以 `slot.constraint_violated` 拒绝。编程错误:text 或 templateUri 为 null 抛 `NullPointerException`;templateUri 为空白或非法(见 1.1 门面失败策略)抛 `IllegalArgumentException`。 **响应样例** @@ -1296,7 +1295,7 @@ promptText : ```java public MetadataContent generateAuthPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) + Map data, Map schema, String templateUri) ``` **典型场景**:客户端Agent将授权管理界面/系统按字段提交的结构化授权策略数据转换为Authorization-T报文。 @@ -1309,7 +1308,7 @@ public MetadataContent generateAuthPromptFromDataWithSchema( | ---- | ---- | ---- | ---- | | data | Map<String, Object> | 是 | 结构化授权输入(操作类型、策略数量、策略详情列表等) | | schema | Map<String, Object> | 是(非空) | 字段语义 JSON Schema | -| templateUri | TemplateUri | 是 | Authorization-T 模板 | +| templateUri | String | 是 | Authorization-T 模板 | **请求样例** @@ -1345,14 +1344,14 @@ Map schema = Map.of( "有效期", Map.of("type", "string")))))); MetadataContent result = client.generateAuthPromptFromDataWithSchema( - data, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + data, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI); ``` **输出说明** 成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`)。 -失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,schema 为空 Map 抛 `IllegalArgumentException`。 +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,templateUri 为空白或非法(见 1.1 门面失败策略)或 schema 为空 Map 抛 `IllegalArgumentException`。 **响应样例** @@ -1381,7 +1380,7 @@ promptText : ```java public FilledParamData validateAuthPromptAndDataFilling( - String prompt, Map schema, TemplateUri templateUri) + String prompt, Map schema, String templateUri) ``` **典型场景**:服务端Agent收到Authorization-T报文后校验合规性并提取操作类型与策略列表,据此执行授权策略管理动作。 @@ -1394,7 +1393,7 @@ public FilledParamData validateAuthPromptAndDataFilling( | ---- | ---- | ---- | ---- | | prompt | String | 是(非空) | 待校验的授权提示词报文文本;输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | | schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema(含 `操作类型`、`策略列表` 等) | -| templateUri | TemplateUri | 是 | Authorization-T 模板(前缀段必须为 `Authorization-T`) | +| templateUri | String | 是 | Authorization-T 模板(前缀段必须为 `Authorization-T`) | **请求样例** @@ -1409,7 +1408,7 @@ Map paramSchema = MAPPER.readValue( //(数组,条目含 策略标识/业务场景/处置类型/操作名称/有效期) FilledParamData result = server.validateAuthPromptAndDataFilling( - metadata.promptText(), paramSchema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + metadata.promptText(), paramSchema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI); ``` **输出说明** @@ -1426,7 +1425,7 @@ FilledParamData result = server.validateAuthPromptAndDataFilling( - `input.text_too_long`(提示词超过 `A2AT_INPUT_TEXT_MAX_CHARS`) -编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白抛 `IllegalArgumentException`。 +编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白或 templateUri 为空白/非法(见 1.1 门面失败策略)抛 `IllegalArgumentException`。 **响应样例** diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index b80c025d..56ce9bab 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -1652,15 +1652,14 @@ templateUri:// ```java import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; - -// 构造方式:解析原始 URI 字符串 -TemplateUri templateUri = TemplateUri.parse("Task-T/network-layer/site-inspection/v1").orElseThrow(); -MetadataContent metadata = client.generateTaskPromptFromText("请对xx基站开展现场巡检,重点检查告警和性能指标", templateUri); +// 门面直接接收原始 URI 字符串 +MetadataContent metadata = client.generateTaskPromptFromText("请对xx基站开展现场巡检,重点检查告警和性能指标", "Task-T/network-layer/site-inspection/v1"); ``` -如需覆盖内置模板(如 `StandardTemplates.PRIVATE_LINE_COMPLAINT`),在本地根目录下按相同相对路径放置 `template.md` 与 `slot.json`,代码中继续使用原有常量即可。注意 `local_file` 模式下业务模板只从本地根目录读取、不回退 classpath,`templateUri` 对应的文件缺失时接口抛出 `PromptGenerationException`,错误码为 `template.not_found`。 +门面对 `templateUri` 的失败策略:为 null 抛 `NullPointerException`;为空白或非法 URI(少于三段,或某一段不是简单段)抛 `IllegalArgumentException`,消息为 `Unparseable template URI: <输入>`。 + +如需覆盖内置模板(如 `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI`),在本地根目录下按相同相对路径放置 `template.md` 与 `slot.json`,代码中继续使用原有常量即可。注意 `local_file` 模式下业务模板只从本地根目录读取、不回退 classpath,`templateUri` 对应的文件缺失时接口抛出 `PromptGenerationException`,错误码为 `template.not_found`。 #### Step4 验证与排障 From b386bd839199d0c4361b855fd7193c34571417ba Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Sun, 30 Aug 2026 14:19:21 +0800 Subject: [PATCH 255/295] fix(doc): optimize the content of the document Signed-off-by: zhangwenjun-huawei --- docs/en/API_Reference.md | 63 ++++++--- docs/en/developer_guide.md | 43 ++---- "docs/zh/API\345\217\202\350\200\203.md" | 125 ++++++++++-------- ...00\345\217\221\346\214\207\345\215\227.md" | 47 +++---- 4 files changed, 141 insertions(+), 137 deletions(-) diff --git a/docs/en/API_Reference.md b/docs/en/API_Reference.md index 8efaf9f9..eaa7191b 100644 --- a/docs/en/API_Reference.md +++ b/docs/en/API_Reference.md @@ -61,7 +61,7 @@ The A2A-T SDK exposes exactly two public entry points: the client entry `A2ATCli | StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T feasibility negotiation accept/reject template | Negotiation-T/feasibility-negotiation/accept-reject/v1 | | StandardTemplates.NEGOTIATION_ABORT_URI | Negotiation-T common negotiation abort template | Negotiation-T/common/abort/v1 | -- **Facade template URI failure policy (both `A2ATClient` and `A2ATServer`):** every facade method that takes a `templateUri` parameter (including `getPrompt`) accepts a raw URI string. A null template URI throws `NullPointerException`; a blank or malformed URI (fewer than three segments, or a segment that is not simple) throws `IllegalArgumentException` with the message `Unparseable template URI: `. This replaces the previous behavior where a null typed template URI on the server task/notification/authorization validate trio threw `ContentValidationException` with the code `negotiation.invalid_input` — such programming errors are now plain JDK exceptions, not part of the `A2ATError` tree. +- **Facade template URI failure policy:** every facade method that takes a `templateUri` parameter accepts a raw URI string. A null template URI throws `NullPointerException`; a blank or malformed URI (fewer than three segments, or a segment that is not simple) throws `IllegalArgumentException`. @@ -152,7 +152,7 @@ Error codes: - `negotiation.field_missing` (a required field is missing) -A null argument throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or a templateUri whose phase segment is not `propose` throws `IllegalArgumentException`. +A null context or templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or a templateUri whose phase segment is not `propose` throws `IllegalArgumentException`; a null or blank text is not a programming error and throws `NegotiationGenerationException` with `negotiation.invalid_input` (see the error codes above). **Response Example** @@ -178,7 +178,7 @@ public MetadataContent generateNegotiationAcceptPromptFromText( **Typical scenarios**: after receiving the peer's information-negotiation request, the negotiation responder (usually the client agent) supplements/delivers the requested information in natural language and generates an accept message to return, e.g. confirming that diagnosis can start after supplementing the access port name and complaint category. -**Function Description**: generates a negotiation accept message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `ACCEPT`, otherwise it is rejected with `negotiation.invalid_input`) plus deterministic rendering. Applicable to the negotiation responder supplementing/delivering information. +**Function Description**: generates a negotiation accept message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `ACCEPT`, otherwise it is rejected with `negotiation.conclusion_mismatch`) plus deterministic rendering. Applicable to the negotiation responder supplementing/delivering information. **Input** @@ -213,7 +213,9 @@ On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). E - `llm.response_invalid` (LLM response violates the step contract, retryable) -- `negotiation.invalid_input` (text is blank, or the extracted conclusion is not `ACCEPT`) +- `negotiation.invalid_input` (text is blank) + +- `negotiation.conclusion_mismatch` (the extracted conclusion is not `ACCEPT`) - `negotiation.field_missing` (a required field is missing) @@ -242,7 +244,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( **Typical scenarios**: when the negotiation responder (usually the client agent) cannot satisfy the peer's negotiation request, it generates a reject message in natural language to return and end the current negotiation round, e.g. the access port name cannot be provided because the site inventory is unavailable. -**Function Description**: generates a negotiation reject message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `REJECT`, otherwise it is rejected with `negotiation.invalid_input`) plus deterministic rendering. +**Function Description**: generates a negotiation reject message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `REJECT`, otherwise it is rejected with `negotiation.conclusion_mismatch`) plus deterministic rendering. **Input**: same as [generateNegotiationAcceptPromptFromText](#132-generatenegotiationacceptpromptfromtext), where text is natural language describing the rejection reason. @@ -269,7 +271,9 @@ On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). E - `llm.response_invalid` (LLM response violates the step contract, retryable) -- `negotiation.invalid_input` (text is blank, or the extracted conclusion is not `REJECT`) +- `negotiation.invalid_input` (text is blank) + +- `negotiation.conclusion_mismatch` (the extracted conclusion is not `REJECT`) - `negotiation.field_missing` (a required field is missing) @@ -559,7 +563,7 @@ On failure, throws `NegotiationParamExtractionException` (an `A2ATError` subclas Error codes: -- `negotiation.invalid_input` (the message is not a negotiation message, or the context is null) +- `negotiation.invalid_input` (the prompt is null or blank, the message is not a negotiation message, or the context is null) - `negotiation.rule_violation` (the negotiation context violates a rule; the nested slot error codes in `getErrors()` identify the concrete rule, e.g. `negotiation.invalid_context_id`, `negotiation.round_exceeded`) @@ -569,7 +573,7 @@ Error codes: - `template.not_found` (validation prompt resources missing) -Programming errors: a null prompt, schema or templateUri throws `NullPointerException`; a blank prompt, a blank or malformed templateUri (see the facade failure policy in 1.1), or a mismatched phase segment throws `IllegalArgumentException`. +Programming errors: a null schema or templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or a mismatched phase segment throws `IllegalArgumentException`; a null or blank prompt is not a programming error and throws `NegotiationParamExtractionException` with `negotiation.invalid_input` (see the error codes above). **Response Example** @@ -618,7 +622,7 @@ On success, returns `FilledParamData` (structure same as [1.3.7](#137-validatepr On failure, throws `NegotiationParamExtractionException` (structure same as 1.3.7). Error codes: -- `negotiation.invalid_input` (the message is not an accept negotiation message, or the context is null) +- `negotiation.invalid_input` (the prompt is null or blank, the message is not an accept negotiation message, or the context is null) - `negotiation.rule_violation` (the negotiation context violates a rule; the nested slot error codes in `getErrors()` identify the concrete rule, e.g. `negotiation.invalid_context_id`, `negotiation.round_exceeded`) @@ -628,7 +632,7 @@ On failure, throws `NegotiationParamExtractionException` (structure same as 1.3. - `template.not_found` (validation prompt resources missing) -Programming errors: a null prompt, schema or templateUri throws `NullPointerException`; a blank prompt, a blank or malformed templateUri (see the facade failure policy in 1.1), or a phase segment that is not `accept-reject` throws `IllegalArgumentException`. +Programming errors: a null schema or templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or a phase segment that is not `accept-reject` throws `IllegalArgumentException`; a null or blank prompt is not a programming error and throws `NegotiationParamExtractionException` with `negotiation.invalid_input` (see the error codes above). **Response Example** @@ -671,7 +675,7 @@ On success, returns `FilledParamData` (structure same as [1.3.7](#137-validatepr On failure, throws `NegotiationParamExtractionException` (structure same as 1.3.7). Error codes: -- `negotiation.invalid_input` (the message is not a reject negotiation message, or the context is null) +- `negotiation.invalid_input` (the prompt is null or blank, the message is not a reject negotiation message, or the context is null) - `negotiation.rule_violation` (the negotiation context violates a rule; the nested slot error codes in `getErrors()` identify the concrete rule, e.g. `negotiation.invalid_context_id`, `negotiation.round_exceeded`) @@ -681,7 +685,7 @@ On failure, throws `NegotiationParamExtractionException` (structure same as 1.3. - `template.not_found` (validation prompt resources missing) -Programming errors: a null prompt, schema or templateUri throws `NullPointerException`; a blank prompt, a blank or malformed templateUri (see the facade failure policy in 1.1), or a phase segment that is not `accept-reject` throws `IllegalArgumentException`. +Programming errors: a null schema or templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or a phase segment that is not `accept-reject` throws `IllegalArgumentException`; a null or blank prompt is not a programming error and throws `NegotiationParamExtractionException` with `negotiation.invalid_input` (see the error codes above). **Response Example** @@ -804,6 +808,12 @@ Requirement: The complaint diagnosis task result should include the following in 2. Diagnosis result details (required) 3. Repair suggestions (optional) 4. Fault root cause list, where each fault root cause includes fault root cause name, detailed description, repair suggestions, fault root cause point location, etc. (optional) + +## Terminology Explanation +1. Private line interruption + - Synonyms: private line interruption, service interruption, network unreachable, service down, service inaccessible +2. Poor private line quality + - Synonyms: private line poor quality, service stutter, service access timeout, service packet loss, service high latency, service jitter, service congestion, service experience degradation, service high error rate, service optical power abnormal ``` ### 1.3.11 generateTaskPromptFromDataWithSchema @@ -894,6 +904,12 @@ Requirement: The complaint diagnosis task result should include the following in 2. Diagnosis result details (required) 3. Repair suggestions (optional) 4. Fault root cause list, where each fault root cause includes fault root cause name, detailed description, repair suggestions, fault root cause point location, etc. (optional) + +## Terminology Explanation +1. Private line interruption + - Synonyms: private line interruption, service interruption, network unreachable, service down, service inaccessible +2. Poor private line quality + - Synonyms: private line poor quality, service stutter, service access timeout, service packet loss, service high latency, service jitter, service congestion, service experience degradation, service high error rate, service optical power abnormal ``` ### 1.3.12 validateTaskPromptAndDataFilling @@ -966,6 +982,8 @@ On failure, throws `ContentValidationException` (an `A2ATError` subclass): Error codes: +- `negotiation.invalid_input` (the prompt is null or blank, the schema is null, or the templateUri prefix segment/version does not match this API) + - `negotiation.semantic_rejected` (semantic validation rejected, including missing or invalid required parameters; the per-slot details in `errors()` use the `content.*` codes, e.g. `content.param_missing`, `content.entry_field_missing`, `content.format_error`, `content.value_not_allowed`) - `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) @@ -974,9 +992,9 @@ Error codes: - `input.text_too_long` (prompt longer than `A2AT_INPUT_TEXT_MAX_CHARS`) -Programming errors: a null prompt / schema / templateUri throws `NullPointerException`; a blank prompt or a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`. +Programming errors: a null templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`; a null or blank prompt or a null schema is not a programming error and throws `ContentValidationException` with `negotiation.invalid_input` (see the error codes above). -**Response Example** +**Response Example** (`faultTime` and `faultDetail` are truncated sample values) ```text extracted = @@ -993,7 +1011,7 @@ On validation rejection (a negative sample with a missing key slot, from case 3 ```text ContentValidationException: [negotiation.semantic_rejected] ... - slot=task_object code=content.param_missing message=... facts={section_label=任务对象} + slot=Task Object code=content.param_missing message=... facts={section_label=Task Object} ``` ### 1.3.13 generateNotificationPromptFromText @@ -1054,8 +1072,6 @@ Please complete the network-side service recovery event subscription and reporti Service recovery event ## Subscribe Condition -(optional) -1. Subnetwork name. Example: xx subnetwork ## Notification Data Format 1. Service recovery plan execution status. Allowed values: not started, ended @@ -1147,8 +1163,7 @@ Please complete the network-side service recovery event subscription and reporti Service recovery event ## Subscribe Condition -Subnetwork name: xx subnetwork (optional) -1. Subnetwork name. Example: xx subnetwork +Subnetwork name: xx subnetwork ## Notification Data Format 1. Service recovery plan execution status. Allowed values: not started, ended (required) @@ -1215,6 +1230,8 @@ On success, returns `FilledParamData` (structure same as [1.3.12](#1312-validate On failure, throws `ContentValidationException` (structure same as 1.3.12). Error codes: +- `negotiation.invalid_input` (the prompt is null or blank, the schema is null, or the templateUri prefix segment/version does not match this API) + - `negotiation.semantic_rejected` (required parameter missing or invalid value; the per-slot details in `errors()` use the `content.*` codes) - `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) @@ -1223,7 +1240,7 @@ On failure, throws `ContentValidationException` (structure same as 1.3.12). Erro - `input.text_too_long` (prompt longer than `A2AT_INPUT_TEXT_MAX_CHARS`) -Programming errors: a null prompt / schema / templateUri throws `NullPointerException`; a blank prompt or a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`. +Programming errors: a null templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`; a null or blank prompt or a null schema is not a programming error and throws `ContentValidationException` with `negotiation.invalid_input` (see the error codes above). **Response Example** @@ -1425,6 +1442,8 @@ On success, returns `FilledParamData` (structure same as [1.3.12](#1312-validate On failure, throws `ContentValidationException` (structure same as 1.3.12). Error codes: +- `negotiation.invalid_input` (the prompt is null or blank, the schema is null, or the templateUri prefix segment/version does not match this API) + - `negotiation.semantic_rejected` (required parameter missing or invalid value, e.g. an add entry missing required fields (`content.entry_field_missing`) or a validity period format error (`content.format_error`)) - `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) @@ -1433,7 +1452,7 @@ On failure, throws `ContentValidationException` (structure same as 1.3.12). Erro - `input.text_too_long` (prompt longer than `A2AT_INPUT_TEXT_MAX_CHARS`) -Programming errors: a null prompt / schema / templateUri throws `NullPointerException`; a blank prompt or a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`. +Programming errors: a null templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`; a null or blank prompt or a null schema is not a programming error and throws `ContentValidationException` with `negotiation.invalid_input` (see the error codes above). **Response Example** @@ -1598,7 +1617,7 @@ On failure (`success()` is `false`; no exception is thrown, the failure payload | ---- | ---- | ---- | | code | String | Machine-readable error code from the error-code list: `scenario.not_matched` (message parsing/scenario recognition failure), `template.not_found` (template missing), slot-domain codes such as `slot.not_provided` (missing required value), `slot.constraint_violated` (out-of-range value) and `slot.rule_violation` (other slot rule violations), `input.text_too_long` (input length guard) | | message | String | Human-readable failure description | -| stage | String | Stage where the failure occurred: `prompt_parse` (message parsing), `generation` (template loading), `slot_validation` (slot validation) | +| stage | String | Stage where the failure occurred: `input_gate` (input length guard), `prompt_parse` (message parsing), `generation` (template loading), `slot_validation` (slot validation) | **Response Example** diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index d6c3e9d1..715414f6 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -147,7 +147,7 @@ This basic development sample mainly uses the following two A2A-T SDK APIs. In a API definition and function description: generates a notification subscription prompt message from natural-language text with the specified Notification-T template (skipping scenario recognition). It runs one LLM slot-extraction step and then renders the template deterministically; the generation phase performs built-in slot schema validation. ```java -public MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri) +public MetadataContent generateNotificationPromptFromText(String text, String templateUri) ``` Sample invocation: @@ -156,28 +156,24 @@ Sample invocation: import java.nio.file.Path; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; A2ATClient client = new A2ATClient(Path.of("client.env")); -TemplateUri templateUri = TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); MetadataContent result = client.generateNotificationPromptFromText( "I would like to subscribe to service recovery events. The notification data format is as follows: " + "1. Service recovery plan execution status, possible values: not started, finished; " + "2. Complaint diagnosis task serial number; 3. OSS-side event serial number; 4. Access port name; " + "5. Whether OMC automatic recovery is authorized, possible values: yes, no; 6. Service recovery plan name; 7. Service recovery plan details", - templateUri); + "Notification-T/network-layer/service-recovery/v1"); -/* Output of the generated notification subscription prompt message (result.promptText(); the actual text varies with the LLM slot-extraction result) +/* Output of the generated notification subscription prompt message (result.promptText(); the actual text varies with the LLM slot-extraction result; this example provides no subscribe condition, so the corresponding section is left empty) ## Subscription Description -Based on the following , , , and information, complete the subscription to and reporting of network-side service recovery events. +Please complete the network-side service recovery event subscription and reporting task based on the following , , , and information. ## Notification Topic Service recovery event ## Subscribe Condition -(Optional) -1. Subnet name; e.g. xx subnet; ## Notification Data Format 1. Service recovery plan execution status, possible values: not started, finished @@ -201,7 +197,7 @@ API definition and function description: validates whether a Notification-T noti ```java public FilledParamData validateNotificationPromptAndDataFilling( - String prompt, Map schema, TemplateUri templateUri) + String prompt, Map schema, String templateUri) ``` Sample invocation: validates the notification subscription message sent by the client and extracts the subscription parameters @@ -211,11 +207,9 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.server.A2ATServer; A2ATServer server = new A2ATServer(Path.of("server.env")); -TemplateUri templateUri = TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); Map validationSchema = Map.of( "type", "object", @@ -229,7 +223,7 @@ Map validationSchema = Map.of( // promptText is the text of the notification subscription prompt message generated by the client (see the client sample invocation above) FilledParamData result = server.validateNotificationPromptAndDataFilling( - promptText, validationSchema, templateUri); + promptText, validationSchema, "Notification-T/network-layer/service-recovery/v1"); /* Subscription parameters extracted per the schema (result.data(); an empty subscription condition means the input in this example carries no subscription condition) @@ -700,20 +694,17 @@ The client uses the A2A-T Client SDK API `generateNotificationPromptFromText` to import java.nio.file.Path; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; A2ATClient client = new A2ATClient(Path.of("client.env")); // Generate the A2A-T notification subscription prompt message // (on generation failure a PromptGenerationException is thrown, carrying code and message) -TemplateUri templateUri = - TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); MetadataContent result = client.generateNotificationPromptFromText( "I would like to subscribe to service recovery events. The notification data format is as follows: " + "1. Service recovery plan execution status, possible values: not started, finished; " + "2. Complaint diagnosis task serial number; 3. OSS-side event serial number; 4. Access port name; " + "5. Whether OMC automatic recovery is authorized, possible values: yes, no; 6. Service recovery plan name; 7. Service recovery plan details", - templateUri); + "Notification-T/network-layer/service-recovery/v1"); String processedPrompt = result.promptText(); String extensionUri = result.extensionUri(); // A2A-T extension URI, used for message metadata and request headers @@ -752,7 +743,6 @@ import java.util.UUID; import java.util.concurrent.CountDownLatch; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; import org.a2aproject.sdk.client.Client; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; @@ -771,14 +761,12 @@ String notificationPromptExt = "https://projects.tmforum.org/a2aproject/telecomm // 1) Generate the A2A-T template message (see 1.4.3 Step6) A2ATClient client = new A2ATClient(Path.of("client.env")); -TemplateUri templateUri = - TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); MetadataContent result = client.generateNotificationPromptFromText( "I would like to subscribe to service recovery events. The notification data format is as follows: " + "1. Service recovery plan execution status, possible values: not started, finished; " + "2. Complaint diagnosis task serial number; 3. OSS-side event serial number; 4. Access port name; " + "5. Whether OMC automatic recovery is authorized, possible values: yes, no; 6. Service recovery plan name; 7. Service recovery plan details", - templateUri); + "Notification-T/network-layer/service-recovery/v1"); String processedPrompt = result.promptText(); // 2) Build the A2A message carrying the A2A-T extension (the prompt goes into metadata, keyed by the extension URI) @@ -846,7 +834,6 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; import org.a2aproject.sdk.client.Client; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; @@ -876,14 +863,12 @@ public final class ClientSample { // 2) Use the SDK capability to generate the A2A-T notification subscription prompt message A2ATClient client = new A2ATClient(Path.of("client.env")); - TemplateUri templateUri = - TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); MetadataContent result = client.generateNotificationPromptFromText( "I would like to subscribe to service recovery events. The notification data format is as follows: " + "1. Service recovery plan execution status, possible values: not started, finished; " + "2. Complaint diagnosis task serial number; 3. OSS-side event serial number; 4. Access port name; " + "5. Whether OMC automatic recovery is authorized, possible values: yes, no; 6. Service recovery plan name; 7. Service recovery plan details", - templateUri); + "Notification-T/network-layer/service-recovery/v1"); String processedPrompt = result.promptText(); // 3) Build the A2A message carrying the A2A-T extension (request headers declare the extension; the message body carries the A2A-T extension field) @@ -1194,7 +1179,6 @@ import java.util.List; import java.util.Map; import java.util.UUID; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.server.A2ATServer; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; @@ -1210,8 +1194,8 @@ public final class ServiceRecoverySubscriptionExecutor implements AgentExecutor private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; - private static final TemplateUri NOTIFICATION_TEMPLATE_URI = - TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); + private static final String NOTIFICATION_TEMPLATE_URI = + "Notification-T/network-layer/service-recovery/v1"; private static final Map NOTIFICATION_PARAM_SCHEMA = Map.of( "type", "object", @@ -1328,7 +1312,6 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import jakarta.enterprise.inject.Produces; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.server.A2ATServer; import org.a2aproject.sdk.server.PublicAgentCard; @@ -1425,8 +1408,8 @@ public class ServerSample { private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; - private static final TemplateUri NOTIFICATION_TEMPLATE_URI = - TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); + private static final String NOTIFICATION_TEMPLATE_URI = + "Notification-T/network-layer/service-recovery/v1"; // Parameter extraction schema (same as a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json) private static final Map NOTIFICATION_PARAM_SCHEMA = Map.of( diff --git "a/docs/zh/API\345\217\202\350\200\203.md" "b/docs/zh/API\345\217\202\350\200\203.md" index 0daf3d64..36fbbfc7 100644 --- "a/docs/zh/API\345\217\202\350\200\203.md" +++ "b/docs/zh/API\345\217\202\350\200\203.md" @@ -37,31 +37,31 @@ A2A-T SDK 对外仅提供两个入口:客户端入口 `A2ATClient`(提示词 - 模板校验+提参失败抛 `ContentValidationException`(Task-T / Notification-T / Authorization-T)或 `NegotiationParamExtractionException`(Negotiation-T)。 - **SlotValidationError:** 逐槽位校验错误明细,随校验失败异常或失败负载返回(各接口输出说明中的 `getErrors()` / `failedParameters()` / `errors()` 均引用此定义): -| 字段 | 类型 | 说明 | -| ---- | ---- |--------------------------------------------------------------------------------------------------------------------------| -| slotName | String | 出错槽位名 | -| code | String | 槽位级错误码,取值来自 1.4 错误码列表,如 `slot.not_provided`、`content.param_missing`、`content.entry_field_missing`、`content.format_error` | -| message | String | 人类可读的错误说明,由 SDK 按错误码的消息模板渲染,语言跟随 `A2AT_LANGUAGE` | -| facts | Map<String, String> | 渲染消息所依据的结构化事实值(如 `section_label`、`index`、`field_label`),可为 null | + | 字段 | 类型 | 说明 | + | ---- | ---- |--------------------------------------------------------------------------------------------------------------------------| + | slotName | String | 出错槽位名 | + | code | String | 槽位级错误码,取值来自 1.4 错误码列表,如 `slot.not_provided`、`content.param_missing`、`content.entry_field_missing`、`content.format_error` | + | message | String | 人类可读的错误说明,由 SDK 按错误码的消息模板渲染,语言跟随 `A2AT_LANGUAGE` | + | facts | Map<String, String> | 渲染消息所依据的结构化事实值(如 `section_label`、`index`、`field_label`),可为 null | - **模板 URI:** `A2ATClient` 与 `A2ATServer` 门面上所有指定模板的方法均以 `String templateUri`(如 `Task-T/network-layer/private-line-complaint/v1`)声明模板选择,推荐直接传 `net.openan.a2at.sdk.core.model.StandardTemplates` 的 String 常量(常量名以 `_URI` 结尾,每个都有去掉后缀的同名 `TemplateUri` 类型化孪生常量);来自外部的字符串直接传入即可。类型 `TemplateUri` 仍用于底层服务层(如 `NegotiationContentService`),可用 `TemplateUri.parse(String)` 解析,返回 `Optional` 且不抛异常。当前支持的模板 URI 常量(String 形态)如下: -| 常量名称 | 含义 | TemplateUri | -| ---- | ---- | ---- | -| StandardTemplates.ENERGY_SAVING_URI | Task-T 节能任务模板 | Task-T/network-layer/ran-energy-saving/v1 | -| StandardTemplates.PRIVATE_LINE_COMPLAINT_URI | Task-T 专线投诉任务模板 | Task-T/network-layer/private-line-complaint/v1 | -| StandardTemplates.SUBSCRIBE_INCIDENT_URI | Notification-T 事件订阅通知模板 | Notification-T/network-layer/subscribe-incident/v1 | -| StandardTemplates.SERVICE_RECOVERY_URI | Notification-T 业务抢通通知模板 | Notification-T/network-layer/service-recovery/v1 | -| StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI | Authorization-T 授权策略管理模板 | Authorization-T/authorization-policy-management/v1 | -| StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI | Negotiation-T 信息协商意图发起模板 | Negotiation-T/information-negotiation/propose/v1 | -| StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T 信息协商接受/拒绝模板 | Negotiation-T/information-negotiation/accept-reject/v1 | -| StandardTemplates.TARGET_NEGOTIATION_PROPOSE_URI | Negotiation-T 目标协商意图发起模板 | Negotiation-T/target-negotiation/propose/v1 | -| StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T 目标协商接受/拒绝模板 | Negotiation-T/target-negotiation/accept-reject/v1 | -| StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE_URI | Negotiation-T 可行性协商意图发起模板 | Negotiation-T/feasibility-negotiation/propose/v1 | -| StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T 可行性协商接受/拒绝模板 | Negotiation-T/feasibility-negotiation/accept-reject/v1 | -| StandardTemplates.NEGOTIATION_ABORT_URI | Negotiation-T 协商中止通用模板 | Negotiation-T/common/abort/v1 | - -- **门面模板 URI 失败策略(`A2ATClient` 与 `A2ATServer` 均适用):** 门面上所有带 `templateUri` 参数的方法(含 `getPrompt`)均接收原始 URI 字符串。templateUri 为 null 抛 `NullPointerException`;为空白或非法 URI(少于三段,或某一段不是简单段)抛 `IllegalArgumentException`,消息为 `Unparseable template URI: <输入>`。此前服务端任务/通知/授权校验三件套传入 null 类型化模板 URI 时抛 `ContentValidationException`(错误码 `negotiation.invalid_input`);String 化后这类编程错误统一为标准 JDK 异常,不在 `A2ATError` 树内。 + | 常量名称 | 含义 | TemplateUri | + |-------------------------------------------------------------| ---- | ---- | + | StandardTemplates.ENERGY_SAVING_URI | Task-T 节能任务模板 | Task-T/network-layer/ran-energy-saving/v1 | + | StandardTemplates.PRIVATE_LINE_COMPLAINT_URI | Task-T 专线投诉任务模板 | Task-T/network-layer/private-line-complaint/v1 | + | StandardTemplates.SUBSCRIBE_INCIDENT_URI | Notification-T 事件订阅通知模板 | Notification-T/network-layer/subscribe-incident/v1 | + | StandardTemplates.SERVICE_RECOVERY_URI | Notification-T 业务抢通通知模板 | Notification-T/network-layer/service-recovery/v1 | + | StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI | Authorization-T 授权策略管理模板 | Authorization-T/authorization-policy-management/v1 | + | StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI | Negotiation-T 信息协商意图发起模板 | Negotiation-T/information-negotiation/propose/v1 | + | StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T 信息协商接受/拒绝模板 | Negotiation-T/information-negotiation/accept-reject/v1 | + | StandardTemplates.TARGET_NEGOTIATION_PROPOSE_URI | Negotiation-T 目标协商意图发起模板 | Negotiation-T/target-negotiation/propose/v1 | + | StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T 目标协商接受/拒绝模板 | Negotiation-T/target-negotiation/accept-reject/v1 | + | StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE_URI | Negotiation-T 可行性协商意图发起模板 | Negotiation-T/feasibility-negotiation/propose/v1 | + | StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T 可行性协商接受/拒绝模板 | Negotiation-T/feasibility-negotiation/accept-reject/v1 | + | StandardTemplates.NEGOTIATION_ABORT_URI | Negotiation-T 协商中止通用模板 | Negotiation-T/common/abort/v1 | + +- **模板 URI 校验策略:** 所有带 `templateUri` 参数的方法均接收原始 URI 字符串。templateUri 为 null 抛 `NullPointerException`;为空白或非法 URI(少于三段,或某一段不是简单段)抛 `IllegalArgumentException`。 ## 1.2 约束和限制 @@ -150,7 +150,7 @@ Map metadata = propose.buildMetadataContent(); - `negotiation.field_missing`(缺少必填字段) -入参为 null 抛 `NullPointerException`,templateUri 为空白或非法(见 1.1 门面失败策略)、或 phase 段不是 `propose` 抛 `IllegalArgumentException`。 +context 或 templateUri 为 null 抛 `NullPointerException`,templateUri 为空白或非法、或 phase 段不是 `propose` 抛 `IllegalArgumentException`;text 为 null 或空白不属编程错误,以 `negotiation.invalid_input` 抛 `NegotiationGenerationException`(见上错误码)。 **响应样例** @@ -176,7 +176,7 @@ public MetadataContent generateNegotiationAcceptPromptFromText( **典型场景**:协商响应方(通常是客户端Agent)收到对端的信息协商请求后,用自然语言补充/交付所请求的信息并生成接受报文回传,如补齐接入端口名称与投诉分类后确认启动诊断。 -**功能说明**:从自然语言文本生成协商接受(accept)报文。一次 LLM 内容抽取(抽取出的结论必须为 `ACCEPT`,否则以 `negotiation.invalid_input` 拒绝)+ 确定性渲染。适用于协商响应方补充/交付信息。 +**功能说明**:从自然语言文本生成协商接受(accept)报文。一次 LLM 内容抽取(抽取出的结论必须为 `ACCEPT`,否则以 `negotiation.conclusion_mismatch` 拒绝)+ 确定性渲染。适用于协商响应方补充/交付信息。 **输入说明** @@ -210,7 +210,9 @@ MetadataContent accept = client.generateNegotiationAcceptPromptFromText( - `llm.response_invalid`(LLM 返回内容不符合步骤要求,可重试) -- `negotiation.invalid_input`(文本为空或抽取结论不是 `ACCEPT`) +- `negotiation.invalid_input`(文本为空) + +- `negotiation.conclusion_mismatch`(抽取结论不是 `ACCEPT`) - `negotiation.field_missing`(缺少必填字段) @@ -239,7 +241,7 @@ public MetadataContent generateNegotiationRejectPromptFromText( **典型场景**:协商响应方(通常是客户端Agent)无法满足对端的协商请求时,用自然语言生成拒绝报文回传并结束本轮协商,如因站点清单不可用无法提供接入端口名称。 -**功能说明**:从自然语言文本生成协商拒绝(reject)报文。一次 LLM 内容抽取(抽取出的结论必须为 `REJECT`,否则以 `negotiation.invalid_input` 拒绝)+ 确定性渲染。 +**功能说明**:从自然语言文本生成协商拒绝(reject)报文。一次 LLM 内容抽取(抽取出的结论必须为 `REJECT`,否则以 `negotiation.conclusion_mismatch` 拒绝)+ 确定性渲染。 **输入说明**:同 [generateNegotiationAcceptPromptFromText](#132-generatenegotiationacceptpromptfromtext),text 为描述拒绝原因的自然语言文本。 @@ -266,7 +268,9 @@ MetadataContent reject = client.generateNegotiationRejectPromptFromText( - `llm.response_invalid`(LLM 返回内容不符合步骤要求,可重试) -- `negotiation.invalid_input`(文本为空或抽取结论不是 `REJECT`) +- `negotiation.invalid_input`(文本为空) + +- `negotiation.conclusion_mismatch`(抽取结论不是 `REJECT`) - `negotiation.field_missing`(缺少必填字段) @@ -348,7 +352,7 @@ MetadataContent propose = client.generateNegotiationProposePromptFromData( - `template.render_failed`(模板渲染失败) -另有两类编程错误(`A2ATError` 树外,标准 JDK 异常):入参或其 context 为 null 抛 `NullPointerException`;templateUri 为空白或非法(见 1.1 门面失败策略)、内容类型与模板协商类型不符、phase 段不是 `propose` 抛 `IllegalArgumentException`。 +另有两类编程错误(`A2ATError` 树外,标准 JDK 异常):入参或其 context 为 null 抛 `NullPointerException`;templateUri 为空白或非法、内容类型与模板协商类型不符、phase 段不是 `propose` 抛 `IllegalArgumentException`。 **响应样例** @@ -418,7 +422,7 @@ MetadataContent accept = client.generateNegotiationAcceptPromptFromData( - `template.render_failed`(模板渲染失败) -编程错误:入参或其 context 为 null 抛 `NullPointerException`;templateUri 为空白或非法(见 1.1 门面失败策略)、内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;`conclusion` 不是 `ACCEPT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 +编程错误:入参或其 context 为 null 抛 `NullPointerException`;templateUri 为空白或非法、内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;`conclusion` 不是 `ACCEPT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 **响应样例** @@ -473,7 +477,7 @@ MetadataContent reject = client.generateNegotiationRejectPromptFromData( - `template.render_failed`(模板渲染失败) -编程错误:入参或其 context 为 null 抛 `NullPointerException`;templateUri 为空白或非法(见 1.1 门面失败策略)、内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;`conclusion` 不是 `REJECT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 +编程错误:入参或其 context 为 null 抛 `NullPointerException`;templateUri 为空白或非法、内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;`conclusion` 不是 `REJECT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 **响应样例** @@ -555,7 +559,7 @@ requested.data().keySet().removeAll(List.of("id", "round", "maxRounds")); 错误码: -- `negotiation.invalid_input`(报文不是协商消息或 context 为 null) +- `negotiation.invalid_input`(prompt 为 null 或空白、报文不是协商消息,或 context 为 null) - `negotiation.rule_violation`(协商上下文违反规则;`getErrors()` 中的嵌套槽位错误码标识具体规则,如 `negotiation.invalid_context_id`、`negotiation.round_exceeded`) @@ -565,7 +569,7 @@ requested.data().keySet().removeAll(List.of("id", "round", "maxRounds")); - `template.not_found`(校验提示词资源缺失) -编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白、templateUri 为空白或非法(见 1.1 门面失败策略)、phase 段不匹配抛 `IllegalArgumentException`。 +编程错误:schema / templateUri 为 null 抛 `NullPointerException`,templateUri 为空白或非法、phase 段不匹配抛 `IllegalArgumentException`;prompt 为 null 或空白不属编程错误,以 `negotiation.invalid_input` 抛 `NegotiationParamExtractionException`(见上错误码)。 **响应样例** @@ -613,7 +617,7 @@ FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( 失败时抛 `NegotiationParamExtractionException`(结构同 1.3.7)。错误码: -- `negotiation.invalid_input`(报文不是 accept 协商消息或 context 为 null) +- `negotiation.invalid_input`(prompt 为 null 或空白、报文不是 accept 协商消息,或 context 为 null) - `negotiation.rule_violation`(协商上下文违反规则;`getErrors()` 中的嵌套槽位错误码标识具体规则,如 `negotiation.invalid_context_id`、`negotiation.round_exceeded`) @@ -623,7 +627,7 @@ FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( - `template.not_found`(校验提示词资源缺失) -编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白、templateUri 为空白或非法(见 1.1 门面失败策略)、phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。 +编程错误:schema / templateUri 为 null 抛 `NullPointerException`,templateUri 为空白或非法、phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;prompt 为 null 或空白不属编程错误,以 `negotiation.invalid_input` 抛 `NegotiationParamExtractionException`(见上错误码)。 **响应样例** @@ -666,7 +670,7 @@ FilledParamData rejectParams = server.validateRejectPromptAndDataFilling( 失败时抛 `NegotiationParamExtractionException`(结构同 1.3.7)。错误码: -- `negotiation.invalid_input`(报文不是 reject 协商消息或 context 为 null) +- `negotiation.invalid_input`(prompt 为 null 或空白、报文不是 reject 协商消息,或 context 为 null) - `negotiation.rule_violation`(协商上下文违反规则;`getErrors()` 中的嵌套槽位错误码标识具体规则,如 `negotiation.invalid_context_id`、`negotiation.round_exceeded`) @@ -676,7 +680,7 @@ FilledParamData rejectParams = server.validateRejectPromptAndDataFilling( - `template.not_found`(校验提示词资源缺失) -编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白、templateUri 为空白或非法(见 1.1 门面失败策略)、phase 段不是 `accept-reject` 抛 `IllegalArgumentException`。 +编程错误:schema / templateUri 为 null 抛 `NullPointerException`,templateUri 为空白或非法、phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;prompt 为 null 或空白不属编程错误,以 `negotiation.invalid_input` 抛 `NegotiationParamExtractionException`(见上错误码)。 **响应样例** @@ -766,7 +770,7 @@ MetadataContent metadata = client.generateTaskPromptFromText( - `input.text_too_long`(输入超过 `A2AT_INPUT_TEXT_MAX_CHARS`) -编程错误:text 或 templateUri 为 null 抛 `NullPointerException`;templateUri 为空白或非法(见 1.1 门面失败策略)抛 `IllegalArgumentException`。 +编程错误:text 或 templateUri 为 null 抛 `NullPointerException`;templateUri 为空白或非法抛 `IllegalArgumentException`。 **响应样例** @@ -792,12 +796,18 @@ promptText : 3. OSS侧事件流水号:event-id-20260511-09013 4. 投诉详情:从5月11号早上8点半开始,深圳访问广州的响应时延从平均12ms骤升至320ms -## 预期输出 +## 预期输出(Expected Output) 要求投诉诊断任务的结果包含如下信息: 1. 诊断结果;参数的取值范围包括:成功、失败;(必选) 2. 诊断结果详细信息; (必选) 3. 修复建议; (可选) 4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息; (可选) + +## 术语解释(Terminology Explanation) +1. 专线中断 + - 同义词术语:private line interruption,业务中断,网络无法连通,业务不通,业务无法访问 +2. 专线质差 + - 同义词术语:private line poor quality,业务卡顿,业务访问超时,业务丢包,业务时延大,业务抖动,业务拥塞,业务体验下滑,业务误码率高,业务光功率异常 ``` ### 1.3.11 generateTaskPromptFromDataWithSchema @@ -856,7 +866,7 @@ MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( 成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext))。 -失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,templateUri 为空白或非法(见 1.1 门面失败策略)或 schema 为空 Map 抛 `IllegalArgumentException`。 +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,templateUri 为空白或非法或 schema 为空 Map 抛 `IllegalArgumentException`。 **响应样例**(与 1.3.10 同模板,槽位值来自结构化输入,`faultDetail` 为示例截断值) @@ -882,12 +892,18 @@ promptText : 3. OSS侧事件流水号:event-id-20260511-09013 4. 投诉详情:从5月11号早上8点半开始,深圳访问广州的响应时延从平均12ms骤升至320ms -## 预期输出 +## 预期输出(Expected Output) 要求投诉诊断任务的结果包含如下信息: 1. 诊断结果;参数的取值范围包括:成功、失败;(必选) 2. 诊断结果详细信息; (必选) 3. 修复建议; (可选) 4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息; (可选) + +## 术语解释(Terminology Explanation) +1. 专线中断 + - 同义词术语:private line interruption,业务中断,网络无法连通,业务不通,业务无法访问 +2. 专线质差 + - 同义词术语:private line poor quality,业务卡顿,业务访问超时,业务丢包,业务时延大,业务抖动,业务拥塞,业务体验下滑,业务误码率高,业务光功率异常 ``` ### 1.3.12 validateTaskPromptAndDataFilling @@ -959,6 +975,8 @@ Map extracted = server 错误码: +- `negotiation.invalid_input`(prompt 为 null 或空白、schema 为 null,或 templateUri 前缀段/版本与该接口不符) + - `negotiation.semantic_rejected`(语义校验拒绝,含 required 参数缺失或取值非法;`errors()` 中的逐槽位明细使用 `content.*` 码集,如 `content.param_missing`、`content.entry_field_missing`、`content.format_error`、`content.value_not_allowed`) - `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) @@ -967,9 +985,9 @@ Map extracted = server - `input.text_too_long`(提示词超过 `A2AT_INPUT_TEXT_MAX_CHARS`) -编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白或 templateUri 为空白/非法(见 1.1 门面失败策略)抛 `IllegalArgumentException`。 +编程错误:templateUri 为 null 抛 `NullPointerException`,为空白/非法抛 `IllegalArgumentException`;prompt 为 null 或空白、schema 为 null 不属编程错误,以 `negotiation.invalid_input` 抛 `ContentValidationException`(见上错误码)。 -**响应样例** +**响应样例**(`faultTime`、`faultDetail` 为示例截断值) ```text extracted = @@ -1031,7 +1049,7 @@ MetadataContent result = client.generateNotificationPromptFromText( 成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1`)。 -失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:text 或 templateUri 为 null 抛 `NullPointerException`;templateUri 为空白或非法(见 1.1 门面失败策略)抛 `IllegalArgumentException`。 +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:text 或 templateUri 为 null 抛 `NullPointerException`;templateUri 为空白或非法抛 `IllegalArgumentException`。 **响应样例**(按模板渲染,实际文本随 LLM 槽位提取结果变化;本例输入未指定订阅条件,对应槽位留空) @@ -1046,8 +1064,6 @@ promptText : 业务抢通事件 ## 订阅条件 -(可选) -1. 子网名称;举例:xx子网; ## 上报通知数据格式 1. 业务抢通方案执行状态,取值范围:未启动、已结束 @@ -1124,7 +1140,7 @@ MetadataContent result = client.generateNotificationPromptFromDataWithSchema( 成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1`)。 -失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,templateUri 为空白或非法(见 1.1 门面失败策略)或 schema 为空 Map 抛 `IllegalArgumentException`。 +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,templateUri 为空白或非法或 schema 为空 Map 抛 `IllegalArgumentException`。 **响应样例**(与 1.3.13 同模板,槽位值来自结构化输入:`订阅条件` 填充 `condition`,`上报通知数据格式` 按 `reportFormat` 列表渲染,字段排列样式示意) @@ -1139,8 +1155,7 @@ promptText : 业务抢通事件 ## 订阅条件 -子网名称:xx子网(可选) -1. 子网名称;举例:xx子网; +子网名称:xx子网 ## 上报通知数据格式 1. 业务抢通方案执行状态,取值范围:未启动、已结束(必选) @@ -1207,6 +1222,8 @@ FilledParamData result = server.validateNotificationPromptAndDataFilling( 失败时抛 `ContentValidationException`(结构同 1.3.12)。错误码: +- `negotiation.invalid_input`(prompt 为 null 或空白、schema 为 null,或 templateUri 前缀段/版本与该接口不符) + - `negotiation.semantic_rejected`(必选参数缺失或取值非法;`errors()` 中的逐槽位明细使用 `content.*` 码集) - `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) @@ -1215,7 +1232,7 @@ FilledParamData result = server.validateNotificationPromptAndDataFilling( - `input.text_too_long`(提示词超过 `A2AT_INPUT_TEXT_MAX_CHARS`) -编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白或 templateUri 为空白/非法(见 1.1 门面失败策略)抛 `IllegalArgumentException`。 +编程错误:templateUri 为 null 抛 `NullPointerException`,为空白/非法抛 `IllegalArgumentException`;prompt 为 null 或空白、schema 为 null 不属编程错误,以 `negotiation.invalid_input` 抛 `ContentValidationException`(见上错误码)。 **响应样例** @@ -1266,7 +1283,7 @@ MetadataContent result = client.generateAuthPromptFromText( 成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`)。 -失败时抛 `PromptGenerationException`(结构同 1.3.10),如操作类型不在"新增/修改/删除/查询授权策略"范围内时以 `slot.constraint_violated` 拒绝。编程错误:text 或 templateUri 为 null 抛 `NullPointerException`;templateUri 为空白或非法(见 1.1 门面失败策略)抛 `IllegalArgumentException`。 +失败时抛 `PromptGenerationException`(结构同 1.3.10),如操作类型不在"新增/修改/删除/查询授权策略"范围内时以 `slot.constraint_violated` 拒绝。编程错误:text 或 templateUri 为 null 抛 `NullPointerException`;templateUri 为空白或非法抛 `IllegalArgumentException`。 **响应样例** @@ -1351,7 +1368,7 @@ MetadataContent result = client.generateAuthPromptFromDataWithSchema( 成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`)。 -失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,templateUri 为空白或非法(见 1.1 门面失败策略)或 schema 为空 Map 抛 `IllegalArgumentException`。 +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,templateUri 为空白或非法或 schema 为空 Map 抛 `IllegalArgumentException`。 **响应样例** @@ -1417,6 +1434,8 @@ FilledParamData result = server.validateAuthPromptAndDataFilling( 失败时抛 `ContentValidationException`(结构同 1.3.12)。错误码: +- `negotiation.invalid_input`(prompt 为 null 或空白、schema 为 null,或 templateUri 前缀段/版本与该接口不符) + - `negotiation.semantic_rejected`(必选参数缺失或取值非法,如新增条目缺少必填字段(`content.entry_field_missing`)、有效期格式错误(`content.format_error`)) - `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) @@ -1425,7 +1444,7 @@ FilledParamData result = server.validateAuthPromptAndDataFilling( - `input.text_too_long`(提示词超过 `A2AT_INPUT_TEXT_MAX_CHARS`) -编程错误:prompt / schema / templateUri 为 null 抛 `NullPointerException`,prompt 为空白或 templateUri 为空白/非法(见 1.1 门面失败策略)抛 `IllegalArgumentException`。 +编程错误:templateUri 为 null 抛 `NullPointerException`,为空白/非法抛 `IllegalArgumentException`;prompt 为 null 或空白、schema 为 null 不属编程错误,以 `negotiation.invalid_input` 抛 `ContentValidationException`(见上错误码)。 **响应样例** @@ -1589,7 +1608,7 @@ if (result.success()) { | ---- | ---- | ---- | | code | String | 机器可读错误码,取值来自错误码列表:`scenario.not_matched`(报文解析/场景识别失败)、`template.not_found`(模板缺失)、槽位域码如 `slot.not_provided`(必填缺失)、`slot.constraint_violated`(取值越界)、`slot.rule_violation`(其它槽位规则违规)、`input.text_too_long`(输入长度防护) | | message | String | 人类可读的失败描述 | -| stage | String | 失败发生的阶段:`prompt_parse`(报文解析)、`generation`(模板加载)、`slot_validation`(槽位校验) | +| stage | String | 失败发生的阶段:`input_gate`(输入长度防护)、`prompt_parse`(报文解析)、`generation`(模板加载)、`slot_validation`(槽位校验) | **响应样例** diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 56ce9bab..f543469a 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -147,7 +147,7 @@ flowchart TD 接口定义及功能描述:从自然语言文本按指定 Notification-T 模板生成通知订阅提示词报文(跳过场景识别)。执行一次 LLM 槽位提取后确定性渲染模板,生成阶段执行内置槽位 Schema 校验。 ```java -public MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri) +public MetadataContent generateNotificationPromptFromText(String text, String templateUri) ``` 接口调用样例: @@ -156,19 +156,17 @@ public MetadataContent generateNotificationPromptFromText(String text, TemplateU import java.nio.file.Path; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; A2ATClient client = new A2ATClient(Path.of("client.env")); -TemplateUri templateUri = TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); MetadataContent result = client.generateNotificationPromptFromText( "我想订阅一下业务抢通事件。上报通知数据格式如下:" + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;" + "2. 投诉诊断任务流水号;3. OSS侧事件流水号;4. 接入端口名称;" + "5. 是否已授权OMC自动抢通,取值范围:是、否;6. 业务抢通方案名称;7. 业务抢通方案详情", - templateUri); + "Notification-T/network-layer/service-recovery/v1"); -/*输出生成的通知订阅提示词报文(result.promptText(),实际文本随 LLM 槽位提取结果变化) +/*输出生成的通知订阅提示词报文(result.promptText(),实际文本随 LLM 槽位提取结果变化;本例未提供订阅条件,该槽位对应章节留空) ## 订阅描述 请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。 @@ -176,8 +174,6 @@ MetadataContent result = client.generateNotificationPromptFromText( 业务抢通事件 ## 订阅条件 -(可选) -1. 子网名称;举例:xx子网; ## 上报通知数据格式 1. 业务抢通方案执行状态,取值范围:未启动、已结束 @@ -201,7 +197,7 @@ MetadataContent result = client.generateNotificationPromptFromText( ```java public FilledParamData validateNotificationPromptAndDataFilling( - String prompt, Map schema, TemplateUri templateUri) + String prompt, Map schema, String templateUri) ``` 接口调用样例:校验客户端发来的通知订阅报文并提取订阅参数 @@ -211,11 +207,9 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.server.A2ATServer; A2ATServer server = new A2ATServer(Path.of("server.env")); -TemplateUri templateUri = TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); Map validationSchema = Map.of( "type", "object", @@ -229,7 +223,7 @@ Map validationSchema = Map.of( // promptText 为客户端生成的通知订阅提示词报文文本(见上文客户端调用样例) FilledParamData result = server.validateNotificationPromptAndDataFilling( - promptText, validationSchema, templateUri); + promptText, validationSchema, "Notification-T/network-layer/service-recovery/v1"); /*按 Schema 提取的订阅参数(result.data(),订阅条件为空表示本例输入未携带订阅条件) { @@ -700,19 +694,16 @@ A2ATClient client = new A2ATClient(Path.of("client.env")); import java.nio.file.Path; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; A2ATClient client = new A2ATClient(Path.of("client.env")); // 生成 A2A-T 通知订阅提示词报文(生成失败时抛出PromptGenerationException,携带code、message) -TemplateUri templateUri = - TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); MetadataContent result = client.generateNotificationPromptFromText( "我想订阅一下业务抢通事件。上报通知数据格式如下:" + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;" + "2. 投诉诊断任务流水号;3. OSS侧事件流水号;4. 接入端口名称;" + "5. 是否已授权OMC自动抢通,取值范围:是、否;6. 业务抢通方案名称;7. 业务抢通方案详情", - templateUri); + "Notification-T/network-layer/service-recovery/v1"); String processedPrompt = result.promptText(); String extensionUri = result.extensionUri(); // A2A-T扩展URI,用于消息metadata与请求头 @@ -751,7 +742,6 @@ import java.util.UUID; import java.util.concurrent.CountDownLatch; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; import org.a2aproject.sdk.client.Client; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; @@ -770,14 +760,12 @@ String notificationPromptExt = "https://projects.tmforum.org/a2aproject/telecomm // 1) 生成 A2A-T 模板报文(见 1.4.3 Step6) A2ATClient client = new A2ATClient(Path.of("client.env")); -TemplateUri templateUri = - TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); MetadataContent result = client.generateNotificationPromptFromText( "我想订阅一下业务抢通事件。上报通知数据格式如下:" + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;" + "2. 投诉诊断任务流水号;3. OSS侧事件流水号;4. 接入端口名称;" + "5. 是否已授权OMC自动抢通,取值范围:是、否;6. 业务抢通方案名称;7. 业务抢通方案详情", - templateUri); + "Notification-T/network-layer/service-recovery/v1"); String processedPrompt = result.promptText(); // 2) 构造携带A2A-T扩展的A2A消息(prompt放入metadata,以扩展URI为key) @@ -844,7 +832,6 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; import org.a2aproject.sdk.client.Client; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; @@ -874,14 +861,12 @@ public final class ClientSample { // 2) 使用SDK能力生成 A2A-T 通知订阅提示词报文 A2ATClient client = new A2ATClient(Path.of("client.env")); - TemplateUri templateUri = - TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); MetadataContent result = client.generateNotificationPromptFromText( "我想订阅一下业务抢通事件。上报通知数据格式如下:" + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;" + "2. 投诉诊断任务流水号;3. OSS侧事件流水号;4. 接入端口名称;" + "5. 是否已授权OMC自动抢通,取值范围:是、否;6. 业务抢通方案名称;7. 业务抢通方案详情", - templateUri); + "Notification-T/network-layer/service-recovery/v1"); String processedPrompt = result.promptText(); // 3) 构造携带A2A-T扩展的A2A消息(请求头声明扩展,消息体携带 A2A-T 扩展字段) @@ -1192,7 +1177,6 @@ import java.util.List; import java.util.Map; import java.util.UUID; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.server.A2ATServer; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; @@ -1208,8 +1192,8 @@ public final class ServiceRecoverySubscriptionExecutor implements AgentExecutor private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; - private static final TemplateUri NOTIFICATION_TEMPLATE_URI = - TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); + private static final String NOTIFICATION_TEMPLATE_URI = + "Notification-T/network-layer/service-recovery/v1"; private static final Map NOTIFICATION_PARAM_SCHEMA = Map.of( "type", "object", @@ -1325,7 +1309,6 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import jakarta.enterprise.inject.Produces; import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.server.A2ATServer; import org.a2aproject.sdk.server.PublicAgentCard; @@ -1421,8 +1404,8 @@ public class ServerSample { private static final String NOTIFICATION_T_EXT_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; - private static final TemplateUri NOTIFICATION_TEMPLATE_URI = - TemplateUri.parse("Notification-T/network-layer/service-recovery/v1").orElseThrow(); + private static final String NOTIFICATION_TEMPLATE_URI = + "Notification-T/network-layer/service-recovery/v1"; // 参数提取Schema(与 a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json 一致) private static final Map NOTIFICATION_PARAM_SCHEMA = Map.of( @@ -1653,13 +1636,13 @@ templateUri:// ```java import net.openan.a2at.sdk.core.model.MetadataContent; -// 门面直接接收原始 URI 字符串 +// 直接接收原始 URI 字符串 MetadataContent metadata = client.generateTaskPromptFromText("请对xx基站开展现场巡检,重点检查告警和性能指标", "Task-T/network-layer/site-inspection/v1"); ``` -门面对 `templateUri` 的失败策略:为 null 抛 `NullPointerException`;为空白或非法 URI(少于三段,或某一段不是简单段)抛 `IllegalArgumentException`,消息为 `Unparseable template URI: <输入>`。 +对 `templateUri` 的失败策略:为 null 抛 `NullPointerException`;为空白或非法 URI(少于三段,或某一段不是简单段)抛 `IllegalArgumentException`,消息为 `Unparseable template URI: <输入>`。 -如需覆盖内置模板(如 `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI`),在本地根目录下按相同相对路径放置 `template.md` 与 `slot.json`,代码中继续使用原有常量即可。注意 `local_file` 模式下业务模板只从本地根目录读取、不回退 classpath,`templateUri` 对应的文件缺失时接口抛出 `PromptGenerationException`,错误码为 `template.not_found`。 +如需覆盖内置模板(如 `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI`),在本地根目录下按相同相对路径放置 `template.md` 与 `slot.json`,代码中继续使用原有常量即可。注意 `local_file` 模式下业务模板只从本地根目录读取,`templateUri` 对应的文件缺失时接口抛出 `PromptGenerationException`,错误码为 `template.not_found`。 #### Step4 验证与排障 From 0a80a879bd08bdb0ee0707ca264aa758c4050eba Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Mon, 31 Aug 2026 00:16:03 +0800 Subject: [PATCH 256/295] test(sample): sync negotiation evaluations to catalog error contract The abnormal and business-abnormal negotiation evaluation mains still asserted the pre-catalog exception contract and ran red after the negotiation error codes were migrated to the dotted catalog: - migrate the 12 stale abnormal-case expectations: blank/null validate prompts now expect NegotiationParamExtractionException with negotiation.invalid_input, and context rule violations (invalid id, exhausted round) expect the unified top-level negotiation.rule_violation instead of the detail-level invalid_context_id/round_exceeded codes - throw LLMRuntimeError instead of IllegalStateException from the scripted LLM client so the infra-failure case maps to llm.invocation_failed on the validation path (plain runtime exceptions fall into the llm.response_invalid catch-all), and refresh the error-code coverage list to the current catalog codes - mark all business-abnormal cases modelDependent: their expected failures hinge on the real LLM declining to extract vague content or rejecting structural violations, so a model-pass outcome records model_dependent_success instead of failing the run; unexpected error codes still fail. Deterministic contract coverage stays with the 49-case abnormal evaluation. Verified against a live OpenAI-compatible endpoint: abnormal 49/49, business-abnormal 12/12; all other sample entries and the sample module unit tests (194 run, 0 failures) pass. Signed-off-by: zhangwenjun-huawei --- .../NegotiationAbnormalEvaluationMain.java | 6 +- ...tiationBusinessAbnormalEvaluationCase.java | 10 +-- ...tiationBusinessAbnormalEvaluationMain.java | 85 +++++++++++++------ .../evaluation/abnormal-cases.json | 36 ++++---- .../evaluation/business-abnormal-cases.json | 12 +++ 5 files changed, 97 insertions(+), 52 deletions(-) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java index b5745376..959c7821 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java @@ -30,6 +30,7 @@ import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; @@ -46,8 +47,7 @@ public final class NegotiationAbnormalEvaluationMain { "negotiation.semantic_rejected", "negotiation.field_missing", "negotiation.invalid_input", - "negotiation.invalid_context_id", - "negotiation.round_exceeded", + "negotiation.rule_violation", "llm.invocation_failed", "llm.response_invalid"); @@ -520,7 +520,7 @@ public LLMResponse structured( Double temperature, Integer maxTokens) { if ("throw".equals(payload)) { - throw new IllegalStateException("scripted LLM infrastructure failure"); + throw new LLMRuntimeError("scripted LLM infrastructure failure"); } return new LLMResponse( payload, "sample-scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCase.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCase.java index b47121bf..2b514b59 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCase.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCase.java @@ -4,9 +4,9 @@ /** One business-level malformed-content case executed against a real LLM. */ public record NegotiationBusinessAbnormalEvaluationCase( - String id, - String api, - String input, - String description, - List expectedCodes) { + String id, String api, String input, String description, Boolean modelDependent, List expectedCodes) { + + public NegotiationBusinessAbnormalEvaluationCase { + modelDependent = Boolean.TRUE.equals(modelDependent); + } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java index 7af4e11b..b6435ca4 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java @@ -28,18 +28,22 @@ /** Runs malformed business-content cases against the configured real LLM. */ public final class NegotiationBusinessAbnormalEvaluationMain { - private static final ObjectMapper OBJECT_MAPPER = - new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); - private NegotiationBusinessAbnormalEvaluationMain() { - } + private NegotiationBusinessAbnormalEvaluationMain() {} public static void main(String[] args) throws IOException { Path envPath = args.length > 0 ? Path.of(args[0]) - : Path.of("a2a-t-sample", "src", "main", "resources", "sample", - "private-line-complaint-negotiation", "qwen.env"); + : Path.of( + "a2a-t-sample", + "src", + "main", + "resources", + "sample", + "private-line-complaint-negotiation", + "qwen.env"); String suffix = LocalDateTime.now(ZoneId.systemDefault()).format(FILE_TIMESTAMP); Path reportPath = args.length > 1 ? Path.of(args[1]) @@ -50,8 +54,7 @@ public static void main(String[] args) throws IOException { Map environment = NegotiationSampleEnvironment.read(envPath); requireQwenConfiguration(environment); - List cases = - NegotiationBusinessAbnormalEvaluationCaseLoader.load(); + List cases = NegotiationBusinessAbnormalEvaluationCaseLoader.load(); String runId = UUID.randomUUID().toString(); List> results = new ArrayList<>(); try (NegotiationEvaluationProcessLogger logger = @@ -64,7 +67,9 @@ public static void main(String[] args) throws IOException { } } - long passed = results.stream().filter(result -> Boolean.TRUE.equals(result.get("passed"))).count(); + long passed = results.stream() + .filter(result -> Boolean.TRUE.equals(result.get("passed"))) + .count(); Map report = new LinkedHashMap<>(); report.put("generated_at", Instant.now().toString()); report.put("run_id", runId); @@ -75,7 +80,12 @@ public static void main(String[] args) throws IOException { report.put("passed", passed); report.put("failed", results.size() - passed); report.put("success_rate", results.isEmpty() ? 1.0 : (double) passed / results.size()); - report.put("definition", "业务结构异常用例要求 SDK 识别无法渲染或无法校验的协商报文;这些用例调用真实 LLM,结果可能受模型输出影响。"); + report.put( + "definition", + "业务结构异常用例要求 SDK 识别无法渲染或无法校验的协商报文;这些用例调用真实 LLM,结果可能受模型输出影响。" + + "全部用例的期望失败均依赖模型判定(生成侧依赖模型拒绝从模糊输入抽取内容,校验侧依赖语义门模型拒绝结构违规):" + + "模型放行并成功返回(model_dependent_success)同样判定通过,仅意外错误码判定失败。" + + "确定性的 SDK 异常契约验证由 negotiation-abnormal-evaluation 承担。"); report.put("process_log", processLogPath.toAbsolutePath().toString()); report.put("cases", results); Path parent = reportPath.toAbsolutePath().getParent(); @@ -83,7 +93,8 @@ public static void main(String[] args) throws IOException { Files.createDirectories(parent); } OBJECT_MAPPER.writeValue(reportPath.toFile(), report); - System.out.printf("Business abnormal negotiation evaluation complete: %d/%d matched; report=%s; process-log=%s%n", + System.out.printf( + "Business abnormal negotiation evaluation complete: %d/%d matched; report=%s; process-log=%s%n", passed, results.size(), reportPath.toAbsolutePath(), processLogPath.toAbsolutePath()); } @@ -103,7 +114,8 @@ private static Map runCase( A2ATServer server, NegotiationBusinessAbnormalEvaluationCase testCase, String runId, - NegotiationEvaluationProcessLogger logger) throws IOException { + NegotiationEvaluationProcessLogger logger) + throws IOException { long startedAt = System.nanoTime(); NegotiationPerformative performative = performativeFor(testCase.api()); NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); @@ -117,8 +129,13 @@ private static Map runCase( try { Object response = invoke(client, server, testCase, context); result.put("response", responseSummary(response)); - result.put("outcome", "unexpected_success"); - result.put("passed", false); + if (testCase.modelDependent()) { + result.put("outcome", "model_dependent_success"); + result.put("passed", true); + } else { + result.put("outcome", "unexpected_success"); + result.put("passed", false); + } logger.write(event(runId, testCase, request, responseSummary(response), result, startedAt)); } catch (RuntimeException exception) { Map error = errorDetails(exception); @@ -141,17 +158,23 @@ private static Object invoke( case "generate_propose" -> client.generateNegotiationProposePromptFromText( testCase.input(), context, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); case "validate_propose" -> server.validateProposePromptAndDataFilling( - testCase.input(), context, InformationNegotiationSchemas.propose(), + testCase.input(), + context, + InformationNegotiationSchemas.propose(), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); case "generate_accept" -> server.generateNegotiationAcceptPromptFromText( testCase.input(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); case "validate_accept" -> client.validateAcceptPromptAndDataFilling( - testCase.input(), context, InformationNegotiationSchemas.accept(), + testCase.input(), + context, + InformationNegotiationSchemas.accept(), NegotiationSampleFlow.ENDING_TEMPLATE_URI); case "generate_reject" -> server.generateNegotiationRejectPromptFromText( testCase.input(), context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); case "validate_reject" -> client.validateRejectPromptAndDataFilling( - testCase.input(), context, InformationNegotiationSchemas.reject(), + testCase.input(), + context, + InformationNegotiationSchemas.reject(), NegotiationSampleFlow.ENDING_TEMPLATE_URI); default -> throw new IllegalArgumentException("Unknown business abnormal API: " + testCase.api()); }; @@ -167,9 +190,11 @@ private static Map requestFor( request.put("schema", schemaFor(testCase.api())); } request.put("context", contextPayload(context)); - request.put("template_uri", testCase.api().contains("propose") - ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI - : NegotiationSampleFlow.ENDING_TEMPLATE_URI); + request.put( + "template_uri", + testCase.api().contains("propose") + ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI + : NegotiationSampleFlow.ENDING_TEMPLATE_URI); return request; } @@ -196,7 +221,8 @@ private static Map event( } private static NegotiationPerformative performativeFor(String api) { - return api.contains("accept") ? NegotiationPerformative.ACCEPT + return api.contains("accept") + ? NegotiationPerformative.ACCEPT : api.contains("reject") ? NegotiationPerformative.REJECT : NegotiationPerformative.PROPOSE; } @@ -248,13 +274,19 @@ private static Map errorDetails(RuntimeException exception) { } private static Map contextPayload(NegotiationContext context) { - return Map.of("id", context.id(), "round", context.round(), "maxRounds", context.maxRounds(), - "performative", context.performative().name()); + return Map.of( + "id", + context.id(), + "round", + context.round(), + "maxRounds", + context.maxRounds(), + "performative", + context.performative().name()); } private static void requireQwenConfiguration(Map environment) { - if (!"openai".equals(environment.get("A2AT_LLM_PROVIDER")) - || environment.get("A2AT_LLM_BASE_URL") == null) { + if (!"openai".equals(environment.get("A2AT_LLM_PROVIDER")) || environment.get("A2AT_LLM_BASE_URL") == null) { throw new IllegalArgumentException( "Business abnormal evaluation requires A2AT_LLM_PROVIDER=openai and A2AT_LLM_BASE_URL"); } @@ -263,7 +295,8 @@ private static void requireQwenConfiguration(Map environment) { private static String gitRevision() { try { Process process = new ProcessBuilder("git", "rev-parse", "HEAD") - .redirectErrorStream(true).start(); + .redirectErrorStream(true) + .start(); String revision; try (var input = process.getInputStream()) { revision = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).trim(); diff --git a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/abnormal-cases.json b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/abnormal-cases.json index 11cbae67..cc342269 100644 --- a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/abnormal-cases.json +++ b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/abnormal-cases.json @@ -15,8 +15,8 @@ "input": "", "template": "propose", "performative": "PROPOSE", - "expected_exception": "IllegalArgumentException", - "expected_code": null, + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation.invalid_input", "description": "校验协商发起报文时输入为空" }, { @@ -188,8 +188,8 @@ "input": null, "template": "propose", "performative": "PROPOSE", - "expected_exception": "NullPointerException", - "expected_code": null, + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation.invalid_input", "description": "校验协商发起报文时 prompt 为 null" }, { @@ -242,7 +242,7 @@ "performative": "PROPOSE", "context_mode": "invalid_id", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation.invalid_context_id", + "expected_code": "negotiation.rule_violation", "description": "校验协商发起报文时上下文 id 不是 UUID" }, { @@ -253,7 +253,7 @@ "performative": "PROPOSE", "context_mode": "exhausted", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation.round_exceeded", + "expected_code": "negotiation.rule_violation", "description": "校验协商发起报文时 round 超过 maxRounds" }, { @@ -262,8 +262,8 @@ "input": "", "template": "ending", "performative": "ACCEPT", - "expected_exception": "IllegalArgumentException", - "expected_code": null, + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation.invalid_input", "description": "接受校验接口 prompt 为空" }, { @@ -272,8 +272,8 @@ "input": null, "template": "ending", "performative": "ACCEPT", - "expected_exception": "NullPointerException", - "expected_code": null, + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation.invalid_input", "description": "接受校验接口 prompt 为 null" }, { @@ -326,7 +326,7 @@ "performative": "ACCEPT", "context_mode": "invalid_id", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation.invalid_context_id", + "expected_code": "negotiation.rule_violation", "description": "接受校验接口上下文 id 不是 UUID" }, { @@ -337,7 +337,7 @@ "performative": "ACCEPT", "context_mode": "exhausted", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation.round_exceeded", + "expected_code": "negotiation.rule_violation", "description": "接受校验接口 round 超过 maxRounds" }, { @@ -346,8 +346,8 @@ "input": "", "template": "ending", "performative": "REJECT", - "expected_exception": "IllegalArgumentException", - "expected_code": null, + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation.invalid_input", "description": "拒绝校验接口 prompt 为空" }, { @@ -356,8 +356,8 @@ "input": null, "template": "ending", "performative": "REJECT", - "expected_exception": "NullPointerException", - "expected_code": null, + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation.invalid_input", "description": "拒绝校验接口 prompt 为 null" }, { @@ -410,7 +410,7 @@ "performative": "REJECT", "context_mode": "invalid_id", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation.invalid_context_id", + "expected_code": "negotiation.rule_violation", "description": "拒绝校验接口上下文 id 不是 UUID" }, { @@ -421,7 +421,7 @@ "performative": "REJECT", "context_mode": "exhausted", "expected_exception": "NegotiationParamExtractionException", - "expected_code": "negotiation.round_exceeded", + "expected_code": "negotiation.rule_violation", "description": "拒绝校验接口 round 超过 maxRounds" } ] diff --git a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json index ecbd751b..81746a51 100644 --- a/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json +++ b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json @@ -4,6 +4,7 @@ "api": "generate_propose", "input": "当前只需要继续处理这条专线投诉,请直接给出处理建议,不需要列出待补充信息。", "description": "发起协商文本没有明确的待补充信息项", + "modelDependent": true, "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] }, { @@ -11,6 +12,7 @@ "api": "generate_propose", "input": "请用一段自然语言概述对端需要提供的资料,不要使用信息项列表或逐项结构。", "description": "发起协商文本明确要求非模板列表结构", + "modelDependent": true, "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] }, { @@ -18,6 +20,7 @@ "api": "generate_accept", "input": "接受本次协商,后续再补充具体信息。", "description": "接受文本没有提供任何结果内容", + "modelDependent": true, "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] }, { @@ -25,6 +28,7 @@ "api": "generate_accept", "input": "同意,信息按之前说的处理即可。", "description": "接受文本只有结论,没有可渲染的逐项结果", + "modelDependent": true, "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] }, { @@ -32,6 +36,7 @@ "api": "generate_reject", "input": "拒绝本次协商。", "description": "拒绝文本没有说明拒绝原因或结果项", + "modelDependent": true, "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] }, { @@ -39,6 +44,7 @@ "api": "generate_reject", "input": "这次不方便提供资料,请用一段话说明即可,不需要逐项列出拒绝内容。", "description": "拒绝文本要求非逐项结果结构", + "modelDependent": true, "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] }, { @@ -46,6 +52,7 @@ "api": "validate_propose", "input": "## 信息协商\n请继续处理当前投诉。", "description": "发起报文缺少所需信息项段落", + "modelDependent": true, "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] }, { @@ -53,6 +60,7 @@ "api": "validate_propose", "input": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n请提供相关资料即可。", "description": "发起报文的所需信息项不是逐项列表", + "modelDependent": true, "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] }, { @@ -60,6 +68,7 @@ "api": "validate_accept", "input": "## 信息协商结果\nAccept", "description": "接受报文缺少结果内容段落", + "modelDependent": true, "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] }, { @@ -67,6 +76,7 @@ "api": "validate_accept", "input": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n相关信息保持不变。", "description": "接受结果内容不是逐项信息", + "modelDependent": true, "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] }, { @@ -74,6 +84,7 @@ "api": "validate_reject", "input": "## 信息协商结果\nReject", "description": "拒绝报文缺少结果内容段落", + "modelDependent": true, "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] }, { @@ -81,6 +92,7 @@ "api": "validate_reject", "input": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n由于当前情况不允许,暂时无法提供。", "description": "拒绝结果内容没有按请求信息项逐项说明", + "modelDependent": true, "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] } ] From 8c87b3ef26207f7a816b6542d8390c815d366e95 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Mon, 31 Aug 2026 00:16:13 +0800 Subject: [PATCH 257/295] style(sample): apply palantir format to sample sources Catch-up spotless formatting (palantir-java-format with javadoc formatting and unused-import removal) for sample sources untouched by the previous format pass; no semantic changes. Signed-off-by: zhangwenjun-huawei --- .../net/openan/a2at/sample/SampleRunner.java | 100 +++--- .../authz_policy/AuthzPromptGenerator.java | 2 +- .../authz_policy/AuthzPromptValidator.java | 2 +- .../authz_policy/AuthzReasoningCapture.java | 46 ++- .../sample/authz_policy/AuthzSampleMain.java | 3 +- .../authz_policy/AuthzScenarioLoader.java | 19 +- .../sample/authz_policy/package-info.java | 4 +- .../negotiation/client/NegotiationClient.java | 5 +- .../eval/EvalLlmCaptureClient.java | 27 +- .../negotiation/eval/NegotiationEvalApp.java | 48 ++- .../eval/NegotiationFromDataApiEvalApp.java | 147 ++++---- .../negotiation/server/DiagnosisService.java | 4 +- .../server/NegotiationAgentExecutor.java | 19 +- .../negotiation/shared/FromDataStrategy.java | 2 +- .../shared/InformationNegotiationSchemas.java | 11 +- .../shared/NegotiationStrategy.java | 4 +- .../negotiation/shared/ScenarioData.java | 4 +- .../client/NegotiationClientMain.java | 23 +- .../NegotiationAbnormalEvaluationCase.java | 15 +- ...gotiationAbnormalEvaluationCaseLoader.java | 11 +- ...nBusinessAbnormalEvaluationCaseLoader.java | 3 +- .../evaluation/NegotiationEvaluationCase.java | 3 +- .../NegotiationEvaluationCaseLoader.java | 47 ++- .../NegotiationEvaluationProcessLogger.java | 4 +- .../NegotiationQwenEvaluationMain.java | 327 +++++++++++++----- .../server/NegotiationAgentExecutor.java | 7 +- .../server/NegotiationServerMain.java | 12 +- .../shared/NegotiationAgentCard.java | 39 ++- .../shared/NegotiationMetadataReader.java | 3 +- .../shared/NegotiationSampleEnvironment.java | 7 +- .../shared/NegotiationSampleFlow.java | 29 +- .../shared/NegotiationScenario.java | 3 +- .../shared/NegotiationScenarioLoader.java | 9 +- .../shared/mock/NegotiationMockLLMClient.java | 27 +- .../mock/NegotiationMockLlmInstaller.java | 3 +- .../service_recovery/FromTextProbe.java | 24 +- .../service_recovery/SampleResourceJson.java | 15 +- .../ServiceRecoverySampleInputs.java | 71 ++-- .../client/ClientSampleMain.java | 19 +- .../client/flow/A2AJavaClientEventMapper.java | 9 +- .../client/flow/ClientFlowOutcome.java | 3 +- .../client/flow/ClientSampleFlow.java | 74 ++-- .../SampleStreamTerminalStateDecider.java | 5 +- .../service_recovery/client/package-info.java | 1 - .../prompt/A2ATNotificationPromptClient.java | 4 +- .../client/prompt/SamplePromptClient.java | 4 +- .../client/registry/SampleRegistryClient.java | 2 - .../client/request/A2AJavaRequestBuilder.java | 5 +- .../client/request/BuiltA2AJavaRequest.java | 5 +- .../runtime/DefaultSampleClientRuntime.java | 28 +- .../runtime/SampleClientRuntimeFactory.java | 2 - .../sample/service_recovery/package-info.java | 13 +- .../server/ServerSampleMain.java | 20 +- .../ServerSampleAgentCardBuilder.java | 56 +-- .../flow/NotificationPromptValidator.java | 4 +- .../flow/ServerFlowInterruptedException.java | 2 - .../flow/ServerSampleAgentExecutor.java | 7 +- .../server/flow/ServerSampleFlow.java | 43 +-- .../server/flow/ServerSleepController.java | 2 - .../server/http/EmbeddedA2AHttpServer.java | 10 +- .../service_recovery/server/package-info.java | 1 - .../registry/ServerRegistryResponse.java | 5 +- .../registry/ServerRegistryTransport.java | 2 - .../server/runtime/A2AJavaServerRuntime.java | 2 - .../runtime/DefaultSampleServerRuntime.java | 14 +- .../runtime/SampleServerRuntimeFactory.java | 2 - .../server/runtime/ServerBind.java | 5 +- .../server/runtime/ServerBootstrapResult.java | 9 +- .../endpoint/AgentEndpointResolver.java | 5 +- .../endpoint/ResolvedAgentEndpoint.java | 6 +- .../env/SampleEnvironmentPathResolver.java | 5 +- .../logging/SampleLoggingFormatter.java | 9 +- .../shared/mock/SampleLoggingLLMClient.java | 22 +- .../shared/mock/SampleMockLLMClient.java | 37 +- .../shared/mock/SampleMockLlmInstaller.java | 45 +-- .../service_recovery/shared/package-info.java | 1 - .../registry/RegistryAgentCardMapper.java | 71 ++-- .../stream/SampleStreamEventNormalizer.java | 5 +- .../client/ClientSampleMain.java | 14 +- .../client/ClientSampleMarker.java | 4 +- .../client/flow/A2AJavaClientEventMapper.java | 9 +- .../client/flow/ClientSampleFlow.java | 63 ++-- .../SampleStreamTerminalStateDecider.java | 5 +- .../client/package-info.java | 1 - .../client/prompt/SamplePromptClient.java | 2 - .../prompt/SamplePromptInputAdapter.java | 12 +- .../client/registry/SampleRegistryClient.java | 2 - .../client/request/A2AJavaRequestBuilder.java | 5 +- .../client/request/BuiltA2AJavaRequest.java | 5 +- .../client/runtime/A2AJavaClientRuntime.java | 2 - .../runtime/DefaultSampleClientRuntime.java | 31 +- .../client/runtime/SampleClientRuntime.java | 2 - .../runtime/SampleClientRuntimeFactory.java | 2 - .../subscribe_incident/package-info.java | 1 - .../server/ServerSampleMain.java | 25 +- .../server/ServerSampleMarker.java | 4 +- .../ServerSampleAgentCardBuilder.java | 58 ++-- .../server/flow/PromptComplianceChecker.java | 2 - .../flow/ServerFlowInterruptedException.java | 2 - .../flow/ServerSampleAgentExecutor.java | 9 +- .../server/flow/ServerSampleFlow.java | 14 +- .../server/flow/ServerSleepController.java | 2 - .../server/http/EmbeddedA2AHttpServer.java | 10 +- .../server/http/ServerSampleApplication.java | 5 +- .../server/package-info.java | 1 - .../registry/ServerRegistryResponse.java | 5 +- .../registry/ServerRegistryTransport.java | 2 - .../server/runtime/A2AJavaServerRuntime.java | 2 - .../runtime/DefaultSampleServerRuntime.java | 3 +- .../server/runtime/SampleServerRuntime.java | 2 - .../runtime/SampleServerRuntimeFactory.java | 2 - .../server/runtime/ServerBind.java | 5 +- .../server/runtime/ServerBootstrapResult.java | 9 +- .../shared/SharedSampleMarker.java | 4 +- .../shared/endpoint/AgentEndpointCache.java | 2 - .../endpoint/AgentEndpointResolver.java | 5 +- .../endpoint/ResolvedAgentEndpoint.java | 6 +- .../env/SampleEnvironmentPathResolver.java | 5 +- .../logging/SampleLoggingFormatter.java | 5 +- .../shared/mock/SampleLoggingLLMClient.java | 25 +- .../shared/mock/SampleMockLLMClient.java | 29 +- .../shared/mock/SampleMockLlmInstaller.java | 50 +-- .../shared/package-info.java | 1 - .../registry/RegistryAgentCardMapper.java | 71 ++-- .../shared/scenario/SampleScenarioLoader.java | 11 +- .../stream/SampleStreamEventNormalizer.java | 5 +- .../sample/task_t/TaskTAccuracyEvaluator.java | 63 ++-- .../a2at/sample/task_t/TaskTDemoMain.java | 8 +- .../a2at/sample/task_t/package-info.java | 2 +- .../NegotiationFacadeOutputSymmetryTest.java | 8 +- .../AuthzReasoningCaptureTest.java | 5 +- .../NegotiationFromDataApiEvalAppTest.java | 17 +- .../NegotiationEvaluationCaseLoaderTest.java | 77 +++-- .../NegotiationMetadataReaderTest.java | 46 ++- .../NegotiationSampleFlowTest.java | 12 +- ...egotiationEvaluationProcessLoggerTest.java | 7 +- .../DefaultSampleServerRuntimeTest.java | 3 +- .../shared/mock/SampleMockLLMClientTest.java | 2 +- .../task_t/TaskTAccuracyEvaluatorTest.java | 35 +- .../TaskTPrivateLineComplaintSamplesTest.java | 27 +- .../sample/task_t/TaskTSingleSampleTest.java | 156 +++------ 141 files changed, 1373 insertions(+), 1329 deletions(-) diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/SampleRunner.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/SampleRunner.java index 0822d94f..e211b2f0 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/SampleRunner.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/SampleRunner.java @@ -17,9 +17,8 @@ /** * Unified launcher for every sample in this module. * - *

        One command runs any sample end to end — start the server, wait until its HTTP endpoint is up, - * run the client, then stop the server. The server output is captured to a temp log file; the client - * output streams to the console. + *

        One command runs any sample end to end — start the server, wait until its HTTP endpoint is up, run the client, + * then stop the server. The server output is captured to a temp log file; the client output streams to the console. * *

        Usage from the repository root: * @@ -27,13 +26,11 @@ * java -cp @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner * * - *

        Each sample is discovered by convention from {@code src/main/resources/sample/} — a directory - * containing both {@code client/} and {@code server/} subdirectories is a runnable sample. The - * corresponding main classes are resolved as - * {@code net.openan.a2at.sample..client.ClientSampleMain} and - * {@code ...server.ServerSampleMain}, where {@code } is the directory name with - * dashes replaced by underscores. Adding a new sample needs no registration: drop the package and - * the resource directory, and the runner picks it up. + *

        Each sample is discovered by convention from {@code src/main/resources/sample/} — a directory containing both + * {@code client/} and {@code server/} subdirectories is a runnable sample. The corresponding main classes are resolved + * as {@code net.openan.a2at.sample..client.ClientSampleMain} and {@code ...server.ServerSampleMain}, + * where {@code } is the directory name with dashes replaced by underscores. Adding a new sample needs no + * registration: drop the package and the resource directory, and the runner picks it up. * *

        Environment variables: {@code A2AT_SAMPLE_MAX_ARTIFACTS} (passed through to the client) and * {@code A2AT_SAMPLE_LIST} (set to any value to only list samples). @@ -48,8 +45,7 @@ public final class SampleRunner { private static final long SERVER_STARTUP_TIMEOUT_SECONDS = 120; - private SampleRunner() { - } + private SampleRunner() {} /** * Runner entry point. @@ -63,7 +59,8 @@ public static void main(String[] args) throws Exception { System.exit(2); } if (args.length == 0 || "list".equalsIgnoreCase(args[0]) || System.getenv("A2AT_SAMPLE_LIST") != null) { - System.out.println("Available samples (run with: java @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner ):"); + System.out.println( + "Available samples (run with: java @a2a-t-sample/target/sample.args net.openan.a2at.sample.SampleRunner ):"); samples.forEach(name -> System.out.println(" - " + name)); return; } @@ -82,8 +79,16 @@ private static int runSample(String sample) throws Exception { String javaPackage = "net.openan.a2at.sample." + sample.replace('-', '_'); String serverMain = javaPackage + ".server.ServerSampleMain"; String clientMain = javaPackage + ".client.ClientSampleMain"; - String serverEnv = SAMPLES_ROOT.resolve(sample).resolve("server").resolve("server.env").toString(); - String clientEnv = SAMPLES_ROOT.resolve(sample).resolve("client").resolve("client.env").toString(); + String serverEnv = SAMPLES_ROOT + .resolve(sample) + .resolve("server") + .resolve("server.env") + .toString(); + String clientEnv = SAMPLES_ROOT + .resolve(sample) + .resolve("client") + .resolve("client.env") + .toString(); if (!Files.exists(Path.of(serverEnv)) || !Files.exists(Path.of(clientEnv))) { System.err.println("Sample '" + sample + "' is missing its env files:"); System.err.println(" " + serverEnv); @@ -104,22 +109,26 @@ private static int runSample(String sample) throws Exception { Path serverLog = Path.of("a2a-t-sample", "target", "sample-logs").resolve(sample + "-server.log"); Files.createDirectories(serverLog.getParent()); - System.out.println("[runner] starting server for sample '" + sample + "' (log: " + serverLog.toAbsolutePath() - + ")"); + System.out.println( + "[runner] starting server for sample '" + sample + "' (log: " + serverLog.toAbsolutePath() + ")"); Process server = new ProcessBuilder(javaCommand(classpath, serverMain, serverEnv)) .redirectErrorStream(true) .start(); // Kill the spawned processes when this runner is interrupted (Ctrl+C), so no orphaned // server keeps running and locking jars under target/. - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - server.destroyForcibly(); - System.out.println("[runner] shutdown: server stopped"); - }, "sample-runner-shutdown")); + Runtime.getRuntime() + .addShutdownHook(new Thread( + () -> { + server.destroyForcibly(); + System.out.println("[runner] shutdown: server stopped"); + }, + "sample-runner-shutdown")); Thread serverLogPumper = new Thread(() -> pumpServerLog(server, serverLog), "sample-runner-server-log"); serverLogPumper.setDaemon(true); serverLogPumper.start(); if (!waitForServer(serverPort, SERVER_STARTUP_TIMEOUT_SECONDS)) { - System.err.println("[runner] server did not start within " + SERVER_STARTUP_TIMEOUT_SECONDS + "s; server log tail:"); + System.err.println( + "[runner] server did not start within " + SERVER_STARTUP_TIMEOUT_SECONDS + "s; server log tail:"); tailLog(serverLog, 30); server.destroyForcibly(); return 2; @@ -130,10 +139,13 @@ private static int runSample(String sample) throws Exception { Process client = new ProcessBuilder(javaCommand(classpath, clientMain, clientEnv)) .redirectErrorStream(true) .start(); - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - client.destroyForcibly(); - System.out.println("[runner] shutdown: client stopped"); - }, "sample-runner-shutdown-client")); + Runtime.getRuntime() + .addShutdownHook(new Thread( + () -> { + client.destroyForcibly(); + System.out.println("[runner] shutdown: client stopped"); + }, + "sample-runner-shutdown-client")); Thread clientLogPumper = new Thread(() -> pumpLog(client, clientLog, "[client] "), "sample-runner-client-log"); clientLogPumper.setDaemon(true); clientLogPumper.start(); @@ -149,9 +161,7 @@ private static int runSample(String sample) throws Exception { return clientExit; } - /** - * Pumps the server process output to the console (prefixed) and to the log file concurrently. - */ + /** Pumps the server process output to the console (prefixed) and to the log file concurrently. */ private static void pumpServerLog(Process server, Path serverLog) { pumpLog(server, serverLog, "[server] "); } @@ -159,9 +169,9 @@ private static void pumpServerLog(Process server, Path serverLog) { /** * Pumps one child process output to the console (prefixed) and to the log file concurrently. * - *

        Both the reader and the log file use UTF-8; the child JVMs are launched with - * {@code stdout.encoding=UTF-8} so their console streams emit UTF-8 bytes even on Windows - * where the default console encoding is the ANSI code page (GBK). + *

        Both the reader and the log file use UTF-8; the child JVMs are launched with {@code stdout.encoding=UTF-8} so + * their console streams emit UTF-8 bytes even on Windows where the default console encoding is the ANSI code page + * (GBK). */ private static void pumpLog(Process process, Path logFile, String consolePrefix) { try (BufferedReader reader = @@ -217,8 +227,7 @@ private static List discoverSamples() { } try (Stream dirs = Files.list(SAMPLES_ROOT)) { return dirs.filter(Files::isDirectory) - .filter(dir -> Files.isDirectory(dir.resolve("client")) - && Files.isDirectory(dir.resolve("server"))) + .filter(dir -> Files.isDirectory(dir.resolve("client")) && Files.isDirectory(dir.resolve("server"))) .map(dir -> dir.getFileName().toString().toLowerCase(Locale.ROOT)) .sorted(Comparator.naturalOrder()) .toList(); @@ -238,7 +247,8 @@ private static int resolveServerPort(String clientEnv) { String line = rawLine.trim(); if (line.startsWith("A2AT_SAMPLE_PORT=")) { try { - return Integer.parseInt(line.substring("A2AT_SAMPLE_PORT=".length()).trim()); + return Integer.parseInt( + line.substring("A2AT_SAMPLE_PORT=".length()).trim()); } catch (NumberFormatException ignored) { // fall through to default } @@ -249,8 +259,8 @@ private static int resolveServerPort(String clientEnv) { private static boolean isPortOpen(int port) { try { - HttpURLConnection connection = - (HttpURLConnection) URI.create("http://127.0.0.1:" + port + "/").toURL().openConnection(); + HttpURLConnection connection = (HttpURLConnection) + URI.create("http://127.0.0.1:" + port + "/").toURL().openConnection(); connection.setConnectTimeout(1000); connection.setReadTimeout(1000); connection.setRequestMethod("GET"); @@ -266,8 +276,8 @@ private static boolean waitForServer(int port, long timeoutSeconds) throws Inter long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L; while (System.currentTimeMillis() < deadline) { try { - HttpURLConnection connection = - (HttpURLConnection) URI.create("http://127.0.0.1:" + port + "/").toURL().openConnection(); + HttpURLConnection connection = (HttpURLConnection) + URI.create("http://127.0.0.1:" + port + "/").toURL().openConnection(); connection.setConnectTimeout(1000); connection.setReadTimeout(1000); connection.setRequestMethod("GET"); @@ -286,9 +296,15 @@ private static boolean waitForServer(int port, long timeoutSeconds) throws Inter private static String javaExecutable() { String javaHome = System.getProperty("java.home"); - return Path.of(javaHome, "bin", System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win") - ? "java.exe" - : "java").toString(); + return Path.of( + javaHome, + "bin", + System.getProperty("os.name", "") + .toLowerCase(Locale.ROOT) + .contains("win") + ? "java.exe" + : "java") + .toString(); } private static void tailLog(Path log, int lineCount) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptGenerator.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptGenerator.java index 4a81edea..cad978dd 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptGenerator.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptGenerator.java @@ -11,4 +11,4 @@ public interface AuthzPromptGenerator { MetadataContent generate(AuthzScenario scenario); -} \ No newline at end of file +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java index af880b53..ba5b4c4d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java @@ -12,4 +12,4 @@ public interface AuthzPromptValidator { FilledParamData validate(String prompt, Map schema, String templateUri); -} \ No newline at end of file +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCapture.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCapture.java index a365fa60..86953ec4 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCapture.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCapture.java @@ -18,35 +18,32 @@ * Reasoning capture apparatus for the Authorization-T demo. * *

        When enabled ({@code -Dauthz.reasoning=true}), it decorates the underlying {@link LLMClient} to: + * *

          - *
        • append a short reasoning-instruction appendix to the {@code slot_extraction} and - * {@code content_validation} system prompts (identified by exact fingerprint match against the - * classpath {@code system.md} resources);
        • - *
        • extend the {@code content_validation} output schema with an optional {@code reasoning} - * property (so the instruction does not contradict {@code additionalProperties:false});
        • - *
        • best-effort parse the returned raw content for the {@code reasoning} field and record it, - * keyed by scenario label.
        • + *
        • append a short reasoning-instruction appendix to the {@code slot_extraction} and {@code content_validation} + * system prompts (identified by exact fingerprint match against the classpath {@code system.md} resources); + *
        • extend the {@code content_validation} output schema with an optional {@code reasoning} property (so the + * instruction does not contradict {@code additionalProperties:false}); + *
        • best-effort parse the returned raw content for the {@code reasoning} field and record it, keyed by scenario + * label. *
        * - *

        Unmatched stages (negotiation and other extensions) are passed through untouched with no capture. - * Captures are associated to scenarios through a {@link ThreadLocal} double-slot (label + entry list), - * which is correct because one executor task thread runs the complete client→server flow for one - * scenario. + *

        Unmatched stages (negotiation and other extensions) are passed through untouched with no capture. Captures are + * associated to scenarios through a {@link ThreadLocal} double-slot (label + entry list), which is correct because one + * executor task thread runs the complete client→server flow for one scenario. * * @since 2026-08 */ public final class AuthzReasoningCapture { - private static final String SLOT_EXTRACTION_SYSTEM = - "/prompt_resources/prompts/slot_extraction/zh-CN/system.md"; + private static final String SLOT_EXTRACTION_SYSTEM = "/prompt_resources/prompts/slot_extraction/zh-CN/system.md"; private static final String CONTENT_VALIDATION_SYSTEM = "/prompt_resources/prompts/content_validation/zh-CN/system.md"; - private static final String REASONING_APPENDIX = - "\n\n[reasoning_appendix]\n" - + "除上述要求的 JSON 字段外,请额外输出一个 \"reasoning\" 字段(字符串)," - + "简要说明本次提参/校验每个结论的依据与推理过程。" - + "该字段仅供调用方分析使用,不会影响对其他字段的判定与使用。"; + private static final String REASONING_APPENDIX = "\n\n[reasoning_appendix]\n" + + "除上述要求的 JSON 字段外,请额外输出一个 \"reasoning\" 字段(字符串)," + + "简要说明本次提参/校验每个结论的依据与推理过程。" + + "该字段仅供调用方分析使用,不会影响对其他字段的判定与使用。"; private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -64,8 +61,8 @@ public final class AuthzReasoningCapture { * Resolves the reasoning-capture flag from the {@code authz.reasoning} system property. * * @return {@code true} when reasoning capture is enabled, {@code false} by default - * @throws IllegalArgumentException if the property is set to a value other than {@code true} or - * {@code false} (case-insensitive) + * @throws IllegalArgumentException if the property is set to a value other than {@code true} or {@code false} + * (case-insensitive) * @since 2026-08 */ public static boolean resolveReasoningFlag() { @@ -123,8 +120,8 @@ public void beginScenario(String label) { } /** - * Ends capture for the current thread's scenario, merging the recorded stages into the registry - * (the last response per stage wins across infra retries) and clearing the thread-local state. + * Ends capture for the current thread's scenario, merging the recorded stages into the registry (the last response + * per stage wins across infra retries) and clearing the thread-local state. * * @since 2026-08 */ @@ -158,8 +155,7 @@ public Optional capture(String label) { * @param serverRaw raw LLM response content from the content_validation stage, or {@code null} * @since 2026-08 */ - public record StageCapture( - String clientReasoning, String serverReasoning, String clientRaw, String serverRaw) {} + public record StageCapture(String clientReasoning, String serverReasoning, String clientRaw, String serverRaw) {} private enum Stage { SLOT_EXTRACTION, @@ -256,4 +252,4 @@ private static String readResource(String path) { throw new IllegalStateException("Failed to read resource: " + path, e); } } -} \ No newline at end of file +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java index e4606506..d17da791 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java @@ -246,7 +246,8 @@ static AuthzPromptValidator buildValidator(Path envPath, AuthzReasoningCapture c .config(config) .llmClient(decorated); ContentValidator contentValidator = builder.buildAuthContentValidator(); - return (prompt, schema, templateUri) -> contentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + return (prompt, schema, templateUri) -> + contentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); } /** Parses a template URI string for the typed orchestrator/content-validator seams. */ diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoader.java index 85fc22d0..e2c1c767 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoader.java @@ -15,9 +15,8 @@ * Loads and validates demo scenarios from a JSON resource file. * *

        The JSON file must contain a top-level {@code scenarios} array. Each scenario entry carries a staged - * {@code expected} object: {@code expected.client} with {@code outcome} (generation failure) or - * {@code promptText} (successful generation), and {@code expected.server} with {@code outcome}, - * {@code slot_errors} and {@code params}. + * {@code expected} object: {@code expected.client} with {@code outcome} (generation failure) or {@code promptText} + * (successful generation), and {@code expected.server} with {@code outcome}, {@code slot_errors} and {@code params}. * * @since 2026-08 */ @@ -47,9 +46,8 @@ public static List load(String resourcePath) { throw new IllegalStateException("'scenarios' must be an array in: " + resourcePath); } - List scenarios = rawList.stream() - .map(item -> parseScenario(item, resourcePath)) - .toList(); + List scenarios = + rawList.stream().map(item -> parseScenario(item, resourcePath)).toList(); for (AuthzScenario scenario : scenarios) { try { @@ -96,13 +94,10 @@ private static AuthzScenario parseScenario(Object item, String resourcePath) { Object serverObj = expectedRaw.get("server"); if (serverObj instanceof Map serverRaw) { @SuppressWarnings("unchecked") - Map params = serverRaw.get("params") instanceof Map paramsRaw - ? (Map) paramsRaw - : null; + Map params = + serverRaw.get("params") instanceof Map paramsRaw ? (Map) paramsRaw : null; server = new ServerExpected( - asNullableString(serverRaw.get("outcome")), - parseSlotErrors(serverRaw.get("slot_errors")), - params); + asNullableString(serverRaw.get("outcome")), parseSlotErrors(serverRaw.get("slot_errors")), params); } Object validateSchemaObj = raw.get("validate_schema"); Map validateSchema = null; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/package-info.java index b3951cfa..5e574ae1 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/package-info.java @@ -3,8 +3,8 @@ * *

        The demo loads scenarios from a JSON configuration file, generates authorization prompts through the * {@code A2ATClient} facade, validates them through the {@code A2ATServer} facade, and reports - * {@code match}/{@code mismatch} results with a non-zero exit code when any scenario does not match - * its expected outcome. + * {@code match}/{@code mismatch} results with a non-zero exit code when any scenario does not match its expected + * outcome. * *

        Layout: * diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java index 99fce636..28c9e4e4 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java @@ -137,7 +137,10 @@ public Map runFourMessageFlow(NegotiationServerRuntime serverRun MetadataContent acceptPrompt = strategy.generateAccept( client, new NegotiationContext( - UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.ACCEPT), + UUID.randomUUID().toString(), + 1, + NegotiationContext.DEFAULT_MAX_ROUNDS, + NegotiationPerformative.ACCEPT), itemsFromFilledParams(), DemoConstants.NEGOTIATION_ACCEPT); emit("[client] Negotiation-T accept rendered"); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/EvalLlmCaptureClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/EvalLlmCaptureClient.java index e135ac7a..3617e2d8 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/EvalLlmCaptureClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/EvalLlmCaptureClient.java @@ -13,17 +13,17 @@ import net.openan.a2at.sdk.llm.LLMResponse; /** - * LLM client wrapper for the evaluator: records every structured call — the full request (messages, JSON - * schema, temperature, max tokens) and the response (content, model, usage) — into an in-memory buffer so - * the report can attach the exact prompts fed to the model to each step. + * LLM client wrapper for the evaluator: records every structured call — the full request (messages, JSON schema, + * temperature, max tokens) and the response (content, model, usage) — into an in-memory buffer so the report can attach + * the exact prompts fed to the model to each step. * - *

        When a case fails, the recorded prompts show precisely which system prompt, slot description or schema - * entry the model saw, which is what drives reverse tuning of the prompt resources: a rejected or - * mis-extracted case can be traced to the exact prompt text instead of being guessed at. + *

        When a case fails, the recorded prompts show precisely which system prompt, slot description or schema entry the + * model saw, which is what drives reverse tuning of the prompt resources: a rejected or mis-extracted case can be + * traced to the exact prompt text instead of being guessed at. * - *

        Installed into {@link LLMClientFactory} by replacing the {@code openai} entry — the same reflective - * mechanism {@code SampleMockLlmInstaller.installLlmLogger} uses. Delegates to - * {@link SampleLoggingLLMClient} so the live console logging keeps working unchanged. + *

        Installed into {@link LLMClientFactory} by replacing the {@code openai} entry — the same reflective mechanism + * {@code SampleMockLlmInstaller.installLlmLogger} uses. Delegates to {@link SampleLoggingLLMClient} so the live console + * logging keeps working unchanged. * * @since 2026-08 */ @@ -45,8 +45,8 @@ public EvalLlmCaptureClient(LLMClientConfig config) { /** * Installs this client as the {@code openai} provider and keeps the console logging configured. * - *

        Must be called once at evaluator startup, before any {@code A2ATClient} or {@code A2ATServer} is - * constructed, so that every structured call of the run is captured. + *

        Must be called once at evaluator startup, before any {@code A2ATClient} or {@code A2ATServer} is constructed, + * so that every structured call of the run is captured. */ public static synchronized void install() { try { @@ -80,10 +80,7 @@ public static List> drain() { @Override public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { + List> messages, Map jsonSchema, Double temperature, Integer maxTokens) { long nanos = System.nanoTime(); try { LLMResponse response = delegate.structured(messages, jsonSchema, temperature, maxTokens); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java index 31e43414..8ae8750a 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java @@ -408,10 +408,14 @@ proposeContext, new InformationProposeContent(missingItems, proposeRelationship) // -- step 4: outbound validation of the generated propose -- Map proposeValidationInput = Map.of( - "prompt", proposePrompt, - "context", contextJson(proposeContext), - "schema", negotiationSchema(actualMissing, taskSchema), - "template_uri", DemoTemplates.NEGOTIATION_PROPOSE); + "prompt", + proposePrompt, + "context", + contextJson(proposeContext), + "schema", + negotiationSchema(actualMissing, taskSchema), + "template_uri", + DemoTemplates.NEGOTIATION_PROPOSE); Boolean proposeValid = null; try { long nanos = System.nanoTime(); @@ -478,10 +482,14 @@ proposeContext, new InformationProposeContent(missingItems, proposeRelationship) apiCall( "A2ATClient.validateProposePromptAndDataFilling", Map.of( - "prompt", proposePrompt, - "context", contextJson(proposeContext), - "schema", negotiationSchema(actualMissing, taskSchema), - "template_uri", DemoTemplates.NEGOTIATION_PROPOSE))); + "prompt", + proposePrompt, + "context", + contextJson(proposeContext), + "schema", + negotiationSchema(actualMissing, taskSchema), + "template_uri", + DemoTemplates.NEGOTIATION_PROPOSE))); step.put("client_requested_slots", new ArrayList<>(clientRequestedSlots)); steps.add(step); emit("[eval] [5] client extracted requested slots: " + clientRequestedSlots + " (" + secs(nanos) + "s)"); @@ -501,10 +509,14 @@ proposeContext, new InformationProposeContent(missingItems, proposeRelationship) apiCall( "A2ATClient.validateProposePromptAndDataFilling", Map.of( - "prompt", proposePrompt, - "context", contextJson(proposeContext), - "schema", negotiationSchema(actualMissing, taskSchema), - "template_uri", DemoTemplates.NEGOTIATION_PROPOSE))); + "prompt", + proposePrompt, + "context", + contextJson(proposeContext), + "schema", + negotiationSchema(actualMissing, taskSchema), + "template_uri", + DemoTemplates.NEGOTIATION_PROPOSE))); step.put("client_requested_slots", List.of()); steps.add(step); emit("[eval] [5] client propose validation REJECTED: " + error.getCode() + " " + error.getMessage() @@ -631,10 +643,14 @@ proposeContext, new InformationProposeContent(missingItems, proposeRelationship) // -- step 7: inbound validation of the accept message; extracted values must match the client fill -- Map acceptValidationInput = Map.of( - "prompt", acceptPrompt, - "context", contextJson(acceptContext), - "schema", negotiationSchema(slotsToFill, taskSchema), - "template_uri", DemoTemplates.NEGOTIATION_ACCEPT); + "prompt", + acceptPrompt, + "context", + contextJson(acceptContext), + "schema", + negotiationSchema(slotsToFill, taskSchema), + "template_uri", + DemoTemplates.NEGOTIATION_ACCEPT); Boolean acceptValid = null; Boolean fillValueMatch = null; Map extractedAcceptParams = null; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java index 8ae4ddb1..a211db4e 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java @@ -38,22 +38,21 @@ * Focused capability evaluator for the six Negotiation-T fromData interfaces: * *

          - *
        • generation - {@code generateNegotiationProposePromptFromData}, - * {@code generateNegotiationAcceptPromptFromData}, {@code generateNegotiationRejectPromptFromData}; + *
        • generation - {@code generateNegotiationProposePromptFromData}, {@code generateNegotiationAcceptPromptFromData}, + * {@code generateNegotiationRejectPromptFromData}; *
        • validation and parameter extraction - {@code validateProposePromptAndDataFilling}, * {@code validateAcceptPromptAndDataFilling}, {@code validateRejectPromptAndDataFilling}. *
        * - *

        Each case is one generate-validate round trip over pure structured data: every item is one key (the - * Task-T template slot name from the private-line-complaint scenario) carrying an atomic value - no - * natural-language passages, no template-error scenarios. The generated message is validated through the - * matching validate interface (propose validated client-side, accept/reject server-side - the receiving - * role of the protocol), and the extracted parameters must carry back exactly the input key-value pairs. + *

        Each case is one generate-validate round trip over pure structured data: every item is one key (the Task-T + * template slot name from the private-line-complaint scenario) carrying an atomic value - no natural-language passages, + * no template-error scenarios. The generated message is validated through the matching validate interface (propose + * validated client-side, accept/reject server-side - the receiving role of the protocol), and the extracted parameters + * must carry back exactly the input key-value pairs. * - *

        Generation is deterministic rendering (no LLM call); validation runs the SDK negotiation semantic - * pipeline (one LLM call), so a real API key is needed and each validation is recorded as {@code llm_calls} - * evidence in the report. The case matrix follows the Task-T field model (required: - * 任务对象/投诉分类/OSS侧事件流水号; optional: 问题发生时间/投诉详情). + *

        Generation is deterministic rendering (no LLM call); validation runs the SDK negotiation semantic pipeline (one + * LLM call), so a real API key is needed and each validation is recorded as {@code llm_calls} evidence in the report. + * The case matrix follows the Task-T field model (required: 任务对象/投诉分类/OSS侧事件流水号; optional: 问题发生时间/投诉详情). * *

        Usage: * @@ -133,16 +132,14 @@ private static Map runCase(Path envPath, Map tes boolean expectSuccess = Boolean.TRUE.equals(expect.get("succeeds")); Map itemsInput = asMap(input.get("items")); - String relationship = input.containsKey("relationship") - ? String.valueOf(input.get("relationship")) - : null; + String relationship = input.containsKey("relationship") ? String.valueOf(input.get("relationship")) : null; String template = resolveTemplate(api, input); NegotiationPerformative performative = "propose".equals(api) ? NegotiationPerformative.PROPOSE : "accept".equals(api) ? NegotiationPerformative.ACCEPT : NegotiationPerformative.REJECT; - NegotiationContext context = - new NegotiationContext(UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, performative); + NegotiationContext context = new NegotiationContext( + UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, performative); List items = new ArrayList<>(); for (Map.Entry entry : itemsInput.entrySet()) { items.add(new NegotiationItem(entry.getKey(), String.valueOf(entry.getValue()))); @@ -150,12 +147,10 @@ private static Map runCase(Path envPath, Map tes String generateMethod = "propose".equals(api) ? "A2ATServer.generateNegotiationProposePromptFromData" - : "A2ATClient.generateNegotiation" - + ("accept".equals(api) ? "Accept" : "Reject") + "PromptFromData"; + : "A2ATClient.generateNegotiation" + ("accept".equals(api) ? "Accept" : "Reject") + "PromptFromData"; String validateMethod = "propose".equals(api) ? "A2ATClient.validateProposePromptAndDataFilling" - : "A2ATServer.validate" - + ("accept".equals(api) ? "Accept" : "Reject") + "PromptAndDataFilling"; + : "A2ATServer.validate" + ("accept".equals(api) ? "Accept" : "Reject") + "PromptAndDataFilling"; // the receiving role validates: the client validates the server's propose, the server validates the // client's accept/reject - exactly the role assignment of the negotiation protocol String generateRole = "propose".equals(api) ? "server" : "client"; @@ -184,20 +179,27 @@ private static Map runCase(Path envPath, Map tes api(generateStep, apiCall(generateMethod, generateInput)); String prompt = null; try { - MetadataContent content = switch (api) { - case "propose" -> new A2ATServer(envPath).generateNegotiationProposePromptFromData( - new NegotiationProposeData( - context, new InformationProposeContent(items, relationship)), template); - case "accept" -> new A2ATClient(envPath).generateNegotiationAcceptPromptFromData( - new NegotiationEndingData( - context, new InformationEndingContent(NegotiationConclusion.ACCEPT, items)), - template); - case "reject" -> new A2ATClient(envPath).generateNegotiationRejectPromptFromData( - new NegotiationEndingData( - context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), - template); - default -> throw new IllegalArgumentException("Unknown api: " + api); - }; + MetadataContent content = + switch (api) { + case "propose" -> new A2ATServer(envPath) + .generateNegotiationProposePromptFromData( + new NegotiationProposeData( + context, new InformationProposeContent(items, relationship)), + template); + case "accept" -> new A2ATClient(envPath) + .generateNegotiationAcceptPromptFromData( + new NegotiationEndingData( + context, + new InformationEndingContent(NegotiationConclusion.ACCEPT, items)), + template); + case "reject" -> new A2ATClient(envPath) + .generateNegotiationRejectPromptFromData( + new NegotiationEndingData( + context, + new InformationEndingContent(NegotiationConclusion.REJECT, items)), + template); + default -> throw new IllegalArgumentException("Unknown api: " + api); + }; prompt = content.promptText(); generateStep.put("generated_prompt", prompt); generateStep.put("template_uri", content.templateUri()); @@ -209,9 +211,13 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), if (expectSuccess) { String normalized = normalize(prompt); for (Map.Entry entry : itemsInput.entrySet()) { - check(checks, "prompt contains item name: " + entry.getKey(), + check( + checks, + "prompt contains item name: " + entry.getKey(), normalized.contains(normalize(entry.getKey()))); - check(checks, "prompt contains item value: " + entry.getKey(), + check( + checks, + "prompt contains item value: " + entry.getKey(), normalized.contains(normalize(String.valueOf(entry.getValue())))); } if (input.containsKey("relationship") && !relationship.isBlank()) { @@ -221,13 +227,19 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), check(checks, "prompt contains marker: " + marker, normalized.contains(normalize(marker))); } if (expect.get("template") != null) { - check(checks, "template uri matches", + check( + checks, + "template uri matches", String.valueOf(expect.get("template")).equals(content.templateUri())); } // the wire context carries the performative of the emitted message and keeps the session identity - check(checks, "emitted context performative matches the api", + check( + checks, + "emitted context performative matches the api", emitted != null && performative.equals(emitted.performative())); - check(checks, "emitted context keeps the session id", + check( + checks, + "emitted context keeps the session id", emitted != null && context.id().equals(emitted.id())); } else { check(checks, "expected generation failure but succeeded", false); @@ -235,7 +247,11 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), } catch (RuntimeException error) { generateStep.put("error", errorJson(error)); if (expectSuccess) { - check(checks, "expected generation success but failed: " + error.getClass().getSimpleName(), false); + check( + checks, + "expected generation success but failed: " + + error.getClass().getSimpleName(), + false); } else { check(checks, "generation failed as expected", true); } @@ -256,17 +272,17 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), Map validateStep = step("2. validate + extract " + api + " (fromData)", validateRole); api(validateStep, apiCall(validateMethod, validateInput)); try { - FilledParamData params = switch (api) { - case "propose" -> new A2ATClient(envPath).validateProposePromptAndDataFilling( - prompt, context, extractionSchema, template); - case "accept" -> new A2ATServer(envPath).validateAcceptPromptAndDataFilling( - prompt, context, extractionSchema, template); - case "reject" -> new A2ATServer(envPath).validateRejectPromptAndDataFilling( - prompt, context, extractionSchema, template); - default -> throw new IllegalArgumentException("Unknown api: " + api); - }; - Map extracted = - params.data() == null ? Map.of() : new LinkedHashMap<>(params.data()); + FilledParamData params = + switch (api) { + case "propose" -> new A2ATClient(envPath) + .validateProposePromptAndDataFilling(prompt, context, extractionSchema, template); + case "accept" -> new A2ATServer(envPath) + .validateAcceptPromptAndDataFilling(prompt, context, extractionSchema, template); + case "reject" -> new A2ATServer(envPath) + .validateRejectPromptAndDataFilling(prompt, context, extractionSchema, template); + default -> throw new IllegalArgumentException("Unknown api: " + api); + }; + Map extracted = params.data() == null ? Map.of() : new LinkedHashMap<>(params.data()); Map extractedJson = new LinkedHashMap<>(); for (Map.Entry entry : extracted.entrySet()) { if (!CONTEXT_KEYS.contains(entry.getKey())) { @@ -307,9 +323,9 @@ context, new InformationEndingContent(NegotiationConclusion.REJECT, items)), } /** - * Caller-owned extraction schema, shared with the fromText samples: the same information-negotiation - * contracts work for both generation paths because the validate*AndDataFilling interfaces and the rendered - * wire format are identical. + * Caller-owned extraction schema, shared with the fromText samples: the same information-negotiation contracts work + * for both generation paths because the validate*AndDataFilling interfaces and the rendered wire format are + * identical. */ static Map extractionSchema(String api) { return switch (api) { @@ -321,8 +337,8 @@ static Map extractionSchema(String api) { } /** - * Asserts that semantic extraction returns exactly the negotiated business field names, and that - * accept/reject payloads transport the input item text (supplied value / reason). + * Asserts that semantic extraction returns exactly the negotiated business field names, and that accept/reject + * payloads transport the input item text (supplied value / reason). */ private static void checkExtractedFields( List> checks, @@ -335,21 +351,29 @@ private static void checkExtractedFields( for (Map item : extractedItems) { extractedNames.add(String.valueOf(item.get("name"))); } - check(checks, "extracted field names match the negotiated items", + check( + checks, + "extracted field names match the negotiated items", extractedNames.equals(new TreeSet<>(itemsInput.keySet()))); if ("propose".equals(api) && input.containsKey("relationship")) { String relationship = String.valueOf(input.get("relationship")); - check(checks, "extracted relationship matches input", + check( + checks, + "extracted relationship matches input", valueMatches(extractedJson.get("relationship"), relationship)); } else { - check(checks, "relationship is absent for a single field or ending message", + check( + checks, + "relationship is absent for a single field or ending message", extractedJson.get("relationship") == null); } if ("accept".equals(api) || "reject".equals(api)) { String payloadKey = "accept".equals(api) ? "value" : "reason"; for (Map item : extractedItems) { String name = String.valueOf(item.get("name")); - check(checks, "extracted " + payloadKey + " matches input item: " + name, + check( + checks, + "extracted " + payloadKey + " matches input item: " + name, valueMatches(item.get(payloadKey), String.valueOf(itemsInput.get(name)))); } } @@ -362,8 +386,7 @@ private static boolean valueMatches(Object extracted, String input) { } String extractedText = normalize(String.valueOf(extracted)); String inputText = normalize(input); - return !extractedText.isEmpty() - && (extractedText.contains(inputText) || inputText.contains(extractedText)); + return !extractedText.isEmpty() && (extractedText.contains(inputText) || inputText.contains(extractedText)); } private static Map errorJson(RuntimeException error) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java index 42f6054d..c63f8a55 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java @@ -9,8 +9,8 @@ * *

        The text layout comes from the {@code diagnosis} templates of the scenario configuration ({@code result_line}, * {@code detail_line} with a {@code {params}} placeholder, {@code advice_line}); the parameter values themselves are - * taken from what {@code validateTaskPromptAndDataFilling} extracted, so the result adapts to any Task-T input. In a real - * deployment this service would call the EMS/NMS north-bound API instead of rendering text. + * taken from what {@code validateTaskPromptAndDataFilling} extracted, so the result adapts to any Task-T input. In a + * real deployment this service would call the EMS/NMS north-bound API instead of rendering text. * * @since 2026-08 */ diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java index 30934294..c09f1dff 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java @@ -11,10 +11,10 @@ import net.openan.a2at.sample.negotiation.shared.ScenarioData; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.ContentValidationException; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.server.A2ATServer; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; @@ -120,13 +120,15 @@ private void handleMissingParams( Map replyContext = negotiationContext; // dynamically generate the Negotiation-T information-propose prompt via the strategy - String relationship = missingItems.size() > 1 - ? ScenarioData.negotiationPhrasing().get("propose_relationship") - : null; + String relationship = + missingItems.size() > 1 ? ScenarioData.negotiationPhrasing().get("propose_relationship") : null; MetadataContent negotiationPrompt = strategy.generatePropose( server, new NegotiationContext( - UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.PROPOSE), + UUID.randomUUID().toString(), + 1, + NegotiationContext.DEFAULT_MAX_ROUNDS, + NegotiationPerformative.PROPOSE), missingItems, relationship, DemoConstants.NEGOTIATION_PROPOSE); @@ -151,7 +153,10 @@ private void handleFilledParams( MetadataContent acceptPrompt = strategy.generateAcceptServer( server, new NegotiationContext( - UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.ACCEPT), + UUID.randomUUID().toString(), + 1, + NegotiationContext.DEFAULT_MAX_ROUNDS, + NegotiationPerformative.ACCEPT), filledItems, DemoConstants.NEGOTIATION_ACCEPT); emit("[server] negotiation accept rendered"); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java index 2e035ee9..99cbaa27 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java @@ -3,10 +3,10 @@ import java.util.List; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationItem; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/InformationNegotiationSchemas.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/InformationNegotiationSchemas.java index 77d80edf..a7b1842c 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/InformationNegotiationSchemas.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/InformationNegotiationSchemas.java @@ -8,12 +8,12 @@ /** * Business-neutral, phase-specific caller schemas for information-negotiation validation. * - *

        The three validation APIs intentionally use independent parameter contracts. The contracts describe the - * shape of the extracted result while the information-item names and values come from the negotiation message. + *

        The three validation APIs intentionally use independent parameter contracts. The contracts describe the shape of + * the extracted result while the information-item names and values come from the negotiation message. * *

        Shared by every information-negotiation sample path — fromText (natural-language generation) and fromData - * (typed-record generation) — because the validate*AndDataFilling interfaces and the rendered wire format are - * identical for both. + * (typed-record generation) — because the validate*AndDataFilling interfaces and the rendered wire format are identical + * for both. */ public final class InformationNegotiationSchemas { @@ -23,8 +23,7 @@ public final class InformationNegotiationSchemas { private static final Map REJECT = createRejectSchema(); - private InformationNegotiationSchemas() { - } + private InformationNegotiationSchemas() {} public static Map propose() { return PROPOSE; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java index 8b0d8e06..08844146 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java @@ -11,8 +11,8 @@ * Strategy abstraction for generating negotiation messages, isolating the fromData (rule-based) vs fromText (LLM) * difference behind a single interface. * - *

        Everything else in the demo — Task-T generation, {@code validateTaskPromptAndDataFilling} parameter validation, the - * negotiation state machine — is identical regardless of which strategy is active. Only the negotiation prompt + *

        Everything else in the demo — Task-T generation, {@code validateTaskPromptAndDataFilling} parameter validation, + * the negotiation state machine — is identical regardless of which strategy is active. Only the negotiation prompt * generation step differs: fromData builds typed records and calls the deterministic API; fromText passes natural * language and lets the SDK's LLM extraction step parse it. * diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java index 1bff74a1..64eb8fcd 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java @@ -12,8 +12,8 @@ * *

        The Java code of the sample contains only generic assembly rules (how to iterate slots, how to merge metadata); * every scenario-specific value lives in the JSON file: the Task-T slot schema, the missing/filled parameter maps - * driving the 4-message flow and the negotiation phrasing templates. Changing the scenario means editing the JSON — - * no Java recompilation — and the whole flow, from slot extraction to the diagnosis text, adapts to the new inputs. + * driving the 4-message flow and the negotiation phrasing templates. Changing the scenario means editing the JSON — no + * Java recompilation — and the whole flow, from slot extraction to the diagnosis text, adapts to the new inputs. * * @since 2026-08 */ diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java index dc576d3d..7c551f90 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java @@ -28,13 +28,10 @@ /** Sends one A2A information-negotiation request and validates its terminal Artifact. */ public final class NegotiationClientMain { - private NegotiationClientMain() { - } + private NegotiationClientMain() {} public static void main(String[] args) { - Path envPath = args.length == 0 - ? NegotiationSampleEnvironment.defaultEnvPath("client") - : Path.of(args[0]); + Path envPath = args.length == 0 ? NegotiationSampleEnvironment.defaultEnvPath("client") : Path.of(args[0]); Map env = NegotiationSampleEnvironment.read(envPath); if (NegotiationMockLlmInstaller.PROVIDER.equals(env.get("A2AT_LLM_PROVIDER"))) { NegotiationMockLlmInstaller.install(); @@ -72,16 +69,16 @@ public static void main(String[] args) { NegotiationContext endingContext = NegotiationMetadataReader.readContext(endingMetadata); Map result = decision == NegotiationDecision.ACCEPT ? client.validateAcceptPromptAndDataFilling( - prompt, - endingContext, - InformationNegotiationSchemas.accept(), - NegotiationSampleFlow.ENDING_TEMPLATE_URI) + prompt, + endingContext, + InformationNegotiationSchemas.accept(), + NegotiationSampleFlow.ENDING_TEMPLATE_URI) .data() : client.validateRejectPromptAndDataFilling( - prompt, - endingContext, - InformationNegotiationSchemas.reject(), - NegotiationSampleFlow.ENDING_TEMPLATE_URI) + prompt, + endingContext, + InformationNegotiationSchemas.reject(), + NegotiationSampleFlow.ENDING_TEMPLATE_URI) .data(); System.out.println("[negotiation-client] result=" + result); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCase.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCase.java index 23d1113a..72d3b9d1 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCase.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCase.java @@ -9,13 +9,8 @@ public record NegotiationAbnormalEvaluationCase( String input, String template, String performative, - @JsonProperty("expected_exception") - String expectedException, - @JsonProperty("expected_code") - String expectedCode, - @JsonProperty("context_mode") - String contextMode, - @JsonProperty("schema_mode") - String schemaMode, - String description) { -} + @JsonProperty("expected_exception") String expectedException, + @JsonProperty("expected_code") String expectedCode, + @JsonProperty("context_mode") String contextMode, + @JsonProperty("schema_mode") String schemaMode, + String description) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCaseLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCaseLoader.java index 612e5fb2..6656eb09 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCaseLoader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCaseLoader.java @@ -14,18 +14,15 @@ public final class NegotiationAbnormalEvaluationCaseLoader { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private NegotiationAbnormalEvaluationCaseLoader() { - } + private NegotiationAbnormalEvaluationCaseLoader() {} public static List load() { - try (InputStream input = NegotiationAbnormalEvaluationCaseLoader.class - .getClassLoader() - .getResourceAsStream(RESOURCE_PATH)) { + try (InputStream input = + NegotiationAbnormalEvaluationCaseLoader.class.getClassLoader().getResourceAsStream(RESOURCE_PATH)) { if (input == null) { throw new IllegalStateException("Abnormal negotiation cases not found: " + RESOURCE_PATH); } - return List.copyOf(OBJECT_MAPPER.readValue(input, new TypeReference<>() { - })); + return List.copyOf(OBJECT_MAPPER.readValue(input, new TypeReference<>() {})); } catch (IOException exception) { throw new IllegalStateException("Failed to read abnormal negotiation cases", exception); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCaseLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCaseLoader.java index ce13fd72..6f8f9dfa 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCaseLoader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCaseLoader.java @@ -12,8 +12,7 @@ final class NegotiationBusinessAbnormalEvaluationCaseLoader { private static final String RESOURCE = "/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json"; - private NegotiationBusinessAbnormalEvaluationCaseLoader() { - } + private NegotiationBusinessAbnormalEvaluationCaseLoader() {} static List load() { ObjectMapper mapper = new ObjectMapper(); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java index 74fa212f..b4ac3fd1 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java @@ -29,8 +29,7 @@ public List dataItems() { if (raw instanceof List entries) { for (Object entry : entries) { if (entry instanceof Map item) { - items.add(new NegotiationItem( - String.valueOf(item.get("name")), String.valueOf(item.get("value")))); + items.add(new NegotiationItem(String.valueOf(item.get("name")), String.valueOf(item.get("value")))); } } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java index 18b1cbfa..2e1e7cf1 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java @@ -6,8 +6,8 @@ import java.io.InputStream; import java.util.LinkedHashSet; import java.util.List; -import java.util.Set; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -16,27 +16,24 @@ public final class NegotiationEvaluationCaseLoader { public static final String RESOURCE_PATH = "sample/private-line-complaint-negotiation/evaluation/cases.json"; /** - * A representative 20-case set for routine smoke validation. It includes every generation and - * validation/filling phase, and spans common, short, contextual, mixed-language, noisy, and - * business-oriented expressions. + * A representative 20-case set for routine smoke validation. It includes every generation and validation/filling + * phase, and spans common, short, contextual, mixed-language, noisy, and business-oriented expressions. */ public static final List SMOKE_CASE_IDS = List.of( - "P01", "P05", "P11", "P14", "P16", "P21", "P27", - "A01", "A05", "A11", "A16", "A21", "A28", "A33", - "R01", "R05", "R11", "R16", "R21", "R27"); + "P01", "P05", "P11", "P14", "P16", "P21", "P27", "A01", "A05", "A11", "A16", "A21", "A28", "A33", "R01", + "R05", "R11", "R16", "R21", "R27"); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private NegotiationEvaluationCaseLoader() { - } + private NegotiationEvaluationCaseLoader() {} public static List load() { - try (InputStream input = NegotiationEvaluationCaseLoader.class.getClassLoader().getResourceAsStream(RESOURCE_PATH)) { + try (InputStream input = + NegotiationEvaluationCaseLoader.class.getClassLoader().getResourceAsStream(RESOURCE_PATH)) { if (input == null) { throw new IllegalStateException("Negotiation evaluation corpus not found: " + RESOURCE_PATH); } - List cases = OBJECT_MAPPER.readValue(input, new TypeReference<>() { - }); + List cases = OBJECT_MAPPER.readValue(input, new TypeReference<>() {}); if (cases.size() != 100) { throw new IllegalStateException("Negotiation evaluation corpus must contain exactly 100 cases"); } @@ -60,9 +57,8 @@ public static List loadSelected(List caseIds) if (requested.size() != caseIds.size()) { throw new IllegalArgumentException("Duplicate negotiation evaluation case IDs are not allowed: " + caseIds); } - var casesById = load().stream().collect(java.util.stream.Collectors.toMap( - NegotiationEvaluationCase::id, - testCase -> testCase)); + var casesById = load().stream() + .collect(java.util.stream.Collectors.toMap(NegotiationEvaluationCase::id, testCase -> testCase)); Set unknown = new LinkedHashSet<>(requested); unknown.removeAll(casesById.keySet()); if (!unknown.isEmpty()) { @@ -77,11 +73,10 @@ public static List loadSmoke() { } /** - * Assembles every labelled input into a complete propose-to-decision flow. A propose-labelled - * input drives the first generation step and is paired with a deterministic ending case; an - * accept/reject-labelled input drives the ending step and is paired with a deterministic - * propose case. This preserves the original 100 IDs while exercising two matching API pairs - * in every run. + * Assembles every labelled input into a complete propose-to-decision flow. A propose-labelled input drives the + * first generation step and is paired with a deterministic ending case; an accept/reject-labelled input drives the + * ending step and is paired with a deterministic propose case. This preserves the original 100 IDs while exercising + * two matching API pairs in every run. */ public static List loadFlows() { List cases = load(); @@ -97,8 +92,8 @@ public static List loadSelectedFlows(List if (caseIds.isEmpty()) { throw new IllegalArgumentException("At least one evaluation case ID is required"); } - Map flowsById = loadFlows().stream().collect(Collectors.toMap( - NegotiationEvaluationFlowCase::id, Function.identity())); + Map flowsById = + loadFlows().stream().collect(Collectors.toMap(NegotiationEvaluationFlowCase::id, Function.identity())); Set requested = new LinkedHashSet<>(caseIds); if (requested.size() != caseIds.size()) { throw new IllegalArgumentException("Duplicate negotiation evaluation case IDs are not allowed: " + caseIds); @@ -125,11 +120,9 @@ private static NegotiationEvaluationFlowCase toFlow( List proposeCases, List acceptCases, List rejectCases) { - NegotiationEvaluationCase propose = "propose".equals(source.phase()) - ? source - : proposeCases.get(index % proposeCases.size()); - boolean accept = "accept".equals(source.phase()) - || ("propose".equals(source.phase()) && index % 2 == 0); + NegotiationEvaluationCase propose = + "propose".equals(source.phase()) ? source : proposeCases.get(index % proposeCases.size()); + boolean accept = "accept".equals(source.phase()) || ("propose".equals(source.phase()) && index % 2 == 0); NegotiationEvaluationCase ending = "propose".equals(source.phase()) ? (accept ? acceptCases : rejectCases).get(index % (accept ? acceptCases.size() : rejectCases.size())) : source; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLogger.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLogger.java index 26e49364..d4ad0f32 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLogger.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLogger.java @@ -10,9 +10,7 @@ import java.nio.file.StandardOpenOption; import java.util.Map; -/** - * Writes one JSON object per invocation stage so a failed evaluation can be diagnosed without rerunning the model. - */ +/** Writes one JSON object per invocation stage so a failed evaluation can be diagnosed without rerunning the model. */ final class NegotiationEvaluationProcessLogger implements AutoCloseable { private final ObjectMapper objectMapper; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java index 962bb077..317f3056 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -3,9 +3,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; @@ -34,22 +34,22 @@ /** * Runs a selected manually labelled case set against a configured real LLM and writes a JSON report. * - *

        Each case runs the full negotiation protocol chain (mirroring NegotiationEvalApp steps 3-7): the server - * generates the propose; the client validates the inbound propose and discovers the fields it is asked to - * supplement; the client generates the accept/reject from that discovery (fromData channel fills exactly the - * discovered fields, fromText channel voices the ending case's natural language); the server validates the ending - * message. The fifth argument selects the generation channel: {@code fromText} (default) or {@code fromData}. + *

        Each case runs the full negotiation protocol chain (mirroring NegotiationEvalApp steps 3-7): the server generates + * the propose; the client validates the inbound propose and discovers the fields it is asked to supplement; the client + * generates the accept/reject from that discovery (fromData channel fills exactly the discovered fields, fromText + * channel voices the ending case's natural language); the server validates the ending message. The fifth argument + * selects the generation channel: {@code fromText} (default) or {@code fromData}. */ public final class NegotiationQwenEvaluationMain { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); - private NegotiationQwenEvaluationMain() { - } + private NegotiationQwenEvaluationMain() {} public static void main(String[] args) throws IOException { Path envPath = args.length > 0 ? Path.of(args[0]) : NegotiationSampleEnvironment.defaultEnvPath("client"); - Path reportPath = args.length > 1 ? Path.of(args[1]) : Path.of("a2a-t-sample", "target", "negotiation-qwen-report.json"); + Path reportPath = + args.length > 1 ? Path.of(args[1]) : Path.of("a2a-t-sample", "target", "negotiation-qwen-report.json"); Path processLogPath = args.length > 2 ? Path.of(args[2]) : defaultProcessLogPath(reportPath); String caseSet = args.length > 3 ? args[3].trim() : "full"; String channel = args.length > 4 ? args[4].trim() : "fromText"; @@ -64,14 +64,17 @@ public static void main(String[] args) throws IOException { A2ATServer server = new A2ATServer(envPath); List> results = new ArrayList<>(); String runId = UUID.randomUUID().toString(); - try (NegotiationEvaluationProcessLogger processLogger = new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, processLogPath)) { + try (NegotiationEvaluationProcessLogger processLogger = + new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, processLogPath)) { processLogger.write(runStartedEvent(runId, environment, envPath)); for (NegotiationEvaluationFlowCase testCase : testCases) { results.add(runCase(client, server, testCase, channel, runId, processLogger)); } } - long passed = results.stream().filter(result -> Boolean.TRUE.equals(result.get("passed"))).count(); + long passed = results.stream() + .filter(result -> Boolean.TRUE.equals(result.get("passed"))) + .count(); long proposeSucceeded = results.stream() .filter(result -> Boolean.TRUE.equals(result.get("propose_succeeded"))) .count(); @@ -89,7 +92,9 @@ public static void main(String[] args) throws IOException { report.put("git_revision", gitRevision()); report.put("case_set", caseSet); report.put("channel", channel); - report.put("case_ids", testCases.stream().map(NegotiationEvaluationFlowCase::id).toList()); + report.put( + "case_ids", + testCases.stream().map(NegotiationEvaluationFlowCase::id).toList()); report.put("total", results.size()); report.put("propose_succeeded", proposeSucceeded); report.put("ending_succeeded", endingSucceeded); @@ -97,11 +102,13 @@ public static void main(String[] args) throws IOException { report.put("end_to_end_success_rate", (double) passed / results.size()); report.put("golden_matched", goldenMatched); report.put("golden_exact_match_rate", (double) goldenMatched / results.size()); - report.put("note", "A flow passes when all four generation/validation API calls return successfully" - + ("fromData".equals(channel) - ? "; the fromData channel additionally requires an exact completedPrompt match because its rendering is deterministic" - : "") - + ". Golden exact matching is an auxiliary diagnostic because semantically equivalent natural-language values may differ in wording."); + report.put( + "note", + "A flow passes when all four generation/validation API calls return successfully" + + ("fromData".equals(channel) + ? "; the fromData channel additionally requires an exact completedPrompt match because its rendering is deterministic" + : "") + + ". Golden exact matching is an auxiliary diagnostic because semantically equivalent natural-language values may differ in wording."); report.put("process_log", processLogPath.toAbsolutePath().toString()); report.put("cases", results); Files.createDirectories(reportPath.toAbsolutePath().getParent()); @@ -117,7 +124,8 @@ private static Map runCase( NegotiationEvaluationFlowCase testCase, String channel, String runId, - NegotiationEvaluationProcessLogger processLogger) throws IOException { + NegotiationEvaluationProcessLogger processLogger) + throws IOException { long startedAt = System.nanoTime(); boolean fromData = "fromData".equals(channel); Map result = new LinkedHashMap<>(); @@ -127,7 +135,11 @@ private static Map runCase( result.put("channel", channel); result.put("propose_case_id", testCase.proposeCase().id()); result.put("ending_case_id", testCase.endingCase().id()); - result.put("propose_input", fromData ? testCase.proposeCase().data() : testCase.proposeCase().text()); + result.put( + "propose_input", + fromData + ? testCase.proposeCase().data() + : testCase.proposeCase().text()); result.put("expected_propose", testCase.expectedPropose()); result.put("expected_ending", testCase.expectedEnding()); NegotiationContext context = @@ -149,11 +161,12 @@ private static Map runCase( // protocol chain (mirrors NegotiationEvalApp steps 3-7): the server proposes, the client validates the // inbound propose and discovers the fields to fill, then generates the accept/reject from that discovery; // the server finally validates the ending message - MetadataContent propose = generateProposeWithLog( - server, testCase, context, channel, runId, processLogger, apiTrace); + MetadataContent propose = + generateProposeWithLog(server, testCase, context, channel, runId, processLogger, apiTrace); result.put("propose_generation_succeeded", true); result.put("generated_propose_prompt", propose.promptText()); - boolean proposeGoldenExact = propose.promptText().equals(testCase.proposeCase().completedPrompt()); + boolean proposeGoldenExact = + propose.promptText().equals(testCase.proposeCase().completedPrompt()); result.put("propose_golden_exact", proposeGoldenExact); FilledParamData proposeFilled = validateProposeWithLog( client, testCase, propose.promptText(), context, channel, runId, processLogger, apiTrace); @@ -161,20 +174,25 @@ private static Map runCase( result.put("actual_propose", proposeFilled.data()); List requestedFields = requestedFields(proposeFilled, testCase.proposeCase()); result.put("client_requested_fields", requestedFields); - result.put("ending_input", fromData ? requestedFields : testCase.endingCase().text()); + result.put( + "ending_input", + fromData ? requestedFields : testCase.endingCase().text()); boolean proposeMatched = expectedValuesMatch(testCase.expectedPropose(), proposeFilled.data()); result.put("propose_expected_matched", proposeMatched); result.put("propose_context_matched", contextMatches(context, proposeFilled.data())); result.put("propose_succeeded", true); - NegotiationPerformative endingPerformative = - "accept".equals(testCase.decision()) ? NegotiationPerformative.ACCEPT : NegotiationPerformative.REJECT; - NegotiationContext responseContext = NegotiationSampleFlow.contextFrom(proposeFilled.data(), endingPerformative); + NegotiationPerformative endingPerformative = "accept".equals(testCase.decision()) + ? NegotiationPerformative.ACCEPT + : NegotiationPerformative.REJECT; + NegotiationContext responseContext = + NegotiationSampleFlow.contextFrom(proposeFilled.data(), endingPerformative); MetadataContent ending = generateEndingWithLog( client, testCase, requestedFields, responseContext, channel, runId, processLogger, apiTrace); result.put("ending_generation_succeeded", true); result.put("generated_ending_prompt", ending.promptText()); - boolean endingGoldenExact = ending.promptText().equals(testCase.endingCase().completedPrompt()); + boolean endingGoldenExact = + ending.promptText().equals(testCase.endingCase().completedPrompt()); result.put("ending_golden_exact", endingGoldenExact); FilledParamData endingFilled = validateEndingWithLog( server, testCase, ending.promptText(), responseContext, channel, runId, processLogger, apiTrace); @@ -203,7 +221,8 @@ private static MetadataContent generateProposeWithLog( String channel, String runId, NegotiationEvaluationProcessLogger processLogger, - List> apiTrace) throws IOException { + List> apiTrace) + throws IOException { long startedAt = System.nanoTime(); NegotiationEvaluationCase proposeCase = testCase.proposeCase(); boolean fromData = "fromData".equals(channel); @@ -215,17 +234,44 @@ private static MetadataContent generateProposeWithLog( ? server.generateNegotiationProposePromptFromData( new NegotiationProposeData( context, - new InformationProposeContent(proposeCase.dataItems(), proposeCase.dataRelationship())), + new InformationProposeContent( + proposeCase.dataItems(), proposeCase.dataRelationship())), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI) : server.generateNegotiationProposePromptFromText( proposeCase.text(), context, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_propose", "propose", channel, context, request, Map.of( - "prompt", content.promptText(), - "template_uri", content.templateUri(), - "extension_uri", content.extensionUri()), startedAt, null)); + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "generate_propose", + "propose", + channel, + context, + request, + Map.of( + "prompt", content.promptText(), + "template_uri", content.templateUri(), + "extension_uri", content.extensionUri()), + startedAt, + null)); return content; } catch (RuntimeException exception) { - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_propose", "propose", channel, context, request, null, startedAt, exception)); + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "generate_propose", + "propose", + channel, + context, + request, + null, + startedAt, + exception)); throw exception; } } @@ -238,29 +284,56 @@ private static FilledParamData validateProposeWithLog( String channel, String runId, NegotiationEvaluationProcessLogger processLogger, - List> apiTrace) throws IOException { + List> apiTrace) + throws IOException { long startedAt = System.nanoTime(); Map schema = InformationNegotiationSchemas.propose(); try { FilledParamData filled = client.validateProposePromptAndDataFilling( prompt, context, schema, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_propose_and_fill", "propose", channel, null, Map.of( - "prompt", prompt, - "schema", schema, - "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI), Map.of("filled_data", filled.data()), startedAt, null)); + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "validate_propose_and_fill", + "propose", + channel, + null, + Map.of( + "prompt", prompt, + "schema", schema, + "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI), + Map.of("filled_data", filled.data()), + startedAt, + null)); return filled; } catch (RuntimeException exception) { - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_propose_and_fill", "propose", channel, null, Map.of( - "prompt", prompt, - "schema", schema, - "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI), null, startedAt, exception)); + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "validate_propose_and_fill", + "propose", + channel, + null, + Map.of( + "prompt", prompt, + "schema", schema, + "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI), + null, + startedAt, + exception)); throw exception; } } /** - * Generates the client's accept/reject. The client fills exactly the fields it discovered from the inbound - * propose (step 2 output), taking each value/reason from the ending case. + * Generates the client's accept/reject. The client fills exactly the fields it discovered from the inbound propose + * (step 2 output), taking each value/reason from the ending case. */ private static MetadataContent generateEndingWithLog( A2ATClient client, @@ -270,7 +343,8 @@ private static MetadataContent generateEndingWithLog( String channel, String runId, NegotiationEvaluationProcessLogger processLogger, - List> apiTrace) throws IOException { + List> apiTrace) + throws IOException { long startedAt = System.nanoTime(); boolean fromData = "fromData".equals(channel); boolean accept = "accept".equals(testCase.decision()); @@ -280,35 +354,72 @@ private static MetadataContent generateEndingWithLog( // texts may paraphrase or omit the field names, so the LLM extraction needs the discovered fields spelled out String endingInputText = clientSupplementText(endingItems, accept) + "\n\n" + endingCase.text(); Map request = fromData - ? Map.of("data", Map.of( - "items", itemsJson(endingItems), - "conclusion", accept ? NegotiationConclusion.ACCEPT.toString() : NegotiationConclusion.REJECT.toString()), - "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI) - : Map.of("text", endingInputText, - "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI); + ? Map.of( + "data", + Map.of( + "items", + itemsJson(endingItems), + "conclusion", + accept + ? NegotiationConclusion.ACCEPT.toString() + : NegotiationConclusion.REJECT.toString()), + "template_uri", + NegotiationSampleFlow.ENDING_TEMPLATE_URI) + : Map.of("text", endingInputText, "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI); try { MetadataContent content = fromData ? accept ? client.generateNegotiationAcceptPromptFromData( new NegotiationEndingData( - context, new InformationEndingContent(NegotiationConclusion.ACCEPT, endingItems)), + context, + new InformationEndingContent(NegotiationConclusion.ACCEPT, endingItems)), NegotiationSampleFlow.ENDING_TEMPLATE_URI) : client.generateNegotiationRejectPromptFromData( new NegotiationEndingData( - context, new InformationEndingContent(NegotiationConclusion.REJECT, endingItems)), + context, + new InformationEndingContent(NegotiationConclusion.REJECT, endingItems)), NegotiationSampleFlow.ENDING_TEMPLATE_URI) : accept ? client.generateNegotiationAcceptPromptFromText( endingInputText, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI) : client.generateNegotiationRejectPromptFromText( endingInputText, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), channel, context, - request, Map.of("prompt", content.promptText(), "template_uri", content.templateUri(), - "extension_uri", content.extensionUri()), startedAt, null)); + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "generate_" + testCase.decision(), + testCase.decision(), + channel, + context, + request, + Map.of( + "prompt", + content.promptText(), + "template_uri", + content.templateUri(), + "extension_uri", + content.extensionUri()), + startedAt, + null)); return content; } catch (RuntimeException exception) { - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "generate_" + testCase.decision(), testCase.decision(), channel, context, - request, null, startedAt, exception)); + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "generate_" + testCase.decision(), + testCase.decision(), + channel, + context, + request, + null, + startedAt, + exception)); throw exception; } } @@ -321,7 +432,8 @@ private static FilledParamData validateEndingWithLog( String channel, String runId, NegotiationEvaluationProcessLogger processLogger, - List> apiTrace) throws IOException { + List> apiTrace) + throws IOException { long startedAt = System.nanoTime(); Map schema = "accept".equals(testCase.decision()) ? InformationNegotiationSchemas.accept() @@ -332,15 +444,48 @@ private static FilledParamData validateEndingWithLog( prompt, context, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI) : server.validateRejectPromptAndDataFilling( prompt, context, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI); - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_" + testCase.decision() + "_and_fill", - testCase.decision(), channel, null, Map.of("prompt", prompt, "schema", schema, - "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI), - Map.of("filled_data", filled.data()), startedAt, null)); + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "validate_" + testCase.decision() + "_and_fill", + testCase.decision(), + channel, + null, + Map.of( + "prompt", + prompt, + "schema", + schema, + "template_uri", + NegotiationSampleFlow.ENDING_TEMPLATE_URI), + Map.of("filled_data", filled.data()), + startedAt, + null)); return filled; } catch (RuntimeException exception) { - writeStage(processLogger, apiTrace, stageEvent(runId, testCase, "validate_" + testCase.decision() + "_and_fill", - testCase.decision(), channel, null, Map.of("prompt", prompt, "schema", schema, - "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI), null, startedAt, exception)); + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "validate_" + testCase.decision() + "_and_fill", + testCase.decision(), + channel, + null, + Map.of( + "prompt", + prompt, + "schema", + schema, + "template_uri", + NegotiationSampleFlow.ENDING_TEMPLATE_URI), + null, + startedAt, + exception)); throw exception; } } @@ -366,10 +511,11 @@ private static List requestedFields(FilledParamData filled, NegotiationE } /** - * The client fills exactly the fields it discovered from the inbound propose, taking each value (accept) or - * reason (reject) from the ending case's structured items. + * The client fills exactly the fields it discovered from the inbound propose, taking each value (accept) or reason + * (reject) from the ending case's structured items. */ - private static List filledItems(List requestedFields, NegotiationEvaluationCase endingCase) { + private static List filledItems( + List requestedFields, NegotiationEvaluationCase endingCase) { Map byName = new LinkedHashMap<>(); for (NegotiationItem item : endingCase.dataItems()) { byName.put(item.name(), item.value()); @@ -437,7 +583,8 @@ private static Map stageEvent( event.put("stage", stage); event.put("expected", "propose".equals(phase) ? testCase.expectedPropose() : testCase.expectedEnding()); if (context != null) { - event.put("context", Map.of("id", context.id(), "round", context.round(), "max_rounds", context.maxRounds())); + event.put( + "context", Map.of("id", context.id(), "round", context.round(), "max_rounds", context.maxRounds())); } event.put("request", request); if (response != null) { @@ -454,7 +601,8 @@ private static Map stageEvent( private static void writeStage( NegotiationEvaluationProcessLogger processLogger, List> apiTrace, - Map event) throws IOException { + Map event) + throws IOException { processLogger.write(event); apiTrace.add(event); } @@ -470,15 +618,16 @@ private static int stageStep(String stage) { } private static String stageApi(String stage, String channel) { - String api = switch (stage) { - case "generate_propose" -> "A2ATServer.generateNegotiationProposePromptFromText"; - case "validate_propose_and_fill" -> "A2ATClient.validateProposePromptAndDataFilling"; - case "generate_accept" -> "A2ATClient.generateNegotiationAcceptPromptFromText"; - case "validate_accept_and_fill" -> "A2ATServer.validateAcceptPromptAndDataFilling"; - case "generate_reject" -> "A2ATClient.generateNegotiationRejectPromptFromText"; - case "validate_reject_and_fill" -> "A2ATServer.validateRejectPromptAndDataFilling"; - default -> stage; - }; + String api = + switch (stage) { + case "generate_propose" -> "A2ATServer.generateNegotiationProposePromptFromText"; + case "validate_propose_and_fill" -> "A2ATClient.validateProposePromptAndDataFilling"; + case "generate_accept" -> "A2ATClient.generateNegotiationAcceptPromptFromText"; + case "validate_accept_and_fill" -> "A2ATServer.validateAcceptPromptAndDataFilling"; + case "generate_reject" -> "A2ATClient.generateNegotiationRejectPromptFromText"; + case "validate_reject_and_fill" -> "A2ATServer.validateRejectPromptAndDataFilling"; + default -> stage; + }; return "fromData".equals(channel) ? api.replace("FromText", "FromData") : api; } @@ -511,7 +660,12 @@ private static Map errorDetails(RuntimeException exception) { error.put("slot_errors", extractionError.getErrors()); } error.put("cause_chain", causeChain(exception)); - error.put("stack_trace", Arrays.stream(exception.getStackTrace()).limit(20).map(StackTraceElement::toString).toList()); + error.put( + "stack_trace", + Arrays.stream(exception.getStackTrace()) + .limit(20) + .map(StackTraceElement::toString) + .toList()); return error; } @@ -575,9 +729,9 @@ private static Path defaultProcessLogPath(Path reportPath) { } /** - * Matches the labelled expected values against the extracted items of the shared information-negotiation - * schemas ({@code items: [{name, requirement|value|reason}]}). Bidirectional containment after whitespace - * removal: the extracted payload may carry the full line text while the label records only the value or reason. + * Matches the labelled expected values against the extracted items of the shared information-negotiation schemas + * ({@code items: [{name, requirement|value|reason}]}). Bidirectional containment after whitespace removal: the + * extracted payload may carry the full line text while the label records only the value or reason. */ private static boolean expectedValuesMatch(Map expected, Map actual) { Map actualByName = itemsByName(actual); @@ -625,7 +779,8 @@ private static boolean numberEquals(int expected, Object actual) { private static void requireQwenConfiguration(Map environment) { if (!"openai".equals(environment.get("A2AT_LLM_PROVIDER")) || environment.get("A2AT_LLM_BASE_URL") == null) { - throw new IllegalArgumentException("Qwen evaluation requires A2AT_LLM_PROVIDER=openai and A2AT_LLM_BASE_URL in the supplied env file"); + throw new IllegalArgumentException( + "Qwen evaluation requires A2AT_LLM_PROVIDER=openai and A2AT_LLM_BASE_URL in the supplied env file"); } } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java index e58aceeb..928a877d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java @@ -38,8 +38,8 @@ public void execute(RequestContext requestContext, AgentEmitter agentEmitter) th NegotiationMetadataReader.requireExtension(extensionHeader(requestContext)); Message message = requestContext.getMessage(); Map metadata = message == null ? null : message.metadata(); - String proposePrompt = NegotiationMetadataReader.readPrompt( - metadata, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + String proposePrompt = + NegotiationMetadataReader.readPrompt(metadata, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); NegotiationContext context = NegotiationMetadataReader.readContext(metadata); server.validateProposePromptAndDataFilling( proposePrompt, @@ -54,7 +54,8 @@ public void execute(RequestContext requestContext, AgentEmitter agentEmitter) th agentEmitter.submit(); agentEmitter.startWork(); agentEmitter.addArtifact( - List.of(new TextPart("Information negotiation " + decision.name().toLowerCase())), + List.of(new TextPart( + "Information negotiation " + decision.name().toLowerCase())), "information-negotiation", "Private-line complaint negotiation result", Map.copyOf(ending.buildMetadataContent()), diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationServerMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationServerMain.java index 84f7f2ac..7b8f6ba8 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationServerMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationServerMain.java @@ -24,13 +24,10 @@ /** Starts the A2A HTTP server for the private-line complaint negotiation sample. */ public final class NegotiationServerMain { - private NegotiationServerMain() { - } + private NegotiationServerMain() {} public static void main(String[] args) throws InterruptedException { - Path envPath = args.length == 0 - ? NegotiationSampleEnvironment.defaultEnvPath("server") - : Path.of(args[0]); + Path envPath = args.length == 0 ? NegotiationSampleEnvironment.defaultEnvPath("server") : Path.of(args[0]); Map env = NegotiationSampleEnvironment.read(envPath); if (NegotiationMockLlmInstaller.PROVIDER.equals(env.get("A2AT_LLM_PROVIDER"))) { NegotiationMockLlmInstaller.install(); @@ -58,7 +55,10 @@ public static void main(String[] args) throws InterruptedException { executor, executor); try (EmbeddedA2AHttpServer ignored = EmbeddedA2AHttpServer.start( - host, port, RegistryAgentCardMapper.toA2AJavaAgentCard(NegotiationAgentCard.build(host, port)), handler)) { + host, + port, + RegistryAgentCardMapper.toA2AJavaAgentCard(NegotiationAgentCard.build(host, port)), + handler)) { System.out.println("[negotiation-server] listening on http://" + host + ":" + port); Thread.currentThread().join(); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationAgentCard.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationAgentCard.java index a87e49fd..aed41c15 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationAgentCard.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationAgentCard.java @@ -7,8 +7,7 @@ /** AgentCard payload shared by the negotiation sample client and server. */ public final class NegotiationAgentCard { - private NegotiationAgentCard() { - } + private NegotiationAgentCard() {} public static Map build(String host, int port) { return Map.of( @@ -18,20 +17,26 @@ public static Map build(String host, int port) { "defaultInputModes", List.of("text/plain"), "defaultOutputModes", List.of("text/plain"), "provider", Map.of("organization", "OpenAN", "url", "https://github.com/project-openan"), - "skills", List.of(Map.of( - "id", "private-line-complaint-negotiation", - "name", "Private-line complaint negotiation", - "description", "Completes an information negotiation with Accept or Reject.", - "tags", List.of("negotiation", "private-line"))), - "capabilities", Map.of( - "streaming", true, - "pushNotifications", false, - "extensions", List.of(Map.of( - "uri", ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, - "description", "Negotiation-T information negotiation extension."))), - "supportedInterfaces", List.of(Map.of( - "protocolBinding", "HTTP+JSON", - "protocolVersion", "1.0", - "url", "http://" + host + ":" + port))); + "skills", + List.of(Map.of( + "id", "private-line-complaint-negotiation", + "name", "Private-line complaint negotiation", + "description", "Completes an information negotiation with Accept or Reject.", + "tags", List.of("negotiation", "private-line"))), + "capabilities", + Map.of( + "streaming", true, + "pushNotifications", false, + "extensions", + List.of(Map.of( + "uri", + ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, + "description", + "Negotiation-T information negotiation extension."))), + "supportedInterfaces", + List.of(Map.of( + "protocolBinding", "HTTP+JSON", + "protocolVersion", "1.0", + "url", "http://" + host + ":" + port))); } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java index d2d3a367..904d8f65 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java @@ -9,8 +9,7 @@ /** Reads and validates Negotiation-T metadata received over A2A. */ public final class NegotiationMetadataReader { - private NegotiationMetadataReader() { - } + private NegotiationMetadataReader() {} public static String readPrompt(Map metadata, String expectedTemplateUri) { if (metadata == null) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleEnvironment.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleEnvironment.java index c4662468..ba62d191 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleEnvironment.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleEnvironment.java @@ -10,8 +10,7 @@ /** Reads the small set of environment values needed by the negotiation sample entry points. */ public final class NegotiationSampleEnvironment { - private NegotiationSampleEnvironment() { - } + private NegotiationSampleEnvironment() {} public static Path defaultEnvPath(String role) { return Path.of( @@ -35,7 +34,9 @@ public static Map read(Path envPath) { continue; } int separator = line.indexOf('='); - values.put(line.substring(0, separator).trim(), line.substring(separator + 1).trim()); + values.put( + line.substring(0, separator).trim(), + line.substring(separator + 1).trim()); } return values; } catch (IOException exception) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java index 2d781fe9..9aad07dc 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java @@ -2,14 +2,14 @@ import java.util.Map; import java.util.UUID; +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; /** Runs the six Negotiation-T APIs used by the private-line complaint sample. */ public final class NegotiationSampleFlow { @@ -18,18 +18,14 @@ public final class NegotiationSampleFlow { public static final String ENDING_TEMPLATE_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI; - private NegotiationSampleFlow() { - } + private NegotiationSampleFlow() {} public static NegotiationFlowResult run( - A2ATClient client, - A2ATServer server, - NegotiationScenario scenario, - NegotiationDecision decision) { + A2ATClient client, A2ATServer server, NegotiationScenario scenario, NegotiationDecision decision) { NegotiationContext requestContext = new NegotiationContext(UUID.randomUUID().toString(), 1, 3, NegotiationPerformative.PROPOSE); - MetadataContent propose = - client.generateNegotiationProposePromptFromText(scenario.proposeText(), requestContext, PROPOSE_TEMPLATE_URI); + MetadataContent propose = client.generateNegotiationProposePromptFromText( + scenario.proposeText(), requestContext, PROPOSE_TEMPLATE_URI); Map proposeMetadata = propose.buildMetadataContent(); String proposePrompt = NegotiationMetadataReader.readPrompt(proposeMetadata, PROPOSE_TEMPLATE_URI); NegotiationContext proposeContext = NegotiationMetadataReader.readContext(proposeMetadata); @@ -38,8 +34,10 @@ public static NegotiationFlowResult run( NegotiationContext responseContext = proposeContext; MetadataContent ending = decision == NegotiationDecision.ACCEPT - ? server.generateNegotiationAcceptPromptFromText(scenario.acceptText(), responseContext, ENDING_TEMPLATE_URI) - : server.generateNegotiationRejectPromptFromText(scenario.rejectText(), responseContext, ENDING_TEMPLATE_URI); + ? server.generateNegotiationAcceptPromptFromText( + scenario.acceptText(), responseContext, ENDING_TEMPLATE_URI) + : server.generateNegotiationRejectPromptFromText( + scenario.rejectText(), responseContext, ENDING_TEMPLATE_URI); Map endingMetadata = ending.buildMetadataContent(); String endingPrompt = NegotiationMetadataReader.readPrompt(endingMetadata, ENDING_TEMPLATE_URI); NegotiationContext endingContext = NegotiationMetadataReader.readContext(endingMetadata); @@ -67,7 +65,9 @@ public static NegotiationContext contextFrom(Map data, Negotiati Object id = data.get("id"); Object round = data.get("round"); Object maxRounds = data.get("maxRounds"); - if (!(id instanceof String text) || !(round instanceof Number roundNumber) || !(maxRounds instanceof Number maxRoundsNumber)) { + if (!(id instanceof String text) + || !(round instanceof Number roundNumber) + || !(maxRounds instanceof Number maxRoundsNumber)) { throw new IllegalArgumentException("Filled negotiation data does not contain a valid context"); } return new NegotiationContext(text, roundNumber.intValue(), maxRoundsNumber.intValue(), performative); @@ -79,6 +79,5 @@ public record NegotiationFlowResult( FilledParamData proposeData, MetadataContent ending, FilledParamData endingData, - NegotiationDecision decision) { - } + NegotiationDecision decision) {} } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenario.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenario.java index ddb7cb55..d914cefd 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenario.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenario.java @@ -1,5 +1,4 @@ package net.openan.a2at.sample.private_line_complaint.negotiation.shared; /** Natural-language inputs used by the private-line complaint negotiation sample. */ -public record NegotiationScenario(String scenario, String proposeText, String acceptText, String rejectText) { -} +public record NegotiationScenario(String scenario, String proposeText, String acceptText, String rejectText) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenarioLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenarioLoader.java index 6c3e764b..e671c93c 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenarioLoader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenarioLoader.java @@ -13,16 +13,15 @@ public final class NegotiationScenarioLoader { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private NegotiationScenarioLoader() { - } + private NegotiationScenarioLoader() {} public static NegotiationScenario load() { - try (InputStream input = NegotiationScenarioLoader.class.getClassLoader().getResourceAsStream(RESOURCE_PATH)) { + try (InputStream input = + NegotiationScenarioLoader.class.getClassLoader().getResourceAsStream(RESOURCE_PATH)) { if (input == null) { throw new IllegalStateException("Negotiation scenario resource not found: " + RESOURCE_PATH); } - Map values = OBJECT_MAPPER.readValue(input, new TypeReference<>() { - }); + Map values = OBJECT_MAPPER.readValue(input, new TypeReference<>() {}); return new NegotiationScenario( require(values, "scenario"), require(values, "propose_text"), diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLLMClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLLMClient.java index 324ff245..45fb830e 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLLMClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLLMClient.java @@ -9,39 +9,45 @@ /** * Deterministic structured LLM used by the information negotiation sample. * - *

        Responses are selected from the requested schema and text, so client and server calls can - * run in either order without a shared call counter. + *

        Responses are selected from the requested schema and text, so client and server calls can run in either order + * without a shared call counter. */ public final class NegotiationMockLLMClient implements LLMClient { - private static final String PROPOSE_CONTENT = """ + private static final String PROPOSE_CONTENT = + """ {"items":[{"name":"接入端口名称","value":"物理端口或逻辑端口名称"}, {"name":"投诉分类","value":"专线中断或专线质差"}],"relationship":null} """; - private static final String ACCEPT_CONTENT = """ + private static final String ACCEPT_CONTENT = + """ {"conclusion":"Accept","items":[{"name":"接入端口名称", "value":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)"}, {"name":"投诉分类","value":"专线质差"}]} """; - private static final String REJECT_CONTENT = """ + private static final String REJECT_CONTENT = + """ {"conclusion":"Reject","items":[{"name":"接入端口名称", "value":"当前账号没有资源系统查询权限"},{"name":"投诉分类", "value":"当前账号没有资源系统查询权限"}]} """; - private static final String PROPOSE_PARAMS = """ + private static final String PROPOSE_PARAMS = + """ {"items":[{"name":"接入端口名称","requirement":"物理端口或逻辑端口名称"}, {"name":"投诉分类","requirement":"专线中断或专线质差"}],"relationship":null} """; - private static final String ACCEPT_PARAMS = """ + private static final String ACCEPT_PARAMS = + """ {"items":[{"name":"接入端口名称","value":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)"}, {"name":"投诉分类","value":"专线质差"}]} """; - private static final String REJECT_PARAMS = """ + private static final String REJECT_PARAMS = + """ {"items":[{"name":"接入端口名称","reason":"当前账号没有资源系统查询权限"}, {"name":"投诉分类","reason":"当前账号没有资源系统查询权限"}]} """; @@ -53,10 +59,7 @@ public NegotiationMockLLMClient(LLMClientConfig config) { @Override @SuppressWarnings("unchecked") public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { + List> messages, Map jsonSchema, Double temperature, Integer maxTokens) { Map properties = propertiesOf(jsonSchema); String content; if (properties.containsKey("semantic_verdict")) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLlmInstaller.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLlmInstaller.java index 23f76615..64b4a7c4 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLlmInstaller.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLlmInstaller.java @@ -7,8 +7,7 @@ public final class NegotiationMockLlmInstaller { public static final String PROVIDER = "negotiation-sample-mock"; - private NegotiationMockLlmInstaller() { - } + private NegotiationMockLlmInstaller() {} public static synchronized void install() { if (!LLMClientFactory.availableProviders().contains(PROVIDER)) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java index 362b76cd..5b6c3eb8 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java @@ -6,10 +6,11 @@ import net.openan.a2at.sdk.core.model.MetadataContent; /** - * One-shot diagnostic entry: runs {@code generateNotificationPromptFromText} alone with the - * realistic natural-language input and prints every intermediate artifact. + * One-shot diagnostic entry: runs {@code generateNotificationPromptFromText} alone with the realistic natural-language + * input and prints every intermediate artifact. * *

        Run from the repository root: + * *

          * java @a2a-t-sample/target/sample.args \
          *   net.openan.a2at.sample.service_recovery.FromTextProbe \
        @@ -20,8 +21,7 @@
          */
         public final class FromTextProbe {
         
        -    private FromTextProbe() {
        -    }
        +    private FromTextProbe() {}
         
             /**
              * Probe entry point.
        @@ -31,8 +31,8 @@ private FromTextProbe() {
             public static void main(String[] args) throws Exception {
                 // Route all probe output to a UTF-8 file so the console codepage cannot garble it.
                 java.io.PrintStream out = new java.io.PrintStream(
        -                java.nio.file.Files.newOutputStream(java.nio.file.Path.of(
        -                        "a2a-t-sample", "target", "sample-logs", "from-text-probe-output.txt")),
        +                java.nio.file.Files.newOutputStream(
        +                        java.nio.file.Path.of("a2a-t-sample", "target", "sample-logs", "from-text-probe-output.txt")),
                         true,
                         java.nio.charset.StandardCharsets.UTF_8);
                 System.setOut(out);
        @@ -47,7 +47,14 @@ private static void run(String[] args) {
         
                 Path envPath = args.length > 0
                         ? Path.of(args[0])
        -                : Path.of("a2a-t-sample", "src", "main", "resources", "sample", "service-recovery", "client",
        +                : Path.of(
        +                        "a2a-t-sample",
        +                        "src",
        +                        "main",
        +                        "resources",
        +                        "sample",
        +                        "service-recovery",
        +                        "client",
                                 "client.env");
         
                 String input = ServiceRecoverySampleInputs.naturalLanguageInput();
        @@ -72,7 +79,8 @@ private static void run(String[] args) {
                     System.out.println("--- promptText ---");
                     System.out.println(content.promptText());
                     System.out.println("--- metadata map ---");
        -            for (Map.Entry entry : content.buildMetadataContent().entrySet()) {
        +            for (Map.Entry entry :
        +                    content.buildMetadataContent().entrySet()) {
                         System.out.println("  " + entry.getKey() + " = " + summarize(String.valueOf(entry.getValue())));
                     }
                 } catch (RuntimeException error) {
        diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/SampleResourceJson.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/SampleResourceJson.java
        index 68eb18d6..00a8770f 100644
        --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/SampleResourceJson.java
        +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/SampleResourceJson.java
        @@ -10,8 +10,8 @@
         /**
          * Loads JSON resources from the service-recovery sample resource tree.
          *
        - * 

        Keeps locale-specific business data (zh-CN slot names, event field labels) in resource files - * so the Java sources stay pure ASCII. + *

        Keeps locale-specific business data (zh-CN slot names, event field labels) in resource files so the Java sources + * stay pure ASCII. * * @since 2026-08 */ @@ -19,8 +19,7 @@ public final class SampleResourceJson { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private SampleResourceJson() { - } + private SampleResourceJson() {} /** * Loads one JSON object resource from the classpath. @@ -29,9 +28,7 @@ private SampleResourceJson() { * @return parsed JSON object as a string-to-object map */ public static Map load(String resourcePath) { - try (InputStream inputStream = SampleResourceJson.class - .getClassLoader() - .getResourceAsStream(resourcePath)) { + try (InputStream inputStream = SampleResourceJson.class.getClassLoader().getResourceAsStream(resourcePath)) { if (inputStream == null) { throw new IllegalStateException("Sample resource not found: " + resourcePath); } @@ -48,9 +45,7 @@ public static Map load(String resourcePath) { * @return resource content as UTF-8 text */ static String loadText(String resourcePath) { - try (InputStream inputStream = SampleResourceJson.class - .getClassLoader() - .getResourceAsStream(resourcePath)) { + try (InputStream inputStream = SampleResourceJson.class.getClassLoader().getResourceAsStream(resourcePath)) { if (inputStream == null) { throw new IllegalStateException("Sample resource not found: " + resourcePath); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java index 09c9bce7..e749ea85 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java @@ -8,18 +8,17 @@ import java.util.Map; /** - * Loads the sample inputs and schemas from the classpath resources under - * {@code sample/service-recovery/}. + * Loads the sample inputs and schemas from the classpath resources under {@code sample/service-recovery/}. * *

        Four artifacts live as resources so they can be edited without touching Java code: * *

          - *
        • {@code client/input-with-text.txt} — the natural-language subscription request; - *
        • {@code client/input-with-data.json} — the structured subscription request consumed by - * {@code generateNotificationPromptFromDataWithSchema}; - *
        • {@code client/schema.json} — the data schema guiding the structured extraction; - *
        • {@code server/schema.json} — the parameter schema handed to - * {@code A2ATServer.validateAndFillingNotificationData}. + *
        • {@code client/input-with-text.txt} — the natural-language subscription request; + *
        • {@code client/input-with-data.json} — the structured subscription request consumed by + * {@code generateNotificationPromptFromDataWithSchema}; + *
        • {@code client/schema.json} — the data schema guiding the structured extraction; + *
        • {@code server/schema.json} — the parameter schema handed to + * {@code A2ATServer.validateAndFillingNotificationData}. * * @since 2026-08 */ @@ -27,17 +26,13 @@ public final class ServiceRecoverySampleInputs { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final String INPUT_RESOURCE = - "sample/service-recovery/client/input-with-text.txt"; + private static final String INPUT_RESOURCE = "sample/service-recovery/client/input-with-text.txt"; - private static final String STRUCTURED_INPUT_RESOURCE = - "sample/service-recovery/client/input-with-data.json"; + private static final String STRUCTURED_INPUT_RESOURCE = "sample/service-recovery/client/input-with-data.json"; - private static final String DATA_SCHEMA_RESOURCE = - "sample/service-recovery/client/schema.json"; + private static final String DATA_SCHEMA_RESOURCE = "sample/service-recovery/client/schema.json"; - private static final String VALIDATION_SCHEMA_RESOURCE = - "sample/service-recovery/server/schema.json"; + private static final String VALIDATION_SCHEMA_RESOURCE = "sample/service-recovery/server/schema.json"; /** Template URI verified by this sample (network-layer domain layout of the bundled template). */ public static final String TEMPLATE_URI = "Notification-T/network-layer/service-recovery/v1"; @@ -63,12 +58,10 @@ public final class ServiceRecoverySampleInputs { /** AgentCard query organization of the sample server. */ public static final String AGENT_ORGANIZATION = "Huawei"; - private ServiceRecoverySampleInputs() { - } + private ServiceRecoverySampleInputs() {} /** - * Loads the natural-language subscription request from - * {@code sample/service-recovery/client/input-with-text.txt}. + * Loads the natural-language subscription request from {@code sample/service-recovery/client/input-with-text.txt}. * * @return the natural-language input text */ @@ -77,12 +70,11 @@ public static String naturalLanguageInput() { } /** - * Loads the structured subscription request consumed by - * {@code generateNotificationPromptFromDataWithSchema} from + * Loads the structured subscription request consumed by {@code generateNotificationPromptFromDataWithSchema} from * {@code sample/service-recovery/client/input-with-data.json}. * - *

          The keys follow the English business-style parameter names of the server-side validation - * schema ({@code topic}/{@code subscriptionCondition}/{@code notificationDataFormat}), demonstrating that the + *

          The keys follow the English business-style parameter names of the server-side validation schema + * ({@code topic}/{@code subscriptionCondition}/{@code notificationDataFormat}), demonstrating that the * schema-guided generation path maps caller-provided structured data onto the template slots. * * @return structured notification input as a string-to-object map @@ -92,12 +84,11 @@ public static Map structuredInput() { } /** - * Loads the data schema guiding the structured extraction of - * {@code generateNotificationPromptFromDataWithSchema} from - * {@code sample/service-recovery/client/schema.json}. + * Loads the data schema guiding the structured extraction of {@code generateNotificationPromptFromDataWithSchema} + * from {@code sample/service-recovery/client/schema.json}. * - *

          The schema describes the two input-with-data fields (type, description, required) and is - * handed to the SDK verbatim; the SDK prints its entries into the LLM slot-extraction prompt. + *

          The schema describes the two input-with-data fields (type, description, required) and is handed to the SDK + * verbatim; the SDK prints its entries into the LLM slot-extraction prompt. * * @return data schema map for schema-guided extraction */ @@ -106,14 +97,14 @@ public static Map dataSchema() { } /** - * Loads the parameter schema handed to {@code A2ATServer.validateAndFillingNotificationData} - * from {@code sample/service-recovery/server/schema.json}. + * Loads the parameter schema handed to {@code A2ATServer.validateAndFillingNotificationData} from + * {@code sample/service-recovery/server/schema.json}. * *

          The schema mirrors the slot.json format ({@code $schema}/{@code additionalProperties}/ - * {@code description}/{@code examples}/{@code x-a2at-value-constraint}), but the property keys - * are English business-style identifiers ({@code topic}/{@code notificationDataFormat}) instead of - * the zh-CN slot names, demonstrating that the validation API fills parameters per the - * caller-provided schema rather than echoing template slots. + * {@code description}/{@code examples}/{@code x-a2at-value-constraint}), but the property keys are English + * business-style identifiers ({@code topic}/{@code notificationDataFormat}) instead of the zh-CN slot names, + * demonstrating that the validation API fills parameters per the caller-provided schema rather than echoing + * template slots. * * @return parameter JSON schema for validation and parameter extraction */ @@ -122,9 +113,8 @@ public static Map validationParamSchema() { } private static String readText(String resourcePath) { - try (InputStream inputStream = ServiceRecoverySampleInputs.class - .getClassLoader() - .getResourceAsStream(resourcePath)) { + try (InputStream inputStream = + ServiceRecoverySampleInputs.class.getClassLoader().getResourceAsStream(resourcePath)) { if (inputStream == null) { throw new IllegalStateException("Sample resource not found: " + resourcePath); } @@ -135,9 +125,8 @@ private static String readText(String resourcePath) { } private static Map readJson(String resourcePath) { - try (InputStream inputStream = ServiceRecoverySampleInputs.class - .getClassLoader() - .getResourceAsStream(resourcePath)) { + try (InputStream inputStream = + ServiceRecoverySampleInputs.class.getClassLoader().getResourceAsStream(resourcePath)) { if (inputStream == null) { throw new IllegalStateException("Sample resource not found: " + resourcePath); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/ClientSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/ClientSampleMain.java index b7d22b26..9fbb6ec6 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/ClientSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/ClientSampleMain.java @@ -21,11 +21,9 @@ */ public final class ClientSampleMain { - private static final String MOCK_RESOURCE_ROOT = - "sample/service-recovery/mock_responses"; + private static final String MOCK_RESOURCE_ROOT = "sample/service-recovery/mock_responses"; - private ClientSampleMain() { - } + private ClientSampleMain() {} /** * Resolves the sample environment file path, preferring an explicit argument. @@ -46,9 +44,7 @@ public static Path resolveEnvPath(String[] args) { * @return client flow outcome with verification checks and received events */ public static ClientFlowOutcome runMain( - Path envPath, - SampleClientRuntimeFactory runtimeFactory, - Consumer logSink) { + Path envPath, SampleClientRuntimeFactory runtimeFactory, Consumer logSink) { boolean mockNeeded = SampleMockLlmInstaller.isMockNeeded(envPath); SampleMockLlmInstaller.installLlmLogger(mockNeeded, "client"); Path resolvedEnvPath = SampleMockLlmInstaller.resolveEnvPath(envPath, MOCK_RESOURCE_ROOT); @@ -61,11 +57,7 @@ public static ClientFlowOutcome runMain( throw new IllegalStateException("Sample client runtime must implement A2AJavaClientRuntime"); } return ClientSampleFlow.runClientFlow( - runtime.registryClient(), - runtime.promptClient(), - a2aRuntime, - logSink, - resolveMaxArtifacts()); + runtime.registryClient(), runtime.promptClient(), a2aRuntime, logSink, resolveMaxArtifacts()); } finally { runtime.close(); } @@ -83,7 +75,8 @@ public static void main(String[] args) { } private static void printSummary(ClientFlowOutcome outcome) { - long passedCount = outcome.checks().stream().filter(VerificationCheck::passed).count(); + long passedCount = + outcome.checks().stream().filter(VerificationCheck::passed).count(); long failedCount = outcome.checks().size() - passedCount; System.out.println("==== service-recovery client verification summary ===="); for (VerificationCheck check : outcome.checks()) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/A2AJavaClientEventMapper.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/A2AJavaClientEventMapper.java index 938bb956..c5fd7f3a 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/A2AJavaClientEventMapper.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/A2AJavaClientEventMapper.java @@ -22,8 +22,7 @@ */ public final class A2AJavaClientEventMapper { - private A2AJavaClientEventMapper() { - } + private A2AJavaClientEventMapper() {} public static Map toPayload(ClientEvent event) { if (event instanceof TaskEvent taskEvent) { @@ -33,7 +32,9 @@ public static Map toPayload(ClientEvent event) { } if (event instanceof TaskUpdateEvent taskUpdateEvent) { if (taskUpdateEvent.getUpdateEvent() instanceof TaskStatusUpdateEvent statusUpdateEvent) { - return Map.of("status", Map.of("state", statusUpdateEvent.status().state().name())); + return Map.of( + "status", + Map.of("state", statusUpdateEvent.status().state().name())); } if (taskUpdateEvent.getUpdateEvent() instanceof TaskArtifactUpdateEvent artifactUpdateEvent) { return Map.of("artifact", toArtifactMap(artifactUpdateEvent.artifact())); @@ -92,5 +93,3 @@ private static Map toPartMap(Part part) { return Map.of("value", String.valueOf(part)); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientFlowOutcome.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientFlowOutcome.java index b1c8664a..51809515 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientFlowOutcome.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientFlowOutcome.java @@ -11,5 +11,4 @@ * @param events normalized stream events received from the server, in arrival order * @since 2026-08 */ -public record ClientFlowOutcome(List checks, List> events) { -} +public record ClientFlowOutcome(List checks, List> events) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java index c935d91d..2efd37ee 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java @@ -21,31 +21,26 @@ /** * Core client-side orchestration for the service-recovery e2e sample. * - *

          One client process runs two independent subscription rounds, so each generated prompt is - * validated by the server on its own merits — one API producing a usable prompt can never mask the - * other producing a broken one: + *

          One client process runs two independent subscription rounds, so each generated prompt is validated by the server + * on its own merits — one API producing a usable prompt can never mask the other producing a broken one: * *

            - *
          1. {@code generateNotificationPromptFromText} — natural-language subscription request to a - * rendered prompt, sent to the server over the real a2a-java transport; - *
          2. {@code generateNotificationPromptFromDataWithSchema} — structured input plus data schema to - * a rendered prompt, sent through the same chain. - * - *

            Each round's server task reports - * {@link ServiceRecoverySampleInputs#NOTIFICATION_REPORT_COUNT} notification artifacts and then - * reaches its terminal {@code TASK_STATE_COMPLETED} state, so every stream ends on its own; the - * client only stops earlier when {@code maxArtifacts} is positive. - * - *

            Console logging is trimmed to the essentials: the API inputs and outputs, the LLM requests - * and responses (logged by the shared LLM wrapper), and the received notification artifacts in - * full detail. Stage lines carry a small set of markers for fast visual scanning. + *

          3. {@code generateNotificationPromptFromText} — natural-language subscription request to a rendered prompt, sent + * to the server over the real a2a-java transport; + *
          4. {@code generateNotificationPromptFromDataWithSchema} — structured input plus data schema to a rendered prompt, + * sent through the same chain. + *

            Each round's server task reports {@link ServiceRecoverySampleInputs#NOTIFICATION_REPORT_COUNT} notification + * artifacts and then reaches its terminal {@code TASK_STATE_COMPLETED} state, so every stream ends on its own; + * the client only stops earlier when {@code maxArtifacts} is positive. + *

            Console logging is trimmed to the essentials: the API inputs and outputs, the LLM requests and responses + * (logged by the shared LLM wrapper), and the received notification artifacts in full detail. Stage lines carry a + * small set of markers for fast visual scanning. * * @since 2026-08 */ public final class ClientSampleFlow { - private ClientSampleFlow() { - } + private ClientSampleFlow() {} /** * Runs the client flow. @@ -77,7 +72,8 @@ public static ClientFlowOutcome runClientFlow( "fromText", fromText, a2aRuntime, agentCard, logSink, checks, maxArtifacts, normalizedEvents); } else { checks.add(VerificationCheck.failed( - "fromText: a2a request sent", "skipped, the from-text generation step produced no rendered prompt")); + "fromText: a2a request sent", + "skipped, the from-text generation step produced no rendered prompt")); } MetadataContent fromData = verifyGenerateFromData(promptClient, logSink, checks); @@ -94,8 +90,8 @@ public static ClientFlowOutcome runClientFlow( } /** - * Runs one subscription round: sends the rendered prompt as an A2A message:stream request and - * collects the server events until the task completes (or the artifact budget is reached). + * Runs one subscription round: sends the rendered prompt as an A2A message:stream request and collects the server + * events until the task completes (or the artifact budget is reached). * * @param label round label used in check names and logs * @param content rendered prompt of this round @@ -124,8 +120,8 @@ private static void runSubscriptionRound( int eventCount = 0; String terminalState = ""; try { - for (ClientEvent rawEvent : a2aRuntime.sendMessage( - agentCard, builtRequest.request(), builtRequest.callContext(), logSink)) { + for (ClientEvent rawEvent : + a2aRuntime.sendMessage(agentCard, builtRequest.request(), builtRequest.callContext(), logSink)) { Map payload = A2AJavaClientEventMapper.toPayload(rawEvent); Map normalizedEvent = SampleStreamEventNormalizer.normalize(payload); normalizedEvents.add(normalizedEvent); @@ -148,8 +144,7 @@ private static void runSubscriptionRound( checks.add(VerificationCheck.failed(label + ": a2a response received", describe(error))); return; } - int expectedArtifacts = - maxArtifacts > 0 ? maxArtifacts : ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT; + int expectedArtifacts = maxArtifacts > 0 ? maxArtifacts : ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT; checks.add(VerificationCheck.passed( label + ": a2a response received", "events=" + eventCount + " artifacts=" + artifactCount)); checks.add(expectation( @@ -171,9 +166,7 @@ private static void runSubscriptionRound( } private static MetadataContent verifyGenerateFromText( - SamplePromptClient promptClient, - Consumer logSink, - List checks) { + SamplePromptClient promptClient, Consumer logSink, List checks) { String promptInput = ServiceRecoverySampleInputs.naturalLanguageInput(); emit(logSink, "=================================================================="); emit(logSink, "== API 1 generateNotificationPromptFromText"); @@ -181,12 +174,11 @@ private static MetadataContent verifyGenerateFromText( emit(logSink, "-- input (natural language):"); emit(logSink, promptInput); try { - MetadataContent content = - promptClient.generateNotificationPromptFromText(promptInput, ServiceRecoverySampleInputs.TEMPLATE_URI); + MetadataContent content = promptClient.generateNotificationPromptFromText( + promptInput, ServiceRecoverySampleInputs.TEMPLATE_URI); emit(logSink, "-- output (rendered subscription prompt):"); emit(logSink, content.promptText()); - emit(logSink, "-- templateUri=" + content.templateUri() - + " extensionUri=" + content.extensionUri()); + emit(logSink, "-- templateUri=" + content.templateUri() + " extensionUri=" + content.extensionUri()); checks.add(VerificationCheck.passed("fromText: generation returned", "no exception")); addGenerationChecks(checks, "fromText", content); @@ -198,9 +190,7 @@ private static MetadataContent verifyGenerateFromText( } private static MetadataContent verifyGenerateFromData( - SamplePromptClient promptClient, - Consumer logSink, - List checks) { + SamplePromptClient promptClient, Consumer logSink, List checks) { Map data = ServiceRecoverySampleInputs.structuredInput(); Map schema = ServiceRecoverySampleInputs.dataSchema(); emit(logSink, "=================================================================="); @@ -215,8 +205,7 @@ private static MetadataContent verifyGenerateFromData( data, schema, ServiceRecoverySampleInputs.TEMPLATE_URI); emit(logSink, "-- output (rendered subscription prompt):"); emit(logSink, content.promptText()); - emit(logSink, "-- templateUri=" + content.templateUri() - + " extensionUri=" + content.extensionUri()); + emit(logSink, "-- templateUri=" + content.templateUri() + " extensionUri=" + content.extensionUri()); checks.add(VerificationCheck.passed("fromData: generation returned", "no exception")); addGenerationChecks(checks, "fromData", content); @@ -231,10 +220,7 @@ private static String formatArtifact(Object artifact) { return String.valueOf(artifact); } - private static void addGenerationChecks( - List checks, - String label, - MetadataContent content) { + private static void addGenerationChecks(List checks, String label, MetadataContent content) { checks.add(expectation( label + ": templateUri matches", ServiceRecoverySampleInputs.TEMPLATE_URI.equals(content.templateUri()), @@ -245,10 +231,8 @@ private static void addGenerationChecks( "promptText blank")); checks.add(expectation( label + ": prompt carries the recovery plan execution status", - content.promptText() != null - && content.promptText().contains("业务抢通方案执行状态"), - "promptText does not carry the required " - + "业务抢通方案执行状态" + " slot")); + content.promptText() != null && content.promptText().contains("业务抢通方案执行状态"), + "promptText does not carry the required " + "业务抢通方案执行状态" + " slot")); } private static VerificationCheck expectation(String name, boolean passed, String failureDetail) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/SampleStreamTerminalStateDecider.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/SampleStreamTerminalStateDecider.java index 8acfc56e..5e57044d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/SampleStreamTerminalStateDecider.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/SampleStreamTerminalStateDecider.java @@ -13,8 +13,7 @@ * @since 2026-05 */ public final class SampleStreamTerminalStateDecider { - private SampleStreamTerminalStateDecider() { - } + private SampleStreamTerminalStateDecider() {} public static boolean isTerminal(ClientEvent event) { if (event instanceof TaskEvent taskEvent) { @@ -40,5 +39,3 @@ private static boolean isTerminal(TaskState state) { || state == TaskState.TASK_STATE_AUTH_REQUIRED; } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/package-info.java index ee6496f2..d53491fb 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/package-info.java @@ -4,4 +4,3 @@ * @since 2026-05 */ package net.openan.a2at.sample.service_recovery.client; - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/A2ATNotificationPromptClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/A2ATNotificationPromptClient.java index 1acb610d..92b1f0d5 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/A2ATNotificationPromptClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/A2ATNotificationPromptClient.java @@ -7,8 +7,8 @@ /** * {@link SamplePromptClient} adapter delegating to the real {@link A2ATClient} facade. * - *

            Both generation entry points share one facade instance, proving the two Notification-T - * generation APIs can run sequentially over the same client configuration. + *

            Both generation entry points share one facade instance, proving the two Notification-T generation APIs can run + * sequentially over the same client configuration. * * @since 2026-08 */ diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/SamplePromptClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/SamplePromptClient.java index 1f2a42a7..7d8a8b32 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/SamplePromptClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/SamplePromptClient.java @@ -6,8 +6,8 @@ /** * Prompt-generation bridge used by the client sample flow. * - *

            Exposes the two Notification-T generation entry points verified by this sample: the from-text - * variant and the schema-guided from-data variant. + *

            Exposes the two Notification-T generation entry points verified by this sample: the from-text variant and the + * schema-guided from-data variant. * * @since 2026-08 */ diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/registry/SampleRegistryClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/registry/SampleRegistryClient.java index f9f89a9c..51bad905 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/registry/SampleRegistryClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/registry/SampleRegistryClient.java @@ -11,5 +11,3 @@ public interface SampleRegistryClient { Map queryAgentCardByName(String name, String organization); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/A2AJavaRequestBuilder.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/A2AJavaRequestBuilder.java index 7602a715..b8be8150 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/A2AJavaRequestBuilder.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/A2AJavaRequestBuilder.java @@ -14,8 +14,7 @@ */ public final class A2AJavaRequestBuilder { - private A2AJavaRequestBuilder() { - } + private A2AJavaRequestBuilder() {} public static BuiltA2AJavaRequest buildStreamRequest( String promptText, String extensionUri, String metadataPayload) { @@ -30,5 +29,3 @@ public static BuiltA2AJavaRequest buildStreamRequest( return new BuiltA2AJavaRequest(request, callContext); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/BuiltA2AJavaRequest.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/BuiltA2AJavaRequest.java index 1356e3c8..ea87bb3f 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/BuiltA2AJavaRequest.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/BuiltA2AJavaRequest.java @@ -8,7 +8,4 @@ * * @since 2026-05 */ -public record BuiltA2AJavaRequest(MessageSendParams request, ClientCallContext callContext) { -} - - +public record BuiltA2AJavaRequest(MessageSendParams request, ClientCallContext callContext) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/DefaultSampleClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/DefaultSampleClientRuntime.java index 5690b4d6..32bf7c25 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/DefaultSampleClientRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/DefaultSampleClientRuntime.java @@ -72,8 +72,7 @@ public SamplePromptClient promptClient() { } @Override - public void close() { - } + public void close() {} @Override public Iterable sendMessage( @@ -125,8 +124,8 @@ public Map queryAgentCardByName(String name, String organization .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) { - throw new ValueErrorException( - "AgentCard query by name failed: status=" + response.statusCode() + " body=" + response.body()); + throw new ValueErrorException("AgentCard query by name failed: status=" + response.statusCode() + + " body=" + response.body()); } Map payload = parseObject(response.body()); Object agentCards = payload.get("agentCards"); @@ -152,11 +151,10 @@ private Map queryAgentCardFromServerRoot(String agentCardUrl) { .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) { - throw new ValueErrorException( - "AgentCard query from server root failed: status=" - + response.statusCode() - + " body=" - + response.body()); + throw new ValueErrorException("AgentCard query from server root failed: status=" + + response.statusCode() + + " body=" + + response.body()); } return parseObject(response.body()); } catch (IOException exception) { @@ -227,7 +225,8 @@ private static long resolveTimeoutSeconds(Path envPath) { private static Map parseEnvFile(Path envPath) { try { - List lines = java.nio.file.Files.exists(envPath) ? java.nio.file.Files.readAllLines(envPath) : List.of(); + List lines = + java.nio.file.Files.exists(envPath) ? java.nio.file.Files.readAllLines(envPath) : List.of(); Map values = new LinkedHashMap<>(); for (String line : lines) { String trimmed = line.trim(); @@ -235,7 +234,9 @@ private static Map parseEnvFile(Path envPath) { continue; } int separatorIndex = trimmed.indexOf('='); - values.put(trimmed.substring(0, separatorIndex).trim(), trimmed.substring(separatorIndex + 1).trim()); + values.put( + trimmed.substring(0, separatorIndex).trim(), + trimmed.substring(separatorIndex + 1).trim()); } return values; } catch (IOException exception) { @@ -244,9 +245,6 @@ private static Map parseEnvFile(Path envPath) { } private static Map parseObject(String text) throws IOException { - return OBJECT_MAPPER.readValue(text, new TypeReference>() { - }); + return OBJECT_MAPPER.readValue(text, new TypeReference>() {}); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntimeFactory.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntimeFactory.java index 91cbfc17..98e1ed9d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntimeFactory.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntimeFactory.java @@ -11,5 +11,3 @@ public interface SampleClientRuntimeFactory { SampleClientRuntime create(Path envPath); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/package-info.java index ec84b9ee..fb497717 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/package-info.java @@ -4,15 +4,14 @@ *

            This sample exercises the Notification-T {@code service-recovery} scenario end to end: * *

              - *
            • {@code client} / {@code server} / {@code shared} — full HTTP e2e sample mirroring the - * subscribe-incident layout: the client generates the subscription prompt with the two - * Notification-T generation APIs, sends it over the real a2a-java REST transport, and the server - * validates the rendered prompt with {@code validateAndFillingNotificationData} before reporting - * mock service recovery events as artifacts. + *
            • {@code client} / {@code server} / {@code shared} — full HTTP e2e sample mirroring the subscribe-incident + * layout: the client generates the subscription prompt with the two Notification-T generation APIs, sends it over + * the real a2a-java REST transport, and the server validates the rendered prompt with + * {@code validateAndFillingNotificationData} before reporting mock service recovery events as artifacts. *
            * - *

            The sample inputs and checks target the zh-CN resources; keep {@code A2AT_LANGUAGE=zh-CN} in - * the sample environment files. + *

            The sample inputs and checks target the zh-CN resources; keep {@code A2AT_LANGUAGE=zh-CN} in the sample + * environment files. * * @since 2026-08 */ diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/ServerSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/ServerSampleMain.java index 533acfc8..6562f45a 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/ServerSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/ServerSampleMain.java @@ -24,11 +24,9 @@ */ public final class ServerSampleMain { - private static final String MOCK_RESOURCE_ROOT = - "sample/service-recovery/mock_responses"; + private static final String MOCK_RESOURCE_ROOT = "sample/service-recovery/mock_responses"; - private ServerSampleMain() { - } + private ServerSampleMain() {} /** * Resolves the sample environment file path, preferring an explicit argument. @@ -49,9 +47,7 @@ public static Path resolveEnvPath(String[] args) { * @return bootstrap result carrying the bind address, app and server handle */ public static ServerBootstrapResult runMain( - Path envPath, - SampleServerRuntimeFactory runtimeFactory, - Consumer logSink) { + Path envPath, SampleServerRuntimeFactory runtimeFactory, Consumer logSink) { boolean mockNeeded = SampleMockLlmInstaller.isMockNeeded(envPath); SampleMockLlmInstaller.installLlmLogger(mockNeeded, "server"); Path resolvedEnvPath = SampleMockLlmInstaller.resolveEnvPath(envPath, MOCK_RESOURCE_ROOT); @@ -65,7 +61,8 @@ public static ServerBootstrapResult runMain( Object app = runtime.buildApp(agentCard, notificationValidator); Map registrationPayload; if (runtime instanceof A2AJavaServerRuntime a2aJavaServerRuntime - && a2aJavaServerRuntime.createRestApplication(bind.host(), bind.port()) instanceof org.a2aproject.sdk.spec.AgentCard agentCardModel) { + && a2aJavaServerRuntime.createRestApplication(bind.host(), bind.port()) + instanceof org.a2aproject.sdk.spec.AgentCard agentCardModel) { registrationPayload = RegistryAgentCardMapper.toRegistryRegistrationPayload(agentCardModel); } else { registrationPayload = ServerSampleAgentCardBuilder.buildRegistrationPayload(bind.host(), bind.port()); @@ -74,7 +71,8 @@ public static ServerBootstrapResult runMain( AutoCloseable serverHandle = null; if (runtime instanceof A2AJavaServerRuntime a2aJavaServerRuntime && app instanceof RequestHandler requestHandler - && a2aJavaServerRuntime.createRestApplication(bind.host(), bind.port()) instanceof org.a2aproject.sdk.spec.AgentCard agentCardModel) { + && a2aJavaServerRuntime.createRestApplication(bind.host(), bind.port()) + instanceof org.a2aproject.sdk.spec.AgentCard agentCardModel) { serverHandle = EmbeddedA2AHttpServer.start(bind.host(), bind.port(), agentCardModel, requestHandler); } return new ServerBootstrapResult(bind.host(), bind.port(), app, serverHandle, registrationResult); @@ -86,7 +84,9 @@ public static ServerBootstrapResult runMain( * @param args optional environment file path as the first argument */ public static void main(String[] args) { - runMain(resolveEnvPath(args), envPath -> new DefaultSampleServerRuntime(envPath, System.out::println), + runMain( + resolveEnvPath(args), + envPath -> new DefaultSampleServerRuntime(envPath, System.out::println), System.out::println); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/agentcard/ServerSampleAgentCardBuilder.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/agentcard/ServerSampleAgentCardBuilder.java index 9d43b1c5..f4e65dd4 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/agentcard/ServerSampleAgentCardBuilder.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/agentcard/ServerSampleAgentCardBuilder.java @@ -15,8 +15,7 @@ public final class ServerSampleAgentCardBuilder { static final String NOTIFICATION_T_EXTENSION_URI_NL = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/NL/v1"; - private ServerSampleAgentCardBuilder() { - } + private ServerSampleAgentCardBuilder() {} /** * Builds the sample AgentCard payload for one bind address. @@ -32,28 +31,37 @@ public static Map buildAgentCard(String host, int port) { "version", "1.0.0", "defaultInputModes", List.of("application/json", "text/plain"), "defaultOutputModes", List.of("application/json", "text/plain"), - "provider", Map.of( - "organization", "Huawei", - "url", "https://www.huawei.com"), - "skills", List.of(Map.of( - "id", "Service-Recovery-Subscription", - "name", "Service recovery event reporting", - "description", "Mock service recovery event reporting sample skill", - "tags", List.of("service-recovery", "reporting"))), - "capabilities", Map.of( - "streaming", true, - "pushNotifications", false, - "extensions", List.of( - Map.of( - "uri", NOTIFICATION_T_EXTENSION_URI, - "description", "Extension of structured prompt Notification-T requests."), - Map.of( - "uri", NOTIFICATION_T_EXTENSION_URI_NL, - "description", "Legacy alias of the Notification-T extension."))), - "supportedInterfaces", List.of(Map.of( - "protocolBinding", "HTTP+JSON", - "protocolVersion", "1.0", - "url", "http://" + host + ":" + port))); + "provider", + Map.of( + "organization", "Huawei", + "url", "https://www.huawei.com"), + "skills", + List.of(Map.of( + "id", "Service-Recovery-Subscription", + "name", "Service recovery event reporting", + "description", "Mock service recovery event reporting sample skill", + "tags", List.of("service-recovery", "reporting"))), + "capabilities", + Map.of( + "streaming", true, + "pushNotifications", false, + "extensions", + List.of( + Map.of( + "uri", + NOTIFICATION_T_EXTENSION_URI, + "description", + "Extension of structured prompt Notification-T requests."), + Map.of( + "uri", + NOTIFICATION_T_EXTENSION_URI_NL, + "description", + "Legacy alias of the Notification-T extension."))), + "supportedInterfaces", + List.of(Map.of( + "protocolBinding", "HTTP+JSON", + "protocolVersion", "1.0", + "url", "http://" + host + ":" + port))); } /** diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/NotificationPromptValidator.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/NotificationPromptValidator.java index 2db749ae..d7fc9273 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/NotificationPromptValidator.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/NotificationPromptValidator.java @@ -5,8 +5,8 @@ /** * Sample-owned bridge for validating one rendered notification prompt with the server SDK. * - *

            The bridge wraps {@code A2ATServer.validateAndFillingNotificationData} with the fixed - * service-recovery parameter schema and template URI; a validation failure surfaces as + *

            The bridge wraps {@code A2ATServer.validateAndFillingNotificationData} with the fixed service-recovery parameter + * schema and template URI; a validation failure surfaces as * {@link net.openan.a2at.sdk.core.validation.ContentValidationException}. * * @since 2026-08 diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerFlowInterruptedException.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerFlowInterruptedException.java index 61f6ff10..b6c3a732 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerFlowInterruptedException.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerFlowInterruptedException.java @@ -11,5 +11,3 @@ public ServerFlowInterruptedException(String message) { super(message); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleAgentExecutor.java index cf8d4bfe..fe522911 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleAgentExecutor.java @@ -43,12 +43,7 @@ public ServerSampleAgentExecutor( @Override public void execute(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { ServerSampleFlow.executeServerFlow( - requestContext, - promptValidator, - agentEmitter, - sleepController, - recoveryEventArtifactData, - logSink); + requestContext, promptValidator, agentEmitter, sleepController, recoveryEventArtifactData, logSink); } @Override diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleFlow.java index b2564291..77b6b4ea 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleFlow.java @@ -19,16 +19,14 @@ /** * Mock recurring service-recovery-event reporting flow for the server sample. * - *

            Each received subscription request is validated with the server SDK's - * {@code validateAndFillingNotificationData} API (extracting the subscription parameters), then the - * task reports mock service recovery events as artifacts. After - * {@link ServiceRecoverySampleInputs#NOTIFICATION_REPORT_COUNT} reports the task reaches its - * terminal {@code TASK_STATE_COMPLETED} state, so every subscription task — and with it every - * client stream — ends on its own instead of reporting forever. + *

            Each received subscription request is validated with the server SDK's {@code validateAndFillingNotificationData} + * API (extracting the subscription parameters), then the task reports mock service recovery events as artifacts. After + * {@link ServiceRecoverySampleInputs#NOTIFICATION_REPORT_COUNT} reports the task reaches its terminal + * {@code TASK_STATE_COMPLETED} state, so every subscription task — and with it every client stream — ends on its own + * instead of reporting forever. * - *

            Console logging is trimmed to the essentials: the validation API output (the filled - * parameters) and the task status transitions. The validation LLM request and response are logged - * by the shared LLM wrapper. + *

            Console logging is trimmed to the essentials: the validation API output (the filled parameters) and the task + * status transitions. The validation LLM request and response are logged by the shared LLM wrapper. * * @since 2026-08 */ @@ -47,8 +45,7 @@ public final class ServerSampleFlow { private static final String COMPLETE_MESSAGE = "Service recovery notification reporting finished, subscription task completed"; - private ServerSampleFlow() { - } + private ServerSampleFlow() {} /** * Executes the server-side flow for one subscription request. @@ -90,14 +87,15 @@ public static void executeServerFlow( "Notification validation failed: " + error.getCode() + ": " + error.getMessage())); return; } catch (RuntimeException error) { - emit(logSink, "!! validation failed unexpectedly: " - + error.getClass().getName() + ": " + error.getMessage()); + emit( + logSink, + "!! validation failed unexpectedly: " + error.getClass().getName() + ": " + error.getMessage()); agentEmitter.submit(buildStatusMessage(contextId, taskId, SUBMITTED_MESSAGE)); agentEmitter.reject(buildStatusMessage( contextId, taskId, - "Notification validation failed unexpectedly: " + error.getClass().getName() + ": " - + error.getMessage())); + "Notification validation failed unexpectedly: " + + error.getClass().getName() + ": " + error.getMessage())); return; } @@ -106,7 +104,8 @@ public static void executeServerFlow( emit(logSink, ">> subscription accepted, reporting service recovery events"); try { - for (int reportIndex = 1; reportIndex <= ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT; + for (int reportIndex = 1; + reportIndex <= ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT; reportIndex++) { String artifactId = UUID.randomUUID().toString(); agentEmitter.addArtifact( @@ -116,15 +115,19 @@ public static void executeServerFlow( Map.of("artifactId", artifactId), false, true); - emit(logSink, "- artifact " + reportIndex + "/" + ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT - + " emitted (" + artifactId + ")"); + emit( + logSink, + "- artifact " + reportIndex + "/" + ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT + + " emitted (" + artifactId + ")"); if (reportIndex < ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT) { sleepController.sleepSeconds(ServiceRecoverySampleInputs.NOTIFICATION_REPORT_INTERVAL_SECONDS); } } agentEmitter.complete(buildStatusMessage(contextId, taskId, COMPLETE_MESSAGE)); - emit(logSink, "<< subscription task completed after " - + ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT + " reports"); + emit( + logSink, + "<< subscription task completed after " + ServiceRecoverySampleInputs.NOTIFICATION_REPORT_COUNT + + " reports"); } catch (ServerFlowInterruptedException exception) { throw exception; } catch (RuntimeException exception) { diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSleepController.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSleepController.java index 664500c1..ef57a9d4 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSleepController.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSleepController.java @@ -10,5 +10,3 @@ public interface ServerSleepController { void sleepSeconds(long delaySeconds); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/http/EmbeddedA2AHttpServer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/http/EmbeddedA2AHttpServer.java index cbf2f133..16c2bfbe 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/http/EmbeddedA2AHttpServer.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/http/EmbeddedA2AHttpServer.java @@ -57,11 +57,7 @@ public static EmbeddedA2AHttpServer start( try { HttpServer server = HttpServer.create(new InetSocketAddress(host, port), 0); ExecutorService executorService = new ThreadPoolExecutor( - SERVER_THREAD_COUNT, - SERVER_THREAD_COUNT, - 0L, - TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>()); + SERVER_THREAD_COUNT, SERVER_THREAD_COUNT, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); server.setExecutor(executorService); RestHandler restHandler = new RestHandler( @@ -259,7 +255,9 @@ public void onNext(StreamingEventKind item) { try { String payload = JsonFormat.printer().print(toStreamResponse(item)); String encoded = formatSseFrame(sequence.incrementAndGet(), payload); - LOGGER.trace("[server] sse-write: {}", encoded.replace("\r", "\\r").replace("\n", "\\n")); + LOGGER.trace( + "[server] sse-write: {}", + encoded.replace("\r", "\\r").replace("\n", "\\n")); outputStream.write(encoded.getBytes(StandardCharsets.UTF_8)); outputStream.flush(); LOGGER.trace("[server] sse-write-flushed"); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/package-info.java index 81df1903..0565033b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/package-info.java @@ -4,4 +4,3 @@ * @since 2026-05 */ package net.openan.a2at.sample.service_recovery.server; - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryResponse.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryResponse.java index e54a37f2..c438dafc 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryResponse.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryResponse.java @@ -7,7 +7,4 @@ * * @since 2026-05 */ -public record ServerRegistryResponse(int statusCode, String text, Map jsonBody) { -} - - +public record ServerRegistryResponse(int statusCode, String text, Map jsonBody) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryTransport.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryTransport.java index 2d42d00e..5158f147 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryTransport.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryTransport.java @@ -12,5 +12,3 @@ public interface ServerRegistryTransport { ServerRegistryResponse post(String url, Map payload); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/A2AJavaServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/A2AJavaServerRuntime.java index c6d8407f..db1f54ab 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/A2AJavaServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/A2AJavaServerRuntime.java @@ -9,5 +9,3 @@ public interface A2AJavaServerRuntime { Object createRestApplication(String host, int port); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java index bdddd9cb..905dcf4a 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java @@ -1,7 +1,6 @@ package net.openan.a2at.sample.service_recovery.server.runtime; import java.nio.file.Path; -import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -97,9 +96,7 @@ public NotificationPromptValidator buildNotificationValidator(Path envPath) { A2ATServer server = new A2ATServer(envPath); String templateUri = ServiceRecoverySampleInputs.TEMPLATE_URI; return promptText -> server.validateNotificationPromptAndDataFilling( - promptText, - ServiceRecoverySampleInputs.validationParamSchema(), - templateUri); + promptText, ServiceRecoverySampleInputs.validationParamSchema(), templateUri); } @Override @@ -154,15 +151,14 @@ public static Path resolveDefaultEnvPath() { } /** - * Builds the mock service recovery event artifact payload. The field structure and values are - * loaded from the {@code mock-event-data.json} resource so the zh-CN business fields stay out - * of the Java source. + * Builds the mock service recovery event artifact payload. The field structure and values are loaded from the + * {@code mock-event-data.json} resource so the zh-CN business fields stay out of the Java source. * * @return mock service recovery event data */ static Map buildMockServiceRecoveryEventData() { - return net.openan.a2at.sample.service_recovery.SampleResourceJson - .load("sample/service-recovery/server/mock-event-data.json"); + return net.openan.a2at.sample.service_recovery.SampleResourceJson.load( + "sample/service-recovery/server/mock-event-data.json"); } /** diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntimeFactory.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntimeFactory.java index 642d13c4..6c764dd5 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntimeFactory.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntimeFactory.java @@ -12,5 +12,3 @@ public interface SampleServerRuntimeFactory { SampleServerRuntime create(Path envPath); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBind.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBind.java index 1e54d0ac..a1068f39 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBind.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBind.java @@ -5,7 +5,4 @@ * * @since 2026-05 */ -public record ServerBind(String host, int port) { -} - - +public record ServerBind(String host, int port) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBootstrapResult.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBootstrapResult.java index b57c1943..56f85cfa 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBootstrapResult.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBootstrapResult.java @@ -8,11 +8,4 @@ * @since 2026-05 */ public record ServerBootstrapResult( - String host, - int port, - Object app, - AutoCloseable serverHandle, - Map registrationResult) { -} - - + String host, int port, Object app, AutoCloseable serverHandle, Map registrationResult) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/AgentEndpointResolver.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/AgentEndpointResolver.java index 90b5bd3b..653cb286 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/AgentEndpointResolver.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/AgentEndpointResolver.java @@ -12,8 +12,7 @@ */ public final class AgentEndpointResolver { - private AgentEndpointResolver() { - } + private AgentEndpointResolver() {} public static ResolvedAgentEndpoint resolvePreferredInterface(Map agentCard) { Object supportedInterfacesValue = agentCard.get("supportedInterfaces"); @@ -52,5 +51,3 @@ private static String stringValue(Object value) { return value == null ? "" : String.valueOf(value); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/ResolvedAgentEndpoint.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/ResolvedAgentEndpoint.java index b3cb9eb8..5078b5e7 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/ResolvedAgentEndpoint.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/ResolvedAgentEndpoint.java @@ -5,8 +5,4 @@ * * @since 2026-05 */ -public record ResolvedAgentEndpoint( - String agentName, String protocolBinding, String protocolVersion, String url) { -} - - +public record ResolvedAgentEndpoint(String agentName, String protocolBinding, String protocolVersion, String url) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/env/SampleEnvironmentPathResolver.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/env/SampleEnvironmentPathResolver.java index bc1635a0..e213e62d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/env/SampleEnvironmentPathResolver.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/env/SampleEnvironmentPathResolver.java @@ -10,8 +10,7 @@ */ public final class SampleEnvironmentPathResolver { - private SampleEnvironmentPathResolver() { - } + private SampleEnvironmentPathResolver() {} public static Path resolve(Path directory, String primaryFileName, String fallbackFileName) { Path primary = directory.resolve(primaryFileName); @@ -26,5 +25,3 @@ public static Path resolve(Path directory, String primaryFileName, String fallba return primary; } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/logging/SampleLoggingFormatter.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/logging/SampleLoggingFormatter.java index b69b5e1f..deb78529 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/logging/SampleLoggingFormatter.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/logging/SampleLoggingFormatter.java @@ -30,8 +30,7 @@ public final class SampleLoggingFormatter { private static final List SECRET_KEYWORDS = List.of("api_key", "authorization", "token", "secret", "password"); - private SampleLoggingFormatter() { - } + private SampleLoggingFormatter() {} public static String formatStageLog(String actor, String stage, String detail) { return timestamp() + " [" + actor + "] " + stage + ": " + detail; @@ -40,8 +39,8 @@ public static String formatStageLog(String actor, String stage, String detail) { /** * Prefixes one log message with the sample timestamp. * - *

            Multi-line messages keep their internal line breaks, but every blank line is removed so - * the log output never contains empty lines. + *

            Multi-line messages keep their internal line breaks, but every blank line is removed so the log output never + * contains empty lines. * * @param message log message, may span multiple lines — only the first line carries the stamp * @return timestamped log line without any blank line @@ -144,5 +143,3 @@ private static boolean isSecretKey(String keyName) { return normalizedKeyName.endsWith("_token") && !normalizedKeyName.endsWith("max_tokens"); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleLoggingLLMClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleLoggingLLMClient.java index 62ad4318..b9bcd98c 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleLoggingLLMClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleLoggingLLMClient.java @@ -12,10 +12,10 @@ /** * Logging-aware LLM client wrapper used by the sample. * - *

            Wraps either the real {@link OpenAIClient} or the {@link SampleMockLLMClient} and logs the - * full request (messages, JSON schema, temperature, max tokens) and response (content, model, - * usage) for every structured call, mirroring the Python sample's {@code llm_logger}. In mock mode - * an extra {@code llm-mock} stage line marks that the canned response was used. + *

            Wraps either the real {@link OpenAIClient} or the {@link SampleMockLLMClient} and logs the full request (messages, + * JSON schema, temperature, max tokens) and response (content, model, usage) for every structured call, mirroring the + * Python sample's {@code llm_logger}. In mock mode an extra {@code llm-mock} stage line marks that the canned response + * was used. * * @since 2026-08 */ @@ -61,22 +61,22 @@ public SampleLoggingLLMClient(LLMClientConfig config) { @Override public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { + List> messages, Map jsonSchema, Double temperature, Integer maxTokens) { // Keep the LLM request and response fully visible: they are core debugging artifacts. StringBuilder request = new StringBuilder("llm-request:"); for (Map message : messages) { - request.append("\n--- role=").append(message.get("role")).append(" ---\n").append(message.get("content")); + request.append("\n--- role=") + .append(message.get("role")) + .append(" ---\n") + .append(message.get("content")); } logSink.accept(SampleLoggingFormatter.timestamped(request.toString())); LLMResponse response = delegate.structured(messages, jsonSchema, temperature, maxTokens); if (mockMode) { logSink.accept(SampleLoggingFormatter.formatStageLog(role, "llm-mock", "using canned mock LLM response")); } - logSink.accept(SampleLoggingFormatter.timestamped("llm-response:\n" + response.content() - + "\n(model=" + response.model() + ", usage=" + response.usage() + ")")); + logSink.accept(SampleLoggingFormatter.timestamped("llm-response:\n" + response.content() + "\n(model=" + + response.model() + ", usage=" + response.usage() + ")")); return response; } } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClient.java index 930fc5fb..aaed689b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClient.java @@ -13,23 +13,21 @@ /** * Deterministic LLM client used by the sample when no real API key is available. * - *

            Unlike a call-order replay, this client classifies every structured call by its request - * signature and returns the matching canned response, so the sample flow stays deterministic no - * matter how many LLM steps run or in which order. The service-recovery flow makes exactly four - * LLM calls (two slot extractions on the client, two content validations on the server): + *

            Unlike a call-order replay, this client classifies every structured call by its request signature and returns the + * matching canned response, so the sample flow stays deterministic no matter how many LLM steps run or in which order. + * The service-recovery flow makes exactly four LLM calls (two slot extractions on the client, two content validations + * on the server): * *

              - *
            • slot extraction — the JSON schema carries a {@code slotNames} key; - *
            • content validation — the schema requires {@code semantic_verdict}. + *
            • slot extraction — the JSON schema carries a {@code slotNames} key; + *
            • content validation — the schema requires {@code semantic_verdict}. *
            * - *

            Any other structured call fails loudly instead of returning an unrelated canned response — - * scenario recognition and task-prompt compliance validation never happen in this sample (the - * template URI is fixed and the server only calls - * {@code validateAndFillingNotificationData}). The canned responses are loaded from - * {@code mock_responses} JSON resources on the classpath and mirror what a real LLM returned for - * the bundled service-recovery resources, so the end-to-end sample flow runs without any external - * LLM service. + *

            Any other structured call fails loudly instead of returning an unrelated canned response — scenario recognition + * and task-prompt compliance validation never happen in this sample (the template URI is fixed and the server only + * calls {@code validateAndFillingNotificationData}). The canned responses are loaded from {@code mock_responses} JSON + * resources on the classpath and mirror what a real LLM returned for the bundled service-recovery resources, so the + * end-to-end sample flow runs without any external LLM service. * * @since 2026-08 */ @@ -60,18 +58,14 @@ public SampleMockLLMClient(LLMClientConfig config) { * @param language locale identifier used to select the response files */ public static synchronized void configure(String resourceRoot, String language) { - SampleMockLLMClient.resourceRoot = resourceRoot == null || resourceRoot.isBlank() - ? DEFAULT_RESOURCE_ROOT - : resourceRoot; + SampleMockLLMClient.resourceRoot = + resourceRoot == null || resourceRoot.isBlank() ? DEFAULT_RESOURCE_ROOT : resourceRoot; SampleMockLLMClient.language = language == null || language.isBlank() ? DEFAULT_LANGUAGE : language.trim(); } @Override public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { + List> messages, Map jsonSchema, Double temperature, Integer maxTokens) { String resource = responseResource(jsonSchema); return new LLMResponse( loadResource(resource), @@ -93,7 +87,8 @@ static String responseResource(Map jsonSchema) { private static String loadResource(String resourcePath) { String path = resourceRoot + "/" + language + "/" + resourcePath; - try (InputStream inputStream = SampleMockLLMClient.class.getClassLoader().getResourceAsStream(path)) { + try (InputStream inputStream = + SampleMockLLMClient.class.getClassLoader().getResourceAsStream(path)) { if (inputStream == null) { throw new ValueErrorException("Mock LLM response resource not found: " + path); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLlmInstaller.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLlmInstaller.java index 81a4b954..aa11f2dd 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLlmInstaller.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLlmInstaller.java @@ -14,12 +14,11 @@ /** * Installs a mock LLM mode for the sample when no real LLM API key is available. * - *

            When {@code A2AT_LLM_API_KEY} is missing, blank, or a placeholder template such as - * {@code ${your_api_key}}, the sample installs {@link SampleMockLLMClient} as the provider used by - * the SDK and rewrites the environment file with a non-empty placeholder key. The mock client - * returns canned structured responses loaded from the specified classpath resource root, so the - * end-to-end sample flow runs deterministically on CI environments that have no LLM API key. When a - * real API key is present, the original environment file is used unchanged and no mock is installed. + *

            When {@code A2AT_LLM_API_KEY} is missing, blank, or a placeholder template such as {@code ${your_api_key}}, the + * sample installs {@link SampleMockLLMClient} as the provider used by the SDK and rewrites the environment file with a + * non-empty placeholder key. The mock client returns canned structured responses loaded from the specified classpath + * resource root, so the end-to-end sample flow runs deterministically on CI environments that have no LLM API key. When + * a real API key is present, the original environment file is used unchanged and no mock is installed. * * @since 2026-08 */ @@ -50,10 +49,10 @@ private SampleMockLlmInstaller() {} /** * Returns whether the sample should fall back to the mock LLM provider. * - *

            The mock fallback is needed when no usable real LLM configuration is present: a missing, - * blank, or placeholder {@code A2AT_LLM_API_KEY}, or a missing/blank {@code A2AT_LLM_MODEL} or - * {@code A2AT_LLM_BASE_URL}. When the environment file lacks any of these, the SDK's - * {@code LLMConfigLoader} would fail at startup, so the mock is installed instead. + *

            The mock fallback is needed when no usable real LLM configuration is present: a missing, blank, or placeholder + * {@code A2AT_LLM_API_KEY}, or a missing/blank {@code A2AT_LLM_MODEL} or {@code A2AT_LLM_BASE_URL}. When the + * environment file lacks any of these, the SDK's {@code LLMConfigLoader} would fail at startup, so the mock is + * installed instead. * * @param envPath sample environment file path * @return {@code true} when no usable real LLM configuration is present @@ -72,13 +71,12 @@ public static boolean isMockNeeded(Path envPath) { /** * Installs the logging-aware LLM client wrapper as the {@code openai} provider. * - *

            This method must be called once at startup, before any {@code A2ATClient} or - * {@code A2ATServer} is constructed, so that every LLM-structured call is logged. The wrapper - * delegates to the real {@code OpenAIClient} when {@code mockEnabled} is false, or to - * {@link SampleMockLLMClient} when it is true. + *

            This method must be called once at startup, before any {@code A2ATClient} or {@code A2ATServer} is + * constructed, so that every LLM-structured call is logged. The wrapper delegates to the real {@code OpenAIClient} + * when {@code mockEnabled} is false, or to {@link SampleMockLLMClient} when it is true. * * @param mockEnabled whether the delegate should be the mock client - * @param roleLabel log role label (for example {@code client} or {@code server}) + * @param roleLabel log role label (for example {@code client} or {@code server}) */ public static synchronized void installLlmLogger(boolean mockEnabled, String roleLabel) { if (mockInstalled) { @@ -101,12 +99,12 @@ public static synchronized void installLlmLogger(boolean mockEnabled, String rol /** * Resolves the environment file path that should be used for the current run. * - *

            When a mock fallback is needed, the mock client is configured with the supplied classpath - * resource root (and the language read from the env file), then a temporary environment file is - * written next to the original file (so relative prompt-resource paths keep resolving correctly) - * with a non-empty placeholder API key. Otherwise the original path is returned unchanged. + *

            When a mock fallback is needed, the mock client is configured with the supplied classpath resource root (and + * the language read from the env file), then a temporary environment file is written next to the original file (so + * relative prompt-resource paths keep resolving correctly) with a non-empty placeholder API key. Otherwise the + * original path is returned unchanged. * - * @param envPath sample environment file path + * @param envPath sample environment file path * @param mockResourceRoot classpath root for mock LLM response JSON files * @return the environment file path to use for this run */ @@ -160,7 +158,10 @@ private static Path writeMockEnvFile(Path originalEnvPath) { StringBuilder content = new StringBuilder(); content.append("# Mock LLM environment for sample runs without a real API key.\n"); for (Map.Entry entry : values.entrySet()) { - content.append(entry.getKey()).append('=').append(entry.getValue()).append('\n'); + content.append(entry.getKey()) + .append('=') + .append(entry.getValue()) + .append('\n'); } Files.writeString(tempFile, content.toString(), StandardCharsets.UTF_8); return tempFile; @@ -168,4 +169,4 @@ private static Path writeMockEnvFile(Path originalEnvPath) { throw new ValueErrorException("Failed to create mock LLM environment file", exception); } } -} \ No newline at end of file +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/package-info.java index 0cc64672..c8a8d5ce 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/package-info.java @@ -4,4 +4,3 @@ * @since 2026-05 */ package net.openan.a2at.sample.service_recovery.shared; - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/registry/RegistryAgentCardMapper.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/registry/RegistryAgentCardMapper.java index 1441ecf5..73917393 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/registry/RegistryAgentCardMapper.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/registry/RegistryAgentCardMapper.java @@ -2,8 +2,8 @@ import java.util.List; import java.util.Map; -import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentExtension; import org.a2aproject.sdk.spec.AgentInterface; import org.a2aproject.sdk.spec.AgentProvider; @@ -16,8 +16,7 @@ */ public final class RegistryAgentCardMapper { - private RegistryAgentCardMapper() { - } + private RegistryAgentCardMapper() {} public static AgentCard toA2AJavaAgentCard(Map registryAgentCard) { @SuppressWarnings("unchecked") @@ -25,16 +24,21 @@ public static AgentCard toA2AJavaAgentCard(Map registryAgentCard @SuppressWarnings("unchecked") Map capabilities = (Map) registryAgentCard.get("capabilities"); @SuppressWarnings("unchecked") - List> extensionMaps = (List>) capabilities.getOrDefault("extensions", List.of()); + List> extensionMaps = + (List>) capabilities.getOrDefault("extensions", List.of()); @SuppressWarnings("unchecked") - List> skillMaps = (List>) registryAgentCard.getOrDefault("skills", List.of()); + List> skillMaps = + (List>) registryAgentCard.getOrDefault("skills", List.of()); @SuppressWarnings("unchecked") List> interfaceMaps = (List>) registryAgentCard.getOrDefault("supportedInterfaces", List.of()); return new AgentCard( stringValue(registryAgentCard.get("name")), stringValue(registryAgentCard.get("description")), - provider == null ? null : new AgentProvider(stringValue(provider.get("organization")), stringValue(provider.get("url"))), + provider == null + ? null + : new AgentProvider( + stringValue(provider.get("organization")), stringValue(provider.get("url"))), stringValue(registryAgentCard.get("version")), null, new AgentCapabilities( @@ -85,30 +89,35 @@ public static Map toRegistryAgentCard(AgentCard agentCard) { "version", agentCard.version(), "defaultInputModes", agentCard.defaultInputModes(), "defaultOutputModes", agentCard.defaultOutputModes(), - "provider", Map.of( - "organization", agentCard.provider().organization(), - "url", agentCard.provider().url()), - "skills", agentCard.skills().stream() - .map(skill -> Map.of( - "id", skill.id(), - "name", skill.name(), - "description", skill.description(), - "tags", skill.tags())) - .toList(), - "capabilities", Map.of( - "streaming", agentCard.capabilities().streaming(), - "pushNotifications", agentCard.capabilities().pushNotifications(), - "extensions", agentCard.capabilities().extensions().stream() - .map(extension -> Map.of( - "uri", extension.uri(), - "description", extension.description())) - .toList()), - "supportedInterfaces", agentCard.supportedInterfaces().stream() - .map(agentInterface -> Map.of( - "protocolBinding", agentInterface.protocolBinding(), - "protocolVersion", agentInterface.protocolVersion(), - "url", agentInterface.url())) - .toList()); + "provider", + Map.of( + "organization", agentCard.provider().organization(), + "url", agentCard.provider().url()), + "skills", + agentCard.skills().stream() + .map(skill -> Map.of( + "id", skill.id(), + "name", skill.name(), + "description", skill.description(), + "tags", skill.tags())) + .toList(), + "capabilities", + Map.of( + "streaming", agentCard.capabilities().streaming(), + "pushNotifications", agentCard.capabilities().pushNotifications(), + "extensions", + agentCard.capabilities().extensions().stream() + .map(extension -> Map.of( + "uri", extension.uri(), + "description", extension.description())) + .toList()), + "supportedInterfaces", + agentCard.supportedInterfaces().stream() + .map(agentInterface -> Map.of( + "protocolBinding", agentInterface.protocolBinding(), + "protocolVersion", agentInterface.protocolVersion(), + "url", agentInterface.url())) + .toList()); } private static List stringList(Object value) { @@ -122,5 +131,3 @@ private static String stringValue(Object value) { return value == null ? "" : String.valueOf(value); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/stream/SampleStreamEventNormalizer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/stream/SampleStreamEventNormalizer.java index d72d405b..0f1b91d4 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/stream/SampleStreamEventNormalizer.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/stream/SampleStreamEventNormalizer.java @@ -11,8 +11,7 @@ */ public final class SampleStreamEventNormalizer { - private SampleStreamEventNormalizer() { - } + private SampleStreamEventNormalizer() {} public static Map normalize(Map payload) { if (payload == null || payload.isEmpty()) { @@ -44,5 +43,3 @@ public static Map normalize(Map payload) { throw new ValueErrorException("Unsupported SSE payload: " + payload); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMain.java index adc26670..2aa69005 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMain.java @@ -19,11 +19,9 @@ */ public final class ClientSampleMain { - private static final String MOCK_RESOURCE_ROOT = - "sample/subscribe-incident/mock_responses"; + private static final String MOCK_RESOURCE_ROOT = "sample/subscribe-incident/mock_responses"; - private ClientSampleMain() { - } + private ClientSampleMain() {} public static Path resolveEnvPath(String[] args) { return args.length > 0 ? Path.of(args[0]) : DefaultSampleClientRuntime.resolveDefaultEnvPath(); @@ -34,9 +32,7 @@ public static List> runMain(Path envPath, SampleClientRuntim } public static List> runMain( - Path envPath, - SampleClientRuntimeFactory runtimeFactory, - Consumer logSink) { + Path envPath, SampleClientRuntimeFactory runtimeFactory, Consumer logSink) { boolean mockNeeded = SampleMockLlmInstaller.isMockNeeded(envPath); SampleMockLlmInstaller.installLlmLogger(mockNeeded, "client"); Path resolvedEnvPath = SampleMockLlmInstaller.resolveEnvPath(envPath, MOCK_RESOURCE_ROOT); @@ -48,7 +44,8 @@ public static List> runMain( if (!(runtime instanceof A2AJavaClientRuntime a2aRuntime)) { throw new IllegalStateException("Sample client runtime must implement A2AJavaClientRuntime"); } - Map scenarioPayload = SampleScenarioLoader.loadClasspathScenario("sample/subscribe-incident/client/scenario.json"); + Map scenarioPayload = + SampleScenarioLoader.loadClasspathScenario("sample/subscribe-incident/client/scenario.json"); return ClientSampleFlow.runClientFlow( scenarioPayload, runtime.registryClient(), @@ -111,4 +108,3 @@ private static void emit(Consumer logSink, String message) { } } } - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMarker.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMarker.java index e7a28d32..3f6f2356 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMarker.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMarker.java @@ -7,7 +7,5 @@ */ public final class ClientSampleMarker { - private ClientSampleMarker() { - } + private ClientSampleMarker() {} } - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/A2AJavaClientEventMapper.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/A2AJavaClientEventMapper.java index 742417c9..289dfa93 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/A2AJavaClientEventMapper.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/A2AJavaClientEventMapper.java @@ -22,8 +22,7 @@ */ public final class A2AJavaClientEventMapper { - private A2AJavaClientEventMapper() { - } + private A2AJavaClientEventMapper() {} public static Map toPayload(ClientEvent event) { if (event instanceof TaskEvent taskEvent) { @@ -33,7 +32,9 @@ public static Map toPayload(ClientEvent event) { } if (event instanceof TaskUpdateEvent taskUpdateEvent) { if (taskUpdateEvent.getUpdateEvent() instanceof TaskStatusUpdateEvent statusUpdateEvent) { - return Map.of("status", Map.of("state", statusUpdateEvent.status().state().name())); + return Map.of( + "status", + Map.of("state", statusUpdateEvent.status().state().name())); } if (taskUpdateEvent.getUpdateEvent() instanceof TaskArtifactUpdateEvent artifactUpdateEvent) { return Map.of("artifact", toArtifactMap(artifactUpdateEvent.artifact())); @@ -92,5 +93,3 @@ private static Map toPartMap(Part part) { return Map.of("value", String.valueOf(part)); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientSampleFlow.java index f88ca5b7..7918efa2 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientSampleFlow.java @@ -38,8 +38,7 @@ public final class ClientSampleFlow { private static final String LANGUAGE_EN_US = "en-US"; - private ClientSampleFlow() { - } + private ClientSampleFlow() {} public static List> runClientFlow( Map scenarioPayload, @@ -48,7 +47,8 @@ public static List> runClientFlow( A2AJavaClientRuntime a2aRuntime, AgentEndpointCache endpointCache, Consumer logSink) { - return runClientFlow(scenarioPayload, registryClient, promptClient, a2aRuntime, endpointCache, logSink, 0, "zh-CN"); + return runClientFlow( + scenarioPayload, registryClient, promptClient, a2aRuntime, endpointCache, logSink, 0, "zh-CN"); } public static List> runClientFlow( @@ -60,7 +60,14 @@ public static List> runClientFlow( Consumer logSink, int maxArtifacts) { return runClientFlow( - scenarioPayload, registryClient, promptClient, a2aRuntime, endpointCache, logSink, maxArtifacts, "zh-CN"); + scenarioPayload, + registryClient, + promptClient, + a2aRuntime, + endpointCache, + logSink, + maxArtifacts, + "zh-CN"); } public static List> runClientFlow( @@ -81,7 +88,9 @@ public static List> runClientFlow( emit( logSink, SampleLoggingFormatter.formatStageLog( - "client", "agentcard-resolved", "url=" + endpoint.url() + " binding=" + endpoint.protocolBinding())); + "client", + "agentcard-resolved", + "url=" + endpoint.url() + " binding=" + endpoint.protocolBinding())); String promptInput = buildPromptInput(language); emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "prompt-input", promptInput)); @@ -92,26 +101,39 @@ public static List> runClientFlow( BuiltA2AJavaRequest builtRequest = A2AJavaRequestBuilder.buildStreamRequest( promptText, NOTIFICATION_T_EXTENSION_URI_NL, buildRequestMetadata(scenarioPayload)); emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "a2a-request-body", builtRequest.request())); - emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "request-headers", builtRequest.callContext().getHeaders())); - emit(logSink, SampleLoggingFormatter.formatStageLog( - "client", "request-built", "extension=" + builtRequest.callContext().getHeaders().get("A2A-Extensions"))); + emit( + logSink, + SampleLoggingFormatter.formatPayloadLog( + "client", "request-headers", builtRequest.callContext().getHeaders())); + emit( + logSink, + SampleLoggingFormatter.formatStageLog( + "client", + "request-built", + "extension=" + builtRequest.callContext().getHeaders().get("A2A-Extensions"))); List> normalizedEvents = new ArrayList<>(); int artifactCount = 0; - for (ClientEvent rawEvent : a2aRuntime.sendMessage( - agentCard, builtRequest.request(), builtRequest.callContext(), logSink)) { + for (ClientEvent rawEvent : + a2aRuntime.sendMessage(agentCard, builtRequest.request(), builtRequest.callContext(), logSink)) { Map payload = A2AJavaClientEventMapper.toPayload(rawEvent); Map normalizedEvent = SampleStreamEventNormalizer.normalize(payload); normalizedEvents.add(normalizedEvent); if ("status".equals(normalizedEvent.get("kind"))) { - emit(logSink, SampleLoggingFormatter.formatStageLog( - "client", "task-status", String.valueOf(normalizedEvent.get("state")))); + emit( + logSink, + SampleLoggingFormatter.formatStageLog( + "client", "task-status", String.valueOf(normalizedEvent.get("state")))); } else if ("message".equals(normalizedEvent.get("kind"))) { - emit(logSink, SampleLoggingFormatter.formatStageLog( - "client", "task-message", String.valueOf(normalizedEvent.get("text")))); + emit( + logSink, + SampleLoggingFormatter.formatStageLog( + "client", "task-message", String.valueOf(normalizedEvent.get("text")))); } else if ("artifact".equals(normalizedEvent.get("kind"))) { - emit(logSink, SampleLoggingFormatter.formatPayloadLog( - "client", "task-artifact", normalizedEvent.get("artifact"))); + emit( + logSink, + SampleLoggingFormatter.formatPayloadLog( + "client", "task-artifact", normalizedEvent.get("artifact"))); artifactCount++; if (maxArtifacts > 0 && artifactCount >= maxArtifacts) { emit(logSink, "[client] max-artifacts-reached: " + maxArtifacts); @@ -143,7 +165,9 @@ private static String buildRequestMetadata(Map scenarioPayload) } private static String buildPromptInput(String language) { - return LANGUAGE_EN_US.equalsIgnoreCase(language) ? NATURAL_LANGUAGE_PROMPT_INPUT_EN : NATURAL_LANGUAGE_PROMPT_INPUT_ZH; + return LANGUAGE_EN_US.equalsIgnoreCase(language) + ? NATURAL_LANGUAGE_PROMPT_INPUT_EN + : NATURAL_LANGUAGE_PROMPT_INPUT_ZH; } private static AgentCardQuery resolveAgentCardQuery(Map scenarioPayload) { @@ -173,8 +197,5 @@ private static String stringOrDefault(Object value, String defaultValue) { return resolved.isEmpty() ? defaultValue : resolved; } - private record AgentCardQuery(String name, String organization) { - } + private record AgentCardQuery(String name, String organization) {} } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/SampleStreamTerminalStateDecider.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/SampleStreamTerminalStateDecider.java index 9bcc55ed..5df7dbc5 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/SampleStreamTerminalStateDecider.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/SampleStreamTerminalStateDecider.java @@ -13,8 +13,7 @@ * @since 2026-05 */ public final class SampleStreamTerminalStateDecider { - private SampleStreamTerminalStateDecider() { - } + private SampleStreamTerminalStateDecider() {} public static boolean isTerminal(ClientEvent event) { if (event instanceof TaskEvent taskEvent) { @@ -40,5 +39,3 @@ private static boolean isTerminal(TaskState state) { || state == TaskState.TASK_STATE_AUTH_REQUIRED; } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/package-info.java index 65675bd7..62d006c0 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/package-info.java @@ -4,4 +4,3 @@ * @since 2026-05 */ package net.openan.a2at.sample.subscribe_incident.client; - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptClient.java index 83b6233b..2a32dab9 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptClient.java @@ -11,5 +11,3 @@ public interface SamplePromptClient { PromptGenerationResult generateTaskPrompt(Object userInput); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptInputAdapter.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptInputAdapter.java index 33b59eff..a0698476 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptInputAdapter.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptInputAdapter.java @@ -18,16 +18,16 @@ public final class SamplePromptInputAdapter { private static final String SLOT_REPORT_FORMAT = "上报通知数据格式"; - private SamplePromptInputAdapter() { - } + private SamplePromptInputAdapter() {} public static Map adapt(Map scenarioPayload) { Map adapted = new LinkedHashMap<>(scenarioPayload); adapted.put(SLOT_TOPIC, stringOrDefault(scenarioPayload.get(SLOT_TOPIC), "Incident")); - adapted.put(SLOT_CONDITION, stringOrDefault(scenarioPayload.get(SLOT_CONDITION), buildConditionText(scenarioPayload))); - adapted.put(SLOT_REPORT_FORMAT, stringOrDefault( - scenarioPayload.get(SLOT_REPORT_FORMAT), - "通过DataPart上报Incident数据")); + adapted.put( + SLOT_CONDITION, + stringOrDefault(scenarioPayload.get(SLOT_CONDITION), buildConditionText(scenarioPayload))); + adapted.put( + SLOT_REPORT_FORMAT, stringOrDefault(scenarioPayload.get(SLOT_REPORT_FORMAT), "通过DataPart上报Incident数据")); return adapted; } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/registry/SampleRegistryClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/registry/SampleRegistryClient.java index 1db9272c..8c11d971 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/registry/SampleRegistryClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/registry/SampleRegistryClient.java @@ -11,5 +11,3 @@ public interface SampleRegistryClient { Map queryAgentCardByName(String name, String organization); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/A2AJavaRequestBuilder.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/A2AJavaRequestBuilder.java index d8f9f2b7..386a1cc3 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/A2AJavaRequestBuilder.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/A2AJavaRequestBuilder.java @@ -14,8 +14,7 @@ */ public final class A2AJavaRequestBuilder { - private A2AJavaRequestBuilder() { - } + private A2AJavaRequestBuilder() {} public static BuiltA2AJavaRequest buildStreamRequest( String promptText, String extensionUri, String metadataPayload) { @@ -30,5 +29,3 @@ public static BuiltA2AJavaRequest buildStreamRequest( return new BuiltA2AJavaRequest(request, callContext); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/BuiltA2AJavaRequest.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/BuiltA2AJavaRequest.java index f35a718a..7952b3fa 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/BuiltA2AJavaRequest.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/BuiltA2AJavaRequest.java @@ -8,7 +8,4 @@ * * @since 2026-05 */ -public record BuiltA2AJavaRequest(MessageSendParams request, ClientCallContext callContext) { -} - - +public record BuiltA2AJavaRequest(MessageSendParams request, ClientCallContext callContext) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/A2AJavaClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/A2AJavaClientRuntime.java index 89281732..6074d927 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/A2AJavaClientRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/A2AJavaClientRuntime.java @@ -21,5 +21,3 @@ Iterable sendMessage( ClientCallContext callContext, Consumer logSink); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java index 3a0ad498..1432d47f 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java @@ -61,7 +61,8 @@ public DefaultSampleClientRuntime(Path envPath) { } public static Path resolveDefaultEnvPath() { - Path sampleEnvDir = Path.of("a2a-t-sample", "src", "main", "resources", "sample", "subscribe-incident", "client"); + Path sampleEnvDir = + Path.of("a2a-t-sample", "src", "main", "resources", "sample", "subscribe-incident", "client"); return SampleEnvironmentPathResolver.resolve(sampleEnvDir, "client.env", "client.env"); } @@ -81,8 +82,7 @@ public AgentEndpointCache endpointCache() { } @Override - public void close() { - } + public void close() {} @Override public Object createStreamingClient(String agentBaseUrl) { @@ -160,8 +160,8 @@ public Map queryAgentCardByName(String name, String organization .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) { - throw new ValueErrorException( - "AgentCard query by name failed: status=" + response.statusCode() + " body=" + response.body()); + throw new ValueErrorException("AgentCard query by name failed: status=" + response.statusCode() + + " body=" + response.body()); } Map payload = parseObject(response.body()); Object agentCards = payload.get("agentCards"); @@ -187,11 +187,10 @@ private Map queryAgentCardFromServerRoot(String agentCardUrl) { .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) { - throw new ValueErrorException( - "AgentCard query from server root failed: status=" - + response.statusCode() - + " body=" - + response.body()); + throw new ValueErrorException("AgentCard query from server root failed: status=" + + response.statusCode() + + " body=" + + response.body()); } return parseObject(response.body()); } catch (IOException exception) { @@ -262,7 +261,8 @@ private static long resolveTimeoutSeconds(Path envPath) { private static Map parseEnvFile(Path envPath) { try { - List lines = java.nio.file.Files.exists(envPath) ? java.nio.file.Files.readAllLines(envPath) : List.of(); + List lines = + java.nio.file.Files.exists(envPath) ? java.nio.file.Files.readAllLines(envPath) : List.of(); Map values = new LinkedHashMap<>(); for (String line : lines) { String trimmed = line.trim(); @@ -270,7 +270,9 @@ private static Map parseEnvFile(Path envPath) { continue; } int separatorIndex = trimmed.indexOf('='); - values.put(trimmed.substring(0, separatorIndex).trim(), trimmed.substring(separatorIndex + 1).trim()); + values.put( + trimmed.substring(0, separatorIndex).trim(), + trimmed.substring(separatorIndex + 1).trim()); } return values; } catch (IOException exception) { @@ -279,9 +281,6 @@ private static Map parseEnvFile(Path envPath) { } private static Map parseObject(String text) throws IOException { - return OBJECT_MAPPER.readValue(text, new TypeReference>() { - }); + return OBJECT_MAPPER.readValue(text, new TypeReference>() {}); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntime.java index af23a488..8ef3e998 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntime.java @@ -20,5 +20,3 @@ public interface SampleClientRuntime extends AutoCloseable { @Override void close(); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntimeFactory.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntimeFactory.java index 0ad39e73..2023783a 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntimeFactory.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntimeFactory.java @@ -11,5 +11,3 @@ public interface SampleClientRuntimeFactory { SampleClientRuntime create(Path envPath); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/package-info.java index af0d34b9..c935239c 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/package-info.java @@ -4,4 +4,3 @@ * @since 2026-05 */ package net.openan.a2at.sample.subscribe_incident; - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java index bc455fc2..83498171 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java @@ -23,20 +23,16 @@ */ public final class ServerSampleMain { - private static final String MOCK_RESOURCE_ROOT = - "sample/subscribe-incident/mock_responses"; + private static final String MOCK_RESOURCE_ROOT = "sample/subscribe-incident/mock_responses"; - private ServerSampleMain() { - } + private ServerSampleMain() {} public static Path resolveEnvPath(String[] args) { return args.length > 0 ? Path.of(args[0]) : DefaultSampleServerRuntime.resolveDefaultEnvPath(); } public static ServerBootstrapResult runMain( - Path envPath, - SampleServerRuntimeFactory runtimeFactory, - Consumer logSink) { + Path envPath, SampleServerRuntimeFactory runtimeFactory, Consumer logSink) { boolean mockNeeded = SampleMockLlmInstaller.isMockNeeded(envPath); SampleMockLlmInstaller.installLlmLogger(mockNeeded, "server"); Path resolvedEnvPath = SampleMockLlmInstaller.resolveEnvPath(envPath, MOCK_RESOURCE_ROOT); @@ -51,7 +47,8 @@ public static ServerBootstrapResult runMain( Object app = runtime.buildApp(agentCard, promptChecker); Map registrationPayload; if (runtime instanceof A2AJavaServerRuntime a2aJavaServerRuntime - && a2aJavaServerRuntime.createRestApplication(bind.host(), bind.port()) instanceof org.a2aproject.sdk.spec.AgentCard agentCardModel) { + && a2aJavaServerRuntime.createRestApplication(bind.host(), bind.port()) + instanceof org.a2aproject.sdk.spec.AgentCard agentCardModel) { registrationPayload = RegistryAgentCardMapper.toRegistryRegistrationPayload(agentCardModel); } else { registrationPayload = ServerSampleAgentCardBuilder.buildRegistrationPayload(bind.host(), bind.port()); @@ -60,14 +57,16 @@ public static ServerBootstrapResult runMain( if ("success".equals(registrationResult.get("status"))) { emit(logSink, "[server] agent-card registration: success"); } else { - emit(logSink, + emit( + logSink, "[server] agent-card registration: skipped (no registry-center available, " + "client direct mode is used instead)"); } AutoCloseable serverHandle = null; if (runtime instanceof A2AJavaServerRuntime a2aJavaServerRuntime && app instanceof RequestHandler requestHandler - && a2aJavaServerRuntime.createRestApplication(bind.host(), bind.port()) instanceof org.a2aproject.sdk.spec.AgentCard agentCardModel) { + && a2aJavaServerRuntime.createRestApplication(bind.host(), bind.port()) + instanceof org.a2aproject.sdk.spec.AgentCard agentCardModel) { serverHandle = EmbeddedA2AHttpServer.start(bind.host(), bind.port(), agentCardModel, requestHandler); emit(logSink, "[server] http server started: http://" + bind.host() + ":" + bind.port()); } @@ -75,7 +74,10 @@ public static ServerBootstrapResult runMain( } public static void main(String[] args) { - runMain(resolveEnvPath(args), envPath -> new DefaultSampleServerRuntime(envPath, System.out::println), System.out::println); + runMain( + resolveEnvPath(args), + envPath -> new DefaultSampleServerRuntime(envPath, System.out::println), + System.out::println); } private static void emit(Consumer logSink, String message) { @@ -84,4 +86,3 @@ private static void emit(Consumer logSink, String message) { } } } - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMarker.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMarker.java index 49997e33..7a490eea 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMarker.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMarker.java @@ -7,7 +7,5 @@ */ public final class ServerSampleMarker { - private ServerSampleMarker() { - } + private ServerSampleMarker() {} } - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/agentcard/ServerSampleAgentCardBuilder.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/agentcard/ServerSampleAgentCardBuilder.java index ed7f4e41..62b74c98 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/agentcard/ServerSampleAgentCardBuilder.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/agentcard/ServerSampleAgentCardBuilder.java @@ -15,8 +15,7 @@ public final class ServerSampleAgentCardBuilder { static final String NOTIFICATION_T_EXTENSION_URI_NL = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/NL/v1"; - private ServerSampleAgentCardBuilder() { - } + private ServerSampleAgentCardBuilder() {} public static Map buildAgentCard(String host, int port) { return Map.of( @@ -25,33 +24,40 @@ public static Map buildAgentCard(String host, int port) { "version", "1.0.0", "defaultInputModes", List.of("application/json", "text/plain"), "defaultOutputModes", List.of("application/json", "text/plain"), - "provider", Map.of( - "organization", "Huawei", - "url", "https://www.huawei.com"), - "skills", List.of(Map.of( - "id", "Incident-Subscription", - "name", "Incident reporting", - "description", "Mock incident reporting sample skill", - "tags", List.of("incident", "reporting"))), - "capabilities", Map.of( - "streaming", true, - "pushNotifications", false, - "extensions", List.of( - Map.of( - "uri", TASK_T_EXTENSION_URI_NL, - "description", "Extension of structured prompt Task-T requests."), - Map.of( - "uri", NOTIFICATION_T_EXTENSION_URI_NL, - "description", "Extension of structured prompt Notification-T requests."))), - "supportedInterfaces", List.of(Map.of( - "protocolBinding", "HTTP+JSON", - "protocolVersion", "1.0", - "url", "http://" + host + ":" + port))); + "provider", + Map.of( + "organization", "Huawei", + "url", "https://www.huawei.com"), + "skills", + List.of(Map.of( + "id", "Incident-Subscription", + "name", "Incident reporting", + "description", "Mock incident reporting sample skill", + "tags", List.of("incident", "reporting"))), + "capabilities", + Map.of( + "streaming", true, + "pushNotifications", false, + "extensions", + List.of( + Map.of( + "uri", + TASK_T_EXTENSION_URI_NL, + "description", + "Extension of structured prompt Task-T requests."), + Map.of( + "uri", + NOTIFICATION_T_EXTENSION_URI_NL, + "description", + "Extension of structured prompt Notification-T requests."))), + "supportedInterfaces", + List.of(Map.of( + "protocolBinding", "HTTP+JSON", + "protocolVersion", "1.0", + "url", "http://" + host + ":" + port))); } public static Map buildRegistrationPayload(String host, int port) { return Map.of("agentCards", List.of(buildAgentCard(host, port))); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/PromptComplianceChecker.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/PromptComplianceChecker.java index 7db51f8e..81343e76 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/PromptComplianceChecker.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/PromptComplianceChecker.java @@ -12,5 +12,3 @@ public interface PromptComplianceChecker { PromptComplianceResult checkTaskPrompt(String processedPromptText); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerFlowInterruptedException.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerFlowInterruptedException.java index f48c3132..81e03f06 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerFlowInterruptedException.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerFlowInterruptedException.java @@ -11,5 +11,3 @@ public ServerFlowInterruptedException(String message) { super(message); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleAgentExecutor.java index 3b9495eb..c2cdeb7f 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleAgentExecutor.java @@ -35,12 +35,7 @@ public ServerSampleAgentExecutor( @Override public void execute(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { ServerSampleFlow.executeServerFlow( - requestContext, - promptChecker, - agentEmitter, - sleepController, - incidentArtifactData, - logSink); + requestContext, promptChecker, agentEmitter, sleepController, incidentArtifactData, logSink); } @Override @@ -48,5 +43,3 @@ public void cancel(RequestContext requestContext, AgentEmitter agentEmitter) thr agentEmitter.cancel(); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleFlow.java index 257298ce..632db86b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleFlow.java @@ -4,8 +4,8 @@ import java.util.Map; import java.util.UUID; import java.util.function.Consumer; -import net.openan.a2at.sample.subscribe_incident.shared.logging.SampleLoggingFormatter; import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.logging.SampleLoggingFormatter; import net.openan.a2at.sdk.server.model.PromptComplianceFailure; import net.openan.a2at.sdk.server.model.PromptComplianceResult; import org.a2aproject.sdk.server.ServerCallContext; @@ -33,8 +33,7 @@ public final class ServerSampleFlow { private static final String WORKING_MESSAGE = "Incident reporting task in progress"; - private ServerSampleFlow() { - } + private ServerSampleFlow() {} public static void executeServerFlow( RequestContext requestContext, @@ -71,9 +70,7 @@ public static void executeServerFlow( emit(logSink, SampleLoggingFormatter.formatStageLog("server", "task-status", "TASK_STATE_SUBMITTED")); if (!complianceResult.success()) { agentEmitter.reject(buildStatusMessage( - contextId, - taskId, - "Prompt validation failed: " + failureMessage(complianceResult.failure()))); + contextId, taskId, "Prompt validation failed: " + failureMessage(complianceResult.failure()))); emit(logSink, SampleLoggingFormatter.formatStageLog("server", "task-status", "TASK_STATE_REJECTED")); return; } @@ -96,7 +93,8 @@ public static void executeServerFlow( } catch (ServerFlowInterruptedException exception) { throw exception; } catch (RuntimeException exception) { - agentEmitter.fail(buildStatusMessage(contextId, taskId, "Mock incident stream failed: " + exception.getMessage())); + agentEmitter.fail( + buildStatusMessage(contextId, taskId, "Mock incident stream failed: " + exception.getMessage())); emit(logSink, SampleLoggingFormatter.formatStageLog("server", "task-status", "TASK_STATE_FAILED")); } } @@ -157,5 +155,3 @@ private static String failureMessage(PromptComplianceFailure failure) { return failure == null ? "unknown error" : failure.message(); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSleepController.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSleepController.java index 6a9eb21b..fea63496 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSleepController.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSleepController.java @@ -10,5 +10,3 @@ public interface ServerSleepController { void sleepSeconds(long delaySeconds); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/EmbeddedA2AHttpServer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/EmbeddedA2AHttpServer.java index 62db2a64..33c0c077 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/EmbeddedA2AHttpServer.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/EmbeddedA2AHttpServer.java @@ -57,11 +57,7 @@ public static EmbeddedA2AHttpServer start( try { HttpServer server = HttpServer.create(new InetSocketAddress(host, port), 0); ExecutorService executorService = new ThreadPoolExecutor( - SERVER_THREAD_COUNT, - SERVER_THREAD_COUNT, - 0L, - TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>()); + SERVER_THREAD_COUNT, SERVER_THREAD_COUNT, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); server.setExecutor(executorService); RestHandler restHandler = new RestHandler( @@ -259,7 +255,9 @@ public void onNext(StreamingEventKind item) { try { String payload = JsonFormat.printer().print(toStreamResponse(item)); String encoded = formatSseFrame(sequence.incrementAndGet(), payload); - LOGGER.trace("[server] sse-write: {}", encoded.replace("\r", "\\r").replace("\n", "\\n")); + LOGGER.trace( + "[server] sse-write: {}", + encoded.replace("\r", "\\r").replace("\n", "\\n")); outputStream.write(encoded.getBytes(StandardCharsets.UTF_8)); outputStream.flush(); LOGGER.trace("[server] sse-write-flushed"); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/ServerSampleApplication.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/ServerSampleApplication.java index e59b7bc6..fb6a10e4 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/ServerSampleApplication.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/ServerSampleApplication.java @@ -8,7 +8,4 @@ * * @since 2026-05 */ -public record ServerSampleApplication(Map agentCard, PromptComplianceChecker promptChecker) { -} - - +public record ServerSampleApplication(Map agentCard, PromptComplianceChecker promptChecker) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/package-info.java index f1547c5d..f4b1caaf 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/package-info.java @@ -4,4 +4,3 @@ * @since 2026-05 */ package net.openan.a2at.sample.subscribe_incident.server; - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryResponse.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryResponse.java index f37e1eae..3f0f97b8 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryResponse.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryResponse.java @@ -7,7 +7,4 @@ * * @since 2026-05 */ -public record ServerRegistryResponse(int statusCode, String text, Map jsonBody) { -} - - +public record ServerRegistryResponse(int statusCode, String text, Map jsonBody) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryTransport.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryTransport.java index 95e1e6c5..85ad36d9 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryTransport.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryTransport.java @@ -12,5 +12,3 @@ public interface ServerRegistryTransport { ServerRegistryResponse post(String url, Map payload); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/A2AJavaServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/A2AJavaServerRuntime.java index 095e7ac9..8fc8df9a 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/A2AJavaServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/A2AJavaServerRuntime.java @@ -9,5 +9,3 @@ public interface A2AJavaServerRuntime { Object createRestApplication(String host, int port); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java index 4e5f8761..5e14ab76 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java @@ -128,7 +128,8 @@ public Map registerAgentCard(Map registrationPay } public static Path resolveDefaultEnvPath() { - Path sampleEnvDir = Path.of("a2a-t-sample", "src", "main", "resources", "sample", "subscribe-incident", "server"); + Path sampleEnvDir = + Path.of("a2a-t-sample", "src", "main", "resources", "sample", "subscribe-incident", "server"); return SampleEnvironmentPathResolver.resolve(sampleEnvDir, "server.env", "server.env"); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntime.java index 988bc58b..a5e95648 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntime.java @@ -21,5 +21,3 @@ public interface SampleServerRuntime { Map registerAgentCard(Map registrationPayload, Path envPath); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntimeFactory.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntimeFactory.java index d9b0a839..030911e1 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntimeFactory.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntimeFactory.java @@ -12,5 +12,3 @@ public interface SampleServerRuntimeFactory { SampleServerRuntime create(Path envPath); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBind.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBind.java index 6c50a872..73b25430 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBind.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBind.java @@ -5,7 +5,4 @@ * * @since 2026-05 */ -public record ServerBind(String host, int port) { -} - - +public record ServerBind(String host, int port) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBootstrapResult.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBootstrapResult.java index bd7ab8a8..63d58b5a 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBootstrapResult.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBootstrapResult.java @@ -8,11 +8,4 @@ * @since 2026-05 */ public record ServerBootstrapResult( - String host, - int port, - Object app, - AutoCloseable serverHandle, - Map registrationResult) { -} - - + String host, int port, Object app, AutoCloseable serverHandle, Map registrationResult) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/SharedSampleMarker.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/SharedSampleMarker.java index 0018da2f..36e44ec3 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/SharedSampleMarker.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/SharedSampleMarker.java @@ -7,7 +7,5 @@ */ public final class SharedSampleMarker { - private SharedSampleMarker() { - } + private SharedSampleMarker() {} } - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointCache.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointCache.java index baf3a07c..ad7a8d0b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointCache.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointCache.java @@ -21,5 +21,3 @@ public ResolvedAgentEndpoint getCurrent() { return current; } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointResolver.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointResolver.java index ddae2f03..ea2eda19 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointResolver.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointResolver.java @@ -12,8 +12,7 @@ */ public final class AgentEndpointResolver { - private AgentEndpointResolver() { - } + private AgentEndpointResolver() {} public static ResolvedAgentEndpoint resolvePreferredInterface(Map agentCard) { Object supportedInterfacesValue = agentCard.get("supportedInterfaces"); @@ -52,5 +51,3 @@ private static String stringValue(Object value) { return value == null ? "" : String.valueOf(value); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/ResolvedAgentEndpoint.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/ResolvedAgentEndpoint.java index 330a7119..a6206d01 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/ResolvedAgentEndpoint.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/ResolvedAgentEndpoint.java @@ -5,8 +5,4 @@ * * @since 2026-05 */ -public record ResolvedAgentEndpoint( - String agentName, String protocolBinding, String protocolVersion, String url) { -} - - +public record ResolvedAgentEndpoint(String agentName, String protocolBinding, String protocolVersion, String url) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/env/SampleEnvironmentPathResolver.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/env/SampleEnvironmentPathResolver.java index 40bafe53..f584e6df 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/env/SampleEnvironmentPathResolver.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/env/SampleEnvironmentPathResolver.java @@ -10,8 +10,7 @@ */ public final class SampleEnvironmentPathResolver { - private SampleEnvironmentPathResolver() { - } + private SampleEnvironmentPathResolver() {} public static Path resolve(Path directory, String primaryFileName, String fallbackFileName) { Path primary = directory.resolve(primaryFileName); @@ -26,5 +25,3 @@ public static Path resolve(Path directory, String primaryFileName, String fallba return primary; } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java index 770c42ec..e5452053 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java @@ -30,8 +30,7 @@ public final class SampleLoggingFormatter { private static final List SECRET_KEYWORDS = List.of("api_key", "authorization", "token", "secret", "password"); - private SampleLoggingFormatter() { - } + private SampleLoggingFormatter() {} public static String formatStageLog(String actor, String stage, String detail) { return timestamp() + " [" + actor + "] " + stage + ": " + detail; @@ -115,5 +114,3 @@ private static boolean isSecretKey(String keyName) { return normalizedKeyName.endsWith("_token") && !normalizedKeyName.endsWith("max_tokens"); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleLoggingLLMClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleLoggingLLMClient.java index 8a2a5dd7..c04982c8 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleLoggingLLMClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleLoggingLLMClient.java @@ -12,10 +12,10 @@ /** * Logging-aware LLM client wrapper used by the sample. * - *

            Wraps either the real {@link OpenAIClient} or the {@link SampleMockLLMClient} and logs the - * full request (messages, JSON schema, temperature, max tokens) and response (content, model, - * usage) for every structured call, mirroring the Python sample's {@code llm_logger}. In mock mode - * an extra {@code llm-mock} stage line marks that the canned response was used. + *

            Wraps either the real {@link OpenAIClient} or the {@link SampleMockLLMClient} and logs the full request (messages, + * JSON schema, temperature, max tokens) and response (content, model, usage) for every structured call, mirroring the + * Python sample's {@code llm_logger}. In mock mode an extra {@code llm-mock} stage line marks that the canned response + * was used. * * @since 2026-08 */ @@ -61,18 +61,19 @@ public SampleLoggingLLMClient(LLMClientConfig config) { @Override public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { + List> messages, Map jsonSchema, Double temperature, Integer maxTokens) { logSink.accept(SampleLoggingFormatter.formatPayloadLog( role, "llm-request", Map.of( - "messages", messages, - "json_schema", jsonSchema, - "temperature", temperature == null ? "null" : temperature, - "max_tokens", maxTokens == null ? "null" : maxTokens))); + "messages", + messages, + "json_schema", + jsonSchema, + "temperature", + temperature == null ? "null" : temperature, + "max_tokens", + maxTokens == null ? "null" : maxTokens))); LLMResponse response = delegate.structured(messages, jsonSchema, temperature, maxTokens); if (mockMode) { logSink.accept(SampleLoggingFormatter.formatStageLog(role, "llm-mock", "using canned mock LLM response")); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLLMClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLLMClient.java index 9ff281be..d442740a 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLLMClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLLMClient.java @@ -14,12 +14,11 @@ /** * Deterministic LLM client used by the sample when no real API key is available. * - *

            Returns canned structured responses for the three SDK LLM steps, in call order: scenario - * recognition, slot extraction, then semantic validation. The canned responses are loaded from - * {@code mock_responses} JSON resources on the classpath and mirror what a real LLM would return - * for the bundled subscribe-incident templates, so the end-to-end sample flow runs without any - * external LLM service. Each process keeps its own call cursor, which matches the separate client - * and server runtimes used by the sample e2e flow. + *

            Returns canned structured responses for the three SDK LLM steps, in call order: scenario recognition, slot + * extraction, then semantic validation. The canned responses are loaded from {@code mock_responses} JSON resources on + * the classpath and mirror what a real LLM would return for the bundled subscribe-incident templates, so the end-to-end + * sample flow runs without any external LLM service. Each process keeps its own call cursor, which matches the separate + * client and server runtimes used by the sample e2e flow. * * @since 2026-08 */ @@ -45,9 +44,8 @@ public final class SampleMockLLMClient implements LLMClient { * @param language locale identifier used to select the response files */ public static synchronized void configure(String resourceRoot, String language) { - SampleMockLLMClient.resourceRoot = resourceRoot == null || resourceRoot.isBlank() - ? DEFAULT_RESOURCE_ROOT - : resourceRoot; + SampleMockLLMClient.resourceRoot = + resourceRoot == null || resourceRoot.isBlank() ? DEFAULT_RESOURCE_ROOT : resourceRoot; SampleMockLLMClient.language = language == null || language.isBlank() ? DEFAULT_LANGUAGE : language.trim(); SampleMockLLMClient.responses = null; } @@ -63,17 +61,11 @@ public SampleMockLLMClient(LLMClientConfig config) { @Override public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { + List> messages, Map jsonSchema, Double temperature, Integer maxTokens) { List mockResponses = responses(); String content = mockResponses.get(Math.floorMod(callIndex.getAndIncrement(), mockResponses.size())); return new LLMResponse( - content, - "mock-llm", - Map.of("prompt_tokens", 0, "completion_tokens", 0, "total_tokens", 0), - Map.of()); + content, "mock-llm", Map.of("prompt_tokens", 0, "completion_tokens", 0, "total_tokens", 0), Map.of()); } private static List responses() { @@ -99,7 +91,8 @@ private static List loadResponses() { } private static String loadResource(String resourcePath) { - try (InputStream inputStream = SampleMockLLMClient.class.getClassLoader().getResourceAsStream(resourcePath)) { + try (InputStream inputStream = + SampleMockLLMClient.class.getClassLoader().getResourceAsStream(resourcePath)) { if (inputStream == null) { throw new ValueErrorException("Mock LLM response resource not found: " + resourcePath); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLlmInstaller.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLlmInstaller.java index 45463cb2..8959cb17 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLlmInstaller.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleMockLlmInstaller.java @@ -14,12 +14,11 @@ /** * Installs a mock LLM mode for the sample when no real LLM API key is available. * - *

            When {@code A2AT_LLM_API_KEY} is missing, blank, or a placeholder template such as - * {@code ${your_api_key}}, the sample installs {@link SampleMockLLMClient} as the provider used by - * the SDK and rewrites the environment file with a non-empty placeholder key. The mock client - * returns canned structured responses loaded from the specified classpath resource root, so the - * end-to-end sample flow runs deterministically on CI environments that have no LLM API key. When a - * real API key is present, the original environment file is used unchanged and no mock is installed. + *

            When {@code A2AT_LLM_API_KEY} is missing, blank, or a placeholder template such as {@code ${your_api_key}}, the + * sample installs {@link SampleMockLLMClient} as the provider used by the SDK and rewrites the environment file with a + * non-empty placeholder key. The mock client returns canned structured responses loaded from the specified classpath + * resource root, so the end-to-end sample flow runs deterministically on CI environments that have no LLM API key. When + * a real API key is present, the original environment file is used unchanged and no mock is installed. * * @since 2026-08 */ @@ -50,11 +49,10 @@ private SampleMockLlmInstaller() {} /** * Returns whether the sample should fall back to the mock LLM provider. * - *

            The mock fallback is needed when no usable real LLM configuration is present: a missing, - * blank, or placeholder {@code A2AT_LLM_API_KEY}, or a missing/blank {@code A2AT_LLM_MODEL} or -* {@code A2AT_LLM_BASE_URL}. When the environment file lacks any of these, the SDK's - * {@code LLMClientConfig.from(LlmConfig)} would fail at startup with an {@code LLMConfigError}, - * so the mock is installed instead. + *

            The mock fallback is needed when no usable real LLM configuration is present: a missing, blank, or placeholder + * {@code A2AT_LLM_API_KEY}, or a missing/blank {@code A2AT_LLM_MODEL} or {@code A2AT_LLM_BASE_URL}. When the + * environment file lacks any of these, the SDK's {@code LLMClientConfig.from(LlmConfig)} would fail at startup with + * an {@code LLMConfigError}, so the mock is installed instead. * * @param envPath sample environment file path * @return {@code true} when no usable real LLM configuration is present @@ -73,13 +71,12 @@ public static boolean isMockNeeded(Path envPath) { /** * Installs the logging-aware LLM client wrapper as the {@code openai} provider. * - *

            This method must be called once at startup, before any {@code A2ATClient} or - * {@code A2ATServer} is constructed, so that every LLM-structured call is logged. The wrapper - * delegates to the real {@code OpenAIClient} when {@code mockEnabled} is false, or to - * {@link SampleMockLLMClient} when it is true. + *

            This method must be called once at startup, before any {@code A2ATClient} or {@code A2ATServer} is + * constructed, so that every LLM-structured call is logged. The wrapper delegates to the real {@code OpenAIClient} + * when {@code mockEnabled} is false, or to {@link SampleMockLLMClient} when it is true. * * @param mockEnabled whether the delegate should be the mock client - * @param roleLabel log role label (for example {@code client} or {@code server}) + * @param roleLabel log role label (for example {@code client} or {@code server}) */ public static synchronized void installLlmLogger(boolean mockEnabled, String roleLabel) { if (mockInstalled) { @@ -109,8 +106,8 @@ public static boolean isLoggerInstalled() { } /** - * Resolves the environment file path that should be used for the current run, using the default - * mock resource root {@code sample/subscribe-incident/mock_responses}. + * Resolves the environment file path that should be used for the current run, using the default mock resource root + * {@code sample/subscribe-incident/mock_responses}. * * @param envPath sample environment file path * @return the environment file path to use for this run @@ -123,12 +120,12 @@ public static Path resolveEnvPath(Path envPath) { /** * Resolves the environment file path that should be used for the current run. * - *

            When a mock fallback is needed, the mock client is configured with the supplied classpath - * resource root (and the language read from the env file), then a temporary environment file is - * written next to the original file (so relative prompt-resource paths keep resolving correctly) - * with a non-empty placeholder API key. Otherwise the original path is returned unchanged. + *

            When a mock fallback is needed, the mock client is configured with the supplied classpath resource root (and + * the language read from the env file), then a temporary environment file is written next to the original file (so + * relative prompt-resource paths keep resolving correctly) with a non-empty placeholder API key. Otherwise the + * original path is returned unchanged. * - * @param envPath sample environment file path + * @param envPath sample environment file path * @param mockResourceRoot classpath root for mock LLM response JSON files * @return the environment file path to use for this run */ @@ -182,7 +179,10 @@ private static Path writeMockEnvFile(Path originalEnvPath) { StringBuilder content = new StringBuilder(); content.append("# Mock LLM environment for sample runs without a real API key.\n"); for (Map.Entry entry : values.entrySet()) { - content.append(entry.getKey()).append('=').append(entry.getValue()).append('\n'); + content.append(entry.getKey()) + .append('=') + .append(entry.getValue()) + .append('\n'); } Files.writeString(tempFile, content.toString(), StandardCharsets.UTF_8); return tempFile; @@ -190,4 +190,4 @@ private static Path writeMockEnvFile(Path originalEnvPath) { throw new ValueErrorException("Failed to create mock LLM environment file", exception); } } -} \ No newline at end of file +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/package-info.java index 3f21f701..2aabbcda 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/package-info.java @@ -4,4 +4,3 @@ * @since 2026-05 */ package net.openan.a2at.sample.subscribe_incident.shared; - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/registry/RegistryAgentCardMapper.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/registry/RegistryAgentCardMapper.java index 48654529..18ff63d9 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/registry/RegistryAgentCardMapper.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/registry/RegistryAgentCardMapper.java @@ -2,8 +2,8 @@ import java.util.List; import java.util.Map; -import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentExtension; import org.a2aproject.sdk.spec.AgentInterface; import org.a2aproject.sdk.spec.AgentProvider; @@ -16,8 +16,7 @@ */ public final class RegistryAgentCardMapper { - private RegistryAgentCardMapper() { - } + private RegistryAgentCardMapper() {} public static AgentCard toA2AJavaAgentCard(Map registryAgentCard) { @SuppressWarnings("unchecked") @@ -25,16 +24,21 @@ public static AgentCard toA2AJavaAgentCard(Map registryAgentCard @SuppressWarnings("unchecked") Map capabilities = (Map) registryAgentCard.get("capabilities"); @SuppressWarnings("unchecked") - List> extensionMaps = (List>) capabilities.getOrDefault("extensions", List.of()); + List> extensionMaps = + (List>) capabilities.getOrDefault("extensions", List.of()); @SuppressWarnings("unchecked") - List> skillMaps = (List>) registryAgentCard.getOrDefault("skills", List.of()); + List> skillMaps = + (List>) registryAgentCard.getOrDefault("skills", List.of()); @SuppressWarnings("unchecked") List> interfaceMaps = (List>) registryAgentCard.getOrDefault("supportedInterfaces", List.of()); return new AgentCard( stringValue(registryAgentCard.get("name")), stringValue(registryAgentCard.get("description")), - provider == null ? null : new AgentProvider(stringValue(provider.get("organization")), stringValue(provider.get("url"))), + provider == null + ? null + : new AgentProvider( + stringValue(provider.get("organization")), stringValue(provider.get("url"))), stringValue(registryAgentCard.get("version")), null, new AgentCapabilities( @@ -85,30 +89,35 @@ public static Map toRegistryAgentCard(AgentCard agentCard) { "version", agentCard.version(), "defaultInputModes", agentCard.defaultInputModes(), "defaultOutputModes", agentCard.defaultOutputModes(), - "provider", Map.of( - "organization", agentCard.provider().organization(), - "url", agentCard.provider().url()), - "skills", agentCard.skills().stream() - .map(skill -> Map.of( - "id", skill.id(), - "name", skill.name(), - "description", skill.description(), - "tags", skill.tags())) - .toList(), - "capabilities", Map.of( - "streaming", agentCard.capabilities().streaming(), - "pushNotifications", agentCard.capabilities().pushNotifications(), - "extensions", agentCard.capabilities().extensions().stream() - .map(extension -> Map.of( - "uri", extension.uri(), - "description", extension.description())) - .toList()), - "supportedInterfaces", agentCard.supportedInterfaces().stream() - .map(agentInterface -> Map.of( - "protocolBinding", agentInterface.protocolBinding(), - "protocolVersion", agentInterface.protocolVersion(), - "url", agentInterface.url())) - .toList()); + "provider", + Map.of( + "organization", agentCard.provider().organization(), + "url", agentCard.provider().url()), + "skills", + agentCard.skills().stream() + .map(skill -> Map.of( + "id", skill.id(), + "name", skill.name(), + "description", skill.description(), + "tags", skill.tags())) + .toList(), + "capabilities", + Map.of( + "streaming", agentCard.capabilities().streaming(), + "pushNotifications", agentCard.capabilities().pushNotifications(), + "extensions", + agentCard.capabilities().extensions().stream() + .map(extension -> Map.of( + "uri", extension.uri(), + "description", extension.description())) + .toList()), + "supportedInterfaces", + agentCard.supportedInterfaces().stream() + .map(agentInterface -> Map.of( + "protocolBinding", agentInterface.protocolBinding(), + "protocolVersion", agentInterface.protocolVersion(), + "url", agentInterface.url())) + .toList()); } private static List stringList(Object value) { @@ -122,5 +131,3 @@ private static String stringValue(Object value) { return value == null ? "" : String.valueOf(value); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/scenario/SampleScenarioLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/scenario/SampleScenarioLoader.java index 8b0a724f..ba4bffa7 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/scenario/SampleScenarioLoader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/scenario/SampleScenarioLoader.java @@ -15,20 +15,17 @@ public final class SampleScenarioLoader { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private SampleScenarioLoader() { - } + private SampleScenarioLoader() {} public static Map loadClasspathScenario(String resourcePath) { - try (InputStream inputStream = SampleScenarioLoader.class.getClassLoader().getResourceAsStream(resourcePath)) { + try (InputStream inputStream = + SampleScenarioLoader.class.getClassLoader().getResourceAsStream(resourcePath)) { if (inputStream == null) { throw new ValueErrorException("Scenario resource not found: " + resourcePath); } - return OBJECT_MAPPER.readValue(inputStream, new TypeReference<>() { - }); + return OBJECT_MAPPER.readValue(inputStream, new TypeReference<>() {}); } catch (IOException exception) { throw new ValueErrorException("Unable to load scenario resource: " + resourcePath); } } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/stream/SampleStreamEventNormalizer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/stream/SampleStreamEventNormalizer.java index fb6b3590..7ec64032 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/stream/SampleStreamEventNormalizer.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/stream/SampleStreamEventNormalizer.java @@ -11,8 +11,7 @@ */ public final class SampleStreamEventNormalizer { - private SampleStreamEventNormalizer() { - } + private SampleStreamEventNormalizer() {} public static Map normalize(Map payload) { if (payload == null || payload.isEmpty()) { @@ -44,5 +43,3 @@ public static Map normalize(Map payload) { throw new ValueErrorException("Unsupported SSE payload: " + payload); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java index 9454f1f0..0b5ce1f4 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java @@ -11,8 +11,8 @@ * Field-level and sample-level accuracy scoring for the {@code Task-T} demo. * *

            Each {@link SampleScore} scores one sample: every expected ground-truth field is compared with the corresponding - * value extracted by {@code A2ATServer#validateTaskPromptAndDataFilling}. {@link Summary} aggregates the scores per client - * API both as a field hit rate (matched expected fields over total expected fields) and as a sample pass rate + * value extracted by {@code A2ATServer#validateTaskPromptAndDataFilling}. {@link Summary} aggregates the scores per + * client API both as a field hit rate (matched expected fields over total expected fields) and as a sample pass rate * (samples whose expected fields all hit over total samples). * *

            Fields are matched in one of two modes: structured identifier fields ({@code accessPort}, {@code bizScenario}, @@ -31,8 +31,7 @@ enum MatchMode { CONTAINS } - private TaskTAccuracyEvaluator() { - } + private TaskTAccuracyEvaluator() {} /** * One expected slot compared against the extracted value. @@ -97,16 +96,15 @@ record Summary(String api, int sampleCount, int passedSamples, int matchedFields */ static List scoreFields(TaskTSample sample, Map extractedParams) { List scores = new ArrayList<>(); - sample.expectedParams() - .forEach((slot, expected) -> { - Object extracted = extractedParams.get(slot); - String extractedText = extracted == null ? null : String.valueOf(extracted); - boolean matched = matches(extractedText, expected, slot); - String detail = matched ? "" : "expected=" + expected + ", extracted=" + (extractedText == null - ? "" - : extractedText); - scores.add(new FieldScore(slot, expected, extractedText, matched, detail)); - }); + sample.expectedParams().forEach((slot, expected) -> { + Object extracted = extractedParams.get(slot); + String extractedText = extracted == null ? null : String.valueOf(extracted); + boolean matched = matches(extractedText, expected, slot); + String detail = matched + ? "" + : "expected=" + expected + ", extracted=" + (extractedText == null ? "" : extractedText); + scores.add(new FieldScore(slot, expected, extractedText, matched, detail)); + }); return scores; } @@ -114,8 +112,8 @@ static List scoreFields(TaskTSample sample, Map extr * Picks the match mode for one slot: the free-text complaint detail and fault time are matched by containment, * every other structured field is matched exactly. * - *

            Fault time uses containment because the LLM may return the time in various formats (ISO with/without - * timezone, Chinese natural language, etc.) while the expected value is a canonical subset. + *

            Fault time uses containment because the LLM may return the time in various formats (ISO with/without timezone, + * Chinese natural language, etc.) while the expected value is a canonical subset. * * @param slot expected slot name keyed by the server field names * @return containment mode for the complaint detail and fault time, exact mode otherwise @@ -131,11 +129,9 @@ private static MatchMode matchMode(String slot) { } /** - * Hit rule for one slot. For structured fields, equal after normalization. - * For the fault detail, one contains the other. For the fault time, the - * numeric month-day digits are extracted and compared, so both ISO - * ({@code 2026-05-22T14:00:00Z}) and Chinese ({@code 5月22号下午两点多}) - * formats match the same canonical expected value. + * Hit rule for one slot. For structured fields, equal after normalization. For the fault detail, one contains the + * other. For the fault time, the numeric month-day digits are extracted and compared, so both ISO + * ({@code 2026-05-22T14:00:00Z}) and Chinese ({@code 5月22号下午两点多}) formats match the same canonical expected value. * * @param extracted extracted value, may be {@code null} * @param expected ground-truth value @@ -160,10 +156,8 @@ static boolean matches(String extracted, String expected, String slot) { } /** - * Extracts the numeric month-day part from a date string and compares. - * Handles both ISO ({@code 2026-05-22t14:00:00z}) and Chinese - * ({@code 5月22号下午两点多}) formats by extracting the month and day - * digits. + * Extracts the numeric month-day part from a date string and compares. Handles both ISO + * ({@code 2026-05-22t14:00:00z}) and Chinese ({@code 5月22号下午两点多}) formats by extracting the month and day digits. */ private static boolean matchDatePart(String extracted, String expected) { String extractedMd = extractMonthDay(extracted); @@ -176,9 +170,8 @@ private static String extractMonthDay(String value) { // Try Chinese format: "5月22号" or "2026年5月11号" Matcher chineseMatcher = CHINESE_DATE_PATTERN.matcher(value); if (chineseMatcher.find()) { - return String.format("%02d%02d", - Integer.parseInt(chineseMatcher.group(1)), - Integer.parseInt(chineseMatcher.group(2))); + return String.format( + "%02d%02d", Integer.parseInt(chineseMatcher.group(1)), Integer.parseInt(chineseMatcher.group(2))); } // Try ISO format: "2026-05-22" in "2026-05-22t14:00:00z" Matcher isoMatcher = ISO_DATE_PATTERN.matcher(value); @@ -188,10 +181,8 @@ private static String extractMonthDay(String value) { return ""; } - private static final Pattern CHINESE_DATE_PATTERN = - Pattern.compile("(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*[号日]"); - private static final Pattern ISO_DATE_PATTERN = - Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})"); + private static final Pattern CHINESE_DATE_PATTERN = Pattern.compile("(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*[号日]"); + private static final Pattern ISO_DATE_PATTERN = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})"); private static String normalize(String value) { return value == null ? "" : value.replaceAll("\\s+", "").toLowerCase(Locale.ROOT); @@ -206,8 +197,10 @@ private static String normalize(String value) { */ static Summary summarize(String api, List scores) { int passedSamples = (int) scores.stream().filter(SampleScore::passed).count(); - int matchedFields = scores.stream().mapToInt(SampleScore::matchedFieldCount).sum(); - int expectedFields = scores.stream().mapToInt(SampleScore::expectedFieldCount).sum(); + int matchedFields = + scores.stream().mapToInt(SampleScore::matchedFieldCount).sum(); + int expectedFields = + scores.stream().mapToInt(SampleScore::expectedFieldCount).sum(); return new Summary(api, scores.size(), passedSamples, matchedFields, expectedFields); } -} \ No newline at end of file +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java index 13c9a843..4b0708a2 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java @@ -223,8 +223,8 @@ private static RejectionSummary runRejectionCase(A2ATClient client, A2ATServer s println(sample.text()); println(); println("[生成] generateTaskPromptFromText:"); - metadata = - client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + metadata = client.generateTaskPromptFromText( + sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); } System.out.println(" templateUri : " + metadata.templateUri()); System.out.println(" promptText : " + metadata.promptText()); @@ -266,7 +266,9 @@ private static TaskTAccuracyEvaluator.SampleScore validateAndScore( A2ATServer server, TaskTSample sample, MetadataContent metadata, String caseLabel) { try { Map extracted = server.validateTaskPromptAndDataFilling( - metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) + metadata.promptText(), + sample.validationSchema(), + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) .data(); println("[服务端] " + caseLabel + " validateTaskPromptAndDataFilling 通过,提取参数:"); println(pretty(extracted)); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/package-info.java index e570cfe3..4d3cabd8 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/package-info.java @@ -9,4 +9,4 @@ * * @since 2026-08 */ -package net.openan.a2at.sample.task_t; \ No newline at end of file +package net.openan.a2at.sample.task_t; diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index ddeb461d..f8e7397d 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -230,10 +230,7 @@ private static List symmetryCases() { } private static SymmetryCase endingCase( - String label, - NegotiationPerformative performative, - String templateUri, - NegotiationEndingContent content) { + String label, NegotiationPerformative performative, String templateUri, NegotiationEndingContent content) { return new SymmetryCase( label, performative, @@ -243,8 +240,7 @@ private static SymmetryCase endingCase( } /** One symmetry case: a fixed input addressed to one of the three from-data generation methods. */ - private record SymmetryCase( - String label, NegotiationPerformative performative, String templateUri, Object data) { + private record SymmetryCase(String label, NegotiationPerformative performative, String templateUri, Object data) { MetadataContent generate(ProposeGenerator propose, EndingGenerator accept, EndingGenerator reject) { return switch (performative) { diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCaptureTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCaptureTest.java index cad2e442..94e4e4f2 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCaptureTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCaptureTest.java @@ -25,8 +25,7 @@ class AuthzReasoningCaptureTest { - private static final String SLOT_EXTRACTION_SYSTEM = - "/prompt_resources/prompts/slot_extraction/zh-CN/system.md"; + private static final String SLOT_EXTRACTION_SYSTEM = "/prompt_resources/prompts/slot_extraction/zh-CN/system.md"; private static final String CONTENT_VALIDATION_SYSTEM = "/prompt_resources/prompts/content_validation/zh-CN/system.md"; @@ -287,4 +286,4 @@ void should_resolveReasoningFlag_ThrowOnInvalid() { assertTrue(ex.getMessage().contains("authz.reasoning")); } } -} \ No newline at end of file +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalAppTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalAppTest.java index 5a3c9197..cba5035b 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalAppTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalAppTest.java @@ -16,9 +16,8 @@ import java.util.Map; import java.util.Set; import java.util.TreeSet; -import org.junit.jupiter.api.Test; - import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; +import org.junit.jupiter.api.Test; class NegotiationFromDataApiEvalAppTest { @@ -28,7 +27,8 @@ class NegotiationFromDataApiEvalAppTest { @Test void should_DelegateToTheSharedInformationNegotiationSchemas() { - assertEquals(InformationNegotiationSchemas.propose(), NegotiationFromDataApiEvalApp.extractionSchema("propose")); + assertEquals( + InformationNegotiationSchemas.propose(), NegotiationFromDataApiEvalApp.extractionSchema("propose")); assertEquals(InformationNegotiationSchemas.accept(), NegotiationFromDataApiEvalApp.extractionSchema("accept")); assertEquals(InformationNegotiationSchemas.reject(), NegotiationFromDataApiEvalApp.extractionSchema("reject")); assertThrows(IllegalArgumentException.class, () -> NegotiationFromDataApiEvalApp.extractionSchema("unknown")); @@ -71,7 +71,8 @@ void should_AcceptOrRejectOnlyFieldsThatCanBeNegotiated() throws IOException { assertTrue(REQUIRED_FIELDS.containsAll(items.keySet())); assertTrue(disjoint(items.keySet(), OPTIONAL_FIELDS)); if ("reject".equals(api)) { - items.values().forEach(reason -> assertTrue(String.valueOf(reason).contains("无法提供"))); + items.values() + .forEach(reason -> assertTrue(String.valueOf(reason).contains("无法提供"))); } } } @@ -81,7 +82,8 @@ void should_KeepSharedScenarioWithinPrivateLineComplaintDomain() throws IOExcept Map scenario = resource("sample/negotiation/scenario.json"); Map taskSchema = map(scenario.get("task_schema")); assertEquals(List.of("任务对象", "任务上下文"), taskSchema.get("required")); - String missingContext = String.valueOf(map(scenario.get("missing_params")).get("任务上下文")); + String missingContext = + String.valueOf(map(scenario.get("missing_params")).get("任务上下文")); String filledContext = String.valueOf(map(scenario.get("filled_params")).get("任务上下文")); assertFalse(missingContext.contains("投诉分类")); assertFalse(missingContext.contains("OSS侧事件流水号")); @@ -98,9 +100,8 @@ private static List> cases() throws IOException { } private static Map resource(String resource) throws IOException { - try (InputStream input = NegotiationFromDataApiEvalAppTest.class - .getClassLoader() - .getResourceAsStream(resource)) { + try (InputStream input = + NegotiationFromDataApiEvalAppTest.class.getClassLoader().getResourceAsStream(resource)) { assertNotNull(input, resource); return MAPPER.readValue(input, new TypeReference<>() {}); } diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java index fa304b9d..000bc86b 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java @@ -3,8 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.util.Map; import java.util.List; +import java.util.Map; import net.openan.a2at.sample.private_line_complaint.negotiation.evaluation.NegotiationEvaluationCase; import net.openan.a2at.sample.private_line_complaint.negotiation.evaluation.NegotiationEvaluationCaseLoader; import org.junit.jupiter.api.Test; @@ -16,9 +16,21 @@ void loadsOneHundredManuallyLabelledCasesAcrossAllGenerationPhases() { var cases = NegotiationEvaluationCaseLoader.load(); assertEquals(100, cases.size()); - assertEquals(34, cases.stream().filter(testCase -> testCase.phase().equals("propose")).count()); - assertEquals(33, cases.stream().filter(testCase -> testCase.phase().equals("accept")).count()); - assertEquals(33, cases.stream().filter(testCase -> testCase.phase().equals("reject")).count()); + assertEquals( + 34, + cases.stream() + .filter(testCase -> testCase.phase().equals("propose")) + .count()); + assertEquals( + 33, + cases.stream() + .filter(testCase -> testCase.phase().equals("accept")) + .count()); + assertEquals( + 33, + cases.stream() + .filter(testCase -> testCase.phase().equals("reject")) + .count()); assertTrue(cases.stream().allMatch(NegotiationEvaluationCaseLoaderTest::isComplete)); } @@ -26,7 +38,9 @@ void loadsOneHundredManuallyLabelledCasesAcrossAllGenerationPhases() { void loadsFocusedReproductionCasesFromTheCanonicalCorpus() { var cases = NegotiationEvaluationCaseLoader.loadSelected(List.of("R21", "P01", "A28")); - assertEquals(List.of("R21", "P01", "A28"), cases.stream().map(NegotiationEvaluationCase::id).toList()); + assertEquals( + List.of("R21", "P01", "A28"), + cases.stream().map(NegotiationEvaluationCase::id).toList()); } @Test @@ -34,11 +48,24 @@ void loadsTwentyRepresentativeSmokeCasesFromTheCanonicalCorpus() { var cases = NegotiationEvaluationCaseLoader.loadSmoke(); assertEquals(20, cases.size()); - assertEquals(NegotiationEvaluationCaseLoader.SMOKE_CASE_IDS, + assertEquals( + NegotiationEvaluationCaseLoader.SMOKE_CASE_IDS, cases.stream().map(NegotiationEvaluationCase::id).toList()); - assertEquals(7, cases.stream().filter(testCase -> testCase.phase().equals("propose")).count()); - assertEquals(7, cases.stream().filter(testCase -> testCase.phase().equals("accept")).count()); - assertEquals(6, cases.stream().filter(testCase -> testCase.phase().equals("reject")).count()); + assertEquals( + 7, + cases.stream() + .filter(testCase -> testCase.phase().equals("propose")) + .count()); + assertEquals( + 7, + cases.stream() + .filter(testCase -> testCase.phase().equals("accept")) + .count()); + assertEquals( + 6, + cases.stream() + .filter(testCase -> testCase.phase().equals("reject")) + .count()); } @Test @@ -46,13 +73,18 @@ void assemblesEveryCorpusEntryIntoAnEndToEndFlow() { var flows = NegotiationEvaluationCaseLoader.loadFlows(); assertEquals(100, flows.size()); - assertEquals(50, flows.stream().filter(flow -> flow.decision().equals("accept")).count()); - assertEquals(50, flows.stream().filter(flow -> flow.decision().equals("reject")).count()); + assertEquals( + 50, + flows.stream().filter(flow -> flow.decision().equals("accept")).count()); + assertEquals( + 50, + flows.stream().filter(flow -> flow.decision().equals("reject")).count()); assertTrue(flows.stream().allMatch(flow -> flow.proposeCase().phase().equals("propose"))); assertTrue(flows.stream().allMatch(flow -> flow.endingCase().phase().equals(flow.decision()))); - assertTrue(flows.stream().filter(flow -> flow.decision().equals("reject")) - .allMatch(flow -> flow.expectedEnding().keySet().equals( - Map.of("接入端口名称", "", "投诉分类", "").keySet()))); + assertTrue( + flows.stream().filter(flow -> flow.decision().equals("reject")).allMatch(flow -> flow.expectedEnding() + .keySet() + .equals(Map.of("接入端口名称", "", "投诉分类", "").keySet()))); } @Test @@ -60,7 +92,8 @@ void assemblesTwentySmokeFlowsUsingTheExistingSelectorIds() { var flows = NegotiationEvaluationCaseLoader.loadSmokeFlows(); assertEquals(20, flows.size()); - assertEquals(NegotiationEvaluationCaseLoader.SMOKE_CASE_IDS, + assertEquals( + NegotiationEvaluationCaseLoader.SMOKE_CASE_IDS, flows.stream().map(flow -> flow.id()).toList()); assertTrue(flows.stream().anyMatch(flow -> flow.decision().equals("accept"))); assertTrue(flows.stream().anyMatch(flow -> flow.decision().equals("reject"))); @@ -80,15 +113,18 @@ void everyCaseCarriesStructuredDataParallelToItsText() { if (index > 0) { rebuilt.append('\n'); } - rebuilt.append(index + 1).append(". ") + rebuilt.append(index + 1) + .append(". ") .append(testCase.dataItems().get(index).name()) .append(':') .append(testCase.dataItems().get(index).value()); } assertTrue(testCase.completedPrompt().contains(rebuilt.toString()), testCase.id()); // data item names match the expected keys (both use the display names) - assertEquals(testCase.expected().keySet(), testCase.dataItems().stream() - .map(item -> item.name()).collect(java.util.stream.Collectors.toSet()), testCase.id()); + assertEquals( + testCase.expected().keySet(), + testCase.dataItems().stream().map(item -> item.name()).collect(java.util.stream.Collectors.toSet()), + testCase.id()); } } @@ -100,9 +136,8 @@ void completedPromptsCarryNoNegotiationContextSection() { String prompt = testCase.completedPrompt(); assertTrue(!prompt.contains("协商上下文"), testCase.id()); assertTrue(!prompt.contains("{{"), testCase.id()); - assertTrue(prompt.contains(testCase.phase().equals("propose") - ? "## 信息协商\n" - : "## 信息协商结果\n"), testCase.id()); + assertTrue( + prompt.contains(testCase.phase().equals("propose") ? "## 信息协商\n" : "## 信息协商结果\n"), testCase.id()); } } diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java index 278cce77..fcb1bd21 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java @@ -23,8 +23,7 @@ void readsCanonicalNegotiationMetadata() { NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); assertEquals( - "prompt", - NegotiationMetadataReader.readPrompt(metadata, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI)); + "prompt", NegotiationMetadataReader.readPrompt(metadata, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI)); } @Test @@ -35,7 +34,15 @@ void readsTheNegotiationContextFromMetadata() { MetadataContent.TEMPLATE_URI_METADATA_KEY, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI, MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY, - Map.of("id", "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round", 1, "maxRounds", 5, "performative", "PROPOSE")); + Map.of( + "id", + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round", + 1, + "maxRounds", + 5, + "performative", + "PROPOSE")); assertEquals( new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5, NegotiationPerformative.PROPOSE), @@ -53,10 +60,14 @@ void readsThePerformativeOfTheNegotiationContext() { Map metadata = Map.of( MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY, Map.of( - "id", "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round", 1, - "maxRounds", 5, - "performative", "PROPOSE")); + "id", + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round", + 1, + "maxRounds", + 5, + "performative", + "PROPOSE")); assertEquals( new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5, NegotiationPerformative.PROPOSE), @@ -69,19 +80,24 @@ void rejectsAContextWithoutAPerformative() { MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY, Map.of("id", "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round", 1, "maxRounds", 5)); - assertThrows( - IllegalArgumentException.class, () -> NegotiationMetadataReader.readContext(metadata)); + assertThrows(IllegalArgumentException.class, () -> NegotiationMetadataReader.readContext(metadata)); } @Test void rejectsAnUnknownPerformativeOfTheNegotiationContext() { Map metadata = Map.of( MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY, - Map.of("id", "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round", 1, "maxRounds", 5, "performative", "propose")); + Map.of( + "id", + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round", + 1, + "maxRounds", + 5, + "performative", + "propose")); - assertThrows( - IllegalArgumentException.class, - () -> NegotiationMetadataReader.readContext(metadata)); + assertThrows(IllegalArgumentException.class, () -> NegotiationMetadataReader.readContext(metadata)); } @Test @@ -89,9 +105,7 @@ void rejectsMissingPromptAndWrongTemplateOrExtension() { assertThrows( IllegalArgumentException.class, () -> NegotiationMetadataReader.readPrompt( - Map.of( - MetadataContent.TEMPLATE_URI_METADATA_KEY, - NegotiationSampleFlow.PROPOSE_TEMPLATE_URI), + Map.of(MetadataContent.TEMPLATE_URI_METADATA_KEY, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI), NegotiationSampleFlow.PROPOSE_TEMPLATE_URI)); assertThrows( IllegalArgumentException.class, diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java index 30a0717c..97bd5677 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java @@ -35,9 +35,12 @@ void acceptFlowCoversFourApis() throws IOException { NegotiationSampleFlow.NegotiationFlowResult result = run(NegotiationDecision.ACCEPT); assertEquals(NegotiationDecision.ACCEPT, result.decision()); - assertEquals(NegotiationSampleFlow.PROPOSE_TEMPLATE_URI, result.propose().templateUri()); + assertEquals( + NegotiationSampleFlow.PROPOSE_TEMPLATE_URI, result.propose().templateUri()); assertEquals(NegotiationSampleFlow.ENDING_TEMPLATE_URI, result.ending().templateUri()); - assertEquals(ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, result.propose().extensionUri()); + assertEquals( + ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, + result.propose().extensionUri()); assertContextIsShared(result); assertEquals( List.of( @@ -64,10 +67,7 @@ void rejectFlowCoversFourApis() throws IOException { private NegotiationSampleFlow.NegotiationFlowResult run(NegotiationDecision decision) throws IOException { Path env = envFile(decision.name().toLowerCase() + ".env"); return NegotiationSampleFlow.run( - new A2ATClient(env), - new A2ATServer(env), - NegotiationScenarioLoader.load(), - decision); + new A2ATClient(env), new A2ATServer(env), NegotiationScenarioLoader.load(), decision); } private Path envFile(String fileName) throws IOException { diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLoggerTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLoggerTest.java index b5daaaa6..f966b5a0 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLoggerTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLoggerTest.java @@ -30,7 +30,10 @@ void writesOneCompactJsonObjectPerLine() throws Exception { List lines = Files.readAllLines(log); assertEquals(2, lines.size()); assertFalse(lines.stream().anyMatch(String::isBlank)); - assertEquals("generate", OBJECT_MAPPER.readTree(lines.get(0)).get("stage").asText()); - assertEquals("validate_and_fill", OBJECT_MAPPER.readTree(lines.get(1)).get("stage").asText()); + assertEquals( + "generate", OBJECT_MAPPER.readTree(lines.get(0)).get("stage").asText()); + assertEquals( + "validate_and_fill", + OBJECT_MAPPER.readTree(lines.get(1)).get("stage").asText()); } } diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntimeTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntimeTest.java index 8e7c6ac3..b7ed91dd 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntimeTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntimeTest.java @@ -43,8 +43,7 @@ void resolveBindRaisesWhenPortIsNotANumber() { Path envPath = createEnvFile("A2AT_SAMPLE_PORT=not-a-number\n"); ValueErrorException exception = assertThrows( - ValueErrorException.class, - () -> new DefaultSampleServerRuntime(envPath, message -> {}).resolveBind()); + ValueErrorException.class, () -> new DefaultSampleServerRuntime(envPath, message -> {}).resolveBind()); assertTrue(exception.getMessage().contains("not-a-number")); } diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClientTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClientTest.java index c6190762..213ea18c 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClientTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClientTest.java @@ -39,4 +39,4 @@ void responseResourceRaisesForUnknownSchema() { void responseResourceRaisesForNullSchema() { assertThrows(ValueErrorException.class, () -> SampleMockLLMClient.responseResource(null)); } -} \ No newline at end of file +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluatorTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluatorTest.java index 5f3cd44f..3e83ba19 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluatorTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluatorTest.java @@ -13,8 +13,8 @@ * *

            Two match modes exist: {@link TaskTAccuracyEvaluator.MatchMode#EXACT} applies to structured identifier fields * (access port, scenario, time, serial) where a truncated or partially matching value must NOT score a hit; and - * {@link TaskTAccuracyEvaluator.MatchMode#CONTAINS} applies to the free-text complaint detail where the extracted - * value and the expected keyword are allowed to contain each other after normalization. + * {@link TaskTAccuracyEvaluator.MatchMode#CONTAINS} applies to the free-text complaint detail where the extracted value + * and the expected keyword are allowed to contain each other after normalization. * *

            The match mode is now determined by the slot name rather than passed explicitly. Use * {@link TaskTPrivateLineComplaintSamples#SERVER_PORT} for EXACT mode tests and @@ -46,7 +46,8 @@ void exact_shouldNotHit_WhenExtractedIsOnlyAPrefixOfExpected() { @Test void exact_shouldNotHit_WhenExtractedContainsExpectedButIsLonger() { // "connect timeout..." is not equal to the expected serial — trailing content must not pass as equal - assertFalse(TaskTAccuracyEvaluator.matches("event-id-20260511-09013-ext", "event-id-20260511-09013", EXACT_SLOT)); + assertFalse( + TaskTAccuracyEvaluator.matches("event-id-20260511-09013-ext", "event-id-20260511-09013", EXACT_SLOT)); } @Test @@ -151,26 +152,36 @@ void scoreFields_shouldScoreStructuredFieldsExactly_AndDetailByContains() { List fields = TaskTAccuracyEvaluator.scoreFields(sample, extracted); assertEquals(4, fields.size()); - assertFalse(field(fields, TaskTPrivateLineComplaintSamples.SERVER_PORT).matched(), "truncated port prefix must not hit"); - assertTrue(field(fields, TaskTPrivateLineComplaintSamples.SERVER_SCENARIO).matched(), "exact scenario must hit"); + assertFalse( + field(fields, TaskTPrivateLineComplaintSamples.SERVER_PORT).matched(), + "truncated port prefix must not hit"); + assertTrue( + field(fields, TaskTPrivateLineComplaintSamples.SERVER_SCENARIO).matched(), "exact scenario must hit"); assertTrue(field(fields, TaskTPrivateLineComplaintSamples.SERVER_TICKET).matched(), "exact serial must hit"); - assertTrue(field(fields, TaskTPrivateLineComplaintSamples.SERVER_DETAIL).matched(), "detail containing the keyword must hit"); + assertTrue( + field(fields, TaskTPrivateLineComplaintSamples.SERVER_DETAIL).matched(), + "detail containing the keyword must hit"); } - private static TaskTAccuracyEvaluator.FieldScore field(List fields, String slot) { + private static TaskTAccuracyEvaluator.FieldScore field( + List fields, String slot) { return fields.stream().filter(f -> f.slot().equals(slot)).findFirst().orElseThrow(); } @Test void sampleScore_shouldPass_OnlyWhenEveryFieldHit() { Map expected = Map.of( - TaskTPrivateLineComplaintSamples.SERVER_PORT, PORT, - TaskTPrivateLineComplaintSamples.SERVER_DETAIL, "丢包"); + TaskTPrivateLineComplaintSamples.SERVER_PORT, + PORT, + TaskTPrivateLineComplaintSamples.SERVER_DETAIL, + "丢包"); TaskTSample sample = new TaskTSample("hit-all", "text", null, null, expected, Map.of()); Map fullExtraction = Map.of( - TaskTPrivateLineComplaintSamples.SERVER_PORT, PORT, - TaskTPrivateLineComplaintSamples.SERVER_DETAIL, "故障现象:丢包率最高15%。"); + TaskTPrivateLineComplaintSamples.SERVER_PORT, + PORT, + TaskTPrivateLineComplaintSamples.SERVER_DETAIL, + "故障现象:丢包率最高15%。"); TaskTAccuracyEvaluator.SampleScore pass = new TaskTAccuracyEvaluator.SampleScore( sample.name(), true, TaskTAccuracyEvaluator.scoreFields(sample, fullExtraction)); assertTrue(pass.passed()); @@ -204,4 +215,4 @@ void summarize_shouldAggregateMatchedFieldsAndPassedSamples() { assertEquals(50.0d, summary.fieldAccuracyPercent(), 1e-6); assertEquals(50.0d, summary.samplePassRatePercent(), 1e-6); } -} \ No newline at end of file +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamplesTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamplesTest.java index 921c14e7..fa6c8cc1 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamplesTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamplesTest.java @@ -2,7 +2,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; @@ -11,8 +10,8 @@ /** * Guards the packaged sample file {@code private-line-complaint-samples.json}: sample counts per case, the contract - * {@code enum} on the scenario fields of both schemas, and the optional-slot observation samples (which must not - * expect the optional time/serial fields). + * {@code enum} on the scenario fields of both schemas, and the optional-slot observation samples (which must not expect + * the optional time/serial fields). */ class TaskTPrivateLineComplaintSamplesTest { @@ -29,10 +28,10 @@ void should_Load8RejectionSamples() { @Test void should_ContractScenarioEnum_OnBothSchemas() { - Map semantics = TaskTPrivateLineComplaintSamples - .dataWithSchemaSamples().get(0).semanticsSchema(); - Map validation = TaskTPrivateLineComplaintSamples - .dataWithSchemaSamples().get(0).validationSchema(); + Map semantics = + TaskTPrivateLineComplaintSamples.dataWithSchemaSamples().get(0).semanticsSchema(); + Map validation = + TaskTPrivateLineComplaintSamples.dataWithSchemaSamples().get(0).validationSchema(); assertEquals(List.of("专线中断", "专线质差"), enumOf(semantics, "complaintScenario")); assertEquals(List.of("专线中断", "专线质差"), enumOf(validation, "bizScenario")); @@ -40,10 +39,9 @@ void should_ContractScenarioEnum_OnBothSchemas() { @Test void should_OptionalSlotObservationSamples_NotExpectTimeOrSerial() { - TaskTSample text = sampleByName( - TaskTPrivateLineComplaintSamples.textSamples(), "text-optional-slots-missing"); - TaskTSample data = sampleByName( - TaskTPrivateLineComplaintSamples.dataWithSchemaSamples(), "data-optional-slots-missing"); + TaskTSample text = sampleByName(TaskTPrivateLineComplaintSamples.textSamples(), "text-optional-slots-missing"); + TaskTSample data = + sampleByName(TaskTPrivateLineComplaintSamples.dataWithSchemaSamples(), "data-optional-slots-missing"); assertFalse(text.expectedParams().containsKey(TaskTPrivateLineComplaintSamples.SERVER_TIME)); assertFalse(text.expectedParams().containsKey(TaskTPrivateLineComplaintSamples.SERVER_TICKET)); @@ -60,6 +58,9 @@ private static List enumOf(Map schema, String property) { } private static TaskTSample sampleByName(List samples, String name) { - return samples.stream().filter(sample -> sample.name().equals(name)).findFirst().orElse(null); + return samples.stream() + .filter(sample -> sample.name().equals(name)) + .findFirst() + .orElse(null); } -} \ No newline at end of file +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java index 3fe657e0..ae74be3a 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java @@ -1,6 +1,5 @@ package net.openan.a2at.sample.task_t; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -25,12 +24,13 @@ /** * 集成测试:将单个 Task-T 样本走通 client → server 完整链路。 * - *

            所有测试方法默认 {@code @Disabled},需要 LLM 环境({@code client.env} 配置了可用的 OpenAI 兼容接口)。 - * 在 IDE 中手动取消对应方法的 {@code @Disabled} 即可单独调试某一个样本。 + *

            所有测试方法默认 {@code @Disabled},需要 LLM 环境({@code client.env} 配置了可用的 OpenAI 兼容接口)。 在 IDE 中手动取消对应方法的 {@code @Disabled} + * 即可单独调试某一个样本。 * *

            样本来源:{@link TaskTPrivateLineComplaintSamples}(从 {@code private-line-complaint-samples.json} 加载)。 * *

            用法

            + * *
            {@code
              * // 1. 把要调试的样本方法的 @Disabled 注释掉
              * // 2. 在 IDE 中右键运行该方法
            @@ -78,54 +78,42 @@ void textSample_privateLineQuality() {
                     runAndAssert(sample);
                 }
             
            -    /**
            -     * 文本样本:逻辑端口专线中断投诉。
            -     */
            +    /** 文本样本:逻辑端口专线中断投诉。 */
                 @Test
                 void textSample_logicalPortInterruption() {
                     TaskTSample sample = textSample("text-logical-port-interruption");
                     runAndAssert(sample);
                 }
             
            -    /**
            -     * 文本样本:端口质差抖动投诉。
            -     */
            +    /** 文本样本:端口质差抖动投诉。 */
                 @Test
                 void textSample_portQualityJitter() {
                     TaskTSample sample = textSample("text-port-quality-jitter");
                     runAndAssert(sample);
                 }
             
            -    /**
            -     * 文本样本:端口中断路由投诉。
            -     */
            +    /** 文本样本:端口中断路由投诉。 */
                 @Test
                 void textSample_portInterruptionRoute() {
                     TaskTSample sample = textSample("text-port-interruption-route");
                     runAndAssert(sample);
                 }
             
            -    /**
            -     * 文本样本:端口质差时延投诉。
            -     */
            +    /** 文本样本:端口质差时延投诉。 */
                 @Test
                 void textSample_portQualityLatency() {
                     TaskTSample sample = textSample("text-port-quality-latency");
                     runAndAssert(sample);
                 }
             
            -    /**
            -     * 文本样本:逻辑端口中断 VLAN 投诉。
            -     */
            +    /** 文本样本:逻辑端口中断 VLAN 投诉。 */
                 @Test
                 void textSample_logicalPortInterruptionVlan() {
                     TaskTSample sample = textSample("text-logical-port-interruption-vlan");
                     runAndAssert(sample);
                 }
             
            -    /**
            -     * 文本样本:可选槽位缺失(无时间、无流水号),验证服务端对可选字段不强制要求。
            -     */
            +    /** 文本样本:可选槽位缺失(无时间、无流水号),验证服务端对可选字段不强制要求。 */
                 @Test
                 void textSample_optionalSlotsMissing() {
                     TaskTSample sample = textSample("text-optional-slots-missing");
            @@ -136,54 +124,42 @@ void textSample_optionalSlotsMissing() {
                 // 用例二:generateTaskPromptFromDataWithSchema(结构化数据 + 语义 schema)
                 // =========================================================================
             
            -    /**
            -     * 数据样本:端口质差(结构化输入,客户端 key → 服务端 key 跨键适配)。
            -     */
            +    /** 数据样本:端口质差(结构化输入,客户端 key → 服务端 key 跨键适配)。 */
                 @Test
                 void dataSample_portQuality() {
                     TaskTSample sample = dataSample("data-port-quality");
                     runDataAndAssert(sample);
                 }
             
            -    /**
            -     * 数据样本:逻辑端口中断。
            -     */
            +    /** 数据样本:逻辑端口中断。 */
                 @Test
                 void dataSample_logicalPortInterruption() {
                     TaskTSample sample = dataSample("data-logical-port-interruption");
                     runDataAndAssert(sample);
                 }
             
            -    /**
            -     * 数据样本:端口抖动。
            -     */
            +    /** 数据样本:端口抖动。 */
                 @Test
                 void dataSample_portJitter() {
                     TaskTSample sample = dataSample("data-port-jitter");
                     runDataAndAssert(sample);
                 }
             
            -    /**
            -     * 数据样本:逻辑端口中断路由。
            -     */
            +    /** 数据样本:逻辑端口中断路由。 */
                 @Test
                 void dataSample_logicalPortInterruptionRoute() {
                     TaskTSample sample = dataSample("data-logical-port-interruption-route");
                     runDataAndAssert(sample);
                 }
             
            -    /**
            -     * 数据样本:端口质差丢包。
            -     */
            +    /** 数据样本:端口质差丢包。 */
                 @Test
                 void dataSample_portQualityLoss() {
                     TaskTSample sample = dataSample("data-port-quality-loss");
                     runDataAndAssert(sample);
                 }
             
            -    /**
            -     * 数据样本:端口质差核心慢。
            -     */
            +    /** 数据样本:端口质差核心慢。 */
                 @Test
                 void dataSample_portQualityCoreSlow() {
                     TaskTSample sample = dataSample("data-port-quality-core-slow");
            @@ -206,54 +182,42 @@ void dataSample_optionalSlotsMissing() {
                 // 用例三:拒绝样本(缺少关键槽位,期望服务端校验拒绝)
                 // =========================================================================
             
            -    /**
            -     * 文本拒绝样本:缺少接入端口。
            -     */
            +    /** 文本拒绝样本:缺少接入端口。 */
                 @Test
                 void rejectionSample_textMissingAccessPort() {
                     TaskTRejectionSample sample = rejectionSample("text-missing-access-port");
                     runRejectionAndAssert(sample, false);
                 }
             
            -    /**
            -     * 文本拒绝样本:缺少投诉场景。
            -     */
            +    /** 文本拒绝样本:缺少投诉场景。 */
                 @Test
                 void rejectionSample_textMissingScenario() {
                     TaskTRejectionSample sample = rejectionSample("text-missing-scenario");
                     runRejectionAndAssert(sample, false);
                 }
             
            -    /**
            -     * 文本拒绝样本:缺少所有关键字段。
            -     */
            +    /** 文本拒绝样本:缺少所有关键字段。 */
                 @Test
                 void rejectionSample_textMinimalNoKeyFields() {
                     TaskTRejectionSample sample = rejectionSample("text-minimal-no-key-fields");
                     runRejectionAndAssert(sample, false);
                 }
             
            -    /**
            -     * 文本拒绝样本:缺少场景和流水号。
            -     */
            +    /** 文本拒绝样本:缺少场景和流水号。 */
                 @Test
                 void rejectionSample_textMissingScenarioAndSerial() {
                     TaskTRejectionSample sample = rejectionSample("text-missing-scenario-and-serial");
                     runRejectionAndAssert(sample, false);
                 }
             
            -    /**
            -     * 数据拒绝样本:缺少端口。
            -     */
            +    /** 数据拒绝样本:缺少端口。 */
                 @Test
                 void rejectionSample_dataMissingPort() {
                     TaskTRejectionSample sample = rejectionSample("data-missing-port");
                     runRejectionAndAssert(sample, true);
                 }
             
            -    /**
            -     * 数据拒绝样本:缺少投诉场景。
            -     */
            +    /** 数据拒绝样本:缺少投诉场景。 */
                 @Test
                 void rejectionSample_dataMissingScenario() {
                     TaskTRejectionSample sample = rejectionSample("data-missing-scenario");
            @@ -263,8 +227,7 @@ void rejectionSample_dataMissingScenario() {
                 /**
                  * 数据拒绝样本:投诉场景值不在枚举合约内。
                  *
            -     * 

            已禁用:LLM 总会从 faultDetailText 等上下文推断并规范化 complaintScenario, - * 无法可靠触发枚举值校验拒绝。 + *

            已禁用:LLM 总会从 faultDetailText 等上下文推断并规范化 complaintScenario, 无法可靠触发枚举值校验拒绝。 */ @Test @Disabled("LLM 会从上下文推断并规范化非法枚举值,无法可靠触发拒绝") @@ -273,9 +236,7 @@ void rejectionSample_dataInvalidScenarioValue() { runRejectionAndAssert(sample, true); } - /** - * 数据拒绝样本:缺少端口和场景。 - */ + /** 数据拒绝样本:缺少端口和场景。 */ @Test void rejectionSample_dataMissingPortAndScenario() { TaskTRejectionSample sample = rejectionSample("data-missing-port-and-scenario"); @@ -286,35 +247,30 @@ void rejectionSample_dataMissingPortAndScenario() { // 内部方法 // ========================================================================= - /** - * 运行一个文本 accuracy 样本:generateTaskPromptFromText → validate → scoreFields → 断言全部字段命中。 - */ + /** 运行一个文本 accuracy 样本:generateTaskPromptFromText → validate → scoreFields → 断言全部字段命中。 */ private void runAndAssert(TaskTSample sample) { System.out.println("─── 样本: " + sample.name() + " (text) ───"); System.out.println("[输入] " + sample.text()); System.out.println(); - MetadataContent metadata = client.generateTaskPromptFromText( - sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + MetadataContent metadata = + client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); printMetadata(metadata); Map extracted = validate(server, sample, metadata); printExtracted(extracted); - List fields = - TaskTAccuracyEvaluator.scoreFields(sample, extracted); + List fields = TaskTAccuracyEvaluator.scoreFields(sample, extracted); printFieldScores(fields); - TaskTAccuracyEvaluator.SampleScore score = - new TaskTAccuracyEvaluator.SampleScore(sample.name(), true, fields); - assertTrue(score.passed(), - () -> "样本 " + sample.name() + " 未通过: " + score.matchedFieldCount() - + "/" + score.expectedFieldCount() + " 字段命中"); + TaskTAccuracyEvaluator.SampleScore score = new TaskTAccuracyEvaluator.SampleScore(sample.name(), true, fields); + assertTrue( + score.passed(), + () -> "样本 " + sample.name() + " 未通过: " + score.matchedFieldCount() + "/" + score.expectedFieldCount() + + " 字段命中"); } - /** - * 运行一个数据 accuracy 样本:generateTaskPromptFromDataWithSchema → validate → scoreFields → 断言全部字段命中。 - */ + /** 运行一个数据 accuracy 样本:generateTaskPromptFromDataWithSchema → validate → scoreFields → 断言全部字段命中。 */ private void runDataAndAssert(TaskTSample sample) { System.out.println("─── 样本: " + sample.name() + " (data) ───"); System.out.println("[输入] data: " + sample.data()); @@ -328,22 +284,21 @@ private void runDataAndAssert(TaskTSample sample) { Map extracted = validate(server, sample, metadata); printExtracted(extracted); - List fields = - TaskTAccuracyEvaluator.scoreFields(sample, extracted); + List fields = TaskTAccuracyEvaluator.scoreFields(sample, extracted); printFieldScores(fields); - TaskTAccuracyEvaluator.SampleScore score = - new TaskTAccuracyEvaluator.SampleScore(sample.name(), true, fields); - assertTrue(score.passed(), - () -> "样本 " + sample.name() + " 未通过: " + score.matchedFieldCount() - + "/" + score.expectedFieldCount() + " 字段命中"); + TaskTAccuracyEvaluator.SampleScore score = new TaskTAccuracyEvaluator.SampleScore(sample.name(), true, fields); + assertTrue( + score.passed(), + () -> "样本 " + sample.name() + " 未通过: " + score.matchedFieldCount() + "/" + score.expectedFieldCount() + + " 字段命中"); } /** * 运行一个拒绝样本:期望被服务端语义校验拒绝,或被客户端前置校验拒绝。 * - * @param isData {@code true} 表示数据样本(走 {@code generateTaskPromptFromDataWithSchema}), - * {@code false} 表示文本样本(走 {@code generateTaskPromptFromText}) + * @param isData {@code true} 表示数据样本(走 {@code generateTaskPromptFromDataWithSchema}), {@code false} 表示文本样本(走 + * {@code generateTaskPromptFromText}) */ private void runRejectionAndAssert(TaskTRejectionSample sample, boolean isData) { System.out.println("─── 拒绝样本: " + sample.name() + " (" + (isData ? "data" : "text") + ") ───"); @@ -361,8 +316,8 @@ private void runRejectionAndAssert(TaskTRejectionSample sample, boolean isData) metadata = client.generateTaskPromptFromDataWithSchema( sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); } else { - metadata = client.generateTaskPromptFromText( - sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + metadata = + client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); } } catch (PromptGenerationException e) { // 客户端前置校验拒绝:缺少必填槽位,在调用 LLM 之前就被拦截 @@ -373,7 +328,9 @@ private void runRejectionAndAssert(TaskTRejectionSample sample, boolean isData) try { Map extracted = server.validateTaskPromptAndDataFilling( - metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) + metadata.promptText(), + sample.validationSchema(), + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) .data(); System.out.println("[意外通过] 应被拒绝却校验通过,提取参数: " + extracted); // 拒绝样本应被拒绝,意外通过视为失败 @@ -383,9 +340,8 @@ private void runRejectionAndAssert(TaskTRejectionSample sample, boolean isData) List errors = e.errors(); if (errors != null && !errors.isEmpty()) { for (net.openan.a2at.sdk.core.model.SlotValidationError error : errors) { - System.out.println(" slot=" + error.slotName() - + " code=" + error.code() - + " message=" + error.message()); + System.out.println( + " slot=" + error.slotName() + " code=" + error.code() + " message=" + error.message()); } } // 期望被拒绝,测试通过 @@ -452,10 +408,11 @@ private static TaskTSample sampleByName(List samples, String name) * 解析 {@code client.env} 路径。 * *

            优先级: + * *

              - *
            1. 工作目录下的 {@code client.env}(需包含有效的 LLM 配置密钥);
            2. - *
            3. repo 根目录下的 {@code client.env}(Maven 多模块项目,从模块目录向上一级);
            4. - *
            5. classpath 中打包的 sample 模板,复制到临时文件。
            6. + *
            7. 工作目录下的 {@code client.env}(需包含有效的 LLM 配置密钥); + *
            8. repo 根目录下的 {@code client.env}(Maven 多模块项目,从模块目录向上一级); + *
            9. classpath 中打包的 sample 模板,复制到临时文件。 *
            */ private static Path resolveEnvPath() throws IOException { @@ -472,8 +429,7 @@ private static Path resolveEnvPath() throws IOException { ClassLoader classLoader = TaskTSingleSampleTest.class.getClassLoader(); try (InputStream in = classLoader.getResourceAsStream(BUNDLED_ENV_RESOURCE)) { if (in == null) { - throw new IllegalStateException( - "classpath 中未找到打包的 env 模板: " + BUNDLED_ENV_RESOURCE); + throw new IllegalStateException("classpath 中未找到打包的 env 模板: " + BUNDLED_ENV_RESOURCE); } tempEnvFile = Files.createTempFile("taskt-client-", ".env"); Files.copy(in, tempEnvFile, StandardCopyOption.REPLACE_EXISTING); @@ -493,7 +449,9 @@ private static boolean hasRequiredLlmKeys(Path envFile) { if (separator <= 0) { continue; } - entries.put(trimmed.substring(0, separator).trim(), trimmed.substring(separator + 1).trim()); + entries.put( + trimmed.substring(0, separator).trim(), + trimmed.substring(separator + 1).trim()); } } catch (java.io.IOException e) { return false; @@ -505,4 +463,4 @@ private static boolean hasRequiredLlmKeys(Path envFile) { } return true; } -} \ No newline at end of file +} From 779a3a1753c5a2de8b63d03b4bfbc9e67d4e3b03 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Mon, 31 Aug 2026 00:42:32 +0800 Subject: [PATCH 258/295] chore(release): bump version to 1.1.0 Signed-off-by: zhangwenjun-huawei --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 42a4f1ec..ea01d6e2 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ - 1.0.0 + 1.1.0 17 UTF-8 3.3.1 From b0d98bfeed93fbd19cc2db17b1f65b9396a7db18 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Mon, 31 Aug 2026 01:15:56 +0800 Subject: [PATCH 259/295] fix(doc): change version to 1.1.0 Signed-off-by: zhangwenjun-huawei --- docs/en/developer_guide.md | 2 +- "docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 715414f6..8496c611 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -294,7 +294,7 @@ Business projects can use the BOM to manage A2A-T SDK versions uniformly: net.openan.a2a-t.sdk a2a-t-bom - 1.0.0 + 1.1.0 pom import diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index f543469a..b6c5e4c1 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -293,7 +293,7 @@ flowchart TD net.openan.a2a-t.sdk a2a-t-bom - 1.0.0 + 1.1.0 pom import From e0fe680f77502455c19d791fd624b85340788f55 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Sat, 5 Sep 2026 15:18:55 +0800 Subject: [PATCH 260/295] fix(doc): refresh template.md and docs Signed-off-by: zhangwenjun-huawei --- .../v1/en-US/template.md | 8 +++++++ .../v1/zh-CN/template.md | 10 ++++++++- .../accept-reject/v1/en-US/template.md | 4 ++-- .../accept-reject/v1/zh-CN/template.md | 4 ++-- .../service-recovery/v1/en-US/template.md | 5 ++--- .../service-recovery/v1/zh-CN/template.md | 5 ++--- docs/en/API_Reference.md | 10 ++++----- docs/en/developer_guide.md | 7 +++---- "docs/zh/API\345\217\202\350\200\203.md" | 10 ++++----- ...00\345\217\221\346\214\207\345\215\227.md" | 21 +++++++++---------- 10 files changed, 46 insertions(+), 38 deletions(-) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/en-US/template.md index 4535b96d..918bc847 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/en-US/template.md @@ -27,3 +27,11 @@ Requirement: 1. Authorization operation execution result. Allowed values: success, failure, partial success 2. When the authorization operation is executed successfully, return the that was executed successfully 3. When the authorization operation fails or is partially successful, return a failure list containing the authorization policies and the failure reasons + +## Terminology Explanation +1. Service complaint diagnosis + - Synonyms: Leased Line Service Complaint, corporate leased line complaint, corporate business complaint, leased line complaint, corporate leased line business complaint diagnosis, leased line business complaint diagnosis, leased line complaint diagnosis, circuit complaint, leased line business appeal, leased line service complaint, group leased line business complaint, government-enterprise leased line customer complaint, leased line complaint event +2. Service recovery + - Synonyms: Service Restoration, Service Recovery, restore service, service recovery, temporary service recovery, rapid service recovery, rapid service restoration, emergency service restoration, service restoration handling +3. Tunnel optimization + - Synonyms: Tunnel Optimization, tunnel optimization, tunnel path optimization, tunnel service optimization, tunnel resource optimization, tunnel rerouting optimization, tunnel routing regularization, tunnel path reorganization diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md index 9530c7cf..79e22db3 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md @@ -26,4 +26,12 @@ ## 预期输出 1. 授权操作执行结果,取值范围: 成功、失败、部分成功; 2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; -3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; \ No newline at end of file +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; + +## 术语解释(Terminology Explanation) +1. 业务投诉诊断 + - 同义词术语:Leased Line Service Complaint,集客专线投诉,集客业务投诉,专线投诉,集客专线业务投诉诊断,专线业务投诉诊断,专线投诉诊断,电路投诉,专线业务申诉,专线服务投诉,集团专线业务投诉,政企专线客户投诉,专线投诉事件 +2. 业务抢通 + - 同义词术语:Service Restoration,Service Recovery, 恢复业务,业务恢复,业务临时恢复,业务快速恢复,业务快速抢通,业务应急抢通,业务抢通处置 +3. 隧道调优 + - 同义词术语:Tunnel Optimization,隧道优化,隧道路径调优,隧道业务调优,隧道资源调优,隧道路径优化,隧道重路由优化,隧道路由规整,隧道路径重整 \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/en-US/template.md index 099373d5..31aba02d 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/en-US/template.md @@ -9,9 +9,9 @@ Requirement: {{information_negotiation_result_content}} (required) Requirement: 1. If the information can be provided: - - State the name and content + - State the name and content 2. If the information cannot be provided: - - State the name and the specific reason why it cannot be provided (optional) + - State the name and the specific reason why it cannot be provided (optional) Example 1: Energy saving area information: Songshanhu Energy saving rate guarantee goal: 20Mbps diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/zh-CN/template.md index 88f19097..380c86a6 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/zh-CN/template.md @@ -9,9 +9,9 @@ {{信息协商结果内容}}(必填) 要求: 1. 如果能够提供信息: - - 说明名称、内容 + - 说明名称、内容 2. 如果无法提供信息: - - 说明名称、无法提供的具体原因(可选) + - 说明名称、无法提供的具体原因(可选) 样例1: 节能区域信息为松山湖 节能速率保障目标为20Mbps diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md index d3718bce..fd3d11c7 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md @@ -42,6 +42,5 @@ Requirement: 10. Service recovery plan execution failure reason. This information must be provided when the service recovery plan execution result is "failure". Example: After optimization, tunnel link C is occupied and tunnel optimization cannot be performed (optional) ## Expected Output -1. Subscription result. Allowed values: success, failure -2. Subscription failure reason (optional) -3. After successful subscription, report messages according to +1. Subscription result. Allowed values: success +2. After successful subscription, report messages according to diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md index c5f7eea9..6f68c97c 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md @@ -42,6 +42,5 @@ 10. 业务抢通方案执行失败原因;业务抢通方案执行结果为“失败”时,需要填写此信息;举例:调优后隧道链路C已被占用,无法执行隧道调优 (可选) ## 预期输出 -1. 订阅结果,取值范围:成功、失败 -2. 订阅失败原因(可选) -3. 订阅成功后,按照<上报通知数据格式>上报消息 \ No newline at end of file +1. 订阅结果,取值范围:成功 +2. 订阅成功后,按照<上报通知数据格式>上报消息 \ No newline at end of file diff --git a/docs/en/API_Reference.md b/docs/en/API_Reference.md index eaa7191b..ac7c57b1 100644 --- a/docs/en/API_Reference.md +++ b/docs/en/API_Reference.md @@ -1083,9 +1083,8 @@ Service recovery event 7. Service recovery plan details ## Expected Output -1. Subscription result. Allowed values: success, failure -2. Subscription failure reason (optional) -3. After successful subscription, report messages according to +1. Subscription result. Allowed values: success +2. After successful subscription, report messages according to ``` ### 1.3.14 generateNotificationPromptFromDataWithSchema @@ -1176,9 +1175,8 @@ Subnetwork name: xx subnetwork 8. Service recovery plan execution end time (optional) ## Expected Output -1. Subscription result. Allowed values: success, failure -2. Subscription failure reason (optional) -3. After successful subscription, report messages according to +1. Subscription result. Allowed values: success +2. After successful subscription, report messages according to ``` ### 1.3.15 validateNotificationPromptAndDataFilling diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 8496c611..d3ab2512 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -20,7 +20,7 @@ Main capabilities include: - **Client-side template message generation**: the client generates protocol messages conforming to the A2A-T format from natural-language or structured input. - **Server-side template message validation**: the server validates whether A2A-T protocol-format messages sent by the client match the scenario, template, and slot constraints. - **Multi-round negotiation management**: supports the `information`, `feasibility`, and `target` negotiation flows. -- **Template resource management**: bundles scenario, slot, template, and system prompt resources, and supports both `classpath` and local-file resource loading. +- **Template resource management**: bundles scenario, slot, template, and system prompt resources, and supports two resource loading modes: `classpath` built-in resources and `local_file` local file resources. - **LLM adaptation**: connects to external large models through OpenAI-compatible call chains. For the API list and usage details provided by the SDK, see [API_Reference.md](API_Reference.md). @@ -185,9 +185,8 @@ Service recovery event 7. Service recovery plan details ## Expected Output -1. Subscription result, possible values: success, failure -2. Reason for subscription failure (optional) -3. After a successful subscription, report messages per the +1. Subscription result, possible values: success +2. After a successful subscription, report messages per the */ ``` diff --git "a/docs/zh/API\345\217\202\350\200\203.md" "b/docs/zh/API\345\217\202\350\200\203.md" index 36fbbfc7..6712a7bd 100644 --- "a/docs/zh/API\345\217\202\350\200\203.md" +++ "b/docs/zh/API\345\217\202\350\200\203.md" @@ -1075,9 +1075,8 @@ promptText : 7. 业务抢通方案详情 ## 预期输出 -1. 订阅结果,取值范围:成功、失败 -2. 订阅失败原因(可选) -3. 订阅成功后,按照<上报通知数据格式>上报消息 +1. 订阅结果,取值范围:成功 +2. 订阅成功后,按照<上报通知数据格式>上报消息 ``` ### 1.3.14 generateNotificationPromptFromDataWithSchema @@ -1168,9 +1167,8 @@ promptText : 8. 业务抢通方案执行结束时间(可选) ## 预期输出 -1. 订阅结果,取值范围:成功、失败 -2. 订阅失败原因(可选) -3. 订阅成功后,按照<上报通知数据格式>上报消息 +1. 订阅结果,取值范围:成功 +2. 订阅成功后,按照<上报通知数据格式>上报消息 ``` ### 1.3.15 validateNotificationPromptAndDataFilling diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index b6c5e4c1..7a0de3f6 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -1,10 +1,10 @@ # 1 a2a-t-sdk-java 开发指南 -| 分类 | 说明 | -| -------- | ------------------------------------------------------------ | -| 目标读者 | 基于A2A-T SDK进行多智能体协议交互的开发者、集成部署人员、项目对接运维人员 | -| 文档目的 | 本文档详细介绍A2A-T SDK的完整安装方式、参数配置及最小实践。帮助使用者快速、规范地完成SDK接入、功能开发和线上落地。 | -| 阅读前提 | 了解A2A多智能体协议的数据模型定义和使用,了解AgentCard模型定义和使用,了解注册中心相关功能 | +| 分类 | 说明 | +| -------- |-----------------------------------------------------------------| +| 目标读者 | 基于A2A-T SDK进行多智能体协议交互的开发者、集成部署人员、项目对接运维人员 | +| 文档目的 | 本文档详细介绍A2A-T SDK的完整安装方式、参数配置及最小实践。帮助使用者快速、规范地完成SDK接入、功能开发和上线落地。 | +| 阅读前提 | 了解A2A多智能体协议的数据模型定义和使用,了解AgentCard模型定义和使用,了解注册中心相关功能 | ## 1.1 特性介绍 @@ -20,14 +20,14 @@ a2a-t-sdk-java是面向电信智能体协作场景的 Java SDK,用于在 A2A-T - **客户端模板报文生成**:客户端根据自然语言或结构化输入生成符合 A2A-T 格式的协议报文。 - **服务端模板报文校验**:服务端校验客户端下发的 A2A-T协议格式报文 是否匹配场景、模板和槽位约束。 - **多轮协商管理**:支持`information`、`feasibility`、`target`协商流程。 -- **模板资源管理**:内置场景、槽位、模板和系统提示词资源,支持`classpath`与本地文件两种资源加载方式。 +- **模板资源管理**:内置场景、槽位、模板和系统提示词资源,支持`classpath`内置资源与`local_file`本地文件两种资源加载方式。 - **LLM 适配**:通过 OpenAI-compatible 调用链接入外部大模型。 SDK提供的API列表和使用说明,详见 [API参考.md](API参考.md)。 ### 1.1.2 A2A-T SDK与A2A SDK的关系 -A2A-T协议是基于A2A协议的扩展,针对协议扩展的内容提供了A2A-T SDK,支撑快速构建满用于电信领域复杂协同场景的智能体。 +A2A-T协议是基于A2A协议的扩展,针对协议扩展的内容提供了A2A-T SDK,支撑快速构建用于电信领域复杂协同场景的智能体。 A2A-T SDK独立于A2A SDK,通过同时集成A2A-T SDK与A2A SDK可以构建支持A2A-T协议的agent,支持电信领域多agent之间的确定性、高可靠、高效及安全协同。Java生态中可搭配的A2A SDK为a2a-java(`org.a2aproject.sdk`)。 @@ -136,7 +136,7 @@ flowchart TD ## 1.4 基础开发样例 -目的:通过提供A2A-T客户端和服务端的样例代码,帮助使用者快速、规范地熟悉SDK接入流程,加速功能开发和线上落地。 +目的:通过提供A2A-T客户端和服务端的样例代码,帮助使用者快速、规范地熟悉SDK接入流程,加速功能开发和上线落地。 ### 1.4.1 样例接口说明 @@ -185,9 +185,8 @@ MetadataContent result = client.generateNotificationPromptFromText( 7. 业务抢通方案详情 ## 预期输出 -1. 订阅结果,取值范围:成功、失败 -2. 订阅失败原因(可选) -3. 订阅成功后,按照<上报通知数据格式>上报消息 +1. 订阅结果,取值范围:成功 +2. 订阅成功后,按照<上报通知数据格式>上报消息 */ ``` From ca476303b6e9854208e9cecdd61a576eb426a670 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Sat, 5 Sep 2026 15:24:37 +0800 Subject: [PATCH 261/295] ci: restrict push and pull_request triggers to main and dev branches Signed-off-by: zhangwenjun-huawei --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5374ee9e..af1717e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,9 @@ name: CI on: push: + branches: [ main, dev ] pull_request: - branches: [ main ] + branches: [ main, dev ] jobs: build-and-test: From 90a154ae4d91180ba32073fedcb0f5dfbf87b602 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Sat, 5 Sep 2026 16:07:15 +0800 Subject: [PATCH 262/295] fix(ci): allow Terminology Explanation in Authorization-T lint profile Signed-off-by: zhangwenjun-huawei --- tools/template_lint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/template_lint.py b/tools/template_lint.py index e464a006..7553031c 100644 --- a/tools/template_lint.py +++ b/tools/template_lint.py @@ -14,7 +14,7 @@ SLOT = re.compile(r"{{\s*([^{}\s]+)\s*}}") TASK = {"Task Description", "Task Type", "Task Target", "Task Object", "Task Context", "Constraints", "Expected Output", "Operation Type", "Terminology Explanation"} NOTIFICATION = {"Subscription Description", "Notification Topic", "Subscribe Condition", "Notification Data Format", "Expected Output"} -AUTHORIZATION = {"Authorization Policy Operation Type", "Authorization Policy Operation Description", "Network Operation Authorization Policy List", "Expected Output"} +AUTHORIZATION = {"Authorization Policy Operation Type", "Authorization Policy Operation Description", "Network Operation Authorization Policy List", "Expected Output", "Terminology Explanation"} ALIASES = { "任务描述": "Task Description", "任务类型": "Task Type", "任务目标": "Task Target", "任务对象": "Task Object", "目标对象": "Task Object", "任务上下文": "Task Context", "约束条件": "Constraints", "预期输出": "Expected Output", From 88f53dfdfe637b4ef4b0978bb421b7fbfa6dc7cb Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Sat, 5 Sep 2026 17:14:20 +0800 Subject: [PATCH 263/295] chore: add .gitattributes and normalize line endings to LF Add a comprehensive .gitattributes file tailored for this Java SDK project, covering all source types (.java, .xml, .properties), build/config files (.yml, .yaml, .json, .toml, .env, .example), documentation (.md, .txt, .html, .css, .js), Python tooling (.py), and binary files (.jar, .class, .png, .jpg, etc.). Also run git add --renormalize to normalize all tracked files from CRLF to LF in the Git index, and convert the working tree files accordingly, ensuring consistent LF line endings across the project. Signed-off-by: zhangwenjun-huawei --- .gitattributes | 53 + .../openan/a2at/sdk/client/A2ATClient.java | 1320 ++++---- .../assembly/DefaultA2ATClientBuilder.java | 476 +-- .../ClientPromptGenerationOrchestrator.java | 208 +- ...ultClientPromptGenerationOrchestrator.java | 1060 +++---- .../api/A2ATClientNegotiationApiTest.java | 438 +-- .../A2ATClientNegotiationEnvConfigTest.java | 480 +-- .../a2at/sdk/client/api/A2ATClientTest.java | 1680 +++++----- ...lientPromptGenerationOrchestratorTest.java | 2378 +++++++-------- .../core/exception/A2ATBusinessException.java | 218 +- .../a2at/sdk/core/exception/A2ATError.java | 148 +- .../sdk/core/exception/A2ATErrorCodes.java | 204 +- .../exception/A2ATParamExtractionError.java | 128 +- .../ConfigFileNotFoundException.java | 54 +- .../a2at/sdk/core/exception/ErrorCatalog.java | 484 +-- .../sdk/core/exception/ErrorMessages.java | 290 +- .../exception/PromptGenerationException.java | 132 +- .../a2at/sdk/core/model/A2ATConfig.java | 146 +- .../a2at/sdk/core/model/A2ATConfigKeys.java | 192 +- .../sdk/core/model/PromptRuntimeConfig.java | 70 +- .../a2at/sdk/core/model/PromptTemplate.java | 56 +- .../sdk/core/model/SlotValidationError.java | 70 +- .../a2at/sdk/core/resources/PathSegments.java | 132 +- .../ContentValidationException.java | 210 +- .../sdk/core/validation/ContentValidator.java | 64 +- .../a2at/sdk/core/validation/RetryUtil.java | 146 +- .../validation/TemplateContentLoader.java | 54 +- .../core/validation/ValidationPipeline.java | 508 +-- .../core/exception/A2ATErrorCodesTest.java | 152 +- .../sdk/core/exception/A2ATErrorTest.java | 138 +- .../a2at/sdk/core/model/A2ATConfigTest.java | 562 ++-- .../LlmConfigMaxAttemptsLoggingTest.java | 218 +- .../a2at/sdk/core/model/LlmConfigTest.java | 500 +-- .../core/model/PromptRuntimeConfigTest.java | 122 +- .../sdk/core/model/PromptTemplateTest.java | 104 +- .../sdk/core/resources/PathSegmentsTest.java | 262 +- .../validation/ValidationPipelineTest.java | 270 +- .../openan/a2at/sdk/corpus/CaseEngine.java | 1888 ++++++------ .../a2at/sdk/corpus/CaseEngineTest.java | 1740 +++++------ .../a2at/sdk/corpus/CorpusContractTest.java | 1152 +++---- .../sdk/corpus/CorpusSensitivitySelfTest.java | 950 +++--- .../openan/a2at/sdk/corpus/Expectation.java | 256 +- .../a2at/sdk/corpus/LiveCaseEngine.java | 960 +++--- .../a2at/sdk/corpus/LiveCaseEngineTest.java | 564 ++-- .../sdk/corpus/NegotiationCaseLoaderTest.java | 2260 +++++++------- .../a2at/sdk/corpus/ScenarioEngineTest.java | 1034 +++---- .../a2at/sdk/corpus/TaskApiAssembler.java | 328 +- .../ErrorCodePartitionPropertyTest.java | 524 ++-- .../NegotiationErrorPathPropertyTest.java | 664 ++-- .../sdk/corpus/property/PropertyHarness.java | 358 +-- .../content/FeasibilityEndingContent.java | 22 +- .../content/InformationEndingContent.java | 26 +- .../content/InformationProposeContent.java | 26 +- .../content/NegotiationAction.java | 36 +- .../content/NegotiationConclusion.java | 74 +- .../content/NegotiationContent.java | 26 +- .../content/NegotiationEndingContent.java | 34 +- .../NegotiationGenerationException.java | 124 +- .../negotiation/content/NegotiationItem.java | 20 +- .../NegotiationParamExtractionException.java | 240 +- .../NegotiationProcessingException.java | 152 +- .../content/NegotiationProposeContent.java | 18 +- .../content/NegotiationProposeData.java | 24 +- .../negotiation/content/NegotiationType.java | 72 +- .../content/TargetEndingContent.java | 30 +- .../sdk/negotiation/content/Vocabulary.java | 462 +-- .../sdk/negotiation/content/package-info.java | 4 +- .../generation/AbortGenerator.java | 88 +- .../AbstractNegotiationGenerator.java | 284 +- .../DefaultNegotiationContentExtractor.java | 888 +++--- .../FeasibilityEndingGenerator.java | 96 +- .../FeasibilityProposeGenerator.java | 230 +- .../InformationEndingGenerator.java | 88 +- .../InformationProposeGenerator.java | 100 +- .../NegotiationContentExtractor.java | 62 +- .../generation/NegotiationContentService.java | 644 ++-- .../NegotiationGenerationOrchestrator.java | 1302 ++++---- ...otiationGenerationOrchestratorBuilder.java | 454 +-- .../NegotiationGeneratorRegistry.java | 304 +- .../generation/NegotiationItemFormatter.java | 82 +- .../NegotiationJsonSchemaBuilder.java | 322 +- .../generation/NegotiationMessageBuilder.java | 156 +- .../NegotiationPromptResourceLoader.java | 154 +- .../NegotiationRenderException.java | 62 +- .../generation/TargetEndingGenerator.java | 118 +- .../generation/TargetProposeGenerator.java | 190 +- .../negotiation/generation/package-info.java | 4 +- .../DefaultNegotiationTemplateLoader.java | 212 +- .../resources/NegotiationTemplateLoader.java | 42 +- .../negotiation/resources/package-info.java | 4 +- .../DefaultNegotiationComplianceChecker.java | 224 +- .../DefaultNegotiationSemanticValidator.java | 834 ++--- .../NegotiationRuleCheckerAdapter.java | 194 +- .../NegotiationSemanticValidator.java | 160 +- .../NegotiationValidationException.java | 66 +- .../validation/ParamExtractor.java | 218 +- .../validation/SemanticValidationResult.java | 70 +- .../negotiation/validation/package-info.java | 4 +- .../DeprecatedAnnotationScanTest.java | 128 +- .../NegotiationExceptionHierarchyTest.java | 222 +- .../negotiation/content/VocabularyTest.java | 328 +- ...otiationDeliveredResourceContractTest.java | 252 +- .../NegotiationPackageStructureTest.java | 300 +- .../contract/SdkSourceCommentScanTest.java | 138 +- .../AbortMessageGenerationTest.java | 596 ++-- .../CustomRootPromptsIgnoredTest.java | 266 +- .../FromDataProgrammingErrorMatrixTest.java | 418 +-- .../generation/FromTextLlmPipelineTest.java | 1536 +++++----- .../generation/InputTextLengthLimitTest.java | 336 +- .../NegotiationContentExtractorTest.java | 834 ++--- ...nerationOrchestratorBuilderWiringTest.java | 494 +-- ...NegotiationGenerationOrchestratorTest.java | 718 ++--- .../NegotiationGeneratorRegistryTest.java | 392 +-- .../generation/NegotiationGeneratorsTest.java | 1496 ++++----- .../NegotiationItemFormatterTest.java | 90 +- .../NegotiationJsonSchemaBuilderTest.java | 268 +- ...otiationPromptRendererConcurrencyTest.java | 120 +- .../NegotiationPromptRendererTest.java | 432 +-- .../NegotiationPromptResourceLoaderTest.java | 230 +- .../NegotiationResourceIntegrityTest.java | 496 +-- .../TemplateUriEntryMatrixTest.java | 338 +- .../ValidateAndFillingDataPipelineTest.java | 1182 +++---- .../NegotiationLanguageSwitchTest.java | 230 +- .../UnsupportedLanguageBehaviorTest.java | 160 +- .../NegotiationErrorCodeUsageMatrixTest.java | 886 +++--- ...NegotiationExceptionTreeRootCatchTest.java | 500 +-- .../NegotiationLogEventContractTest.java | 794 ++--- ...NegotiationPublicExceptionSurfaceTest.java | 318 +- .../DefaultNegotiationTemplateLoaderTest.java | 348 +-- ...rdTemplatesNegotiationConsistencyTest.java | 196 +- .../validation/ComplianceCheckerTest.java | 260 +- ...faultNegotiationSemanticValidatorTest.java | 874 +++--- .../validation/ParamExtractorTest.java | 644 ++-- ...ultStructuredPromptSlotValueExtractor.java | 488 +-- .../model/StructuredSlotValidationError.java | 70 +- .../catalog/PromptTemplateCatalog.java | 752 ++--- .../catalog/TemplateQueryService.java | 196 +- .../LocalFilePromptScenarioCatalogLoader.java | 124 +- .../LocalFilePromptSlotSchemaLoader.java | 168 +- .../loader/LocalFilePromptTemplateLoader.java | 154 +- .../loader/LocalFileResourceSnapshot.java | 172 +- .../loader/PromptResourceAccess.java | 398 +-- .../resources/loader/ResourceReadErrors.java | 68 +- .../exception/TaskPromptRenderException.java | 50 +- .../validation/DefaultContentValidator.java | 272 +- .../validation/DefaultSemanticValidator.java | 698 ++--- ...tructuredPromptSlotValueExtractorTest.java | 750 ++--- .../catalog/TemplateQueryServiceTest.java | 480 +-- .../loader/LocalFilePromptLoadersTest.java | 654 ++-- .../loader/PromptResourceAccessTest.java | 470 +-- .../DefaultContentValidatorTest.java | 326 +- .../DefaultSemanticValidatorTest.java | 1128 +++---- .../ClasspathPromptResourceLoader.java | 102 +- .../ClasspathPromptResourceLoaderTest.java | 206 +- .../sample/authz_policy/AuthzSampleMain.java | 922 +++--- .../sample/authz_policy/AuthzScenario.java | 324 +- .../authz_policy/AuthzScenarioExecutor.java | 424 +-- .../authz_policy/AuthzScenarioRunner.java | 558 ++-- .../negotiation/eval/NegotiationEvalApp.java | 2714 ++++++++--------- .../NegotiationAbnormalEvaluationMain.java | 1110 +++---- .../a2at/sample/task_t/TaskTDemoMain.java | 840 ++--- .../TaskTPrivateLineComplaintSamples.java | 466 +-- .../api/NegotiationFacadeApiSymmetryTest.java | 192 +- .../NegotiationFacadeOutputSymmetryTest.java | 568 ++-- .../AuthzSampleMainConcurrencyTest.java | 450 +-- .../authz_policy/AuthzSampleMainTest.java | 474 +-- .../AuthzScenarioExecutorTest.java | 394 +-- .../authz_policy/AuthzScenarioLoaderTest.java | 338 +- .../authz_policy/AuthzScenarioRunnerTest.java | 1154 +++---- .../authz_policy/AuthzScenarioTest.java | 476 +-- .../accuracy/NotificationTAccuracyTest.java | 630 ++-- .../api/NegotiationV3ApiSurfaceTest.java | 328 +- .../openan/a2at/sdk/server/A2ATServer.java | 1184 +++---- .../assembly/DefaultA2ATServerBuilder.java | 552 ++-- ...ultServerPromptComplianceOrchestrator.java | 200 +- .../PromptComplianceCheckException.java | 114 +- .../LlmBackedPromptMetadataExtractor.java | 418 +-- .../InputLimitedContentValidator.java | 132 +- .../LlmBackedPromptSemanticValidator.java | 420 +-- .../api/A2ATServerNegotiationApiTest.java | 286 +- .../server/api/InputTextLengthLimitTest.java | 292 +- .../TaskPromptNegotiationCoexistenceTest.java | 302 +- ...verNegotiationOrchestratorBuilderTest.java | 188 +- ...erverPromptComplianceOrchestratorTest.java | 294 +- .../LlmBackedPromptMetadataExtractorTest.java | 562 ++-- .../InputLimitedContentValidatorTest.java | 108 +- .../LlmBackedPromptSemanticValidatorTest.java | 442 +-- 187 files changed, 38953 insertions(+), 38900 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..794ee5cd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,53 @@ +# Auto-detect text files; enable Git's built-in text normalization +* text=auto + +# ===================== +# Java SDK source files +# ===================== +*.java text eol=lf +*.xml text eol=lf +*.properties text eol=lf + +# ===================== +# Build & configuration +# ===================== +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.toml text eol=lf +*.env text eol=lf +*.example text eol=lf + +# ===================== +# Documentation +# ===================== +*.md text eol=lf +*.txt text eol=lf +*.html text eol=lf +*.css text eol=lf +*.js text eol=lf + +# ===================== +# Python tooling +# ===================== +*.py text eol=lf + +# ===================== +# Others +# ===================== +*.lock text eol=lf +*.svg text eol=lf + +# ===================== +# Binary files — never normalize +# ===================== +*.jar binary +*.class binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.zip binary +*.tar binary +*.gz binary \ No newline at end of file diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 6744e96f..ae8c4dd0 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -1,660 +1,660 @@ -package net.openan.a2at.sdk.client; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; -import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import org.jspecify.annotations.NonNull; - -/** - * High-level client facade for prompt generation and negotiation APIs. The caller provides the {@code .env} file path - * explicitly, typically after copying the repository {@code env.example}. - * - * @since 2026-06 - */ -public final class A2ATClient { - - private final ClientPromptGenerationOrchestrator promptGenerationOrchestrator; - - private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; - - private final NegotiationContentService negotiationContentService; - - private final TemplateQueryService templateQueryService; - - /** - * Creates a client facade from one user-supplied {@code .env} path. - * - * @param envPath user-supplied {@code .env} file path - */ - public A2ATClient(Path envPath) { - Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); - A2ATConfig config = - A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); - DefaultA2ATClientBuilder builder = - DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); - this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); - this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); - this.negotiationContentService = - new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); - this.templateQueryService = builder.buildTemplateQueryService(); - } - - /** - * Generates a processed task prompt from raw user input. Both text and structured input ({@code Map}) are unified - * through LLM extraction; there is no zero-LLM rule shortcut. - * - * @param userInput user-provided task description (text or structured input map); a String input longer than the - * configured maximum length ({@code A2AT_INPUT_TEXT_MAX_CHARS}) fails fast without any LLM call - * @return prompt generation result containing either rendered prompt text or failure details; a String input over - * the limit yields the failure code {@code input.text_too_long} - */ - public PromptGenerationResult generateTaskPrompt(Object userInput) { - return promptGenerationOrchestrator.generateTaskPrompt(userInput); - } - - /** - * Generates a task prompt with metadata from natural-language input using the template identified by the template - * URI, bypassing scenario recognition. - * - * @param text natural-language task input - * @param templateUri template URI string identifying the target template, such as - * {@code Task-T/network-layer/ran-energy-saving/v1} - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, - * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, - * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull String templateUri) { - Objects.requireNonNull(text, "text"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateTaskPromptFromText(text, parsedTemplateUri); - } - - /** - * Generates a task prompt with metadata from structured input and a data schema using the template identified by - * the template URI, bypassing scenario recognition. - * - * @param data structured task input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI string identifying the target template, such as - * {@code Task-T/network-layer/ran-energy-saving/v1} - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or the schema is empty - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, - * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, - * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails - */ - public MetadataContent generateTaskPromptFromDataWithSchema( - @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, parsedTemplateUri); - } - - /** - * Generates an authorization prompt with metadata from natural-language input using the template identified by the - * template URI, bypassing scenario recognition. - * - *

            Authorization-T slot schemas are bundled with a2a-t-resources, so the entry point works out of the box with - * classpath resource source. - * - * @param text natural-language authorization input - * @param templateUri template URI string identifying the target template, such as - * {@code Authorization-T/authorization-policy-management/v1} - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, - * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, - * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull String templateUri) { - Objects.requireNonNull(text, "text"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateAuthPromptFromText(text, parsedTemplateUri); - } - - /** - * Generates an authorization prompt with metadata from structured input and a data schema using the template - * identified by the template URI, bypassing scenario recognition. - * - *

            Authorization-T slot schemas are bundled with a2a-t-resources, so the entry point works out of the box with - * classpath resource source. - * - * @param data structured authorization input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI string identifying the target template, such as - * {@code Authorization-T/authorization-policy-management/v1} - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or the schema is empty - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, - * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, - * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails - */ - public MetadataContent generateAuthPromptFromDataWithSchema( - @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, parsedTemplateUri); - } - - /** - * Generates a notification prompt with metadata from natural-language input using the template identified by the - * template URI, bypassing scenario recognition. - * - * @param text natural-language notification input - * @param templateUri template URI string identifying the target template, such as - * {@code Notification-T/network-layer/subscribe-incident/v1} - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, - * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, - * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull String templateUri) { - Objects.requireNonNull(text, "text"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateNotificationPromptFromText(text, parsedTemplateUri); - } - - /** - * Generates a notification prompt with metadata from structured input and a data schema using the template - * identified by the template URI, bypassing scenario recognition. - * - * @param data structured notification input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI string identifying the target template, such as - * {@code Notification-T/network-layer/subscribe-incident/v1} - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or the schema is empty - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, - * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, - * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails - */ - public MetadataContent generateNotificationPromptFromDataWithSchema( - @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, parsedTemplateUri); - } - - /** - * Starts a new negotiation payload for the requested negotiation type. - * - * @param type negotiation type to initiate - * @param contentText human-readable negotiation message - * @param facts structured facts attached to the payload - * @return transport payload representing the initial negotiation turn - */ - public Map startNegotiation(NegotiationType type, String contentText, Map facts) { - return negotiationOrchestrator.startNegotiation(type, contentText, facts); - } - - /** - * Processes a received negotiation message using its transport context payload. - * - * @param message received negotiation message - * @param context transport context payload associated with the message - * @return normalized payload describing the receive result - */ - public Map receiveNegotiation(String message, Map context) { - return negotiationOrchestrator.receiveNegotiation(message, context); - } - - /** - * Continues an existing negotiation with a locally stored context snapshot. - * - * @param context current negotiation context - * @param status next status to emit - * @param contentText continuation message content - * @return transport payload representing the next negotiation turn - */ - public Map continueNegotiation( - NegotiationContext context, NegotiationStatus status, String contentText) { - return negotiationOrchestrator.continueNegotiation(context, status, contentText); - } - - /** - * Generates a propose-phase negotiation message from typed data. - * - *

            This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the - * generator of the negotiation type addressed by the template URI and rendered from that template. - * - * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/propose/v1}; its - * performative segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the - * method or the content type - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, the code - * {@code negotiation.content_invalid} when a required content field is blank or empty, the code - * {@code negotiation.invalid_input} when the content combines a non-blank confirm request with conditional - * sections, or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateNegotiationProposePromptFromData( - @NonNull NegotiationProposeData data, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateProposeFromData(data, parsedTemplateUri); - } - - /** - * Generates an accept-phase negotiation message from typed data. - * - *

            This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; - * its performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the - * method or the content - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, the code - * {@code negotiation.conclusion_mismatch} when the content conclusion is not {@code Accept}, the code - * {@code negotiation.content_invalid} when a required content field is blank or empty, or the code - * {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateNegotiationAcceptPromptFromData( - @NonNull NegotiationEndingData data, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateAcceptFromData(data, parsedTemplateUri); - } - - /** - * Generates a reject-phase negotiation message from typed data. - * - *

            This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; - * its performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the - * method or the content - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, the code - * {@code negotiation.conclusion_mismatch} when the content conclusion is not {@code Reject}, the code - * {@code negotiation.content_invalid} when a required content field is blank or empty, or the code - * {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateNegotiationRejectPromptFromData( - @NonNull NegotiationEndingData data, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateRejectFromData(data, parsedTemplateUri); - } - - /** - * Generates an abort negotiation message from typed data. - * - *

            This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed - * template must be the common abort template and the content carries only the termination reason. - * - * @param data typed abort input carrying the negotiation context and the termination reason - * @param templateUri template URI string of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or does not address the common abort - * template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, the code - * {@code negotiation.content_invalid} when the termination reason is blank, or the code - * {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateNegotiationAbortPromptFromData( - @NonNull NegotiationAbortData data, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateAbortFromData(data, parsedTemplateUri); - } - - /** - * Generates a propose-phase negotiation message from free text. - * - *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI string such as {@code Negotiation-T/target-negotiation/propose/v1}; its performative - * segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails - * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required - * field, or {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the - * phase, or the code {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateNegotiationProposePromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, - @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateProposeFromText(text, context, parsedTemplateUri); - } - - /** - * Generates an accept-phase negotiation message from free text. - * - *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Accept}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; - * its performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails - * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required - * field, or {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} - * when the extracted conclusion is not {@code Accept}, or the code {@code input.text_too_long} when the text - * exceeds the configured maximum length - */ - public MetadataContent generateNegotiationAcceptPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, - @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateAcceptFromText(text, context, parsedTemplateUri); - } - - /** - * Generates a reject-phase negotiation message from free text. - * - *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Reject}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; - * its performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails - * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required - * field, or {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} - * when the extracted conclusion is not {@code Reject}, or the code {@code input.text_too_long} when the text - * exceeds the configured maximum length - */ - public MetadataContent generateNegotiationRejectPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, - @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateRejectFromText(text, context, parsedTemplateUri); - } - - /** - * Generates an abort negotiation message from free text. - * - *

            This variant runs one LLM content-extraction step constrained by the common abort template and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. - * - * @param text free-text input stating the termination reason - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI string of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or does not address the common abort - * template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails - * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses the - * termination reason, or {@code negotiation.invalid_input} when the text is blank, or the code - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateNegotiationAbortPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, - @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateAbortFromText(text, context, parsedTemplateUri); - } - - /** - * Lists every template available for the configured language across all A2A-T extensions. - * - *

            This query never throws: the extension directories are discovered from the bundled resource tree itself, so - * templates of extensions added later are included automatically. Templates that exist nowhere for the language are - * skipped and an empty list is returned when no template can be loaded at all. - * - * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when - * none can be loaded - */ - public List getPrompts() { - return templateQueryService.getPrompts(); - } - - /** - * Loads one template by its URI, regardless of the extension. - * - *

            This query never throws for a well-formed template URI: a missing template yields an empty result together - * with a warning log instead of a failure. - * - * @param templateUri template URI string such as {@code Negotiation-T/target-negotiation/propose/v1} or - * {@code Task-T/network-layer/ran-energy-saving/v1} - * @return the addressed template, or an empty optional when no template exists for it in the configured language - * @throws NullPointerException if the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed - */ - public Optional getPrompt(@NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return templateQueryService.getPrompt(parsedTemplateUri); - } - - /** - * Validates a propose-phase negotiation message and extracts its parameters. - * - *

            The pipeline checks the template URI before any LLM call, runs the deterministic rule gate on the negotiation - * context carried in the {@code negotiationContext} metadata entry, then performs one LLM semantic validation call - * that also extracts the parameters, and finally merges the parameters with the context parameters taking - * precedence. The semantic step is retried up to the configured attempt limit on the retryable LLM infrastructure - * failure code. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI string declaring the expected negotiation type and phase; its performative segment must - * be {@code propose} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or the template URI phase contradicts - * the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes - * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed - * {@code negotiation.*} code set (for example - * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateProposePromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.validateProposePromptAndDataFilling( - prompt, context, schema, parsedTemplateUri); - } - - /** - * Validates an accept-phase negotiation message and extracts its parameters. - * - *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to - * accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * accept-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI string declaring the expected negotiation type and phase; its performative segment must - * be {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or the template URI phase contradicts - * the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes - * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed - * {@code negotiation.*} code set (for example - * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateAcceptPromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); - } - - /** - * Validates a reject-phase negotiation message and extracts its parameters. - * - *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to - * reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * reject-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI string declaring the expected negotiation type and phase; its performative segment must - * be {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or the template URI phase contradicts - * the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes - * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed - * {@code negotiation.*} code set (for example - * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateRejectPromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); - } - - /** - * Validates an abort negotiation message and extracts its parameters. - * - *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to - * abort: the template URI must address the common abort template and the message must satisfy the abort-phase - * semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI string of the common abort template {@code Negotiation-T/common/abort/v1} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or does not address the common abort - * template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes - * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed - * {@code negotiation.*} code set (for example - * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateAbortPromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); - } - - private static TemplateUri parseTemplateUri(String templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return TemplateUri.parse(templateUri) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); - } -} +package net.openan.a2at.sdk.client; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import org.jspecify.annotations.NonNull; + +/** + * High-level client facade for prompt generation and negotiation APIs. The caller provides the {@code .env} file path + * explicitly, typically after copying the repository {@code env.example}. + * + * @since 2026-06 + */ +public final class A2ATClient { + + private final ClientPromptGenerationOrchestrator promptGenerationOrchestrator; + + private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; + + private final NegotiationContentService negotiationContentService; + + private final TemplateQueryService templateQueryService; + + /** + * Creates a client facade from one user-supplied {@code .env} path. + * + * @param envPath user-supplied {@code .env} file path + */ + public A2ATClient(Path envPath) { + Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); + A2ATConfig config = + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + DefaultA2ATClientBuilder builder = + DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); + this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); + this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); + this.negotiationContentService = + new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.templateQueryService = builder.buildTemplateQueryService(); + } + + /** + * Generates a processed task prompt from raw user input. Both text and structured input ({@code Map}) are unified + * through LLM extraction; there is no zero-LLM rule shortcut. + * + * @param userInput user-provided task description (text or structured input map); a String input longer than the + * configured maximum length ({@code A2AT_INPUT_TEXT_MAX_CHARS}) fails fast without any LLM call + * @return prompt generation result containing either rendered prompt text or failure details; a String input over + * the limit yields the failure code {@code input.text_too_long} + */ + public PromptGenerationResult generateTaskPrompt(Object userInput) { + return promptGenerationOrchestrator.generateTaskPrompt(userInput); + } + + /** + * Generates a task prompt with metadata from natural-language input using the template identified by the template + * URI, bypassing scenario recognition. + * + * @param text natural-language task input + * @param templateUri template URI string identifying the target template, such as + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull String templateUri) { + Objects.requireNonNull(text, "text"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateTaskPromptFromText(text, parsedTemplateUri); + } + + /** + * Generates a task prompt with metadata from structured input and a data schema using the template identified by + * the template URI, bypassing scenario recognition. + * + * @param data structured task input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI string identifying the target template, such as + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails + */ + public MetadataContent generateTaskPromptFromDataWithSchema( + @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, parsedTemplateUri); + } + + /** + * Generates an authorization prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + *

            Authorization-T slot schemas are bundled with a2a-t-resources, so the entry point works out of the box with + * classpath resource source. + * + * @param text natural-language authorization input + * @param templateUri template URI string identifying the target template, such as + * {@code Authorization-T/authorization-policy-management/v1} + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull String templateUri) { + Objects.requireNonNull(text, "text"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateAuthPromptFromText(text, parsedTemplateUri); + } + + /** + * Generates an authorization prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + *

            Authorization-T slot schemas are bundled with a2a-t-resources, so the entry point works out of the box with + * classpath resource source. + * + * @param data structured authorization input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI string identifying the target template, such as + * {@code Authorization-T/authorization-policy-management/v1} + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails + */ + public MetadataContent generateAuthPromptFromDataWithSchema( + @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, parsedTemplateUri); + } + + /** + * Generates a notification prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + * @param text natural-language notification input + * @param templateUri template URI string identifying the target template, such as + * {@code Notification-T/network-layer/subscribe-incident/v1} + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull String templateUri) { + Objects.requireNonNull(text, "text"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateNotificationPromptFromText(text, parsedTemplateUri); + } + + /** + * Generates a notification prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + * @param data structured notification input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI string identifying the target template, such as + * {@code Notification-T/network-layer/subscribe-incident/v1} + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails + */ + public MetadataContent generateNotificationPromptFromDataWithSchema( + @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, parsedTemplateUri); + } + + /** + * Starts a new negotiation payload for the requested negotiation type. + * + * @param type negotiation type to initiate + * @param contentText human-readable negotiation message + * @param facts structured facts attached to the payload + * @return transport payload representing the initial negotiation turn + */ + public Map startNegotiation(NegotiationType type, String contentText, Map facts) { + return negotiationOrchestrator.startNegotiation(type, contentText, facts); + } + + /** + * Processes a received negotiation message using its transport context payload. + * + * @param message received negotiation message + * @param context transport context payload associated with the message + * @return normalized payload describing the receive result + */ + public Map receiveNegotiation(String message, Map context) { + return negotiationOrchestrator.receiveNegotiation(message, context); + } + + /** + * Continues an existing negotiation with a locally stored context snapshot. + * + * @param context current negotiation context + * @param status next status to emit + * @param contentText continuation message content + * @return transport payload representing the next negotiation turn + */ + public Map continueNegotiation( + NegotiationContext context, NegotiationStatus status, String contentText) { + return negotiationOrchestrator.continueNegotiation(context, status, contentText); + } + + /** + * Generates a propose-phase negotiation message from typed data. + * + *

            This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the + * generator of the negotiation type addressed by the template URI and rendered from that template. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/propose/v1}; its + * performative segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content type + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, the code + * {@code negotiation.invalid_input} when the content combines a non-blank confirm request with conditional + * sections, or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateNegotiationProposePromptFromData( + @NonNull NegotiationProposeData data, @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateProposeFromData(data, parsedTemplateUri); + } + + /** + * Generates an accept-phase negotiation message from typed data. + * + *

            This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; + * its performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.conclusion_mismatch} when the content conclusion is not {@code Accept}, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, or the code + * {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateNegotiationAcceptPromptFromData( + @NonNull NegotiationEndingData data, @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAcceptFromData(data, parsedTemplateUri); + } + + /** + * Generates a reject-phase negotiation message from typed data. + * + *

            This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; + * its performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.conclusion_mismatch} when the content conclusion is not {@code Reject}, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, or the code + * {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateNegotiationRejectPromptFromData( + @NonNull NegotiationEndingData data, @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateRejectFromData(data, parsedTemplateUri); + } + + /** + * Generates an abort negotiation message from typed data. + * + *

            This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed + * template must be the common abort template and the content carries only the termination reason. + * + * @param data typed abort input carrying the negotiation context and the termination reason + * @param templateUri template URI string of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.content_invalid} when the termination reason is blank, or the code + * {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateNegotiationAbortPromptFromData( + @NonNull NegotiationAbortData data, @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAbortFromData(data, parsedTemplateUri); + } + + /** + * Generates a propose-phase negotiation message from free text. + * + *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI string such as {@code Negotiation-T/target-negotiation/propose/v1}; its performative + * segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the + * phase, or the code {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateNegotiationProposePromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateProposeFromText(text, context, parsedTemplateUri); + } + + /** + * Generates an accept-phase negotiation message from free text. + * + *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Accept}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; + * its performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} + * when the extracted conclusion is not {@code Accept}, or the code {@code input.text_too_long} when the text + * exceeds the configured maximum length + */ + public MetadataContent generateNegotiationAcceptPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAcceptFromText(text, context, parsedTemplateUri); + } + + /** + * Generates a reject-phase negotiation message from free text. + * + *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Reject}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; + * its performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} + * when the extracted conclusion is not {@code Reject}, or the code {@code input.text_too_long} when the text + * exceeds the configured maximum length + */ + public MetadataContent generateNegotiationRejectPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateRejectFromText(text, context, parsedTemplateUri); + } + + /** + * Generates an abort negotiation message from free text. + * + *

            This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. + * + * @param text free-text input stating the termination reason + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI string of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses the + * termination reason, or {@code negotiation.invalid_input} when the text is blank, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateNegotiationAbortPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAbortFromText(text, context, parsedTemplateUri); + } + + /** + * Lists every template available for the configured language across all A2A-T extensions. + * + *

            This query never throws: the extension directories are discovered from the bundled resource tree itself, so + * templates of extensions added later are included automatically. Templates that exist nowhere for the language are + * skipped and an empty list is returned when no template can be loaded at all. + * + * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when + * none can be loaded + */ + public List getPrompts() { + return templateQueryService.getPrompts(); + } + + /** + * Loads one template by its URI, regardless of the extension. + * + *

            This query never throws for a well-formed template URI: a missing template yields an empty result together + * with a warning log instead of a failure. + * + * @param templateUri template URI string such as {@code Negotiation-T/target-negotiation/propose/v1} or + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return the addressed template, or an empty optional when no template exists for it in the configured language + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + */ + public Optional getPrompt(@NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return templateQueryService.getPrompt(parsedTemplateUri); + } + + /** + * Validates a propose-phase negotiation message and extracts its parameters. + * + *

            The pipeline checks the template URI before any LLM call, runs the deterministic rule gate on the negotiation + * context carried in the {@code negotiationContext} metadata entry, then performs one LLM semantic validation call + * that also extracts the parameters, and finally merges the parameters with the context parameters taking + * precedence. The semantic step is retried up to the configured attempt limit on the retryable LLM infrastructure + * failure code. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI string declaring the expected negotiation type and phase; its performative segment must + * be {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the template URI phase contradicts + * the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateProposePromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateProposePromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); + } + + /** + * Validates an accept-phase negotiation message and extracts its parameters. + * + *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * accept-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI string declaring the expected negotiation type and phase; its performative segment must + * be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the template URI phase contradicts + * the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateAcceptPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); + } + + /** + * Validates a reject-phase negotiation message and extracts its parameters. + * + *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * reject-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI string declaring the expected negotiation type and phase; its performative segment must + * be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the template URI phase contradicts + * the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateRejectPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * abort: the template URI must address the common abort template and the message must satisfy the abort-phase + * semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI string of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateAbortPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); + } + + private static TemplateUri parseTemplateUri(String templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); + } +} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java index 72eccd55..0206bbfb 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -1,238 +1,238 @@ -package net.openan.a2at.sdk.client.prompt.assembly; - -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationRole; -import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; -import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; -import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; -import org.jspecify.annotations.Nullable; - -/** - * Default builder that assembles one high-level A2AT client runtime from unified config. - * - * @since 2026-06 - */ -public final class DefaultA2ATClientBuilder { - - private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; - - private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; - - private A2ATConfig config; - - private Path envPath; - - private LLMClient llmClient; - - /** - * Creates one new builder instance. - * - * @return empty client builder - */ - public static DefaultA2ATClientBuilder builder() { - return new DefaultA2ATClientBuilder(); - } - - /** - * Configures the unified SDK config consumed by the high-level client facade. - * - * @param config unified SDK config - * @return current builder - */ - public DefaultA2ATClientBuilder config(A2ATConfig config) { - this.config = config; - return this; - } - - /** - * Configures the `.env` file path used to assemble downstream facades. - * - * @param envPath caller-supplied `.env` path - * @return current builder - */ - public DefaultA2ATClientBuilder envPath(Path envPath) { - this.envPath = envPath; - return this; - } - - /** - * Injects an explicit LLM client that fully replaces the factory default. - * - *

            The injection point exists for testability and custom LLM assemblies: when set, every orchestrator built by - * this builder reuses the given client and no client is created from the `.env` LLM config. When unset, the builder - * keeps creating its default client and the behavior is unchanged. - * - * @param llmClient LLM client to inject; {@code null} keeps the factory default built from the `.env` LLM config - * @return current builder - * @since 2026-08 - */ - public DefaultA2ATClientBuilder llmClient(@Nullable LLMClient llmClient) { - this.llmClient = llmClient; - return this; - } - - /** - * Builds the default prompt-generation orchestrator from the configured unified SDK config. - * - * @return assembled prompt-generation orchestrator - */ - public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { - require(config, "Unified SDK config must be configured."); - require(envPath, "Unified SDK env path must be configured."); - requireSupportedConfig(); - - PromptResourceAccess resources = PromptResourceAccess.create(config.prompt()); - List scenarios = - resources.loadScenarios(config.prompt().language()); - - PromptSlotSchemaLoader slotSchemaLoader = resources.slotSchemaLoader(); - PromptTemplateTextLoader templateLoader = resources.templateLoader(); - String language = config.prompt().language(); - - LLMClient client = llmClient(); - - String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); - String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); - String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); - String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); - - ScenarioRecognizer scenarioRecognizer = - new SingleScenarioAwareRecognizer(scenarios, new LlmScenarioRecognizer(client)::recognize); - PromptSlotValueExtractor slotValueExtractor = new DefaultStructuredPromptSlotValueExtractor( - client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt); - - return ClientPromptGenerationOrchestratorBuilder.builder() - .llmClient(client) - .scenarios(scenarios) - .language(language) - .scenarioSystemPrompt(scenarioSystemPrompt) - .scenarioUserPrompt(scenarioUserPrompt) - .slotSystemPrompt(slotSystemPrompt) - .slotUserPrompt(slotUserPrompt) - .scenarioRecognizer(scenarioRecognizer) - .templateLoader(templateLoader) - .slotSchemaLoader(slotSchemaLoader) - .slotValueExtractor(slotValueExtractor) - .renderer(new TaskPromptRenderer()) - .maxTextChars(config.inputLimits().maxTextChars()) - .build(); - } - - /** - * Builds the default negotiation orchestrator from the configured unified SDK config. - * - * @return assembled negotiation orchestrator - */ - public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - return new RoleBoundNegotiationOrchestrator( - NegotiationHandler.builder() - .store(new InMemoryNegotiationStore()) - .build(), - NegotiationRole.CLIENT); - } - - /** - * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. - * - *

            The wiring is shared with the server side through - * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local - * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config. - * - * @return assembled negotiation generation orchestrator - */ - public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - require(envPath, "Unified SDK env path must be configured."); - return NegotiationContentService.buildOrchestrator(config, llmClient()); - } - - /** - * Builds the generic template query service from the configured unified SDK config. - * - *

            The service answers the extension-agnostic template queries: the message language and the local template root - * come from the prompt runtime config, exactly like the negotiation generation orchestrator wiring. - * - * @return assembled template query service - */ - public TemplateQueryService buildTemplateQueryService() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - return new TemplateQueryService( - config.prompt().language(), - config.prompt().sourceType(), - config.prompt().localRootDir()); - } - - private void requireSupportedConfig() { - if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) - && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals( - config.prompt().sourceType())) { - throw new UnsupportedOperationException( - "Unsupported prompt source type: " + config.prompt().sourceType()); - } - if (!LLMClientFactory.availableProviders().contains(config.llm().provider())) { - throw new UnsupportedOperationException( - "Unsupported LLM provider: " + config.llm().provider()); - } - if (!"in_memory".equals(config.negotiation().stateStoreType())) { - throw new UnsupportedOperationException("Unsupported negotiation state store type: " - + config.negotiation().stateStoreType()); - } - } - - private synchronized LLMClient llmClient() { - if (llmClient == null) { - llmClient = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); - } - return llmClient; - } - - private static void require(Object value, String message) { - if (value == null) { - throw new IllegalStateException(message); - } - } - - private static final class SingleScenarioAwareRecognizer implements ScenarioRecognizer { - - private final List scenarios; - - private final ScenarioRecognizer delegate; - - private SingleScenarioAwareRecognizer(List scenarios, ScenarioRecognizer delegate) { - this.scenarios = scenarios; - this.delegate = delegate; - } - - @Override - public ScenarioRecognitionResult recognize( - String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { - if (this.scenarios.size() == 1) { - return new ScenarioRecognitionResult(true, this.scenarios.get(0).scenarioCode(), null); - } - return delegate.recognize(normalizedInput, this.scenarios, systemPrompt, userPrompt); - } - } -} +package net.openan.a2at.sdk.client.prompt.assembly; + +import java.nio.file.Path; +import java.util.List; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationRole; +import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; +import org.jspecify.annotations.Nullable; + +/** + * Default builder that assembles one high-level A2AT client runtime from unified config. + * + * @since 2026-06 + */ +public final class DefaultA2ATClientBuilder { + + private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; + + private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; + + private A2ATConfig config; + + private Path envPath; + + private LLMClient llmClient; + + /** + * Creates one new builder instance. + * + * @return empty client builder + */ + public static DefaultA2ATClientBuilder builder() { + return new DefaultA2ATClientBuilder(); + } + + /** + * Configures the unified SDK config consumed by the high-level client facade. + * + * @param config unified SDK config + * @return current builder + */ + public DefaultA2ATClientBuilder config(A2ATConfig config) { + this.config = config; + return this; + } + + /** + * Configures the `.env` file path used to assemble downstream facades. + * + * @param envPath caller-supplied `.env` path + * @return current builder + */ + public DefaultA2ATClientBuilder envPath(Path envPath) { + this.envPath = envPath; + return this; + } + + /** + * Injects an explicit LLM client that fully replaces the factory default. + * + *

            The injection point exists for testability and custom LLM assemblies: when set, every orchestrator built by + * this builder reuses the given client and no client is created from the `.env` LLM config. When unset, the builder + * keeps creating its default client and the behavior is unchanged. + * + * @param llmClient LLM client to inject; {@code null} keeps the factory default built from the `.env` LLM config + * @return current builder + * @since 2026-08 + */ + public DefaultA2ATClientBuilder llmClient(@Nullable LLMClient llmClient) { + this.llmClient = llmClient; + return this; + } + + /** + * Builds the default prompt-generation orchestrator from the configured unified SDK config. + * + * @return assembled prompt-generation orchestrator + */ + public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { + require(config, "Unified SDK config must be configured."); + require(envPath, "Unified SDK env path must be configured."); + requireSupportedConfig(); + + PromptResourceAccess resources = PromptResourceAccess.create(config.prompt()); + List scenarios = + resources.loadScenarios(config.prompt().language()); + + PromptSlotSchemaLoader slotSchemaLoader = resources.slotSchemaLoader(); + PromptTemplateTextLoader templateLoader = resources.templateLoader(); + String language = config.prompt().language(); + + LLMClient client = llmClient(); + + String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); + String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); + String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); + String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); + + ScenarioRecognizer scenarioRecognizer = + new SingleScenarioAwareRecognizer(scenarios, new LlmScenarioRecognizer(client)::recognize); + PromptSlotValueExtractor slotValueExtractor = new DefaultStructuredPromptSlotValueExtractor( + client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt); + + return ClientPromptGenerationOrchestratorBuilder.builder() + .llmClient(client) + .scenarios(scenarios) + .language(language) + .scenarioSystemPrompt(scenarioSystemPrompt) + .scenarioUserPrompt(scenarioUserPrompt) + .slotSystemPrompt(slotSystemPrompt) + .slotUserPrompt(slotUserPrompt) + .scenarioRecognizer(scenarioRecognizer) + .templateLoader(templateLoader) + .slotSchemaLoader(slotSchemaLoader) + .slotValueExtractor(slotValueExtractor) + .renderer(new TaskPromptRenderer()) + .maxTextChars(config.inputLimits().maxTextChars()) + .build(); + } + + /** + * Builds the default negotiation orchestrator from the configured unified SDK config. + * + * @return assembled negotiation orchestrator + */ + public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + return new RoleBoundNegotiationOrchestrator( + NegotiationHandler.builder() + .store(new InMemoryNegotiationStore()) + .build(), + NegotiationRole.CLIENT); + } + + /** + * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. + * + *

            The wiring is shared with the server side through + * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local + * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config. + * + * @return assembled negotiation generation orchestrator + */ + public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + require(envPath, "Unified SDK env path must be configured."); + return NegotiationContentService.buildOrchestrator(config, llmClient()); + } + + /** + * Builds the generic template query service from the configured unified SDK config. + * + *

            The service answers the extension-agnostic template queries: the message language and the local template root + * come from the prompt runtime config, exactly like the negotiation generation orchestrator wiring. + * + * @return assembled template query service + */ + public TemplateQueryService buildTemplateQueryService() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + return new TemplateQueryService( + config.prompt().language(), + config.prompt().sourceType(), + config.prompt().localRootDir()); + } + + private void requireSupportedConfig() { + if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) + && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals( + config.prompt().sourceType())) { + throw new UnsupportedOperationException( + "Unsupported prompt source type: " + config.prompt().sourceType()); + } + if (!LLMClientFactory.availableProviders().contains(config.llm().provider())) { + throw new UnsupportedOperationException( + "Unsupported LLM provider: " + config.llm().provider()); + } + if (!"in_memory".equals(config.negotiation().stateStoreType())) { + throw new UnsupportedOperationException("Unsupported negotiation state store type: " + + config.negotiation().stateStoreType()); + } + } + + private synchronized LLMClient llmClient() { + if (llmClient == null) { + llmClient = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); + } + return llmClient; + } + + private static void require(Object value, String message) { + if (value == null) { + throw new IllegalStateException(message); + } + } + + private static final class SingleScenarioAwareRecognizer implements ScenarioRecognizer { + + private final List scenarios; + + private final ScenarioRecognizer delegate; + + private SingleScenarioAwareRecognizer(List scenarios, ScenarioRecognizer delegate) { + this.scenarios = scenarios; + this.delegate = delegate; + } + + @Override + public ScenarioRecognitionResult recognize( + String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { + if (this.scenarios.size() == 1) { + return new ScenarioRecognitionResult(true, this.scenarios.get(0).scenarioCode(), null); + } + return delegate.recognize(normalizedInput, this.scenarios, systemPrompt, userPrompt); + } + } +} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java index b73b028f..21e59af5 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java @@ -1,104 +1,104 @@ -package net.openan.a2at.sdk.client.prompt.orchestration; - -import java.util.Map; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; - -/** - * Internal prompt-generation orchestration contract used by the client facade. - * - * @since 2026-06 - */ -public interface ClientPromptGenerationOrchestrator { - - /** - * Generates a processed task prompt from user input. - * - * @param userInput raw or structured user input; a String input longer than the configured maximum length fails - * fast with the code {@code input.text_too_long} - * @return prompt-generation result - */ - PromptGenerationResult generateTaskPrompt(Object userInput); - - /** - * Generates a task prompt with metadata from natural-language input using the template identified by the template - * URI, bypassing scenario recognition. - * - * @param text natural-language task input - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input.text_too_long} - * when the text exceeds the configured maximum length - */ - MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri); - - /** - * Generates a task prompt with metadata from structured input and a data schema using the template identified by - * the template URI, bypassing scenario recognition. - * - * @param data structured task input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty - */ - MetadataContent generateTaskPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri); - - /** - * Generates an authorization prompt with metadata from natural-language input using the template identified by the - * template URI, bypassing scenario recognition. - * - * @param text natural-language authorization input - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input.text_too_long} - * when the text exceeds the configured maximum length - */ - MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri); - - /** - * Generates an authorization prompt with metadata from structured input and a data schema using the template - * identified by the template URI, bypassing scenario recognition. - * - * @param data structured authorization input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty - */ - MetadataContent generateAuthPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri); - - /** - * Generates a notification prompt with metadata from natural-language input using the template identified by the - * template URI, bypassing scenario recognition. - * - * @param text natural-language notification input - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the text or template URI is null - * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input.text_too_long} - * when the text exceeds the configured maximum length - */ - MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri); - - /** - * Generates a notification prompt with metadata from structured input and a data schema using the template - * identified by the template URI, bypassing scenario recognition. - * - * @param data structured notification input as a string-to-object map - * @param schema data schema map describing the meaning of each input field; must not be null or empty - * @param templateUri template URI identifying the target template - * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI - * @throws NullPointerException if the template URI, data or schema is null - * @throws IllegalArgumentException if the schema is empty - */ - MetadataContent generateNotificationPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri); -} +package net.openan.a2at.sdk.client.prompt.orchestration; + +import java.util.Map; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; + +/** + * Internal prompt-generation orchestration contract used by the client facade. + * + * @since 2026-06 + */ +public interface ClientPromptGenerationOrchestrator { + + /** + * Generates a processed task prompt from user input. + * + * @param userInput raw or structured user input; a String input longer than the configured maximum length fails + * fast with the code {@code input.text_too_long} + * @return prompt-generation result + */ + PromptGenerationResult generateTaskPrompt(Object userInput); + + /** + * Generates a task prompt with metadata from natural-language input using the template identified by the template + * URI, bypassing scenario recognition. + * + * @param text natural-language task input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input.text_too_long} + * when the text exceeds the configured maximum length + */ + MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri); + + /** + * Generates a task prompt with metadata from structured input and a data schema using the template identified by + * the template URI, bypassing scenario recognition. + * + * @param data structured task input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + */ + MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri); + + /** + * Generates an authorization prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + * @param text natural-language authorization input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input.text_too_long} + * when the text exceeds the configured maximum length + */ + MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri); + + /** + * Generates an authorization prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + * @param data structured authorization input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + */ + MetadataContent generateAuthPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri); + + /** + * Generates a notification prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + * @param text natural-language notification input + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or template URI is null + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code input.text_too_long} + * when the text exceeds the configured maximum length + */ + MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri); + + /** + * Generates a notification prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + * @param data structured notification input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI identifying the target template + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the schema is empty + */ + MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri); +} diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java index 5118c867..eb7fbc10 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -1,530 +1,530 @@ -package net.openan.a2at.sdk.client.prompt.orchestration; - -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.function.Function; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.client.model.PromptGenerationFailure; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.ExtensionUriConstants; -import net.openan.a2at.sdk.core.model.InputLimitConfig; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMConfigError; -import net.openan.a2at.sdk.llm.LLMError; -import net.openan.a2at.sdk.prompt.analysis.exception.ScenarioRecognitionException; -import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; -import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; -import net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Minimal runnable client prompt generation orchestrator. - * - * @since 2026-06 - */ -public final class DefaultClientPromptGenerationOrchestrator implements ClientPromptGenerationOrchestrator { - - private static final Logger LOGGER = LoggerFactory.getLogger(DefaultClientPromptGenerationOrchestrator.class); - - /** Old slot-extraction error code for a required slot that could not be extracted. */ - private static final String LEGACY_CODE_MISSING_INPUT = "missing_input"; - - /** Old slot-extraction error code for a value violating a closed constraint. */ - private static final String LEGACY_CODE_INVALID_VALUE = "invalid_value"; - - /** Step label used when the scenario-recognition LLM call fails. */ - private static final String STEP_SCENARIO_RECOGNITION = "scenario recognition"; - - /** Step label used when the slot-extraction LLM call fails. */ - private static final String STEP_SLOT_EXTRACTION = "slot extraction"; - - private static final List RESPONSE_CONTRACT_MARKERS = List.of( - "returned invalid json", "returned empty content", "must return a json object", "response did not include"); - - private final ScenarioRecognizer scenarioRecognizer; - - private final List scenarios; - - private final String language; - - private final String systemPrompt; - - private final String userPrompt; - - private final PromptTemplateTextLoader templateLoader; - - private final PromptSlotValueExtractor slotValueExtractor; - - private final PromptSlotSchemaLoader slotSchemaLoader; - - private final TaskPromptRenderer renderer; - - private final int maxTextChars; - - /** - * Creates a client prompt-generation orchestrator with explicit collaborators. - * - * @param scenarioRecognizer scenario recognizer - * @param scenarios supported scenario definitions - * @param language locale identifier for resource lookup - * @param systemPrompt system prompt for scenario recognition - * @param userPrompt user prompt for scenario recognition - * @param templateLoader template loader - * @param slotValueExtractor slot value extractor - * @param renderer task prompt renderer - * @param slotSchemaLoader slot schema loader - */ - public DefaultClientPromptGenerationOrchestrator( - ScenarioRecognizer scenarioRecognizer, - List scenarios, - String language, - String systemPrompt, - String userPrompt, - PromptTemplateTextLoader templateLoader, - PromptSlotValueExtractor slotValueExtractor, - TaskPromptRenderer renderer, - PromptSlotSchemaLoader slotSchemaLoader) { - this( - scenarioRecognizer, - scenarios, - language, - systemPrompt, - userPrompt, - templateLoader, - slotValueExtractor, - renderer, - slotSchemaLoader, - InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); - } - - /** - * Creates a client prompt-generation orchestrator with explicit collaborators and one free-text input length limit. - * - * @param scenarioRecognizer scenario recognizer - * @param scenarios supported scenario definitions - * @param language locale identifier for resource lookup - * @param systemPrompt system prompt for scenario recognition - * @param userPrompt user prompt for scenario recognition - * @param templateLoader template loader - * @param slotValueExtractor slot value extractor - * @param renderer task prompt renderer - * @param slotSchemaLoader slot schema loader - * @param maxTextChars maximum accepted length in characters for free-text inputs - */ - public DefaultClientPromptGenerationOrchestrator( - ScenarioRecognizer scenarioRecognizer, - List scenarios, - String language, - String systemPrompt, - String userPrompt, - PromptTemplateTextLoader templateLoader, - PromptSlotValueExtractor slotValueExtractor, - TaskPromptRenderer renderer, - PromptSlotSchemaLoader slotSchemaLoader, - int maxTextChars) { - this.scenarioRecognizer = scenarioRecognizer; - this.scenarios = scenarios; - this.language = language; - this.systemPrompt = systemPrompt; - this.userPrompt = userPrompt; - this.templateLoader = templateLoader; - this.slotValueExtractor = slotValueExtractor; - this.renderer = renderer; - this.slotSchemaLoader = slotSchemaLoader; - this.maxTextChars = maxTextChars; - } - - @Override - public PromptGenerationResult generateTaskPrompt(Object userInput) { - if (userInput instanceof String text && InputLimitConfig.isTooLong(text, maxTextChars)) { - return failure( - ErrorCatalog.INPUT_TEXT_TOO_LONG, - Map.of("actual_length", String.valueOf(text.length()), "max_chars", String.valueOf(maxTextChars)), - "input"); - } - String normalizedInput = String.valueOf(userInput); - - final ScenarioRecognitionResult recognition; - try { - recognition = scenarioRecognizer.recognize(normalizedInput, scenarios, systemPrompt, userPrompt); - } catch (ResourceNotFoundException error) { - return failure( - ErrorCatalog.TEMPLATE_LOAD_FAILED, Map.of("resource_path", error.resourcePath()), "generation"); - } catch (ScenarioRecognitionException error) { - return failure(ErrorCatalog.SCENARIO_NOT_MATCHED, Map.of("reason", error.getMessage()), "scenario"); - } catch (LLMError error) { - return PromptGenerationResult.failure(llmFailure(error, STEP_SCENARIO_RECOGNITION)); - } - if (!recognition.matched() - || recognition.scenarioCode() == null - || recognition.scenarioCode().isBlank()) { - return failure( - ErrorCatalog.SCENARIO_NOT_MATCHED, - Map.of( - "reason", - recognition.errorMessage() == null - ? "Scenario recognition failed." - : recognition.errorMessage()), - "scenario"); - } - - final String templateText; - try { - templateText = templateLoader.loadTemplate(recognition.scenarioCode(), language); - } catch (ResourceNotFoundException error) { - return failure( - ErrorCatalog.TEMPLATE_NOT_FOUND, - Map.of("template_uri", recognition.scenarioCode(), "language", language), - "generation"); - } catch (A2ATError error) { - return failure( - ErrorCatalog.TEMPLATE_LOAD_FAILED, - Map.of("resource_path", recognition.scenarioCode()), - "generation"); - } - - try { - StructuredSlotExtractionResult extractionResult = - slotValueExtractor.extractSlots(userInput, recognition.scenarioCode(), language); - PromptGenerationFailure slotFailure = slotExtractionFailure(extractionResult.slotErrors()); - if (slotFailure != null) { - return PromptGenerationResult.failure(slotFailure); - } - Map slots = extractionResult.slots(); - String renderedPrompt = renderer.render(templateText, slots); - return PromptGenerationResult.success(renderedPrompt); - } catch (ResourceNotFoundException error) { - return failure( - ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, - Map.of("template_uri", recognition.scenarioCode(), "language", language), - "generation"); - } catch (LLMError error) { - return PromptGenerationResult.failure(llmFailure(error, STEP_SLOT_EXTRACTION)); - } catch (TaskPromptRenderException error) { - return failure( - ErrorCatalog.TEMPLATE_RENDER_FAILED, - Map.of("template_uri", recognition.scenarioCode(), "reason", error.getMessage()), - "generation"); - } catch (A2ATError error) { - return failure(ErrorCatalog.LLM_INVOCATION_FAILED, Map.of("reason", error.getMessage()), "generation"); - } - } - - @Override - public MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) { - return generateFromTemplateUriWithMetadata(text, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); - } - - @Override - public MetadataContent generateTaskPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) { - return generateFromDataWithSchema(data, schema, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); - } - - @Override - public MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri) { - return generateFromTemplateUriWithMetadata( - text, templateUri, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); - } - - @Override - public MetadataContent generateAuthPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) { - return generateFromDataWithSchema( - data, schema, templateUri, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); - } - - @Override - public MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri) { - return generateFromTemplateUriWithMetadata( - text, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); - } - - @Override - public MetadataContent generateNotificationPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) { - return generateFromDataWithSchema( - data, schema, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); - } - - private MetadataContent generateFromTemplateUriWithMetadata( - String userInput, TemplateUri templateUri, String extensionUri) { - Objects.requireNonNull(userInput, "userInput"); - if (InputLimitConfig.isTooLong(userInput, maxTextChars)) { - throw new PromptGenerationException( - ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), - render( - ErrorCatalog.INPUT_TEXT_TOO_LONG, - Map.of( - "actual_length", - String.valueOf(userInput.length()), - "max_chars", - String.valueOf(maxTextChars)))); - } - return generateWithMetadata( - templateUri, - extensionUri, - templateIdentifier -> slotValueExtractor.extractSlots(userInput, templateIdentifier, language)); - } - - private MetadataContent generateFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri, String extensionUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - if (schema.isEmpty()) { - throw new IllegalArgumentException( - "Data schema must not be empty; it describes the meaning of each input field."); - } - return generateWithMetadata( - templateUri, - extensionUri, - templateIdentifier -> slotValueExtractor.extractSlots(data, templateIdentifier, language, schema)); - } - - private MetadataContent generateWithMetadata( - TemplateUri templateUri, - String extensionUri, - Function slotExtractor) { - Objects.requireNonNull(templateUri, "templateUri"); - String templateIdentifier = templateUri.uri(); - final String templateText; - try { - templateText = templateLoader.loadTemplate(templateIdentifier, language); - } catch (ResourceNotFoundException e) { - throw new PromptGenerationException( - ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), - render( - ErrorCatalog.TEMPLATE_NOT_FOUND, - Map.of("template_uri", templateIdentifier, "language", language)), - e); - } catch (A2ATError e) { - throw new PromptGenerationException( - ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode(), - render(ErrorCatalog.TEMPLATE_LOAD_FAILED, Map.of("resource_path", templateIdentifier)), - e); - } - final StructuredSlotExtractionResult extractionResult; - try { - extractionResult = slotExtractor.apply(templateIdentifier); - } catch (ResourceNotFoundException e) { - throw new PromptGenerationException( - ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), - render( - ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, - Map.of("template_uri", templateIdentifier, "language", language)), - e); - } catch (LLMConfigError e) { - throw new PromptGenerationException( - ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), render(ErrorCatalog.LLM_NOT_CONFIGURED, Map.of()), e); - } catch (LLMError e) { - throw llmException(e, STEP_SLOT_EXTRACTION); - } catch (A2ATError e) { - throw new PromptGenerationException( - ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), - render(ErrorCatalog.LLM_INVOCATION_FAILED, Map.of("reason", e.getMessage())), - e); - } - List extractionErrors = mapSlotErrors(extractionResult.slotErrors()); - if (!extractionErrors.isEmpty()) { - throw new PromptGenerationException( - extractionErrors.get(0).code(), joinMessages(extractionErrors), extractionErrors); - } - Map slots = extractionResult.slots(); - validateRequiredSlots(slots, templateIdentifier); - final String renderedPrompt; - try { - renderedPrompt = renderer.render(templateText, slots); - } catch (TaskPromptRenderException e) { - throw new PromptGenerationException( - ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode(), - render( - ErrorCatalog.TEMPLATE_RENDER_FAILED, - Map.of("template_uri", templateIdentifier, "reason", e.getMessage())), - e); - } - return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); - } - - private void validateRequiredSlots(Map slots, String templateIdentifier) { - final PromptSlotSchema schema; - try { - schema = slotSchemaLoader.loadSlotSchema(templateIdentifier, language); - } catch (A2ATError e) { - throw new PromptGenerationException( - ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), - render( - ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, - Map.of("template_uri", templateIdentifier, "language", language)), - e); - } - if (schema == null) { - throw new PromptGenerationException( - ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), - render( - ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, - Map.of("template_uri", templateIdentifier, "language", language))); - } - List defs = schema.slotDefinitions(); - if (defs == null) { - return; - } - Map effectiveSlots = slots; - if (effectiveSlots == null) { - effectiveSlots = Map.of(); - } - List failed = new ArrayList<>(); - for (PromptSlotDefinition def : defs) { - if (def == null) { - continue; - } - if (def.required()) { - String name = def.name(); - if (name == null) { - continue; - } - String value = effectiveSlots.get(name); - if (value == null || value.trim().isEmpty()) { - failed.add(notProvidedError(name, slotLabel(def))); - } - } - } - if (!failed.isEmpty()) { - throw new PromptGenerationException(failed.get(0).code(), joinMessages(failed), failed); - } - } - - /** - * Maps the extraction-time slot errors reported by the slot-extraction step to catalog errors. - * - *

            Unknown codes returned by the LLM step are mapped to the {@code slot.rule_violation} fallback and logged. - * - * @param slotErrors extraction-time slot errors, may be null - * @return mapped slot validation errors, possibly empty, never null - */ - private List mapSlotErrors(@Nullable List slotErrors) { - if (slotErrors == null || slotErrors.isEmpty()) { - return List.of(); - } - List mapped = new ArrayList<>(); - for (StructuredSlotValidationError error : slotErrors) { - if (error == null) { - continue; - } - mapped.add(mapSlotError(error)); - } - return mapped; - } - - private SlotValidationError mapSlotError(StructuredSlotValidationError error) { - String label = error.slotName() == null ? "" : error.slotName(); - ErrorCatalog entry; - if (ErrorCatalog.SLOT_NOT_PROVIDED.getCode().equals(error.code()) - || LEGACY_CODE_MISSING_INPUT.equals(error.code())) { - entry = ErrorCatalog.SLOT_NOT_PROVIDED; - } else if (ErrorCatalog.SLOT_CONSTRAINT_VIOLATED.getCode().equals(error.code()) - || LEGACY_CODE_INVALID_VALUE.equals(error.code())) { - entry = ErrorCatalog.SLOT_CONSTRAINT_VIOLATED; - } else { - LOGGER.warn( - "Unknown slot-extraction error code '{}' mapped to '{}'.", - error.code(), - ErrorCatalog.SLOT_RULE_VIOLATION.getCode()); - entry = ErrorCatalog.SLOT_RULE_VIOLATION; - } - Map facts = Map.of("slot_label", label); - return new SlotValidationError(label, entry.getCode(), render(entry, facts), facts); - } - - private SlotValidationError notProvidedError(String slotName, String slotLabel) { - Map facts = Map.of("slot_label", slotLabel); - return new SlotValidationError( - slotName, - ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), - render(ErrorCatalog.SLOT_NOT_PROVIDED, facts), - facts); - } - - private String slotLabel(PromptSlotDefinition definition) { - if (definition.description() != null && !definition.description().isBlank()) { - return definition.description(); - } - return definition.name() == null ? "" : definition.name(); - } - - private @Nullable PromptGenerationFailure slotExtractionFailure( - @Nullable List slotErrors) { - List mapped = mapSlotErrors(slotErrors); - if (mapped.isEmpty()) { - return null; - } - return new PromptGenerationFailure(mapped.get(0).code(), joinMessages(mapped), "generation"); - } - - private PromptGenerationFailure llmFailure(LLMError error, String step) { - if (error instanceof LLMConfigError) { - return new PromptGenerationFailure( - ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), - render(ErrorCatalog.LLM_NOT_CONFIGURED, Map.of()), - "generation"); - } - if (isResponseContractViolation(error)) { - return new PromptGenerationFailure( - ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), - render(ErrorCatalog.LLM_RESPONSE_INVALID, Map.of("step", step)), - "generation"); - } - return new PromptGenerationFailure( - ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), - render(ErrorCatalog.LLM_INVOCATION_FAILED, Map.of("reason", error.getMessage())), - "generation"); - } - - private PromptGenerationException llmException(LLMError error, String step) { - PromptGenerationFailure failure = llmFailure(error, step); - return new PromptGenerationException(failure.code(), failure.message(), error); - } - - private boolean isResponseContractViolation(LLMError error) { - String message = error.getMessage() == null ? "" : error.getMessage().toLowerCase(Locale.ROOT); - for (String marker : RESPONSE_CONTRACT_MARKERS) { - if (message.contains(marker)) { - return true; - } - } - return false; - } - - private String joinMessages(List errors) { - return errors.stream().map(SlotValidationError::message).collect(Collectors.joining("; ")); - } - - private String render(ErrorCatalog entry, Map facts) { - return ErrorMessages.render(entry, language, facts); - } - - private PromptGenerationResult failure(ErrorCatalog entry, Map facts, String stage) { - return PromptGenerationResult.failure( - new PromptGenerationFailure(entry.getCode(), render(entry, facts), stage)); - } -} +package net.openan.a2at.sdk.client.prompt.orchestration; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.model.PromptGenerationFailure; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMConfigError; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.prompt.analysis.exception.ScenarioRecognitionException; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; +import net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Minimal runnable client prompt generation orchestrator. + * + * @since 2026-06 + */ +public final class DefaultClientPromptGenerationOrchestrator implements ClientPromptGenerationOrchestrator { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultClientPromptGenerationOrchestrator.class); + + /** Old slot-extraction error code for a required slot that could not be extracted. */ + private static final String LEGACY_CODE_MISSING_INPUT = "missing_input"; + + /** Old slot-extraction error code for a value violating a closed constraint. */ + private static final String LEGACY_CODE_INVALID_VALUE = "invalid_value"; + + /** Step label used when the scenario-recognition LLM call fails. */ + private static final String STEP_SCENARIO_RECOGNITION = "scenario recognition"; + + /** Step label used when the slot-extraction LLM call fails. */ + private static final String STEP_SLOT_EXTRACTION = "slot extraction"; + + private static final List RESPONSE_CONTRACT_MARKERS = List.of( + "returned invalid json", "returned empty content", "must return a json object", "response did not include"); + + private final ScenarioRecognizer scenarioRecognizer; + + private final List scenarios; + + private final String language; + + private final String systemPrompt; + + private final String userPrompt; + + private final PromptTemplateTextLoader templateLoader; + + private final PromptSlotValueExtractor slotValueExtractor; + + private final PromptSlotSchemaLoader slotSchemaLoader; + + private final TaskPromptRenderer renderer; + + private final int maxTextChars; + + /** + * Creates a client prompt-generation orchestrator with explicit collaborators. + * + * @param scenarioRecognizer scenario recognizer + * @param scenarios supported scenario definitions + * @param language locale identifier for resource lookup + * @param systemPrompt system prompt for scenario recognition + * @param userPrompt user prompt for scenario recognition + * @param templateLoader template loader + * @param slotValueExtractor slot value extractor + * @param renderer task prompt renderer + * @param slotSchemaLoader slot schema loader + */ + public DefaultClientPromptGenerationOrchestrator( + ScenarioRecognizer scenarioRecognizer, + List scenarios, + String language, + String systemPrompt, + String userPrompt, + PromptTemplateTextLoader templateLoader, + PromptSlotValueExtractor slotValueExtractor, + TaskPromptRenderer renderer, + PromptSlotSchemaLoader slotSchemaLoader) { + this( + scenarioRecognizer, + scenarios, + language, + systemPrompt, + userPrompt, + templateLoader, + slotValueExtractor, + renderer, + slotSchemaLoader, + InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); + } + + /** + * Creates a client prompt-generation orchestrator with explicit collaborators and one free-text input length limit. + * + * @param scenarioRecognizer scenario recognizer + * @param scenarios supported scenario definitions + * @param language locale identifier for resource lookup + * @param systemPrompt system prompt for scenario recognition + * @param userPrompt user prompt for scenario recognition + * @param templateLoader template loader + * @param slotValueExtractor slot value extractor + * @param renderer task prompt renderer + * @param slotSchemaLoader slot schema loader + * @param maxTextChars maximum accepted length in characters for free-text inputs + */ + public DefaultClientPromptGenerationOrchestrator( + ScenarioRecognizer scenarioRecognizer, + List scenarios, + String language, + String systemPrompt, + String userPrompt, + PromptTemplateTextLoader templateLoader, + PromptSlotValueExtractor slotValueExtractor, + TaskPromptRenderer renderer, + PromptSlotSchemaLoader slotSchemaLoader, + int maxTextChars) { + this.scenarioRecognizer = scenarioRecognizer; + this.scenarios = scenarios; + this.language = language; + this.systemPrompt = systemPrompt; + this.userPrompt = userPrompt; + this.templateLoader = templateLoader; + this.slotValueExtractor = slotValueExtractor; + this.renderer = renderer; + this.slotSchemaLoader = slotSchemaLoader; + this.maxTextChars = maxTextChars; + } + + @Override + public PromptGenerationResult generateTaskPrompt(Object userInput) { + if (userInput instanceof String text && InputLimitConfig.isTooLong(text, maxTextChars)) { + return failure( + ErrorCatalog.INPUT_TEXT_TOO_LONG, + Map.of("actual_length", String.valueOf(text.length()), "max_chars", String.valueOf(maxTextChars)), + "input"); + } + String normalizedInput = String.valueOf(userInput); + + final ScenarioRecognitionResult recognition; + try { + recognition = scenarioRecognizer.recognize(normalizedInput, scenarios, systemPrompt, userPrompt); + } catch (ResourceNotFoundException error) { + return failure( + ErrorCatalog.TEMPLATE_LOAD_FAILED, Map.of("resource_path", error.resourcePath()), "generation"); + } catch (ScenarioRecognitionException error) { + return failure(ErrorCatalog.SCENARIO_NOT_MATCHED, Map.of("reason", error.getMessage()), "scenario"); + } catch (LLMError error) { + return PromptGenerationResult.failure(llmFailure(error, STEP_SCENARIO_RECOGNITION)); + } + if (!recognition.matched() + || recognition.scenarioCode() == null + || recognition.scenarioCode().isBlank()) { + return failure( + ErrorCatalog.SCENARIO_NOT_MATCHED, + Map.of( + "reason", + recognition.errorMessage() == null + ? "Scenario recognition failed." + : recognition.errorMessage()), + "scenario"); + } + + final String templateText; + try { + templateText = templateLoader.loadTemplate(recognition.scenarioCode(), language); + } catch (ResourceNotFoundException error) { + return failure( + ErrorCatalog.TEMPLATE_NOT_FOUND, + Map.of("template_uri", recognition.scenarioCode(), "language", language), + "generation"); + } catch (A2ATError error) { + return failure( + ErrorCatalog.TEMPLATE_LOAD_FAILED, + Map.of("resource_path", recognition.scenarioCode()), + "generation"); + } + + try { + StructuredSlotExtractionResult extractionResult = + slotValueExtractor.extractSlots(userInput, recognition.scenarioCode(), language); + PromptGenerationFailure slotFailure = slotExtractionFailure(extractionResult.slotErrors()); + if (slotFailure != null) { + return PromptGenerationResult.failure(slotFailure); + } + Map slots = extractionResult.slots(); + String renderedPrompt = renderer.render(templateText, slots); + return PromptGenerationResult.success(renderedPrompt); + } catch (ResourceNotFoundException error) { + return failure( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, + Map.of("template_uri", recognition.scenarioCode(), "language", language), + "generation"); + } catch (LLMError error) { + return PromptGenerationResult.failure(llmFailure(error, STEP_SLOT_EXTRACTION)); + } catch (TaskPromptRenderException error) { + return failure( + ErrorCatalog.TEMPLATE_RENDER_FAILED, + Map.of("template_uri", recognition.scenarioCode(), "reason", error.getMessage()), + "generation"); + } catch (A2ATError error) { + return failure(ErrorCatalog.LLM_INVOCATION_FAILED, Map.of("reason", error.getMessage()), "generation"); + } + } + + @Override + public MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) { + return generateFromTemplateUriWithMetadata(text, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) { + return generateFromDataWithSchema(data, schema, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri) { + return generateFromTemplateUriWithMetadata( + text, templateUri, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateAuthPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) { + return generateFromDataWithSchema( + data, schema, templateUri, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri) { + return generateFromTemplateUriWithMetadata( + text, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) { + return generateFromDataWithSchema( + data, schema, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); + } + + private MetadataContent generateFromTemplateUriWithMetadata( + String userInput, TemplateUri templateUri, String extensionUri) { + Objects.requireNonNull(userInput, "userInput"); + if (InputLimitConfig.isTooLong(userInput, maxTextChars)) { + throw new PromptGenerationException( + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), + render( + ErrorCatalog.INPUT_TEXT_TOO_LONG, + Map.of( + "actual_length", + String.valueOf(userInput.length()), + "max_chars", + String.valueOf(maxTextChars)))); + } + return generateWithMetadata( + templateUri, + extensionUri, + templateIdentifier -> slotValueExtractor.extractSlots(userInput, templateIdentifier, language)); + } + + private MetadataContent generateFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri, String extensionUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + if (schema.isEmpty()) { + throw new IllegalArgumentException( + "Data schema must not be empty; it describes the meaning of each input field."); + } + return generateWithMetadata( + templateUri, + extensionUri, + templateIdentifier -> slotValueExtractor.extractSlots(data, templateIdentifier, language, schema)); + } + + private MetadataContent generateWithMetadata( + TemplateUri templateUri, + String extensionUri, + Function slotExtractor) { + Objects.requireNonNull(templateUri, "templateUri"); + String templateIdentifier = templateUri.uri(); + final String templateText; + try { + templateText = templateLoader.loadTemplate(templateIdentifier, language); + } catch (ResourceNotFoundException e) { + throw new PromptGenerationException( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + render( + ErrorCatalog.TEMPLATE_NOT_FOUND, + Map.of("template_uri", templateIdentifier, "language", language)), + e); + } catch (A2ATError e) { + throw new PromptGenerationException( + ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode(), + render(ErrorCatalog.TEMPLATE_LOAD_FAILED, Map.of("resource_path", templateIdentifier)), + e); + } + final StructuredSlotExtractionResult extractionResult; + try { + extractionResult = slotExtractor.apply(templateIdentifier); + } catch (ResourceNotFoundException e) { + throw new PromptGenerationException( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), + render( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, + Map.of("template_uri", templateIdentifier, "language", language)), + e); + } catch (LLMConfigError e) { + throw new PromptGenerationException( + ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), render(ErrorCatalog.LLM_NOT_CONFIGURED, Map.of()), e); + } catch (LLMError e) { + throw llmException(e, STEP_SLOT_EXTRACTION); + } catch (A2ATError e) { + throw new PromptGenerationException( + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + render(ErrorCatalog.LLM_INVOCATION_FAILED, Map.of("reason", e.getMessage())), + e); + } + List extractionErrors = mapSlotErrors(extractionResult.slotErrors()); + if (!extractionErrors.isEmpty()) { + throw new PromptGenerationException( + extractionErrors.get(0).code(), joinMessages(extractionErrors), extractionErrors); + } + Map slots = extractionResult.slots(); + validateRequiredSlots(slots, templateIdentifier); + final String renderedPrompt; + try { + renderedPrompt = renderer.render(templateText, slots); + } catch (TaskPromptRenderException e) { + throw new PromptGenerationException( + ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode(), + render( + ErrorCatalog.TEMPLATE_RENDER_FAILED, + Map.of("template_uri", templateIdentifier, "reason", e.getMessage())), + e); + } + return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); + } + + private void validateRequiredSlots(Map slots, String templateIdentifier) { + final PromptSlotSchema schema; + try { + schema = slotSchemaLoader.loadSlotSchema(templateIdentifier, language); + } catch (A2ATError e) { + throw new PromptGenerationException( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), + render( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, + Map.of("template_uri", templateIdentifier, "language", language)), + e); + } + if (schema == null) { + throw new PromptGenerationException( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), + render( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, + Map.of("template_uri", templateIdentifier, "language", language))); + } + List defs = schema.slotDefinitions(); + if (defs == null) { + return; + } + Map effectiveSlots = slots; + if (effectiveSlots == null) { + effectiveSlots = Map.of(); + } + List failed = new ArrayList<>(); + for (PromptSlotDefinition def : defs) { + if (def == null) { + continue; + } + if (def.required()) { + String name = def.name(); + if (name == null) { + continue; + } + String value = effectiveSlots.get(name); + if (value == null || value.trim().isEmpty()) { + failed.add(notProvidedError(name, slotLabel(def))); + } + } + } + if (!failed.isEmpty()) { + throw new PromptGenerationException(failed.get(0).code(), joinMessages(failed), failed); + } + } + + /** + * Maps the extraction-time slot errors reported by the slot-extraction step to catalog errors. + * + *

            Unknown codes returned by the LLM step are mapped to the {@code slot.rule_violation} fallback and logged. + * + * @param slotErrors extraction-time slot errors, may be null + * @return mapped slot validation errors, possibly empty, never null + */ + private List mapSlotErrors(@Nullable List slotErrors) { + if (slotErrors == null || slotErrors.isEmpty()) { + return List.of(); + } + List mapped = new ArrayList<>(); + for (StructuredSlotValidationError error : slotErrors) { + if (error == null) { + continue; + } + mapped.add(mapSlotError(error)); + } + return mapped; + } + + private SlotValidationError mapSlotError(StructuredSlotValidationError error) { + String label = error.slotName() == null ? "" : error.slotName(); + ErrorCatalog entry; + if (ErrorCatalog.SLOT_NOT_PROVIDED.getCode().equals(error.code()) + || LEGACY_CODE_MISSING_INPUT.equals(error.code())) { + entry = ErrorCatalog.SLOT_NOT_PROVIDED; + } else if (ErrorCatalog.SLOT_CONSTRAINT_VIOLATED.getCode().equals(error.code()) + || LEGACY_CODE_INVALID_VALUE.equals(error.code())) { + entry = ErrorCatalog.SLOT_CONSTRAINT_VIOLATED; + } else { + LOGGER.warn( + "Unknown slot-extraction error code '{}' mapped to '{}'.", + error.code(), + ErrorCatalog.SLOT_RULE_VIOLATION.getCode()); + entry = ErrorCatalog.SLOT_RULE_VIOLATION; + } + Map facts = Map.of("slot_label", label); + return new SlotValidationError(label, entry.getCode(), render(entry, facts), facts); + } + + private SlotValidationError notProvidedError(String slotName, String slotLabel) { + Map facts = Map.of("slot_label", slotLabel); + return new SlotValidationError( + slotName, + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + render(ErrorCatalog.SLOT_NOT_PROVIDED, facts), + facts); + } + + private String slotLabel(PromptSlotDefinition definition) { + if (definition.description() != null && !definition.description().isBlank()) { + return definition.description(); + } + return definition.name() == null ? "" : definition.name(); + } + + private @Nullable PromptGenerationFailure slotExtractionFailure( + @Nullable List slotErrors) { + List mapped = mapSlotErrors(slotErrors); + if (mapped.isEmpty()) { + return null; + } + return new PromptGenerationFailure(mapped.get(0).code(), joinMessages(mapped), "generation"); + } + + private PromptGenerationFailure llmFailure(LLMError error, String step) { + if (error instanceof LLMConfigError) { + return new PromptGenerationFailure( + ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), + render(ErrorCatalog.LLM_NOT_CONFIGURED, Map.of()), + "generation"); + } + if (isResponseContractViolation(error)) { + return new PromptGenerationFailure( + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + render(ErrorCatalog.LLM_RESPONSE_INVALID, Map.of("step", step)), + "generation"); + } + return new PromptGenerationFailure( + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + render(ErrorCatalog.LLM_INVOCATION_FAILED, Map.of("reason", error.getMessage())), + "generation"); + } + + private PromptGenerationException llmException(LLMError error, String step) { + PromptGenerationFailure failure = llmFailure(error, step); + return new PromptGenerationException(failure.code(), failure.message(), error); + } + + private boolean isResponseContractViolation(LLMError error) { + String message = error.getMessage() == null ? "" : error.getMessage().toLowerCase(Locale.ROOT); + for (String marker : RESPONSE_CONTRACT_MARKERS) { + if (message.contains(marker)) { + return true; + } + } + return false; + } + + private String joinMessages(List errors) { + return errors.stream().map(SlotValidationError::message).collect(Collectors.joining("; ")); + } + + private String render(ErrorCatalog entry, Map facts) { + return ErrorMessages.render(entry, language, facts); + } + + private PromptGenerationResult failure(ErrorCatalog entry, Map facts, String stage) { + return PromptGenerationResult.failure( + new PromptGenerationFailure(entry.getCode(), render(entry, facts), stage)); + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java index d404229a..4a0d8a1c 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -1,219 +1,219 @@ -package net.openan.a2at.sdk.client.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class A2ATClientNegotiationApiTest { - - private static final String TEST_MOCK_PROVIDER = "test-negotiation-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); - } - } - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); - - @Test - void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - MetadataContent result = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); - Map metadata = result.buildMetadataContent(); - assertEquals(3, metadata.size()); - assertEquals(result.promptText(), metadata.get(result.extensionUri())); - assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); - } - - @Test - void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("en-US")); - - MetadataContent result = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE_URI); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); - assertTrue(result.promptText().contains("Required Information Items")); - } - - @Test - void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { - assertEquals(7, negotiationPrompts(new A2ATClient(writeEnv("zh-CN"))).size()); - assertEquals(7, negotiationPrompts(new A2ATClient(writeEnv("en-US"))).size()); - } - - @Test - void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - MetadataContent result = client.generateNegotiationAbortPromptFromData( - new NegotiationAbortData( - new NegotiationContext(UUID, 5, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - StandardTemplates.NEGOTIATION_ABORT_URI); - - assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); - assertTrue(result.promptText().contains("## 协商结果\nAbort")); - assertTrue(result.promptText().contains("## 协商终止原因")); - assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); - assertEquals(5, result.negotiationContext().round()); - } - - @Test - void generatesAbortPromptFromDataWithBuiltinEnglishTemplates() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("en-US")); - - MetadataContent result = client.generateNegotiationAbortPromptFromData( - new NegotiationAbortData( - new NegotiationContext(UUID, 3, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent( - "Reached the negotiation round limit. This negotiation is confirmed and ended.")), - StandardTemplates.NEGOTIATION_ABORT_URI); - - assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); - assertTrue(result.promptText().contains("## Negotiation Result\nAbort")); - assertTrue(result.promptText().contains("## Negotiation Termination Reason")); - assertTrue(result.promptText().contains("Reached the negotiation round limit.")); - } - - @Test - void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - PromptTemplate template = - client.getPrompt(StandardTemplates.NEGOTIATION_ABORT_URI).orElseThrow(); - assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); - assertFalse(template.content().isBlank()); - } - - @Test - void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - assertTrue(client.getPrompt(INFORMATION_PROPOSE_URI).isPresent()); - assertTrue(client.getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI) - .isPresent()); - assertFalse(client.getPrompt("Negotiation-T/information-negotiation/propose/v9").isPresent()); - PromptTemplate template = client.getPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); - assertEquals(INFORMATION_PROPOSE, template.templateUri()); - assertFalse(template.content().isBlank()); - } - - @Test - void generateNegotiationPromptFromDataThrowsOnMalformedTemplateUriString() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - IllegalArgumentException ex = assertThrows( - IllegalArgumentException.class, - () -> client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - "Task-T/only-one-segment")); - assertTrue(ex.getMessage().contains("Unparseable template URI"), "message was: " + ex.getMessage()); - } - - @Test - void validateNegotiationPromptThrowsOnBlankTemplateUriString() throws IOException { - A2ATClient client = new A2ATClient(writeEnv("zh-CN")); - - IllegalArgumentException ex = assertThrows( - IllegalArgumentException.class, - () -> client.validateProposePromptAndDataFilling( - "rendered negotiation message", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - " ")); - assertTrue(ex.getMessage().contains("Unparseable template URI"), "message was: " + ex.getMessage()); - } - - private static List negotiationPrompts(A2ATClient client) { - return client.getPrompts().stream() - .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( - template.templateUri().extensionName())) - .toList(); - } - - private static Path writeEnv(String language) throws IOException { - Path tempDir = Files.createTempDirectory("a2at-client-negotiation-env"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=%s - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=test-negotiation-mock - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(language)); - return envFile; - } - - public static final class RecordingClient implements LLMClient { - - private final LLMClientConfig config; - - public RecordingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse("{}", config.model(), Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sdk.client.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class A2ATClientNegotiationApiTest { + + private static final String TEST_MOCK_PROVIDER = "test-negotiation-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + @Test + void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); + Map metadata = result.buildMetadataContent(); + assertEquals(3, metadata.size()); + assertEquals(result.promptText(), metadata.get(result.extensionUri())); + assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); + } + + @Test + void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("en-US")); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE_URI); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); + assertTrue(result.promptText().contains("Required Information Items")); + } + + @Test + void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { + assertEquals(7, negotiationPrompts(new A2ATClient(writeEnv("zh-CN"))).size()); + assertEquals(7, negotiationPrompts(new A2ATClient(writeEnv("en-US"))).size()); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + MetadataContent result = client.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 5, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + StandardTemplates.NEGOTIATION_ABORT_URI); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("## 协商终止原因")); + assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); + assertEquals(5, result.negotiationContext().round()); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinEnglishTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("en-US")); + + MetadataContent result = client.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 3, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent( + "Reached the negotiation round limit. This negotiation is confirmed and ended.")), + StandardTemplates.NEGOTIATION_ABORT_URI); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## Negotiation Result\nAbort")); + assertTrue(result.promptText().contains("## Negotiation Termination Reason")); + assertTrue(result.promptText().contains("Reached the negotiation round limit.")); + } + + @Test + void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + PromptTemplate template = + client.getPrompt(StandardTemplates.NEGOTIATION_ABORT_URI).orElseThrow(); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + @Test + void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + assertTrue(client.getPrompt(INFORMATION_PROPOSE_URI).isPresent()); + assertTrue(client.getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI) + .isPresent()); + assertFalse(client.getPrompt("Negotiation-T/information-negotiation/propose/v9").isPresent()); + PromptTemplate template = client.getPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); + assertEquals(INFORMATION_PROPOSE, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + @Test + void generateNegotiationPromptFromDataThrowsOnMalformedTemplateUriString() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + "Task-T/only-one-segment")); + assertTrue(ex.getMessage().contains("Unparseable template URI"), "message was: " + ex.getMessage()); + } + + @Test + void validateNegotiationPromptThrowsOnBlankTemplateUriString() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> client.validateProposePromptAndDataFilling( + "rendered negotiation message", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + " ")); + assertTrue(ex.getMessage().contains("Unparseable template URI"), "message was: " + ex.getMessage()); + } + + private static List negotiationPrompts(A2ATClient client) { + return client.getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + + private static Path writeEnv(String language) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-negotiation-env"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=%s + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-negotiation-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(language)); + return envFile; + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java index 326dcf43..2a83e7b6 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -1,240 +1,240 @@ -package net.openan.a2at.sdk.client.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Drives the env-configured negotiation behavior of the client facade end to end. - * - *

            The negotiation-relevant env keys must become observable in the facade behavior: the language selects the Chinese - * templates, and the LLM attempt limit bounds the retry loop of the from-text generation. The negotiation templates are - * classpath-fixed, so a configured local resource root is ignored in {@code classpath} mode: the built-in template wins - * over any local override. A second test proves the zero-configuration defaults (English templates, three attempts, - * built-in resources) work out of the box. - */ -class A2ATClientNegotiationEnvConfigTest { - - private static final String TEST_MOCK_PROVIDER = "test-negotiation-failing-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, FailingClient.class); - } - } - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); - - private static final String CUSTOM_TEMPLATE_MARKER = "CUSTOM-TEMPLATE-MARKER-7d31"; - - @TempDir - Path tempDir; - - private Logger rootLogger; - - private ListAppender appender; - - @BeforeEach - void attachAppender() { - rootLogger = - (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); - appender = new ListAppender<>(); - appender.start(); - rootLogger.addAppender(appender); - } - - @AfterEach - void detachAppender() { - rootLogger.detachAppender(appender); - appender.stop(); - } - - @Test - void languageAndMaxAttemptsAreObservableAndClasspathIgnoresLocalRoot() throws IOException { - Path customRoot = writeCustomRootWithMarkeredTemplate(); - Path envFile = writeEnvFile( - "A2AT_LANGUAGE=zh-CN", - "A2AT_PROMPT_SOURCE_TYPE=classpath", - "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=" + customRoot.toString().replace('\\', '/'), - "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, - "A2AT_LLM_MODEL=example-model", - "A2AT_LLM_BASE_URL=https://llm.example.test/v1", - "A2AT_LLM_API_KEY=test-key", - "A2AT_LLM_MAX_ATTEMPTS=2", - "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory"); - A2ATClient client = new A2ATClient(envFile); - - MetadataContent result = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - - assertTrue(result.promptText().contains("所需信息项"), "the zh-CN language must select the Chinese templates"); - assertFalse( - result.promptText().contains(CUSTOM_TEMPLATE_MARKER), - "the configured local resource root must be ignored in classpath mode and the built-in template used"); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> client.generateNegotiationProposePromptFromText( - "请提供节能区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI)); - assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); - assertEquals( - 1, - retryEventCount(), - "A2AT_LLM_MAX_ATTEMPTS=2 must allow exactly one retry before surfacing the failure"); - assertEquals(1, exhaustedEventCount(), "the retry loop must report exhaustion exactly once"); - } - - @Test - void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { - Path envFile = writeEnvFile( - "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, - "A2AT_LLM_MODEL=example-model", - "A2AT_LLM_BASE_URL=https://llm.example.test/v1", - "A2AT_LLM_API_KEY=test-key"); - A2ATClient client = new A2ATClient(envFile); - - MetadataContent result = client.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE_URI); - - assertTrue(result.promptText().contains("## Information Negotiation"), "the default language must be en-US"); - assertTrue(result.promptText().contains("Required Information Items")); - assertEquals(7, negotiationPrompts(client).size(), "the built-in resources must be used by default"); - assertTrue(client.getPrompt(INFORMATION_PROPOSE_URI).isPresent()); - - assertThrows( - NegotiationGenerationException.class, - () -> client.generateNegotiationProposePromptFromText( - "Provide the ran-energy-saving region.", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI)); - assertEquals( - 2, - retryEventCount(), - "the default attempt limit of 3 must allow exactly two retries before surfacing the failure"); - assertEquals(1, exhaustedEventCount()); - } - - private Path writeEnvFile(String... lines) throws IOException { - Path envFile = tempDir.resolve("client.env"); - Files.writeString(envFile, String.join("\n", lines) + "\n", StandardCharsets.UTF_8); - return envFile; - } - - private static List negotiationPrompts(A2ATClient client) { - return client.getPrompts().stream() - .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( - template.templateUri().extensionName())) - .toList(); - } - - private Path writeCustomRootWithMarkeredTemplate() throws IOException { - Path customTemplate = tempDir.resolve("custom-root") - .resolve("templates") - .resolve("Negotiation-T") - .resolve("information-negotiation") - .resolve("propose") - .resolve("v1") - .resolve("zh-CN") - .resolve("template.md"); - Files.createDirectories(customTemplate.getParent()); - Files.writeString( - customTemplate, - builtinTemplate(INFORMATION_PROPOSE_URI, "zh-CN") + "\n\n## 自定义标记\n" + CUSTOM_TEMPLATE_MARKER + "\n", - StandardCharsets.UTF_8); - return tempDir.resolve("custom-root"); - } - - private static String builtinTemplate(String templateUri, String language) throws IOException { - String[] segments = templateUri.split("/"); - String typeSegment = segments[1]; - String phaseSegment = segments[2]; - String classpathPath = "prompt_resources/templates/Negotiation-T/" + typeSegment + "/" + phaseSegment + "/" - + TemplateUri.DEFAULT_TEMPLATE_VERSION + "/" + language + "/template.md"; - InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(classpathPath); - assertFalse(stream == null, "the built-in template must exist on the classpath: " + classpathPath); - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } - } - - private long retryEventCount() { - return countEvents("negotiation_llm_retry"); - } - - private long exhaustedEventCount() { - return countEvents("negotiation_llm_retry_exhausted"); - } - - private long countEvents(String eventName) { - return appender.list.stream() - .filter(event -> event.getLevel() == Level.WARN) - .map(ILoggingEvent::getFormattedMessage) - .filter(message -> message.startsWith(eventName + " ")) - .count(); - } - - public static final class FailingClient implements LLMClient { - - private final LLMClientConfig config; - - public FailingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - throw new LLMRuntimeError("test mock failure"); - } - } -} +package net.openan.a2at.sdk.client.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Drives the env-configured negotiation behavior of the client facade end to end. + * + *

            The negotiation-relevant env keys must become observable in the facade behavior: the language selects the Chinese + * templates, and the LLM attempt limit bounds the retry loop of the from-text generation. The negotiation templates are + * classpath-fixed, so a configured local resource root is ignored in {@code classpath} mode: the built-in template wins + * over any local override. A second test proves the zero-configuration defaults (English templates, three attempts, + * built-in resources) work out of the box. + */ +class A2ATClientNegotiationEnvConfigTest { + + private static final String TEST_MOCK_PROVIDER = "test-negotiation-failing-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, FailingClient.class); + } + } + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + private static final String CUSTOM_TEMPLATE_MARKER = "CUSTOM-TEMPLATE-MARKER-7d31"; + + @TempDir + Path tempDir; + + private Logger rootLogger; + + private ListAppender appender; + + @BeforeEach + void attachAppender() { + rootLogger = + (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + appender = new ListAppender<>(); + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + rootLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void languageAndMaxAttemptsAreObservableAndClasspathIgnoresLocalRoot() throws IOException { + Path customRoot = writeCustomRootWithMarkeredTemplate(); + Path envFile = writeEnvFile( + "A2AT_LANGUAGE=zh-CN", + "A2AT_PROMPT_SOURCE_TYPE=classpath", + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=" + customRoot.toString().replace('\\', '/'), + "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, + "A2AT_LLM_MODEL=example-model", + "A2AT_LLM_BASE_URL=https://llm.example.test/v1", + "A2AT_LLM_API_KEY=test-key", + "A2AT_LLM_MAX_ATTEMPTS=2", + "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory"); + A2ATClient client = new A2ATClient(envFile); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + assertTrue(result.promptText().contains("所需信息项"), "the zh-CN language must select the Chinese templates"); + assertFalse( + result.promptText().contains(CUSTOM_TEMPLATE_MARKER), + "the configured local resource root must be ignored in classpath mode and the built-in template used"); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> client.generateNegotiationProposePromptFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI)); + assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); + assertEquals( + 1, + retryEventCount(), + "A2AT_LLM_MAX_ATTEMPTS=2 must allow exactly one retry before surfacing the failure"); + assertEquals(1, exhaustedEventCount(), "the retry loop must report exhaustion exactly once"); + } + + @Test + void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { + Path envFile = writeEnvFile( + "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, + "A2AT_LLM_MODEL=example-model", + "A2AT_LLM_BASE_URL=https://llm.example.test/v1", + "A2AT_LLM_API_KEY=test-key"); + A2ATClient client = new A2ATClient(envFile); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE_URI); + + assertTrue(result.promptText().contains("## Information Negotiation"), "the default language must be en-US"); + assertTrue(result.promptText().contains("Required Information Items")); + assertEquals(7, negotiationPrompts(client).size(), "the built-in resources must be used by default"); + assertTrue(client.getPrompt(INFORMATION_PROPOSE_URI).isPresent()); + + assertThrows( + NegotiationGenerationException.class, + () -> client.generateNegotiationProposePromptFromText( + "Provide the ran-energy-saving region.", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI)); + assertEquals( + 2, + retryEventCount(), + "the default attempt limit of 3 must allow exactly two retries before surfacing the failure"); + assertEquals(1, exhaustedEventCount()); + } + + private Path writeEnvFile(String... lines) throws IOException { + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, String.join("\n", lines) + "\n", StandardCharsets.UTF_8); + return envFile; + } + + private static List negotiationPrompts(A2ATClient client) { + return client.getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + + private Path writeCustomRootWithMarkeredTemplate() throws IOException { + Path customTemplate = tempDir.resolve("custom-root") + .resolve("templates") + .resolve("Negotiation-T") + .resolve("information-negotiation") + .resolve("propose") + .resolve("v1") + .resolve("zh-CN") + .resolve("template.md"); + Files.createDirectories(customTemplate.getParent()); + Files.writeString( + customTemplate, + builtinTemplate(INFORMATION_PROPOSE_URI, "zh-CN") + "\n\n## 自定义标记\n" + CUSTOM_TEMPLATE_MARKER + "\n", + StandardCharsets.UTF_8); + return tempDir.resolve("custom-root"); + } + + private static String builtinTemplate(String templateUri, String language) throws IOException { + String[] segments = templateUri.split("/"); + String typeSegment = segments[1]; + String phaseSegment = segments[2]; + String classpathPath = "prompt_resources/templates/Negotiation-T/" + typeSegment + "/" + phaseSegment + "/" + + TemplateUri.DEFAULT_TEMPLATE_VERSION + "/" + language + "/template.md"; + InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(classpathPath); + assertFalse(stream == null, "the built-in template must exist on the classpath: " + classpathPath); + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private long retryEventCount() { + return countEvents("negotiation_llm_retry"); + } + + private long exhaustedEventCount() { + return countEvents("negotiation_llm_retry_exhausted"); + } + + private long countEvents(String eventName) { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.startsWith(eventName + " ")) + .count(); + } + + public static final class FailingClient implements LLMClient { + + private final LLMClientConfig config; + + public FailingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new LLMRuntimeError("test mock failure"); + } + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index 62ca5d2e..e9ad5d70 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -1,840 +1,840 @@ -package net.openan.a2at.sdk.client; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; -import net.openan.a2at.sdk.core.model.ExtensionUriConstants; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class A2ATClientTest { - - private static final String TEST_MOCK_PROVIDER = "test-a2at-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); - } - } - - @BeforeEach - void resetRecordingClientState() { - RecordingClient.SLOT_OVERRIDES.clear(); - RecordingClient.REQUEST_COUNT.set(0); - } - - @Test - void onlyExposesPathBasedPublicConstructor() throws NoSuchMethodException { - assertNotNull(A2ATClient.class.getConstructor(Path.class)); - assertEquals(1, A2ATClient.class.getConstructors().length); - assertEquals(1, A2ATClient.class.getDeclaredConstructors().length); - } - - @Test - void promptGenerationResultDoesNotExposeScenarioValidationOrSlotsFields() { - assertThrows(NoSuchMethodException.class, () -> PromptGenerationResult.class.getMethod("scenarioCode")); - assertThrows(NoSuchMethodException.class, () -> PromptGenerationResult.class.getMethod("validation")); - assertThrows(NoSuchMethodException.class, () -> PromptGenerationResult.class.getMethod("slots")); - } - - @Test - void noLongerExposesLowLevelPromptAndNegotiationConstructor() { - assertThrows( - NoSuchMethodException.class, - () -> A2ATClient.class.getDeclaredConstructor( - ClientPromptGenerationOrchestrator.class, - net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator.class)); - } - - @Test - void noLongerExposesLowLevelPromptGenerationAssemblyConstructors() { - assertThrows( - NoSuchMethodException.class, - () -> A2ATClient.class.getDeclaredConstructor( - net.openan.a2at.sdk.llm.LLMClient.class, - net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator.class, - java.util.List.class, - String.class, - String.class, - String.class, - String.class, - String.class)); - assertThrows( - NoSuchMethodException.class, - () -> A2ATClient.class.getDeclaredConstructor( - net.openan.a2at.sdk.llm.LLMClient.class, - net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator.class, - java.util.List.class, - net.openan.a2at.sdk.core.model.PromptGenerationConfig.class)); - } - - @Test - void doesNotKeepStaticAssemblyHelpersInsideFacade() { - long staticMethodCount = Arrays.stream(A2ATClient.class.getDeclaredMethods()) - .filter(method -> java.lang.reflect.Modifier.isStatic(method.getModifiers())) - .filter(method -> !java.lang.reflect.Modifier.isPrivate(method.getModifiers())) - .count(); - - assertEquals(0, staticMethodCount); - } - - @Test - void pathBasedConstructorLoadsLocalPromptResources() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); - RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); - A2ATClient client = new A2ATClient(envFile); - - PromptGenerationResult result = - client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); - - assertTrue(result.success()); - assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); - assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); - } - - @Test - void scenarioRecognitionShortCircuitsWhenSingleScenarioIsConfigured() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); - RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); - A2ATClient client = new A2ATClient(envFile); - - PromptGenerationResult result = - client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); - - assertTrue(result.success()); - assertEquals( - 1, - RecordingClient.REQUEST_COUNT.get(), - "Single scenario must short-circuit recognition — only extraction LLM call expected"); - } - - @Test - void scenarioRecognitionDelegatesToWrappedRecognizerWhenMultipleScenariosConfigured() throws IOException { - Path envFile = writeMinimalMultiScenarioClientEnv(); - RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); - RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); - A2ATClient client = new A2ATClient(envFile); - - PromptGenerationResult result = - client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); - - assertTrue(result.success()); - assertEquals( - 2, - RecordingClient.REQUEST_COUNT.get(), - "Multiple scenarios must delegate to wrapped recognizer — recognition + extraction calls expected"); - } - - @Test - void pathBasedConstructorAcceptsRelativeEnvPath() throws IOException { - Path targetDir = Files.createDirectories(Path.of("target")); - Path tempDir = Files.createTempDirectory(targetDir, "a2at-client-relative-env"); - writeMinimalClientEnv(tempDir, TEST_MOCK_PROVIDER); - Path relativeEnvPath = - targetDir.getFileName().resolve(tempDir.getFileName()).resolve("client.env"); - - RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); - RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); - A2ATClient client = new A2ATClient(relativeEnvPath); - PromptGenerationResult result = - client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); - - assertTrue(result.success()); - assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); - assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); - } - - @Test - void pathBasedConstructorLoadsClasspathPromptResources() throws IOException { - Path envFile = writeMinimalClasspathClientEnv(); - - A2ATClient client = new A2ATClient(envFile); - - assertNotNull(client); - } - - @Test - void pathBasedConstructorAcceptsOpenAiProvider() throws IOException { - Path envFile = writeMinimalClientEnv(TEST_MOCK_PROVIDER); - - RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); - RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); - A2ATClient client = new A2ATClient(envFile); - PromptGenerationResult result = - client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); - - assertTrue(result.success()); - assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); - assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); - } - - @Test - void pathBasedConstructorBuildsDefaultNegotiationRuntime() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - - Map startResult = - client.startNegotiation(NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); - @SuppressWarnings("unchecked") - Map startData = (Map) - startResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_T_URI_NL); - - Map continueResult = client.continueNegotiation( - new NegotiationContext( - NegotiationType.TARGET, - String.valueOf(startData.get("negotiationId")), - 1, - NegotiationStatus.IN_PROGRESS), - NegotiationStatus.IN_PROGRESS, - "Site A"); - @SuppressWarnings("unchecked") - Map continueData = (Map) - continueResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_T_URI_NL); - - assertEquals("Please clarify the target.", startData.get("message")); - assertEquals("Site A", continueData.get("message")); - } - - @Test - void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-client-private-line"); - Path promptRoot = tempDir.resolve("prompt_resources"); - Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); - Path templatesDir = promptRoot - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("private-line-complaint") - .resolve("v1") - .resolve("zh-CN"); - Path slotsDir = promptRoot - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("private-line-complaint") - .resolve("v1") - .resolve("zh-CN"); - Files.createDirectories(scenariosDir); - Files.createDirectories(templatesDir); - Files.createDirectories(slotsDir); - - Path scenarioPromptDir = - promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); - Path slotPromptDir = - promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); - Files.createDirectories(scenarioPromptDir); - Files.createDirectories(slotPromptDir); - Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); - Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); - Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); - Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); - - Files.writeString( - scenariosDir.resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "private-line-complaint", - "scenario_name": "Private Line Complaint", - "description": "Complaint analysis", - "example": "Analyze private line fault" - } - ] - } - """); - Files.writeString(templatesDir.resolve("template.md"), "Line: {line_id}\\nFault: {fault_id}"); - Files.writeString( - slotsDir.resolve("slot.json"), - """ - { - "required": ["line_id", "fault_id"], - "properties": { - "line_id": { - "type": "string" - }, - "fault_id": { - "type": "string" - } - } - } - """); - - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=local_file - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources -A2AT_LLM_PROVIDER=%s -A2AT_LLM_MODEL=example-model -A2AT_LLM_BASE_URL=https://llm.example.test/v1 -A2AT_LLM_API_KEY=test-key -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(TEST_MOCK_PROVIDER)); - - RecordingClient.SLOT_OVERRIDES.put("line_id", "line-1"); - RecordingClient.SLOT_OVERRIDES.put("fault_id", "fault-9"); - A2ATClient client = new A2ATClient(envFile); - PromptGenerationResult result = client.generateTaskPrompt(Map.of("line_id", "line-1", "fault_id", "fault-9")); - - assertTrue(result.success()); - assertEquals("Line: line-1\\nFault: fault-9", result.promptText()); - assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); - } - - @Test - void fromTextEntryPointsReturnMetadataContent() throws IOException { - Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); - A2ATClient client = new A2ATClient(envFile); - String unconstrained = "Task-T/network-layer/unconstrained/v1"; - - MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); - MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); - MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", unconstrained); - - assertNotNull(taskResult); - assertEquals("Task-T/network-layer/unconstrained/v1", taskResult.templateUri()); - assertNotNull(taskResult.promptText()); - assertNotNull(taskResult.extensionUri()); - - assertNotNull(authResult); - assertEquals("Task-T/network-layer/unconstrained/v1", authResult.templateUri()); - assertNotNull(authResult.promptText()); - assertNotNull(authResult.extensionUri()); - - assertNotNull(notificationResult); - assertEquals("Task-T/network-layer/unconstrained/v1", notificationResult.templateUri()); - assertNotNull(notificationResult.promptText()); - assertNotNull(notificationResult.extensionUri()); - } - - @Test - void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); - Map schema = Map.of("type", "object"); - - MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); - MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); - MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); - - assertNotNull(taskResult); - assertEquals(StandardTemplates.ENERGY_SAVING.uri(), taskResult.templateUri()); - assertNotNull(taskResult.promptText()); - assertNotNull(taskResult.extensionUri()); - - assertNotNull(authResult); - assertEquals(StandardTemplates.ENERGY_SAVING.uri(), authResult.templateUri()); - assertNotNull(authResult.promptText()); - assertNotNull(authResult.extensionUri()); - - assertNotNull(notificationResult); - assertEquals(StandardTemplates.ENERGY_SAVING.uri(), notificationResult.templateUri()); - assertNotNull(notificationResult.promptText()); - assertNotNull(notificationResult.extensionUri()); - } - - @Test - void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { - Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); - A2ATClient client = new A2ATClient(envFile); - String unconstrained = "Task-T/network-layer/unconstrained/v1"; - - MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); - MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); - MetadataContent notificationResult = - client.generateNotificationPromptFromText("Report finished.", unconstrained); - - assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); - assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); - assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notificationResult.extensionUri()); - } - - @Test - void fromDataWithSchemaReturnsCorrectExtensionUriPerContentType() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); - Map schema = Map.of("type", "object"); - - MetadataContent taskResult = - client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); - MetadataContent authResult = - client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); - MetadataContent notificationResult = - client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); - - assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); - assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); - assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notificationResult.extensionUri()); - } - - @Test - void fromTextThrowsOnNullTemplateUri() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - - NullPointerException taskEx = assertThrows( - NullPointerException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", null)); - assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); - NullPointerException authEx = assertThrows( - NullPointerException.class, () -> client.generateAuthPromptFromText("Authorize access.", null)); - assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); - NullPointerException notifEx = assertThrows( - NullPointerException.class, () -> client.generateNotificationPromptFromText("Report finished.", null)); - assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); - } - - @Test - void fromDataWithSchemaThrowsOnNullTemplateUri() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - Map data = Map.of("site", "Site A"); - Map schema = Map.of("type", "object"); - - NullPointerException taskEx = assertThrows( - NullPointerException.class, () -> client.generateTaskPromptFromDataWithSchema(data, schema, null)); - assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); - NullPointerException authEx = assertThrows( - NullPointerException.class, () -> client.generateAuthPromptFromDataWithSchema(data, schema, null)); - assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); - NullPointerException notifEx = assertThrows( - NullPointerException.class, - () -> client.generateNotificationPromptFromDataWithSchema(data, schema, null)); - assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); - } - - @Test - void fromTextThrowsOnMalformedTemplateUriString() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - - for (String malformed : new String[] {"not-a-uri", "Task-T/only-one-segment", "Task-T/../v1"}) { - IllegalArgumentException taskEx = assertThrows( - IllegalArgumentException.class, - () -> client.generateTaskPromptFromText("Please analyze Site A.", malformed)); - assertTrue(taskEx.getMessage().contains("Unparseable template URI"), "message was: " + taskEx.getMessage()); - IllegalArgumentException authEx = assertThrows( - IllegalArgumentException.class, () -> client.generateAuthPromptFromText("Authorize access.", malformed)); - assertTrue(authEx.getMessage().contains("Unparseable template URI"), "message was: " + authEx.getMessage()); - IllegalArgumentException notifEx = assertThrows( - IllegalArgumentException.class, - () -> client.generateNotificationPromptFromText("Report finished.", malformed)); - assertTrue(notifEx.getMessage().contains("Unparseable template URI"), "message was: " + notifEx.getMessage()); - } - } - - @Test - void fromTextThrowsOnBlankTemplateUriString() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - - IllegalArgumentException taskEx = assertThrows( - IllegalArgumentException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", " ")); - assertTrue(taskEx.getMessage().contains("Unparseable template URI"), "message was: " + taskEx.getMessage()); - } - - @Test - void getPromptThrowsOnMalformedAndBlankTemplateUriString() throws IOException { - Path envFile = writeMinimalLocalClientEnv(); - A2ATClient client = new A2ATClient(envFile); - - IllegalArgumentException malformedEx = - assertThrows(IllegalArgumentException.class, () -> client.getPrompt("not-a-uri")); - assertTrue(malformedEx.getMessage().contains("Unparseable template URI"), "message was: " + malformedEx.getMessage()); - IllegalArgumentException blankEx = assertThrows(IllegalArgumentException.class, () -> client.getPrompt(" ")); - assertTrue(blankEx.getMessage().contains("Unparseable template URI"), "message was: " + blankEx.getMessage()); - } - - @Test - void fromTextRejectsOverLimitTextWhenConfiguredWithSmallLimit() throws IOException { - Path envFile = writeMinimalClientEnvWithInputLimit(TEST_MOCK_PROVIDER, 100); - A2ATClient client = new A2ATClient(envFile); - String overLimitText = "a".repeat(101); - - PromptGenerationException taskEx = assertThrows( - PromptGenerationException.class, - () -> client.generateTaskPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING_URI)); - PromptGenerationException authEx = assertThrows( - PromptGenerationException.class, - () -> client.generateAuthPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING_URI)); - PromptGenerationException notificationEx = assertThrows( - PromptGenerationException.class, - () -> client.generateNotificationPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING_URI)); - - assertEquals("input.text_too_long", taskEx.getCode()); - assertEquals("input.text_too_long", authEx.getCode()); - assertEquals("input.text_too_long", notificationEx.getCode()); - assertEquals(0, RecordingClient.REQUEST_COUNT.get(), "over-limit input must fail before any LLM call"); - } - - @Test - void generateTaskPromptRejectsOverLimitStringInputWithoutLlmCall() throws IOException { - Path envFile = writeMinimalClientEnvWithInputLimit(TEST_MOCK_PROVIDER, 100); - A2ATClient client = new A2ATClient(envFile); - - PromptGenerationResult result = client.generateTaskPrompt("a".repeat(101)); - - assertFalse(result.success()); - assertEquals("input.text_too_long", result.failure().code()); - assertEquals(0, RecordingClient.REQUEST_COUNT.get(), "over-limit input must fail before any LLM call"); - } - - private static Path writeMinimalClientEnvWithInputLimit(String provider, int maxTextChars) throws IOException { - Path envFile = writeMinimalClientEnv(provider); - Files.writeString( - envFile, "A2AT_INPUT_TEXT_MAX_CHARS=" + maxTextChars + "\n", java.nio.file.StandardOpenOption.APPEND); - return envFile; - } - - private static Path writeMinimalClientEnvWithoutRequiredSlots(String provider) throws IOException { - Path tempDir = Files.createTempDirectory("a2at-client-env-no-required"); - Path promptRoot = tempDir.resolve("prompt_resources"); - Path scenarioPromptDir = - promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); - Path slotPromptDir = - promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); - Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); - Path templatesDir = promptRoot - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("unconstrained") - .resolve("v1") - .resolve("zh-CN"); - Path slotsDir = promptRoot - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("unconstrained") - .resolve("v1") - .resolve("zh-CN"); - Files.createDirectories(scenarioPromptDir); - Files.createDirectories(slotPromptDir); - Files.createDirectories(scenariosDir); - Files.createDirectories(templatesDir); - Files.createDirectories(slotsDir); - - Files.writeString( - scenariosDir.resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "unconstrained", - "scenario_name": "Unconstrained", - "description": "No required slots", - "example": "Analyze unconstrained" - } - ] - } - """); - Files.writeString(templatesDir.resolve("template.md"), "Notes: {notes}\\nFault: {fault_description}"); - Files.writeString( - slotsDir.resolve("slot.json"), - """ - { - "required": [], - "properties": { - "notes": { - "type": "string" - }, - "fault_description": { - "type": "string" - } - } - } - """); - Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); - Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); - Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); - Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); - - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=local_file - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=%s - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(provider)); - return envFile; - } - - private static Path writeMinimalLocalClientEnv() throws IOException { - return writeMinimalClientEnv(TEST_MOCK_PROVIDER); - } - - private static Path writeMinimalClasspathClientEnv() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-client-classpath-env"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=example-model -A2AT_LLM_BASE_URL=https://llm.example.test/v1 -A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """); - return envFile; - } - - private static Path writeMinimalClientEnv(String provider) throws IOException { - return writeMinimalClientEnv(Files.createTempDirectory("a2at-client-env"), provider); - } - - private static Path writeMinimalClientEnv(Path tempDir, String provider) throws IOException { - Path promptRoot = tempDir.resolve("prompt_resources"); - Path scenarioPromptDir = - promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); - Path slotPromptDir = - promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); - Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); - Path templatesDir = promptRoot - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("ran-energy-saving") - .resolve("v1") - .resolve("zh-CN"); - Path slotsDir = promptRoot - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("ran-energy-saving") - .resolve("v1") - .resolve("zh-CN"); - Files.createDirectories(scenarioPromptDir); - Files.createDirectories(slotPromptDir); - Files.createDirectories(scenariosDir); - Files.createDirectories(templatesDir); - Files.createDirectories(slotsDir); - - Files.writeString( - scenariosDir.resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "ran-energy-saving", - "scenario_name": "Energy Saving", - "description": "Energy analysis", - "example": "Analyze site power" - } - ] - } - """); - Files.writeString(templatesDir.resolve("template.md"), "Site: {site}\\nTarget: {target}"); - Files.writeString( - slotsDir.resolve("slot.json"), - """ - { - "required": ["site", "target"], - "properties": { - "site": { - "type": "string" - }, - "target": { - "type": "string" - } - } - } - """); - Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); - Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); - Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); - Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); - - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=local_file - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=%s - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(provider)); - return envFile; - } - - private static Path writeMinimalMultiScenarioClientEnv() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-client-multi-scenario"); - Path promptRoot = tempDir.resolve("prompt_resources"); - Path scenarioPromptDir = - promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); - Path slotPromptDir = - promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); - Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); - Path templatesDir = promptRoot - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("ran-energy-saving") - .resolve("v1") - .resolve("zh-CN"); - Path slotsDir = promptRoot - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("ran-energy-saving") - .resolve("v1") - .resolve("zh-CN"); - Files.createDirectories(scenarioPromptDir); - Files.createDirectories(slotPromptDir); - Files.createDirectories(scenariosDir); - Files.createDirectories(templatesDir); - Files.createDirectories(slotsDir); - - Files.writeString( - scenariosDir.resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "ran-energy-saving", - "scenario_name": "Energy Saving", - "description": "Energy analysis", - "example": "Analyze site power" - }, - { - "scenario_code": "data-report", - "scenario_name": "Data Report", - "description": "Report generation", - "example": "Generate report" - } - ] - } - """); - Files.writeString(templatesDir.resolve("template.md"), "Site: {site}\\nTarget: {target}"); - Files.writeString( - slotsDir.resolve("slot.json"), - """ - { - "required": ["site", "target"], - "properties": { - "site": { - "type": "string" - }, - "target": { - "type": "string" - } - } - } - """); - Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); - Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); - Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); - Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); - - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=local_file - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=%s - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(TEST_MOCK_PROVIDER)); - return envFile; - } - - public static final class RecordingClient implements LLMClient { - - public static final Map SLOT_OVERRIDES = new ConcurrentHashMap<>(); - public static final AtomicInteger REQUEST_COUNT = new AtomicInteger(0); - - private final LLMClientConfig config; - - public RecordingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - REQUEST_COUNT.incrementAndGet(); - return new LLMResponse(buildResponse(jsonSchema), config.model(), Map.of(), Map.of()); - } - - private static String buildResponse(Map jsonSchema) { - Object slotNames = jsonSchema.get("slotNames"); - if (slotNames instanceof List names) { - StringBuilder slots = new StringBuilder("{"); - for (int i = 0; i < names.size(); i++) { - if (i > 0) { - slots.append(","); - } - String name = String.valueOf(names.get(i)); - String value = SLOT_OVERRIDES.getOrDefault(name, "placeholder"); - slots.append("\"") - .append(name) - .append("\":\"") - .append(value) - .append("\""); - } - slots.append("}"); - return "{\"slots\":" + slots + ",\"slot_errors\":[]}"; - } - return "{\"matched\":true,\"scenario_code\":\"ran-energy-saving\",\"error_message\":null}"; - } - } -} +package net.openan.a2at.sdk.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class A2ATClientTest { + + private static final String TEST_MOCK_PROVIDER = "test-a2at-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + @BeforeEach + void resetRecordingClientState() { + RecordingClient.SLOT_OVERRIDES.clear(); + RecordingClient.REQUEST_COUNT.set(0); + } + + @Test + void onlyExposesPathBasedPublicConstructor() throws NoSuchMethodException { + assertNotNull(A2ATClient.class.getConstructor(Path.class)); + assertEquals(1, A2ATClient.class.getConstructors().length); + assertEquals(1, A2ATClient.class.getDeclaredConstructors().length); + } + + @Test + void promptGenerationResultDoesNotExposeScenarioValidationOrSlotsFields() { + assertThrows(NoSuchMethodException.class, () -> PromptGenerationResult.class.getMethod("scenarioCode")); + assertThrows(NoSuchMethodException.class, () -> PromptGenerationResult.class.getMethod("validation")); + assertThrows(NoSuchMethodException.class, () -> PromptGenerationResult.class.getMethod("slots")); + } + + @Test + void noLongerExposesLowLevelPromptAndNegotiationConstructor() { + assertThrows( + NoSuchMethodException.class, + () -> A2ATClient.class.getDeclaredConstructor( + ClientPromptGenerationOrchestrator.class, + net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator.class)); + } + + @Test + void noLongerExposesLowLevelPromptGenerationAssemblyConstructors() { + assertThrows( + NoSuchMethodException.class, + () -> A2ATClient.class.getDeclaredConstructor( + net.openan.a2at.sdk.llm.LLMClient.class, + net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator.class, + java.util.List.class, + String.class, + String.class, + String.class, + String.class, + String.class)); + assertThrows( + NoSuchMethodException.class, + () -> A2ATClient.class.getDeclaredConstructor( + net.openan.a2at.sdk.llm.LLMClient.class, + net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator.class, + java.util.List.class, + net.openan.a2at.sdk.core.model.PromptGenerationConfig.class)); + } + + @Test + void doesNotKeepStaticAssemblyHelpersInsideFacade() { + long staticMethodCount = Arrays.stream(A2ATClient.class.getDeclaredMethods()) + .filter(method -> java.lang.reflect.Modifier.isStatic(method.getModifiers())) + .filter(method -> !java.lang.reflect.Modifier.isPrivate(method.getModifiers())) + .count(); + + assertEquals(0, staticMethodCount); + } + + @Test + void pathBasedConstructorLoadsLocalPromptResources() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult result = + client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); + + assertTrue(result.success()); + assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); + assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); + } + + @Test + void scenarioRecognitionShortCircuitsWhenSingleScenarioIsConfigured() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult result = + client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); + + assertTrue(result.success()); + assertEquals( + 1, + RecordingClient.REQUEST_COUNT.get(), + "Single scenario must short-circuit recognition — only extraction LLM call expected"); + } + + @Test + void scenarioRecognitionDelegatesToWrappedRecognizerWhenMultipleScenariosConfigured() throws IOException { + Path envFile = writeMinimalMultiScenarioClientEnv(); + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult result = + client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); + + assertTrue(result.success()); + assertEquals( + 2, + RecordingClient.REQUEST_COUNT.get(), + "Multiple scenarios must delegate to wrapped recognizer — recognition + extraction calls expected"); + } + + @Test + void pathBasedConstructorAcceptsRelativeEnvPath() throws IOException { + Path targetDir = Files.createDirectories(Path.of("target")); + Path tempDir = Files.createTempDirectory(targetDir, "a2at-client-relative-env"); + writeMinimalClientEnv(tempDir, TEST_MOCK_PROVIDER); + Path relativeEnvPath = + targetDir.getFileName().resolve(tempDir.getFileName()).resolve("client.env"); + + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); + A2ATClient client = new A2ATClient(relativeEnvPath); + PromptGenerationResult result = + client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); + + assertTrue(result.success()); + assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); + assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); + } + + @Test + void pathBasedConstructorLoadsClasspathPromptResources() throws IOException { + Path envFile = writeMinimalClasspathClientEnv(); + + A2ATClient client = new A2ATClient(envFile); + + assertNotNull(client); + } + + @Test + void pathBasedConstructorAcceptsOpenAiProvider() throws IOException { + Path envFile = writeMinimalClientEnv(TEST_MOCK_PROVIDER); + + RecordingClient.SLOT_OVERRIDES.put("site", "Site A"); + RecordingClient.SLOT_OVERRIDES.put("target", "Reduce power by 10%"); + A2ATClient client = new A2ATClient(envFile); + PromptGenerationResult result = + client.generateTaskPrompt(Map.of("site", "Site A", "target", "Reduce power by 10%")); + + assertTrue(result.success()); + assertEquals("Site: Site A\\nTarget: Reduce power by 10%", result.promptText()); + assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); + } + + @Test + void pathBasedConstructorBuildsDefaultNegotiationRuntime() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + Map startResult = + client.startNegotiation(NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); + @SuppressWarnings("unchecked") + Map startData = (Map) + startResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_T_URI_NL); + + Map continueResult = client.continueNegotiation( + new NegotiationContext( + NegotiationType.TARGET, + String.valueOf(startData.get("negotiationId")), + 1, + NegotiationStatus.IN_PROGRESS), + NegotiationStatus.IN_PROGRESS, + "Site A"); + @SuppressWarnings("unchecked") + Map continueData = (Map) + continueResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_T_URI_NL); + + assertEquals("Please clarify the target.", startData.get("message")); + assertEquals("Site A", continueData.get("message")); + } + + @Test + void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-private-line"); + Path promptRoot = tempDir.resolve("prompt_resources"); + Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); + Path templatesDir = promptRoot + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("private-line-complaint") + .resolve("v1") + .resolve("zh-CN"); + Path slotsDir = promptRoot + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("private-line-complaint") + .resolve("v1") + .resolve("zh-CN"); + Files.createDirectories(scenariosDir); + Files.createDirectories(templatesDir); + Files.createDirectories(slotsDir); + + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); + Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); + Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); + + Files.writeString( + scenariosDir.resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "private-line-complaint", + "scenario_name": "Private Line Complaint", + "description": "Complaint analysis", + "example": "Analyze private line fault" + } + ] + } + """); + Files.writeString(templatesDir.resolve("template.md"), "Line: {line_id}\\nFault: {fault_id}"); + Files.writeString( + slotsDir.resolve("slot.json"), + """ + { + "required": ["line_id", "fault_id"], + "properties": { + "line_id": { + "type": "string" + }, + "fault_id": { + "type": "string" + } + } + } + """); + + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources +A2AT_LLM_PROVIDER=%s +A2AT_LLM_MODEL=example-model +A2AT_LLM_BASE_URL=https://llm.example.test/v1 +A2AT_LLM_API_KEY=test-key +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(TEST_MOCK_PROVIDER)); + + RecordingClient.SLOT_OVERRIDES.put("line_id", "line-1"); + RecordingClient.SLOT_OVERRIDES.put("fault_id", "fault-9"); + A2ATClient client = new A2ATClient(envFile); + PromptGenerationResult result = client.generateTaskPrompt(Map.of("line_id", "line-1", "fault_id", "fault-9")); + + assertTrue(result.success()); + assertEquals("Line: line-1\\nFault: fault-9", result.promptText()); + assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); + } + + @Test + void fromTextEntryPointsReturnMetadataContent() throws IOException { + Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); + A2ATClient client = new A2ATClient(envFile); + String unconstrained = "Task-T/network-layer/unconstrained/v1"; + + MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); + MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); + MetadataContent notificationResult = + client.generateNotificationPromptFromText("Report finished.", unconstrained); + + assertNotNull(taskResult); + assertEquals("Task-T/network-layer/unconstrained/v1", taskResult.templateUri()); + assertNotNull(taskResult.promptText()); + assertNotNull(taskResult.extensionUri()); + + assertNotNull(authResult); + assertEquals("Task-T/network-layer/unconstrained/v1", authResult.templateUri()); + assertNotNull(authResult.promptText()); + assertNotNull(authResult.extensionUri()); + + assertNotNull(notificationResult); + assertEquals("Task-T/network-layer/unconstrained/v1", notificationResult.templateUri()); + assertNotNull(notificationResult.promptText()); + assertNotNull(notificationResult.extensionUri()); + } + + @Test + void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); + Map schema = Map.of("type", "object"); + + MetadataContent taskResult = + client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); + MetadataContent authResult = + client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); + MetadataContent notificationResult = + client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); + + assertNotNull(taskResult); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), taskResult.templateUri()); + assertNotNull(taskResult.promptText()); + assertNotNull(taskResult.extensionUri()); + + assertNotNull(authResult); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), authResult.templateUri()); + assertNotNull(authResult.promptText()); + assertNotNull(authResult.extensionUri()); + + assertNotNull(notificationResult); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), notificationResult.templateUri()); + assertNotNull(notificationResult.promptText()); + assertNotNull(notificationResult.extensionUri()); + } + + @Test + void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { + Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); + A2ATClient client = new A2ATClient(envFile); + String unconstrained = "Task-T/network-layer/unconstrained/v1"; + + MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); + MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); + MetadataContent notificationResult = + client.generateNotificationPromptFromText("Report finished.", unconstrained); + + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notificationResult.extensionUri()); + } + + @Test + void fromDataWithSchemaReturnsCorrectExtensionUriPerContentType() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); + Map schema = Map.of("type", "object"); + + MetadataContent taskResult = + client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); + MetadataContent authResult = + client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); + MetadataContent notificationResult = + client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); + + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notificationResult.extensionUri()); + } + + @Test + void fromTextThrowsOnNullTemplateUri() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + NullPointerException taskEx = assertThrows( + NullPointerException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", null)); + assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException authEx = assertThrows( + NullPointerException.class, () -> client.generateAuthPromptFromText("Authorize access.", null)); + assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException notifEx = assertThrows( + NullPointerException.class, () -> client.generateNotificationPromptFromText("Report finished.", null)); + assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); + } + + @Test + void fromDataWithSchemaThrowsOnNullTemplateUri() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A"); + Map schema = Map.of("type", "object"); + + NullPointerException taskEx = assertThrows( + NullPointerException.class, () -> client.generateTaskPromptFromDataWithSchema(data, schema, null)); + assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException authEx = assertThrows( + NullPointerException.class, () -> client.generateAuthPromptFromDataWithSchema(data, schema, null)); + assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException notifEx = assertThrows( + NullPointerException.class, + () -> client.generateNotificationPromptFromDataWithSchema(data, schema, null)); + assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); + } + + @Test + void fromTextThrowsOnMalformedTemplateUriString() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + for (String malformed : new String[] {"not-a-uri", "Task-T/only-one-segment", "Task-T/../v1"}) { + IllegalArgumentException taskEx = assertThrows( + IllegalArgumentException.class, + () -> client.generateTaskPromptFromText("Please analyze Site A.", malformed)); + assertTrue(taskEx.getMessage().contains("Unparseable template URI"), "message was: " + taskEx.getMessage()); + IllegalArgumentException authEx = assertThrows( + IllegalArgumentException.class, () -> client.generateAuthPromptFromText("Authorize access.", malformed)); + assertTrue(authEx.getMessage().contains("Unparseable template URI"), "message was: " + authEx.getMessage()); + IllegalArgumentException notifEx = assertThrows( + IllegalArgumentException.class, + () -> client.generateNotificationPromptFromText("Report finished.", malformed)); + assertTrue(notifEx.getMessage().contains("Unparseable template URI"), "message was: " + notifEx.getMessage()); + } + } + + @Test + void fromTextThrowsOnBlankTemplateUriString() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + IllegalArgumentException taskEx = assertThrows( + IllegalArgumentException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", " ")); + assertTrue(taskEx.getMessage().contains("Unparseable template URI"), "message was: " + taskEx.getMessage()); + } + + @Test + void getPromptThrowsOnMalformedAndBlankTemplateUriString() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + IllegalArgumentException malformedEx = + assertThrows(IllegalArgumentException.class, () -> client.getPrompt("not-a-uri")); + assertTrue(malformedEx.getMessage().contains("Unparseable template URI"), "message was: " + malformedEx.getMessage()); + IllegalArgumentException blankEx = assertThrows(IllegalArgumentException.class, () -> client.getPrompt(" ")); + assertTrue(blankEx.getMessage().contains("Unparseable template URI"), "message was: " + blankEx.getMessage()); + } + + @Test + void fromTextRejectsOverLimitTextWhenConfiguredWithSmallLimit() throws IOException { + Path envFile = writeMinimalClientEnvWithInputLimit(TEST_MOCK_PROVIDER, 100); + A2ATClient client = new A2ATClient(envFile); + String overLimitText = "a".repeat(101); + + PromptGenerationException taskEx = assertThrows( + PromptGenerationException.class, + () -> client.generateTaskPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING_URI)); + PromptGenerationException authEx = assertThrows( + PromptGenerationException.class, + () -> client.generateAuthPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING_URI)); + PromptGenerationException notificationEx = assertThrows( + PromptGenerationException.class, + () -> client.generateNotificationPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING_URI)); + + assertEquals("input.text_too_long", taskEx.getCode()); + assertEquals("input.text_too_long", authEx.getCode()); + assertEquals("input.text_too_long", notificationEx.getCode()); + assertEquals(0, RecordingClient.REQUEST_COUNT.get(), "over-limit input must fail before any LLM call"); + } + + @Test + void generateTaskPromptRejectsOverLimitStringInputWithoutLlmCall() throws IOException { + Path envFile = writeMinimalClientEnvWithInputLimit(TEST_MOCK_PROVIDER, 100); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult result = client.generateTaskPrompt("a".repeat(101)); + + assertFalse(result.success()); + assertEquals("input.text_too_long", result.failure().code()); + assertEquals(0, RecordingClient.REQUEST_COUNT.get(), "over-limit input must fail before any LLM call"); + } + + private static Path writeMinimalClientEnvWithInputLimit(String provider, int maxTextChars) throws IOException { + Path envFile = writeMinimalClientEnv(provider); + Files.writeString( + envFile, "A2AT_INPUT_TEXT_MAX_CHARS=" + maxTextChars + "\n", java.nio.file.StandardOpenOption.APPEND); + return envFile; + } + + private static Path writeMinimalClientEnvWithoutRequiredSlots(String provider) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-env-no-required"); + Path promptRoot = tempDir.resolve("prompt_resources"); + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); + Path templatesDir = promptRoot + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("unconstrained") + .resolve("v1") + .resolve("zh-CN"); + Path slotsDir = promptRoot + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("unconstrained") + .resolve("v1") + .resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.createDirectories(scenariosDir); + Files.createDirectories(templatesDir); + Files.createDirectories(slotsDir); + + Files.writeString( + scenariosDir.resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "unconstrained", + "scenario_name": "Unconstrained", + "description": "No required slots", + "example": "Analyze unconstrained" + } + ] + } + """); + Files.writeString(templatesDir.resolve("template.md"), "Notes: {notes}\\nFault: {fault_description}"); + Files.writeString( + slotsDir.resolve("slot.json"), + """ + { + "required": [], + "properties": { + "notes": { + "type": "string" + }, + "fault_description": { + "type": "string" + } + } + } + """); + Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); + Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); + Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); + + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(provider)); + return envFile; + } + + private static Path writeMinimalLocalClientEnv() throws IOException { + return writeMinimalClientEnv(TEST_MOCK_PROVIDER); + } + + private static Path writeMinimalClasspathClientEnv() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-classpath-env"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=example-model +A2AT_LLM_BASE_URL=https://llm.example.test/v1 +A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """); + return envFile; + } + + private static Path writeMinimalClientEnv(String provider) throws IOException { + return writeMinimalClientEnv(Files.createTempDirectory("a2at-client-env"), provider); + } + + private static Path writeMinimalClientEnv(Path tempDir, String provider) throws IOException { + Path promptRoot = tempDir.resolve("prompt_resources"); + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); + Path templatesDir = promptRoot + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("ran-energy-saving") + .resolve("v1") + .resolve("zh-CN"); + Path slotsDir = promptRoot + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("ran-energy-saving") + .resolve("v1") + .resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.createDirectories(scenariosDir); + Files.createDirectories(templatesDir); + Files.createDirectories(slotsDir); + + Files.writeString( + scenariosDir.resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "ran-energy-saving", + "scenario_name": "Energy Saving", + "description": "Energy analysis", + "example": "Analyze site power" + } + ] + } + """); + Files.writeString(templatesDir.resolve("template.md"), "Site: {site}\\nTarget: {target}"); + Files.writeString( + slotsDir.resolve("slot.json"), + """ + { + "required": ["site", "target"], + "properties": { + "site": { + "type": "string" + }, + "target": { + "type": "string" + } + } + } + """); + Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); + Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); + Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); + + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(provider)); + return envFile; + } + + private static Path writeMinimalMultiScenarioClientEnv() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-multi-scenario"); + Path promptRoot = tempDir.resolve("prompt_resources"); + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); + Path templatesDir = promptRoot + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("ran-energy-saving") + .resolve("v1") + .resolve("zh-CN"); + Path slotsDir = promptRoot + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("ran-energy-saving") + .resolve("v1") + .resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.createDirectories(scenariosDir); + Files.createDirectories(templatesDir); + Files.createDirectories(slotsDir); + + Files.writeString( + scenariosDir.resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "ran-energy-saving", + "scenario_name": "Energy Saving", + "description": "Energy analysis", + "example": "Analyze site power" + }, + { + "scenario_code": "data-report", + "scenario_name": "Data Report", + "description": "Report generation", + "example": "Generate report" + } + ] + } + """); + Files.writeString(templatesDir.resolve("template.md"), "Site: {site}\\nTarget: {target}"); + Files.writeString( + slotsDir.resolve("slot.json"), + """ + { + "required": ["site", "target"], + "properties": { + "site": { + "type": "string" + }, + "target": { + "type": "string" + } + } + } + """); + Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); + Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); + Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); + + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(TEST_MOCK_PROVIDER)); + return envFile; + } + + public static final class RecordingClient implements LLMClient { + + public static final Map SLOT_OVERRIDES = new ConcurrentHashMap<>(); + public static final AtomicInteger REQUEST_COUNT = new AtomicInteger(0); + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + REQUEST_COUNT.incrementAndGet(); + return new LLMResponse(buildResponse(jsonSchema), config.model(), Map.of(), Map.of()); + } + + private static String buildResponse(Map jsonSchema) { + Object slotNames = jsonSchema.get("slotNames"); + if (slotNames instanceof List names) { + StringBuilder slots = new StringBuilder("{"); + for (int i = 0; i < names.size(); i++) { + if (i > 0) { + slots.append(","); + } + String name = String.valueOf(names.get(i)); + String value = SLOT_OVERRIDES.getOrDefault(name, "placeholder"); + slots.append("\"") + .append(name) + .append("\":\"") + .append(value) + .append("\""); + } + slots.append("}"); + return "{\"slots\":" + slots + ",\"slot_errors\":[]}"; + } + return "{\"matched\":true,\"scenario_code\":\"ran-energy-saving\",\"error_message\":null}"; + } + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index c2fe16d3..5b3908f1 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -1,1189 +1,1189 @@ -package net.openan.a2at.sdk.client.prompt.orchestration; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.ExtensionUriConstants; -import net.openan.a2at.sdk.core.model.InputLimitConfig; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; -import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; -import org.junit.jupiter.api.Test; - -class DefaultClientPromptGenerationOrchestratorTest { - - private static final PromptSlotSchemaLoader EMPTY_SCHEMA_LOADER = - (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()); - - private static final TemplateUri AUTH_DATABASE_READ = TemplateUri.of("Authorization-T", "database_read"); - - @Test - void generateTaskPromptLoadsTemplateAndRendersExtractedSlotsWhenScenarioIsMatched() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}\nNotes: {additional_notes}"); - FakeSlotValueExtractor slotValueExtractor = - new FakeSlotValueExtractor(Map.of("site", "Site A", "additional_notes", "critical")); - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - recognizer, - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - templateLoader, - slotValueExtractor, - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertTrue(result.success()); - assertEquals("Site: Site A\nNotes: critical", result.promptText()); - assertEquals("Analyze Site A.", recognizer.lastNormalizedInput); - assertEquals("ran-energy-saving", templateLoader.lastScenarioCode); - assertEquals("en-US", templateLoader.lastLanguage); - assertEquals("Analyze Site A.", slotValueExtractor.lastUserInput); - assertEquals("ran-energy-saving", slotValueExtractor.lastScenarioCode); - assertEquals("en-US", slotValueExtractor.lastLanguage); - } - - @Test - void generateTaskPromptReturnsFailureWhenScenarioIsNotMatched() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(false, null, "No scenario matched."), - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( - Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("scenario.not_matched", result.failure().code()); - assertEquals("scenario", result.failure().stage()); - } - - @Test - void generateTaskPromptReturnsFailureWhenTemplateIsMissing() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "ran-energy-saving", null), - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - (scenarioCode, language) -> { - throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); - }, - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( - Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("template.not_found", result.failure().code()); - assertEquals("generation", result.failure().stage()); - } - - @Test - void generateTaskPromptReturnsFailureWhenRenderingFails() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "ran-energy-saving", null), - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - (scenarioCode, language) -> "Scenario: {scenario}\nMissing: {{missing_slot}}", - (userInput, scenarioCode, language) -> - new StructuredSlotExtractionResult(Map.of("scenario", scenarioCode), List.of()), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("template.render_failed", result.failure().code()); - assertEquals("generation", result.failure().stage()); - } - - @Test - void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissingForRequestedLanguage() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> { - throw new ResourceNotFoundException( - "Prompt resource file does not exist.", - "prompt_resources/prompts/scenario_recognition/zh-CN/system.md"); - }, - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "zh-CN", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( - Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("template.load_failed", result.failure().code()); - assertEquals("generation", result.failure().stage()); - } - - private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestrator( - ScenarioRecognizer recognizer, - PromptTemplateTextLoader templateLoader, - PromptSlotValueExtractor slotValueExtractor) { - return new DefaultClientPromptGenerationOrchestrator( - recognizer, - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - templateLoader, - slotValueExtractor, - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - } - - private static final class RecordingScenarioRecognizer implements ScenarioRecognizer { - private int invocationCount; - private String lastNormalizedInput; - - @Override - public ScenarioRecognitionResult recognize( - String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { - this.invocationCount++; - this.lastNormalizedInput = normalizedInput; - return new ScenarioRecognitionResult(true, "ran-energy-saving", null); - } - } - - private static final class CountingFailingTemplateLoader implements PromptTemplateTextLoader { - private int loadCount; - - @Override - public String loadTemplate(String scenarioCode, String language) { - this.loadCount++; - throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); - } - } - - private static final class FakeTemplateLoader implements PromptTemplateTextLoader { - private final String templateText; - private String lastScenarioCode; - private String lastLanguage; - - private FakeTemplateLoader(String templateText) { - this.templateText = templateText; - } - - @Override - public String loadTemplate(String scenarioCode, String language) { - this.lastScenarioCode = scenarioCode; - this.lastLanguage = language; - return templateText; - } - } - - private static final class FakeSlotValueExtractor implements PromptSlotValueExtractor { - private final Map slots; - private Object lastUserInput; - private String lastScenarioCode; - private String lastLanguage; - - private FakeSlotValueExtractor(Map slots) { - this.slots = slots; - } - - @Override - public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { - this.lastUserInput = userInput; - this.lastScenarioCode = scenarioCode; - this.lastLanguage = language; - return new StructuredSlotExtractionResult(slots, List.of()); - } - } - - private static final class FakeSlotValueExtractorWithSchema implements PromptSlotValueExtractor { - private final Map slots; - private Object lastUserInput; - private String lastScenarioCode; - private String lastLanguage; - private Map lastDataSchema; - - private FakeSlotValueExtractorWithSchema(Map slots) { - this.slots = slots; - } - - @Override - public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { - this.lastUserInput = userInput; - this.lastScenarioCode = scenarioCode; - this.lastLanguage = language; - return new StructuredSlotExtractionResult(slots, List.of()); - } - - @Override - public StructuredSlotExtractionResult extractSlots( - Object userInput, String scenarioCode, String language, Map dataSchema) { - this.lastUserInput = userInput; - this.lastScenarioCode = scenarioCode; - this.lastLanguage = language; - this.lastDataSchema = dataSchema; - return new StructuredSlotExtractionResult(slots, List.of()); - } - } - - private static final class FailingSlotValueExtractor implements PromptSlotValueExtractor { - private final RuntimeException exception; - - private FailingSlotValueExtractor(RuntimeException exception) { - this.exception = exception; - } - - @Override - public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { - return new StructuredSlotExtractionResult(Map.of(), List.of()); - } - - @Override - public StructuredSlotExtractionResult extractSlots( - Object userInput, String scenarioCode, String language, Map dataSchema) { - throw exception; - } - } - - // --- MetadataContent pipeline tests (generateFromTemplateUriWithMetadata / FromText) --- - - @Test - void generateFromTemplateUriWithMetadataReturnsMetadataContentOnSuccess() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractor slotValueExtractor = new FakeSlotValueExtractor(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - MetadataContent result = - orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); - - assertEquals(StandardTemplates.ENERGY_SAVING.uri(), result.templateUri()); - assertEquals("Site: Site A", result.promptText()); - assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); - } - - @Test - void generateFromTemplateUriWithMetadataScenarioRecognizerIsNotInvoked() { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - recognizer, templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); - - orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); - - assertEquals(0, recognizer.invocationCount); - } - - @Test - void generateFromTemplateUriWithMetadataThrowsOnNullTemplateUri() { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); - - NullPointerException ex = assertThrows( - NullPointerException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", null)); - assertFalse(A2ATError.class.isInstance(ex), "null template URI must stay outside the A2ATError tree"); - assertEquals(0, recognizer.invocationCount); - assertEquals(0, templateLoader.loadCount); - } - - @Test - void generateTaskPromptFromTextRejectsNullText() { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); - - assertThrows( - NullPointerException.class, - () -> orchestrator.generateTaskPromptFromText(null, StandardTemplates.ENERGY_SAVING)); - } - - @Test - void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - (scenarioCode, language) -> { - throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); - }, - new FakeSlotValueExtractor(Map.of("site", "Site A"))); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("template.not_found", ex.getCode()); - } - - @Test - void generateFromTemplateUriWithMetadataPropagatesRenderException() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}\nMissing: {{missing_slot}}"), - new FakeSlotValueExtractor(Map.of("site", "Site A"))); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("template.render_failed", ex.getCode()); - } - - @Test - void generateFromTemplateUriWithMetadataPropagatesLlmRuntimeError() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}"), - (userInput, scenarioCode, language) -> { - throw new LLMRuntimeError("LLM invocation failed."); - }); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("llm.invocation_failed", ex.getCode()); - } - - // --- MetadataContent pipeline tests (generateFromDataWithSchema) --- - - @Test - void generateFromDataWithSchemaReturnsMetadataContentOnSuccess() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); - - assertEquals(StandardTemplates.ENERGY_SAVING.uri(), result.templateUri()); - assertEquals("Site: Site A", result.promptText()); - assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); - } - - @Test - void generateFromDataWithSchemaPassesSchemaToExtractor() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - Map schema = Map.of("site", "string", "count", "number"); - orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), schema, StandardTemplates.ENERGY_SAVING); - - assertEquals(schema, slotValueExtractor.lastDataSchema); - assertEquals(StandardTemplates.ENERGY_SAVING.uri(), slotValueExtractor.lastScenarioCode); - } - - @Test - void generateTaskPromptFromDataWithSchemaRejectsNullData() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - NullPointerException ex = assertThrows( - NullPointerException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - null, Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); - assertTrue(ex.getMessage().contains("data")); - } - - @Test - void generateTaskPromptFromDataWithSchemaRejectsNullSchema() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - NullPointerException ex = assertThrows( - NullPointerException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), null, StandardTemplates.ENERGY_SAVING)); - assertEquals("schema", ex.getMessage()); - } - - @Test - void generateTaskPromptFromDataWithSchemaRejectsEmptySchema() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - IllegalArgumentException ex = assertThrows( - IllegalArgumentException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING)); - assertTrue(ex.getMessage().contains("Data schema must not be empty")); - } - - @Test - void generateFromDataWithSchemaPropagatesResourceNotFoundException() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - (scenarioCode, language) -> { - throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); - }, - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); - assertEquals("template.not_found", ex.getCode()); - } - - @Test - void generateFromDataWithSchemaPropagatesRenderException() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}\nMissing: {{missing_slot}}"), - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); - assertEquals("template.render_failed", ex.getCode()); - } - - @Test - void generateFromDataWithSchemaPropagatesLlmRuntimeError() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}"), - new FailingSlotValueExtractor(new LLMRuntimeError("LLM invocation failed."))); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); - assertEquals("llm.invocation_failed", ex.getCode()); - } - - @Test - void generateFromDataWithSchemaPropagatesResourceNotFoundExceptionFromExtractor() { - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}"), - new FailingSlotValueExtractor( - new ResourceNotFoundException("Slot schema file does not exist.", "ran-energy-saving"))); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); - assertEquals("slot.schema_not_found", ex.getCode()); - } - - // --- Public entry point tests --- - - @Test - void eachMetadataEntryPointReturnsCorrectMetadataContent() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - MetadataContent taskText = - orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); - MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); - MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", AUTH_DATABASE_READ); - MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), AUTH_DATABASE_READ); - MetadataContent notifText = - orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); - MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); - - assertTrue(taskText.promptText().contains("Site A")); - assertTrue(taskData.promptText().contains("Site A")); - assertTrue(authText.promptText().contains("Site A")); - assertTrue(authData.promptText().contains("Site A")); - assertTrue(notifText.promptText().contains("Site A")); - assertTrue(notifData.promptText().contains("Site A")); - } - - @Test - void eachMetadataEntryPointUsesCorrectExtensionUri() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - templateLoader, - new FakeSlotValueExtractor(Map.of("site", "Site A"))); - - MetadataContent taskText = - orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); - MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); - MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", AUTH_DATABASE_READ); - MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), AUTH_DATABASE_READ); - MetadataContent notifText = - orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); - MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( - Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); - - assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskText.extensionUri()); - assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskData.extensionUri()); - assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authText.extensionUri()); - assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authData.extensionUri()); - assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notifText.extensionUri()); - assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notifData.extensionUri()); - } - - @Test - void authorizationMetadataEntryPointsPassTemplateUriIdentifier() { - FakeTemplateLoader templateLoader = new FakeTemplateLoader("Access: {scope}"); - FakeSlotValueExtractorWithSchema slotValueExtractor = - new FakeSlotValueExtractorWithSchema(Map.of("scope", "read")); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); - - MetadataContent nlResult = orchestrator.generateAuthPromptFromText("Grant read access.", AUTH_DATABASE_READ); - - assertEquals("Authorization-T/database_read/v1", nlResult.templateUri()); - assertEquals("Access: read", nlResult.promptText()); - assertEquals("Authorization-T/database_read/v1", templateLoader.lastScenarioCode); - assertEquals("Authorization-T/database_read/v1", slotValueExtractor.lastScenarioCode); - assertEquals("Grant read access.", slotValueExtractor.lastUserInput); - - MetadataContent jsonResult = orchestrator.generateAuthPromptFromDataWithSchema( - Map.of("scope", "read"), Map.of("scope", "string"), AUTH_DATABASE_READ); - - assertEquals("Authorization-T/database_read/v1", jsonResult.templateUri()); - assertEquals("Authorization-T/database_read/v1", templateLoader.lastScenarioCode); - assertEquals(Map.of("scope", "read"), slotValueExtractor.lastUserInput); - assertEquals(Map.of("scope", "string"), slotValueExtractor.lastDataSchema); - } - - // --- slot validation tests --- - - @Test - void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition( - "site", true, "string", null, null, null, null, null, "Site name", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of()), - new TaskPromptRenderer(), - schemaLoader); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot.not_provided", ex.getCode()); - List failed = ex.failedParameters(); - assertFalse(failed.isEmpty()); - assertEquals("site", failed.get(0).slotName()); - assertEquals("slot.not_provided", failed.get(0).code()); - } - - @Test - void validateRequiredSlotsThrowsSlotValidationErrorWhenMultipleRequiredSlotsMissing() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of( - new PromptSlotDefinition( - "site", true, "string", null, null, null, null, null, "Site name", null), - new PromptSlotDefinition( - "target", true, "string", null, null, null, null, null, "Target", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}\nTarget: {target}"), - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - schemaLoader); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot.not_provided", ex.getCode()); - assertEquals(1, ex.failedParameters().size()); - assertEquals("target", ex.failedParameters().get(0).slotName()); - } - - @Test - void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundWhenSchemaLoadingFails() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - (scenarioCode, language) -> { - throw new A2ATError("Schema file is corrupt."); - }); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot.schema_not_found", ex.getCode()); - } - - @Test - void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundFromExtractor() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - (userInput, scenarioCode, language) -> { - throw new ResourceNotFoundException("Slot schema file does not exist.", scenarioCode); - }, - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot.schema_not_found", ex.getCode()); - } - - @Test - void generateFromTemplateUriWithMetadataThrowsPromptResourceLoadError() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - (scenarioCode, language) -> { - throw new A2ATError("Template file is corrupt."); - }, - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("template.load_failed", ex.getCode()); - } - - @Test - void generateFromTemplateUriWithMetadataThrowsLlmInvocationFailedFromA2ATError() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - (userInput, scenarioCode, language) -> { - throw new A2ATError("Unparseable LLM response."); - }, - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("llm.invocation_failed", ex.getCode()); - } - - @Test - void validateRequiredSlotsThrowsSlotValidationErrorWhenSlotValueIsEmptyString() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition( - "site", true, "string", null, null, null, null, null, "Site name", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "")), - new TaskPromptRenderer(), - schemaLoader); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot.not_provided", ex.getCode()); - assertEquals(1, ex.failedParameters().size()); - assertEquals("site", ex.failedParameters().get(0).slotName()); - assertEquals("slot.not_provided", ex.failedParameters().get(0).code()); - } - - @Test - void validateRequiredSlotsThrowsSlotValidationErrorWhenSlotValueIsBlankString() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition( - "site", true, "string", null, null, null, null, null, "Site name", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", " ")), - new TaskPromptRenderer(), - schemaLoader); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot.not_provided", ex.getCode()); - assertEquals(1, ex.failedParameters().size()); - assertEquals("site", ex.failedParameters().get(0).slotName()); - } - - @Test - void validateRequiredSlotsSucceedsWhenSchemaHasNoRequiredSlotsAndAllSlotsAreEmpty() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition( - "site", false, "string", null, null, null, null, null, "Site name", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of()), - new TaskPromptRenderer(), - schemaLoader); - - MetadataContent result = - orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); - assertNotNull(result); - assertNotNull(result.promptText()); - } - - @Test - void validateRequiredSlotsIsNotInvokedInGenerateTaskPromptPipeline() { - PromptSlotSchemaLoader failingSchemaLoader = (scenarioCode, language) -> { - throw new A2ATError("Schema loading should not be triggered in generateTaskPrompt pipeline."); - }; - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "energy_saving", null), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - (scenarioCode, language) -> "Site: {site}", - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - failingSchemaLoader); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - assertTrue(result.success()); - assertEquals("Site: Site A", result.promptText()); - } - - @Test - void generateTaskPromptNormalizesNullInputToString() { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - recognizer, - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt(null); - - assertTrue(result.success()); - assertEquals("null", recognizer.lastNormalizedInput); - assertEquals("Site: Site A", result.promptText()); - } - - @Test - void generateTaskPromptReturnsFailureWithEmptyScenarioList() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> { - assertEquals(0, scenarios.size()); - return new ScenarioRecognitionResult(false, null, "No scenarios available."); - }, - List.of(), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of()), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("scenario.not_matched", result.failure().code()); - assertEquals("scenario", result.failure().stage()); - } - - @Test - void generateTaskPromptMapsLlmRuntimeErrorFromExtractorToResultFailure() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "ran-energy-saving", null), - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - (scenarioCode, language) -> "Site: {site}", - (userInput, scenarioCode, language) -> { - throw new LLMRuntimeError("LLM invocation failed."); - }, - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("llm.invocation_failed", result.failure().code()); - assertEquals("generation", result.failure().stage()); - } - - @Test - void generateTaskPromptMapsLlmResponseContractErrorToResultFailure() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "ran-energy-saving", null), - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - (scenarioCode, language) -> "Site: {site}", - (userInput, scenarioCode, language) -> { - throw new LLMRuntimeError("openai returned invalid json: unexpected token"); - }, - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("llm.response_invalid", result.failure().code()); - assertEquals( - "The LLM response is invalid (step: slot extraction); please retry", - result.failure().message()); - } - - @Test - void generateTaskPromptSurfacesExtractionSlotErrorsAsFailure() { - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - recognizer, - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - new FakeTemplateLoader("Site: {site}"), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( - Map.of("site", "Site A"), - List.of(new StructuredSlotValidationError("subscriptionCondition", "slot.not_provided", null))), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("slot.not_provided", result.failure().code()); - assertEquals( - "'subscriptionCondition' is not provided in the input.", - result.failure().message()); - assertEquals("generation", result.failure().stage()); - } - - @Test - void generateTaskPromptMapsUnknownExtractionSlotErrorCodeToFallback() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - new FakeTemplateLoader("Site: {site}"), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( - Map.of("site", "Site A"), - List.of(new StructuredSlotValidationError("extra_field", "unexpected_field", null))), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("slot.rule_violation", result.failure().code()); - } - - @Test - void generateTaskPromptMapsLegacyExtractionSlotErrorCodes() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition( - "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "en-US", - "Identify the best matching scenario.", - "Choose from the provided scenario list.", - new FakeTemplateLoader("Site: {site}"), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( - Map.of("site", "Site A"), - List.of( - new StructuredSlotValidationError("site", "missing_input", null), - new StructuredSlotValidationError("site", "invalid_value", null))), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); - - assertFalse(result.success()); - assertNotNull(result.failure()); - assertEquals("slot.not_provided", result.failure().code()); - assertTrue(result.failure().message().contains("; "), "both mapped messages must be reported"); - } - - @Test - void generateFromTemplateUriWithMetadataEmptyInputProducesSlotValidationError() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition( - "site", true, "string", null, null, null, null, null, "Site name", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of()), - new TaskPromptRenderer(), - schemaLoader); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot.not_provided", ex.getCode()); - assertEquals(1, ex.failedParameters().size()); - assertEquals("site", ex.failedParameters().get(0).slotName()); - } - - @Test - void validateRequiredSlotsTreatsNullSlotValueAsMissing() { - PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition( - "site", true, "string", null, null, null, null, null, "Site name", null))); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Collections.singletonMap("site", null)), - new TaskPromptRenderer(), - schemaLoader); - - PromptGenerationException ex = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); - assertEquals("slot.not_provided", ex.getCode()); - assertEquals(1, ex.failedParameters().size()); - assertEquals("site", ex.failedParameters().get(0).slotName()); - assertEquals("slot.not_provided", ex.failedParameters().get(0).code()); - } - - // --- free-text input length limit tests --- - - @Test - void fromTextEntryPointsRejectTextOverTheDefaultLimitBeforeAnyLlmCall() { - String overLimitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1); - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); - DefaultClientPromptGenerationOrchestrator orchestrator = - newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); - - PromptGenerationException taskEx = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); - PromptGenerationException authEx = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateAuthPromptFromText(overLimitText, AUTH_DATABASE_READ)); - PromptGenerationException notificationEx = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateNotificationPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); - - assertEquals("input.text_too_long", taskEx.getCode()); - assertEquals("input.text_too_long", authEx.getCode()); - assertEquals("input.text_too_long", notificationEx.getCode()); - assertTrue(taskEx.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1))); - assertEquals(0, recognizer.invocationCount, "over-limit input must fail before any LLM call"); - assertEquals(0, templateLoader.loadCount, "over-limit input must fail before template loading"); - } - - @Test - void fromTextAcceptsTextAtExactlyTheDefaultLimit() { - String limitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); - DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( - new RecordingScenarioRecognizer(), - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "Site A"))); - - MetadataContent result = orchestrator.generateTaskPromptFromText(limitText, StandardTemplates.ENERGY_SAVING); - - assertEquals("Site: Site A", result.promptText()); - } - - @Test - void fromTextRejectsTextOverOneConfiguredLimit() { - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER, - 10); - - PromptGenerationException overEx = assertThrows( - PromptGenerationException.class, - () -> orchestrator.generateTaskPromptFromText("a".repeat(11), StandardTemplates.ENERGY_SAVING)); - assertEquals("input.text_too_long", overEx.getCode()); - - MetadataContent atLimit = - orchestrator.generateTaskPromptFromText("a".repeat(10), StandardTemplates.ENERGY_SAVING); - assertEquals("Site: Site A", atLimit.promptText()); - } - - @Test - void generateTaskPromptReturnsFailureWhenStringInputExceedsTheDefaultLimit() { - String overLimitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1); - RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - recognizer, - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt(overLimitText); - - assertFalse(result.success()); - assertEquals("input.text_too_long", result.failure().code()); - assertEquals("input", result.failure().stage()); - assertEquals(0, recognizer.invocationCount, "over-limit input must fail before any LLM call"); - } - - @Test - void generateTaskPromptDoesNotCheckLengthForMapInput() { - Map mapInputWithLongStringValue = - Map.of("notes", "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1)); - DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( - new RecordingScenarioRecognizer(), - List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), - "en-US", - "", - "", - new FakeTemplateLoader("Site: {site}"), - new FakeSlotValueExtractor(Map.of("site", "Site A")), - new TaskPromptRenderer(), - EMPTY_SCHEMA_LOADER); - - PromptGenerationResult result = orchestrator.generateTaskPrompt(mapInputWithLongStringValue); - - assertTrue(result.success(), "map input must not be length-checked"); - assertEquals("Site: Site A", result.promptText()); - } -} +package net.openan.a2at.sdk.client.prompt.orchestration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; +import org.junit.jupiter.api.Test; + +class DefaultClientPromptGenerationOrchestratorTest { + + private static final PromptSlotSchemaLoader EMPTY_SCHEMA_LOADER = + (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()); + + private static final TemplateUri AUTH_DATABASE_READ = TemplateUri.of("Authorization-T", "database_read"); + + @Test + void generateTaskPromptLoadsTemplateAndRendersExtractedSlotsWhenScenarioIsMatched() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}\nNotes: {additional_notes}"); + FakeSlotValueExtractor slotValueExtractor = + new FakeSlotValueExtractor(Map.of("site", "Site A", "additional_notes", "critical")); + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + templateLoader, + slotValueExtractor, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertTrue(result.success()); + assertEquals("Site: Site A\nNotes: critical", result.promptText()); + assertEquals("Analyze Site A.", recognizer.lastNormalizedInput); + assertEquals("ran-energy-saving", templateLoader.lastScenarioCode); + assertEquals("en-US", templateLoader.lastLanguage); + assertEquals("Analyze Site A.", slotValueExtractor.lastUserInput); + assertEquals("ran-energy-saving", slotValueExtractor.lastScenarioCode); + assertEquals("en-US", slotValueExtractor.lastLanguage); + } + + @Test + void generateTaskPromptReturnsFailureWhenScenarioIsNotMatched() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(false, null, "No scenario matched."), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("scenario.not_matched", result.failure().code()); + assertEquals("scenario", result.failure().stage()); + } + + @Test + void generateTaskPromptReturnsFailureWhenTemplateIsMissing() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(true, "ran-energy-saving", null), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> { + throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); + }, + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("template.not_found", result.failure().code()); + assertEquals("generation", result.failure().stage()); + } + + @Test + void generateTaskPromptReturnsFailureWhenRenderingFails() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(true, "ran-energy-saving", null), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Scenario: {scenario}\nMissing: {{missing_slot}}", + (userInput, scenarioCode, language) -> + new StructuredSlotExtractionResult(Map.of("scenario", scenarioCode), List.of()), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("template.render_failed", result.failure().code()); + assertEquals("generation", result.failure().stage()); + } + + @Test + void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissingForRequestedLanguage() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> { + throw new ResourceNotFoundException( + "Prompt resource file does not exist.", + "prompt_resources/prompts/scenario_recognition/zh-CN/system.md"); + }, + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "zh-CN", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), List.of()), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("template.load_failed", result.failure().code()); + assertEquals("generation", result.failure().stage()); + } + + private static DefaultClientPromptGenerationOrchestrator newTemplateUriOrchestrator( + ScenarioRecognizer recognizer, + PromptTemplateTextLoader templateLoader, + PromptSlotValueExtractor slotValueExtractor) { + return new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + templateLoader, + slotValueExtractor, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + } + + private static final class RecordingScenarioRecognizer implements ScenarioRecognizer { + private int invocationCount; + private String lastNormalizedInput; + + @Override + public ScenarioRecognitionResult recognize( + String normalizedInput, List scenarios, String systemPrompt, String userPrompt) { + this.invocationCount++; + this.lastNormalizedInput = normalizedInput; + return new ScenarioRecognitionResult(true, "ran-energy-saving", null); + } + } + + private static final class CountingFailingTemplateLoader implements PromptTemplateTextLoader { + private int loadCount; + + @Override + public String loadTemplate(String scenarioCode, String language) { + this.loadCount++; + throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); + } + } + + private static final class FakeTemplateLoader implements PromptTemplateTextLoader { + private final String templateText; + private String lastScenarioCode; + private String lastLanguage; + + private FakeTemplateLoader(String templateText) { + this.templateText = templateText; + } + + @Override + public String loadTemplate(String scenarioCode, String language) { + this.lastScenarioCode = scenarioCode; + this.lastLanguage = language; + return templateText; + } + } + + private static final class FakeSlotValueExtractor implements PromptSlotValueExtractor { + private final Map slots; + private Object lastUserInput; + private String lastScenarioCode; + private String lastLanguage; + + private FakeSlotValueExtractor(Map slots) { + this.slots = slots; + } + + @Override + public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { + this.lastUserInput = userInput; + this.lastScenarioCode = scenarioCode; + this.lastLanguage = language; + return new StructuredSlotExtractionResult(slots, List.of()); + } + } + + private static final class FakeSlotValueExtractorWithSchema implements PromptSlotValueExtractor { + private final Map slots; + private Object lastUserInput; + private String lastScenarioCode; + private String lastLanguage; + private Map lastDataSchema; + + private FakeSlotValueExtractorWithSchema(Map slots) { + this.slots = slots; + } + + @Override + public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { + this.lastUserInput = userInput; + this.lastScenarioCode = scenarioCode; + this.lastLanguage = language; + return new StructuredSlotExtractionResult(slots, List.of()); + } + + @Override + public StructuredSlotExtractionResult extractSlots( + Object userInput, String scenarioCode, String language, Map dataSchema) { + this.lastUserInput = userInput; + this.lastScenarioCode = scenarioCode; + this.lastLanguage = language; + this.lastDataSchema = dataSchema; + return new StructuredSlotExtractionResult(slots, List.of()); + } + } + + private static final class FailingSlotValueExtractor implements PromptSlotValueExtractor { + private final RuntimeException exception; + + private FailingSlotValueExtractor(RuntimeException exception) { + this.exception = exception; + } + + @Override + public StructuredSlotExtractionResult extractSlots(Object userInput, String scenarioCode, String language) { + return new StructuredSlotExtractionResult(Map.of(), List.of()); + } + + @Override + public StructuredSlotExtractionResult extractSlots( + Object userInput, String scenarioCode, String language, Map dataSchema) { + throw exception; + } + } + + // --- MetadataContent pipeline tests (generateFromTemplateUriWithMetadata / FromText) --- + + @Test + void generateFromTemplateUriWithMetadataReturnsMetadataContentOnSuccess() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractor slotValueExtractor = new FakeSlotValueExtractor(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent result = + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), result.templateUri()); + assertEquals("Site: Site A", result.promptText()); + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); + } + + @Test + void generateFromTemplateUriWithMetadataScenarioRecognizerIsNotInvoked() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + recognizer, templateLoader, new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + + assertEquals(0, recognizer.invocationCount); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsOnNullTemplateUri() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); + + NullPointerException ex = assertThrows( + NullPointerException.class, () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", null)); + assertFalse(A2ATError.class.isInstance(ex), "null template URI must stay outside the A2ATError tree"); + assertEquals(0, recognizer.invocationCount); + assertEquals(0, templateLoader.loadCount); + } + + @Test + void generateTaskPromptFromTextRejectsNullText() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); + + assertThrows( + NullPointerException.class, + () -> orchestrator.generateTaskPromptFromText(null, StandardTemplates.ENERGY_SAVING)); + } + + @Test + void generateFromTemplateUriWithMetadataPropagatesResourceNotFoundException() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + (scenarioCode, language) -> { + throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); + }, + new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("template.not_found", ex.getCode()); + } + + @Test + void generateFromTemplateUriWithMetadataPropagatesRenderException() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}\nMissing: {{missing_slot}}"), + new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("template.render_failed", ex.getCode()); + } + + @Test + void generateFromTemplateUriWithMetadataPropagatesLlmRuntimeError() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language) -> { + throw new LLMRuntimeError("LLM invocation failed."); + }); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("llm.invocation_failed", ex.getCode()); + } + + // --- MetadataContent pipeline tests (generateFromDataWithSchema) --- + + @Test + void generateFromDataWithSchemaReturnsMetadataContentOnSuccess() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent result = orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); + + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), result.templateUri()); + assertEquals("Site: Site A", result.promptText()); + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, result.extensionUri()); + } + + @Test + void generateFromDataWithSchemaPassesSchemaToExtractor() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + Map schema = Map.of("site", "string", "count", "number"); + orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), schema, StandardTemplates.ENERGY_SAVING); + + assertEquals(schema, slotValueExtractor.lastDataSchema); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), slotValueExtractor.lastScenarioCode); + } + + @Test + void generateTaskPromptFromDataWithSchemaRejectsNullData() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + NullPointerException ex = assertThrows( + NullPointerException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + null, Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); + assertTrue(ex.getMessage().contains("data")); + } + + @Test + void generateTaskPromptFromDataWithSchemaRejectsNullSchema() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + NullPointerException ex = assertThrows( + NullPointerException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), null, StandardTemplates.ENERGY_SAVING)); + assertEquals("schema", ex.getMessage()); + } + + @Test + void generateTaskPromptFromDataWithSchemaRejectsEmptySchema() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of(), StandardTemplates.ENERGY_SAVING)); + assertTrue(ex.getMessage().contains("Data schema must not be empty")); + } + + @Test + void generateFromDataWithSchemaPropagatesResourceNotFoundException() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + (scenarioCode, language) -> { + throw new ResourceNotFoundException("Prompt resource file does not exist.", scenarioCode); + }, + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); + assertEquals("template.not_found", ex.getCode()); + } + + @Test + void generateFromDataWithSchemaPropagatesRenderException() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}\nMissing: {{missing_slot}}"), + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A"))); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); + assertEquals("template.render_failed", ex.getCode()); + } + + @Test + void generateFromDataWithSchemaPropagatesLlmRuntimeError() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}"), + new FailingSlotValueExtractor(new LLMRuntimeError("LLM invocation failed."))); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); + assertEquals("llm.invocation_failed", ex.getCode()); + } + + @Test + void generateFromDataWithSchemaPropagatesResourceNotFoundExceptionFromExtractor() { + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}"), + new FailingSlotValueExtractor( + new ResourceNotFoundException("Slot schema file does not exist.", "ran-energy-saving"))); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.schema_not_found", ex.getCode()); + } + + // --- Public entry point tests --- + + @Test + void eachMetadataEntryPointReturnsCorrectMetadataContent() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("site", "Site A")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent taskText = + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); + MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", AUTH_DATABASE_READ); + MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), AUTH_DATABASE_READ); + MetadataContent notifText = + orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); + MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); + + assertTrue(taskText.promptText().contains("Site A")); + assertTrue(taskData.promptText().contains("Site A")); + assertTrue(authText.promptText().contains("Site A")); + assertTrue(authData.promptText().contains("Site A")); + assertTrue(notifText.promptText().contains("Site A")); + assertTrue(notifData.promptText().contains("Site A")); + } + + @Test + void eachMetadataEntryPointUsesCorrectExtensionUri() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Site: {site}"); + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + templateLoader, + new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + MetadataContent taskText = + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + MetadataContent taskData = orchestrator.generateTaskPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); + MetadataContent authText = orchestrator.generateAuthPromptFromText("Grant access.", AUTH_DATABASE_READ); + MetadataContent authData = orchestrator.generateAuthPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), AUTH_DATABASE_READ); + MetadataContent notifText = + orchestrator.generateNotificationPromptFromText("Report finished.", StandardTemplates.ENERGY_SAVING); + MetadataContent notifData = orchestrator.generateNotificationPromptFromDataWithSchema( + Map.of("site", "Site A"), Map.of("site", "string"), StandardTemplates.ENERGY_SAVING); + + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskText.extensionUri()); + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskData.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authText.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authData.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notifText.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notifData.extensionUri()); + } + + @Test + void authorizationMetadataEntryPointsPassTemplateUriIdentifier() { + FakeTemplateLoader templateLoader = new FakeTemplateLoader("Access: {scope}"); + FakeSlotValueExtractorWithSchema slotValueExtractor = + new FakeSlotValueExtractorWithSchema(Map.of("scope", "read")); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(new RecordingScenarioRecognizer(), templateLoader, slotValueExtractor); + + MetadataContent nlResult = orchestrator.generateAuthPromptFromText("Grant read access.", AUTH_DATABASE_READ); + + assertEquals("Authorization-T/database_read/v1", nlResult.templateUri()); + assertEquals("Access: read", nlResult.promptText()); + assertEquals("Authorization-T/database_read/v1", templateLoader.lastScenarioCode); + assertEquals("Authorization-T/database_read/v1", slotValueExtractor.lastScenarioCode); + assertEquals("Grant read access.", slotValueExtractor.lastUserInput); + + MetadataContent jsonResult = orchestrator.generateAuthPromptFromDataWithSchema( + Map.of("scope", "read"), Map.of("scope", "string"), AUTH_DATABASE_READ); + + assertEquals("Authorization-T/database_read/v1", jsonResult.templateUri()); + assertEquals("Authorization-T/database_read/v1", templateLoader.lastScenarioCode); + assertEquals(Map.of("scope", "read"), slotValueExtractor.lastUserInput); + assertEquals(Map.of("scope", "string"), slotValueExtractor.lastDataSchema); + } + + // --- slot validation tests --- + + @Test + void validateRequiredSlotsThrowsSlotValidationErrorWhenRequiredSlotsMissing() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of()), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.not_provided", ex.getCode()); + List failed = ex.failedParameters(); + assertFalse(failed.isEmpty()); + assertEquals("site", failed.get(0).slotName()); + assertEquals("slot.not_provided", failed.get(0).code()); + } + + @Test + void validateRequiredSlotsThrowsSlotValidationErrorWhenMultipleRequiredSlotsMissing() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of( + new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, "Site name", null), + new PromptSlotDefinition( + "target", true, "string", null, null, null, null, null, "Target", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}\nTarget: {target}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.not_provided", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("target", ex.failedParameters().get(0).slotName()); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundWhenSchemaLoadingFails() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + (scenarioCode, language) -> { + throw new A2ATError("Schema file is corrupt."); + }); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.schema_not_found", ex.getCode()); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsSlotSchemaNotFoundFromExtractor() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language) -> { + throw new ResourceNotFoundException("Slot schema file does not exist.", scenarioCode); + }, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.schema_not_found", ex.getCode()); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsPromptResourceLoadError() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + (scenarioCode, language) -> { + throw new A2ATError("Template file is corrupt."); + }, + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("template.load_failed", ex.getCode()); + } + + @Test + void generateFromTemplateUriWithMetadataThrowsLlmInvocationFailedFromA2ATError() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language) -> { + throw new A2ATError("Unparseable LLM response."); + }, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("llm.invocation_failed", ex.getCode()); + } + + @Test + void validateRequiredSlotsThrowsSlotValidationErrorWhenSlotValueIsEmptyString() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "")), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.not_provided", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("site", ex.failedParameters().get(0).slotName()); + assertEquals("slot.not_provided", ex.failedParameters().get(0).code()); + } + + @Test + void validateRequiredSlotsThrowsSlotValidationErrorWhenSlotValueIsBlankString() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", " ")), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.not_provided", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("site", ex.failedParameters().get(0).slotName()); + } + + @Test + void validateRequiredSlotsSucceedsWhenSchemaHasNoRequiredSlotsAndAllSlotsAreEmpty() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", false, "string", null, null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of()), + new TaskPromptRenderer(), + schemaLoader); + + MetadataContent result = + orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING); + assertNotNull(result); + assertNotNull(result.promptText()); + } + + @Test + void validateRequiredSlotsIsNotInvokedInGenerateTaskPromptPipeline() { + PromptSlotSchemaLoader failingSchemaLoader = (scenarioCode, language) -> { + throw new A2ATError("Schema loading should not be triggered in generateTaskPrompt pipeline."); + }; + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(true, "energy_saving", null), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + (scenarioCode, language) -> "Site: {site}", + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + failingSchemaLoader); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + assertTrue(result.success()); + assertEquals("Site: Site A", result.promptText()); + } + + @Test + void generateTaskPromptNormalizesNullInputToString() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt(null); + + assertTrue(result.success()); + assertEquals("null", recognizer.lastNormalizedInput); + assertEquals("Site: Site A", result.promptText()); + } + + @Test + void generateTaskPromptReturnsFailureWithEmptyScenarioList() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> { + assertEquals(0, scenarios.size()); + return new ScenarioRecognitionResult(false, null, "No scenarios available."); + }, + List.of(), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Scenario: {scenario}\nInput: {input}", + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of()), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("scenario.not_matched", result.failure().code()); + assertEquals("scenario", result.failure().stage()); + } + + @Test + void generateTaskPromptMapsLlmRuntimeErrorFromExtractorToResultFailure() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(true, "ran-energy-saving", null), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Site: {site}", + (userInput, scenarioCode, language) -> { + throw new LLMRuntimeError("LLM invocation failed."); + }, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("llm.invocation_failed", result.failure().code()); + assertEquals("generation", result.failure().stage()); + } + + @Test + void generateTaskPromptMapsLlmResponseContractErrorToResultFailure() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + (normalizedInput, scenarios, systemPrompt, userPrompt) -> + new ScenarioRecognitionResult(true, "ran-energy-saving", null), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + (scenarioCode, language) -> "Site: {site}", + (userInput, scenarioCode, language) -> { + throw new LLMRuntimeError("openai returned invalid json: unexpected token"); + }, + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("llm.response_invalid", result.failure().code()); + assertEquals( + "The LLM response is invalid (step: slot extraction); please retry", + result.failure().message()); + } + + @Test + void generateTaskPromptSurfacesExtractionSlotErrorsAsFailure() { + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("site", "Site A"), + List.of(new StructuredSlotValidationError("subscriptionCondition", "slot.not_provided", null))), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("slot.not_provided", result.failure().code()); + assertEquals( + "'subscriptionCondition' is not provided in the input.", + result.failure().message()); + assertEquals("generation", result.failure().stage()); + } + + @Test + void generateTaskPromptMapsUnknownExtractionSlotErrorCodeToFallback() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("site", "Site A"), + List.of(new StructuredSlotValidationError("extra_field", "unexpected_field", null))), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("slot.rule_violation", result.failure().code()); + } + + @Test + void generateTaskPromptMapsLegacyExtractionSlotErrorCodes() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "en-US", + "Identify the best matching scenario.", + "Choose from the provided scenario list.", + new FakeTemplateLoader("Site: {site}"), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of("site", "Site A"), + List.of( + new StructuredSlotValidationError("site", "missing_input", null), + new StructuredSlotValidationError("site", "invalid_value", null))), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt("Analyze Site A."); + + assertFalse(result.success()); + assertNotNull(result.failure()); + assertEquals("slot.not_provided", result.failure().code()); + assertTrue(result.failure().message().contains("; "), "both mapped messages must be reported"); + } + + @Test + void generateFromTemplateUriWithMetadataEmptyInputProducesSlotValidationError() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of()), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.not_provided", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("site", ex.failedParameters().get(0).slotName()); + } + + @Test + void validateRequiredSlotsTreatsNullSlotValueAsMissing() { + PromptSlotSchemaLoader schemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + "site", true, "string", null, null, null, null, null, "Site name", null))); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Collections.singletonMap("site", null)), + new TaskPromptRenderer(), + schemaLoader); + + PromptGenerationException ex = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("Analyze Site A.", StandardTemplates.ENERGY_SAVING)); + assertEquals("slot.not_provided", ex.getCode()); + assertEquals(1, ex.failedParameters().size()); + assertEquals("site", ex.failedParameters().get(0).slotName()); + assertEquals("slot.not_provided", ex.failedParameters().get(0).code()); + } + + // --- free-text input length limit tests --- + + @Test + void fromTextEntryPointsRejectTextOverTheDefaultLimitBeforeAnyLlmCall() { + String overLimitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1); + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + CountingFailingTemplateLoader templateLoader = new CountingFailingTemplateLoader(); + DefaultClientPromptGenerationOrchestrator orchestrator = + newTemplateUriOrchestrator(recognizer, templateLoader, new FakeSlotValueExtractor(Map.of())); + + PromptGenerationException taskEx = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + PromptGenerationException authEx = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateAuthPromptFromText(overLimitText, AUTH_DATABASE_READ)); + PromptGenerationException notificationEx = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateNotificationPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING)); + + assertEquals("input.text_too_long", taskEx.getCode()); + assertEquals("input.text_too_long", authEx.getCode()); + assertEquals("input.text_too_long", notificationEx.getCode()); + assertTrue(taskEx.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1))); + assertEquals(0, recognizer.invocationCount, "over-limit input must fail before any LLM call"); + assertEquals(0, templateLoader.loadCount, "over-limit input must fail before template loading"); + } + + @Test + void fromTextAcceptsTextAtExactlyTheDefaultLimit() { + String limitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); + DefaultClientPromptGenerationOrchestrator orchestrator = newTemplateUriOrchestrator( + new RecordingScenarioRecognizer(), + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A"))); + + MetadataContent result = orchestrator.generateTaskPromptFromText(limitText, StandardTemplates.ENERGY_SAVING); + + assertEquals("Site: Site A", result.promptText()); + } + + @Test + void fromTextRejectsTextOverOneConfiguredLimit() { + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER, + 10); + + PromptGenerationException overEx = assertThrows( + PromptGenerationException.class, + () -> orchestrator.generateTaskPromptFromText("a".repeat(11), StandardTemplates.ENERGY_SAVING)); + assertEquals("input.text_too_long", overEx.getCode()); + + MetadataContent atLimit = + orchestrator.generateTaskPromptFromText("a".repeat(10), StandardTemplates.ENERGY_SAVING); + assertEquals("Site: Site A", atLimit.promptText()); + } + + @Test + void generateTaskPromptReturnsFailureWhenStringInputExceedsTheDefaultLimit() { + String overLimitText = "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1); + RecordingScenarioRecognizer recognizer = new RecordingScenarioRecognizer(); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + recognizer, + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt(overLimitText); + + assertFalse(result.success()); + assertEquals("input.text_too_long", result.failure().code()); + assertEquals("input", result.failure().stage()); + assertEquals(0, recognizer.invocationCount, "over-limit input must fail before any LLM call"); + } + + @Test + void generateTaskPromptDoesNotCheckLengthForMapInput() { + Map mapInputWithLongStringValue = + Map.of("notes", "a".repeat(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1)); + DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( + new RecordingScenarioRecognizer(), + List.of(new ScenarioDefinition("energy_saving", "Energy Saving", "Energy analysis", "Analyze")), + "en-US", + "", + "", + new FakeTemplateLoader("Site: {site}"), + new FakeSlotValueExtractor(Map.of("site", "Site A")), + new TaskPromptRenderer(), + EMPTY_SCHEMA_LOADER); + + PromptGenerationResult result = orchestrator.generateTaskPrompt(mapInputWithLongStringValue); + + assertTrue(result.success(), "map input must not be length-checked"); + assertEquals("Site: Site A", result.promptText()); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATBusinessException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATBusinessException.java index fb3c409f..373f2bed 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATBusinessException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATBusinessException.java @@ -1,109 +1,109 @@ -package net.openan.a2at.sdk.core.exception; - -import java.util.Map; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Base type for expected business failures raised by the A2A-T SDK. - * - *

            Business failures carry a machine-readable code from {@link ErrorCatalog} and a message rendered from the code's - * message template (see {@link ErrorMessages}); the structured fact values that produced the message travel in - * {@link #getFacts()}. Programming errors (null or malformed arguments) stay {@link NullPointerException} and - * {@link IllegalArgumentException}; infrastructure failures stay plain {@link A2ATError}. - * - * @since 2026-08 - */ -public class A2ATBusinessException extends A2ATError { - - private final Map facts; - - /** - * Creates a business failure for one catalog entry with a rendered message. - * - * @param entry catalog entry identifying the failure - * @param message rendered failure message - * @throws NullPointerException if {@code entry} or {@code message} is null - */ - public A2ATBusinessException(@NonNull ErrorCatalog entry, @NonNull String message) { - this(entry.getCode(), message, Map.of()); - } - - /** - * Creates a business failure for one catalog entry with a rendered message and its fact values. - * - * @param entry catalog entry identifying the failure - * @param message rendered failure message - * @param facts fact values keyed by fact parameter name, may be null - * @throws NullPointerException if {@code entry} or {@code message} is null - */ - public A2ATBusinessException( - @NonNull ErrorCatalog entry, @NonNull String message, @Nullable Map facts) { - this(entry.getCode(), message, facts); - } - - /** - * Creates a business failure for one error code with a rendered message. - * - * @param code machine-readable error code from {@link ErrorCatalog} - * @param message rendered failure message - * @throws NullPointerException if {@code code} or {@code message} is null - */ - public A2ATBusinessException(@NonNull String code, @NonNull String message) { - this(code, message, null); - } - - /** - * Creates a business failure for one error code with a rendered message and its fact values. - * - * @param code machine-readable error code from {@link ErrorCatalog} - * @param message rendered failure message - * @param facts fact values keyed by fact parameter name, may be null - * @throws NullPointerException if {@code code} or {@code message} is null - */ - public A2ATBusinessException(@NonNull String code, @NonNull String message, @Nullable Map facts) { - super(code, message); - this.facts = facts == null ? Map.of() : Map.copyOf(facts); - } - - /** - * Creates a business failure for one error code with a rendered message, its fact values and a root cause. - * - * @param code machine-readable error code from {@link ErrorCatalog} - * @param message rendered failure message - * @param facts fact values keyed by fact parameter name, may be null - * @param cause root cause, may be null - * @throws NullPointerException if {@code code} or {@code message} is null - */ - public A2ATBusinessException( - @NonNull String code, - @NonNull String message, - @Nullable Map facts, - @Nullable Throwable cause) { - super(code, message, cause); - this.facts = facts == null ? Map.of() : Map.copyOf(facts); - } - - /** - * Returns the fact values that produced the rendered message. - * - * @return facts keyed by fact parameter name, possibly empty, never null - */ - public @NonNull Map getFacts() { - return facts; - } - - /** - * Creates a business failure whose message is rendered from the code's template in one language. - * - * @param entry catalog entry identifying the failure - * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} - * @param facts fact values keyed by fact parameter name, may be null - * @return business failure with the rendered message - * @throws NullPointerException if {@code entry} is null - */ - public static A2ATBusinessException of( - @NonNull ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { - return new A2ATBusinessException(entry, ErrorMessages.render(entry, language, facts), facts); - } -} +package net.openan.a2at.sdk.core.exception; + +import java.util.Map; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Base type for expected business failures raised by the A2A-T SDK. + * + *

            Business failures carry a machine-readable code from {@link ErrorCatalog} and a message rendered from the code's + * message template (see {@link ErrorMessages}); the structured fact values that produced the message travel in + * {@link #getFacts()}. Programming errors (null or malformed arguments) stay {@link NullPointerException} and + * {@link IllegalArgumentException}; infrastructure failures stay plain {@link A2ATError}. + * + * @since 2026-08 + */ +public class A2ATBusinessException extends A2ATError { + + private final Map facts; + + /** + * Creates a business failure for one catalog entry with a rendered message. + * + * @param entry catalog entry identifying the failure + * @param message rendered failure message + * @throws NullPointerException if {@code entry} or {@code message} is null + */ + public A2ATBusinessException(@NonNull ErrorCatalog entry, @NonNull String message) { + this(entry.getCode(), message, Map.of()); + } + + /** + * Creates a business failure for one catalog entry with a rendered message and its fact values. + * + * @param entry catalog entry identifying the failure + * @param message rendered failure message + * @param facts fact values keyed by fact parameter name, may be null + * @throws NullPointerException if {@code entry} or {@code message} is null + */ + public A2ATBusinessException( + @NonNull ErrorCatalog entry, @NonNull String message, @Nullable Map facts) { + this(entry.getCode(), message, facts); + } + + /** + * Creates a business failure for one error code with a rendered message. + * + * @param code machine-readable error code from {@link ErrorCatalog} + * @param message rendered failure message + * @throws NullPointerException if {@code code} or {@code message} is null + */ + public A2ATBusinessException(@NonNull String code, @NonNull String message) { + this(code, message, null); + } + + /** + * Creates a business failure for one error code with a rendered message and its fact values. + * + * @param code machine-readable error code from {@link ErrorCatalog} + * @param message rendered failure message + * @param facts fact values keyed by fact parameter name, may be null + * @throws NullPointerException if {@code code} or {@code message} is null + */ + public A2ATBusinessException(@NonNull String code, @NonNull String message, @Nullable Map facts) { + super(code, message); + this.facts = facts == null ? Map.of() : Map.copyOf(facts); + } + + /** + * Creates a business failure for one error code with a rendered message, its fact values and a root cause. + * + * @param code machine-readable error code from {@link ErrorCatalog} + * @param message rendered failure message + * @param facts fact values keyed by fact parameter name, may be null + * @param cause root cause, may be null + * @throws NullPointerException if {@code code} or {@code message} is null + */ + public A2ATBusinessException( + @NonNull String code, + @NonNull String message, + @Nullable Map facts, + @Nullable Throwable cause) { + super(code, message, cause); + this.facts = facts == null ? Map.of() : Map.copyOf(facts); + } + + /** + * Returns the fact values that produced the rendered message. + * + * @return facts keyed by fact parameter name, possibly empty, never null + */ + public @NonNull Map getFacts() { + return facts; + } + + /** + * Creates a business failure whose message is rendered from the code's template in one language. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @return business failure with the rendered message + * @throws NullPointerException if {@code entry} is null + */ + public static A2ATBusinessException of( + @NonNull ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { + return new A2ATBusinessException(entry, ErrorMessages.render(entry, language, facts), facts); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java index 85e71658..e6e70237 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java @@ -1,74 +1,74 @@ -package net.openan.a2at.sdk.core.exception; - -import java.util.Objects; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Single root exception type for all A2A-T SDK processing failures. - * - *

            Every runtime, environment or data-processing failure raised by the SDK is part of this tree, so callers can catch - * this one root type and branch on the machine-readable error code returned by {@link #getCode()}, which is never - * {@code null}. Caller contract violations (null, blank or otherwise malformed arguments) are programming errors raised - * as {@link NullPointerException} or {@link IllegalArgumentException} and intentionally stay outside this tree. - * - * @since 2026-08 - */ -public class A2ATError extends RuntimeException { - - /** Machine-readable error code for this failure, never null. */ - private final String code; - - /** - * Creates an A2A-T processing failure with one message and the default error code. - * - * @param message failure message - */ - public A2ATError(String message) { - this(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), message); - } - - /** - * Creates an A2A-T processing failure with one message, one root cause and the default error code. - * - * @param message failure message - * @param cause root cause - */ - public A2ATError(String message, @Nullable Throwable cause) { - this(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), message, cause); - } - - /** - * Creates an A2A-T processing failure with one specific error code and one message. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @throws NullPointerException if {@code code} is null - */ - public A2ATError(@NonNull String code, String message) { - super(message); - this.code = Objects.requireNonNull(code, "code must not be null"); - } - - /** - * Creates an A2A-T processing failure with one specific error code, one message and one root cause. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param cause root cause - * @throws NullPointerException if {@code code} is null - */ - public A2ATError(@NonNull String code, String message, @Nullable Throwable cause) { - super(message, cause); - this.code = Objects.requireNonNull(code, "code must not be null"); - } - - /** - * Returns the machine-readable error code for this failure. - * - * @return error code, never null - */ - public @NonNull String getCode() { - return code; - } -} +package net.openan.a2at.sdk.core.exception; + +import java.util.Objects; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Single root exception type for all A2A-T SDK processing failures. + * + *

            Every runtime, environment or data-processing failure raised by the SDK is part of this tree, so callers can catch + * this one root type and branch on the machine-readable error code returned by {@link #getCode()}, which is never + * {@code null}. Caller contract violations (null, blank or otherwise malformed arguments) are programming errors raised + * as {@link NullPointerException} or {@link IllegalArgumentException} and intentionally stay outside this tree. + * + * @since 2026-08 + */ +public class A2ATError extends RuntimeException { + + /** Machine-readable error code for this failure, never null. */ + private final String code; + + /** + * Creates an A2A-T processing failure with one message and the default error code. + * + * @param message failure message + */ + public A2ATError(String message) { + this(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), message); + } + + /** + * Creates an A2A-T processing failure with one message, one root cause and the default error code. + * + * @param message failure message + * @param cause root cause + */ + public A2ATError(String message, @Nullable Throwable cause) { + this(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), message, cause); + } + + /** + * Creates an A2A-T processing failure with one specific error code and one message. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @throws NullPointerException if {@code code} is null + */ + public A2ATError(@NonNull String code, String message) { + super(message); + this.code = Objects.requireNonNull(code, "code must not be null"); + } + + /** + * Creates an A2A-T processing failure with one specific error code, one message and one root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param cause root cause + * @throws NullPointerException if {@code code} is null + */ + public A2ATError(@NonNull String code, String message, @Nullable Throwable cause) { + super(message, cause); + this.code = Objects.requireNonNull(code, "code must not be null"); + } + + /** + * Returns the machine-readable error code for this failure. + * + * @return error code, never null + */ + public @NonNull String getCode() { + return code; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java index 2ca91459..75476350 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java @@ -1,102 +1,102 @@ -package net.openan.a2at.sdk.core.exception; - -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -/** - * Centralized machine-readable error code constants shared across A2A-T SDK processing failures. - * - * @deprecated superseded by {@link ErrorCatalog}; each constant forwards to the layered {@code domain.semantic} code of - * the catalog entry that replaced it. Kept only while the corpus and sample modules reference the old constant - * names; new code must use {@link ErrorCatalog} directly. - * @since 2026-08 - */ -@Deprecated(forRemoval = true) -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public final class A2ATErrorCodes { - - /** Default code for SDK processing failures without a more specific code. */ - @Deprecated(forRemoval = true) - public static final String SDK_INTERNAL_ERROR = ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(); - - /** A free-text input exceeded the configured maximum length. */ - @Deprecated(forRemoval = true) - public static final String INPUT_TEXT_TOO_LONG = ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(); - - /** Default code for parameter-extraction failures without a more specific code. */ - @Deprecated(forRemoval = true) - public static final String PARAM_EXTRACTION_FAILED = ErrorCatalog.SLOT_NOT_PROVIDED.getCode(); - - /** A referenced template could not be resolved. */ - @Deprecated(forRemoval = true) - public static final String TEMPLATE_NOT_FOUND = ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); - - /** Structured content could not be extracted from a free-text input. */ - @Deprecated(forRemoval = true) - public static final String NEGOTIATION_CONTENT_EXTRACT_FAILED = - ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(); - - /** Semantic validation rejected the input. */ - @Deprecated(forRemoval = true) - public static final String NEGOTIATION_SEMANTIC_REJECTED = ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(); - - /** One or more structural rules of the expected template were violated. */ - @Deprecated(forRemoval = true) - public static final String NEGOTIATION_RULE_VIOLATION = ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(); - - /** A required slot is missing from the input. */ - @Deprecated(forRemoval = true) - public static final String NEGOTIATION_SLOT_MISSING = ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(); - - /** The input is not valid for the requested operation. */ - @Deprecated(forRemoval = true) - public static final String NEGOTIATION_INVALID_INPUT = ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(); - - /** An LLM infrastructure failure prevented the step from completing. */ - @Deprecated(forRemoval = true) - public static final String NEGOTIATION_LLM_INFRASTRUCTURE_ERROR = ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); - - /** The input is not valid for the requested validation operation. */ - @Deprecated(forRemoval = true) - public static final String VALIDATION_INVALID_INPUT = ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(); - - /** One or more structural rules of the expected content were violated. */ - @Deprecated(forRemoval = true) - public static final String VALIDATION_RULE_VIOLATION = ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(); - - /** Semantic validation rejected the content. */ - @Deprecated(forRemoval = true) - public static final String VALIDATION_SEMANTIC_REJECTED = ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(); - - /** An LLM infrastructure failure prevented the validation step from completing. */ - @Deprecated(forRemoval = true) - public static final String VALIDATION_LLM_INFRASTRUCTURE_ERROR = ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); - - /** A referenced prompt resource could not be resolved. */ - @Deprecated(forRemoval = true) - public static final String VALIDATION_PROMPT_RESOURCE_NOT_FOUND = ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); - - /** A prompt resource could not be loaded from its configured source. */ - @Deprecated(forRemoval = true) - public static final String PROMPT_RESOURCE_LOAD_ERROR = ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode(); - - /** The slot schema describing the expected slots could not be resolved. */ - @Deprecated(forRemoval = true) - public static final String SLOT_SCHEMA_NOT_FOUND = ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(); - - /** An LLM invocation failed at the infrastructure level. */ - @Deprecated(forRemoval = true) - public static final String LLM_INVOCATION_FAILED = ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); - - /** Rendering a template with its slot values failed. */ - @Deprecated(forRemoval = true) - public static final String RENDER_FAILED = ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode(); - - /** The slot values extracted or provided for a template failed validation. */ - @Deprecated(forRemoval = true) - public static final String SLOT_VALIDATION_ERROR = ErrorCatalog.SLOT_RULE_VIOLATION.getCode(); - - /** A processed prompt could not be parsed into its structured parts. */ - @Deprecated(forRemoval = true) - public static final String PROCESSED_PROMPT_PARSE_ERROR = ErrorCatalog.SCENARIO_NOT_MATCHED.getCode(); -} +package net.openan.a2at.sdk.core.exception; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Centralized machine-readable error code constants shared across A2A-T SDK processing failures. + * + * @deprecated superseded by {@link ErrorCatalog}; each constant forwards to the layered {@code domain.semantic} code of + * the catalog entry that replaced it. Kept only while the corpus and sample modules reference the old constant + * names; new code must use {@link ErrorCatalog} directly. + * @since 2026-08 + */ +@Deprecated(forRemoval = true) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class A2ATErrorCodes { + + /** Default code for SDK processing failures without a more specific code. */ + @Deprecated(forRemoval = true) + public static final String SDK_INTERNAL_ERROR = ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(); + + /** A free-text input exceeded the configured maximum length. */ + @Deprecated(forRemoval = true) + public static final String INPUT_TEXT_TOO_LONG = ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(); + + /** Default code for parameter-extraction failures without a more specific code. */ + @Deprecated(forRemoval = true) + public static final String PARAM_EXTRACTION_FAILED = ErrorCatalog.SLOT_NOT_PROVIDED.getCode(); + + /** A referenced template could not be resolved. */ + @Deprecated(forRemoval = true) + public static final String TEMPLATE_NOT_FOUND = ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); + + /** Structured content could not be extracted from a free-text input. */ + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_CONTENT_EXTRACT_FAILED = + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(); + + /** Semantic validation rejected the input. */ + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_SEMANTIC_REJECTED = ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(); + + /** One or more structural rules of the expected template were violated. */ + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_RULE_VIOLATION = ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(); + + /** A required slot is missing from the input. */ + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_SLOT_MISSING = ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(); + + /** The input is not valid for the requested operation. */ + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_INVALID_INPUT = ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(); + + /** An LLM infrastructure failure prevented the step from completing. */ + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_LLM_INFRASTRUCTURE_ERROR = ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); + + /** The input is not valid for the requested validation operation. */ + @Deprecated(forRemoval = true) + public static final String VALIDATION_INVALID_INPUT = ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(); + + /** One or more structural rules of the expected content were violated. */ + @Deprecated(forRemoval = true) + public static final String VALIDATION_RULE_VIOLATION = ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(); + + /** Semantic validation rejected the content. */ + @Deprecated(forRemoval = true) + public static final String VALIDATION_SEMANTIC_REJECTED = ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(); + + /** An LLM infrastructure failure prevented the validation step from completing. */ + @Deprecated(forRemoval = true) + public static final String VALIDATION_LLM_INFRASTRUCTURE_ERROR = ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); + + /** A referenced prompt resource could not be resolved. */ + @Deprecated(forRemoval = true) + public static final String VALIDATION_PROMPT_RESOURCE_NOT_FOUND = ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); + + /** A prompt resource could not be loaded from its configured source. */ + @Deprecated(forRemoval = true) + public static final String PROMPT_RESOURCE_LOAD_ERROR = ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode(); + + /** The slot schema describing the expected slots could not be resolved. */ + @Deprecated(forRemoval = true) + public static final String SLOT_SCHEMA_NOT_FOUND = ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(); + + /** An LLM invocation failed at the infrastructure level. */ + @Deprecated(forRemoval = true) + public static final String LLM_INVOCATION_FAILED = ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); + + /** Rendering a template with its slot values failed. */ + @Deprecated(forRemoval = true) + public static final String RENDER_FAILED = ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode(); + + /** The slot values extracted or provided for a template failed validation. */ + @Deprecated(forRemoval = true) + public static final String SLOT_VALIDATION_ERROR = ErrorCatalog.SLOT_RULE_VIOLATION.getCode(); + + /** A processed prompt could not be parsed into its structured parts. */ + @Deprecated(forRemoval = true) + public static final String PROCESSED_PROMPT_PARSE_ERROR = ErrorCatalog.SCENARIO_NOT_MATCHED.getCode(); +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java index 50c1b0a6..2e0d71d6 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java @@ -1,64 +1,64 @@ -package net.openan.a2at.sdk.core.exception; - -import java.util.List; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import org.jspecify.annotations.NonNull; - -/** - * Shared failure type raised when validating a prompt and extracting parameters from it fails. - * - *

            This base type is shared across prompt families so that a caller can handle extraction failures uniformly. The - * machine-readable error code is carried by the root {@link A2ATError} and is never null; subclasses pass a more - * specific code when one is available. - * - * @since 2026-08 - */ -public class A2ATParamExtractionError extends A2ATBusinessException { - - /** Structured per-slot validation error details. */ - private final List errors; - - /** - * Creates a parameter-extraction failure with the default error code and no slot details. - * - * @param message failure message - */ - public A2ATParamExtractionError(String message) { - this(ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), message, List.of()); - } - - /** - * Creates a parameter-extraction failure with one specific error code and slot details. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param errors structured per-slot validation error details - */ - public A2ATParamExtractionError(@NonNull String code, String message, @NonNull List errors) { - super(code, message); - this.errors = List.copyOf(errors); - } - - /** - * Creates a parameter-extraction failure with one specific error code, slot details and a root cause. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param errors structured per-slot validation error details - * @param cause root cause of the failure - */ - public A2ATParamExtractionError( - @NonNull String code, String message, @NonNull List errors, Throwable cause) { - super(code, message, null, cause); - this.errors = List.copyOf(errors); - } - - /** - * Returns the structured per-slot validation error details. - * - * @return immutable list of slot validation errors, never null - */ - public @NonNull List getErrors() { - return errors; - } -} +package net.openan.a2at.sdk.core.exception; + +import java.util.List; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import org.jspecify.annotations.NonNull; + +/** + * Shared failure type raised when validating a prompt and extracting parameters from it fails. + * + *

            This base type is shared across prompt families so that a caller can handle extraction failures uniformly. The + * machine-readable error code is carried by the root {@link A2ATError} and is never null; subclasses pass a more + * specific code when one is available. + * + * @since 2026-08 + */ +public class A2ATParamExtractionError extends A2ATBusinessException { + + /** Structured per-slot validation error details. */ + private final List errors; + + /** + * Creates a parameter-extraction failure with the default error code and no slot details. + * + * @param message failure message + */ + public A2ATParamExtractionError(String message) { + this(ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), message, List.of()); + } + + /** + * Creates a parameter-extraction failure with one specific error code and slot details. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + */ + public A2ATParamExtractionError(@NonNull String code, String message, @NonNull List errors) { + super(code, message); + this.errors = List.copyOf(errors); + } + + /** + * Creates a parameter-extraction failure with one specific error code, slot details and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + * @param cause root cause of the failure + */ + public A2ATParamExtractionError( + @NonNull String code, String message, @NonNull List errors, Throwable cause) { + super(code, message, null, cause); + this.errors = List.copyOf(errors); + } + + /** + * Returns the structured per-slot validation error details. + * + * @return immutable list of slot validation errors, never null + */ + public @NonNull List getErrors() { + return errors; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java index 371252cf..6dcd19a2 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java @@ -1,27 +1,27 @@ -package net.openan.a2at.sdk.core.exception; - -import java.nio.file.Path; -import java.util.Map; -import org.jspecify.annotations.NonNull; - -/** - * Thrown when one required configuration file path does not exist. - * - * @since 2026-06 - */ -public final class ConfigFileNotFoundException extends A2ATError { - - /** - * Creates one exception for one missing config file. - * - * @param path missing config file path - */ - public ConfigFileNotFoundException(@NonNull Path path) { - super( - ErrorCatalog.INFRA_CONFIG_INVALID.getCode(), - ErrorMessages.render( - ErrorCatalog.INFRA_CONFIG_INVALID, - null, - Map.of("key", String.valueOf(path), "reason", "config file does not exist"))); - } -} +package net.openan.a2at.sdk.core.exception; + +import java.nio.file.Path; +import java.util.Map; +import org.jspecify.annotations.NonNull; + +/** + * Thrown when one required configuration file path does not exist. + * + * @since 2026-06 + */ +public final class ConfigFileNotFoundException extends A2ATError { + + /** + * Creates one exception for one missing config file. + * + * @param path missing config file path + */ + public ConfigFileNotFoundException(@NonNull Path path) { + super( + ErrorCatalog.INFRA_CONFIG_INVALID.getCode(), + ErrorMessages.render( + ErrorCatalog.INFRA_CONFIG_INVALID, + null, + Map.of("key", String.valueOf(path), "reason", "config file does not exist"))); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorCatalog.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorCatalog.java index bb13bcb5..f890661a 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorCatalog.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorCatalog.java @@ -1,242 +1,242 @@ -package net.openan.a2at.sdk.core.exception; - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Closed catalog of the machine-readable error codes exposed by the A2A-T SDK. - * - *

            Each constant carries the layered {@code domain.semantic} code string (for example {@code content.param_missing}), - * the {@link Category} that decides which exception family carries it, and the names of the fact parameters the message - * template for the code renders. Message templates live in {@code prompt_resources/errors/{language}/errors.json}; use - * {@link ErrorMessages} to render them. - * - *

            The catalog is closed: codes returned by an LLM step that are not in this catalog are mapped to the per-domain - * {@code *.rule_violation} fallback constants by the callers, never surfaced raw. - * - * @since 2026-08 - */ -public enum ErrorCatalog { - - // domain: template (template resources) - - /** The referenced template does not exist for the configured language. */ - TEMPLATE_NOT_FOUND("template.not_found", Category.BUSINESS, "template_uri", "language"), - - /** Rendering a template with its slot values failed. */ - TEMPLATE_RENDER_FAILED("template.render_failed", Category.BUSINESS, "template_uri", "reason"), - - /** A template resource could not be loaded from its configured source. */ - TEMPLATE_LOAD_FAILED("template.load_failed", Category.INFRA, "resource_path"), - - // domain: slot (generation-side slots and generic schemas) - - /** The slot schema describing the expected slots could not be resolved for the template. */ - SLOT_SCHEMA_NOT_FOUND("slot.schema_not_found", Category.BUSINESS, "template_uri", "language"), - - /** A required slot could not be extracted from the caller input. */ - SLOT_NOT_PROVIDED("slot.not_provided", Category.BUSINESS, "slot_label"), - - /** An extracted slot value violates a closed value range or structural constraint. */ - SLOT_CONSTRAINT_VIOLATED("slot.constraint_violated", Category.BUSINESS, "slot_label", "actual"), - - /** A slot value conflicts with the slot definition. */ - SLOT_SEMANTIC_CONFLICT("slot.semantic_conflict", Category.BUSINESS, "slot_label", "reason"), - - /** A slot value is obvious placeholder content rather than a valid value. */ - SLOT_FABRICATED_VALUE("slot.fabricated_value", Category.BUSINESS, "slot_label", "actual"), - - /** A slot value mixes in content from a different scenario. */ - SLOT_CROSS_SCENARIO_POLLUTION("slot.cross_scenario_pollution", Category.BUSINESS, "slot_label"), - - /** A slot value lacks sufficient grounding in the input. */ - SLOT_INSUFFICIENT_GROUNDING("slot.insufficient_grounding", Category.BUSINESS, "slot_label"), - - /** Fallback for unknown slot-domain codes returned by an LLM step. */ - SLOT_RULE_VIOLATION("slot.rule_violation", Category.BUSINESS, "slot_label"), - - // domain: input (caller input) - - /** A free-text input exceeded the configured maximum length. */ - INPUT_TEXT_TOO_LONG("input.text_too_long", Category.BUSINESS, "actual_length", "max_chars"), - - // domain: content (validation-side parameter content, LLM-judged) - - /** A parameter section carries no value at all. */ - CONTENT_PARAM_MISSING("content.param_missing", Category.BUSINESS, "section_label"), - - /** A list entry is missing a required field. */ - CONTENT_ENTRY_FIELD_MISSING( - "content.entry_field_missing", Category.BUSINESS, "section_label", "index", "field_label"), - - /** A value exists but its format is invalid. */ - CONTENT_FORMAT_ERROR("content.format_error", Category.BUSINESS, "section_label", "reason"), - - /** A value exists and is well-formed but is not within the allowed range. */ - CONTENT_VALUE_NOT_ALLOWED("content.value_not_allowed", Category.BUSINESS, "section_label", "actual"), - - /** Values conflict semantically. */ - CONTENT_SEMANTIC_CONFLICT("content.semantic_conflict", Category.BUSINESS, "section_label", "reason"), - - /** Fallback for unknown content-domain codes returned by an LLM step. */ - CONTENT_RULE_VIOLATION("content.rule_violation", Category.BUSINESS, "section_label"), - - // domain: scenario (scenario recognition) - - /** The input does not match any known scenario. */ - SCENARIO_NOT_MATCHED("scenario.not_matched", Category.BUSINESS, "reason"), - - // domain: llm (LLM invocations, retryable by the caller) - - /** No LLM client is configured. */ - LLM_NOT_CONFIGURED("llm.not_configured", Category.BUSINESS), - - /** An LLM invocation failed at the transport level. */ - LLM_INVOCATION_FAILED("llm.invocation_failed", Category.BUSINESS, "provider", "reason"), - - /** An LLM response violates the response contract of the step. */ - LLM_RESPONSE_INVALID("llm.response_invalid", Category.BUSINESS, "step"), - - // domain: negotiation (negotiation flows) - - /** The negotiation input is not valid for the requested operation. */ - NEGOTIATION_INVALID_INPUT("negotiation.invalid_input", Category.BUSINESS, "reason"), - - /** The negotiation context id is not a valid UUID. */ - NEGOTIATION_INVALID_CONTEXT_ID("negotiation.invalid_context_id", Category.BUSINESS, "actual"), - - /** The negotiation round exceeds the configured maximum. */ - NEGOTIATION_ROUND_EXCEEDED("negotiation.round_exceeded", Category.BUSINESS, "round", "max_rounds"), - - /** The message content implies a different negotiation type than the declared template. */ - NEGOTIATION_TYPE_MISMATCH("negotiation.type_mismatch", Category.BUSINESS, "implied", "declared"), - - /** The message phase does not match the declared template phase. */ - NEGOTIATION_PHASE_MISMATCH("negotiation.phase_mismatch", Category.BUSINESS, "implied", "declared"), - - /** The message conclusion does not match the conclusion expected by the called method. */ - NEGOTIATION_CONCLUSION_MISMATCH("negotiation.conclusion_mismatch", Category.BUSINESS, "expected", "actual"), - - /** A negotiation content field passed to a from-data method is invalid. */ - NEGOTIATION_CONTENT_INVALID("negotiation.content_invalid", Category.BUSINESS, "field", "reason"), - - /** The negotiation message is missing a required field. */ - NEGOTIATION_FIELD_MISSING("negotiation.field_missing", Category.BUSINESS, "field"), - - /** Negotiation content could not be extracted from free text. */ - NEGOTIATION_CONTENT_EXTRACT_FAILED("negotiation.content_extract_failed", Category.BUSINESS, "field", "reason"), - - /** The conclusion does not match the content required by the result section. */ - NEGOTIATION_CONCLUSION_CONTENT_MISMATCH( - "negotiation.conclusion_content_mismatch", Category.BUSINESS, "conclusion", "section_label"), - - /** The result section is missing the content required by its conclusion. */ - NEGOTIATION_MISSING_RESULT_CONTENT("negotiation.missing_result_content", Category.BUSINESS, "section_label"), - - /** Mutually exclusive sections appear together. */ - NEGOTIATION_MUTUALLY_EXCLUSIVE_SECTIONS("negotiation.mutually_exclusive_sections", Category.BUSINESS, "sections"), - - /** A section conflicts with existing constraints. */ - NEGOTIATION_CONSTRAINT_CONFLICT("negotiation.constraint_conflict", Category.BUSINESS, "section_label", "reason"), - - /** Fields within one section are inconsistent. */ - NEGOTIATION_FIELD_INCONSISTENCY("negotiation.field_inconsistency", Category.BUSINESS, "section_label", "reason"), - - /** A time interval is invalid, for example start later than end. */ - NEGOTIATION_INVALID_TIME_INTERVAL("negotiation.invalid_time_interval", Category.BUSINESS, "section_label"), - - /** The negotiation message failed semantic validation. */ - NEGOTIATION_SEMANTIC_REJECTED("negotiation.semantic_rejected", Category.BUSINESS), - - /** Fallback for unknown negotiation-domain codes returned by an LLM step. */ - NEGOTIATION_RULE_VIOLATION("negotiation.rule_violation", Category.BUSINESS, "section_label"), - - // domain: infra (infrastructure) - - /** A configuration entry is invalid. */ - INFRA_CONFIG_INVALID("infra.config_invalid", Category.INFRA, "key", "reason"), - - /** A resource could not be read. */ - INFRA_RESOURCE_READ_FAILED("infra.resource_read_failed", Category.INFRA, "resource_path"), - - /** Default code for SDK internal errors. */ - INFRA_INTERNAL_ERROR("infra.internal_error", Category.INFRA); - - /** Category deciding which exception family carries the code. */ - public enum Category { - /** Expected business failure the caller can act on; carried by {@link A2ATBusinessException}. */ - BUSINESS, - - /** Infrastructure failure; carried by {@link A2ATError}. */ - INFRA - } - - private static final Map BY_CODE = - Stream.of(values()).collect(Collectors.toMap(ErrorCatalog::getCode, Function.identity())); - - private final String code; - - private final Category category; - - private final List factParameters; - - ErrorCatalog(String code, Category category, String... factParameters) { - this.code = code; - this.category = category; - this.factParameters = List.of(factParameters); - } - - /** - * Returns the catalog entry for one code string. - * - * @param code layered error code, for example {@code content.param_missing} - * @return catalog entry, or empty when the code is not in the catalog - * @throws NullPointerException if {@code code} is null - */ - public static Optional byCode(@NonNull String code) { - return Optional.ofNullable(BY_CODE.get(code)); - } - - /** - * Returns the layered {@code domain.semantic} code string. - * - * @return error code, never null - */ - public @NonNull String getCode() { - return code; - } - - /** - * Returns the category of the code. - * - * @return category, never null - */ - public @NonNull Category getCategory() { - return category; - } - - /** - * Returns the names of the fact parameters the message template of the code renders. - * - * @return fact parameter names, possibly empty, never null - */ - public @NonNull List getFactParameters() { - return factParameters; - } - - /** - * Returns whether one name is a declared fact parameter of the code. - * - * @param name fact parameter name - * @return true when the name is declared for the code - */ - public boolean hasFactParameter(@Nullable String name) { - return name != null && factParameters.contains(name); - } -} +package net.openan.a2at.sdk.core.exception; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Closed catalog of the machine-readable error codes exposed by the A2A-T SDK. + * + *

            Each constant carries the layered {@code domain.semantic} code string (for example {@code content.param_missing}), + * the {@link Category} that decides which exception family carries it, and the names of the fact parameters the message + * template for the code renders. Message templates live in {@code prompt_resources/errors/{language}/errors.json}; use + * {@link ErrorMessages} to render them. + * + *

            The catalog is closed: codes returned by an LLM step that are not in this catalog are mapped to the per-domain + * {@code *.rule_violation} fallback constants by the callers, never surfaced raw. + * + * @since 2026-08 + */ +public enum ErrorCatalog { + + // domain: template (template resources) + + /** The referenced template does not exist for the configured language. */ + TEMPLATE_NOT_FOUND("template.not_found", Category.BUSINESS, "template_uri", "language"), + + /** Rendering a template with its slot values failed. */ + TEMPLATE_RENDER_FAILED("template.render_failed", Category.BUSINESS, "template_uri", "reason"), + + /** A template resource could not be loaded from its configured source. */ + TEMPLATE_LOAD_FAILED("template.load_failed", Category.INFRA, "resource_path"), + + // domain: slot (generation-side slots and generic schemas) + + /** The slot schema describing the expected slots could not be resolved for the template. */ + SLOT_SCHEMA_NOT_FOUND("slot.schema_not_found", Category.BUSINESS, "template_uri", "language"), + + /** A required slot could not be extracted from the caller input. */ + SLOT_NOT_PROVIDED("slot.not_provided", Category.BUSINESS, "slot_label"), + + /** An extracted slot value violates a closed value range or structural constraint. */ + SLOT_CONSTRAINT_VIOLATED("slot.constraint_violated", Category.BUSINESS, "slot_label", "actual"), + + /** A slot value conflicts with the slot definition. */ + SLOT_SEMANTIC_CONFLICT("slot.semantic_conflict", Category.BUSINESS, "slot_label", "reason"), + + /** A slot value is obvious placeholder content rather than a valid value. */ + SLOT_FABRICATED_VALUE("slot.fabricated_value", Category.BUSINESS, "slot_label", "actual"), + + /** A slot value mixes in content from a different scenario. */ + SLOT_CROSS_SCENARIO_POLLUTION("slot.cross_scenario_pollution", Category.BUSINESS, "slot_label"), + + /** A slot value lacks sufficient grounding in the input. */ + SLOT_INSUFFICIENT_GROUNDING("slot.insufficient_grounding", Category.BUSINESS, "slot_label"), + + /** Fallback for unknown slot-domain codes returned by an LLM step. */ + SLOT_RULE_VIOLATION("slot.rule_violation", Category.BUSINESS, "slot_label"), + + // domain: input (caller input) + + /** A free-text input exceeded the configured maximum length. */ + INPUT_TEXT_TOO_LONG("input.text_too_long", Category.BUSINESS, "actual_length", "max_chars"), + + // domain: content (validation-side parameter content, LLM-judged) + + /** A parameter section carries no value at all. */ + CONTENT_PARAM_MISSING("content.param_missing", Category.BUSINESS, "section_label"), + + /** A list entry is missing a required field. */ + CONTENT_ENTRY_FIELD_MISSING( + "content.entry_field_missing", Category.BUSINESS, "section_label", "index", "field_label"), + + /** A value exists but its format is invalid. */ + CONTENT_FORMAT_ERROR("content.format_error", Category.BUSINESS, "section_label", "reason"), + + /** A value exists and is well-formed but is not within the allowed range. */ + CONTENT_VALUE_NOT_ALLOWED("content.value_not_allowed", Category.BUSINESS, "section_label", "actual"), + + /** Values conflict semantically. */ + CONTENT_SEMANTIC_CONFLICT("content.semantic_conflict", Category.BUSINESS, "section_label", "reason"), + + /** Fallback for unknown content-domain codes returned by an LLM step. */ + CONTENT_RULE_VIOLATION("content.rule_violation", Category.BUSINESS, "section_label"), + + // domain: scenario (scenario recognition) + + /** The input does not match any known scenario. */ + SCENARIO_NOT_MATCHED("scenario.not_matched", Category.BUSINESS, "reason"), + + // domain: llm (LLM invocations, retryable by the caller) + + /** No LLM client is configured. */ + LLM_NOT_CONFIGURED("llm.not_configured", Category.BUSINESS), + + /** An LLM invocation failed at the transport level. */ + LLM_INVOCATION_FAILED("llm.invocation_failed", Category.BUSINESS, "provider", "reason"), + + /** An LLM response violates the response contract of the step. */ + LLM_RESPONSE_INVALID("llm.response_invalid", Category.BUSINESS, "step"), + + // domain: negotiation (negotiation flows) + + /** The negotiation input is not valid for the requested operation. */ + NEGOTIATION_INVALID_INPUT("negotiation.invalid_input", Category.BUSINESS, "reason"), + + /** The negotiation context id is not a valid UUID. */ + NEGOTIATION_INVALID_CONTEXT_ID("negotiation.invalid_context_id", Category.BUSINESS, "actual"), + + /** The negotiation round exceeds the configured maximum. */ + NEGOTIATION_ROUND_EXCEEDED("negotiation.round_exceeded", Category.BUSINESS, "round", "max_rounds"), + + /** The message content implies a different negotiation type than the declared template. */ + NEGOTIATION_TYPE_MISMATCH("negotiation.type_mismatch", Category.BUSINESS, "implied", "declared"), + + /** The message phase does not match the declared template phase. */ + NEGOTIATION_PHASE_MISMATCH("negotiation.phase_mismatch", Category.BUSINESS, "implied", "declared"), + + /** The message conclusion does not match the conclusion expected by the called method. */ + NEGOTIATION_CONCLUSION_MISMATCH("negotiation.conclusion_mismatch", Category.BUSINESS, "expected", "actual"), + + /** A negotiation content field passed to a from-data method is invalid. */ + NEGOTIATION_CONTENT_INVALID("negotiation.content_invalid", Category.BUSINESS, "field", "reason"), + + /** The negotiation message is missing a required field. */ + NEGOTIATION_FIELD_MISSING("negotiation.field_missing", Category.BUSINESS, "field"), + + /** Negotiation content could not be extracted from free text. */ + NEGOTIATION_CONTENT_EXTRACT_FAILED("negotiation.content_extract_failed", Category.BUSINESS, "field", "reason"), + + /** The conclusion does not match the content required by the result section. */ + NEGOTIATION_CONCLUSION_CONTENT_MISMATCH( + "negotiation.conclusion_content_mismatch", Category.BUSINESS, "conclusion", "section_label"), + + /** The result section is missing the content required by its conclusion. */ + NEGOTIATION_MISSING_RESULT_CONTENT("negotiation.missing_result_content", Category.BUSINESS, "section_label"), + + /** Mutually exclusive sections appear together. */ + NEGOTIATION_MUTUALLY_EXCLUSIVE_SECTIONS("negotiation.mutually_exclusive_sections", Category.BUSINESS, "sections"), + + /** A section conflicts with existing constraints. */ + NEGOTIATION_CONSTRAINT_CONFLICT("negotiation.constraint_conflict", Category.BUSINESS, "section_label", "reason"), + + /** Fields within one section are inconsistent. */ + NEGOTIATION_FIELD_INCONSISTENCY("negotiation.field_inconsistency", Category.BUSINESS, "section_label", "reason"), + + /** A time interval is invalid, for example start later than end. */ + NEGOTIATION_INVALID_TIME_INTERVAL("negotiation.invalid_time_interval", Category.BUSINESS, "section_label"), + + /** The negotiation message failed semantic validation. */ + NEGOTIATION_SEMANTIC_REJECTED("negotiation.semantic_rejected", Category.BUSINESS), + + /** Fallback for unknown negotiation-domain codes returned by an LLM step. */ + NEGOTIATION_RULE_VIOLATION("negotiation.rule_violation", Category.BUSINESS, "section_label"), + + // domain: infra (infrastructure) + + /** A configuration entry is invalid. */ + INFRA_CONFIG_INVALID("infra.config_invalid", Category.INFRA, "key", "reason"), + + /** A resource could not be read. */ + INFRA_RESOURCE_READ_FAILED("infra.resource_read_failed", Category.INFRA, "resource_path"), + + /** Default code for SDK internal errors. */ + INFRA_INTERNAL_ERROR("infra.internal_error", Category.INFRA); + + /** Category deciding which exception family carries the code. */ + public enum Category { + /** Expected business failure the caller can act on; carried by {@link A2ATBusinessException}. */ + BUSINESS, + + /** Infrastructure failure; carried by {@link A2ATError}. */ + INFRA + } + + private static final Map BY_CODE = + Stream.of(values()).collect(Collectors.toMap(ErrorCatalog::getCode, Function.identity())); + + private final String code; + + private final Category category; + + private final List factParameters; + + ErrorCatalog(String code, Category category, String... factParameters) { + this.code = code; + this.category = category; + this.factParameters = List.of(factParameters); + } + + /** + * Returns the catalog entry for one code string. + * + * @param code layered error code, for example {@code content.param_missing} + * @return catalog entry, or empty when the code is not in the catalog + * @throws NullPointerException if {@code code} is null + */ + public static Optional byCode(@NonNull String code) { + return Optional.ofNullable(BY_CODE.get(code)); + } + + /** + * Returns the layered {@code domain.semantic} code string. + * + * @return error code, never null + */ + public @NonNull String getCode() { + return code; + } + + /** + * Returns the category of the code. + * + * @return category, never null + */ + public @NonNull Category getCategory() { + return category; + } + + /** + * Returns the names of the fact parameters the message template of the code renders. + * + * @return fact parameter names, possibly empty, never null + */ + public @NonNull List getFactParameters() { + return factParameters; + } + + /** + * Returns whether one name is a declared fact parameter of the code. + * + * @param name fact parameter name + * @return true when the name is declared for the code + */ + public boolean hasFactParameter(@Nullable String name) { + return name != null && factParameters.contains(name); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorMessages.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorMessages.java index e763ce0a..a623582b 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorMessages.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ErrorMessages.java @@ -1,145 +1,145 @@ -package net.openan.a2at.sdk.core.exception; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Renders error messages from the bundled {@code prompt_resources/errors/{language}/errors.json} templates. - * - *

            The language follows the {@code A2AT_LANGUAGE} configuration resolved by the caller (default {@code en-US}). A - * template missing in the requested language falls back to {@code en-US}; a template missing in both languages renders - * as the bare code string. Placeholders are {@code {name}} tokens; a placeholder without a matching fact value is kept - * literal, matching the template style of the A2A-T prompt resources. - * - * @since 2026-08 - */ -public final class ErrorMessages { - - /** Language used when no language is configured or a template is missing in the requested language. */ - public static final String DEFAULT_LANGUAGE = "en-US"; - - private static final Logger LOGGER = LoggerFactory.getLogger(ErrorMessages.class); - - private static final String RESOURCE_PATTERN = "prompt_resources/errors/%s/errors.json"; - - private static final TypeReference> TEMPLATE_MAP_TYPE = - new TypeReference>() {}; - - private static final Pattern PLACEHOLDER = Pattern.compile("\\{([a-zA-Z][a-zA-Z0-9_]*)\\}"); - - private static final Map> TEMPLATES_BY_LANGUAGE = new ConcurrentHashMap<>(); - - private ErrorMessages() { - throw new UnsupportedOperationException("Utility class - do not instantiate"); - } - - /** - * Renders the message of one catalog entry in one language. - * - * @param entry catalog entry whose message template is rendered - * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} - * @param facts fact values keyed by fact parameter name; null values render as the literal placeholder - * @return rendered message, never null - * @throws NullPointerException if {@code entry} is null - */ - public static String render( - @NonNull ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { - return render(entry.getCode(), language, facts); - } - - /** - * Renders the message of one error code in one language. - * - * @param code layered error code, for example {@code content.param_missing} - * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} - * @param facts fact values keyed by fact parameter name; null values render as the literal placeholder - * @return rendered message, never null; the bare code when no template exists in either language - * @throws NullPointerException if {@code code} is null - */ - public static String render(@NonNull String code, @Nullable String language, @Nullable Map facts) { - String template = resolveTemplate(code, language); - if (template == null) { - LOGGER.warn( - "No error message template found for code '{}' in language '{}' or '{}'.", - code, - language, - DEFAULT_LANGUAGE); - return code; - } - return renderTemplate(template, facts); - } - - /** - * Returns the message template of one error code in one language, without rendering it. - * - * @param code layered error code - * @param language message language; null or blank falls back to {@code en-US} - * @return template text, or null when no template exists in the requested language or {@code en-US} - * @throws NullPointerException if {@code code} is null - */ - public static @Nullable String template(@NonNull String code, @Nullable String language) { - return resolveTemplate(code, language); - } - - private static @Nullable String resolveTemplate(String code, String language) { - String normalized = normalizeLanguage(language); - String template = templates(normalized).get(code); - if (template == null && !DEFAULT_LANGUAGE.equals(normalized)) { - template = templates(DEFAULT_LANGUAGE).get(code); - } - return template; - } - - private static Map templates(String language) { - return TEMPLATES_BY_LANGUAGE.computeIfAbsent(language, ErrorMessages::loadTemplates); - } - - private static Map loadTemplates(String language) { - String resourcePath = String.format(RESOURCE_PATTERN, language); - try (InputStream stream = ClasspathResourceStreams.open(resourcePath)) { - if (stream == null) { - LOGGER.warn("Error message resource '{}' not found on the classpath.", resourcePath); - return Map.of(); - } - Map templates = new ObjectMapper().readValue(stream, TEMPLATE_MAP_TYPE); - return Map.copyOf(templates); - } catch (IOException | RuntimeException error) { - LOGGER.warn("Cannot load error message resource '{}': {}", resourcePath, error.getMessage()); - return Map.of(); - } - } - - private static String renderTemplate(String template, Map facts) { - if (facts == null || facts.isEmpty()) { - return template; - } - StringBuilder rendered = new StringBuilder(); - Matcher matcher = PLACEHOLDER.matcher(template); - while (matcher.find()) { - String value = facts.get(matcher.group(1)); - String replacement = value != null ? value : matcher.group(0); - matcher.appendReplacement(rendered, Matcher.quoteReplacement(replacement)); - } - matcher.appendTail(rendered); - return rendered.toString(); - } - - private static String normalizeLanguage(String language) { - if (language == null || language.isBlank()) { - return DEFAULT_LANGUAGE; - } - String trimmed = language.trim(); - return trimmed.isEmpty() ? DEFAULT_LANGUAGE : trimmed; - } -} +package net.openan.a2at.sdk.core.exception; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Renders error messages from the bundled {@code prompt_resources/errors/{language}/errors.json} templates. + * + *

            The language follows the {@code A2AT_LANGUAGE} configuration resolved by the caller (default {@code en-US}). A + * template missing in the requested language falls back to {@code en-US}; a template missing in both languages renders + * as the bare code string. Placeholders are {@code {name}} tokens; a placeholder without a matching fact value is kept + * literal, matching the template style of the A2A-T prompt resources. + * + * @since 2026-08 + */ +public final class ErrorMessages { + + /** Language used when no language is configured or a template is missing in the requested language. */ + public static final String DEFAULT_LANGUAGE = "en-US"; + + private static final Logger LOGGER = LoggerFactory.getLogger(ErrorMessages.class); + + private static final String RESOURCE_PATTERN = "prompt_resources/errors/%s/errors.json"; + + private static final TypeReference> TEMPLATE_MAP_TYPE = + new TypeReference>() {}; + + private static final Pattern PLACEHOLDER = Pattern.compile("\\{([a-zA-Z][a-zA-Z0-9_]*)\\}"); + + private static final Map> TEMPLATES_BY_LANGUAGE = new ConcurrentHashMap<>(); + + private ErrorMessages() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** + * Renders the message of one catalog entry in one language. + * + * @param entry catalog entry whose message template is rendered + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name; null values render as the literal placeholder + * @return rendered message, never null + * @throws NullPointerException if {@code entry} is null + */ + public static String render( + @NonNull ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { + return render(entry.getCode(), language, facts); + } + + /** + * Renders the message of one error code in one language. + * + * @param code layered error code, for example {@code content.param_missing} + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name; null values render as the literal placeholder + * @return rendered message, never null; the bare code when no template exists in either language + * @throws NullPointerException if {@code code} is null + */ + public static String render(@NonNull String code, @Nullable String language, @Nullable Map facts) { + String template = resolveTemplate(code, language); + if (template == null) { + LOGGER.warn( + "No error message template found for code '{}' in language '{}' or '{}'.", + code, + language, + DEFAULT_LANGUAGE); + return code; + } + return renderTemplate(template, facts); + } + + /** + * Returns the message template of one error code in one language, without rendering it. + * + * @param code layered error code + * @param language message language; null or blank falls back to {@code en-US} + * @return template text, or null when no template exists in the requested language or {@code en-US} + * @throws NullPointerException if {@code code} is null + */ + public static @Nullable String template(@NonNull String code, @Nullable String language) { + return resolveTemplate(code, language); + } + + private static @Nullable String resolveTemplate(String code, String language) { + String normalized = normalizeLanguage(language); + String template = templates(normalized).get(code); + if (template == null && !DEFAULT_LANGUAGE.equals(normalized)) { + template = templates(DEFAULT_LANGUAGE).get(code); + } + return template; + } + + private static Map templates(String language) { + return TEMPLATES_BY_LANGUAGE.computeIfAbsent(language, ErrorMessages::loadTemplates); + } + + private static Map loadTemplates(String language) { + String resourcePath = String.format(RESOURCE_PATTERN, language); + try (InputStream stream = ClasspathResourceStreams.open(resourcePath)) { + if (stream == null) { + LOGGER.warn("Error message resource '{}' not found on the classpath.", resourcePath); + return Map.of(); + } + Map templates = new ObjectMapper().readValue(stream, TEMPLATE_MAP_TYPE); + return Map.copyOf(templates); + } catch (IOException | RuntimeException error) { + LOGGER.warn("Cannot load error message resource '{}': {}", resourcePath, error.getMessage()); + return Map.of(); + } + } + + private static String renderTemplate(String template, Map facts) { + if (facts == null || facts.isEmpty()) { + return template; + } + StringBuilder rendered = new StringBuilder(); + Matcher matcher = PLACEHOLDER.matcher(template); + while (matcher.find()) { + String value = facts.get(matcher.group(1)); + String replacement = value != null ? value : matcher.group(0); + matcher.appendReplacement(rendered, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(rendered); + return rendered.toString(); + } + + private static String normalizeLanguage(String language) { + if (language == null || language.isBlank()) { + return DEFAULT_LANGUAGE; + } + String trimmed = language.trim(); + return trimmed.isEmpty() ? DEFAULT_LANGUAGE : trimmed; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java index 3a16367f..d8300f07 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java @@ -1,66 +1,66 @@ -package net.openan.a2at.sdk.core.exception; - -import java.util.List; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Unified exception type for MetadataContent pipeline failures. - * - *

            The machine-readable error code is carried by the root {@link A2ATError} and is never null. - * - * @since 2026-08 - */ -public final class PromptGenerationException extends A2ATBusinessException { - - private final List failedParameters; - - /** - * Creates an exception with a stable error code and a human-readable message. - * - * @param code stable error code - * @param message human-readable failure description - * @throws NullPointerException if {@code code} is null - */ - public PromptGenerationException(@NonNull String code, String message) { - super(code, message); - this.failedParameters = List.of(); - } - - /** - * Creates an exception with a stable error code, a human-readable message, and a list of failed parameters. - * - * @param code stable error code - * @param message human-readable failure description - * @param failedParameters list of failed slot validations - * @throws NullPointerException if {@code code} is null - */ - public PromptGenerationException( - @NonNull String code, String message, @Nullable List failedParameters) { - super(code, message); - this.failedParameters = failedParameters == null ? List.of() : List.copyOf(failedParameters); - } - - /** - * Creates an exception with a stable error code, a human-readable message, and a root cause. - * - * @param code stable error code - * @param message human-readable failure description - * @param cause root cause - * @throws NullPointerException if {@code code} is null - */ - public PromptGenerationException(@NonNull String code, String message, @Nullable Throwable cause) { - super(code, message, null, cause); - this.failedParameters = List.of(); - } - - /** - * Returns the list of failed parameters, or an empty list when not applicable. - * - * @return failed parameters - */ - public @NonNull List failedParameters() { - return failedParameters; - } -} +package net.openan.a2at.sdk.core.exception; + +import java.util.List; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Unified exception type for MetadataContent pipeline failures. + * + *

            The machine-readable error code is carried by the root {@link A2ATError} and is never null. + * + * @since 2026-08 + */ +public final class PromptGenerationException extends A2ATBusinessException { + + private final List failedParameters; + + /** + * Creates an exception with a stable error code and a human-readable message. + * + * @param code stable error code + * @param message human-readable failure description + * @throws NullPointerException if {@code code} is null + */ + public PromptGenerationException(@NonNull String code, String message) { + super(code, message); + this.failedParameters = List.of(); + } + + /** + * Creates an exception with a stable error code, a human-readable message, and a list of failed parameters. + * + * @param code stable error code + * @param message human-readable failure description + * @param failedParameters list of failed slot validations + * @throws NullPointerException if {@code code} is null + */ + public PromptGenerationException( + @NonNull String code, String message, @Nullable List failedParameters) { + super(code, message); + this.failedParameters = failedParameters == null ? List.of() : List.copyOf(failedParameters); + } + + /** + * Creates an exception with a stable error code, a human-readable message, and a root cause. + * + * @param code stable error code + * @param message human-readable failure description + * @param cause root cause + * @throws NullPointerException if {@code code} is null + */ + public PromptGenerationException(@NonNull String code, String message, @Nullable Throwable cause) { + super(code, message, null, cause); + this.failedParameters = List.of(); + } + + /** + * Returns the list of failed parameters, or an empty list when not applicable. + * + * @return failed parameters + */ + public @NonNull List failedParameters() { + return failedParameters; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java index dc89c0e1..28960e17 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java @@ -1,73 +1,73 @@ -package net.openan.a2at.sdk.core.model; - -import java.nio.file.Path; -import java.util.Map; -import org.jspecify.annotations.NonNull; - -/** - * Unified SDK configuration entry point loaded from one caller-supplied `.env` file. Users are expected to copy the - * repository `env.example` into their own application and pass that file path in. - * - * @since 2026-06 - */ -public record A2ATConfig( - PromptRuntimeConfig prompt, - LlmConfig llm, - InputLimitConfig inputLimits, - NegotiationConfig negotiation, - PromptComplianceConfig promptCompliance) { - - /** - * Loads one unified SDK config from one `.env` file path. - * - * @param envPath caller-supplied `.env` file path - * @return unified SDK config - */ - public static @NonNull A2ATConfig load(@NonNull Path envPath) { - Map values = DotEnvConfigSource.load(envPath); - return new A2ATConfig( - PromptRuntimeConfig.fromMap(values), - LlmConfig.fromMap(values), - InputLimitConfig.fromMap(values), - NegotiationConfig.fromMap(values), - PromptComplianceConfig.fromMap(values)); - } - - /** - * Resolves the relative prompt resource local root directory against the `.env` file location. - * - *

            The returned config carries the local root as an absolute normalized path: a relative configured root is - * resolved against the `.env` file parent, an absolute configured root is only normalized. An unset (null or blank) - * local root is preserved as-is, since only {@code local_file} mode consumes the value (and enforces its presence - * at assembly time). - * - * @param config unified SDK config as loaded from the `.env` file - * @param envPath resolved `.env` file path the config was loaded from; a relative path is absolved against the - * current working directory before its parent is derived - * @return config with the local root resolved to an absolute normalized path, or unchanged when the local root is - * unset (null or blank) - */ - public static @NonNull A2ATConfig resolvePromptResourceLocalRootDir( - @NonNull A2ATConfig config, @NonNull Path envPath) { - String localRootDir = config.prompt().localRootDir(); - if (localRootDir == null || localRootDir.isBlank()) { - return config; - } - Path localRootPath = Path.of(localRootDir); - Path resolvedLocalRootPath = localRootPath.isAbsolute() - ? localRootPath.normalize() - : envBaseDir(envPath).resolve(localRootPath).toAbsolutePath().normalize(); - return new A2ATConfig( - new PromptRuntimeConfig( - config.prompt().language(), config.prompt().sourceType(), resolvedLocalRootPath.toString()), - config.llm(), - config.inputLimits(), - config.negotiation(), - config.promptCompliance()); - } - - private static Path envBaseDir(Path envPath) { - Path parent = envPath.toAbsolutePath().normalize().getParent(); - return parent != null ? parent : Path.of("").toAbsolutePath(); - } -} +package net.openan.a2at.sdk.core.model; + +import java.nio.file.Path; +import java.util.Map; +import org.jspecify.annotations.NonNull; + +/** + * Unified SDK configuration entry point loaded from one caller-supplied `.env` file. Users are expected to copy the + * repository `env.example` into their own application and pass that file path in. + * + * @since 2026-06 + */ +public record A2ATConfig( + PromptRuntimeConfig prompt, + LlmConfig llm, + InputLimitConfig inputLimits, + NegotiationConfig negotiation, + PromptComplianceConfig promptCompliance) { + + /** + * Loads one unified SDK config from one `.env` file path. + * + * @param envPath caller-supplied `.env` file path + * @return unified SDK config + */ + public static @NonNull A2ATConfig load(@NonNull Path envPath) { + Map values = DotEnvConfigSource.load(envPath); + return new A2ATConfig( + PromptRuntimeConfig.fromMap(values), + LlmConfig.fromMap(values), + InputLimitConfig.fromMap(values), + NegotiationConfig.fromMap(values), + PromptComplianceConfig.fromMap(values)); + } + + /** + * Resolves the relative prompt resource local root directory against the `.env` file location. + * + *

            The returned config carries the local root as an absolute normalized path: a relative configured root is + * resolved against the `.env` file parent, an absolute configured root is only normalized. An unset (null or blank) + * local root is preserved as-is, since only {@code local_file} mode consumes the value (and enforces its presence + * at assembly time). + * + * @param config unified SDK config as loaded from the `.env` file + * @param envPath resolved `.env` file path the config was loaded from; a relative path is absolved against the + * current working directory before its parent is derived + * @return config with the local root resolved to an absolute normalized path, or unchanged when the local root is + * unset (null or blank) + */ + public static @NonNull A2ATConfig resolvePromptResourceLocalRootDir( + @NonNull A2ATConfig config, @NonNull Path envPath) { + String localRootDir = config.prompt().localRootDir(); + if (localRootDir == null || localRootDir.isBlank()) { + return config; + } + Path localRootPath = Path.of(localRootDir); + Path resolvedLocalRootPath = localRootPath.isAbsolute() + ? localRootPath.normalize() + : envBaseDir(envPath).resolve(localRootPath).toAbsolutePath().normalize(); + return new A2ATConfig( + new PromptRuntimeConfig( + config.prompt().language(), config.prompt().sourceType(), resolvedLocalRootPath.toString()), + config.llm(), + config.inputLimits(), + config.negotiation(), + config.promptCompliance()); + } + + private static Path envBaseDir(Path envPath) { + Path parent = envPath.toAbsolutePath().normalize().getParent(); + return parent != null ? parent : Path.of("").toAbsolutePath(); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java index 59a8dd45..a8a0647a 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java @@ -1,96 +1,96 @@ -package net.openan.a2at.sdk.core.model; - -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -/** - * Centralized configuration key constants for A2A-T SDK environment variables. These keys correspond to entries in the - * {@code .env} file loaded by {@link DotEnvConfigSource}. - * - * @since 2026-06 - */ -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public final class A2ATConfigKeys { - - /** Prompt runtime configuration keys. */ - @NoArgsConstructor(access = AccessLevel.PRIVATE) - public static final class PromptRuntime { - - /** Controls the language used by prompt runtime components. */ - public static final String LANGUAGE = "A2AT_LANGUAGE"; - - /** Selects the prompt source backend (e.g., classpath, local_file). */ - public static final String SOURCE_TYPE = "A2AT_PROMPT_SOURCE_TYPE"; - - /** Optional root directory when A2AT_PROMPT_SOURCE_TYPE=local_file. */ - public static final String LOCAL_ROOT_DIR = "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"; - } - - /** LLM runtime configuration keys. */ - @NoArgsConstructor(access = AccessLevel.PRIVATE) - public static final class Llm { - - /** Selects the LLM provider used by the shared client (e.g., openai). */ - public static final String PROVIDER = "A2AT_LLM_PROVIDER"; - - /** Selects the default model name for the configured provider. */ - public static final String MODEL = "A2AT_LLM_MODEL"; - - /** API key for the configured LLM provider. */ - public static final String API_KEY = "A2AT_LLM_API_KEY"; - - /** Base URL for the configured LLM provider. */ - public static final String BASE_URL = "A2AT_LLM_BASE_URL"; - - /** Number of chat history messages to keep. */ - public static final String HISTORY_WINDOW = "A2AT_LLM_HISTORY_WINDOW"; - - /** Optional maximum tokens for completion calls. */ - public static final String MAX_TOKENS = "A2AT_LLM_MAX_TOKENS"; - - /** Default sampling temperature. */ - public static final String TEMPERATURE = "A2AT_LLM_TEMPERATURE"; - - /** Request timeout in seconds. */ - public static final String TIMEOUT_SECONDS = "A2AT_LLM_TIMEOUT_SECONDS"; - - /** Disables use of the JVM or operating-system HTTP proxy for the LLM request. */ - public static final String DISABLE_SYSTEM_PROXY = "A2AT_LLM_DISABLE_SYSTEM_PROXY"; - - /** Optional reasoning effort level for reasoning models (none/minimal/low/medium/high/xhigh). */ - public static final String REASONING_EFFORT = "A2AT_LLM_REASONING_EFFORT"; - - /** Maximum total number of tracked sessions. */ - public static final String SESSION_MAX_TOTAL = "A2AT_LLM_SESSION_MAX_TOTAL"; - - /** Maximum number of tracked sessions per provider. */ - public static final String SESSION_MAX_PER_PROVIDER = "A2AT_LLM_SESSION_MAX_PER_PROVIDER"; - - /** Maximum number of attempts for retryable LLM steps (range 1-10, default 3). */ - public static final String MAX_ATTEMPTS = "A2AT_LLM_MAX_ATTEMPTS"; - } - - /** Negotiation runtime configuration keys. */ - @NoArgsConstructor(access = AccessLevel.PRIVATE) - public static final class Negotiation { - - /** Selects the negotiation state store implementation (e.g., in_memory). */ - public static final String STATE_STORE_TYPE = "A2AT_NEGOTIATION_STATE_STORE_TYPE"; - } - - /** Input limit configuration keys. */ - @NoArgsConstructor(access = AccessLevel.PRIVATE) - public static final class Input { - - /** Maximum length in characters accepted for free-text inputs before they reach an LLM step. */ - public static final String MAX_TEXT_CHARS = "A2AT_INPUT_TEXT_MAX_CHARS"; - } - - /** Prompt compliance configuration keys. */ - @NoArgsConstructor(access = AccessLevel.PRIVATE) - public static final class PromptCompliance { - - /** Enables or disables prompt compliance checks. */ - public static final String ENABLED = "A2AT_PROMPT_COMPLIANCE_ENABLED"; - } -} +package net.openan.a2at.sdk.core.model; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Centralized configuration key constants for A2A-T SDK environment variables. These keys correspond to entries in the + * {@code .env} file loaded by {@link DotEnvConfigSource}. + * + * @since 2026-06 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class A2ATConfigKeys { + + /** Prompt runtime configuration keys. */ + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static final class PromptRuntime { + + /** Controls the language used by prompt runtime components. */ + public static final String LANGUAGE = "A2AT_LANGUAGE"; + + /** Selects the prompt source backend (e.g., classpath, local_file). */ + public static final String SOURCE_TYPE = "A2AT_PROMPT_SOURCE_TYPE"; + + /** Optional root directory when A2AT_PROMPT_SOURCE_TYPE=local_file. */ + public static final String LOCAL_ROOT_DIR = "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"; + } + + /** LLM runtime configuration keys. */ + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static final class Llm { + + /** Selects the LLM provider used by the shared client (e.g., openai). */ + public static final String PROVIDER = "A2AT_LLM_PROVIDER"; + + /** Selects the default model name for the configured provider. */ + public static final String MODEL = "A2AT_LLM_MODEL"; + + /** API key for the configured LLM provider. */ + public static final String API_KEY = "A2AT_LLM_API_KEY"; + + /** Base URL for the configured LLM provider. */ + public static final String BASE_URL = "A2AT_LLM_BASE_URL"; + + /** Number of chat history messages to keep. */ + public static final String HISTORY_WINDOW = "A2AT_LLM_HISTORY_WINDOW"; + + /** Optional maximum tokens for completion calls. */ + public static final String MAX_TOKENS = "A2AT_LLM_MAX_TOKENS"; + + /** Default sampling temperature. */ + public static final String TEMPERATURE = "A2AT_LLM_TEMPERATURE"; + + /** Request timeout in seconds. */ + public static final String TIMEOUT_SECONDS = "A2AT_LLM_TIMEOUT_SECONDS"; + + /** Disables use of the JVM or operating-system HTTP proxy for the LLM request. */ + public static final String DISABLE_SYSTEM_PROXY = "A2AT_LLM_DISABLE_SYSTEM_PROXY"; + + /** Optional reasoning effort level for reasoning models (none/minimal/low/medium/high/xhigh). */ + public static final String REASONING_EFFORT = "A2AT_LLM_REASONING_EFFORT"; + + /** Maximum total number of tracked sessions. */ + public static final String SESSION_MAX_TOTAL = "A2AT_LLM_SESSION_MAX_TOTAL"; + + /** Maximum number of tracked sessions per provider. */ + public static final String SESSION_MAX_PER_PROVIDER = "A2AT_LLM_SESSION_MAX_PER_PROVIDER"; + + /** Maximum number of attempts for retryable LLM steps (range 1-10, default 3). */ + public static final String MAX_ATTEMPTS = "A2AT_LLM_MAX_ATTEMPTS"; + } + + /** Negotiation runtime configuration keys. */ + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static final class Negotiation { + + /** Selects the negotiation state store implementation (e.g., in_memory). */ + public static final String STATE_STORE_TYPE = "A2AT_NEGOTIATION_STATE_STORE_TYPE"; + } + + /** Input limit configuration keys. */ + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static final class Input { + + /** Maximum length in characters accepted for free-text inputs before they reach an LLM step. */ + public static final String MAX_TEXT_CHARS = "A2AT_INPUT_TEXT_MAX_CHARS"; + } + + /** Prompt compliance configuration keys. */ + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static final class PromptCompliance { + + /** Enables or disables prompt compliance checks. */ + public static final String ENABLED = "A2AT_PROMPT_COMPLIANCE_ENABLED"; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfig.java index 427b94a9..e61c0f77 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfig.java @@ -1,35 +1,35 @@ -package net.openan.a2at.sdk.core.model; - -import java.util.Map; -import org.apache.commons.lang3.StringUtils; - -/** - * Prompt runtime configuration resolved from unified SDK config. - * - * @since 2026-06 - */ -public record PromptRuntimeConfig(String language, String sourceType, String localRootDir) { - - private static final String DEFAULT_LANGUAGE = "en-US"; - - /** Source-type selector value for loading business content from the built-in classpath. */ - public static final String SOURCE_TYPE_CLASSPATH = "classpath"; - - /** Source-type selector value for loading business content from the configured local root. */ - public static final String SOURCE_TYPE_LOCAL_FILE = "local_file"; - - private static final String DEFAULT_SOURCE_TYPE = SOURCE_TYPE_CLASSPATH; - - /** - * Builds one prompt runtime config from raw `.env` values. - * - * @param values raw config values - * @return resolved prompt runtime config - */ - public static PromptRuntimeConfig fromMap(Map values) { - return new PromptRuntimeConfig( - StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.PromptRuntime.LANGUAGE), DEFAULT_LANGUAGE), - StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.PromptRuntime.SOURCE_TYPE), DEFAULT_SOURCE_TYPE), - values.get(A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR)); - } -} +package net.openan.a2at.sdk.core.model; + +import java.util.Map; +import org.apache.commons.lang3.StringUtils; + +/** + * Prompt runtime configuration resolved from unified SDK config. + * + * @since 2026-06 + */ +public record PromptRuntimeConfig(String language, String sourceType, String localRootDir) { + + private static final String DEFAULT_LANGUAGE = "en-US"; + + /** Source-type selector value for loading business content from the built-in classpath. */ + public static final String SOURCE_TYPE_CLASSPATH = "classpath"; + + /** Source-type selector value for loading business content from the configured local root. */ + public static final String SOURCE_TYPE_LOCAL_FILE = "local_file"; + + private static final String DEFAULT_SOURCE_TYPE = SOURCE_TYPE_CLASSPATH; + + /** + * Builds one prompt runtime config from raw `.env` values. + * + * @param values raw config values + * @return resolved prompt runtime config + */ + public static PromptRuntimeConfig fromMap(Map values) { + return new PromptRuntimeConfig( + StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.PromptRuntime.LANGUAGE), DEFAULT_LANGUAGE), + StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.PromptRuntime.SOURCE_TYPE), DEFAULT_SOURCE_TYPE), + values.get(A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR)); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java index 5b240563..483fd733 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java @@ -1,28 +1,28 @@ -package net.openan.a2at.sdk.core.model; - -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * One loadable prompt template of any A2A-T extension. - * - * @param templateUri typed template URI such as {@code Task-T/network-layer/ran-energy-saving/v1} or - * {@code Negotiation-T/information-negotiation/propose/v1} - * @param description template description taken from the leading HTML comment; empty string when the template has no - * comment - * @param content full template file text; null when the template content is unavailable - * @param source effective origin of the loaded template, either {@code classpath} or {@code local} - * @since 2026-08 - */ -public record PromptTemplate( - @NonNull TemplateUri templateUri, - @NonNull String description, - @Nullable String content, - @NonNull String source) { - - /** Source marker for templates loaded from the built-in classpath resources. */ - public static final String SOURCE_CLASSPATH = "classpath"; - - /** Source marker for templates loaded from the configured local root. */ - public static final String SOURCE_LOCAL = "local"; -} +package net.openan.a2at.sdk.core.model; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * One loadable prompt template of any A2A-T extension. + * + * @param templateUri typed template URI such as {@code Task-T/network-layer/ran-energy-saving/v1} or + * {@code Negotiation-T/information-negotiation/propose/v1} + * @param description template description taken from the leading HTML comment; empty string when the template has no + * comment + * @param content full template file text; null when the template content is unavailable + * @param source effective origin of the loaded template, either {@code classpath} or {@code local} + * @since 2026-08 + */ +public record PromptTemplate( + @NonNull TemplateUri templateUri, + @NonNull String description, + @Nullable String content, + @NonNull String source) { + + /** Source marker for templates loaded from the built-in classpath resources. */ + public static final String SOURCE_CLASSPATH = "classpath"; + + /** Source marker for templates loaded from the configured local root. */ + public static final String SOURCE_LOCAL = "local"; +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/SlotValidationError.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/SlotValidationError.java index a8e22ba6..8aa71290 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/SlotValidationError.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/SlotValidationError.java @@ -1,35 +1,35 @@ -package net.openan.a2at.sdk.core.model; - -import java.util.Map; -import org.jspecify.annotations.Nullable; - -/** - * Structured validation error details for one named slot. - * - *

            Instances are carried inside parameter-extraction failures so callers can inspect which slot failed, under which - * error code, and why, without parsing exception messages. The {@code message} is rendered by the SDK from the code's - * message template; {@code facts} carries the structured fact values that produced the message. - * - * @param slotName name of the slot the error refers to - * @param code machine-readable error code for the error - * @param message human-readable explanation of the error - * @param facts fact values keyed by fact parameter name, may be null - * @since 2026-08 - */ -public record SlotValidationError(String slotName, String code, String message, @Nullable Map facts) { - - public SlotValidationError { - facts = facts == null ? null : Map.copyOf(facts); - } - - /** - * Creates a slot validation error without structured facts. - * - * @param slotName name of the slot the error refers to - * @param code machine-readable error code for the error - * @param message human-readable explanation of the error - */ - public SlotValidationError(String slotName, String code, String message) { - this(slotName, code, message, null); - } -} +package net.openan.a2at.sdk.core.model; + +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * Structured validation error details for one named slot. + * + *

            Instances are carried inside parameter-extraction failures so callers can inspect which slot failed, under which + * error code, and why, without parsing exception messages. The {@code message} is rendered by the SDK from the code's + * message template; {@code facts} carries the structured fact values that produced the message. + * + * @param slotName name of the slot the error refers to + * @param code machine-readable error code for the error + * @param message human-readable explanation of the error + * @param facts fact values keyed by fact parameter name, may be null + * @since 2026-08 + */ +public record SlotValidationError(String slotName, String code, String message, @Nullable Map facts) { + + public SlotValidationError { + facts = facts == null ? null : Map.copyOf(facts); + } + + /** + * Creates a slot validation error without structured facts. + * + * @param slotName name of the slot the error refers to + * @param code machine-readable error code for the error + * @param message human-readable explanation of the error + */ + public SlotValidationError(String slotName, String code, String message) { + this(slotName, code, message, null); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java index dc2b8083..efa7ef2c 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java @@ -1,66 +1,66 @@ -package net.openan.a2at.sdk.core.resources; - -import org.jspecify.annotations.Nullable; - -/** - * Validates path segment values used to compose classpath or filesystem resource paths. - * - *

            A simple segment is non-null, non-blank and free of slashes, backslashes and {@code ..} sequences, so that it can - * never escape the resource root it is resolved against. - * - * @since 2026-08 - */ -public final class PathSegments { - - private PathSegments() { - throw new UnsupportedOperationException("Utility class - do not instantiate"); - } - - /** - * Checks whether the value is a non-blank simple path segment. - * - * @param value candidate path segment such as a language or category identifier - * @return true when the value is non-null, non-blank and contains no slash, backslash or {@code ..} sequence - */ - public static boolean isSimpleSegment(@Nullable String value) { - return value != null - && !value.isBlank() - && !value.contains("/") - && !value.contains("\\") - && !value.contains(".."); - } - - /** - * Requires the value to be a non-blank simple path segment, throwing an {@link IllegalArgumentException} otherwise. - * - * @param value candidate path segment such as a language or category identifier; may be {@code null} - * @param label human-readable label describing the segment, used in the failure message - * @throws IllegalArgumentException when the value is not a simple path segment - */ - public static void requireSimpleSegment(@Nullable String value, String label) { - if (!isSimpleSegment(value)) { - throw new IllegalArgumentException( - label + " must be a non-blank simple path segment but was " + value + "."); - } - } - - /** - * Requires the value to be a non-blank relative path whose slash-separated segments are each simple path segments, - * throwing an {@link IllegalArgumentException} otherwise. - * - * @param value candidate relative path such as a scenario code; may be {@code null} - * @param label human-readable label describing the path, used in the failure message - * @throws IllegalArgumentException when the value is null, blank or contains a non-simple path segment - */ - public static void requireSimpleRelativePath(@Nullable String value, String label) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException(label + " must be a non-blank relative path but was " + value + "."); - } - for (String segment : value.split("/", -1)) { - if (!isSimpleSegment(segment)) { - throw new IllegalArgumentException( - label + " must contain only simple path segments but was " + value + "."); - } - } - } -} +package net.openan.a2at.sdk.core.resources; + +import org.jspecify.annotations.Nullable; + +/** + * Validates path segment values used to compose classpath or filesystem resource paths. + * + *

            A simple segment is non-null, non-blank and free of slashes, backslashes and {@code ..} sequences, so that it can + * never escape the resource root it is resolved against. + * + * @since 2026-08 + */ +public final class PathSegments { + + private PathSegments() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** + * Checks whether the value is a non-blank simple path segment. + * + * @param value candidate path segment such as a language or category identifier + * @return true when the value is non-null, non-blank and contains no slash, backslash or {@code ..} sequence + */ + public static boolean isSimpleSegment(@Nullable String value) { + return value != null + && !value.isBlank() + && !value.contains("/") + && !value.contains("\\") + && !value.contains(".."); + } + + /** + * Requires the value to be a non-blank simple path segment, throwing an {@link IllegalArgumentException} otherwise. + * + * @param value candidate path segment such as a language or category identifier; may be {@code null} + * @param label human-readable label describing the segment, used in the failure message + * @throws IllegalArgumentException when the value is not a simple path segment + */ + public static void requireSimpleSegment(@Nullable String value, String label) { + if (!isSimpleSegment(value)) { + throw new IllegalArgumentException( + label + " must be a non-blank simple path segment but was " + value + "."); + } + } + + /** + * Requires the value to be a non-blank relative path whose slash-separated segments are each simple path segments, + * throwing an {@link IllegalArgumentException} otherwise. + * + * @param value candidate relative path such as a scenario code; may be {@code null} + * @param label human-readable label describing the path, used in the failure message + * @throws IllegalArgumentException when the value is null, blank or contains a non-simple path segment + */ + public static void requireSimpleRelativePath(@Nullable String value, String label) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(label + " must be a non-blank relative path but was " + value + "."); + } + for (String segment : value.split("/", -1)) { + if (!isSimpleSegment(segment)) { + throw new IllegalArgumentException( + label + " must contain only simple path segments but was " + value + "."); + } + } + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java index c400e1ad..8bb4d8ed 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java @@ -1,105 +1,105 @@ -package net.openan.a2at.sdk.core.validation; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATBusinessException; -import net.openan.a2at.sdk.core.model.SlotValidationError; - -/** - * Failure raised by the content validation pipeline. - * - *

            Carries the machine-readable error code inherited from {@link A2ATBusinessException} and optionally structured - * per-slot validation error details so callers can inspect which slot failed, under which error code, and why, without - * parsing exception messages. - * - * @since 2026-08 - */ -public class ContentValidationException extends A2ATBusinessException { - - private final List errors; - - private final Map params; - - /** - * Creates a content validation failure with an error code and one message. - * - * @param code machine-readable error code for the failure - * @param message failure message - */ - public ContentValidationException(String code, String message) { - this(code, message, List.of(), Map.of(), null); - } - - /** - * Creates a content validation failure with an error code, one message and slot details. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param errors structured per-slot validation error details - */ - public ContentValidationException(String code, String message, List errors) { - this(code, message, errors, Map.of(), null); - } - - /** - * Creates a content validation failure with an error code, one message and a root cause. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param cause root cause - */ - public ContentValidationException(String code, String message, Throwable cause) { - this(code, message, List.of(), Map.of(), cause); - } - - public ContentValidationException(String code, String message, List errors, Throwable cause) { - this(code, message, errors, Map.of(), cause); - } - - /** - * Creates a content validation failure with an error code, one message, slot details, partial extraction params and - * a root cause. - * - *

            {@code params} carries the extraction result produced before rejection; the semantic validator deliberately - * emits {@code null} values for slots it could not extract, so both the map and its values may be {@code null} and - * must be accepted defensively. The stored copies are unmodifiable and preserve the original ordering. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param errors structured per-slot validation error details - * @param params partial extraction params, may carry {@code null} values - * @param cause root cause - */ - public ContentValidationException( - String code, - String message, - List errors, - Map params, - Throwable cause) { - super(code, message, null, cause); - this.errors = errors == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(errors)); - this.params = params == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(params)); - } - - /** - * Returns the structured per-slot validation error details. - * - * @return immutable list of slot validation errors, never null - */ - public List errors() { - return errors; - } - - /** - * Returns the partial extraction params captured before rejection. - * - * @return unmodifiable map preserving insertion order, never null; values may be {@code null} for slots the - * semantic validator could not extract - */ - public Map params() { - return params; - } -} +package net.openan.a2at.sdk.core.validation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.model.SlotValidationError; + +/** + * Failure raised by the content validation pipeline. + * + *

            Carries the machine-readable error code inherited from {@link A2ATBusinessException} and optionally structured + * per-slot validation error details so callers can inspect which slot failed, under which error code, and why, without + * parsing exception messages. + * + * @since 2026-08 + */ +public class ContentValidationException extends A2ATBusinessException { + + private final List errors; + + private final Map params; + + /** + * Creates a content validation failure with an error code and one message. + * + * @param code machine-readable error code for the failure + * @param message failure message + */ + public ContentValidationException(String code, String message) { + this(code, message, List.of(), Map.of(), null); + } + + /** + * Creates a content validation failure with an error code, one message and slot details. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + */ + public ContentValidationException(String code, String message, List errors) { + this(code, message, errors, Map.of(), null); + } + + /** + * Creates a content validation failure with an error code, one message and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param cause root cause + */ + public ContentValidationException(String code, String message, Throwable cause) { + this(code, message, List.of(), Map.of(), cause); + } + + public ContentValidationException(String code, String message, List errors, Throwable cause) { + this(code, message, errors, Map.of(), cause); + } + + /** + * Creates a content validation failure with an error code, one message, slot details, partial extraction params and + * a root cause. + * + *

            {@code params} carries the extraction result produced before rejection; the semantic validator deliberately + * emits {@code null} values for slots it could not extract, so both the map and its values may be {@code null} and + * must be accepted defensively. The stored copies are unmodifiable and preserve the original ordering. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + * @param params partial extraction params, may carry {@code null} values + * @param cause root cause + */ + public ContentValidationException( + String code, + String message, + List errors, + Map params, + Throwable cause) { + super(code, message, null, cause); + this.errors = errors == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(errors)); + this.params = params == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(params)); + } + + /** + * Returns the structured per-slot validation error details. + * + * @return immutable list of slot validation errors, never null + */ + public List errors() { + return errors; + } + + /** + * Returns the partial extraction params captured before rejection. + * + * @return unmodifiable map preserving insertion order, never null; values may be {@code null} for slots the + * semantic validator could not extract + */ + public Map params() { + return params; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java index 41f18f24..c9304fac 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java @@ -1,32 +1,32 @@ -package net.openan.a2at.sdk.core.validation; - -import java.util.Map; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.TemplateUri; -import org.jspecify.annotations.NonNull; - -/** - * Entry point for validating content and extracting filled parameters. - * - *

            Implementations orchestrate the full validation pipeline: input validation, rule-level gate, semantic validation - * and parameter merging. - * - * @since 2026-08 - */ -public interface ContentValidator { - - /** - * Validates one content prompt and extracts its filled parameters. - * - * @param prompt content prompt text - * @param schema caller-provided parameter JSON schema - * @param templateUri URI of the template the content is validated against, such as - * {@code Task-T/network-layer/ran-energy-saving/v1} - * @return filled parameter data carrying the merged parameters - * @throws ContentValidationException with {@code negotiation.invalid_input} if the template URI is null or - * malformed, the prompt is null or blank, or the schema is null - * @throws ContentValidationException if the validation fails at any stage - */ - FilledParamData validate( - @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri); -} +package net.openan.a2at.sdk.core.validation; + +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.jspecify.annotations.NonNull; + +/** + * Entry point for validating content and extracting filled parameters. + * + *

            Implementations orchestrate the full validation pipeline: input validation, rule-level gate, semantic validation + * and parameter merging. + * + * @since 2026-08 + */ +public interface ContentValidator { + + /** + * Validates one content prompt and extracts its filled parameters. + * + * @param prompt content prompt text + * @param schema caller-provided parameter JSON schema + * @param templateUri URI of the template the content is validated against, such as + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return filled parameter data carrying the merged parameters + * @throws ContentValidationException with {@code negotiation.invalid_input} if the template URI is null or + * malformed, the prompt is null or blank, or the schema is null + * @throws ContentValidationException if the validation fails at any stage + */ + FilledParamData validate( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri); +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java index fc3cd62b..c3074105 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java @@ -1,73 +1,73 @@ -package net.openan.a2at.sdk.core.validation; - -import java.util.function.Supplier; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Lightweight retry utility for the content validation pipeline. - * - *

            Only retries on {@link ContentValidationException} failures carrying an {@code llm.*} catalog code, such as - * {@link ErrorCatalog#LLM_INVOCATION_FAILED} and {@link ErrorCatalog#LLM_RESPONSE_INVALID}. All other failure codes and - * unknown runtime exceptions are rethrown immediately. - * - * @since 2026-08 - */ -final class RetryUtil { - - private static final Logger LOGGER = LoggerFactory.getLogger(RetryUtil.class); - - private static final String LLM_CODE_DOMAIN = "llm."; - - private RetryUtil() {} - - /** - * Executes one action with retry on LLM failures. - * - * @param result type - * @param maxAttempts maximum number of attempts, must be at least 1 - * @param stepName name of the step for logging purposes - * @param action the action to execute - * @return the result of the action - * @throws ContentValidationException if the action fails on every attempt with a retryable error, or if it fails - * with a non-retryable error - */ - public static T withRetry(int maxAttempts, String stepName, Supplier action) { - if (maxAttempts < 1) { - throw new IllegalArgumentException("maxAttempts must be at least 1"); - } - for (int attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return action.get(); - } catch (ContentValidationException exception) { - if (!isRetryableLlmFailure(exception)) { - throw exception; - } - if (attempt == maxAttempts) { - LOGGER.atWarn() - .log( - "{}_retry_exhausted attempts={} last_error={}", - stepName, - attempt, - exception.getMessage()); - throw exception; - } - LOGGER.atWarn() - .log( - "{}_retry_attempt attempt={}/{} error={}", - stepName, - attempt, - maxAttempts, - exception.getMessage()); - } catch (RuntimeException exception) { - throw exception; - } - } - throw new IllegalStateException("unreachable"); - } - - private static boolean isRetryableLlmFailure(ContentValidationException exception) { - return exception.getCode().startsWith(LLM_CODE_DOMAIN); - } -} +package net.openan.a2at.sdk.core.validation; + +import java.util.function.Supplier; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Lightweight retry utility for the content validation pipeline. + * + *

            Only retries on {@link ContentValidationException} failures carrying an {@code llm.*} catalog code, such as + * {@link ErrorCatalog#LLM_INVOCATION_FAILED} and {@link ErrorCatalog#LLM_RESPONSE_INVALID}. All other failure codes and + * unknown runtime exceptions are rethrown immediately. + * + * @since 2026-08 + */ +final class RetryUtil { + + private static final Logger LOGGER = LoggerFactory.getLogger(RetryUtil.class); + + private static final String LLM_CODE_DOMAIN = "llm."; + + private RetryUtil() {} + + /** + * Executes one action with retry on LLM failures. + * + * @param result type + * @param maxAttempts maximum number of attempts, must be at least 1 + * @param stepName name of the step for logging purposes + * @param action the action to execute + * @return the result of the action + * @throws ContentValidationException if the action fails on every attempt with a retryable error, or if it fails + * with a non-retryable error + */ + public static T withRetry(int maxAttempts, String stepName, Supplier action) { + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be at least 1"); + } + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return action.get(); + } catch (ContentValidationException exception) { + if (!isRetryableLlmFailure(exception)) { + throw exception; + } + if (attempt == maxAttempts) { + LOGGER.atWarn() + .log( + "{}_retry_exhausted attempts={} last_error={}", + stepName, + attempt, + exception.getMessage()); + throw exception; + } + LOGGER.atWarn() + .log( + "{}_retry_attempt attempt={}/{} error={}", + stepName, + attempt, + maxAttempts, + exception.getMessage()); + } catch (RuntimeException exception) { + throw exception; + } + } + throw new IllegalStateException("unreachable"); + } + + private static boolean isRetryableLlmFailure(ContentValidationException exception) { + return exception.getCode().startsWith(LLM_CODE_DOMAIN); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java index cb8b1a3f..a3cfb446 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java @@ -1,27 +1,27 @@ -package net.openan.a2at.sdk.core.validation; - -import org.jspecify.annotations.NonNull; - -/** - * Loads the template text for a template reference during validation. - * - *

            Injected into {@link ValidationPipeline} as an optional gate that runs after the rule gate and before semantic - * validation. Loaders resolve the template the content is validated against, so the semantic validator can compare the - * content against the authoritative template body. - * - * @param template addressing type the loader resolves - * @since 2026-08 - */ -@FunctionalInterface -public interface TemplateContentLoader { - - /** - * Loads the template text for the given template reference. - * - * @param reference template addressing value to resolve - * @return loaded template text - * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the template cannot be resolved - */ - @NonNull - String load(@NonNull T reference); -} +package net.openan.a2at.sdk.core.validation; + +import org.jspecify.annotations.NonNull; + +/** + * Loads the template text for a template reference during validation. + * + *

            Injected into {@link ValidationPipeline} as an optional gate that runs after the rule gate and before semantic + * validation. Loaders resolve the template the content is validated against, so the semantic validator can compare the + * content against the authoritative template body. + * + * @param template addressing type the loader resolves + * @since 2026-08 + */ +@FunctionalInterface +public interface TemplateContentLoader { + + /** + * Loads the template text for the given template reference. + * + * @param reference template addressing value to resolve + * @return loaded template text + * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the template cannot be resolved + */ + @NonNull + String load(@NonNull T reference); +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java index 4ba6bc57..27c356e6 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java @@ -1,254 +1,254 @@ -package net.openan.a2at.sdk.core.validation; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Orchestrates the content validation pipeline: input validation, rule-level gate, retryable semantic validation and - * deterministic parameter merging. - * - *

            Parameter merging writes the context parameters first and the semantically extracted parameters second; on a key - * conflict the context parameter wins and a warning is logged, so the LLM output can never override the rule-level - * parsed values. - * - * @param template addressing type the validation is performed against, such as {@link TemplateUri} - * @since 2026-08 - */ -public final class ValidationPipeline { - - private static final Logger LOGGER = LoggerFactory.getLogger(ValidationPipeline.class); - - private static final String SEMANTIC_VALIDATION_STEP = "semantic_validation"; - - private final RuleChecker ruleChecker; - - private final SemanticValidator semanticValidator; - - private final int maxAttempts; - - private final String language; - - private final TemplateContentLoader templateContentLoader; - - /** - * Creates a validation pipeline without a template loading gate. - * - * @param ruleChecker rule-level checker used as the entry gate - * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters - * @param maxAttempts maximum number of retry attempts for the semantic validation step - * @throws NullPointerException if the rule checker or the semantic validator is null - */ - public ValidationPipeline( - @NonNull RuleChecker ruleChecker, @NonNull SemanticValidator semanticValidator, int maxAttempts) { - this(ruleChecker, semanticValidator, maxAttempts, null, null); - } - - /** - * Creates a validation pipeline without a template loading gate. - * - * @param ruleChecker rule-level checker used as the entry gate - * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters - * @param maxAttempts maximum number of retry attempts for the semantic validation step - * @param language language used to render failure messages, for example {@code zh-CN}; null falls back to - * {@code en-US} - * @throws NullPointerException if the rule checker or the semantic validator is null - */ - public ValidationPipeline( - @NonNull RuleChecker ruleChecker, - @NonNull SemanticValidator semanticValidator, - int maxAttempts, - @Nullable String language) { - this(ruleChecker, semanticValidator, maxAttempts, language, null); - } - - /** - * Creates a validation pipeline with an optional template loading gate. - * - *

            The loader, when injected, runs after the input gate and before semantic validation to resolve the template - * body for the {@link #validate(String, Map, Object)} variant. A {@code null} loader leaves only the preloaded - * {@link #validate(String, Map, Object, String)} variant available. - * - * @param ruleChecker rule-level checker used as the entry gate - * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters - * @param maxAttempts maximum number of retry attempts for the semantic validation step - * @param language language used to render failure messages, for example {@code zh-CN}; null falls back to - * {@code en-US} - * @param templateContentLoader optional loader resolving the template body, may be null - * @throws NullPointerException if the rule checker or the semantic validator is null - */ - public ValidationPipeline( - @NonNull RuleChecker ruleChecker, - @NonNull SemanticValidator semanticValidator, - int maxAttempts, - @Nullable String language, - @Nullable TemplateContentLoader templateContentLoader) { - this.ruleChecker = Objects.requireNonNull(ruleChecker, "ruleChecker"); - this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); - this.maxAttempts = maxAttempts; - this.language = language; - this.templateContentLoader = templateContentLoader; - } - - /** - * Validates one content prompt and extracts its filled parameters through the full pipeline, loading the template - * body via the injected template loading gate after the input gate and before semantic validation. - * - * @param prompt content prompt text - * @param schema caller-provided parameter JSON schema - * @param reference template addressing value the content is validated against - * @return filled parameter data carrying the merged parameters - * @throws ContentValidationException with {@code negotiation.invalid_input} if the prompt is null or blank, the - * schema is null, or the reference is null - * @throws IllegalStateException if no {@link TemplateContentLoader} was injected into this pipeline - * @throws ContentValidationException if the validation fails at any stage - */ - public @NonNull FilledParamData validate( - @NonNull String prompt, @NonNull Map schema, @NonNull T reference) { - if (templateContentLoader == null) { - throw new IllegalStateException( - "Template content loader is not configured; provide the template content explicitly."); - } - - Map contextParams = validateInputsAndRunRuleGate(prompt, schema, reference); - - String templateContent; - try { - templateContent = templateContentLoader.load(reference); - } catch (ResourceNotFoundException exception) { - throw templateNotFound(exception); - } - - return runSemanticValidationAndMerge(prompt, schema, reference, contextParams, templateContent); - } - - /** - * Validates one content prompt and extracts its filled parameters through the full pipeline. - * - * @param prompt content prompt text - * @param schema caller-provided parameter JSON schema - * @param reference template addressing value the content is validated against - * @param templateContent loaded template text used as a reference for structure/completeness checks - * @return filled parameter data carrying the merged parameters - * @throws ContentValidationException with {@code negotiation.invalid_input} if the prompt is null or blank, the - * schema is null, or the reference is null - * @throws ContentValidationException if the validation fails at any stage - */ - public @NonNull FilledParamData validate( - @NonNull String prompt, - @NonNull Map schema, - @NonNull T reference, - @NonNull String templateContent) { - Map contextParams = validateInputsAndRunRuleGate(prompt, schema, reference); - return runSemanticValidationAndMerge(prompt, schema, reference, contextParams, templateContent); - } - - private Map validateInputsAndRunRuleGate( - @NonNull String prompt, @NonNull Map schema, @NonNull T reference) { - if (schema == null) { - throw invalidInput("Parameter schema must not be null."); - } - if (prompt == null || prompt.isBlank()) { - throw invalidInput("Prompt must not be null or blank."); - } - if (reference == null) { - throw invalidInput("Template reference must not be null."); - } - return ruleChecker.check(prompt); - } - - private ContentValidationException invalidInput(String reason) { - Map facts = Map.of("reason", reason); - return new ContentValidationException( - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), - List.of(new SlotValidationError( - "_input", - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), - facts)), - null); - } - - private ContentValidationException templateNotFound(ResourceNotFoundException exception) { - String effectiveLanguage = language == null ? ErrorMessages.DEFAULT_LANGUAGE : language; - Map facts = Map.of("template_uri", exception.resourcePath(), "language", effectiveLanguage); - return new ContentValidationException( - ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), - ErrorMessages.render(ErrorCatalog.TEMPLATE_NOT_FOUND, language, facts), - exception); - } - - private @NonNull FilledParamData runSemanticValidationAndMerge( - String prompt, - Map schema, - T reference, - Map contextParams, - String templateContent) { - ValidationResult semanticResult; - try { - semanticResult = RetryUtil.withRetry( - maxAttempts, - SEMANTIC_VALIDATION_STEP, - () -> semanticValidator.validate(prompt, schema, reference, templateContent)); - } catch (ResourceNotFoundException exception) { - throw templateNotFound(exception); - } catch (ContentValidationException exception) { - // already carries a final catalog code emitted by the semantic validator - throw exception; - } catch (RuntimeException exception) { - Map facts = Map.of("step", SEMANTIC_VALIDATION_STEP); - throw new ContentValidationException( - ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), - ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, language, facts), - List.of(new SlotValidationError( - "_llm", - ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), - ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, language, facts), - facts)), - exception); - } - - if (!semanticResult.verdict()) { - throw new ContentValidationException( - ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED, language, null), - semanticResult.errors(), - semanticResult.params(), - null); - } - - Map merged = mergeParams(contextParams, semanticResult.params()); - FilledParamData filledParamData = new FilledParamData(merged); - LOGGER.atInfo().log( - "content_validation_completed param_keys={}", - filledParamData.data().keySet()); - return filledParamData; - } - - private static Map mergeParams( - Map contextParams, Map semanticParams) { - Map merged = new LinkedHashMap<>(contextParams); - for (Map.Entry entry : semanticParams.entrySet()) { - String key = entry.getKey(); - if (merged.containsKey(key)) { - LOGGER.atWarn().log("content_param_merge_conflict key={} resolution=context_param_wins", key); - continue; - } - merged.put(key, entry.getValue()); - } - return Collections.unmodifiableMap(merged); - } -} +package net.openan.a2at.sdk.core.validation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Orchestrates the content validation pipeline: input validation, rule-level gate, retryable semantic validation and + * deterministic parameter merging. + * + *

            Parameter merging writes the context parameters first and the semantically extracted parameters second; on a key + * conflict the context parameter wins and a warning is logged, so the LLM output can never override the rule-level + * parsed values. + * + * @param template addressing type the validation is performed against, such as {@link TemplateUri} + * @since 2026-08 + */ +public final class ValidationPipeline { + + private static final Logger LOGGER = LoggerFactory.getLogger(ValidationPipeline.class); + + private static final String SEMANTIC_VALIDATION_STEP = "semantic_validation"; + + private final RuleChecker ruleChecker; + + private final SemanticValidator semanticValidator; + + private final int maxAttempts; + + private final String language; + + private final TemplateContentLoader templateContentLoader; + + /** + * Creates a validation pipeline without a template loading gate. + * + * @param ruleChecker rule-level checker used as the entry gate + * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters + * @param maxAttempts maximum number of retry attempts for the semantic validation step + * @throws NullPointerException if the rule checker or the semantic validator is null + */ + public ValidationPipeline( + @NonNull RuleChecker ruleChecker, @NonNull SemanticValidator semanticValidator, int maxAttempts) { + this(ruleChecker, semanticValidator, maxAttempts, null, null); + } + + /** + * Creates a validation pipeline without a template loading gate. + * + * @param ruleChecker rule-level checker used as the entry gate + * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters + * @param maxAttempts maximum number of retry attempts for the semantic validation step + * @param language language used to render failure messages, for example {@code zh-CN}; null falls back to + * {@code en-US} + * @throws NullPointerException if the rule checker or the semantic validator is null + */ + public ValidationPipeline( + @NonNull RuleChecker ruleChecker, + @NonNull SemanticValidator semanticValidator, + int maxAttempts, + @Nullable String language) { + this(ruleChecker, semanticValidator, maxAttempts, language, null); + } + + /** + * Creates a validation pipeline with an optional template loading gate. + * + *

            The loader, when injected, runs after the input gate and before semantic validation to resolve the template + * body for the {@link #validate(String, Map, Object)} variant. A {@code null} loader leaves only the preloaded + * {@link #validate(String, Map, Object, String)} variant available. + * + * @param ruleChecker rule-level checker used as the entry gate + * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters + * @param maxAttempts maximum number of retry attempts for the semantic validation step + * @param language language used to render failure messages, for example {@code zh-CN}; null falls back to + * {@code en-US} + * @param templateContentLoader optional loader resolving the template body, may be null + * @throws NullPointerException if the rule checker or the semantic validator is null + */ + public ValidationPipeline( + @NonNull RuleChecker ruleChecker, + @NonNull SemanticValidator semanticValidator, + int maxAttempts, + @Nullable String language, + @Nullable TemplateContentLoader templateContentLoader) { + this.ruleChecker = Objects.requireNonNull(ruleChecker, "ruleChecker"); + this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); + this.maxAttempts = maxAttempts; + this.language = language; + this.templateContentLoader = templateContentLoader; + } + + /** + * Validates one content prompt and extracts its filled parameters through the full pipeline, loading the template + * body via the injected template loading gate after the input gate and before semantic validation. + * + * @param prompt content prompt text + * @param schema caller-provided parameter JSON schema + * @param reference template addressing value the content is validated against + * @return filled parameter data carrying the merged parameters + * @throws ContentValidationException with {@code negotiation.invalid_input} if the prompt is null or blank, the + * schema is null, or the reference is null + * @throws IllegalStateException if no {@link TemplateContentLoader} was injected into this pipeline + * @throws ContentValidationException if the validation fails at any stage + */ + public @NonNull FilledParamData validate( + @NonNull String prompt, @NonNull Map schema, @NonNull T reference) { + if (templateContentLoader == null) { + throw new IllegalStateException( + "Template content loader is not configured; provide the template content explicitly."); + } + + Map contextParams = validateInputsAndRunRuleGate(prompt, schema, reference); + + String templateContent; + try { + templateContent = templateContentLoader.load(reference); + } catch (ResourceNotFoundException exception) { + throw templateNotFound(exception); + } + + return runSemanticValidationAndMerge(prompt, schema, reference, contextParams, templateContent); + } + + /** + * Validates one content prompt and extracts its filled parameters through the full pipeline. + * + * @param prompt content prompt text + * @param schema caller-provided parameter JSON schema + * @param reference template addressing value the content is validated against + * @param templateContent loaded template text used as a reference for structure/completeness checks + * @return filled parameter data carrying the merged parameters + * @throws ContentValidationException with {@code negotiation.invalid_input} if the prompt is null or blank, the + * schema is null, or the reference is null + * @throws ContentValidationException if the validation fails at any stage + */ + public @NonNull FilledParamData validate( + @NonNull String prompt, + @NonNull Map schema, + @NonNull T reference, + @NonNull String templateContent) { + Map contextParams = validateInputsAndRunRuleGate(prompt, schema, reference); + return runSemanticValidationAndMerge(prompt, schema, reference, contextParams, templateContent); + } + + private Map validateInputsAndRunRuleGate( + @NonNull String prompt, @NonNull Map schema, @NonNull T reference) { + if (schema == null) { + throw invalidInput("Parameter schema must not be null."); + } + if (prompt == null || prompt.isBlank()) { + throw invalidInput("Prompt must not be null or blank."); + } + if (reference == null) { + throw invalidInput("Template reference must not be null."); + } + return ruleChecker.check(prompt); + } + + private ContentValidationException invalidInput(String reason) { + Map facts = Map.of("reason", reason); + return new ContentValidationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), + List.of(new SlotValidationError( + "_input", + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), + facts)), + null); + } + + private ContentValidationException templateNotFound(ResourceNotFoundException exception) { + String effectiveLanguage = language == null ? ErrorMessages.DEFAULT_LANGUAGE : language; + Map facts = Map.of("template_uri", exception.resourcePath(), "language", effectiveLanguage); + return new ContentValidationException( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorMessages.render(ErrorCatalog.TEMPLATE_NOT_FOUND, language, facts), + exception); + } + + private @NonNull FilledParamData runSemanticValidationAndMerge( + String prompt, + Map schema, + T reference, + Map contextParams, + String templateContent) { + ValidationResult semanticResult; + try { + semanticResult = RetryUtil.withRetry( + maxAttempts, + SEMANTIC_VALIDATION_STEP, + () -> semanticValidator.validate(prompt, schema, reference, templateContent)); + } catch (ResourceNotFoundException exception) { + throw templateNotFound(exception); + } catch (ContentValidationException exception) { + // already carries a final catalog code emitted by the semantic validator + throw exception; + } catch (RuntimeException exception) { + Map facts = Map.of("step", SEMANTIC_VALIDATION_STEP); + throw new ContentValidationException( + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, language, facts), + List.of(new SlotValidationError( + "_llm", + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, language, facts), + facts)), + exception); + } + + if (!semanticResult.verdict()) { + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED, language, null), + semanticResult.errors(), + semanticResult.params(), + null); + } + + Map merged = mergeParams(contextParams, semanticResult.params()); + FilledParamData filledParamData = new FilledParamData(merged); + LOGGER.atInfo().log( + "content_validation_completed param_keys={}", + filledParamData.data().keySet()); + return filledParamData; + } + + private static Map mergeParams( + Map contextParams, Map semanticParams) { + Map merged = new LinkedHashMap<>(contextParams); + for (Map.Entry entry : semanticParams.entrySet()) { + String key = entry.getKey(); + if (merged.containsKey(key)) { + LOGGER.atWarn().log("content_param_merge_conflict key={} resolution=context_param_wins", key); + continue; + } + merged.put(key, entry.getValue()); + } + return Collections.unmodifiableMap(merged); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java index b9b2aa11..4e0ee42e 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java @@ -1,76 +1,76 @@ -package net.openan.a2at.sdk.core.exception; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; -import java.util.Map; -import java.util.TreeMap; -import org.junit.jupiter.api.Test; - -/** - * Unit tests for the deprecated {@link A2ATErrorCodes} forwarding constants. - * - *

            Tests cover the following scenarios: - * - *

              - *
            • Every deprecated constant forwards to the layered {@code domain.semantic} code of its replacement - * {@link ErrorCatalog} entry - *
            - * - * @since 2026-06 - */ -@SuppressWarnings("removal") -class A2ATErrorCodesTest { - - /** - * Verifies that {@link A2ATErrorCodes} forwards exactly the twenty-one legacy constants to their catalog - * replacements. - * - *

            Scenario: Reflection inspects all declared static final String fields of the deprecated registry. Expected - * result: Each field value equals the code of the catalog entry that replaced it. - * - * @throws IllegalAccessException if a declared field cannot be read - */ - @Test - void should_forwardEveryConstantToItsCatalogReplacement_When_reflectingOverDeclaredFields() - throws IllegalAccessException { - Map constants = new TreeMap<>(); - for (Field field : A2ATErrorCodes.class.getDeclaredFields()) { - if (field.getType() == String.class - && Modifier.isStatic(field.getModifiers()) - && Modifier.isFinal(field.getModifiers())) { - field.setAccessible(true); - constants.put(field.getName(), (String) field.get(null)); - } - } - - assertEquals( - Map.ofEntries( - Map.entry("SDK_INTERNAL_ERROR", ErrorCatalog.INFRA_INTERNAL_ERROR.getCode()), - Map.entry("PARAM_EXTRACTION_FAILED", ErrorCatalog.SLOT_NOT_PROVIDED.getCode()), - Map.entry("INPUT_TEXT_TOO_LONG", ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode()), - Map.entry("TEMPLATE_NOT_FOUND", ErrorCatalog.TEMPLATE_NOT_FOUND.getCode()), - Map.entry( - "NEGOTIATION_CONTENT_EXTRACT_FAILED", - ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode()), - Map.entry( - "NEGOTIATION_SEMANTIC_REJECTED", ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode()), - Map.entry("NEGOTIATION_RULE_VIOLATION", ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode()), - Map.entry("NEGOTIATION_SLOT_MISSING", ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode()), - Map.entry("NEGOTIATION_INVALID_INPUT", ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode()), - Map.entry("NEGOTIATION_LLM_INFRASTRUCTURE_ERROR", ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), - Map.entry("VALIDATION_INVALID_INPUT", ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode()), - Map.entry("VALIDATION_RULE_VIOLATION", ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode()), - Map.entry("VALIDATION_SEMANTIC_REJECTED", ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode()), - Map.entry("VALIDATION_LLM_INFRASTRUCTURE_ERROR", ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), - Map.entry("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", ErrorCatalog.TEMPLATE_NOT_FOUND.getCode()), - Map.entry("PROMPT_RESOURCE_LOAD_ERROR", ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode()), - Map.entry("SLOT_SCHEMA_NOT_FOUND", ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode()), - Map.entry("LLM_INVOCATION_FAILED", ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), - Map.entry("RENDER_FAILED", ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode()), - Map.entry("SLOT_VALIDATION_ERROR", ErrorCatalog.SLOT_RULE_VIOLATION.getCode()), - Map.entry("PROCESSED_PROMPT_PARSE_ERROR", ErrorCatalog.SCENARIO_NOT_MATCHED.getCode())), - constants); - } -} +package net.openan.a2at.sdk.core.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Map; +import java.util.TreeMap; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the deprecated {@link A2ATErrorCodes} forwarding constants. + * + *

            Tests cover the following scenarios: + * + *

              + *
            • Every deprecated constant forwards to the layered {@code domain.semantic} code of its replacement + * {@link ErrorCatalog} entry + *
            + * + * @since 2026-06 + */ +@SuppressWarnings("removal") +class A2ATErrorCodesTest { + + /** + * Verifies that {@link A2ATErrorCodes} forwards exactly the twenty-one legacy constants to their catalog + * replacements. + * + *

            Scenario: Reflection inspects all declared static final String fields of the deprecated registry. Expected + * result: Each field value equals the code of the catalog entry that replaced it. + * + * @throws IllegalAccessException if a declared field cannot be read + */ + @Test + void should_forwardEveryConstantToItsCatalogReplacement_When_reflectingOverDeclaredFields() + throws IllegalAccessException { + Map constants = new TreeMap<>(); + for (Field field : A2ATErrorCodes.class.getDeclaredFields()) { + if (field.getType() == String.class + && Modifier.isStatic(field.getModifiers()) + && Modifier.isFinal(field.getModifiers())) { + field.setAccessible(true); + constants.put(field.getName(), (String) field.get(null)); + } + } + + assertEquals( + Map.ofEntries( + Map.entry("SDK_INTERNAL_ERROR", ErrorCatalog.INFRA_INTERNAL_ERROR.getCode()), + Map.entry("PARAM_EXTRACTION_FAILED", ErrorCatalog.SLOT_NOT_PROVIDED.getCode()), + Map.entry("INPUT_TEXT_TOO_LONG", ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode()), + Map.entry("TEMPLATE_NOT_FOUND", ErrorCatalog.TEMPLATE_NOT_FOUND.getCode()), + Map.entry( + "NEGOTIATION_CONTENT_EXTRACT_FAILED", + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode()), + Map.entry( + "NEGOTIATION_SEMANTIC_REJECTED", ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode()), + Map.entry("NEGOTIATION_RULE_VIOLATION", ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode()), + Map.entry("NEGOTIATION_SLOT_MISSING", ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode()), + Map.entry("NEGOTIATION_INVALID_INPUT", ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode()), + Map.entry("NEGOTIATION_LLM_INFRASTRUCTURE_ERROR", ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), + Map.entry("VALIDATION_INVALID_INPUT", ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode()), + Map.entry("VALIDATION_RULE_VIOLATION", ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode()), + Map.entry("VALIDATION_SEMANTIC_REJECTED", ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode()), + Map.entry("VALIDATION_LLM_INFRASTRUCTURE_ERROR", ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), + Map.entry("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", ErrorCatalog.TEMPLATE_NOT_FOUND.getCode()), + Map.entry("PROMPT_RESOURCE_LOAD_ERROR", ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode()), + Map.entry("SLOT_SCHEMA_NOT_FOUND", ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode()), + Map.entry("LLM_INVOCATION_FAILED", ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), + Map.entry("RENDER_FAILED", ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode()), + Map.entry("SLOT_VALIDATION_ERROR", ErrorCatalog.SLOT_RULE_VIOLATION.getCode()), + Map.entry("PROCESSED_PROMPT_PARSE_ERROR", ErrorCatalog.SCENARIO_NOT_MATCHED.getCode())), + constants); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorTest.java index 1c726573..fa8bb25b 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorTest.java @@ -1,69 +1,69 @@ -package net.openan.a2at.sdk.core.exception; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import org.junit.jupiter.api.Test; - -/** - * Unit tests for {@link A2ATError}. - * - *

            Tests cover the following scenarios: - * - *

              - *
            • Default error code when no explicit code is provided - *
            • Explicit error code forwarding - *
            • Null safety for the explicit code - *
            - * - * @since 2026-08 - */ -class A2ATErrorTest { - - /** - * Verifies that {@link A2ATError#A2ATError(String)} and {@link A2ATError#A2ATError(String, Throwable)} fall back to - * {@link ErrorCatalog#INFRA_INTERNAL_ERROR}. - * - *

            Scenario: Create A2A-T errors without an explicit code. Expected result: getCode() returns - * {@code infra.internal_error} and is never null. - */ - @Test - void should_defaultToInfraInternalErrorCode_When_createdWithoutExplicitCode() { - assertEquals(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), new A2ATError("boom").getCode()); - assertEquals( - ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), - new A2ATError("boom", new IllegalStateException("root")).getCode()); - } - - /** - * Verifies that {@link A2ATError#A2ATError(String, String)} and {@link A2ATError#A2ATError(String, String, - * Throwable)} carry the explicitly provided code. - * - *

            Scenario: Create A2A-T errors with an explicit code. Expected result: getCode() returns the explicit code, - * getMessage() returns the message and getCause() returns the cause when provided. - */ - @Test - void should_carryExplicitCode_When_createdWithExplicitCode() { - String templateNotFound = ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); - assertEquals(templateNotFound, new A2ATError(templateNotFound, "missing").getCode()); - assertEquals("missing", new A2ATError(templateNotFound, "missing").getMessage()); - assertNull(new A2ATError(templateNotFound, "missing").getCause()); - - IllegalStateException cause = new IllegalStateException("root"); - A2ATError withCause = new A2ATError(templateNotFound, "missing", cause); - assertEquals(templateNotFound, withCause.getCode()); - assertEquals(cause, withCause.getCause()); - } - - /** - * Verifies that the explicit-code constructors reject null codes. - * - *

            Scenario: Attempt to create A2A-T errors with a null code. Expected result: NullPointerException is thrown. - */ - @Test - void should_throwNullPointerException_When_createdWithNullCode() { - assertThrows(NullPointerException.class, () -> new A2ATError(null, "missing")); - assertThrows(NullPointerException.class, () -> new A2ATError(null, "missing", new IllegalStateException())); - } -} +package net.openan.a2at.sdk.core.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link A2ATError}. + * + *

            Tests cover the following scenarios: + * + *

              + *
            • Default error code when no explicit code is provided + *
            • Explicit error code forwarding + *
            • Null safety for the explicit code + *
            + * + * @since 2026-08 + */ +class A2ATErrorTest { + + /** + * Verifies that {@link A2ATError#A2ATError(String)} and {@link A2ATError#A2ATError(String, Throwable)} fall back to + * {@link ErrorCatalog#INFRA_INTERNAL_ERROR}. + * + *

            Scenario: Create A2A-T errors without an explicit code. Expected result: getCode() returns + * {@code infra.internal_error} and is never null. + */ + @Test + void should_defaultToInfraInternalErrorCode_When_createdWithoutExplicitCode() { + assertEquals(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), new A2ATError("boom").getCode()); + assertEquals( + ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), + new A2ATError("boom", new IllegalStateException("root")).getCode()); + } + + /** + * Verifies that {@link A2ATError#A2ATError(String, String)} and {@link A2ATError#A2ATError(String, String, + * Throwable)} carry the explicitly provided code. + * + *

            Scenario: Create A2A-T errors with an explicit code. Expected result: getCode() returns the explicit code, + * getMessage() returns the message and getCause() returns the cause when provided. + */ + @Test + void should_carryExplicitCode_When_createdWithExplicitCode() { + String templateNotFound = ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); + assertEquals(templateNotFound, new A2ATError(templateNotFound, "missing").getCode()); + assertEquals("missing", new A2ATError(templateNotFound, "missing").getMessage()); + assertNull(new A2ATError(templateNotFound, "missing").getCause()); + + IllegalStateException cause = new IllegalStateException("root"); + A2ATError withCause = new A2ATError(templateNotFound, "missing", cause); + assertEquals(templateNotFound, withCause.getCode()); + assertEquals(cause, withCause.getCause()); + } + + /** + * Verifies that the explicit-code constructors reject null codes. + * + *

            Scenario: Attempt to create A2A-T errors with a null code. Expected result: NullPointerException is thrown. + */ + @Test + void should_throwNullPointerException_When_createdWithNullCode() { + assertThrows(NullPointerException.class, () -> new A2ATError(null, "missing")); + assertThrows(NullPointerException.class, () -> new A2ATError(null, "missing", new IllegalStateException())); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java index 30e8d217..05e699bb 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java @@ -1,281 +1,281 @@ -package net.openan.a2at.sdk.core.model; - -import static org.junit.jupiter.api.Assertions.*; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import net.openan.a2at.sdk.core.exception.ConfigFileNotFoundException; -import org.junit.jupiter.api.Test; - -/** - * Unit tests for {@link A2ATConfig}. - * - *

            Tests cover the following scenarios: - * - *

              - *
            • Loading unified SDK configuration from .env files - *
            • Default values for missing configuration keys - *
            • Error handling for missing configuration files - *
            - * - * @since 2026-06 - */ -class A2ATConfigTest { - - /** - * Verifies that {@link A2ATConfig#load(Path)} correctly builds a unified configuration from a complete .env file - * with all keys specified. - * - *

            Scenario: A .env file contains all configuration keys for prompt, LLM, and negotiation. Expected result: All - * configuration values are correctly parsed and accessible via {@link A2ATConfig#prompt()}, - * {@link A2ATConfig#llm()}, and {@link A2ATConfig#negotiation()}. - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_buildUnifiedConfig_When_envFileContainsAllKeys() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-config"); - Path promptRoot = tempDir.resolve("prompt_resources"); - Files.createDirectories(promptRoot); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=local_file - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=gpt-4.1 - A2AT_LLM_API_KEY=test-key - A2AT_LLM_BASE_URL=https://api.openai.com/v1 - A2AT_LLM_HISTORY_WINDOW=12 - A2AT_LLM_MAX_TOKENS=1024 - A2AT_LLM_TEMPERATURE=0.3 - A2AT_LLM_TIMEOUT_SECONDS=15 - A2AT_LLM_SESSION_MAX_TOTAL=300 - A2AT_LLM_SESSION_MAX_PER_PROVIDER=100 - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """); - - A2ATConfig config = A2ATConfig.load(envFile); - - assertEquals("zh-CN", config.prompt().language()); - assertEquals("local_file", config.prompt().sourceType()); - assertEquals("prompt_resources", config.prompt().localRootDir()); - assertEquals("openai", config.llm().provider()); - assertEquals("gpt-4.1", config.llm().model()); - assertEquals("test-key", config.llm().apiKey()); - assertEquals("https://api.openai.com/v1", config.llm().baseUrl()); - assertEquals(12, config.llm().historyWindow()); - assertEquals(1024, config.llm().maxTokens()); - assertEquals(0.3d, config.llm().temperature()); - assertEquals(15.0d, config.llm().timeoutSeconds()); - assertEquals(300, config.llm().sessionMaxTotal()); - assertEquals(100, config.llm().sessionMaxPerProvider()); - assertEquals("in_memory", config.negotiation().stateStoreType()); - } - - /** - * Verifies that {@link A2ATConfig#load(Path)} applies default values for missing configuration keys. - * - *

            Scenario: A .env file contains only minimal required keys (LLM provider and negotiation). Expected result: - * Default values are applied for prompt configuration (classpath source type, unset local root directory). - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_useClasspathDefaults_When_promptKeysAreMissing() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-config-default"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_LLM_PROVIDER=openai - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """); - - A2ATConfig config = A2ATConfig.load(envFile); - - assertEquals("classpath", config.prompt().sourceType()); - assertNull(config.prompt().localRootDir()); - } - - /** - * Verifies that {@link A2ATConfig#load(Path)} throws {@link ConfigFileNotFoundException} when the specified file - * does not exist. - * - *

            Scenario: Attempt to load configuration from a non-existent file path. Expected result: - * ConfigFileNotFoundException is thrown. - */ - @Test - void should_throwConfigFileNotFoundException_When_envFileDoesNotExist() { - Path missing = Path.of("build", "missing", "a2at.env"); - - assertThrows(ConfigFileNotFoundException.class, () -> A2ATConfig.load(missing)); - } - - /** - * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} resolves a relative local - * root directory to an absolute normalized path against the environment file parent. - * - *

            Scenario: A .env file configures a relative local root directory. Expected result: the returned config carries - * the local root resolved to an absolute normalized path relative to the .env file parent. - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_resolveRelativeLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-resolve-relative"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_PROMPT_SOURCE_TYPE=local_file - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=openai - """); - - A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); - - assertEquals( - tempDir.resolve("prompt_resources").toAbsolutePath().normalize().toString(), - resolved.prompt().localRootDir()); - } - - /** - * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} normalizes an - * already-absolute local root directory without re-resolving it against the environment file parent. - * - *

            Scenario: A .env file configures an absolute local root directory. Expected result: the returned config - * carries the normalized absolute path unchanged. - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_normalizeAbsoluteLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-resolve-absolute"); - Path absoluteRoot = tempDir.resolve(".").resolve("prompt_resources").toAbsolutePath(); - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - "A2AT_PROMPT_SOURCE_TYPE=local_file\n" - + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=" + absoluteRoot + "\n" - + "A2AT_LLM_PROVIDER=openai\n"); - - A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); - - assertEquals(absoluteRoot.normalize().toString(), resolved.prompt().localRootDir()); - } - - /** - * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} preserves an unset (null) - * local root directory instead of throwing. - * - *

            Scenario: A .env file does not configure a local root directory (classpath source). Expected result: the - * returned config keeps the local root as null. - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_preserveNullLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-resolve-null"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString(envFile, """ - A2AT_LLM_PROVIDER=openai - """); - - A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); - - assertNull(resolved.prompt().localRootDir()); - } - - /** - * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} preserves a blank local root - * directory instead of resolving it to a legal absolute path. - * - *

            Scenario: A config carries a blank local root directory. Expected result: the method returns the original - * config unchanged, treating a blank root like an unset root (matching the downstream {@code PromptResourceAccess} - * and {@code PromptTemplateCatalog} "blank == unset" convention). - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_preserveBlankLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-resolve-blank"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\n"); - - A2ATConfig loaded = A2ATConfig.load(envFile); - A2ATConfig config = new A2ATConfig( - new PromptRuntimeConfig( - loaded.prompt().language(), loaded.prompt().sourceType(), " "), - loaded.llm(), - loaded.inputLimits(), - loaded.negotiation(), - loaded.promptCompliance()); - - A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(config, envFile); - - assertSame(config, resolved); - assertEquals(" ", resolved.prompt().localRootDir()); - } - - /** - * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} does not throw when the - * {@code envPath} is a single-segment relative path with no parent, resolving the relative local root against the - * current working directory instead of a missing parent. - * - *

            Scenario: A single-segment relative {@code envPath} (e.g. {@code Path.of(".env")}) has no parent. Expected - * result: the returned config carries the local root resolved against the current working directory. - * - * @throws IOException if temp directory creation fails - */ - @Test - void should_resolveRelativeLocalRootAgainstCwd_When_envPathIsSingleSegment() throws IOException { - Path tempDir = Files.createTempDirectory("a2at-resolve-single-segment"); - Path envFile = tempDir.resolve("client.env"); - Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\n"); - - A2ATConfig loaded = A2ATConfig.load(envFile); - A2ATConfig config = new A2ATConfig( - new PromptRuntimeConfig(loaded.prompt().language(), "local_file", "prompt_resources"), - loaded.llm(), - loaded.inputLimits(), - loaded.negotiation(), - loaded.promptCompliance()); - - A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(config, Path.of(".env")); - - assertEquals( - Path.of("") - .toAbsolutePath() - .normalize() - .resolve("prompt_resources") - .normalize() - .toString(), - resolved.prompt().localRootDir()); - } - - /** - * Verifies that the repository's env.example file exists and optionally matches an upstream template. - * - *

            Scenario: Check for the existence of env.example in the repository root. If an upstream template exists, - * verify they match (ignoring line ending differences). - * - * @throws IOException if file reading fails - */ - @Test - void should_matchUpstreamTemplate_When_envExampleExists() throws IOException { - Path repoEnvExample = Path.of("..", "env.example").normalize(); - Path upstreamEnvExample = - Path.of("..", ".upstream-src", "package_data", "env.example").normalize(); - - assertTrue(Files.exists(repoEnvExample), "repo root env.example should exist"); - if (Files.exists(upstreamEnvExample)) { - assertEquals( - Files.readString(upstreamEnvExample).replace("\r\n", "\n"), - Files.readString(repoEnvExample).replace("\r\n", "\n")); - } - } -} +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import net.openan.a2at.sdk.core.exception.ConfigFileNotFoundException; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link A2ATConfig}. + * + *

            Tests cover the following scenarios: + * + *

              + *
            • Loading unified SDK configuration from .env files + *
            • Default values for missing configuration keys + *
            • Error handling for missing configuration files + *
            + * + * @since 2026-06 + */ +class A2ATConfigTest { + + /** + * Verifies that {@link A2ATConfig#load(Path)} correctly builds a unified configuration from a complete .env file + * with all keys specified. + * + *

            Scenario: A .env file contains all configuration keys for prompt, LLM, and negotiation. Expected result: All + * configuration values are correctly parsed and accessible via {@link A2ATConfig#prompt()}, + * {@link A2ATConfig#llm()}, and {@link A2ATConfig#negotiation()}. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_buildUnifiedConfig_When_envFileContainsAllKeys() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-config"); + Path promptRoot = tempDir.resolve("prompt_resources"); + Files.createDirectories(promptRoot); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=gpt-4.1 + A2AT_LLM_API_KEY=test-key + A2AT_LLM_BASE_URL=https://api.openai.com/v1 + A2AT_LLM_HISTORY_WINDOW=12 + A2AT_LLM_MAX_TOKENS=1024 + A2AT_LLM_TEMPERATURE=0.3 + A2AT_LLM_TIMEOUT_SECONDS=15 + A2AT_LLM_SESSION_MAX_TOTAL=300 + A2AT_LLM_SESSION_MAX_PER_PROVIDER=100 + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """); + + A2ATConfig config = A2ATConfig.load(envFile); + + assertEquals("zh-CN", config.prompt().language()); + assertEquals("local_file", config.prompt().sourceType()); + assertEquals("prompt_resources", config.prompt().localRootDir()); + assertEquals("openai", config.llm().provider()); + assertEquals("gpt-4.1", config.llm().model()); + assertEquals("test-key", config.llm().apiKey()); + assertEquals("https://api.openai.com/v1", config.llm().baseUrl()); + assertEquals(12, config.llm().historyWindow()); + assertEquals(1024, config.llm().maxTokens()); + assertEquals(0.3d, config.llm().temperature()); + assertEquals(15.0d, config.llm().timeoutSeconds()); + assertEquals(300, config.llm().sessionMaxTotal()); + assertEquals(100, config.llm().sessionMaxPerProvider()); + assertEquals("in_memory", config.negotiation().stateStoreType()); + } + + /** + * Verifies that {@link A2ATConfig#load(Path)} applies default values for missing configuration keys. + * + *

            Scenario: A .env file contains only minimal required keys (LLM provider and negotiation). Expected result: + * Default values are applied for prompt configuration (classpath source type, unset local root directory). + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_useClasspathDefaults_When_promptKeysAreMissing() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-config-default"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LLM_PROVIDER=openai + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """); + + A2ATConfig config = A2ATConfig.load(envFile); + + assertEquals("classpath", config.prompt().sourceType()); + assertNull(config.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#load(Path)} throws {@link ConfigFileNotFoundException} when the specified file + * does not exist. + * + *

            Scenario: Attempt to load configuration from a non-existent file path. Expected result: + * ConfigFileNotFoundException is thrown. + */ + @Test + void should_throwConfigFileNotFoundException_When_envFileDoesNotExist() { + Path missing = Path.of("build", "missing", "a2at.env"); + + assertThrows(ConfigFileNotFoundException.class, () -> A2ATConfig.load(missing)); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} resolves a relative local + * root directory to an absolute normalized path against the environment file parent. + * + *

            Scenario: A .env file configures a relative local root directory. Expected result: the returned config carries + * the local root resolved to an absolute normalized path relative to the .env file parent. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_resolveRelativeLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-relative"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=openai + """); + + A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); + + assertEquals( + tempDir.resolve("prompt_resources").toAbsolutePath().normalize().toString(), + resolved.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} normalizes an + * already-absolute local root directory without re-resolving it against the environment file parent. + * + *

            Scenario: A .env file configures an absolute local root directory. Expected result: the returned config + * carries the normalized absolute path unchanged. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_normalizeAbsoluteLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-absolute"); + Path absoluteRoot = tempDir.resolve(".").resolve("prompt_resources").toAbsolutePath(); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + "A2AT_PROMPT_SOURCE_TYPE=local_file\n" + + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=" + absoluteRoot + "\n" + + "A2AT_LLM_PROVIDER=openai\n"); + + A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); + + assertEquals(absoluteRoot.normalize().toString(), resolved.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} preserves an unset (null) + * local root directory instead of throwing. + * + *

            Scenario: A .env file does not configure a local root directory (classpath source). Expected result: the + * returned config keeps the local root as null. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_preserveNullLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-null"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, """ + A2AT_LLM_PROVIDER=openai + """); + + A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envFile), envFile); + + assertNull(resolved.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} preserves a blank local root + * directory instead of resolving it to a legal absolute path. + * + *

            Scenario: A config carries a blank local root directory. Expected result: the method returns the original + * config unchanged, treating a blank root like an unset root (matching the downstream {@code PromptResourceAccess} + * and {@code PromptTemplateCatalog} "blank == unset" convention). + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_preserveBlankLocalRoot_When_resolvingPromptResourceLocalRootDir() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-blank"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\n"); + + A2ATConfig loaded = A2ATConfig.load(envFile); + A2ATConfig config = new A2ATConfig( + new PromptRuntimeConfig( + loaded.prompt().language(), loaded.prompt().sourceType(), " "), + loaded.llm(), + loaded.inputLimits(), + loaded.negotiation(), + loaded.promptCompliance()); + + A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(config, envFile); + + assertSame(config, resolved); + assertEquals(" ", resolved.prompt().localRootDir()); + } + + /** + * Verifies that {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)} does not throw when the + * {@code envPath} is a single-segment relative path with no parent, resolving the relative local root against the + * current working directory instead of a missing parent. + * + *

            Scenario: A single-segment relative {@code envPath} (e.g. {@code Path.of(".env")}) has no parent. Expected + * result: the returned config carries the local root resolved against the current working directory. + * + * @throws IOException if temp directory creation fails + */ + @Test + void should_resolveRelativeLocalRootAgainstCwd_When_envPathIsSingleSegment() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-resolve-single-segment"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\n"); + + A2ATConfig loaded = A2ATConfig.load(envFile); + A2ATConfig config = new A2ATConfig( + new PromptRuntimeConfig(loaded.prompt().language(), "local_file", "prompt_resources"), + loaded.llm(), + loaded.inputLimits(), + loaded.negotiation(), + loaded.promptCompliance()); + + A2ATConfig resolved = A2ATConfig.resolvePromptResourceLocalRootDir(config, Path.of(".env")); + + assertEquals( + Path.of("") + .toAbsolutePath() + .normalize() + .resolve("prompt_resources") + .normalize() + .toString(), + resolved.prompt().localRootDir()); + } + + /** + * Verifies that the repository's env.example file exists and optionally matches an upstream template. + * + *

            Scenario: Check for the existence of env.example in the repository root. If an upstream template exists, + * verify they match (ignoring line ending differences). + * + * @throws IOException if file reading fails + */ + @Test + void should_matchUpstreamTemplate_When_envExampleExists() throws IOException { + Path repoEnvExample = Path.of("..", "env.example").normalize(); + Path upstreamEnvExample = + Path.of("..", ".upstream-src", "package_data", "env.example").normalize(); + + assertTrue(Files.exists(repoEnvExample), "repo root env.example should exist"); + if (Files.exists(upstreamEnvExample)) { + assertEquals( + Files.readString(upstreamEnvExample).replace("\r\n", "\n"), + Files.readString(repoEnvExample).replace("\r\n", "\n")); + } + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigMaxAttemptsLoggingTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigMaxAttemptsLoggingTest.java index e37970f9..23988487 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigMaxAttemptsLoggingTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigMaxAttemptsLoggingTest.java @@ -1,109 +1,109 @@ -package net.openan.a2at.sdk.core.model; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; - -/** - * Unit tests for the warning logs emitted while parsing {@code A2AT_LLM_MAX_ATTEMPTS}. - * - *

            The value semantics themselves (in-range pass-through, clamping to 1 and 10, fallback to the default of 3 for - * unparseable or blank values) are covered by {@link LlmConfigTest}; this suite locks the warning side of the contract: - * every clamp and every fallback emits one warning carrying the raw configured value and the resolved value, and - * well-formed values emit no warning at all. - */ -class LlmConfigMaxAttemptsLoggingTest { - - private final ListAppender appender = new ListAppender<>(); - - private final Logger logger = (Logger) LoggerFactory.getLogger(LlmConfig.class); - - @BeforeEach - void attachAppender() { - appender.start(); - logger.addAppender(appender); - } - - @AfterEach - void detachAppender() { - logger.detachAppender(appender); - } - - /** - * Verifies that a value below the allowed range clamps to 1 with one warning carrying the raw value and the clamped - * value. - */ - @Test - void should_warnWithRawValue_When_maxAttemptsIsBelowTheLowerBound() { - LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "0")); - - assertEquals(1, config.maxAttempts()); - List warnings = warningMessages(); - assertEquals(1, warnings.size()); - assertContains(warnings.get(0), "key=A2AT_LLM_MAX_ATTEMPTS", "raw_value=0", "clamped_value=1"); - } - - /** - * Verifies that a value above the allowed range clamps to 10 with one warning carrying the raw value and the - * clamped value. - */ - @Test - void should_warnWithRawValue_When_maxAttemptsIsAboveTheUpperBound() { - LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "99")); - - assertEquals(10, config.maxAttempts()); - List warnings = warningMessages(); - assertEquals(1, warnings.size()); - assertContains(warnings.get(0), "key=A2AT_LLM_MAX_ATTEMPTS", "raw_value=99", "clamped_value=10"); - } - - /** - * Verifies that an unparseable value falls back to the default of 3 with one warning carrying the raw value and the - * default value. - */ - @Test - void should_warnWithRawValue_When_maxAttemptsIsUnparseable() { - LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "garbage")); - - assertEquals(3, config.maxAttempts()); - List warnings = warningMessages(); - assertEquals(1, warnings.size()); - assertContains(warnings.get(0), "key=A2AT_LLM_MAX_ATTEMPTS", "raw_value=garbage", "default_value=3"); - } - - /** Verifies that in-range values and missing or blank values resolve without any warning. */ - @Test - void should_notWarn_When_maxAttemptsIsInRangeAbsentOrBlank() { - assertEquals(5, LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "5")).maxAttempts()); - assertEquals(1, LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "1")).maxAttempts()); - assertEquals( - 10, LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "10")).maxAttempts()); - assertEquals(3, LlmConfig.fromMap(Map.of()).maxAttempts()); - assertEquals(3, LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", " ")).maxAttempts()); - - assertTrue(warningMessages().isEmpty(), "well-formed or absent values must not emit a warning"); - } - - private List warningMessages() { - return appender.list.stream() - .filter(event -> event.getLevel() == Level.WARN) - .map(ILoggingEvent::getFormattedMessage) - .toList(); - } - - private static void assertContains(String message, String... fragments) { - for (String fragment : fragments) { - assertTrue(message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); - } - } -} +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * Unit tests for the warning logs emitted while parsing {@code A2AT_LLM_MAX_ATTEMPTS}. + * + *

            The value semantics themselves (in-range pass-through, clamping to 1 and 10, fallback to the default of 3 for + * unparseable or blank values) are covered by {@link LlmConfigTest}; this suite locks the warning side of the contract: + * every clamp and every fallback emits one warning carrying the raw configured value and the resolved value, and + * well-formed values emit no warning at all. + */ +class LlmConfigMaxAttemptsLoggingTest { + + private final ListAppender appender = new ListAppender<>(); + + private final Logger logger = (Logger) LoggerFactory.getLogger(LlmConfig.class); + + @BeforeEach + void attachAppender() { + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + logger.detachAppender(appender); + } + + /** + * Verifies that a value below the allowed range clamps to 1 with one warning carrying the raw value and the clamped + * value. + */ + @Test + void should_warnWithRawValue_When_maxAttemptsIsBelowTheLowerBound() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "0")); + + assertEquals(1, config.maxAttempts()); + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertContains(warnings.get(0), "key=A2AT_LLM_MAX_ATTEMPTS", "raw_value=0", "clamped_value=1"); + } + + /** + * Verifies that a value above the allowed range clamps to 10 with one warning carrying the raw value and the + * clamped value. + */ + @Test + void should_warnWithRawValue_When_maxAttemptsIsAboveTheUpperBound() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "99")); + + assertEquals(10, config.maxAttempts()); + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertContains(warnings.get(0), "key=A2AT_LLM_MAX_ATTEMPTS", "raw_value=99", "clamped_value=10"); + } + + /** + * Verifies that an unparseable value falls back to the default of 3 with one warning carrying the raw value and the + * default value. + */ + @Test + void should_warnWithRawValue_When_maxAttemptsIsUnparseable() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "garbage")); + + assertEquals(3, config.maxAttempts()); + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertContains(warnings.get(0), "key=A2AT_LLM_MAX_ATTEMPTS", "raw_value=garbage", "default_value=3"); + } + + /** Verifies that in-range values and missing or blank values resolve without any warning. */ + @Test + void should_notWarn_When_maxAttemptsIsInRangeAbsentOrBlank() { + assertEquals(5, LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "5")).maxAttempts()); + assertEquals(1, LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "1")).maxAttempts()); + assertEquals( + 10, LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "10")).maxAttempts()); + assertEquals(3, LlmConfig.fromMap(Map.of()).maxAttempts()); + assertEquals(3, LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", " ")).maxAttempts()); + + assertTrue(warningMessages().isEmpty(), "well-formed or absent values must not emit a warning"); + } + + private List warningMessages() { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void assertContains(String message, String... fragments) { + for (String fragment : fragments) { + assertTrue(message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); + } + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java index d03bce73..b2e03468 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java @@ -1,250 +1,250 @@ -package net.openan.a2at.sdk.core.model; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -/** - * Unit tests for {@link LlmConfig}. - * - *

            Tests cover the following scenarios: - * - *

              - *
            • Default values when configuration keys are missing - *
            • Overriding defaults with environment variable values - *
            • Max attempts parsing: in-range values, clamping, fallback, and blank values - *
            • Reasoning effort parsing: valid values, case normalization, blank values - *
            - * - * @since 2026-06 - */ -class LlmConfigTest { - - /** - * Verifies that {@link LlmConfig#fromMap(Map)} applies default values when no configuration keys are provided. - * - *

            Scenario: An empty map is passed to fromMap(). Expected result: All fields use predefined defaults: - - * provider: "openai" - model, apiKey, baseUrl: empty strings - historyWindow: 10 - maxTokens: null - temperature: - * null - timeoutSeconds: null - reasoningEffort: null - sessionMaxTotal: 300 - sessionMaxPerProvider: 100 - - * maxAttempts: 3 - */ - @Test - void should_useDefaults_When_keysAreMissing() { - Map values = Map.of(); - - LlmConfig config = LlmConfig.fromMap(values); - - assertEquals("openai", config.provider()); - assertEquals("", config.model()); - assertEquals("", config.apiKey()); - assertEquals("", config.baseUrl()); - assertEquals(10, config.historyWindow()); - assertNull(config.maxTokens()); - assertNull(config.temperature()); - assertNull(config.timeoutSeconds()); - assertEquals(false, config.disableSystemProxy()); - assertNull(config.reasoningEffort()); - assertEquals(300, config.sessionMaxTotal()); - assertEquals(100, config.sessionMaxPerProvider()); - assertEquals(3, config.maxAttempts()); - } - - /** - * Verifies that {@link LlmConfig#fromMap(Map)} overrides default values with values from the provided map. - * - *

            Scenario: A map containing all LLM configuration keys with custom values. Expected result: All fields use the - * values from the map instead of defaults. - */ - @Test - void should_overrideDefaults_When_keysAreProvided() { - Map values = Map.ofEntries( - Map.entry("A2AT_LLM_PROVIDER", "example_provider"), - Map.entry("A2AT_LLM_MODEL", "example-model"), - Map.entry("A2AT_LLM_API_KEY", "test-api-key"), - Map.entry("A2AT_LLM_BASE_URL", "https://llm.example.test/v1"), - Map.entry("A2AT_LLM_HISTORY_WINDOW", "20"), - Map.entry("A2AT_LLM_MAX_TOKENS", "4096"), - Map.entry("A2AT_LLM_TEMPERATURE", "0.5"), - Map.entry("A2AT_LLM_TIMEOUT_SECONDS", "60"), - Map.entry("A2AT_LLM_DISABLE_SYSTEM_PROXY", "true"), - Map.entry("A2AT_LLM_SESSION_MAX_TOTAL", "500"), - Map.entry("A2AT_LLM_SESSION_MAX_PER_PROVIDER", "150"), - Map.entry("A2AT_LLM_MAX_ATTEMPTS", "5"), - Map.entry("A2AT_LLM_REASONING_EFFORT", "HIGH")); - - LlmConfig config = LlmConfig.fromMap(values); - - assertEquals("example_provider", config.provider()); - assertEquals("example-model", config.model()); - assertEquals("test-api-key", config.apiKey()); - assertEquals("https://llm.example.test/v1", config.baseUrl()); - assertEquals(20, config.historyWindow()); - assertEquals(4096, config.maxTokens()); - assertEquals(0.5d, config.temperature()); - assertEquals(60.0d, config.timeoutSeconds()); - assertEquals(true, config.disableSystemProxy()); - assertEquals(500, config.sessionMaxTotal()); - assertEquals(150, config.sessionMaxPerProvider()); - assertEquals(5, config.maxAttempts()); - assertEquals("HIGH", config.reasoningEffort()); - } - - /** - * Verifies that {@link LlmConfig#fromMap(Map)} clamps an out-of-range max attempts value to the nearest allowed - * bound. - * - *

            Scenario: A2AT_LLM_MAX_ATTEMPTS is set to "0" (below the lower bound of 1) and to "99" (above the upper bound - * of 10). Expected result: maxAttempts resolves to 1 and to 10 respectively. - */ - @Test - void should_clampMaxAttempts_When_valueIsOutOfRange() { - LlmConfig tooSmall = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "0")); - LlmConfig tooLarge = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "99")); - - assertEquals(1, tooSmall.maxAttempts()); - assertEquals(10, tooLarge.maxAttempts()); - } - - /** - * Verifies that {@link LlmConfig#fromMap(Map)} falls back to the default max attempts value when the configured - * value is not a valid integer. - * - *

            Scenario: A2AT_LLM_MAX_ATTEMPTS is set to a non-numeric value. Expected result: maxAttempts resolves to the - * default of 3. - */ - @Test - void should_fallBackToDefaultMaxAttempts_When_valueIsUnparseable() { - LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "garbage")); - - assertEquals(3, config.maxAttempts()); - } - - /** - * Verifies that {@link LlmConfig#fromMap(Map)} uses the default max attempts value when the key is absent or blank. - * - *

            Scenario: A2AT_LLM_MAX_ATTEMPTS is missing from the map, or set to a blank value. Expected result: maxAttempts - * resolves to the default of 3 in both cases. - */ - @Test - void should_useDefaultMaxAttempts_When_valueIsAbsentOrBlank() { - LlmConfig absent = LlmConfig.fromMap(Map.of()); - LlmConfig blank = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "")); - - assertEquals(3, absent.maxAttempts()); - assertEquals(3, blank.maxAttempts()); - } - - /** - * Verifies that {@link LlmConfig#fromMap(Map)} parses reasoning effort with case normalization. - * - *

            Scenario: A2AT_LLM_REASONING_EFFORT is set to "MEDIUM". Expected result: reasoningEffort resolves to "MEDIUM" - * (preserved as-is after trimming). - */ - @Test - void should_normalizeReasoningEffort_When_configured() { - LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_REASONING_EFFORT", "MEDIUM")); - - assertEquals("MEDIUM", config.reasoningEffort()); - } - - /** - * Verifies that {@link LlmConfig#fromMap(Map)} returns null reasoning effort when the key is absent or blank. - * - *

            Scenario: A2AT_LLM_REASONING_EFFORT is missing or set to blank. Expected result: reasoningEffort is null. - */ - @Test - void should_returnNullReasoningEffort_When_valueIsAbsentOrBlank() { - LlmConfig absent = LlmConfig.fromMap(Map.of()); - LlmConfig blank = LlmConfig.fromMap(Map.of("A2AT_LLM_REASONING_EFFORT", "")); - - assertNull(absent.reasoningEffort()); - assertNull(blank.reasoningEffort()); - } - - @Test - void shouldRecordParseErrors_When_numericValuesAreNonNumeric() { - Map values = Map.of( - "A2AT_LLM_MAX_TOKENS", "garbage", - "A2AT_LLM_TEMPERATURE", "xyz", - "A2AT_LLM_TIMEOUT_SECONDS", "abc", - "A2AT_LLM_HISTORY_WINDOW", "not_a_number", - "A2AT_LLM_SESSION_MAX_TOTAL", "bad", - "A2AT_LLM_SESSION_MAX_PER_PROVIDER", "nope"); - - LlmConfig config = LlmConfig.fromMap(values); - - List errors = config.parseErrors(); - assertEquals(6, errors.size()); - assertTrue(errors.contains("A2AT_LLM_MAX_TOKENS: non-numeric value 'garbage'")); - assertTrue(errors.contains("A2AT_LLM_TEMPERATURE: non-numeric value 'xyz'")); - assertTrue(errors.contains("A2AT_LLM_TIMEOUT_SECONDS: non-numeric value 'abc'")); - assertTrue(errors.contains("A2AT_LLM_HISTORY_WINDOW: non-numeric value 'not_a_number'")); - assertTrue(errors.contains("A2AT_LLM_SESSION_MAX_TOTAL: non-numeric value 'bad'")); - assertTrue(errors.contains("A2AT_LLM_SESSION_MAX_PER_PROVIDER: non-numeric value 'nope'")); - - assertNull(config.maxTokens()); - assertNull(config.temperature()); - assertNull(config.timeoutSeconds()); - assertEquals(10, config.historyWindow()); - assertEquals(300, config.sessionMaxTotal()); - assertEquals(100, config.sessionMaxPerProvider()); - } - - @Test - void shouldNotRecordMaxAttemptsInParseErrors_When_maxAttemptsIsNonNumeric() { - LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "garbage")); - - assertTrue(config.parseErrors().isEmpty()); - assertEquals(3, config.maxAttempts()); - } - - @Test - void shouldReturnEmptyParseErrors_When_noErrors() { - Map values = Map.of( - "A2AT_LLM_PROVIDER", "openai", - "A2AT_LLM_MAX_TOKENS", "4096", - "A2AT_LLM_TEMPERATURE", "0.5", - "A2AT_LLM_HISTORY_WINDOW", "20", - "A2AT_LLM_MAX_ATTEMPTS", "5"); - - LlmConfig config = LlmConfig.fromMap(values); - - assertTrue(config.parseErrors().isEmpty()); - assertEquals(4096, config.maxTokens()); - assertEquals(0.5d, config.temperature()); - assertEquals(20, config.historyWindow()); - assertEquals(5, config.maxAttempts()); - } - - @Test - void shouldReturnEmptyParseErrors_When_noValuesProvided() { - LlmConfig config = LlmConfig.fromMap(Map.of()); - - assertTrue(config.parseErrors().isEmpty()); - } - - @Test - void shouldRecordParseError_When_booleanValueIsInvalid() { - LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_DISABLE_SYSTEM_PROXY", "maybe")); - - List errors = config.parseErrors(); - assertEquals(1, errors.size()); - assertTrue(errors.contains("A2AT_LLM_DISABLE_SYSTEM_PROXY: invalid boolean value 'maybe'")); - assertEquals(false, config.disableSystemProxy()); - } - - @Test - void shouldParseBooleanValueCaseInsensitive() { - LlmConfig trueConfig = LlmConfig.fromMap(Map.of("A2AT_LLM_DISABLE_SYSTEM_PROXY", "TRUE")); - LlmConfig falseConfig = LlmConfig.fromMap(Map.of("A2AT_LLM_DISABLE_SYSTEM_PROXY", "False")); - - assertEquals(true, trueConfig.disableSystemProxy()); - assertEquals(false, falseConfig.disableSystemProxy()); - assertTrue(trueConfig.parseErrors().isEmpty()); - assertTrue(falseConfig.parseErrors().isEmpty()); - } -} +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link LlmConfig}. + * + *

            Tests cover the following scenarios: + * + *

              + *
            • Default values when configuration keys are missing + *
            • Overriding defaults with environment variable values + *
            • Max attempts parsing: in-range values, clamping, fallback, and blank values + *
            • Reasoning effort parsing: valid values, case normalization, blank values + *
            + * + * @since 2026-06 + */ +class LlmConfigTest { + + /** + * Verifies that {@link LlmConfig#fromMap(Map)} applies default values when no configuration keys are provided. + * + *

            Scenario: An empty map is passed to fromMap(). Expected result: All fields use predefined defaults: - + * provider: "openai" - model, apiKey, baseUrl: empty strings - historyWindow: 10 - maxTokens: null - temperature: + * null - timeoutSeconds: null - reasoningEffort: null - sessionMaxTotal: 300 - sessionMaxPerProvider: 100 - + * maxAttempts: 3 + */ + @Test + void should_useDefaults_When_keysAreMissing() { + Map values = Map.of(); + + LlmConfig config = LlmConfig.fromMap(values); + + assertEquals("openai", config.provider()); + assertEquals("", config.model()); + assertEquals("", config.apiKey()); + assertEquals("", config.baseUrl()); + assertEquals(10, config.historyWindow()); + assertNull(config.maxTokens()); + assertNull(config.temperature()); + assertNull(config.timeoutSeconds()); + assertEquals(false, config.disableSystemProxy()); + assertNull(config.reasoningEffort()); + assertEquals(300, config.sessionMaxTotal()); + assertEquals(100, config.sessionMaxPerProvider()); + assertEquals(3, config.maxAttempts()); + } + + /** + * Verifies that {@link LlmConfig#fromMap(Map)} overrides default values with values from the provided map. + * + *

            Scenario: A map containing all LLM configuration keys with custom values. Expected result: All fields use the + * values from the map instead of defaults. + */ + @Test + void should_overrideDefaults_When_keysAreProvided() { + Map values = Map.ofEntries( + Map.entry("A2AT_LLM_PROVIDER", "example_provider"), + Map.entry("A2AT_LLM_MODEL", "example-model"), + Map.entry("A2AT_LLM_API_KEY", "test-api-key"), + Map.entry("A2AT_LLM_BASE_URL", "https://llm.example.test/v1"), + Map.entry("A2AT_LLM_HISTORY_WINDOW", "20"), + Map.entry("A2AT_LLM_MAX_TOKENS", "4096"), + Map.entry("A2AT_LLM_TEMPERATURE", "0.5"), + Map.entry("A2AT_LLM_TIMEOUT_SECONDS", "60"), + Map.entry("A2AT_LLM_DISABLE_SYSTEM_PROXY", "true"), + Map.entry("A2AT_LLM_SESSION_MAX_TOTAL", "500"), + Map.entry("A2AT_LLM_SESSION_MAX_PER_PROVIDER", "150"), + Map.entry("A2AT_LLM_MAX_ATTEMPTS", "5"), + Map.entry("A2AT_LLM_REASONING_EFFORT", "HIGH")); + + LlmConfig config = LlmConfig.fromMap(values); + + assertEquals("example_provider", config.provider()); + assertEquals("example-model", config.model()); + assertEquals("test-api-key", config.apiKey()); + assertEquals("https://llm.example.test/v1", config.baseUrl()); + assertEquals(20, config.historyWindow()); + assertEquals(4096, config.maxTokens()); + assertEquals(0.5d, config.temperature()); + assertEquals(60.0d, config.timeoutSeconds()); + assertEquals(true, config.disableSystemProxy()); + assertEquals(500, config.sessionMaxTotal()); + assertEquals(150, config.sessionMaxPerProvider()); + assertEquals(5, config.maxAttempts()); + assertEquals("HIGH", config.reasoningEffort()); + } + + /** + * Verifies that {@link LlmConfig#fromMap(Map)} clamps an out-of-range max attempts value to the nearest allowed + * bound. + * + *

            Scenario: A2AT_LLM_MAX_ATTEMPTS is set to "0" (below the lower bound of 1) and to "99" (above the upper bound + * of 10). Expected result: maxAttempts resolves to 1 and to 10 respectively. + */ + @Test + void should_clampMaxAttempts_When_valueIsOutOfRange() { + LlmConfig tooSmall = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "0")); + LlmConfig tooLarge = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "99")); + + assertEquals(1, tooSmall.maxAttempts()); + assertEquals(10, tooLarge.maxAttempts()); + } + + /** + * Verifies that {@link LlmConfig#fromMap(Map)} falls back to the default max attempts value when the configured + * value is not a valid integer. + * + *

            Scenario: A2AT_LLM_MAX_ATTEMPTS is set to a non-numeric value. Expected result: maxAttempts resolves to the + * default of 3. + */ + @Test + void should_fallBackToDefaultMaxAttempts_When_valueIsUnparseable() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "garbage")); + + assertEquals(3, config.maxAttempts()); + } + + /** + * Verifies that {@link LlmConfig#fromMap(Map)} uses the default max attempts value when the key is absent or blank. + * + *

            Scenario: A2AT_LLM_MAX_ATTEMPTS is missing from the map, or set to a blank value. Expected result: maxAttempts + * resolves to the default of 3 in both cases. + */ + @Test + void should_useDefaultMaxAttempts_When_valueIsAbsentOrBlank() { + LlmConfig absent = LlmConfig.fromMap(Map.of()); + LlmConfig blank = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "")); + + assertEquals(3, absent.maxAttempts()); + assertEquals(3, blank.maxAttempts()); + } + + /** + * Verifies that {@link LlmConfig#fromMap(Map)} parses reasoning effort with case normalization. + * + *

            Scenario: A2AT_LLM_REASONING_EFFORT is set to "MEDIUM". Expected result: reasoningEffort resolves to "MEDIUM" + * (preserved as-is after trimming). + */ + @Test + void should_normalizeReasoningEffort_When_configured() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_REASONING_EFFORT", "MEDIUM")); + + assertEquals("MEDIUM", config.reasoningEffort()); + } + + /** + * Verifies that {@link LlmConfig#fromMap(Map)} returns null reasoning effort when the key is absent or blank. + * + *

            Scenario: A2AT_LLM_REASONING_EFFORT is missing or set to blank. Expected result: reasoningEffort is null. + */ + @Test + void should_returnNullReasoningEffort_When_valueIsAbsentOrBlank() { + LlmConfig absent = LlmConfig.fromMap(Map.of()); + LlmConfig blank = LlmConfig.fromMap(Map.of("A2AT_LLM_REASONING_EFFORT", "")); + + assertNull(absent.reasoningEffort()); + assertNull(blank.reasoningEffort()); + } + + @Test + void shouldRecordParseErrors_When_numericValuesAreNonNumeric() { + Map values = Map.of( + "A2AT_LLM_MAX_TOKENS", "garbage", + "A2AT_LLM_TEMPERATURE", "xyz", + "A2AT_LLM_TIMEOUT_SECONDS", "abc", + "A2AT_LLM_HISTORY_WINDOW", "not_a_number", + "A2AT_LLM_SESSION_MAX_TOTAL", "bad", + "A2AT_LLM_SESSION_MAX_PER_PROVIDER", "nope"); + + LlmConfig config = LlmConfig.fromMap(values); + + List errors = config.parseErrors(); + assertEquals(6, errors.size()); + assertTrue(errors.contains("A2AT_LLM_MAX_TOKENS: non-numeric value 'garbage'")); + assertTrue(errors.contains("A2AT_LLM_TEMPERATURE: non-numeric value 'xyz'")); + assertTrue(errors.contains("A2AT_LLM_TIMEOUT_SECONDS: non-numeric value 'abc'")); + assertTrue(errors.contains("A2AT_LLM_HISTORY_WINDOW: non-numeric value 'not_a_number'")); + assertTrue(errors.contains("A2AT_LLM_SESSION_MAX_TOTAL: non-numeric value 'bad'")); + assertTrue(errors.contains("A2AT_LLM_SESSION_MAX_PER_PROVIDER: non-numeric value 'nope'")); + + assertNull(config.maxTokens()); + assertNull(config.temperature()); + assertNull(config.timeoutSeconds()); + assertEquals(10, config.historyWindow()); + assertEquals(300, config.sessionMaxTotal()); + assertEquals(100, config.sessionMaxPerProvider()); + } + + @Test + void shouldNotRecordMaxAttemptsInParseErrors_When_maxAttemptsIsNonNumeric() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "garbage")); + + assertTrue(config.parseErrors().isEmpty()); + assertEquals(3, config.maxAttempts()); + } + + @Test + void shouldReturnEmptyParseErrors_When_noErrors() { + Map values = Map.of( + "A2AT_LLM_PROVIDER", "openai", + "A2AT_LLM_MAX_TOKENS", "4096", + "A2AT_LLM_TEMPERATURE", "0.5", + "A2AT_LLM_HISTORY_WINDOW", "20", + "A2AT_LLM_MAX_ATTEMPTS", "5"); + + LlmConfig config = LlmConfig.fromMap(values); + + assertTrue(config.parseErrors().isEmpty()); + assertEquals(4096, config.maxTokens()); + assertEquals(0.5d, config.temperature()); + assertEquals(20, config.historyWindow()); + assertEquals(5, config.maxAttempts()); + } + + @Test + void shouldReturnEmptyParseErrors_When_noValuesProvided() { + LlmConfig config = LlmConfig.fromMap(Map.of()); + + assertTrue(config.parseErrors().isEmpty()); + } + + @Test + void shouldRecordParseError_When_booleanValueIsInvalid() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_DISABLE_SYSTEM_PROXY", "maybe")); + + List errors = config.parseErrors(); + assertEquals(1, errors.size()); + assertTrue(errors.contains("A2AT_LLM_DISABLE_SYSTEM_PROXY: invalid boolean value 'maybe'")); + assertEquals(false, config.disableSystemProxy()); + } + + @Test + void shouldParseBooleanValueCaseInsensitive() { + LlmConfig trueConfig = LlmConfig.fromMap(Map.of("A2AT_LLM_DISABLE_SYSTEM_PROXY", "TRUE")); + LlmConfig falseConfig = LlmConfig.fromMap(Map.of("A2AT_LLM_DISABLE_SYSTEM_PROXY", "False")); + + assertEquals(true, trueConfig.disableSystemProxy()); + assertEquals(false, falseConfig.disableSystemProxy()); + assertTrue(trueConfig.parseErrors().isEmpty()); + assertTrue(falseConfig.parseErrors().isEmpty()); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java index c507dbd8..bd6965a4 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java @@ -1,61 +1,61 @@ -package net.openan.a2at.sdk.core.model; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -import java.util.Map; -import org.junit.jupiter.api.Test; - -/** - * Unit tests for {@link PromptRuntimeConfig}. - * - *

            Tests cover the following scenarios: - * - *

              - *
            • Default values when configuration keys are missing - *
            • Overriding defaults with environment variable values - *
            - * - * @since 2026-06 - */ -class PromptRuntimeConfigTest { - - /** - * Verifies that {@link PromptRuntimeConfig#fromMap(Map)} applies default values when no configuration keys are - * provided. - * - *

            Scenario: An empty map is passed to fromMap(). Expected result: All fields use predefined defaults: - - * language: "en-US" - sourceType: "classpath" - localRootDir: null (unset) - */ - @Test - void should_useDefaults_When_keysAreMissing() { - Map values = Map.of(); - - PromptRuntimeConfig config = PromptRuntimeConfig.fromMap(values); - - assertEquals("en-US", config.language()); - assertEquals("classpath", config.sourceType()); - assertNull(config.localRootDir()); - } - - /** - * Verifies that {@link PromptRuntimeConfig#fromMap(Map)} overrides default values with values from the provided - * map. - * - *

            Scenario: A map containing language, source type, and local root directory. Expected result: All fields use - * the values from the map instead of defaults. - */ - @Test - void should_overrideDefaults_When_keysAreProvided() { - Map values = Map.of( - "A2AT_LANGUAGE", "zh-CN", - "A2AT_PROMPT_SOURCE_TYPE", "local_file", - "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR", "/path/to/prompts"); - - PromptRuntimeConfig config = PromptRuntimeConfig.fromMap(values); - - assertEquals("zh-CN", config.language()); - assertEquals("local_file", config.sourceType()); - assertEquals("/path/to/prompts", config.localRootDir()); - } -} +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link PromptRuntimeConfig}. + * + *

            Tests cover the following scenarios: + * + *

              + *
            • Default values when configuration keys are missing + *
            • Overriding defaults with environment variable values + *
            + * + * @since 2026-06 + */ +class PromptRuntimeConfigTest { + + /** + * Verifies that {@link PromptRuntimeConfig#fromMap(Map)} applies default values when no configuration keys are + * provided. + * + *

            Scenario: An empty map is passed to fromMap(). Expected result: All fields use predefined defaults: - + * language: "en-US" - sourceType: "classpath" - localRootDir: null (unset) + */ + @Test + void should_useDefaults_When_keysAreMissing() { + Map values = Map.of(); + + PromptRuntimeConfig config = PromptRuntimeConfig.fromMap(values); + + assertEquals("en-US", config.language()); + assertEquals("classpath", config.sourceType()); + assertNull(config.localRootDir()); + } + + /** + * Verifies that {@link PromptRuntimeConfig#fromMap(Map)} overrides default values with values from the provided + * map. + * + *

            Scenario: A map containing language, source type, and local root directory. Expected result: All fields use + * the values from the map instead of defaults. + */ + @Test + void should_overrideDefaults_When_keysAreProvided() { + Map values = Map.of( + "A2AT_LANGUAGE", "zh-CN", + "A2AT_PROMPT_SOURCE_TYPE", "local_file", + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR", "/path/to/prompts"); + + PromptRuntimeConfig config = PromptRuntimeConfig.fromMap(values); + + assertEquals("zh-CN", config.language()); + assertEquals("local_file", config.sourceType()); + assertEquals("/path/to/prompts", config.localRootDir()); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java index 0070d0c0..c6dc137b 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java @@ -1,52 +1,52 @@ -package net.openan.a2at.sdk.core.model; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -import org.junit.jupiter.api.Test; - -/** - * Unit tests for {@link PromptTemplate}. - * - *

            Tests cover the four-component record shape and the {@code source()} accessor that identifies the effective - * resource origin ({@code classpath} / {@code local}). - * - * @since 2026-08 - */ -class PromptTemplateTest { - - /** - * Verifies that {@link PromptTemplate} exposes all four components. - * - *

            Scenario: Construct a template with a URI, description, content and a {@code classpath} source. Expected - * result: templateUri(), description(), content() and source() each return the supplied value. - */ - @Test - void should_exposeAllFourComponents_When_templateConstructedWithClasspathSource() { - TemplateUri templateUri = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); - - PromptTemplate template = new PromptTemplate(templateUri, "energy saving", "# template", "classpath"); - - assertEquals(templateUri, template.templateUri()); - assertEquals("energy saving", template.description()); - assertEquals("# template", template.content()); - assertEquals("classpath", template.source()); - } - - /** - * Verifies that {@link PromptTemplate#source()} carries the {@code local} source value. - * - *

            Scenario: Construct a template with a {@code local} source. Expected result: source() returns {@code local}. - */ - @Test - void should_exposeLocalSource_When_templateConstructedWithLocalSource() { - TemplateUri templateUri = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); - - PromptTemplate template = new PromptTemplate(templateUri, "", null, "local"); - - assertEquals(templateUri, template.templateUri()); - assertEquals("", template.description()); - assertNull(template.content()); - assertEquals("local", template.source()); - } -} +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link PromptTemplate}. + * + *

            Tests cover the four-component record shape and the {@code source()} accessor that identifies the effective + * resource origin ({@code classpath} / {@code local}). + * + * @since 2026-08 + */ +class PromptTemplateTest { + + /** + * Verifies that {@link PromptTemplate} exposes all four components. + * + *

            Scenario: Construct a template with a URI, description, content and a {@code classpath} source. Expected + * result: templateUri(), description(), content() and source() each return the supplied value. + */ + @Test + void should_exposeAllFourComponents_When_templateConstructedWithClasspathSource() { + TemplateUri templateUri = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); + + PromptTemplate template = new PromptTemplate(templateUri, "energy saving", "# template", "classpath"); + + assertEquals(templateUri, template.templateUri()); + assertEquals("energy saving", template.description()); + assertEquals("# template", template.content()); + assertEquals("classpath", template.source()); + } + + /** + * Verifies that {@link PromptTemplate#source()} carries the {@code local} source value. + * + *

            Scenario: Construct a template with a {@code local} source. Expected result: source() returns {@code local}. + */ + @Test + void should_exposeLocalSource_When_templateConstructedWithLocalSource() { + TemplateUri templateUri = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); + + PromptTemplate template = new PromptTemplate(templateUri, "", null, "local"); + + assertEquals(templateUri, template.templateUri()); + assertEquals("", template.description()); + assertNull(template.content()); + assertEquals("local", template.source()); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java index 52206c42..428d04b5 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java @@ -1,131 +1,131 @@ -package net.openan.a2at.sdk.core.resources; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -class PathSegmentsTest { - - @Test - void should_rejectNull_When_valueIsNull() { - assertFalse(PathSegments.isSimpleSegment(null)); - } - - @Test - void should_rejectBlank_When_valueIsBlank() { - assertFalse(PathSegments.isSimpleSegment("")); - assertFalse(PathSegments.isSimpleSegment(" ")); - } - - @Test - void should_rejectForwardSlash_When_valueContainsSlash() { - assertFalse(PathSegments.isSimpleSegment("a/b")); - assertFalse(PathSegments.isSimpleSegment("/")); - } - - @Test - void should_rejectBackslash_When_valueContainsBackslash() { - assertFalse(PathSegments.isSimpleSegment("a\\b")); - assertFalse(PathSegments.isSimpleSegment("\\")); - } - - @Test - void should_rejectTraversal_When_valueContainsDotDot() { - assertFalse(PathSegments.isSimpleSegment("..")); - assertFalse(PathSegments.isSimpleSegment("a/../b")); - } - - @Test - void should_acceptValidSegment_When_valueIsSimple() { - assertTrue(PathSegments.isSimpleSegment("ran-energy-saving")); - assertTrue(PathSegments.isSimpleSegment("Task-T")); - assertTrue(PathSegments.isSimpleSegment("zh-CN")); - assertTrue(PathSegments.isSimpleSegment("a.b")); - } - - @Test - void requireSimpleSegment_rejectsNullWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment(null, "the label")); - - assertTrue(exception.getMessage().contains("the label")); - } - - @Test - void requireSimpleSegment_rejectsBlankWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment(" ", "the label")); - - assertTrue(exception.getMessage().contains("the label")); - } - - @Test - void requireSimpleSegment_rejectsSlashWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("a/b", "the label")); - - assertTrue(exception.getMessage().contains("the label")); - } - - @Test - void requireSimpleSegment_rejectsBackslashWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("a\\b", "the label")); - - assertTrue(exception.getMessage().contains("the label")); - } - - @Test - void requireSimpleSegment_rejectsTraversalWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("..", "the label")); - - assertTrue(exception.getMessage().contains("the label")); - } - - @Test - void requireSimpleSegment_acceptsValidValues() { - assertDoesNotThrow(() -> { - PathSegments.requireSimpleSegment("ran-energy-saving", "segment"); - PathSegments.requireSimpleSegment("Task-T", "segment"); - PathSegments.requireSimpleSegment("zh-CN", "segment"); - PathSegments.requireSimpleSegment("a.b", "segment"); - }); - } - - @Test - void requireSimpleRelativePath_rejectsEmptySegmentsWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath("a//b", "the path")); - - assertTrue(exception.getMessage().contains("the path")); - } - - @Test - void requireSimpleRelativePath_rejectsTraversalSegmentWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath("../x", "the path")); - - assertTrue(exception.getMessage().contains("the path")); - } - - @Test - void requireSimpleRelativePath_rejectsNullWithLabelInMessage() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath(null, "the path")); - - assertTrue(exception.getMessage().contains("the path")); - } - - @Test - void requireSimpleRelativePath_acceptsValidValues() { - assertDoesNotThrow(() -> { - PathSegments.requireSimpleRelativePath("a/b/c", "path"); - PathSegments.requireSimpleRelativePath("Task-T/network-layer/ran-energy-saving/v1", "path"); - PathSegments.requireSimpleRelativePath("zh-CN", "path"); - }); - } -} +package net.openan.a2at.sdk.core.resources; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class PathSegmentsTest { + + @Test + void should_rejectNull_When_valueIsNull() { + assertFalse(PathSegments.isSimpleSegment(null)); + } + + @Test + void should_rejectBlank_When_valueIsBlank() { + assertFalse(PathSegments.isSimpleSegment("")); + assertFalse(PathSegments.isSimpleSegment(" ")); + } + + @Test + void should_rejectForwardSlash_When_valueContainsSlash() { + assertFalse(PathSegments.isSimpleSegment("a/b")); + assertFalse(PathSegments.isSimpleSegment("/")); + } + + @Test + void should_rejectBackslash_When_valueContainsBackslash() { + assertFalse(PathSegments.isSimpleSegment("a\\b")); + assertFalse(PathSegments.isSimpleSegment("\\")); + } + + @Test + void should_rejectTraversal_When_valueContainsDotDot() { + assertFalse(PathSegments.isSimpleSegment("..")); + assertFalse(PathSegments.isSimpleSegment("a/../b")); + } + + @Test + void should_acceptValidSegment_When_valueIsSimple() { + assertTrue(PathSegments.isSimpleSegment("ran-energy-saving")); + assertTrue(PathSegments.isSimpleSegment("Task-T")); + assertTrue(PathSegments.isSimpleSegment("zh-CN")); + assertTrue(PathSegments.isSimpleSegment("a.b")); + } + + @Test + void requireSimpleSegment_rejectsNullWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment(null, "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_rejectsBlankWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment(" ", "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_rejectsSlashWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("a/b", "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_rejectsBackslashWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("a\\b", "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_rejectsTraversalWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleSegment("..", "the label")); + + assertTrue(exception.getMessage().contains("the label")); + } + + @Test + void requireSimpleSegment_acceptsValidValues() { + assertDoesNotThrow(() -> { + PathSegments.requireSimpleSegment("ran-energy-saving", "segment"); + PathSegments.requireSimpleSegment("Task-T", "segment"); + PathSegments.requireSimpleSegment("zh-CN", "segment"); + PathSegments.requireSimpleSegment("a.b", "segment"); + }); + } + + @Test + void requireSimpleRelativePath_rejectsEmptySegmentsWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath("a//b", "the path")); + + assertTrue(exception.getMessage().contains("the path")); + } + + @Test + void requireSimpleRelativePath_rejectsTraversalSegmentWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath("../x", "the path")); + + assertTrue(exception.getMessage().contains("the path")); + } + + @Test + void requireSimpleRelativePath_rejectsNullWithLabelInMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> PathSegments.requireSimpleRelativePath(null, "the path")); + + assertTrue(exception.getMessage().contains("the path")); + } + + @Test + void requireSimpleRelativePath_acceptsValidValues() { + assertDoesNotThrow(() -> { + PathSegments.requireSimpleRelativePath("a/b/c", "path"); + PathSegments.requireSimpleRelativePath("Task-T/network-layer/ran-energy-saving/v1", "path"); + PathSegments.requireSimpleRelativePath("zh-CN", "path"); + }); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ValidationPipelineTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ValidationPipelineTest.java index 6eedbd08..e35fe131 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ValidationPipelineTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ValidationPipelineTest.java @@ -1,135 +1,135 @@ -package net.openan.a2at.sdk.core.validation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import org.junit.jupiter.api.Test; - -/** - * Guards the optional template loading gate of {@link ValidationPipeline}: the gate must run after the rule gate and - * before semantic validation, a loader failure must map to {@code template.not_found}, and the loader-less default - * {@code validate} variant must reject callers that did not provide the template content. - */ -class ValidationPipelineTest { - - @Test - void should_loadTemplateBetweenRuleGateAndSemanticValidation() { - List events = new ArrayList<>(); - RuleChecker ruleChecker = prompt -> { - events.add("rule"); - return Map.of(); - }; - SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> { - events.add("semantic"); - return new ValidationResult(true, List.of(), Map.of("param", "value")); - }; - TemplateContentLoader loader = reference -> { - events.add("load"); - return "template-content"; - }; - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, null, loader); - - pipeline.validate("prompt", Map.of(), "ref"); - - assertEquals(List.of("rule", "load", "semantic"), events); - } - - @Test - void should_mapLoaderFailureToPromptResourceNotFound() { - RuleChecker ruleChecker = prompt -> Map.of(); - SemanticValidator semanticValidator = - (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); - TemplateContentLoader loader = reference -> { - throw new ResourceNotFoundException("missing template", reference); - }; - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, null, loader); - - ContentValidationException exception = - assertThrows(ContentValidationException.class, () -> pipeline.validate("prompt", Map.of(), "ref")); - assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), exception.getCode()); - } - - @Test - void should_throwValidationInvalidInput_When_schemaIsNull() { - RuleChecker ruleChecker = prompt -> Map.of(); - SemanticValidator semanticValidator = - (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, () -> pipeline.validate("prompt", null, "ref", "content")); - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); - } - - @Test - void should_throwValidationInvalidInput_When_promptIsNullOrBlank() { - RuleChecker ruleChecker = prompt -> Map.of(); - SemanticValidator semanticValidator = - (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); - - ContentValidationException nullException = assertThrows( - ContentValidationException.class, () -> pipeline.validate(null, Map.of(), "ref", "content")); - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), nullException.getCode()); - - ContentValidationException blankException = assertThrows( - ContentValidationException.class, () -> pipeline.validate(" ", Map.of(), "ref", "content")); - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), blankException.getCode()); - } - - @Test - void should_throwValidationInvalidInput_When_referenceIsNull() { - RuleChecker ruleChecker = prompt -> Map.of(); - SemanticValidator semanticValidator = - (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, () -> pipeline.validate("prompt", Map.of(), null, "content")); - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); - } - - @Test - void should_throwIllegalStateException_When_noLoaderInjected() { - RuleChecker ruleChecker = prompt -> Map.of(); - SemanticValidator semanticValidator = - (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); - - assertThrows(IllegalStateException.class, () -> pipeline.validate("prompt", Map.of(), "ref")); - } - - @Test - void should_notInvokeLoader_When_templateContentProvidedExplicitly() { - List events = new ArrayList<>(); - RuleChecker ruleChecker = prompt -> { - events.add("rule"); - return Map.of(); - }; - SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> { - events.add("semantic"); - return new ValidationResult(true, List.of(), Map.of()); - }; - TemplateContentLoader loader = reference -> { - events.add("load"); - return "template-content"; - }; - - ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, null, loader); - - pipeline.validate("prompt", Map.of(), "ref", "explicit-content"); - - assertEquals(List.of("rule", "semantic"), events); - } -} +package net.openan.a2at.sdk.core.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import org.junit.jupiter.api.Test; + +/** + * Guards the optional template loading gate of {@link ValidationPipeline}: the gate must run after the rule gate and + * before semantic validation, a loader failure must map to {@code template.not_found}, and the loader-less default + * {@code validate} variant must reject callers that did not provide the template content. + */ +class ValidationPipelineTest { + + @Test + void should_loadTemplateBetweenRuleGateAndSemanticValidation() { + List events = new ArrayList<>(); + RuleChecker ruleChecker = prompt -> { + events.add("rule"); + return Map.of(); + }; + SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> { + events.add("semantic"); + return new ValidationResult(true, List.of(), Map.of("param", "value")); + }; + TemplateContentLoader loader = reference -> { + events.add("load"); + return "template-content"; + }; + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, null, loader); + + pipeline.validate("prompt", Map.of(), "ref"); + + assertEquals(List.of("rule", "load", "semantic"), events); + } + + @Test + void should_mapLoaderFailureToPromptResourceNotFound() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + TemplateContentLoader loader = reference -> { + throw new ResourceNotFoundException("missing template", reference); + }; + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, null, loader); + + ContentValidationException exception = + assertThrows(ContentValidationException.class, () -> pipeline.validate("prompt", Map.of(), "ref")); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), exception.getCode()); + } + + @Test + void should_throwValidationInvalidInput_When_schemaIsNull() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, () -> pipeline.validate("prompt", null, "ref", "content")); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); + } + + @Test + void should_throwValidationInvalidInput_When_promptIsNullOrBlank() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + ContentValidationException nullException = assertThrows( + ContentValidationException.class, () -> pipeline.validate(null, Map.of(), "ref", "content")); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), nullException.getCode()); + + ContentValidationException blankException = assertThrows( + ContentValidationException.class, () -> pipeline.validate(" ", Map.of(), "ref", "content")); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), blankException.getCode()); + } + + @Test + void should_throwValidationInvalidInput_When_referenceIsNull() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, () -> pipeline.validate("prompt", Map.of(), null, "content")); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); + } + + @Test + void should_throwIllegalStateException_When_noLoaderInjected() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + assertThrows(IllegalStateException.class, () -> pipeline.validate("prompt", Map.of(), "ref")); + } + + @Test + void should_notInvokeLoader_When_templateContentProvidedExplicitly() { + List events = new ArrayList<>(); + RuleChecker ruleChecker = prompt -> { + events.add("rule"); + return Map.of(); + }; + SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> { + events.add("semantic"); + return new ValidationResult(true, List.of(), Map.of()); + }; + TemplateContentLoader loader = reference -> { + events.add("load"); + return "template-content"; + }; + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, null, loader); + + pipeline.validate("prompt", Map.of(), "ref", "explicit-content"); + + assertEquals(List.of("rule", "semantic"), events); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java index 5016a0d3..98d739f4 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java @@ -1,944 +1,944 @@ -package net.openan.a2at.sdk.corpus; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; -import org.jspecify.annotations.Nullable; - -/** - * Executes one expanded corpus case against the production negotiation content wiring and asserts its expectation - * block. - * - *

            Everything except the LLM client is production assembly: the case engine builds a real - * {@link NegotiationGenerationOrchestrator} through {@link NegotiationGenerationOrchestratorBuilder} for the language - * of the case, wires the {@code inject} hooks onto their real builder injection points ({@code failingTemplateLoader} - * for the generation template-not-found matrix, {@code failingSemanticValidator} for the validate-family - * prompt-resource-not-found mapping) and dispatches on the {@link NegotiationApi} enum at compile time, so a misspelled - * API name fails at corpus load time and a renamed service method fails this compilation. The three task-family APIs - * run through the {@link TaskApiAssembler}, the real facade builders' assembly of the closed loop (Q21) with the - * scripted LLM client injected at the builders' LLM seam. - * - *

            Result normalization: a success run returns the produced {@link MetadataContent} or {@link FilledParamData}, a - * failure run captures the thrown exception. The expectation comparison covers outcome, exception name, error code, - * message fragments, slot errors (slot plus code pairs, exact but order-insensitive), the exact LLM call count, the - * golden fixture text (per language, byte equality after CRLF normalization), the metadata echoes, the expected merged - * parameter map, the P0 behavior contracts and the Q17 C+ differential double run (fromText == fromData == golden, - * from-data side proven zero-call with an assertion-only client). Every mismatch fails with the case id, the JSON path - * of the expectation and the expected-versus-actual pair. - * - * @since 2026-08 - */ -public final class CaseEngine { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private static final String INJECT_FAILING_TEMPLATE_LOADER = "failingTemplateLoader"; - - private static final String INJECT_FAILING_SEMANTIC_VALIDATOR = "failingSemanticValidator"; - - private static final int MAX_REPORTED_TEXT_LENGTH = 400; - - /** - * Runs one case without a prompt override. - * - * @param testCase expanded corpus case - * @return the normalized outcome of the run - * @throws AssertionError when any expectation mismatches - */ - public CaseOutcome run(NegotiationCase testCase) { - return run(testCase, null); - } - - /** - * Runs one case, optionally overriding the prompt input of the validate family. - * - *

            The {@link ScenarioEngine} resolves {@code prompt.fromStep} references into prompt texts and hands them in - * through this parameter; a standalone validate case resolves its golden or inline prompt itself. - * - * @param testCase expanded corpus case - * @param promptOverride prompt text resolved from an earlier scenario step, or null - * @return the normalized outcome of the run - * @throws AssertionError when any expectation mismatches - */ - public CaseOutcome run(NegotiationCase testCase, @Nullable String promptOverride) { - return run(testCase, promptOverride, false); - } - - /** - * Runs one case, optionally overriding the prompt input of the validate family and optionally running as a scenario - * step. - * - *

            The {@link ScenarioEngine} resolves {@code prompt.fromStep} references into prompt texts and hands them in - * through the prompt parameter; a standalone validate case resolves its golden or inline prompt itself. Scenario - * mode defers the {@code expect.paramsFromStep} check to the scenario engine, which resolves the cross-step - * reference after the step ran. - * - * @param testCase expanded corpus case - * @param promptOverride prompt text resolved from an earlier scenario step, or null - * @param inScenario true inside the ScenarioEngine, false for a standalone run - * @return the normalized outcome of the run - * @throws AssertionError when any expectation mismatches - */ - CaseOutcome run(NegotiationCase testCase, @Nullable String promptOverride, boolean inScenario) { - ScriptedNegotiationLlmClient llmClient = llmClientFor(testCase); - NegotiationContentService service = new NegotiationContentService(orchestratorFor(testCase, llmClient)); - TaskApiAssembler taskApi = - testCase.api().family() == NegotiationApi.Family.TASK ? taskApiFor(testCase, llmClient) : null; - Object value = null; - Throwable failure = null; - try { - value = invoke(service, taskApi, testCase, promptOverride); - } catch (RuntimeException | AssertionError error) { - failure = error; - } - CaseOutcome outcome = new CaseOutcome(testCase, value, failure, llmClient.callCount(), llmClient); - assertExpectations(outcome, inScenario); - return outcome; - } - - // ------------------------------------------------------------------ outcome record - - /** - * The normalized outcome of one executed case. - * - * @param testCase the executed expanded corpus case - * @param value the returned {@code MetadataContent} or {@code FilledParamData} on success, or null on failure - * @param failure the captured exception on failure, or null on success - * @param llmCalls exact number of LLM calls the run made - * @param llmClient the scripted client the run used - */ - public record CaseOutcome( - NegotiationCase testCase, - @Nullable Object value, - @Nullable Throwable failure, - int llmCalls, - ScriptedNegotiationLlmClient llmClient) { - - /** - * Returns the generated message of a successful generation run. - * - * @return the metadata content, or null when the run failed or produced filled parameters - */ - public @Nullable MetadataContent message() { - return value instanceof MetadataContent metadata ? metadata : null; - } - - /** - * Returns the filled parameters of a successful validation run. - * - * @return the filled parameter data, or null when the run failed or produced a message - */ - public @Nullable FilledParamData filledParams() { - return value instanceof FilledParamData filled ? filled : null; - } - } - - // ------------------------------------------------------------------ expectation comparison - - private void assertExpectations(CaseOutcome outcome, boolean inScenario) { - NegotiationCase testCase = outcome.testCase(); - Expectation expect = testCase.expect(); - if (expect.success()) { - if (outcome.failure() != null) { - fail(testCase, "$.expect.outcome", "success", "failure (" + outcome.failure() + ")"); - } - assertSuccessFields(outcome, inScenario); - } else { - if (outcome.failure() == null) { - fail( - testCase, - "$.expect.outcome", - "failure", - "success (" + typeName(outcome.value()) + ": " + outcome.value() + ")"); - } - assertFailureFields(outcome); - } - if (expect.llmCalls() != null && expect.llmCalls() != outcome.llmCalls()) { - fail(testCase, "$.expect.llmCalls", String.valueOf(expect.llmCalls()), String.valueOf(outcome.llmCalls())); - } - for (String contractName : expect.contracts()) { - assertContract(outcome, contractName); - } - if (expect.differential()) { - runDifferential(outcome); - } - } - - private void assertFailureFields(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - Expectation expect = testCase.expect(); - Throwable failure = outcome.failure(); - if (expect.exception() != null) { - String actual = failure.getClass().getSimpleName(); - if (!expect.exception().equals(actual)) { - fail(testCase, "$.expect.exception", expect.exception(), actual); - } - } - if (expect.code() != null) { - if (!(failure instanceof A2ATError error)) { - throw fail( - testCase, - "$.expect.code", - expect.code(), - "no error code (exception " + failure.getClass().getSimpleName() + ")"); - } - if (!expect.code().equals(error.getCode())) { - fail(testCase, "$.expect.code", expect.code(), error.getCode()); - } - } - for (String fragment : expect.messageContains()) { - String message = failure.getMessage(); - if (message == null || !(message.contains(fragment) || message.contains(performativeWording(fragment)))) { - fail( - testCase, - "$.expect.messageContains", - "a message containing '" + fragment + "'", - "message " + quoted(message)); - } - } - if (!expect.slotErrors().isEmpty()) { - List slotErrors = slotErrorsOf(failure); - if (slotErrors == null) { - throw fail( - testCase, - "$.expect.slotErrors", - renderSlotErrors(expect.slotErrors()), - "exception " + failure.getClass().getSimpleName() + " carries no slot errors"); - } - assertSlotErrors(testCase, expect.slotErrors(), slotErrors); - } - } - - /** - * Returns the slot errors the failure carries, or null when the failure type carries none. - * - *

            The negotiation content layer surfaces its failures as {@link NegotiationParamExtractionException} (an - * {@code A2ATBusinessException} subtype since the ErrorCatalog migration) while the prompt families still use - * {@link A2ATParamExtractionError}; the engine accepts both carriers. - * - * @param failure captured failure of the run - * @return the slot validation errors of the failure, or null when the failure carries none - */ - private static @Nullable List slotErrorsOf(Throwable failure) { - if (failure instanceof A2ATParamExtractionError extraction) { - return extraction.getErrors(); - } - if (failure instanceof NegotiationParamExtractionException negotiation) { - return negotiation.getErrors(); - } - return null; - } - - private void assertSlotErrors( - NegotiationCase testCase, List expected, List actual) { - Map expectedCounts = new LinkedHashMap<>(); - for (Expectation.SlotError slotError : expected) { - expectedCounts.merge(slotError.slot() + ":" + slotError.code(), 1, Integer::sum); - } - Map actualCounts = new LinkedHashMap<>(); - List actualPairs = new ArrayList<>(); - for (SlotValidationError error : actual) { - String pair = error.slotName() + ":" + error.code(); - actualCounts.merge(pair, 1, Integer::sum); - actualPairs.add(pair); - } - if (!expectedCounts.equals(actualCounts)) { - fail( - testCase, - "$.expect.slotErrors", - renderSlotErrors(expected), - String.join(", ", actualPairs.isEmpty() ? List.of("(none)") : actualPairs)); - } - } - - private void assertSuccessFields(CaseOutcome outcome, boolean inScenario) { - NegotiationCase testCase = outcome.testCase(); - Expectation expect = testCase.expect(); - if (expect.promptTextEqualsGolden() != null) { - MetadataContent message = requireMessage(outcome, "$.expect.promptTextEqualsGolden"); - String golden = readGoldenFixture(testCase, expect.promptTextEqualsGolden()); - if (!normalize(message.promptText()).equals(golden)) { - fail( - testCase, - "$.expect.promptTextEqualsGolden", - quoted(truncate(golden)), - quoted(truncate(normalize(message.promptText())))); - } - } - if (expect.metadata() != null) { - MetadataContent message = requireMessage(outcome, "$.expect.metadata"); - if (expect.metadata().templateUriEcho() != null - && !expect.metadata().templateUriEcho().equals(message.templateUri())) { - fail( - testCase, - "$.expect.metadata.templateUriEcho", - expect.metadata().templateUriEcho(), - message.templateUri()); - } - if (Boolean.TRUE.equals(expect.metadata().contextEcho())) { - // The input context is built with the performative of the case's API, so it equals the emitted context - // the generation pipeline stamped with the performative of the addressed template. - NegotiationContext expectedContext = toContext(testCase.context(), testCase.api()); - if (!Objects.equals(expectedContext, message.negotiationContext())) { - fail( - testCase, - "$.expect.metadata.contextEcho", - String.valueOf(expectedContext), - String.valueOf(message.negotiationContext())); - } - } - } - if (!expect.params().isEmpty() || expect.missingParams() != null) { - if (!(outcome.value() instanceof FilledParamData filled)) { - throw fail( - testCase, - expect.missingParams() != null && expect.params().isEmpty() - ? "$.expect.missingParams" - : "$.expect.params", - expect.missingParams() != null - ? "filled parameter data with the missing-parameter set " + expect.missingParams() - : render(expect.params()), - "no filled parameter data (" + typeName(outcome.value()) + ")"); - } - if (expect.missingParams() != null) { - // Task semantics (Q21): the expected params are a subset check with exact values, and the missing - // parameter set — the null-valued entries of the filled data — is asserted exactly. - for (Map.Entry entry : expect.params().entrySet()) { - if (!filled.data().containsKey(entry.getKey()) - || !Objects.equals(entry.getValue(), filled.data().get(entry.getKey()))) { - fail( - testCase, - "$.expect.params", - entry.getKey() + "=" + entry.getValue(), - entry.getKey() + "=" + filled.data().get(entry.getKey())); - } - } - List actualMissing = filled.data().entrySet().stream() - .filter(entry -> entry.getValue() == null) - .map(Map.Entry::getKey) - .toList(); - Set expectedMissing = new LinkedHashSet<>(expect.missingParams()); - if (!expectedMissing.equals(new LinkedHashSet<>(actualMissing))) { - fail( - testCase, - "$.expect.missingParams", - String.join(", ", expectedMissing), - actualMissing.isEmpty() ? "(none)" : String.join(", ", actualMissing)); - } - } else if (!Objects.equals(expect.params(), filled.data())) { - fail(testCase, "$.expect.params", render(expect.params()), render(filled.data())); - } - } - for (String fragment : expect.promptTextContains()) { - MetadataContent message = requireMessage(outcome, "$.expect.promptTextContains"); - String promptText = message.promptText() == null ? "" : message.promptText(); - if (!promptText.contains(fragment)) { - fail( - testCase, - "$.expect.promptTextContains", - "a prompt text containing '" + fragment + "'", - quoted(truncate(promptText))); - } - } - if (expect.paramsFromStep() != null && !inScenario) { - throw new IllegalStateException(testCase.errorPrefix() + " expect.paramsFromStep " + expect.paramsFromStep() - + " is resolved by the ScenarioEngine; run the enclosing scenario through the" - + " ScenarioEngine"); - } - } - - // ------------------------------------------------------------------ contracts (P0) - - private void assertContract(CaseOutcome outcome, String contractName) { - NegotiationCase testCase = outcome.testCase(); - Contract contract = Contract.fromJsonName(contractName); - if (contract == null) { - fail( - testCase, - "$.expect.contracts", - "a registered contract name (one of " + knownContractNames() + ")", - "'" + contractName + "'"); - } - if (!contract.isP0()) { - fail( - testCase, - "$.expect.contracts", - "a P0 contract the engine asserts today", - "'" + contractName + "' is registered as a P1 expectation and is not yet lit"); - } - switch (contract) { - case CONCLUSION_LITERAL_PRESENT -> assertConclusionLiteralPresent(outcome); - case CONTEXT_KEYS_IN_MERGED_PARAMS -> assertContextKeysInMergedParams(outcome); - case NO_LLM_LEAK_IN_USER_MESSAGE -> assertNoLlmLeakInUserMessage(outcome); - case METADATA_TRIPLE_SHAPE -> assertMetadataTripleShape(outcome); - default -> fail( - testCase, - "$.expect.contracts", - "an implemented contract", - "'" + contractName + "' is flagged P0 but has no implementation"); - } - } - - private void assertConclusionLiteralPresent(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - String literal = - switch (testCase.api()) { - case GENERATE_ACCEPT_FROM_TEXT, GENERATE_ACCEPT_FROM_DATA -> "Accept"; - case GENERATE_REJECT_FROM_TEXT, GENERATE_REJECT_FROM_DATA -> "Reject"; - case GENERATE_ABORT_FROM_TEXT, GENERATE_ABORT_FROM_DATA -> "Abort"; - default -> null; - }; - if (literal == null) { - fail( - testCase, - "$.expect.contracts[conclusionLiteralPresent]", - "a terminal or abort generation API", - testCase.api().jsonName()); - } - MetadataContent message = requireMessage(outcome, "$.expect.contracts[conclusionLiteralPresent]"); - if (!String.valueOf(message.promptText()).contains(literal)) { - fail( - testCase, - "$.expect.contracts[conclusionLiteralPresent]", - "a prompt text containing the literal '" + literal + "'", - quoted(truncate(message.promptText()))); - } - } - - private void assertContextKeysInMergedParams(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - if (!(outcome.value() instanceof FilledParamData filled)) { - throw fail( - testCase, - "$.expect.contracts[contextKeysInMergedParams]", - "filled parameter data carrying the context keys", - typeName(outcome.value())); - } - for (String key : List.of("id", "round", "maxRounds")) { - if (!filled.data().containsKey(key)) { - fail( - testCase, - "$.expect.contracts[contextKeysInMergedParams]", - "merged params containing '" + key + "'", - render(filled.data())); - } - } - } - - private void assertNoLlmLeakInUserMessage(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - List details = outcome.llmClient().leakedFailureDetails(); - if (details.isEmpty()) { - return; - } - String visible = outcome.failure() != null - ? outcome.failure().getMessage() - : outcome.message() != null ? outcome.message().promptText() : null; - if (visible == null) { - fail( - testCase, - "$.expect.contracts[noLlmLeakInUserMessage]", - "a user-visible message to inspect", - "neither a failure message nor a generated message was produced"); - } - for (String detail : details) { - if (visible.contains(detail)) { - fail( - testCase, - "$.expect.contracts[noLlmLeakInUserMessage]", - "a message free of the raw LLM failure detail '" + detail + "'", - quoted(truncate(visible))); - } - } - } - - private void assertMetadataTripleShape(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - MetadataContent message = requireMessage(outcome, "$.expect.contracts[metadataTripleShape]"); - Map metadata = message.buildMetadataContent(); - if (metadata.size() != 3 - || !metadata.containsKey(message.extensionUri()) - || !metadata.containsKey(MetadataContent.TEMPLATE_URI_METADATA_KEY) - || !metadata.containsKey(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY)) { - fail( - testCase, - "$.expect.contracts[metadataTripleShape]", - "exactly the three entries ", - String.valueOf(metadata.keySet())); - } - NegotiationPerformative performative = performativeOf(testCase.api()); - Object nested = metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); - if (!(nested instanceof Map context) - || context.size() != 4 - || !context.containsKey("id") - || !context.containsKey("round") - || !context.containsKey("maxRounds") - || !performative.name().equals(context.get("performative"))) { - fail( - testCase, - "$.expect.contracts[metadataTripleShape]", - "the nested negotiation context with exactly ", - String.valueOf(nested)); - } - } - - // ------------------------------------------------------------------ differential (Q17 C+) - - private void runDifferential(CaseOutcome main) { - NegotiationCase testCase = main.testCase(); - if (testCase.api().family() != NegotiationApi.Family.FROM_TEXT) { - fail( - testCase, - "$.expect.differential", - "a from-text family API", - testCase.api().jsonName()); - } - if (testCase.inputData() == null) { - fail(testCase, "$.expect.differential", "input.data alongside input.text", "no input.data"); - } - if (testCase.expect().promptTextEqualsGolden() == null) { - fail( - testCase, - "$.expect.differential", - "promptTextEqualsGolden as the third comparison leg", - "no golden name"); - } - MetadataContent fromText = requireMessage(main, "$.expect.differential"); - ScriptedNegotiationLlmClient zeroCallClient = ScriptedNegotiationLlmClient.assertionOnly(); - NegotiationContentService service = new NegotiationContentService(orchestratorFor(testCase, zeroCallClient)); - MetadataContent fromData; - try { - fromData = generateFromData(service, testCase); - } catch (RuntimeException | AssertionError error) { - fail(testCase, "$.expect.differential", "a successful from-data message", "failure (" + error + ")"); - return; - } - if (!Objects.equals(fromText.promptText(), fromData.promptText())) { - fail( - testCase, - "$.expect.differential", - "fromText == fromData (" + quoted(truncate(fromText.promptText())) + ")", - quoted(truncate(fromData.promptText()))); - } - String golden = readGoldenFixture(testCase, testCase.expect().promptTextEqualsGolden()); - if (!normalize(fromData.promptText()).equals(golden)) { - fail( - testCase, - "$.expect.differential", - "fromData == golden " + quoted(truncate(golden)), - quoted(truncate(normalize(fromData.promptText())))); - } - if (zeroCallClient.callCount() != 0) { - fail( - testCase, - "$.expect.differential", - "0 LLM calls on the from-data leg", - String.valueOf(zeroCallClient.callCount())); - } - } - - private MetadataContent generateFromData(NegotiationContentService service, NegotiationCase testCase) { - TemplateUri templateUri = parseTemplateUri(testCase); - NegotiationContext context = toContext(testCase.context(), testCase.api()); - JsonNode data = testCase.inputData(); - return switch (testCase.api()) { - case GENERATE_PROPOSE_FROM_TEXT -> service.generateProposeFromData( - (NegotiationProposeData) TypedInputAssembler.assemble( - data, context, templateUri, NegotiationPerformative.PROPOSE, testCase.language()), - templateUri); - case GENERATE_ACCEPT_FROM_TEXT -> service.generateAcceptFromData( - (NegotiationEndingData) TypedInputAssembler.assemble( - data, context, templateUri, NegotiationPerformative.ACCEPT, testCase.language()), - templateUri); - case GENERATE_REJECT_FROM_TEXT -> service.generateRejectFromData( - (NegotiationEndingData) TypedInputAssembler.assemble( - data, context, templateUri, NegotiationPerformative.REJECT, testCase.language()), - templateUri); - case GENERATE_ABORT_FROM_TEXT -> service.generateAbortFromData( - (NegotiationAbortData) TypedInputAssembler.assemble( - data, context, templateUri, NegotiationPerformative.ABORT, testCase.language()), - templateUri); - default -> throw new IllegalStateException( - "The differential run pairs a from-text API with its from-data twin but got " - + testCase.api().jsonName() + "."); - }; - } - - // ------------------------------------------------------------------ production wiring - - private static ScriptedNegotiationLlmClient llmClientFor(NegotiationCase testCase) { - if (testCase.api().family() == NegotiationApi.Family.FROM_DATA || testCase.llm() == null) { - return ScriptedNegotiationLlmClient.assertionOnly(); - } - return new ScriptedNegotiationLlmClient(testCase.llm().steps()); - } - - private static NegotiationGenerationOrchestrator orchestratorFor( - NegotiationCase testCase, ScriptedNegotiationLlmClient llmClient) { - NegotiationGenerationOrchestratorBuilder builder = NegotiationGenerationOrchestratorBuilder.builder() - .language(testCase.language()) - .llmClient(llmClient); - if (testCase.llm() != null && testCase.llm().maxAttempts() != null) { - builder.maxAttempts(testCase.llm().maxAttempts()); - } - if (INJECT_FAILING_TEMPLATE_LOADER.equals(testCase.inject())) { - builder.templateLoader(failingTemplateLoader()); - } - if (INJECT_FAILING_SEMANTIC_VALIDATOR.equals(testCase.inject())) { - builder.semanticValidator(failingSemanticValidator()); - } - return builder.build(); - } - - /** - * Assembles the task API wiring of the closed loop for one case through the real facade builders: the retry limit - * comes from the case's LLM script or falls back to the builder default of 3. - * - * @param testCase expanded corpus case of the task family - * @param llmClient scripted LLM client injected at the facade builders' LLM seam - * @return the task API assembly of the case's language - */ - private static TaskApiAssembler taskApiFor(NegotiationCase testCase, ScriptedNegotiationLlmClient llmClient) { - int maxAttempts = testCase.llm() != null && testCase.llm().maxAttempts() != null - ? testCase.llm().maxAttempts() - : 3; - return new TaskApiAssembler(testCase.language(), maxAttempts, llmClient); - } - - private static NegotiationTemplateLoader failingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - }; - } - - /** - * Replays the semantic validator of a language without bundled semantic validation prompt resources: the pipeline - * maps the resource failure onto the public {@code template.not_found} code without bubbling the raw exception (the - * validate-family leg of the template-resolution matrix; mirrors the hand-written pipeline suite). - * - * @return semantic validator failing every call with a resource-not-found exception - */ - private static NegotiationSemanticValidator failingSemanticValidator() { - return (prompt, callerSchema, reference, templateContent) -> { - throw new ResourceNotFoundException( - "Negotiation semantic validation prompt resource does not exist for language " - + reference.language() - + "; set A2AT_LANGUAGE to a language with bundled prompt resources (zh-CN or en-US).", - "prompt_resources/prompts/negotiation_semantic_validation/" + reference.language() + "/system.md"); - }; - } - - // ------------------------------------------------------------------ API dispatch - - private Object invoke( - NegotiationContentService service, - @Nullable TaskApiAssembler taskApi, - NegotiationCase testCase, - @Nullable String promptOverride) { - TemplateUri templateUri = parseTemplateUri(testCase); - NegotiationContext context = toContext(testCase.context(), testCase.api()); - return switch (testCase.api()) { - case GENERATE_PROPOSE_FROM_TEXT -> service.generateProposeFromText( - testCase.inputText(), context, templateUri); - case GENERATE_ACCEPT_FROM_TEXT -> service.generateAcceptFromText( - testCase.inputText(), context, templateUri); - case GENERATE_REJECT_FROM_TEXT -> service.generateRejectFromText( - testCase.inputText(), context, templateUri); - case GENERATE_ABORT_FROM_TEXT -> service.generateAbortFromText(testCase.inputText(), context, templateUri); - case GENERATE_PROPOSE_FROM_DATA -> service.generateProposeFromData( - (NegotiationProposeData) TypedInputAssembler.assemble( - requireInputData(testCase), - context, - templateUri, - NegotiationPerformative.PROPOSE, - testCase.language()), - templateUri); - case GENERATE_ACCEPT_FROM_DATA -> service.generateAcceptFromData( - (NegotiationEndingData) TypedInputAssembler.assemble( - requireInputData(testCase), - context, - templateUri, - NegotiationPerformative.ACCEPT, - testCase.language()), - templateUri); - case GENERATE_REJECT_FROM_DATA -> service.generateRejectFromData( - (NegotiationEndingData) TypedInputAssembler.assemble( - requireInputData(testCase), - context, - templateUri, - NegotiationPerformative.REJECT, - testCase.language()), - templateUri); - case GENERATE_ABORT_FROM_DATA -> service.generateAbortFromData( - (NegotiationAbortData) TypedInputAssembler.assemble( - requireInputData(testCase), - context, - templateUri, - NegotiationPerformative.ABORT, - testCase.language()), - templateUri); - case VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING -> service.validateProposePromptAndDataFilling( - promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); - case VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING -> service.validateAcceptPromptAndDataFilling( - promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); - case VALIDATE_REJECT_PROMPT_AND_DATA_FILLING -> service.validateRejectPromptAndDataFilling( - promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); - case VALIDATE_ABORT_PROMPT_AND_DATA_FILLING -> service.validateAbortPromptAndDataFilling( - promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); - case GENERATE_TASK_PROMPT_FROM_TEXT -> requireTaskApi(taskApi, testCase) - .generateTaskPromptFromText(requireInputText(testCase), templateUri); - case GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA -> requireTaskApi(taskApi, testCase) - .generateTaskPromptFromDataWithSchema(dataOf(testCase), requireSchema(testCase), templateUri); - case VALIDATE_TASK_PROMPT_AND_DATA_FILLING -> requireTaskApi(taskApi, testCase) - .validateTaskPromptAndDataFilling( - promptOf(testCase, promptOverride), requireSchema(testCase), templateUri); - }; - } - - private static TaskApiAssembler requireTaskApi(@Nullable TaskApiAssembler taskApi, NegotiationCase testCase) { - if (taskApi == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " the task API assembly is only wired for the task family but got " - + testCase.api().jsonName()); - } - return taskApi; - } - - private static String requireInputText(NegotiationCase testCase) { - String text = testCase.inputText(); - if (text == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " input.text: the task from-text API requires a text input"); - } - return text; - } - - private static Map requireSchema(NegotiationCase testCase) { - Map schema = schemaOf(testCase); - if (schema == null) { - throw new IllegalStateException(testCase.errorPrefix() + " schema: the task APIs require a schema"); - } - return schema; - } - - private static Map dataOf(NegotiationCase testCase) { - JsonNode data = testCase.inputData(); - if (data == null || !data.isObject()) { - throw new IllegalStateException( - testCase.errorPrefix() + " input.data: the task from-data API requires typed input data"); - } - return MAPPER.convertValue(data, new TypeReference>() {}); - } - - private static TemplateUri parseTemplateUri(NegotiationCase testCase) { - String raw = testCase.templateUri(); - if (raw == null) { - return null; - } - return TemplateUri.parse(raw) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); - } - - private static JsonNode requireInputData(NegotiationCase testCase) { - JsonNode data = testCase.inputData(); - if (data == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " input.data: the from-data family requires typed input data"); - } - return data; - } - - private static String promptOf(NegotiationCase testCase, @Nullable String promptOverride) { - if (promptOverride != null) { - return promptOverride; - } - PromptSource source = testCase.prompt(); - if (source == null) { - return null; - } - if (source instanceof PromptSource.Text text) { - return text.text(); - } - if (source instanceof PromptSource.Golden golden) { - return readGoldenFixture(testCase, golden.golden()); - } - PromptSource.FromStep fromStep = (PromptSource.FromStep) source; - throw new IllegalStateException(testCase.errorPrefix() + " prompt.fromStep " + fromStep.step() - + " is resolved by the ScenarioEngine; run the enclosing scenario through the ScenarioEngine"); - } - - private static @Nullable Map schemaOf(NegotiationCase testCase) { - JsonNode schema = testCase.schema(); - if (schema == null) { - return null; - } - return MAPPER.convertValue(schema, new TypeReference>() {}); - } - - /** - * Builds the input context of a case, stamped with the performative of the case's API. - * - *

            The generation pipeline stamps the performative of the addressed template onto the emitted context, so an - * input context built with the API's performative echoes back unchanged; a validate API receives the message of - * exactly that performative, so its input context carries it as well. - * - * @param spec context spec of the case, or null - * @param api API of the case - * @return the negotiation context of the spec stamped with the API's performative, or null when the case carries no - * context spec or the task-family API takes no negotiation context - */ - private static @Nullable NegotiationContext toContext(@Nullable ContextSpec spec, NegotiationApi api) { - NegotiationPerformative performative = performativeOf(api); - if (spec == null || performative == null) { - return null; - } - return new NegotiationContext(spec.id(), spec.round(), spec.maxRounds(), performative); - } - - /** - * Returns the performative an API addresses: each generation API emits the message of one performative and each - * validate API checks the message of one performative, while the task family produces no negotiation message. - * - * @param api API of the case - * @return the performative of the generation and validate families, or null for the task family - */ - private static @Nullable NegotiationPerformative performativeOf(NegotiationApi api) { - return switch (api) { - case GENERATE_PROPOSE_FROM_TEXT, - GENERATE_PROPOSE_FROM_DATA, - VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.PROPOSE; - case GENERATE_ACCEPT_FROM_TEXT, - GENERATE_ACCEPT_FROM_DATA, - VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.ACCEPT; - case GENERATE_REJECT_FROM_TEXT, - GENERATE_REJECT_FROM_DATA, - VALIDATE_REJECT_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.REJECT; - case GENERATE_ABORT_FROM_TEXT, - GENERATE_ABORT_FROM_DATA, - VALIDATE_ABORT_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.ABORT; - default -> null; - }; - } - - /** - * Rewrites the pre-rename wording of the frozen corpus fixtures: the case JSON files still say {@code expected - * phase}, while the orchestrator's reference-resolution error has said {@code expected performative} since the - * {@code phase} to {@code performative} rename. The corpus case JSON files are frozen by policy, so the harness - * translates the legacy fragment instead of the fixtures. - * - * @param fragment message fragment declared by a frozen case fixture - * @return the fragment with the legacy wording translated to the current one - */ - private static String performativeWording(String fragment) { - return fragment.replace("expected phase", "expected performative"); - } - - // ------------------------------------------------------------------ helpers - - private static MetadataContent requireMessage(CaseOutcome outcome, String jsonPath) { - NegotiationCase testCase = outcome.testCase(); - MetadataContent message = outcome.message(); - if (message == null) { - fail( - testCase, - jsonPath, - "a generated negotiation message", - "failure (" + outcome.failure() + ") or " + typeName(outcome.value())); - } - return message; - } - - private static String readGoldenFixture(NegotiationCase testCase, String goldenName) { - String resourcePath = "/golden/" + testCase.language() + "/" + goldenName + ".md"; - InputStream stream = CaseEngine.class.getResourceAsStream(resourcePath); - if (stream == null) { - throw new AssertionError(testCase.errorPrefix() + " golden fixture '" + goldenName - + "' does not exist on the test" + " classpath: " + resourcePath); - } - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } catch (IOException exception) { - throw new AssertionError( - testCase.errorPrefix() + " failed to read the golden fixture " + resourcePath, exception); - } - } - - private static String normalize(@Nullable String text) { - return text == null ? null : text.replace("\r\n", "\n"); - } - - private static String typeName(@Nullable Object value) { - return value == null ? "null" : value.getClass().getSimpleName(); - } - - private static String render(Map values) { - try { - return MAPPER.writeValueAsString(values); - } catch (IOException exception) { - return String.valueOf(values); - } - } - - private static String renderSlotErrors(List slotErrors) { - List pairs = new ArrayList<>(); - for (Expectation.SlotError slotError : slotErrors) { - pairs.add(slotError.slot() + ":" + slotError.code()); - } - return String.join(", ", pairs); - } - - private static String knownContractNames() { - List names = new ArrayList<>(); - for (Contract contract : Contract.values()) { - names.add(contract.jsonName()); - } - return String.join(", ", names); - } - - private static String truncate(String text) { - return text.length() <= MAX_REPORTED_TEXT_LENGTH ? text : text.substring(0, MAX_REPORTED_TEXT_LENGTH) + "..."; - } - - private static String quoted(@Nullable String text) { - return text == null ? "null" : "<" + text + ">"; - } - - private static AssertionError fail(NegotiationCase testCase, String jsonPath, String expected, String actual) { - throw new AssertionError( - testCase.errorPrefix() + " " + jsonPath + ": expected " + expected + " but was " + actual); - } -} +package net.openan.a2at.sdk.corpus; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; +import org.jspecify.annotations.Nullable; + +/** + * Executes one expanded corpus case against the production negotiation content wiring and asserts its expectation + * block. + * + *

            Everything except the LLM client is production assembly: the case engine builds a real + * {@link NegotiationGenerationOrchestrator} through {@link NegotiationGenerationOrchestratorBuilder} for the language + * of the case, wires the {@code inject} hooks onto their real builder injection points ({@code failingTemplateLoader} + * for the generation template-not-found matrix, {@code failingSemanticValidator} for the validate-family + * prompt-resource-not-found mapping) and dispatches on the {@link NegotiationApi} enum at compile time, so a misspelled + * API name fails at corpus load time and a renamed service method fails this compilation. The three task-family APIs + * run through the {@link TaskApiAssembler}, the real facade builders' assembly of the closed loop (Q21) with the + * scripted LLM client injected at the builders' LLM seam. + * + *

            Result normalization: a success run returns the produced {@link MetadataContent} or {@link FilledParamData}, a + * failure run captures the thrown exception. The expectation comparison covers outcome, exception name, error code, + * message fragments, slot errors (slot plus code pairs, exact but order-insensitive), the exact LLM call count, the + * golden fixture text (per language, byte equality after CRLF normalization), the metadata echoes, the expected merged + * parameter map, the P0 behavior contracts and the Q17 C+ differential double run (fromText == fromData == golden, + * from-data side proven zero-call with an assertion-only client). Every mismatch fails with the case id, the JSON path + * of the expectation and the expected-versus-actual pair. + * + * @since 2026-08 + */ +public final class CaseEngine { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String INJECT_FAILING_TEMPLATE_LOADER = "failingTemplateLoader"; + + private static final String INJECT_FAILING_SEMANTIC_VALIDATOR = "failingSemanticValidator"; + + private static final int MAX_REPORTED_TEXT_LENGTH = 400; + + /** + * Runs one case without a prompt override. + * + * @param testCase expanded corpus case + * @return the normalized outcome of the run + * @throws AssertionError when any expectation mismatches + */ + public CaseOutcome run(NegotiationCase testCase) { + return run(testCase, null); + } + + /** + * Runs one case, optionally overriding the prompt input of the validate family. + * + *

            The {@link ScenarioEngine} resolves {@code prompt.fromStep} references into prompt texts and hands them in + * through this parameter; a standalone validate case resolves its golden or inline prompt itself. + * + * @param testCase expanded corpus case + * @param promptOverride prompt text resolved from an earlier scenario step, or null + * @return the normalized outcome of the run + * @throws AssertionError when any expectation mismatches + */ + public CaseOutcome run(NegotiationCase testCase, @Nullable String promptOverride) { + return run(testCase, promptOverride, false); + } + + /** + * Runs one case, optionally overriding the prompt input of the validate family and optionally running as a scenario + * step. + * + *

            The {@link ScenarioEngine} resolves {@code prompt.fromStep} references into prompt texts and hands them in + * through the prompt parameter; a standalone validate case resolves its golden or inline prompt itself. Scenario + * mode defers the {@code expect.paramsFromStep} check to the scenario engine, which resolves the cross-step + * reference after the step ran. + * + * @param testCase expanded corpus case + * @param promptOverride prompt text resolved from an earlier scenario step, or null + * @param inScenario true inside the ScenarioEngine, false for a standalone run + * @return the normalized outcome of the run + * @throws AssertionError when any expectation mismatches + */ + CaseOutcome run(NegotiationCase testCase, @Nullable String promptOverride, boolean inScenario) { + ScriptedNegotiationLlmClient llmClient = llmClientFor(testCase); + NegotiationContentService service = new NegotiationContentService(orchestratorFor(testCase, llmClient)); + TaskApiAssembler taskApi = + testCase.api().family() == NegotiationApi.Family.TASK ? taskApiFor(testCase, llmClient) : null; + Object value = null; + Throwable failure = null; + try { + value = invoke(service, taskApi, testCase, promptOverride); + } catch (RuntimeException | AssertionError error) { + failure = error; + } + CaseOutcome outcome = new CaseOutcome(testCase, value, failure, llmClient.callCount(), llmClient); + assertExpectations(outcome, inScenario); + return outcome; + } + + // ------------------------------------------------------------------ outcome record + + /** + * The normalized outcome of one executed case. + * + * @param testCase the executed expanded corpus case + * @param value the returned {@code MetadataContent} or {@code FilledParamData} on success, or null on failure + * @param failure the captured exception on failure, or null on success + * @param llmCalls exact number of LLM calls the run made + * @param llmClient the scripted client the run used + */ + public record CaseOutcome( + NegotiationCase testCase, + @Nullable Object value, + @Nullable Throwable failure, + int llmCalls, + ScriptedNegotiationLlmClient llmClient) { + + /** + * Returns the generated message of a successful generation run. + * + * @return the metadata content, or null when the run failed or produced filled parameters + */ + public @Nullable MetadataContent message() { + return value instanceof MetadataContent metadata ? metadata : null; + } + + /** + * Returns the filled parameters of a successful validation run. + * + * @return the filled parameter data, or null when the run failed or produced a message + */ + public @Nullable FilledParamData filledParams() { + return value instanceof FilledParamData filled ? filled : null; + } + } + + // ------------------------------------------------------------------ expectation comparison + + private void assertExpectations(CaseOutcome outcome, boolean inScenario) { + NegotiationCase testCase = outcome.testCase(); + Expectation expect = testCase.expect(); + if (expect.success()) { + if (outcome.failure() != null) { + fail(testCase, "$.expect.outcome", "success", "failure (" + outcome.failure() + ")"); + } + assertSuccessFields(outcome, inScenario); + } else { + if (outcome.failure() == null) { + fail( + testCase, + "$.expect.outcome", + "failure", + "success (" + typeName(outcome.value()) + ": " + outcome.value() + ")"); + } + assertFailureFields(outcome); + } + if (expect.llmCalls() != null && expect.llmCalls() != outcome.llmCalls()) { + fail(testCase, "$.expect.llmCalls", String.valueOf(expect.llmCalls()), String.valueOf(outcome.llmCalls())); + } + for (String contractName : expect.contracts()) { + assertContract(outcome, contractName); + } + if (expect.differential()) { + runDifferential(outcome); + } + } + + private void assertFailureFields(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + Expectation expect = testCase.expect(); + Throwable failure = outcome.failure(); + if (expect.exception() != null) { + String actual = failure.getClass().getSimpleName(); + if (!expect.exception().equals(actual)) { + fail(testCase, "$.expect.exception", expect.exception(), actual); + } + } + if (expect.code() != null) { + if (!(failure instanceof A2ATError error)) { + throw fail( + testCase, + "$.expect.code", + expect.code(), + "no error code (exception " + failure.getClass().getSimpleName() + ")"); + } + if (!expect.code().equals(error.getCode())) { + fail(testCase, "$.expect.code", expect.code(), error.getCode()); + } + } + for (String fragment : expect.messageContains()) { + String message = failure.getMessage(); + if (message == null || !(message.contains(fragment) || message.contains(performativeWording(fragment)))) { + fail( + testCase, + "$.expect.messageContains", + "a message containing '" + fragment + "'", + "message " + quoted(message)); + } + } + if (!expect.slotErrors().isEmpty()) { + List slotErrors = slotErrorsOf(failure); + if (slotErrors == null) { + throw fail( + testCase, + "$.expect.slotErrors", + renderSlotErrors(expect.slotErrors()), + "exception " + failure.getClass().getSimpleName() + " carries no slot errors"); + } + assertSlotErrors(testCase, expect.slotErrors(), slotErrors); + } + } + + /** + * Returns the slot errors the failure carries, or null when the failure type carries none. + * + *

            The negotiation content layer surfaces its failures as {@link NegotiationParamExtractionException} (an + * {@code A2ATBusinessException} subtype since the ErrorCatalog migration) while the prompt families still use + * {@link A2ATParamExtractionError}; the engine accepts both carriers. + * + * @param failure captured failure of the run + * @return the slot validation errors of the failure, or null when the failure carries none + */ + private static @Nullable List slotErrorsOf(Throwable failure) { + if (failure instanceof A2ATParamExtractionError extraction) { + return extraction.getErrors(); + } + if (failure instanceof NegotiationParamExtractionException negotiation) { + return negotiation.getErrors(); + } + return null; + } + + private void assertSlotErrors( + NegotiationCase testCase, List expected, List actual) { + Map expectedCounts = new LinkedHashMap<>(); + for (Expectation.SlotError slotError : expected) { + expectedCounts.merge(slotError.slot() + ":" + slotError.code(), 1, Integer::sum); + } + Map actualCounts = new LinkedHashMap<>(); + List actualPairs = new ArrayList<>(); + for (SlotValidationError error : actual) { + String pair = error.slotName() + ":" + error.code(); + actualCounts.merge(pair, 1, Integer::sum); + actualPairs.add(pair); + } + if (!expectedCounts.equals(actualCounts)) { + fail( + testCase, + "$.expect.slotErrors", + renderSlotErrors(expected), + String.join(", ", actualPairs.isEmpty() ? List.of("(none)") : actualPairs)); + } + } + + private void assertSuccessFields(CaseOutcome outcome, boolean inScenario) { + NegotiationCase testCase = outcome.testCase(); + Expectation expect = testCase.expect(); + if (expect.promptTextEqualsGolden() != null) { + MetadataContent message = requireMessage(outcome, "$.expect.promptTextEqualsGolden"); + String golden = readGoldenFixture(testCase, expect.promptTextEqualsGolden()); + if (!normalize(message.promptText()).equals(golden)) { + fail( + testCase, + "$.expect.promptTextEqualsGolden", + quoted(truncate(golden)), + quoted(truncate(normalize(message.promptText())))); + } + } + if (expect.metadata() != null) { + MetadataContent message = requireMessage(outcome, "$.expect.metadata"); + if (expect.metadata().templateUriEcho() != null + && !expect.metadata().templateUriEcho().equals(message.templateUri())) { + fail( + testCase, + "$.expect.metadata.templateUriEcho", + expect.metadata().templateUriEcho(), + message.templateUri()); + } + if (Boolean.TRUE.equals(expect.metadata().contextEcho())) { + // The input context is built with the performative of the case's API, so it equals the emitted context + // the generation pipeline stamped with the performative of the addressed template. + NegotiationContext expectedContext = toContext(testCase.context(), testCase.api()); + if (!Objects.equals(expectedContext, message.negotiationContext())) { + fail( + testCase, + "$.expect.metadata.contextEcho", + String.valueOf(expectedContext), + String.valueOf(message.negotiationContext())); + } + } + } + if (!expect.params().isEmpty() || expect.missingParams() != null) { + if (!(outcome.value() instanceof FilledParamData filled)) { + throw fail( + testCase, + expect.missingParams() != null && expect.params().isEmpty() + ? "$.expect.missingParams" + : "$.expect.params", + expect.missingParams() != null + ? "filled parameter data with the missing-parameter set " + expect.missingParams() + : render(expect.params()), + "no filled parameter data (" + typeName(outcome.value()) + ")"); + } + if (expect.missingParams() != null) { + // Task semantics (Q21): the expected params are a subset check with exact values, and the missing + // parameter set — the null-valued entries of the filled data — is asserted exactly. + for (Map.Entry entry : expect.params().entrySet()) { + if (!filled.data().containsKey(entry.getKey()) + || !Objects.equals(entry.getValue(), filled.data().get(entry.getKey()))) { + fail( + testCase, + "$.expect.params", + entry.getKey() + "=" + entry.getValue(), + entry.getKey() + "=" + filled.data().get(entry.getKey())); + } + } + List actualMissing = filled.data().entrySet().stream() + .filter(entry -> entry.getValue() == null) + .map(Map.Entry::getKey) + .toList(); + Set expectedMissing = new LinkedHashSet<>(expect.missingParams()); + if (!expectedMissing.equals(new LinkedHashSet<>(actualMissing))) { + fail( + testCase, + "$.expect.missingParams", + String.join(", ", expectedMissing), + actualMissing.isEmpty() ? "(none)" : String.join(", ", actualMissing)); + } + } else if (!Objects.equals(expect.params(), filled.data())) { + fail(testCase, "$.expect.params", render(expect.params()), render(filled.data())); + } + } + for (String fragment : expect.promptTextContains()) { + MetadataContent message = requireMessage(outcome, "$.expect.promptTextContains"); + String promptText = message.promptText() == null ? "" : message.promptText(); + if (!promptText.contains(fragment)) { + fail( + testCase, + "$.expect.promptTextContains", + "a prompt text containing '" + fragment + "'", + quoted(truncate(promptText))); + } + } + if (expect.paramsFromStep() != null && !inScenario) { + throw new IllegalStateException(testCase.errorPrefix() + " expect.paramsFromStep " + expect.paramsFromStep() + + " is resolved by the ScenarioEngine; run the enclosing scenario through the" + + " ScenarioEngine"); + } + } + + // ------------------------------------------------------------------ contracts (P0) + + private void assertContract(CaseOutcome outcome, String contractName) { + NegotiationCase testCase = outcome.testCase(); + Contract contract = Contract.fromJsonName(contractName); + if (contract == null) { + fail( + testCase, + "$.expect.contracts", + "a registered contract name (one of " + knownContractNames() + ")", + "'" + contractName + "'"); + } + if (!contract.isP0()) { + fail( + testCase, + "$.expect.contracts", + "a P0 contract the engine asserts today", + "'" + contractName + "' is registered as a P1 expectation and is not yet lit"); + } + switch (contract) { + case CONCLUSION_LITERAL_PRESENT -> assertConclusionLiteralPresent(outcome); + case CONTEXT_KEYS_IN_MERGED_PARAMS -> assertContextKeysInMergedParams(outcome); + case NO_LLM_LEAK_IN_USER_MESSAGE -> assertNoLlmLeakInUserMessage(outcome); + case METADATA_TRIPLE_SHAPE -> assertMetadataTripleShape(outcome); + default -> fail( + testCase, + "$.expect.contracts", + "an implemented contract", + "'" + contractName + "' is flagged P0 but has no implementation"); + } + } + + private void assertConclusionLiteralPresent(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + String literal = + switch (testCase.api()) { + case GENERATE_ACCEPT_FROM_TEXT, GENERATE_ACCEPT_FROM_DATA -> "Accept"; + case GENERATE_REJECT_FROM_TEXT, GENERATE_REJECT_FROM_DATA -> "Reject"; + case GENERATE_ABORT_FROM_TEXT, GENERATE_ABORT_FROM_DATA -> "Abort"; + default -> null; + }; + if (literal == null) { + fail( + testCase, + "$.expect.contracts[conclusionLiteralPresent]", + "a terminal or abort generation API", + testCase.api().jsonName()); + } + MetadataContent message = requireMessage(outcome, "$.expect.contracts[conclusionLiteralPresent]"); + if (!String.valueOf(message.promptText()).contains(literal)) { + fail( + testCase, + "$.expect.contracts[conclusionLiteralPresent]", + "a prompt text containing the literal '" + literal + "'", + quoted(truncate(message.promptText()))); + } + } + + private void assertContextKeysInMergedParams(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + if (!(outcome.value() instanceof FilledParamData filled)) { + throw fail( + testCase, + "$.expect.contracts[contextKeysInMergedParams]", + "filled parameter data carrying the context keys", + typeName(outcome.value())); + } + for (String key : List.of("id", "round", "maxRounds")) { + if (!filled.data().containsKey(key)) { + fail( + testCase, + "$.expect.contracts[contextKeysInMergedParams]", + "merged params containing '" + key + "'", + render(filled.data())); + } + } + } + + private void assertNoLlmLeakInUserMessage(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + List details = outcome.llmClient().leakedFailureDetails(); + if (details.isEmpty()) { + return; + } + String visible = outcome.failure() != null + ? outcome.failure().getMessage() + : outcome.message() != null ? outcome.message().promptText() : null; + if (visible == null) { + fail( + testCase, + "$.expect.contracts[noLlmLeakInUserMessage]", + "a user-visible message to inspect", + "neither a failure message nor a generated message was produced"); + } + for (String detail : details) { + if (visible.contains(detail)) { + fail( + testCase, + "$.expect.contracts[noLlmLeakInUserMessage]", + "a message free of the raw LLM failure detail '" + detail + "'", + quoted(truncate(visible))); + } + } + } + + private void assertMetadataTripleShape(CaseOutcome outcome) { + NegotiationCase testCase = outcome.testCase(); + MetadataContent message = requireMessage(outcome, "$.expect.contracts[metadataTripleShape]"); + Map metadata = message.buildMetadataContent(); + if (metadata.size() != 3 + || !metadata.containsKey(message.extensionUri()) + || !metadata.containsKey(MetadataContent.TEMPLATE_URI_METADATA_KEY) + || !metadata.containsKey(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY)) { + fail( + testCase, + "$.expect.contracts[metadataTripleShape]", + "exactly the three entries ", + String.valueOf(metadata.keySet())); + } + NegotiationPerformative performative = performativeOf(testCase.api()); + Object nested = metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + if (!(nested instanceof Map context) + || context.size() != 4 + || !context.containsKey("id") + || !context.containsKey("round") + || !context.containsKey("maxRounds") + || !performative.name().equals(context.get("performative"))) { + fail( + testCase, + "$.expect.contracts[metadataTripleShape]", + "the nested negotiation context with exactly ", + String.valueOf(nested)); + } + } + + // ------------------------------------------------------------------ differential (Q17 C+) + + private void runDifferential(CaseOutcome main) { + NegotiationCase testCase = main.testCase(); + if (testCase.api().family() != NegotiationApi.Family.FROM_TEXT) { + fail( + testCase, + "$.expect.differential", + "a from-text family API", + testCase.api().jsonName()); + } + if (testCase.inputData() == null) { + fail(testCase, "$.expect.differential", "input.data alongside input.text", "no input.data"); + } + if (testCase.expect().promptTextEqualsGolden() == null) { + fail( + testCase, + "$.expect.differential", + "promptTextEqualsGolden as the third comparison leg", + "no golden name"); + } + MetadataContent fromText = requireMessage(main, "$.expect.differential"); + ScriptedNegotiationLlmClient zeroCallClient = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = new NegotiationContentService(orchestratorFor(testCase, zeroCallClient)); + MetadataContent fromData; + try { + fromData = generateFromData(service, testCase); + } catch (RuntimeException | AssertionError error) { + fail(testCase, "$.expect.differential", "a successful from-data message", "failure (" + error + ")"); + return; + } + if (!Objects.equals(fromText.promptText(), fromData.promptText())) { + fail( + testCase, + "$.expect.differential", + "fromText == fromData (" + quoted(truncate(fromText.promptText())) + ")", + quoted(truncate(fromData.promptText()))); + } + String golden = readGoldenFixture(testCase, testCase.expect().promptTextEqualsGolden()); + if (!normalize(fromData.promptText()).equals(golden)) { + fail( + testCase, + "$.expect.differential", + "fromData == golden " + quoted(truncate(golden)), + quoted(truncate(normalize(fromData.promptText())))); + } + if (zeroCallClient.callCount() != 0) { + fail( + testCase, + "$.expect.differential", + "0 LLM calls on the from-data leg", + String.valueOf(zeroCallClient.callCount())); + } + } + + private MetadataContent generateFromData(NegotiationContentService service, NegotiationCase testCase) { + TemplateUri templateUri = parseTemplateUri(testCase); + NegotiationContext context = toContext(testCase.context(), testCase.api()); + JsonNode data = testCase.inputData(); + return switch (testCase.api()) { + case GENERATE_PROPOSE_FROM_TEXT -> service.generateProposeFromData( + (NegotiationProposeData) TypedInputAssembler.assemble( + data, context, templateUri, NegotiationPerformative.PROPOSE, testCase.language()), + templateUri); + case GENERATE_ACCEPT_FROM_TEXT -> service.generateAcceptFromData( + (NegotiationEndingData) TypedInputAssembler.assemble( + data, context, templateUri, NegotiationPerformative.ACCEPT, testCase.language()), + templateUri); + case GENERATE_REJECT_FROM_TEXT -> service.generateRejectFromData( + (NegotiationEndingData) TypedInputAssembler.assemble( + data, context, templateUri, NegotiationPerformative.REJECT, testCase.language()), + templateUri); + case GENERATE_ABORT_FROM_TEXT -> service.generateAbortFromData( + (NegotiationAbortData) TypedInputAssembler.assemble( + data, context, templateUri, NegotiationPerformative.ABORT, testCase.language()), + templateUri); + default -> throw new IllegalStateException( + "The differential run pairs a from-text API with its from-data twin but got " + + testCase.api().jsonName() + "."); + }; + } + + // ------------------------------------------------------------------ production wiring + + private static ScriptedNegotiationLlmClient llmClientFor(NegotiationCase testCase) { + if (testCase.api().family() == NegotiationApi.Family.FROM_DATA || testCase.llm() == null) { + return ScriptedNegotiationLlmClient.assertionOnly(); + } + return new ScriptedNegotiationLlmClient(testCase.llm().steps()); + } + + private static NegotiationGenerationOrchestrator orchestratorFor( + NegotiationCase testCase, ScriptedNegotiationLlmClient llmClient) { + NegotiationGenerationOrchestratorBuilder builder = NegotiationGenerationOrchestratorBuilder.builder() + .language(testCase.language()) + .llmClient(llmClient); + if (testCase.llm() != null && testCase.llm().maxAttempts() != null) { + builder.maxAttempts(testCase.llm().maxAttempts()); + } + if (INJECT_FAILING_TEMPLATE_LOADER.equals(testCase.inject())) { + builder.templateLoader(failingTemplateLoader()); + } + if (INJECT_FAILING_SEMANTIC_VALIDATOR.equals(testCase.inject())) { + builder.semanticValidator(failingSemanticValidator()); + } + return builder.build(); + } + + /** + * Assembles the task API wiring of the closed loop for one case through the real facade builders: the retry limit + * comes from the case's LLM script or falls back to the builder default of 3. + * + * @param testCase expanded corpus case of the task family + * @param llmClient scripted LLM client injected at the facade builders' LLM seam + * @return the task API assembly of the case's language + */ + private static TaskApiAssembler taskApiFor(NegotiationCase testCase, ScriptedNegotiationLlmClient llmClient) { + int maxAttempts = testCase.llm() != null && testCase.llm().maxAttempts() != null + ? testCase.llm().maxAttempts() + : 3; + return new TaskApiAssembler(testCase.language(), maxAttempts, llmClient); + } + + private static NegotiationTemplateLoader failingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + }; + } + + /** + * Replays the semantic validator of a language without bundled semantic validation prompt resources: the pipeline + * maps the resource failure onto the public {@code template.not_found} code without bubbling the raw exception (the + * validate-family leg of the template-resolution matrix; mirrors the hand-written pipeline suite). + * + * @return semantic validator failing every call with a resource-not-found exception + */ + private static NegotiationSemanticValidator failingSemanticValidator() { + return (prompt, callerSchema, reference, templateContent) -> { + throw new ResourceNotFoundException( + "Negotiation semantic validation prompt resource does not exist for language " + + reference.language() + + "; set A2AT_LANGUAGE to a language with bundled prompt resources (zh-CN or en-US).", + "prompt_resources/prompts/negotiation_semantic_validation/" + reference.language() + "/system.md"); + }; + } + + // ------------------------------------------------------------------ API dispatch + + private Object invoke( + NegotiationContentService service, + @Nullable TaskApiAssembler taskApi, + NegotiationCase testCase, + @Nullable String promptOverride) { + TemplateUri templateUri = parseTemplateUri(testCase); + NegotiationContext context = toContext(testCase.context(), testCase.api()); + return switch (testCase.api()) { + case GENERATE_PROPOSE_FROM_TEXT -> service.generateProposeFromText( + testCase.inputText(), context, templateUri); + case GENERATE_ACCEPT_FROM_TEXT -> service.generateAcceptFromText( + testCase.inputText(), context, templateUri); + case GENERATE_REJECT_FROM_TEXT -> service.generateRejectFromText( + testCase.inputText(), context, templateUri); + case GENERATE_ABORT_FROM_TEXT -> service.generateAbortFromText(testCase.inputText(), context, templateUri); + case GENERATE_PROPOSE_FROM_DATA -> service.generateProposeFromData( + (NegotiationProposeData) TypedInputAssembler.assemble( + requireInputData(testCase), + context, + templateUri, + NegotiationPerformative.PROPOSE, + testCase.language()), + templateUri); + case GENERATE_ACCEPT_FROM_DATA -> service.generateAcceptFromData( + (NegotiationEndingData) TypedInputAssembler.assemble( + requireInputData(testCase), + context, + templateUri, + NegotiationPerformative.ACCEPT, + testCase.language()), + templateUri); + case GENERATE_REJECT_FROM_DATA -> service.generateRejectFromData( + (NegotiationEndingData) TypedInputAssembler.assemble( + requireInputData(testCase), + context, + templateUri, + NegotiationPerformative.REJECT, + testCase.language()), + templateUri); + case GENERATE_ABORT_FROM_DATA -> service.generateAbortFromData( + (NegotiationAbortData) TypedInputAssembler.assemble( + requireInputData(testCase), + context, + templateUri, + NegotiationPerformative.ABORT, + testCase.language()), + templateUri); + case VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING -> service.validateProposePromptAndDataFilling( + promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); + case VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING -> service.validateAcceptPromptAndDataFilling( + promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); + case VALIDATE_REJECT_PROMPT_AND_DATA_FILLING -> service.validateRejectPromptAndDataFilling( + promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); + case VALIDATE_ABORT_PROMPT_AND_DATA_FILLING -> service.validateAbortPromptAndDataFilling( + promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); + case GENERATE_TASK_PROMPT_FROM_TEXT -> requireTaskApi(taskApi, testCase) + .generateTaskPromptFromText(requireInputText(testCase), templateUri); + case GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA -> requireTaskApi(taskApi, testCase) + .generateTaskPromptFromDataWithSchema(dataOf(testCase), requireSchema(testCase), templateUri); + case VALIDATE_TASK_PROMPT_AND_DATA_FILLING -> requireTaskApi(taskApi, testCase) + .validateTaskPromptAndDataFilling( + promptOf(testCase, promptOverride), requireSchema(testCase), templateUri); + }; + } + + private static TaskApiAssembler requireTaskApi(@Nullable TaskApiAssembler taskApi, NegotiationCase testCase) { + if (taskApi == null) { + throw new IllegalStateException( + testCase.errorPrefix() + " the task API assembly is only wired for the task family but got " + + testCase.api().jsonName()); + } + return taskApi; + } + + private static String requireInputText(NegotiationCase testCase) { + String text = testCase.inputText(); + if (text == null) { + throw new IllegalStateException( + testCase.errorPrefix() + " input.text: the task from-text API requires a text input"); + } + return text; + } + + private static Map requireSchema(NegotiationCase testCase) { + Map schema = schemaOf(testCase); + if (schema == null) { + throw new IllegalStateException(testCase.errorPrefix() + " schema: the task APIs require a schema"); + } + return schema; + } + + private static Map dataOf(NegotiationCase testCase) { + JsonNode data = testCase.inputData(); + if (data == null || !data.isObject()) { + throw new IllegalStateException( + testCase.errorPrefix() + " input.data: the task from-data API requires typed input data"); + } + return MAPPER.convertValue(data, new TypeReference>() {}); + } + + private static TemplateUri parseTemplateUri(NegotiationCase testCase) { + String raw = testCase.templateUri(); + if (raw == null) { + return null; + } + return TemplateUri.parse(raw) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); + } + + private static JsonNode requireInputData(NegotiationCase testCase) { + JsonNode data = testCase.inputData(); + if (data == null) { + throw new IllegalStateException( + testCase.errorPrefix() + " input.data: the from-data family requires typed input data"); + } + return data; + } + + private static String promptOf(NegotiationCase testCase, @Nullable String promptOverride) { + if (promptOverride != null) { + return promptOverride; + } + PromptSource source = testCase.prompt(); + if (source == null) { + return null; + } + if (source instanceof PromptSource.Text text) { + return text.text(); + } + if (source instanceof PromptSource.Golden golden) { + return readGoldenFixture(testCase, golden.golden()); + } + PromptSource.FromStep fromStep = (PromptSource.FromStep) source; + throw new IllegalStateException(testCase.errorPrefix() + " prompt.fromStep " + fromStep.step() + + " is resolved by the ScenarioEngine; run the enclosing scenario through the ScenarioEngine"); + } + + private static @Nullable Map schemaOf(NegotiationCase testCase) { + JsonNode schema = testCase.schema(); + if (schema == null) { + return null; + } + return MAPPER.convertValue(schema, new TypeReference>() {}); + } + + /** + * Builds the input context of a case, stamped with the performative of the case's API. + * + *

            The generation pipeline stamps the performative of the addressed template onto the emitted context, so an + * input context built with the API's performative echoes back unchanged; a validate API receives the message of + * exactly that performative, so its input context carries it as well. + * + * @param spec context spec of the case, or null + * @param api API of the case + * @return the negotiation context of the spec stamped with the API's performative, or null when the case carries no + * context spec or the task-family API takes no negotiation context + */ + private static @Nullable NegotiationContext toContext(@Nullable ContextSpec spec, NegotiationApi api) { + NegotiationPerformative performative = performativeOf(api); + if (spec == null || performative == null) { + return null; + } + return new NegotiationContext(spec.id(), spec.round(), spec.maxRounds(), performative); + } + + /** + * Returns the performative an API addresses: each generation API emits the message of one performative and each + * validate API checks the message of one performative, while the task family produces no negotiation message. + * + * @param api API of the case + * @return the performative of the generation and validate families, or null for the task family + */ + private static @Nullable NegotiationPerformative performativeOf(NegotiationApi api) { + return switch (api) { + case GENERATE_PROPOSE_FROM_TEXT, + GENERATE_PROPOSE_FROM_DATA, + VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.PROPOSE; + case GENERATE_ACCEPT_FROM_TEXT, + GENERATE_ACCEPT_FROM_DATA, + VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.ACCEPT; + case GENERATE_REJECT_FROM_TEXT, + GENERATE_REJECT_FROM_DATA, + VALIDATE_REJECT_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.REJECT; + case GENERATE_ABORT_FROM_TEXT, + GENERATE_ABORT_FROM_DATA, + VALIDATE_ABORT_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.ABORT; + default -> null; + }; + } + + /** + * Rewrites the pre-rename wording of the frozen corpus fixtures: the case JSON files still say {@code expected + * phase}, while the orchestrator's reference-resolution error has said {@code expected performative} since the + * {@code phase} to {@code performative} rename. The corpus case JSON files are frozen by policy, so the harness + * translates the legacy fragment instead of the fixtures. + * + * @param fragment message fragment declared by a frozen case fixture + * @return the fragment with the legacy wording translated to the current one + */ + private static String performativeWording(String fragment) { + return fragment.replace("expected phase", "expected performative"); + } + + // ------------------------------------------------------------------ helpers + + private static MetadataContent requireMessage(CaseOutcome outcome, String jsonPath) { + NegotiationCase testCase = outcome.testCase(); + MetadataContent message = outcome.message(); + if (message == null) { + fail( + testCase, + jsonPath, + "a generated negotiation message", + "failure (" + outcome.failure() + ") or " + typeName(outcome.value())); + } + return message; + } + + private static String readGoldenFixture(NegotiationCase testCase, String goldenName) { + String resourcePath = "/golden/" + testCase.language() + "/" + goldenName + ".md"; + InputStream stream = CaseEngine.class.getResourceAsStream(resourcePath); + if (stream == null) { + throw new AssertionError(testCase.errorPrefix() + " golden fixture '" + goldenName + + "' does not exist on the test" + " classpath: " + resourcePath); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); + } catch (IOException exception) { + throw new AssertionError( + testCase.errorPrefix() + " failed to read the golden fixture " + resourcePath, exception); + } + } + + private static String normalize(@Nullable String text) { + return text == null ? null : text.replace("\r\n", "\n"); + } + + private static String typeName(@Nullable Object value) { + return value == null ? "null" : value.getClass().getSimpleName(); + } + + private static String render(Map values) { + try { + return MAPPER.writeValueAsString(values); + } catch (IOException exception) { + return String.valueOf(values); + } + } + + private static String renderSlotErrors(List slotErrors) { + List pairs = new ArrayList<>(); + for (Expectation.SlotError slotError : slotErrors) { + pairs.add(slotError.slot() + ":" + slotError.code()); + } + return String.join(", ", pairs); + } + + private static String knownContractNames() { + List names = new ArrayList<>(); + for (Contract contract : Contract.values()) { + names.add(contract.jsonName()); + } + return String.join(", ", names); + } + + private static String truncate(String text) { + return text.length() <= MAX_REPORTED_TEXT_LENGTH ? text : text.substring(0, MAX_REPORTED_TEXT_LENGTH) + "..."; + } + + private static String quoted(@Nullable String text) { + return text == null ? "null" : "<" + text + ">"; + } + + private static AssertionError fail(NegotiationCase testCase, String jsonPath, String expected, String actual) { + throw new AssertionError( + testCase.errorPrefix() + " " + jsonPath + ": expected " + expected + " but was " + actual); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java index b2c618ff..154da7a6 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java @@ -1,870 +1,870 @@ -package net.openan.a2at.sdk.corpus; - -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.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.corpus.Expectation.Metadata; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * Direct unit tests of the {@link CaseEngine} against inline case objects: the four P0 contracts, the Q17 C+ - * differential double run, the inject hook, the fail-on-overconsumption calibration through {@code llmCalls}, and the - * red paths proving the engine is not a rubber stamp (every flipped expectation fails with the case id and the JSON - * path of the expectation). - * - *

            The engine runs against the real production wiring (builders, classpath resources, renderers, vocabulary, rule - * gate, semantic validator); the golden comparisons use the committed golden fixtures of the test resources. - */ -class CaseEngineTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String ZH_CN = "zh-CN"; - - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; - - private static final String INFORMATION_ACCEPT_REJECT_URI = - "Negotiation-T/information-negotiation/accept-reject/v1"; - - private static final String PRIVATE_LINE_COMPLAINT_URI = "Task-T/network-layer/private-line-complaint/v1"; - - /** - * The workbench raw complaint of the five-step closed loop (样例步骤1): deliberately lacking the access port name and - * the complaint category — the causal starting point of the information negotiation. - */ - private static final String COMPLAINT_TEXT = "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢," - + "柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; - - /** Slot-extraction payload of the closed loop's step 1: the task object stays empty (no port name in the text). */ - private static final String TASK_SLOTS_OBJECT_EMPTY = - "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" - + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** - * Slot-extraction payload of the from-text success path since the task_object slot became required upstream: the - * task object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays - * portless. - */ - private static final String TASK_SLOTS_OBJECT_PORTLESS = - "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" - + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** Slot-extraction payload of the filled variant: the port name and the complaint category are present. */ - private static final String TASK_SLOTS_FILLED = - "{\"slots\": {\"任务对象\": \"接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\", \"任务上下文\":" - + " \"投诉分类:专线质差;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:" - + "event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** - * Semantic-validation payload of the closed loop's step 2: the prompt is acceptable, but the access port name and - * the complaint category are missing — the two null-valued parameters the OMC then negotiates for. - */ - private static final String TASK_SEMANTIC_MISSING_PARAMS = - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," - + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; - - /** Semantic payload rejecting the task prompt (rejection-sample shape: key slots missing). */ - private static final String TASK_SEMANTIC_REJECTED = - "{\"semantic_verdict\":false,\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"content.param_missing\"," - + "\"facts\":{\"section_label\":\"接入端口名称\"}}],\"params\":{}}"; - - /** The task parameter schema of the closed loop (server-side keys, dictionary §10). */ - private static final String TASK_PARAM_SCHEMA = - "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" - + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" - + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; - - /** The rendered task prompt the OMC receives (样例步骤1, shortened): the negotiation-causing message. */ - private static final String TASK_PROMPT_MISSING_PARAMS = - """ - ## 任务类型(Task Type) - 传输专线业务投诉诊断 - - ## 任务对象(Task Object) - 接入端口名称: - - ## 任务上下文(Task Context) - 1. 投诉分类: - 2. 问题发生时间: "2026-05-11T08:21:46Z" - 3. OSS侧事件流水号:"event-id-20260511-09013" - 4. 投诉详情:"深圳访问广州的响应时延从平均12ms骤升至320ms" - """; - - /** Extraction payload that maps back to the typed content of the information accept golden fixture. */ - private static final String ACCEPT_PAYLOAD = - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" - + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; - - /** Extraction payload that maps back to the typed content of the information propose golden fixture. */ - private static final String PROPOSE_PAYLOAD = - "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}," - + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," - + "\"value\":null}],\"relationship\":\"OR\"}"; - - /** The same content as typed data, disagreeing in one item value for the differential red path. */ - private static final String PROPOSE_DATA_DISAGREEING = - "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P781-珠江新城-PTN7900-23-TPA1EG24-17\"}," - + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," - + "\"value\":null}],\"relationship\":\"OR\"}"; - - private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" - + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; - - private static final String SEMANTIC_REJECT_PAYLOAD = - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" - + "\"accessPort\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":" - + "\"接入端口名称\"}}],\"params\":{}}"; - - private final CaseEngine engine = new CaseEngine(); - - private final ObjectMapper mapper = new ObjectMapper(); - - private JsonNode schema; - - @BeforeEach - void readSchema() throws Exception { - schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); - } - - // ------------------------------------------------------------------ green paths - - @Test - void runsASuccessfulFromTextCaseAgainstTheGoldenFixture() { - NegotiationCase testCase = acceptFromTextCase(ok(1, "information_accept", metadata(), false), null); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - MetadataContent message = outcome.message(); - assertNotNull(message); - assertEquals(1, outcome.llmCalls()); - assertEquals(INFORMATION_ACCEPT_REJECT_URI, message.templateUri()); - assertEquals( - new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.ACCEPT), message.negotiationContext()); - } - - @Test - void runsFromDataCasesDeterministicallyWithoutAnyLlmCall() throws Exception { - NegotiationCase testCase = new NegotiationCase( - "FD-HAPPY-01/zh-CN", - "FD-HAPPY-01", - "from-data/happy.json", - NegotiationApi.GENERATE_ACCEPT_FROM_DATA, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - null, - mapper.readTree(ACCEPT_PAYLOAD), - null, - null, - null, - null, - ok(0, "information_accept", metadata(), false)); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - assertNotNull(outcome.message()); - assertEquals(0, outcome.llmCalls(), "the from-data run must not call the LLM (assertion-only client)"); - } - - @Test - void injectsTheFailingTemplateLoaderForTheTemplateNotFoundMatrix() { - NegotiationCase testCase = new NegotiationCase( - "FT-TPL-01/zh-CN", - "FT-TPL-01", - "from-text/template-resolution.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(ACCEPT_PAYLOAD))), - null, - null, - "failingTemplateLoader", - failed("NegotiationGenerationException", "template.not_found", 0, List.of(), List.of())); - - engine.run(testCase); - } - - @Test - void injectsTheFailingSemanticValidatorForTheValidateTemplateNotFoundMapping() { - NegotiationCase testCase = new NegotiationCase( - "VAL-MAP-05/zh-CN", - "VAL-MAP-05", - "validate/error-code-mapping.json", - NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_PROPOSE_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.ASSERTION))), - new PromptSource.Golden("information_propose"), - schema, - "failingSemanticValidator", - failed("NegotiationParamExtractionException", "template.not_found", 0, List.of(), List.of())); - - engine.run(testCase); - } - - // ------------------------------------------------------------------ P0 contracts - - @Test - void assertConclusionLiteralPresentContract() { - NegotiationCase testCase = - acceptFromTextCase(okContracts(1, "information_accept", List.of("conclusionLiteralPresent")), null); - - engine.run(testCase); - } - - @Test - void conclusionLiteralPresentContractRejectsNonTerminalApis() { - NegotiationCase testCase = - proposeFromTextCase(okContracts(1, "information_propose", List.of("conclusionLiteralPresent")), null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("conclusionLiteralPresent") - && failure.getMessage().contains("a terminal or abort generation API"), - "the contract must reject a propose API but was: " + failure.getMessage()); - } - - @Test - void assertContextKeysInMergedParamsContract() { - NegotiationCase testCase = validateProposeCase( - okParams( - 1, - Map.of( - "id", - SESSION_ID, - "round", - 2, - "maxRounds", - 5, - "accessPort", - "P533-珠江旧城-PTN3900-23-TPA1EG24-1"), - List.of("contextKeysInMergedParams")), - SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - FilledParamData filled = outcome.filledParams(); - assertNotNull(filled); - assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); - } - - @Test - void assertNoLlmLeakInUserMessageContract() { - NegotiationCase testCase = new NegotiationCase( - "FT-RETRY-01/zh-CN", - "FT-RETRY-01", - "from-text/retry.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - new LlmScript( - null, - List.of( - new LlmScriptStep.Fail(LlmFailMarker.NON_JSON), - new LlmScriptStep.Fail(LlmFailMarker.NON_JSON), - new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), - null, - null, - null, - failed( - "NegotiationGenerationException", - "llm.response_invalid", - 3, - List.of(), - List.of("noLlmLeakInUserMessage"))); - - engine.run(testCase); - } - - @Test - void assertMetadataTripleShapeContract() { - NegotiationCase testCase = - acceptFromTextCase(okContracts(1, "information_accept", List.of("metadataTripleShape")), null); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - assertEquals( - 3, - outcome.message().buildMetadataContent().size(), - "the contract must have verified the triple metadata shape"); - } - - @Test - void rejectsUnknownAndNotYetLitContractNames() { - NegotiationCase unknown = - acceptFromTextCase(okContracts(1, "information_accept", List.of("not-a-contract")), null); - AssertionError unknownFailure = assertThrows(AssertionError.class, () -> engine.run(unknown)); - assertTrue( - unknownFailure.getMessage().contains("a registered contract name"), - "an unknown contract name must fail but was: " + unknownFailure.getMessage()); - - NegotiationCase notYetLit = - acceptFromTextCase(okContracts(1, "information_accept", List.of("noRenderSlotLeak")), null); - AssertionError notYetLitFailure = assertThrows(AssertionError.class, () -> engine.run(notYetLit)); - assertTrue( - notYetLitFailure.getMessage().contains("not yet lit"), - "a registered P1 contract must fail as not yet lit but was: " + notYetLitFailure.getMessage()); - } - - // ------------------------------------------------------------------ differential (Q17 C+) - - @Test - void runsTheDifferentialDoubleRun() throws Exception { - NegotiationCase testCase = proposeFromTextCase( - ok(1, "information_propose", new Expectation.Metadata(INFORMATION_PROPOSE_URI, Boolean.TRUE), true), - mapper.readTree(PROPOSE_PAYLOAD)); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - assertNotNull(outcome.message()); - assertEquals(1, outcome.llmCalls(), "only the from-text leg may call the LLM"); - } - - @Test - void differentialFailsWhenTheTypedDataDisagreesWithTheText() throws Exception { - NegotiationCase testCase = proposeFromTextCase( - ok(1, "information_propose", new Expectation.Metadata(INFORMATION_PROPOSE_URI, Boolean.TRUE), true), - mapper.readTree(PROPOSE_DATA_DISAGREEING)); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.differential") - && failure.getMessage().contains("fromText == fromData"), - "the differential must compare both legs but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ validate expectations - - @Test - void comparesSlotErrorsOrderInsensitively() { - NegotiationCase testCase = validateProposeCase( - failed( - "NegotiationParamExtractionException", - "negotiation.semantic_rejected", - 1, - List.of(new Expectation.SlotError("accessPort", "negotiation.field_missing")), - List.of()), - SEMANTIC_REJECT_PAYLOAD); - - engine.run(testCase); - } - - @Test - void slotErrorsFailOnAnExtraExpectedPair() { - NegotiationCase testCase = validateProposeCase( - failed( - "NegotiationParamExtractionException", - "negotiation.semantic_rejected", - 1, - List.of( - new Expectation.SlotError("accessPort", "negotiation.field_missing"), - new Expectation.SlotError("round", "negotiation.round_exceeded")), - List.of()), - SEMANTIC_REJECT_PAYLOAD); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.slotErrors"), - "an extra expected slot error must fail but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ red paths (not a rubber stamp) - - @Test - void failsWithCaseIdAndJsonPathWhenTheExpectedCodeMismatches() { - NegotiationCase testCase = new NegotiationCase( - "FT-EXTRACT-01/zh-CN", - "FT-EXTRACT-01", - "from-text/extraction-failures.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload("{\"relationship\":null}"))), - null, - null, - null, - failed(null, "negotiation.invalid_input", 1, List.of(), List.of())); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("FT-EXTRACT-01/zh-CN") - && failure.getMessage().contains("$.expect.code") - && failure.getMessage().contains("negotiation.field_missing"), - "the failure must carry the case id, the JSON path and both codes but was: " + failure.getMessage()); - } - - @Test - void failsWithCaseIdAndJsonPathWhenTheLlmCallCountMismatches() { - NegotiationCase testCase = acceptFromTextCase(ok(2, "information_accept", null, false), null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.llmCalls") - && failure.getMessage().contains("2"), - "a wrong llmCalls expectation must fail but was: " + failure.getMessage()); - } - - @Test - void failsWithCaseIdAndJsonPathWhenTheGoldenNameMismatches() { - NegotiationCase testCase = acceptFromTextCase(ok(1, "information_reject", null, false), null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.promptTextEqualsGolden"), - "a wrong golden name must fail but was: " + failure.getMessage()); - } - - @Test - void failsWhenASuccessCaseActuallyFails() { - NegotiationCase testCase = new NegotiationCase( - "FT-HAPPY-99/zh-CN", - "FT-HAPPY-99", - "from-text/happy.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), - null, - null, - null, - ok(1, "information_accept", null, false)); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.outcome") - && failure.getMessage().contains("failure"), - "an unexpected failure must flip the outcome expectation but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ task API dispatch family (Q21) - - @Test - void runsATaskFromTextCaseThroughTheMirroredClientWiring() { - NegotiationCase testCase = taskFromTextCase( - okTask(1, List.of("## 任务类型", "## 任务对象", "## 任务上下文", "event-id-20260511-09013"), null, Map.of())); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - MetadataContent message = outcome.message(); - assertNotNull(message); - assertEquals(1, outcome.llmCalls(), "the task from-text pipeline makes exactly one slot-extraction call"); - assertEquals(PRIVATE_LINE_COMPLAINT_URI, message.templateUri()); - assertTrue( - message.promptText().contains("## 任务对象(Task Object)"), - "the task object section header must be rendered: " + message.promptText()); - assertTrue(!message.promptText().contains("P533"), "no port name may leak into the incomplete task prompt"); - } - - @Test - void runsATaskFromDataWithSchemaCaseThroughTheMirroredClientWiring() throws Exception { - NegotiationCase testCase = taskFromDataCase( - okTask(1, List.of("## 任务类型", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差"), null, Map.of())); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - MetadataContent message = outcome.message(); - assertNotNull(message); - assertEquals(1, outcome.llmCalls(), "the from-data-with-schema pipeline also extracts slots through the LLM"); - assertEquals(PRIVATE_LINE_COMPLAINT_URI, message.templateUri()); - } - - @Test - void validateTaskPromptReportsTheMissingParametersAsNullValuedParams() throws Exception { - NegotiationCase testCase = taskValidateCase( - okTask( - 1, - null, - List.of("accessPort", "bizScenario"), - Map.of( - "faultTime", "2026-05-11T08:21:46Z", - "eventSerialNo", "event-id-20260511-09013")), - TASK_SEMANTIC_MISSING_PARAMS); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - FilledParamData filled = outcome.filledParams(); - assertNotNull(filled); - assertEquals(1, outcome.llmCalls(), "the task validation pipeline makes exactly one semantic call"); - assertEquals("2026-05-11T08:21:46Z", filled.data().get("faultTime")); - assertEquals("event-id-20260511-09013", filled.data().get("eventSerialNo")); - assertTrue( - filled.data().containsKey("accessPort") && filled.data().get("accessPort") == null, - "a missing parameter must surface as a null-valued entry, not as an absent key"); - } - - @Test - void taskValidateMissingParamsFlipFails() throws Exception { - NegotiationCase testCase = taskValidateCase( - okTask(1, null, List.of("accessPort", "bizScenario", "faultDetail"), Map.of()), - TASK_SEMANTIC_MISSING_PARAMS); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.missingParams") - && failure.getMessage().contains("faultDetail"), - "an extra expected missing parameter must fail but was: " + failure.getMessage()); - } - - @Test - void taskValidateSemanticRejectionCarriesTheValidationCode() throws Exception { - NegotiationCase testCase = taskValidateCase( - failed("ContentValidationException", "negotiation.semantic_rejected", 1, List.of(), List.of()), - TASK_SEMANTIC_REJECTED); - - engine.run(testCase); - } - - @Test - void taskFromTextFailsOnAMissingRequiredSlot() throws Exception { - NegotiationCase testCase = new NegotiationCase( - "TASK-ERR-01/zh-CN", - "TASK-ERR-01", - "task/programming-errors.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - COMPLAINT_TEXT, - null, - new LlmScript( - null, - List.of(new LlmScriptStep.Payload( - "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"\"}, \"slot_errors\": []}"))), - null, - null, - null, - failed("PromptGenerationException", "slot.not_provided", 1, List.of(), List.of())); - - engine.run(testCase); - } - - @Test - void taskPromptTextContainsFailsOnAnAbsentFragment() { - NegotiationCase testCase = taskFromTextCase(okTask(1, List.of("## 不存在的节头"), null, Map.of())); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.promptTextContains") - && failure.getMessage().contains("不存在的节头"), - "an absent structural fragment must fail but was: " + failure.getMessage()); - } - - @Test - void paramsFromStepIsReservedForTheScenarioEngine() throws Exception { - NegotiationCase testCase = taskValidateCase(okTask(1, null, null, Map.of()), TASK_SEMANTIC_MISSING_PARAMS); - NegotiationCase withFromStep = new NegotiationCase( - testCase.id(), - testCase.baseId(), - testCase.sourceFile(), - testCase.api(), - testCase.language(), - testCase.priority(), - testCase.tags(), - testCase.summary(), - testCase.context(), - testCase.templateUri(), - testCase.inputText(), - testCase.inputData(), - testCase.llm(), - testCase.prompt(), - testCase.schema(), - testCase.inject(), - new Expectation( - testCase.expect().success(), - testCase.expect().exception(), - testCase.expect().code(), - testCase.expect().messageContains(), - testCase.expect().slotErrors(), - testCase.expect().llmCalls(), - testCase.expect().promptTextEqualsGolden(), - testCase.expect().metadata(), - testCase.expect().params(), - testCase.expect().contracts(), - testCase.expect().differential(), - testCase.expect().promptTextContains(), - testCase.expect().missingParams(), - 1)); - - IllegalStateException failure = assertThrows(IllegalStateException.class, () -> engine.run(withFromStep)); - - assertTrue( - failure.getMessage().contains("paramsFromStep 1") - && failure.getMessage().contains("ScenarioEngine"), - "a standalone case cannot resolve paramsFromStep but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ expectation helpers - - private static Expectation ok( - @Nullable Integer llmCalls, - @Nullable String promptTextEqualsGolden, - @Nullable Metadata metadata, - boolean differential) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - promptTextEqualsGolden, - metadata, - Map.of(), - List.of(), - differential); - } - - private static Expectation okContracts( - @Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden, List contracts) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - promptTextEqualsGolden, - null, - Map.of(), - contracts, - false); - } - - private static Expectation okParams( - @Nullable Integer llmCalls, Map params, List contracts) { - return new Expectation(true, null, null, List.of(), List.of(), llmCalls, null, null, params, contracts, false); - } - - private static Expectation failed( - @Nullable String exception, - @Nullable String code, - @Nullable Integer llmCalls, - List slotErrors, - List contracts) { - return new Expectation( - false, exception, code, List.of(), slotErrors, llmCalls, null, null, Map.of(), contracts, false); - } - - // ------------------------------------------------------------------ case builders - - private NegotiationCase acceptFromTextCase(Expectation expect, @Nullable JsonNode inputData) { - return new NegotiationCase( - "FT-HAPPY-01/zh-CN", - "FT-HAPPY-01", - "from-text/happy.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "我确认第一阶段的信息。", - inputData, - new LlmScript(null, List.of(new LlmScriptStep.Payload(ACCEPT_PAYLOAD))), - null, - null, - null, - expect); - } - - private NegotiationCase proposeFromTextCase(Expectation expect, @Nullable JsonNode inputData) { - return new NegotiationCase( - "FT-HAPPY-02/zh-CN", - "FT-HAPPY-02", - "from-text/happy.json", - NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_PROPOSE_URI, - "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", - inputData, - new LlmScript(null, List.of(new LlmScriptStep.Payload(PROPOSE_PAYLOAD))), - null, - null, - null, - expect); - } - - private NegotiationCase validateProposeCase(Expectation expect, String semanticPayload) { - return new NegotiationCase( - "VAL-HAPPY-01/zh-CN", - "VAL-HAPPY-01", - "validate/happy.json", - NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_PROPOSE_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), - new PromptSource.Golden("information_propose"), - schema, - null, - expect); - } - - private static Expectation.Metadata metadata() { - return new Expectation.Metadata(INFORMATION_ACCEPT_REJECT_URI, Boolean.TRUE); - } - - // ------------------------------------------------------------------ task family helpers - - private static Expectation okTask( - @Nullable Integer llmCalls, - @Nullable List promptTextContains, - @Nullable List missingParams, - Map params) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - null, - null, - params, - List.of(), - false, - promptTextContains == null ? List.of() : promptTextContains, - missingParams, - null); - } - - private NegotiationCase taskFromTextCase(Expectation expect) { - return new NegotiationCase( - "TASK-FT-01/zh-CN", - "TASK-FT-01", - "task/from-text.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - COMPLAINT_TEXT, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_OBJECT_PORTLESS))), - null, - null, - null, - expect); - } - - private NegotiationCase taskFromDataCase(Expectation expect) throws Exception { - return new NegotiationCase( - "TASK-FD-01/zh-CN", - "TASK-FD-01", - "task/from-data.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA, - ZH_CN, - "P0", - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - null, - mapper.readTree("{\"portName\": \"P533-珠江旧城-PTN3900-23-TPA1EG24-1\", \"complaintScenario\": \"专线质差\"," - + " \"faultStartTime\": \"2026-05-11T08:21:46Z\", \"ticketNo\":" - + " \"event-id-20260511-09013\", \"faultDetailText\": \"深圳访问广州的响应时延从平均12ms" - + "骤升至320ms\"}"), - new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_FILLED))), - null, - mapper.readTree("{\"type\": \"object\", \"properties\": {\"portName\": {\"type\": \"string\"}," - + " \"complaintScenario\": {\"type\": \"string\"}, \"faultStartTime\":" - + " {\"type\": \"string\"}, \"ticketNo\": {\"type\": \"string\"}," - + " \"faultDetailText\": {\"type\": \"string\"}}}"), - null, - expect); - } - - private NegotiationCase taskValidateCase(Expectation expect, String semanticPayload) throws Exception { - return new NegotiationCase( - "TASK-VAL-01/zh-CN", - "TASK-VAL-01", - "task/validate.json", - NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - ZH_CN, - "P0", - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), - new PromptSource.Text(TASK_PROMPT_MISSING_PARAMS), - mapper.readTree(TASK_PARAM_SCHEMA), - null, - expect); - } -} +package net.openan.a2at.sdk.corpus; + +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.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.corpus.Expectation.Metadata; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Direct unit tests of the {@link CaseEngine} against inline case objects: the four P0 contracts, the Q17 C+ + * differential double run, the inject hook, the fail-on-overconsumption calibration through {@code llmCalls}, and the + * red paths proving the engine is not a rubber stamp (every flipped expectation fails with the case id and the JSON + * path of the expectation). + * + *

            The engine runs against the real production wiring (builders, classpath resources, renderers, vocabulary, rule + * gate, semantic validator); the golden comparisons use the committed golden fixtures of the test resources. + */ +class CaseEngineTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String ZH_CN = "zh-CN"; + + private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; + + private static final String INFORMATION_ACCEPT_REJECT_URI = + "Negotiation-T/information-negotiation/accept-reject/v1"; + + private static final String PRIVATE_LINE_COMPLAINT_URI = "Task-T/network-layer/private-line-complaint/v1"; + + /** + * The workbench raw complaint of the five-step closed loop (样例步骤1): deliberately lacking the access port name and + * the complaint category — the causal starting point of the information negotiation. + */ + private static final String COMPLAINT_TEXT = "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢," + + "柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; + + /** Slot-extraction payload of the closed loop's step 1: the task object stays empty (no port name in the text). */ + private static final String TASK_SLOTS_OBJECT_EMPTY = + "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" + + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** + * Slot-extraction payload of the from-text success path since the task_object slot became required upstream: the + * task object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays + * portless. + */ + private static final String TASK_SLOTS_OBJECT_PORTLESS = + "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" + + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** Slot-extraction payload of the filled variant: the port name and the complaint category are present. */ + private static final String TASK_SLOTS_FILLED = + "{\"slots\": {\"任务对象\": \"接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\", \"任务上下文\":" + + " \"投诉分类:专线质差;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:" + + "event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** + * Semantic-validation payload of the closed loop's step 2: the prompt is acceptable, but the access port name and + * the complaint category are missing — the two null-valued parameters the OMC then negotiates for. + */ + private static final String TASK_SEMANTIC_MISSING_PARAMS = + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," + + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; + + /** Semantic payload rejecting the task prompt (rejection-sample shape: key slots missing). */ + private static final String TASK_SEMANTIC_REJECTED = + "{\"semantic_verdict\":false,\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"content.param_missing\"," + + "\"facts\":{\"section_label\":\"接入端口名称\"}}],\"params\":{}}"; + + /** The task parameter schema of the closed loop (server-side keys, dictionary §10). */ + private static final String TASK_PARAM_SCHEMA = + "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" + + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" + + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; + + /** The rendered task prompt the OMC receives (样例步骤1, shortened): the negotiation-causing message. */ + private static final String TASK_PROMPT_MISSING_PARAMS = + """ + ## 任务类型(Task Type) + 传输专线业务投诉诊断 + + ## 任务对象(Task Object) + 接入端口名称: + + ## 任务上下文(Task Context) + 1. 投诉分类: + 2. 问题发生时间: "2026-05-11T08:21:46Z" + 3. OSS侧事件流水号:"event-id-20260511-09013" + 4. 投诉详情:"深圳访问广州的响应时延从平均12ms骤升至320ms" + """; + + /** Extraction payload that maps back to the typed content of the information accept golden fixture. */ + private static final String ACCEPT_PAYLOAD = + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" + + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; + + /** Extraction payload that maps back to the typed content of the information propose golden fixture. */ + private static final String PROPOSE_PAYLOAD = + "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}," + + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," + + "\"value\":null}],\"relationship\":\"OR\"}"; + + /** The same content as typed data, disagreeing in one item value for the differential red path. */ + private static final String PROPOSE_DATA_DISAGREEING = + "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P781-珠江新城-PTN7900-23-TPA1EG24-17\"}," + + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," + + "\"value\":null}],\"relationship\":\"OR\"}"; + + private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" + + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; + + private static final String SEMANTIC_REJECT_PAYLOAD = + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" + + "\"accessPort\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":" + + "\"接入端口名称\"}}],\"params\":{}}"; + + private final CaseEngine engine = new CaseEngine(); + + private final ObjectMapper mapper = new ObjectMapper(); + + private JsonNode schema; + + @BeforeEach + void readSchema() throws Exception { + schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); + } + + // ------------------------------------------------------------------ green paths + + @Test + void runsASuccessfulFromTextCaseAgainstTheGoldenFixture() { + NegotiationCase testCase = acceptFromTextCase(ok(1, "information_accept", metadata(), false), null); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + MetadataContent message = outcome.message(); + assertNotNull(message); + assertEquals(1, outcome.llmCalls()); + assertEquals(INFORMATION_ACCEPT_REJECT_URI, message.templateUri()); + assertEquals( + new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.ACCEPT), message.negotiationContext()); + } + + @Test + void runsFromDataCasesDeterministicallyWithoutAnyLlmCall() throws Exception { + NegotiationCase testCase = new NegotiationCase( + "FD-HAPPY-01/zh-CN", + "FD-HAPPY-01", + "from-data/happy.json", + NegotiationApi.GENERATE_ACCEPT_FROM_DATA, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + null, + mapper.readTree(ACCEPT_PAYLOAD), + null, + null, + null, + null, + ok(0, "information_accept", metadata(), false)); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + assertNotNull(outcome.message()); + assertEquals(0, outcome.llmCalls(), "the from-data run must not call the LLM (assertion-only client)"); + } + + @Test + void injectsTheFailingTemplateLoaderForTheTemplateNotFoundMatrix() { + NegotiationCase testCase = new NegotiationCase( + "FT-TPL-01/zh-CN", + "FT-TPL-01", + "from-text/template-resolution.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(ACCEPT_PAYLOAD))), + null, + null, + "failingTemplateLoader", + failed("NegotiationGenerationException", "template.not_found", 0, List.of(), List.of())); + + engine.run(testCase); + } + + @Test + void injectsTheFailingSemanticValidatorForTheValidateTemplateNotFoundMapping() { + NegotiationCase testCase = new NegotiationCase( + "VAL-MAP-05/zh-CN", + "VAL-MAP-05", + "validate/error-code-mapping.json", + NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_PROPOSE_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.ASSERTION))), + new PromptSource.Golden("information_propose"), + schema, + "failingSemanticValidator", + failed("NegotiationParamExtractionException", "template.not_found", 0, List.of(), List.of())); + + engine.run(testCase); + } + + // ------------------------------------------------------------------ P0 contracts + + @Test + void assertConclusionLiteralPresentContract() { + NegotiationCase testCase = + acceptFromTextCase(okContracts(1, "information_accept", List.of("conclusionLiteralPresent")), null); + + engine.run(testCase); + } + + @Test + void conclusionLiteralPresentContractRejectsNonTerminalApis() { + NegotiationCase testCase = + proposeFromTextCase(okContracts(1, "information_propose", List.of("conclusionLiteralPresent")), null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("conclusionLiteralPresent") + && failure.getMessage().contains("a terminal or abort generation API"), + "the contract must reject a propose API but was: " + failure.getMessage()); + } + + @Test + void assertContextKeysInMergedParamsContract() { + NegotiationCase testCase = validateProposeCase( + okParams( + 1, + Map.of( + "id", + SESSION_ID, + "round", + 2, + "maxRounds", + 5, + "accessPort", + "P533-珠江旧城-PTN3900-23-TPA1EG24-1"), + List.of("contextKeysInMergedParams")), + SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + FilledParamData filled = outcome.filledParams(); + assertNotNull(filled); + assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); + } + + @Test + void assertNoLlmLeakInUserMessageContract() { + NegotiationCase testCase = new NegotiationCase( + "FT-RETRY-01/zh-CN", + "FT-RETRY-01", + "from-text/retry.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + new LlmScript( + null, + List.of( + new LlmScriptStep.Fail(LlmFailMarker.NON_JSON), + new LlmScriptStep.Fail(LlmFailMarker.NON_JSON), + new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), + null, + null, + null, + failed( + "NegotiationGenerationException", + "llm.response_invalid", + 3, + List.of(), + List.of("noLlmLeakInUserMessage"))); + + engine.run(testCase); + } + + @Test + void assertMetadataTripleShapeContract() { + NegotiationCase testCase = + acceptFromTextCase(okContracts(1, "information_accept", List.of("metadataTripleShape")), null); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + assertEquals( + 3, + outcome.message().buildMetadataContent().size(), + "the contract must have verified the triple metadata shape"); + } + + @Test + void rejectsUnknownAndNotYetLitContractNames() { + NegotiationCase unknown = + acceptFromTextCase(okContracts(1, "information_accept", List.of("not-a-contract")), null); + AssertionError unknownFailure = assertThrows(AssertionError.class, () -> engine.run(unknown)); + assertTrue( + unknownFailure.getMessage().contains("a registered contract name"), + "an unknown contract name must fail but was: " + unknownFailure.getMessage()); + + NegotiationCase notYetLit = + acceptFromTextCase(okContracts(1, "information_accept", List.of("noRenderSlotLeak")), null); + AssertionError notYetLitFailure = assertThrows(AssertionError.class, () -> engine.run(notYetLit)); + assertTrue( + notYetLitFailure.getMessage().contains("not yet lit"), + "a registered P1 contract must fail as not yet lit but was: " + notYetLitFailure.getMessage()); + } + + // ------------------------------------------------------------------ differential (Q17 C+) + + @Test + void runsTheDifferentialDoubleRun() throws Exception { + NegotiationCase testCase = proposeFromTextCase( + ok(1, "information_propose", new Expectation.Metadata(INFORMATION_PROPOSE_URI, Boolean.TRUE), true), + mapper.readTree(PROPOSE_PAYLOAD)); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + assertNotNull(outcome.message()); + assertEquals(1, outcome.llmCalls(), "only the from-text leg may call the LLM"); + } + + @Test + void differentialFailsWhenTheTypedDataDisagreesWithTheText() throws Exception { + NegotiationCase testCase = proposeFromTextCase( + ok(1, "information_propose", new Expectation.Metadata(INFORMATION_PROPOSE_URI, Boolean.TRUE), true), + mapper.readTree(PROPOSE_DATA_DISAGREEING)); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.differential") + && failure.getMessage().contains("fromText == fromData"), + "the differential must compare both legs but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ validate expectations + + @Test + void comparesSlotErrorsOrderInsensitively() { + NegotiationCase testCase = validateProposeCase( + failed( + "NegotiationParamExtractionException", + "negotiation.semantic_rejected", + 1, + List.of(new Expectation.SlotError("accessPort", "negotiation.field_missing")), + List.of()), + SEMANTIC_REJECT_PAYLOAD); + + engine.run(testCase); + } + + @Test + void slotErrorsFailOnAnExtraExpectedPair() { + NegotiationCase testCase = validateProposeCase( + failed( + "NegotiationParamExtractionException", + "negotiation.semantic_rejected", + 1, + List.of( + new Expectation.SlotError("accessPort", "negotiation.field_missing"), + new Expectation.SlotError("round", "negotiation.round_exceeded")), + List.of()), + SEMANTIC_REJECT_PAYLOAD); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.slotErrors"), + "an extra expected slot error must fail but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ red paths (not a rubber stamp) + + @Test + void failsWithCaseIdAndJsonPathWhenTheExpectedCodeMismatches() { + NegotiationCase testCase = new NegotiationCase( + "FT-EXTRACT-01/zh-CN", + "FT-EXTRACT-01", + "from-text/extraction-failures.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload("{\"relationship\":null}"))), + null, + null, + null, + failed(null, "negotiation.invalid_input", 1, List.of(), List.of())); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("FT-EXTRACT-01/zh-CN") + && failure.getMessage().contains("$.expect.code") + && failure.getMessage().contains("negotiation.field_missing"), + "the failure must carry the case id, the JSON path and both codes but was: " + failure.getMessage()); + } + + @Test + void failsWithCaseIdAndJsonPathWhenTheLlmCallCountMismatches() { + NegotiationCase testCase = acceptFromTextCase(ok(2, "information_accept", null, false), null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.llmCalls") + && failure.getMessage().contains("2"), + "a wrong llmCalls expectation must fail but was: " + failure.getMessage()); + } + + @Test + void failsWithCaseIdAndJsonPathWhenTheGoldenNameMismatches() { + NegotiationCase testCase = acceptFromTextCase(ok(1, "information_reject", null, false), null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.promptTextEqualsGolden"), + "a wrong golden name must fail but was: " + failure.getMessage()); + } + + @Test + void failsWhenASuccessCaseActuallyFails() { + NegotiationCase testCase = new NegotiationCase( + "FT-HAPPY-99/zh-CN", + "FT-HAPPY-99", + "from-text/happy.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), + null, + null, + null, + ok(1, "information_accept", null, false)); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.outcome") + && failure.getMessage().contains("failure"), + "an unexpected failure must flip the outcome expectation but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ task API dispatch family (Q21) + + @Test + void runsATaskFromTextCaseThroughTheMirroredClientWiring() { + NegotiationCase testCase = taskFromTextCase( + okTask(1, List.of("## 任务类型", "## 任务对象", "## 任务上下文", "event-id-20260511-09013"), null, Map.of())); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + MetadataContent message = outcome.message(); + assertNotNull(message); + assertEquals(1, outcome.llmCalls(), "the task from-text pipeline makes exactly one slot-extraction call"); + assertEquals(PRIVATE_LINE_COMPLAINT_URI, message.templateUri()); + assertTrue( + message.promptText().contains("## 任务对象(Task Object)"), + "the task object section header must be rendered: " + message.promptText()); + assertTrue(!message.promptText().contains("P533"), "no port name may leak into the incomplete task prompt"); + } + + @Test + void runsATaskFromDataWithSchemaCaseThroughTheMirroredClientWiring() throws Exception { + NegotiationCase testCase = taskFromDataCase( + okTask(1, List.of("## 任务类型", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差"), null, Map.of())); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + MetadataContent message = outcome.message(); + assertNotNull(message); + assertEquals(1, outcome.llmCalls(), "the from-data-with-schema pipeline also extracts slots through the LLM"); + assertEquals(PRIVATE_LINE_COMPLAINT_URI, message.templateUri()); + } + + @Test + void validateTaskPromptReportsTheMissingParametersAsNullValuedParams() throws Exception { + NegotiationCase testCase = taskValidateCase( + okTask( + 1, + null, + List.of("accessPort", "bizScenario"), + Map.of( + "faultTime", "2026-05-11T08:21:46Z", + "eventSerialNo", "event-id-20260511-09013")), + TASK_SEMANTIC_MISSING_PARAMS); + + CaseEngine.CaseOutcome outcome = engine.run(testCase); + + FilledParamData filled = outcome.filledParams(); + assertNotNull(filled); + assertEquals(1, outcome.llmCalls(), "the task validation pipeline makes exactly one semantic call"); + assertEquals("2026-05-11T08:21:46Z", filled.data().get("faultTime")); + assertEquals("event-id-20260511-09013", filled.data().get("eventSerialNo")); + assertTrue( + filled.data().containsKey("accessPort") && filled.data().get("accessPort") == null, + "a missing parameter must surface as a null-valued entry, not as an absent key"); + } + + @Test + void taskValidateMissingParamsFlipFails() throws Exception { + NegotiationCase testCase = taskValidateCase( + okTask(1, null, List.of("accessPort", "bizScenario", "faultDetail"), Map.of()), + TASK_SEMANTIC_MISSING_PARAMS); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.missingParams") + && failure.getMessage().contains("faultDetail"), + "an extra expected missing parameter must fail but was: " + failure.getMessage()); + } + + @Test + void taskValidateSemanticRejectionCarriesTheValidationCode() throws Exception { + NegotiationCase testCase = taskValidateCase( + failed("ContentValidationException", "negotiation.semantic_rejected", 1, List.of(), List.of()), + TASK_SEMANTIC_REJECTED); + + engine.run(testCase); + } + + @Test + void taskFromTextFailsOnAMissingRequiredSlot() throws Exception { + NegotiationCase testCase = new NegotiationCase( + "TASK-ERR-01/zh-CN", + "TASK-ERR-01", + "task/programming-errors.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + COMPLAINT_TEXT, + null, + new LlmScript( + null, + List.of(new LlmScriptStep.Payload( + "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"\"}, \"slot_errors\": []}"))), + null, + null, + null, + failed("PromptGenerationException", "slot.not_provided", 1, List.of(), List.of())); + + engine.run(testCase); + } + + @Test + void taskPromptTextContainsFailsOnAnAbsentFragment() { + NegotiationCase testCase = taskFromTextCase(okTask(1, List.of("## 不存在的节头"), null, Map.of())); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); + + assertTrue( + failure.getMessage().contains("$.expect.promptTextContains") + && failure.getMessage().contains("不存在的节头"), + "an absent structural fragment must fail but was: " + failure.getMessage()); + } + + @Test + void paramsFromStepIsReservedForTheScenarioEngine() throws Exception { + NegotiationCase testCase = taskValidateCase(okTask(1, null, null, Map.of()), TASK_SEMANTIC_MISSING_PARAMS); + NegotiationCase withFromStep = new NegotiationCase( + testCase.id(), + testCase.baseId(), + testCase.sourceFile(), + testCase.api(), + testCase.language(), + testCase.priority(), + testCase.tags(), + testCase.summary(), + testCase.context(), + testCase.templateUri(), + testCase.inputText(), + testCase.inputData(), + testCase.llm(), + testCase.prompt(), + testCase.schema(), + testCase.inject(), + new Expectation( + testCase.expect().success(), + testCase.expect().exception(), + testCase.expect().code(), + testCase.expect().messageContains(), + testCase.expect().slotErrors(), + testCase.expect().llmCalls(), + testCase.expect().promptTextEqualsGolden(), + testCase.expect().metadata(), + testCase.expect().params(), + testCase.expect().contracts(), + testCase.expect().differential(), + testCase.expect().promptTextContains(), + testCase.expect().missingParams(), + 1)); + + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> engine.run(withFromStep)); + + assertTrue( + failure.getMessage().contains("paramsFromStep 1") + && failure.getMessage().contains("ScenarioEngine"), + "a standalone case cannot resolve paramsFromStep but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ expectation helpers + + private static Expectation ok( + @Nullable Integer llmCalls, + @Nullable String promptTextEqualsGolden, + @Nullable Metadata metadata, + boolean differential) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + promptTextEqualsGolden, + metadata, + Map.of(), + List.of(), + differential); + } + + private static Expectation okContracts( + @Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden, List contracts) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + promptTextEqualsGolden, + null, + Map.of(), + contracts, + false); + } + + private static Expectation okParams( + @Nullable Integer llmCalls, Map params, List contracts) { + return new Expectation(true, null, null, List.of(), List.of(), llmCalls, null, null, params, contracts, false); + } + + private static Expectation failed( + @Nullable String exception, + @Nullable String code, + @Nullable Integer llmCalls, + List slotErrors, + List contracts) { + return new Expectation( + false, exception, code, List.of(), slotErrors, llmCalls, null, null, Map.of(), contracts, false); + } + + // ------------------------------------------------------------------ case builders + + private NegotiationCase acceptFromTextCase(Expectation expect, @Nullable JsonNode inputData) { + return new NegotiationCase( + "FT-HAPPY-01/zh-CN", + "FT-HAPPY-01", + "from-text/happy.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "我确认第一阶段的信息。", + inputData, + new LlmScript(null, List.of(new LlmScriptStep.Payload(ACCEPT_PAYLOAD))), + null, + null, + null, + expect); + } + + private NegotiationCase proposeFromTextCase(Expectation expect, @Nullable JsonNode inputData) { + return new NegotiationCase( + "FT-HAPPY-02/zh-CN", + "FT-HAPPY-02", + "from-text/happy.json", + NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_PROPOSE_URI, + "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", + inputData, + new LlmScript(null, List.of(new LlmScriptStep.Payload(PROPOSE_PAYLOAD))), + null, + null, + null, + expect); + } + + private NegotiationCase validateProposeCase(Expectation expect, String semanticPayload) { + return new NegotiationCase( + "VAL-HAPPY-01/zh-CN", + "VAL-HAPPY-01", + "validate/happy.json", + NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, + ZH_CN, + "P0", + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_PROPOSE_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), + new PromptSource.Golden("information_propose"), + schema, + null, + expect); + } + + private static Expectation.Metadata metadata() { + return new Expectation.Metadata(INFORMATION_ACCEPT_REJECT_URI, Boolean.TRUE); + } + + // ------------------------------------------------------------------ task family helpers + + private static Expectation okTask( + @Nullable Integer llmCalls, + @Nullable List promptTextContains, + @Nullable List missingParams, + Map params) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + null, + null, + params, + List.of(), + false, + promptTextContains == null ? List.of() : promptTextContains, + missingParams, + null); + } + + private NegotiationCase taskFromTextCase(Expectation expect) { + return new NegotiationCase( + "TASK-FT-01/zh-CN", + "TASK-FT-01", + "task/from-text.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + ZH_CN, + "P0", + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + COMPLAINT_TEXT, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_OBJECT_PORTLESS))), + null, + null, + null, + expect); + } + + private NegotiationCase taskFromDataCase(Expectation expect) throws Exception { + return new NegotiationCase( + "TASK-FD-01/zh-CN", + "TASK-FD-01", + "task/from-data.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA, + ZH_CN, + "P0", + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + null, + mapper.readTree("{\"portName\": \"P533-珠江旧城-PTN3900-23-TPA1EG24-1\", \"complaintScenario\": \"专线质差\"," + + " \"faultStartTime\": \"2026-05-11T08:21:46Z\", \"ticketNo\":" + + " \"event-id-20260511-09013\", \"faultDetailText\": \"深圳访问广州的响应时延从平均12ms" + + "骤升至320ms\"}"), + new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_FILLED))), + null, + mapper.readTree("{\"type\": \"object\", \"properties\": {\"portName\": {\"type\": \"string\"}," + + " \"complaintScenario\": {\"type\": \"string\"}, \"faultStartTime\":" + + " {\"type\": \"string\"}, \"ticketNo\": {\"type\": \"string\"}," + + " \"faultDetailText\": {\"type\": \"string\"}}}"), + null, + expect); + } + + private NegotiationCase taskValidateCase(Expectation expect, String semanticPayload) throws Exception { + return new NegotiationCase( + "TASK-VAL-01/zh-CN", + "TASK-VAL-01", + "task/validate.json", + NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, + ZH_CN, + "P0", + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), + new PromptSource.Text(TASK_PROMPT_MISSING_PARAMS), + mapper.readTree(TASK_PARAM_SCHEMA), + null, + expect); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java index 8b95d83f..aa24a609 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java @@ -1,576 +1,576 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.Test; - -/** - * Meta test of the negotiation test corpus (design document §6 Q6 and §7): the corpus is checked against its own - * contracts instead of the production code, so a hole in the corpus is caught before it silently narrows the suites. - * - *

            Checked today, in full strictness: global id uniqueness, expectation-block completeness, every expected error code - * being one of the content-layer error codes, the content-layer failure coverage, the validate-family code coverage, - * the four operational definitions of the bilingual parity (§7), and the per-record uniform language expansion. The - * business-alignment decisions add three more (design §10): the three closed-loop task APIs are exercised by scenario - * steps only and are role-bound (A=workbench generates, B=OMC validates), and every VAL-DRIFT drift probe is a - * compliant peer text expecting success. The Q19 business-review soft gate (§8.7) is opt-in: P0 cases must be 100% - * approved in review-status.json when {@code -Dcorpus.review.gate=true} is set and the status file exists, and is - * skipped otherwise. - * - *

            The full corpus has landed (design §8.4): a missing error-code or mapCode dimension is a hard failure, not a TODO - * line — a hole in the corpus silently narrows the suites, so it must block the build. - * - * @since 2026-08 - */ -class CorpusContractTest { - - /** - * Full strictness (design Q6/§8.4): every one of the content-layer error codes must be covered by at least one - * failure case of the corpus; a missing code fails the build. - */ - private static final boolean REQUIRE_FULL_ERROR_CODE_COVERAGE = true; - - /** - * Full strictness (design Q6/§8.4): every code the validate family can fail with must be covered by at least one - * validate-family failure case (the VAL-MAP/VAL-RULE/VAL-RETRY batches). The unknown-code fallback of the LLM - * response parsing (negotiation.rule_violation / content.rule_violation) cannot be expressed through the public - * exception surface and stays a hand-written-suite concern (ParamExtractorTest). - */ - private static final boolean REQUIRE_FULL_VALIDATE_CODE_COVERAGE = true; - - /** The content-layer error codes of the production exception surface (ErrorCatalog codes). */ - private static final List CONTENT_LAYER_ERROR_CODES = List.of( - ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), - ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), - ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), - ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), - ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), - ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), - ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), - ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); - - /** - * The codes the validate family fails with: the pipeline surfaces these directly (there is no code mapping layer - * since the ErrorCatalog migration), so a validate-family failure case must expect each of them. - */ - private static final List VALIDATE_FAMILY_ERROR_CODES = List.of( - ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), - ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), - ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), - ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); - - private static final LoadedCorpus CORPUS = CorpusSuites.loadCorpus(); - - // ------------------------------------------------------------------ ids and expectation blocks - - @Test - void idsAreGloballyUniqueAcrossCasesAndScenarios() { - Set expandedIds = new LinkedHashSet<>(); - int expectedCount = 0; - for (NegotiationCase testCase : allCaseRecords()) { - assertTrue(expandedIds.add(testCase.id()), "duplicate expanded id " + testCase.id()); - expectedCount++; - } - for (ScenarioCase scenario : CORPUS.scenarios()) { - assertTrue(expandedIds.add(scenario.id()), "duplicate expanded id " + scenario.id()); - expectedCount++; - } - for (LiveCase liveCase : CORPUS.liveCases()) { - assertTrue(expandedIds.add(liveCase.id()), "duplicate expanded id " + liveCase.id()); - expectedCount++; - } - assertEquals(expectedCount, expandedIds.size(), "the id is the primary key of the whole corpus"); - - // One base id belongs to exactly one record: all its language expansions come from the same file, and every - // expansion carries a distinct language (the loader already fails fast on duplicates at parse time; this - // re-asserts the invariant on the loaded corpus). Scenario step cases are derived records of a scenario and are - // covered by the scenario check below. - Map> caseGroups = new LinkedHashMap<>(); - for (NegotiationCase testCase : allCaseRecords()) { - if (testCase.id().contains("#step-")) { - continue; - } - caseGroups - .computeIfAbsent(testCase.baseId(), key -> new ArrayList<>()) - .add(testCase); - } - for (List group : caseGroups.values()) { - Set languages = new LinkedHashSet<>(); - String sourceFile = group.get(0).sourceFile(); - for (NegotiationCase expansion : group) { - assertEquals( - sourceFile, - expansion.sourceFile(), - "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); - assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); - } - } - Map> scenarioGroups = new LinkedHashMap<>(); - for (ScenarioCase scenario : CORPUS.scenarios()) { - scenarioGroups - .computeIfAbsent(scenario.baseId(), key -> new ArrayList<>()) - .add(scenario); - } - for (List group : scenarioGroups.values()) { - Set languages = new LinkedHashSet<>(); - String sourceFile = group.get(0).sourceFile(); - for (ScenarioCase expansion : group) { - assertEquals( - sourceFile, - expansion.sourceFile(), - "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); - assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); - } - } - Map> liveGroups = new LinkedHashMap<>(); - for (LiveCase liveCase : CORPUS.liveCases()) { - liveGroups - .computeIfAbsent(liveCase.baseId(), key -> new ArrayList<>()) - .add(liveCase); - } - for (List group : liveGroups.values()) { - Set languages = new LinkedHashSet<>(); - String sourceFile = group.get(0).sourceFile(); - for (LiveCase expansion : group) { - assertEquals( - sourceFile, - expansion.sourceFile(), - "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); - assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); - } - } - } - - @Test - void expectationBlocksAreComplete() { - for (NegotiationCase testCase : allCaseRecords()) { - Expectation expect = testCase.expect(); - if (expect.success()) { - assertTrue( - expect.exception() == null && expect.code() == null, - testCase.errorPrefix() - + ": a success expectation must not carry an exception or an error code"); - } else { - assertTrue( - expect.exception() != null || expect.code() != null, - testCase.errorPrefix() - + ": a failure expectation must name the expected exception or the expected error code"); - } - } - } - - @Test - void everyExpectedErrorCodeIsAKnownNegotiationCode() { - for (NegotiationCase testCase : allCaseRecords()) { - String code = testCase.expect().code(); - if (code != null) { - assertTrue( - CONTENT_LAYER_ERROR_CODES.contains(code), - testCase.errorPrefix() + ": the expected code '" + code - + "' is not one of the content-layer error codes"); - } - } - } - - // ------------------------------------------------------------------ error-code coverage - - @Test - void everyNegotiationErrorCodeIsCoveredByAFailureCase() { - Set covered = new LinkedHashSet<>(); - for (NegotiationCase testCase : allCaseRecords()) { - if (!testCase.expect().success() && testCase.expect().code() != null) { - covered.add(testCase.expect().code()); - } - } - List missing = new ArrayList<>(); - for (String code : CONTENT_LAYER_ERROR_CODES) { - if (covered.contains(code)) { - continue; - } - if (REQUIRE_FULL_ERROR_CODE_COVERAGE) { - fail("no failure case of the corpus expects the error code '" + code + "' yet"); - } - missing.add(code); - } - if (!missing.isEmpty()) { - System.out.println("error codes not yet covered by any failure case: " + missing); - } - } - - // ------------------------------------------------------------------ validate-family code coverage - - @Test - void everyValidateFamilyCodeIsCoveredByAValidateFailureCase() { - Set covered = new LinkedHashSet<>(); - for (NegotiationCase testCase : allCaseRecords()) { - if (testCase.api().family() == NegotiationApi.Family.VALIDATE - && !testCase.expect().success() - && testCase.expect().code() != null) { - covered.add(testCase.expect().code()); - } - } - List missing = new ArrayList<>(); - for (String code : VALIDATE_FAMILY_ERROR_CODES) { - if (covered.contains(code)) { - continue; - } - if (REQUIRE_FULL_VALIDATE_CODE_COVERAGE) { - fail("no validate-family failure case expects the error code '" + code + "' yet"); - } - missing.add(code); - } - if (!missing.isEmpty()) { - System.out.println("validate-family codes not yet covered by any validate failure case: " + missing); - } - } - - // ------------------------------------------------------------------ closed-loop task APIs (Q20-Q23) - - /** - * Q21 full strictness: every one of the three closed-loop task APIs must be exercised by at least one scenario - * step, and the task APIs must never appear as standalone case-file records — the closed loop (task prompt - * generation, peer validation, then negotiation) only has a business meaning inside a scenario, so a standalone - * task case would bypass the 缺参 → 协商 → 补参 → 提取 causal chain the corpus exists to model. - */ - @Test - void everyTaskApiIsExercisedByScenarioStepsOnly() { - List taskApis = new ArrayList<>(); - for (ScenarioCase scenario : CORPUS.scenarios()) { - for (ScenarioCase.ScenarioStep step : scenario.steps()) { - NegotiationApi api = step.caseData().api(); - if (api.family() == NegotiationApi.Family.TASK) { - taskApis.add(api); - } - } - } - for (NegotiationApi api : NegotiationApi.values()) { - if (api.family() != NegotiationApi.Family.TASK) { - continue; - } - assertTrue( - taskApis.contains(api), - "no scenario step exercises the closed-loop task API '" + api.jsonName() + "' yet"); - } - for (NegotiationCase testCase : CORPUS.cases()) { - assertTrue( - testCase.api().family() != NegotiationApi.Family.TASK, - testCase.errorPrefix() - + ": a task API must not appear as a standalone case record, only as a scenario step" - + " (the closed-loop causal chain lives in scenarios)"); - } - } - - /** - * Q21/Q23 role semantics: the workbench (A) generates the task prompt and the OMC (B) validates the peer message, - * so every task-generation step must be acted by role A and every task-validation step by role B (the dual-session - * scenario numbers its roles A1/A2/B1/B2 — the letter prefix decides). - */ - @Test - void taskStepsAreBoundToTheirClosedLoopRoles() { - for (ScenarioCase scenario : CORPUS.scenarios()) { - for (ScenarioCase.ScenarioStep step : scenario.steps()) { - NegotiationApi api = step.caseData().api(); - if (api.family() != NegotiationApi.Family.TASK) { - continue; - } - String role = step.role() == null ? "" : step.role(); - String expectedSide = api.jsonName().startsWith("generate") ? "A" : "B"; - assertTrue( - role.startsWith(expectedSide), - scenario.id() + " step " + step.step() + " (" + api.jsonName() - + ") must be acted by the " + (expectedSide.equals("A") ? "workbench (A)" : "OMC (B)") - + " side, but the role is '" + step.role() + "'"); - } - } - } - - // ------------------------------------------------------------------ drift probes (Q22) - - /** - * Q22 full strictness: a drift probe is a compliant-but-reworded peer message, so it must (a) live in the validate - * family, (b) expect success — a drift probe that fails would reward exact-wording coupling instead of semantic - * compliance — and (c) take the peer message as inline text, never a golden fixture (golden is the regression - * equality anchor, not the validate mainline input). - */ - @Test - void driftProbesAreCompliantPeerTextExpectingSuccess() { - boolean seen = false; - for (NegotiationCase testCase : CORPUS.cases()) { - if (!testCase.baseId().startsWith("VAL-DRIFT-")) { - continue; - } - seen = true; - assertTrue( - testCase.api().family() == NegotiationApi.Family.VALIDATE, - testCase.errorPrefix() + ": a drift probe must be a validate-family case"); - assertTrue( - testCase.expect().success(), - testCase.errorPrefix() + ": a compliant-but-reworded peer message must pass validation"); - assertTrue( - testCase.prompt() instanceof PromptSource.Text, - testCase.errorPrefix() - + ": a drift probe must carry the peer message as inline text (prompt.text)," - + " not a golden fixture or a fromStep reference"); - } - assertTrue(seen, "the corpus must carry at least one VAL-DRIFT drift probe"); - } - - // ------------------------------------------------------------------ live family (live design §2.2) - - /** - * The live records live in their own {@code liveCases} list and are invisible to the six offline contracts above; - * this contract pins their phase-1 scope (Q5/Q6): the {@code LIVE-} id prefix, zh-CN only, and the two task APIs — - * a record outside the scope could never run through the live engine's dispatch. - */ - @Test - void liveRecordsStayInThePhase1Scope() { - assertFalse(CORPUS.liveCases().isEmpty(), "the corpus must carry at least one live record"); - for (LiveCase liveCase : CORPUS.liveCases()) { - assertTrue( - liveCase.baseId().startsWith("LIVE-"), - liveCase.errorPrefix() + ": the live family ids carry the 'LIVE-' prefix"); - assertEquals( - "zh-CN", liveCase.language(), liveCase.errorPrefix() + ": live phase 1 covers zh-CN only (Q6)"); - assertTrue( - liveCase.api() == NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT - || liveCase.api() == NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - liveCase.errorPrefix() + ": live phase 1 covers the two task APIs (Q5) but the record declares " - + liveCase.api().jsonName()); - } - } - - /** - * Live expectation completeness: the loose block still has to pin something checkable — {@code paramsContains} - * values non-null (a null expected value would never match, the engine asserts non-null extraction), non-blank - * {@code paramsAbsent} slots and {@code promptTextContains} fragments, and a positive {@code maxLlmCalls} bound. - */ - @Test - void liveExpectationBlocksAreComplete() { - for (LiveCase liveCase : CORPUS.liveCases()) { - LiveExpectation expect = liveCase.liveExpect(); - for (Map.Entry entry : expect.paramsContains().entrySet()) { - assertTrue( - entry.getValue() != null, - liveCase.errorPrefix() + ": paramsContains." + entry.getKey() + " must pin a non-null value"); - } - for (String slot : expect.paramsAbsent()) { - assertFalse(slot.isBlank(), liveCase.errorPrefix() + ": paramsAbsent entries must not be blank"); - } - for (String fragment : expect.promptTextContains()) { - assertFalse( - fragment.isBlank(), - liveCase.errorPrefix() + ": promptTextContains fragments must not be blank"); - } - if (expect.maxLlmCalls() != null) { - assertTrue( - expect.maxLlmCalls() > 0, - liveCase.errorPrefix() + ": maxLlmCalls must be a positive upper bound"); - } - } - } - - // ------------------------------------------------------------------ bilingual parity (§7, four definitions) - - /** - * Parity ①: every happy case of the case files declares both languages, so both expansions run and must succeed. - */ - @Test - void happyCasesDeclareBothLanguages() { - for (List group : caseFileGroupsByBaseId().values()) { - if (!group.get(0).expect().success()) { - continue; - } - Set languages = languagesOf(group); - assertEquals( - Set.of("zh-CN", "en-US"), - languages, - group.get(0).errorPrefix() - + ": a happy case must run in both languages (the suite executes every expansion)"); - } - } - - /** - * Parity ②: all language expansions of one record share the identical expectation block, so a failure has one code. - */ - @Test - void failureCasesShareTheIdenticalExpectationAcrossLanguages() { - for (List group : caseFileGroupsByBaseId().values()) { - Expectation reference = group.get(0).expect(); - for (NegotiationCase expansion : group) { - assertEquals( - reference, - expansion.expect(), - expansion.errorPrefix() - + ": every language expansion of one record must carry the identical expectation" - + " block, so a failure case fails with the same error code in both languages"); - } - } - } - - /** Parity ③: the golden fixture directories of the two languages carry the same number of fixtures. */ - @Test - void goldenFixtureCountsMatchAcrossLanguages() { - assertEquals( - countGoldenFixtures("zh-CN"), - countGoldenFixtures("en-US"), - "the golden fixtures must exist in equal numbers for zh-CN and en-US"); - } - - /** - * Parity ④: every record expands exactly once per declared language — no language is silently dropped or doubled. - */ - @Test - void recordsExpandExactlyOncePerDeclaredLanguage() { - for (List group : caseFileGroupsByBaseId().values()) { - Set languages = languagesOf(group); - assertEquals( - languages.size(), - group.size(), - group.get(0).errorPrefix() + ": every declared language must expand into exactly one case"); - } - Map> scenarioGroups = new LinkedHashMap<>(); - for (ScenarioCase scenario : CORPUS.scenarios()) { - scenarioGroups - .computeIfAbsent(scenario.baseId(), key -> new ArrayList<>()) - .add(scenario); - } - for (List group : scenarioGroups.values()) { - Set languages = new LinkedHashSet<>(); - for (ScenarioCase scenario : group) { - assertTrue(languages.add(scenario.language()), "duplicate language expansion of " + scenario.id()); - } - assertEquals( - languages.size(), group.size(), "every declared language must expand into exactly one scenario"); - } - } - - // ------------------------------------------------------------------ business review soft gate (Q19, §8.7) - - /** - * Q19 soft gate (design §8.7): when enabled, every P0 case of the corpus must carry the review status 通过 in the - * {@code docs-local/review/review-status.json} produced by {@code tools/corpus_review.py collect} — the P0 - * review-approval rate must be 100% before a release. - * - *

            The gate is strictly opt-in so it never blocks daily development: it runs only when the system property - * {@code corpus.review.gate} is set to {@code true} AND the status file exists; otherwise the test is skipped via - * {@link Assumptions#assumeTrue(boolean, String)}. Scenario records carry no priority and are therefore out of the - * P0 gate's scope (they stay tracked through the review dashboard itself). - */ - @Test - void p0CasesAreFullyApprovedByTheBusinessReview() throws IOException { - Assumptions.assumeTrue( - Boolean.getBoolean("corpus.review.gate"), - "the business review gate is opt-in (enable with -Dcorpus.review.gate=true)"); - Path statusFile = findReviewStatusFile(); - Assumptions.assumeTrue( - statusFile != null, - "docs-local/review/review-status.json not found (run tools/corpus_review.py collect to produce it)"); - - JsonNode reviewedCases = - new ObjectMapper().readTree(Files.readString(statusFile)).path("cases"); - List notApproved = new ArrayList<>(); - for (List group : caseFileGroupsByBaseId().values()) { - if (!"P0".equals(group.get(0).priority())) { - continue; - } - String baseId = group.get(0).baseId(); - String status = reviewedCases.path(baseId).path("status").asText(""); - if (!APPROVED_STATUS.equals(status)) { - notApproved.add(baseId + " (status: '" + (status.isEmpty() ? "unreviewed" : status) + "')"); - } - } - assertTrue( - notApproved.isEmpty(), - "the P0 review-approval rate must be 100% before a release, not approved yet: " + notApproved); - } - - /** The single review status that counts as approved ({@code tools/corpus_review.py}: 通过 / 有疑问 / 否决). */ - private static final String APPROVED_STATUS = "通过"; - - /** - * Locates {@code docs-local/review/review-status.json} by walking up from the working directory to the repo root. - */ - private static @Nullable Path findReviewStatusFile() { - Path directory = Path.of(".").toAbsolutePath(); - while (directory != null) { - Path candidate = directory.resolve("docs-local").resolve("review").resolve("review-status.json"); - if (Files.isRegularFile(candidate)) { - return candidate; - } - directory = directory.getParent(); - } - return null; - } - - // ------------------------------------------------------------------ helpers - - /** All case records of the corpus: the case-file records plus the scenario step cases. */ - private static List allCaseRecords() { - List records = new ArrayList<>(CORPUS.cases()); - for (ScenarioCase scenario : CORPUS.scenarios()) { - for (ScenarioCase.ScenarioStep step : scenario.steps()) { - records.add(step.caseData()); - } - } - return records; - } - - /** Case-file records grouped by base id (the loader puts scenario step cases into the scenarios, not the cases). */ - private static Map> caseFileGroupsByBaseId() { - Map> groups = new LinkedHashMap<>(); - for (NegotiationCase testCase : CORPUS.cases()) { - groups.computeIfAbsent(testCase.baseId(), key -> new ArrayList<>()).add(testCase); - } - assertFalse(groups.isEmpty(), "the corpus must carry at least one case-file record"); - return groups; - } - - private static Set languagesOf(List group) { - Set languages = new LinkedHashSet<>(); - for (NegotiationCase expansion : group) { - assertTrue( - languages.add(expansion.language()), "duplicate language expansion of " + expansion.errorPrefix()); - } - return languages; - } - - private static int countGoldenFixtures(String language) { - URL url = CorpusContractTest.class.getClassLoader().getResource("golden/" + language); - if (url == null) { - return 0; - } - try { - Path directory = Path.of(url.toURI()); - try (Stream files = Files.list(directory)) { - return (int) files.filter(file -> file.getFileName().toString().endsWith(".md")) - .count(); - } - } catch (URISyntaxException | IOException exception) { - throw new IllegalStateException("Failed to list the golden fixtures of " + language, exception); - } - } -} +package net.openan.a2at.sdk.corpus; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +/** + * Meta test of the negotiation test corpus (design document §6 Q6 and §7): the corpus is checked against its own + * contracts instead of the production code, so a hole in the corpus is caught before it silently narrows the suites. + * + *

            Checked today, in full strictness: global id uniqueness, expectation-block completeness, every expected error code + * being one of the content-layer error codes, the content-layer failure coverage, the validate-family code coverage, + * the four operational definitions of the bilingual parity (§7), and the per-record uniform language expansion. The + * business-alignment decisions add three more (design §10): the three closed-loop task APIs are exercised by scenario + * steps only and are role-bound (A=workbench generates, B=OMC validates), and every VAL-DRIFT drift probe is a + * compliant peer text expecting success. The Q19 business-review soft gate (§8.7) is opt-in: P0 cases must be 100% + * approved in review-status.json when {@code -Dcorpus.review.gate=true} is set and the status file exists, and is + * skipped otherwise. + * + *

            The full corpus has landed (design §8.4): a missing error-code or mapCode dimension is a hard failure, not a TODO + * line — a hole in the corpus silently narrows the suites, so it must block the build. + * + * @since 2026-08 + */ +class CorpusContractTest { + + /** + * Full strictness (design Q6/§8.4): every one of the content-layer error codes must be covered by at least one + * failure case of the corpus; a missing code fails the build. + */ + private static final boolean REQUIRE_FULL_ERROR_CODE_COVERAGE = true; + + /** + * Full strictness (design Q6/§8.4): every code the validate family can fail with must be covered by at least one + * validate-family failure case (the VAL-MAP/VAL-RULE/VAL-RETRY batches). The unknown-code fallback of the LLM + * response parsing (negotiation.rule_violation / content.rule_violation) cannot be expressed through the public + * exception surface and stays a hand-written-suite concern (ParamExtractorTest). + */ + private static final boolean REQUIRE_FULL_VALIDATE_CODE_COVERAGE = true; + + /** The content-layer error codes of the production exception surface (ErrorCatalog codes). */ + private static final List CONTENT_LAYER_ERROR_CODES = List.of( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), + ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); + + /** + * The codes the validate family fails with: the pipeline surfaces these directly (there is no code mapping layer + * since the ErrorCatalog migration), so a validate-family failure case must expect each of them. + */ + private static final List VALIDATE_FAMILY_ERROR_CODES = List.of( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); + + private static final LoadedCorpus CORPUS = CorpusSuites.loadCorpus(); + + // ------------------------------------------------------------------ ids and expectation blocks + + @Test + void idsAreGloballyUniqueAcrossCasesAndScenarios() { + Set expandedIds = new LinkedHashSet<>(); + int expectedCount = 0; + for (NegotiationCase testCase : allCaseRecords()) { + assertTrue(expandedIds.add(testCase.id()), "duplicate expanded id " + testCase.id()); + expectedCount++; + } + for (ScenarioCase scenario : CORPUS.scenarios()) { + assertTrue(expandedIds.add(scenario.id()), "duplicate expanded id " + scenario.id()); + expectedCount++; + } + for (LiveCase liveCase : CORPUS.liveCases()) { + assertTrue(expandedIds.add(liveCase.id()), "duplicate expanded id " + liveCase.id()); + expectedCount++; + } + assertEquals(expectedCount, expandedIds.size(), "the id is the primary key of the whole corpus"); + + // One base id belongs to exactly one record: all its language expansions come from the same file, and every + // expansion carries a distinct language (the loader already fails fast on duplicates at parse time; this + // re-asserts the invariant on the loaded corpus). Scenario step cases are derived records of a scenario and are + // covered by the scenario check below. + Map> caseGroups = new LinkedHashMap<>(); + for (NegotiationCase testCase : allCaseRecords()) { + if (testCase.id().contains("#step-")) { + continue; + } + caseGroups + .computeIfAbsent(testCase.baseId(), key -> new ArrayList<>()) + .add(testCase); + } + for (List group : caseGroups.values()) { + Set languages = new LinkedHashSet<>(); + String sourceFile = group.get(0).sourceFile(); + for (NegotiationCase expansion : group) { + assertEquals( + sourceFile, + expansion.sourceFile(), + "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); + assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); + } + } + Map> scenarioGroups = new LinkedHashMap<>(); + for (ScenarioCase scenario : CORPUS.scenarios()) { + scenarioGroups + .computeIfAbsent(scenario.baseId(), key -> new ArrayList<>()) + .add(scenario); + } + for (List group : scenarioGroups.values()) { + Set languages = new LinkedHashSet<>(); + String sourceFile = group.get(0).sourceFile(); + for (ScenarioCase expansion : group) { + assertEquals( + sourceFile, + expansion.sourceFile(), + "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); + assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); + } + } + Map> liveGroups = new LinkedHashMap<>(); + for (LiveCase liveCase : CORPUS.liveCases()) { + liveGroups + .computeIfAbsent(liveCase.baseId(), key -> new ArrayList<>()) + .add(liveCase); + } + for (List group : liveGroups.values()) { + Set languages = new LinkedHashSet<>(); + String sourceFile = group.get(0).sourceFile(); + for (LiveCase expansion : group) { + assertEquals( + sourceFile, + expansion.sourceFile(), + "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); + assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); + } + } + } + + @Test + void expectationBlocksAreComplete() { + for (NegotiationCase testCase : allCaseRecords()) { + Expectation expect = testCase.expect(); + if (expect.success()) { + assertTrue( + expect.exception() == null && expect.code() == null, + testCase.errorPrefix() + + ": a success expectation must not carry an exception or an error code"); + } else { + assertTrue( + expect.exception() != null || expect.code() != null, + testCase.errorPrefix() + + ": a failure expectation must name the expected exception or the expected error code"); + } + } + } + + @Test + void everyExpectedErrorCodeIsAKnownNegotiationCode() { + for (NegotiationCase testCase : allCaseRecords()) { + String code = testCase.expect().code(); + if (code != null) { + assertTrue( + CONTENT_LAYER_ERROR_CODES.contains(code), + testCase.errorPrefix() + ": the expected code '" + code + + "' is not one of the content-layer error codes"); + } + } + } + + // ------------------------------------------------------------------ error-code coverage + + @Test + void everyNegotiationErrorCodeIsCoveredByAFailureCase() { + Set covered = new LinkedHashSet<>(); + for (NegotiationCase testCase : allCaseRecords()) { + if (!testCase.expect().success() && testCase.expect().code() != null) { + covered.add(testCase.expect().code()); + } + } + List missing = new ArrayList<>(); + for (String code : CONTENT_LAYER_ERROR_CODES) { + if (covered.contains(code)) { + continue; + } + if (REQUIRE_FULL_ERROR_CODE_COVERAGE) { + fail("no failure case of the corpus expects the error code '" + code + "' yet"); + } + missing.add(code); + } + if (!missing.isEmpty()) { + System.out.println("error codes not yet covered by any failure case: " + missing); + } + } + + // ------------------------------------------------------------------ validate-family code coverage + + @Test + void everyValidateFamilyCodeIsCoveredByAValidateFailureCase() { + Set covered = new LinkedHashSet<>(); + for (NegotiationCase testCase : allCaseRecords()) { + if (testCase.api().family() == NegotiationApi.Family.VALIDATE + && !testCase.expect().success() + && testCase.expect().code() != null) { + covered.add(testCase.expect().code()); + } + } + List missing = new ArrayList<>(); + for (String code : VALIDATE_FAMILY_ERROR_CODES) { + if (covered.contains(code)) { + continue; + } + if (REQUIRE_FULL_VALIDATE_CODE_COVERAGE) { + fail("no validate-family failure case expects the error code '" + code + "' yet"); + } + missing.add(code); + } + if (!missing.isEmpty()) { + System.out.println("validate-family codes not yet covered by any validate failure case: " + missing); + } + } + + // ------------------------------------------------------------------ closed-loop task APIs (Q20-Q23) + + /** + * Q21 full strictness: every one of the three closed-loop task APIs must be exercised by at least one scenario + * step, and the task APIs must never appear as standalone case-file records — the closed loop (task prompt + * generation, peer validation, then negotiation) only has a business meaning inside a scenario, so a standalone + * task case would bypass the 缺参 → 协商 → 补参 → 提取 causal chain the corpus exists to model. + */ + @Test + void everyTaskApiIsExercisedByScenarioStepsOnly() { + List taskApis = new ArrayList<>(); + for (ScenarioCase scenario : CORPUS.scenarios()) { + for (ScenarioCase.ScenarioStep step : scenario.steps()) { + NegotiationApi api = step.caseData().api(); + if (api.family() == NegotiationApi.Family.TASK) { + taskApis.add(api); + } + } + } + for (NegotiationApi api : NegotiationApi.values()) { + if (api.family() != NegotiationApi.Family.TASK) { + continue; + } + assertTrue( + taskApis.contains(api), + "no scenario step exercises the closed-loop task API '" + api.jsonName() + "' yet"); + } + for (NegotiationCase testCase : CORPUS.cases()) { + assertTrue( + testCase.api().family() != NegotiationApi.Family.TASK, + testCase.errorPrefix() + + ": a task API must not appear as a standalone case record, only as a scenario step" + + " (the closed-loop causal chain lives in scenarios)"); + } + } + + /** + * Q21/Q23 role semantics: the workbench (A) generates the task prompt and the OMC (B) validates the peer message, + * so every task-generation step must be acted by role A and every task-validation step by role B (the dual-session + * scenario numbers its roles A1/A2/B1/B2 — the letter prefix decides). + */ + @Test + void taskStepsAreBoundToTheirClosedLoopRoles() { + for (ScenarioCase scenario : CORPUS.scenarios()) { + for (ScenarioCase.ScenarioStep step : scenario.steps()) { + NegotiationApi api = step.caseData().api(); + if (api.family() != NegotiationApi.Family.TASK) { + continue; + } + String role = step.role() == null ? "" : step.role(); + String expectedSide = api.jsonName().startsWith("generate") ? "A" : "B"; + assertTrue( + role.startsWith(expectedSide), + scenario.id() + " step " + step.step() + " (" + api.jsonName() + + ") must be acted by the " + (expectedSide.equals("A") ? "workbench (A)" : "OMC (B)") + + " side, but the role is '" + step.role() + "'"); + } + } + } + + // ------------------------------------------------------------------ drift probes (Q22) + + /** + * Q22 full strictness: a drift probe is a compliant-but-reworded peer message, so it must (a) live in the validate + * family, (b) expect success — a drift probe that fails would reward exact-wording coupling instead of semantic + * compliance — and (c) take the peer message as inline text, never a golden fixture (golden is the regression + * equality anchor, not the validate mainline input). + */ + @Test + void driftProbesAreCompliantPeerTextExpectingSuccess() { + boolean seen = false; + for (NegotiationCase testCase : CORPUS.cases()) { + if (!testCase.baseId().startsWith("VAL-DRIFT-")) { + continue; + } + seen = true; + assertTrue( + testCase.api().family() == NegotiationApi.Family.VALIDATE, + testCase.errorPrefix() + ": a drift probe must be a validate-family case"); + assertTrue( + testCase.expect().success(), + testCase.errorPrefix() + ": a compliant-but-reworded peer message must pass validation"); + assertTrue( + testCase.prompt() instanceof PromptSource.Text, + testCase.errorPrefix() + + ": a drift probe must carry the peer message as inline text (prompt.text)," + + " not a golden fixture or a fromStep reference"); + } + assertTrue(seen, "the corpus must carry at least one VAL-DRIFT drift probe"); + } + + // ------------------------------------------------------------------ live family (live design §2.2) + + /** + * The live records live in their own {@code liveCases} list and are invisible to the six offline contracts above; + * this contract pins their phase-1 scope (Q5/Q6): the {@code LIVE-} id prefix, zh-CN only, and the two task APIs — + * a record outside the scope could never run through the live engine's dispatch. + */ + @Test + void liveRecordsStayInThePhase1Scope() { + assertFalse(CORPUS.liveCases().isEmpty(), "the corpus must carry at least one live record"); + for (LiveCase liveCase : CORPUS.liveCases()) { + assertTrue( + liveCase.baseId().startsWith("LIVE-"), + liveCase.errorPrefix() + ": the live family ids carry the 'LIVE-' prefix"); + assertEquals( + "zh-CN", liveCase.language(), liveCase.errorPrefix() + ": live phase 1 covers zh-CN only (Q6)"); + assertTrue( + liveCase.api() == NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT + || liveCase.api() == NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, + liveCase.errorPrefix() + ": live phase 1 covers the two task APIs (Q5) but the record declares " + + liveCase.api().jsonName()); + } + } + + /** + * Live expectation completeness: the loose block still has to pin something checkable — {@code paramsContains} + * values non-null (a null expected value would never match, the engine asserts non-null extraction), non-blank + * {@code paramsAbsent} slots and {@code promptTextContains} fragments, and a positive {@code maxLlmCalls} bound. + */ + @Test + void liveExpectationBlocksAreComplete() { + for (LiveCase liveCase : CORPUS.liveCases()) { + LiveExpectation expect = liveCase.liveExpect(); + for (Map.Entry entry : expect.paramsContains().entrySet()) { + assertTrue( + entry.getValue() != null, + liveCase.errorPrefix() + ": paramsContains." + entry.getKey() + " must pin a non-null value"); + } + for (String slot : expect.paramsAbsent()) { + assertFalse(slot.isBlank(), liveCase.errorPrefix() + ": paramsAbsent entries must not be blank"); + } + for (String fragment : expect.promptTextContains()) { + assertFalse( + fragment.isBlank(), + liveCase.errorPrefix() + ": promptTextContains fragments must not be blank"); + } + if (expect.maxLlmCalls() != null) { + assertTrue( + expect.maxLlmCalls() > 0, + liveCase.errorPrefix() + ": maxLlmCalls must be a positive upper bound"); + } + } + } + + // ------------------------------------------------------------------ bilingual parity (§7, four definitions) + + /** + * Parity ①: every happy case of the case files declares both languages, so both expansions run and must succeed. + */ + @Test + void happyCasesDeclareBothLanguages() { + for (List group : caseFileGroupsByBaseId().values()) { + if (!group.get(0).expect().success()) { + continue; + } + Set languages = languagesOf(group); + assertEquals( + Set.of("zh-CN", "en-US"), + languages, + group.get(0).errorPrefix() + + ": a happy case must run in both languages (the suite executes every expansion)"); + } + } + + /** + * Parity ②: all language expansions of one record share the identical expectation block, so a failure has one code. + */ + @Test + void failureCasesShareTheIdenticalExpectationAcrossLanguages() { + for (List group : caseFileGroupsByBaseId().values()) { + Expectation reference = group.get(0).expect(); + for (NegotiationCase expansion : group) { + assertEquals( + reference, + expansion.expect(), + expansion.errorPrefix() + + ": every language expansion of one record must carry the identical expectation" + + " block, so a failure case fails with the same error code in both languages"); + } + } + } + + /** Parity ③: the golden fixture directories of the two languages carry the same number of fixtures. */ + @Test + void goldenFixtureCountsMatchAcrossLanguages() { + assertEquals( + countGoldenFixtures("zh-CN"), + countGoldenFixtures("en-US"), + "the golden fixtures must exist in equal numbers for zh-CN and en-US"); + } + + /** + * Parity ④: every record expands exactly once per declared language — no language is silently dropped or doubled. + */ + @Test + void recordsExpandExactlyOncePerDeclaredLanguage() { + for (List group : caseFileGroupsByBaseId().values()) { + Set languages = languagesOf(group); + assertEquals( + languages.size(), + group.size(), + group.get(0).errorPrefix() + ": every declared language must expand into exactly one case"); + } + Map> scenarioGroups = new LinkedHashMap<>(); + for (ScenarioCase scenario : CORPUS.scenarios()) { + scenarioGroups + .computeIfAbsent(scenario.baseId(), key -> new ArrayList<>()) + .add(scenario); + } + for (List group : scenarioGroups.values()) { + Set languages = new LinkedHashSet<>(); + for (ScenarioCase scenario : group) { + assertTrue(languages.add(scenario.language()), "duplicate language expansion of " + scenario.id()); + } + assertEquals( + languages.size(), group.size(), "every declared language must expand into exactly one scenario"); + } + } + + // ------------------------------------------------------------------ business review soft gate (Q19, §8.7) + + /** + * Q19 soft gate (design §8.7): when enabled, every P0 case of the corpus must carry the review status 通过 in the + * {@code docs-local/review/review-status.json} produced by {@code tools/corpus_review.py collect} — the P0 + * review-approval rate must be 100% before a release. + * + *

            The gate is strictly opt-in so it never blocks daily development: it runs only when the system property + * {@code corpus.review.gate} is set to {@code true} AND the status file exists; otherwise the test is skipped via + * {@link Assumptions#assumeTrue(boolean, String)}. Scenario records carry no priority and are therefore out of the + * P0 gate's scope (they stay tracked through the review dashboard itself). + */ + @Test + void p0CasesAreFullyApprovedByTheBusinessReview() throws IOException { + Assumptions.assumeTrue( + Boolean.getBoolean("corpus.review.gate"), + "the business review gate is opt-in (enable with -Dcorpus.review.gate=true)"); + Path statusFile = findReviewStatusFile(); + Assumptions.assumeTrue( + statusFile != null, + "docs-local/review/review-status.json not found (run tools/corpus_review.py collect to produce it)"); + + JsonNode reviewedCases = + new ObjectMapper().readTree(Files.readString(statusFile)).path("cases"); + List notApproved = new ArrayList<>(); + for (List group : caseFileGroupsByBaseId().values()) { + if (!"P0".equals(group.get(0).priority())) { + continue; + } + String baseId = group.get(0).baseId(); + String status = reviewedCases.path(baseId).path("status").asText(""); + if (!APPROVED_STATUS.equals(status)) { + notApproved.add(baseId + " (status: '" + (status.isEmpty() ? "unreviewed" : status) + "')"); + } + } + assertTrue( + notApproved.isEmpty(), + "the P0 review-approval rate must be 100% before a release, not approved yet: " + notApproved); + } + + /** The single review status that counts as approved ({@code tools/corpus_review.py}: 通过 / 有疑问 / 否决). */ + private static final String APPROVED_STATUS = "通过"; + + /** + * Locates {@code docs-local/review/review-status.json} by walking up from the working directory to the repo root. + */ + private static @Nullable Path findReviewStatusFile() { + Path directory = Path.of(".").toAbsolutePath(); + while (directory != null) { + Path candidate = directory.resolve("docs-local").resolve("review").resolve("review-status.json"); + if (Files.isRegularFile(candidate)) { + return candidate; + } + directory = directory.getParent(); + } + return null; + } + + // ------------------------------------------------------------------ helpers + + /** All case records of the corpus: the case-file records plus the scenario step cases. */ + private static List allCaseRecords() { + List records = new ArrayList<>(CORPUS.cases()); + for (ScenarioCase scenario : CORPUS.scenarios()) { + for (ScenarioCase.ScenarioStep step : scenario.steps()) { + records.add(step.caseData()); + } + } + return records; + } + + /** Case-file records grouped by base id (the loader puts scenario step cases into the scenarios, not the cases). */ + private static Map> caseFileGroupsByBaseId() { + Map> groups = new LinkedHashMap<>(); + for (NegotiationCase testCase : CORPUS.cases()) { + groups.computeIfAbsent(testCase.baseId(), key -> new ArrayList<>()).add(testCase); + } + assertFalse(groups.isEmpty(), "the corpus must carry at least one case-file record"); + return groups; + } + + private static Set languagesOf(List group) { + Set languages = new LinkedHashSet<>(); + for (NegotiationCase expansion : group) { + assertTrue( + languages.add(expansion.language()), "duplicate language expansion of " + expansion.errorPrefix()); + } + return languages; + } + + private static int countGoldenFixtures(String language) { + URL url = CorpusContractTest.class.getClassLoader().getResource("golden/" + language); + if (url == null) { + return 0; + } + try { + Path directory = Path.of(url.toURI()); + try (Stream files = Files.list(directory)) { + return (int) files.filter(file -> file.getFileName().toString().endsWith(".md")) + .count(); + } + } catch (URISyntaxException | IOException exception) { + throw new IllegalStateException("Failed to list the golden fixtures of " + language, exception); + } + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java index f2770c6f..9ab924f5 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java @@ -1,475 +1,475 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; -import java.util.Map; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * Meta-meta test of the corpus engine (design document §8.6, Q16): it guards against the deadliest failure mode of a - * data-driven suite — an always-green rubber-stamp engine whose bug lets every case pass silently. - * - *

            Every class of expectation assertion is proven sensitive on a minimal inline sample case: the sample runs green - * with its true expectation, then one flipped expectation value must make the {@link CaseEngine} red, with the case id - * and the JSON path of the flipped expectation in the failure message. The samples are built inline in Java and never - * touch the corpus files. - * - *

            The second guard, {@code engineMustNotCrashOnAnyCorpusCase}, walks the whole loaded corpus: the engine may report - * red (an AssertionError is the engine correctly rejecting a mismatch — that is what the suites turn into test - * failures), but it must never crash with an unexpected exception such as an NPE or a ClassCastException escaping the - * run. A crash is an engine bug; a red assertion is the engine working. - * - * @since 2026-08 - */ -class CorpusSensitivitySelfTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String ZH_CN = "zh-CN"; - - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; - - private static final String INFORMATION_ACCEPT_REJECT_URI = - "Negotiation-T/information-negotiation/accept-reject/v1"; - - private static final String PRIVATE_LINE_COMPLAINT_URI = "Task-T/network-layer/private-line-complaint/v1"; - - /** The workbench raw complaint of the closed loop (样例步骤1): no port name and no complaint category. */ - private static final String COMPLAINT_TEXT = - "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报" + "“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; - - /** - * Slot-extraction payload of the closed loop's step 1 since the task_object slot became required upstream: the task - * object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays portless. - */ - private static final String TASK_SLOTS_OBJECT_PORTLESS = - "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" - + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** Semantic payload of the closed loop's step 2: the port name and the complaint category are missing. */ - private static final String TASK_SEMANTIC_MISSING_PARAMS = - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," - + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; - - /** The rendered task prompt the OMC receives (样例步骤1, shortened): the negotiation-causing message. */ - private static final String TASK_PROMPT_MISSING_PARAMS = - """ - ## 任务类型(Task Type) - 传输专线业务投诉诊断 - - ## 任务对象(Task Object) - 接入端口名称: - - ## 任务上下文(Task Context) - 1. 投诉分类: - 2. 问题发生时间: "2026-05-11T08:21:46Z" - 3. OSS侧事件流水号:"event-id-20260511-09013" - 4. 投诉详情:"深圳访问广州的响应时延从平均12ms骤升至320ms" - """; - - /** The task parameter schema of the closed loop (server-side keys, dictionary §10). */ - private static final String TASK_PARAM_SCHEMA = - "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" - + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" - + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; - - /** Extraction payload mapping to the typed content of the information accept golden fixture. */ - private static final String ACCEPT_PAYLOAD = - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" - + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; - - /** Extraction payload whose mapped content lacks every required slot (fails with negotiation.field_missing). */ - private static final String SLOTS_MISSING_PAYLOAD = "{\"relationship\":null}"; - - /** Semantic verdict payload of a successful validation carrying one business parameter. */ - private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" - + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; - - /** Semantic verdict payload rejecting the message with two slot errors. */ - private static final String SEMANTIC_REJECT_PAYLOAD_TWO_ERRORS = - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" - + "\"accessPort\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":" - + "\"接入端口名称\"}},{\"slot_name\":\"latency_target\",\"code\":" - + "\"negotiation.constraint_conflict\",\"facts\":{\"section_label\":\"时延目标\"," - + "\"reason\":\"latency target is out of range\"}}],\"params\":{}}"; - - private final CaseEngine engine = new CaseEngine(); - - private final ObjectMapper mapper = new ObjectMapper(); - - private JsonNode schema; - - @BeforeEach - void readSchema() throws Exception { - schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); - } - - // ------------------------------------------------------------------ flipped expectations must be red - - @Test - void flippedErrorCodeMustFailTheEngine() { - NegotiationCase trueExpectation = - fromTextCase("FT-SENS-CODE", SLOTS_MISSING_PAYLOAD, failed("negotiation.field_missing", 1, null, null)); - engine.run(trueExpectation); - - NegotiationCase flipped = fromTextCase( - "FT-SENS-CODE-FLIP", SLOTS_MISSING_PAYLOAD, failed("negotiation.invalid_input", 1, null, null)); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); - - assertTrue( - failure.getMessage().contains("FT-SENS-CODE-FLIP") - && failure.getMessage().contains("$.expect.code"), - "a swapped error code must fail with the case id and the JSON path but was: " + failure.getMessage()); - } - - @Test - void flippedLlmCallCountMustFailTheEngine() { - engine.run(fromTextCase("FT-SENS-CALLS", ACCEPT_PAYLOAD, ok(1, "information_accept"))); - - AssertionError tooHigh = assertThrows( - AssertionError.class, - () -> engine.run(fromTextCase("FT-SENS-CALLS-HIGH", ACCEPT_PAYLOAD, ok(2, "information_accept")))); - assertTrue( - tooHigh.getMessage().contains("FT-SENS-CALLS-HIGH") - && tooHigh.getMessage().contains("$.expect.llmCalls"), - "llmCalls + 1 must fail but was: " + tooHigh.getMessage()); - - AssertionError tooLow = assertThrows( - AssertionError.class, - () -> engine.run(fromTextCase("FT-SENS-CALLS-LOW", ACCEPT_PAYLOAD, ok(0, "information_accept")))); - assertTrue( - tooLow.getMessage().contains("FT-SENS-CALLS-LOW") - && tooLow.getMessage().contains("$.expect.llmCalls"), - "llmCalls - 1 must fail but was: " + tooLow.getMessage()); - } - - @Test - void flippedGoldenNameMustFailTheEngine() { - engine.run(fromTextCase("FT-SENS-GOLDEN", ACCEPT_PAYLOAD, ok(1, "information_accept"))); - - NegotiationCase flipped = fromTextCase("FT-SENS-GOLDEN-FLIP", ACCEPT_PAYLOAD, ok(1, "information_reject")); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); - - assertTrue( - failure.getMessage().contains("FT-SENS-GOLDEN-FLIP") - && failure.getMessage().contains("$.expect.promptTextEqualsGolden"), - "a swapped golden fixture must fail but was: " + failure.getMessage()); - } - - @Test - void tamperedParamsMustFailTheEngine() { - engine.run(validateCase( - "VAL-SENS-PARAMS", - Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P533-珠江旧城-PTN3900-23-TPA1EG24-1"))); - - NegotiationCase tampered = validateCase( - "VAL-SENS-PARAMS-FLIP", - Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P781-珠江新城-PTN7900-23-TPA1EG24-17")); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(tampered)); - - assertTrue( - failure.getMessage().contains("VAL-SENS-PARAMS-FLIP") - && failure.getMessage().contains("$.expect.params") - && failure.getMessage().contains("P781-珠江新城-PTN7900-23-TPA1EG24-17"), - "a tampered params value must fail but was: " + failure.getMessage()); - } - - @Test - void flippedOutcomeMustFailTheEngine() { - NegotiationCase successReadAsFailure = - fromTextCase("FT-SENS-OUTCOME-SF", ACCEPT_PAYLOAD, failed("negotiation.invalid_input", 1, null, null)); - AssertionError successFlipped = assertThrows(AssertionError.class, () -> engine.run(successReadAsFailure)); - assertTrue( - successFlipped.getMessage().contains("FT-SENS-OUTCOME-SF") - && successFlipped.getMessage().contains("$.expect.outcome"), - "a success run expected to fail must be red but was: " + successFlipped.getMessage()); - - NegotiationCase failureReadAsSuccess = fromTextCase("FT-SENS-OUTCOME-FS", null, ok(1, "information_accept")); - AssertionError failureFlipped = assertThrows(AssertionError.class, () -> engine.run(failureReadAsSuccess)); - assertTrue( - failureFlipped.getMessage().contains("FT-SENS-OUTCOME-FS") - && failureFlipped.getMessage().contains("$.expect.outcome"), - "a failing run expected to succeed must be red but was: " + failureFlipped.getMessage()); - } - - @Test - void flippedSlotErrorsMustFailTheEngine() { - List both = List.of( - new Expectation.SlotError("accessPort", "negotiation.field_missing"), - new Expectation.SlotError("latency_target", "negotiation.constraint_conflict")); - engine.run(validateCase("VAL-SENS-SLOTS", null, both)); - - NegotiationCase removed = validateCase( - "VAL-SENS-SLOTS-DEL", - null, - List.of(new Expectation.SlotError("accessPort", "negotiation.field_missing"))); - AssertionError removalFailure = assertThrows(AssertionError.class, () -> engine.run(removed)); - assertTrue( - removalFailure.getMessage().contains("VAL-SENS-SLOTS-DEL") - && removalFailure.getMessage().contains("$.expect.slotErrors"), - "a removed slot error must fail but was: " + removalFailure.getMessage()); - - List three = List.of( - new Expectation.SlotError("accessPort", "negotiation.field_missing"), - new Expectation.SlotError("latency_target", "negotiation.constraint_conflict"), - new Expectation.SlotError("round", "negotiation.round_exceeded")); - NegotiationCase added = validateCase("VAL-SENS-SLOTS-ADD", null, three); - AssertionError additionFailure = assertThrows(AssertionError.class, () -> engine.run(added)); - assertTrue( - additionFailure.getMessage().contains("VAL-SENS-SLOTS-ADD") - && additionFailure.getMessage().contains("$.expect.slotErrors"), - "an added slot error must fail but was: " + additionFailure.getMessage()); - } - - // ------------------------------------------------------------------ task-family expectations must be sensitive - - @Test - void flippedMissingParamsMustFailTheEngine() throws Exception { - NegotiationCase trueExpectation = taskValidateCase( - "VAL-SENS-MISSING", - taskOk(1, null, List.of("accessPort", "bizScenario"), Map.of("faultTime", "2026-05-11T08:21:46Z")), - TASK_SEMANTIC_MISSING_PARAMS); - engine.run(trueExpectation); - - NegotiationCase flipped = taskValidateCase( - "VAL-SENS-MISSING-FLIP", - taskOk(1, null, List.of("accessPort"), Map.of("faultTime", "2026-05-11T08:21:46Z")), - TASK_SEMANTIC_MISSING_PARAMS); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); - - assertTrue( - failure.getMessage().contains("VAL-SENS-MISSING-FLIP") - && failure.getMessage().contains("$.expect.missingParams") - && failure.getMessage().contains("bizScenario"), - "a shrunken missing-parameter set must fail but was: " + failure.getMessage()); - } - - @Test - void flippedPromptTextContainsMustFailTheEngine() throws Exception { - NegotiationCase trueExpectation = - taskFromTextCase("VAL-SENS-HEADERS", taskOk(1, List.of("## 任务类型"), null, Map.of())); - engine.run(trueExpectation); - - NegotiationCase flipped = - taskFromTextCase("VAL-SENS-HEADERS-FLIP", taskOk(1, List.of("## 所需信息项"), null, Map.of())); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); - - assertTrue( - failure.getMessage().contains("VAL-SENS-HEADERS-FLIP") - && failure.getMessage().contains("$.expect.promptTextContains"), - "a wrong structural fragment must fail but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ engine crash guard - - @Test - void engineMustNotCrashOnAnyCorpusCase() { - LoadedCorpus corpus = CorpusSuites.loadCorpus(); - CaseEngine caseEngine = new CaseEngine(); - for (NegotiationCase testCase : corpus.cases()) { - try { - caseEngine.run(testCase); - } catch (AssertionError redIsTheEngineWorking) { - // A red assertion is the engine rejecting a mismatch; the suites turn it into a test failure. - } catch (Throwable crash) { - fail("The case engine crashed on " + testCase.errorPrefix() + ": " + crash); - } - } - ScenarioEngine scenarioEngine = new ScenarioEngine(); - for (ScenarioCase scenario : corpus.scenarios()) { - try { - scenarioEngine.runScenario(scenario); - } catch (AssertionError redIsTheEngineWorking) { - // See above: red is not a crash. - } catch (Throwable crash) { - fail("The scenario engine crashed on " + scenario.id() + ": " + crash); - } - } - } - - // ------------------------------------------------------------------ inline sample cases - - private NegotiationCase fromTextCase(String id, @Nullable String payload, Expectation expect) { - LlmScript script = new LlmScript( - null, - List.of( - payload == null - ? new LlmScriptStep.Fail(LlmFailMarker.NON_JSON) - : new LlmScriptStep.Payload(payload))); - return new NegotiationCase( - id + "/zh-CN", - id, - "from-text/sensitivity-probes.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - null, - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - script, - null, - null, - null, - expect); - } - - private NegotiationCase validateCase(String id, @Nullable Map params) { - return validateCase(id, params, null); - } - - private NegotiationCase validateCase( - String id, @Nullable Map params, @Nullable List slotErrors) { - Expectation expect; - if (params == null) { - expect = new Expectation( - false, - null, - "negotiation.semantic_rejected", - List.of(), - slotErrors == null ? List.of() : slotErrors, - 1, - null, - null, - Map.of(), - List.of(), - false); - } else { - expect = new Expectation(true, null, null, List.of(), List.of(), 1, null, null, params, List.of(), false); - } - String payload = params == null ? SEMANTIC_REJECT_PAYLOAD_TWO_ERRORS : SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS; - return new NegotiationCase( - id + "/zh-CN", - id, - "validate/sensitivity-probes.json", - NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, - ZH_CN, - null, - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_PROPOSE_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(payload))), - new PromptSource.Golden("information_propose"), - schema, - null, - expect); - } - - private static Expectation ok(@Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - promptTextEqualsGolden, - null, - Map.of(), - List.of(), - false); - } - - /** Task-family success expectation with the prompt-text fragments and the missing-parameter set. */ - private static Expectation taskOk( - @Nullable Integer llmCalls, - @Nullable List promptTextContains, - @Nullable List missingParams, - Map params) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - null, - null, - params, - List.of(), - false, - promptTextContains == null ? List.of() : promptTextContains, - missingParams, - null); - } - - private NegotiationCase taskFromTextCase(String id, Expectation expect) { - return new NegotiationCase( - id + "/zh-CN", - id, - "task/sensitivity-probes.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - ZH_CN, - null, - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - COMPLAINT_TEXT, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_OBJECT_PORTLESS))), - null, - null, - null, - expect); - } - - private NegotiationCase taskValidateCase(String id, Expectation expect, String semanticPayload) throws Exception { - return new NegotiationCase( - id + "/zh-CN", - id, - "task/sensitivity-probes.json", - NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - ZH_CN, - null, - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), - new PromptSource.Text(TASK_PROMPT_MISSING_PARAMS), - new ObjectMapper().readTree(TASK_PARAM_SCHEMA), - null, - expect); - } - - private static Expectation failed( - @Nullable String code, - @Nullable Integer llmCalls, - @Nullable Map params, - @Nullable List slotErrors) { - return new Expectation( - false, - null, - code, - List.of(), - slotErrors == null ? List.of() : slotErrors, - llmCalls, - null, - null, - params == null ? Map.of() : params, - List.of(), - false); - } -} +package net.openan.a2at.sdk.corpus; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Meta-meta test of the corpus engine (design document §8.6, Q16): it guards against the deadliest failure mode of a + * data-driven suite — an always-green rubber-stamp engine whose bug lets every case pass silently. + * + *

            Every class of expectation assertion is proven sensitive on a minimal inline sample case: the sample runs green + * with its true expectation, then one flipped expectation value must make the {@link CaseEngine} red, with the case id + * and the JSON path of the flipped expectation in the failure message. The samples are built inline in Java and never + * touch the corpus files. + * + *

            The second guard, {@code engineMustNotCrashOnAnyCorpusCase}, walks the whole loaded corpus: the engine may report + * red (an AssertionError is the engine correctly rejecting a mismatch — that is what the suites turn into test + * failures), but it must never crash with an unexpected exception such as an NPE or a ClassCastException escaping the + * run. A crash is an engine bug; a red assertion is the engine working. + * + * @since 2026-08 + */ +class CorpusSensitivitySelfTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String ZH_CN = "zh-CN"; + + private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; + + private static final String INFORMATION_ACCEPT_REJECT_URI = + "Negotiation-T/information-negotiation/accept-reject/v1"; + + private static final String PRIVATE_LINE_COMPLAINT_URI = "Task-T/network-layer/private-line-complaint/v1"; + + /** The workbench raw complaint of the closed loop (样例步骤1): no port name and no complaint category. */ + private static final String COMPLAINT_TEXT = + "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报" + "“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; + + /** + * Slot-extraction payload of the closed loop's step 1 since the task_object slot became required upstream: the task + * object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays portless. + */ + private static final String TASK_SLOTS_OBJECT_PORTLESS = + "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" + + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** Semantic payload of the closed loop's step 2: the port name and the complaint category are missing. */ + private static final String TASK_SEMANTIC_MISSING_PARAMS = + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," + + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; + + /** The rendered task prompt the OMC receives (样例步骤1, shortened): the negotiation-causing message. */ + private static final String TASK_PROMPT_MISSING_PARAMS = + """ + ## 任务类型(Task Type) + 传输专线业务投诉诊断 + + ## 任务对象(Task Object) + 接入端口名称: + + ## 任务上下文(Task Context) + 1. 投诉分类: + 2. 问题发生时间: "2026-05-11T08:21:46Z" + 3. OSS侧事件流水号:"event-id-20260511-09013" + 4. 投诉详情:"深圳访问广州的响应时延从平均12ms骤升至320ms" + """; + + /** The task parameter schema of the closed loop (server-side keys, dictionary §10). */ + private static final String TASK_PARAM_SCHEMA = + "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" + + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" + + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; + + /** Extraction payload mapping to the typed content of the information accept golden fixture. */ + private static final String ACCEPT_PAYLOAD = + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" + + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; + + /** Extraction payload whose mapped content lacks every required slot (fails with negotiation.field_missing). */ + private static final String SLOTS_MISSING_PAYLOAD = "{\"relationship\":null}"; + + /** Semantic verdict payload of a successful validation carrying one business parameter. */ + private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" + + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; + + /** Semantic verdict payload rejecting the message with two slot errors. */ + private static final String SEMANTIC_REJECT_PAYLOAD_TWO_ERRORS = + "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" + + "\"accessPort\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":" + + "\"接入端口名称\"}},{\"slot_name\":\"latency_target\",\"code\":" + + "\"negotiation.constraint_conflict\",\"facts\":{\"section_label\":\"时延目标\"," + + "\"reason\":\"latency target is out of range\"}}],\"params\":{}}"; + + private final CaseEngine engine = new CaseEngine(); + + private final ObjectMapper mapper = new ObjectMapper(); + + private JsonNode schema; + + @BeforeEach + void readSchema() throws Exception { + schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); + } + + // ------------------------------------------------------------------ flipped expectations must be red + + @Test + void flippedErrorCodeMustFailTheEngine() { + NegotiationCase trueExpectation = + fromTextCase("FT-SENS-CODE", SLOTS_MISSING_PAYLOAD, failed("negotiation.field_missing", 1, null, null)); + engine.run(trueExpectation); + + NegotiationCase flipped = fromTextCase( + "FT-SENS-CODE-FLIP", SLOTS_MISSING_PAYLOAD, failed("negotiation.invalid_input", 1, null, null)); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); + + assertTrue( + failure.getMessage().contains("FT-SENS-CODE-FLIP") + && failure.getMessage().contains("$.expect.code"), + "a swapped error code must fail with the case id and the JSON path but was: " + failure.getMessage()); + } + + @Test + void flippedLlmCallCountMustFailTheEngine() { + engine.run(fromTextCase("FT-SENS-CALLS", ACCEPT_PAYLOAD, ok(1, "information_accept"))); + + AssertionError tooHigh = assertThrows( + AssertionError.class, + () -> engine.run(fromTextCase("FT-SENS-CALLS-HIGH", ACCEPT_PAYLOAD, ok(2, "information_accept")))); + assertTrue( + tooHigh.getMessage().contains("FT-SENS-CALLS-HIGH") + && tooHigh.getMessage().contains("$.expect.llmCalls"), + "llmCalls + 1 must fail but was: " + tooHigh.getMessage()); + + AssertionError tooLow = assertThrows( + AssertionError.class, + () -> engine.run(fromTextCase("FT-SENS-CALLS-LOW", ACCEPT_PAYLOAD, ok(0, "information_accept")))); + assertTrue( + tooLow.getMessage().contains("FT-SENS-CALLS-LOW") + && tooLow.getMessage().contains("$.expect.llmCalls"), + "llmCalls - 1 must fail but was: " + tooLow.getMessage()); + } + + @Test + void flippedGoldenNameMustFailTheEngine() { + engine.run(fromTextCase("FT-SENS-GOLDEN", ACCEPT_PAYLOAD, ok(1, "information_accept"))); + + NegotiationCase flipped = fromTextCase("FT-SENS-GOLDEN-FLIP", ACCEPT_PAYLOAD, ok(1, "information_reject")); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); + + assertTrue( + failure.getMessage().contains("FT-SENS-GOLDEN-FLIP") + && failure.getMessage().contains("$.expect.promptTextEqualsGolden"), + "a swapped golden fixture must fail but was: " + failure.getMessage()); + } + + @Test + void tamperedParamsMustFailTheEngine() { + engine.run(validateCase( + "VAL-SENS-PARAMS", + Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P533-珠江旧城-PTN3900-23-TPA1EG24-1"))); + + NegotiationCase tampered = validateCase( + "VAL-SENS-PARAMS-FLIP", + Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P781-珠江新城-PTN7900-23-TPA1EG24-17")); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(tampered)); + + assertTrue( + failure.getMessage().contains("VAL-SENS-PARAMS-FLIP") + && failure.getMessage().contains("$.expect.params") + && failure.getMessage().contains("P781-珠江新城-PTN7900-23-TPA1EG24-17"), + "a tampered params value must fail but was: " + failure.getMessage()); + } + + @Test + void flippedOutcomeMustFailTheEngine() { + NegotiationCase successReadAsFailure = + fromTextCase("FT-SENS-OUTCOME-SF", ACCEPT_PAYLOAD, failed("negotiation.invalid_input", 1, null, null)); + AssertionError successFlipped = assertThrows(AssertionError.class, () -> engine.run(successReadAsFailure)); + assertTrue( + successFlipped.getMessage().contains("FT-SENS-OUTCOME-SF") + && successFlipped.getMessage().contains("$.expect.outcome"), + "a success run expected to fail must be red but was: " + successFlipped.getMessage()); + + NegotiationCase failureReadAsSuccess = fromTextCase("FT-SENS-OUTCOME-FS", null, ok(1, "information_accept")); + AssertionError failureFlipped = assertThrows(AssertionError.class, () -> engine.run(failureReadAsSuccess)); + assertTrue( + failureFlipped.getMessage().contains("FT-SENS-OUTCOME-FS") + && failureFlipped.getMessage().contains("$.expect.outcome"), + "a failing run expected to succeed must be red but was: " + failureFlipped.getMessage()); + } + + @Test + void flippedSlotErrorsMustFailTheEngine() { + List both = List.of( + new Expectation.SlotError("accessPort", "negotiation.field_missing"), + new Expectation.SlotError("latency_target", "negotiation.constraint_conflict")); + engine.run(validateCase("VAL-SENS-SLOTS", null, both)); + + NegotiationCase removed = validateCase( + "VAL-SENS-SLOTS-DEL", + null, + List.of(new Expectation.SlotError("accessPort", "negotiation.field_missing"))); + AssertionError removalFailure = assertThrows(AssertionError.class, () -> engine.run(removed)); + assertTrue( + removalFailure.getMessage().contains("VAL-SENS-SLOTS-DEL") + && removalFailure.getMessage().contains("$.expect.slotErrors"), + "a removed slot error must fail but was: " + removalFailure.getMessage()); + + List three = List.of( + new Expectation.SlotError("accessPort", "negotiation.field_missing"), + new Expectation.SlotError("latency_target", "negotiation.constraint_conflict"), + new Expectation.SlotError("round", "negotiation.round_exceeded")); + NegotiationCase added = validateCase("VAL-SENS-SLOTS-ADD", null, three); + AssertionError additionFailure = assertThrows(AssertionError.class, () -> engine.run(added)); + assertTrue( + additionFailure.getMessage().contains("VAL-SENS-SLOTS-ADD") + && additionFailure.getMessage().contains("$.expect.slotErrors"), + "an added slot error must fail but was: " + additionFailure.getMessage()); + } + + // ------------------------------------------------------------------ task-family expectations must be sensitive + + @Test + void flippedMissingParamsMustFailTheEngine() throws Exception { + NegotiationCase trueExpectation = taskValidateCase( + "VAL-SENS-MISSING", + taskOk(1, null, List.of("accessPort", "bizScenario"), Map.of("faultTime", "2026-05-11T08:21:46Z")), + TASK_SEMANTIC_MISSING_PARAMS); + engine.run(trueExpectation); + + NegotiationCase flipped = taskValidateCase( + "VAL-SENS-MISSING-FLIP", + taskOk(1, null, List.of("accessPort"), Map.of("faultTime", "2026-05-11T08:21:46Z")), + TASK_SEMANTIC_MISSING_PARAMS); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); + + assertTrue( + failure.getMessage().contains("VAL-SENS-MISSING-FLIP") + && failure.getMessage().contains("$.expect.missingParams") + && failure.getMessage().contains("bizScenario"), + "a shrunken missing-parameter set must fail but was: " + failure.getMessage()); + } + + @Test + void flippedPromptTextContainsMustFailTheEngine() throws Exception { + NegotiationCase trueExpectation = + taskFromTextCase("VAL-SENS-HEADERS", taskOk(1, List.of("## 任务类型"), null, Map.of())); + engine.run(trueExpectation); + + NegotiationCase flipped = + taskFromTextCase("VAL-SENS-HEADERS-FLIP", taskOk(1, List.of("## 所需信息项"), null, Map.of())); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); + + assertTrue( + failure.getMessage().contains("VAL-SENS-HEADERS-FLIP") + && failure.getMessage().contains("$.expect.promptTextContains"), + "a wrong structural fragment must fail but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ engine crash guard + + @Test + void engineMustNotCrashOnAnyCorpusCase() { + LoadedCorpus corpus = CorpusSuites.loadCorpus(); + CaseEngine caseEngine = new CaseEngine(); + for (NegotiationCase testCase : corpus.cases()) { + try { + caseEngine.run(testCase); + } catch (AssertionError redIsTheEngineWorking) { + // A red assertion is the engine rejecting a mismatch; the suites turn it into a test failure. + } catch (Throwable crash) { + fail("The case engine crashed on " + testCase.errorPrefix() + ": " + crash); + } + } + ScenarioEngine scenarioEngine = new ScenarioEngine(); + for (ScenarioCase scenario : corpus.scenarios()) { + try { + scenarioEngine.runScenario(scenario); + } catch (AssertionError redIsTheEngineWorking) { + // See above: red is not a crash. + } catch (Throwable crash) { + fail("The scenario engine crashed on " + scenario.id() + ": " + crash); + } + } + } + + // ------------------------------------------------------------------ inline sample cases + + private NegotiationCase fromTextCase(String id, @Nullable String payload, Expectation expect) { + LlmScript script = new LlmScript( + null, + List.of( + payload == null + ? new LlmScriptStep.Fail(LlmFailMarker.NON_JSON) + : new LlmScriptStep.Payload(payload))); + return new NegotiationCase( + id + "/zh-CN", + id, + "from-text/sensitivity-probes.json", + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + ZH_CN, + null, + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + null, + script, + null, + null, + null, + expect); + } + + private NegotiationCase validateCase(String id, @Nullable Map params) { + return validateCase(id, params, null); + } + + private NegotiationCase validateCase( + String id, @Nullable Map params, @Nullable List slotErrors) { + Expectation expect; + if (params == null) { + expect = new Expectation( + false, + null, + "negotiation.semantic_rejected", + List.of(), + slotErrors == null ? List.of() : slotErrors, + 1, + null, + null, + Map.of(), + List.of(), + false); + } else { + expect = new Expectation(true, null, null, List.of(), List.of(), 1, null, null, params, List.of(), false); + } + String payload = params == null ? SEMANTIC_REJECT_PAYLOAD_TWO_ERRORS : SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS; + return new NegotiationCase( + id + "/zh-CN", + id, + "validate/sensitivity-probes.json", + NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, + ZH_CN, + null, + List.of(), + null, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_PROPOSE_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(payload))), + new PromptSource.Golden("information_propose"), + schema, + null, + expect); + } + + private static Expectation ok(@Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + promptTextEqualsGolden, + null, + Map.of(), + List.of(), + false); + } + + /** Task-family success expectation with the prompt-text fragments and the missing-parameter set. */ + private static Expectation taskOk( + @Nullable Integer llmCalls, + @Nullable List promptTextContains, + @Nullable List missingParams, + Map params) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + null, + null, + params, + List.of(), + false, + promptTextContains == null ? List.of() : promptTextContains, + missingParams, + null); + } + + private NegotiationCase taskFromTextCase(String id, Expectation expect) { + return new NegotiationCase( + id + "/zh-CN", + id, + "task/sensitivity-probes.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + ZH_CN, + null, + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + COMPLAINT_TEXT, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_OBJECT_PORTLESS))), + null, + null, + null, + expect); + } + + private NegotiationCase taskValidateCase(String id, Expectation expect, String semanticPayload) throws Exception { + return new NegotiationCase( + id + "/zh-CN", + id, + "task/sensitivity-probes.json", + NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, + ZH_CN, + null, + List.of(), + null, + null, + PRIVATE_LINE_COMPLAINT_URI, + null, + null, + new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), + new PromptSource.Text(TASK_PROMPT_MISSING_PARAMS), + new ObjectMapper().readTree(TASK_PARAM_SCHEMA), + null, + expect); + } + + private static Expectation failed( + @Nullable String code, + @Nullable Integer llmCalls, + @Nullable Map params, + @Nullable List slotErrors) { + return new Expectation( + false, + null, + code, + List.of(), + slotErrors == null ? List.of() : slotErrors, + llmCalls, + null, + null, + params == null ? Map.of() : params, + List.of(), + false); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java index d9ae7998..563789e5 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java @@ -1,128 +1,128 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.List; -import java.util.Map; -import org.jspecify.annotations.Nullable; - -/** - * The validated expectation block of one corpus record, already checked for completeness by the loader. - * - *

            The success and failure shapes are a discriminated union in the corpus JSON; on the loaded record both live on one - * object and the loader guarantees the field combinations that the outcome allows: a failure expectation always carries - * an exception name or an error code, and success-only fields never appear on a failure expectation or the other way - * round. - * - *

            The task-family block (Q21–Q23) adds three success-only fields: {@code promptTextContains} asserts structural - * fragments of the generated task prompt (the {@code ## 任务类型(Task Type)} style section headers), {@code missingParams} - * asserts the exact set of schema parameters the validation found missing — a missing parameter is a null-valued entry - * of the filled parameter data — and {@code paramsFromStep} ties this validation step to the earlier task-validation - * step whose missing parameters this step must have filled. - * - * @param success true for the success outcome, false for the failure outcome - * @param exception expected exception simple name, failure expectations only - * @param code expected error code, failure expectations only - * @param messageContains substrings the failure message must contain, failure expectations only - * @param slotErrors expected slot errors as slot and code pairs, failure expectations only - * @param llmCalls expected exact LLM call count, or null when the record does not pin it - * @param promptTextEqualsGolden golden fixture name the generated prompt text must equal, success expectations only - * @param metadata expected metadata entries, success expectations only - * @param params expected merged parameter map, success expectations only; when {@code missingParams} is present the - * entries are a subset check instead of full-map equality (the missing-parameter set carries the rest) - * @param contracts behavior contract names to assert, both outcomes - * @param differential true when the engine runs the from-text and from-data double assertion, success expectations - * @param promptTextContains substrings the generated task prompt text must contain, success expectations of the task - * family, empty when the record states none - * @param missingParams exact set of parameter names expected to be missing (null-valued) in the filled parameter data, - * success expectations of the task validation; null when the record does not assert the missing set - * @param paramsFromStep number of the earlier scenario step whose missing parameters this step must have filled; - * resolved by the ScenarioEngine, null on standalone cases - * @since 2026-08 - */ -public record Expectation( - boolean success, - @Nullable String exception, - @Nullable String code, - List messageContains, - List slotErrors, - @Nullable Integer llmCalls, - @Nullable String promptTextEqualsGolden, - @Nullable Metadata metadata, - Map params, - List contracts, - boolean differential, - List promptTextContains, - @Nullable List missingParams, - @Nullable Integer paramsFromStep) { - - /** - * Creates an expectation without any task-family assertions; kept for the negotiation-family records and the inline - * sample cases of the engine tests. - * - * @param success true for the success outcome, false for the failure outcome - * @param exception expected exception simple name, failure expectations only - * @param code expected error code, failure expectations only - * @param messageContains substrings the failure message must contain, failure expectations only - * @param slotErrors expected slot errors as slot and code pairs, failure expectations only - * @param llmCalls expected exact LLM call count, or null when the record does not pin it - * @param promptTextEqualsGolden golden fixture name the generated prompt text must equal, success expectations only - * @param metadata expected metadata entries, success expectations only - * @param params expected merged parameter map, success expectations only - * @param contracts behavior contract names to assert, both outcomes - * @param differential true when the engine runs the from-text and from-data double assertion - */ - public Expectation( - boolean success, - @Nullable String exception, - @Nullable String code, - List messageContains, - List slotErrors, - @Nullable Integer llmCalls, - @Nullable String promptTextEqualsGolden, - @Nullable Metadata metadata, - Map params, - List contracts, - boolean differential) { - this( - success, - exception, - code, - messageContains, - slotErrors, - llmCalls, - promptTextEqualsGolden, - metadata, - params, - contracts, - differential, - List.of(), - null, - null); - } - - public Expectation { - messageContains = List.copyOf(messageContains); - slotErrors = List.copyOf(slotErrors); - params = Map.copyOf(params); - contracts = List.copyOf(contracts); - promptTextContains = List.copyOf(promptTextContains); - if (missingParams != null) { - missingParams = List.copyOf(missingParams); - } - } - - /** - * One expected slot error as a slot and code pair. - * - * @param slot slot name such as {@code round} - * @param code expected slot error code such as {@code negotiation.round_exceeded} - */ - public record SlotError(String slot, String code) {} - - /** - * The expected metadata entries of a success expectation. - * - * @param templateUriEcho expected template URI metadata echo, or null when not asserted - * @param contextEcho true when the negotiation context must travel in the metadata, or null when not asserted - */ - public record Metadata(@Nullable String templateUriEcho, @Nullable Boolean contextEcho) {} -} +package net.openan.a2at.sdk.corpus; + +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * The validated expectation block of one corpus record, already checked for completeness by the loader. + * + *

            The success and failure shapes are a discriminated union in the corpus JSON; on the loaded record both live on one + * object and the loader guarantees the field combinations that the outcome allows: a failure expectation always carries + * an exception name or an error code, and success-only fields never appear on a failure expectation or the other way + * round. + * + *

            The task-family block (Q21–Q23) adds three success-only fields: {@code promptTextContains} asserts structural + * fragments of the generated task prompt (the {@code ## 任务类型(Task Type)} style section headers), {@code missingParams} + * asserts the exact set of schema parameters the validation found missing — a missing parameter is a null-valued entry + * of the filled parameter data — and {@code paramsFromStep} ties this validation step to the earlier task-validation + * step whose missing parameters this step must have filled. + * + * @param success true for the success outcome, false for the failure outcome + * @param exception expected exception simple name, failure expectations only + * @param code expected error code, failure expectations only + * @param messageContains substrings the failure message must contain, failure expectations only + * @param slotErrors expected slot errors as slot and code pairs, failure expectations only + * @param llmCalls expected exact LLM call count, or null when the record does not pin it + * @param promptTextEqualsGolden golden fixture name the generated prompt text must equal, success expectations only + * @param metadata expected metadata entries, success expectations only + * @param params expected merged parameter map, success expectations only; when {@code missingParams} is present the + * entries are a subset check instead of full-map equality (the missing-parameter set carries the rest) + * @param contracts behavior contract names to assert, both outcomes + * @param differential true when the engine runs the from-text and from-data double assertion, success expectations + * @param promptTextContains substrings the generated task prompt text must contain, success expectations of the task + * family, empty when the record states none + * @param missingParams exact set of parameter names expected to be missing (null-valued) in the filled parameter data, + * success expectations of the task validation; null when the record does not assert the missing set + * @param paramsFromStep number of the earlier scenario step whose missing parameters this step must have filled; + * resolved by the ScenarioEngine, null on standalone cases + * @since 2026-08 + */ +public record Expectation( + boolean success, + @Nullable String exception, + @Nullable String code, + List messageContains, + List slotErrors, + @Nullable Integer llmCalls, + @Nullable String promptTextEqualsGolden, + @Nullable Metadata metadata, + Map params, + List contracts, + boolean differential, + List promptTextContains, + @Nullable List missingParams, + @Nullable Integer paramsFromStep) { + + /** + * Creates an expectation without any task-family assertions; kept for the negotiation-family records and the inline + * sample cases of the engine tests. + * + * @param success true for the success outcome, false for the failure outcome + * @param exception expected exception simple name, failure expectations only + * @param code expected error code, failure expectations only + * @param messageContains substrings the failure message must contain, failure expectations only + * @param slotErrors expected slot errors as slot and code pairs, failure expectations only + * @param llmCalls expected exact LLM call count, or null when the record does not pin it + * @param promptTextEqualsGolden golden fixture name the generated prompt text must equal, success expectations only + * @param metadata expected metadata entries, success expectations only + * @param params expected merged parameter map, success expectations only + * @param contracts behavior contract names to assert, both outcomes + * @param differential true when the engine runs the from-text and from-data double assertion + */ + public Expectation( + boolean success, + @Nullable String exception, + @Nullable String code, + List messageContains, + List slotErrors, + @Nullable Integer llmCalls, + @Nullable String promptTextEqualsGolden, + @Nullable Metadata metadata, + Map params, + List contracts, + boolean differential) { + this( + success, + exception, + code, + messageContains, + slotErrors, + llmCalls, + promptTextEqualsGolden, + metadata, + params, + contracts, + differential, + List.of(), + null, + null); + } + + public Expectation { + messageContains = List.copyOf(messageContains); + slotErrors = List.copyOf(slotErrors); + params = Map.copyOf(params); + contracts = List.copyOf(contracts); + promptTextContains = List.copyOf(promptTextContains); + if (missingParams != null) { + missingParams = List.copyOf(missingParams); + } + } + + /** + * One expected slot error as a slot and code pair. + * + * @param slot slot name such as {@code round} + * @param code expected slot error code such as {@code negotiation.round_exceeded} + */ + public record SlotError(String slot, String code) {} + + /** + * The expected metadata entries of a success expectation. + * + * @param templateUriEcho expected template URI metadata echo, or null when not asserted + * @param contextEcho true when the negotiation context must travel in the metadata, or null when not asserted + */ + public record Metadata(@Nullable String templateUriEcho, @Nullable Boolean contextEcho) {} +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngine.java index aa48e445..4b32944f 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngine.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngine.java @@ -1,480 +1,480 @@ -package net.openan.a2at.sdk.corpus; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.nio.file.Path; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import org.jspecify.annotations.Nullable; - -/** - * Executes one {@link LiveCase} against the real LLM endpoint of the live family (design document §4) and asserts its - * {@link LiveExpectation}. - * - *

            Everything except the model itself is production assembly: the engine wraps the real provider client built from - * {@link LiveLlmConfig} in a {@link RecordingLLMClient} and hands the wrapper to the {@link TaskApiAssembler} — the - * real facade builders' assembly of the closed loop — driven by the {@code LiveLlmEnvWriter} bridge, so the pipeline - * retry limit comes from the bridge's {@code A2AT_LLM_MAX_ATTEMPTS=3} ([R6]/[R7], not a per-record value); the offline - * engine-test seam keeps the scripted minimal env instead. A generation case runs the whole mini closed loop its - * records assert against: the generated prompt is fed straight into {@code validateTaskPromptAndDataFilling} with the - * record's schema, so {@code paramsContains}/{@code paramsAbsent} judge the validate-step parameter keys and the two - * calls together make up the {@code maxLlmCalls} budget. {@code scenarioCode} compares the scenario segment of the - * template URI the pipeline echoes — the from-text task API resolves the template from the caller's URI and reports no - * separate recognition result. - * - *

            Retry semantics (Q7): only infrastructure failures retry — LLM runtime errors and IO/timeout exceptions, raw or - * wrapped by the pipeline's LLM-infrastructure error codes — at most {@code -Dcorpus.live.infraRetries} times (default - * 2), each attempt on a fresh recording client; an assertion mismatch fails immediately. Every case verdict is appended - * to the run's {@link LiveTranscript} before it surfaces: the transcript keeps the recorded LLM calls of the failed - * attempts too, while {@code maxLlmCalls} bounds only the final, judged attempt. - * - * @since 2026-08 - */ -public final class LiveCaseEngine { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - /** - * System property overriding the infra-retry limit (live design document §4, same property channel as case.filter). - */ - static final String INFRA_RETRIES_PROPERTY = "corpus.live.infraRetries"; - - /** Default of the infra-retry limit: at most two retries, three attempts in total. */ - private static final int DEFAULT_INFRA_RETRIES = 2; - - /** Fixed pipeline retry limit of the live env bridge ([R6]: the record does not declare it). */ - private static final int PIPELINE_MAX_ATTEMPTS = 3; - - private static final int MAX_REPORTED_TEXT_LENGTH = 400; - - private final LLMClient llmClient; - - private final int infraRetries; - - /** The {@code LiveLlmEnvWriter} bridge of the configured run; null on the offline engine-test seam. */ - private final @Nullable Path envFile; - - private final LiveTranscript.Run transcript; - - /** - * Creates the engine for one suite run: the real provider client of the given configuration, the default - * infra-retry limit, the live {@code .env} bridge of the configuration and the run the verdicts are appended to. - * - * @param config resolved live test configuration - * @param transcript run handle the case verdicts are appended to - */ - public LiveCaseEngine(LiveLlmConfig config, LiveTranscript.Run transcript) { - this(LiveLlmConfig.createLlmClient(config), infraRetryLimit(), LiveLlmEnvWriter.envFileFor(config), transcript); - } - - /** - * Creates the engine around an explicit LLM client, env file and infra-retry limit — the seam of the offline engine - * tests, which drive the assertion logic with a stub client and the scripted minimal env instead of a real endpoint - * and the live env bridge. - * - * @param llmClient the LLM client every recording wrapper delegates to - * @param infraRetries number of infrastructure retries after the first attempt (negative values clamp to 0) - * @param envFile the {@code .env} file the task assembly loads (the {@code LiveLlmEnvWriter} bridge), or null to - * fall back to the scripted minimal env with the fixed pipeline retry limit - * @param transcript run handle the case verdicts are appended to - */ - LiveCaseEngine(LLMClient llmClient, int infraRetries, @Nullable Path envFile, LiveTranscript.Run transcript) { - this.llmClient = Objects.requireNonNull(llmClient, "llmClient"); - this.infraRetries = Math.max(0, infraRetries); - this.envFile = envFile; - this.transcript = Objects.requireNonNull(transcript, "transcript"); - } - - /** - * Runs one live case, appends its verdict to the transcript and returns it. - * - * @param testCase expanded live corpus case - * @return the verdict of the executed case - * @throws AssertionError when a live expectation mismatches (the transcript carries the same diff) - * @throws RuntimeException when the engine itself failed, or an infrastructure failure survived all retries - */ - public LiveCaseResult run(LiveCase testCase) { - long startNanos = System.nanoTime(); - int attempts = infraRetries + 1; - List recordedCalls = new ArrayList<>(); - for (int attempt = 1; attempt <= attempts; attempt++) { - RecordingLLMClient recording = new RecordingLLMClient(llmClient); - TaskApiAssembler assembler = envFile == null - ? new TaskApiAssembler(testCase.language(), PIPELINE_MAX_ATTEMPTS, recording) - : new TaskApiAssembler(envFile, recording); - try { - Judged judged = judge(assembler, recording, testCase); - recordedCalls.addAll(recording.snapshot()); - boolean passed = judged.failure() == null; - LiveCaseResult result = asResult( - testCase, - judged, - passed ? LiveCaseResult.Outcome.PASS : LiveCaseResult.Outcome.FAIL, - recordedCalls, - Duration.ofNanos(System.nanoTime() - startNanos).toMillis(), - passed ? null : judged.failure().getMessage()); - transcript.appendCase(result); - if (!passed) { - throw judged.failure(); - } - return result; - } catch (RuntimeException error) { - recordedCalls.addAll(recording.snapshot()); - if (isInfrastructureFailure(error) && attempt < attempts) { - continue; - } - // A surviving failure is recorded as ERROR and rethrown, so the transcript verdict and the build - // verdict agree on the same red instead of the summary counting a skip the build reports as an error. - String failureDiff = isInfrastructureFailure(error) - ? "infrastructure failure after " + attempts + " attempts: " + error - : error.toString(); - LiveCaseResult result = asResult( - testCase, - null, - LiveCaseResult.Outcome.ERROR, - recordedCalls, - Duration.ofNanos(System.nanoTime() - startNanos).toMillis(), - failureDiff); - transcript.appendCase(result); - throw error; - } - } - throw new IllegalStateException( - testCase.errorPrefix() + " the live engine left the retry loop without a verdict"); - } - - // ------------------------------------------------------------------ judging - - /** - * Judges one attempt without throwing for an expectation mismatch: the returned {@link Judged} carries the pipeline - * output plus the first failed assertion, so the transcript can embed what the pipeline actually produced even for - * a failed case. Only infrastructure failures propagate — they are retried by {@link #run}. - */ - private static Judged judge(TaskApiAssembler assembler, RecordingLLMClient recording, LiveCase testCase) { - LiveExpectation expect = testCase.liveExpect(); - Judgement judgement = null; - RuntimeException pipelineFailure = null; - try { - judgement = invoke(assembler, testCase); - } catch (RuntimeException error) { - if (isInfrastructureFailure(error)) { - throw error; - } - pipelineFailure = error; - } - @Nullable AssertionError failure = null; - if (expect.success()) { - failure = pipelineFailure != null - ? fail(testCase, "$.expect.success", "success", "failure (" + pipelineFailure + ")") - : assertValueExpectations(testCase, judgement); - } else if (pipelineFailure == null) { - failure = fail( - testCase, - "$.expect.success", - "failure", - "success (" + render(judgement == null ? null : judgement.params()) + ")"); - } - if (failure == null && expect.maxLlmCalls() != null && recording.callCount() > expect.maxLlmCalls()) { - failure = fail( - testCase, - "$.expect.maxLlmCalls", - "at most " + expect.maxLlmCalls() + " LLM calls", - String.valueOf(recording.callCount())); - } - return new Judged(judgement, failure); - } - - /** - * Asserts the value-level live expectations; returns the first mismatch instead of throwing so the caller keeps the - * pipeline output for the transcript. - */ - private static @Nullable AssertionError assertValueExpectations(LiveCase testCase, Judgement judgement) { - LiveExpectation expect = testCase.liveExpect(); - if (expect.scenarioCode() != null && !expect.scenarioCode().equals(judgement.scenarioCode())) { - return fail( - testCase, "$.expect.scenarioCode", expect.scenarioCode(), String.valueOf(judgement.scenarioCode())); - } - Map params = - judgement.params() == null ? Map.of() : judgement.params().data(); - for (Map.Entry entry : expect.paramsContains().entrySet()) { - Object actual = params.get(entry.getKey()); - if (actual == null) { - return fail( - testCase, - "$.expect.paramsContains", - entry.getKey() + "=" + entry.getValue(), - entry.getKey() + "=" + (params.containsKey(entry.getKey()) ? "null" : "(missing)")); - } - if (!valueMatches(entry.getValue(), actual)) { - return fail( - testCase, - "$.expect.paramsContains", - entry.getKey() + "=" + entry.getValue(), - entry.getKey() + "=" + actual); - } - } - for (String slot : expect.paramsAbsent()) { - if (params.get(slot) != null) { - return fail( - testCase, "$.expect.paramsAbsent", slot + " null or missing", slot + "=" + params.get(slot)); - } - } - for (String fragment : expect.promptTextContains()) { - String promptText = - judgement.message() == null || judgement.message().promptText() == null - ? "" - : judgement.message().promptText(); - if (!normalize(promptText).contains(normalize(fragment))) { - return fail( - testCase, - "$.expect.promptTextContains", - "a prompt text containing '" + fragment + "'", - quoted(truncate(normalize(promptText)))); - } - } - return null; - } - - // ------------------------------------------------------------------ API dispatch - - /** - * Invokes the case's API on the real task assembly: the generation API runs the mini closed loop (generate, then - * validate the generated prompt against the record's schema), the validation API validates the record's prompt. - */ - private static Judgement invoke(TaskApiAssembler assembler, LiveCase testCase) { - TemplateUri templateUri = parseTemplateUri(testCase); - Map schema = requireSchema(testCase); - return switch (testCase.api()) { - case GENERATE_TASK_PROMPT_FROM_TEXT -> { - MetadataContent message = assembler.generateTaskPromptFromText(requireInputText(testCase), templateUri); - FilledParamData params = - assembler.validateTaskPromptAndDataFilling(message.promptText(), schema, templateUri); - yield new Judgement(message, params, scenarioOf(message.templateUri())); - } - case VALIDATE_TASK_PROMPT_AND_DATA_FILLING -> { - FilledParamData params = - assembler.validateTaskPromptAndDataFilling(requirePromptText(testCase), schema, templateUri); - yield new Judgement(null, params, scenarioOf(templateUri.uri())); - } - default -> throw new IllegalStateException( - testCase.errorPrefix() + " the live engine covers the two phase-1 task APIs but got " - + testCase.api().jsonName()); - }; - } - - // ------------------------------------------------------------------ result assembly - - private static LiveCaseResult asResult( - LiveCase testCase, - @Nullable Judged judged, - LiveCaseResult.Outcome outcome, - List recordedCalls, - long durationMs, - @Nullable String failureDiff) { - Judgement judgement = judged == null ? null : judged.judgement(); - return new LiveCaseResult( - testCase.id(), - outcome, - assertionSummary(testCase, recordedCalls.size()), - inputSummaryOf(testCase), - judgement == null ? null : judgement.scenarioCode(), - judgement == null || judgement.params() == null - ? null - : judgement.params().data(), - recordedCalls, - durationMs, - failureDiff); - } - - /** - * Excerpt of what the case fed the pipeline (design document §5: the transcript's input summary): the - * natural-language text of a generation case, or the prompt text a validation case validates, truncated to the - * reporting length. - */ - private static @Nullable String inputSummaryOf(LiveCase testCase) { - String input = testCase.api() == NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT - ? testCase.inputText() - : testCase.prompt() instanceof PromptSource.Text text ? text.text() : null; - return input == null ? null : truncate(normalize(input)); - } - - /** One-line summary of what the case asserted, with the actual LLM call count for quick budget reading. */ - private static String assertionSummary(LiveCase testCase, int llmCalls) { - LiveExpectation expect = testCase.liveExpect(); - StringBuilder summary = new StringBuilder("expect.success=").append(expect.success()); - if (expect.scenarioCode() != null) { - summary.append(" scenarioCode=").append(expect.scenarioCode()); - } - if (!expect.paramsContains().isEmpty()) { - summary.append(" paramsContains=").append(expect.paramsContains().keySet()); - } - if (!expect.paramsAbsent().isEmpty()) { - summary.append(" paramsAbsent=").append(expect.paramsAbsent()); - } - if (!expect.promptTextContains().isEmpty()) { - summary.append(" promptTextContains=") - .append(expect.promptTextContains().size()); - } - if (expect.maxLlmCalls() != null) { - summary.append(" maxLlmCalls<=").append(expect.maxLlmCalls()); - } - return summary.append(" llmCalls=").append(llmCalls).toString(); - } - - // ------------------------------------------------------------------ classification - - /** - * Classifies one pipeline exception as an infrastructure failure (Q7): the LLM client's runtime errors and - * IO/timeout exceptions, raw or anywhere in the pipeline's wrapping cause chain, or an error carrying one of the - * retryable {@code llm.*} codes. Semantic rejections and rule violations are verdicts, not outages, and never - * retry. - */ - private static boolean isInfrastructureFailure(Throwable error) { - for (Throwable current = error; - current != null; - current = current.getCause() == current ? null : current.getCause()) { - if (current instanceof LLMRuntimeError || current instanceof IOException) { - return true; - } - if (current instanceof A2ATError a2atError - && (ErrorCatalog.LLM_INVOCATION_FAILED.getCode().equals(a2atError.getCode()) - || ErrorCatalog.LLM_RESPONSE_INVALID.getCode().equals(a2atError.getCode()) - || ErrorCatalog.LLM_NOT_CONFIGURED.getCode().equals(a2atError.getCode()))) { - return true; - } - } - return false; - } - - private static int infraRetryLimit() { - Integer limit = Integer.getInteger(INFRA_RETRIES_PROPERTY, DEFAULT_INFRA_RETRIES); - return limit == null ? DEFAULT_INFRA_RETRIES : Math.max(0, limit); - } - - // ------------------------------------------------------------------ helpers - - /** - * Compares one expected slot value with the extracted one: strings compare trimmed (model outputs may pad), and a - * number matches its JSON string form so a schema-declared number and its quoted extraction stay equivalent. - */ - private static boolean valueMatches(Object expected, Object actual) { - Double expectedNumber = toDoubleOrNull(expected); - Double actualNumber = toDoubleOrNull(actual); - if (expectedNumber != null - && actualNumber != null - && (expected instanceof Number || actual instanceof Number)) { - return Double.compare(expectedNumber, actualNumber) == 0; - } - return String.valueOf(expected).trim().equals(String.valueOf(actual).trim()); - } - - private static @Nullable Double toDoubleOrNull(Object value) { - if (value instanceof Number number) { - return number.doubleValue(); - } - if (value instanceof String text) { - try { - return Double.valueOf(text.trim()); - } catch (NumberFormatException error) { - return null; - } - } - return null; - } - - /** - * Returns the scenario segment of a template URI — the last path segment of the Task-T layout - * ({@code Task-T/network-layer/private-line-complaint/v1} names the scenario {@code private-line-complaint}); null - * when the URI does not parse. - */ - private static @Nullable String scenarioOf(@Nullable String templateUri) { - return TemplateUri.parse(templateUri) - .map(uri -> uri.pathSegments().get(uri.pathSegments().size() - 1)) - .orElse(null); - } - - private static TemplateUri parseTemplateUri(LiveCase testCase) { - String raw = testCase.templateUri(); - if (raw == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " templateUri: the live task APIs require a template URI"); - } - return TemplateUri.parse(raw) - .orElseThrow(() -> new IllegalStateException( - testCase.errorPrefix() + " templateUri: unparseable template URI " + raw)); - } - - private static String requireInputText(LiveCase testCase) { - String text = testCase.inputText(); - if (text == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " input.text: the task from-text API requires a text input"); - } - return text; - } - - private static String requirePromptText(LiveCase testCase) { - if (testCase.prompt() instanceof PromptSource.Text text) { - return text.text(); - } - throw new IllegalStateException( - testCase.errorPrefix() + " prompt.text: the live validate API requires an inline prompt text"); - } - - private static Map requireSchema(LiveCase testCase) { - JsonNode schema = testCase.schema(); - if (schema == null) { - throw new IllegalStateException(testCase.errorPrefix() + " schema: the live task APIs require a schema"); - } - return MAPPER.convertValue(schema, new TypeReference>() {}); - } - - private static String render(@Nullable FilledParamData params) { - return params == null ? "(no parameter data)" : String.valueOf(params.data()); - } - - private static String normalize(@Nullable String text) { - return text == null ? "" : text.replace("\r\n", "\n"); - } - - private static String truncate(String text) { - return text.length() <= MAX_REPORTED_TEXT_LENGTH ? text : text.substring(0, MAX_REPORTED_TEXT_LENGTH) + "..."; - } - - private static String quoted(@Nullable String text) { - return text == null ? "null" : "<" + text + ">"; - } - - private static AssertionError fail(LiveCase testCase, String jsonPath, String expected, String actual) { - return new AssertionError( - testCase.errorPrefix() + " " + jsonPath + ": expected " + expected + " but was " + actual); - } - - // ------------------------------------------------------------------ internal records - - /** - * The judgement of one attempt: the pipeline output plus the first failed assertion, or null when the attempt - * passed. - */ - private record Judged(@Nullable Judgement judgement, @Nullable AssertionError failure) {} - - /** - * What the pipeline produced for one case: the generated message of a generation case (null for a pure validation - * case or an expected failure), the filled parameter data of the validate step, and the scenario code the pipeline - * reported. - */ - private record Judgement( - @Nullable MetadataContent message, @Nullable FilledParamData params, @Nullable String scenarioCode) {} -} +package net.openan.a2at.sdk.corpus; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.jspecify.annotations.Nullable; + +/** + * Executes one {@link LiveCase} against the real LLM endpoint of the live family (design document §4) and asserts its + * {@link LiveExpectation}. + * + *

            Everything except the model itself is production assembly: the engine wraps the real provider client built from + * {@link LiveLlmConfig} in a {@link RecordingLLMClient} and hands the wrapper to the {@link TaskApiAssembler} — the + * real facade builders' assembly of the closed loop — driven by the {@code LiveLlmEnvWriter} bridge, so the pipeline + * retry limit comes from the bridge's {@code A2AT_LLM_MAX_ATTEMPTS=3} ([R6]/[R7], not a per-record value); the offline + * engine-test seam keeps the scripted minimal env instead. A generation case runs the whole mini closed loop its + * records assert against: the generated prompt is fed straight into {@code validateTaskPromptAndDataFilling} with the + * record's schema, so {@code paramsContains}/{@code paramsAbsent} judge the validate-step parameter keys and the two + * calls together make up the {@code maxLlmCalls} budget. {@code scenarioCode} compares the scenario segment of the + * template URI the pipeline echoes — the from-text task API resolves the template from the caller's URI and reports no + * separate recognition result. + * + *

            Retry semantics (Q7): only infrastructure failures retry — LLM runtime errors and IO/timeout exceptions, raw or + * wrapped by the pipeline's LLM-infrastructure error codes — at most {@code -Dcorpus.live.infraRetries} times (default + * 2), each attempt on a fresh recording client; an assertion mismatch fails immediately. Every case verdict is appended + * to the run's {@link LiveTranscript} before it surfaces: the transcript keeps the recorded LLM calls of the failed + * attempts too, while {@code maxLlmCalls} bounds only the final, judged attempt. + * + * @since 2026-08 + */ +public final class LiveCaseEngine { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * System property overriding the infra-retry limit (live design document §4, same property channel as case.filter). + */ + static final String INFRA_RETRIES_PROPERTY = "corpus.live.infraRetries"; + + /** Default of the infra-retry limit: at most two retries, three attempts in total. */ + private static final int DEFAULT_INFRA_RETRIES = 2; + + /** Fixed pipeline retry limit of the live env bridge ([R6]: the record does not declare it). */ + private static final int PIPELINE_MAX_ATTEMPTS = 3; + + private static final int MAX_REPORTED_TEXT_LENGTH = 400; + + private final LLMClient llmClient; + + private final int infraRetries; + + /** The {@code LiveLlmEnvWriter} bridge of the configured run; null on the offline engine-test seam. */ + private final @Nullable Path envFile; + + private final LiveTranscript.Run transcript; + + /** + * Creates the engine for one suite run: the real provider client of the given configuration, the default + * infra-retry limit, the live {@code .env} bridge of the configuration and the run the verdicts are appended to. + * + * @param config resolved live test configuration + * @param transcript run handle the case verdicts are appended to + */ + public LiveCaseEngine(LiveLlmConfig config, LiveTranscript.Run transcript) { + this(LiveLlmConfig.createLlmClient(config), infraRetryLimit(), LiveLlmEnvWriter.envFileFor(config), transcript); + } + + /** + * Creates the engine around an explicit LLM client, env file and infra-retry limit — the seam of the offline engine + * tests, which drive the assertion logic with a stub client and the scripted minimal env instead of a real endpoint + * and the live env bridge. + * + * @param llmClient the LLM client every recording wrapper delegates to + * @param infraRetries number of infrastructure retries after the first attempt (negative values clamp to 0) + * @param envFile the {@code .env} file the task assembly loads (the {@code LiveLlmEnvWriter} bridge), or null to + * fall back to the scripted minimal env with the fixed pipeline retry limit + * @param transcript run handle the case verdicts are appended to + */ + LiveCaseEngine(LLMClient llmClient, int infraRetries, @Nullable Path envFile, LiveTranscript.Run transcript) { + this.llmClient = Objects.requireNonNull(llmClient, "llmClient"); + this.infraRetries = Math.max(0, infraRetries); + this.envFile = envFile; + this.transcript = Objects.requireNonNull(transcript, "transcript"); + } + + /** + * Runs one live case, appends its verdict to the transcript and returns it. + * + * @param testCase expanded live corpus case + * @return the verdict of the executed case + * @throws AssertionError when a live expectation mismatches (the transcript carries the same diff) + * @throws RuntimeException when the engine itself failed, or an infrastructure failure survived all retries + */ + public LiveCaseResult run(LiveCase testCase) { + long startNanos = System.nanoTime(); + int attempts = infraRetries + 1; + List recordedCalls = new ArrayList<>(); + for (int attempt = 1; attempt <= attempts; attempt++) { + RecordingLLMClient recording = new RecordingLLMClient(llmClient); + TaskApiAssembler assembler = envFile == null + ? new TaskApiAssembler(testCase.language(), PIPELINE_MAX_ATTEMPTS, recording) + : new TaskApiAssembler(envFile, recording); + try { + Judged judged = judge(assembler, recording, testCase); + recordedCalls.addAll(recording.snapshot()); + boolean passed = judged.failure() == null; + LiveCaseResult result = asResult( + testCase, + judged, + passed ? LiveCaseResult.Outcome.PASS : LiveCaseResult.Outcome.FAIL, + recordedCalls, + Duration.ofNanos(System.nanoTime() - startNanos).toMillis(), + passed ? null : judged.failure().getMessage()); + transcript.appendCase(result); + if (!passed) { + throw judged.failure(); + } + return result; + } catch (RuntimeException error) { + recordedCalls.addAll(recording.snapshot()); + if (isInfrastructureFailure(error) && attempt < attempts) { + continue; + } + // A surviving failure is recorded as ERROR and rethrown, so the transcript verdict and the build + // verdict agree on the same red instead of the summary counting a skip the build reports as an error. + String failureDiff = isInfrastructureFailure(error) + ? "infrastructure failure after " + attempts + " attempts: " + error + : error.toString(); + LiveCaseResult result = asResult( + testCase, + null, + LiveCaseResult.Outcome.ERROR, + recordedCalls, + Duration.ofNanos(System.nanoTime() - startNanos).toMillis(), + failureDiff); + transcript.appendCase(result); + throw error; + } + } + throw new IllegalStateException( + testCase.errorPrefix() + " the live engine left the retry loop without a verdict"); + } + + // ------------------------------------------------------------------ judging + + /** + * Judges one attempt without throwing for an expectation mismatch: the returned {@link Judged} carries the pipeline + * output plus the first failed assertion, so the transcript can embed what the pipeline actually produced even for + * a failed case. Only infrastructure failures propagate — they are retried by {@link #run}. + */ + private static Judged judge(TaskApiAssembler assembler, RecordingLLMClient recording, LiveCase testCase) { + LiveExpectation expect = testCase.liveExpect(); + Judgement judgement = null; + RuntimeException pipelineFailure = null; + try { + judgement = invoke(assembler, testCase); + } catch (RuntimeException error) { + if (isInfrastructureFailure(error)) { + throw error; + } + pipelineFailure = error; + } + @Nullable AssertionError failure = null; + if (expect.success()) { + failure = pipelineFailure != null + ? fail(testCase, "$.expect.success", "success", "failure (" + pipelineFailure + ")") + : assertValueExpectations(testCase, judgement); + } else if (pipelineFailure == null) { + failure = fail( + testCase, + "$.expect.success", + "failure", + "success (" + render(judgement == null ? null : judgement.params()) + ")"); + } + if (failure == null && expect.maxLlmCalls() != null && recording.callCount() > expect.maxLlmCalls()) { + failure = fail( + testCase, + "$.expect.maxLlmCalls", + "at most " + expect.maxLlmCalls() + " LLM calls", + String.valueOf(recording.callCount())); + } + return new Judged(judgement, failure); + } + + /** + * Asserts the value-level live expectations; returns the first mismatch instead of throwing so the caller keeps the + * pipeline output for the transcript. + */ + private static @Nullable AssertionError assertValueExpectations(LiveCase testCase, Judgement judgement) { + LiveExpectation expect = testCase.liveExpect(); + if (expect.scenarioCode() != null && !expect.scenarioCode().equals(judgement.scenarioCode())) { + return fail( + testCase, "$.expect.scenarioCode", expect.scenarioCode(), String.valueOf(judgement.scenarioCode())); + } + Map params = + judgement.params() == null ? Map.of() : judgement.params().data(); + for (Map.Entry entry : expect.paramsContains().entrySet()) { + Object actual = params.get(entry.getKey()); + if (actual == null) { + return fail( + testCase, + "$.expect.paramsContains", + entry.getKey() + "=" + entry.getValue(), + entry.getKey() + "=" + (params.containsKey(entry.getKey()) ? "null" : "(missing)")); + } + if (!valueMatches(entry.getValue(), actual)) { + return fail( + testCase, + "$.expect.paramsContains", + entry.getKey() + "=" + entry.getValue(), + entry.getKey() + "=" + actual); + } + } + for (String slot : expect.paramsAbsent()) { + if (params.get(slot) != null) { + return fail( + testCase, "$.expect.paramsAbsent", slot + " null or missing", slot + "=" + params.get(slot)); + } + } + for (String fragment : expect.promptTextContains()) { + String promptText = + judgement.message() == null || judgement.message().promptText() == null + ? "" + : judgement.message().promptText(); + if (!normalize(promptText).contains(normalize(fragment))) { + return fail( + testCase, + "$.expect.promptTextContains", + "a prompt text containing '" + fragment + "'", + quoted(truncate(normalize(promptText)))); + } + } + return null; + } + + // ------------------------------------------------------------------ API dispatch + + /** + * Invokes the case's API on the real task assembly: the generation API runs the mini closed loop (generate, then + * validate the generated prompt against the record's schema), the validation API validates the record's prompt. + */ + private static Judgement invoke(TaskApiAssembler assembler, LiveCase testCase) { + TemplateUri templateUri = parseTemplateUri(testCase); + Map schema = requireSchema(testCase); + return switch (testCase.api()) { + case GENERATE_TASK_PROMPT_FROM_TEXT -> { + MetadataContent message = assembler.generateTaskPromptFromText(requireInputText(testCase), templateUri); + FilledParamData params = + assembler.validateTaskPromptAndDataFilling(message.promptText(), schema, templateUri); + yield new Judgement(message, params, scenarioOf(message.templateUri())); + } + case VALIDATE_TASK_PROMPT_AND_DATA_FILLING -> { + FilledParamData params = + assembler.validateTaskPromptAndDataFilling(requirePromptText(testCase), schema, templateUri); + yield new Judgement(null, params, scenarioOf(templateUri.uri())); + } + default -> throw new IllegalStateException( + testCase.errorPrefix() + " the live engine covers the two phase-1 task APIs but got " + + testCase.api().jsonName()); + }; + } + + // ------------------------------------------------------------------ result assembly + + private static LiveCaseResult asResult( + LiveCase testCase, + @Nullable Judged judged, + LiveCaseResult.Outcome outcome, + List recordedCalls, + long durationMs, + @Nullable String failureDiff) { + Judgement judgement = judged == null ? null : judged.judgement(); + return new LiveCaseResult( + testCase.id(), + outcome, + assertionSummary(testCase, recordedCalls.size()), + inputSummaryOf(testCase), + judgement == null ? null : judgement.scenarioCode(), + judgement == null || judgement.params() == null + ? null + : judgement.params().data(), + recordedCalls, + durationMs, + failureDiff); + } + + /** + * Excerpt of what the case fed the pipeline (design document §5: the transcript's input summary): the + * natural-language text of a generation case, or the prompt text a validation case validates, truncated to the + * reporting length. + */ + private static @Nullable String inputSummaryOf(LiveCase testCase) { + String input = testCase.api() == NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT + ? testCase.inputText() + : testCase.prompt() instanceof PromptSource.Text text ? text.text() : null; + return input == null ? null : truncate(normalize(input)); + } + + /** One-line summary of what the case asserted, with the actual LLM call count for quick budget reading. */ + private static String assertionSummary(LiveCase testCase, int llmCalls) { + LiveExpectation expect = testCase.liveExpect(); + StringBuilder summary = new StringBuilder("expect.success=").append(expect.success()); + if (expect.scenarioCode() != null) { + summary.append(" scenarioCode=").append(expect.scenarioCode()); + } + if (!expect.paramsContains().isEmpty()) { + summary.append(" paramsContains=").append(expect.paramsContains().keySet()); + } + if (!expect.paramsAbsent().isEmpty()) { + summary.append(" paramsAbsent=").append(expect.paramsAbsent()); + } + if (!expect.promptTextContains().isEmpty()) { + summary.append(" promptTextContains=") + .append(expect.promptTextContains().size()); + } + if (expect.maxLlmCalls() != null) { + summary.append(" maxLlmCalls<=").append(expect.maxLlmCalls()); + } + return summary.append(" llmCalls=").append(llmCalls).toString(); + } + + // ------------------------------------------------------------------ classification + + /** + * Classifies one pipeline exception as an infrastructure failure (Q7): the LLM client's runtime errors and + * IO/timeout exceptions, raw or anywhere in the pipeline's wrapping cause chain, or an error carrying one of the + * retryable {@code llm.*} codes. Semantic rejections and rule violations are verdicts, not outages, and never + * retry. + */ + private static boolean isInfrastructureFailure(Throwable error) { + for (Throwable current = error; + current != null; + current = current.getCause() == current ? null : current.getCause()) { + if (current instanceof LLMRuntimeError || current instanceof IOException) { + return true; + } + if (current instanceof A2ATError a2atError + && (ErrorCatalog.LLM_INVOCATION_FAILED.getCode().equals(a2atError.getCode()) + || ErrorCatalog.LLM_RESPONSE_INVALID.getCode().equals(a2atError.getCode()) + || ErrorCatalog.LLM_NOT_CONFIGURED.getCode().equals(a2atError.getCode()))) { + return true; + } + } + return false; + } + + private static int infraRetryLimit() { + Integer limit = Integer.getInteger(INFRA_RETRIES_PROPERTY, DEFAULT_INFRA_RETRIES); + return limit == null ? DEFAULT_INFRA_RETRIES : Math.max(0, limit); + } + + // ------------------------------------------------------------------ helpers + + /** + * Compares one expected slot value with the extracted one: strings compare trimmed (model outputs may pad), and a + * number matches its JSON string form so a schema-declared number and its quoted extraction stay equivalent. + */ + private static boolean valueMatches(Object expected, Object actual) { + Double expectedNumber = toDoubleOrNull(expected); + Double actualNumber = toDoubleOrNull(actual); + if (expectedNumber != null + && actualNumber != null + && (expected instanceof Number || actual instanceof Number)) { + return Double.compare(expectedNumber, actualNumber) == 0; + } + return String.valueOf(expected).trim().equals(String.valueOf(actual).trim()); + } + + private static @Nullable Double toDoubleOrNull(Object value) { + if (value instanceof Number number) { + return number.doubleValue(); + } + if (value instanceof String text) { + try { + return Double.valueOf(text.trim()); + } catch (NumberFormatException error) { + return null; + } + } + return null; + } + + /** + * Returns the scenario segment of a template URI — the last path segment of the Task-T layout + * ({@code Task-T/network-layer/private-line-complaint/v1} names the scenario {@code private-line-complaint}); null + * when the URI does not parse. + */ + private static @Nullable String scenarioOf(@Nullable String templateUri) { + return TemplateUri.parse(templateUri) + .map(uri -> uri.pathSegments().get(uri.pathSegments().size() - 1)) + .orElse(null); + } + + private static TemplateUri parseTemplateUri(LiveCase testCase) { + String raw = testCase.templateUri(); + if (raw == null) { + throw new IllegalStateException( + testCase.errorPrefix() + " templateUri: the live task APIs require a template URI"); + } + return TemplateUri.parse(raw) + .orElseThrow(() -> new IllegalStateException( + testCase.errorPrefix() + " templateUri: unparseable template URI " + raw)); + } + + private static String requireInputText(LiveCase testCase) { + String text = testCase.inputText(); + if (text == null) { + throw new IllegalStateException( + testCase.errorPrefix() + " input.text: the task from-text API requires a text input"); + } + return text; + } + + private static String requirePromptText(LiveCase testCase) { + if (testCase.prompt() instanceof PromptSource.Text text) { + return text.text(); + } + throw new IllegalStateException( + testCase.errorPrefix() + " prompt.text: the live validate API requires an inline prompt text"); + } + + private static Map requireSchema(LiveCase testCase) { + JsonNode schema = testCase.schema(); + if (schema == null) { + throw new IllegalStateException(testCase.errorPrefix() + " schema: the live task APIs require a schema"); + } + return MAPPER.convertValue(schema, new TypeReference>() {}); + } + + private static String render(@Nullable FilledParamData params) { + return params == null ? "(no parameter data)" : String.valueOf(params.data()); + } + + private static String normalize(@Nullable String text) { + return text == null ? "" : text.replace("\r\n", "\n"); + } + + private static String truncate(String text) { + return text.length() <= MAX_REPORTED_TEXT_LENGTH ? text : text.substring(0, MAX_REPORTED_TEXT_LENGTH) + "..."; + } + + private static String quoted(@Nullable String text) { + return text == null ? "null" : "<" + text + ">"; + } + + private static AssertionError fail(LiveCase testCase, String jsonPath, String expected, String actual) { + return new AssertionError( + testCase.errorPrefix() + " " + jsonPath + ": expected " + expected + " but was " + actual); + } + + // ------------------------------------------------------------------ internal records + + /** + * The judgement of one attempt: the pipeline output plus the first failed assertion, or null when the attempt + * passed. + */ + private record Judged(@Nullable Judgement judgement, @Nullable AssertionError failure) {} + + /** + * What the pipeline produced for one case: the generated message of a generation case (null for a pure validation + * case or an expected failure), the filled parameter data of the validate step, and the scenario code the pipeline + * reported. + */ + private record Judgement( + @Nullable MetadataContent message, @Nullable FilledParamData params, @Nullable String scenarioCode) {} +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngineTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngineTest.java index 69976023..6378e827 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngineTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngineTest.java @@ -1,282 +1,282 @@ -package net.openan.a2at.sdk.corpus; - -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.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Offline unit tests of the {@link LiveCaseEngine} judging logic: a step-playing stub client drives the real production - * assembly exactly the way a live endpoint would (the scripted responses reuse the response shapes the offline task - * scenarios script), so the expectation assertions, the infra-only retry and the transcript verdicts are verified - * without any endpoint. - * - * @since 2026-08 - */ -class LiveCaseEngineTest { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - /** Slot-extraction response of the generate step, the shape the offline task scenarios script. */ - private static final String SLOT_EXTRACTION_RESPONSE = - """ - {"slots":{"任务对象":"P533-珠江旧城-PTN3900-23-TPA1EG24-1",\ - "任务上下文":"投诉分类:专线质差;问题发生时间:2026-05-11T08:21:46Z;\ - OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的专线时延从平均12ms骤升至320ms"},\ - "slot_errors":[]}"""; - - /** Validate-step response of a semantically compliant prompt with every parameter filled. */ - private static final String VALIDATE_PASS_RESPONSE = - """ - {"semantic_verdict":true,"errors":[],"params":{"accessPort":"P533-珠江旧城-PTN3900-23-TPA1EG24-1",\ - "bizScenario":"专线质差","faultTime":"2026-05-11T08:21:46Z","eventSerialNo":"event-id-20260511-09013"}}"""; - - /** Validate-step response whose two schema-required parameters stay null (the paramsAbsent probes). */ - private static final String VALIDATE_PARTIAL_RESPONSE = - """ - {"semantic_verdict":true,"errors":[],"params":{"accessPort":null,"bizScenario":null,\ - "faultTime":"2026-05-11T08:21:46Z","eventSerialNo":"event-id-20260511-09013"}}"""; - - /** Validate-step response of a semantically contradictory prompt (the semantic-reject probes). */ - private static final String VALIDATE_REJECT_RESPONSE = - """ - {"semantic_verdict":false,"errors":[{"slot_name":"bizScenario","code":"content.semantic_conflict",\ - "facts":{"section_label":"投诉分类","reason":"投诉分类声明为专线质差,投诉详情却描述完全中断"}}],"params":{}}"""; - - /** Hand-authored prompt following the Task-T private-line template structure (the validate family's input). */ - private static final String VALIDATE_PROMPT = - """ - ## 任务类型(Task Type) - 传输专线业务投诉诊断 - - ## 任务描述(Task Description) - 基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。 - - ## 任务目标(Task Target) - 对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。 - - ## 任务对象(Task Object) - 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1 - - ## 任务上下文(Task Context) - 1. 投诉分类:“专线质差” - 2. 问题发生时间:“2026-05-11T08:21:46Z” - 3. OSS侧事件流水号:“event-id-20260511-09013” - 4. 投诉详情:“从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报‘连接超时’。” - - ## 预期输出(Expected Output) - 要求投诉诊断任务的结果包含如下信息: - 1. 诊断结果;参数的取值范围包括:成功、失败;(必选) - 2. 诊断结果详细信息; (必选) - 3. 修复建议; (可选)"""; - - @TempDir - Path tempDir; - - @Test - void generateCaseRunsTheClosedLoopAndAssertsTheKeyFields() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine( - new StepStubClient(List.of(SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), 2, null, run); - LiveCase testCase = generateCase(new LiveExpectation( - true, - "private-line-complaint", - Map.of("accessPort", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "bizScenario", "专线质差"), - List.of(), - List.of("## 任务类型", "event-id-20260511-09013"), - 4)); - - LiveCaseResult result = engine.run(testCase); - - assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); - assertEquals("private-line-complaint", result.scenarioCode()); - assertTrue(result.inputSummary().contains("深圳访问广州的政企专线"), "the verdict carries the input summary"); - assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", result.params().get("accessPort")); - // The generation case is a mini closed loop: one slot-extraction call plus one validate call. - assertEquals(2, result.llmCalls().size()); - LiveRunSummary summary = run.summary(); - assertEquals(1, summary.totalCases()); - assertEquals(1, summary.passCount()); - assertEquals(2, summary.totalLlmCalls()); - } - - @Test - void validateCaseAssertsParamsContainsAndParamsAbsent() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = - new LiveCaseEngine(new StepStubClient(List.of(VALIDATE_PARTIAL_RESPONSE)), 2, null, run); - LiveCase testCase = validateCase(new LiveExpectation( - true, - null, - Map.of("faultTime", "2026-05-11T08:21:46Z", "eventSerialNo", "event-id-20260511-09013"), - List.of("accessPort", "bizScenario"), - List.of(), - 3)); - - LiveCaseResult result = engine.run(testCase); - - assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); - assertEquals(1, result.llmCalls().size()); - assertNull(result.params().get("accessPort")); - assertEquals("event-id-20260511-09013", result.params().get("eventSerialNo")); - assertEquals(1, run.summary().passCount()); - } - - @Test - void expectedFailureVerdictPassesWhenThePipelineRejects() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine(new StepStubClient(List.of(VALIDATE_REJECT_RESPONSE)), 2, null, run); - LiveCase testCase = validateCase(new LiveExpectation(false, null, Map.of(), List.of(), List.of(), 3)); - - LiveCaseResult result = engine.run(testCase); - - assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); - assertEquals(1, result.llmCalls().size()); - assertNull(result.params()); - assertEquals(1, run.summary().passCount()); - } - - @Test - void assertionMismatchFailsWithTheCaseDiff() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine( - new StepStubClient(List.of(SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), 2, null, run); - LiveCase testCase = generateCase(new LiveExpectation( - true, null, Map.of("accessPort", "P999-不存在-PTN0000-0-TPA1EG24-0"), List.of(), List.of(), 4)); - - AssertionError failure = - assertThrows(AssertionError.class, () -> engine.run(testCase), "a paramsContains mismatch is a red"); - - assertTrue(failure.getMessage().contains(testCase.errorPrefix()), failure.getMessage()); - assertTrue(failure.getMessage().contains("$.expect.paramsContains"), failure.getMessage()); - assertEquals(1, run.summary().failCount(), "the failed verdict still lands in the transcript"); - } - - @Test - void infrastructureFailuresRetryOnFreshClients() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine( - new StepStubClient(List.of( - new LLMRuntimeError("endpoint unreachable"), SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), - 2, - null, - run); - LiveCase testCase = - generateCase(new LiveExpectation(true, null, Map.of("bizScenario", "专线质差"), List.of(), List.of(), 4)); - - LiveCaseResult result = engine.run(testCase); - - assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); - // The failed attempt's call is kept in the transcript while the judged attempt made the two closed-loop calls. - assertEquals(3, result.llmCalls().size()); - assertEquals(1, run.summary().passCount()); - assertEquals(3, run.summary().totalLlmCalls()); - } - - @Test - void exhaustedInfrastructureRetriesRecordAnErrorAndStayRed() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine( - new StepStubClient(List.of(new LLMRuntimeError("endpoint unreachable"))), 0, null, run); - LiveCase testCase = - generateCase(new LiveExpectation(true, null, Map.of("bizScenario", "专线质差"), List.of(), List.of(), 4)); - - assertThrows(RuntimeException.class, () -> engine.run(testCase), "a surviving infra failure stays a red"); - - // The transcript verdict matches the build verdict: the rethrown failure is an ERROR, not a skip. - assertEquals(1, run.summary().errorCount(), "the given-up case is recorded as ERROR"); - assertEquals(1, run.summary().totalLlmCalls()); - } - - // ------------------------------------------------------------------ fixtures - - private static LiveCase generateCase(LiveExpectation expect) { - return new LiveCase( - "LIVE-TEST-GEN/zh-CN", - "LIVE-TEST-GEN", - "live/task-apis.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - "zh-CN", - "P0", - List.of("live"), - "engine test case of the generate API", - null, - "Task-T/network-layer/private-line-complaint/v1", - "深圳访问广州的政企专线自5月11日起时延骤升,柜面交易频繁超时。" + "接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1;投诉分类:专线质差。", - null, - complaintParamsSchema(), - expect); - } - - private static LiveCase validateCase(LiveExpectation expect) { - return new LiveCase( - "LIVE-TEST-VAL/zh-CN", - "LIVE-TEST-VAL", - "live/task-apis.json", - NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - "zh-CN", - "P0", - List.of("live"), - "engine test case of the validate API", - null, - "Task-T/network-layer/private-line-complaint/v1", - null, - new PromptSource.Text(VALIDATE_PROMPT), - complaintParamsSchema(), - expect); - } - - /** The real shared complaint schema of the corpus, so the engine tests judge against the actual validate step. */ - private static JsonNode complaintParamsSchema() { - try (InputStream stream = - LiveCaseEngineTest.class.getResourceAsStream("/negotiation-cases/shared/schemas.json")) { - assertNotNull(stream, "the shared schemas of the corpus must be on the test classpath"); - return MAPPER.readTree(stream).path("biz.complaint.params"); - } catch (IOException exception) { - throw new UncheckedIOException("Failed to read the shared corpus schemas", exception); - } - } - - /** - * Step-playing stub client: it answers the canned response (or rethrows the canned exception) of the next script - * step and repeats the last step once the script is exhausted — the offline stand-in for the live endpoint. - */ - private static final class StepStubClient implements LLMClient { - - private final List steps; - - private int cursor; - - StepStubClient(List steps) { - this.steps = List.copyOf(steps); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - Object step = steps.get(Math.min(cursor++, steps.size() - 1)); - if (step instanceof RuntimeException error) { - throw error; - } - return new LLMResponse((String) step, "stub-model", Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sdk.corpus; + +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.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Offline unit tests of the {@link LiveCaseEngine} judging logic: a step-playing stub client drives the real production + * assembly exactly the way a live endpoint would (the scripted responses reuse the response shapes the offline task + * scenarios script), so the expectation assertions, the infra-only retry and the transcript verdicts are verified + * without any endpoint. + * + * @since 2026-08 + */ +class LiveCaseEngineTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** Slot-extraction response of the generate step, the shape the offline task scenarios script. */ + private static final String SLOT_EXTRACTION_RESPONSE = + """ + {"slots":{"任务对象":"P533-珠江旧城-PTN3900-23-TPA1EG24-1",\ + "任务上下文":"投诉分类:专线质差;问题发生时间:2026-05-11T08:21:46Z;\ + OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的专线时延从平均12ms骤升至320ms"},\ + "slot_errors":[]}"""; + + /** Validate-step response of a semantically compliant prompt with every parameter filled. */ + private static final String VALIDATE_PASS_RESPONSE = + """ + {"semantic_verdict":true,"errors":[],"params":{"accessPort":"P533-珠江旧城-PTN3900-23-TPA1EG24-1",\ + "bizScenario":"专线质差","faultTime":"2026-05-11T08:21:46Z","eventSerialNo":"event-id-20260511-09013"}}"""; + + /** Validate-step response whose two schema-required parameters stay null (the paramsAbsent probes). */ + private static final String VALIDATE_PARTIAL_RESPONSE = + """ + {"semantic_verdict":true,"errors":[],"params":{"accessPort":null,"bizScenario":null,\ + "faultTime":"2026-05-11T08:21:46Z","eventSerialNo":"event-id-20260511-09013"}}"""; + + /** Validate-step response of a semantically contradictory prompt (the semantic-reject probes). */ + private static final String VALIDATE_REJECT_RESPONSE = + """ + {"semantic_verdict":false,"errors":[{"slot_name":"bizScenario","code":"content.semantic_conflict",\ + "facts":{"section_label":"投诉分类","reason":"投诉分类声明为专线质差,投诉详情却描述完全中断"}}],"params":{}}"""; + + /** Hand-authored prompt following the Task-T private-line template structure (the validate family's input). */ + private static final String VALIDATE_PROMPT = + """ + ## 任务类型(Task Type) + 传输专线业务投诉诊断 + + ## 任务描述(Task Description) + 基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。 + + ## 任务目标(Task Target) + 对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。 + + ## 任务对象(Task Object) + 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1 + + ## 任务上下文(Task Context) + 1. 投诉分类:“专线质差” + 2. 问题发生时间:“2026-05-11T08:21:46Z” + 3. OSS侧事件流水号:“event-id-20260511-09013” + 4. 投诉详情:“从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报‘连接超时’。” + + ## 预期输出(Expected Output) + 要求投诉诊断任务的结果包含如下信息: + 1. 诊断结果;参数的取值范围包括:成功、失败;(必选) + 2. 诊断结果详细信息; (必选) + 3. 修复建议; (可选)"""; + + @TempDir + Path tempDir; + + @Test + void generateCaseRunsTheClosedLoopAndAssertsTheKeyFields() { + LiveTranscript.Run run = LiveTranscript.createRun(tempDir); + LiveCaseEngine engine = new LiveCaseEngine( + new StepStubClient(List.of(SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), 2, null, run); + LiveCase testCase = generateCase(new LiveExpectation( + true, + "private-line-complaint", + Map.of("accessPort", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "bizScenario", "专线质差"), + List.of(), + List.of("## 任务类型", "event-id-20260511-09013"), + 4)); + + LiveCaseResult result = engine.run(testCase); + + assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); + assertEquals("private-line-complaint", result.scenarioCode()); + assertTrue(result.inputSummary().contains("深圳访问广州的政企专线"), "the verdict carries the input summary"); + assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", result.params().get("accessPort")); + // The generation case is a mini closed loop: one slot-extraction call plus one validate call. + assertEquals(2, result.llmCalls().size()); + LiveRunSummary summary = run.summary(); + assertEquals(1, summary.totalCases()); + assertEquals(1, summary.passCount()); + assertEquals(2, summary.totalLlmCalls()); + } + + @Test + void validateCaseAssertsParamsContainsAndParamsAbsent() { + LiveTranscript.Run run = LiveTranscript.createRun(tempDir); + LiveCaseEngine engine = + new LiveCaseEngine(new StepStubClient(List.of(VALIDATE_PARTIAL_RESPONSE)), 2, null, run); + LiveCase testCase = validateCase(new LiveExpectation( + true, + null, + Map.of("faultTime", "2026-05-11T08:21:46Z", "eventSerialNo", "event-id-20260511-09013"), + List.of("accessPort", "bizScenario"), + List.of(), + 3)); + + LiveCaseResult result = engine.run(testCase); + + assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); + assertEquals(1, result.llmCalls().size()); + assertNull(result.params().get("accessPort")); + assertEquals("event-id-20260511-09013", result.params().get("eventSerialNo")); + assertEquals(1, run.summary().passCount()); + } + + @Test + void expectedFailureVerdictPassesWhenThePipelineRejects() { + LiveTranscript.Run run = LiveTranscript.createRun(tempDir); + LiveCaseEngine engine = new LiveCaseEngine(new StepStubClient(List.of(VALIDATE_REJECT_RESPONSE)), 2, null, run); + LiveCase testCase = validateCase(new LiveExpectation(false, null, Map.of(), List.of(), List.of(), 3)); + + LiveCaseResult result = engine.run(testCase); + + assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); + assertEquals(1, result.llmCalls().size()); + assertNull(result.params()); + assertEquals(1, run.summary().passCount()); + } + + @Test + void assertionMismatchFailsWithTheCaseDiff() { + LiveTranscript.Run run = LiveTranscript.createRun(tempDir); + LiveCaseEngine engine = new LiveCaseEngine( + new StepStubClient(List.of(SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), 2, null, run); + LiveCase testCase = generateCase(new LiveExpectation( + true, null, Map.of("accessPort", "P999-不存在-PTN0000-0-TPA1EG24-0"), List.of(), List.of(), 4)); + + AssertionError failure = + assertThrows(AssertionError.class, () -> engine.run(testCase), "a paramsContains mismatch is a red"); + + assertTrue(failure.getMessage().contains(testCase.errorPrefix()), failure.getMessage()); + assertTrue(failure.getMessage().contains("$.expect.paramsContains"), failure.getMessage()); + assertEquals(1, run.summary().failCount(), "the failed verdict still lands in the transcript"); + } + + @Test + void infrastructureFailuresRetryOnFreshClients() { + LiveTranscript.Run run = LiveTranscript.createRun(tempDir); + LiveCaseEngine engine = new LiveCaseEngine( + new StepStubClient(List.of( + new LLMRuntimeError("endpoint unreachable"), SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), + 2, + null, + run); + LiveCase testCase = + generateCase(new LiveExpectation(true, null, Map.of("bizScenario", "专线质差"), List.of(), List.of(), 4)); + + LiveCaseResult result = engine.run(testCase); + + assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); + // The failed attempt's call is kept in the transcript while the judged attempt made the two closed-loop calls. + assertEquals(3, result.llmCalls().size()); + assertEquals(1, run.summary().passCount()); + assertEquals(3, run.summary().totalLlmCalls()); + } + + @Test + void exhaustedInfrastructureRetriesRecordAnErrorAndStayRed() { + LiveTranscript.Run run = LiveTranscript.createRun(tempDir); + LiveCaseEngine engine = new LiveCaseEngine( + new StepStubClient(List.of(new LLMRuntimeError("endpoint unreachable"))), 0, null, run); + LiveCase testCase = + generateCase(new LiveExpectation(true, null, Map.of("bizScenario", "专线质差"), List.of(), List.of(), 4)); + + assertThrows(RuntimeException.class, () -> engine.run(testCase), "a surviving infra failure stays a red"); + + // The transcript verdict matches the build verdict: the rethrown failure is an ERROR, not a skip. + assertEquals(1, run.summary().errorCount(), "the given-up case is recorded as ERROR"); + assertEquals(1, run.summary().totalLlmCalls()); + } + + // ------------------------------------------------------------------ fixtures + + private static LiveCase generateCase(LiveExpectation expect) { + return new LiveCase( + "LIVE-TEST-GEN/zh-CN", + "LIVE-TEST-GEN", + "live/task-apis.json", + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + "zh-CN", + "P0", + List.of("live"), + "engine test case of the generate API", + null, + "Task-T/network-layer/private-line-complaint/v1", + "深圳访问广州的政企专线自5月11日起时延骤升,柜面交易频繁超时。" + "接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1;投诉分类:专线质差。", + null, + complaintParamsSchema(), + expect); + } + + private static LiveCase validateCase(LiveExpectation expect) { + return new LiveCase( + "LIVE-TEST-VAL/zh-CN", + "LIVE-TEST-VAL", + "live/task-apis.json", + NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, + "zh-CN", + "P0", + List.of("live"), + "engine test case of the validate API", + null, + "Task-T/network-layer/private-line-complaint/v1", + null, + new PromptSource.Text(VALIDATE_PROMPT), + complaintParamsSchema(), + expect); + } + + /** The real shared complaint schema of the corpus, so the engine tests judge against the actual validate step. */ + private static JsonNode complaintParamsSchema() { + try (InputStream stream = + LiveCaseEngineTest.class.getResourceAsStream("/negotiation-cases/shared/schemas.json")) { + assertNotNull(stream, "the shared schemas of the corpus must be on the test classpath"); + return MAPPER.readTree(stream).path("biz.complaint.params"); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to read the shared corpus schemas", exception); + } + } + + /** + * Step-playing stub client: it answers the canned response (or rethrows the canned exception) of the next script + * step and repeats the last step once the script is exhausted — the offline stand-in for the live endpoint. + */ + private static final class StepStubClient implements LLMClient { + + private final List steps; + + private int cursor; + + StepStubClient(List steps) { + this.steps = List.copyOf(steps); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + Object step = steps.get(Math.min(cursor++, steps.size() - 1)); + if (step instanceof RuntimeException error) { + throw error; + } + return new LLMResponse((String) step, "stub-model", Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java index 3e4abee4..e7bfa7cc 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java @@ -1,1130 +1,1130 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -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 java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Smoke test of {@link NegotiationCaseLoader}: a good corpus loads and expands correctly and every format violation - * fails fast with an error naming the file, the record id and the defect. - * - *

            All corpora are written into a temporary directory, so the test does not depend on any corpus file existing. - */ -class NegotiationCaseLoaderTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String SHARED_RESPONSES = - """ - { - "extract.information.accept.full": "{\\"conclusion\\": \\"Accept\\", \\"items\\": []}", - "extract.information.propose.full": "{\\"items\\": [{\\"name\\": \\"节能区域信息\\", \\"value\\": \\"松山湖\\"}]}" - } - """; - - private static final String SHARED_SCHEMAS = - """ - { - "flat": {"type": "object", "properties": {"id": {"type": "string"}}} - } - """; - - // ------------------------------------------------------------------ happy path - - @Test - void loadsAndExpandsAGoodCorpus(@TempDir Path root) throws IOException { - writeGoodCorpus(root); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - assertEquals(3, corpus.cases().size(), "FT-HAPPY-01 expands to two languages, VAL-HAPPY-01 to one"); - assertEquals(1, corpus.scenarios().size()); - - NegotiationCase acceptZh = caseById(corpus, "FT-HAPPY-01/zh-CN"); - NegotiationCase acceptEn = caseById(corpus, "FT-HAPPY-01/en-US"); - NegotiationCase validateZh = caseById(corpus, "VAL-HAPPY-01/zh-CN"); - - assertEquals("FT-HAPPY-01", acceptZh.baseId()); - assertEquals("from-text/happy.json", acceptZh.sourceFile()); - assertEquals(NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, acceptZh.api()); - assertEquals("zh-CN", acceptZh.language()); - assertEquals("P1", acceptZh.priority()); - assertEquals("我确认第一阶段的信息。", acceptZh.inputText()); - assertEquals("I confirm the first-stage information.", acceptEn.inputText()); - assertEquals(new ContextSpec(SESSION_ID, 2, 5), acceptZh.context()); - assertTrue(acceptZh.inputData().isObject(), "the differential typed input data travels as a JSON node"); - assertTrue(acceptZh.expect().success()); - assertTrue(acceptZh.expect().differential()); - assertEquals(2, acceptZh.expect().llmCalls()); - assertEquals("information_accept", acceptZh.expect().promptTextEqualsGolden()); - assertEquals( - "Negotiation-T/information-negotiation/accept-reject/v1", - acceptZh.expect().metadata().templateUriEcho()); - assertEquals(Boolean.TRUE, acceptZh.expect().metadata().contextEcho()); - - LlmScriptStep firstStep = acceptZh.llm().steps().get(0); - LlmScriptStep.Fail failStep = assertInstanceOf(LlmScriptStep.Fail.class, firstStep); - assertEquals(LlmFailMarker.NON_JSON, failStep.marker()); - LlmScriptStep.Payload payloadStep = assertInstanceOf( - LlmScriptStep.Payload.class, acceptZh.llm().steps().get(1)); - assertEquals("{\"conclusion\": \"Accept\", \"items\": []}", payloadStep.json()); - assertEquals(3, acceptZh.llm().maxAttempts()); - - assertEquals(NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, validateZh.api()); - PromptSource.Golden golden = assertInstanceOf(PromptSource.Golden.class, validateZh.prompt()); - assertEquals("information_propose", golden.golden()); - assertEquals(corpus.sharedSchemas().get("flat"), validateZh.schema()); - assertEquals( - Map.of("id", SESSION_ID, "round", 1, "maxRounds", 5), - validateZh.expect().params()); - - ScenarioCase scenario = corpus.scenarios().get(0); - assertEquals("SC-INFO-01/zh-CN", scenario.id()); - assertEquals("SC-INFO-01", scenario.baseId()); - assertEquals(2, scenario.steps().size()); - assertEquals(1, scenario.steps().get(0).step()); - assertEquals("A", scenario.steps().get(0).role()); - assertEquals( - "SC-INFO-01/zh-CN#step-1", scenario.steps().get(0).caseData().id()); - assertEquals( - NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, - scenario.steps().get(0).caseData().api()); - PromptSource.FromStep fromStep = assertInstanceOf( - PromptSource.FromStep.class, scenario.steps().get(1).caseData().prompt()); - assertEquals(1, fromStep.step()); - assertEquals("reject", scenario.expectFlow().terminalCondition()); - assertEquals(1, scenario.expectFlow().roundsUsed()); - assertEquals(Boolean.TRUE, scenario.expectFlow().distinctMessages()); - - NegotiationCase failedStep = scenario.steps().get(1).caseData(); - assertFalse(failedStep.expect().success()); - assertEquals("negotiation.semantic_rejected", failedStep.expect().code()); - assertEquals(1, failedStep.expect().llmCalls()); - LlmScriptStep.Fail llmErrorStep = assertInstanceOf( - LlmScriptStep.Fail.class, failedStep.llm().steps().get(0)); - assertEquals(LlmFailMarker.LLM_ERROR, llmErrorStep.marker()); - } - - @Test - void skipsTheSchemaFileAndLoadsAnEmptyCorpus(@TempDir Path root) throws IOException { - write(root, "corpus-schema.json", "{\"formatVersion\": 1}"); - write(root, "shared/llm-responses.json", SHARED_RESPONSES); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - assertTrue(corpus.cases().isEmpty()); - assertTrue(corpus.scenarios().isEmpty()); - assertEquals(2, corpus.sharedResponses().size(), "the shared files load even without any case file"); - } - - @Test - void rejectsAMissingCorpusRoot(@TempDir Path root) { - CorpusLoadException exception = - assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root.resolve("nope"))); - - assertTrue(exception.getMessage().contains("not an existing directory")); - } - - // ------------------------------------------------------------------ fail-fast violations - - @Test - void failsFastOnAnUnknownKey(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "expect": {"outcome": "success", "expection": "Oops"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("from-text/bad.json"), exception.getMessage()); - assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); - assertTrue(exception.getMessage().contains("expection"), exception.getMessage()); - assertTrue(exception.getMessage().contains("expect"), exception.getMessage()); - } - - @Test - void failsFastOnADanglingRef(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "llm": {"script": [{"$ref": "responses/missing.payload"}]}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("dangling"), exception.getMessage()); - assertTrue(exception.getMessage().contains("missing.payload"), exception.getMessage()); - assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); - } - - @Test - void failsFastOnAnOutOfScopeRef(@TempDir Path root) throws IOException { - write(root, "shared/schemas.json", SHARED_SCHEMAS); - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "llm": {"script": [{"$ref": "schemas/flat"}]}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("out of scope"), exception.getMessage()); - assertTrue(exception.getMessage().contains("responses/"), exception.getMessage()); - } - - @Test - void failsFastOnANestedRef(@TempDir Path root) throws IOException { - write( - root, - "shared/schemas.json", - """ - { - "a": {"$ref": "schemas/b"}, - "b": {"type": "object"} - } - """); - write( - root, - "validate/bad.json", - """ - [ - { - "id": "VAL-BAD-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": {"text": "## 提议"}, - "schema": {"$ref": "schemas/a"}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("nested $ref"), exception.getMessage()); - assertTrue(exception.getMessage().contains("VAL-BAD-01"), exception.getMessage()); - } - - @Test - void failsFastOnACircularRef(@TempDir Path root) throws IOException { - write( - root, - "shared/schemas.json", - """ - { - "a": {"$ref": "schemas/a"} - } - """); - write( - root, - "validate/bad.json", - """ - [ - { - "id": "VAL-BAD-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": {"text": "## 提议"}, - "schema": {"$ref": "schemas/a"}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("circular"), exception.getMessage()); - } - - @Test - void failsFastOnADuplicateId(@TempDir Path root) throws IOException { - write(root, "from-text/one.json", minimalCase("FT-DUP-01")); - write(root, "from-text/two.json", minimalCase("FT-DUP-01")); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("duplicate id 'FT-DUP-01'"), exception.getMessage()); - assertTrue(exception.getMessage().contains("from-text/one.json"), exception.getMessage()); - } - - @Test - void failsFastOnADuplicateExpandedId(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "zh-CN"], - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("duplicate language"), exception.getMessage()); - } - - @Test - void failsFastOnAnIncompleteFailureExpectation(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "expect": {"outcome": "failure"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("failure expectation must name"), exception.getMessage()); - assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); - } - - @Test - void failsFastOnFailureOnlyFieldsOnASuccessExpectation(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "expect": {"outcome": "success", "code": "negotiation.invalid_input"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue( - exception.getMessage().contains("success expectation must not carry failure-only fields"), - exception.getMessage()); - } - - @Test - void failsFastOnAnUnknownApi(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateWhateverFromText", - "languages": ["zh-CN"], - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("unknown api 'generateWhateverFromText'"), exception.getMessage()); - assertTrue(exception.getMessage().contains("generateAcceptFromText"), exception.getMessage()); - } - - @Test - void failsFastOnAnUnknownFailMarker(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "llm": {"script": [{"$fail": "explodes"}]}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("unknown $fail marker 'explodes'"), exception.getMessage()); - assertTrue(exception.getMessage().contains("non-json"), exception.getMessage()); - } - - @Test - void failsFastOnAnUnsupportedLanguage(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh_CN"], - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("unsupported language 'zh_CN'"), exception.getMessage()); - } - - @Test - void failsFastOnAMissingLanguageTextEntry(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "en-US"], - "input": {"text": {"zh-CN": "我确认"}}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("missing the 'en-US' text entry"), exception.getMessage()); - } - - @Test - void failsFastOnNonConsecutiveScenarioSteps(@TempDir Path root) throws IOException { - write( - root, - "scenarios/bad.json", - """ - [ - { - "id": "SC-BAD-01", - "languages": ["zh-CN"], - "steps": [ - { - "step": 2, - "api": "generateProposeFromText", - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "expect": {"outcome": "success"} - } - ] - } - ] - """ - .formatted(SESSION_ID)); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("consecutively from 1"), exception.getMessage()); - assertTrue(exception.getMessage().contains("SC-BAD-01"), exception.getMessage()); - } - - @Test - void failsFastOnAnUnknownKeyInsideAScenarioStep(@TempDir Path root) throws IOException { - write( - root, - "scenarios/bad.json", - """ - [ - { - "id": "SC-BAD-01", - "languages": ["zh-CN"], - "steps": [ - { - "step": 1, - "api": "generateProposeFromText", - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "rol": "A", - "expect": {"outcome": "success"} - } - ] - } - ] - """ - .formatted(SESSION_ID)); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("rol"), exception.getMessage()); - assertTrue(exception.getMessage().contains("SC-BAD-01"), exception.getMessage()); - } - - // ------------------------------------------------------------------ task family format (Q21–Q23) - - @Test - void loadsTaskFamilyRecordsWithTheClosedLoopExpectationFields(@TempDir Path root) throws IOException { - write(root, "shared/llm-responses.json", SHARED_RESPONSES); - write( - root, - "task/happy.json", - """ - [ - { - "id": "TASK-FT-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "summary": "工作台从原始投诉文本生成缺参任务报文", - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"} - }, - "llm": {"script": ["{\\"slots\\": {\\"任务对象\\": \\"\\", \\"任务上下文\\": \\"投诉分类:待补充\\"}, \\"slot_errors\\": []}"]}, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["## 任务类型", "## 任务对象"] - } - } - ] - """); - write( - root, - "scenarios/task-closed-loop.json", - """ - [ - { - "id": "SC-TASK-01", - "languages": ["zh-CN"], - "summary": "任务缺参发现与补参提取的因果闭环", - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateTaskPromptFromText", - "context": null, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"} - }, - "llm": {"script": ["{\\"slots\\": {\\"任务对象\\": \\"\\", \\"任务上下文\\": \\"投诉分类:待补充\\"}, \\"slot_errors\\": []}"]}, - "expect": {"outcome": "success", "llmCalls": 1} - }, - { - "step": 2, - "role": "B", - "api": "validateTaskPromptAndDataFilling", - "context": null, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": {"fromStep": 1}, - "schema": {"type": "object", "properties": {"accessPort": {"type": "string"}}}, - "llm": {"script": ["{\\"semantic_verdict\\":true,\\"errors\\":[],\\"params\\":{\\"accessPort\\":null}}"]}, - "expect": { - "outcome": "success", - "llmCalls": 1, - "missingParams": ["accessPort"], - "params": {"faultTime": "2026-05-11T08:21:46Z"} - } - }, - { - "step": 3, - "role": "B", - "api": "validateAcceptPromptAndDataFilling", - "context": {"id": "%s", "round": 2, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": {"fromStep": 1}, - "schema": {"type": "object"}, - "llm": {"script": ["{\\"semantic_verdict\\":true,\\"errors\\":[],\\"params\\":{}}"]}, - "expect": {"outcome": "success", "llmCalls": 1, "paramsFromStep": 2} - } - ], - "expectFlow": {"terminalCondition": "accept", "missingParamsFilled": 2} - } - ] - """ - .formatted(SESSION_ID)); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - NegotiationCase taskCase = caseById(corpus, "TASK-FT-01/zh-CN"); - assertEquals(NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, taskCase.api()); - assertEquals(List.of("## 任务类型", "## 任务对象"), taskCase.expect().promptTextContains()); - - ScenarioCase scenario = corpus.scenarios().get(0); - assertEquals(Map.of("A", "工作台(client,任务发起/补数方)", "B", "OMC(server,执行/要数方,协商发起方)"), scenario.rolesDesc()); - assertEquals("A=工作台(client,任务发起/补数方)", scenario.describeRole("A")); - NegotiationCase validationStep = scenario.steps().get(1).caseData(); - assertEquals(NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, validationStep.api()); - assertEquals(List.of("accessPort"), validationStep.expect().missingParams()); - assertEquals( - Map.of("faultTime", "2026-05-11T08:21:46Z"), - validationStep.expect().params()); - assertEquals(2, scenario.steps().get(2).caseData().expect().paramsFromStep()); - assertEquals(2, scenario.expectFlow().missingParamsFilled()); - } - - @Test - void failsFastOnTaskSuccessFieldsOnAFailureExpectation(@TempDir Path root) throws IOException { - write( - root, - "task/bad.json", - """ - [ - { - "id": "TASK-BAD-01", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": {"text": "## 任务类型(Task Type)"}, - "schema": {"type": "object"}, - "llm": {"script": ["{}"]}, - "expect": { - "outcome": "failure", - "code": "negotiation.semantic_rejected", - "missingParams": ["accessPort"] - } - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("success-only fields"), exception.getMessage()); - assertTrue(exception.getMessage().contains("missingParams"), exception.getMessage()); - } - - @Test - void failsFastOnAJsonNullInsideParams(@TempDir Path root) throws IOException { - write( - root, - "task/bad.json", - """ - [ - { - "id": "TASK-BAD-02", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": {"text": "## 任务类型(Task Type)"}, - "schema": {"type": "object"}, - "llm": {"script": ["{}"]}, - "expect": { - "outcome": "success", - "params": {"accessPort": null} - } - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue( - exception.getMessage().contains("accessPort") - && exception.getMessage().contains("missingParams"), - "a JSON null param must point at missingParams but was: " + exception.getMessage()); - } - - @Test - void failsFastOnParamsFromStepBelowOne(@TempDir Path root) throws IOException { - write( - root, - "task/bad.json", - """ - [ - { - "id": "TASK-BAD-03", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": {"text": "## 任务类型(Task Type)"}, - "schema": {"type": "object"}, - "llm": {"script": ["{}"]}, - "expect": { - "outcome": "success", - "paramsFromStep": 0 - } - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("paramsFromStep"), exception.getMessage()); - } - - @Test - void failsFastOnARolesDescEntryOutsideTheDeclaredRoles(@TempDir Path root) throws IOException { - write( - root, - "scenarios/bad.json", - """ - [ - { - "id": "SC-BAD-02", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": {"C": "OSS(第三方)"}, - "steps": [ - { - "step": 1, - "api": "generateProposeFromText", - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "expect": {"outcome": "success"} - } - ] - } - ] - """ - .formatted(SESSION_ID)); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue( - exception.getMessage().contains("rolesDesc") - && exception.getMessage().contains("'C'"), - exception.getMessage()); - } - - // ------------------------------------------------------------------ live family (live LLM phase 1) - - @Test - void loadsLiveRecordsIntoTheDedicatedLiveCasesList(@TempDir Path root) throws IOException { - write( - root, - "live/generate.json", - """ - [ - { - "id": "LIVE-GEN-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["live", "scenario-recognition"], - "summary": "明确场景的典型投诉文本", - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"}}, - "expect": { - "success": true, - "scenarioCode": "private-line-complaint", - "paramsContains": {"accessPort": "P533-01"}, - "paramsAbsent": ["faultTime"], - "promptTextContains": ["## instruction", "event-id-20260511-09013"], - "maxLlmCalls": 4 - } - } - ] - """); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - assertEquals(1, corpus.liveCases().size(), "the live record expands once and lands in liveCases"); - assertTrue(corpus.cases().isEmpty(), "a live record never mixes into the offline cases list"); - LiveCase live = corpus.liveCases().get(0); - assertEquals("LIVE-GEN-01/zh-CN", live.id()); - assertEquals("LIVE-GEN-01", live.baseId()); - assertEquals("live/generate.json", live.sourceFile()); - assertEquals(NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, live.api()); - assertEquals("zh-CN", live.language()); - assertEquals("P0", live.priority()); - assertEquals(List.of("live", "scenario-recognition"), live.tags()); - assertEquals("深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。", live.inputText()); - assertTrue(live.liveExpect().success()); - assertEquals("private-line-complaint", live.liveExpect().scenarioCode()); - assertEquals(Map.of("accessPort", "P533-01"), live.liveExpect().paramsContains()); - assertEquals(List.of("faultTime"), live.liveExpect().paramsAbsent()); - assertEquals( - List.of("## instruction", "event-id-20260511-09013"), - live.liveExpect().promptTextContains()); - assertEquals(4, live.liveExpect().maxLlmCalls()); - assertEquals("live/generate.json [LIVE-GEN-01/zh-CN]", live.errorPrefix()); - } - - @Test - void failsFastOnALiveRecordWithoutTheLiveIdPrefix(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "TASK-LIVE-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("LIVE-"), exception.getMessage()); - assertTrue(exception.getMessage().contains("TASK-LIVE-01"), exception.getMessage()); - } - - @Test - void failsFastOnALiveRecordOutsideThePhaseOneLanguages(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "LIVE-BAD-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN", "en-US"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("exactly the languages [zh-CN]"), exception.getMessage()); - } - - @Test - void failsFastOnALiveRecordOutsideTheTaskFamily(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "LIVE-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("live phase 1 supports only"), exception.getMessage()); - assertTrue(exception.getMessage().contains("generateTaskPromptFromText"), exception.getMessage()); - assertTrue(exception.getMessage().contains("validateTaskPromptAndDataFilling"), exception.getMessage()); - } - - @Test - void failsFastOnALiveExpectationWithoutSuccess(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "LIVE-BAD-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升。"}}, - "expect": {"scenarioCode": "private-line-complaint"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("missing required field 'success'"), exception.getMessage()); - } - - @Test - void failsFastOnALiveGenerateRecordWithoutInputText(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "LIVE-BAD-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("input.text"), exception.getMessage()); - assertTrue(exception.getMessage().contains("generateTaskPromptFromText"), exception.getMessage()); - } - - @Test - void failsFastOnALiveRecordWithAGoldenOrFromStepPrompt(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "LIVE-BAD-01", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "prompt": {"golden": "task-happy-zh-CN"}, - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("only the inline prompt.text"), exception.getMessage()); - assertTrue(exception.getMessage().contains("golden"), exception.getMessage()); - } - - @Test - void failsFastOnALiveValidateRecordDeclaringPromptTextContains(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "LIVE-BAD-01", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "prompt": {"text": "## 任务类型(Task Type)"}, - "expect": {"success": true, "promptTextContains": ["## 任务类型"]} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("promptTextContains"), exception.getMessage()); - assertTrue(exception.getMessage().contains("no generated prompt"), exception.getMessage()); - } - - @Test - void defaultsTheLiveMaxLlmCallsToFourWhenTheRecordOmitsIt(@TempDir Path root) throws IOException { - write( - root, - "live/generate.json", - """ - [ - { - "id": "LIVE-GEN-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升。"}}, - "expect": {"success": true} - } - ] - """); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - assertEquals(4, corpus.liveCases().get(0).liveExpect().maxLlmCalls(), "maxLlmCalls defaults to 4"); - } - - @Test - void failsFastOnALiveIdCollidingWithAnOfflineCase(@TempDir Path root) throws IOException { - write(root, "from-text/dup.json", minimalCase("LIVE-DUP-01")); - write( - root, - "live/dup.json", - """ - [ - { - "id": "LIVE-DUP-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("duplicate id 'LIVE-DUP-01'"), exception.getMessage()); - assertTrue(exception.getMessage().contains("from-text/dup.json"), exception.getMessage()); - } - - // ------------------------------------------------------------------ helpers - - private static void write(Path root, String relative, String content) throws IOException { - Path file = root.resolve(relative); - if (file.getParent() != null) { - Files.createDirectories(file.getParent()); - } - Files.writeString(file, content, StandardCharsets.UTF_8); - } - - private static void writeGoodCorpus(Path root) throws IOException { - write(root, "shared/llm-responses.json", SHARED_RESPONSES); - write(root, "shared/schemas.json", SHARED_SCHEMAS); - write( - root, - "from-text/happy.json", - """ - [ - { - "id": "FT-HAPPY-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["happy", "accept-reject"], - "summary": "信息确认后接受,成功", - "context": {"id": "%s", "round": 2, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": {"zh-CN": "我确认第一阶段的信息。", "en-US": "I confirm the first-stage information."}, - "data": {"items": [], "relationship": null} - }, - "llm": { - "maxAttempts": 3, - "script": [{"$fail": "non-json"}, {"$ref": "responses/extract.information.accept.full"}] - }, - "expect": { - "outcome": "success", - "llmCalls": 2, - "promptTextEqualsGolden": "information_accept", - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent"], - "differential": true - } - } - ] - """ - .formatted(SESSION_ID)); - write( - root, - "validate/happy.json", - """ - [ - { - "id": "VAL-HAPPY-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": {"golden": "information_propose"}, - "schema": {"$ref": "schemas/flat"}, - "llm": {"script": ["{\\"verdict\\": true}"]}, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": {"id": "%s", "round": 1, "maxRounds": 5} - } - } - ] - """ - .formatted(SESSION_ID, SESSION_ID)); - write( - root, - "scenarios/flows.json", - """ - [ - { - "id": "SC-INFO-01", - "summary": "提议后语义拒绝", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateProposeFromText", - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": {"text": {"zh-CN": "请提供节能区域信息"}}, - "llm": {"script": [{"$ref": "responses/extract.information.propose.full"}]}, - "expect": {"outcome": "success", "llmCalls": 1} - }, - { - "step": 2, - "role": "B", - "api": "validateProposePromptAndDataFilling", - "prompt": {"fromStep": 1}, - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": {"$ref": "schemas/flat"}, - "llm": {"script": [{"$fail": "llm-error"}]}, - "expect": {"outcome": "failure", "code": "negotiation.semantic_rejected", "llmCalls": 1} - } - ], - "expectFlow": {"terminalCondition": "reject", "roundsUsed": 1, "distinctMessages": true} - } - ] - """ - .formatted(SESSION_ID, SESSION_ID)); - } - - private static String minimalCase(String id) { - return """ - [ - { - "id": "%s", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "context": {"id": "%s", "round": 2, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": {"text": {"zh-CN": "我确认"}}, - "llm": {"script": ["{}"]}, - "expect": {"outcome": "success"} - } - ] - """ - .formatted(id, SESSION_ID); - } - - private static NegotiationCase caseById(LoadedCorpus corpus, String id) { - NegotiationCase found = null; - for (NegotiationCase candidate : corpus.cases()) { - if (candidate.id().equals(id)) { - found = candidate; - } - } - assertNotNull(found, "the corpus must expand the case " + id); - return found; - } -} +package net.openan.a2at.sdk.corpus; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Smoke test of {@link NegotiationCaseLoader}: a good corpus loads and expands correctly and every format violation + * fails fast with an error naming the file, the record id and the defect. + * + *

            All corpora are written into a temporary directory, so the test does not depend on any corpus file existing. + */ +class NegotiationCaseLoaderTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String SHARED_RESPONSES = + """ + { + "extract.information.accept.full": "{\\"conclusion\\": \\"Accept\\", \\"items\\": []}", + "extract.information.propose.full": "{\\"items\\": [{\\"name\\": \\"节能区域信息\\", \\"value\\": \\"松山湖\\"}]}" + } + """; + + private static final String SHARED_SCHEMAS = + """ + { + "flat": {"type": "object", "properties": {"id": {"type": "string"}}} + } + """; + + // ------------------------------------------------------------------ happy path + + @Test + void loadsAndExpandsAGoodCorpus(@TempDir Path root) throws IOException { + writeGoodCorpus(root); + + LoadedCorpus corpus = NegotiationCaseLoader.load(root); + + assertEquals(3, corpus.cases().size(), "FT-HAPPY-01 expands to two languages, VAL-HAPPY-01 to one"); + assertEquals(1, corpus.scenarios().size()); + + NegotiationCase acceptZh = caseById(corpus, "FT-HAPPY-01/zh-CN"); + NegotiationCase acceptEn = caseById(corpus, "FT-HAPPY-01/en-US"); + NegotiationCase validateZh = caseById(corpus, "VAL-HAPPY-01/zh-CN"); + + assertEquals("FT-HAPPY-01", acceptZh.baseId()); + assertEquals("from-text/happy.json", acceptZh.sourceFile()); + assertEquals(NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, acceptZh.api()); + assertEquals("zh-CN", acceptZh.language()); + assertEquals("P1", acceptZh.priority()); + assertEquals("我确认第一阶段的信息。", acceptZh.inputText()); + assertEquals("I confirm the first-stage information.", acceptEn.inputText()); + assertEquals(new ContextSpec(SESSION_ID, 2, 5), acceptZh.context()); + assertTrue(acceptZh.inputData().isObject(), "the differential typed input data travels as a JSON node"); + assertTrue(acceptZh.expect().success()); + assertTrue(acceptZh.expect().differential()); + assertEquals(2, acceptZh.expect().llmCalls()); + assertEquals("information_accept", acceptZh.expect().promptTextEqualsGolden()); + assertEquals( + "Negotiation-T/information-negotiation/accept-reject/v1", + acceptZh.expect().metadata().templateUriEcho()); + assertEquals(Boolean.TRUE, acceptZh.expect().metadata().contextEcho()); + + LlmScriptStep firstStep = acceptZh.llm().steps().get(0); + LlmScriptStep.Fail failStep = assertInstanceOf(LlmScriptStep.Fail.class, firstStep); + assertEquals(LlmFailMarker.NON_JSON, failStep.marker()); + LlmScriptStep.Payload payloadStep = assertInstanceOf( + LlmScriptStep.Payload.class, acceptZh.llm().steps().get(1)); + assertEquals("{\"conclusion\": \"Accept\", \"items\": []}", payloadStep.json()); + assertEquals(3, acceptZh.llm().maxAttempts()); + + assertEquals(NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, validateZh.api()); + PromptSource.Golden golden = assertInstanceOf(PromptSource.Golden.class, validateZh.prompt()); + assertEquals("information_propose", golden.golden()); + assertEquals(corpus.sharedSchemas().get("flat"), validateZh.schema()); + assertEquals( + Map.of("id", SESSION_ID, "round", 1, "maxRounds", 5), + validateZh.expect().params()); + + ScenarioCase scenario = corpus.scenarios().get(0); + assertEquals("SC-INFO-01/zh-CN", scenario.id()); + assertEquals("SC-INFO-01", scenario.baseId()); + assertEquals(2, scenario.steps().size()); + assertEquals(1, scenario.steps().get(0).step()); + assertEquals("A", scenario.steps().get(0).role()); + assertEquals( + "SC-INFO-01/zh-CN#step-1", scenario.steps().get(0).caseData().id()); + assertEquals( + NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, + scenario.steps().get(0).caseData().api()); + PromptSource.FromStep fromStep = assertInstanceOf( + PromptSource.FromStep.class, scenario.steps().get(1).caseData().prompt()); + assertEquals(1, fromStep.step()); + assertEquals("reject", scenario.expectFlow().terminalCondition()); + assertEquals(1, scenario.expectFlow().roundsUsed()); + assertEquals(Boolean.TRUE, scenario.expectFlow().distinctMessages()); + + NegotiationCase failedStep = scenario.steps().get(1).caseData(); + assertFalse(failedStep.expect().success()); + assertEquals("negotiation.semantic_rejected", failedStep.expect().code()); + assertEquals(1, failedStep.expect().llmCalls()); + LlmScriptStep.Fail llmErrorStep = assertInstanceOf( + LlmScriptStep.Fail.class, failedStep.llm().steps().get(0)); + assertEquals(LlmFailMarker.LLM_ERROR, llmErrorStep.marker()); + } + + @Test + void skipsTheSchemaFileAndLoadsAnEmptyCorpus(@TempDir Path root) throws IOException { + write(root, "corpus-schema.json", "{\"formatVersion\": 1}"); + write(root, "shared/llm-responses.json", SHARED_RESPONSES); + + LoadedCorpus corpus = NegotiationCaseLoader.load(root); + + assertTrue(corpus.cases().isEmpty()); + assertTrue(corpus.scenarios().isEmpty()); + assertEquals(2, corpus.sharedResponses().size(), "the shared files load even without any case file"); + } + + @Test + void rejectsAMissingCorpusRoot(@TempDir Path root) { + CorpusLoadException exception = + assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root.resolve("nope"))); + + assertTrue(exception.getMessage().contains("not an existing directory")); + } + + // ------------------------------------------------------------------ fail-fast violations + + @Test + void failsFastOnAnUnknownKey(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "expect": {"outcome": "success", "expection": "Oops"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("from-text/bad.json"), exception.getMessage()); + assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); + assertTrue(exception.getMessage().contains("expection"), exception.getMessage()); + assertTrue(exception.getMessage().contains("expect"), exception.getMessage()); + } + + @Test + void failsFastOnADanglingRef(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "llm": {"script": [{"$ref": "responses/missing.payload"}]}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("dangling"), exception.getMessage()); + assertTrue(exception.getMessage().contains("missing.payload"), exception.getMessage()); + assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); + } + + @Test + void failsFastOnAnOutOfScopeRef(@TempDir Path root) throws IOException { + write(root, "shared/schemas.json", SHARED_SCHEMAS); + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "llm": {"script": [{"$ref": "schemas/flat"}]}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("out of scope"), exception.getMessage()); + assertTrue(exception.getMessage().contains("responses/"), exception.getMessage()); + } + + @Test + void failsFastOnANestedRef(@TempDir Path root) throws IOException { + write( + root, + "shared/schemas.json", + """ + { + "a": {"$ref": "schemas/b"}, + "b": {"type": "object"} + } + """); + write( + root, + "validate/bad.json", + """ + [ + { + "id": "VAL-BAD-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": {"text": "## 提议"}, + "schema": {"$ref": "schemas/a"}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("nested $ref"), exception.getMessage()); + assertTrue(exception.getMessage().contains("VAL-BAD-01"), exception.getMessage()); + } + + @Test + void failsFastOnACircularRef(@TempDir Path root) throws IOException { + write( + root, + "shared/schemas.json", + """ + { + "a": {"$ref": "schemas/a"} + } + """); + write( + root, + "validate/bad.json", + """ + [ + { + "id": "VAL-BAD-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": {"text": "## 提议"}, + "schema": {"$ref": "schemas/a"}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("circular"), exception.getMessage()); + } + + @Test + void failsFastOnADuplicateId(@TempDir Path root) throws IOException { + write(root, "from-text/one.json", minimalCase("FT-DUP-01")); + write(root, "from-text/two.json", minimalCase("FT-DUP-01")); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("duplicate id 'FT-DUP-01'"), exception.getMessage()); + assertTrue(exception.getMessage().contains("from-text/one.json"), exception.getMessage()); + } + + @Test + void failsFastOnADuplicateExpandedId(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "zh-CN"], + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("duplicate language"), exception.getMessage()); + } + + @Test + void failsFastOnAnIncompleteFailureExpectation(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "expect": {"outcome": "failure"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("failure expectation must name"), exception.getMessage()); + assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); + } + + @Test + void failsFastOnFailureOnlyFieldsOnASuccessExpectation(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "expect": {"outcome": "success", "code": "negotiation.invalid_input"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue( + exception.getMessage().contains("success expectation must not carry failure-only fields"), + exception.getMessage()); + } + + @Test + void failsFastOnAnUnknownApi(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateWhateverFromText", + "languages": ["zh-CN"], + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("unknown api 'generateWhateverFromText'"), exception.getMessage()); + assertTrue(exception.getMessage().contains("generateAcceptFromText"), exception.getMessage()); + } + + @Test + void failsFastOnAnUnknownFailMarker(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "llm": {"script": [{"$fail": "explodes"}]}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("unknown $fail marker 'explodes'"), exception.getMessage()); + assertTrue(exception.getMessage().contains("non-json"), exception.getMessage()); + } + + @Test + void failsFastOnAnUnsupportedLanguage(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh_CN"], + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("unsupported language 'zh_CN'"), exception.getMessage()); + } + + @Test + void failsFastOnAMissingLanguageTextEntry(@TempDir Path root) throws IOException { + write( + root, + "from-text/bad.json", + """ + [ + { + "id": "FT-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "en-US"], + "input": {"text": {"zh-CN": "我确认"}}, + "expect": {"outcome": "success"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("missing the 'en-US' text entry"), exception.getMessage()); + } + + @Test + void failsFastOnNonConsecutiveScenarioSteps(@TempDir Path root) throws IOException { + write( + root, + "scenarios/bad.json", + """ + [ + { + "id": "SC-BAD-01", + "languages": ["zh-CN"], + "steps": [ + { + "step": 2, + "api": "generateProposeFromText", + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "expect": {"outcome": "success"} + } + ] + } + ] + """ + .formatted(SESSION_ID)); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("consecutively from 1"), exception.getMessage()); + assertTrue(exception.getMessage().contains("SC-BAD-01"), exception.getMessage()); + } + + @Test + void failsFastOnAnUnknownKeyInsideAScenarioStep(@TempDir Path root) throws IOException { + write( + root, + "scenarios/bad.json", + """ + [ + { + "id": "SC-BAD-01", + "languages": ["zh-CN"], + "steps": [ + { + "step": 1, + "api": "generateProposeFromText", + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "rol": "A", + "expect": {"outcome": "success"} + } + ] + } + ] + """ + .formatted(SESSION_ID)); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("rol"), exception.getMessage()); + assertTrue(exception.getMessage().contains("SC-BAD-01"), exception.getMessage()); + } + + // ------------------------------------------------------------------ task family format (Q21–Q23) + + @Test + void loadsTaskFamilyRecordsWithTheClosedLoopExpectationFields(@TempDir Path root) throws IOException { + write(root, "shared/llm-responses.json", SHARED_RESPONSES); + write( + root, + "task/happy.json", + """ + [ + { + "id": "TASK-FT-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "summary": "工作台从原始投诉文本生成缺参任务报文", + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "input": { + "text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"} + }, + "llm": {"script": ["{\\"slots\\": {\\"任务对象\\": \\"\\", \\"任务上下文\\": \\"投诉分类:待补充\\"}, \\"slot_errors\\": []}"]}, + "expect": { + "outcome": "success", + "llmCalls": 1, + "promptTextContains": ["## 任务类型", "## 任务对象"] + } + } + ] + """); + write( + root, + "scenarios/task-closed-loop.json", + """ + [ + { + "id": "SC-TASK-01", + "languages": ["zh-CN"], + "summary": "任务缺参发现与补参提取的因果闭环", + "roles": ["A", "B"], + "rolesDesc": { + "A": "工作台(client,任务发起/补数方)", + "B": "OMC(server,执行/要数方,协商发起方)" + }, + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateTaskPromptFromText", + "context": null, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "input": { + "text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"} + }, + "llm": {"script": ["{\\"slots\\": {\\"任务对象\\": \\"\\", \\"任务上下文\\": \\"投诉分类:待补充\\"}, \\"slot_errors\\": []}"]}, + "expect": {"outcome": "success", "llmCalls": 1} + }, + { + "step": 2, + "role": "B", + "api": "validateTaskPromptAndDataFilling", + "context": null, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": {"fromStep": 1}, + "schema": {"type": "object", "properties": {"accessPort": {"type": "string"}}}, + "llm": {"script": ["{\\"semantic_verdict\\":true,\\"errors\\":[],\\"params\\":{\\"accessPort\\":null}}"]}, + "expect": { + "outcome": "success", + "llmCalls": 1, + "missingParams": ["accessPort"], + "params": {"faultTime": "2026-05-11T08:21:46Z"} + } + }, + { + "step": 3, + "role": "B", + "api": "validateAcceptPromptAndDataFilling", + "context": {"id": "%s", "round": 2, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "prompt": {"fromStep": 1}, + "schema": {"type": "object"}, + "llm": {"script": ["{\\"semantic_verdict\\":true,\\"errors\\":[],\\"params\\":{}}"]}, + "expect": {"outcome": "success", "llmCalls": 1, "paramsFromStep": 2} + } + ], + "expectFlow": {"terminalCondition": "accept", "missingParamsFilled": 2} + } + ] + """ + .formatted(SESSION_ID)); + + LoadedCorpus corpus = NegotiationCaseLoader.load(root); + + NegotiationCase taskCase = caseById(corpus, "TASK-FT-01/zh-CN"); + assertEquals(NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, taskCase.api()); + assertEquals(List.of("## 任务类型", "## 任务对象"), taskCase.expect().promptTextContains()); + + ScenarioCase scenario = corpus.scenarios().get(0); + assertEquals(Map.of("A", "工作台(client,任务发起/补数方)", "B", "OMC(server,执行/要数方,协商发起方)"), scenario.rolesDesc()); + assertEquals("A=工作台(client,任务发起/补数方)", scenario.describeRole("A")); + NegotiationCase validationStep = scenario.steps().get(1).caseData(); + assertEquals(NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, validationStep.api()); + assertEquals(List.of("accessPort"), validationStep.expect().missingParams()); + assertEquals( + Map.of("faultTime", "2026-05-11T08:21:46Z"), + validationStep.expect().params()); + assertEquals(2, scenario.steps().get(2).caseData().expect().paramsFromStep()); + assertEquals(2, scenario.expectFlow().missingParamsFilled()); + } + + @Test + void failsFastOnTaskSuccessFieldsOnAFailureExpectation(@TempDir Path root) throws IOException { + write( + root, + "task/bad.json", + """ + [ + { + "id": "TASK-BAD-01", + "api": "validateTaskPromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": {"text": "## 任务类型(Task Type)"}, + "schema": {"type": "object"}, + "llm": {"script": ["{}"]}, + "expect": { + "outcome": "failure", + "code": "negotiation.semantic_rejected", + "missingParams": ["accessPort"] + } + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("success-only fields"), exception.getMessage()); + assertTrue(exception.getMessage().contains("missingParams"), exception.getMessage()); + } + + @Test + void failsFastOnAJsonNullInsideParams(@TempDir Path root) throws IOException { + write( + root, + "task/bad.json", + """ + [ + { + "id": "TASK-BAD-02", + "api": "validateTaskPromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": {"text": "## 任务类型(Task Type)"}, + "schema": {"type": "object"}, + "llm": {"script": ["{}"]}, + "expect": { + "outcome": "success", + "params": {"accessPort": null} + } + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue( + exception.getMessage().contains("accessPort") + && exception.getMessage().contains("missingParams"), + "a JSON null param must point at missingParams but was: " + exception.getMessage()); + } + + @Test + void failsFastOnParamsFromStepBelowOne(@TempDir Path root) throws IOException { + write( + root, + "task/bad.json", + """ + [ + { + "id": "TASK-BAD-03", + "api": "validateTaskPromptAndDataFilling", + "languages": ["zh-CN"], + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "prompt": {"text": "## 任务类型(Task Type)"}, + "schema": {"type": "object"}, + "llm": {"script": ["{}"]}, + "expect": { + "outcome": "success", + "paramsFromStep": 0 + } + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("paramsFromStep"), exception.getMessage()); + } + + @Test + void failsFastOnARolesDescEntryOutsideTheDeclaredRoles(@TempDir Path root) throws IOException { + write( + root, + "scenarios/bad.json", + """ + [ + { + "id": "SC-BAD-02", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "rolesDesc": {"C": "OSS(第三方)"}, + "steps": [ + { + "step": 1, + "api": "generateProposeFromText", + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "expect": {"outcome": "success"} + } + ] + } + ] + """ + .formatted(SESSION_ID)); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue( + exception.getMessage().contains("rolesDesc") + && exception.getMessage().contains("'C'"), + exception.getMessage()); + } + + // ------------------------------------------------------------------ live family (live LLM phase 1) + + @Test + void loadsLiveRecordsIntoTheDedicatedLiveCasesList(@TempDir Path root) throws IOException { + write( + root, + "live/generate.json", + """ + [ + { + "id": "LIVE-GEN-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "priority": "P0", + "tags": ["live", "scenario-recognition"], + "summary": "明确场景的典型投诉文本", + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"}}, + "expect": { + "success": true, + "scenarioCode": "private-line-complaint", + "paramsContains": {"accessPort": "P533-01"}, + "paramsAbsent": ["faultTime"], + "promptTextContains": ["## instruction", "event-id-20260511-09013"], + "maxLlmCalls": 4 + } + } + ] + """); + + LoadedCorpus corpus = NegotiationCaseLoader.load(root); + + assertEquals(1, corpus.liveCases().size(), "the live record expands once and lands in liveCases"); + assertTrue(corpus.cases().isEmpty(), "a live record never mixes into the offline cases list"); + LiveCase live = corpus.liveCases().get(0); + assertEquals("LIVE-GEN-01/zh-CN", live.id()); + assertEquals("LIVE-GEN-01", live.baseId()); + assertEquals("live/generate.json", live.sourceFile()); + assertEquals(NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, live.api()); + assertEquals("zh-CN", live.language()); + assertEquals("P0", live.priority()); + assertEquals(List.of("live", "scenario-recognition"), live.tags()); + assertEquals("深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。", live.inputText()); + assertTrue(live.liveExpect().success()); + assertEquals("private-line-complaint", live.liveExpect().scenarioCode()); + assertEquals(Map.of("accessPort", "P533-01"), live.liveExpect().paramsContains()); + assertEquals(List.of("faultTime"), live.liveExpect().paramsAbsent()); + assertEquals( + List.of("## instruction", "event-id-20260511-09013"), + live.liveExpect().promptTextContains()); + assertEquals(4, live.liveExpect().maxLlmCalls()); + assertEquals("live/generate.json [LIVE-GEN-01/zh-CN]", live.errorPrefix()); + } + + @Test + void failsFastOnALiveRecordWithoutTheLiveIdPrefix(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "TASK-LIVE-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "expect": {"success": true} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("LIVE-"), exception.getMessage()); + assertTrue(exception.getMessage().contains("TASK-LIVE-01"), exception.getMessage()); + } + + @Test + void failsFastOnALiveRecordOutsideThePhaseOneLanguages(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "LIVE-BAD-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN", "en-US"], + "expect": {"success": true} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("exactly the languages [zh-CN]"), exception.getMessage()); + } + + @Test + void failsFastOnALiveRecordOutsideTheTaskFamily(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "LIVE-BAD-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "expect": {"success": true} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("live phase 1 supports only"), exception.getMessage()); + assertTrue(exception.getMessage().contains("generateTaskPromptFromText"), exception.getMessage()); + assertTrue(exception.getMessage().contains("validateTaskPromptAndDataFilling"), exception.getMessage()); + } + + @Test + void failsFastOnALiveExpectationWithoutSuccess(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "LIVE-BAD-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升。"}}, + "expect": {"scenarioCode": "private-line-complaint"} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("missing required field 'success'"), exception.getMessage()); + } + + @Test + void failsFastOnALiveGenerateRecordWithoutInputText(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "LIVE-BAD-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "expect": {"success": true} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("input.text"), exception.getMessage()); + assertTrue(exception.getMessage().contains("generateTaskPromptFromText"), exception.getMessage()); + } + + @Test + void failsFastOnALiveRecordWithAGoldenOrFromStepPrompt(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "LIVE-BAD-01", + "api": "validateTaskPromptAndDataFilling", + "languages": ["zh-CN"], + "prompt": {"golden": "task-happy-zh-CN"}, + "expect": {"success": true} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("only the inline prompt.text"), exception.getMessage()); + assertTrue(exception.getMessage().contains("golden"), exception.getMessage()); + } + + @Test + void failsFastOnALiveValidateRecordDeclaringPromptTextContains(@TempDir Path root) throws IOException { + write( + root, + "live/bad.json", + """ + [ + { + "id": "LIVE-BAD-01", + "api": "validateTaskPromptAndDataFilling", + "languages": ["zh-CN"], + "prompt": {"text": "## 任务类型(Task Type)"}, + "expect": {"success": true, "promptTextContains": ["## 任务类型"]} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("promptTextContains"), exception.getMessage()); + assertTrue(exception.getMessage().contains("no generated prompt"), exception.getMessage()); + } + + @Test + void defaultsTheLiveMaxLlmCallsToFourWhenTheRecordOmitsIt(@TempDir Path root) throws IOException { + write( + root, + "live/generate.json", + """ + [ + { + "id": "LIVE-GEN-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升。"}}, + "expect": {"success": true} + } + ] + """); + + LoadedCorpus corpus = NegotiationCaseLoader.load(root); + + assertEquals(4, corpus.liveCases().get(0).liveExpect().maxLlmCalls(), "maxLlmCalls defaults to 4"); + } + + @Test + void failsFastOnALiveIdCollidingWithAnOfflineCase(@TempDir Path root) throws IOException { + write(root, "from-text/dup.json", minimalCase("LIVE-DUP-01")); + write( + root, + "live/dup.json", + """ + [ + { + "id": "LIVE-DUP-01", + "api": "generateTaskPromptFromText", + "languages": ["zh-CN"], + "expect": {"success": true} + } + ] + """); + + CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); + + assertTrue(exception.getMessage().contains("duplicate id 'LIVE-DUP-01'"), exception.getMessage()); + assertTrue(exception.getMessage().contains("from-text/dup.json"), exception.getMessage()); + } + + // ------------------------------------------------------------------ helpers + + private static void write(Path root, String relative, String content) throws IOException { + Path file = root.resolve(relative); + if (file.getParent() != null) { + Files.createDirectories(file.getParent()); + } + Files.writeString(file, content, StandardCharsets.UTF_8); + } + + private static void writeGoodCorpus(Path root) throws IOException { + write(root, "shared/llm-responses.json", SHARED_RESPONSES); + write(root, "shared/schemas.json", SHARED_SCHEMAS); + write( + root, + "from-text/happy.json", + """ + [ + { + "id": "FT-HAPPY-01", + "api": "generateAcceptFromText", + "languages": ["zh-CN", "en-US"], + "priority": "P1", + "tags": ["happy", "accept-reject"], + "summary": "信息确认后接受,成功", + "context": {"id": "%s", "round": 2, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": { + "text": {"zh-CN": "我确认第一阶段的信息。", "en-US": "I confirm the first-stage information."}, + "data": {"items": [], "relationship": null} + }, + "llm": { + "maxAttempts": 3, + "script": [{"$fail": "non-json"}, {"$ref": "responses/extract.information.accept.full"}] + }, + "expect": { + "outcome": "success", + "llmCalls": 2, + "promptTextEqualsGolden": "information_accept", + "metadata": { + "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", + "contextEcho": true + }, + "contracts": ["conclusionLiteralPresent"], + "differential": true + } + } + ] + """ + .formatted(SESSION_ID)); + write( + root, + "validate/happy.json", + """ + [ + { + "id": "VAL-HAPPY-01", + "api": "validateProposePromptAndDataFilling", + "languages": ["zh-CN"], + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "prompt": {"golden": "information_propose"}, + "schema": {"$ref": "schemas/flat"}, + "llm": {"script": ["{\\"verdict\\": true}"]}, + "expect": { + "outcome": "success", + "llmCalls": 1, + "params": {"id": "%s", "round": 1, "maxRounds": 5} + } + } + ] + """ + .formatted(SESSION_ID, SESSION_ID)); + write( + root, + "scenarios/flows.json", + """ + [ + { + "id": "SC-INFO-01", + "summary": "提议后语义拒绝", + "languages": ["zh-CN"], + "roles": ["A", "B"], + "steps": [ + { + "step": 1, + "role": "A", + "api": "generateProposeFromText", + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "input": {"text": {"zh-CN": "请提供节能区域信息"}}, + "llm": {"script": [{"$ref": "responses/extract.information.propose.full"}]}, + "expect": {"outcome": "success", "llmCalls": 1} + }, + { + "step": 2, + "role": "B", + "api": "validateProposePromptAndDataFilling", + "prompt": {"fromStep": 1}, + "context": {"id": "%s", "round": 1, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/propose/v1", + "schema": {"$ref": "schemas/flat"}, + "llm": {"script": [{"$fail": "llm-error"}]}, + "expect": {"outcome": "failure", "code": "negotiation.semantic_rejected", "llmCalls": 1} + } + ], + "expectFlow": {"terminalCondition": "reject", "roundsUsed": 1, "distinctMessages": true} + } + ] + """ + .formatted(SESSION_ID, SESSION_ID)); + } + + private static String minimalCase(String id) { + return """ + [ + { + "id": "%s", + "api": "generateAcceptFromText", + "languages": ["zh-CN"], + "context": {"id": "%s", "round": 2, "maxRounds": 5}, + "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", + "input": {"text": {"zh-CN": "我确认"}}, + "llm": {"script": ["{}"]}, + "expect": {"outcome": "success"} + } + ] + """ + .formatted(id, SESSION_ID); + } + + private static NegotiationCase caseById(LoadedCorpus corpus, String id) { + NegotiationCase found = null; + for (NegotiationCase candidate : corpus.cases()) { + if (candidate.id().equals(id)) { + found = candidate; + } + } + assertNotNull(found, "the corpus must expand the case " + id); + return found; + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java index 0ff04653..16196195 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java @@ -1,517 +1,517 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.corpus.ScenarioCase.ExpectFlow; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * Direct unit tests of the {@link ScenarioEngine} against inline scenario objects: the {@code prompt.fromStep} - * resolution, the fail-fast step execution, and the three {@code expectFlow} fields (terminal condition, rounds used, - * pairwise distinct messages). - */ -class ScenarioEngineTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String ZH_CN = "zh-CN"; - - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; - - private static final String INFORMATION_ACCEPT_REJECT_URI = - "Negotiation-T/information-negotiation/accept-reject/v1"; - - private static final String ACCEPT_PAYLOAD = - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" - + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; - - private static final String PROPOSE_PAYLOAD = - "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}," - + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," - + "\"value\":null}],\"relationship\":\"OR\"}"; - - private static final String SEMANTIC_ACCEPT_PAYLOAD = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; - - private static final String PRIVATE_LINE_COMPLAINT_URI = "Task-T/network-layer/private-line-complaint/v1"; - - private static final Map CLOSED_LOOP_ROLES = - Map.of("A", "工作台(client,任务发起/补数方)", "B", "OMC(server,执行/要数方,协商发起方)"); - - /** The workbench raw complaint (样例步骤1): no access port name and no complaint category in the text. */ - private static final String COMPLAINT_TEXT = - "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报" + "“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; - - /** - * Slot-extraction payload of the closed loop's step 1 since the task_object slot became required upstream: the task - * object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays portless and - * the two schema parameters stay missing for the negotiation to fill. - */ - private static final String TASK_SLOTS_OBJECT_PORTLESS = - "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" - + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** - * Semantic payload of the closed loop's step 2: the OMC finds the access port name and the complaint category - * missing — the two null-valued parameters driving the information negotiation. - */ - private static final String TASK_SEMANTIC_MISSING_PARAMS = - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," - + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; - - /** Semantic payload of the closed loop's final step: the accept message carries both previously missing values. */ - private static final String SEMANTIC_ACCEPT_WITH_FILLED_PARAMS = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" - + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\",\"bizScenario\":\"专线质差\"}}"; - - /** Semantic payload filling only one of the two missing parameters (the unfilled-parameter red path). */ - private static final String SEMANTIC_ACCEPT_PARTIALLY_FILLED = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" - + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; - - private static final String TASK_PARAM_SCHEMA = - "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" - + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" - + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; - - private final ScenarioEngine engine = new ScenarioEngine(); - - private final ObjectMapper mapper = new ObjectMapper(); - - private JsonNode schema; - - @BeforeEach - void readSchema() throws Exception { - schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); - } - - // ------------------------------------------------------------------ fromStep resolution - - @Test - void runsATwoStepScenarioResolvingTheFromStepPrompt() { - ScenarioCase scenario = scenario( - "SC-INFO-01", - List.of( - step(1, "A", acceptStep("SC-INFO-01", ok(1, "information_accept"))), - step(2, "B", validateAcceptStep("SC-INFO-01", 1, ok(1, null)))), - new ScenarioCase.ExpectFlow("accept", 2, null)); - - engine.runScenario(scenario); - } - - @Test - void failsWhenAFromStepReferencesAnUnknownStep() { - ScenarioCase scenario = - scenario("SC-INFO-02", List.of(step(1, "A", validateAcceptStep("SC-INFO-02", 3, ok(1, null)))), null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("SC-INFO-02/zh-CN") - && failure.getMessage().contains("prompt.fromStep 3") - && failure.getMessage().contains("produced no prompt text"), - "the unknown fromStep reference must fail with the scenario id and the step number but was: " - + failure.getMessage()); - } - - // ------------------------------------------------------------------ fail-fast - - @Test - void failsFastWhenAStepFails() { - NegotiationCase failingStep = stepCase( - "SC-ERR-01", - 1, - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), - null, - null, - failed("NegotiationGenerationException", "llm.response_invalid", null)); - ScenarioCase scenario = scenario( - "SC-ERR-01", - List.of(step(1, "A", failingStep), step(2, "B", validateAcceptStep("SC-ERR-01", 1, ok(1, null)))), - null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("#step-1") && failure.getMessage().contains("$.expect.code"), - "the scenario must abort on the failing step 1 but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ expectFlow - - @Test - void assertsDistinctMessagesAndRoundsUsedAcrossSteps() { - ScenarioCase scenario = scenario( - "SC-INFO-03", - List.of( - step(1, "A", proposeStep("SC-INFO-03", 2, ok(1, "information_propose"))), - step(2, "B", acceptStep("SC-INFO-03", 3, ok(1, "information_accept")))), - new ScenarioCase.ExpectFlow("accept", 3, Boolean.TRUE)); - - engine.runScenario(scenario); - } - - @Test - void distinctMessagesFailsOnDuplicateMessages() { - ScenarioCase scenario = scenario( - "SC-INFO-04", - List.of( - step(1, "A", acceptStep("SC-INFO-04", 2, ok(1, "information_accept"))), - step(2, "B", acceptStep("SC-INFO-04", 3, ok(1, "information_accept")))), - new ScenarioCase.ExpectFlow("accept", 3, Boolean.TRUE)); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("$.expectFlow.distinctMessages"), - "duplicate messages must fail the distinctMessages expectation but was: " + failure.getMessage()); - } - - @Test - void exhaustedRequiresTheRoundLimitToBeReached() { - ScenarioCase exhausted = scenario( - "SC-EXH-01", - List.of(step(1, "A", acceptStep("SC-EXH-01", 5, ok(1, "information_accept")))), - new ScenarioCase.ExpectFlow("exhausted", 5, null)); - engine.runScenario(exhausted); - - ScenarioCase notExhausted = scenario( - "SC-EXH-02", - List.of(step(1, "A", acceptStep("SC-EXH-02", 2, ok(1, "information_accept")))), - new ScenarioCase.ExpectFlow("exhausted", 2, null)); - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(notExhausted)); - - assertTrue( - failure.getMessage().contains("$.expectFlow.terminalCondition"), - "a round below the limit must fail the exhausted expectation but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ closed loop: 缺参 -> 补参 (Q21) - - @Test - void runsTheClosedLoopScenarioLinkingMissingParamsToTheFilledParams() throws Exception { - ScenarioCase scenario = closedLoopScenario( - SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 2), 2); - - engine.runScenario(scenario); - } - - @Test - void paramsFromStepFailsWhenTheReferencedStepProducedNoFilledParams() throws Exception { - ScenarioCase scenario = closedLoopScenario(SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, null, 1); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("expect.paramsFromStep 1") - && failure.getMessage().contains("produced no filled parameter data") - && failure.getMessage().contains("B=OMC(server,执行/要数方,协商发起方)"), - "the dangling paramsFromStep reference must fail with role semantics but was: " + failure.getMessage()); - } - - @Test - void paramsFromStepFailsWhenAParameterStaysUnfilled() throws Exception { - ScenarioCase scenario = closedLoopScenario(SEMANTIC_ACCEPT_PARTIALLY_FILLED, null, 2); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("expect.paramsFromStep 2") - && failure.getMessage().contains("still missing: bizScenario") - && failure.getMessage().contains("#step-4"), - "an unfilled missing parameter must fail the causal assertion but was: " + failure.getMessage()); - } - - @Test - void missingParamsFilledFailsWhenNoLaterStepFillsTheParameters() throws Exception { - ScenarioCase scenario = closedLoopScenario( - SEMANTIC_ACCEPT_PARTIALLY_FILLED, new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 2), null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("$.expectFlow.missingParamsFilled") - && failure.getMessage().contains("still missing after step 2: bizScenario"), - "the flow-level causal expectation must fail on an unfilled parameter but was: " - + failure.getMessage()); - } - - @Test - void missingParamsFilledFailsWhenTheReferencedStepFoundNothingMissing() throws Exception { - ScenarioCase scenario = closedLoopScenario( - SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 4), 2); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("$.expectFlow.missingParamsFilled") - && failure.getMessage().contains("no missing parameters"), - "a referenced step without missing parameters must fail the flow expectation but was: " - + failure.getMessage()); - } - - // ------------------------------------------------------------------ builders - - private static ScenarioCase scenario( - String id, List steps, @Nullable ExpectFlow expectFlow) { - return new ScenarioCase( - id + "/zh-CN", - id, - "scenarios/information-flows.json", - ZH_CN, - null, - List.of("A", "B"), - steps, - expectFlow); - } - - /** - * Builds the four-step closed loop of the private-line complaint diagnosis (Q21): the workbench generates the - * incomplete task prompt (step 1), the OMC validates it and finds the two missing parameters (step 2), the - * workbench accepts the information negotiation and supplies the values (step 3), and the OMC validates the accept - * message and extracts the filled parameters (step 4). - */ - private ScenarioCase closedLoopScenario( - String acceptSemanticPayload, @Nullable ExpectFlow expectFlow, @Nullable Integer paramsFromStep) - throws Exception { - NegotiationCase taskGeneration = stepCase( - "SC-TASK-01", - 1, - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - null, - PRIVATE_LINE_COMPLAINT_URI, - COMPLAINT_TEXT, - script(TASK_SLOTS_OBJECT_PORTLESS), - null, - null, - ok(1, null)); - NegotiationCase taskValidation = stepCase( - "SC-TASK-01", - 2, - NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - null, - PRIVATE_LINE_COMPLAINT_URI, - null, - script(TASK_SEMANTIC_MISSING_PARAMS), - new PromptSource.FromStep(1), - mapper.readTree(TASK_PARAM_SCHEMA), - okTask(1, null, List.of("accessPort", "bizScenario"), Map.of("faultTime", "2026-05-11T08:21:46Z"))); - NegotiationCase acceptGeneration = stepCase( - "SC-TASK-01", - 3, - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "我方补充:接入端口名称P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。", - script(ACCEPT_PAYLOAD), - null, - null, - ok(1, null)); - NegotiationCase acceptValidation = stepCase( - "SC-TASK-01", - 4, - NegotiationApi.VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - null, - script(acceptSemanticPayload), - new PromptSource.FromStep(3), - schema, - okFromStep(1, paramsFromStep)); - return new ScenarioCase( - "SC-TASK-01/zh-CN", - "SC-TASK-01", - "scenarios/task-closed-loop.json", - ZH_CN, - null, - List.of("A", "B"), - List.of( - new ScenarioCase.ScenarioStep(1, "A", taskGeneration), - new ScenarioCase.ScenarioStep(2, "B", taskValidation), - new ScenarioCase.ScenarioStep(3, "A", acceptGeneration), - new ScenarioCase.ScenarioStep(4, "B", acceptValidation)), - expectFlow, - CLOSED_LOOP_ROLES); - } - - private static ScenarioCase.ScenarioStep step(int number, String role, NegotiationCase caseData) { - return new ScenarioCase.ScenarioStep(number, role, caseData); - } - - private NegotiationCase acceptStep(String scenarioId, Expectation expect) { - return stepCase( - scenarioId, - 1, - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "我确认第一阶段的信息。", - script(ACCEPT_PAYLOAD), - null, - null, - expect); - } - - private NegotiationCase acceptStep(String scenarioId, int round, Expectation expect) { - return stepCase( - scenarioId, - 1, - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - new ContextSpec(SESSION_ID, round, 5), - INFORMATION_ACCEPT_REJECT_URI, - "我确认第一阶段的信息。", - script(ACCEPT_PAYLOAD), - null, - null, - expect); - } - - private NegotiationCase proposeStep(String scenarioId, int round, Expectation expect) { - return stepCase( - scenarioId, - 1, - NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, - new ContextSpec(SESSION_ID, round, 5), - INFORMATION_PROPOSE_URI, - "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", - script(PROPOSE_PAYLOAD), - null, - null, - expect); - } - - private NegotiationCase validateAcceptStep(String scenarioId, int fromStep, Expectation expect) { - return stepCase( - scenarioId, - 2, - NegotiationApi.VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - null, - script(SEMANTIC_ACCEPT_PAYLOAD), - new PromptSource.FromStep(fromStep), - schema, - expect); - } - - private NegotiationCase stepCase( - String scenarioId, - int stepNumber, - NegotiationApi api, - ContextSpec context, - String templateUri, - @Nullable String inputText, - LlmScript llmScript, - @Nullable PromptSource prompt, - @Nullable JsonNode schema, - Expectation expect) { - return new NegotiationCase( - scenarioId + "/zh-CN#step-" + stepNumber, - scenarioId, - "scenarios/information-flows.json", - api, - ZH_CN, - null, - List.of(), - null, - context, - templateUri, - inputText, - null, - llmScript, - prompt, - schema, - null, - expect); - } - - private static LlmScript script(String payload) { - return new LlmScript(null, List.of(new LlmScriptStep.Payload(payload))); - } - - // ------------------------------------------------------------------ expectation helpers - - private static Expectation ok(@Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - promptTextEqualsGolden, - null, - java.util.Map.of(), - List.of(), - false); - } - - private static Expectation failed(String exception, String code, @Nullable Integer llmCalls) { - return new Expectation( - false, - exception, - code, - List.of(), - List.of(), - llmCalls, - null, - null, - java.util.Map.of(), - List.of(), - false); - } - - /** Task-family success expectation with the missing-parameter set of the task validation. */ - private static Expectation okTask( - @Nullable Integer llmCalls, - @Nullable List promptTextContains, - @Nullable List missingParams, - Map params) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - null, - null, - params, - List.of(), - false, - promptTextContains == null ? List.of() : promptTextContains, - missingParams, - null); - } - - /** Success expectation with the step-level causal reference {@code expect.paramsFromStep}. */ - private static Expectation okFromStep(@Nullable Integer llmCalls, @Nullable Integer paramsFromStep) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - null, - null, - java.util.Map.of(), - List.of(), - false, - List.of(), - null, - paramsFromStep); - } -} +package net.openan.a2at.sdk.corpus; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.corpus.ScenarioCase.ExpectFlow; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Direct unit tests of the {@link ScenarioEngine} against inline scenario objects: the {@code prompt.fromStep} + * resolution, the fail-fast step execution, and the three {@code expectFlow} fields (terminal condition, rounds used, + * pairwise distinct messages). + */ +class ScenarioEngineTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String ZH_CN = "zh-CN"; + + private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; + + private static final String INFORMATION_ACCEPT_REJECT_URI = + "Negotiation-T/information-negotiation/accept-reject/v1"; + + private static final String ACCEPT_PAYLOAD = + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" + + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; + + private static final String PROPOSE_PAYLOAD = + "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}," + + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," + + "\"value\":null}],\"relationship\":\"OR\"}"; + + private static final String SEMANTIC_ACCEPT_PAYLOAD = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; + + private static final String PRIVATE_LINE_COMPLAINT_URI = "Task-T/network-layer/private-line-complaint/v1"; + + private static final Map CLOSED_LOOP_ROLES = + Map.of("A", "工作台(client,任务发起/补数方)", "B", "OMC(server,执行/要数方,协商发起方)"); + + /** The workbench raw complaint (样例步骤1): no access port name and no complaint category in the text. */ + private static final String COMPLAINT_TEXT = + "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报" + "“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; + + /** + * Slot-extraction payload of the closed loop's step 1 since the task_object slot became required upstream: the task + * object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays portless and + * the two schema parameters stay missing for the negotiation to fill. + */ + private static final String TASK_SLOTS_OBJECT_PORTLESS = + "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" + + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," + + " \"slot_errors\": []}"; + + /** + * Semantic payload of the closed loop's step 2: the OMC finds the access port name and the complaint category + * missing — the two null-valued parameters driving the information negotiation. + */ + private static final String TASK_SEMANTIC_MISSING_PARAMS = + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," + + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; + + /** Semantic payload of the closed loop's final step: the accept message carries both previously missing values. */ + private static final String SEMANTIC_ACCEPT_WITH_FILLED_PARAMS = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" + + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\",\"bizScenario\":\"专线质差\"}}"; + + /** Semantic payload filling only one of the two missing parameters (the unfilled-parameter red path). */ + private static final String SEMANTIC_ACCEPT_PARTIALLY_FILLED = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" + + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; + + private static final String TASK_PARAM_SCHEMA = + "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" + + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" + + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; + + private final ScenarioEngine engine = new ScenarioEngine(); + + private final ObjectMapper mapper = new ObjectMapper(); + + private JsonNode schema; + + @BeforeEach + void readSchema() throws Exception { + schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); + } + + // ------------------------------------------------------------------ fromStep resolution + + @Test + void runsATwoStepScenarioResolvingTheFromStepPrompt() { + ScenarioCase scenario = scenario( + "SC-INFO-01", + List.of( + step(1, "A", acceptStep("SC-INFO-01", ok(1, "information_accept"))), + step(2, "B", validateAcceptStep("SC-INFO-01", 1, ok(1, null)))), + new ScenarioCase.ExpectFlow("accept", 2, null)); + + engine.runScenario(scenario); + } + + @Test + void failsWhenAFromStepReferencesAnUnknownStep() { + ScenarioCase scenario = + scenario("SC-INFO-02", List.of(step(1, "A", validateAcceptStep("SC-INFO-02", 3, ok(1, null)))), null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("SC-INFO-02/zh-CN") + && failure.getMessage().contains("prompt.fromStep 3") + && failure.getMessage().contains("produced no prompt text"), + "the unknown fromStep reference must fail with the scenario id and the step number but was: " + + failure.getMessage()); + } + + // ------------------------------------------------------------------ fail-fast + + @Test + void failsFastWhenAStepFails() { + NegotiationCase failingStep = stepCase( + "SC-ERR-01", + 1, + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "请接受。", + new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), + null, + null, + failed("NegotiationGenerationException", "llm.response_invalid", null)); + ScenarioCase scenario = scenario( + "SC-ERR-01", + List.of(step(1, "A", failingStep), step(2, "B", validateAcceptStep("SC-ERR-01", 1, ok(1, null)))), + null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("#step-1") && failure.getMessage().contains("$.expect.code"), + "the scenario must abort on the failing step 1 but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ expectFlow + + @Test + void assertsDistinctMessagesAndRoundsUsedAcrossSteps() { + ScenarioCase scenario = scenario( + "SC-INFO-03", + List.of( + step(1, "A", proposeStep("SC-INFO-03", 2, ok(1, "information_propose"))), + step(2, "B", acceptStep("SC-INFO-03", 3, ok(1, "information_accept")))), + new ScenarioCase.ExpectFlow("accept", 3, Boolean.TRUE)); + + engine.runScenario(scenario); + } + + @Test + void distinctMessagesFailsOnDuplicateMessages() { + ScenarioCase scenario = scenario( + "SC-INFO-04", + List.of( + step(1, "A", acceptStep("SC-INFO-04", 2, ok(1, "information_accept"))), + step(2, "B", acceptStep("SC-INFO-04", 3, ok(1, "information_accept")))), + new ScenarioCase.ExpectFlow("accept", 3, Boolean.TRUE)); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("$.expectFlow.distinctMessages"), + "duplicate messages must fail the distinctMessages expectation but was: " + failure.getMessage()); + } + + @Test + void exhaustedRequiresTheRoundLimitToBeReached() { + ScenarioCase exhausted = scenario( + "SC-EXH-01", + List.of(step(1, "A", acceptStep("SC-EXH-01", 5, ok(1, "information_accept")))), + new ScenarioCase.ExpectFlow("exhausted", 5, null)); + engine.runScenario(exhausted); + + ScenarioCase notExhausted = scenario( + "SC-EXH-02", + List.of(step(1, "A", acceptStep("SC-EXH-02", 2, ok(1, "information_accept")))), + new ScenarioCase.ExpectFlow("exhausted", 2, null)); + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(notExhausted)); + + assertTrue( + failure.getMessage().contains("$.expectFlow.terminalCondition"), + "a round below the limit must fail the exhausted expectation but was: " + failure.getMessage()); + } + + // ------------------------------------------------------------------ closed loop: 缺参 -> 补参 (Q21) + + @Test + void runsTheClosedLoopScenarioLinkingMissingParamsToTheFilledParams() throws Exception { + ScenarioCase scenario = closedLoopScenario( + SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 2), 2); + + engine.runScenario(scenario); + } + + @Test + void paramsFromStepFailsWhenTheReferencedStepProducedNoFilledParams() throws Exception { + ScenarioCase scenario = closedLoopScenario(SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, null, 1); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("expect.paramsFromStep 1") + && failure.getMessage().contains("produced no filled parameter data") + && failure.getMessage().contains("B=OMC(server,执行/要数方,协商发起方)"), + "the dangling paramsFromStep reference must fail with role semantics but was: " + failure.getMessage()); + } + + @Test + void paramsFromStepFailsWhenAParameterStaysUnfilled() throws Exception { + ScenarioCase scenario = closedLoopScenario(SEMANTIC_ACCEPT_PARTIALLY_FILLED, null, 2); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("expect.paramsFromStep 2") + && failure.getMessage().contains("still missing: bizScenario") + && failure.getMessage().contains("#step-4"), + "an unfilled missing parameter must fail the causal assertion but was: " + failure.getMessage()); + } + + @Test + void missingParamsFilledFailsWhenNoLaterStepFillsTheParameters() throws Exception { + ScenarioCase scenario = closedLoopScenario( + SEMANTIC_ACCEPT_PARTIALLY_FILLED, new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 2), null); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("$.expectFlow.missingParamsFilled") + && failure.getMessage().contains("still missing after step 2: bizScenario"), + "the flow-level causal expectation must fail on an unfilled parameter but was: " + + failure.getMessage()); + } + + @Test + void missingParamsFilledFailsWhenTheReferencedStepFoundNothingMissing() throws Exception { + ScenarioCase scenario = closedLoopScenario( + SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 4), 2); + + AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); + + assertTrue( + failure.getMessage().contains("$.expectFlow.missingParamsFilled") + && failure.getMessage().contains("no missing parameters"), + "a referenced step without missing parameters must fail the flow expectation but was: " + + failure.getMessage()); + } + + // ------------------------------------------------------------------ builders + + private static ScenarioCase scenario( + String id, List steps, @Nullable ExpectFlow expectFlow) { + return new ScenarioCase( + id + "/zh-CN", + id, + "scenarios/information-flows.json", + ZH_CN, + null, + List.of("A", "B"), + steps, + expectFlow); + } + + /** + * Builds the four-step closed loop of the private-line complaint diagnosis (Q21): the workbench generates the + * incomplete task prompt (step 1), the OMC validates it and finds the two missing parameters (step 2), the + * workbench accepts the information negotiation and supplies the values (step 3), and the OMC validates the accept + * message and extracts the filled parameters (step 4). + */ + private ScenarioCase closedLoopScenario( + String acceptSemanticPayload, @Nullable ExpectFlow expectFlow, @Nullable Integer paramsFromStep) + throws Exception { + NegotiationCase taskGeneration = stepCase( + "SC-TASK-01", + 1, + NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, + null, + PRIVATE_LINE_COMPLAINT_URI, + COMPLAINT_TEXT, + script(TASK_SLOTS_OBJECT_PORTLESS), + null, + null, + ok(1, null)); + NegotiationCase taskValidation = stepCase( + "SC-TASK-01", + 2, + NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, + null, + PRIVATE_LINE_COMPLAINT_URI, + null, + script(TASK_SEMANTIC_MISSING_PARAMS), + new PromptSource.FromStep(1), + mapper.readTree(TASK_PARAM_SCHEMA), + okTask(1, null, List.of("accessPort", "bizScenario"), Map.of("faultTime", "2026-05-11T08:21:46Z"))); + NegotiationCase acceptGeneration = stepCase( + "SC-TASK-01", + 3, + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "我方补充:接入端口名称P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。", + script(ACCEPT_PAYLOAD), + null, + null, + ok(1, null)); + NegotiationCase acceptValidation = stepCase( + "SC-TASK-01", + 4, + NegotiationApi.VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + null, + script(acceptSemanticPayload), + new PromptSource.FromStep(3), + schema, + okFromStep(1, paramsFromStep)); + return new ScenarioCase( + "SC-TASK-01/zh-CN", + "SC-TASK-01", + "scenarios/task-closed-loop.json", + ZH_CN, + null, + List.of("A", "B"), + List.of( + new ScenarioCase.ScenarioStep(1, "A", taskGeneration), + new ScenarioCase.ScenarioStep(2, "B", taskValidation), + new ScenarioCase.ScenarioStep(3, "A", acceptGeneration), + new ScenarioCase.ScenarioStep(4, "B", acceptValidation)), + expectFlow, + CLOSED_LOOP_ROLES); + } + + private static ScenarioCase.ScenarioStep step(int number, String role, NegotiationCase caseData) { + return new ScenarioCase.ScenarioStep(number, role, caseData); + } + + private NegotiationCase acceptStep(String scenarioId, Expectation expect) { + return stepCase( + scenarioId, + 1, + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + "我确认第一阶段的信息。", + script(ACCEPT_PAYLOAD), + null, + null, + expect); + } + + private NegotiationCase acceptStep(String scenarioId, int round, Expectation expect) { + return stepCase( + scenarioId, + 1, + NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, + new ContextSpec(SESSION_ID, round, 5), + INFORMATION_ACCEPT_REJECT_URI, + "我确认第一阶段的信息。", + script(ACCEPT_PAYLOAD), + null, + null, + expect); + } + + private NegotiationCase proposeStep(String scenarioId, int round, Expectation expect) { + return stepCase( + scenarioId, + 1, + NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, + new ContextSpec(SESSION_ID, round, 5), + INFORMATION_PROPOSE_URI, + "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", + script(PROPOSE_PAYLOAD), + null, + null, + expect); + } + + private NegotiationCase validateAcceptStep(String scenarioId, int fromStep, Expectation expect) { + return stepCase( + scenarioId, + 2, + NegotiationApi.VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING, + new ContextSpec(SESSION_ID, 2, 5), + INFORMATION_ACCEPT_REJECT_URI, + null, + script(SEMANTIC_ACCEPT_PAYLOAD), + new PromptSource.FromStep(fromStep), + schema, + expect); + } + + private NegotiationCase stepCase( + String scenarioId, + int stepNumber, + NegotiationApi api, + ContextSpec context, + String templateUri, + @Nullable String inputText, + LlmScript llmScript, + @Nullable PromptSource prompt, + @Nullable JsonNode schema, + Expectation expect) { + return new NegotiationCase( + scenarioId + "/zh-CN#step-" + stepNumber, + scenarioId, + "scenarios/information-flows.json", + api, + ZH_CN, + null, + List.of(), + null, + context, + templateUri, + inputText, + null, + llmScript, + prompt, + schema, + null, + expect); + } + + private static LlmScript script(String payload) { + return new LlmScript(null, List.of(new LlmScriptStep.Payload(payload))); + } + + // ------------------------------------------------------------------ expectation helpers + + private static Expectation ok(@Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + promptTextEqualsGolden, + null, + java.util.Map.of(), + List.of(), + false); + } + + private static Expectation failed(String exception, String code, @Nullable Integer llmCalls) { + return new Expectation( + false, + exception, + code, + List.of(), + List.of(), + llmCalls, + null, + null, + java.util.Map.of(), + List.of(), + false); + } + + /** Task-family success expectation with the missing-parameter set of the task validation. */ + private static Expectation okTask( + @Nullable Integer llmCalls, + @Nullable List promptTextContains, + @Nullable List missingParams, + Map params) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + null, + null, + params, + List.of(), + false, + promptTextContains == null ? List.of() : promptTextContains, + missingParams, + null); + } + + /** Success expectation with the step-level causal reference {@code expect.paramsFromStep}. */ + private static Expectation okFromStep(@Nullable Integer llmCalls, @Nullable Integer paramsFromStep) { + return new Expectation( + true, + null, + null, + List.of(), + List.of(), + llmCalls, + null, + null, + java.util.Map.of(), + List.of(), + false, + List.of(), + null, + paramsFromStep); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java index 4071e191..0a354de4 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java @@ -1,164 +1,164 @@ -package net.openan.a2at.sdk.corpus; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; -import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; - -/** - * Real-builder assembly of the three closed-loop task APIs (Q21): the corpus harness drives exactly the production - * wiring the {@code A2ATClient} and {@code A2ATServer} facades drive internally, so a change of the production assembly - * cannot drift past the corpus. - * - *

              - *
            • {@code generateTaskPromptFromText} and {@code generateTaskPromptFromDataWithSchema} run through the - * {@link ClientPromptGenerationOrchestrator} built by - * {@link DefaultA2ATClientBuilder#buildPromptGenerationOrchestrator()} — the same build method the client facade - * constructor calls; - *
            • {@code validateTaskPromptAndDataFilling} runs through the {@link ContentValidator} built by - * {@link DefaultA2ATServerBuilder#buildTaskContentValidator()} — the same build method the server facade - * constructor calls. - *
            - * - *

            Both builders are driven exactly like the facades drive them: a minimal classpath-source {@code .env} (written - * once per language and retry limit into a temporary directory, following the facade test precedent) is loaded through - * {@link A2ATConfig#load(Path)} plus {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)}, and the - * {@link ScriptedNegotiationLlmClient} is injected through the builders' {@code llmClient(...)} seam — the same - * instance feeds the client-side and the server-side components, so the {@code llmCalls} expectation counts the whole - * closed loop. The facade constructors are locked to the {@code Path}-only signature by the API-surface guard tests, so - * the builders are the direct route to the identical assembly. - * - * @since 2026-08 - */ -final class TaskApiAssembler { - - private static final Map ENV_FILES = new ConcurrentHashMap<>(); - - private final ClientPromptGenerationOrchestrator promptGeneration; - - private final ContentValidator taskValidator; - - /** - * Assembles the task API wiring for one language. - * - * @param language language of the generated and validated task prompts, such as {@code zh-CN} - * @param maxAttempts retry limit of the LLM steps, mirroring the builder default of 3 - * @param llmClient scripted LLM client injected at the same seam the facade builders inject their real client - */ - TaskApiAssembler(String language, int maxAttempts, LLMClient llmClient) { - this(minimalEnvFor(language, maxAttempts), llmClient); - } - - /** - * Assembles the task API wiring around an explicit {@code .env} file — the seam of the live family, whose harness - * hands in the {@code LiveLlmEnvWriter} bridge (real test-endpoint values plus the explicit stability knobs and the - * pipeline retry limit) instead of the scripted minimal env. - * - * @param envPath the {@code .env} file the config is loaded from, carrying the language and retry limit - * @param llmClient LLM client injected at the same seam the facade builders inject their real client - */ - TaskApiAssembler(Path envPath, LLMClient llmClient) { - A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); - this.promptGeneration = DefaultA2ATClientBuilder.builder() - .config(config) - .envPath(envPath) - .llmClient(llmClient) - .buildPromptGenerationOrchestrator(); - this.taskValidator = DefaultA2ATServerBuilder.builder() - .config(config) - .envPath(envPath) - .llmClient(llmClient) - .buildTaskContentValidator(); - } - - /** - * Generates a task prompt with metadata from natural-language input through the real client orchestrator, the - * pipeline behind {@code A2ATClient.generateTaskPromptFromText}. - * - * @param text natural-language task input - * @param templateUri template URI identifying the target task template - * @return metadata content carrying the resolved template URI, rendered prompt text and Task-T extension URI - */ - MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) { - return promptGeneration.generateTaskPromptFromText(text, templateUri); - } - - /** - * Generates a task prompt with metadata from structured input and a data schema through the real client - * orchestrator, the pipeline behind {@code A2ATClient.generateTaskPromptFromDataWithSchema}. - * - * @param data structured task input as a string-to-object map - * @param schema data schema map describing the meaning of each input field - * @param templateUri template URI identifying the target task template - * @return metadata content carrying the resolved template URI, rendered prompt text and Task-T extension URI - */ - MetadataContent generateTaskPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) { - return promptGeneration.generateTaskPromptFromDataWithSchema(data, schema, templateUri); - } - - /** - * Validates a task prompt and extracts its filled parameters through the real server-side task content validator, - * the pipeline behind {@code A2ATServer.validateTaskPromptAndDataFilling}. - * - *

            A schema slot the prompt misses surfaces as a null-valued entry of the returned parameter data — that set of - * null-valued keys is the missing-parameter set the negotiation loop then fills. - * - * @param prompt rendered task prompt text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected task template - * @return filled parameter data carrying the extracted parameters; null values mark missing parameters - */ - FilledParamData validateTaskPromptAndDataFilling( - String prompt, Map schema, TemplateUri templateUri) { - return taskValidator.validate(prompt, schema, templateUri); - } - - // ------------------------------------------------------------------ minimal facade env - - /** - * Writes (once per language and retry limit) the minimal classpath-source {@code .env} the facades would be handed, - * following the {@code A2ATClientTest} minimal-env precedent; the LLM entries are inert because the scripted client - * is injected at the builders' LLM seam. - * - * @param language language of the corpus case - * @param maxAttempts retry limit of the LLM steps from the case's LLM script - * @return path of the written {@code .env} file - */ - private static Path minimalEnvFor(String language, int maxAttempts) { - return ENV_FILES.computeIfAbsent(language + "/" + maxAttempts, key -> { - try { - Path envFile = Files.createTempDirectory("a2at-corpus-task-env").resolve("client.env"); - Files.writeString( - envFile, - ("A2AT_LANGUAGE=%s%n" - + "A2AT_PROMPT_SOURCE_TYPE=classpath%n" - + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=%n" - + "A2AT_LLM_PROVIDER=openai%n" - + "A2AT_LLM_MODEL=scripted-model%n" - + "A2AT_LLM_BASE_URL=https://llm.example.test/v1%n" - + "A2AT_LLM_API_KEY=corpus-scripted%n" - + "A2AT_LLM_MAX_ATTEMPTS=%d%n" - + "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory%n") - .formatted(language, maxAttempts), - StandardCharsets.UTF_8); - return envFile; - } catch (IOException exception) { - throw new UncheckedIOException( - "Failed to write the minimal corpus .env for language " + language, exception); - } - }); - } -} +package net.openan.a2at.sdk.corpus; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; + +/** + * Real-builder assembly of the three closed-loop task APIs (Q21): the corpus harness drives exactly the production + * wiring the {@code A2ATClient} and {@code A2ATServer} facades drive internally, so a change of the production assembly + * cannot drift past the corpus. + * + *

              + *
            • {@code generateTaskPromptFromText} and {@code generateTaskPromptFromDataWithSchema} run through the + * {@link ClientPromptGenerationOrchestrator} built by + * {@link DefaultA2ATClientBuilder#buildPromptGenerationOrchestrator()} — the same build method the client facade + * constructor calls; + *
            • {@code validateTaskPromptAndDataFilling} runs through the {@link ContentValidator} built by + * {@link DefaultA2ATServerBuilder#buildTaskContentValidator()} — the same build method the server facade + * constructor calls. + *
            + * + *

            Both builders are driven exactly like the facades drive them: a minimal classpath-source {@code .env} (written + * once per language and retry limit into a temporary directory, following the facade test precedent) is loaded through + * {@link A2ATConfig#load(Path)} plus {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)}, and the + * {@link ScriptedNegotiationLlmClient} is injected through the builders' {@code llmClient(...)} seam — the same + * instance feeds the client-side and the server-side components, so the {@code llmCalls} expectation counts the whole + * closed loop. The facade constructors are locked to the {@code Path}-only signature by the API-surface guard tests, so + * the builders are the direct route to the identical assembly. + * + * @since 2026-08 + */ +final class TaskApiAssembler { + + private static final Map ENV_FILES = new ConcurrentHashMap<>(); + + private final ClientPromptGenerationOrchestrator promptGeneration; + + private final ContentValidator taskValidator; + + /** + * Assembles the task API wiring for one language. + * + * @param language language of the generated and validated task prompts, such as {@code zh-CN} + * @param maxAttempts retry limit of the LLM steps, mirroring the builder default of 3 + * @param llmClient scripted LLM client injected at the same seam the facade builders inject their real client + */ + TaskApiAssembler(String language, int maxAttempts, LLMClient llmClient) { + this(minimalEnvFor(language, maxAttempts), llmClient); + } + + /** + * Assembles the task API wiring around an explicit {@code .env} file — the seam of the live family, whose harness + * hands in the {@code LiveLlmEnvWriter} bridge (real test-endpoint values plus the explicit stability knobs and the + * pipeline retry limit) instead of the scripted minimal env. + * + * @param envPath the {@code .env} file the config is loaded from, carrying the language and retry limit + * @param llmClient LLM client injected at the same seam the facade builders inject their real client + */ + TaskApiAssembler(Path envPath, LLMClient llmClient) { + A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); + this.promptGeneration = DefaultA2ATClientBuilder.builder() + .config(config) + .envPath(envPath) + .llmClient(llmClient) + .buildPromptGenerationOrchestrator(); + this.taskValidator = DefaultA2ATServerBuilder.builder() + .config(config) + .envPath(envPath) + .llmClient(llmClient) + .buildTaskContentValidator(); + } + + /** + * Generates a task prompt with metadata from natural-language input through the real client orchestrator, the + * pipeline behind {@code A2ATClient.generateTaskPromptFromText}. + * + * @param text natural-language task input + * @param templateUri template URI identifying the target task template + * @return metadata content carrying the resolved template URI, rendered prompt text and Task-T extension URI + */ + MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) { + return promptGeneration.generateTaskPromptFromText(text, templateUri); + } + + /** + * Generates a task prompt with metadata from structured input and a data schema through the real client + * orchestrator, the pipeline behind {@code A2ATClient.generateTaskPromptFromDataWithSchema}. + * + * @param data structured task input as a string-to-object map + * @param schema data schema map describing the meaning of each input field + * @param templateUri template URI identifying the target task template + * @return metadata content carrying the resolved template URI, rendered prompt text and Task-T extension URI + */ + MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) { + return promptGeneration.generateTaskPromptFromDataWithSchema(data, schema, templateUri); + } + + /** + * Validates a task prompt and extracts its filled parameters through the real server-side task content validator, + * the pipeline behind {@code A2ATServer.validateTaskPromptAndDataFilling}. + * + *

            A schema slot the prompt misses surfaces as a null-valued entry of the returned parameter data — that set of + * null-valued keys is the missing-parameter set the negotiation loop then fills. + * + * @param prompt rendered task prompt text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected task template + * @return filled parameter data carrying the extracted parameters; null values mark missing parameters + */ + FilledParamData validateTaskPromptAndDataFilling( + String prompt, Map schema, TemplateUri templateUri) { + return taskValidator.validate(prompt, schema, templateUri); + } + + // ------------------------------------------------------------------ minimal facade env + + /** + * Writes (once per language and retry limit) the minimal classpath-source {@code .env} the facades would be handed, + * following the {@code A2ATClientTest} minimal-env precedent; the LLM entries are inert because the scripted client + * is injected at the builders' LLM seam. + * + * @param language language of the corpus case + * @param maxAttempts retry limit of the LLM steps from the case's LLM script + * @return path of the written {@code .env} file + */ + private static Path minimalEnvFor(String language, int maxAttempts) { + return ENV_FILES.computeIfAbsent(language + "/" + maxAttempts, key -> { + try { + Path envFile = Files.createTempDirectory("a2at-corpus-task-env").resolve("client.env"); + Files.writeString( + envFile, + ("A2AT_LANGUAGE=%s%n" + + "A2AT_PROMPT_SOURCE_TYPE=classpath%n" + + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=%n" + + "A2AT_LLM_PROVIDER=openai%n" + + "A2AT_LLM_MODEL=scripted-model%n" + + "A2AT_LLM_BASE_URL=https://llm.example.test/v1%n" + + "A2AT_LLM_API_KEY=corpus-scripted%n" + + "A2AT_LLM_MAX_ATTEMPTS=%d%n" + + "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory%n") + .formatted(language, maxAttempts), + StandardCharsets.UTF_8); + return envFile; + } catch (IOException exception) { + throw new UncheckedIOException( + "Failed to write the minimal corpus .env for language " + language, exception); + } + }); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/ErrorCodePartitionPropertyTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/ErrorCodePartitionPropertyTest.java index 1de48153..57ee9fbb 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/ErrorCodePartitionPropertyTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/ErrorCodePartitionPropertyTest.java @@ -1,262 +1,262 @@ -package net.openan.a2at.sdk.corpus.property; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Map; -import java.util.Set; -import net.jqwik.api.Arbitraries; -import net.jqwik.api.Arbitrary; -import net.jqwik.api.ForAll; -import net.jqwik.api.Property; -import net.jqwik.api.Provide; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.corpus.LlmFailMarker; -import net.openan.a2at.sdk.corpus.ScriptedNegotiationLlmClient; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; - -/** - * Error-code partition property layer (design §8.3): the public error-code universe partitions into exactly the - * content-layer {@link ErrorCatalog} codes, and the retryable partition is exactly the three retryable LLM-step codes. - * - *

            Every trigger runs with the attempt limit fixed at {@link PropertyHarness#MAX_ATTEMPTS}, so the retryable - * partition has an operational definition observable from the outside: a failure code is retryable if and only if the - * scripted LLM client was consumed to the attempt limit. Pre-LLM failures (0 calls) and non-retryable in-step failures - * (1 call) never reach the limit. - * - * @since 2026-08 - */ -class ErrorCodePartitionPropertyTest { - - /** The complete public error-code set of the content layer (ErrorCatalog codes). */ - private static final Set CONTENT_LAYER_ERROR_CODES = Set.of( - ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), - ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), - ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), - ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), - ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), - ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), - ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), - ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); - - /** The retryable partition: exactly the three retryable LLM-step codes of the content layer. */ - private static final Set RETRYABLE_ERROR_CODES = Set.of( - ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), - ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), - ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); - - @Property(tries = 100, seed = "20260920") - void everyFailureCarriesACodeFromTheContentLayerCodeSet( - @ForAll("languages") String language, - @ForAll("contexts") NegotiationContext context, - @ForAll("failureTriggers") FailureTrigger trigger) { - FailureOutcome outcome = trigger.run(language, context); - assertTrue( - CONTENT_LAYER_ERROR_CODES.contains(outcome.code()), - "code '" + outcome.code() + "' is outside the content-layer error codes"); - assertEquals(trigger.expectedCode(), outcome.code()); - assertEquals( - RETRYABLE_ERROR_CODES.contains(outcome.code()), - outcome.llmCalls() == PropertyHarness.MAX_ATTEMPTS, - "a failure is retried to the attempt limit if and only if its code is retryable, but code " - + outcome.code() + " made " + outcome.llmCalls() + " LLM call(s)"); - } - - /** The normalized outcome of one triggered failure: its public error code and its exact LLM call count. */ - private record FailureOutcome(String code, int llmCalls) {} - - /** One deterministic trigger of one public failure code. */ - private enum FailureTrigger { - BLANK_TEXT { - @Override - String expectedCode() { - return ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(); - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationGenerationException.class, - () -> service.generateProposeFromText( - " ", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - CONCLUSION_MISMATCH { - @Override - String expectedCode() { - return ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(); - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = PropertyHarness.scripted( - "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"no agreement\"}"); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationGenerationException.class, - () -> service.generateAcceptFromText( - "I must refuse the current offer.", - context, - PropertyHarness.templateUri("Negotiation-T/target-negotiation/accept-reject/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - MISSING_REQUIRED_FIELD { - @Override - String expectedCode() { - return ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(); - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = PropertyHarness.scripted("{\"relationship\":null}"); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationGenerationException.class, - () -> service.generateProposeFromText( - "Please provide the missing information.", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - ROUND_ABOVE_BUDGET { - @Override - String expectedCode() { - return ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(); - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - // The over-budget context is the incoming context of the propose message being validated. - NegotiationContext overBudget = new NegotiationContext( - context.id(), context.maxRounds() + 1, context.maxRounds(), NegotiationPerformative.PROPOSE); - ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationParamExtractionException.class, - () -> service.validateProposePromptAndDataFilling( - "Rendered negotiation message text.", - overBudget, - PropertyHarness.objectSchema(Map.of()), - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - SEMANTIC_REJECTED { - @Override - String expectedCode() { - return ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(); - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = - PropertyHarness.scripted("{\"semantic_verdict\":false,\"negotiation_type\":\"information\"," - + "\"errors\":[{\"slot_name\":\"region\",\"code\":" - + "\"negotiation.field_missing\",\"facts\":{\"field\":\"区域\"}}]," - + "\"params\":{}}"); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationParamExtractionException.class, - () -> service.validateProposePromptAndDataFilling( - "Rendered negotiation message text.", - context, - PropertyHarness.objectSchema(Map.of()), - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - RESPONSE_INVALID { - @Override - String expectedCode() { - return ErrorCatalog.LLM_RESPONSE_INVALID.getCode(); - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = PropertyHarness.failing(LlmFailMarker.NON_JSON); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationGenerationException.class, - () -> service.generateProposeFromText( - "Please provide the missing information.", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - LLM_INFRASTRUCTURE_ERROR { - @Override - String expectedCode() { - return ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = PropertyHarness.failing(LlmFailMarker.RUNTIME_EXCEPTION); - NegotiationContentService service = PropertyHarness.service(language, llm); - A2ATError error = assertThrows( - NegotiationGenerationException.class, - () -> service.generateProposeFromText( - "Please provide the missing information.", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }, - TEMPLATE_NOT_FOUND { - @Override - String expectedCode() { - return ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); - } - - @Override - FailureOutcome run(String language, NegotiationContext context) { - ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); - NegotiationContentService service = PropertyHarness.serviceWithFailingTemplateLoader(language, llm); - A2ATError error = assertThrows( - NegotiationGenerationException.class, - () -> service.generateProposeFromText( - "Please provide the missing information.", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - return new FailureOutcome(error.getCode(), llm.callCount()); - } - }; - - abstract String expectedCode(); - - abstract FailureOutcome run(String language, NegotiationContext context); - } - - // ------------------------------------------------------------------ providers - - @Provide - Arbitrary languages() { - return PropertyArbitraries.languages(); - } - - @Provide - Arbitrary contexts() { - return PropertyArbitraries.contexts(); - } - - @Provide - Arbitrary failureTriggers() { - return Arbitraries.of(FailureTrigger.values()); - } -} +package net.openan.a2at.sdk.corpus.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.Set; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.corpus.LlmFailMarker; +import net.openan.a2at.sdk.corpus.ScriptedNegotiationLlmClient; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; + +/** + * Error-code partition property layer (design §8.3): the public error-code universe partitions into exactly the + * content-layer {@link ErrorCatalog} codes, and the retryable partition is exactly the three retryable LLM-step codes. + * + *

            Every trigger runs with the attempt limit fixed at {@link PropertyHarness#MAX_ATTEMPTS}, so the retryable + * partition has an operational definition observable from the outside: a failure code is retryable if and only if the + * scripted LLM client was consumed to the attempt limit. Pre-LLM failures (0 calls) and non-retryable in-step failures + * (1 call) never reach the limit. + * + * @since 2026-08 + */ +class ErrorCodePartitionPropertyTest { + + /** The complete public error-code set of the content layer (ErrorCatalog codes). */ + private static final Set CONTENT_LAYER_ERROR_CODES = Set.of( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), + ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), + ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); + + /** The retryable partition: exactly the three retryable LLM-step codes of the content layer. */ + private static final Set RETRYABLE_ERROR_CODES = Set.of( + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); + + @Property(tries = 100, seed = "20260920") + void everyFailureCarriesACodeFromTheContentLayerCodeSet( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("failureTriggers") FailureTrigger trigger) { + FailureOutcome outcome = trigger.run(language, context); + assertTrue( + CONTENT_LAYER_ERROR_CODES.contains(outcome.code()), + "code '" + outcome.code() + "' is outside the content-layer error codes"); + assertEquals(trigger.expectedCode(), outcome.code()); + assertEquals( + RETRYABLE_ERROR_CODES.contains(outcome.code()), + outcome.llmCalls() == PropertyHarness.MAX_ATTEMPTS, + "a failure is retried to the attempt limit if and only if its code is retryable, but code " + + outcome.code() + " made " + outcome.llmCalls() + " LLM call(s)"); + } + + /** The normalized outcome of one triggered failure: its public error code and its exact LLM call count. */ + private record FailureOutcome(String code, int llmCalls) {} + + /** One deterministic trigger of one public failure code. */ + private enum FailureTrigger { + BLANK_TEXT { + @Override + String expectedCode() { + return ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + " ", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + CONCLUSION_MISMATCH { + @Override + String expectedCode() { + return ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = PropertyHarness.scripted( + "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"no agreement\"}"); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateAcceptFromText( + "I must refuse the current offer.", + context, + PropertyHarness.templateUri("Negotiation-T/target-negotiation/accept-reject/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + MISSING_REQUIRED_FIELD { + @Override + String expectedCode() { + return ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = PropertyHarness.scripted("{\"relationship\":null}"); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + ROUND_ABOVE_BUDGET { + @Override + String expectedCode() { + return ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + // The over-budget context is the incoming context of the propose message being validated. + NegotiationContext overBudget = new NegotiationContext( + context.id(), context.maxRounds() + 1, context.maxRounds(), NegotiationPerformative.PROPOSE); + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationParamExtractionException.class, + () -> service.validateProposePromptAndDataFilling( + "Rendered negotiation message text.", + overBudget, + PropertyHarness.objectSchema(Map.of()), + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + SEMANTIC_REJECTED { + @Override + String expectedCode() { + return ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = + PropertyHarness.scripted("{\"semantic_verdict\":false,\"negotiation_type\":\"information\"," + + "\"errors\":[{\"slot_name\":\"region\",\"code\":" + + "\"negotiation.field_missing\",\"facts\":{\"field\":\"区域\"}}]," + + "\"params\":{}}"); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationParamExtractionException.class, + () -> service.validateProposePromptAndDataFilling( + "Rendered negotiation message text.", + context, + PropertyHarness.objectSchema(Map.of()), + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + RESPONSE_INVALID { + @Override + String expectedCode() { + return ErrorCatalog.LLM_RESPONSE_INVALID.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = PropertyHarness.failing(LlmFailMarker.NON_JSON); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + LLM_INFRASTRUCTURE_ERROR { + @Override + String expectedCode() { + return ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = PropertyHarness.failing(LlmFailMarker.RUNTIME_EXCEPTION); + NegotiationContentService service = PropertyHarness.service(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }, + TEMPLATE_NOT_FOUND { + @Override + String expectedCode() { + return ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); + } + + @Override + FailureOutcome run(String language, NegotiationContext context) { + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.serviceWithFailingTemplateLoader(language, llm); + A2ATError error = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + return new FailureOutcome(error.getCode(), llm.callCount()); + } + }; + + abstract String expectedCode(); + + abstract FailureOutcome run(String language, NegotiationContext context); + } + + // ------------------------------------------------------------------ providers + + @Provide + Arbitrary languages() { + return PropertyArbitraries.languages(); + } + + @Provide + Arbitrary contexts() { + return PropertyArbitraries.contexts(); + } + + @Provide + Arbitrary failureTriggers() { + return Arbitraries.of(FailureTrigger.values()); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/NegotiationErrorPathPropertyTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/NegotiationErrorPathPropertyTest.java index 0b3f1c05..12f3598c 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/NegotiationErrorPathPropertyTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/NegotiationErrorPathPropertyTest.java @@ -1,332 +1,332 @@ -package net.openan.a2at.sdk.corpus.property; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import net.jqwik.api.Arbitraries; -import net.jqwik.api.Arbitrary; -import net.jqwik.api.ForAll; -import net.jqwik.api.Property; -import net.jqwik.api.Provide; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.corpus.ScriptedNegotiationLlmClient; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; - -/** - * Error-path property layer (design §8.3): arbitraries specifically designed to trigger one error code each. - * - *

            Every property samples ~100 inputs built to violate exactly one rule — blank free text, a conclusion that - * contradicts the addressed phase, a payload missing one required field, or a context round above the budget — and - * asserts the exact public error code plus the exact LLM call count (0 when the failure precedes the LLM step, 1 when - * the mapped failure is non-retryable). - * - * @since 2026-08 - */ -class NegotiationErrorPathPropertyTest { - - private static final Map FLAT_SCHEMA = PropertyHarness.objectSchema(Map.of()); - - @Property(tries = 100, seed = "20260910") - void blankTextFailsWithInvalidInput( - @ForAll("languages") String language, - @ForAll("contexts") NegotiationContext context, - @ForAll("blankTexts") String text) { - ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); - NegotiationContentService service = PropertyHarness.service(language, llm); - NegotiationGenerationException propose = assertThrows( - NegotiationGenerationException.class, - () -> service.generateProposeFromText( - text, - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), propose.getCode()); - NegotiationGenerationException abort = assertThrows( - NegotiationGenerationException.class, - () -> service.generateAbortFromText( - text, context, PropertyHarness.templateUri("Negotiation-T/common/abort/v1"))); - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), abort.getCode()); - assertEquals(0, llm.callCount()); - } - - @Property(tries = 100, seed = "20260911") - void conclusionMismatchingThePhaseFailsWithConclusionMismatch( - @ForAll("languages") String language, - @ForAll("contexts") NegotiationContext context, - @ForAll("negotiationTypes") String typeSegment, - @ForAll("swappedConclusions") boolean swapped) { - String rawUri = "Negotiation-T/" + typeSegment + "/accept-reject/v1"; - TemplateUri templateUri = PropertyHarness.templateUri(rawUri); - // An accept call receives a Reject payload and vice versa; all other required fields are present. - String conclusion = swapped ? "Reject" : "Accept"; - ScriptedNegotiationLlmClient llm = PropertyHarness.scripted(endingPayload(conclusion)); - NegotiationContentService service = PropertyHarness.service(language, llm); - NegotiationGenerationException exception; - if (swapped) { - exception = assertThrows( - NegotiationGenerationException.class, - () -> service.generateAcceptFromText("I must refuse the current offer.", context, templateUri)); - } else { - exception = assertThrows( - NegotiationGenerationException.class, - () -> service.generateRejectFromText( - "I cannot agree with the current offer.", context, templateUri)); - } - assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), exception.getCode()); - assertEquals(1, llm.callCount()); - } - - @Property(tries = 100, seed = "20260912") - void payloadMissingOneRequiredFieldFailsWithSlotMissing( - @ForAll("languages") String language, - @ForAll("contexts") NegotiationContext context, - @ForAll("missingRequiredFields") MissingRequiredField field) { - ScriptedNegotiationLlmClient llm = PropertyHarness.scripted(PropertyHarness.json(field.payload())); - NegotiationContentService service = PropertyHarness.service(language, llm); - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> field.run(service, context)); - assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); - assertEquals(1, llm.callCount()); - } - - @Property(tries = 100, seed = "20260913") - void roundAboveTheBudgetFailsWithRuleViolation( - @ForAll("languages") String language, - @ForAll("maxRoundsValues") int maxRounds, - @ForAll("budgetOvershoots") int overshoot) { - // Each leg validates the message of one performative, so its incoming context carries that performative. - NegotiationContext proposeContext = new NegotiationContext( - PropertyArbitraries.anySessionId(), maxRounds + overshoot, maxRounds, NegotiationPerformative.PROPOSE); - NegotiationContext abortContext = new NegotiationContext( - PropertyArbitraries.anySessionId(), maxRounds + overshoot, maxRounds, NegotiationPerformative.ABORT); - ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); - NegotiationContentService service = PropertyHarness.service(language, llm); - NegotiationParamExtractionException propose = assertThrows( - NegotiationParamExtractionException.class, - () -> service.validateProposePromptAndDataFilling( - "Rendered negotiation message text.", - proposeContext, - FLAT_SCHEMA, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); - assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), propose.getCode()); - NegotiationParamExtractionException abort = assertThrows( - NegotiationParamExtractionException.class, - () -> service.validateAbortPromptAndDataFilling( - "Rendered negotiation message text.", - abortContext, - FLAT_SCHEMA, - PropertyHarness.templateUri("Negotiation-T/common/abort/v1"))); - assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), abort.getCode()); - assertEquals(0, llm.callCount()); - } - - // ------------------------------------------------------------------ missing-field matrix - - /** - * One required extraction field dropped from an otherwise valid payload, paired with the API whose payload it is. - */ - private enum MissingRequiredField { - INFORMATION_PROPOSE_ITEMS { - @Override - Map payload() { - return withoutKey(validInformationProposePayload(), "items"); - } - - @Override - Object run(NegotiationContentService service, NegotiationContext context) { - return service.generateProposeFromText( - "Please provide the missing information.", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1")); - } - }, - TARGET_PROPOSE_DESCRIPTION { - @Override - Map payload() { - return withoutKey(validTargetProposePayload(), "target_negotiation_description"); - } - - @Override - Object run(NegotiationContentService service, NegotiationContext context) { - return service.generateProposeFromText( - "I want to negotiate the coverage target.", - context, - PropertyHarness.templateUri("Negotiation-T/target-negotiation/propose/v1")); - } - }, - FEASIBILITY_PROPOSE_DESCRIPTION { - @Override - Map payload() { - return withoutKey(validFeasibilityProposePayload(), "feasibility_negotiation_description"); - } - - @Override - Object run(NegotiationContentService service, NegotiationContext context) { - return service.generateProposeFromText( - "Please evaluate whether the upgrade is feasible.", - context, - PropertyHarness.templateUri("Negotiation-T/feasibility-negotiation/propose/v1")); - } - }, - ENDING_CONCLUSION { - @Override - Map payload() { - return withoutKey(validInformationEndingPayload(), "conclusion"); - } - - @Override - Object run(NegotiationContentService service, NegotiationContext context) { - return service.generateAcceptFromText( - "I accept the proposed information set.", - context, - PropertyHarness.templateUri("Negotiation-T/information-negotiation/accept-reject/v1")); - } - }, - TARGET_ACCEPT_CONFIRMED_INTENT { - @Override - Map payload() { - return withoutKey(validTargetEndingPayload(), "confirmed_intent"); - } - - @Override - Object run(NegotiationContentService service, NegotiationContext context) { - return service.generateAcceptFromText( - "I confirm the negotiated intent.", - context, - PropertyHarness.templateUri("Negotiation-T/target-negotiation/accept-reject/v1")); - } - }, - ABORT_TERMINATION_REASON { - @Override - Map payload() { - return withoutKey(validAbortPayload(), "termination_reason"); - } - - @Override - Object run(NegotiationContentService service, NegotiationContext context) { - return service.generateAbortFromText( - "The round limit is reached.", - context, - PropertyHarness.templateUri("Negotiation-T/common/abort/v1")); - } - }; - - abstract Map payload(); - - abstract Object run(NegotiationContentService service, NegotiationContext context); - - private static Map withoutKey(Map payload, String key) { - Map reduced = new LinkedHashMap<>(payload); - reduced.remove(key); - return reduced; - } - } - - // ------------------------------------------------------------------ payload builders - - private static String endingPayload(String conclusion) { - Map payload = new LinkedHashMap<>(validTargetEndingPayload()); - payload.put("conclusion", conclusion); - return PropertyHarness.json(payload); - } - - private static Map validInformationProposePayload() { - Map payload = new LinkedHashMap<>(); - payload.put( - "items", - List.of(Map.of("name", "access_port", "value", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"))); - payload.put("relationship", null); - return payload; - } - - private static Map validTargetProposePayload() { - Map payload = new LinkedHashMap<>(); - payload.put("target_negotiation_description", "Latency repair target of the quality degradation complaint"); - payload.put("intent_understanding", List.of()); - payload.put("alignment_and_clarification", List.of()); - payload.put("request_for_clarification", List.of()); - return payload; - } - - private static Map validFeasibilityProposePayload() { - Map payload = new LinkedHashMap<>(); - payload.put( - "feasibility_negotiation_description", "Access port expansion feasibility within the cutover window"); - payload.put("action", "REQUEST_FEASIBILITY_EVALUATION"); - payload.put("contents_to_evaluate", List.of(Map.of("name", "latency_target", "value", "within 20ms"))); - payload.put("infeasibility_details_and_proposal", List.of()); - return payload; - } - - private static Map validInformationEndingPayload() { - Map payload = new LinkedHashMap<>(); - payload.put("conclusion", "Accept"); - payload.put( - "items", - List.of(Map.of("name", "access_port", "value", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"))); - return payload; - } - - private static Map validTargetEndingPayload() { - Map payload = new LinkedHashMap<>(); - payload.put("conclusion", "Accept"); - payload.put("confirmed_intent", "Confirmed latency repair intent within 20ms"); - payload.put("failure_reason", null); - return payload; - } - - private static Map validAbortPayload() { - Map payload = new LinkedHashMap<>(); - payload.put("termination_reason", "The negotiation round limit is reached."); - return payload; - } - - // ------------------------------------------------------------------ providers - - @Provide - Arbitrary languages() { - return PropertyArbitraries.languages(); - } - - @Provide - Arbitrary contexts() { - return PropertyArbitraries.contexts(); - } - - @Provide - Arbitrary blankTexts() { - return Arbitraries.strings().withChars(" \t\r\n").ofMinLength(0).ofMaxLength(12); - } - - @Provide - Arbitrary negotiationTypes() { - return Arbitraries.of("information-negotiation", "target-negotiation", "feasibility-negotiation"); - } - - @Provide - Arbitrary swappedConclusions() { - return Arbitraries.of(true, false); - } - - @Provide - Arbitrary missingRequiredFields() { - return Arbitraries.of(MissingRequiredField.values()); - } - - @Provide - Arbitrary maxRoundsValues() { - return Arbitraries.integers().between(1, 10); - } - - @Provide - Arbitrary budgetOvershoots() { - return Arbitraries.integers().between(1, 20); - } -} +package net.openan.a2at.sdk.corpus.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.corpus.ScriptedNegotiationLlmClient; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; + +/** + * Error-path property layer (design §8.3): arbitraries specifically designed to trigger one error code each. + * + *

            Every property samples ~100 inputs built to violate exactly one rule — blank free text, a conclusion that + * contradicts the addressed phase, a payload missing one required field, or a context round above the budget — and + * asserts the exact public error code plus the exact LLM call count (0 when the failure precedes the LLM step, 1 when + * the mapped failure is non-retryable). + * + * @since 2026-08 + */ +class NegotiationErrorPathPropertyTest { + + private static final Map FLAT_SCHEMA = PropertyHarness.objectSchema(Map.of()); + + @Property(tries = 100, seed = "20260910") + void blankTextFailsWithInvalidInput( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("blankTexts") String text) { + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.service(language, llm); + NegotiationGenerationException propose = assertThrows( + NegotiationGenerationException.class, + () -> service.generateProposeFromText( + text, + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), propose.getCode()); + NegotiationGenerationException abort = assertThrows( + NegotiationGenerationException.class, + () -> service.generateAbortFromText( + text, context, PropertyHarness.templateUri("Negotiation-T/common/abort/v1"))); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), abort.getCode()); + assertEquals(0, llm.callCount()); + } + + @Property(tries = 100, seed = "20260911") + void conclusionMismatchingThePhaseFailsWithConclusionMismatch( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("negotiationTypes") String typeSegment, + @ForAll("swappedConclusions") boolean swapped) { + String rawUri = "Negotiation-T/" + typeSegment + "/accept-reject/v1"; + TemplateUri templateUri = PropertyHarness.templateUri(rawUri); + // An accept call receives a Reject payload and vice versa; all other required fields are present. + String conclusion = swapped ? "Reject" : "Accept"; + ScriptedNegotiationLlmClient llm = PropertyHarness.scripted(endingPayload(conclusion)); + NegotiationContentService service = PropertyHarness.service(language, llm); + NegotiationGenerationException exception; + if (swapped) { + exception = assertThrows( + NegotiationGenerationException.class, + () -> service.generateAcceptFromText("I must refuse the current offer.", context, templateUri)); + } else { + exception = assertThrows( + NegotiationGenerationException.class, + () -> service.generateRejectFromText( + "I cannot agree with the current offer.", context, templateUri)); + } + assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), exception.getCode()); + assertEquals(1, llm.callCount()); + } + + @Property(tries = 100, seed = "20260912") + void payloadMissingOneRequiredFieldFailsWithSlotMissing( + @ForAll("languages") String language, + @ForAll("contexts") NegotiationContext context, + @ForAll("missingRequiredFields") MissingRequiredField field) { + ScriptedNegotiationLlmClient llm = PropertyHarness.scripted(PropertyHarness.json(field.payload())); + NegotiationContentService service = PropertyHarness.service(language, llm); + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> field.run(service, context)); + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); + assertEquals(1, llm.callCount()); + } + + @Property(tries = 100, seed = "20260913") + void roundAboveTheBudgetFailsWithRuleViolation( + @ForAll("languages") String language, + @ForAll("maxRoundsValues") int maxRounds, + @ForAll("budgetOvershoots") int overshoot) { + // Each leg validates the message of one performative, so its incoming context carries that performative. + NegotiationContext proposeContext = new NegotiationContext( + PropertyArbitraries.anySessionId(), maxRounds + overshoot, maxRounds, NegotiationPerformative.PROPOSE); + NegotiationContext abortContext = new NegotiationContext( + PropertyArbitraries.anySessionId(), maxRounds + overshoot, maxRounds, NegotiationPerformative.ABORT); + ScriptedNegotiationLlmClient llm = ScriptedNegotiationLlmClient.assertionOnly(); + NegotiationContentService service = PropertyHarness.service(language, llm); + NegotiationParamExtractionException propose = assertThrows( + NegotiationParamExtractionException.class, + () -> service.validateProposePromptAndDataFilling( + "Rendered negotiation message text.", + proposeContext, + FLAT_SCHEMA, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1"))); + assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), propose.getCode()); + NegotiationParamExtractionException abort = assertThrows( + NegotiationParamExtractionException.class, + () -> service.validateAbortPromptAndDataFilling( + "Rendered negotiation message text.", + abortContext, + FLAT_SCHEMA, + PropertyHarness.templateUri("Negotiation-T/common/abort/v1"))); + assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), abort.getCode()); + assertEquals(0, llm.callCount()); + } + + // ------------------------------------------------------------------ missing-field matrix + + /** + * One required extraction field dropped from an otherwise valid payload, paired with the API whose payload it is. + */ + private enum MissingRequiredField { + INFORMATION_PROPOSE_ITEMS { + @Override + Map payload() { + return withoutKey(validInformationProposePayload(), "items"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateProposeFromText( + "Please provide the missing information.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/propose/v1")); + } + }, + TARGET_PROPOSE_DESCRIPTION { + @Override + Map payload() { + return withoutKey(validTargetProposePayload(), "target_negotiation_description"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateProposeFromText( + "I want to negotiate the coverage target.", + context, + PropertyHarness.templateUri("Negotiation-T/target-negotiation/propose/v1")); + } + }, + FEASIBILITY_PROPOSE_DESCRIPTION { + @Override + Map payload() { + return withoutKey(validFeasibilityProposePayload(), "feasibility_negotiation_description"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateProposeFromText( + "Please evaluate whether the upgrade is feasible.", + context, + PropertyHarness.templateUri("Negotiation-T/feasibility-negotiation/propose/v1")); + } + }, + ENDING_CONCLUSION { + @Override + Map payload() { + return withoutKey(validInformationEndingPayload(), "conclusion"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateAcceptFromText( + "I accept the proposed information set.", + context, + PropertyHarness.templateUri("Negotiation-T/information-negotiation/accept-reject/v1")); + } + }, + TARGET_ACCEPT_CONFIRMED_INTENT { + @Override + Map payload() { + return withoutKey(validTargetEndingPayload(), "confirmed_intent"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateAcceptFromText( + "I confirm the negotiated intent.", + context, + PropertyHarness.templateUri("Negotiation-T/target-negotiation/accept-reject/v1")); + } + }, + ABORT_TERMINATION_REASON { + @Override + Map payload() { + return withoutKey(validAbortPayload(), "termination_reason"); + } + + @Override + Object run(NegotiationContentService service, NegotiationContext context) { + return service.generateAbortFromText( + "The round limit is reached.", + context, + PropertyHarness.templateUri("Negotiation-T/common/abort/v1")); + } + }; + + abstract Map payload(); + + abstract Object run(NegotiationContentService service, NegotiationContext context); + + private static Map withoutKey(Map payload, String key) { + Map reduced = new LinkedHashMap<>(payload); + reduced.remove(key); + return reduced; + } + } + + // ------------------------------------------------------------------ payload builders + + private static String endingPayload(String conclusion) { + Map payload = new LinkedHashMap<>(validTargetEndingPayload()); + payload.put("conclusion", conclusion); + return PropertyHarness.json(payload); + } + + private static Map validInformationProposePayload() { + Map payload = new LinkedHashMap<>(); + payload.put( + "items", + List.of(Map.of("name", "access_port", "value", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"))); + payload.put("relationship", null); + return payload; + } + + private static Map validTargetProposePayload() { + Map payload = new LinkedHashMap<>(); + payload.put("target_negotiation_description", "Latency repair target of the quality degradation complaint"); + payload.put("intent_understanding", List.of()); + payload.put("alignment_and_clarification", List.of()); + payload.put("request_for_clarification", List.of()); + return payload; + } + + private static Map validFeasibilityProposePayload() { + Map payload = new LinkedHashMap<>(); + payload.put( + "feasibility_negotiation_description", "Access port expansion feasibility within the cutover window"); + payload.put("action", "REQUEST_FEASIBILITY_EVALUATION"); + payload.put("contents_to_evaluate", List.of(Map.of("name", "latency_target", "value", "within 20ms"))); + payload.put("infeasibility_details_and_proposal", List.of()); + return payload; + } + + private static Map validInformationEndingPayload() { + Map payload = new LinkedHashMap<>(); + payload.put("conclusion", "Accept"); + payload.put( + "items", + List.of(Map.of("name", "access_port", "value", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"))); + return payload; + } + + private static Map validTargetEndingPayload() { + Map payload = new LinkedHashMap<>(); + payload.put("conclusion", "Accept"); + payload.put("confirmed_intent", "Confirmed latency repair intent within 20ms"); + payload.put("failure_reason", null); + return payload; + } + + private static Map validAbortPayload() { + Map payload = new LinkedHashMap<>(); + payload.put("termination_reason", "The negotiation round limit is reached."); + return payload; + } + + // ------------------------------------------------------------------ providers + + @Provide + Arbitrary languages() { + return PropertyArbitraries.languages(); + } + + @Provide + Arbitrary contexts() { + return PropertyArbitraries.contexts(); + } + + @Provide + Arbitrary blankTexts() { + return Arbitraries.strings().withChars(" \t\r\n").ofMinLength(0).ofMaxLength(12); + } + + @Provide + Arbitrary negotiationTypes() { + return Arbitraries.of("information-negotiation", "target-negotiation", "feasibility-negotiation"); + } + + @Provide + Arbitrary swappedConclusions() { + return Arbitraries.of(true, false); + } + + @Provide + Arbitrary missingRequiredFields() { + return Arbitraries.of(MissingRequiredField.values()); + } + + @Provide + Arbitrary maxRoundsValues() { + return Arbitraries.integers().between(1, 10); + } + + @Provide + Arbitrary budgetOvershoots() { + return Arbitraries.integers().between(1, 20); + } +} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java index 8ae4103e..7d95913a 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java @@ -1,179 +1,179 @@ -package net.openan.a2at.sdk.corpus.property; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.corpus.LlmFailMarker; -import net.openan.a2at.sdk.corpus.LlmScriptStep; -import net.openan.a2at.sdk.corpus.ScriptedNegotiationLlmClient; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; - -/** - * Shared plumbing of the jqwik property layer: production-wired service assembly through the real builder, JSON payload - * construction and the scripted-LLM seam reused from the corpus testdata package. - * - *

            Mirroring the {@code CaseEngine} discipline, everything except the LLM client is production assembly; the property - * layer only decides inputs and expected invariants. - * - * @since 2026-08 - */ -final class PropertyHarness { - - /** Default attempt limit of the property runs; the retry partition property depends on this exact value. */ - static final int MAX_ATTEMPTS = 3; - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private PropertyHarness() {} - - /** - * Assembles the production negotiation content service for one property run. - * - * @param language message language such as {@code zh-CN} - * @param llmClient scripted LLM client of the run - * @return production-wired negotiation content service - */ - static NegotiationContentService service(String language, LLMClient llmClient) { - return new NegotiationContentService(orchestrator(language, llmClient)); - } - - /** - * Assembles the production service with a template loader that fails every load, the property-layer stand-in of the - * corpus {@code inject: failingTemplateLoader} hook. - * - * @param language message language - * @param llmClient scripted LLM client of the run - * @return production-wired service whose template loader always misses - */ - static NegotiationContentService serviceWithFailingTemplateLoader(String language, LLMClient llmClient) { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .llmClient(llmClient) - .maxAttempts(MAX_ATTEMPTS) - .templateLoader(failingTemplateLoader()) - .build(); - return new NegotiationContentService(orchestrator); - } - - private static NegotiationGenerationOrchestrator orchestrator(String language, LLMClient llmClient) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .llmClient(llmClient) - .maxAttempts(MAX_ATTEMPTS) - .build(); - } - - private static NegotiationTemplateLoader failingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - }; - } - - /** - * Parses one raw template URI string. - * - * @param raw template URI such as {@code Negotiation-T/information-negotiation/propose/v1} - * @return parsed template URI - */ - static TemplateUri templateUri(String raw) { - return TemplateUri.parse(raw) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); - } - - /** - * Serializes one value into the JSON payload text of a scripted LLM step. - * - * @param value payload value - * @return JSON text - */ - static String json(Object value) { - try { - return MAPPER.writeValueAsString(value); - } catch (JsonProcessingException exception) { - throw new IllegalStateException("The property payload could not be serialized.", exception); - } - } - - /** - * Builds the semantic-validation payload of an accepting verdict carrying the given extracted parameters. - * - * @param negotiationType negotiation type name the verdict reports, or null for the type-independent abort phase - * @param params extracted parameters the scripted validator returns - * @return JSON payload text of the scripted semantic-validation answer - */ - static String semanticVerdict(String negotiationType, Map params) { - Map payload = new LinkedHashMap<>(); - payload.put("semantic_verdict", true); - payload.put("negotiation_type", negotiationType); - payload.put("errors", List.of()); - payload.put("params", params); - return json(payload); - } - - /** - * Creates a scripted client answering exactly one payload step. - * - * @param payloadJson payload text of the single answer - * @return strictly consuming scripted client - */ - static ScriptedNegotiationLlmClient scripted(String payloadJson) { - return new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Payload(payloadJson))); - } - - /** - * Creates a scripted client replaying one failure marker for every attempt of the run. - * - * @param marker failure marker to replay - * @return strictly consuming scripted client that always fails - */ - static ScriptedNegotiationLlmClient failing(LlmFailMarker marker) { - return new ScriptedNegotiationLlmClient(java.util.stream.IntStream.rangeClosed(1, MAX_ATTEMPTS) - .mapToObj(ignored -> (LlmScriptStep) new LlmScriptStep.Fail(marker)) - .toList()); - } - - /** - * Builds a flat object JSON Schema over the given properties. - * - * @param properties per-key type schemas keyed by parameter name - * @return caller parameter schema of a validate run - */ - static Map objectSchema(Map properties) { - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - return schema; - } - - /** - * Derives the JSON Schema type of one parameter value. - * - * @param value parameter value generated by a property - * @return single-key type schema - */ - static Map typeSchema(Object value) { - if (value instanceof String) { - return Map.of("type", "string"); - } - if (value instanceof Boolean) { - return Map.of("type", "boolean"); - } - if (value instanceof Double) { - return Map.of("type", "number"); - } - return Map.of("type", "integer"); - } -} +package net.openan.a2at.sdk.corpus.property; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.corpus.LlmFailMarker; +import net.openan.a2at.sdk.corpus.LlmScriptStep; +import net.openan.a2at.sdk.corpus.ScriptedNegotiationLlmClient; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; + +/** + * Shared plumbing of the jqwik property layer: production-wired service assembly through the real builder, JSON payload + * construction and the scripted-LLM seam reused from the corpus testdata package. + * + *

            Mirroring the {@code CaseEngine} discipline, everything except the LLM client is production assembly; the property + * layer only decides inputs and expected invariants. + * + * @since 2026-08 + */ +final class PropertyHarness { + + /** Default attempt limit of the property runs; the retry partition property depends on this exact value. */ + static final int MAX_ATTEMPTS = 3; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private PropertyHarness() {} + + /** + * Assembles the production negotiation content service for one property run. + * + * @param language message language such as {@code zh-CN} + * @param llmClient scripted LLM client of the run + * @return production-wired negotiation content service + */ + static NegotiationContentService service(String language, LLMClient llmClient) { + return new NegotiationContentService(orchestrator(language, llmClient)); + } + + /** + * Assembles the production service with a template loader that fails every load, the property-layer stand-in of the + * corpus {@code inject: failingTemplateLoader} hook. + * + * @param language message language + * @param llmClient scripted LLM client of the run + * @return production-wired service whose template loader always misses + */ + static NegotiationContentService serviceWithFailingTemplateLoader(String language, LLMClient llmClient) { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .llmClient(llmClient) + .maxAttempts(MAX_ATTEMPTS) + .templateLoader(failingTemplateLoader()) + .build(); + return new NegotiationContentService(orchestrator); + } + + private static NegotiationGenerationOrchestrator orchestrator(String language, LLMClient llmClient) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .llmClient(llmClient) + .maxAttempts(MAX_ATTEMPTS) + .build(); + } + + private static NegotiationTemplateLoader failingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + }; + } + + /** + * Parses one raw template URI string. + * + * @param raw template URI such as {@code Negotiation-T/information-negotiation/propose/v1} + * @return parsed template URI + */ + static TemplateUri templateUri(String raw) { + return TemplateUri.parse(raw) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); + } + + /** + * Serializes one value into the JSON payload text of a scripted LLM step. + * + * @param value payload value + * @return JSON text + */ + static String json(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("The property payload could not be serialized.", exception); + } + } + + /** + * Builds the semantic-validation payload of an accepting verdict carrying the given extracted parameters. + * + * @param negotiationType negotiation type name the verdict reports, or null for the type-independent abort phase + * @param params extracted parameters the scripted validator returns + * @return JSON payload text of the scripted semantic-validation answer + */ + static String semanticVerdict(String negotiationType, Map params) { + Map payload = new LinkedHashMap<>(); + payload.put("semantic_verdict", true); + payload.put("negotiation_type", negotiationType); + payload.put("errors", List.of()); + payload.put("params", params); + return json(payload); + } + + /** + * Creates a scripted client answering exactly one payload step. + * + * @param payloadJson payload text of the single answer + * @return strictly consuming scripted client + */ + static ScriptedNegotiationLlmClient scripted(String payloadJson) { + return new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Payload(payloadJson))); + } + + /** + * Creates a scripted client replaying one failure marker for every attempt of the run. + * + * @param marker failure marker to replay + * @return strictly consuming scripted client that always fails + */ + static ScriptedNegotiationLlmClient failing(LlmFailMarker marker) { + return new ScriptedNegotiationLlmClient(java.util.stream.IntStream.rangeClosed(1, MAX_ATTEMPTS) + .mapToObj(ignored -> (LlmScriptStep) new LlmScriptStep.Fail(marker)) + .toList()); + } + + /** + * Builds a flat object JSON Schema over the given properties. + * + * @param properties per-key type schemas keyed by parameter name + * @return caller parameter schema of a validate run + */ + static Map objectSchema(Map properties) { + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + return schema; + } + + /** + * Derives the JSON Schema type of one parameter value. + * + * @param value parameter value generated by a property + * @return single-key type schema + */ + static Map typeSchema(Object value) { + if (value instanceof String) { + return Map.of("type", "string"); + } + if (value instanceof Boolean) { + return Map.of("type", "boolean"); + } + if (value instanceof Double) { + return Map.of("type", "number"); + } + return Map.of("type", "integer"); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityEndingContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityEndingContent.java index 0c1578a8..51404ed4 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityEndingContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityEndingContent.java @@ -1,11 +1,11 @@ -package net.openan.a2at.sdk.negotiation.content; - -/** - * Content of a feasibility negotiation terminal message. - * - * @param conclusion terminal conclusion of the feasibility negotiation - * @param feasibilitySummary required summary of the feasibility evaluation result - * @since 2026-08 - */ -public record FeasibilityEndingContent(NegotiationConclusion conclusion, String feasibilitySummary) - implements NegotiationEndingContent {} +package net.openan.a2at.sdk.negotiation.content; + +/** + * Content of a feasibility negotiation terminal message. + * + * @param conclusion terminal conclusion of the feasibility negotiation + * @param feasibilitySummary required summary of the feasibility evaluation result + * @since 2026-08 + */ +public record FeasibilityEndingContent(NegotiationConclusion conclusion, String feasibilitySummary) + implements NegotiationEndingContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationEndingContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationEndingContent.java index 3fa5894a..9e290586 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationEndingContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationEndingContent.java @@ -1,13 +1,13 @@ -package net.openan.a2at.sdk.negotiation.content; - -import java.util.List; - -/** - * Content of an information negotiation terminal message. - * - * @param conclusion terminal conclusion of the information negotiation - * @param items information items delivered with the terminal message - * @since 2026-08 - */ -public record InformationEndingContent(NegotiationConclusion conclusion, List items) - implements NegotiationEndingContent {} +package net.openan.a2at.sdk.negotiation.content; + +import java.util.List; + +/** + * Content of an information negotiation terminal message. + * + * @param conclusion terminal conclusion of the information negotiation + * @param items information items delivered with the terminal message + * @since 2026-08 + */ +public record InformationEndingContent(NegotiationConclusion conclusion, List items) + implements NegotiationEndingContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationProposeContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationProposeContent.java index c5914084..01406c0d 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationProposeContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationProposeContent.java @@ -1,13 +1,13 @@ -package net.openan.a2at.sdk.negotiation.content; - -import java.util.List; - -/** - * Content of an information negotiation propose message. - * - * @param items information items the requester is missing - * @param relationship free-form description of how the missing items relate to each other; null when there is none - * @since 2026-08 - */ -public record InformationProposeContent(List items, String relationship) - implements NegotiationProposeContent {} +package net.openan.a2at.sdk.negotiation.content; + +import java.util.List; + +/** + * Content of an information negotiation propose message. + * + * @param items information items the requester is missing + * @param relationship free-form description of how the missing items relate to each other; null when there is none + * @since 2026-08 + */ +public record InformationProposeContent(List items, String relationship) + implements NegotiationProposeContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAction.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAction.java index 24ab6ca3..2bfbf66f 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAction.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAction.java @@ -1,18 +1,18 @@ -package net.openan.a2at.sdk.negotiation.content; - -/** - * Action a feasibility negotiation propose message performs. - * - *

            The action is not rendered into the message; it selects which conditional sections of the feasibility propose - * template are emitted. - * - * @since 2026-08 - */ -public enum NegotiationAction { - - /** Asks the counterpart to evaluate whether the request is feasible. */ - REQUEST_FEASIBILITY_EVALUATION, - - /** Reports that the request is infeasible and proposes an alternative. */ - PROPOSE_ALTERNATIVE_ON_FAILURE -} +package net.openan.a2at.sdk.negotiation.content; + +/** + * Action a feasibility negotiation propose message performs. + * + *

            The action is not rendered into the message; it selects which conditional sections of the feasibility propose + * template are emitted. + * + * @since 2026-08 + */ +public enum NegotiationAction { + + /** Asks the counterpart to evaluate whether the request is feasible. */ + REQUEST_FEASIBILITY_EVALUATION, + + /** Reports that the request is infeasible and proposes an alternative. */ + PROPOSE_ALTERNATIVE_ON_FAILURE +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationConclusion.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationConclusion.java index 0e04a46d..a2ba389c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationConclusion.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationConclusion.java @@ -1,37 +1,37 @@ -package net.openan.a2at.sdk.negotiation.content; - -/** - * Terminal outcome of a negotiation as expressed in negotiation result sections. - * - *

            Only {@code ACCEPT} and {@code REJECT} are renderable conclusions of the typed negotiation templates; the - * {@code ABORT} outcome is rendered through the type-independent common abort template, whose fixed conclusion section - * carries no conclusion slot, so the typed generation methods reject it as a programming error. - * - * @since 2026-08 - */ -public enum NegotiationConclusion { - - /** The negotiation parties reached an agreement. */ - ACCEPT("Accept"), - - /** The negotiation parties did not reach an agreement. */ - REJECT("Reject"), - - /** The negotiation was abandoned outside the template outcome model. */ - ABORT("Abort"); - - private final String literal; - - NegotiationConclusion(String literal) { - this.literal = literal; - } - - /** - * Returns the literal text filled into negotiation result slots. - * - * @return literal conclusion text such as {@code Accept} - */ - public String literal() { - return literal; - } -} +package net.openan.a2at.sdk.negotiation.content; + +/** + * Terminal outcome of a negotiation as expressed in negotiation result sections. + * + *

            Only {@code ACCEPT} and {@code REJECT} are renderable conclusions of the typed negotiation templates; the + * {@code ABORT} outcome is rendered through the type-independent common abort template, whose fixed conclusion section + * carries no conclusion slot, so the typed generation methods reject it as a programming error. + * + * @since 2026-08 + */ +public enum NegotiationConclusion { + + /** The negotiation parties reached an agreement. */ + ACCEPT("Accept"), + + /** The negotiation parties did not reach an agreement. */ + REJECT("Reject"), + + /** The negotiation was abandoned outside the template outcome model. */ + ABORT("Abort"); + + private final String literal; + + NegotiationConclusion(String literal) { + this.literal = literal; + } + + /** + * Returns the literal text filled into negotiation result slots. + * + * @return literal conclusion text such as {@code Accept} + */ + public String literal() { + return literal; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java index e09b9576..51aceef3 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java @@ -1,13 +1,13 @@ -package net.openan.a2at.sdk.negotiation.content; - -/** - * Marker for the typed content of any negotiation message, regardless of phase. - * - *

            The concrete families are {@link NegotiationProposeContent} for propose-phase messages, - * {@link NegotiationEndingContent} for terminal accept/reject messages and {@link NegotiationAbortContent} for the - * type-independent abort message. Generators and content extractors accept this common supertype and dispatch on the - * exact runtime type. - * - * @since 2026-08 - */ -public sealed interface NegotiationContent permits NegotiationProposeContent, NegotiationEndingContent, NegotiationAbortContent {} +package net.openan.a2at.sdk.negotiation.content; + +/** + * Marker for the typed content of any negotiation message, regardless of phase. + * + *

            The concrete families are {@link NegotiationProposeContent} for propose-phase messages, + * {@link NegotiationEndingContent} for terminal accept/reject messages and {@link NegotiationAbortContent} for the + * type-independent abort message. Generators and content extractors accept this common supertype and dispatch on the + * exact runtime type. + * + * @since 2026-08 + */ +public sealed interface NegotiationContent permits NegotiationProposeContent, NegotiationEndingContent, NegotiationAbortContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java index 598cce6d..b709f012 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java @@ -1,17 +1,17 @@ -package net.openan.a2at.sdk.negotiation.content; - -/** - * Marker for the typed content of a terminal (accept or reject) negotiation message. - * - * @since 2026-08 - */ -public sealed interface NegotiationEndingContent extends NegotiationContent - permits InformationEndingContent, TargetEndingContent, FeasibilityEndingContent { - - /** - * Returns the terminal conclusion this content carries. - * - * @return negotiation conclusion - */ - NegotiationConclusion conclusion(); -} +package net.openan.a2at.sdk.negotiation.content; + +/** + * Marker for the typed content of a terminal (accept or reject) negotiation message. + * + * @since 2026-08 + */ +public sealed interface NegotiationEndingContent extends NegotiationContent + permits InformationEndingContent, TargetEndingContent, FeasibilityEndingContent { + + /** + * Returns the terminal conclusion this content carries. + * + * @return negotiation conclusion + */ + NegotiationConclusion conclusion(); +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java index b0372214..d29e21c3 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java @@ -1,62 +1,62 @@ -package net.openan.a2at.sdk.negotiation.content; - -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import org.jspecify.annotations.Nullable; - -/** - * Raised when generating a negotiation message fails at runtime. - * - * @since 2026-08 - */ -public class NegotiationGenerationException extends NegotiationProcessingException { - - /** - * Creates a negotiation generation failure with one error code. - * - * @param code machine-readable error code for the failure - * @param message failure message - */ - public NegotiationGenerationException(String code, String message) { - super(code, message); - } - - /** - * Creates a negotiation generation failure with one error code and a root cause. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param cause root cause - */ - public NegotiationGenerationException(String code, String message, @Nullable Throwable cause) { - super(code, message, cause); - } - - /** - * Creates a negotiation generation failure whose message is rendered from the code's template in one language. - * - * @param entry catalog entry identifying the failure - * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} - * @param facts fact values keyed by fact parameter name, may be null - */ - public NegotiationGenerationException( - ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { - super(entry, language, facts); - } - - /** - * Creates a negotiation generation failure with a rendered message and a root cause. - * - * @param entry catalog entry identifying the failure - * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} - * @param facts fact values keyed by fact parameter name, may be null - * @param cause root cause - */ - public NegotiationGenerationException( - ErrorCatalog entry, - @Nullable String language, - @Nullable Map facts, - @Nullable Throwable cause) { - super(entry, language, facts, cause); - } -} +package net.openan.a2at.sdk.negotiation.content; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import org.jspecify.annotations.Nullable; + +/** + * Raised when generating a negotiation message fails at runtime. + * + * @since 2026-08 + */ +public class NegotiationGenerationException extends NegotiationProcessingException { + + /** + * Creates a negotiation generation failure with one error code. + * + * @param code machine-readable error code for the failure + * @param message failure message + */ + public NegotiationGenerationException(String code, String message) { + super(code, message); + } + + /** + * Creates a negotiation generation failure with one error code and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param cause root cause + */ + public NegotiationGenerationException(String code, String message, @Nullable Throwable cause) { + super(code, message, cause); + } + + /** + * Creates a negotiation generation failure whose message is rendered from the code's template in one language. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + */ + public NegotiationGenerationException( + ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { + super(entry, language, facts); + } + + /** + * Creates a negotiation generation failure with a rendered message and a root cause. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param cause root cause + */ + public NegotiationGenerationException( + ErrorCatalog entry, + @Nullable String language, + @Nullable Map facts, + @Nullable Throwable cause) { + super(entry, language, facts, cause); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationItem.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationItem.java index 381eacab..a5e8dd86 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationItem.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationItem.java @@ -1,10 +1,10 @@ -package net.openan.a2at.sdk.negotiation.content; - -/** - * One named entry of a negotiation item list. - * - * @param name item name such as a field path or identifier; never blank in valid content - * @param value free-form explanation of the item such as meaning, format, example or reason; may be null - * @since 2026-08 - */ -public record NegotiationItem(String name, String value) {} +package net.openan.a2at.sdk.negotiation.content; + +/** + * One named entry of a negotiation item list. + * + * @param name item name such as a field path or identifier; never blank in valid content + * @param value free-form explanation of the item such as meaning, format, example or reason; may be null + * @since 2026-08 + */ +public record NegotiationItem(String name, String value) {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationParamExtractionException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationParamExtractionException.java index c7407ab8..f730641a 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationParamExtractionException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationParamExtractionException.java @@ -1,120 +1,120 @@ -package net.openan.a2at.sdk.negotiation.content; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATBusinessException; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import org.jspecify.annotations.Nullable; - -/** - * Raised when validating a negotiation message and extracting parameters from it fails. - * - * @since 2026-08 - */ -public class NegotiationParamExtractionException extends A2ATBusinessException { - - private final List errors; - - /** - * Creates a negotiation parameter-extraction failure with one specific error code and slot details. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param errors structured per-slot validation error details - */ - public NegotiationParamExtractionException(String code, String message, List errors) { - super(code, message); - this.errors = errors == null ? List.of() : List.copyOf(errors); - } - - /** - * Creates a negotiation parameter-extraction failure with one specific error code, slot details and a root cause. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param errors structured per-slot validation error details - * @param cause root cause of the failure - */ - public NegotiationParamExtractionException( - String code, String message, List errors, @Nullable Throwable cause) { - super(code, message); - if (cause != null) { - initCause(cause); - } - this.errors = errors == null ? List.of() : List.copyOf(errors); - } - - /** - * Creates a negotiation parameter-extraction failure carrying one catalog code, an already rendered message, its - * fact values, slot details and a root cause. - * - *

            Unlike the {@code ErrorCatalog}-rendering constructors, this constructor keeps the message exactly as passed - * in, so a message rendered upstream (for example by the shared validation pipeline) survives the wrap unchanged - * while the facts stay available to callers. - * - * @param code machine-readable error code for the failure - * @param message already rendered failure message - * @param facts fact values keyed by fact parameter name, may be null - * @param errors structured per-slot validation error details - * @param cause root cause of the failure - */ - public NegotiationParamExtractionException( - String code, - String message, - @Nullable Map facts, - List errors, - @Nullable Throwable cause) { - super(code, message, facts, cause); - this.errors = errors == null ? List.of() : List.copyOf(errors); - } - - /** - * Creates a negotiation parameter-extraction failure whose message is rendered from the code's template in one - * language. - * - * @param entry catalog entry identifying the failure - * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} - * @param facts fact values keyed by fact parameter name, may be null - * @param errors structured per-slot validation error details - */ - public NegotiationParamExtractionException( - ErrorCatalog entry, - @Nullable String language, - @Nullable Map facts, - List errors) { - this(entry, language, facts, errors, null); - } - - /** - * Creates a negotiation parameter-extraction failure with a rendered message, slot details and a root cause. - * - * @param entry catalog entry identifying the failure - * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} - * @param facts fact values keyed by fact parameter name, may be null - * @param errors structured per-slot validation error details - * @param cause root cause of the failure - */ - public NegotiationParamExtractionException( - ErrorCatalog entry, - @Nullable String language, - @Nullable Map facts, - List errors, - @Nullable Throwable cause) { - super(entry.getCode(), ErrorMessages.render(entry, language, facts), facts); - if (cause != null) { - initCause(cause); - } - this.errors = errors == null ? List.of() : List.copyOf(errors); - } - - /** - * Returns the structured per-slot validation error details. - * - * @return immutable list of slot validation errors, never null - */ - public List getErrors() { - return errors; - } -} +package net.openan.a2at.sdk.negotiation.content; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import org.jspecify.annotations.Nullable; + +/** + * Raised when validating a negotiation message and extracting parameters from it fails. + * + * @since 2026-08 + */ +public class NegotiationParamExtractionException extends A2ATBusinessException { + + private final List errors; + + /** + * Creates a negotiation parameter-extraction failure with one specific error code and slot details. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + */ + public NegotiationParamExtractionException(String code, String message, List errors) { + super(code, message); + this.errors = errors == null ? List.of() : List.copyOf(errors); + } + + /** + * Creates a negotiation parameter-extraction failure with one specific error code, slot details and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + * @param cause root cause of the failure + */ + public NegotiationParamExtractionException( + String code, String message, List errors, @Nullable Throwable cause) { + super(code, message); + if (cause != null) { + initCause(cause); + } + this.errors = errors == null ? List.of() : List.copyOf(errors); + } + + /** + * Creates a negotiation parameter-extraction failure carrying one catalog code, an already rendered message, its + * fact values, slot details and a root cause. + * + *

            Unlike the {@code ErrorCatalog}-rendering constructors, this constructor keeps the message exactly as passed + * in, so a message rendered upstream (for example by the shared validation pipeline) survives the wrap unchanged + * while the facts stay available to callers. + * + * @param code machine-readable error code for the failure + * @param message already rendered failure message + * @param facts fact values keyed by fact parameter name, may be null + * @param errors structured per-slot validation error details + * @param cause root cause of the failure + */ + public NegotiationParamExtractionException( + String code, + String message, + @Nullable Map facts, + List errors, + @Nullable Throwable cause) { + super(code, message, facts, cause); + this.errors = errors == null ? List.of() : List.copyOf(errors); + } + + /** + * Creates a negotiation parameter-extraction failure whose message is rendered from the code's template in one + * language. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param errors structured per-slot validation error details + */ + public NegotiationParamExtractionException( + ErrorCatalog entry, + @Nullable String language, + @Nullable Map facts, + List errors) { + this(entry, language, facts, errors, null); + } + + /** + * Creates a negotiation parameter-extraction failure with a rendered message, slot details and a root cause. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param errors structured per-slot validation error details + * @param cause root cause of the failure + */ + public NegotiationParamExtractionException( + ErrorCatalog entry, + @Nullable String language, + @Nullable Map facts, + List errors, + @Nullable Throwable cause) { + super(entry.getCode(), ErrorMessages.render(entry, language, facts), facts); + if (cause != null) { + initCause(cause); + } + this.errors = errors == null ? List.of() : List.copyOf(errors); + } + + /** + * Returns the structured per-slot validation error details. + * + * @return immutable list of slot validation errors, never null + */ + public List getErrors() { + return errors; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java index ae02cdeb..7a762bb1 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java @@ -1,76 +1,76 @@ -package net.openan.a2at.sdk.negotiation.content; - -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATBusinessException; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import org.jspecify.annotations.Nullable; - -/** - * Base failure type of the generation branch, raised when a negotiation generation step fails at runtime. - * - *

            Each failure carries a machine-readable code from {@link ErrorCatalog} — inherited from the - * {@link A2ATBusinessException} base — and a message rendered from the code's message template, so callers can branch - * on the failure class without parsing messages. The parameter-extraction branch - * ({@code NegotiationParamExtractionException}) is deliberately not a subtype; catch {@link A2ATBusinessException} for - * full negotiation business-failure coverage. - * - * @since 2026-08 - */ -public class NegotiationProcessingException extends A2ATBusinessException { - - /** - * Creates a negotiation processing failure with one error code. - * - * @param code machine-readable error code for the failure - * @param message failure message - */ - public NegotiationProcessingException(String code, String message) { - super(code, message); - } - - /** - * Creates a negotiation processing failure with one error code and a root cause. - * - * @param code machine-readable error code for the failure - * @param message failure message - * @param cause root cause - */ - public NegotiationProcessingException(String code, String message, @Nullable Throwable cause) { - super(code, message); - if (cause != null) { - initCause(cause); - } - } - - /** - * Creates a negotiation processing failure whose message is rendered from the code's template in one language. - * - * @param entry catalog entry identifying the failure - * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} - * @param facts fact values keyed by fact parameter name, may be null - */ - public NegotiationProcessingException( - ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { - super(entry.getCode(), ErrorMessages.render(entry, language, facts), facts); - } - - /** - * Creates a negotiation processing failure with a rendered message and a root cause. - * - * @param entry catalog entry identifying the failure - * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} - * @param facts fact values keyed by fact parameter name, may be null - * @param cause root cause - */ - public NegotiationProcessingException( - ErrorCatalog entry, - @Nullable String language, - @Nullable Map facts, - @Nullable Throwable cause) { - super(entry.getCode(), ErrorMessages.render(entry, language, facts), facts); - if (cause != null) { - initCause(cause); - } - } -} +package net.openan.a2at.sdk.negotiation.content; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import org.jspecify.annotations.Nullable; + +/** + * Base failure type of the generation branch, raised when a negotiation generation step fails at runtime. + * + *

            Each failure carries a machine-readable code from {@link ErrorCatalog} — inherited from the + * {@link A2ATBusinessException} base — and a message rendered from the code's message template, so callers can branch + * on the failure class without parsing messages. The parameter-extraction branch + * ({@code NegotiationParamExtractionException}) is deliberately not a subtype; catch {@link A2ATBusinessException} for + * full negotiation business-failure coverage. + * + * @since 2026-08 + */ +public class NegotiationProcessingException extends A2ATBusinessException { + + /** + * Creates a negotiation processing failure with one error code. + * + * @param code machine-readable error code for the failure + * @param message failure message + */ + public NegotiationProcessingException(String code, String message) { + super(code, message); + } + + /** + * Creates a negotiation processing failure with one error code and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param cause root cause + */ + public NegotiationProcessingException(String code, String message, @Nullable Throwable cause) { + super(code, message); + if (cause != null) { + initCause(cause); + } + } + + /** + * Creates a negotiation processing failure whose message is rendered from the code's template in one language. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + */ + public NegotiationProcessingException( + ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { + super(entry.getCode(), ErrorMessages.render(entry, language, facts), facts); + } + + /** + * Creates a negotiation processing failure with a rendered message and a root cause. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param cause root cause + */ + public NegotiationProcessingException( + ErrorCatalog entry, + @Nullable String language, + @Nullable Map facts, + @Nullable Throwable cause) { + super(entry.getCode(), ErrorMessages.render(entry, language, facts), facts); + if (cause != null) { + initCause(cause); + } + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java index 4cd873da..a32989f7 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java @@ -1,9 +1,9 @@ -package net.openan.a2at.sdk.negotiation.content; - -/** - * Marker for the typed content of a propose-phase negotiation message. - * - * @since 2026-08 - */ -public sealed interface NegotiationProposeContent extends NegotiationContent - permits InformationProposeContent, TargetProposeContent, FeasibilityProposeContent {} +package net.openan.a2at.sdk.negotiation.content; + +/** + * Marker for the typed content of a propose-phase negotiation message. + * + * @since 2026-08 + */ +public sealed interface NegotiationProposeContent extends NegotiationContent + permits InformationProposeContent, TargetProposeContent, FeasibilityProposeContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeData.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeData.java index 630db9e3..1be87f3c 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeData.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeData.java @@ -1,12 +1,12 @@ -package net.openan.a2at.sdk.negotiation.content; - -import net.openan.a2at.sdk.core.model.NegotiationContext; - -/** - * Input bundle for generating a propose-phase negotiation message from typed data. - * - * @param context negotiation session context - * @param content typed propose content matching the negotiation type addressed by the template URI - * @since 2026-08 - */ -public record NegotiationProposeData(NegotiationContext context, NegotiationProposeContent content) {} +package net.openan.a2at.sdk.negotiation.content; + +import net.openan.a2at.sdk.core.model.NegotiationContext; + +/** + * Input bundle for generating a propose-phase negotiation message from typed data. + * + * @param context negotiation session context + * @param content typed propose content matching the negotiation type addressed by the template URI + * @since 2026-08 + */ +public record NegotiationProposeData(NegotiationContext context, NegotiationProposeContent content) {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationType.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationType.java index d537e875..c8f4b970 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationType.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationType.java @@ -1,36 +1,36 @@ -package net.openan.a2at.sdk.negotiation.content; - -/** - * Kind of negotiation a negotiation message belongs to. - * - *

            The enum also owns the hyphenated URI segment used both in template URIs and in template resource paths, so that - * URIs and on-disk locations can never drift apart. - * - * @since 2026-08 - */ -public enum NegotiationType { - - /** Negotiation about missing information the requester needs. */ - INFORMATION("information-negotiation"), - - /** Negotiation about aligning the target and intent of a request. */ - TARGET("target-negotiation"), - - /** Negotiation about whether a request can be fulfilled at all. */ - FEASIBILITY("feasibility-negotiation"); - - private final String typeSegment; - - NegotiationType(String typeSegment) { - this.typeSegment = typeSegment; - } - - /** - * Returns the hyphenated URI segment identifying this negotiation type. - * - * @return URI segment such as {@code information-negotiation} - */ - public String typeSegment() { - return typeSegment; - } -} +package net.openan.a2at.sdk.negotiation.content; + +/** + * Kind of negotiation a negotiation message belongs to. + * + *

            The enum also owns the hyphenated URI segment used both in template URIs and in template resource paths, so that + * URIs and on-disk locations can never drift apart. + * + * @since 2026-08 + */ +public enum NegotiationType { + + /** Negotiation about missing information the requester needs. */ + INFORMATION("information-negotiation"), + + /** Negotiation about aligning the target and intent of a request. */ + TARGET("target-negotiation"), + + /** Negotiation about whether a request can be fulfilled at all. */ + FEASIBILITY("feasibility-negotiation"); + + private final String typeSegment; + + NegotiationType(String typeSegment) { + this.typeSegment = typeSegment; + } + + /** + * Returns the hyphenated URI segment identifying this negotiation type. + * + * @return URI segment such as {@code information-negotiation} + */ + public String typeSegment() { + return typeSegment; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetEndingContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetEndingContent.java index 63d0889a..2558b5fa 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetEndingContent.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetEndingContent.java @@ -1,15 +1,15 @@ -package net.openan.a2at.sdk.negotiation.content; - -/** - * Content of a target negotiation terminal message. - * - *

            Exactly one of {@code confirmedIntent} and {@code failureReason} is meaningful, selected by the conclusion: an - * accept message must carry the confirmed intent, a reject message must carry the failure reason. - * - * @param conclusion terminal conclusion of the target negotiation - * @param confirmedIntent confirmed intent for an accept conclusion; null otherwise - * @param failureReason reason for a reject conclusion; null otherwise - * @since 2026-08 - */ -public record TargetEndingContent(NegotiationConclusion conclusion, String confirmedIntent, String failureReason) - implements NegotiationEndingContent {} +package net.openan.a2at.sdk.negotiation.content; + +/** + * Content of a target negotiation terminal message. + * + *

            Exactly one of {@code confirmedIntent} and {@code failureReason} is meaningful, selected by the conclusion: an + * accept message must carry the confirmed intent, a reject message must carry the failure reason. + * + * @param conclusion terminal conclusion of the target negotiation + * @param confirmedIntent confirmed intent for an accept conclusion; null otherwise + * @param failureReason reason for a reject conclusion; null otherwise + * @since 2026-08 + */ +public record TargetEndingContent(NegotiationConclusion conclusion, String confirmedIntent, String failureReason) + implements NegotiationEndingContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java index 08f0c357..1281255b 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java @@ -1,231 +1,231 @@ -package net.openan.a2at.sdk.negotiation.content; - -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; -import net.openan.a2at.sdk.core.resources.PathSegments; -import org.jspecify.annotations.Nullable; - -/** - * Language-specific text constants for negotiation templates. - * - *

            A vocabulary is the single source of the section titles, slot names, appended line labels and list punctuation - * used when rendering negotiation messages and when recognising sections inside received messages. The canonical keys - * are language-neutral; every supported language exposes exactly the same key set, while the values match the bundled - * template bytes of that language verbatim. - * - *

            The values are file-driven: each language resolves {@code negotiation-vocabulary/{language}/vocabulary.json} from - * the built-in classpath resources — negotiation vocabularies are classpath-fixed and never configurable. The bundled - * bytes are validated against the pinned {@link #CANONICAL_KEYS} set, and a vocabulary that does not exist, cannot be - * read, is malformed (including duplicate JSON keys or blank values) or drifts from the canonical key set fails fast - * instead of silently degrading. - * - *

            The built-in classpath vocabulary is resolved once per JVM and context classloader and frozen as an assembly-time - * snapshot (jar bytes are immutable). - * - * @since 2026-08 - */ -public final class Vocabulary { - - private static final String VOCABULARY_DIRECTORY = "negotiation-vocabulary"; - - private static final String VOCABULARY_FILE_NAME = "vocabulary.json"; - - private static final String CLASSPATH_ROOT = "prompt_resources/" + VOCABULARY_DIRECTORY + "/"; - - private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() - .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) - .build(); - - private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); - - /** The language-neutral canonical key set every vocabulary file must define exactly, in a fixed order. */ - public static final List CANONICAL_KEYS = List.of( - "section.termination_reason", - "section.info_items", - "section.info_static", - "section.info_conclusion", - "section.info_result_content", - "section.target", - "section.target_intent", - "section.target_alignment", - "section.target_clarification", - "section.target_confirm_request", - "section.target_conclusion", - "section.target_result_content", - "section.feasibility", - "section.feasibility_evaluate", - "section.feasibility_infeasible", - "section.feasibility_confirm_request", - "section.feasibility_conclusion", - "section.feasibility_confirm", - "slot.termination_reason", - "slot.info_items", - "slot.info_conclusion", - "slot.info_result_content", - "slot.target", - "slot.target_intent", - "slot.target_alignment", - "slot.target_clarification", - "slot.target_confirm_request", - "slot.target_conclusion", - "slot.target_result_content", - "slot.feasibility", - "slot.feasibility_evaluate", - "slot.feasibility_infeasible", - "slot.feasibility_confirm_request", - "slot.feasibility_conclusion", - "slot.feasibility_confirm", - "label.relationship", - "punct.list_colon"); - - private static final Set CANONICAL_KEY_SET = Set.copyOf(CANONICAL_KEYS); - - private final String language; - - private final Map entries; - - private Vocabulary(String language, Map entries) { - this.language = language; - this.entries = entries; - } - - /** - * Returns the vocabulary for one language, resolved and frozen from the built-in classpath resources. - * - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @return vocabulary holding the text constants of that language - * @throws IllegalArgumentException if the language has no bundled vocabulary, or the bundled file is unreadable, - * malformed or does not define exactly the canonical key set - */ - public static Vocabulary forLanguage(String language) { - if (!PathSegments.isSimpleSegment(language)) { - throw new IllegalArgumentException( - "Negotiation vocabulary language must be a non-blank simple path segment but was " + language - + "."); - } - CacheKey cacheKey = new CacheKey(language, Thread.currentThread().getContextClassLoader()); - return CACHE.computeIfAbsent(cacheKey, key -> load(key.language)); - } - - /** - * Returns the text constant registered under one canonical key. - * - * @param canonicalKey canonical vocabulary key such as {@code section.termination_reason} or - * {@code punct.list_colon} - * @return language-specific text constant - * @throws IllegalArgumentException if the key is not part of the vocabulary - */ - public String get(String canonicalKey) { - String value = entries.get(canonicalKey); - if (value == null) { - throw new IllegalArgumentException( - "Unknown negotiation vocabulary key " + canonicalKey + " for language " + language + "."); - } - return value; - } - - /** - * Returns all canonical keys exposed by this vocabulary. - * - * @return immutable set of canonical keys, identical for every supported language - */ - public Set canonicalKeys() { - return entries.keySet(); - } - - /** - * Returns the language this vocabulary is bound to. - * - * @return locale identifier such as {@code zh-CN} - */ - public String language() { - return language; - } - - private static Vocabulary load(String language) { - String classpathPath = CLASSPATH_ROOT + language + "/" + VOCABULARY_FILE_NAME; - InputStream stream = ClasspathResourceStreams.open(classpathPath); - if (stream == null) { - throw new IllegalArgumentException("Negotiation vocabulary does not exist for the configured language " - + language + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly."); - } - try (stream) { - return parse(language, new String(stream.readAllBytes(), StandardCharsets.UTF_8), classpathPath); - } catch (IOException exception) { - throw new IllegalArgumentException( - "Failed to read the negotiation vocabulary " + classpathPath + " for language " + language - + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly.", - exception); - } - } - - private static Vocabulary parse(String language, String payload, String origin) { - JsonNode root; - try { - root = OBJECT_MAPPER.readTree(payload); - } catch (JsonProcessingException exception) { - String detail = - exception.getMessage() != null && exception.getMessage().contains("Duplicate field") - ? "it contains duplicate keys" - : "it is not valid JSON"; - throw malformed(language, origin, detail, exception); - } - if (root == null || !root.isObject()) { - throw malformed(language, origin, "it is not a flat JSON object of string values", null); - } - Map entries = new LinkedHashMap<>(); - root.fields().forEachRemaining(field -> { - JsonNode value = field.getValue(); - if (!value.isTextual()) { - throw malformed(language, origin, "the value of key '" + field.getKey() + "' is not a string", null); - } - String text = value.textValue(); - if (text.isBlank()) { - throw malformed(language, origin, "the value of key '" + field.getKey() + "' is blank", null); - } - entries.put(field.getKey(), text); - }); - validateCanonicalKeys(language, entries, origin); - return new Vocabulary(language, Collections.unmodifiableMap(entries)); - } - - private static void validateCanonicalKeys(String language, Map entries, String origin) { - Set missing = new LinkedHashSet<>(CANONICAL_KEYS); - missing.removeAll(entries.keySet()); - Set unexpected = new LinkedHashSet<>(entries.keySet()); - unexpected.removeAll(CANONICAL_KEY_SET); - if (missing.isEmpty() && unexpected.isEmpty()) { - return; - } - throw new IllegalArgumentException("Negotiation vocabulary " + origin + " for language " + language - + " must define exactly the canonical vocabulary keys; missing keys: " + missing - + ", unexpected keys: " + unexpected + "."); - } - - private static IllegalArgumentException malformed( - String language, String origin, String detail, @Nullable Throwable cause) { - IllegalArgumentException exception = new IllegalArgumentException( - "Negotiation vocabulary " + origin + " for language " + language + " is malformed: " + detail - + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly."); - if (cause != null) { - exception.initCause(cause); - } - return exception; - } - - private record CacheKey(String language, @Nullable ClassLoader contextClassLoader) {} -} +package net.openan.a2at.sdk.negotiation.content; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.core.resources.PathSegments; +import org.jspecify.annotations.Nullable; + +/** + * Language-specific text constants for negotiation templates. + * + *

            A vocabulary is the single source of the section titles, slot names, appended line labels and list punctuation + * used when rendering negotiation messages and when recognising sections inside received messages. The canonical keys + * are language-neutral; every supported language exposes exactly the same key set, while the values match the bundled + * template bytes of that language verbatim. + * + *

            The values are file-driven: each language resolves {@code negotiation-vocabulary/{language}/vocabulary.json} from + * the built-in classpath resources — negotiation vocabularies are classpath-fixed and never configurable. The bundled + * bytes are validated against the pinned {@link #CANONICAL_KEYS} set, and a vocabulary that does not exist, cannot be + * read, is malformed (including duplicate JSON keys or blank values) or drifts from the canonical key set fails fast + * instead of silently degrading. + * + *

            The built-in classpath vocabulary is resolved once per JVM and context classloader and frozen as an assembly-time + * snapshot (jar bytes are immutable). + * + * @since 2026-08 + */ +public final class Vocabulary { + + private static final String VOCABULARY_DIRECTORY = "negotiation-vocabulary"; + + private static final String VOCABULARY_FILE_NAME = "vocabulary.json"; + + private static final String CLASSPATH_ROOT = "prompt_resources/" + VOCABULARY_DIRECTORY + "/"; + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() + .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .build(); + + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + /** The language-neutral canonical key set every vocabulary file must define exactly, in a fixed order. */ + public static final List CANONICAL_KEYS = List.of( + "section.termination_reason", + "section.info_items", + "section.info_static", + "section.info_conclusion", + "section.info_result_content", + "section.target", + "section.target_intent", + "section.target_alignment", + "section.target_clarification", + "section.target_confirm_request", + "section.target_conclusion", + "section.target_result_content", + "section.feasibility", + "section.feasibility_evaluate", + "section.feasibility_infeasible", + "section.feasibility_confirm_request", + "section.feasibility_conclusion", + "section.feasibility_confirm", + "slot.termination_reason", + "slot.info_items", + "slot.info_conclusion", + "slot.info_result_content", + "slot.target", + "slot.target_intent", + "slot.target_alignment", + "slot.target_clarification", + "slot.target_confirm_request", + "slot.target_conclusion", + "slot.target_result_content", + "slot.feasibility", + "slot.feasibility_evaluate", + "slot.feasibility_infeasible", + "slot.feasibility_confirm_request", + "slot.feasibility_conclusion", + "slot.feasibility_confirm", + "label.relationship", + "punct.list_colon"); + + private static final Set CANONICAL_KEY_SET = Set.copyOf(CANONICAL_KEYS); + + private final String language; + + private final Map entries; + + private Vocabulary(String language, Map entries) { + this.language = language; + this.entries = entries; + } + + /** + * Returns the vocabulary for one language, resolved and frozen from the built-in classpath resources. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @return vocabulary holding the text constants of that language + * @throws IllegalArgumentException if the language has no bundled vocabulary, or the bundled file is unreadable, + * malformed or does not define exactly the canonical key set + */ + public static Vocabulary forLanguage(String language) { + if (!PathSegments.isSimpleSegment(language)) { + throw new IllegalArgumentException( + "Negotiation vocabulary language must be a non-blank simple path segment but was " + language + + "."); + } + CacheKey cacheKey = new CacheKey(language, Thread.currentThread().getContextClassLoader()); + return CACHE.computeIfAbsent(cacheKey, key -> load(key.language)); + } + + /** + * Returns the text constant registered under one canonical key. + * + * @param canonicalKey canonical vocabulary key such as {@code section.termination_reason} or + * {@code punct.list_colon} + * @return language-specific text constant + * @throws IllegalArgumentException if the key is not part of the vocabulary + */ + public String get(String canonicalKey) { + String value = entries.get(canonicalKey); + if (value == null) { + throw new IllegalArgumentException( + "Unknown negotiation vocabulary key " + canonicalKey + " for language " + language + "."); + } + return value; + } + + /** + * Returns all canonical keys exposed by this vocabulary. + * + * @return immutable set of canonical keys, identical for every supported language + */ + public Set canonicalKeys() { + return entries.keySet(); + } + + /** + * Returns the language this vocabulary is bound to. + * + * @return locale identifier such as {@code zh-CN} + */ + public String language() { + return language; + } + + private static Vocabulary load(String language) { + String classpathPath = CLASSPATH_ROOT + language + "/" + VOCABULARY_FILE_NAME; + InputStream stream = ClasspathResourceStreams.open(classpathPath); + if (stream == null) { + throw new IllegalArgumentException("Negotiation vocabulary does not exist for the configured language " + + language + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly."); + } + try (stream) { + return parse(language, new String(stream.readAllBytes(), StandardCharsets.UTF_8), classpathPath); + } catch (IOException exception) { + throw new IllegalArgumentException( + "Failed to read the negotiation vocabulary " + classpathPath + " for language " + language + + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly.", + exception); + } + } + + private static Vocabulary parse(String language, String payload, String origin) { + JsonNode root; + try { + root = OBJECT_MAPPER.readTree(payload); + } catch (JsonProcessingException exception) { + String detail = + exception.getMessage() != null && exception.getMessage().contains("Duplicate field") + ? "it contains duplicate keys" + : "it is not valid JSON"; + throw malformed(language, origin, detail, exception); + } + if (root == null || !root.isObject()) { + throw malformed(language, origin, "it is not a flat JSON object of string values", null); + } + Map entries = new LinkedHashMap<>(); + root.fields().forEachRemaining(field -> { + JsonNode value = field.getValue(); + if (!value.isTextual()) { + throw malformed(language, origin, "the value of key '" + field.getKey() + "' is not a string", null); + } + String text = value.textValue(); + if (text.isBlank()) { + throw malformed(language, origin, "the value of key '" + field.getKey() + "' is blank", null); + } + entries.put(field.getKey(), text); + }); + validateCanonicalKeys(language, entries, origin); + return new Vocabulary(language, Collections.unmodifiableMap(entries)); + } + + private static void validateCanonicalKeys(String language, Map entries, String origin) { + Set missing = new LinkedHashSet<>(CANONICAL_KEYS); + missing.removeAll(entries.keySet()); + Set unexpected = new LinkedHashSet<>(entries.keySet()); + unexpected.removeAll(CANONICAL_KEY_SET); + if (missing.isEmpty() && unexpected.isEmpty()) { + return; + } + throw new IllegalArgumentException("Negotiation vocabulary " + origin + " for language " + language + + " must define exactly the canonical vocabulary keys; missing keys: " + missing + + ", unexpected keys: " + unexpected + "."); + } + + private static IllegalArgumentException malformed( + String language, String origin, String detail, @Nullable Throwable cause) { + IllegalArgumentException exception = new IllegalArgumentException( + "Negotiation vocabulary " + origin + " for language " + language + " is malformed: " + detail + + "; supported languages are zh-CN and en-US, configure A2AT_LANGUAGE accordingly."); + if (cause != null) { + exception.initCause(cause); + } + return exception; + } + + private record CacheKey(String language, @Nullable ClassLoader contextClassLoader) {} +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/package-info.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/package-info.java index 3a111858..766779eb 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/package-info.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/package-info.java @@ -1,2 +1,2 @@ -/** Typed content models, vocabulary and failure types for negotiation messages. */ -package net.openan.a2at.sdk.negotiation.content; +/** Typed content models, vocabulary and failure types for negotiation messages. */ +package net.openan.a2at.sdk.negotiation.content; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java index 7a102a5c..227bc1cc 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java @@ -1,44 +1,44 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; - -/** - * Generator for the type-independent abort negotiation message. - * - *

            The common abort template carries the fixed {@code Abort} conclusion section, so this generator fills only the - * negotiation context and the termination reason slots. - * - * @since 2026-08 - */ -final class AbortGenerator extends AbstractNegotiationGenerator { - - /** - * Generates an abort negotiation message. - * - * @param context negotiation context of the message - * @param content abort content carrying the termination reason - * @param template common abort template to render - * @param vocabulary vocabulary of the message language - * @return rendered abort message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - NegotiationAbortContent abortContent = contentOf(content, NegotiationAbortContent.class, "Abort generator"); - Map slots = new LinkedHashMap<>(); - slots.put( - vocabulary.get("slot.termination_reason"), - requiredText( - abortContent.terminationReason(), - "content.terminationReason", - "Termination reason of an abort negotiation message", - vocabulary)); - return render(template, slots); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Generator for the type-independent abort negotiation message. + * + *

            The common abort template carries the fixed {@code Abort} conclusion section, so this generator fills only the + * negotiation context and the termination reason slots. + * + * @since 2026-08 + */ +final class AbortGenerator extends AbstractNegotiationGenerator { + + /** + * Generates an abort negotiation message. + * + * @param context negotiation context of the message + * @param content abort content carrying the termination reason + * @param template common abort template to render + * @param vocabulary vocabulary of the message language + * @return rendered abort message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + NegotiationAbortContent abortContent = contentOf(content, NegotiationAbortContent.class, "Abort generator"); + Map slots = new LinkedHashMap<>(); + slots.put( + vocabulary.get("slot.termination_reason"), + requiredText( + abortContent.terminationReason(), + "content.terminationReason", + "Termination reason of an abort negotiation message", + vocabulary)); + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java index 48495d5d..7066d0d1 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java @@ -1,142 +1,142 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; - -/** - * Shared plumbing of the six negotiation generators. - * - *

            Subclasses validate their exact content type, assemble the slot map and hand over to the shared renderers. The - * slot map is keyed by the language-specific slot names taken from the vocabulary. - */ -abstract class AbstractNegotiationGenerator implements NegotiationGenerator { - - private final NegotiationPromptRenderer promptRenderer = new NegotiationPromptRenderer(); - - private final NegotiationItemFormatter itemFormatter = new NegotiationItemFormatter(); - - /** - * Casts the content to the exact type this generator serves. - * - * @param expected content type - * @param content content object validated by the registry - * @param expectedType exact content class this generator serves - * @param generatorName human-readable generator name used in the failure message - * @return content cast to the expected type - * @throws IllegalArgumentException if the content is null or of another runtime type - */ - protected static T contentOf( - NegotiationContent content, Class expectedType, String generatorName) { - if (content == null || content.getClass() != expectedType) { - throw new IllegalArgumentException( - generatorName + " requires content of type " + expectedType.getSimpleName() + " but received " - + (content == null ? "null" : content.getClass().getSimpleName()) + "."); - } - return expectedType.cast(content); - } - - /** - * Validates that a conclusion is renderable by a typed ending generator, rejecting null and the abort conclusion. - * - * @param conclusion conclusion carried by ending content - * @return the validated conclusion - * @throws NullPointerException if the conclusion is null - * @throws IllegalArgumentException if the conclusion is abort; the abort message has no typed conclusion slot and - * is generated through the abort phase against the common abort template instead - */ - protected static NegotiationConclusion renderableConclusion(NegotiationConclusion conclusion) { - Objects.requireNonNull( - conclusion, - "Negotiation conclusion must not be null; accept and reject are the renderable" - + " conclusions of a typed negotiation."); - if (conclusion == NegotiationConclusion.ABORT) { - throw new IllegalArgumentException( - "Typed negotiation templates carry no Abort conclusion slot; generate the abort message of a" - + " terminated negotiation with generateAbortFromData against the common abort template" - + " instead."); - } - return conclusion; - } - - /** - * Validates that a required text field is present. - * - * @param value field value - * @param field field path used in the failure facts - * @param description field description used in the failure reason - * @param vocabulary vocabulary of the message language, used to render the failure message - * @return the validated text - * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the value is null or - * blank - */ - protected static String requiredText(String value, String field, String description, Vocabulary vocabulary) { - if (value == null || value.isBlank()) { - throw contentInvalid(field, description + " must not be blank.", vocabulary); - } - return value; - } - - /** - * Validates that a required item list is present and non-empty. - * - * @param items item list - * @param field field path used in the failure facts - * @param description field description used in the failure reason - * @param vocabulary vocabulary of the message language, used to render the failure message - * @return the validated item list - * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the list is null or - * empty - */ - protected static List requiredItems( - List items, String field, String description, Vocabulary vocabulary) { - if (items == null || items.isEmpty()) { - throw contentInvalid(field, description + " must contain at least one item.", vocabulary); - } - return items; - } - - /** - * Builds the coded failure for invalid negotiation content data of a from-data generation. - * - * @param field content field path - * @param reason failure reason - * @param vocabulary vocabulary of the message language, used to render the failure message - * @return generation failure carrying the code {@code negotiation.content_invalid} - */ - protected static NegotiationGenerationException contentInvalid(String field, String reason, Vocabulary vocabulary) { - return new NegotiationGenerationException( - ErrorCatalog.NEGOTIATION_CONTENT_INVALID, - vocabulary.language(), - Map.of("field", field, "reason", reason)); - } - - /** - * Formats an item list with the list punctuation of the message language. - * - * @param items items to format; null formats as an empty value - * @param vocabulary vocabulary of the message language - * @return formatted numbered lines, or an empty string when there is no item - */ - protected String formatItems(List items, Vocabulary vocabulary) { - return itemFormatter.format(items, vocabulary.get("punct.list_colon")); - } - - /** - * Renders the template with the assembled slots. - * - * @param template loaded template to render - * @param slots slot values keyed by language-specific slot names - * @return rendered negotiation message text - */ - protected String render(PromptTemplate template, Map slots) { - return promptRenderer.render(template.content(), slots); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Shared plumbing of the six negotiation generators. + * + *

            Subclasses validate their exact content type, assemble the slot map and hand over to the shared renderers. The + * slot map is keyed by the language-specific slot names taken from the vocabulary. + */ +abstract class AbstractNegotiationGenerator implements NegotiationGenerator { + + private final NegotiationPromptRenderer promptRenderer = new NegotiationPromptRenderer(); + + private final NegotiationItemFormatter itemFormatter = new NegotiationItemFormatter(); + + /** + * Casts the content to the exact type this generator serves. + * + * @param expected content type + * @param content content object validated by the registry + * @param expectedType exact content class this generator serves + * @param generatorName human-readable generator name used in the failure message + * @return content cast to the expected type + * @throws IllegalArgumentException if the content is null or of another runtime type + */ + protected static T contentOf( + NegotiationContent content, Class expectedType, String generatorName) { + if (content == null || content.getClass() != expectedType) { + throw new IllegalArgumentException( + generatorName + " requires content of type " + expectedType.getSimpleName() + " but received " + + (content == null ? "null" : content.getClass().getSimpleName()) + "."); + } + return expectedType.cast(content); + } + + /** + * Validates that a conclusion is renderable by a typed ending generator, rejecting null and the abort conclusion. + * + * @param conclusion conclusion carried by ending content + * @return the validated conclusion + * @throws NullPointerException if the conclusion is null + * @throws IllegalArgumentException if the conclusion is abort; the abort message has no typed conclusion slot and + * is generated through the abort phase against the common abort template instead + */ + protected static NegotiationConclusion renderableConclusion(NegotiationConclusion conclusion) { + Objects.requireNonNull( + conclusion, + "Negotiation conclusion must not be null; accept and reject are the renderable" + + " conclusions of a typed negotiation."); + if (conclusion == NegotiationConclusion.ABORT) { + throw new IllegalArgumentException( + "Typed negotiation templates carry no Abort conclusion slot; generate the abort message of a" + + " terminated negotiation with generateAbortFromData against the common abort template" + + " instead."); + } + return conclusion; + } + + /** + * Validates that a required text field is present. + * + * @param value field value + * @param field field path used in the failure facts + * @param description field description used in the failure reason + * @param vocabulary vocabulary of the message language, used to render the failure message + * @return the validated text + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the value is null or + * blank + */ + protected static String requiredText(String value, String field, String description, Vocabulary vocabulary) { + if (value == null || value.isBlank()) { + throw contentInvalid(field, description + " must not be blank.", vocabulary); + } + return value; + } + + /** + * Validates that a required item list is present and non-empty. + * + * @param items item list + * @param field field path used in the failure facts + * @param description field description used in the failure reason + * @param vocabulary vocabulary of the message language, used to render the failure message + * @return the validated item list + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the list is null or + * empty + */ + protected static List requiredItems( + List items, String field, String description, Vocabulary vocabulary) { + if (items == null || items.isEmpty()) { + throw contentInvalid(field, description + " must contain at least one item.", vocabulary); + } + return items; + } + + /** + * Builds the coded failure for invalid negotiation content data of a from-data generation. + * + * @param field content field path + * @param reason failure reason + * @param vocabulary vocabulary of the message language, used to render the failure message + * @return generation failure carrying the code {@code negotiation.content_invalid} + */ + protected static NegotiationGenerationException contentInvalid(String field, String reason, Vocabulary vocabulary) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_CONTENT_INVALID, + vocabulary.language(), + Map.of("field", field, "reason", reason)); + } + + /** + * Formats an item list with the list punctuation of the message language. + * + * @param items items to format; null formats as an empty value + * @param vocabulary vocabulary of the message language + * @return formatted numbered lines, or an empty string when there is no item + */ + protected String formatItems(List items, Vocabulary vocabulary) { + return itemFormatter.format(items, vocabulary.get("punct.list_colon")); + } + + /** + * Renders the template with the assembled slots. + * + * @param template loaded template to render + * @param slots slot values keyed by language-specific slot names + * @return rendered negotiation message text + */ + protected String render(PromptTemplate template, Map slots) { + return promptRenderer.render(template.content(), slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java index 3534d43c..be746ae3 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java @@ -1,444 +1,444 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.json.JacksonJsonValueParser; -import net.openan.a2at.sdk.core.json.JsonValueParser; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Default content extractor backed by one structured LLM call. - * - *

            The extractor loads the system and user prompts of the addressed negotiation type from the classpath, asks the LLM - * for the snake_case JSON described by the extraction schema, and maps the response onto the typed content records. - * Failure mapping: a missing LLM client maps to {@code llm.not_configured}, transport failures map to - * {@code llm.invocation_failed}, responses that violate the response contract (empty or not a JSON object) map to the - * retryable {@code llm.response_invalid}, responses that cannot be mapped onto the expected content map to the - * retryable {@code negotiation.content_extract_failed}, missing required fields map to the non-retryable - * {@code negotiation.field_missing}, a conclusion contradicting the addressed phase maps to - * {@code negotiation.conclusion_mismatch}, and content that contradicts the addressed phase or action maps to - * {@code negotiation.invalid_input}. Every message is rendered from the code's message template in the reference - * language. - * - * @since 2026-08 - */ -final class DefaultNegotiationContentExtractor implements NegotiationContentExtractor { - - private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationContentExtractor.class); - - private static final String CATEGORY_INFORMATION = "information_negotiation"; - - private static final String CATEGORY_TARGET = "target_negotiation"; - - private static final String CATEGORY_FEASIBILITY = "feasibility_negotiation"; - - private static final String CATEGORY_ABORT = "abort_negotiation"; - - private static final String EXTRACTION_STEP_EN = "negotiation content extraction"; - - private static final String EXTRACTION_STEP_ZH = "协商内容提取"; - - private final LLMClient llmClient; - - private final NegotiationMessageBuilder messageBuilder; - - private final NegotiationJsonSchemaBuilder schemaBuilder; - - private final JsonValueParser parser; - - /** - * Creates an extractor backed by one LLM client and the default message and schema builders. - * - * @param llmClient LLM client used for the structured content extraction call - */ - public DefaultNegotiationContentExtractor(LLMClient llmClient) { - this( - llmClient, - new NegotiationMessageBuilder(), - new NegotiationJsonSchemaBuilder(), - new JacksonJsonValueParser()); - } - - /** - * Creates an extractor with injectable collaborators. - * - * @param llmClient LLM client used for the structured content extraction call - * @param messageBuilder builder assembling the LLM messages of the extraction step - * @param schemaBuilder builder providing the extraction JSON Schema - * @param parser parser turning the LLM response content into a key-value map - */ - public DefaultNegotiationContentExtractor( - LLMClient llmClient, - NegotiationMessageBuilder messageBuilder, - NegotiationJsonSchemaBuilder schemaBuilder, - JsonValueParser parser) { - this.llmClient = llmClient; - this.messageBuilder = messageBuilder; - this.schemaBuilder = schemaBuilder; - this.parser = parser; - } - - /** - * Extracts the typed content of one negotiation message from free text. - * - * @param text free-text input describing the message content - * @param reference reference identifying the negotiation type, performative and language to extract for - * @return typed negotiation content matching the reference - * @throws NullPointerException if the reference is null - * @throws NegotiationGenerationException with one of the codes {@code llm.not_configured}, - * {@code llm.invocation_failed}, {@code llm.response_invalid}, {@code negotiation.content_extract_failed}, - * {@code negotiation.field_missing}, {@code negotiation.conclusion_mismatch} or - * {@code negotiation.invalid_input} - */ - @Override - public NegotiationContent extract(String text, NegotiationReference reference) { - Objects.requireNonNull(reference, "Negotiation reference must not be null."); - String language = reference.language(); - if (text == null || text.isBlank()) { - throw invalidInput(language, "Input text for negotiation content extraction must not be blank."); - } - Map tokens = Map.of( - NegotiationMessageBuilder.TOKEN_PHASE, - phaseToken(reference.performative()), - NegotiationMessageBuilder.TOKEN_INPUT, - text); - List> messages = - messageBuilder.buildMessages(promptCategory(reference.type()), reference.language(), tokens); - Map schema = schemaBuilder.buildExtractionSchema(reference.type(), reference.performative()); - Map payload = invokeLlm(messages, schema, language); - NegotiationContent content = mapContent(payload, reference.type(), reference.performative(), language); - LOGGER.atInfo().log( - "negotiation_content_extraction_completed type={} performative={}", - reference.type(), - reference.performative()); - return content; - } - - private Map invokeLlm( - List> messages, Map schema, String language) { - if (llmClient == null) { - throw new NegotiationGenerationException(ErrorCatalog.LLM_NOT_CONFIGURED, language, null); - } - LLMResponse response; - try { - response = llmClient.structured(messages, schema, null, null); - } catch (RuntimeException error) { - throw new NegotiationGenerationException( - ErrorCatalog.LLM_INVOCATION_FAILED, - language, - Map.of( - "provider", llmClient.getClass().getSimpleName(), - "reason", String.valueOf(error.getMessage())), - error); - } - String content = response == null ? null : response.content(); - if (content == null || content.isBlank()) { - throw responseInvalid(language, "Negotiation content extraction returned an empty response."); - } - try { - return parser.parseObject(content); - } catch (RuntimeException error) { - throw responseInvalid(language, "Negotiation content extraction response is not a JSON object."); - } - } - - private static NegotiationContent mapContent( - Map payload, - @Nullable NegotiationType type, - NegotiationPerformative performative, - String language) { - if (performative == NegotiationPerformative.ABORT) { - return new NegotiationAbortContent(requiredString(payload, "termination_reason", language)); - } - if (performative == NegotiationPerformative.PROPOSE) { - return mapProposeContent(payload, type, language); - } - return mapEndingContent(payload, type, performative, language); - } - - private static NegotiationContent mapProposeContent( - Map payload, NegotiationType type, String language) { - return switch (type) { - case INFORMATION -> new InformationProposeContent( - requiredNonEmptyItems(payload, "items", "information negotiation requested items", language), - optionalString(payload, "relationship", language)); - case TARGET -> mapTargetProposeContent(payload, language); - case FEASIBILITY -> mapFeasibilityProposeContent(payload, language); - }; - } - - private static NegotiationContent mapTargetProposeContent(Map payload, String language) { - String description = requiredString(payload, "target_negotiation_description", language); - List intentUnderstanding = optionalItems(payload, "intent_understanding", language); - List alignmentAndClarification = - optionalItems(payload, "alignment_and_clarification", language); - List requestForClarification = optionalItems(payload, "request_for_clarification", language); - String confirmRequest = optionalString(payload, "target_confirm_request", language); - if (hasText(confirmRequest) - && (hasItems(intentUnderstanding) - || hasItems(alignmentAndClarification) - || hasItems(requestForClarification))) { - throw invalidInput( - language, - "Target confirm request extracted together with the intent understanding, alignment and" - + " clarification or clarification request sections; a confirm-request round carries only" - + " the summary and the confirm request."); - } - return new TargetProposeContent( - description, intentUnderstanding, alignmentAndClarification, requestForClarification, confirmRequest); - } - - private static NegotiationContent mapFeasibilityProposeContent(Map payload, String language) { - String description = requiredString(payload, "feasibility_negotiation_description", language); - NegotiationAction action = feasibilityAction(payload, language); - List contentsToEvaluate = optionalItems(payload, "contents_to_evaluate", language); - List infeasibilityDetails = - optionalItems(payload, "infeasibility_details_and_proposal", language); - String confirmRequest = optionalString(payload, "feasibility_confirm_request", language); - if (hasText(confirmRequest)) { - if (action != NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { - throw invalidInput( - language, - "Feasibility confirm request requires the REQUEST_FEASIBILITY_EVALUATION action but the" - + " extracted action was " - + action.name() - + "."); - } - if (hasItems(contentsToEvaluate) || hasItems(infeasibilityDetails)) { - throw invalidInput( - language, - "Feasibility confirm request extracted together with the contents to evaluate or" - + " infeasibility details and proposal sections; a confirm-request round carries only" - + " the summary and the confirm request."); - } - } else if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { - if (contentsToEvaluate == null || contentsToEvaluate.isEmpty()) { - throw invalidInput( - language, - "Feasibility evaluation request extracted no contents to evaluate; the driven section would" - + " be empty."); - } - } else if (infeasibilityDetails == null || infeasibilityDetails.isEmpty()) { - throw invalidInput( - language, - "Alternative proposal on failure extracted no infeasibility details; the driven section would be" - + " empty."); - } - return new FeasibilityProposeContent( - description, action, contentsToEvaluate, infeasibilityDetails, confirmRequest); - } - - private static NegotiationContent mapEndingContent( - Map payload, NegotiationType type, NegotiationPerformative performative, String language) { - NegotiationConclusion conclusion = requiredConclusion(payload, language); - requireConclusionMatchesPhase(conclusion, performative, language); - return switch (type) { - case INFORMATION -> new InformationEndingContent( - conclusion, - requiredNonEmptyItems(payload, "items", "information negotiation result content", language)); - case TARGET -> mapTargetEndingContent(payload, conclusion, language); - case FEASIBILITY -> new FeasibilityEndingContent( - conclusion, requiredString(payload, "feasibility_summary", language)); - }; - } - - private static NegotiationContent mapTargetEndingContent( - Map payload, NegotiationConclusion conclusion, String language) { - String confirmedIntent = optionalString(payload, "confirmed_intent", language); - String failureReason = optionalString(payload, "failure_reason", language); - if (conclusion == NegotiationConclusion.ACCEPT && (confirmedIntent == null || confirmedIntent.isBlank())) { - throw slotMissing(language, "confirmed_intent"); - } - if (conclusion == NegotiationConclusion.REJECT && (failureReason == null || failureReason.isBlank())) { - throw slotMissing(language, "failure_reason"); - } - return new TargetEndingContent(conclusion, confirmedIntent, failureReason); - } - - private static void requireConclusionMatchesPhase( - NegotiationConclusion conclusion, NegotiationPerformative performative, String language) { - NegotiationConclusion expected = performative == NegotiationPerformative.ACCEPT - ? NegotiationConclusion.ACCEPT - : NegotiationConclusion.REJECT; - if (conclusion != expected) { - throw new NegotiationGenerationException( - ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH, - language, - Map.of("expected", expected.literal(), "actual", conclusion.literal())); - } - } - - private static NegotiationAction feasibilityAction(Map payload, String language) { - Object value = payload.get("action"); - if (value == null) { - throw invalidInput( - language, - "Feasibility negotiation content extraction produced no action; the action drives the conditional" - + " sections of the message."); - } - if (value instanceof String action) { - for (NegotiationAction candidate : NegotiationAction.values()) { - if (candidate.name().equals(action)) { - return candidate; - } - } - } - throw extractFailed(language, "action", "Feasibility negotiation action must be one of the two action names"); - } - - private static NegotiationConclusion requiredConclusion(Map payload, String language) { - Object value = payload.get("conclusion"); - if (value == null) { - throw slotMissing(language, "conclusion"); - } - if (value instanceof String conclusion) { - for (NegotiationConclusion candidate : NegotiationConclusion.values()) { - if (candidate.literal().equals(conclusion)) { - return candidate; - } - } - } - throw extractFailed(language, "conclusion", "Negotiation conclusion must be Accept or Reject"); - } - - private static String requiredString(Map payload, String field, String language) { - Object value = payload.get(field); - if (value == null || (value instanceof String text && text.isBlank())) { - throw slotMissing(language, field); - } - if (value instanceof String text) { - return text; - } - throw extractFailed(language, field, "Field " + field + " must be a string"); - } - - private static String optionalString(Map payload, String field, String language) { - Object value = payload.get(field); - if (value == null) { - return null; - } - if (value instanceof String text) { - return text; - } - throw extractFailed(language, field, "Field " + field + " must be a string or null"); - } - - private static boolean hasText(@Nullable String value) { - return value != null && !value.isBlank(); - } - - private static boolean hasItems(@Nullable List items) { - return items != null && !items.isEmpty(); - } - - private static List requiredItems(Map payload, String field, String language) { - Object value = payload.get(field); - if (value == null) { - throw slotMissing(language, field); - } - return itemsOf(value, field, language); - } - - private static List requiredNonEmptyItems( - Map payload, String field, String description, String language) { - List items = requiredItems(payload, field, language); - if (items.isEmpty()) { - throw slotMissing(language, description); - } - return items; - } - - private static List optionalItems(Map payload, String field, String language) { - Object value = payload.get(field); - if (value == null) { - return null; - } - return itemsOf(value, field, language); - } - - private static List itemsOf(Object value, String field, String language) { - if (!(value instanceof List entries)) { - throw extractFailed(language, field, "Field " + field + " must be an array of items"); - } - List items = new ArrayList<>(); - for (Object entry : entries) { - if (!(entry instanceof Map entryMap)) { - throw extractFailed(language, field, "Field " + field + " must contain item objects"); - } - Object name = entryMap.get("name"); - if (!(name instanceof String itemName) || itemName.isBlank()) { - throw extractFailed(language, field, "Field " + field + " contained an item without a name"); - } - Object itemValue = entryMap.get("value"); - if (itemValue != null && !(itemValue instanceof String)) { - throw extractFailed( - language, field, "Field " + field + " contained an item whose value is not a string"); - } - items.add(new NegotiationItem(itemName, (String) itemValue)); - } - return List.copyOf(items); - } - - private static String phaseToken(NegotiationPerformative performative) { - return performative.name().toLowerCase(Locale.ROOT); - } - - private static String promptCategory(@Nullable NegotiationType type) { - if (type == null) { - return CATEGORY_ABORT; - } - return switch (type) { - case INFORMATION -> CATEGORY_INFORMATION; - case TARGET -> CATEGORY_TARGET; - case FEASIBILITY -> CATEGORY_FEASIBILITY; - }; - } - - private static NegotiationGenerationException slotMissing(String language, String field) { - return new NegotiationGenerationException( - ErrorCatalog.NEGOTIATION_FIELD_MISSING, language, Map.of("field", field)); - } - - private static NegotiationGenerationException extractFailed(String language, String field, String reason) { - return new NegotiationGenerationException( - ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED, language, Map.of("field", field, "reason", reason)); - } - - private static NegotiationGenerationException invalidInput(String language, String reason) { - return new NegotiationGenerationException( - ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, Map.of("reason", reason)); - } - - private static NegotiationGenerationException responseInvalid(String language, String detail) { - LOGGER.atWarn().log("negotiation_content_extraction_response_invalid detail={}", detail); - return new NegotiationGenerationException( - ErrorCatalog.LLM_RESPONSE_INVALID, language, Map.of("step", extractionStepLabel(language))); - } - - private static String extractionStepLabel(String language) { - return language != null && language.startsWith("zh") ? EXTRACTION_STEP_ZH : EXTRACTION_STEP_EN; - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.json.JacksonJsonValueParser; +import net.openan.a2at.sdk.core.json.JsonValueParser; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default content extractor backed by one structured LLM call. + * + *

            The extractor loads the system and user prompts of the addressed negotiation type from the classpath, asks the LLM + * for the snake_case JSON described by the extraction schema, and maps the response onto the typed content records. + * Failure mapping: a missing LLM client maps to {@code llm.not_configured}, transport failures map to + * {@code llm.invocation_failed}, responses that violate the response contract (empty or not a JSON object) map to the + * retryable {@code llm.response_invalid}, responses that cannot be mapped onto the expected content map to the + * retryable {@code negotiation.content_extract_failed}, missing required fields map to the non-retryable + * {@code negotiation.field_missing}, a conclusion contradicting the addressed phase maps to + * {@code negotiation.conclusion_mismatch}, and content that contradicts the addressed phase or action maps to + * {@code negotiation.invalid_input}. Every message is rendered from the code's message template in the reference + * language. + * + * @since 2026-08 + */ +final class DefaultNegotiationContentExtractor implements NegotiationContentExtractor { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationContentExtractor.class); + + private static final String CATEGORY_INFORMATION = "information_negotiation"; + + private static final String CATEGORY_TARGET = "target_negotiation"; + + private static final String CATEGORY_FEASIBILITY = "feasibility_negotiation"; + + private static final String CATEGORY_ABORT = "abort_negotiation"; + + private static final String EXTRACTION_STEP_EN = "negotiation content extraction"; + + private static final String EXTRACTION_STEP_ZH = "协商内容提取"; + + private final LLMClient llmClient; + + private final NegotiationMessageBuilder messageBuilder; + + private final NegotiationJsonSchemaBuilder schemaBuilder; + + private final JsonValueParser parser; + + /** + * Creates an extractor backed by one LLM client and the default message and schema builders. + * + * @param llmClient LLM client used for the structured content extraction call + */ + public DefaultNegotiationContentExtractor(LLMClient llmClient) { + this( + llmClient, + new NegotiationMessageBuilder(), + new NegotiationJsonSchemaBuilder(), + new JacksonJsonValueParser()); + } + + /** + * Creates an extractor with injectable collaborators. + * + * @param llmClient LLM client used for the structured content extraction call + * @param messageBuilder builder assembling the LLM messages of the extraction step + * @param schemaBuilder builder providing the extraction JSON Schema + * @param parser parser turning the LLM response content into a key-value map + */ + public DefaultNegotiationContentExtractor( + LLMClient llmClient, + NegotiationMessageBuilder messageBuilder, + NegotiationJsonSchemaBuilder schemaBuilder, + JsonValueParser parser) { + this.llmClient = llmClient; + this.messageBuilder = messageBuilder; + this.schemaBuilder = schemaBuilder; + this.parser = parser; + } + + /** + * Extracts the typed content of one negotiation message from free text. + * + * @param text free-text input describing the message content + * @param reference reference identifying the negotiation type, performative and language to extract for + * @return typed negotiation content matching the reference + * @throws NullPointerException if the reference is null + * @throws NegotiationGenerationException with one of the codes {@code llm.not_configured}, + * {@code llm.invocation_failed}, {@code llm.response_invalid}, {@code negotiation.content_extract_failed}, + * {@code negotiation.field_missing}, {@code negotiation.conclusion_mismatch} or + * {@code negotiation.invalid_input} + */ + @Override + public NegotiationContent extract(String text, NegotiationReference reference) { + Objects.requireNonNull(reference, "Negotiation reference must not be null."); + String language = reference.language(); + if (text == null || text.isBlank()) { + throw invalidInput(language, "Input text for negotiation content extraction must not be blank."); + } + Map tokens = Map.of( + NegotiationMessageBuilder.TOKEN_PHASE, + phaseToken(reference.performative()), + NegotiationMessageBuilder.TOKEN_INPUT, + text); + List> messages = + messageBuilder.buildMessages(promptCategory(reference.type()), reference.language(), tokens); + Map schema = schemaBuilder.buildExtractionSchema(reference.type(), reference.performative()); + Map payload = invokeLlm(messages, schema, language); + NegotiationContent content = mapContent(payload, reference.type(), reference.performative(), language); + LOGGER.atInfo().log( + "negotiation_content_extraction_completed type={} performative={}", + reference.type(), + reference.performative()); + return content; + } + + private Map invokeLlm( + List> messages, Map schema, String language) { + if (llmClient == null) { + throw new NegotiationGenerationException(ErrorCatalog.LLM_NOT_CONFIGURED, language, null); + } + LLMResponse response; + try { + response = llmClient.structured(messages, schema, null, null); + } catch (RuntimeException error) { + throw new NegotiationGenerationException( + ErrorCatalog.LLM_INVOCATION_FAILED, + language, + Map.of( + "provider", llmClient.getClass().getSimpleName(), + "reason", String.valueOf(error.getMessage())), + error); + } + String content = response == null ? null : response.content(); + if (content == null || content.isBlank()) { + throw responseInvalid(language, "Negotiation content extraction returned an empty response."); + } + try { + return parser.parseObject(content); + } catch (RuntimeException error) { + throw responseInvalid(language, "Negotiation content extraction response is not a JSON object."); + } + } + + private static NegotiationContent mapContent( + Map payload, + @Nullable NegotiationType type, + NegotiationPerformative performative, + String language) { + if (performative == NegotiationPerformative.ABORT) { + return new NegotiationAbortContent(requiredString(payload, "termination_reason", language)); + } + if (performative == NegotiationPerformative.PROPOSE) { + return mapProposeContent(payload, type, language); + } + return mapEndingContent(payload, type, performative, language); + } + + private static NegotiationContent mapProposeContent( + Map payload, NegotiationType type, String language) { + return switch (type) { + case INFORMATION -> new InformationProposeContent( + requiredNonEmptyItems(payload, "items", "information negotiation requested items", language), + optionalString(payload, "relationship", language)); + case TARGET -> mapTargetProposeContent(payload, language); + case FEASIBILITY -> mapFeasibilityProposeContent(payload, language); + }; + } + + private static NegotiationContent mapTargetProposeContent(Map payload, String language) { + String description = requiredString(payload, "target_negotiation_description", language); + List intentUnderstanding = optionalItems(payload, "intent_understanding", language); + List alignmentAndClarification = + optionalItems(payload, "alignment_and_clarification", language); + List requestForClarification = optionalItems(payload, "request_for_clarification", language); + String confirmRequest = optionalString(payload, "target_confirm_request", language); + if (hasText(confirmRequest) + && (hasItems(intentUnderstanding) + || hasItems(alignmentAndClarification) + || hasItems(requestForClarification))) { + throw invalidInput( + language, + "Target confirm request extracted together with the intent understanding, alignment and" + + " clarification or clarification request sections; a confirm-request round carries only" + + " the summary and the confirm request."); + } + return new TargetProposeContent( + description, intentUnderstanding, alignmentAndClarification, requestForClarification, confirmRequest); + } + + private static NegotiationContent mapFeasibilityProposeContent(Map payload, String language) { + String description = requiredString(payload, "feasibility_negotiation_description", language); + NegotiationAction action = feasibilityAction(payload, language); + List contentsToEvaluate = optionalItems(payload, "contents_to_evaluate", language); + List infeasibilityDetails = + optionalItems(payload, "infeasibility_details_and_proposal", language); + String confirmRequest = optionalString(payload, "feasibility_confirm_request", language); + if (hasText(confirmRequest)) { + if (action != NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + throw invalidInput( + language, + "Feasibility confirm request requires the REQUEST_FEASIBILITY_EVALUATION action but the" + + " extracted action was " + + action.name() + + "."); + } + if (hasItems(contentsToEvaluate) || hasItems(infeasibilityDetails)) { + throw invalidInput( + language, + "Feasibility confirm request extracted together with the contents to evaluate or" + + " infeasibility details and proposal sections; a confirm-request round carries only" + + " the summary and the confirm request."); + } + } else if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + if (contentsToEvaluate == null || contentsToEvaluate.isEmpty()) { + throw invalidInput( + language, + "Feasibility evaluation request extracted no contents to evaluate; the driven section would" + + " be empty."); + } + } else if (infeasibilityDetails == null || infeasibilityDetails.isEmpty()) { + throw invalidInput( + language, + "Alternative proposal on failure extracted no infeasibility details; the driven section would be" + + " empty."); + } + return new FeasibilityProposeContent( + description, action, contentsToEvaluate, infeasibilityDetails, confirmRequest); + } + + private static NegotiationContent mapEndingContent( + Map payload, NegotiationType type, NegotiationPerformative performative, String language) { + NegotiationConclusion conclusion = requiredConclusion(payload, language); + requireConclusionMatchesPhase(conclusion, performative, language); + return switch (type) { + case INFORMATION -> new InformationEndingContent( + conclusion, + requiredNonEmptyItems(payload, "items", "information negotiation result content", language)); + case TARGET -> mapTargetEndingContent(payload, conclusion, language); + case FEASIBILITY -> new FeasibilityEndingContent( + conclusion, requiredString(payload, "feasibility_summary", language)); + }; + } + + private static NegotiationContent mapTargetEndingContent( + Map payload, NegotiationConclusion conclusion, String language) { + String confirmedIntent = optionalString(payload, "confirmed_intent", language); + String failureReason = optionalString(payload, "failure_reason", language); + if (conclusion == NegotiationConclusion.ACCEPT && (confirmedIntent == null || confirmedIntent.isBlank())) { + throw slotMissing(language, "confirmed_intent"); + } + if (conclusion == NegotiationConclusion.REJECT && (failureReason == null || failureReason.isBlank())) { + throw slotMissing(language, "failure_reason"); + } + return new TargetEndingContent(conclusion, confirmedIntent, failureReason); + } + + private static void requireConclusionMatchesPhase( + NegotiationConclusion conclusion, NegotiationPerformative performative, String language) { + NegotiationConclusion expected = performative == NegotiationPerformative.ACCEPT + ? NegotiationConclusion.ACCEPT + : NegotiationConclusion.REJECT; + if (conclusion != expected) { + throw new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH, + language, + Map.of("expected", expected.literal(), "actual", conclusion.literal())); + } + } + + private static NegotiationAction feasibilityAction(Map payload, String language) { + Object value = payload.get("action"); + if (value == null) { + throw invalidInput( + language, + "Feasibility negotiation content extraction produced no action; the action drives the conditional" + + " sections of the message."); + } + if (value instanceof String action) { + for (NegotiationAction candidate : NegotiationAction.values()) { + if (candidate.name().equals(action)) { + return candidate; + } + } + } + throw extractFailed(language, "action", "Feasibility negotiation action must be one of the two action names"); + } + + private static NegotiationConclusion requiredConclusion(Map payload, String language) { + Object value = payload.get("conclusion"); + if (value == null) { + throw slotMissing(language, "conclusion"); + } + if (value instanceof String conclusion) { + for (NegotiationConclusion candidate : NegotiationConclusion.values()) { + if (candidate.literal().equals(conclusion)) { + return candidate; + } + } + } + throw extractFailed(language, "conclusion", "Negotiation conclusion must be Accept or Reject"); + } + + private static String requiredString(Map payload, String field, String language) { + Object value = payload.get(field); + if (value == null || (value instanceof String text && text.isBlank())) { + throw slotMissing(language, field); + } + if (value instanceof String text) { + return text; + } + throw extractFailed(language, field, "Field " + field + " must be a string"); + } + + private static String optionalString(Map payload, String field, String language) { + Object value = payload.get(field); + if (value == null) { + return null; + } + if (value instanceof String text) { + return text; + } + throw extractFailed(language, field, "Field " + field + " must be a string or null"); + } + + private static boolean hasText(@Nullable String value) { + return value != null && !value.isBlank(); + } + + private static boolean hasItems(@Nullable List items) { + return items != null && !items.isEmpty(); + } + + private static List requiredItems(Map payload, String field, String language) { + Object value = payload.get(field); + if (value == null) { + throw slotMissing(language, field); + } + return itemsOf(value, field, language); + } + + private static List requiredNonEmptyItems( + Map payload, String field, String description, String language) { + List items = requiredItems(payload, field, language); + if (items.isEmpty()) { + throw slotMissing(language, description); + } + return items; + } + + private static List optionalItems(Map payload, String field, String language) { + Object value = payload.get(field); + if (value == null) { + return null; + } + return itemsOf(value, field, language); + } + + private static List itemsOf(Object value, String field, String language) { + if (!(value instanceof List entries)) { + throw extractFailed(language, field, "Field " + field + " must be an array of items"); + } + List items = new ArrayList<>(); + for (Object entry : entries) { + if (!(entry instanceof Map entryMap)) { + throw extractFailed(language, field, "Field " + field + " must contain item objects"); + } + Object name = entryMap.get("name"); + if (!(name instanceof String itemName) || itemName.isBlank()) { + throw extractFailed(language, field, "Field " + field + " contained an item without a name"); + } + Object itemValue = entryMap.get("value"); + if (itemValue != null && !(itemValue instanceof String)) { + throw extractFailed( + language, field, "Field " + field + " contained an item whose value is not a string"); + } + items.add(new NegotiationItem(itemName, (String) itemValue)); + } + return List.copyOf(items); + } + + private static String phaseToken(NegotiationPerformative performative) { + return performative.name().toLowerCase(Locale.ROOT); + } + + private static String promptCategory(@Nullable NegotiationType type) { + if (type == null) { + return CATEGORY_ABORT; + } + return switch (type) { + case INFORMATION -> CATEGORY_INFORMATION; + case TARGET -> CATEGORY_TARGET; + case FEASIBILITY -> CATEGORY_FEASIBILITY; + }; + } + + private static NegotiationGenerationException slotMissing(String language, String field) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_FIELD_MISSING, language, Map.of("field", field)); + } + + private static NegotiationGenerationException extractFailed(String language, String field, String reason) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED, language, Map.of("field", field, "reason", reason)); + } + + private static NegotiationGenerationException invalidInput(String language, String reason) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, Map.of("reason", reason)); + } + + private static NegotiationGenerationException responseInvalid(String language, String detail) { + LOGGER.atWarn().log("negotiation_content_extraction_response_invalid detail={}", detail); + return new NegotiationGenerationException( + ErrorCatalog.LLM_RESPONSE_INVALID, language, Map.of("step", extractionStepLabel(language))); + } + + private static String extractionStepLabel(String language) { + return language != null && language.startsWith("zh") ? EXTRACTION_STEP_ZH : EXTRACTION_STEP_EN; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java index 3410efc5..78126942 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java @@ -1,48 +1,48 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; - -/** - * Generator for feasibility negotiation terminal messages. - * - *

            The message carries the terminal conclusion literal and the required feasibility summary. The summary slot name - * differs from its section title, so the vocabulary exception key is used. - * - * @since 2026-08 - */ -final class FeasibilityEndingGenerator extends AbstractNegotiationGenerator { - - /** - * Generates a feasibility negotiation accept or reject message. - * - * @param context negotiation context of the message - * @param content feasibility ending content - * @param template feasibility ending template to render - * @param vocabulary vocabulary of the message language - * @return rendered feasibility terminal message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - FeasibilityEndingContent endingContent = - contentOf(content, FeasibilityEndingContent.class, "Feasibility ending generator"); - renderableConclusion(endingContent.conclusion()); - requiredText( - endingContent.feasibilitySummary(), - "content.feasibilitySummary", - "Feasibility summary of a terminal feasibility negotiation message", - vocabulary); - Map slots = new LinkedHashMap<>(); - slots.put( - vocabulary.get("slot.feasibility_conclusion"), - endingContent.conclusion().literal()); - slots.put(vocabulary.get("slot.feasibility_confirm"), endingContent.feasibilitySummary()); - return render(template, slots); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Generator for feasibility negotiation terminal messages. + * + *

            The message carries the terminal conclusion literal and the required feasibility summary. The summary slot name + * differs from its section title, so the vocabulary exception key is used. + * + * @since 2026-08 + */ +final class FeasibilityEndingGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a feasibility negotiation accept or reject message. + * + * @param context negotiation context of the message + * @param content feasibility ending content + * @param template feasibility ending template to render + * @param vocabulary vocabulary of the message language + * @return rendered feasibility terminal message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + FeasibilityEndingContent endingContent = + contentOf(content, FeasibilityEndingContent.class, "Feasibility ending generator"); + renderableConclusion(endingContent.conclusion()); + requiredText( + endingContent.feasibilitySummary(), + "content.feasibilitySummary", + "Feasibility summary of a terminal feasibility negotiation message", + vocabulary); + Map slots = new LinkedHashMap<>(); + slots.put( + vocabulary.get("slot.feasibility_conclusion"), + endingContent.conclusion().literal()); + slots.put(vocabulary.get("slot.feasibility_confirm"), endingContent.feasibilitySummary()); + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java index 30f62eee..60d3f113 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java @@ -1,115 +1,115 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import org.jspecify.annotations.Nullable; - -/** - * Generator for feasibility negotiation propose messages. - * - *

            The message category selects which conditional section is rendered: requesting a feasibility evaluation renders - * the contents to evaluate, proposing an alternative after an infeasible outcome renders the infeasibility details and - * proposal, and a non-blank confirm request marks the derived "assessed as feasible and requesting confirmation" - * category (action {@code REQUEST_FEASIBILITY_EVALUATION} with both lists empty) that renders only the confirm request. - * Exactly one of the three sections is always present. - * - * @since 2026-08 - */ -final class FeasibilityProposeGenerator extends AbstractNegotiationGenerator { - - /** - * Generates a feasibility negotiation propose message. - * - * @param context negotiation context of the message - * @param content feasibility propose content - * @param template feasibility propose template to render - * @param vocabulary vocabulary of the message language - * @return rendered feasibility propose message text - * @throws NullPointerException if the action is null - * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the summary is blank - * or the action-selected section is empty, or {@code negotiation.invalid_input} if the confirm request is - * combined with the wrong action or either conditional section - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - FeasibilityProposeContent proposeContent = - contentOf(content, FeasibilityProposeContent.class, "Feasibility propose generator"); - requiredText( - proposeContent.feasibilityNegotiationDescription(), - "content.feasibilityNegotiationDescription", - "Feasibility negotiation description", - vocabulary); - NegotiationAction action = proposeContent.action(); - Objects.requireNonNull( - action, - "Feasibility negotiation action must not be null; it selects the conditional sections of the message."); - String confirmRequest = presentText(proposeContent.feasibilityConfirmRequest()); - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.feasibility"), proposeContent.feasibilityNegotiationDescription()); - if (confirmRequest != null) { - if (action != NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { - throw invalidInput( - "Feasibility confirm request requires the REQUEST_FEASIBILITY_EVALUATION action but the content" - + " carries " - + action.name() - + ".", - vocabulary); - } - if (hasItems(proposeContent.contentsToEvaluate()) - || hasItems(proposeContent.infeasibilityDetailsAndProposal())) { - throw invalidInput( - "Feasibility confirm request must not be combined with the contents to evaluate or" - + " infeasibility details and proposal sections; a confirm-request round carries only" - + " the summary and the confirm request.", - vocabulary); - } - slots.put(vocabulary.get("slot.feasibility_confirm_request"), confirmRequest); - } else if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { - slots.put( - vocabulary.get("slot.feasibility_evaluate"), - formatItems( - requiredItems( - proposeContent.contentsToEvaluate(), - "content.contentsToEvaluate", - "Contents to evaluate of a feasibility evaluation request", - vocabulary), - vocabulary)); - } else { - slots.put( - vocabulary.get("slot.feasibility_infeasible"), - formatItems( - requiredItems( - proposeContent.infeasibilityDetailsAndProposal(), - "content.infeasibilityDetailsAndProposal", - "Infeasibility details and proposal of an alternative proposal", - vocabulary), - vocabulary)); - } - return render(template, slots); - } - - private static @Nullable String presentText(@Nullable String value) { - return value == null || value.isBlank() ? null : value; - } - - private static boolean hasItems(@Nullable List items) { - return items != null && !items.isEmpty(); - } - - private static NegotiationGenerationException invalidInput(String reason, Vocabulary vocabulary) { - return new NegotiationGenerationException( - ErrorCatalog.NEGOTIATION_INVALID_INPUT, vocabulary.language(), Map.of("reason", reason)); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import org.jspecify.annotations.Nullable; + +/** + * Generator for feasibility negotiation propose messages. + * + *

            The message category selects which conditional section is rendered: requesting a feasibility evaluation renders + * the contents to evaluate, proposing an alternative after an infeasible outcome renders the infeasibility details and + * proposal, and a non-blank confirm request marks the derived "assessed as feasible and requesting confirmation" + * category (action {@code REQUEST_FEASIBILITY_EVALUATION} with both lists empty) that renders only the confirm request. + * Exactly one of the three sections is always present. + * + * @since 2026-08 + */ +final class FeasibilityProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a feasibility negotiation propose message. + * + * @param context negotiation context of the message + * @param content feasibility propose content + * @param template feasibility propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered feasibility propose message text + * @throws NullPointerException if the action is null + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the summary is blank + * or the action-selected section is empty, or {@code negotiation.invalid_input} if the confirm request is + * combined with the wrong action or either conditional section + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + FeasibilityProposeContent proposeContent = + contentOf(content, FeasibilityProposeContent.class, "Feasibility propose generator"); + requiredText( + proposeContent.feasibilityNegotiationDescription(), + "content.feasibilityNegotiationDescription", + "Feasibility negotiation description", + vocabulary); + NegotiationAction action = proposeContent.action(); + Objects.requireNonNull( + action, + "Feasibility negotiation action must not be null; it selects the conditional sections of the message."); + String confirmRequest = presentText(proposeContent.feasibilityConfirmRequest()); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.feasibility"), proposeContent.feasibilityNegotiationDescription()); + if (confirmRequest != null) { + if (action != NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + throw invalidInput( + "Feasibility confirm request requires the REQUEST_FEASIBILITY_EVALUATION action but the content" + + " carries " + + action.name() + + ".", + vocabulary); + } + if (hasItems(proposeContent.contentsToEvaluate()) + || hasItems(proposeContent.infeasibilityDetailsAndProposal())) { + throw invalidInput( + "Feasibility confirm request must not be combined with the contents to evaluate or" + + " infeasibility details and proposal sections; a confirm-request round carries only" + + " the summary and the confirm request.", + vocabulary); + } + slots.put(vocabulary.get("slot.feasibility_confirm_request"), confirmRequest); + } else if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + slots.put( + vocabulary.get("slot.feasibility_evaluate"), + formatItems( + requiredItems( + proposeContent.contentsToEvaluate(), + "content.contentsToEvaluate", + "Contents to evaluate of a feasibility evaluation request", + vocabulary), + vocabulary)); + } else { + slots.put( + vocabulary.get("slot.feasibility_infeasible"), + formatItems( + requiredItems( + proposeContent.infeasibilityDetailsAndProposal(), + "content.infeasibilityDetailsAndProposal", + "Infeasibility details and proposal of an alternative proposal", + vocabulary), + vocabulary)); + } + return render(template, slots); + } + + private static @Nullable String presentText(@Nullable String value) { + return value == null || value.isBlank() ? null : value; + } + + private static boolean hasItems(@Nullable List items) { + return items != null && !items.isEmpty(); + } + + private static NegotiationGenerationException invalidInput(String reason, Vocabulary vocabulary) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT, vocabulary.language(), Map.of("reason", reason)); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java index 6fd26a21..d9d4f974 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java @@ -1,44 +1,44 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; - -/** - * Generator for information negotiation terminal messages. - * - *

            The message carries the terminal conclusion literal and the information items delivered with it. - * - * @since 2026-08 - */ -final class InformationEndingGenerator extends AbstractNegotiationGenerator { - - /** - * Generates an information negotiation accept or reject message. - * - * @param context negotiation context of the message - * @param content information ending content - * @param template information ending template to render - * @param vocabulary vocabulary of the message language - * @return rendered information terminal message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - InformationEndingContent endingContent = - contentOf(content, InformationEndingContent.class, "Information ending generator"); - renderableConclusion(endingContent.conclusion()); - requiredItems( - endingContent.items(), "items", "Information negotiation terminal message result content", vocabulary); - Map slots = new LinkedHashMap<>(); - slots.put( - vocabulary.get("slot.info_conclusion"), - endingContent.conclusion().literal()); - slots.put(vocabulary.get("slot.info_result_content"), formatItems(endingContent.items(), vocabulary)); - return render(template, slots); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Generator for information negotiation terminal messages. + * + *

            The message carries the terminal conclusion literal and the information items delivered with it. + * + * @since 2026-08 + */ +final class InformationEndingGenerator extends AbstractNegotiationGenerator { + + /** + * Generates an information negotiation accept or reject message. + * + * @param context negotiation context of the message + * @param content information ending content + * @param template information ending template to render + * @param vocabulary vocabulary of the message language + * @return rendered information terminal message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + InformationEndingContent endingContent = + contentOf(content, InformationEndingContent.class, "Information ending generator"); + renderableConclusion(endingContent.conclusion()); + requiredItems( + endingContent.items(), "items", "Information negotiation terminal message result content", vocabulary); + Map slots = new LinkedHashMap<>(); + slots.put( + vocabulary.get("slot.info_conclusion"), + endingContent.conclusion().literal()); + slots.put(vocabulary.get("slot.info_result_content"), formatItems(endingContent.items(), vocabulary)); + return render(template, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java index c7baa3e9..abc84ccf 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java @@ -1,50 +1,50 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; - -/** - * Generator for information negotiation propose messages. - * - *

            The message carries the requested information items and, when present, a free-form line describing how the missing - * items relate to each other, appended after the item list. - * - * @since 2026-08 - */ -final class InformationProposeGenerator extends AbstractNegotiationGenerator { - - /** - * Generates an information negotiation propose message. - * - * @param context negotiation context of the message - * @param content information propose content - * @param template information propose template to render - * @param vocabulary vocabulary of the message language - * @return rendered information propose message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - InformationProposeContent proposeContent = - contentOf(content, InformationProposeContent.class, "Information propose generator"); - requiredItems( - proposeContent.items(), "items", "Information negotiation propose message requested items", vocabulary); - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.info_items"), itemsSlotValue(proposeContent, vocabulary)); - return render(template, slots); - } - - private String itemsSlotValue(InformationProposeContent content, Vocabulary vocabulary) { - String items = formatItems(content.items(), vocabulary); - if (content.relationship() == null || content.relationship().isBlank()) { - return items; - } - String relationshipLine = vocabulary.get("label.relationship") + content.relationship(); - return items.isEmpty() ? relationshipLine : items + "\n" + relationshipLine; - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Generator for information negotiation propose messages. + * + *

            The message carries the requested information items and, when present, a free-form line describing how the missing + * items relate to each other, appended after the item list. + * + * @since 2026-08 + */ +final class InformationProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates an information negotiation propose message. + * + * @param context negotiation context of the message + * @param content information propose content + * @param template information propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered information propose message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + InformationProposeContent proposeContent = + contentOf(content, InformationProposeContent.class, "Information propose generator"); + requiredItems( + proposeContent.items(), "items", "Information negotiation propose message requested items", vocabulary); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.info_items"), itemsSlotValue(proposeContent, vocabulary)); + return render(template, slots); + } + + private String itemsSlotValue(InformationProposeContent content, Vocabulary vocabulary) { + String items = formatItems(content.items(), vocabulary); + if (content.relationship() == null || content.relationship().isBlank()) { + return items; + } + String relationshipLine = vocabulary.get("label.relationship") + content.relationship(); + return items.isEmpty() ? relationshipLine : items + "\n" + relationshipLine; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java index 8d26d48a..09fef7fd 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractor.java @@ -1,31 +1,31 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; - -/** - * Extracts typed negotiation content from a free-text input. - * - *

            Implementations decide how the text is understood; the returned content must already satisfy the generator input - * rules so the deterministic rendering step can consume it directly. - * - * @since 2026-08 - */ -public interface NegotiationContentExtractor { - - /** - * Extracts the typed content of one negotiation message from free text. - * - * @param text free-text input describing the message content - * @param reference reference identifying the negotiation type, phase and language to extract for - * @return typed negotiation content matching the reference - * @throws NegotiationGenerationException with code {@code llm.not_configured} when no LLM client is configured, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the LLM invocation fails, - * {@code negotiation.content_extract_failed} when the response cannot be parsed as the expected content, - * {@code negotiation.field_missing} when a required field is missing, {@code negotiation.conclusion_mismatch} - * when the extracted conclusion contradicts the addressed phase, or {@code negotiation.invalid_input} when the - * extracted content contradicts the addressed phase or action - */ - NegotiationContent extract(String text, NegotiationReference reference); -} +package net.openan.a2at.sdk.negotiation.generation; + +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; + +/** + * Extracts typed negotiation content from a free-text input. + * + *

            Implementations decide how the text is understood; the returned content must already satisfy the generator input + * rules so the deterministic rendering step can consume it directly. + * + * @since 2026-08 + */ +public interface NegotiationContentExtractor { + + /** + * Extracts the typed content of one negotiation message from free text. + * + * @param text free-text input describing the message content + * @param reference reference identifying the negotiation type, phase and language to extract for + * @return typed negotiation content matching the reference + * @throws NegotiationGenerationException with code {@code llm.not_configured} when no LLM client is configured, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the LLM invocation fails, + * {@code negotiation.content_extract_failed} when the response cannot be parsed as the expected content, + * {@code negotiation.field_missing} when a required field is missing, {@code negotiation.conclusion_mismatch} + * when the extracted conclusion contradicts the addressed phase, or {@code negotiation.invalid_input} when the + * extracted content contradicts the addressed phase or action + */ + NegotiationContent extract(String text, NegotiationReference reference); +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java index 05be2d3f..d9114957 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -1,322 +1,322 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Shared service for the negotiation content layer consumed by both the client and the server facade. - * - *

            Each facade method delegates to exactly one service method, so the negotiation content-layer surface is defined - * once instead of being copy-pasted per facade. The service itself is a thin typing over the - * {@link NegotiationGenerationOrchestrator} pipeline and adds no behavior. - * - * @since 2026-08 - */ -public final class NegotiationContentService { - - private final NegotiationGenerationOrchestrator orchestrator; - - /** - * Creates one service over the given negotiation generation orchestrator. - * - * @param orchestrator negotiation generation orchestrator carrying the actual pipelines - */ - public NegotiationContentService(@NonNull NegotiationGenerationOrchestrator orchestrator) { - this.orchestrator = Objects.requireNonNull(orchestrator, "Negotiation orchestrator must not be null."); - } - - /** - * Assembles the default negotiation generation orchestrator from the unified SDK config. - * - *

            The wiring is shared by the client and the server builder: the message language comes from the prompt runtime - * config (negotiation templates and vocabularies are classpath-fixed), the retry attempt limit comes from the LLM - * config, and the LLM client is passed by the caller and may be null when the provider is {@code local_rule}. - * - * @param config unified SDK config - * @param llmClient LLM client for the LLM-backed steps; null keeps those steps unavailable - * @return assembled negotiation generation orchestrator - */ - public static NegotiationGenerationOrchestrator buildOrchestrator( - @NonNull A2ATConfig config, @Nullable LLMClient llmClient) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(config.prompt().language()) - .llmClient(llmClient) - .maxAttempts(config.llm().maxAttempts()) - .maxTextChars(config.inputLimits().maxTextChars()) - .build(); - } - - /** - * Generates a propose negotiation message from typed data, deterministically without any LLM call. - * - * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI whose performative segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content - * type - * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when a required content - * field is blank or empty, or {@code negotiation.invalid_input} when the content combines a non-blank confirm - * request with conditional sections (target: the three clarification lists; feasibility: either conditional - * list or the alternative action) - * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, - * or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateProposeFromData( - @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { - return orchestrator.generateProposeFromData(data, templateUri); - } - - /** - * Generates an accept negotiation message from typed data, deterministically without any LLM call. - * - * @param data typed terminal input whose content conclusion must be {@code Accept} - * @param templateUri template URI whose performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content - * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content - * conclusion is not {@code Accept}, or {@code negotiation.content_invalid} when a required content field is - * blank or empty - * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, - * or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateAcceptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - return orchestrator.generateAcceptFromData(data, templateUri); - } - - /** - * Generates a reject negotiation message from typed data, deterministically without any LLM call. - * - * @param data typed terminal input whose content conclusion must be {@code Reject} - * @param templateUri template URI whose performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content - * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content - * conclusion is not {@code Reject}, or {@code negotiation.content_invalid} when a required content field is - * blank or empty - * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, - * or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateRejectFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - return orchestrator.generateRejectFromData(data, templateUri); - } - - /** - * Generates an abort negotiation message from typed data, deterministically without any LLM call. - * - * @param data typed abort input carrying the termination reason - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when the termination - * reason is blank - * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, - * or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateAbortFromData(@NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { - return orchestrator.generateAbortFromData(data, templateUri); - } - - /** - * Generates a propose negotiation message from free text through one LLM content-extraction step. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI whose performative segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationGenerationException with the code {@code template.not_found}, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} - * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, - * {@code negotiation.field_missing} when the extracted content misses a - * required field, or {@code negotiation.invalid_input} when the text is blank, the extracted content - * contradicts the performative or the extracted confirm request is combined with conditional sections or the - * wrong feasibility action, or {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateProposeFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return orchestrator.generateProposeFromText(text, context, templateUri); - } - - /** - * Generates an accept negotiation message from free text through one LLM content-extraction step. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI whose performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationGenerationException with the code {@code template.not_found}, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} - * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, - * {@code negotiation.field_missing} when the extracted content misses a - * required field, or {@code negotiation.invalid_input} when the text is blank, - * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Accept}, or - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateAcceptFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return orchestrator.generateAcceptFromText(text, context, templateUri); - } - - /** - * Generates a reject negotiation message from free text through one LLM content-extraction step. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI whose performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationGenerationException with the code {@code template.not_found}, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} - * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, - * {@code negotiation.field_missing} when the extracted content misses a - * required field, or {@code negotiation.invalid_input} when the text is blank, - * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Reject}, or - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateRejectFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return orchestrator.generateRejectFromText(text, context, templateUri); - } - - /** - * Generates an abort negotiation message from free text through one LLM content-extraction step. - * - * @param text free-text input stating the termination reason - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationGenerationException with the code {@code template.not_found}, - * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} - * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, - * {@code negotiation.field_missing} when the extracted content misses the - * termination reason, or {@code negotiation.invalid_input} when the text is blank, or - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateAbortFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return orchestrator.generateAbortFromText(text, context, templateUri); - } - - /** - * Validates a propose negotiation message and extracts its parameters. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI whose performative segment must be {@code propose} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, - * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or - * {@code template.not_found} when the validation pipeline fails - */ - public FilledParamData validateProposePromptAndDataFilling( - String prompt, - @Nullable NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return orchestrator.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates an accept negotiation message and extracts its parameters. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI whose performative segment must be {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, - * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or - * {@code template.not_found} when the validation pipeline fails - */ - public FilledParamData validateAcceptPromptAndDataFilling( - String prompt, - @Nullable NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return orchestrator.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates a reject negotiation message and extracts its parameters. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI whose performative segment must be {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, - * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or - * {@code template.not_found} when the validation pipeline fails - */ - public FilledParamData validateRejectPromptAndDataFilling( - String prompt, - @Nullable NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return orchestrator.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates an abort negotiation message and extracts its parameters. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationParamExtractionException with the code {@code input.text_too_long} when the prompt exceeds the - * configured maximum length, or another validation pipeline failure code otherwise - */ - public FilledParamData validateAbortPromptAndDataFilling( - String prompt, - @Nullable NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return orchestrator.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Shared service for the negotiation content layer consumed by both the client and the server facade. + * + *

            Each facade method delegates to exactly one service method, so the negotiation content-layer surface is defined + * once instead of being copy-pasted per facade. The service itself is a thin typing over the + * {@link NegotiationGenerationOrchestrator} pipeline and adds no behavior. + * + * @since 2026-08 + */ +public final class NegotiationContentService { + + private final NegotiationGenerationOrchestrator orchestrator; + + /** + * Creates one service over the given negotiation generation orchestrator. + * + * @param orchestrator negotiation generation orchestrator carrying the actual pipelines + */ + public NegotiationContentService(@NonNull NegotiationGenerationOrchestrator orchestrator) { + this.orchestrator = Objects.requireNonNull(orchestrator, "Negotiation orchestrator must not be null."); + } + + /** + * Assembles the default negotiation generation orchestrator from the unified SDK config. + * + *

            The wiring is shared by the client and the server builder: the message language comes from the prompt runtime + * config (negotiation templates and vocabularies are classpath-fixed), the retry attempt limit comes from the LLM + * config, and the LLM client is passed by the caller and may be null when the provider is {@code local_rule}. + * + * @param config unified SDK config + * @param llmClient LLM client for the LLM-backed steps; null keeps those steps unavailable + * @return assembled negotiation generation orchestrator + */ + public static NegotiationGenerationOrchestrator buildOrchestrator( + @NonNull A2ATConfig config, @Nullable LLMClient llmClient) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(config.prompt().language()) + .llmClient(llmClient) + .maxAttempts(config.llm().maxAttempts()) + .maxTextChars(config.inputLimits().maxTextChars()) + .build(); + } + + /** + * Generates a propose negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri template URI whose performative segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content + * type + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when a required content + * field is blank or empty, or {@code negotiation.invalid_input} when the content combines a non-blank confirm + * request with conditional sections (target: the three clarification lists; feasibility: either conditional + * list or the alternative action) + * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, + * or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateProposeFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { + return orchestrator.generateProposeFromData(data, templateUri); + } + + /** + * Generates an accept negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed terminal input whose content conclusion must be {@code Accept} + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Accept}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, + * or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateAcceptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + return orchestrator.generateAcceptFromData(data, templateUri); + } + + /** + * Generates a reject negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed terminal input whose content conclusion must be {@code Reject} + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Reject}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, + * or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateRejectFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + return orchestrator.generateRejectFromData(data, templateUri); + } + + /** + * Generates an abort negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed abort input carrying the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when the termination + * reason is blank + * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, + * or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateAbortFromData(@NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + return orchestrator.generateAbortFromData(data, templateUri); + } + + /** + * Generates a propose negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI whose performative segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, + * {@code negotiation.field_missing} when the extracted content misses a + * required field, or {@code negotiation.invalid_input} when the text is blank, the extracted content + * contradicts the performative or the extracted confirm request is combined with conditional sections or the + * wrong feasibility action, or {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateProposeFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return orchestrator.generateProposeFromText(text, context, templateUri); + } + + /** + * Generates an accept negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, + * {@code negotiation.field_missing} when the extracted content misses a + * required field, or {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Accept}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateAcceptFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return orchestrator.generateAcceptFromText(text, context, templateUri); + } + + /** + * Generates a reject negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, + * {@code negotiation.field_missing} when the extracted content misses a + * required field, or {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Reject}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateRejectFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return orchestrator.generateRejectFromText(text, context, templateUri); + } + + /** + * Generates an abort negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input stating the termination reason + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, + * {@code negotiation.field_missing} when the extracted content misses the + * termination reason, or {@code negotiation.invalid_input} when the text is blank, or + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateAbortFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return orchestrator.generateAbortFromText(text, context, templateUri); + } + + /** + * Validates a propose negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI whose performative segment must be {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, + * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or + * {@code template.not_found} when the validation pipeline fails + */ + public FilledParamData validateProposePromptAndDataFilling( + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates an accept negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, + * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or + * {@code template.not_found} when the validation pipeline fails + */ + public FilledParamData validateAcceptPromptAndDataFilling( + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates a reject negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, + * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or + * {@code template.not_found} when the validation pipeline fails + */ + public FilledParamData validateRejectPromptAndDataFilling( + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationParamExtractionException with the code {@code input.text_too_long} when the prompt exceeds the + * configured maximum length, or another validation pipeline failure code otherwise + */ + public FilledParamData validateAbortPromptAndDataFilling( + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java index 6cf02522..0def625f 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -1,651 +1,651 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.function.Supplier; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.ExtensionUriConstants; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.InputLimitConfig; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; -import org.jspecify.annotations.NonNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Orchestrates the negotiation content layer: deterministic message generation, single-step LLM content extraction and - * the validation-plus-parameter-extraction pipeline. - * - *

            The orchestrator owns the retry loop of every LLM step. A step failing with one of the retryable codes - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} is re-run - * up to the configured attempt limit; the exhaustion failure rethrows the original error code. Internal exceptions of - * the generation and validation pipelines never bubble out raw: render failures become generation failures carrying - * {@code template.render_failed}, semantic validation infrastructure failures become parameter-extraction failures - * carrying {@code llm.invocation_failed} or {@code llm.response_invalid}, and resource load misses become the code - * {@code template.not_found} on both pipelines. - * - *

            Instances are created through {@link NegotiationGenerationOrchestratorBuilder}; the builder wires the default - * collaborators and allows overriding each of them. - * - * @since 2026-08 - */ -public final class NegotiationGenerationOrchestrator { - - private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(NegotiationGenerationOrchestrator.class); - - private static final String STEP_CONTENT_EXTRACT = "negotiation_content_extract"; - - private final String language; - - private final int maxAttempts; - - private final int maxTextChars; - - private final NegotiationTemplateLoader templateLoader; - - private final NegotiationContentExtractor contentExtractor; - - private final ParamExtractor paramExtractor; - - private final NegotiationGeneratorRegistry generatorRegistry; - - private final Vocabulary vocabulary; - - private final Logger logger; - - NegotiationGenerationOrchestrator( - String language, - int maxAttempts, - int maxTextChars, - NegotiationTemplateLoader templateLoader, - NegotiationContentExtractor contentExtractor, - ParamExtractor paramExtractor, - NegotiationGeneratorRegistry generatorRegistry, - Vocabulary vocabulary, - Logger logger) { - this.language = language; - this.maxAttempts = maxAttempts; - this.maxTextChars = maxTextChars; - this.templateLoader = templateLoader; - this.contentExtractor = contentExtractor; - this.paramExtractor = paramExtractor; - this.generatorRegistry = generatorRegistry; - this.vocabulary = vocabulary; - this.logger = logger == null ? DEFAULT_LOGGER : logger; - } - - /** - * Generates a propose-phase negotiation message from typed data. - * - *

            This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the - * generator of the negotiation type addressed by the template URI and rendered from that template. - * - * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase - * segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content type - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the - * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateProposeFromData( - @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "Negotiation propose data must not be null."); - return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.PROPOSE); - } - - /** - * Generates an accept-phase negotiation message from typed data. - * - *

            This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content - * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content - * conclusion is not {@code Accept}, or {@code negotiation.content_invalid} when a required content field is - * blank or empty - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the - * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateAcceptFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "Negotiation ending data must not be null."); - return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.ACCEPT); - } - - /** - * Generates a reject-phase negotiation message from typed data. - * - *

            This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content - * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content - * conclusion is not {@code Reject}, or {@code negotiation.content_invalid} when a required content field is - * blank or empty - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the - * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateRejectFromData( - @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "Negotiation ending data must not be null."); - return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.REJECT); - } - - /** - * Generates an abort negotiation message from typed data. - * - *

            This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed - * template must be the common abort template and the content carries only the termination reason. - * - * @param data typed abort input carrying the negotiation context and the termination reason - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when the termination - * reason is blank - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the - * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateAbortFromData(@NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { - Objects.requireNonNull(data, "Negotiation abort data must not be null."); - return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.ABORT); - } - - /** - * Generates a propose-phase negotiation message from free text. - * - *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment - * must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation.content_extract_failed} or - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting - * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or - * {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the phase, - * {@code template.render_failed} when rendering the template fails, or - * {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateProposeFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return generateFromText(text, context, templateUri, NegotiationPerformative.PROPOSE); - } - - /** - * Generates an accept-phase negotiation message from free text. - * - *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Accept}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation.content_extract_failed} or - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting - * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or - * {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} when the - * extracted conclusion is not {@code Accept}, {@code template.render_failed} when rendering the template - * fails, or {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateAcceptFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return generateFromText(text, context, templateUri, NegotiationPerformative.ACCEPT); - } - - /** - * Generates a reject-phase negotiation message from free text. - * - *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Reject}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase - * segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation.content_extract_failed} or - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting - * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or - * {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} when the - * extracted conclusion is not {@code Reject}, {@code template.render_failed} when rendering the template - * fails, or {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateRejectFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return generateFromText(text, context, templateUri, NegotiationPerformative.REJECT); - } - - /** - * Generates an abort negotiation message from free text. - * - *

            This variant runs one LLM content-extraction step constrained by the common abort template and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. - * - * @param text free-text input stating the termination reason - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; - * the emitted negotiation context is the input context stamped with the performative of the addressed template, - * so it may differ from the context the caller passed in - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt - * resource exists for the URI and language, {@code negotiation.content_extract_failed} or - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting - * its retries, {@code negotiation.field_missing} when the extracted content misses the termination reason, or - * {@code negotiation.invalid_input} when the text is blank, {@code template.render_failed} when rendering the - * template fails, or {@code input.text_too_long} when the text exceeds the configured maximum length - */ - public MetadataContent generateAbortFromText( - String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { - return generateFromText(text, context, templateUri, NegotiationPerformative.ABORT); - } - - /** - * Validates a propose-phase negotiation message and extracts its parameters. - * - *

            The pipeline checks the template URI before any LLM call, runs the deterministic rule gate, then performs one - * semantic validation LLM call (retried on the retryable failure codes) and merges the extracted parameters with - * the rule-level context parameters; context parameters win on conflict. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code propose} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or when the context is null and the message is therefore not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length - */ - public FilledParamData validateProposePromptAndDataFilling( - String prompt, - NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.PROPOSE); - } - - /** - * Validates an accept-phase negotiation message and extracts its parameters. - * - *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, - * TemplateUri)} with the expected phase fixed to accept: the template URI must declare the {@code accept-reject} - * segment and the message must satisfy the accept-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or when the context is null and the message is therefore not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length - */ - public FilledParamData validateAcceptPromptAndDataFilling( - String prompt, - NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.ACCEPT); - } - - /** - * Validates a reject-phase negotiation message and extracts its parameters. - * - *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, - * TemplateUri)} with the expected phase fixed to reject: the template URI must declare the {@code accept-reject} - * segment and the message must satisfy the reject-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be - * {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI's phase contradicts the method - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or when the context is null and the message is therefore not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length - */ - public FilledParamData validateRejectPromptAndDataFilling( - String prompt, - NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.REJECT); - } - - /** - * Validates an abort negotiation message and extracts its parameters. - * - *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, - * TemplateUri)} with the expected phase fixed to abort: the template URI must address the common abort template and - * the message must satisfy the abort-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI does not address the common abort template - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is - * null or blank, or when the context is null and the message is therefore not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length - */ - public FilledParamData validateAbortPromptAndDataFilling( - String prompt, - NegotiationContext context, - @NonNull Map schema, - @NonNull TemplateUri templateUri) { - return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.ABORT); - } - - private MetadataContent generateFromData( - NegotiationContext context, - NegotiationContent content, - TemplateUri templateUri, - NegotiationPerformative performative) { - requireContext(context); - try { - NegotiationReference reference = requireReference(templateUri, performative); - PromptTemplate template = loadTemplate(reference); - String promptText = renderMessage(context, content, reference, template); - return completeGeneration(reference, promptText, context); - } catch (NegotiationGenerationException failure) { - logger.atWarn() - .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); - throw failure; - } - } - - private MetadataContent generateFromText( - String text, NegotiationContext context, TemplateUri templateUri, NegotiationPerformative performative) { - requireContext(context); - requireTextWithinLimit(text); - try { - NegotiationReference reference = requireReference(templateUri, performative); - PromptTemplate template = loadTemplate(reference); - NegotiationContent content = extractContent(text, reference); - String promptText = renderMessage(context, content, reference, template); - return completeGeneration(reference, promptText, context); - } catch (NegotiationGenerationException failure) { - logger.atWarn() - .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); - throw failure; - } - } - - private PromptTemplate loadTemplate(NegotiationReference reference) { - try { - return templateLoader.load(reference); - } catch (ResourceNotFoundException exception) { - throw templateNotFound(reference, exception); - } - } - - private NegotiationContent extractContent(String text, NegotiationReference reference) { - try { - return withRetry(STEP_CONTENT_EXTRACT, () -> contentExtractor.extract(text, reference)); - } catch (ResourceNotFoundException exception) { - throw templateNotFound(reference, exception); - } - } - - private static NegotiationGenerationException templateNotFound( - NegotiationReference reference, ResourceNotFoundException cause) { - return new NegotiationGenerationException( - ErrorCatalog.TEMPLATE_NOT_FOUND, - reference.language(), - Map.of("template_uri", reference.uri(), "language", reference.language()), - cause); - } - - private String renderMessage( - NegotiationContext context, - NegotiationContent content, - NegotiationReference reference, - PromptTemplate template) { - NegotiationGenerator generator = - generatorRegistry.resolve(reference.type(), reference.performative(), content, reference.language()); - try { - return generator.generate(context, content, template, vocabulary); - } catch (NegotiationRenderException exception) { - throw new NegotiationGenerationException( - ErrorCatalog.TEMPLATE_RENDER_FAILED, - reference.language(), - Map.of( - "template_uri", reference.uri(), - "reason", String.valueOf(exception.getMessage())), - exception); - } - } - - private MetadataContent completeGeneration( - NegotiationReference reference, String promptText, NegotiationContext context) { - // The operation is the source of truth for the performative: the emitted context is the input context stamped - // with the performative of the addressed template, overriding whatever the caller passed in. - NegotiationContext stampedContext = context.withPerformative(reference.performative()); - logger.atInfo().log( - "negotiation_generation_completed uri={} type={} performative={} round={} id={}", - reference.uri(), - reference.type(), - reference.performative(), - stampedContext.round(), - stampedContext.id()); - return new MetadataContent( - reference.uri(), promptText, ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, stampedContext); - } - - private FilledParamData validatePromptAndDataFilling( - String prompt, - NegotiationContext context, - Map schema, - TemplateUri templateUri, - NegotiationPerformative performative) { - Objects.requireNonNull(schema, "Parameter schema must not be null."); - requirePromptWithinLimit(prompt); - NegotiationReference reference = requireReference(templateUri, performative); - try { - return paramExtractor.extract(prompt, context, schema, reference); - } catch (NegotiationParamExtractionException failure) { - logger.atWarn() - .log( - "negotiation_param_extraction_failed code={} error_count={}", - failure.getCode(), - failure.getErrors().size()); - throw failure; - } - } - - /** - * Derives the reference of a typed template URI against one expected performative. - * - * @param templateUri template URI declared by the caller - * @param performative performative the calling method operates on - * @return reference addressed by the URI carrying the expected performative - * @throws IllegalArgumentException if the URI does not address a negotiation template of the expected performative - */ - private NegotiationReference requireReference(TemplateUri templateUri, NegotiationPerformative performative) { - return NegotiationReference.fromTemplateUri(templateUri, performative, language) - .orElseThrow(() -> new IllegalArgumentException( - "Template URI does not address a negotiation template of the expected performative " - + performative + " (" + NegotiationReference.uriSegmentOf(performative) + "): " - + templateUri.uri() + ".")); - } - - /** - * Runs one LLM step with the retry policy of the negotiation content layer. - * - *

            A failure carrying a retryable code is re-run up to the configured attempt limit; a failure carrying any other - * code is rethrown immediately. When the attempts are exhausted, the original failure is rethrown with its original - * error code. - * - * @param result type of the step - * @param step internal diagnostic step name used in the retry logs - * @param action step implementation performing exactly one LLM call - * @return step result - */ - private T withRetry(String step, Supplier action) { - RuntimeException lastFailure = null; - for (int attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return action.get(); - } catch (RuntimeException failure) { - String code = retryableCode(failure); - if (code == null) { - throw failure; - } - lastFailure = failure; - if (attempt == maxAttempts) { - logger.atWarn() - .log( - "negotiation_llm_retry_exhausted step={} max_attempts={} code={}", - step, - maxAttempts, - code); - throw failure; - } - logger.atWarn() - .log( - "negotiation_llm_retry step={} attempt={} max_attempts={} code={}", - step, - attempt, - maxAttempts, - code); - } - } - throw lastFailure; - } - - private static String retryableCode(RuntimeException failure) { - String code = null; - if (failure instanceof NegotiationGenerationException generation) { - code = generation.getCode(); - } else if (failure instanceof NegotiationParamExtractionException extraction) { - code = extraction.getCode(); - } - if (code == null) { - return null; - } - boolean retryable = - ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode().equals(code) - || ErrorCatalog.LLM_INVOCATION_FAILED.getCode().equals(code) - || ErrorCatalog.LLM_RESPONSE_INVALID.getCode().equals(code); - return retryable ? code : null; - } - - private static void requireContext(NegotiationContext context) { - Objects.requireNonNull(context, "Negotiation context must not be null."); - } - - private void requireTextWithinLimit(String text) { - if (InputLimitConfig.isTooLong(text, maxTextChars)) { - throw new NegotiationGenerationException( - ErrorCatalog.INPUT_TEXT_TOO_LONG, language, inputTooLongFacts(text)); - } - } - - private void requirePromptWithinLimit(String prompt) { - if (InputLimitConfig.isTooLong(prompt, maxTextChars)) { - throw new NegotiationParamExtractionException( - ErrorCatalog.INPUT_TEXT_TOO_LONG, language, inputTooLongFacts(prompt), List.of()); - } - } - - private Map inputTooLongFacts(String text) { - return Map.of( - "actual_length", String.valueOf(text == null ? 0 : text.length()), - "max_chars", String.valueOf(maxTextChars)); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Orchestrates the negotiation content layer: deterministic message generation, single-step LLM content extraction and + * the validation-plus-parameter-extraction pipeline. + * + *

            The orchestrator owns the retry loop of every LLM step. A step failing with one of the retryable codes + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} is re-run + * up to the configured attempt limit; the exhaustion failure rethrows the original error code. Internal exceptions of + * the generation and validation pipelines never bubble out raw: render failures become generation failures carrying + * {@code template.render_failed}, semantic validation infrastructure failures become parameter-extraction failures + * carrying {@code llm.invocation_failed} or {@code llm.response_invalid}, and resource load misses become the code + * {@code template.not_found} on both pipelines. + * + *

            Instances are created through {@link NegotiationGenerationOrchestratorBuilder}; the builder wires the default + * collaborators and allows overriding each of them. + * + * @since 2026-08 + */ +public final class NegotiationGenerationOrchestrator { + + private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(NegotiationGenerationOrchestrator.class); + + private static final String STEP_CONTENT_EXTRACT = "negotiation_content_extract"; + + private final String language; + + private final int maxAttempts; + + private final int maxTextChars; + + private final NegotiationTemplateLoader templateLoader; + + private final NegotiationContentExtractor contentExtractor; + + private final ParamExtractor paramExtractor; + + private final NegotiationGeneratorRegistry generatorRegistry; + + private final Vocabulary vocabulary; + + private final Logger logger; + + NegotiationGenerationOrchestrator( + String language, + int maxAttempts, + int maxTextChars, + NegotiationTemplateLoader templateLoader, + NegotiationContentExtractor contentExtractor, + ParamExtractor paramExtractor, + NegotiationGeneratorRegistry generatorRegistry, + Vocabulary vocabulary, + Logger logger) { + this.language = language; + this.maxAttempts = maxAttempts; + this.maxTextChars = maxTextChars; + this.templateLoader = templateLoader; + this.contentExtractor = contentExtractor; + this.paramExtractor = paramExtractor; + this.generatorRegistry = generatorRegistry; + this.vocabulary = vocabulary; + this.logger = logger == null ? DEFAULT_LOGGER : logger; + } + + /** + * Generates a propose-phase negotiation message from typed data. + * + *

            This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the + * generator of the negotiation type addressed by the template URI and rendered from that template. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1}; its phase + * segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content type + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the + * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateProposeFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "Negotiation propose data must not be null."); + return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.PROPOSE); + } + + /** + * Generates an accept-phase negotiation message from typed data. + * + *

            This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Accept}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the + * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateAcceptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "Negotiation ending data must not be null."); + return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.ACCEPT); + } + + /** + * Generates a reject-phase negotiation message from typed data. + * + *

            This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Reject}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the + * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateRejectFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "Negotiation ending data must not be null."); + return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.REJECT); + } + + /** + * Generates an abort negotiation message from typed data. + * + *

            This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed + * template must be the common abort template and the content carries only the termination reason. + * + * @param data typed abort input carrying the negotiation context and the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when the termination + * reason is blank + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template exists for the + * URI in any resource root, or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateAbortFromData(@NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + Objects.requireNonNull(data, "Negotiation abort data must not be null."); + return generateFromData(data.context(), data.content(), templateUri, NegotiationPerformative.ABORT); + } + + /** + * Generates a propose-phase negotiation message from free text. + * + *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment + * must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting + * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or + * {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the phase, + * {@code template.render_failed} when rendering the template fails, or + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateProposeFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPerformative.PROPOSE); + } + + /** + * Generates an accept-phase negotiation message from free text. + * + *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Accept}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting + * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or + * {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} when the + * extracted conclusion is not {@code Accept}, {@code template.render_failed} when rendering the template + * fails, or {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateAcceptFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPerformative.ACCEPT); + } + + /** + * Generates a reject-phase negotiation message from free text. + * + *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Reject}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting + * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or + * {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} when the + * extracted conclusion is not {@code Reject}, {@code template.render_failed} when rendering the template + * fails, or {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateRejectFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPerformative.REJECT); + } + + /** + * Generates an abort negotiation message from free text. + * + *

            This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. + * + * @param text free-text input stating the termination reason + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting + * its retries, {@code negotiation.field_missing} when the extracted content misses the termination reason, or + * {@code negotiation.invalid_input} when the text is blank, {@code template.render_failed} when rendering the + * template fails, or {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateAbortFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPerformative.ABORT); + } + + /** + * Validates a propose-phase negotiation message and extracts its parameters. + * + *

            The pipeline checks the template URI before any LLM call, runs the deterministic rule gate, then performs one + * semantic validation LLM call (retried on the retryable failure codes) and merges the extracted parameters with + * the rule-level context parameters; context parameters win on conflict. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length + */ + public FilledParamData validateProposePromptAndDataFilling( + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.PROPOSE); + } + + /** + * Validates an accept-phase negotiation message and extracts its parameters. + * + *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, + * TemplateUri)} with the expected phase fixed to accept: the template URI must declare the {@code accept-reject} + * segment and the message must satisfy the accept-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length + */ + public FilledParamData validateAcceptPromptAndDataFilling( + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.ACCEPT); + } + + /** + * Validates a reject-phase negotiation message and extracts its parameters. + * + *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, + * TemplateUri)} with the expected phase fixed to reject: the template URI must declare the {@code accept-reject} + * segment and the message must satisfy the reject-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length + */ + public FilledParamData validateRejectPromptAndDataFilling( + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.REJECT); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, + * TemplateUri)} with the expected phase fixed to abort: the template URI must address the common abort template and + * the message must satisfy the abort-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length + */ + public FilledParamData validateAbortPromptAndDataFilling( + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.ABORT); + } + + private MetadataContent generateFromData( + NegotiationContext context, + NegotiationContent content, + TemplateUri templateUri, + NegotiationPerformative performative) { + requireContext(context); + try { + NegotiationReference reference = requireReference(templateUri, performative); + PromptTemplate template = loadTemplate(reference); + String promptText = renderMessage(context, content, reference, template); + return completeGeneration(reference, promptText, context); + } catch (NegotiationGenerationException failure) { + logger.atWarn() + .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); + throw failure; + } + } + + private MetadataContent generateFromText( + String text, NegotiationContext context, TemplateUri templateUri, NegotiationPerformative performative) { + requireContext(context); + requireTextWithinLimit(text); + try { + NegotiationReference reference = requireReference(templateUri, performative); + PromptTemplate template = loadTemplate(reference); + NegotiationContent content = extractContent(text, reference); + String promptText = renderMessage(context, content, reference, template); + return completeGeneration(reference, promptText, context); + } catch (NegotiationGenerationException failure) { + logger.atWarn() + .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); + throw failure; + } + } + + private PromptTemplate loadTemplate(NegotiationReference reference) { + try { + return templateLoader.load(reference); + } catch (ResourceNotFoundException exception) { + throw templateNotFound(reference, exception); + } + } + + private NegotiationContent extractContent(String text, NegotiationReference reference) { + try { + return withRetry(STEP_CONTENT_EXTRACT, () -> contentExtractor.extract(text, reference)); + } catch (ResourceNotFoundException exception) { + throw templateNotFound(reference, exception); + } + } + + private static NegotiationGenerationException templateNotFound( + NegotiationReference reference, ResourceNotFoundException cause) { + return new NegotiationGenerationException( + ErrorCatalog.TEMPLATE_NOT_FOUND, + reference.language(), + Map.of("template_uri", reference.uri(), "language", reference.language()), + cause); + } + + private String renderMessage( + NegotiationContext context, + NegotiationContent content, + NegotiationReference reference, + PromptTemplate template) { + NegotiationGenerator generator = + generatorRegistry.resolve(reference.type(), reference.performative(), content, reference.language()); + try { + return generator.generate(context, content, template, vocabulary); + } catch (NegotiationRenderException exception) { + throw new NegotiationGenerationException( + ErrorCatalog.TEMPLATE_RENDER_FAILED, + reference.language(), + Map.of( + "template_uri", reference.uri(), + "reason", String.valueOf(exception.getMessage())), + exception); + } + } + + private MetadataContent completeGeneration( + NegotiationReference reference, String promptText, NegotiationContext context) { + // The operation is the source of truth for the performative: the emitted context is the input context stamped + // with the performative of the addressed template, overriding whatever the caller passed in. + NegotiationContext stampedContext = context.withPerformative(reference.performative()); + logger.atInfo().log( + "negotiation_generation_completed uri={} type={} performative={} round={} id={}", + reference.uri(), + reference.type(), + reference.performative(), + stampedContext.round(), + stampedContext.id()); + return new MetadataContent( + reference.uri(), promptText, ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, stampedContext); + } + + private FilledParamData validatePromptAndDataFilling( + String prompt, + NegotiationContext context, + Map schema, + TemplateUri templateUri, + NegotiationPerformative performative) { + Objects.requireNonNull(schema, "Parameter schema must not be null."); + requirePromptWithinLimit(prompt); + NegotiationReference reference = requireReference(templateUri, performative); + try { + return paramExtractor.extract(prompt, context, schema, reference); + } catch (NegotiationParamExtractionException failure) { + logger.atWarn() + .log( + "negotiation_param_extraction_failed code={} error_count={}", + failure.getCode(), + failure.getErrors().size()); + throw failure; + } + } + + /** + * Derives the reference of a typed template URI against one expected performative. + * + * @param templateUri template URI declared by the caller + * @param performative performative the calling method operates on + * @return reference addressed by the URI carrying the expected performative + * @throws IllegalArgumentException if the URI does not address a negotiation template of the expected performative + */ + private NegotiationReference requireReference(TemplateUri templateUri, NegotiationPerformative performative) { + return NegotiationReference.fromTemplateUri(templateUri, performative, language) + .orElseThrow(() -> new IllegalArgumentException( + "Template URI does not address a negotiation template of the expected performative " + + performative + " (" + NegotiationReference.uriSegmentOf(performative) + "): " + + templateUri.uri() + ".")); + } + + /** + * Runs one LLM step with the retry policy of the negotiation content layer. + * + *

            A failure carrying a retryable code is re-run up to the configured attempt limit; a failure carrying any other + * code is rethrown immediately. When the attempts are exhausted, the original failure is rethrown with its original + * error code. + * + * @param result type of the step + * @param step internal diagnostic step name used in the retry logs + * @param action step implementation performing exactly one LLM call + * @return step result + */ + private T withRetry(String step, Supplier action) { + RuntimeException lastFailure = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return action.get(); + } catch (RuntimeException failure) { + String code = retryableCode(failure); + if (code == null) { + throw failure; + } + lastFailure = failure; + if (attempt == maxAttempts) { + logger.atWarn() + .log( + "negotiation_llm_retry_exhausted step={} max_attempts={} code={}", + step, + maxAttempts, + code); + throw failure; + } + logger.atWarn() + .log( + "negotiation_llm_retry step={} attempt={} max_attempts={} code={}", + step, + attempt, + maxAttempts, + code); + } + } + throw lastFailure; + } + + private static String retryableCode(RuntimeException failure) { + String code = null; + if (failure instanceof NegotiationGenerationException generation) { + code = generation.getCode(); + } else if (failure instanceof NegotiationParamExtractionException extraction) { + code = extraction.getCode(); + } + if (code == null) { + return null; + } + boolean retryable = + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode().equals(code) + || ErrorCatalog.LLM_INVOCATION_FAILED.getCode().equals(code) + || ErrorCatalog.LLM_RESPONSE_INVALID.getCode().equals(code); + return retryable ? code : null; + } + + private static void requireContext(NegotiationContext context) { + Objects.requireNonNull(context, "Negotiation context must not be null."); + } + + private void requireTextWithinLimit(String text) { + if (InputLimitConfig.isTooLong(text, maxTextChars)) { + throw new NegotiationGenerationException( + ErrorCatalog.INPUT_TEXT_TOO_LONG, language, inputTooLongFacts(text)); + } + } + + private void requirePromptWithinLimit(String prompt) { + if (InputLimitConfig.isTooLong(prompt, maxTextChars)) { + throw new NegotiationParamExtractionException( + ErrorCatalog.INPUT_TEXT_TOO_LONG, language, inputTooLongFacts(prompt), List.of()); + } + } + + private Map inputTooLongFacts(String text) { + return Map.of( + "actual_length", String.valueOf(text == null ? 0 : text.length()), + "max_chars", String.valueOf(maxTextChars)); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java index da91f805..2c8ab435 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -1,227 +1,227 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.InputLimitConfig; -import net.openan.a2at.sdk.core.model.LlmConfig; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMConfigError; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; -import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationSemanticValidator; -import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; -import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; -import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; -import org.slf4j.Logger; - -/** - * Fluent builder assembling one {@link NegotiationGenerationOrchestrator}. - * - *

            The language is required. The LLM client is optional: without one, the from-data generation still works, while the - * LLM steps of the from-text generation and the validation pipeline fail with the {@code llm.not_configured} code. - * Every collaborator has a default implementation wired from the language (classpath-fixed templates and negotiation - * vocabulary), and each of them can be overridden for testing or customization. - * - * @since 2026-08 - */ -public final class NegotiationGenerationOrchestratorBuilder { - - private String language; - - private LLMClient llmClient; - - private int maxAttempts = LlmConfig.DEFAULT_MAX_ATTEMPTS; - - private int maxTextChars = InputLimitConfig.DEFAULT_MAX_TEXT_CHARS; - - private NegotiationTemplateLoader templateLoader; - - private NegotiationContentExtractor contentExtractor; - - private NegotiationComplianceChecker complianceChecker; - - private NegotiationSemanticValidator semanticValidator; - - private Logger logger; - - private NegotiationGenerationOrchestratorBuilder() {} - - /** - * Creates one new builder instance. - * - * @return empty orchestrator builder - */ - public static NegotiationGenerationOrchestratorBuilder builder() { - return new NegotiationGenerationOrchestratorBuilder(); - } - - /** - * Configures the language of the generated and validated messages. - * - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder language(String language) { - this.language = language; - return this; - } - - /** - * Configures the LLM client used by the LLM steps of the pipelines. - * - * @param llmClient LLM client; null keeps the LLM steps unavailable while the deterministic steps keep working - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder llmClient(LLMClient llmClient) { - this.llmClient = llmClient; - return this; - } - - /** - * Configures how often one LLM step is attempted before its failure is surfaced. - * - * @param maxAttempts maximum number of attempts per LLM step, at least 1 - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder maxAttempts(int maxAttempts) { - this.maxAttempts = maxAttempts; - return this; - } - - /** - * Configures the maximum length in characters accepted for free-text inputs before they reach an LLM step. - * - * @param maxTextChars maximum accepted length in characters, at least 1; oversized inputs fail fast with the code - * {@code input.text_too_long} instead of overflowing the LLM context - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder maxTextChars(int maxTextChars) { - this.maxTextChars = maxTextChars; - return this; - } - - /** - * Overrides the negotiation template loader. - * - * @param templateLoader loader resolving template references to templates - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder templateLoader(NegotiationTemplateLoader templateLoader) { - this.templateLoader = templateLoader; - return this; - } - - /** - * Overrides the content extractor used by the from-text generation. - * - * @param contentExtractor extractor turning free text into typed negotiation content - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder contentExtractor(NegotiationContentExtractor contentExtractor) { - this.contentExtractor = contentExtractor; - return this; - } - - /** - * Overrides the rule-level compliance checker used by the validation pipeline. - * - * @param complianceChecker deterministic rule gate of the validation pipeline - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder complianceChecker(NegotiationComplianceChecker complianceChecker) { - this.complianceChecker = complianceChecker; - return this; - } - - /** - * Overrides the LLM-backed semantic validator used by the validation pipeline. - * - * @param semanticValidator semantic validator producing the verdict and the extracted parameters - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder semanticValidator(NegotiationSemanticValidator semanticValidator) { - this.semanticValidator = semanticValidator; - return this; - } - - /** - * Overrides the logger of the assembled orchestrator. - * - * @param logger logger receiving the pipeline events; null falls back to the default class logger - * @return current builder - */ - public NegotiationGenerationOrchestratorBuilder logger(Logger logger) { - this.logger = logger; - return this; - } - - /** - * Assembles the orchestrator from the configured inputs. - * - * @return assembled negotiation generation orchestrator - * @throws IllegalStateException if the language is missing or the attempt limit is below 1 - * @throws IllegalArgumentException if the language has no bundled vocabulary - */ - public NegotiationGenerationOrchestrator build() { - if (language == null || language.isBlank()) { - throw new IllegalStateException("Negotiation language must be configured."); - } - if (maxAttempts < 1) { - throw new IllegalStateException( - "Negotiation LLM max attempts must be at least 1 but was " + maxAttempts + "."); - } - if (maxTextChars < 1) { - throw new IllegalStateException( - "Negotiation max text chars must be at least 1 but was " + maxTextChars + "."); - } - Vocabulary vocabulary = Vocabulary.forLanguage(language); - NegotiationTemplateLoader effectiveTemplateLoader = - templateLoader != null ? templateLoader : new DefaultNegotiationTemplateLoader(language); - NegotiationContentExtractor effectiveContentExtractor = - contentExtractor != null ? contentExtractor : new DefaultNegotiationContentExtractor(llmClient); - NegotiationComplianceChecker effectiveComplianceChecker = - complianceChecker != null ? complianceChecker : new DefaultNegotiationComplianceChecker(language); - NegotiationSemanticValidator effectiveSemanticValidator = - semanticValidator != null ? semanticValidator : defaultSemanticValidator(); - ParamExtractor paramExtractor = - new ParamExtractor(effectiveComplianceChecker, effectiveSemanticValidator, maxAttempts, reference -> { - PromptTemplate template = effectiveTemplateLoader.load(reference); - String content = template.content(); - if (content == null) { - throw new ResourceNotFoundException( - "Negotiation template body is missing for " - + template.templateUri().uri() + ".", - template.templateUri().uri()); - } - return content; - }); - return new NegotiationGenerationOrchestrator( - language, - maxAttempts, - maxTextChars, - effectiveTemplateLoader, - effectiveContentExtractor, - paramExtractor, - new NegotiationGeneratorRegistry(), - vocabulary, - logger); - } - - /** - * Builds the default semantic validator, wiring the schema builder of the generation package into the - * validation-package schema seam. - * - * @return default LLM-backed semantic validator, or a validator that fails every call when no LLM client is - * configured - */ - private NegotiationSemanticValidator defaultSemanticValidator() { - if (llmClient == null) { - return (prompt, callerSchema, reference, templateContent) -> { - throw new LLMConfigError("Semantic validation requires an LLM client but none is configured."); - }; - } - return new DefaultNegotiationSemanticValidator(llmClient); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.LlmConfig; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMConfigError; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationComplianceChecker; +import net.openan.a2at.sdk.negotiation.validation.DefaultNegotiationSemanticValidator; +import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; +import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; +import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; +import org.slf4j.Logger; + +/** + * Fluent builder assembling one {@link NegotiationGenerationOrchestrator}. + * + *

            The language is required. The LLM client is optional: without one, the from-data generation still works, while the + * LLM steps of the from-text generation and the validation pipeline fail with the {@code llm.not_configured} code. + * Every collaborator has a default implementation wired from the language (classpath-fixed templates and negotiation + * vocabulary), and each of them can be overridden for testing or customization. + * + * @since 2026-08 + */ +public final class NegotiationGenerationOrchestratorBuilder { + + private String language; + + private LLMClient llmClient; + + private int maxAttempts = LlmConfig.DEFAULT_MAX_ATTEMPTS; + + private int maxTextChars = InputLimitConfig.DEFAULT_MAX_TEXT_CHARS; + + private NegotiationTemplateLoader templateLoader; + + private NegotiationContentExtractor contentExtractor; + + private NegotiationComplianceChecker complianceChecker; + + private NegotiationSemanticValidator semanticValidator; + + private Logger logger; + + private NegotiationGenerationOrchestratorBuilder() {} + + /** + * Creates one new builder instance. + * + * @return empty orchestrator builder + */ + public static NegotiationGenerationOrchestratorBuilder builder() { + return new NegotiationGenerationOrchestratorBuilder(); + } + + /** + * Configures the language of the generated and validated messages. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder language(String language) { + this.language = language; + return this; + } + + /** + * Configures the LLM client used by the LLM steps of the pipelines. + * + * @param llmClient LLM client; null keeps the LLM steps unavailable while the deterministic steps keep working + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder llmClient(LLMClient llmClient) { + this.llmClient = llmClient; + return this; + } + + /** + * Configures how often one LLM step is attempted before its failure is surfaced. + * + * @param maxAttempts maximum number of attempts per LLM step, at least 1 + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder maxAttempts(int maxAttempts) { + this.maxAttempts = maxAttempts; + return this; + } + + /** + * Configures the maximum length in characters accepted for free-text inputs before they reach an LLM step. + * + * @param maxTextChars maximum accepted length in characters, at least 1; oversized inputs fail fast with the code + * {@code input.text_too_long} instead of overflowing the LLM context + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder maxTextChars(int maxTextChars) { + this.maxTextChars = maxTextChars; + return this; + } + + /** + * Overrides the negotiation template loader. + * + * @param templateLoader loader resolving template references to templates + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder templateLoader(NegotiationTemplateLoader templateLoader) { + this.templateLoader = templateLoader; + return this; + } + + /** + * Overrides the content extractor used by the from-text generation. + * + * @param contentExtractor extractor turning free text into typed negotiation content + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder contentExtractor(NegotiationContentExtractor contentExtractor) { + this.contentExtractor = contentExtractor; + return this; + } + + /** + * Overrides the rule-level compliance checker used by the validation pipeline. + * + * @param complianceChecker deterministic rule gate of the validation pipeline + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder complianceChecker(NegotiationComplianceChecker complianceChecker) { + this.complianceChecker = complianceChecker; + return this; + } + + /** + * Overrides the LLM-backed semantic validator used by the validation pipeline. + * + * @param semanticValidator semantic validator producing the verdict and the extracted parameters + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder semanticValidator(NegotiationSemanticValidator semanticValidator) { + this.semanticValidator = semanticValidator; + return this; + } + + /** + * Overrides the logger of the assembled orchestrator. + * + * @param logger logger receiving the pipeline events; null falls back to the default class logger + * @return current builder + */ + public NegotiationGenerationOrchestratorBuilder logger(Logger logger) { + this.logger = logger; + return this; + } + + /** + * Assembles the orchestrator from the configured inputs. + * + * @return assembled negotiation generation orchestrator + * @throws IllegalStateException if the language is missing or the attempt limit is below 1 + * @throws IllegalArgumentException if the language has no bundled vocabulary + */ + public NegotiationGenerationOrchestrator build() { + if (language == null || language.isBlank()) { + throw new IllegalStateException("Negotiation language must be configured."); + } + if (maxAttempts < 1) { + throw new IllegalStateException( + "Negotiation LLM max attempts must be at least 1 but was " + maxAttempts + "."); + } + if (maxTextChars < 1) { + throw new IllegalStateException( + "Negotiation max text chars must be at least 1 but was " + maxTextChars + "."); + } + Vocabulary vocabulary = Vocabulary.forLanguage(language); + NegotiationTemplateLoader effectiveTemplateLoader = + templateLoader != null ? templateLoader : new DefaultNegotiationTemplateLoader(language); + NegotiationContentExtractor effectiveContentExtractor = + contentExtractor != null ? contentExtractor : new DefaultNegotiationContentExtractor(llmClient); + NegotiationComplianceChecker effectiveComplianceChecker = + complianceChecker != null ? complianceChecker : new DefaultNegotiationComplianceChecker(language); + NegotiationSemanticValidator effectiveSemanticValidator = + semanticValidator != null ? semanticValidator : defaultSemanticValidator(); + ParamExtractor paramExtractor = + new ParamExtractor(effectiveComplianceChecker, effectiveSemanticValidator, maxAttempts, reference -> { + PromptTemplate template = effectiveTemplateLoader.load(reference); + String content = template.content(); + if (content == null) { + throw new ResourceNotFoundException( + "Negotiation template body is missing for " + + template.templateUri().uri() + ".", + template.templateUri().uri()); + } + return content; + }); + return new NegotiationGenerationOrchestrator( + language, + maxAttempts, + maxTextChars, + effectiveTemplateLoader, + effectiveContentExtractor, + paramExtractor, + new NegotiationGeneratorRegistry(), + vocabulary, + logger); + } + + /** + * Builds the default semantic validator, wiring the schema builder of the generation package into the + * validation-package schema seam. + * + * @return default LLM-backed semantic validator, or a validator that fails every call when no LLM client is + * configured + */ + private NegotiationSemanticValidator defaultSemanticValidator() { + if (llmClient == null) { + return (prompt, callerSchema, reference, templateContent) -> { + throw new LLMConfigError("Semantic validation requires an LLM client but none is configured."); + }; + } + return new DefaultNegotiationSemanticValidator(llmClient); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java index 0141e54c..17d45bca 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java @@ -1,152 +1,152 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.EnumMap; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Dispatch table from negotiation type and phase to the generator serving them. - * - *

            Dispatch requires an exact runtime type match between the content and the addressed (type, phase) pair: propose - * phases only accept propose content, terminal phases only accept ending content whose conclusion matches the phase, - * and the content type must match the negotiation type. The abort phase dispatches to the type-independent abort - * generator and requires abort content. Subtype matching is deliberately not supported; new content types must be - * registered explicitly. - * - * @since 2026-08 - */ -final class NegotiationGeneratorRegistry { - - private static final Logger LOGGER = LoggerFactory.getLogger(NegotiationGeneratorRegistry.class); - - private final Map proposeGenerators = new EnumMap<>(NegotiationType.class); - - private final Map endingGenerators = new EnumMap<>(NegotiationType.class); - - private final NegotiationGenerator abortGenerator = new AbortGenerator(); - - /** Creates a registry holding the built-in negotiation generators. */ - public NegotiationGeneratorRegistry() { - proposeGenerators.put(NegotiationType.INFORMATION, new InformationProposeGenerator()); - proposeGenerators.put(NegotiationType.TARGET, new TargetProposeGenerator()); - proposeGenerators.put(NegotiationType.FEASIBILITY, new FeasibilityProposeGenerator()); - endingGenerators.put(NegotiationType.INFORMATION, new InformationEndingGenerator()); - endingGenerators.put(NegotiationType.TARGET, new TargetEndingGenerator()); - endingGenerators.put(NegotiationType.FEASIBILITY, new FeasibilityEndingGenerator()); - } - - /** - * Resolves the generator for one (type, phase, content) triple. - * - * @param type negotiation type addressed by the template URI; null only for the type-independent abort phase - * @param phase API-level phase addressed by the calling method - * @param content typed content of the message - * @param language message language used to render the failure message of a conclusion mismatch - * @return generator registered for the exact (type, phase) pair - * @throws NullPointerException if the phase or content is null, or the type is null on a typed phase - * @throws IllegalArgumentException if the content family does not match the phase or the content runtime type does - * not match the negotiation type - * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} if an ending content - * carries a conclusion that does not match the phase - */ - public NegotiationGenerator resolve( - @Nullable NegotiationType type, - NegotiationPerformative phase, - NegotiationContent content, - String language) { - Objects.requireNonNull(phase, "Negotiation phase must not be null."); - Objects.requireNonNull(content, "Negotiation content must not be null."); - if (phase == NegotiationPerformative.ABORT) { - if (type != null) { - throw new IllegalArgumentException( - "The ABORT phase is type-independent and must not carry a type but carried " + type + "."); - } - if (content.getClass() != NegotiationAbortContent.class) { - throw new IllegalArgumentException("The ABORT phase requires abort content but received " - + content.getClass().getSimpleName() + "."); - } - LOGGER.atDebug().log( - "negotiation_generator_dispatched generator=AbortGenerator type=common performative=ABORT"); - return abortGenerator; - } - Objects.requireNonNull(type, "Negotiation type must not be null for the " + phase + " phase."); - boolean proposePhase = phase == NegotiationPerformative.PROPOSE; - boolean proposeContent = content instanceof NegotiationProposeContent; - if (proposePhase != proposeContent) { - throw new IllegalArgumentException( - "The " + phase + " phase requires " + (proposePhase ? "propose" : "ending") - + " content but received " - + (proposeContent ? "propose" : "ending") + " content of type " - + content.getClass().getSimpleName() + "."); - } - Class expectedType = expectedContentClass(type, proposePhase); - if (content.getClass() != expectedType) { - throw new IllegalArgumentException( - "Negotiation type " + type + " requires content of type " + expectedType.getSimpleName() - + " but received " + content.getClass().getSimpleName() + "."); - } - if (!proposePhase) { - requireConclusionMatchesPhase((NegotiationEndingContent) content, phase, language); - } - NegotiationGenerator generator = (proposePhase ? proposeGenerators : endingGenerators).get(type); - if (generator == null) { - throw new IllegalArgumentException( - "No negotiation generator is registered for type " + type + " and phase " + phase + "."); - } - LOGGER.atDebug().log( - "negotiation_generator_dispatched generator={} type={} performative={}", - generator.getClass().getSimpleName(), - type, - phase); - return generator; - } - - private static void requireConclusionMatchesPhase( - NegotiationEndingContent content, NegotiationPerformative phase, String language) { - NegotiationConclusion conclusion = Objects.requireNonNull( - content.conclusion(), - "Negotiation conclusion must not be null; the " + phase + " phase requires a conclusion."); - NegotiationConclusion expected = - phase == NegotiationPerformative.ACCEPT ? NegotiationConclusion.ACCEPT : NegotiationConclusion.REJECT; - if (conclusion != expected) { - throw new NegotiationGenerationException( - ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH, - language, - Map.of("expected", expected.literal(), "actual", conclusion.literal())); - } - } - - private static Class expectedContentClass( - NegotiationType type, boolean proposePhase) { - if (proposePhase) { - return switch (type) { - case INFORMATION -> InformationProposeContent.class; - case TARGET -> TargetProposeContent.class; - case FEASIBILITY -> FeasibilityProposeContent.class; - }; - } - return switch (type) { - case INFORMATION -> InformationEndingContent.class; - case TARGET -> TargetEndingContent.class; - case FEASIBILITY -> FeasibilityEndingContent.class; - }; - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Dispatch table from negotiation type and phase to the generator serving them. + * + *

            Dispatch requires an exact runtime type match between the content and the addressed (type, phase) pair: propose + * phases only accept propose content, terminal phases only accept ending content whose conclusion matches the phase, + * and the content type must match the negotiation type. The abort phase dispatches to the type-independent abort + * generator and requires abort content. Subtype matching is deliberately not supported; new content types must be + * registered explicitly. + * + * @since 2026-08 + */ +final class NegotiationGeneratorRegistry { + + private static final Logger LOGGER = LoggerFactory.getLogger(NegotiationGeneratorRegistry.class); + + private final Map proposeGenerators = new EnumMap<>(NegotiationType.class); + + private final Map endingGenerators = new EnumMap<>(NegotiationType.class); + + private final NegotiationGenerator abortGenerator = new AbortGenerator(); + + /** Creates a registry holding the built-in negotiation generators. */ + public NegotiationGeneratorRegistry() { + proposeGenerators.put(NegotiationType.INFORMATION, new InformationProposeGenerator()); + proposeGenerators.put(NegotiationType.TARGET, new TargetProposeGenerator()); + proposeGenerators.put(NegotiationType.FEASIBILITY, new FeasibilityProposeGenerator()); + endingGenerators.put(NegotiationType.INFORMATION, new InformationEndingGenerator()); + endingGenerators.put(NegotiationType.TARGET, new TargetEndingGenerator()); + endingGenerators.put(NegotiationType.FEASIBILITY, new FeasibilityEndingGenerator()); + } + + /** + * Resolves the generator for one (type, phase, content) triple. + * + * @param type negotiation type addressed by the template URI; null only for the type-independent abort phase + * @param phase API-level phase addressed by the calling method + * @param content typed content of the message + * @param language message language used to render the failure message of a conclusion mismatch + * @return generator registered for the exact (type, phase) pair + * @throws NullPointerException if the phase or content is null, or the type is null on a typed phase + * @throws IllegalArgumentException if the content family does not match the phase or the content runtime type does + * not match the negotiation type + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} if an ending content + * carries a conclusion that does not match the phase + */ + public NegotiationGenerator resolve( + @Nullable NegotiationType type, + NegotiationPerformative phase, + NegotiationContent content, + String language) { + Objects.requireNonNull(phase, "Negotiation phase must not be null."); + Objects.requireNonNull(content, "Negotiation content must not be null."); + if (phase == NegotiationPerformative.ABORT) { + if (type != null) { + throw new IllegalArgumentException( + "The ABORT phase is type-independent and must not carry a type but carried " + type + "."); + } + if (content.getClass() != NegotiationAbortContent.class) { + throw new IllegalArgumentException("The ABORT phase requires abort content but received " + + content.getClass().getSimpleName() + "."); + } + LOGGER.atDebug().log( + "negotiation_generator_dispatched generator=AbortGenerator type=common performative=ABORT"); + return abortGenerator; + } + Objects.requireNonNull(type, "Negotiation type must not be null for the " + phase + " phase."); + boolean proposePhase = phase == NegotiationPerformative.PROPOSE; + boolean proposeContent = content instanceof NegotiationProposeContent; + if (proposePhase != proposeContent) { + throw new IllegalArgumentException( + "The " + phase + " phase requires " + (proposePhase ? "propose" : "ending") + + " content but received " + + (proposeContent ? "propose" : "ending") + " content of type " + + content.getClass().getSimpleName() + "."); + } + Class expectedType = expectedContentClass(type, proposePhase); + if (content.getClass() != expectedType) { + throw new IllegalArgumentException( + "Negotiation type " + type + " requires content of type " + expectedType.getSimpleName() + + " but received " + content.getClass().getSimpleName() + "."); + } + if (!proposePhase) { + requireConclusionMatchesPhase((NegotiationEndingContent) content, phase, language); + } + NegotiationGenerator generator = (proposePhase ? proposeGenerators : endingGenerators).get(type); + if (generator == null) { + throw new IllegalArgumentException( + "No negotiation generator is registered for type " + type + " and phase " + phase + "."); + } + LOGGER.atDebug().log( + "negotiation_generator_dispatched generator={} type={} performative={}", + generator.getClass().getSimpleName(), + type, + phase); + return generator; + } + + private static void requireConclusionMatchesPhase( + NegotiationEndingContent content, NegotiationPerformative phase, String language) { + NegotiationConclusion conclusion = Objects.requireNonNull( + content.conclusion(), + "Negotiation conclusion must not be null; the " + phase + " phase requires a conclusion."); + NegotiationConclusion expected = + phase == NegotiationPerformative.ACCEPT ? NegotiationConclusion.ACCEPT : NegotiationConclusion.REJECT; + if (conclusion != expected) { + throw new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH, + language, + Map.of("expected", expected.literal(), "actual", conclusion.literal())); + } + } + + private static Class expectedContentClass( + NegotiationType type, boolean proposePhase) { + if (proposePhase) { + return switch (type) { + case INFORMATION -> InformationProposeContent.class; + case TARGET -> TargetProposeContent.class; + case FEASIBILITY -> FeasibilityProposeContent.class; + }; + } + return switch (type) { + case INFORMATION -> InformationEndingContent.class; + case TARGET -> TargetEndingContent.class; + case FEASIBILITY -> FeasibilityEndingContent.class; + }; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java index 1729ae4c..2417ed41 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java @@ -1,41 +1,41 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.List; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; - -/** - * Formats negotiation item lists as numbered markdown lines. - * - *

            Each item becomes one line {@code N. namevalue}; an item without a value becomes {@code N. name}. The - * caller supplies the list punctuation from the negotiation vocabulary so the output matches the message language. - * - * @since 2026-08 - */ -public final class NegotiationItemFormatter { - - /** - * Formats one item list as numbered lines. - * - * @param items items to format; null or empty produces an empty string - * @param listColon colon punctuation appended between item name and value, such as {@code :} or {@code : } - * @return numbered lines joined by single newlines, or an empty string when there is no item - */ - public String format(List items, String listColon) { - if (items == null || items.isEmpty()) { - return ""; - } - String colon = listColon == null ? "" : listColon; - StringBuilder text = new StringBuilder(); - for (int index = 0; index < items.size(); index++) { - NegotiationItem item = items.get(index); - if (index > 0) { - text.append('\n'); - } - text.append(index + 1).append(". ").append(item.name()); - if (item.value() != null && !item.value().isBlank()) { - text.append(colon).append(item.value()); - } - } - return text.toString(); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; + +/** + * Formats negotiation item lists as numbered markdown lines. + * + *

            Each item becomes one line {@code N. namevalue}; an item without a value becomes {@code N. name}. The + * caller supplies the list punctuation from the negotiation vocabulary so the output matches the message language. + * + * @since 2026-08 + */ +public final class NegotiationItemFormatter { + + /** + * Formats one item list as numbered lines. + * + * @param items items to format; null or empty produces an empty string + * @param listColon colon punctuation appended between item name and value, such as {@code :} or {@code : } + * @return numbered lines joined by single newlines, or an empty string when there is no item + */ + public String format(List items, String listColon) { + if (items == null || items.isEmpty()) { + return ""; + } + String colon = listColon == null ? "" : listColon; + StringBuilder text = new StringBuilder(); + for (int index = 0; index < items.size(); index++) { + NegotiationItem item = items.get(index); + if (index > 0) { + text.append('\n'); + } + text.append(index + 1).append(". ").append(item.name()); + if (item.value() != null && !item.value().isBlank()) { + text.append(colon).append(item.value()); + } + } + return text.toString(); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java index bfc024eb..97777978 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilder.java @@ -1,161 +1,161 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import org.jspecify.annotations.Nullable; - -/** - * Builds the JSON Schemas of the negotiation LLM steps. - * - *

            Extraction schemas are keyed by the snake_case property names of the extracted content, one schema per - * (negotiation type, phase) pair with accept and reject sharing the terminal schema. The semantic validation schema - * merges a caller-provided parameter schema into the fixed four-key output contract of the semantic validation step. - * - * @since 2026-08 - */ -final class NegotiationJsonSchemaBuilder { - - private static final List CONCLUSION_ENUM = List.of("Accept", "Reject"); - - private static final List ACTION_ENUM = - List.of("REQUEST_FEASIBILITY_EVALUATION", "PROPOSE_ALTERNATIVE_ON_FAILURE"); - - /** - * Builds the content extraction schema of one (negotiation type, phase) pair. - * - * @param type negotiation type whose content is extracted; null only for the type-independent abort phase - * @param phase API-level phase of the extraction; accept and reject share the terminal schema - * @return JSON Schema describing the snake_case extraction output of the pair - * @throws NullPointerException if the phase is null, or the type is null on a typed phase - */ - public Map buildExtractionSchema( - @Nullable NegotiationType type, NegotiationPerformative phase) { - Objects.requireNonNull(phase, "Negotiation phase must not be null."); - if (phase == NegotiationPerformative.ABORT) { - if (type != null) { - throw new IllegalArgumentException( - "The ABORT phase is type-independent and must not carry a type but carried " + type + "."); - } - return abortSchema(); - } - Objects.requireNonNull(type, "Negotiation type must not be null for the " + phase + " phase."); - return switch (type) { - case INFORMATION -> phase == NegotiationPerformative.PROPOSE - ? informationProposeSchema() - : informationEndingSchema(); - case TARGET -> phase == NegotiationPerformative.PROPOSE ? targetProposeSchema() : targetEndingSchema(); - case FEASIBILITY -> phase == NegotiationPerformative.PROPOSE - ? feasibilityProposeSchema() - : feasibilityEndingSchema(); - }; - } - - private static Map abortSchema() { - Map properties = new LinkedHashMap<>(); - properties.put("termination_reason", Map.of("type", "string")); - return objectSchema(properties, List.of("termination_reason")); - } - - private static Map informationProposeSchema() { - Map properties = new LinkedHashMap<>(); - properties.put("items", nonEmptyItemArraySchema()); - properties.put("relationship", nullableStringSchema()); - return objectSchema(properties, List.of("items")); - } - - private static Map informationEndingSchema() { - Map properties = new LinkedHashMap<>(); - properties.put("conclusion", conclusionSchema()); - properties.put("items", nonEmptyItemArraySchema()); - return objectSchema(properties, List.of("conclusion", "items")); - } - - private static Map targetProposeSchema() { - Map properties = new LinkedHashMap<>(); - properties.put("target_negotiation_description", Map.of("type", "string")); - properties.put("intent_understanding", nullableItemArraySchema()); - properties.put("alignment_and_clarification", nullableItemArraySchema()); - properties.put("request_for_clarification", nullableItemArraySchema()); - properties.put("target_confirm_request", nullableStringSchema()); - return objectSchema(properties, List.of("target_negotiation_description")); - } - - private static Map targetEndingSchema() { - Map properties = new LinkedHashMap<>(); - properties.put("conclusion", conclusionSchema()); - properties.put("confirmed_intent", nullableStringSchema()); - properties.put("failure_reason", nullableStringSchema()); - return objectSchema(properties, List.of("conclusion")); - } - - private static Map feasibilityProposeSchema() { - Map properties = new LinkedHashMap<>(); - properties.put("feasibility_negotiation_description", Map.of("type", "string")); - Map action = new LinkedHashMap<>(); - action.put("type", "string"); - action.put("enum", ACTION_ENUM); - properties.put("action", action); - properties.put("contents_to_evaluate", nullableItemArraySchema()); - properties.put("infeasibility_details_and_proposal", nullableItemArraySchema()); - properties.put("feasibility_confirm_request", nullableStringSchema()); - return objectSchema(properties, List.of("feasibility_negotiation_description", "action")); - } - - private static Map feasibilityEndingSchema() { - Map properties = new LinkedHashMap<>(); - properties.put("conclusion", conclusionSchema()); - properties.put("feasibility_summary", Map.of("type", "string")); - return objectSchema(properties, List.of("conclusion", "feasibility_summary")); - } - - private static Map itemArraySchema() { - Map schema = new LinkedHashMap<>(); - schema.put("type", "array"); - schema.put("items", itemSchema()); - return schema; - } - - private static Map nonEmptyItemArraySchema() { - Map schema = new LinkedHashMap<>(itemArraySchema()); - schema.put("minItems", 1); - return schema; - } - - private static Map nullableItemArraySchema() { - Map schema = new LinkedHashMap<>(); - schema.put("type", List.of("array", "null")); - schema.put("items", itemSchema()); - return schema; - } - - private static Map itemSchema() { - Map itemProperties = new LinkedHashMap<>(); - itemProperties.put("name", Map.of("type", "string")); - itemProperties.put("value", nullableStringSchema()); - return objectSchema(itemProperties, List.of("name", "value")); - } - - private static Map conclusionSchema() { - Map schema = new LinkedHashMap<>(); - schema.put("type", "string"); - schema.put("enum", CONCLUSION_ENUM); - return schema; - } - - private static Map nullableStringSchema() { - return Map.of("type", List.of("string", "null")); - } - - private static Map objectSchema(Map properties, List required) { - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - schema.put("required", required); - return schema; - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import org.jspecify.annotations.Nullable; + +/** + * Builds the JSON Schemas of the negotiation LLM steps. + * + *

            Extraction schemas are keyed by the snake_case property names of the extracted content, one schema per + * (negotiation type, phase) pair with accept and reject sharing the terminal schema. The semantic validation schema + * merges a caller-provided parameter schema into the fixed four-key output contract of the semantic validation step. + * + * @since 2026-08 + */ +final class NegotiationJsonSchemaBuilder { + + private static final List CONCLUSION_ENUM = List.of("Accept", "Reject"); + + private static final List ACTION_ENUM = + List.of("REQUEST_FEASIBILITY_EVALUATION", "PROPOSE_ALTERNATIVE_ON_FAILURE"); + + /** + * Builds the content extraction schema of one (negotiation type, phase) pair. + * + * @param type negotiation type whose content is extracted; null only for the type-independent abort phase + * @param phase API-level phase of the extraction; accept and reject share the terminal schema + * @return JSON Schema describing the snake_case extraction output of the pair + * @throws NullPointerException if the phase is null, or the type is null on a typed phase + */ + public Map buildExtractionSchema( + @Nullable NegotiationType type, NegotiationPerformative phase) { + Objects.requireNonNull(phase, "Negotiation phase must not be null."); + if (phase == NegotiationPerformative.ABORT) { + if (type != null) { + throw new IllegalArgumentException( + "The ABORT phase is type-independent and must not carry a type but carried " + type + "."); + } + return abortSchema(); + } + Objects.requireNonNull(type, "Negotiation type must not be null for the " + phase + " phase."); + return switch (type) { + case INFORMATION -> phase == NegotiationPerformative.PROPOSE + ? informationProposeSchema() + : informationEndingSchema(); + case TARGET -> phase == NegotiationPerformative.PROPOSE ? targetProposeSchema() : targetEndingSchema(); + case FEASIBILITY -> phase == NegotiationPerformative.PROPOSE + ? feasibilityProposeSchema() + : feasibilityEndingSchema(); + }; + } + + private static Map abortSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("termination_reason", Map.of("type", "string")); + return objectSchema(properties, List.of("termination_reason")); + } + + private static Map informationProposeSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("items", nonEmptyItemArraySchema()); + properties.put("relationship", nullableStringSchema()); + return objectSchema(properties, List.of("items")); + } + + private static Map informationEndingSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("conclusion", conclusionSchema()); + properties.put("items", nonEmptyItemArraySchema()); + return objectSchema(properties, List.of("conclusion", "items")); + } + + private static Map targetProposeSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("target_negotiation_description", Map.of("type", "string")); + properties.put("intent_understanding", nullableItemArraySchema()); + properties.put("alignment_and_clarification", nullableItemArraySchema()); + properties.put("request_for_clarification", nullableItemArraySchema()); + properties.put("target_confirm_request", nullableStringSchema()); + return objectSchema(properties, List.of("target_negotiation_description")); + } + + private static Map targetEndingSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("conclusion", conclusionSchema()); + properties.put("confirmed_intent", nullableStringSchema()); + properties.put("failure_reason", nullableStringSchema()); + return objectSchema(properties, List.of("conclusion")); + } + + private static Map feasibilityProposeSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("feasibility_negotiation_description", Map.of("type", "string")); + Map action = new LinkedHashMap<>(); + action.put("type", "string"); + action.put("enum", ACTION_ENUM); + properties.put("action", action); + properties.put("contents_to_evaluate", nullableItemArraySchema()); + properties.put("infeasibility_details_and_proposal", nullableItemArraySchema()); + properties.put("feasibility_confirm_request", nullableStringSchema()); + return objectSchema(properties, List.of("feasibility_negotiation_description", "action")); + } + + private static Map feasibilityEndingSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("conclusion", conclusionSchema()); + properties.put("feasibility_summary", Map.of("type", "string")); + return objectSchema(properties, List.of("conclusion", "feasibility_summary")); + } + + private static Map itemArraySchema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", "array"); + schema.put("items", itemSchema()); + return schema; + } + + private static Map nonEmptyItemArraySchema() { + Map schema = new LinkedHashMap<>(itemArraySchema()); + schema.put("minItems", 1); + return schema; + } + + private static Map nullableItemArraySchema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", List.of("array", "null")); + schema.put("items", itemSchema()); + return schema; + } + + private static Map itemSchema() { + Map itemProperties = new LinkedHashMap<>(); + itemProperties.put("name", Map.of("type", "string")); + itemProperties.put("value", nullableStringSchema()); + return objectSchema(itemProperties, List.of("name", "value")); + } + + private static Map conclusionSchema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", "string"); + schema.put("enum", CONCLUSION_ENUM); + return schema; + } + + private static Map nullableStringSchema() { + return Map.of("type", List.of("string", "null")); + } + + private static Map objectSchema(Map properties, List required) { + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + schema.put("required", required); + return schema; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java index 3eaeb74f..aac9f813 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java @@ -1,78 +1,78 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.List; -import java.util.Map; -import java.util.Objects; - -/** - * Assembles the LLM message list of one negotiation LLM step. - * - *

            The system prompt of the addressed category is used verbatim. The user prompt is loaded from the same category and - * every literal bracket token it contains, such as {@code [phase]} or {@code [input]}, is replaced by the value - * supplied for that token name; a supplied null value is replaced with an empty string and bracket tokens without a - * supplied value are left unchanged. - * - * @since 2026-08 - */ -final class NegotiationMessageBuilder { - - /** Token name receiving the negotiation phase of the step, such as {@code propose} or {@code accept}. */ - public static final String TOKEN_PHASE = "phase"; - - /** Token name receiving the free-text input of the step. */ - public static final String TOKEN_INPUT = "input"; - - /** Token name receiving the template URI declared by the caller. */ - public static final String TOKEN_TEMPLATE_URI = "template_uri"; - - /** Token name receiving the negotiation type declared by the caller. */ - public static final String TOKEN_NEGOTIATION_TYPE = "negotiation_type"; - - /** Token name receiving the JSON Schema of the step. */ - public static final String TOKEN_SCHEMA = "schema"; - - private final NegotiationPromptResourceLoader resourceLoader; - - /** Creates a message builder using the default classpath prompt resource loader. */ - public NegotiationMessageBuilder() { - this(new NegotiationPromptResourceLoader()); - } - - /** - * Creates a message builder on one prompt resource loader. - * - * @param resourceLoader loader supplying the system and user prompts - * @throws NullPointerException if the resource loader is null - */ - public NegotiationMessageBuilder(NegotiationPromptResourceLoader resourceLoader) { - Objects.requireNonNull(resourceLoader, "Negotiation message builder requires a prompt resource loader."); - this.resourceLoader = resourceLoader; - } - - /** - * Builds the system and user messages of one LLM step. - * - * @param promptCategory prompt category directory such as {@code information_negotiation} - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @param tokens token values keyed by token name such as {@code phase} or {@code input}; null or blank values are - * replaced with an empty string - * @return ordered message list with one system message followed by one user message - */ - public List> buildMessages(String promptCategory, String language, Map tokens) { - String systemPrompt = resourceLoader.loadSystem(promptCategory, language); - String userPrompt = replaceTokens(resourceLoader.loadUser(promptCategory, language), tokens); - return List.of( - Map.of("role", "system", "content", systemPrompt), Map.of("role", "user", "content", userPrompt)); - } - - private static String replaceTokens(String userPrompt, Map tokens) { - String result = userPrompt; - if (tokens != null) { - for (Map.Entry token : tokens.entrySet()) { - String value = token.getValue() == null ? "" : token.getValue(); - result = result.replace("[" + token.getKey() + "]", value); - } - } - return result; - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Assembles the LLM message list of one negotiation LLM step. + * + *

            The system prompt of the addressed category is used verbatim. The user prompt is loaded from the same category and + * every literal bracket token it contains, such as {@code [phase]} or {@code [input]}, is replaced by the value + * supplied for that token name; a supplied null value is replaced with an empty string and bracket tokens without a + * supplied value are left unchanged. + * + * @since 2026-08 + */ +final class NegotiationMessageBuilder { + + /** Token name receiving the negotiation phase of the step, such as {@code propose} or {@code accept}. */ + public static final String TOKEN_PHASE = "phase"; + + /** Token name receiving the free-text input of the step. */ + public static final String TOKEN_INPUT = "input"; + + /** Token name receiving the template URI declared by the caller. */ + public static final String TOKEN_TEMPLATE_URI = "template_uri"; + + /** Token name receiving the negotiation type declared by the caller. */ + public static final String TOKEN_NEGOTIATION_TYPE = "negotiation_type"; + + /** Token name receiving the JSON Schema of the step. */ + public static final String TOKEN_SCHEMA = "schema"; + + private final NegotiationPromptResourceLoader resourceLoader; + + /** Creates a message builder using the default classpath prompt resource loader. */ + public NegotiationMessageBuilder() { + this(new NegotiationPromptResourceLoader()); + } + + /** + * Creates a message builder on one prompt resource loader. + * + * @param resourceLoader loader supplying the system and user prompts + * @throws NullPointerException if the resource loader is null + */ + public NegotiationMessageBuilder(NegotiationPromptResourceLoader resourceLoader) { + Objects.requireNonNull(resourceLoader, "Negotiation message builder requires a prompt resource loader."); + this.resourceLoader = resourceLoader; + } + + /** + * Builds the system and user messages of one LLM step. + * + * @param promptCategory prompt category directory such as {@code information_negotiation} + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @param tokens token values keyed by token name such as {@code phase} or {@code input}; null or blank values are + * replaced with an empty string + * @return ordered message list with one system message followed by one user message + */ + public List> buildMessages(String promptCategory, String language, Map tokens) { + String systemPrompt = resourceLoader.loadSystem(promptCategory, language); + String userPrompt = replaceTokens(resourceLoader.loadUser(promptCategory, language), tokens); + return List.of( + Map.of("role", "system", "content", systemPrompt), Map.of("role", "user", "content", userPrompt)); + } + + private static String replaceTokens(String userPrompt, Map tokens) { + String result = userPrompt; + if (tokens != null) { + for (Map.Entry token : tokens.entrySet()) { + String value = token.getValue() == null ? "" : token.getValue(); + result = result.replace("[" + token.getKey() + "]", value); + } + } + return result; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java index 9ce36574..90a616c6 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java @@ -1,77 +1,77 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; -import net.openan.a2at.sdk.core.resources.PathSegments; - -/** - * Loads negotiation LLM prompt resources from the classpath bundle packaged with the SDK. - * - *

            Prompt resources are classpath-only: the categories are {@code information_negotiation}, - * {@code target_negotiation}, {@code feasibility_negotiation} and {@code negotiation_semantic_validation}, each with a - * {@code system.md} and a {@code user.md} per language. - * - * @since 2026-08 - */ -class NegotiationPromptResourceLoader { - - private static final String CLASSPATH_ROOT = "prompt_resources/prompts/"; - - private static final String SYSTEM_FILE_NAME = "system.md"; - - private static final String USER_FILE_NAME = "user.md"; - - /** - * Loads the system prompt of one prompt category. - * - * @param promptCategory prompt category directory such as {@code information_negotiation} - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @return full system prompt text - * @throws ResourceNotFoundException if the system prompt does not exist for the category and language - */ - public String loadSystem(String promptCategory, String language) { - return loadPrompt(SYSTEM_FILE_NAME, promptCategory, language); - } - - /** - * Loads the user prompt of one prompt category. - * - * @param promptCategory prompt category directory such as {@code information_negotiation} - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @return full user prompt text - * @throws ResourceNotFoundException if the user prompt does not exist for the category and language - */ - public String loadUser(String promptCategory, String language) { - return loadPrompt(USER_FILE_NAME, promptCategory, language); - } - - private static String loadPrompt(String fileName, String promptCategory, String language) { - if (!PathSegments.isSimpleSegment(promptCategory)) { - throw new IllegalArgumentException( - "Prompt category must be a non-blank simple path segment but was " + promptCategory + "."); - } - if (!PathSegments.isSimpleSegment(language)) { - throw new IllegalArgumentException( - "Prompt language must be a non-blank simple path segment but was " + language + "."); - } - String path = CLASSPATH_ROOT + promptCategory + "/" + language + "/" + fileName; - return readResource(path); - } - - private static String readResource(String path) { - InputStream stream = ClasspathResourceStreams.open(path); - if (stream == null) { - throw new ResourceNotFoundException( - "Negotiation prompt resource does not exist on the classpath: " + path, path); - } - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException exception) { - throw new A2ATError("Failed to read negotiation prompt resource: " + path, exception); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.core.resources.PathSegments; + +/** + * Loads negotiation LLM prompt resources from the classpath bundle packaged with the SDK. + * + *

            Prompt resources are classpath-only: the categories are {@code information_negotiation}, + * {@code target_negotiation}, {@code feasibility_negotiation} and {@code negotiation_semantic_validation}, each with a + * {@code system.md} and a {@code user.md} per language. + * + * @since 2026-08 + */ +class NegotiationPromptResourceLoader { + + private static final String CLASSPATH_ROOT = "prompt_resources/prompts/"; + + private static final String SYSTEM_FILE_NAME = "system.md"; + + private static final String USER_FILE_NAME = "user.md"; + + /** + * Loads the system prompt of one prompt category. + * + * @param promptCategory prompt category directory such as {@code information_negotiation} + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @return full system prompt text + * @throws ResourceNotFoundException if the system prompt does not exist for the category and language + */ + public String loadSystem(String promptCategory, String language) { + return loadPrompt(SYSTEM_FILE_NAME, promptCategory, language); + } + + /** + * Loads the user prompt of one prompt category. + * + * @param promptCategory prompt category directory such as {@code information_negotiation} + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @return full user prompt text + * @throws ResourceNotFoundException if the user prompt does not exist for the category and language + */ + public String loadUser(String promptCategory, String language) { + return loadPrompt(USER_FILE_NAME, promptCategory, language); + } + + private static String loadPrompt(String fileName, String promptCategory, String language) { + if (!PathSegments.isSimpleSegment(promptCategory)) { + throw new IllegalArgumentException( + "Prompt category must be a non-blank simple path segment but was " + promptCategory + "."); + } + if (!PathSegments.isSimpleSegment(language)) { + throw new IllegalArgumentException( + "Prompt language must be a non-blank simple path segment but was " + language + "."); + } + String path = CLASSPATH_ROOT + promptCategory + "/" + language + "/" + fileName; + return readResource(path); + } + + private static String readResource(String path) { + InputStream stream = ClasspathResourceStreams.open(path); + if (stream == null) { + throw new ResourceNotFoundException( + "Negotiation prompt resource does not exist on the classpath: " + path, path); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new A2ATError("Failed to read negotiation prompt resource: " + path, exception); + } + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java index 2a7a43b0..9434223f 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationRenderException.java @@ -1,31 +1,31 @@ -package net.openan.a2at.sdk.negotiation.generation; - -/** - * Internal failure raised when rendering a negotiation template cannot proceed. - * - *

            This exception never bubbles out of the public API; the orchestration layer maps it to a typed negotiation failure - * instead. - * - * @since 2026-08 - */ -class NegotiationRenderException extends RuntimeException { - - /** - * Creates a render failure. - * - * @param message failure message - */ - public NegotiationRenderException(String message) { - super(message); - } - - /** - * Creates a render failure with a root cause. - * - * @param message failure message - * @param cause root cause - */ - public NegotiationRenderException(String message, Throwable cause) { - super(message, cause); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +/** + * Internal failure raised when rendering a negotiation template cannot proceed. + * + *

            This exception never bubbles out of the public API; the orchestration layer maps it to a typed negotiation failure + * instead. + * + * @since 2026-08 + */ +class NegotiationRenderException extends RuntimeException { + + /** + * Creates a render failure. + * + * @param message failure message + */ + public NegotiationRenderException(String message) { + super(message); + } + + /** + * Creates a render failure with a root cause. + * + * @param message failure message + * @param cause root cause + */ + public NegotiationRenderException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java index 586219f7..61cd85bd 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetEndingGenerator.java @@ -1,59 +1,59 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; - -/** - * Generator for target negotiation terminal messages. - * - *

            The result content slot carries the confirmed intent for an accept conclusion or the failure reason for a reject - * conclusion; the other field is ignored. - * - * @since 2026-08 - */ -final class TargetEndingGenerator extends AbstractNegotiationGenerator { - - /** - * Generates a target negotiation accept or reject message. - * - * @param context negotiation context of the message - * @param content target ending content - * @param template target ending template to render - * @param vocabulary vocabulary of the message language - * @return rendered target terminal message text - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - TargetEndingContent endingContent = contentOf(content, TargetEndingContent.class, "Target ending generator"); - NegotiationConclusion conclusion = renderableConclusion(endingContent.conclusion()); - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.target_conclusion"), conclusion.literal()); - slots.put( - vocabulary.get("slot.target_result_content"), - resultContentSlotValue(endingContent, conclusion, vocabulary)); - return render(template, slots); - } - - private static String resultContentSlotValue( - TargetEndingContent content, NegotiationConclusion conclusion, Vocabulary vocabulary) { - if (conclusion == NegotiationConclusion.ACCEPT) { - return requiredText( - content.confirmedIntent(), - "content.confirmedIntent", - "Confirmed intent of an accepting target negotiation message", - vocabulary); - } - return requiredText( - content.failureReason(), - "content.failureReason", - "Failure reason of a rejecting target negotiation" + " message", - vocabulary); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Generator for target negotiation terminal messages. + * + *

            The result content slot carries the confirmed intent for an accept conclusion or the failure reason for a reject + * conclusion; the other field is ignored. + * + * @since 2026-08 + */ +final class TargetEndingGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a target negotiation accept or reject message. + * + * @param context negotiation context of the message + * @param content target ending content + * @param template target ending template to render + * @param vocabulary vocabulary of the message language + * @return rendered target terminal message text + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + TargetEndingContent endingContent = contentOf(content, TargetEndingContent.class, "Target ending generator"); + NegotiationConclusion conclusion = renderableConclusion(endingContent.conclusion()); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.target_conclusion"), conclusion.literal()); + slots.put( + vocabulary.get("slot.target_result_content"), + resultContentSlotValue(endingContent, conclusion, vocabulary)); + return render(template, slots); + } + + private static String resultContentSlotValue( + TargetEndingContent content, NegotiationConclusion conclusion, Vocabulary vocabulary) { + if (conclusion == NegotiationConclusion.ACCEPT) { + return requiredText( + content.confirmedIntent(), + "content.confirmedIntent", + "Confirmed intent of an accepting target negotiation message", + vocabulary); + } + return requiredText( + content.failureReason(), + "content.failureReason", + "Failure reason of a rejecting target negotiation" + " message", + vocabulary); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java index 893e4e23..9480f126 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/TargetProposeGenerator.java @@ -1,95 +1,95 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import org.jspecify.annotations.Nullable; - -/** - * Generator for target negotiation propose messages. - * - *

            Besides the required target negotiation summary, the message renders round-driven conditional sections: the intent - * understanding section appears only on the first round, the alignment and clarification section only on later rounds, - * and the clarification request section only when clarification items are present. A non-blank confirm request marks - * the "target clarified and requesting confirmation" category instead; the three conditional lists must then be empty, - * so only the summary and the confirm request section remain. - * - * @since 2026-08 - */ -final class TargetProposeGenerator extends AbstractNegotiationGenerator { - - /** - * Generates a target negotiation propose message. - * - * @param context negotiation context of the message - * @param content target propose content - * @param template target propose template to render - * @param vocabulary vocabulary of the message language - * @return rendered target propose message text - * @throws IllegalArgumentException if the content is of another runtime type - * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the summary is blank, - * or {@code negotiation.invalid_input} if the confirm request is combined with any of the three conditional - * sections - */ - @Override - public String generate( - NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { - TargetProposeContent proposeContent = - contentOf(content, TargetProposeContent.class, "Target propose generator"); - requiredText( - proposeContent.targetNegotiationDescription(), - "content.targetNegotiationDescription", - "Target negotiation description", - vocabulary); - String confirmRequest = presentText(proposeContent.targetConfirmRequest()); - if (confirmRequest != null - && (hasItems(proposeContent.intentUnderstanding()) - || hasItems(proposeContent.alignmentAndClarification()) - || hasItems(proposeContent.requestForClarification()))) { - throw invalidInput( - "Target confirm request must not be combined with the intent understanding, alignment and" - + " clarification or clarification request sections; a confirm-request round carries only" - + " the summary and the confirm request.", - vocabulary); - } - Map slots = new LinkedHashMap<>(); - slots.put(vocabulary.get("slot.target"), proposeContent.targetNegotiationDescription()); - if (context.round() == 1) { - slots.put( - vocabulary.get("slot.target_intent"), - formatItems(proposeContent.intentUnderstanding(), vocabulary)); - } else { - slots.put( - vocabulary.get("slot.target_alignment"), - formatItems(proposeContent.alignmentAndClarification(), vocabulary)); - } - slots.put( - vocabulary.get("slot.target_clarification"), - formatItems(proposeContent.requestForClarification(), vocabulary)); - if (confirmRequest != null) { - slots.put(vocabulary.get("slot.target_confirm_request"), confirmRequest); - } - return render(template, slots); - } - - private static @Nullable String presentText(@Nullable String value) { - return value == null || value.isBlank() ? null : value; - } - - private static NegotiationGenerationException invalidInput(String reason, Vocabulary vocabulary) { - return new NegotiationGenerationException( - ErrorCatalog.NEGOTIATION_INVALID_INPUT, vocabulary.language(), Map.of("reason", reason)); - } - - private static boolean hasItems(@Nullable List items) { - return items != null && !items.isEmpty(); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import org.jspecify.annotations.Nullable; + +/** + * Generator for target negotiation propose messages. + * + *

            Besides the required target negotiation summary, the message renders round-driven conditional sections: the intent + * understanding section appears only on the first round, the alignment and clarification section only on later rounds, + * and the clarification request section only when clarification items are present. A non-blank confirm request marks + * the "target clarified and requesting confirmation" category instead; the three conditional lists must then be empty, + * so only the summary and the confirm request section remain. + * + * @since 2026-08 + */ +final class TargetProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a target negotiation propose message. + * + * @param context negotiation context of the message + * @param content target propose content + * @param template target propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered target propose message text + * @throws IllegalArgumentException if the content is of another runtime type + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the summary is blank, + * or {@code negotiation.invalid_input} if the confirm request is combined with any of the three conditional + * sections + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + TargetProposeContent proposeContent = + contentOf(content, TargetProposeContent.class, "Target propose generator"); + requiredText( + proposeContent.targetNegotiationDescription(), + "content.targetNegotiationDescription", + "Target negotiation description", + vocabulary); + String confirmRequest = presentText(proposeContent.targetConfirmRequest()); + if (confirmRequest != null + && (hasItems(proposeContent.intentUnderstanding()) + || hasItems(proposeContent.alignmentAndClarification()) + || hasItems(proposeContent.requestForClarification()))) { + throw invalidInput( + "Target confirm request must not be combined with the intent understanding, alignment and" + + " clarification or clarification request sections; a confirm-request round carries only" + + " the summary and the confirm request.", + vocabulary); + } + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.target"), proposeContent.targetNegotiationDescription()); + if (context.round() == 1) { + slots.put( + vocabulary.get("slot.target_intent"), + formatItems(proposeContent.intentUnderstanding(), vocabulary)); + } else { + slots.put( + vocabulary.get("slot.target_alignment"), + formatItems(proposeContent.alignmentAndClarification(), vocabulary)); + } + slots.put( + vocabulary.get("slot.target_clarification"), + formatItems(proposeContent.requestForClarification(), vocabulary)); + if (confirmRequest != null) { + slots.put(vocabulary.get("slot.target_confirm_request"), confirmRequest); + } + return render(template, slots); + } + + private static @Nullable String presentText(@Nullable String value) { + return value == null || value.isBlank() ? null : value; + } + + private static NegotiationGenerationException invalidInput(String reason, Vocabulary vocabulary) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT, vocabulary.language(), Map.of("reason", reason)); + } + + private static boolean hasItems(@Nullable List items) { + return items != null && !items.isEmpty(); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/package-info.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/package-info.java index 0582d74a..ce7c1129 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/package-info.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/package-info.java @@ -1,2 +1,2 @@ -/** Deterministic negotiation message generation: rendering, generators, prompt assembly and content extraction. */ -package net.openan.a2at.sdk.negotiation.generation; +/** Deterministic negotiation message generation: rendering, generators, prompt assembly and content extraction. */ +package net.openan.a2at.sdk.negotiation.generation; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java index bdad754d..db4abdac 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -1,106 +1,106 @@ -package net.openan.a2at.sdk.negotiation.resources; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.ConcurrentHashMap; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; -import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateDescriptions; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Default negotiation template loader, classpath-fixed. - * - *

            Templates are resolved from the built-in classpath resources bundled with the SDK — negotiation templates are - * classpath-fixed and never configurable. Local template copies are not consulted. - * - *

            Classpath resources are frozen at assembly time: each template is resolved once and cached at the JVM level, so - * every subsequent load returns the cached snapshot. Missing templates throw on every call and are never cached. The - * loader is thread-safe. - * - * @since 2026-08 - */ -public final class DefaultNegotiationTemplateLoader implements NegotiationTemplateLoader { - - private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationTemplateLoader.class); - - private static final String CLASSPATH_ROOT = "prompt_resources/"; - - private static final String TEMPLATE_FILE_NAME = "template.md"; - - private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); - - /** - * Creates a classpath-fixed loader for one language. - * - *

            The language is validated here for assembly-time fail-fast only; {@link #load} resolves the language from the - * reference, not from this constructor argument. - * - * @param language locale identifier such as {@code zh-CN} or {@code en-US} - * @throws IllegalArgumentException if the language is not a simple path segment - */ - public DefaultNegotiationTemplateLoader(String language) { - if (!PathSegments.isSimpleSegment(language)) { - throw new IllegalArgumentException( - "Negotiation template loader language must be a non-blank simple path segment but was " + language - + "."); - } - } - - /** - * Loads one negotiation template from the built-in classpath resources. - * - * @param reference template addressing key, including the language to load - * @return loaded template with its URI, description and full content - * @throws ResourceNotFoundException if the template does not exist on the classpath - */ - @Override - public PromptTemplate load(NegotiationReference reference) { - String classpathPath = CLASSPATH_ROOT + templateRelativePath(reference); - CacheKey cacheKey = new CacheKey(classpathPath, Thread.currentThread().getContextClassLoader()); - String content = CACHE.computeIfAbsent(cacheKey, ignored -> readTemplate(classpathPath)); - PromptTemplate template = new PromptTemplate( - reference.templateUri(), - TemplateDescriptions.extract(content), - content, - PromptTemplate.SOURCE_CLASSPATH); - LOGGER.atDebug().log("negotiation_template_loaded uri={} language={}", reference.uri(), reference.language()); - return template; - } - - private static String templateRelativePath(NegotiationReference reference) { - return String.join( - "/", - "templates", - "Negotiation-T", - reference.typeSegment(), - NegotiationReference.uriSegmentOf(reference.performative()), - TemplateUri.DEFAULT_TEMPLATE_VERSION, - reference.language(), - TEMPLATE_FILE_NAME); - } - - private static String readTemplate(String classpathPath) { - InputStream stream = ClasspathResourceStreams.open(classpathPath); - if (stream == null) { - throw new ResourceNotFoundException( - "Negotiation template does not exist for the configured language; set A2AT_LANGUAGE to a" - + " language with bundled templates (zh-CN or en-US).", - classpathPath); - } - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException exception) { - throw new A2ATError("Failed to read negotiation template: " + classpathPath, exception); - } - } - - private record CacheKey(String classpathPath, @Nullable ClassLoader contextClassLoader) {} -} +package net.openan.a2at.sdk.negotiation.resources; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.core.resources.PathSegments; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateDescriptions; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default negotiation template loader, classpath-fixed. + * + *

            Templates are resolved from the built-in classpath resources bundled with the SDK — negotiation templates are + * classpath-fixed and never configurable. Local template copies are not consulted. + * + *

            Classpath resources are frozen at assembly time: each template is resolved once and cached at the JVM level, so + * every subsequent load returns the cached snapshot. Missing templates throw on every call and are never cached. The + * loader is thread-safe. + * + * @since 2026-08 + */ +public final class DefaultNegotiationTemplateLoader implements NegotiationTemplateLoader { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationTemplateLoader.class); + + private static final String CLASSPATH_ROOT = "prompt_resources/"; + + private static final String TEMPLATE_FILE_NAME = "template.md"; + + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + /** + * Creates a classpath-fixed loader for one language. + * + *

            The language is validated here for assembly-time fail-fast only; {@link #load} resolves the language from the + * reference, not from this constructor argument. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @throws IllegalArgumentException if the language is not a simple path segment + */ + public DefaultNegotiationTemplateLoader(String language) { + if (!PathSegments.isSimpleSegment(language)) { + throw new IllegalArgumentException( + "Negotiation template loader language must be a non-blank simple path segment but was " + language + + "."); + } + } + + /** + * Loads one negotiation template from the built-in classpath resources. + * + * @param reference template addressing key, including the language to load + * @return loaded template with its URI, description and full content + * @throws ResourceNotFoundException if the template does not exist on the classpath + */ + @Override + public PromptTemplate load(NegotiationReference reference) { + String classpathPath = CLASSPATH_ROOT + templateRelativePath(reference); + CacheKey cacheKey = new CacheKey(classpathPath, Thread.currentThread().getContextClassLoader()); + String content = CACHE.computeIfAbsent(cacheKey, ignored -> readTemplate(classpathPath)); + PromptTemplate template = new PromptTemplate( + reference.templateUri(), + TemplateDescriptions.extract(content), + content, + PromptTemplate.SOURCE_CLASSPATH); + LOGGER.atDebug().log("negotiation_template_loaded uri={} language={}", reference.uri(), reference.language()); + return template; + } + + private static String templateRelativePath(NegotiationReference reference) { + return String.join( + "/", + "templates", + "Negotiation-T", + reference.typeSegment(), + NegotiationReference.uriSegmentOf(reference.performative()), + TemplateUri.DEFAULT_TEMPLATE_VERSION, + reference.language(), + TEMPLATE_FILE_NAME); + } + + private static String readTemplate(String classpathPath) { + InputStream stream = ClasspathResourceStreams.open(classpathPath); + if (stream == null) { + throw new ResourceNotFoundException( + "Negotiation template does not exist for the configured language; set A2AT_LANGUAGE to a" + + " language with bundled templates (zh-CN or en-US).", + classpathPath); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new A2ATError("Failed to read negotiation template: " + classpathPath, exception); + } + } + + private record CacheKey(String classpathPath, @Nullable ClassLoader contextClassLoader) {} +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java index c2cd9373..3e17c667 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java @@ -1,21 +1,21 @@ -package net.openan.a2at.sdk.negotiation.resources; - -import net.openan.a2at.sdk.core.model.PromptTemplate; - -/** - * Loads negotiation templates addressed by {@link NegotiationReference} keys. - * - * @since 2026-08 - */ -public interface NegotiationTemplateLoader { - - /** - * Loads one negotiation template. - * - * @param reference template addressing key, including the language to load - * @return loaded template with its URI, description and full content - * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the template does not exist in the - * built-in classpath resources - */ - PromptTemplate load(NegotiationReference reference); -} +package net.openan.a2at.sdk.negotiation.resources; + +import net.openan.a2at.sdk.core.model.PromptTemplate; + +/** + * Loads negotiation templates addressed by {@link NegotiationReference} keys. + * + * @since 2026-08 + */ +public interface NegotiationTemplateLoader { + + /** + * Loads one negotiation template. + * + * @param reference template addressing key, including the language to load + * @return loaded template with its URI, description and full content + * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the template does not exist in the + * built-in classpath resources + */ + PromptTemplate load(NegotiationReference reference); +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/package-info.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/package-info.java index 0e4103f8..b28a6007 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/package-info.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/package-info.java @@ -1,2 +1,2 @@ -/** Negotiation template resources: addressing keys, template entries and the template loader. */ -package net.openan.a2at.sdk.negotiation.resources; +/** Negotiation template resources: addressing keys, template entries and the template loader. */ +package net.openan.a2at.sdk.negotiation.resources; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java index ba613460..16be4366 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java @@ -1,112 +1,112 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.regex.Pattern; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Default rule-level compliance checker for negotiation messages. - * - *

            The checker validates the negotiation context carried alongside the message in the A2A-T metadata: the id must be - * a UUID of 36 characters in 8-4-4-4-12 hexadecimal form and the round must not exceed the round budget. Error messages - * are rendered from the {@link ErrorCatalog} message templates in the configured language. The positive-integer shape - * of the round fields is already guaranteed by the {@link NegotiationContext} constructor. No other rules are applied: - * the checker does not infer the negotiation type, does not validate conclusion values, does not require ending result - * sections and does not check conditional-section exclusivity. - * - * @since 2026-08 - */ -public final class DefaultNegotiationComplianceChecker implements NegotiationComplianceChecker { - - private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationComplianceChecker.class); - - private static final Pattern UUID_PATTERN = - Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); - - private static final String ID_SLOT = "id"; - - private static final String ROUND_SLOT = "round"; - - private final String language; - - /** Creates the default checker rendering messages in {@code en-US}. */ - public DefaultNegotiationComplianceChecker() { - this(null); - } - - /** - * Creates the default checker rendering messages in one language. - * - * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} - */ - public DefaultNegotiationComplianceChecker(@Nullable String language) { - this.language = language; - } - - /** - * Runs the rule-level compliance check of one negotiation context. - * - * @param context negotiation context carried alongside the message in the A2A-T metadata - * @return rule check outcome with no errors when every context rule holds, otherwise the context rule errors - * @throws NullPointerException if the context is null - */ - @Override - public NegotiationRuleCheckResult check(NegotiationContext context) { - Objects.requireNonNull(context, "context"); - List errors = new ArrayList<>(); - collectIdErrors(context.id(), errors); - if (context.round() > context.maxRounds()) { - errors.add(new SlotValidationError( - ROUND_SLOT, - ErrorCatalog.NEGOTIATION_ROUND_EXCEEDED.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_ROUND_EXCEEDED, language, roundFacts(context)), - roundFacts(context))); - } - return logResult(new NegotiationRuleCheckResult(errors.isEmpty(), List.copyOf(errors))); - } - - private static Map roundFacts(NegotiationContext context) { - return Map.of( - "round", String.valueOf(context.round()), - "max_rounds", String.valueOf(context.maxRounds())); - } - - private void collectIdErrors(String id, List errors) { - if (!UUID_PATTERN.matcher(id).matches()) { - Map facts = Map.of("actual", id); - errors.add(new SlotValidationError( - ID_SLOT, - ErrorCatalog.NEGOTIATION_INVALID_CONTEXT_ID.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_CONTEXT_ID, language, facts), - facts)); - } - } - - /** - * Emits the rule-check completion event of one check outcome. - * - * @param result rule check outcome to log - * @return the unchanged outcome - */ - private static NegotiationRuleCheckResult logResult(NegotiationRuleCheckResult result) { - if (result.errors().isEmpty()) { - LOGGER.atDebug().log("negotiation_rule_checks_completed passed={} error_count=0", result.passed()); - } else { - LOGGER.atWarn() - .log( - "negotiation_rule_checks_completed passed={} error_count={}", - result.passed(), - result.errors().size()); - } - return result; - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default rule-level compliance checker for negotiation messages. + * + *

            The checker validates the negotiation context carried alongside the message in the A2A-T metadata: the id must be + * a UUID of 36 characters in 8-4-4-4-12 hexadecimal form and the round must not exceed the round budget. Error messages + * are rendered from the {@link ErrorCatalog} message templates in the configured language. The positive-integer shape + * of the round fields is already guaranteed by the {@link NegotiationContext} constructor. No other rules are applied: + * the checker does not infer the negotiation type, does not validate conclusion values, does not require ending result + * sections and does not check conditional-section exclusivity. + * + * @since 2026-08 + */ +public final class DefaultNegotiationComplianceChecker implements NegotiationComplianceChecker { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationComplianceChecker.class); + + private static final Pattern UUID_PATTERN = + Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + + private static final String ID_SLOT = "id"; + + private static final String ROUND_SLOT = "round"; + + private final String language; + + /** Creates the default checker rendering messages in {@code en-US}. */ + public DefaultNegotiationComplianceChecker() { + this(null); + } + + /** + * Creates the default checker rendering messages in one language. + * + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + */ + public DefaultNegotiationComplianceChecker(@Nullable String language) { + this.language = language; + } + + /** + * Runs the rule-level compliance check of one negotiation context. + * + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @return rule check outcome with no errors when every context rule holds, otherwise the context rule errors + * @throws NullPointerException if the context is null + */ + @Override + public NegotiationRuleCheckResult check(NegotiationContext context) { + Objects.requireNonNull(context, "context"); + List errors = new ArrayList<>(); + collectIdErrors(context.id(), errors); + if (context.round() > context.maxRounds()) { + errors.add(new SlotValidationError( + ROUND_SLOT, + ErrorCatalog.NEGOTIATION_ROUND_EXCEEDED.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_ROUND_EXCEEDED, language, roundFacts(context)), + roundFacts(context))); + } + return logResult(new NegotiationRuleCheckResult(errors.isEmpty(), List.copyOf(errors))); + } + + private static Map roundFacts(NegotiationContext context) { + return Map.of( + "round", String.valueOf(context.round()), + "max_rounds", String.valueOf(context.maxRounds())); + } + + private void collectIdErrors(String id, List errors) { + if (!UUID_PATTERN.matcher(id).matches()) { + Map facts = Map.of("actual", id); + errors.add(new SlotValidationError( + ID_SLOT, + ErrorCatalog.NEGOTIATION_INVALID_CONTEXT_ID.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_CONTEXT_ID, language, facts), + facts)); + } + } + + /** + * Emits the rule-check completion event of one check outcome. + * + * @param result rule check outcome to log + * @return the unchanged outcome + */ + private static NegotiationRuleCheckResult logResult(NegotiationRuleCheckResult result) { + if (result.errors().isEmpty()) { + LOGGER.atDebug().log("negotiation_rule_checks_completed passed={} error_count=0", result.passed()); + } else { + LOGGER.atWarn() + .log( + "negotiation_rule_checks_completed passed={} error_count={}", + result.passed(), + result.errors().size()); + } + return result; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java index 180f8933..a392e1cd 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java @@ -1,417 +1,417 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.function.UnaryOperator; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Default LLM-backed semantic validator for negotiation messages. - * - *

            One structured LLM call performs the semantic validation and the parameter extraction together. The call loads the - * semantic validation prompt resources of the reference language from the classpath, fills the literal bracket tokens - * of the user prompt with the declared negotiation type, the template URI, the serialized caller schema and the message - * text, and passes the caller schema merged into the semantic validation output contract as the output schema. - * - *

            After the call the validator enforces the output contract in code: the response must contain the four required - * keys with the expected shapes, otherwise the internal {@link NegotiationValidationException} is thrown for the - * orchestration layer to map to the retryable {@code llm.response_invalid} code; a transport failure of the LLM call - * propagates as {@link net.openan.a2at.sdk.llm.LLMError} for the orchestration layer to map to - * {@code llm.invocation_failed}. The LLM reports each error as {@code {slot_name, code, facts}}; the human-readable - * message is rendered from the code's message template in the reference language, never taken from the LLM response, - * and a code outside the closed {@code negotiation.*} set of the catalog is mapped to the fallback - * {@code negotiation.rule_violation} and logged as a warning. When the verdict is true, the reported negotiation type - * must be present and must match the type declared by the template reference; a null or mismatching type turns the - * outcome into a semantic rejection carrying a {@code negotiation.type_mismatch} error. Phase consistency between the - * declared template and the message sections is part of the semantic tasks performed by the LLM and surfaces through - * the returned errors. - * - * @since 2026-08 - */ -public final class DefaultNegotiationSemanticValidator implements NegotiationSemanticValidator { - - private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationSemanticValidator.class); - - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - private static final String PROMPT_RESOURCE_ROOT = "prompt_resources/prompts/negotiation_semantic_validation/"; - - private static final String SYSTEM_PROMPT_FILE = "system.md"; - - private static final String USER_PROMPT_FILE = "user.md"; - - private static final String KEY_SEMANTIC_VERDICT = "semantic_verdict"; - - private static final String KEY_NEGOTIATION_TYPE = "negotiation_type"; - - private static final String KEY_ERRORS = "errors"; - - private static final String KEY_PARAMS = "params"; - - private static final String KEY_SLOT_NAME = "slot_name"; - - private static final String KEY_CODE = "code"; - - private static final String KEY_FACTS = "facts"; - - private static final String NEGOTIATION_CODE_DOMAIN = "negotiation."; - - private final LLMClient llmClient; - - private static final List NEGOTIATION_TYPE_ENUM = - Arrays.asList("information", "target", "feasibility", null); - - private final UnaryOperator> schemaBuilder; - - /** - * Creates the default semantic validator merging the caller schema with the built-in semantic validation output - * contract. - * - * @param llmClient LLM client used for the single structured call - * @throws NullPointerException if the LLM client is null - */ - public DefaultNegotiationSemanticValidator(LLMClient llmClient) { - this(llmClient, DefaultNegotiationSemanticValidator::buildSemanticValidationSchema); - } - - /** - * Creates the default semantic validator with a custom schema-merging collaborator. - * - * @param llmClient LLM client used for the single structured call - * @param schemaBuilder collaborator merging the caller schema into the semantic validation output contract - * @throws NullPointerException if the LLM client or the schema builder is null - */ - DefaultNegotiationSemanticValidator(LLMClient llmClient, UnaryOperator> schemaBuilder) { - this.llmClient = Objects.requireNonNull(llmClient, "llmClient"); - this.schemaBuilder = Objects.requireNonNull(schemaBuilder, "schemaBuilder"); - } - - /** - * Merges the caller-provided parameter schema into the semantic validation output contract. - * - *

            The merged schema requires exactly the four keys {@code semantic_verdict}, {@code negotiation_type}, - * {@code errors} and {@code params} and allows no additional properties. The caller schema is embedded as the - * {@code params} property; a caller schema without a {@code type} keyword is wrapped as an object schema first. - * - * @param callerSchema parameter schema provided by the caller of the validation API - * @return merged JSON Schema of the semantic validation LLM call - * @throws NullPointerException if the caller schema is null - */ - static Map buildSemanticValidationSchema(Map callerSchema) { - Objects.requireNonNull(callerSchema, "Caller parameter schema must not be null."); - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - Map properties = new LinkedHashMap<>(); - properties.put("semantic_verdict", Map.of("type", "boolean")); - Map negotiationType = new LinkedHashMap<>(); - negotiationType.put("type", List.of("string", "null")); - negotiationType.put("enum", NEGOTIATION_TYPE_ENUM); - properties.put("negotiation_type", negotiationType); - properties.put("errors", errorsSchema()); - properties.put("params", wrapCallerSchema(callerSchema)); - schema.put("properties", properties); - schema.put("required", List.of("semantic_verdict", "negotiation_type", "errors", "params")); - schema.put("additionalProperties", false); - return schema; - } - - private static Map errorsSchema() { - Map factsProperties = new LinkedHashMap<>(); - factsProperties.put("type", "object"); - factsProperties.put("additionalProperties", Map.of("type", "string")); - Map errorProperties = new LinkedHashMap<>(); - errorProperties.put(KEY_SLOT_NAME, Map.of("type", "string")); - errorProperties.put(KEY_CODE, Map.of("type", "string")); - errorProperties.put(KEY_FACTS, factsProperties); - Map errorItem = new LinkedHashMap<>(); - errorItem.put("type", "object"); - errorItem.put("properties", errorProperties); - errorItem.put("required", List.of(KEY_SLOT_NAME, KEY_CODE, KEY_FACTS)); - errorItem.put("additionalProperties", false); - Map errors = new LinkedHashMap<>(); - errors.put("type", "array"); - errors.put("items", errorItem); - return errors; - } - - private static Map wrapCallerSchema(Map callerSchema) { - if (callerSchema.containsKey("type")) { - return callerSchema; - } - Map wrapped = new LinkedHashMap<>(); - wrapped.put("type", "object"); - wrapped.putAll(callerSchema); - return wrapped; - } - - /** - * Validates one rendered negotiation message semantically and extracts its parameters in a single LLM call. - * - * @param prompt rendered negotiation message text - * @param callerSchema caller-provided parameter JSON schema embedded into the structured-call output contract - * @param reference template reference the message is validated against, carrying the declared type, performative - * and language - * @return semantic validation outcome carrying the verdict, the implied negotiation type, the semantic errors and - * the extracted parameters - * @throws NegotiationValidationException if the response misses a required key or has the wrong shape - * @throws net.openan.a2at.sdk.llm.LLMError if the LLM invocation fails at the transport level - * @throws ResourceNotFoundException if the semantic validation prompt resources of the reference language are - * missing - */ - @Override - public SemanticValidationResult validateNegotiation( - String prompt, Map callerSchema, NegotiationReference reference, String templateContent) { - Objects.requireNonNull(prompt, "prompt"); - Objects.requireNonNull(reference, "reference"); - List> messages = buildMessages(prompt, callerSchema, reference, templateContent); - Map mergedSchema = schemaBuilder.apply(callerSchema); - LLMResponse response = llmClient.structured(messages, mergedSchema, null, null); - SemanticValidationResult result = interpret(parseResponse(response.content()), reference); - LOGGER.atInfo().log( - "negotiation_semantic_validation_completed verdict={} error_count={}", - result.verdict(), - result.errors().size()); - return result; - } - - private static final Pattern USER_PROMPT_PLACEHOLDER_PATTERN = - Pattern.compile("\\[(phase|input|template_uri|negotiation_type|template_content|schema)\\]"); - - private static List> buildMessages( - String prompt, Map callerSchema, NegotiationReference reference, String templateContent) { - String language = reference.language(); - String systemPrompt = loadPromptResource(SYSTEM_PROMPT_FILE, language); - String userPrompt = loadPromptResource(USER_PROMPT_FILE, language); - Matcher matcher = USER_PROMPT_PLACEHOLDER_PATTERN.matcher(userPrompt); - StringBuilder sb = new StringBuilder(); - while (matcher.find()) { - String replacement = - switch (matcher.group(1)) { - case "phase" -> Matcher.quoteReplacement( - reference.performative().name().toLowerCase(Locale.ROOT)); - case "input" -> Matcher.quoteReplacement(prompt); - case "template_uri" -> Matcher.quoteReplacement(reference.uri()); - case "negotiation_type" -> Matcher.quoteReplacement(declaredTypeName(reference)); - case "template_content" -> Matcher.quoteReplacement(templateContent); - case "schema" -> Matcher.quoteReplacement(toJson(callerSchema)); - default -> Matcher.quoteReplacement(matcher.group()); - }; - matcher.appendReplacement(sb, replacement); - } - matcher.appendTail(sb); - String filledUserPrompt = sb.toString(); - return List.of( - Map.of("role", "system", "content", systemPrompt), Map.of("role", "user", "content", filledUserPrompt)); - } - - private static String loadPromptResource(String fileName, String language) { - String classpathPath = PROMPT_RESOURCE_ROOT + language + "/" + fileName; - InputStream stream = ClasspathResourceStreams.open(classpathPath); - if (stream == null) { - throw new ResourceNotFoundException( - "Negotiation semantic validation prompt resource does not exist for language " + language - + "; set A2AT_LANGUAGE to a language with bundled prompt resources (zh-CN or en-US).", - classpathPath); - } - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException exception) { - throw new A2ATError("Failed to read negotiation semantic validation prompt: " + classpathPath, exception); - } - } - - private static String toJson(Map callerSchema) { - try { - return OBJECT_MAPPER.writeValueAsString(callerSchema == null ? Map.of() : callerSchema); - } catch (JsonProcessingException error) { - throw new NegotiationValidationException("Failed to serialize the caller parameter schema.", error); - } - } - - private static Map parseResponse(String content) { - if (content == null || content.isBlank()) { - throw new NegotiationValidationException("Semantic validation response is empty."); - } - try { - Map parsed = OBJECT_MAPPER.readValue(content, new TypeReference>() {}); - return parsed == null ? Map.of() : parsed; - } catch (JsonProcessingException error) { - throw new NegotiationValidationException( - "Semantic validation response is not a JSON object: " + error.getMessage(), error); - } - } - - private static SemanticValidationResult interpret(Map response, NegotiationReference reference) { - if (!(response.get(KEY_SEMANTIC_VERDICT) instanceof Boolean verdict)) { - throw new NegotiationValidationException( - "Semantic validation response key semantic_verdict must be a boolean."); - } - if (!response.containsKey(KEY_NEGOTIATION_TYPE)) { - throw new NegotiationValidationException( - "Semantic validation response is missing the required key negotiation_type."); - } - Object typeValue = response.get(KEY_NEGOTIATION_TYPE); - if (typeValue != null && !(typeValue instanceof String)) { - throw new NegotiationValidationException( - "Semantic validation response key negotiation_type must be a string or null."); - } - if (!(response.get(KEY_ERRORS) instanceof List rawErrors)) { - throw new NegotiationValidationException("Semantic validation response key errors must be an array."); - } - if (!(response.get(KEY_PARAMS) instanceof Map rawParams)) { - throw new NegotiationValidationException("Semantic validation response key params must be an object."); - } - - List errors = parseErrors(rawErrors, reference.language()); - Map params = parseParams(rawParams); - String negotiationType = (String) typeValue; - - if (verdict && reference.type() != null) { - if (negotiationType == null) { - List rejectionErrors = new ArrayList<>(errors); - rejectionErrors.add(typeConsistencyError( - reference.type(), "unknown", declaredTypeName(reference), reference.language())); - return new SemanticValidationResult(false, null, rejectionErrors, params); - } - NegotiationType impliedType = parseImpliedType(negotiationType); - if (impliedType != reference.type()) { - NegotiationType sectionType = impliedType == null ? reference.type() : impliedType; - List rejectionErrors = new ArrayList<>(errors); - rejectionErrors.add(typeConsistencyError( - sectionType, negotiationType, declaredTypeName(reference), reference.language())); - return new SemanticValidationResult(false, negotiationType, rejectionErrors, params); - } - } - return new SemanticValidationResult(verdict, negotiationType, errors, params); - } - - private static List parseErrors(List rawErrors, String language) { - List errors = new ArrayList<>(); - for (Object rawError : rawErrors) { - if (!(rawError instanceof Map errorMap)) { - throw new NegotiationValidationException( - "Semantic validation response errors must be objects with slot_name, code and facts."); - } - if (!(errorMap.get(KEY_SLOT_NAME) instanceof String slotName) - || !(errorMap.get(KEY_CODE) instanceof String code)) { - throw new NegotiationValidationException( - "Semantic validation response errors must carry string slot_name and code values."); - } - if (!(errorMap.get(KEY_FACTS) instanceof Map rawFacts)) { - throw new NegotiationValidationException( - "Semantic validation response errors must carry a facts object."); - } - Map facts = stringFacts(rawFacts); - ErrorCatalog entry = resolveCode(code); - if (!entry.getCode().equals(code)) { - LOGGER.atWarn() - .log( - "negotiation_semantic_validation_unknown_code original_code={} fallback_code={}", - code, - entry.getCode()); - facts = Map.of("section_label", slotName); - } - errors.add(new SlotValidationError( - slotName, entry.getCode(), ErrorMessages.render(entry, language, facts), facts)); - } - return errors; - } - - /** Resolves one LLM-reported code to its catalog entry, falling back to {@code negotiation.rule_violation}. */ - private static ErrorCatalog resolveCode(String code) { - Optional entry = ErrorCatalog.byCode(code); - if (entry.isPresent() && entry.get().getCode().startsWith(NEGOTIATION_CODE_DOMAIN)) { - return entry.get(); - } - return ErrorCatalog.NEGOTIATION_RULE_VIOLATION; - } - - private static Map stringFacts(Map rawFacts) { - Map facts = new LinkedHashMap<>(); - for (Map.Entry entry : rawFacts.entrySet()) { - if (!(entry.getKey() instanceof String key)) { - continue; - } - Object value = entry.getValue(); - if (value instanceof String text) { - facts.put(key, text); - } else if (value instanceof Number || value instanceof Boolean) { - facts.put(key, String.valueOf(value)); - } - } - return Map.copyOf(facts); - } - - private static Map parseParams(Map rawParams) { - Map params = new LinkedHashMap<>(); - for (Map.Entry entry : rawParams.entrySet()) { - if (!(entry.getKey() instanceof String key)) { - throw new NegotiationValidationException("Semantic validation response params keys must be strings."); - } - params.put(key, entry.getValue()); - } - return params; - } - - private static SlotValidationError typeConsistencyError( - NegotiationType sectionType, String implied, String declared, String language) { - Map facts = Map.of("implied", implied, "declared", declared); - return new SlotValidationError( - declaredTypeSectionKey(sectionType), - ErrorCatalog.NEGOTIATION_TYPE_MISMATCH.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_TYPE_MISMATCH, language, facts), - facts); - } - - /** Parses the negotiation type reported for the message; null when the reported type is none of the known types. */ - private static NegotiationType parseImpliedType(String negotiationType) { - for (NegotiationType candidate : NegotiationType.values()) { - if (candidate.name().toLowerCase(Locale.ROOT).equals(negotiationType)) { - return candidate; - } - } - return null; - } - - private static String declaredTypeName(NegotiationReference reference) { - return reference.type() == null ? "common" : reference.type().name().toLowerCase(Locale.ROOT); - } - - private static String declaredTypeSectionKey(NegotiationType type) { - switch (type) { - case INFORMATION: - return "section.info_static"; - case TARGET: - return "section.target"; - case FEASIBILITY: - return "section.feasibility"; - default: - throw new NegotiationValidationException("Unknown negotiation type " + type + "."); - } - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.UnaryOperator; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default LLM-backed semantic validator for negotiation messages. + * + *

            One structured LLM call performs the semantic validation and the parameter extraction together. The call loads the + * semantic validation prompt resources of the reference language from the classpath, fills the literal bracket tokens + * of the user prompt with the declared negotiation type, the template URI, the serialized caller schema and the message + * text, and passes the caller schema merged into the semantic validation output contract as the output schema. + * + *

            After the call the validator enforces the output contract in code: the response must contain the four required + * keys with the expected shapes, otherwise the internal {@link NegotiationValidationException} is thrown for the + * orchestration layer to map to the retryable {@code llm.response_invalid} code; a transport failure of the LLM call + * propagates as {@link net.openan.a2at.sdk.llm.LLMError} for the orchestration layer to map to + * {@code llm.invocation_failed}. The LLM reports each error as {@code {slot_name, code, facts}}; the human-readable + * message is rendered from the code's message template in the reference language, never taken from the LLM response, + * and a code outside the closed {@code negotiation.*} set of the catalog is mapped to the fallback + * {@code negotiation.rule_violation} and logged as a warning. When the verdict is true, the reported negotiation type + * must be present and must match the type declared by the template reference; a null or mismatching type turns the + * outcome into a semantic rejection carrying a {@code negotiation.type_mismatch} error. Phase consistency between the + * declared template and the message sections is part of the semantic tasks performed by the LLM and surfaces through + * the returned errors. + * + * @since 2026-08 + */ +public final class DefaultNegotiationSemanticValidator implements NegotiationSemanticValidator { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationSemanticValidator.class); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String PROMPT_RESOURCE_ROOT = "prompt_resources/prompts/negotiation_semantic_validation/"; + + private static final String SYSTEM_PROMPT_FILE = "system.md"; + + private static final String USER_PROMPT_FILE = "user.md"; + + private static final String KEY_SEMANTIC_VERDICT = "semantic_verdict"; + + private static final String KEY_NEGOTIATION_TYPE = "negotiation_type"; + + private static final String KEY_ERRORS = "errors"; + + private static final String KEY_PARAMS = "params"; + + private static final String KEY_SLOT_NAME = "slot_name"; + + private static final String KEY_CODE = "code"; + + private static final String KEY_FACTS = "facts"; + + private static final String NEGOTIATION_CODE_DOMAIN = "negotiation."; + + private final LLMClient llmClient; + + private static final List NEGOTIATION_TYPE_ENUM = + Arrays.asList("information", "target", "feasibility", null); + + private final UnaryOperator> schemaBuilder; + + /** + * Creates the default semantic validator merging the caller schema with the built-in semantic validation output + * contract. + * + * @param llmClient LLM client used for the single structured call + * @throws NullPointerException if the LLM client is null + */ + public DefaultNegotiationSemanticValidator(LLMClient llmClient) { + this(llmClient, DefaultNegotiationSemanticValidator::buildSemanticValidationSchema); + } + + /** + * Creates the default semantic validator with a custom schema-merging collaborator. + * + * @param llmClient LLM client used for the single structured call + * @param schemaBuilder collaborator merging the caller schema into the semantic validation output contract + * @throws NullPointerException if the LLM client or the schema builder is null + */ + DefaultNegotiationSemanticValidator(LLMClient llmClient, UnaryOperator> schemaBuilder) { + this.llmClient = Objects.requireNonNull(llmClient, "llmClient"); + this.schemaBuilder = Objects.requireNonNull(schemaBuilder, "schemaBuilder"); + } + + /** + * Merges the caller-provided parameter schema into the semantic validation output contract. + * + *

            The merged schema requires exactly the four keys {@code semantic_verdict}, {@code negotiation_type}, + * {@code errors} and {@code params} and allows no additional properties. The caller schema is embedded as the + * {@code params} property; a caller schema without a {@code type} keyword is wrapped as an object schema first. + * + * @param callerSchema parameter schema provided by the caller of the validation API + * @return merged JSON Schema of the semantic validation LLM call + * @throws NullPointerException if the caller schema is null + */ + static Map buildSemanticValidationSchema(Map callerSchema) { + Objects.requireNonNull(callerSchema, "Caller parameter schema must not be null."); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + Map properties = new LinkedHashMap<>(); + properties.put("semantic_verdict", Map.of("type", "boolean")); + Map negotiationType = new LinkedHashMap<>(); + negotiationType.put("type", List.of("string", "null")); + negotiationType.put("enum", NEGOTIATION_TYPE_ENUM); + properties.put("negotiation_type", negotiationType); + properties.put("errors", errorsSchema()); + properties.put("params", wrapCallerSchema(callerSchema)); + schema.put("properties", properties); + schema.put("required", List.of("semantic_verdict", "negotiation_type", "errors", "params")); + schema.put("additionalProperties", false); + return schema; + } + + private static Map errorsSchema() { + Map factsProperties = new LinkedHashMap<>(); + factsProperties.put("type", "object"); + factsProperties.put("additionalProperties", Map.of("type", "string")); + Map errorProperties = new LinkedHashMap<>(); + errorProperties.put(KEY_SLOT_NAME, Map.of("type", "string")); + errorProperties.put(KEY_CODE, Map.of("type", "string")); + errorProperties.put(KEY_FACTS, factsProperties); + Map errorItem = new LinkedHashMap<>(); + errorItem.put("type", "object"); + errorItem.put("properties", errorProperties); + errorItem.put("required", List.of(KEY_SLOT_NAME, KEY_CODE, KEY_FACTS)); + errorItem.put("additionalProperties", false); + Map errors = new LinkedHashMap<>(); + errors.put("type", "array"); + errors.put("items", errorItem); + return errors; + } + + private static Map wrapCallerSchema(Map callerSchema) { + if (callerSchema.containsKey("type")) { + return callerSchema; + } + Map wrapped = new LinkedHashMap<>(); + wrapped.put("type", "object"); + wrapped.putAll(callerSchema); + return wrapped; + } + + /** + * Validates one rendered negotiation message semantically and extracts its parameters in a single LLM call. + * + * @param prompt rendered negotiation message text + * @param callerSchema caller-provided parameter JSON schema embedded into the structured-call output contract + * @param reference template reference the message is validated against, carrying the declared type, performative + * and language + * @return semantic validation outcome carrying the verdict, the implied negotiation type, the semantic errors and + * the extracted parameters + * @throws NegotiationValidationException if the response misses a required key or has the wrong shape + * @throws net.openan.a2at.sdk.llm.LLMError if the LLM invocation fails at the transport level + * @throws ResourceNotFoundException if the semantic validation prompt resources of the reference language are + * missing + */ + @Override + public SemanticValidationResult validateNegotiation( + String prompt, Map callerSchema, NegotiationReference reference, String templateContent) { + Objects.requireNonNull(prompt, "prompt"); + Objects.requireNonNull(reference, "reference"); + List> messages = buildMessages(prompt, callerSchema, reference, templateContent); + Map mergedSchema = schemaBuilder.apply(callerSchema); + LLMResponse response = llmClient.structured(messages, mergedSchema, null, null); + SemanticValidationResult result = interpret(parseResponse(response.content()), reference); + LOGGER.atInfo().log( + "negotiation_semantic_validation_completed verdict={} error_count={}", + result.verdict(), + result.errors().size()); + return result; + } + + private static final Pattern USER_PROMPT_PLACEHOLDER_PATTERN = + Pattern.compile("\\[(phase|input|template_uri|negotiation_type|template_content|schema)\\]"); + + private static List> buildMessages( + String prompt, Map callerSchema, NegotiationReference reference, String templateContent) { + String language = reference.language(); + String systemPrompt = loadPromptResource(SYSTEM_PROMPT_FILE, language); + String userPrompt = loadPromptResource(USER_PROMPT_FILE, language); + Matcher matcher = USER_PROMPT_PLACEHOLDER_PATTERN.matcher(userPrompt); + StringBuilder sb = new StringBuilder(); + while (matcher.find()) { + String replacement = + switch (matcher.group(1)) { + case "phase" -> Matcher.quoteReplacement( + reference.performative().name().toLowerCase(Locale.ROOT)); + case "input" -> Matcher.quoteReplacement(prompt); + case "template_uri" -> Matcher.quoteReplacement(reference.uri()); + case "negotiation_type" -> Matcher.quoteReplacement(declaredTypeName(reference)); + case "template_content" -> Matcher.quoteReplacement(templateContent); + case "schema" -> Matcher.quoteReplacement(toJson(callerSchema)); + default -> Matcher.quoteReplacement(matcher.group()); + }; + matcher.appendReplacement(sb, replacement); + } + matcher.appendTail(sb); + String filledUserPrompt = sb.toString(); + return List.of( + Map.of("role", "system", "content", systemPrompt), Map.of("role", "user", "content", filledUserPrompt)); + } + + private static String loadPromptResource(String fileName, String language) { + String classpathPath = PROMPT_RESOURCE_ROOT + language + "/" + fileName; + InputStream stream = ClasspathResourceStreams.open(classpathPath); + if (stream == null) { + throw new ResourceNotFoundException( + "Negotiation semantic validation prompt resource does not exist for language " + language + + "; set A2AT_LANGUAGE to a language with bundled prompt resources (zh-CN or en-US).", + classpathPath); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new A2ATError("Failed to read negotiation semantic validation prompt: " + classpathPath, exception); + } + } + + private static String toJson(Map callerSchema) { + try { + return OBJECT_MAPPER.writeValueAsString(callerSchema == null ? Map.of() : callerSchema); + } catch (JsonProcessingException error) { + throw new NegotiationValidationException("Failed to serialize the caller parameter schema.", error); + } + } + + private static Map parseResponse(String content) { + if (content == null || content.isBlank()) { + throw new NegotiationValidationException("Semantic validation response is empty."); + } + try { + Map parsed = OBJECT_MAPPER.readValue(content, new TypeReference>() {}); + return parsed == null ? Map.of() : parsed; + } catch (JsonProcessingException error) { + throw new NegotiationValidationException( + "Semantic validation response is not a JSON object: " + error.getMessage(), error); + } + } + + private static SemanticValidationResult interpret(Map response, NegotiationReference reference) { + if (!(response.get(KEY_SEMANTIC_VERDICT) instanceof Boolean verdict)) { + throw new NegotiationValidationException( + "Semantic validation response key semantic_verdict must be a boolean."); + } + if (!response.containsKey(KEY_NEGOTIATION_TYPE)) { + throw new NegotiationValidationException( + "Semantic validation response is missing the required key negotiation_type."); + } + Object typeValue = response.get(KEY_NEGOTIATION_TYPE); + if (typeValue != null && !(typeValue instanceof String)) { + throw new NegotiationValidationException( + "Semantic validation response key negotiation_type must be a string or null."); + } + if (!(response.get(KEY_ERRORS) instanceof List rawErrors)) { + throw new NegotiationValidationException("Semantic validation response key errors must be an array."); + } + if (!(response.get(KEY_PARAMS) instanceof Map rawParams)) { + throw new NegotiationValidationException("Semantic validation response key params must be an object."); + } + + List errors = parseErrors(rawErrors, reference.language()); + Map params = parseParams(rawParams); + String negotiationType = (String) typeValue; + + if (verdict && reference.type() != null) { + if (negotiationType == null) { + List rejectionErrors = new ArrayList<>(errors); + rejectionErrors.add(typeConsistencyError( + reference.type(), "unknown", declaredTypeName(reference), reference.language())); + return new SemanticValidationResult(false, null, rejectionErrors, params); + } + NegotiationType impliedType = parseImpliedType(negotiationType); + if (impliedType != reference.type()) { + NegotiationType sectionType = impliedType == null ? reference.type() : impliedType; + List rejectionErrors = new ArrayList<>(errors); + rejectionErrors.add(typeConsistencyError( + sectionType, negotiationType, declaredTypeName(reference), reference.language())); + return new SemanticValidationResult(false, negotiationType, rejectionErrors, params); + } + } + return new SemanticValidationResult(verdict, negotiationType, errors, params); + } + + private static List parseErrors(List rawErrors, String language) { + List errors = new ArrayList<>(); + for (Object rawError : rawErrors) { + if (!(rawError instanceof Map errorMap)) { + throw new NegotiationValidationException( + "Semantic validation response errors must be objects with slot_name, code and facts."); + } + if (!(errorMap.get(KEY_SLOT_NAME) instanceof String slotName) + || !(errorMap.get(KEY_CODE) instanceof String code)) { + throw new NegotiationValidationException( + "Semantic validation response errors must carry string slot_name and code values."); + } + if (!(errorMap.get(KEY_FACTS) instanceof Map rawFacts)) { + throw new NegotiationValidationException( + "Semantic validation response errors must carry a facts object."); + } + Map facts = stringFacts(rawFacts); + ErrorCatalog entry = resolveCode(code); + if (!entry.getCode().equals(code)) { + LOGGER.atWarn() + .log( + "negotiation_semantic_validation_unknown_code original_code={} fallback_code={}", + code, + entry.getCode()); + facts = Map.of("section_label", slotName); + } + errors.add(new SlotValidationError( + slotName, entry.getCode(), ErrorMessages.render(entry, language, facts), facts)); + } + return errors; + } + + /** Resolves one LLM-reported code to its catalog entry, falling back to {@code negotiation.rule_violation}. */ + private static ErrorCatalog resolveCode(String code) { + Optional entry = ErrorCatalog.byCode(code); + if (entry.isPresent() && entry.get().getCode().startsWith(NEGOTIATION_CODE_DOMAIN)) { + return entry.get(); + } + return ErrorCatalog.NEGOTIATION_RULE_VIOLATION; + } + + private static Map stringFacts(Map rawFacts) { + Map facts = new LinkedHashMap<>(); + for (Map.Entry entry : rawFacts.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + continue; + } + Object value = entry.getValue(); + if (value instanceof String text) { + facts.put(key, text); + } else if (value instanceof Number || value instanceof Boolean) { + facts.put(key, String.valueOf(value)); + } + } + return Map.copyOf(facts); + } + + private static Map parseParams(Map rawParams) { + Map params = new LinkedHashMap<>(); + for (Map.Entry entry : rawParams.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + throw new NegotiationValidationException("Semantic validation response params keys must be strings."); + } + params.put(key, entry.getValue()); + } + return params; + } + + private static SlotValidationError typeConsistencyError( + NegotiationType sectionType, String implied, String declared, String language) { + Map facts = Map.of("implied", implied, "declared", declared); + return new SlotValidationError( + declaredTypeSectionKey(sectionType), + ErrorCatalog.NEGOTIATION_TYPE_MISMATCH.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_TYPE_MISMATCH, language, facts), + facts); + } + + /** Parses the negotiation type reported for the message; null when the reported type is none of the known types. */ + private static NegotiationType parseImpliedType(String negotiationType) { + for (NegotiationType candidate : NegotiationType.values()) { + if (candidate.name().toLowerCase(Locale.ROOT).equals(negotiationType)) { + return candidate; + } + } + return null; + } + + private static String declaredTypeName(NegotiationReference reference) { + return reference.type() == null ? "common" : reference.type().name().toLowerCase(Locale.ROOT); + } + + private static String declaredTypeSectionKey(NegotiationType type) { + switch (type) { + case INFORMATION: + return "section.info_static"; + case TARGET: + return "section.target"; + case FEASIBILITY: + return "section.feasibility"; + default: + throw new NegotiationValidationException("Unknown negotiation type " + type + "."); + } + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java index f16fb614..9ee69288 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java @@ -1,97 +1,97 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.RuleChecker; -import org.jspecify.annotations.Nullable; - -/** - * Adapter that bridges {@link NegotiationComplianceChecker} to the {@link RuleChecker} contract for one validation - * call. - * - *

            The adapter holds a {@link NegotiationComplianceChecker} and the negotiation context carried alongside the message - * in the A2A-T metadata, delegating the {@link RuleChecker#check(String)} call to the context-aware - * {@link NegotiationComplianceChecker#check(NegotiationContext)} method. A null context is reported as not being a - * negotiation message. The resulting {@link NegotiationRuleCheckResult} is converted into either a map of context - * parameters ({@code id}, {@code round}, {@code maxRounds}) or a {@link ContentValidationException} carrying the - * catalog codes {@code negotiation.invalid_input} (no negotiation context) or {@code negotiation.rule_violation} - * (context rule violation) with messages rendered in the configured language. - * - * @since 2026-08 - */ -public final class NegotiationRuleCheckerAdapter implements RuleChecker { - - private static final String MISSING_CONTEXT_REASON_EN = - "the negotiation context is missing (the message is not a negotiation message)"; - - private static final String MISSING_CONTEXT_REASON_ZH = "缺少协商上下文(该报文不是协商报文)"; - - private final NegotiationComplianceChecker checker; - - private final NegotiationContext context; - - private final String language; - - /** - * Creates an adapter for one compliance checker and negotiation context rendering messages in {@code en-US}. - * - * @param checker compliance checker validating the negotiation context - * @param context negotiation context carried alongside the message; null is reported as not being a negotiation - * message - */ - public NegotiationRuleCheckerAdapter(NegotiationComplianceChecker checker, @Nullable NegotiationContext context) { - this(checker, context, null); - } - - /** - * Creates an adapter for one compliance checker, negotiation context and message language. - * - * @param checker compliance checker validating the negotiation context - * @param context negotiation context carried alongside the message; null is reported as not being a negotiation - * message - * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} - */ - public NegotiationRuleCheckerAdapter( - NegotiationComplianceChecker checker, @Nullable NegotiationContext context, @Nullable String language) { - this.checker = Objects.requireNonNull(checker, "checker"); - this.context = context; - this.language = language; - } - - @Override - public Map check(String prompt) { - if (context == null) { - Map facts = Map.of("reason", missingContextReason()); - throw new ContentValidationException( - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), - List.of(), - Map.of(), - null); - } - NegotiationRuleCheckResult result = checker.check(context); - if (!result.passed()) { - String message = result.errors().isEmpty() - ? ErrorMessages.render(ErrorCatalog.NEGOTIATION_RULE_VIOLATION, language, null) - : result.errors().get(0).message(); - throw new ContentValidationException( - ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), message, result.errors()); - } - Map params = new LinkedHashMap<>(); - params.put("id", context.id()); - params.put("round", context.round()); - params.put("maxRounds", context.maxRounds()); - return Collections.unmodifiableMap(params); - } - - private String missingContextReason() { - return language != null && language.startsWith("zh") ? MISSING_CONTEXT_REASON_ZH : MISSING_CONTEXT_REASON_EN; - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.RuleChecker; +import org.jspecify.annotations.Nullable; + +/** + * Adapter that bridges {@link NegotiationComplianceChecker} to the {@link RuleChecker} contract for one validation + * call. + * + *

            The adapter holds a {@link NegotiationComplianceChecker} and the negotiation context carried alongside the message + * in the A2A-T metadata, delegating the {@link RuleChecker#check(String)} call to the context-aware + * {@link NegotiationComplianceChecker#check(NegotiationContext)} method. A null context is reported as not being a + * negotiation message. The resulting {@link NegotiationRuleCheckResult} is converted into either a map of context + * parameters ({@code id}, {@code round}, {@code maxRounds}) or a {@link ContentValidationException} carrying the + * catalog codes {@code negotiation.invalid_input} (no negotiation context) or {@code negotiation.rule_violation} + * (context rule violation) with messages rendered in the configured language. + * + * @since 2026-08 + */ +public final class NegotiationRuleCheckerAdapter implements RuleChecker { + + private static final String MISSING_CONTEXT_REASON_EN = + "the negotiation context is missing (the message is not a negotiation message)"; + + private static final String MISSING_CONTEXT_REASON_ZH = "缺少协商上下文(该报文不是协商报文)"; + + private final NegotiationComplianceChecker checker; + + private final NegotiationContext context; + + private final String language; + + /** + * Creates an adapter for one compliance checker and negotiation context rendering messages in {@code en-US}. + * + * @param checker compliance checker validating the negotiation context + * @param context negotiation context carried alongside the message; null is reported as not being a negotiation + * message + */ + public NegotiationRuleCheckerAdapter(NegotiationComplianceChecker checker, @Nullable NegotiationContext context) { + this(checker, context, null); + } + + /** + * Creates an adapter for one compliance checker, negotiation context and message language. + * + * @param checker compliance checker validating the negotiation context + * @param context negotiation context carried alongside the message; null is reported as not being a negotiation + * message + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + */ + public NegotiationRuleCheckerAdapter( + NegotiationComplianceChecker checker, @Nullable NegotiationContext context, @Nullable String language) { + this.checker = Objects.requireNonNull(checker, "checker"); + this.context = context; + this.language = language; + } + + @Override + public Map check(String prompt) { + if (context == null) { + Map facts = Map.of("reason", missingContextReason()); + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), + List.of(), + Map.of(), + null); + } + NegotiationRuleCheckResult result = checker.check(context); + if (!result.passed()) { + String message = result.errors().isEmpty() + ? ErrorMessages.render(ErrorCatalog.NEGOTIATION_RULE_VIOLATION, language, null) + : result.errors().get(0).message(); + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), message, result.errors()); + } + Map params = new LinkedHashMap<>(); + params.put("id", context.id()); + params.put("round", context.round()); + params.put("maxRounds", context.maxRounds()); + return Collections.unmodifiableMap(params); + } + + private String missingContextReason() { + return language != null && language.startsWith("zh") ? MISSING_CONTEXT_REASON_ZH : MISSING_CONTEXT_REASON_EN; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java index 6d9512cd..fe1955e0 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java @@ -1,80 +1,80 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.SemanticValidator; -import net.openan.a2at.sdk.core.validation.ValidationResult; -import net.openan.a2at.sdk.llm.LLMConfigError; -import net.openan.a2at.sdk.llm.LLMError; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; - -/** - * LLM-backed semantic validator for negotiation messages. - * - *

            The validator performs a single structured LLM call that combines semantic validation with parameter extraction - * and then enforces the declared type consistency in code: when the verdict is true, the negotiation type reported for - * the message must be present and must match the type declared by the template reference. A response that misses one of - * the four required keys or has the wrong shape is a validation infrastructure failure signalled through the internal - * {@link NegotiationValidationException}. - * - * @since 2026-08 - */ -public interface NegotiationSemanticValidator extends SemanticValidator { - - /** - * Validates one rendered negotiation message semantically and extracts its parameters. - * - * @param prompt rendered negotiation message text - * @param callerSchema caller-provided parameter JSON schema embedded into the structured-call output contract - * @param reference negotiation reference the message is validated against, carrying the declared type, phase and - * language - * @param templateContent loaded template text used as a reference for structure/completeness checks - * @return semantic validation outcome carrying the verdict, the implied negotiation type, the semantic errors and - * the extracted parameters - * @throws NegotiationValidationException if the response misses a required key or has the wrong shape - * @throws net.openan.a2at.sdk.llm.LLMError if the LLM invocation fails at the transport level - * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the semantic validation prompt resources - * of the reference language are missing - */ - SemanticValidationResult validateNegotiation( - String prompt, Map callerSchema, NegotiationReference reference, String templateContent); - - @Override - default ValidationResult validate( - String prompt, Map schema, NegotiationReference reference, String templateContent) { - try { - SemanticValidationResult result = validateNegotiation(prompt, schema, reference, templateContent); - return new ValidationResult(result.verdict(), result.errors(), result.params()); - } catch (LLMConfigError exception) { - // A missing LLM configuration never recovers within this call. - throw new ContentValidationException( - ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), - ErrorMessages.render(ErrorCatalog.LLM_NOT_CONFIGURED, reference.language(), null), - exception); - } catch (LLMError exception) { - Map facts = Map.of( - "provider", exception.getClass().getSimpleName(), - "reason", String.valueOf(exception.getMessage())); - String message = ErrorMessages.render(ErrorCatalog.LLM_INVOCATION_FAILED, reference.language(), facts); - throw new ContentValidationException( - ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), - message, - List.of(new SlotValidationError( - "_llm", ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), message, facts)), - exception); - } catch (NegotiationValidationException exception) { - Map facts = Map.of("step", "semantic_validation"); - String message = ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, reference.language(), facts); - throw new ContentValidationException( - ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), - message, - List.of(new SlotValidationError( - "_llm", ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), message, facts)), - exception); - } - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.SemanticValidator; +import net.openan.a2at.sdk.core.validation.ValidationResult; +import net.openan.a2at.sdk.llm.LLMConfigError; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; + +/** + * LLM-backed semantic validator for negotiation messages. + * + *

            The validator performs a single structured LLM call that combines semantic validation with parameter extraction + * and then enforces the declared type consistency in code: when the verdict is true, the negotiation type reported for + * the message must be present and must match the type declared by the template reference. A response that misses one of + * the four required keys or has the wrong shape is a validation infrastructure failure signalled through the internal + * {@link NegotiationValidationException}. + * + * @since 2026-08 + */ +public interface NegotiationSemanticValidator extends SemanticValidator { + + /** + * Validates one rendered negotiation message semantically and extracts its parameters. + * + * @param prompt rendered negotiation message text + * @param callerSchema caller-provided parameter JSON schema embedded into the structured-call output contract + * @param reference negotiation reference the message is validated against, carrying the declared type, phase and + * language + * @param templateContent loaded template text used as a reference for structure/completeness checks + * @return semantic validation outcome carrying the verdict, the implied negotiation type, the semantic errors and + * the extracted parameters + * @throws NegotiationValidationException if the response misses a required key or has the wrong shape + * @throws net.openan.a2at.sdk.llm.LLMError if the LLM invocation fails at the transport level + * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the semantic validation prompt resources + * of the reference language are missing + */ + SemanticValidationResult validateNegotiation( + String prompt, Map callerSchema, NegotiationReference reference, String templateContent); + + @Override + default ValidationResult validate( + String prompt, Map schema, NegotiationReference reference, String templateContent) { + try { + SemanticValidationResult result = validateNegotiation(prompt, schema, reference, templateContent); + return new ValidationResult(result.verdict(), result.errors(), result.params()); + } catch (LLMConfigError exception) { + // A missing LLM configuration never recovers within this call. + throw new ContentValidationException( + ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), + ErrorMessages.render(ErrorCatalog.LLM_NOT_CONFIGURED, reference.language(), null), + exception); + } catch (LLMError exception) { + Map facts = Map.of( + "provider", exception.getClass().getSimpleName(), + "reason", String.valueOf(exception.getMessage())); + String message = ErrorMessages.render(ErrorCatalog.LLM_INVOCATION_FAILED, reference.language(), facts); + throw new ContentValidationException( + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + message, + List.of(new SlotValidationError( + "_llm", ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), message, facts)), + exception); + } catch (NegotiationValidationException exception) { + Map facts = Map.of("step", "semantic_validation"); + String message = ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, reference.language(), facts); + throw new ContentValidationException( + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + message, + List.of(new SlotValidationError( + "_llm", ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), message, facts)), + exception); + } + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java index c1e034b8..a5027b8a 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java @@ -1,33 +1,33 @@ -package net.openan.a2at.sdk.negotiation.validation; - -/** - * Internal failure of the negotiation semantic validation step. - * - *

            This exception is internal to the validation pipeline: it signals an LLM infrastructure failure or a response that - * violates the output contract (missing required keys or wrong shapes). It must never bubble out of the public APIs; - * the orchestration layer converts it into a parameter-extraction failure carrying the retryable LLM infrastructure - * error code. - * - * @since 2026-08 - */ -public class NegotiationValidationException extends RuntimeException { - - /** - * Creates an internal semantic validation failure. - * - * @param message failure message - */ - public NegotiationValidationException(String message) { - super(message); - } - - /** - * Creates an internal semantic validation failure with a cause. - * - * @param message failure message - * @param cause underlying failure - */ - public NegotiationValidationException(String message, Throwable cause) { - super(message, cause); - } -} +package net.openan.a2at.sdk.negotiation.validation; + +/** + * Internal failure of the negotiation semantic validation step. + * + *

            This exception is internal to the validation pipeline: it signals an LLM infrastructure failure or a response that + * violates the output contract (missing required keys or wrong shapes). It must never bubble out of the public APIs; + * the orchestration layer converts it into a parameter-extraction failure carrying the retryable LLM infrastructure + * error code. + * + * @since 2026-08 + */ +public class NegotiationValidationException extends RuntimeException { + + /** + * Creates an internal semantic validation failure. + * + * @param message failure message + */ + public NegotiationValidationException(String message) { + super(message); + } + + /** + * Creates an internal semantic validation failure with a cause. + * + * @param message failure message + * @param cause underlying failure + */ + public NegotiationValidationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java index 07650a17..e0b67c77 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/ParamExtractor.java @@ -1,109 +1,109 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.SemanticValidator; -import net.openan.a2at.sdk.core.validation.TemplateContentLoader; -import net.openan.a2at.sdk.core.validation.ValidationPipeline; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.jspecify.annotations.Nullable; - -/** - * Orchestrates the validation of a negotiation message and the extraction of its parameters. - * - *

            The extractor delegates to the shared {@link ValidationPipeline} from the core module, which runs the rule-level - * gate, the template loading gate, retryable semantic validation and deterministic parameter merging in one pass. The - * rule-level gate is a per-call {@link NegotiationRuleCheckerAdapter} bridging the negotiation context carried - * alongside the message to the core rule checker contract; the injected {@link TemplateContentLoader} resolves the - * template body after the rule gate and before semantic validation, so the template is never preloaded by the caller. - * The pipeline emits the final {@link net.openan.a2at.sdk.core.exception.ErrorCatalog} codes with messages rendered in - * the language of the negotiation reference; there is no code mapping layer between the pipeline steps and the surfaced - * exception. - * - * @since 2026-08 - */ -public final class ParamExtractor { - - private final NegotiationComplianceChecker complianceChecker; - - private final SemanticValidator semanticValidator; - - private final int maxAttempts; - - private final TemplateContentLoader templateContentLoader; - - /** - * Creates a parameter extractor. - * - * @param complianceChecker rule-level checker used as the entry gate - * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters - * @param maxAttempts maximum number of retry attempts for the semantic validation step - * @param templateContentLoader template loading gate resolving the template body after the rule gate - * @throws NullPointerException if any collaborator is null - */ - public ParamExtractor( - NegotiationComplianceChecker complianceChecker, - SemanticValidator semanticValidator, - int maxAttempts, - TemplateContentLoader templateContentLoader) { - this.complianceChecker = Objects.requireNonNull(complianceChecker, "complianceChecker"); - this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); - this.maxAttempts = maxAttempts; - this.templateContentLoader = Objects.requireNonNull(templateContentLoader, "templateContentLoader"); - } - - /** - * Validates one negotiation message and extracts its parameters through the full pipeline. - * - * @param prompt rendered negotiation message text - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param reference template reference the message is validated against - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input}, - * {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, {@code llm.invocation_failed}, - * {@code llm.response_invalid} or {@code template.not_found} when the validation pipeline fails - */ - public FilledParamData extract( - String prompt, - @Nullable NegotiationContext context, - Map schema, - NegotiationReference reference) { - ValidationPipeline pipeline = new ValidationPipeline<>( - new NegotiationRuleCheckerAdapter(complianceChecker, context, reference.language()), - semanticValidator, - maxAttempts, - reference.language(), - templateContentLoader); - try { - return pipeline.validate(prompt, schema, reference); - } catch (ContentValidationException e) { - throw negotiationFailure(e, reference.language()); - } - } - - private static NegotiationParamExtractionException negotiationFailure( - ContentValidationException failure, String language) { - // The pipeline renders the final message itself; only the structured facts are reconstructed from the first - // slot detail that carries them, so callers keep the rendered message and gain the facts map. - ErrorCatalog entry = ErrorCatalog.byCode(failure.getCode()).orElse(null); - if (entry == null) { - throw new NegotiationParamExtractionException( - failure.getCode(), failure.getMessage(), failure.errors(), failure); - } - Map facts = failure.errors().stream() - .map(SlotValidationError::facts) - .filter(Objects::nonNull) - .findFirst() - .orElse(null); - return new NegotiationParamExtractionException( - entry.getCode(), failure.getMessage(), facts, failure.errors(), failure); - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.SemanticValidator; +import net.openan.a2at.sdk.core.validation.TemplateContentLoader; +import net.openan.a2at.sdk.core.validation.ValidationPipeline; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.jspecify.annotations.Nullable; + +/** + * Orchestrates the validation of a negotiation message and the extraction of its parameters. + * + *

            The extractor delegates to the shared {@link ValidationPipeline} from the core module, which runs the rule-level + * gate, the template loading gate, retryable semantic validation and deterministic parameter merging in one pass. The + * rule-level gate is a per-call {@link NegotiationRuleCheckerAdapter} bridging the negotiation context carried + * alongside the message to the core rule checker contract; the injected {@link TemplateContentLoader} resolves the + * template body after the rule gate and before semantic validation, so the template is never preloaded by the caller. + * The pipeline emits the final {@link net.openan.a2at.sdk.core.exception.ErrorCatalog} codes with messages rendered in + * the language of the negotiation reference; there is no code mapping layer between the pipeline steps and the surfaced + * exception. + * + * @since 2026-08 + */ +public final class ParamExtractor { + + private final NegotiationComplianceChecker complianceChecker; + + private final SemanticValidator semanticValidator; + + private final int maxAttempts; + + private final TemplateContentLoader templateContentLoader; + + /** + * Creates a parameter extractor. + * + * @param complianceChecker rule-level checker used as the entry gate + * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters + * @param maxAttempts maximum number of retry attempts for the semantic validation step + * @param templateContentLoader template loading gate resolving the template body after the rule gate + * @throws NullPointerException if any collaborator is null + */ + public ParamExtractor( + NegotiationComplianceChecker complianceChecker, + SemanticValidator semanticValidator, + int maxAttempts, + TemplateContentLoader templateContentLoader) { + this.complianceChecker = Objects.requireNonNull(complianceChecker, "complianceChecker"); + this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); + this.maxAttempts = maxAttempts; + this.templateContentLoader = Objects.requireNonNull(templateContentLoader, "templateContentLoader"); + } + + /** + * Validates one negotiation message and extracts its parameters through the full pipeline. + * + * @param prompt rendered negotiation message text + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param reference template reference the message is validated against + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input}, + * {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, {@code llm.invocation_failed}, + * {@code llm.response_invalid} or {@code template.not_found} when the validation pipeline fails + */ + public FilledParamData extract( + String prompt, + @Nullable NegotiationContext context, + Map schema, + NegotiationReference reference) { + ValidationPipeline pipeline = new ValidationPipeline<>( + new NegotiationRuleCheckerAdapter(complianceChecker, context, reference.language()), + semanticValidator, + maxAttempts, + reference.language(), + templateContentLoader); + try { + return pipeline.validate(prompt, schema, reference); + } catch (ContentValidationException e) { + throw negotiationFailure(e, reference.language()); + } + } + + private static NegotiationParamExtractionException negotiationFailure( + ContentValidationException failure, String language) { + // The pipeline renders the final message itself; only the structured facts are reconstructed from the first + // slot detail that carries them, so callers keep the rendered message and gain the facts map. + ErrorCatalog entry = ErrorCatalog.byCode(failure.getCode()).orElse(null); + if (entry == null) { + throw new NegotiationParamExtractionException( + failure.getCode(), failure.getMessage(), failure.errors(), failure); + } + Map facts = failure.errors().stream() + .map(SlotValidationError::facts) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + return new NegotiationParamExtractionException( + entry.getCode(), failure.getMessage(), facts, failure.errors(), failure); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java index d667d32a..f2dbac06 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java @@ -1,35 +1,35 @@ -package net.openan.a2at.sdk.negotiation.validation; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.SlotValidationError; - -/** - * Outcome of the LLM-backed semantic validation of a negotiation message. - * - *

            The semantic validation step combines the semantic verdict, the negotiation type implied by the message sections, - * the structured semantic errors and the parameters extracted from the message per the caller-provided schema. - * - * @param verdict overall semantic verdict; {@code true} only when every semantic constraint holds - * @param negotiationType negotiation type implied by the message sections, one of {@code information}, {@code target} - * or {@code feasibility}; may be null when the verdict is false - * @param errors structured semantic errors using language-neutral {@code section.*} slot names; empty when the verdict - * is true - * @param params parameters extracted from the message per the caller-provided schema - * @since 2026-08 - */ -public record SemanticValidationResult( - boolean verdict, String negotiationType, List errors, Map params) { - - /** - * Normalizes the errors and the extracted parameters. - * - * @throws NullPointerException if the errors list or the params map is null - */ - public SemanticValidationResult { - errors = List.copyOf(errors); - params = Collections.unmodifiableMap(new LinkedHashMap<>(params)); - } -} +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.SlotValidationError; + +/** + * Outcome of the LLM-backed semantic validation of a negotiation message. + * + *

            The semantic validation step combines the semantic verdict, the negotiation type implied by the message sections, + * the structured semantic errors and the parameters extracted from the message per the caller-provided schema. + * + * @param verdict overall semantic verdict; {@code true} only when every semantic constraint holds + * @param negotiationType negotiation type implied by the message sections, one of {@code information}, {@code target} + * or {@code feasibility}; may be null when the verdict is false + * @param errors structured semantic errors using language-neutral {@code section.*} slot names; empty when the verdict + * is true + * @param params parameters extracted from the message per the caller-provided schema + * @since 2026-08 + */ +public record SemanticValidationResult( + boolean verdict, String negotiationType, List errors, Map params) { + + /** + * Normalizes the errors and the extracted parameters. + * + * @throws NullPointerException if the errors list or the params map is null + */ + public SemanticValidationResult { + errors = List.copyOf(errors); + params = Collections.unmodifiableMap(new LinkedHashMap<>(params)); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/package-info.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/package-info.java index a1cac51c..353b225e 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/package-info.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/package-info.java @@ -1,2 +1,2 @@ -/** Rule and semantic validation of rendered negotiation messages plus parameter extraction. */ -package net.openan.a2at.sdk.negotiation.validation; +/** Rule and semantic validation of rendered negotiation messages plus parameter extraction. */ +package net.openan.a2at.sdk.negotiation.validation; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/DeprecatedAnnotationScanTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/DeprecatedAnnotationScanTest.java index 4b5ecd20..d47c4f7f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/DeprecatedAnnotationScanTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/DeprecatedAnnotationScanTest.java @@ -1,64 +1,64 @@ -package net.openan.a2at.sdk.negotiation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.stream.Stream; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.Test; - -/** - * Guards the compatibility promise of the negotiation content layer: no API of the pre-existing negotiation state - * machine may gain a {@code @Deprecated} marker now that the content layer exists. The scan pins the exact set of - * pre-existing {@code @Deprecated} annotations of the module main sources, so any newly added annotation fails the - * test. - */ -class DeprecatedAnnotationScanTest { - - private static final Path MAIN_SOURCES = Path.of("src", "main", "java"); - - /** The only @Deprecated markers that existed before the negotiation content layer was added. */ - private static final List PINNED_DEPRECATED_ANNOTATIONS = - List.of("net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java"); - - @Test - void noNewDeprecatedAnnotationsWereAddedToTheModuleMainSources() throws IOException { - Assumptions.assumeTrue( - Files.isDirectory(MAIN_SOURCES), "module main sources are not visible from the test working directory"); - - List annotatedFiles; - try (Stream sources = Files.walk(MAIN_SOURCES)) { - annotatedFiles = sources.filter(Files::isRegularFile) - .filter(DeprecatedAnnotationScanTest::containsDeprecatedAnnotation) - .map(path -> MAIN_SOURCES.relativize(path).toString().replace('\\', '/')) - .sorted() - .toList(); - } - - assertEquals( - PINNED_DEPRECATED_ANNOTATIONS, - annotatedFiles, - "the negotiation module must not gain new @Deprecated annotations; the pinned set is the" - + " pre-content-layer baseline"); - assertTrue( - annotatedFiles.stream() - .noneMatch(file -> file.startsWith("content") - || file.startsWith("generation") - || file.startsWith("validation") - || file.startsWith("resources")), - "none of the negotiation content layer packages may carry @Deprecated annotations"); - } - - private static boolean containsDeprecatedAnnotation(Path file) { - try { - return Files.readString(file, StandardCharsets.UTF_8).contains("@Deprecated"); - } catch (IOException exception) { - return false; - } - } -} +package net.openan.a2at.sdk.negotiation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +/** + * Guards the compatibility promise of the negotiation content layer: no API of the pre-existing negotiation state + * machine may gain a {@code @Deprecated} marker now that the content layer exists. The scan pins the exact set of + * pre-existing {@code @Deprecated} annotations of the module main sources, so any newly added annotation fails the + * test. + */ +class DeprecatedAnnotationScanTest { + + private static final Path MAIN_SOURCES = Path.of("src", "main", "java"); + + /** The only @Deprecated markers that existed before the negotiation content layer was added. */ + private static final List PINNED_DEPRECATED_ANNOTATIONS = + List.of("net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java"); + + @Test + void noNewDeprecatedAnnotationsWereAddedToTheModuleMainSources() throws IOException { + Assumptions.assumeTrue( + Files.isDirectory(MAIN_SOURCES), "module main sources are not visible from the test working directory"); + + List annotatedFiles; + try (Stream sources = Files.walk(MAIN_SOURCES)) { + annotatedFiles = sources.filter(Files::isRegularFile) + .filter(DeprecatedAnnotationScanTest::containsDeprecatedAnnotation) + .map(path -> MAIN_SOURCES.relativize(path).toString().replace('\\', '/')) + .sorted() + .toList(); + } + + assertEquals( + PINNED_DEPRECATED_ANNOTATIONS, + annotatedFiles, + "the negotiation module must not gain new @Deprecated annotations; the pinned set is the" + + " pre-content-layer baseline"); + assertTrue( + annotatedFiles.stream() + .noneMatch(file -> file.startsWith("content") + || file.startsWith("generation") + || file.startsWith("validation") + || file.startsWith("resources")), + "none of the negotiation content layer packages may carry @Deprecated annotations"); + } + + private static boolean containsDeprecatedAnnotation(Path file) { + try { + return Files.readString(file, StandardCharsets.UTF_8).contains("@Deprecated"); + } catch (IOException exception) { + return false; + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java index f40a4fe5..64c8eb4e 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java @@ -1,111 +1,111 @@ -package net.openan.a2at.sdk.negotiation.content; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Arrays; -import java.util.List; -import net.openan.a2at.sdk.core.exception.A2ATBusinessException; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import org.junit.jupiter.api.Test; - -class NegotiationExceptionHierarchyTest { - - private static final Class[] NEGOTIATION_EXCEPTION_TYPES = { - NegotiationProcessingException.class, - NegotiationGenerationException.class, - NegotiationParamExtractionException.class - }; - - @Test - void processingExceptionIsABusinessFailureWithCode() { - NegotiationProcessingException exception = - new NegotiationProcessingException(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), "invalid input"); - - assertTrue(exception instanceof A2ATBusinessException); - assertTrue(exception instanceof A2ATError); - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); - } - - @Test - void processingExceptionRendersItsMessageFromTheCatalogTemplate() { - NegotiationProcessingException exception = new NegotiationProcessingException( - ErrorCatalog.NEGOTIATION_CONTENT_INVALID, - "zh-CN", - java.util.Map.of("field", "content.terminationReason", "reason", "blank")); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); - assertEquals("协商内容字段「content.terminationReason」无效:blank", exception.getMessage()); - assertEquals("content.terminationReason", exception.getFacts().get("field")); - } - - @Test - void processingExceptionInheritsGetCodeFromTheA2ATErrorRoot() { - NegotiationProcessingException exception = - new NegotiationProcessingException(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), "invalid input"); - - assertEquals( - A2ATError.class, - findGetMethod(NegotiationProcessingException.class).getDeclaringClass(), - "the code accessor must come from the A2ATError root so every negotiation failure shares one code" - + " contract"); - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); - } - - @Test - void generationExceptionExtendsProcessingException() { - NegotiationGenerationException exception = - new NegotiationGenerationException(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), "missing field"); - - assertTrue(exception instanceof NegotiationProcessingException); - assertTrue(exception instanceof A2ATBusinessException); - assertTrue(exception instanceof A2ATError); - assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); - } - - @Test - void paramExtractionExceptionIsABusinessFailureCarryingSlotDetails() { - NegotiationParamExtractionException codedException = new NegotiationParamExtractionException( - ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), "rule violated", List.of()); - - assertTrue(codedException instanceof A2ATBusinessException); - assertTrue(codedException instanceof A2ATError); - assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), codedException.getCode()); - assertTrue(codedException.getErrors().isEmpty()); - } - - @Test - void paramExtractionExceptionRendersItsMessageAndKeepsTheCause() { - IllegalStateException cause = new IllegalStateException("endpoint unavailable"); - NegotiationParamExtractionException exception = new NegotiationParamExtractionException( - ErrorCatalog.LLM_INVOCATION_FAILED, - "zh-CN", - java.util.Map.of("provider", "OpenAIClient", "reason", "endpoint unavailable"), - List.of(), - cause); - - assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), exception.getCode()); - assertEquals("LLM 调用失败(提供方 OpenAIClient):endpoint unavailable", exception.getMessage()); - assertEquals("endpoint unavailable", exception.getFacts().get("reason")); - assertEquals(cause, exception.getCause()); - } - - @Test - void noNegotiationExceptionExposesAStageProperty() { - for (Class exceptionType : NEGOTIATION_EXCEPTION_TYPES) { - boolean exposesStage = Arrays.stream(exceptionType.getMethods()) - .anyMatch(method -> "getStage".equals(method.getName()) || "stage".equals(method.getName())); - assertFalse(exposesStage, exceptionType.getSimpleName() + " must not expose a stage property"); - } - } - - private static java.lang.reflect.Method findGetMethod(Class exceptionType) { - try { - return exceptionType.getMethod("getCode"); - } catch (NoSuchMethodException exception) { - throw new AssertionError("getCode must be inherited from A2ATError", exception); - } - } -} +package net.openan.a2at.sdk.negotiation.content; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.List; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import org.junit.jupiter.api.Test; + +class NegotiationExceptionHierarchyTest { + + private static final Class[] NEGOTIATION_EXCEPTION_TYPES = { + NegotiationProcessingException.class, + NegotiationGenerationException.class, + NegotiationParamExtractionException.class + }; + + @Test + void processingExceptionIsABusinessFailureWithCode() { + NegotiationProcessingException exception = + new NegotiationProcessingException(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), "invalid input"); + + assertTrue(exception instanceof A2ATBusinessException); + assertTrue(exception instanceof A2ATError); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); + } + + @Test + void processingExceptionRendersItsMessageFromTheCatalogTemplate() { + NegotiationProcessingException exception = new NegotiationProcessingException( + ErrorCatalog.NEGOTIATION_CONTENT_INVALID, + "zh-CN", + java.util.Map.of("field", "content.terminationReason", "reason", "blank")); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals("协商内容字段「content.terminationReason」无效:blank", exception.getMessage()); + assertEquals("content.terminationReason", exception.getFacts().get("field")); + } + + @Test + void processingExceptionInheritsGetCodeFromTheA2ATErrorRoot() { + NegotiationProcessingException exception = + new NegotiationProcessingException(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), "invalid input"); + + assertEquals( + A2ATError.class, + findGetMethod(NegotiationProcessingException.class).getDeclaringClass(), + "the code accessor must come from the A2ATError root so every negotiation failure shares one code" + + " contract"); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); + } + + @Test + void generationExceptionExtendsProcessingException() { + NegotiationGenerationException exception = + new NegotiationGenerationException(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), "missing field"); + + assertTrue(exception instanceof NegotiationProcessingException); + assertTrue(exception instanceof A2ATBusinessException); + assertTrue(exception instanceof A2ATError); + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); + } + + @Test + void paramExtractionExceptionIsABusinessFailureCarryingSlotDetails() { + NegotiationParamExtractionException codedException = new NegotiationParamExtractionException( + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), "rule violated", List.of()); + + assertTrue(codedException instanceof A2ATBusinessException); + assertTrue(codedException instanceof A2ATError); + assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), codedException.getCode()); + assertTrue(codedException.getErrors().isEmpty()); + } + + @Test + void paramExtractionExceptionRendersItsMessageAndKeepsTheCause() { + IllegalStateException cause = new IllegalStateException("endpoint unavailable"); + NegotiationParamExtractionException exception = new NegotiationParamExtractionException( + ErrorCatalog.LLM_INVOCATION_FAILED, + "zh-CN", + java.util.Map.of("provider", "OpenAIClient", "reason", "endpoint unavailable"), + List.of(), + cause); + + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), exception.getCode()); + assertEquals("LLM 调用失败(提供方 OpenAIClient):endpoint unavailable", exception.getMessage()); + assertEquals("endpoint unavailable", exception.getFacts().get("reason")); + assertEquals(cause, exception.getCause()); + } + + @Test + void noNegotiationExceptionExposesAStageProperty() { + for (Class exceptionType : NEGOTIATION_EXCEPTION_TYPES) { + boolean exposesStage = Arrays.stream(exceptionType.getMethods()) + .anyMatch(method -> "getStage".equals(method.getName()) || "stage".equals(method.getName())); + assertFalse(exposesStage, exceptionType.getSimpleName() + " must not expose a stage property"); + } + } + + private static java.lang.reflect.Method findGetMethod(Class exceptionType) { + try { + return exceptionType.getMethod("getCode"); + } catch (NoSuchMethodException exception) { + throw new AssertionError("getCode must be inherited from A2ATError", exception); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java index 6437a067..26f7d24f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java @@ -1,164 +1,164 @@ -package net.openan.a2at.sdk.negotiation.content; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Set; -import java.util.TreeSet; -import org.junit.jupiter.api.Test; - -class VocabularyTest { - - private static final Set CANONICAL_KEYS = Set.of( - "section.termination_reason", - "section.info_items", - "section.info_static", - "section.info_conclusion", - "section.info_result_content", - "section.target", - "section.target_intent", - "section.target_alignment", - "section.target_clarification", - "section.target_confirm_request", - "section.target_conclusion", - "section.target_result_content", - "section.feasibility", - "section.feasibility_evaluate", - "section.feasibility_infeasible", - "section.feasibility_confirm_request", - "section.feasibility_conclusion", - "section.feasibility_confirm", - "slot.termination_reason", - "slot.info_items", - "slot.info_conclusion", - "slot.info_result_content", - "slot.feasibility", - "slot.target", - "slot.target_intent", - "slot.target_alignment", - "slot.target_clarification", - "slot.target_confirm_request", - "slot.target_conclusion", - "slot.target_result_content", - "slot.feasibility_evaluate", - "slot.feasibility_infeasible", - "slot.feasibility_confirm_request", - "slot.feasibility_conclusion", - "slot.feasibility_confirm", - "label.relationship", - "punct.list_colon"); - - @Test - void bothLanguagesExposeExactlyTheCanonicalKeys() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - assertEquals(CANONICAL_KEYS, zhCn.canonicalKeys()); - assertEquals(CANONICAL_KEYS, enUs.canonicalKeys()); - assertEquals(Set.copyOf(Vocabulary.CANONICAL_KEYS), zhCn.canonicalKeys()); - assertEquals(new TreeSet<>(zhCn.canonicalKeys()), new TreeSet<>(enUs.canonicalKeys())); - assertEquals(37, zhCn.canonicalKeys().size()); - assertEquals(37, Vocabulary.CANONICAL_KEYS.size()); - } - - @Test - void terminationReasonSlotMatchesTheCommonAbortTemplatePlaceholder() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - assertEquals("协商终止原因", zhCn.get("slot.termination_reason")); - assertEquals("negotiation_termination_reason", enUs.get("slot.termination_reason")); - assertEquals("协商终止原因", zhCn.get("section.termination_reason")); - assertEquals("Negotiation Termination Reason", enUs.get("section.termination_reason")); - } - - @Test - void slotPlaceholdersAreSnakeCaseForEnglishAndCjkForChinese() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - // The placeholders are snake_case for en-US and the matching CJK section title for zh-CN. - assertEquals("意图理解陈述", zhCn.get("slot.target_intent")); - assertEquals("intent_understanding_statement", enUs.get("slot.target_intent")); - } - - @Test - void summaryAndConfirmSlotsDifferFromTheirSectionTitle() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - assertEquals("可行性协商概述", zhCn.get("slot.feasibility")); - assertEquals("feasibility_negotiation_summary", enUs.get("slot.feasibility")); - assertEquals("目标协商概述", zhCn.get("slot.target")); - assertEquals("target_negotiation_summary", enUs.get("slot.target")); - assertEquals("评估结果确认", zhCn.get("slot.feasibility_confirm")); - assertEquals("evaluation_result_confirmation", enUs.get("slot.feasibility_confirm")); - } - - @Test - void sectionValuesMatchTemplateSectionTitles() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - assertEquals("目标协商结果内容", zhCn.get("section.target_result_content")); - assertEquals("Target Negotiation Result Content", enUs.get("section.target_result_content")); - assertEquals("可行性评估结果确认", zhCn.get("section.feasibility_confirm")); - assertEquals("Feasibility Assessment Result Confirmation", enUs.get("section.feasibility_confirm")); - } - - @Test - void confirmRequestSectionAndSlotKeysMatchTheNewTemplates() { - Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); - Vocabulary enUs = Vocabulary.forLanguage("en-US"); - - assertEquals("目标澄清后的确认请求", zhCn.get("section.target_confirm_request")); - assertEquals("Target Clarification Confirmation Request", enUs.get("section.target_confirm_request")); - assertEquals("目标澄清后的确认请求", zhCn.get("slot.target_confirm_request")); - assertEquals("target_confirm_request", enUs.get("slot.target_confirm_request")); - assertEquals("评估可行时的确认请求", zhCn.get("section.feasibility_confirm_request")); - assertEquals("Feasible Evaluation Confirmation Request", enUs.get("section.feasibility_confirm_request")); - assertEquals("评估可行时的确认请求", zhCn.get("slot.feasibility_confirm_request")); - assertEquals("feasibility_confirm_request", enUs.get("slot.feasibility_confirm_request")); - } - - @Test - void englishRelationshipLabelCarriesOneTrailingSpace() { - String englishLabel = Vocabulary.forLanguage("en-US").get("label.relationship"); - String chineseLabel = Vocabulary.forLanguage("zh-CN").get("label.relationship"); - - assertEquals("Relationship between missing items: ", englishLabel); - assertTrue(englishLabel.endsWith(" ")); - assertEquals("缺失项之间的关系:", chineseLabel); - } - - @Test - void listColonPunctuationDiffersPerLanguage() { - assertEquals(":", Vocabulary.forLanguage("zh-CN").get("punct.list_colon")); - assertEquals(": ", Vocabulary.forLanguage("en-US").get("punct.list_colon")); - } - - @Test - void unsupportedLanguageThrows() { - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("fr-FR")); - - assertTrue(exception.getMessage().contains("fr-FR")); - assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); - } - - @Test - void unknownKeyThrows() { - Vocabulary vocabulary = Vocabulary.forLanguage("zh-CN"); - - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> vocabulary.get("section.unknown")); - - assertTrue(exception.getMessage().contains("section.unknown")); - } - - @Test - void languageThatIsNotASimplePathSegmentThrows() { - assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("../escape")); - } -} +package net.openan.a2at.sdk.negotiation.content; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; +import java.util.TreeSet; +import org.junit.jupiter.api.Test; + +class VocabularyTest { + + private static final Set CANONICAL_KEYS = Set.of( + "section.termination_reason", + "section.info_items", + "section.info_static", + "section.info_conclusion", + "section.info_result_content", + "section.target", + "section.target_intent", + "section.target_alignment", + "section.target_clarification", + "section.target_confirm_request", + "section.target_conclusion", + "section.target_result_content", + "section.feasibility", + "section.feasibility_evaluate", + "section.feasibility_infeasible", + "section.feasibility_confirm_request", + "section.feasibility_conclusion", + "section.feasibility_confirm", + "slot.termination_reason", + "slot.info_items", + "slot.info_conclusion", + "slot.info_result_content", + "slot.feasibility", + "slot.target", + "slot.target_intent", + "slot.target_alignment", + "slot.target_clarification", + "slot.target_confirm_request", + "slot.target_conclusion", + "slot.target_result_content", + "slot.feasibility_evaluate", + "slot.feasibility_infeasible", + "slot.feasibility_confirm_request", + "slot.feasibility_conclusion", + "slot.feasibility_confirm", + "label.relationship", + "punct.list_colon"); + + @Test + void bothLanguagesExposeExactlyTheCanonicalKeys() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals(CANONICAL_KEYS, zhCn.canonicalKeys()); + assertEquals(CANONICAL_KEYS, enUs.canonicalKeys()); + assertEquals(Set.copyOf(Vocabulary.CANONICAL_KEYS), zhCn.canonicalKeys()); + assertEquals(new TreeSet<>(zhCn.canonicalKeys()), new TreeSet<>(enUs.canonicalKeys())); + assertEquals(37, zhCn.canonicalKeys().size()); + assertEquals(37, Vocabulary.CANONICAL_KEYS.size()); + } + + @Test + void terminationReasonSlotMatchesTheCommonAbortTemplatePlaceholder() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("协商终止原因", zhCn.get("slot.termination_reason")); + assertEquals("negotiation_termination_reason", enUs.get("slot.termination_reason")); + assertEquals("协商终止原因", zhCn.get("section.termination_reason")); + assertEquals("Negotiation Termination Reason", enUs.get("section.termination_reason")); + } + + @Test + void slotPlaceholdersAreSnakeCaseForEnglishAndCjkForChinese() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + // The placeholders are snake_case for en-US and the matching CJK section title for zh-CN. + assertEquals("意图理解陈述", zhCn.get("slot.target_intent")); + assertEquals("intent_understanding_statement", enUs.get("slot.target_intent")); + } + + @Test + void summaryAndConfirmSlotsDifferFromTheirSectionTitle() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("可行性协商概述", zhCn.get("slot.feasibility")); + assertEquals("feasibility_negotiation_summary", enUs.get("slot.feasibility")); + assertEquals("目标协商概述", zhCn.get("slot.target")); + assertEquals("target_negotiation_summary", enUs.get("slot.target")); + assertEquals("评估结果确认", zhCn.get("slot.feasibility_confirm")); + assertEquals("evaluation_result_confirmation", enUs.get("slot.feasibility_confirm")); + } + + @Test + void sectionValuesMatchTemplateSectionTitles() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("目标协商结果内容", zhCn.get("section.target_result_content")); + assertEquals("Target Negotiation Result Content", enUs.get("section.target_result_content")); + assertEquals("可行性评估结果确认", zhCn.get("section.feasibility_confirm")); + assertEquals("Feasibility Assessment Result Confirmation", enUs.get("section.feasibility_confirm")); + } + + @Test + void confirmRequestSectionAndSlotKeysMatchTheNewTemplates() { + Vocabulary zhCn = Vocabulary.forLanguage("zh-CN"); + Vocabulary enUs = Vocabulary.forLanguage("en-US"); + + assertEquals("目标澄清后的确认请求", zhCn.get("section.target_confirm_request")); + assertEquals("Target Clarification Confirmation Request", enUs.get("section.target_confirm_request")); + assertEquals("目标澄清后的确认请求", zhCn.get("slot.target_confirm_request")); + assertEquals("target_confirm_request", enUs.get("slot.target_confirm_request")); + assertEquals("评估可行时的确认请求", zhCn.get("section.feasibility_confirm_request")); + assertEquals("Feasible Evaluation Confirmation Request", enUs.get("section.feasibility_confirm_request")); + assertEquals("评估可行时的确认请求", zhCn.get("slot.feasibility_confirm_request")); + assertEquals("feasibility_confirm_request", enUs.get("slot.feasibility_confirm_request")); + } + + @Test + void englishRelationshipLabelCarriesOneTrailingSpace() { + String englishLabel = Vocabulary.forLanguage("en-US").get("label.relationship"); + String chineseLabel = Vocabulary.forLanguage("zh-CN").get("label.relationship"); + + assertEquals("Relationship between missing items: ", englishLabel); + assertTrue(englishLabel.endsWith(" ")); + assertEquals("缺失项之间的关系:", chineseLabel); + } + + @Test + void listColonPunctuationDiffersPerLanguage() { + assertEquals(":", Vocabulary.forLanguage("zh-CN").get("punct.list_colon")); + assertEquals(": ", Vocabulary.forLanguage("en-US").get("punct.list_colon")); + } + + @Test + void unsupportedLanguageThrows() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("fr-FR")); + + assertTrue(exception.getMessage().contains("fr-FR")); + assertTrue(exception.getMessage().contains("A2AT_LANGUAGE")); + } + + @Test + void unknownKeyThrows() { + Vocabulary vocabulary = Vocabulary.forLanguage("zh-CN"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> vocabulary.get("section.unknown")); + + assertTrue(exception.getMessage().contains("section.unknown")); + } + + @Test + void languageThatIsNotASimplePathSegmentThrows() { + assertThrows(IllegalArgumentException.class, () -> Vocabulary.forLanguage("../escape")); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java index a1c91063..e93bfe1d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java @@ -1,126 +1,126 @@ -package net.openan.a2at.sdk.negotiation.contract; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.junit.jupiter.api.Test; - -/** - * Guards the delivered negotiation resources and the error-code constant registry. - * - *

            The twelve templates resolved from the test classpath must be byte-identical to the delivered template files of - * the working tree, so that no packaging or formatting step can silently rewrite the authoritative template bytes. The - * deprecated error-code constants must stay exactly the pinned forwarding registry: each constant forwards to the - * layered {@code domain.semantic} code of its {@link net.openan.a2at.sdk.core.exception.ErrorCatalog} replacement. - */ -class NegotiationDeliveredResourceContractTest { - - private static final List TYPE_ORDER = - List.of(NegotiationType.INFORMATION, NegotiationType.TARGET, NegotiationType.FEASIBILITY); - - private static final List PERFORMATIVE_ORDER = - List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT); - - private static final List LANGUAGES = List.of("zh-CN", "en-US"); - - @Test - void everyPackagedTemplateIsByteIdenticalToTheDeliveredFile() throws IOException { - List mismatches = new ArrayList<>(); - int compared = 0; - for (NegotiationType type : TYPE_ORDER) { - for (NegotiationPerformative performative : PERFORMATIVE_ORDER) { - for (String language : LANGUAGES) { - String relativePath = String.join( - "/", - "templates", - "Negotiation-T", - type.typeSegment(), - NegotiationReference.uriSegmentOf(performative), - "v1", - language, - "template.md"); - byte[] packaged = readClasspathBytes("prompt_resources/" + relativePath); - byte[] delivered = - Files.readAllBytes(deliveredResourceRoot().resolve(relativePath)); - compared++; - if (!java.util.Arrays.equals(packaged, delivered)) { - mismatches.add(relativePath); - } - } - } - } - assertEquals(12, compared, "exactly the twelve negotiation templates must be compared"); - assertTrue( - mismatches.isEmpty(), "packaged templates must match the delivered files byte for byte: " + mismatches); - } - - @Test - void errorCodeConstantsStayExactlyThePinnedRegistry() { - java.util.Map expected = new java.util.LinkedHashMap<>(); - expected.put("SDK_INTERNAL_ERROR", "infra.internal_error"); - expected.put("INPUT_TEXT_TOO_LONG", "input.text_too_long"); - expected.put("PARAM_EXTRACTION_FAILED", "slot.not_provided"); - expected.put("TEMPLATE_NOT_FOUND", "template.not_found"); - expected.put("NEGOTIATION_CONTENT_EXTRACT_FAILED", "negotiation.content_extract_failed"); - expected.put("NEGOTIATION_SEMANTIC_REJECTED", "negotiation.semantic_rejected"); - expected.put("NEGOTIATION_RULE_VIOLATION", "negotiation.rule_violation"); - expected.put("NEGOTIATION_SLOT_MISSING", "negotiation.field_missing"); - expected.put("NEGOTIATION_INVALID_INPUT", "negotiation.invalid_input"); - expected.put("NEGOTIATION_LLM_INFRASTRUCTURE_ERROR", "llm.invocation_failed"); - expected.put("VALIDATION_INVALID_INPUT", "negotiation.invalid_input"); - expected.put("VALIDATION_RULE_VIOLATION", "negotiation.rule_violation"); - expected.put("VALIDATION_SEMANTIC_REJECTED", "negotiation.semantic_rejected"); - expected.put("VALIDATION_LLM_INFRASTRUCTURE_ERROR", "llm.invocation_failed"); - expected.put("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", "template.not_found"); - expected.put("PROMPT_RESOURCE_LOAD_ERROR", "template.load_failed"); - expected.put("SLOT_SCHEMA_NOT_FOUND", "slot.schema_not_found"); - expected.put("LLM_INVOCATION_FAILED", "llm.invocation_failed"); - expected.put("RENDER_FAILED", "template.render_failed"); - expected.put("SLOT_VALIDATION_ERROR", "slot.rule_violation"); - expected.put("PROCESSED_PROMPT_PARSE_ERROR", "scenario.not_matched"); - - java.util.Map actual = new java.util.LinkedHashMap<>(); - for (java.lang.reflect.Field field : A2ATErrorCodes.class.getDeclaredFields()) { - if (field.getType() == String.class && java.lang.reflect.Modifier.isStatic(field.getModifiers())) { - field.setAccessible(true); - try { - actual.put(field.getName(), (String) field.get(null)); - } catch (IllegalAccessException exception) { - throw new IllegalStateException("Failed to read the constant " + field.getName(), exception); - } - } - } - - assertEquals( - expected, - actual, - "the deprecated forwarding registry is locked; adding or changing constants must update this" - + " test"); - assertTrue( - !actual.containsValue("negotiation_type_unrecognized"), - "the removed type-recognition code must not reappear"); - } - - private static byte[] readClasspathBytes(String classpathPath) throws IOException { - InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(classpathPath); - assertNotNull(stream, "the resource must exist on the test classpath: " + classpathPath); - try (stream) { - return stream.readAllBytes(); - } - } - - private static Path deliveredResourceRoot() { - return Path.of("..", "a2a-t-resources", "src", "main", "resources", "prompt_resources"); - } -} +package net.openan.a2at.sdk.negotiation.contract; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +/** + * Guards the delivered negotiation resources and the error-code constant registry. + * + *

            The twelve templates resolved from the test classpath must be byte-identical to the delivered template files of + * the working tree, so that no packaging or formatting step can silently rewrite the authoritative template bytes. The + * deprecated error-code constants must stay exactly the pinned forwarding registry: each constant forwards to the + * layered {@code domain.semantic} code of its {@link net.openan.a2at.sdk.core.exception.ErrorCatalog} replacement. + */ +class NegotiationDeliveredResourceContractTest { + + private static final List TYPE_ORDER = + List.of(NegotiationType.INFORMATION, NegotiationType.TARGET, NegotiationType.FEASIBILITY); + + private static final List PERFORMATIVE_ORDER = + List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT); + + private static final List LANGUAGES = List.of("zh-CN", "en-US"); + + @Test + void everyPackagedTemplateIsByteIdenticalToTheDeliveredFile() throws IOException { + List mismatches = new ArrayList<>(); + int compared = 0; + for (NegotiationType type : TYPE_ORDER) { + for (NegotiationPerformative performative : PERFORMATIVE_ORDER) { + for (String language : LANGUAGES) { + String relativePath = String.join( + "/", + "templates", + "Negotiation-T", + type.typeSegment(), + NegotiationReference.uriSegmentOf(performative), + "v1", + language, + "template.md"); + byte[] packaged = readClasspathBytes("prompt_resources/" + relativePath); + byte[] delivered = + Files.readAllBytes(deliveredResourceRoot().resolve(relativePath)); + compared++; + if (!java.util.Arrays.equals(packaged, delivered)) { + mismatches.add(relativePath); + } + } + } + } + assertEquals(12, compared, "exactly the twelve negotiation templates must be compared"); + assertTrue( + mismatches.isEmpty(), "packaged templates must match the delivered files byte for byte: " + mismatches); + } + + @Test + void errorCodeConstantsStayExactlyThePinnedRegistry() { + java.util.Map expected = new java.util.LinkedHashMap<>(); + expected.put("SDK_INTERNAL_ERROR", "infra.internal_error"); + expected.put("INPUT_TEXT_TOO_LONG", "input.text_too_long"); + expected.put("PARAM_EXTRACTION_FAILED", "slot.not_provided"); + expected.put("TEMPLATE_NOT_FOUND", "template.not_found"); + expected.put("NEGOTIATION_CONTENT_EXTRACT_FAILED", "negotiation.content_extract_failed"); + expected.put("NEGOTIATION_SEMANTIC_REJECTED", "negotiation.semantic_rejected"); + expected.put("NEGOTIATION_RULE_VIOLATION", "negotiation.rule_violation"); + expected.put("NEGOTIATION_SLOT_MISSING", "negotiation.field_missing"); + expected.put("NEGOTIATION_INVALID_INPUT", "negotiation.invalid_input"); + expected.put("NEGOTIATION_LLM_INFRASTRUCTURE_ERROR", "llm.invocation_failed"); + expected.put("VALIDATION_INVALID_INPUT", "negotiation.invalid_input"); + expected.put("VALIDATION_RULE_VIOLATION", "negotiation.rule_violation"); + expected.put("VALIDATION_SEMANTIC_REJECTED", "negotiation.semantic_rejected"); + expected.put("VALIDATION_LLM_INFRASTRUCTURE_ERROR", "llm.invocation_failed"); + expected.put("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", "template.not_found"); + expected.put("PROMPT_RESOURCE_LOAD_ERROR", "template.load_failed"); + expected.put("SLOT_SCHEMA_NOT_FOUND", "slot.schema_not_found"); + expected.put("LLM_INVOCATION_FAILED", "llm.invocation_failed"); + expected.put("RENDER_FAILED", "template.render_failed"); + expected.put("SLOT_VALIDATION_ERROR", "slot.rule_violation"); + expected.put("PROCESSED_PROMPT_PARSE_ERROR", "scenario.not_matched"); + + java.util.Map actual = new java.util.LinkedHashMap<>(); + for (java.lang.reflect.Field field : A2ATErrorCodes.class.getDeclaredFields()) { + if (field.getType() == String.class && java.lang.reflect.Modifier.isStatic(field.getModifiers())) { + field.setAccessible(true); + try { + actual.put(field.getName(), (String) field.get(null)); + } catch (IllegalAccessException exception) { + throw new IllegalStateException("Failed to read the constant " + field.getName(), exception); + } + } + } + + assertEquals( + expected, + actual, + "the deprecated forwarding registry is locked; adding or changing constants must update this" + + " test"); + assertTrue( + !actual.containsValue("negotiation_type_unrecognized"), + "the removed type-recognition code must not reappear"); + } + + private static byte[] readClasspathBytes(String classpathPath) throws IOException { + InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(classpathPath); + assertNotNull(stream, "the resource must exist on the test classpath: " + classpathPath); + try (stream) { + return stream.readAllBytes(); + } + } + + private static Path deliveredResourceRoot() { + return Path.of("..", "a2a-t-resources", "src", "main", "resources", "prompt_resources"); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java index 137cc9ea..c8cce0ef 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -1,150 +1,150 @@ -package net.openan.a2at.sdk.negotiation.contract; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; - -/** - * Guards the package layout of the negotiation content layer. - * - *

            The four content-layer packages carry exactly the pinned set of compilation units and no subpackages, so that the - * internal layering (models and vocabulary, resources, generation, validation) cannot drift silently. The pre-existing - * legacy packages of the old negotiation state machine are not pinned by this guard. - */ -class NegotiationPackageStructureTest { - - @Test - void contentPackageExposesExactlyThePinnedTypes() throws IOException { - assertEquals( - List.of( - "FeasibilityEndingContent.java", - "FeasibilityProposeContent.java", - "InformationEndingContent.java", - "InformationProposeContent.java", - "NegotiationAbortContent.java", - "NegotiationAbortData.java", - "NegotiationAction.java", - "NegotiationConclusion.java", - "NegotiationContent.java", - "NegotiationEndingContent.java", - "NegotiationEndingData.java", - "NegotiationGenerationException.java", - "NegotiationItem.java", - "NegotiationParamExtractionException.java", - "NegotiationProcessingException.java", - "NegotiationProposeContent.java", - "NegotiationProposeData.java", - "NegotiationType.java", - "TargetEndingContent.java", - "TargetProposeContent.java", - "Vocabulary.java", - "package-info.java"), - topLevelJavaFiles(negotiationRoot().resolve("content"))); - assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("content"))); - } - - @Test - void resourcesPackageExposesExactlyThePinnedTypes() throws IOException { - assertEquals( - List.of( - "DefaultNegotiationTemplateLoader.java", - "NegotiationReference.java", - "NegotiationTemplateLoader.java", - "package-info.java"), - topLevelJavaFiles(negotiationRoot().resolve("resources"))); - assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("resources"))); - } - - @Test - void generationPackageExposesExactlyThePinnedTypes() throws IOException { - assertEquals( - List.of( - "AbortGenerator.java", - "AbstractNegotiationGenerator.java", - "DefaultNegotiationContentExtractor.java", - "FeasibilityEndingGenerator.java", - "FeasibilityProposeGenerator.java", - "InformationEndingGenerator.java", - "InformationProposeGenerator.java", - "NegotiationContentExtractor.java", - "NegotiationContentService.java", - "NegotiationGenerationOrchestrator.java", - "NegotiationGenerationOrchestratorBuilder.java", - "NegotiationGenerator.java", - "NegotiationGeneratorRegistry.java", - "NegotiationItemFormatter.java", - "NegotiationJsonSchemaBuilder.java", - "NegotiationMessageBuilder.java", - "NegotiationPromptRenderer.java", - "NegotiationPromptResourceLoader.java", - "NegotiationRenderException.java", - "TargetEndingGenerator.java", - "TargetProposeGenerator.java", - "package-info.java"), - topLevelJavaFiles(negotiationRoot().resolve("generation"))); - assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("generation"))); - } - - @Test - void validationPackageExposesExactlyThePinnedTypes() throws IOException { - assertEquals( - List.of( - "DefaultNegotiationComplianceChecker.java", - "DefaultNegotiationSemanticValidator.java", - "NegotiationComplianceChecker.java", - "NegotiationRuleCheckResult.java", - "NegotiationRuleCheckerAdapter.java", - "NegotiationSemanticValidator.java", - "NegotiationValidationException.java", - "ParamExtractor.java", - "SemanticValidationResult.java", - "package-info.java"), - topLevelJavaFiles(negotiationRoot().resolve("validation"))); - assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("validation"))); - } - - @Test - void contentLayerPackagesKeepNoUnexpectedSubpackages() { - Path root = negotiationRoot(); - List.of("content", "generation", "resources", "validation") - .forEach(packageName -> assertFalse( - hasSubdirectory(root.resolve(packageName)), - "the " + packageName + " package must not grow subpackages")); - } - - private static Path negotiationRoot() { - return Path.of("src", "main", "java", "net", "openan", "a2at", "sdk", "negotiation"); - } - - private static boolean hasSubdirectory(Path path) { - try (var files = Files.list(path)) { - return files.anyMatch(Files::isDirectory); - } catch (IOException exception) { - throw new IllegalStateException("Failed to list " + path, exception); - } - } - - private static List topLevelJavaFiles(Path path) throws IOException { - try (var files = Files.list(path)) { - return files.filter(file -> file.getFileName().toString().endsWith(".java")) - .map(file -> file.getFileName().toString()) - .sorted() - .collect(Collectors.toList()); - } - } - - private static List topLevelDirectories(Path path) throws IOException { - try (var files = Files.list(path)) { - return files.filter(Files::isDirectory) - .map(file -> file.getFileName().toString()) - .sorted() - .collect(Collectors.toList()); - } - } -} +package net.openan.a2at.sdk.negotiation.contract; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +/** + * Guards the package layout of the negotiation content layer. + * + *

            The four content-layer packages carry exactly the pinned set of compilation units and no subpackages, so that the + * internal layering (models and vocabulary, resources, generation, validation) cannot drift silently. The pre-existing + * legacy packages of the old negotiation state machine are not pinned by this guard. + */ +class NegotiationPackageStructureTest { + + @Test + void contentPackageExposesExactlyThePinnedTypes() throws IOException { + assertEquals( + List.of( + "FeasibilityEndingContent.java", + "FeasibilityProposeContent.java", + "InformationEndingContent.java", + "InformationProposeContent.java", + "NegotiationAbortContent.java", + "NegotiationAbortData.java", + "NegotiationAction.java", + "NegotiationConclusion.java", + "NegotiationContent.java", + "NegotiationEndingContent.java", + "NegotiationEndingData.java", + "NegotiationGenerationException.java", + "NegotiationItem.java", + "NegotiationParamExtractionException.java", + "NegotiationProcessingException.java", + "NegotiationProposeContent.java", + "NegotiationProposeData.java", + "NegotiationType.java", + "TargetEndingContent.java", + "TargetProposeContent.java", + "Vocabulary.java", + "package-info.java"), + topLevelJavaFiles(negotiationRoot().resolve("content"))); + assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("content"))); + } + + @Test + void resourcesPackageExposesExactlyThePinnedTypes() throws IOException { + assertEquals( + List.of( + "DefaultNegotiationTemplateLoader.java", + "NegotiationReference.java", + "NegotiationTemplateLoader.java", + "package-info.java"), + topLevelJavaFiles(negotiationRoot().resolve("resources"))); + assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("resources"))); + } + + @Test + void generationPackageExposesExactlyThePinnedTypes() throws IOException { + assertEquals( + List.of( + "AbortGenerator.java", + "AbstractNegotiationGenerator.java", + "DefaultNegotiationContentExtractor.java", + "FeasibilityEndingGenerator.java", + "FeasibilityProposeGenerator.java", + "InformationEndingGenerator.java", + "InformationProposeGenerator.java", + "NegotiationContentExtractor.java", + "NegotiationContentService.java", + "NegotiationGenerationOrchestrator.java", + "NegotiationGenerationOrchestratorBuilder.java", + "NegotiationGenerator.java", + "NegotiationGeneratorRegistry.java", + "NegotiationItemFormatter.java", + "NegotiationJsonSchemaBuilder.java", + "NegotiationMessageBuilder.java", + "NegotiationPromptRenderer.java", + "NegotiationPromptResourceLoader.java", + "NegotiationRenderException.java", + "TargetEndingGenerator.java", + "TargetProposeGenerator.java", + "package-info.java"), + topLevelJavaFiles(negotiationRoot().resolve("generation"))); + assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("generation"))); + } + + @Test + void validationPackageExposesExactlyThePinnedTypes() throws IOException { + assertEquals( + List.of( + "DefaultNegotiationComplianceChecker.java", + "DefaultNegotiationSemanticValidator.java", + "NegotiationComplianceChecker.java", + "NegotiationRuleCheckResult.java", + "NegotiationRuleCheckerAdapter.java", + "NegotiationSemanticValidator.java", + "NegotiationValidationException.java", + "ParamExtractor.java", + "SemanticValidationResult.java", + "package-info.java"), + topLevelJavaFiles(negotiationRoot().resolve("validation"))); + assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("validation"))); + } + + @Test + void contentLayerPackagesKeepNoUnexpectedSubpackages() { + Path root = negotiationRoot(); + List.of("content", "generation", "resources", "validation") + .forEach(packageName -> assertFalse( + hasSubdirectory(root.resolve(packageName)), + "the " + packageName + " package must not grow subpackages")); + } + + private static Path negotiationRoot() { + return Path.of("src", "main", "java", "net", "openan", "a2at", "sdk", "negotiation"); + } + + private static boolean hasSubdirectory(Path path) { + try (var files = Files.list(path)) { + return files.anyMatch(Files::isDirectory); + } catch (IOException exception) { + throw new IllegalStateException("Failed to list " + path, exception); + } + } + + private static List topLevelJavaFiles(Path path) throws IOException { + try (var files = Files.list(path)) { + return files.filter(file -> file.getFileName().toString().endsWith(".java")) + .map(file -> file.getFileName().toString()) + .sorted() + .collect(Collectors.toList()); + } + } + + private static List topLevelDirectories(Path path) throws IOException { + try (var files = Files.list(path)) { + return files.filter(Files::isDirectory) + .map(file -> file.getFileName().toString()) + .sorted() + .collect(Collectors.toList()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/SdkSourceCommentScanTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/SdkSourceCommentScanTest.java index 2491be30..d954ee3b 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/SdkSourceCommentScanTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/SdkSourceCommentScanTest.java @@ -1,69 +1,69 @@ -package net.openan.a2at.sdk.negotiation.contract; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Pattern; -import java.util.stream.Stream; -import org.junit.jupiter.api.Test; - -/** - * Scans the SDK main sources for references to internal design documents. - * - *

            Comments and javadoc of the shipped sources must be self-contained English text: they must not reference the - * internal design specification, internal document paths or numbered spec sections. The scan walks the main source - * trees of the core, negotiation, client and server modules and fails with the offending file and line. - */ -class SdkSourceCommentScanTest { - - private static final List FORBIDDEN_PATTERNS = List.of( - Pattern.compile("设计规范"), - Pattern.compile("design spec"), - Pattern.compile("docs-local"), - Pattern.compile("docs/feat"), - Pattern.compile("§\\d")); - - private static final List SCANNED_MODULE_ROOTS = List.of( - "../a2a-t-core/src/main/java", - "../a2a-t-negotiation/src/main/java", - "../a2a-t-client/src/main/java", - "../a2a-t-server/src/main/java"); - - @Test - void mainSourcesNeverReferenceInternalDesignDocuments() throws IOException { - List violations = new ArrayList<>(); - for (String moduleRoot : SCANNED_MODULE_ROOTS) { - Path root = Path.of(moduleRoot); - assertTrue(Files.isDirectory(root), "the scanned module root must exist: " + moduleRoot); - try (Stream files = Files.walk(root)) { - files.filter(file -> file.toString().endsWith(".java")) - .forEach(file -> collectViolations(file, violations)); - } - } - assertTrue( - violations.isEmpty(), - "main sources must not reference internal design documents: " + String.join("; ", violations)); - } - - private static void collectViolations(Path file, List violations) { - List lines; - try { - lines = Files.readAllLines(file, StandardCharsets.UTF_8); - } catch (IOException exception) { - throw new IllegalStateException("Failed to read " + file, exception); - } - for (int index = 0; index < lines.size(); index++) { - String line = lines.get(index); - for (Pattern pattern : FORBIDDEN_PATTERNS) { - if (pattern.matcher(line).find()) { - violations.add(file + ":" + (index + 1) + " matches " + pattern.pattern()); - } - } - } - } -} +package net.openan.a2at.sdk.negotiation.contract; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; + +/** + * Scans the SDK main sources for references to internal design documents. + * + *

            Comments and javadoc of the shipped sources must be self-contained English text: they must not reference the + * internal design specification, internal document paths or numbered spec sections. The scan walks the main source + * trees of the core, negotiation, client and server modules and fails with the offending file and line. + */ +class SdkSourceCommentScanTest { + + private static final List FORBIDDEN_PATTERNS = List.of( + Pattern.compile("设计规范"), + Pattern.compile("design spec"), + Pattern.compile("docs-local"), + Pattern.compile("docs/feat"), + Pattern.compile("§\\d")); + + private static final List SCANNED_MODULE_ROOTS = List.of( + "../a2a-t-core/src/main/java", + "../a2a-t-negotiation/src/main/java", + "../a2a-t-client/src/main/java", + "../a2a-t-server/src/main/java"); + + @Test + void mainSourcesNeverReferenceInternalDesignDocuments() throws IOException { + List violations = new ArrayList<>(); + for (String moduleRoot : SCANNED_MODULE_ROOTS) { + Path root = Path.of(moduleRoot); + assertTrue(Files.isDirectory(root), "the scanned module root must exist: " + moduleRoot); + try (Stream files = Files.walk(root)) { + files.filter(file -> file.toString().endsWith(".java")) + .forEach(file -> collectViolations(file, violations)); + } + } + assertTrue( + violations.isEmpty(), + "main sources must not reference internal design documents: " + String.join("; ", violations)); + } + + private static void collectViolations(Path file, List violations) { + List lines; + try { + lines = Files.readAllLines(file, StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read " + file, exception); + } + for (int index = 0; index < lines.size(); index++) { + String line = lines.get(index); + for (Pattern pattern : FORBIDDEN_PATTERNS) { + if (pattern.matcher(line).find()) { + violations.add(file + ":" + (index + 1) + " matches " + pattern.pattern()); + } + } + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java index 4cace135..5ed8aac8 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java @@ -1,298 +1,298 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import org.junit.jupiter.api.Test; - -/** - * Verifies abort message generation from typed data through the full orchestrator pipeline. - * - *

            The abort message is type-independent: one common template, one content type carrying only the termination reason. - * The rendered message must keep the fixed Abort conclusion section, carry the negotiation context bullets and never - * leak an unreplaced placeholder. The from-data variant must stay deterministic — no LLM call. - */ -class AbortMessageGenerationTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri ABORT_URI = StandardTemplates.NEGOTIATION_ABORT; - - private final CountingClient llm = new CountingClient(); - - private NegotiationGenerationOrchestrator orchestrator(String language) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .llmClient(llm) - .build(); - } - - @Test - void generatesAChineseAbortMessageFromDataWithoutAnyLlmCall() { - MetadataContent result = orchestrator("zh-CN") - .generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - ABORT_URI); - - assertEquals(ABORT_URI.uri(), result.templateUri()); - String text = result.promptText(); - assertFalse(text.contains("## 协商上下文"), "the context section must not be rendered"); - assertTrue(text.contains("## 协商结果\nAbort"), "the fixed Abort conclusion must be kept"); - assertTrue(text.contains("## 协商终止原因"), "the termination reason section must be rendered"); - assertTrue(text.contains("达到协商轮次上限,本次协商确认结束。")); - assertEquals( - new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), result.negotiationContext()); - assertFalse(text.contains("{{"), "no unreplaced placeholder may remain"); - assertEquals(0, llm.calls, "the from-data variant must never call the LLM"); - } - - @Test - void generatesAnEnglishAbortMessageFromData() { - MetadataContent result = orchestrator("en-US") - .generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 3, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent( - "Reached the negotiation round limit. This negotiation is confirmed and ended.")), - ABORT_URI); - - assertEquals(ABORT_URI.uri(), result.templateUri()); - String text = result.promptText(); - assertFalse(text.contains("## Negotiation Context"), "the context section must not be rendered"); - assertTrue(text.contains("## Negotiation Result\nAbort")); - assertTrue(text.contains("## Negotiation Termination Reason")); - assertTrue(text.contains("Reached the negotiation round limit.")); - assertEquals( - new NegotiationContext(SESSION_ID, 3, 5, NegotiationPerformative.ABORT), result.negotiationContext()); - assertFalse(text.contains("{{")); - assertEquals(0, llm.calls); - } - - @Test - void blankTerminationReasonFailsWithTheCodedContentInvalidFailureBeforeAnyLlmCall() { - NegotiationGenerationException failure = - assertThrows(NegotiationGenerationException.class, () -> orchestrator("zh-CN") - .generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent(" ")), - ABORT_URI)); - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), failure.getCode()); - assertEquals("content.terminationReason", failure.getFacts().get("field")); - assertTrue( - failure.getMessage().contains("content.terminationReason"), - "message must point at the offending field"); - assertEquals(0, llm.calls); - } - - @Test - void abortGenerationRejectsTypedTemplateUris() { - IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> orchestrator("zh-CN") - .generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent("达到协商轮次上限。")), - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT)); - assertTrue(failure.getMessage().contains("abort")); - } - - @Test - void typedEndingGenerationRejectsTheAbortTemplateUri() { - IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> orchestrator("zh-CN") - .generateRejectFromData( - new net.openan.a2at.sdk.negotiation.content.NegotiationEndingData( - new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.REJECT), - new net.openan.a2at.sdk.negotiation.content.TargetEndingContent( - net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.REJECT, - null, - "预算耗尽")), - ABORT_URI)); - assertTrue(failure.getMessage().contains("reject")); - } - - @Test - void singleQueryResolvesTheCommonAbortTemplateUri() { - assertTrue(new TemplateQueryService("zh-CN", "classpath", null) - .getPrompt(ABORT_URI) - .isPresent()); - assertTrue(new TemplateQueryService("en-US", "classpath", null) - .getPrompt(ABORT_URI) - .isPresent()); - } - - @Test - void serviceMirrorsTheAbortGenerationEntryPoint() { - NegotiationContentService service = - new NegotiationContentService(NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build()); - - MetadataContent result = service.generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent("token 消耗超限,协商终止。")), - ABORT_URI); - - assertEquals(ABORT_URI.uri(), result.templateUri()); - assertTrue(result.promptText().contains("token 消耗超限")); - assertEquals(0, llm.calls); - } - - @Test - void generatesAnAbortMessageFromFreeTextThroughOneExtractionStep() { - ScriptedClient extractionLlm = new ScriptedClient("{\"termination_reason\":\"达到协商轮次上限,协商终止。\"}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(extractionLlm) - .build(); - - MetadataContent result = orchestrator.generateAbortFromText( - "协商已达到轮次上限,无法继续推进,本次协商终止。", - new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), - ABORT_URI); - - assertEquals(ABORT_URI.uri(), result.templateUri()); - assertTrue(result.promptText().contains("## 协商结果\nAbort")); - assertTrue(result.promptText().contains("达到协商轮次上限,协商终止。")); - assertFalse(result.promptText().contains("{{")); - assertEquals(1, extractionLlm.calls, "the from-text variant runs exactly one extraction step"); - } - - @Test - void peerValidatesAReceivedAbortMessageAndExtractsItsParameters() { - NegotiationGenerationOrchestrator agent = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - ScriptedClient peerLlm = - new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator peer = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(peerLlm) - .build(); - - MetadataContent abortMessage = agent.generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - ABORT_URI); - - FilledParamData parameters = peer.validateAbortPromptAndDataFilling( - abortMessage.promptText(), - new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), - Map.of("type", "object"), - ABORT_URI); - - assertEquals(SESSION_ID, parameters.data().get("id")); - assertEquals(5, parameters.data().get("round")); - assertEquals(5, parameters.data().get("maxRounds")); - assertEquals(1, peerLlm.calls, "the validation pipeline runs exactly one semantic step"); - } - - @Test - void abortValidationFailsOnBeyondBudgetRoundsBeforeAnyLlmCall() { - NegotiationGenerationOrchestrator agent = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - ScriptedClient peerLlm = - new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator peer = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(peerLlm) - .build(); - - MetadataContent beyondBudget = agent.generateAbortFromData( - new NegotiationAbortData( - new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent("轮次预算耗尽。")), - ABORT_URI); - - NegotiationParamExtractionException failure = assertThrows( - NegotiationParamExtractionException.class, - () -> peer.validateAbortPromptAndDataFilling( - beyondBudget.promptText(), - new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.ABORT), - Map.of("type", "object"), - ABORT_URI)); - assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), failure.getCode()); - assertTrue( - failure.getErrors().stream().anyMatch(error -> "round".equals(error.slotName())), - "the beyond-budget round must be reported on the round slot"); - assertEquals(0, peerLlm.calls, "the rule gate must fail before any semantic LLM call"); - } - - @Test - void abortValidationRejectsTypedTemplateUris() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> orchestrator.validateAbortPromptAndDataFilling( - "任意文本", null, Map.of("type", "object"), StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE)); - assertTrue(failure.getMessage().contains("abort")); - assertEquals(0, llm.calls); - } - - private static final class CountingClient implements LLMClient { - - private int calls; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - throw new AssertionError("The from-data abort variant must never call the LLM"); - } - } - - private static final class ScriptedClient implements LLMClient { - - private final String payload; - - private final List>> recordedMessages = new ArrayList<>(); - - private int calls; - - private ScriptedClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - recordedMessages.add(List.copyOf(messages)); - return new LLMResponse(payload, "scripted-abort-model", Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import org.junit.jupiter.api.Test; + +/** + * Verifies abort message generation from typed data through the full orchestrator pipeline. + * + *

            The abort message is type-independent: one common template, one content type carrying only the termination reason. + * The rendered message must keep the fixed Abort conclusion section, carry the negotiation context bullets and never + * leak an unreplaced placeholder. The from-data variant must stay deterministic — no LLM call. + */ +class AbortMessageGenerationTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri ABORT_URI = StandardTemplates.NEGOTIATION_ABORT; + + private final CountingClient llm = new CountingClient(); + + private NegotiationGenerationOrchestrator orchestrator(String language) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .llmClient(llm) + .build(); + } + + @Test + void generatesAChineseAbortMessageFromDataWithoutAnyLlmCall() { + MetadataContent result = orchestrator("zh-CN") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + String text = result.promptText(); + assertFalse(text.contains("## 协商上下文"), "the context section must not be rendered"); + assertTrue(text.contains("## 协商结果\nAbort"), "the fixed Abort conclusion must be kept"); + assertTrue(text.contains("## 协商终止原因"), "the termination reason section must be rendered"); + assertTrue(text.contains("达到协商轮次上限,本次协商确认结束。")); + assertEquals( + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), result.negotiationContext()); + assertFalse(text.contains("{{"), "no unreplaced placeholder may remain"); + assertEquals(0, llm.calls, "the from-data variant must never call the LLM"); + } + + @Test + void generatesAnEnglishAbortMessageFromData() { + MetadataContent result = orchestrator("en-US") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 3, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent( + "Reached the negotiation round limit. This negotiation is confirmed and ended.")), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + String text = result.promptText(); + assertFalse(text.contains("## Negotiation Context"), "the context section must not be rendered"); + assertTrue(text.contains("## Negotiation Result\nAbort")); + assertTrue(text.contains("## Negotiation Termination Reason")); + assertTrue(text.contains("Reached the negotiation round limit.")); + assertEquals( + new NegotiationContext(SESSION_ID, 3, 5, NegotiationPerformative.ABORT), result.negotiationContext()); + assertFalse(text.contains("{{")); + assertEquals(0, llm.calls); + } + + @Test + void blankTerminationReasonFailsWithTheCodedContentInvalidFailureBeforeAnyLlmCall() { + NegotiationGenerationException failure = + assertThrows(NegotiationGenerationException.class, () -> orchestrator("zh-CN") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent(" ")), + ABORT_URI)); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), failure.getCode()); + assertEquals("content.terminationReason", failure.getFacts().get("field")); + assertTrue( + failure.getMessage().contains("content.terminationReason"), + "message must point at the offending field"); + assertEquals(0, llm.calls); + } + + @Test + void abortGenerationRejectsTypedTemplateUris() { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> orchestrator("zh-CN") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限。")), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT)); + assertTrue(failure.getMessage().contains("abort")); + } + + @Test + void typedEndingGenerationRejectsTheAbortTemplateUri() { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> orchestrator("zh-CN") + .generateRejectFromData( + new net.openan.a2at.sdk.negotiation.content.NegotiationEndingData( + new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.REJECT), + new net.openan.a2at.sdk.negotiation.content.TargetEndingContent( + net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.REJECT, + null, + "预算耗尽")), + ABORT_URI)); + assertTrue(failure.getMessage().contains("reject")); + } + + @Test + void singleQueryResolvesTheCommonAbortTemplateUri() { + assertTrue(new TemplateQueryService("zh-CN", "classpath", null) + .getPrompt(ABORT_URI) + .isPresent()); + assertTrue(new TemplateQueryService("en-US", "classpath", null) + .getPrompt(ABORT_URI) + .isPresent()); + } + + @Test + void serviceMirrorsTheAbortGenerationEntryPoint() { + NegotiationContentService service = + new NegotiationContentService(NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build()); + + MetadataContent result = service.generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("token 消耗超限,协商终止。")), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + assertTrue(result.promptText().contains("token 消耗超限")); + assertEquals(0, llm.calls); + } + + @Test + void generatesAnAbortMessageFromFreeTextThroughOneExtractionStep() { + ScriptedClient extractionLlm = new ScriptedClient("{\"termination_reason\":\"达到协商轮次上限,协商终止。\"}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(extractionLlm) + .build(); + + MetadataContent result = orchestrator.generateAbortFromText( + "协商已达到轮次上限,无法继续推进,本次协商终止。", + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("达到协商轮次上限,协商终止。")); + assertFalse(result.promptText().contains("{{")); + assertEquals(1, extractionLlm.calls, "the from-text variant runs exactly one extraction step"); + } + + @Test + void peerValidatesAReceivedAbortMessageAndExtractsItsParameters() { + NegotiationGenerationOrchestrator agent = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + ScriptedClient peerLlm = + new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator peer = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(peerLlm) + .build(); + + MetadataContent abortMessage = agent.generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + ABORT_URI); + + FilledParamData parameters = peer.validateAbortPromptAndDataFilling( + abortMessage.promptText(), + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), + Map.of("type", "object"), + ABORT_URI); + + assertEquals(SESSION_ID, parameters.data().get("id")); + assertEquals(5, parameters.data().get("round")); + assertEquals(5, parameters.data().get("maxRounds")); + assertEquals(1, peerLlm.calls, "the validation pipeline runs exactly one semantic step"); + } + + @Test + void abortValidationFailsOnBeyondBudgetRoundsBeforeAnyLlmCall() { + NegotiationGenerationOrchestrator agent = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + ScriptedClient peerLlm = + new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator peer = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(peerLlm) + .build(); + + MetadataContent beyondBudget = agent.generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("轮次预算耗尽。")), + ABORT_URI); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> peer.validateAbortPromptAndDataFilling( + beyondBudget.promptText(), + new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.ABORT), + Map.of("type", "object"), + ABORT_URI)); + assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), failure.getCode()); + assertTrue( + failure.getErrors().stream().anyMatch(error -> "round".equals(error.slotName())), + "the beyond-budget round must be reported on the round slot"); + assertEquals(0, peerLlm.calls, "the rule gate must fail before any semantic LLM call"); + } + + @Test + void abortValidationRejectsTypedTemplateUris() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.validateAbortPromptAndDataFilling( + "任意文本", null, Map.of("type", "object"), StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE)); + assertTrue(failure.getMessage().contains("abort")); + assertEquals(0, llm.calls); + } + + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new AssertionError("The from-data abort variant must never call the LLM"); + } + } + + private static final class ScriptedClient implements LLMClient { + + private final String payload; + + private final List>> recordedMessages = new ArrayList<>(); + + private int calls; + + private ScriptedClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + recordedMessages.add(List.copyOf(messages)); + return new LLMResponse(payload, "scripted-abort-model", Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java index d97d6f0c..cf606017 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java @@ -1,133 +1,133 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import org.junit.jupiter.api.Test; - -/** - * Locks the classpath-only loading of the negotiation LLM prompt resources. - * - *

            The LLM prompt resources are deliberately not configurable: the system prompt sent to the LLM is always the - * built-in classpath prompt of the configured language. Both prompt-consuming steps are covered: the content extraction - * of the from-text generation and the semantic validation of the parameter extraction pipeline. - */ -class CustomRootPromptsIgnoredTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String EXTRACTION_RESPONSE = - "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; - - private static final String SEMANTIC_RESPONSE = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}"; - - @Test - void contentExtractionKeepsUsingTheBuiltInSystemPrompt() { - RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - NegotiationContext context = new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE); - - MetadataContent result = orchestrator.generateProposeFromText("请提供节能区域。", context, INFORMATION_PROPOSE_URI); - - assertEquals(1, llm.callCount()); - String builtinSystemPrompt = - new NegotiationPromptResourceLoader().loadSystem("information_negotiation", "zh-CN"); - assertEquals( - builtinSystemPrompt, - llm.systemContentOfLastCall(), - "the system prompt sent to the LLM must be the built-in classpath prompt"); - assertTrue(llm.userContentOfLastCall().contains("请提供节能区域。"), "the user prompt must carry the input text"); - assertTrue(result.promptText().contains("节能区域")); - } - - @Test - void semanticValidationKeepsUsingTheBuiltInSystemPrompt() { - RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE, SEMANTIC_RESPONSE); - NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); - MetadataContent message = orchestrator.generateProposeFromText( - "请提供节能区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI); - - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message.promptText(), - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - INFORMATION_PROPOSE_URI); - - assertEquals(2, llm.callCount()); - String builtinSystemPrompt = - new NegotiationPromptResourceLoader().loadSystem("negotiation_semantic_validation", "zh-CN"); - assertEquals( - builtinSystemPrompt, - llm.systemContentOfLastCall(), - "the semantic validation system prompt must be the built-in classpath prompt"); - assertEquals("松山湖", filled.data().get("region")); - } - - private NegotiationGenerationOrchestrator orchestrator(RecordingLlmClient llm) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - } - - private static final class RecordingLlmClient implements LLMClient { - - private final List payloads; - - private final ArrayList>> recordedMessages = new ArrayList<>(); - - private RecordingLlmClient(String... payloads) { - this.payloads = List.of(payloads); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - recordedMessages.add(messages); - String payload = payloads.get(Math.min(recordedMessages.size() - 1, payloads.size() - 1)); - return new LLMResponse( - payload, "recording-test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - - int callCount() { - return recordedMessages.size(); - } - - String systemContentOfLastCall() { - return roleContentOfLastCall("system"); - } - - String userContentOfLastCall() { - return roleContentOfLastCall("user"); - } - - private String roleContentOfLastCall(String role) { - List> messages = recordedMessages.get(recordedMessages.size() - 1); - return messages.stream() - .filter(message -> role.equals(message.get("role"))) - .map(message -> message.get("content")) - .findFirst() - .orElseThrow(); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.Test; + +/** + * Locks the classpath-only loading of the negotiation LLM prompt resources. + * + *

            The LLM prompt resources are deliberately not configurable: the system prompt sent to the LLM is always the + * built-in classpath prompt of the configured language. Both prompt-consuming steps are covered: the content extraction + * of the from-text generation and the semantic validation of the parameter extraction pipeline. + */ +class CustomRootPromptsIgnoredTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String EXTRACTION_RESPONSE = + "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; + + private static final String SEMANTIC_RESPONSE = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"; + + @Test + void contentExtractionKeepsUsingTheBuiltInSystemPrompt() { + RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + NegotiationContext context = new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE); + + MetadataContent result = orchestrator.generateProposeFromText("请提供节能区域。", context, INFORMATION_PROPOSE_URI); + + assertEquals(1, llm.callCount()); + String builtinSystemPrompt = + new NegotiationPromptResourceLoader().loadSystem("information_negotiation", "zh-CN"); + assertEquals( + builtinSystemPrompt, + llm.systemContentOfLastCall(), + "the system prompt sent to the LLM must be the built-in classpath prompt"); + assertTrue(llm.userContentOfLastCall().contains("请提供节能区域。"), "the user prompt must carry the input text"); + assertTrue(result.promptText().contains("节能区域")); + } + + @Test + void semanticValidationKeepsUsingTheBuiltInSystemPrompt() { + RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE, SEMANTIC_RESPONSE); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + MetadataContent message = orchestrator.generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message.promptText(), + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), + INFORMATION_PROPOSE_URI); + + assertEquals(2, llm.callCount()); + String builtinSystemPrompt = + new NegotiationPromptResourceLoader().loadSystem("negotiation_semantic_validation", "zh-CN"); + assertEquals( + builtinSystemPrompt, + llm.systemContentOfLastCall(), + "the semantic validation system prompt must be the built-in classpath prompt"); + assertEquals("松山湖", filled.data().get("region")); + } + + private NegotiationGenerationOrchestrator orchestrator(RecordingLlmClient llm) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + } + + private static final class RecordingLlmClient implements LLMClient { + + private final List payloads; + + private final ArrayList>> recordedMessages = new ArrayList<>(); + + private RecordingLlmClient(String... payloads) { + this.payloads = List.of(payloads); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + recordedMessages.add(messages); + String payload = payloads.get(Math.min(recordedMessages.size() - 1, payloads.size() - 1)); + return new LLMResponse( + payload, "recording-test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + + int callCount() { + return recordedMessages.size(); + } + + String systemContentOfLastCall() { + return roleContentOfLastCall("system"); + } + + String userContentOfLastCall() { + return roleContentOfLastCall("user"); + } + + private String roleContentOfLastCall(String role) { + List> messages = recordedMessages.get(recordedMessages.size() - 1); + return messages.stream() + .filter(message -> role.equals(message.get("role"))) + .map(message -> message.get("content")) + .findFirst() + .orElseThrow(); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java index 50ead996..d1a33880 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java @@ -1,209 +1,209 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import java.util.function.Supplier; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import org.junit.jupiter.api.Test; - -/** - * Verifies the corpus-inexpressible rows of the programming-error matrix of the from-data generation (design §6 Q8). - * - *

            The remaining rows of the matrix — null context, phase-conclusion mismatch, blank conditional fields of the ending - * family, empty evaluation/alternative lists and every template-URI phase violation — are absorbed by the - * {@code FD-PROG} batch of the test corpus ({@code negotiation-cases/from-data/programming-errors.json}), which asserts - * the same exception type, message fragment and zero-LLM guarantee per case. Only the rows the corpus cannot express - * stay here: null propose/ending data (a corpus record always carries {@code input.data}), a null conclusion and a - * missing feasibility action (the typed-input assembler rejects both as authoring defects), blank propose descriptions - * and ending summaries (the assembler demands non-blank text), and a template URI whose type contradicts the content - * type (the assembler derives the content type from the URI, so the contradiction cannot be authored). Every row fails - * with a standard {@link NullPointerException} (pure null arguments) or {@link IllegalArgumentException} (URI-shape and - * constructor-constraint violations) that is not part of the SDK business-failure hierarchy and carries an English - * message pointing at the offending input. Blank content fields are coded business failures carrying - * {@code negotiation.content_invalid} and are covered by a dedicated test below. No row ever reaches the LLM. - * Structural URI malformation is impossible by construction of {@link TemplateUri} and is therefore not a row. - */ -class FromDataProgrammingErrorMatrixTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - - private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; - - private static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; - - private static final TemplateUri FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; - - private static final TemplateUri TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; - - private final CountingClient llm = new CountingClient(); - - private final NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - - @Test - void everyMatrixRowFailsWithAStandardJavaException() { - List matrix = matrix(); - - // The propose-versus-ending family mismatch of UT-GEN-002 is deliberately absent here: the typed data records - // make that mismatch a compile-time error at the facade, so it cannot occur at runtime. - assertEquals(5, matrix.size(), "exactly the corpus-inexpressible rows must stay (FD-PROG absorbs the rest)"); - for (MatrixRow row : matrix) { - RuntimeException failure = - assertThrows(row.expectedException(), () -> row.call().get(), "row must fail: " + row.label()); - assertFalse( - A2ATError.class.isInstance(failure), - "a programming error must not be part of the processing-error hierarchy: " + row.label()); - assertTrue( - failure.getMessage() != null && !failure.getMessage().isBlank(), - "failure message must be present: " + row.label()); - assertTrue( - isAsciiText(failure.getMessage()), - "failure message must be English (ASCII): " + failure.getMessage()); - assertTrue( - failure.getMessage().contains(row.expectedMessageFragment()), - "failure message must point at the problem of row " + row.label() + " but was: " - + failure.getMessage()); - } - assertEquals(0, llm.calls, "no matrix row may call the LLM"); - } - - /** - * Returns the corpus-inexpressible rows of the programming-error matrix of the from-data variants (design §6 Q8: - * FD-PROG-01..14 absorb the other fourteen rows). See the class javadoc for why each row below cannot be expressed - * as a corpus record. - */ - private List matrix() { - NegotiationContext context = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE); - NegotiationContext acceptContext = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.ACCEPT); - return List.of( - new MatrixRow( - "null propose data", - () -> orchestrator.generateProposeFromData(null, INFORMATION_PROPOSE_URI), - NullPointerException.class, - "Negotiation propose data must not be null."), - new MatrixRow( - "null ending data", - () -> orchestrator.generateAcceptFromData(null, INFORMATION_ACCEPT_URI), - NullPointerException.class, - "Negotiation ending data must not be null."), - new MatrixRow( - "accept method with null conclusion", - () -> orchestrator.generateAcceptFromData( - new NegotiationEndingData(acceptContext, new TargetEndingContent(null, "intent", null)), - TARGET_ACCEPT_URI), - NullPointerException.class, - "conclusion must not be null"), - new MatrixRow( - "feasibility propose without action", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData( - context, - new FeasibilityProposeContent( - "请评估。", null, List.of(new NegotiationItem("目标", "2Mbps")), null, null)), - FEASIBILITY_PROPOSE_URI), - NullPointerException.class, - "Feasibility negotiation action must not be null"), - new MatrixRow( - "template URI type contradicts the content type", - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData( - context, new TargetProposeContent("目标协商概述。", null, null, null, null)), - INFORMATION_PROPOSE_URI), - IllegalArgumentException.class, - "Negotiation type INFORMATION requires content of type InformationProposeContent")); - } - - @Test - void blankRequiredContentFieldsFailWithTheCodedContentInvalidFailure() { - NegotiationContext context = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE); - NegotiationContext acceptContext = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.ACCEPT); - List blankFieldCalls = List.of( - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData(context, new TargetProposeContent(" ", null, null, null, null)), - TARGET_PROPOSE_URI), - () -> orchestrator.generateProposeFromData( - new NegotiationProposeData( - context, - new FeasibilityProposeContent( - " ", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("目标", "2Mbps")), - null, - null)), - FEASIBILITY_PROPOSE_URI), - () -> orchestrator.generateAcceptFromData( - new NegotiationEndingData( - acceptContext, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " ")), - FEASIBILITY_ACCEPT_URI)); - List expectedFields = List.of( - "content.targetNegotiationDescription", - "content.feasibilityNegotiationDescription", - "content.feasibilitySummary"); - - for (int index = 0; index < blankFieldCalls.size(); index++) { - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - blankFieldCalls.get(index)::run, - "blank content field " + expectedFields.get(index) + " must fail"); - assertEquals( - ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), failure.getCode(), expectedFields.get(index)); - assertEquals(expectedFields.get(index), failure.getFacts().get("field")); - assertTrue(failure.getMessage().contains(expectedFields.get(index)), failure.getMessage()); - } - assertEquals(0, llm.calls, "no blank-content failure may call the LLM"); - } - - private static boolean isAsciiText(String message) { - return message.chars().allMatch(codePoint -> codePoint < 128); - } - - /** One row of the programming-error matrix: a failing call, the expected exception type and message fragment. */ - private record MatrixRow( - String label, - Supplier call, - Class expectedException, - String expectedMessageFragment) {} - - private static final class CountingClient implements LLMClient { - - private int calls; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - throw new AssertionError("A from-data programming error must fail before any LLM call"); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.junit.jupiter.api.Test; + +/** + * Verifies the corpus-inexpressible rows of the programming-error matrix of the from-data generation (design §6 Q8). + * + *

            The remaining rows of the matrix — null context, phase-conclusion mismatch, blank conditional fields of the ending + * family, empty evaluation/alternative lists and every template-URI phase violation — are absorbed by the + * {@code FD-PROG} batch of the test corpus ({@code negotiation-cases/from-data/programming-errors.json}), which asserts + * the same exception type, message fragment and zero-LLM guarantee per case. Only the rows the corpus cannot express + * stay here: null propose/ending data (a corpus record always carries {@code input.data}), a null conclusion and a + * missing feasibility action (the typed-input assembler rejects both as authoring defects), blank propose descriptions + * and ending summaries (the assembler demands non-blank text), and a template URI whose type contradicts the content + * type (the assembler derives the content type from the URI, so the contradiction cannot be authored). Every row fails + * with a standard {@link NullPointerException} (pure null arguments) or {@link IllegalArgumentException} (URI-shape and + * constructor-constraint violations) that is not part of the SDK business-failure hierarchy and carries an English + * message pointing at the offending input. Blank content fields are coded business failures carrying + * {@code negotiation.content_invalid} and are covered by a dedicated test below. No row ever reaches the LLM. + * Structural URI malformation is impossible by construction of {@link TemplateUri} and is therefore not a row. + */ +class FromDataProgrammingErrorMatrixTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + + private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; + + private static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; + + private static final TemplateUri FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; + + private static final TemplateUri TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; + + private final CountingClient llm = new CountingClient(); + + private final NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + @Test + void everyMatrixRowFailsWithAStandardJavaException() { + List matrix = matrix(); + + // The propose-versus-ending family mismatch of UT-GEN-002 is deliberately absent here: the typed data records + // make that mismatch a compile-time error at the facade, so it cannot occur at runtime. + assertEquals(5, matrix.size(), "exactly the corpus-inexpressible rows must stay (FD-PROG absorbs the rest)"); + for (MatrixRow row : matrix) { + RuntimeException failure = + assertThrows(row.expectedException(), () -> row.call().get(), "row must fail: " + row.label()); + assertFalse( + A2ATError.class.isInstance(failure), + "a programming error must not be part of the processing-error hierarchy: " + row.label()); + assertTrue( + failure.getMessage() != null && !failure.getMessage().isBlank(), + "failure message must be present: " + row.label()); + assertTrue( + isAsciiText(failure.getMessage()), + "failure message must be English (ASCII): " + failure.getMessage()); + assertTrue( + failure.getMessage().contains(row.expectedMessageFragment()), + "failure message must point at the problem of row " + row.label() + " but was: " + + failure.getMessage()); + } + assertEquals(0, llm.calls, "no matrix row may call the LLM"); + } + + /** + * Returns the corpus-inexpressible rows of the programming-error matrix of the from-data variants (design §6 Q8: + * FD-PROG-01..14 absorb the other fourteen rows). See the class javadoc for why each row below cannot be expressed + * as a corpus record. + */ + private List matrix() { + NegotiationContext context = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE); + NegotiationContext acceptContext = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.ACCEPT); + return List.of( + new MatrixRow( + "null propose data", + () -> orchestrator.generateProposeFromData(null, INFORMATION_PROPOSE_URI), + NullPointerException.class, + "Negotiation propose data must not be null."), + new MatrixRow( + "null ending data", + () -> orchestrator.generateAcceptFromData(null, INFORMATION_ACCEPT_URI), + NullPointerException.class, + "Negotiation ending data must not be null."), + new MatrixRow( + "accept method with null conclusion", + () -> orchestrator.generateAcceptFromData( + new NegotiationEndingData(acceptContext, new TargetEndingContent(null, "intent", null)), + TARGET_ACCEPT_URI), + NullPointerException.class, + "conclusion must not be null"), + new MatrixRow( + "feasibility propose without action", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, + new FeasibilityProposeContent( + "请评估。", null, List.of(new NegotiationItem("目标", "2Mbps")), null, null)), + FEASIBILITY_PROPOSE_URI), + NullPointerException.class, + "Feasibility negotiation action must not be null"), + new MatrixRow( + "template URI type contradicts the content type", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, new TargetProposeContent("目标协商概述。", null, null, null, null)), + INFORMATION_PROPOSE_URI), + IllegalArgumentException.class, + "Negotiation type INFORMATION requires content of type InformationProposeContent")); + } + + @Test + void blankRequiredContentFieldsFailWithTheCodedContentInvalidFailure() { + NegotiationContext context = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE); + NegotiationContext acceptContext = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.ACCEPT); + List blankFieldCalls = List.of( + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData(context, new TargetProposeContent(" ", null, null, null, null)), + TARGET_PROPOSE_URI), + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, + new FeasibilityProposeContent( + " ", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("目标", "2Mbps")), + null, + null)), + FEASIBILITY_PROPOSE_URI), + () -> orchestrator.generateAcceptFromData( + new NegotiationEndingData( + acceptContext, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " ")), + FEASIBILITY_ACCEPT_URI)); + List expectedFields = List.of( + "content.targetNegotiationDescription", + "content.feasibilityNegotiationDescription", + "content.feasibilitySummary"); + + for (int index = 0; index < blankFieldCalls.size(); index++) { + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + blankFieldCalls.get(index)::run, + "blank content field " + expectedFields.get(index) + " must fail"); + assertEquals( + ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), failure.getCode(), expectedFields.get(index)); + assertEquals(expectedFields.get(index), failure.getFacts().get("field")); + assertTrue(failure.getMessage().contains(expectedFields.get(index)), failure.getMessage()); + } + assertEquals(0, llm.calls, "no blank-content failure may call the LLM"); + } + + private static boolean isAsciiText(String message) { + return message.chars().allMatch(codePoint -> codePoint < 128); + } + + /** One row of the programming-error matrix: a failing call, the expected exception type and message fragment. */ + private record MatrixRow( + String label, + Supplier call, + Class expectedException, + String expectedMessageFragment) {} + + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new AssertionError("A from-data programming error must fail before any LLM call"); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java index 92044d5e..2fcb017f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -1,768 +1,768 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.LlmConfig; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.slf4j.LoggerFactory; - -/** - * Integration tests of the from-text generation pipeline against the real built-in resources. - * - *

            The LLM is replaced by a scripted client that returns queued payloads or failures; every other collaborator is the - * production wiring. The suite covers the single-step content extraction (one LLM call, no type-recognition step), the - * byte-equality of the from-text output with the from-data output and the golden fixtures, the step-level retry policy - * with its warning logs, the infrastructure failure paths, the non-retryable failure codes that must stop after one LLM - * call, the max-attempts configuration semantics and the default en-US end-to-end chain. - */ -class FromTextLlmPipelineTest { - - private static final String ZH_CN = NegotiationGoldenCases.ZH_CN; - - private static final String EN_US = NegotiationGoldenCases.EN_US; - - private static final TemplateUri INFORMATION_PROPOSE_URI = GoldenCase.INFORMATION_PROPOSE.template(); - - private final ListAppender orchestratorAppender = new ListAppender<>(); - - private final ListAppender extractorAppender = new ListAppender<>(); - - private final ListAppender llmConfigAppender = new ListAppender<>(); - - private final Logger orchestratorLogger = (Logger) LoggerFactory.getLogger(NegotiationGenerationOrchestrator.class); - - private final Logger extractorLogger = (Logger) LoggerFactory.getLogger(DefaultNegotiationContentExtractor.class); - - private final Logger llmConfigLogger = (Logger) LoggerFactory.getLogger(LlmConfig.class); - - @BeforeEach - void attachLogAppenders() { - orchestratorAppender.start(); - orchestratorLogger.addAppender(orchestratorAppender); - extractorAppender.start(); - extractorLogger.addAppender(extractorAppender); - llmConfigAppender.start(); - llmConfigLogger.addAppender(llmConfigAppender); - } - - @AfterEach - void detachLogAppenders() { - orchestratorLogger.detachAppender(orchestratorAppender); - extractorLogger.detachAppender(extractorAppender); - llmConfigLogger.detachAppender(llmConfigAppender); - } - - /** - * IT-B-001: the from-text chain runs exactly one content-extraction LLM call and then renders deterministically: - * the output equals both the from-data generation of the same content and the committed golden fixture byte for - * byte, the metadata contract holds, and no type-recognition step exists anywhere in the pipeline. - */ - @Test - void fromTextMatchesFromDataAndTheGoldenFixtureAfterOneExtractionCall() { - GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; - ScriptedExtractionClient llm = - new ScriptedExtractionClient(extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - MetadataContent fromText = orchestrator.generateProposeFromText( - "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", - goldenCase.context(), - goldenCase.template()); - - assertEquals(1, llm.calls, "from-text generation must run exactly one content extraction call"); - assertEquals(goldenCase.templateUri(), fromText.templateUri()); - - MetadataContent fromData = goldenCase.generate(orchestrator, ZH_CN); - assertEquals(1, llm.calls, "the from-data variant must not add any LLM call"); - assertEquals(fromData.promptText(), fromText.promptText()); - assertEquals(goldenCase.goldenText(ZH_CN), fromText.promptText()); - - Map metadata = fromText.buildMetadataContent(); - assertEquals(3, metadata.size()); - assertEquals(fromText.promptText(), metadata.get(fromText.extensionUri())); - assertEquals(fromText.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - assertEquals(goldenCase.context().withPerformative(goldenCase.performative()), fromText.negotiationContext()); - @SuppressWarnings("unchecked") - Map nestedContext = - (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); - assertEquals(goldenCase.context().id(), nestedContext.get("id")); - assertEquals(goldenCase.context().round(), nestedContext.get("round")); - assertEquals(goldenCase.context().maxRounds(), nestedContext.get("maxRounds")); - - assertEquals( - 2, llm.lastMessages.size(), "the extraction step must be a single system-plus-user message exchange"); - assertTrue( - logMessages(extractorAppender, Level.INFO).stream() - .anyMatch(message -> message.startsWith("negotiation_content_extraction_completed")), - "the completed extraction step must emit its info event"); - assertTrue( - logMessages(orchestratorAppender, Level.INFO).stream() - .noneMatch(message -> message.contains("negotiation_type_recognition")) - && logMessages(extractorAppender, Level.INFO).stream() - .noneMatch(message -> message.contains("negotiation_type_recognition")), - "the removed type-recognition step must never appear in the events"); - } - - /** - * IT-B-002: every from-text method (propose, accept, reject, abort) succeeds for every negotiation type with - * exactly one LLM call per generation, the caller-supplied template URI is echoed, the output matches the golden - * fixture, the phase token reaches the LLM so the accept and reject phases can be distinguished, and the abort - * method addresses the termination-specific extraction prompt. - */ - @ParameterizedTest(name = "from-text succeeds for {0}") - @EnumSource(GoldenCase.class) - void fromTextSucceedsForEveryMethodAndTypeCombination(GoldenCase goldenCase) { - ScriptedExtractionClient llm = - new ScriptedExtractionClient(extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - MetadataContent result = generateFromText(orchestrator, goldenCase, "请根据以下专线投诉诊断协商文本生成协商报文。"); - - assertEquals(1, llm.calls); - assertEquals(goldenCase.templateUri(), result.templateUri()); - assertEquals(goldenCase.goldenText(ZH_CN), result.promptText()); - String userPrompt = llm.lastMessages.get(1).get("content"); - if (goldenCase.performative() == NegotiationPerformative.ABORT) { - assertTrue( - userPrompt.contains("终止"), - "the abort user prompt must address the termination content but was: " + userPrompt); - } else { - assertTrue( - userPrompt.contains(phaseToken(goldenCase.performative())), - "the user prompt must carry the phase token of the addressed method"); - } - } - - /** - * IT-B-002: the accept and reject methods share the same accept-reject template URI; both load that one template - * and differ only in the rendered conclusion literal. - */ - @Test - void acceptAndRejectShareTheSameAcceptRejectTemplate() { - assertEquals( - GoldenCase.INFORMATION_ACCEPT.templateUri(), - GoldenCase.INFORMATION_REJECT.templateUri(), - "accept and reject must address the same accept-reject template"); - - ScriptedExtractionClient acceptLlm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_ACCEPT.content(ZH_CN), NegotiationPerformative.ACCEPT)); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, acceptLlm, 3); - MetadataContent acceptResult = - generateFromText(orchestrator, GoldenCase.INFORMATION_ACCEPT, "请确认补充接入端口名称与投诉分类。"); - assertEquals(1, acceptLlm.calls); - - ScriptedExtractionClient rejectLlm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_REJECT.content(ZH_CN), NegotiationPerformative.REJECT)); - MetadataContent rejectResult = - generateFromText(orchestrator(ZH_CN, rejectLlm, 3), GoldenCase.INFORMATION_REJECT, "请拒绝。"); - assertEquals(1, rejectLlm.calls); - - assertTrue( - acceptResult.promptText().contains("\nAccept\n"), "the accept output must render the Accept literal"); - assertTrue( - rejectResult.promptText().contains("\nReject\n"), "the reject output must render the Reject literal"); - assertEquals(acceptResult.templateUri(), rejectResult.templateUri()); - } - - /** - * IT-B-003: a retryable extraction failure is retried until it succeeds; every failed attempt logs a warning with - * the step, attempt, attempt limit and failure code, and the successful output still matches the golden fixture. - */ - @Test - void retriesContentExtractionUntilItSucceeds() { - GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; - ScriptedExtractionClient llm = new ScriptedExtractionClient( - "", "", extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - MetadataContent result = - orchestrator.generateProposeFromText("请提供接入端口名称。", goldenCase.context(), goldenCase.template()); - - assertEquals(3, llm.calls, "two failures followed by one success must result in three calls"); - assertEquals(goldenCase.goldenText(ZH_CN), result.promptText()); - - List retries = warningMessages("negotiation_llm_retry "); - assertEquals(2, retries.size()); - for (int attempt = 1; attempt <= 2; attempt++) { - String retry = retries.get(attempt - 1); - assertTrue(retry.contains("step=negotiation_content_extract"), retry); - assertTrue(retry.contains("attempt=" + attempt), retry); - assertTrue(retry.contains("max_attempts=3"), retry); - assertTrue(retry.contains("code=" + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()), retry); - } - assertTrue( - warningMessages("negotiation_llm_retry_exhausted").isEmpty(), - "a successful retry sequence must not log the exhaustion event"); - } - - /** - * IT-B-003: when every attempt fails with the retryable extraction failure code, the original error code is - * rethrown after the exhaustion warning has been logged. - */ - @Test - void rethrowsTheOriginalCodeWhenRetriesAreExhausted() { - ScriptedExtractionClient llm = new ScriptedExtractionClient("", "", ""); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); - assertEquals(3, llm.calls); - - List exhausted = warningMessages("negotiation_llm_retry_exhausted"); - assertEquals(1, exhausted.size()); - assertTrue(exhausted.get(0).contains("step=negotiation_content_extract"), exhausted.get(0)); - assertTrue(exhausted.get(0).contains("max_attempts=3"), exhausted.get(0)); - assertTrue(exhausted.get(0).contains("code=" + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()), exhausted.get(0)); - assertEquals(2, warningMessages("negotiation_llm_retry ").size()); - assertTrue( - warningMessages("negotiation_generation_failed").stream() - .anyMatch(message -> message.contains("code=" + ErrorCatalog.LLM_RESPONSE_INVALID.getCode())), - "the surfaced generation failure must be logged with its code"); - } - - /** - * IT-B-004: persistent transport failures of the LLM client are retried and surface as the infrastructure error - * code on the generation side. - */ - @Test - void retriesInfrastructureFailuresUntilExhaustion() { - ScriptedExtractionClient llm = new ScriptedExtractionClient(new IllegalStateException("connection refused")); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), failure.getCode()); - assertEquals(2, llm.calls); - assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); - assertTrue(warningMessages("negotiation_llm_retry_exhausted") - .get(0) - .contains("code=" + ErrorCatalog.LLM_INVOCATION_FAILED.getCode())); - } - - /** - * IT-B-004: a persistently unparseable LLM response fails with the retryable extraction failure code after the - * retries are exhausted, rethrowing the original code. - */ - @Test - void retriesUnparseableResponsesUntilExhaustion() { - ScriptedExtractionClient llm = new ScriptedExtractionClient(""); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); - assertEquals(2, llm.calls); - assertEquals(1, warningMessages("negotiation_llm_retry ").size()); - assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); - } - - /** - * IT-B-005 (1): an extracted conclusion that contradicts the method phase fails with the non-retryable - * invalid-input code after exactly one LLM call and without any retry event. - */ - @Test - void phaseConclusionMismatchFailsFastWithConclusionMismatch() { - ScriptedExtractionClient llm = new ScriptedExtractionClient( - "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}]}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateAcceptFromText( - "请接受。", GoldenCase.INFORMATION_ACCEPT.context(), GoldenCase.INFORMATION_ACCEPT.template())); - - assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), failure.getCode()); - assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); - assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); - assertTrue(warningMessages("negotiation_llm_retry_exhausted").isEmpty()); - } - - /** - * IT-B-005 (2): an extraction response missing a required field fails with the non-retryable slot-missing code - * after exactly one LLM call. - */ - @Test - void missingRequiredFieldFailsFastWithSlotMissing() { - ScriptedExtractionClient llm = new ScriptedExtractionClient("{\"relationship\":null}"); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), failure.getCode()); - assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); - assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); - assertTrue(warningMessages("negotiation_llm_retry_exhausted").isEmpty()); - } - - /** - * IT-B-005 (3): a template URI without any loadable template fails with template-not-found before the LLM is - * touched at all. - */ - @Test - void missingTemplateFailsBeforeAnyLlmCall() { - ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPerformative.PROPOSE)); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language(ZH_CN) - .llmClient(llm) - .templateLoader(missingTemplateLoader()) - .build(); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), failure.getCode()); - assertEquals(0, llm.calls, "the template must be loaded before the LLM step is started"); - } - - /** - * IT-B-005 (4): a template URI whose phase segment contradicts the method is a programming error with an - * {@link IllegalArgumentException} pointing at the template URI and never reaches the LLM. - */ - @Test - void phaseMismatchedTemplateUriFailsBeforeAnyLlmCall() { - ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPerformative.PROPOSE)); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", - GoldenCase.INFORMATION_PROPOSE.context(), - GoldenCase.INFORMATION_ACCEPT.template())); - - assertTrue( - failure.getMessage() - .contains( - "Template URI does not address a negotiation template of the expected performative PROPOSE"), - "the failure must point at the template URI but was: " + failure.getMessage()); - assertEquals(0, llm.calls); - } - - /** - * IT-B-006 (1): a single allowed attempt makes the retryable extraction failure surface after exactly one call, - * with the exhaustion warning but without any per-attempt retry warning. - */ - @Test - void singleAttemptFailsFast() { - ScriptedExtractionClient llm = new ScriptedExtractionClient(""); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 1); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); - assertEquals(1, llm.calls); - assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); - assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); - } - - /** IT-B-006 (2): an attempt limit of two allows exactly one retry of a retryable failure. */ - @Test - void twoAttemptsRetryExactlyOnce() { - ScriptedExtractionClient llm = new ScriptedExtractionClient("", ""); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); - assertEquals(2, llm.calls); - assertEquals(1, warningMessages("negotiation_llm_retry ").size()); - assertTrue(warningMessages("negotiation_llm_retry ").get(0).contains("max_attempts=2")); - assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); - } - - /** - * IT-B-006 (3): the clamped attempt limits of the unified LLM config drive the retry loop: the value 99 clamps to - * 10 with a warning and allows nine failed attempts before a success, and the value 0 clamps to 1 with a warning - * and fails fast. - */ - @Test - void clampedConfigValuesDriveTheRetryLoop() { - LlmConfig large = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "99")); - assertEquals(10, large.maxAttempts()); - assertTrue( - warningMessagesOf(llmConfigAppender).stream() - .anyMatch(message -> message.contains("raw_value=99") && message.contains("clamped_value=10")), - "the clamp of the value 99 must be warned about"); - - GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; - ScriptedExtractionClient recovering = new ScriptedExtractionClient( - "", - "", - "", - "", - "", - "", - "", - "", - "", - extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); - NegotiationGenerationOrchestrator recoveringOrchestrator = orchestrator(ZH_CN, recovering, large.maxAttempts()); - - MetadataContent result = recoveringOrchestrator.generateProposeFromText( - "请提供接入端口名称。", goldenCase.context(), goldenCase.template()); - - assertEquals(10, recovering.calls, "nine failed attempts plus one success must consume the clamped limit"); - assertEquals(goldenCase.goldenText(ZH_CN), result.promptText()); - assertEquals(9, warningMessages("negotiation_llm_retry ").size()); - - LlmConfig small = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "0")); - assertEquals(1, small.maxAttempts()); - assertTrue( - warningMessagesOf(llmConfigAppender).stream() - .anyMatch(message -> message.contains("raw_value=0") && message.contains("clamped_value=1")), - "the clamp of the value 0 must be warned about"); - - ScriptedExtractionClient failing = new ScriptedExtractionClient(""); - NegotiationGenerationOrchestrator failingOrchestrator = orchestrator(ZH_CN, failing, small.maxAttempts()); - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> failingOrchestrator.generateProposeFromText( - "请提供接入端口名称。", goldenCase.context(), goldenCase.template())); - - assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); - assertEquals(1, failing.calls); - } - - /** - * IT-B-007: with no language configured the unified config resolves the default en-US, and the whole chain works - * against the real en-US resources: one extraction call for the from-text generation, prompt resources that are - * really loaded from the classpath, and a second single LLM call for the validation pipeline. - */ - @Test - void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("client.env"); - Files.writeString( - envFile, - """ - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=local_rule - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """); - A2ATConfig config = A2ATConfig.load(envFile); - assertEquals(EN_US, config.prompt().language(), "the default language without A2AT_LANGUAGE is en-US"); - assertEquals(3, config.llm().maxAttempts(), "the default attempt limit is 3"); - - GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; - String inputText = "Please provide the access port name and the complaint category of the private line."; - Map bizSchema = Map.of( - "type", - "object", - "properties", - Map.of( - "accessPort", Map.of("type", "string"), - "bizScenario", Map.of("type", "string")), - "required", - List.of("accessPort", "bizScenario")); - ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(goldenCase.content(EN_US), goldenCase.performative()), - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"accessPort\":\"P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\"," - + "\"bizScenario\":\"dedicated-line quality degradation\"}}"); - NegotiationGenerationOrchestrator orchestrator = - orchestrator(config.prompt().language(), llm, config.llm().maxAttempts()); - - MetadataContent message = - orchestrator.generateProposeFromText(inputText, goldenCase.context(), goldenCase.template()); - - assertEquals(1, llm.calls, "the en-US from-text chain needs exactly one extraction call"); - assertEquals(goldenCase.goldenText(EN_US), message.promptText()); - assertEquals(2, llm.lastMessages.size()); - assertFalse(llm.lastMessages.get(0).get("content").isBlank(), "the en-US system prompt must be loadable"); - assertTrue(llm.lastMessages.get(1).get("content").contains(inputText)); - assertTrue( - llm.lastMessages.get(1).get("content").contains("propose"), - "the user prompt must carry the phase token"); - - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message.promptText(), goldenCase.context(), bizSchema, goldenCase.template()); - - assertEquals(2, llm.calls, "the validation pipeline adds exactly one semantic validation call"); - assertEquals(goldenCase.context().id(), filled.data().get("id")); - assertEquals(goldenCase.context().round(), filled.data().get("round")); - assertEquals(goldenCase.context().maxRounds(), filled.data().get("maxRounds")); - assertEquals( - "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); - assertEquals("dedicated-line quality degradation", filled.data().get("bizScenario")); - } - - /** - * IT-B-008: the negotiation context of the generated message comes from the caller-supplied context: the extraction - * schema sent to the LLM has no context fields and the metadata carries exactly the caller's context. - */ - @Test - void contextIsInjectedFromTheCallerWithoutAnyLlmInvolvement() { - NegotiationContext context = - new NegotiationContext(NegotiationGoldenCases.SESSION_ID, 4, 7, NegotiationPerformative.PROPOSE); - ScriptedExtractionClient llm = new ScriptedExtractionClient( - extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPerformative.PROPOSE)); - NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); - - MetadataContent result = orchestrator.generateProposeFromText("请提供接入端口名称。", context, INFORMATION_PROPOSE_URI); - - assertEquals(1, llm.calls); - Map schemaProperties = schemaProperties(llm.lastSchema); - assertFalse(schemaProperties.containsKey("id"), "the extraction schema must not ask for the context id"); - assertFalse(schemaProperties.containsKey("round"), "the extraction schema must not ask for the round"); - assertFalse(schemaProperties.containsKey("maxRounds"), "the extraction schema must not ask for the limit"); - assertEquals( - context.withPerformative(NegotiationPerformative.PROPOSE), - result.negotiationContext(), - "the caller context travels in the metadata, stamped with the addressed performative"); - assertFalse( - result.promptText().contains("- id: " + NegotiationGoldenCases.SESSION_ID), - "the context lines must not be rendered into the message"); - } - - private static NegotiationGenerationOrchestrator orchestrator( - String language, LLMClient llmClient, int maxAttempts) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .llmClient(llmClient) - .maxAttempts(maxAttempts) - .build(); - } - - private static MetadataContent generateFromText( - NegotiationGenerationOrchestrator orchestrator, GoldenCase goldenCase, String text) { - return switch (goldenCase.performative()) { - case PROPOSE -> orchestrator.generateProposeFromText(text, goldenCase.context(), goldenCase.template()); - case ACCEPT -> orchestrator.generateAcceptFromText(text, goldenCase.context(), goldenCase.template()); - case REJECT -> orchestrator.generateRejectFromText(text, goldenCase.context(), goldenCase.template()); - case ABORT -> orchestrator.generateAbortFromText(text, goldenCase.context(), goldenCase.template()); - }; - } - - private static String phaseToken(NegotiationPerformative performative) { - return switch (performative) { - case PROPOSE -> "propose"; - case ACCEPT -> "accept"; - case REJECT -> "reject"; - case ABORT -> "abort"; - }; - } - - private static NegotiationTemplateLoader missingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - }; - } - - @SuppressWarnings("unchecked") - private static Map schemaProperties(Map jsonSchema) { - return (Map) jsonSchema.get("properties"); - } - - private static List logMessages(ListAppender appender, Level level) { - List messages = new ArrayList<>(); - for (ILoggingEvent event : appender.list) { - if (event.getLevel() == level) { - messages.add(event.getFormattedMessage()); - } - } - return messages; - } - - private static List warningMessagesOf(ListAppender appender) { - return logMessages(appender, Level.WARN); - } - - private List warningMessages(String prefix) { - List matches = new ArrayList<>(); - for (String message : warningMessagesOf(orchestratorAppender)) { - if (message.contains(prefix)) { - matches.add(message); - } - } - return matches; - } - - /** - * Builds the snake_case extraction JSON of one typed content so the scripted LLM response maps back to exactly that - * content. - */ - private static String extractionJson(NegotiationContent content, NegotiationPerformative performative) { - if (performative == NegotiationPerformative.PROPOSE) { - if (content instanceof InformationProposeContent info) { - return "{\"items\":" + itemsJson(info.items()) + ",\"relationship\":" - + stringOrNull(info.relationship()) + "}"; - } - if (content instanceof TargetProposeContent target) { - return "{\"target_negotiation_description\":" - + quote(target.targetNegotiationDescription()) - + ",\"intent_understanding\":" - + itemsJson(target.intentUnderstanding()) - + ",\"alignment_and_clarification\":" - + itemsJson(target.alignmentAndClarification()) - + ",\"request_for_clarification\":" - + itemsJson(target.requestForClarification()) - + ",\"target_confirm_request\":" - + stringOrNull(target.targetConfirmRequest()) - + "}"; - } - FeasibilityProposeContent feasibility = (FeasibilityProposeContent) content; - return "{\"feasibility_negotiation_description\":" - + quote(feasibility.feasibilityNegotiationDescription()) - + ",\"action\":\"" - + feasibility.action().name() - + "\",\"contents_to_evaluate\":" - + itemsJson(feasibility.contentsToEvaluate()) - + ",\"infeasibility_details_and_proposal\":" - + itemsJson(feasibility.infeasibilityDetailsAndProposal()) - + ",\"feasibility_confirm_request\":" - + stringOrNull(feasibility.feasibilityConfirmRequest()) - + "}"; - } - if (content instanceof NegotiationAbortContent abort) { - return "{\"termination_reason\":" + quote(abort.terminationReason()) + "}"; - } - if (content instanceof InformationEndingContent info) { - return "{\"conclusion\":" + quote(info.conclusion().literal()) + ",\"items\":" + itemsJson(info.items()) - + "}"; - } - if (content instanceof TargetEndingContent target) { - return "{\"conclusion\":" - + quote(target.conclusion().literal()) - + ",\"confirmed_intent\":" - + stringOrNull(target.confirmedIntent()) - + ",\"failure_reason\":" - + stringOrNull(target.failureReason()) - + "}"; - } - FeasibilityEndingContent feasibility = (FeasibilityEndingContent) content; - return "{\"conclusion\":" - + quote(feasibility.conclusion().literal()) - + ",\"feasibility_summary\":" - + stringOrNull(feasibility.feasibilitySummary()) - + "}"; - } - - private static String itemsJson(List items) { - if (items == null) { - return "null"; - } - List entries = new ArrayList<>(); - for (NegotiationItem item : items) { - entries.add("{\"name\":" + quote(item.name()) + ",\"value\":" + stringOrNull(item.value()) + "}"); - } - return "[" + String.join(",", entries) + "]"; - } - - private static String stringOrNull(String value) { - return value == null ? "null" : quote(value); - } - - private static String quote(String value) { - return "\"" - + value.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - + "\""; - } - - /** - * Scripted LLM client: returns the queued payloads in order, throws the queued failure, and repeats the last entry - * once the queue is exhausted. Every call records the request for later assertions. - */ - private static final class ScriptedExtractionClient implements LLMClient { - - private final List script; - - private int calls; - - private List> lastMessages; - - private Map lastSchema; - - private ScriptedExtractionClient(Object... entries) { - this.script = List.of(entries); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - Object next = script.get(Math.min(calls, script.size() - 1)); - calls++; - lastMessages = messages; - lastSchema = jsonSchema; - if (next instanceof RuntimeException failure) { - throw failure; - } - return new LLMResponse( - (String) next, "scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.LlmConfig; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.slf4j.LoggerFactory; + +/** + * Integration tests of the from-text generation pipeline against the real built-in resources. + * + *

            The LLM is replaced by a scripted client that returns queued payloads or failures; every other collaborator is the + * production wiring. The suite covers the single-step content extraction (one LLM call, no type-recognition step), the + * byte-equality of the from-text output with the from-data output and the golden fixtures, the step-level retry policy + * with its warning logs, the infrastructure failure paths, the non-retryable failure codes that must stop after one LLM + * call, the max-attempts configuration semantics and the default en-US end-to-end chain. + */ +class FromTextLlmPipelineTest { + + private static final String ZH_CN = NegotiationGoldenCases.ZH_CN; + + private static final String EN_US = NegotiationGoldenCases.EN_US; + + private static final TemplateUri INFORMATION_PROPOSE_URI = GoldenCase.INFORMATION_PROPOSE.template(); + + private final ListAppender orchestratorAppender = new ListAppender<>(); + + private final ListAppender extractorAppender = new ListAppender<>(); + + private final ListAppender llmConfigAppender = new ListAppender<>(); + + private final Logger orchestratorLogger = (Logger) LoggerFactory.getLogger(NegotiationGenerationOrchestrator.class); + + private final Logger extractorLogger = (Logger) LoggerFactory.getLogger(DefaultNegotiationContentExtractor.class); + + private final Logger llmConfigLogger = (Logger) LoggerFactory.getLogger(LlmConfig.class); + + @BeforeEach + void attachLogAppenders() { + orchestratorAppender.start(); + orchestratorLogger.addAppender(orchestratorAppender); + extractorAppender.start(); + extractorLogger.addAppender(extractorAppender); + llmConfigAppender.start(); + llmConfigLogger.addAppender(llmConfigAppender); + } + + @AfterEach + void detachLogAppenders() { + orchestratorLogger.detachAppender(orchestratorAppender); + extractorLogger.detachAppender(extractorAppender); + llmConfigLogger.detachAppender(llmConfigAppender); + } + + /** + * IT-B-001: the from-text chain runs exactly one content-extraction LLM call and then renders deterministically: + * the output equals both the from-data generation of the same content and the committed golden fixture byte for + * byte, the metadata contract holds, and no type-recognition step exists anywhere in the pipeline. + */ + @Test + void fromTextMatchesFromDataAndTheGoldenFixtureAfterOneExtractionCall() { + GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; + ScriptedExtractionClient llm = + new ScriptedExtractionClient(extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + MetadataContent fromText = orchestrator.generateProposeFromText( + "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", + goldenCase.context(), + goldenCase.template()); + + assertEquals(1, llm.calls, "from-text generation must run exactly one content extraction call"); + assertEquals(goldenCase.templateUri(), fromText.templateUri()); + + MetadataContent fromData = goldenCase.generate(orchestrator, ZH_CN); + assertEquals(1, llm.calls, "the from-data variant must not add any LLM call"); + assertEquals(fromData.promptText(), fromText.promptText()); + assertEquals(goldenCase.goldenText(ZH_CN), fromText.promptText()); + + Map metadata = fromText.buildMetadataContent(); + assertEquals(3, metadata.size()); + assertEquals(fromText.promptText(), metadata.get(fromText.extensionUri())); + assertEquals(fromText.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(goldenCase.context().withPerformative(goldenCase.performative()), fromText.negotiationContext()); + @SuppressWarnings("unchecked") + Map nestedContext = + (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + assertEquals(goldenCase.context().id(), nestedContext.get("id")); + assertEquals(goldenCase.context().round(), nestedContext.get("round")); + assertEquals(goldenCase.context().maxRounds(), nestedContext.get("maxRounds")); + + assertEquals( + 2, llm.lastMessages.size(), "the extraction step must be a single system-plus-user message exchange"); + assertTrue( + logMessages(extractorAppender, Level.INFO).stream() + .anyMatch(message -> message.startsWith("negotiation_content_extraction_completed")), + "the completed extraction step must emit its info event"); + assertTrue( + logMessages(orchestratorAppender, Level.INFO).stream() + .noneMatch(message -> message.contains("negotiation_type_recognition")) + && logMessages(extractorAppender, Level.INFO).stream() + .noneMatch(message -> message.contains("negotiation_type_recognition")), + "the removed type-recognition step must never appear in the events"); + } + + /** + * IT-B-002: every from-text method (propose, accept, reject, abort) succeeds for every negotiation type with + * exactly one LLM call per generation, the caller-supplied template URI is echoed, the output matches the golden + * fixture, the phase token reaches the LLM so the accept and reject phases can be distinguished, and the abort + * method addresses the termination-specific extraction prompt. + */ + @ParameterizedTest(name = "from-text succeeds for {0}") + @EnumSource(GoldenCase.class) + void fromTextSucceedsForEveryMethodAndTypeCombination(GoldenCase goldenCase) { + ScriptedExtractionClient llm = + new ScriptedExtractionClient(extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + MetadataContent result = generateFromText(orchestrator, goldenCase, "请根据以下专线投诉诊断协商文本生成协商报文。"); + + assertEquals(1, llm.calls); + assertEquals(goldenCase.templateUri(), result.templateUri()); + assertEquals(goldenCase.goldenText(ZH_CN), result.promptText()); + String userPrompt = llm.lastMessages.get(1).get("content"); + if (goldenCase.performative() == NegotiationPerformative.ABORT) { + assertTrue( + userPrompt.contains("终止"), + "the abort user prompt must address the termination content but was: " + userPrompt); + } else { + assertTrue( + userPrompt.contains(phaseToken(goldenCase.performative())), + "the user prompt must carry the phase token of the addressed method"); + } + } + + /** + * IT-B-002: the accept and reject methods share the same accept-reject template URI; both load that one template + * and differ only in the rendered conclusion literal. + */ + @Test + void acceptAndRejectShareTheSameAcceptRejectTemplate() { + assertEquals( + GoldenCase.INFORMATION_ACCEPT.templateUri(), + GoldenCase.INFORMATION_REJECT.templateUri(), + "accept and reject must address the same accept-reject template"); + + ScriptedExtractionClient acceptLlm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_ACCEPT.content(ZH_CN), NegotiationPerformative.ACCEPT)); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, acceptLlm, 3); + MetadataContent acceptResult = + generateFromText(orchestrator, GoldenCase.INFORMATION_ACCEPT, "请确认补充接入端口名称与投诉分类。"); + assertEquals(1, acceptLlm.calls); + + ScriptedExtractionClient rejectLlm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_REJECT.content(ZH_CN), NegotiationPerformative.REJECT)); + MetadataContent rejectResult = + generateFromText(orchestrator(ZH_CN, rejectLlm, 3), GoldenCase.INFORMATION_REJECT, "请拒绝。"); + assertEquals(1, rejectLlm.calls); + + assertTrue( + acceptResult.promptText().contains("\nAccept\n"), "the accept output must render the Accept literal"); + assertTrue( + rejectResult.promptText().contains("\nReject\n"), "the reject output must render the Reject literal"); + assertEquals(acceptResult.templateUri(), rejectResult.templateUri()); + } + + /** + * IT-B-003: a retryable extraction failure is retried until it succeeds; every failed attempt logs a warning with + * the step, attempt, attempt limit and failure code, and the successful output still matches the golden fixture. + */ + @Test + void retriesContentExtractionUntilItSucceeds() { + GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; + ScriptedExtractionClient llm = new ScriptedExtractionClient( + "", "", extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + MetadataContent result = + orchestrator.generateProposeFromText("请提供接入端口名称。", goldenCase.context(), goldenCase.template()); + + assertEquals(3, llm.calls, "two failures followed by one success must result in three calls"); + assertEquals(goldenCase.goldenText(ZH_CN), result.promptText()); + + List retries = warningMessages("negotiation_llm_retry "); + assertEquals(2, retries.size()); + for (int attempt = 1; attempt <= 2; attempt++) { + String retry = retries.get(attempt - 1); + assertTrue(retry.contains("step=negotiation_content_extract"), retry); + assertTrue(retry.contains("attempt=" + attempt), retry); + assertTrue(retry.contains("max_attempts=3"), retry); + assertTrue(retry.contains("code=" + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()), retry); + } + assertTrue( + warningMessages("negotiation_llm_retry_exhausted").isEmpty(), + "a successful retry sequence must not log the exhaustion event"); + } + + /** + * IT-B-003: when every attempt fails with the retryable extraction failure code, the original error code is + * rethrown after the exhaustion warning has been logged. + */ + @Test + void rethrowsTheOriginalCodeWhenRetriesAreExhausted() { + ScriptedExtractionClient llm = new ScriptedExtractionClient("", "", ""); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); + assertEquals(3, llm.calls); + + List exhausted = warningMessages("negotiation_llm_retry_exhausted"); + assertEquals(1, exhausted.size()); + assertTrue(exhausted.get(0).contains("step=negotiation_content_extract"), exhausted.get(0)); + assertTrue(exhausted.get(0).contains("max_attempts=3"), exhausted.get(0)); + assertTrue(exhausted.get(0).contains("code=" + ErrorCatalog.LLM_RESPONSE_INVALID.getCode()), exhausted.get(0)); + assertEquals(2, warningMessages("negotiation_llm_retry ").size()); + assertTrue( + warningMessages("negotiation_generation_failed").stream() + .anyMatch(message -> message.contains("code=" + ErrorCatalog.LLM_RESPONSE_INVALID.getCode())), + "the surfaced generation failure must be logged with its code"); + } + + /** + * IT-B-004: persistent transport failures of the LLM client are retried and surface as the infrastructure error + * code on the generation side. + */ + @Test + void retriesInfrastructureFailuresUntilExhaustion() { + ScriptedExtractionClient llm = new ScriptedExtractionClient(new IllegalStateException("connection refused")); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), failure.getCode()); + assertEquals(2, llm.calls); + assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); + assertTrue(warningMessages("negotiation_llm_retry_exhausted") + .get(0) + .contains("code=" + ErrorCatalog.LLM_INVOCATION_FAILED.getCode())); + } + + /** + * IT-B-004: a persistently unparseable LLM response fails with the retryable extraction failure code after the + * retries are exhausted, rethrowing the original code. + */ + @Test + void retriesUnparseableResponsesUntilExhaustion() { + ScriptedExtractionClient llm = new ScriptedExtractionClient(""); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); + assertEquals(2, llm.calls); + assertEquals(1, warningMessages("negotiation_llm_retry ").size()); + assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); + } + + /** + * IT-B-005 (1): an extracted conclusion that contradicts the method phase fails with the non-retryable + * invalid-input code after exactly one LLM call and without any retry event. + */ + @Test + void phaseConclusionMismatchFailsFastWithConclusionMismatch() { + ScriptedExtractionClient llm = new ScriptedExtractionClient( + "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}]}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateAcceptFromText( + "请接受。", GoldenCase.INFORMATION_ACCEPT.context(), GoldenCase.INFORMATION_ACCEPT.template())); + + assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), failure.getCode()); + assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); + assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); + assertTrue(warningMessages("negotiation_llm_retry_exhausted").isEmpty()); + } + + /** + * IT-B-005 (2): an extraction response missing a required field fails with the non-retryable slot-missing code + * after exactly one LLM call. + */ + @Test + void missingRequiredFieldFailsFastWithSlotMissing() { + ScriptedExtractionClient llm = new ScriptedExtractionClient("{\"relationship\":null}"); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), failure.getCode()); + assertEquals(1, llm.calls, "non-retryable failures must not be attempted again"); + assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); + assertTrue(warningMessages("negotiation_llm_retry_exhausted").isEmpty()); + } + + /** + * IT-B-005 (3): a template URI without any loadable template fails with template-not-found before the LLM is + * touched at all. + */ + @Test + void missingTemplateFailsBeforeAnyLlmCall() { + ScriptedExtractionClient llm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPerformative.PROPOSE)); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .llmClient(llm) + .templateLoader(missingTemplateLoader()) + .build(); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), failure.getCode()); + assertEquals(0, llm.calls, "the template must be loaded before the LLM step is started"); + } + + /** + * IT-B-005 (4): a template URI whose phase segment contradicts the method is a programming error with an + * {@link IllegalArgumentException} pointing at the template URI and never reaches the LLM. + */ + @Test + void phaseMismatchedTemplateUriFailsBeforeAnyLlmCall() { + ScriptedExtractionClient llm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPerformative.PROPOSE)); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", + GoldenCase.INFORMATION_PROPOSE.context(), + GoldenCase.INFORMATION_ACCEPT.template())); + + assertTrue( + failure.getMessage() + .contains( + "Template URI does not address a negotiation template of the expected performative PROPOSE"), + "the failure must point at the template URI but was: " + failure.getMessage()); + assertEquals(0, llm.calls); + } + + /** + * IT-B-006 (1): a single allowed attempt makes the retryable extraction failure surface after exactly one call, + * with the exhaustion warning but without any per-attempt retry warning. + */ + @Test + void singleAttemptFailsFast() { + ScriptedExtractionClient llm = new ScriptedExtractionClient(""); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 1); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); + assertEquals(1, llm.calls); + assertTrue(warningMessages("negotiation_llm_retry ").isEmpty()); + assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); + } + + /** IT-B-006 (2): an attempt limit of two allows exactly one retry of a retryable failure. */ + @Test + void twoAttemptsRetryExactlyOnce() { + ScriptedExtractionClient llm = new ScriptedExtractionClient("", ""); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 2); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供接入端口名称。", GoldenCase.INFORMATION_PROPOSE.context(), INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); + assertEquals(2, llm.calls); + assertEquals(1, warningMessages("negotiation_llm_retry ").size()); + assertTrue(warningMessages("negotiation_llm_retry ").get(0).contains("max_attempts=2")); + assertEquals(1, warningMessages("negotiation_llm_retry_exhausted").size()); + } + + /** + * IT-B-006 (3): the clamped attempt limits of the unified LLM config drive the retry loop: the value 99 clamps to + * 10 with a warning and allows nine failed attempts before a success, and the value 0 clamps to 1 with a warning + * and fails fast. + */ + @Test + void clampedConfigValuesDriveTheRetryLoop() { + LlmConfig large = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "99")); + assertEquals(10, large.maxAttempts()); + assertTrue( + warningMessagesOf(llmConfigAppender).stream() + .anyMatch(message -> message.contains("raw_value=99") && message.contains("clamped_value=10")), + "the clamp of the value 99 must be warned about"); + + GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; + ScriptedExtractionClient recovering = new ScriptedExtractionClient( + "", + "", + "", + "", + "", + "", + "", + "", + "", + extractionJson(goldenCase.content(ZH_CN), goldenCase.performative())); + NegotiationGenerationOrchestrator recoveringOrchestrator = orchestrator(ZH_CN, recovering, large.maxAttempts()); + + MetadataContent result = recoveringOrchestrator.generateProposeFromText( + "请提供接入端口名称。", goldenCase.context(), goldenCase.template()); + + assertEquals(10, recovering.calls, "nine failed attempts plus one success must consume the clamped limit"); + assertEquals(goldenCase.goldenText(ZH_CN), result.promptText()); + assertEquals(9, warningMessages("negotiation_llm_retry ").size()); + + LlmConfig small = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "0")); + assertEquals(1, small.maxAttempts()); + assertTrue( + warningMessagesOf(llmConfigAppender).stream() + .anyMatch(message -> message.contains("raw_value=0") && message.contains("clamped_value=1")), + "the clamp of the value 0 must be warned about"); + + ScriptedExtractionClient failing = new ScriptedExtractionClient(""); + NegotiationGenerationOrchestrator failingOrchestrator = orchestrator(ZH_CN, failing, small.maxAttempts()); + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> failingOrchestrator.generateProposeFromText( + "请提供接入端口名称。", goldenCase.context(), goldenCase.template())); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), failure.getCode()); + assertEquals(1, failing.calls); + } + + /** + * IT-B-007: with no language configured the unified config resolves the default en-US, and the whole chain works + * against the real en-US resources: one extraction call for the from-text generation, prompt resources that are + * really loaded from the classpath, and a second single LLM call for the validation pipeline. + */ + @Test + void defaultEnglishConfigurationWorksEndToEnd(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=local_rule + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """); + A2ATConfig config = A2ATConfig.load(envFile); + assertEquals(EN_US, config.prompt().language(), "the default language without A2AT_LANGUAGE is en-US"); + assertEquals(3, config.llm().maxAttempts(), "the default attempt limit is 3"); + + GoldenCase goldenCase = GoldenCase.INFORMATION_PROPOSE; + String inputText = "Please provide the access port name and the complaint category of the private line."; + Map bizSchema = Map.of( + "type", + "object", + "properties", + Map.of( + "accessPort", Map.of("type", "string"), + "bizScenario", Map.of("type", "string")), + "required", + List.of("accessPort", "bizScenario")); + ScriptedExtractionClient llm = new ScriptedExtractionClient( + extractionJson(goldenCase.content(EN_US), goldenCase.performative()), + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"accessPort\":\"P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\"," + + "\"bizScenario\":\"dedicated-line quality degradation\"}}"); + NegotiationGenerationOrchestrator orchestrator = + orchestrator(config.prompt().language(), llm, config.llm().maxAttempts()); + + MetadataContent message = + orchestrator.generateProposeFromText(inputText, goldenCase.context(), goldenCase.template()); + + assertEquals(1, llm.calls, "the en-US from-text chain needs exactly one extraction call"); + assertEquals(goldenCase.goldenText(EN_US), message.promptText()); + assertEquals(2, llm.lastMessages.size()); + assertFalse(llm.lastMessages.get(0).get("content").isBlank(), "the en-US system prompt must be loadable"); + assertTrue(llm.lastMessages.get(1).get("content").contains(inputText)); + assertTrue( + llm.lastMessages.get(1).get("content").contains("propose"), + "the user prompt must carry the phase token"); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message.promptText(), goldenCase.context(), bizSchema, goldenCase.template()); + + assertEquals(2, llm.calls, "the validation pipeline adds exactly one semantic validation call"); + assertEquals(goldenCase.context().id(), filled.data().get("id")); + assertEquals(goldenCase.context().round(), filled.data().get("round")); + assertEquals(goldenCase.context().maxRounds(), filled.data().get("maxRounds")); + assertEquals( + "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); + assertEquals("dedicated-line quality degradation", filled.data().get("bizScenario")); + } + + /** + * IT-B-008: the negotiation context of the generated message comes from the caller-supplied context: the extraction + * schema sent to the LLM has no context fields and the metadata carries exactly the caller's context. + */ + @Test + void contextIsInjectedFromTheCallerWithoutAnyLlmInvolvement() { + NegotiationContext context = + new NegotiationContext(NegotiationGoldenCases.SESSION_ID, 4, 7, NegotiationPerformative.PROPOSE); + ScriptedExtractionClient llm = new ScriptedExtractionClient( + extractionJson(GoldenCase.INFORMATION_PROPOSE.content(ZH_CN), NegotiationPerformative.PROPOSE)); + NegotiationGenerationOrchestrator orchestrator = orchestrator(ZH_CN, llm, 3); + + MetadataContent result = orchestrator.generateProposeFromText("请提供接入端口名称。", context, INFORMATION_PROPOSE_URI); + + assertEquals(1, llm.calls); + Map schemaProperties = schemaProperties(llm.lastSchema); + assertFalse(schemaProperties.containsKey("id"), "the extraction schema must not ask for the context id"); + assertFalse(schemaProperties.containsKey("round"), "the extraction schema must not ask for the round"); + assertFalse(schemaProperties.containsKey("maxRounds"), "the extraction schema must not ask for the limit"); + assertEquals( + context.withPerformative(NegotiationPerformative.PROPOSE), + result.negotiationContext(), + "the caller context travels in the metadata, stamped with the addressed performative"); + assertFalse( + result.promptText().contains("- id: " + NegotiationGoldenCases.SESSION_ID), + "the context lines must not be rendered into the message"); + } + + private static NegotiationGenerationOrchestrator orchestrator( + String language, LLMClient llmClient, int maxAttempts) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .llmClient(llmClient) + .maxAttempts(maxAttempts) + .build(); + } + + private static MetadataContent generateFromText( + NegotiationGenerationOrchestrator orchestrator, GoldenCase goldenCase, String text) { + return switch (goldenCase.performative()) { + case PROPOSE -> orchestrator.generateProposeFromText(text, goldenCase.context(), goldenCase.template()); + case ACCEPT -> orchestrator.generateAcceptFromText(text, goldenCase.context(), goldenCase.template()); + case REJECT -> orchestrator.generateRejectFromText(text, goldenCase.context(), goldenCase.template()); + case ABORT -> orchestrator.generateAbortFromText(text, goldenCase.context(), goldenCase.template()); + }; + } + + private static String phaseToken(NegotiationPerformative performative) { + return switch (performative) { + case PROPOSE -> "propose"; + case ACCEPT -> "accept"; + case REJECT -> "reject"; + case ABORT -> "abort"; + }; + } + + private static NegotiationTemplateLoader missingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + }; + } + + @SuppressWarnings("unchecked") + private static Map schemaProperties(Map jsonSchema) { + return (Map) jsonSchema.get("properties"); + } + + private static List logMessages(ListAppender appender, Level level) { + List messages = new ArrayList<>(); + for (ILoggingEvent event : appender.list) { + if (event.getLevel() == level) { + messages.add(event.getFormattedMessage()); + } + } + return messages; + } + + private static List warningMessagesOf(ListAppender appender) { + return logMessages(appender, Level.WARN); + } + + private List warningMessages(String prefix) { + List matches = new ArrayList<>(); + for (String message : warningMessagesOf(orchestratorAppender)) { + if (message.contains(prefix)) { + matches.add(message); + } + } + return matches; + } + + /** + * Builds the snake_case extraction JSON of one typed content so the scripted LLM response maps back to exactly that + * content. + */ + private static String extractionJson(NegotiationContent content, NegotiationPerformative performative) { + if (performative == NegotiationPerformative.PROPOSE) { + if (content instanceof InformationProposeContent info) { + return "{\"items\":" + itemsJson(info.items()) + ",\"relationship\":" + + stringOrNull(info.relationship()) + "}"; + } + if (content instanceof TargetProposeContent target) { + return "{\"target_negotiation_description\":" + + quote(target.targetNegotiationDescription()) + + ",\"intent_understanding\":" + + itemsJson(target.intentUnderstanding()) + + ",\"alignment_and_clarification\":" + + itemsJson(target.alignmentAndClarification()) + + ",\"request_for_clarification\":" + + itemsJson(target.requestForClarification()) + + ",\"target_confirm_request\":" + + stringOrNull(target.targetConfirmRequest()) + + "}"; + } + FeasibilityProposeContent feasibility = (FeasibilityProposeContent) content; + return "{\"feasibility_negotiation_description\":" + + quote(feasibility.feasibilityNegotiationDescription()) + + ",\"action\":\"" + + feasibility.action().name() + + "\",\"contents_to_evaluate\":" + + itemsJson(feasibility.contentsToEvaluate()) + + ",\"infeasibility_details_and_proposal\":" + + itemsJson(feasibility.infeasibilityDetailsAndProposal()) + + ",\"feasibility_confirm_request\":" + + stringOrNull(feasibility.feasibilityConfirmRequest()) + + "}"; + } + if (content instanceof NegotiationAbortContent abort) { + return "{\"termination_reason\":" + quote(abort.terminationReason()) + "}"; + } + if (content instanceof InformationEndingContent info) { + return "{\"conclusion\":" + quote(info.conclusion().literal()) + ",\"items\":" + itemsJson(info.items()) + + "}"; + } + if (content instanceof TargetEndingContent target) { + return "{\"conclusion\":" + + quote(target.conclusion().literal()) + + ",\"confirmed_intent\":" + + stringOrNull(target.confirmedIntent()) + + ",\"failure_reason\":" + + stringOrNull(target.failureReason()) + + "}"; + } + FeasibilityEndingContent feasibility = (FeasibilityEndingContent) content; + return "{\"conclusion\":" + + quote(feasibility.conclusion().literal()) + + ",\"feasibility_summary\":" + + stringOrNull(feasibility.feasibilitySummary()) + + "}"; + } + + private static String itemsJson(List items) { + if (items == null) { + return "null"; + } + List entries = new ArrayList<>(); + for (NegotiationItem item : items) { + entries.add("{\"name\":" + quote(item.name()) + ",\"value\":" + stringOrNull(item.value()) + "}"); + } + return "[" + String.join(",", entries) + "]"; + } + + private static String stringOrNull(String value) { + return value == null ? "null" : quote(value); + } + + private static String quote(String value) { + return "\"" + + value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + + "\""; + } + + /** + * Scripted LLM client: returns the queued payloads in order, throws the queued failure, and repeats the last entry + * once the queue is exhausted. Every call records the request for later assertions. + */ + private static final class ScriptedExtractionClient implements LLMClient { + + private final List script; + + private int calls; + + private List> lastMessages; + + private Map lastSchema; + + private ScriptedExtractionClient(Object... entries) { + this.script = List.of(entries); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + Object next = script.get(Math.min(calls, script.size() - 1)); + calls++; + lastMessages = messages; + lastSchema = jsonSchema; + if (next instanceof RuntimeException failure) { + throw failure; + } + return new LLMResponse( + (String) next, "scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/InputTextLengthLimitTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/InputTextLengthLimitTest.java index 8bbe6686..1a67357d 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/InputTextLengthLimitTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/InputTextLengthLimitTest.java @@ -1,168 +1,168 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.InputLimitConfig; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; -import org.junit.jupiter.api.Test; - -/** - * Tests of the free-text input length limit of the negotiation pipelines. - * - *

            Oversized inputs fail fast with the code {@code input.text_too_long} before any LLM call, inputs at exactly the - * limit pass the gate and reach the LLM, and the limit is configurable through the builder. - */ -class InputTextLengthLimitTest { - - private static final String ZH_CN = NegotiationGoldenCases.ZH_CN; - - private static final GoldenCase PROPOSE = GoldenCase.INFORMATION_PROPOSE; - - /** A text one character over the default limit must be rejected with the dedicated code before any LLM call. */ - @Test - void rejectsFromTextOverTheDefaultLimitBeforeAnyLlmCall() { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1), - PROPOSE.context(), - PROPOSE.template())); - - assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); - assertTrue( - failure.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1)), - "the violation message must state the actual length but was: " + failure.getMessage()); - assertTrue( - failure.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS)), - "the violation message must state the limit but was: " + failure.getMessage()); - assertEquals(0, llm.calls, "an oversized text must never reach the LLM"); - } - - /** A text at exactly the default limit passes the gate and reaches the extraction LLM call. */ - @Test - void acceptsFromTextAtExactlyTheDefaultLimit() { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), PROPOSE.context(), PROPOSE.template())); - - assertNotEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); - assertTrue(llm.calls >= 1, "a text at exactly the limit must reach the LLM"); - } - - /** An oversized prompt of the validation pipeline is rejected with the dedicated code before any LLM call. */ - @Test - void rejectsValidationPromptOverTheDefaultLimitBeforeAnyLlmCall() { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); - - NegotiationParamExtractionException failure = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1), - PROPOSE.context(), - Map.of("type", "object"), - PROPOSE.template())); - - assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); - assertEquals(0, llm.calls, "an oversized prompt must never reach the LLM"); - } - - /** - * A validation prompt at exactly the default limit passes the gate and reaches the semantic validation LLM call. - */ - @Test - void acceptsValidationPromptAtExactlyTheDefaultLimit() { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); - - NegotiationParamExtractionException failure = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), - PROPOSE.context(), - Map.of("type", "object"), - PROPOSE.template())); - - assertNotEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); - assertTrue(llm.calls >= 1, "a prompt at exactly the limit must reach the LLM"); - } - - /** A configured limit below the default drives the rejection: a text one character over it fails fast. */ - @Test - void customLimitDrivesTheRejection() { - CountingClient llm = new CountingClient(); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language(ZH_CN) - .llmClient(llm) - .maxTextChars(10) - .build(); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText("01234567890", PROPOSE.context(), PROPOSE.template())); - - assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); - assertEquals(0, llm.calls); - } - - /** The builder rejects a non-positive limit as a programming error. */ - @Test - void builderRejectsNonPositiveLimit() { - IllegalStateException failure = - assertThrows(IllegalStateException.class, () -> NegotiationGenerationOrchestratorBuilder.builder() - .language(ZH_CN) - .maxTextChars(0) - .build()); - - assertTrue( - failure.getMessage().contains("max text chars"), - "the failure must point at the limit but was: " + failure.getMessage()); - } - - private static NegotiationGenerationOrchestrator defaultOrchestrator(LLMClient llmClient) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(ZH_CN) - .llmClient(llmClient) - .build(); - } - - private static String textOfLength(int length) { - return "x".repeat(length); - } - - /** - * LLM client stub recording every call and returning an empty payload, so the tests can assert both that a call - * happened and that the pipeline fails downstream of the length gate instead of inside it. - */ - private static final class CountingClient implements LLMClient { - - private int calls; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - return new LLMResponse("", "counting-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; +import org.junit.jupiter.api.Test; + +/** + * Tests of the free-text input length limit of the negotiation pipelines. + * + *

            Oversized inputs fail fast with the code {@code input.text_too_long} before any LLM call, inputs at exactly the + * limit pass the gate and reach the LLM, and the limit is configurable through the builder. + */ +class InputTextLengthLimitTest { + + private static final String ZH_CN = NegotiationGoldenCases.ZH_CN; + + private static final GoldenCase PROPOSE = GoldenCase.INFORMATION_PROPOSE; + + /** A text one character over the default limit must be rejected with the dedicated code before any LLM call. */ + @Test + void rejectsFromTextOverTheDefaultLimitBeforeAnyLlmCall() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1), + PROPOSE.context(), + PROPOSE.template())); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertTrue( + failure.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1)), + "the violation message must state the actual length but was: " + failure.getMessage()); + assertTrue( + failure.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS)), + "the violation message must state the limit but was: " + failure.getMessage()); + assertEquals(0, llm.calls, "an oversized text must never reach the LLM"); + } + + /** A text at exactly the default limit passes the gate and reaches the extraction LLM call. */ + @Test + void acceptsFromTextAtExactlyTheDefaultLimit() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), PROPOSE.context(), PROPOSE.template())); + + assertNotEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertTrue(llm.calls >= 1, "a text at exactly the limit must reach the LLM"); + } + + /** An oversized prompt of the validation pipeline is rejected with the dedicated code before any LLM call. */ + @Test + void rejectsValidationPromptOverTheDefaultLimitBeforeAnyLlmCall() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1), + PROPOSE.context(), + Map.of("type", "object"), + PROPOSE.template())); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertEquals(0, llm.calls, "an oversized prompt must never reach the LLM"); + } + + /** + * A validation prompt at exactly the default limit passes the gate and reaches the semantic validation LLM call. + */ + @Test + void acceptsValidationPromptAtExactlyTheDefaultLimit() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), + PROPOSE.context(), + Map.of("type", "object"), + PROPOSE.template())); + + assertNotEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertTrue(llm.calls >= 1, "a prompt at exactly the limit must reach the LLM"); + } + + /** A configured limit below the default drives the rejection: a text one character over it fails fast. */ + @Test + void customLimitDrivesTheRejection() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .llmClient(llm) + .maxTextChars(10) + .build(); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText("01234567890", PROPOSE.context(), PROPOSE.template())); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertEquals(0, llm.calls); + } + + /** The builder rejects a non-positive limit as a programming error. */ + @Test + void builderRejectsNonPositiveLimit() { + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .maxTextChars(0) + .build()); + + assertTrue( + failure.getMessage().contains("max text chars"), + "the failure must point at the limit but was: " + failure.getMessage()); + } + + private static NegotiationGenerationOrchestrator defaultOrchestrator(LLMClient llmClient) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .llmClient(llmClient) + .build(); + } + + private static String textOfLength(int length) { + return "x".repeat(length); + } + + /** + * LLM client stub recording every call and returning an empty payload, so the tests can assert both that a call + * happened and that the pipeline fails downstream of the length gate instead of inside it. + */ + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + return new LLMResponse("", "counting-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java index 966702a2..0beb3e76 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java @@ -1,417 +1,417 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -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 java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.junit.jupiter.api.Test; - -class NegotiationContentExtractorTest { - - @Test - void extractsInformationProposeContent() { - RecordingClient llm = new RecordingClient( - "{\"items\":[{\"name\":\"故障发生时间\",\"value\":\"精确到分钟的时间点\"},{\"name\":\"受影响小区标识\",\"value\":null}]," - + "\"relationship\":\"故障发生时间与受影响小区标识需逐小区对应\"}"); - - NegotiationContent content = new DefaultNegotiationContentExtractor(llm) - .extract( - "请提供故障发生时间与受影响小区标识。", - reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN")); - - InformationProposeContent proposeContent = assertInstanceOf(InformationProposeContent.class, content); - assertEquals( - List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点"), new NegotiationItem("受影响小区标识", null)), - proposeContent.items()); - assertEquals("故障发生时间与受影响小区标识需逐小区对应", proposeContent.relationship()); - - assertEquals(2, llm.lastMessages.size()); - assertEquals("system", llm.lastMessages.get(0).get("role")); - assertEquals("user", llm.lastMessages.get(1).get("role")); - assertTrue(llm.lastMessages.get(1).get("content").contains("协商阶段:propose")); - assertTrue(llm.lastMessages.get(1).get("content").contains("请提供故障发生时间与受影响小区标识。")); - assertEquals( - new NegotiationJsonSchemaBuilder() - .buildExtractionSchema(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE), - llm.lastSchema); - } - - @Test - void extractsTargetProposeContent() { - NegotiationContent content = new DefaultNegotiationContentExtractor( - new RecordingClient("{\"target_negotiation_description\":\"请求将节能目标由30%调整为20%。\"," - + "\"intent_understanding\":[{\"name\":\"发起方理解\",\"value\":\"对方希望降低节能力度\"}]," - + "\"alignment_and_clarification\":null," - + "\"request_for_clarification\":[{\"name\":\"速率保障下限\",\"value\":null}]}")) - .extract("请求调整节能目标。", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "en-US")); - - TargetProposeContent proposeContent = assertInstanceOf(TargetProposeContent.class, content); - assertEquals("请求将节能目标由30%调整为20%。", proposeContent.targetNegotiationDescription()); - assertEquals(List.of(new NegotiationItem("发起方理解", "对方希望降低节能力度")), proposeContent.intentUnderstanding()); - assertNull(proposeContent.alignmentAndClarification()); - assertEquals(List.of(new NegotiationItem("速率保障下限", null)), proposeContent.requestForClarification()); - } - - @Test - void extractsFeasibilityProposeContentForBothActions() { - NegotiationContent request = new DefaultNegotiationContentExtractor(new RecordingClient( - "{\"feasibility_negotiation_description\":\"请评估该节能目标能否达成。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," - + "\"contents_to_evaluate\":[{\"name\":\"评估对象\",\"value\":\"停电8小时期间的速率保障\"}]," - + "\"infeasibility_details_and_proposal\":null}")) - .extract("请评估。", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN")); - - FeasibilityProposeContent requestContent = assertInstanceOf(FeasibilityProposeContent.class, request); - assertEquals(NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, requestContent.action()); - assertEquals(List.of(new NegotiationItem("评估对象", "停电8小时期间的速率保障")), requestContent.contentsToEvaluate()); - - NegotiationContent alternative = new DefaultNegotiationContentExtractor( - new RecordingClient( - "{\"feasibility_negotiation_description\":\"目标不可行,提出下调方案。\",\"action\":\"PROPOSE_ALTERNATIVE_ON_FAILURE\"," - + "\"contents_to_evaluate\":null," - + "\"infeasibility_details_and_proposal\":[{\"name\":\"替代提案\",\"value\":\"下调至2Mbps\"}]}")) - .extract("不可行。", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN")); - - FeasibilityProposeContent alternativeContent = assertInstanceOf(FeasibilityProposeContent.class, alternative); - assertEquals(NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, alternativeContent.action()); - assertEquals( - List.of(new NegotiationItem("替代提案", "下调至2Mbps")), alternativeContent.infeasibilityDetailsAndProposal()); - } - - @Test - void extractsTargetProposeConfirmRequestContent() { - NegotiationContent content = new DefaultNegotiationContentExtractor( - new RecordingClient("{\"target_negotiation_description\":\"任务目标澄清完成,请答复<目标澄清后的确认请求>。\"," - + "\"intent_understanding\":null," - + "\"alignment_and_clarification\":null," - + "\"request_for_clarification\":null," - + "\"target_confirm_request\":\"目标已经澄清,是否同意按照此目标继续执行?\"}")) - .extract("目标已经澄清,请确认。", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "zh-CN")); - - TargetProposeContent proposeContent = assertInstanceOf(TargetProposeContent.class, content); - assertEquals("任务目标澄清完成,请答复<目标澄清后的确认请求>。", proposeContent.targetNegotiationDescription()); - assertEquals("目标已经澄清,是否同意按照此目标继续执行?", proposeContent.targetConfirmRequest()); - assertNull(proposeContent.intentUnderstanding()); - assertNull(proposeContent.alignmentAndClarification()); - assertNull(proposeContent.requestForClarification()); - } - - @Test - void extractsFeasibilityProposeConfirmRequestContent() { - NegotiationContent content = new DefaultNegotiationContentExtractor(new RecordingClient( - "{\"feasibility_negotiation_description\":\"针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。\"," - + "\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," - + "\"contents_to_evaluate\":null," - + "\"infeasibility_details_and_proposal\":null," - + "\"feasibility_confirm_request\":\"评估目标可行,是否同意按照此目标继续执行?\"}")) - .extract( - "评估目标可行,请确认。", - reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN")); - - FeasibilityProposeContent proposeContent = assertInstanceOf(FeasibilityProposeContent.class, content); - assertEquals(NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, proposeContent.action()); - assertEquals("评估目标可行,是否同意按照此目标继续执行?", proposeContent.feasibilityConfirmRequest()); - assertNull(proposeContent.contentsToEvaluate()); - assertNull(proposeContent.infeasibilityDetailsAndProposal()); - } - - @Test - void passesConfirmRequestWordingThroughLeniently() { - TargetProposeContent target = assertInstanceOf( - TargetProposeContent.class, - new DefaultNegotiationContentExtractor(new RecordingClient( - "{\"target_negotiation_description\":\"描述\",\"intent_understanding\":null," - + "\"alignment_and_clarification\":null,\"request_for_clarification\":null," - + "\"target_confirm_request\":\"请确认按此目标推进\"}")) - .extract("文本", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "zh-CN"))); - assertEquals("请确认按此目标推进", target.targetConfirmRequest()); - - FeasibilityProposeContent feasibility = assertInstanceOf( - FeasibilityProposeContent.class, - new DefaultNegotiationContentExtractor(new RecordingClient( - "{\"feasibility_negotiation_description\":\"描述\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," - + "\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null," - + "\"feasibility_confirm_request\":\"方案可行,望确认\"}")) - .extract( - "文本", - reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); - assertEquals("方案可行,望确认", feasibility.feasibilityConfirmRequest()); - } - - @Test - void extractsEndingContentForEveryTypeAndBothPhases() { - NegotiationContent infoAccept = new DefaultNegotiationContentExtractor(new RecordingClient( - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"故障发生时间\",\"value\":\"2026-08-19 10:30\"}]}")) - .extract("同意提供。", reference(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, "zh-CN")); - - InformationEndingContent infoEnding = assertInstanceOf(InformationEndingContent.class, infoAccept); - assertEquals(NegotiationConclusion.ACCEPT, infoEnding.conclusion()); - assertEquals(List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30")), infoEnding.items()); - - NegotiationContent targetReject = new DefaultNegotiationContentExtractor(new RecordingClient( - "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"双方未达成一致。\"}")) - .extract("无法同意。", reference(NegotiationType.TARGET, NegotiationPerformative.REJECT, "zh-CN")); - - TargetEndingContent targetEnding = assertInstanceOf(TargetEndingContent.class, targetReject); - assertEquals(NegotiationConclusion.REJECT, targetEnding.conclusion()); - assertEquals("双方未达成一致。", targetEnding.failureReason()); - - NegotiationContent feasibilityAccept = new DefaultNegotiationContentExtractor( - new RecordingClient("{\"conclusion\":\"Accept\",\"feasibility_summary\":\"同意下调至2Mbps。\"}")) - .extract("同意。", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.ACCEPT, "zh-CN")); - - assertEquals( - "同意下调至2Mbps。", - assertInstanceOf( - net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent.class, - feasibilityAccept) - .feasibilitySummary()); - - assertEquals("accept", phaseTokenInUserPrompt(NegotiationPerformative.ACCEPT)); - assertEquals("reject", phaseTokenInUserPrompt(NegotiationPerformative.REJECT)); - assertEquals("propose", phaseTokenInUserPrompt(NegotiationPerformative.PROPOSE)); - } - - @Test - void mapsTransportFailuresToTheInvocationFailedCode() { - LLMClient failing = (messages, jsonSchema, temperature, maxTokens) -> { - throw new IllegalStateException("connection reset"); - }; - - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(failing) - .extract( - "文本", - reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - - assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), exception.getCode()); - } - - @Test - void mapsMissingLlmClientToTheNotConfiguredCode() { - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(null) - .extract( - "文本", - reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - - assertEquals(ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), exception.getCode()); - } - - @Test - void mapsResponseContractViolationsAndShapeFailures() { - NegotiationGenerationException unparseable = - assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( - new RecordingClient("这不是 JSON")) - .extract( - "文本", - reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - - assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), unparseable.getCode()); - - NegotiationGenerationException wrongShape = - assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( - new RecordingClient("{\"items\":\"不是数组\"}")) - .extract( - "文本", - reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), wrongShape.getCode()); - assertEquals("items", wrongShape.getFacts().get("field")); - - NegotiationGenerationException emptyResponse = - assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( - new RecordingClient(" ")) - .extract( - "文本", - reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - - assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), emptyResponse.getCode()); - } - - @Test - void mapsMissingRequiredFieldsToTheSlotMissingCode() { - NegotiationGenerationException missingItems = - assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( - new RecordingClient("{\"relationship\":null}")) - .extract( - "文本", - reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - - assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingItems.getCode()); - assertTrue(missingItems.getMessage().contains("items")); - - NegotiationGenerationException missingDescription = assertThrows( - NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( - "{\"intent_understanding\":null,\"alignment_and_clarification\":null," - + "\"request_for_clarification\":null}")) - .extract("文本", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "zh-CN"))); - - assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingDescription.getCode()); - assertTrue(missingDescription.getMessage().contains("target_negotiation_description")); - - NegotiationGenerationException missingConclusion = - assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( - new RecordingClient("{\"items\":[]}")) - .extract( - "文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, "zh-CN"))); - - assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingConclusion.getCode()); - assertTrue(missingConclusion.getMessage().contains("conclusion")); - - NegotiationGenerationException missingConfirmedIntent = assertThrows( - NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( - "{\"conclusion\":\"Accept\",\"confirmed_intent\":null,\"failure_reason\":null}")) - .extract("文本", reference(NegotiationType.TARGET, NegotiationPerformative.ACCEPT, "zh-CN"))); - - assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingConfirmedIntent.getCode()); - assertTrue(missingConfirmedIntent.getMessage().contains("confirmed_intent")); - } - - @Test - void mapsConclusionPhaseMismatchesToTheConclusionMismatchCode() { - NegotiationGenerationException rejectInAccept = - assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( - new RecordingClient("{\"conclusion\":\"Reject\",\"items\":[]}")) - .extract( - "文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, "zh-CN"))); - - assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), rejectInAccept.getCode()); - assertEquals("Accept", rejectInAccept.getFacts().get("expected")); - assertEquals("Reject", rejectInAccept.getFacts().get("actual")); - - NegotiationGenerationException acceptInReject = - assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( - new RecordingClient("{\"conclusion\":\"Accept\",\"feasibility_summary\":\"同意。\"}")) - .extract( - "文本", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.REJECT, "zh-CN"))); - - assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), acceptInReject.getCode()); - } - - @Test - void rejectsEmptyInformationEndingItemsBeforeTemplateRendering() { - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( - new RecordingClient("{\"conclusion\":\"Reject\",\"items\":[]}")) - .extract( - "拒绝,资源查询服务正在检修。", - reference(NegotiationType.INFORMATION, NegotiationPerformative.REJECT, "zh-CN"))); - - assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); - assertTrue(exception.getMessage().contains("result content")); - } - - @Test - void rejectsEmptyInformationProposeItemsBeforeTemplateRendering() { - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( - new RecordingClient("{\"items\":[]}")) - .extract( - "请补充缺失信息。", - reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - - assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); - assertTrue(exception.getMessage().contains("requested items")); - } - - @Test - void mapsFeasibilityActionProblemsToTheInvalidInputCode() { - NegotiationGenerationException missingAction = assertThrows( - NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( - "{\"feasibility_negotiation_description\":\"描述\",\"contents_to_evaluate\":[]}")) - .extract( - "文本", - reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); - - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), missingAction.getCode()); - - NegotiationGenerationException emptyDrivenContent = assertThrows( - NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( - "{\"feasibility_negotiation_description\":\"描述\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," - + "\"contents_to_evaluate\":[],\"infeasibility_details_and_proposal\":null}")) - .extract( - "文本", - reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); - - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), emptyDrivenContent.getCode()); - } - - @Test - void rejectsBlankInputTextAndNullReference() { - DefaultNegotiationContentExtractor extractor = - new DefaultNegotiationContentExtractor(new RecordingClient("{}")); - - NegotiationGenerationException blank = assertThrows( - NegotiationGenerationException.class, - () -> extractor.extract( - " ", reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); - - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), blank.getCode()); - - assertEquals( - "Negotiation reference must not be null.", - assertThrows(NullPointerException.class, () -> extractor.extract("文本", null)) - .getMessage()); - } - - private static NegotiationReference reference( - NegotiationType type, NegotiationPerformative performative, String language) { - return new NegotiationReference(type, performative, language); - } - - private static String phaseTokenInUserPrompt(NegotiationPerformative performative) { - RecordingClient client = new RecordingClient("{\"conclusion\":\"Accept\",\"items\":[]}"); - try { - new DefaultNegotiationContentExtractor(client) - .extract("文本", reference(NegotiationType.INFORMATION, performative, "zh-CN")); - } catch (NegotiationGenerationException expectedForReject) { - // The scripted conclusion only satisfies the accept phase; the messages are recorded either way. - } - String userPrompt = client.lastMessages.get(1).get("content").replace("\r\n", "\n"); - int start = userPrompt.indexOf("协商阶段:") + "协商阶段:".length(); - return userPrompt.substring(start, userPrompt.indexOf('\n', start)); - } - - private static final class RecordingClient implements LLMClient { - private final String payload; - - private List> lastMessages; - - private Map lastSchema; - - private RecordingClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - this.lastMessages = messages; - this.lastSchema = jsonSchema; - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +class NegotiationContentExtractorTest { + + @Test + void extractsInformationProposeContent() { + RecordingClient llm = new RecordingClient( + "{\"items\":[{\"name\":\"故障发生时间\",\"value\":\"精确到分钟的时间点\"},{\"name\":\"受影响小区标识\",\"value\":null}]," + + "\"relationship\":\"故障发生时间与受影响小区标识需逐小区对应\"}"); + + NegotiationContent content = new DefaultNegotiationContentExtractor(llm) + .extract( + "请提供故障发生时间与受影响小区标识。", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN")); + + InformationProposeContent proposeContent = assertInstanceOf(InformationProposeContent.class, content); + assertEquals( + List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点"), new NegotiationItem("受影响小区标识", null)), + proposeContent.items()); + assertEquals("故障发生时间与受影响小区标识需逐小区对应", proposeContent.relationship()); + + assertEquals(2, llm.lastMessages.size()); + assertEquals("system", llm.lastMessages.get(0).get("role")); + assertEquals("user", llm.lastMessages.get(1).get("role")); + assertTrue(llm.lastMessages.get(1).get("content").contains("协商阶段:propose")); + assertTrue(llm.lastMessages.get(1).get("content").contains("请提供故障发生时间与受影响小区标识。")); + assertEquals( + new NegotiationJsonSchemaBuilder() + .buildExtractionSchema(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE), + llm.lastSchema); + } + + @Test + void extractsTargetProposeContent() { + NegotiationContent content = new DefaultNegotiationContentExtractor( + new RecordingClient("{\"target_negotiation_description\":\"请求将节能目标由30%调整为20%。\"," + + "\"intent_understanding\":[{\"name\":\"发起方理解\",\"value\":\"对方希望降低节能力度\"}]," + + "\"alignment_and_clarification\":null," + + "\"request_for_clarification\":[{\"name\":\"速率保障下限\",\"value\":null}]}")) + .extract("请求调整节能目标。", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "en-US")); + + TargetProposeContent proposeContent = assertInstanceOf(TargetProposeContent.class, content); + assertEquals("请求将节能目标由30%调整为20%。", proposeContent.targetNegotiationDescription()); + assertEquals(List.of(new NegotiationItem("发起方理解", "对方希望降低节能力度")), proposeContent.intentUnderstanding()); + assertNull(proposeContent.alignmentAndClarification()); + assertEquals(List.of(new NegotiationItem("速率保障下限", null)), proposeContent.requestForClarification()); + } + + @Test + void extractsFeasibilityProposeContentForBothActions() { + NegotiationContent request = new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"请评估该节能目标能否达成。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + + "\"contents_to_evaluate\":[{\"name\":\"评估对象\",\"value\":\"停电8小时期间的速率保障\"}]," + + "\"infeasibility_details_and_proposal\":null}")) + .extract("请评估。", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN")); + + FeasibilityProposeContent requestContent = assertInstanceOf(FeasibilityProposeContent.class, request); + assertEquals(NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, requestContent.action()); + assertEquals(List.of(new NegotiationItem("评估对象", "停电8小时期间的速率保障")), requestContent.contentsToEvaluate()); + + NegotiationContent alternative = new DefaultNegotiationContentExtractor( + new RecordingClient( + "{\"feasibility_negotiation_description\":\"目标不可行,提出下调方案。\",\"action\":\"PROPOSE_ALTERNATIVE_ON_FAILURE\"," + + "\"contents_to_evaluate\":null," + + "\"infeasibility_details_and_proposal\":[{\"name\":\"替代提案\",\"value\":\"下调至2Mbps\"}]}")) + .extract("不可行。", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN")); + + FeasibilityProposeContent alternativeContent = assertInstanceOf(FeasibilityProposeContent.class, alternative); + assertEquals(NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, alternativeContent.action()); + assertEquals( + List.of(new NegotiationItem("替代提案", "下调至2Mbps")), alternativeContent.infeasibilityDetailsAndProposal()); + } + + @Test + void extractsTargetProposeConfirmRequestContent() { + NegotiationContent content = new DefaultNegotiationContentExtractor( + new RecordingClient("{\"target_negotiation_description\":\"任务目标澄清完成,请答复<目标澄清后的确认请求>。\"," + + "\"intent_understanding\":null," + + "\"alignment_and_clarification\":null," + + "\"request_for_clarification\":null," + + "\"target_confirm_request\":\"目标已经澄清,是否同意按照此目标继续执行?\"}")) + .extract("目标已经澄清,请确认。", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "zh-CN")); + + TargetProposeContent proposeContent = assertInstanceOf(TargetProposeContent.class, content); + assertEquals("任务目标澄清完成,请答复<目标澄清后的确认请求>。", proposeContent.targetNegotiationDescription()); + assertEquals("目标已经澄清,是否同意按照此目标继续执行?", proposeContent.targetConfirmRequest()); + assertNull(proposeContent.intentUnderstanding()); + assertNull(proposeContent.alignmentAndClarification()); + assertNull(proposeContent.requestForClarification()); + } + + @Test + void extractsFeasibilityProposeConfirmRequestContent() { + NegotiationContent content = new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。\"," + + "\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + + "\"contents_to_evaluate\":null," + + "\"infeasibility_details_and_proposal\":null," + + "\"feasibility_confirm_request\":\"评估目标可行,是否同意按照此目标继续执行?\"}")) + .extract( + "评估目标可行,请确认。", + reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN")); + + FeasibilityProposeContent proposeContent = assertInstanceOf(FeasibilityProposeContent.class, content); + assertEquals(NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, proposeContent.action()); + assertEquals("评估目标可行,是否同意按照此目标继续执行?", proposeContent.feasibilityConfirmRequest()); + assertNull(proposeContent.contentsToEvaluate()); + assertNull(proposeContent.infeasibilityDetailsAndProposal()); + } + + @Test + void passesConfirmRequestWordingThroughLeniently() { + TargetProposeContent target = assertInstanceOf( + TargetProposeContent.class, + new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"target_negotiation_description\":\"描述\",\"intent_understanding\":null," + + "\"alignment_and_clarification\":null,\"request_for_clarification\":null," + + "\"target_confirm_request\":\"请确认按此目标推进\"}")) + .extract("文本", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "zh-CN"))); + assertEquals("请确认按此目标推进", target.targetConfirmRequest()); + + FeasibilityProposeContent feasibility = assertInstanceOf( + FeasibilityProposeContent.class, + new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"描述\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + + "\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null," + + "\"feasibility_confirm_request\":\"方案可行,望确认\"}")) + .extract( + "文本", + reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); + assertEquals("方案可行,望确认", feasibility.feasibilityConfirmRequest()); + } + + @Test + void extractsEndingContentForEveryTypeAndBothPhases() { + NegotiationContent infoAccept = new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"故障发生时间\",\"value\":\"2026-08-19 10:30\"}]}")) + .extract("同意提供。", reference(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, "zh-CN")); + + InformationEndingContent infoEnding = assertInstanceOf(InformationEndingContent.class, infoAccept); + assertEquals(NegotiationConclusion.ACCEPT, infoEnding.conclusion()); + assertEquals(List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30")), infoEnding.items()); + + NegotiationContent targetReject = new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"双方未达成一致。\"}")) + .extract("无法同意。", reference(NegotiationType.TARGET, NegotiationPerformative.REJECT, "zh-CN")); + + TargetEndingContent targetEnding = assertInstanceOf(TargetEndingContent.class, targetReject); + assertEquals(NegotiationConclusion.REJECT, targetEnding.conclusion()); + assertEquals("双方未达成一致。", targetEnding.failureReason()); + + NegotiationContent feasibilityAccept = new DefaultNegotiationContentExtractor( + new RecordingClient("{\"conclusion\":\"Accept\",\"feasibility_summary\":\"同意下调至2Mbps。\"}")) + .extract("同意。", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.ACCEPT, "zh-CN")); + + assertEquals( + "同意下调至2Mbps。", + assertInstanceOf( + net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent.class, + feasibilityAccept) + .feasibilitySummary()); + + assertEquals("accept", phaseTokenInUserPrompt(NegotiationPerformative.ACCEPT)); + assertEquals("reject", phaseTokenInUserPrompt(NegotiationPerformative.REJECT)); + assertEquals("propose", phaseTokenInUserPrompt(NegotiationPerformative.PROPOSE)); + } + + @Test + void mapsTransportFailuresToTheInvocationFailedCode() { + LLMClient failing = (messages, jsonSchema, temperature, maxTokens) -> { + throw new IllegalStateException("connection reset"); + }; + + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(failing) + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), exception.getCode()); + } + + @Test + void mapsMissingLlmClientToTheNotConfiguredCode() { + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(null) + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), exception.getCode()); + } + + @Test + void mapsResponseContractViolationsAndShapeFailures() { + NegotiationGenerationException unparseable = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("这不是 JSON")) + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), unparseable.getCode()); + + NegotiationGenerationException wrongShape = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"items\":\"不是数组\"}")) + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), wrongShape.getCode()); + assertEquals("items", wrongShape.getFacts().get("field")); + + NegotiationGenerationException emptyResponse = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient(" ")) + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), emptyResponse.getCode()); + } + + @Test + void mapsMissingRequiredFieldsToTheSlotMissingCode() { + NegotiationGenerationException missingItems = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"relationship\":null}")) + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingItems.getCode()); + assertTrue(missingItems.getMessage().contains("items")); + + NegotiationGenerationException missingDescription = assertThrows( + NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"intent_understanding\":null,\"alignment_and_clarification\":null," + + "\"request_for_clarification\":null}")) + .extract("文本", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingDescription.getCode()); + assertTrue(missingDescription.getMessage().contains("target_negotiation_description")); + + NegotiationGenerationException missingConclusion = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"items\":[]}")) + .extract( + "文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingConclusion.getCode()); + assertTrue(missingConclusion.getMessage().contains("conclusion")); + + NegotiationGenerationException missingConfirmedIntent = assertThrows( + NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"conclusion\":\"Accept\",\"confirmed_intent\":null,\"failure_reason\":null}")) + .extract("文本", reference(NegotiationType.TARGET, NegotiationPerformative.ACCEPT, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingConfirmedIntent.getCode()); + assertTrue(missingConfirmedIntent.getMessage().contains("confirmed_intent")); + } + + @Test + void mapsConclusionPhaseMismatchesToTheConclusionMismatchCode() { + NegotiationGenerationException rejectInAccept = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"conclusion\":\"Reject\",\"items\":[]}")) + .extract( + "文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), rejectInAccept.getCode()); + assertEquals("Accept", rejectInAccept.getFacts().get("expected")); + assertEquals("Reject", rejectInAccept.getFacts().get("actual")); + + NegotiationGenerationException acceptInReject = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"conclusion\":\"Accept\",\"feasibility_summary\":\"同意。\"}")) + .extract( + "文本", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.REJECT, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), acceptInReject.getCode()); + } + + @Test + void rejectsEmptyInformationEndingItemsBeforeTemplateRendering() { + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"conclusion\":\"Reject\",\"items\":[]}")) + .extract( + "拒绝,资源查询服务正在检修。", + reference(NegotiationType.INFORMATION, NegotiationPerformative.REJECT, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); + assertTrue(exception.getMessage().contains("result content")); + } + + @Test + void rejectsEmptyInformationProposeItemsBeforeTemplateRendering() { + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"items\":[]}")) + .extract( + "请补充缺失信息。", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); + assertTrue(exception.getMessage().contains("requested items")); + } + + @Test + void mapsFeasibilityActionProblemsToTheInvalidInputCode() { + NegotiationGenerationException missingAction = assertThrows( + NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"描述\",\"contents_to_evaluate\":[]}")) + .extract( + "文本", + reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), missingAction.getCode()); + + NegotiationGenerationException emptyDrivenContent = assertThrows( + NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"描述\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + + "\"contents_to_evaluate\":[],\"infeasibility_details_and_proposal\":null}")) + .extract( + "文本", + reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), emptyDrivenContent.getCode()); + } + + @Test + void rejectsBlankInputTextAndNullReference() { + DefaultNegotiationContentExtractor extractor = + new DefaultNegotiationContentExtractor(new RecordingClient("{}")); + + NegotiationGenerationException blank = assertThrows( + NegotiationGenerationException.class, + () -> extractor.extract( + " ", reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), blank.getCode()); + + assertEquals( + "Negotiation reference must not be null.", + assertThrows(NullPointerException.class, () -> extractor.extract("文本", null)) + .getMessage()); + } + + private static NegotiationReference reference( + NegotiationType type, NegotiationPerformative performative, String language) { + return new NegotiationReference(type, performative, language); + } + + private static String phaseTokenInUserPrompt(NegotiationPerformative performative) { + RecordingClient client = new RecordingClient("{\"conclusion\":\"Accept\",\"items\":[]}"); + try { + new DefaultNegotiationContentExtractor(client) + .extract("文本", reference(NegotiationType.INFORMATION, performative, "zh-CN")); + } catch (NegotiationGenerationException expectedForReject) { + // The scripted conclusion only satisfies the accept phase; the messages are recorded either way. + } + String userPrompt = client.lastMessages.get(1).get("content").replace("\r\n", "\n"); + int start = userPrompt.indexOf("协商阶段:") + "协商阶段:".length(); + return userPrompt.substring(start, userPrompt.indexOf('\n', start)); + } + + private static final class RecordingClient implements LLMClient { + private final String payload; + + private List> lastMessages; + + private Map lastSchema; + + private RecordingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + this.lastMessages = messages; + this.lastSchema = jsonSchema; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java index c8df8c87..ba3303e3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java @@ -1,247 +1,247 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.lang.reflect.Method; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMError; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; -import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckResult; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; - -/** - * Verifies the wiring defaults and injection seams of {@link NegotiationGenerationOrchestratorBuilder}. - * - *

            The builder falls back to the module logger when no logger is injected, honors an injected compliance checker, - * propagates the attempt limit to both retry chains (content extraction and semantic validation), and exposes no - * negotiation-type recognizer injection point. - */ -class NegotiationGenerationOrchestratorBuilderWiringTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private Logger rootLogger; - - private ListAppender appender; - - @BeforeEach - void attachAppender() { - rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); - appender = new ListAppender<>(); - appender.start(); - rootLogger.addAppender(appender); - } - - @AfterEach - void detachAppender() { - rootLogger.detachAppender(appender); - appender.stop(); - } - - @Test - void absentLoggerFallsBackToTheModuleLogger() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .build(); - - MetadataContent result = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - - assertFalse(result.promptText().isBlank()); - ILoggingEvent completed = appender.list.stream() - .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) - .findFirst() - .orElseThrow(); - assertEquals(NegotiationGenerationOrchestrator.class.getName(), completed.getLoggerName()); - assertEquals(Level.INFO, completed.getLevel()); - } - - @Test - void injectedLoggerReceivesThePipelineEvents() { - Logger customLogger = (Logger) LoggerFactory.getLogger("negotiation.wiring.custom-logger"); - ListAppender customAppender = new ListAppender<>(); - customAppender.start(); - customLogger.addAppender(customAppender); - try { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .logger(customLogger) - .build(); - - orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - - ILoggingEvent completed = customAppender.list.stream() - .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) - .findFirst() - .orElseThrow(); - assertEquals("negotiation.wiring.custom-logger", completed.getLoggerName()); - } finally { - customLogger.detachAppender(customAppender); - customAppender.stop(); - } - } - - @Test - void injectedComplianceCheckerIsUsedByTheValidationPipeline() { - RecordingComplianceChecker checker = new RecordingComplianceChecker(); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(new ScriptedClient( - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}")) - .complianceChecker(checker) - .build(); - - orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI); - - assertTrue(checker.calls.get() >= 1, "the injected compliance checker must be used"); - } - - @Test - void nullTemplateContentFromCustomLoaderIsReportedAsTemplateNotFoundNotNpe() { - NegotiationTemplateLoader nullContentLoader = - reference -> new PromptTemplate(reference.templateUri(), "", null, PromptTemplate.SOURCE_CLASSPATH); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .templateLoader(nullContentLoader) - .build(); - - NegotiationParamExtractionException exception = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - - assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), exception.getCode()); - } - - @Test - void maxAttemptsReachesBothRetryChains() { - CountingFailingClient generationClient = new CountingFailingClient(); - NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(generationClient) - .maxAttempts(2) - .build(); - NegotiationGenerationException generationFailure = org.junit.jupiter.api.Assertions.assertThrows( - NegotiationGenerationException.class, - () -> generationOrchestrator.generateProposeFromText( - "请提供节能区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE_URI)); - assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), generationFailure.getCode()); - assertEquals(2, generationClient.calls.get(), "generation chain must retry up to the limit"); - - CountingFailingClient semanticClient = new CountingFailingClient(); - NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(semanticClient) - .maxAttempts(2) - .build(); - org.junit.jupiter.api.Assertions.assertThrows( - net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException.class, - () -> validationOrchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE_URI)); - assertEquals(2, semanticClient.calls.get(), "semantic validation chain must retry up to the limit"); - } - - @Test - void builderExposesNoTypeRecognizerInjectionPoint() { - for (Method method : NegotiationGenerationOrchestratorBuilder.class.getDeclaredMethods()) { - String name = method.getName().toLowerCase(Locale.ROOT); - assertFalse( - name.contains("recogn"), - "the builder must not expose a type recognizer injection point but declares " + method.getName()); - } - } - - private static final class RecordingComplianceChecker implements NegotiationComplianceChecker { - - private final AtomicInteger calls = new AtomicInteger(); - - @Override - public NegotiationRuleCheckResult check(net.openan.a2at.sdk.core.model.NegotiationContext context) { - calls.incrementAndGet(); - return new NegotiationRuleCheckResult(true, List.of()); - } - } - - private static final class ScriptedClient implements LLMClient { - - private final String payload; - - private ScriptedClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class CountingFailingClient implements LLMClient { - - private final AtomicInteger calls = new AtomicInteger(); - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls.incrementAndGet(); - throw new LLMError("LLM endpoint unavailable."); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; +import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * Verifies the wiring defaults and injection seams of {@link NegotiationGenerationOrchestratorBuilder}. + * + *

            The builder falls back to the module logger when no logger is injected, honors an injected compliance checker, + * propagates the attempt limit to both retry chains (content extraction and semantic validation), and exposes no + * negotiation-type recognizer injection point. + */ +class NegotiationGenerationOrchestratorBuilderWiringTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private Logger rootLogger; + + private ListAppender appender; + + @BeforeEach + void attachAppender() { + rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + appender = new ListAppender<>(); + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + rootLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void absentLoggerFallsBackToTheModuleLogger() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + MetadataContent result = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + assertFalse(result.promptText().isBlank()); + ILoggingEvent completed = appender.list.stream() + .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) + .findFirst() + .orElseThrow(); + assertEquals(NegotiationGenerationOrchestrator.class.getName(), completed.getLoggerName()); + assertEquals(Level.INFO, completed.getLevel()); + } + + @Test + void injectedLoggerReceivesThePipelineEvents() { + Logger customLogger = (Logger) LoggerFactory.getLogger("negotiation.wiring.custom-logger"); + ListAppender customAppender = new ListAppender<>(); + customAppender.start(); + customLogger.addAppender(customAppender); + try { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .logger(customLogger) + .build(); + + orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + ILoggingEvent completed = customAppender.list.stream() + .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) + .findFirst() + .orElseThrow(); + assertEquals("negotiation.wiring.custom-logger", completed.getLoggerName()); + } finally { + customLogger.detachAppender(customAppender); + customAppender.stop(); + } + } + + @Test + void injectedComplianceCheckerIsUsedByTheValidationPipeline() { + RecordingComplianceChecker checker = new RecordingComplianceChecker(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient( + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}")) + .complianceChecker(checker) + .build(); + + orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI); + + assertTrue(checker.calls.get() >= 1, "the injected compliance checker must be used"); + } + + @Test + void nullTemplateContentFromCustomLoaderIsReportedAsTemplateNotFoundNotNpe() { + NegotiationTemplateLoader nullContentLoader = + reference -> new PromptTemplate(reference.templateUri(), "", null, PromptTemplate.SOURCE_CLASSPATH); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(nullContentLoader) + .build(); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), exception.getCode()); + } + + @Test + void maxAttemptsReachesBothRetryChains() { + CountingFailingClient generationClient = new CountingFailingClient(); + NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(generationClient) + .maxAttempts(2) + .build(); + NegotiationGenerationException generationFailure = org.junit.jupiter.api.Assertions.assertThrows( + NegotiationGenerationException.class, + () -> generationOrchestrator.generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI)); + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), generationFailure.getCode()); + assertEquals(2, generationClient.calls.get(), "generation chain must retry up to the limit"); + + CountingFailingClient semanticClient = new CountingFailingClient(); + NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(semanticClient) + .maxAttempts(2) + .build(); + org.junit.jupiter.api.Assertions.assertThrows( + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException.class, + () -> validationOrchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + assertEquals(2, semanticClient.calls.get(), "semantic validation chain must retry up to the limit"); + } + + @Test + void builderExposesNoTypeRecognizerInjectionPoint() { + for (Method method : NegotiationGenerationOrchestratorBuilder.class.getDeclaredMethods()) { + String name = method.getName().toLowerCase(Locale.ROOT); + assertFalse( + name.contains("recogn"), + "the builder must not expose a type recognizer injection point but declares " + method.getName()); + } + } + + private static final class RecordingComplianceChecker implements NegotiationComplianceChecker { + + private final AtomicInteger calls = new AtomicInteger(); + + @Override + public NegotiationRuleCheckResult check(net.openan.a2at.sdk.core.model.NegotiationContext context) { + calls.incrementAndGet(); + return new NegotiationRuleCheckResult(true, List.of()); + } + } + + private static final class ScriptedClient implements LLMClient { + + private final String payload; + + private ScriptedClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class CountingFailingClient implements LLMClient { + + private final AtomicInteger calls = new AtomicInteger(); + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls.incrementAndGet(); + throw new LLMError("LLM endpoint unavailable."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java index 1d92a22d..8544d2e9 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -1,359 +1,359 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import org.junit.jupiter.api.Test; - -class NegotiationGenerationOrchestratorTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); - - @Test - void generatesInformationProposeFromDataInChinese() { - MetadataContent result = zhOrchestrator() - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertEquals(NegotiationHandler.NEGOTIATION_T_URI, result.extensionUri()); - assertFalse(result.promptText().isBlank()); - assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); - assertTrue(result.promptText().contains("所需信息项")); - - Map metadata = result.buildMetadataContent(); - assertEquals(3, metadata.size()); - assertEquals(result.promptText(), metadata.get(NegotiationHandler.NEGOTIATION_T_URI)); - assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); - @SuppressWarnings("unchecked") - Map nestedContext = - (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); - assertEquals(UUID, nestedContext.get("id")); - assertEquals(1, nestedContext.get("round")); - assertEquals(5, nestedContext.get("maxRounds")); - assertEquals("PROPOSE", nestedContext.get("performative")); - } - - @Test - void generatesInformationProposeFromDataInEnglish() { - MetadataContent result = orchestrator("en-US") - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent( - List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); - assertEquals(new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); - assertTrue(result.promptText().contains("Required Information Items")); - } - - @Test - void generatesProposeFromTextWithScriptedExtraction() { - ScriptedClient llm = - new ScriptedClient("{\"items\":[{\"name\":\"故障发生时间\",\"value\":\"精确到分钟的时间点\"}],\"relationship\":null}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - - MetadataContent result = orchestrator.generateProposeFromText( - "请提供故障发生时间。", new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE); - - assertEquals(1, llm.calls); - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertEquals(2, result.negotiationContext().round()); - assertTrue(result.promptText().contains("故障发生时间")); - } - - @Test - void validatesAndFillsProposeDataWithScriptedSemanticResponse() { - ScriptedClient llm = - new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," - + "\"params\":{\"region\":\"松山湖\"}}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - MetadataContent message = orchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE); - - FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( - message.promptText(), - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - INFORMATION_PROPOSE); - - assertEquals(1, llm.calls); - assertEquals(UUID, filled.data().get("id")); - assertEquals(1, filled.data().get("round")); - assertEquals(5, filled.data().get("maxRounds")); - assertEquals("松山湖", filled.data().get("region")); - } - - @Test - void listsAllSevenTemplatesPerLanguage() { - assertEquals(7, negotiationPrompts("zh-CN").size()); - assertEquals(7, negotiationPrompts("en-US").size()); - } - - @Test - void queriesSingleTemplateByUri() { - assertTrue(queryService("zh-CN").getPrompt(INFORMATION_PROPOSE).isPresent()); - assertTrue(queryService("zh-CN") - .getPrompt(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) - .isPresent()); - assertFalse(queryService("zh-CN") - .getPrompt(TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")) - .isPresent()); - } - - @Test - void retriesContentExtractionUntilItSucceeds() { - ScriptedClient llm = new ScriptedClient("", "", "{\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(3) - .build(); - - MetadataContent result = orchestrator.generateProposeFromText( - "请提供区域。", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE); - - assertEquals(3, llm.calls); - assertFalse(result.promptText().isBlank()); - } - - @Test - void rethrowsOriginalCodeWhenRetriesAreExhausted() { - FailingClient llm = new FailingClient(); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(2) - .build(); - - NegotiationGenerationException failure = assertThrows( - NegotiationGenerationException.class, - () -> orchestrator.generateProposeFromText( - "请提供区域。", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - INFORMATION_PROPOSE)); - - assertEquals(2, llm.calls); - assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), failure.getCode()); - } - - @Test - void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { - NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .templateLoader(missingTemplateLoader()) - .build(); - - NegotiationGenerationException generationFailure = assertThrows( - NegotiationGenerationException.class, - () -> generationOrchestrator.generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), - INFORMATION_PROPOSE)); - assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), generationFailure.getCode()); - - NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .semanticValidator((prompt, callerSchema, reference, templateContent) -> { - throw new ResourceNotFoundException( - "Semantic validation prompt does not exist.", "prompt_resources/prompts"); - }) - .build(); - - NegotiationParamExtractionException extractionFailure = assertThrows( - NegotiationParamExtractionException.class, - () -> validationOrchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE)); - assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), extractionFailure.getCode()); - } - - @Test - void validationTemplateLoadFailurePreservesTheOriginalCause() { - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .templateLoader(missingTemplateLoader()) - .build(); - - NegotiationParamExtractionException failure = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - "## 所需信息项\n1. 区域\n", - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - Map.of("type", "object"), - INFORMATION_PROPOSE)); - - assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), failure.getCode()); - assertTrue( - failure.getCause() instanceof ContentValidationException, - "the template-load failure must preserve the mapped pipeline failure as cause for debugging"); - assertTrue( - failure.getCause().getCause() instanceof ResourceNotFoundException, - "the original resource failure stays reachable through the cause chain"); - } - - @Test - void rejectsNonNegotiationPromptInParameterExtraction() { - ScriptedClient llm = new ScriptedClient( - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); - NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .build(); - - NegotiationParamExtractionException failure = assertThrows( - NegotiationParamExtractionException.class, - () -> orchestrator.validateProposePromptAndDataFilling( - "plain text without any negotiation section", - null, - Map.of("type", "object"), - INFORMATION_PROPOSE)); - - assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), failure.getCode()); - assertEquals(0, llm.calls); - } - - @Test - void rejectsTemplateUriThatDoesNotAddressTheExpectedPerformative() { - IllegalArgumentException performativeMismatchFailure = - assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT)); - assertTrue(performativeMismatchFailure - .getMessage() - .contains("Template URI does not address a negotiation template of the expected performative" - + " PROPOSE (propose)")); - - IllegalArgumentException wrongExtensionFailure = - assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() - .generateProposeFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), - StandardTemplates.ENERGY_SAVING)); - assertTrue(wrongExtensionFailure - .getMessage() - .contains("Template URI does not address a negotiation template of the expected performative" - + " PROPOSE (propose)")); - } - - private static NegotiationGenerationOrchestrator zhOrchestrator() { - return orchestrator("zh-CN"); - } - - private static NegotiationGenerationOrchestrator orchestrator(String language) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .build(); - } - - private static TemplateQueryService queryService(String language) { - return new TemplateQueryService(language, "classpath", null); - } - - private static List negotiationPrompts(String language) { - return queryService(language).getPrompts().stream() - .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( - template.templateUri().extensionName())) - .toList(); - } - - private static NegotiationTemplateLoader missingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - }; - } - - private static final class ScriptedClient implements LLMClient { - - private final List payloads; - - private int calls; - - private ScriptedClient(String... payloads) { - this.payloads = List.of(payloads); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - String payload = payloads.get(Math.min(calls, payloads.size() - 1)); - calls++; - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class FailingClient implements LLMClient { - - private int calls; - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - calls++; - throw new IllegalStateException("LLM endpoint unavailable."); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import org.junit.jupiter.api.Test; + +class NegotiationGenerationOrchestratorTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + @Test + void generatesInformationProposeFromDataInChinese() { + MetadataContent result = zhOrchestrator() + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertEquals(NegotiationHandler.NEGOTIATION_T_URI, result.extensionUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); + assertTrue(result.promptText().contains("所需信息项")); + + Map metadata = result.buildMetadataContent(); + assertEquals(3, metadata.size()); + assertEquals(result.promptText(), metadata.get(NegotiationHandler.NEGOTIATION_T_URI)); + assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); + @SuppressWarnings("unchecked") + Map nestedContext = + (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + assertEquals(UUID, nestedContext.get("id")); + assertEquals(1, nestedContext.get("round")); + assertEquals(5, nestedContext.get("maxRounds")); + assertEquals("PROPOSE", nestedContext.get("performative")); + } + + @Test + void generatesInformationProposeFromDataInEnglish() { + MetadataContent result = orchestrator("en-US") + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent( + List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); + assertEquals(new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); + assertTrue(result.promptText().contains("Required Information Items")); + } + + @Test + void generatesProposeFromTextWithScriptedExtraction() { + ScriptedClient llm = + new ScriptedClient("{\"items\":[{\"name\":\"故障发生时间\",\"value\":\"精确到分钟的时间点\"}],\"relationship\":null}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + MetadataContent result = orchestrator.generateProposeFromText( + "请提供故障发生时间。", new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE); + + assertEquals(1, llm.calls); + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertEquals(2, result.negotiationContext().round()); + assertTrue(result.promptText().contains("故障发生时间")); + } + + @Test + void validatesAndFillsProposeDataWithScriptedSemanticResponse() { + ScriptedClient llm = + new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + MetadataContent message = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message.promptText(), + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), + INFORMATION_PROPOSE); + + assertEquals(1, llm.calls); + assertEquals(UUID, filled.data().get("id")); + assertEquals(1, filled.data().get("round")); + assertEquals(5, filled.data().get("maxRounds")); + assertEquals("松山湖", filled.data().get("region")); + } + + @Test + void listsAllSevenTemplatesPerLanguage() { + assertEquals(7, negotiationPrompts("zh-CN").size()); + assertEquals(7, negotiationPrompts("en-US").size()); + } + + @Test + void queriesSingleTemplateByUri() { + assertTrue(queryService("zh-CN").getPrompt(INFORMATION_PROPOSE).isPresent()); + assertTrue(queryService("zh-CN") + .getPrompt(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) + .isPresent()); + assertFalse(queryService("zh-CN") + .getPrompt(TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")) + .isPresent()); + } + + @Test + void retriesContentExtractionUntilItSucceeds() { + ScriptedClient llm = new ScriptedClient("", "", "{\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build(); + + MetadataContent result = orchestrator.generateProposeFromText( + "请提供区域。", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE); + + assertEquals(3, llm.calls); + assertFalse(result.promptText().isBlank()); + } + + @Test + void rethrowsOriginalCodeWhenRetriesAreExhausted() { + FailingClient llm = new FailingClient(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build(); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE)); + + assertEquals(2, llm.calls); + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), failure.getCode()); + } + + @Test + void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { + NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(missingTemplateLoader()) + .build(); + + NegotiationGenerationException generationFailure = assertThrows( + NegotiationGenerationException.class, + () -> generationOrchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + INFORMATION_PROPOSE)); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), generationFailure.getCode()); + + NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .semanticValidator((prompt, callerSchema, reference, templateContent) -> { + throw new ResourceNotFoundException( + "Semantic validation prompt does not exist.", "prompt_resources/prompts"); + }) + .build(); + + NegotiationParamExtractionException extractionFailure = assertThrows( + NegotiationParamExtractionException.class, + () -> validationOrchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE)); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), extractionFailure.getCode()); + } + + @Test + void validationTemplateLoadFailurePreservesTheOriginalCause() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(missingTemplateLoader()) + .build(); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE)); + + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), failure.getCode()); + assertTrue( + failure.getCause() instanceof ContentValidationException, + "the template-load failure must preserve the mapped pipeline failure as cause for debugging"); + assertTrue( + failure.getCause().getCause() instanceof ResourceNotFoundException, + "the original resource failure stays reachable through the cause chain"); + } + + @Test + void rejectsNonNegotiationPromptInParameterExtraction() { + ScriptedClient llm = new ScriptedClient( + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "plain text without any negotiation section", + null, + Map.of("type", "object"), + INFORMATION_PROPOSE)); + + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), failure.getCode()); + assertEquals(0, llm.calls); + } + + @Test + void rejectsTemplateUriThatDoesNotAddressTheExpectedPerformative() { + IllegalArgumentException performativeMismatchFailure = + assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT)); + assertTrue(performativeMismatchFailure + .getMessage() + .contains("Template URI does not address a negotiation template of the expected performative" + + " PROPOSE (propose)")); + + IllegalArgumentException wrongExtensionFailure = + assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + StandardTemplates.ENERGY_SAVING)); + assertTrue(wrongExtensionFailure + .getMessage() + .contains("Template URI does not address a negotiation template of the expected performative" + + " PROPOSE (propose)")); + } + + private static NegotiationGenerationOrchestrator zhOrchestrator() { + return orchestrator("zh-CN"); + } + + private static NegotiationGenerationOrchestrator orchestrator(String language) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .build(); + } + + private static TemplateQueryService queryService(String language) { + return new TemplateQueryService(language, "classpath", null); + } + + private static List negotiationPrompts(String language) { + return queryService(language).getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + + private static NegotiationTemplateLoader missingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + }; + } + + private static final class ScriptedClient implements LLMClient { + + private final List payloads; + + private int calls; + + private ScriptedClient(String... payloads) { + this.payloads = List.of(payloads); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + String payload = payloads.get(Math.min(calls, payloads.size() - 1)); + calls++; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class FailingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new IllegalStateException("LLM endpoint unavailable."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java index 8994b076..e4386890 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java @@ -1,196 +1,196 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import org.junit.jupiter.api.Test; - -class NegotiationGeneratorRegistryTest { - - private final NegotiationGeneratorRegistry registry = new NegotiationGeneratorRegistry(); - - private NegotiationGenerator resolve( - NegotiationType type, NegotiationPerformative phase, NegotiationContent content) { - return registry.resolve(type, phase, content, "zh-CN"); - } - - @Test - void dispatchesEveryTypeAndPhaseCombination() { - assertInstanceOf( - InformationProposeGenerator.class, - resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.PROPOSE, - new InformationProposeContent(List.of(), null))); - assertInstanceOf( - TargetProposeGenerator.class, - resolve( - NegotiationType.TARGET, - NegotiationPerformative.PROPOSE, - new TargetProposeContent("描述", null, null, null, null))); - assertInstanceOf( - FeasibilityProposeGenerator.class, - resolve( - NegotiationType.FEASIBILITY, - NegotiationPerformative.PROPOSE, - new FeasibilityProposeContent( - "描述", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("名称", "值")), - null, - null))); - assertInstanceOf( - InformationEndingGenerator.class, - resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.ACCEPT, - new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); - assertInstanceOf( - InformationEndingGenerator.class, - resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.REJECT, - new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); - assertInstanceOf( - TargetEndingGenerator.class, - resolve( - NegotiationType.TARGET, - NegotiationPerformative.ACCEPT, - new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); - assertInstanceOf( - TargetEndingGenerator.class, - resolve( - NegotiationType.TARGET, - NegotiationPerformative.REJECT, - new TargetEndingContent(NegotiationConclusion.REJECT, null, "失败原因"))); - assertInstanceOf( - FeasibilityEndingGenerator.class, - resolve( - NegotiationType.FEASIBILITY, - NegotiationPerformative.ACCEPT, - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "评估结论"))); - assertInstanceOf( - FeasibilityEndingGenerator.class, - resolve( - NegotiationType.FEASIBILITY, - NegotiationPerformative.REJECT, - new FeasibilityEndingContent(NegotiationConclusion.REJECT, "评估结论"))); - } - - @Test - void rejectsProposeContentInTerminalPhaseAndEndingContentInProposePhase() { - IllegalArgumentException proposeInEnding = assertThrows( - IllegalArgumentException.class, - () -> resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.ACCEPT, - new InformationProposeContent(List.of(), null))); - assertTrue(proposeInEnding - .getMessage() - .contains("ACCEPT phase requires ending content but received propose content of type" - + " InformationProposeContent")); - - IllegalArgumentException endingInPropose = assertThrows( - IllegalArgumentException.class, - () -> resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.PROPOSE, - new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); - assertTrue(endingInPropose - .getMessage() - .contains("PROPOSE phase requires propose content but received ending content of type" - + " InformationEndingContent")); - } - - @Test - void rejectsContentRuntimeTypeNotMatchingTheNegotiationType() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.PROPOSE, - new TargetProposeContent("描述", null, null, null, null))); - - assertTrue(exception - .getMessage() - .contains("Negotiation type INFORMATION requires content of type InformationProposeContent but received" - + " TargetProposeContent")); - - assertThrows( - IllegalArgumentException.class, - () -> resolve( - NegotiationType.TARGET, - NegotiationPerformative.ACCEPT, - new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); - } - - @Test - void rejectsEndingConclusionNotMatchingThePhase() { - NegotiationGenerationException exception = assertThrows( - NegotiationGenerationException.class, - () -> resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.ACCEPT, - new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); - - assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), exception.getCode()); - assertEquals("Accept", exception.getFacts().get("expected")); - assertEquals("Reject", exception.getFacts().get("actual")); - - assertThrows( - NegotiationGenerationException.class, - () -> resolve( - NegotiationType.TARGET, - NegotiationPerformative.REJECT, - new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); - } - - @Test - void rejectsEndingContentWithoutConclusion() { - NullPointerException exception = assertThrows( - NullPointerException.class, - () -> resolve( - NegotiationType.INFORMATION, - NegotiationPerformative.ACCEPT, - new InformationEndingContent(null, List.of()))); - - assertTrue(exception.getMessage().contains("conclusion must not be null")); - } - - @Test - void rejectsNullArguments() { - NegotiationContent content = new InformationProposeContent(List.of(), null); - - assertEquals( - "Negotiation type must not be null for the PROPOSE phase.", - assertThrows(NullPointerException.class, () -> resolve(null, NegotiationPerformative.PROPOSE, content)) - .getMessage()); - assertEquals( - "Negotiation phase must not be null.", - assertThrows(NullPointerException.class, () -> resolve(NegotiationType.INFORMATION, null, content)) - .getMessage()); - assertEquals( - "Negotiation content must not be null.", - assertThrows( - NullPointerException.class, - () -> resolve(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, null)) - .getMessage()); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.junit.jupiter.api.Test; + +class NegotiationGeneratorRegistryTest { + + private final NegotiationGeneratorRegistry registry = new NegotiationGeneratorRegistry(); + + private NegotiationGenerator resolve( + NegotiationType type, NegotiationPerformative phase, NegotiationContent content) { + return registry.resolve(type, phase, content, "zh-CN"); + } + + @Test + void dispatchesEveryTypeAndPhaseCombination() { + assertInstanceOf( + InformationProposeGenerator.class, + resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.PROPOSE, + new InformationProposeContent(List.of(), null))); + assertInstanceOf( + TargetProposeGenerator.class, + resolve( + NegotiationType.TARGET, + NegotiationPerformative.PROPOSE, + new TargetProposeContent("描述", null, null, null, null))); + assertInstanceOf( + FeasibilityProposeGenerator.class, + resolve( + NegotiationType.FEASIBILITY, + NegotiationPerformative.PROPOSE, + new FeasibilityProposeContent( + "描述", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("名称", "值")), + null, + null))); + assertInstanceOf( + InformationEndingGenerator.class, + resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + assertInstanceOf( + InformationEndingGenerator.class, + resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.REJECT, + new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); + assertInstanceOf( + TargetEndingGenerator.class, + resolve( + NegotiationType.TARGET, + NegotiationPerformative.ACCEPT, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); + assertInstanceOf( + TargetEndingGenerator.class, + resolve( + NegotiationType.TARGET, + NegotiationPerformative.REJECT, + new TargetEndingContent(NegotiationConclusion.REJECT, null, "失败原因"))); + assertInstanceOf( + FeasibilityEndingGenerator.class, + resolve( + NegotiationType.FEASIBILITY, + NegotiationPerformative.ACCEPT, + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "评估结论"))); + assertInstanceOf( + FeasibilityEndingGenerator.class, + resolve( + NegotiationType.FEASIBILITY, + NegotiationPerformative.REJECT, + new FeasibilityEndingContent(NegotiationConclusion.REJECT, "评估结论"))); + } + + @Test + void rejectsProposeContentInTerminalPhaseAndEndingContentInProposePhase() { + IllegalArgumentException proposeInEnding = assertThrows( + IllegalArgumentException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationProposeContent(List.of(), null))); + assertTrue(proposeInEnding + .getMessage() + .contains("ACCEPT phase requires ending content but received propose content of type" + + " InformationProposeContent")); + + IllegalArgumentException endingInPropose = assertThrows( + IllegalArgumentException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.PROPOSE, + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + assertTrue(endingInPropose + .getMessage() + .contains("PROPOSE phase requires propose content but received ending content of type" + + " InformationEndingContent")); + } + + @Test + void rejectsContentRuntimeTypeNotMatchingTheNegotiationType() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.PROPOSE, + new TargetProposeContent("描述", null, null, null, null))); + + assertTrue(exception + .getMessage() + .contains("Negotiation type INFORMATION requires content of type InformationProposeContent but received" + + " TargetProposeContent")); + + assertThrows( + IllegalArgumentException.class, + () -> resolve( + NegotiationType.TARGET, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + } + + @Test + void rejectsEndingConclusionNotMatchingThePhase() { + NegotiationGenerationException exception = assertThrows( + NegotiationGenerationException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); + + assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), exception.getCode()); + assertEquals("Accept", exception.getFacts().get("expected")); + assertEquals("Reject", exception.getFacts().get("actual")); + + assertThrows( + NegotiationGenerationException.class, + () -> resolve( + NegotiationType.TARGET, + NegotiationPerformative.REJECT, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); + } + + @Test + void rejectsEndingContentWithoutConclusion() { + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(null, List.of()))); + + assertTrue(exception.getMessage().contains("conclusion must not be null")); + } + + @Test + void rejectsNullArguments() { + NegotiationContent content = new InformationProposeContent(List.of(), null); + + assertEquals( + "Negotiation type must not be null for the PROPOSE phase.", + assertThrows(NullPointerException.class, () -> resolve(null, NegotiationPerformative.PROPOSE, content)) + .getMessage()); + assertEquals( + "Negotiation phase must not be null.", + assertThrows(NullPointerException.class, () -> resolve(NegotiationType.INFORMATION, null, content)) + .getMessage()); + assertEquals( + "Negotiation content must not be null.", + assertThrows( + NullPointerException.class, + () -> resolve(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, null)) + .getMessage()); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java index 9b4810f0..3ce57491 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -1,748 +1,748 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.content.Vocabulary; -import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.junit.jupiter.api.Test; - -class NegotiationGeneratorsTest { - - private static final String CONTEXT_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String ZH_TARGET_PROPOSE_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 目标协商 - {{目标协商概述}}(必填) - 要求: - 概述要求。 - - ## 意图理解陈述 - {{意图理解陈述}}(选填) - 要求: - 意图要求。 - - ## 理解对齐与疑问澄清 - {{理解对齐与疑问澄清}}(选填) - 要求: - 对齐要求。 - - ## 待澄清内容 - {{待澄清内容}}(选填) - 要求: - 澄清要求。 - """; - - private static final String ZH_FEASIBILITY_PROPOSE_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 可行性协商 - {{可行性协商概述}}(必填) - 要求: - 概述要求。 - - ## 待评估内容说明 - {{待评估内容说明}}(选填) - 要求: - 评估要求。 - - ## 评估不可行时的详情和提案 - {{评估不可行时的详情和提案}}(选填) - 要求: - 提案要求。 - """; - - private static final String ZH_TARGET_PROPOSE_CONFIRM_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 目标协商 - {{目标协商概述}}(必填) - 要求: - 概述要求。 - - ## 意图理解陈述 - {{意图理解陈述}}(选填) - 要求: - 意图要求。 - - ## 理解对齐与疑问澄清 - {{理解对齐与疑问澄清}}(选填) - 要求: - 对齐要求。 - - ## 待澄清内容 - {{待澄清内容}}(选填) - 要求: - 澄清要求。 - - ## 目标澄清后的确认请求 - {{目标澄清后的确认请求}}(选填) - 要求: - 确认要求。 - """; - - private static final String ZH_FEASIBILITY_PROPOSE_CONFIRM_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 可行性协商 - {{可行性协商概述}}(必填) - 要求: - 概述要求。 - - ## 待评估内容说明 - {{待评估内容说明}}(选填) - 要求: - 评估要求。 - - ## 评估不可行时的详情和提案 - {{评估不可行时的详情和提案}}(选填) - 要求: - 提案要求。 - - ## 评估可行时的确认请求 - {{评估可行时的确认请求}}(选填) - 要求: - 确认要求。 - """; - - private static final String ZH_INFO_PROPOSE_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 信息协商 - 请根据<所需信息项>补充相关内容。 - - ## 所需信息项 - {{所需信息项}}(必填) - 要求: - 信息项要求。 - """; - - private static final String ZH_INFO_ENDING_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 信息协商结果 - {{信息协商结果}}(必填) - 要求: - 结论要求。 - - ## 信息协商结果内容 - {{信息协商结果内容}}(必填) - 要求: - 内容要求。 - """; - - private static final String ZH_TARGET_ENDING_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 目标协商结果 - {{目标协商结果}}(必填) - 要求: - 结论要求。 - - ## 目标协商结果内容 - {{目标协商结果内容}}(必填) - 要求: - 内容要求。 - """; - - private static final String ZH_FEASIBILITY_ENDING_TEMPLATE = - """ - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 上下文要求。 - - ## 可行性协商结果 - {{可行性协商结果}}(必填) - 要求: - 结论要求。 - - ## 可行性评估结果确认 - {{评估结果确认}}(必填) - 要求: - 确认要求。 - """; - - private final Vocabulary zhVocabulary = Vocabulary.forLanguage("zh-CN"); - - private final Vocabulary enVocabulary = Vocabulary.forLanguage("en-US"); - - @Test - void feasibilityRequestActionRendersExactlyTheEvaluationSection() { - FeasibilityProposeContent content = new FeasibilityProposeContent( - "请协助评估该节能目标能否达成。", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("评估对象", "停电8小时期间核心用户的速率保障")), - List.of(new NegotiationItem("不应出现", "值")), - null); - - String rendered = new FeasibilityProposeGenerator() - .generate( - context(1), - content, - template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 可行性协商\n请协助评估该节能目标能否达成。")); - assertTrue(rendered.contains("## 待评估内容说明\n1. 评估对象:停电8小时期间核心用户的速率保障")); - assertFalse(rendered.contains("## 评估不可行时的详情和提案")); - assertFalse(rendered.contains("要求:")); - } - - @Test - void feasibilityAlternativeActionRendersExactlyTheInfeasibilitySection() { - FeasibilityProposeContent content = new FeasibilityProposeContent( - "当前速率目标不可行,提出下调方案。", - NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, - List.of(new NegotiationItem("不应出现", "值")), - List.of( - new NegotiationItem("不可行原因", "蓄电池仅能支撑8小时2Mbps的保障能力"), - new NegotiationItem("替代提案", "停电期间将速率保障目标下调至2Mbps")), - null); - - String rendered = new FeasibilityProposeGenerator() - .generate( - context(2), - content, - template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 可行性协商\n当前速率目标不可行,提出下调方案。")); - assertTrue(rendered.contains("## 评估不可行时的详情和提案\n1. 不可行原因:蓄电池仅能支撑8小时2Mbps的保障能力")); - assertTrue(rendered.contains("2. 替代提案:停电期间将速率保障目标下调至2Mbps")); - assertFalse(rendered.contains("## 待评估内容说明")); - } - - @Test - void feasibilityNullActionOrEmptyDrivenContentIsRejected() { - FeasibilityProposeContent nullAction = - new FeasibilityProposeContent("描述", null, List.of(new NegotiationItem("名称", "值")), null, null); - - NullPointerException nullActionError = - assertThrows(NullPointerException.class, () -> new FeasibilityProposeGenerator() - .generate( - context(1), - nullAction, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary)); - assertTrue(nullActionError.getMessage().contains("action must not be null")); - - FeasibilityProposeContent emptyEvaluation = - new FeasibilityProposeContent("描述", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null, null); - - NegotiationGenerationException emptyEvaluationError = - assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() - .generate( - context(1), - emptyEvaluation, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary)); - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), emptyEvaluationError.getCode()); - assertEquals( - "content.contentsToEvaluate", emptyEvaluationError.getFacts().get("field")); - - FeasibilityProposeContent emptyAlternative = new FeasibilityProposeContent( - "描述", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, List.of(), null); - - NegotiationGenerationException emptyAlternativeError = - assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() - .generate( - context(1), - emptyAlternative, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary)); - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), emptyAlternativeError.getCode()); - assertEquals( - "content.infeasibilityDetailsAndProposal", - emptyAlternativeError.getFacts().get("field")); - - FeasibilityProposeContent blankDescription = new FeasibilityProposeContent( - " ", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("名称", "值")), - null, - null); - - NegotiationGenerationException blankDescriptionError = - assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() - .generate( - context(1), - blankDescription, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_TEMPLATE), - zhVocabulary)); - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), blankDescriptionError.getCode()); - assertEquals( - "content.feasibilityNegotiationDescription", - blankDescriptionError.getFacts().get("field")); - } - - @Test - void targetConfirmRequestRendersOnlyTheSummaryAndConfirmRequestSections() { - TargetProposeContent content = - new TargetProposeContent("任务目标澄清完成,请答复<目标澄清后的确认请求>。", null, null, null, "目标已经澄清,是否同意按照此目标继续执行?"); - - String rendered = new TargetProposeGenerator() - .generate( - context(2), - content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_CONFIRM_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 目标协商\n任务目标澄清完成,请答复<目标澄清后的确认请求>。")); - assertTrue(rendered.contains("## 目标澄清后的确认请求\n目标已经澄清,是否同意按照此目标继续执行?")); - assertFalse(rendered.contains("## 意图理解陈述")); - assertFalse(rendered.contains("## 理解对齐与疑问澄清")); - assertFalse(rendered.contains("## 待澄清内容")); - assertFalse(rendered.contains("要求:")); - - String renderedEn = new TargetProposeGenerator() - .generate( - context(2), - new TargetProposeContent( - "The clarification of the task target has been completed. Please reply to .", - null, - null, - null, - "The target has been clarified. Do you agree to proceed with this target?"), - template( - StandardTemplates.TARGET_NEGOTIATION_PROPOSE, - """ - ## Target Negotiation - {{target_negotiation_summary}} (required) - Requirement: summary. - - ## Intent Understanding Statement - {{intent_understanding_statement}} (optional) - Requirement: intent. - - ## Target Clarification Confirmation Request - {{target_confirm_request}} (optional) - Requirement: confirm. - """), - enVocabulary); - - assertTrue(renderedEn.contains("## Target Clarification Confirmation Request\nThe target has been clarified." - + " Do you agree to proceed with this target?")); - assertFalse(renderedEn.contains("## Intent Understanding Statement")); - } - - @Test - void feasibilityConfirmRequestRendersOnlyTheSummaryAndConfirmRequestSections() { - FeasibilityProposeContent content = new FeasibilityProposeContent( - "针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - null, - null, - "评估目标可行,是否同意按照此目标继续执行?"); - - String rendered = new FeasibilityProposeGenerator() - .generate( - context(2), - content, - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - ZH_FEASIBILITY_PROPOSE_CONFIRM_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 可行性协商\n针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。")); - assertTrue(rendered.contains("## 评估可行时的确认请求\n评估目标可行,是否同意按照此目标继续执行?")); - assertFalse(rendered.contains("## 待评估内容说明")); - assertFalse(rendered.contains("## 评估不可行时的详情和提案")); - assertFalse(rendered.contains("要求:")); - - String renderedEn = new FeasibilityProposeGenerator() - .generate( - context(2), - new FeasibilityProposeContent( - "Regarding the adjusted rate guarantee target, the feasibility assessment has been" - + " completed and the conclusion is feasible. Please reply to .", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - null, - null, - "The target is assessed as feasible. Do you agree to proceed with this target?"), - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, - """ - ## Feasibility Negotiation - {{feasibility_negotiation_summary}} (required) - Requirement: summary. - - ## Under Evaluation Description - {{under_evaluation_description}} (optional) - Requirement: evaluate. - - ## Feasible Evaluation Confirmation Request - {{feasibility_confirm_request}} (optional) - Requirement: confirm. - """), - enVocabulary); - - assertTrue(renderedEn.contains("## Feasible Evaluation Confirmation Request\nThe target is assessed as" - + " feasible. Do you agree to proceed with this target?")); - assertFalse(renderedEn.contains("## Under Evaluation Description")); - } - - @Test - void targetFirstRoundRendersIntentSectionAndDropsAlignmentSection() { - TargetProposeContent content = new TargetProposeContent( - "对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。", - List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), - List.of(new NegotiationItem("对齐项", "不应在首轮渲染")), - List.of(new NegotiationItem("节能时间范围", "需要澄清")), - null); - - String rendered = new TargetProposeGenerator() - .generate( - context(1), - content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 意图理解陈述\n1. 发起方理解:对方希望在体验无损的前提下降低节能力度")); - assertFalse(rendered.contains("## 理解对齐与疑问澄清")); - assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); - assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。")); - } - - @Test - void targetLaterRoundRendersAlignmentSectionAndDropsIntentSection() { - TargetProposeContent content = new TargetProposeContent( - "已提供疑问澄清,请确认。", - List.of(new NegotiationItem("意图项", "不应在非首轮渲染")), - List.of(new NegotiationItem("确认结果", "节能时间范围确认为08:00~18:00")), - null, - null); - - String rendered = new TargetProposeGenerator() - .generate( - context(3), - content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(rendered.contains("## 理解对齐与疑问澄清\n1. 确认结果:节能时间范围确认为08:00~18:00")); - assertFalse(rendered.contains("## 意图理解陈述")); - assertFalse(rendered.contains("## 待澄清内容")); - } - - @Test - void targetBlankDescriptionIsRejected() { - TargetProposeContent content = new TargetProposeContent(" ", null, null, null, null); - - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> new TargetProposeGenerator() - .generate( - context(1), - content, - template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), - zhVocabulary)); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); - assertEquals( - "content.targetNegotiationDescription", exception.getFacts().get("field")); - } - - @Test - void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { - InformationProposeContent withRelationship = new InformationProposeContent( - List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点"), new NegotiationItem("受影响小区标识", "CGI 或小区名称")), - "故障发生时间与受影响小区标识需逐小区对应"); - - String renderedZh = new InformationProposeGenerator() - .generate( - context(1), - withRelationship, - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), - zhVocabulary); - - assertTrue(renderedZh.contains("## 所需信息项\n1. 故障发生时间:精确到分钟的时间点\n2. 受影响小区标识:CGI 或小区名称")); - assertTrue(renderedZh.endsWith("缺失项之间的关系:故障发生时间与受影响小区标识需逐小区对应")); - - InformationProposeContent withoutRelationship = - new InformationProposeContent(List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点")), null); - - String renderedWithout = new InformationProposeGenerator() - .generate( - context(1), - withoutRelationship, - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), - zhVocabulary); - - assertFalse(renderedWithout.contains("缺失项之间的关系")); - } - - @Test - void informationProposeRendersEnglishRelationshipLabelWithTrailingSpace() { - InformationProposeContent content = new InformationProposeContent( - List.of(new NegotiationItem("Failure time", "minute precision")), - "failure time and cell identity must correspond per cell"); - - PromptTemplate enTemplate = template( - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, - """ - ## Required Information Items - {{required_information_items}} (required) - Requirement: items. - """); - - String rendered = new InformationProposeGenerator().generate(context(1), content, enTemplate, enVocabulary); - - assertTrue(rendered.endsWith( - "Relationship between missing items: failure time and cell identity must correspond per cell")); - } - - @Test - void endingGeneratorsRenderConclusionLiterals() { - String infoRendered = new InformationEndingGenerator() - .generate( - context(2, NegotiationPerformative.ACCEPT), - new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30"))), - template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), - zhVocabulary); - - assertTrue(infoRendered.contains("## 信息协商结果\nAccept")); - assertTrue(infoRendered.contains("## 信息协商结果内容\n1. 故障发生时间:2026-08-19 10:30")); - - String targetRendered = new TargetEndingGenerator() - .generate( - context(2, NegotiationPerformative.REJECT), - new TargetEndingContent(NegotiationConclusion.REJECT, null, "双方未就速率保障下限达成一致。"), - template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), - zhVocabulary); - - assertTrue(targetRendered.contains("## 目标协商结果\nReject")); - assertTrue(targetRendered.contains("## 目标协商结果内容\n双方未就速率保障下限达成一致。")); - - String feasibilityRendered = new FeasibilityEndingGenerator() - .generate( - context(2), - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "同意将速率保障目标由5Mbps下调至2Mbps。"), - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - ZH_FEASIBILITY_ENDING_TEMPLATE), - zhVocabulary); - - assertTrue(feasibilityRendered.contains("## 可行性协商结果\nAccept")); - assertTrue(feasibilityRendered.contains("## 可行性评估结果确认\n同意将速率保障目标由5Mbps下调至2Mbps。")); - } - - @Test - void informationEndingRejectsEmptyResultItems() { - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> new InformationEndingGenerator() - .generate( - context(2), - new InformationEndingContent(NegotiationConclusion.REJECT, List.of()), - template( - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, - ZH_INFO_ENDING_TEMPLATE), - zhVocabulary)); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); - assertEquals("items", exception.getFacts().get("field")); - } - - @Test - void informationProposeRejectsEmptyRequestedItems() { - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> new InformationProposeGenerator() - .generate( - context(1), - new InformationProposeContent(List.of(), null), - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), - zhVocabulary)); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); - assertEquals("items", exception.getFacts().get("field")); - } - - @Test - void abortConclusionIsRejectedByEveryEndingGenerator() { - assertThrows(IllegalArgumentException.class, () -> new InformationEndingGenerator() - .generate( - context(1), - new InformationEndingContent(NegotiationConclusion.ABORT, List.of()), - template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), - zhVocabulary)); - assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() - .generate( - context(1), - new TargetEndingContent(NegotiationConclusion.ABORT, "意图", null), - template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), - zhVocabulary)); - assertThrows(IllegalArgumentException.class, () -> new FeasibilityEndingGenerator() - .generate( - context(1), - new FeasibilityEndingContent(NegotiationConclusion.ABORT, "结论"), - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - ZH_FEASIBILITY_ENDING_TEMPLATE), - zhVocabulary)); - } - - @Test - void nullConclusionIsRejected() { - NullPointerException exception = assertThrows(NullPointerException.class, () -> new InformationEndingGenerator() - .generate( - context(1), - new InformationEndingContent(null, List.of()), - template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), - zhVocabulary)); - - assertTrue(exception.getMessage().contains("conclusion must not be null")); - } - - @Test - void targetEndingRequiresTheFieldMatchingTheConclusion() { - NegotiationGenerationException acceptWithoutIntent = - assertThrows(NegotiationGenerationException.class, () -> new TargetEndingGenerator() - .generate( - context(1), - new TargetEndingContent(NegotiationConclusion.ACCEPT, null, "失败原因"), - template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), - zhVocabulary)); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), acceptWithoutIntent.getCode()); - assertEquals("content.confirmedIntent", acceptWithoutIntent.getFacts().get("field")); - - NegotiationGenerationException rejectWithoutReason = - assertThrows(NegotiationGenerationException.class, () -> new TargetEndingGenerator() - .generate( - context(1), - new TargetEndingContent(NegotiationConclusion.REJECT, " ", null), - template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), - zhVocabulary)); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), rejectWithoutReason.getCode()); - assertEquals("content.failureReason", rejectWithoutReason.getFacts().get("field")); - } - - @Test - void feasibilityEndingRequiresTheSummary() { - NegotiationGenerationException exception = - assertThrows(NegotiationGenerationException.class, () -> new FeasibilityEndingGenerator() - .generate( - context(1), - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " "), - template( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - ZH_FEASIBILITY_ENDING_TEMPLATE), - zhVocabulary)); - - assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); - assertEquals("content.feasibilitySummary", exception.getFacts().get("field")); - } - - @Test - void generatorRejectsContentOfAnotherRuntimeType() { - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> new InformationProposeGenerator() - .generate( - context(1), - new TargetProposeContent("描述", null, null, null, null), - template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), - zhVocabulary)); - - assertTrue(exception.getMessage().contains("requires content of type InformationProposeContent")); - } - - @Test - void generatorsRenderAgainstTheBundledTemplates() { - NegotiationReference reference = NegotiationReference.tryParse( - StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), NegotiationPerformative.PROPOSE, "zh-CN") - .orElseThrow(() -> new AssertionError("expected the bundled target propose template to resolve")); - PromptTemplate loaded = new DefaultNegotiationTemplateLoader("zh-CN").load(reference); - - TargetProposeContent content = new TargetProposeContent( - "对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。", - List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), - null, - List.of(new NegotiationItem("节能时间范围", "需要澄清")), - null); - - String rendered = new TargetProposeGenerator().generate(context(1), content, loaded, zhVocabulary); - - assertTrue(rendered.startsWith("## 目标协商\n")); - assertFalse(rendered.contains("协商上下文"), "the context section must not be rendered"); - assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。")); - assertTrue(rendered.contains("## 意图理解陈述")); - assertFalse(rendered.contains("## 理解对齐与疑问澄清")); - assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); - assertFalse(rendered.contains("要求:")); - assertFalse(rendered.endsWith("\n")); - } - - private static NegotiationContext context(int round) { - return context(round, NegotiationPerformative.PROPOSE); - } - - private static NegotiationContext context(int round, NegotiationPerformative performative) { - return new NegotiationContext(CONTEXT_ID, round, 5, performative); - } - - private static PromptTemplate template(TemplateUri templateUri, String content) { - return new PromptTemplate(templateUri, "", content, "classpath"); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +class NegotiationGeneratorsTest { + + private static final String CONTEXT_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String ZH_TARGET_PROPOSE_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 目标协商 + {{目标协商概述}}(必填) + 要求: + 概述要求。 + + ## 意图理解陈述 + {{意图理解陈述}}(选填) + 要求: + 意图要求。 + + ## 理解对齐与疑问澄清 + {{理解对齐与疑问澄清}}(选填) + 要求: + 对齐要求。 + + ## 待澄清内容 + {{待澄清内容}}(选填) + 要求: + 澄清要求。 + """; + + private static final String ZH_FEASIBILITY_PROPOSE_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 可行性协商 + {{可行性协商概述}}(必填) + 要求: + 概述要求。 + + ## 待评估内容说明 + {{待评估内容说明}}(选填) + 要求: + 评估要求。 + + ## 评估不可行时的详情和提案 + {{评估不可行时的详情和提案}}(选填) + 要求: + 提案要求。 + """; + + private static final String ZH_TARGET_PROPOSE_CONFIRM_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 目标协商 + {{目标协商概述}}(必填) + 要求: + 概述要求。 + + ## 意图理解陈述 + {{意图理解陈述}}(选填) + 要求: + 意图要求。 + + ## 理解对齐与疑问澄清 + {{理解对齐与疑问澄清}}(选填) + 要求: + 对齐要求。 + + ## 待澄清内容 + {{待澄清内容}}(选填) + 要求: + 澄清要求。 + + ## 目标澄清后的确认请求 + {{目标澄清后的确认请求}}(选填) + 要求: + 确认要求。 + """; + + private static final String ZH_FEASIBILITY_PROPOSE_CONFIRM_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 可行性协商 + {{可行性协商概述}}(必填) + 要求: + 概述要求。 + + ## 待评估内容说明 + {{待评估内容说明}}(选填) + 要求: + 评估要求。 + + ## 评估不可行时的详情和提案 + {{评估不可行时的详情和提案}}(选填) + 要求: + 提案要求。 + + ## 评估可行时的确认请求 + {{评估可行时的确认请求}}(选填) + 要求: + 确认要求。 + """; + + private static final String ZH_INFO_PROPOSE_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 信息协商 + 请根据<所需信息项>补充相关内容。 + + ## 所需信息项 + {{所需信息项}}(必填) + 要求: + 信息项要求。 + """; + + private static final String ZH_INFO_ENDING_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 信息协商结果 + {{信息协商结果}}(必填) + 要求: + 结论要求。 + + ## 信息协商结果内容 + {{信息协商结果内容}}(必填) + 要求: + 内容要求。 + """; + + private static final String ZH_TARGET_ENDING_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 目标协商结果 + {{目标协商结果}}(必填) + 要求: + 结论要求。 + + ## 目标协商结果内容 + {{目标协商结果内容}}(必填) + 要求: + 内容要求。 + """; + + private static final String ZH_FEASIBILITY_ENDING_TEMPLATE = + """ + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 上下文要求。 + + ## 可行性协商结果 + {{可行性协商结果}}(必填) + 要求: + 结论要求。 + + ## 可行性评估结果确认 + {{评估结果确认}}(必填) + 要求: + 确认要求。 + """; + + private final Vocabulary zhVocabulary = Vocabulary.forLanguage("zh-CN"); + + private final Vocabulary enVocabulary = Vocabulary.forLanguage("en-US"); + + @Test + void feasibilityRequestActionRendersExactlyTheEvaluationSection() { + FeasibilityProposeContent content = new FeasibilityProposeContent( + "请协助评估该节能目标能否达成。", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("评估对象", "停电8小时期间核心用户的速率保障")), + List.of(new NegotiationItem("不应出现", "值")), + null); + + String rendered = new FeasibilityProposeGenerator() + .generate( + context(1), + content, + template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 可行性协商\n请协助评估该节能目标能否达成。")); + assertTrue(rendered.contains("## 待评估内容说明\n1. 评估对象:停电8小时期间核心用户的速率保障")); + assertFalse(rendered.contains("## 评估不可行时的详情和提案")); + assertFalse(rendered.contains("要求:")); + } + + @Test + void feasibilityAlternativeActionRendersExactlyTheInfeasibilitySection() { + FeasibilityProposeContent content = new FeasibilityProposeContent( + "当前速率目标不可行,提出下调方案。", + NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, + List.of(new NegotiationItem("不应出现", "值")), + List.of( + new NegotiationItem("不可行原因", "蓄电池仅能支撑8小时2Mbps的保障能力"), + new NegotiationItem("替代提案", "停电期间将速率保障目标下调至2Mbps")), + null); + + String rendered = new FeasibilityProposeGenerator() + .generate( + context(2), + content, + template(StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 可行性协商\n当前速率目标不可行,提出下调方案。")); + assertTrue(rendered.contains("## 评估不可行时的详情和提案\n1. 不可行原因:蓄电池仅能支撑8小时2Mbps的保障能力")); + assertTrue(rendered.contains("2. 替代提案:停电期间将速率保障目标下调至2Mbps")); + assertFalse(rendered.contains("## 待评估内容说明")); + } + + @Test + void feasibilityNullActionOrEmptyDrivenContentIsRejected() { + FeasibilityProposeContent nullAction = + new FeasibilityProposeContent("描述", null, List.of(new NegotiationItem("名称", "值")), null, null); + + NullPointerException nullActionError = + assertThrows(NullPointerException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + nullAction, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertTrue(nullActionError.getMessage().contains("action must not be null")); + + FeasibilityProposeContent emptyEvaluation = + new FeasibilityProposeContent("描述", NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, null, null, null); + + NegotiationGenerationException emptyEvaluationError = + assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + emptyEvaluation, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), emptyEvaluationError.getCode()); + assertEquals( + "content.contentsToEvaluate", emptyEvaluationError.getFacts().get("field")); + + FeasibilityProposeContent emptyAlternative = new FeasibilityProposeContent( + "描述", NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, null, List.of(), null); + + NegotiationGenerationException emptyAlternativeError = + assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + emptyAlternative, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), emptyAlternativeError.getCode()); + assertEquals( + "content.infeasibilityDetailsAndProposal", + emptyAlternativeError.getFacts().get("field")); + + FeasibilityProposeContent blankDescription = new FeasibilityProposeContent( + " ", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("名称", "值")), + null, + null); + + NegotiationGenerationException blankDescriptionError = + assertThrows(NegotiationGenerationException.class, () -> new FeasibilityProposeGenerator() + .generate( + context(1), + blankDescription, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_TEMPLATE), + zhVocabulary)); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), blankDescriptionError.getCode()); + assertEquals( + "content.feasibilityNegotiationDescription", + blankDescriptionError.getFacts().get("field")); + } + + @Test + void targetConfirmRequestRendersOnlyTheSummaryAndConfirmRequestSections() { + TargetProposeContent content = + new TargetProposeContent("任务目标澄清完成,请答复<目标澄清后的确认请求>。", null, null, null, "目标已经澄清,是否同意按照此目标继续执行?"); + + String rendered = new TargetProposeGenerator() + .generate( + context(2), + content, + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_CONFIRM_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 目标协商\n任务目标澄清完成,请答复<目标澄清后的确认请求>。")); + assertTrue(rendered.contains("## 目标澄清后的确认请求\n目标已经澄清,是否同意按照此目标继续执行?")); + assertFalse(rendered.contains("## 意图理解陈述")); + assertFalse(rendered.contains("## 理解对齐与疑问澄清")); + assertFalse(rendered.contains("## 待澄清内容")); + assertFalse(rendered.contains("要求:")); + + String renderedEn = new TargetProposeGenerator() + .generate( + context(2), + new TargetProposeContent( + "The clarification of the task target has been completed. Please reply to .", + null, + null, + null, + "The target has been clarified. Do you agree to proceed with this target?"), + template( + StandardTemplates.TARGET_NEGOTIATION_PROPOSE, + """ + ## Target Negotiation + {{target_negotiation_summary}} (required) + Requirement: summary. + + ## Intent Understanding Statement + {{intent_understanding_statement}} (optional) + Requirement: intent. + + ## Target Clarification Confirmation Request + {{target_confirm_request}} (optional) + Requirement: confirm. + """), + enVocabulary); + + assertTrue(renderedEn.contains("## Target Clarification Confirmation Request\nThe target has been clarified." + + " Do you agree to proceed with this target?")); + assertFalse(renderedEn.contains("## Intent Understanding Statement")); + } + + @Test + void feasibilityConfirmRequestRendersOnlyTheSummaryAndConfirmRequestSections() { + FeasibilityProposeContent content = new FeasibilityProposeContent( + "针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + null, + null, + "评估目标可行,是否同意按照此目标继续执行?"); + + String rendered = new FeasibilityProposeGenerator() + .generate( + context(2), + content, + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + ZH_FEASIBILITY_PROPOSE_CONFIRM_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 可行性协商\n针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。")); + assertTrue(rendered.contains("## 评估可行时的确认请求\n评估目标可行,是否同意按照此目标继续执行?")); + assertFalse(rendered.contains("## 待评估内容说明")); + assertFalse(rendered.contains("## 评估不可行时的详情和提案")); + assertFalse(rendered.contains("要求:")); + + String renderedEn = new FeasibilityProposeGenerator() + .generate( + context(2), + new FeasibilityProposeContent( + "Regarding the adjusted rate guarantee target, the feasibility assessment has been" + + " completed and the conclusion is feasible. Please reply to .", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + null, + null, + "The target is assessed as feasible. Do you agree to proceed with this target?"), + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE, + """ + ## Feasibility Negotiation + {{feasibility_negotiation_summary}} (required) + Requirement: summary. + + ## Under Evaluation Description + {{under_evaluation_description}} (optional) + Requirement: evaluate. + + ## Feasible Evaluation Confirmation Request + {{feasibility_confirm_request}} (optional) + Requirement: confirm. + """), + enVocabulary); + + assertTrue(renderedEn.contains("## Feasible Evaluation Confirmation Request\nThe target is assessed as" + + " feasible. Do you agree to proceed with this target?")); + assertFalse(renderedEn.contains("## Under Evaluation Description")); + } + + @Test + void targetFirstRoundRendersIntentSectionAndDropsAlignmentSection() { + TargetProposeContent content = new TargetProposeContent( + "对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。", + List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), + List.of(new NegotiationItem("对齐项", "不应在首轮渲染")), + List.of(new NegotiationItem("节能时间范围", "需要澄清")), + null); + + String rendered = new TargetProposeGenerator() + .generate( + context(1), + content, + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 意图理解陈述\n1. 发起方理解:对方希望在体验无损的前提下降低节能力度")); + assertFalse(rendered.contains("## 理解对齐与疑问澄清")); + assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); + assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见意图理解陈述,请澄清和确认。")); + } + + @Test + void targetLaterRoundRendersAlignmentSectionAndDropsIntentSection() { + TargetProposeContent content = new TargetProposeContent( + "已提供疑问澄清,请确认。", + List.of(new NegotiationItem("意图项", "不应在非首轮渲染")), + List.of(new NegotiationItem("确认结果", "节能时间范围确认为08:00~18:00")), + null, + null); + + String rendered = new TargetProposeGenerator() + .generate( + context(3), + content, + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(rendered.contains("## 理解对齐与疑问澄清\n1. 确认结果:节能时间范围确认为08:00~18:00")); + assertFalse(rendered.contains("## 意图理解陈述")); + assertFalse(rendered.contains("## 待澄清内容")); + } + + @Test + void targetBlankDescriptionIsRejected() { + TargetProposeContent content = new TargetProposeContent(" ", null, null, null, null); + + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new TargetProposeGenerator() + .generate( + context(1), + content, + template(StandardTemplates.TARGET_NEGOTIATION_PROPOSE, ZH_TARGET_PROPOSE_TEMPLATE), + zhVocabulary)); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals( + "content.targetNegotiationDescription", exception.getFacts().get("field")); + } + + @Test + void informationProposeAppendsRelationshipLineAndOmitsItWhenAbsent() { + InformationProposeContent withRelationship = new InformationProposeContent( + List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点"), new NegotiationItem("受影响小区标识", "CGI 或小区名称")), + "故障发生时间与受影响小区标识需逐小区对应"); + + String renderedZh = new InformationProposeGenerator() + .generate( + context(1), + withRelationship, + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary); + + assertTrue(renderedZh.contains("## 所需信息项\n1. 故障发生时间:精确到分钟的时间点\n2. 受影响小区标识:CGI 或小区名称")); + assertTrue(renderedZh.endsWith("缺失项之间的关系:故障发生时间与受影响小区标识需逐小区对应")); + + InformationProposeContent withoutRelationship = + new InformationProposeContent(List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点")), null); + + String renderedWithout = new InformationProposeGenerator() + .generate( + context(1), + withoutRelationship, + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary); + + assertFalse(renderedWithout.contains("缺失项之间的关系")); + } + + @Test + void informationProposeRendersEnglishRelationshipLabelWithTrailingSpace() { + InformationProposeContent content = new InformationProposeContent( + List.of(new NegotiationItem("Failure time", "minute precision")), + "failure time and cell identity must correspond per cell"); + + PromptTemplate enTemplate = template( + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, + """ + ## Required Information Items + {{required_information_items}} (required) + Requirement: items. + """); + + String rendered = new InformationProposeGenerator().generate(context(1), content, enTemplate, enVocabulary); + + assertTrue(rendered.endsWith( + "Relationship between missing items: failure time and cell identity must correspond per cell")); + } + + @Test + void endingGeneratorsRenderConclusionLiterals() { + String infoRendered = new InformationEndingGenerator() + .generate( + context(2, NegotiationPerformative.ACCEPT), + new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30"))), + template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), + zhVocabulary); + + assertTrue(infoRendered.contains("## 信息协商结果\nAccept")); + assertTrue(infoRendered.contains("## 信息协商结果内容\n1. 故障发生时间:2026-08-19 10:30")); + + String targetRendered = new TargetEndingGenerator() + .generate( + context(2, NegotiationPerformative.REJECT), + new TargetEndingContent(NegotiationConclusion.REJECT, null, "双方未就速率保障下限达成一致。"), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary); + + assertTrue(targetRendered.contains("## 目标协商结果\nReject")); + assertTrue(targetRendered.contains("## 目标协商结果内容\n双方未就速率保障下限达成一致。")); + + String feasibilityRendered = new FeasibilityEndingGenerator() + .generate( + context(2), + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "同意将速率保障目标由5Mbps下调至2Mbps。"), + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + ZH_FEASIBILITY_ENDING_TEMPLATE), + zhVocabulary); + + assertTrue(feasibilityRendered.contains("## 可行性协商结果\nAccept")); + assertTrue(feasibilityRendered.contains("## 可行性评估结果确认\n同意将速率保障目标由5Mbps下调至2Mbps。")); + } + + @Test + void informationEndingRejectsEmptyResultItems() { + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new InformationEndingGenerator() + .generate( + context(2), + new InformationEndingContent(NegotiationConclusion.REJECT, List.of()), + template( + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, + ZH_INFO_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals("items", exception.getFacts().get("field")); + } + + @Test + void informationProposeRejectsEmptyRequestedItems() { + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new InformationProposeGenerator() + .generate( + context(1), + new InformationProposeContent(List.of(), null), + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary)); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals("items", exception.getFacts().get("field")); + } + + @Test + void abortConclusionIsRejectedByEveryEndingGenerator() { + assertThrows(IllegalArgumentException.class, () -> new InformationEndingGenerator() + .generate( + context(1), + new InformationEndingContent(NegotiationConclusion.ABORT, List.of()), + template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), + zhVocabulary)); + assertThrows(IllegalArgumentException.class, () -> new TargetEndingGenerator() + .generate( + context(1), + new TargetEndingContent(NegotiationConclusion.ABORT, "意图", null), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary)); + assertThrows(IllegalArgumentException.class, () -> new FeasibilityEndingGenerator() + .generate( + context(1), + new FeasibilityEndingContent(NegotiationConclusion.ABORT, "结论"), + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + ZH_FEASIBILITY_ENDING_TEMPLATE), + zhVocabulary)); + } + + @Test + void nullConclusionIsRejected() { + NullPointerException exception = assertThrows(NullPointerException.class, () -> new InformationEndingGenerator() + .generate( + context(1), + new InformationEndingContent(null, List.of()), + template(StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, ZH_INFO_ENDING_TEMPLATE), + zhVocabulary)); + + assertTrue(exception.getMessage().contains("conclusion must not be null")); + } + + @Test + void targetEndingRequiresTheFieldMatchingTheConclusion() { + NegotiationGenerationException acceptWithoutIntent = + assertThrows(NegotiationGenerationException.class, () -> new TargetEndingGenerator() + .generate( + context(1), + new TargetEndingContent(NegotiationConclusion.ACCEPT, null, "失败原因"), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), acceptWithoutIntent.getCode()); + assertEquals("content.confirmedIntent", acceptWithoutIntent.getFacts().get("field")); + + NegotiationGenerationException rejectWithoutReason = + assertThrows(NegotiationGenerationException.class, () -> new TargetEndingGenerator() + .generate( + context(1), + new TargetEndingContent(NegotiationConclusion.REJECT, " ", null), + template(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, ZH_TARGET_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), rejectWithoutReason.getCode()); + assertEquals("content.failureReason", rejectWithoutReason.getFacts().get("field")); + } + + @Test + void feasibilityEndingRequiresTheSummary() { + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new FeasibilityEndingGenerator() + .generate( + context(1), + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " "), + template( + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + ZH_FEASIBILITY_ENDING_TEMPLATE), + zhVocabulary)); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals("content.feasibilitySummary", exception.getFacts().get("field")); + } + + @Test + void generatorRejectsContentOfAnotherRuntimeType() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new InformationProposeGenerator() + .generate( + context(1), + new TargetProposeContent("描述", null, null, null, null), + template(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, ZH_INFO_PROPOSE_TEMPLATE), + zhVocabulary)); + + assertTrue(exception.getMessage().contains("requires content of type InformationProposeContent")); + } + + @Test + void generatorsRenderAgainstTheBundledTemplates() { + NegotiationReference reference = NegotiationReference.tryParse( + StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), NegotiationPerformative.PROPOSE, "zh-CN") + .orElseThrow(() -> new AssertionError("expected the bundled target propose template to resolve")); + PromptTemplate loaded = new DefaultNegotiationTemplateLoader("zh-CN").load(reference); + + TargetProposeContent content = new TargetProposeContent( + "对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。", + List.of(new NegotiationItem("发起方理解", "对方希望在体验无损的前提下降低节能力度")), + null, + List.of(new NegotiationItem("节能时间范围", "需要澄清")), + null); + + String rendered = new TargetProposeGenerator().generate(context(1), content, loaded, zhVocabulary); + + assertTrue(rendered.startsWith("## 目标协商\n")); + assertFalse(rendered.contains("协商上下文"), "the context section must not be rendered"); + assertTrue(rendered.contains("## 目标协商\n对无线节能优化任务的意图理解参见<意图理解陈述>,请澄清和确认。")); + assertTrue(rendered.contains("## 意图理解陈述")); + assertFalse(rendered.contains("## 理解对齐与疑问澄清")); + assertTrue(rendered.contains("## 待澄清内容\n1. 节能时间范围:需要澄清")); + assertFalse(rendered.contains("要求:")); + assertFalse(rendered.endsWith("\n")); + } + + private static NegotiationContext context(int round) { + return context(round, NegotiationPerformative.PROPOSE); + } + + private static NegotiationContext context(int round, NegotiationPerformative performative) { + return new NegotiationContext(CONTEXT_ID, round, 5, performative); + } + + private static PromptTemplate template(TemplateUri templateUri, String content) { + return new PromptTemplate(templateUri, "", content, "classpath"); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatterTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatterTest.java index d81230d8..3bf960fb 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatterTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatterTest.java @@ -1,45 +1,45 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.List; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import org.junit.jupiter.api.Test; - -class NegotiationItemFormatterTest { - - private final NegotiationItemFormatter formatter = new NegotiationItemFormatter(); - - @Test - void formatsNumberedLinesWithSuppliedColonPunctuation() { - List items = - List.of(new NegotiationItem("节能区域信息", "松山湖"), new NegotiationItem("节能速率保障目标", "20Mbps")); - - assertEquals("1. 节能区域信息:松山湖\n2. 节能速率保障目标:20Mbps", formatter.format(items, ":")); - assertEquals( - "1. Energy-saving area information: Songshan Lake\n2. Rate guarantee target: 20 Mbps", - formatter.format( - List.of( - new NegotiationItem("Energy-saving area information", "Songshan Lake"), - new NegotiationItem("Rate guarantee target", "20 Mbps")), - ": ")); - } - - @Test - void omitsColonAndValueWhenValueIsNullOrBlank() { - List items = List.of(new NegotiationItem("名称", null), new NegotiationItem("另一个名称", " ")); - - assertEquals("1. 名称\n2. 另一个名称", formatter.format(items, ":")); - } - - @Test - void rendersEmptyStringForNullOrEmptyList() { - assertEquals("", formatter.format(null, ":")); - assertEquals("", formatter.format(List.of(), ":")); - } - - @Test - void toleratesNullColonPunctuation() { - assertEquals("1. 名称值", formatter.format(List.of(new NegotiationItem("名称", "值")), null)); - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import org.junit.jupiter.api.Test; + +class NegotiationItemFormatterTest { + + private final NegotiationItemFormatter formatter = new NegotiationItemFormatter(); + + @Test + void formatsNumberedLinesWithSuppliedColonPunctuation() { + List items = + List.of(new NegotiationItem("节能区域信息", "松山湖"), new NegotiationItem("节能速率保障目标", "20Mbps")); + + assertEquals("1. 节能区域信息:松山湖\n2. 节能速率保障目标:20Mbps", formatter.format(items, ":")); + assertEquals( + "1. Energy-saving area information: Songshan Lake\n2. Rate guarantee target: 20 Mbps", + formatter.format( + List.of( + new NegotiationItem("Energy-saving area information", "Songshan Lake"), + new NegotiationItem("Rate guarantee target", "20 Mbps")), + ": ")); + } + + @Test + void omitsColonAndValueWhenValueIsNullOrBlank() { + List items = List.of(new NegotiationItem("名称", null), new NegotiationItem("另一个名称", " ")); + + assertEquals("1. 名称\n2. 另一个名称", formatter.format(items, ":")); + } + + @Test + void rendersEmptyStringForNullOrEmptyList() { + assertEquals("", formatter.format(null, ":")); + assertEquals("", formatter.format(List.of(), ":")); + } + + @Test + void toleratesNullColonPunctuation() { + assertEquals("1. 名称值", formatter.format(List.of(new NegotiationItem("名称", "值")), null)); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java index 4551c522..89040c6f 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java @@ -1,134 +1,134 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -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 java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import org.junit.jupiter.api.Test; - -class NegotiationJsonSchemaBuilderTest { - - private final NegotiationJsonSchemaBuilder builder = new NegotiationJsonSchemaBuilder(); - - @Test - void informationProposeSchemaUsesSnakeCasePropertiesAndNullableRelationship() { - Map schema = - builder.buildExtractionSchema(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE); - - assertEquals("object", schema.get("type")); - Map properties = (Map) schema.get("properties"); - assertEquals(List.of("items", "relationship"), List.copyOf(properties.keySet())); - assertEquals(List.of("items"), schema.get("required")); - - Map relationship = (Map) properties.get("relationship"); - assertEquals(List.of("string", "null"), relationship.get("type")); - assertFalse(relationship.containsKey("enum")); - - Map items = (Map) properties.get("items"); - assertEquals("array", items.get("type")); - Map item = (Map) items.get("items"); - assertEquals(List.of("name", "value"), List.copyOf(((Map) item.get("properties")).keySet())); - assertEquals(List.of("name", "value"), item.get("required")); - } - - @Test - void informationEndingSchemaRequiresConclusionAndItems() { - Map schema = - builder.buildExtractionSchema(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT); - - Map properties = (Map) schema.get("properties"); - assertEquals(List.of("conclusion", "items"), List.copyOf(properties.keySet())); - assertEquals(List.of("conclusion", "items"), schema.get("required")); - assertEquals(List.of("Accept", "Reject"), ((Map) properties.get("conclusion")).get("enum")); - assertEquals(1, ((Map) properties.get("items")).get("minItems")); - } - - @Test - void acceptAndRejectShareTheTerminalSchema() { - assertEquals( - builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPerformative.ACCEPT), - builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPerformative.REJECT)); - } - - @Test - void targetProposeSchemaUsesSnakeCaseProperties() { - Map schema = builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPerformative.PROPOSE); - - Map properties = (Map) schema.get("properties"); - assertEquals( - List.of( - "target_negotiation_description", - "intent_understanding", - "alignment_and_clarification", - "request_for_clarification", - "target_confirm_request"), - List.copyOf(properties.keySet())); - assertEquals(List.of("target_negotiation_description"), schema.get("required")); - assertEquals(List.of("array", "null"), ((Map) properties.get("intent_understanding")).get("type")); - assertEquals(List.of("string", "null"), ((Map) properties.get("target_confirm_request")).get("type")); - } - - @Test - void targetEndingSchemaCarriesNullableResultFields() { - Map schema = builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPerformative.REJECT); - - Map properties = (Map) schema.get("properties"); - assertEquals(List.of("conclusion", "confirmed_intent", "failure_reason"), List.copyOf(properties.keySet())); - assertEquals(List.of("conclusion"), schema.get("required")); - assertEquals(List.of("string", "null"), ((Map) properties.get("confirmed_intent")).get("type")); - assertEquals(List.of("string", "null"), ((Map) properties.get("failure_reason")).get("type")); - } - - @Test - void feasibilityProposeSchemaRequiresTheActionEnum() { - Map schema = - builder.buildExtractionSchema(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE); - - Map properties = (Map) schema.get("properties"); - assertEquals( - List.of( - "feasibility_negotiation_description", - "action", - "contents_to_evaluate", - "infeasibility_details_and_proposal", - "feasibility_confirm_request"), - List.copyOf(properties.keySet())); - assertEquals(List.of("feasibility_negotiation_description", "action"), schema.get("required")); - Map action = (Map) properties.get("action"); - assertEquals(List.of("REQUEST_FEASIBILITY_EVALUATION", "PROPOSE_ALTERNATIVE_ON_FAILURE"), action.get("enum")); - assertEquals(List.of("string", "null"), ((Map) properties.get("feasibility_confirm_request")).get("type")); - } - - @Test - void feasibilityEndingSchemaRequiresTheSummary() { - Map schema = - builder.buildExtractionSchema(NegotiationType.FEASIBILITY, NegotiationPerformative.ACCEPT); - - Map properties = (Map) schema.get("properties"); - assertEquals(List.of("conclusion", "feasibility_summary"), List.copyOf(properties.keySet())); - assertEquals(List.of("conclusion", "feasibility_summary"), schema.get("required")); - } - - @Test - void rejectsNullTypeAndPhase() { - assertEquals( - "Negotiation type must not be null for the PROPOSE phase.", - assertThrows( - NullPointerException.class, - () -> builder.buildExtractionSchema(null, NegotiationPerformative.PROPOSE)) - .getMessage()); - assertEquals( - "Negotiation phase must not be null.", - assertThrows( - NullPointerException.class, - () -> builder.buildExtractionSchema(NegotiationType.INFORMATION, null)) - .getMessage()); - } - -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import org.junit.jupiter.api.Test; + +class NegotiationJsonSchemaBuilderTest { + + private final NegotiationJsonSchemaBuilder builder = new NegotiationJsonSchemaBuilder(); + + @Test + void informationProposeSchemaUsesSnakeCasePropertiesAndNullableRelationship() { + Map schema = + builder.buildExtractionSchema(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE); + + assertEquals("object", schema.get("type")); + Map properties = (Map) schema.get("properties"); + assertEquals(List.of("items", "relationship"), List.copyOf(properties.keySet())); + assertEquals(List.of("items"), schema.get("required")); + + Map relationship = (Map) properties.get("relationship"); + assertEquals(List.of("string", "null"), relationship.get("type")); + assertFalse(relationship.containsKey("enum")); + + Map items = (Map) properties.get("items"); + assertEquals("array", items.get("type")); + Map item = (Map) items.get("items"); + assertEquals(List.of("name", "value"), List.copyOf(((Map) item.get("properties")).keySet())); + assertEquals(List.of("name", "value"), item.get("required")); + } + + @Test + void informationEndingSchemaRequiresConclusionAndItems() { + Map schema = + builder.buildExtractionSchema(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT); + + Map properties = (Map) schema.get("properties"); + assertEquals(List.of("conclusion", "items"), List.copyOf(properties.keySet())); + assertEquals(List.of("conclusion", "items"), schema.get("required")); + assertEquals(List.of("Accept", "Reject"), ((Map) properties.get("conclusion")).get("enum")); + assertEquals(1, ((Map) properties.get("items")).get("minItems")); + } + + @Test + void acceptAndRejectShareTheTerminalSchema() { + assertEquals( + builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPerformative.ACCEPT), + builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPerformative.REJECT)); + } + + @Test + void targetProposeSchemaUsesSnakeCaseProperties() { + Map schema = builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPerformative.PROPOSE); + + Map properties = (Map) schema.get("properties"); + assertEquals( + List.of( + "target_negotiation_description", + "intent_understanding", + "alignment_and_clarification", + "request_for_clarification", + "target_confirm_request"), + List.copyOf(properties.keySet())); + assertEquals(List.of("target_negotiation_description"), schema.get("required")); + assertEquals(List.of("array", "null"), ((Map) properties.get("intent_understanding")).get("type")); + assertEquals(List.of("string", "null"), ((Map) properties.get("target_confirm_request")).get("type")); + } + + @Test + void targetEndingSchemaCarriesNullableResultFields() { + Map schema = builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPerformative.REJECT); + + Map properties = (Map) schema.get("properties"); + assertEquals(List.of("conclusion", "confirmed_intent", "failure_reason"), List.copyOf(properties.keySet())); + assertEquals(List.of("conclusion"), schema.get("required")); + assertEquals(List.of("string", "null"), ((Map) properties.get("confirmed_intent")).get("type")); + assertEquals(List.of("string", "null"), ((Map) properties.get("failure_reason")).get("type")); + } + + @Test + void feasibilityProposeSchemaRequiresTheActionEnum() { + Map schema = + builder.buildExtractionSchema(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE); + + Map properties = (Map) schema.get("properties"); + assertEquals( + List.of( + "feasibility_negotiation_description", + "action", + "contents_to_evaluate", + "infeasibility_details_and_proposal", + "feasibility_confirm_request"), + List.copyOf(properties.keySet())); + assertEquals(List.of("feasibility_negotiation_description", "action"), schema.get("required")); + Map action = (Map) properties.get("action"); + assertEquals(List.of("REQUEST_FEASIBILITY_EVALUATION", "PROPOSE_ALTERNATIVE_ON_FAILURE"), action.get("enum")); + assertEquals(List.of("string", "null"), ((Map) properties.get("feasibility_confirm_request")).get("type")); + } + + @Test + void feasibilityEndingSchemaRequiresTheSummary() { + Map schema = + builder.buildExtractionSchema(NegotiationType.FEASIBILITY, NegotiationPerformative.ACCEPT); + + Map properties = (Map) schema.get("properties"); + assertEquals(List.of("conclusion", "feasibility_summary"), List.copyOf(properties.keySet())); + assertEquals(List.of("conclusion", "feasibility_summary"), schema.get("required")); + } + + @Test + void rejectsNullTypeAndPhase() { + assertEquals( + "Negotiation type must not be null for the PROPOSE phase.", + assertThrows( + NullPointerException.class, + () -> builder.buildExtractionSchema(null, NegotiationPerformative.PROPOSE)) + .getMessage()); + assertEquals( + "Negotiation phase must not be null.", + assertThrows( + NullPointerException.class, + () -> builder.buildExtractionSchema(NegotiationType.INFORMATION, null)) + .getMessage()); + } + +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java index cd029a7e..dfaddfb3 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java @@ -1,60 +1,60 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.junit.jupiter.api.Test; - -/** - * Verifies that the package-private {@link NegotiationPromptRenderer} is a pure function: repeated concurrent calls - * with the same input must return the same result. - */ -class NegotiationPromptRendererConcurrencyTest { - - private static final int THREADS = 8; - - private static final int ITERATIONS = 200; - - @Test - void promptRendererIsPureUnderConcurrentCalls() throws Exception { - String template = new DefaultNegotiationTemplateLoader("zh-CN") - .load(new NegotiationReference( - net.openan.a2at.sdk.negotiation.content.NegotiationType.INFORMATION, - net.openan.a2at.sdk.core.model.NegotiationPerformative.PROPOSE, - "zh-CN")) - .content(); - Map slots = Map.of( - "协商上下文", - "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 1\n- maxRounds: 5", - "所需信息项", - "1. 节能区域:松山湖"); - NegotiationPromptRenderer renderer = new NegotiationPromptRenderer(); - String baseline = renderer.render(template, slots); - - ExecutorService pool = Executors.newFixedThreadPool(THREADS); - try { - List> futures = new ArrayList<>(); - for (int index = 0; index < THREADS; index++) { - futures.add(pool.submit(() -> { - for (int iteration = 0; iteration < ITERATIONS; iteration++) { - assertEquals(baseline, renderer.render(template, slots), "renderer output must be stable"); - } - return null; - })); - } - for (Future future : futures) { - future.get(30, TimeUnit.SECONDS); - } - } finally { - pool.shutdownNow(); - } - } -} +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import net.openan.a2at.sdk.negotiation.resources.DefaultNegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +/** + * Verifies that the package-private {@link NegotiationPromptRenderer} is a pure function: repeated concurrent calls + * with the same input must return the same result. + */ +class NegotiationPromptRendererConcurrencyTest { + + private static final int THREADS = 8; + + private static final int ITERATIONS = 200; + + @Test + void promptRendererIsPureUnderConcurrentCalls() throws Exception { + String template = new DefaultNegotiationTemplateLoader("zh-CN") + .load(new NegotiationReference( + net.openan.a2at.sdk.negotiation.content.NegotiationType.INFORMATION, + net.openan.a2at.sdk.core.model.NegotiationPerformative.PROPOSE, + "zh-CN")) + .content(); + Map slots = Map.of( + "协商上下文", + "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 1\n- maxRounds: 5", + "所需信息项", + "1. 节能区域:松山湖"); + NegotiationPromptRenderer renderer = new NegotiationPromptRenderer(); + String baseline = renderer.render(template, slots); + + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + try { + List> futures = new ArrayList<>(); + for (int index = 0; index < THREADS; index++) { + futures.add(pool.submit(() -> { + for (int iteration = 0; iteration < ITERATIONS; iteration++) { + assertEquals(baseline, renderer.render(template, slots), "renderer output must be stable"); + } + return null; + })); + } + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } finally { + pool.shutdownNow(); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererTest.java index cff889a7..5fd8fa32 100644 --- a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererTest.java +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererTest.java @@ -1,216 +1,216 @@ -package net.openan.a2at.sdk.negotiation.generation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.LinkedHashMap; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class NegotiationPromptRendererTest { - - private final NegotiationPromptRenderer renderer = new NegotiationPromptRenderer(); - - private static final String ZH_TEMPLATE = - """ - - - ## 协商上下文 - {{协商上下文}}(必填) - 要求: - 必须包含 id、round 与 maxRounds。 - - ## 信息协商 - 请根据<所需信息项>补充相关内容。 - - ## 所需信息项 - {{所需信息项}}(必填) - 要求: - 提供每个缺失项的名称。 - """; - - private static final String EN_TEMPLATE = - """ - - - ## Negotiation Context - {{Negotiation Context}} (required) - Requirements: - Every negotiation must include id, round and maxRounds. - - ## Required Information Items - {{Required Information Items}} (required) - Requirements: - Provide the name of each missing item. - """; - - @Test - void rendersSlotSectionsDroppingRequirementsAndStaticSections() { - Map slots = new LinkedHashMap<>(); - slots.put("协商上下文", "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 1\n- maxRounds: 5"); - slots.put("所需信息项", "1. 节能区域信息:松山湖"); - - String rendered = renderer.render(ZH_TEMPLATE, slots); - - assertEquals( - "## 协商上下文\n" - + "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n" - + "- round: 1\n" - + "- maxRounds: 5\n" - + "\n" - + "## 信息协商\n" - + "请根据<所需信息项>补充相关内容。\n" - + "\n" - + "## 所需信息项\n" - + "1. 节能区域信息:松山湖", - rendered); - } - - @Test - void dropsWholeSlotSectionWhenValueIsMissingNullOrBlank() { - Map slots = new LinkedHashMap<>(); - slots.put("协商上下文", "- id: id-1\n- round: 1\n- maxRounds: 5"); - slots.put("所需信息项", null); - - String renderedWithNull = renderer.render(ZH_TEMPLATE, slots); - - assertEquals( - "## 协商上下文\n- id: id-1\n- round: 1\n- maxRounds: 5\n\n## 信息协商\n请根据<所需信息项>补充相关内容。", renderedWithNull); - - slots.put("所需信息项", " "); - String renderedWithBlank = renderer.render(ZH_TEMPLATE, slots); - - assertEquals(renderedWithNull, renderedWithBlank); - } - - @Test - void dropsLeadingHtmlCommentAndPreambleBeforeFirstSection() { - String rendered = renderer.render(ZH_TEMPLATE, Map.of("协商上下文", "- id: id-1", "所需信息项", "1. 名称")); - - assertFalse(rendered.contains(" + + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 必须包含 id、round 与 maxRounds。 + + ## 信息协商 + 请根据<所需信息项>补充相关内容。 + + ## 所需信息项 + {{所需信息项}}(必填) + 要求: + 提供每个缺失项的名称。 + """; + + private static final String EN_TEMPLATE = + """ + + + ## Negotiation Context + {{Negotiation Context}} (required) + Requirements: + Every negotiation must include id, round and maxRounds. + + ## Required Information Items + {{Required Information Items}} (required) + Requirements: + Provide the name of each missing item. + """; + + @Test + void rendersSlotSectionsDroppingRequirementsAndStaticSections() { + Map slots = new LinkedHashMap<>(); + slots.put("协商上下文", "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 1\n- maxRounds: 5"); + slots.put("所需信息项", "1. 节能区域信息:松山湖"); + + String rendered = renderer.render(ZH_TEMPLATE, slots); + + assertEquals( + "## 协商上下文\n" + + "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n" + + "- round: 1\n" + + "- maxRounds: 5\n" + + "\n" + + "## 信息协商\n" + + "请根据<所需信息项>补充相关内容。\n" + + "\n" + + "## 所需信息项\n" + + "1. 节能区域信息:松山湖", + rendered); + } + + @Test + void dropsWholeSlotSectionWhenValueIsMissingNullOrBlank() { + Map slots = new LinkedHashMap<>(); + slots.put("协商上下文", "- id: id-1\n- round: 1\n- maxRounds: 5"); + slots.put("所需信息项", null); + + String renderedWithNull = renderer.render(ZH_TEMPLATE, slots); + + assertEquals( + "## 协商上下文\n- id: id-1\n- round: 1\n- maxRounds: 5\n\n## 信息协商\n请根据<所需信息项>补充相关内容。", renderedWithNull); + + slots.put("所需信息项", " "); + String renderedWithBlank = renderer.render(ZH_TEMPLATE, slots); + + assertEquals(renderedWithNull, renderedWithBlank); + } + + @Test + void dropsLeadingHtmlCommentAndPreambleBeforeFirstSection() { + String rendered = renderer.render(ZH_TEMPLATE, Map.of("协商上下文", "- id: id-1", "所需信息项", "1. 名称")); + + assertFalse(rendered.contains("\nbody"); - - TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); - - List templates = service.getPrompts(); - Set uris = templates.stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); - - assertTrue(uris.contains("Task-T/network-layer/custom-planning/v1")); - assertFalse(uris.contains("Task-T/network-layer/ran-energy-saving/v1")); - assertFalse(uris.contains("Notification-T/network-layer/subscribe-incident/v1")); - assertFalse(uris.contains("Authorization-T/authorization-policy-management/v1")); - for (String negotiationUri : NEGOTIATION_CLOSED_SET) { - assertTrue(uris.contains(negotiationUri)); - } - } - - @Test - void localFileSourceTypeAnnotatesLocalAndClasspathSources() throws IOException { - writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); - - TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); - - List templates = service.getPrompts(); - PromptTemplate localTask = templates.stream() - .filter(t -> t.templateUri().uri().equals("Task-T/network-layer/custom-planning/v1")) - .findFirst() - .orElseThrow(); - assertEquals(LOCAL, localTask.source()); - - assertTrue( - templates.stream() - .filter(t -> t.templateUri().extensionName().equals("Negotiation-T")) - .allMatch(t -> CLASSPATH.equals(t.source())), - "all negotiation templates must be sourced from the classpath"); - } - - @Test - void closedSetFilterExcludesOutOfSetNegotiationTemplates() throws IOException { - writeTemplate(localRootDir, "Negotiation-T/feasibility-negotiation/revise/v1", "\nbody"); - writeTemplate( - localRootDir, "Negotiation-T/information-negotiation/propose/v1", "\nbody"); - - TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); - - Set uris = - service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); - - assertFalse(uris.contains("Negotiation-T/feasibility-negotiation/revise/v1")); - assertTrue(uris.contains("Negotiation-T/information-negotiation/propose/v1")); - assertTrue( - service.getPrompts().stream() - .filter(t -> t.templateUri().uri().equals("Negotiation-T/information-negotiation/propose/v1")) - .allMatch(t -> CLASSPATH.equals(t.source())), - "the in-closed-set negotiation template must come from the classpath, not the local override"); - - assertTrue(service.getPrompt(TemplateUri.of("Negotiation-T", "feasibility-negotiation", "revise")) - .isEmpty()); - } - - @Test - void localFileSourceTypeFreezesCatalogSnapshotIgnoringRuntimeFileChanges() throws IOException { - writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); - - TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); - Set before = - service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); - - writeTemplate(localRootDir, "Notification-T/network-layer/added-after-construction/v1", "\nbody"); - Files.delete(localRootDir - .resolve("templates") - .resolve("Task-T/network-layer/custom-planning/v1") - .resolve(LANGUAGE) - .resolve("template.md")); - - Set after = - service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); - - assertEquals(before, after); - } - - @Test - void closedSetMissLogsSinglePromptTemplateNotFoundWarning() { - new TemplateQueryService(LANGUAGE, "classpath", null) - .getPrompt(TemplateUri.of("Negotiation-T", "feasibility-negotiation", "revise")); - - List warnings = warningEvents(); - assertEquals(1, warnings.size(), "expected exactly one WARN for querying a closed-set-outside negotiation URI"); - assertTrue( - warnings.get(0).startsWith("prompt_template_not_found"), - "expected a prompt_template_not_found WARN, not a negotiation_template_outside_closed_set WARN"); - } - - @Test - void unsupportedSourceTypeFailsFast() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new TemplateQueryService(LANGUAGE, "database", localRootDir.toString())); - - assertEquals("Unsupported prompt source type: database", exception.getMessage()); - } - - @Test - void localFileSourceTypeWithoutRootFailsFast() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> new TemplateQueryService(LANGUAGE, "local_file", null)); - - assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); - } - - @Test - void localFileSourceTypeWithBlankRootFailsFast() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> new TemplateQueryService(LANGUAGE, "local_file", " ")); - - assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); - } - - @Test - void localFileSourceTypeWithNonexistentRootFailsFast() { - Path missing = localRootDir.resolve("missing"); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new TemplateQueryService(LANGUAGE, "local_file", missing.toString())); - - assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); - } - - @Test - void singleQueryMissLogsPromptTemplateNotFoundWarning() { - new TemplateQueryService(LANGUAGE, "classpath", null) - .getPrompt(TemplateUri.of("Task-T", "network-layer", "does-not-exist")); - - assertTrue( - warningEvents().stream().anyMatch(message -> message.startsWith("prompt_template_not_found")), - "expected a WARN for a missing single template query"); - } - - private List warningEvents() { - return appender.list.stream() - .filter(event -> event.getLevel() == Level.WARN) - .map(ILoggingEvent::getFormattedMessage) - .toList(); - } - - private static void writeTemplate(Path root, String uri, String content) throws IOException { - Path file = root.resolve("templates").resolve(uri).resolve(LANGUAGE).resolve("template.md"); - Files.createDirectories(file.getParent()); - Files.writeString(file, content); - } - - private static void assertSortedByUri(List templates) { - for (int i = 1; i < templates.size(); i++) { - assertTrue( - templates - .get(i - 1) - .templateUri() - .uri() - .compareTo(templates.get(i).templateUri().uri()) - <= 0, - "templates must be sorted by URI"); - } - } -} +package net.openan.a2at.sdk.prompt.resources.catalog; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +class TemplateQueryServiceTest { + + private static final String LANGUAGE = "en-US"; + + private static final String CLASSPATH = "classpath"; + + private static final String LOCAL = "local"; + + private static final List NEGOTIATION_CLOSED_SET = List.of( + "Negotiation-T/common/abort/v1", + "Negotiation-T/feasibility-negotiation/accept-reject/v1", + "Negotiation-T/feasibility-negotiation/propose/v1", + "Negotiation-T/information-negotiation/accept-reject/v1", + "Negotiation-T/information-negotiation/propose/v1", + "Negotiation-T/target-negotiation/accept-reject/v1", + "Negotiation-T/target-negotiation/propose/v1"); + + @TempDir + Path localRootDir; + + private final ListAppender appender = new ListAppender<>(); + + private final Logger rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + + @BeforeEach + void attachAppender() { + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + rootLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void classpathSourceTypeListsBuiltInTemplatesWithClasspathSource() { + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "classpath", null); + + List templates = service.getPrompts(); + + assertFalse(templates.isEmpty()); + assertTrue(templates.stream().allMatch(t -> CLASSPATH.equals(t.source()))); + assertSortedByUri(templates); + Set uris = templates.stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + assertTrue(uris.containsAll(NEGOTIATION_CLOSED_SET)); + } + + @Test + void localFileSourceTypeListsLocalBusinessContentUnionClasspathNegotiationOnly() throws IOException { + writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + List templates = service.getPrompts(); + Set uris = templates.stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + + assertTrue(uris.contains("Task-T/network-layer/custom-planning/v1")); + assertFalse(uris.contains("Task-T/network-layer/ran-energy-saving/v1")); + assertFalse(uris.contains("Notification-T/network-layer/subscribe-incident/v1")); + assertFalse(uris.contains("Authorization-T/authorization-policy-management/v1")); + for (String negotiationUri : NEGOTIATION_CLOSED_SET) { + assertTrue(uris.contains(negotiationUri)); + } + } + + @Test + void localFileSourceTypeAnnotatesLocalAndClasspathSources() throws IOException { + writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + List templates = service.getPrompts(); + PromptTemplate localTask = templates.stream() + .filter(t -> t.templateUri().uri().equals("Task-T/network-layer/custom-planning/v1")) + .findFirst() + .orElseThrow(); + assertEquals(LOCAL, localTask.source()); + + assertTrue( + templates.stream() + .filter(t -> t.templateUri().extensionName().equals("Negotiation-T")) + .allMatch(t -> CLASSPATH.equals(t.source())), + "all negotiation templates must be sourced from the classpath"); + } + + @Test + void closedSetFilterExcludesOutOfSetNegotiationTemplates() throws IOException { + writeTemplate(localRootDir, "Negotiation-T/feasibility-negotiation/revise/v1", "\nbody"); + writeTemplate( + localRootDir, "Negotiation-T/information-negotiation/propose/v1", "\nbody"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + Set uris = + service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + + assertFalse(uris.contains("Negotiation-T/feasibility-negotiation/revise/v1")); + assertTrue(uris.contains("Negotiation-T/information-negotiation/propose/v1")); + assertTrue( + service.getPrompts().stream() + .filter(t -> t.templateUri().uri().equals("Negotiation-T/information-negotiation/propose/v1")) + .allMatch(t -> CLASSPATH.equals(t.source())), + "the in-closed-set negotiation template must come from the classpath, not the local override"); + + assertTrue(service.getPrompt(TemplateUri.of("Negotiation-T", "feasibility-negotiation", "revise")) + .isEmpty()); + } + + @Test + void localFileSourceTypeFreezesCatalogSnapshotIgnoringRuntimeFileChanges() throws IOException { + writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + Set before = + service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + + writeTemplate(localRootDir, "Notification-T/network-layer/added-after-construction/v1", "\nbody"); + Files.delete(localRootDir + .resolve("templates") + .resolve("Task-T/network-layer/custom-planning/v1") + .resolve(LANGUAGE) + .resolve("template.md")); + + Set after = + service.getPrompts().stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); + + assertEquals(before, after); + } + + @Test + void closedSetMissLogsSinglePromptTemplateNotFoundWarning() { + new TemplateQueryService(LANGUAGE, "classpath", null) + .getPrompt(TemplateUri.of("Negotiation-T", "feasibility-negotiation", "revise")); + + List warnings = warningEvents(); + assertEquals(1, warnings.size(), "expected exactly one WARN for querying a closed-set-outside negotiation URI"); + assertTrue( + warnings.get(0).startsWith("prompt_template_not_found"), + "expected a prompt_template_not_found WARN, not a negotiation_template_outside_closed_set WARN"); + } + + @Test + void unsupportedSourceTypeFailsFast() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new TemplateQueryService(LANGUAGE, "database", localRootDir.toString())); + + assertEquals("Unsupported prompt source type: database", exception.getMessage()); + } + + @Test + void localFileSourceTypeWithoutRootFailsFast() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> new TemplateQueryService(LANGUAGE, "local_file", null)); + + assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); + } + + @Test + void localFileSourceTypeWithBlankRootFailsFast() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> new TemplateQueryService(LANGUAGE, "local_file", " ")); + + assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); + } + + @Test + void localFileSourceTypeWithNonexistentRootFailsFast() { + Path missing = localRootDir.resolve("missing"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new TemplateQueryService(LANGUAGE, "local_file", missing.toString())); + + assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); + } + + @Test + void singleQueryMissLogsPromptTemplateNotFoundWarning() { + new TemplateQueryService(LANGUAGE, "classpath", null) + .getPrompt(TemplateUri.of("Task-T", "network-layer", "does-not-exist")); + + assertTrue( + warningEvents().stream().anyMatch(message -> message.startsWith("prompt_template_not_found")), + "expected a WARN for a missing single template query"); + } + + private List warningEvents() { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void writeTemplate(Path root, String uri, String content) throws IOException { + Path file = root.resolve("templates").resolve(uri).resolve(LANGUAGE).resolve("template.md"); + Files.createDirectories(file.getParent()); + Files.writeString(file, content); + } + + private static void assertSortedByUri(List templates) { + for (int i = 1; i < templates.size(); i++) { + assertTrue( + templates + .get(i - 1) + .templateUri() + .uri() + .compareTo(templates.get(i).templateUri().uri()) + <= 0, + "templates must be sorted by URI"); + } + } +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java index 36e08404..e536f45a 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java @@ -1,327 +1,327 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class LocalFilePromptLoadersTest { - - @TempDir - Path promptRootDir; - - @Test - void loadScenarioCatalogMapsJacksonAnnotatedRecords() throws IOException { - write( - promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "incident_triage", - "scenario_name": "Incident Triage", - "description": "Classify and route a production incident.", - "example": "Investigate elevated API latency." - }, - { - "scenario_code": "release_review", - "scenario_name": "Release Review", - "description": "Review a release candidate before rollout.", - "example": "Check the payment service release." - } - ] - } - """); - - List scenarios = - new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en"); - - assertEquals(2, scenarios.size()); - assertEquals("incident_triage", scenarios.get(0).scenarioCode()); - assertEquals("Incident Triage", scenarios.get(0).scenarioName()); - } - - @Test - void loadScenarioCatalogTreatsMissingScenariosArrayAsEmptyCatalog() throws IOException { - write(promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), "{}"); - - assertEquals(List.of(), new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en")); - } - - @Test - void loadTemplateReadsExactLocalMarkdownText() throws IOException { - write( - promptRootDir - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("template.md"), - """ - # Incident Triage - - Severity: {{severity}} - Summary: {{summary}} - """); - - String template = - new LocalFilePromptTemplateLoader(snapshot(), promptRootDir).loadTemplate("incident_triage", "en"); - - assertEquals( - """ - # Incident Triage - - Severity: {{severity}} - Summary: {{summary}} - """, - template); - } - - @Test - void loadSlotSchemaMapsJacksonAnnotatedRecords() throws IOException { - write( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - """ - { - "required": ["severity", "service"], - "properties": { - "service": { - "type": "string", - "pattern": "^[a-z0-9-]+$", - "description": "Affected service name" - }, - "severity": { - "type": "integer", - "minimum": 1, - "maximum": 5, - "enum": ["1", "2", "3", "4", "5"], - "x-a2at-value-constraint": "Severity from 1 to 5" - }, - "note": null - } - } - """); - - PromptSlotSchema schema = - new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir).loadSlotSchema("incident_triage", "en"); - - assertEquals("incident_triage", schema.scenarioCode()); - assertEquals(3, schema.slotDefinitions().size()); - assertEquals("service", schema.slotDefinitions().get(0).name()); - assertEquals(true, schema.slotDefinitions().get(0).required()); - assertEquals("^[a-z0-9-]+$", schema.slotDefinitions().get(0).pattern()); - assertEquals( - List.of("1", "2", "3", "4", "5"), - schema.slotDefinitions().get(1).allowedValues()); - assertEquals("Severity from 1 to 5", schema.slotDefinitions().get(1).valueConstraint()); - assertEquals("note", schema.slotDefinitions().get(2).name()); - } - - @Test - void missingTemplateIncludesResolvedLocalPathInResourceNotFoundException() { - ResourceNotFoundException exception = assertThrows( - ResourceNotFoundException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) - .loadTemplate("incident_triage", "en")); - - String expected = (promptRootDir.resolve("templates").toString() - + "/*/network-layer/incident_triage/v1/en/template.md" - + " (or the layout without the network-layer segment)") - .replace('\\', '/'); - assertEquals(expected, exception.resourcePath().replace('\\', '/')); - } - - @Test - void malformedLocalSlotSchemaIsWrappedAsA2ATError() throws IOException { - write( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - "{ \"required\": [\"severity\"], \"properties\": "); - - A2ATError exception = - assertThrows(A2ATError.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) - .loadSlotSchema("incident_triage", "en")); - - assertEquals("infra.resource_read_failed", exception.getCode()); - assertTrue(exception.getMessage().startsWith("Failed to read resource '")); - } - - @Test - void snapshotFreezesResourcesAgainstRuntimeFileChanges() throws IOException { - write( - promptRootDir - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("template.md"), - "Original template {{service}}."); - write( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - """ - { - "required": ["service"], - "properties": { - "service": {"type": "string", "description": "Affected service name"} - } - } - """); - write( - promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "incident_triage", - "scenario_name": "Incident Triage", - "description": "Classify and route a production incident.", - "example": "Investigate elevated API latency." - } - ] - } - """); - - PromptResourceAccess access = createAccess(); - - Files.writeString( - promptRootDir - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("template.md"), - "Mutated template."); - Files.writeString( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - "{\"properties\":{}}"); - write( - promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "mutated", - "scenario_name": "Mutated", - "description": "Mutated after snapshot.", - "example": "Mutated example." - } - ] - } - """); - - assertEquals("Original template {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); - assertEquals( - "service", - access.slotSchemaLoader() - .loadSlotSchema("incident_triage", "en") - .slotDefinitions() - .get(0) - .name()); - assertEquals("incident_triage", access.loadScenarios("en").get(0).scenarioCode()); - } - - @Test - void loadTemplateRejectsTraversalOrBlankScenarioPathSegments() { - assertThrows(IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) - .loadTemplate("../etc/passwd", "en")); - assertThrows(IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) - .loadTemplate("Task-T//network-layer/x", "en")); - assertThrows(IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) - .loadTemplate("incident_triage", "en/../admin")); - } - - @Test - void loadSlotSchemaRejectsNonSimpleLanguage() { - assertThrows( - IllegalArgumentException.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) - .loadSlotSchema("incident_triage", "../en")); - assertThrows( - IllegalArgumentException.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) - .loadSlotSchema("Task-T/network-layer/..", "en")); - } - - @Test - void loadScenarioCatalogRejectsNonSimpleLanguage() { - assertThrows( - IllegalArgumentException.class, - () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en/../admin")); - assertThrows( - IllegalArgumentException.class, - () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load(" ")); - } - - @Test - void localSnapshotKeepsPromptsOnClasspath() throws IOException { - write( - promptRootDir - .resolve("prompts") - .resolve("scenario_recognition") - .resolve("en-US") - .resolve("system.md"), - "Local prompt copy."); - - PromptResourceAccess access = createAccess(); - - assertFalse(access.classpath()); - assertNotEquals("Local prompt copy.", access.loadPrompt("scenario_recognition", "en-US", "system.md")); - } - - private PromptResourceAccess createAccess() { - return PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - } - - private Map snapshot() { - return LocalFileResourceSnapshot.capture(promptRootDir); - } - - private static void write(Path file, String content) throws IOException { - Files.createDirectories(file.getParent()); - Files.writeString(file, content); - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LocalFilePromptLoadersTest { + + @TempDir + Path promptRootDir; + + @Test + void loadScenarioCatalogMapsJacksonAnnotatedRecords() throws IOException { + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "incident_triage", + "scenario_name": "Incident Triage", + "description": "Classify and route a production incident.", + "example": "Investigate elevated API latency." + }, + { + "scenario_code": "release_review", + "scenario_name": "Release Review", + "description": "Review a release candidate before rollout.", + "example": "Check the payment service release." + } + ] + } + """); + + List scenarios = + new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en"); + + assertEquals(2, scenarios.size()); + assertEquals("incident_triage", scenarios.get(0).scenarioCode()); + assertEquals("Incident Triage", scenarios.get(0).scenarioName()); + } + + @Test + void loadScenarioCatalogTreatsMissingScenariosArrayAsEmptyCatalog() throws IOException { + write(promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), "{}"); + + assertEquals(List.of(), new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en")); + } + + @Test + void loadTemplateReadsExactLocalMarkdownText() throws IOException { + write( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + """ + # Incident Triage + + Severity: {{severity}} + Summary: {{summary}} + """); + + String template = + new LocalFilePromptTemplateLoader(snapshot(), promptRootDir).loadTemplate("incident_triage", "en"); + + assertEquals( + """ + # Incident Triage + + Severity: {{severity}} + Summary: {{summary}} + """, + template); + } + + @Test + void loadSlotSchemaMapsJacksonAnnotatedRecords() throws IOException { + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + """ + { + "required": ["severity", "service"], + "properties": { + "service": { + "type": "string", + "pattern": "^[a-z0-9-]+$", + "description": "Affected service name" + }, + "severity": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "enum": ["1", "2", "3", "4", "5"], + "x-a2at-value-constraint": "Severity from 1 to 5" + }, + "note": null + } + } + """); + + PromptSlotSchema schema = + new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir).loadSlotSchema("incident_triage", "en"); + + assertEquals("incident_triage", schema.scenarioCode()); + assertEquals(3, schema.slotDefinitions().size()); + assertEquals("service", schema.slotDefinitions().get(0).name()); + assertEquals(true, schema.slotDefinitions().get(0).required()); + assertEquals("^[a-z0-9-]+$", schema.slotDefinitions().get(0).pattern()); + assertEquals( + List.of("1", "2", "3", "4", "5"), + schema.slotDefinitions().get(1).allowedValues()); + assertEquals("Severity from 1 to 5", schema.slotDefinitions().get(1).valueConstraint()); + assertEquals("note", schema.slotDefinitions().get(2).name()); + } + + @Test + void missingTemplateIncludesResolvedLocalPathInResourceNotFoundException() { + ResourceNotFoundException exception = assertThrows( + ResourceNotFoundException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) + .loadTemplate("incident_triage", "en")); + + String expected = (promptRootDir.resolve("templates").toString() + + "/*/network-layer/incident_triage/v1/en/template.md" + + " (or the layout without the network-layer segment)") + .replace('\\', '/'); + assertEquals(expected, exception.resourcePath().replace('\\', '/')); + } + + @Test + void malformedLocalSlotSchemaIsWrappedAsA2ATError() throws IOException { + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + "{ \"required\": [\"severity\"], \"properties\": "); + + A2ATError exception = + assertThrows(A2ATError.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) + .loadSlotSchema("incident_triage", "en")); + + assertEquals("infra.resource_read_failed", exception.getCode()); + assertTrue(exception.getMessage().startsWith("Failed to read resource '")); + } + + @Test + void snapshotFreezesResourcesAgainstRuntimeFileChanges() throws IOException { + write( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + "Original template {{service}}."); + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + """ + { + "required": ["service"], + "properties": { + "service": {"type": "string", "description": "Affected service name"} + } + } + """); + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "incident_triage", + "scenario_name": "Incident Triage", + "description": "Classify and route a production incident.", + "example": "Investigate elevated API latency." + } + ] + } + """); + + PromptResourceAccess access = createAccess(); + + Files.writeString( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + "Mutated template."); + Files.writeString( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + "{\"properties\":{}}"); + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "mutated", + "scenario_name": "Mutated", + "description": "Mutated after snapshot.", + "example": "Mutated example." + } + ] + } + """); + + assertEquals("Original template {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); + assertEquals( + "service", + access.slotSchemaLoader() + .loadSlotSchema("incident_triage", "en") + .slotDefinitions() + .get(0) + .name()); + assertEquals("incident_triage", access.loadScenarios("en").get(0).scenarioCode()); + } + + @Test + void loadTemplateRejectsTraversalOrBlankScenarioPathSegments() { + assertThrows(IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) + .loadTemplate("../etc/passwd", "en")); + assertThrows(IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) + .loadTemplate("Task-T//network-layer/x", "en")); + assertThrows(IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) + .loadTemplate("incident_triage", "en/../admin")); + } + + @Test + void loadSlotSchemaRejectsNonSimpleLanguage() { + assertThrows( + IllegalArgumentException.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) + .loadSlotSchema("incident_triage", "../en")); + assertThrows( + IllegalArgumentException.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) + .loadSlotSchema("Task-T/network-layer/..", "en")); + } + + @Test + void loadScenarioCatalogRejectsNonSimpleLanguage() { + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en/../admin")); + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load(" ")); + } + + @Test + void localSnapshotKeepsPromptsOnClasspath() throws IOException { + write( + promptRootDir + .resolve("prompts") + .resolve("scenario_recognition") + .resolve("en-US") + .resolve("system.md"), + "Local prompt copy."); + + PromptResourceAccess access = createAccess(); + + assertFalse(access.classpath()); + assertNotEquals("Local prompt copy.", access.loadPrompt("scenario_recognition", "en-US", "system.md")); + } + + private PromptResourceAccess createAccess() { + return PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + } + + private Map snapshot() { + return LocalFileResourceSnapshot.capture(promptRootDir); + } + + private static void write(Path file, String content) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, content); + } +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java index b2526c62..5e424bb5 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java @@ -1,235 +1,235 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.slf4j.LoggerFactory; - -class PromptResourceAccessTest { - - @TempDir - Path promptRootDir; - - private final ListAppender appender = new ListAppender<>(); - - private final Logger logger = (Logger) LoggerFactory.getLogger(PromptResourceAccess.class); - - @BeforeEach - void attachAppender() { - appender.start(); - logger.addAppender(appender); - } - - @AfterEach - void detachAppender() { - logger.detachAppender(appender); - } - - @Test - void classpathModeIgnoresConfiguredLocalRootWithSingleWarning() { - PromptResourceAccess access = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", promptRootDir.toString())); - - assertTrue(access.classpath()); - List warnings = warningMessages(); - assertEquals(1, warnings.size()); - assertContains( - warnings.get(0), "prompt_resource_local_root_ignored", "root=" + promptRootDir, "source=classpath"); - } - - @Test - void localFileModeFailsFastWhenLocalRootIsNull() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", null))); - - assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); - } - - @Test - void localFileModeFailsFastWhenLocalRootIsBlank() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", " "))); - - assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); - } - - @Test - void localFileModeFailsFastWhenLocalRootDoesNotExist() { - Path missing = promptRootDir.resolve("missing"); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", missing.toString()))); - - assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); - } - - @Test - void localFileModeLoadsPromptsFromClasspath() throws IOException { - write( - promptRootDir - .resolve("prompts") - .resolve("scenario_recognition") - .resolve("en-US") - .resolve("system.md"), - "Local prompt copy."); - - PromptResourceAccess access = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - - assertFalse(access.classpath()); - String prompt = access.loadPrompt("scenario_recognition", "en-US", "system.md"); - assertTrue(prompt.startsWith("You are a scenario recognition agent.")); - assertFalse(prompt.equals("Local prompt copy.")); - } - - @Test - void localFileModeLoadsBusinessContentFromLocalRoot() throws IOException { - write( - promptRootDir - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("template.md"), - "Local template for {{service}}."); - write( - promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "incident_triage", - "scenario_name": "Incident Triage", - "description": "Classify and route a production incident.", - "example": "Investigate elevated API latency." - } - ] - } - """); - write( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - """ - { - "required": ["service"], - "properties": { - "service": {"type": "string", "description": "Affected service name"} - } - } - """); - - PromptResourceAccess access = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - - assertFalse(access.classpath()); - assertEquals(promptRootDir, access.localRootDir()); - assertEquals("Local template for {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); - List scenarios = access.loadScenarios("en"); - PromptSlotSchema slotSchema = access.slotSchemaLoader().loadSlotSchema("incident_triage", "en"); - assertEquals("incident_triage", scenarios.get(0).scenarioCode()); - assertEquals("service", slotSchema.slotDefinitions().get(0).name()); - assertEquals(true, slotSchema.slotDefinitions().get(0).required()); - } - - @Test - void localFileModeIgnoresUnsupportedLocalDirectoriesWithSingleWarning() throws IOException { - write( - promptRootDir - .resolve("prompts") - .resolve("scenario_recognition") - .resolve("en-US") - .resolve("system.md"), - "Local prompt copy."); - write( - promptRootDir - .resolve("templates") - .resolve("Negotiation-T") - .resolve("information-negotiation") - .resolve("propose") - .resolve("v1") - .resolve("en-US") - .resolve("template.md"), - "Local negotiation template copy."); - write(promptRootDir.resolve("negotiation-vocabulary").resolve("en-US").resolve("vocabulary.json"), "{}"); - - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - - List warnings = warningMessages(); - assertEquals(1, warnings.size()); - assertContains( - warnings.get(0), - "prompt_resource_local_directories_ignored", - "prompts", - "templates/Negotiation-T", - "negotiation-vocabulary", - "reason=classpath_fixed"); - } - - @Test - void localFileModePromptLoadUsesClasspathPathForMissingResource() { - PromptResourceAccess access = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - - ResourceNotFoundException exception = - assertThrows(ResourceNotFoundException.class, () -> access.loadPrompt("analysis", "en", "missing.md")); - - assertEquals("prompt_resources/prompts/analysis/en/missing.md", exception.resourcePath()); - } - - @Test - void unsupportedSourceTypeFailsFast() { - UnsupportedOperationException exception = assertThrows( - UnsupportedOperationException.class, - () -> PromptResourceAccess.create( - new PromptRuntimeConfig("en-US", "database", promptRootDir.toString()))); - - assertEquals("Unsupported prompt source type: database", exception.getMessage()); - } - - private List warningMessages() { - return appender.list.stream() - .filter(event -> event.getLevel() == Level.WARN) - .map(ILoggingEvent::getFormattedMessage) - .toList(); - } - - private static void assertContains(String message, String... fragments) { - for (String fragment : fragments) { - assertTrue(message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); - } - } - - private static void write(Path file, String content) throws IOException { - Files.createDirectories(file.getParent()); - Files.writeString(file, content); - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +class PromptResourceAccessTest { + + @TempDir + Path promptRootDir; + + private final ListAppender appender = new ListAppender<>(); + + private final Logger logger = (Logger) LoggerFactory.getLogger(PromptResourceAccess.class); + + @BeforeEach + void attachAppender() { + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + logger.detachAppender(appender); + } + + @Test + void classpathModeIgnoresConfiguredLocalRootWithSingleWarning() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", promptRootDir.toString())); + + assertTrue(access.classpath()); + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), "prompt_resource_local_root_ignored", "root=" + promptRootDir, "source=classpath"); + } + + @Test + void localFileModeFailsFastWhenLocalRootIsNull() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", null))); + + assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + } + + @Test + void localFileModeFailsFastWhenLocalRootIsBlank() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", " "))); + + assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + } + + @Test + void localFileModeFailsFastWhenLocalRootDoesNotExist() { + Path missing = promptRootDir.resolve("missing"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", missing.toString()))); + + assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + } + + @Test + void localFileModeLoadsPromptsFromClasspath() throws IOException { + write( + promptRootDir + .resolve("prompts") + .resolve("scenario_recognition") + .resolve("en-US") + .resolve("system.md"), + "Local prompt copy."); + + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + assertFalse(access.classpath()); + String prompt = access.loadPrompt("scenario_recognition", "en-US", "system.md"); + assertTrue(prompt.startsWith("You are a scenario recognition agent.")); + assertFalse(prompt.equals("Local prompt copy.")); + } + + @Test + void localFileModeLoadsBusinessContentFromLocalRoot() throws IOException { + write( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + "Local template for {{service}}."); + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "incident_triage", + "scenario_name": "Incident Triage", + "description": "Classify and route a production incident.", + "example": "Investigate elevated API latency." + } + ] + } + """); + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + """ + { + "required": ["service"], + "properties": { + "service": {"type": "string", "description": "Affected service name"} + } + } + """); + + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + assertFalse(access.classpath()); + assertEquals(promptRootDir, access.localRootDir()); + assertEquals("Local template for {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); + List scenarios = access.loadScenarios("en"); + PromptSlotSchema slotSchema = access.slotSchemaLoader().loadSlotSchema("incident_triage", "en"); + assertEquals("incident_triage", scenarios.get(0).scenarioCode()); + assertEquals("service", slotSchema.slotDefinitions().get(0).name()); + assertEquals(true, slotSchema.slotDefinitions().get(0).required()); + } + + @Test + void localFileModeIgnoresUnsupportedLocalDirectoriesWithSingleWarning() throws IOException { + write( + promptRootDir + .resolve("prompts") + .resolve("scenario_recognition") + .resolve("en-US") + .resolve("system.md"), + "Local prompt copy."); + write( + promptRootDir + .resolve("templates") + .resolve("Negotiation-T") + .resolve("information-negotiation") + .resolve("propose") + .resolve("v1") + .resolve("en-US") + .resolve("template.md"), + "Local negotiation template copy."); + write(promptRootDir.resolve("negotiation-vocabulary").resolve("en-US").resolve("vocabulary.json"), "{}"); + + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), + "prompt_resource_local_directories_ignored", + "prompts", + "templates/Negotiation-T", + "negotiation-vocabulary", + "reason=classpath_fixed"); + } + + @Test + void localFileModePromptLoadUsesClasspathPathForMissingResource() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + ResourceNotFoundException exception = + assertThrows(ResourceNotFoundException.class, () -> access.loadPrompt("analysis", "en", "missing.md")); + + assertEquals("prompt_resources/prompts/analysis/en/missing.md", exception.resourcePath()); + } + + @Test + void unsupportedSourceTypeFailsFast() { + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> PromptResourceAccess.create( + new PromptRuntimeConfig("en-US", "database", promptRootDir.toString()))); + + assertEquals("Unsupported prompt source type: database", exception.getMessage()); + } + + private List warningMessages() { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void assertContains(String message, String... fragments) { + for (String fragment : fragments) { + assertTrue(message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); + } + } + + private static void write(Path file, String content) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, content); + } +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java index fe550fc1..6eddd569 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java @@ -1,163 +1,163 @@ -package net.openan.a2at.sdk.prompt.validation; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -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 java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import org.junit.jupiter.api.Test; - -class DefaultContentValidatorTest { - - private static final TemplateUri TASK_URI = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); - - private static final PromptTemplateTextLoader STUB_LOADER = (scenarioCode, language) -> "dummy template content"; - - @Test - void validatesSuccessfullyOnFirstCall() { - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); - - FilledParamData result = - assertDoesNotThrow(() -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); - - assertNotNull(result); - assertEquals(Map.of("site", "Site A"), result.data()); - } - - @Test - void rejectsMismatchedExtensionName() { - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", - Map.of("type", "object"), - TemplateUri.of("Notification-T", "network-layer", "service-recovery"))); - - assertEquals("negotiation.invalid_input", exception.getCode()); - } - - @Test - void templateVersionIsUnsupported() { - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); - - TemplateUri unsupportedVersion = TemplateUri.of("Task-T", List.of("network-layer", "ran-energy-saving"), "v2"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate("task prompt", Map.of("type", "object"), unsupportedVersion)); - - assertEquals("negotiation.invalid_input", exception.getCode()); - } - - @Test - void templateUriIsNull() { - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate("task prompt", Map.of("type", "object"), null)); - - assertEquals("negotiation.invalid_input", exception.getCode()); - } - - @Test - void unsupportedLanguageFailsAtConstructionWithTemplateNotFound() { - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient(), STUB_LOADER)); - - assertEquals("template.not_found", exception.getCode()); - assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); - assertTrue(exception.getCause().getMessage().contains("zh-CN or en-US")); - } - - @Test - void constructionSucceedsWithEmptyLocalRootBecausePromptLoadsFromClasspath() { - PromptTemplateTextLoader emptyLocalRootLoader = (scenarioCode, language) -> { - throw new ResourceNotFoundException("No template in the local root for " + scenarioCode, scenarioCode); - }; - - assertDoesNotThrow( - () -> new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), emptyLocalRootLoader)); - } - - @Test - void retryableFailureAfterResourceLoadMapsLlmInvocationFailed() { - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 1, new FailingClient(), STUB_LOADER); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); - - assertEquals("llm.invocation_failed", exception.getCode()); - } - - @Test - void missingTemplateFailsWithTemplateNotFound() { - PromptTemplateTextLoader failingLoader = (scenarioCode, language) -> { - throw new ResourceNotFoundException("Template does not exist.", scenarioCode); - }; - DefaultContentValidator validator = - new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), failingLoader); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); - - assertEquals("template.not_found", exception.getCode()); - assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); - } - - private static final class StubClient implements LLMClient { - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse( - """ - { - "semantic_verdict": true, - "errors": [], - "params": {"site": "Site A"} - } - """, - "stub-llm", - Map.of(), - Map.of()); - } - } - - private static final class FailingClient implements LLMClient { - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - throw new LLMRuntimeError("network timeout"); - } - } -} +package net.openan.a2at.sdk.prompt.validation; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import org.junit.jupiter.api.Test; + +class DefaultContentValidatorTest { + + private static final TemplateUri TASK_URI = TemplateUri.of("Task-T", "network-layer", "ran-energy-saving"); + + private static final PromptTemplateTextLoader STUB_LOADER = (scenarioCode, language) -> "dummy template content"; + + @Test + void validatesSuccessfullyOnFirstCall() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); + + FilledParamData result = + assertDoesNotThrow(() -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + + assertNotNull(result); + assertEquals(Map.of("site", "Site A"), result.data()); + } + + @Test + void rejectsMismatchedExtensionName() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Notification-T", "network-layer", "service-recovery"))); + + assertEquals("negotiation.invalid_input", exception.getCode()); + } + + @Test + void templateVersionIsUnsupported() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); + + TemplateUri unsupportedVersion = TemplateUri.of("Task-T", List.of("network-layer", "ran-energy-saving"), "v2"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate("task prompt", Map.of("type", "object"), unsupportedVersion)); + + assertEquals("negotiation.invalid_input", exception.getCode()); + } + + @Test + void templateUriIsNull() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), STUB_LOADER); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate("task prompt", Map.of("type", "object"), null)); + + assertEquals("negotiation.invalid_input", exception.getCode()); + } + + @Test + void unsupportedLanguageFailsAtConstructionWithTemplateNotFound() { + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> new DefaultContentValidator("Task-T", "fr-FR", 3, new StubClient(), STUB_LOADER)); + + assertEquals("template.not_found", exception.getCode()); + assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); + assertTrue(exception.getCause().getMessage().contains("zh-CN or en-US")); + } + + @Test + void constructionSucceedsWithEmptyLocalRootBecausePromptLoadsFromClasspath() { + PromptTemplateTextLoader emptyLocalRootLoader = (scenarioCode, language) -> { + throw new ResourceNotFoundException("No template in the local root for " + scenarioCode, scenarioCode); + }; + + assertDoesNotThrow( + () -> new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), emptyLocalRootLoader)); + } + + @Test + void retryableFailureAfterResourceLoadMapsLlmInvocationFailed() { + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 1, new FailingClient(), STUB_LOADER); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + + assertEquals("llm.invocation_failed", exception.getCode()); + } + + @Test + void missingTemplateFailsWithTemplateNotFound() { + PromptTemplateTextLoader failingLoader = (scenarioCode, language) -> { + throw new ResourceNotFoundException("Template does not exist.", scenarioCode); + }; + DefaultContentValidator validator = + new DefaultContentValidator("Task-T", "zh-CN", 3, new StubClient(), failingLoader); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate("task prompt", Map.of("type", "object"), TASK_URI)); + + assertEquals("template.not_found", exception.getCode()); + assertInstanceOf(ResourceNotFoundException.class, exception.getCause()); + } + + private static final class StubClient implements LLMClient { + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse( + """ + { + "semantic_verdict": true, + "errors": [], + "params": {"site": "Site A"} + } + """, + "stub-llm", + Map.of(), + Map.of()); + } + } + + private static final class FailingClient implements LLMClient { + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new LLMRuntimeError("network timeout"); + } + } +} diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java index 7a7deb98..75462cb3 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java @@ -1,564 +1,564 @@ -package net.openan.a2at.sdk.prompt.validation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -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 java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.ValidationPipeline; -import net.openan.a2at.sdk.core.validation.ValidationResult; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import org.junit.jupiter.api.Test; - -class DefaultSemanticValidatorTest { - - private static final String VALID_RESPONSE = - """ - { - "semantic_verdict": true, - "errors": [], - "params": {"site": "Site A"} - } - """; - - private static final String TEMPLATE_CONTENT = "dummy template content"; - - @Test - void validatesEnUSPromptAndExtractsParams() { - RecordingClient llmClient = new RecordingClient(VALID_RESPONSE); - - DefaultSemanticValidator validator = new DefaultSemanticValidator(llmClient, "en-US"); - - ValidationResult result = validator.validate( - "Check Site A power usage.", - Map.of("type", "object"), - TemplateUri.of("Task-T", "ran-energy-saving"), - TEMPLATE_CONTENT); - - assertTrue(result.verdict()); - assertEquals(Map.of("site", "Site A"), result.params()); - assertTrue(llmClient.lastSystemContent().contains("validation")); - } - - @Test - void loadsZhCnResourcesWithoutException() { - DefaultSemanticValidator validator = new DefaultSemanticValidator(null, "zh-CN"); - - assertNotNull(validator); - } - - @Test - void throwsResourceNotFoundExceptionForMissingLanguage() { - ResourceNotFoundException exception = - assertThrows(ResourceNotFoundException.class, () -> new DefaultSemanticValidator(null, "fr-FR")); - - assertTrue(exception.resourcePath().contains("content_validation")); - assertTrue(exception.getMessage().contains("zh-CN or en-US")); - } - - @Test - void validateRetriesLlmInfrastructureErrorThroughPipeline() { - FlakyClient flakyClient = new FlakyClient(1, VALID_RESPONSE); - - DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US"); - - ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2, "en-US"); - - FilledParamData result = pipeline.validate( - "Check Site A power usage.", - Map.of("type", "object"), - TemplateUri.of("Task-T", "ran-energy-saving"), - TEMPLATE_CONTENT); - - assertEquals(Map.of("site", "Site A"), result.data()); - assertEquals(2, flakyClient.invocations()); - } - - @Test - void validateExhaustsRetriesAndWrapsLlmError() { - FlakyClient flakyClient = new FlakyClient( - Integer.MAX_VALUE, - """ - { - "semantic_verdict": true, - "errors": [], - "params": {} - } - """); - - DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US"); - - ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3, "en-US"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> pipeline.validate( - "Check Site A power usage.", - Map.of("type", "object"), - TemplateUri.of("Task-T", "ran-energy-saving"), - TEMPLATE_CONTENT)); - - assertEquals("llm.invocation_failed", exception.getCode()); - assertInstanceOf(LLMRuntimeError.class, exception.getCause()); - assertEquals(3, flakyClient.invocations()); - } - - @Test - void pipelineCarriesNullParamsIntoFilledParamData() { - // the semantic validator passed overall, but one schema slot is explicitly null: the merged - // FilledParamData must carry the key with a null value so callers scanning for blank slots can see the - // missing parameter and trigger negotiation for it - String llmJson = - "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; - DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); - - ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2, "en-US"); - - FilledParamData result = pipeline.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); - - assertEquals(2, result.data().size()); - assertEquals("端口A", result.data().get("任务对象")); - assertNull(result.data().get("任务上下文")); - } - - @Test - void validatePreservesNullParamValuesAsMissingSlots() { - String llmJson = - "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; - DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); - - ValidationResult result = validator.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); - - assertTrue(result.verdict()); - // a null param is the validator's signal that a schema slot is missing from the content; the key must stay - // present with a null value so downstream missing-slot detection (negotiation triggering) can see it - assertEquals(2, result.params().size()); - assertEquals("端口A", result.params().get("任务对象")); - assertTrue(result.params().containsKey("任务上下文")); - assertNull(result.params().get("任务上下文")); - } - - @Test - void missingVerdictKeyFailsWithInfrastructureError() { - DefaultSemanticValidator validator = - new DefaultSemanticValidator(new StubClient("{\"errors\": [], \"params\": {}}"), "en-US"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", - Map.of("type", "object"), - TemplateUri.of("Task-T", "network-layer"), - TEMPLATE_CONTENT)); - - assertEquals("llm.response_invalid", exception.getCode()); - assertTrue(exception.getCause().getMessage().contains("semantic_verdict")); - } - - @Test - void stringVerdictValueFailsWithInfrastructureError() { - DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}"), "en-US"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", - Map.of("type", "object"), - TemplateUri.of("Task-T", "network-layer"), - TEMPLATE_CONTENT)); - - assertEquals("llm.response_invalid", exception.getCode()); - assertTrue(exception.getCause().getMessage().contains("semantic_verdict")); - } - - @Test - void nonListErrorsFailsWithInfrastructureError() { - DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("{\"semantic_verdict\": true, \"errors\": {}, \"params\": {}}"), "en-US"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", - Map.of("type", "object"), - TemplateUri.of("Task-T", "network-layer"), - TEMPLATE_CONTENT)); - - assertEquals("llm.response_invalid", exception.getCode()); - assertTrue(exception.getCause().getMessage().contains("errors")); - } - - @Test - void malformedErrorElementFailsWithInfrastructureError() { - DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("{\"semantic_verdict\": false, \"errors\": [\"oops\"], \"params\": {}}"), "en-US"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", - Map.of("type", "object"), - TemplateUri.of("Task-T", "network-layer"), - TEMPLATE_CONTENT)); - - assertEquals("llm.response_invalid", exception.getCode()); - assertTrue(exception.getCause().getMessage().contains("errors")); - } - - @Test - void nonMapParamsFailsWithInfrastructureError() { - DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": []}"), "en-US"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", - Map.of("type", "object"), - TemplateUri.of("Task-T", "network-layer"), - TEMPLATE_CONTENT)); - - assertEquals("llm.response_invalid", exception.getCode()); - assertTrue(exception.getCause().getMessage().contains("params")); - } - - @Test - void stringParamKeysPassTheContract() { - DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": {\"site\": \"Site A\"}}"), - "en-US"); - - ValidationResult result = validator.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); - - assertTrue(result.verdict()); - } - - @Test - void nonStringParamKeyFailsWithInfrastructureError() { - // JSON object keys are strings by definition, so the negative path is driven through a parser - // stub that produces a non-string key - Map rawParams = new LinkedHashMap<>(); - rawParams.put(42, "Site A"); - Map parsed = new LinkedHashMap<>(); - parsed.put("semantic_verdict", Boolean.TRUE); - parsed.put("errors", List.of()); - parsed.put("params", rawParams); - DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("payload content is irrelevant"), "en-US", prompt -> parsed); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", - Map.of("type", "object"), - TemplateUri.of("Task-T", "network-layer"), - TEMPLATE_CONTENT)); - - assertEquals("llm.response_invalid", exception.getCode()); - assertTrue(exception.getCause().getMessage().contains("params keys")); - } - - @Test - void contractViolationRetriesAndRecoversThroughPipeline() { - FlakyPayloadClient client = new FlakyPayloadClient( - "{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", VALID_RESPONSE); - - DefaultSemanticValidator validator = new DefaultSemanticValidator(client, "en-US"); - ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2, "en-US"); - - FilledParamData result = pipeline.validate( - "Check Site A power usage.", - Map.of("type", "object"), - TemplateUri.of("Task-T", "ran-energy-saving"), - TEMPLATE_CONTENT); - - // first attempt violates the output contract (string verdict) → retryable infra error → second attempt - // returns a compliant response and the pipeline succeeds - assertEquals(Map.of("site", "Site A"), result.data()); - assertEquals(2, client.invocations()); - } - - @Test - void contractViolationExhaustsRetriesThroughPipeline() { - FlakyPayloadClient client = - new FlakyPayloadClient("{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", null); - - DefaultSemanticValidator validator = new DefaultSemanticValidator(client, "en-US"); - ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3, "en-US"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> pipeline.validate( - "Check Site A power usage.", - Map.of("type", "object"), - TemplateUri.of("Task-T", "ran-energy-saving"), - TEMPLATE_CONTENT)); - - assertEquals("llm.response_invalid", exception.getCode()); - assertEquals(3, client.invocations()); - } - - @Test - void parsesErrorsWithFactsAndRendersTheMessage() { - String llmJson = "{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"subscriptionCondition\"," - + " \"code\": \"content.param_missing\", \"facts\": {\"section_label\": \"订阅条件\"}}]," - + "\"params\": {}}"; - DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); - - ValidationResult result = validator.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); - - assertFalse(result.verdict()); - assertEquals(1, result.errors().size()); - SlotValidationError error = result.errors().get(0); - assertEquals("subscriptionCondition", error.slotName()); - assertEquals("content.param_missing", error.code()); - assertEquals("「订阅条件」未填写,请补充该参数的取值", error.message()); - assertEquals(Map.of("section_label", "订阅条件"), error.facts()); - } - - @Test - void rendersEntryFieldMissingMessageInEnglish() { - String llmJson = "{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"policyList\"," - + " \"code\": \"content.entry_field_missing\"," - + " \"facts\": {\"section_label\": \"Policy List\", \"index\": 2," - + " \"field_label\": \"Effective Date\"}}], \"params\": {}}"; - DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "en-US"); - - ValidationResult result = validator.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); - - assertEquals(1, result.errors().size()); - SlotValidationError error = result.errors().get(0); - assertEquals("content.entry_field_missing", error.code()); - assertEquals("Entry 2 of 'Policy List' is missing required field 'Effective Date'", error.message()); - } - - @Test - void unknownErrorCodeFallsBackToContentRuleViolation() { - String llmJson = "{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"policyList\"," - + " \"code\": \"data_problem\", \"facts\": {\"section_label\": \"Policy List\"}}]," - + "\"params\": {}}"; - DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "en-US"); - - ValidationResult result = validator.validate( - "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); - - assertEquals(1, result.errors().size()); - SlotValidationError error = result.errors().get(0); - assertEquals("content.rule_violation", error.code()); - assertEquals("The value of 'policyList' violates the validation rules.", error.message()); - assertEquals(Map.of("section_label", "policyList"), error.facts()); - } - - @Test - void missingLlmClientFailsWithLlmNotConfigured() { - DefaultSemanticValidator validator = new DefaultSemanticValidator(null, "zh-CN"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", - Map.of("type", "object"), - TemplateUri.of("Task-T", "network-layer"), - TEMPLATE_CONTENT)); - - assertEquals("llm.not_configured", exception.getCode()); - assertEquals("未配置 LLM 客户端,无法执行该操作(请检查 A2AT_LLM_* 配置)", exception.getMessage()); - } - - @Test - void malformedErrorFactsFailsWithResponseInvalid() { - DefaultSemanticValidator validator = new DefaultSemanticValidator( - new StubClient("{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"site\", \"code\":" - + " \"content.param_missing\"}], \"params\": {}}"), - "en-US"); - - ContentValidationException exception = assertThrows( - ContentValidationException.class, - () -> validator.validate( - "task prompt", - Map.of("type", "object"), - TemplateUri.of("Task-T", "network-layer"), - TEMPLATE_CONTENT)); - - assertEquals("llm.response_invalid", exception.getCode()); - assertTrue(exception.getCause().getMessage().contains("facts")); - } - - @Test - void templateContentWithPlaceholderLiteralIsNotSecondOrderReplaced() { - String templateContentWithPlaceholder = "Template body with [extension_name] and [input] literals."; - RecordingClient recordingClient = new RecordingClient(VALID_RESPONSE); - DefaultSemanticValidator validator = new DefaultSemanticValidator(recordingClient, "en-US"); - - validator.validate( - "Check Site A power usage.", - Map.of("type", "object"), - TemplateUri.of("Task-T", "network-layer"), - templateContentWithPlaceholder); - - String userContent = recordingClient.userContent(); - assertTrue(userContent.contains("Template body with [extension_name] and [input] literals.")); - assertTrue(userContent.contains("Task-T")); - assertTrue(userContent.contains("Check Site A power usage")); - } - - @Test - void templateContentPlaceholderIsFilled() { - RecordingClient recordingClient = new RecordingClient(VALID_RESPONSE); - DefaultSemanticValidator validator = new DefaultSemanticValidator(recordingClient, "en-US"); - - validator.validate( - "Check Site A power usage.", - Map.of("type", "object"), - TemplateUri.of("Task-T", "network-layer"), - "## Energy Saving Template\n\nCheck the energy saving region."); - - String userContent = recordingClient.userContent(); - assertTrue(userContent.contains("## Energy Saving Template")); - assertTrue(userContent.contains("energy saving region")); - } - - private static final class RecordingClient implements LLMClient { - - private final String payload; - - private List> lastMessages; - - private RecordingClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - this.lastMessages = messages; - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - - String lastSystemContent() { - for (Map message : lastMessages) { - if ("system".equals(message.get("role"))) { - return message.get("content"); - } - } - return ""; - } - - String userContent() { - for (Map message : lastMessages) { - if ("user".equals(message.get("role"))) { - return message.get("content"); - } - } - return ""; - } - } - - private static final class FlakyClient implements LLMClient { - - private final int failuresToSimulate; - - private final String successPayload; - - private final AtomicInteger counter = new AtomicInteger(0); - - private FlakyClient(int failuresToSimulate, String successPayload) { - this.failuresToSimulate = failuresToSimulate; - this.successPayload = successPayload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - int invocation = counter.incrementAndGet(); - if (invocation <= failuresToSimulate) { - throw new LLMRuntimeError("network timeout"); - } - return new LLMResponse( - successPayload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - - int invocations() { - return counter.get(); - } - } - - /** Stub LLM client returning a fixed content payload. */ - private static final class StubClient implements LLMClient { - - private final String content; - - private StubClient(String content) { - this.content = content; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse(content, "stub-llm", Map.of(), Map.of()); - } - } - - /** - * LLM client returning a malformed payload first, then a compliant one (or always malformed when success is null). - */ - private static final class FlakyPayloadClient implements LLMClient { - - private final String malformedPayload; - - private final String successPayload; - - private final AtomicInteger counter = new AtomicInteger(0); - - private FlakyPayloadClient(String malformedPayload, String successPayload) { - this.malformedPayload = malformedPayload; - this.successPayload = successPayload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - int invocation = counter.incrementAndGet(); - if (invocation == 1 || successPayload == null) { - return new LLMResponse(malformedPayload, "test-model", Map.of(), Map.of()); - } - return new LLMResponse(successPayload, "test-model", Map.of(), Map.of()); - } - - int invocations() { - return counter.get(); - } - } -} +package net.openan.a2at.sdk.prompt.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.ValidationPipeline; +import net.openan.a2at.sdk.core.validation.ValidationResult; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.junit.jupiter.api.Test; + +class DefaultSemanticValidatorTest { + + private static final String VALID_RESPONSE = + """ + { + "semantic_verdict": true, + "errors": [], + "params": {"site": "Site A"} + } + """; + + private static final String TEMPLATE_CONTENT = "dummy template content"; + + @Test + void validatesEnUSPromptAndExtractsParams() { + RecordingClient llmClient = new RecordingClient(VALID_RESPONSE); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(llmClient, "en-US"); + + ValidationResult result = validator.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT); + + assertTrue(result.verdict()); + assertEquals(Map.of("site", "Site A"), result.params()); + assertTrue(llmClient.lastSystemContent().contains("validation")); + } + + @Test + void loadsZhCnResourcesWithoutException() { + DefaultSemanticValidator validator = new DefaultSemanticValidator(null, "zh-CN"); + + assertNotNull(validator); + } + + @Test + void throwsResourceNotFoundExceptionForMissingLanguage() { + ResourceNotFoundException exception = + assertThrows(ResourceNotFoundException.class, () -> new DefaultSemanticValidator(null, "fr-FR")); + + assertTrue(exception.resourcePath().contains("content_validation")); + assertTrue(exception.getMessage().contains("zh-CN or en-US")); + } + + @Test + void validateRetriesLlmInfrastructureErrorThroughPipeline() { + FlakyClient flakyClient = new FlakyClient(1, VALID_RESPONSE); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US"); + + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2, "en-US"); + + FilledParamData result = pipeline.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT); + + assertEquals(Map.of("site", "Site A"), result.data()); + assertEquals(2, flakyClient.invocations()); + } + + @Test + void validateExhaustsRetriesAndWrapsLlmError() { + FlakyClient flakyClient = new FlakyClient( + Integer.MAX_VALUE, + """ + { + "semantic_verdict": true, + "errors": [], + "params": {} + } + """); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US"); + + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3, "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> pipeline.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT)); + + assertEquals("llm.invocation_failed", exception.getCode()); + assertInstanceOf(LLMRuntimeError.class, exception.getCause()); + assertEquals(3, flakyClient.invocations()); + } + + @Test + void pipelineCarriesNullParamsIntoFilledParamData() { + // the semantic validator passed overall, but one schema slot is explicitly null: the merged + // FilledParamData must carry the key with a null value so callers scanning for blank slots can see the + // missing parameter and trigger negotiation for it + String llmJson = + "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); + + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2, "en-US"); + + FilledParamData result = pipeline.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertEquals(2, result.data().size()); + assertEquals("端口A", result.data().get("任务对象")); + assertNull(result.data().get("任务上下文")); + } + + @Test + void validatePreservesNullParamValuesAsMissingSlots() { + String llmJson = + "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertTrue(result.verdict()); + // a null param is the validator's signal that a schema slot is missing from the content; the key must stay + // present with a null value so downstream missing-slot detection (negotiation triggering) can see it + assertEquals(2, result.params().size()); + assertEquals("端口A", result.params().get("任务对象")); + assertTrue(result.params().containsKey("任务上下文")); + assertNull(result.params().get("任务上下文")); + } + + @Test + void missingVerdictKeyFailsWithInfrastructureError() { + DefaultSemanticValidator validator = + new DefaultSemanticValidator(new StubClient("{\"errors\": [], \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("semantic_verdict")); + } + + @Test + void stringVerdictValueFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("semantic_verdict")); + } + + @Test + void nonListErrorsFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": true, \"errors\": {}, \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("errors")); + } + + @Test + void malformedErrorElementFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": false, \"errors\": [\"oops\"], \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("errors")); + } + + @Test + void nonMapParamsFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": []}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("params")); + } + + @Test + void stringParamKeysPassTheContract() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": {\"site\": \"Site A\"}}"), + "en-US"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertTrue(result.verdict()); + } + + @Test + void nonStringParamKeyFailsWithInfrastructureError() { + // JSON object keys are strings by definition, so the negative path is driven through a parser + // stub that produces a non-string key + Map rawParams = new LinkedHashMap<>(); + rawParams.put(42, "Site A"); + Map parsed = new LinkedHashMap<>(); + parsed.put("semantic_verdict", Boolean.TRUE); + parsed.put("errors", List.of()); + parsed.put("params", rawParams); + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("payload content is irrelevant"), "en-US", prompt -> parsed); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("params keys")); + } + + @Test + void contractViolationRetriesAndRecoversThroughPipeline() { + FlakyPayloadClient client = new FlakyPayloadClient( + "{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", VALID_RESPONSE); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(client, "en-US"); + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2, "en-US"); + + FilledParamData result = pipeline.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT); + + // first attempt violates the output contract (string verdict) → retryable infra error → second attempt + // returns a compliant response and the pipeline succeeds + assertEquals(Map.of("site", "Site A"), result.data()); + assertEquals(2, client.invocations()); + } + + @Test + void contractViolationExhaustsRetriesThroughPipeline() { + FlakyPayloadClient client = + new FlakyPayloadClient("{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", null); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(client, "en-US"); + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3, "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> pipeline.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertEquals(3, client.invocations()); + } + + @Test + void parsesErrorsWithFactsAndRendersTheMessage() { + String llmJson = "{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"subscriptionCondition\"," + + " \"code\": \"content.param_missing\", \"facts\": {\"section_label\": \"订阅条件\"}}]," + + "\"params\": {}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertFalse(result.verdict()); + assertEquals(1, result.errors().size()); + SlotValidationError error = result.errors().get(0); + assertEquals("subscriptionCondition", error.slotName()); + assertEquals("content.param_missing", error.code()); + assertEquals("「订阅条件」未填写,请补充该参数的取值", error.message()); + assertEquals(Map.of("section_label", "订阅条件"), error.facts()); + } + + @Test + void rendersEntryFieldMissingMessageInEnglish() { + String llmJson = "{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"policyList\"," + + " \"code\": \"content.entry_field_missing\"," + + " \"facts\": {\"section_label\": \"Policy List\", \"index\": 2," + + " \"field_label\": \"Effective Date\"}}], \"params\": {}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "en-US"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertEquals(1, result.errors().size()); + SlotValidationError error = result.errors().get(0); + assertEquals("content.entry_field_missing", error.code()); + assertEquals("Entry 2 of 'Policy List' is missing required field 'Effective Date'", error.message()); + } + + @Test + void unknownErrorCodeFallsBackToContentRuleViolation() { + String llmJson = "{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"policyList\"," + + " \"code\": \"data_problem\", \"facts\": {\"section_label\": \"Policy List\"}}]," + + "\"params\": {}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "en-US"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertEquals(1, result.errors().size()); + SlotValidationError error = result.errors().get(0); + assertEquals("content.rule_violation", error.code()); + assertEquals("The value of 'policyList' violates the validation rules.", error.message()); + assertEquals(Map.of("section_label", "policyList"), error.facts()); + } + + @Test + void missingLlmClientFailsWithLlmNotConfigured() { + DefaultSemanticValidator validator = new DefaultSemanticValidator(null, "zh-CN"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.not_configured", exception.getCode()); + assertEquals("未配置 LLM 客户端,无法执行该操作(请检查 A2AT_LLM_* 配置)", exception.getMessage()); + } + + @Test + void malformedErrorFactsFailsWithResponseInvalid() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"site\", \"code\":" + + " \"content.param_missing\"}], \"params\": {}}"), + "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("facts")); + } + + @Test + void templateContentWithPlaceholderLiteralIsNotSecondOrderReplaced() { + String templateContentWithPlaceholder = "Template body with [extension_name] and [input] literals."; + RecordingClient recordingClient = new RecordingClient(VALID_RESPONSE); + DefaultSemanticValidator validator = new DefaultSemanticValidator(recordingClient, "en-US"); + + validator.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + templateContentWithPlaceholder); + + String userContent = recordingClient.userContent(); + assertTrue(userContent.contains("Template body with [extension_name] and [input] literals.")); + assertTrue(userContent.contains("Task-T")); + assertTrue(userContent.contains("Check Site A power usage")); + } + + @Test + void templateContentPlaceholderIsFilled() { + RecordingClient recordingClient = new RecordingClient(VALID_RESPONSE); + DefaultSemanticValidator validator = new DefaultSemanticValidator(recordingClient, "en-US"); + + validator.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + "## Energy Saving Template\n\nCheck the energy saving region."); + + String userContent = recordingClient.userContent(); + assertTrue(userContent.contains("## Energy Saving Template")); + assertTrue(userContent.contains("energy saving region")); + } + + private static final class RecordingClient implements LLMClient { + + private final String payload; + + private List> lastMessages; + + private RecordingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + this.lastMessages = messages; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + + String lastSystemContent() { + for (Map message : lastMessages) { + if ("system".equals(message.get("role"))) { + return message.get("content"); + } + } + return ""; + } + + String userContent() { + for (Map message : lastMessages) { + if ("user".equals(message.get("role"))) { + return message.get("content"); + } + } + return ""; + } + } + + private static final class FlakyClient implements LLMClient { + + private final int failuresToSimulate; + + private final String successPayload; + + private final AtomicInteger counter = new AtomicInteger(0); + + private FlakyClient(int failuresToSimulate, String successPayload) { + this.failuresToSimulate = failuresToSimulate; + this.successPayload = successPayload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + int invocation = counter.incrementAndGet(); + if (invocation <= failuresToSimulate) { + throw new LLMRuntimeError("network timeout"); + } + return new LLMResponse( + successPayload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + + int invocations() { + return counter.get(); + } + } + + /** Stub LLM client returning a fixed content payload. */ + private static final class StubClient implements LLMClient { + + private final String content; + + private StubClient(String content) { + this.content = content; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(content, "stub-llm", Map.of(), Map.of()); + } + } + + /** + * LLM client returning a malformed payload first, then a compliant one (or always malformed when success is null). + */ + private static final class FlakyPayloadClient implements LLMClient { + + private final String malformedPayload; + + private final String successPayload; + + private final AtomicInteger counter = new AtomicInteger(0); + + private FlakyPayloadClient(String malformedPayload, String successPayload) { + this.malformedPayload = malformedPayload; + this.successPayload = successPayload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + int invocation = counter.incrementAndGet(); + if (invocation == 1 || successPayload == null) { + return new LLMResponse(malformedPayload, "test-model", Map.of(), Map.of()); + } + return new LLMResponse(successPayload, "test-model", Map.of(), Map.of()); + } + + int invocations() { + return counter.get(); + } + } +} diff --git a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java index f7a460d3..c00eaf3b 100644 --- a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java +++ b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java @@ -1,51 +1,51 @@ -package net.openan.a2at.sdk.resources; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.ConcurrentHashMap; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; -import org.jspecify.annotations.Nullable; - -/** - * Loads prompt resources from the classpath bundle packaged with the SDK. - * - *

            Classpath resources are frozen at assembly time: each resource path is resolved once and cached at the JVM level, - * so every subsequent load returns the cached copy. Missing resources throw on every call and are never cached. The - * loader is thread-safe. - * - * @since 2026-06 - */ -public final class ClasspathPromptResourceLoader { - - private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); - - /** - * Loads one UTF-8 text resource from the packaged prompt resource tree. - * - * @param key resource key to resolve - * @return loaded text payload - */ - public String loadText(PromptResourceKey key) { - String relativePath = key.relativePath(); - CacheKey cacheKey = new CacheKey(relativePath, Thread.currentThread().getContextClassLoader()); - return CACHE.computeIfAbsent(cacheKey, ignored -> read(relativePath)); - } - - private static String read(String relativePath) { - InputStream stream = ClasspathResourceStreams.open(relativePath); - if (stream == null) { - throw new ResourceNotFoundException("Prompt resource file does not exist.", relativePath); - } - - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException error) { - throw new A2ATError("Failed to read prompt resource: " + relativePath, error); - } - } - - private record CacheKey(String relativePath, @Nullable ClassLoader contextClassLoader) {} -} +package net.openan.a2at.sdk.resources; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import org.jspecify.annotations.Nullable; + +/** + * Loads prompt resources from the classpath bundle packaged with the SDK. + * + *

            Classpath resources are frozen at assembly time: each resource path is resolved once and cached at the JVM level, + * so every subsequent load returns the cached copy. Missing resources throw on every call and are never cached. The + * loader is thread-safe. + * + * @since 2026-06 + */ +public final class ClasspathPromptResourceLoader { + + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + /** + * Loads one UTF-8 text resource from the packaged prompt resource tree. + * + * @param key resource key to resolve + * @return loaded text payload + */ + public String loadText(PromptResourceKey key) { + String relativePath = key.relativePath(); + CacheKey cacheKey = new CacheKey(relativePath, Thread.currentThread().getContextClassLoader()); + return CACHE.computeIfAbsent(cacheKey, ignored -> read(relativePath)); + } + + private static String read(String relativePath) { + InputStream stream = ClasspathResourceStreams.open(relativePath); + if (stream == null) { + throw new ResourceNotFoundException("Prompt resource file does not exist.", relativePath); + } + + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException error) { + throw new A2ATError("Failed to read prompt resource: " + relativePath, error); + } + } + + private record CacheKey(String relativePath, @Nullable ClassLoader contextClassLoader) {} +} diff --git a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java index 5809ec68..f8ca33f6 100644 --- a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java +++ b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java @@ -1,103 +1,103 @@ -package net.openan.a2at.sdk.resources; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.TemplateUri; -import org.junit.jupiter.api.Test; - -class ClasspathPromptResourceLoaderTest { - - private final ClasspathPromptResourceLoader loader = new ClasspathPromptResourceLoader(); - - @Test - void loadsTextResourceUsingPromptResourceKey() { - PromptResourceKey key = PromptResourceKey.prompt("slot_extraction", "en-US", "system.md"); - - String text = loader.loadText(key); - - assertEquals("system prompt", text.trim()); - } - - @Test - void rejectsResourceTraversalSegments() { - assertThrows( - IllegalArgumentException.class, - () -> new PromptResourceKey("prompts", List.of("../escape"), "en-US", "system.md")); - } - - @Test - void raisesTypedErrorWhenResourceIsMissing() { - PromptResourceKey key = PromptResourceKey.template( - TemplateUri.of("Task-T", "network-layer", "missing_scenario"), "en-US", "template.md"); - - ResourceNotFoundException error = assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); - - assertEquals( - "prompt_resources/templates/Task-T/network-layer/missing_scenario/v1/en-US/template.md", - error.resourcePath().replace('\\', '/')); - } - - @Test - void loadsPackagedScenarioCatalogForZhCn() { - String text = loader.loadText(PromptResourceKey.scenario("zh-CN", "scenarios.json")); - - assertTrue(text.contains("subscribe-incident")); - assertTrue(text.contains("ran-energy-saving")); - } - - @Test - void loadsPackagedSubscribeIncidentSlotSchemaWithSemanticHint() { - PromptResourceKey key = PromptResourceKey.slotSchema( - TemplateUri.of("Notification-T", "network-layer", "subscribe-incident"), "zh-CN", "slot.json"); - - String text = loader.loadText(key); - - assertTrue(text.contains("\"required\": []")); - assertTrue(text.contains("x-a2at-value-constraint")); - assertTrue(text.contains("critical")); - assertTrue(text.contains("high")); - assertTrue(text.contains("medium")); - assertTrue(text.contains("low")); - } - - @Test - void servesTheCachedValueAfterTheClasspathResourceIsAlteredOnDisk() throws Exception { - PromptResourceKey key = PromptResourceKey.prompt("slot_extraction", "en-US", "system.md"); - String first = loader.loadText(key); - - Path resourceFile = testResourceFile("prompt_resources/prompts/slot_extraction/en-US/system.md"); - String original = Files.readString(resourceFile, StandardCharsets.UTF_8); - Files.writeString(resourceFile, "changed on disk", StandardCharsets.UTF_8); - try { - assertEquals(first, loader.loadText(key)); - } finally { - Files.writeString(resourceFile, original, StandardCharsets.UTF_8); - } - } - - @Test - void doesNotCacheMissingResourcesAndKeepsThrowing() { - PromptResourceKey key = PromptResourceKey.template( - TemplateUri.of("Task-T", "network-layer", "missing_scenario"), "en-US", "template.md"); - - assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); - assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); - } - - private static Path testResourceFile(String classpathPath) throws Exception { - URL resource = ClasspathPromptResourceLoaderTest.class.getClassLoader().getResource(classpathPath); - if (resource == null || !"file".equals(resource.getProtocol())) { - throw new IllegalStateException( - "test resource not reachable as a file URL: " + classpathPath + " -> " + resource); - } - return Path.of(resource.toURI()); - } -} +package net.openan.a2at.sdk.resources; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.junit.jupiter.api.Test; + +class ClasspathPromptResourceLoaderTest { + + private final ClasspathPromptResourceLoader loader = new ClasspathPromptResourceLoader(); + + @Test + void loadsTextResourceUsingPromptResourceKey() { + PromptResourceKey key = PromptResourceKey.prompt("slot_extraction", "en-US", "system.md"); + + String text = loader.loadText(key); + + assertEquals("system prompt", text.trim()); + } + + @Test + void rejectsResourceTraversalSegments() { + assertThrows( + IllegalArgumentException.class, + () -> new PromptResourceKey("prompts", List.of("../escape"), "en-US", "system.md")); + } + + @Test + void raisesTypedErrorWhenResourceIsMissing() { + PromptResourceKey key = PromptResourceKey.template( + TemplateUri.of("Task-T", "network-layer", "missing_scenario"), "en-US", "template.md"); + + ResourceNotFoundException error = assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); + + assertEquals( + "prompt_resources/templates/Task-T/network-layer/missing_scenario/v1/en-US/template.md", + error.resourcePath().replace('\\', '/')); + } + + @Test + void loadsPackagedScenarioCatalogForZhCn() { + String text = loader.loadText(PromptResourceKey.scenario("zh-CN", "scenarios.json")); + + assertTrue(text.contains("subscribe-incident")); + assertTrue(text.contains("ran-energy-saving")); + } + + @Test + void loadsPackagedSubscribeIncidentSlotSchemaWithSemanticHint() { + PromptResourceKey key = PromptResourceKey.slotSchema( + TemplateUri.of("Notification-T", "network-layer", "subscribe-incident"), "zh-CN", "slot.json"); + + String text = loader.loadText(key); + + assertTrue(text.contains("\"required\": []")); + assertTrue(text.contains("x-a2at-value-constraint")); + assertTrue(text.contains("critical")); + assertTrue(text.contains("high")); + assertTrue(text.contains("medium")); + assertTrue(text.contains("low")); + } + + @Test + void servesTheCachedValueAfterTheClasspathResourceIsAlteredOnDisk() throws Exception { + PromptResourceKey key = PromptResourceKey.prompt("slot_extraction", "en-US", "system.md"); + String first = loader.loadText(key); + + Path resourceFile = testResourceFile("prompt_resources/prompts/slot_extraction/en-US/system.md"); + String original = Files.readString(resourceFile, StandardCharsets.UTF_8); + Files.writeString(resourceFile, "changed on disk", StandardCharsets.UTF_8); + try { + assertEquals(first, loader.loadText(key)); + } finally { + Files.writeString(resourceFile, original, StandardCharsets.UTF_8); + } + } + + @Test + void doesNotCacheMissingResourcesAndKeepsThrowing() { + PromptResourceKey key = PromptResourceKey.template( + TemplateUri.of("Task-T", "network-layer", "missing_scenario"), "en-US", "template.md"); + + assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); + assertThrows(ResourceNotFoundException.class, () -> loader.loadText(key)); + } + + private static Path testResourceFile(String classpathPath) throws Exception { + URL resource = ClasspathPromptResourceLoaderTest.class.getClassLoader().getResource(classpathPath); + if (resource == null || !"file".equals(resource.getProtocol())) { + throw new IllegalStateException( + "test resource not reachable as a file URL: " + classpathPath + " -> " + resource); + } + return Path.of(resource.toURI()); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java index d17da791..2c2b9425 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java @@ -1,461 +1,461 @@ -package net.openan.a2at.sample.authz_policy; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import net.openan.a2at.sample.authz_policy.AuthzReasoningCapture.StageCapture; -import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; -import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; - -/** - * Entry point for the Authorization-T demo. - * - *

            Loads scenarios from the resource configured via {@code authz.scenarios} (default: - * {@code sample/authz-policy/scenarios.json}), generates authorization prompts through {@link A2ATClient}, validates - * them through {@link A2ATServer} against the business-level parameter schema - * ({@code sample/authz-policy/param-schema.json}), and writes a JSON report with staged client/server expectations, - * actual behaviour and per-assertion match details. - * - * @since 2026-08 - */ -public final class AuthzSampleMain { - - private static final String DEFAULT_ENV_FILE = "authz.env"; - - private static final String BUNDLED_ENV_FILE = Path.of( - "a2a-t-sample", "src", "main", "resources", "sample", "authz-policy", "authz.env") - .toString(); - - private static final String SCENARIOS_RESOURCE = - System.getProperty("authz.scenarios", "sample/authz-policy/scenarios.json"); - - private static final String PARAM_SCHEMA_RESOURCE = "sample/authz-policy/param-schema.json"; - - private static final String DEFAULT_OUTPUT_DIR = "eval-results/authz-demo"; - - private static final String OUTDIR_PROPERTY = "authz.outdir"; - - private static final DateTimeFormatter REPORT_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); - - private static final List REQUIRED_LLM_KEYS = - List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY"); - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private AuthzSampleMain() {} - - public static void main(String[] args) { - Path envPath = resolveEnvPath(args); - if (!hasRequiredLlmKeys(envPath)) { - System.err.println("Required LLM keys not configured in env file: " + envPath); - System.exit(1); - } - - int workers; - try { - workers = AuthzScenarioExecutor.resolveWorkers(); - } catch (IllegalArgumentException e) { - System.err.println("Usage: -Dauthz.workers= (integer >= 1, default 8). " + e.getMessage()); - System.exit(1); - return; - } - - boolean reasoning; - try { - reasoning = AuthzReasoningCapture.resolveReasoningFlag(); - } catch (IllegalArgumentException e) { - System.err.println("Usage: -Dauthz.reasoning=true|false (default false). " + e.getMessage()); - System.exit(1); - return; - } - - AuthzReasoningCapture capture = reasoning ? new AuthzReasoningCapture() : null; - - Map paramSchema = loadParamSchema(); - - List scenarios = AuthzScenarioLoader.load(SCENARIOS_RESOURCE); - String templateUri = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; - - AuthzPromptGenerator generator = buildGenerator(envPath, templateUri, capture); - AuthzPromptValidator validator = buildValidator(envPath, capture); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - long startMillis = System.currentTimeMillis(); - - List outcomes = executor.executeAll( - scenarios, - paramSchema, - templateUri, - workers, - (completedIndex, outcome, elapsedSeconds) -> { - String matchStr = outcome.result().match() ? "PASS" : "FAIL"; - System.out.printf( - "[%d/%d] %s %s (%s, %.1fs)%n", - completedIndex, - scenarios.size(), - matchStr, - scenarios.get(completedIndex - 1).label(), - outcome.result().outcome(), - elapsedSeconds); - }, - capture); - - double wallTimeSeconds = (System.currentTimeMillis() - startMillis) / 1000.0; - - for (int i = 0; i < scenarios.size(); i++) { - printScenarioReport(scenarios.get(i), outcomes.get(i)); - } - - Path outDir = Path.of(System.getProperty(OUTDIR_PROPERTY, DEFAULT_OUTPUT_DIR)); - Path reportPath = writeReport( - scenarios, outcomes, outDir, workers, wallTimeSeconds, SCENARIOS_RESOURCE, reasoning, capture); - System.out.println("Report written to: " + reportPath.toAbsolutePath().normalize()); - System.out.println(); - - List results = - outcomes.stream().map(ScenarioOutcome::result).toList(); - printSummary(scenarios, results); - System.exit(exitCode(results)); - } - - static Path resolveEnvPath(String[] args) { - if (args.length > 0) { - return Path.of(args[0]); - } - Path cwdEnv = Path.of(DEFAULT_ENV_FILE); - if (Files.exists(cwdEnv) && hasRequiredLlmKeys(cwdEnv)) { - return cwdEnv; - } - return Path.of(BUNDLED_ENV_FILE); - } - - static boolean hasRequiredLlmKeys(Path envFile) { - Map entries = new HashMap<>(); - try { - for (String line : Files.readAllLines(envFile)) { - String trimmed = line.trim(); - if (trimmed.isEmpty() || trimmed.startsWith("#")) { - continue; - } - int separator = trimmed.indexOf('='); - if (separator <= 0) { - continue; - } - entries.put( - trimmed.substring(0, separator).trim(), - trimmed.substring(separator + 1).trim()); - } - } catch (IOException exception) { - return false; - } - for (String key : REQUIRED_LLM_KEYS) { - if (entries.getOrDefault(key, "").isBlank()) { - return false; - } - } - return true; - } - - static Map loadParamSchema() { - try (InputStream stream = AuthzSampleMain.class.getClassLoader().getResourceAsStream(PARAM_SCHEMA_RESOURCE)) { - if (stream == null) { - throw new IllegalStateException("Param schema resource not found: " + PARAM_SCHEMA_RESOURCE); - } - return MAPPER.readValue(stream, new TypeReference>() {}); - } catch (IOException e) { - throw new IllegalStateException("Failed to load param schema: " + PARAM_SCHEMA_RESOURCE, e); - } - } - - static AuthzPromptGenerator buildGenerator(Path envPath, String templateUri, AuthzReasoningCapture capture) { - if (capture == null) { - A2ATClient client = new A2ATClient(envPath); - return scenario -> { - if (AuthzScenario.FROM_TEXT.equals(scenario.entry())) { - String text = (String) scenario.input().get("text"); - return client.generateAuthPromptFromText(text, templateUri); - } - @SuppressWarnings("unchecked") - Map data = - (Map) scenario.input().get("data"); - @SuppressWarnings("unchecked") - Map schema = - (Map) scenario.input().get("schema"); - return client.generateAuthPromptFromDataWithSchema(data, schema, templateUri); - }; - } - A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); - LLMClient real = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); - LLMClient decorated = capture.wrap(real); - DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder() - .envPath(envPath) - .config(config) - .llmClient(decorated); - ClientPromptGenerationOrchestrator orchestrator = builder.buildPromptGenerationOrchestrator(); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return scenario -> { - if (AuthzScenario.FROM_TEXT.equals(scenario.entry())) { - String text = (String) scenario.input().get("text"); - return orchestrator.generateAuthPromptFromText(text, parsedTemplateUri); - } - @SuppressWarnings("unchecked") - Map data = (Map) scenario.input().get("data"); - @SuppressWarnings("unchecked") - Map schema = (Map) scenario.input().get("schema"); - return orchestrator.generateAuthPromptFromDataWithSchema(data, schema, parsedTemplateUri); - }; - } - - static AuthzPromptValidator buildValidator(Path envPath, AuthzReasoningCapture capture) { - if (capture == null) { - A2ATServer server = new A2ATServer(envPath); - return server::validateAuthPromptAndDataFilling; - } - A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); - LLMClient real = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); - LLMClient decorated = capture.wrap(real); - DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder() - .envPath(envPath) - .config(config) - .llmClient(decorated); - ContentValidator contentValidator = builder.buildAuthContentValidator(); - return (prompt, schema, templateUri) -> - contentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); - } - - /** Parses a template URI string for the typed orchestrator/content-validator seams. */ - private static TemplateUri parseTemplateUri(String templateUri) { - return TemplateUri.parse(templateUri) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); - } - - static void printScenarioReport(AuthzScenario scenario, ScenarioOutcome outcome) { - System.out.println("--- Scenario: " + scenario.label() + " ---"); - System.out.println(" Entry: " + scenario.entry()); - System.out.println("[Client]"); - AuthzExpected expected = scenario.expected(); - if (outcome.metadata() == null) { - System.out.println(" 生成结果: " + outcome.result().outcome()); - System.out.println(" 错误明细: " + outcome.result().slotErrors()); - } else { - System.out.println(" 生成结果: generated"); - System.out.println(" Prompt:"); - System.out.println(indent(outcome.metadata().promptText(), " ")); - } - System.out.println("[Server]"); - if (outcome.metadata() == null) { - System.out.println(" (跳过 - 客户端生成失败)"); - } else if (outcome.filled() == null) { - System.out.println(" 校验结果: " + outcome.result().outcome()); - System.out.println(" 校验明细: " + outcome.result().slotErrors()); - } else { - System.out.println(" 校验结果: success"); - System.out.println(" 校验明细: []"); - System.out.println(" 提参结果: " + outcome.filled().data()); - } - System.out.println("[判定] " + verdictLine(outcome.result())); - System.out.println(); - } - - static String verdictLine(ScenarioResult result) { - StringBuilder sb = new StringBuilder("match=" + result.match()); - if (result.clientPromptMatch() != null) { - sb.append(" (client: prompt=").append(result.clientPromptMatch() ? "OK" : "DRIFT"); - if (result.serverOutcomeMatch() != null) { - sb.append(" | server: outcome=").append(result.serverOutcomeMatch() ? "OK" : "FAIL"); - sb.append(" slot_errors=").append(result.serverOutcomeMatch() ? "OK" : "FAIL"); - } - if (result.serverParamsMatch() != null) { - sb.append(" params=").append(result.serverParamsMatch() ? "OK" : "FAIL"); - } - sb.append(")"); - } else { - sb.append(" (client: outcome=") - .append(result.match() ? "OK" : "FAIL") - .append(")"); - } - return sb.toString(); - } - - static String indent(String text, String prefix) { - return text.lines() - .map(line -> prefix + line) - .reduce((a, b) -> a + "\n" + b) - .orElse(""); - } - - static void printSummary(List scenarios, List results) { - long matchCount = results.stream().filter(ScenarioResult::match).count(); - long mismatchCount = results.size() - matchCount; - System.out.println("========== Summary =========="); - System.out.println(" Total: " + results.size() + ", Match: " + matchCount + ", Mismatch: " + mismatchCount); - System.out.println(" Exit Code: " + exitCode(results)); - System.out.println(); - } - - static int exitCode(List results) { - boolean allMatch = results.stream().allMatch(ScenarioResult::match); - return allMatch ? 0 : 1; - } - - static Path writeReport( - List scenarios, - List outcomes, - Path reportDir, - int workers, - double wallTimeSeconds, - String scenariosResource, - boolean reasoning, - AuthzReasoningCapture capture) { - Map report = new LinkedHashMap<>(); - report.put( - "meta", - Map.of( - "workers", workers, - "wallTimeSeconds", wallTimeSeconds, - "scenariosResource", scenariosResource, - "reasoning", reasoning)); - long matchCount = outcomes.stream().filter(o -> o.result().match()).count(); - long mismatchCount = outcomes.size() - matchCount; - report.put( - "summary", - Map.of( - "total", scenarios.size(), - "match", matchCount, - "mismatch", mismatchCount)); - List> scenarioEntries = new ArrayList<>(); - for (int i = 0; i < scenarios.size(); i++) { - AuthzScenario scenario = scenarios.get(i); - ScenarioOutcome outcome = outcomes.get(i); - Map entry = new LinkedHashMap<>(); - entry.put("label", scenario.label()); - entry.put("entry", scenario.entry()); - entry.put("expected_client", formatClientExpectation(scenario)); - entry.put("expected_server", formatServerExpectation(scenario)); - entry.put("actual_outcome", outcome.result().outcome()); - entry.put("match", outcome.result().match()); - entry.put( - "actual_slot_errors", - formatActualSlotErrors(outcome.result().slotErrors())); - entry.put( - "prompt_text", - outcome.metadata() != null ? outcome.metadata().promptText() : null); - entry.put( - "actual_params", outcome.filled() != null ? outcome.filled().data() : null); - Map assertions = new LinkedHashMap<>(); - assertions.put("client_prompt", outcome.result().clientPromptMatch()); - assertions.put("server_outcome", outcome.result().serverOutcomeMatch()); - assertions.put("server_params", outcome.result().serverParamsMatch()); - entry.put("assertions", assertions); - entry.put( - "error", - outcome.result().error() != null - ? Map.of( - "code", outcome.result().error().getCode(), - "message", outcome.result().error().getMessage()) - : null); - entry.put("warnings", outcome.result().warnings()); - if (reasoning && capture != null) { - Optional captured = capture.capture(scenario.label()); - if (captured.isPresent()) { - StageCapture sc = captured.get(); - if (sc.clientReasoning() != null) { - entry.put("client_reasoning", sc.clientReasoning()); - } - if (sc.serverReasoning() != null) { - entry.put("server_reasoning", sc.serverReasoning()); - } - } - } - scenarioEntries.add(entry); - } - report.put("scenarios", scenarioEntries); - try { - Files.createDirectories(reportDir); - String timestamp = LocalDateTime.now().format(REPORT_TIMESTAMP); - Path reportFile = reportDir.resolve("authz-report-" + timestamp + ".json"); - MAPPER.writerWithDefaultPrettyPrinter().writeValue(reportFile.toFile(), report); - return reportFile; - } catch (IOException e) { - throw new IllegalStateException("Failed to write report to: " + reportDir, e); - } - } - - private static Map formatClientExpectation(AuthzScenario scenario) { - Map m = new LinkedHashMap<>(); - var client = scenario.expected().client(); - m.put("outcome", client.outcome()); - m.put("prompt_text", client.promptText()); - m.put("slot_errors", formatExpectedSlotErrors(client.slotErrors())); - return m; - } - - private static Map formatServerExpectation(AuthzScenario scenario) { - var server = scenario.expected().server(); - if (server == null) { - return null; - } - Map m = new LinkedHashMap<>(); - m.put("outcome", server.outcome()); - m.put("slot_errors", formatExpectedSlotErrors(server.slotErrors())); - m.put("params", server.params()); - return m; - } - - private static List> formatExpectedSlotErrors(List slotErrors) { - if (slotErrors == null || slotErrors.isEmpty()) { - return List.of(); - } - return slotErrors.stream() - .map(e -> { - Map m = new LinkedHashMap<>(); - m.put("slot_name", e.slotName()); - m.put("code", e.code()); - return m; - }) - .toList(); - } - - private static List> formatActualSlotErrors(List slotErrors) { - if (slotErrors == null || slotErrors.isEmpty()) { - return List.of(); - } - return slotErrors.stream() - .map(e -> { - Map m = new LinkedHashMap<>(); - m.put("slot_name", e.slotName()); - m.put("code", e.code()); - m.put("message", e.message()); - m.put("facts", e.facts()); - return m; - }) - .toList(); - } -} +package net.openan.a2at.sample.authz_policy; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.openan.a2at.sample.authz_policy.AuthzReasoningCapture.StageCapture; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; + +/** + * Entry point for the Authorization-T demo. + * + *

            Loads scenarios from the resource configured via {@code authz.scenarios} (default: + * {@code sample/authz-policy/scenarios.json}), generates authorization prompts through {@link A2ATClient}, validates + * them through {@link A2ATServer} against the business-level parameter schema + * ({@code sample/authz-policy/param-schema.json}), and writes a JSON report with staged client/server expectations, + * actual behaviour and per-assertion match details. + * + * @since 2026-08 + */ +public final class AuthzSampleMain { + + private static final String DEFAULT_ENV_FILE = "authz.env"; + + private static final String BUNDLED_ENV_FILE = Path.of( + "a2a-t-sample", "src", "main", "resources", "sample", "authz-policy", "authz.env") + .toString(); + + private static final String SCENARIOS_RESOURCE = + System.getProperty("authz.scenarios", "sample/authz-policy/scenarios.json"); + + private static final String PARAM_SCHEMA_RESOURCE = "sample/authz-policy/param-schema.json"; + + private static final String DEFAULT_OUTPUT_DIR = "eval-results/authz-demo"; + + private static final String OUTDIR_PROPERTY = "authz.outdir"; + + private static final DateTimeFormatter REPORT_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + private static final List REQUIRED_LLM_KEYS = + List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY"); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private AuthzSampleMain() {} + + public static void main(String[] args) { + Path envPath = resolveEnvPath(args); + if (!hasRequiredLlmKeys(envPath)) { + System.err.println("Required LLM keys not configured in env file: " + envPath); + System.exit(1); + } + + int workers; + try { + workers = AuthzScenarioExecutor.resolveWorkers(); + } catch (IllegalArgumentException e) { + System.err.println("Usage: -Dauthz.workers= (integer >= 1, default 8). " + e.getMessage()); + System.exit(1); + return; + } + + boolean reasoning; + try { + reasoning = AuthzReasoningCapture.resolveReasoningFlag(); + } catch (IllegalArgumentException e) { + System.err.println("Usage: -Dauthz.reasoning=true|false (default false). " + e.getMessage()); + System.exit(1); + return; + } + + AuthzReasoningCapture capture = reasoning ? new AuthzReasoningCapture() : null; + + Map paramSchema = loadParamSchema(); + + List scenarios = AuthzScenarioLoader.load(SCENARIOS_RESOURCE); + String templateUri = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; + + AuthzPromptGenerator generator = buildGenerator(envPath, templateUri, capture); + AuthzPromptValidator validator = buildValidator(envPath, capture); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + long startMillis = System.currentTimeMillis(); + + List outcomes = executor.executeAll( + scenarios, + paramSchema, + templateUri, + workers, + (completedIndex, outcome, elapsedSeconds) -> { + String matchStr = outcome.result().match() ? "PASS" : "FAIL"; + System.out.printf( + "[%d/%d] %s %s (%s, %.1fs)%n", + completedIndex, + scenarios.size(), + matchStr, + scenarios.get(completedIndex - 1).label(), + outcome.result().outcome(), + elapsedSeconds); + }, + capture); + + double wallTimeSeconds = (System.currentTimeMillis() - startMillis) / 1000.0; + + for (int i = 0; i < scenarios.size(); i++) { + printScenarioReport(scenarios.get(i), outcomes.get(i)); + } + + Path outDir = Path.of(System.getProperty(OUTDIR_PROPERTY, DEFAULT_OUTPUT_DIR)); + Path reportPath = writeReport( + scenarios, outcomes, outDir, workers, wallTimeSeconds, SCENARIOS_RESOURCE, reasoning, capture); + System.out.println("Report written to: " + reportPath.toAbsolutePath().normalize()); + System.out.println(); + + List results = + outcomes.stream().map(ScenarioOutcome::result).toList(); + printSummary(scenarios, results); + System.exit(exitCode(results)); + } + + static Path resolveEnvPath(String[] args) { + if (args.length > 0) { + return Path.of(args[0]); + } + Path cwdEnv = Path.of(DEFAULT_ENV_FILE); + if (Files.exists(cwdEnv) && hasRequiredLlmKeys(cwdEnv)) { + return cwdEnv; + } + return Path.of(BUNDLED_ENV_FILE); + } + + static boolean hasRequiredLlmKeys(Path envFile) { + Map entries = new HashMap<>(); + try { + for (String line : Files.readAllLines(envFile)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int separator = trimmed.indexOf('='); + if (separator <= 0) { + continue; + } + entries.put( + trimmed.substring(0, separator).trim(), + trimmed.substring(separator + 1).trim()); + } + } catch (IOException exception) { + return false; + } + for (String key : REQUIRED_LLM_KEYS) { + if (entries.getOrDefault(key, "").isBlank()) { + return false; + } + } + return true; + } + + static Map loadParamSchema() { + try (InputStream stream = AuthzSampleMain.class.getClassLoader().getResourceAsStream(PARAM_SCHEMA_RESOURCE)) { + if (stream == null) { + throw new IllegalStateException("Param schema resource not found: " + PARAM_SCHEMA_RESOURCE); + } + return MAPPER.readValue(stream, new TypeReference>() {}); + } catch (IOException e) { + throw new IllegalStateException("Failed to load param schema: " + PARAM_SCHEMA_RESOURCE, e); + } + } + + static AuthzPromptGenerator buildGenerator(Path envPath, String templateUri, AuthzReasoningCapture capture) { + if (capture == null) { + A2ATClient client = new A2ATClient(envPath); + return scenario -> { + if (AuthzScenario.FROM_TEXT.equals(scenario.entry())) { + String text = (String) scenario.input().get("text"); + return client.generateAuthPromptFromText(text, templateUri); + } + @SuppressWarnings("unchecked") + Map data = + (Map) scenario.input().get("data"); + @SuppressWarnings("unchecked") + Map schema = + (Map) scenario.input().get("schema"); + return client.generateAuthPromptFromDataWithSchema(data, schema, templateUri); + }; + } + A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); + LLMClient real = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); + LLMClient decorated = capture.wrap(real); + DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder() + .envPath(envPath) + .config(config) + .llmClient(decorated); + ClientPromptGenerationOrchestrator orchestrator = builder.buildPromptGenerationOrchestrator(); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return scenario -> { + if (AuthzScenario.FROM_TEXT.equals(scenario.entry())) { + String text = (String) scenario.input().get("text"); + return orchestrator.generateAuthPromptFromText(text, parsedTemplateUri); + } + @SuppressWarnings("unchecked") + Map data = (Map) scenario.input().get("data"); + @SuppressWarnings("unchecked") + Map schema = (Map) scenario.input().get("schema"); + return orchestrator.generateAuthPromptFromDataWithSchema(data, schema, parsedTemplateUri); + }; + } + + static AuthzPromptValidator buildValidator(Path envPath, AuthzReasoningCapture capture) { + if (capture == null) { + A2ATServer server = new A2ATServer(envPath); + return server::validateAuthPromptAndDataFilling; + } + A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); + LLMClient real = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); + LLMClient decorated = capture.wrap(real); + DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder() + .envPath(envPath) + .config(config) + .llmClient(decorated); + ContentValidator contentValidator = builder.buildAuthContentValidator(); + return (prompt, schema, templateUri) -> + contentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + } + + /** Parses a template URI string for the typed orchestrator/content-validator seams. */ + private static TemplateUri parseTemplateUri(String templateUri) { + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); + } + + static void printScenarioReport(AuthzScenario scenario, ScenarioOutcome outcome) { + System.out.println("--- Scenario: " + scenario.label() + " ---"); + System.out.println(" Entry: " + scenario.entry()); + System.out.println("[Client]"); + AuthzExpected expected = scenario.expected(); + if (outcome.metadata() == null) { + System.out.println(" 生成结果: " + outcome.result().outcome()); + System.out.println(" 错误明细: " + outcome.result().slotErrors()); + } else { + System.out.println(" 生成结果: generated"); + System.out.println(" Prompt:"); + System.out.println(indent(outcome.metadata().promptText(), " ")); + } + System.out.println("[Server]"); + if (outcome.metadata() == null) { + System.out.println(" (跳过 - 客户端生成失败)"); + } else if (outcome.filled() == null) { + System.out.println(" 校验结果: " + outcome.result().outcome()); + System.out.println(" 校验明细: " + outcome.result().slotErrors()); + } else { + System.out.println(" 校验结果: success"); + System.out.println(" 校验明细: []"); + System.out.println(" 提参结果: " + outcome.filled().data()); + } + System.out.println("[判定] " + verdictLine(outcome.result())); + System.out.println(); + } + + static String verdictLine(ScenarioResult result) { + StringBuilder sb = new StringBuilder("match=" + result.match()); + if (result.clientPromptMatch() != null) { + sb.append(" (client: prompt=").append(result.clientPromptMatch() ? "OK" : "DRIFT"); + if (result.serverOutcomeMatch() != null) { + sb.append(" | server: outcome=").append(result.serverOutcomeMatch() ? "OK" : "FAIL"); + sb.append(" slot_errors=").append(result.serverOutcomeMatch() ? "OK" : "FAIL"); + } + if (result.serverParamsMatch() != null) { + sb.append(" params=").append(result.serverParamsMatch() ? "OK" : "FAIL"); + } + sb.append(")"); + } else { + sb.append(" (client: outcome=") + .append(result.match() ? "OK" : "FAIL") + .append(")"); + } + return sb.toString(); + } + + static String indent(String text, String prefix) { + return text.lines() + .map(line -> prefix + line) + .reduce((a, b) -> a + "\n" + b) + .orElse(""); + } + + static void printSummary(List scenarios, List results) { + long matchCount = results.stream().filter(ScenarioResult::match).count(); + long mismatchCount = results.size() - matchCount; + System.out.println("========== Summary =========="); + System.out.println(" Total: " + results.size() + ", Match: " + matchCount + ", Mismatch: " + mismatchCount); + System.out.println(" Exit Code: " + exitCode(results)); + System.out.println(); + } + + static int exitCode(List results) { + boolean allMatch = results.stream().allMatch(ScenarioResult::match); + return allMatch ? 0 : 1; + } + + static Path writeReport( + List scenarios, + List outcomes, + Path reportDir, + int workers, + double wallTimeSeconds, + String scenariosResource, + boolean reasoning, + AuthzReasoningCapture capture) { + Map report = new LinkedHashMap<>(); + report.put( + "meta", + Map.of( + "workers", workers, + "wallTimeSeconds", wallTimeSeconds, + "scenariosResource", scenariosResource, + "reasoning", reasoning)); + long matchCount = outcomes.stream().filter(o -> o.result().match()).count(); + long mismatchCount = outcomes.size() - matchCount; + report.put( + "summary", + Map.of( + "total", scenarios.size(), + "match", matchCount, + "mismatch", mismatchCount)); + List> scenarioEntries = new ArrayList<>(); + for (int i = 0; i < scenarios.size(); i++) { + AuthzScenario scenario = scenarios.get(i); + ScenarioOutcome outcome = outcomes.get(i); + Map entry = new LinkedHashMap<>(); + entry.put("label", scenario.label()); + entry.put("entry", scenario.entry()); + entry.put("expected_client", formatClientExpectation(scenario)); + entry.put("expected_server", formatServerExpectation(scenario)); + entry.put("actual_outcome", outcome.result().outcome()); + entry.put("match", outcome.result().match()); + entry.put( + "actual_slot_errors", + formatActualSlotErrors(outcome.result().slotErrors())); + entry.put( + "prompt_text", + outcome.metadata() != null ? outcome.metadata().promptText() : null); + entry.put( + "actual_params", outcome.filled() != null ? outcome.filled().data() : null); + Map assertions = new LinkedHashMap<>(); + assertions.put("client_prompt", outcome.result().clientPromptMatch()); + assertions.put("server_outcome", outcome.result().serverOutcomeMatch()); + assertions.put("server_params", outcome.result().serverParamsMatch()); + entry.put("assertions", assertions); + entry.put( + "error", + outcome.result().error() != null + ? Map.of( + "code", outcome.result().error().getCode(), + "message", outcome.result().error().getMessage()) + : null); + entry.put("warnings", outcome.result().warnings()); + if (reasoning && capture != null) { + Optional captured = capture.capture(scenario.label()); + if (captured.isPresent()) { + StageCapture sc = captured.get(); + if (sc.clientReasoning() != null) { + entry.put("client_reasoning", sc.clientReasoning()); + } + if (sc.serverReasoning() != null) { + entry.put("server_reasoning", sc.serverReasoning()); + } + } + } + scenarioEntries.add(entry); + } + report.put("scenarios", scenarioEntries); + try { + Files.createDirectories(reportDir); + String timestamp = LocalDateTime.now().format(REPORT_TIMESTAMP); + Path reportFile = reportDir.resolve("authz-report-" + timestamp + ".json"); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(reportFile.toFile(), report); + return reportFile; + } catch (IOException e) { + throw new IllegalStateException("Failed to write report to: " + reportDir, e); + } + } + + private static Map formatClientExpectation(AuthzScenario scenario) { + Map m = new LinkedHashMap<>(); + var client = scenario.expected().client(); + m.put("outcome", client.outcome()); + m.put("prompt_text", client.promptText()); + m.put("slot_errors", formatExpectedSlotErrors(client.slotErrors())); + return m; + } + + private static Map formatServerExpectation(AuthzScenario scenario) { + var server = scenario.expected().server(); + if (server == null) { + return null; + } + Map m = new LinkedHashMap<>(); + m.put("outcome", server.outcome()); + m.put("slot_errors", formatExpectedSlotErrors(server.slotErrors())); + m.put("params", server.params()); + return m; + } + + private static List> formatExpectedSlotErrors(List slotErrors) { + if (slotErrors == null || slotErrors.isEmpty()) { + return List.of(); + } + return slotErrors.stream() + .map(e -> { + Map m = new LinkedHashMap<>(); + m.put("slot_name", e.slotName()); + m.put("code", e.code()); + return m; + }) + .toList(); + } + + private static List> formatActualSlotErrors(List slotErrors) { + if (slotErrors == null || slotErrors.isEmpty()) { + return List.of(); + } + return slotErrors.stream() + .map(e -> { + Map m = new LinkedHashMap<>(); + m.put("slot_name", e.slotName()); + m.put("code", e.code()); + m.put("message", e.message()); + m.put("facts", e.facts()); + return m; + }) + .toList(); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenario.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenario.java index 05224e83..85b33c35 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenario.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenario.java @@ -1,162 +1,162 @@ -package net.openan.a2at.sample.authz_policy; - -import java.util.List; -import java.util.Map; -import java.util.Set; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; - -/** - * Immutable configuration item for a single demo scenario. - * - *

            The expected outcome is staged: {@link ClientExpected} asserts the client SDK behaviour (generation failure with - * slot errors, or the exact rendered prompt text), {@link ServerExpected} asserts the server SDK behaviour (validation - * outcome, slot-level error details and the extracted parameters). - * - * @param label human-readable scenario label - * @param entry client entry point ({@code from_text} or {@code from_data_with_schema}) - * @param input scenario input data - * @param expected staged expected outcome - * @param validateSchema optional scenario-level validation schema that overrides the suite-level default parameter - * schema; {@code null} when the suite-level default applies - * @since 2026-08 - */ -public record AuthzScenario( - String label, - String entry, - Map input, - AuthzExpected expected, - Map validateSchema) { - - /** - * Convenience constructor for scenarios without a scenario-level validation schema. - * - * @param label human-readable scenario label - * @param entry client entry point ({@code from_text} or {@code from_data_with_schema}) - * @param input scenario input data - * @param expected staged expected outcome - */ - public AuthzScenario(String label, String entry, Map input, AuthzExpected expected) { - this(label, entry, input, expected, null); - } - - static final String FROM_TEXT = "from_text"; - static final String FROM_DATA_WITH_SCHEMA = "from_data_with_schema"; - static final String EXPECTED_SUCCESS = "success"; - - static final Set VALID_SERVER_OUTCOMES = Set.of( - EXPECTED_SUCCESS, - ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), - ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), - ErrorCatalog.TEMPLATE_NOT_FOUND.getCode()); - - static final Set VALID_CLIENT_OUTCOMES = Set.of( - ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), - ErrorCatalog.SLOT_CONSTRAINT_VIOLATED.getCode(), - ErrorCatalog.SLOT_RULE_VIOLATION.getCode(), - ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), - ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), - ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), - ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), - ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode(), - ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode()); - - /** - * Staged expected outcome of one scenario. - * - * @param client client-stage expectation; a non-null client outcome declares an expected generation failure (in - * which case {@code server} must be {@code null}), a null client outcome declares an expected successful - * generation whose {@code promptText} is asserted - * @param server server-stage expectation; {@code null} when the client stage is expected to fail - */ - public record AuthzExpected(ClientExpected client, ServerExpected server) {} - - /** - * Client-stage expectation. - * - * @param outcome expected client failure code; {@code null} means generation is expected to succeed - * @param promptText expected rendered prompt text; asserted with trailing-whitespace-insensitive equality when - * {@code outcome} is {@code null} - * @param slotErrors expected per-slot error details of the failed generation; may be {@code null} or empty when - * only the outcome code matters - */ - public record ClientExpected(String outcome, String promptText, List slotErrors) {} - - /** - * Server-stage expectation. - * - * @param outcome expected server outcome code ({@code success} or {@code negotiation.semantic_rejected}) - * @param slotErrors expected per-slot error details; may be {@code null} or empty when only the outcome code - * matters - * @param params expected extracted parameters; asserted as a subset match (recursive over maps and ordered lists) - * and only meaningful when {@code outcome} is {@code success} - */ - public record ServerExpected(String outcome, List slotErrors, Map params) {} - - /** - * Expected error detail for a single slot. - * - * @param slotName name of the slot expected to fail - * @param code machine-readable error code expected for this slot - */ - public record SlotErrorExpectation(String slotName, String code) {} - - /** - * Validates that the scenario configuration is well-formed. - * - * @param scenario the scenario to validate - * @throws IllegalArgumentException if the configuration is invalid - */ - public static void validate(AuthzScenario scenario) { - if (scenario == null) { - throw new IllegalArgumentException("scenario must not be null"); - } - if (scenario.input == null) { - throw new IllegalArgumentException("input must not be null"); - } - if (scenario.validateSchema != null && scenario.validateSchema.isEmpty()) { - throw new IllegalArgumentException("validateSchema must be non-empty when set"); - } - if (scenario.expected == null || scenario.expected.client() == null) { - throw new IllegalArgumentException("expected.client must not be null"); - } - ClientExpected client = scenario.expected.client(); - ServerExpected server = scenario.expected.server(); - if (client.outcome() != null) { - if (!VALID_CLIENT_OUTCOMES.contains(client.outcome())) { - throw new IllegalArgumentException("Invalid expected client outcome: " + client.outcome()); - } - if (server != null) { - throw new IllegalArgumentException("expected.server must be null when expected.client.outcome is set"); - } - } else { - if (client.promptText() == null || client.promptText().isBlank()) { - throw new IllegalArgumentException( - "expected.client.promptText must be non-blank when client outcome is not set"); - } - if (server == null || server.outcome() == null || !VALID_SERVER_OUTCOMES.contains(server.outcome())) { - throw new IllegalArgumentException( - "expected.server.outcome must be one of " + VALID_SERVER_OUTCOMES + " when client succeeds"); - } - // server.params on a non-success outcome is documentation-only (not asserted), mirroring the probe. - } - if (!FROM_TEXT.equals(scenario.entry) && !FROM_DATA_WITH_SCHEMA.equals(scenario.entry)) { - throw new IllegalArgumentException("Invalid entry: " + scenario.entry); - } - if (FROM_TEXT.equals(scenario.entry)) { - Object text = scenario.input.get("text"); - if (!(text instanceof String) || ((String) text).isBlank()) { - throw new IllegalArgumentException("from_text scenario requires a non-blank input.text"); - } - } - if (FROM_DATA_WITH_SCHEMA.equals(scenario.entry)) { - Object data = scenario.input.get("data"); - if (!(data instanceof Map) || ((Map) data).isEmpty()) { - throw new IllegalArgumentException("from_data_with_schema scenario requires a non-empty input.data"); - } - Object schema = scenario.input.get("schema"); - if (!(schema instanceof Map) || ((Map) schema).isEmpty()) { - throw new IllegalArgumentException("from_data_with_schema scenario requires a non-empty input.schema"); - } - } - } -} +package net.openan.a2at.sample.authz_policy; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; + +/** + * Immutable configuration item for a single demo scenario. + * + *

            The expected outcome is staged: {@link ClientExpected} asserts the client SDK behaviour (generation failure with + * slot errors, or the exact rendered prompt text), {@link ServerExpected} asserts the server SDK behaviour (validation + * outcome, slot-level error details and the extracted parameters). + * + * @param label human-readable scenario label + * @param entry client entry point ({@code from_text} or {@code from_data_with_schema}) + * @param input scenario input data + * @param expected staged expected outcome + * @param validateSchema optional scenario-level validation schema that overrides the suite-level default parameter + * schema; {@code null} when the suite-level default applies + * @since 2026-08 + */ +public record AuthzScenario( + String label, + String entry, + Map input, + AuthzExpected expected, + Map validateSchema) { + + /** + * Convenience constructor for scenarios without a scenario-level validation schema. + * + * @param label human-readable scenario label + * @param entry client entry point ({@code from_text} or {@code from_data_with_schema}) + * @param input scenario input data + * @param expected staged expected outcome + */ + public AuthzScenario(String label, String entry, Map input, AuthzExpected expected) { + this(label, entry, input, expected, null); + } + + static final String FROM_TEXT = "from_text"; + static final String FROM_DATA_WITH_SCHEMA = "from_data_with_schema"; + static final String EXPECTED_SUCCESS = "success"; + + static final Set VALID_SERVER_OUTCOMES = Set.of( + EXPECTED_SUCCESS, + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode()); + + static final Set VALID_CLIENT_OUTCOMES = Set.of( + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + ErrorCatalog.SLOT_CONSTRAINT_VIOLATED.getCode(), + ErrorCatalog.SLOT_RULE_VIOLATION.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), + ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode(), + ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode()); + + /** + * Staged expected outcome of one scenario. + * + * @param client client-stage expectation; a non-null client outcome declares an expected generation failure (in + * which case {@code server} must be {@code null}), a null client outcome declares an expected successful + * generation whose {@code promptText} is asserted + * @param server server-stage expectation; {@code null} when the client stage is expected to fail + */ + public record AuthzExpected(ClientExpected client, ServerExpected server) {} + + /** + * Client-stage expectation. + * + * @param outcome expected client failure code; {@code null} means generation is expected to succeed + * @param promptText expected rendered prompt text; asserted with trailing-whitespace-insensitive equality when + * {@code outcome} is {@code null} + * @param slotErrors expected per-slot error details of the failed generation; may be {@code null} or empty when + * only the outcome code matters + */ + public record ClientExpected(String outcome, String promptText, List slotErrors) {} + + /** + * Server-stage expectation. + * + * @param outcome expected server outcome code ({@code success} or {@code negotiation.semantic_rejected}) + * @param slotErrors expected per-slot error details; may be {@code null} or empty when only the outcome code + * matters + * @param params expected extracted parameters; asserted as a subset match (recursive over maps and ordered lists) + * and only meaningful when {@code outcome} is {@code success} + */ + public record ServerExpected(String outcome, List slotErrors, Map params) {} + + /** + * Expected error detail for a single slot. + * + * @param slotName name of the slot expected to fail + * @param code machine-readable error code expected for this slot + */ + public record SlotErrorExpectation(String slotName, String code) {} + + /** + * Validates that the scenario configuration is well-formed. + * + * @param scenario the scenario to validate + * @throws IllegalArgumentException if the configuration is invalid + */ + public static void validate(AuthzScenario scenario) { + if (scenario == null) { + throw new IllegalArgumentException("scenario must not be null"); + } + if (scenario.input == null) { + throw new IllegalArgumentException("input must not be null"); + } + if (scenario.validateSchema != null && scenario.validateSchema.isEmpty()) { + throw new IllegalArgumentException("validateSchema must be non-empty when set"); + } + if (scenario.expected == null || scenario.expected.client() == null) { + throw new IllegalArgumentException("expected.client must not be null"); + } + ClientExpected client = scenario.expected.client(); + ServerExpected server = scenario.expected.server(); + if (client.outcome() != null) { + if (!VALID_CLIENT_OUTCOMES.contains(client.outcome())) { + throw new IllegalArgumentException("Invalid expected client outcome: " + client.outcome()); + } + if (server != null) { + throw new IllegalArgumentException("expected.server must be null when expected.client.outcome is set"); + } + } else { + if (client.promptText() == null || client.promptText().isBlank()) { + throw new IllegalArgumentException( + "expected.client.promptText must be non-blank when client outcome is not set"); + } + if (server == null || server.outcome() == null || !VALID_SERVER_OUTCOMES.contains(server.outcome())) { + throw new IllegalArgumentException( + "expected.server.outcome must be one of " + VALID_SERVER_OUTCOMES + " when client succeeds"); + } + // server.params on a non-success outcome is documentation-only (not asserted), mirroring the probe. + } + if (!FROM_TEXT.equals(scenario.entry) && !FROM_DATA_WITH_SCHEMA.equals(scenario.entry)) { + throw new IllegalArgumentException("Invalid entry: " + scenario.entry); + } + if (FROM_TEXT.equals(scenario.entry)) { + Object text = scenario.input.get("text"); + if (!(text instanceof String) || ((String) text).isBlank()) { + throw new IllegalArgumentException("from_text scenario requires a non-blank input.text"); + } + } + if (FROM_DATA_WITH_SCHEMA.equals(scenario.entry)) { + Object data = scenario.input.get("data"); + if (!(data instanceof Map) || ((Map) data).isEmpty()) { + throw new IllegalArgumentException("from_data_with_schema scenario requires a non-empty input.data"); + } + Object schema = scenario.input.get("schema"); + if (!(schema instanceof Map) || ((Map) schema).isEmpty()) { + throw new IllegalArgumentException("from_data_with_schema scenario requires a non-empty input.schema"); + } + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java index 58f40fbc..c7e516ac 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java @@ -1,212 +1,212 @@ -package net.openan.a2at.sample.authz_policy; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.SlotValidationError; - -/** - * Concurrent scenario executor that runs multiple {@link AuthzScenario} instances in parallel using a fixed thread - * pool, while preserving input order in the returned result list. - * - *

            Each task runs the complete client→server flow for a single scenario. Internal failures are caught per-task and - * converted to {@code infra.internal_error} outcomes, isolating the remaining scenarios from a single failure. - * - *

            An optional {@link ProgressListener} receives per-completion callbacks (synchronized, index 1-based) and can be - * {@code null} when no progress reporting is needed. An optional {@link AuthzReasoningCapture} records LLM reasoning - * for each scenario when enabled. - * - * @since 2026-08 - */ -public final class AuthzScenarioExecutor { - - private static final int DEFAULT_WORKERS = 8; - - private final AuthzScenarioRunner runner; - - /** @param runner scenario runner (stateless, safe for concurrent reuse) */ - public AuthzScenarioExecutor(AuthzScenarioRunner runner) { - this.runner = runner; - } - - /** - * Progress listener invoked after each scenario completes. - * - *

            Calls are serialized with a lock so the listener implementation does not need to be thread-safe. The callback - * runs on the worker thread and must return quickly. - * - * @param completedIndex 1-based completion order (not input index) - * @param outcome the scenario outcome - * @param elapsedSeconds wall-clock time for this scenario in seconds, non-negative - * @since 2026-08 - */ - @FunctionalInterface - public interface ProgressListener { - void onCompleted(int completedIndex, ScenarioOutcome outcome, double elapsedSeconds); - } - - /** - * Executes all scenarios concurrently and returns outcomes in the same order as the input list. - * - * @param scenarios scenarios to execute - * @param paramSchema business-level parameter schema for validation - * @param templateUri template URI for prompt generation - * @param workers maximum number of concurrent worker threads - * @param onCompleted optional progress callback; may be {@code null} - * @return outcomes in input order - * @throws IllegalArgumentException if {@code workers < 1} - * @throws IllegalStateException if the calling thread is interrupted while waiting - * @throws java.util.concurrent.RejectedExecutionException if a task cannot be accepted - * @since 2026-08 - */ - public List executeAll( - List scenarios, - Map paramSchema, - String templateUri, - int workers, - ProgressListener onCompleted) { - return executeAll(scenarios, paramSchema, templateUri, workers, onCompleted, null); - } - - /** - * Executes all scenarios concurrently with optional reasoning capture. - * - * @param scenarios scenarios to execute - * @param paramSchema business-level parameter schema for validation - * @param templateUri template URI for prompt generation - * @param workers maximum number of concurrent worker threads - * @param onCompleted optional progress callback; may be {@code null} - * @param capture optional reasoning capture; may be {@code null} - * @return outcomes in input order - * @throws IllegalArgumentException if {@code workers < 1} - * @throws IllegalStateException if the calling thread is interrupted while waiting - * @throws java.util.concurrent.RejectedExecutionException if a task cannot be accepted - * @since 2026-08 - */ - public List executeAll( - List scenarios, - Map paramSchema, - String templateUri, - int workers, - ProgressListener onCompleted, - AuthzReasoningCapture capture) { - int size = scenarios.size(); - if (size == 0) { - return List.of(); - } - int poolSize = Math.min(workers, size); - AtomicInteger nameCounter = new AtomicInteger(0); - ExecutorService pool = Executors.newFixedThreadPool(poolSize, r -> { - Thread t = new Thread(r, "authz-worker-" + nameCounter.incrementAndGet()); - t.setDaemon(false); - return t; - }); - AtomicInteger completedCounter = new AtomicInteger(0); - Object callbackLock = new Object(); - List> futures = new ArrayList<>(size); - for (int i = 0; i < size; i++) { - final int index = i; - futures.add(pool.submit(() -> { - String label = scenarios.get(index).label(); - if (capture != null) { - capture.beginScenario(label); - } - long startNanos = System.nanoTime(); - ScenarioOutcome outcome; - try { - outcome = runner.run(scenarios.get(index), paramSchema, templateUri); - } catch (Throwable t) { - outcome = buildInternalErrorOutcome(scenarios.get(index), t); - } finally { - if (capture != null) { - capture.endScenario(); - } - } - double elapsed = (System.nanoTime() - startNanos) / 1e9; - if (onCompleted != null) { - synchronized (callbackLock) { - onCompleted.onCompleted(completedCounter.incrementAndGet(), outcome, elapsed); - } - } - return outcome; - })); - } - - List outcomes = new ArrayList<>(size); - try { - for (int i = 0; i < size; i++) { - outcomes.add(futures.get(i).get()); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - pool.shutdownNow(); - throw new IllegalStateException("Scenario execution interrupted", e); - } catch (ExecutionException e) { - pool.shutdownNow(); - throw new IllegalStateException("Unexpected execution exception", e); - } finally { - pool.shutdown(); - try { - if (!pool.awaitTermination(10, TimeUnit.SECONDS)) { - pool.shutdownNow(); - } - } catch (InterruptedException e) { - pool.shutdownNow(); - Thread.currentThread().interrupt(); - } - } - return outcomes; - } - - /** - * Resolves the worker count from the {@code authz.workers} system property. - * - * @return worker count, defaulting to {@value DEFAULT_WORKERS} - * @throws IllegalArgumentException if the property is set to a non-integer or value < 1 - * @since 2026-08 - */ - public static int resolveWorkers() { - String raw = System.getProperty("authz.workers"); - if (raw == null || raw.isBlank()) { - return DEFAULT_WORKERS; - } - int workers; - try { - workers = Integer.parseInt(raw.trim()); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Invalid authz.workers value: '" + raw - + "'. Expected an integer >= 1 (default " + DEFAULT_WORKERS + ")."); - } - if (workers < 1) { - throw new IllegalArgumentException("Invalid authz.workers value: " + workers - + ". Expected an integer >= 1 (default " + DEFAULT_WORKERS + ")."); - } - return workers; - } - - private ScenarioOutcome buildInternalErrorOutcome(AuthzScenario scenario, Throwable t) { - return new ScenarioOutcome( - new ScenarioResult( - ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), - false, - new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), t.getMessage(), t), - List.of(new SlotValidationError( - "_llm", ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), t.getMessage())), - null, - null, - null, - List.of()), - null, - null); - } -} +package net.openan.a2at.sample.authz_policy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.SlotValidationError; + +/** + * Concurrent scenario executor that runs multiple {@link AuthzScenario} instances in parallel using a fixed thread + * pool, while preserving input order in the returned result list. + * + *

            Each task runs the complete client→server flow for a single scenario. Internal failures are caught per-task and + * converted to {@code infra.internal_error} outcomes, isolating the remaining scenarios from a single failure. + * + *

            An optional {@link ProgressListener} receives per-completion callbacks (synchronized, index 1-based) and can be + * {@code null} when no progress reporting is needed. An optional {@link AuthzReasoningCapture} records LLM reasoning + * for each scenario when enabled. + * + * @since 2026-08 + */ +public final class AuthzScenarioExecutor { + + private static final int DEFAULT_WORKERS = 8; + + private final AuthzScenarioRunner runner; + + /** @param runner scenario runner (stateless, safe for concurrent reuse) */ + public AuthzScenarioExecutor(AuthzScenarioRunner runner) { + this.runner = runner; + } + + /** + * Progress listener invoked after each scenario completes. + * + *

            Calls are serialized with a lock so the listener implementation does not need to be thread-safe. The callback + * runs on the worker thread and must return quickly. + * + * @param completedIndex 1-based completion order (not input index) + * @param outcome the scenario outcome + * @param elapsedSeconds wall-clock time for this scenario in seconds, non-negative + * @since 2026-08 + */ + @FunctionalInterface + public interface ProgressListener { + void onCompleted(int completedIndex, ScenarioOutcome outcome, double elapsedSeconds); + } + + /** + * Executes all scenarios concurrently and returns outcomes in the same order as the input list. + * + * @param scenarios scenarios to execute + * @param paramSchema business-level parameter schema for validation + * @param templateUri template URI for prompt generation + * @param workers maximum number of concurrent worker threads + * @param onCompleted optional progress callback; may be {@code null} + * @return outcomes in input order + * @throws IllegalArgumentException if {@code workers < 1} + * @throws IllegalStateException if the calling thread is interrupted while waiting + * @throws java.util.concurrent.RejectedExecutionException if a task cannot be accepted + * @since 2026-08 + */ + public List executeAll( + List scenarios, + Map paramSchema, + String templateUri, + int workers, + ProgressListener onCompleted) { + return executeAll(scenarios, paramSchema, templateUri, workers, onCompleted, null); + } + + /** + * Executes all scenarios concurrently with optional reasoning capture. + * + * @param scenarios scenarios to execute + * @param paramSchema business-level parameter schema for validation + * @param templateUri template URI for prompt generation + * @param workers maximum number of concurrent worker threads + * @param onCompleted optional progress callback; may be {@code null} + * @param capture optional reasoning capture; may be {@code null} + * @return outcomes in input order + * @throws IllegalArgumentException if {@code workers < 1} + * @throws IllegalStateException if the calling thread is interrupted while waiting + * @throws java.util.concurrent.RejectedExecutionException if a task cannot be accepted + * @since 2026-08 + */ + public List executeAll( + List scenarios, + Map paramSchema, + String templateUri, + int workers, + ProgressListener onCompleted, + AuthzReasoningCapture capture) { + int size = scenarios.size(); + if (size == 0) { + return List.of(); + } + int poolSize = Math.min(workers, size); + AtomicInteger nameCounter = new AtomicInteger(0); + ExecutorService pool = Executors.newFixedThreadPool(poolSize, r -> { + Thread t = new Thread(r, "authz-worker-" + nameCounter.incrementAndGet()); + t.setDaemon(false); + return t; + }); + AtomicInteger completedCounter = new AtomicInteger(0); + Object callbackLock = new Object(); + List> futures = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + final int index = i; + futures.add(pool.submit(() -> { + String label = scenarios.get(index).label(); + if (capture != null) { + capture.beginScenario(label); + } + long startNanos = System.nanoTime(); + ScenarioOutcome outcome; + try { + outcome = runner.run(scenarios.get(index), paramSchema, templateUri); + } catch (Throwable t) { + outcome = buildInternalErrorOutcome(scenarios.get(index), t); + } finally { + if (capture != null) { + capture.endScenario(); + } + } + double elapsed = (System.nanoTime() - startNanos) / 1e9; + if (onCompleted != null) { + synchronized (callbackLock) { + onCompleted.onCompleted(completedCounter.incrementAndGet(), outcome, elapsed); + } + } + return outcome; + })); + } + + List outcomes = new ArrayList<>(size); + try { + for (int i = 0; i < size; i++) { + outcomes.add(futures.get(i).get()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + pool.shutdownNow(); + throw new IllegalStateException("Scenario execution interrupted", e); + } catch (ExecutionException e) { + pool.shutdownNow(); + throw new IllegalStateException("Unexpected execution exception", e); + } finally { + pool.shutdown(); + try { + if (!pool.awaitTermination(10, TimeUnit.SECONDS)) { + pool.shutdownNow(); + } + } catch (InterruptedException e) { + pool.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + return outcomes; + } + + /** + * Resolves the worker count from the {@code authz.workers} system property. + * + * @return worker count, defaulting to {@value DEFAULT_WORKERS} + * @throws IllegalArgumentException if the property is set to a non-integer or value < 1 + * @since 2026-08 + */ + public static int resolveWorkers() { + String raw = System.getProperty("authz.workers"); + if (raw == null || raw.isBlank()) { + return DEFAULT_WORKERS; + } + int workers; + try { + workers = Integer.parseInt(raw.trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid authz.workers value: '" + raw + + "'. Expected an integer >= 1 (default " + DEFAULT_WORKERS + ")."); + } + if (workers < 1) { + throw new IllegalArgumentException("Invalid authz.workers value: " + workers + + ". Expected an integer >= 1 (default " + DEFAULT_WORKERS + ")."); + } + return workers; + } + + private ScenarioOutcome buildInternalErrorOutcome(AuthzScenario scenario, Throwable t) { + return new ScenarioOutcome( + new ScenarioResult( + ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), + false, + new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), t.getMessage(), t), + List.of(new SlotValidationError( + "_llm", ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), t.getMessage())), + null, + null, + null, + List.of()), + null, + null); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java index cbef4259..b7f41368 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java @@ -1,279 +1,279 @@ -package net.openan.a2at.sample.authz_policy; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.validation.ContentValidationException; - -/** - * Scenario execution engine that runs a single scenario through prompt generation and validation, comparing the staged - * client/server expectations against the actual behaviour. - * - *

            Assertion semantics mirror the Python probe: client {@code promptText} is compared with - * trailing-whitespace-insensitive equality only for recording ({@code clientPromptMatch}); it does not participate in - * the {@code match} verdict. Server {@code slot_errors} are subset-matched (each expected error must appear with the - * same slot name and code); server {@code params} are subset-matched recursively (maps by key, ordered lists - * element-wise, trimmed strings) and only asserted for expected server outcome {@code success}. Structural warnings - * (such as an empty policy-list section for a mutation operation) are recorded in {@code warnings} but never affect the - * {@code match} verdict. - * - * @since 2026-08 - */ -public final class AuthzScenarioRunner { - - private final AuthzPromptGenerator generator; - - private final AuthzPromptValidator validator; - - public AuthzScenarioRunner(AuthzPromptGenerator generator, AuthzPromptValidator validator) { - this.generator = generator; - this.validator = validator; - } - - public ScenarioOutcome run(AuthzScenario scenario, Map paramSchema, String templateUri) { - MetadataContent metadata; - try { - metadata = generator.generate(scenario); - } catch (A2ATError e) { - return buildClientErrorOutcome(scenario, e, null); - } catch (RuntimeException e) { - A2ATError wrapped = new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), e.getMessage(), e); - return buildClientErrorOutcome(scenario, wrapped, null); - } - - ClientExpected expectedClient = scenario.expected().client(); - boolean clientPromptMatch = - expectedClient.outcome() == null && promptTextMatch(expectedClient.promptText(), metadata.promptText()); - List warnings = detectWarnings(metadata.promptText()); - if (expectedClient.outcome() != null) { - // generation unexpectedly succeeded — no server-stage expectation applies - return new ScenarioOutcome( - new ScenarioResult( - AuthzScenario.EXPECTED_SUCCESS, - false, - null, - List.of(), - clientPromptMatch, - null, - null, - warnings), - metadata, - null); - } - - FilledParamData filled; - try { - Map effectiveSchema = - scenario.validateSchema() != null ? scenario.validateSchema() : paramSchema; - filled = validator.validate(metadata.promptText(), effectiveSchema, templateUri); - } catch (A2ATError e) { - return buildServerErrorOutcome(scenario, e, metadata, clientPromptMatch, warnings); - } catch (RuntimeException e) { - A2ATError wrapped = new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), e.getMessage(), e); - return buildServerErrorOutcome(scenario, wrapped, metadata, clientPromptMatch, warnings); - } - - ServerExpected expectedServer = scenario.expected().server(); - List noErrors = List.of(); - boolean serverOutcomeMatch = - expectedServer != null && AuthzScenario.EXPECTED_SUCCESS.equals(expectedServer.outcome()); - boolean serverParamsMatch = serverOutcomeMatch - && (expectedServer.params() == null || paramsMatch(expectedServer.params(), filled.data())); - boolean match = serverOutcomeMatch && serverParamsMatch; - return new ScenarioOutcome( - new ScenarioResult( - AuthzScenario.EXPECTED_SUCCESS, - match, - null, - noErrors, - clientPromptMatch, - serverOutcomeMatch, - serverParamsMatch, - warnings), - metadata, - filled); - } - - private ScenarioOutcome buildClientErrorOutcome(AuthzScenario scenario, A2ATError error, MetadataContent metadata) { - String actualOutcome = error.getCode(); - List actualSlotErrors = extractSlotErrors(error); - ClientExpected expectedClient = scenario.expected().client(); - boolean outcomeMatch = - expectedClient.outcome() != null && expectedClient.outcome().equals(actualOutcome); - boolean slotErrorsMatch = outcomeMatch && slotErrorsMatch(expectedClient.slotErrors(), actualSlotErrors); - boolean match = outcomeMatch && slotErrorsMatch; - return new ScenarioOutcome( - new ScenarioResult(actualOutcome, match, error, actualSlotErrors, null, null, null, List.of()), - metadata, - null); - } - - private ScenarioOutcome buildServerErrorOutcome( - AuthzScenario scenario, - A2ATError error, - MetadataContent metadata, - boolean clientPromptMatch, - List warnings) { - String actualOutcome = error.getCode(); - List actualSlotErrors = extractSlotErrors(error); - ServerExpected expectedServer = scenario.expected().server(); - boolean outcomeMatch = expectedServer != null - && expectedServer.outcome() != null - && expectedServer.outcome().equals(actualOutcome); - boolean slotErrorsMatch = outcomeMatch && slotErrorsMatch(expectedServer.slotErrors(), actualSlotErrors); - boolean match = outcomeMatch && slotErrorsMatch; - return new ScenarioOutcome( - new ScenarioResult( - actualOutcome, match, error, actualSlotErrors, clientPromptMatch, outcomeMatch, null, warnings), - metadata, - null); - } - - private static List extractSlotErrors(A2ATError error) { - if (error instanceof PromptGenerationException pge) { - return pge.failedParameters(); - } - if (error instanceof ContentValidationException cve) { - return cve.errors(); - } - return List.of(); - } - - /** Trailing-whitespace-insensitive equality for the rendered prompt text. */ - static boolean promptTextMatch(String expected, String actual) { - if (expected == null || actual == null) { - return expected == actual; - } - return expected.strip().equals(actual.strip()); - } - - /** Subset match: every expected slot error must appear in the actual errors with the same name and code. */ - static boolean slotErrorsMatch(List expected, List actual) { - if (expected == null || expected.isEmpty()) { - return true; - } - for (SlotErrorExpectation expectedError : expected) { - boolean found = actual.stream() - .anyMatch(actualError -> actualError.slotName().equals(expectedError.slotName()) - && actualError.code().equals(expectedError.code())); - if (!found) { - return false; - } - } - return true; - } - - /** - * Recursive subset match for extracted parameters: maps match when every expected key is present with a - * recursively-equal value; lists match when of equal length and element-wise recursively equal; strings match after - * trimming; other values match via {@link Objects#equals}. - */ - static boolean paramsMatch(Object expected, Object actual) { - if (expected instanceof Map expectedMap) { - if (!(actual instanceof Map actualMap)) { - return false; - } - for (Map.Entry entry : expectedMap.entrySet()) { - if (!actualMap.containsKey(entry.getKey())) { - return false; - } - if (!paramsMatch(entry.getValue(), actualMap.get(entry.getKey()))) { - return false; - } - } - return true; - } - if (expected instanceof List expectedList) { - if (!(actual instanceof List actualList) || expectedList.size() != actualList.size()) { - return false; - } - for (int i = 0; i < expectedList.size(); i++) { - if (!paramsMatch(expectedList.get(i), actualList.get(i))) { - return false; - } - } - return true; - } - if (expected instanceof String expectedText) { - return actual instanceof String actualText && expectedText.strip().equals(actualText.strip()); - } - return Objects.equals(expected, actual); - } - - /** - * Extracts the content of a {@code ## heading} section from the prompt text. Returns the text between the heading - * line and the next heading (or end of string), stripped. Returns {@code null} if the heading is not found. - */ - static String extractSectionContent(String promptText, String heading) { - String marker = heading + "\n"; - int start = promptText.indexOf(marker); - if (start < 0) { - return null; - } - int contentStart = start + marker.length(); - int nextHeading = promptText.indexOf("\n## ", contentStart); - if (nextHeading < 0) { - return promptText.substring(contentStart).strip(); - } - return promptText.substring(contentStart, nextHeading).strip(); - } - - /** - * Detects structural warnings in the generated prompt text. Warnings are informational only and never affect the - * {@code match} verdict. - */ - static List detectWarnings(String promptText) { - if (promptText == null) { - return List.of(); - } - List warnings = new ArrayList<>(); - String operationTypeContent = extractSectionContent(promptText, "## 授权策略的操作类型"); - String policyListContent = extractSectionContent(promptText, "## 动网操作的授权策略列表"); - String operationType = operationTypeContent != null - ? operationTypeContent.lines().findFirst().map(String::strip).orElse("") - : ""; - boolean isMutation = - operationType.contains("新增") || operationType.contains("修改") || operationType.contains("删除"); - boolean policyListEmpty = policyListContent == null || policyListContent.isEmpty(); - if (isMutation && policyListEmpty) { - warnings.add("empty_policy_list_section"); - } - return warnings; - } - - /** - * Result of one scenario run. - * - * @param outcome actual outcome code ({@code success} or an {@code ErrorCatalog} code) - * @param match whether the actual behaviour satisfies all staged expectations - * @param error the raised error, if any - * @param slotErrors actual per-slot error details - * @param clientPromptMatch whether the client-generated prompt text matched; {@code null} when the client stage was - * expected to fail. Recorded for diagnostics only; does not affect {@code match}. - * @param serverOutcomeMatch whether the server outcome matched; {@code null} when no server stage ran - * @param serverParamsMatch whether the extracted parameters matched; {@code null} when not asserted - * @param warnings structural warnings detected during the run (e.g. empty policy-list section for a mutation - * operation); never affects {@code match} - */ - public record ScenarioResult( - String outcome, - boolean match, - A2ATError error, - List slotErrors, - Boolean clientPromptMatch, - Boolean serverOutcomeMatch, - Boolean serverParamsMatch, - List warnings) {} - - public record ScenarioOutcome(ScenarioResult result, MetadataContent metadata, FilledParamData filled) {} -} +package net.openan.a2at.sample.authz_policy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.ContentValidationException; + +/** + * Scenario execution engine that runs a single scenario through prompt generation and validation, comparing the staged + * client/server expectations against the actual behaviour. + * + *

            Assertion semantics mirror the Python probe: client {@code promptText} is compared with + * trailing-whitespace-insensitive equality only for recording ({@code clientPromptMatch}); it does not participate in + * the {@code match} verdict. Server {@code slot_errors} are subset-matched (each expected error must appear with the + * same slot name and code); server {@code params} are subset-matched recursively (maps by key, ordered lists + * element-wise, trimmed strings) and only asserted for expected server outcome {@code success}. Structural warnings + * (such as an empty policy-list section for a mutation operation) are recorded in {@code warnings} but never affect the + * {@code match} verdict. + * + * @since 2026-08 + */ +public final class AuthzScenarioRunner { + + private final AuthzPromptGenerator generator; + + private final AuthzPromptValidator validator; + + public AuthzScenarioRunner(AuthzPromptGenerator generator, AuthzPromptValidator validator) { + this.generator = generator; + this.validator = validator; + } + + public ScenarioOutcome run(AuthzScenario scenario, Map paramSchema, String templateUri) { + MetadataContent metadata; + try { + metadata = generator.generate(scenario); + } catch (A2ATError e) { + return buildClientErrorOutcome(scenario, e, null); + } catch (RuntimeException e) { + A2ATError wrapped = new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), e.getMessage(), e); + return buildClientErrorOutcome(scenario, wrapped, null); + } + + ClientExpected expectedClient = scenario.expected().client(); + boolean clientPromptMatch = + expectedClient.outcome() == null && promptTextMatch(expectedClient.promptText(), metadata.promptText()); + List warnings = detectWarnings(metadata.promptText()); + if (expectedClient.outcome() != null) { + // generation unexpectedly succeeded — no server-stage expectation applies + return new ScenarioOutcome( + new ScenarioResult( + AuthzScenario.EXPECTED_SUCCESS, + false, + null, + List.of(), + clientPromptMatch, + null, + null, + warnings), + metadata, + null); + } + + FilledParamData filled; + try { + Map effectiveSchema = + scenario.validateSchema() != null ? scenario.validateSchema() : paramSchema; + filled = validator.validate(metadata.promptText(), effectiveSchema, templateUri); + } catch (A2ATError e) { + return buildServerErrorOutcome(scenario, e, metadata, clientPromptMatch, warnings); + } catch (RuntimeException e) { + A2ATError wrapped = new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), e.getMessage(), e); + return buildServerErrorOutcome(scenario, wrapped, metadata, clientPromptMatch, warnings); + } + + ServerExpected expectedServer = scenario.expected().server(); + List noErrors = List.of(); + boolean serverOutcomeMatch = + expectedServer != null && AuthzScenario.EXPECTED_SUCCESS.equals(expectedServer.outcome()); + boolean serverParamsMatch = serverOutcomeMatch + && (expectedServer.params() == null || paramsMatch(expectedServer.params(), filled.data())); + boolean match = serverOutcomeMatch && serverParamsMatch; + return new ScenarioOutcome( + new ScenarioResult( + AuthzScenario.EXPECTED_SUCCESS, + match, + null, + noErrors, + clientPromptMatch, + serverOutcomeMatch, + serverParamsMatch, + warnings), + metadata, + filled); + } + + private ScenarioOutcome buildClientErrorOutcome(AuthzScenario scenario, A2ATError error, MetadataContent metadata) { + String actualOutcome = error.getCode(); + List actualSlotErrors = extractSlotErrors(error); + ClientExpected expectedClient = scenario.expected().client(); + boolean outcomeMatch = + expectedClient.outcome() != null && expectedClient.outcome().equals(actualOutcome); + boolean slotErrorsMatch = outcomeMatch && slotErrorsMatch(expectedClient.slotErrors(), actualSlotErrors); + boolean match = outcomeMatch && slotErrorsMatch; + return new ScenarioOutcome( + new ScenarioResult(actualOutcome, match, error, actualSlotErrors, null, null, null, List.of()), + metadata, + null); + } + + private ScenarioOutcome buildServerErrorOutcome( + AuthzScenario scenario, + A2ATError error, + MetadataContent metadata, + boolean clientPromptMatch, + List warnings) { + String actualOutcome = error.getCode(); + List actualSlotErrors = extractSlotErrors(error); + ServerExpected expectedServer = scenario.expected().server(); + boolean outcomeMatch = expectedServer != null + && expectedServer.outcome() != null + && expectedServer.outcome().equals(actualOutcome); + boolean slotErrorsMatch = outcomeMatch && slotErrorsMatch(expectedServer.slotErrors(), actualSlotErrors); + boolean match = outcomeMatch && slotErrorsMatch; + return new ScenarioOutcome( + new ScenarioResult( + actualOutcome, match, error, actualSlotErrors, clientPromptMatch, outcomeMatch, null, warnings), + metadata, + null); + } + + private static List extractSlotErrors(A2ATError error) { + if (error instanceof PromptGenerationException pge) { + return pge.failedParameters(); + } + if (error instanceof ContentValidationException cve) { + return cve.errors(); + } + return List.of(); + } + + /** Trailing-whitespace-insensitive equality for the rendered prompt text. */ + static boolean promptTextMatch(String expected, String actual) { + if (expected == null || actual == null) { + return expected == actual; + } + return expected.strip().equals(actual.strip()); + } + + /** Subset match: every expected slot error must appear in the actual errors with the same name and code. */ + static boolean slotErrorsMatch(List expected, List actual) { + if (expected == null || expected.isEmpty()) { + return true; + } + for (SlotErrorExpectation expectedError : expected) { + boolean found = actual.stream() + .anyMatch(actualError -> actualError.slotName().equals(expectedError.slotName()) + && actualError.code().equals(expectedError.code())); + if (!found) { + return false; + } + } + return true; + } + + /** + * Recursive subset match for extracted parameters: maps match when every expected key is present with a + * recursively-equal value; lists match when of equal length and element-wise recursively equal; strings match after + * trimming; other values match via {@link Objects#equals}. + */ + static boolean paramsMatch(Object expected, Object actual) { + if (expected instanceof Map expectedMap) { + if (!(actual instanceof Map actualMap)) { + return false; + } + for (Map.Entry entry : expectedMap.entrySet()) { + if (!actualMap.containsKey(entry.getKey())) { + return false; + } + if (!paramsMatch(entry.getValue(), actualMap.get(entry.getKey()))) { + return false; + } + } + return true; + } + if (expected instanceof List expectedList) { + if (!(actual instanceof List actualList) || expectedList.size() != actualList.size()) { + return false; + } + for (int i = 0; i < expectedList.size(); i++) { + if (!paramsMatch(expectedList.get(i), actualList.get(i))) { + return false; + } + } + return true; + } + if (expected instanceof String expectedText) { + return actual instanceof String actualText && expectedText.strip().equals(actualText.strip()); + } + return Objects.equals(expected, actual); + } + + /** + * Extracts the content of a {@code ## heading} section from the prompt text. Returns the text between the heading + * line and the next heading (or end of string), stripped. Returns {@code null} if the heading is not found. + */ + static String extractSectionContent(String promptText, String heading) { + String marker = heading + "\n"; + int start = promptText.indexOf(marker); + if (start < 0) { + return null; + } + int contentStart = start + marker.length(); + int nextHeading = promptText.indexOf("\n## ", contentStart); + if (nextHeading < 0) { + return promptText.substring(contentStart).strip(); + } + return promptText.substring(contentStart, nextHeading).strip(); + } + + /** + * Detects structural warnings in the generated prompt text. Warnings are informational only and never affect the + * {@code match} verdict. + */ + static List detectWarnings(String promptText) { + if (promptText == null) { + return List.of(); + } + List warnings = new ArrayList<>(); + String operationTypeContent = extractSectionContent(promptText, "## 授权策略的操作类型"); + String policyListContent = extractSectionContent(promptText, "## 动网操作的授权策略列表"); + String operationType = operationTypeContent != null + ? operationTypeContent.lines().findFirst().map(String::strip).orElse("") + : ""; + boolean isMutation = + operationType.contains("新增") || operationType.contains("修改") || operationType.contains("删除"); + boolean policyListEmpty = policyListContent == null || policyListContent.isEmpty(); + if (isMutation && policyListEmpty) { + warnings.add("empty_policy_list_section"); + } + return warnings; + } + + /** + * Result of one scenario run. + * + * @param outcome actual outcome code ({@code success} or an {@code ErrorCatalog} code) + * @param match whether the actual behaviour satisfies all staged expectations + * @param error the raised error, if any + * @param slotErrors actual per-slot error details + * @param clientPromptMatch whether the client-generated prompt text matched; {@code null} when the client stage was + * expected to fail. Recorded for diagnostics only; does not affect {@code match}. + * @param serverOutcomeMatch whether the server outcome matched; {@code null} when no server stage ran + * @param serverParamsMatch whether the extracted parameters matched; {@code null} when not asserted + * @param warnings structural warnings detected during the run (e.g. empty policy-list section for a mutation + * operation); never affects {@code match} + */ + public record ScenarioResult( + String outcome, + boolean match, + A2ATError error, + List slotErrors, + Boolean clientPromptMatch, + Boolean serverOutcomeMatch, + Boolean serverParamsMatch, + List warnings) {} + + public record ScenarioOutcome(ScenarioResult result, MetadataContent metadata, FilledParamData filled) {} +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java index 8ae8750a..f19897c1 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java @@ -1,1357 +1,1357 @@ -package net.openan.a2at.sample.negotiation.eval; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; -import java.util.UUID; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.server.A2ATServer; - -/** - * Closed-loop evaluator for the negotiation prompt interfaces of the private-line-complaint scenario. - * - *

            Each case of the bundled suite ({@code sample/negotiation/eval/eval-suite.json}) is driven through the negotiation - * interfaces in message order, exactly the way the negotiation demo wires them: - * - *

              - *
            1. Task-T generation — the client value from the Task-T extension metadata is either structured JSON - * ({@code generateTaskPromptFromDataWithSchema}: every data key maps to one slot value, the schema is passed as a - * call parameter) or natural language ({@code generateTaskPromptFromText}, LLM slot extraction); - *
            2. server validation — {@code validateTaskPromptAndDataFilling} extracts the parameters against the passed JSON - * schema; the missing-slot set (blank slots plus semantic {@code content.param_missing} errors) decides whether - * negotiation is triggered; - *
            3. propose generation — for the detected missing slots, {@code generateNegotiationProposePromptFromData} / - * {@code FromText} renders the Negotiation-T information-propose message; - *
            4. outbound propose validation — the server self-checks the generated propose via - * {@code validateProposePromptAndDataFilling} before sending it (expected to pass); - *
            5. inbound propose validation — the client validates the received propose through the symmetric client-side API - * {@code validateProposePromptAndDataFilling} and reads out which slots it is asked to supplement; the fill round - * is driven by this client-side view; - *
            6. client fill + accept — the client merges the round-1 parameters with its fill values, RE-RENDERS the full - * Task-T prompt from the complete parameter map (there is no incremental merge API; the supplement is - * incorporated by re-generating the whole prompt) and renders the Negotiation-T accept; - *
            7. inbound accept validation — {@code validateAcceptPromptAndDataFilling} checks the accept message and extracts - * the supplemented values (expected to pass with the values the client sent); - *
            8. second Task-T validation — {@code validateTaskPromptAndDataFilling} re-validates the filled Task-T prompt; no - * missing slot means the negotiation completed the flow (COMPLETED), any rejection keeps it in INPUT_REQUIRED. - *
            - * - *

            Every intermediate artifact is recorded per case in a JSON report: the generated Task-T prompt, both negotiation - * messages, each validation verdict with extracted parameters and per-slot errors, the client's fill values — and, for - * every SDK call, an {@code api} entry carrying the exact method name plus its input parameters (including the full - * JSON schema passed to generate/validate), so a reviewer can verify from the report alone which interface ran with - * which arguments. A single case can be replayed and audited without re-running it. The report is rewritten after every - * case, so an interrupted run keeps the cases already finished. - * - *

            Usage: - * - *

            {@code
            - * java @a2a-t-sample/target/eval.javaargs.txt [--out eval-report.json] [--case PLC-04] /path/to/.env
            - * }
            - * - * @since 2026-08 - */ -public final class NegotiationEvalApp { - - private static final ObjectMapper MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); - - private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - - private NegotiationEvalApp() {} - - /** - * Entry point. - * - * @param args options ({@code --out } report file, {@code --case } repeatable case filter), then the - * {@code .env} path carrying a real LLM API key - */ - public static void main(String[] args) { - Path envPath = null; - Path outPath = Path.of("eval-report.json"); - List caseFilter = new ArrayList<>(); - String negotiationChannelOverride = null; - String channelFilter = null; - for (int i = 0; i < args.length; i++) { - String arg = args[i]; - if ("--out".equals(arg) && i + 1 < args.length) { - outPath = Path.of(args[++i]); - } else if ("--case".equals(arg) && i + 1 < args.length) { - caseFilter.add(args[++i]); - } else if ("--negotiation-channel".equals(arg) && i + 1 < args.length) { - negotiationChannelOverride = args[++i]; - } else if ("--channel".equals(arg) && i + 1 < args.length) { - channelFilter = args[++i]; - } else if (!arg.startsWith("--")) { - envPath = Path.of(arg); - } - } - if (envPath == null) { - System.err.println( - "Usage: java @a2a-t-sample/target/eval.javaargs.txt [--out eval-report.json] [--case PLC-04]" - + " [--channel fromData|fromText] [--negotiation-channel fromData|fromText]" - + " /path/to/.env"); - System.exit(1); - } - - EvalLlmCaptureClient.install(); - Map suite = loadSuite(); - if (negotiationChannelOverride != null) { - // run the whole suite with one negotiation generation channel without duplicating cases - for (Map testCase : cases(suite)) { - testCase.put("negotiation_channel", negotiationChannelOverride); - } - } - - Map report = new LinkedHashMap<>(); - report.put("suite", suite.get("suite")); - report.put("description", suite.get("description")); - report.put("scenario", suite.get("scenario")); - report.put("generated_at", LocalDateTime.now().format(TIMESTAMP)); - report.put("llm", llmInfo(envPath)); - report.put("case_filter", caseFilter); - report.put("channel_filter", channelFilter == null ? "all" : channelFilter); - report.put("negotiation_channel", negotiationChannelOverride == null ? "per-case" : negotiationChannelOverride); - - List> cases = new ArrayList<>(); - report.put("cases", cases); - int index = 0; - int total = countCases(suite, caseFilter, channelFilter); - for (Map testCase : cases(suite)) { - String caseId = String.valueOf(testCase.get("id")); - if (!caseFilter.isEmpty() && !caseFilter.contains(caseId)) { - continue; - } - // evaluate one task-generation channel in isolation: only its cases run, so fromData and - // fromText can be measured (and reported) separately - if (channelFilter != null && !channelFilter.equals(String.valueOf(testCase.get("channel")))) { - continue; - } - index++; - emit("\n[eval] ===== [" + index + "/" + total + "] case " + caseId + ": " + testCase.get("intent") - + " ====="); - long startNanos = System.nanoTime(); - Map record = runCase(envPath, suite, testCase); - record.put("duration_seconds", round1((System.nanoTime() - startNanos) / 1_000_000_000.0)); - cases.add(record); - report.put("metrics", metrics(cases)); - writeReport(report, outPath); - } - - report.put("metrics", metrics(cases)); - writeReport(report, outPath); - Map metrics = metrics(cases); - emit("\n[eval] ===== Negotiation interface correctness metrics ====="); - emit("[eval] cases: " + metrics.get("cases")); - for (String key : List.of( - "trigger_accuracy", - "missing_slot_exact_match", - "propose_outbound_valid", - "accept_fill_value_match", - "closed_loop_completion")) { - emit("[eval] " + metrics.get(key)); - } - emit("[eval] report written to " + outPath.toAbsolutePath()); - } - - // -- one case: the negotiation interface closed loop with full evidence capture -- - - /** Runs one case end to end and returns its replayable trace record. */ - private static Map runCase(Path envPath, Map suite, Map testCase) { - EvalLlmCaptureClient.reset(); - String caseId = String.valueOf(testCase.get("id")); - String channel = String.valueOf(testCase.get("channel")); - boolean fromText = "fromText".equals(channel); - String negotiationChannel = String.valueOf(testCase.getOrDefault("negotiation_channel", "fromData")); - boolean negotiationFromText = "fromText".equals(negotiationChannel); - String inputText = String.valueOf(testCase.get("input_text")); - Map inputData = asMap(testCase.get("input_data")); - - String taskTemplate = String.valueOf(suite.get("template_uri")); - Map taskSchema = asMap(suite.get("task_schema")); - Map phrasing = stringMap(suite.get("negotiation_phrasing")); - Map properties = asMap(taskSchema.get("properties")); - Set requiredSlots = new TreeSet<>(stringList(taskSchema.get("required"))); - Map fillValues = - mergeFills(stringMap(suite.get("client_fill_values")), stringMap(testCase.get("fill_override"))); - - Map expect = asMap(testCase.get("expect")); - boolean expectNegotiation = Boolean.TRUE.equals(expect.get("negotiation_needed")); - Set expectMissing = new TreeSet<>(stringList(expect.get("missing_slots"))); - Boolean expectFillCompletes = (Boolean) expect.get("fill_completes"); - boolean expectGenerationFails = Boolean.TRUE.equals(expect.get("generation_fails")); - - Map record = new LinkedHashMap<>(); - record.put("case", caseId); - record.put("channel", channel); - record.put("negotiation_channel", negotiationChannel); - record.put("intent", testCase.get("intent")); - Map input = new LinkedHashMap<>(); - input.put("fromText", fromText); - if (fromText) { - input.put("text", inputText); - } else { - input.put("data", inputData); - } - record.put("input", input); - record.put("task_schema", taskSchema); - Map expectJson = new LinkedHashMap<>(); - expectJson.put("negotiation_needed", expectNegotiation); - expectJson.put("missing_slots", new ArrayList<>(expectMissing)); - expectJson.put("fill_completes", expectFillCompletes); - expectJson.put("generation_fails", expectGenerationFails); - record.put("expect", expectJson); - List> steps = new ArrayList<>(); - record.put("steps", steps); - - // -- step 1: Task-T prompt generation from the extension metadata value -- - Map taskGenerationInput = new LinkedHashMap<>(); - if (fromText) { - taskGenerationInput.put("text", inputText); - } else { - taskGenerationInput.put("data", inputData); - taskGenerationInput.put("schema", taskSchema); - } - taskGenerationInput.put("template_uri", taskTemplate); - String taskGenerationApi = - fromText ? "A2ATClient.generateTaskPromptFromText" : "A2ATClient.generateTaskPromptFromDataWithSchema"; - String taskPrompt; - try { - long nanos = System.nanoTime(); - A2ATClient client = new A2ATClient(envPath); - MetadataContent taskContent = fromText - ? client.generateTaskPromptFromText(inputText, taskTemplate) - : client.generateTaskPromptFromDataWithSchema(inputData, taskSchema, taskTemplate); - taskPrompt = taskContent.promptText(); - Map step = step("1. Task-T generation (" + channel + ")", "client"); - api(step, apiCall(taskGenerationApi, taskGenerationInput)); - step.put("generated_prompt", taskPrompt); - step.put("template_uri", taskContent.templateUri()); - steps.add(step); - emit("[eval] [1] Task-T prompt generated (" + channel + ", " + secs(nanos) + "s)"); - } catch (A2ATError error) { - steps.add(errorStep( - "1. Task-T generation (" + channel + ")", - "task_generation", - error, - apiCall(taskGenerationApi, taskGenerationInput))); - emit("[eval] [1] Task-T generation FAILED: " + error.getCode() + " " + error.getMessage()); - if (expectGenerationFails) { - // generation-time fail-fast is the expected outcome (e.g. a required slot missing from the - // natural-language input): the task never exists, so negotiation cannot and must not trigger - Map verdict = new LinkedHashMap<>(); - verdict.put("trigger_correct", !expectNegotiation); - verdict.put("slots_correct", expectMissing.isEmpty()); - verdict.put("propose_outbound_valid", null); - verdict.put("accept_fill_value_match", null); - verdict.put("fill_completes_correct", null); - verdict.put("pass", !expectNegotiation && expectMissing.isEmpty()); - verdict.put( - "reason", "generation failed fast as expected: " + error.getCode() + " " + error.getMessage()); - record.put("verdict", verdict); - emit("[eval] verdict: PASS (generation fail-fast as expected)"); - return record; - } - return finish( - record, steps, null, null, null, null, null, expectNegotiation, expectMissing, expectFillCompletes); - } - - // -- step 2: server validation of the Task-T prompt -> missing-slot detection -- - Map taskValidationInput = Map.of( - "prompt", taskPrompt, - "schema", taskSchema, - "template_uri", taskTemplate); - Set actualMissing; - Map extractedRound1 = new LinkedHashMap<>(); - try { - long nanos = System.nanoTime(); - A2ATServer server = new A2ATServer(envPath); - FilledParamData params = server.validateTaskPromptAndDataFilling(taskPrompt, taskSchema, taskTemplate); - actualMissing = requiredMissing(blankSlots(params), requiredSlots); - if (params.data() != null) { - extractedRound1.putAll(params.data()); - } - Map step = stepOf( - "2. server Task-T validation (missing-slot detection)", - "server", - validationJson("passed", null, null, params, actualMissing)); - api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", taskValidationInput)); - steps.add(step); - emit("[eval] [2] validation passed, missing slots: " + actualMissing + " (" + secs(nanos) + "s)"); - } catch (ContentValidationException error) { - actualMissing = requiredMissing(errorSlots(error), requiredSlots); - Map step = stepOf( - "2. server Task-T validation (missing-slot detection)", - "server", - validationJson("rejected", error.getCode(), error.errors(), null, actualMissing)); - api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", taskValidationInput)); - steps.add(step); - emit("[eval] [2] validation rejected (" + error.getCode() + "), missing slots: " + actualMissing); - } - - boolean negotiationTriggered = !actualMissing.isEmpty(); - if (!negotiationTriggered) { - Map done = step("3. no negotiation needed -> flow completes directly", "server"); - done.put("task_state", "COMPLETED"); - steps.add(done); - emit("[eval] [3] no missing parameters -> COMPLETED without negotiation"); - return finish( - record, - steps, - negotiationTriggered, - actualMissing, - null, - null, - null, - expectNegotiation, - expectMissing, - expectFillCompletes); - } - - // -- step 3: Negotiation-T propose generation from the detected missing slots -- - List missingItems = new ArrayList<>(); - for (String slot : actualMissing) { - missingItems.add(new NegotiationItem(slot, missingHint(phrasing, properties, slot))); - } - String proposeRelationship = missingItems.size() > 1 ? phrasing.get("propose_relationship") : null; - NegotiationContext proposeContext = new NegotiationContext( - UUID.randomUUID().toString(), - 1, - NegotiationContext.DEFAULT_MAX_ROUNDS, - NegotiationPerformative.PROPOSE); - String proposeInputText = itemsToProposeText(missingItems, phrasing); - Map proposeGenerationInput = new LinkedHashMap<>(); - if (negotiationFromText) { - proposeGenerationInput.put("text", proposeInputText); - proposeGenerationInput.put("context", contextJson(proposeContext)); - } else { - Map proposeData = new LinkedHashMap<>(); - proposeData.put("context", contextJson(proposeContext)); - proposeData.put("items", itemsJson(missingItems)); - if (proposeRelationship != null) { - proposeData.put("relationship", proposeRelationship); - } - proposeGenerationInput.put("data", proposeData); - } - proposeGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_PROPOSE); - String proposeGenerationApi = negotiationFromText - ? "A2ATServer.generateNegotiationProposePromptFromText" - : "A2ATServer.generateNegotiationProposePromptFromData"; - String proposePrompt; - try { - A2ATServer server = new A2ATServer(envPath); - MetadataContent propose = negotiationFromText - ? server.generateNegotiationProposePromptFromText( - proposeInputText, proposeContext, DemoTemplates.NEGOTIATION_PROPOSE) - : server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - proposeContext, new InformationProposeContent(missingItems, proposeRelationship)), - DemoTemplates.NEGOTIATION_PROPOSE); - proposePrompt = propose.promptText(); - Map step = - step("3. Negotiation-T propose generation (" + negotiationChannel + ")", "server"); - api(step, apiCall(proposeGenerationApi, proposeGenerationInput)); - step.put("negotiation_items", itemsJson(missingItems)); - if (negotiationFromText) { - step.put("input_text", proposeInputText); - } - step.put("generated_prompt", proposePrompt); - step.put("template_uri", propose.templateUri()); - step.put("task_state", "INPUT_REQUIRED"); - steps.add(step); - emit("[eval] [3] propose rendered for " + actualMissing + " (" + negotiationChannel - + ") -> INPUT_REQUIRED"); - } catch (A2ATError error) { - steps.add(errorStep( - "3. Negotiation-T propose generation (" + negotiationChannel + ")", - "propose_generation", - error, - apiCall(proposeGenerationApi, proposeGenerationInput))); - emit("[eval] [3] propose generation FAILED: " + error.getCode() + " " + error.getMessage()); - return finish( - record, - steps, - negotiationTriggered, - actualMissing, - null, - null, - null, - expectNegotiation, - expectMissing, - expectFillCompletes); - } - - // -- step 4: outbound validation of the generated propose -- - Map proposeValidationInput = Map.of( - "prompt", - proposePrompt, - "context", - contextJson(proposeContext), - "schema", - negotiationSchema(actualMissing, taskSchema), - "template_uri", - DemoTemplates.NEGOTIATION_PROPOSE); - Boolean proposeValid = null; - try { - long nanos = System.nanoTime(); - A2ATServer server = new A2ATServer(envPath); - FilledParamData proposeParams = server.validateProposePromptAndDataFilling( - proposePrompt, - proposeContext, - negotiationSchema(actualMissing, taskSchema), - DemoTemplates.NEGOTIATION_PROPOSE); - proposeValid = true; - Map step = stepOf( - "4. outbound propose validation", - "server", - validationJson("passed", null, null, proposeParams, Set.of())); - api(step, apiCall("A2ATServer.validateProposePromptAndDataFilling", proposeValidationInput)); - steps.add(step); - emit("[eval] [4] outbound propose validation passed (" + secs(nanos) + "s)"); - } catch (A2ATError error) { - proposeValid = false; - List slotErrors = error - instanceof - net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException - extraction - ? extraction.getErrors() - : List.of(); - Map step = stepOf( - "4. outbound propose validation", - "server", - validationJson("rejected", error.getCode(), slotErrors, null, Set.of())); - api(step, apiCall("A2ATServer.validateProposePromptAndDataFilling", proposeValidationInput)); - steps.add(step); - emit("[eval] [4] outbound propose validation REJECTED: " + error.getCode() + " " + error.getMessage()); - } - - // -- step 5: client validates the INBOUND propose and reads out the requested slots -- - // The SDK exposes the validate* API symmetrically on client and server: the receiving client re-validates - // the negotiation message it got and extracts which slots the server asked it to supplement. The fill round - // below is driven by this client-side view (not by the server's internal missing-slot set), so the report - // shows what a real client would learn from the propose message alone. - Set clientRequestedSlots = new TreeSet<>(); - try { - long nanos = System.nanoTime(); - A2ATClient client = new A2ATClient(envPath); - FilledParamData requested = client.validateProposePromptAndDataFilling( - proposePrompt, - proposeContext, - negotiationSchema(actualMissing, taskSchema), - DemoTemplates.NEGOTIATION_PROPOSE); - if (requested.data() != null) { - for (String key : requested.data().keySet()) { - // the SDK merges the negotiation context (id/round/maxRounds) into the extracted data; only the - // real slot names tell the client what to supplement - if (!"id".equals(key) && !"round".equals(key) && !"maxRounds".equals(key)) { - clientRequestedSlots.add(key); - } - } - } - Map step = stepOf( - "5. client inbound propose validation (requested-slot extraction)", - "client", - validationJson("passed", null, null, requested, Set.of())); - api( - step, - apiCall( - "A2ATClient.validateProposePromptAndDataFilling", - Map.of( - "prompt", - proposePrompt, - "context", - contextJson(proposeContext), - "schema", - negotiationSchema(actualMissing, taskSchema), - "template_uri", - DemoTemplates.NEGOTIATION_PROPOSE))); - step.put("client_requested_slots", new ArrayList<>(clientRequestedSlots)); - steps.add(step); - emit("[eval] [5] client extracted requested slots: " + clientRequestedSlots + " (" + secs(nanos) + "s)"); - } catch (A2ATError error) { - List slotErrors = error - instanceof - net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException - extraction - ? extraction.getErrors() - : List.of(); - Map step = stepOf( - "5. client inbound propose validation (requested-slot extraction)", - "client", - validationJson("rejected", error.getCode(), slotErrors, null, Set.of())); - api( - step, - apiCall( - "A2ATClient.validateProposePromptAndDataFilling", - Map.of( - "prompt", - proposePrompt, - "context", - contextJson(proposeContext), - "schema", - negotiationSchema(actualMissing, taskSchema), - "template_uri", - DemoTemplates.NEGOTIATION_PROPOSE))); - step.put("client_requested_slots", List.of()); - steps.add(step); - emit("[eval] [5] client propose validation REJECTED: " + error.getCode() + " " + error.getMessage() - + " -> falling back to the server-side missing-slot set for the fill"); - } - Set slotsToFill = clientRequestedSlots.isEmpty() ? actualMissing : clientRequestedSlots; - - // -- step 6: client fills the requested slots and renders the Negotiation-T accept -- - // the filled Task-T prompt is RE-GENERATED from the complete parameter map, exactly like the demo renders - // message 3's Task-T from the filled params — not text-appended. There is no incremental merge API: the - // client holds the full parameter set (the fromData input, or for fromText the parameters the server - // extracted in round 1), completed with the fill values for everything still missing, and re-renders the - // whole Task-T prompt from that map. - Map fills = fills(slotsToFill, fillValues); - // the fill round re-renders from the CLIENT's own knowledge: for fromData the structured input, for - // fromText the client_data the case carries (what its text quoted) — falling back to the server's - // round-1 extraction, which is only available when that validation passed - Map baseData = fromText ? clientBaseData(testCase, extractedRound1) : inputData; - // only the negotiated slots' fill values enter the merge: round-1 base values are preserved verbatim, - // so the re-rendered prompt carries exactly what the client knew plus what it supplemented - Map filledData = mergeInputData(baseData, taskSchema, fills); - String filledPrompt; - Map filledGenerationInput = Map.of( - "data", filledData, - "schema", taskSchema, - "template_uri", taskTemplate); - try { - A2ATClient client = new A2ATClient(envPath); - filledPrompt = client.generateTaskPromptFromDataWithSchema(filledData, taskSchema, taskTemplate) - .promptText(); - } catch (A2ATError error) { - // a fill value violates a slot constraint: fromData generation fails fast — the closed loop cannot - // complete, which is the expected negative outcome for constraint-violating fills - filledPrompt = appendSupplements(taskPrompt, fills); - Map step = step("6. client fill + Negotiation-T accept generation (fromData)", "client"); - api(step, apiCall("A2ATClient.generateTaskPromptFromDataWithSchema", filledGenerationInput)); - step.put("client_fill", fills); - step.put( - "fill_generation_rejected", - Map.of( - "code", error.getCode(), - "message", String.valueOf(error.getMessage()))); - steps.add(step); - emit("[eval] [6] filled Task-T generation REJECTED (" + error.getCode() + "): " + error.getMessage()); - return finish( - record, - steps, - negotiationTriggered, - actualMissing, - proposeValid, - null, - false, - expectNegotiation, - expectMissing, - expectFillCompletes); - } - String acceptPrompt; - NegotiationContext acceptContext = new NegotiationContext( - UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.ACCEPT); - String acceptInputText = itemsToAcceptText(filledItems(fills), phrasing); - Map acceptGenerationInput = new LinkedHashMap<>(); - if (negotiationFromText) { - acceptGenerationInput.put("text", acceptInputText); - acceptGenerationInput.put("context", contextJson(acceptContext)); - } else { - acceptGenerationInput.put( - "data", - Map.of( - "context", contextJson(acceptContext), - "conclusion", NegotiationConclusion.ACCEPT.toString(), - "items", itemsJson(filledItems(fills)))); - } - acceptGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_ACCEPT); - String acceptGenerationApi = negotiationFromText - ? "A2ATClient.generateNegotiationAcceptPromptFromText" - : "A2ATClient.generateNegotiationAcceptPromptFromData"; - try { - A2ATClient client = new A2ATClient(envPath); - List filledItems = filledItems(fills); - MetadataContent accept = negotiationFromText - ? client.generateNegotiationAcceptPromptFromText( - acceptInputText, acceptContext, DemoTemplates.NEGOTIATION_ACCEPT) - : client.generateNegotiationAcceptPromptFromData( - new NegotiationEndingData( - acceptContext, - new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems)), - DemoTemplates.NEGOTIATION_ACCEPT); - acceptPrompt = accept.promptText(); - Map step = - step("6. client fill + Negotiation-T accept generation (" + negotiationChannel + ")", "client"); - api( - step, - apiCall("A2ATClient.generateTaskPromptFromDataWithSchema", filledGenerationInput), - apiCall(acceptGenerationApi, acceptGenerationInput)); - step.put("client_fill", fills); - step.put("filled_task_prompt", filledPrompt); - step.put("filled_params", filledData); - if (negotiationFromText) { - step.put("input_text", acceptInputText); - } - step.put("generated_prompt", acceptPrompt); - step.put("template_uri", accept.templateUri()); - steps.add(step); - emit("[eval] [6] client fills " + fills.keySet() + ", accept rendered (" + negotiationChannel + ")"); - } catch (A2ATError error) { - steps.add(errorStep( - "6. client fill + Negotiation-T accept generation (" + negotiationChannel + ")", - "accept_generation", - error, - apiCall(acceptGenerationApi, acceptGenerationInput))); - emit("[eval] [6] accept generation FAILED: " + error.getCode() + " " + error.getMessage()); - return finish( - record, - steps, - negotiationTriggered, - actualMissing, - proposeValid, - null, - null, - expectNegotiation, - expectMissing, - expectFillCompletes); - } - - // -- step 7: inbound validation of the accept message; extracted values must match the client fill -- - Map acceptValidationInput = Map.of( - "prompt", - acceptPrompt, - "context", - contextJson(acceptContext), - "schema", - negotiationSchema(slotsToFill, taskSchema), - "template_uri", - DemoTemplates.NEGOTIATION_ACCEPT); - Boolean acceptValid = null; - Boolean fillValueMatch = null; - Map extractedAcceptParams = null; - try { - long nanos = System.nanoTime(); - A2ATServer server = new A2ATServer(envPath); - FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( - acceptPrompt, - acceptContext, - negotiationSchema(slotsToFill, taskSchema), - DemoTemplates.NEGOTIATION_ACCEPT); - acceptValid = true; - extractedAcceptParams = acceptParams.data() == null ? Map.of() : new LinkedHashMap<>(acceptParams.data()); - fillValueMatch = fillValuesMatch(extractedAcceptParams, fills); - Map validation = validationJson("passed", null, null, acceptParams, Set.of()); - validation.put("expected_fill", fills); - validation.put("fill_value_match", fillValueMatch); - Map step = stepOf("7. inbound accept validation", "server", validation); - api(step, apiCall("A2ATServer.validateAcceptPromptAndDataFilling", acceptValidationInput)); - steps.add(step); - emit("[eval] [7] accept validation passed, fill value match: " + fillValueMatch + " (" + secs(nanos) - + "s)"); - } catch (A2ATError error) { - acceptValid = false; - fillValueMatch = false; - List slotErrors = error - instanceof - net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException - extraction - ? extraction.getErrors() - : List.of(); - Map validation = validationJson("rejected", error.getCode(), slotErrors, null, Set.of()); - validation.put("expected_fill", fills); - validation.put("fill_value_match", false); - Map step = stepOf("7. inbound accept validation", "server", validation); - api(step, apiCall("A2ATServer.validateAcceptPromptAndDataFilling", acceptValidationInput)); - steps.add(step); - emit("[eval] [7] accept validation REJECTED: " + error.getCode() + " " + error.getMessage()); - } - - // -- step 8: second Task-T validation of the filled prompt -> COMPLETED or stays INPUT_REQUIRED -- - Map secondValidationInput = Map.of( - "prompt", filledPrompt, - "schema", taskSchema, - "template_uri", taskTemplate); - Boolean fillCompleted = null; - try { - long nanos = System.nanoTime(); - A2ATServer server = new A2ATServer(envPath); - FilledParamData filled = server.validateTaskPromptAndDataFilling(filledPrompt, taskSchema, taskTemplate); - Set stillMissing = requiredMissing(blankSlots(filled), requiredSlots); - fillCompleted = stillMissing.isEmpty(); - Map step = stepOf( - "8. second Task-T validation (fill round)", - "server", - validationJson( - fillCompleted ? "passed" : "rejected", - fillCompleted ? null : "slots_still_missing", - fillCompleted ? null : stillMissingErrors(stillMissing), - filled, - stillMissing)); - api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", secondValidationInput)); - step.put("task_state", fillCompleted ? "COMPLETED" : "INPUT_REQUIRED"); - steps.add(step); - emit("[eval] [8] second Task-T validation " - + (fillCompleted ? "-> COMPLETED" : "still missing: " + stillMissing) + " (" + secs(nanos) + "s)"); - } catch (ContentValidationException error) { - fillCompleted = false; - Map step = stepOf( - "8. second Task-T validation (fill round)", - "server", - validationJson("rejected", error.getCode(), error.errors(), null, errorSlots(error))); - api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", secondValidationInput)); - steps.add(step); - emit("[eval] [8] second Task-T validation REJECTED (" + error.getCode() + "): " + errorSlots(error)); - } - - return finish( - record, - steps, - negotiationTriggered, - actualMissing, - proposeValid, - fillValueMatch, - fillCompleted, - expectNegotiation, - expectMissing, - expectFillCompletes); - } - - // -- record assembly -- - - private static Map finish( - Map record, - List> steps, - Boolean triggered, - Set actualMissing, - Boolean proposeValid, - Boolean fillValueMatch, - Boolean fillCompleted, - boolean expectNegotiation, - Set expectMissing, - Boolean expectFillCompletes) { - boolean error = triggered == null; - boolean triggerCorrect = !error && triggered == expectNegotiation; - boolean slotsCorrect = !error && actualMissing != null && actualMissing.equals(expectMissing); - Boolean proposeValidCorrect = error || proposeValid == null ? null : proposeValid; - // value match is only meaningful for cases whose fill round is expected to succeed; a negative fill case - // (expectFillCompletes=false) is judged by the fill round NOT completing — the accept validation rejecting - // the constraint-violating fill value is exactly the expected outcome, not a mismatch - Boolean fillMatchCorrect = - error || fillValueMatch == null || Boolean.FALSE.equals(expectFillCompletes) ? null : fillValueMatch; - Boolean fillCorrect; - if (error) { - fillCorrect = false; - } else if (expectFillCompletes == null) { - fillCorrect = null; - } else if (Boolean.TRUE.equals(triggered)) { - fillCorrect = expectFillCompletes.equals(fillCompleted); - } else { - fillCorrect = !expectFillCompletes; - } - boolean pass = triggerCorrect - && slotsCorrect - && (proposeValidCorrect == null || proposeValidCorrect) - && (fillMatchCorrect == null || fillMatchCorrect) - && (fillCorrect == null || fillCorrect); - - Map actual = new LinkedHashMap<>(); - actual.put("negotiation_triggered", triggered); - actual.put("missing_slots", actualMissing == null ? null : new ArrayList<>(actualMissing)); - actual.put("propose_outbound_valid", proposeValid); - actual.put("accept_fill_value_match", fillValueMatch); - actual.put("closed_loop_completed", fillCompleted); - record.put("actual", actual); - - Map verdict = new LinkedHashMap<>(); - verdict.put("trigger_correct", triggerCorrect); - verdict.put("slots_correct", slotsCorrect); - verdict.put("propose_outbound_valid", proposeValidCorrect); - verdict.put("accept_fill_value_match", fillMatchCorrect); - verdict.put("fill_completes_correct", fillCorrect); - verdict.put("pass", pass); - verdict.put( - "reason", - reason( - error, - triggerCorrect, - slotsCorrect, - fillMatchCorrect, - fillCorrect, - actualMissing, - expectMissing, - triggered, - expectNegotiation, - proposeValid)); - record.put("verdict", verdict); - - emit("[eval] verdict: " + (pass ? "PASS" : "FAIL") - + " | trigger=" + triggerCorrect + " slots=" + slotsCorrect - + " proposeValid=" + proposeValidCorrect + " fillMatch=" + fillMatchCorrect + " fill=" + fillCorrect); - return record; - } - - private static String reason( - boolean error, - boolean triggerCorrect, - boolean slotsCorrect, - Boolean fillMatchCorrect, - Boolean fillCorrect, - Set actualMissing, - Set expectMissing, - Boolean triggered, - boolean expectNegotiation, - Boolean proposeValid) { - if (error) { - return "evaluation aborted by an error (see steps)"; - } - List notes = new ArrayList<>(); - if (!triggerCorrect) { - notes.add("negotiation expected=" + expectNegotiation + " actual=" + triggered); - } - if (!slotsCorrect && actualMissing != null) { - Set missed = new TreeSet<>(expectMissing); - missed.removeAll(actualMissing); - Set extra = new TreeSet<>(actualMissing); - extra.removeAll(expectMissing); - notes.add("missing-slot set mismatch: expected=" + expectMissing + " actual=" + actualMissing - + (missed.isEmpty() ? "" : " (not reported: " + missed + ")") - + (extra.isEmpty() ? "" : " (unexpected: " + extra + ")")); - } - if (proposeValid != null && !proposeValid) { - notes.add("generated propose failed outbound validation (step 4)"); - } - if (fillMatchCorrect != null && !fillMatchCorrect) { - notes.add("server-side extracted fill values differ from what the client sent (step 7)"); - } - if (fillCorrect != null && !fillCorrect) { - notes.add("fill round did not complete as expected (step 8)"); - } - return String.join("; ", notes); - } - - // -- trace step builders -- - - private static Map step(String name, String role) { - Map step = new LinkedHashMap<>(); - step.put("step", name); - step.put("role", role); - attachLlmCalls(step); - return step; - } - - /** - * Attaches the LLM calls recorded since the previous step was built: the exact messages (system + user prompt) and - * JSON schema fed to the model, plus the response or the failure. When a case fails, this is what makes the cause - * traceable — the recorded prompt shows which slot description, constraint or system instruction the model actually - * saw, so the prompt resources can be tuned in reverse. - */ - private static void attachLlmCalls(Map step) { - List> calls = EvalLlmCaptureClient.drain(); - if (!calls.isEmpty()) { - step.put("llm_calls", calls); - } - } - - /** - * Attaches the SDK API call evidence (method name + input parameters) to a step; one step may make several calls. - */ - @SafeVarargs - private static void api(Map step, Map... calls) { - step.put("api_calls", List.of(calls)); - } - - /** One SDK API invocation record: the exact facade method plus the parameters it was called with. */ - private static Map apiCall(String method, Map input) { - Map call = new LinkedHashMap<>(); - call.put("method", method); - call.put("input", input); - return call; - } - - /** Negotiation context as report JSON (the same object handed to the generate/validate calls). */ - private static Map contextJson(NegotiationContext context) { - Map json = new LinkedHashMap<>(); - json.put("id", context.id()); - json.put("round", context.round()); - json.put("maxRounds", context.maxRounds()); - json.put("performative", context.performative().name()); - return json; - } - - private static Map stepOf(String name, String role, Map validation) { - Map step = step(name, role); - step.put("validation", validation); - return step; - } - - private static Map validationJson( - String outcome, - String errorCode, - List slotErrors, - FilledParamData params, - Set missingSlots) { - Map validation = new LinkedHashMap<>(); - validation.put("outcome", outcome); - if (errorCode != null) { - validation.put("error_code", errorCode); - } - if (slotErrors != null && !slotErrors.isEmpty()) { - List> errors = new ArrayList<>(); - for (SlotValidationError slotError : slotErrors) { - errors.add(Map.of( - "slot", nullToEmpty(slotError.slotName()), - "code", nullToEmpty(slotError.code()), - "message", nullToEmpty(slotError.message()))); - } - validation.put("slot_errors", errors); - } - if (params != null) { - validation.put("extracted_params", params.data() == null ? Map.of() : new LinkedHashMap<>(params.data())); - } - validation.put("missing_slots", missingSlots == null ? List.of() : new ArrayList<>(missingSlots)); - return validation; - } - - private static List stillMissingErrors(Set stillMissing) { - List errors = new ArrayList<>(); - for (String slot : stillMissing) { - errors.add(new SlotValidationError(slot, "slots_still_missing", "slot still missing after the fill round")); - } - return errors; - } - - private static Map errorStep( - String name, String stage, A2ATError error, Map... calls) { - Map step = step(name, "n/a"); - step.put( - "error", - Map.of( - "stage", stage, - "code", error.getCode(), - "message", String.valueOf(error.getMessage()))); - if (calls.length > 0) { - api(step, calls); - } - return step; - } - - private static List> itemsJson(List items) { - List> list = new ArrayList<>(); - for (NegotiationItem item : items) { - list.add(Map.of("name", item.name(), "value", nullToEmpty(item.value()))); - } - return list; - } - - // -- negotiation helpers -- - - /** Missing-item hint from the phrasing template ({@code {slot}}/{@code {description}} placeholders). */ - private static String missingHint(Map phrasing, Map properties, String slot) { - String template = phrasing.getOrDefault("missing_item_hint", "{slot}"); - Object description = null; - Object property = properties.get(slot); - if (property instanceof Map slotSchema - && slotSchema.get("description") instanceof String text - && !text.isBlank()) { - description = text; - } - return template.replace("{slot}", slot) - .replace("{description}", description == null ? "" : String.valueOf(description)); - } - - /** - * Assembles the natural-language propose input for the fromText negotiation channel, mirroring the demo's - * {@code FromTextStrategy}: scenario-configured prefix, numbered item list, relationship sentence. - */ - private static String itemsToProposeText(List items, Map phrasing) { - StringBuilder text = new StringBuilder(phrasing.getOrDefault("from_text_propose_prefix", "")); - appendNumberedItems(text, items); - String relationship = items.size() > 1 ? phrasing.get("propose_relationship") : null; - if (relationship != null && !relationship.isBlank()) { - text.append(relationship); - } - return text.toString(); - } - - /** - * Assembles the natural-language accept input for the fromText negotiation channel: scenario-configured prefix, - * numbered item list, suffix sentence. - */ - private static String itemsToAcceptText(List items, Map phrasing) { - StringBuilder text = new StringBuilder(phrasing.getOrDefault("from_text_accept_prefix", "")); - appendNumberedItems(text, items); - text.append(phrasing.getOrDefault("from_text_accept_suffix", "")); - return text.toString(); - } - - /** Generic numbered-list rule: {@code N. name:value;} per item, value omitted when blank. */ - private static void appendNumberedItems(StringBuilder text, List items) { - for (int i = 0; i < items.size(); i++) { - NegotiationItem item = items.get(i); - text.append(i + 1).append(". ").append(item.name()); - if (item.value() != null && !item.value().isBlank()) { - text.append(":").append(item.value()); - } - text.append(";"); - } - } - - /** - * The client's own structured knowledge for the fill round of a fromText case: the fields its input text quoted. - * The server's round-1 extraction is the fallback when the case carries no client data — a lossy fallback, because - * those params are only produced when round-1 validation passed. - */ - private static Map clientBaseData( - Map testCase, Map extractedRound1) { - Map clientData = asMap(testCase.get("client_data")); - return clientData.isEmpty() ? extractedRound1 : clientData; - } - - /** The supplement values the simulated client supplies, keyed by slot. */ - private static Map fills(Set missingSlots, Map fillValues) { - Map fills = new LinkedHashMap<>(); - for (String slot : missingSlots) { - String value = fillValues.get(slot); - if (value != null && !value.isBlank()) { - fills.put(slot, value); - } - } - return fills; - } - - private static Map mergeFills(Map base, Map override) { - Map merged = new LinkedHashMap<>(base); - merged.putAll(override); - return merged; - } - - private static List filledItems(Map fills) { - List items = new ArrayList<>(); - for (Map.Entry entry : fills.entrySet()) { - items.add(new NegotiationItem(entry.getKey(), entry.getValue())); - } - return items; - } - - /** - * Builds the complete parameter map for the fill round: every slot declared by the task schema carries either the - * round-1 input value or the client's fill value, so the filled Task-T prompt can be regenerated from data. - */ - private static Map mergeInputData( - Map inputData, Map taskSchema, Map fills) { - Map merged = new LinkedHashMap<>(); - Map properties = asMap(taskSchema.get("properties")); - for (String slot : properties.keySet()) { - Object base = inputData.get(slot); - Object value = base == null || (base instanceof String s && s.isBlank()) ? fills.get(slot) : base; - merged.put(slot, value == null ? "" : value); - } - merged.putAll(fills); - return merged; - } - - /** Appends the supplement values to a prompt verbatim; fallback when regeneration is impossible. */ - private static String appendSupplements(String taskPrompt, Map fills) { - StringBuilder filled = new StringBuilder(taskPrompt); - for (Map.Entry entry : fills.entrySet()) { - filled.append(System.lineSeparator()) - .append(entry.getKey()) - .append(":") - .append(entry.getValue()); - } - return filled.toString(); - } - - /** - * Parameter schema for the negotiation-message validations (steps 4/5/7): one property per negotiated slot, derived - * from the task schema so the description and {@code x-a2at-value-constraint} of each slot flow into the validation - * prompt — the extraction surface is exactly the supplemented information, with the same semantic constraints the - * Task-T validation enforces. - */ - private static Map negotiationSchema(Set slots, Map taskSchema) { - Map taskProperties = asMap(taskSchema.get("properties")); - Map properties = new LinkedHashMap<>(); - for (String slot : slots) { - Map property = new LinkedHashMap<>(asMap(taskProperties.get(slot))); - if (property.isEmpty()) { - property.put("type", "string"); - } - properties.put(slot, property); - } - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - schema.put("required", new ArrayList<>(slots)); - return schema; - } - - /** - * Whether every server-extracted fill value matches the value the client sent for that slot. The comparison is - * containment in either direction after whitespace removal: the extracted value may carry the slot's field label - * (e.g. 接入端口名称:P781-…) or only the bare value (e.g. P781-…), both of which faithfully transport the fill. - */ - private static boolean fillValuesMatch(Map extracted, Map fills) { - for (Map.Entry entry : fills.entrySet()) { - Object value = extracted.get(entry.getKey()); - if (value == null) { - return false; - } - String extractedText = normalize(String.valueOf(value)); - String sentText = normalize(entry.getValue()); - if (!extractedText.contains(sentText) && !sentText.contains(extractedText)) { - return false; - } - } - return true; - } - - private static String normalize(String value) { - return value == null ? "" : value.replaceAll("\\s+", ""); - } - - /** Slots whose extracted value is null/blank (negotiation candidates). */ - private static Set blankSlots(FilledParamData params) { - Set blank = new TreeSet<>(); - if (params != null && params.data() != null) { - for (Map.Entry entry : params.data().entrySet()) { - Object value = entry.getValue(); - if (value == null || (value instanceof String s && s.isBlank())) { - blank.add(entry.getKey()); - } - } - } - return blank; - } - - /** - * Restricts missing-slot candidates to the task schema's required slots: an empty optional slot is a valid state - * and never a negotiation trigger — only a required slot that is blank (or flagged by the semantic validator) means - * information the negotiation must ask for. - */ - private static Set requiredMissing(Set candidates, Set requiredSlots) { - Set missing = new TreeSet<>(candidates); - missing.retainAll(requiredSlots); - return missing; - } - - /** Slots reported as errors by the semantic validation. */ - private static Set errorSlots(ContentValidationException error) { - Set slots = new TreeSet<>(); - if (error.errors() != null) { - for (SlotValidationError validationError : error.errors()) { - if (validationError.slotName() != null - && !validationError.slotName().isBlank()) { - slots.add(validationError.slotName()); - } - } - } - return slots; - } - - // -- metrics -- - - private static Map metrics(List> cases) { - int total = cases.size(); - int triggerOk = 0; - int slotsOk = 0; - int proposeEvaluated = 0; - int proposeOk = 0; - int fillMatchEvaluated = 0; - int fillMatchOk = 0; - int loopEvaluated = 0; - int loopOk = 0; - int pass = 0; - for (Map record : cases) { - Map verdict = (Map) record.get("verdict"); - if (Boolean.TRUE.equals(verdict.get("trigger_correct"))) { - triggerOk++; - } - if (Boolean.TRUE.equals(verdict.get("slots_correct"))) { - slotsOk++; - } - if (verdict.get("propose_outbound_valid") != null) { - proposeEvaluated++; - if (Boolean.TRUE.equals(verdict.get("propose_outbound_valid"))) { - proposeOk++; - } - } - if (verdict.get("accept_fill_value_match") != null) { - fillMatchEvaluated++; - if (Boolean.TRUE.equals(verdict.get("accept_fill_value_match"))) { - fillMatchOk++; - } - } - if (verdict.get("fill_completes_correct") != null) { - loopEvaluated++; - if (Boolean.TRUE.equals(verdict.get("fill_completes_correct"))) { - loopOk++; - } - } - if (Boolean.TRUE.equals(verdict.get("pass"))) { - pass++; - } - } - Map metrics = new LinkedHashMap<>(); - metrics.put("cases", total); - metrics.put("trigger_accuracy", ratio("negotiation correctly triggered/held", triggerOk, total)); - metrics.put("missing_slot_exact_match", ratio("missing-slot set exact match", slotsOk, total)); - metrics.put( - "propose_outbound_valid", - ratio("generated propose passes outbound validation", proposeOk, proposeEvaluated)); - metrics.put( - "accept_fill_value_match", - ratio("extracted fill values match client fill", fillMatchOk, fillMatchEvaluated)); - metrics.put("closed_loop_completion", ratio("closed loop completes as expected", loopOk, loopEvaluated)); - metrics.put("full_pass", ratio("all dimensions pass", pass, total)); - return metrics; - } - - private static Map ratio(String label, int part, int total) { - Map ratio = new LinkedHashMap<>(); - ratio.put("label", label); - ratio.put("passed", part); - ratio.put("total", total); - ratio.put("pct", total == 0 ? 0.0 : round1(100.0 * part / total)); - return ratio; - } - - // -- suite loading and small utilities -- - - private static Map loadSuite() { - try (InputStream stream = NegotiationEvalApp.class - .getClassLoader() - .getResourceAsStream("sample/negotiation/eval/eval-suite.json")) { - if (stream == null) { - throw new IllegalStateException("Eval suite resource not found"); - } - return MAPPER.readValue(stream, new TypeReference>() {}); - } catch (IOException exception) { - throw new UncheckedIOException("Failed to read eval suite", exception); - } - } - - /** LLM run configuration from the env file, without the API key. */ - private static Map llmInfo(Path envPath) { - Map values = readEnv(envPath); - Map info = new LinkedHashMap<>(); - info.put("provider", values.getOrDefault("A2AT_LLM_PROVIDER", "")); - info.put("model", values.getOrDefault("A2AT_LLM_MODEL", "")); - info.put("base_url", values.getOrDefault("A2AT_LLM_BASE_URL", "")); - return info; - } - - private static Map readEnv(Path envPath) { - Map values = new LinkedHashMap<>(); - if (envPath == null || !Files.exists(envPath)) { - return values; - } - try { - for (String rawLine : Files.readAllLines(envPath)) { - String line = rawLine.trim(); - if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { - continue; - } - int separator = line.indexOf('='); - values.put( - line.substring(0, separator).trim(), - line.substring(separator + 1).trim()); - } - } catch (IOException exception) { - throw new UncheckedIOException("Failed to read env file: " + envPath, exception); - } - return values; - } - - private static void writeReport(Map report, Path outPath) { - try { - MAPPER.writeValue(outPath.toFile(), report); - } catch (IOException exception) { - throw new UncheckedIOException("Failed to write eval report: " + outPath, exception); - } - } - - private static int countCases(Map suite, List filter, String channelFilter) { - return (int) cases(suite).stream() - .filter(testCase -> filter.isEmpty() || filter.contains(String.valueOf(testCase.get("id")))) - .filter(testCase -> - channelFilter == null || channelFilter.equals(String.valueOf(testCase.get("channel")))) - .count(); - } - - /** Negotiation template URIs used by the closed loop; kept local so the evaluator stays self-contained. */ - private static final class DemoTemplates { - - static final String NEGOTIATION_PROPOSE = "Negotiation-T/information-negotiation/propose/v1"; - - static final String NEGOTIATION_ACCEPT = "Negotiation-T/information-negotiation/accept-reject/v1"; - - private DemoTemplates() {} - } - - @SuppressWarnings("unchecked") - private static List> cases(Map suite) { - Object raw = suite.get("cases"); - return raw instanceof List list ? (List>) raw : List.of(); - } - - @SuppressWarnings("unchecked") - private static Map asMap(Object value) { - return value instanceof Map map ? (Map) map : Map.of(); - } - - private static Map stringMap(Object value) { - Map result = new LinkedHashMap<>(); - if (value instanceof Map map) { - map.forEach((key, val) -> result.put(String.valueOf(key), String.valueOf(val))); - } - return result; - } - - private static List stringList(Object value) { - if (value instanceof List list) { - return list.stream().map(String::valueOf).toList(); - } - return List.of(); - } - - private static String nullToEmpty(Object value) { - return value == null ? "" : String.valueOf(value); - } - - private static double round1(double value) { - return Math.round(value * 10.0) / 10.0; - } - - private static String secs(long startNanos) { - return String.valueOf(round1((System.nanoTime() - startNanos) / 1_000_000_000.0)); - } - - private static void emit(String message) { - System.out.println(message); - } -} +package net.openan.a2at.sample.negotiation.eval; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.server.A2ATServer; + +/** + * Closed-loop evaluator for the negotiation prompt interfaces of the private-line-complaint scenario. + * + *

            Each case of the bundled suite ({@code sample/negotiation/eval/eval-suite.json}) is driven through the negotiation + * interfaces in message order, exactly the way the negotiation demo wires them: + * + *

              + *
            1. Task-T generation — the client value from the Task-T extension metadata is either structured JSON + * ({@code generateTaskPromptFromDataWithSchema}: every data key maps to one slot value, the schema is passed as a + * call parameter) or natural language ({@code generateTaskPromptFromText}, LLM slot extraction); + *
            2. server validation — {@code validateTaskPromptAndDataFilling} extracts the parameters against the passed JSON + * schema; the missing-slot set (blank slots plus semantic {@code content.param_missing} errors) decides whether + * negotiation is triggered; + *
            3. propose generation — for the detected missing slots, {@code generateNegotiationProposePromptFromData} / + * {@code FromText} renders the Negotiation-T information-propose message; + *
            4. outbound propose validation — the server self-checks the generated propose via + * {@code validateProposePromptAndDataFilling} before sending it (expected to pass); + *
            5. inbound propose validation — the client validates the received propose through the symmetric client-side API + * {@code validateProposePromptAndDataFilling} and reads out which slots it is asked to supplement; the fill round + * is driven by this client-side view; + *
            6. client fill + accept — the client merges the round-1 parameters with its fill values, RE-RENDERS the full + * Task-T prompt from the complete parameter map (there is no incremental merge API; the supplement is + * incorporated by re-generating the whole prompt) and renders the Negotiation-T accept; + *
            7. inbound accept validation — {@code validateAcceptPromptAndDataFilling} checks the accept message and extracts + * the supplemented values (expected to pass with the values the client sent); + *
            8. second Task-T validation — {@code validateTaskPromptAndDataFilling} re-validates the filled Task-T prompt; no + * missing slot means the negotiation completed the flow (COMPLETED), any rejection keeps it in INPUT_REQUIRED. + *
            + * + *

            Every intermediate artifact is recorded per case in a JSON report: the generated Task-T prompt, both negotiation + * messages, each validation verdict with extracted parameters and per-slot errors, the client's fill values — and, for + * every SDK call, an {@code api} entry carrying the exact method name plus its input parameters (including the full + * JSON schema passed to generate/validate), so a reviewer can verify from the report alone which interface ran with + * which arguments. A single case can be replayed and audited without re-running it. The report is rewritten after every + * case, so an interrupted run keeps the cases already finished. + * + *

            Usage: + * + *

            {@code
            + * java @a2a-t-sample/target/eval.javaargs.txt [--out eval-report.json] [--case PLC-04] /path/to/.env
            + * }
            + * + * @since 2026-08 + */ +public final class NegotiationEvalApp { + + private static final ObjectMapper MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private NegotiationEvalApp() {} + + /** + * Entry point. + * + * @param args options ({@code --out } report file, {@code --case } repeatable case filter), then the + * {@code .env} path carrying a real LLM API key + */ + public static void main(String[] args) { + Path envPath = null; + Path outPath = Path.of("eval-report.json"); + List caseFilter = new ArrayList<>(); + String negotiationChannelOverride = null; + String channelFilter = null; + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + if ("--out".equals(arg) && i + 1 < args.length) { + outPath = Path.of(args[++i]); + } else if ("--case".equals(arg) && i + 1 < args.length) { + caseFilter.add(args[++i]); + } else if ("--negotiation-channel".equals(arg) && i + 1 < args.length) { + negotiationChannelOverride = args[++i]; + } else if ("--channel".equals(arg) && i + 1 < args.length) { + channelFilter = args[++i]; + } else if (!arg.startsWith("--")) { + envPath = Path.of(arg); + } + } + if (envPath == null) { + System.err.println( + "Usage: java @a2a-t-sample/target/eval.javaargs.txt [--out eval-report.json] [--case PLC-04]" + + " [--channel fromData|fromText] [--negotiation-channel fromData|fromText]" + + " /path/to/.env"); + System.exit(1); + } + + EvalLlmCaptureClient.install(); + Map suite = loadSuite(); + if (negotiationChannelOverride != null) { + // run the whole suite with one negotiation generation channel without duplicating cases + for (Map testCase : cases(suite)) { + testCase.put("negotiation_channel", negotiationChannelOverride); + } + } + + Map report = new LinkedHashMap<>(); + report.put("suite", suite.get("suite")); + report.put("description", suite.get("description")); + report.put("scenario", suite.get("scenario")); + report.put("generated_at", LocalDateTime.now().format(TIMESTAMP)); + report.put("llm", llmInfo(envPath)); + report.put("case_filter", caseFilter); + report.put("channel_filter", channelFilter == null ? "all" : channelFilter); + report.put("negotiation_channel", negotiationChannelOverride == null ? "per-case" : negotiationChannelOverride); + + List> cases = new ArrayList<>(); + report.put("cases", cases); + int index = 0; + int total = countCases(suite, caseFilter, channelFilter); + for (Map testCase : cases(suite)) { + String caseId = String.valueOf(testCase.get("id")); + if (!caseFilter.isEmpty() && !caseFilter.contains(caseId)) { + continue; + } + // evaluate one task-generation channel in isolation: only its cases run, so fromData and + // fromText can be measured (and reported) separately + if (channelFilter != null && !channelFilter.equals(String.valueOf(testCase.get("channel")))) { + continue; + } + index++; + emit("\n[eval] ===== [" + index + "/" + total + "] case " + caseId + ": " + testCase.get("intent") + + " ====="); + long startNanos = System.nanoTime(); + Map record = runCase(envPath, suite, testCase); + record.put("duration_seconds", round1((System.nanoTime() - startNanos) / 1_000_000_000.0)); + cases.add(record); + report.put("metrics", metrics(cases)); + writeReport(report, outPath); + } + + report.put("metrics", metrics(cases)); + writeReport(report, outPath); + Map metrics = metrics(cases); + emit("\n[eval] ===== Negotiation interface correctness metrics ====="); + emit("[eval] cases: " + metrics.get("cases")); + for (String key : List.of( + "trigger_accuracy", + "missing_slot_exact_match", + "propose_outbound_valid", + "accept_fill_value_match", + "closed_loop_completion")) { + emit("[eval] " + metrics.get(key)); + } + emit("[eval] report written to " + outPath.toAbsolutePath()); + } + + // -- one case: the negotiation interface closed loop with full evidence capture -- + + /** Runs one case end to end and returns its replayable trace record. */ + private static Map runCase(Path envPath, Map suite, Map testCase) { + EvalLlmCaptureClient.reset(); + String caseId = String.valueOf(testCase.get("id")); + String channel = String.valueOf(testCase.get("channel")); + boolean fromText = "fromText".equals(channel); + String negotiationChannel = String.valueOf(testCase.getOrDefault("negotiation_channel", "fromData")); + boolean negotiationFromText = "fromText".equals(negotiationChannel); + String inputText = String.valueOf(testCase.get("input_text")); + Map inputData = asMap(testCase.get("input_data")); + + String taskTemplate = String.valueOf(suite.get("template_uri")); + Map taskSchema = asMap(suite.get("task_schema")); + Map phrasing = stringMap(suite.get("negotiation_phrasing")); + Map properties = asMap(taskSchema.get("properties")); + Set requiredSlots = new TreeSet<>(stringList(taskSchema.get("required"))); + Map fillValues = + mergeFills(stringMap(suite.get("client_fill_values")), stringMap(testCase.get("fill_override"))); + + Map expect = asMap(testCase.get("expect")); + boolean expectNegotiation = Boolean.TRUE.equals(expect.get("negotiation_needed")); + Set expectMissing = new TreeSet<>(stringList(expect.get("missing_slots"))); + Boolean expectFillCompletes = (Boolean) expect.get("fill_completes"); + boolean expectGenerationFails = Boolean.TRUE.equals(expect.get("generation_fails")); + + Map record = new LinkedHashMap<>(); + record.put("case", caseId); + record.put("channel", channel); + record.put("negotiation_channel", negotiationChannel); + record.put("intent", testCase.get("intent")); + Map input = new LinkedHashMap<>(); + input.put("fromText", fromText); + if (fromText) { + input.put("text", inputText); + } else { + input.put("data", inputData); + } + record.put("input", input); + record.put("task_schema", taskSchema); + Map expectJson = new LinkedHashMap<>(); + expectJson.put("negotiation_needed", expectNegotiation); + expectJson.put("missing_slots", new ArrayList<>(expectMissing)); + expectJson.put("fill_completes", expectFillCompletes); + expectJson.put("generation_fails", expectGenerationFails); + record.put("expect", expectJson); + List> steps = new ArrayList<>(); + record.put("steps", steps); + + // -- step 1: Task-T prompt generation from the extension metadata value -- + Map taskGenerationInput = new LinkedHashMap<>(); + if (fromText) { + taskGenerationInput.put("text", inputText); + } else { + taskGenerationInput.put("data", inputData); + taskGenerationInput.put("schema", taskSchema); + } + taskGenerationInput.put("template_uri", taskTemplate); + String taskGenerationApi = + fromText ? "A2ATClient.generateTaskPromptFromText" : "A2ATClient.generateTaskPromptFromDataWithSchema"; + String taskPrompt; + try { + long nanos = System.nanoTime(); + A2ATClient client = new A2ATClient(envPath); + MetadataContent taskContent = fromText + ? client.generateTaskPromptFromText(inputText, taskTemplate) + : client.generateTaskPromptFromDataWithSchema(inputData, taskSchema, taskTemplate); + taskPrompt = taskContent.promptText(); + Map step = step("1. Task-T generation (" + channel + ")", "client"); + api(step, apiCall(taskGenerationApi, taskGenerationInput)); + step.put("generated_prompt", taskPrompt); + step.put("template_uri", taskContent.templateUri()); + steps.add(step); + emit("[eval] [1] Task-T prompt generated (" + channel + ", " + secs(nanos) + "s)"); + } catch (A2ATError error) { + steps.add(errorStep( + "1. Task-T generation (" + channel + ")", + "task_generation", + error, + apiCall(taskGenerationApi, taskGenerationInput))); + emit("[eval] [1] Task-T generation FAILED: " + error.getCode() + " " + error.getMessage()); + if (expectGenerationFails) { + // generation-time fail-fast is the expected outcome (e.g. a required slot missing from the + // natural-language input): the task never exists, so negotiation cannot and must not trigger + Map verdict = new LinkedHashMap<>(); + verdict.put("trigger_correct", !expectNegotiation); + verdict.put("slots_correct", expectMissing.isEmpty()); + verdict.put("propose_outbound_valid", null); + verdict.put("accept_fill_value_match", null); + verdict.put("fill_completes_correct", null); + verdict.put("pass", !expectNegotiation && expectMissing.isEmpty()); + verdict.put( + "reason", "generation failed fast as expected: " + error.getCode() + " " + error.getMessage()); + record.put("verdict", verdict); + emit("[eval] verdict: PASS (generation fail-fast as expected)"); + return record; + } + return finish( + record, steps, null, null, null, null, null, expectNegotiation, expectMissing, expectFillCompletes); + } + + // -- step 2: server validation of the Task-T prompt -> missing-slot detection -- + Map taskValidationInput = Map.of( + "prompt", taskPrompt, + "schema", taskSchema, + "template_uri", taskTemplate); + Set actualMissing; + Map extractedRound1 = new LinkedHashMap<>(); + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData params = server.validateTaskPromptAndDataFilling(taskPrompt, taskSchema, taskTemplate); + actualMissing = requiredMissing(blankSlots(params), requiredSlots); + if (params.data() != null) { + extractedRound1.putAll(params.data()); + } + Map step = stepOf( + "2. server Task-T validation (missing-slot detection)", + "server", + validationJson("passed", null, null, params, actualMissing)); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", taskValidationInput)); + steps.add(step); + emit("[eval] [2] validation passed, missing slots: " + actualMissing + " (" + secs(nanos) + "s)"); + } catch (ContentValidationException error) { + actualMissing = requiredMissing(errorSlots(error), requiredSlots); + Map step = stepOf( + "2. server Task-T validation (missing-slot detection)", + "server", + validationJson("rejected", error.getCode(), error.errors(), null, actualMissing)); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", taskValidationInput)); + steps.add(step); + emit("[eval] [2] validation rejected (" + error.getCode() + "), missing slots: " + actualMissing); + } + + boolean negotiationTriggered = !actualMissing.isEmpty(); + if (!negotiationTriggered) { + Map done = step("3. no negotiation needed -> flow completes directly", "server"); + done.put("task_state", "COMPLETED"); + steps.add(done); + emit("[eval] [3] no missing parameters -> COMPLETED without negotiation"); + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + null, + null, + null, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + + // -- step 3: Negotiation-T propose generation from the detected missing slots -- + List missingItems = new ArrayList<>(); + for (String slot : actualMissing) { + missingItems.add(new NegotiationItem(slot, missingHint(phrasing, properties, slot))); + } + String proposeRelationship = missingItems.size() > 1 ? phrasing.get("propose_relationship") : null; + NegotiationContext proposeContext = new NegotiationContext( + UUID.randomUUID().toString(), + 1, + NegotiationContext.DEFAULT_MAX_ROUNDS, + NegotiationPerformative.PROPOSE); + String proposeInputText = itemsToProposeText(missingItems, phrasing); + Map proposeGenerationInput = new LinkedHashMap<>(); + if (negotiationFromText) { + proposeGenerationInput.put("text", proposeInputText); + proposeGenerationInput.put("context", contextJson(proposeContext)); + } else { + Map proposeData = new LinkedHashMap<>(); + proposeData.put("context", contextJson(proposeContext)); + proposeData.put("items", itemsJson(missingItems)); + if (proposeRelationship != null) { + proposeData.put("relationship", proposeRelationship); + } + proposeGenerationInput.put("data", proposeData); + } + proposeGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_PROPOSE); + String proposeGenerationApi = negotiationFromText + ? "A2ATServer.generateNegotiationProposePromptFromText" + : "A2ATServer.generateNegotiationProposePromptFromData"; + String proposePrompt; + try { + A2ATServer server = new A2ATServer(envPath); + MetadataContent propose = negotiationFromText + ? server.generateNegotiationProposePromptFromText( + proposeInputText, proposeContext, DemoTemplates.NEGOTIATION_PROPOSE) + : server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + proposeContext, new InformationProposeContent(missingItems, proposeRelationship)), + DemoTemplates.NEGOTIATION_PROPOSE); + proposePrompt = propose.promptText(); + Map step = + step("3. Negotiation-T propose generation (" + negotiationChannel + ")", "server"); + api(step, apiCall(proposeGenerationApi, proposeGenerationInput)); + step.put("negotiation_items", itemsJson(missingItems)); + if (negotiationFromText) { + step.put("input_text", proposeInputText); + } + step.put("generated_prompt", proposePrompt); + step.put("template_uri", propose.templateUri()); + step.put("task_state", "INPUT_REQUIRED"); + steps.add(step); + emit("[eval] [3] propose rendered for " + actualMissing + " (" + negotiationChannel + + ") -> INPUT_REQUIRED"); + } catch (A2ATError error) { + steps.add(errorStep( + "3. Negotiation-T propose generation (" + negotiationChannel + ")", + "propose_generation", + error, + apiCall(proposeGenerationApi, proposeGenerationInput))); + emit("[eval] [3] propose generation FAILED: " + error.getCode() + " " + error.getMessage()); + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + null, + null, + null, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + + // -- step 4: outbound validation of the generated propose -- + Map proposeValidationInput = Map.of( + "prompt", + proposePrompt, + "context", + contextJson(proposeContext), + "schema", + negotiationSchema(actualMissing, taskSchema), + "template_uri", + DemoTemplates.NEGOTIATION_PROPOSE); + Boolean proposeValid = null; + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData proposeParams = server.validateProposePromptAndDataFilling( + proposePrompt, + proposeContext, + negotiationSchema(actualMissing, taskSchema), + DemoTemplates.NEGOTIATION_PROPOSE); + proposeValid = true; + Map step = stepOf( + "4. outbound propose validation", + "server", + validationJson("passed", null, null, proposeParams, Set.of())); + api(step, apiCall("A2ATServer.validateProposePromptAndDataFilling", proposeValidationInput)); + steps.add(step); + emit("[eval] [4] outbound propose validation passed (" + secs(nanos) + "s)"); + } catch (A2ATError error) { + proposeValid = false; + List slotErrors = error + instanceof + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException + extraction + ? extraction.getErrors() + : List.of(); + Map step = stepOf( + "4. outbound propose validation", + "server", + validationJson("rejected", error.getCode(), slotErrors, null, Set.of())); + api(step, apiCall("A2ATServer.validateProposePromptAndDataFilling", proposeValidationInput)); + steps.add(step); + emit("[eval] [4] outbound propose validation REJECTED: " + error.getCode() + " " + error.getMessage()); + } + + // -- step 5: client validates the INBOUND propose and reads out the requested slots -- + // The SDK exposes the validate* API symmetrically on client and server: the receiving client re-validates + // the negotiation message it got and extracts which slots the server asked it to supplement. The fill round + // below is driven by this client-side view (not by the server's internal missing-slot set), so the report + // shows what a real client would learn from the propose message alone. + Set clientRequestedSlots = new TreeSet<>(); + try { + long nanos = System.nanoTime(); + A2ATClient client = new A2ATClient(envPath); + FilledParamData requested = client.validateProposePromptAndDataFilling( + proposePrompt, + proposeContext, + negotiationSchema(actualMissing, taskSchema), + DemoTemplates.NEGOTIATION_PROPOSE); + if (requested.data() != null) { + for (String key : requested.data().keySet()) { + // the SDK merges the negotiation context (id/round/maxRounds) into the extracted data; only the + // real slot names tell the client what to supplement + if (!"id".equals(key) && !"round".equals(key) && !"maxRounds".equals(key)) { + clientRequestedSlots.add(key); + } + } + } + Map step = stepOf( + "5. client inbound propose validation (requested-slot extraction)", + "client", + validationJson("passed", null, null, requested, Set.of())); + api( + step, + apiCall( + "A2ATClient.validateProposePromptAndDataFilling", + Map.of( + "prompt", + proposePrompt, + "context", + contextJson(proposeContext), + "schema", + negotiationSchema(actualMissing, taskSchema), + "template_uri", + DemoTemplates.NEGOTIATION_PROPOSE))); + step.put("client_requested_slots", new ArrayList<>(clientRequestedSlots)); + steps.add(step); + emit("[eval] [5] client extracted requested slots: " + clientRequestedSlots + " (" + secs(nanos) + "s)"); + } catch (A2ATError error) { + List slotErrors = error + instanceof + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException + extraction + ? extraction.getErrors() + : List.of(); + Map step = stepOf( + "5. client inbound propose validation (requested-slot extraction)", + "client", + validationJson("rejected", error.getCode(), slotErrors, null, Set.of())); + api( + step, + apiCall( + "A2ATClient.validateProposePromptAndDataFilling", + Map.of( + "prompt", + proposePrompt, + "context", + contextJson(proposeContext), + "schema", + negotiationSchema(actualMissing, taskSchema), + "template_uri", + DemoTemplates.NEGOTIATION_PROPOSE))); + step.put("client_requested_slots", List.of()); + steps.add(step); + emit("[eval] [5] client propose validation REJECTED: " + error.getCode() + " " + error.getMessage() + + " -> falling back to the server-side missing-slot set for the fill"); + } + Set slotsToFill = clientRequestedSlots.isEmpty() ? actualMissing : clientRequestedSlots; + + // -- step 6: client fills the requested slots and renders the Negotiation-T accept -- + // the filled Task-T prompt is RE-GENERATED from the complete parameter map, exactly like the demo renders + // message 3's Task-T from the filled params — not text-appended. There is no incremental merge API: the + // client holds the full parameter set (the fromData input, or for fromText the parameters the server + // extracted in round 1), completed with the fill values for everything still missing, and re-renders the + // whole Task-T prompt from that map. + Map fills = fills(slotsToFill, fillValues); + // the fill round re-renders from the CLIENT's own knowledge: for fromData the structured input, for + // fromText the client_data the case carries (what its text quoted) — falling back to the server's + // round-1 extraction, which is only available when that validation passed + Map baseData = fromText ? clientBaseData(testCase, extractedRound1) : inputData; + // only the negotiated slots' fill values enter the merge: round-1 base values are preserved verbatim, + // so the re-rendered prompt carries exactly what the client knew plus what it supplemented + Map filledData = mergeInputData(baseData, taskSchema, fills); + String filledPrompt; + Map filledGenerationInput = Map.of( + "data", filledData, + "schema", taskSchema, + "template_uri", taskTemplate); + try { + A2ATClient client = new A2ATClient(envPath); + filledPrompt = client.generateTaskPromptFromDataWithSchema(filledData, taskSchema, taskTemplate) + .promptText(); + } catch (A2ATError error) { + // a fill value violates a slot constraint: fromData generation fails fast — the closed loop cannot + // complete, which is the expected negative outcome for constraint-violating fills + filledPrompt = appendSupplements(taskPrompt, fills); + Map step = step("6. client fill + Negotiation-T accept generation (fromData)", "client"); + api(step, apiCall("A2ATClient.generateTaskPromptFromDataWithSchema", filledGenerationInput)); + step.put("client_fill", fills); + step.put( + "fill_generation_rejected", + Map.of( + "code", error.getCode(), + "message", String.valueOf(error.getMessage()))); + steps.add(step); + emit("[eval] [6] filled Task-T generation REJECTED (" + error.getCode() + "): " + error.getMessage()); + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + proposeValid, + null, + false, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + String acceptPrompt; + NegotiationContext acceptContext = new NegotiationContext( + UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.ACCEPT); + String acceptInputText = itemsToAcceptText(filledItems(fills), phrasing); + Map acceptGenerationInput = new LinkedHashMap<>(); + if (negotiationFromText) { + acceptGenerationInput.put("text", acceptInputText); + acceptGenerationInput.put("context", contextJson(acceptContext)); + } else { + acceptGenerationInput.put( + "data", + Map.of( + "context", contextJson(acceptContext), + "conclusion", NegotiationConclusion.ACCEPT.toString(), + "items", itemsJson(filledItems(fills)))); + } + acceptGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_ACCEPT); + String acceptGenerationApi = negotiationFromText + ? "A2ATClient.generateNegotiationAcceptPromptFromText" + : "A2ATClient.generateNegotiationAcceptPromptFromData"; + try { + A2ATClient client = new A2ATClient(envPath); + List filledItems = filledItems(fills); + MetadataContent accept = negotiationFromText + ? client.generateNegotiationAcceptPromptFromText( + acceptInputText, acceptContext, DemoTemplates.NEGOTIATION_ACCEPT) + : client.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData( + acceptContext, + new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems)), + DemoTemplates.NEGOTIATION_ACCEPT); + acceptPrompt = accept.promptText(); + Map step = + step("6. client fill + Negotiation-T accept generation (" + negotiationChannel + ")", "client"); + api( + step, + apiCall("A2ATClient.generateTaskPromptFromDataWithSchema", filledGenerationInput), + apiCall(acceptGenerationApi, acceptGenerationInput)); + step.put("client_fill", fills); + step.put("filled_task_prompt", filledPrompt); + step.put("filled_params", filledData); + if (negotiationFromText) { + step.put("input_text", acceptInputText); + } + step.put("generated_prompt", acceptPrompt); + step.put("template_uri", accept.templateUri()); + steps.add(step); + emit("[eval] [6] client fills " + fills.keySet() + ", accept rendered (" + negotiationChannel + ")"); + } catch (A2ATError error) { + steps.add(errorStep( + "6. client fill + Negotiation-T accept generation (" + negotiationChannel + ")", + "accept_generation", + error, + apiCall(acceptGenerationApi, acceptGenerationInput))); + emit("[eval] [6] accept generation FAILED: " + error.getCode() + " " + error.getMessage()); + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + proposeValid, + null, + null, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + + // -- step 7: inbound validation of the accept message; extracted values must match the client fill -- + Map acceptValidationInput = Map.of( + "prompt", + acceptPrompt, + "context", + contextJson(acceptContext), + "schema", + negotiationSchema(slotsToFill, taskSchema), + "template_uri", + DemoTemplates.NEGOTIATION_ACCEPT); + Boolean acceptValid = null; + Boolean fillValueMatch = null; + Map extractedAcceptParams = null; + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( + acceptPrompt, + acceptContext, + negotiationSchema(slotsToFill, taskSchema), + DemoTemplates.NEGOTIATION_ACCEPT); + acceptValid = true; + extractedAcceptParams = acceptParams.data() == null ? Map.of() : new LinkedHashMap<>(acceptParams.data()); + fillValueMatch = fillValuesMatch(extractedAcceptParams, fills); + Map validation = validationJson("passed", null, null, acceptParams, Set.of()); + validation.put("expected_fill", fills); + validation.put("fill_value_match", fillValueMatch); + Map step = stepOf("7. inbound accept validation", "server", validation); + api(step, apiCall("A2ATServer.validateAcceptPromptAndDataFilling", acceptValidationInput)); + steps.add(step); + emit("[eval] [7] accept validation passed, fill value match: " + fillValueMatch + " (" + secs(nanos) + + "s)"); + } catch (A2ATError error) { + acceptValid = false; + fillValueMatch = false; + List slotErrors = error + instanceof + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException + extraction + ? extraction.getErrors() + : List.of(); + Map validation = validationJson("rejected", error.getCode(), slotErrors, null, Set.of()); + validation.put("expected_fill", fills); + validation.put("fill_value_match", false); + Map step = stepOf("7. inbound accept validation", "server", validation); + api(step, apiCall("A2ATServer.validateAcceptPromptAndDataFilling", acceptValidationInput)); + steps.add(step); + emit("[eval] [7] accept validation REJECTED: " + error.getCode() + " " + error.getMessage()); + } + + // -- step 8: second Task-T validation of the filled prompt -> COMPLETED or stays INPUT_REQUIRED -- + Map secondValidationInput = Map.of( + "prompt", filledPrompt, + "schema", taskSchema, + "template_uri", taskTemplate); + Boolean fillCompleted = null; + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData filled = server.validateTaskPromptAndDataFilling(filledPrompt, taskSchema, taskTemplate); + Set stillMissing = requiredMissing(blankSlots(filled), requiredSlots); + fillCompleted = stillMissing.isEmpty(); + Map step = stepOf( + "8. second Task-T validation (fill round)", + "server", + validationJson( + fillCompleted ? "passed" : "rejected", + fillCompleted ? null : "slots_still_missing", + fillCompleted ? null : stillMissingErrors(stillMissing), + filled, + stillMissing)); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", secondValidationInput)); + step.put("task_state", fillCompleted ? "COMPLETED" : "INPUT_REQUIRED"); + steps.add(step); + emit("[eval] [8] second Task-T validation " + + (fillCompleted ? "-> COMPLETED" : "still missing: " + stillMissing) + " (" + secs(nanos) + "s)"); + } catch (ContentValidationException error) { + fillCompleted = false; + Map step = stepOf( + "8. second Task-T validation (fill round)", + "server", + validationJson("rejected", error.getCode(), error.errors(), null, errorSlots(error))); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", secondValidationInput)); + steps.add(step); + emit("[eval] [8] second Task-T validation REJECTED (" + error.getCode() + "): " + errorSlots(error)); + } + + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + proposeValid, + fillValueMatch, + fillCompleted, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + + // -- record assembly -- + + private static Map finish( + Map record, + List> steps, + Boolean triggered, + Set actualMissing, + Boolean proposeValid, + Boolean fillValueMatch, + Boolean fillCompleted, + boolean expectNegotiation, + Set expectMissing, + Boolean expectFillCompletes) { + boolean error = triggered == null; + boolean triggerCorrect = !error && triggered == expectNegotiation; + boolean slotsCorrect = !error && actualMissing != null && actualMissing.equals(expectMissing); + Boolean proposeValidCorrect = error || proposeValid == null ? null : proposeValid; + // value match is only meaningful for cases whose fill round is expected to succeed; a negative fill case + // (expectFillCompletes=false) is judged by the fill round NOT completing — the accept validation rejecting + // the constraint-violating fill value is exactly the expected outcome, not a mismatch + Boolean fillMatchCorrect = + error || fillValueMatch == null || Boolean.FALSE.equals(expectFillCompletes) ? null : fillValueMatch; + Boolean fillCorrect; + if (error) { + fillCorrect = false; + } else if (expectFillCompletes == null) { + fillCorrect = null; + } else if (Boolean.TRUE.equals(triggered)) { + fillCorrect = expectFillCompletes.equals(fillCompleted); + } else { + fillCorrect = !expectFillCompletes; + } + boolean pass = triggerCorrect + && slotsCorrect + && (proposeValidCorrect == null || proposeValidCorrect) + && (fillMatchCorrect == null || fillMatchCorrect) + && (fillCorrect == null || fillCorrect); + + Map actual = new LinkedHashMap<>(); + actual.put("negotiation_triggered", triggered); + actual.put("missing_slots", actualMissing == null ? null : new ArrayList<>(actualMissing)); + actual.put("propose_outbound_valid", proposeValid); + actual.put("accept_fill_value_match", fillValueMatch); + actual.put("closed_loop_completed", fillCompleted); + record.put("actual", actual); + + Map verdict = new LinkedHashMap<>(); + verdict.put("trigger_correct", triggerCorrect); + verdict.put("slots_correct", slotsCorrect); + verdict.put("propose_outbound_valid", proposeValidCorrect); + verdict.put("accept_fill_value_match", fillMatchCorrect); + verdict.put("fill_completes_correct", fillCorrect); + verdict.put("pass", pass); + verdict.put( + "reason", + reason( + error, + triggerCorrect, + slotsCorrect, + fillMatchCorrect, + fillCorrect, + actualMissing, + expectMissing, + triggered, + expectNegotiation, + proposeValid)); + record.put("verdict", verdict); + + emit("[eval] verdict: " + (pass ? "PASS" : "FAIL") + + " | trigger=" + triggerCorrect + " slots=" + slotsCorrect + + " proposeValid=" + proposeValidCorrect + " fillMatch=" + fillMatchCorrect + " fill=" + fillCorrect); + return record; + } + + private static String reason( + boolean error, + boolean triggerCorrect, + boolean slotsCorrect, + Boolean fillMatchCorrect, + Boolean fillCorrect, + Set actualMissing, + Set expectMissing, + Boolean triggered, + boolean expectNegotiation, + Boolean proposeValid) { + if (error) { + return "evaluation aborted by an error (see steps)"; + } + List notes = new ArrayList<>(); + if (!triggerCorrect) { + notes.add("negotiation expected=" + expectNegotiation + " actual=" + triggered); + } + if (!slotsCorrect && actualMissing != null) { + Set missed = new TreeSet<>(expectMissing); + missed.removeAll(actualMissing); + Set extra = new TreeSet<>(actualMissing); + extra.removeAll(expectMissing); + notes.add("missing-slot set mismatch: expected=" + expectMissing + " actual=" + actualMissing + + (missed.isEmpty() ? "" : " (not reported: " + missed + ")") + + (extra.isEmpty() ? "" : " (unexpected: " + extra + ")")); + } + if (proposeValid != null && !proposeValid) { + notes.add("generated propose failed outbound validation (step 4)"); + } + if (fillMatchCorrect != null && !fillMatchCorrect) { + notes.add("server-side extracted fill values differ from what the client sent (step 7)"); + } + if (fillCorrect != null && !fillCorrect) { + notes.add("fill round did not complete as expected (step 8)"); + } + return String.join("; ", notes); + } + + // -- trace step builders -- + + private static Map step(String name, String role) { + Map step = new LinkedHashMap<>(); + step.put("step", name); + step.put("role", role); + attachLlmCalls(step); + return step; + } + + /** + * Attaches the LLM calls recorded since the previous step was built: the exact messages (system + user prompt) and + * JSON schema fed to the model, plus the response or the failure. When a case fails, this is what makes the cause + * traceable — the recorded prompt shows which slot description, constraint or system instruction the model actually + * saw, so the prompt resources can be tuned in reverse. + */ + private static void attachLlmCalls(Map step) { + List> calls = EvalLlmCaptureClient.drain(); + if (!calls.isEmpty()) { + step.put("llm_calls", calls); + } + } + + /** + * Attaches the SDK API call evidence (method name + input parameters) to a step; one step may make several calls. + */ + @SafeVarargs + private static void api(Map step, Map... calls) { + step.put("api_calls", List.of(calls)); + } + + /** One SDK API invocation record: the exact facade method plus the parameters it was called with. */ + private static Map apiCall(String method, Map input) { + Map call = new LinkedHashMap<>(); + call.put("method", method); + call.put("input", input); + return call; + } + + /** Negotiation context as report JSON (the same object handed to the generate/validate calls). */ + private static Map contextJson(NegotiationContext context) { + Map json = new LinkedHashMap<>(); + json.put("id", context.id()); + json.put("round", context.round()); + json.put("maxRounds", context.maxRounds()); + json.put("performative", context.performative().name()); + return json; + } + + private static Map stepOf(String name, String role, Map validation) { + Map step = step(name, role); + step.put("validation", validation); + return step; + } + + private static Map validationJson( + String outcome, + String errorCode, + List slotErrors, + FilledParamData params, + Set missingSlots) { + Map validation = new LinkedHashMap<>(); + validation.put("outcome", outcome); + if (errorCode != null) { + validation.put("error_code", errorCode); + } + if (slotErrors != null && !slotErrors.isEmpty()) { + List> errors = new ArrayList<>(); + for (SlotValidationError slotError : slotErrors) { + errors.add(Map.of( + "slot", nullToEmpty(slotError.slotName()), + "code", nullToEmpty(slotError.code()), + "message", nullToEmpty(slotError.message()))); + } + validation.put("slot_errors", errors); + } + if (params != null) { + validation.put("extracted_params", params.data() == null ? Map.of() : new LinkedHashMap<>(params.data())); + } + validation.put("missing_slots", missingSlots == null ? List.of() : new ArrayList<>(missingSlots)); + return validation; + } + + private static List stillMissingErrors(Set stillMissing) { + List errors = new ArrayList<>(); + for (String slot : stillMissing) { + errors.add(new SlotValidationError(slot, "slots_still_missing", "slot still missing after the fill round")); + } + return errors; + } + + private static Map errorStep( + String name, String stage, A2ATError error, Map... calls) { + Map step = step(name, "n/a"); + step.put( + "error", + Map.of( + "stage", stage, + "code", error.getCode(), + "message", String.valueOf(error.getMessage()))); + if (calls.length > 0) { + api(step, calls); + } + return step; + } + + private static List> itemsJson(List items) { + List> list = new ArrayList<>(); + for (NegotiationItem item : items) { + list.add(Map.of("name", item.name(), "value", nullToEmpty(item.value()))); + } + return list; + } + + // -- negotiation helpers -- + + /** Missing-item hint from the phrasing template ({@code {slot}}/{@code {description}} placeholders). */ + private static String missingHint(Map phrasing, Map properties, String slot) { + String template = phrasing.getOrDefault("missing_item_hint", "{slot}"); + Object description = null; + Object property = properties.get(slot); + if (property instanceof Map slotSchema + && slotSchema.get("description") instanceof String text + && !text.isBlank()) { + description = text; + } + return template.replace("{slot}", slot) + .replace("{description}", description == null ? "" : String.valueOf(description)); + } + + /** + * Assembles the natural-language propose input for the fromText negotiation channel, mirroring the demo's + * {@code FromTextStrategy}: scenario-configured prefix, numbered item list, relationship sentence. + */ + private static String itemsToProposeText(List items, Map phrasing) { + StringBuilder text = new StringBuilder(phrasing.getOrDefault("from_text_propose_prefix", "")); + appendNumberedItems(text, items); + String relationship = items.size() > 1 ? phrasing.get("propose_relationship") : null; + if (relationship != null && !relationship.isBlank()) { + text.append(relationship); + } + return text.toString(); + } + + /** + * Assembles the natural-language accept input for the fromText negotiation channel: scenario-configured prefix, + * numbered item list, suffix sentence. + */ + private static String itemsToAcceptText(List items, Map phrasing) { + StringBuilder text = new StringBuilder(phrasing.getOrDefault("from_text_accept_prefix", "")); + appendNumberedItems(text, items); + text.append(phrasing.getOrDefault("from_text_accept_suffix", "")); + return text.toString(); + } + + /** Generic numbered-list rule: {@code N. name:value;} per item, value omitted when blank. */ + private static void appendNumberedItems(StringBuilder text, List items) { + for (int i = 0; i < items.size(); i++) { + NegotiationItem item = items.get(i); + text.append(i + 1).append(". ").append(item.name()); + if (item.value() != null && !item.value().isBlank()) { + text.append(":").append(item.value()); + } + text.append(";"); + } + } + + /** + * The client's own structured knowledge for the fill round of a fromText case: the fields its input text quoted. + * The server's round-1 extraction is the fallback when the case carries no client data — a lossy fallback, because + * those params are only produced when round-1 validation passed. + */ + private static Map clientBaseData( + Map testCase, Map extractedRound1) { + Map clientData = asMap(testCase.get("client_data")); + return clientData.isEmpty() ? extractedRound1 : clientData; + } + + /** The supplement values the simulated client supplies, keyed by slot. */ + private static Map fills(Set missingSlots, Map fillValues) { + Map fills = new LinkedHashMap<>(); + for (String slot : missingSlots) { + String value = fillValues.get(slot); + if (value != null && !value.isBlank()) { + fills.put(slot, value); + } + } + return fills; + } + + private static Map mergeFills(Map base, Map override) { + Map merged = new LinkedHashMap<>(base); + merged.putAll(override); + return merged; + } + + private static List filledItems(Map fills) { + List items = new ArrayList<>(); + for (Map.Entry entry : fills.entrySet()) { + items.add(new NegotiationItem(entry.getKey(), entry.getValue())); + } + return items; + } + + /** + * Builds the complete parameter map for the fill round: every slot declared by the task schema carries either the + * round-1 input value or the client's fill value, so the filled Task-T prompt can be regenerated from data. + */ + private static Map mergeInputData( + Map inputData, Map taskSchema, Map fills) { + Map merged = new LinkedHashMap<>(); + Map properties = asMap(taskSchema.get("properties")); + for (String slot : properties.keySet()) { + Object base = inputData.get(slot); + Object value = base == null || (base instanceof String s && s.isBlank()) ? fills.get(slot) : base; + merged.put(slot, value == null ? "" : value); + } + merged.putAll(fills); + return merged; + } + + /** Appends the supplement values to a prompt verbatim; fallback when regeneration is impossible. */ + private static String appendSupplements(String taskPrompt, Map fills) { + StringBuilder filled = new StringBuilder(taskPrompt); + for (Map.Entry entry : fills.entrySet()) { + filled.append(System.lineSeparator()) + .append(entry.getKey()) + .append(":") + .append(entry.getValue()); + } + return filled.toString(); + } + + /** + * Parameter schema for the negotiation-message validations (steps 4/5/7): one property per negotiated slot, derived + * from the task schema so the description and {@code x-a2at-value-constraint} of each slot flow into the validation + * prompt — the extraction surface is exactly the supplemented information, with the same semantic constraints the + * Task-T validation enforces. + */ + private static Map negotiationSchema(Set slots, Map taskSchema) { + Map taskProperties = asMap(taskSchema.get("properties")); + Map properties = new LinkedHashMap<>(); + for (String slot : slots) { + Map property = new LinkedHashMap<>(asMap(taskProperties.get(slot))); + if (property.isEmpty()) { + property.put("type", "string"); + } + properties.put(slot, property); + } + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + schema.put("required", new ArrayList<>(slots)); + return schema; + } + + /** + * Whether every server-extracted fill value matches the value the client sent for that slot. The comparison is + * containment in either direction after whitespace removal: the extracted value may carry the slot's field label + * (e.g. 接入端口名称:P781-…) or only the bare value (e.g. P781-…), both of which faithfully transport the fill. + */ + private static boolean fillValuesMatch(Map extracted, Map fills) { + for (Map.Entry entry : fills.entrySet()) { + Object value = extracted.get(entry.getKey()); + if (value == null) { + return false; + } + String extractedText = normalize(String.valueOf(value)); + String sentText = normalize(entry.getValue()); + if (!extractedText.contains(sentText) && !sentText.contains(extractedText)) { + return false; + } + } + return true; + } + + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("\\s+", ""); + } + + /** Slots whose extracted value is null/blank (negotiation candidates). */ + private static Set blankSlots(FilledParamData params) { + Set blank = new TreeSet<>(); + if (params != null && params.data() != null) { + for (Map.Entry entry : params.data().entrySet()) { + Object value = entry.getValue(); + if (value == null || (value instanceof String s && s.isBlank())) { + blank.add(entry.getKey()); + } + } + } + return blank; + } + + /** + * Restricts missing-slot candidates to the task schema's required slots: an empty optional slot is a valid state + * and never a negotiation trigger — only a required slot that is blank (or flagged by the semantic validator) means + * information the negotiation must ask for. + */ + private static Set requiredMissing(Set candidates, Set requiredSlots) { + Set missing = new TreeSet<>(candidates); + missing.retainAll(requiredSlots); + return missing; + } + + /** Slots reported as errors by the semantic validation. */ + private static Set errorSlots(ContentValidationException error) { + Set slots = new TreeSet<>(); + if (error.errors() != null) { + for (SlotValidationError validationError : error.errors()) { + if (validationError.slotName() != null + && !validationError.slotName().isBlank()) { + slots.add(validationError.slotName()); + } + } + } + return slots; + } + + // -- metrics -- + + private static Map metrics(List> cases) { + int total = cases.size(); + int triggerOk = 0; + int slotsOk = 0; + int proposeEvaluated = 0; + int proposeOk = 0; + int fillMatchEvaluated = 0; + int fillMatchOk = 0; + int loopEvaluated = 0; + int loopOk = 0; + int pass = 0; + for (Map record : cases) { + Map verdict = (Map) record.get("verdict"); + if (Boolean.TRUE.equals(verdict.get("trigger_correct"))) { + triggerOk++; + } + if (Boolean.TRUE.equals(verdict.get("slots_correct"))) { + slotsOk++; + } + if (verdict.get("propose_outbound_valid") != null) { + proposeEvaluated++; + if (Boolean.TRUE.equals(verdict.get("propose_outbound_valid"))) { + proposeOk++; + } + } + if (verdict.get("accept_fill_value_match") != null) { + fillMatchEvaluated++; + if (Boolean.TRUE.equals(verdict.get("accept_fill_value_match"))) { + fillMatchOk++; + } + } + if (verdict.get("fill_completes_correct") != null) { + loopEvaluated++; + if (Boolean.TRUE.equals(verdict.get("fill_completes_correct"))) { + loopOk++; + } + } + if (Boolean.TRUE.equals(verdict.get("pass"))) { + pass++; + } + } + Map metrics = new LinkedHashMap<>(); + metrics.put("cases", total); + metrics.put("trigger_accuracy", ratio("negotiation correctly triggered/held", triggerOk, total)); + metrics.put("missing_slot_exact_match", ratio("missing-slot set exact match", slotsOk, total)); + metrics.put( + "propose_outbound_valid", + ratio("generated propose passes outbound validation", proposeOk, proposeEvaluated)); + metrics.put( + "accept_fill_value_match", + ratio("extracted fill values match client fill", fillMatchOk, fillMatchEvaluated)); + metrics.put("closed_loop_completion", ratio("closed loop completes as expected", loopOk, loopEvaluated)); + metrics.put("full_pass", ratio("all dimensions pass", pass, total)); + return metrics; + } + + private static Map ratio(String label, int part, int total) { + Map ratio = new LinkedHashMap<>(); + ratio.put("label", label); + ratio.put("passed", part); + ratio.put("total", total); + ratio.put("pct", total == 0 ? 0.0 : round1(100.0 * part / total)); + return ratio; + } + + // -- suite loading and small utilities -- + + private static Map loadSuite() { + try (InputStream stream = NegotiationEvalApp.class + .getClassLoader() + .getResourceAsStream("sample/negotiation/eval/eval-suite.json")) { + if (stream == null) { + throw new IllegalStateException("Eval suite resource not found"); + } + return MAPPER.readValue(stream, new TypeReference>() {}); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to read eval suite", exception); + } + } + + /** LLM run configuration from the env file, without the API key. */ + private static Map llmInfo(Path envPath) { + Map values = readEnv(envPath); + Map info = new LinkedHashMap<>(); + info.put("provider", values.getOrDefault("A2AT_LLM_PROVIDER", "")); + info.put("model", values.getOrDefault("A2AT_LLM_MODEL", "")); + info.put("base_url", values.getOrDefault("A2AT_LLM_BASE_URL", "")); + return info; + } + + private static Map readEnv(Path envPath) { + Map values = new LinkedHashMap<>(); + if (envPath == null || !Files.exists(envPath)) { + return values; + } + try { + for (String rawLine : Files.readAllLines(envPath)) { + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { + continue; + } + int separator = line.indexOf('='); + values.put( + line.substring(0, separator).trim(), + line.substring(separator + 1).trim()); + } + } catch (IOException exception) { + throw new UncheckedIOException("Failed to read env file: " + envPath, exception); + } + return values; + } + + private static void writeReport(Map report, Path outPath) { + try { + MAPPER.writeValue(outPath.toFile(), report); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to write eval report: " + outPath, exception); + } + } + + private static int countCases(Map suite, List filter, String channelFilter) { + return (int) cases(suite).stream() + .filter(testCase -> filter.isEmpty() || filter.contains(String.valueOf(testCase.get("id")))) + .filter(testCase -> + channelFilter == null || channelFilter.equals(String.valueOf(testCase.get("channel")))) + .count(); + } + + /** Negotiation template URIs used by the closed loop; kept local so the evaluator stays self-contained. */ + private static final class DemoTemplates { + + static final String NEGOTIATION_PROPOSE = "Negotiation-T/information-negotiation/propose/v1"; + + static final String NEGOTIATION_ACCEPT = "Negotiation-T/information-negotiation/accept-reject/v1"; + + private DemoTemplates() {} + } + + @SuppressWarnings("unchecked") + private static List> cases(Map suite) { + Object raw = suite.get("cases"); + return raw instanceof List list ? (List>) raw : List.of(); + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object value) { + return value instanceof Map map ? (Map) map : Map.of(); + } + + private static Map stringMap(Object value) { + Map result = new LinkedHashMap<>(); + if (value instanceof Map map) { + map.forEach((key, val) -> result.put(String.valueOf(key), String.valueOf(val))); + } + return result; + } + + private static List stringList(Object value) { + if (value instanceof List list) { + return list.stream().map(String::valueOf).toList(); + } + return List.of(); + } + + private static String nullToEmpty(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static double round1(double value) { + return Math.round(value * 10.0) / 10.0; + } + + private static String secs(long startNanos) { + return String.valueOf(round1((System.nanoTime() - startNanos) / 1_000_000_000.0)); + } + + private static void emit(String message) { + System.out.println(message); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java index 959c7821..38ad5dac 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java @@ -1,555 +1,555 @@ -package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; -import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.server.A2ATServer; - -/** Runs deterministic abnormal-input checks against all six Negotiation-T facade APIs and core pipelines. */ -public final class NegotiationAbnormalEvaluationMain { - - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); - private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); - private static final List NEGOTIATION_ERROR_CODES = List.of( - "template.not_found", - "negotiation.semantic_rejected", - "negotiation.field_missing", - "negotiation.invalid_input", - "negotiation.rule_violation", - "llm.invocation_failed", - "llm.response_invalid"); - - private NegotiationAbnormalEvaluationMain() {} - - public static void main(String[] args) throws IOException { - Path envPath = args.length > 0 - ? Path.of(args[0]) - : Path.of( - "a2a-t-sample", - "src", - "main", - "resources", - "sample", - "private-line-complaint-negotiation", - "qwen.env"); - String suffix = LocalDateTime.now(ZoneId.systemDefault()).format(FILE_TIMESTAMP); - Path reportPath = args.length > 1 - ? Path.of(args[1]) - : Path.of("a2a-t-sample", "target", "negotiation-abnormal-report-" + suffix + ".json"); - Path processLogPath = args.length > 2 - ? Path.of(args[2]) - : Path.of("a2a-t-sample", "target", "negotiation-abnormal-process-log-" + suffix + ".jsonl"); - - A2ATClient client = new A2ATClient(envPath); - A2ATServer server = new A2ATServer(envPath); - String runId = UUID.randomUUID().toString(); - List> results = new ArrayList<>(); - try (NegotiationEvaluationProcessLogger logger = - new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, processLogPath)) { - logger.write(event( - "run_started", - runId, - null, - Map.of( - "env_path", - envPath.toAbsolutePath().toString(), - "case_count", - NegotiationAbnormalEvaluationCaseLoader.load().size() - + scriptedCases().size()), - null, - null)); - for (NegotiationAbnormalEvaluationCase testCase : NegotiationAbnormalEvaluationCaseLoader.load()) { - results.add(runCase(client, server, testCase, runId, logger)); - } - for (ScriptedAbnormalCase testCase : scriptedCases()) { - results.add(runScriptedCase(testCase, runId, logger)); - } - } - - long passed = results.stream() - .filter(result -> Boolean.TRUE.equals(result.get("passed"))) - .count(); - Map report = new LinkedHashMap<>(); - report.put("generated_at", Instant.now().toString()); - report.put("run_id", runId); - report.put("git_revision", gitRevision()); - report.put("total", results.size()); - report.put("passed", passed); - report.put("failed", results.size() - passed); - report.put("success_rate", results.isEmpty() ? 1.0 : (double) passed / results.size()); - report.put("definition", "异常用例在预期 SDK 异常发生且错误类型/错误码匹配时判定通过;门面用例使用边界输入,脚本化用例注入固定 LLM 响应,不访问外部 LLM。"); - report.put("error_code_coverage", errorCodeCoverage(results)); - report.put("process_log", processLogPath.toAbsolutePath().toString()); - report.put("cases", results); - Path parent = reportPath.toAbsolutePath().getParent(); - if (parent != null) { - Files.createDirectories(parent); - } - OBJECT_MAPPER.writeValue(reportPath.toFile(), report); - System.out.printf( - "Abnormal negotiation evaluation complete: %d/%d passed; report=%s; process-log=%s%n", - passed, results.size(), reportPath.toAbsolutePath(), processLogPath.toAbsolutePath()); - } - - private static Map errorCodeCoverage(List> results) { - Map counts = new LinkedHashMap<>(); - NEGOTIATION_ERROR_CODES.forEach(code -> counts.put(code, 0)); - Set observed = new LinkedHashSet<>(); - for (Map result : results) { - Object error = result.get("actual_error"); - if (error instanceof Map errorMap && errorMap.get("code") instanceof String code) { - observed.add(code); - if (counts.containsKey(code)) { - counts.computeIfPresent(code, (ignored, count) -> count + 1); - } - } - } - List missing = NEGOTIATION_ERROR_CODES.stream() - .filter(code -> counts.get(code) == 0) - .toList(); - List unexpected = - observed.stream().filter(code -> !counts.containsKey(code)).toList(); - Map coverage = new LinkedHashMap<>(); - coverage.put("expected_codes", NEGOTIATION_ERROR_CODES); - coverage.put("observed_counts", counts); - coverage.put("missing_codes", missing); - coverage.put("unexpected_codes", unexpected); - coverage.put("all_expected_codes_covered", missing.isEmpty()); - return coverage; - } - - private static Map runCase( - A2ATClient client, - A2ATServer server, - NegotiationAbnormalEvaluationCase testCase, - String runId, - NegotiationEvaluationProcessLogger logger) - throws IOException { - long startedAt = System.nanoTime(); - NegotiationPerformative performative = NegotiationPerformative.valueOf(testCase.performative()); - NegotiationContext context = contextFor(testCase.contextMode(), performative); - String templateUri = templateFor(testCase.template()); - Map request = new LinkedHashMap<>(); - if (testCase.api().startsWith("generate_")) { - request.put("text", testCase.input()); - } else { - request.put("prompt", testCase.input()); - request.put("schema", schemaFor(testCase.api(), testCase.schemaMode())); - } - request.put("template_uri", templateUri); - request.put("context", context == null ? null : contextPayload(context)); - Map result = new LinkedHashMap<>(); - result.put("id", testCase.id()); - result.put("description", testCase.description()); - result.put("api", apiMethod(testCase.api())); - result.put("expected_exception", testCase.expectedException()); - result.put("expected_code", testCase.expectedCode()); - result.put("request", request); - try { - Object response = invoke(client, server, testCase, context, templateUri); - result.put("outcome", "unexpected_success"); - result.put("response", responseSummary(response)); - result.put("passed", false); - logger.write(event( - "abnormal_case", runId, testCase.id(), request, Map.of("outcome", "unexpected_success"), result)); - } catch (RuntimeException exception) { - Map actualError = errorDetails(exception); - boolean classMatched = testCase.expectedException().equals(actualError.get("class")); - boolean codeMatched = - testCase.expectedCode() == null || testCase.expectedCode().equals(actualError.get("code")); - result.put("actual_error", actualError); - result.put("passed", classMatched && codeMatched); - result.put("outcome", classMatched && codeMatched ? "expected_failure" : "unexpected_failure"); - logger.write(event("abnormal_case", runId, testCase.id(), request, null, result)); - } - result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); - return result; - } - - private static List scriptedCases() { - return List.of( - new ScriptedAbnormalCase( - "G-PROPOSE-LLM-MALFORMED", - "generate_propose", - "请补充接入端口名称和投诉分类。", - "not-json", - false, - "NegotiationGenerationException", - "llm.response_invalid"), - new ScriptedAbnormalCase( - "G-ACCEPT-LLM-INFRA", - "generate_accept", - "接受协商,端口为 PE1-GZ-ETH-0/0/0.200,投诉分类为专线中断。", - "throw", - false, - "NegotiationGenerationException", - "llm.invocation_failed"), - new ScriptedAbnormalCase( - "G-REJECT-SLOT-MISSING", - "generate_reject", - "拒绝协商,原因是资源系统暂时无法查询。", - "{\"conclusion\":\"Reject\",\"items\":null}", - false, - "NegotiationGenerationException", - "negotiation.field_missing"), - new ScriptedAbnormalCase( - "V-PROPOSE-SEMANTIC-REJECTED", - "validate_propose", - "## 信息协商\n请根据<所需信息项>补充相关内容。", - semanticRejectedPayload(), - false, - "NegotiationParamExtractionException", - "negotiation.semantic_rejected"), - new ScriptedAbnormalCase( - "V-ACCEPT-LLM-INFRA", - "validate_accept", - "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", - "throw", - false, - "NegotiationParamExtractionException", - "llm.invocation_failed"), - new ScriptedAbnormalCase( - "V-REJECT-SEMANTIC-REJECTED", - "validate_reject", - "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", - semanticRejectedPayload(), - false, - "NegotiationParamExtractionException", - "negotiation.semantic_rejected"), - new ScriptedAbnormalCase( - "G-PROPOSE-TEMPLATE-NOT-FOUND", - "generate_propose", - "请补充接入端口名称和投诉分类。", - "unused", - true, - "NegotiationGenerationException", - "template.not_found"), - new ScriptedAbnormalCase( - "V-PROPOSE-TEMPLATE-NOT-FOUND", - "validate_propose", - "## 信息协商\n请根据<所需信息项>补充相关内容。", - "unused", - true, - "NegotiationParamExtractionException", - "template.not_found")); - } - - private static String semanticRejectedPayload() { - return "{\"semantic_verdict\":false,\"negotiation_type\":\"information\"," - + "\"errors\":[{\"slot_name\":\"section.info_result_content\"," - + "\"code\":\"negotiation.missing_result_content\"," - + "\"facts\":{\"section_label\":\"信息协商结果内容\"}}]," - + "\"params\":{}}"; - } - - private static Map runScriptedCase( - ScriptedAbnormalCase testCase, String runId, NegotiationEvaluationProcessLogger logger) throws IOException { - long startedAt = System.nanoTime(); - NegotiationPerformative performative = testCase.api().contains("accept") - ? NegotiationPerformative.ACCEPT - : testCase.api().contains("reject") ? NegotiationPerformative.REJECT : NegotiationPerformative.PROPOSE; - NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); - String template = testCase.api().contains("propose") - ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI - : NegotiationSampleFlow.ENDING_TEMPLATE_URI; - ScriptedLlmClient llm = new ScriptedLlmClient(testCase.payload()); - NegotiationGenerationOrchestratorBuilder builder = NegotiationGenerationOrchestratorBuilder.builder() - .language("zh-CN") - .llmClient(llm) - .maxAttempts(1); - if (testCase.missingTemplate()) { - builder.templateLoader(new MissingTemplateLoader()); - } - NegotiationGenerationOrchestrator orchestrator = builder.build(); - Map request = new LinkedHashMap<>(); - if (testCase.api().startsWith("generate_")) { - request.put("text", testCase.input()); - } else { - request.put("prompt", testCase.input()); - request.put("schema", schemaFor(testCase.api(), null)); - } - request.put("context", contextPayload(context)); - request.put("template_uri", template); - Map result = new LinkedHashMap<>(); - result.put("id", testCase.id()); - result.put("api", scriptedApiMethod(testCase.api())); - result.put("request", request); - result.put("expected_exception", testCase.expectedException()); - result.put("expected_code", testCase.expectedCode()); - try { - Object response = invoke(orchestrator, testCase, context, parseTemplateUri(template)); - result.put("response", responseSummary(response)); - result.put("outcome", "unexpected_success"); - result.put("passed", false); - logger.write( - event("abnormal_scripted_case", runId, testCase.id(), request, responseSummary(response), result)); - } catch (RuntimeException exception) { - Map actualError = errorDetails(exception); - boolean matched = testCase.expectedException().equals(actualError.get("class")) - && testCase.expectedCode().equals(actualError.get("code")); - result.put("actual_error", actualError); - result.put("outcome", matched ? "expected_failure" : "unexpected_failure"); - result.put("passed", matched); - logger.write(event("abnormal_scripted_case", runId, testCase.id(), request, null, result)); - } - result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); - return result; - } - - private static Object invoke( - NegotiationGenerationOrchestrator orchestrator, - ScriptedAbnormalCase testCase, - NegotiationContext context, - TemplateUri template) { - return switch (testCase.api()) { - case "generate_propose" -> orchestrator.generateProposeFromText(testCase.input(), context, template); - case "generate_accept" -> orchestrator.generateAcceptFromText(testCase.input(), context, template); - case "generate_reject" -> orchestrator.generateRejectFromText(testCase.input(), context, template); - case "validate_propose" -> orchestrator.validateProposePromptAndDataFilling( - testCase.input(), context, InformationNegotiationSchemas.propose(), template); - case "validate_accept" -> orchestrator.validateAcceptPromptAndDataFilling( - testCase.input(), context, InformationNegotiationSchemas.accept(), template); - case "validate_reject" -> orchestrator.validateRejectPromptAndDataFilling( - testCase.input(), context, InformationNegotiationSchemas.reject(), template); - default -> throw new IllegalArgumentException("Unknown scripted abnormal API: " + testCase.api()); - }; - } - - private static Object invoke( - A2ATClient client, - A2ATServer server, - NegotiationAbnormalEvaluationCase testCase, - NegotiationContext context, - String templateUri) { - return switch (testCase.api()) { - case "generate_propose" -> client.generateNegotiationProposePromptFromText( - testCase.input(), context, templateUri); - case "validate_propose" -> server.validateProposePromptAndDataFilling( - testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); - case "generate_accept" -> server.generateNegotiationAcceptPromptFromText( - testCase.input(), context, templateUri); - case "validate_accept" -> client.validateAcceptPromptAndDataFilling( - testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); - case "generate_reject" -> server.generateNegotiationRejectPromptFromText( - testCase.input(), context, templateUri); - case "validate_reject" -> client.validateRejectPromptAndDataFilling( - testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); - default -> throw new IllegalArgumentException("Unknown abnormal evaluation API: " + testCase.api()); - }; - } - - private static Map schemaFor(String api, String mode) { - if ("null".equalsIgnoreCase(mode)) { - return null; - } - return switch (api) { - case "validate_propose" -> InformationNegotiationSchemas.propose(); - case "validate_accept" -> InformationNegotiationSchemas.accept(); - case "validate_reject" -> InformationNegotiationSchemas.reject(); - default -> Map.of(); - }; - } - - private static NegotiationContext contextFor(String mode, NegotiationPerformative performative) { - if ("null".equalsIgnoreCase(mode)) { - return null; - } - if ("invalid_id".equalsIgnoreCase(mode)) { - return new NegotiationContext("not-a-uuid", 1, 3, performative); - } - if ("exhausted".equalsIgnoreCase(mode)) { - return new NegotiationContext(UUID.randomUUID().toString(), 4, 3, performative); - } - return new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); - } - - private static String templateFor(String template) { - return switch (template == null ? "null" : template.toLowerCase(java.util.Locale.ROOT)) { - case "null" -> null; - case "propose" -> NegotiationSampleFlow.PROPOSE_TEMPLATE_URI; - case "ending" -> NegotiationSampleFlow.ENDING_TEMPLATE_URI; - case "unknown_propose" -> "Negotiation-T/information-negotiation/propose/v999"; - case "unknown_ending" -> "Negotiation-T/information-negotiation/accept-reject/v999"; - default -> throw new IllegalArgumentException("Unknown abnormal evaluation template: " + template); - }; - } - - /** Parses a template URI string for the typed {@code NegotiationGenerationOrchestrator} seam. */ - private static TemplateUri parseTemplateUri(String templateUri) { - return TemplateUri.parse(templateUri) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); - } - - private static String apiMethod(String api) { - return switch (api) { - case "generate_propose" -> "A2ATClient.generateNegotiationProposePromptFromText"; - case "validate_propose" -> "A2ATServer.validateProposePromptAndDataFilling"; - case "generate_accept" -> "A2ATServer.generateNegotiationAcceptPromptFromText"; - case "validate_accept" -> "A2ATClient.validateAcceptPromptAndDataFilling"; - case "generate_reject" -> "A2ATServer.generateNegotiationRejectPromptFromText"; - case "validate_reject" -> "A2ATClient.validateRejectPromptAndDataFilling"; - default -> api; - }; - } - - private static Map responseSummary(Object response) { - Map summary = new LinkedHashMap<>(); - if (response instanceof MetadataContent metadata) { - summary.put("type", "MetadataContent"); - summary.put("prompt", metadata.promptText()); - summary.put("template_uri", metadata.templateUri()); - } else if (response instanceof FilledParamData filled) { - summary.put("type", "FilledParamData"); - summary.put("data", filled.data()); - } else { - summary.put("type", response == null ? "null" : response.getClass().getName()); - } - return summary; - } - - private static Map event( - String stage, - String runId, - String caseId, - Map request, - Map response, - Map result) { - Map event = new LinkedHashMap<>(); - event.put("timestamp", Instant.now().toString()); - event.put("run_id", runId); - event.put("stage", stage); - if (result != null && result.get("api") != null) { - event.put("api", result.get("api")); - } - if (caseId != null) { - event.put("case_id", caseId); - } - event.put("request", request); - if (response != null) { - event.put("response", response); - } - if (result != null) { - event.put("result", result); - } - return event; - } - - private static Map errorDetails(RuntimeException exception) { - Map error = new LinkedHashMap<>(); - error.put("class", exception.getClass().getSimpleName()); - error.put("message", exception.getMessage()); - if (exception instanceof A2ATError a2atError) { - error.put("code", a2atError.getCode()); - } - return error; - } - - private static Map contextPayload(NegotiationContext context) { - return Map.of( - "id", context.id(), - "round", context.round(), - "maxRounds", context.maxRounds(), - "performative", context.performative().name()); - } - - private static String scriptedApiMethod(String api) { - return switch (api) { - case "generate_propose" -> "NegotiationGenerationOrchestrator.generateProposeFromText"; - case "generate_accept" -> "NegotiationGenerationOrchestrator.generateAcceptFromText"; - case "generate_reject" -> "NegotiationGenerationOrchestrator.generateRejectFromText"; - case "validate_propose" -> "NegotiationGenerationOrchestrator.validateProposePromptAndDataFilling"; - case "validate_accept" -> "NegotiationGenerationOrchestrator.validateAcceptPromptAndDataFilling"; - case "validate_reject" -> "NegotiationGenerationOrchestrator.validateRejectPromptAndDataFilling"; - default -> api; - }; - } - - private record ScriptedAbnormalCase( - String id, - String api, - String input, - String payload, - boolean missingTemplate, - String expectedException, - String expectedCode) {} - - private static final class ScriptedLlmClient implements LLMClient { - - private final String payload; - - private ScriptedLlmClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - if ("throw".equals(payload)) { - throw new LLMRuntimeError("scripted LLM infrastructure failure"); - } - return new LLMResponse( - payload, "sample-scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } - - private static final class MissingTemplateLoader implements NegotiationTemplateLoader { - - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - } - - private static String gitRevision() { - try { - Process process = new ProcessBuilder("git", "rev-parse", "HEAD") - .redirectErrorStream(true) - .start(); - String revision; - try (var input = process.getInputStream()) { - revision = new String(input.readAllBytes(), StandardCharsets.UTF_8).trim(); - } - return process.waitFor() == 0 && !revision.isBlank() ? revision : "unknown"; - } catch (IOException exception) { - return "unknown"; - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - return "unknown"; - } - } -} +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.server.A2ATServer; + +/** Runs deterministic abnormal-input checks against all six Negotiation-T facade APIs and core pipelines. */ +public final class NegotiationAbnormalEvaluationMain { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + private static final List NEGOTIATION_ERROR_CODES = List.of( + "template.not_found", + "negotiation.semantic_rejected", + "negotiation.field_missing", + "negotiation.invalid_input", + "negotiation.rule_violation", + "llm.invocation_failed", + "llm.response_invalid"); + + private NegotiationAbnormalEvaluationMain() {} + + public static void main(String[] args) throws IOException { + Path envPath = args.length > 0 + ? Path.of(args[0]) + : Path.of( + "a2a-t-sample", + "src", + "main", + "resources", + "sample", + "private-line-complaint-negotiation", + "qwen.env"); + String suffix = LocalDateTime.now(ZoneId.systemDefault()).format(FILE_TIMESTAMP); + Path reportPath = args.length > 1 + ? Path.of(args[1]) + : Path.of("a2a-t-sample", "target", "negotiation-abnormal-report-" + suffix + ".json"); + Path processLogPath = args.length > 2 + ? Path.of(args[2]) + : Path.of("a2a-t-sample", "target", "negotiation-abnormal-process-log-" + suffix + ".jsonl"); + + A2ATClient client = new A2ATClient(envPath); + A2ATServer server = new A2ATServer(envPath); + String runId = UUID.randomUUID().toString(); + List> results = new ArrayList<>(); + try (NegotiationEvaluationProcessLogger logger = + new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, processLogPath)) { + logger.write(event( + "run_started", + runId, + null, + Map.of( + "env_path", + envPath.toAbsolutePath().toString(), + "case_count", + NegotiationAbnormalEvaluationCaseLoader.load().size() + + scriptedCases().size()), + null, + null)); + for (NegotiationAbnormalEvaluationCase testCase : NegotiationAbnormalEvaluationCaseLoader.load()) { + results.add(runCase(client, server, testCase, runId, logger)); + } + for (ScriptedAbnormalCase testCase : scriptedCases()) { + results.add(runScriptedCase(testCase, runId, logger)); + } + } + + long passed = results.stream() + .filter(result -> Boolean.TRUE.equals(result.get("passed"))) + .count(); + Map report = new LinkedHashMap<>(); + report.put("generated_at", Instant.now().toString()); + report.put("run_id", runId); + report.put("git_revision", gitRevision()); + report.put("total", results.size()); + report.put("passed", passed); + report.put("failed", results.size() - passed); + report.put("success_rate", results.isEmpty() ? 1.0 : (double) passed / results.size()); + report.put("definition", "异常用例在预期 SDK 异常发生且错误类型/错误码匹配时判定通过;门面用例使用边界输入,脚本化用例注入固定 LLM 响应,不访问外部 LLM。"); + report.put("error_code_coverage", errorCodeCoverage(results)); + report.put("process_log", processLogPath.toAbsolutePath().toString()); + report.put("cases", results); + Path parent = reportPath.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + OBJECT_MAPPER.writeValue(reportPath.toFile(), report); + System.out.printf( + "Abnormal negotiation evaluation complete: %d/%d passed; report=%s; process-log=%s%n", + passed, results.size(), reportPath.toAbsolutePath(), processLogPath.toAbsolutePath()); + } + + private static Map errorCodeCoverage(List> results) { + Map counts = new LinkedHashMap<>(); + NEGOTIATION_ERROR_CODES.forEach(code -> counts.put(code, 0)); + Set observed = new LinkedHashSet<>(); + for (Map result : results) { + Object error = result.get("actual_error"); + if (error instanceof Map errorMap && errorMap.get("code") instanceof String code) { + observed.add(code); + if (counts.containsKey(code)) { + counts.computeIfPresent(code, (ignored, count) -> count + 1); + } + } + } + List missing = NEGOTIATION_ERROR_CODES.stream() + .filter(code -> counts.get(code) == 0) + .toList(); + List unexpected = + observed.stream().filter(code -> !counts.containsKey(code)).toList(); + Map coverage = new LinkedHashMap<>(); + coverage.put("expected_codes", NEGOTIATION_ERROR_CODES); + coverage.put("observed_counts", counts); + coverage.put("missing_codes", missing); + coverage.put("unexpected_codes", unexpected); + coverage.put("all_expected_codes_covered", missing.isEmpty()); + return coverage; + } + + private static Map runCase( + A2ATClient client, + A2ATServer server, + NegotiationAbnormalEvaluationCase testCase, + String runId, + NegotiationEvaluationProcessLogger logger) + throws IOException { + long startedAt = System.nanoTime(); + NegotiationPerformative performative = NegotiationPerformative.valueOf(testCase.performative()); + NegotiationContext context = contextFor(testCase.contextMode(), performative); + String templateUri = templateFor(testCase.template()); + Map request = new LinkedHashMap<>(); + if (testCase.api().startsWith("generate_")) { + request.put("text", testCase.input()); + } else { + request.put("prompt", testCase.input()); + request.put("schema", schemaFor(testCase.api(), testCase.schemaMode())); + } + request.put("template_uri", templateUri); + request.put("context", context == null ? null : contextPayload(context)); + Map result = new LinkedHashMap<>(); + result.put("id", testCase.id()); + result.put("description", testCase.description()); + result.put("api", apiMethod(testCase.api())); + result.put("expected_exception", testCase.expectedException()); + result.put("expected_code", testCase.expectedCode()); + result.put("request", request); + try { + Object response = invoke(client, server, testCase, context, templateUri); + result.put("outcome", "unexpected_success"); + result.put("response", responseSummary(response)); + result.put("passed", false); + logger.write(event( + "abnormal_case", runId, testCase.id(), request, Map.of("outcome", "unexpected_success"), result)); + } catch (RuntimeException exception) { + Map actualError = errorDetails(exception); + boolean classMatched = testCase.expectedException().equals(actualError.get("class")); + boolean codeMatched = + testCase.expectedCode() == null || testCase.expectedCode().equals(actualError.get("code")); + result.put("actual_error", actualError); + result.put("passed", classMatched && codeMatched); + result.put("outcome", classMatched && codeMatched ? "expected_failure" : "unexpected_failure"); + logger.write(event("abnormal_case", runId, testCase.id(), request, null, result)); + } + result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); + return result; + } + + private static List scriptedCases() { + return List.of( + new ScriptedAbnormalCase( + "G-PROPOSE-LLM-MALFORMED", + "generate_propose", + "请补充接入端口名称和投诉分类。", + "not-json", + false, + "NegotiationGenerationException", + "llm.response_invalid"), + new ScriptedAbnormalCase( + "G-ACCEPT-LLM-INFRA", + "generate_accept", + "接受协商,端口为 PE1-GZ-ETH-0/0/0.200,投诉分类为专线中断。", + "throw", + false, + "NegotiationGenerationException", + "llm.invocation_failed"), + new ScriptedAbnormalCase( + "G-REJECT-SLOT-MISSING", + "generate_reject", + "拒绝协商,原因是资源系统暂时无法查询。", + "{\"conclusion\":\"Reject\",\"items\":null}", + false, + "NegotiationGenerationException", + "negotiation.field_missing"), + new ScriptedAbnormalCase( + "V-PROPOSE-SEMANTIC-REJECTED", + "validate_propose", + "## 信息协商\n请根据<所需信息项>补充相关内容。", + semanticRejectedPayload(), + false, + "NegotiationParamExtractionException", + "negotiation.semantic_rejected"), + new ScriptedAbnormalCase( + "V-ACCEPT-LLM-INFRA", + "validate_accept", + "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", + "throw", + false, + "NegotiationParamExtractionException", + "llm.invocation_failed"), + new ScriptedAbnormalCase( + "V-REJECT-SEMANTIC-REJECTED", + "validate_reject", + "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", + semanticRejectedPayload(), + false, + "NegotiationParamExtractionException", + "negotiation.semantic_rejected"), + new ScriptedAbnormalCase( + "G-PROPOSE-TEMPLATE-NOT-FOUND", + "generate_propose", + "请补充接入端口名称和投诉分类。", + "unused", + true, + "NegotiationGenerationException", + "template.not_found"), + new ScriptedAbnormalCase( + "V-PROPOSE-TEMPLATE-NOT-FOUND", + "validate_propose", + "## 信息协商\n请根据<所需信息项>补充相关内容。", + "unused", + true, + "NegotiationParamExtractionException", + "template.not_found")); + } + + private static String semanticRejectedPayload() { + return "{\"semantic_verdict\":false,\"negotiation_type\":\"information\"," + + "\"errors\":[{\"slot_name\":\"section.info_result_content\"," + + "\"code\":\"negotiation.missing_result_content\"," + + "\"facts\":{\"section_label\":\"信息协商结果内容\"}}]," + + "\"params\":{}}"; + } + + private static Map runScriptedCase( + ScriptedAbnormalCase testCase, String runId, NegotiationEvaluationProcessLogger logger) throws IOException { + long startedAt = System.nanoTime(); + NegotiationPerformative performative = testCase.api().contains("accept") + ? NegotiationPerformative.ACCEPT + : testCase.api().contains("reject") ? NegotiationPerformative.REJECT : NegotiationPerformative.PROPOSE; + NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); + String template = testCase.api().contains("propose") + ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI + : NegotiationSampleFlow.ENDING_TEMPLATE_URI; + ScriptedLlmClient llm = new ScriptedLlmClient(testCase.payload()); + NegotiationGenerationOrchestratorBuilder builder = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(1); + if (testCase.missingTemplate()) { + builder.templateLoader(new MissingTemplateLoader()); + } + NegotiationGenerationOrchestrator orchestrator = builder.build(); + Map request = new LinkedHashMap<>(); + if (testCase.api().startsWith("generate_")) { + request.put("text", testCase.input()); + } else { + request.put("prompt", testCase.input()); + request.put("schema", schemaFor(testCase.api(), null)); + } + request.put("context", contextPayload(context)); + request.put("template_uri", template); + Map result = new LinkedHashMap<>(); + result.put("id", testCase.id()); + result.put("api", scriptedApiMethod(testCase.api())); + result.put("request", request); + result.put("expected_exception", testCase.expectedException()); + result.put("expected_code", testCase.expectedCode()); + try { + Object response = invoke(orchestrator, testCase, context, parseTemplateUri(template)); + result.put("response", responseSummary(response)); + result.put("outcome", "unexpected_success"); + result.put("passed", false); + logger.write( + event("abnormal_scripted_case", runId, testCase.id(), request, responseSummary(response), result)); + } catch (RuntimeException exception) { + Map actualError = errorDetails(exception); + boolean matched = testCase.expectedException().equals(actualError.get("class")) + && testCase.expectedCode().equals(actualError.get("code")); + result.put("actual_error", actualError); + result.put("outcome", matched ? "expected_failure" : "unexpected_failure"); + result.put("passed", matched); + logger.write(event("abnormal_scripted_case", runId, testCase.id(), request, null, result)); + } + result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); + return result; + } + + private static Object invoke( + NegotiationGenerationOrchestrator orchestrator, + ScriptedAbnormalCase testCase, + NegotiationContext context, + TemplateUri template) { + return switch (testCase.api()) { + case "generate_propose" -> orchestrator.generateProposeFromText(testCase.input(), context, template); + case "generate_accept" -> orchestrator.generateAcceptFromText(testCase.input(), context, template); + case "generate_reject" -> orchestrator.generateRejectFromText(testCase.input(), context, template); + case "validate_propose" -> orchestrator.validateProposePromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.propose(), template); + case "validate_accept" -> orchestrator.validateAcceptPromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.accept(), template); + case "validate_reject" -> orchestrator.validateRejectPromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.reject(), template); + default -> throw new IllegalArgumentException("Unknown scripted abnormal API: " + testCase.api()); + }; + } + + private static Object invoke( + A2ATClient client, + A2ATServer server, + NegotiationAbnormalEvaluationCase testCase, + NegotiationContext context, + String templateUri) { + return switch (testCase.api()) { + case "generate_propose" -> client.generateNegotiationProposePromptFromText( + testCase.input(), context, templateUri); + case "validate_propose" -> server.validateProposePromptAndDataFilling( + testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); + case "generate_accept" -> server.generateNegotiationAcceptPromptFromText( + testCase.input(), context, templateUri); + case "validate_accept" -> client.validateAcceptPromptAndDataFilling( + testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); + case "generate_reject" -> server.generateNegotiationRejectPromptFromText( + testCase.input(), context, templateUri); + case "validate_reject" -> client.validateRejectPromptAndDataFilling( + testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); + default -> throw new IllegalArgumentException("Unknown abnormal evaluation API: " + testCase.api()); + }; + } + + private static Map schemaFor(String api, String mode) { + if ("null".equalsIgnoreCase(mode)) { + return null; + } + return switch (api) { + case "validate_propose" -> InformationNegotiationSchemas.propose(); + case "validate_accept" -> InformationNegotiationSchemas.accept(); + case "validate_reject" -> InformationNegotiationSchemas.reject(); + default -> Map.of(); + }; + } + + private static NegotiationContext contextFor(String mode, NegotiationPerformative performative) { + if ("null".equalsIgnoreCase(mode)) { + return null; + } + if ("invalid_id".equalsIgnoreCase(mode)) { + return new NegotiationContext("not-a-uuid", 1, 3, performative); + } + if ("exhausted".equalsIgnoreCase(mode)) { + return new NegotiationContext(UUID.randomUUID().toString(), 4, 3, performative); + } + return new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); + } + + private static String templateFor(String template) { + return switch (template == null ? "null" : template.toLowerCase(java.util.Locale.ROOT)) { + case "null" -> null; + case "propose" -> NegotiationSampleFlow.PROPOSE_TEMPLATE_URI; + case "ending" -> NegotiationSampleFlow.ENDING_TEMPLATE_URI; + case "unknown_propose" -> "Negotiation-T/information-negotiation/propose/v999"; + case "unknown_ending" -> "Negotiation-T/information-negotiation/accept-reject/v999"; + default -> throw new IllegalArgumentException("Unknown abnormal evaluation template: " + template); + }; + } + + /** Parses a template URI string for the typed {@code NegotiationGenerationOrchestrator} seam. */ + private static TemplateUri parseTemplateUri(String templateUri) { + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); + } + + private static String apiMethod(String api) { + return switch (api) { + case "generate_propose" -> "A2ATClient.generateNegotiationProposePromptFromText"; + case "validate_propose" -> "A2ATServer.validateProposePromptAndDataFilling"; + case "generate_accept" -> "A2ATServer.generateNegotiationAcceptPromptFromText"; + case "validate_accept" -> "A2ATClient.validateAcceptPromptAndDataFilling"; + case "generate_reject" -> "A2ATServer.generateNegotiationRejectPromptFromText"; + case "validate_reject" -> "A2ATClient.validateRejectPromptAndDataFilling"; + default -> api; + }; + } + + private static Map responseSummary(Object response) { + Map summary = new LinkedHashMap<>(); + if (response instanceof MetadataContent metadata) { + summary.put("type", "MetadataContent"); + summary.put("prompt", metadata.promptText()); + summary.put("template_uri", metadata.templateUri()); + } else if (response instanceof FilledParamData filled) { + summary.put("type", "FilledParamData"); + summary.put("data", filled.data()); + } else { + summary.put("type", response == null ? "null" : response.getClass().getName()); + } + return summary; + } + + private static Map event( + String stage, + String runId, + String caseId, + Map request, + Map response, + Map result) { + Map event = new LinkedHashMap<>(); + event.put("timestamp", Instant.now().toString()); + event.put("run_id", runId); + event.put("stage", stage); + if (result != null && result.get("api") != null) { + event.put("api", result.get("api")); + } + if (caseId != null) { + event.put("case_id", caseId); + } + event.put("request", request); + if (response != null) { + event.put("response", response); + } + if (result != null) { + event.put("result", result); + } + return event; + } + + private static Map errorDetails(RuntimeException exception) { + Map error = new LinkedHashMap<>(); + error.put("class", exception.getClass().getSimpleName()); + error.put("message", exception.getMessage()); + if (exception instanceof A2ATError a2atError) { + error.put("code", a2atError.getCode()); + } + return error; + } + + private static Map contextPayload(NegotiationContext context) { + return Map.of( + "id", context.id(), + "round", context.round(), + "maxRounds", context.maxRounds(), + "performative", context.performative().name()); + } + + private static String scriptedApiMethod(String api) { + return switch (api) { + case "generate_propose" -> "NegotiationGenerationOrchestrator.generateProposeFromText"; + case "generate_accept" -> "NegotiationGenerationOrchestrator.generateAcceptFromText"; + case "generate_reject" -> "NegotiationGenerationOrchestrator.generateRejectFromText"; + case "validate_propose" -> "NegotiationGenerationOrchestrator.validateProposePromptAndDataFilling"; + case "validate_accept" -> "NegotiationGenerationOrchestrator.validateAcceptPromptAndDataFilling"; + case "validate_reject" -> "NegotiationGenerationOrchestrator.validateRejectPromptAndDataFilling"; + default -> api; + }; + } + + private record ScriptedAbnormalCase( + String id, + String api, + String input, + String payload, + boolean missingTemplate, + String expectedException, + String expectedCode) {} + + private static final class ScriptedLlmClient implements LLMClient { + + private final String payload; + + private ScriptedLlmClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + if ("throw".equals(payload)) { + throw new LLMRuntimeError("scripted LLM infrastructure failure"); + } + return new LLMResponse( + payload, "sample-scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class MissingTemplateLoader implements NegotiationTemplateLoader { + + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + } + + private static String gitRevision() { + try { + Process process = new ProcessBuilder("git", "rev-parse", "HEAD") + .redirectErrorStream(true) + .start(); + String revision; + try (var input = process.getInputStream()) { + revision = new String(input.readAllBytes(), StandardCharsets.UTF_8).trim(); + } + return process.waitFor() == 0 && !revision.isBlank() ? revision : "unknown"; + } catch (IOException exception) { + return "unknown"; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return "unknown"; + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java index 4b0708a2..1d03cf6b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java @@ -1,420 +1,420 @@ -package net.openan.a2at.sample.task_t; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.server.A2ATServer; - -/** - * End-to-end accuracy demo for the {@code Task-T} extension. - * - *

            The demo closes the loop between the client facade and the server facade over the - * {@code Task-T/network-layer/private-line-complaint/v1} template (transfer / private-line business complaint - * diagnosis) in two independent cases: - * - *

              - *
            1. {@code A2ATClient#generateTaskPromptFromText} fed with natural language; - *
            2. {@code A2ATClient#generateTaskPromptFromDataWithSchema} fed with a slot-value map plus a semantics schema; - *
            3. {@code generateTaskPromptFromText} fed with key-slot-missing content, expected to be rejected by the - * server-side semantic validation. - *
            - * - *

            Each generated prompt is passed to {@code A2ATServer#validateTaskPromptAndDataFilling}; for cases one and two the - * extracted parameters are compared against the sample ground truth and the results are aggregated into a per-case - * field accuracy and sample pass rate, while case three reports a server-side validation rejection rate and an overall - * interception rate (server rejection plus client-side generation block) over the negative samples. - * - *

            Run with {@code java ... TaskTDemoMain [env-file-path] [case]}. The env file resolves as follows: an explicit - * first argument wins; otherwise a {@code client.env} in the working directory (repo root) that carries the required - * LLM keys; otherwise the bundled sample template {@code a2a-t-sample/src/main/resources/sample/task_t/client.env}. A - * working-directory {@code client.env} is only honored when it defines non-blank {@code A2AT_LLM_PROVIDER}, - * {@code A2AT_LLM_MODEL} and {@code A2AT_LLM_API_KEY} (cf. the leftover {@code subscribe_incident} template in newer - * checkouts would otherwise shadow the Task-T sample); otherwise the bundled template is used. The env must configure a - * reachable OpenAI-compatible LLM ({@code A2AT_LLM_API_KEY}, {@code A2AT_LLM_BASE_URL}, {@code A2AT_LLM_MODEL}, - * {@code A2AT_LLM_PROVIDER=openai}) — the server-side semantic validation performs one LLM call per sample. - * - *

            The second optional argument selects which case to run: {@code text} (case one), {@code data} (case two), - * {@code rejection} (case three) or {@code all} (default); when a single case is selected only its summary is printed. - */ -public final class TaskTDemoMain { - - private static final String DEFAULT_ENV_FILE = "client.env"; - - private static final String BUNDLED_ENV_FILE = Path.of( - "a2a-t-sample", "src", "main", "resources", "sample", "task_t", "client.env") - .toString(); - - private static final List REQUIRED_LLM_KEYS = - List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY"); - - private static final String CASE_TEXT = "text"; - - private static final String CASE_DATA = "data"; - - private static final String CASE_REJECTION = "rejection"; - - private static final String CASE_ALL = "all"; - - private TaskTDemoMain() {} - - /** - * Runs one or all client-API cases against the built-in private-line complaint diagnosis samples. - * - * @param args first optional argument is the {@code .env} file path; second optional argument selects the case - * ({@code text}, {@code data}, {@code rejection} or {@code all}) - */ - public static void main(String[] args) { - Path envPath = resolveEnvPath(args); - String caseSelection = resolveCaseSelection(args); - println("Task-T 准确率验证样例,env: " + envPath.toAbsolutePath() + (Files.exists(envPath) ? "" : " (不存在,请先配置)")); - println("模板: " + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); - println("用例: " + caseLabel(caseSelection)); - println(); - - A2ATClient client = new A2ATClient(envPath); - A2ATServer server = new A2ATServer(envPath); - - if (CASE_TEXT.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { - List textScores = runTextCase(client, server); - println(); - System.out.println("══════════════════════════════════════════════════════"); - printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromText", textScores)); - println(); - } - if (CASE_DATA.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { - List dataScores = runDataWithSchemaCase(client, server); - println(); - System.out.println("══════════════════════════════════════════════════════"); - printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromDataWithSchema", dataScores)); - println(); - } - if (CASE_REJECTION.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { - RejectionSummary rejectionSummary = runRejectionCase(client, server); - println(); - System.out.println("══════════════════════════════════════════════════════"); - printRejectionSummary(rejectionSummary); - println(); - } - } - - private static String resolveCaseSelection(String[] args) { - if (args.length > 1) { - String selection = args[1].toLowerCase(Locale.ROOT); - if (CASE_TEXT.equals(selection) - || CASE_DATA.equals(selection) - || CASE_REJECTION.equals(selection) - || CASE_ALL.equals(selection)) { - return selection; - } - System.err.println("未知用例参数: " + args[1] + ",可选值: text | data | rejection | all,将运行全部用例"); - return CASE_ALL; - } - return CASE_ALL; - } - - private static String caseLabel(String caseSelection) { - return switch (caseSelection) { - case CASE_TEXT -> "用例一:generateTaskPromptFromText"; - case CASE_DATA -> "用例二:generateTaskPromptFromDataWithSchema"; - case CASE_REJECTION -> "用例三:缺少关键槽位拒绝用例"; - default -> "全部(用例一 + 用例二 + 用例三)"; - }; - } - - private static List runTextCase(A2ATClient client, A2ATServer server) { - println("================ 用例一:generateTaskPromptFromText ================"); - List samples = TaskTPrivateLineComplaintSamples.textSamples(); - List scores = new ArrayList<>(); - for (int i = 0; i < samples.size(); i++) { - TaskTSample sample = samples.get(i); - printSampleHeader("用例一", i, samples.size(), sample.name()); - println("[输入] 自然语言文本:"); - println(sample.text()); - println(); - try { - MetadataContent metadata = - client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); - printGeneratedMetadata(metadata); - TaskTAccuracyEvaluator.SampleScore score = validateAndScore(server, sample, metadata, "用例一"); - scores.add(score); - } catch (PromptGenerationException exception) { - printFailure("生成失败", exception); - scores.add(new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of())); - } - println(); - } - return scores; - } - - private static List runDataWithSchemaCase( - A2ATClient client, A2ATServer server) { - println("========== 用例二:generateTaskPromptFromDataWithSchema =========="); - List samples = TaskTPrivateLineComplaintSamples.dataWithSchemaSamples(); - List scores = new ArrayList<>(); - for (int i = 0; i < samples.size(); i++) { - TaskTSample sample = samples.get(i); - printSampleHeader("用例二", i, samples.size(), sample.name()); - println("[输入] 数据(data):"); - println(pretty(sample.data())); - println("[输入] 语义schema(schema):"); - println(pretty(sample.semanticsSchema())); - println(); - try { - MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( - sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); - printGeneratedMetadata(metadata); - TaskTAccuracyEvaluator.SampleScore score = validateAndScore(server, sample, metadata, "用例二"); - scores.add(score); - } catch (PromptGenerationException exception) { - printFailure("生成失败", exception); - scores.add(new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of())); - } - println(); - } - return scores; - } - - /** - * Runs the rejection case over {@link TaskTPrivateLineComplaintSamples#rejectionSamples()}: each sample - * deliberately omits one or more key slots (or carries an invalid slot value), so the server-side semantic - * validation is expected to fail with {@code negotiation.semantic_rejected}. The sample set mixes a text variant - * (fed to {@code generateTaskPromptFromText}) and a data variant (fed to - * {@code generateTaskPromptFromDataWithSchema}); a sample is counted as "server-rejected" only when the validation - * throws {@link ContentValidationException} — the structured slot errors are printed per sample — while a failure - * already at prompt generation counts as a client-side block. The run aggregates a server-side validation rejection - * rate (server-rejected over total) side by side with an overall interception rate (server-rejected plus - * client-blocked over total), so the validation point itself can be read independently of client-side blocks. - */ - private static RejectionSummary runRejectionCase(A2ATClient client, A2ATServer server) { - println("========== 用例三:缺少关键槽位拒绝用例(期望 rejected) =========="); - List samples = TaskTPrivateLineComplaintSamples.rejectionSamples(); - int serverRejected = 0; - int clientBlocked = 0; - int unexpectedlyPassed = 0; - for (int i = 0; i < samples.size(); i++) { - TaskTRejectionSample sample = samples.get(i); - printSampleHeader("用例三", i, samples.size(), sample.name()); - try { - MetadataContent metadata; - if (sample.data() != null) { - println("[输入] 数据(data,客户端 key,缺关键槽位):"); - println(pretty(sample.data())); - println("[输入] 语义schema(schema):"); - println(pretty(sample.semanticsSchema())); - println(); - println("[生成] generateTaskPromptFromDataWithSchema:"); - metadata = client.generateTaskPromptFromDataWithSchema( - sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); - } else { - println("[输入] 自然语言文本 (缺少关键槽位):"); - println(sample.text()); - println(); - println("[生成] generateTaskPromptFromText:"); - metadata = client.generateTaskPromptFromText( - sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); - } - System.out.println(" templateUri : " + metadata.templateUri()); - System.out.println(" promptText : " + metadata.promptText()); - System.out.println(" extensionUri : " + metadata.extensionUri()); - println(); - Map extracted = server.validateTaskPromptAndDataFilling( - metadata.promptText(), - sample.validationSchema(), - StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) - .data(); - unexpectedlyPassed++; - println("[意外通过] 应被拒绝却校验通过,提取参数:"); - println(pretty(extracted)); - } catch (ContentValidationException exception) { - serverRejected++; - System.out.println("[正确拒绝] [" + exception.getCode() + "] " + exception.getMessage()); - printValidationErrors(exception); - } catch (PromptGenerationException exception) { - clientBlocked++; - System.out.println("[生成阶段拦截] 客户端生成即失败,[" + exception.getCode() + "] " + exception.getMessage()); - } - println(); - } - return new RejectionSummary(samples.size(), serverRejected, clientBlocked, unexpectedlyPassed); - } - - private static void printValidationErrors(ContentValidationException exception) { - List errors = exception.errors(); - if (errors == null || errors.isEmpty()) { - println(" (无结构化错误明细)"); - return; - } - for (SlotValidationError error : errors) { - println(" slot=" + error.slotName() + " code=" + error.code() + " message=" + error.message()); - } - } - - private static TaskTAccuracyEvaluator.SampleScore validateAndScore( - A2ATServer server, TaskTSample sample, MetadataContent metadata, String caseLabel) { - try { - Map extracted = server.validateTaskPromptAndDataFilling( - metadata.promptText(), - sample.validationSchema(), - StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) - .data(); - println("[服务端] " + caseLabel + " validateTaskPromptAndDataFilling 通过,提取参数:"); - println(pretty(extracted)); - List fields = TaskTAccuracyEvaluator.scoreFields(sample, extracted); - printFieldScores(fields); - return new TaskTAccuracyEvaluator.SampleScore(sample.name(), true, fields); - } catch (ContentValidationException exception) { - printFailure("校验不通过", exception); - return new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of()); - } - } - - private static void printSampleHeader(String caseLabel, int index, int total, String name) { - System.out.println( - "──────────── " + caseLabel + " 样本[" + (index + 1) + "/" + total + "] " + name + " ────────────"); - } - - private static void printGeneratedMetadata(MetadataContent metadata) { - println("[生成] MetadataContent:"); - println(" extensionUri: " + metadata.extensionUri()); - println(" templateUri : " + metadata.templateUri()); - println(" promptText : "); - println(metadata.promptText()); - } - - private static void printFieldScores(List fields) { - println("[比对] 字段级命中 (命中规则: 归一化后相同或互相包含):"); - for (TaskTAccuracyEvaluator.FieldScore field : fields) { - String mark = field.matched() ? "✔ 命中" : "✘ 未命中"; - String detail = field.matched() ? "" : " " + field.detail(); - println(" " + mark + " " + field.slot() + detail); - } - } - - private static void printFailure(String stage, A2ATError error) { - println("[失败] " + stage + ": [" + error.getCode() + "] " + error.getMessage()); - if (error instanceof ContentValidationException validationException) { - List errors = validationException.errors(); - if (!errors.isEmpty()) { - println(" 详细原因:"); - for (SlotValidationError slotError : errors) { - println(" slot: " + slotError.slotName() - + ", code: " + slotError.code() - + ", message: " + slotError.message()); - } - } - Map partialParams = validationException.params(); - if (!partialParams.isEmpty()) { - println(" LLM部分提取参数:"); - println(pretty(partialParams)); - } - } - Throwable cause = error.getCause(); - if (cause != null) { - println(" 根因异常: " + cause.getClass().getName() + ": " + cause.getMessage()); - } - } - - private static void printSummary(TaskTAccuracyEvaluator.Summary summary) { - System.out.println("──────────── 汇总: " + summary.api() + " ────────────"); - println(" 样本数: " + summary.sampleCount() - + " 通过样本: " + summary.passedSamples() - + " 字段命中: " + summary.matchedFields() + "/" + summary.expectedFields()); - println(" 字段准确率: " + percent(summary.fieldAccuracyPercent()) + " 样本通过率: " - + percent(summary.samplePassRatePercent())); - } - - private static void printRejectionSummary(RejectionSummary summary) { - System.out.println("──────────── 汇总: 缺少关键槽位拒绝用例 ────────────"); - println(" 样本数: " + summary.total() - + " 服务端拒绝: " + summary.serverRejected() - + " 生成阶段拦截: " + summary.clientBlocked() - + " 意外通过: " + summary.unexpectedlyPassed()); - println(" 服务端校验点拒绝率: " + percent(rejectionRate(summary.serverRejected(), summary.total())) - + " 总拦截率(服务端+客户端): " - + percent(rejectionRate(summary.serverRejected() + summary.clientBlocked(), summary.total()))); - } - - /** Rate guarded against a zero sample set. */ - private static double rejectionRate(int numerator, int total) { - return total == 0 ? 0d : numerator * 100.0 / total; - } - - /** - * Aggregated result of the rejection case over the key-slot-missing negative samples: how many were intercepted - * (either server-side semantic rejection or client-side generation failure) vs unexpectedly passed. - */ - private record RejectionSummary(int total, int serverRejected, int clientBlocked, int unexpectedlyPassed) {} - - private static String percent(double value) { - return String.format(Locale.ROOT, "%.2f%%", value); - } - - private static String pretty(Map map) { - if (map == null) { - return " "; - } - String body = map.entrySet().stream() - .map(entry -> entry.getKey() + " = " + entry.getValue()) - .collect(Collectors.joining("\n ", " ", "")); - return body; - } - - private static void println(Object message) { - System.out.println(message); - } - - private static void println() { - System.out.println(); - } - - private static Path resolveEnvPath(String[] args) { - if (args.length > 0) { - return Path.of(args[0]); - } - Path cwdEnv = Path.of(DEFAULT_ENV_FILE); - if (Files.exists(cwdEnv) && hasRequiredLlmKeys(cwdEnv)) { - return cwdEnv; - } - return Path.of(BUNDLED_ENV_FILE); - } - - private static boolean hasRequiredLlmKeys(Path envFile) { - Map entries = new HashMap<>(); - try { - for (String line : Files.readAllLines(envFile)) { - String trimmed = line.trim(); - if (trimmed.isEmpty() || trimmed.startsWith("#")) { - continue; - } - int separator = trimmed.indexOf('='); - if (separator <= 0) { - continue; - } - entries.put( - trimmed.substring(0, separator).trim(), - trimmed.substring(separator + 1).trim()); - } - } catch (IOException exception) { - return false; - } - for (String key : REQUIRED_LLM_KEYS) { - if (entries.getOrDefault(key, "").isBlank()) { - return false; - } - } - return true; - } -} +package net.openan.a2at.sample.task_t; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.server.A2ATServer; + +/** + * End-to-end accuracy demo for the {@code Task-T} extension. + * + *

            The demo closes the loop between the client facade and the server facade over the + * {@code Task-T/network-layer/private-line-complaint/v1} template (transfer / private-line business complaint + * diagnosis) in two independent cases: + * + *

              + *
            1. {@code A2ATClient#generateTaskPromptFromText} fed with natural language; + *
            2. {@code A2ATClient#generateTaskPromptFromDataWithSchema} fed with a slot-value map plus a semantics schema; + *
            3. {@code generateTaskPromptFromText} fed with key-slot-missing content, expected to be rejected by the + * server-side semantic validation. + *
            + * + *

            Each generated prompt is passed to {@code A2ATServer#validateTaskPromptAndDataFilling}; for cases one and two the + * extracted parameters are compared against the sample ground truth and the results are aggregated into a per-case + * field accuracy and sample pass rate, while case three reports a server-side validation rejection rate and an overall + * interception rate (server rejection plus client-side generation block) over the negative samples. + * + *

            Run with {@code java ... TaskTDemoMain [env-file-path] [case]}. The env file resolves as follows: an explicit + * first argument wins; otherwise a {@code client.env} in the working directory (repo root) that carries the required + * LLM keys; otherwise the bundled sample template {@code a2a-t-sample/src/main/resources/sample/task_t/client.env}. A + * working-directory {@code client.env} is only honored when it defines non-blank {@code A2AT_LLM_PROVIDER}, + * {@code A2AT_LLM_MODEL} and {@code A2AT_LLM_API_KEY} (cf. the leftover {@code subscribe_incident} template in newer + * checkouts would otherwise shadow the Task-T sample); otherwise the bundled template is used. The env must configure a + * reachable OpenAI-compatible LLM ({@code A2AT_LLM_API_KEY}, {@code A2AT_LLM_BASE_URL}, {@code A2AT_LLM_MODEL}, + * {@code A2AT_LLM_PROVIDER=openai}) — the server-side semantic validation performs one LLM call per sample. + * + *

            The second optional argument selects which case to run: {@code text} (case one), {@code data} (case two), + * {@code rejection} (case three) or {@code all} (default); when a single case is selected only its summary is printed. + */ +public final class TaskTDemoMain { + + private static final String DEFAULT_ENV_FILE = "client.env"; + + private static final String BUNDLED_ENV_FILE = Path.of( + "a2a-t-sample", "src", "main", "resources", "sample", "task_t", "client.env") + .toString(); + + private static final List REQUIRED_LLM_KEYS = + List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY"); + + private static final String CASE_TEXT = "text"; + + private static final String CASE_DATA = "data"; + + private static final String CASE_REJECTION = "rejection"; + + private static final String CASE_ALL = "all"; + + private TaskTDemoMain() {} + + /** + * Runs one or all client-API cases against the built-in private-line complaint diagnosis samples. + * + * @param args first optional argument is the {@code .env} file path; second optional argument selects the case + * ({@code text}, {@code data}, {@code rejection} or {@code all}) + */ + public static void main(String[] args) { + Path envPath = resolveEnvPath(args); + String caseSelection = resolveCaseSelection(args); + println("Task-T 准确率验证样例,env: " + envPath.toAbsolutePath() + (Files.exists(envPath) ? "" : " (不存在,请先配置)")); + println("模板: " + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + println("用例: " + caseLabel(caseSelection)); + println(); + + A2ATClient client = new A2ATClient(envPath); + A2ATServer server = new A2ATServer(envPath); + + if (CASE_TEXT.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { + List textScores = runTextCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromText", textScores)); + println(); + } + if (CASE_DATA.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { + List dataScores = runDataWithSchemaCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromDataWithSchema", dataScores)); + println(); + } + if (CASE_REJECTION.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { + RejectionSummary rejectionSummary = runRejectionCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + printRejectionSummary(rejectionSummary); + println(); + } + } + + private static String resolveCaseSelection(String[] args) { + if (args.length > 1) { + String selection = args[1].toLowerCase(Locale.ROOT); + if (CASE_TEXT.equals(selection) + || CASE_DATA.equals(selection) + || CASE_REJECTION.equals(selection) + || CASE_ALL.equals(selection)) { + return selection; + } + System.err.println("未知用例参数: " + args[1] + ",可选值: text | data | rejection | all,将运行全部用例"); + return CASE_ALL; + } + return CASE_ALL; + } + + private static String caseLabel(String caseSelection) { + return switch (caseSelection) { + case CASE_TEXT -> "用例一:generateTaskPromptFromText"; + case CASE_DATA -> "用例二:generateTaskPromptFromDataWithSchema"; + case CASE_REJECTION -> "用例三:缺少关键槽位拒绝用例"; + default -> "全部(用例一 + 用例二 + 用例三)"; + }; + } + + private static List runTextCase(A2ATClient client, A2ATServer server) { + println("================ 用例一:generateTaskPromptFromText ================"); + List samples = TaskTPrivateLineComplaintSamples.textSamples(); + List scores = new ArrayList<>(); + for (int i = 0; i < samples.size(); i++) { + TaskTSample sample = samples.get(i); + printSampleHeader("用例一", i, samples.size(), sample.name()); + println("[输入] 自然语言文本:"); + println(sample.text()); + println(); + try { + MetadataContent metadata = + client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + printGeneratedMetadata(metadata); + TaskTAccuracyEvaluator.SampleScore score = validateAndScore(server, sample, metadata, "用例一"); + scores.add(score); + } catch (PromptGenerationException exception) { + printFailure("生成失败", exception); + scores.add(new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of())); + } + println(); + } + return scores; + } + + private static List runDataWithSchemaCase( + A2ATClient client, A2ATServer server) { + println("========== 用例二:generateTaskPromptFromDataWithSchema =========="); + List samples = TaskTPrivateLineComplaintSamples.dataWithSchemaSamples(); + List scores = new ArrayList<>(); + for (int i = 0; i < samples.size(); i++) { + TaskTSample sample = samples.get(i); + printSampleHeader("用例二", i, samples.size(), sample.name()); + println("[输入] 数据(data):"); + println(pretty(sample.data())); + println("[输入] 语义schema(schema):"); + println(pretty(sample.semanticsSchema())); + println(); + try { + MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + printGeneratedMetadata(metadata); + TaskTAccuracyEvaluator.SampleScore score = validateAndScore(server, sample, metadata, "用例二"); + scores.add(score); + } catch (PromptGenerationException exception) { + printFailure("生成失败", exception); + scores.add(new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of())); + } + println(); + } + return scores; + } + + /** + * Runs the rejection case over {@link TaskTPrivateLineComplaintSamples#rejectionSamples()}: each sample + * deliberately omits one or more key slots (or carries an invalid slot value), so the server-side semantic + * validation is expected to fail with {@code negotiation.semantic_rejected}. The sample set mixes a text variant + * (fed to {@code generateTaskPromptFromText}) and a data variant (fed to + * {@code generateTaskPromptFromDataWithSchema}); a sample is counted as "server-rejected" only when the validation + * throws {@link ContentValidationException} — the structured slot errors are printed per sample — while a failure + * already at prompt generation counts as a client-side block. The run aggregates a server-side validation rejection + * rate (server-rejected over total) side by side with an overall interception rate (server-rejected plus + * client-blocked over total), so the validation point itself can be read independently of client-side blocks. + */ + private static RejectionSummary runRejectionCase(A2ATClient client, A2ATServer server) { + println("========== 用例三:缺少关键槽位拒绝用例(期望 rejected) =========="); + List samples = TaskTPrivateLineComplaintSamples.rejectionSamples(); + int serverRejected = 0; + int clientBlocked = 0; + int unexpectedlyPassed = 0; + for (int i = 0; i < samples.size(); i++) { + TaskTRejectionSample sample = samples.get(i); + printSampleHeader("用例三", i, samples.size(), sample.name()); + try { + MetadataContent metadata; + if (sample.data() != null) { + println("[输入] 数据(data,客户端 key,缺关键槽位):"); + println(pretty(sample.data())); + println("[输入] 语义schema(schema):"); + println(pretty(sample.semanticsSchema())); + println(); + println("[生成] generateTaskPromptFromDataWithSchema:"); + metadata = client.generateTaskPromptFromDataWithSchema( + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + } else { + println("[输入] 自然语言文本 (缺少关键槽位):"); + println(sample.text()); + println(); + println("[生成] generateTaskPromptFromText:"); + metadata = client.generateTaskPromptFromText( + sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + } + System.out.println(" templateUri : " + metadata.templateUri()); + System.out.println(" promptText : " + metadata.promptText()); + System.out.println(" extensionUri : " + metadata.extensionUri()); + println(); + Map extracted = server.validateTaskPromptAndDataFilling( + metadata.promptText(), + sample.validationSchema(), + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) + .data(); + unexpectedlyPassed++; + println("[意外通过] 应被拒绝却校验通过,提取参数:"); + println(pretty(extracted)); + } catch (ContentValidationException exception) { + serverRejected++; + System.out.println("[正确拒绝] [" + exception.getCode() + "] " + exception.getMessage()); + printValidationErrors(exception); + } catch (PromptGenerationException exception) { + clientBlocked++; + System.out.println("[生成阶段拦截] 客户端生成即失败,[" + exception.getCode() + "] " + exception.getMessage()); + } + println(); + } + return new RejectionSummary(samples.size(), serverRejected, clientBlocked, unexpectedlyPassed); + } + + private static void printValidationErrors(ContentValidationException exception) { + List errors = exception.errors(); + if (errors == null || errors.isEmpty()) { + println(" (无结构化错误明细)"); + return; + } + for (SlotValidationError error : errors) { + println(" slot=" + error.slotName() + " code=" + error.code() + " message=" + error.message()); + } + } + + private static TaskTAccuracyEvaluator.SampleScore validateAndScore( + A2ATServer server, TaskTSample sample, MetadataContent metadata, String caseLabel) { + try { + Map extracted = server.validateTaskPromptAndDataFilling( + metadata.promptText(), + sample.validationSchema(), + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) + .data(); + println("[服务端] " + caseLabel + " validateTaskPromptAndDataFilling 通过,提取参数:"); + println(pretty(extracted)); + List fields = TaskTAccuracyEvaluator.scoreFields(sample, extracted); + printFieldScores(fields); + return new TaskTAccuracyEvaluator.SampleScore(sample.name(), true, fields); + } catch (ContentValidationException exception) { + printFailure("校验不通过", exception); + return new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of()); + } + } + + private static void printSampleHeader(String caseLabel, int index, int total, String name) { + System.out.println( + "──────────── " + caseLabel + " 样本[" + (index + 1) + "/" + total + "] " + name + " ────────────"); + } + + private static void printGeneratedMetadata(MetadataContent metadata) { + println("[生成] MetadataContent:"); + println(" extensionUri: " + metadata.extensionUri()); + println(" templateUri : " + metadata.templateUri()); + println(" promptText : "); + println(metadata.promptText()); + } + + private static void printFieldScores(List fields) { + println("[比对] 字段级命中 (命中规则: 归一化后相同或互相包含):"); + for (TaskTAccuracyEvaluator.FieldScore field : fields) { + String mark = field.matched() ? "✔ 命中" : "✘ 未命中"; + String detail = field.matched() ? "" : " " + field.detail(); + println(" " + mark + " " + field.slot() + detail); + } + } + + private static void printFailure(String stage, A2ATError error) { + println("[失败] " + stage + ": [" + error.getCode() + "] " + error.getMessage()); + if (error instanceof ContentValidationException validationException) { + List errors = validationException.errors(); + if (!errors.isEmpty()) { + println(" 详细原因:"); + for (SlotValidationError slotError : errors) { + println(" slot: " + slotError.slotName() + + ", code: " + slotError.code() + + ", message: " + slotError.message()); + } + } + Map partialParams = validationException.params(); + if (!partialParams.isEmpty()) { + println(" LLM部分提取参数:"); + println(pretty(partialParams)); + } + } + Throwable cause = error.getCause(); + if (cause != null) { + println(" 根因异常: " + cause.getClass().getName() + ": " + cause.getMessage()); + } + } + + private static void printSummary(TaskTAccuracyEvaluator.Summary summary) { + System.out.println("──────────── 汇总: " + summary.api() + " ────────────"); + println(" 样本数: " + summary.sampleCount() + + " 通过样本: " + summary.passedSamples() + + " 字段命中: " + summary.matchedFields() + "/" + summary.expectedFields()); + println(" 字段准确率: " + percent(summary.fieldAccuracyPercent()) + " 样本通过率: " + + percent(summary.samplePassRatePercent())); + } + + private static void printRejectionSummary(RejectionSummary summary) { + System.out.println("──────────── 汇总: 缺少关键槽位拒绝用例 ────────────"); + println(" 样本数: " + summary.total() + + " 服务端拒绝: " + summary.serverRejected() + + " 生成阶段拦截: " + summary.clientBlocked() + + " 意外通过: " + summary.unexpectedlyPassed()); + println(" 服务端校验点拒绝率: " + percent(rejectionRate(summary.serverRejected(), summary.total())) + + " 总拦截率(服务端+客户端): " + + percent(rejectionRate(summary.serverRejected() + summary.clientBlocked(), summary.total()))); + } + + /** Rate guarded against a zero sample set. */ + private static double rejectionRate(int numerator, int total) { + return total == 0 ? 0d : numerator * 100.0 / total; + } + + /** + * Aggregated result of the rejection case over the key-slot-missing negative samples: how many were intercepted + * (either server-side semantic rejection or client-side generation failure) vs unexpectedly passed. + */ + private record RejectionSummary(int total, int serverRejected, int clientBlocked, int unexpectedlyPassed) {} + + private static String percent(double value) { + return String.format(Locale.ROOT, "%.2f%%", value); + } + + private static String pretty(Map map) { + if (map == null) { + return " "; + } + String body = map.entrySet().stream() + .map(entry -> entry.getKey() + " = " + entry.getValue()) + .collect(Collectors.joining("\n ", " ", "")); + return body; + } + + private static void println(Object message) { + System.out.println(message); + } + + private static void println() { + System.out.println(); + } + + private static Path resolveEnvPath(String[] args) { + if (args.length > 0) { + return Path.of(args[0]); + } + Path cwdEnv = Path.of(DEFAULT_ENV_FILE); + if (Files.exists(cwdEnv) && hasRequiredLlmKeys(cwdEnv)) { + return cwdEnv; + } + return Path.of(BUNDLED_ENV_FILE); + } + + private static boolean hasRequiredLlmKeys(Path envFile) { + Map entries = new HashMap<>(); + try { + for (String line : Files.readAllLines(envFile)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int separator = trimmed.indexOf('='); + if (separator <= 0) { + continue; + } + entries.put( + trimmed.substring(0, separator).trim(), + trimmed.substring(separator + 1).trim()); + } + } catch (IOException exception) { + return false; + } + for (String key : REQUIRED_LLM_KEYS) { + if (entries.getOrDefault(key, "").isBlank()) { + return false; + } + } + return true; + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java index 03cbcaf4..f0a9ef14 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java @@ -1,233 +1,233 @@ -package net.openan.a2at.sample.task_t; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Built-in evaluation samples for the {@code Task-T/network-layer/private-line-complaint/v1} template (transfer / - * private-line business complaint diagnosis), loaded from the packaged JSON file - * {@code a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json}. - * - *

            The sample set is split into two groups matching the two client APIs under test: - * - *

              - *
            • {@link #textSamples()} feed {@code A2ATClient#generateTaskPromptFromText} with concise colloquial natural - * language — deliberately not field-listed, so a run exercises the SDK natural-language parsing - * capability; - *
            • {@link #dataWithSchemaSamples()} feed {@code A2ATClient#generateTaskPromptFromDataWithSchema} with a structured - * input whose {@code data} carries English business fields — not the template slot names. - *
            • {@link #rejectionSamples()} feed {@code generateTaskPromptFromText} with content that deliberately omits key - * slots; server-side semantic validation is expected to reject them — scored separately from the accuracy - * samples. - *
            - * - *

            Per design, the client-side keys and the server-side keys intentionally diverge: the client submits its - * business fields under its own key names, the SDK renders them into the template prompt, and the server re-extracts - * them from the rendered prompt under the server-side key names. This cross-key round trip exercises the a2a-t - * SDK adaptation capability — neither side may assume the other's key layout. - * - * - * - * - * - * - * - * - * - *
            near semantics, distinct keys
            client key (data / semantics schema)server key (validation schema / extracted params)meaning
            {@code portName}{@code accessPort}接入端口名称
            {@code complaintScenario}{@code bizScenario}投诉分类(专线中断/专线质差)
            {@code faultStartTime}{@code faultTime}问题发生时间
            {@code ticketNo}{@code eventSerialNo}OSS 侧事件流水号
            {@code faultDetailText}{@code faultDetail}投诉/故障详情
            - * - *

            Expected values ({@link TaskTSample#expectedParams()}) are keyed by the server field names, the keys the - * accuracy evaluator actually reads out of {@code validateTaskPromptAndDataFilling}; they hold canonical key facts that - * always appear in the input. Structured fields (port, scenario, time, serial) hit only when equal after - * whitespace-and-case normalization; the free-text detail field hits on containment. - */ -public final class TaskTPrivateLineComplaintSamples { - - /** Client key: the addressed access port name, e.g. {@code P781-珠江新城-PTN7900-23-TPA1EG24-17}. */ - public static final String CLIENT_PORT = "portName"; - - /** Client key: complaint category scenario, {@code 专线中断} or {@code 专线质差}. */ - public static final String CLIENT_SCENARIO = "complaintScenario"; - - /** Client key: problem occurrence time (ISO-8601). */ - public static final String CLIENT_TIME = "faultStartTime"; - - /** Client key: OSS-side complaint/event ticket number. */ - public static final String CLIENT_TICKET = "ticketNo"; - - /** Client key: free-text complaint detail. */ - public static final String CLIENT_DETAIL = "faultDetailText"; - - /** Server key (extracted parameter): access port name; near-semantics twin of {@link #CLIENT_PORT}. */ - public static final String SERVER_PORT = "accessPort"; - - /** Server key (extracted parameter): complaint category; near-semantics twin of {@link #CLIENT_SCENARIO}. */ - public static final String SERVER_SCENARIO = "bizScenario"; - - /** Server key (extracted parameter): problem occurrence time; near-semantics twin of {@link #CLIENT_TIME}. */ - public static final String SERVER_TIME = "faultTime"; - - /** Server key (extracted parameter): event serial number; near-semantics twin of {@link #CLIENT_TICKET}. */ - public static final String SERVER_TICKET = "eventSerialNo"; - - /** Server key (extracted parameter): complaint detail; near-semantics twin of {@link #CLIENT_DETAIL}. */ - public static final String SERVER_DETAIL = "faultDetail"; - - private static final String SAMPLE_RESOURCE = "sample/task_t/private-line-complaint-samples.json"; - - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - private static final Loaded LOADED = load(); - - private TaskTPrivateLineComplaintSamples() {} - - /** - * Natural-language samples for {@code generateTaskPromptFromText}. - * - * @return immutable text sample list - */ - public static List textSamples() { - return LOADED.samples().stream().filter(sample -> sample.text() != null).toList(); - } - - /** - * Data-plus-schema samples for {@code generateTaskPromptFromDataWithSchema}. - * - *

            Each sample passes business fields under the client keys in {@code data} plus a client-side semantics - * {@code schema}; the server-side validation schema re-extracts the parameters under the server keys, so a - * run exercises the cross-key adaptation of the SDK. - * - * @return immutable data sample list - */ - public static List dataWithSchemaSamples() { - return LOADED.samples().stream().filter(sample -> sample.data() != null).toList(); - } - - /** - * Rejection samples that deliberately omit one or more key slots. - * - *

            Two variants mirror the two client APIs under test: {@code text} samples are colloquial natural-language - * complaints missing a required fact (access port, complaint scenario), while {@code data} samples are structured - * inputs under the client field names that omit a required field or carry a complaint category outside the contract - * {@code enum}. Time and serial are optional slots and are deliberately not used as rejection criteria — - * their absence is exercised by the positive samples {@code text-optional-slots-missing} / - * {@code data-optional-slots-missing} instead. The server-side semantic validation is expected to reject these - * samples with {@code negotiation.semantic_rejected}; they are scored separately from the accuracy samples and are - * never fed into the field-accuracy evaluation. - * - * @return immutable rejection sample list - */ - public static List rejectionSamples() { - return LOADED.rejections(); - } - - private static Loaded load() { - try (InputStream in = TaskTPrivateLineComplaintSamples.class.getResourceAsStream("/" + SAMPLE_RESOURCE)) { - if (in == null) { - throw new IllegalStateException("sample resource not found on classpath: " + SAMPLE_RESOURCE); - } - JsonNode root = OBJECT_MAPPER.readTree(in); - Map semanticsSchema = toObjectMap(root.get("semanticsSchema")); - Map validationSchema = toObjectMap(root.get("validationSchema")); - List samples = new ArrayList<>(); - for (JsonNode node : root.get("samples")) { - String name = node.get("name").asText(); - JsonNode text = node.get("text"); - JsonNode data = node.get("data"); - Map expected = toStringMap(node.get("expected")); - if (data == null) { - samples.add(new TaskTSample(name, text.asText(), null, null, expected, validationSchema)); - } else { - samples.add(new TaskTSample( - name, null, toObjectMap(data), semanticsSchema, expected, validationSchema)); - } - } - List rejections = new ArrayList<>(); - JsonNode rejectionNodes = root.get("rejectionSamples"); - if (rejectionNodes != null) { - for (JsonNode node : rejectionNodes) { - String name = node.get("name").asText(); - JsonNode data = node.get("data"); - if (data == null) { - rejections.add(new TaskTRejectionSample( - name, node.get("text").asText(), null, null, validationSchema)); - } else { - rejections.add(new TaskTRejectionSample( - name, null, toObjectMap(data), semanticsSchema, validationSchema)); - } - } - } - return new Loaded(List.copyOf(samples), List.copyOf(rejections)); - } catch (IOException error) { - throw new IllegalStateException("failed to load task-t samples from " + SAMPLE_RESOURCE, error); - } - } - - /** Loaded evaluation sample set split into accuracy samples and rejection samples. */ - private record Loaded(List samples, List rejections) {} - - private static Map toObjectMap(JsonNode node) { - return OBJECT_MAPPER.convertValue(node, new TypeReference>() {}); - } - - private static Map toStringMap(JsonNode node) { - return OBJECT_MAPPER.convertValue(node, new TypeReference>() {}); - } -} - -/** - * One private-line complaint diagnosis evaluation sample. - * - * @param name sample identifier shown in the accuracy report - * @param text natural-language input for the FromText case; {@code null} when the sample only targets the data case - * @param data structured business-field input for the FromDataWithSchema case, keyed by the client field names; - * {@code null} when the sample only targets the text case - * @param semanticsSchema client-side field-semantics schema, keyed by the client field names; may be {@code null} when - * {@code data} is {@code null} - * @param expectedParams ground truth business values, keyed by the server field names (the keys read out of - * {@code validateTaskPromptAndDataFilling}); these are the fields actually scored - * @param validationSchema caller-provided JSON parameter schema passed to {@code validateTaskPromptAndDataFilling}, - * keyed by the server field names - * @since 2026-08 - */ -record TaskTSample( - String name, - String text, - Map data, - Map semanticsSchema, - Map expectedParams, - Map validationSchema) {} - -/** - * One negative sample for the rejection check: content that deliberately omits one or more key slots (or carries an - * invalid slot value) and is expected to be rejected by the server-side semantic validation. Two variants exist, - * mirroring the two client APIs under test: - * - *

              - *
            • {@code text} variant feeds {@code A2ATClient#generateTaskPromptFromText} with colloquial natural language; - *
            • {@code data} variant feeds {@code A2ATClient#generateTaskPromptFromDataWithSchema} with structured input keyed - * by the client field names plus the client-side semantics schema. - *
            - * - * @param name sample identifier shown in the rejection report - * @param text natural-language input for the FromText variant; {@code null} when the sample only targets the data case - * @param data structured business-field input for the FromDataWithSchema variant, keyed by the client field - * names; {@code null} when the sample only targets the text case - * @param semanticsSchema client-side field-semantics schema, keyed by the client field names; may be {@code null} when - * {@code data} is {@code null} - * @param validationSchema caller-provided JSON parameter schema passed to {@code validateTaskPromptAndDataFilling} - * @since 2026-08 - */ -record TaskTRejectionSample( - String name, - String text, - Map data, - Map semanticsSchema, - Map validationSchema) {} +package net.openan.a2at.sample.task_t; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Built-in evaluation samples for the {@code Task-T/network-layer/private-line-complaint/v1} template (transfer / + * private-line business complaint diagnosis), loaded from the packaged JSON file + * {@code a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json}. + * + *

            The sample set is split into two groups matching the two client APIs under test: + * + *

              + *
            • {@link #textSamples()} feed {@code A2ATClient#generateTaskPromptFromText} with concise colloquial natural + * language — deliberately not field-listed, so a run exercises the SDK natural-language parsing + * capability; + *
            • {@link #dataWithSchemaSamples()} feed {@code A2ATClient#generateTaskPromptFromDataWithSchema} with a structured + * input whose {@code data} carries English business fields — not the template slot names. + *
            • {@link #rejectionSamples()} feed {@code generateTaskPromptFromText} with content that deliberately omits key + * slots; server-side semantic validation is expected to reject them — scored separately from the accuracy + * samples. + *
            + * + *

            Per design, the client-side keys and the server-side keys intentionally diverge: the client submits its + * business fields under its own key names, the SDK renders them into the template prompt, and the server re-extracts + * them from the rendered prompt under the server-side key names. This cross-key round trip exercises the a2a-t + * SDK adaptation capability — neither side may assume the other's key layout. + * + * + * + * + * + * + * + * + * + *
            near semantics, distinct keys
            client key (data / semantics schema)server key (validation schema / extracted params)meaning
            {@code portName}{@code accessPort}接入端口名称
            {@code complaintScenario}{@code bizScenario}投诉分类(专线中断/专线质差)
            {@code faultStartTime}{@code faultTime}问题发生时间
            {@code ticketNo}{@code eventSerialNo}OSS 侧事件流水号
            {@code faultDetailText}{@code faultDetail}投诉/故障详情
            + * + *

            Expected values ({@link TaskTSample#expectedParams()}) are keyed by the server field names, the keys the + * accuracy evaluator actually reads out of {@code validateTaskPromptAndDataFilling}; they hold canonical key facts that + * always appear in the input. Structured fields (port, scenario, time, serial) hit only when equal after + * whitespace-and-case normalization; the free-text detail field hits on containment. + */ +public final class TaskTPrivateLineComplaintSamples { + + /** Client key: the addressed access port name, e.g. {@code P781-珠江新城-PTN7900-23-TPA1EG24-17}. */ + public static final String CLIENT_PORT = "portName"; + + /** Client key: complaint category scenario, {@code 专线中断} or {@code 专线质差}. */ + public static final String CLIENT_SCENARIO = "complaintScenario"; + + /** Client key: problem occurrence time (ISO-8601). */ + public static final String CLIENT_TIME = "faultStartTime"; + + /** Client key: OSS-side complaint/event ticket number. */ + public static final String CLIENT_TICKET = "ticketNo"; + + /** Client key: free-text complaint detail. */ + public static final String CLIENT_DETAIL = "faultDetailText"; + + /** Server key (extracted parameter): access port name; near-semantics twin of {@link #CLIENT_PORT}. */ + public static final String SERVER_PORT = "accessPort"; + + /** Server key (extracted parameter): complaint category; near-semantics twin of {@link #CLIENT_SCENARIO}. */ + public static final String SERVER_SCENARIO = "bizScenario"; + + /** Server key (extracted parameter): problem occurrence time; near-semantics twin of {@link #CLIENT_TIME}. */ + public static final String SERVER_TIME = "faultTime"; + + /** Server key (extracted parameter): event serial number; near-semantics twin of {@link #CLIENT_TICKET}. */ + public static final String SERVER_TICKET = "eventSerialNo"; + + /** Server key (extracted parameter): complaint detail; near-semantics twin of {@link #CLIENT_DETAIL}. */ + public static final String SERVER_DETAIL = "faultDetail"; + + private static final String SAMPLE_RESOURCE = "sample/task_t/private-line-complaint-samples.json"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final Loaded LOADED = load(); + + private TaskTPrivateLineComplaintSamples() {} + + /** + * Natural-language samples for {@code generateTaskPromptFromText}. + * + * @return immutable text sample list + */ + public static List textSamples() { + return LOADED.samples().stream().filter(sample -> sample.text() != null).toList(); + } + + /** + * Data-plus-schema samples for {@code generateTaskPromptFromDataWithSchema}. + * + *

            Each sample passes business fields under the client keys in {@code data} plus a client-side semantics + * {@code schema}; the server-side validation schema re-extracts the parameters under the server keys, so a + * run exercises the cross-key adaptation of the SDK. + * + * @return immutable data sample list + */ + public static List dataWithSchemaSamples() { + return LOADED.samples().stream().filter(sample -> sample.data() != null).toList(); + } + + /** + * Rejection samples that deliberately omit one or more key slots. + * + *

            Two variants mirror the two client APIs under test: {@code text} samples are colloquial natural-language + * complaints missing a required fact (access port, complaint scenario), while {@code data} samples are structured + * inputs under the client field names that omit a required field or carry a complaint category outside the contract + * {@code enum}. Time and serial are optional slots and are deliberately not used as rejection criteria — + * their absence is exercised by the positive samples {@code text-optional-slots-missing} / + * {@code data-optional-slots-missing} instead. The server-side semantic validation is expected to reject these + * samples with {@code negotiation.semantic_rejected}; they are scored separately from the accuracy samples and are + * never fed into the field-accuracy evaluation. + * + * @return immutable rejection sample list + */ + public static List rejectionSamples() { + return LOADED.rejections(); + } + + private static Loaded load() { + try (InputStream in = TaskTPrivateLineComplaintSamples.class.getResourceAsStream("/" + SAMPLE_RESOURCE)) { + if (in == null) { + throw new IllegalStateException("sample resource not found on classpath: " + SAMPLE_RESOURCE); + } + JsonNode root = OBJECT_MAPPER.readTree(in); + Map semanticsSchema = toObjectMap(root.get("semanticsSchema")); + Map validationSchema = toObjectMap(root.get("validationSchema")); + List samples = new ArrayList<>(); + for (JsonNode node : root.get("samples")) { + String name = node.get("name").asText(); + JsonNode text = node.get("text"); + JsonNode data = node.get("data"); + Map expected = toStringMap(node.get("expected")); + if (data == null) { + samples.add(new TaskTSample(name, text.asText(), null, null, expected, validationSchema)); + } else { + samples.add(new TaskTSample( + name, null, toObjectMap(data), semanticsSchema, expected, validationSchema)); + } + } + List rejections = new ArrayList<>(); + JsonNode rejectionNodes = root.get("rejectionSamples"); + if (rejectionNodes != null) { + for (JsonNode node : rejectionNodes) { + String name = node.get("name").asText(); + JsonNode data = node.get("data"); + if (data == null) { + rejections.add(new TaskTRejectionSample( + name, node.get("text").asText(), null, null, validationSchema)); + } else { + rejections.add(new TaskTRejectionSample( + name, null, toObjectMap(data), semanticsSchema, validationSchema)); + } + } + } + return new Loaded(List.copyOf(samples), List.copyOf(rejections)); + } catch (IOException error) { + throw new IllegalStateException("failed to load task-t samples from " + SAMPLE_RESOURCE, error); + } + } + + /** Loaded evaluation sample set split into accuracy samples and rejection samples. */ + private record Loaded(List samples, List rejections) {} + + private static Map toObjectMap(JsonNode node) { + return OBJECT_MAPPER.convertValue(node, new TypeReference>() {}); + } + + private static Map toStringMap(JsonNode node) { + return OBJECT_MAPPER.convertValue(node, new TypeReference>() {}); + } +} + +/** + * One private-line complaint diagnosis evaluation sample. + * + * @param name sample identifier shown in the accuracy report + * @param text natural-language input for the FromText case; {@code null} when the sample only targets the data case + * @param data structured business-field input for the FromDataWithSchema case, keyed by the client field names; + * {@code null} when the sample only targets the text case + * @param semanticsSchema client-side field-semantics schema, keyed by the client field names; may be {@code null} when + * {@code data} is {@code null} + * @param expectedParams ground truth business values, keyed by the server field names (the keys read out of + * {@code validateTaskPromptAndDataFilling}); these are the fields actually scored + * @param validationSchema caller-provided JSON parameter schema passed to {@code validateTaskPromptAndDataFilling}, + * keyed by the server field names + * @since 2026-08 + */ +record TaskTSample( + String name, + String text, + Map data, + Map semanticsSchema, + Map expectedParams, + Map validationSchema) {} + +/** + * One negative sample for the rejection check: content that deliberately omits one or more key slots (or carries an + * invalid slot value) and is expected to be rejected by the server-side semantic validation. Two variants exist, + * mirroring the two client APIs under test: + * + *

              + *
            • {@code text} variant feeds {@code A2ATClient#generateTaskPromptFromText} with colloquial natural language; + *
            • {@code data} variant feeds {@code A2ATClient#generateTaskPromptFromDataWithSchema} with structured input keyed + * by the client field names plus the client-side semantics schema. + *
            + * + * @param name sample identifier shown in the rejection report + * @param text natural-language input for the FromText variant; {@code null} when the sample only targets the data case + * @param data structured business-field input for the FromDataWithSchema variant, keyed by the client field + * names; {@code null} when the sample only targets the text case + * @param semanticsSchema client-side field-semantics schema, keyed by the client field names; may be {@code null} when + * {@code data} is {@code null} + * @param validationSchema caller-provided JSON parameter schema passed to {@code validateTaskPromptAndDataFilling} + * @since 2026-08 + */ +record TaskTRejectionSample( + String name, + String text, + Map data, + Map semanticsSchema, + Map validationSchema) {} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java index ddd43800..01cc743a 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java @@ -1,96 +1,96 @@ -package net.openan.a2at.sample.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; - -import java.lang.reflect.Method; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.server.A2ATServer; -import org.junit.jupiter.api.Test; - -/** - * Guards the negotiation content-layer API surface of both high-level facades. - * - *

            The client and the server facade expose the negotiation generation, query and validation APIs as one symmetric - * surface: identical method names, parameter types and return types. This test fails when one facade drifts from the - * other. - */ -class NegotiationFacadeApiSymmetryTest { - - private static final List NEGOTIATION_API_METHODS = List.of( - "generateNegotiationProposePromptFromData", - "generateNegotiationAcceptPromptFromData", - "generateNegotiationRejectPromptFromData", - "generateNegotiationAbortPromptFromData", - "generateNegotiationProposePromptFromText", - "generateNegotiationAcceptPromptFromText", - "generateNegotiationRejectPromptFromText", - "generateNegotiationAbortPromptFromText", - "validateProposePromptAndDataFilling", - "validateAcceptPromptAndDataFilling", - "validateRejectPromptAndDataFilling", - "validateAbortPromptAndDataFilling"); - - private static final List REMOVED_NEGOTIATION_QUERY_METHODS = - List.of("getNegotiationPrompts", "getNegotiationPrompt"); - - private static final List CROSS_EXTENSION_QUERY_METHODS = List.of("getPrompts", "getPrompt"); - - @Test - void bothFacadesExposeTheSameTwelveNegotiationApiMethods() { - Map clientSurface = negotiationApiSurface(A2ATClient.class); - Map serverSurface = negotiationApiSurface(A2ATServer.class); - - assertEquals(new HashSet<>(NEGOTIATION_API_METHODS), clientSurface.keySet()); - assertEquals(clientSurface, serverSurface); - } - - @Test - void neitherFacadeExposesTheRemovedNegotiationQueryMethods() { - for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { - for (String removedMethod : REMOVED_NEGOTIATION_QUERY_METHODS) { - boolean present = Arrays.stream(facade.getMethods()) - .anyMatch(method -> method.getName().equals(removedMethod)); - assertFalse( - present, - facade.getSimpleName() + " must not expose the removed " + removedMethod + " query method"); - } - } - } - - @Test - void bothFacadesExposeTheSameCrossExtensionQueryMethods() { - assertEquals( - new HashSet<>(CROSS_EXTENSION_QUERY_METHODS), - Arrays.stream(A2ATClient.class.getMethods()) - .filter(method -> CROSS_EXTENSION_QUERY_METHODS.contains(method.getName())) - .map(Method::getName) - .collect(Collectors.toSet())); - assertEquals( - Arrays.stream(A2ATClient.class.getMethods()) - .filter(method -> CROSS_EXTENSION_QUERY_METHODS.contains(method.getName())) - .map(NegotiationFacadeApiSymmetryTest::methodSignature) - .collect(Collectors.toSet()), - Arrays.stream(A2ATServer.class.getMethods()) - .filter(method -> CROSS_EXTENSION_QUERY_METHODS.contains(method.getName())) - .map(NegotiationFacadeApiSymmetryTest::methodSignature) - .collect(Collectors.toSet())); - } - - private static Map negotiationApiSurface(Class facade) { - return Arrays.stream(facade.getMethods()) - .filter(method -> NEGOTIATION_API_METHODS.contains(method.getName())) - .collect(Collectors.toMap(Method::getName, NegotiationFacadeApiSymmetryTest::methodSignature)); - } - - private static String methodSignature(Method method) { - String parameters = - Arrays.stream(method.getParameterTypes()).map(Class::getName).collect(Collectors.joining(",")); - return parameters + "->" + method.getReturnType().getName(); - } -} +package net.openan.a2at.sample.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.Test; + +/** + * Guards the negotiation content-layer API surface of both high-level facades. + * + *

            The client and the server facade expose the negotiation generation, query and validation APIs as one symmetric + * surface: identical method names, parameter types and return types. This test fails when one facade drifts from the + * other. + */ +class NegotiationFacadeApiSymmetryTest { + + private static final List NEGOTIATION_API_METHODS = List.of( + "generateNegotiationProposePromptFromData", + "generateNegotiationAcceptPromptFromData", + "generateNegotiationRejectPromptFromData", + "generateNegotiationAbortPromptFromData", + "generateNegotiationProposePromptFromText", + "generateNegotiationAcceptPromptFromText", + "generateNegotiationRejectPromptFromText", + "generateNegotiationAbortPromptFromText", + "validateProposePromptAndDataFilling", + "validateAcceptPromptAndDataFilling", + "validateRejectPromptAndDataFilling", + "validateAbortPromptAndDataFilling"); + + private static final List REMOVED_NEGOTIATION_QUERY_METHODS = + List.of("getNegotiationPrompts", "getNegotiationPrompt"); + + private static final List CROSS_EXTENSION_QUERY_METHODS = List.of("getPrompts", "getPrompt"); + + @Test + void bothFacadesExposeTheSameTwelveNegotiationApiMethods() { + Map clientSurface = negotiationApiSurface(A2ATClient.class); + Map serverSurface = negotiationApiSurface(A2ATServer.class); + + assertEquals(new HashSet<>(NEGOTIATION_API_METHODS), clientSurface.keySet()); + assertEquals(clientSurface, serverSurface); + } + + @Test + void neitherFacadeExposesTheRemovedNegotiationQueryMethods() { + for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { + for (String removedMethod : REMOVED_NEGOTIATION_QUERY_METHODS) { + boolean present = Arrays.stream(facade.getMethods()) + .anyMatch(method -> method.getName().equals(removedMethod)); + assertFalse( + present, + facade.getSimpleName() + " must not expose the removed " + removedMethod + " query method"); + } + } + } + + @Test + void bothFacadesExposeTheSameCrossExtensionQueryMethods() { + assertEquals( + new HashSet<>(CROSS_EXTENSION_QUERY_METHODS), + Arrays.stream(A2ATClient.class.getMethods()) + .filter(method -> CROSS_EXTENSION_QUERY_METHODS.contains(method.getName())) + .map(Method::getName) + .collect(Collectors.toSet())); + assertEquals( + Arrays.stream(A2ATClient.class.getMethods()) + .filter(method -> CROSS_EXTENSION_QUERY_METHODS.contains(method.getName())) + .map(NegotiationFacadeApiSymmetryTest::methodSignature) + .collect(Collectors.toSet()), + Arrays.stream(A2ATServer.class.getMethods()) + .filter(method -> CROSS_EXTENSION_QUERY_METHODS.contains(method.getName())) + .map(NegotiationFacadeApiSymmetryTest::methodSignature) + .collect(Collectors.toSet())); + } + + private static Map negotiationApiSurface(Class facade) { + return Arrays.stream(facade.getMethods()) + .filter(method -> NEGOTIATION_API_METHODS.contains(method.getName())) + .collect(Collectors.toMap(Method::getName, NegotiationFacadeApiSymmetryTest::methodSignature)); + } + + private static String methodSignature(Method method) { + String parameters = + Arrays.stream(method.getParameterTypes()).map(Class::getName).collect(Collectors.joining(",")); + return parameters + "->" + method.getReturnType().getName(); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java index f8e7397d..9d699137 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -1,284 +1,284 @@ -package net.openan.a2at.sample.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import java.io.IOException; -import java.lang.reflect.Method; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.server.A2ATServer; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Verifies the output symmetry of the negotiation content layer across the client and the server facade. - * - *

            Both facades expose the twelve negotiation API methods as one symmetric surface and produce, for identical inputs, - * identical generation results: the whole MetadataContent record and the metadata map built from it are equal on both - * sides. The inputs are the fixed golden fixture inputs of the negotiation module, so this test also proves that both - * facades are wired to the same built-in resources. - */ -class NegotiationFacadeOutputSymmetryTest { - - private static final String TEST_MOCK_PROVIDER = "test-symmetry-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); - } - } - - private static final List NEGOTIATION_API_METHODS = List.of( - "generateNegotiationProposePromptFromData", - "generateNegotiationAcceptPromptFromData", - "generateNegotiationRejectPromptFromData", - "generateNegotiationAbortPromptFromData", - "generateNegotiationProposePromptFromText", - "generateNegotiationAcceptPromptFromText", - "generateNegotiationRejectPromptFromText", - "generateNegotiationAbortPromptFromText", - "validateProposePromptAndDataFilling", - "validateAcceptPromptAndDataFilling", - "validateRejectPromptAndDataFilling", - "validateAbortPromptAndDataFilling"); - - @TempDir - Path tempDir; - - @Test - void bothFacadesExposeTheSameTwelveMethodSignatures() { - Map clientSurface = negotiationApiSurface(A2ATClient.class); - Map serverSurface = negotiationApiSurface(A2ATServer.class); - - assertEquals(NEGOTIATION_API_METHODS.stream().sorted().toList(), sortedKeys(clientSurface)); - assertEquals(clientSurface, serverSurface); - } - - @Test - void bothFacadesProduceIdenticalMetadataContentForTheSameInput() throws IOException { - for (String language : List.of("zh-CN", "en-US")) { - A2ATClient client = new A2ATClient(envFile(language, "client.env")); - A2ATServer server = new A2ATServer(envFile(language, "server.env")); - - for (SymmetryCase symmetryCase : symmetryCases()) { - MetadataContent clientResult = symmetryCase.generate( - client::generateNegotiationProposePromptFromData, - client::generateNegotiationAcceptPromptFromData, - client::generateNegotiationRejectPromptFromData); - MetadataContent serverResult = symmetryCase.generate( - server::generateNegotiationProposePromptFromData, - server::generateNegotiationAcceptPromptFromData, - server::generateNegotiationRejectPromptFromData); - - assertNotNull(clientResult.promptText()); - assertEquals(clientResult, serverResult, "case " + symmetryCase.label() + " [" + language + "]"); - assertEquals(clientResult.buildMetadataContent(), serverResult.buildMetadataContent()); - assertEquals(symmetryCase.templateUri(), clientResult.templateUri()); - assertEquals( - symmetryCase.performative(), - clientResult.negotiationContext().performative(), - "stamped performative of case " + symmetryCase.label() + " [" + language + "]"); - } - } - } - - private Path envFile(String language, String fileName) throws IOException { - Path envFile = tempDir.resolve(language + "-" + fileName); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=%s - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=test-symmetry-mock - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted(language)); - return envFile; - } - - private static List sortedKeys(Map surface) { - return surface.keySet().stream().sorted().collect(Collectors.toList()); - } - - private static Map negotiationApiSurface(Class facade) { - Map surface = new LinkedHashMap<>(); - for (Method method : facade.getMethods()) { - if (NEGOTIATION_API_METHODS.contains(method.getName())) { - String parameters = Arrays.stream(method.getParameterTypes()) - .map(Class::getName) - .collect(Collectors.joining(",")); - surface.put( - method.getName(), - parameters + "->" + method.getReturnType().getName()); - } - } - return surface; - } - - /** - * Builds the nine golden fixture cases of the negotiation module: one fixed typed content, context and template URI - * per negotiation type and performative, identical on the client and the server side. - */ - private static List symmetryCases() { - return List.of( - new SymmetryCase( - "information_propose", - NegotiationPerformative.PROPOSE, - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI, - new NegotiationProposeData( - new NegotiationContext( - "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent( - List.of( - new NegotiationItem( - "ran-energy-saving area information", "e.g. Songshan Lake"), - new NegotiationItem("VLANId", null)), - "OR"))), - new SymmetryCase( - "target_propose", - NegotiationPerformative.PROPOSE, - StandardTemplates.TARGET_NEGOTIATION_PROPOSE_URI, - new NegotiationProposeData( - new NegotiationContext( - "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5, NegotiationPerformative.PROPOSE), - new TargetProposeContent( - "Clarify the intent of the ran-energy-saving task.", - List.of(new NegotiationItem("task intent", "ran-energy-saving optimization")), - null, - List.of(new NegotiationItem("area", "which site is covered")), - null))), - new SymmetryCase( - "feasibility_propose", - NegotiationPerformative.PROPOSE, - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE_URI, - new NegotiationProposeData( - new NegotiationContext( - "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, NegotiationPerformative.PROPOSE), - new FeasibilityProposeContent( - "Please assess the adjusted rate target.", - NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - List.of(new NegotiationItem("adjusted target", "rate lowered to 2Mbps")), - null, - null))), - endingCase( - "information_accept", - NegotiationPerformative.ACCEPT, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, - new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of(new NegotiationItem("area information", "Songshan Lake")))), - endingCase( - "target_accept", - NegotiationPerformative.ACCEPT, - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI, - new TargetEndingContent(NegotiationConclusion.ACCEPT, "The confirmed intent.", null)), - endingCase( - "feasibility_accept", - NegotiationPerformative.ACCEPT, - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, - new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "The target is achievable.")), - endingCase( - "information_reject", - NegotiationPerformative.REJECT, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, - new InformationEndingContent( - NegotiationConclusion.REJECT, - List.of(new NegotiationItem("area information", "not available")))), - endingCase( - "target_reject", - NegotiationPerformative.REJECT, - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI, - new TargetEndingContent(NegotiationConclusion.REJECT, null, "The intent is unclear.")), - endingCase( - "feasibility_reject", - NegotiationPerformative.REJECT, - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, - new FeasibilityEndingContent(NegotiationConclusion.REJECT, "The target is not achievable."))); - } - - private static SymmetryCase endingCase( - String label, NegotiationPerformative performative, String templateUri, NegotiationEndingContent content) { - return new SymmetryCase( - label, - performative, - templateUri, - new NegotiationEndingData( - new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, performative), content)); - } - - /** One symmetry case: a fixed input addressed to one of the three from-data generation methods. */ - private record SymmetryCase(String label, NegotiationPerformative performative, String templateUri, Object data) { - - MetadataContent generate(ProposeGenerator propose, EndingGenerator accept, EndingGenerator reject) { - return switch (performative) { - case PROPOSE -> propose.generate((NegotiationProposeData) data, templateUri); - case ACCEPT -> accept.generate((NegotiationEndingData) data, templateUri); - case REJECT -> reject.generate((NegotiationEndingData) data, templateUri); - case ABORT -> throw new IllegalArgumentException("The symmetry cases carry no abort performative."); - }; - } - } - - @FunctionalInterface - private interface ProposeGenerator { - - MetadataContent generate(NegotiationProposeData data, String templateUri); - } - - @FunctionalInterface - private interface EndingGenerator { - - MetadataContent generate(NegotiationEndingData data, String templateUri); - } - - public static final class RecordingClient implements LLMClient { - - private final LLMClientConfig config; - - public RecordingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse("{}", config.model(), Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sample.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies the output symmetry of the negotiation content layer across the client and the server facade. + * + *

            Both facades expose the twelve negotiation API methods as one symmetric surface and produce, for identical inputs, + * identical generation results: the whole MetadataContent record and the metadata map built from it are equal on both + * sides. The inputs are the fixed golden fixture inputs of the negotiation module, so this test also proves that both + * facades are wired to the same built-in resources. + */ +class NegotiationFacadeOutputSymmetryTest { + + private static final String TEST_MOCK_PROVIDER = "test-symmetry-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + private static final List NEGOTIATION_API_METHODS = List.of( + "generateNegotiationProposePromptFromData", + "generateNegotiationAcceptPromptFromData", + "generateNegotiationRejectPromptFromData", + "generateNegotiationAbortPromptFromData", + "generateNegotiationProposePromptFromText", + "generateNegotiationAcceptPromptFromText", + "generateNegotiationRejectPromptFromText", + "generateNegotiationAbortPromptFromText", + "validateProposePromptAndDataFilling", + "validateAcceptPromptAndDataFilling", + "validateRejectPromptAndDataFilling", + "validateAbortPromptAndDataFilling"); + + @TempDir + Path tempDir; + + @Test + void bothFacadesExposeTheSameTwelveMethodSignatures() { + Map clientSurface = negotiationApiSurface(A2ATClient.class); + Map serverSurface = negotiationApiSurface(A2ATServer.class); + + assertEquals(NEGOTIATION_API_METHODS.stream().sorted().toList(), sortedKeys(clientSurface)); + assertEquals(clientSurface, serverSurface); + } + + @Test + void bothFacadesProduceIdenticalMetadataContentForTheSameInput() throws IOException { + for (String language : List.of("zh-CN", "en-US")) { + A2ATClient client = new A2ATClient(envFile(language, "client.env")); + A2ATServer server = new A2ATServer(envFile(language, "server.env")); + + for (SymmetryCase symmetryCase : symmetryCases()) { + MetadataContent clientResult = symmetryCase.generate( + client::generateNegotiationProposePromptFromData, + client::generateNegotiationAcceptPromptFromData, + client::generateNegotiationRejectPromptFromData); + MetadataContent serverResult = symmetryCase.generate( + server::generateNegotiationProposePromptFromData, + server::generateNegotiationAcceptPromptFromData, + server::generateNegotiationRejectPromptFromData); + + assertNotNull(clientResult.promptText()); + assertEquals(clientResult, serverResult, "case " + symmetryCase.label() + " [" + language + "]"); + assertEquals(clientResult.buildMetadataContent(), serverResult.buildMetadataContent()); + assertEquals(symmetryCase.templateUri(), clientResult.templateUri()); + assertEquals( + symmetryCase.performative(), + clientResult.negotiationContext().performative(), + "stamped performative of case " + symmetryCase.label() + " [" + language + "]"); + } + } + } + + private Path envFile(String language, String fileName) throws IOException { + Path envFile = tempDir.resolve(language + "-" + fileName); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=%s + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-symmetry-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(language)); + return envFile; + } + + private static List sortedKeys(Map surface) { + return surface.keySet().stream().sorted().collect(Collectors.toList()); + } + + private static Map negotiationApiSurface(Class facade) { + Map surface = new LinkedHashMap<>(); + for (Method method : facade.getMethods()) { + if (NEGOTIATION_API_METHODS.contains(method.getName())) { + String parameters = Arrays.stream(method.getParameterTypes()) + .map(Class::getName) + .collect(Collectors.joining(",")); + surface.put( + method.getName(), + parameters + "->" + method.getReturnType().getName()); + } + } + return surface; + } + + /** + * Builds the nine golden fixture cases of the negotiation module: one fixed typed content, context and template URI + * per negotiation type and performative, identical on the client and the server side. + */ + private static List symmetryCases() { + return List.of( + new SymmetryCase( + "information_propose", + NegotiationPerformative.PROPOSE, + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI, + new NegotiationProposeData( + new NegotiationContext( + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent( + List.of( + new NegotiationItem( + "ran-energy-saving area information", "e.g. Songshan Lake"), + new NegotiationItem("VLANId", null)), + "OR"))), + new SymmetryCase( + "target_propose", + NegotiationPerformative.PROPOSE, + StandardTemplates.TARGET_NEGOTIATION_PROPOSE_URI, + new NegotiationProposeData( + new NegotiationContext( + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5, NegotiationPerformative.PROPOSE), + new TargetProposeContent( + "Clarify the intent of the ran-energy-saving task.", + List.of(new NegotiationItem("task intent", "ran-energy-saving optimization")), + null, + List.of(new NegotiationItem("area", "which site is covered")), + null))), + new SymmetryCase( + "feasibility_propose", + NegotiationPerformative.PROPOSE, + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE_URI, + new NegotiationProposeData( + new NegotiationContext( + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, NegotiationPerformative.PROPOSE), + new FeasibilityProposeContent( + "Please assess the adjusted rate target.", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("adjusted target", "rate lowered to 2Mbps")), + null, + null))), + endingCase( + "information_accept", + NegotiationPerformative.ACCEPT, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, + new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of(new NegotiationItem("area information", "Songshan Lake")))), + endingCase( + "target_accept", + NegotiationPerformative.ACCEPT, + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "The confirmed intent.", null)), + endingCase( + "feasibility_accept", + NegotiationPerformative.ACCEPT, + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "The target is achievable.")), + endingCase( + "information_reject", + NegotiationPerformative.REJECT, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, + new InformationEndingContent( + NegotiationConclusion.REJECT, + List.of(new NegotiationItem("area information", "not available")))), + endingCase( + "target_reject", + NegotiationPerformative.REJECT, + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI, + new TargetEndingContent(NegotiationConclusion.REJECT, null, "The intent is unclear.")), + endingCase( + "feasibility_reject", + NegotiationPerformative.REJECT, + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, + new FeasibilityEndingContent(NegotiationConclusion.REJECT, "The target is not achievable."))); + } + + private static SymmetryCase endingCase( + String label, NegotiationPerformative performative, String templateUri, NegotiationEndingContent content) { + return new SymmetryCase( + label, + performative, + templateUri, + new NegotiationEndingData( + new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, performative), content)); + } + + /** One symmetry case: a fixed input addressed to one of the three from-data generation methods. */ + private record SymmetryCase(String label, NegotiationPerformative performative, String templateUri, Object data) { + + MetadataContent generate(ProposeGenerator propose, EndingGenerator accept, EndingGenerator reject) { + return switch (performative) { + case PROPOSE -> propose.generate((NegotiationProposeData) data, templateUri); + case ACCEPT -> accept.generate((NegotiationEndingData) data, templateUri); + case REJECT -> reject.generate((NegotiationEndingData) data, templateUri); + case ABORT -> throw new IllegalArgumentException("The symmetry cases carry no abort performative."); + }; + } + } + + @FunctionalInterface + private interface ProposeGenerator { + + MetadataContent generate(NegotiationProposeData data, String templateUri); + } + + @FunctionalInterface + private interface EndingGenerator { + + MetadataContent generate(NegotiationEndingData data, String templateUri); + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainConcurrencyTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainConcurrencyTest.java index 5654c5e4..fe65211e 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainConcurrencyTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainConcurrencyTest.java @@ -1,225 +1,225 @@ -package net.openan.a2at.sample.authz_policy; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class AuthzSampleMainConcurrencyTest { - - private static final String SLOT_EXTRACTION_SYSTEM = "/prompt_resources/prompts/slot_extraction/zh-CN/system.md"; - private static final String CONTENT_VALIDATION_SYSTEM = - "/prompt_resources/prompts/content_validation/zh-CN/system.md"; - - private static final String TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; - - private static final String ENV_CONTENT = - "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-test\n" - + "A2AT_LANGUAGE=zh-CN\nA2AT_PROMPT_SOURCE_TYPE=classpath\n" - + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=\nA2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory\n"; - - private static String loadResource(String path) { - try (InputStream in = AuthzSampleMainConcurrencyTest.class.getResourceAsStream(path)) { - if (in == null) { - throw new IllegalStateException("Resource not found: " + path); - } - return new String(in.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException e) { - throw new IllegalStateException("Failed to read resource: " + path, e); - } - } - - private static List> messagesWithSystem(String systemContent) { - List> messages = new ArrayList<>(); - messages.add(Map.of("role", "system", "content", systemContent)); - messages.add(Map.of("role", "user", "content", "dummy")); - return messages; - } - - private static AuthzScenario successScenario(String label) { - return new AuthzScenario( - label, - "from_text", - Map.of("text", "hello"), - new AuthzExpected( - new ClientExpected(null, "prompt text", null), new ServerExpected("success", null, null))); - } - - private static ScenarioOutcome successOutcome() { - MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); - return new ScenarioOutcome( - new ScenarioResult("success", true, null, List.of(), true, true, null, List.of()), - metadata, - new FilledParamData(Map.of())); - } - - @Test - void should_buildGenerator_withoutCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, ENV_CONTENT); - - AuthzPromptGenerator generator = AuthzSampleMain.buildGenerator(envFile, TEMPLATE_URI, null); - assertNotNull(generator); - } - - @Test - void should_buildValidator_withoutCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, ENV_CONTENT); - - AuthzPromptValidator validator = AuthzSampleMain.buildValidator(envFile, null); - assertNotNull(validator); - } - - @Test - void should_buildGenerator_withCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, ENV_CONTENT); - - AuthzReasoningCapture capture = new AuthzReasoningCapture(); - AuthzPromptGenerator generator = AuthzSampleMain.buildGenerator(envFile, TEMPLATE_URI, capture); - assertNotNull(generator); - } - - @Test - void should_buildValidator_withCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, ENV_CONTENT); - - AuthzReasoningCapture capture = new AuthzReasoningCapture(); - AuthzPromptValidator validator = AuthzSampleMain.buildValidator(envFile, capture); - assertNotNull(validator); - } - - @Test - void should_writeReport_includeReasoningFields_whenReasoningEnabled(@TempDir Path tempDir) throws IOException { - AuthzScenario s = successScenario("test"); - ScenarioOutcome outcome = successOutcome(); - - AuthzReasoningCapture capture = new AuthzReasoningCapture(); - String[] content = {"unused"}; - LLMClient fake = (messages, jsonSchema, temperature, maxTokens) -> - new LLMResponse(content[0], "test-model", Map.of(), Map.of()); - LLMClient wrapped = capture.wrap(fake); - capture.beginScenario("test"); - content[0] = "{\"slots\":{},\"reasoning\":\"client-reason\"}"; - wrapped.structured(messagesWithSystem(loadResource(SLOT_EXTRACTION_SYSTEM)), Map.of(), null, null); - content[0] = "{\"semantic_verdict\":true,\"errors\":[],\"params\":{},\"reasoning\":\"server-reason\"}"; - wrapped.structured(messagesWithSystem(loadResource(CONTENT_VALIDATION_SYSTEM)), Map.of(), null, null); - capture.endScenario(); - - Path reportPath = AuthzSampleMain.writeReport( - List.of(s), List.of(outcome), tempDir, 8, 42.5, "sample/scenarios.json", true, capture); - - String reportContent = Files.readString(reportPath); - assertTrue(reportContent.contains("\"meta\"")); - assertTrue(reportContent.contains("\"workers\"")); - assertTrue(reportContent.contains("\"wallTimeSeconds\"")); - assertTrue(reportContent.contains("\"scenariosResource\"")); - assertTrue(reportContent.contains("\"reasoning\"")); - assertTrue(reportContent.contains("\"client_reasoning\"")); - assertTrue(reportContent.contains("\"server_reasoning\"")); - assertTrue(reportContent.contains("client-reason")); - assertTrue(reportContent.contains("server-reason")); - } - - @Test - void should_writeReport_omitReasoningFields_whenReasoningDisabled(@TempDir Path tempDir) throws IOException { - AuthzScenario s = successScenario("test"); - ScenarioOutcome outcome = successOutcome(); - - Path reportPath = AuthzSampleMain.writeReport( - List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); - - String reportContent = Files.readString(reportPath); - assertTrue(reportContent.contains("\"meta\"")); - assertTrue(reportContent.contains("\"reasoning\"")); - assertFalse(reportContent.contains("client_reasoning")); - assertFalse(reportContent.contains("server_reasoning")); - } - - @Test - void should_writeReport_includeClientReasoningOnly_whenServerMissing(@TempDir Path tempDir) throws IOException { - AuthzScenario s = new AuthzScenario( - "a-only", - "from_text", - Map.of("text", "hello"), - new AuthzExpected(new ClientExpected("slot.rule_violation", null, null), null)); - ScenarioOutcome outcome = new ScenarioOutcome( - new ScenarioResult("slot.rule_violation", false, null, List.of(), null, null, null, List.of()), - null, - null); - - AuthzReasoningCapture capture = new AuthzReasoningCapture(); - String[] content = {"unused"}; - LLMClient fake = (messages, jsonSchema, temperature, maxTokens) -> - new LLMResponse(content[0], "test-model", Map.of(), Map.of()); - LLMClient wrapped = capture.wrap(fake); - capture.beginScenario("a-only"); - content[0] = "{\"slots\":{},\"reasoning\":\"only-client\"}"; - wrapped.structured(messagesWithSystem(loadResource(SLOT_EXTRACTION_SYSTEM)), Map.of(), null, null); - capture.endScenario(); - - Path reportPath = AuthzSampleMain.writeReport( - List.of(s), List.of(outcome), tempDir, 8, 5.0, "sample/scenarios.json", true, capture); - - String reportContent = Files.readString(reportPath); - assertTrue(reportContent.contains("\"client_reasoning\"")); - assertTrue(reportContent.contains("only-client")); - assertFalse(reportContent.contains("server_reasoning")); - } - - @Test - void should_writeReport_useTimestampedFilename(@TempDir Path tempDir) throws IOException { - AuthzScenario s = successScenario("test"); - ScenarioOutcome outcome = successOutcome(); - - Path reportPath = AuthzSampleMain.writeReport( - List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); - - String fileName = reportPath.getFileName().toString(); - assertTrue(fileName.startsWith("authz-report-")); - assertTrue(fileName.endsWith(".json")); - String timestamp = fileName.substring("authz-report-".length(), fileName.length() - ".json".length()); - assertEquals(15, timestamp.length()); - } - - @Test - void should_writeReport_notOverwrite_WhenCalledTwice(@TempDir Path tempDir) - throws IOException, InterruptedException { - AuthzScenario s = successScenario("test"); - ScenarioOutcome outcome = successOutcome(); - - Path report1 = AuthzSampleMain.writeReport( - List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); - Thread.sleep(1100); - Path report2 = AuthzSampleMain.writeReport( - List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); - - assertNotEquals(report1, report2); - assertTrue(Files.exists(report1)); - assertTrue(Files.exists(report2)); - } -} +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AuthzSampleMainConcurrencyTest { + + private static final String SLOT_EXTRACTION_SYSTEM = "/prompt_resources/prompts/slot_extraction/zh-CN/system.md"; + private static final String CONTENT_VALIDATION_SYSTEM = + "/prompt_resources/prompts/content_validation/zh-CN/system.md"; + + private static final String TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; + + private static final String ENV_CONTENT = + "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-test\n" + + "A2AT_LANGUAGE=zh-CN\nA2AT_PROMPT_SOURCE_TYPE=classpath\n" + + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=\nA2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory\n"; + + private static String loadResource(String path) { + try (InputStream in = AuthzSampleMainConcurrencyTest.class.getResourceAsStream(path)) { + if (in == null) { + throw new IllegalStateException("Resource not found: " + path); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("Failed to read resource: " + path, e); + } + } + + private static List> messagesWithSystem(String systemContent) { + List> messages = new ArrayList<>(); + messages.add(Map.of("role", "system", "content", systemContent)); + messages.add(Map.of("role", "user", "content", "dummy")); + return messages; + } + + private static AuthzScenario successScenario(String label) { + return new AuthzScenario( + label, + "from_text", + Map.of("text", "hello"), + new AuthzExpected( + new ClientExpected(null, "prompt text", null), new ServerExpected("success", null, null))); + } + + private static ScenarioOutcome successOutcome() { + MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); + return new ScenarioOutcome( + new ScenarioResult("success", true, null, List.of(), true, true, null, List.of()), + metadata, + new FilledParamData(Map.of())); + } + + @Test + void should_buildGenerator_withoutCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, ENV_CONTENT); + + AuthzPromptGenerator generator = AuthzSampleMain.buildGenerator(envFile, TEMPLATE_URI, null); + assertNotNull(generator); + } + + @Test + void should_buildValidator_withoutCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, ENV_CONTENT); + + AuthzPromptValidator validator = AuthzSampleMain.buildValidator(envFile, null); + assertNotNull(validator); + } + + @Test + void should_buildGenerator_withCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, ENV_CONTENT); + + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + AuthzPromptGenerator generator = AuthzSampleMain.buildGenerator(envFile, TEMPLATE_URI, capture); + assertNotNull(generator); + } + + @Test + void should_buildValidator_withCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, ENV_CONTENT); + + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + AuthzPromptValidator validator = AuthzSampleMain.buildValidator(envFile, capture); + assertNotNull(validator); + } + + @Test + void should_writeReport_includeReasoningFields_whenReasoningEnabled(@TempDir Path tempDir) throws IOException { + AuthzScenario s = successScenario("test"); + ScenarioOutcome outcome = successOutcome(); + + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + String[] content = {"unused"}; + LLMClient fake = (messages, jsonSchema, temperature, maxTokens) -> + new LLMResponse(content[0], "test-model", Map.of(), Map.of()); + LLMClient wrapped = capture.wrap(fake); + capture.beginScenario("test"); + content[0] = "{\"slots\":{},\"reasoning\":\"client-reason\"}"; + wrapped.structured(messagesWithSystem(loadResource(SLOT_EXTRACTION_SYSTEM)), Map.of(), null, null); + content[0] = "{\"semantic_verdict\":true,\"errors\":[],\"params\":{},\"reasoning\":\"server-reason\"}"; + wrapped.structured(messagesWithSystem(loadResource(CONTENT_VALIDATION_SYSTEM)), Map.of(), null, null); + capture.endScenario(); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 42.5, "sample/scenarios.json", true, capture); + + String reportContent = Files.readString(reportPath); + assertTrue(reportContent.contains("\"meta\"")); + assertTrue(reportContent.contains("\"workers\"")); + assertTrue(reportContent.contains("\"wallTimeSeconds\"")); + assertTrue(reportContent.contains("\"scenariosResource\"")); + assertTrue(reportContent.contains("\"reasoning\"")); + assertTrue(reportContent.contains("\"client_reasoning\"")); + assertTrue(reportContent.contains("\"server_reasoning\"")); + assertTrue(reportContent.contains("client-reason")); + assertTrue(reportContent.contains("server-reason")); + } + + @Test + void should_writeReport_omitReasoningFields_whenReasoningDisabled(@TempDir Path tempDir) throws IOException { + AuthzScenario s = successScenario("test"); + ScenarioOutcome outcome = successOutcome(); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); + + String reportContent = Files.readString(reportPath); + assertTrue(reportContent.contains("\"meta\"")); + assertTrue(reportContent.contains("\"reasoning\"")); + assertFalse(reportContent.contains("client_reasoning")); + assertFalse(reportContent.contains("server_reasoning")); + } + + @Test + void should_writeReport_includeClientReasoningOnly_whenServerMissing(@TempDir Path tempDir) throws IOException { + AuthzScenario s = new AuthzScenario( + "a-only", + "from_text", + Map.of("text", "hello"), + new AuthzExpected(new ClientExpected("slot.rule_violation", null, null), null)); + ScenarioOutcome outcome = new ScenarioOutcome( + new ScenarioResult("slot.rule_violation", false, null, List.of(), null, null, null, List.of()), + null, + null); + + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + String[] content = {"unused"}; + LLMClient fake = (messages, jsonSchema, temperature, maxTokens) -> + new LLMResponse(content[0], "test-model", Map.of(), Map.of()); + LLMClient wrapped = capture.wrap(fake); + capture.beginScenario("a-only"); + content[0] = "{\"slots\":{},\"reasoning\":\"only-client\"}"; + wrapped.structured(messagesWithSystem(loadResource(SLOT_EXTRACTION_SYSTEM)), Map.of(), null, null); + capture.endScenario(); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 5.0, "sample/scenarios.json", true, capture); + + String reportContent = Files.readString(reportPath); + assertTrue(reportContent.contains("\"client_reasoning\"")); + assertTrue(reportContent.contains("only-client")); + assertFalse(reportContent.contains("server_reasoning")); + } + + @Test + void should_writeReport_useTimestampedFilename(@TempDir Path tempDir) throws IOException { + AuthzScenario s = successScenario("test"); + ScenarioOutcome outcome = successOutcome(); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); + + String fileName = reportPath.getFileName().toString(); + assertTrue(fileName.startsWith("authz-report-")); + assertTrue(fileName.endsWith(".json")); + String timestamp = fileName.substring("authz-report-".length(), fileName.length() - ".json".length()); + assertEquals(15, timestamp.length()); + } + + @Test + void should_writeReport_notOverwrite_WhenCalledTwice(@TempDir Path tempDir) + throws IOException, InterruptedException { + AuthzScenario s = successScenario("test"); + ScenarioOutcome outcome = successOutcome(); + + Path report1 = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); + Thread.sleep(1100); + Path report2 = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); + + assertNotEquals(report1, report2); + assertTrue(Files.exists(report1)); + assertTrue(Files.exists(report2)); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java index 49961d20..ce242775 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java @@ -1,237 +1,237 @@ -package net.openan.a2at.sample.authz_policy; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.PrintStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; -import net.openan.a2at.sdk.core.model.MetadataContent; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class AuthzSampleMainTest { - - private static final AuthzExpected SUCCESS = new AuthzExpected( - new ClientExpected(null, "## rendered prompt", null), new ServerExpected("success", null, null)); - private static final ScenarioResult MATCH_RESULT = - new ScenarioResult("success", true, null, List.of(), true, true, true, List.of()); - private static final ScenarioResult MISMATCH_RESULT = - new ScenarioResult("slot.rule_violation", false, null, List.of(), null, null, null, List.of()); - - private PrintStream originalOut; - private ByteArrayOutputStream outContent; - - @BeforeEach - void setUp() { - originalOut = System.out; - outContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); - } - - @AfterEach - void tearDown() { - System.setOut(originalOut); - } - - @Test - void should_returnZeroExitCode_WhenAllScenariosMatch() { - assertEquals(0, AuthzSampleMain.exitCode(List.of(MATCH_RESULT, MATCH_RESULT, MATCH_RESULT))); - } - - @Test - void should_returnNonZeroExitCode_WhenAnyMismatch() { - assertTrue(AuthzSampleMain.exitCode(List.of(MATCH_RESULT, MISMATCH_RESULT, MATCH_RESULT)) != 0); - } - - @Test - void should_resolveEnvPath_ArgsFirst() { - Path result = AuthzSampleMain.resolveEnvPath(new String[] {"custom.env"}); - assertEquals(Path.of("custom.env"), result); - } - - @Test - void should_resolveEnvPath_WorkingDirFallback() throws IOException { - Path cwdEnv = Path.of("authz.env"); - try { - Files.writeString(cwdEnv, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-test\n"); - Path result = AuthzSampleMain.resolveEnvPath(new String[0]); - assertEquals(cwdEnv.toAbsolutePath(), result.toAbsolutePath()); - } finally { - Files.deleteIfExists(cwdEnv); - } - } - - @Test - void should_resolveEnvPath_ClasspathFallback() { - Path result = AuthzSampleMain.resolveEnvPath(new String[0]); - assertTrue( - result.toString().contains("authz-policy") && result.toString().endsWith("authz.env")); - } - - @Test - void should_hasRequiredLlmKeys_ReturnTrue_WhenAllKeysPresent(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-12345\n"); - assertTrue(AuthzSampleMain.hasRequiredLlmKeys(envFile)); - } - - @Test - void should_hasRequiredLlmKeys_ReturnFalse_WhenKeyMissing(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\n"); - assertFalse(AuthzSampleMain.hasRequiredLlmKeys(envFile)); - } - - @Test - void should_hasRequiredLlmKeys_ReturnFalse_WhenKeyBlank(@TempDir Path tempDir) throws IOException { - Path envFile = tempDir.resolve("test.env"); - Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=\n"); - assertFalse(AuthzSampleMain.hasRequiredLlmKeys(envFile)); - } - - @Test - void should_hasRequiredLlmKeys_ReturnFalse_WhenFileNotFound() { - assertFalse(AuthzSampleMain.hasRequiredLlmKeys(Path.of("nonexistent.env"))); - } - - @Test - void should_loadParamSchema_ReturnBusinessLevelSchema() { - Map schema = AuthzSampleMain.loadParamSchema(); - - assertNotNull(schema); - assertTrue(schema.containsKey("properties")); - Map properties = (Map) schema.get("properties"); - assertTrue(properties.containsKey("operationType")); - assertTrue(properties.containsKey("policyList")); - } - - @Test - void should_printScenarioReport_WhenMetadataNull() { - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - ScenarioOutcome outcome = new ScenarioOutcome(MISMATCH_RESULT, null, null); - AuthzSampleMain.printScenarioReport(scenario, outcome); - String output = outContent.toString(); - assertTrue(output.contains("--- Scenario: test ---")); - assertTrue(output.contains("[Client]")); - assertTrue(output.contains("slot.rule_violation")); - assertTrue(output.contains("(跳过 - 客户端生成失败)")); - } - - @Test - void should_printScenarioReport_WhenFilledNull() { - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); - ScenarioResult result = new ScenarioResult( - "negotiation.semantic_rejected", false, null, List.of(), true, false, null, List.of()); - ScenarioOutcome outcome = new ScenarioOutcome(result, metadata, null); - AuthzSampleMain.printScenarioReport(scenario, outcome); - String output = outContent.toString(); - assertTrue(output.contains("[Server]")); - assertTrue(output.contains("negotiation.semantic_rejected")); - assertTrue(output.contains("校验结果")); - } - - @Test - void should_printSummary() { - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - AuthzSampleMain.printSummary(List.of(scenario), List.of(MATCH_RESULT, MISMATCH_RESULT, MISMATCH_RESULT)); - String output = outContent.toString(); - assertTrue(output.contains("Match")); - assertTrue(output.contains("Mismatch")); - } - - @Test - void should_writeReport_WithStagedExpectationsAndAssertions(@TempDir Path tempDir) throws IOException { - AuthzScenario scenario = new AuthzScenario( - "test", - "from_text", - Map.of("text", "hello"), - new AuthzExpected( - new ClientExpected(null, "prompt text", null), - new ServerExpected("success", null, Map.of("slot1", "expected_value")))); - MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); - ScenarioOutcome outcome = new ScenarioOutcome( - MATCH_RESULT, - metadata, - new net.openan.a2at.sdk.core.model.FilledParamData(Map.of("slot1", "actual_value"))); - - Path reportPath = AuthzSampleMain.writeReport( - List.of(scenario), - List.of(outcome), - tempDir, - 8, - 12.5, - "sample/authz-policy/scenarios.json", - false, - null); - - assertTrue(Files.exists(reportPath)); - String content = Files.readString(reportPath); - assertTrue(content.contains("\"expected_client\"")); - assertTrue(content.contains("\"expected_server\"")); - assertTrue(content.contains("\"actual_outcome\"")); - assertTrue(content.contains("\"success\"")); - assertTrue(content.contains("\"match\"")); - assertTrue(content.contains("\"expected_value\"")); - assertTrue(content.contains("\"actual_params\"")); - assertTrue(content.contains("\"actual_value\"")); - assertTrue(content.contains("\"prompt_text\"")); - assertTrue(content.contains("\"prompt text\"")); - assertTrue(content.contains("\"assertions\"")); - assertTrue(content.contains("\"actual_slot_errors\"")); - } - - @Test - void should_writeReport_WithSlotErrors(@TempDir Path tempDir) throws IOException { - AuthzScenario scenario = new AuthzScenario( - "test", - "from_text", - Map.of("text", "hello"), - new AuthzExpected( - new ClientExpected( - "slot.not_provided", - null, - List.of(new AuthzScenario.SlotErrorExpectation("授权策略的操作类型", "slot.not_provided"))), - null)); - ScenarioResult result = new ScenarioResult( - "slot.not_provided", - true, - null, - List.of(new net.openan.a2at.sdk.core.model.SlotValidationError( - "授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。")), - null, - null, - null, - List.of()); - ScenarioOutcome outcome = new ScenarioOutcome(result, null, null); - - Path reportPath = AuthzSampleMain.writeReport( - List.of(scenario), - List.of(outcome), - tempDir, - 8, - 12.5, - "sample/authz-policy/scenarios.json", - false, - null); - - String content = Files.readString(reportPath); - assertTrue(content.contains("授权策略的操作类型")); - assertTrue(content.contains("slot.not_provided")); - assertTrue(content.contains("输入中未提供「授权策略的操作类型」。")); - } -} +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AuthzSampleMainTest { + + private static final AuthzExpected SUCCESS = new AuthzExpected( + new ClientExpected(null, "## rendered prompt", null), new ServerExpected("success", null, null)); + private static final ScenarioResult MATCH_RESULT = + new ScenarioResult("success", true, null, List.of(), true, true, true, List.of()); + private static final ScenarioResult MISMATCH_RESULT = + new ScenarioResult("slot.rule_violation", false, null, List.of(), null, null, null, List.of()); + + private PrintStream originalOut; + private ByteArrayOutputStream outContent; + + @BeforeEach + void setUp() { + originalOut = System.out; + outContent = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outContent)); + } + + @AfterEach + void tearDown() { + System.setOut(originalOut); + } + + @Test + void should_returnZeroExitCode_WhenAllScenariosMatch() { + assertEquals(0, AuthzSampleMain.exitCode(List.of(MATCH_RESULT, MATCH_RESULT, MATCH_RESULT))); + } + + @Test + void should_returnNonZeroExitCode_WhenAnyMismatch() { + assertTrue(AuthzSampleMain.exitCode(List.of(MATCH_RESULT, MISMATCH_RESULT, MATCH_RESULT)) != 0); + } + + @Test + void should_resolveEnvPath_ArgsFirst() { + Path result = AuthzSampleMain.resolveEnvPath(new String[] {"custom.env"}); + assertEquals(Path.of("custom.env"), result); + } + + @Test + void should_resolveEnvPath_WorkingDirFallback() throws IOException { + Path cwdEnv = Path.of("authz.env"); + try { + Files.writeString(cwdEnv, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-test\n"); + Path result = AuthzSampleMain.resolveEnvPath(new String[0]); + assertEquals(cwdEnv.toAbsolutePath(), result.toAbsolutePath()); + } finally { + Files.deleteIfExists(cwdEnv); + } + } + + @Test + void should_resolveEnvPath_ClasspathFallback() { + Path result = AuthzSampleMain.resolveEnvPath(new String[0]); + assertTrue( + result.toString().contains("authz-policy") && result.toString().endsWith("authz.env")); + } + + @Test + void should_hasRequiredLlmKeys_ReturnTrue_WhenAllKeysPresent(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-12345\n"); + assertTrue(AuthzSampleMain.hasRequiredLlmKeys(envFile)); + } + + @Test + void should_hasRequiredLlmKeys_ReturnFalse_WhenKeyMissing(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\n"); + assertFalse(AuthzSampleMain.hasRequiredLlmKeys(envFile)); + } + + @Test + void should_hasRequiredLlmKeys_ReturnFalse_WhenKeyBlank(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=\n"); + assertFalse(AuthzSampleMain.hasRequiredLlmKeys(envFile)); + } + + @Test + void should_hasRequiredLlmKeys_ReturnFalse_WhenFileNotFound() { + assertFalse(AuthzSampleMain.hasRequiredLlmKeys(Path.of("nonexistent.env"))); + } + + @Test + void should_loadParamSchema_ReturnBusinessLevelSchema() { + Map schema = AuthzSampleMain.loadParamSchema(); + + assertNotNull(schema); + assertTrue(schema.containsKey("properties")); + Map properties = (Map) schema.get("properties"); + assertTrue(properties.containsKey("operationType")); + assertTrue(properties.containsKey("policyList")); + } + + @Test + void should_printScenarioReport_WhenMetadataNull() { + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + ScenarioOutcome outcome = new ScenarioOutcome(MISMATCH_RESULT, null, null); + AuthzSampleMain.printScenarioReport(scenario, outcome); + String output = outContent.toString(); + assertTrue(output.contains("--- Scenario: test ---")); + assertTrue(output.contains("[Client]")); + assertTrue(output.contains("slot.rule_violation")); + assertTrue(output.contains("(跳过 - 客户端生成失败)")); + } + + @Test + void should_printScenarioReport_WhenFilledNull() { + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); + ScenarioResult result = new ScenarioResult( + "negotiation.semantic_rejected", false, null, List.of(), true, false, null, List.of()); + ScenarioOutcome outcome = new ScenarioOutcome(result, metadata, null); + AuthzSampleMain.printScenarioReport(scenario, outcome); + String output = outContent.toString(); + assertTrue(output.contains("[Server]")); + assertTrue(output.contains("negotiation.semantic_rejected")); + assertTrue(output.contains("校验结果")); + } + + @Test + void should_printSummary() { + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + AuthzSampleMain.printSummary(List.of(scenario), List.of(MATCH_RESULT, MISMATCH_RESULT, MISMATCH_RESULT)); + String output = outContent.toString(); + assertTrue(output.contains("Match")); + assertTrue(output.contains("Mismatch")); + } + + @Test + void should_writeReport_WithStagedExpectationsAndAssertions(@TempDir Path tempDir) throws IOException { + AuthzScenario scenario = new AuthzScenario( + "test", + "from_text", + Map.of("text", "hello"), + new AuthzExpected( + new ClientExpected(null, "prompt text", null), + new ServerExpected("success", null, Map.of("slot1", "expected_value")))); + MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); + ScenarioOutcome outcome = new ScenarioOutcome( + MATCH_RESULT, + metadata, + new net.openan.a2at.sdk.core.model.FilledParamData(Map.of("slot1", "actual_value"))); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(scenario), + List.of(outcome), + tempDir, + 8, + 12.5, + "sample/authz-policy/scenarios.json", + false, + null); + + assertTrue(Files.exists(reportPath)); + String content = Files.readString(reportPath); + assertTrue(content.contains("\"expected_client\"")); + assertTrue(content.contains("\"expected_server\"")); + assertTrue(content.contains("\"actual_outcome\"")); + assertTrue(content.contains("\"success\"")); + assertTrue(content.contains("\"match\"")); + assertTrue(content.contains("\"expected_value\"")); + assertTrue(content.contains("\"actual_params\"")); + assertTrue(content.contains("\"actual_value\"")); + assertTrue(content.contains("\"prompt_text\"")); + assertTrue(content.contains("\"prompt text\"")); + assertTrue(content.contains("\"assertions\"")); + assertTrue(content.contains("\"actual_slot_errors\"")); + } + + @Test + void should_writeReport_WithSlotErrors(@TempDir Path tempDir) throws IOException { + AuthzScenario scenario = new AuthzScenario( + "test", + "from_text", + Map.of("text", "hello"), + new AuthzExpected( + new ClientExpected( + "slot.not_provided", + null, + List.of(new AuthzScenario.SlotErrorExpectation("授权策略的操作类型", "slot.not_provided"))), + null)); + ScenarioResult result = new ScenarioResult( + "slot.not_provided", + true, + null, + List.of(new net.openan.a2at.sdk.core.model.SlotValidationError( + "授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。")), + null, + null, + null, + List.of()); + ScenarioOutcome outcome = new ScenarioOutcome(result, null, null); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(scenario), + List.of(outcome), + tempDir, + 8, + 12.5, + "sample/authz-policy/scenarios.json", + false, + null); + + String content = Files.readString(reportPath); + assertTrue(content.contains("授权策略的操作类型")); + assertTrue(content.contains("slot.not_provided")); + assertTrue(content.contains("输入中未提供「授权策略的操作类型」。")); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutorTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutorTest.java index 61e906cd..c859a8bd 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutorTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutorTest.java @@ -1,197 +1,197 @@ -package net.openan.a2at.sample.authz_policy; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -class AuthzScenarioExecutorTest { - - private static final String TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; - private static final Map PARAM_SCHEMA = Map.of("properties", Map.of(), "required", Map.of()); - private static final AuthzExpected SUCCESS = new AuthzExpected( - new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, null)); - - @AfterEach - void clearProperty() { - System.clearProperty("authz.workers"); - } - - private static AuthzScenario scenario(String label) { - return new AuthzScenario(label, "from_text", Map.of("text", "hello"), SUCCESS); - } - - @Test - void should_preserveInputOrder_WhenSlowerScenarioFinishesLast() throws Exception { - CountDownLatch gate = new CountDownLatch(2); - AuthzPromptGenerator generator = scenario -> { - if ("c0".equals(scenario.label())) { - try { - gate.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } else { - gate.countDown(); - } - return new MetadataContent(TEMPLATE_URI, scenario.label(), "Authorization-T/v1"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - List scenarios = List.of(scenario("c0"), scenario("c1"), scenario("c2")); - List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 3, null); - - assertEquals(3, outcomes.size()); - assertEquals("c0", outcomes.get(0).metadata().promptText()); - assertEquals("c1", outcomes.get(1).metadata().promptText()); - assertEquals("c2", outcomes.get(2).metadata().promptText()); - } - - @Test - void should_isolateFailures_WhenTaskThrows() { - AuthzPromptGenerator generator = scenario -> { - switch (scenario.label()) { - case "boom-runtime": - throw new RuntimeException("runtime failure"); - case "boom-assert": - throw new AssertionError("assertion failure"); - default: - return new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - } - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - List scenarios = - List.of(scenario("ok1"), scenario("boom-runtime"), scenario("ok2"), scenario("boom-assert")); - List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 4, null); - - assertEquals(4, outcomes.size()); - assertTrue(outcomes.get(0).result().match()); - assertEquals("infra.internal_error", outcomes.get(1).result().outcome()); - assertFalse(outcomes.get(1).result().match()); - assertTrue(outcomes.get(2).result().match()); - assertEquals("infra.internal_error", outcomes.get(3).result().outcome()); - assertFalse(outcomes.get(3).result().match()); - assertNotNull(outcomes.get(3).result().slotErrors()); - assertEquals(1, outcomes.get(3).result().slotErrors().size()); - assertEquals("_llm", outcomes.get(3).result().slotErrors().get(0).slotName()); - } - - @Test - void should_deliverProgressCallbacks_WithDistinctCompletionIndices() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - ConcurrentHashMap seen = new ConcurrentHashMap<>(); - List scenarios = List.of(scenario("a"), scenario("b"), scenario("c"), scenario("d")); - - List outcomes = - executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 4, (index, outcome, elapsed) -> { - assertTrue(elapsed >= 0.0); - seen.put(index, elapsed); - }); - - assertEquals(4, outcomes.size()); - assertEquals(Set.of(1, 2, 3, 4), seen.keySet()); - } - - @Test - void should_runAllScenariosSerially_WhenSingleWorker() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - List scenarios = List.of(scenario("a"), scenario("b"), scenario("c")); - List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 1, null); - - assertEquals(3, outcomes.size()); - for (ScenarioOutcome outcome : outcomes) { - assertTrue(outcome.result().match()); - } - } - - @Test - void should_returnEmptyList_WhenNoScenarios() { - AuthzScenarioRunner runner = new AuthzScenarioRunner( - s -> new MetadataContent("uri", "prompt", "ext"), (p, s, t) -> new FilledParamData(Map.of())); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - AtomicInteger callbackCalls = new AtomicInteger(); - List outcomes = executor.executeAll( - List.of(), PARAM_SCHEMA, TEMPLATE_URI, 8, (index, outcome, elapsed) -> callbackCalls.incrementAndGet()); - - assertTrue(outcomes.isEmpty()); - assertEquals(0, callbackCalls.get()); - } - - @Test - void should_notThrow_WhenProgressListenerIsNull() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); - - List outcomes = - executor.executeAll(List.of(scenario("a")), PARAM_SCHEMA, TEMPLATE_URI, 1, null); - - assertEquals(1, outcomes.size()); - assertTrue(outcomes.get(0).result().match()); - } - - @Test - void should_resolveWorkers_DefaultTo8() { - System.clearProperty("authz.workers"); - assertEquals(8, AuthzScenarioExecutor.resolveWorkers()); - } - - @Test - void should_resolveWorkers_FromSystemProperty() { - System.setProperty("authz.workers", "3"); - assertEquals(3, AuthzScenarioExecutor.resolveWorkers()); - } - - @Test - void should_resolveWorkers_ThrowOnNonPositive() { - for (String value : List.of("0", "-1")) { - System.setProperty("authz.workers", value); - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, AuthzScenarioExecutor::resolveWorkers); - assertTrue(ex.getMessage().contains("authz.workers")); - } - } - - @Test - void should_resolveWorkers_ThrowOnNonNumeric() { - System.setProperty("authz.workers", "abc"); - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, AuthzScenarioExecutor::resolveWorkers); - assertTrue(ex.getMessage().contains("authz.workers")); - } -} +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class AuthzScenarioExecutorTest { + + private static final String TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; + private static final Map PARAM_SCHEMA = Map.of("properties", Map.of(), "required", Map.of()); + private static final AuthzExpected SUCCESS = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, null)); + + @AfterEach + void clearProperty() { + System.clearProperty("authz.workers"); + } + + private static AuthzScenario scenario(String label) { + return new AuthzScenario(label, "from_text", Map.of("text", "hello"), SUCCESS); + } + + @Test + void should_preserveInputOrder_WhenSlowerScenarioFinishesLast() throws Exception { + CountDownLatch gate = new CountDownLatch(2); + AuthzPromptGenerator generator = scenario -> { + if ("c0".equals(scenario.label())) { + try { + gate.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } else { + gate.countDown(); + } + return new MetadataContent(TEMPLATE_URI, scenario.label(), "Authorization-T/v1"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + List scenarios = List.of(scenario("c0"), scenario("c1"), scenario("c2")); + List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 3, null); + + assertEquals(3, outcomes.size()); + assertEquals("c0", outcomes.get(0).metadata().promptText()); + assertEquals("c1", outcomes.get(1).metadata().promptText()); + assertEquals("c2", outcomes.get(2).metadata().promptText()); + } + + @Test + void should_isolateFailures_WhenTaskThrows() { + AuthzPromptGenerator generator = scenario -> { + switch (scenario.label()) { + case "boom-runtime": + throw new RuntimeException("runtime failure"); + case "boom-assert": + throw new AssertionError("assertion failure"); + default: + return new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + } + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + List scenarios = + List.of(scenario("ok1"), scenario("boom-runtime"), scenario("ok2"), scenario("boom-assert")); + List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 4, null); + + assertEquals(4, outcomes.size()); + assertTrue(outcomes.get(0).result().match()); + assertEquals("infra.internal_error", outcomes.get(1).result().outcome()); + assertFalse(outcomes.get(1).result().match()); + assertTrue(outcomes.get(2).result().match()); + assertEquals("infra.internal_error", outcomes.get(3).result().outcome()); + assertFalse(outcomes.get(3).result().match()); + assertNotNull(outcomes.get(3).result().slotErrors()); + assertEquals(1, outcomes.get(3).result().slotErrors().size()); + assertEquals("_llm", outcomes.get(3).result().slotErrors().get(0).slotName()); + } + + @Test + void should_deliverProgressCallbacks_WithDistinctCompletionIndices() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + ConcurrentHashMap seen = new ConcurrentHashMap<>(); + List scenarios = List.of(scenario("a"), scenario("b"), scenario("c"), scenario("d")); + + List outcomes = + executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 4, (index, outcome, elapsed) -> { + assertTrue(elapsed >= 0.0); + seen.put(index, elapsed); + }); + + assertEquals(4, outcomes.size()); + assertEquals(Set.of(1, 2, 3, 4), seen.keySet()); + } + + @Test + void should_runAllScenariosSerially_WhenSingleWorker() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + List scenarios = List.of(scenario("a"), scenario("b"), scenario("c")); + List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 1, null); + + assertEquals(3, outcomes.size()); + for (ScenarioOutcome outcome : outcomes) { + assertTrue(outcome.result().match()); + } + } + + @Test + void should_returnEmptyList_WhenNoScenarios() { + AuthzScenarioRunner runner = new AuthzScenarioRunner( + s -> new MetadataContent("uri", "prompt", "ext"), (p, s, t) -> new FilledParamData(Map.of())); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + AtomicInteger callbackCalls = new AtomicInteger(); + List outcomes = executor.executeAll( + List.of(), PARAM_SCHEMA, TEMPLATE_URI, 8, (index, outcome, elapsed) -> callbackCalls.incrementAndGet()); + + assertTrue(outcomes.isEmpty()); + assertEquals(0, callbackCalls.get()); + } + + @Test + void should_notThrow_WhenProgressListenerIsNull() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + List outcomes = + executor.executeAll(List.of(scenario("a")), PARAM_SCHEMA, TEMPLATE_URI, 1, null); + + assertEquals(1, outcomes.size()); + assertTrue(outcomes.get(0).result().match()); + } + + @Test + void should_resolveWorkers_DefaultTo8() { + System.clearProperty("authz.workers"); + assertEquals(8, AuthzScenarioExecutor.resolveWorkers()); + } + + @Test + void should_resolveWorkers_FromSystemProperty() { + System.setProperty("authz.workers", "3"); + assertEquals(3, AuthzScenarioExecutor.resolveWorkers()); + } + + @Test + void should_resolveWorkers_ThrowOnNonPositive() { + for (String value : List.of("0", "-1")) { + System.setProperty("authz.workers", value); + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, AuthzScenarioExecutor::resolveWorkers); + assertTrue(ex.getMessage().contains("authz.workers")); + } + } + + @Test + void should_resolveWorkers_ThrowOnNonNumeric() { + System.setProperty("authz.workers", "abc"); + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, AuthzScenarioExecutor::resolveWorkers); + assertTrue(ex.getMessage().contains("authz.workers")); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java index 5aee1726..d46f8c3c 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java @@ -1,169 +1,169 @@ -package net.openan.a2at.sample.authz_policy; - -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 java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class AuthzScenarioLoaderTest { - - @Test - void should_loadAndValidateAllScenarios() { - List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios.json"); - - assertEquals(15, scenarios.size()); - // 预期成功在前(index 0-7),预期拒绝在中(index 8-12),客户端拦截在末尾(index 13-14) - assertEquals("c1-nl-add-01", scenarios.get(0).label()); - assertEquals("from_text", scenarios.get(0).entry()); - assertEquals("success", scenarios.get(0).expected().server().outcome()); - assertEquals("c1-nl-add-01-varname", scenarios.get(1).label()); - assertEquals("from_text", scenarios.get(1).entry()); - assertEquals("success", scenarios.get(1).expected().server().outcome()); - assertNotNull(scenarios.get(1).validateSchema()); - assertEquals("c3-nl-mod-06", scenarios.get(3).label()); - assertEquals("success", scenarios.get(3).expected().server().outcome()); - assertEquals("c3-nl-mod-06-varfields", scenarios.get(4).label()); - assertEquals("success", scenarios.get(4).expected().server().outcome()); - assertNotNull(scenarios.get(4).validateSchema()); - assertEquals("b4-nl-bad-id-01", scenarios.get(7).label()); - assertEquals("success", scenarios.get(7).expected().server().outcome()); - assertEquals("b3-nl-invalid-mod-01", scenarios.get(8).label()); - assertEquals( - "negotiation.semantic_rejected", - scenarios.get(8).expected().server().outcome()); - assertEquals("b2-nl-format-01", scenarios.get(9).label()); - assertEquals( - "negotiation.semantic_rejected", - scenarios.get(9).expected().server().outcome()); - assertEquals("b2-nl-format-01-varreq", scenarios.get(10).label()); - assertEquals( - "negotiation.semantic_rejected", - scenarios.get(10).expected().server().outcome()); - assertNotNull(scenarios.get(10).validateSchema()); - assertEquals("b1-nl-missing-01", scenarios.get(11).label()); - assertEquals( - "negotiation.semantic_rejected", - scenarios.get(11).expected().server().outcome()); - assertEquals("c6-nl-mixed-07", scenarios.get(12).label()); - assertEquals( - "negotiation.semantic_rejected", - scenarios.get(12).expected().server().outcome()); - assertEquals("a-data-starve-01", scenarios.get(13).label()); - assertEquals("from_data_with_schema", scenarios.get(13).entry()); - assertEquals("slot.not_provided", scenarios.get(13).expected().client().outcome()); - assertNull(scenarios.get(13).expected().server()); - assertEquals("a-nl-neg-01", scenarios.get(14).label()); - assertEquals("slot.not_provided", scenarios.get(14).expected().client().outcome()); - assertNull(scenarios.get(14).expected().server()); - } - - @Test - void should_loadAndValidateFullSuiteScenarios() { - List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios-100.json"); - - assertEquals(102, scenarios.size()); - long variants = scenarios.stream() - .filter(s -> s.label().endsWith("-varname") - || s.label().endsWith("-varfields") - || s.label().endsWith("-varflat") - || s.label().endsWith("-varreq") - || s.label().endsWith("-varsch") - || s.label().endsWith("-varuuid") - || s.label().endsWith("-vardesc") - || s.label().endsWith("-dual")) - .count(); - assertEquals(47, variants); - long clientInterceptions = scenarios.stream() - .filter(s -> - s.expected().client() != null && s.expected().client().outcome() != null) - .count(); - assertEquals(9, clientInterceptions); - long serverRejections = scenarios.stream() - .filter(s -> s.expected().server() != null - && "negotiation.semantic_rejected" - .equals(s.expected().server().outcome())) - .count(); - assertEquals(37, serverRejections); - long validateSchemas = - scenarios.stream().filter(s -> s.validateSchema() != null).count(); - assertEquals(43, validateSchemas); - } - - @Test - void should_throwIllegalStateException_WhenResourceNotFound() { - IllegalStateException ex = assertThrows( - IllegalStateException.class, () -> AuthzScenarioLoader.load("sample/authz-policy/nonexistent.json")); - - assertTrue(ex.getMessage().contains("not found")); - } - - @Test - void should_throwIllegalStateException_WhenJsonMalformed() { - IllegalStateException ex = assertThrows( - IllegalStateException.class, () -> AuthzScenarioLoader.load("sample/authz-policy/test/malformed.json")); - - assertTrue(ex.getMessage().contains("malformed") || ex.getMessage().contains("parse")); - } - - @Test - void should_throwIllegalArgumentException_WhenScenarioHasInvalidEntry() { - IllegalArgumentException ex = assertThrows( - IllegalArgumentException.class, - () -> AuthzScenarioLoader.load("sample/authz-policy/test/invalid-scenarios.json")); - - assertTrue(ex.getMessage().contains("bad-scenario")); - } - - @Test - void should_throwIllegalStateException_WhenScenariosIsNotAnArray() { - IllegalStateException ex = assertThrows( - IllegalStateException.class, - () -> AuthzScenarioLoader.load("sample/authz-policy/test/scenarios-as-string.json")); - - assertTrue(ex.getMessage().contains("scenarios")); - } - - @Test - void should_throwIllegalStateException_WhenScenarioLabelIsNotAString() { - IllegalStateException ex = assertThrows( - IllegalStateException.class, - () -> AuthzScenarioLoader.load("sample/authz-policy/test/scenario-label-non-string.json")); - - assertTrue(ex.getMessage().contains("label")); - } - - @Test - void should_parseValidateSchema_WhenPresent() { - List scenarios = - AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-scenarios.json"); - - assertEquals(2, scenarios.size()); - AuthzScenario present = scenarios.get(0); - assertEquals("vs-present", present.label()); - assertNotNull(present.validateSchema()); - assertEquals("应为简短的处置动作短语", ((Map) present.validateSchema().get("动网操作的授权策略列表")).get("处置类型")); - } - - @Test - void should_haveNullValidateSchema_WhenAbsent() { - List scenarios = - AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-scenarios.json"); - - assertEquals("vs-missing", scenarios.get(1).label()); - assertNull(scenarios.get(1).validateSchema()); - } - - @Test - void should_throwIllegalStateException_WhenValidateSchemaIsEmpty() { - IllegalStateException ex = assertThrows( - IllegalStateException.class, - () -> AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-empty.json")); - - assertTrue(ex.getMessage().contains("validate_schema")); - } -} +package net.openan.a2at.sample.authz_policy; + +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 java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AuthzScenarioLoaderTest { + + @Test + void should_loadAndValidateAllScenarios() { + List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios.json"); + + assertEquals(15, scenarios.size()); + // 预期成功在前(index 0-7),预期拒绝在中(index 8-12),客户端拦截在末尾(index 13-14) + assertEquals("c1-nl-add-01", scenarios.get(0).label()); + assertEquals("from_text", scenarios.get(0).entry()); + assertEquals("success", scenarios.get(0).expected().server().outcome()); + assertEquals("c1-nl-add-01-varname", scenarios.get(1).label()); + assertEquals("from_text", scenarios.get(1).entry()); + assertEquals("success", scenarios.get(1).expected().server().outcome()); + assertNotNull(scenarios.get(1).validateSchema()); + assertEquals("c3-nl-mod-06", scenarios.get(3).label()); + assertEquals("success", scenarios.get(3).expected().server().outcome()); + assertEquals("c3-nl-mod-06-varfields", scenarios.get(4).label()); + assertEquals("success", scenarios.get(4).expected().server().outcome()); + assertNotNull(scenarios.get(4).validateSchema()); + assertEquals("b4-nl-bad-id-01", scenarios.get(7).label()); + assertEquals("success", scenarios.get(7).expected().server().outcome()); + assertEquals("b3-nl-invalid-mod-01", scenarios.get(8).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(8).expected().server().outcome()); + assertEquals("b2-nl-format-01", scenarios.get(9).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(9).expected().server().outcome()); + assertEquals("b2-nl-format-01-varreq", scenarios.get(10).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(10).expected().server().outcome()); + assertNotNull(scenarios.get(10).validateSchema()); + assertEquals("b1-nl-missing-01", scenarios.get(11).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(11).expected().server().outcome()); + assertEquals("c6-nl-mixed-07", scenarios.get(12).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(12).expected().server().outcome()); + assertEquals("a-data-starve-01", scenarios.get(13).label()); + assertEquals("from_data_with_schema", scenarios.get(13).entry()); + assertEquals("slot.not_provided", scenarios.get(13).expected().client().outcome()); + assertNull(scenarios.get(13).expected().server()); + assertEquals("a-nl-neg-01", scenarios.get(14).label()); + assertEquals("slot.not_provided", scenarios.get(14).expected().client().outcome()); + assertNull(scenarios.get(14).expected().server()); + } + + @Test + void should_loadAndValidateFullSuiteScenarios() { + List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios-100.json"); + + assertEquals(102, scenarios.size()); + long variants = scenarios.stream() + .filter(s -> s.label().endsWith("-varname") + || s.label().endsWith("-varfields") + || s.label().endsWith("-varflat") + || s.label().endsWith("-varreq") + || s.label().endsWith("-varsch") + || s.label().endsWith("-varuuid") + || s.label().endsWith("-vardesc") + || s.label().endsWith("-dual")) + .count(); + assertEquals(47, variants); + long clientInterceptions = scenarios.stream() + .filter(s -> + s.expected().client() != null && s.expected().client().outcome() != null) + .count(); + assertEquals(9, clientInterceptions); + long serverRejections = scenarios.stream() + .filter(s -> s.expected().server() != null + && "negotiation.semantic_rejected" + .equals(s.expected().server().outcome())) + .count(); + assertEquals(37, serverRejections); + long validateSchemas = + scenarios.stream().filter(s -> s.validateSchema() != null).count(); + assertEquals(43, validateSchemas); + } + + @Test + void should_throwIllegalStateException_WhenResourceNotFound() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, () -> AuthzScenarioLoader.load("sample/authz-policy/nonexistent.json")); + + assertTrue(ex.getMessage().contains("not found")); + } + + @Test + void should_throwIllegalStateException_WhenJsonMalformed() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, () -> AuthzScenarioLoader.load("sample/authz-policy/test/malformed.json")); + + assertTrue(ex.getMessage().contains("malformed") || ex.getMessage().contains("parse")); + } + + @Test + void should_throwIllegalArgumentException_WhenScenarioHasInvalidEntry() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/invalid-scenarios.json")); + + assertTrue(ex.getMessage().contains("bad-scenario")); + } + + @Test + void should_throwIllegalStateException_WhenScenariosIsNotAnArray() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/scenarios-as-string.json")); + + assertTrue(ex.getMessage().contains("scenarios")); + } + + @Test + void should_throwIllegalStateException_WhenScenarioLabelIsNotAString() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/scenario-label-non-string.json")); + + assertTrue(ex.getMessage().contains("label")); + } + + @Test + void should_parseValidateSchema_WhenPresent() { + List scenarios = + AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-scenarios.json"); + + assertEquals(2, scenarios.size()); + AuthzScenario present = scenarios.get(0); + assertEquals("vs-present", present.label()); + assertNotNull(present.validateSchema()); + assertEquals("应为简短的处置动作短语", ((Map) present.validateSchema().get("动网操作的授权策略列表")).get("处置类型")); + } + + @Test + void should_haveNullValidateSchema_WhenAbsent() { + List scenarios = + AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-scenarios.json"); + + assertEquals("vs-missing", scenarios.get(1).label()); + assertNull(scenarios.get(1).validateSchema()); + } + + @Test + void should_throwIllegalStateException_WhenValidateSchemaIsEmpty() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-empty.json")); + + assertTrue(ex.getMessage().contains("validate_schema")); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java index eaeab23f..4a3caf2a 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java @@ -1,577 +1,577 @@ -package net.openan.a2at.sample.authz_policy; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; -import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.PromptGenerationException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import org.junit.jupiter.api.Test; - -class AuthzScenarioRunnerTest { - - private static final String TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; - private static final Map PARAM_SCHEMA = Map.of("properties", Map.of(), "required", Map.of()); - private static final AuthzExpected SUCCESS = new AuthzExpected( - new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, null)); - private static final AuthzExpected EXPECTED_SLOT_ERROR = - new AuthzExpected(new ClientExpected("slot.rule_violation", null, null), null); - private static final AuthzExpected EXPECTED_SEMANTIC_REJECTED = new AuthzExpected( - new ClientExpected(null, "generated prompt", null), - new ServerExpected("negotiation.semantic_rejected", null, null)); - - @Test - void should_dispatchToFromTextGenerator() { - AtomicReference calledEntry = new AtomicReference<>(); - AuthzPromptGenerator generator = scenario -> { - calledEntry.set(scenario.entry()); - return new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertEquals("from_text", calledEntry.get()); - } - - @Test - void should_dispatchToFromDataWithSchemaGenerator() { - AtomicReference calledEntry = new AtomicReference<>(); - AuthzPromptGenerator generator = scenario -> { - calledEntry.set(scenario.entry()); - return new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario( - "test", "from_data_with_schema", Map.of("data", Map.of("k", "v"), "schema", Map.of("k", "d")), SUCCESS); - - runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertEquals("from_data_with_schema", calledEntry.get()); - } - - @Test - void should_match_WhenExpectedSuccessAndValidationPasses() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals("success", outcome.result().outcome()); - assertNotNull(outcome.metadata()); - assertNotNull(outcome.filled()); - } - - @Test - void should_match_WhenExpectedSemanticRejectedAndSemanticRejected() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - throw new ContentValidationException( - ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), "semantic rejected"); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = - new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SEMANTIC_REJECTED); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals("negotiation.semantic_rejected", outcome.result().outcome()); - assertNotNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_notMatch_WhenExpectedSuccessAndValidationRejected() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - throw new ContentValidationException( - ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), "semantic rejected"); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("negotiation.semantic_rejected", outcome.result().outcome()); - assertNotNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_notMatch_WhenExpectedSemanticRejectedAndValidationPasses() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = - new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SEMANTIC_REJECTED); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("success", outcome.result().outcome()); - assertNotNull(outcome.metadata()); - assertNotNull(outcome.filled()); - } - - @Test - void should_match_WhenPromptTextDiffersFromExpectation() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "different prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals(Boolean.FALSE, outcome.result().clientPromptMatch()); - assertEquals(Boolean.TRUE, outcome.result().serverOutcomeMatch()); - assertTrue(outcome.result().serverParamsMatch()); - } - - @Test - void should_match_WhenPromptTextDiffersAndServerMatches() { - AuthzExpected expected = new AuthzExpected( - new ClientExpected(null, "expected prompt", null), - new ServerExpected("success", null, Map.of("操作类型", "新增授权策略"))); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "drifted prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("操作类型", "新增授权策略")); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals(Boolean.FALSE, outcome.result().clientPromptMatch()); - assertEquals(Boolean.TRUE, outcome.result().serverParamsMatch()); - } - - @Test - void should_match_WhenPromptTextDiffersOnlyInTrailingWhitespace() { - AuthzExpected expected = new AuthzExpected( - new ClientExpected(null, "generated prompt \n", null), new ServerExpected("success", null, null)); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, " generated prompt\n", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - } - - @Test - void should_notMatch_WhenExpectedSuccessAndGeneratorThrowsPromptGenerationException() { - AuthzPromptGenerator generator = scenario -> { - throw new PromptGenerationException("slot.schema_not_found", "schema not found"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("slot.schema_not_found", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_match_WhenExpectedSlotValidationErrorAndGeneratorThrowsSlotValidationError() { - AuthzPromptGenerator generator = scenario -> { - throw new PromptGenerationException( - ErrorCatalog.SLOT_RULE_VIOLATION.getCode(), "Required slots are missing or empty: operation_type"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SLOT_ERROR); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals("slot.rule_violation", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_notMatch_WhenExpectedSlotValidationErrorAndGeneratorThrowsInfrastructureError() { - AuthzPromptGenerator generator = scenario -> { - throw new PromptGenerationException(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), "LLM invocation failed"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SLOT_ERROR); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("llm.invocation_failed", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_notMatch_WhenExpectedSuccessAndValidatorThrowsInfrastructureError() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - throw new ContentValidationException(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), "infrastructure error"); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("llm.invocation_failed", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNotNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_notMatch_WhenExpectedSuccessAndValidatorThrowsResourceNotFound() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - throw new ContentValidationException(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), "resource not found"); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("template.not_found", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNotNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_notMatch_WhenExpectedSuccessAndValidatorThrowsUnknownCode() { - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - throw new ContentValidationException("unknown_code", "unknown error"); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("unknown_code", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNotNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_matchSlotErrors_WhenExpectedSlotErrorsMatchActual() { - AuthzPromptGenerator generator = scenario -> { - throw new PromptGenerationException( - ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), - "输入中未提供「授权策略的操作类型」。", - List.of(new SlotValidationError("授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。"))); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzExpected expected = new AuthzExpected( - new ClientExpected( - "slot.not_provided", null, List.of(new SlotErrorExpectation("授权策略的操作类型", "slot.not_provided"))), - null); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals(1, outcome.result().slotErrors().size()); - assertEquals("授权策略的操作类型", outcome.result().slotErrors().get(0).slotName()); - assertEquals("slot.not_provided", outcome.result().slotErrors().get(0).code()); - } - - @Test - void should_notMatchSlotErrors_WhenExpectedSlotErrorsDontMatchActual() { - AuthzPromptGenerator generator = scenario -> { - throw new PromptGenerationException( - ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), - "输入中未提供「授权策略的操作类型」。", - List.of(new SlotValidationError("授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。"))); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzExpected expected = new AuthzExpected( - new ClientExpected( - "slot.not_provided", - null, - List.of(new SlotErrorExpectation("动网操作的授权策略列表", "slot.not_provided"))), - null); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("slot.not_provided", outcome.result().outcome()); - } - - @Test - void should_notMatchSlotErrors_WhenSlotNameMatchesButCodeDiffers() { - AuthzPromptGenerator generator = scenario -> { - throw new PromptGenerationException( - ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), - "输入中未提供「授权策略的操作类型」。", - List.of(new SlotValidationError("授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。"))); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzExpected expected = new AuthzExpected( - new ClientExpected( - "slot.not_provided", - null, - List.of(new SlotErrorExpectation("授权策略的操作类型", "slot.constraint_violated"))), - null); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - } - - @Test - void should_matchParams_WhenActualIsSupersetOfExpected() { - AuthzExpected expected = new AuthzExpected( - new ClientExpected(null, "generated prompt", null), - new ServerExpected("success", null, Map.of("操作类型", "新增授权策略", "策略列表", List.of(Map.of("业务场景", "校园专网"))))); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - Map actualEntry = new java.util.LinkedHashMap<>(); - actualEntry.put("策略标识", null); - actualEntry.put("业务场景", "校园专网"); - actualEntry.put("处置类型", "紧急扩容"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> - new FilledParamData(Map.of("操作类型", "新增授权策略", "策略列表", List.of(actualEntry))); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertEquals(Boolean.TRUE, outcome.result().serverParamsMatch()); - } - - @Test - void should_notMatchParams_WhenListItemValueDiffers() { - AuthzExpected expected = new AuthzExpected( - new ClientExpected(null, "generated prompt", null), - new ServerExpected("success", null, Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"))))); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = - (prompt, schema, templateUri) -> new FilledParamData(Map.of("策略列表", List.of(Map.of("业务场景", "医疗专线")))); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals(Boolean.FALSE, outcome.result().serverParamsMatch()); - } - - @Test - void should_notMatchParams_WhenListLengthDiffers() { - AuthzExpected expected = new AuthzExpected( - new ClientExpected(null, "generated prompt", null), - new ServerExpected("success", null, Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"))))); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> - new FilledParamData(Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"), Map.of("业务场景", "医疗专线")))); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals(Boolean.FALSE, outcome.result().serverParamsMatch()); - } - - @Test - void should_notMatch_WhenExpectedSuccessAndGeneratorThrowsRuntimeException() { - AuthzPromptGenerator generator = scenario -> { - throw new NullPointerException("unexpected NPE"); - }; - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertFalse(outcome.result().match()); - assertEquals("infra.internal_error", outcome.result().outcome()); - assertNotNull(outcome.result().error()); - assertNull(outcome.metadata()); - assertNull(outcome.filled()); - } - - @Test - void should_warn_WhenMutationAndEmptyPolicyListSection() { - String promptText = "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n\n\n## 预期输出\n输出格式"; - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); - } - - @Test - void should_warn_WhenModifyAndEmptyPolicyListSection() { - String promptText = "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); - } - - @Test - void should_notWarn_WhenQueryAndEmptyPolicyListSection() { - String promptText = "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertTrue(outcome.result().warnings().isEmpty()); - } - - @Test - void should_notWarn_WhenMutationAndNonEmptyPolicyListSection() { - String promptText = "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n校园专网,紧急扩容\n\n## 预期输出\n输出格式"; - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertTrue(outcome.result().warnings().isEmpty()); - } - - @Test - void should_notWarn_WhenPromptTextHasUnknownSectionOrder() { - String promptText = "## 授权策略的操作描述\n描述\n\n## 授权策略的操作类型\n新增授权策略\n\n## 预期输出\n输出格式\n\n## 动网操作的授权策略列表\n\n"; - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); - } - - @Test - void should_notWarn_WhenNoOperationTypeSection() { - String promptText = "## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertTrue(outcome.result().match()); - assertTrue(outcome.result().warnings().isEmpty()); - } - - @Test - void should_useScenarioSchema_WhenValidateSchemaPresent() { - Map scenarioSchema = Map.of("处置规则", "应为短语"); - AtomicReference> receivedSchema = new AtomicReference<>(); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - receivedSchema.set(schema); - return new FilledParamData(Map.of()); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = - new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS, scenarioSchema); - - runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertSame(scenarioSchema, receivedSchema.get()); - } - - @Test - void should_useDefaultSchema_WhenValidateSchemaAbsent() { - AtomicReference> receivedSchema = new AtomicReference<>(); - AuthzPromptGenerator generator = - scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); - AuthzPromptValidator validator = (prompt, schema, templateUri) -> { - receivedSchema.set(schema); - return new FilledParamData(Map.of()); - }; - AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); - AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); - - runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); - - assertSame(PARAM_SCHEMA, receivedSchema.get()); - } -} +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import org.junit.jupiter.api.Test; + +class AuthzScenarioRunnerTest { + + private static final String TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; + private static final Map PARAM_SCHEMA = Map.of("properties", Map.of(), "required", Map.of()); + private static final AuthzExpected SUCCESS = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, null)); + private static final AuthzExpected EXPECTED_SLOT_ERROR = + new AuthzExpected(new ClientExpected("slot.rule_violation", null, null), null); + private static final AuthzExpected EXPECTED_SEMANTIC_REJECTED = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), + new ServerExpected("negotiation.semantic_rejected", null, null)); + + @Test + void should_dispatchToFromTextGenerator() { + AtomicReference calledEntry = new AtomicReference<>(); + AuthzPromptGenerator generator = scenario -> { + calledEntry.set(scenario.entry()); + return new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertEquals("from_text", calledEntry.get()); + } + + @Test + void should_dispatchToFromDataWithSchemaGenerator() { + AtomicReference calledEntry = new AtomicReference<>(); + AuthzPromptGenerator generator = scenario -> { + calledEntry.set(scenario.entry()); + return new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario( + "test", "from_data_with_schema", Map.of("data", Map.of("k", "v"), "schema", Map.of("k", "d")), SUCCESS); + + runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertEquals("from_data_with_schema", calledEntry.get()); + } + + @Test + void should_match_WhenExpectedSuccessAndValidationPasses() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals("success", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNotNull(outcome.filled()); + } + + @Test + void should_match_WhenExpectedSemanticRejectedAndSemanticRejected() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), "semantic rejected"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = + new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SEMANTIC_REJECTED); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals("negotiation.semantic_rejected", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidationRejected() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), "semantic rejected"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("negotiation.semantic_rejected", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSemanticRejectedAndValidationPasses() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = + new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SEMANTIC_REJECTED); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("success", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNotNull(outcome.filled()); + } + + @Test + void should_match_WhenPromptTextDiffersFromExpectation() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "different prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals(Boolean.FALSE, outcome.result().clientPromptMatch()); + assertEquals(Boolean.TRUE, outcome.result().serverOutcomeMatch()); + assertTrue(outcome.result().serverParamsMatch()); + } + + @Test + void should_match_WhenPromptTextDiffersAndServerMatches() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "expected prompt", null), + new ServerExpected("success", null, Map.of("操作类型", "新增授权策略"))); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "drifted prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("操作类型", "新增授权策略")); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals(Boolean.FALSE, outcome.result().clientPromptMatch()); + assertEquals(Boolean.TRUE, outcome.result().serverParamsMatch()); + } + + @Test + void should_match_WhenPromptTextDiffersOnlyInTrailingWhitespace() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "generated prompt \n", null), new ServerExpected("success", null, null)); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, " generated prompt\n", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndGeneratorThrowsPromptGenerationException() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException("slot.schema_not_found", "schema not found"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("slot.schema_not_found", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_match_WhenExpectedSlotValidationErrorAndGeneratorThrowsSlotValidationError() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + ErrorCatalog.SLOT_RULE_VIOLATION.getCode(), "Required slots are missing or empty: operation_type"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SLOT_ERROR); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals("slot.rule_violation", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSlotValidationErrorAndGeneratorThrowsInfrastructureError() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), "LLM invocation failed"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SLOT_ERROR); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("llm.invocation_failed", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidatorThrowsInfrastructureError() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), "infrastructure error"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("llm.invocation_failed", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidatorThrowsResourceNotFound() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), "resource not found"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("template.not_found", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidatorThrowsUnknownCode() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException("unknown_code", "unknown error"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("unknown_code", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_matchSlotErrors_WhenExpectedSlotErrorsMatchActual() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + "输入中未提供「授权策略的操作类型」。", + List.of(new SlotValidationError("授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。"))); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzExpected expected = new AuthzExpected( + new ClientExpected( + "slot.not_provided", null, List.of(new SlotErrorExpectation("授权策略的操作类型", "slot.not_provided"))), + null); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals(1, outcome.result().slotErrors().size()); + assertEquals("授权策略的操作类型", outcome.result().slotErrors().get(0).slotName()); + assertEquals("slot.not_provided", outcome.result().slotErrors().get(0).code()); + } + + @Test + void should_notMatchSlotErrors_WhenExpectedSlotErrorsDontMatchActual() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + "输入中未提供「授权策略的操作类型」。", + List.of(new SlotValidationError("授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。"))); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzExpected expected = new AuthzExpected( + new ClientExpected( + "slot.not_provided", + null, + List.of(new SlotErrorExpectation("动网操作的授权策略列表", "slot.not_provided"))), + null); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("slot.not_provided", outcome.result().outcome()); + } + + @Test + void should_notMatchSlotErrors_WhenSlotNameMatchesButCodeDiffers() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + "输入中未提供「授权策略的操作类型」。", + List.of(new SlotValidationError("授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。"))); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzExpected expected = new AuthzExpected( + new ClientExpected( + "slot.not_provided", + null, + List.of(new SlotErrorExpectation("授权策略的操作类型", "slot.constraint_violated"))), + null); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + } + + @Test + void should_matchParams_WhenActualIsSupersetOfExpected() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), + new ServerExpected("success", null, Map.of("操作类型", "新增授权策略", "策略列表", List.of(Map.of("业务场景", "校园专网"))))); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + Map actualEntry = new java.util.LinkedHashMap<>(); + actualEntry.put("策略标识", null); + actualEntry.put("业务场景", "校园专网"); + actualEntry.put("处置类型", "紧急扩容"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> + new FilledParamData(Map.of("操作类型", "新增授权策略", "策略列表", List.of(actualEntry))); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals(Boolean.TRUE, outcome.result().serverParamsMatch()); + } + + @Test + void should_notMatchParams_WhenListItemValueDiffers() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), + new ServerExpected("success", null, Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"))))); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = + (prompt, schema, templateUri) -> new FilledParamData(Map.of("策略列表", List.of(Map.of("业务场景", "医疗专线")))); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals(Boolean.FALSE, outcome.result().serverParamsMatch()); + } + + @Test + void should_notMatchParams_WhenListLengthDiffers() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), + new ServerExpected("success", null, Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"))))); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> + new FilledParamData(Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"), Map.of("业务场景", "医疗专线")))); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals(Boolean.FALSE, outcome.result().serverParamsMatch()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndGeneratorThrowsRuntimeException() { + AuthzPromptGenerator generator = scenario -> { + throw new NullPointerException("unexpected NPE"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("infra.internal_error", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_warn_WhenMutationAndEmptyPolicyListSection() { + String promptText = "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); + } + + @Test + void should_warn_WhenModifyAndEmptyPolicyListSection() { + String promptText = "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); + } + + @Test + void should_notWarn_WhenQueryAndEmptyPolicyListSection() { + String promptText = "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().isEmpty()); + } + + @Test + void should_notWarn_WhenMutationAndNonEmptyPolicyListSection() { + String promptText = "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n校园专网,紧急扩容\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().isEmpty()); + } + + @Test + void should_notWarn_WhenPromptTextHasUnknownSectionOrder() { + String promptText = "## 授权策略的操作描述\n描述\n\n## 授权策略的操作类型\n新增授权策略\n\n## 预期输出\n输出格式\n\n## 动网操作的授权策略列表\n\n"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); + } + + @Test + void should_notWarn_WhenNoOperationTypeSection() { + String promptText = "## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().isEmpty()); + } + + @Test + void should_useScenarioSchema_WhenValidateSchemaPresent() { + Map scenarioSchema = Map.of("处置规则", "应为短语"); + AtomicReference> receivedSchema = new AtomicReference<>(); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + receivedSchema.set(schema); + return new FilledParamData(Map.of()); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = + new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS, scenarioSchema); + + runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertSame(scenarioSchema, receivedSchema.get()); + } + + @Test + void should_useDefaultSchema_WhenValidateSchemaAbsent() { + AtomicReference> receivedSchema = new AtomicReference<>(); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + receivedSchema.set(schema); + return new FilledParamData(Map.of()); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertSame(PARAM_SCHEMA, receivedSchema.get()); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioTest.java index 39bbb2b9..257ed095 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioTest.java @@ -1,238 +1,238 @@ -package net.openan.a2at.sample.authz_policy; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -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 java.util.Map; -import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; -import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; -import org.junit.jupiter.api.Test; - -class AuthzScenarioTest { - - private static AuthzExpected success() { - return new AuthzExpected( - new ClientExpected(null, "## rendered prompt", null), new ServerExpected("success", null, null)); - } - - @Test - void should_acceptValidFromTextScenario() { - AuthzScenario scenario = new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success()); - - assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); - } - - @Test - void should_acceptValidFromDataWithSchemaScenario() { - AuthzScenario scenario = new AuthzScenario( - "add-from-data", - "from_data_with_schema", - Map.of("data", Map.of("k", "v"), "schema", Map.of("k", "description")), - success()); - - assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); - } - - @Test - void should_rejectInvalidEntry() { - AuthzScenario scenario = new AuthzScenario("bad", "invalid_entry", Map.of("text", "test"), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("Invalid entry: invalid_entry", ex.getMessage()); - } - - @Test - void should_rejectInvalidClientOutcome() { - AuthzScenario scenario = new AuthzScenario( - "bad", - "from_text", - Map.of("text", "test"), - new AuthzExpected(new ClientExpected("invalid_outcome", null, null), null)); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("Invalid expected client outcome: invalid_outcome", ex.getMessage()); - } - - @Test - void should_rejectClientOutcomeWithServerExpectation() { - AuthzScenario scenario = new AuthzScenario( - "bad", - "from_text", - Map.of("text", "test"), - new AuthzExpected( - new ClientExpected("slot.rule_violation", null, null), - new ServerExpected("success", null, null))); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("expected.server must be null when expected.client.outcome is set", ex.getMessage()); - } - - @Test - void should_rejectClientSuccessWithoutPromptText() { - AuthzScenario scenario = new AuthzScenario( - "bad", - "from_text", - Map.of("text", "test"), - new AuthzExpected(new ClientExpected(null, " ", null), new ServerExpected("success", null, null))); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("expected.client.promptText must be non-blank when client outcome is not set", ex.getMessage()); - } - - @Test - void should_rejectClientSuccessWithoutServerExpectation() { - AuthzScenario scenario = new AuthzScenario( - "bad", - "from_text", - Map.of("text", "test"), - new AuthzExpected(new ClientExpected(null, "prompt", null), null)); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertTrue(ex.getMessage().startsWith("expected.server.outcome must be one of")); - assertTrue(ex.getMessage().contains("when client succeeds")); - } - - @Test - void should_acceptSlotValidationErrorOutcome() { - AuthzScenario scenario = new AuthzScenario( - "reject", - "from_text", - Map.of("text", "test"), - new AuthzExpected(new ClientExpected("slot.rule_violation", null, null), null)); - - assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); - } - - @Test - void should_acceptSemanticRejectedServerOutcome() { - AuthzScenario scenario = new AuthzScenario( - "reject", - "from_text", - Map.of("text", "test"), - new AuthzExpected( - new ClientExpected(null, "prompt", null), - new ServerExpected("negotiation.semantic_rejected", null, null))); - - assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); - } - - @Test - void should_rejectNullExpected() { - AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of("text", "test"), null); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("expected.client must not be null", ex.getMessage()); - } - - @Test - void should_rejectFromTextMissingText() { - AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of(), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("from_text scenario requires a non-blank input.text", ex.getMessage()); - } - - @Test - void should_rejectFromTextBlankText() { - AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of("text", " "), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("from_text scenario requires a non-blank input.text", ex.getMessage()); - } - - @Test - void should_rejectFromDataMissingData() { - AuthzScenario scenario = - new AuthzScenario("bad", "from_data_with_schema", Map.of("schema", Map.of("k", "d")), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("from_data_with_schema scenario requires a non-empty input.data", ex.getMessage()); - } - - @Test - void should_rejectFromDataMissingSchema() { - AuthzScenario scenario = - new AuthzScenario("bad", "from_data_with_schema", Map.of("data", Map.of("k", "v")), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("from_data_with_schema scenario requires a non-empty input.schema", ex.getMessage()); - } - - @Test - void should_rejectFromDataWithSchema_EmptyData() { - AuthzScenario scenario = new AuthzScenario( - "bad", "from_data_with_schema", Map.of("data", Map.of(), "schema", Map.of("k", "d")), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("from_data_with_schema scenario requires a non-empty input.data", ex.getMessage()); - } - - @Test - void should_rejectFromDataWithSchema_EmptySchema() { - AuthzScenario scenario = new AuthzScenario( - "bad", "from_data_with_schema", Map.of("data", Map.of("k", "v"), "schema", Map.of()), success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("from_data_with_schema scenario requires a non-empty input.schema", ex.getMessage()); - } - - @Test - void should_rejectNullInput() { - AuthzScenario scenario = new AuthzScenario("bad", "from_text", null, success()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("input must not be null", ex.getMessage()); - } - - @Test - void should_rejectNullScenario() { - IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(null)); - assertEquals("scenario must not be null", ex.getMessage()); - } - - @Test - void should_haveNullValidateSchema_WhenFourArgConstructor() { - AuthzScenario scenario = new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success()); - - assertNull(scenario.validateSchema()); - } - - @Test - void should_acceptNonEmptyValidateSchema() { - AuthzScenario scenario = new AuthzScenario( - "add-from-text", - "from_text", - Map.of("text", "test"), - success(), - Map.of("动网操作的授权策略列表", Map.of("处置类型", "应为简短的处置动作短语"))); - - assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); - } - - @Test - void should_rejectEmptyValidateSchema() { - AuthzScenario scenario = - new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success(), Map.of()); - - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); - assertEquals("validateSchema must be non-empty when set", ex.getMessage()); - } -} +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +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 java.util.Map; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import org.junit.jupiter.api.Test; + +class AuthzScenarioTest { + + private static AuthzExpected success() { + return new AuthzExpected( + new ClientExpected(null, "## rendered prompt", null), new ServerExpected("success", null, null)); + } + + @Test + void should_acceptValidFromTextScenario() { + AuthzScenario scenario = new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success()); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_acceptValidFromDataWithSchemaScenario() { + AuthzScenario scenario = new AuthzScenario( + "add-from-data", + "from_data_with_schema", + Map.of("data", Map.of("k", "v"), "schema", Map.of("k", "description")), + success()); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_rejectInvalidEntry() { + AuthzScenario scenario = new AuthzScenario("bad", "invalid_entry", Map.of("text", "test"), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("Invalid entry: invalid_entry", ex.getMessage()); + } + + @Test + void should_rejectInvalidClientOutcome() { + AuthzScenario scenario = new AuthzScenario( + "bad", + "from_text", + Map.of("text", "test"), + new AuthzExpected(new ClientExpected("invalid_outcome", null, null), null)); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("Invalid expected client outcome: invalid_outcome", ex.getMessage()); + } + + @Test + void should_rejectClientOutcomeWithServerExpectation() { + AuthzScenario scenario = new AuthzScenario( + "bad", + "from_text", + Map.of("text", "test"), + new AuthzExpected( + new ClientExpected("slot.rule_violation", null, null), + new ServerExpected("success", null, null))); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("expected.server must be null when expected.client.outcome is set", ex.getMessage()); + } + + @Test + void should_rejectClientSuccessWithoutPromptText() { + AuthzScenario scenario = new AuthzScenario( + "bad", + "from_text", + Map.of("text", "test"), + new AuthzExpected(new ClientExpected(null, " ", null), new ServerExpected("success", null, null))); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("expected.client.promptText must be non-blank when client outcome is not set", ex.getMessage()); + } + + @Test + void should_rejectClientSuccessWithoutServerExpectation() { + AuthzScenario scenario = new AuthzScenario( + "bad", + "from_text", + Map.of("text", "test"), + new AuthzExpected(new ClientExpected(null, "prompt", null), null)); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertTrue(ex.getMessage().startsWith("expected.server.outcome must be one of")); + assertTrue(ex.getMessage().contains("when client succeeds")); + } + + @Test + void should_acceptSlotValidationErrorOutcome() { + AuthzScenario scenario = new AuthzScenario( + "reject", + "from_text", + Map.of("text", "test"), + new AuthzExpected(new ClientExpected("slot.rule_violation", null, null), null)); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_acceptSemanticRejectedServerOutcome() { + AuthzScenario scenario = new AuthzScenario( + "reject", + "from_text", + Map.of("text", "test"), + new AuthzExpected( + new ClientExpected(null, "prompt", null), + new ServerExpected("negotiation.semantic_rejected", null, null))); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_rejectNullExpected() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of("text", "test"), null); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("expected.client must not be null", ex.getMessage()); + } + + @Test + void should_rejectFromTextMissingText() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of(), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_text scenario requires a non-blank input.text", ex.getMessage()); + } + + @Test + void should_rejectFromTextBlankText() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of("text", " "), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_text scenario requires a non-blank input.text", ex.getMessage()); + } + + @Test + void should_rejectFromDataMissingData() { + AuthzScenario scenario = + new AuthzScenario("bad", "from_data_with_schema", Map.of("schema", Map.of("k", "d")), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.data", ex.getMessage()); + } + + @Test + void should_rejectFromDataMissingSchema() { + AuthzScenario scenario = + new AuthzScenario("bad", "from_data_with_schema", Map.of("data", Map.of("k", "v")), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.schema", ex.getMessage()); + } + + @Test + void should_rejectFromDataWithSchema_EmptyData() { + AuthzScenario scenario = new AuthzScenario( + "bad", "from_data_with_schema", Map.of("data", Map.of(), "schema", Map.of("k", "d")), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.data", ex.getMessage()); + } + + @Test + void should_rejectFromDataWithSchema_EmptySchema() { + AuthzScenario scenario = new AuthzScenario( + "bad", "from_data_with_schema", Map.of("data", Map.of("k", "v"), "schema", Map.of()), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.schema", ex.getMessage()); + } + + @Test + void should_rejectNullInput() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", null, success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("input must not be null", ex.getMessage()); + } + + @Test + void should_rejectNullScenario() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(null)); + assertEquals("scenario must not be null", ex.getMessage()); + } + + @Test + void should_haveNullValidateSchema_WhenFourArgConstructor() { + AuthzScenario scenario = new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success()); + + assertNull(scenario.validateSchema()); + } + + @Test + void should_acceptNonEmptyValidateSchema() { + AuthzScenario scenario = new AuthzScenario( + "add-from-text", + "from_text", + Map.of("text", "test"), + success(), + Map.of("动网操作的授权策略列表", Map.of("处置类型", "应为简短的处置动作短语"))); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_rejectEmptyValidateSchema() { + AuthzScenario scenario = + new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success(), Map.of()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("validateSchema must be non-empty when set", ex.getMessage()); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java index d4b80f1a..54a81126 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java @@ -1,315 +1,315 @@ -package net.openan.a2at.sample.service_recovery.accuracy; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.json.JsonMapper; -import java.io.IOException; -import java.io.InputStream; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.server.A2ATServer; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; - -@Tag("integration") -class NotificationTAccuracyTest { - - private static final ObjectMapper MAPPER = - JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); - - private static final String TEMPLATE_URI_STR = "Notification-T/network-layer/service-recovery/v1"; - private static final String DATA_SCHEMA_RESOURCE = "sample/service-recovery/client/schema.json"; - private static final String VALIDATION_SCHEMA_RESOURCE = "sample/service-recovery/server/schema.json"; - - private static A2ATClient client; - private static A2ATServer server; - private static String templateUri; - private static Map dataSchema; - private static Map validationSchema; - private static boolean apiKeyConfigured; - private static Map envInfo; - - private static final List> fromTextResults = new ArrayList<>(); - private static final List> fromDataResults = new ArrayList<>(); - private static final List> validateResults = new ArrayList<>(); - - @BeforeAll - static void setup() throws Exception { - templateUri = TEMPLATE_URI_STR; - - dataSchema = loadJsonResource(DATA_SCHEMA_RESOURCE); - validationSchema = loadJsonResource(VALIDATION_SCHEMA_RESOURCE); - - Path env = resolveEnvFile("service-recovery-accuracy-test/client.env"); - envInfo = readEnvInfo(env); - - String apiKey = envInfo.get("A2AT_LLM_API_KEY"); - apiKeyConfigured = apiKey != null && !apiKey.isBlank() && !apiKey.contains("${"); - - if (apiKeyConfigured) { - client = new A2ATClient(env); - server = new A2ATServer(env); - } - } - - @AfterAll - static void writeReport() throws Exception { - Map report = new LinkedHashMap<>(); - - Map testRun = new LinkedHashMap<>(); - testRun.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); - testRun.put("env", envInfo); - testRun.put("apiKeyConfigured", apiKeyConfigured); - report.put("testRun", testRun); - - report.put("generateNotificationPromptFromText", Map.of("testCases", fromTextResults)); - report.put("generateNotificationPromptFromDataWithSchema", Map.of("testCases", fromDataResults)); - report.put("validateNotificationPromptAndDataFilling", Map.of("testCases", validateResults)); - - Path reportDir = Path.of("target"); - Files.createDirectories(reportDir); - Path reportFile = reportDir.resolve("service-recovery-accuracy-report.json"); - MAPPER.writeValue(reportFile.toFile(), report); - System.out.println("Report written to: " + reportFile.toAbsolutePath().normalize()); - } - - @Test - void generateNotificationPromptFromText() throws Exception { - if (!apiKeyConfigured) { - System.out.println("SKIP: generateNotificationPromptFromText - API key not configured"); - return; - } - List cases = loadTextCases("service-recovery-accuracy-test/test-cases-generate-from-text.json"); - System.out.println("== generateNotificationPromptFromText =="); - boolean first = true; - for (TextCase tc : cases) { - if (!first) System.out.println("---"); - first = false; - Map entry = new LinkedHashMap<>(); - entry.put("name", tc.name); - entry.put("input", Map.of("text", tc.text)); - - try { - MetadataContent result = client.generateNotificationPromptFromText(tc.text, templateUri); - entry.put("output", metadataOutput(result)); - System.out.println(" " + tc.name + ": " + compact(result.promptText())); - } catch (Exception e) { - entry.put("output", Map.of("error", e.getClass().getName() + ": " + e.getMessage())); - System.out.println(" " + tc.name + ": ERROR - " + e.getMessage()); - } - fromTextResults.add(entry); - } - } - - @Test - void generateNotificationPromptFromDataWithSchema() throws Exception { - if (!apiKeyConfigured) { - System.out.println("SKIP: generateNotificationPromptFromDataWithSchema - API key not configured"); - return; - } - List cases = loadDataCases("service-recovery-accuracy-test/test-cases-generate-from-data.json"); - System.out.println("== generateNotificationPromptFromDataWithSchema =="); - boolean first = true; - for (DataCase dc : cases) { - if (!first) System.out.println("---"); - first = false; - Map entry = new LinkedHashMap<>(); - entry.put("name", dc.name); - entry.put("input", Map.of("data", dc.data)); - - try { - MetadataContent result = - client.generateNotificationPromptFromDataWithSchema(dc.data, dataSchema, templateUri); - entry.put("output", metadataOutput(result)); - System.out.println(" " + dc.name + ": " + compact(result.promptText())); - } catch (Exception e) { - entry.put("output", Map.of("error", e.getClass().getName() + ": " + e.getMessage())); - System.out.println(" " + dc.name + ": ERROR - " + e.getMessage()); - } - fromDataResults.add(entry); - } - } - - private static Map validate(String promptText) { - try { - FilledParamData result = - server.validateNotificationPromptAndDataFilling(promptText, validationSchema, templateUri); - Map output = new LinkedHashMap<>(); - output.put("semantic_verdict", true); - output.put("params", result.data()); - return output; - } catch (ContentValidationException e) { - Map output = new LinkedHashMap<>(); - output.put("semantic_verdict", false); - output.put("code", e.getCode()); - output.put("message", e.getMessage()); - if (!e.errors().isEmpty()) { - output.put( - "errors", - e.errors().stream() - .map(err -> { - Map item = new LinkedHashMap(); - item.put("slot_name", err.slotName()); - item.put("code", err.code()); - item.put("message", err.message()); - item.put("facts", err.facts()); - return item; - }) - .toList()); - } - return output; - } catch (Exception e) { - return Map.of("error", e.getClass().getName() + ": " + e.getMessage()); - } - } - - @Test - void validateNotificationPromptAndDataFilling() throws Exception { - if (!apiKeyConfigured) { - System.out.println("SKIP: validateNotificationPromptAndDataFilling - API key not configured"); - return; - } - List cases = - loadValidateCases("service-recovery-accuracy-test/test-cases-validate-and-fill.json"); - System.out.println("== validateNotificationPromptAndDataFilling =="); - boolean first = true; - for (ValidateCase vc : cases) { - if (!first) System.out.println("---"); - first = false; - Map entry = new LinkedHashMap<>(); - entry.put("name", vc.name); - entry.put("input", Map.of("promptText", vc.promptText)); - Map output = validate(vc.promptText); - entry.put("output", output); - System.out.println(" " + vc.name + ": " + MAPPER.writeValueAsString(output)); - validateResults.add(entry); - } - } - - private static Map metadataOutput(MetadataContent result) { - Map output = new LinkedHashMap<>(); - output.put("templateUri", result.templateUri()); - output.put("extensionUri", result.extensionUri()); - output.put("promptText", result.promptText()); - return output; - } - - private static String compact(String text) { - if (text == null || text.isEmpty()) { - return text; - } - return text.replaceAll("(?m)^[ \t]*\\r?\\n", ""); - } - - private static Path resolveEnvFile(String resourcePath) throws IOException, URISyntaxException { - URL url = NotificationTAccuracyTest.class.getClassLoader().getResource(resourcePath); - Objects.requireNonNull(url, "Resource not found: " + resourcePath); - Path envPath = Path.of(url.toURI()); - Path projectRoot = findProjectRoot(envPath.getParent()); - - String content = Files.readString(envPath, StandardCharsets.UTF_8); - String resolvedContent = resolveRootDir(content, projectRoot); - - Path tempFile = Files.createTempFile("notification-t-test-", ".env"); - Files.writeString(tempFile, resolvedContent, StandardCharsets.UTF_8); - tempFile.toFile().deleteOnExit(); - return tempFile; - } - - private static Path findProjectRoot(Path start) { - Path dir = start; - while (dir != null) { - if (Files.isDirectory(dir.resolve("a2a-t-resources"))) { - return dir; - } - dir = dir.getParent(); - } - throw new IllegalStateException("Project root (containing a2a-t-resources) not found above " + start); - } - - private static String resolveRootDir(String content, Path projectRoot) { - String prefix = "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR="; - Path promptRoot = projectRoot - .resolve("a2a-t-resources/src/main/resources/prompt_resources") - .normalize(); - String absolutePath = promptRoot.toString(); - for (String line : content.split("\n")) { - String trimmed = line.strip(); - if (trimmed.startsWith(prefix)) { - return content.replace(trimmed, prefix + absolutePath); - } - } - return content; - } - - private static Map readEnvInfo(Path envPath) throws IOException { - Map info = new LinkedHashMap<>(); - for (String line : Files.readAllLines(envPath, StandardCharsets.UTF_8)) { - String trimmed = line.strip(); - if (trimmed.isEmpty() || trimmed.startsWith("#")) { - continue; - } - int eqIdx = trimmed.indexOf('='); - if (eqIdx > 0) { - String key = trimmed.substring(0, eqIdx).strip(); - String value = trimmed.substring(eqIdx + 1).strip(); - if (key.equals("A2AT_LLM_API_KEY")) { - value = value.isBlank() ? "" : "***"; - } - info.put(key, value); - } - } - return info; - } - - private static Map loadJsonResource(String resourcePath) throws IOException { - try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { - Objects.requireNonNull(is, "Resource not found: " + resourcePath); - return MAPPER.readValue(is, new TypeReference>() {}); - } - } - - private static List loadTextCases(String resourcePath) throws IOException { - try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { - Objects.requireNonNull(is, "Resource not found: " + resourcePath); - return MAPPER.readValue(is, new TypeReference>() {}); - } - } - - private static List loadDataCases(String resourcePath) throws IOException { - try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { - Objects.requireNonNull(is, "Resource not found: " + resourcePath); - return MAPPER.readValue(is, new TypeReference>() {}); - } - } - - private static List loadValidateCases(String resourcePath) throws IOException { - try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { - Objects.requireNonNull(is, "Resource not found: " + resourcePath); - return MAPPER.readValue(is, new TypeReference>() {}); - } - } - - private record TextCase(String name, String text) {} - - private record DataCase(String name, Map data) {} - - private record ValidateCase(String name, String promptText) {} -} +package net.openan.a2at.sample.service_recovery.accuracy; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("integration") +class NotificationTAccuracyTest { + + private static final ObjectMapper MAPPER = + JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + + private static final String TEMPLATE_URI_STR = "Notification-T/network-layer/service-recovery/v1"; + private static final String DATA_SCHEMA_RESOURCE = "sample/service-recovery/client/schema.json"; + private static final String VALIDATION_SCHEMA_RESOURCE = "sample/service-recovery/server/schema.json"; + + private static A2ATClient client; + private static A2ATServer server; + private static String templateUri; + private static Map dataSchema; + private static Map validationSchema; + private static boolean apiKeyConfigured; + private static Map envInfo; + + private static final List> fromTextResults = new ArrayList<>(); + private static final List> fromDataResults = new ArrayList<>(); + private static final List> validateResults = new ArrayList<>(); + + @BeforeAll + static void setup() throws Exception { + templateUri = TEMPLATE_URI_STR; + + dataSchema = loadJsonResource(DATA_SCHEMA_RESOURCE); + validationSchema = loadJsonResource(VALIDATION_SCHEMA_RESOURCE); + + Path env = resolveEnvFile("service-recovery-accuracy-test/client.env"); + envInfo = readEnvInfo(env); + + String apiKey = envInfo.get("A2AT_LLM_API_KEY"); + apiKeyConfigured = apiKey != null && !apiKey.isBlank() && !apiKey.contains("${"); + + if (apiKeyConfigured) { + client = new A2ATClient(env); + server = new A2ATServer(env); + } + } + + @AfterAll + static void writeReport() throws Exception { + Map report = new LinkedHashMap<>(); + + Map testRun = new LinkedHashMap<>(); + testRun.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + testRun.put("env", envInfo); + testRun.put("apiKeyConfigured", apiKeyConfigured); + report.put("testRun", testRun); + + report.put("generateNotificationPromptFromText", Map.of("testCases", fromTextResults)); + report.put("generateNotificationPromptFromDataWithSchema", Map.of("testCases", fromDataResults)); + report.put("validateNotificationPromptAndDataFilling", Map.of("testCases", validateResults)); + + Path reportDir = Path.of("target"); + Files.createDirectories(reportDir); + Path reportFile = reportDir.resolve("service-recovery-accuracy-report.json"); + MAPPER.writeValue(reportFile.toFile(), report); + System.out.println("Report written to: " + reportFile.toAbsolutePath().normalize()); + } + + @Test + void generateNotificationPromptFromText() throws Exception { + if (!apiKeyConfigured) { + System.out.println("SKIP: generateNotificationPromptFromText - API key not configured"); + return; + } + List cases = loadTextCases("service-recovery-accuracy-test/test-cases-generate-from-text.json"); + System.out.println("== generateNotificationPromptFromText =="); + boolean first = true; + for (TextCase tc : cases) { + if (!first) System.out.println("---"); + first = false; + Map entry = new LinkedHashMap<>(); + entry.put("name", tc.name); + entry.put("input", Map.of("text", tc.text)); + + try { + MetadataContent result = client.generateNotificationPromptFromText(tc.text, templateUri); + entry.put("output", metadataOutput(result)); + System.out.println(" " + tc.name + ": " + compact(result.promptText())); + } catch (Exception e) { + entry.put("output", Map.of("error", e.getClass().getName() + ": " + e.getMessage())); + System.out.println(" " + tc.name + ": ERROR - " + e.getMessage()); + } + fromTextResults.add(entry); + } + } + + @Test + void generateNotificationPromptFromDataWithSchema() throws Exception { + if (!apiKeyConfigured) { + System.out.println("SKIP: generateNotificationPromptFromDataWithSchema - API key not configured"); + return; + } + List cases = loadDataCases("service-recovery-accuracy-test/test-cases-generate-from-data.json"); + System.out.println("== generateNotificationPromptFromDataWithSchema =="); + boolean first = true; + for (DataCase dc : cases) { + if (!first) System.out.println("---"); + first = false; + Map entry = new LinkedHashMap<>(); + entry.put("name", dc.name); + entry.put("input", Map.of("data", dc.data)); + + try { + MetadataContent result = + client.generateNotificationPromptFromDataWithSchema(dc.data, dataSchema, templateUri); + entry.put("output", metadataOutput(result)); + System.out.println(" " + dc.name + ": " + compact(result.promptText())); + } catch (Exception e) { + entry.put("output", Map.of("error", e.getClass().getName() + ": " + e.getMessage())); + System.out.println(" " + dc.name + ": ERROR - " + e.getMessage()); + } + fromDataResults.add(entry); + } + } + + private static Map validate(String promptText) { + try { + FilledParamData result = + server.validateNotificationPromptAndDataFilling(promptText, validationSchema, templateUri); + Map output = new LinkedHashMap<>(); + output.put("semantic_verdict", true); + output.put("params", result.data()); + return output; + } catch (ContentValidationException e) { + Map output = new LinkedHashMap<>(); + output.put("semantic_verdict", false); + output.put("code", e.getCode()); + output.put("message", e.getMessage()); + if (!e.errors().isEmpty()) { + output.put( + "errors", + e.errors().stream() + .map(err -> { + Map item = new LinkedHashMap(); + item.put("slot_name", err.slotName()); + item.put("code", err.code()); + item.put("message", err.message()); + item.put("facts", err.facts()); + return item; + }) + .toList()); + } + return output; + } catch (Exception e) { + return Map.of("error", e.getClass().getName() + ": " + e.getMessage()); + } + } + + @Test + void validateNotificationPromptAndDataFilling() throws Exception { + if (!apiKeyConfigured) { + System.out.println("SKIP: validateNotificationPromptAndDataFilling - API key not configured"); + return; + } + List cases = + loadValidateCases("service-recovery-accuracy-test/test-cases-validate-and-fill.json"); + System.out.println("== validateNotificationPromptAndDataFilling =="); + boolean first = true; + for (ValidateCase vc : cases) { + if (!first) System.out.println("---"); + first = false; + Map entry = new LinkedHashMap<>(); + entry.put("name", vc.name); + entry.put("input", Map.of("promptText", vc.promptText)); + Map output = validate(vc.promptText); + entry.put("output", output); + System.out.println(" " + vc.name + ": " + MAPPER.writeValueAsString(output)); + validateResults.add(entry); + } + } + + private static Map metadataOutput(MetadataContent result) { + Map output = new LinkedHashMap<>(); + output.put("templateUri", result.templateUri()); + output.put("extensionUri", result.extensionUri()); + output.put("promptText", result.promptText()); + return output; + } + + private static String compact(String text) { + if (text == null || text.isEmpty()) { + return text; + } + return text.replaceAll("(?m)^[ \t]*\\r?\\n", ""); + } + + private static Path resolveEnvFile(String resourcePath) throws IOException, URISyntaxException { + URL url = NotificationTAccuracyTest.class.getClassLoader().getResource(resourcePath); + Objects.requireNonNull(url, "Resource not found: " + resourcePath); + Path envPath = Path.of(url.toURI()); + Path projectRoot = findProjectRoot(envPath.getParent()); + + String content = Files.readString(envPath, StandardCharsets.UTF_8); + String resolvedContent = resolveRootDir(content, projectRoot); + + Path tempFile = Files.createTempFile("notification-t-test-", ".env"); + Files.writeString(tempFile, resolvedContent, StandardCharsets.UTF_8); + tempFile.toFile().deleteOnExit(); + return tempFile; + } + + private static Path findProjectRoot(Path start) { + Path dir = start; + while (dir != null) { + if (Files.isDirectory(dir.resolve("a2a-t-resources"))) { + return dir; + } + dir = dir.getParent(); + } + throw new IllegalStateException("Project root (containing a2a-t-resources) not found above " + start); + } + + private static String resolveRootDir(String content, Path projectRoot) { + String prefix = "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR="; + Path promptRoot = projectRoot + .resolve("a2a-t-resources/src/main/resources/prompt_resources") + .normalize(); + String absolutePath = promptRoot.toString(); + for (String line : content.split("\n")) { + String trimmed = line.strip(); + if (trimmed.startsWith(prefix)) { + return content.replace(trimmed, prefix + absolutePath); + } + } + return content; + } + + private static Map readEnvInfo(Path envPath) throws IOException { + Map info = new LinkedHashMap<>(); + for (String line : Files.readAllLines(envPath, StandardCharsets.UTF_8)) { + String trimmed = line.strip(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int eqIdx = trimmed.indexOf('='); + if (eqIdx > 0) { + String key = trimmed.substring(0, eqIdx).strip(); + String value = trimmed.substring(eqIdx + 1).strip(); + if (key.equals("A2AT_LLM_API_KEY")) { + value = value.isBlank() ? "" : "***"; + } + info.put(key, value); + } + } + return info; + } + + private static Map loadJsonResource(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private static List loadTextCases(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private static List loadDataCases(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private static List loadValidateCases(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private record TextCase(String name, String text) {} + + private record DataCase(String name, Map data) {} + + private record ValidateCase(String name, String promptText) {} +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java index 89716458..cb5371ab 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java @@ -1,164 +1,164 @@ -package net.openan.a2at.sdk.sample.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.lang.reflect.Method; -import java.lang.reflect.RecordComponent; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.server.A2ATServer; -import org.junit.jupiter.api.Test; - -/** - * Locks the v3 negotiation API surface of both facades and the field faces of the result records. - * - *

            The client and the server expose exactly the twelve camelCase negotiation methods with their pinned parameter - * counts, their surface carries none of the removed v2-shape method names nor the removed negotiation query methods, - * and the result records expose exactly their documented components. - */ -class NegotiationV3ApiSurfaceTest { - - private static final Map EXPECTED_NEGOTIATION_METHOD_PARAMETERS = Map.ofEntries( - Map.entry("generateNegotiationProposePromptFromData", 2), - Map.entry("generateNegotiationAcceptPromptFromData", 2), - Map.entry("generateNegotiationRejectPromptFromData", 2), - Map.entry("generateNegotiationAbortPromptFromData", 2), - Map.entry("generateNegotiationProposePromptFromText", 3), - Map.entry("generateNegotiationAcceptPromptFromText", 3), - Map.entry("generateNegotiationRejectPromptFromText", 3), - Map.entry("generateNegotiationAbortPromptFromText", 3), - Map.entry("validateProposePromptAndDataFilling", 4), - Map.entry("validateAcceptPromptAndDataFilling", 4), - Map.entry("validateRejectPromptAndDataFilling", 4), - Map.entry("validateAbortPromptAndDataFilling", 4)); - - private static final List REMOVED_NEGOTIATION_QUERY_METHODS = - List.of("getNegotiationPrompts", "getNegotiationPrompt"); - - private static final List CROSS_EXTENSION_QUERY_METHODS = List.of("getPrompts", "getPrompt"); - - private static final List REMOVED_V2_METHOD_NAME_FRAGMENTS = - List.of("FromNl", "FromJsonData", "validateAndExtractParams"); - - @Test - void bothFacadesExposeExactlyTheTwelveV3NegotiationMethods() { - assertExactNegotiationSurface(A2ATClient.class); - assertExactNegotiationSurface(A2ATServer.class); - } - - @Test - void removedNegotiationQueryMethodsAreAbsentWhileCrossExtensionQueriesRemain() { - for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { - for (String removedMethod : REMOVED_NEGOTIATION_QUERY_METHODS) { - assertFalse( - Arrays.stream(facade.getMethods()) - .anyMatch(method -> method.getName().equals(removedMethod)), - facade.getSimpleName() + " must not expose the removed " + removedMethod); - } - for (String queryMethod : CROSS_EXTENSION_QUERY_METHODS) { - assertTrue( - Arrays.stream(facade.getMethods()) - .anyMatch(method -> method.getName().equals(queryMethod)), - facade.getSimpleName() + " must expose the cross-extension query " + queryMethod); - } - } - } - - @Test - void neitherFacadeCarriesRemovedV2ShapeMethodNames() { - assertNoV2ShapeNames(A2ATClient.class); - assertNoV2ShapeNames(A2ATServer.class); - } - - @Test - void resultRecordsExposeTheirPinnedFieldFaces() { - List metadataComponents = componentNames(MetadataContent.class); - assertEquals(List.of("templateUri", "promptText", "extensionUri", "negotiationContext"), metadataComponents); - List contextComponents = componentNames(NegotiationContext.class); - assertEquals(List.of("id", "round", "maxRounds", "performative"), contextComponents); - List filledComponents = componentNames(FilledParamData.class); - assertEquals(List.of("data"), filledComponents); - assertTrue( - Arrays.stream(MetadataContent.class.getMethods()) - .anyMatch(method -> method.getName().equals("buildMetadataContent")), - "MetadataContent must expose buildMetadataContent"); - } - - @Test - void allFacadeTemplateUriParamsAreStrings() { - for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { - List methodsWithTemplateUri = Arrays.stream(facade.getDeclaredMethods()) - .filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers()) - && !java.lang.reflect.Modifier.isStatic(method.getModifiers())) - .filter(method -> Arrays.stream(method.getParameters()) - .anyMatch(parameter -> "templateUri".equals(parameter.getName()))) - .toList(); - assertFalse(methodsWithTemplateUri.isEmpty(), facade.getSimpleName() + " must declare templateUri methods"); - for (Method method : methodsWithTemplateUri) { - for (java.lang.reflect.Parameter parameter : method.getParameters()) { - if ("templateUri".equals(parameter.getName())) { - assertEquals( - String.class, - parameter.getType(), - "templateUri parameter of " + facade.getSimpleName() + "." + method.getName()); - } - } - } - } - } - - private static void assertExactNegotiationSurface(Class facade) { - Method[] allMethods = facade.getMethods(); - List negotiationMethods = Arrays.stream(allMethods) - .filter(method -> method.getName().startsWith("generateNegotiation") - || method.getName().startsWith("getNegotiation") - || "validateProposePromptAndDataFilling".equals(method.getName()) - || "validateAcceptPromptAndDataFilling".equals(method.getName()) - || "validateRejectPromptAndDataFilling".equals(method.getName()) - || "validateAbortPromptAndDataFilling".equals(method.getName())) - .toList(); - Set names = negotiationMethods.stream().map(Method::getName).collect(Collectors.toSet()); - assertEquals( - EXPECTED_NEGOTIATION_METHOD_PARAMETERS.keySet(), - names, - facade.getSimpleName() + " must expose exactly the twelve v3 negotiation methods"); - assertEquals( - EXPECTED_NEGOTIATION_METHOD_PARAMETERS.size(), - negotiationMethods.size(), - facade.getSimpleName() + " must not overload any negotiation method"); - for (Method method : negotiationMethods) { - assertEquals( - EXPECTED_NEGOTIATION_METHOD_PARAMETERS.get(method.getName()), - method.getParameterCount(), - "parameter count of " + facade.getSimpleName() + "." + method.getName()); - } - } - - private static void assertNoV2ShapeNames(Class facade) { - for (Method method : facade.getMethods()) { - String name = method.getName(); - for (String fragment : REMOVED_V2_METHOD_NAME_FRAGMENTS) { - assertFalse( - name.toLowerCase(Locale.ROOT).contains(fragment.toLowerCase(Locale.ROOT)), - facade.getSimpleName() + " must not expose the removed v2-shape method name fragment " - + fragment + " but declares " + name); - } - } - } - - private static List componentNames(Class recordType) { - return Arrays.stream(recordType.getRecordComponents()) - .map(RecordComponent::getName) - .collect(Collectors.toList()); - } -} +package net.openan.a2at.sdk.sample.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; +import java.lang.reflect.RecordComponent; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.Test; + +/** + * Locks the v3 negotiation API surface of both facades and the field faces of the result records. + * + *

            The client and the server expose exactly the twelve camelCase negotiation methods with their pinned parameter + * counts, their surface carries none of the removed v2-shape method names nor the removed negotiation query methods, + * and the result records expose exactly their documented components. + */ +class NegotiationV3ApiSurfaceTest { + + private static final Map EXPECTED_NEGOTIATION_METHOD_PARAMETERS = Map.ofEntries( + Map.entry("generateNegotiationProposePromptFromData", 2), + Map.entry("generateNegotiationAcceptPromptFromData", 2), + Map.entry("generateNegotiationRejectPromptFromData", 2), + Map.entry("generateNegotiationAbortPromptFromData", 2), + Map.entry("generateNegotiationProposePromptFromText", 3), + Map.entry("generateNegotiationAcceptPromptFromText", 3), + Map.entry("generateNegotiationRejectPromptFromText", 3), + Map.entry("generateNegotiationAbortPromptFromText", 3), + Map.entry("validateProposePromptAndDataFilling", 4), + Map.entry("validateAcceptPromptAndDataFilling", 4), + Map.entry("validateRejectPromptAndDataFilling", 4), + Map.entry("validateAbortPromptAndDataFilling", 4)); + + private static final List REMOVED_NEGOTIATION_QUERY_METHODS = + List.of("getNegotiationPrompts", "getNegotiationPrompt"); + + private static final List CROSS_EXTENSION_QUERY_METHODS = List.of("getPrompts", "getPrompt"); + + private static final List REMOVED_V2_METHOD_NAME_FRAGMENTS = + List.of("FromNl", "FromJsonData", "validateAndExtractParams"); + + @Test + void bothFacadesExposeExactlyTheTwelveV3NegotiationMethods() { + assertExactNegotiationSurface(A2ATClient.class); + assertExactNegotiationSurface(A2ATServer.class); + } + + @Test + void removedNegotiationQueryMethodsAreAbsentWhileCrossExtensionQueriesRemain() { + for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { + for (String removedMethod : REMOVED_NEGOTIATION_QUERY_METHODS) { + assertFalse( + Arrays.stream(facade.getMethods()) + .anyMatch(method -> method.getName().equals(removedMethod)), + facade.getSimpleName() + " must not expose the removed " + removedMethod); + } + for (String queryMethod : CROSS_EXTENSION_QUERY_METHODS) { + assertTrue( + Arrays.stream(facade.getMethods()) + .anyMatch(method -> method.getName().equals(queryMethod)), + facade.getSimpleName() + " must expose the cross-extension query " + queryMethod); + } + } + } + + @Test + void neitherFacadeCarriesRemovedV2ShapeMethodNames() { + assertNoV2ShapeNames(A2ATClient.class); + assertNoV2ShapeNames(A2ATServer.class); + } + + @Test + void resultRecordsExposeTheirPinnedFieldFaces() { + List metadataComponents = componentNames(MetadataContent.class); + assertEquals(List.of("templateUri", "promptText", "extensionUri", "negotiationContext"), metadataComponents); + List contextComponents = componentNames(NegotiationContext.class); + assertEquals(List.of("id", "round", "maxRounds", "performative"), contextComponents); + List filledComponents = componentNames(FilledParamData.class); + assertEquals(List.of("data"), filledComponents); + assertTrue( + Arrays.stream(MetadataContent.class.getMethods()) + .anyMatch(method -> method.getName().equals("buildMetadataContent")), + "MetadataContent must expose buildMetadataContent"); + } + + @Test + void allFacadeTemplateUriParamsAreStrings() { + for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { + List methodsWithTemplateUri = Arrays.stream(facade.getDeclaredMethods()) + .filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers()) + && !java.lang.reflect.Modifier.isStatic(method.getModifiers())) + .filter(method -> Arrays.stream(method.getParameters()) + .anyMatch(parameter -> "templateUri".equals(parameter.getName()))) + .toList(); + assertFalse(methodsWithTemplateUri.isEmpty(), facade.getSimpleName() + " must declare templateUri methods"); + for (Method method : methodsWithTemplateUri) { + for (java.lang.reflect.Parameter parameter : method.getParameters()) { + if ("templateUri".equals(parameter.getName())) { + assertEquals( + String.class, + parameter.getType(), + "templateUri parameter of " + facade.getSimpleName() + "." + method.getName()); + } + } + } + } + } + + private static void assertExactNegotiationSurface(Class facade) { + Method[] allMethods = facade.getMethods(); + List negotiationMethods = Arrays.stream(allMethods) + .filter(method -> method.getName().startsWith("generateNegotiation") + || method.getName().startsWith("getNegotiation") + || "validateProposePromptAndDataFilling".equals(method.getName()) + || "validateAcceptPromptAndDataFilling".equals(method.getName()) + || "validateRejectPromptAndDataFilling".equals(method.getName()) + || "validateAbortPromptAndDataFilling".equals(method.getName())) + .toList(); + Set names = negotiationMethods.stream().map(Method::getName).collect(Collectors.toSet()); + assertEquals( + EXPECTED_NEGOTIATION_METHOD_PARAMETERS.keySet(), + names, + facade.getSimpleName() + " must expose exactly the twelve v3 negotiation methods"); + assertEquals( + EXPECTED_NEGOTIATION_METHOD_PARAMETERS.size(), + negotiationMethods.size(), + facade.getSimpleName() + " must not overload any negotiation method"); + for (Method method : negotiationMethods) { + assertEquals( + EXPECTED_NEGOTIATION_METHOD_PARAMETERS.get(method.getName()), + method.getParameterCount(), + "parameter count of " + facade.getSimpleName() + "." + method.getName()); + } + } + + private static void assertNoV2ShapeNames(Class facade) { + for (Method method : facade.getMethods()) { + String name = method.getName(); + for (String fragment : REMOVED_V2_METHOD_NAME_FRAGMENTS) { + assertFalse( + name.toLowerCase(Locale.ROOT).contains(fragment.toLowerCase(Locale.ROOT)), + facade.getSimpleName() + " must not expose the removed v2-shape method name fragment " + + fragment + " but declares " + name); + } + } + } + + private static List componentNames(Class recordType) { + return Arrays.stream(recordType.getRecordComponents()) + .map(RecordComponent::getName) + .collect(Collectors.toList()); + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 5b5848b0..5dbf3092 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -1,592 +1,592 @@ -package net.openan.a2at.sdk.server; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; -import net.openan.a2at.sdk.server.compliance.ServerPromptComplianceOrchestrator; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import org.jspecify.annotations.NonNull; - -/** - * High-level server facade for prompt compliance and negotiation APIs. The caller provides the `.env` file path - * explicitly, typically after copying the repository `env.example`. - * - * @since 2026-06 - */ -public final class A2ATServer { - - private final ServerPromptComplianceOrchestrator promptComplianceOrchestrator; - - private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; - - private final NegotiationContentService negotiationContentService; - - private final TemplateQueryService templateQueryService; - - private final ContentValidator taskContentValidator; - - private final ContentValidator notificationContentValidator; - - private final ContentValidator authContentValidator; - - /** - * Creates a server facade from one user-supplied `.env` path. - * - * @param envPath user-supplied `.env` file path - */ - public A2ATServer(Path envPath) { - Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); - A2ATConfig config = - A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); - DefaultA2ATServerBuilder builder = - DefaultA2ATServerBuilder.builder().envPath(resolvedEnvPath).config(config); - this.promptComplianceOrchestrator = builder.buildPromptComplianceOrchestrator(); - this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); - this.negotiationContentService = - new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); - this.templateQueryService = builder.buildTemplateQueryService(); - this.taskContentValidator = builder.buildTaskContentValidator(); - this.notificationContentValidator = builder.buildNotificationContentValidator(); - this.authContentValidator = builder.buildAuthContentValidator(); - } - - /** - * Checks one processed task prompt for server-side compliance. - * - *

            An input longer than {@code A2AT_INPUT_TEXT_MAX_CHARS} characters (default 16384) fails fast with the code - * {@code input.text_too_long} before any LLM call. - * - * @param processedPromptText processed task prompt text - * @return compliance result containing only success state or failure details - */ - public PromptComplianceResult checkTaskPrompt(@NonNull String processedPromptText) { - return promptComplianceOrchestrator.checkTaskPrompt(processedPromptText); - } - - /** - * Starts a new negotiation payload for the requested negotiation type. - * - * @param type negotiation type to initiate - * @param contentText human-readable negotiation message - * @param facts structured facts attached to the payload - * @return transport payload representing the initial negotiation turn - */ - public Map startNegotiation( - @NonNull NegotiationType type, @NonNull String contentText, @NonNull Map facts) { - return negotiationOrchestrator.startNegotiation(type, contentText, facts); - } - - /** - * Processes a received negotiation message using its transport context payload. - * - * @param message received negotiation message - * @param context transport context payload associated with the message - * @return normalized payload describing the receive result - */ - public Map receiveNegotiation(@NonNull String message, @NonNull Map context) { - return negotiationOrchestrator.receiveNegotiation(message, context); - } - - /** - * Continues an existing negotiation with a locally stored context snapshot. - * - * @param context current negotiation context - * @param status next status to emit - * @param contentText continuation message content - * @return transport payload representing the next negotiation turn - */ - public Map continueNegotiation( - @NonNull NegotiationContext context, @NonNull NegotiationStatus status, @NonNull String contentText) { - return negotiationOrchestrator.continueNegotiation(context, status, contentText); - } - - /** - * Generates a propose-phase negotiation message from typed data. - * - *

            This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the - * generator of the negotiation type addressed by the template URI and rendered from that template. - * - * @param data typed propose input carrying the negotiation context and the typed content - * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/propose/v1}; - * its performative segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the - * method or the content type - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, the code - * {@code negotiation.content_invalid} when a required content field is blank or empty, the code - * {@code negotiation.invalid_input} when the content combines a non-blank confirm request with conditional - * sections, or the code {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateNegotiationProposePromptFromData( - @NonNull NegotiationProposeData data, @NonNull String templateUri) { - return negotiationContentService.generateProposeFromData(data, parseTemplateUri(templateUri)); - } - - /** - * Generates an accept-phase negotiation message from typed data. - * - *

            This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; - * its performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the - * method or the content - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, the code - * {@code negotiation.conclusion_mismatch} when the content conclusion is not {@code Accept}, the code - * {@code negotiation.content_invalid} when a required content field is blank or empty, or the code - * {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateNegotiationAcceptPromptFromData( - @NonNull NegotiationEndingData data, @NonNull String templateUri) { - return negotiationContentService.generateAcceptFromData(data, parseTemplateUri(templateUri)); - } - - /** - * Generates a reject-phase negotiation message from typed data. - * - *

            This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a - * mismatched conclusion is a content error. - * - * @param data typed terminal input carrying the negotiation context and the typed ending content - * @param templateUri raw template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; - * its performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the - * method or the content - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, the code - * {@code negotiation.conclusion_mismatch} when the content conclusion is not {@code Reject}, the code - * {@code negotiation.content_invalid} when a required content field is blank or empty, or the code - * {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateNegotiationRejectPromptFromData( - @NonNull NegotiationEndingData data, @NonNull String templateUri) { - return negotiationContentService.generateRejectFromData(data, parseTemplateUri(templateUri)); - } - - /** - * Generates an abort negotiation message from typed data. - * - *

            This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed - * template must be the common abort template and the content carries only the termination reason. - * - * @param data typed abort input carrying the negotiation context and the termination reason - * @param templateUri raw template URI string of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the data, its context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank, malformed or does not address the common abort - * template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template exists for the URI in any resource root, the code - * {@code negotiation.content_invalid} when the termination reason is blank, or the code - * {@code template.render_failed} when rendering the template fails - */ - public MetadataContent generateNegotiationAbortPromptFromData( - @NonNull NegotiationAbortData data, @NonNull String templateUri) { - return negotiationContentService.generateAbortFromData(data, parseTemplateUri(templateUri)); - } - - /** - * Generates a propose-phase negotiation message from free text. - * - *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step - * is retried up to the configured attempt limit on the retryable failure codes - * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri raw template URI string such as {@code Negotiation-T/target-negotiation/propose/v1}; its - * performative segment must be {@code propose} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} - * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the - * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the - * extracted content contradicts the phase, or {@code input.text_too_long} when the text exceeds the configured - * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) - */ - public MetadataContent generateNegotiationProposePromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, - @NonNull String templateUri) { - return negotiationContentService.generateProposeFromText(text, context, parseTemplateUri(templateUri)); - } - - /** - * Generates an accept-phase negotiation message from free text. - * - *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Accept}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; - * its performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} - * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the - * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank, - * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Accept}, or - * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public MetadataContent generateNegotiationAcceptPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, - @NonNull String templateUri) { - return negotiationContentService.generateAcceptFromText(text, context, parseTemplateUri(templateUri)); - } - - /** - * Generates a reject-phase negotiation message from free text. - * - *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders - * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted - * conclusion must be {@code Reject}. - * - * @param text free-text input describing the message content - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri raw template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; - * its performative segment must be {@code accept-reject} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} - * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the - * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank, - * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Reject}, or - * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public MetadataContent generateNegotiationRejectPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, - @NonNull String templateUri) { - return negotiationContentService.generateRejectFromText(text, context, parseTemplateUri(templateUri)); - } - - /** - * Generates an abort negotiation message from free text. - * - *

            This variant runs one LLM content-extraction step constrained by the common abort template and then renders - * deterministically like the from-data variant. - * - * @param text free-text input stating the termination reason - * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated - * message without any LLM involvement - * @param templateUri raw template URI string of the common abort template {@code Negotiation-T/common/abort/v1} - * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI - * @throws NullPointerException if the context or the template URI is null - * @throws IllegalArgumentException if the template URI is blank, malformed or does not address the common abort - * template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code - * {@code template.not_found} when no template or prompt resource exists for the URI and language, - * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} - * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the - * extracted content misses the termination reason, {@code negotiation.invalid_input} when the text is blank, or - * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public MetadataContent generateNegotiationAbortPromptFromText( - @NonNull String text, - net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, - @NonNull String templateUri) { - return negotiationContentService.generateAbortFromText(text, context, parseTemplateUri(templateUri)); - } - - /** - * Lists every template available for the configured language across all A2A-T extensions. - * - *

            This query never throws: the extension directories are discovered from the bundled resource tree itself, so - * templates of extensions added later are included automatically. Templates that exist nowhere for the language are - * skipped and an empty list is returned when no template can be loaded at all. - * - * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when - * none can be loaded - */ - public List getPrompts() { - return templateQueryService.getPrompts(); - } - - /** - * Loads one template by its URI, regardless of the extension. - * - *

            This query never throws for a well-formed template URI: a template that exists nowhere for the configured - * language returns an empty result and logs an actionable warning. - * - * @param templateUri raw template URI string such as {@code Negotiation-T/target-negotiation/propose/v1} or - * {@code Task-T/network-layer/ran-energy-saving/v1} - * @return the addressed template, or an empty result when the template does not exist for the configured language - * @throws NullPointerException if the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed - */ - public Optional getPrompt(@NonNull String templateUri) { - return templateQueryService.getPrompt(parseTemplateUri(templateUri)); - } - - /** - * Validates a propose-phase negotiation message and extracts its parameters. - * - *

            The pipeline checks the template URI format before any LLM call, runs the deterministic rule gate, then - * performs one semantic validation LLM call (retried on the retryable failure codes) and merges the extracted - * parameters with the rule-level context parameters; context parameters win on conflict. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri raw template URI string declaring the expected negotiation type and phase; its performative segment - * must be {@code propose} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes - * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed - * {@code negotiation.*} code set (for example - * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateProposePromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull String templateUri) { - return negotiationContentService.validateProposePromptAndDataFilling( - prompt, context, schema, parseTemplateUri(templateUri)); - } - - /** - * Validates an accept-phase negotiation message and extracts its parameters. - * - *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to - * accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * accept-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri raw template URI string declaring the expected negotiation type and phase; its performative segment - * must be {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes - * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed - * {@code negotiation.*} code set (for example - * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateAcceptPromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull String templateUri) { - return negotiationContentService.validateAcceptPromptAndDataFilling( - prompt, context, schema, parseTemplateUri(templateUri)); - } - - /** - * Validates a reject-phase negotiation message and extracts its parameters. - * - *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to - * reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the - * reject-phase semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri raw template URI string declaring the expected negotiation type and phase; its performative segment - * must be {@code accept-reject} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes - * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed - * {@code negotiation.*} code set (for example - * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateRejectPromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull String templateUri) { - return negotiationContentService.validateRejectPromptAndDataFilling( - prompt, context, schema, parseTemplateUri(templateUri)); - } - - /** - * Validates an abort negotiation message and extracts its parameters. - * - *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to - * abort: the template URI must address the common abort template and the message must satisfy the abort-phase - * semantic constraints. - * - * @param prompt rendered negotiation message text to validate - * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported - * as not being a negotiation message - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri raw template URI string of the common abort template {@code Negotiation-T/common/abort/v1} - * @return filled parameter data carrying the context parameters and the extracted parameters - * @throws NullPointerException if the schema or the template URI is null - * @throws IllegalArgumentException if the template URI is blank, malformed or does not address the common abort - * template - * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code - * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, - * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes - * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed - * {@code negotiation.*} code set (for example - * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, - * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or - * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, - * default 16384) - */ - public FilledParamData validateAbortPromptAndDataFilling( - @NonNull String prompt, - net.openan.a2at.sdk.core.model.NegotiationContext context, - @NonNull Map schema, - @NonNull String templateUri) { - return negotiationContentService.validateAbortPromptAndDataFilling( - prompt, context, schema, parseTemplateUri(templateUri)); - } - - /** - * Validates a task prompt and extracts its parameters. - * - * @param prompt rendered task prompt text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri raw template URI string declaring the expected task template; its prefix segment must be - * {@code Task-T} - * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed - * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code negotiation.invalid_input} when the prompt is null or blank, the schema is null, or the template URI - * addresses another extension or carries an unsupported version, the code - * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, the code - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, the code {@code template.not_found} when the template or the validation prompt resources are - * missing, or the code {@code input.text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) - */ - public FilledParamData validateTaskPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { - return taskContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); - } - - /** - * Validates a notification prompt and extracts its parameters. - * - * @param prompt rendered notification prompt text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri raw template URI string declaring the expected notification template; its prefix segment must - * be {@code Notification-T} - * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed - * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code negotiation.invalid_input} when the prompt is null or blank, the schema is null, or the template URI - * addresses another extension or carries an unsupported version, the code - * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, the code - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, the code {@code template.not_found} when the template or the validation prompt resources are - * missing, or the code {@code input.text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) - */ - public FilledParamData validateNotificationPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { - return notificationContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); - } - - /** - * Validates an authorization prompt and extracts its parameters. - * - * @param prompt rendered authorization prompt text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri raw template URI string declaring the expected authorization template; its prefix segment - * must be {@code Authorization-T} - * @return filled parameter data carrying the merged parameters - * @throws NullPointerException if the template URI is null - * @throws IllegalArgumentException if the template URI is blank or malformed - * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code - * {@code negotiation.invalid_input} when the prompt is null or blank, the schema is null, or the template URI - * addresses another extension or carries an unsupported version, the code - * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, the code - * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting - * its retries, the code {@code template.not_found} when the template or the validation prompt resources are - * missing, or the code {@code input.text_too_long} when the prompt exceeds the configured maximum length - * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) - */ - public FilledParamData validateAuthPromptAndDataFilling( - @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { - return authContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); - } - - private static TemplateUri parseTemplateUri(String templateUri) { - Objects.requireNonNull(templateUri, "templateUri"); - return TemplateUri.parse(templateUri) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); - } -} +package net.openan.a2at.sdk.server; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; +import net.openan.a2at.sdk.server.compliance.ServerPromptComplianceOrchestrator; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.jspecify.annotations.NonNull; + +/** + * High-level server facade for prompt compliance and negotiation APIs. The caller provides the `.env` file path + * explicitly, typically after copying the repository `env.example`. + * + * @since 2026-06 + */ +public final class A2ATServer { + + private final ServerPromptComplianceOrchestrator promptComplianceOrchestrator; + + private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; + + private final NegotiationContentService negotiationContentService; + + private final TemplateQueryService templateQueryService; + + private final ContentValidator taskContentValidator; + + private final ContentValidator notificationContentValidator; + + private final ContentValidator authContentValidator; + + /** + * Creates a server facade from one user-supplied `.env` path. + * + * @param envPath user-supplied `.env` file path + */ + public A2ATServer(Path envPath) { + Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); + A2ATConfig config = + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + DefaultA2ATServerBuilder builder = + DefaultA2ATServerBuilder.builder().envPath(resolvedEnvPath).config(config); + this.promptComplianceOrchestrator = builder.buildPromptComplianceOrchestrator(); + this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); + this.negotiationContentService = + new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.templateQueryService = builder.buildTemplateQueryService(); + this.taskContentValidator = builder.buildTaskContentValidator(); + this.notificationContentValidator = builder.buildNotificationContentValidator(); + this.authContentValidator = builder.buildAuthContentValidator(); + } + + /** + * Checks one processed task prompt for server-side compliance. + * + *

            An input longer than {@code A2AT_INPUT_TEXT_MAX_CHARS} characters (default 16384) fails fast with the code + * {@code input.text_too_long} before any LLM call. + * + * @param processedPromptText processed task prompt text + * @return compliance result containing only success state or failure details + */ + public PromptComplianceResult checkTaskPrompt(@NonNull String processedPromptText) { + return promptComplianceOrchestrator.checkTaskPrompt(processedPromptText); + } + + /** + * Starts a new negotiation payload for the requested negotiation type. + * + * @param type negotiation type to initiate + * @param contentText human-readable negotiation message + * @param facts structured facts attached to the payload + * @return transport payload representing the initial negotiation turn + */ + public Map startNegotiation( + @NonNull NegotiationType type, @NonNull String contentText, @NonNull Map facts) { + return negotiationOrchestrator.startNegotiation(type, contentText, facts); + } + + /** + * Processes a received negotiation message using its transport context payload. + * + * @param message received negotiation message + * @param context transport context payload associated with the message + * @return normalized payload describing the receive result + */ + public Map receiveNegotiation(@NonNull String message, @NonNull Map context) { + return negotiationOrchestrator.receiveNegotiation(message, context); + } + + /** + * Continues an existing negotiation with a locally stored context snapshot. + * + * @param context current negotiation context + * @param status next status to emit + * @param contentText continuation message content + * @return transport payload representing the next negotiation turn + */ + public Map continueNegotiation( + @NonNull NegotiationContext context, @NonNull NegotiationStatus status, @NonNull String contentText) { + return negotiationOrchestrator.continueNegotiation(context, status, contentText); + } + + /** + * Generates a propose-phase negotiation message from typed data. + * + *

            This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the + * generator of the negotiation type addressed by the template URI and rendered from that template. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/propose/v1}; + * its performative segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content type + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, the code + * {@code negotiation.invalid_input} when the content combines a non-blank confirm request with conditional + * sections, or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateNegotiationProposePromptFromData( + @NonNull NegotiationProposeData data, @NonNull String templateUri) { + return negotiationContentService.generateProposeFromData(data, parseTemplateUri(templateUri)); + } + + /** + * Generates an accept-phase negotiation message from typed data. + * + *

            This variant is deterministic and never calls an LLM. The content conclusion must be {@code Accept}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; + * its performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.conclusion_mismatch} when the content conclusion is not {@code Accept}, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, or the code + * {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateNegotiationAcceptPromptFromData( + @NonNull NegotiationEndingData data, @NonNull String templateUri) { + return negotiationContentService.generateAcceptFromData(data, parseTemplateUri(templateUri)); + } + + /** + * Generates a reject-phase negotiation message from typed data. + * + *

            This variant is deterministic and never calls an LLM. The content conclusion must be {@code Reject}; a + * mismatched conclusion is a content error. + * + * @param data typed terminal input carrying the negotiation context and the typed ending content + * @param templateUri raw template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; + * its performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.conclusion_mismatch} when the content conclusion is not {@code Reject}, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, or the code + * {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateNegotiationRejectPromptFromData( + @NonNull NegotiationEndingData data, @NonNull String templateUri) { + return negotiationContentService.generateRejectFromData(data, parseTemplateUri(templateUri)); + } + + /** + * Generates an abort negotiation message from typed data. + * + *

            This variant is deterministic and never calls an LLM. Abort messages are type-independent: the addressed + * template must be the common abort template and the content carries only the termination reason. + * + * @param data typed abort input carrying the negotiation context and the termination reason + * @param templateUri raw template URI string of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank, malformed or does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.content_invalid} when the termination reason is blank, or the code + * {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateNegotiationAbortPromptFromData( + @NonNull NegotiationAbortData data, @NonNull String templateUri) { + return negotiationContentService.generateAbortFromData(data, parseTemplateUri(templateUri)); + } + + /** + * Generates a propose-phase negotiation message from free text. + * + *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri raw template URI string such as {@code Negotiation-T/target-negotiation/propose/v1}; its + * performative segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the + * extracted content contradicts the phase, or {@code input.text_too_long} when the text exceeds the configured + * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public MetadataContent generateNegotiationProposePromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + return negotiationContentService.generateProposeFromText(text, context, parseTemplateUri(templateUri)); + } + + /** + * Generates an accept-phase negotiation message from free text. + * + *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Accept}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; + * its performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Accept}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public MetadataContent generateNegotiationAcceptPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + return negotiationContentService.generateAcceptFromText(text, context, parseTemplateUri(templateUri)); + } + + /** + * Generates a reject-phase negotiation message from free text. + * + *

            This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Reject}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri raw template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; + * its performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Reject}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public MetadataContent generateNegotiationRejectPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + return negotiationContentService.generateRejectFromText(text, context, parseTemplateUri(templateUri)); + } + + /** + * Generates an abort negotiation message from free text. + * + *

            This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. + * + * @param text free-text input stating the termination reason + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri raw template URI string of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank, malformed or does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses the termination reason, {@code negotiation.invalid_input} when the text is blank, or + * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public MetadataContent generateNegotiationAbortPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + return negotiationContentService.generateAbortFromText(text, context, parseTemplateUri(templateUri)); + } + + /** + * Lists every template available for the configured language across all A2A-T extensions. + * + *

            This query never throws: the extension directories are discovered from the bundled resource tree itself, so + * templates of extensions added later are included automatically. Templates that exist nowhere for the language are + * skipped and an empty list is returned when no template can be loaded at all. + * + * @return loadable templates of the configured language across all extensions, sorted by template URI; empty when + * none can be loaded + */ + public List getPrompts() { + return templateQueryService.getPrompts(); + } + + /** + * Loads one template by its URI, regardless of the extension. + * + *

            This query never throws for a well-formed template URI: a template that exists nowhere for the configured + * language returns an empty result and logs an actionable warning. + * + * @param templateUri raw template URI string such as {@code Negotiation-T/target-negotiation/propose/v1} or + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return the addressed template, or an empty result when the template does not exist for the configured language + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + */ + public Optional getPrompt(@NonNull String templateUri) { + return templateQueryService.getPrompt(parseTemplateUri(templateUri)); + } + + /** + * Validates a propose-phase negotiation message and extracts its parameters. + * + *

            The pipeline checks the template URI format before any LLM call, runs the deterministic rule gate, then + * performs one semantic validation LLM call (retried on the retryable failure codes) and merges the extracted + * parameters with the rule-level context parameters; context parameters win on conflict. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string declaring the expected negotiation type and phase; its performative segment + * must be {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateProposePromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + return negotiationContentService.validateProposePromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri)); + } + + /** + * Validates an accept-phase negotiation message and extracts its parameters. + * + *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * accept-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string declaring the expected negotiation type and phase; its performative segment + * must be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateAcceptPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + return negotiationContentService.validateAcceptPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri)); + } + + /** + * Validates a reject-phase negotiation message and extracts its parameters. + * + *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * reject-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string declaring the expected negotiation type and phase; its performative segment + * must be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateRejectPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + return negotiationContentService.validateRejectPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri)); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

            The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * abort: the template URI must address the common abort template and the message must satisfy the abort-phase + * semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank, malformed or does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateAbortPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + return negotiationContentService.validateAbortPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri)); + } + + /** + * Validates a task prompt and extracts its parameters. + * + * @param prompt rendered task prompt text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string declaring the expected task template; its prefix segment must be + * {@code Task-T} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank, the schema is null, or the template URI + * addresses another extension or carries an unsupported version, the code + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, the code + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, the code {@code template.not_found} when the template or the validation prompt resources are + * missing, or the code {@code input.text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public FilledParamData validateTaskPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { + return taskContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + } + + /** + * Validates a notification prompt and extracts its parameters. + * + * @param prompt rendered notification prompt text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string declaring the expected notification template; its prefix segment must + * be {@code Notification-T} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank, the schema is null, or the template URI + * addresses another extension or carries an unsupported version, the code + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, the code + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, the code {@code template.not_found} when the template or the validation prompt resources are + * missing, or the code {@code input.text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public FilledParamData validateNotificationPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { + return notificationContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + } + + /** + * Validates an authorization prompt and extracts its parameters. + * + * @param prompt rendered authorization prompt text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string declaring the expected authorization template; its prefix segment + * must be {@code Authorization-T} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank, the schema is null, or the template URI + * addresses another extension or carries an unsupported version, the code + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, the code + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, the code {@code template.not_found} when the template or the validation prompt resources are + * missing, or the code {@code input.text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public FilledParamData validateAuthPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { + return authContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + } + + private static TemplateUri parseTemplateUri(String templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index edd14d1c..b3239996 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -1,276 +1,276 @@ -package net.openan.a2at.sdk.server.assembly; - -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; -import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; -import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.validation.DefaultContentValidator; -import net.openan.a2at.sdk.server.compliance.DefaultServerPromptComplianceOrchestrator; -import net.openan.a2at.sdk.server.metadata.LlmBackedPromptMetadataExtractor; -import net.openan.a2at.sdk.server.validation.InputLimitedContentValidator; -import net.openan.a2at.sdk.server.validation.LlmBackedPromptSemanticValidator; -import org.jspecify.annotations.Nullable; - -/** - * Default builder that assembles one high-level A2AT server runtime from unified config. - * - * @since 2026-06 - */ -public final class DefaultA2ATServerBuilder { - - private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; - - private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; - - private static final String SEMANTIC_VALIDATION_PROMPT = "semantic_validation"; - - private A2ATConfig config; - - private Path envPath; - - private LLMClient llmClient; - - private PromptResourceAccess promptResourceAccess; - - private DefaultServerPromptComplianceOrchestrator promptComplianceOrchestrator; - - /** - * Creates one new builder instance. - * - * @return empty server builder - */ - public static DefaultA2ATServerBuilder builder() { - return new DefaultA2ATServerBuilder(); - } - - /** - * Configures the unified SDK config consumed by the high-level server facade. - * - * @param config unified SDK config - * @return current builder - */ - public DefaultA2ATServerBuilder config(A2ATConfig config) { - this.config = config; - return this; - } - - /** - * Configures the `.env` file path used to assemble downstream facades. - * - * @param envPath caller-supplied `.env` path - * @return current builder - */ - public DefaultA2ATServerBuilder envPath(Path envPath) { - this.envPath = envPath; - return this; - } - - /** - * Injects an explicit LLM client that fully replaces the factory default. - * - *

            The injection point exists for testability and custom LLM assemblies: when set, every orchestrator and - * validator built by this builder reuses the given client and no client is created from the `.env` LLM config. When - * unset, the builder keeps creating its default client and the behavior is unchanged. - * - * @param llmClient LLM client to inject; {@code null} keeps the factory default built from the `.env` LLM config - * @return current builder - * @since 2026-08 - */ - public DefaultA2ATServerBuilder llmClient(@Nullable LLMClient llmClient) { - this.llmClient = llmClient; - return this; - } - - /** - * Builds the default prompt-compliance orchestrator from the configured unified SDK config. - * - * @return assembled prompt-compliance orchestrator - */ - public synchronized DefaultServerPromptComplianceOrchestrator buildPromptComplianceOrchestrator() { - require(config, "Unified SDK config must be configured."); - require(envPath, "Unified SDK env path must be configured."); - requireSupportedConfig(); - if (promptComplianceOrchestrator != null) { - return promptComplianceOrchestrator; - } - - PromptResourceAccess resources = promptResourceAccess(); - String language = config.prompt().language(); - List scenarios = resources.loadScenarios(language); - PromptTemplateTextLoader templateLoader = resources.templateLoader(); - PromptSlotSchemaLoader slotSchemaLoader = resources.slotSchemaLoader(); - LLMClient client = llmClient(); - - String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); - String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); - String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); - String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); - String semanticSystemPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "system.md"); - String semanticUserPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "user.md"); - - promptComplianceOrchestrator = new DefaultServerPromptComplianceOrchestrator( - new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(client), - scenarios, - language, - scenarioSystemPrompt, - scenarioUserPrompt, - templateLoader, - slotSchemaLoader, - new DefaultStructuredPromptSlotValueExtractor( - client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)), - new LlmBackedPromptSemanticValidator( - client, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt), - config.inputLimits().maxTextChars(), - language); - return promptComplianceOrchestrator; - } - - /** - * Builds the default negotiation orchestrator from the configured unified SDK config. - * - * @return assembled negotiation orchestrator - */ - public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - return new ServerNegotiationOrchestratorBuilder() - .promptComplianceOrchestrator(buildPromptComplianceOrchestrator()) - .build(); - } - - /** - * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. - * - *

            The wiring is shared with the client side through - * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local - * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config. - * - * @return assembled negotiation generation orchestrator - */ - public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - require(envPath, "Unified SDK env path must be configured."); - return NegotiationContentService.buildOrchestrator(config, llmClient()); - } - - /** - * Builds the generic template query service from the configured unified SDK config. - * - *

            The service answers the extension-agnostic template queries: the message language and the local template root - * come from the prompt runtime config, exactly like the negotiation generation orchestrator wiring. - * - * @return assembled template query service - */ - public TemplateQueryService buildTemplateQueryService() { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - return new TemplateQueryService( - config.prompt().language(), - config.prompt().sourceType(), - config.prompt().localRootDir()); - } - - /** - * Builds the task content validator from the configured unified SDK config. - * - *

            The validator enforces the {@code Task-T} extension prefix and reuses the configured language, LLM retry - * attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. - * - * @return assembled task content validator - */ - public ContentValidator buildTaskContentValidator() { - return buildContentValidator(StandardTemplates.TASK_EXTENSION_NAME); - } - - /** - * Builds the notification content validator from the configured unified SDK config. - * - *

            The validator enforces the {@code Notification-T} extension prefix and reuses the configured language, LLM - * retry attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. - * - * @return assembled notification content validator - */ - public ContentValidator buildNotificationContentValidator() { - return buildContentValidator(StandardTemplates.NOTIFICATION_EXTENSION_NAME); - } - - /** - * Builds the authorization content validator from the configured unified SDK config. - * - *

            The validator enforces the {@code Authorization-T} extension prefix and reuses the configured language, LLM - * retry attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. - * - * @return assembled authorization content validator - */ - public ContentValidator buildAuthContentValidator() { - return buildContentValidator(StandardTemplates.AUTHORIZATION_EXTENSION_NAME); - } - - private ContentValidator buildContentValidator(String extensionName) { - require(config, "Unified SDK config must be configured."); - requireSupportedConfig(); - require(envPath, "Unified SDK env path must be configured."); - return new InputLimitedContentValidator( - new DefaultContentValidator( - extensionName, - config.prompt().language(), - config.llm().maxAttempts(), - llmClient(), - promptResourceAccess().templateLoader()), - config.inputLimits().maxTextChars(), - config.prompt().language()); - } - - private static void require(Object value, String message) { - if (value == null) { - throw new IllegalStateException(message); - } - } - - private void requireSupportedConfig() { - if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) - && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals( - config.prompt().sourceType())) { - throw new UnsupportedOperationException( - "Unsupported prompt source type: " + config.prompt().sourceType()); - } - if (!LLMClientFactory.availableProviders().contains(config.llm().provider())) { - throw new UnsupportedOperationException( - "Unsupported LLM provider: " + config.llm().provider()); - } - if (!"in_memory".equals(config.negotiation().stateStoreType())) { - throw new UnsupportedOperationException("Unsupported negotiation state store type: " - + config.negotiation().stateStoreType()); - } - } - - private synchronized LLMClient llmClient() { - if (llmClient == null) { - llmClient = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); - } - return llmClient; - } - - private synchronized PromptResourceAccess promptResourceAccess() { - if (promptResourceAccess == null) { - promptResourceAccess = PromptResourceAccess.create(config.prompt()); - } - return promptResourceAccess; - } -} +package net.openan.a2at.sdk.server.assembly; + +import java.nio.file.Path; +import java.util.List; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.prompt.analysis.impl.DefaultStructuredPromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.prompt.validation.DefaultContentValidator; +import net.openan.a2at.sdk.server.compliance.DefaultServerPromptComplianceOrchestrator; +import net.openan.a2at.sdk.server.metadata.LlmBackedPromptMetadataExtractor; +import net.openan.a2at.sdk.server.validation.InputLimitedContentValidator; +import net.openan.a2at.sdk.server.validation.LlmBackedPromptSemanticValidator; +import org.jspecify.annotations.Nullable; + +/** + * Default builder that assembles one high-level A2AT server runtime from unified config. + * + * @since 2026-06 + */ +public final class DefaultA2ATServerBuilder { + + private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; + + private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; + + private static final String SEMANTIC_VALIDATION_PROMPT = "semantic_validation"; + + private A2ATConfig config; + + private Path envPath; + + private LLMClient llmClient; + + private PromptResourceAccess promptResourceAccess; + + private DefaultServerPromptComplianceOrchestrator promptComplianceOrchestrator; + + /** + * Creates one new builder instance. + * + * @return empty server builder + */ + public static DefaultA2ATServerBuilder builder() { + return new DefaultA2ATServerBuilder(); + } + + /** + * Configures the unified SDK config consumed by the high-level server facade. + * + * @param config unified SDK config + * @return current builder + */ + public DefaultA2ATServerBuilder config(A2ATConfig config) { + this.config = config; + return this; + } + + /** + * Configures the `.env` file path used to assemble downstream facades. + * + * @param envPath caller-supplied `.env` path + * @return current builder + */ + public DefaultA2ATServerBuilder envPath(Path envPath) { + this.envPath = envPath; + return this; + } + + /** + * Injects an explicit LLM client that fully replaces the factory default. + * + *

            The injection point exists for testability and custom LLM assemblies: when set, every orchestrator and + * validator built by this builder reuses the given client and no client is created from the `.env` LLM config. When + * unset, the builder keeps creating its default client and the behavior is unchanged. + * + * @param llmClient LLM client to inject; {@code null} keeps the factory default built from the `.env` LLM config + * @return current builder + * @since 2026-08 + */ + public DefaultA2ATServerBuilder llmClient(@Nullable LLMClient llmClient) { + this.llmClient = llmClient; + return this; + } + + /** + * Builds the default prompt-compliance orchestrator from the configured unified SDK config. + * + * @return assembled prompt-compliance orchestrator + */ + public synchronized DefaultServerPromptComplianceOrchestrator buildPromptComplianceOrchestrator() { + require(config, "Unified SDK config must be configured."); + require(envPath, "Unified SDK env path must be configured."); + requireSupportedConfig(); + if (promptComplianceOrchestrator != null) { + return promptComplianceOrchestrator; + } + + PromptResourceAccess resources = promptResourceAccess(); + String language = config.prompt().language(); + List scenarios = resources.loadScenarios(language); + PromptTemplateTextLoader templateLoader = resources.templateLoader(); + PromptSlotSchemaLoader slotSchemaLoader = resources.slotSchemaLoader(); + LLMClient client = llmClient(); + + String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); + String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); + String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); + String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); + String semanticSystemPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "system.md"); + String semanticUserPrompt = resources.loadPrompt(SEMANTIC_VALIDATION_PROMPT, language, "user.md"); + + promptComplianceOrchestrator = new DefaultServerPromptComplianceOrchestrator( + new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(client), + scenarios, + language, + scenarioSystemPrompt, + scenarioUserPrompt, + templateLoader, + slotSchemaLoader, + new DefaultStructuredPromptSlotValueExtractor( + client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)), + new LlmBackedPromptSemanticValidator( + client, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt), + config.inputLimits().maxTextChars(), + language); + return promptComplianceOrchestrator; + } + + /** + * Builds the default negotiation orchestrator from the configured unified SDK config. + * + * @return assembled negotiation orchestrator + */ + public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + return new ServerNegotiationOrchestratorBuilder() + .promptComplianceOrchestrator(buildPromptComplianceOrchestrator()) + .build(); + } + + /** + * Builds the default negotiation content-layer orchestrator from the configured unified SDK config. + * + *

            The wiring is shared with the client side through + * {@link NegotiationContentService#buildOrchestrator(A2ATConfig, LLMClient)}: the message language and the local + * template root come from the prompt runtime config, the retry attempt limit comes from the LLM config. + * + * @return assembled negotiation generation orchestrator + */ + public NegotiationGenerationOrchestrator buildNegotiationGenerationOrchestrator() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + require(envPath, "Unified SDK env path must be configured."); + return NegotiationContentService.buildOrchestrator(config, llmClient()); + } + + /** + * Builds the generic template query service from the configured unified SDK config. + * + *

            The service answers the extension-agnostic template queries: the message language and the local template root + * come from the prompt runtime config, exactly like the negotiation generation orchestrator wiring. + * + * @return assembled template query service + */ + public TemplateQueryService buildTemplateQueryService() { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + return new TemplateQueryService( + config.prompt().language(), + config.prompt().sourceType(), + config.prompt().localRootDir()); + } + + /** + * Builds the task content validator from the configured unified SDK config. + * + *

            The validator enforces the {@code Task-T} extension prefix and reuses the configured language, LLM retry + * attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. + * + * @return assembled task content validator + */ + public ContentValidator buildTaskContentValidator() { + return buildContentValidator(StandardTemplates.TASK_EXTENSION_NAME); + } + + /** + * Builds the notification content validator from the configured unified SDK config. + * + *

            The validator enforces the {@code Notification-T} extension prefix and reuses the configured language, LLM + * retry attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. + * + * @return assembled notification content validator + */ + public ContentValidator buildNotificationContentValidator() { + return buildContentValidator(StandardTemplates.NOTIFICATION_EXTENSION_NAME); + } + + /** + * Builds the authorization content validator from the configured unified SDK config. + * + *

            The validator enforces the {@code Authorization-T} extension prefix and reuses the configured language, LLM + * retry attempt limit and LLM client; the content_validation prompt resources are loaded from the classpath. + * + * @return assembled authorization content validator + */ + public ContentValidator buildAuthContentValidator() { + return buildContentValidator(StandardTemplates.AUTHORIZATION_EXTENSION_NAME); + } + + private ContentValidator buildContentValidator(String extensionName) { + require(config, "Unified SDK config must be configured."); + requireSupportedConfig(); + require(envPath, "Unified SDK env path must be configured."); + return new InputLimitedContentValidator( + new DefaultContentValidator( + extensionName, + config.prompt().language(), + config.llm().maxAttempts(), + llmClient(), + promptResourceAccess().templateLoader()), + config.inputLimits().maxTextChars(), + config.prompt().language()); + } + + private static void require(Object value, String message) { + if (value == null) { + throw new IllegalStateException(message); + } + } + + private void requireSupportedConfig() { + if (!PromptResourceAccess.CLASSPATH_SOURCE_TYPE.equals(config.prompt().sourceType()) + && !PromptResourceAccess.LOCAL_FILE_SOURCE_TYPE.equals( + config.prompt().sourceType())) { + throw new UnsupportedOperationException( + "Unsupported prompt source type: " + config.prompt().sourceType()); + } + if (!LLMClientFactory.availableProviders().contains(config.llm().provider())) { + throw new UnsupportedOperationException( + "Unsupported LLM provider: " + config.llm().provider()); + } + if (!"in_memory".equals(config.negotiation().stateStoreType())) { + throw new UnsupportedOperationException("Unsupported negotiation state store type: " + + config.negotiation().stateStoreType()); + } + } + + private synchronized LLMClient llmClient() { + if (llmClient == null) { + llmClient = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); + } + return llmClient; + } + + private synchronized PromptResourceAccess promptResourceAccess() { + if (promptResourceAccess == null) { + promptResourceAccess = PromptResourceAccess.create(config.prompt()); + } + return promptResourceAccess; + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java index f05c3000..6dac1f0c 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java @@ -1,100 +1,100 @@ -package net.openan.a2at.sdk.server.compliance; - -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import net.openan.a2at.sdk.core.model.InputLimitConfig; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.metadata.ServerPromptMetadataExtractor; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import net.openan.a2at.sdk.server.model.PromptComplianceFailure; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import net.openan.a2at.sdk.server.validation.ServerPromptSemanticValidator; -import org.jspecify.annotations.Nullable; - -/** - * Minimal runnable server-side prompt compliance orchestrator. - * - * @since 2026-06 - */ -public final class DefaultServerPromptComplianceOrchestrator implements ServerPromptComplianceOrchestrator { - - /** Compliance stage reported when the input gate rejects an oversized prompt before any LLM call. */ - private static final String INPUT_GATE_STAGE = "input_gate"; - - private final ServerPromptMetadataExtractor metadataExtractor; - - private final ServerPromptSemanticValidator semanticValidator; - - private final int maxTextChars; - - private final String language; - - /** - * Creates a compliance orchestrator. - * - * @param metadataExtractor prompt metadata extractor - * @param semanticValidator semantic validator - */ - public DefaultServerPromptComplianceOrchestrator( - ServerPromptMetadataExtractor metadataExtractor, ServerPromptSemanticValidator semanticValidator) { - this(metadataExtractor, semanticValidator, InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); - } - - /** - * Creates a compliance orchestrator with an explicit free-text input limit. - * - * @param metadataExtractor prompt metadata extractor - * @param semanticValidator semantic validator - * @param maxTextChars maximum length in characters accepted for the processed prompt text - */ - public DefaultServerPromptComplianceOrchestrator( - ServerPromptMetadataExtractor metadataExtractor, - ServerPromptSemanticValidator semanticValidator, - int maxTextChars) { - this(metadataExtractor, semanticValidator, maxTextChars, null); - } - - /** - * Creates a compliance orchestrator with an explicit free-text input limit and message language. - * - * @param metadataExtractor prompt metadata extractor - * @param semanticValidator semantic validator - * @param maxTextChars maximum length in characters accepted for the processed prompt text - * @param language language used to render failure messages, for example {@code zh-CN}; null falls back to - * {@code en-US} - */ - public DefaultServerPromptComplianceOrchestrator( - ServerPromptMetadataExtractor metadataExtractor, - ServerPromptSemanticValidator semanticValidator, - int maxTextChars, - @Nullable String language) { - this.metadataExtractor = metadataExtractor; - this.semanticValidator = semanticValidator; - this.maxTextChars = maxTextChars; - this.language = language; - } - - @Override - public PromptComplianceResult checkTaskPrompt(String processedPromptText) { - if (InputLimitConfig.isTooLong(processedPromptText, maxTextChars)) { - Map facts = Map.of( - "actual_length", String.valueOf(processedPromptText.length()), - "max_chars", String.valueOf(maxTextChars)); - return new PromptComplianceResult( - false, - new PromptComplianceFailure( - ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), - ErrorMessages.render(ErrorCatalog.INPUT_TEXT_TOO_LONG, language, facts), - INPUT_GATE_STAGE)); - } - try { - ProcessedPromptMetadata metadata = metadataExtractor.extract(processedPromptText); - semanticValidator.validate(processedPromptText, metadata); - return new PromptComplianceResult(true, null); - } catch (PromptComplianceCheckException error) { - return new PromptComplianceResult( - false, new PromptComplianceFailure(error.getCode(), error.getMessage(), error.getStage())); - } - } -} +package net.openan.a2at.sdk.server.compliance; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; +import net.openan.a2at.sdk.server.metadata.ServerPromptMetadataExtractor; +import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; +import net.openan.a2at.sdk.server.model.PromptComplianceFailure; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import net.openan.a2at.sdk.server.validation.ServerPromptSemanticValidator; +import org.jspecify.annotations.Nullable; + +/** + * Minimal runnable server-side prompt compliance orchestrator. + * + * @since 2026-06 + */ +public final class DefaultServerPromptComplianceOrchestrator implements ServerPromptComplianceOrchestrator { + + /** Compliance stage reported when the input gate rejects an oversized prompt before any LLM call. */ + private static final String INPUT_GATE_STAGE = "input_gate"; + + private final ServerPromptMetadataExtractor metadataExtractor; + + private final ServerPromptSemanticValidator semanticValidator; + + private final int maxTextChars; + + private final String language; + + /** + * Creates a compliance orchestrator. + * + * @param metadataExtractor prompt metadata extractor + * @param semanticValidator semantic validator + */ + public DefaultServerPromptComplianceOrchestrator( + ServerPromptMetadataExtractor metadataExtractor, ServerPromptSemanticValidator semanticValidator) { + this(metadataExtractor, semanticValidator, InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); + } + + /** + * Creates a compliance orchestrator with an explicit free-text input limit. + * + * @param metadataExtractor prompt metadata extractor + * @param semanticValidator semantic validator + * @param maxTextChars maximum length in characters accepted for the processed prompt text + */ + public DefaultServerPromptComplianceOrchestrator( + ServerPromptMetadataExtractor metadataExtractor, + ServerPromptSemanticValidator semanticValidator, + int maxTextChars) { + this(metadataExtractor, semanticValidator, maxTextChars, null); + } + + /** + * Creates a compliance orchestrator with an explicit free-text input limit and message language. + * + * @param metadataExtractor prompt metadata extractor + * @param semanticValidator semantic validator + * @param maxTextChars maximum length in characters accepted for the processed prompt text + * @param language language used to render failure messages, for example {@code zh-CN}; null falls back to + * {@code en-US} + */ + public DefaultServerPromptComplianceOrchestrator( + ServerPromptMetadataExtractor metadataExtractor, + ServerPromptSemanticValidator semanticValidator, + int maxTextChars, + @Nullable String language) { + this.metadataExtractor = metadataExtractor; + this.semanticValidator = semanticValidator; + this.maxTextChars = maxTextChars; + this.language = language; + } + + @Override + public PromptComplianceResult checkTaskPrompt(String processedPromptText) { + if (InputLimitConfig.isTooLong(processedPromptText, maxTextChars)) { + Map facts = Map.of( + "actual_length", String.valueOf(processedPromptText.length()), + "max_chars", String.valueOf(maxTextChars)); + return new PromptComplianceResult( + false, + new PromptComplianceFailure( + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), + ErrorMessages.render(ErrorCatalog.INPUT_TEXT_TOO_LONG, language, facts), + INPUT_GATE_STAGE)); + } + try { + ProcessedPromptMetadata metadata = metadataExtractor.extract(processedPromptText); + semanticValidator.validate(processedPromptText, metadata); + return new PromptComplianceResult(true, null); + } catch (PromptComplianceCheckException error) { + return new PromptComplianceResult( + false, new PromptComplianceFailure(error.getCode(), error.getMessage(), error.getStage())); + } + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java index e74d81b2..c281ad26 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java @@ -1,57 +1,57 @@ -package net.openan.a2at.sdk.server.exception; - -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATBusinessException; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import org.jspecify.annotations.Nullable; - -/** - * Compliance-check failure carrying a stable error code and the compliance stage where the failure occurred. - * - *

            Part of the {@link A2ATBusinessException} tree: callers can catch the {@link A2ATError} root to handle any A2A-T - * processing failure and dispatch on its machine-readable error code. Messages are rendered from the code's message - * template via {@link ErrorMessages}; the fact values that produced the message travel in {@link #getFacts()}. - * - * @since 2026-06 - */ -public final class PromptComplianceCheckException extends A2ATBusinessException { - - private final String stage; - - /** - * Creates a standardized compliance-check exception. - * - * @param code stable error code - * @param message human-readable failure message - * @param stage compliance stage where the failure occurred - */ - public PromptComplianceCheckException(String code, String message, String stage) { - super(code, message); - this.stage = stage; - } - - /** - * Creates a standardized compliance-check exception whose message is rendered from the code's template. - * - * @param entry catalog entry identifying the failure - * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} - * @param facts fact values keyed by fact parameter name, may be null - * @param stage compliance stage where the failure occurred - */ - public PromptComplianceCheckException( - ErrorCatalog entry, @Nullable String language, @Nullable Map facts, String stage) { - super(entry, ErrorMessages.render(entry, language, facts), facts); - this.stage = stage; - } - - /** - * Returns the compliance stage where the failure occurred. - * - * @return failure stage - */ - public String getStage() { - return stage; - } -} +package net.openan.a2at.sdk.server.exception; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import org.jspecify.annotations.Nullable; + +/** + * Compliance-check failure carrying a stable error code and the compliance stage where the failure occurred. + * + *

            Part of the {@link A2ATBusinessException} tree: callers can catch the {@link A2ATError} root to handle any A2A-T + * processing failure and dispatch on its machine-readable error code. Messages are rendered from the code's message + * template via {@link ErrorMessages}; the fact values that produced the message travel in {@link #getFacts()}. + * + * @since 2026-06 + */ +public final class PromptComplianceCheckException extends A2ATBusinessException { + + private final String stage; + + /** + * Creates a standardized compliance-check exception. + * + * @param code stable error code + * @param message human-readable failure message + * @param stage compliance stage where the failure occurred + */ + public PromptComplianceCheckException(String code, String message, String stage) { + super(code, message); + this.stage = stage; + } + + /** + * Creates a standardized compliance-check exception whose message is rendered from the code's template. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param stage compliance stage where the failure occurred + */ + public PromptComplianceCheckException( + ErrorCatalog entry, @Nullable String language, @Nullable Map facts, String stage) { + super(entry, ErrorMessages.render(entry, language, facts), facts); + this.stage = stage; + } + + /** + * Returns the compliance stage where the failure occurred. + * + * @return failure stage + */ + public String getStage() { + return stage; + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java index 7acd72e7..19c185b2 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java @@ -1,209 +1,209 @@ -package net.openan.a2at.sdk.server.metadata; - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.prompt.analysis.exception.ScenarioRecognitionException; -import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; -import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Server-side metadata extractor that mirrors the Python flow by resolving scenario and slots from the processed prompt - * text with LLM-backed analysis steps. - * - * @since 2026-06 - */ -public final class LlmBackedPromptMetadataExtractor implements ServerPromptMetadataExtractor { - - private static final Logger LOGGER = LoggerFactory.getLogger(LlmBackedPromptMetadataExtractor.class); - - /** Default reason rendered into {@code scenario.not_matched} when the recognizer reports none. */ - private static final String DEFAULT_SCENARIO_REASON = "scenario recognition failed"; - - private final ScenarioRecognizer scenarioRecognizer; - - private final List scenarios; - - private final String language; - - private final String systemPrompt; - - private final String userPrompt; - - private final PromptTemplateTextLoader templateLoader; - - private final PromptSlotSchemaLoader slotSchemaLoader; - - private final PromptSlotValueExtractor slotValueExtractor; - - public LlmBackedPromptMetadataExtractor( - ScenarioRecognizer scenarioRecognizer, - List scenarios, - String language, - String systemPrompt, - String userPrompt, - PromptTemplateTextLoader templateLoader, - PromptSlotSchemaLoader slotSchemaLoader, - PromptSlotValueExtractor slotValueExtractor) { - this.scenarioRecognizer = scenarioRecognizer; - this.scenarios = List.copyOf(scenarios); - this.language = language; - this.systemPrompt = systemPrompt; - this.userPrompt = userPrompt; - this.templateLoader = templateLoader; - this.slotSchemaLoader = slotSchemaLoader; - this.slotValueExtractor = slotValueExtractor; - } - - @Override - public ProcessedPromptMetadata extract(String processedPromptText) { - ScenarioRecognitionResult recognitionResult = resolveScenario(processedPromptText); - String scenarioCode = recognitionResult.scenarioCode(); - String templateText = loadTemplate(scenarioCode); - PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(scenarioCode, language); - StructuredSlotExtractionResult extractionResult = - slotValueExtractor.extractSlots(processedPromptText, scenarioCode, language); - validateExtractionResult(extractionResult, slotSchema); - return new ProcessedPromptMetadata(scenarioCode, language, templateText, Map.copyOf(extractionResult.slots())); - } - - private ScenarioRecognitionResult resolveScenario(String processedPromptText) { - try { - ScenarioRecognitionResult result = - scenarioRecognizer.recognize(processedPromptText, scenarios, systemPrompt, userPrompt); - if (!result.matched() - || result.scenarioCode() == null - || result.scenarioCode().isBlank()) { - throw scenarioNotMatched(result.errorMessage()); - } - return result; - } catch (ScenarioRecognitionException error) { - throw scenarioNotMatched(error.getMessage()); - } - } - - private PromptComplianceCheckException scenarioNotMatched(String reason) { - String effectiveReason = reason == null || reason.isBlank() ? DEFAULT_SCENARIO_REASON : reason; - return new PromptComplianceCheckException( - ErrorCatalog.SCENARIO_NOT_MATCHED, language, Map.of("reason", effectiveReason), "prompt_parse"); - } - - private String loadTemplate(String scenarioCode) { - try { - return templateLoader.loadTemplate(scenarioCode, language); - } catch (ResourceNotFoundException error) { - throw new PromptComplianceCheckException( - ErrorCatalog.TEMPLATE_NOT_FOUND, - language, - Map.of("template_uri", scenarioCode, "language", language), - "generation"); - } catch (A2ATError error) { - LOGGER.warn( - "Template load failed for scenario '{}' and language '{}': {}", - scenarioCode, - language, - error.getMessage()); - throw new PromptComplianceCheckException( - ErrorCatalog.INFRA_RESOURCE_READ_FAILED, - language, - Map.of("resource_path", scenarioCode), - "generation"); - } - } - - private void validateExtractionResult( - StructuredSlotExtractionResult extractionResult, PromptSlotSchema slotSchema) { - if (!extractionResult.slotErrors().isEmpty()) { - throw slotFailure(extractionResult.slotErrors().get(0)); - } - - for (PromptSlotDefinition definition : slotSchema.slotDefinitions()) { - String value = extractionResult.slots().get(definition.name()); - if (definition.required() && (value == null || value.isBlank())) { - throw new PromptComplianceCheckException( - ErrorCatalog.SLOT_NOT_PROVIDED, - language, - Map.of("slot_label", definition.name()), - "slot_validation"); - } - if (value == null || value.isBlank()) { - continue; - } - String trimmedValue = value.trim(); - if (definition.allowedValues() != null - && !definition.allowedValues().isEmpty() - && !definition.allowedValues().contains(trimmedValue)) { - throw constraintViolation(definition, trimmedValue); - } - if (definition.pattern() != null - && !definition.pattern().isBlank() - && !trimmedValue.matches(definition.pattern())) { - throw constraintViolation(definition, trimmedValue); - } - validateNumericConstraint(definition, trimmedValue); - } - } - - private PromptComplianceCheckException slotFailure(StructuredSlotValidationError error) { - String slotName = error.slotName() == null ? "" : error.slotName(); - return new PromptComplianceCheckException( - resolveSlotErrorCode(error.code(), slotName), - language, - Map.of("slot_label", slotName), - "slot_validation"); - } - - private static ErrorCatalog resolveSlotErrorCode(String code, String slotName) { - Optional entry = ErrorCatalog.byCode(code); - if (entry.isPresent()) { - return entry.get(); - } - LOGGER.warn( - "Unknown slot validation error code '{}' for slot '{}'; falling back to '{}'.", - code, - slotName, - ErrorCatalog.SLOT_RULE_VIOLATION.getCode()); - return ErrorCatalog.SLOT_RULE_VIOLATION; - } - - private PromptComplianceCheckException constraintViolation(PromptSlotDefinition definition, String actual) { - return new PromptComplianceCheckException( - ErrorCatalog.SLOT_CONSTRAINT_VIOLATED, - language, - Map.of("slot_label", definition.name(), "actual", actual), - "slot_validation"); - } - - private void validateNumericConstraint(PromptSlotDefinition definition, String value) { - if (!"integer".equalsIgnoreCase(definition.jsonType()) && !"number".equalsIgnoreCase(definition.jsonType())) { - return; - } - double numericValue; - try { - numericValue = Double.parseDouble(value); - } catch (NumberFormatException error) { - throw constraintViolation(definition, value); - } - if (definition.minimum() != null && numericValue < definition.minimum()) { - throw constraintViolation(definition, value); - } - if (definition.maximum() != null && numericValue > definition.maximum()) { - throw constraintViolation(definition, value); - } - } -} +package net.openan.a2at.sdk.server.metadata; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.prompt.analysis.exception.ScenarioRecognitionException; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; +import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Server-side metadata extractor that mirrors the Python flow by resolving scenario and slots from the processed prompt + * text with LLM-backed analysis steps. + * + * @since 2026-06 + */ +public final class LlmBackedPromptMetadataExtractor implements ServerPromptMetadataExtractor { + + private static final Logger LOGGER = LoggerFactory.getLogger(LlmBackedPromptMetadataExtractor.class); + + /** Default reason rendered into {@code scenario.not_matched} when the recognizer reports none. */ + private static final String DEFAULT_SCENARIO_REASON = "scenario recognition failed"; + + private final ScenarioRecognizer scenarioRecognizer; + + private final List scenarios; + + private final String language; + + private final String systemPrompt; + + private final String userPrompt; + + private final PromptTemplateTextLoader templateLoader; + + private final PromptSlotSchemaLoader slotSchemaLoader; + + private final PromptSlotValueExtractor slotValueExtractor; + + public LlmBackedPromptMetadataExtractor( + ScenarioRecognizer scenarioRecognizer, + List scenarios, + String language, + String systemPrompt, + String userPrompt, + PromptTemplateTextLoader templateLoader, + PromptSlotSchemaLoader slotSchemaLoader, + PromptSlotValueExtractor slotValueExtractor) { + this.scenarioRecognizer = scenarioRecognizer; + this.scenarios = List.copyOf(scenarios); + this.language = language; + this.systemPrompt = systemPrompt; + this.userPrompt = userPrompt; + this.templateLoader = templateLoader; + this.slotSchemaLoader = slotSchemaLoader; + this.slotValueExtractor = slotValueExtractor; + } + + @Override + public ProcessedPromptMetadata extract(String processedPromptText) { + ScenarioRecognitionResult recognitionResult = resolveScenario(processedPromptText); + String scenarioCode = recognitionResult.scenarioCode(); + String templateText = loadTemplate(scenarioCode); + PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(scenarioCode, language); + StructuredSlotExtractionResult extractionResult = + slotValueExtractor.extractSlots(processedPromptText, scenarioCode, language); + validateExtractionResult(extractionResult, slotSchema); + return new ProcessedPromptMetadata(scenarioCode, language, templateText, Map.copyOf(extractionResult.slots())); + } + + private ScenarioRecognitionResult resolveScenario(String processedPromptText) { + try { + ScenarioRecognitionResult result = + scenarioRecognizer.recognize(processedPromptText, scenarios, systemPrompt, userPrompt); + if (!result.matched() + || result.scenarioCode() == null + || result.scenarioCode().isBlank()) { + throw scenarioNotMatched(result.errorMessage()); + } + return result; + } catch (ScenarioRecognitionException error) { + throw scenarioNotMatched(error.getMessage()); + } + } + + private PromptComplianceCheckException scenarioNotMatched(String reason) { + String effectiveReason = reason == null || reason.isBlank() ? DEFAULT_SCENARIO_REASON : reason; + return new PromptComplianceCheckException( + ErrorCatalog.SCENARIO_NOT_MATCHED, language, Map.of("reason", effectiveReason), "prompt_parse"); + } + + private String loadTemplate(String scenarioCode) { + try { + return templateLoader.loadTemplate(scenarioCode, language); + } catch (ResourceNotFoundException error) { + throw new PromptComplianceCheckException( + ErrorCatalog.TEMPLATE_NOT_FOUND, + language, + Map.of("template_uri", scenarioCode, "language", language), + "generation"); + } catch (A2ATError error) { + LOGGER.warn( + "Template load failed for scenario '{}' and language '{}': {}", + scenarioCode, + language, + error.getMessage()); + throw new PromptComplianceCheckException( + ErrorCatalog.INFRA_RESOURCE_READ_FAILED, + language, + Map.of("resource_path", scenarioCode), + "generation"); + } + } + + private void validateExtractionResult( + StructuredSlotExtractionResult extractionResult, PromptSlotSchema slotSchema) { + if (!extractionResult.slotErrors().isEmpty()) { + throw slotFailure(extractionResult.slotErrors().get(0)); + } + + for (PromptSlotDefinition definition : slotSchema.slotDefinitions()) { + String value = extractionResult.slots().get(definition.name()); + if (definition.required() && (value == null || value.isBlank())) { + throw new PromptComplianceCheckException( + ErrorCatalog.SLOT_NOT_PROVIDED, + language, + Map.of("slot_label", definition.name()), + "slot_validation"); + } + if (value == null || value.isBlank()) { + continue; + } + String trimmedValue = value.trim(); + if (definition.allowedValues() != null + && !definition.allowedValues().isEmpty() + && !definition.allowedValues().contains(trimmedValue)) { + throw constraintViolation(definition, trimmedValue); + } + if (definition.pattern() != null + && !definition.pattern().isBlank() + && !trimmedValue.matches(definition.pattern())) { + throw constraintViolation(definition, trimmedValue); + } + validateNumericConstraint(definition, trimmedValue); + } + } + + private PromptComplianceCheckException slotFailure(StructuredSlotValidationError error) { + String slotName = error.slotName() == null ? "" : error.slotName(); + return new PromptComplianceCheckException( + resolveSlotErrorCode(error.code(), slotName), + language, + Map.of("slot_label", slotName), + "slot_validation"); + } + + private static ErrorCatalog resolveSlotErrorCode(String code, String slotName) { + Optional entry = ErrorCatalog.byCode(code); + if (entry.isPresent()) { + return entry.get(); + } + LOGGER.warn( + "Unknown slot validation error code '{}' for slot '{}'; falling back to '{}'.", + code, + slotName, + ErrorCatalog.SLOT_RULE_VIOLATION.getCode()); + return ErrorCatalog.SLOT_RULE_VIOLATION; + } + + private PromptComplianceCheckException constraintViolation(PromptSlotDefinition definition, String actual) { + return new PromptComplianceCheckException( + ErrorCatalog.SLOT_CONSTRAINT_VIOLATED, + language, + Map.of("slot_label", definition.name(), "actual", actual), + "slot_validation"); + } + + private void validateNumericConstraint(PromptSlotDefinition definition, String value) { + if (!"integer".equalsIgnoreCase(definition.jsonType()) && !"number".equalsIgnoreCase(definition.jsonType())) { + return; + } + double numericValue; + try { + numericValue = Double.parseDouble(value); + } catch (NumberFormatException error) { + throw constraintViolation(definition, value); + } + if (definition.minimum() != null && numericValue < definition.minimum()) { + throw constraintViolation(definition, value); + } + if (definition.maximum() != null && numericValue > definition.maximum()) { + throw constraintViolation(definition, value); + } + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidator.java index 6710f096..f9297c16 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidator.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidator.java @@ -1,66 +1,66 @@ -package net.openan.a2at.sdk.server.validation; - -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.exception.ErrorMessages; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.InputLimitConfig; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import org.jspecify.annotations.Nullable; - -/** - * Free-text input gate wrapped around one delegated content validator. - * - *

            The gate rejects an oversized prompt with the code {@code input.text_too_long} before the delegated pipeline - * starts, so no LLM call is made for an input that could not fit the LLM context anyway. The limit is configured - * through {@code A2AT_INPUT_TEXT_MAX_CHARS} and defaults to {@link InputLimitConfig#DEFAULT_MAX_TEXT_CHARS} characters. - * - * @since 2026-08 - */ -public final class InputLimitedContentValidator implements ContentValidator { - - private final ContentValidator delegate; - - private final int maxTextChars; - - private final String language; - - /** - * Creates one gating validator around the given delegate. - * - * @param delegate content validator carrying the actual validation pipeline - * @param maxTextChars maximum length in characters accepted for the prompt text - */ - public InputLimitedContentValidator(ContentValidator delegate, int maxTextChars) { - this(delegate, maxTextChars, null); - } - - /** - * Creates one gating validator around the given delegate with an explicit message language. - * - * @param delegate content validator carrying the actual validation pipeline - * @param maxTextChars maximum length in characters accepted for the prompt text - * @param language language used to render the rejection message, for example {@code zh-CN}; null falls back to - * {@code en-US} - */ - public InputLimitedContentValidator(ContentValidator delegate, int maxTextChars, @Nullable String language) { - this.delegate = delegate; - this.maxTextChars = maxTextChars; - this.language = language; - } - - @Override - public FilledParamData validate(String prompt, Map schema, TemplateUri templateUri) { - if (InputLimitConfig.isTooLong(prompt, maxTextChars)) { - Map facts = Map.of( - "actual_length", String.valueOf(prompt.length()), - "max_chars", String.valueOf(maxTextChars)); - throw new ContentValidationException( - ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), - ErrorMessages.render(ErrorCatalog.INPUT_TEXT_TOO_LONG, language, facts)); - } - return delegate.validate(prompt, schema, templateUri); - } -} +package net.openan.a2at.sdk.server.validation; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import org.jspecify.annotations.Nullable; + +/** + * Free-text input gate wrapped around one delegated content validator. + * + *

            The gate rejects an oversized prompt with the code {@code input.text_too_long} before the delegated pipeline + * starts, so no LLM call is made for an input that could not fit the LLM context anyway. The limit is configured + * through {@code A2AT_INPUT_TEXT_MAX_CHARS} and defaults to {@link InputLimitConfig#DEFAULT_MAX_TEXT_CHARS} characters. + * + * @since 2026-08 + */ +public final class InputLimitedContentValidator implements ContentValidator { + + private final ContentValidator delegate; + + private final int maxTextChars; + + private final String language; + + /** + * Creates one gating validator around the given delegate. + * + * @param delegate content validator carrying the actual validation pipeline + * @param maxTextChars maximum length in characters accepted for the prompt text + */ + public InputLimitedContentValidator(ContentValidator delegate, int maxTextChars) { + this(delegate, maxTextChars, null); + } + + /** + * Creates one gating validator around the given delegate with an explicit message language. + * + * @param delegate content validator carrying the actual validation pipeline + * @param maxTextChars maximum length in characters accepted for the prompt text + * @param language language used to render the rejection message, for example {@code zh-CN}; null falls back to + * {@code en-US} + */ + public InputLimitedContentValidator(ContentValidator delegate, int maxTextChars, @Nullable String language) { + this.delegate = delegate; + this.maxTextChars = maxTextChars; + this.language = language; + } + + @Override + public FilledParamData validate(String prompt, Map schema, TemplateUri templateUri) { + if (InputLimitConfig.isTooLong(prompt, maxTextChars)) { + Map facts = Map.of( + "actual_length", String.valueOf(prompt.length()), + "max_chars", String.valueOf(maxTextChars)); + throw new ContentValidationException( + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), + ErrorMessages.render(ErrorCatalog.INPUT_TEXT_TOO_LONG, language, facts)); + } + return delegate.validate(prompt, schema, templateUri); + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java index 59529d64..d60c00bc 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java @@ -1,210 +1,210 @@ -package net.openan.a2at.sdk.server.validation; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.PromptMessage; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * LLM-backed semantic validator aligned with the Python server-side compliance flow. - * - *

            The LLM reports semantic failures as {@code {slot_name, code, facts}} entries; the human-readable message is - * rendered from the code's message template, never taken from the LLM response. An error code outside the catalog is - * mapped to the slot-domain fallback {@code slot.rule_violation} and logged as a warning. - * - * @since 2026-06 - */ -public final class LlmBackedPromptSemanticValidator implements ServerPromptSemanticValidator { - - private static final Logger LOGGER = LoggerFactory.getLogger(LlmBackedPromptSemanticValidator.class); - - /** Step name reported in {@code llm.response_invalid} when the LLM response violates the response contract. */ - private static final String SEMANTIC_VALIDATION_STEP = "semantic_validation"; - - private static final String SLOT_VALIDATION_STAGE = "slot_validation"; - - private static final String SLOT_CODE_DOMAIN = "slot."; - - private static final ObjectMapper OBJECT_MAPPER = - new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); - - private final LLMClient llmClient; - - private final PromptSlotSchemaLoader slotSchemaLoader; - - private final String systemPrompt; - - private final String userPrompt; - - /** - * Creates an LLM-backed semantic validator. - * - * @param llmClient LLM client for structured generation - * @param slotSchemaLoader loader for slot schemas - * @param systemPrompt system prompt for the LLM - * @param userPrompt user prompt template for the LLM - */ - public LlmBackedPromptSemanticValidator( - LLMClient llmClient, PromptSlotSchemaLoader slotSchemaLoader, String systemPrompt, String userPrompt) { - this.llmClient = llmClient; - this.slotSchemaLoader = slotSchemaLoader; - this.systemPrompt = systemPrompt; - this.userPrompt = userPrompt; - } - - @Override - public void validate(String processedPromptText, ProcessedPromptMetadata metadata) { - PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(metadata.scenarioCode(), metadata.language()); - String payload = llmClient - .structured( - toStructuredMessages(List.of( - new PromptMessage("system", systemPrompt), - new PromptMessage("user", buildUserPrompt(slotSchema, metadata.slots())))), - schema(), - null, - null) - .content(); - validateResponse(payload, metadata.language()); - } - - private String buildUserPrompt(PromptSlotSchema slotSchema, Map extractedSlots) { - try { - return userPrompt - + "\n\n{\n" - + " \"slot_json_schema\": " - + OBJECT_MAPPER.writeValueAsString(slotSchema) - + ",\n" - + " \"extracted_slots\": " - + OBJECT_MAPPER.writeValueAsString(extractedSlots) - + "\n}"; - } catch (JsonProcessingException error) { - throw internalError(error); - } - } - - private static Map schema() { - Map factsSchema = new LinkedHashMap<>(); - factsSchema.put("type", "object"); - factsSchema.put("additionalProperties", Map.of("type", "string")); - - Map itemSchema = new LinkedHashMap<>(); - itemSchema.put("type", "object"); - itemSchema.put("additionalProperties", false); - itemSchema.put("required", List.of("slot_name", "code", "facts")); - itemSchema.put( - "properties", - Map.of( - "slot_name", Map.of("type", "string"), - "code", Map.of("type", "string"), - "facts", factsSchema)); - - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("additionalProperties", false); - schema.put("required", List.of("passed", "errors")); - schema.put( - "properties", - Map.of( - "passed", Map.of("type", "boolean"), - "errors", Map.of("type", "array", "items", itemSchema))); - return schema; - } - - private static List> toStructuredMessages(List messages) { - return messages.stream() - .map(message -> Map.of("role", message.role(), "content", message.content())) - .toList(); - } - - private void validateResponse(String payload, String language) { - Map response = parseResponse(payload, language); - boolean passed = Boolean.TRUE.equals(response.get("passed")); - List errors = response.get("errors") instanceof List list ? list : List.of(); - if (passed && errors.isEmpty()) { - return; - } - - SemanticError firstError = firstError(errors); - if (firstError == null) { - throw responseInvalid(language); - } - throw new PromptComplianceCheckException( - resolveErrorCode(firstError.code()), language, firstError.facts(), SLOT_VALIDATION_STAGE); - } - - private static Map parseResponse(String payload, String language) { - try { - Map response = - OBJECT_MAPPER.readValue(payload, new TypeReference>() {}); - return Optional.ofNullable(response).orElseGet(Map::of); - } catch (JsonProcessingException error) { - throw responseInvalid(language); - } - } - - private static SemanticError firstError(List errors) { - for (Object item : errors) { - if (!(item instanceof Map errorMap) - || !(errorMap.get("slot_name") instanceof String slotName) - || !(errorMap.get("code") instanceof String code)) { - continue; - } - return new SemanticError(slotName, code, stringFacts(errorMap.get("facts"))); - } - return null; - } - - private static Map stringFacts(Object factsValue) { - if (!(factsValue instanceof Map facts)) { - return Map.of(); - } - Map rendered = new LinkedHashMap<>(); - for (Map.Entry entry : facts.entrySet()) { - if (entry.getKey() instanceof String key && entry.getValue() instanceof String value) { - rendered.put(key, value); - } - } - return rendered; - } - - private static ErrorCatalog resolveErrorCode(String code) { - Optional entry = ErrorCatalog.byCode(code); - if (entry.isPresent() && entry.get().getCode().startsWith(SLOT_CODE_DOMAIN)) { - return entry.get(); - } - LOGGER.warn( - "Unknown semantic validation error code '{}'; falling back to '{}'.", - code, - ErrorCatalog.SLOT_RULE_VIOLATION.getCode()); - return ErrorCatalog.SLOT_RULE_VIOLATION; - } - - private static PromptComplianceCheckException responseInvalid(String language) { - return new PromptComplianceCheckException( - ErrorCatalog.LLM_RESPONSE_INVALID, - language, - Map.of("step", SEMANTIC_VALIDATION_STEP), - SLOT_VALIDATION_STAGE); - } - - private static PromptComplianceCheckException internalError(JsonProcessingException cause) { - LOGGER.warn("Failed to serialize slot schema for the semantic validation prompt.", cause); - return new PromptComplianceCheckException(ErrorCatalog.INFRA_INTERNAL_ERROR, null, null, SLOT_VALIDATION_STAGE); - } - - /** One error entry reported by the semantic validation LLM step. */ - private record SemanticError(String slotName, String code, Map facts) {} -} +package net.openan.a2at.sdk.server.validation; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.PromptMessage; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; +import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * LLM-backed semantic validator aligned with the Python server-side compliance flow. + * + *

            The LLM reports semantic failures as {@code {slot_name, code, facts}} entries; the human-readable message is + * rendered from the code's message template, never taken from the LLM response. An error code outside the catalog is + * mapped to the slot-domain fallback {@code slot.rule_violation} and logged as a warning. + * + * @since 2026-06 + */ +public final class LlmBackedPromptSemanticValidator implements ServerPromptSemanticValidator { + + private static final Logger LOGGER = LoggerFactory.getLogger(LlmBackedPromptSemanticValidator.class); + + /** Step name reported in {@code llm.response_invalid} when the LLM response violates the response contract. */ + private static final String SEMANTIC_VALIDATION_STEP = "semantic_validation"; + + private static final String SLOT_VALIDATION_STAGE = "slot_validation"; + + private static final String SLOT_CODE_DOMAIN = "slot."; + + private static final ObjectMapper OBJECT_MAPPER = + new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); + + private final LLMClient llmClient; + + private final PromptSlotSchemaLoader slotSchemaLoader; + + private final String systemPrompt; + + private final String userPrompt; + + /** + * Creates an LLM-backed semantic validator. + * + * @param llmClient LLM client for structured generation + * @param slotSchemaLoader loader for slot schemas + * @param systemPrompt system prompt for the LLM + * @param userPrompt user prompt template for the LLM + */ + public LlmBackedPromptSemanticValidator( + LLMClient llmClient, PromptSlotSchemaLoader slotSchemaLoader, String systemPrompt, String userPrompt) { + this.llmClient = llmClient; + this.slotSchemaLoader = slotSchemaLoader; + this.systemPrompt = systemPrompt; + this.userPrompt = userPrompt; + } + + @Override + public void validate(String processedPromptText, ProcessedPromptMetadata metadata) { + PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(metadata.scenarioCode(), metadata.language()); + String payload = llmClient + .structured( + toStructuredMessages(List.of( + new PromptMessage("system", systemPrompt), + new PromptMessage("user", buildUserPrompt(slotSchema, metadata.slots())))), + schema(), + null, + null) + .content(); + validateResponse(payload, metadata.language()); + } + + private String buildUserPrompt(PromptSlotSchema slotSchema, Map extractedSlots) { + try { + return userPrompt + + "\n\n{\n" + + " \"slot_json_schema\": " + + OBJECT_MAPPER.writeValueAsString(slotSchema) + + ",\n" + + " \"extracted_slots\": " + + OBJECT_MAPPER.writeValueAsString(extractedSlots) + + "\n}"; + } catch (JsonProcessingException error) { + throw internalError(error); + } + } + + private static Map schema() { + Map factsSchema = new LinkedHashMap<>(); + factsSchema.put("type", "object"); + factsSchema.put("additionalProperties", Map.of("type", "string")); + + Map itemSchema = new LinkedHashMap<>(); + itemSchema.put("type", "object"); + itemSchema.put("additionalProperties", false); + itemSchema.put("required", List.of("slot_name", "code", "facts")); + itemSchema.put( + "properties", + Map.of( + "slot_name", Map.of("type", "string"), + "code", Map.of("type", "string"), + "facts", factsSchema)); + + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("additionalProperties", false); + schema.put("required", List.of("passed", "errors")); + schema.put( + "properties", + Map.of( + "passed", Map.of("type", "boolean"), + "errors", Map.of("type", "array", "items", itemSchema))); + return schema; + } + + private static List> toStructuredMessages(List messages) { + return messages.stream() + .map(message -> Map.of("role", message.role(), "content", message.content())) + .toList(); + } + + private void validateResponse(String payload, String language) { + Map response = parseResponse(payload, language); + boolean passed = Boolean.TRUE.equals(response.get("passed")); + List errors = response.get("errors") instanceof List list ? list : List.of(); + if (passed && errors.isEmpty()) { + return; + } + + SemanticError firstError = firstError(errors); + if (firstError == null) { + throw responseInvalid(language); + } + throw new PromptComplianceCheckException( + resolveErrorCode(firstError.code()), language, firstError.facts(), SLOT_VALIDATION_STAGE); + } + + private static Map parseResponse(String payload, String language) { + try { + Map response = + OBJECT_MAPPER.readValue(payload, new TypeReference>() {}); + return Optional.ofNullable(response).orElseGet(Map::of); + } catch (JsonProcessingException error) { + throw responseInvalid(language); + } + } + + private static SemanticError firstError(List errors) { + for (Object item : errors) { + if (!(item instanceof Map errorMap) + || !(errorMap.get("slot_name") instanceof String slotName) + || !(errorMap.get("code") instanceof String code)) { + continue; + } + return new SemanticError(slotName, code, stringFacts(errorMap.get("facts"))); + } + return null; + } + + private static Map stringFacts(Object factsValue) { + if (!(factsValue instanceof Map facts)) { + return Map.of(); + } + Map rendered = new LinkedHashMap<>(); + for (Map.Entry entry : facts.entrySet()) { + if (entry.getKey() instanceof String key && entry.getValue() instanceof String value) { + rendered.put(key, value); + } + } + return rendered; + } + + private static ErrorCatalog resolveErrorCode(String code) { + Optional entry = ErrorCatalog.byCode(code); + if (entry.isPresent() && entry.get().getCode().startsWith(SLOT_CODE_DOMAIN)) { + return entry.get(); + } + LOGGER.warn( + "Unknown semantic validation error code '{}'; falling back to '{}'.", + code, + ErrorCatalog.SLOT_RULE_VIOLATION.getCode()); + return ErrorCatalog.SLOT_RULE_VIOLATION; + } + + private static PromptComplianceCheckException responseInvalid(String language) { + return new PromptComplianceCheckException( + ErrorCatalog.LLM_RESPONSE_INVALID, + language, + Map.of("step", SEMANTIC_VALIDATION_STEP), + SLOT_VALIDATION_STAGE); + } + + private static PromptComplianceCheckException internalError(JsonProcessingException cause) { + LOGGER.warn("Failed to serialize slot schema for the semantic validation prompt.", cause); + return new PromptComplianceCheckException(ErrorCatalog.INFRA_INTERNAL_ERROR, null, null, SLOT_VALIDATION_STAGE); + } + + /** One error entry reported by the semantic validation LLM step. */ + private record SemanticError(String slotName, String code, Map facts) {} +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java index c93b522d..b75212c7 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -1,143 +1,143 @@ -package net.openan.a2at.sdk.server.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.server.A2ATServer; -import org.junit.jupiter.api.Test; - -class A2ATServerNegotiationApiTest { - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - - private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); - - @Test - void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - - MetadataContent result = server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); - Map metadata = result.buildMetadataContent(); - assertEquals(3, metadata.size()); - assertEquals(result.promptText(), metadata.get(result.extensionUri())); - assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); - } - - @Test - void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("en-US")); - - MetadataContent result = server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), - INFORMATION_PROPOSE_URI); - - assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); - assertFalse(result.promptText().isBlank()); - assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); - assertTrue(result.promptText().contains("Required Information Items")); - } - - @Test - void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { - assertEquals(7, negotiationPrompts(new A2ATServer(writeEnv("zh-CN"))).size()); - assertEquals(7, negotiationPrompts(new A2ATServer(writeEnv("en-US"))).size()); - } - - @Test - void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - - MetadataContent result = server.generateNegotiationAbortPromptFromData( - new NegotiationAbortData( - new NegotiationContext(UUID, 5, 5, NegotiationPerformative.ABORT), - new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), - StandardTemplates.NEGOTIATION_ABORT_URI); - - assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); - assertTrue(result.promptText().contains("## 协商结果\nAbort")); - assertTrue(result.promptText().contains("## 协商终止原因")); - assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); - } - - @Test - void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - - PromptTemplate template = - server.getPrompt(StandardTemplates.NEGOTIATION_ABORT_URI).orElseThrow(); - assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); - assertFalse(template.content().isBlank()); - } - - @Test - void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("zh-CN")); - - assertTrue(server.getPrompt(INFORMATION_PROPOSE_URI).isPresent()); - assertTrue(server.getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI) - .isPresent()); - assertFalse(server.getPrompt( - StandardTemplates.NEGOTIATION_EXTENSION_NAME + "/unknown-negotiation/propose/v1") - .isPresent()); - PromptTemplate template = server.getPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); - assertEquals(INFORMATION_PROPOSE, template.templateUri()); - assertFalse(template.content().isBlank()); - } - - private static List negotiationPrompts(A2ATServer server) { - return server.getPrompts().stream() - .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( - template.templateUri().extensionName())) - .toList(); - } - - private static Path writeEnv(String language) throws IOException { - Path tempDir = Files.createTempDirectory("a2at-server-negotiation-env"); - Path envFile = tempDir.resolve("server.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=%s - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=openai - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - A2AT_PROMPT_COMPLIANCE_ENABLED=false - """ - .formatted(language)); - return envFile; - } -} +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.Test; + +class A2ATServerNegotiationApiTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + @Test + void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + MetadataContent result = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); + Map metadata = result.buildMetadataContent(); + assertEquals(3, metadata.size()); + assertEquals(result.promptText(), metadata.get(result.extensionUri())); + assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); + } + + @Test + void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("en-US")); + + MetadataContent result = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE_URI); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); + assertTrue(result.promptText().contains("Required Information Items")); + } + + @Test + void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { + assertEquals(7, negotiationPrompts(new A2ATServer(writeEnv("zh-CN"))).size()); + assertEquals(7, negotiationPrompts(new A2ATServer(writeEnv("en-US"))).size()); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + MetadataContent result = server.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 5, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + StandardTemplates.NEGOTIATION_ABORT_URI); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("## 协商终止原因")); + assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); + } + + @Test + void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + PromptTemplate template = + server.getPrompt(StandardTemplates.NEGOTIATION_ABORT_URI).orElseThrow(); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + @Test + void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + assertTrue(server.getPrompt(INFORMATION_PROPOSE_URI).isPresent()); + assertTrue(server.getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI) + .isPresent()); + assertFalse(server.getPrompt( + StandardTemplates.NEGOTIATION_EXTENSION_NAME + "/unknown-negotiation/propose/v1") + .isPresent()); + PromptTemplate template = server.getPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); + assertEquals(INFORMATION_PROPOSE, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + private static List negotiationPrompts(A2ATServer server) { + return server.getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + + private static Path writeEnv(String language) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-server-negotiation-env"); + Path envFile = tempDir.resolve("server.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=%s + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_PROMPT_COMPLIANCE_ENABLED=false + """ + .formatted(language)); + return envFile; + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java index 4b43dc5a..f52e4134 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java @@ -1,146 +1,146 @@ -package net.openan.a2at.sdk.server.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class InputTextLengthLimitTest { - - private static final String TEST_MOCK_PROVIDER = "test-input-limit-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, CountingClient.class); - } - } - - @BeforeEach - void resetCounter() { - CountingClient.resetCallCount(); - } - - @TempDir - Path tempDir; - - @Test - void checkTaskPromptFailsFastWithoutLlmCallWhenInputExceedsConfiguredLimit() throws IOException { - A2ATServer server = new A2ATServer(writeEnv("10")); - String oversizedInput = "a".repeat(11); - - PromptComplianceResult result = server.checkTaskPrompt(oversizedInput); - - assertEquals(false, result.success()); - assertEquals( - ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), result.failure().code()); - assertEquals("input_gate", result.failure().stage()); - assertEquals(0, CountingClient.callCount(), "No LLM call may happen for an oversized input"); - } - - @Test - void checkTaskPromptDoesNotFailForLengthWhenInputIsExactlyAtDefaultLimit() throws IOException { - A2ATServer server = new A2ATServer(writeEnv(null)); - String boundaryInput = "a".repeat(16384); - - PromptComplianceResult result = server.checkTaskPrompt(boundaryInput); - - assertNotEquals( - ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), - result.success() ? null : result.failure().code(), - "An input exactly at the default limit must not be rejected for its length"); - } - - @Test - void validateContentPromptAndDataFillingFailsFastWithoutLlmCallWhenPromptExceedsConfiguredLimit() - throws IOException { - A2ATServer server = new A2ATServer(writeEnv("10")); - String oversizedPrompt = "a".repeat(11); - Map schema = Map.of(); - - ContentValidationException taskError = assertThrows( - ContentValidationException.class, - () -> server.validateTaskPromptAndDataFilling( - oversizedPrompt, schema, StandardTemplates.ENERGY_SAVING_URI)); - ContentValidationException notificationError = assertThrows( - ContentValidationException.class, - () -> server.validateNotificationPromptAndDataFilling( - oversizedPrompt, schema, StandardTemplates.SUBSCRIBE_INCIDENT_URI)); - ContentValidationException authError = assertThrows( - ContentValidationException.class, - () -> server.validateAuthPromptAndDataFilling( - oversizedPrompt, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI)); - - assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), taskError.getCode()); - assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), notificationError.getCode()); - assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), authError.getCode()); - assertEquals(0, CountingClient.callCount(), "No LLM call may happen for an oversized prompt"); - } - - private Path writeEnv(String maxTextChars) throws IOException { - Path envFile = tempDir.resolve("server.env"); - String limitEntry = maxTextChars == null ? "" : "A2AT_INPUT_TEXT_MAX_CHARS=" + maxTextChars + "\n"; - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=test-input-limit-mock - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - + limitEntry); - return envFile; - } - - public static final class CountingClient implements LLMClient { - - private static final AtomicInteger CALL_COUNT = new AtomicInteger(0); - - private final LLMClientConfig config; - - public CountingClient(LLMClientConfig config) { - this.config = config; - } - - static void resetCallCount() { - CALL_COUNT.set(0); - } - - static int callCount() { - return CALL_COUNT.get(); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - CALL_COUNT.incrementAndGet(); - return new LLMResponse("{}", config.model(), Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class InputTextLengthLimitTest { + + private static final String TEST_MOCK_PROVIDER = "test-input-limit-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, CountingClient.class); + } + } + + @BeforeEach + void resetCounter() { + CountingClient.resetCallCount(); + } + + @TempDir + Path tempDir; + + @Test + void checkTaskPromptFailsFastWithoutLlmCallWhenInputExceedsConfiguredLimit() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("10")); + String oversizedInput = "a".repeat(11); + + PromptComplianceResult result = server.checkTaskPrompt(oversizedInput); + + assertEquals(false, result.success()); + assertEquals( + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), result.failure().code()); + assertEquals("input_gate", result.failure().stage()); + assertEquals(0, CountingClient.callCount(), "No LLM call may happen for an oversized input"); + } + + @Test + void checkTaskPromptDoesNotFailForLengthWhenInputIsExactlyAtDefaultLimit() throws IOException { + A2ATServer server = new A2ATServer(writeEnv(null)); + String boundaryInput = "a".repeat(16384); + + PromptComplianceResult result = server.checkTaskPrompt(boundaryInput); + + assertNotEquals( + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), + result.success() ? null : result.failure().code(), + "An input exactly at the default limit must not be rejected for its length"); + } + + @Test + void validateContentPromptAndDataFillingFailsFastWithoutLlmCallWhenPromptExceedsConfiguredLimit() + throws IOException { + A2ATServer server = new A2ATServer(writeEnv("10")); + String oversizedPrompt = "a".repeat(11); + Map schema = Map.of(); + + ContentValidationException taskError = assertThrows( + ContentValidationException.class, + () -> server.validateTaskPromptAndDataFilling( + oversizedPrompt, schema, StandardTemplates.ENERGY_SAVING_URI)); + ContentValidationException notificationError = assertThrows( + ContentValidationException.class, + () -> server.validateNotificationPromptAndDataFilling( + oversizedPrompt, schema, StandardTemplates.SUBSCRIBE_INCIDENT_URI)); + ContentValidationException authError = assertThrows( + ContentValidationException.class, + () -> server.validateAuthPromptAndDataFilling( + oversizedPrompt, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI)); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), taskError.getCode()); + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), notificationError.getCode()); + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), authError.getCode()); + assertEquals(0, CountingClient.callCount(), "No LLM call may happen for an oversized prompt"); + } + + private Path writeEnv(String maxTextChars) throws IOException { + Path envFile = tempDir.resolve("server.env"); + String limitEntry = maxTextChars == null ? "" : "A2AT_INPUT_TEXT_MAX_CHARS=" + maxTextChars + "\n"; + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-input-limit-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + + limitEntry); + return envFile; + } + + public static final class CountingClient implements LLMClient { + + private static final AtomicInteger CALL_COUNT = new AtomicInteger(0); + + private final LLMClientConfig config; + + public CountingClient(LLMClientConfig config) { + this.config = config; + } + + static void resetCallCount() { + CALL_COUNT.set(0); + } + + static int callCount() { + return CALL_COUNT.get(); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + CALL_COUNT.incrementAndGet(); + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java index 0138d72f..bd1d2587 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -1,151 +1,151 @@ -package net.openan.a2at.sdk.server.api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Task-T and Negotiation-T coexistence regression: one server instance answers both the Task-T compliance API and the - * negotiation content-layer APIs without interference, and the pre-existing negotiation state machine keeps working - * next to the new APIs. - */ -class TaskPromptNegotiationCoexistenceTest { - - private static final String TEST_MOCK_PROVIDER = "test-coexistence-mock"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { - LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); - } - } - - private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI; - - private static final String TASK_T_MESSAGE = "## 任务类型(Task Type)\n" - + "无线能效优化\n\n" - + "## 任务对象(Task Object)\n" - + "松山湖管委会\n\n" - + "## 任务目标(Task Target)\n" - + "总功耗降低30%\n"; - - @TempDir - Path tempDir; - - @Test - void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - PromptComplianceResult complianceResult = server.checkTaskPrompt(TASK_T_MESSAGE); - - assertFalse(complianceResult.success()); - assertEquals("scenario.not_matched", complianceResult.failure().code()); - assertEquals("prompt_parse", complianceResult.failure().stage()); - - NegotiationParamExtractionException negotiationFailure = assertThrows( - NegotiationParamExtractionException.class, - () -> server.validateProposePromptAndDataFilling( - TASK_T_MESSAGE, - null, - Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), - INFORMATION_PROPOSE_URI)); - - assertEquals("negotiation.invalid_input", negotiationFailure.getCode()); - assertEquals("输入的协商内容无效:缺少协商上下文(该报文不是协商报文)", negotiationFailure.getMessage()); - - MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - assertEquals(INFORMATION_PROPOSE_URI, negotiationMessage.templateUri()); - assertEquals(UUID, negotiationMessage.negotiationContext().id()); - } - - @Test - void oldNegotiationStateMachineKeepsWorkingNextToTheContentLayerApis() throws IOException { - A2ATServer server = new A2ATServer(writeEnv()); - - Map started = server.startNegotiation( - NegotiationType.INFORMATION, "需要完整的任务提示词。", Map.of("source", "coexistence-test")); - Map context = (Map) started.get(NegotiationHandler.NEGOTIATION_T_URI_NL); - assertEquals("information", context.get("negotiationType")); - assertEquals("in-progress", context.get("status")); - assertEquals("coexistence-test", ((Map) started.get("facts")).get("source")); - - MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( - new NegotiationProposeData( - new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), - INFORMATION_PROPOSE_URI); - assertEquals(2, negotiationMessage.negotiationContext().round()); - - Map startedAgain = server.startNegotiation(NegotiationType.TARGET, "请澄清目标。", Map.of()); - assertEquals( - "target", - ((Map) startedAgain.get(NegotiationHandler.NEGOTIATION_T_URI_NL)).get("negotiationType")); - } - - private Path writeEnv() throws IOException { - Path envFile = tempDir.resolve("server.env"); - Files.writeString( - envFile, - """ - A2AT_LANGUAGE=zh-CN - A2AT_PROMPT_SOURCE_TYPE=classpath - A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=test-coexistence-mock - A2AT_LLM_MODEL=example-model - A2AT_LLM_BASE_URL=https://llm.example.test/v1 - A2AT_LLM_API_KEY=test-key - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """ - .formatted()); - return envFile; - } - - public static final class RecordingClient implements LLMClient { - - private final LLMClientConfig config; - - public RecordingClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse("{}", config.model(), Map.of(), Map.of()); - } - } -} +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Task-T and Negotiation-T coexistence regression: one server instance answers both the Task-T compliance API and the + * negotiation content-layer APIs without interference, and the pre-existing negotiation state machine keeps working + * next to the new APIs. + */ +class TaskPromptNegotiationCoexistenceTest { + + private static final String TEST_MOCK_PROVIDER = "test-coexistence-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI; + + private static final String TASK_T_MESSAGE = "## 任务类型(Task Type)\n" + + "无线能效优化\n\n" + + "## 任务对象(Task Object)\n" + + "松山湖管委会\n\n" + + "## 任务目标(Task Target)\n" + + "总功耗降低30%\n"; + + @TempDir + Path tempDir; + + @Test + void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + PromptComplianceResult complianceResult = server.checkTaskPrompt(TASK_T_MESSAGE); + + assertFalse(complianceResult.success()); + assertEquals("scenario.not_matched", complianceResult.failure().code()); + assertEquals("prompt_parse", complianceResult.failure().stage()); + + NegotiationParamExtractionException negotiationFailure = assertThrows( + NegotiationParamExtractionException.class, + () -> server.validateProposePromptAndDataFilling( + TASK_T_MESSAGE, + null, + Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), + INFORMATION_PROPOSE_URI)); + + assertEquals("negotiation.invalid_input", negotiationFailure.getCode()); + assertEquals("输入的协商内容无效:缺少协商上下文(该报文不是协商报文)", negotiationFailure.getMessage()); + + MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + assertEquals(INFORMATION_PROPOSE_URI, negotiationMessage.templateUri()); + assertEquals(UUID, negotiationMessage.negotiationContext().id()); + } + + @Test + void oldNegotiationStateMachineKeepsWorkingNextToTheContentLayerApis() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + Map started = server.startNegotiation( + NegotiationType.INFORMATION, "需要完整的任务提示词。", Map.of("source", "coexistence-test")); + Map context = (Map) started.get(NegotiationHandler.NEGOTIATION_T_URI_NL); + assertEquals("information", context.get("negotiationType")); + assertEquals("in-progress", context.get("status")); + assertEquals("coexistence-test", ((Map) started.get("facts")).get("source")); + + MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + assertEquals(2, negotiationMessage.negotiationContext().round()); + + Map startedAgain = server.startNegotiation(NegotiationType.TARGET, "请澄清目标。", Map.of()); + assertEquals( + "target", + ((Map) startedAgain.get(NegotiationHandler.NEGOTIATION_T_URI_NL)).get("negotiationType")); + } + + private Path writeEnv() throws IOException { + Path envFile = tempDir.resolve("server.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-coexistence-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted()); + return envFile; + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java index 4641752a..2d53334c 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java @@ -1,94 +1,94 @@ -package net.openan.a2at.sdk.server.assembly; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Map; -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; -import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; -import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; -import net.openan.a2at.sdk.server.compliance.ServerPromptComplianceOrchestrator; -import net.openan.a2at.sdk.server.model.PromptComplianceFailure; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import org.junit.jupiter.api.Test; - -class ServerNegotiationOrchestratorBuilderTest { - - @Test - void buildCreatesServerRoleOrchestratorThatUsesPromptComplianceForInformationNegotiation() { - RecordingPromptComplianceOrchestrator complianceOrchestrator = - new RecordingPromptComplianceOrchestrator(new PromptComplianceResult( - false, - new PromptComplianceFailure( - "slot.semantic_conflict", "Need more information", "slot_validation"))); - - RoleBoundNegotiationOrchestrator orchestrator = new ServerNegotiationOrchestratorBuilder() - .promptComplianceOrchestrator(complianceOrchestrator) - .store(new InMemoryNegotiationStore()) - .build(); - - Map started = - orchestrator.startNegotiation(NegotiationType.INFORMATION, "Need full task prompt.", Map.of()); - Map context = cast(started.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); - - Map received = orchestrator.receiveNegotiation("latest full task prompt", context); - - assertEquals("latest full task prompt", complianceOrchestrator.lastProcessedPromptText); - assertEquals("Need more information", received.get("message")); - assertTrue(booleanValue(received.get("needResponse"))); - } - - @Test - void buildUsesServerRoleInStartPayload() { - RoleBoundNegotiationOrchestrator orchestrator = new ServerNegotiationOrchestratorBuilder() - .promptComplianceOrchestrator(processedPromptText -> new PromptComplianceResult(true, null)) - .store(new InMemoryNegotiationStore()) - .build(); - - Map result = orchestrator.startNegotiation( - NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); - Map context = cast(result.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); - - assertEquals("target", context.get("negotiationType")); - assertEquals("in-progress", context.get("status")); - } - - private static final class RecordingPromptComplianceOrchestrator implements ServerPromptComplianceOrchestrator { - private final PromptComplianceResult result; - private String lastProcessedPromptText; - - private RecordingPromptComplianceOrchestrator(PromptComplianceResult result) { - this.result = result; - } - - @Override - public PromptComplianceResult checkTaskPrompt(String processedPromptText) { - this.lastProcessedPromptText = processedPromptText; - return result; - } - } - - private static Map cast(Object value) { - if (value instanceof Map map) { - return map.entrySet().stream() - .collect(java.util.stream.Collectors.toMap( - entry -> stringValue(entry.getKey()), Map.Entry::getValue)); - } - throw new AssertionError("Expected map value but was: " + value); - } - - private static String stringValue(Object value) { - if (value instanceof String text) { - return text; - } - throw new AssertionError("Expected string value but was: " + value); - } - - private static boolean booleanValue(Object value) { - if (value instanceof Boolean booleanValue) { - return booleanValue; - } - throw new AssertionError("Expected boolean value but was: " + value); - } -} +package net.openan.a2at.sdk.server.assembly; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; +import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.server.compliance.ServerPromptComplianceOrchestrator; +import net.openan.a2at.sdk.server.model.PromptComplianceFailure; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.junit.jupiter.api.Test; + +class ServerNegotiationOrchestratorBuilderTest { + + @Test + void buildCreatesServerRoleOrchestratorThatUsesPromptComplianceForInformationNegotiation() { + RecordingPromptComplianceOrchestrator complianceOrchestrator = + new RecordingPromptComplianceOrchestrator(new PromptComplianceResult( + false, + new PromptComplianceFailure( + "slot.semantic_conflict", "Need more information", "slot_validation"))); + + RoleBoundNegotiationOrchestrator orchestrator = new ServerNegotiationOrchestratorBuilder() + .promptComplianceOrchestrator(complianceOrchestrator) + .store(new InMemoryNegotiationStore()) + .build(); + + Map started = + orchestrator.startNegotiation(NegotiationType.INFORMATION, "Need full task prompt.", Map.of()); + Map context = cast(started.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); + + Map received = orchestrator.receiveNegotiation("latest full task prompt", context); + + assertEquals("latest full task prompt", complianceOrchestrator.lastProcessedPromptText); + assertEquals("Need more information", received.get("message")); + assertTrue(booleanValue(received.get("needResponse"))); + } + + @Test + void buildUsesServerRoleInStartPayload() { + RoleBoundNegotiationOrchestrator orchestrator = new ServerNegotiationOrchestratorBuilder() + .promptComplianceOrchestrator(processedPromptText -> new PromptComplianceResult(true, null)) + .store(new InMemoryNegotiationStore()) + .build(); + + Map result = orchestrator.startNegotiation( + NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); + Map context = cast(result.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); + + assertEquals("target", context.get("negotiationType")); + assertEquals("in-progress", context.get("status")); + } + + private static final class RecordingPromptComplianceOrchestrator implements ServerPromptComplianceOrchestrator { + private final PromptComplianceResult result; + private String lastProcessedPromptText; + + private RecordingPromptComplianceOrchestrator(PromptComplianceResult result) { + this.result = result; + } + + @Override + public PromptComplianceResult checkTaskPrompt(String processedPromptText) { + this.lastProcessedPromptText = processedPromptText; + return result; + } + } + + private static Map cast(Object value) { + if (value instanceof Map map) { + return map.entrySet().stream() + .collect(java.util.stream.Collectors.toMap( + entry -> stringValue(entry.getKey()), Map.Entry::getValue)); + } + throw new AssertionError("Expected map value but was: " + value); + } + + private static String stringValue(Object value) { + if (value instanceof String text) { + return text; + } + throw new AssertionError("Expected string value but was: " + value); + } + + private static boolean booleanValue(Object value) { + if (value instanceof Boolean booleanValue) { + return booleanValue; + } + throw new AssertionError("Expected boolean value but was: " + value); + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java index 3c553581..434b395f 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java @@ -1,147 +1,147 @@ -package net.openan.a2at.sdk.server.compliance; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Map; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.metadata.ServerPromptMetadataExtractor; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import net.openan.a2at.sdk.server.validation.ServerPromptSemanticValidator; -import org.junit.jupiter.api.Test; - -class DefaultServerPromptComplianceOrchestratorTest { - - @Test - void checkTaskPromptReturnsSuccessWhenMetadataExtractionAndValidationPass() { - ProcessedPromptMetadata metadata = - new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A")); - RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor(metadata); - RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); - DefaultServerPromptComplianceOrchestrator orchestrator = - new DefaultServerPromptComplianceOrchestrator(extractor, validator); - - PromptComplianceResult result = orchestrator.checkTaskPrompt("Site: Site A"); - - assertTrue(result.success()); - assertThrows(NoSuchMethodException.class, () -> PromptComplianceResult.class.getMethod("metadata")); - assertEquals(null, result.failure()); - assertEquals("Site: Site A", extractor.lastProcessedPromptText); - assertEquals("Site: Site A", validator.lastProcessedPromptText); - assertEquals(metadata, validator.lastMetadata); - } - - @Test - void checkTaskPromptReturnsFailureWhenMetadataExtractionFails() { - DefaultServerPromptComplianceOrchestrator orchestrator = new DefaultServerPromptComplianceOrchestrator( - new RecordingPromptMetadataExtractor(new PromptComplianceCheckException( - "scenario.not_mapped", "Prompt does not match any known template.", "prompt_parse")), - new RecordingPromptSemanticValidator(null)); - - PromptComplianceResult result = orchestrator.checkTaskPrompt("Unknown"); - - assertEquals(false, result.success()); - assertEquals("scenario.not_mapped", result.failure().code()); - assertEquals("prompt_parse", result.failure().stage()); - } - - @Test - void checkTaskPromptReturnsFailureWhenSemanticValidationFails() { - ProcessedPromptMetadata metadata = - new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A")); - DefaultServerPromptComplianceOrchestrator orchestrator = new DefaultServerPromptComplianceOrchestrator( - new RecordingPromptMetadataExtractor(metadata), - new RecordingPromptSemanticValidator(new PromptComplianceCheckException( - "slot.semantic_conflict", - "The value of 'site' conflicts with the slot definition.", - "slot_validation"))); - - PromptComplianceResult result = orchestrator.checkTaskPrompt("Site: Site A"); - - assertEquals(false, result.success()); - assertEquals("slot.semantic_conflict", result.failure().code()); - assertEquals("slot_validation", result.failure().stage()); - } - - @Test - void checkTaskPromptReturnsInputTooLongFailureWithoutRunningThePipelineWhenOverLimit() { - RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor( - new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A"))); - RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); - DefaultServerPromptComplianceOrchestrator orchestrator = - new DefaultServerPromptComplianceOrchestrator(extractor, validator, 5); - String oversizedInput = "a".repeat(6); - - PromptComplianceResult result = orchestrator.checkTaskPrompt(oversizedInput); - - assertEquals(false, result.success()); - assertEquals("input.text_too_long", result.failure().code()); - assertEquals("input_gate", result.failure().stage()); - assertEquals( - "Input text length 6 exceeds the maximum of 5 (A2AT_INPUT_TEXT_MAX_CHARS)", - result.failure().message()); - assertEquals(null, extractor.lastProcessedPromptText, "Extractor must not run for an oversized input"); - assertEquals(null, validator.lastProcessedPromptText, "Validator must not run for an oversized input"); - } - - @Test - void checkTaskPromptRunsThePipelineWhenInputIsExactlyAtLimit() { - RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor( - new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A"))); - RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); - DefaultServerPromptComplianceOrchestrator orchestrator = - new DefaultServerPromptComplianceOrchestrator(extractor, validator, 5); - String boundaryInput = "a".repeat(5); - - PromptComplianceResult result = orchestrator.checkTaskPrompt(boundaryInput); - - assertTrue(result.success()); - assertEquals(boundaryInput, extractor.lastProcessedPromptText); - } - - private static final class RecordingPromptMetadataExtractor implements ServerPromptMetadataExtractor { - private final ProcessedPromptMetadata metadata; - private final PromptComplianceCheckException exception; - private String lastProcessedPromptText; - - private RecordingPromptMetadataExtractor(ProcessedPromptMetadata metadata) { - this.metadata = metadata; - this.exception = null; - } - - private RecordingPromptMetadataExtractor(PromptComplianceCheckException exception) { - this.metadata = null; - this.exception = exception; - } - - @Override - public ProcessedPromptMetadata extract(String processedPromptText) { - this.lastProcessedPromptText = processedPromptText; - if (exception != null) { - throw exception; - } - return metadata; - } - } - - private static final class RecordingPromptSemanticValidator implements ServerPromptSemanticValidator { - private final PromptComplianceCheckException exception; - private String lastProcessedPromptText; - private ProcessedPromptMetadata lastMetadata; - - private RecordingPromptSemanticValidator(PromptComplianceCheckException exception) { - this.exception = exception; - } - - @Override - public void validate(String processedPromptText, ProcessedPromptMetadata metadata) { - this.lastProcessedPromptText = processedPromptText; - this.lastMetadata = metadata; - if (exception != null) { - throw exception; - } - } - } -} +package net.openan.a2at.sdk.server.compliance; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; +import net.openan.a2at.sdk.server.metadata.ServerPromptMetadataExtractor; +import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import net.openan.a2at.sdk.server.validation.ServerPromptSemanticValidator; +import org.junit.jupiter.api.Test; + +class DefaultServerPromptComplianceOrchestratorTest { + + @Test + void checkTaskPromptReturnsSuccessWhenMetadataExtractionAndValidationPass() { + ProcessedPromptMetadata metadata = + new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A")); + RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor(metadata); + RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); + DefaultServerPromptComplianceOrchestrator orchestrator = + new DefaultServerPromptComplianceOrchestrator(extractor, validator); + + PromptComplianceResult result = orchestrator.checkTaskPrompt("Site: Site A"); + + assertTrue(result.success()); + assertThrows(NoSuchMethodException.class, () -> PromptComplianceResult.class.getMethod("metadata")); + assertEquals(null, result.failure()); + assertEquals("Site: Site A", extractor.lastProcessedPromptText); + assertEquals("Site: Site A", validator.lastProcessedPromptText); + assertEquals(metadata, validator.lastMetadata); + } + + @Test + void checkTaskPromptReturnsFailureWhenMetadataExtractionFails() { + DefaultServerPromptComplianceOrchestrator orchestrator = new DefaultServerPromptComplianceOrchestrator( + new RecordingPromptMetadataExtractor(new PromptComplianceCheckException( + "scenario.not_mapped", "Prompt does not match any known template.", "prompt_parse")), + new RecordingPromptSemanticValidator(null)); + + PromptComplianceResult result = orchestrator.checkTaskPrompt("Unknown"); + + assertEquals(false, result.success()); + assertEquals("scenario.not_mapped", result.failure().code()); + assertEquals("prompt_parse", result.failure().stage()); + } + + @Test + void checkTaskPromptReturnsFailureWhenSemanticValidationFails() { + ProcessedPromptMetadata metadata = + new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A")); + DefaultServerPromptComplianceOrchestrator orchestrator = new DefaultServerPromptComplianceOrchestrator( + new RecordingPromptMetadataExtractor(metadata), + new RecordingPromptSemanticValidator(new PromptComplianceCheckException( + "slot.semantic_conflict", + "The value of 'site' conflicts with the slot definition.", + "slot_validation"))); + + PromptComplianceResult result = orchestrator.checkTaskPrompt("Site: Site A"); + + assertEquals(false, result.success()); + assertEquals("slot.semantic_conflict", result.failure().code()); + assertEquals("slot_validation", result.failure().stage()); + } + + @Test + void checkTaskPromptReturnsInputTooLongFailureWithoutRunningThePipelineWhenOverLimit() { + RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor( + new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A"))); + RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); + DefaultServerPromptComplianceOrchestrator orchestrator = + new DefaultServerPromptComplianceOrchestrator(extractor, validator, 5); + String oversizedInput = "a".repeat(6); + + PromptComplianceResult result = orchestrator.checkTaskPrompt(oversizedInput); + + assertEquals(false, result.success()); + assertEquals("input.text_too_long", result.failure().code()); + assertEquals("input_gate", result.failure().stage()); + assertEquals( + "Input text length 6 exceeds the maximum of 5 (A2AT_INPUT_TEXT_MAX_CHARS)", + result.failure().message()); + assertEquals(null, extractor.lastProcessedPromptText, "Extractor must not run for an oversized input"); + assertEquals(null, validator.lastProcessedPromptText, "Validator must not run for an oversized input"); + } + + @Test + void checkTaskPromptRunsThePipelineWhenInputIsExactlyAtLimit() { + RecordingPromptMetadataExtractor extractor = new RecordingPromptMetadataExtractor( + new ProcessedPromptMetadata("ran-energy-saving", "en-US", "Site: {site}", Map.of("site", "Site A"))); + RecordingPromptSemanticValidator validator = new RecordingPromptSemanticValidator(null); + DefaultServerPromptComplianceOrchestrator orchestrator = + new DefaultServerPromptComplianceOrchestrator(extractor, validator, 5); + String boundaryInput = "a".repeat(5); + + PromptComplianceResult result = orchestrator.checkTaskPrompt(boundaryInput); + + assertTrue(result.success()); + assertEquals(boundaryInput, extractor.lastProcessedPromptText); + } + + private static final class RecordingPromptMetadataExtractor implements ServerPromptMetadataExtractor { + private final ProcessedPromptMetadata metadata; + private final PromptComplianceCheckException exception; + private String lastProcessedPromptText; + + private RecordingPromptMetadataExtractor(ProcessedPromptMetadata metadata) { + this.metadata = metadata; + this.exception = null; + } + + private RecordingPromptMetadataExtractor(PromptComplianceCheckException exception) { + this.metadata = null; + this.exception = exception; + } + + @Override + public ProcessedPromptMetadata extract(String processedPromptText) { + this.lastProcessedPromptText = processedPromptText; + if (exception != null) { + throw exception; + } + return metadata; + } + } + + private static final class RecordingPromptSemanticValidator implements ServerPromptSemanticValidator { + private final PromptComplianceCheckException exception; + private String lastProcessedPromptText; + private ProcessedPromptMetadata lastMetadata; + + private RecordingPromptSemanticValidator(PromptComplianceCheckException exception) { + this.exception = exception; + } + + @Override + public void validate(String processedPromptText, ProcessedPromptMetadata metadata) { + this.lastProcessedPromptText = processedPromptText; + this.lastMetadata = metadata; + if (exception != null) { + throw exception; + } + } + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java index 844b2ae3..3e1b8123 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java @@ -1,281 +1,281 @@ -package net.openan.a2at.sdk.server.metadata; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; -import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; -import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import org.junit.jupiter.api.Test; - -class LlmBackedPromptMetadataExtractorTest { - - private static final String SLOT_NAME = "notification_topic"; - - @Test - void extractResolvesScenarioLoadsTemplateAndReturnsExtractedSlots() { - LLMClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); - PromptTemplateTextLoader templateLoader = - (scenarioCode, language) -> "## notification_topic\n{{notification_topic}}\n"; - PromptSlotSchemaLoader slotSchemaLoader = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition(SLOT_NAME, true, "string", null, null, null, null, null, null, null))); - PromptSlotValueExtractor slotValueExtractor = (userInput, scenarioCode, language) -> - new StructuredSlotExtractionResult(Map.of(SLOT_NAME, "Incident"), List.of()); - LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(llmClient), - List.of(new ScenarioDefinition( - "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), - "zh-CN", - "Identify scenario.", - "Choose scenario.", - templateLoader, - slotSchemaLoader, - slotValueExtractor); - - ProcessedPromptMetadata metadata = extractor.extract("## notification_topic\nIncident\n"); - - assertEquals("subscribe-incident", metadata.scenarioCode()); - assertEquals("zh-CN", metadata.language()); - assertEquals("## notification_topic\n{{notification_topic}}\n", metadata.templateText()); - assertEquals(Map.of(SLOT_NAME, "Incident"), metadata.slots()); - } - - @Test - void extractReturnsScenarioNotMatchedWhenScenarioRecognitionDoesNotMatch() { - LLMClient llmClient = new RecordingClient( - "{\"matched\":false,\"scenario_code\":null,\"error_message\":\"No scenario matched.\"}"); - LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(llmClient), - List.of(new ScenarioDefinition( - "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), - "zh-CN", - "Identify scenario.", - "Choose scenario.", - (scenarioCode, language) -> "", - (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("unknown prompt")); - - assertEquals("scenario.not_matched", error.getCode()); - assertEquals("prompt_parse", error.getStage()); - assertTrue(error.getMessage().contains("No scenario matched."), "message must render the recognition reason"); - } - - @Test - void extractReturnsScenarioNotMatchedWhenRecognitionFailsWithoutReason() { - LLMClient llmClient = new RecordingClient("{\"matched\":false,\"scenario_code\":null,\"error_message\":null}"); - LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(llmClient), - List.of(new ScenarioDefinition( - "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), - "zh-CN", - "Identify scenario.", - "Choose scenario.", - (scenarioCode, language) -> "", - (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("unknown prompt")); - - assertEquals("scenario.not_matched", error.getCode()); - assertEquals("prompt_parse", error.getStage()); - } - - @Test - void extractReturnsCatalogSlotCodeWhenStructuredExtractionReportsSlotErrors() { - LLMClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); - LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(llmClient), - List.of(new ScenarioDefinition( - "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), - "zh-CN", - "Identify scenario.", - "Choose scenario.", - (scenarioCode, language) -> "template", - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition( - SLOT_NAME, true, "string", null, null, null, null, null, null, null))), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( - Map.of(SLOT_NAME, ""), - List.of(new StructuredSlotValidationError( - SLOT_NAME, "slot.not_provided", "topic is missing")))); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); - - assertEquals("slot.not_provided", error.getCode()); - assertEquals("slot_validation", error.getStage()); - assertTrue(error.getMessage().contains(SLOT_NAME), "message must render the slot label"); - assertEquals(Map.of("slot_label", SLOT_NAME), error.getFacts()); - } - - @Test - void extractFallsBackToSlotRuleViolationForUnknownExtractionErrorCode() { - LLMClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); - LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(llmClient), - List.of(new ScenarioDefinition( - "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), - "zh-CN", - "Identify scenario.", - "Choose scenario.", - (scenarioCode, language) -> "template", - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition( - SLOT_NAME, true, "string", null, null, null, null, null, null, null))), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( - Map.of(SLOT_NAME, ""), - List.of(new StructuredSlotValidationError(SLOT_NAME, "data_problem", "whatever")))); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); - - assertEquals("slot.rule_violation", error.getCode()); - assertEquals("slot_validation", error.getStage()); - } - - @Test - void extractReturnsSlotNotExtractedWhenRequiredSlotIsMissing() { - LLMClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); - LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(llmClient), - List.of(new ScenarioDefinition( - "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), - "zh-CN", - "Identify scenario.", - "Choose scenario.", - (scenarioCode, language) -> "template", - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition( - SLOT_NAME, true, "string", null, null, null, null, null, null, null))), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); - - assertEquals("slot.not_provided", error.getCode()); - assertEquals("slot_validation", error.getStage()); - assertTrue(error.getMessage().contains(SLOT_NAME), "message must render the slot label"); - } - - @Test - void extractReturnsSlotConstraintViolatedWhenValueIsOutsideAllowedValues() { - LLMClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); - LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(llmClient), - List.of(new ScenarioDefinition( - "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), - "zh-CN", - "Identify scenario.", - "Choose scenario.", - (scenarioCode, language) -> "template", - (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition( - SLOT_NAME, true, "string", null, null, null, List.of("Incident"), null, null, null))), - (userInput, scenarioCode, language) -> - new StructuredSlotExtractionResult(Map.of(SLOT_NAME, "Outage"), List.of())); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); - - assertEquals("slot.constraint_violated", error.getCode()); - assertEquals("slot_validation", error.getStage()); - assertTrue(error.getMessage().contains("Outage"), "message must render the offending value"); - assertEquals(Map.of("slot_label", SLOT_NAME, "actual", "Outage"), error.getFacts()); - } - - @Test - void extractPropagatesTemplateLoadErrorsAsGenerationFailures() { - LLMClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); - LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(llmClient), - List.of(new ScenarioDefinition( - "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), - "zh-CN", - "Identify scenario.", - "Choose scenario.", - (scenarioCode, language) -> { - throw new ResourceNotFoundException("Prompt resource file does not exist.", "template.md"); - }, - (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("prompt")); - - assertEquals("template.not_found", error.getCode()); - assertEquals("generation", error.getStage()); - assertTrue(error.getMessage().contains("subscribe-incident"), "message must render the template URI"); - assertTrue(error.getMessage().contains("zh-CN"), "message must render the language"); - } - - @Test - void extractWrapsUnexpectedTemplateLoadErrorsAsResourceReadFailures() { - LLMClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); - LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( - new LlmScenarioRecognizer(llmClient), - List.of(new ScenarioDefinition( - "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), - "zh-CN", - "Identify scenario.", - "Choose scenario.", - (scenarioCode, language) -> { - throw new A2ATError("boom"); - }, - (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), - (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("prompt")); - - assertEquals("infra.resource_read_failed", error.getCode()); - assertEquals("generation", error.getStage()); - } - - private static final class RecordingClient implements LLMClient { - private final String payload; - - private RecordingClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.server.metadata; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.prompt.analysis.impl.LlmScenarioRecognizer; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; +import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; +import org.junit.jupiter.api.Test; + +class LlmBackedPromptMetadataExtractorTest { + + private static final String SLOT_NAME = "notification_topic"; + + @Test + void extractResolvesScenarioLoadsTemplateAndReturnsExtractedSlots() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + PromptTemplateTextLoader templateLoader = + (scenarioCode, language) -> "## notification_topic\n{{notification_topic}}\n"; + PromptSlotSchemaLoader slotSchemaLoader = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition(SLOT_NAME, true, "string", null, null, null, null, null, null, null))); + PromptSlotValueExtractor slotValueExtractor = (userInput, scenarioCode, language) -> + new StructuredSlotExtractionResult(Map.of(SLOT_NAME, "Incident"), List.of()); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + templateLoader, + slotSchemaLoader, + slotValueExtractor); + + ProcessedPromptMetadata metadata = extractor.extract("## notification_topic\nIncident\n"); + + assertEquals("subscribe-incident", metadata.scenarioCode()); + assertEquals("zh-CN", metadata.language()); + assertEquals("## notification_topic\n{{notification_topic}}\n", metadata.templateText()); + assertEquals(Map.of(SLOT_NAME, "Incident"), metadata.slots()); + } + + @Test + void extractReturnsScenarioNotMatchedWhenScenarioRecognitionDoesNotMatch() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":false,\"scenario_code\":null,\"error_message\":\"No scenario matched.\"}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> "", + (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("unknown prompt")); + + assertEquals("scenario.not_matched", error.getCode()); + assertEquals("prompt_parse", error.getStage()); + assertTrue(error.getMessage().contains("No scenario matched."), "message must render the recognition reason"); + } + + @Test + void extractReturnsScenarioNotMatchedWhenRecognitionFailsWithoutReason() { + LLMClient llmClient = new RecordingClient("{\"matched\":false,\"scenario_code\":null,\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> "", + (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("unknown prompt")); + + assertEquals("scenario.not_matched", error.getCode()); + assertEquals("prompt_parse", error.getStage()); + } + + @Test + void extractReturnsCatalogSlotCodeWhenStructuredExtractionReportsSlotErrors() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> "template", + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + SLOT_NAME, true, "string", null, null, null, null, null, null, null))), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of(SLOT_NAME, ""), + List.of(new StructuredSlotValidationError( + SLOT_NAME, "slot.not_provided", "topic is missing")))); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); + + assertEquals("slot.not_provided", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertTrue(error.getMessage().contains(SLOT_NAME), "message must render the slot label"); + assertEquals(Map.of("slot_label", SLOT_NAME), error.getFacts()); + } + + @Test + void extractFallsBackToSlotRuleViolationForUnknownExtractionErrorCode() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> "template", + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + SLOT_NAME, true, "string", null, null, null, null, null, null, null))), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult( + Map.of(SLOT_NAME, ""), + List.of(new StructuredSlotValidationError(SLOT_NAME, "data_problem", "whatever")))); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); + + assertEquals("slot.rule_violation", error.getCode()); + assertEquals("slot_validation", error.getStage()); + } + + @Test + void extractReturnsSlotNotExtractedWhenRequiredSlotIsMissing() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> "template", + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + SLOT_NAME, true, "string", null, null, null, null, null, null, null))), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); + + assertEquals("slot.not_provided", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertTrue(error.getMessage().contains(SLOT_NAME), "message must render the slot label"); + } + + @Test + void extractReturnsSlotConstraintViolatedWhenValueIsOutsideAllowedValues() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> "template", + (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition( + SLOT_NAME, true, "string", null, null, null, List.of("Incident"), null, null, null))), + (userInput, scenarioCode, language) -> + new StructuredSlotExtractionResult(Map.of(SLOT_NAME, "Outage"), List.of())); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("bad prompt")); + + assertEquals("slot.constraint_violated", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertTrue(error.getMessage().contains("Outage"), "message must render the offending value"); + assertEquals(Map.of("slot_label", SLOT_NAME, "actual", "Outage"), error.getFacts()); + } + + @Test + void extractPropagatesTemplateLoadErrorsAsGenerationFailures() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> { + throw new ResourceNotFoundException("Prompt resource file does not exist.", "template.md"); + }, + (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("prompt")); + + assertEquals("template.not_found", error.getCode()); + assertEquals("generation", error.getStage()); + assertTrue(error.getMessage().contains("subscribe-incident"), "message must render the template URI"); + assertTrue(error.getMessage().contains("zh-CN"), "message must render the language"); + } + + @Test + void extractWrapsUnexpectedTemplateLoadErrorsAsResourceReadFailures() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":true,\"scenario_code\":\"subscribe-incident\",\"error_message\":null}"); + LlmBackedPromptMetadataExtractor extractor = new LlmBackedPromptMetadataExtractor( + new LlmScenarioRecognizer(llmClient), + List.of(new ScenarioDefinition( + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), + "zh-CN", + "Identify scenario.", + "Choose scenario.", + (scenarioCode, language) -> { + throw new A2ATError("boom"); + }, + (scenarioCode, language) -> new PromptSlotSchema(scenarioCode, List.of()), + (userInput, scenarioCode, language) -> new StructuredSlotExtractionResult(Map.of(), List.of())); + + PromptComplianceCheckException error = + assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("prompt")); + + assertEquals("infra.resource_read_failed", error.getCode()); + assertEquals("generation", error.getStage()); + } + + private static final class RecordingClient implements LLMClient { + private final String payload; + + private RecordingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidatorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidatorTest.java index 7d7971bb..0a7f19de 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidatorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidatorTest.java @@ -1,54 +1,54 @@ -package net.openan.a2at.sdk.server.validation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.validation.ContentValidationException; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import org.junit.jupiter.api.Test; - -class InputLimitedContentValidatorTest { - - @Test - void validateThrowsInputTooLongWithoutDelegationWhenPromptExceedsLimit() { - RecordingContentValidator delegate = new RecordingContentValidator(); - InputLimitedContentValidator validator = new InputLimitedContentValidator(delegate, 5); - String oversizedPrompt = "a".repeat(6); - - ContentValidationException error = assertThrows( - ContentValidationException.class, - () -> validator.validate(oversizedPrompt, Map.of(), StandardTemplates.ENERGY_SAVING)); - - assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), error.getCode()); - assertEquals("Input text length 6 exceeds the maximum of 5 (A2AT_INPUT_TEXT_MAX_CHARS)", error.getMessage()); - assertEquals(null, delegate.lastPrompt, "Delegate must not run for an oversized prompt"); - } - - @Test - void validateDelegatesWhenPromptIsExactlyAtLimit() { - RecordingContentValidator delegate = new RecordingContentValidator(); - InputLimitedContentValidator validator = new InputLimitedContentValidator(delegate, 5); - String boundaryPrompt = "a".repeat(5); - - FilledParamData result = validator.validate(boundaryPrompt, Map.of(), StandardTemplates.ENERGY_SAVING); - - assertEquals(boundaryPrompt, delegate.lastPrompt); - assertEquals(FilledParamData.class, result.getClass()); - } - - private static final class RecordingContentValidator implements ContentValidator { - - private String lastPrompt; - - @Override - public FilledParamData validate( - String prompt, Map schema, net.openan.a2at.sdk.core.model.TemplateUri templateUri) { - this.lastPrompt = prompt; - return new FilledParamData(Map.of()); - } - } -} +package net.openan.a2at.sdk.server.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import org.junit.jupiter.api.Test; + +class InputLimitedContentValidatorTest { + + @Test + void validateThrowsInputTooLongWithoutDelegationWhenPromptExceedsLimit() { + RecordingContentValidator delegate = new RecordingContentValidator(); + InputLimitedContentValidator validator = new InputLimitedContentValidator(delegate, 5); + String oversizedPrompt = "a".repeat(6); + + ContentValidationException error = assertThrows( + ContentValidationException.class, + () -> validator.validate(oversizedPrompt, Map.of(), StandardTemplates.ENERGY_SAVING)); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), error.getCode()); + assertEquals("Input text length 6 exceeds the maximum of 5 (A2AT_INPUT_TEXT_MAX_CHARS)", error.getMessage()); + assertEquals(null, delegate.lastPrompt, "Delegate must not run for an oversized prompt"); + } + + @Test + void validateDelegatesWhenPromptIsExactlyAtLimit() { + RecordingContentValidator delegate = new RecordingContentValidator(); + InputLimitedContentValidator validator = new InputLimitedContentValidator(delegate, 5); + String boundaryPrompt = "a".repeat(5); + + FilledParamData result = validator.validate(boundaryPrompt, Map.of(), StandardTemplates.ENERGY_SAVING); + + assertEquals(boundaryPrompt, delegate.lastPrompt); + assertEquals(FilledParamData.class, result.getClass()); + } + + private static final class RecordingContentValidator implements ContentValidator { + + private String lastPrompt; + + @Override + public FilledParamData validate( + String prompt, Map schema, net.openan.a2at.sdk.core.model.TemplateUri templateUri) { + this.lastPrompt = prompt; + return new FilledParamData(Map.of()); + } + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java index 31ad3a26..4c10d22f 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java @@ -1,221 +1,221 @@ -package net.openan.a2at.sdk.server.validation; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import org.junit.jupiter.api.Test; - -class LlmBackedPromptSemanticValidatorTest { - - private static final String SLOT_NAME = "notification_topic"; - - private static final PromptSlotSchemaLoader SLOT_SCHEMA_LOADER = (scenarioCode, language) -> new PromptSlotSchema( - scenarioCode, - List.of(new PromptSlotDefinition(SLOT_NAME, true, "string", null, null, null, null, null, null, null))); - - @Test - void validatePassesWhenSemanticValidatorApprovesSlots() { - LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":[]}"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - assertDoesNotThrow(() -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - } - - @Test - void validatePassesWhenSemanticValidatorReturnsFormattedJson() { - LLMClient llmClient = new RecordingClient( - """ - { - "passed": true, - "errors": [] - } - """); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - assertDoesNotThrow(() -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - } - - @Test - void validateReturnsCatalogCodeWhenSemanticValidatorRejectsSlots() { - LLMClient llmClient = new RecordingClient("{\"passed\":false,\"errors\":[{\"slot_name\":\"notification_topic\"," - + "\"code\":\"slot.semantic_conflict\"," - + "\"facts\":{\"slot_label\":\"notification_topic\",\"reason\":\"topic does not match\"}}]}"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - PromptComplianceCheckException error = assertThrows( - PromptComplianceCheckException.class, - () -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - - assertEquals("slot.semantic_conflict", error.getCode()); - assertEquals("slot_validation", error.getStage()); - assertTrue(error.getMessage().contains("notification_topic"), "message must render the slot label"); - assertTrue(error.getMessage().contains("topic does not match"), "message must render the conflict reason"); - assertEquals(Map.of("slot_label", "notification_topic", "reason", "topic does not match"), error.getFacts()); - } - - @Test - void validateMapsUnknownCodeToSlotRuleViolationFallback() { - LLMClient llmClient = new RecordingClient("{\"passed\":false,\"errors\":[{\"slot_name\":\"notification_topic\"," - + "\"code\":\"data_problem\"," - + "\"facts\":{\"slot_label\":\"notification_topic\"}}]}"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - PromptComplianceCheckException error = assertThrows( - PromptComplianceCheckException.class, - () -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - - assertEquals("slot.rule_violation", error.getCode()); - assertEquals("slot_validation", error.getStage()); - assertTrue(error.getMessage().contains("notification_topic"), "message must render the slot label"); - } - - @Test - void validateRejectsWhenPassedTrueButErrorsNonEmpty() { - LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":[{\"slot_name\":\"notification_topic\"," - + "\"code\":\"slot.semantic_conflict\"," - + "\"facts\":{\"slot_label\":\"notification_topic\",\"reason\":\"topic does not match\"}}]}"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - PromptComplianceCheckException error = assertThrows( - PromptComplianceCheckException.class, - () -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - - assertEquals("slot.semantic_conflict", error.getCode()); - assertEquals("slot_validation", error.getStage()); - assertTrue(error.getMessage().contains("topic does not match"), "message must render the conflict reason"); - } - - @Test - void validatePassesWhenPassedTrueAndErrorsMissing() { - LLMClient llmClient = new RecordingClient("{\"passed\":true}"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - assertDoesNotThrow(() -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - } - - @Test - void validatePassesWhenPassedTrueAndErrorsNull() { - LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":null}"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - assertDoesNotThrow(() -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - } - - @Test - void validateRejectsWithResponseInvalidWhenPassedFalseWithEmptyErrors() { - LLMClient llmClient = new RecordingClient("{\"passed\":false,\"errors\":[]}"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - PromptComplianceCheckException error = assertThrows( - PromptComplianceCheckException.class, - () -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - - assertEquals("llm.response_invalid", error.getCode()); - assertEquals("slot_validation", error.getStage()); - assertEquals(Map.of("step", "semantic_validation"), error.getFacts()); - } - - @Test - void validateRejectsWithResponseInvalidWhenPayloadIsNotJson() { - LLMClient llmClient = new RecordingClient("not-json"); - LlmBackedPromptSemanticValidator validator = - new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); - - PromptComplianceCheckException error = assertThrows( - PromptComplianceCheckException.class, - () -> validator.validate( - "## notification_topic\nIncident\n", - new ProcessedPromptMetadata( - "subscribe-incident", - "zh-CN", - "## notification_topic\n{{notification_topic}}\n", - Map.of(SLOT_NAME, "Incident")))); - - assertEquals("llm.response_invalid", error.getCode()); - assertEquals("slot_validation", error.getStage()); - } - - private static final class RecordingClient implements LLMClient { - private final String payload; - - private RecordingClient(String payload) { - this.payload = payload; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - } -} +package net.openan.a2at.sdk.server.validation; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; +import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; +import org.junit.jupiter.api.Test; + +class LlmBackedPromptSemanticValidatorTest { + + private static final String SLOT_NAME = "notification_topic"; + + private static final PromptSlotSchemaLoader SLOT_SCHEMA_LOADER = (scenarioCode, language) -> new PromptSlotSchema( + scenarioCode, + List.of(new PromptSlotDefinition(SLOT_NAME, true, "string", null, null, null, null, null, null, null))); + + @Test + void validatePassesWhenSemanticValidatorApprovesSlots() { + LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":[]}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + assertDoesNotThrow(() -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + } + + @Test + void validatePassesWhenSemanticValidatorReturnsFormattedJson() { + LLMClient llmClient = new RecordingClient( + """ + { + "passed": true, + "errors": [] + } + """); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + assertDoesNotThrow(() -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + } + + @Test + void validateReturnsCatalogCodeWhenSemanticValidatorRejectsSlots() { + LLMClient llmClient = new RecordingClient("{\"passed\":false,\"errors\":[{\"slot_name\":\"notification_topic\"," + + "\"code\":\"slot.semantic_conflict\"," + + "\"facts\":{\"slot_label\":\"notification_topic\",\"reason\":\"topic does not match\"}}]}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + PromptComplianceCheckException error = assertThrows( + PromptComplianceCheckException.class, + () -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + + assertEquals("slot.semantic_conflict", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertTrue(error.getMessage().contains("notification_topic"), "message must render the slot label"); + assertTrue(error.getMessage().contains("topic does not match"), "message must render the conflict reason"); + assertEquals(Map.of("slot_label", "notification_topic", "reason", "topic does not match"), error.getFacts()); + } + + @Test + void validateMapsUnknownCodeToSlotRuleViolationFallback() { + LLMClient llmClient = new RecordingClient("{\"passed\":false,\"errors\":[{\"slot_name\":\"notification_topic\"," + + "\"code\":\"data_problem\"," + + "\"facts\":{\"slot_label\":\"notification_topic\"}}]}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + PromptComplianceCheckException error = assertThrows( + PromptComplianceCheckException.class, + () -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + + assertEquals("slot.rule_violation", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertTrue(error.getMessage().contains("notification_topic"), "message must render the slot label"); + } + + @Test + void validateRejectsWhenPassedTrueButErrorsNonEmpty() { + LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":[{\"slot_name\":\"notification_topic\"," + + "\"code\":\"slot.semantic_conflict\"," + + "\"facts\":{\"slot_label\":\"notification_topic\",\"reason\":\"topic does not match\"}}]}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + PromptComplianceCheckException error = assertThrows( + PromptComplianceCheckException.class, + () -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + + assertEquals("slot.semantic_conflict", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertTrue(error.getMessage().contains("topic does not match"), "message must render the conflict reason"); + } + + @Test + void validatePassesWhenPassedTrueAndErrorsMissing() { + LLMClient llmClient = new RecordingClient("{\"passed\":true}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + assertDoesNotThrow(() -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + } + + @Test + void validatePassesWhenPassedTrueAndErrorsNull() { + LLMClient llmClient = new RecordingClient("{\"passed\":true,\"errors\":null}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + assertDoesNotThrow(() -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + } + + @Test + void validateRejectsWithResponseInvalidWhenPassedFalseWithEmptyErrors() { + LLMClient llmClient = new RecordingClient("{\"passed\":false,\"errors\":[]}"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + PromptComplianceCheckException error = assertThrows( + PromptComplianceCheckException.class, + () -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + + assertEquals("llm.response_invalid", error.getCode()); + assertEquals("slot_validation", error.getStage()); + assertEquals(Map.of("step", "semantic_validation"), error.getFacts()); + } + + @Test + void validateRejectsWithResponseInvalidWhenPayloadIsNotJson() { + LLMClient llmClient = new RecordingClient("not-json"); + LlmBackedPromptSemanticValidator validator = + new LlmBackedPromptSemanticValidator(llmClient, SLOT_SCHEMA_LOADER, "semantic system", "semantic user"); + + PromptComplianceCheckException error = assertThrows( + PromptComplianceCheckException.class, + () -> validator.validate( + "## notification_topic\nIncident\n", + new ProcessedPromptMetadata( + "subscribe-incident", + "zh-CN", + "## notification_topic\n{{notification_topic}}\n", + Map.of(SLOT_NAME, "Incident")))); + + assertEquals("llm.response_invalid", error.getCode()); + assertEquals("slot_validation", error.getStage()); + } + + private static final class RecordingClient implements LLMClient { + private final String payload; + + private RecordingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} From 597be45e6bcb72ab60a9cfb5350d0ee1418bdf96 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Wed, 9 Sep 2026 11:58:14 +0800 Subject: [PATCH 264/295] feat(corpus): rewrite a2a-t-corpus as a data-driven accuracy verification module Rebuild the corpus module around a real-LLM accuracy workflow: - JSON case files with ordered input/expected step arrays (unlimited steps, $fromStep cross-step references), strict load-time validation and runtime scenario auto-discovery, so new scenarios need zero Java changes - per-step API/LLM interaction transcripts with full request/response dumps, latency and token metrics, plus 95%-annotated accuracy summaries and CSV tooling (inputCsvToJson / outputJsonToCsv) - Task-T pilot suites (FromText / FromData) with the private-line-complaint scenario, plus a structural self-guard meta test - module stays out of packaging (deploy/source/javadoc skipped) and out of CI (-pl '!a2a-t-corpus'); .env keys reuse the root A2AT_LLM_* env.example names Also: - fix(llm): report finish_reason in the OpenAIClient empty-content error for rate-limit/timeout/max-token diagnosis - chore(build): set surefire failIfNoSpecifiedTests=false so -Dtest runs under -am do not fail in upstream modules - chore: drop obsolete corpus tooling and the root client.env/server.env samples Signed-off-by: zhangwenjun-huawei --- .github/workflows/ci.yml | 3 +- README.md | 2 +- README_zh.md | 2 +- a2a-t-corpus/README.md | 252 ++-- a2a-t-corpus/README_zh.md | 104 ++ a2a-t-corpus/env.example | 23 + a2a-t-corpus/pom.xml | 42 +- a2a-t-corpus/schemas/input-case.schema.json | 99 ++ .../schemas/output-result.schema.json | 54 + .../openan/a2at/sdk/corpus/CaseEngine.java | 944 ------------ .../a2at/sdk/corpus/CaseEngineTest.java | 870 ----------- .../openan/a2at/sdk/corpus/ContextSpec.java | 24 - .../net/openan/a2at/sdk/corpus/Contract.java | 94 -- .../a2at/sdk/corpus/CorpusContractTest.java | 576 -------- .../a2at/sdk/corpus/CorpusLoadException.java | 34 - .../sdk/corpus/CorpusSensitivitySelfTest.java | 475 ------ .../openan/a2at/sdk/corpus/CorpusSuites.java | 136 -- .../openan/a2at/sdk/corpus/Expectation.java | 128 -- .../sdk/corpus/FromDataCorpusSuiteTest.java | 26 - .../sdk/corpus/FromTextCorpusSuiteTest.java | 27 - .../net/openan/a2at/sdk/corpus/LiveCase.java | 69 - .../a2at/sdk/corpus/LiveCaseEngine.java | 480 ------ .../a2at/sdk/corpus/LiveCaseEngineTest.java | 282 ---- .../a2at/sdk/corpus/LiveCaseResult.java | 68 - .../a2at/sdk/corpus/LiveCorpusSuiteTest.java | 71 - .../a2at/sdk/corpus/LiveExpectation.java | 40 - .../openan/a2at/sdk/corpus/LiveLlmCall.java | 50 - .../openan/a2at/sdk/corpus/LiveLlmConfig.java | 218 --- .../a2at/sdk/corpus/LiveLlmConfigTest.java | 169 --- .../a2at/sdk/corpus/LiveLlmEnvWriter.java | 78 - .../a2at/sdk/corpus/LiveLlmEnvWriterTest.java | 60 - .../a2at/sdk/corpus/LiveRunSummary.java | 31 - .../a2at/sdk/corpus/LiveTranscript.java | 215 --- .../a2at/sdk/corpus/LiveTranscriptTest.java | 211 --- .../openan/a2at/sdk/corpus/LlmFailMarker.java | 63 - .../net/openan/a2at/sdk/corpus/LlmScript.java | 20 - .../openan/a2at/sdk/corpus/LlmScriptStep.java | 27 - .../openan/a2at/sdk/corpus/LoadedCorpus.java | 47 - .../a2at/sdk/corpus/NegotiationApi.java | 131 -- .../a2at/sdk/corpus/NegotiationCase.java | 72 - .../sdk/corpus/NegotiationCaseLoader.java | 1281 ----------------- .../sdk/corpus/NegotiationCaseLoaderTest.java | 1130 --------------- .../openan/a2at/sdk/corpus/PromptSource.java | 33 - .../a2at/sdk/corpus/RecordingLLMClient.java | 108 -- .../sdk/corpus/RecordingLLMClientTest.java | 170 --- .../openan/a2at/sdk/corpus/ScenarioCase.java | 165 --- .../sdk/corpus/ScenarioCorpusSuiteTest.java | 27 - .../a2at/sdk/corpus/ScenarioEngine.java | 345 ----- .../a2at/sdk/corpus/ScenarioEngineTest.java | 517 ------- .../corpus/ScriptedNegotiationLlmClient.java | 175 --- .../ScriptedNegotiationLlmClientTest.java | 137 -- .../a2at/sdk/corpus/TaskApiAssembler.java | 164 --- .../a2at/sdk/corpus/TypedInputAssembler.java | 205 --- .../sdk/corpus/ValidateCorpusSuiteTest.java | 26 - .../corpus/engine/CorpusWorkFlowSuite.java | 139 ++ .../assertion/ExpectationComparator.java | 130 ++ .../corpus/engine/config/CorpusEnvConfig.java | 296 ++++ .../engine/discover/ScenarioScanner.java | 136 ++ .../engine/engine/FromStepResolver.java | 98 ++ .../sdk/corpus/engine/engine/StepOutcome.java | 62 + .../corpus/engine/engine/WorkflowEngine.java | 216 +++ .../engine/llm/RecordingMeteredLlmClient.java | 98 ++ .../corpus/engine/loader/CaseFileLoader.java | 305 ++++ .../engine/loader/CorpusLoadException.java | 9 + .../corpus/engine/loader/ExpectedStep.java | 12 + .../sdk/corpus/engine/loader/InputCase.java | 12 + .../sdk/corpus/engine/loader/InputStep.java | 6 + .../corpus/engine/registry/ApiHandler.java | 15 + .../corpus/engine/registry/ApiRegistry.java | 32 + .../corpus/engine/registry/ClientApis.java | 70 + .../engine/registry/SdkRuntimeAssembler.java | 92 ++ .../corpus/engine/registry/ServerApis.java | 28 + .../engine/report/ResultAggregator.java | 269 ++++ .../engine/report/TranscriptWriter.java | 88 ++ .../engine/util/ThrowableSerializer.java | 84 ++ .../golden/GoldenFixtureComparisonTest.java | 183 --- .../golden/GoldenFixtureGeneratorTest.java | 48 - .../a2at/sdk/corpus/golden/GoldenInputs.java | 472 ------ .../ErrorCodePartitionPropertyTest.java | 262 ---- .../FromDataDeterminismPropertyTest.java | 141 -- .../FromDataRoundTripPropertyTest.java | 154 -- .../LargeParamsMergePropertyTest.java | 111 -- .../NegotiationErrorPathPropertyTest.java | 332 ----- .../corpus/property/PropertyArbitraries.java | 312 ---- .../sdk/corpus/property/PropertyHarness.java | 179 --- .../sdk/corpus/self/CorpusSelfGuardTest.java | 63 + .../task/TaskTFromDataWorkFlowTest.java | 31 + .../task/TaskTFromTextWorkFlowTest.java | 32 + .../input_case_from_data.json | 197 +++ .../input_case_from_text.json | 251 ++++ .../src/test/resources/golden/en-US/abort.md | 5 - .../golden/en-US/feasibility_accept.md | 5 - .../golden/en-US/feasibility_propose.md | 6 - .../en-US/feasibility_propose_confirm.md | 5 - .../golden/en-US/feasibility_reject.md | 5 - .../golden/en-US/information_accept.md | 6 - .../golden/en-US/information_propose.md | 8 - .../golden/en-US/information_reject.md | 5 - .../resources/golden/en-US/target_accept.md | 5 - .../resources/golden/en-US/target_propose.md | 10 - .../golden/en-US/target_propose_confirm.md | 5 - .../resources/golden/en-US/target_reject.md | 5 - .../src/test/resources/golden/zh-CN/abort.md | 5 - .../golden/zh-CN/feasibility_accept.md | 5 - .../golden/zh-CN/feasibility_propose.md | 6 - .../zh-CN/feasibility_propose_confirm.md | 5 - .../golden/zh-CN/feasibility_reject.md | 5 - .../golden/zh-CN/information_accept.md | 6 - .../golden/zh-CN/information_propose.md | 8 - .../golden/zh-CN/information_reject.md | 5 - .../resources/golden/zh-CN/target_accept.md | 5 - .../resources/golden/zh-CN/target_propose.md | 10 - .../golden/zh-CN/target_propose_confirm.md | 5 - .../resources/golden/zh-CN/target_reject.md | 5 - .../test/resources/negotiation-cases/INDEX.md | 384 ----- .../negotiation-cases/corpus-schema.json | 757 ---------- .../negotiation-cases/from-data/happy.json | 326 ----- .../from-data/programming-errors.json | 355 ----- .../from-text/extraction-failures.json | 272 ---- .../negotiation-cases/from-text/happy.json | 437 ------ .../from-text/programming-errors.json | 145 -- .../negotiation-cases/from-text/retry.json | 288 ---- .../from-text/template-resolution.json | 78 - .../negotiation-cases/live/task-apis.json | 266 ---- .../scenarios/boundary-flows.json | 1084 -------------- .../scenarios/feasibility-flows.json | 423 ------ .../scenarios/information-flows.json | 464 ------ .../scenarios/target-flows.json | 361 ----- .../shared/llm-responses.json | 37 - .../negotiation-cases/shared/schemas.json | 91 -- .../validate/drift-probes.json | 102 -- .../validate/error-code-mapping.json | 104 -- .../negotiation-cases/validate/happy.json | 248 ---- .../validate/llm-shape-and-retry.json | 234 --- .../validate/merge-and-schema.json | 131 -- .../validate/programming-errors.json | 329 ----- .../negotiation-cases/validate/rule-gate.json | 180 --- .../validate/semantic-verdicts.json | 376 ----- .../csv-templates/input-case-template.csv | 2 + a2a-t-corpus/tools/inputCsvToJson.py | 298 ++++ a2a-t-corpus/tools/outputJsonToCsv.py | 139 ++ .../a2at/sdk/llm/providers/OpenAIClient.java | 17 +- client.env | 10 - pom.xml | 5 + server.env | 12 - tools/corpus_index.py | 363 ----- tools/corpus_review.py | 819 ----------- tools/live_transcript_export.py | 201 --- 148 files changed, 3644 insertions(+), 21599 deletions(-) create mode 100644 a2a-t-corpus/README_zh.md create mode 100644 a2a-t-corpus/env.example create mode 100644 a2a-t-corpus/schemas/input-case.schema.json create mode 100644 a2a-t-corpus/schemas/output-result.schema.json delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ContextSpec.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Contract.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusLoadException.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSuites.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromDataCorpusSuiteTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromTextCorpusSuiteTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCase.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngine.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngineTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseResult.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCorpusSuiteTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveExpectation.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmCall.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmConfig.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmConfigTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmEnvWriter.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmEnvWriterTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveRunSummary.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveTranscript.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveTranscriptTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmFailMarker.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScript.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScriptStep.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LoadedCorpus.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationApi.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCase.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoader.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/PromptSource.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/RecordingLLMClient.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/RecordingLLMClientTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCase.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCorpusSuiteTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngine.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClient.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClientTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ValidateCorpusSuiteTest.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/CorpusWorkFlowSuite.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/assertion/ExpectationComparator.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/config/CorpusEnvConfig.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/discover/ScenarioScanner.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/FromStepResolver.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/StepOutcome.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/WorkflowEngine.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/llm/RecordingMeteredLlmClient.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CaseFileLoader.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CorpusLoadException.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/ExpectedStep.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputCase.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputStep.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiHandler.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiRegistry.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ClientApis.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/SdkRuntimeAssembler.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ServerApis.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/ResultAggregator.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/TranscriptWriter.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/util/ThrowableSerializer.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenFixtureComparisonTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenFixtureGeneratorTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenInputs.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/ErrorCodePartitionPropertyTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/FromDataDeterminismPropertyTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/FromDataRoundTripPropertyTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/LargeParamsMergePropertyTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/NegotiationErrorPathPropertyTest.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyArbitraries.java delete mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/property/PropertyHarness.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/self/CorpusSelfGuardTest.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/TaskTFromDataWorkFlowTest.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/TaskTFromTextWorkFlowTest.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/private-line-complaint/input_case_from_data.json create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/private-line-complaint/input_case_from_text.json delete mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/abort.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/feasibility_accept.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/feasibility_propose.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/feasibility_propose_confirm.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/feasibility_reject.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/information_accept.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/information_propose.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/information_reject.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/target_accept.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/target_propose.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/target_propose_confirm.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/en-US/target_reject.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/abort.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_accept.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_propose.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_propose_confirm.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/feasibility_reject.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/information_accept.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/information_propose.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/information_reject.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/target_accept.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/target_propose.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/target_propose_confirm.md delete mode 100644 a2a-t-corpus/src/test/resources/golden/zh-CN/target_reject.md delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/INDEX.md delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/corpus-schema.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/from-data/happy.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/from-data/programming-errors.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/from-text/extraction-failures.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/from-text/happy.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/from-text/programming-errors.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/from-text/retry.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/from-text/template-resolution.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/live/task-apis.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/boundary-flows.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/information-flows.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/target-flows.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/shared/schemas.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/validate/drift-probes.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/validate/error-code-mapping.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/validate/happy.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/validate/merge-and-schema.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/validate/rule-gate.json delete mode 100644 a2a-t-corpus/src/test/resources/negotiation-cases/validate/semantic-verdicts.json create mode 100644 a2a-t-corpus/tools/csv-templates/input-case-template.csv create mode 100644 a2a-t-corpus/tools/inputCsvToJson.py create mode 100644 a2a-t-corpus/tools/outputJsonToCsv.py delete mode 100644 client.env delete mode 100644 server.env delete mode 100644 tools/corpus_index.py delete mode 100644 tools/corpus_review.py delete mode 100644 tools/live_transcript_export.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af1717e7..e6cabb11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,4 +30,5 @@ jobs: run: python tools/template_lint.py --resource-root a2a-t-resources/src/main/resources/prompt_resources - name: Build and run tests - run: mvn -B clean verify + # a2a-t-corpus is the accuracy-verification test module: it must run against a real LLM, which CI does not provide, so exclude it. + run: mvn -B clean verify -pl '!a2a-t-corpus' diff --git a/README.md b/README.md index af0cb48c..ee082e05 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ The repository is organized as Maven multi-modules, with core code under each mo | `a2a-t-negotiation` | Negotiation types, runtime state machine, and state storage | | `a2a-t-client` | Client facade providing task prompt generation and negotiation entry points (`A2ATClient`) | | `a2a-t-server` | Server facade providing A2A-T protocol message validation and negotiation entry points (`A2ATServer`) | -| `a2a-t-corpus` | Conformance test corpus (pure test module) with data-driven cases and property-based tests | +| `a2a-t-corpus` | Accuracy verification corpus (pure test module): data-driven workflow cases executed against a real LLM; excluded from packaging and CI | | `a2a-t-sample` | Runnable client/server samples | Each module's `src/test/java` mirrors the main package structure, covering the negotiation state machine, negotiation handlers, resource loaders, prompt rendering, and client/server orchestration. diff --git a/README_zh.md b/README_zh.md index 9cc4bcd3..d127ba14 100644 --- a/README_zh.md +++ b/README_zh.md @@ -82,7 +82,7 @@ flowchart LR | `a2a-t-negotiation` | 协商类型、运行时状态机与状态存储 | | `a2a-t-client` | 客户端封装,提供任务提示词生成与协商入口(`A2ATClient`) | | `a2a-t-server` | 服务端封装,提供 A2A-T 协议报文的校验与协商入口(`A2ATServer`) | -| `a2a-t-corpus` | 一致性测试语料(纯测试模块),含数据驱动用例与属性测试 | +| `a2a-t-corpus` | 准确性验证语料(纯测试模块):数据驱动工作流用例,需配置真实 LLM 执行;不参与打包与 CI | | `a2a-t-sample` | 可运行的客户端/服务端示例 | 各模块的 `src/test/java` 与 main 包结构对应,覆盖协商状态机、协商处理器、资源加载器、提示词渲染以及客户端/服务端编排等测试用例。 diff --git a/a2a-t-corpus/README.md b/a2a-t-corpus/README.md index 2b0c57af..72ec921e 100644 --- a/a2a-t-corpus/README.md +++ b/a2a-t-corpus/README.md @@ -1,176 +1,134 @@ -# a2a-t-corpus — A2A-T 一致性测试语料模块 +# a2a-t-corpus - A2A-T Accuracy Verification Corpus -## 这个模块是什么 +Data-driven accuracy verification for the A2A-T SDK: workflow cases written as JSON run against a **real LLM** +through the production SDK assembly path, with per-step API/LLM transcripts, latency and token metrics, and 95%+ accuracy +target reporting for prompt tuning. -`a2a-t-corpus` 是 A2A-T SDK 的**协议一致性测试语料模块**:用数据驱动的方式,验证 SDK 帮助上层 A2A 智能体完成两件核心事情的端到端正确性—— +Phase 1 (current) implements **Task-T only**, as the benchmark pilot; Notification-T / Negotiation-T / +Authorization-T follow the same structure once Task-T stabilizes (suite class + `resources/` + registry entries, +zero fork). -1. **生成符合规范的 Prompt**:从自然语言或结构化数据生成 Task-T 任务报文与 Negotiation-T 协商报文(propose / accept / reject / abort); -2. **从交互 Prompt 中提取参数**:对端发来的报文经校验管线完成规则校验、语义校验与参数提取(含合并、缺参发现)。 +- **Pure test module**: no `src/main`, not in the BOM, excluded from release artifacts + (`maven.deploy.skip/source.skip/javadoc.skip=true`) and from CI (`mvn ... -pl '!a2a-t-corpus'`). +- **A real LLM is mandatory**: running without the three required `A2AT_LLM_*` values fails fast with guidance. + The `.env` keys reuse the project-root `env.example` naming, so the corpus template is a tuned subset of the + root one. -模块为**纯测试模块**(无 `src/main`,jar 打包不进 BOM),在 reactor 中位于 `a2a-t-server` 之后、`a2a-t-sample` 之前,依赖 `a2a-t-client` / `a2a-t-server` / `a2a-t-negotiation`——因此能同时编排客户端 facade、服务端 facade 与协商内容服务,测试**跨模块的闭环行为**。这是它独立成模块的原因:单 API 用例与多步闭环场景共享同一套 harness 与业务事实,任何上游模块都覆盖不了全部协议扩展。 - -## 为什么采用"数据驱动语料" - -- **用例即数据**:一个用例 = 一条自描述的 JSON 记录,新增用例零 Java 改动; -- **期望精确到键**:行为变更时红色 diff 直达 `expect.code: A != B`,修复 = 一次 JSON 编辑; -- **可查询**:用例 id 全局唯一(grep/jq 主键),`INDEX.md` 是机器生成的活文档(覆盖矩阵 + 逐用例索引); -- **AI 可编写**:格式有正式 JSON Schema(`negotiation-cases/corpus-schema.json`),配套缺口驱动的编写流程。 - -## 目录结构 +## Layout ``` a2a-t-corpus/ -├── README.md ← 本文件 -├── pom.xml -└── src/test/ - ├── java/net/openan/a2at/sdk/corpus/ - │ ├── NegotiationCaseLoader.java 语料加载(严格解析 + fail-fast 校验,含 live 家族独立绑定) - │ ├── ScriptedNegotiationLlmClient.java 脚本化 LLM 桩(离线家族的唯一测试接缝) - │ ├── CaseEngine.java 单用例引擎(API 分发 + 期望比对 + 契约断言) - │ ├── ScenarioEngine.java 多步场景引擎(跨步引用 + 流级断言) - │ ├── TaskApiAssembler.java task API 真实装配(builder 委托) - │ ├── Contract.java 行为契约注册表(12 个,P0/P1 分级) - │ ├── Live*.java / RecordingLLMClient.java live 家族:真实 LLM 配置门禁、.env 桥、录制客户端、 - │ │ 引擎与 transcript(见下文"live 家族") - │ ├── *CorpusSuiteTest.java 五个 @TestFactory 套件(from-text / from-data / validate / scenario / live) - │ ├── CorpusContractTest.java 元测试:覆盖完整性、双语 parity、评审门禁、live 契约 - │ ├── CorpusSensitivitySelfTest.java 元元测试:翻转期望断言引擎必红(防橡皮图章) - │ ├── property/ jqwik 属性测试层(round-trip / 幂等 / 错误码分区 / 大参数合并) - │ └── golden/ golden fixture 生成器与一致性守卫 - └── resources/ - ├── negotiation-cases/ - │ ├── corpus-schema.json 语料格式的一手定义(JSON Schema,含 live 记录定义) - │ ├── INDEX.md 自动生成的覆盖矩阵与用例索引(勿手编) - │ ├── shared/ 共享 LLM payload(llm-responses.json)与 JSON Schema 变体(schemas.json) - │ ├── from-text/ fromText 家族用例(happy / 编程错误 / 模板解析 / 抽取失败 / 重试) - │ ├── from-data/ fromData 家族用例(happy / 编程错误) - │ ├── validate/ validate 家族用例(happy / 规则门 / 语义判定 / LLM 形态与重试 / - │ │ 合并与 schema / 错误码映射 / 对端报文漂移探针 / 编程错误) - │ ├── live/ live 家族用例(真实 LLM,zh-CN,TASK 生成 + 验证,门禁控制启停) - │ └── scenarios/ E2E 场景(信息/目标/可行性协商流 + 边界流) - └── golden/{zh-CN,en-US}/ golden fixture(模板渲染的基准输出,含 abort) -``` - -## 语料速览 - -当前规模:**151 个用例 + 18 个场景 = 238 条基记录**(双语展开后 226 个执行单元),业务域统一为**专线业务投诉诊断**(工作台智能体 ↔ SPN OMC 智能体的缺参协商闭环)。另有 **live 家族 7 条 zh-CN 记录**(真实 LLM 验证,见下节)。 - -### 单用例(case 记录) - -```json -{ - "id": "FT-RETRY-02", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["retry", "extract-failed"], - "summary": "大模型第一次返回不可解析内容、第二次返回合法接受报文,重试后成功", - "context": { "id": "3dbc13b5-...", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "我确认补充:接入端口名称 P533-……" } }, - "llm": { "maxAttempts": 3, "script": [ { "$fail": "non-json" }, { "$ref": "responses/extract.information.accept.full" } ] }, - "expect": { "outcome": "success", "llmCalls": 2, "promptTextEqualsGolden": "information_accept" } -} +├── pom.xml / env.example / .env (secrets, gitignored) / README.md / README_zh.md +├── schemas/ input-case.schema.json, output-result.schema.json (first-hand format definitions) +├── tools/ inputCsvToJson(.py/.sh), outputJsonToCsv(.py/.sh), csv-templates/ +└── src/test/java/net/openan/a2at/sdk/corpus/ + ├── engine/ framework core: config/loader/registry/engine/llm/assertion/report/discover + CorpusWorkFlowSuite + ├── task/ TaskTFromTextWorkFlowTest, TaskTFromDataWorkFlowTest, resources// + └── self/ corpus self-guard meta test (no LLM) ``` -要点: - -- **LLM 脚本步三态**:`{"$ref": "responses/名称"}` 引共享 payload;`{"$fail": "标记"}` 注入失败(六种标记:runtime-exception / llm-error / null-response / blank-content / non-json / assertion);字面 JSON 串内联。桩**耗尽即抛**(failOnOverconsumption),杜绝 repeat-last 掩盖错误期望。 -- **`languages` 加载期展开**:`["zh-CN","en-US"]` 展开为两个执行单元(id 加 `/zh-CN`、`/en-US` 后缀),happy 用例强制双语 parity。 -- **失败期望块**错误码是一等公民:`expect.code` / `expect.slotErrors`(槽+错误码精确匹配)/ `expect.llmCalls`(精确整数)。 -- **差分断言**:happy 用例同时携带 `input.text` 与 `input.data`,引擎双跑断言 fromText == fromData == golden 三方相等。 +`task/resources//` holds `input_case_from_text.json` and `input_case_from_data.json` (case design, +hand-authored); `output_result_*.json` files are written back by the engine after each run. -### 场景(scenario 记录) +## Quick start -多步闭环按样例报文建模,如投诉诊断主链路: - -``` -step 1 工作台(A) generateTaskPromptFromText 用户投诉 → 任务报文 -step 2 OMC(B) validateTaskPromptAndDataFilling 校验任务报文 → 发现缺参(接入端口名称/投诉分类) -step 3 OMC(B) generateNegotiationProposeFromData 缺参驱动发起协商(所需信息项带举例) -step 4 工作台(A) generateNegotiationAcceptFromText 补参 -step 5 OMC(B) validateAcceptPromptAndDataFilling 提取补充参数 +```bash +cp a2a-t-corpus/env.example a2a-t-corpus/.env # fill A2AT_LLM_BASE_URL / API_KEY / MODEL +mvn -pl a2a-t-corpus -am test -Dtest=TaskTFromTextWorkFlowTest +mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario=private-line-complaint +mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario='private-*' -Dcase.filter='TC0000000*' ``` -场景级因果断言:`expectFlow.missingParamsFilled` 保证**提取到的补参 == 发现的缺参**;另有 `terminalCondition`(accept/reject/abort/exhausted)、`roundsUsed`、`distinctMessages`。角色语义固定:**A = 工作台(客户端,任务发起/补数方),B = OMC(服务端,执行/要数方,协商发起方)**。 - -## live 家族:真实 LLM 验证 +- `-Dcorpus.scenario`: scenario name glob (`*` wildcard, comma-separated); `-Dcase.filter`: case id glob. +- `-Dtest` filters apply reactor-wide under `-am`; the parent POM configures surefire + `failIfNoSpecifiedTests=false`, so upstream modules without a matching test class do not fail the build. +- Every executed case is printed to the console as it completes; failed/crashed cases print their full interaction + trace there too. The transcript file reflects exactly this run (a filtered run overwrites it with the matching + cases only). +- `-Dcorpus.output.dir=

            `: redirect outputs instead of writing back into the scenario directories + (summaries default to `target/corpus/`). +- Structure-only gate without an LLM: `mvn -pl a2a-t-corpus test -Dtest=CorpusSelfGuardTest`. -上面四个家族用脚本化 LLM 保证离线确定性;live 家族回答另一个问题——**我们的 prompt 在客户量级的真实模型(如 qwen3-27b)上到底好不好用**。配置了测试 LLM 端点时,用真实 OpenAI 兼容 `/v1/chat/completions` 接口端到端跑任务 prompt 生成与校验;未配置时套件自动跳过,`mvn test` / CI 始终离线确定。 +## Case JSON contract (v1, first-hand definitions in `schemas/`) -- **门禁**(与生产 `A2AT_LLM_*` 完全解耦):环境变量 `A2AT_TEST_LLM_BASE_URL` / `A2AT_TEST_LLM_API_KEY` / `A2AT_TEST_LLM_MODEL` 三项齐备才启用;系统属性 `-Da2at.test.llm.base.url` 等优先于环境变量,空值视为未配置。缺失 → assumeTrue 跳过并打印配置提示;配了但非法 → 红灯。 -- **用例形态**:`live/` 目录独立记录(`LIVE-` 前缀,zh-CN,仅 `generateTaskPromptFromText` / `validateTaskPromptAndDataFilling` 两个 TASK API),加载进独立的 `LoadedCorpus.liveCases` 列表,不影响离线家族与契约。 -- **宽松期望块**(针对小模型输出抖动):`expect.success` + `scenarioCode` + `paramsContains`(关键槽位**子集**断言)+ `paramsAbsent` + `promptTextContains` + `maxLlmCalls`(**上限**而非精确值,缺省 4)。 -- **接缝**:真实 `OpenAIClient` 经 `LiveLlmConfig` 从测试变量构造,`RecordingLLMClient` 装饰后注入与离线家族相同的 builder `llmClient(...)` 接缝——装配路径与生产完全一致;`.env` 桥(`LiveLlmEnvWriter`)显式钉住 `A2AT_LLM_TEMPERATURE=0` / `TIMEOUT_SECONDS=60` / `MAX_ATTEMPTS=3`。 -- **抖动策略**:仅基础设施错误(`LLMRuntimeError`/超时/连接失败)测试层重试(`-Dcorpus.live.infraRetries`,默认 2),耗尽记 ERROR 保持红灯;**断言失败不重试**——那是 prompt 质量信号。 -- **transcript**:每次运行写 `a2a-t-corpus/target/live-corpus/<时间戳>/`——`transcript.json`(逐用例输入、每次 LLM 调用的完整请求/响应、解析结果、判定、token 统计)+ `summary.json`(含 **schema 解析失败率**,即模型对 json_object + prompt 注入 schema 的遵从度,是评估是否需要原生 json_schema 的依据)。 -- **评审导出**:`python tools/live_transcript_export.py ` 把 transcript 渲染成单文件 `export/report.md`(逐用例的输入、完整请求消息、schema、原始响应、解析参数、判定,以及每次 LLM 调用**内联的可重放请求体**——与生产 `OpenAIClient` 完全一致的 `/v1/chat/completions` JSON,含 JSON-mode 系统消息与 schema 注入)。请求体可直接拷贝进 Postman(`POST $A2AT_TEST_LLM_BASE_URL/chat/completions`,Bearer 鉴权),改 prompt 后在套件外对真实端点迭代调优。 - -按裁决 live 运行**仅本地,不入 CI**。 - -## 测试接缝设计(为什么测试可信) - -- **单一脚本化接缝**:除 LLM 客户端外全部为**生产装配**——真实 builder、真实模板加载、真实规则门、真实渲染器。task API 经 `TaskApiAssembler` 走 `DefaultA2ATClientBuilder` / `DefaultA2ATServerBuilder`(与 `A2ATClient` / `A2ATServer` facade 内部同一条装配路径),脚本 LLM 通过 builder 的 `llmClient(...)` 注入口贯穿闭环,因此 `expect.llmCalls` 对全链路精确校准。 -- **故障注入钩子**:`"inject": "failingTemplateLoader"` / `"failingSemanticValidator"` 映射到 builder 真实注入点,用于触发数据不可达的错误路径。 -- **双层防呆**: - - `CorpusContractTest`(元测试):7 个协商错误码每个至少一个失败用例、5 条内部错误码映射、happy 双语 parity、task API 仅经场景步骤调用、闭环角色绑定等契约; - - `CorpusSensitivitySelfTest`(元元测试):自动翻转各类期望的样本值断言引擎**必须红**——防止"永远绿的橡皮图章引擎",并区分引擎崩溃与断言失败。 -- **属性层**(jqwik,固定种子):fromData→validate round-trip 参数存活、生成幂等、每错误码的专门触发采样、50+ 键大参数合并(context 键冲突时 context 赢)、错误码分区(可重试 ⟺ {extract_failed, llm_infrastructure_error})。 - -## 如何运行 - -```bash -mvn -pl a2a-t-corpus -am test # 本模块及其依赖(CI 会随 mvn clean verify 全量跑;live 未配置自动跳过) -mvn -pl a2a-t-corpus test -Dtest=ValidateCorpusSuiteTest # 单个套件 -mvn -pl a2a-t-corpus test -Dcase.filter='FT-RETRY-*' # glob 过滤用例 id +```json +{ + "id": "TC00000001", "caseDimension": "01-意图清晰参数完整", "caseDesc": "标准专线业务中断投诉", + "caseCategory": "正常", + "input": [ + { "api": "generateTaskPromptFromText", + "args": { "text": "发生专线业务中断,接入端口名称为P781-……", "templateUri": "Task-T/network-layer/private-line-complaint/v1" } }, + { "api": "validateTaskPromptAndDataFilling", + "args": { "schema": { "…": "JSON Schema" }, "templateUri": "…", "promptText": { "$fromStep": 1, "$field": "promptText" } } } + ], + "expected": [ + { "result": "success", "data": {}, "error": { "errCode": "", "errMessage": "" } }, + { "result": "success", "data": { "accessPort": "P781-……" }, "error": { "errCode": "", "errMessage": "" } } + ] +} ``` -五个套件:`FromTextCorpusSuiteTest` / `FromDataCorpusSuiteTest` / `ValidateCorpusSuiteTest` / `ScenarioCorpusSuiteTest` / `LiveCorpusSuiteTest`。每条用例一个 DynamicTest,display name 带用例 id——CI 红色报告名即主键,`grep ` 直达唯一 JSON 文件。 - -live 套件对接真实模型(本地): +- `id` uses the `TC` (TestCase) prefix, e.g. `TC00000001`; ids are unique within one case file. +- `input` / `expected` are equal-length arrays; order is the execution order; any step count is allowed and the same + API may appear repeatedly. +- `expected.result`: `success` / `error`; `data` is a subset assertion (optional `dataExact` tightens to full + equality); `error.errCode` must be a code from the SDK error catalog. +- `{"$fromStep": N, "$field": "promptText"}` copies a field from the response of an earlier step (1-based); + literal values always win. +- `caseCategory` is an open enumeration, e.g. `正常` / `异常` / `边界` / `模糊语义` ... (aggregation key only, + never coupled to assertions). +- Loading is strict: unknown keys, missing expectations, mismatched array lengths, unregistered api names and + out-of-catalog error codes all fail at load time with aggregated diagnostics. +- Business payloads (input texts, schema descriptions, expected slot values) and the case metadata + (`caseDimension` / `caseDesc` / `caseCategory`) stay in Chinese because they describe business cases for the + zh-CN prompt resources; everything else (tooling, messages, code) is English. + +## Outputs and metrics + +- Per scenario: `output_result_from_text.json` / `output_result_from_data.json` with one record per case whose + interactions interleave **API steps and their underlying LLM calls**, carrying complete requests/responses + (error steps dump every exception property plus the cause chain), latency and tokens - complete output, never + truncated. +- `a2a-t-corpus/target/corpus/summary_report_.json` + console table: accuracy overall / per scenario / per + dimension / per category / per API with the **95% target line annotated**, latency p50/p95, token totals. + +## Tooling loop ```bash -mvn -pl a2a-t-corpus -am test -Dtest=LiveCorpusSuiteTest \ - -Da2at.test.llm.base.url=https://<端点>/v1 \ - -Da2at.test.llm.api.key=<密钥> \ - -Da2at.test.llm.model=<模型名> # 也可用 A2AT_TEST_LLM_* 环境变量 +python a2a-t-corpus/tools/inputCsvToJson.py --template --out my-cases.csv # design table (with an example row) +# fill the table, then convert with structural validation on by default +python a2a-t-corpus/tools/inputCsvToJson.py --csv my-cases.csv \ + --out a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources//input_case_from_text.json +# run a suite, then review (one row per case, full per-step request/response) +python a2a-t-corpus/tools/outputJsonToCsv.py \ + --json a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources//output_result_from_text.json \ + --out review.csv +# optional: fill input JSON back into the design table +python a2a-t-corpus/tools/inputCsvToJson.py --reverse --csv --out back.csv ``` -配套工具(仓库根目录): +Run the Python scripts directly on any platform; `tools/*.sh` are thin bash wrappers that forward every argument 1:1, +so any command above works by swapping `python a2a-t-corpus/tools/.py` for `tools/.sh`: ```bash -python tools/corpus_index.py # 重新生成 INDEX.md(语料变更后) -python tools/corpus_index.py --check # 校验 INDEX 新鲜度 -python tools/corpus_review.py export # 生成业务评审材料(HTML 仪表盘 + 可批注 xlsx,技术字段翻译为业务中文) -python tools/corpus_review.py collect --marked <批注后的文件> # 回读评审标记 → findings + review-status.json -python tools/live_transcript_export.py # 导出 live transcript 为评审报告 + 可重放请求 +tools/inputCsvToJson.sh --template --out my-cases.csv +tools/inputCsvToJson.sh --csv my-cases.csv --out /input_case_from_text.json +tools/outputJsonToCsv.sh --json /output_result_from_text.json --out review.csv +tools/inputCsvToJson.sh --reverse --csv --out back.csv ``` -## 如何扩展 - -### 新增一个用例 - -1. 打开 `negotiation-cases/corpus-schema.json`(格式一手定义)与同类别的现有 JSON 文件,模仿键序与风格; -2. 向对应类别文件 append 一个对象(id 按 `<家族前缀>-<类别>-<序号>` 全局唯一递增;`summary` 必须是面向业务的中文一句话); -3. 共享 LLM payload 放 `shared/llm-responses.json` 并以 `{"$ref": "responses/名称"}` 引用; -4. 跑 `mvn -pl a2a-t-corpus test -Dcase.filter=''` 验证,再重新生成 INDEX.md。 - -### 新增一个 API - -在 `NegotiationApi` 枚举加条目并在 `CaseEngine` 分发;若属于新的装配面(参照 `TaskApiAssembler` 的 builder 委托写法)。 - -### 接入新的协议扩展(Task-T 独立用例 / Notification-T / Authorization-T) +The wrappers (Git Bash / WSL / Linux / macOS) are **not** meant to be double-clicked: double-clicking starts them +without arguments, so they only print the usage text (and the terminal may close right away). Run them from a terminal +with the arguments shown above, or run `python a2a-t-corpus/tools/...` directly when no bash shell is available. The +wrappers resolve `python3` (falling back to `python`) from the terminal PATH and fail with a clear message when no +Python interpreter is installed. -本模块是四个 A2A-T 扩展共同的语料之家。接入步骤: +## Adding a scenario (zero Java changes) -1. 在 `negotiation-cases/` 下按扩展新建语料目录(如 `task-cases/`),复用 `shared/` 体系或建扩展专属共享文件; -2. `NegotiationApi` 加该扩展的 API 条目 + `CaseEngine` 分发 + 必要的 Assembler(一律真实 builder 装配 + 脚本 LLM 注入,禁止镜像生产代码); -3. 新增 `@TestFactory` 套件加载新目录,并在 `CorpusContractTest` 收紧对应覆盖契约; -4. `tools/corpus_index.py` / `corpus_review.py` 按目录扫描,加目录后刷新 INDEX 与评审投影即可。 +Create `task/resources//` with the two input JSON files - the `@TestFactory` suites discover scenarios at +runtime. Run `CorpusSelfGuardTest` (no LLM) as the structural gate first. -## 约定与守卫 +## Later phases (after Task-T stabilizes) -- 语料 JSON 由 loader **严格解析**(未知键、悬空 `$ref`、重复 id、期望块残缺一律加载期 fail-fast,报错点名文件+id+JSON 路径)——打错键名的红色正是守卫在工作; -- `INDEX.md` 为生成物,禁止手工编辑; -- golden fixture 由 `corpus.golden` 包的生成器产出(模板变更后重新生成); -- 评审软门禁默认关闭:`-Dcorpus.review.gate=true` 开启"P0 用例评审通过率 100%"断言(需先经 `corpus_review.py` collect 产生 review-status.json)。 +For Notification-T / Negotiation-T / Authorization-T: add `corpus//` suites plus their +`resources/`, register the extension facade methods in `ApiRegistry`, and reuse the framework and tools as-is. \ No newline at end of file diff --git a/a2a-t-corpus/README_zh.md b/a2a-t-corpus/README_zh.md new file mode 100644 index 00000000..d1519a2a --- /dev/null +++ b/a2a-t-corpus/README_zh.md @@ -0,0 +1,104 @@ +# a2a-t-corpus — A2A-T 准确性验证语料 + +A2A-T SDK 的数据驱动准确性验证:把 JSON 工作流用例经生产 SDK 装配链路对**真实 LLM** 执行,产出逐步 API/LLM 完整转录、时延与 Token 指标,并以 95%+ 准确率达标线支撑提示词调优。 + +阶段策略(当前)仅实现 **Task-T**,作为标杆迭代优化;Notification-T / Negotiation-T / Authorization-T 待 Task-T 稳定后按同结构扩展(套件类 + `resources/` + 注册表条目,零分叉)。 + +- **纯测试模块**:无 `src/main`,不进 BOM、不参与发布(`maven.deploy.skip/source.skip/javadoc.skip=true`),且从 CI 排除(`mvn ... -pl '!a2a-t-corpus'`)。 +- **必须配置真实 LLM**:缺少三个必填 `A2AT_LLM_*` 配置时 fail-fast 并给出配置指引。`.env` 键名与项目根 `env.example` 保持一致,corpus 模板即根模板的精调子集。 + +## 目录 + +``` +a2a-t-corpus/ +├── pom.xml / env.example / .env(密钥,gitignore)/ README.md / README_zh.md +├── schemas/ input-case.schema.json、output-result.schema.json(格式一手定义) +├── tools/ inputCsvToJson(.py/.sh)、outputJsonToCsv(.py/.sh)、csv-templates/ +└── src/test/java/net/openan/a2at/sdk/corpus/ + ├── engine/ 框架核心:config/loader/registry/engine/llm/assertion/report/discover + CorpusWorkFlowSuite + ├── task/ TaskTFromTextWorkFlowTest、TaskTFromDataWorkFlowTest、resources/<场景>/ + └── self/ 语料自守卫元测试(无 LLM) +``` + +`task/resources/<场景>/` 下为 `input_case_from_text.json` 与 `input_case_from_data.json`(用例设计,人工构造);`output_result_*.json` 由引擎在每次运行后写回。 + +## 快速开始 + +```bash +cp a2a-t-corpus/env.example a2a-t-corpus/.env # 填写 A2AT_LLM_BASE_URL / API_KEY / MODEL +mvn -pl a2a-t-corpus -am test -Dtest=TaskTFromTextWorkFlowTest +mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario=private-line-complaint +mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario='private-*' -Dcase.filter='TC0000000*' +``` + +- `-Dcorpus.scenario`:场景名 glob(`*` 通配,逗号分隔);`-Dcase.filter`:用例 id glob。 +- `-Dtest` 过滤在 `-am` 下作用于整个反应堆;父 POM 已配置 surefire `failIfNoSpecifiedTests=false`,上游模块无同名测试类不会导致构建失败。 +- 每条已执行用例完成即打印到控制台;失败/崩溃用例同时打印完整 interaction 轨迹;转录文件只反映本次执行(过滤运行会以命中的用例整体覆盖文件)。 +- `-Dcorpus.output.dir=`:把输出重定向到指定目录(缺省写回场景目录;summary 默认落 `target/corpus/`)。 +- 无 LLM 时先跑结构自守卫:`mvn -pl a2a-t-corpus test -Dtest=CorpusSelfGuardTest`。 + +## 用例 JSON 契约(v1,一手定义见 `schemas/`) + +```json +{ + "id": "TC00000001", "caseDimension": "01-意图清晰参数完整", "caseDesc": "标准专线业务中断投诉", + "caseCategory": "正常", + "input": [ + { "api": "generateTaskPromptFromText", + "args": { "text": "发生专线业务中断,接入端口名称为P781-……", "templateUri": "Task-T/network-layer/private-line-complaint/v1" } }, + { "api": "validateTaskPromptAndDataFilling", + "args": { "schema": { "…": "JSON Schema" }, "templateUri": "…", "promptText": { "$fromStep": 1, "$field": "promptText" } } } + ], + "expected": [ + { "result": "success", "data": {}, "error": { "errCode": "", "errMessage": "" } }, + { "result": "success", "data": { "accessPort": "P781-……" }, "error": { "errCode": "", "errMessage": "" } } + ] +} +``` + +- `id` 使用 `TC`(TestCase)前缀,如 `TC00000001`;id 在单个用例文件内唯一。 +- `input` / `expected` 为**等长数组**,顺序即执行序;步数不限,同一 API 可多次出现。 +- `expected.result`:`success` / `error`;`data` 为子集断言(可选 `dataExact` 收紧为全量相等);`error.errCode` 必须位于 SDK 错误码目录。 +- `{"$fromStep": N, "$field": "promptText"}` 按步序(1-based)引用更早步骤响应中的字段;显式字面值优先。 +- `caseCategory` 为开放枚举:`正常` / `异常` / `边界` / `模糊语义` …(仅作统计与组织维度,断言与之间无耦合)。 +- 加载为严格解析:未知键、缺期望、数组不等长、api 未注册、errCode 不在目录 → 加载期汇总报错。 +- 业务载荷(输入文本、schema 描述、期望槽位值)与用例元数据(`caseDimension` / `caseDesc` / `caseCategory`)保留中文,因为它们描述的是面向 zh-CN 提示词资源的业务用例;其余(工具、消息、代码)一律英文。 + +## 输出与指标 + +- 每场景:`output_result_from_text.json` / `output_result_from_data.json`,每个用例一条记录,其 interactions 交错记录 **API 步与其底层 LLM 调用**,携带完整请求/响应(异常步 dump 每个属性 + cause 链)、时延、Token——**完整输出,不截断**。 +- `a2a-t-corpus/target/corpus/summary_report_.json` + 控制台表:总体/按场景/按维度/按分类/按 API 的准确率(**95% 达标线标注**)、时延 p50/p95、Token 合计。 + +## 工具闭环 + +```bash +python a2a-t-corpus/tools/inputCsvToJson.py --template --out my-cases.csv # 用例设计表(含一行示例) +# 填表后转换(默认开启产物结构校验) +python a2a-t-corpus/tools/inputCsvToJson.py --csv my-cases.csv \ + --out a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/<场景>/input_case_from_text.json +# 跑套件后审视(一行=一个用例,含每步完整请求/响应) +python a2a-t-corpus/tools/outputJsonToCsv.py \ + --json a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/<场景>/output_result_from_text.json \ + --out review.csv +# 可选:把 input JSON 回填为设计表 +python a2a-t-corpus/tools/inputCsvToJson.py --reverse --csv --out back.csv +``` + +各平台直接运行 Python 脚本;`tools/*.sh` 为 bash 薄包装(Git Bash / WSL / Linux / macOS),参数 1:1 透传,把上面命令中的 `python a2a-t-corpus/tools/<名称>.py` 换成 `tools/<名称>.sh` 即可: + +```bash +tools/inputCsvToJson.sh --template --out my-cases.csv +tools/inputCsvToJson.sh --csv my-cases.csv --out <场景>/input_case_from_text.json +tools/outputJsonToCsv.sh --json <场景>/output_result_from_text.json --out review.csv +tools/inputCsvToJson.sh --reverse --csv --out back.csv +``` + +两个 sh 脚本**不是双击直接运行的**:双击启动时没有参数,只会打印用法说明(且终端可能立刻关闭)。请在终端中带参数运行;没有 bash 环境时直接 `python a2a-t-corpus/tools/...` 运行 Python 脚本。脚本会从终端 PATH 中查找 `python3`(找不到则回退 `python`),两者都缺失时给出明确报错。 + +## 新增场景(零 Java 改动) + +新建 `task/resources/<场景>/` 并放入两个 input JSON 即可——`@TestFactory` 套件运行时扫描发现场景。结构门禁先用 `CorpusSelfGuardTest`(无 LLM)跑一遍。 + +## 后续阶段(Task-T 稳定后) + +Notification-T / Negotiation-T / Authorization-T:新增 `corpus/<扩展>/` 套件与对应 `resources/`,在 `ApiRegistry` 登记该扩展 facade 方法,框架与工具链原样复用。 \ No newline at end of file diff --git a/a2a-t-corpus/env.example b/a2a-t-corpus/env.example new file mode 100644 index 00000000..23731ae9 --- /dev/null +++ b/a2a-t-corpus/env.example @@ -0,0 +1,23 @@ +# Corpus test LLM runtime +# Uses the same A2AT_LLM_* key names as the project-root env.example, so the corpus +# template is a tuned subset of the root one. Copy this file to .env and fill in the +# required entries; extra keys copied over from the root template are ignored. + +# LLM runtime +# Selects the LLM provider used by the shared client. Only "openai" is supported. +A2AT_LLM_PROVIDER=openai +# Selects the default model name for the configured provider. +A2AT_LLM_MODEL= +# API key for the configured LLM provider. +A2AT_LLM_API_KEY= +# Base URL for the configured LLM provider. +A2AT_LLM_BASE_URL= +# Maximum number of completion tokens. Validation outputs can exceed 2k tokens and +# reasoning models may consume the budget with reasoning content; keep this ample. +A2AT_LLM_MAX_TOKENS=102400 +# Default sampling temperature. +A2AT_LLM_TEMPERATURE=0 +# Request timeout in seconds. +A2AT_LLM_TIMEOUT_SECONDS=300 +# Maximum number of attempts for retryable LLM steps (range 1-10, default 3). +A2AT_LLM_MAX_ATTEMPTS=1 \ No newline at end of file diff --git a/a2a-t-corpus/pom.xml b/a2a-t-corpus/pom.xml index 01a043c0..fc45979b 100644 --- a/a2a-t-corpus/pom.xml +++ b/a2a-t-corpus/pom.xml @@ -13,22 +13,28 @@ jar a2a-t Corpus - SDK-level consistency corpus suite: negotiation test cases, golden fixtures and property tests. + Accuracy verification corpus suite for the a2a-t Java SDK: data-driven workflow cases executed against a real LLM; excluded from packaging and CI. + + + true + true + true + net.openan.a2a-t.sdk - a2a-t-negotiation + a2a-t-client ${project.version} net.openan.a2a-t.sdk - a2a-t-client + a2a-t-server ${project.version} net.openan.a2a-t.sdk - a2a-t-server + a2a-t-negotiation ${project.version} @@ -42,17 +48,19 @@ junit-jupiter test - - org.junit.jupiter - junit-jupiter-params - ${junit.jupiter.version} - test - - - net.jqwik - jqwik - ${jqwik.version} - test - - + + + + + src/test/java + + **/resources/** + + + + src/test/resources + + + + \ No newline at end of file diff --git a/a2a-t-corpus/schemas/input-case.schema.json b/a2a-t-corpus/schemas/input-case.schema.json new file mode 100644 index 00000000..baf585d1 --- /dev/null +++ b/a2a-t-corpus/schemas/input-case.schema.json @@ -0,0 +1,99 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openan.net/a2a-t-sdk/corpus/schemas/input-case.schema.json", + "title": "A2A-T corpus input case format", + "description": "First-hand definition of the data-driven workflow case consumed by the corpus engine. One file is a JSON array of case records. Each case declares an ordered array of SDK API steps (input) and one expectation per step (expected, aligned by index). Keys are strict: unknown keys are load-time errors.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "caseDimension", "caseDesc", "caseCategory", "input", "expected"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Case primary key, unique within one scenario." + }, + "caseDimension": { + "type": "string", + "minLength": 1, + "description": "Accuracy dimension this case exercises, the aggregation key for accuracy statistics." + }, + "caseDesc": { + "type": "string", + "minLength": 1, + "description": "One business-facing sentence describing the case." + }, + "caseCategory": { + "type": "string", + "minLength": 1, + "description": "Open enumeration of case categories, e.g. 正常 / 异常 / 边界 / 模糊语义 / ... ; statistics aggregate by the raw value." + }, + "input": { + "type": "array", + "minItems": 1, + "description": "Ordered SDK API steps; array order is the execution order; the same API may appear multiple times.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["api", "args"], + "properties": { + "api": { + "type": "string", + "minLength": 1, + "description": "Registered SDK facade method name, e.g. generateTaskPromptFromText." + }, + "args": { + "type": "object", + "description": "Request arguments for the step. Literal values take precedence; a {\"$fromStep\": N, \"$field\": \"a.b\"} object copies the field from the response of step N (1-based, earlier steps only)." + } + } + } + }, + "expected": { + "type": "array", + "minItems": 1, + "description": "Per-step expectations, aligned by index with input.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["result", "error"], + "properties": { + "result": { + "type": "string", + "enum": ["success", "error"], + "description": "Expected step outcome." + }, + "data": { + "type": "object", + "description": "Subset assertion against the step response payload: every listed key must equal. Applies to the payload root, or to its nested data map when the payload carries one (FilledParamData)." + }, + "dataExact": { + "type": "boolean", + "description": "When true, the asserted map must contain exactly the listed keys (no extras).", + "default": false + }, + "error": { + "type": "object", + "additionalProperties": false, + "properties": { + "errCode": { + "type": "string", + "description": "Exact SDK error catalog code expected when result is error; may be empty when the code is not asserted." + }, + "errMessage": { + "type": "string", + "description": "Exact error message; when absent only non-empty is required." + }, + "errMessageContains": { + "type": "string", + "description": "Substring the error message must contain." + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/a2a-t-corpus/schemas/output-result.schema.json b/a2a-t-corpus/schemas/output-result.schema.json new file mode 100644 index 00000000..ba554344 --- /dev/null +++ b/a2a-t-corpus/schemas/output-result.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openan.net/a2a-t-sdk/corpus/schemas/output-result.schema.json", + "title": "A2A-T corpus output result format", + "description": "Execution transcript written by the corpus engine for one scenario file. One file is a JSON array of result records; each record echoes the case metadata, aggregates per-case totals, and lists the full interaction transcript (SDK API steps interleaved with their underlying LLM calls). Content is always complete, never truncated.", + "type": "array", + "items": { + "type": "object", + "required": ["id", "caseDimension", "caseDesc", "caseCategory", "result", "totalTime", "totalInputToken", "totalOutputToken", "totalToken", "output"], + "properties": { + "id": { "type": "string" }, + "caseDimension": { "type": "string" }, + "caseDesc": { "type": "string" }, + "caseCategory": { "type": "string" }, + "totalTime": { "type": "integer", "description": "Case total duration in milliseconds." }, + "totalInputToken": { "type": "integer" }, + "totalOutputToken": { "type": "integer" }, + "totalToken": { "type": "integer" }, + "result": { + "type": "string", + "enum": ["success", "failure", "error"], + "description": "success: every step matched its expectation; failure: at least one assertion did not match; error: the engine crashed on a step." + }, + "failReason": { + "type": "string", + "description": "One-sentence pointer to the first unmet assertion, e.g. step2.data.accessPort." + }, + "output": { + "type": "object", + "required": ["interactions"], + "properties": { + "interactions": { + "type": "array", + "description": "Globally numbered transcript mixing SDK API steps (type = API method name) with their underlying LLM calls (type = LLM).", + "items": { + "type": "object", + "required": ["step", "type", "result", "duration_ms", "request", "response", "inputToken", "outputToken"], + "properties": { + "step": { "type": "string" }, + "type": { "type": "string" }, + "result": { "type": "string", "enum": ["success", "error", "skipped"] }, + "duration_ms": { "type": "integer" }, + "request": { "type": "object" }, + "response": { "type": "object" }, + "inputToken": { "type": "integer" }, + "outputToken": { "type": "integer" } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java deleted file mode 100644 index 98d739f4..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngine.java +++ /dev/null @@ -1,944 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.A2ATParamExtractionError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.SlotValidationError; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.generation.NegotiationContentService; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; -import net.openan.a2at.sdk.negotiation.validation.NegotiationSemanticValidator; -import org.jspecify.annotations.Nullable; - -/** - * Executes one expanded corpus case against the production negotiation content wiring and asserts its expectation - * block. - * - *

            Everything except the LLM client is production assembly: the case engine builds a real - * {@link NegotiationGenerationOrchestrator} through {@link NegotiationGenerationOrchestratorBuilder} for the language - * of the case, wires the {@code inject} hooks onto their real builder injection points ({@code failingTemplateLoader} - * for the generation template-not-found matrix, {@code failingSemanticValidator} for the validate-family - * prompt-resource-not-found mapping) and dispatches on the {@link NegotiationApi} enum at compile time, so a misspelled - * API name fails at corpus load time and a renamed service method fails this compilation. The three task-family APIs - * run through the {@link TaskApiAssembler}, the real facade builders' assembly of the closed loop (Q21) with the - * scripted LLM client injected at the builders' LLM seam. - * - *

            Result normalization: a success run returns the produced {@link MetadataContent} or {@link FilledParamData}, a - * failure run captures the thrown exception. The expectation comparison covers outcome, exception name, error code, - * message fragments, slot errors (slot plus code pairs, exact but order-insensitive), the exact LLM call count, the - * golden fixture text (per language, byte equality after CRLF normalization), the metadata echoes, the expected merged - * parameter map, the P0 behavior contracts and the Q17 C+ differential double run (fromText == fromData == golden, - * from-data side proven zero-call with an assertion-only client). Every mismatch fails with the case id, the JSON path - * of the expectation and the expected-versus-actual pair. - * - * @since 2026-08 - */ -public final class CaseEngine { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private static final String INJECT_FAILING_TEMPLATE_LOADER = "failingTemplateLoader"; - - private static final String INJECT_FAILING_SEMANTIC_VALIDATOR = "failingSemanticValidator"; - - private static final int MAX_REPORTED_TEXT_LENGTH = 400; - - /** - * Runs one case without a prompt override. - * - * @param testCase expanded corpus case - * @return the normalized outcome of the run - * @throws AssertionError when any expectation mismatches - */ - public CaseOutcome run(NegotiationCase testCase) { - return run(testCase, null); - } - - /** - * Runs one case, optionally overriding the prompt input of the validate family. - * - *

            The {@link ScenarioEngine} resolves {@code prompt.fromStep} references into prompt texts and hands them in - * through this parameter; a standalone validate case resolves its golden or inline prompt itself. - * - * @param testCase expanded corpus case - * @param promptOverride prompt text resolved from an earlier scenario step, or null - * @return the normalized outcome of the run - * @throws AssertionError when any expectation mismatches - */ - public CaseOutcome run(NegotiationCase testCase, @Nullable String promptOverride) { - return run(testCase, promptOverride, false); - } - - /** - * Runs one case, optionally overriding the prompt input of the validate family and optionally running as a scenario - * step. - * - *

            The {@link ScenarioEngine} resolves {@code prompt.fromStep} references into prompt texts and hands them in - * through the prompt parameter; a standalone validate case resolves its golden or inline prompt itself. Scenario - * mode defers the {@code expect.paramsFromStep} check to the scenario engine, which resolves the cross-step - * reference after the step ran. - * - * @param testCase expanded corpus case - * @param promptOverride prompt text resolved from an earlier scenario step, or null - * @param inScenario true inside the ScenarioEngine, false for a standalone run - * @return the normalized outcome of the run - * @throws AssertionError when any expectation mismatches - */ - CaseOutcome run(NegotiationCase testCase, @Nullable String promptOverride, boolean inScenario) { - ScriptedNegotiationLlmClient llmClient = llmClientFor(testCase); - NegotiationContentService service = new NegotiationContentService(orchestratorFor(testCase, llmClient)); - TaskApiAssembler taskApi = - testCase.api().family() == NegotiationApi.Family.TASK ? taskApiFor(testCase, llmClient) : null; - Object value = null; - Throwable failure = null; - try { - value = invoke(service, taskApi, testCase, promptOverride); - } catch (RuntimeException | AssertionError error) { - failure = error; - } - CaseOutcome outcome = new CaseOutcome(testCase, value, failure, llmClient.callCount(), llmClient); - assertExpectations(outcome, inScenario); - return outcome; - } - - // ------------------------------------------------------------------ outcome record - - /** - * The normalized outcome of one executed case. - * - * @param testCase the executed expanded corpus case - * @param value the returned {@code MetadataContent} or {@code FilledParamData} on success, or null on failure - * @param failure the captured exception on failure, or null on success - * @param llmCalls exact number of LLM calls the run made - * @param llmClient the scripted client the run used - */ - public record CaseOutcome( - NegotiationCase testCase, - @Nullable Object value, - @Nullable Throwable failure, - int llmCalls, - ScriptedNegotiationLlmClient llmClient) { - - /** - * Returns the generated message of a successful generation run. - * - * @return the metadata content, or null when the run failed or produced filled parameters - */ - public @Nullable MetadataContent message() { - return value instanceof MetadataContent metadata ? metadata : null; - } - - /** - * Returns the filled parameters of a successful validation run. - * - * @return the filled parameter data, or null when the run failed or produced a message - */ - public @Nullable FilledParamData filledParams() { - return value instanceof FilledParamData filled ? filled : null; - } - } - - // ------------------------------------------------------------------ expectation comparison - - private void assertExpectations(CaseOutcome outcome, boolean inScenario) { - NegotiationCase testCase = outcome.testCase(); - Expectation expect = testCase.expect(); - if (expect.success()) { - if (outcome.failure() != null) { - fail(testCase, "$.expect.outcome", "success", "failure (" + outcome.failure() + ")"); - } - assertSuccessFields(outcome, inScenario); - } else { - if (outcome.failure() == null) { - fail( - testCase, - "$.expect.outcome", - "failure", - "success (" + typeName(outcome.value()) + ": " + outcome.value() + ")"); - } - assertFailureFields(outcome); - } - if (expect.llmCalls() != null && expect.llmCalls() != outcome.llmCalls()) { - fail(testCase, "$.expect.llmCalls", String.valueOf(expect.llmCalls()), String.valueOf(outcome.llmCalls())); - } - for (String contractName : expect.contracts()) { - assertContract(outcome, contractName); - } - if (expect.differential()) { - runDifferential(outcome); - } - } - - private void assertFailureFields(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - Expectation expect = testCase.expect(); - Throwable failure = outcome.failure(); - if (expect.exception() != null) { - String actual = failure.getClass().getSimpleName(); - if (!expect.exception().equals(actual)) { - fail(testCase, "$.expect.exception", expect.exception(), actual); - } - } - if (expect.code() != null) { - if (!(failure instanceof A2ATError error)) { - throw fail( - testCase, - "$.expect.code", - expect.code(), - "no error code (exception " + failure.getClass().getSimpleName() + ")"); - } - if (!expect.code().equals(error.getCode())) { - fail(testCase, "$.expect.code", expect.code(), error.getCode()); - } - } - for (String fragment : expect.messageContains()) { - String message = failure.getMessage(); - if (message == null || !(message.contains(fragment) || message.contains(performativeWording(fragment)))) { - fail( - testCase, - "$.expect.messageContains", - "a message containing '" + fragment + "'", - "message " + quoted(message)); - } - } - if (!expect.slotErrors().isEmpty()) { - List slotErrors = slotErrorsOf(failure); - if (slotErrors == null) { - throw fail( - testCase, - "$.expect.slotErrors", - renderSlotErrors(expect.slotErrors()), - "exception " + failure.getClass().getSimpleName() + " carries no slot errors"); - } - assertSlotErrors(testCase, expect.slotErrors(), slotErrors); - } - } - - /** - * Returns the slot errors the failure carries, or null when the failure type carries none. - * - *

            The negotiation content layer surfaces its failures as {@link NegotiationParamExtractionException} (an - * {@code A2ATBusinessException} subtype since the ErrorCatalog migration) while the prompt families still use - * {@link A2ATParamExtractionError}; the engine accepts both carriers. - * - * @param failure captured failure of the run - * @return the slot validation errors of the failure, or null when the failure carries none - */ - private static @Nullable List slotErrorsOf(Throwable failure) { - if (failure instanceof A2ATParamExtractionError extraction) { - return extraction.getErrors(); - } - if (failure instanceof NegotiationParamExtractionException negotiation) { - return negotiation.getErrors(); - } - return null; - } - - private void assertSlotErrors( - NegotiationCase testCase, List expected, List actual) { - Map expectedCounts = new LinkedHashMap<>(); - for (Expectation.SlotError slotError : expected) { - expectedCounts.merge(slotError.slot() + ":" + slotError.code(), 1, Integer::sum); - } - Map actualCounts = new LinkedHashMap<>(); - List actualPairs = new ArrayList<>(); - for (SlotValidationError error : actual) { - String pair = error.slotName() + ":" + error.code(); - actualCounts.merge(pair, 1, Integer::sum); - actualPairs.add(pair); - } - if (!expectedCounts.equals(actualCounts)) { - fail( - testCase, - "$.expect.slotErrors", - renderSlotErrors(expected), - String.join(", ", actualPairs.isEmpty() ? List.of("(none)") : actualPairs)); - } - } - - private void assertSuccessFields(CaseOutcome outcome, boolean inScenario) { - NegotiationCase testCase = outcome.testCase(); - Expectation expect = testCase.expect(); - if (expect.promptTextEqualsGolden() != null) { - MetadataContent message = requireMessage(outcome, "$.expect.promptTextEqualsGolden"); - String golden = readGoldenFixture(testCase, expect.promptTextEqualsGolden()); - if (!normalize(message.promptText()).equals(golden)) { - fail( - testCase, - "$.expect.promptTextEqualsGolden", - quoted(truncate(golden)), - quoted(truncate(normalize(message.promptText())))); - } - } - if (expect.metadata() != null) { - MetadataContent message = requireMessage(outcome, "$.expect.metadata"); - if (expect.metadata().templateUriEcho() != null - && !expect.metadata().templateUriEcho().equals(message.templateUri())) { - fail( - testCase, - "$.expect.metadata.templateUriEcho", - expect.metadata().templateUriEcho(), - message.templateUri()); - } - if (Boolean.TRUE.equals(expect.metadata().contextEcho())) { - // The input context is built with the performative of the case's API, so it equals the emitted context - // the generation pipeline stamped with the performative of the addressed template. - NegotiationContext expectedContext = toContext(testCase.context(), testCase.api()); - if (!Objects.equals(expectedContext, message.negotiationContext())) { - fail( - testCase, - "$.expect.metadata.contextEcho", - String.valueOf(expectedContext), - String.valueOf(message.negotiationContext())); - } - } - } - if (!expect.params().isEmpty() || expect.missingParams() != null) { - if (!(outcome.value() instanceof FilledParamData filled)) { - throw fail( - testCase, - expect.missingParams() != null && expect.params().isEmpty() - ? "$.expect.missingParams" - : "$.expect.params", - expect.missingParams() != null - ? "filled parameter data with the missing-parameter set " + expect.missingParams() - : render(expect.params()), - "no filled parameter data (" + typeName(outcome.value()) + ")"); - } - if (expect.missingParams() != null) { - // Task semantics (Q21): the expected params are a subset check with exact values, and the missing - // parameter set — the null-valued entries of the filled data — is asserted exactly. - for (Map.Entry entry : expect.params().entrySet()) { - if (!filled.data().containsKey(entry.getKey()) - || !Objects.equals(entry.getValue(), filled.data().get(entry.getKey()))) { - fail( - testCase, - "$.expect.params", - entry.getKey() + "=" + entry.getValue(), - entry.getKey() + "=" + filled.data().get(entry.getKey())); - } - } - List actualMissing = filled.data().entrySet().stream() - .filter(entry -> entry.getValue() == null) - .map(Map.Entry::getKey) - .toList(); - Set expectedMissing = new LinkedHashSet<>(expect.missingParams()); - if (!expectedMissing.equals(new LinkedHashSet<>(actualMissing))) { - fail( - testCase, - "$.expect.missingParams", - String.join(", ", expectedMissing), - actualMissing.isEmpty() ? "(none)" : String.join(", ", actualMissing)); - } - } else if (!Objects.equals(expect.params(), filled.data())) { - fail(testCase, "$.expect.params", render(expect.params()), render(filled.data())); - } - } - for (String fragment : expect.promptTextContains()) { - MetadataContent message = requireMessage(outcome, "$.expect.promptTextContains"); - String promptText = message.promptText() == null ? "" : message.promptText(); - if (!promptText.contains(fragment)) { - fail( - testCase, - "$.expect.promptTextContains", - "a prompt text containing '" + fragment + "'", - quoted(truncate(promptText))); - } - } - if (expect.paramsFromStep() != null && !inScenario) { - throw new IllegalStateException(testCase.errorPrefix() + " expect.paramsFromStep " + expect.paramsFromStep() - + " is resolved by the ScenarioEngine; run the enclosing scenario through the" - + " ScenarioEngine"); - } - } - - // ------------------------------------------------------------------ contracts (P0) - - private void assertContract(CaseOutcome outcome, String contractName) { - NegotiationCase testCase = outcome.testCase(); - Contract contract = Contract.fromJsonName(contractName); - if (contract == null) { - fail( - testCase, - "$.expect.contracts", - "a registered contract name (one of " + knownContractNames() + ")", - "'" + contractName + "'"); - } - if (!contract.isP0()) { - fail( - testCase, - "$.expect.contracts", - "a P0 contract the engine asserts today", - "'" + contractName + "' is registered as a P1 expectation and is not yet lit"); - } - switch (contract) { - case CONCLUSION_LITERAL_PRESENT -> assertConclusionLiteralPresent(outcome); - case CONTEXT_KEYS_IN_MERGED_PARAMS -> assertContextKeysInMergedParams(outcome); - case NO_LLM_LEAK_IN_USER_MESSAGE -> assertNoLlmLeakInUserMessage(outcome); - case METADATA_TRIPLE_SHAPE -> assertMetadataTripleShape(outcome); - default -> fail( - testCase, - "$.expect.contracts", - "an implemented contract", - "'" + contractName + "' is flagged P0 but has no implementation"); - } - } - - private void assertConclusionLiteralPresent(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - String literal = - switch (testCase.api()) { - case GENERATE_ACCEPT_FROM_TEXT, GENERATE_ACCEPT_FROM_DATA -> "Accept"; - case GENERATE_REJECT_FROM_TEXT, GENERATE_REJECT_FROM_DATA -> "Reject"; - case GENERATE_ABORT_FROM_TEXT, GENERATE_ABORT_FROM_DATA -> "Abort"; - default -> null; - }; - if (literal == null) { - fail( - testCase, - "$.expect.contracts[conclusionLiteralPresent]", - "a terminal or abort generation API", - testCase.api().jsonName()); - } - MetadataContent message = requireMessage(outcome, "$.expect.contracts[conclusionLiteralPresent]"); - if (!String.valueOf(message.promptText()).contains(literal)) { - fail( - testCase, - "$.expect.contracts[conclusionLiteralPresent]", - "a prompt text containing the literal '" + literal + "'", - quoted(truncate(message.promptText()))); - } - } - - private void assertContextKeysInMergedParams(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - if (!(outcome.value() instanceof FilledParamData filled)) { - throw fail( - testCase, - "$.expect.contracts[contextKeysInMergedParams]", - "filled parameter data carrying the context keys", - typeName(outcome.value())); - } - for (String key : List.of("id", "round", "maxRounds")) { - if (!filled.data().containsKey(key)) { - fail( - testCase, - "$.expect.contracts[contextKeysInMergedParams]", - "merged params containing '" + key + "'", - render(filled.data())); - } - } - } - - private void assertNoLlmLeakInUserMessage(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - List details = outcome.llmClient().leakedFailureDetails(); - if (details.isEmpty()) { - return; - } - String visible = outcome.failure() != null - ? outcome.failure().getMessage() - : outcome.message() != null ? outcome.message().promptText() : null; - if (visible == null) { - fail( - testCase, - "$.expect.contracts[noLlmLeakInUserMessage]", - "a user-visible message to inspect", - "neither a failure message nor a generated message was produced"); - } - for (String detail : details) { - if (visible.contains(detail)) { - fail( - testCase, - "$.expect.contracts[noLlmLeakInUserMessage]", - "a message free of the raw LLM failure detail '" + detail + "'", - quoted(truncate(visible))); - } - } - } - - private void assertMetadataTripleShape(CaseOutcome outcome) { - NegotiationCase testCase = outcome.testCase(); - MetadataContent message = requireMessage(outcome, "$.expect.contracts[metadataTripleShape]"); - Map metadata = message.buildMetadataContent(); - if (metadata.size() != 3 - || !metadata.containsKey(message.extensionUri()) - || !metadata.containsKey(MetadataContent.TEMPLATE_URI_METADATA_KEY) - || !metadata.containsKey(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY)) { - fail( - testCase, - "$.expect.contracts[metadataTripleShape]", - "exactly the three entries ", - String.valueOf(metadata.keySet())); - } - NegotiationPerformative performative = performativeOf(testCase.api()); - Object nested = metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); - if (!(nested instanceof Map context) - || context.size() != 4 - || !context.containsKey("id") - || !context.containsKey("round") - || !context.containsKey("maxRounds") - || !performative.name().equals(context.get("performative"))) { - fail( - testCase, - "$.expect.contracts[metadataTripleShape]", - "the nested negotiation context with exactly ", - String.valueOf(nested)); - } - } - - // ------------------------------------------------------------------ differential (Q17 C+) - - private void runDifferential(CaseOutcome main) { - NegotiationCase testCase = main.testCase(); - if (testCase.api().family() != NegotiationApi.Family.FROM_TEXT) { - fail( - testCase, - "$.expect.differential", - "a from-text family API", - testCase.api().jsonName()); - } - if (testCase.inputData() == null) { - fail(testCase, "$.expect.differential", "input.data alongside input.text", "no input.data"); - } - if (testCase.expect().promptTextEqualsGolden() == null) { - fail( - testCase, - "$.expect.differential", - "promptTextEqualsGolden as the third comparison leg", - "no golden name"); - } - MetadataContent fromText = requireMessage(main, "$.expect.differential"); - ScriptedNegotiationLlmClient zeroCallClient = ScriptedNegotiationLlmClient.assertionOnly(); - NegotiationContentService service = new NegotiationContentService(orchestratorFor(testCase, zeroCallClient)); - MetadataContent fromData; - try { - fromData = generateFromData(service, testCase); - } catch (RuntimeException | AssertionError error) { - fail(testCase, "$.expect.differential", "a successful from-data message", "failure (" + error + ")"); - return; - } - if (!Objects.equals(fromText.promptText(), fromData.promptText())) { - fail( - testCase, - "$.expect.differential", - "fromText == fromData (" + quoted(truncate(fromText.promptText())) + ")", - quoted(truncate(fromData.promptText()))); - } - String golden = readGoldenFixture(testCase, testCase.expect().promptTextEqualsGolden()); - if (!normalize(fromData.promptText()).equals(golden)) { - fail( - testCase, - "$.expect.differential", - "fromData == golden " + quoted(truncate(golden)), - quoted(truncate(normalize(fromData.promptText())))); - } - if (zeroCallClient.callCount() != 0) { - fail( - testCase, - "$.expect.differential", - "0 LLM calls on the from-data leg", - String.valueOf(zeroCallClient.callCount())); - } - } - - private MetadataContent generateFromData(NegotiationContentService service, NegotiationCase testCase) { - TemplateUri templateUri = parseTemplateUri(testCase); - NegotiationContext context = toContext(testCase.context(), testCase.api()); - JsonNode data = testCase.inputData(); - return switch (testCase.api()) { - case GENERATE_PROPOSE_FROM_TEXT -> service.generateProposeFromData( - (NegotiationProposeData) TypedInputAssembler.assemble( - data, context, templateUri, NegotiationPerformative.PROPOSE, testCase.language()), - templateUri); - case GENERATE_ACCEPT_FROM_TEXT -> service.generateAcceptFromData( - (NegotiationEndingData) TypedInputAssembler.assemble( - data, context, templateUri, NegotiationPerformative.ACCEPT, testCase.language()), - templateUri); - case GENERATE_REJECT_FROM_TEXT -> service.generateRejectFromData( - (NegotiationEndingData) TypedInputAssembler.assemble( - data, context, templateUri, NegotiationPerformative.REJECT, testCase.language()), - templateUri); - case GENERATE_ABORT_FROM_TEXT -> service.generateAbortFromData( - (NegotiationAbortData) TypedInputAssembler.assemble( - data, context, templateUri, NegotiationPerformative.ABORT, testCase.language()), - templateUri); - default -> throw new IllegalStateException( - "The differential run pairs a from-text API with its from-data twin but got " - + testCase.api().jsonName() + "."); - }; - } - - // ------------------------------------------------------------------ production wiring - - private static ScriptedNegotiationLlmClient llmClientFor(NegotiationCase testCase) { - if (testCase.api().family() == NegotiationApi.Family.FROM_DATA || testCase.llm() == null) { - return ScriptedNegotiationLlmClient.assertionOnly(); - } - return new ScriptedNegotiationLlmClient(testCase.llm().steps()); - } - - private static NegotiationGenerationOrchestrator orchestratorFor( - NegotiationCase testCase, ScriptedNegotiationLlmClient llmClient) { - NegotiationGenerationOrchestratorBuilder builder = NegotiationGenerationOrchestratorBuilder.builder() - .language(testCase.language()) - .llmClient(llmClient); - if (testCase.llm() != null && testCase.llm().maxAttempts() != null) { - builder.maxAttempts(testCase.llm().maxAttempts()); - } - if (INJECT_FAILING_TEMPLATE_LOADER.equals(testCase.inject())) { - builder.templateLoader(failingTemplateLoader()); - } - if (INJECT_FAILING_SEMANTIC_VALIDATOR.equals(testCase.inject())) { - builder.semanticValidator(failingSemanticValidator()); - } - return builder.build(); - } - - /** - * Assembles the task API wiring of the closed loop for one case through the real facade builders: the retry limit - * comes from the case's LLM script or falls back to the builder default of 3. - * - * @param testCase expanded corpus case of the task family - * @param llmClient scripted LLM client injected at the facade builders' LLM seam - * @return the task API assembly of the case's language - */ - private static TaskApiAssembler taskApiFor(NegotiationCase testCase, ScriptedNegotiationLlmClient llmClient) { - int maxAttempts = testCase.llm() != null && testCase.llm().maxAttempts() != null - ? testCase.llm().maxAttempts() - : 3; - return new TaskApiAssembler(testCase.language(), maxAttempts, llmClient); - } - - private static NegotiationTemplateLoader failingTemplateLoader() { - return new NegotiationTemplateLoader() { - @Override - public PromptTemplate load(NegotiationReference reference) { - throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); - } - }; - } - - /** - * Replays the semantic validator of a language without bundled semantic validation prompt resources: the pipeline - * maps the resource failure onto the public {@code template.not_found} code without bubbling the raw exception (the - * validate-family leg of the template-resolution matrix; mirrors the hand-written pipeline suite). - * - * @return semantic validator failing every call with a resource-not-found exception - */ - private static NegotiationSemanticValidator failingSemanticValidator() { - return (prompt, callerSchema, reference, templateContent) -> { - throw new ResourceNotFoundException( - "Negotiation semantic validation prompt resource does not exist for language " - + reference.language() - + "; set A2AT_LANGUAGE to a language with bundled prompt resources (zh-CN or en-US).", - "prompt_resources/prompts/negotiation_semantic_validation/" + reference.language() + "/system.md"); - }; - } - - // ------------------------------------------------------------------ API dispatch - - private Object invoke( - NegotiationContentService service, - @Nullable TaskApiAssembler taskApi, - NegotiationCase testCase, - @Nullable String promptOverride) { - TemplateUri templateUri = parseTemplateUri(testCase); - NegotiationContext context = toContext(testCase.context(), testCase.api()); - return switch (testCase.api()) { - case GENERATE_PROPOSE_FROM_TEXT -> service.generateProposeFromText( - testCase.inputText(), context, templateUri); - case GENERATE_ACCEPT_FROM_TEXT -> service.generateAcceptFromText( - testCase.inputText(), context, templateUri); - case GENERATE_REJECT_FROM_TEXT -> service.generateRejectFromText( - testCase.inputText(), context, templateUri); - case GENERATE_ABORT_FROM_TEXT -> service.generateAbortFromText(testCase.inputText(), context, templateUri); - case GENERATE_PROPOSE_FROM_DATA -> service.generateProposeFromData( - (NegotiationProposeData) TypedInputAssembler.assemble( - requireInputData(testCase), - context, - templateUri, - NegotiationPerformative.PROPOSE, - testCase.language()), - templateUri); - case GENERATE_ACCEPT_FROM_DATA -> service.generateAcceptFromData( - (NegotiationEndingData) TypedInputAssembler.assemble( - requireInputData(testCase), - context, - templateUri, - NegotiationPerformative.ACCEPT, - testCase.language()), - templateUri); - case GENERATE_REJECT_FROM_DATA -> service.generateRejectFromData( - (NegotiationEndingData) TypedInputAssembler.assemble( - requireInputData(testCase), - context, - templateUri, - NegotiationPerformative.REJECT, - testCase.language()), - templateUri); - case GENERATE_ABORT_FROM_DATA -> service.generateAbortFromData( - (NegotiationAbortData) TypedInputAssembler.assemble( - requireInputData(testCase), - context, - templateUri, - NegotiationPerformative.ABORT, - testCase.language()), - templateUri); - case VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING -> service.validateProposePromptAndDataFilling( - promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); - case VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING -> service.validateAcceptPromptAndDataFilling( - promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); - case VALIDATE_REJECT_PROMPT_AND_DATA_FILLING -> service.validateRejectPromptAndDataFilling( - promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); - case VALIDATE_ABORT_PROMPT_AND_DATA_FILLING -> service.validateAbortPromptAndDataFilling( - promptOf(testCase, promptOverride), context, schemaOf(testCase), templateUri); - case GENERATE_TASK_PROMPT_FROM_TEXT -> requireTaskApi(taskApi, testCase) - .generateTaskPromptFromText(requireInputText(testCase), templateUri); - case GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA -> requireTaskApi(taskApi, testCase) - .generateTaskPromptFromDataWithSchema(dataOf(testCase), requireSchema(testCase), templateUri); - case VALIDATE_TASK_PROMPT_AND_DATA_FILLING -> requireTaskApi(taskApi, testCase) - .validateTaskPromptAndDataFilling( - promptOf(testCase, promptOverride), requireSchema(testCase), templateUri); - }; - } - - private static TaskApiAssembler requireTaskApi(@Nullable TaskApiAssembler taskApi, NegotiationCase testCase) { - if (taskApi == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " the task API assembly is only wired for the task family but got " - + testCase.api().jsonName()); - } - return taskApi; - } - - private static String requireInputText(NegotiationCase testCase) { - String text = testCase.inputText(); - if (text == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " input.text: the task from-text API requires a text input"); - } - return text; - } - - private static Map requireSchema(NegotiationCase testCase) { - Map schema = schemaOf(testCase); - if (schema == null) { - throw new IllegalStateException(testCase.errorPrefix() + " schema: the task APIs require a schema"); - } - return schema; - } - - private static Map dataOf(NegotiationCase testCase) { - JsonNode data = testCase.inputData(); - if (data == null || !data.isObject()) { - throw new IllegalStateException( - testCase.errorPrefix() + " input.data: the task from-data API requires typed input data"); - } - return MAPPER.convertValue(data, new TypeReference>() {}); - } - - private static TemplateUri parseTemplateUri(NegotiationCase testCase) { - String raw = testCase.templateUri(); - if (raw == null) { - return null; - } - return TemplateUri.parse(raw) - .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); - } - - private static JsonNode requireInputData(NegotiationCase testCase) { - JsonNode data = testCase.inputData(); - if (data == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " input.data: the from-data family requires typed input data"); - } - return data; - } - - private static String promptOf(NegotiationCase testCase, @Nullable String promptOverride) { - if (promptOverride != null) { - return promptOverride; - } - PromptSource source = testCase.prompt(); - if (source == null) { - return null; - } - if (source instanceof PromptSource.Text text) { - return text.text(); - } - if (source instanceof PromptSource.Golden golden) { - return readGoldenFixture(testCase, golden.golden()); - } - PromptSource.FromStep fromStep = (PromptSource.FromStep) source; - throw new IllegalStateException(testCase.errorPrefix() + " prompt.fromStep " + fromStep.step() - + " is resolved by the ScenarioEngine; run the enclosing scenario through the ScenarioEngine"); - } - - private static @Nullable Map schemaOf(NegotiationCase testCase) { - JsonNode schema = testCase.schema(); - if (schema == null) { - return null; - } - return MAPPER.convertValue(schema, new TypeReference>() {}); - } - - /** - * Builds the input context of a case, stamped with the performative of the case's API. - * - *

            The generation pipeline stamps the performative of the addressed template onto the emitted context, so an - * input context built with the API's performative echoes back unchanged; a validate API receives the message of - * exactly that performative, so its input context carries it as well. - * - * @param spec context spec of the case, or null - * @param api API of the case - * @return the negotiation context of the spec stamped with the API's performative, or null when the case carries no - * context spec or the task-family API takes no negotiation context - */ - private static @Nullable NegotiationContext toContext(@Nullable ContextSpec spec, NegotiationApi api) { - NegotiationPerformative performative = performativeOf(api); - if (spec == null || performative == null) { - return null; - } - return new NegotiationContext(spec.id(), spec.round(), spec.maxRounds(), performative); - } - - /** - * Returns the performative an API addresses: each generation API emits the message of one performative and each - * validate API checks the message of one performative, while the task family produces no negotiation message. - * - * @param api API of the case - * @return the performative of the generation and validate families, or null for the task family - */ - private static @Nullable NegotiationPerformative performativeOf(NegotiationApi api) { - return switch (api) { - case GENERATE_PROPOSE_FROM_TEXT, - GENERATE_PROPOSE_FROM_DATA, - VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.PROPOSE; - case GENERATE_ACCEPT_FROM_TEXT, - GENERATE_ACCEPT_FROM_DATA, - VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.ACCEPT; - case GENERATE_REJECT_FROM_TEXT, - GENERATE_REJECT_FROM_DATA, - VALIDATE_REJECT_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.REJECT; - case GENERATE_ABORT_FROM_TEXT, - GENERATE_ABORT_FROM_DATA, - VALIDATE_ABORT_PROMPT_AND_DATA_FILLING -> NegotiationPerformative.ABORT; - default -> null; - }; - } - - /** - * Rewrites the pre-rename wording of the frozen corpus fixtures: the case JSON files still say {@code expected - * phase}, while the orchestrator's reference-resolution error has said {@code expected performative} since the - * {@code phase} to {@code performative} rename. The corpus case JSON files are frozen by policy, so the harness - * translates the legacy fragment instead of the fixtures. - * - * @param fragment message fragment declared by a frozen case fixture - * @return the fragment with the legacy wording translated to the current one - */ - private static String performativeWording(String fragment) { - return fragment.replace("expected phase", "expected performative"); - } - - // ------------------------------------------------------------------ helpers - - private static MetadataContent requireMessage(CaseOutcome outcome, String jsonPath) { - NegotiationCase testCase = outcome.testCase(); - MetadataContent message = outcome.message(); - if (message == null) { - fail( - testCase, - jsonPath, - "a generated negotiation message", - "failure (" + outcome.failure() + ") or " + typeName(outcome.value())); - } - return message; - } - - private static String readGoldenFixture(NegotiationCase testCase, String goldenName) { - String resourcePath = "/golden/" + testCase.language() + "/" + goldenName + ".md"; - InputStream stream = CaseEngine.class.getResourceAsStream(resourcePath); - if (stream == null) { - throw new AssertionError(testCase.errorPrefix() + " golden fixture '" + goldenName - + "' does not exist on the test" + " classpath: " + resourcePath); - } - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } catch (IOException exception) { - throw new AssertionError( - testCase.errorPrefix() + " failed to read the golden fixture " + resourcePath, exception); - } - } - - private static String normalize(@Nullable String text) { - return text == null ? null : text.replace("\r\n", "\n"); - } - - private static String typeName(@Nullable Object value) { - return value == null ? "null" : value.getClass().getSimpleName(); - } - - private static String render(Map values) { - try { - return MAPPER.writeValueAsString(values); - } catch (IOException exception) { - return String.valueOf(values); - } - } - - private static String renderSlotErrors(List slotErrors) { - List pairs = new ArrayList<>(); - for (Expectation.SlotError slotError : slotErrors) { - pairs.add(slotError.slot() + ":" + slotError.code()); - } - return String.join(", ", pairs); - } - - private static String knownContractNames() { - List names = new ArrayList<>(); - for (Contract contract : Contract.values()) { - names.add(contract.jsonName()); - } - return String.join(", ", names); - } - - private static String truncate(String text) { - return text.length() <= MAX_REPORTED_TEXT_LENGTH ? text : text.substring(0, MAX_REPORTED_TEXT_LENGTH) + "..."; - } - - private static String quoted(@Nullable String text) { - return text == null ? "null" : "<" + text + ">"; - } - - private static AssertionError fail(NegotiationCase testCase, String jsonPath, String expected, String actual) { - throw new AssertionError( - testCase.errorPrefix() + " " + jsonPath + ": expected " + expected + " but was " + actual); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java deleted file mode 100644 index 154da7a6..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CaseEngineTest.java +++ /dev/null @@ -1,870 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -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.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.corpus.Expectation.Metadata; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * Direct unit tests of the {@link CaseEngine} against inline case objects: the four P0 contracts, the Q17 C+ - * differential double run, the inject hook, the fail-on-overconsumption calibration through {@code llmCalls}, and the - * red paths proving the engine is not a rubber stamp (every flipped expectation fails with the case id and the JSON - * path of the expectation). - * - *

            The engine runs against the real production wiring (builders, classpath resources, renderers, vocabulary, rule - * gate, semantic validator); the golden comparisons use the committed golden fixtures of the test resources. - */ -class CaseEngineTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String ZH_CN = "zh-CN"; - - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; - - private static final String INFORMATION_ACCEPT_REJECT_URI = - "Negotiation-T/information-negotiation/accept-reject/v1"; - - private static final String PRIVATE_LINE_COMPLAINT_URI = "Task-T/network-layer/private-line-complaint/v1"; - - /** - * The workbench raw complaint of the five-step closed loop (样例步骤1): deliberately lacking the access port name and - * the complaint category — the causal starting point of the information negotiation. - */ - private static final String COMPLAINT_TEXT = "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢," - + "柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; - - /** Slot-extraction payload of the closed loop's step 1: the task object stays empty (no port name in the text). */ - private static final String TASK_SLOTS_OBJECT_EMPTY = - "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" - + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** - * Slot-extraction payload of the from-text success path since the task_object slot became required upstream: the - * task object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays - * portless. - */ - private static final String TASK_SLOTS_OBJECT_PORTLESS = - "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" - + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** Slot-extraction payload of the filled variant: the port name and the complaint category are present. */ - private static final String TASK_SLOTS_FILLED = - "{\"slots\": {\"任务对象\": \"接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\", \"任务上下文\":" - + " \"投诉分类:专线质差;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:" - + "event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** - * Semantic-validation payload of the closed loop's step 2: the prompt is acceptable, but the access port name and - * the complaint category are missing — the two null-valued parameters the OMC then negotiates for. - */ - private static final String TASK_SEMANTIC_MISSING_PARAMS = - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," - + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; - - /** Semantic payload rejecting the task prompt (rejection-sample shape: key slots missing). */ - private static final String TASK_SEMANTIC_REJECTED = - "{\"semantic_verdict\":false,\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"content.param_missing\"," - + "\"facts\":{\"section_label\":\"接入端口名称\"}}],\"params\":{}}"; - - /** The task parameter schema of the closed loop (server-side keys, dictionary §10). */ - private static final String TASK_PARAM_SCHEMA = - "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" - + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" - + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; - - /** The rendered task prompt the OMC receives (样例步骤1, shortened): the negotiation-causing message. */ - private static final String TASK_PROMPT_MISSING_PARAMS = - """ - ## 任务类型(Task Type) - 传输专线业务投诉诊断 - - ## 任务对象(Task Object) - 接入端口名称: - - ## 任务上下文(Task Context) - 1. 投诉分类: - 2. 问题发生时间: "2026-05-11T08:21:46Z" - 3. OSS侧事件流水号:"event-id-20260511-09013" - 4. 投诉详情:"深圳访问广州的响应时延从平均12ms骤升至320ms" - """; - - /** Extraction payload that maps back to the typed content of the information accept golden fixture. */ - private static final String ACCEPT_PAYLOAD = - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" - + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; - - /** Extraction payload that maps back to the typed content of the information propose golden fixture. */ - private static final String PROPOSE_PAYLOAD = - "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}," - + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," - + "\"value\":null}],\"relationship\":\"OR\"}"; - - /** The same content as typed data, disagreeing in one item value for the differential red path. */ - private static final String PROPOSE_DATA_DISAGREEING = - "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P781-珠江新城-PTN7900-23-TPA1EG24-17\"}," - + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," - + "\"value\":null}],\"relationship\":\"OR\"}"; - - private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" - + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; - - private static final String SEMANTIC_REJECT_PAYLOAD = - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" - + "\"accessPort\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":" - + "\"接入端口名称\"}}],\"params\":{}}"; - - private final CaseEngine engine = new CaseEngine(); - - private final ObjectMapper mapper = new ObjectMapper(); - - private JsonNode schema; - - @BeforeEach - void readSchema() throws Exception { - schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); - } - - // ------------------------------------------------------------------ green paths - - @Test - void runsASuccessfulFromTextCaseAgainstTheGoldenFixture() { - NegotiationCase testCase = acceptFromTextCase(ok(1, "information_accept", metadata(), false), null); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - MetadataContent message = outcome.message(); - assertNotNull(message); - assertEquals(1, outcome.llmCalls()); - assertEquals(INFORMATION_ACCEPT_REJECT_URI, message.templateUri()); - assertEquals( - new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.ACCEPT), message.negotiationContext()); - } - - @Test - void runsFromDataCasesDeterministicallyWithoutAnyLlmCall() throws Exception { - NegotiationCase testCase = new NegotiationCase( - "FD-HAPPY-01/zh-CN", - "FD-HAPPY-01", - "from-data/happy.json", - NegotiationApi.GENERATE_ACCEPT_FROM_DATA, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - null, - mapper.readTree(ACCEPT_PAYLOAD), - null, - null, - null, - null, - ok(0, "information_accept", metadata(), false)); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - assertNotNull(outcome.message()); - assertEquals(0, outcome.llmCalls(), "the from-data run must not call the LLM (assertion-only client)"); - } - - @Test - void injectsTheFailingTemplateLoaderForTheTemplateNotFoundMatrix() { - NegotiationCase testCase = new NegotiationCase( - "FT-TPL-01/zh-CN", - "FT-TPL-01", - "from-text/template-resolution.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(ACCEPT_PAYLOAD))), - null, - null, - "failingTemplateLoader", - failed("NegotiationGenerationException", "template.not_found", 0, List.of(), List.of())); - - engine.run(testCase); - } - - @Test - void injectsTheFailingSemanticValidatorForTheValidateTemplateNotFoundMapping() { - NegotiationCase testCase = new NegotiationCase( - "VAL-MAP-05/zh-CN", - "VAL-MAP-05", - "validate/error-code-mapping.json", - NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_PROPOSE_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.ASSERTION))), - new PromptSource.Golden("information_propose"), - schema, - "failingSemanticValidator", - failed("NegotiationParamExtractionException", "template.not_found", 0, List.of(), List.of())); - - engine.run(testCase); - } - - // ------------------------------------------------------------------ P0 contracts - - @Test - void assertConclusionLiteralPresentContract() { - NegotiationCase testCase = - acceptFromTextCase(okContracts(1, "information_accept", List.of("conclusionLiteralPresent")), null); - - engine.run(testCase); - } - - @Test - void conclusionLiteralPresentContractRejectsNonTerminalApis() { - NegotiationCase testCase = - proposeFromTextCase(okContracts(1, "information_propose", List.of("conclusionLiteralPresent")), null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("conclusionLiteralPresent") - && failure.getMessage().contains("a terminal or abort generation API"), - "the contract must reject a propose API but was: " + failure.getMessage()); - } - - @Test - void assertContextKeysInMergedParamsContract() { - NegotiationCase testCase = validateProposeCase( - okParams( - 1, - Map.of( - "id", - SESSION_ID, - "round", - 2, - "maxRounds", - 5, - "accessPort", - "P533-珠江旧城-PTN3900-23-TPA1EG24-1"), - List.of("contextKeysInMergedParams")), - SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - FilledParamData filled = outcome.filledParams(); - assertNotNull(filled); - assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", filled.data().get("accessPort")); - } - - @Test - void assertNoLlmLeakInUserMessageContract() { - NegotiationCase testCase = new NegotiationCase( - "FT-RETRY-01/zh-CN", - "FT-RETRY-01", - "from-text/retry.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - new LlmScript( - null, - List.of( - new LlmScriptStep.Fail(LlmFailMarker.NON_JSON), - new LlmScriptStep.Fail(LlmFailMarker.NON_JSON), - new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), - null, - null, - null, - failed( - "NegotiationGenerationException", - "llm.response_invalid", - 3, - List.of(), - List.of("noLlmLeakInUserMessage"))); - - engine.run(testCase); - } - - @Test - void assertMetadataTripleShapeContract() { - NegotiationCase testCase = - acceptFromTextCase(okContracts(1, "information_accept", List.of("metadataTripleShape")), null); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - assertEquals( - 3, - outcome.message().buildMetadataContent().size(), - "the contract must have verified the triple metadata shape"); - } - - @Test - void rejectsUnknownAndNotYetLitContractNames() { - NegotiationCase unknown = - acceptFromTextCase(okContracts(1, "information_accept", List.of("not-a-contract")), null); - AssertionError unknownFailure = assertThrows(AssertionError.class, () -> engine.run(unknown)); - assertTrue( - unknownFailure.getMessage().contains("a registered contract name"), - "an unknown contract name must fail but was: " + unknownFailure.getMessage()); - - NegotiationCase notYetLit = - acceptFromTextCase(okContracts(1, "information_accept", List.of("noRenderSlotLeak")), null); - AssertionError notYetLitFailure = assertThrows(AssertionError.class, () -> engine.run(notYetLit)); - assertTrue( - notYetLitFailure.getMessage().contains("not yet lit"), - "a registered P1 contract must fail as not yet lit but was: " + notYetLitFailure.getMessage()); - } - - // ------------------------------------------------------------------ differential (Q17 C+) - - @Test - void runsTheDifferentialDoubleRun() throws Exception { - NegotiationCase testCase = proposeFromTextCase( - ok(1, "information_propose", new Expectation.Metadata(INFORMATION_PROPOSE_URI, Boolean.TRUE), true), - mapper.readTree(PROPOSE_PAYLOAD)); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - assertNotNull(outcome.message()); - assertEquals(1, outcome.llmCalls(), "only the from-text leg may call the LLM"); - } - - @Test - void differentialFailsWhenTheTypedDataDisagreesWithTheText() throws Exception { - NegotiationCase testCase = proposeFromTextCase( - ok(1, "information_propose", new Expectation.Metadata(INFORMATION_PROPOSE_URI, Boolean.TRUE), true), - mapper.readTree(PROPOSE_DATA_DISAGREEING)); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.differential") - && failure.getMessage().contains("fromText == fromData"), - "the differential must compare both legs but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ validate expectations - - @Test - void comparesSlotErrorsOrderInsensitively() { - NegotiationCase testCase = validateProposeCase( - failed( - "NegotiationParamExtractionException", - "negotiation.semantic_rejected", - 1, - List.of(new Expectation.SlotError("accessPort", "negotiation.field_missing")), - List.of()), - SEMANTIC_REJECT_PAYLOAD); - - engine.run(testCase); - } - - @Test - void slotErrorsFailOnAnExtraExpectedPair() { - NegotiationCase testCase = validateProposeCase( - failed( - "NegotiationParamExtractionException", - "negotiation.semantic_rejected", - 1, - List.of( - new Expectation.SlotError("accessPort", "negotiation.field_missing"), - new Expectation.SlotError("round", "negotiation.round_exceeded")), - List.of()), - SEMANTIC_REJECT_PAYLOAD); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.slotErrors"), - "an extra expected slot error must fail but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ red paths (not a rubber stamp) - - @Test - void failsWithCaseIdAndJsonPathWhenTheExpectedCodeMismatches() { - NegotiationCase testCase = new NegotiationCase( - "FT-EXTRACT-01/zh-CN", - "FT-EXTRACT-01", - "from-text/extraction-failures.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload("{\"relationship\":null}"))), - null, - null, - null, - failed(null, "negotiation.invalid_input", 1, List.of(), List.of())); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("FT-EXTRACT-01/zh-CN") - && failure.getMessage().contains("$.expect.code") - && failure.getMessage().contains("negotiation.field_missing"), - "the failure must carry the case id, the JSON path and both codes but was: " + failure.getMessage()); - } - - @Test - void failsWithCaseIdAndJsonPathWhenTheLlmCallCountMismatches() { - NegotiationCase testCase = acceptFromTextCase(ok(2, "information_accept", null, false), null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.llmCalls") - && failure.getMessage().contains("2"), - "a wrong llmCalls expectation must fail but was: " + failure.getMessage()); - } - - @Test - void failsWithCaseIdAndJsonPathWhenTheGoldenNameMismatches() { - NegotiationCase testCase = acceptFromTextCase(ok(1, "information_reject", null, false), null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.promptTextEqualsGolden"), - "a wrong golden name must fail but was: " + failure.getMessage()); - } - - @Test - void failsWhenASuccessCaseActuallyFails() { - NegotiationCase testCase = new NegotiationCase( - "FT-HAPPY-99/zh-CN", - "FT-HAPPY-99", - "from-text/happy.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), - null, - null, - null, - ok(1, "information_accept", null, false)); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.outcome") - && failure.getMessage().contains("failure"), - "an unexpected failure must flip the outcome expectation but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ task API dispatch family (Q21) - - @Test - void runsATaskFromTextCaseThroughTheMirroredClientWiring() { - NegotiationCase testCase = taskFromTextCase( - okTask(1, List.of("## 任务类型", "## 任务对象", "## 任务上下文", "event-id-20260511-09013"), null, Map.of())); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - MetadataContent message = outcome.message(); - assertNotNull(message); - assertEquals(1, outcome.llmCalls(), "the task from-text pipeline makes exactly one slot-extraction call"); - assertEquals(PRIVATE_LINE_COMPLAINT_URI, message.templateUri()); - assertTrue( - message.promptText().contains("## 任务对象(Task Object)"), - "the task object section header must be rendered: " + message.promptText()); - assertTrue(!message.promptText().contains("P533"), "no port name may leak into the incomplete task prompt"); - } - - @Test - void runsATaskFromDataWithSchemaCaseThroughTheMirroredClientWiring() throws Exception { - NegotiationCase testCase = taskFromDataCase( - okTask(1, List.of("## 任务类型", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差"), null, Map.of())); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - MetadataContent message = outcome.message(); - assertNotNull(message); - assertEquals(1, outcome.llmCalls(), "the from-data-with-schema pipeline also extracts slots through the LLM"); - assertEquals(PRIVATE_LINE_COMPLAINT_URI, message.templateUri()); - } - - @Test - void validateTaskPromptReportsTheMissingParametersAsNullValuedParams() throws Exception { - NegotiationCase testCase = taskValidateCase( - okTask( - 1, - null, - List.of("accessPort", "bizScenario"), - Map.of( - "faultTime", "2026-05-11T08:21:46Z", - "eventSerialNo", "event-id-20260511-09013")), - TASK_SEMANTIC_MISSING_PARAMS); - - CaseEngine.CaseOutcome outcome = engine.run(testCase); - - FilledParamData filled = outcome.filledParams(); - assertNotNull(filled); - assertEquals(1, outcome.llmCalls(), "the task validation pipeline makes exactly one semantic call"); - assertEquals("2026-05-11T08:21:46Z", filled.data().get("faultTime")); - assertEquals("event-id-20260511-09013", filled.data().get("eventSerialNo")); - assertTrue( - filled.data().containsKey("accessPort") && filled.data().get("accessPort") == null, - "a missing parameter must surface as a null-valued entry, not as an absent key"); - } - - @Test - void taskValidateMissingParamsFlipFails() throws Exception { - NegotiationCase testCase = taskValidateCase( - okTask(1, null, List.of("accessPort", "bizScenario", "faultDetail"), Map.of()), - TASK_SEMANTIC_MISSING_PARAMS); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.missingParams") - && failure.getMessage().contains("faultDetail"), - "an extra expected missing parameter must fail but was: " + failure.getMessage()); - } - - @Test - void taskValidateSemanticRejectionCarriesTheValidationCode() throws Exception { - NegotiationCase testCase = taskValidateCase( - failed("ContentValidationException", "negotiation.semantic_rejected", 1, List.of(), List.of()), - TASK_SEMANTIC_REJECTED); - - engine.run(testCase); - } - - @Test - void taskFromTextFailsOnAMissingRequiredSlot() throws Exception { - NegotiationCase testCase = new NegotiationCase( - "TASK-ERR-01/zh-CN", - "TASK-ERR-01", - "task/programming-errors.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - COMPLAINT_TEXT, - null, - new LlmScript( - null, - List.of(new LlmScriptStep.Payload( - "{\"slots\": {\"任务对象\": \"\", \"任务上下文\": \"\"}, \"slot_errors\": []}"))), - null, - null, - null, - failed("PromptGenerationException", "slot.not_provided", 1, List.of(), List.of())); - - engine.run(testCase); - } - - @Test - void taskPromptTextContainsFailsOnAnAbsentFragment() { - NegotiationCase testCase = taskFromTextCase(okTask(1, List.of("## 不存在的节头"), null, Map.of())); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(testCase)); - - assertTrue( - failure.getMessage().contains("$.expect.promptTextContains") - && failure.getMessage().contains("不存在的节头"), - "an absent structural fragment must fail but was: " + failure.getMessage()); - } - - @Test - void paramsFromStepIsReservedForTheScenarioEngine() throws Exception { - NegotiationCase testCase = taskValidateCase(okTask(1, null, null, Map.of()), TASK_SEMANTIC_MISSING_PARAMS); - NegotiationCase withFromStep = new NegotiationCase( - testCase.id(), - testCase.baseId(), - testCase.sourceFile(), - testCase.api(), - testCase.language(), - testCase.priority(), - testCase.tags(), - testCase.summary(), - testCase.context(), - testCase.templateUri(), - testCase.inputText(), - testCase.inputData(), - testCase.llm(), - testCase.prompt(), - testCase.schema(), - testCase.inject(), - new Expectation( - testCase.expect().success(), - testCase.expect().exception(), - testCase.expect().code(), - testCase.expect().messageContains(), - testCase.expect().slotErrors(), - testCase.expect().llmCalls(), - testCase.expect().promptTextEqualsGolden(), - testCase.expect().metadata(), - testCase.expect().params(), - testCase.expect().contracts(), - testCase.expect().differential(), - testCase.expect().promptTextContains(), - testCase.expect().missingParams(), - 1)); - - IllegalStateException failure = assertThrows(IllegalStateException.class, () -> engine.run(withFromStep)); - - assertTrue( - failure.getMessage().contains("paramsFromStep 1") - && failure.getMessage().contains("ScenarioEngine"), - "a standalone case cannot resolve paramsFromStep but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ expectation helpers - - private static Expectation ok( - @Nullable Integer llmCalls, - @Nullable String promptTextEqualsGolden, - @Nullable Metadata metadata, - boolean differential) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - promptTextEqualsGolden, - metadata, - Map.of(), - List.of(), - differential); - } - - private static Expectation okContracts( - @Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden, List contracts) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - promptTextEqualsGolden, - null, - Map.of(), - contracts, - false); - } - - private static Expectation okParams( - @Nullable Integer llmCalls, Map params, List contracts) { - return new Expectation(true, null, null, List.of(), List.of(), llmCalls, null, null, params, contracts, false); - } - - private static Expectation failed( - @Nullable String exception, - @Nullable String code, - @Nullable Integer llmCalls, - List slotErrors, - List contracts) { - return new Expectation( - false, exception, code, List.of(), slotErrors, llmCalls, null, null, Map.of(), contracts, false); - } - - // ------------------------------------------------------------------ case builders - - private NegotiationCase acceptFromTextCase(Expectation expect, @Nullable JsonNode inputData) { - return new NegotiationCase( - "FT-HAPPY-01/zh-CN", - "FT-HAPPY-01", - "from-text/happy.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "我确认第一阶段的信息。", - inputData, - new LlmScript(null, List.of(new LlmScriptStep.Payload(ACCEPT_PAYLOAD))), - null, - null, - null, - expect); - } - - private NegotiationCase proposeFromTextCase(Expectation expect, @Nullable JsonNode inputData) { - return new NegotiationCase( - "FT-HAPPY-02/zh-CN", - "FT-HAPPY-02", - "from-text/happy.json", - NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_PROPOSE_URI, - "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", - inputData, - new LlmScript(null, List.of(new LlmScriptStep.Payload(PROPOSE_PAYLOAD))), - null, - null, - null, - expect); - } - - private NegotiationCase validateProposeCase(Expectation expect, String semanticPayload) { - return new NegotiationCase( - "VAL-HAPPY-01/zh-CN", - "VAL-HAPPY-01", - "validate/happy.json", - NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, - ZH_CN, - "P0", - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_PROPOSE_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), - new PromptSource.Golden("information_propose"), - schema, - null, - expect); - } - - private static Expectation.Metadata metadata() { - return new Expectation.Metadata(INFORMATION_ACCEPT_REJECT_URI, Boolean.TRUE); - } - - // ------------------------------------------------------------------ task family helpers - - private static Expectation okTask( - @Nullable Integer llmCalls, - @Nullable List promptTextContains, - @Nullable List missingParams, - Map params) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - null, - null, - params, - List.of(), - false, - promptTextContains == null ? List.of() : promptTextContains, - missingParams, - null); - } - - private NegotiationCase taskFromTextCase(Expectation expect) { - return new NegotiationCase( - "TASK-FT-01/zh-CN", - "TASK-FT-01", - "task/from-text.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - ZH_CN, - "P0", - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - COMPLAINT_TEXT, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_OBJECT_PORTLESS))), - null, - null, - null, - expect); - } - - private NegotiationCase taskFromDataCase(Expectation expect) throws Exception { - return new NegotiationCase( - "TASK-FD-01/zh-CN", - "TASK-FD-01", - "task/from-data.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA, - ZH_CN, - "P0", - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - null, - mapper.readTree("{\"portName\": \"P533-珠江旧城-PTN3900-23-TPA1EG24-1\", \"complaintScenario\": \"专线质差\"," - + " \"faultStartTime\": \"2026-05-11T08:21:46Z\", \"ticketNo\":" - + " \"event-id-20260511-09013\", \"faultDetailText\": \"深圳访问广州的响应时延从平均12ms" - + "骤升至320ms\"}"), - new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_FILLED))), - null, - mapper.readTree("{\"type\": \"object\", \"properties\": {\"portName\": {\"type\": \"string\"}," - + " \"complaintScenario\": {\"type\": \"string\"}, \"faultStartTime\":" - + " {\"type\": \"string\"}, \"ticketNo\": {\"type\": \"string\"}," - + " \"faultDetailText\": {\"type\": \"string\"}}}"), - null, - expect); - } - - private NegotiationCase taskValidateCase(Expectation expect, String semanticPayload) throws Exception { - return new NegotiationCase( - "TASK-VAL-01/zh-CN", - "TASK-VAL-01", - "task/validate.json", - NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - ZH_CN, - "P0", - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), - new PromptSource.Text(TASK_PROMPT_MISSING_PARAMS), - mapper.readTree(TASK_PARAM_SCHEMA), - null, - expect); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ContextSpec.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ContextSpec.java deleted file mode 100644 index a8b77c54..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ContextSpec.java +++ /dev/null @@ -1,24 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -/** - * Negotiation context of one corpus record, inline in the corpus JSON. - * - *

            The engine converts this spec into a {@code NegotiationContext}; a JSON {@code null} context (the null-context - * probes of the validate family) stays a null reference on the loaded case instead of a spec. - * - * @param id negotiation session id (a 36-digit UUID in the rule-gated cases) - * @param round current negotiation round, at least 1 - * @param maxRounds negotiated round limit, at least 1 - * @since 2026-08 - */ -public record ContextSpec(String id, int round, int maxRounds) { - - public ContextSpec { - if (id == null || id.isBlank()) { - throw new IllegalArgumentException("The context id must not be blank."); - } - if (round < 1 || maxRounds < 1) { - throw new IllegalArgumentException("The context round and maxRounds must be at least 1."); - } - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Contract.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Contract.java deleted file mode 100644 index 94b803bd..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Contract.java +++ /dev/null @@ -1,94 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import org.jspecify.annotations.Nullable; - -/** - * Registry of the behavior contract names a corpus expectation can reference (design document §8.1, Q18). - * - *

            The registry defines all twelve contracts at once with their P0/P1 level, so it doubles as the readable expectation - * list of the corpus-generation workflow. The engine implements the four P0 contracts; referencing a P1 contract is an - * explicit engine failure ("not yet lit") rather than a silent pass, so a corpus author always knows what is actually - * asserted. - * - * @since 2026-08 - */ -public enum Contract { - - /** Accept/Reject/Abort messages must carry the corresponding conclusion literal. P0, case-level. */ - CONCLUSION_LITERAL_PRESENT("conclusionLiteralPresent", true), - - /** The validate merge result must always carry the context keys id, round and maxRounds. P0, case-level. */ - CONTEXT_KEYS_IN_MERGED_PARAMS("contextKeysInMergedParams", true), - - /** Raw LLM failure details must never reach a user-visible message. P0, case-level. */ - NO_LLM_LEAK_IN_USER_MESSAGE("noLlmLeakInUserMessage", true), - - /** buildMetadataContent() always carries exactly the three negotiation metadata entries. P0, case-level. */ - METADATA_TRIPLE_SHAPE("metadataTripleShape", true), - - /** The rendered output must not contain an unreplaced {@code {{}} slot. P1, case-level. */ - NO_RENDER_SLOT_LEAK("noRenderSlotLeak", false), - - /** The rendered text must not contain the context id (41d0247 invariant). P1, case-level. */ - NO_CONTEXT_IN_RENDERED_TEXT("noContextInRenderedText", false), - - /** The returned template URI must equal the input template URI. P1, case-level. */ - TEMPLATE_URI_ECHO("templateUriEcho", false), - - /** An error message must contain its error code literal. P1, case-level. */ - ERROR_CODE_IN_MESSAGE("errorCodeInMessage", false), - - /** A rule-gate failure must happen with zero LLM calls. P1, case-level. */ - ZERO_LLM_CALLS_ON_RULE_GATE("zeroLlmCallsOnRuleGate", false), - - /** The same input must always fail with the same error code. P1, case-level. */ - ERROR_CODE_DETERMINISM("errorCodeDeterminism", false), - - /** Within one scenario the round must only increase. P1, scenario-level. */ - ROUND_MONOTONIC("roundMonotonic", false), - - /** nextRound() must not modify the original context. P1, property-level. */ - CONTEXT_IMMUTABILITY("contextImmutability", false); - - private final String jsonName; - - private final boolean p0; - - Contract(String jsonName, boolean p0) { - this.jsonName = jsonName; - this.p0 = p0; - } - - /** - * Returns the corpus JSON name of this contract. - * - * @return contract name such as {@code conclusionLiteralPresent} - */ - public String jsonName() { - return jsonName; - } - - /** - * Returns whether this contract is a P0 contract the engine asserts today. - * - * @return true for the four implemented P0 contracts - */ - public boolean isP0() { - return p0; - } - - /** - * Resolves a corpus JSON name into a contract. - * - * @param jsonName corpus JSON name such as {@code metadataTripleShape} - * @return the contract, or null when the name matches none of the twelve registered contracts - */ - public static @Nullable Contract fromJsonName(@Nullable String jsonName) { - for (Contract contract : values()) { - if (contract.jsonName.equals(jsonName)) { - return contract; - } - } - return null; - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java deleted file mode 100644 index aa24a609..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusContractTest.java +++ /dev/null @@ -1,576 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.Test; - -/** - * Meta test of the negotiation test corpus (design document §6 Q6 and §7): the corpus is checked against its own - * contracts instead of the production code, so a hole in the corpus is caught before it silently narrows the suites. - * - *

            Checked today, in full strictness: global id uniqueness, expectation-block completeness, every expected error code - * being one of the content-layer error codes, the content-layer failure coverage, the validate-family code coverage, - * the four operational definitions of the bilingual parity (§7), and the per-record uniform language expansion. The - * business-alignment decisions add three more (design §10): the three closed-loop task APIs are exercised by scenario - * steps only and are role-bound (A=workbench generates, B=OMC validates), and every VAL-DRIFT drift probe is a - * compliant peer text expecting success. The Q19 business-review soft gate (§8.7) is opt-in: P0 cases must be 100% - * approved in review-status.json when {@code -Dcorpus.review.gate=true} is set and the status file exists, and is - * skipped otherwise. - * - *

            The full corpus has landed (design §8.4): a missing error-code or mapCode dimension is a hard failure, not a TODO - * line — a hole in the corpus silently narrows the suites, so it must block the build. - * - * @since 2026-08 - */ -class CorpusContractTest { - - /** - * Full strictness (design Q6/§8.4): every one of the content-layer error codes must be covered by at least one - * failure case of the corpus; a missing code fails the build. - */ - private static final boolean REQUIRE_FULL_ERROR_CODE_COVERAGE = true; - - /** - * Full strictness (design Q6/§8.4): every code the validate family can fail with must be covered by at least one - * validate-family failure case (the VAL-MAP/VAL-RULE/VAL-RETRY batches). The unknown-code fallback of the LLM - * response parsing (negotiation.rule_violation / content.rule_violation) cannot be expressed through the public - * exception surface and stays a hand-written-suite concern (ParamExtractorTest). - */ - private static final boolean REQUIRE_FULL_VALIDATE_CODE_COVERAGE = true; - - /** The content-layer error codes of the production exception surface (ErrorCatalog codes). */ - private static final List CONTENT_LAYER_ERROR_CODES = List.of( - ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), - ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), - ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), - ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), - ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), - ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), - ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), - ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); - - /** - * The codes the validate family fails with: the pipeline surfaces these directly (there is no code mapping layer - * since the ErrorCatalog migration), so a validate-family failure case must expect each of them. - */ - private static final List VALIDATE_FAMILY_ERROR_CODES = List.of( - ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), - ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), - ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), - ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), - ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), - ErrorCatalog.LLM_RESPONSE_INVALID.getCode()); - - private static final LoadedCorpus CORPUS = CorpusSuites.loadCorpus(); - - // ------------------------------------------------------------------ ids and expectation blocks - - @Test - void idsAreGloballyUniqueAcrossCasesAndScenarios() { - Set expandedIds = new LinkedHashSet<>(); - int expectedCount = 0; - for (NegotiationCase testCase : allCaseRecords()) { - assertTrue(expandedIds.add(testCase.id()), "duplicate expanded id " + testCase.id()); - expectedCount++; - } - for (ScenarioCase scenario : CORPUS.scenarios()) { - assertTrue(expandedIds.add(scenario.id()), "duplicate expanded id " + scenario.id()); - expectedCount++; - } - for (LiveCase liveCase : CORPUS.liveCases()) { - assertTrue(expandedIds.add(liveCase.id()), "duplicate expanded id " + liveCase.id()); - expectedCount++; - } - assertEquals(expectedCount, expandedIds.size(), "the id is the primary key of the whole corpus"); - - // One base id belongs to exactly one record: all its language expansions come from the same file, and every - // expansion carries a distinct language (the loader already fails fast on duplicates at parse time; this - // re-asserts the invariant on the loaded corpus). Scenario step cases are derived records of a scenario and are - // covered by the scenario check below. - Map> caseGroups = new LinkedHashMap<>(); - for (NegotiationCase testCase : allCaseRecords()) { - if (testCase.id().contains("#step-")) { - continue; - } - caseGroups - .computeIfAbsent(testCase.baseId(), key -> new ArrayList<>()) - .add(testCase); - } - for (List group : caseGroups.values()) { - Set languages = new LinkedHashSet<>(); - String sourceFile = group.get(0).sourceFile(); - for (NegotiationCase expansion : group) { - assertEquals( - sourceFile, - expansion.sourceFile(), - "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); - assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); - } - } - Map> scenarioGroups = new LinkedHashMap<>(); - for (ScenarioCase scenario : CORPUS.scenarios()) { - scenarioGroups - .computeIfAbsent(scenario.baseId(), key -> new ArrayList<>()) - .add(scenario); - } - for (List group : scenarioGroups.values()) { - Set languages = new LinkedHashSet<>(); - String sourceFile = group.get(0).sourceFile(); - for (ScenarioCase expansion : group) { - assertEquals( - sourceFile, - expansion.sourceFile(), - "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); - assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); - } - } - Map> liveGroups = new LinkedHashMap<>(); - for (LiveCase liveCase : CORPUS.liveCases()) { - liveGroups - .computeIfAbsent(liveCase.baseId(), key -> new ArrayList<>()) - .add(liveCase); - } - for (List group : liveGroups.values()) { - Set languages = new LinkedHashSet<>(); - String sourceFile = group.get(0).sourceFile(); - for (LiveCase expansion : group) { - assertEquals( - sourceFile, - expansion.sourceFile(), - "the base id " + expansion.baseId() + " must belong to exactly one corpus file"); - assertTrue(languages.add(expansion.language()), "duplicate language expansion " + expansion.id()); - } - } - } - - @Test - void expectationBlocksAreComplete() { - for (NegotiationCase testCase : allCaseRecords()) { - Expectation expect = testCase.expect(); - if (expect.success()) { - assertTrue( - expect.exception() == null && expect.code() == null, - testCase.errorPrefix() - + ": a success expectation must not carry an exception or an error code"); - } else { - assertTrue( - expect.exception() != null || expect.code() != null, - testCase.errorPrefix() - + ": a failure expectation must name the expected exception or the expected error code"); - } - } - } - - @Test - void everyExpectedErrorCodeIsAKnownNegotiationCode() { - for (NegotiationCase testCase : allCaseRecords()) { - String code = testCase.expect().code(); - if (code != null) { - assertTrue( - CONTENT_LAYER_ERROR_CODES.contains(code), - testCase.errorPrefix() + ": the expected code '" + code - + "' is not one of the content-layer error codes"); - } - } - } - - // ------------------------------------------------------------------ error-code coverage - - @Test - void everyNegotiationErrorCodeIsCoveredByAFailureCase() { - Set covered = new LinkedHashSet<>(); - for (NegotiationCase testCase : allCaseRecords()) { - if (!testCase.expect().success() && testCase.expect().code() != null) { - covered.add(testCase.expect().code()); - } - } - List missing = new ArrayList<>(); - for (String code : CONTENT_LAYER_ERROR_CODES) { - if (covered.contains(code)) { - continue; - } - if (REQUIRE_FULL_ERROR_CODE_COVERAGE) { - fail("no failure case of the corpus expects the error code '" + code + "' yet"); - } - missing.add(code); - } - if (!missing.isEmpty()) { - System.out.println("error codes not yet covered by any failure case: " + missing); - } - } - - // ------------------------------------------------------------------ validate-family code coverage - - @Test - void everyValidateFamilyCodeIsCoveredByAValidateFailureCase() { - Set covered = new LinkedHashSet<>(); - for (NegotiationCase testCase : allCaseRecords()) { - if (testCase.api().family() == NegotiationApi.Family.VALIDATE - && !testCase.expect().success() - && testCase.expect().code() != null) { - covered.add(testCase.expect().code()); - } - } - List missing = new ArrayList<>(); - for (String code : VALIDATE_FAMILY_ERROR_CODES) { - if (covered.contains(code)) { - continue; - } - if (REQUIRE_FULL_VALIDATE_CODE_COVERAGE) { - fail("no validate-family failure case expects the error code '" + code + "' yet"); - } - missing.add(code); - } - if (!missing.isEmpty()) { - System.out.println("validate-family codes not yet covered by any validate failure case: " + missing); - } - } - - // ------------------------------------------------------------------ closed-loop task APIs (Q20-Q23) - - /** - * Q21 full strictness: every one of the three closed-loop task APIs must be exercised by at least one scenario - * step, and the task APIs must never appear as standalone case-file records — the closed loop (task prompt - * generation, peer validation, then negotiation) only has a business meaning inside a scenario, so a standalone - * task case would bypass the 缺参 → 协商 → 补参 → 提取 causal chain the corpus exists to model. - */ - @Test - void everyTaskApiIsExercisedByScenarioStepsOnly() { - List taskApis = new ArrayList<>(); - for (ScenarioCase scenario : CORPUS.scenarios()) { - for (ScenarioCase.ScenarioStep step : scenario.steps()) { - NegotiationApi api = step.caseData().api(); - if (api.family() == NegotiationApi.Family.TASK) { - taskApis.add(api); - } - } - } - for (NegotiationApi api : NegotiationApi.values()) { - if (api.family() != NegotiationApi.Family.TASK) { - continue; - } - assertTrue( - taskApis.contains(api), - "no scenario step exercises the closed-loop task API '" + api.jsonName() + "' yet"); - } - for (NegotiationCase testCase : CORPUS.cases()) { - assertTrue( - testCase.api().family() != NegotiationApi.Family.TASK, - testCase.errorPrefix() - + ": a task API must not appear as a standalone case record, only as a scenario step" - + " (the closed-loop causal chain lives in scenarios)"); - } - } - - /** - * Q21/Q23 role semantics: the workbench (A) generates the task prompt and the OMC (B) validates the peer message, - * so every task-generation step must be acted by role A and every task-validation step by role B (the dual-session - * scenario numbers its roles A1/A2/B1/B2 — the letter prefix decides). - */ - @Test - void taskStepsAreBoundToTheirClosedLoopRoles() { - for (ScenarioCase scenario : CORPUS.scenarios()) { - for (ScenarioCase.ScenarioStep step : scenario.steps()) { - NegotiationApi api = step.caseData().api(); - if (api.family() != NegotiationApi.Family.TASK) { - continue; - } - String role = step.role() == null ? "" : step.role(); - String expectedSide = api.jsonName().startsWith("generate") ? "A" : "B"; - assertTrue( - role.startsWith(expectedSide), - scenario.id() + " step " + step.step() + " (" + api.jsonName() - + ") must be acted by the " + (expectedSide.equals("A") ? "workbench (A)" : "OMC (B)") - + " side, but the role is '" + step.role() + "'"); - } - } - } - - // ------------------------------------------------------------------ drift probes (Q22) - - /** - * Q22 full strictness: a drift probe is a compliant-but-reworded peer message, so it must (a) live in the validate - * family, (b) expect success — a drift probe that fails would reward exact-wording coupling instead of semantic - * compliance — and (c) take the peer message as inline text, never a golden fixture (golden is the regression - * equality anchor, not the validate mainline input). - */ - @Test - void driftProbesAreCompliantPeerTextExpectingSuccess() { - boolean seen = false; - for (NegotiationCase testCase : CORPUS.cases()) { - if (!testCase.baseId().startsWith("VAL-DRIFT-")) { - continue; - } - seen = true; - assertTrue( - testCase.api().family() == NegotiationApi.Family.VALIDATE, - testCase.errorPrefix() + ": a drift probe must be a validate-family case"); - assertTrue( - testCase.expect().success(), - testCase.errorPrefix() + ": a compliant-but-reworded peer message must pass validation"); - assertTrue( - testCase.prompt() instanceof PromptSource.Text, - testCase.errorPrefix() - + ": a drift probe must carry the peer message as inline text (prompt.text)," - + " not a golden fixture or a fromStep reference"); - } - assertTrue(seen, "the corpus must carry at least one VAL-DRIFT drift probe"); - } - - // ------------------------------------------------------------------ live family (live design §2.2) - - /** - * The live records live in their own {@code liveCases} list and are invisible to the six offline contracts above; - * this contract pins their phase-1 scope (Q5/Q6): the {@code LIVE-} id prefix, zh-CN only, and the two task APIs — - * a record outside the scope could never run through the live engine's dispatch. - */ - @Test - void liveRecordsStayInThePhase1Scope() { - assertFalse(CORPUS.liveCases().isEmpty(), "the corpus must carry at least one live record"); - for (LiveCase liveCase : CORPUS.liveCases()) { - assertTrue( - liveCase.baseId().startsWith("LIVE-"), - liveCase.errorPrefix() + ": the live family ids carry the 'LIVE-' prefix"); - assertEquals( - "zh-CN", liveCase.language(), liveCase.errorPrefix() + ": live phase 1 covers zh-CN only (Q6)"); - assertTrue( - liveCase.api() == NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT - || liveCase.api() == NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - liveCase.errorPrefix() + ": live phase 1 covers the two task APIs (Q5) but the record declares " - + liveCase.api().jsonName()); - } - } - - /** - * Live expectation completeness: the loose block still has to pin something checkable — {@code paramsContains} - * values non-null (a null expected value would never match, the engine asserts non-null extraction), non-blank - * {@code paramsAbsent} slots and {@code promptTextContains} fragments, and a positive {@code maxLlmCalls} bound. - */ - @Test - void liveExpectationBlocksAreComplete() { - for (LiveCase liveCase : CORPUS.liveCases()) { - LiveExpectation expect = liveCase.liveExpect(); - for (Map.Entry entry : expect.paramsContains().entrySet()) { - assertTrue( - entry.getValue() != null, - liveCase.errorPrefix() + ": paramsContains." + entry.getKey() + " must pin a non-null value"); - } - for (String slot : expect.paramsAbsent()) { - assertFalse(slot.isBlank(), liveCase.errorPrefix() + ": paramsAbsent entries must not be blank"); - } - for (String fragment : expect.promptTextContains()) { - assertFalse( - fragment.isBlank(), - liveCase.errorPrefix() + ": promptTextContains fragments must not be blank"); - } - if (expect.maxLlmCalls() != null) { - assertTrue( - expect.maxLlmCalls() > 0, - liveCase.errorPrefix() + ": maxLlmCalls must be a positive upper bound"); - } - } - } - - // ------------------------------------------------------------------ bilingual parity (§7, four definitions) - - /** - * Parity ①: every happy case of the case files declares both languages, so both expansions run and must succeed. - */ - @Test - void happyCasesDeclareBothLanguages() { - for (List group : caseFileGroupsByBaseId().values()) { - if (!group.get(0).expect().success()) { - continue; - } - Set languages = languagesOf(group); - assertEquals( - Set.of("zh-CN", "en-US"), - languages, - group.get(0).errorPrefix() - + ": a happy case must run in both languages (the suite executes every expansion)"); - } - } - - /** - * Parity ②: all language expansions of one record share the identical expectation block, so a failure has one code. - */ - @Test - void failureCasesShareTheIdenticalExpectationAcrossLanguages() { - for (List group : caseFileGroupsByBaseId().values()) { - Expectation reference = group.get(0).expect(); - for (NegotiationCase expansion : group) { - assertEquals( - reference, - expansion.expect(), - expansion.errorPrefix() - + ": every language expansion of one record must carry the identical expectation" - + " block, so a failure case fails with the same error code in both languages"); - } - } - } - - /** Parity ③: the golden fixture directories of the two languages carry the same number of fixtures. */ - @Test - void goldenFixtureCountsMatchAcrossLanguages() { - assertEquals( - countGoldenFixtures("zh-CN"), - countGoldenFixtures("en-US"), - "the golden fixtures must exist in equal numbers for zh-CN and en-US"); - } - - /** - * Parity ④: every record expands exactly once per declared language — no language is silently dropped or doubled. - */ - @Test - void recordsExpandExactlyOncePerDeclaredLanguage() { - for (List group : caseFileGroupsByBaseId().values()) { - Set languages = languagesOf(group); - assertEquals( - languages.size(), - group.size(), - group.get(0).errorPrefix() + ": every declared language must expand into exactly one case"); - } - Map> scenarioGroups = new LinkedHashMap<>(); - for (ScenarioCase scenario : CORPUS.scenarios()) { - scenarioGroups - .computeIfAbsent(scenario.baseId(), key -> new ArrayList<>()) - .add(scenario); - } - for (List group : scenarioGroups.values()) { - Set languages = new LinkedHashSet<>(); - for (ScenarioCase scenario : group) { - assertTrue(languages.add(scenario.language()), "duplicate language expansion of " + scenario.id()); - } - assertEquals( - languages.size(), group.size(), "every declared language must expand into exactly one scenario"); - } - } - - // ------------------------------------------------------------------ business review soft gate (Q19, §8.7) - - /** - * Q19 soft gate (design §8.7): when enabled, every P0 case of the corpus must carry the review status 通过 in the - * {@code docs-local/review/review-status.json} produced by {@code tools/corpus_review.py collect} — the P0 - * review-approval rate must be 100% before a release. - * - *

            The gate is strictly opt-in so it never blocks daily development: it runs only when the system property - * {@code corpus.review.gate} is set to {@code true} AND the status file exists; otherwise the test is skipped via - * {@link Assumptions#assumeTrue(boolean, String)}. Scenario records carry no priority and are therefore out of the - * P0 gate's scope (they stay tracked through the review dashboard itself). - */ - @Test - void p0CasesAreFullyApprovedByTheBusinessReview() throws IOException { - Assumptions.assumeTrue( - Boolean.getBoolean("corpus.review.gate"), - "the business review gate is opt-in (enable with -Dcorpus.review.gate=true)"); - Path statusFile = findReviewStatusFile(); - Assumptions.assumeTrue( - statusFile != null, - "docs-local/review/review-status.json not found (run tools/corpus_review.py collect to produce it)"); - - JsonNode reviewedCases = - new ObjectMapper().readTree(Files.readString(statusFile)).path("cases"); - List notApproved = new ArrayList<>(); - for (List group : caseFileGroupsByBaseId().values()) { - if (!"P0".equals(group.get(0).priority())) { - continue; - } - String baseId = group.get(0).baseId(); - String status = reviewedCases.path(baseId).path("status").asText(""); - if (!APPROVED_STATUS.equals(status)) { - notApproved.add(baseId + " (status: '" + (status.isEmpty() ? "unreviewed" : status) + "')"); - } - } - assertTrue( - notApproved.isEmpty(), - "the P0 review-approval rate must be 100% before a release, not approved yet: " + notApproved); - } - - /** The single review status that counts as approved ({@code tools/corpus_review.py}: 通过 / 有疑问 / 否决). */ - private static final String APPROVED_STATUS = "通过"; - - /** - * Locates {@code docs-local/review/review-status.json} by walking up from the working directory to the repo root. - */ - private static @Nullable Path findReviewStatusFile() { - Path directory = Path.of(".").toAbsolutePath(); - while (directory != null) { - Path candidate = directory.resolve("docs-local").resolve("review").resolve("review-status.json"); - if (Files.isRegularFile(candidate)) { - return candidate; - } - directory = directory.getParent(); - } - return null; - } - - // ------------------------------------------------------------------ helpers - - /** All case records of the corpus: the case-file records plus the scenario step cases. */ - private static List allCaseRecords() { - List records = new ArrayList<>(CORPUS.cases()); - for (ScenarioCase scenario : CORPUS.scenarios()) { - for (ScenarioCase.ScenarioStep step : scenario.steps()) { - records.add(step.caseData()); - } - } - return records; - } - - /** Case-file records grouped by base id (the loader puts scenario step cases into the scenarios, not the cases). */ - private static Map> caseFileGroupsByBaseId() { - Map> groups = new LinkedHashMap<>(); - for (NegotiationCase testCase : CORPUS.cases()) { - groups.computeIfAbsent(testCase.baseId(), key -> new ArrayList<>()).add(testCase); - } - assertFalse(groups.isEmpty(), "the corpus must carry at least one case-file record"); - return groups; - } - - private static Set languagesOf(List group) { - Set languages = new LinkedHashSet<>(); - for (NegotiationCase expansion : group) { - assertTrue( - languages.add(expansion.language()), "duplicate language expansion of " + expansion.errorPrefix()); - } - return languages; - } - - private static int countGoldenFixtures(String language) { - URL url = CorpusContractTest.class.getClassLoader().getResource("golden/" + language); - if (url == null) { - return 0; - } - try { - Path directory = Path.of(url.toURI()); - try (Stream files = Files.list(directory)) { - return (int) files.filter(file -> file.getFileName().toString().endsWith(".md")) - .count(); - } - } catch (URISyntaxException | IOException exception) { - throw new IllegalStateException("Failed to list the golden fixtures of " + language, exception); - } - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusLoadException.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusLoadException.java deleted file mode 100644 index eff72e8e..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusLoadException.java +++ /dev/null @@ -1,34 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -/** - * Signals a defect in the negotiation test corpus: an unknown key, a dangling or circular {@code $ref}, a duplicate - * id, an incomplete expectation block or any other format violation. - * - *

            The loader fails fast — before any case runs — and the message always names the offending corpus file, the - * offending record id (when already readable) and the JSON path of the defect. - * - * @since 2026-08 - */ -public final class CorpusLoadException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - /** - * Creates a corpus load failure. - * - * @param message failure description naming the file, the record id and the JSON path - */ - public CorpusLoadException(String message) { - super(message); - } - - /** - * Creates a corpus load failure with an underlying cause. - * - * @param message failure description naming the file, the record id and the JSON path - * @param cause underlying read or parse failure - */ - public CorpusLoadException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java deleted file mode 100644 index 9ab924f5..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSensitivitySelfTest.java +++ /dev/null @@ -1,475 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; -import java.util.Map; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * Meta-meta test of the corpus engine (design document §8.6, Q16): it guards against the deadliest failure mode of a - * data-driven suite — an always-green rubber-stamp engine whose bug lets every case pass silently. - * - *

            Every class of expectation assertion is proven sensitive on a minimal inline sample case: the sample runs green - * with its true expectation, then one flipped expectation value must make the {@link CaseEngine} red, with the case id - * and the JSON path of the flipped expectation in the failure message. The samples are built inline in Java and never - * touch the corpus files. - * - *

            The second guard, {@code engineMustNotCrashOnAnyCorpusCase}, walks the whole loaded corpus: the engine may report - * red (an AssertionError is the engine correctly rejecting a mismatch — that is what the suites turn into test - * failures), but it must never crash with an unexpected exception such as an NPE or a ClassCastException escaping the - * run. A crash is an engine bug; a red assertion is the engine working. - * - * @since 2026-08 - */ -class CorpusSensitivitySelfTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String ZH_CN = "zh-CN"; - - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; - - private static final String INFORMATION_ACCEPT_REJECT_URI = - "Negotiation-T/information-negotiation/accept-reject/v1"; - - private static final String PRIVATE_LINE_COMPLAINT_URI = "Task-T/network-layer/private-line-complaint/v1"; - - /** The workbench raw complaint of the closed loop (样例步骤1): no port name and no complaint category. */ - private static final String COMPLAINT_TEXT = - "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报" + "“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; - - /** - * Slot-extraction payload of the closed loop's step 1 since the task_object slot became required upstream: the task - * object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays portless. - */ - private static final String TASK_SLOTS_OBJECT_PORTLESS = - "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" - + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** Semantic payload of the closed loop's step 2: the port name and the complaint category are missing. */ - private static final String TASK_SEMANTIC_MISSING_PARAMS = - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," - + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; - - /** The rendered task prompt the OMC receives (样例步骤1, shortened): the negotiation-causing message. */ - private static final String TASK_PROMPT_MISSING_PARAMS = - """ - ## 任务类型(Task Type) - 传输专线业务投诉诊断 - - ## 任务对象(Task Object) - 接入端口名称: - - ## 任务上下文(Task Context) - 1. 投诉分类: - 2. 问题发生时间: "2026-05-11T08:21:46Z" - 3. OSS侧事件流水号:"event-id-20260511-09013" - 4. 投诉详情:"深圳访问广州的响应时延从平均12ms骤升至320ms" - """; - - /** The task parameter schema of the closed loop (server-side keys, dictionary §10). */ - private static final String TASK_PARAM_SCHEMA = - "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" - + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" - + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; - - /** Extraction payload mapping to the typed content of the information accept golden fixture. */ - private static final String ACCEPT_PAYLOAD = - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" - + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; - - /** Extraction payload whose mapped content lacks every required slot (fails with negotiation.field_missing). */ - private static final String SLOTS_MISSING_PAYLOAD = "{\"relationship\":null}"; - - /** Semantic verdict payload of a successful validation carrying one business parameter. */ - private static final String SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" - + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; - - /** Semantic verdict payload rejecting the message with two slot errors. */ - private static final String SEMANTIC_REJECT_PAYLOAD_TWO_ERRORS = - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":" - + "\"accessPort\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":" - + "\"接入端口名称\"}},{\"slot_name\":\"latency_target\",\"code\":" - + "\"negotiation.constraint_conflict\",\"facts\":{\"section_label\":\"时延目标\"," - + "\"reason\":\"latency target is out of range\"}}],\"params\":{}}"; - - private final CaseEngine engine = new CaseEngine(); - - private final ObjectMapper mapper = new ObjectMapper(); - - private JsonNode schema; - - @BeforeEach - void readSchema() throws Exception { - schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); - } - - // ------------------------------------------------------------------ flipped expectations must be red - - @Test - void flippedErrorCodeMustFailTheEngine() { - NegotiationCase trueExpectation = - fromTextCase("FT-SENS-CODE", SLOTS_MISSING_PAYLOAD, failed("negotiation.field_missing", 1, null, null)); - engine.run(trueExpectation); - - NegotiationCase flipped = fromTextCase( - "FT-SENS-CODE-FLIP", SLOTS_MISSING_PAYLOAD, failed("negotiation.invalid_input", 1, null, null)); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); - - assertTrue( - failure.getMessage().contains("FT-SENS-CODE-FLIP") - && failure.getMessage().contains("$.expect.code"), - "a swapped error code must fail with the case id and the JSON path but was: " + failure.getMessage()); - } - - @Test - void flippedLlmCallCountMustFailTheEngine() { - engine.run(fromTextCase("FT-SENS-CALLS", ACCEPT_PAYLOAD, ok(1, "information_accept"))); - - AssertionError tooHigh = assertThrows( - AssertionError.class, - () -> engine.run(fromTextCase("FT-SENS-CALLS-HIGH", ACCEPT_PAYLOAD, ok(2, "information_accept")))); - assertTrue( - tooHigh.getMessage().contains("FT-SENS-CALLS-HIGH") - && tooHigh.getMessage().contains("$.expect.llmCalls"), - "llmCalls + 1 must fail but was: " + tooHigh.getMessage()); - - AssertionError tooLow = assertThrows( - AssertionError.class, - () -> engine.run(fromTextCase("FT-SENS-CALLS-LOW", ACCEPT_PAYLOAD, ok(0, "information_accept")))); - assertTrue( - tooLow.getMessage().contains("FT-SENS-CALLS-LOW") - && tooLow.getMessage().contains("$.expect.llmCalls"), - "llmCalls - 1 must fail but was: " + tooLow.getMessage()); - } - - @Test - void flippedGoldenNameMustFailTheEngine() { - engine.run(fromTextCase("FT-SENS-GOLDEN", ACCEPT_PAYLOAD, ok(1, "information_accept"))); - - NegotiationCase flipped = fromTextCase("FT-SENS-GOLDEN-FLIP", ACCEPT_PAYLOAD, ok(1, "information_reject")); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); - - assertTrue( - failure.getMessage().contains("FT-SENS-GOLDEN-FLIP") - && failure.getMessage().contains("$.expect.promptTextEqualsGolden"), - "a swapped golden fixture must fail but was: " + failure.getMessage()); - } - - @Test - void tamperedParamsMustFailTheEngine() { - engine.run(validateCase( - "VAL-SENS-PARAMS", - Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P533-珠江旧城-PTN3900-23-TPA1EG24-1"))); - - NegotiationCase tampered = validateCase( - "VAL-SENS-PARAMS-FLIP", - Map.of("id", SESSION_ID, "round", 2, "maxRounds", 5, "accessPort", "P781-珠江新城-PTN7900-23-TPA1EG24-17")); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(tampered)); - - assertTrue( - failure.getMessage().contains("VAL-SENS-PARAMS-FLIP") - && failure.getMessage().contains("$.expect.params") - && failure.getMessage().contains("P781-珠江新城-PTN7900-23-TPA1EG24-17"), - "a tampered params value must fail but was: " + failure.getMessage()); - } - - @Test - void flippedOutcomeMustFailTheEngine() { - NegotiationCase successReadAsFailure = - fromTextCase("FT-SENS-OUTCOME-SF", ACCEPT_PAYLOAD, failed("negotiation.invalid_input", 1, null, null)); - AssertionError successFlipped = assertThrows(AssertionError.class, () -> engine.run(successReadAsFailure)); - assertTrue( - successFlipped.getMessage().contains("FT-SENS-OUTCOME-SF") - && successFlipped.getMessage().contains("$.expect.outcome"), - "a success run expected to fail must be red but was: " + successFlipped.getMessage()); - - NegotiationCase failureReadAsSuccess = fromTextCase("FT-SENS-OUTCOME-FS", null, ok(1, "information_accept")); - AssertionError failureFlipped = assertThrows(AssertionError.class, () -> engine.run(failureReadAsSuccess)); - assertTrue( - failureFlipped.getMessage().contains("FT-SENS-OUTCOME-FS") - && failureFlipped.getMessage().contains("$.expect.outcome"), - "a failing run expected to succeed must be red but was: " + failureFlipped.getMessage()); - } - - @Test - void flippedSlotErrorsMustFailTheEngine() { - List both = List.of( - new Expectation.SlotError("accessPort", "negotiation.field_missing"), - new Expectation.SlotError("latency_target", "negotiation.constraint_conflict")); - engine.run(validateCase("VAL-SENS-SLOTS", null, both)); - - NegotiationCase removed = validateCase( - "VAL-SENS-SLOTS-DEL", - null, - List.of(new Expectation.SlotError("accessPort", "negotiation.field_missing"))); - AssertionError removalFailure = assertThrows(AssertionError.class, () -> engine.run(removed)); - assertTrue( - removalFailure.getMessage().contains("VAL-SENS-SLOTS-DEL") - && removalFailure.getMessage().contains("$.expect.slotErrors"), - "a removed slot error must fail but was: " + removalFailure.getMessage()); - - List three = List.of( - new Expectation.SlotError("accessPort", "negotiation.field_missing"), - new Expectation.SlotError("latency_target", "negotiation.constraint_conflict"), - new Expectation.SlotError("round", "negotiation.round_exceeded")); - NegotiationCase added = validateCase("VAL-SENS-SLOTS-ADD", null, three); - AssertionError additionFailure = assertThrows(AssertionError.class, () -> engine.run(added)); - assertTrue( - additionFailure.getMessage().contains("VAL-SENS-SLOTS-ADD") - && additionFailure.getMessage().contains("$.expect.slotErrors"), - "an added slot error must fail but was: " + additionFailure.getMessage()); - } - - // ------------------------------------------------------------------ task-family expectations must be sensitive - - @Test - void flippedMissingParamsMustFailTheEngine() throws Exception { - NegotiationCase trueExpectation = taskValidateCase( - "VAL-SENS-MISSING", - taskOk(1, null, List.of("accessPort", "bizScenario"), Map.of("faultTime", "2026-05-11T08:21:46Z")), - TASK_SEMANTIC_MISSING_PARAMS); - engine.run(trueExpectation); - - NegotiationCase flipped = taskValidateCase( - "VAL-SENS-MISSING-FLIP", - taskOk(1, null, List.of("accessPort"), Map.of("faultTime", "2026-05-11T08:21:46Z")), - TASK_SEMANTIC_MISSING_PARAMS); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); - - assertTrue( - failure.getMessage().contains("VAL-SENS-MISSING-FLIP") - && failure.getMessage().contains("$.expect.missingParams") - && failure.getMessage().contains("bizScenario"), - "a shrunken missing-parameter set must fail but was: " + failure.getMessage()); - } - - @Test - void flippedPromptTextContainsMustFailTheEngine() throws Exception { - NegotiationCase trueExpectation = - taskFromTextCase("VAL-SENS-HEADERS", taskOk(1, List.of("## 任务类型"), null, Map.of())); - engine.run(trueExpectation); - - NegotiationCase flipped = - taskFromTextCase("VAL-SENS-HEADERS-FLIP", taskOk(1, List.of("## 所需信息项"), null, Map.of())); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.run(flipped)); - - assertTrue( - failure.getMessage().contains("VAL-SENS-HEADERS-FLIP") - && failure.getMessage().contains("$.expect.promptTextContains"), - "a wrong structural fragment must fail but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ engine crash guard - - @Test - void engineMustNotCrashOnAnyCorpusCase() { - LoadedCorpus corpus = CorpusSuites.loadCorpus(); - CaseEngine caseEngine = new CaseEngine(); - for (NegotiationCase testCase : corpus.cases()) { - try { - caseEngine.run(testCase); - } catch (AssertionError redIsTheEngineWorking) { - // A red assertion is the engine rejecting a mismatch; the suites turn it into a test failure. - } catch (Throwable crash) { - fail("The case engine crashed on " + testCase.errorPrefix() + ": " + crash); - } - } - ScenarioEngine scenarioEngine = new ScenarioEngine(); - for (ScenarioCase scenario : corpus.scenarios()) { - try { - scenarioEngine.runScenario(scenario); - } catch (AssertionError redIsTheEngineWorking) { - // See above: red is not a crash. - } catch (Throwable crash) { - fail("The scenario engine crashed on " + scenario.id() + ": " + crash); - } - } - } - - // ------------------------------------------------------------------ inline sample cases - - private NegotiationCase fromTextCase(String id, @Nullable String payload, Expectation expect) { - LlmScript script = new LlmScript( - null, - List.of( - payload == null - ? new LlmScriptStep.Fail(LlmFailMarker.NON_JSON) - : new LlmScriptStep.Payload(payload))); - return new NegotiationCase( - id + "/zh-CN", - id, - "from-text/sensitivity-probes.json", - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - ZH_CN, - null, - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - null, - script, - null, - null, - null, - expect); - } - - private NegotiationCase validateCase(String id, @Nullable Map params) { - return validateCase(id, params, null); - } - - private NegotiationCase validateCase( - String id, @Nullable Map params, @Nullable List slotErrors) { - Expectation expect; - if (params == null) { - expect = new Expectation( - false, - null, - "negotiation.semantic_rejected", - List.of(), - slotErrors == null ? List.of() : slotErrors, - 1, - null, - null, - Map.of(), - List.of(), - false); - } else { - expect = new Expectation(true, null, null, List.of(), List.of(), 1, null, null, params, List.of(), false); - } - String payload = params == null ? SEMANTIC_REJECT_PAYLOAD_TWO_ERRORS : SEMANTIC_ACCEPT_PAYLOAD_WITH_PARAMS; - return new NegotiationCase( - id + "/zh-CN", - id, - "validate/sensitivity-probes.json", - NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, - ZH_CN, - null, - List.of(), - null, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_PROPOSE_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(payload))), - new PromptSource.Golden("information_propose"), - schema, - null, - expect); - } - - private static Expectation ok(@Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - promptTextEqualsGolden, - null, - Map.of(), - List.of(), - false); - } - - /** Task-family success expectation with the prompt-text fragments and the missing-parameter set. */ - private static Expectation taskOk( - @Nullable Integer llmCalls, - @Nullable List promptTextContains, - @Nullable List missingParams, - Map params) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - null, - null, - params, - List.of(), - false, - promptTextContains == null ? List.of() : promptTextContains, - missingParams, - null); - } - - private NegotiationCase taskFromTextCase(String id, Expectation expect) { - return new NegotiationCase( - id + "/zh-CN", - id, - "task/sensitivity-probes.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - ZH_CN, - null, - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - COMPLAINT_TEXT, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(TASK_SLOTS_OBJECT_PORTLESS))), - null, - null, - null, - expect); - } - - private NegotiationCase taskValidateCase(String id, Expectation expect, String semanticPayload) throws Exception { - return new NegotiationCase( - id + "/zh-CN", - id, - "task/sensitivity-probes.json", - NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - ZH_CN, - null, - List.of(), - null, - null, - PRIVATE_LINE_COMPLAINT_URI, - null, - null, - new LlmScript(null, List.of(new LlmScriptStep.Payload(semanticPayload))), - new PromptSource.Text(TASK_PROMPT_MISSING_PARAMS), - new ObjectMapper().readTree(TASK_PARAM_SCHEMA), - null, - expect); - } - - private static Expectation failed( - @Nullable String code, - @Nullable Integer llmCalls, - @Nullable Map params, - @Nullable List slotErrors) { - return new Expectation( - false, - null, - code, - List.of(), - slotErrors == null ? List.of() : slotErrors, - llmCalls, - null, - null, - params == null ? Map.of() : params, - List.of(), - false); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSuites.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSuites.java deleted file mode 100644 index 8705a820..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/CorpusSuites.java +++ /dev/null @@ -1,136 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Pattern; -import java.util.stream.Stream; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.DynamicTest; - -/** - * Shared machinery of the four corpus suite classes (design document §3.4 item 4, Q1). - * - *

            Every suite is a JUnit {@code @TestFactory} that loads the corpus from the test resources once, keeps the records of - * its family directory, and turns each expanded case or scenario into one {@link DynamicTest} whose display name carries - * the corpus id. A family directory that is absent or empty yields an empty stream (with a note on stdout), so the suites - * stay green while the later corpus batches land file by file. - * - *

            The {@code -Dcase.filter=} system property selects a subset at run time — for example - * {@code mvn test -Dtest='FromTextCorpusSuiteTest' -Dcase.filter=FT-RETRY-*}. The glob matches either the expanded id - * ({@code FT-RETRY-02/zh-CN}) or the base record id ({@code FT-RETRY-02}); {@code *} and {@code ?} are wildcards that - * also match across the language suffix. - * - * @since 2026-08 - */ -final class CorpusSuites { - - /** Classpath root of the negotiation test corpus. */ - static final String CORPUS_RESOURCE = "negotiation-cases"; - - private static final String CASE_FILTER = System.getProperty("case.filter", "").trim(); - - private CorpusSuites() {} - - // ------------------------------------------------------------------ corpus access - - static LoadedCorpus loadCorpus() { - return NegotiationCaseLoader.loadFromClasspath(CORPUS_RESOURCE); - } - - // ------------------------------------------------------------------ dynamic test assembly - - /** - * Builds one dynamic test per expanded case of the given family directory. - * - * @param directory family directory under the corpus root, such as {@code from-text} - * @return one dynamic test per selected case, empty when the directory carries no case - */ - static Stream caseTests(String directory) { - LoadedCorpus corpus = loadCorpus(); - List tests = new ArrayList<>(); - CaseEngine engine = new CaseEngine(); - for (NegotiationCase testCase : corpus.cases()) { - if (!testCase.sourceFile().startsWith(directory + "/")) { - continue; - } - if (!selected(testCase.id(), testCase.baseId())) { - continue; - } - tests.add(DynamicTest.dynamicTest( - displayName(testCase.id(), testCase.summary()), () -> engine.run(testCase))); - } - noteEmpty(directory, tests.size()); - return tests.stream(); - } - - /** - * Builds one dynamic test per expanded scenario of the given family directory. - * - * @param directory family directory under the corpus root, such as {@code scenarios} - * @return one dynamic test per selected scenario, empty when the directory carries no scenario - */ - static Stream scenarioTests(String directory) { - LoadedCorpus corpus = loadCorpus(); - List tests = new ArrayList<>(); - ScenarioEngine engine = new ScenarioEngine(); - for (ScenarioCase scenario : corpus.scenarios()) { - if (!scenario.sourceFile().startsWith(directory + "/")) { - continue; - } - if (!selected(scenario.id(), scenario.baseId())) { - continue; - } - tests.add(DynamicTest.dynamicTest( - displayName(scenario.id(), scenario.summary()), () -> engine.runScenario(scenario))); - } - noteEmpty(directory, tests.size()); - return tests.stream(); - } - - /** Builds the display name of one dynamic test from the corpus id and the record summary; shared with the live suite. */ - static String displayName(String id, @Nullable String summary) { - return summary == null ? id : id + " — " + summary; - } - - /** Notes an empty family contribution on stdout; shared with the live suite. */ - static void noteEmpty(String directory, int size) { - if (size == 0) { - System.out.println( - "[corpus] no record under '" + directory + "/' matched (directory missing, empty, or filtered by" - + " -Dcase.filter='" + CASE_FILTER + "'); the suite contributes no test."); - } - } - - // ------------------------------------------------------------------ case filter - - /** - * Returns whether one corpus record is selected by the {@code -Dcase.filter} glob; shared with the live suite, - * whose records live in their own list but select by the same id-or-base-id rule. - * - * @param id expanded id of the record, such as {@code LIVE-GEN-01/zh-CN} - * @param baseId base record id, such as {@code LIVE-GEN-01} - * @return true when the record is selected (an empty filter selects everything) - */ - static boolean selected(String id, String baseId) { - if (CASE_FILTER.isEmpty()) { - return true; - } - return globMatches(CASE_FILTER, id) || globMatches(CASE_FILTER, baseId); - } - - private static boolean globMatches(String glob, String value) { - return value.matches(globToRegex(glob)); - } - - private static String globToRegex(String glob) { - StringBuilder regex = new StringBuilder(); - for (char character : glob.toCharArray()) { - switch (character) { - case '*' -> regex.append(".*"); - case '?' -> regex.append('.'); - default -> regex.append(Pattern.quote(String.valueOf(character))); - } - } - return regex.toString(); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java deleted file mode 100644 index 563789e5..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/Expectation.java +++ /dev/null @@ -1,128 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.List; -import java.util.Map; -import org.jspecify.annotations.Nullable; - -/** - * The validated expectation block of one corpus record, already checked for completeness by the loader. - * - *

            The success and failure shapes are a discriminated union in the corpus JSON; on the loaded record both live on one - * object and the loader guarantees the field combinations that the outcome allows: a failure expectation always carries - * an exception name or an error code, and success-only fields never appear on a failure expectation or the other way - * round. - * - *

            The task-family block (Q21–Q23) adds three success-only fields: {@code promptTextContains} asserts structural - * fragments of the generated task prompt (the {@code ## 任务类型(Task Type)} style section headers), {@code missingParams} - * asserts the exact set of schema parameters the validation found missing — a missing parameter is a null-valued entry - * of the filled parameter data — and {@code paramsFromStep} ties this validation step to the earlier task-validation - * step whose missing parameters this step must have filled. - * - * @param success true for the success outcome, false for the failure outcome - * @param exception expected exception simple name, failure expectations only - * @param code expected error code, failure expectations only - * @param messageContains substrings the failure message must contain, failure expectations only - * @param slotErrors expected slot errors as slot and code pairs, failure expectations only - * @param llmCalls expected exact LLM call count, or null when the record does not pin it - * @param promptTextEqualsGolden golden fixture name the generated prompt text must equal, success expectations only - * @param metadata expected metadata entries, success expectations only - * @param params expected merged parameter map, success expectations only; when {@code missingParams} is present the - * entries are a subset check instead of full-map equality (the missing-parameter set carries the rest) - * @param contracts behavior contract names to assert, both outcomes - * @param differential true when the engine runs the from-text and from-data double assertion, success expectations - * @param promptTextContains substrings the generated task prompt text must contain, success expectations of the task - * family, empty when the record states none - * @param missingParams exact set of parameter names expected to be missing (null-valued) in the filled parameter data, - * success expectations of the task validation; null when the record does not assert the missing set - * @param paramsFromStep number of the earlier scenario step whose missing parameters this step must have filled; - * resolved by the ScenarioEngine, null on standalone cases - * @since 2026-08 - */ -public record Expectation( - boolean success, - @Nullable String exception, - @Nullable String code, - List messageContains, - List slotErrors, - @Nullable Integer llmCalls, - @Nullable String promptTextEqualsGolden, - @Nullable Metadata metadata, - Map params, - List contracts, - boolean differential, - List promptTextContains, - @Nullable List missingParams, - @Nullable Integer paramsFromStep) { - - /** - * Creates an expectation without any task-family assertions; kept for the negotiation-family records and the inline - * sample cases of the engine tests. - * - * @param success true for the success outcome, false for the failure outcome - * @param exception expected exception simple name, failure expectations only - * @param code expected error code, failure expectations only - * @param messageContains substrings the failure message must contain, failure expectations only - * @param slotErrors expected slot errors as slot and code pairs, failure expectations only - * @param llmCalls expected exact LLM call count, or null when the record does not pin it - * @param promptTextEqualsGolden golden fixture name the generated prompt text must equal, success expectations only - * @param metadata expected metadata entries, success expectations only - * @param params expected merged parameter map, success expectations only - * @param contracts behavior contract names to assert, both outcomes - * @param differential true when the engine runs the from-text and from-data double assertion - */ - public Expectation( - boolean success, - @Nullable String exception, - @Nullable String code, - List messageContains, - List slotErrors, - @Nullable Integer llmCalls, - @Nullable String promptTextEqualsGolden, - @Nullable Metadata metadata, - Map params, - List contracts, - boolean differential) { - this( - success, - exception, - code, - messageContains, - slotErrors, - llmCalls, - promptTextEqualsGolden, - metadata, - params, - contracts, - differential, - List.of(), - null, - null); - } - - public Expectation { - messageContains = List.copyOf(messageContains); - slotErrors = List.copyOf(slotErrors); - params = Map.copyOf(params); - contracts = List.copyOf(contracts); - promptTextContains = List.copyOf(promptTextContains); - if (missingParams != null) { - missingParams = List.copyOf(missingParams); - } - } - - /** - * One expected slot error as a slot and code pair. - * - * @param slot slot name such as {@code round} - * @param code expected slot error code such as {@code negotiation.round_exceeded} - */ - public record SlotError(String slot, String code) {} - - /** - * The expected metadata entries of a success expectation. - * - * @param templateUriEcho expected template URI metadata echo, or null when not asserted - * @param contextEcho true when the negotiation context must travel in the metadata, or null when not asserted - */ - public record Metadata(@Nullable String templateUriEcho, @Nullable Boolean contextEcho) {} -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromDataCorpusSuiteTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromDataCorpusSuiteTest.java deleted file mode 100644 index 49489332..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromDataCorpusSuiteTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.stream.Stream; -import org.junit.jupiter.api.DynamicTest; -import org.junit.jupiter.api.TestFactory; - -/** - * Data-driven suite of the from-data family corpus: every case under {@code negotiation-cases/from-data/} becomes one - * dynamic test executed by the {@link CaseEngine} against the production negotiation content wiring. - * - *

            The seed batch carries {@code from-data/happy.json} (FD-HAPPY: deterministic accept generation with a zero LLM call - * proof). The later batch (FD-PROG, the absorbed 22-row programming-error matrix) lands as a further file of the same - * directory without any Java change. - * - *

            Run a subset with {@code -Dcase.filter=FD-*} (glob against the case id, design document Q1). - * - * @since 2026-08 - */ -class FromDataCorpusSuiteTest { - - /** Executes every expanded from-data case of the corpus. */ - @TestFactory - Stream fromDataCorpusCases() { - return CorpusSuites.caseTests("from-data"); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromTextCorpusSuiteTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromTextCorpusSuiteTest.java deleted file mode 100644 index 3a8e9845..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/FromTextCorpusSuiteTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.stream.Stream; -import org.junit.jupiter.api.DynamicTest; -import org.junit.jupiter.api.TestFactory; - -/** - * Data-driven suite of the from-text family corpus: every case under {@code negotiation-cases/from-text/} becomes one - * dynamic test executed by the {@link CaseEngine} against the production negotiation content wiring. - * - *

            The seed batch carries {@code from-text/happy.json} (FT-HAPPY: propose with the differential double run, accept with - * the conclusion-literal contract) and {@code from-text/template-resolution.json} (FT-TPL: the injected - * template-not-found matrix). The later batches (FT-PROG, FT-EXTRACT, FT-RETRY) land as further files of the same - * directory without any Java change. - * - *

            Run a subset with {@code -Dcase.filter=FT-RETRY-*} (glob against the case id, design document Q1). - * - * @since 2026-08 - */ -class FromTextCorpusSuiteTest { - - /** Executes every expanded from-text case of the corpus. */ - @TestFactory - Stream fromTextCorpusCases() { - return CorpusSuites.caseTests("from-text"); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCase.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCase.java deleted file mode 100644 index a65e02cd..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCase.java +++ /dev/null @@ -1,69 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.List; -import java.util.Objects; -import com.fasterxml.jackson.databind.JsonNode; -import org.jspecify.annotations.Nullable; - -/** - * One live-LLM corpus case record expanded for exactly one language: the sibling of {@link NegotiationCase} that - * runs against a real LLM endpoint instead of a scripted one. - * - *

            The live family lives under {@code live/} of the corpus root and shares the base record fields with the offline - * family, but carries no {@code llm} script, no {@code inject} hook and no typed {@code inputData} — the behavior - * under test comes from the real model — and replaces the offline expectation block with the looser - * {@link LiveExpectation} (subset assertions and an LLM call upper bound). Live phase 1 covers the two TASK APIs - * and zh-CN only; the loader enforces both, and live records land in {@link LoadedCorpus#liveCases()}, never in the - * offline {@code cases} list. - * - * @param id expanded id such as {@code LIVE-GEN-01/zh-CN} - * @param baseId corpus record id before the language expansion, such as {@code LIVE-GEN-01} - * @param sourceFile corpus file path relative to the corpus root, such as {@code live/generate.json} - * @param api the TASK-family API under test - * @param language language this expansion renders for (zh-CN in live phase 1) - * @param priority P0, P1 or P2, or null when the record does not state one - * @param tags free tags, empty when the record states none - * @param summary business-facing one-line summary, or null when the record states none - * @param context inline negotiation context spec, or null for the null-context probes - * @param templateUri template URI, or null when the case probes a null URI - * @param inputText free-text input of this language, or null when the case declares none - * @param prompt resolved prompt source of the validate API, or null otherwise - * @param schema resolved JSON Schema of the validate API, or null otherwise - * @param liveExpect validated live expectation block - * @since 2026-08 - */ -public record LiveCase( - String id, - String baseId, - String sourceFile, - NegotiationApi api, - String language, - @Nullable String priority, - List tags, - @Nullable String summary, - @Nullable ContextSpec context, - @Nullable String templateUri, - @Nullable String inputText, - @Nullable PromptSource prompt, - @Nullable JsonNode schema, - LiveExpectation liveExpect) { - - public LiveCase { - Objects.requireNonNull(id, "id"); - Objects.requireNonNull(baseId, "baseId"); - Objects.requireNonNull(sourceFile, "sourceFile"); - Objects.requireNonNull(api, "api"); - Objects.requireNonNull(language, "language"); - Objects.requireNonNull(liveExpect, "liveExpect"); - tags = List.copyOf(tags); - } - - /** - * Returns the JSON path prefix for failure messages of this case, naming the file and the expanded id. - * - * @return prefix such as {@code live/generate.json [LIVE-GEN-01/zh-CN]} - */ - public String errorPrefix() { - return sourceFile + " [" + id + "]"; - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngine.java deleted file mode 100644 index 4b32944f..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngine.java +++ /dev/null @@ -1,480 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.nio.file.Path; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ErrorCatalog; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import org.jspecify.annotations.Nullable; - -/** - * Executes one {@link LiveCase} against the real LLM endpoint of the live family (design document §4) and asserts its - * {@link LiveExpectation}. - * - *

            Everything except the model itself is production assembly: the engine wraps the real provider client built from - * {@link LiveLlmConfig} in a {@link RecordingLLMClient} and hands the wrapper to the {@link TaskApiAssembler} — the - * real facade builders' assembly of the closed loop — driven by the {@code LiveLlmEnvWriter} bridge, so the pipeline - * retry limit comes from the bridge's {@code A2AT_LLM_MAX_ATTEMPTS=3} ([R6]/[R7], not a per-record value); the offline - * engine-test seam keeps the scripted minimal env instead. A generation case runs the whole mini closed loop its - * records assert against: the generated prompt is fed straight into {@code validateTaskPromptAndDataFilling} with the - * record's schema, so {@code paramsContains}/{@code paramsAbsent} judge the validate-step parameter keys and the two - * calls together make up the {@code maxLlmCalls} budget. {@code scenarioCode} compares the scenario segment of the - * template URI the pipeline echoes — the from-text task API resolves the template from the caller's URI and reports no - * separate recognition result. - * - *

            Retry semantics (Q7): only infrastructure failures retry — LLM runtime errors and IO/timeout exceptions, raw or - * wrapped by the pipeline's LLM-infrastructure error codes — at most {@code -Dcorpus.live.infraRetries} times (default - * 2), each attempt on a fresh recording client; an assertion mismatch fails immediately. Every case verdict is appended - * to the run's {@link LiveTranscript} before it surfaces: the transcript keeps the recorded LLM calls of the failed - * attempts too, while {@code maxLlmCalls} bounds only the final, judged attempt. - * - * @since 2026-08 - */ -public final class LiveCaseEngine { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - /** - * System property overriding the infra-retry limit (live design document §4, same property channel as case.filter). - */ - static final String INFRA_RETRIES_PROPERTY = "corpus.live.infraRetries"; - - /** Default of the infra-retry limit: at most two retries, three attempts in total. */ - private static final int DEFAULT_INFRA_RETRIES = 2; - - /** Fixed pipeline retry limit of the live env bridge ([R6]: the record does not declare it). */ - private static final int PIPELINE_MAX_ATTEMPTS = 3; - - private static final int MAX_REPORTED_TEXT_LENGTH = 400; - - private final LLMClient llmClient; - - private final int infraRetries; - - /** The {@code LiveLlmEnvWriter} bridge of the configured run; null on the offline engine-test seam. */ - private final @Nullable Path envFile; - - private final LiveTranscript.Run transcript; - - /** - * Creates the engine for one suite run: the real provider client of the given configuration, the default - * infra-retry limit, the live {@code .env} bridge of the configuration and the run the verdicts are appended to. - * - * @param config resolved live test configuration - * @param transcript run handle the case verdicts are appended to - */ - public LiveCaseEngine(LiveLlmConfig config, LiveTranscript.Run transcript) { - this(LiveLlmConfig.createLlmClient(config), infraRetryLimit(), LiveLlmEnvWriter.envFileFor(config), transcript); - } - - /** - * Creates the engine around an explicit LLM client, env file and infra-retry limit — the seam of the offline engine - * tests, which drive the assertion logic with a stub client and the scripted minimal env instead of a real endpoint - * and the live env bridge. - * - * @param llmClient the LLM client every recording wrapper delegates to - * @param infraRetries number of infrastructure retries after the first attempt (negative values clamp to 0) - * @param envFile the {@code .env} file the task assembly loads (the {@code LiveLlmEnvWriter} bridge), or null to - * fall back to the scripted minimal env with the fixed pipeline retry limit - * @param transcript run handle the case verdicts are appended to - */ - LiveCaseEngine(LLMClient llmClient, int infraRetries, @Nullable Path envFile, LiveTranscript.Run transcript) { - this.llmClient = Objects.requireNonNull(llmClient, "llmClient"); - this.infraRetries = Math.max(0, infraRetries); - this.envFile = envFile; - this.transcript = Objects.requireNonNull(transcript, "transcript"); - } - - /** - * Runs one live case, appends its verdict to the transcript and returns it. - * - * @param testCase expanded live corpus case - * @return the verdict of the executed case - * @throws AssertionError when a live expectation mismatches (the transcript carries the same diff) - * @throws RuntimeException when the engine itself failed, or an infrastructure failure survived all retries - */ - public LiveCaseResult run(LiveCase testCase) { - long startNanos = System.nanoTime(); - int attempts = infraRetries + 1; - List recordedCalls = new ArrayList<>(); - for (int attempt = 1; attempt <= attempts; attempt++) { - RecordingLLMClient recording = new RecordingLLMClient(llmClient); - TaskApiAssembler assembler = envFile == null - ? new TaskApiAssembler(testCase.language(), PIPELINE_MAX_ATTEMPTS, recording) - : new TaskApiAssembler(envFile, recording); - try { - Judged judged = judge(assembler, recording, testCase); - recordedCalls.addAll(recording.snapshot()); - boolean passed = judged.failure() == null; - LiveCaseResult result = asResult( - testCase, - judged, - passed ? LiveCaseResult.Outcome.PASS : LiveCaseResult.Outcome.FAIL, - recordedCalls, - Duration.ofNanos(System.nanoTime() - startNanos).toMillis(), - passed ? null : judged.failure().getMessage()); - transcript.appendCase(result); - if (!passed) { - throw judged.failure(); - } - return result; - } catch (RuntimeException error) { - recordedCalls.addAll(recording.snapshot()); - if (isInfrastructureFailure(error) && attempt < attempts) { - continue; - } - // A surviving failure is recorded as ERROR and rethrown, so the transcript verdict and the build - // verdict agree on the same red instead of the summary counting a skip the build reports as an error. - String failureDiff = isInfrastructureFailure(error) - ? "infrastructure failure after " + attempts + " attempts: " + error - : error.toString(); - LiveCaseResult result = asResult( - testCase, - null, - LiveCaseResult.Outcome.ERROR, - recordedCalls, - Duration.ofNanos(System.nanoTime() - startNanos).toMillis(), - failureDiff); - transcript.appendCase(result); - throw error; - } - } - throw new IllegalStateException( - testCase.errorPrefix() + " the live engine left the retry loop without a verdict"); - } - - // ------------------------------------------------------------------ judging - - /** - * Judges one attempt without throwing for an expectation mismatch: the returned {@link Judged} carries the pipeline - * output plus the first failed assertion, so the transcript can embed what the pipeline actually produced even for - * a failed case. Only infrastructure failures propagate — they are retried by {@link #run}. - */ - private static Judged judge(TaskApiAssembler assembler, RecordingLLMClient recording, LiveCase testCase) { - LiveExpectation expect = testCase.liveExpect(); - Judgement judgement = null; - RuntimeException pipelineFailure = null; - try { - judgement = invoke(assembler, testCase); - } catch (RuntimeException error) { - if (isInfrastructureFailure(error)) { - throw error; - } - pipelineFailure = error; - } - @Nullable AssertionError failure = null; - if (expect.success()) { - failure = pipelineFailure != null - ? fail(testCase, "$.expect.success", "success", "failure (" + pipelineFailure + ")") - : assertValueExpectations(testCase, judgement); - } else if (pipelineFailure == null) { - failure = fail( - testCase, - "$.expect.success", - "failure", - "success (" + render(judgement == null ? null : judgement.params()) + ")"); - } - if (failure == null && expect.maxLlmCalls() != null && recording.callCount() > expect.maxLlmCalls()) { - failure = fail( - testCase, - "$.expect.maxLlmCalls", - "at most " + expect.maxLlmCalls() + " LLM calls", - String.valueOf(recording.callCount())); - } - return new Judged(judgement, failure); - } - - /** - * Asserts the value-level live expectations; returns the first mismatch instead of throwing so the caller keeps the - * pipeline output for the transcript. - */ - private static @Nullable AssertionError assertValueExpectations(LiveCase testCase, Judgement judgement) { - LiveExpectation expect = testCase.liveExpect(); - if (expect.scenarioCode() != null && !expect.scenarioCode().equals(judgement.scenarioCode())) { - return fail( - testCase, "$.expect.scenarioCode", expect.scenarioCode(), String.valueOf(judgement.scenarioCode())); - } - Map params = - judgement.params() == null ? Map.of() : judgement.params().data(); - for (Map.Entry entry : expect.paramsContains().entrySet()) { - Object actual = params.get(entry.getKey()); - if (actual == null) { - return fail( - testCase, - "$.expect.paramsContains", - entry.getKey() + "=" + entry.getValue(), - entry.getKey() + "=" + (params.containsKey(entry.getKey()) ? "null" : "(missing)")); - } - if (!valueMatches(entry.getValue(), actual)) { - return fail( - testCase, - "$.expect.paramsContains", - entry.getKey() + "=" + entry.getValue(), - entry.getKey() + "=" + actual); - } - } - for (String slot : expect.paramsAbsent()) { - if (params.get(slot) != null) { - return fail( - testCase, "$.expect.paramsAbsent", slot + " null or missing", slot + "=" + params.get(slot)); - } - } - for (String fragment : expect.promptTextContains()) { - String promptText = - judgement.message() == null || judgement.message().promptText() == null - ? "" - : judgement.message().promptText(); - if (!normalize(promptText).contains(normalize(fragment))) { - return fail( - testCase, - "$.expect.promptTextContains", - "a prompt text containing '" + fragment + "'", - quoted(truncate(normalize(promptText)))); - } - } - return null; - } - - // ------------------------------------------------------------------ API dispatch - - /** - * Invokes the case's API on the real task assembly: the generation API runs the mini closed loop (generate, then - * validate the generated prompt against the record's schema), the validation API validates the record's prompt. - */ - private static Judgement invoke(TaskApiAssembler assembler, LiveCase testCase) { - TemplateUri templateUri = parseTemplateUri(testCase); - Map schema = requireSchema(testCase); - return switch (testCase.api()) { - case GENERATE_TASK_PROMPT_FROM_TEXT -> { - MetadataContent message = assembler.generateTaskPromptFromText(requireInputText(testCase), templateUri); - FilledParamData params = - assembler.validateTaskPromptAndDataFilling(message.promptText(), schema, templateUri); - yield new Judgement(message, params, scenarioOf(message.templateUri())); - } - case VALIDATE_TASK_PROMPT_AND_DATA_FILLING -> { - FilledParamData params = - assembler.validateTaskPromptAndDataFilling(requirePromptText(testCase), schema, templateUri); - yield new Judgement(null, params, scenarioOf(templateUri.uri())); - } - default -> throw new IllegalStateException( - testCase.errorPrefix() + " the live engine covers the two phase-1 task APIs but got " - + testCase.api().jsonName()); - }; - } - - // ------------------------------------------------------------------ result assembly - - private static LiveCaseResult asResult( - LiveCase testCase, - @Nullable Judged judged, - LiveCaseResult.Outcome outcome, - List recordedCalls, - long durationMs, - @Nullable String failureDiff) { - Judgement judgement = judged == null ? null : judged.judgement(); - return new LiveCaseResult( - testCase.id(), - outcome, - assertionSummary(testCase, recordedCalls.size()), - inputSummaryOf(testCase), - judgement == null ? null : judgement.scenarioCode(), - judgement == null || judgement.params() == null - ? null - : judgement.params().data(), - recordedCalls, - durationMs, - failureDiff); - } - - /** - * Excerpt of what the case fed the pipeline (design document §5: the transcript's input summary): the - * natural-language text of a generation case, or the prompt text a validation case validates, truncated to the - * reporting length. - */ - private static @Nullable String inputSummaryOf(LiveCase testCase) { - String input = testCase.api() == NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT - ? testCase.inputText() - : testCase.prompt() instanceof PromptSource.Text text ? text.text() : null; - return input == null ? null : truncate(normalize(input)); - } - - /** One-line summary of what the case asserted, with the actual LLM call count for quick budget reading. */ - private static String assertionSummary(LiveCase testCase, int llmCalls) { - LiveExpectation expect = testCase.liveExpect(); - StringBuilder summary = new StringBuilder("expect.success=").append(expect.success()); - if (expect.scenarioCode() != null) { - summary.append(" scenarioCode=").append(expect.scenarioCode()); - } - if (!expect.paramsContains().isEmpty()) { - summary.append(" paramsContains=").append(expect.paramsContains().keySet()); - } - if (!expect.paramsAbsent().isEmpty()) { - summary.append(" paramsAbsent=").append(expect.paramsAbsent()); - } - if (!expect.promptTextContains().isEmpty()) { - summary.append(" promptTextContains=") - .append(expect.promptTextContains().size()); - } - if (expect.maxLlmCalls() != null) { - summary.append(" maxLlmCalls<=").append(expect.maxLlmCalls()); - } - return summary.append(" llmCalls=").append(llmCalls).toString(); - } - - // ------------------------------------------------------------------ classification - - /** - * Classifies one pipeline exception as an infrastructure failure (Q7): the LLM client's runtime errors and - * IO/timeout exceptions, raw or anywhere in the pipeline's wrapping cause chain, or an error carrying one of the - * retryable {@code llm.*} codes. Semantic rejections and rule violations are verdicts, not outages, and never - * retry. - */ - private static boolean isInfrastructureFailure(Throwable error) { - for (Throwable current = error; - current != null; - current = current.getCause() == current ? null : current.getCause()) { - if (current instanceof LLMRuntimeError || current instanceof IOException) { - return true; - } - if (current instanceof A2ATError a2atError - && (ErrorCatalog.LLM_INVOCATION_FAILED.getCode().equals(a2atError.getCode()) - || ErrorCatalog.LLM_RESPONSE_INVALID.getCode().equals(a2atError.getCode()) - || ErrorCatalog.LLM_NOT_CONFIGURED.getCode().equals(a2atError.getCode()))) { - return true; - } - } - return false; - } - - private static int infraRetryLimit() { - Integer limit = Integer.getInteger(INFRA_RETRIES_PROPERTY, DEFAULT_INFRA_RETRIES); - return limit == null ? DEFAULT_INFRA_RETRIES : Math.max(0, limit); - } - - // ------------------------------------------------------------------ helpers - - /** - * Compares one expected slot value with the extracted one: strings compare trimmed (model outputs may pad), and a - * number matches its JSON string form so a schema-declared number and its quoted extraction stay equivalent. - */ - private static boolean valueMatches(Object expected, Object actual) { - Double expectedNumber = toDoubleOrNull(expected); - Double actualNumber = toDoubleOrNull(actual); - if (expectedNumber != null - && actualNumber != null - && (expected instanceof Number || actual instanceof Number)) { - return Double.compare(expectedNumber, actualNumber) == 0; - } - return String.valueOf(expected).trim().equals(String.valueOf(actual).trim()); - } - - private static @Nullable Double toDoubleOrNull(Object value) { - if (value instanceof Number number) { - return number.doubleValue(); - } - if (value instanceof String text) { - try { - return Double.valueOf(text.trim()); - } catch (NumberFormatException error) { - return null; - } - } - return null; - } - - /** - * Returns the scenario segment of a template URI — the last path segment of the Task-T layout - * ({@code Task-T/network-layer/private-line-complaint/v1} names the scenario {@code private-line-complaint}); null - * when the URI does not parse. - */ - private static @Nullable String scenarioOf(@Nullable String templateUri) { - return TemplateUri.parse(templateUri) - .map(uri -> uri.pathSegments().get(uri.pathSegments().size() - 1)) - .orElse(null); - } - - private static TemplateUri parseTemplateUri(LiveCase testCase) { - String raw = testCase.templateUri(); - if (raw == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " templateUri: the live task APIs require a template URI"); - } - return TemplateUri.parse(raw) - .orElseThrow(() -> new IllegalStateException( - testCase.errorPrefix() + " templateUri: unparseable template URI " + raw)); - } - - private static String requireInputText(LiveCase testCase) { - String text = testCase.inputText(); - if (text == null) { - throw new IllegalStateException( - testCase.errorPrefix() + " input.text: the task from-text API requires a text input"); - } - return text; - } - - private static String requirePromptText(LiveCase testCase) { - if (testCase.prompt() instanceof PromptSource.Text text) { - return text.text(); - } - throw new IllegalStateException( - testCase.errorPrefix() + " prompt.text: the live validate API requires an inline prompt text"); - } - - private static Map requireSchema(LiveCase testCase) { - JsonNode schema = testCase.schema(); - if (schema == null) { - throw new IllegalStateException(testCase.errorPrefix() + " schema: the live task APIs require a schema"); - } - return MAPPER.convertValue(schema, new TypeReference>() {}); - } - - private static String render(@Nullable FilledParamData params) { - return params == null ? "(no parameter data)" : String.valueOf(params.data()); - } - - private static String normalize(@Nullable String text) { - return text == null ? "" : text.replace("\r\n", "\n"); - } - - private static String truncate(String text) { - return text.length() <= MAX_REPORTED_TEXT_LENGTH ? text : text.substring(0, MAX_REPORTED_TEXT_LENGTH) + "..."; - } - - private static String quoted(@Nullable String text) { - return text == null ? "null" : "<" + text + ">"; - } - - private static AssertionError fail(LiveCase testCase, String jsonPath, String expected, String actual) { - return new AssertionError( - testCase.errorPrefix() + " " + jsonPath + ": expected " + expected + " but was " + actual); - } - - // ------------------------------------------------------------------ internal records - - /** - * The judgement of one attempt: the pipeline output plus the first failed assertion, or null when the attempt - * passed. - */ - private record Judged(@Nullable Judgement judgement, @Nullable AssertionError failure) {} - - /** - * What the pipeline produced for one case: the generated message of a generation case (null for a pure validation - * case or an expected failure), the filled parameter data of the validate step, and the scenario code the pipeline - * reported. - */ - private record Judgement( - @Nullable MetadataContent message, @Nullable FilledParamData params, @Nullable String scenarioCode) {} -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngineTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngineTest.java deleted file mode 100644 index 6378e827..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseEngineTest.java +++ /dev/null @@ -1,282 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -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.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Offline unit tests of the {@link LiveCaseEngine} judging logic: a step-playing stub client drives the real production - * assembly exactly the way a live endpoint would (the scripted responses reuse the response shapes the offline task - * scenarios script), so the expectation assertions, the infra-only retry and the transcript verdicts are verified - * without any endpoint. - * - * @since 2026-08 - */ -class LiveCaseEngineTest { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - /** Slot-extraction response of the generate step, the shape the offline task scenarios script. */ - private static final String SLOT_EXTRACTION_RESPONSE = - """ - {"slots":{"任务对象":"P533-珠江旧城-PTN3900-23-TPA1EG24-1",\ - "任务上下文":"投诉分类:专线质差;问题发生时间:2026-05-11T08:21:46Z;\ - OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的专线时延从平均12ms骤升至320ms"},\ - "slot_errors":[]}"""; - - /** Validate-step response of a semantically compliant prompt with every parameter filled. */ - private static final String VALIDATE_PASS_RESPONSE = - """ - {"semantic_verdict":true,"errors":[],"params":{"accessPort":"P533-珠江旧城-PTN3900-23-TPA1EG24-1",\ - "bizScenario":"专线质差","faultTime":"2026-05-11T08:21:46Z","eventSerialNo":"event-id-20260511-09013"}}"""; - - /** Validate-step response whose two schema-required parameters stay null (the paramsAbsent probes). */ - private static final String VALIDATE_PARTIAL_RESPONSE = - """ - {"semantic_verdict":true,"errors":[],"params":{"accessPort":null,"bizScenario":null,\ - "faultTime":"2026-05-11T08:21:46Z","eventSerialNo":"event-id-20260511-09013"}}"""; - - /** Validate-step response of a semantically contradictory prompt (the semantic-reject probes). */ - private static final String VALIDATE_REJECT_RESPONSE = - """ - {"semantic_verdict":false,"errors":[{"slot_name":"bizScenario","code":"content.semantic_conflict",\ - "facts":{"section_label":"投诉分类","reason":"投诉分类声明为专线质差,投诉详情却描述完全中断"}}],"params":{}}"""; - - /** Hand-authored prompt following the Task-T private-line template structure (the validate family's input). */ - private static final String VALIDATE_PROMPT = - """ - ## 任务类型(Task Type) - 传输专线业务投诉诊断 - - ## 任务描述(Task Description) - 基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。 - - ## 任务目标(Task Target) - 对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。 - - ## 任务对象(Task Object) - 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1 - - ## 任务上下文(Task Context) - 1. 投诉分类:“专线质差” - 2. 问题发生时间:“2026-05-11T08:21:46Z” - 3. OSS侧事件流水号:“event-id-20260511-09013” - 4. 投诉详情:“从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报‘连接超时’。” - - ## 预期输出(Expected Output) - 要求投诉诊断任务的结果包含如下信息: - 1. 诊断结果;参数的取值范围包括:成功、失败;(必选) - 2. 诊断结果详细信息; (必选) - 3. 修复建议; (可选)"""; - - @TempDir - Path tempDir; - - @Test - void generateCaseRunsTheClosedLoopAndAssertsTheKeyFields() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine( - new StepStubClient(List.of(SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), 2, null, run); - LiveCase testCase = generateCase(new LiveExpectation( - true, - "private-line-complaint", - Map.of("accessPort", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "bizScenario", "专线质差"), - List.of(), - List.of("## 任务类型", "event-id-20260511-09013"), - 4)); - - LiveCaseResult result = engine.run(testCase); - - assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); - assertEquals("private-line-complaint", result.scenarioCode()); - assertTrue(result.inputSummary().contains("深圳访问广州的政企专线"), "the verdict carries the input summary"); - assertEquals("P533-珠江旧城-PTN3900-23-TPA1EG24-1", result.params().get("accessPort")); - // The generation case is a mini closed loop: one slot-extraction call plus one validate call. - assertEquals(2, result.llmCalls().size()); - LiveRunSummary summary = run.summary(); - assertEquals(1, summary.totalCases()); - assertEquals(1, summary.passCount()); - assertEquals(2, summary.totalLlmCalls()); - } - - @Test - void validateCaseAssertsParamsContainsAndParamsAbsent() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = - new LiveCaseEngine(new StepStubClient(List.of(VALIDATE_PARTIAL_RESPONSE)), 2, null, run); - LiveCase testCase = validateCase(new LiveExpectation( - true, - null, - Map.of("faultTime", "2026-05-11T08:21:46Z", "eventSerialNo", "event-id-20260511-09013"), - List.of("accessPort", "bizScenario"), - List.of(), - 3)); - - LiveCaseResult result = engine.run(testCase); - - assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); - assertEquals(1, result.llmCalls().size()); - assertNull(result.params().get("accessPort")); - assertEquals("event-id-20260511-09013", result.params().get("eventSerialNo")); - assertEquals(1, run.summary().passCount()); - } - - @Test - void expectedFailureVerdictPassesWhenThePipelineRejects() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine(new StepStubClient(List.of(VALIDATE_REJECT_RESPONSE)), 2, null, run); - LiveCase testCase = validateCase(new LiveExpectation(false, null, Map.of(), List.of(), List.of(), 3)); - - LiveCaseResult result = engine.run(testCase); - - assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); - assertEquals(1, result.llmCalls().size()); - assertNull(result.params()); - assertEquals(1, run.summary().passCount()); - } - - @Test - void assertionMismatchFailsWithTheCaseDiff() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine( - new StepStubClient(List.of(SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), 2, null, run); - LiveCase testCase = generateCase(new LiveExpectation( - true, null, Map.of("accessPort", "P999-不存在-PTN0000-0-TPA1EG24-0"), List.of(), List.of(), 4)); - - AssertionError failure = - assertThrows(AssertionError.class, () -> engine.run(testCase), "a paramsContains mismatch is a red"); - - assertTrue(failure.getMessage().contains(testCase.errorPrefix()), failure.getMessage()); - assertTrue(failure.getMessage().contains("$.expect.paramsContains"), failure.getMessage()); - assertEquals(1, run.summary().failCount(), "the failed verdict still lands in the transcript"); - } - - @Test - void infrastructureFailuresRetryOnFreshClients() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine( - new StepStubClient(List.of( - new LLMRuntimeError("endpoint unreachable"), SLOT_EXTRACTION_RESPONSE, VALIDATE_PASS_RESPONSE)), - 2, - null, - run); - LiveCase testCase = - generateCase(new LiveExpectation(true, null, Map.of("bizScenario", "专线质差"), List.of(), List.of(), 4)); - - LiveCaseResult result = engine.run(testCase); - - assertEquals(LiveCaseResult.Outcome.PASS, result.outcome()); - // The failed attempt's call is kept in the transcript while the judged attempt made the two closed-loop calls. - assertEquals(3, result.llmCalls().size()); - assertEquals(1, run.summary().passCount()); - assertEquals(3, run.summary().totalLlmCalls()); - } - - @Test - void exhaustedInfrastructureRetriesRecordAnErrorAndStayRed() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - LiveCaseEngine engine = new LiveCaseEngine( - new StepStubClient(List.of(new LLMRuntimeError("endpoint unreachable"))), 0, null, run); - LiveCase testCase = - generateCase(new LiveExpectation(true, null, Map.of("bizScenario", "专线质差"), List.of(), List.of(), 4)); - - assertThrows(RuntimeException.class, () -> engine.run(testCase), "a surviving infra failure stays a red"); - - // The transcript verdict matches the build verdict: the rethrown failure is an ERROR, not a skip. - assertEquals(1, run.summary().errorCount(), "the given-up case is recorded as ERROR"); - assertEquals(1, run.summary().totalLlmCalls()); - } - - // ------------------------------------------------------------------ fixtures - - private static LiveCase generateCase(LiveExpectation expect) { - return new LiveCase( - "LIVE-TEST-GEN/zh-CN", - "LIVE-TEST-GEN", - "live/task-apis.json", - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - "zh-CN", - "P0", - List.of("live"), - "engine test case of the generate API", - null, - "Task-T/network-layer/private-line-complaint/v1", - "深圳访问广州的政企专线自5月11日起时延骤升,柜面交易频繁超时。" + "接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1;投诉分类:专线质差。", - null, - complaintParamsSchema(), - expect); - } - - private static LiveCase validateCase(LiveExpectation expect) { - return new LiveCase( - "LIVE-TEST-VAL/zh-CN", - "LIVE-TEST-VAL", - "live/task-apis.json", - NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - "zh-CN", - "P0", - List.of("live"), - "engine test case of the validate API", - null, - "Task-T/network-layer/private-line-complaint/v1", - null, - new PromptSource.Text(VALIDATE_PROMPT), - complaintParamsSchema(), - expect); - } - - /** The real shared complaint schema of the corpus, so the engine tests judge against the actual validate step. */ - private static JsonNode complaintParamsSchema() { - try (InputStream stream = - LiveCaseEngineTest.class.getResourceAsStream("/negotiation-cases/shared/schemas.json")) { - assertNotNull(stream, "the shared schemas of the corpus must be on the test classpath"); - return MAPPER.readTree(stream).path("biz.complaint.params"); - } catch (IOException exception) { - throw new UncheckedIOException("Failed to read the shared corpus schemas", exception); - } - } - - /** - * Step-playing stub client: it answers the canned response (or rethrows the canned exception) of the next script - * step and repeats the last step once the script is exhausted — the offline stand-in for the live endpoint. - */ - private static final class StepStubClient implements LLMClient { - - private final List steps; - - private int cursor; - - StepStubClient(List steps) { - this.steps = List.copyOf(steps); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - Object step = steps.get(Math.min(cursor++, steps.size() - 1)); - if (step instanceof RuntimeException error) { - throw error; - } - return new LLMResponse((String) step, "stub-model", Map.of(), Map.of()); - } - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseResult.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseResult.java deleted file mode 100644 index 4662ff9a..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCaseResult.java +++ /dev/null @@ -1,68 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import org.jspecify.annotations.Nullable; - -/** - * The per-case verdict of one live corpus run: what the case asserted, what the pipeline extracted from the real - * model's output, and the LLM calls the case triggered. - * - *

            The record is the unit the live suite feeds into {@link LiveTranscript.Run#appendCase(LiveCaseResult)}: PASS and - * FAIL are assertion verdicts (a FAIL carries the assertion diff), and ERROR marks a case that could not be judged — - * an engine failure or an infrastructure failure that survived all retries, rethrown so the build verdict matches the - * recorded one. SKIP is reserved for a case the run did not execute; the phase-1 engine never records it. - * - * @param caseId expanded case id, such as {@code LIVE-GEN-01/zh-CN} - * @param outcome verdict of the case - * @param assertionSummary one-line summary of what the case asserted - * @param inputSummary excerpt of the case's input — the natural-language text of a generation case or the prompt text - * of a validation case, truncated to the reporting length — or null when the case declares none - * @param scenarioCode scenario code the pipeline actually recognized, or null when none was extracted - * @param params parameters the pipeline actually extracted, or null when the case produced no parameter data - * @param llmCalls LLM calls the case triggered, recorded by the {@link RecordingLLMClient} - * @param durationMs wall-clock duration of the case in milliseconds - * @param failureDiff assertion diff of a failed case, or null otherwise - * @since 2026-08 - */ -public record LiveCaseResult( - String caseId, - Outcome outcome, - String assertionSummary, - @Nullable String inputSummary, - @Nullable String scenarioCode, - @Nullable Map params, - List llmCalls, - long durationMs, - @Nullable String failureDiff) { - - /** Verdict of one live case. */ - public enum Outcome { - /** The live expectation block held against the real model's output. */ - PASS, - - /** At least one live expectation assertion failed; {@code failureDiff} carries the diff. */ - FAIL, - - /** The case was not executed in this run; reserved — the phase-1 engine records surviving failures as ERROR. */ - SKIP, - - /** The engine could not judge the case: it failed before the assertions, or an infrastructure failure survived - * all retries. */ - ERROR - } - - public LiveCaseResult { - Objects.requireNonNull(caseId, "caseId"); - Objects.requireNonNull(outcome, "outcome"); - Objects.requireNonNull(assertionSummary, "assertionSummary"); - Objects.requireNonNull(llmCalls, "llmCalls"); - // Map.copyOf rejects null values, but the filled parameter data legitimately carries them: a schema slot the - // prompt misses surfaces as a null-valued entry, exactly what the paramsAbsent probes assert. - params = params == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(params)); - llmCalls = List.copyOf(llmCalls); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCorpusSuiteTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCorpusSuiteTest.java deleted file mode 100644 index b4ae900e..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveCorpusSuiteTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.io.UncheckedIOException; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Stream; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.DynamicTest; -import org.junit.jupiter.api.TestFactory; - -/** - * Data-driven suite of the live-LLM family corpus (live design document §2): every case under {@code - * negotiation-cases/live/} becomes one dynamic test executed by the {@link LiveCaseEngine} against the real - * OpenAI-compatible endpoint of the dedicated test configuration. - * - *

            The family is opt-in exactly like its configuration (Q1): the factory passes through the adjudicated skip gate - * {@link LiveLlmConfig#assumeConfigured()} — an unconfigured environment skips the whole family through an assumption - * with the configuration hint, and a resolved but invalid configuration is a red failure, not a skip — so {@code mvn - * test} and CI stay offline-deterministic. A configured run creates its transcript directory at factory-evaluation - * time (§5 [R-C3]) and flushes the transcript and summary after the last case. - * - *

            Run a subset with {@code -Dcase.filter=LIVE-GEN-*} (the same glob rule as the offline families). - * - * @since 2026-08 - */ -class LiveCorpusSuiteTest { - - /** The run handle of the configured run; null while the family is skipped, set at factory evaluation. */ - private static LiveTranscript.@Nullable Run run; - - /** Executes every expanded live case of the corpus against the real endpoint. */ - @TestFactory - Stream liveCorpusCases() { - LiveLlmConfig.assumeConfigured(); - LiveLlmConfig config = LiveLlmConfig.fromCurrentProcess(); - run = LiveTranscript.createRun(); - LiveCaseEngine engine = new LiveCaseEngine(config, run); - List tests = new ArrayList<>(); - for (LiveCase liveCase : CorpusSuites.loadCorpus().liveCases()) { - if (!CorpusSuites.selected(liveCase.id(), liveCase.baseId())) { - continue; - } - tests.add(DynamicTest.dynamicTest( - CorpusSuites.displayName(liveCase.id(), liveCase.summary()), () -> engine.run(liveCase))); - } - CorpusSuites.noteEmpty("live", tests.size()); - return tests.stream(); - } - - /** - * Flushes the transcript and summary of the configured run once every live case has been executed. A failed write - * is a warning, not a verdict: the case outcomes have already surfaced through their dynamic tests, so a locked or - * read-only {@code target/} must not turn a green run red. - */ - @AfterAll - static void writeTranscript() { - if (run == null) { - return; - } - try { - Path transcript = run.write(); - System.out.println("[corpus] live transcript written to " + transcript + ": " + run.summary()); - } catch (UncheckedIOException exception) { - System.out.println( - "[corpus] WARNING: failed to write the live transcript to " + run.directory() + " (" - + exception.getMessage() + "); the case verdicts above stay authoritative"); - } - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveExpectation.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveExpectation.java deleted file mode 100644 index 8e9fd445..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveExpectation.java +++ /dev/null @@ -1,40 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.List; -import java.util.Map; -import org.jspecify.annotations.Nullable; - -/** - * The validated live expectation block of one {@link LiveCase}, already checked for completeness by the loader. - * - *

            The live family asserts a key-field subset against the output of a real LLM instead of the full offline - * equivalence: {@code paramsContains} is a subset check (the named slots must be filled with the expected values, - * other slots are not asserted), {@code paramsAbsent} names the slots that must be null or missing, and {@code - * maxLlmCalls} is an upper bound rather than the exact call count of the offline {@link Expectation#llmCalls()} — - * a real model may trigger pipeline-internal retries. There is no golden fixture and no differential run. - * - * @param success expected operation outcome; live phase 1 expects the success path only - * @param scenarioCode expected recognized scenario code, or null when the record does not pin it - * @param paramsContains slots that must be filled with exactly these values (subset check), empty when the record - * states none - * @param paramsAbsent slots that must be null or missing, empty when the record states none - * @param promptTextContains structural fragments the generated task prompt text must contain, empty when the record - * states none (generate API only; the loader rejects it on validate records) - * @param maxLlmCalls upper bound of LLM calls the case may trigger; the loader defaults a record that omits it to 4, - * so null appears only on hand-built instances and means unbounded - * @since 2026-08 - */ -public record LiveExpectation( - boolean success, - @Nullable String scenarioCode, - Map paramsContains, - List paramsAbsent, - List promptTextContains, - @Nullable Integer maxLlmCalls) { - - public LiveExpectation { - paramsContains = Map.copyOf(paramsContains); - paramsAbsent = List.copyOf(paramsAbsent); - promptTextContains = List.copyOf(promptTextContains); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmCall.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmCall.java deleted file mode 100644 index da329a28..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmCall.java +++ /dev/null @@ -1,50 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.List; -import java.util.Map; -import org.jspecify.annotations.Nullable; - -/** - * One LLM call recorded by {@link RecordingLLMClient}: the request the pipeline issued plus the response (or the - * failure) the live endpoint produced, and the wall-clock duration of the call. - * - *

            A call that threw carries a non-null {@code error} (the exception class name and message) and null response - * fields; a call that succeeded carries null {@code error}. The record is the unit both of the {@code maxLlmCalls} - * upper bound and of the per-case {@code llmCalls} section of the live transcript. - * - * @param messages messages of the request, one map per message - * @param jsonSchema JSON schema of the request, or null when the pipeline passed none - * @param temperature temperature of the request, or null when the request carries none - * @param maxTokens max-tokens override of the request, or null when the request carries none - * @param content raw response content, or null when the call failed or answered null - * @param model resolved model name of the response, or null when the call failed or answered null - * @param usage token usage summary of the response, empty when the call failed or answered null - * @param durationMs wall-clock duration of the call in milliseconds - * @param error exception class name and message when the call threw, or null on success - * @since 2026-08 - */ -public record LiveLlmCall( - List> messages, - @Nullable Map jsonSchema, - @Nullable Double temperature, - @Nullable Integer maxTokens, - @Nullable String content, - @Nullable String model, - Map usage, - long durationMs, - @Nullable String error) { - - public LiveLlmCall { - messages = messages == null ? List.of() : List.copyOf(messages); - usage = usage == null ? Map.of() : Map.copyOf(usage); - } - - /** - * Returns whether this call threw instead of answering. - * - * @return true when the call failed with an exception - */ - public boolean failed() { - return error != null; - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmConfig.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmConfig.java deleted file mode 100644 index 34e2725b..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmConfig.java +++ /dev/null @@ -1,218 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.core.model.A2ATConfigKeys; -import net.openan.a2at.sdk.core.model.LlmConfig; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMConfigError; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.Assumptions; - -/** - * The dedicated test-LLM configuration of the live corpus family (live design document §1.1), resolved once per process - * and fully decoupled from the production {@code A2AT_LLM_*} variables: the live harness talks to a real - * OpenAI-compatible endpoint (the customer's qwen3-27b-class model) while the offline corpus stays deterministic. - * - *

            Resolution order per variable: the lowercase-dotted system property ({@code -Da2at.test.llm.base.url} and its - * siblings, matching the repo's {@code case.filter} / {@code corpus.review.gate} property convention) takes precedence, - * then the {@code A2AT_TEST_LLM_*} OS environment variable. A variable that is set but blank counts as absent. The three - * required variables must all resolve — any one missing means the live family is not configured and every live test - * skips through {@link #assumeConfigured()}; the two optional variables fall back to their documented defaults. - * - *

            The values are kept as raw strings because their only destinations are string-keyed: the {@code .env} bridge of - * {@link LiveLlmEnvWriter} and {@link LlmConfig#fromMap(Map)}. Numeric validation is delegated to - * {@link LLMClientConfig#from(LlmConfig)} — the gate probe of the live design document reuses exactly that validation - * semantics, so {@link #validationError()} reports what the production config layer would reject. - * - * @param baseUrl OpenAI-compatible endpoint base URL, such as a vLLM deployment of the test model - * @param apiKey endpoint API key (a placeholder value is fine for a local deployment) - * @param model model name to request - * @param temperature sampling temperature, {@code "0"} unless overridden (stable output) - * @param timeoutSeconds request timeout in seconds, {@code "60"} unless overridden - * @since 2026-08 - */ -public record LiveLlmConfig( - String baseUrl, String apiKey, String model, String temperature, String timeoutSeconds) { - - /** Required test variable: OpenAI-compatible endpoint base URL. */ - static final String BASE_URL_VARIABLE = "A2AT_TEST_LLM_BASE_URL"; - - /** Required test variable: endpoint API key. */ - static final String API_KEY_VARIABLE = "A2AT_TEST_LLM_API_KEY"; - - /** Required test variable: model name. */ - static final String MODEL_VARIABLE = "A2AT_TEST_LLM_MODEL"; - - /** Optional test variable: sampling temperature (default 0). */ - static final String TEMPERATURE_VARIABLE = "A2AT_TEST_LLM_TEMPERATURE"; - - /** Optional test variable: request timeout in seconds (default 60). */ - static final String TIMEOUT_SECONDS_VARIABLE = "A2AT_TEST_LLM_TIMEOUT_SECONDS"; - - /** System-property override of {@link #BASE_URL_VARIABLE}. */ - static final String BASE_URL_PROPERTY = "a2at.test.llm.base.url"; - - /** System-property override of {@link #API_KEY_VARIABLE}. */ - static final String API_KEY_PROPERTY = "a2at.test.llm.api.key"; - - /** System-property override of {@link #MODEL_VARIABLE}. */ - static final String MODEL_PROPERTY = "a2at.test.llm.model"; - - /** System-property override of {@link #TEMPERATURE_VARIABLE}. */ - static final String TEMPERATURE_PROPERTY = "a2at.test.llm.temperature"; - - /** System-property override of {@link #TIMEOUT_SECONDS_VARIABLE}. */ - static final String TIMEOUT_SECONDS_PROPERTY = "a2at.test.llm.timeout.seconds"; - - /** Default of the optional temperature, mirroring the live design document §1.1. */ - static final String DEFAULT_TEMPERATURE = "0"; - - /** Default of the optional timeout, mirroring the SDK default the live design document §1.1 keeps. */ - static final String DEFAULT_TIMEOUT_SECONDS = "60"; - - /** The live family speaks the OpenAI-compatible protocol only (live design document §1.2). */ - static final String PROVIDER = "openai"; - - public LiveLlmConfig { - Objects.requireNonNull(baseUrl, "baseUrl"); - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(model, "model"); - Objects.requireNonNull(temperature, "temperature"); - Objects.requireNonNull(timeoutSeconds, "timeoutSeconds"); - } - - /** - * Resolves the live test configuration from the system properties and the OS environment of the current process. - * - * @return the resolved configuration, or null when the family is not configured (any required variable missing) - */ - public static @Nullable LiveLlmConfig fromCurrentProcess() { - String baseUrl = resolve(BASE_URL_PROPERTY, BASE_URL_VARIABLE); - String apiKey = resolve(API_KEY_PROPERTY, API_KEY_VARIABLE); - String model = resolve(MODEL_PROPERTY, MODEL_VARIABLE); - if (baseUrl == null || apiKey == null || model == null) { - return null; - } - String temperature = resolveOrDefault(TEMPERATURE_PROPERTY, TEMPERATURE_VARIABLE, DEFAULT_TEMPERATURE); - String timeoutSeconds = - resolveOrDefault(TIMEOUT_SECONDS_PROPERTY, TIMEOUT_SECONDS_VARIABLE, DEFAULT_TIMEOUT_SECONDS); - return new LiveLlmConfig(baseUrl, apiKey, model, temperature, timeoutSeconds); - } - - /** - * The skip gate of every live test (live design document §1.1): an unconfigured environment skips the whole family - * through {@link Assumptions#assumeTrue(boolean, String)}, mirroring the opt-in gate precedent of - * {@code CorpusContractTest}; a configured but invalid environment is a configuration failure, not a skip, so the - * broken setup cannot hide behind a green build. - */ - public static void assumeConfigured() { - LiveLlmConfig config = fromCurrentProcess(); - Assumptions.assumeTrue(config != null, configurationHint()); - String error = config.validationError(); - if (error != null) { - throw new IllegalStateException("live LLM configuration is invalid: " + error + " (" + configurationHint() - + ")"); - } - } - - /** - * Lists what to set for users hitting the skip: the three required variables in both spellings plus the two - * optional ones with their defaults. Variables that resolve already are omitted, so a partial setup names exactly - * the missing pieces. - * - * @return the configuration-method hint carried by the skip message - */ - private static String configurationHint() { - StringBuilder hint = new StringBuilder("live LLM validation is disabled; set "); - appendVariable(hint, BASE_URL_PROPERTY, BASE_URL_VARIABLE); - appendVariable(hint, API_KEY_PROPERTY, API_KEY_VARIABLE); - appendVariable(hint, MODEL_PROPERTY, MODEL_VARIABLE); - hint.append(" to enable it (optional: ") - .append(TEMPERATURE_VARIABLE) - .append(", default ") - .append(DEFAULT_TEMPERATURE) - .append("; ") - .append(TIMEOUT_SECONDS_VARIABLE) - .append(", default ") - .append(DEFAULT_TIMEOUT_SECONDS) - .append(")"); - return hint.toString(); - } - - private static void appendVariable(StringBuilder hint, String propertyKey, String envName) { - if (resolve(propertyKey, envName) != null) { - return; - } - hint.append("-D").append(propertyKey).append(" or ").append(envName).append(", "); - } - - /** - * Builds the unified LLM configuration the same way the {@code .env} loader builds it: raw string values through - * {@link LlmConfig#fromMap(Map)}, so every default and parse-error semantics of the production path apply. - * - * @return the unified LLM configuration of this test setup - */ - public LlmConfig toLlmConfig() { - Map values = new LinkedHashMap<>(); - values.put(A2ATConfigKeys.Llm.PROVIDER, PROVIDER); - values.put(A2ATConfigKeys.Llm.MODEL, model); - values.put(A2ATConfigKeys.Llm.API_KEY, apiKey); - values.put(A2ATConfigKeys.Llm.BASE_URL, baseUrl); - values.put(A2ATConfigKeys.Llm.TEMPERATURE, temperature); - values.put(A2ATConfigKeys.Llm.TIMEOUT_SECONDS, timeoutSeconds); - return LlmConfig.fromMap(values); - } - - /** - * Probes this configuration with the production validation semantics (live design document §1.1: a configuration - * that can build a legal {@link LLMClientConfig} is enabled). - * - * @return the validation error message, or null when the configuration is legal - */ - public @Nullable String validationError() { - try { - LLMClientConfig.from(toLlmConfig()); - return null; - } catch (LLMConfigError error) { - return error.getMessage(); - } - } - - /** - * Creates the real provider client of this configuration — {@code LLMClientFactory} instantiates the production - * {@code OpenAIClient} for the {@code openai} provider, the exact client the facades would build from the same - * configuration. Configuration problems surface as a readable configuration failure instead of the raw - * {@link LLMConfigError}. - * - * @param config resolved live test configuration - * @return the real OpenAI-compatible LLM client - * @throws IllegalStateException when the configuration fails the production validation - */ - public static LLMClient createLlmClient(LiveLlmConfig config) { - try { - return LLMClientFactory.create(PROVIDER, LLMClientConfig.from(config.toLlmConfig())); - } catch (LLMConfigError error) { - throw new IllegalStateException( - "live LLM configuration is invalid: " + error.getMessage() - + " (fix the A2AT_TEST_LLM_* variables or their -Da2at.test.llm.* overrides)", - error); - } - } - - private static @Nullable String resolve(String propertyKey, String envName) { - String value = System.getProperty(propertyKey); - if (value == null || value.isBlank()) { - value = System.getenv(envName); - } - return value == null || value.isBlank() ? null : value.trim(); - } - - private static String resolveOrDefault(String propertyKey, String envName, String defaultValue) { - String value = resolve(propertyKey, envName); - return value == null ? defaultValue : value; - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmConfigTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmConfigTest.java deleted file mode 100644 index c82379f7..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmConfigTest.java +++ /dev/null @@ -1,169 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -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 java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.llm.providers.OpenAIClient; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.opentest4j.TestAbortedException; - -/** - * Unit tests of the live-LLM configuration bridge (live design document §1.1): property-over-environment resolution, - * the not-configured null, the optional defaults, and the production-validation probe. - * - *

            The tests stay hermetic for {@code mvn test}: every case drives the resolution through system properties and - * restores the values it found afterwards, so a developer's {@code -Da2at.test.llm.*} command-line setup survives the - * run; the assertions that involve the OS environment are guarded by - * {@link Assumptions#assumeTrue(boolean, String)} so they run only on a machine that exports the variable (the - * developer machine that configured the live endpoint), never in CI. - * - * @since 2026-08 - */ -class LiveLlmConfigTest { - - private static final List ALL_PROPERTY_KEYS = List.of( - LiveLlmConfig.BASE_URL_PROPERTY, - LiveLlmConfig.API_KEY_PROPERTY, - LiveLlmConfig.MODEL_PROPERTY, - LiveLlmConfig.TEMPERATURE_PROPERTY, - LiveLlmConfig.TIMEOUT_SECONDS_PROPERTY); - - /** - * The property values the tests found before overriding them: the tests of one JVM may run after a developer - * enabled the live family through {@code -Da2at.test.llm.*}, so the teardown restores the command-line values - * instead of clearing the keys — otherwise this test class could silently switch the live suite off. - */ - private final Map savedProperties = new LinkedHashMap<>(); - - @BeforeEach - void saveProperties() { - for (String key : ALL_PROPERTY_KEYS) { - savedProperties.put(key, System.getProperty(key)); - } - } - - @AfterEach - void restoreProperties() { - for (Map.Entry saved : savedProperties.entrySet()) { - if (saved.getValue() == null) { - System.clearProperty(saved.getKey()); - } else { - System.setProperty(saved.getKey(), saved.getValue()); - } - } - } - - @Test - void absentWhenNoRequiredVariableIsConfigured() { - assumeEnvironmentAbsent( - LiveLlmConfig.BASE_URL_VARIABLE, LiveLlmConfig.API_KEY_VARIABLE, LiveLlmConfig.MODEL_VARIABLE); - assertNull(LiveLlmConfig.fromCurrentProcess(), "no required variable resolves, so the family is not configured"); - assertThrows(TestAbortedException.class, LiveLlmConfig::assumeConfigured); - } - - @Test - void nullWhenOnlyPartiallyConfigured() { - System.setProperty(LiveLlmConfig.MODEL_PROPERTY, "qwen3-27b"); - assumeEnvironmentAbsent(LiveLlmConfig.BASE_URL_VARIABLE, LiveLlmConfig.API_KEY_VARIABLE); - assertNull( - LiveLlmConfig.fromCurrentProcess(), - "any missing required variable means the family is not configured (skip, not a half-configured run)"); - } - - @Test - void blankValueCountsAsNotConfigured() { - System.setProperty(LiveLlmConfig.BASE_URL_PROPERTY, "https://live.example.test/v1"); - System.setProperty(LiveLlmConfig.API_KEY_PROPERTY, " "); - System.setProperty(LiveLlmConfig.MODEL_PROPERTY, "qwen3-27b"); - assumeEnvironmentAbsent(LiveLlmConfig.API_KEY_VARIABLE); - assertNull(LiveLlmConfig.fromCurrentProcess(), "a blank value is an unset value"); - } - - @Test - void systemPropertyTakesPrecedenceOverEnvironmentVariable() { - System.setProperty(LiveLlmConfig.BASE_URL_PROPERTY, "https://property.example.test/v1"); - System.setProperty(LiveLlmConfig.API_KEY_PROPERTY, "property-key"); - System.setProperty(LiveLlmConfig.MODEL_PROPERTY, "property-model"); - LiveLlmConfig config = LiveLlmConfig.fromCurrentProcess(); - assertNotNull(config, "the three required properties configure the family on their own"); - assertEquals("https://property.example.test/v1", config.baseUrl()); - assertEquals("property-key", config.apiKey()); - assertEquals("property-model", config.model()); - } - - @Test - void optionalValuesFallBackToTheirDefaults() { - System.setProperty(LiveLlmConfig.BASE_URL_PROPERTY, "https://live.example.test/v1"); - System.setProperty(LiveLlmConfig.API_KEY_PROPERTY, "live-key"); - System.setProperty(LiveLlmConfig.MODEL_PROPERTY, "qwen3-27b"); - assumeEnvironmentAbsent(LiveLlmConfig.TEMPERATURE_VARIABLE, LiveLlmConfig.TIMEOUT_SECONDS_VARIABLE); - LiveLlmConfig config = LiveLlmConfig.fromCurrentProcess(); - assertNotNull(config); - assertEquals(LiveLlmConfig.DEFAULT_TEMPERATURE, config.temperature(), "temperature defaults to 0"); - assertEquals(LiveLlmConfig.DEFAULT_TIMEOUT_SECONDS, config.timeoutSeconds(), "timeout defaults to 60 seconds"); - } - - @Test - void environmentVariableIsUsedWhenNoPropertyIsSet() { - Assumptions.assumeTrue( - System.getenv(LiveLlmConfig.MODEL_VARIABLE) != null, - "runs only on a machine that exports " + LiveLlmConfig.MODEL_VARIABLE); - LiveLlmConfig config = LiveLlmConfig.fromCurrentProcess(); - assertNotNull(config, "the environment variables configure the family when no property intervenes"); - assertEquals(System.getenv(LiveLlmConfig.MODEL_VARIABLE).trim(), config.model()); - } - - @Test - void validConfigurationBuildsTheRealOpenAiClient() { - System.setProperty(LiveLlmConfig.BASE_URL_PROPERTY, "https://live.example.test/v1"); - System.setProperty(LiveLlmConfig.API_KEY_PROPERTY, "live-key"); - System.setProperty(LiveLlmConfig.MODEL_PROPERTY, "qwen3-27b"); - LiveLlmConfig config = LiveLlmConfig.fromCurrentProcess(); - assertNotNull(config); - assertNull(config.validationError(), "the defaults pass the production validation"); - assertEquals("openai", config.toLlmConfig().provider()); - assertEquals("qwen3-27b", config.toLlmConfig().model()); - assertEquals(0.0, config.toLlmConfig().temperature()); - assertEquals(60.0, config.toLlmConfig().timeoutSeconds()); - assertInstanceOf(OpenAIClient.class, LiveLlmConfig.createLlmClient(config)); - } - - @Test - void invalidOptionalValueSurfacesAsConfigurationFailure() { - System.setProperty(LiveLlmConfig.BASE_URL_PROPERTY, "https://live.example.test/v1"); - System.setProperty(LiveLlmConfig.API_KEY_PROPERTY, "live-key"); - System.setProperty(LiveLlmConfig.MODEL_PROPERTY, "qwen3-27b"); - System.setProperty(LiveLlmConfig.TEMPERATURE_PROPERTY, "not-a-number"); - LiveLlmConfig config = LiveLlmConfig.fromCurrentProcess(); - assertNotNull(config); - String error = config.validationError(); - assertNotNull(error, "the production validation rejects the non-numeric temperature"); - assertTrue( - error.contains("A2AT_LLM_TEMPERATURE"), - "the error names the offending variable: " + error); - IllegalStateException thrown = - assertThrows(IllegalStateException.class, () -> LiveLlmConfig.createLlmClient(config)); - assertTrue( - thrown.getMessage().contains("live LLM configuration is invalid"), - "the client factory surfaces a configuration failure message: " + thrown.getMessage()); - assertThrows(IllegalStateException.class, LiveLlmConfig::assumeConfigured); - } - - private static void assumeEnvironmentAbsent(String... variables) { - for (String variable : variables) { - Assumptions.assumeTrue( - System.getenv(variable) == null, - "runs only when " + variable + " is not exported by the environment"); - } - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmEnvWriter.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmEnvWriter.java deleted file mode 100644 index 0154ee65..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmEnvWriter.java +++ /dev/null @@ -1,78 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -/** - * Writes the temporary {@code .env} file that bridges the dedicated test variables into the SDK configuration (live - * design document §1.2): the SDK config only accepts a caller-provided {@code .env} file, so the live harness resolves - * {@link LiveLlmConfig} and materializes it exactly once per distinct configuration into a temporary directory, - * following the {@code TaskApiAssembler} env-file precedent. - * - *

            Entry-by-entry rationale: the prompt resources come from the classpath jar with an empty local override root - * ([R2]); the LLM entries carry the real test-endpoint values with an explicit temperature, timeout and retry limit - * ([R7] — all three default to null in {@code LlmConfig}, and an unset temperature would let the server-side default - * decide, which is unstable); language and state store mirror the minimal facade env. - * - * @since 2026-08 - */ -final class LiveLlmEnvWriter { - - private static final Map ENV_FILES = new ConcurrentHashMap<>(); - - /** Fixed retry limit of the live runs ([R6]: not per-record, it comes from the env bridge). */ - private static final String MAX_ATTEMPTS = "3"; - - private LiveLlmEnvWriter() {} - - /** - * Writes (once per distinct configuration) the live {@code .env} file the live harness hands to the SDK config - * loading, mirroring the facade-test minimal-env precedent. - * - * @param config resolved live test configuration - * @return path of the written {@code .env} file - */ - static Path envFileFor(LiveLlmConfig config) { - return ENV_FILES.computeIfAbsent(cacheKey(config), key -> writeEnvFile(config)); - } - - private static Path writeEnvFile(LiveLlmConfig config) { - try { - Path envFile = Files.createTempDirectory("a2at-corpus-live-env").resolve("live.env"); - Files.writeString( - envFile, - ("A2AT_LANGUAGE=zh-CN%n" - + "A2AT_PROMPT_SOURCE_TYPE=classpath%n" - + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=%n" - + "A2AT_LLM_PROVIDER=openai%n" - + "A2AT_LLM_MODEL=%s%n" - + "A2AT_LLM_API_KEY=%s%n" - + "A2AT_LLM_BASE_URL=%s%n" - + "A2AT_LLM_TEMPERATURE=%s%n" - + "A2AT_LLM_TIMEOUT_SECONDS=%s%n" - + "A2AT_LLM_MAX_ATTEMPTS=%s%n" - + "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory%n") - .formatted( - config.model(), - config.apiKey(), - config.baseUrl(), - config.temperature(), - config.timeoutSeconds(), - MAX_ATTEMPTS), - StandardCharsets.UTF_8); - return envFile; - } catch (IOException exception) { - throw new UncheckedIOException("Failed to write the live corpus .env", exception); - } - } - - private static String cacheKey(LiveLlmConfig config) { - return String.join( - "\n", config.baseUrl(), config.apiKey(), config.model(), config.temperature(), config.timeoutSeconds()); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmEnvWriterTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmEnvWriterTest.java deleted file mode 100644 index 65946149..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveLlmEnvWriterTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import org.junit.jupiter.api.Test; - -/** - * Unit tests of the live {@code .env} bridge (live design document §1.2): the written file carries the live entries - * exactly — classpath prompt source with an empty override root, the real test-endpoint LLM values with the explicit - * stability knobs, and the fixed retry limit — and one file is cached per distinct configuration, following the - * {@code TaskApiAssembler} env-file precedent. - * - * @since 2026-08 - */ -class LiveLlmEnvWriterTest { - - @Test - void envFileCarriesTheLiveBridgeEntries() throws IOException { - Path envFile = LiveLlmEnvWriter.envFileFor( - new LiveLlmConfig("https://live.example.test/v1", "live-key", "qwen3-27b", "0", "60")); - assertEquals( - List.of( - "A2AT_LANGUAGE=zh-CN", - "A2AT_PROMPT_SOURCE_TYPE=classpath", - "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=", - "A2AT_LLM_PROVIDER=openai", - "A2AT_LLM_MODEL=qwen3-27b", - "A2AT_LLM_API_KEY=live-key", - "A2AT_LLM_BASE_URL=https://live.example.test/v1", - "A2AT_LLM_TEMPERATURE=0", - "A2AT_LLM_TIMEOUT_SECONDS=60", - "A2AT_LLM_MAX_ATTEMPTS=3", - "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory"), - Files.readAllLines(envFile, StandardCharsets.UTF_8)); - } - - @Test - void overriddenOptionalValuesFlowIntoTheEnvFile() throws IOException { - Path envFile = LiveLlmEnvWriter.envFileFor( - new LiveLlmConfig("https://live.example.test/v1", "live-key", "qwen3-27b", "0.2", "120")); - List lines = Files.readAllLines(envFile, StandardCharsets.UTF_8); - assertEquals("A2AT_LLM_TEMPERATURE=0.2", lines.get(7)); - assertEquals("A2AT_LLM_TIMEOUT_SECONDS=120", lines.get(8)); - } - - @Test - void envFileIsCachedPerDistinctConfiguration() { - LiveLlmConfig config = new LiveLlmConfig("https://live.example.test/v1", "live-key", "qwen3-27b", "0", "60"); - assertEquals(LiveLlmEnvWriter.envFileFor(config), LiveLlmEnvWriter.envFileFor(config)); - LiveLlmConfig otherTemperature = - new LiveLlmConfig("https://live.example.test/v1", "live-key", "qwen3-27b", "0.2", "60"); - assertNotEquals(LiveLlmEnvWriter.envFileFor(config), LiveLlmEnvWriter.envFileFor(otherTemperature)); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveRunSummary.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveRunSummary.java deleted file mode 100644 index cb2e8dd1..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveRunSummary.java +++ /dev/null @@ -1,31 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -/** - * The aggregate of one live corpus run (design document §5): the case verdict counts, the total LLM traffic and the - * schema-adherence statistic. - * - *

            {@code schemaParseFailureCount} is the M5 schema-adherence data point: the number of successful LLM responses - * whose content did not parse as a JSON object — the share the design's Q10 decision measures before deciding whether - * a native {@code json_schema} request mode is needed. - * - * @param totalCases number of cases appended to the run - * @param passCount number of PASS cases - * @param failCount number of FAIL cases - * @param skipCount number of SKIP cases - * @param errorCount number of ERROR cases - * @param totalLlmCalls number of LLM calls across all cases, including the calls that threw - * @param totalPromptTokens sum of the {@code prompt_tokens} usage entries across all recorded calls - * @param totalCompletionTokens sum of the {@code completion_tokens} usage entries across all recorded calls - * @param schemaParseFailureCount number of answered calls whose content did not parse as a JSON object - * @since 2026-08 - */ -public record LiveRunSummary( - int totalCases, - int passCount, - int failCount, - int skipCount, - int errorCount, - int totalLlmCalls, - long totalPromptTokens, - long totalCompletionTokens, - int schemaParseFailureCount) {} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveTranscript.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveTranscript.java deleted file mode 100644 index 318e281d..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveTranscript.java +++ /dev/null @@ -1,215 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -/** - * Transcript writer of the live corpus (design document §5): every run of {@code LiveCorpusSuiteTest} leaves its full - * evidence under {@code target/live-corpus//} of the corpus module's build directory, so a live run - * can be judged — and its prompts recalibrated — after the fact. - * - *

            A run accumulates its {@link LiveCaseResult}s in memory; {@link Run#write()} flushes once at the end into two - * pretty-printed files: {@code transcript.json} (the JSON array of case results) and {@code summary.json} (the - * aggregated {@link LiveRunSummary}, including the M5 schema-adherence statistic). The run directory is created by - * {@link #createRun()} — evaluated when the suite's {@code @TestFactory} is evaluated, per the design — with a - * second-precision timestamp and a uniquifying suffix when two runs start within the same second. - * - *

            The default root resolves like the other corpus tests resolve the repo layout: from the working directory up to - * the {@code a2a-t-corpus} module directory; when nothing matches (an IDE run with an unrelated working directory) - * the fallback is {@code a2a-t-corpus/target/live-corpus} under the working directory. The whole tree lives under - * {@code target/} and is therefore never committed. - * - * @since 2026-08 - */ -public final class LiveTranscript { - - /** Directory name pattern of one run, second-precision as the design specifies. */ - private static final DateTimeFormatter RUN_DIRECTORY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); - - private static final ObjectMapper MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); - - private LiveTranscript() {} - - /** - * Creates a run directory under the default {@code target/live-corpus} root of the corpus module. - * - * @return handle of the new run, with its timestamped directory already created - */ - public static Run createRun() { - return createRun(defaultRoot()); - } - - /** - * Creates a run directory under an explicit root. - * - * @param rootDir root directory the timestamped run directory is created under - * @return handle of the new run, with its timestamped directory already created - */ - public static Run createRun(Path rootDir) { - Objects.requireNonNull(rootDir, "rootDir"); - try { - String stamp = RUN_DIRECTORY_FORMAT.format(LocalDateTime.now()); - Path directory = rootDir.resolve(stamp); - int uniquifier = 1; - while (Files.exists(directory)) { - directory = rootDir.resolve(stamp + "-" + uniquifier++); - } - Files.createDirectories(directory); - return new Run(directory); - } catch (IOException exception) { - throw new UncheckedIOException( - "Failed to create the live transcript run directory under " + rootDir, exception); - } - } - - /** - * Resolves the default {@code target/live-corpus} root: the corpus module's build directory when the working - * directory is (or lives under) the repository, and a working-directory fallback otherwise. - * - * @return default live transcript root - */ - private static Path defaultRoot() { - Path directory = Path.of(".").toAbsolutePath().normalize(); - while (directory != null) { - Path module = directory.resolve("a2a-t-corpus"); - if (Files.isDirectory(module)) { - return module.resolve("target").resolve("live-corpus"); - } - if (isCorpusModuleDirectory(directory)) { - return directory.resolve("target").resolve("live-corpus"); - } - directory = directory.getParent(); - } - return Path.of(".").toAbsolutePath().normalize().resolve("a2a-t-corpus").resolve("target").resolve("live-corpus"); - } - - private static boolean isCorpusModuleDirectory(Path directory) { - return directory.getFileName() != null && "a2a-t-corpus".equals(directory.getFileName().toString()); - } - - /** - * Handle of one live transcript run: it accumulates the case results of one suite execution and flushes them once. - * - * @since 2026-08 - */ - public static final class Run { - - private static final String TRANSCRIPT_FILE = "transcript.json"; - - private static final String SUMMARY_FILE = "summary.json"; - - private final Path directory; - - private final List cases = new ArrayList<>(); - - private Run(Path directory) { - this.directory = directory; - } - - /** - * Returns the run directory the transcript files are written into. - * - * @return timestamped run directory, already created - */ - public Path directory() { - return directory; - } - - /** - * Appends one case result to the run. - * - * @param result verdict of one executed live case - */ - public synchronized void appendCase(LiveCaseResult result) { - cases.add(Objects.requireNonNull(result, "result")); - } - - /** - * Returns the aggregate of the cases appended so far. - * - * @return run summary computed over the appended cases - */ - public synchronized LiveRunSummary summary() { - return LiveTranscript.summarize(cases); - } - - /** - * Flushes the run into its directory: the pretty-printed JSON array of case results as {@code transcript.json} - * and the aggregate as {@code summary.json}. - * - * @return path of the written {@code transcript.json} - */ - public synchronized Path write() { - try { - Path transcriptFile = directory.resolve(TRANSCRIPT_FILE); - MAPPER.writeValue(transcriptFile.toFile(), List.copyOf(cases)); - MAPPER.writeValue(directory.resolve(SUMMARY_FILE).toFile(), summary()); - return transcriptFile; - } catch (IOException exception) { - throw new UncheckedIOException("Failed to write the live transcript of " + directory, exception); - } - } - } - - /** Aggregates the case results into the run summary. */ - private static LiveRunSummary summarize(List cases) { - int passCount = 0; - int failCount = 0; - int skipCount = 0; - int errorCount = 0; - int totalLlmCalls = 0; - long totalPromptTokens = 0; - long totalCompletionTokens = 0; - int schemaParseFailureCount = 0; - for (LiveCaseResult result : cases) { - switch (result.outcome()) { - case PASS -> passCount++; - case FAIL -> failCount++; - case SKIP -> skipCount++; - case ERROR -> errorCount++; - } - for (LiveLlmCall call : result.llmCalls()) { - totalLlmCalls++; - Integer promptTokens = call.usage().get("prompt_tokens"); - Integer completionTokens = call.usage().get("completion_tokens"); - totalPromptTokens += promptTokens == null ? 0 : promptTokens; - totalCompletionTokens += completionTokens == null ? 0 : completionTokens; - if (!call.failed() && !contentIsJsonObject(call.content())) { - schemaParseFailureCount++; - } - } - } - return new LiveRunSummary( - cases.size(), - passCount, - failCount, - skipCount, - errorCount, - totalLlmCalls, - totalPromptTokens, - totalCompletionTokens, - schemaParseFailureCount); - } - - /** - * Returns whether an answered content is a JSON object; the null content of a failed call does not count, only a - * real non-object answer does (the M5 schema-adherence statistic). - */ - private static boolean contentIsJsonObject(String content) { - try { - return MAPPER.readTree(content).isObject(); - } catch (JsonProcessingException | IllegalArgumentException exception) { - return false; - } - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveTranscriptTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveTranscriptTest.java deleted file mode 100644 index 10afb6e8..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LiveTranscriptTest.java +++ /dev/null @@ -1,211 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Direct unit tests of {@link LiveTranscript}: the run directory layout, the pretty-printed JSON array of case - * results, and the aggregated {@link LiveRunSummary} with the M5 schema-adherence statistic. - */ -class LiveTranscriptTest { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private static final List> MESSAGES = List.of(Map.of("role", "user", "content", "投诉文本")); - - @TempDir - Path tempDir; - - @Test - void aRunCreatesATimestampedDirectoryUnderTheGivenRoot() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - - String directoryName = run.directory().getFileName().toString(); - assertTrue( - directoryName.matches("\\d{8}-\\d{6}(-\\d+)?"), - "the run directory carries the yyyyMMdd-HHmmss timestamp, but was: " + directoryName); - assertTrue(Files.isDirectory(run.directory()), "createRun creates the run directory eagerly"); - } - - @Test - void twoRunsInTheSameSecondGetDistinctDirectories() { - LiveTranscript.Run first = LiveTranscript.createRun(tempDir); - LiveTranscript.Run second = LiveTranscript.createRun(tempDir); - - assertTrue( - !first.directory().equals(second.directory()), - "two runs of the same second must not share a directory: " + first.directory() + " vs " - + second.directory()); - } - - @Test - void writeFlushesTheCaseArrayAndTheAggregateAsValidJson() throws IOException { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - run.appendCase(new LiveCaseResult( - "LIVE-GEN-01/zh-CN", - LiveCaseResult.Outcome.PASS, - "scenario + slot subset", - "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。", - "private-line-complaint", - Map.of("accessPort", "P533-01"), - List.of( - new LiveLlmCall( - MESSAGES, - Map.of("type", "object"), - 0.0, - null, - "{\"scenarioCode\":\"private-line-complaint\"}", - "qwen3-27b", - Map.of("prompt_tokens", 120, "completion_tokens", 30), - 8, - null), - new LiveLlmCall( - MESSAGES, - null, - null, - null, - "", - "qwen3-27b", - Map.of("prompt_tokens", 200, "completion_tokens", 50), - 5, - null)), - 1_234, - null)); - run.appendCase(new LiveCaseResult( - "LIVE-VAL-01/zh-CN", - LiveCaseResult.Outcome.FAIL, - "paramsAbsent", - null, - null, - null, - List.of(new LiveLlmCall( - MESSAGES, - null, - null, - null, - null, - null, - Map.of(), - 3, - "net.openan.a2at.sdk.llm.LLMRuntimeError: connection reset")), - 900, - "faultTime was filled but expected absent: 2026-05-11")); - run.appendCase(new LiveCaseResult( - "LIVE-GEN-02/zh-CN", - LiveCaseResult.Outcome.SKIP, - "no live endpoint", - null, - null, - null, - List.of(), - 0, - null)); - - Path transcriptFile = run.write(); - - assertEquals(run.directory().resolve("transcript.json"), transcriptFile); - assertTrue(Files.isRegularFile(run.directory().resolve("summary.json")), "the aggregate is flushed too"); - - JsonNode transcript = MAPPER.readTree(Files.readString(transcriptFile)); - assertTrue(transcript.isArray(), "the transcript is one JSON array of case results"); - assertEquals(3, transcript.size()); - assertEquals("LIVE-GEN-01/zh-CN", transcript.get(0).path("caseId").asText()); - assertEquals("PASS", transcript.get(0).path("outcome").asText()); - assertTrue( - transcript.get(0).path("inputSummary").asText().contains("event-id-20260511-09013"), - "the transcript carries the input summary of the case"); - assertEquals("private-line-complaint", transcript.get(0).path("scenarioCode").asText()); - assertEquals("P533-01", transcript.get(0).path("params").path("accessPort").asText()); - assertEquals(2, transcript.get(0).path("llmCalls").size(), "the recorded calls are embedded per case"); - assertEquals( - "{\"scenarioCode\":\"private-line-complaint\"}", - transcript.get(0).path("llmCalls").get(0).path("content").asText()); - assertEquals("FAIL", transcript.get(1).path("outcome").asText()); - assertTrue(transcript.get(1).path("failureDiff").asText().contains("faultTime")); - assertEquals("SKIP", transcript.get(2).path("outcome").asText()); - assertTrue( - Files.readString(transcriptFile).contains("\n "), "the transcript is pretty-printed"); - } - - @Test - void summaryAggregatesVerdictsTokensAndSchemaParseFailures() { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - run.appendCase(new LiveCaseResult( - "LIVE-GEN-01/zh-CN", - LiveCaseResult.Outcome.PASS, - "scenario", - "深圳访问广州的专线时延骤升。", - "s", - Map.of(), - List.of( - call("{\"a\":1}", Map.of("prompt_tokens", 10, "completion_tokens", 4)), - call("plain text, not json", Map.of("prompt_tokens", 100, "completion_tokens", 40))), - 10, - null)); - run.appendCase(new LiveCaseResult( - "LIVE-VAL-01/zh-CN", - LiveCaseResult.Outcome.FAIL, - "subset", - null, - null, - null, - List.of(call("[1,2]", Map.of("prompt_tokens", 1, "completion_tokens", 1))), - 10, - "diff")); - run.appendCase(new LiveCaseResult( - "LIVE-VAL-02/zh-CN", - LiveCaseResult.Outcome.ERROR, - "engine blew up", - null, - null, - null, - List.of(new LiveLlmCall(MESSAGES, null, null, null, null, null, Map.of(), 2, - "net.openan.a2at.sdk.llm.LLMRuntimeError: timeout")), - 10, - "boom")); - run.appendCase(new LiveCaseResult( - "LIVE-GEN-02/zh-CN", LiveCaseResult.Outcome.SKIP, "no endpoint", null, null, null, List.of(), 0, null)); - - LiveRunSummary summary = run.summary(); - - assertEquals(4, summary.totalCases()); - assertEquals(1, summary.passCount()); - assertEquals(1, summary.failCount()); - assertEquals(1, summary.skipCount()); - assertEquals(1, summary.errorCount()); - assertEquals(4, summary.totalLlmCalls()); - assertEquals(10 + 100 + 1, summary.totalPromptTokens()); - assertEquals(4 + 40 + 1, summary.totalCompletionTokens()); - assertEquals(2, summary.schemaParseFailureCount(), "plain text and a JSON array are schema parse failures;" - + " the thrown call's null content is not"); - } - - @Test - void anEmptyRunWritesAnEmptyArrayAndZeroCounts() throws IOException { - LiveTranscript.Run run = LiveTranscript.createRun(tempDir); - - Path transcriptFile = run.write(); - - JsonNode transcript = MAPPER.readTree(Files.readString(transcriptFile)); - assertTrue(transcript.isArray()); - assertEquals(0, transcript.size()); - JsonNode summary = MAPPER.readTree(Files.readString(run.directory().resolve("summary.json"))); - assertEquals(0, summary.path("totalCases").asInt()); - assertEquals(0, summary.path("totalLlmCalls").asInt()); - assertEquals(0, summary.path("schemaParseFailureCount").asInt()); - } - - private static LiveLlmCall call(String content, Map usage) { - return new LiveLlmCall(MESSAGES, Map.of("type", "object"), 0.0, 64, content, "qwen3-27b", usage, 1, null); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmFailMarker.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmFailMarker.java deleted file mode 100644 index 26afb06f..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmFailMarker.java +++ /dev/null @@ -1,63 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import org.jspecify.annotations.Nullable; - -/** - * The six scripted LLM failure markers of a corpus script step. - * - *

            A {@code $fail} marker replaces one scripted LLM answer with a failure the later scripted client replays: - * infrastructure failures, degenerate responses or the assertion failure that proves a zero-call run. The marker - * names are part of the corpus format contract. - * - * @since 2026-08 - */ -public enum LlmFailMarker { - - /** Throws a runtime (infrastructure) exception on the call. */ - RUNTIME_EXCEPTION("runtime-exception"), - - /** Answers an LLM error response on the call. */ - LLM_ERROR("llm-error"), - - /** Returns a null response on the call. */ - NULL_RESPONSE("null-response"), - - /** Returns a response with a blank content payload on the call. */ - BLANK_CONTENT("blank-content"), - - /** Returns a response whose content is not parseable JSON on the call. */ - NON_JSON("non-json"), - - /** Throws an assertion failure; the zero-call proof of the from-data and differential runs. */ - ASSERTION("assertion"); - - private final String jsonName; - - LlmFailMarker(String jsonName) { - this.jsonName = jsonName; - } - - /** - * Returns the corpus JSON name of this marker. - * - * @return marker name such as {@code non-json} - */ - public String jsonName() { - return jsonName; - } - - /** - * Resolves a corpus JSON name into a failure marker. - * - * @param jsonName corpus JSON name such as {@code non-json} - * @return the marker, or null when the name matches none of the six markers - */ - public static @Nullable LlmFailMarker fromJsonName(@Nullable String jsonName) { - for (LlmFailMarker marker : values()) { - if (marker.jsonName.equals(jsonName)) { - return marker; - } - } - return null; - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScript.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScript.java deleted file mode 100644 index 60c8f34c..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScript.java +++ /dev/null @@ -1,20 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.List; -import org.jspecify.annotations.Nullable; - -/** - * The scripted LLM behavior of one corpus record. - * - * @param maxAttempts builder-level retry limit, or null for the builder default; clamping assertions stay in the - * handwritten suites - * @param steps scripted answers consumed strictly step by step; an exhausted script fails the run instead of - * repeating the last answer - * @since 2026-08 - */ -public record LlmScript(@Nullable Integer maxAttempts, List steps) { - - public LlmScript { - steps = List.copyOf(steps); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScriptStep.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScriptStep.java deleted file mode 100644 index f98a3d41..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LlmScriptStep.java +++ /dev/null @@ -1,27 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -/** - * One scripted answer of an LLM script. - * - *

            After loader resolution every step is either a literal payload text (an inline corpus string or a resolved - * {@code responses/} reference) or a failure marker. The tri-state of the corpus JSON — {@code $ref} object, - * {@code $fail} object, literal string — collapses into exactly these two shapes. - * - * @since 2026-08 - */ -public sealed interface LlmScriptStep { - - /** - * A scripted answer carrying a literal payload text. - * - * @param json payload text handed to the scripted LLM client - */ - record Payload(String json) implements LlmScriptStep {} - - /** - * A scripted answer that fails with the given marker. - * - * @param marker failure marker the scripted LLM client replays - */ - record Fail(LlmFailMarker marker) implements LlmScriptStep {} -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LoadedCorpus.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LoadedCorpus.java deleted file mode 100644 index e7847a0b..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/LoadedCorpus.java +++ /dev/null @@ -1,47 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import com.fasterxml.jackson.databind.JsonNode; - -/** - * The whole negotiation test corpus loaded from one corpus root. - * - *

            The loader fails fast on any format violation, so a successfully loaded corpus is internally consistent: ids are - * globally unique, every {@code $ref} is resolved, every expectation block is complete and every record is expanded - * per language. - * - * @param root corpus root directory the records were loaded from - * @param cases all case records of the case files, expanded per language - * @param scenarios all scenario records of the scenario files, expanded per language - * @param liveCases all live-LLM case records of the {@code live/} directory, expanded per language; they never mix - * into {@code cases} - * @param sharedResponses named payload texts of {@code shared/llm-responses.json} - * @param sharedSchemas named JSON Schema variants of {@code shared/schemas.json} - * @since 2026-08 - */ -public record LoadedCorpus( - Path root, - List cases, - List scenarios, - List liveCases, - Map sharedResponses, - Map sharedSchemas) { - - public LoadedCorpus { - Objects.requireNonNull(root, "root"); - Objects.requireNonNull(cases, "cases"); - Objects.requireNonNull(scenarios, "scenarios"); - Objects.requireNonNull(liveCases, "liveCases"); - Objects.requireNonNull(sharedResponses, "sharedResponses"); - Objects.requireNonNull(sharedSchemas, "sharedSchemas"); - root = root.normalize(); - cases = List.copyOf(cases); - scenarios = List.copyOf(scenarios); - liveCases = List.copyOf(liveCases); - sharedResponses = Map.copyOf(sharedResponses); - sharedSchemas = Map.copyOf(sharedSchemas); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationApi.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationApi.java deleted file mode 100644 index 85b823f6..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationApi.java +++ /dev/null @@ -1,131 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import org.jspecify.annotations.Nullable; - -/** - * The fifteen APIs the corpus exercises: the twelve {@code NegotiationContentService} APIs plus the three task-API - * facade methods of the closed loop ({@code generateTaskPromptFromText}, {@code generateTaskPromptFromDataWithSchema} - * and {@code validateTaskPromptAndDataFilling}). - * - *

            The corpus references APIs by their Java method name; the later case engine dispatches on this enum, so a - * misspelled API name fails at corpus load time instead of silently skipping a case. - * - * @since 2026-08 - */ -public enum NegotiationApi { - - /** {@code generateProposeFromText}: free text to a propose message through one LLM extraction. */ - GENERATE_PROPOSE_FROM_TEXT("generateProposeFromText", Family.FROM_TEXT), - - /** {@code generateAcceptFromText}: free text to an accept message through one LLM extraction. */ - GENERATE_ACCEPT_FROM_TEXT("generateAcceptFromText", Family.FROM_TEXT), - - /** {@code generateRejectFromText}: free text to a reject message through one LLM extraction. */ - GENERATE_REJECT_FROM_TEXT("generateRejectFromText", Family.FROM_TEXT), - - /** {@code generateAbortFromText}: free text to an abort message through one LLM extraction. */ - GENERATE_ABORT_FROM_TEXT("generateAbortFromText", Family.FROM_TEXT), - - /** {@code generateProposeFromData}: typed data to a propose message, deterministically. */ - GENERATE_PROPOSE_FROM_DATA("generateProposeFromData", Family.FROM_DATA), - - /** {@code generateAcceptFromData}: typed data to an accept message, deterministically. */ - GENERATE_ACCEPT_FROM_DATA("generateAcceptFromData", Family.FROM_DATA), - - /** {@code generateRejectFromData}: typed data to a reject message, deterministically. */ - GENERATE_REJECT_FROM_DATA("generateRejectFromData", Family.FROM_DATA), - - /** {@code generateAbortFromData}: typed data to an abort message, deterministically. */ - GENERATE_ABORT_FROM_DATA("generateAbortFromData", Family.FROM_DATA), - - /** {@code validateProposePromptAndDataFilling}: rule gate plus semantic validation of a propose message. */ - VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING("validateProposePromptAndDataFilling", Family.VALIDATE), - - /** {@code validateAcceptPromptAndDataFilling}: rule gate plus semantic validation of an accept message. */ - VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING("validateAcceptPromptAndDataFilling", Family.VALIDATE), - - /** {@code validateRejectPromptAndDataFilling}: rule gate plus semantic validation of a reject message. */ - VALIDATE_REJECT_PROMPT_AND_DATA_FILLING("validateRejectPromptAndDataFilling", Family.VALIDATE), - - /** {@code validateAbortPromptAndDataFilling}: rule gate plus semantic validation of an abort message. */ - VALIDATE_ABORT_PROMPT_AND_DATA_FILLING("validateAbortPromptAndDataFilling", Family.VALIDATE), - - /** - * {@code generateTaskPromptFromText}: workbench-side task prompt generation from free text, one LLM slot-extraction - * call (runs through the real {@code A2ATClient} facade builder assembly with the scripted LLM injected). - */ - GENERATE_TASK_PROMPT_FROM_TEXT("generateTaskPromptFromText", Family.TASK), - - /** - * {@code generateTaskPromptFromDataWithSchema}: workbench-side task prompt generation from structured input plus a - * data schema, one LLM slot-extraction call. - */ - GENERATE_TASK_PROMPT_FROM_DATA_WITH_SCHEMA("generateTaskPromptFromDataWithSchema", Family.TASK), - - /** - * {@code validateTaskPromptAndDataFilling}: OMC-side validation of a received task prompt and extraction of its - * filled parameters; a schema slot the prompt misses surfaces as a null-valued parameter. - */ - VALIDATE_TASK_PROMPT_AND_DATA_FILLING("validateTaskPromptAndDataFilling", Family.TASK); - - private final String jsonName; - - private final Family family; - - NegotiationApi(String jsonName, Family family) { - this.jsonName = jsonName; - this.family = family; - } - - /** - * Returns the corpus JSON name of this API, which equals the {@code NegotiationContentService} method name. - * - * @return method name such as {@code generateAcceptFromText} - */ - public String jsonName() { - return jsonName; - } - - /** - * Returns the API family this API belongs to. - * - * @return from-text, from-data or validate family - */ - public Family family() { - return family; - } - - /** - * Resolves a corpus JSON name into an API. - * - * @param jsonName corpus JSON name such as {@code generateAcceptFromText} - * @return the API, or null when the name matches none of the fifteen methods - */ - public static @Nullable NegotiationApi fromJsonName(@Nullable String jsonName) { - for (NegotiationApi api : values()) { - if (api.jsonName.equals(jsonName)) { - return api; - } - } - return null; - } - - /** The four API families of the negotiation content service plus the closed-loop task family. */ - public enum Family { - - /** The four from-text generation methods with exactly one LLM extraction call. */ - FROM_TEXT, - - /** The four from-data generation methods without any LLM call. */ - FROM_DATA, - - /** The four validation methods with a rule gate and one semantic LLM call. */ - VALIDATE, - - /** - * The three closed-loop task APIs of the private-line complaint diagnosis: two task-prompt generation methods - * with one LLM slot-extraction call each, and one task-prompt validation method with one LLM semantic call. - */ - TASK - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCase.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCase.java deleted file mode 100644 index d21f9df9..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCase.java +++ /dev/null @@ -1,72 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.List; -import java.util.Objects; -import com.fasterxml.jackson.databind.JsonNode; -import org.jspecify.annotations.Nullable; - -/** - * One corpus case record expanded for exactly one language. - * - *

            The loader expands every corpus record once per entry of its {@code languages} array, appending {@code /} - * to the id (Q3). References are already resolved: the LLM script carries literal payload texts or failure markers and - * the schema is the addressed JSON Schema node. A scenario step is also carried as a {@code NegotiationCase} with the - * derived id {@code /#step-}. - * - * @param id expanded id such as {@code FT-RETRY-02/zh-CN} or {@code SC-INFO-02/en-US#step-1} - * @param baseId corpus record id before the language expansion, such as {@code FT-RETRY-02} - * @param sourceFile corpus file path relative to the corpus root, such as {@code from-text/retry.json} - * @param api the {@code NegotiationContentService} API under test - * @param language language this expansion renders for - * @param priority P0, P1 or P2, or null when the record does not state one - * @param tags free tags, empty when the record states none - * @param summary business-facing one-line summary, or null when the record states none - * @param context inline negotiation context spec, or null for the null-context probes - * @param templateUri template URI, or null when the case probes a null URI - * @param inputText free-text input of this language, or null for the from-data and validate families - * @param inputData language-independent typed input data of the from-data family and the differential runs - * @param llm resolved scripted LLM behavior, or null when the record scripts none - * @param prompt resolved prompt source of the validate family, or null otherwise - * @param schema resolved JSON Schema of the validate family, or null otherwise - * @param inject harness injection hook name, or null when the case injects none - * @param expect validated expectation block - * @since 2026-08 - */ -public record NegotiationCase( - String id, - String baseId, - String sourceFile, - NegotiationApi api, - String language, - @Nullable String priority, - List tags, - @Nullable String summary, - @Nullable ContextSpec context, - @Nullable String templateUri, - @Nullable String inputText, - @Nullable JsonNode inputData, - @Nullable LlmScript llm, - @Nullable PromptSource prompt, - @Nullable JsonNode schema, - @Nullable String inject, - Expectation expect) { - - public NegotiationCase { - Objects.requireNonNull(id, "id"); - Objects.requireNonNull(baseId, "baseId"); - Objects.requireNonNull(sourceFile, "sourceFile"); - Objects.requireNonNull(api, "api"); - Objects.requireNonNull(language, "language"); - Objects.requireNonNull(expect, "expect"); - tags = List.copyOf(tags); - } - - /** - * Returns the JSON path prefix for failure messages of this case, naming the file and the expanded id. - * - * @return prefix such as {@code from-text/retry.json [FT-RETRY-02/zh-CN]} - */ - public String errorPrefix() { - return sourceFile + " [" + id + "]"; - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoader.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoader.java deleted file mode 100644 index 66b419c8..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoader.java +++ /dev/null @@ -1,1281 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.io.IOException; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Stream; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; -import org.jspecify.annotations.Nullable; - -/** - * Strict loader of the negotiation test corpus. - * - *

            The corpus lives under one root directory (the default is {@code src/test/resources/negotiation-cases}): case - * files and scenario files are JSON arrays of records, and the {@code shared/} directory carries the two shared - * reference files {@code llm-responses.json} (named payload texts, addressed through the {@code responses/} prefix) - * and {@code schemas.json} (named JSON Schema variants, addressed through the {@code schemas/} prefix). The - * {@code corpus-schema.json} format definition sits next to the records and is skipped by the loader. The - * {@code live/} subdirectory carries the live-LLM family: records bound as {@link LiveCase} with the dedicated - * {@code RawLiveCase}/{@code RawLiveExpect} bindings, so the live-only expectation keys never collide with the - * offline ones; live records land in {@link LoadedCorpus#liveCases()}, never in the offline {@code cases} list. - * - *

            Loading fails fast — before any case runs — on every format violation, and every error names the corpus file, - * the offending record id and the JSON path of the defect: - * - *

              - *
            • unknown keys (strict Jackson with {@code FAIL_ON_UNKNOWN_PROPERTIES} — a typo is an error, never a silent skip), - *
            • dangling, out-of-scope, nested or circular {@code $ref} references, - *
            • duplicate record ids (before and after the language expansion), - *
            • incomplete expectation blocks (a failure expectation must name the exception or the error code), - *
            • unknown API names, {@code $fail} markers, languages, priorities, terminal conditions or inject hooks, - *
            • scenario steps that are not numbered consecutively from 1, - *
            • live records without the {@code LIVE-} id prefix, with a language other than zh-CN (phase 1), outside the two - * task APIs, or with an incomplete live expectation block (a missing {@code success}). - *
            - * - *

            On success every record is expanded once per entry of its {@code languages} array (Q3), the expanded id appending - * {@code /}, and references are resolved into literal payload texts and schema nodes, so the later case - * engine never sees a reference. - * - * @since 2026-08 - */ -public final class NegotiationCaseLoader { - - private static final Set SUPPORTED_LANGUAGES = Set.of("zh-CN", "en-US"); - - private static final Set SUPPORTED_PRIORITIES = Set.of("P0", "P1", "P2"); - - private static final Set SUPPORTED_TERMINAL_CONDITIONS = Set.of("accept", "reject", "abort", "exhausted"); - - private static final String RESPONSES_PREFIX = "responses/"; - - private static final String SCHEMAS_PREFIX = "schemas/"; - - private static final String RESPONSES_FILE = "shared/llm-responses.json"; - - private static final String SCHEMAS_FILE = "shared/schemas.json"; - - /** Source-file prefix that routes a record file into the live-LLM family bindings. */ - private static final String LIVE_DIR_PREFIX = "live/"; - - /** The id prefix every live record must carry, so the two families cannot collide on ids. */ - private static final String LIVE_ID_PREFIX = "LIVE-"; - - /** Live phase 1 covers zh-CN only (Q6); the language expansion itself stays generic. */ - private static final List LIVE_LANGUAGES = List.of("zh-CN"); - - /** Live phase 1 covers the two TASK APIs (Q5). */ - private static final Set LIVE_APIS = - Set.of(NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING); - - /** Default of the live LLM call upper bound (live design document §3: 默认如 4) when the record omits it. */ - private static final int LIVE_DEFAULT_MAX_LLM_CALLS = 4; - - private static final ObjectMapper MAPPER = - new ObjectMapper().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - - private NegotiationCaseLoader() {} - - /** - * Loads the whole corpus from a corpus root directory: every {@code .json} file except {@code corpus-schema.json} - * and the {@code shared/} reference files. - * - * @param corpusRoot existing corpus root directory - * @return the loaded corpus with all cases and scenarios expanded per language - * @throws CorpusLoadException when any corpus record violates the format - */ - public static LoadedCorpus load(Path corpusRoot) { - Objects.requireNonNull(corpusRoot, "corpusRoot"); - if (!Files.isDirectory(corpusRoot)) { - throw new CorpusLoadException("The corpus root is not an existing directory: " + corpusRoot); - } - Path normalizedRoot = corpusRoot.normalize(); - SharedRefs shared = loadShared(normalizedRoot.resolve("shared")); - Map seenBaseIds = new LinkedHashMap<>(); - Map seenExpandedIds = new LinkedHashMap<>(); - List cases = new ArrayList<>(); - List scenarios = new ArrayList<>(); - List liveCases = new ArrayList<>(); - for (Path file : listCorpusFiles(normalizedRoot)) { - String relative = normalizedRoot.relativize(file.normalize()).toString().replace('\\', '/'); - parseFile(relative, file, shared, seenBaseIds, seenExpandedIds, cases, scenarios, liveCases); - } - return new LoadedCorpus( - normalizedRoot, - cases, - scenarios, - liveCases, - toStringMap(shared.responses()), - toNodeMap(shared.schemas())); - } - - /** - * Loads the whole corpus from a classpath directory, such as {@code negotiation-cases} of the test resources. - * - * @param resourcePath classpath path of the corpus root - * @return the loaded corpus with all cases and scenarios expanded per language - * @throws CorpusLoadException when the resource is missing or any corpus record violates the format - */ - public static LoadedCorpus loadFromClasspath(String resourcePath) { - Objects.requireNonNull(resourcePath, "resourcePath"); - URL url = NegotiationCaseLoader.class.getClassLoader().getResource(resourcePath); - if (url == null) { - throw new CorpusLoadException("The corpus root was not found on the classpath: " + resourcePath); - } - try { - return load(Path.of(url.toURI())); - } catch (URISyntaxException exception) { - throw new CorpusLoadException("The corpus root resource cannot be addressed as a path: " + url, exception); - } - } - - // ------------------------------------------------------------------ file traversal - - private static List listCorpusFiles(Path corpusRoot) { - Path sharedDir = corpusRoot.resolve("shared"); - try (Stream paths = Files.walk(corpusRoot)) { - return paths.filter(Files::isRegularFile) - .filter(path -> path.getFileName().toString().endsWith(".json")) - .filter(path -> !path.normalize().startsWith(sharedDir)) - .filter(path -> !"corpus-schema.json".equals(path.getFileName().toString())) - .sorted() - .toList(); - } catch (IOException exception) { - throw new CorpusLoadException("Failed to walk the corpus root: " + corpusRoot, exception); - } - } - - private record SharedRefs(JsonNode responses, JsonNode schemas) {} - - private static SharedRefs loadShared(Path sharedDir) { - JsonNode responses = readSharedFile(sharedDir.resolve("llm-responses.json")); - JsonNode schemas = readSharedFile(sharedDir.resolve("schemas.json")); - responses.fieldNames().forEachRemaining(name -> { - if (!responses.get(name).isTextual()) { - throw error(RESPONSES_FILE, null, "$." + name, "every shared response payload must be a JSON string"); - } - }); - schemas.fieldNames().forEachRemaining(name -> { - if (!schemas.get(name).isObject()) { - throw error(SCHEMAS_FILE, null, "$." + name, "every shared schema variant must be a JSON object"); - } - }); - return new SharedRefs(responses, schemas); - } - - private static JsonNode readSharedFile(Path file) { - if (!Files.isRegularFile(file)) { - return MAPPER.createObjectNode(); - } - try { - JsonNode root = MAPPER.readTree(file.toFile()); - if (!root.isObject()) { - throw error( - "shared/" + file.getFileName(), - null, - "$", - "expected a JSON object mapping names to shared values"); - } - return root; - } catch (IOException exception) { - throw new CorpusLoadException("Failed to read the shared corpus file: " + file, exception); - } - } - - private static void parseFile( - String file, - Path path, - SharedRefs shared, - Map seenBaseIds, - Map seenExpandedIds, - List cases, - List scenarios, - List liveCases) { - JsonNode root; - try { - root = MAPPER.readTree(path.toFile()); - } catch (IOException exception) { - throw new CorpusLoadException(file + ": failed to read the corpus file", exception); - } - if (!root.isArray()) { - throw error(file, null, "$", "expected a JSON array of corpus records"); - } - for (int i = 0; i < root.size(); i++) { - JsonNode node = root.get(i); - String recordPath = "$[" + i + "]"; - if (!node.isObject()) { - throw error(file, null, recordPath, "expected a JSON object record"); - } - if (!node.path("id").isTextual()) { - throw error(file, null, recordPath + ".id", "missing required field 'id'"); - } - String id = node.get("id").asText(); - if (file.startsWith(LIVE_DIR_PREFIX)) { - parseLiveCase(file, id, recordPath, node, shared, seenBaseIds, seenExpandedIds, liveCases); - } else if (node.has("steps")) { - parseScenario(file, id, recordPath, node, shared, seenBaseIds, seenExpandedIds, scenarios); - } else { - parseCase(file, id, recordPath, node, shared, seenBaseIds, seenExpandedIds, cases); - } - } - } - - // ------------------------------------------------------------------ record parsing - - private static void parseCase( - String file, - String id, - String path, - JsonNode node, - SharedRefs shared, - Map seenBaseIds, - Map seenExpandedIds, - List cases) { - RawCase raw = bind(node, RawCase.class, file, id, path); - List languages = validateLanguages(raw.languages(), file, id, path); - claimBaseId(id, file, path, seenBaseIds); - String priority = validatePriority(raw.priority(), file, id, path); - List tags = raw.tags() == null ? List.of() : List.copyOf(raw.tags()); - ResolvedFields resolved = resolveCaseFields( - file, - id, - path, - languages, - raw.api(), - raw.context(), - raw.templateUri(), - raw.input(), - raw.llm(), - raw.prompt(), - raw.schema(), - raw.inject(), - raw.expect(), - shared); - for (String language : languages) { - String expandedId = id + "/" + language; - claimExpandedId(expandedId, file, id, path, seenExpandedIds); - cases.add(new NegotiationCase( - expandedId, - id, - file, - resolved.api(), - language, - priority, - tags, - raw.summary(), - resolved.context(), - resolved.templateUri(), - resolved.inputText().get(language), - resolved.inputData(), - resolved.llm(), - resolved.prompt(), - resolved.schema(), - resolved.inject(), - resolved.expect())); - } - } - - private static void parseScenario( - String file, - String id, - String path, - JsonNode node, - SharedRefs shared, - Map seenBaseIds, - Map seenExpandedIds, - List scenarios) { - RawScenario raw = bind(node, RawScenario.class, file, id, path); - List languages = validateLanguages(raw.languages(), file, id, path); - claimBaseId(id, file, path, seenBaseIds); - List roles = raw.roles() == null ? List.of() : List.copyOf(raw.roles()); - if (roles.stream().anyMatch(String::isBlank)) { - throw error(file, id, path + ".roles", "role names must not be blank"); - } - Map rolesDesc = - validateRolesDesc(raw.rolesDesc(), roles, file, id, path); - if (raw.steps() == null || raw.steps().isEmpty()) { - throw error(file, id, path + ".steps", "missing required field 'steps' (at least one step)"); - } - for (int i = 0; i < raw.steps().size(); i++) { - Integer stepNumber = raw.steps().get(i).step(); - if (stepNumber == null || stepNumber != i + 1) { - throw error( - file, - id, - path + ".steps[" + i + "].step", - "scenario steps must be numbered consecutively from 1, but step " + (i + 1) + " carries '" - + stepNumber + "'"); - } - } - ScenarioCase.ExpectFlow expectFlow = buildExpectFlow(raw.expectFlow(), file, id, path); - for (String language : languages) { - String expandedId = id + "/" + language; - claimExpandedId(expandedId, file, id, path, seenExpandedIds); - List steps = new ArrayList<>(); - for (RawStep rawStep : raw.steps()) { - ResolvedFields resolved = resolveCaseFields( - file, - id, - path + ".steps[" + (rawStep.step() - 1) + "]", - languages, - rawStep.api(), - rawStep.context(), - rawStep.templateUri(), - rawStep.input(), - rawStep.llm(), - rawStep.prompt(), - rawStep.schema(), - rawStep.inject(), - rawStep.expect(), - shared); - NegotiationCase stepCase = new NegotiationCase( - expandedId + "#step-" + rawStep.step(), - id, - file, - resolved.api(), - language, - null, - List.of(), - null, - resolved.context(), - resolved.templateUri(), - resolved.inputText().get(language), - resolved.inputData(), - resolved.llm(), - resolved.prompt(), - resolved.schema(), - resolved.inject(), - resolved.expect()); - steps.add(new ScenarioCase.ScenarioStep(rawStep.step(), rawStep.role(), stepCase)); - } - scenarios.add(new ScenarioCase( - expandedId, id, file, language, raw.summary(), roles, steps, expectFlow, rolesDesc)); - } - } - - /** - * Parses one live-LLM record: the dedicated {@code RawLiveCase} binding (no llm, no inject, no input.data), the - * phase-1 restrictions (LIVE- id prefix, exactly zh-CN, one of the two task APIs) and the same id bookkeeping as - * the offline families, so live ids are globally unique against cases and scenarios. - */ - private static void parseLiveCase( - String file, - String id, - String path, - JsonNode node, - SharedRefs shared, - Map seenBaseIds, - Map seenExpandedIds, - List liveCases) { - if (!id.startsWith(LIVE_ID_PREFIX)) { - throw error( - file, - id, - path + ".id", - "a live record id must carry the '" + LIVE_ID_PREFIX + "' prefix but is '" + id + "'"); - } - RawLiveCase raw = bind(node, RawLiveCase.class, file, id, path); - List languages = validateLanguages(raw.languages(), file, id, path); - if (!languages.equals(LIVE_LANGUAGES)) { - throw error( - file, - id, - path + ".languages", - "live phase 1 records must declare exactly the languages " + LIVE_LANGUAGES + " but declare " - + languages); - } - claimBaseId(id, file, path, seenBaseIds); - String priority = validatePriority(raw.priority(), file, id, path); - List tags = raw.tags() == null ? List.of() : List.copyOf(raw.tags()); - NegotiationApi api = NegotiationApi.fromJsonName(raw.api()); - if (api == null) { - throw error( - file, - id, - path + ".api", - "unknown api '" + raw.api() + "' (known apis: " + knownApiNames() + ")"); - } - if (!LIVE_APIS.contains(api)) { - throw error( - file, - id, - path + ".api", - "live phase 1 supports only " + liveApiNames() + " but the record declares '" + raw.api() + "'"); - } - ContextSpec contextSpec = validateContext(raw.context(), file, id, path); - if (raw.templateUri() != null && raw.templateUri().isBlank()) { - throw error(file, id, path + ".templateUri", "the template URI must not be blank"); - } - Map inputText = Map.of(); - if (raw.input() != null && raw.input().text() != null) { - inputText = raw.input().text(); - for (String language : languages) { - if (!inputText.containsKey(language)) { - throw error( - file, - id, - path + ".input.text", - "missing the '" + language - + "' text entry (the input must cover every language of the record)"); - } - } - } - if (api == NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT && inputText.isEmpty()) { - throw error( - file, - id, - path + ".input.text", - "a live generateTaskPromptFromText record requires an input.text (the engine has no other source" - + " of the natural-language input)"); - } - PromptSource promptSource = buildPromptSource(raw.prompt(), file, id, path); - if (promptSource != null && !(promptSource instanceof PromptSource.Text)) { - // The live engine only reads an inline prompt text; a golden or fromStep source would load fine and then - // misfire at run time, so the loader rejects it up front, mirroring the offline fail-fast philosophy. - throw error( - file, - id, - path + ".prompt", - "a live record supports only the inline prompt.text but declares " - + (promptSource instanceof PromptSource.Golden ? "golden" : "fromStep")); - } - JsonNode schemaNode = resolveSchema(raw.schema(), file, id, path, shared); - LiveExpectation liveExpect = buildLiveExpectation(raw.expect(), file, id, path); - if (api == NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING - && !liveExpect.promptTextContains().isEmpty()) { - throw error( - file, - id, - path + ".expect.promptTextContains", - "promptTextContains judges the generated prompt of the generate API; a validate record has no" - + " generated prompt to assert fragments of"); - } - for (String language : languages) { - String expandedId = id + "/" + language; - claimExpandedId(expandedId, file, id, path, seenExpandedIds); - liveCases.add(new LiveCase( - expandedId, - id, - file, - api, - language, - priority, - tags, - raw.summary(), - contextSpec, - raw.templateUri(), - inputText.get(language), - promptSource, - schemaNode, - liveExpect)); - } - } - - /** - * Validates the role descriptions of the closed loop (Q23): every key and value must be non-blank, and every key - * must name a declared role, so a typo'd role fails at load time instead of silently never showing up in a - * failure message. - */ - private static Map validateRolesDesc( - @Nullable Map rolesDesc, List roles, String file, String id, String path) { - if (rolesDesc == null) { - return Map.of(); - } - Map validated = new LinkedHashMap<>(); - for (Map.Entry entry : rolesDesc.entrySet()) { - if (entry.getKey() == null || entry.getKey().isBlank()) { - throw error(file, id, path + ".rolesDesc", "role names must not be blank"); - } - if (entry.getValue() == null || entry.getValue().isBlank()) { - throw error( - file, - id, - path + ".rolesDesc", - "the description of role '" + entry.getKey() + "' must not be blank"); - } - if (!roles.contains(entry.getKey())) { - throw error( - file, - id, - path + ".rolesDesc", - "the described role '" + entry.getKey() + "' is not one of the declared roles " - + roles); - } - validated.put(entry.getKey(), entry.getValue()); - } - return Map.copyOf(validated); - } - - private record ResolvedFields( - NegotiationApi api, - @Nullable ContextSpec context, - @Nullable String templateUri, - Map inputText, - @Nullable JsonNode inputData, - @Nullable LlmScript llm, - @Nullable PromptSource prompt, - @Nullable JsonNode schema, - @Nullable String inject, - Expectation expect) {} - - private static ResolvedFields resolveCaseFields( - String file, - String id, - String path, - List languages, - @Nullable String apiName, - @Nullable RawContext context, - @Nullable String templateUri, - @Nullable RawInput input, - @Nullable RawLlm llm, - @Nullable RawPrompt prompt, - @Nullable JsonNode schema, - @Nullable String inject, - @Nullable RawExpect expect, - SharedRefs shared) { - NegotiationApi api = NegotiationApi.fromJsonName(apiName); - if (api == null) { - throw error( - file, - id, - path + ".api", - "unknown api '" + apiName + "' (known apis: " + knownApiNames() + ")"); - } - ContextSpec contextSpec = validateContext(context, file, id, path); - if (templateUri != null && templateUri.isBlank()) { - throw error(file, id, path + ".templateUri", "the template URI must not be blank"); - } - Map inputText = Map.of(); - JsonNode inputData = null; - if (input != null) { - if (input.text() != null) { - inputText = input.text(); - for (String language : languages) { - if (!inputText.containsKey(language)) { - throw error( - file, - id, - path + ".input.text", - "missing the '" + language - + "' text entry (the input must cover every language of the record)"); - } - } - } - if (input.data() != null && !input.data().isNull()) { - if (!input.data().isObject()) { - throw error(file, id, path + ".input.data", "the typed input data must be a JSON object"); - } - inputData = input.data(); - } - } - LlmScript llmScript = null; - if (llm != null) { - if (llm.maxAttempts() != null && llm.maxAttempts() < 1) { - throw error(file, id, path + ".llm.maxAttempts", "maxAttempts must be at least 1"); - } - if (llm.script() == null || llm.script().isEmpty()) { - throw error(file, id, path + ".llm.script", "missing required field 'script' (at least one step)"); - } - llmScript = - new LlmScript(llm.maxAttempts(), resolveScript(llm.script(), file, id, path + ".llm.script", shared)); - } - PromptSource promptSource = buildPromptSource(prompt, file, id, path); - JsonNode schemaNode = resolveSchema(schema, file, id, path, shared); - if (inject != null - && !"failingTemplateLoader".equals(inject) - && !"failingSemanticValidator".equals(inject)) { - throw error( - file, - id, - path + ".inject", - "unknown inject hook '" + inject - + "' (known hooks: failingTemplateLoader, failingSemanticValidator)"); - } - Expectation expectation = buildExpectation(expect, file, id, path); - return new ResolvedFields( - api, - contextSpec, - templateUri, - inputText, - inputData, - llmScript, - promptSource, - schemaNode, - inject, - expectation); - } - - // ------------------------------------------------------------------ field validation - - private static List validateLanguages(@Nullable List languages, String file, String id, String path) { - if (languages == null || languages.isEmpty()) { - throw error(file, id, path + ".languages", "missing required field 'languages' (at least one language)"); - } - Set seen = new LinkedHashSet<>(); - for (String language : languages) { - if (!seen.add(language)) { - throw error(file, id, path + ".languages", "duplicate language '" + language + "'"); - } - if (!SUPPORTED_LANGUAGES.contains(language)) { - throw error( - file, - id, - path + ".languages", - "unsupported language '" + language + "' (supported languages: zh-CN, en-US)"); - } - } - return List.copyOf(languages); - } - - private static @Nullable String validatePriority(@Nullable String priority, String file, String id, String path) { - if (priority != null && !SUPPORTED_PRIORITIES.contains(priority)) { - throw error(file, id, path + ".priority", "unknown priority '" + priority + "' (known priorities: P0, P1, P2)"); - } - return priority; - } - - private static @Nullable ContextSpec validateContext( - @Nullable RawContext context, String file, String id, String path) { - if (context == null) { - return null; - } - if (context.id() == null || context.id().isBlank()) { - throw error(file, id, path + ".context.id", "the context id must not be blank"); - } - if (context.round() == null) { - throw error(file, id, path + ".context.round", "missing required field 'round'"); - } - if (context.maxRounds() == null) { - throw error(file, id, path + ".context.maxRounds", "missing required field 'maxRounds'"); - } - if (context.round() < 1 || context.maxRounds() < 1) { - throw error(file, id, path + ".context", "the context round and maxRounds must be at least 1"); - } - return new ContextSpec(context.id(), context.round(), context.maxRounds()); - } - - private static @Nullable PromptSource buildPromptSource( - @Nullable RawPrompt prompt, String file, String id, String path) { - if (prompt == null) { - return null; - } - int declared = 0; - if (prompt.golden() != null) { - declared++; - } - if (prompt.text() != null) { - declared++; - } - if (prompt.fromStep() != null) { - declared++; - } - if (declared != 1) { - throw error( - file, - id, - path + ".prompt", - "the prompt must declare exactly one of golden, text or fromStep but declares " + declared); - } - if (prompt.golden() != null) { - if (prompt.golden().isBlank()) { - throw error(file, id, path + ".prompt.golden", "the golden fixture name must not be blank"); - } - return new PromptSource.Golden(prompt.golden()); - } - if (prompt.text() != null) { - return new PromptSource.Text(prompt.text()); - } - if (prompt.fromStep() < 1) { - throw error(file, id, path + ".prompt.fromStep", "fromStep must be at least 1"); - } - return new PromptSource.FromStep(prompt.fromStep()); - } - - private static Expectation buildExpectation(@Nullable RawExpect expect, String file, String id, String path) { - if (expect == null) { - throw error(file, id, path + ".expect", "missing required field 'expect'"); - } - boolean success; - if ("success".equals(expect.outcome())) { - success = true; - } else if ("failure".equals(expect.outcome())) { - success = false; - } else { - throw error( - file, - id, - path + ".expect.outcome", - "unknown outcome '" + expect.outcome() + "' (expected 'success' or 'failure')"); - } - if (success) { - if (expect.exception() != null || expect.code() != null || expect.messageContains() != null - || expect.slotErrors() != null) { - throw error( - file, - id, - path + ".expect", - "a success expectation must not carry failure-only fields (exception, code, messageContains," - + " slotErrors)"); - } - } else { - if (expect.exception() == null && expect.code() == null) { - throw error( - file, - id, - path + ".expect", - "a failure expectation must name the expected exception or the expected error code"); - } - if (expect.promptTextEqualsGolden() != null || expect.metadata() != null || expect.params() != null - || expect.differential() != null || expect.promptTextContains() != null - || expect.missingParams() != null || expect.paramsFromStep() != null) { - throw error( - file, - id, - path + ".expect", - "a failure expectation must not carry success-only fields (promptTextEqualsGolden, metadata," - + " params, differential, promptTextContains, missingParams, paramsFromStep)"); - } - } - List slotErrors = new ArrayList<>(); - if (expect.slotErrors() != null) { - for (int i = 0; i < expect.slotErrors().size(); i++) { - RawSlotError slotError = expect.slotErrors().get(i); - if (slotError.slot() == null || slotError.slot().isBlank()) { - throw error(file, id, path + ".expect.slotErrors[" + i + "].slot", "the slot name must not be blank"); - } - if (slotError.code() == null || slotError.code().isBlank()) { - throw error(file, id, path + ".expect.slotErrors[" + i + "].code", "the slot error code must not be blank"); - } - slotErrors.add(new Expectation.SlotError(slotError.slot(), slotError.code())); - } - } - Map params = Map.of(); - if (expect.params() != null && !expect.params().isNull()) { - if (!expect.params().isObject()) { - throw error(file, id, path + ".expect.params", "the expected params must be a JSON object"); - } - params = MAPPER.convertValue(expect.params(), new TypeReference>() {}); - for (Map.Entry entry : params.entrySet()) { - if (entry.getValue() == null) { - throw error( - file, - id, - path + ".expect.params", - "the expected param '" + entry.getKey() - + "' carries a JSON null; a missing parameter belongs into missingParams, not" - + " into params"); - } - } - } - List messageContains = - expect.messageContains() == null ? List.of() : List.copyOf(expect.messageContains()); - if (!messageContains.isEmpty() && messageContains.stream().anyMatch(String::isBlank)) { - throw error(file, id, path + ".expect.messageContains", "messageContains entries must not be blank"); - } - List contracts = expect.contracts() == null ? List.of() : List.copyOf(expect.contracts()); - if (!contracts.isEmpty() && contracts.stream().anyMatch(String::isBlank)) { - throw error(file, id, path + ".expect.contracts", "contract names must not be blank"); - } - List promptTextContains = - expect.promptTextContains() == null ? List.of() : List.copyOf(expect.promptTextContains()); - if (!promptTextContains.isEmpty() && promptTextContains.stream().anyMatch(String::isBlank)) { - throw error( - file, - id, - path + ".expect.promptTextContains", - "promptTextContains entries must not be blank"); - } - List missingParams = expect.missingParams(); - if (missingParams != null) { - if (missingParams.stream().anyMatch(String::isBlank)) { - throw error(file, id, path + ".expect.missingParams", "missingParams entries must not be blank"); - } - missingParams = List.copyOf(missingParams); - } - if (expect.paramsFromStep() != null && expect.paramsFromStep() < 1) { - throw error(file, id, path + ".expect.paramsFromStep", "paramsFromStep must be at least 1"); - } - Expectation.Metadata metadata = expect.metadata() == null - ? null - : new Expectation.Metadata(expect.metadata().templateUriEcho(), expect.metadata().contextEcho()); - return new Expectation( - success, - expect.exception(), - expect.code(), - messageContains, - slotErrors, - expect.llmCalls(), - expect.promptTextEqualsGolden(), - metadata, - params, - contracts, - Boolean.TRUE.equals(expect.differential()), - promptTextContains, - missingParams, - expect.paramsFromStep()); - } - - private static LiveExpectation buildLiveExpectation( - @Nullable RawLiveExpect expect, String file, String id, String path) { - if (expect == null) { - throw error(file, id, path + ".expect", "missing required field 'expect'"); - } - if (expect.success() == null) { - throw error(file, id, path + ".expect.success", "missing required field 'success'"); - } - Map paramsContains = Map.of(); - if (expect.paramsContains() != null && !expect.paramsContains().isNull()) { - if (!expect.paramsContains().isObject()) { - throw error(file, id, path + ".expect.paramsContains", "paramsContains must be a JSON object"); - } - paramsContains = MAPPER.convertValue(expect.paramsContains(), new TypeReference>() {}); - for (Map.Entry entry : paramsContains.entrySet()) { - if (entry.getValue() == null) { - throw error( - file, - id, - path + ".expect.paramsContains", - "the expected param '" + entry.getKey() - + "' carries a JSON null; a missing parameter belongs into paramsAbsent, not" - + " into paramsContains"); - } - } - } - List paramsAbsent = - expect.paramsAbsent() == null ? List.of() : List.copyOf(expect.paramsAbsent()); - if (!paramsAbsent.isEmpty() && paramsAbsent.stream().anyMatch(String::isBlank)) { - throw error(file, id, path + ".expect.paramsAbsent", "paramsAbsent entries must not be blank"); - } - List promptTextContains = - expect.promptTextContains() == null ? List.of() : List.copyOf(expect.promptTextContains()); - if (!promptTextContains.isEmpty() && promptTextContains.stream().anyMatch(String::isBlank)) { - throw error( - file, id, path + ".expect.promptTextContains", "promptTextContains entries must not be blank"); - } - if (expect.maxLlmCalls() != null && expect.maxLlmCalls() < 1) { - throw error(file, id, path + ".expect.maxLlmCalls", "maxLlmCalls must be at least 1"); - } - return new LiveExpectation( - expect.success(), - expect.scenarioCode(), - paramsContains, - paramsAbsent, - promptTextContains, - expect.maxLlmCalls() == null ? LIVE_DEFAULT_MAX_LLM_CALLS : expect.maxLlmCalls()); - } - - private static ScenarioCase.@Nullable ExpectFlow buildExpectFlow( - @Nullable RawExpectFlow expectFlow, String file, String id, String path) { - if (expectFlow == null) { - return null; - } - if (expectFlow.terminalCondition() != null - && !SUPPORTED_TERMINAL_CONDITIONS.contains(expectFlow.terminalCondition())) { - throw error( - file, - id, - path + ".expectFlow.terminalCondition", - "unknown terminal condition '" + expectFlow.terminalCondition() - + "' (known conditions: accept, reject, abort, exhausted)"); - } - if (expectFlow.roundsUsed() != null && expectFlow.roundsUsed() < 1) { - throw error(file, id, path + ".expectFlow.roundsUsed", "roundsUsed must be at least 1"); - } - if (expectFlow.missingParamsFilled() != null && expectFlow.missingParamsFilled() < 1) { - throw error( - file, - id, - path + ".expectFlow.missingParamsFilled", - "missingParamsFilled must be at least 1"); - } - return new ScenarioCase.ExpectFlow( - expectFlow.terminalCondition(), - expectFlow.roundsUsed(), - expectFlow.distinctMessages(), - expectFlow.missingParamsFilled()); - } - - // ------------------------------------------------------------------ $ref and $fail resolution - - private static List resolveScript( - List script, String file, String id, String path, SharedRefs shared) { - List steps = new ArrayList<>(script.size()); - for (int i = 0; i < script.size(); i++) { - JsonNode stepNode = script.get(i); - String stepPath = path + "[" + i + "]"; - if (stepNode.isTextual()) { - steps.add(new LlmScriptStep.Payload(stepNode.asText())); - } else if (stepNode.isObject()) { - if (stepNode.size() == 1 && stepNode.has("$fail")) { - steps.add(new LlmScriptStep.Fail(parseFailMarker(stepNode.get("$fail"), file, id, stepPath))); - } else if (stepNode.size() == 1 && stepNode.has("$ref")) { - JsonNode payload = resolveSharedRef( - stepNode.get("$ref"), - file, - id, - stepPath + ".$ref", - true, - shared, - new LinkedHashSet<>()); - if (!payload.isTextual()) { - throw error(file, id, stepPath + ".$ref", "the referenced response payload must be a JSON string"); - } - steps.add(new LlmScriptStep.Payload(payload.asText())); - } else { - throw error( - file, - id, - stepPath, - "a script step must be a literal JSON string, a {\"$ref\": ...} object or a {\"$fail\": ...}" - + " object"); - } - } else { - throw error( - file, - id, - stepPath, - "a script step must be a literal JSON string, a {\"$ref\": ...} object or a {\"$fail\": ...}" - + " object"); - } - } - return steps; - } - - private static LlmFailMarker parseFailMarker(JsonNode node, String file, String id, String path) { - if (!node.isTextual()) { - throw error(file, id, path, "the $fail marker must be a string"); - } - LlmFailMarker marker = LlmFailMarker.fromJsonName(node.asText()); - if (marker == null) { - throw error( - file, - id, - path, - "unknown $fail marker '" + node.asText() + "' (known markers: " + knownFailMarkerNames() + ")"); - } - return marker; - } - - private static @Nullable JsonNode resolveSchema( - @Nullable JsonNode schema, String file, String id, String path, SharedRefs shared) { - if (schema == null || schema.isNull()) { - return null; - } - if (!schema.isObject()) { - throw error(file, id, path + ".schema", "the schema must be a JSON object or a $ref object"); - } - if (schema.size() == 1 && schema.has("$ref")) { - JsonNode refNode = schema.get("$ref"); - if (!refNode.isTextual()) { - throw error(file, id, path + ".schema.$ref", "the $ref must be a string"); - } - JsonNode resolved = resolveSharedRef( - refNode, file, id, path + ".schema.$ref", false, shared, new LinkedHashSet<>()); - if (!resolved.isObject()) { - throw error(file, id, path + ".schema.$ref", "the referenced schema must be a JSON object"); - } - rejectNestedRefs(resolved, file, id, path + ".schema"); - return resolved; - } - rejectNestedRefs(schema, file, id, path + ".schema"); - return schema; - } - - private static JsonNode resolveSharedRef( - JsonNode refNode, - String file, - String id, - String path, - boolean responseScope, - SharedRefs shared, - Set visited) { - if (!refNode.isTextual()) { - throw error(file, id, path, "the $ref must be a string"); - } - String ref = refNode.asText(); - String prefix = responseScope ? RESPONSES_PREFIX : SCHEMAS_PREFIX; - String otherPrefix = responseScope ? SCHEMAS_PREFIX : RESPONSES_PREFIX; - String sharedFile = responseScope ? RESPONSES_FILE : SCHEMAS_FILE; - if (ref.startsWith(otherPrefix)) { - throw error( - file, - id, - path, - "$ref '" + ref + "' is out of scope: it must address " + sharedFile + " through the " + prefix - + " prefix"); - } - if (!ref.startsWith(prefix)) { - throw error( - file, - id, - path, - "$ref '" + ref + "' is out of scope: it must address shared/llm-responses.json through the" - + " responses/ prefix or shared/schemas.json through the schemas/ prefix"); - } - if (!visited.add(ref)) { - throw error(file, id, path, "circular $ref chain detected at '" + ref + "'"); - } - String key = ref.substring(prefix.length()); - if (key.isEmpty()) { - throw error(file, id, path, "$ref '" + ref + "' carries no name after the " + prefix + " prefix"); - } - JsonNode value = (responseScope ? shared.responses() : shared.schemas()).get(key); - if (value == null) { - throw error( - file, - id, - path, - "dangling $ref '" + ref + "': '" + key + "' is not defined in " + sharedFile); - } - if (value.isObject() && value.size() == 1 && value.has("$ref")) { - String innerRef = value.get("$ref").asText(); - if (visited.contains(innerRef)) { - throw error( - file, - id, - path, - "circular $ref chain '" + ref + "' -> '" + innerRef + "'"); - } - throw error( - file, - id, - path, - "nested $ref '" + innerRef + "' inside '" + ref - + "' is not allowed: shared values are literal and resolve one level only"); - } - return value; - } - - private static void rejectNestedRefs(JsonNode node, String file, String id, String path) { - if (node.isObject()) { - if (node.size() == 1 && node.has("$ref")) { - throw error( - file, - id, - path, - "nested $ref '" + node.get("$ref").asText() - + "' is not allowed: references resolve one level only"); - } - node.fields().forEachRemaining( - field -> rejectNestedRefs(field.getValue(), file, id, path + "." + field.getKey())); - } else if (node.isArray()) { - for (int i = 0; i < node.size(); i++) { - rejectNestedRefs(node.get(i), file, id, path + "[" + i + "]"); - } - } - } - - // ------------------------------------------------------------------ id bookkeeping - - private static void claimBaseId(String id, String file, String path, Map seenBaseIds) { - String seenIn = seenBaseIds.putIfAbsent(id, file); - if (seenIn != null) { - throw error( - file, - id, - path, - "duplicate id '" + id + "': the id is the primary key of the corpus and is also defined in " - + seenIn); - } - } - - private static void claimExpandedId( - String expandedId, String file, String id, String path, Map seenExpandedIds) { - String seenIn = seenExpandedIds.putIfAbsent(expandedId, file); - if (seenIn != null) { - throw error( - file, - id, - path, - "duplicate expanded id '" + expandedId + "': the record is also expanded in " + seenIn); - } - } - - // ------------------------------------------------------------------ strict binding - - private static T bind(JsonNode node, Class type, String file, String id, String path) { - try { - return MAPPER.treeToValue(node, type); - } catch (UnrecognizedPropertyException exception) { - throw error( - file, - id, - path + renderJacksonPath(exception), - "unknown property '" + exception.getPropertyName() + "'"); - } catch (JsonProcessingException exception) { - throw error(file, id, path, "malformed record: " + exception.getOriginalMessage()); - } - } - - private static String renderJacksonPath(JsonMappingException exception) { - StringBuilder rendered = new StringBuilder(); - for (JsonMappingException.Reference reference : exception.getPath()) { - String fieldName = reference.getFieldName(); - if (fieldName != null) { - rendered.append('.').append(fieldName); - } else if (reference.getIndex() >= 0) { - rendered.append('[').append(reference.getIndex()).append(']'); - } - } - return rendered.toString(); - } - - private static CorpusLoadException error(String file, @Nullable String id, String path, String message) { - return new CorpusLoadException( - file + (id == null ? "" : " [" + id + "]") + " " + path + ": " + message); - } - - private static String knownApiNames() { - List names = new ArrayList<>(); - for (NegotiationApi api : NegotiationApi.values()) { - names.add(api.jsonName()); - } - return String.join(", ", names); - } - - private static String liveApiNames() { - List names = new ArrayList<>(); - for (NegotiationApi api : LIVE_APIS.stream().sorted().toList()) { - names.add(api.jsonName()); - } - return String.join(" and ", names); - } - - private static String knownFailMarkerNames() { - List names = new ArrayList<>(); - for (LlmFailMarker marker : LlmFailMarker.values()) { - names.add(marker.jsonName()); - } - return String.join(", ", names); - } - - private static Map toStringMap(JsonNode object) { - Map values = new LinkedHashMap<>(); - object.fields().forEachRemaining(field -> values.put(field.getKey(), field.getValue().asText())); - return values; - } - - private static Map toNodeMap(JsonNode object) { - Map values = new LinkedHashMap<>(); - object.fields().forEachRemaining(field -> values.put(field.getKey(), field.getValue())); - return values; - } - - // ------------------------------------------------------------------ raw JSON records - - private record RawCase( - String id, - @Nullable String api, - @Nullable List languages, - @Nullable String priority, - @Nullable List tags, - @Nullable String summary, - @Nullable RawContext context, - @Nullable String templateUri, - @Nullable RawInput input, - @Nullable RawLlm llm, - @Nullable RawPrompt prompt, - @Nullable JsonNode schema, - @Nullable String inject, - @Nullable RawExpect expect) {} - - /** - * The live-family case binding: the shared base fields minus {@code llm}, {@code inject} and the typed input - * data, plus the live-only expectation block — a strict-binding sibling of {@link RawCase} so the two families' - * keys never collide. - */ - private record RawLiveCase( - String id, - @Nullable String api, - @Nullable List languages, - @Nullable String priority, - @Nullable List tags, - @Nullable String summary, - @Nullable RawContext context, - @Nullable String templateUri, - @Nullable RawLiveInput input, - @Nullable RawPrompt prompt, - @Nullable JsonNode schema, - @Nullable RawLiveExpect expect) {} - - private record RawLiveInput(@Nullable Map text) {} - - private record RawLiveExpect( - @Nullable Boolean success, - @Nullable String scenarioCode, - @Nullable JsonNode paramsContains, - @Nullable List paramsAbsent, - @Nullable List promptTextContains, - @Nullable Integer maxLlmCalls) {} - - private record RawScenario( - String id, - @Nullable String summary, - @Nullable List languages, - @Nullable List roles, - @Nullable Map rolesDesc, - @Nullable List steps, - @Nullable RawExpectFlow expectFlow) {} - - private record RawStep( - Integer step, - @Nullable String role, - @Nullable String api, - @Nullable RawContext context, - @Nullable String templateUri, - @Nullable RawInput input, - @Nullable RawLlm llm, - @Nullable RawPrompt prompt, - @Nullable JsonNode schema, - @Nullable String inject, - @Nullable RawExpect expect) {} - - private record RawContext(String id, Integer round, Integer maxRounds) {} - - private record RawInput(@Nullable Map text, @Nullable JsonNode data) {} - - private record RawLlm(@Nullable Integer maxAttempts, @Nullable List script) {} - - private record RawPrompt(@Nullable String golden, @Nullable String text, @Nullable Integer fromStep) {} - - private record RawExpect( - @Nullable String outcome, - @Nullable String exception, - @Nullable String code, - @Nullable List messageContains, - @Nullable List slotErrors, - @Nullable Integer llmCalls, - @Nullable String promptTextEqualsGolden, - @Nullable RawMetadata metadata, - @Nullable JsonNode params, - @Nullable List contracts, - @Nullable Boolean differential, - @Nullable List promptTextContains, - @Nullable List missingParams, - @Nullable Integer paramsFromStep) {} - - private record RawSlotError(String slot, String code) {} - - private record RawMetadata(@Nullable String templateUriEcho, @Nullable Boolean contextEcho) {} - - private record RawExpectFlow( - @Nullable String terminalCondition, - @Nullable Integer roundsUsed, - @Nullable Boolean distinctMessages, - @Nullable Integer missingParamsFilled) {} -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java deleted file mode 100644 index e7bfa7cc..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/NegotiationCaseLoaderTest.java +++ /dev/null @@ -1,1130 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -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 java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Smoke test of {@link NegotiationCaseLoader}: a good corpus loads and expands correctly and every format violation - * fails fast with an error naming the file, the record id and the defect. - * - *

            All corpora are written into a temporary directory, so the test does not depend on any corpus file existing. - */ -class NegotiationCaseLoaderTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String SHARED_RESPONSES = - """ - { - "extract.information.accept.full": "{\\"conclusion\\": \\"Accept\\", \\"items\\": []}", - "extract.information.propose.full": "{\\"items\\": [{\\"name\\": \\"节能区域信息\\", \\"value\\": \\"松山湖\\"}]}" - } - """; - - private static final String SHARED_SCHEMAS = - """ - { - "flat": {"type": "object", "properties": {"id": {"type": "string"}}} - } - """; - - // ------------------------------------------------------------------ happy path - - @Test - void loadsAndExpandsAGoodCorpus(@TempDir Path root) throws IOException { - writeGoodCorpus(root); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - assertEquals(3, corpus.cases().size(), "FT-HAPPY-01 expands to two languages, VAL-HAPPY-01 to one"); - assertEquals(1, corpus.scenarios().size()); - - NegotiationCase acceptZh = caseById(corpus, "FT-HAPPY-01/zh-CN"); - NegotiationCase acceptEn = caseById(corpus, "FT-HAPPY-01/en-US"); - NegotiationCase validateZh = caseById(corpus, "VAL-HAPPY-01/zh-CN"); - - assertEquals("FT-HAPPY-01", acceptZh.baseId()); - assertEquals("from-text/happy.json", acceptZh.sourceFile()); - assertEquals(NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, acceptZh.api()); - assertEquals("zh-CN", acceptZh.language()); - assertEquals("P1", acceptZh.priority()); - assertEquals("我确认第一阶段的信息。", acceptZh.inputText()); - assertEquals("I confirm the first-stage information.", acceptEn.inputText()); - assertEquals(new ContextSpec(SESSION_ID, 2, 5), acceptZh.context()); - assertTrue(acceptZh.inputData().isObject(), "the differential typed input data travels as a JSON node"); - assertTrue(acceptZh.expect().success()); - assertTrue(acceptZh.expect().differential()); - assertEquals(2, acceptZh.expect().llmCalls()); - assertEquals("information_accept", acceptZh.expect().promptTextEqualsGolden()); - assertEquals( - "Negotiation-T/information-negotiation/accept-reject/v1", - acceptZh.expect().metadata().templateUriEcho()); - assertEquals(Boolean.TRUE, acceptZh.expect().metadata().contextEcho()); - - LlmScriptStep firstStep = acceptZh.llm().steps().get(0); - LlmScriptStep.Fail failStep = assertInstanceOf(LlmScriptStep.Fail.class, firstStep); - assertEquals(LlmFailMarker.NON_JSON, failStep.marker()); - LlmScriptStep.Payload payloadStep = assertInstanceOf( - LlmScriptStep.Payload.class, acceptZh.llm().steps().get(1)); - assertEquals("{\"conclusion\": \"Accept\", \"items\": []}", payloadStep.json()); - assertEquals(3, acceptZh.llm().maxAttempts()); - - assertEquals(NegotiationApi.VALIDATE_PROPOSE_PROMPT_AND_DATA_FILLING, validateZh.api()); - PromptSource.Golden golden = assertInstanceOf(PromptSource.Golden.class, validateZh.prompt()); - assertEquals("information_propose", golden.golden()); - assertEquals(corpus.sharedSchemas().get("flat"), validateZh.schema()); - assertEquals( - Map.of("id", SESSION_ID, "round", 1, "maxRounds", 5), - validateZh.expect().params()); - - ScenarioCase scenario = corpus.scenarios().get(0); - assertEquals("SC-INFO-01/zh-CN", scenario.id()); - assertEquals("SC-INFO-01", scenario.baseId()); - assertEquals(2, scenario.steps().size()); - assertEquals(1, scenario.steps().get(0).step()); - assertEquals("A", scenario.steps().get(0).role()); - assertEquals( - "SC-INFO-01/zh-CN#step-1", scenario.steps().get(0).caseData().id()); - assertEquals( - NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, - scenario.steps().get(0).caseData().api()); - PromptSource.FromStep fromStep = assertInstanceOf( - PromptSource.FromStep.class, scenario.steps().get(1).caseData().prompt()); - assertEquals(1, fromStep.step()); - assertEquals("reject", scenario.expectFlow().terminalCondition()); - assertEquals(1, scenario.expectFlow().roundsUsed()); - assertEquals(Boolean.TRUE, scenario.expectFlow().distinctMessages()); - - NegotiationCase failedStep = scenario.steps().get(1).caseData(); - assertFalse(failedStep.expect().success()); - assertEquals("negotiation.semantic_rejected", failedStep.expect().code()); - assertEquals(1, failedStep.expect().llmCalls()); - LlmScriptStep.Fail llmErrorStep = assertInstanceOf( - LlmScriptStep.Fail.class, failedStep.llm().steps().get(0)); - assertEquals(LlmFailMarker.LLM_ERROR, llmErrorStep.marker()); - } - - @Test - void skipsTheSchemaFileAndLoadsAnEmptyCorpus(@TempDir Path root) throws IOException { - write(root, "corpus-schema.json", "{\"formatVersion\": 1}"); - write(root, "shared/llm-responses.json", SHARED_RESPONSES); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - assertTrue(corpus.cases().isEmpty()); - assertTrue(corpus.scenarios().isEmpty()); - assertEquals(2, corpus.sharedResponses().size(), "the shared files load even without any case file"); - } - - @Test - void rejectsAMissingCorpusRoot(@TempDir Path root) { - CorpusLoadException exception = - assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root.resolve("nope"))); - - assertTrue(exception.getMessage().contains("not an existing directory")); - } - - // ------------------------------------------------------------------ fail-fast violations - - @Test - void failsFastOnAnUnknownKey(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "expect": {"outcome": "success", "expection": "Oops"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("from-text/bad.json"), exception.getMessage()); - assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); - assertTrue(exception.getMessage().contains("expection"), exception.getMessage()); - assertTrue(exception.getMessage().contains("expect"), exception.getMessage()); - } - - @Test - void failsFastOnADanglingRef(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "llm": {"script": [{"$ref": "responses/missing.payload"}]}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("dangling"), exception.getMessage()); - assertTrue(exception.getMessage().contains("missing.payload"), exception.getMessage()); - assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); - } - - @Test - void failsFastOnAnOutOfScopeRef(@TempDir Path root) throws IOException { - write(root, "shared/schemas.json", SHARED_SCHEMAS); - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "llm": {"script": [{"$ref": "schemas/flat"}]}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("out of scope"), exception.getMessage()); - assertTrue(exception.getMessage().contains("responses/"), exception.getMessage()); - } - - @Test - void failsFastOnANestedRef(@TempDir Path root) throws IOException { - write( - root, - "shared/schemas.json", - """ - { - "a": {"$ref": "schemas/b"}, - "b": {"type": "object"} - } - """); - write( - root, - "validate/bad.json", - """ - [ - { - "id": "VAL-BAD-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": {"text": "## 提议"}, - "schema": {"$ref": "schemas/a"}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("nested $ref"), exception.getMessage()); - assertTrue(exception.getMessage().contains("VAL-BAD-01"), exception.getMessage()); - } - - @Test - void failsFastOnACircularRef(@TempDir Path root) throws IOException { - write( - root, - "shared/schemas.json", - """ - { - "a": {"$ref": "schemas/a"} - } - """); - write( - root, - "validate/bad.json", - """ - [ - { - "id": "VAL-BAD-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": {"text": "## 提议"}, - "schema": {"$ref": "schemas/a"}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("circular"), exception.getMessage()); - } - - @Test - void failsFastOnADuplicateId(@TempDir Path root) throws IOException { - write(root, "from-text/one.json", minimalCase("FT-DUP-01")); - write(root, "from-text/two.json", minimalCase("FT-DUP-01")); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("duplicate id 'FT-DUP-01'"), exception.getMessage()); - assertTrue(exception.getMessage().contains("from-text/one.json"), exception.getMessage()); - } - - @Test - void failsFastOnADuplicateExpandedId(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "zh-CN"], - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("duplicate language"), exception.getMessage()); - } - - @Test - void failsFastOnAnIncompleteFailureExpectation(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "expect": {"outcome": "failure"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("failure expectation must name"), exception.getMessage()); - assertTrue(exception.getMessage().contains("FT-BAD-01"), exception.getMessage()); - } - - @Test - void failsFastOnFailureOnlyFieldsOnASuccessExpectation(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "expect": {"outcome": "success", "code": "negotiation.invalid_input"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue( - exception.getMessage().contains("success expectation must not carry failure-only fields"), - exception.getMessage()); - } - - @Test - void failsFastOnAnUnknownApi(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateWhateverFromText", - "languages": ["zh-CN"], - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("unknown api 'generateWhateverFromText'"), exception.getMessage()); - assertTrue(exception.getMessage().contains("generateAcceptFromText"), exception.getMessage()); - } - - @Test - void failsFastOnAnUnknownFailMarker(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "llm": {"script": [{"$fail": "explodes"}]}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("unknown $fail marker 'explodes'"), exception.getMessage()); - assertTrue(exception.getMessage().contains("non-json"), exception.getMessage()); - } - - @Test - void failsFastOnAnUnsupportedLanguage(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh_CN"], - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("unsupported language 'zh_CN'"), exception.getMessage()); - } - - @Test - void failsFastOnAMissingLanguageTextEntry(@TempDir Path root) throws IOException { - write( - root, - "from-text/bad.json", - """ - [ - { - "id": "FT-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "en-US"], - "input": {"text": {"zh-CN": "我确认"}}, - "expect": {"outcome": "success"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("missing the 'en-US' text entry"), exception.getMessage()); - } - - @Test - void failsFastOnNonConsecutiveScenarioSteps(@TempDir Path root) throws IOException { - write( - root, - "scenarios/bad.json", - """ - [ - { - "id": "SC-BAD-01", - "languages": ["zh-CN"], - "steps": [ - { - "step": 2, - "api": "generateProposeFromText", - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "expect": {"outcome": "success"} - } - ] - } - ] - """ - .formatted(SESSION_ID)); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("consecutively from 1"), exception.getMessage()); - assertTrue(exception.getMessage().contains("SC-BAD-01"), exception.getMessage()); - } - - @Test - void failsFastOnAnUnknownKeyInsideAScenarioStep(@TempDir Path root) throws IOException { - write( - root, - "scenarios/bad.json", - """ - [ - { - "id": "SC-BAD-01", - "languages": ["zh-CN"], - "steps": [ - { - "step": 1, - "api": "generateProposeFromText", - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "rol": "A", - "expect": {"outcome": "success"} - } - ] - } - ] - """ - .formatted(SESSION_ID)); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("rol"), exception.getMessage()); - assertTrue(exception.getMessage().contains("SC-BAD-01"), exception.getMessage()); - } - - // ------------------------------------------------------------------ task family format (Q21–Q23) - - @Test - void loadsTaskFamilyRecordsWithTheClosedLoopExpectationFields(@TempDir Path root) throws IOException { - write(root, "shared/llm-responses.json", SHARED_RESPONSES); - write( - root, - "task/happy.json", - """ - [ - { - "id": "TASK-FT-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "summary": "工作台从原始投诉文本生成缺参任务报文", - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"} - }, - "llm": {"script": ["{\\"slots\\": {\\"任务对象\\": \\"\\", \\"任务上下文\\": \\"投诉分类:待补充\\"}, \\"slot_errors\\": []}"]}, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["## 任务类型", "## 任务对象"] - } - } - ] - """); - write( - root, - "scenarios/task-closed-loop.json", - """ - [ - { - "id": "SC-TASK-01", - "languages": ["zh-CN"], - "summary": "任务缺参发现与补参提取的因果闭环", - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateTaskPromptFromText", - "context": null, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"} - }, - "llm": {"script": ["{\\"slots\\": {\\"任务对象\\": \\"\\", \\"任务上下文\\": \\"投诉分类:待补充\\"}, \\"slot_errors\\": []}"]}, - "expect": {"outcome": "success", "llmCalls": 1} - }, - { - "step": 2, - "role": "B", - "api": "validateTaskPromptAndDataFilling", - "context": null, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": {"fromStep": 1}, - "schema": {"type": "object", "properties": {"accessPort": {"type": "string"}}}, - "llm": {"script": ["{\\"semantic_verdict\\":true,\\"errors\\":[],\\"params\\":{\\"accessPort\\":null}}"]}, - "expect": { - "outcome": "success", - "llmCalls": 1, - "missingParams": ["accessPort"], - "params": {"faultTime": "2026-05-11T08:21:46Z"} - } - }, - { - "step": 3, - "role": "B", - "api": "validateAcceptPromptAndDataFilling", - "context": {"id": "%s", "round": 2, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": {"fromStep": 1}, - "schema": {"type": "object"}, - "llm": {"script": ["{\\"semantic_verdict\\":true,\\"errors\\":[],\\"params\\":{}}"]}, - "expect": {"outcome": "success", "llmCalls": 1, "paramsFromStep": 2} - } - ], - "expectFlow": {"terminalCondition": "accept", "missingParamsFilled": 2} - } - ] - """ - .formatted(SESSION_ID)); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - NegotiationCase taskCase = caseById(corpus, "TASK-FT-01/zh-CN"); - assertEquals(NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, taskCase.api()); - assertEquals(List.of("## 任务类型", "## 任务对象"), taskCase.expect().promptTextContains()); - - ScenarioCase scenario = corpus.scenarios().get(0); - assertEquals(Map.of("A", "工作台(client,任务发起/补数方)", "B", "OMC(server,执行/要数方,协商发起方)"), scenario.rolesDesc()); - assertEquals("A=工作台(client,任务发起/补数方)", scenario.describeRole("A")); - NegotiationCase validationStep = scenario.steps().get(1).caseData(); - assertEquals(NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, validationStep.api()); - assertEquals(List.of("accessPort"), validationStep.expect().missingParams()); - assertEquals( - Map.of("faultTime", "2026-05-11T08:21:46Z"), - validationStep.expect().params()); - assertEquals(2, scenario.steps().get(2).caseData().expect().paramsFromStep()); - assertEquals(2, scenario.expectFlow().missingParamsFilled()); - } - - @Test - void failsFastOnTaskSuccessFieldsOnAFailureExpectation(@TempDir Path root) throws IOException { - write( - root, - "task/bad.json", - """ - [ - { - "id": "TASK-BAD-01", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": {"text": "## 任务类型(Task Type)"}, - "schema": {"type": "object"}, - "llm": {"script": ["{}"]}, - "expect": { - "outcome": "failure", - "code": "negotiation.semantic_rejected", - "missingParams": ["accessPort"] - } - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("success-only fields"), exception.getMessage()); - assertTrue(exception.getMessage().contains("missingParams"), exception.getMessage()); - } - - @Test - void failsFastOnAJsonNullInsideParams(@TempDir Path root) throws IOException { - write( - root, - "task/bad.json", - """ - [ - { - "id": "TASK-BAD-02", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": {"text": "## 任务类型(Task Type)"}, - "schema": {"type": "object"}, - "llm": {"script": ["{}"]}, - "expect": { - "outcome": "success", - "params": {"accessPort": null} - } - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue( - exception.getMessage().contains("accessPort") - && exception.getMessage().contains("missingParams"), - "a JSON null param must point at missingParams but was: " + exception.getMessage()); - } - - @Test - void failsFastOnParamsFromStepBelowOne(@TempDir Path root) throws IOException { - write( - root, - "task/bad.json", - """ - [ - { - "id": "TASK-BAD-03", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": {"text": "## 任务类型(Task Type)"}, - "schema": {"type": "object"}, - "llm": {"script": ["{}"]}, - "expect": { - "outcome": "success", - "paramsFromStep": 0 - } - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("paramsFromStep"), exception.getMessage()); - } - - @Test - void failsFastOnARolesDescEntryOutsideTheDeclaredRoles(@TempDir Path root) throws IOException { - write( - root, - "scenarios/bad.json", - """ - [ - { - "id": "SC-BAD-02", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": {"C": "OSS(第三方)"}, - "steps": [ - { - "step": 1, - "api": "generateProposeFromText", - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "expect": {"outcome": "success"} - } - ] - } - ] - """ - .formatted(SESSION_ID)); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue( - exception.getMessage().contains("rolesDesc") - && exception.getMessage().contains("'C'"), - exception.getMessage()); - } - - // ------------------------------------------------------------------ live family (live LLM phase 1) - - @Test - void loadsLiveRecordsIntoTheDedicatedLiveCasesList(@TempDir Path root) throws IOException { - write( - root, - "live/generate.json", - """ - [ - { - "id": "LIVE-GEN-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["live", "scenario-recognition"], - "summary": "明确场景的典型投诉文本", - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。"}}, - "expect": { - "success": true, - "scenarioCode": "private-line-complaint", - "paramsContains": {"accessPort": "P533-01"}, - "paramsAbsent": ["faultTime"], - "promptTextContains": ["## instruction", "event-id-20260511-09013"], - "maxLlmCalls": 4 - } - } - ] - """); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - assertEquals(1, corpus.liveCases().size(), "the live record expands once and lands in liveCases"); - assertTrue(corpus.cases().isEmpty(), "a live record never mixes into the offline cases list"); - LiveCase live = corpus.liveCases().get(0); - assertEquals("LIVE-GEN-01/zh-CN", live.id()); - assertEquals("LIVE-GEN-01", live.baseId()); - assertEquals("live/generate.json", live.sourceFile()); - assertEquals(NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, live.api()); - assertEquals("zh-CN", live.language()); - assertEquals("P0", live.priority()); - assertEquals(List.of("live", "scenario-recognition"), live.tags()); - assertEquals("深圳访问广州的专线时延骤升,OSS侧事件流水号event-id-20260511-09013。", live.inputText()); - assertTrue(live.liveExpect().success()); - assertEquals("private-line-complaint", live.liveExpect().scenarioCode()); - assertEquals(Map.of("accessPort", "P533-01"), live.liveExpect().paramsContains()); - assertEquals(List.of("faultTime"), live.liveExpect().paramsAbsent()); - assertEquals( - List.of("## instruction", "event-id-20260511-09013"), - live.liveExpect().promptTextContains()); - assertEquals(4, live.liveExpect().maxLlmCalls()); - assertEquals("live/generate.json [LIVE-GEN-01/zh-CN]", live.errorPrefix()); - } - - @Test - void failsFastOnALiveRecordWithoutTheLiveIdPrefix(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "TASK-LIVE-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("LIVE-"), exception.getMessage()); - assertTrue(exception.getMessage().contains("TASK-LIVE-01"), exception.getMessage()); - } - - @Test - void failsFastOnALiveRecordOutsideThePhaseOneLanguages(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "LIVE-BAD-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN", "en-US"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("exactly the languages [zh-CN]"), exception.getMessage()); - } - - @Test - void failsFastOnALiveRecordOutsideTheTaskFamily(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "LIVE-BAD-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("live phase 1 supports only"), exception.getMessage()); - assertTrue(exception.getMessage().contains("generateTaskPromptFromText"), exception.getMessage()); - assertTrue(exception.getMessage().contains("validateTaskPromptAndDataFilling"), exception.getMessage()); - } - - @Test - void failsFastOnALiveExpectationWithoutSuccess(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "LIVE-BAD-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升。"}}, - "expect": {"scenarioCode": "private-line-complaint"} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("missing required field 'success'"), exception.getMessage()); - } - - @Test - void failsFastOnALiveGenerateRecordWithoutInputText(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "LIVE-BAD-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("input.text"), exception.getMessage()); - assertTrue(exception.getMessage().contains("generateTaskPromptFromText"), exception.getMessage()); - } - - @Test - void failsFastOnALiveRecordWithAGoldenOrFromStepPrompt(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "LIVE-BAD-01", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "prompt": {"golden": "task-happy-zh-CN"}, - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("only the inline prompt.text"), exception.getMessage()); - assertTrue(exception.getMessage().contains("golden"), exception.getMessage()); - } - - @Test - void failsFastOnALiveValidateRecordDeclaringPromptTextContains(@TempDir Path root) throws IOException { - write( - root, - "live/bad.json", - """ - [ - { - "id": "LIVE-BAD-01", - "api": "validateTaskPromptAndDataFilling", - "languages": ["zh-CN"], - "prompt": {"text": "## 任务类型(Task Type)"}, - "expect": {"success": true, "promptTextContains": ["## 任务类型"]} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("promptTextContains"), exception.getMessage()); - assertTrue(exception.getMessage().contains("no generated prompt"), exception.getMessage()); - } - - @Test - void defaultsTheLiveMaxLlmCallsToFourWhenTheRecordOmitsIt(@TempDir Path root) throws IOException { - write( - root, - "live/generate.json", - """ - [ - { - "id": "LIVE-GEN-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "input": {"text": {"zh-CN": "深圳访问广州的专线时延骤升。"}}, - "expect": {"success": true} - } - ] - """); - - LoadedCorpus corpus = NegotiationCaseLoader.load(root); - - assertEquals(4, corpus.liveCases().get(0).liveExpect().maxLlmCalls(), "maxLlmCalls defaults to 4"); - } - - @Test - void failsFastOnALiveIdCollidingWithAnOfflineCase(@TempDir Path root) throws IOException { - write(root, "from-text/dup.json", minimalCase("LIVE-DUP-01")); - write( - root, - "live/dup.json", - """ - [ - { - "id": "LIVE-DUP-01", - "api": "generateTaskPromptFromText", - "languages": ["zh-CN"], - "expect": {"success": true} - } - ] - """); - - CorpusLoadException exception = assertThrows(CorpusLoadException.class, () -> NegotiationCaseLoader.load(root)); - - assertTrue(exception.getMessage().contains("duplicate id 'LIVE-DUP-01'"), exception.getMessage()); - assertTrue(exception.getMessage().contains("from-text/dup.json"), exception.getMessage()); - } - - // ------------------------------------------------------------------ helpers - - private static void write(Path root, String relative, String content) throws IOException { - Path file = root.resolve(relative); - if (file.getParent() != null) { - Files.createDirectories(file.getParent()); - } - Files.writeString(file, content, StandardCharsets.UTF_8); - } - - private static void writeGoodCorpus(Path root) throws IOException { - write(root, "shared/llm-responses.json", SHARED_RESPONSES); - write(root, "shared/schemas.json", SHARED_SCHEMAS); - write( - root, - "from-text/happy.json", - """ - [ - { - "id": "FT-HAPPY-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["happy", "accept-reject"], - "summary": "信息确认后接受,成功", - "context": {"id": "%s", "round": 2, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": {"zh-CN": "我确认第一阶段的信息。", "en-US": "I confirm the first-stage information."}, - "data": {"items": [], "relationship": null} - }, - "llm": { - "maxAttempts": 3, - "script": [{"$fail": "non-json"}, {"$ref": "responses/extract.information.accept.full"}] - }, - "expect": { - "outcome": "success", - "llmCalls": 2, - "promptTextEqualsGolden": "information_accept", - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent"], - "differential": true - } - } - ] - """ - .formatted(SESSION_ID)); - write( - root, - "validate/happy.json", - """ - [ - { - "id": "VAL-HAPPY-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": {"golden": "information_propose"}, - "schema": {"$ref": "schemas/flat"}, - "llm": {"script": ["{\\"verdict\\": true}"]}, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": {"id": "%s", "round": 1, "maxRounds": 5} - } - } - ] - """ - .formatted(SESSION_ID, SESSION_ID)); - write( - root, - "scenarios/flows.json", - """ - [ - { - "id": "SC-INFO-01", - "summary": "提议后语义拒绝", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateProposeFromText", - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": {"text": {"zh-CN": "请提供节能区域信息"}}, - "llm": {"script": [{"$ref": "responses/extract.information.propose.full"}]}, - "expect": {"outcome": "success", "llmCalls": 1} - }, - { - "step": 2, - "role": "B", - "api": "validateProposePromptAndDataFilling", - "prompt": {"fromStep": 1}, - "context": {"id": "%s", "round": 1, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": {"$ref": "schemas/flat"}, - "llm": {"script": [{"$fail": "llm-error"}]}, - "expect": {"outcome": "failure", "code": "negotiation.semantic_rejected", "llmCalls": 1} - } - ], - "expectFlow": {"terminalCondition": "reject", "roundsUsed": 1, "distinctMessages": true} - } - ] - """ - .formatted(SESSION_ID, SESSION_ID)); - } - - private static String minimalCase(String id) { - return """ - [ - { - "id": "%s", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "context": {"id": "%s", "round": 2, "maxRounds": 5}, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": {"text": {"zh-CN": "我确认"}}, - "llm": {"script": ["{}"]}, - "expect": {"outcome": "success"} - } - ] - """ - .formatted(id, SESSION_ID); - } - - private static NegotiationCase caseById(LoadedCorpus corpus, String id) { - NegotiationCase found = null; - for (NegotiationCase candidate : corpus.cases()) { - if (candidate.id().equals(id)) { - found = candidate; - } - } - assertNotNull(found, "the corpus must expand the case " + id); - return found; - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/PromptSource.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/PromptSource.java deleted file mode 100644 index abaf3f0c..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/PromptSource.java +++ /dev/null @@ -1,33 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -/** - * The prompt input of one validate-family corpus record. - * - *

            Exactly one variant applies per record: a golden fixture name resolved per language by the engine, an inline - * prompt text, or the number of the scenario step whose generated prompt text feeds this validation step. - * - * @since 2026-08 - */ -public sealed interface PromptSource { - - /** - * The prompt is the golden fixture of the given name, resolved per language. - * - * @param golden golden fixture name such as {@code information_propose} - */ - record Golden(String golden) implements PromptSource {} - - /** - * The prompt is the given inline text. - * - * @param text prompt text verbatim - */ - record Text(String text) implements PromptSource {} - - /** - * The prompt is the prompt text generated by an earlier scenario step. - * - * @param step number of the producing scenario step, at least 1 - */ - record FromStep(int step) implements PromptSource {} -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/RecordingLLMClient.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/RecordingLLMClient.java deleted file mode 100644 index 308d2d45..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/RecordingLLMClient.java +++ /dev/null @@ -1,108 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import org.jspecify.annotations.Nullable; - -/** - * Recording decorator around the real LLM client of the live corpus (design document §4): it forwards every call to - * the delegate untouched — the wrapped client is the real provider instance, so the production behavior does not - * deviate — and records the request, the response (or the thrown exception) and the wall-clock duration of each call. - * - *

            The recorded calls are the single source of the live family's calibration points: the {@code maxLlmCalls} upper - * bound is checked against {@link #callCount()}, and the transcript embeds {@link #snapshot()} verbatim. A call that - * throws is recorded and then rethrown, so an infrastructure failure stays a failure while still showing up in the - * transcript. - * - *

            JUnit runs the corpus suites on one thread, but the client is defensive anyway: all accessors are synchronized - * and every read returns an immutable copy. - * - * @since 2026-08 - */ -public final class RecordingLLMClient implements LLMClient { - - private final LLMClient delegate; - - private final List calls = new ArrayList<>(); - - /** - * Creates a recording wrapper around the real delegate client. - * - * @param delegate the real provider client, such as the {@code OpenAIClient} built by the live harness - */ - public RecordingLLMClient(LLMClient delegate) { - this.delegate = Objects.requireNonNull(delegate, "delegate"); - } - - @Override - public synchronized LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - long startNanos = System.nanoTime(); - LLMResponse response = null; - String error = null; - try { - response = delegate.structured(messages, jsonSchema, temperature, maxTokens); - return response; - } catch (RuntimeException exception) { - error = exception.getClass().getName() + ": " + exception.getMessage(); - throw exception; - } finally { - calls.add(new LiveLlmCall( - messages, - jsonSchema, - temperature, - maxTokens, - response == null ? null : response.content(), - response == null ? null : response.model(), - response == null ? Map.of() : sanitizedUsage(response.usage()), - Duration.ofNanos(System.nanoTime() - startNanos).toMillis(), - error)); - } - } - - /** - * Copies the delegate's usage summary defensively: a lenient OpenAI-compatible endpoint may answer a JSON null - * value (such as {@code {"prompt_tokens": null}}), and passing that on would make the recording itself throw and - * mask the delegate outcome — so null-valued entries are dropped instead. - */ - private static Map sanitizedUsage(@Nullable Map usage) { - if (usage == null) { - return Map.of(); - } - Map sanitized = new LinkedHashMap<>(); - for (Map.Entry entry : usage.entrySet()) { - if (entry.getValue() != null) { - sanitized.put(entry.getKey(), entry.getValue()); - } - } - return sanitized; - } - - /** - * Returns the number of LLM calls made through this client, including the calls that threw. - * - * @return non-negative call count - */ - public synchronized int callCount() { - return calls.size(); - } - - /** - * Returns the transcript-facing snapshot of every recorded call, one entry per call in call order; the engine - * embeds this list into the live transcript's per-case {@code llmCalls} section. - * - * @return immutable list of recorded calls at snapshot time - */ - public synchronized List snapshot() { - return List.copyOf(calls); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/RecordingLLMClientTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/RecordingLLMClientTest.java deleted file mode 100644 index 5351a597..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/RecordingLLMClientTest.java +++ /dev/null @@ -1,170 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -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 java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import org.junit.jupiter.api.Test; - -/** - * Direct unit tests of {@link RecordingLLMClient}: transparent forwarding to the real delegate, full recording of the - * request, the response and the duration of every call, and the record-and-rethrow behavior on infrastructure - * failures. - */ -class RecordingLLMClientTest { - - private static final List> MESSAGES = - List.of(Map.of("role", "system", "content", "system prompt"), Map.of("role", "user", "content", "user prompt")); - - private static final Map SCHEMA = Map.of("type", "object", "properties", Map.of()); - - @Test - void forwardsToTheDelegateAndRecordsRequestAndResponse() { - RecordingLLMClient client = new RecordingLLMClient(new StubDelegate( - new LLMResponse("{\"a\":1}", "qwen3-27b", Map.of("prompt_tokens", 11, "completion_tokens", 7), Map.of()))); - - LLMResponse response = client.structured(MESSAGES, SCHEMA, 0.0, 512); - - assertEquals("{\"a\":1}", response.content(), "the wrapper must forward the delegate answer untouched"); - assertEquals(1, client.callCount()); - LiveLlmCall call = client.snapshot().get(0); - assertEquals(MESSAGES, call.messages()); - assertEquals(SCHEMA, call.jsonSchema()); - assertEquals(0.0, call.temperature()); - assertEquals(512, call.maxTokens()); - assertEquals("{\"a\":1}", call.content()); - assertEquals("qwen3-27b", call.model()); - assertEquals(Map.of("prompt_tokens", 11, "completion_tokens", 7), call.usage()); - assertNull(call.error()); - assertTrue(call.durationMs() >= 0, "the wall-clock duration is recorded even when it rounds to zero"); - assertFalse(call.failed()); - } - - @Test - void nullSchemaAndOverridesPassThroughAsNulls() { - RecordingLLMClient client = new RecordingLLMClient(new StubDelegate(new LLMResponse("{}", "m", Map.of(), Map.of()))); - - client.structured(MESSAGES, null, null, null); - - LiveLlmCall call = client.snapshot().get(0); - assertNull(call.jsonSchema()); - assertNull(call.temperature()); - assertNull(call.maxTokens()); - assertEquals(Map.of(), call.usage(), "a null usage records as an empty map"); - } - - @Test - void aNullValuedUsageEntryIsDroppedInsteadOfBreakingTheRecording() { - // A lenient endpoint may answer {"prompt_tokens": null}; the recording must survive it and keep the answered - // values, never mask the delegate outcome with a null-hostile map copy. - Map lenientUsage = new LinkedHashMap<>(); - lenientUsage.put("prompt_tokens", null); - lenientUsage.put("completion_tokens", 5); - RecordingLLMClient client = - new RecordingLLMClient(new StubDelegate(new LLMResponse("{}", "m", lenientUsage, Map.of()))); - - client.structured(MESSAGES, SCHEMA, null, null); - - assertEquals(Map.of("completion_tokens", 5), client.snapshot().get(0).usage()); - } - - @Test - void aNullDelegateAnswerRecordsNullResponseFields() { - RecordingLLMClient client = new RecordingLLMClient(new StubDelegate(null)); - - assertNull(client.structured(MESSAGES, SCHEMA, null, null)); - - LiveLlmCall call = client.snapshot().get(0); - assertEquals(1, client.callCount(), "the answered-null call counts as a call"); - assertNull(call.content()); - assertNull(call.model()); - assertNull(call.error()); - } - - @Test - void anInfrastructureFailureIsRecordedAndRethrown() { - LLMRuntimeError failure = new LLMRuntimeError("connection reset"); - RecordingLLMClient client = new RecordingLLMClient(new FailingDelegate(failure)); - - LLMRuntimeError thrown = - assertThrows(LLMRuntimeError.class, () -> client.structured(MESSAGES, SCHEMA, null, null)); - - assertEquals(failure, thrown, "the wrapper must rethrow the original exception, not wrap it"); - assertEquals(1, client.callCount(), "the failed call counts as a call"); - LiveLlmCall call = client.snapshot().get(0); - assertTrue(call.failed()); - assertTrue( - call.error().contains(LLMRuntimeError.class.getName()) && call.error().contains("connection reset"), - "the error record names the exception class and message, but was: " + call.error()); - assertNull(call.content()); - assertEquals(List.of(call), client.snapshot(), "the snapshot carries the failed call for the transcript"); - } - - @Test - void callsRecordInOrderAndTheAccessorsReturnImmutableCopies() { - RecordingLLMClient client = new RecordingLLMClient(new StubDelegate( - new LLMResponse("{\"a\":1}", "m", Map.of(), Map.of()), - new LLMResponse("{\"a\":2}", "m", Map.of(), Map.of()))); - - client.structured(MESSAGES, SCHEMA, null, null); - client.structured(MESSAGES, SCHEMA, null, null); - - assertEquals(2, client.callCount()); - assertEquals("{\"a\":1}", client.snapshot().get(0).content()); - assertEquals("{\"a\":2}", client.snapshot().get(1).content()); - List snapshot = client.snapshot(); - assertEquals(2, snapshot.size()); - assertThrows( - UnsupportedOperationException.class, - () -> snapshot.add(new LiveLlmCall(MESSAGES, null, null, null, null, null, Map.of(), 0, null)), - "the snapshot must be an immutable list"); - } - - /** Hand-written fake of the real delegate: it answers its canned responses one by one, null once exhausted. */ - private static final class StubDelegate implements LLMClient { - - private final List responses; - - private int cursor; - - private StubDelegate(LLMResponse... responses) { - this.responses = responses == null ? List.of() : List.of(responses); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - return cursor < responses.size() ? responses.get(cursor++) : null; - } - } - - /** Hand-written fake of the real delegate that always throws the given infrastructure failure. */ - private static final class FailingDelegate implements LLMClient { - - private final LLMRuntimeError failure; - - private FailingDelegate(LLMRuntimeError failure) { - this.failure = failure; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - throw failure; - } - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCase.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCase.java deleted file mode 100644 index 1b15e782..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCase.java +++ /dev/null @@ -1,165 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import org.jspecify.annotations.Nullable; - -/** - * One scenario record expanded for exactly one language. - * - *

            A scenario is a multi-step multi-API interaction over the content layer. Every step is a full case record - * (carried as its {@link ScenarioStep#caseData()}); the scenario adds the step ordering, the acting role, the role - * descriptions of the closed loop (Q23: {@code A=工作台 client} / {@code B=OMC server}) and the flow-level expectation. - * - * @param id expanded scenario id such as {@code SC-INFO-02/zh-CN} - * @param baseId scenario record id before the language expansion, such as {@code SC-INFO-02} - * @param sourceFile corpus file path relative to the corpus root, such as {@code scenarios/information-flows.json} - * @param language language this expansion runs in - * @param summary business-facing one-line summary, or null when the record states none - * @param roles role names in their first-appearance order, empty when the record states none - * @param steps scenario steps numbered consecutively from 1 - * @param expectFlow flow-level expectation, or null when the scenario states none - * @param rolesDesc business meaning of each role, keyed by role name, empty when the record states none - * @since 2026-08 - */ -public record ScenarioCase( - String id, - String baseId, - String sourceFile, - String language, - @Nullable String summary, - List roles, - List steps, - @Nullable ExpectFlow expectFlow, - Map rolesDesc) { - - /** - * Creates a scenario without role descriptions. - * - * @param id expanded scenario id such as {@code SC-INFO-02/zh-CN} - * @param baseId scenario record id before the language expansion - * @param sourceFile corpus file path relative to the corpus root - * @param language language this expansion runs in - * @param summary business-facing one-line summary, or null - * @param roles role names in their first-appearance order - * @param steps scenario steps numbered consecutively from 1 - * @param expectFlow flow-level expectation, or null - */ - public ScenarioCase( - String id, - String baseId, - String sourceFile, - String language, - @Nullable String summary, - List roles, - List steps, - @Nullable ExpectFlow expectFlow) { - this(id, baseId, sourceFile, language, summary, roles, steps, expectFlow, Map.of()); - } - - public ScenarioCase { - Objects.requireNonNull(id, "id"); - Objects.requireNonNull(baseId, "baseId"); - Objects.requireNonNull(sourceFile, "sourceFile"); - Objects.requireNonNull(language, "language"); - Objects.requireNonNull(steps, "steps"); - if (steps.isEmpty()) { - throw new IllegalArgumentException("The scenario must carry at least one step."); - } - roles = List.copyOf(roles); - steps = List.copyOf(steps); - rolesDesc = Map.copyOf(rolesDesc); - } - - /** - * Returns the role semantics rendering of one role for failure messages and the run summary, such as - * {@code B=OMC(server,执行/要数方,协商发起方)}. - * - * @param role role name, or null for a step without one - * @return role with its description when the scenario declares one, the bare role otherwise - */ - public String describeRole(@Nullable String role) { - if (role == null) { - return "(no role)"; - } - String description = rolesDesc.get(role); - return description == null ? role : role + "=" + description; - } - - /** - * Returns the role semantics line of the whole scenario, such as - * {@code roles: A=工作台(client,任务发起/补数方), B=OMC(server,执行/要数方,协商发起方)}. - * - * @return role semantics of every declared role, or an empty string when the scenario declares none - */ - public String describeRoles() { - if (roles.isEmpty() && rolesDesc.isEmpty()) { - return ""; - } - Map ordered = new LinkedHashMap<>(); - for (String role : roles) { - ordered.put(role, rolesDesc.getOrDefault(role, "")); - } - rolesDesc.forEach((role, description) -> ordered.putIfAbsent(role, "")); - StringBuilder rendered = new StringBuilder("roles: "); - boolean first = true; - for (Map.Entry entry : ordered.entrySet()) { - if (!first) { - rendered.append(", "); - } - first = false; - rendered.append(entry.getKey()); - if (!entry.getValue().isEmpty()) { - rendered.append('=').append(entry.getValue()); - } - } - return rendered.toString(); - } - - /** - * One step of a scenario. - * - * @param step step number, consecutive from 1 - * @param role acting role name, or null when the step states none - * @param caseData the step as a full expanded case record - */ - public record ScenarioStep(int step, @Nullable String role, NegotiationCase caseData) { - - public ScenarioStep { - if (step < 1) { - throw new IllegalArgumentException("The step number must be at least 1."); - } - Objects.requireNonNull(caseData, "caseData"); - } - } - - /** - * The flow-level expectation of a scenario. - * - * @param terminalCondition expected terminal condition: accept, reject, abort or exhausted - * @param roundsUsed expected largest round value reached across the steps - * @param distinctMessages true when the per-round prompt texts must be pairwise distinct - * @param missingParamsFilled number of the task-validation step whose missing parameters must be filled with - * values by the later steps of the flow (Q21: the {@code 缺参 → 补参} causal chain), or null when not asserted - */ - public record ExpectFlow( - @Nullable String terminalCondition, - @Nullable Integer roundsUsed, - @Nullable Boolean distinctMessages, - @Nullable Integer missingParamsFilled) { - - /** - * Creates a flow expectation without the missing-parameter causal assertion. - * - * @param terminalCondition expected terminal condition: accept, reject, abort or exhausted - * @param roundsUsed expected largest round value reached across the steps - * @param distinctMessages true when the per-round prompt texts must be pairwise distinct - */ - public ExpectFlow( - @Nullable String terminalCondition, @Nullable Integer roundsUsed, @Nullable Boolean distinctMessages) { - this(terminalCondition, roundsUsed, distinctMessages, null); - } - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCorpusSuiteTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCorpusSuiteTest.java deleted file mode 100644 index 024dd746..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioCorpusSuiteTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.stream.Stream; -import org.junit.jupiter.api.DynamicTest; -import org.junit.jupiter.api.TestFactory; - -/** - * Data-driven suite of the E2E scenario corpus: every scenario under {@code negotiation-cases/scenarios/} becomes one - * dynamic test executed by the {@link ScenarioEngine} — every step is a full corpus case run by the {@link CaseEngine}, - * plus the {@code prompt.fromStep} resolution and the flow-level expectation. - * - *

            The seed batch carries {@code scenarios/information-flows.json} (SC-INFO: the single-round happy accept loop, both - * languages). The later batches (SC-TGT, SC-FSB, SC-EXH, SC-ERR, boundary flows) land as further files of the same - * directory without any Java change. - * - *

            Run a subset with {@code -Dcase.filter=SC-INFO-*} (glob against the scenario id, design document Q1). - * - * @since 2026-08 - */ -class ScenarioCorpusSuiteTest { - - /** Executes every expanded scenario of the corpus. */ - @TestFactory - Stream scenarioCorpusCases() { - return CorpusSuites.scenarioTests("scenarios"); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngine.java deleted file mode 100644 index b956c653..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngine.java +++ /dev/null @@ -1,345 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import org.jspecify.annotations.Nullable; - -/** - * Executes one expanded corpus scenario: a multi-step, multi-API interaction over the negotiation content layer and - * the closed-loop task APIs. - * - *

            The scenario engine is deliberately thin (design document §3.2.1, Q11–Q13): every step is a full corpus case - * executed by the {@link CaseEngine} with its own scripted LLM behavior and its exact per-step {@code llmCalls} - * expectation. The scenario layer only adds what a step cannot express alone: - * - *

              - *
            • {@code prompt.fromStep} resolution — the prompt text an earlier generation step produced becomes the prompt input - * of a later validation step; - *
            • fail-fast — the first step failure aborts the whole scenario, later steps never run; - *
            • per-role LLM accounting — every step runs on its own scripted client, so the roles' counters stay independent by - * construction and the totals are tracked per role; - *
            • the flow-level expectation {@code expectFlow}: the terminal condition (the last generated message carries the - * accept/reject/abort literal, or the round limit was reached for {@code exhausted}), the largest round value reached, - * the pairwise distinctness of the generated messages, and {@code missingParamsFilled} — the closed-loop causal chain - * of Q21: the parameters a task-validation step found missing must carry values by the end of the flow; - *
            • the step-level causal expectation {@code expect.paramsFromStep}: the parameters this validation step extracted - * must fill the missing-parameter set an earlier task-validation step discovered — the {@code 缺参 → 补参} link; - *
            • role semantics (Q23): every step records its acting role, the failure messages carry the role with its business - * description, and a successful run prints one flow summary line with the role semantics. - *
            - * - * @since 2026-08 - */ -public final class ScenarioEngine { - - private final CaseEngine caseEngine = new CaseEngine(); - - /** - * Runs one expanded scenario step by step and asserts its flow-level expectation. - * - * @param scenario expanded corpus scenario - * @throws AssertionError when a step fails (fail-fast: later steps do not run) or a flow expectation mismatches - */ - public void runScenario(ScenarioCase scenario) { - Map stepPromptTexts = new LinkedHashMap<>(); - Map> stepMissingParams = new LinkedHashMap<>(); - Map> stepFilledParams = new LinkedHashMap<>(); - Map roleCallCounts = new LinkedHashMap<>(); - int maxRound = 0; - int maxRoundsLimit = 0; - String lastPromptText = null; - for (ScenarioCase.ScenarioStep step : scenario.steps()) { - NegotiationCase stepCase = step.caseData(); - String promptOverride = resolvePromptOverride(scenario, stepCase, stepPromptTexts); - CaseEngine.CaseOutcome outcome = caseEngine.run(stepCase, promptOverride, true); - MetadataContent message = outcome.message(); - if (message != null) { - stepPromptTexts.put(step.step(), message.promptText()); - lastPromptText = message.promptText(); - } - FilledParamData filled = outcome.filledParams(); - if (filled != null) { - stepFilledParams.put(step.step(), filled.data()); - stepMissingParams.put(step.step(), missingParamsOf(filled)); - } - roleCallCounts.merge(step.role() == null ? "(no role)" : step.role(), outcome.llmCalls(), Integer::sum); - if (stepCase.context() != null) { - maxRound = Math.max(maxRound, stepCase.context().round()); - maxRoundsLimit = Math.max(maxRoundsLimit, stepCase.context().maxRounds()); - } - if (stepCase.expect().paramsFromStep() != null) { - assertParamsFromStep(scenario, step, stepMissingParams, stepFilledParams); - } - } - assertExpectFlow(scenario, stepPromptTexts, stepMissingParams, stepFilledParams, maxRound, maxRoundsLimit, - lastPromptText); - printSummary(scenario, stepMissingParams, stepFilledParams); - } - - // ------------------------------------------------------------------ fromStep resolution - - private static String resolvePromptOverride( - ScenarioCase scenario, NegotiationCase stepCase, Map stepPromptTexts) { - if (!(stepCase.prompt() instanceof PromptSource.FromStep fromStep)) { - return null; - } - String promptText = stepPromptTexts.get(fromStep.step()); - if (promptText == null) { - throw new AssertionError( - errorPrefix(scenario) + " prompt.fromStep " + fromStep.step() - + ": the referenced step produced no prompt text (unknown step number, a step that has not" - + " run yet, or a non-generation API)"); - } - return promptText; - } - - // ------------------------------------------------------------------ 缺参 -> 补参 causal chain (Q21) - - /** - * Returns the missing-parameter set of one validation outcome: the names whose value in the filled parameter data - * is null — the semantic validator's explicit missing-slot signal downstream negotiation triggering relies on. - */ - private static Set missingParamsOf(FilledParamData filled) { - Set missing = new LinkedHashSet<>(); - filled.data().forEach((name, value) -> { - if (value == null) { - missing.add(name); - } - }); - return missing; - } - - /** - * Asserts the step-level causal expectation {@code expect.paramsFromStep N} of one step: the referenced step N - * must have discovered a non-empty missing-parameter set, and every one of those parameters must carry a value in - * this step's filled parameter data. - */ - private static void assertParamsFromStep( - ScenarioCase scenario, - ScenarioCase.ScenarioStep step, - Map> stepMissingParams, - Map> stepFilledParams) { - int fromStep = step.caseData().expect().paramsFromStep(); - Set missing = stepMissingParams.get(fromStep); - if (missing == null) { - throw new AssertionError(stepErrorPrefix(scenario, step) + " expect.paramsFromStep " + fromStep - + ": the referenced step produced no filled parameter data (unknown step number, a step that has" - + " not run yet, or a non-validation API)"); - } - if (missing.isEmpty()) { - throw new AssertionError(stepErrorPrefix(scenario, step) + " expect.paramsFromStep " + fromStep - + ": the referenced step found no missing parameters, so there is nothing this step could fill"); - } - Map filled = stepFilledParams.get(step.step()); - List unfilled = new ArrayList<>(); - for (String name : missing) { - if (filled == null || filled.get(name) == null) { - unfilled.add(name); - } - } - if (!unfilled.isEmpty()) { - throw new AssertionError(stepErrorPrefix(scenario, step) + " expect.paramsFromStep " + fromStep - + ": the parameters extracted by this step do not fill the missing parameters of step " + fromStep - + "; still missing: " + String.join(", ", unfilled)); - } - } - - // ------------------------------------------------------------------ flow-level expectation - - private void assertExpectFlow( - ScenarioCase scenario, - Map stepPromptTexts, - Map> stepMissingParams, - Map> stepFilledParams, - int maxRound, - int maxRoundsLimit, - @Nullable String lastPromptText) { - ScenarioCase.ExpectFlow flow = scenario.expectFlow(); - if (flow == null) { - return; - } - if (flow.terminalCondition() != null) { - switch (flow.terminalCondition()) { - case "accept", "reject", "abort" -> { - String literal = terminalLiteral(flow.terminalCondition()); - if (lastPromptText == null) { - fail( - scenario, - "$.expectFlow.terminalCondition", - "a generated message carrying the '" + literal + "' literal", - "no generation step succeeded"); - } - if (!lastPromptText.contains(literal)) { - fail( - scenario, - "$.expectFlow.terminalCondition", - "a final message containing '" + literal + "'", - "<" + lastPromptText + ">"); - } - } - case "exhausted" -> { - if (maxRound != maxRoundsLimit || maxRoundsLimit == 0) { - fail( - scenario, - "$.expectFlow.terminalCondition", - "the round limit reached (largest round equals maxRounds " + maxRoundsLimit + ")", - "largest round " + maxRound + ", maxRounds " + maxRoundsLimit); - } - } - default -> fail( - scenario, - "$.expectFlow.terminalCondition", - "accept, reject, abort or exhausted", - flow.terminalCondition()); - } - } - if (flow.roundsUsed() != null && flow.roundsUsed() != maxRound) { - fail( - scenario, - "$.expectFlow.roundsUsed", - String.valueOf(flow.roundsUsed()), - String.valueOf(maxRound)); - } - if (Boolean.TRUE.equals(flow.distinctMessages())) { - List messages = new ArrayList<>(stepPromptTexts.values()); - Set distinct = new LinkedHashSet<>(messages); - if (distinct.size() != messages.size()) { - fail( - scenario, - "$.expectFlow.distinctMessages", - messages.size() + " pairwise distinct generated messages", - distinct.size() + " distinct message(s)"); - } - } - if (flow.missingParamsFilled() != null) { - assertMissingParamsFilled(scenario, flow.missingParamsFilled(), stepMissingParams, stepFilledParams); - } - } - - /** - * Asserts the flow-level causal expectation {@code expectFlow.missingParamsFilled N} (Q21): step N must have - * discovered a non-empty missing-parameter set, and the union of the filled parameter data of the later steps — - * later steps winning on key conflicts — must carry a value for every one of them. - */ - private static void assertMissingParamsFilled( - ScenarioCase scenario, - int missingStep, - Map> stepMissingParams, - Map> stepFilledParams) { - Set missing = stepMissingParams.get(missingStep); - if (missing == null) { - fail( - scenario, - "$.expectFlow.missingParamsFilled", - "a task-validation step " + missingStep + " carrying a missing-parameter set", - "step " + missingStep + " produced no filled parameter data"); - } - if (missing.isEmpty()) { - fail( - scenario, - "$.expectFlow.missingParamsFilled", - "a non-empty missing-parameter set at step " + missingStep, - "step " + missingStep + " found no missing parameters"); - } - Map filledUnion = new LinkedHashMap<>(); - for (Map.Entry> entry : new TreeMap<>(stepFilledParams).entrySet()) { - if (entry.getKey() > missingStep) { - filledUnion.putAll(entry.getValue()); - } - } - List unfilled = new ArrayList<>(); - for (String name : missing) { - if (filledUnion.get(name) == null) { - unfilled.add(name); - } - } - if (!unfilled.isEmpty()) { - fail( - scenario, - "$.expectFlow.missingParamsFilled", - "the missing parameters of step " + missingStep + " (" + String.join(", ", missing) - + ") filled by the later steps", - "still missing after step " + missingStep + ": " + String.join(", ", unfilled)); - } - } - - // ------------------------------------------------------------------ role-semantic run summary (Q23) - - /** - * Prints the one-line flow summary of a successful scenario run, carrying the role semantics and — when the - * scenario asserts the causal chain — the missing-to-filled parameter link. - */ - private static void printSummary( - ScenarioCase scenario, - Map> stepMissingParams, - Map> stepFilledParams) { - StringBuilder summary = new StringBuilder("[scenario] ") - .append(scenario.id()) - .append(" completed ") - .append(scenario.steps().size()) - .append(" step(s)"); - String roles = scenario.describeRoles(); - if (!roles.isEmpty()) { - summary.append("; ").append(roles); - } - stepMissingParams.forEach((step, missing) -> { - if (missing.isEmpty()) { - return; - } - summary.append("; step-").append(step).append(" missing params: ").append(String.join(", ", missing)); - for (int later : new TreeMap<>(stepFilledParams).keySet()) { - if (later <= step) { - continue; - } - List filledHere = new ArrayList<>(); - for (String name : missing) { - Object value = stepFilledParams.get(later).get(name); - if (value != null) { - filledHere.add(name + "=" + value); - } - } - if (!filledHere.isEmpty()) { - summary.append(" -> filled at step-").append(later).append(": ") - .append(String.join(", ", filledHere)); - } - } - }); - System.out.println(summary); - } - - private static String terminalLiteral(String terminalCondition) { - return switch (terminalCondition) { - case "accept" -> "Accept"; - case "reject" -> "Reject"; - case "abort" -> "Abort"; - default -> throw new IllegalArgumentException("Unknown terminal condition " + terminalCondition + "."); - }; - } - - // ------------------------------------------------------------------ helpers - - private static String errorPrefix(ScenarioCase scenario) { - return scenario.sourceFile() + " [" + scenario.id() + "]"; - } - - /** - * Returns the failure-message prefix of one step, carrying the step number and the role semantics of the acting - * role (Q23), such as {@code scenarios/x.json [SC-1/zh-CN#step-5 (B=OMC(server,执行/要数方))]}. - */ - private static String stepErrorPrefix(ScenarioCase scenario, ScenarioCase.ScenarioStep step) { - return scenario.sourceFile() + " [" + scenario.id() + "#step-" + step.step() + " (" - + scenario.describeRole(step.role()) + ")]"; - } - - private static AssertionError fail(ScenarioCase scenario, String jsonPath, String expected, String actual) { - throw new AssertionError( - errorPrefix(scenario) + " " + jsonPath + ": expected " + expected + " but was " + actual); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java deleted file mode 100644 index 16196195..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScenarioEngineTest.java +++ /dev/null @@ -1,517 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.corpus.ScenarioCase.ExpectFlow; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * Direct unit tests of the {@link ScenarioEngine} against inline scenario objects: the {@code prompt.fromStep} - * resolution, the fail-fast step execution, and the three {@code expectFlow} fields (terminal condition, rounds used, - * pairwise distinct messages). - */ -class ScenarioEngineTest { - - private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; - - private static final String ZH_CN = "zh-CN"; - - private static final String INFORMATION_PROPOSE_URI = "Negotiation-T/information-negotiation/propose/v1"; - - private static final String INFORMATION_ACCEPT_REJECT_URI = - "Negotiation-T/information-negotiation/accept-reject/v1"; - - private static final String ACCEPT_PAYLOAD = - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城" - + "-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}"; - - private static final String PROPOSE_PAYLOAD = - "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}," - + "{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\"," - + "\"value\":null}],\"relationship\":\"OR\"}"; - - private static final String SEMANTIC_ACCEPT_PAYLOAD = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"; - - private static final String PRIVATE_LINE_COMPLAINT_URI = "Task-T/network-layer/private-line-complaint/v1"; - - private static final Map CLOSED_LOOP_ROLES = - Map.of("A", "工作台(client,任务发起/补数方)", "B", "OMC(server,执行/要数方,协商发起方)"); - - /** The workbench raw complaint (样例步骤1): no access port name and no complaint category in the text. */ - private static final String COMPLAINT_TEXT = - "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报" + "“连接超时”。OSS侧事件流水号:event-id-20260511-09013。"; - - /** - * Slot-extraction payload of the closed loop's step 1 since the task_object slot became required upstream: the task - * object carries what the raw text names (the circuit) but no port name, so the rendered prompt stays portless and - * the two schema parameters stay missing for the negotiation to fill. - */ - private static final String TASK_SLOTS_OBJECT_PORTLESS = - "{\"slots\": {\"任务对象\": \"深圳访问广州的专线\", \"任务上下文\": \"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;" - + "OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms\"}," - + " \"slot_errors\": []}"; - - /** - * Semantic payload of the closed loop's step 2: the OMC finds the access port name and the complaint category - * missing — the two null-valued parameters driving the information negotiation. - */ - private static final String TASK_SEMANTIC_MISSING_PARAMS = - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null," - + "\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}"; - - /** Semantic payload of the closed loop's final step: the accept message carries both previously missing values. */ - private static final String SEMANTIC_ACCEPT_WITH_FILLED_PARAMS = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" - + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\",\"bizScenario\":\"专线质差\"}}"; - - /** Semantic payload filling only one of the two missing parameters (the unfilled-parameter red path). */ - private static final String SEMANTIC_ACCEPT_PARTIALLY_FILLED = - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":" - + "{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}"; - - private static final String TASK_PARAM_SCHEMA = - "{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"},\"bizScenario\":" - + "{\"type\":\"string\"},\"faultTime\":{\"type\":\"string\"},\"eventSerialNo\":" - + "{\"type\":\"string\"}},\"required\":[\"accessPort\",\"bizScenario\"]}"; - - private final ScenarioEngine engine = new ScenarioEngine(); - - private final ObjectMapper mapper = new ObjectMapper(); - - private JsonNode schema; - - @BeforeEach - void readSchema() throws Exception { - schema = mapper.readTree("{\"type\":\"object\",\"properties\":{\"accessPort\":{\"type\":\"string\"}}}"); - } - - // ------------------------------------------------------------------ fromStep resolution - - @Test - void runsATwoStepScenarioResolvingTheFromStepPrompt() { - ScenarioCase scenario = scenario( - "SC-INFO-01", - List.of( - step(1, "A", acceptStep("SC-INFO-01", ok(1, "information_accept"))), - step(2, "B", validateAcceptStep("SC-INFO-01", 1, ok(1, null)))), - new ScenarioCase.ExpectFlow("accept", 2, null)); - - engine.runScenario(scenario); - } - - @Test - void failsWhenAFromStepReferencesAnUnknownStep() { - ScenarioCase scenario = - scenario("SC-INFO-02", List.of(step(1, "A", validateAcceptStep("SC-INFO-02", 3, ok(1, null)))), null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("SC-INFO-02/zh-CN") - && failure.getMessage().contains("prompt.fromStep 3") - && failure.getMessage().contains("produced no prompt text"), - "the unknown fromStep reference must fail with the scenario id and the step number but was: " - + failure.getMessage()); - } - - // ------------------------------------------------------------------ fail-fast - - @Test - void failsFastWhenAStepFails() { - NegotiationCase failingStep = stepCase( - "SC-ERR-01", - 1, - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "请接受。", - new LlmScript(null, List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))), - null, - null, - failed("NegotiationGenerationException", "llm.response_invalid", null)); - ScenarioCase scenario = scenario( - "SC-ERR-01", - List.of(step(1, "A", failingStep), step(2, "B", validateAcceptStep("SC-ERR-01", 1, ok(1, null)))), - null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("#step-1") && failure.getMessage().contains("$.expect.code"), - "the scenario must abort on the failing step 1 but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ expectFlow - - @Test - void assertsDistinctMessagesAndRoundsUsedAcrossSteps() { - ScenarioCase scenario = scenario( - "SC-INFO-03", - List.of( - step(1, "A", proposeStep("SC-INFO-03", 2, ok(1, "information_propose"))), - step(2, "B", acceptStep("SC-INFO-03", 3, ok(1, "information_accept")))), - new ScenarioCase.ExpectFlow("accept", 3, Boolean.TRUE)); - - engine.runScenario(scenario); - } - - @Test - void distinctMessagesFailsOnDuplicateMessages() { - ScenarioCase scenario = scenario( - "SC-INFO-04", - List.of( - step(1, "A", acceptStep("SC-INFO-04", 2, ok(1, "information_accept"))), - step(2, "B", acceptStep("SC-INFO-04", 3, ok(1, "information_accept")))), - new ScenarioCase.ExpectFlow("accept", 3, Boolean.TRUE)); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("$.expectFlow.distinctMessages"), - "duplicate messages must fail the distinctMessages expectation but was: " + failure.getMessage()); - } - - @Test - void exhaustedRequiresTheRoundLimitToBeReached() { - ScenarioCase exhausted = scenario( - "SC-EXH-01", - List.of(step(1, "A", acceptStep("SC-EXH-01", 5, ok(1, "information_accept")))), - new ScenarioCase.ExpectFlow("exhausted", 5, null)); - engine.runScenario(exhausted); - - ScenarioCase notExhausted = scenario( - "SC-EXH-02", - List.of(step(1, "A", acceptStep("SC-EXH-02", 2, ok(1, "information_accept")))), - new ScenarioCase.ExpectFlow("exhausted", 2, null)); - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(notExhausted)); - - assertTrue( - failure.getMessage().contains("$.expectFlow.terminalCondition"), - "a round below the limit must fail the exhausted expectation but was: " + failure.getMessage()); - } - - // ------------------------------------------------------------------ closed loop: 缺参 -> 补参 (Q21) - - @Test - void runsTheClosedLoopScenarioLinkingMissingParamsToTheFilledParams() throws Exception { - ScenarioCase scenario = closedLoopScenario( - SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 2), 2); - - engine.runScenario(scenario); - } - - @Test - void paramsFromStepFailsWhenTheReferencedStepProducedNoFilledParams() throws Exception { - ScenarioCase scenario = closedLoopScenario(SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, null, 1); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("expect.paramsFromStep 1") - && failure.getMessage().contains("produced no filled parameter data") - && failure.getMessage().contains("B=OMC(server,执行/要数方,协商发起方)"), - "the dangling paramsFromStep reference must fail with role semantics but was: " + failure.getMessage()); - } - - @Test - void paramsFromStepFailsWhenAParameterStaysUnfilled() throws Exception { - ScenarioCase scenario = closedLoopScenario(SEMANTIC_ACCEPT_PARTIALLY_FILLED, null, 2); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("expect.paramsFromStep 2") - && failure.getMessage().contains("still missing: bizScenario") - && failure.getMessage().contains("#step-4"), - "an unfilled missing parameter must fail the causal assertion but was: " + failure.getMessage()); - } - - @Test - void missingParamsFilledFailsWhenNoLaterStepFillsTheParameters() throws Exception { - ScenarioCase scenario = closedLoopScenario( - SEMANTIC_ACCEPT_PARTIALLY_FILLED, new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 2), null); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("$.expectFlow.missingParamsFilled") - && failure.getMessage().contains("still missing after step 2: bizScenario"), - "the flow-level causal expectation must fail on an unfilled parameter but was: " - + failure.getMessage()); - } - - @Test - void missingParamsFilledFailsWhenTheReferencedStepFoundNothingMissing() throws Exception { - ScenarioCase scenario = closedLoopScenario( - SEMANTIC_ACCEPT_WITH_FILLED_PARAMS, new ScenarioCase.ExpectFlow("accept", 2, Boolean.TRUE, 4), 2); - - AssertionError failure = assertThrows(AssertionError.class, () -> engine.runScenario(scenario)); - - assertTrue( - failure.getMessage().contains("$.expectFlow.missingParamsFilled") - && failure.getMessage().contains("no missing parameters"), - "a referenced step without missing parameters must fail the flow expectation but was: " - + failure.getMessage()); - } - - // ------------------------------------------------------------------ builders - - private static ScenarioCase scenario( - String id, List steps, @Nullable ExpectFlow expectFlow) { - return new ScenarioCase( - id + "/zh-CN", - id, - "scenarios/information-flows.json", - ZH_CN, - null, - List.of("A", "B"), - steps, - expectFlow); - } - - /** - * Builds the four-step closed loop of the private-line complaint diagnosis (Q21): the workbench generates the - * incomplete task prompt (step 1), the OMC validates it and finds the two missing parameters (step 2), the - * workbench accepts the information negotiation and supplies the values (step 3), and the OMC validates the accept - * message and extracts the filled parameters (step 4). - */ - private ScenarioCase closedLoopScenario( - String acceptSemanticPayload, @Nullable ExpectFlow expectFlow, @Nullable Integer paramsFromStep) - throws Exception { - NegotiationCase taskGeneration = stepCase( - "SC-TASK-01", - 1, - NegotiationApi.GENERATE_TASK_PROMPT_FROM_TEXT, - null, - PRIVATE_LINE_COMPLAINT_URI, - COMPLAINT_TEXT, - script(TASK_SLOTS_OBJECT_PORTLESS), - null, - null, - ok(1, null)); - NegotiationCase taskValidation = stepCase( - "SC-TASK-01", - 2, - NegotiationApi.VALIDATE_TASK_PROMPT_AND_DATA_FILLING, - null, - PRIVATE_LINE_COMPLAINT_URI, - null, - script(TASK_SEMANTIC_MISSING_PARAMS), - new PromptSource.FromStep(1), - mapper.readTree(TASK_PARAM_SCHEMA), - okTask(1, null, List.of("accessPort", "bizScenario"), Map.of("faultTime", "2026-05-11T08:21:46Z"))); - NegotiationCase acceptGeneration = stepCase( - "SC-TASK-01", - 3, - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "我方补充:接入端口名称P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。", - script(ACCEPT_PAYLOAD), - null, - null, - ok(1, null)); - NegotiationCase acceptValidation = stepCase( - "SC-TASK-01", - 4, - NegotiationApi.VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - null, - script(acceptSemanticPayload), - new PromptSource.FromStep(3), - schema, - okFromStep(1, paramsFromStep)); - return new ScenarioCase( - "SC-TASK-01/zh-CN", - "SC-TASK-01", - "scenarios/task-closed-loop.json", - ZH_CN, - null, - List.of("A", "B"), - List.of( - new ScenarioCase.ScenarioStep(1, "A", taskGeneration), - new ScenarioCase.ScenarioStep(2, "B", taskValidation), - new ScenarioCase.ScenarioStep(3, "A", acceptGeneration), - new ScenarioCase.ScenarioStep(4, "B", acceptValidation)), - expectFlow, - CLOSED_LOOP_ROLES); - } - - private static ScenarioCase.ScenarioStep step(int number, String role, NegotiationCase caseData) { - return new ScenarioCase.ScenarioStep(number, role, caseData); - } - - private NegotiationCase acceptStep(String scenarioId, Expectation expect) { - return stepCase( - scenarioId, - 1, - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - "我确认第一阶段的信息。", - script(ACCEPT_PAYLOAD), - null, - null, - expect); - } - - private NegotiationCase acceptStep(String scenarioId, int round, Expectation expect) { - return stepCase( - scenarioId, - 1, - NegotiationApi.GENERATE_ACCEPT_FROM_TEXT, - new ContextSpec(SESSION_ID, round, 5), - INFORMATION_ACCEPT_REJECT_URI, - "我确认第一阶段的信息。", - script(ACCEPT_PAYLOAD), - null, - null, - expect); - } - - private NegotiationCase proposeStep(String scenarioId, int round, Expectation expect) { - return stepCase( - scenarioId, - 1, - NegotiationApi.GENERATE_PROPOSE_FROM_TEXT, - new ContextSpec(SESSION_ID, round, 5), - INFORMATION_PROPOSE_URI, - "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识信息。", - script(PROPOSE_PAYLOAD), - null, - null, - expect); - } - - private NegotiationCase validateAcceptStep(String scenarioId, int fromStep, Expectation expect) { - return stepCase( - scenarioId, - 2, - NegotiationApi.VALIDATE_ACCEPT_PROMPT_AND_DATA_FILLING, - new ContextSpec(SESSION_ID, 2, 5), - INFORMATION_ACCEPT_REJECT_URI, - null, - script(SEMANTIC_ACCEPT_PAYLOAD), - new PromptSource.FromStep(fromStep), - schema, - expect); - } - - private NegotiationCase stepCase( - String scenarioId, - int stepNumber, - NegotiationApi api, - ContextSpec context, - String templateUri, - @Nullable String inputText, - LlmScript llmScript, - @Nullable PromptSource prompt, - @Nullable JsonNode schema, - Expectation expect) { - return new NegotiationCase( - scenarioId + "/zh-CN#step-" + stepNumber, - scenarioId, - "scenarios/information-flows.json", - api, - ZH_CN, - null, - List.of(), - null, - context, - templateUri, - inputText, - null, - llmScript, - prompt, - schema, - null, - expect); - } - - private static LlmScript script(String payload) { - return new LlmScript(null, List.of(new LlmScriptStep.Payload(payload))); - } - - // ------------------------------------------------------------------ expectation helpers - - private static Expectation ok(@Nullable Integer llmCalls, @Nullable String promptTextEqualsGolden) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - promptTextEqualsGolden, - null, - java.util.Map.of(), - List.of(), - false); - } - - private static Expectation failed(String exception, String code, @Nullable Integer llmCalls) { - return new Expectation( - false, - exception, - code, - List.of(), - List.of(), - llmCalls, - null, - null, - java.util.Map.of(), - List.of(), - false); - } - - /** Task-family success expectation with the missing-parameter set of the task validation. */ - private static Expectation okTask( - @Nullable Integer llmCalls, - @Nullable List promptTextContains, - @Nullable List missingParams, - Map params) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - null, - null, - params, - List.of(), - false, - promptTextContains == null ? List.of() : promptTextContains, - missingParams, - null); - } - - /** Success expectation with the step-level causal reference {@code expect.paramsFromStep}. */ - private static Expectation okFromStep(@Nullable Integer llmCalls, @Nullable Integer paramsFromStep) { - return new Expectation( - true, - null, - null, - List.of(), - List.of(), - llmCalls, - null, - null, - java.util.Map.of(), - List.of(), - false, - List.of(), - null, - paramsFromStep); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClient.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClient.java deleted file mode 100644 index ab1d6f27..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClient.java +++ /dev/null @@ -1,175 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; - -/** - * Scripted LLM client of the negotiation test corpus: the single scripted seam of the otherwise fully production-wired - * engine. - * - *

            The client consumes the resolved script steps of one corpus case strictly step by step: a payload step answers one - * {@link LLMResponse} carrying the payload text, a failure step replays the marker's real failure form. Every call - * records the received messages and the call count, so the {@code llmCalls} expectation stays the only calibration - * point. - * - *

            {@link LlmFailMarker#ASSERTION} is the zero-call proof: any call throws an {@link AssertionError}, which is how the - * engine proves that the from-data and the differential runs never touch the LLM. - * - *

            Fail-on-overconsumption is on by default (review risk 6 of the design document): once the script is - * exhausted the next call fails instead of silently repeating the last answer, because repeat-last semantics would mask - * a wrong {@code llmCalls} expectation. The flag exists only as an explicit escape hatch for engine self-tests. - * - * @since 2026-08 - */ -public final class ScriptedNegotiationLlmClient implements LLMClient { - - /** Raw infrastructure detail of the {@code runtime-exception} marker; must never reach a user-visible message. */ - public static final String RUNTIME_EXCEPTION_DETAIL = "scripted-llm-transport-failure"; - - /** Raw infrastructure detail of the {@code llm-error} marker; must never reach a user-visible message. */ - public static final String LLM_ERROR_DETAIL = "scripted-llm-error-response"; - - /** Unparseable payload content of the {@code non-json} marker. */ - public static final String NON_JSON_CONTENT = ""; - - private final List steps; - - private final boolean failOnOverconsumption; - - private int cursor; - - private int callCount; - - private final List>> recordedMessages = new ArrayList<>(); - - private final List> recordedSchemas = new ArrayList<>(); - - private final List leakedFailureDetails = new ArrayList<>(); - - /** - * Creates a strictly consuming scripted client. - * - * @param steps resolved script steps, consumed strictly step by step - */ - public ScriptedNegotiationLlmClient(List steps) { - this(steps, true); - } - - /** - * Creates a scripted client with an explicit over-consumption policy. - * - * @param steps resolved script steps, consumed strictly step by step - * @param failOnOverconsumption true fails once the script is exhausted; false repeats the last step (escape hatch) - */ - public ScriptedNegotiationLlmClient(List steps, boolean failOnOverconsumption) { - this.steps = List.copyOf(Objects.requireNonNull(steps, "steps")); - this.failOnOverconsumption = failOnOverconsumption; - } - - /** - * Creates the zero-call proof client: any call throws an {@link AssertionError}. - * - * @return assertion-only scripted client - */ - public static ScriptedNegotiationLlmClient assertionOnly() { - return new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Fail(LlmFailMarker.ASSERTION))); - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - callCount++; - recordedMessages.add(messages == null ? List.of() : List.copyOf(messages)); - recordedSchemas.add(jsonSchema); - if (cursor >= steps.size()) { - if (failOnOverconsumption) { - throw new IllegalStateException("The scripted LLM client consumed its whole script of " - + steps.size() + " step(s); an exhausted script fails instead of repeating the last answer" - + " (the llmCalls expectation is the only calibration point)."); - } - cursor = steps.size() - 1; - } - LlmScriptStep step = steps.get(cursor); - cursor++; - if (step instanceof LlmScriptStep.Payload payload) { - return new LLMResponse( - payload.json(), "scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - } - return failAs(((LlmScriptStep.Fail) step).marker()); - } - - /** - * Returns the number of LLM calls made through this client, including the calls that failed. - * - * @return non-negative call count - */ - public int callCount() { - return callCount; - } - - /** - * Returns the messages of every call, one entry per call. - * - * @return recorded messages per call - */ - public List>> recordedMessages() { - return List.copyOf(recordedMessages); - } - - /** - * Returns the messages of the most recent call. - * - * @return messages of the last call, or an empty list before the first call - */ - public List> lastMessages() { - return recordedMessages.isEmpty() ? List.of() : recordedMessages.get(recordedMessages.size() - 1); - } - - /** - * Returns the JSON schema of the most recent call. - * - * @return schema of the last call, or null before the first call - */ - public Map lastSchema() { - return recordedSchemas.isEmpty() ? null : recordedSchemas.get(recordedSchemas.size() - 1); - } - - /** - * Returns the raw infrastructure details of the scripted failures that were replayed, for the - * {@code noLlmLeakInUserMessage} contract. - * - * @return raw failure details that must never surface in a user-visible message - */ - public List leakedFailureDetails() { - return List.copyOf(leakedFailureDetails); - } - - private LLMResponse failAs(LlmFailMarker marker) { - return switch (marker) { - case RUNTIME_EXCEPTION -> { - leakedFailureDetails.add(RUNTIME_EXCEPTION_DETAIL); - throw new IllegalStateException(RUNTIME_EXCEPTION_DETAIL); - } - case LLM_ERROR -> { - leakedFailureDetails.add(LLM_ERROR_DETAIL); - throw new LLMRuntimeError(LLM_ERROR_DETAIL); - } - case NULL_RESPONSE -> null; - case BLANK_CONTENT -> new LLMResponse( - "", "scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - case NON_JSON -> new LLMResponse( - NON_JSON_CONTENT, "scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); - case ASSERTION -> throw new AssertionError( - "No LLM call was expected in this run (assertion-only client), but one happened with messages: " - + recordedMessages.get(recordedMessages.size() - 1)); - }; - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClientTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClientTest.java deleted file mode 100644 index a4150e24..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ScriptedNegotiationLlmClientTest.java +++ /dev/null @@ -1,137 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -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 java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.llm.LLMRuntimeError; -import org.junit.jupiter.api.Test; - -/** - * Direct unit tests of {@link ScriptedNegotiationLlmClient}: strict step-by-step consumption, the six failure markers, - * the call and message recording, and the fail-on-overconsumption default that forbids repeat-last semantics. - */ -class ScriptedNegotiationLlmClientTest { - - private static final List> MESSAGES = - List.of(Map.of("role", "system", "content", "system prompt"), Map.of("role", "user", "content", "user prompt")); - - @Test - void consumesPayloadStepsStrictlyInOrder() { - ScriptedNegotiationLlmClient client = new ScriptedNegotiationLlmClient( - List.of(new LlmScriptStep.Payload("{\"a\":1}"), new LlmScriptStep.Payload("{\"a\":2}"))); - - LLMResponse first = client.structured(MESSAGES, Map.of(), null, null); - LLMResponse second = client.structured(MESSAGES, Map.of(), null, null); - - assertEquals("{\"a\":1}", first.content()); - assertEquals("{\"a\":2}", second.content()); - assertEquals(2, client.callCount()); - assertEquals(2, client.recordedMessages().size()); - assertEquals(MESSAGES, client.lastMessages()); - assertEquals("scripted-model", first.model()); - } - - @Test - void runtimeExceptionMarkerThrowsAnInfrastructureRuntimeException() { - ScriptedNegotiationLlmClient client = - new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Fail(LlmFailMarker.RUNTIME_EXCEPTION))); - - IllegalStateException failure = - assertThrows(IllegalStateException.class, () -> client.structured(MESSAGES, Map.of(), null, null)); - - assertEquals(ScriptedNegotiationLlmClient.RUNTIME_EXCEPTION_DETAIL, failure.getMessage()); - assertEquals(1, client.callCount()); - assertEquals(List.of(ScriptedNegotiationLlmClient.RUNTIME_EXCEPTION_DETAIL), client.leakedFailureDetails()); - } - - @Test - void llmErrorMarkerThrowsTheLlmInfrastructureErrorType() { - ScriptedNegotiationLlmClient client = - new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Fail(LlmFailMarker.LLM_ERROR))); - - LLMRuntimeError failure = - assertThrows(LLMRuntimeError.class, () -> client.structured(MESSAGES, Map.of(), null, null)); - - assertEquals(ScriptedNegotiationLlmClient.LLM_ERROR_DETAIL, failure.getMessage()); - assertEquals(1, client.callCount()); - assertEquals(List.of(ScriptedNegotiationLlmClient.LLM_ERROR_DETAIL), client.leakedFailureDetails()); - } - - @Test - void nullResponseMarkerReturnsNull() { - ScriptedNegotiationLlmClient client = - new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Fail(LlmFailMarker.NULL_RESPONSE))); - - assertNull(client.structured(MESSAGES, Map.of(), null, null)); - assertEquals(1, client.callCount()); - } - - @Test - void blankContentMarkerAnswersABlankPayload() { - ScriptedNegotiationLlmClient client = - new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Fail(LlmFailMarker.BLANK_CONTENT))); - - LLMResponse response = client.structured(MESSAGES, Map.of(), null, null); - - assertNotNull(response); - assertTrue(response.content().isBlank()); - assertEquals(1, client.callCount()); - } - - @Test - void nonJsonMarkerAnswersAnUnparseablePayload() { - ScriptedNegotiationLlmClient client = - new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Fail(LlmFailMarker.NON_JSON))); - - LLMResponse response = client.structured(MESSAGES, Map.of(), null, null); - - assertEquals(ScriptedNegotiationLlmClient.NON_JSON_CONTENT, response.content()); - assertEquals(1, client.callCount()); - } - - @Test - void assertionMarkerFailsOnAnyCall() { - ScriptedNegotiationLlmClient client = ScriptedNegotiationLlmClient.assertionOnly(); - - AssertionError failure = - assertThrows(AssertionError.class, () -> client.structured(MESSAGES, Map.of(), null, null)); - - assertTrue( - failure.getMessage().contains("No LLM call was expected"), - "the assertion marker must name the zero-call expectation but was: " + failure.getMessage()); - assertEquals(1, client.callCount()); - } - - @Test - void exhaustedScriptFailsInsteadOfRepeatingTheLastAnswer() { - ScriptedNegotiationLlmClient client = - new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Payload("{\"a\":1}"))); - - client.structured(MESSAGES, Map.of(), null, null); - IllegalStateException failure = - assertThrows(IllegalStateException.class, () -> client.structured(MESSAGES, Map.of(), null, null)); - - assertTrue( - failure.getMessage().contains("consumed its whole script"), - "the over-consumption failure must explain the exhausted script but was: " + failure.getMessage()); - assertEquals(2, client.callCount(), "the rejected call still counts as a call"); - } - - @Test - void overconsumptionCanBeDisabledForEngineSelfTests() { - ScriptedNegotiationLlmClient client = - new ScriptedNegotiationLlmClient(List.of(new LlmScriptStep.Payload("{\"a\":1}")), false); - - client.structured(MESSAGES, Map.of(), null, null); - LLMResponse repeated = client.structured(MESSAGES, Map.of(), null, null); - - assertEquals("{\"a\":1}", repeated.content(), "the explicit escape hatch repeats the last step"); - assertEquals(2, client.callCount()); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java deleted file mode 100644 index 0a354de4..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TaskApiAssembler.java +++ /dev/null @@ -1,164 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; -import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.core.validation.ContentValidator; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; - -/** - * Real-builder assembly of the three closed-loop task APIs (Q21): the corpus harness drives exactly the production - * wiring the {@code A2ATClient} and {@code A2ATServer} facades drive internally, so a change of the production assembly - * cannot drift past the corpus. - * - *

              - *
            • {@code generateTaskPromptFromText} and {@code generateTaskPromptFromDataWithSchema} run through the - * {@link ClientPromptGenerationOrchestrator} built by - * {@link DefaultA2ATClientBuilder#buildPromptGenerationOrchestrator()} — the same build method the client facade - * constructor calls; - *
            • {@code validateTaskPromptAndDataFilling} runs through the {@link ContentValidator} built by - * {@link DefaultA2ATServerBuilder#buildTaskContentValidator()} — the same build method the server facade - * constructor calls. - *
            - * - *

            Both builders are driven exactly like the facades drive them: a minimal classpath-source {@code .env} (written - * once per language and retry limit into a temporary directory, following the facade test precedent) is loaded through - * {@link A2ATConfig#load(Path)} plus {@link A2ATConfig#resolvePromptResourceLocalRootDir(A2ATConfig, Path)}, and the - * {@link ScriptedNegotiationLlmClient} is injected through the builders' {@code llmClient(...)} seam — the same - * instance feeds the client-side and the server-side components, so the {@code llmCalls} expectation counts the whole - * closed loop. The facade constructors are locked to the {@code Path}-only signature by the API-surface guard tests, so - * the builders are the direct route to the identical assembly. - * - * @since 2026-08 - */ -final class TaskApiAssembler { - - private static final Map ENV_FILES = new ConcurrentHashMap<>(); - - private final ClientPromptGenerationOrchestrator promptGeneration; - - private final ContentValidator taskValidator; - - /** - * Assembles the task API wiring for one language. - * - * @param language language of the generated and validated task prompts, such as {@code zh-CN} - * @param maxAttempts retry limit of the LLM steps, mirroring the builder default of 3 - * @param llmClient scripted LLM client injected at the same seam the facade builders inject their real client - */ - TaskApiAssembler(String language, int maxAttempts, LLMClient llmClient) { - this(minimalEnvFor(language, maxAttempts), llmClient); - } - - /** - * Assembles the task API wiring around an explicit {@code .env} file — the seam of the live family, whose harness - * hands in the {@code LiveLlmEnvWriter} bridge (real test-endpoint values plus the explicit stability knobs and the - * pipeline retry limit) instead of the scripted minimal env. - * - * @param envPath the {@code .env} file the config is loaded from, carrying the language and retry limit - * @param llmClient LLM client injected at the same seam the facade builders inject their real client - */ - TaskApiAssembler(Path envPath, LLMClient llmClient) { - A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); - this.promptGeneration = DefaultA2ATClientBuilder.builder() - .config(config) - .envPath(envPath) - .llmClient(llmClient) - .buildPromptGenerationOrchestrator(); - this.taskValidator = DefaultA2ATServerBuilder.builder() - .config(config) - .envPath(envPath) - .llmClient(llmClient) - .buildTaskContentValidator(); - } - - /** - * Generates a task prompt with metadata from natural-language input through the real client orchestrator, the - * pipeline behind {@code A2ATClient.generateTaskPromptFromText}. - * - * @param text natural-language task input - * @param templateUri template URI identifying the target task template - * @return metadata content carrying the resolved template URI, rendered prompt text and Task-T extension URI - */ - MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) { - return promptGeneration.generateTaskPromptFromText(text, templateUri); - } - - /** - * Generates a task prompt with metadata from structured input and a data schema through the real client - * orchestrator, the pipeline behind {@code A2ATClient.generateTaskPromptFromDataWithSchema}. - * - * @param data structured task input as a string-to-object map - * @param schema data schema map describing the meaning of each input field - * @param templateUri template URI identifying the target task template - * @return metadata content carrying the resolved template URI, rendered prompt text and Task-T extension URI - */ - MetadataContent generateTaskPromptFromDataWithSchema( - Map data, Map schema, TemplateUri templateUri) { - return promptGeneration.generateTaskPromptFromDataWithSchema(data, schema, templateUri); - } - - /** - * Validates a task prompt and extracts its filled parameters through the real server-side task content validator, - * the pipeline behind {@code A2ATServer.validateTaskPromptAndDataFilling}. - * - *

            A schema slot the prompt misses surfaces as a null-valued entry of the returned parameter data — that set of - * null-valued keys is the missing-parameter set the negotiation loop then fills. - * - * @param prompt rendered task prompt text to validate - * @param schema caller-provided parameter JSON schema describing the parameters to extract - * @param templateUri template URI declaring the expected task template - * @return filled parameter data carrying the extracted parameters; null values mark missing parameters - */ - FilledParamData validateTaskPromptAndDataFilling( - String prompt, Map schema, TemplateUri templateUri) { - return taskValidator.validate(prompt, schema, templateUri); - } - - // ------------------------------------------------------------------ minimal facade env - - /** - * Writes (once per language and retry limit) the minimal classpath-source {@code .env} the facades would be handed, - * following the {@code A2ATClientTest} minimal-env precedent; the LLM entries are inert because the scripted client - * is injected at the builders' LLM seam. - * - * @param language language of the corpus case - * @param maxAttempts retry limit of the LLM steps from the case's LLM script - * @return path of the written {@code .env} file - */ - private static Path minimalEnvFor(String language, int maxAttempts) { - return ENV_FILES.computeIfAbsent(language + "/" + maxAttempts, key -> { - try { - Path envFile = Files.createTempDirectory("a2at-corpus-task-env").resolve("client.env"); - Files.writeString( - envFile, - ("A2AT_LANGUAGE=%s%n" - + "A2AT_PROMPT_SOURCE_TYPE=classpath%n" - + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=%n" - + "A2AT_LLM_PROVIDER=openai%n" - + "A2AT_LLM_MODEL=scripted-model%n" - + "A2AT_LLM_BASE_URL=https://llm.example.test/v1%n" - + "A2AT_LLM_API_KEY=corpus-scripted%n" - + "A2AT_LLM_MAX_ATTEMPTS=%d%n" - + "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory%n") - .formatted(language, maxAttempts), - StandardCharsets.UTF_8); - return envFile; - } catch (IOException exception) { - throw new UncheckedIOException( - "Failed to write the minimal corpus .env for language " + language, exception); - } - }); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java deleted file mode 100644 index 0b756b9f..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/TypedInputAssembler.java +++ /dev/null @@ -1,205 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import com.fasterxml.jackson.databind.JsonNode; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationAction; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; -import net.openan.a2at.sdk.negotiation.content.NegotiationType; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; -import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; -import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; -import org.jspecify.annotations.Nullable; - -/** - * Assembles the typed input data of the from-data family and the differential runs from the corpus {@code input.data} - * JSON node. - * - *

            The corpus carries the typed content in the same snake_case shape the LLM extraction produces - * (design document §8.2: the case author writes the typed data explicitly), so this assembler only converts that JSON - * onto the typed content records — it never invents or reconstructs content. Deliberate performative-content - * contradictions (an {@code Accept} conclusion fed to the reject API) pass through unvalidated so the production - * from-data validation surfaces them. - * - *

            A malformed {@code input.data} node is a corpus authoring defect, not a production behavior: the assembler fails - * with an {@link IllegalStateException} naming the offending field, which no corpus expectation can match by accident. - * - * @since 2026-08 - */ -final class TypedInputAssembler { - - private TypedInputAssembler() {} - - /** - * Assembles the typed API input of one from-data call. - * - * @param data typed content in the snake_case corpus shape - * @param context negotiation context of the case, or null for the null-context probes - * @param templateUri template URI addressed by the case, or null for the null-URI probes - * @param performative performative of the addressed generation method - * @param language language of the case - * @return a {@code NegotiationProposeData}, {@code NegotiationEndingData} or {@code NegotiationAbortData} - */ - static Object assemble( - JsonNode data, - @Nullable NegotiationContext context, - @Nullable TemplateUri templateUri, - NegotiationPerformative performative, - String language) { - Objects.requireNonNull(data, "data"); - NegotiationType type = resolveType(templateUri, performative, language); - return switch (performative) { - case PROPOSE -> new NegotiationProposeData(context, proposeContent(data, type)); - case ACCEPT, REJECT -> new NegotiationEndingData(context, endingContent(data, type)); - case ABORT -> new NegotiationAbortData( - context, new NegotiationAbortContent(requiredText(data, "termination_reason"))); - }; - } - - // ------------------------------------------------------------------ content mapping - - private static NegotiationProposeContent proposeContent(JsonNode data, @Nullable NegotiationType type) { - if (type == null) { - throw authoring("the abort performative carries no typed propose content"); - } - return switch (type) { - case INFORMATION -> new InformationProposeContent( - requiredItems(data, "items"), optionalText(data, "relationship")); - case TARGET -> new TargetProposeContent( - requiredText(data, "target_negotiation_description"), - optionalItems(data, "intent_understanding"), - optionalItems(data, "alignment_and_clarification"), - optionalItems(data, "request_for_clarification"), - optionalText(data, "target_confirm_request")); - case FEASIBILITY -> new FeasibilityProposeContent( - requiredText(data, "feasibility_negotiation_description"), - action(data), - optionalItems(data, "contents_to_evaluate"), - optionalItems(data, "infeasibility_details_and_proposal"), - optionalText(data, "feasibility_confirm_request")); - }; - } - - private static NegotiationEndingContent endingContent(JsonNode data, @Nullable NegotiationType type) { - if (type == null) { - throw authoring("the abort performative carries no typed ending content"); - } - NegotiationConclusion conclusion = conclusion(data); - return switch (type) { - case INFORMATION -> new InformationEndingContent(conclusion, requiredItems(data, "items")); - case TARGET -> new TargetEndingContent( - conclusion, optionalText(data, "confirmed_intent"), optionalText(data, "failure_reason")); - case FEASIBILITY -> new FeasibilityEndingContent( - conclusion, requiredText(data, "feasibility_summary")); - }; - } - - private static NegotiationType resolveType( - @Nullable TemplateUri templateUri, NegotiationPerformative performative, String language) { - if (templateUri == null) { - throw authoring("the template URI is missing but the typed input data needs the negotiation type"); - } - return NegotiationReference.fromTemplateUri(templateUri, performative, language) - .orElseThrow(() -> new IllegalArgumentException( - "Template URI does not address a negotiation template of the expected performative " - + performative + " (" + NegotiationReference.uriSegmentOf(performative) + "): " - + templateUri.uri() + ".")) - .type(); - } - - // ------------------------------------------------------------------ field readers - - private static NegotiationConclusion conclusion(JsonNode data) { - JsonNode node = data.path("conclusion"); - if (!node.isTextual()) { - throw authoring("conclusion must be the Accept or Reject literal"); - } - for (NegotiationConclusion candidate : NegotiationConclusion.values()) { - if (candidate.literal().equals(node.asText())) { - return candidate; - } - } - throw authoring("conclusion must be the Accept or Reject literal but was '" + node.asText() + "'"); - } - - private static NegotiationAction action(JsonNode data) { - JsonNode node = data.path("action"); - if (!node.isTextual()) { - throw authoring("action must be one of the two feasibility action names"); - } - return NegotiationAction.valueOf(node.asText()); - } - - private static String requiredText(JsonNode data, String field) { - JsonNode node = data.path(field); - if (!node.isTextual() || node.asText().isBlank()) { - throw authoring(field + " must be a non-blank string"); - } - return node.asText(); - } - - private static @Nullable String optionalText(JsonNode data, String field) { - JsonNode node = data.path(field); - if (node.isMissingNode() || node.isNull()) { - return null; - } - if (!node.isTextual()) { - throw authoring(field + " must be a string or null"); - } - return node.asText(); - } - - private static List requiredItems(JsonNode data, String field) { - JsonNode node = data.path(field); - if (node.isMissingNode() || node.isNull()) { - throw authoring(field + " must be an array of items"); - } - return items(node, field); - } - - private static @Nullable List optionalItems(JsonNode data, String field) { - JsonNode node = data.path(field); - if (node.isMissingNode() || node.isNull()) { - return null; - } - return items(node, field); - } - - private static List items(JsonNode node, String field) { - if (!node.isArray()) { - throw authoring(field + " must be an array of items"); - } - List items = new ArrayList<>(); - for (JsonNode entry : node) { - JsonNode name = entry.path("name"); - if (!name.isTextual() || name.asText().isBlank()) { - throw authoring(field + " contained an item without a name"); - } - JsonNode value = entry.path("value"); - if (!value.isMissingNode() && !value.isNull() && !value.isTextual()) { - throw authoring(field + " contained an item whose value is not a string or null"); - } - items.add(new NegotiationItem(name.asText(), value.isTextual() ? value.asText() : null)); - } - return List.copyOf(items); - } - - private static IllegalStateException authoring(String message) { - return new IllegalStateException("input.data: " + message); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ValidateCorpusSuiteTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ValidateCorpusSuiteTest.java deleted file mode 100644 index e7a9cc19..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/ValidateCorpusSuiteTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package net.openan.a2at.sdk.corpus; - -import java.util.stream.Stream; -import org.junit.jupiter.api.DynamicTest; -import org.junit.jupiter.api.TestFactory; - -/** - * Data-driven suite of the validate family corpus: every case under {@code negotiation-cases/validate/} becomes one - * dynamic test executed by the {@link CaseEngine} against the production negotiation content wiring. - * - *

            The seed batch carries {@code validate/happy.json} (VAL-HAPPY: propose validation with the merged-params expectation - * carrying the context keys). The later batches (VAL-PROG, VAL-RULE, VAL-SEM, VAL-RETRY, VAL-MERGE, VAL-MAP) land as - * further files of the same directory without any Java change. - * - *

            Run a subset with {@code -Dcase.filter=VAL-RULE-*} (glob against the case id, design document Q1). - * - * @since 2026-08 - */ -class ValidateCorpusSuiteTest { - - /** Executes every expanded validate case of the corpus. */ - @TestFactory - Stream validateCorpusCases() { - return CorpusSuites.caseTests("validate"); - } -} diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/CorpusWorkFlowSuite.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/CorpusWorkFlowSuite.java new file mode 100644 index 00000000..f6b58e29 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/CorpusWorkFlowSuite.java @@ -0,0 +1,139 @@ +package net.openan.a2at.sdk.corpus.engine; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.TestInstance; +import net.openan.a2at.sdk.corpus.engine.discover.ScenarioScanner; +import net.openan.a2at.sdk.corpus.engine.engine.WorkflowEngine; +import net.openan.a2at.sdk.corpus.engine.loader.CaseFileLoader; +import net.openan.a2at.sdk.corpus.engine.loader.InputCase; +import net.openan.a2at.sdk.corpus.engine.registry.ApiRegistry; +import net.openan.a2at.sdk.corpus.engine.registry.ClientApis; +import net.openan.a2at.sdk.corpus.engine.registry.SdkRuntimeAssembler; +import net.openan.a2at.sdk.corpus.engine.registry.ServerApis; +import net.openan.a2at.sdk.corpus.engine.report.ResultAggregator; +import net.openan.a2at.sdk.corpus.engine.report.TranscriptWriter; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Base class of every {@code -T} workflow suite. A concrete suite declares only its extension folder, its workflow + * type (from_text / from_data) and the consumed case file; scenario discovery, case loading, execution, expectation + * comparison and transcript/summary writing are all inherited. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public abstract class CorpusWorkFlowSuite { + + public static final String INPUT_CASE_FROM_TEXT = "input_case_from_text.json"; + + public static final String INPUT_CASE_FROM_DATA = "input_case_from_data.json"; + + public static final String FLOW_FROM_TEXT = "from_text"; + + public static final String FLOW_FROM_DATA = "from_data"; + + public static final String API_GENERATE_TASK_FROM_TEXT = "generateTaskPromptFromText"; + + public static final String API_GENERATE_TASK_FROM_DATA = "generateTaskPromptFromDataWithSchema"; + + public static final String API_VALIDATE_TASK = "validateTaskPromptAndDataFilling"; + + private static volatile SdkRuntimeAssembler.Runtime runtime; + + private final List results = new ArrayList<>(); + + private final Map scenarioDirs = new LinkedHashMap<>(); + + /** Extension resource folder under the corpus package, e.g. {@code task}. */ + protected abstract String extensionFolder(); + + /** Consumed case file, e.g. {@code input_case_from_text.json}. */ + protected abstract String inputFileName(); + + /** Flow type used for the transcript and summary file names: {@code from_text} or {@code from_data}. */ + protected abstract String flowType(); + + @TestFactory + Stream corpusCases() { + SdkRuntimeAssembler.Runtime runtime = runtime(); + ApiRegistry registry = taskRegistry(runtime); + WorkflowEngine engine = new WorkflowEngine(registry, runtime.recorder()); + + List scenarios = ScenarioScanner.filterScenarios( + ScenarioScanner.discover(extensionFolder(), inputFileName()), + System.getProperty("corpus.scenario")); + String caseFilter = System.getProperty("case.filter"); + + List tests = new ArrayList<>(); + for (ScenarioScanner.Scenario scenario : scenarios) { + List cases = + new CaseFileLoader().load(scenario.inputFile(inputFileName()), registry.apiNames()); + scenarioDirs.put(scenario.name(), scenario.sourceDir()); + for (InputCase inputCase : cases) { + if (!ScenarioScanner.matchesCaseFilter(caseFilter, inputCase.id())) { + continue; + } + tests.add(DynamicTest.dynamicTest(scenario.name() + "/" + inputCase.id(), () -> { + WorkflowEngine.CaseResult result = engine.run(scenario.name(), inputCase); + results.add(result); + ResultAggregator.printCaseLine(result); + assertEquals( + "success", + result.verdict(), + () -> result.failReason().isEmpty() + ? "case verdict=" + result.verdict() + ", see the output JSON" + : result.failReason()); + })); + } + } + return tests.stream(); + } + + @AfterAll + void writeTranscripts() { + for (Map.Entry> entry : groupByScenario(results).entrySet()) { + Path resultFile = TranscriptWriter.writeScenarioOutput( + flowType(), entry.getKey(), scenarioDirs.get(entry.getKey()), entry.getValue()); + System.out.println("Output: " + resultFile); + } + Path summaryFile = ResultAggregator.writeSummary(flowType(), results); + System.out.println("Summary: " + summaryFile); + } + + private static synchronized SdkRuntimeAssembler.Runtime runtime() { + if (runtime == null) { + runtime = SdkRuntimeAssembler.taskRuntime(); + } + return runtime; + } + + private static Map> groupByScenario( + List allResults) { + Map> grouped = new LinkedHashMap<>(); + for (WorkflowEngine.CaseResult result : allResults) { + grouped.computeIfAbsent(result.scenario(), ignored -> new ArrayList<>()).add(result); + } + return grouped; + } + + /** Registers the Task-T phase-1 registry entries. */ + public static ApiRegistry taskRegistry(SdkRuntimeAssembler.Runtime runtime) { + ApiRegistry registry = new ApiRegistry(); + ClientApis.registerTaskApis(registry, runtime.clientGeneration()); + ServerApis.registerTaskApis(registry, runtime.taskValidator()); + return registry; + } + + /** Task-T phase-1 API names, used for load-time membership checks without touching the LLM runtime. */ + public static Set taskApiNames() { + return Set.of(API_GENERATE_TASK_FROM_TEXT, API_GENERATE_TASK_FROM_DATA, API_VALIDATE_TASK); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/assertion/ExpectationComparator.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/assertion/ExpectationComparator.java new file mode 100644 index 00000000..c1d87026 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/assertion/ExpectationComparator.java @@ -0,0 +1,130 @@ +package net.openan.a2at.sdk.corpus.engine.assertion; + +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import net.openan.a2at.sdk.corpus.engine.engine.StepOutcome; +import net.openan.a2at.sdk.corpus.engine.loader.ExpectedStep; + +/** + * Compares one step outcome against its expectation. + * + *

              + *
            • {@code result} matches exactly ({@code success} / {@code error});
            • + *
            • on success, {@code data} is a subset assertion: every listed key must equal; the asserted map is the payload + * root, or its nested {@code data} map when the payload carries one (FilledParamData). {@code dataExact} tightens the + * match to exactly the listed keys;
            • + *
            • on error, {@code error.errCode} matches the SDK catalog code exactly, {@code error.errMessage} matches exactly + * when present (otherwise any non-empty message passes) and {@code error.errMessageContains} is a substring test.
            • + *
            + */ +public final class ExpectationComparator { + + /** Comparison verdict: failed flag plus one-sentence pointer-style message. */ + public record StepAssertion(boolean passed, String failMessage) { + + static StepAssertion pass() { + return new StepAssertion(true, ""); + } + + static StepAssertion fail(String message) { + return new StepAssertion(false, message); + } + } + + private ExpectationComparator() {} + + public static StepAssertion compare(ExpectedStep expected, StepOutcome outcome) { + if ("skipped".equals(outcome.actualResult())) { + return StepAssertion.fail("step" + outcome.stepNo() + " was skipped because a previous step assertion failed"); + } + if ("success".equals(expected.result())) { + return compareSuccess(expected, outcome); + } + return compareError(expected, outcome); + } + + private static StepAssertion compareSuccess(ExpectedStep expected, StepOutcome outcome) { + if (!"success".equals(outcome.actualResult())) { + String actual = outcome.crashed() + ? "engine crash(" + exceptionName(outcome) + ")" + : "error(" + (outcome.errCode() == null ? "no error code" : outcome.errCode()) + ")"; + return StepAssertion.fail("step" + outcome.stepNo() + " expected success, actual " + actual); + } + Map target = assertionTarget(outcome.payload()); + for (Map.Entry entry : expected.data().entrySet()) { + String key = entry.getKey(); + Object expectedValue = entry.getValue(); + Object actualValue = target.get(key); + if (!target.containsKey(key) || !Objects.equals(expectedValue, actualValue)) { + return StepAssertion.fail("step" + outcome.stepNo() + ".data." + key + " expected " + + expectedValue + " actual " + actualValue); + } + } + if (expected.dataExact() && !target.keySet().equals(expected.data().keySet())) { + Set onlyActual = new LinkedHashSet<>(target.keySet()); + onlyActual.removeAll(expected.data().keySet()); + Set onlyExpected = new LinkedHashSet<>(expected.data().keySet()); + onlyExpected.removeAll(target.keySet()); + return StepAssertion.fail("step" + outcome.stepNo() + " data exact assertion failed (dataExact): extra=" + + onlyActual + " missing=" + onlyExpected); + } + return StepAssertion.pass(); + } + + private static StepAssertion compareError(ExpectedStep expected, StepOutcome outcome) { + if (!"error".equals(outcome.actualResult())) { + return StepAssertion.fail("step" + outcome.stepNo() + " expected error" + + (expected.errCode().isBlank() ? "" : "(" + expected.errCode() + ")") + ", actual success"); + } + if (outcome.crashed()) { + return StepAssertion.fail("step" + outcome.stepNo() + " expected error(" + + expected.errCode() + "), actual engine crash(" + exceptionName(outcome) + ")"); + } + if (!expected.errCode().isBlank() && !expected.errCode().equals(outcome.errCode())) { + return StepAssertion.fail("step" + outcome.stepNo() + " errCode expected " + + expected.errCode() + " actual " + outcome.errCode()); + } + if (!expected.errMessage().isBlank() && !expected.errMessage().equals(outcome.errMessage())) { + return StepAssertion.fail("step" + outcome.stepNo() + " errMessage expected [" + + expected.errMessage() + "] actual [" + outcome.errMessage() + "]"); + } + if (isBlank(outcome.errMessage())) { + return StepAssertion.fail("step" + outcome.stepNo() + " expected a non-empty error message, actual is empty"); + } + if (!expected.errMessageContains().isBlank() + && (outcome.errMessage() == null + || !outcome.errMessage().contains(expected.errMessageContains()))) { + return StepAssertion.fail("step" + outcome.stepNo() + " errMessage does not contain [" + + expected.errMessageContains() + "], actual [" + outcome.errMessage() + "]"); + } + return StepAssertion.pass(); + } + + /** + * Returns the map the data subset assertion runs against: the nested {@code data} map of a FilledParamData-like + * payload, or the payload root. + */ + private static Map assertionTarget(Map payload) { + Object data = payload.get("data"); + if (data instanceof Map) { + @SuppressWarnings("unchecked") + Map nested = (Map) data; + return nested; + } + return payload; + } + + private static String exceptionName(StepOutcome outcome) { + if (outcome.errorResponse() == null) { + return "unknown error"; + } + Object name = outcome.errorResponse().get("exception"); + return name == null ? "unknown error" : String.valueOf(name); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/config/CorpusEnvConfig.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/config/CorpusEnvConfig.java new file mode 100644 index 00000000..48291c32 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/config/CorpusEnvConfig.java @@ -0,0 +1,296 @@ +package net.openan.a2at.sdk.corpus.engine.config; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Unified LLM configuration for the accuracy verification corpus. + * + *

            Configuration keys follow the project-root {@code env.example} naming (the {@code A2AT_LLM_*} family), so the + * corpus {@code env.example} is a tuned subset of the root template and one set of names serves both. One shared + * configuration serves every -T extension suite: values are resolved in the order system properties + * ({@code a2at.llm.*}) > the module-root {@code .env} file ({@code A2AT_LLM_*}) > process environment + * variables ({@code A2AT_LLM_*}). + * + *

            This module must run against a real LLM; a missing or invalid configuration therefore fails fast with an + * actionable error instead of producing silently wrong results. CI excludes this module entirely, so the fail-fast + * never disturbs the pipeline. + */ +public final class CorpusEnvConfig { + + /** Environment-file keys (also accepted as process environment variables); names mirror the root {@code env.example}. */ + static final String ENV_PROVIDER = "A2AT_LLM_PROVIDER"; + + static final String ENV_BASE_URL = "A2AT_LLM_BASE_URL"; + + static final String ENV_API_KEY = "A2AT_LLM_API_KEY"; + + static final String ENV_MODEL = "A2AT_LLM_MODEL"; + + static final String ENV_TEMPERATURE = "A2AT_LLM_TEMPERATURE"; + + static final String ENV_TIMEOUT_SECONDS = "A2AT_LLM_TIMEOUT_SECONDS"; + + static final String ENV_MAX_ATTEMPTS = "A2AT_LLM_MAX_ATTEMPTS"; + + static final String ENV_MAX_TOKENS = "A2AT_LLM_MAX_TOKENS"; + + /** System-property keys, taking precedence over the {@code .env} file and environment variables. */ + private static final String PROP_PROVIDER = "a2at.llm.provider"; + + private static final String PROP_BASE_URL = "a2at.llm.base.url"; + + private static final String PROP_API_KEY = "a2at.llm.api.key"; + + private static final String PROP_MODEL = "a2at.llm.model"; + + private static final String PROP_TEMPERATURE = "a2at.llm.temperature"; + + private static final String PROP_TIMEOUT_SECONDS = "a2at.llm.timeout.seconds"; + + private static final String PROP_MAX_ATTEMPTS = "a2at.llm.max.attempts"; + + private static final String PROP_MAX_TOKENS = "a2at.llm.max.tokens"; + + private static final String DEFAULT_PROVIDER = "openai"; + + private static final int DEFAULT_MAX_ATTEMPTS = 3; + + private static CorpusEnvConfig instance; + + private final String provider; + + private final String baseUrl; + + private final String apiKey; + + private final String model; + + private final Double temperature; + + private final Double timeoutSeconds; + + private final int maxAttempts; + + private final Integer maxTokens; + + private CorpusEnvConfig( + String provider, + String baseUrl, + String apiKey, + String model, + Double temperature, + Double timeoutSeconds, + int maxAttempts, + Integer maxTokens) { + this.provider = provider; + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.model = model; + this.temperature = temperature; + this.timeoutSeconds = timeoutSeconds; + this.maxAttempts = maxAttempts; + this.maxTokens = maxTokens; + } + + public static synchronized CorpusEnvConfig load() { + if (instance == null) { + instance = create(); + } + return instance; + } + + private static CorpusEnvConfig create() { + Map envFileValues = readEnvFile(); + String provider = resolve(ENV_PROVIDER, PROP_PROVIDER, envFileValues); + if (isBlank(provider)) { + provider = DEFAULT_PROVIDER; + } + String baseUrl = resolve(ENV_BASE_URL, PROP_BASE_URL, envFileValues); + String apiKey = resolve(ENV_API_KEY, PROP_API_KEY, envFileValues); + String model = resolve(ENV_MODEL, PROP_MODEL, envFileValues); + + StringBuilder problems = new StringBuilder(); + if (!DEFAULT_PROVIDER.equals(provider.trim())) { + problems.append("\n- ") + .append(ENV_PROVIDER) + .append(" must be \"") + .append(DEFAULT_PROVIDER) + .append("\" (currently \"") + .append(provider.trim()) + .append("\")"); + } + if (isBlank(baseUrl)) { + problems.append("\n- ").append(ENV_BASE_URL).append(" (or -D").append(PROP_BASE_URL) + .append(") is not configured"); + } + if (isBlank(apiKey)) { + problems.append("\n- ").append(ENV_API_KEY).append(" (or -D").append(PROP_API_KEY) + .append(") is not configured"); + } + if (isBlank(model)) { + problems.append("\n- ").append(ENV_MODEL).append(" (or -D").append(PROP_MODEL) + .append(") is not configured"); + } + if (problems.length() > 0) { + throw new IllegalStateException( + "a2a-t-corpus requires a real LLM configuration; invalid or missing values:" + + problems + + "\nCopy a2a-t-corpus/env.example to a2a-t-corpus/.env and fill in the required" + + " entries (or override them with system properties)." + + "\nCI pipelines should exclude this module with -pl '!a2a-t-corpus'."); + } + + return new CorpusEnvConfig( + provider.trim(), + baseUrl.trim(), + apiKey.trim(), + model.trim(), + parseOptionalDouble(ENV_TEMPERATURE, PROP_TEMPERATURE, envFileValues), + parseOptionalDouble(ENV_TIMEOUT_SECONDS, PROP_TIMEOUT_SECONDS, envFileValues), + parseMaxAttempts(ENV_MAX_ATTEMPTS, PROP_MAX_ATTEMPTS, envFileValues), + parseOptionalInteger(ENV_MAX_TOKENS, PROP_MAX_TOKENS, envFileValues)); + } + + private static Map readEnvFile() { + Path envFile = envFile().toAbsolutePath().normalize(); + Map values = new LinkedHashMap<>(); + if (!Files.isRegularFile(envFile)) { + return values; + } + try { + for (String rawLine : Files.readAllLines(envFile, StandardCharsets.UTF_8)) { + String line = rawLine.strip(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + int equals = line.indexOf('='); + if (equals <= 0) { + continue; + } + String key = line.substring(0, equals).strip(); + String value = stripQuotes(line.substring(equals + 1).strip()); + values.put(key, value); + } + } catch (IOException error) { + throw new IllegalStateException("Failed to read " + envFile + ": " + error.getMessage(), error); + } + return values; + } + + private static String stripQuotes(String value) { + if (value.length() >= 2) { + char first = value.charAt(0); + char last = value.charAt(value.length() - 1); + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + return value.substring(1, value.length() - 1); + } + } + return value; + } + + private static String resolve(String envKey, String propKey, Map envFileValues) { + String fromProperty = System.getProperty(propKey); + if (fromProperty != null && !fromProperty.isBlank()) { + return fromProperty.strip(); + } + String fromFile = envFileValues.get(envKey); + if (fromFile != null && !fromFile.isBlank()) { + return fromFile; + } + String fromEnvironment = System.getenv(envKey); + return fromEnvironment == null ? "" : fromEnvironment; + } + + private static Double parseOptionalDouble(String envKey, String propKey, Map envFileValues) { + String raw = resolve(envKey, propKey, envFileValues); + if (isBlank(raw)) { + return null; + } + try { + return Double.parseDouble(raw.trim()); + } catch (NumberFormatException error) { + throw new IllegalStateException(envKey + " must be numeric: '" + raw.trim() + "'", error); + } + } + + private static Integer parseOptionalInteger(String envKey, String propKey, Map envFileValues) { + String raw = resolve(envKey, propKey, envFileValues); + if (isBlank(raw)) { + return null; + } + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException error) { + throw new IllegalStateException(envKey + " must be an integer: '" + raw.trim() + "'", error); + } + } + + private static int parseMaxAttempts(String envKey, String propKey, Map envFileValues) { + String raw = resolve(envKey, propKey, envFileValues); + if (isBlank(raw)) { + return DEFAULT_MAX_ATTEMPTS; + } + int parsed; + try { + parsed = Integer.parseInt(raw.trim()); + } catch (NumberFormatException error) { + throw new IllegalStateException(envKey + " must be an integer between 1 and 10: '" + raw.trim() + "'", error); + } + if (parsed < 1 || parsed > 10) { + throw new IllegalStateException(envKey + " must be between 1 and 10: " + parsed); + } + return parsed; + } + + /** + * Returns the {@code .env} path as seen by the SDK builders, regardless of whether the file exists. + */ + public Path envPath() { + return envFile().toAbsolutePath().normalize(); + } + + private static Path envFile() { + return Path.of(System.getProperty("user.dir", "."), ".env"); + } + + public String provider() { + return provider; + } + + public String baseUrl() { + return baseUrl; + } + + public String apiKey() { + return apiKey; + } + + public String model() { + return model; + } + + public Double temperature() { + return temperature; + } + + public Double timeoutSeconds() { + return timeoutSeconds; + } + + public int maxAttempts() { + return maxAttempts; + } + + public Integer maxTokens() { + return maxTokens; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/discover/ScenarioScanner.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/discover/ScenarioScanner.java new file mode 100644 index 00000000..c5001e54 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/discover/ScenarioScanner.java @@ -0,0 +1,136 @@ +package net.openan.a2at.sdk.corpus.engine.discover; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.List; + +/** + * Discovers corpus scenarios by directory convention: a scenario is any directory under the extension's + * {@code resources/} folder that carries the requested case file ({@code input_case_from_text.json} or + * {@code input_case_from_data.json}). New scenarios therefore require zero Java changes. + * + *

            Discovery prefers the source tree ({@code src/test/java/...//resources/}), which is where Maven + * runs surefire ({@code user.dir} is the module base directory); it falls back to the test classpath for IDE runs. + */ +public final class ScenarioScanner { + + /** One discovered scenario: directory name plus writable source directory carrying the case files. */ + public record Scenario(String name, Path sourceDir) { + + public Path inputFile(String flowFileName) { + return sourceDir.resolve(flowFileName); + } + } + + private static final String RESOURCE_PATH_PREFIX = + "net/openan/a2at/sdk/corpus/"; + + private ScenarioScanner() {} + + public static List discover(String extensionFolder, String flowFileName) { + List scenarios = new ArrayList<>(); + Path sourceTree = Path.of( + System.getProperty("user.dir", "."), + "src", + "test", + "java", + "net", + "openan", + "a2at", + "sdk", + "corpus", + extensionFolder, + "resources") + .toAbsolutePath() + .normalize(); + if (Files.isDirectory(sourceTree)) { + collectFromDirectory(sourceTree, scenarios, flowFileName); + } + if (scenarios.isEmpty()) { + collectFromClasspath(extensionFolder, scenarios, flowFileName); + } + scenarios.sort(Comparator.comparing(Scenario::name)); + if (scenarios.isEmpty()) { + throw new IllegalStateException( + "No corpus scenario directories found: expected " + extensionFolder + "/resources//" + flowFileName + + " (neither the source tree " + sourceTree + " nor the test classpath matches)"); + } + return scenarios; + } + + public static List filterScenarios(List scenarios, String filter) { + if (filter == null || filter.isBlank()) { + return scenarios; + } + List filtered = new ArrayList<>(); + for (String pattern : filter.split(",")) { + PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern.strip()); + for (Scenario scenario : scenarios) { + if (matcher.matches(Path.of(scenario.name())) && !filtered.contains(scenario)) { + filtered.add(scenario); + } + } + } + return filtered; + } + + public static boolean matchesCaseFilter(String caseFilter, String caseId) { + if (caseFilter == null || caseFilter.isBlank()) { + return true; + } + PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + caseFilter.strip()); + return matcher.matches(Path.of(caseId)); + } + + private static void collectFromDirectory(Path sourceTree, List scenarios, String flowFileName) { + try (var entries = Files.list(sourceTree)) { + List directories = entries.filter(Files::isDirectory).toList(); + for (Path directory : directories) { + if (Files.isRegularFile(directory.resolve(flowFileName))) { + scenarios.add(new Scenario(directory.getFileName().toString(), directory)); + } + } + } catch (IOException error) { + throw new IllegalStateException("Failed to scan the scenario directory: " + sourceTree, error); + } + } + + private static void collectFromClasspath( + String extensionFolder, List scenarios, String flowFileName) { + String resourceRoot = RESOURCE_PATH_PREFIX + extensionFolder + "/resources"; + try { + ClassLoader loader = Thread.currentThread().getContextClassLoader(); + Enumeration roots = loader.getResources(resourceRoot); + while (roots.hasMoreElements()) { + URL root = roots.nextElement(); + if (!"file".equals(root.getProtocol())) { + continue; + } + try { + File rootFile = java.nio.file.Paths.get(root.toURI()).toFile(); + File[] children = rootFile.listFiles(File::isDirectory); + if (children == null) { + continue; + } + for (File child : children) { + if (new File(child, flowFileName).isFile()) { + scenarios.add(new Scenario(child.getName(), child.toPath().toAbsolutePath())); + } + } + } catch (Exception ignored) { + // unreadable root entry; ignore and continue scanning other roots + } + } + } catch (IOException error) { + throw new IllegalStateException("Failed to scan the test classpath for scenario directories: " + resourceRoot, error); + } + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/FromStepResolver.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/FromStepResolver.java new file mode 100644 index 00000000..b3d4cd1c --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/FromStepResolver.java @@ -0,0 +1,98 @@ +package net.openan.a2at.sdk.corpus.engine.engine; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Resolves {@code {"$fromStep": N, "$field": "a.b"}} references inside step arguments before the step runs. + * + *

            {@code $fromStep} is the 1-based index of an earlier step whose serialized response payload is navigated with the + * dot-separated {@code $field} path. Literal values always take precedence; a reference may appear at any nesting + * depth of the arguments map. + */ +public final class FromStepResolver { + + private FromStepResolver() {} + + /** + * Resolves every {@code $fromStep} reference in the arguments of step {@code stepNumber}. + * + * @param args step arguments, possibly carrying references + * @param stepNumber 1-based number of the step being resolved (references must target earlier steps) + * @param priorPayloads serialized response payloads of the preceding steps, aligned by step index + * @return a deep copy of the arguments with all references replaced + */ + public static Map resolve( + Map args, int stepNumber, List> priorPayloads) { + return (Map) resolveValue(args, stepNumber, priorPayloads); + } + + private static Object resolveValue( + Object value, int stepNumber, List> priorPayloads) { + if (value instanceof Map rawMap) { + Map map = rawMap; + if (map.containsKey("$fromStep") || map.containsKey("$field")) { + return resolveReference(map, stepNumber, priorPayloads); + } + Map resolved = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + resolved.put( + String.valueOf(entry.getKey()), + resolveValue(entry.getValue(), stepNumber, priorPayloads)); + } + return resolved; + } + if (value instanceof List list) { + List resolved = new ArrayList<>(); + for (Object entry : list) { + resolved.add(resolveValue(entry, stepNumber, priorPayloads)); + } + return resolved; + } + return value; + } + + private static Object resolveReference( + Map reference, int stepNumber, List> priorPayloads) { + if (reference.size() != 2 || !reference.containsKey("$fromStep") || !reference.containsKey("$field")) { + throw new IllegalArgumentException( + "step" + stepNumber + " $fromStep reference must contain exactly the two keys $fromStep and $field: " + reference); + } + Object rawStep = reference.get("$fromStep"); + Object rawField = reference.get("$field"); + if (!(rawStep instanceof Number) || !(rawField instanceof String field) || field.isBlank()) { + throw new IllegalArgumentException("step" + stepNumber + + " invalid $fromStep reference ($fromStep must be a positive integer, $field a non-blank string): " + reference); + } + int fromStep = ((Number) rawStep).intValue(); + if (fromStep < 1 || fromStep >= stepNumber || fromStep - 1 >= priorPayloads.size()) { + throw new IllegalArgumentException("step" + stepNumber + " $fromStep=" + fromStep + + " must reference an earlier, already-executed step (1.." + (stepNumber - 1) + ")"); + } + Map payload = priorPayloads.get(fromStep - 1); + if (payload == null) { + throw new IllegalArgumentException("step" + stepNumber + " $fromStep=" + fromStep + + " references a step without a success payload (it failed or was skipped)"); + } + return navigate(payload, field, fromStep, stepNumber); + } + + private static Object navigate( + Map payload, String path, int fromStep, int stepNumber) { + Object current = payload; + for (String segment : path.split("\\.")) { + if (!(current instanceof Map map) || !map.containsKey(segment)) { + throw new IllegalArgumentException("step" + stepNumber + " reference $fromStep=" + fromStep + + " $field=" + path + " failed to resolve: field " + segment + " does not exist in the referenced step payload"); + } + current = map.get(segment); + } + if (current == null) { + throw new IllegalArgumentException("step" + stepNumber + " reference $fromStep=" + fromStep + + " $field=" + path + " resolved to null, which cannot be used as a step argument"); + } + return current; + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/StepOutcome.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/StepOutcome.java new file mode 100644 index 00000000..fe27c5db --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/StepOutcome.java @@ -0,0 +1,62 @@ +package net.openan.a2at.sdk.corpus.engine.engine; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.corpus.engine.llm.RecordingMeteredLlmClient; + +/** + * Result of executing one input step. + * + * @param stepNo 1-based step number + * @param api executed API method name + * @param actualResult {@code success} / {@code error} / {@code skipped} + * @param request resolved request arguments actually passed to the SDK + * @param payload serialized response payload on success + * @param errorResponse serialized exception dump on error + * @param errCode machine-readable error code; {@code null} when the failure is not an SDK coded error + * @param errMessage failure message + * @param durationMs step wall-clock duration + * @param llmCalls LLM invocations recorded during this step + * @param crashed true when the step failed with a non-coded error (engine-level crash) + */ +public record StepOutcome( + int stepNo, + String api, + String actualResult, + Map request, + Map payload, + Map errorResponse, + String errCode, + String errMessage, + long durationMs, + List llmCalls, + boolean crashed) { + + public static StepOutcome success( + int stepNo, + String api, + Map request, + Map payload, + long durationMs, + List llmCalls) { + return new StepOutcome(stepNo, api, "success", request, payload, null, null, null, durationMs, llmCalls, false); + } + + public static StepOutcome error( + int stepNo, + String api, + Map request, + Map errorResponse, + String errCode, + String errMessage, + boolean crashed, + long durationMs, + List llmCalls) { + return new StepOutcome( + stepNo, api, "error", request, null, errorResponse, errCode, errMessage, durationMs, llmCalls, crashed); + } + + public static StepOutcome skipped(int stepNo, String api, Map request, long durationMs) { + return new StepOutcome(stepNo, api, "skipped", request, null, null, null, null, durationMs, List.of(), false); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/WorkflowEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/WorkflowEngine.java new file mode 100644 index 00000000..781e7a7f --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/WorkflowEngine.java @@ -0,0 +1,216 @@ +package net.openan.a2at.sdk.corpus.engine.engine; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.corpus.engine.assertion.ExpectationComparator; +import net.openan.a2at.sdk.corpus.engine.llm.RecordingMeteredLlmClient; +import net.openan.a2at.sdk.corpus.engine.loader.ExpectedStep; +import net.openan.a2at.sdk.corpus.engine.loader.InputCase; +import net.openan.a2at.sdk.corpus.engine.loader.InputStep; +import net.openan.a2at.sdk.corpus.engine.registry.ApiRegistry; +import net.openan.a2at.sdk.corpus.engine.util.ThrowableSerializer; + +/** + * Case engine: dispatches the ordered steps of one case to the registered SDK APIs, records the full transcript and + * compares every step against its expectation. Cases are fully isolated: one failing case never aborts the run. + */ +public final class WorkflowEngine { + + private final ApiRegistry registry; + + private final RecordingMeteredLlmClient recorder; + + public WorkflowEngine(ApiRegistry registry, RecordingMeteredLlmClient recorder) { + this.registry = registry; + this.recorder = recorder; + } + + /** Runs one case and returns its transcript; this method never throws for case-level failures. */ + public CaseResult run(String scenarioName, InputCase inputCase) { + recorder.drain(); + + List> priorPayloads = new ArrayList<>(); + List outcomes = new ArrayList<>(); + String failReason = ""; + boolean priorAssertionFailed = false; + boolean crashed = false; + + for (int i = 0; i < inputCase.input().size(); i++) { + InputStep step = inputCase.input().get(i); + ExpectedStep expected = inputCase.expected().get(i); + StepOutcome outcome; + + if (priorAssertionFailed && !"error".equals(expected.result())) { + outcome = StepOutcome.skipped(i + 1, step.api(), softResolve(step, i + 1, priorPayloads), 0L); + } else { + outcome = executeStep(i + 1, step, priorPayloads); + ExpectationComparator.StepAssertion assertion = + ExpectationComparator.compare(expected, outcome); + if (!assertion.passed()) { + if (failReason.isEmpty()) { + failReason = assertion.failMessage(); + } + priorAssertionFailed = true; + } + if (outcome.crashed()) { + crashed = true; + } + } + priorPayloads.add(outcome.payload()); + outcomes.add(outcome); + } + + String verdict = "success"; + if (crashed) { + verdict = "error"; + } else if (priorAssertionFailed) { + verdict = "failure"; + } + + long totalDuration = 0L; + int totalInputToken = 0; + int totalOutputToken = 0; + List interactions = new ArrayList<>(); + int counter = 0; + for (StepOutcome outcome : outcomes) { + counter++; + int stepInputToken = tokenSum(outcome.llmCalls(), true); + int stepOutputToken = tokenSum(outcome.llmCalls(), false); + totalInputToken += stepInputToken; + totalOutputToken += stepOutputToken; + totalDuration += outcome.durationMs(); + interactions.add(new Interaction( + counter, + outcome.api(), + outcome.actualResult(), + outcome.durationMs(), + outcome.request() == null ? Map.of() : outcome.request(), + displayResponse(outcome), + stepInputToken, + stepOutputToken)); + for (RecordingMeteredLlmClient.RecordedLlmCall call : outcome.llmCalls()) { + counter++; + interactions.add(new Interaction( + counter, + "LLM", + call.result(), + call.durationMs(), + call.request(), + call.response(), + call.inputToken(), + call.outputToken())); + } + } + + return new CaseResult( + scenarioName, + inputCase, + verdict, + failReason, + totalDuration, + totalInputToken, + totalOutputToken, + interactions); + } + + private StepOutcome executeStep( + int stepNo, InputStep step, List> priorPayloads) { + long startedAt = System.nanoTime(); + Map request; + try { + request = FromStepResolver.resolve(step.args(), stepNo, priorPayloads); + } catch (RuntimeException error) { + long duration = elapsedMillis(startedAt); + return StepOutcome.error( + stepNo, + step.api(), + step.args(), + ThrowableSerializer.toMap(error), + null, + error.getMessage(), + true, + duration, + recorder.drain()); + } + recorder.drain(); + try { + Map payload = registry.handler(step.api()) + .orElseThrow(() -> new IllegalArgumentException("api not registered: " + step.api())) + .run(request); + List llmCalls = recorder.drain(); + return StepOutcome.success(stepNo, step.api(), request, payload, elapsedMillis(startedAt), llmCalls); + } catch (Throwable error) { + List llmCalls = recorder.drain(); + boolean coded = error instanceof A2ATError; + return StepOutcome.error( + stepNo, + step.api(), + request, + ThrowableSerializer.toMap(error), + coded ? ((A2ATError) error).getCode() : null, + error.getMessage(), + !coded, + elapsedMillis(startedAt), + llmCalls); + } + } + + private static Map softResolve( + InputStep step, int stepNo, List> priorPayloads) { + try { + return FromStepResolver.resolve(step.args(), stepNo, priorPayloads); + } catch (RuntimeException error) { + return step.args(); + } + } + + private static Map displayResponse(StepOutcome outcome) { + if ("error".equals(outcome.actualResult())) { + return outcome.errorResponse() == null ? Map.of() : outcome.errorResponse(); + } + if ("success".equals(outcome.actualResult())) { + return outcome.payload() == null ? Map.of() : outcome.payload(); + } + Map skipped = new LinkedHashMap<>(); + skipped.put("reason", "Previous step assertion failed; this step was skipped"); + return skipped; + } + + private static int tokenSum( + List calls, boolean input) { + int sum = 0; + for (RecordingMeteredLlmClient.RecordedLlmCall call : calls) { + sum += input ? call.inputToken() : call.outputToken(); + } + return sum; + } + + private static long elapsedMillis(long startedAtNanos) { + return Math.max(0L, (System.nanoTime() - startedAtNanos) / 1_000_000L); + } + + /** Execution transcript of one case. */ + public record CaseResult( + String scenario, + InputCase inputCase, + String verdict, + String failReason, + long totalDurationMs, + int totalInputToken, + int totalOutputToken, + List interactions) {} + + /** One transcript row: either an SDK API step or one of its underlying LLM calls, globally numbered. */ + public record Interaction( + int step, + String type, + String result, + long durationMs, + Map request, + Map response, + int inputToken, + int outputToken) {} +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/llm/RecordingMeteredLlmClient.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/llm/RecordingMeteredLlmClient.java new file mode 100644 index 00000000..6ea1b095 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/llm/RecordingMeteredLlmClient.java @@ -0,0 +1,98 @@ +package net.openan.a2at.sdk.corpus.engine.llm; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.corpus.engine.util.ThrowableSerializer; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; + +/** + * Decorator wrapping the real LLM client as the single test seam of the corpus: it records every invocation with the + * complete request (messages, schema, parameters, model), the complete response (content, model, usage), the latency + * and the token counters, then delegates transparently. The production pipeline is otherwise assembled unchanged. + */ +public final class RecordingMeteredLlmClient implements LLMClient { + + /** One captured LLM invocation. */ + public record RecordedLlmCall( + Map request, + Map response, + String result, + long durationMs, + int inputToken, + int outputToken) {} + + private final LLMClient delegate; + + private final String modelName; + + private final List calls = new ArrayList<>(); + + public RecordingMeteredLlmClient(LLMClient delegate, String modelName) { + this.delegate = delegate; + this.modelName = modelName; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + long startedAt = System.nanoTime(); + Map request = new LinkedHashMap<>(); + request.put("messages", new ArrayList<>(messages)); + request.put("jsonSchema", jsonSchema == null ? Map.of() : jsonSchema); + if (temperature != null) { + request.put("temperature", temperature); + } + if (maxTokens != null) { + request.put("maxTokens", maxTokens); + } + request.put("model", modelName); + try { + LLMResponse response = delegate.structured(messages, jsonSchema, temperature, maxTokens); + Map responseMap = new LinkedHashMap<>(); + responseMap.put("content", response.content()); + responseMap.put("model", response.model()); + responseMap.put("usage", response.usage() == null ? Map.of() : response.usage()); + record(new RecordedLlmCall( + request, + responseMap, + "success", + elapsedMillis(startedAt), + tokens(response.usage(), "prompt_tokens"), + tokens(response.usage(), "completion_tokens"))); + return response; + } catch (Throwable error) { + record(new RecordedLlmCall( + request, ThrowableSerializer.toMap(error), "error", elapsedMillis(startedAt), 0, 0)); + throw error; + } + } + + /** Returns the calls recorded since the previous drain and forgets them. */ + public synchronized List drain() { + List snapshot = new ArrayList<>(calls); + calls.clear(); + return snapshot; + } + + private static long elapsedMillis(long startedAtNanos) { + return Math.max(0L, (System.nanoTime() - startedAtNanos) / 1_000_000L); + } + + private static int tokens(Map usage, String key) { + if (usage == null) { + return 0; + } + Integer value = usage.get(key); + return value == null ? 0 : value; + } + + private synchronized void record(RecordedLlmCall call) { + calls.add(call); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CaseFileLoader.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CaseFileLoader.java new file mode 100644 index 00000000..2dfb02fd --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CaseFileLoader.java @@ -0,0 +1,305 @@ +package net.openan.a2at.sdk.corpus.engine.loader; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; + +/** + * Strict loader for one {@code input_case_*.json} file. + * + *

            Every structural violation is collected and reported in a single fail-fast message naming the file, the case id + * and the offending construct: unknown keys, missing or blank fields, mismatched input/expected lengths, unknown api + * names, out-of-catalog error codes and malformed {@code $fromStep} references all fail at load time. + */ +public final class CaseFileLoader { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final Set CASE_KEYS = + Set.of("id", "caseDimension", "caseDesc", "caseCategory", "input", "expected"); + + private static final Set STEP_KEYS = Set.of("api", "args"); + + private static final Set EXPECTED_KEYS = Set.of("result", "data", "dataExact", "error"); + + private static final Set ERROR_KEYS = Set.of("errCode", "errMessage", "errMessageContains"); + + private static final Set KNOWN_RESULTS = Set.of("success", "error"); + + private static final Set CATALOG_CODES = Arrays.stream(ErrorCatalog.values()) + .map(ErrorCatalog::getCode) + .collect(Collectors.toUnmodifiableSet()); + + public List load(Path file, Set registeredApis) { + byte[] bytes; + try { + bytes = Files.readAllBytes(file); + } catch (IOException error) { + throw new CorpusLoadException("Failed to read corpus file: " + file + " - " + error.getMessage()); + } + JsonNode root; + try { + root = MAPPER.readTree(new String(bytes, StandardCharsets.UTF_8)); + } catch (IOException error) { + throw new CorpusLoadException(file + " is not valid JSON: " + error.getMessage()); + } + if (root == null || !root.isArray()) { + throw new CorpusLoadException(file + " top level must be a case array"); + } + + List problems = new ArrayList<>(); + List cases = new ArrayList<>(); + Set ids = new LinkedHashSet<>(); + + for (int i = 0; i < root.size(); i++) { + JsonNode node = root.get(i); + String caseLabel = "case[" + i + "]"; + if (!node.isObject()) { + problems.add(caseLabel + " must be an object"); + continue; + } + List unknownKeys = unknownKeys(node, CASE_KEYS); + if (!unknownKeys.isEmpty()) { + problems.add(caseLabel + " unknown keys: " + String.join(", ", unknownKeys)); + continue; + } + String id = requiredText(node, "id", caseLabel, problems); + if (id != null) { + if (ids.contains(id)) { + problems.add(caseLabel + " duplicate id within the scenario: " + id); + } + ids.add(id); + } + requiredText(node, "caseDimension", caseLabel, problems); + requiredText(node, "caseDesc", caseLabel, problems); + requiredText(node, "caseCategory", caseLabel, problems); + + List input = parseInputSteps(node.get("input"), caseLabel, problems, registeredApis); + List expected = + parseExpectedSteps(node.get("expected"), caseLabel, problems, input.size()); + + if (id != null && !problems.isEmpty()) { + appendCaseContext(problems, id, node, caseLabel); + } + cases.add(new InputCase( + safeText(node.get("id")), + safeText(node.get("caseDimension")), + safeText(node.get("caseDesc")), + safeText(node.get("caseCategory")), + input, + expected)); + } + + if (!problems.isEmpty()) { + throw new CorpusLoadException("Corpus file validation failed " + file + ":\n- " + String.join("\n- ", problems)); + } + return cases; + } + + /** Returns the closed set of SDK error catalog codes; used when an expected error code is asserted. */ + public static Set catalogCodes() { + return CATALOG_CODES; + } + + private static List parseInputSteps( + JsonNode inputNode, String caseLabel, List problems, Set registeredApis) { + List steps = new ArrayList<>(); + if (inputNode == null || !inputNode.isArray() || inputNode.isEmpty()) { + problems.add(caseLabel + " input must be a non-empty step array"); + return steps; + } + for (int i = 0; i < inputNode.size(); i++) { + JsonNode stepNode = inputNode.get(i); + String label = caseLabel + ".input[" + i + "]"; + if (!stepNode.isObject()) { + problems.add(label + " must be an object {api, args}"); + continue; + } + List unknownKeys = unknownKeys(stepNode, STEP_KEYS); + if (!unknownKeys.isEmpty()) { + problems.add(label + " unknown keys: " + String.join(", ", unknownKeys)); + continue; + } + String api = requiredText(stepNode, "api", label, problems); + if (api != null && !registeredApis.contains(api)) { + problems.add(label + " api not registered: " + api + " (available: " + String.join(", ", registeredApis) + ")"); + } + Map args = new LinkedHashMap<>(); + if (stepNode.has("args") && stepNode.get("args").isObject()) { + args = MAPPER.convertValue(stepNode.get("args"), new TypeReference>() {}); + validateFromStepRefs(args, label, problems, i + 1); + } else { + problems.add(label + " args must be an object"); + } + steps.add(new InputStep(safeText(stepNode.get("api")), args)); + } + return steps; + } + + private static List parseExpectedSteps( + JsonNode expectedNode, String caseLabel, List problems, int inputSize) { + List expected = new ArrayList<>(); + if (expectedNode == null || !expectedNode.isArray() || expectedNode.isEmpty()) { + problems.add(caseLabel + " expected must be a non-empty array"); + return expected; + } + if (inputSize > 0 && expectedNode.size() != inputSize) { + problems.add(caseLabel + " input/expected length mismatch: " + inputSize + " != " + expectedNode.size()); + } + for (int i = 0; i < expectedNode.size(); i++) { + JsonNode stepNode = expectedNode.get(i); + String label = caseLabel + ".expected[" + i + "]"; + if (!stepNode.isObject()) { + problems.add(label + " must be an object {result, data?, dataExact?, error?}"); + continue; + } + List unknownKeys = unknownKeys(stepNode, EXPECTED_KEYS); + if (!unknownKeys.isEmpty()) { + problems.add(label + " unknown keys: " + String.join(", ", unknownKeys)); + continue; + } + String result = requiredText(stepNode, "result", label, problems); + if (result != null && !KNOWN_RESULTS.contains(result)) { + problems.add(label + " result must be success or error: " + result); + } + Map data = new LinkedHashMap<>(); + if (stepNode.has("data")) { + if (stepNode.get("data").isObject()) { + data = MAPPER.convertValue(stepNode.get("data"), new TypeReference>() {}); + } else { + problems.add(label + " data must be an object"); + } + } + boolean dataExact = false; + if (stepNode.has("dataExact")) { + if (stepNode.get("dataExact").isBoolean()) { + dataExact = stepNode.get("dataExact").asBoolean(); + } else { + problems.add(label + " dataExact must be a boolean"); + } + } + String errCode = ""; + String errMessage = ""; + String errMessageContains = ""; + if (stepNode.has("error")) { + if (!stepNode.get("error").isObject()) { + problems.add(label + " error must be an object {errCode?, errMessage?, errMessageContains?}"); + } else { + JsonNode errorNode = stepNode.get("error"); + List unknownErrorKeys = unknownKeys(errorNode, ERROR_KEYS); + if (!unknownErrorKeys.isEmpty()) { + problems.add(label + ".error unknown keys: " + String.join(", ", unknownErrorKeys)); + } + errCode = optionalText(errorNode, "errCode", label + ".error", problems); + errMessage = optionalText(errorNode, "errMessage", label + ".error", problems); + errMessageContains = + optionalText(errorNode, "errMessageContains", label + ".error", problems); + if (!errCode.isEmpty() && !CATALOG_CODES.contains(errCode)) { + problems.add(label + ".error errCode not in the SDK error catalog: " + errCode); + } + } + } + expected.add(new ExpectedStep(safeResult(result), data, dataExact, errCode, errMessage, errMessageContains)); + } + return expected; + } + + private static void validateFromStepRefs( + Map args, String label, List problems, int stepNumber) { + validateFromStepValue(args, label, problems, stepNumber); + } + + private static void validateFromStepValue(Object value, String label, List problems, int stepNumber) { + if (value instanceof Map map) { + if (map.containsKey("$fromStep") || map.containsKey("$field")) { + Object fromStep = map.get("$fromStep"); + Object field = map.get("$field"); + boolean referenceOnly = + map.size() == 2 && map.containsKey("$fromStep") && map.containsKey("$field"); + if (!referenceOnly) { + problems.add(label + " a $fromStep reference must contain exactly the two keys $fromStep and $field"); + return; + } + if (!(fromStep instanceof Number number) || number.intValue() <= 0) { + problems.add(label + " $fromStep must be a positive integer: " + fromStep); + } else if (number.intValue() >= stepNumber) { + problems.add(label + " $fromStep=" + number.intValue() + " must reference an earlier step (current step " + + stepNumber + ")"); + } + if (field == null || !(field instanceof String text) || text.isBlank()) { + problems.add(label + " $field must be a non-blank string: " + field); + } + return; + } + for (Object entry : map.values()) { + validateFromStepValue(entry, label, problems, stepNumber); + } + } else if (value instanceof List list) { + for (Object entry : list) { + validateFromStepValue(entry, label, problems, stepNumber); + } + } + } + + private static List unknownKeys(JsonNode node, Set allowed) { + List unknown = new ArrayList<>(); + node.fieldNames().forEachRemaining(name -> { + if (!allowed.contains(name)) { + unknown.add(name); + } + }); + return unknown; + } + + private static String requiredText(JsonNode node, String key, String label, List problems) { + JsonNode value = node.get(key); + if (value == null || !value.isTextual() || value.asText().isBlank()) { + problems.add(label + " missing non-blank field: " + key); + return null; + } + return value.asText(); + } + + private static String optionalText(JsonNode node, String key, String label, List problems) { + JsonNode value = node.get(key); + if (value == null || value.isNull()) { + return ""; + } + if (!value.isTextual()) { + problems.add(label + " field " + key + " must be a string"); + return ""; + } + return value.asText(); + } + + private static String safeText(JsonNode node) { + return node == null || !node.isTextual() ? "" : node.asText(); + } + + private static String safeResult(String result) { + return result == null ? "" : result; + } + + /** Appends the case id to every problem raised while parsing this case, for grep-friendly diagnostics. */ + private static void appendCaseContext(List problems, String id, JsonNode node, String caseLabel) { + for (int i = 0; i < problems.size(); i++) { + String problem = problems.get(i); + if (problem.startsWith(caseLabel) && !problem.contains("id=")) { + problems.set(i, problem + " (id=" + id + ")"); + } + } + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CorpusLoadException.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CorpusLoadException.java new file mode 100644 index 00000000..de821a1a --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CorpusLoadException.java @@ -0,0 +1,9 @@ +package net.openan.a2at.sdk.corpus.engine.loader; + +/** Raised when one corpus case file fails strict load-time validation; the message lists every violation. */ +public final class CorpusLoadException extends RuntimeException { + + public CorpusLoadException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/ExpectedStep.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/ExpectedStep.java new file mode 100644 index 00000000..c97a7374 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/ExpectedStep.java @@ -0,0 +1,12 @@ +package net.openan.a2at.sdk.corpus.engine.loader; + +import java.util.Map; + +/** One per-step expectation, aligned by index with the matching {@link InputStep}. */ +public record ExpectedStep( + String result, + Map data, + boolean dataExact, + String errCode, + String errMessage, + String errMessageContains) {} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputCase.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputCase.java new file mode 100644 index 00000000..28ebcc9f --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputCase.java @@ -0,0 +1,12 @@ +package net.openan.a2at.sdk.corpus.engine.loader; + +import java.util.List; + +/** One workflow case record as declared by {@code input_case_*.json}. */ +public record InputCase( + String id, + String caseDimension, + String caseDesc, + String caseCategory, + List input, + List expected) {} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputStep.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputStep.java new file mode 100644 index 00000000..cbf0a428 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputStep.java @@ -0,0 +1,6 @@ +package net.openan.a2at.sdk.corpus.engine.loader; + +import java.util.Map; + +/** One SDK API step; {@code args} carries the request arguments with optional {@code $fromStep} references. */ +public record InputStep(String api, Map args) {} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiHandler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiHandler.java new file mode 100644 index 00000000..2e01e656 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiHandler.java @@ -0,0 +1,15 @@ +package net.openan.a2at.sdk.corpus.engine.registry; + +import java.util.Map; + +/** Invokes one registered SDK facade method with fully resolved (post {@code $fromStep}) arguments. */ +public interface ApiHandler { + + /** + * Runs the SDK call and returns its payload as a serializable map. + * + * @param args resolved step arguments + * @return serializable response payload + */ + Map run(Map args); +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiRegistry.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiRegistry.java new file mode 100644 index 00000000..1d4fff6d --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiRegistry.java @@ -0,0 +1,32 @@ +package net.openan.a2at.sdk.corpus.engine.registry; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Explicit registry mapping SDK facade method names to their {@link ApiHandler}. Registration is declarative (never + * reflective): adding a new API is one registry line that the IDE can navigate. Unknown method names fail at load + * time. + */ +public final class ApiRegistry { + + private final Map handlers = new LinkedHashMap<>(); + + public ApiRegistry register(String apiName, ApiHandler handler) { + if (handlers.containsKey(apiName)) { + throw new IllegalStateException("API registered twice: " + apiName); + } + handlers.put(apiName, handler); + return this; + } + + public Optional handler(String apiName) { + return Optional.ofNullable(handlers.get(apiName)); + } + + public java.util.Set apiNames() { + return Collections.unmodifiableSet(handlers.keySet()); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ClientApis.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ClientApis.java new file mode 100644 index 00000000..0a1a8641 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ClientApis.java @@ -0,0 +1,70 @@ +package net.openan.a2at.sdk.corpus.engine.registry; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; + +/** + * Task-T client-side registration: JSON step arguments are bound to the production + * {@link ClientPromptGenerationOrchestrator} assembled by {@link SdkRuntimeAssembler}. + */ +public final class ClientApis { + + private ClientApis() {} + + public static void registerTaskApis(ApiRegistry registry, ClientPromptGenerationOrchestrator client) { + registry.register("generateTaskPromptFromText", args -> + metadataToMap(client.generateTaskPromptFromText( + Args.text(args, "text"), Args.templateUri(args, "templateUri")))); + registry.register("generateTaskPromptFromDataWithSchema", args -> + metadataToMap(client.generateTaskPromptFromDataWithSchema( + Args.map(args, "data"), Args.map(args, "schema"), Args.templateUri(args, "templateUri")))); + } + + static Map metadataToMap(MetadataContent content) { + Map map = new LinkedHashMap<>(); + map.put("templateUri", content.templateUri()); + map.put("promptText", content.promptText()); + map.put("extensionUri", content.extensionUri()); + return map; + } + + static TemplateUri templateUri(String raw) { + return TemplateUri.parse(raw) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); + } + + static final class Args { + + private Args() {} + + static String text(Map args, String name) { + Object value = args.get(name); + if (!(value instanceof String str)) { + throw new IllegalArgumentException("step argument " + name + " must be a string: " + value); + } + // Blank strings are passed through to the SDK: the facade raises its own coded error + // (e.g. negotiation.invalid_input), which the expectation layer asserts against. + return str; + } + + static Map map(Map args, String name) { + Object value = args.get(name); + if (!(value instanceof Map)) { + throw new IllegalArgumentException("step argument " + name + " must be an object: " + value); + } + @SuppressWarnings("unchecked") + Map typed = (Map) value; + if (typed.isEmpty()) { + throw new IllegalArgumentException("step argument " + name + " must not be an empty object"); + } + return typed; + } + + static TemplateUri templateUri(Map args, String name) { + return ClientApis.templateUri(text(args, name)); + } + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/SdkRuntimeAssembler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/SdkRuntimeAssembler.java new file mode 100644 index 00000000..cd8ca4ce --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/SdkRuntimeAssembler.java @@ -0,0 +1,92 @@ +package net.openan.a2at.sdk.corpus.engine.registry; + +import java.util.List; +import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.LlmConfig; +import net.openan.a2at.sdk.core.model.NegotiationConfig; +import net.openan.a2at.sdk.core.model.PromptComplianceConfig; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.corpus.engine.config.CorpusEnvConfig; +import net.openan.a2at.sdk.corpus.engine.llm.RecordingMeteredLlmClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.providers.OpenAIClient; +import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; + +/** + * Assembles the SDK runtime the corpus exercises against. + * + *

            Every component is produced by the production builders ({@code DefaultA2ATClientBuilder} / + * {@code DefaultA2ATServerBuilder}); the sole test seam is the {@link RecordingMeteredLlmClient} injected through the + * builder {@code llmClient(...)} injection point. The unified {@link A2ATConfig} is constructed directly from the + * corpus test configuration (classpath prompt resources, fixed language), so the module never touches production + * {@code A2AT_LLM_*} environment values. + */ +public final class SdkRuntimeAssembler { + + /** Fully assembled runtime with the single shared recording LLM client. */ + public record Runtime( + RecordingMeteredLlmClient recorder, + ClientPromptGenerationOrchestrator clientGeneration, + ContentValidator taskValidator) {} + + private SdkRuntimeAssembler() {} + + public static Runtime taskRuntime() { + CorpusEnvConfig env = CorpusEnvConfig.load(); + + LLMClientConfig llmClientConfig = new LLMClientConfig( + env.provider(), + env.model(), + env.apiKey(), + env.baseUrl(), + 10, + env.maxTokens(), + env.temperature(), + env.timeoutSeconds(), + 300, + 100, + false, + null); + RecordingMeteredLlmClient recorder = + new RecordingMeteredLlmClient(new OpenAIClient(llmClientConfig), env.model()); + + LlmConfig llmConfig = new LlmConfig( + env.provider(), + env.model(), + env.apiKey(), + env.baseUrl(), + 10, + env.maxTokens(), + env.temperature(), + env.timeoutSeconds(), + 300, + 100, + false, + null, + env.maxAttempts(), + List.of()); + A2ATConfig config = new A2ATConfig( + new PromptRuntimeConfig("zh-CN", PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH, null), + llmConfig, + new InputLimitConfig(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), + new NegotiationConfig("in_memory"), + new PromptComplianceConfig(false)); + + ClientPromptGenerationOrchestrator clientGeneration = DefaultA2ATClientBuilder.builder() + .config(config) + .envPath(env.envPath()) + .llmClient(recorder) + .buildPromptGenerationOrchestrator(); + ContentValidator taskValidator = DefaultA2ATServerBuilder.builder() + .config(config) + .envPath(env.envPath()) + .llmClient(recorder) + .buildTaskContentValidator(); + + return new Runtime(recorder, clientGeneration, taskValidator); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ServerApis.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ServerApis.java new file mode 100644 index 00000000..4a75d3a9 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ServerApis.java @@ -0,0 +1,28 @@ +package net.openan.a2at.sdk.corpus.engine.registry; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.validation.ContentValidator; + +/** + * Task-T server-side registration: the {@code validateTaskPromptAndDataFilling} step is bound to the production + * {@link ContentValidator} assembled by {@link SdkRuntimeAssembler} from the default server builder. + */ +public final class ServerApis { + + private ServerApis() {} + + public static void registerTaskApis(ApiRegistry registry, ContentValidator taskContentValidator) { + registry.register("validateTaskPromptAndDataFilling", args -> filledToMap(taskContentValidator.validate( + ClientApis.Args.text(args, "promptText"), + ClientApis.Args.map(args, "schema"), + ClientApis.Args.templateUri(args, "templateUri")))); + } + + static Map filledToMap(FilledParamData filled) { + Map map = new LinkedHashMap<>(); + map.put("data", filled.data() == null ? Map.of() : filled.data()); + return map; + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/ResultAggregator.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/ResultAggregator.java new file mode 100644 index 00000000..82da8e54 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/ResultAggregator.java @@ -0,0 +1,269 @@ +package net.openan.a2at.sdk.corpus.engine.report; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.corpus.engine.engine.WorkflowEngine; + +/** + * Aggregates one run into accuracy / latency / token statistics and writes {@code summary_report_.json} plus a + * console table. Accuracy rows group by overall, scenario, case dimension, case category and (for step-level health) + * API method; the 95% target line is marked per row. + */ +public final class ResultAggregator { + + /** 95%+ accuracy target required by the accuracy verification directive. */ + public static final double ACCURACY_TARGET = 0.95; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ResultAggregator() {} + + /** Computes and persists the run summary; returns the written file. */ + public static Path writeSummary(String flowType, List results) { + Map summary = summarize(results); + Path dir = summaryDir(); + try { + Files.createDirectories(dir); + } catch (IOException error) { + throw new IllegalStateException("Failed to create the summary directory: " + dir, error); + } + Path file = dir.resolve("summary_report_" + flowType + ".json"); + try { + MAPPER.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), summary); + } catch (IOException error) { + throw new IllegalStateException("Failed to write the summary: " + file, error); + } + printTable(flowType, summary); + return file; + } + + /** + * Prints one case result as it completes so every executed case - including failures - is visible in the console + * log of the run. Failed or crashed cases additionally print their full interaction trace. + */ + public static void printCaseLine(WorkflowEngine.CaseResult result) { + String fail = result.failReason().isEmpty() + ? "" + : " | fail: " + result.failReason(); + System.out.printf( + "[case] %s/%s => %s | %dms | tokens %d%s%n", + result.scenario(), + result.inputCase().id(), + result.verdict(), + result.totalDurationMs(), + result.totalInputToken() + result.totalOutputToken(), + fail); + if ("success".equals(result.verdict())) { + return; + } + for (WorkflowEngine.Interaction interaction : result.interactions()) { + String detail = ""; + Map response = interaction.response(); + if (response != null) { + Object code = response.get("errorCode"); + Object message = response.get("message"); + if (code != null || message != null) { + detail = " | " + (code == null ? "" : code) + " " + abbreviate( + String.valueOf(message), 160); + } + } + System.out.printf( + " s%-4d %-30s %-8s %6dms in=%d out=%d%s%n", + interaction.step(), + interaction.type(), + interaction.result(), + interaction.durationMs(), + interaction.inputToken(), + interaction.outputToken(), + detail); + } + } + + static Map summarize(List results) { + Map summary = new LinkedHashMap<>(); + summary.put("caseTotal", results.size()); + summary.put("accuracyTarget", ACCURACY_TARGET); + summary.put("rows", caseRows(results)); + summary.put("apiStepRows", apiStepRows(results)); + summary.put("durationMs", percentileStats(stepDurations(results))); + summary.put("tokens", tokenStats(results)); + return summary; + } + + private static List> caseRows(List results) { + Map buckets = new LinkedHashMap<>(); + for (WorkflowEngine.CaseResult result : results) { + bucket(buckets, "overall", "overall").count(result); + bucket(buckets, "scenario", result.scenario()).count(result); + bucket(buckets, "dimension", result.inputCase().caseDimension()).count(result); + bucket(buckets, "category", result.inputCase().caseCategory()).count(result); + } + return bucketRows(buckets); + } + + private static List> apiStepRows(List results) { + Map buckets = new LinkedHashMap<>(); + for (WorkflowEngine.CaseResult result : results) { + for (WorkflowEngine.Interaction interaction : result.interactions()) { + if ("LLM".equals(interaction.type())) { + continue; + } + if ("skipped".equals(interaction.result())) { + continue; + } + bucket(buckets, "api", interaction.type()).count("success".equals(interaction.result())); + } + } + return bucketRows(buckets); + } + + private static final class Bucket { + + final String scope; + + final String key; + + long total; + + long success; + + Bucket(String scope, String key) { + this.scope = scope; + this.key = key; + } + + void count(WorkflowEngine.CaseResult result) { + total++; + if ("success".equals(result.verdict())) { + success++; + } + } + + void count(boolean passed) { + total++; + if (passed) { + success++; + } + } + } + + private static Bucket bucket(Map buckets, String scope, String key) { + return buckets.computeIfAbsent(scope + "/" + key, ignored -> new Bucket(scope, key)); + } + + private static List> bucketRows(Map buckets) { + List> rows = new ArrayList<>(); + buckets.values().stream() + .sorted(Comparator.comparing((Bucket b) -> b.scope).thenComparing(b -> b.key)) + .forEach(bucket -> { + Map row = new LinkedHashMap<>(); + row.put("scope", bucket.scope); + row.put("key", bucket.key); + row.put("total", bucket.total); + row.put("success", bucket.success); + row.put("failure", bucket.total - bucket.success); + row.put("accuracy", percentage(bucket.success, bucket.total).doubleValue()); + row.put("meetsTarget", bucket.total > 0 && percentage(bucket.success, bucket.total) + .doubleValue() >= ACCURACY_TARGET); + rows.add(row); + }); + return rows; + } + + private static List stepDurations(List results) { + List durations = new ArrayList<>(); + for (WorkflowEngine.CaseResult result : results) { + for (WorkflowEngine.Interaction interaction : result.interactions()) { + if (!"LLM".equals(interaction.type())) { + durations.add(interaction.durationMs()); + } + } + } + return durations; + } + + private static Map percentileStats(List durations) { + Map stats = new LinkedHashMap<>(); + stats.put("count", durations.size()); + if (!durations.isEmpty()) { + List sorted = durations.stream().sorted().toList(); + stats.put("p50", sorted.get(Math.min(sorted.size() - 1, sorted.size() * 50 / 100))); + stats.put("p95", sorted.get(Math.min(sorted.size() - 1, sorted.size() * 95 / 100))); + stats.put("max", sorted.get(sorted.size() - 1)); + } + return stats; + } + + private static Map tokenStats(List results) { + long input = 0; + long output = 0; + for (WorkflowEngine.CaseResult result : results) { + input += result.totalInputToken(); + output += result.totalOutputToken(); + } + Map stats = new LinkedHashMap<>(); + stats.put("totalInputToken", input); + stats.put("totalOutputToken", output); + stats.put("totalToken", input + output); + stats.put("meanInputTokenPerCase", results.isEmpty() ? 0 : Math.round((double) input / results.size())); + stats.put("meanOutputTokenPerCase", results.isEmpty() ? 0 : Math.round((double) output / results.size())); + return stats; + } + + private static BigDecimal percentage(long success, long total) { + if (total == 0) { + return BigDecimal.ZERO; + } + return BigDecimal.valueOf(success) + .multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP); + } + + private static Path summaryDir() { + String override = System.getProperty("corpus.output.dir"); + if (override != null && !override.isBlank()) { + return Path.of(override).toAbsolutePath().normalize(); + } + return Path.of(System.getProperty("user.dir", "."), "target", "corpus").toAbsolutePath().normalize(); + } + + @SuppressWarnings("unchecked") + private static void printTable(String flowType, Map summary) { + System.out.println(); + System.out.println("===== a2a-t-corpus run summary (" + flowType + ", target " + + ACCURACY_TARGET * 100 + "%)====="); + List> rows = (List>) summary.get("rows"); + System.out.printf("%-12s %-30s %6s %8s %9s %8s%n", "scope", "key", "total", "success", "accuracy", "meets"); + if (rows != null) { + for (Map row : rows) { + System.out.printf("%-12s %-30s %6d %8d %9.2f%% %8s%n", + row.get("scope"), + abbreviate(String.valueOf(row.get("key")), 30), + ((Number) row.get("total")).longValue(), + ((Number) row.get("success")).longValue(), + ((Number) row.get("accuracy")).doubleValue(), + Boolean.TRUE.equals(row.get("meetsTarget")) ? "YES" : "NO"); + } + } + Map tokens = (Map) summary.get("tokens"); + if (tokens != null) { + System.out.printf("Token totals: input=%d output=%d total=%d%n", + ((Number) tokens.get("totalInputToken")).longValue(), + ((Number) tokens.get("totalOutputToken")).longValue(), + ((Number) tokens.get("totalToken")).longValue()); + } + } + + private static String abbreviate(String value, int maxLength) { + return value.length() <= maxLength ? value : value.substring(0, maxLength - 1) + "…"; + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/TranscriptWriter.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/TranscriptWriter.java new file mode 100644 index 00000000..ac685ea0 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/TranscriptWriter.java @@ -0,0 +1,88 @@ +package net.openan.a2at.sdk.corpus.engine.report; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.corpus.engine.engine.WorkflowEngine; + +/** + * Writes the execution transcript of one scenario to {@code output_result_.json}. + * + *

            Each run fully overwrites the file: it reflects exactly the cases executed in that run (a filtered run therefore + * contains only the matching cases). The default destination is the scenario directory itself (input and output live + * side by side for review and the CSV tooling); {@code -Dcorpus.output.dir=

            } redirects every scenario output + * into one shared directory while keeping the per-scenario layout. + */ +public final class TranscriptWriter { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private TranscriptWriter() {} + + public static Path writeScenarioOutput( + String flowType, String scenarioName, Path defaultScenarioDir, List results) { + Path outputDir = outputDir(scenarioName, defaultScenarioDir); + try { + Files.createDirectories(outputDir); + } catch (IOException error) { + throw new IllegalStateException("Failed to create the output directory: " + outputDir, error); + } + Path file = outputDir.resolve("output_result_" + flowType + ".json"); + List> records = new ArrayList<>(); + for (WorkflowEngine.CaseResult result : results) { + records.add(toRecord(result)); + } + try { + MAPPER.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), records); + } catch (IOException error) { + throw new IllegalStateException("Failed to write the run results: " + file, error); + } + return file; + } + + private static Map toRecord(WorkflowEngine.CaseResult result) { + Map record = new LinkedHashMap<>(); + record.put("id", result.inputCase().id()); + record.put("caseDimension", result.inputCase().caseDimension()); + record.put("caseDesc", result.inputCase().caseDesc()); + record.put("caseCategory", result.inputCase().caseCategory()); + record.put("totalTime", result.totalDurationMs()); + record.put("totalInputToken", result.totalInputToken()); + record.put("totalOutputToken", result.totalOutputToken()); + record.put("totalToken", result.totalInputToken() + result.totalOutputToken()); + record.put("result", result.verdict()); + if (!result.failReason().isEmpty()) { + record.put("failReason", result.failReason()); + } + Map output = new LinkedHashMap<>(); + List> interactions = new ArrayList<>(); + for (WorkflowEngine.Interaction interaction : result.interactions()) { + Map serialized = new LinkedHashMap<>(); + serialized.put("step", String.valueOf(interaction.step())); + serialized.put("type", interaction.type()); + serialized.put("result", interaction.result()); + serialized.put("duration_ms", interaction.durationMs()); + serialized.put("request", interaction.request()); + serialized.put("response", interaction.response()); + serialized.put("inputToken", interaction.inputToken()); + serialized.put("outputToken", interaction.outputToken()); + interactions.add(serialized); + } + output.put("interactions", interactions); + record.put("output", output); + return record; + } + + static Path outputDir(String scenarioName, Path defaultScenarioDir) { + String override = System.getProperty("corpus.output.dir"); + if (override != null && !override.isBlank()) { + return Path.of(override).toAbsolutePath().normalize().resolve(scenarioName); + } + return defaultScenarioDir; + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/util/ThrowableSerializer.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/util/ThrowableSerializer.java new file mode 100644 index 00000000..a351ccf9 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/util/ThrowableSerializer.java @@ -0,0 +1,84 @@ +package net.openan.a2at.sdk.corpus.engine.util; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATError; + +/** + * Serializes a thrown error into a structured map carrying every inspectable property, per the transcript contract: + * exception class, machine-readable error code for {@link A2ATError} instances, message, all public getter values and + * the cause chain. + */ +public final class ThrowableSerializer { + + private static final int MAX_CAUSE_DEPTH = 3; + + private ThrowableSerializer() {} + + public static Map toMap(Throwable error) { + Map serialized = new LinkedHashMap<>(); + fill(serialized, error, 0); + return serialized; + } + + private static void fill(Map target, Throwable error, int depth) { + target.put("exception", error.getClass().getName()); + target.put("message", error.getMessage()); + if (error instanceof A2ATError a2atError) { + target.put("errorCode", a2atError.getCode()); + } + target.put("properties", propertiesOf(error)); + Throwable cause = error.getCause(); + if (cause != null && depth < MAX_CAUSE_DEPTH) { + Map causeMap = new LinkedHashMap<>(); + fill(causeMap, cause, depth + 1); + target.put("cause", causeMap); + } + } + + private static Map propertiesOf(Throwable error) { + Map properties = new LinkedHashMap<>(); + for (Method method : error.getClass().getMethods()) { + if (method.getParameterCount() != 0) { + continue; + } + String name = method.getName(); + if (!name.startsWith("get") && !name.startsWith("is")) { + continue; + } + if (EXCLUDED.contains(name)) { + continue; + } + Object value; + try { + value = method.invoke(error); + } catch (IllegalAccessException | InvocationTargetException ignored) { + continue; + } + if (value == null || value instanceof Throwable) { + continue; + } + properties.put(propertyName(name), value); + } + return properties; + } + + private static String propertyName(String getterName) { + String base = getterName.startsWith("is") ? getterName.substring(2) : getterName.substring(3); + if (base.isEmpty()) { + return getterName; + } + return Character.toLowerCase(base.charAt(0)) + base.substring(1); + } + + private static final java.util.Set EXCLUDED = java.util.Set.of( + "getClass", + "getMessage", + "getLocalizedMessage", + "getCause", + "getStackTrace", + "getSuppressed", + "getCode"); +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenFixtureComparisonTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenFixtureComparisonTest.java deleted file mode 100644 index e25fbfc7..00000000 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/golden/GoldenFixtureComparisonTest.java +++ /dev/null @@ -1,183 +0,0 @@ -package net.openan.a2at.sdk.corpus.golden; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.stream.Stream; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMResponse; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; -import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; -import net.openan.a2at.sdk.corpus.golden.GoldenInputs.GoldenCase; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; - -/** - * Locks the golden fixture set of the negotiation content layer against the real built-in resources. - * - *

            Every golden fixture is rendered deterministically from the fixed {@link GoldenInputs} through an orchestrator - * wired with the built-in templates and vocabulary — no LLM client is involved. The rendered text must match the - * committed fixture file byte for byte, so any drift of the templates, the vocabulary or the rendering pipeline fails - * this test and requires a reviewed fixture revision. This test also pins the MetadataContent contract, the determinism - * of the from-data generation and the zero-LLM guarantee of the from-data variants. - */ -class GoldenFixtureComparisonTest { - - private static final String EXTENSION_URI = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1"; - - private static final String TEMPLATE_URI_METADATA_KEY = MetadataContent.TEMPLATE_URI_METADATA_KEY; - - private static final String NEGOTIATION_CONTEXT_METADATA_KEY = MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY; - - static Stream goldenCases() { - List cases = new ArrayList<>(); - for (String language : GoldenInputs.LANGUAGES) { - for (GoldenCase goldenCase : GoldenCase.values()) { - cases.add(Arguments.of(goldenCase, language)); - } - } - return cases.stream(); - } - - /** Renders one fixture and returns its metadata content, asserting the URI and extension contract up front. */ - private static MetadataContent render(GoldenCase goldenCase, String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - MetadataContent result = goldenCase.generate(orchestrator, language); - assertEquals(goldenCase.templateUri(), result.templateUri()); - assertEquals(EXTENSION_URI, result.extensionUri()); - return result; - } - - private static NegotiationGenerationOrchestrator orchestrator(String language) { - return NegotiationGenerationOrchestratorBuilder.builder() - .language(language) - .build(); - } - - private static String readGoldenFixture(GoldenCase goldenCase, String language) { - String resourcePath = goldenCase.goldenResourcePath(language); - InputStream stream = GoldenFixtureComparisonTest.class.getResourceAsStream(resourcePath); - assertNotNull(stream, "Golden fixture must exist on the test classpath: " + resourcePath); - try (stream) { - return new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } catch (IOException exception) { - throw new AssertionError("Failed to read golden fixture " + resourcePath, exception); - } - } - - @ParameterizedTest(name = "{0} [{1}] matches golden fixture byte for byte") - @MethodSource("goldenCases") - void rendersByteIdenticalToTheGoldenFixture(GoldenCase goldenCase, String language) { - MetadataContent result = render(goldenCase, language); - - assertEquals(readGoldenFixture(goldenCase, language), result.promptText()); - } - - @ParameterizedTest(name = "{0} [{1}] metadata contract") - @MethodSource("goldenCases") - void buildsTheMetadataMapWithExactlyThreeEntries(GoldenCase goldenCase, String language) { - MetadataContent result = render(goldenCase, language); - - Map metadata = result.buildMetadataContent(); - assertEquals(3, metadata.size()); - assertEquals(result.promptText(), metadata.get(EXTENSION_URI)); - assertEquals(goldenCase.templateUri(), metadata.get(TEMPLATE_URI_METADATA_KEY)); - assertEquals(goldenCase.context(), result.negotiationContext()); - Iterator keyOrder = metadata.keySet().iterator(); - assertEquals(EXTENSION_URI, keyOrder.next()); - assertEquals(TEMPLATE_URI_METADATA_KEY, keyOrder.next()); - assertEquals(NEGOTIATION_CONTEXT_METADATA_KEY, keyOrder.next()); - @SuppressWarnings("unchecked") - Map nestedContext = (Map) metadata.get(NEGOTIATION_CONTEXT_METADATA_KEY); - assertEquals(goldenCase.context().id(), nestedContext.get("id")); - assertEquals(goldenCase.context().round(), nestedContext.get("round")); - assertEquals(goldenCase.context().maxRounds(), nestedContext.get("maxRounds")); - assertEquals(goldenCase.performative().name(), nestedContext.get("performative")); - assertEquals(metadata, result.buildMetadataContent()); - } - - @ParameterizedTest(name = "{0} [{1}] deterministic for the same input") - @MethodSource("goldenCases") - void rendersTheSameInputDeterministically(GoldenCase goldenCase, String language) { - NegotiationGenerationOrchestrator orchestrator = orchestrator(language); - - MetadataContent first = goldenCase.generate(orchestrator, language); - MetadataContent second = goldenCase.generate(orchestrator, language); - - assertEquals(first.promptText(), second.promptText()); - assertEquals(first, second); - } - - @ParameterizedTest(name = "{0} [{1}] structural invariants") - @MethodSource("goldenCases") - void keepsTheStructuralInvariantsOfRenderedMessages(GoldenCase goldenCase, String language) { - MetadataContent result = render(goldenCase, language); - String promptText = result.promptText(); - - String contextTitle = "zh-CN".equals(language) ? "协商上下文" : "Negotiation Context"; - assertFalse(promptText.contains(contextTitle), "the context section must not be rendered into the message"); - assertFalse(promptText.contains("- id: " + GoldenInputs.SESSION_ID), "context lines must not be rendered"); - assertEquals(goldenCase.context(), result.negotiationContext(), "the context travels in the metadata"); - assertFalse(promptText.endsWith("\n"), "message must not end with a newline"); - assertFalse(promptText.contains("\n\n\n"), "sections must be joined by exactly one blank line"); - assertFalse(promptText.contains("{{"), "no unreplaced slot placeholder may remain"); - assertFalse(promptText.contains(" - -# Negotiation Test Corpus Index - -Machine-generated view of the corpus under `a2a-t-corpus/src/test/resources/negotiation-cases/`. -Coverage matrices count base records (one case record, or one scenario step under its scenario's category); -language expansion is listed per record and totaled in the statistics. - -## Statistics - -| Metric | Value | -|---|---| -| Case records | 154 | -| Scenario records (steps) | 20 (93) | -| Base records total | 174 | -| Language-expanded units | 234 | -| Shared LLM payloads | 35 | -| Shared schema variants | 8 | -| Error codes covered | 10 (llm.invocation_failed, llm.response_invalid, negotiation.conclusion_mismatch, negotiation.content_extract_failed, negotiation.content_invalid, negotiation.field_missing, negotiation.invalid_input, negotiation.rule_violation, negotiation.semantic_rejected, template.not_found) | -| Exception-only failures | 25 (IllegalArgumentException, NullPointerException) | - -Priority distribution (case records; scenarios carry no priority): - -| Priority | Cases | -|---|---| -| P0 | 79 | -| P1 | 63 | -| P2 | 12 | - -## Family distribution - -| Family | Category | Corpus file | Records | Steps | Languages | -|---|---|---|---|---|---| -| from-text (FT) | FT-EXTRACT | from-text/extraction-failures.json | 15 | 0 | en-US, zh-CN | -| from-text (FT) | FT-HAPPY | from-text/happy.json | 13 | 0 | en-US, zh-CN | -| from-text (FT) | FT-PROG | from-text/programming-errors.json | 8 | 0 | zh-CN | -| from-text (FT) | FT-RETRY | from-text/retry.json | 13 | 0 | en-US, zh-CN | -| from-text (FT) | FT-TPL | from-text/template-resolution.json | 4 | 0 | zh-CN | -| from-data (FD) | FD-HAPPY | from-data/happy.json | 11 | 0 | en-US, zh-CN | -| from-data (FD) | FD-PROG | from-data/programming-errors.json | 14 | 0 | zh-CN | -| validate (VAL) | VAL-DRIFT | validate/drift-probes.json | 4 | 0 | en-US, zh-CN | -| validate (VAL) | VAL-HAPPY | validate/happy.json | 10 | 0 | en-US, zh-CN | -| validate (VAL) | VAL-MAP | validate/error-code-mapping.json | 5 | 0 | zh-CN | -| validate (VAL) | VAL-MERGE | validate/merge-and-schema.json | 6 | 0 | en-US, zh-CN | -| validate (VAL) | VAL-PROG | validate/programming-errors.json | 17 | 0 | zh-CN | -| validate (VAL) | VAL-RETRY | validate/llm-shape-and-retry.json | 9 | 0 | en-US, zh-CN | -| validate (VAL) | VAL-RULE | validate/rule-gate.json | 8 | 0 | en-US, zh-CN | -| validate (VAL) | VAL-SEM | validate/semantic-verdicts.json | 17 | 0 | en-US, zh-CN | -| scenarios (SC) | SC-ERR | scenarios/boundary-flows.json | 7 | 33 | zh-CN | -| scenarios (SC) | SC-EXH | scenarios/boundary-flows.json | 2 | 11 | zh-CN | -| scenarios (SC) | SC-FSB | scenarios/feasibility-flows.json | 4 | 18 | en-US, zh-CN | -| scenarios (SC) | SC-INFO | scenarios/information-flows.json | 3 | 17 | en-US, zh-CN | -| scenarios (SC) | SC-TGT | scenarios/target-flows.json | 4 | 14 | zh-CN | - -## API x category coverage - -Cells count base records (case records plus scenario steps) per API and category; empty cells are coverage gaps. - -| Category | generateProposeFromText | generateAcceptFromText | generateRejectFromText | generateAbortFromText | generateProposeFromData | generateAcceptFromData | generateRejectFromData | generateAbortFromData | validateProposePromptAndDataFilling | validateAcceptPromptAndDataFilling | validateRejectPromptAndDataFilling | validateAbortPromptAndDataFilling | generateTaskPromptFromText | generateTaskPromptFromDataWithSchema | validateTaskPromptAndDataFilling | Total | -|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| FT-EXTRACT | 7 | 4 | 2 | 2 | - | - | - | - | - | - | - | - | - | - | - | 15 | -| FT-HAPPY | 6 | 3 | 3 | 1 | - | - | - | - | - | - | - | - | - | - | - | 13 | -| FT-PROG | 3 | 3 | 1 | 1 | - | - | - | - | - | - | - | - | - | - | - | 8 | -| FT-RETRY | 5 | 6 | 1 | 1 | - | - | - | - | - | - | - | - | - | - | - | 13 | -| FT-TPL | 1 | 1 | 1 | 1 | - | - | - | - | - | - | - | - | - | - | - | 4 | -| FD-HAPPY | - | - | - | - | 4 | 3 | 3 | 1 | - | - | - | - | - | - | - | 11 | -| FD-PROG | - | - | - | - | 8 | 4 | 2 | - | - | - | - | - | - | - | - | 14 | -| VAL-DRIFT | - | - | - | - | - | - | - | - | 2 | 2 | - | - | - | - | - | 4 | -| VAL-HAPPY | - | - | - | - | - | - | - | - | 3 | 3 | 3 | 1 | - | - | - | 10 | -| VAL-MAP | - | - | - | - | - | - | - | - | 5 | - | - | - | - | - | - | 5 | -| VAL-MERGE | - | - | - | - | - | - | - | - | 2 | 2 | 1 | 1 | - | - | - | 6 | -| VAL-PROG | - | - | - | - | - | - | - | - | 5 | 4 | 4 | 4 | - | - | - | 17 | -| VAL-RETRY | - | - | - | - | - | - | - | - | 4 | 4 | 1 | - | - | - | - | 9 | -| VAL-RULE | - | - | - | - | - | - | - | - | 8 | - | - | - | - | - | - | 8 | -| VAL-SEM | - | - | - | - | - | - | - | - | 10 | 2 | 2 | 3 | - | - | - | 17 | -| SC-ERR | 6 | 7 | - | - | - | - | - | - | 2 | 10 | - | - | 4 | - | 4 | 33 | -| SC-EXH | 3 | - | 2 | 1 | - | - | - | - | 2 | - | - | 1 | 1 | - | 1 | 11 | -| SC-FSB | 6 | 3 | 2 | 1 | - | - | - | - | 2 | 2 | 1 | 1 | - | - | - | 18 | -| SC-INFO | 4 | 2 | 1 | 1 | - | - | - | - | - | 2 | - | 1 | 2 | 1 | 3 | 17 | -| SC-TGT | 6 | 3 | 2 | 1 | - | - | - | - | - | 2 | - | - | - | - | - | 14 | -| **Total** | **47** | **32** | **15** | **10** | **12** | **7** | **5** | **1** | **45** | **33** | **12** | **12** | **7** | **1** | **8** | **247** | - -## API x expected outcome coverage - -Cells count base records per API and expectation; the `exception` column aggregates failures that assert only an exception type (failures carrying both an exception and a code are counted under their code). - -| API | `success` | `llm.invocation_failed` | `llm.response_invalid` | `negotiation.conclusion_mismatch` | `negotiation.content_extract_failed` | `negotiation.content_invalid` | `negotiation.field_missing` | `negotiation.invalid_input` | `negotiation.rule_violation` | `negotiation.semantic_rejected` | `template.not_found` | `exception` | Total | -|---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| `generateProposeFromText` | 32 | 1 | 3 | - | 4 | - | 1 | 3 | - | - | 1 | 2 | 47 | -| `generateAcceptFromText` | 20 | 1 | 1 | 2 | 1 | - | 3 | 1 | - | - | 1 | 2 | 32 | -| `generateRejectFromText` | 10 | 1 | - | 1 | - | - | 1 | - | - | - | 1 | 1 | 15 | -| `generateAbortFromText` | 6 | - | - | - | - | - | 2 | - | - | - | 1 | 1 | 10 | -| `generateProposeFromData` | 4 | - | - | - | - | 2 | - | - | - | - | - | 6 | 12 | -| `generateAcceptFromData` | 3 | - | - | 2 | - | 2 | - | - | - | - | - | - | 7 | -| `generateRejectFromData` | 3 | - | - | 1 | - | 1 | - | - | - | - | - | - | 5 | -| `generateAbortFromData` | 1 | - | - | - | - | - | - | - | - | - | - | - | 1 | -| `validateProposePromptAndDataFilling` | 17 | 1 | 2 | - | - | - | - | 3 | 7 | 10 | 1 | 4 | 45 | -| `validateAcceptPromptAndDataFilling` | 23 | 1 | 2 | - | - | - | - | 2 | - | 2 | - | 3 | 33 | -| `validateRejectPromptAndDataFilling` | 6 | - | - | - | - | - | - | 2 | - | 1 | - | 3 | 12 | -| `validateAbortPromptAndDataFilling` | 6 | - | - | - | - | - | - | 2 | - | 1 | - | 3 | 12 | -| `generateTaskPromptFromText` | 7 | - | - | - | - | - | - | - | - | - | - | - | 7 | -| `generateTaskPromptFromDataWithSchema` | 1 | - | - | - | - | - | - | - | - | - | - | - | 1 | -| `validateTaskPromptAndDataFilling` | 8 | - | - | - | - | - | - | - | - | - | - | - | 8 | - -## Case index - -### FT-EXTRACT -- from-text/extraction-failures.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| FT-EXTRACT-01 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.field_missing) | 补数接受消息抽取结果缺少结论字段时生成失败并返回槽位缺失错误码,不发起重试 | -| FT-EXTRACT-02 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.conclusion_mismatch) | 补数接受消息抽取出的结论为拒绝时判定为结论与阶段不符,立即失败并返回结论不符错误码 | -| FT-EXTRACT-03 | `generateRejectFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.conclusion_mismatch) | 无法提供端口名的拒绝消息抽取出的结论为接受时判定为结论与阶段不符,立即失败并返回结论不符错误码 | -| FT-EXTRACT-04 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.field_missing) | 要数提议消息抽取结果缺少信息项列表时生成失败并返回槽位缺失错误码 | -| FT-EXTRACT-05 | `generateProposeFromText` | en-US | P1 | (NegotiationGenerationException, negotiation.content_extract_failed) | 要数信息项列表被抽取为字符串而非数组时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码 | -| FT-EXTRACT-06 | `generateProposeFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation.content_extract_failed) | 要数信息项缺少名称字段时内容抽取失败,返回抽取失败错误码 | -| FT-EXTRACT-07 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_extract_failed) | 结论字面量大小写错误(ACCEPT 而非 Accept)时内容抽取失败,返回抽取失败错误码 | -| FT-EXTRACT-08 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.field_missing) | 修复目标接受消息抽取结果缺少最终确认意图时生成失败并返回槽位缺失错误码 | -| FT-EXTRACT-09 | `generateRejectFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.field_missing) | 修复目标拒绝消息抽取结果缺少拒绝原因时生成失败并返回槽位缺失错误码 | -| FT-EXTRACT-10 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.invalid_input) | 端口扩容可行性提议消息抽取结果缺少动作字段时判定为内容矛盾,立即失败并返回输入无效错误码 | -| FT-EXTRACT-11 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_extract_failed) | 动作字段为未知枚举值时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码 | -| FT-EXTRACT-12 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.invalid_input) | 请求可行性评估却未抽取到任何待评估内容时判定为内容矛盾,立即失败并返回输入无效错误码 | -| FT-EXTRACT-13 | `generateAbortFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.field_missing) | 协商终止消息抽取结果缺少终止原因时生成失败并返回槽位缺失错误码 | -| FT-EXTRACT-14 | `generateAbortFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation.field_missing) | 终止原因为空白字符串时等同于缺失,生成失败并返回槽位缺失错误码 | -| FT-EXTRACT-15 | `generateProposeFromText` | zh-CN | P2 | (NegotiationGenerationException, negotiation.content_extract_failed) | 信息项取值为数字而非字符串时内容抽取失败,返回抽取失败错误码 | - -### FT-HAPPY -- from-text/happy.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| FT-HAPPY-01 | `generateProposeFromText` | en-US, zh-CN | P0 | success | OMC 就投诉诊断缺失的接入端口名称与投诉分类发起信息协商,从自然语言生成提议消息,双语渲染均携带三项缺失信息与 OR 关系(golden 等值与 differential 不可表达降级为包含断言:harness 的 llm 脚本与 input.data 不随语言展开,en-US 渲染为英文模板骨架+中文数据,deviation 已记录) | -| FT-HAPPY-02 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 工作台补充接入端口名称与投诉分类后从自然语言生成信息协商接受消息,消息含 Accept 结论字面量且双语渲染均携带补充的业务信息项(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | -| FT-HAPPY-03 | `generateRejectFromText` | en-US, zh-CN | P0 | success | 工作台侧端口资源台账不可查无法提供接入端口名称时从自然语言生成信息协商拒绝消息,消息含 Reject 结论字面量且携带不可提供原因(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | -| FT-HAPPY-04 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 首轮修复目标协商从自然语言生成提议消息,就时延恢复目标与完成时限澄清,首轮渲染意图理解与待澄清内容两个板块(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | -| FT-HAPPY-05 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 时延恢复目标确认后从自然语言生成目标协商接受消息,消息携带最终确认意图并含 Accept 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | -| FT-HAPPY-06 | `generateRejectFromText` | en-US, zh-CN | P0 | success | 光缆割接窗口未确认致时延目标无法澄清时从自然语言生成目标协商拒绝消息,消息携带拒绝原因并含 Reject 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | -| FT-HAPPY-07 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 工作台请 OMC 评估接入端口扩容方案能否在割接窗口内完成,从自然语言生成可行性协商提议消息,携带扩容方案与既有约束两项待评估内容(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | -| FT-HAPPY-08 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 端口扩容可行性评估通过后从自然语言生成可行性协商接受消息,消息携带可行性结论并含 Accept 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | -| FT-HAPPY-09 | `generateRejectFromText` | en-US, zh-CN | P0 | success | 汇聚机房板卡槽位不足致端口扩容不可行时从自然语言生成可行性协商拒绝消息,消息携带不可行结论并含 Reject 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | -| FT-HAPPY-10 | `generateAbortFromText` | en-US, zh-CN | P0 | success | 投诉诊断协商轮次耗尽时从自然语言生成协商终止消息,走通用 abort 模板并含终止原因与 Abort 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录) | -| FT-HAPPY-11 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 目标澄清完成后的确认请求轮从自然语言生成目标协商提议消息:抽取结果 target_confirm_request 非空且三个互斥板块全空,渲染仅含目标协商概述与目标澄清后的确认请求两个板块、携带固定确认措辞(harness 的 llm 脚本与 input.data 不随语言展开,en-US 渲染为英文模板骨架+中文数据,deviation 已记录) | -| FT-HAPPY-12 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 目标达成类可行性确认请求轮从自然语言生成可行性协商提议消息:抽取结果 feasibility_confirm_request 非空、action 为 REQUEST_FEASIBILITY_EVALUATION 且两个互斥板块全空,渲染仅含可行性协商概述与评估可行时的确认请求两个板块、携带目标可行固定措辞(en-US 渲染为英文模板骨架+中文数据,deviation 已记录) | -| FT-HAPPY-13 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 方案可行性类确认请求轮从自然语言生成可行性协商提议消息:抽取结果 feasibility_confirm_request 非空且互斥板块全空,渲染携带方案可行固定措辞,验证固定措辞在目标/方案两条中的二选一(en-US 渲染为英文模板骨架+中文数据,deviation 已记录) | - -### FT-PROG -- from-text/programming-errors.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| FT-PROG-01 | `generateProposeFromText` | zh-CN | P0 | (NullPointerException) | 协商上下文缺失时生成投诉诊断的信息协商提议在管线入口直接失败,抛出空指针异常且不发起任何大模型调用 | -| FT-PROG-02 | `generateAcceptFromText` | zh-CN | P0 | (NullPointerException) | 模板地址缺失时生成信息协商接受消息在地址解析入口直接失败,抛出空指针异常且不发起任何大模型调用 | -| FT-PROG-03 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.invalid_input) | 调用方未传入任何投诉诊断自然语言内容时生成提议消息被拒绝,返回无效输入错误码且不发起任何大模型调用 | -| FT-PROG-04 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, negotiation.invalid_input) | 投诉诊断补充内容仅含空白字符时生成接受消息被拒绝,返回无效输入错误码且不发起任何大模型调用 | -| FT-PROG-05 | `generateProposeFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址误用未发布的 v2 版本段时生成信息协商提议在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | -| FT-PROG-06 | `generateAcceptFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址误用 Task-T 扩展名时生成信息协商接受消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | -| FT-PROG-07 | `generateRejectFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址中协商类型拼写错误时生成信息协商拒绝消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | -| FT-PROG-08 | `generateAbortFromText` | zh-CN | P1 | (IllegalArgumentException) | 模板地址中阶段段未知时生成协商终止消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用 | - -### FT-RETRY -- from-text/retry.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| FT-RETRY-01 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, llm.response_invalid) | 最大尝试次数为1时大模型返回不可解析内容,不发起任何重试即以 LLM 响应无效错误码失败 | -| FT-RETRY-02 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, llm.response_invalid) | 最大尝试次数为2时大模型连续返回空响应与空白内容,尝试耗尽后仍以原始的 LLM 响应无效错误码失败 | -| FT-RETRY-03 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, llm.response_invalid) | 最大尝试次数为3时三种不可解析形态依次出现,三次尝试耗尽后以 LLM 响应无效错误码失败 | -| FT-RETRY-04 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, llm.invocation_failed) | 最大尝试次数为1时底层通道抛出运行时异常,不重试即以 llm.invocation_failed 失败 | -| FT-RETRY-05 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, llm.invocation_failed) | 最大尝试次数为2时底层先后出现传输异常与错误响应,尝试耗尽后仍以 llm.invocation_failed 失败 | -| FT-RETRY-06 | `generateRejectFromText` | zh-CN | P1 | (NegotiationGenerationException, llm.invocation_failed) | 最大尝试次数为3时基础设施错误持续出现,三次尝试耗尽后以 llm.invocation_failed 失败 | -| FT-RETRY-07 | `generateAcceptFromText` | en-US, zh-CN | P0 | success | 首次返回不可解析内容后第二次返回合法补数接受内容,重试后成功生成携带接入端口名称与投诉分类的接受消息(共享应答单一语种,双语 golden 等值断言降级为内容包含) | -| FT-RETRY-08 | `generateProposeFromText` | en-US, zh-CN | P0 | success | 首次底层传输异常后第二次抽取成功,重试后生成向工作台补充三项缺失信息的要数提议消息且异常细节不外泄(共享应答单一语种,双语 golden 等值断言降级为内容包含) | -| FT-RETRY-09 | `generateAcceptFromText` | en-US, zh-CN | P1 | success | 前两次分别出现基础设施错误与不可解析内容,第三次抽取成功,在最后一次尝试额度内恢复生成修复目标接受消息(共享应答单一语种,双语 golden 等值断言降级为内容包含) | -| FT-RETRY-10 | `generateAbortFromText` | en-US, zh-CN | P1 | success | 协商终止消息首次抽取到空白内容后第二次成功,重试后生成含终止原因的协商终止消息(共享应答单一语种,双语 golden 等值断言降级为内容包含) | -| FT-RETRY-11 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.field_missing) | 抽取结果缺少结论字段属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败 | -| FT-RETRY-12 | `generateAcceptFromText` | zh-CN | P0 | (NegotiationGenerationException, negotiation.conclusion_mismatch) | 结论与阶段矛盾属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败 | -| FT-RETRY-13 | `generateProposeFromText` | zh-CN | P0 | (NegotiationGenerationException, llm.response_invalid) | 两次失败形态不同(基础设施错误后接不可解析内容)时,耗尽后重抛最后一次失败的原始错误码(llm.response_invalid)而非统一的耗尽错误码 | - -### FT-TPL -- from-text/template-resolution.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| FT-TPL-01 | `generateAcceptFromText` | zh-CN | P1 | (NegotiationGenerationException, template.not_found) | 模板加载器不可用时生成投诉诊断信息协商接受消息失败并返回 template.not_found 错误码,且不发起任何 LLM 调用 | -| FT-TPL-02 | `generateProposeFromText` | zh-CN | P1 | (NegotiationGenerationException, template.not_found) | 模板加载器不可用时生成要数提议消息失败并返回 template.not_found 错误码,模板加载发生在重试循环之外,即使配置了重试次数也不重试且不发起任何 LLM 调用 | -| FT-TPL-03 | `generateRejectFromText` | zh-CN | P1 | (NegotiationGenerationException, template.not_found) | 模板加载器不可用时生成信息协商拒绝消息失败并返回 template.not_found 错误码,且不发起任何 LLM 调用 | -| FT-TPL-04 | `generateAbortFromText` | zh-CN | P1 | (NegotiationGenerationException, template.not_found) | 模板加载器不可用时生成协商终止消息失败并返回 template.not_found 错误码,通用终止模板同样无法加载且不发起任何 LLM 调用 | - -### FD-HAPPY -- from-data/happy.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| FD-HAPPY-01 | `generateProposeFromData` | en-US, zh-CN | P0 | success | OMC 以结构化数据直接生成信息协商提议消息,向工作台索要接入端口名称与投诉分类等缺失信息项,全程零 LLM 调用 | -| FD-HAPPY-02 | `generateAcceptFromData` | en-US, zh-CN | P0 | success | 工作台以结构化数据直接生成信息协商接受消息,补齐接入端口名称与投诉分类并携带 Accept 结论字面量,全程零 LLM 调用 | -| FD-HAPPY-03 | `generateRejectFromData` | en-US, zh-CN | P0 | success | 工作台以结构化数据直接生成信息协商拒绝消息,以端口资源台账暂不可查为由无法提供接入端口名称并携带 Reject 结论字面量,全程零 LLM 调用 | -| FD-HAPPY-04 | `generateProposeFromData` | en-US, zh-CN | P0 | success | 首轮目标协商从结构化数据直接生成提议消息,围绕专线质差的时延恢复目标与修复完成时限向对端澄清,全程零 LLM 调用 | -| FD-HAPPY-05 | `generateAcceptFromData` | en-US, zh-CN | P0 | success | 以结构化数据直接生成目标协商接受消息,最终确认专线时延恢复至20ms以内与高峰丢包率目标并携带 Accept 结论字面量,全程零 LLM 调用 | -| FD-HAPPY-06 | `generateRejectFromData` | en-US, zh-CN | P0 | success | 以结构化数据直接生成目标协商拒绝消息,以光缆割接窗口尚未确认为由拒绝时延目标并携带 Reject 结论字面量,全程零 LLM 调用 | -| FD-HAPPY-07 | `generateProposeFromData` | en-US, zh-CN | P0 | success | 以结构化数据直接生成可行性协商提议消息,请求对端评估接入端口带宽扩容能否在割接窗口内完成,全程零 LLM 调用 | -| FD-HAPPY-08 | `generateAcceptFromData` | en-US, zh-CN | P0 | success | 以结构化数据直接生成可行性协商接受消息,确认端口扩容可在割接窗口内完成且满足业务中断约束并携带 Accept 结论字面量,全程零 LLM 调用 | -| FD-HAPPY-09 | `generateRejectFromData` | en-US, zh-CN | P0 | success | 以结构化数据直接生成可行性协商拒绝消息,以汇聚机房板卡槽位不足为由判定扩容不可行并携带 Reject 结论字面量,全程零 LLM 调用 | -| FD-HAPPY-10 | `generateAbortFromData` | en-US, zh-CN | P0 | success | 以结构化数据直接生成协商终止消息,以达到协商轮次上限为由终止本次专线投诉诊断协商并携带 Abort 结论字面量,全程零 LLM 调用 | -| FD-HAPPY-11 | `generateProposeFromData` | en-US, zh-CN | P0 | success | 接入端口名称与投诉分类两项缺失信息均为必填时以与关系生成信息协商提议消息,两项同时列为缺失项,全程零 LLM 调用 | - -### FD-PROG -- from-data/programming-errors.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| FD-PROG-01 | `generateProposeFromData` | zh-CN | P1 | (NullPointerException) | 结构化提议数据缺少协商上下文时生成立即以空指针异常失败,提示上下文必填且不发起任何 LLM 调用 | -| FD-PROG-02 | `generateAcceptFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.conclusion_mismatch) | 接受方法收到携带 Reject 结论的结构化数据时生成以结论不符错误码失败,提示结论与接受方法预期不符且不发起任何 LLM 调用 | -| FD-PROG-03 | `generateRejectFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.conclusion_mismatch) | 拒绝方法收到携带 Accept 结论的结构化数据时生成以结论不符错误码失败,提示结论与拒绝方法预期不符且不发起任何 LLM 调用 | -| FD-PROG-04 | `generateAcceptFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.conclusion_mismatch) | 接受方法收到携带 Abort 结论的结构化数据时生成以结论不符错误码失败,提示终止结论不属于类型化模板且不发起任何 LLM 调用 | -| FD-PROG-05 | `generateAcceptFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_invalid) | 目标协商接受数据缺少最终确认意图时生成以协商内容无效错误码失败,提示接受消息必须携带确认意图且不发起任何 LLM 调用 | -| FD-PROG-06 | `generateAcceptFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_invalid) | 目标协商接受数据的最终确认意图为空白字符串时生成以协商内容无效错误码失败,提示确认意图不能为空白且不发起任何 LLM 调用 | -| FD-PROG-07 | `generateRejectFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_invalid) | 目标协商拒绝数据缺少拒绝原因时生成以协商内容无效错误码失败,提示拒绝消息必须携带失败原因且不发起任何 LLM 调用 | -| FD-PROG-08 | `generateProposeFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_invalid) | 可行性评估请求的结构化提议数据缺少待评估内容时生成以协商内容无效错误码失败,提示至少需要一条评估项且不发起任何 LLM 调用 | -| FD-PROG-09 | `generateProposeFromData` | zh-CN | P1 | (NegotiationGenerationException, negotiation.content_invalid) | 不可行替代方案的结构化提议数据缺少不可行细节与方案时生成以协商内容无效错误码失败,提示至少需要一条方案项且不发起任何 LLM 调用 | -| FD-PROG-10 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 模板 URI 前缀指向 Task-T 而非 Negotiation-T 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用 | -| FD-PROG-11 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 模板 URI 版本段为 v2 而非内置 v1 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用 | -| FD-PROG-12 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 模板 URI 类型段使用下划线拼写 information_negotiation 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用 | -| FD-PROG-13 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 模板 URI 类型段指向未知的 unknown-negotiation 类型时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用 | -| FD-PROG-14 | `generateProposeFromData` | zh-CN | P1 | (IllegalArgumentException) | 提议方法收到的模板 URI 阶段段为 accept-reject 而非 propose 时生成以参数异常失败,提示 URI 阶段与方法不符且不发起任何 LLM 调用 | - -### VAL-DRIFT -- validate/drift-probes.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| VAL-DRIFT-01 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 对端补数接受报文的条目编号与分隔标点相对模板惯例存在差异(1.与冒号改为1、与空格),仍应校验通过并提取接入端口名称与投诉分类 | -| VAL-DRIFT-02 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 对端要数提议报文将缺失项关系从OR字面量改写为文字化措辞,语序微变仍应校验通过并提取投诉参数 | -| VAL-DRIFT-03 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 对端英文补数接受报文的条目编号改为右括号、键值分隔改为长破折号,措辞漂移仍应校验通过并提取接入端口名称与投诉分类 | -| VAL-DRIFT-04 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 对端英文要数提议报文将缺失项关系改写为完整句子并补充限定词,措辞漂移仍应校验通过并提取投诉参数 | - -### VAL-HAPPY -- validate/happy.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| VAL-HAPPY-01 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 以OMC发来的信息协商提议报文全文为输入,校验通过并合并出上下文三键与接入端口名称、投诉分类参数 | -| VAL-HAPPY-02 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | golden 回归:工作台补数接受报文的规范模板形态校验通过,合并参数与对端报文主线用例完全等值 | -| VAL-HAPPY-03 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P0 | success | 以工作台发来的信息协商拒绝报文全文为输入,校验通过并合并出上下文三键与语义层提取的投诉参数 | -| VAL-HAPPY-04 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 以对端发来的修复目标协商提议报文全文为输入,校验通过并合并出上下文三键与时延目标、修复时限参数 | -| VAL-HAPPY-05 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | golden 回归:修复目标确认接受报文的规范模板形态校验通过,合并参数与对端报文主线用例完全等值 | -| VAL-HAPPY-06 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P0 | success | 以对端发来的修复目标协商拒绝报文全文为输入(割接窗口未确认),校验通过并合并出上下文三键与时延目标、修复时限参数 | -| VAL-HAPPY-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 以对端发来的端口扩容可行性协商提议报文全文为输入,校验通过并合并出上下文三键与扩容可行性结论参数 | -| VAL-HAPPY-08 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 以对端发来的端口扩容可行性确认接受报文全文为输入,校验通过并合并出上下文三键与扩容可行性结论参数 | -| VAL-HAPPY-09 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P0 | success | 以对端发来的端口扩容不可行拒绝报文全文为输入(板卡槽位不足),校验通过并合并出上下文三键与扩容可行性结论参数 | -| VAL-HAPPY-10 | `validateAbortPromptAndDataFilling` | en-US, zh-CN | P0 | success | golden 回归:轮次耗尽协商终止报文的规范模板形态校验通过,合并出上下文三键与终止原因参数 | - -### VAL-MAP -- validate/error-code-mapping.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| VAL-MAP-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.invalid_input) | 消息未携带协商上下文元数据时校验判定其不是协商消息,按非协商消息拒绝并返回协商无效输入错误码且不发起任何大模型调用 | -| VAL-MAP-02 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.rule_violation) | 协商轮次超限触发协商规则违反错误码,并保留轮次越界槽位错误 | -| VAL-MAP-03 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 大模型语义校验否决要数提议消息时返回协商语义拒绝错误码,并保留大模型报告的接入端口缺失槽位错误 | -| VAL-MAP-04 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, llm.invocation_failed) | 大模型服务连续两次不可用并耗尽重试后,以 llm.invocation_failed 错误码失败,错误以 _llm 伪槽位保留 | -| VAL-MAP-05 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, template.not_found) | 语义校验提示词资源缺失时映射为 template.not_found 错误码,原始资源异常不透出且不发起任何大模型调用 | - -### VAL-MERGE -- validate/merge-and-schema.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| VAL-MERGE-01 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 业务参数与上下文 id、round 键冲突时上下文参数优先,大模型提取的错误取值不覆盖规则层结果 | -| VAL-MERGE-02 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 调用方未声明 type 关键字的参数 schema 被自动包装为对象 schema,补数接受报文的语义校验与投诉参数提取不受影响 | -| VAL-MERGE-03 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验要数提议消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用 | -| VAL-MERGE-04 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验补数接受消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用 | -| VAL-MERGE-05 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验拒绝消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用 | -| VAL-MERGE-06 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验终止消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用 | - -### VAL-PROG -- validate/programming-errors.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| VAL-PROG-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验要数提议消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | -| VAL-PROG-02 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验要数提议消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针) | -| VAL-PROG-03 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验补数接受消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | -| VAL-PROG-04 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验补数接受消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针) | -| VAL-PROG-05 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验修复目标拒绝消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | -| VAL-PROG-06 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验修复目标拒绝消息时消息文本为空白,抛出参数异常(文档声明未验证,doc-gap 探针) | -| VAL-PROG-07 | `validateAbortPromptAndDataFilling` | zh-CN | P0 | (NullPointerException) | 校验终止消息时未传入消息文本,直接抛出空指针异常且不调用大模型 | -| VAL-PROG-08 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验终止消息时消息文本为空白,抛出参数异常(abort 方法的 javadoc 未声明该行为,doc-gap 探针) | -| VAL-PROG-09 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验要数提议消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input | -| VAL-PROG-10 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验补数接受消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input | -| VAL-PROG-11 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验修复目标拒绝消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input | -| VAL-PROG-12 | `validateAbortPromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.invalid_input) | 校验终止消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input | -| VAL-PROG-13 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验要数提议消息时模板 URI 声明的是接受-拒绝阶段,阶段不匹配抛出参数异常且不调用大模型 | -| VAL-PROG-14 | `validateAcceptPromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验补数接受消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型 | -| VAL-PROG-15 | `validateRejectPromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验修复目标拒绝消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型 | -| VAL-PROG-16 | `validateAbortPromptAndDataFilling` | zh-CN | P0 | (IllegalArgumentException) | 校验终止消息时模板 URI 声明的是类型化提议模板而非通用终止模板,抛出参数异常且不调用大模型 | -| VAL-PROG-17 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (IllegalArgumentException) | 校验要数提议消息时误用通用终止模板 URI,类型化阶段不接受 common 段,抛出参数异常 | - -### VAL-RETRY -- validate/llm-shape-and-retry.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| VAL-RETRY-01 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P1 | success | 工作台补数接受报文的语义校验第一次遭遇 LLM 基础设施错误后自动重试,第二次返回合法判定并成功提取投诉参数 | -| VAL-RETRY-02 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 要数提议报文的语义返回缺少 negotiation_type 键的无效结构时触发重试,第二次结构合法并成功提取投诉参数 | -| VAL-RETRY-03 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, llm.response_invalid) | 语义返回连续三种无效结构时重试至耗尽,最终携带 _llm 伪槽位以 llm.response_invalid 错误失败 | -| VAL-RETRY-04 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, llm.response_invalid) | maxAttempts 配置为 1 时语义结构错误不获重试机会,单次调用即以 llm.response_invalid 错误失败 | -| VAL-RETRY-05 | `validateAcceptPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, llm.invocation_failed) | LLM 传输错误连续出现时按 maxAttempts=2 重试两次后耗尽,携带 _llm 伪槽位以 llm.invocation_failed 失败 | -| VAL-RETRY-06 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P1 | success | 工作台拒绝报文的语义返回无法解析的非 JSON 内容后按 maxAttempts=2 重试,第二次返回合法判定并成功 | -| VAL-RETRY-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 要数提议报文的语义连续两次空内容与非 JSON 应答后第三次成功,重试预算 maxAttempts=3 恰好用满 | -| VAL-RETRY-08 | `validateAcceptPromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, llm.response_invalid) | 语义步骤抛出非 LLMError 的原始运行时异常时以 llm.response_invalid 错误失败(原始运行时异常现已与其他 LLM 步骤失败一样按预算重试,本探针以 maxAttempts=1 固化单次调用形态) | -| VAL-RETRY-09 | `validateAcceptPromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, llm.response_invalid) | 语义步骤返回空响应(null)时以 llm.response_invalid 错误失败(空响应现已按预算重试,本探针以 maxAttempts=1 固化单次调用形态) | - -### VAL-RULE -- validate/rule-gate.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| VAL-RULE-01 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.rule_violation) | 协商会话标识仅 8 位而非 36 位 UUID 时,消息在规则校验阶段即被拒绝并报告标识格式槽位错误,不发起任何大模型调用 | -| VAL-RULE-02 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.rule_violation) | 协商会话标识为 35 位(末段缺少一位十六进制位)时同样被规则门拒绝,UUID 长度边界从严执行 | -| VAL-RULE-03 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation.rule_violation) | 协商会话标识长度与 8-4-4-4-12 分段均正确但首段含非十六进制字符 g 时,规则门仍按 UUID 格式拒绝 | -| VAL-RULE-04 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 当前轮次等于最大轮次预算(第 5 轮即最后一轮)时OMC要数提议报文仍可通过规则校验并正常提取投诉参数,只有超出预算才被拒绝 | -| VAL-RULE-05 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.rule_violation) | 协商轮次超出最大轮次预算(第 6 轮对 5 轮上限)时规则门拒绝消息并报告轮次越界槽位错误,不发起任何大模型调用 | -| VAL-RULE-06 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.rule_violation) | 会话标识非法且轮次超限同时发生时,规则门一次性报告标识格式与轮次越界两条槽位错误,不发起任何大模型调用 | -| VAL-RULE-07 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P1 | success | 单轮预算(最大轮次为 1)的第 1 轮要数提议报文可通过规则校验并正常提取投诉参数,轮次下限边界同样放行 | -| VAL-RULE-08 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P2 | success | 协商会话标识使用大写十六进制 UUID 时规则门同样放行,要数提议报文正常校验并提取投诉参数,合并结果原样保留大写标识 | - -### VAL-SEM -- validate/semantic-verdicts.json - -| Id | API | Languages | Priority | Expectation | Summary | -|---|---|---|---|---|---| -| VAL-SEM-01 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 工作台补数接受报文语义判定通过且上报类型与信息协商模板一致时,校验成功并合并出接入端口名称与投诉分类参数 | -| VAL-SEM-02 | `validateAcceptPromptAndDataFilling` | en-US, zh-CN | P0 | success | 修复目标确认接受报文语义判定通过且上报类型与目标协商模板一致时,校验成功并合并出时延目标与修复时限参数 | -| VAL-SEM-03 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P0 | success | 端口扩容可行性提议报文语义判定通过且上报类型与可行性协商模板一致时,校验成功并合并出扩容可行性结论参数 | -| VAL-SEM-04 | `validateAbortPromptAndDataFilling` | en-US, zh-CN | P0 | success | 轮次耗尽终止报文语义判定通过且未上报协商类型时校验成功,合并出终止原因参数 | -| VAL-SEM-05 | `validateRejectPromptAndDataFilling` | en-US, zh-CN | P1 | success | 工作台无法提供端口名的拒绝报文语义判定通过且类型一致时校验成功,类型一致性不区分接受或拒绝动作 | -| VAL-SEM-06 | `validateAbortPromptAndDataFilling` | en-US, zh-CN | P2 | success | 公共终止模板未声明协商类型,判定通过时即使大模型上报了类型也照常校验成功 | -| VAL-SEM-07 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 大模型判定通过但上报成目标协商类型时,信息协商要数提议被语义拒绝并指出类型不一致 | -| VAL-SEM-08 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 大模型判定通过但未上报协商类型时,信息协商要数提议被语义拒绝并按模板类型报类型不一致 | -| VAL-SEM-09 | `validateProposePromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 大模型判定通过但上报未知协商类型字符串时,信息协商要数提议被语义拒绝 | -| VAL-SEM-10 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 大模型上报的协商类型仅大小写不同也被视为类型不一致,信息协商要数提议被语义拒绝 | -| VAL-SEM-11 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 大模型语义判定不通过时,信息协商要数提议被拒绝并返回缺失的接入端口参数槽位错误 | -| VAL-SEM-12 | `validateRejectPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 语义判定不通过时不再核对协商类型,拒绝侧消息只返回大模型报告的槽位错误 | -| VAL-SEM-13 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 语义判定不通过且未报告任何槽位错误时,校验仍以语义拒绝失败且槽位错误为空 | -| VAL-SEM-14 | `validateProposePromptAndDataFilling` | zh-CN | P2 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 语义判定不通过时已提取的投诉参数被丢弃,校验以语义拒绝失败且不合并任何业务参数 | -| VAL-SEM-15 | `validateAbortPromptAndDataFilling` | zh-CN | P1 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 终止消息语义判定不通过时被拒绝并返回缺失终止原因的槽位错误 | -| VAL-SEM-16 | `validateProposePromptAndDataFilling` | zh-CN | P0 | (NegotiationParamExtractionException, negotiation.semantic_rejected) | 语义拒绝不触发重试:脚本备有第二次通过的应答也不会被消费,LLM 调用数恒为 1 | -| VAL-SEM-17 | `validateProposePromptAndDataFilling` | en-US, zh-CN | P2 | success | 语义判定通过时大模型附带的槽位错误被忽略,要数提议报文校验成功且只合并上下文三键 | - -## Scenario index - -### SC-ERR -- scenarios/boundary-flows.json - -| Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | -|---|---|---|---|---|---|---| -| SC-ERR-01 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 闭环中错误恢复:工作台首次生成任务报文成功,OMC 校验任务报文时语义校验首次返回不可解析内容,重试第二次成功后闭环继续推进,直至缺参被补齐且接受报文提取成功(不可解析探针挂语义校验步骤:任务槽位抽取路径无重试,maxAttempts 仅作用于语义校验) | -| SC-ERR-02 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | (negotiation.semantic_rejected) | 闭环中错误恢复:工作台首轮补数仅提供投诉分类缺接入端口名称,被 OMC 语义校验拒绝,第2轮补充端口名后重新接受并提取成功,缺参最终补齐 | -| SC-ERR-03 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2 | - | 重传幂等:同一条接受补数报文被 OMC 重复校验两次,两次提取的 FilledParamData(接入端口名称与投诉分类)完全一致,重传不产生副作用 | -| SC-ERR-04 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=2 | - | 乱序失同步探针:工作台以滞后的 round 1 上下文校验 OMC 在 round 2 生成的要数提议,规则门不拦截失同步(doc-gap 探针) | -| SC-ERR-05 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=1 | (negotiation.semantic_rejected) | 截断消息探针:传输中被截断的要数提议缺失后两项信息,工作台侧语义校验以槽位缺失为由拒绝 | -| SC-ERR-06 | `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2 | (negotiation.semantic_rejected) | 编码边界探针:带 UTF-8 BOM 前缀或全角标点的接受补数报文可正常校验提取,GBK 乱码形态的报文被语义校验拒绝 | -| SC-ERR-07 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 双会话交错:event-id-20260511-09013 与 fault-id-1-017-20260516-11234 两条投诉工单的缺参协商闭环步骤交替执行,各自提取的上下文与业务参数互不串扰 | - -### SC-EXH -- scenarios/boundary-flows.json - -| Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | -|---|---|---|---|---|---|---| -| SC-EXH-01 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText`, `validateAbortPromptAndDataFilling` | zh-CN | - | exhausted, rounds=3 | - | 轮次预算耗尽闭环:投诉诊断任务缺参后两轮要数均因工作台端口台账不可查被拒,round 3 达到 maxRounds 上限后 OMC 生成终止消息结束协商 | -| SC-EXH-02 | `generateProposeFromText`, `validateProposePromptAndDataFilling` | zh-CN | - | rounds=6 | (negotiation.rule_violation) | 过期重放边界:工作台以 round 6 超出轮次预算的上下文重放校验 OMC 的要数提议,被规则门直接拦截且不调用大模型,修正为 round 5 后重放校验通过 | - -### SC-FSB -- scenarios/feasibility-flows.json - -| Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | -|---|---|---|---|---|---|---| -| SC-FSB-01 | `generateProposeFromText`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=1, distinct-messages | - | 端口扩容可行性协商单轮闭环(双语):工作台就接入端口带宽扩容能否在割接窗口内完成发起可行性评估请求,OMC 评估通过后生成接受消息,工作台校验确认达成一致 | -| SC-FSB-02 | `generateProposeFromText`, `generateRejectFromText`, `validateRejectPromptAndDataFilling`, `validateProposePromptAndDataFilling`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 端口扩容可行性协商两轮演进:round 1 因汇聚机房板卡槽位不足评估不可行并拒绝,工作台于 round 2 改提更换高容量板卡并拆分割接窗口的替代方案,OMC 评估通过并接受 | -| SC-FSB-03 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText`, `validateAbortPromptAndDataFilling` | zh-CN | - | abort, rounds=2, distinct-messages | - | 端口扩容可行性协商中止:round 1 评估因汇聚机房板卡槽位不足不可行且双方无法推进,round 2 生成终止消息并经校验确认协商提前终止 | -| SC-FSB-04 | `generateProposeFromText`, `generateAcceptFromText` | zh-CN | - | accept, rounds=2, distinct-messages | - | 端口扩容可行性协商确认请求闭环:工作台 round 1 发起扩容评估请求,OMC 评估可行后于 round 2 发出评估可行并请求确认的提议,工作台同意后生成接受消息完成闭环 | - -### SC-INFO -- scenarios/information-flows.json - -| Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | -|---|---|---|---|---|---|---| -| SC-INFO-01 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | en-US, zh-CN | - | accept, rounds=2, distinct-messages | - | 投诉诊断五步闭环(双语):工作台从投诉文本生成缺参任务报文,OMC 校验发现缺接入端口名称与投诉分类后发起信息协商要数,工作台接受补数,OMC 提取补参补齐闭环 | -| SC-INFO-02 | `generateTaskPromptFromDataWithSchema`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateRejectFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 投诉诊断闭环两轮演进:工作台首轮因端口资源台账暂不可查拒绝补数,OMC 第2轮重新要数,台账恢复后工作台接受补数,OMC 提取补参补齐闭环 | -| SC-INFO-03 | `generateTaskPromptFromText`, `validateTaskPromptAndDataFilling`, `generateProposeFromText`, `generateAbortFromText`, `validateAbortPromptAndDataFilling` | zh-CN | - | abort, rounds=2, distinct-messages | - | 投诉诊断闭环中断:任务报文缺参协商发起后,投诉被 OSS 侧工单撤销,工作台于第2轮生成终止消息结束协商 | - -### SC-TGT -- scenarios/target-flows.json - -| Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary | -|---|---|---|---|---|---|---| -| SC-TGT-01 | `generateProposeFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 修复目标协商单轮闭环:工作台就专线质差修复目标发起目标协商提议,OMC 确认最终意图生成接受消息,工作台校验提取时延目标与完成时限参数 | -| SC-TGT-02 | `generateProposeFromText`, `generateRejectFromText`, `generateAcceptFromText`, `validateAcceptPromptAndDataFilling` | zh-CN | - | accept, rounds=2, distinct-messages | - | 修复目标协商两轮演进:首轮因光缆割接窗口尚未确认被拒,工作台对齐时延目标后于第2轮重新提议,OMC 确认接受且参数校验提取成功 | -| SC-TGT-03 | `generateProposeFromText`, `generateRejectFromText`, `generateAbortFromText` | zh-CN | - | abort, rounds=2, distinct-messages | - | 修复目标协商中断:光缆割接窗口持续无法确认,修复目标在轮次预算内无法完成澄清,于第2轮生成终止消息结束协商 | -| SC-TGT-04 | `generateProposeFromText`, `generateAcceptFromText` | zh-CN | - | accept, rounds=2, distinct-messages | - | 修复目标协商确认请求闭环:工作台 round 1 发起意图理解澄清提议,OMC 完成澄清对齐后于 round 2 发出目标已澄清并请求确认的提议,工作台同意后生成接受消息完成闭环 | - diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/corpus-schema.json b/a2a-t-corpus/src/test/resources/negotiation-cases/corpus-schema.json deleted file mode 100644 index 9a9e1fca..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/corpus-schema.json +++ /dev/null @@ -1,757 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://openan.net/a2a-t-sdk/negotiation-cases/corpus-schema.json", - "title": "A2A-T negotiation test corpus format", - "description": "First-hand definition of the data-driven negotiation test corpus consumed by the negotiation testdata harness (NegotiationCaseLoader and the later CaseEngine suites). Every corpus file is a JSON array of records: case files carry case records, scenario files carry scenario records, live files carry live-LLM case records, and the two shared files carry reusable LLM payloads and JSON Schema variants. The loader parses everything in strict mode, so an unknown key is an error, never a silently ignored typo.", - "formatVersion": 2, - "$defs": { - "language": { - "title": "Language tag of the bundled negotiation resources", - "type": "string", - "enum": ["zh-CN", "en-US"] - }, - "api": { - "title": "API under test", - "description": "Exactly the twelve methods of the negotiation content service plus the three closed-loop task APIs (the A2ATClient task generation methods and the A2ATServer task validation method, reached through the mirrored prompt-module production wiring); the CaseEngine dispatches on this value.", - "type": "string", - "enum": [ - "generateProposeFromText", - "generateAcceptFromText", - "generateRejectFromText", - "generateAbortFromText", - "generateProposeFromData", - "generateAcceptFromData", - "generateRejectFromData", - "generateAbortFromData", - "validateProposePromptAndDataFilling", - "validateAcceptPromptAndDataFilling", - "validateRejectPromptAndDataFilling", - "validateAbortPromptAndDataFilling", - "generateTaskPromptFromText", - "generateTaskPromptFromDataWithSchema", - "validateTaskPromptAndDataFilling" - ] - }, - "priority": { - "title": "Test priority", - "description": "P0 contract, P1 boundary and retry behavior, P2 rare probes.", - "type": "string", - "enum": ["P0", "P1", "P2"] - }, - "inject": { - "title": "Harness injection hook", - "description": "Maps a corpus case onto a real builder injection point; failingTemplateLoader triggers template.not_found on the generation family, failingSemanticValidator replays the missing semantic validation prompt resources of the validate family (mapped onto template.not_found by the validation pipeline).", - "type": "string", - "enum": ["failingTemplateLoader", "failingSemanticValidator"] - }, - "templateUri": { - "title": "Content template URI", - "description": "Intentionally permissive: programming-error cases carry deliberately malformed URIs (wrong version segment, wrong extension name, misspelled type, unknown phase). Task-family records address the Task-T templates, such as Task-T/network-layer/private-line-complaint/v1.", - "type": "string", - "minLength": 1 - }, - "context": { - "title": "Negotiation context", - "description": "Inline on every case and every scenario step; scenario steps restate it per step so the round evolution stays visible. A JSON null expresses the null-context probes of the validate family.", - "type": "object", - "additionalProperties": false, - "required": ["id", "round", "maxRounds"], - "properties": { - "id": { - "type": "string", - "minLength": 1 - }, - "round": { - "type": "integer", - "minimum": 1 - }, - "maxRounds": { - "type": "integer", - "minimum": 1 - } - } - }, - "input": { - "title": "API input", - "description": "text maps each language to its free-text input; data carries the language-independent typed content of the from-data family. Happy-path from-text cases may carry both for the differential double run.", - "type": "object", - "additionalProperties": false, - "anyOf": [ - { - "required": ["text"] - }, - { - "required": ["data"] - } - ], - "properties": { - "text": { - "type": "object", - "propertyNames": { - "$ref": "#/$defs/language" - }, - "additionalProperties": { - "type": "string" - } - }, - "data": { - "type": "object" - } - } - }, - "failMarker": { - "title": "Scripted LLM failure marker", - "description": "runtime-exception throws an infrastructure error, llm-error answers an LLM error response, null-response returns null, blank-content returns a blank payload, non-json returns an unparseable payload, assertion is the zero-call proof of the from-data and differential runs.", - "type": "string", - "enum": ["runtime-exception", "llm-error", "null-response", "blank-content", "non-json", "assertion"] - }, - "llmScriptStep": { - "title": "One scripted LLM answer", - "oneOf": [ - { - "title": "Shared response reference", - "description": "Resolves to the named payload in shared/llm-responses.json; the scope is locked to the responses/ prefix.", - "type": "object", - "additionalProperties": false, - "required": ["$ref"], - "properties": { - "$ref": { - "type": "string", - "pattern": "^responses/\\S+$" - } - } - }, - { - "title": "Scripted failure", - "type": "object", - "additionalProperties": false, - "required": ["$fail"], - "properties": { - "$fail": { - "$ref": "#/$defs/failMarker" - } - } - }, - { - "title": "Inline payload", - "description": "A literal JSON string carrying the payload text verbatim.", - "type": "string" - } - ] - }, - "llm": { - "title": "Scripted LLM behavior", - "description": "maxAttempts is a builder-level value (clamping belongs to the handwritten suites). The script is consumed step by step; exhausting it fails the run instead of repeating the last answer.", - "type": "object", - "additionalProperties": false, - "required": ["script"], - "properties": { - "maxAttempts": { - "type": "integer", - "minimum": 1 - }, - "script": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/llmScriptStep" - } - } - } - }, - "prompt": { - "title": "Prompt input of the validate family", - "description": "Exactly one of the three: a golden fixture name resolved per language, an inline prompt text, or the step whose generated prompt text feeds this validation step.", - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": ["golden"], - "properties": { - "golden": { - "type": "string", - "minLength": 1 - } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["text"], - "properties": { - "text": { - "type": "string" - } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["fromStep"], - "properties": { - "fromStep": { - "type": "integer", - "minimum": 1 - } - } - } - ] - }, - "schemaReference": { - "title": "Shared schema reference", - "description": "Resolves to the named JSON Schema variant in shared/schemas.json; the scope is locked to the schemas/ prefix. An inline schema object is also accepted.", - "type": "object", - "additionalProperties": false, - "required": ["$ref"], - "properties": { - "$ref": { - "type": "string", - "pattern": "^schemas/\\S+$" - } - } - }, - "slotError": { - "title": "One expected slot error", - "type": "object", - "additionalProperties": false, - "required": ["slot", "code"], - "properties": { - "slot": { - "type": "string", - "minLength": 1 - }, - "code": { - "type": "string", - "minLength": 1 - } - } - }, - "expectSuccess": { - "title": "Success expectation", - "type": "object", - "additionalProperties": false, - "required": ["outcome"], - "properties": { - "outcome": { - "const": "success" - }, - "llmCalls": { - "type": "integer", - "minimum": 0 - }, - "promptTextEqualsGolden": { - "type": "string", - "minLength": 1 - }, - "metadata": { - "type": "object", - "additionalProperties": false, - "properties": { - "templateUriEcho": { - "type": "string" - }, - "contextEcho": { - "type": "boolean" - } - } - }, - "params": { - "type": "object", - "description": "Expected merged FilledParamData map; always contains the context keys id, round and maxRounds. When missingParams is present the entries are a subset check with exact values instead of full-map equality, and JSON null values are rejected — a missing parameter belongs into missingParams." - }, - "contracts": { - "type": "array", - "items": { - "type": "string" - } - }, - "differential": { - "type": "boolean", - "description": "Marks the from-text plus from-data double run asserting that both outputs equal each other and the golden fixture." - }, - "promptTextContains": { - "type": "array", - "description": "Task family: structural fragments the generated task prompt text must contain, such as the ## 任务类型(Task Type) style section headers.", - "items": { - "type": "string", - "minLength": 1 - } - }, - "missingParams": { - "type": "array", - "description": "Task validation: the exact set of schema parameter names expected to be missing — the null-valued entries of the filled parameter data, the causal starting point of the information negotiation. An empty array asserts that nothing is missing.", - "items": { - "type": "string", - "minLength": 1 - } - }, - "paramsFromStep": { - "type": "integer", - "minimum": 1, - "description": "Scenario steps only: the number of the earlier task-validation step whose missing parameters this validation step must have filled with values (the 缺参 -> 补参 causal link); resolved by the ScenarioEngine." - } - } - }, - "expectFailure": { - "title": "Failure expectation", - "description": "Discriminated union with the success block: the failure block must name the expected exception type or the expected error code.", - "type": "object", - "additionalProperties": false, - "required": ["outcome"], - "anyOf": [ - { - "required": ["exception"] - }, - { - "required": ["code"] - } - ], - "properties": { - "outcome": { - "const": "failure" - }, - "exception": { - "type": "string", - "minLength": 1 - }, - "code": { - "type": "string", - "minLength": 1 - }, - "messageContains": { - "type": "array", - "minItems": 1, - "items": { - "type": "string" - } - }, - "slotErrors": { - "type": "array", - "items": { - "$ref": "#/$defs/slotError" - } - }, - "llmCalls": { - "type": "integer", - "minimum": 0 - }, - "contracts": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "expectation": { - "oneOf": [ - { - "$ref": "#/$defs/expectSuccess" - }, - { - "$ref": "#/$defs/expectFailure" - } - ] - }, - "expectFlow": { - "title": "Scenario-level expectation", - "type": "object", - "additionalProperties": false, - "properties": { - "terminalCondition": { - "type": "string", - "enum": ["accept", "reject", "abort", "exhausted"] - }, - "roundsUsed": { - "type": "integer", - "minimum": 1 - }, - "distinctMessages": { - "type": "boolean" - }, - "missingParamsFilled": { - "type": "integer", - "minimum": 1, - "description": "The number of the task-validation step whose missing parameters must be filled with values by the later steps of the flow — the 缺参 -> 补参 causal chain asserted at flow level." - } - } - }, - "case": { - "title": "Corpus case record", - "description": "One fully self-describing test case; key order is fixed (id, api, languages, priority, tags, summary, context, templateUri, input, llm, expect) so diffs stay localized.", - "type": "object", - "additionalProperties": false, - "required": ["id", "api", "languages", "expect"], - "properties": { - "id": { - "title": "Globally unique case id", - "description": "Naming syntax: -- such as FT-RETRY-02. The loader expands it per language into id/zh-CN and id/en-US.", - "type": "string", - "pattern": "^[A-Z]+(-[A-Z0-9]+)*-[0-9]+$" - }, - "api": { - "$ref": "#/$defs/api" - }, - "languages": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": { - "$ref": "#/$defs/language" - } - }, - "priority": { - "$ref": "#/$defs/priority" - }, - "tags": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - }, - "summary": { - "title": "Business-facing one-line Chinese summary", - "type": "string", - "minLength": 1 - }, - "context": { - "$ref": "#/$defs/context" - }, - "templateUri": { - "$ref": "#/$defs/templateUri" - }, - "input": { - "$ref": "#/$defs/input" - }, - "llm": { - "$ref": "#/$defs/llm" - }, - "prompt": { - "$ref": "#/$defs/prompt" - }, - "schema": { - "oneOf": [ - { - "$ref": "#/$defs/schemaReference" - }, - { - "type": "object" - } - ] - }, - "inject": { - "$ref": "#/$defs/inject" - }, - "expect": { - "$ref": "#/$defs/expectation" - } - } - }, - "scenarioStep": { - "title": "Scenario step record", - "description": "A case record plus the three step-level extension keys: the consecutive step number, the acting role and the cross-step prompt reference carried inside prompt.fromStep.", - "type": "object", - "additionalProperties": false, - "required": ["step", "api", "context", "expect"], - "properties": { - "step": { - "type": "integer", - "minimum": 1 - }, - "role": { - "type": "string", - "minLength": 1 - }, - "api": { - "$ref": "#/$defs/api" - }, - "context": { - "$ref": "#/$defs/context" - }, - "templateUri": { - "$ref": "#/$defs/templateUri" - }, - "input": { - "$ref": "#/$defs/input" - }, - "llm": { - "$ref": "#/$defs/llm" - }, - "prompt": { - "$ref": "#/$defs/prompt" - }, - "schema": { - "oneOf": [ - { - "$ref": "#/$defs/schemaReference" - }, - { - "type": "object" - } - ] - }, - "inject": { - "$ref": "#/$defs/inject" - }, - "expect": { - "$ref": "#/$defs/expectation" - } - } - }, - "scenario": { - "title": "Scenario record", - "description": "A multi-step multi-API interaction over the content layer; every step is a full case record and the scenario adds the step list, the roles and the flow-level expectation.", - "type": "object", - "additionalProperties": false, - "required": ["id", "languages", "steps"], - "properties": { - "id": { - "type": "string", - "pattern": "^[A-Z]+(-[A-Z0-9]+)*-[0-9]+$" - }, - "summary": { - "type": "string", - "minLength": 1 - }, - "languages": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": { - "$ref": "#/$defs/language" - } - }, - "roles": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - }, - "rolesDesc": { - "title": "Role semantics of the closed loop", - "description": "Business meaning of each role, keyed by role name, such as {\"A\": \"工作台(client,任务发起/补数方)\", \"B\": \"OMC(server,执行/要数方,协商发起方)\"}. Every key must name a declared role; the descriptions travel into the engine failure messages and the run summary.", - "type": "object", - "minProperties": 1, - "additionalProperties": { - "type": "string", - "minLength": 1 - } - }, - "steps": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/scenarioStep" - } - }, - "expectFlow": { - "$ref": "#/$defs/expectFlow" - } - } - }, - "liveExpectation": { - "title": "Live expectation block", - "description": "The looser expectation of the live family: subset parameter assertions and an LLM call upper bound instead of the offline exact equivalence; no golden fixture and no differential. Live phase 1 expects the success path only, so success false carries no failure details.", - "type": "object", - "additionalProperties": false, - "required": ["success"], - "properties": { - "success": { - "type": "boolean", - "description": "Expected operation outcome: true expects the operation to succeed, false expects it to fail." - }, - "scenarioCode": { - "type": "string", - "minLength": 1, - "description": "Expected recognized scenario code, judged as the scenario segment of the echoed template URI." - }, - "paramsContains": { - "type": "object", - "description": "Subset check: the named slots must be filled with exactly these values (other slots are not asserted). JSON null values are rejected — a missing parameter belongs into paramsAbsent." - }, - "paramsAbsent": { - "type": "array", - "description": "Slots that must be null or missing.", - "items": { - "type": "string", - "minLength": 1 - } - }, - "promptTextContains": { - "type": "array", - "description": "Generate API only: structural fragments the generated task prompt text must contain; the loader rejects the key on validate records.", - "items": { - "type": "string", - "minLength": 1 - } - }, - "maxLlmCalls": { - "type": "integer", - "minimum": 1, - "description": "Upper bound of LLM calls the case may trigger (a real model may trigger pipeline-internal retries); the loader defaults it to 4 when omitted." - } - } - }, - "liveCase": { - "title": "Live-LLM case record", - "description": "One live family record under live/: no llm script, no inject hook and no typed input data — the behavior under test comes from the real model — with the looser live expectation block instead of the offline one. Ids carry the LIVE- prefix; phase 1 covers zh-CN and the two task APIs only.", - "type": "object", - "additionalProperties": false, - "required": ["id", "api", "languages", "expect"], - "properties": { - "id": { - "title": "Live case id", - "description": "Naming syntax with the mandatory LIVE- prefix, such as LIVE-GEN-01; expanded per language like the offline ids.", - "type": "string", - "pattern": "^LIVE(-[A-Z0-9]+)*-[0-9]+$" - }, - "api": { - "type": "string", - "enum": ["generateTaskPromptFromText", "validateTaskPromptAndDataFilling"] - }, - "languages": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": { - "const": "zh-CN" - } - }, - "priority": { - "$ref": "#/$defs/priority" - }, - "tags": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - }, - "summary": { - "type": "string", - "minLength": 1 - }, - "context": { - "$ref": "#/$defs/context" - }, - "templateUri": { - "$ref": "#/$defs/templateUri" - }, - "input": { - "title": "Live input", - "description": "The natural-language input of the generate API; required for generateTaskPromptFromText records, rejected for validate records.", - "type": "object", - "additionalProperties": false, - "required": ["text"], - "properties": { - "text": { - "type": "object", - "propertyNames": { - "$ref": "#/$defs/language" - }, - "additionalProperties": { - "type": "string" - } - } - } - }, - "prompt": { - "title": "Live prompt input", - "description": "The validate API's prompt: an inline text only — golden and fromStep are offline-only in the live family.", - "type": "object", - "additionalProperties": false, - "required": ["text"], - "properties": { - "text": { - "type": "string" - } - } - }, - "schema": { - "anyOf": [ - { - "$ref": "#/$defs/schemaReference" - }, - { - "type": "object" - } - ] - }, - "expect": { - "$ref": "#/$defs/liveExpectation" - } - } - }, - "liveCaseFile": { - "title": "Live case corpus file", - "description": "A file under live/ of the corpus root; its records land in the dedicated liveCases list, never in the offline cases list.", - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/liveCase" - } - }, - "caseFile": { - "title": "Case corpus file", - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/case" - } - }, - "scenarioFile": { - "title": "Scenario corpus file", - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/scenario" - } - }, - "sharedResponsesFile": { - "title": "Shared LLM response payloads", - "description": "Flat map of snake_case payload names to literal JSON string payloads; every reusable payload appears exactly once.", - "type": "object", - "minProperties": 1, - "propertyNames": { - "type": "string", - "pattern": "^[a-z0-9_.]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - "sharedSchemasFile": { - "title": "Shared JSON Schema variants", - "description": "Flat map of schema names to JSON Schema objects referenced through the schemas/ prefix.", - "type": "object", - "minProperties": 1, - "propertyNames": { - "type": "string", - "pattern": "^[a-z0-9_.]+$" - }, - "additionalProperties": { - "type": "object" - } - } - }, - "oneOf": [ - { - "$ref": "#/$defs/caseFile" - }, - { - "$ref": "#/$defs/scenarioFile" - }, - { - "$ref": "#/$defs/liveCaseFile" - }, - { - "$ref": "#/$defs/sharedResponsesFile" - }, - { - "$ref": "#/$defs/sharedSchemasFile" - } - ] -} diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/from-data/happy.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-data/happy.json deleted file mode 100644 index 21152ac9..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/from-data/happy.json +++ /dev/null @@ -1,326 +0,0 @@ -[ - { - "id": "FD-HAPPY-01", - "api": "generateProposeFromData", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "information", "propose", "deterministic"], - "summary": "OMC 以结构化数据直接生成信息协商提议消息,向工作台索要接入端口名称与投诉分类等缺失信息项,全程零 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "data": { - "items": [ - { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" }, - { "name": "投诉分类", "value": "举例:专线质差" }, - { "name": "专线业务标识", "value": null } - ], - "relationship": "OR" - } - }, - "expect": { - "outcome": "success", - "llmCalls": 0, - "promptTextContains": ["接入端口名称", "投诉分类", "专线业务标识", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差", "OR"], - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - }, - { - "id": "FD-HAPPY-02", - "api": "generateAcceptFromData", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "information", "accept", "deterministic"], - "summary": "工作台以结构化数据直接生成信息协商接受消息,补齐接入端口名称与投诉分类并携带 Accept 结论字面量,全程零 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "data": { - "conclusion": "Accept", - "items": [ - { "name": "接入端口名称", "value": "P533-珠江旧城-PTN3900-23-TPA1EG24-1" }, - { "name": "投诉分类", "value": "专线质差" } - ] - } - }, - "expect": { - "outcome": "success", - "llmCalls": 0, - "promptTextContains": ["Accept", "接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差"], - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FD-HAPPY-03", - "api": "generateRejectFromData", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "information", "reject", "deterministic"], - "summary": "工作台以结构化数据直接生成信息协商拒绝消息,以端口资源台账暂不可查为由无法提供接入端口名称并携带 Reject 结论字面量,全程零 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "data": { - "conclusion": "Reject", - "items": [ - { "name": "接入端口名称", "value": "无法提供,工作台侧端口资源台账暂不可查" } - ] - } - }, - "expect": { - "outcome": "success", - "llmCalls": 0, - "promptTextContains": ["Reject", "接入端口名称", "无法提供,工作台侧端口资源台账暂不可查"], - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FD-HAPPY-04", - "api": "generateProposeFromData", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "target", "propose", "first-round", "deterministic"], - "summary": "首轮目标协商从结构化数据直接生成提议消息,围绕专线质差的时延恢复目标与修复完成时限向对端澄清,全程零 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/propose/v1", - "input": { - "data": { - "target_negotiation_description": "专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。", - "intent_understanding": [ - { "name": "修复意图", "value": "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内" }, - { "name": "修复目标", "value": "高峰时段丢包率不高于1%" } - ], - "alignment_and_clarification": null, - "request_for_clarification": [ - { "name": "时延目标", "value": "平均时延目标是恢复至20ms以内还是50ms以内" }, - { "name": "完成时限", "value": "修复完成时限是48小时内还是72小时内" } - ] - } - }, - "expect": { - "outcome": "success", - "llmCalls": 0, - "promptTextContains": [ - "修复目标协商意图", - "修复意图", - "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内", - "高峰时段丢包率不高于1%", - "时延目标", - "完成时限" - ], - "metadata": { - "templateUriEcho": "Negotiation-T/target-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - }, - { - "id": "FD-HAPPY-05", - "api": "generateAcceptFromData", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "target", "accept", "deterministic"], - "summary": "以结构化数据直接生成目标协商接受消息,最终确认专线时延恢复至20ms以内与高峰丢包率目标并携带 Accept 结论字面量,全程零 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { - "data": { - "conclusion": "Accept", - "confirmed_intent": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。", - "failure_reason": null - } - }, - "expect": { - "outcome": "success", - "llmCalls": 0, - "promptTextContains": ["Accept", "最终确认的意图", "20ms以内", "高峰时段丢包率不高于1%"], - "metadata": { - "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FD-HAPPY-06", - "api": "generateRejectFromData", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "target", "reject", "deterministic"], - "summary": "以结构化数据直接生成目标协商拒绝消息,以光缆割接窗口尚未确认为由拒绝时延目标并携带 Reject 结论字面量,全程零 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { - "data": { - "conclusion": "Reject", - "confirmed_intent": null, - "failure_reason": "因光缆割接窗口尚未确认,时延目标未能完全澄清。" - } - }, - "expect": { - "outcome": "success", - "llmCalls": 0, - "promptTextContains": ["Reject", "光缆割接窗口尚未确认", "时延目标未能完全澄清"], - "metadata": { - "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FD-HAPPY-07", - "api": "generateProposeFromData", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "feasibility", "propose", "evaluation-request", "deterministic"], - "summary": "以结构化数据直接生成可行性协商提议消息,请求对端评估接入端口带宽扩容能否在割接窗口内完成,全程零 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { - "data": { - "feasibility_negotiation_description": "请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。", - "action": "REQUEST_FEASIBILITY_EVALUATION", - "contents_to_evaluate": [ - { "name": "扩容方案", "value": "接入端口带宽由100Mbps扩容至1000Mbps" }, - { "name": "既有约束", "value": "割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟" } - ], - "infeasibility_details_and_proposal": null - } - }, - "expect": { - "outcome": "success", - "llmCalls": 0, - "promptTextContains": [ - "扩容方案", - "接入端口带宽由100Mbps扩容至1000Mbps", - "既有约束", - "割接窗口仅限2026年5月30日02:00-06:00" - ], - "metadata": { - "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - }, - { - "id": "FD-HAPPY-08", - "api": "generateAcceptFromData", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "feasibility", "accept", "deterministic"], - "summary": "以结构化数据直接生成可行性协商接受消息,确认端口扩容可在割接窗口内完成且满足业务中断约束并携带 Accept 结论字面量,全程零 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "input": { - "data": { - "conclusion": "Accept", - "feasibility_summary": "端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。" - } - }, - "expect": { - "outcome": "success", - "llmCalls": 0, - "promptTextContains": ["Accept", "端口扩容方案可在割接窗口内完成", "不超过30分钟的约束"], - "metadata": { - "templateUriEcho": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FD-HAPPY-09", - "api": "generateRejectFromData", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "feasibility", "reject", "deterministic"], - "summary": "以结构化数据直接生成可行性协商拒绝消息,以汇聚机房板卡槽位不足为由判定扩容不可行并携带 Reject 结论字面量,全程零 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "input": { - "data": { - "conclusion": "Reject", - "feasibility_summary": "受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。" - } - }, - "expect": { - "outcome": "success", - "llmCalls": 0, - "promptTextContains": ["Reject", "汇聚机房板卡槽位不足", "无法在指定割接窗口内完成"], - "metadata": { - "templateUriEcho": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FD-HAPPY-10", - "api": "generateAbortFromData", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "abort", "common", "deterministic"], - "summary": "以结构化数据直接生成协商终止消息,以达到协商轮次上限为由终止本次专线投诉诊断协商并携带 Abort 结论字面量,全程零 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "input": { - "data": { - "termination_reason": "已达到协商轮次上限,本次协商确认终止。" - } - }, - "expect": { - "outcome": "success", - "llmCalls": 0, - "promptTextContains": ["Abort", "已达到协商轮次上限"], - "metadata": { - "templateUriEcho": "Negotiation-T/common/abort/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FD-HAPPY-11", - "api": "generateProposeFromData", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "information", "propose", "and-relationship", "deterministic"], - "summary": "接入端口名称与投诉分类两项缺失信息均为必填时以与关系生成信息协商提议消息,两项同时列为缺失项,全程零 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "data": { - "items": [ - { "name": "接入端口名称", "value": "举例:P781-珠江新城-PTN7900-23-TPA1EG24-17" }, - { "name": "投诉分类", "value": "举例:专线中断" } - ], - "relationship": "AND" - } - }, - "expect": { - "outcome": "success", - "llmCalls": 0, - "promptTextContains": ["接入端口名称", "投诉分类", "P781-珠江新城-PTN7900-23-TPA1EG24-17", "专线中断", "AND"], - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/from-data/programming-errors.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-data/programming-errors.json deleted file mode 100644 index 0534323b..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/from-data/programming-errors.json +++ /dev/null @@ -1,355 +0,0 @@ -[ - { - "id": "FD-PROG-01", - "api": "generateProposeFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "null-context", "information", "propose"], - "summary": "结构化提议数据缺少协商上下文时生成立即以空指针异常失败,提示上下文必填且不发起任何 LLM 调用", - "context": null, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "data": { - "items": [ - { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" } - ], - "relationship": "OR" - } - }, - "expect": { - "outcome": "failure", - "exception": "NullPointerException", - "messageContains": ["Negotiation context must not be null."], - "llmCalls": 0 - } - }, - { - "id": "FD-PROG-02", - "api": "generateAcceptFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "conclusion-mismatch", "information", "accept"], - "summary": "接受方法收到携带 Reject 结论的结构化数据时生成以结论不符错误码失败,提示结论与接受方法预期不符且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "data": { - "conclusion": "Reject", - "items": [ - { "name": "接入端口名称", "value": "P533-珠江旧城-PTN3900-23-TPA1EG24-1" } - ] - } - }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.conclusion_mismatch", - "messageContains": ["与该方法的预期"], - "llmCalls": 0 - } - }, - { - "id": "FD-PROG-03", - "api": "generateRejectFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "conclusion-mismatch", "information", "reject"], - "summary": "拒绝方法收到携带 Accept 结论的结构化数据时生成以结论不符错误码失败,提示结论与拒绝方法预期不符且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "data": { - "conclusion": "Accept", - "items": [ - { "name": "接入端口名称", "value": "P533-珠江旧城-PTN3900-23-TPA1EG24-1" } - ] - } - }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.conclusion_mismatch", - "messageContains": ["与该方法的预期"], - "llmCalls": 0 - } - }, - { - "id": "FD-PROG-04", - "api": "generateAcceptFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "conclusion-mismatch", "information", "accept", "abort-conclusion"], - "summary": "接受方法收到携带 Abort 结论的结构化数据时生成以结论不符错误码失败,提示终止结论不属于类型化模板且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "data": { - "conclusion": "Abort", - "items": [ - { "name": "接入端口名称", "value": "P533-珠江旧城-PTN3900-23-TPA1EG24-1" } - ] - } - }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.conclusion_mismatch", - "messageContains": ["与该方法的预期"], - "llmCalls": 0 - } - }, - { - "id": "FD-PROG-05", - "api": "generateAcceptFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "missing-field", "target", "accept"], - "summary": "目标协商接受数据缺少最终确认意图时生成以协商内容无效错误码失败,提示接受消息必须携带确认意图且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { - "data": { - "conclusion": "Accept", - "confirmed_intent": null, - "failure_reason": null - } - }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.content_invalid", - "messageContains": ["Confirmed intent of an accepting target negotiation message must not be blank."], - "llmCalls": 0 - } - }, - { - "id": "FD-PROG-06", - "api": "generateAcceptFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "blank-field", "target", "accept"], - "summary": "目标协商接受数据的最终确认意图为空白字符串时生成以协商内容无效错误码失败,提示确认意图不能为空白且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { - "data": { - "conclusion": "Accept", - "confirmed_intent": " ", - "failure_reason": null - } - }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.content_invalid", - "messageContains": ["Confirmed intent of an accepting target negotiation message must not be blank."], - "llmCalls": 0 - } - }, - { - "id": "FD-PROG-07", - "api": "generateRejectFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "missing-field", "target", "reject"], - "summary": "目标协商拒绝数据缺少拒绝原因时生成以协商内容无效错误码失败,提示拒绝消息必须携带失败原因且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { - "data": { - "conclusion": "Reject", - "confirmed_intent": null, - "failure_reason": null - } - }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.content_invalid", - "messageContains": ["Failure reason of a rejecting target negotiation message must not be blank."], - "llmCalls": 0 - } - }, - { - "id": "FD-PROG-08", - "api": "generateProposeFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "missing-field", "feasibility", "propose", "evaluation-request"], - "summary": "可行性评估请求的结构化提议数据缺少待评估内容时生成以协商内容无效错误码失败,提示至少需要一条评估项且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { - "data": { - "feasibility_negotiation_description": "请评估专线接入端口扩容方案能否在割接窗口内完成;请评估。", - "action": "REQUEST_FEASIBILITY_EVALUATION", - "contents_to_evaluate": null, - "infeasibility_details_and_proposal": null - } - }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.content_invalid", - "messageContains": ["Contents to evaluate of a feasibility evaluation request must contain at least one item."], - "llmCalls": 0 - } - }, - { - "id": "FD-PROG-09", - "api": "generateProposeFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "missing-field", "feasibility", "propose", "alternative-proposal"], - "summary": "不可行替代方案的结构化提议数据缺少不可行细节与方案时生成以协商内容无效错误码失败,提示至少需要一条方案项且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { - "data": { - "feasibility_negotiation_description": "受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;替代方案见<不可行细节与方案>。", - "action": "PROPOSE_ALTERNATIVE_ON_FAILURE", - "contents_to_evaluate": null, - "infeasibility_details_and_proposal": null - } - }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.content_invalid", - "messageContains": [ - "Infeasibility details and proposal of an alternative proposal must contain at least one item." - ], - "llmCalls": 0 - } - }, - { - "id": "FD-PROG-10", - "api": "generateProposeFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "template-uri", "wrong-prefix", "information", "propose"], - "summary": "模板 URI 前缀指向 Task-T 而非 Negotiation-T 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Task-T/information-negotiation/propose/v1", - "input": { - "data": { - "items": [ - { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" } - ], - "relationship": "OR" - } - }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": [ - "Template URI does not address a negotiation template of the expected phase PROPOSE (propose): Task-T/information-negotiation/propose/v1." - ], - "llmCalls": 0 - } - }, - { - "id": "FD-PROG-11", - "api": "generateProposeFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "template-uri", "wrong-version", "information", "propose"], - "summary": "模板 URI 版本段为 v2 而非内置 v1 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v2", - "input": { - "data": { - "items": [ - { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" } - ], - "relationship": "OR" - } - }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": [ - "Template URI does not address a negotiation template of the expected phase PROPOSE (propose): Negotiation-T/information-negotiation/propose/v2." - ], - "llmCalls": 0 - } - }, - { - "id": "FD-PROG-12", - "api": "generateProposeFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "template-uri", "malformed-type-segment", "information", "propose"], - "summary": "模板 URI 类型段使用下划线拼写 information_negotiation 时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information_negotiation/propose/v1", - "input": { - "data": { - "items": [ - { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" } - ], - "relationship": "OR" - } - }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": [ - "Template URI does not address a negotiation template of the expected phase PROPOSE (propose): Negotiation-T/information_negotiation/propose/v1." - ], - "llmCalls": 0 - } - }, - { - "id": "FD-PROG-13", - "api": "generateProposeFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "template-uri", "unknown-type", "information", "propose"], - "summary": "模板 URI 类型段指向未知的 unknown-negotiation 类型时生成以参数异常失败,提示 URI 未指向期望阶段的协商模板且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/unknown-negotiation/propose/v1", - "input": { - "data": { - "items": [ - { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" } - ], - "relationship": "OR" - } - }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": [ - "Template URI does not address a negotiation template of the expected phase PROPOSE (propose): Negotiation-T/unknown-negotiation/propose/v1." - ], - "llmCalls": 0 - } - }, - { - "id": "FD-PROG-14", - "api": "generateProposeFromData", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "template-uri", "phase-mismatch", "information", "propose"], - "summary": "提议方法收到的模板 URI 阶段段为 accept-reject 而非 propose 时生成以参数异常失败,提示 URI 阶段与方法不符且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "data": { - "items": [ - { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" } - ], - "relationship": "OR" - } - }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": [ - "Template URI does not address a negotiation template of the expected phase PROPOSE (propose): Negotiation-T/information-negotiation/accept-reject/v1." - ], - "llmCalls": 0 - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/extraction-failures.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/extraction-failures.json deleted file mode 100644 index 05ea1644..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/extraction-failures.json +++ /dev/null @@ -1,272 +0,0 @@ -[ - { - "id": "FT-EXTRACT-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["extraction-failure", "information", "accept", "slot-missing"], - "summary": "补数接受消息抽取结果缺少结论字段时生成失败并返回槽位缺失错误码,不发起重试", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.missing_conclusion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.field_missing", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-02", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["extraction-failure", "information", "accept", "conclusion-mismatch"], - "summary": "补数接受消息抽取出的结论为拒绝时判定为结论与阶段不符,立即失败并返回结论不符错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.conclusion_swapped" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.conclusion_mismatch", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-03", - "api": "generateRejectFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["extraction-failure", "information", "reject", "conclusion-mismatch"], - "summary": "无法提供端口名的拒绝消息抽取出的结论为接受时判定为结论与阶段不符,立即失败并返回结论不符错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮协商拒绝。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.reject.conclusion_swapped" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.conclusion_mismatch", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-04", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["extraction-failure", "information", "propose", "slot-missing"], - "summary": "要数提议消息抽取结果缺少信息项列表时生成失败并返回槽位缺失错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.missing_items" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.field_missing", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-05", - "api": "generateProposeFromText", - "languages": ["en-US"], - "priority": "P1", - "tags": ["extraction-failure", "information", "propose", "shape-error", "max-attempts-1"], - "summary": "要数信息项列表被抽取为字符串而非数组时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "en-US": "Please supplement the Access Port Name, the Complaint Category and the Private Line Service Identifier." } }, - "llm": { "maxAttempts": 1, "script": [ { "$ref": "responses/extract.information.propose.items_not_array" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.content_extract_failed", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-06", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P2", - "tags": ["extraction-failure", "information", "propose", "shape-error", "max-attempts-1"], - "summary": "要数信息项缺少名称字段时内容抽取失败,返回抽取失败错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, - "llm": { "maxAttempts": 1, "script": [ "{\"items\":[{\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}],\"relationship\":\"OR\"}" ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.content_extract_failed", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-07", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["extraction-failure", "target", "accept", "shape-error", "max-attempts-1"], - "summary": "结论字面量大小写错误(ACCEPT 而非 Accept)时内容抽取失败,返回抽取失败错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" } }, - "llm": { "maxAttempts": 1, "script": [ "{\"conclusion\":\"ACCEPT\",\"confirmed_intent\":\"最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。\",\"failure_reason\":null}" ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.content_extract_failed", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-08", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["extraction-failure", "target", "accept", "slot-missing"], - "summary": "修复目标接受消息抽取结果缺少最终确认意图时生成失败并返回槽位缺失错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" } }, - "llm": { "script": [ { "$ref": "responses/extract.target.accept.missing_confirmed_intent" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.field_missing", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-09", - "api": "generateRejectFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["extraction-failure", "target", "reject", "slot-missing"], - "summary": "修复目标拒绝消息抽取结果缺少拒绝原因时生成失败并返回槽位缺失错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "因光缆割接窗口尚未确认,时延目标未能完全澄清,本轮目标协商拒绝。" } }, - "llm": { "script": [ { "$ref": "responses/extract.target.reject.missing_failure_reason" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.field_missing", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-10", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["extraction-failure", "feasibility", "propose", "invalid-input"], - "summary": "端口扩容可行性提议消息抽取结果缺少动作字段时判定为内容矛盾,立即失败并返回输入无效错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps。" } }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.missing_action" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.invalid_input", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-11", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["extraction-failure", "feasibility", "propose", "shape-error", "max-attempts-1"], - "summary": "动作字段为未知枚举值时内容抽取失败,单次尝试额度即耗尽并返回抽取失败错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps。" } }, - "llm": { "maxAttempts": 1, "script": [ { "$ref": "responses/extract.feasibility.propose.unknown_action" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.content_extract_failed", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-12", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["extraction-failure", "feasibility", "propose", "invalid-input"], - "summary": "请求可行性评估却未抽取到任何待评估内容时判定为内容矛盾,立即失败并返回输入无效错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps。" } }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.evaluation_without_contents" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.invalid_input", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-13", - "api": "generateAbortFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["extraction-failure", "abort", "common", "slot-missing"], - "summary": "协商终止消息抽取结果缺少终止原因时生成失败并返回槽位缺失错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "input": { "text": { "zh-CN": "已达到协商轮次上限,本次协商确认终止。" } }, - "llm": { "script": [ { "$ref": "responses/extract.common.abort.missing_termination_reason" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.field_missing", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-14", - "api": "generateAbortFromText", - "languages": ["zh-CN"], - "priority": "P2", - "tags": ["extraction-failure", "abort", "common", "slot-missing", "blank-value"], - "summary": "终止原因为空白字符串时等同于缺失,生成失败并返回槽位缺失错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "input": { "text": { "zh-CN": "已达到协商轮次上限,本次协商确认终止。" } }, - "llm": { "script": [ "{\"termination_reason\":\"\"}" ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.field_missing", - "llmCalls": 1 - } - }, - { - "id": "FT-EXTRACT-15", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P2", - "tags": ["extraction-failure", "information", "propose", "shape-error", "max-attempts-1"], - "summary": "信息项取值为数字而非字符串时内容抽取失败,返回抽取失败错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, - "llm": { "maxAttempts": 1, "script": [ "{\"items\":[{\"name\":\"专线业务标识\",\"value\":100}],\"relationship\":\"OR\"}" ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.content_extract_failed", - "llmCalls": 1 - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/happy.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/happy.json deleted file mode 100644 index fe5d9c88..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/happy.json +++ /dev/null @@ -1,437 +0,0 @@ -[ - { - "id": "FT-HAPPY-01", - "api": "generateProposeFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "information", "propose"], - "summary": "OMC 就投诉诊断缺失的接入端口名称与投诉分类发起信息协商,从自然语言生成提议消息,双语渲染均携带三项缺失信息与 OR 关系(golden 等值与 differential 不可表达降级为包含断言:harness 的 llm 脚本与 input.data 不随语言展开,en-US 渲染为英文模板骨架+中文数据,deviation 已记录)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识三项信息,三项之间为或的关系。", - "en-US": "Please supplement the Access Port Name (e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1), the Complaint Category (e.g. dedicated-line quality degradation) and the Private Line Service Identifier; the three items are related by OR." - }, - "data": { - "items": [ - { "name": "接入端口名称", "value": "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1" }, - { "name": "投诉分类", "value": "举例:专线质差" }, - { "name": "专线业务标识", "value": null } - ], - "relationship": "OR" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["接入端口名称", "投诉分类", "专线业务标识", "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差", "OR"], - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - }, - { - "id": "FT-HAPPY-02", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "information", "accept"], - "summary": "工作台补充接入端口名称与投诉分类后从自然语言生成信息协商接受消息,消息含 Accept 结论字面量且双语渲染均携带补充的业务信息项(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。", - "en-US": "I confirm the supplemented complaint diagnosis information: the access port name is P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1 and the complaint category is dedicated-line quality degradation." - }, - "data": { - "conclusion": "Accept", - "items": [ - { "name": "接入端口名称", "value": "P533-珠江旧城-PTN3900-23-TPA1EG24-1" }, - { "name": "投诉分类", "value": "专线质差" } - ] - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["Accept", "接入端口名称", "投诉分类", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", "专线质差"], - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FT-HAPPY-03", - "api": "generateRejectFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "information", "reject"], - "summary": "工作台侧端口资源台账不可查无法提供接入端口名称时从自然语言生成信息协商拒绝消息,消息含 Reject 结论字面量且携带不可提供原因(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮协商拒绝。", - "en-US": "The access port name cannot be provided because the port inventory is temporarily unavailable on the workbench side; this round is rejected." - }, - "data": { - "conclusion": "Reject", - "items": [ - { "name": "接入端口名称", "value": "无法提供,工作台侧端口资源台账暂不可查" } - ] - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["Reject", "接入端口名称", "无法提供,工作台侧端口资源台账暂不可查"], - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FT-HAPPY-04", - "api": "generateProposeFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "target", "propose", "first-round"], - "summary": "首轮修复目标协商从自然语言生成提议消息,就时延恢复目标与完成时限澄清,首轮渲染意图理解与待澄清内容两个板块(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "专线质差投诉修复目标的意图理解:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%;对时延目标与完成时限仍有疑问,请澄清和确认。", - "en-US": "The intent understanding of the dedicated-line quality degradation repair target: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15 with a peak-hour packet loss rate no higher than 1%; open questions remain about the latency target and the completion deadline, please clarify and confirm." - }, - "data": { - "target_negotiation_description": "专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。", - "intent_understanding": [ - { "name": "修复意图", "value": "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内" }, - { "name": "修复目标", "value": "高峰时段丢包率不高于1%" } - ], - "alignment_and_clarification": null, - "request_for_clarification": [ - { "name": "时延目标", "value": "平均时延目标是恢复至20ms以内还是50ms以内" }, - { "name": "完成时限", "value": "修复完成时限是48小时内还是72小时内" } - ] - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["修复意图", "在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内", "高峰时段丢包率不高于1%", "时延目标", "平均时延目标是恢复至20ms以内还是50ms以内", "完成时限", "修复完成时限是48小时内还是72小时内"], - "metadata": { - "templateUriEcho": "Negotiation-T/target-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - }, - { - "id": "FT-HAPPY-05", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "target", "accept"], - "summary": "时延恢复目标确认后从自然语言生成目标协商接受消息,消息携带最终确认意图并含 Accept 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。", - "en-US": "The finally confirmed intent: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15, with a peak-hour packet loss rate no higher than 1%." - }, - "data": { - "conclusion": "Accept", - "confirmed_intent": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。", - "failure_reason": null - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["Accept", "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。"], - "metadata": { - "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FT-HAPPY-06", - "api": "generateRejectFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "target", "reject"], - "summary": "光缆割接窗口未确认致时延目标无法澄清时从自然语言生成目标协商拒绝消息,消息携带拒绝原因并含 Reject 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "因光缆割接窗口尚未确认,时延目标未能完全澄清,本轮目标协商拒绝。", - "en-US": "The latency target cannot be clarified in full because the fiber cutover window is not confirmed yet; this target negotiation round is rejected." - }, - "data": { - "conclusion": "Reject", - "confirmed_intent": null, - "failure_reason": "因光缆割接窗口尚未确认,时延目标未能完全澄清。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.reject.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["Reject", "因光缆割接窗口尚未确认,时延目标未能完全澄清。"], - "metadata": { - "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FT-HAPPY-07", - "api": "generateProposeFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "feasibility", "propose", "evaluation-request"], - "summary": "工作台请 OMC 评估接入端口扩容方案能否在割接窗口内完成,从自然语言生成可行性协商提议消息,携带扩容方案与既有约束两项待评估内容(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps,割接窗口仅限2026年5月30日02:00-06:00且业务中断不超过30分钟。", - "en-US": "Please assess whether the dedicated-line access port expansion can be completed within the cutover window: expand the access port bandwidth from 100Mbps to 1000Mbps, with the cutover window limited to 02:00-06:00 on 2026-05-30 and service interruption no longer than 30 minutes." - }, - "data": { - "feasibility_negotiation_description": "请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。", - "action": "REQUEST_FEASIBILITY_EVALUATION", - "contents_to_evaluate": [ - { "name": "扩容方案", "value": "接入端口带宽由100Mbps扩容至1000Mbps" }, - { "name": "既有约束", "value": "割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟" } - ], - "infeasibility_details_and_proposal": null - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["扩容方案", "接入端口带宽由100Mbps扩容至1000Mbps", "既有约束", "割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟"], - "metadata": { - "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - }, - { - "id": "FT-HAPPY-08", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "feasibility", "accept"], - "summary": "端口扩容可行性评估通过后从自然语言生成可行性协商接受消息,消息携带可行性结论并含 Accept 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束,本次可行性协商确认结束。", - "en-US": "The port expansion can be completed within the cutover window and satisfies the 30-minute interruption constraint; this feasibility negotiation is confirmed as concluded." - }, - "data": { - "conclusion": "Accept", - "feasibility_summary": "端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["Accept", "端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。"], - "metadata": { - "templateUriEcho": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FT-HAPPY-09", - "api": "generateRejectFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "feasibility", "reject"], - "summary": "汇聚机房板卡槽位不足致端口扩容不可行时从自然语言生成可行性协商拒绝消息,消息携带不可行结论并含 Reject 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成,本次可行性协商确认结束。", - "en-US": "The port expansion cannot be completed within the designated cutover window because of insufficient board slots in the aggregation room; this feasibility negotiation is confirmed as concluded." - }, - "data": { - "conclusion": "Reject", - "feasibility_summary": "受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.reject.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["Reject", "受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。"], - "metadata": { - "templateUriEcho": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FT-HAPPY-10", - "api": "generateAbortFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "abort", "common"], - "summary": "投诉诊断协商轮次耗尽时从自然语言生成协商终止消息,走通用 abort 模板并含终止原因与 Abort 结论字面量(golden 等值与 differential 不可表达降级为包含断言,缘由同 FT-HAPPY-01,deviation 已记录)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "input": { - "text": { - "zh-CN": "已达到协商轮次上限,本次协商确认终止。", - "en-US": "The negotiation round limit is reached; this negotiation is confirmed as terminated." - }, - "data": { - "termination_reason": "已达到协商轮次上限,本次协商确认终止。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["Abort", "已达到协商轮次上限,本次协商确认终止。"], - "metadata": { - "templateUriEcho": "Negotiation-T/common/abort/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FT-HAPPY-11", - "api": "generateProposeFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "target", "propose", "confirm-request"], - "summary": "目标澄清完成后的确认请求轮从自然语言生成目标协商提议消息:抽取结果 target_confirm_request 非空且三个互斥板块全空,渲染仅含目标协商概述与目标澄清后的确认请求两个板块、携带固定确认措辞(harness 的 llm 脚本与 input.data 不随语言展开,en-US 渲染为英文模板骨架+中文数据,deviation 已记录)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "任务目标澄清完成:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。目标已经澄清,是否同意按照此目标继续执行?", - "en-US": "The clarification of the task target has been completed: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15, with a peak-hour packet loss rate no higher than 1%. The target has been clarified. Do you agree to proceed with this target?" - }, - "data": { - "target_negotiation_description": "任务目标澄清完成,请答复<目标澄清后的确认请求>。", - "intent_understanding": null, - "alignment_and_clarification": null, - "request_for_clarification": null, - "target_confirm_request": "目标已经澄清,是否同意按照此目标继续执行?" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.propose.confirm" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["目标已经澄清,是否同意按照此目标继续执行?"], - "metadata": { - "templateUriEcho": "Negotiation-T/target-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - }, - { - "id": "FT-HAPPY-12", - "api": "generateProposeFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "feasibility", "propose", "confirm-request", "goal"], - "summary": "目标达成类可行性确认请求轮从自然语言生成可行性协商提议消息:抽取结果 feasibility_confirm_request 非空、action 为 REQUEST_FEASIBILITY_EVALUATION 且两个互斥板块全空,渲染仅含可行性协商概述与评估可行时的确认请求两个板块、携带目标可行固定措辞(en-US 渲染为英文模板骨架+中文数据,deviation 已记录)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "针对调整后的速率保障目标(由5Mbps下调至2Mbps),可行性评估已完成,结论为可行。评估目标可行,是否同意按照此目标继续执行?", - "en-US": "Regarding the adjusted rate guarantee target (lowered from 5Mbps to 2Mbps), the feasibility assessment has been completed and the conclusion is feasible. The target is assessed as feasible. Do you agree to proceed with this target?" - }, - "data": { - "feasibility_negotiation_description": "针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。", - "action": "REQUEST_FEASIBILITY_EVALUATION", - "contents_to_evaluate": null, - "infeasibility_details_and_proposal": null, - "feasibility_confirm_request": "评估目标可行,是否同意按照此目标继续执行?" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.confirm.goal" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["评估目标可行,是否同意按照此目标继续执行?"], - "metadata": { - "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - }, - { - "id": "FT-HAPPY-13", - "api": "generateProposeFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "feasibility", "propose", "confirm-request", "solution"], - "summary": "方案可行性类确认请求轮从自然语言生成可行性协商提议消息:抽取结果 feasibility_confirm_request 非空且互斥板块全空,渲染携带方案可行固定措辞,验证固定措辞在目标/方案两条中的二选一(en-US 渲染为英文模板骨架+中文数据,deviation 已记录)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "故障订阅任务履行过程中自诊断识别到的保活方案(心跳探测周期调整为60秒)评估已完成,结论为可行。评估方案可行,是否同意按照此方案继续执行?", - "en-US": "The assessment of the keepalive solution identified by self-diagnosis during the fault subscription task (heartbeat probing interval adjusted to 60 seconds) has been completed and the conclusion is feasible. The solution is assessed as feasible. Do you agree to proceed with this solution?" - }, - "data": { - "feasibility_negotiation_description": "故障订阅任务履行过程中自诊断识别到的保活方案评估已完成,结论为可行,请答复<评估可行时的确认请求>。", - "action": "REQUEST_FEASIBILITY_EVALUATION", - "contents_to_evaluate": null, - "infeasibility_details_and_proposal": null, - "feasibility_confirm_request": "评估方案可行,是否同意按照此方案继续执行?" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.confirm.solution" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["评估方案可行,是否同意按照此方案继续执行?"], - "metadata": { - "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/programming-errors.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/programming-errors.json deleted file mode 100644 index 3c43e12a..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/programming-errors.json +++ /dev/null @@ -1,145 +0,0 @@ -[ - { - "id": "FT-PROG-01", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "npe", "null-context"], - "summary": "协商上下文缺失时生成投诉诊断的信息协商提议在管线入口直接失败,抛出空指针异常且不发起任何大模型调用", - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "failure", - "exception": "NullPointerException", - "messageContains": ["Negotiation context must not be null."], - "llmCalls": 0 - } - }, - { - "id": "FT-PROG-02", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "npe", "null-template-uri"], - "summary": "模板地址缺失时生成信息协商接受消息在地址解析入口直接失败,抛出空指针异常且不发起任何大模型调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, - "expect": { - "outcome": "failure", - "exception": "NullPointerException", - "messageContains": ["Template URI must not be null."], - "llmCalls": 0 - } - }, - { - "id": "FT-PROG-03", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "null-text", "invalid-input"], - "summary": "调用方未传入任何投诉诊断自然语言内容时生成提议消息被拒绝,返回无效输入错误码且不发起任何大模型调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.invalid_input", - "messageContains": ["must not be blank"], - "llmCalls": 0 - } - }, - { - "id": "FT-PROG-04", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "blank-text", "invalid-input"], - "summary": "投诉诊断补充内容仅含空白字符时生成接受消息被拒绝,返回无效输入错误码且不发起任何大模型调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": " " } }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.invalid_input", - "messageContains": ["must not be blank"], - "llmCalls": 0 - } - }, - { - "id": "FT-PROG-05", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "iae", "uri-probe", "version-segment"], - "summary": "模板地址误用未发布的 v2 版本段时生成信息协商提议在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v2", - "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["does not address a negotiation template of the expected phase"], - "llmCalls": 0 - } - }, - { - "id": "FT-PROG-06", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "iae", "uri-probe", "extension-name"], - "summary": "模板地址误用 Task-T 扩展名时生成信息协商接受消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Task-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["does not address a negotiation template of the expected phase"], - "llmCalls": 0 - } - }, - { - "id": "FT-PROG-07", - "api": "generateRejectFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "iae", "uri-probe", "type-misspelled"], - "summary": "模板地址中协商类型拼写错误时生成信息协商拒绝消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/informations-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮协商拒绝。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["does not address a negotiation template of the expected phase"], - "llmCalls": 0 - } - }, - { - "id": "FT-PROG-08", - "api": "generateAbortFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "iae", "uri-probe", "unknown-phase"], - "summary": "模板地址中阶段段未知时生成协商终止消息在地址校验即被拒绝,抛出参数异常且不发起任何大模型调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/terminate/v1", - "input": { "text": { "zh-CN": "已达到协商轮次上限,本次协商确认终止。" } }, - "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["does not address a negotiation template of the expected phase"], - "llmCalls": 0 - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/retry.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/retry.json deleted file mode 100644 index 39b06f6f..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/retry.json +++ /dev/null @@ -1,288 +0,0 @@ -[ - { - "id": "FT-RETRY-01", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["retry", "extract-failed", "exhaustion", "max-attempts-1"], - "summary": "最大尝试次数为1时大模型返回不可解析内容,不发起任何重试即以 LLM 响应无效错误码失败", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, - "llm": { "maxAttempts": 1, "script": [ { "$fail": "non-json" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "llm.response_invalid", - "llmCalls": 1 - } - }, - { - "id": "FT-RETRY-02", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["retry", "extract-failed", "exhaustion", "max-attempts-2"], - "summary": "最大尝试次数为2时大模型连续返回空响应与空白内容,尝试耗尽后仍以原始的 LLM 响应无效错误码失败", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, - "llm": { "maxAttempts": 2, "script": [ { "$fail": "null-response" }, { "$fail": "blank-content" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "llm.response_invalid", - "llmCalls": 2 - } - }, - { - "id": "FT-RETRY-03", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["retry", "extract-failed", "exhaustion", "max-attempts-3"], - "summary": "最大尝试次数为3时三种不可解析形态依次出现,三次尝试耗尽后以 LLM 响应无效错误码失败", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, - "llm": { "maxAttempts": 3, "script": [ { "$fail": "non-json" }, { "$fail": "blank-content" }, { "$fail": "null-response" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "llm.response_invalid", - "llmCalls": 3 - } - }, - { - "id": "FT-RETRY-04", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["retry", "infrastructure-error", "exhaustion", "max-attempts-1"], - "summary": "最大尝试次数为1时底层通道抛出运行时异常,不重试即以 llm.invocation_failed 失败", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, - "llm": { "maxAttempts": 1, "script": [ { "$fail": "runtime-exception" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "llm.invocation_failed", - "llmCalls": 1 - } - }, - { - "id": "FT-RETRY-05", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["retry", "infrastructure-error", "exhaustion", "max-attempts-2"], - "summary": "最大尝试次数为2时底层先后出现传输异常与错误响应,尝试耗尽后仍以 llm.invocation_failed 失败", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, - "llm": { "maxAttempts": 2, "script": [ { "$fail": "runtime-exception" }, { "$fail": "llm-error" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "llm.invocation_failed", - "llmCalls": 2 - } - }, - { - "id": "FT-RETRY-06", - "api": "generateRejectFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["retry", "infrastructure-error", "exhaustion", "max-attempts-3"], - "summary": "最大尝试次数为3时基础设施错误持续出现,三次尝试耗尽后以 llm.invocation_failed 失败", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮协商拒绝。" } }, - "llm": { "maxAttempts": 3, "script": [ { "$fail": "llm-error" }, { "$fail": "runtime-exception" }, { "$fail": "llm-error" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "llm.invocation_failed", - "llmCalls": 3 - } - }, - { - "id": "FT-RETRY-07", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["retry", "recovery", "extract-failed", "accept-reject", "golden-downgraded-contains"], - "summary": "首次返回不可解析内容后第二次返回合法补数接受内容,重试后成功生成携带接入端口名称与投诉分类的接受消息(共享应答单一语种,双语 golden 等值断言降级为内容包含)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。", - "en-US": "I confirm the supplemented complaint diagnosis information: the access port name is P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1 and the complaint category is dedicated-line quality degradation." - } - }, - "llm": { "maxAttempts": 3, "script": [ { "$fail": "non-json" }, { "$ref": "responses/extract.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 2, - "promptTextContains": [ - "接入端口名称", - "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "投诉分类", - "专线质差" - ], - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FT-RETRY-08", - "api": "generateProposeFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["retry", "recovery", "infrastructure-error", "golden-downgraded-contains"], - "summary": "首次底层传输异常后第二次抽取成功,重试后生成向工作台补充三项缺失信息的要数提议消息且异常细节不外泄(共享应答单一语种,双语 golden 等值断言降级为内容包含)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识三项信息,三项之间为或的关系。", - "en-US": "Please supplement the Access Port Name (e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1), the Complaint Category (e.g. dedicated-line quality degradation) and the Private Line Service Identifier; the three items are related by OR." - } - }, - "llm": { "maxAttempts": 2, "script": [ { "$fail": "runtime-exception" }, { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 2, - "promptTextContains": [ - "接入端口名称", - "投诉分类", - "专线业务标识", - "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "专线质差", - "OR" - ], - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["noLlmLeakInUserMessage", "metadataTripleShape"] - } - }, - { - "id": "FT-RETRY-09", - "api": "generateAcceptFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["retry", "recovery", "final-attempt", "target", "golden-downgraded-contains"], - "summary": "前两次分别出现基础设施错误与不可解析内容,第三次抽取成功,在最后一次尝试额度内恢复生成修复目标接受消息(共享应答单一语种,双语 golden 等值断言降级为内容包含)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。", - "en-US": "The finally confirmed intent: restore the average latency of the Shenzhen-to-Guangzhou dedicated line to within 20ms before 2026-05-15, with a peak-hour packet loss rate no higher than 1%." - } - }, - "llm": { "maxAttempts": 3, "script": [ { "$fail": "llm-error" }, { "$fail": "non-json" }, { "$ref": "responses/extract.target.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 3, - "promptTextContains": [ - "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" - ], - "metadata": { - "templateUriEcho": "Negotiation-T/target-negotiation/accept-reject/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FT-RETRY-10", - "api": "generateAbortFromText", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["retry", "recovery", "abort", "common", "golden-downgraded-contains"], - "summary": "协商终止消息首次抽取到空白内容后第二次成功,重试后生成含终止原因的协商终止消息(共享应答单一语种,双语 golden 等值断言降级为内容包含)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "input": { - "text": { - "zh-CN": "已达到协商轮次上限,本次协商确认终止。", - "en-US": "The negotiation round limit is reached; this negotiation is confirmed as terminated." - } - }, - "llm": { "maxAttempts": 2, "script": [ { "$fail": "blank-content" }, { "$ref": "responses/extract.common.abort.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 2, - "promptTextContains": [ - "已达到协商轮次上限,本次协商确认终止。" - ], - "metadata": { - "templateUriEcho": "Negotiation-T/common/abort/v1", - "contextEcho": true - }, - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "id": "FT-RETRY-11", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["retry", "non-retryable", "slot-missing"], - "summary": "抽取结果缺少结论字段属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, - "llm": { "maxAttempts": 3, "script": [ { "$ref": "responses/extract.information.accept.missing_conclusion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.field_missing", - "llmCalls": 1 - } - }, - { - "id": "FT-RETRY-12", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["retry", "non-retryable", "invalid-input"], - "summary": "结论与阶段矛盾属于不可重试错误,即使还有三次尝试额度也只调用一次大模型即失败", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, - "llm": { "maxAttempts": 3, "script": [ { "$ref": "responses/extract.information.accept.conclusion_swapped" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "negotiation.conclusion_mismatch", - "llmCalls": 1 - } - }, - { - "id": "FT-RETRY-13", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["retry", "exhaustion", "original-code"], - "summary": "两次失败形态不同(基础设施错误后接不可解析内容)时,耗尽后重抛最后一次失败的原始错误码(llm.response_invalid)而非统一的耗尽错误码", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, - "llm": { "maxAttempts": 2, "script": [ { "$fail": "runtime-exception" }, { "$fail": "non-json" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "llm.response_invalid", - "llmCalls": 2 - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/template-resolution.json b/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/template-resolution.json deleted file mode 100644 index f842ce70..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/from-text/template-resolution.json +++ /dev/null @@ -1,78 +0,0 @@ -[ - { - "id": "FT-TPL-01", - "api": "generateAcceptFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["template-resolution", "template-not-found", "inject"], - "summary": "模板加载器不可用时生成投诉诊断信息协商接受消息失败并返回 template.not_found 错误码,且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "确认补充投诉诊断信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, - "inject": "failingTemplateLoader", - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "template.not_found", - "llmCalls": 0 - } - }, - { - "id": "FT-TPL-02", - "api": "generateProposeFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["template-resolution", "template-not-found", "inject", "propose", "no-retry"], - "summary": "模板加载器不可用时生成要数提议消息失败并返回 template.not_found 错误码,模板加载发生在重试循环之外,即使配置了重试次数也不重试且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { "text": { "zh-CN": "请补充接入端口名称、投诉分类与专线业务标识三项信息。" } }, - "llm": { "maxAttempts": 3, "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "inject": "failingTemplateLoader", - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "template.not_found", - "llmCalls": 0 - } - }, - { - "id": "FT-TPL-03", - "api": "generateRejectFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["template-resolution", "template-not-found", "inject", "reject"], - "summary": "模板加载器不可用时生成信息协商拒绝消息失败并返回 template.not_found 错误码,且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { "text": { "zh-CN": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮协商拒绝。" } }, - "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, - "inject": "failingTemplateLoader", - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "template.not_found", - "llmCalls": 0 - } - }, - { - "id": "FT-TPL-04", - "api": "generateAbortFromText", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["template-resolution", "template-not-found", "inject", "abort", "common"], - "summary": "模板加载器不可用时生成协商终止消息失败并返回 template.not_found 错误码,通用终止模板同样无法加载且不发起任何 LLM 调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "input": { "text": { "zh-CN": "已达到协商轮次上限,本次协商确认终止。" } }, - "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, - "inject": "failingTemplateLoader", - "expect": { - "outcome": "failure", - "exception": "NegotiationGenerationException", - "code": "template.not_found", - "llmCalls": 0 - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/live/task-apis.json b/a2a-t-corpus/src/test/resources/negotiation-cases/live/task-apis.json deleted file mode 100644 index 0d17f3f7..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/live/task-apis.json +++ /dev/null @@ -1,266 +0,0 @@ -[ - { - "id": "LIVE-GEN-01", - "api": "generateTaskPromptFromText", - "languages": [ - "zh-CN" - ], - "priority": "P0", - "tags": [ - "live", - "happy", - "task", - "from-text" - ], - "summary": "明确场景的专线质差投诉文本,全部关键槽位(端口、分类、时间、流水号)齐备且带显式字段锚点,断言端口/分类/流水号/时间四个关键参数与回显流水号", - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": { - "zh-CN": "深圳访问广州的政企专线从5月11日早上8点21分开始,响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢,柜面和手机银行的交易接口频繁报“连接超时”,正常业务操作要等十几秒甚至半分钟才能返回结果。接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1;投诉分类:专线质差;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的政企专线响应时延骤升至320ms,柜面和手机银行的交易接口频繁报连接超时。" - } - }, - "schema": { - "$ref": "schemas/biz.complaint.params" - }, - "expect": { - "success": true, - "scenarioCode": "private-line-complaint", - "paramsContains": { - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差", - "faultTime": "2026-05-11T08:21:46Z", - "eventSerialNo": "event-id-20260511-09013" - }, - "promptTextContains": [ - "## 任务类型", - "event-id-20260511-09013" - ], - "maxLlmCalls": 4 - } - }, - { - "id": "LIVE-GEN-02", - "api": "generateTaskPromptFromText", - "languages": [ - "zh-CN" - ], - "priority": "P0", - "tags": [ - "live", - "happy", - "task", - "ran-energy-saving" - ], - "summary": "第二场景类型(能效优化任务下发)的典型文本,字段锚点齐备;校验 schema 内联声明(键为英文参数名,语义对应节能模板的 操作类型/任务对象/任务目标/任务上下文 槽位),仅断言确定性的 操作类型 与 小区制式", - "templateUri": "Task-T/network-layer/ran-energy-saving/v1", - "input": { - "text": { - "zh-CN": "帮我创建一个无线网络节能任务,操作类型:创建;任务描述:基于松山湖管委会区域和NR制式节能条件,输出无线网络节能方案,达成总功耗降低30%的目标,并按照预期输出中定义的格式返回无线网络节能结果;任务对象:区域为松山湖管委会;节能的小区制式为NR;需要节能的时间范围为每天08:00:00至12:00:00;能耗目标:总功耗降低30%;速率保障目标:全时段不低于50Mbps;预期输出:输出网络节能任务的意图报告,包含节能报告唯一标识、节能任务唯一标识、总体意图执行结果。" - } - }, - "schema": { - "type": "object", - "properties": { - "operationType": { - "type": "string", - "description": "操作类型,仅允许取值:创建、修改", - "examples": [ - "创建" - ] - }, - "region": { - "type": "string", - "description": "节能覆盖的区域名称,具体物理或逻辑区域,例如 松山湖管委会", - "examples": [ - "松山湖管委会" - ] - }, - "energyTarget": { - "type": "string", - "description": "节能的量化能耗目标,目标值格式为百分比,例如 能耗目标:30%", - "examples": [ - "能耗目标:30%" - ] - }, - "radioMode": { - "type": "string", - "description": "节能的小区制式,取值范围:LTE、NR、全部", - "examples": [ - "NR" - ] - }, - "energyTimeRange": { - "type": "string", - "description": "需要执行节能的时间范围,以天为周期循环执行", - "examples": [ - "08:00:00至12:00:00" - ] - } - }, - "required": [ - "operationType", - "region" - ] - }, - "expect": { - "success": true, - "scenarioCode": "ran-energy-saving", - "paramsContains": { - "operationType": "创建", - "radioMode": "NR" - }, - "promptTextContains": [ - "## 任务类型", - "松山湖" - ], - "maxLlmCalls": 4 - } - }, - { - "id": "LIVE-GEN-03", - "api": "generateTaskPromptFromText", - "languages": [ - "zh-CN" - ], - "priority": "P1", - "tags": [ - "live", - "boundary", - "task", - "from-text" - ], - "summary": "措辞模糊但场景可判定的专线投诉文本(口语化“老掉线”),场景判定仍锚定专线投诉;因缺少模板必选字段OSS侧事件流水号,validate 步骤正确拒绝(success=false),仅断言场景识别与参数提取", - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": { - "zh-CN": "我们有一条政企专线最近老掉线,柜面交易动不动就超时,麻烦帮忙查查网络侧到底是什么问题。接入端口名称:P781-福田中心-PTN7900-2-TPA1EG24-03;投诉分类:专线质差;投诉详情:政企专线最近老掉线,柜面交易动不动就超时。" - } - }, - "schema": { - "$ref": "schemas/biz.complaint.params" - }, - "expect": { - "success": false, - "maxLlmCalls": 4 - } - }, - { - "id": "LIVE-GEN-04", - "api": "generateTaskPromptFromText", - "languages": [ - "zh-CN" - ], - "priority": "P0", - "tags": [ - "live", - "partial-info", - "task", - "from-text" - ], - "summary": "部分信息缺失的专线质差投诉文本(无问题发生时间、无OSS侧事件流水号),因缺少模板必选字段OSS侧事件流水号,validate 步骤正确拒绝(success=false);paramsAbsent 不再断言,因拒绝时不返回参数数据", - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": { - "zh-CN": "广州南沙的政企专线最近频繁出现丢包,客户反馈视频会议卡顿明显,请帮忙诊断一下网络侧原因。接入端口名称:P781-南沙-PTN7900-9-TPA1EG24-31;投诉分类:专线质差;投诉详情:专线频繁丢包导致视频会议卡顿。" - } - }, - "schema": { - "$ref": "schemas/biz.complaint.params" - }, - "expect": { - "success": false, - "maxLlmCalls": 4 - } - }, - { - "id": "LIVE-GEN-05", - "api": "generateTaskPromptFromText", - "languages": [ - "zh-CN" - ], - "priority": "P1", - "tags": [ - "live", - "normalization", - "task", - "from-text" - ], - "summary": "槽位值归一化探针:问题发生时间以口语化中文(“5月16日上午10点20分左右”)给出,同时提供了归一化后的 ISO-8601 时间字段锚点;qwen3-32b 对 faultTime 的提取不稳定,validate 步骤可能因 content.format_error 拒绝,期望 success=false 时仅断言结果成败", - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": { - "zh-CN": "惠州江北的政企专线上2026年5月16日上午10点20分左右出现断纤告警,业务完全中断,客户柜面交易全部无法办理。接入端口名称:P781-惠州江北-PTN7900-7-TPA1EG24-21;投诉分类:专线中断;问题发生时间:2026-05-16T10:20:00Z;OSS侧故障流水号:fault-id-1-017-20260516-11234;投诉详情:政企专线上出现断纤告警,业务完全中断,客户柜面交易全部无法办理。" - } - }, - "schema": { - "$ref": "schemas/biz.complaint.params" - }, - "expect": { - "success": false, - "maxLlmCalls": 4 - } - }, - { - "id": "LIVE-VAL-01", - "api": "validateTaskPromptAndDataFilling", - "languages": [ - "zh-CN" - ], - "priority": "P0", - "tags": [ - "live", - "happy", - "task", - "validate" - ], - "summary": "结构合规且信息完整的专线质差任务报文(按 Task-T 专线投诉模板结构手工构造,任务对象与任务上下文均已填写),语义校验通过并完整提取四个关键参数", - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": { - "text": "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n## 任务描述(Task Description)\n基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n## 任务对象(Task Object)\n接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n\n## 任务上下文(Task Context)\n1. 投诉分类:“专线质差”\n2. 问题发生时间:“2026-05-11T08:21:46Z”\n3. OSS侧事件流水号:“event-id-20260511-09013”\n4. 投诉详情:“从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报‘连接超时’,正常业务操作要等十几秒甚至半分钟才能返回结果!”\n\n## 预期输出(Expected Output)\n要求投诉诊断任务的结果包含如下信息:\n1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n2. 诊断结果详细信息; (必选)\n3. 修复建议; (可选)\n4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位等信息; (可选)" - }, - "schema": { - "$ref": "schemas/biz.complaint.params" - }, - "expect": { - "success": true, - "paramsContains": { - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差", - "faultTime": "2026-05-11T08:21:46Z", - "eventSerialNo": "event-id-20260511-09013" - }, - "maxLlmCalls": 3 - } - }, - { - "id": "LIVE-VAL-02", - "api": "validateTaskPromptAndDataFilling", - "languages": [ - "zh-CN" - ], - "priority": "P1", - "tags": [ - "live", - "semantic-reject", - "task", - "validate" - ], - "summary": "语义冲突探针:结构合规的任务报文中投诉分类声明为“专线质差”(时延/丢包劣化),而投诉详情明确描述专线“完全中断、业务完全不通”,参数与正文语义直接矛盾;qwen3-32b 无法可靠检测此冲突,期望校验通过但参数完整提取", - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": { - "text": "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n## 任务描述(Task Description)\n基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n## 任务对象(Task Object)\n接入端口名称:P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)\n\n## 任务上下文(Task Context)\n1. 投诉分类:“专线质差”\n2. 问题发生时间:“2026-05-16T09:00:00Z”\n3. OSS侧事件流水号:“fault-id-1-017-20260516-11234”\n4. 投诉详情:“该专线自5月16日上午9点起完全中断,业务完全不通,客户所有柜面交易均无法办理。”\n\n## 预期输出(Expected Output)\n要求投诉诊断任务的结果包含如下信息:\n1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n2. 诊断结果详细信息; (必选)\n3. 修复建议; (可选)\n4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位等信息; (可选)" - }, - "schema": { - "$ref": "schemas/biz.complaint.params" - }, - "expect": { - "success": true, - "paramsContains": { - "accessPort": "P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)", - "bizScenario": "专线质差", - "eventSerialNo": "fault-id-1-017-20260516-11234" - }, - "maxLlmCalls": 3 - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/boundary-flows.json b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/boundary-flows.json deleted file mode 100644 index e5b9ec56..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/boundary-flows.json +++ /dev/null @@ -1,1084 +0,0 @@ -[ - { - "id": "SC-EXH-01", - "summary": "轮次预算耗尽闭环:投诉诊断任务缺参后两轮要数均因工作台端口台账不可查被拒,round 3 达到 maxRounds 上限后 OMC 生成终止消息结束协商", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateTaskPromptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": { - "zh-CN": "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢,柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。" - } - }, - "llm": { - "script": [ - "{\"slots\":{\"任务对象\":\"深圳访问广州的专线\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\"},\"slot_errors\":[]}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" - }, - "promptTextContains": ["event-id-20260511-09013"] - } - }, - { - "step": 2, - "role": "B", - "api": "validateTaskPromptAndDataFilling", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": { "fromStep": 1 }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "script": [ - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "faultTime": "2026-05-11T08:21:46Z", - "eventSerialNo": "event-id-20260511-09013" - }, - "missingParams": ["accessPort", "bizScenario"] - } - }, - { - "step": 3, - "role": "B", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_propose" - } - }, - { - "step": 4, - "role": "A", - "api": "generateRejectFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "端口资源台账暂不可查,无法提供接入端口名称,本轮信息协商拒绝。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_reject", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 5, - "role": "B", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 3 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "第2轮信息协商:请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)与投诉分类(如专线质差)两项。" - } - }, - "llm": { - "script": [ - "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"}],\"relationship\":\"AND\"}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - }, - { - "step": 6, - "role": "A", - "api": "generateRejectFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 3 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "端口资源台账仍不可查,无法提供接入端口名称,本轮信息协商再次拒绝。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_reject", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 7, - "role": "B", - "api": "generateAbortFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 3 }, - "templateUri": "Negotiation-T/common/abort/v1", - "input": { - "text": { - "zh-CN": "信息协商两轮未果,已达到协商轮次上限,本次协商确认终止。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "abort", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 8, - "role": "A", - "api": "validateAbortPromptAndDataFilling", - "prompt": { "fromStep": 7 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 3 }, - "templateUri": "Negotiation-T/common/abort/v1", - "schema": { "$ref": "schemas/flat" }, - "llm": { "script": [ { "$ref": "responses/semantic.common.abort.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 3, - "maxRounds": 3, - "termination_reason": "已达到协商轮次上限" - } - } - } - ], - "expectFlow": { "terminalCondition": "exhausted", "roundsUsed": 3 } - }, - { - "id": "SC-EXH-02", - "summary": "过期重放边界:工作台以 round 6 超出轮次预算的上下文重放校验 OMC 的要数提议,被规则门直接拦截且不调用大模型,修正为 round 5 后重放校验通过", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "B", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_propose" - } - }, - { - "step": 2, - "role": "A", - "api": "validateProposePromptAndDataFilling", - "prompt": { "fromStep": 1 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 6, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "expect": { - "outcome": "failure", - "code": "negotiation.rule_violation", - "slotErrors": [ { "slot": "round", "code": "negotiation.round_exceeded" } ], - "llmCalls": 0 - } - }, - { - "step": 3, - "role": "A", - "api": "validateProposePromptAndDataFilling", - "prompt": { "fromStep": 1 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 5, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - } - } - } - ], - "expectFlow": { "roundsUsed": 6 } - }, - { - "id": "SC-ERR-01", - "summary": "闭环中错误恢复:工作台首次生成任务报文成功,OMC 校验任务报文时语义校验首次返回不可解析内容,重试第二次成功后闭环继续推进,直至缺参被补齐且接受报文提取成功(不可解析探针挂语义校验步骤:任务槽位抽取路径无重试,maxAttempts 仅作用于语义校验)", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateTaskPromptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": { - "zh-CN": "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢,柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。" - } - }, - "llm": { - "script": [ - "{\"slots\":{\"任务对象\":\"深圳访问广州的专线\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\"},\"slot_errors\":[]}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" - }, - "promptTextContains": ["event-id-20260511-09013"] - } - }, - { - "step": 2, - "role": "B", - "api": "validateTaskPromptAndDataFilling", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": { "fromStep": 1 }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "maxAttempts": 3, - "script": [ - { "$fail": "non-json" }, - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 2, - "params": { - "faultTime": "2026-05-11T08:21:46Z", - "eventSerialNo": "event-id-20260511-09013" - }, - "missingParams": ["accessPort", "bizScenario"] - } - }, - { - "step": 3, - "role": "B", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_propose" - } - }, - { - "step": 4, - "role": "A", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "端口台账已查到:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差,确认补充。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_accept", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 5, - "role": "B", - "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 4 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"], - "paramsFromStep": 2 - } - } - ], - "expectFlow": { - "terminalCondition": "accept", - "roundsUsed": 2, - "distinctMessages": true, - "missingParamsFilled": 2 - } - }, - { - "id": "SC-ERR-02", - "summary": "闭环中错误恢复:工作台首轮补数仅提供投诉分类缺接入端口名称,被 OMC 语义校验拒绝,第2轮补充端口名后重新接受并提取成功,缺参最终补齐", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateTaskPromptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": { - "zh-CN": "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢,柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。" - } - }, - "llm": { - "script": [ - "{\"slots\":{\"任务对象\":\"深圳访问广州的专线\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\"},\"slot_errors\":[]}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" - }, - "promptTextContains": ["event-id-20260511-09013"] - } - }, - { - "step": 2, - "role": "B", - "api": "validateTaskPromptAndDataFilling", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": { "fromStep": 1 }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "script": [ - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "faultTime": "2026-05-11T08:21:46Z", - "eventSerialNo": "event-id-20260511-09013" - }, - "missingParams": ["accessPort", "bizScenario"] - } - }, - { - "step": 3, - "role": "B", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_propose" - } - }, - { - "step": 4, - "role": "A", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "投诉分类为专线质差;接入端口名称暂时无法提供,端口台账查询中。" - } - }, - "llm": { - "script": [ - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["Accept", "专线质差"], - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 5, - "role": "B", - "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 4 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.reject" } ] }, - "expect": { - "outcome": "failure", - "code": "negotiation.semantic_rejected", - "slotErrors": [ { "slot": "accessPort", "code": "negotiation.field_missing" } ], - "llmCalls": 1 - } - }, - { - "step": 6, - "role": "A", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "端口台账已恢复,我补充:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_accept", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 7, - "role": "B", - "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 6 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"], - "paramsFromStep": 2 - } - } - ], - "expectFlow": { - "terminalCondition": "accept", - "roundsUsed": 2, - "distinctMessages": true, - "missingParamsFilled": 2 - } - }, - { - "id": "SC-ERR-03", - "summary": "重传幂等:同一条接受补数报文被 OMC 重复校验两次,两次提取的 FilledParamData(接入端口名称与投诉分类)完全一致,重传不产生副作用", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "端口台账已查到:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差,确认补充。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_accept", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 2, - "role": "B", - "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 1 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "step": 3, - "role": "B", - "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 1 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - } - ], - "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2 } - }, - { - "id": "SC-ERR-04", - "summary": "乱序失同步探针:工作台以滞后的 round 1 上下文校验 OMC 在 round 2 生成的要数提议,规则门不拦截失同步(doc-gap 探针)", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "B", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "第2轮信息协商:任务报文仍缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差)。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_propose" - } - }, - { - "step": 2, - "role": "A", - "api": "validateProposePromptAndDataFilling", - "prompt": { "fromStep": 1 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 1, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - } - } - } - ], - "expectFlow": { "roundsUsed": 2 } - }, - { - "id": "SC-ERR-05", - "summary": "截断消息探针:传输中被截断的要数提议缺失后两项信息,工作台侧语义校验以槽位缺失为由拒绝", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "B", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_propose" - } - }, - { - "step": 2, - "role": "A", - "api": "validateProposePromptAndDataFilling", - "prompt": { - "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分" - }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "script": [ - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"投诉分类\",\"code\":\"missing\",\"facts\":{\"section_label\":\"投诉分类\"}},{\"slot_name\":\"专线业务标识\",\"code\":\"missing\",\"facts\":{\"section_label\":\"专线业务标识\"}}],\"params\":{}}" - ] - }, - "expect": { - "outcome": "failure", - "code": "negotiation.semantic_rejected", - "slotErrors": [ - { "slot": "投诉分类", "code": "negotiation.rule_violation" }, - { "slot": "专线业务标识", "code": "negotiation.rule_violation" } - ], - "llmCalls": 1 - } - } - ], - "expectFlow": { "roundsUsed": 1 } - }, - { - "id": "SC-ERR-06", - "summary": "编码边界探针:带 UTF-8 BOM 前缀或全角标点的接受补数报文可正常校验提取,GBK 乱码形态的报文被语义校验拒绝", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "端口台账已查到:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差,确认补充。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_accept" - } - }, - { - "step": 2, - "role": "B", - "api": "validateAcceptPromptAndDataFilling", - "prompt": { - "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:专线质差" - }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - } - } - }, - { - "step": 3, - "role": "B", - "api": "validateAcceptPromptAndDataFilling", - "prompt": { - "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1.接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2.投诉分类:专线质差" - }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "script": [ - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\",\"bizScenario\":\"专线质差\"}}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - } - } - }, - { - "step": 4, - "role": "B", - "api": "validateAcceptPromptAndDataFilling", - "prompt": { - "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:锟斤拷锟斤拷\n2. 投诉分类:锟斤拷烫烫" - }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "script": [ - "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"接入端口名称\",\"code\":\"unreadable\",\"facts\":{\"section_label\":\"接入端口名称\"}},{\"slot_name\":\"投诉分类\",\"code\":\"unreadable\",\"facts\":{\"section_label\":\"投诉分类\"}}],\"params\":{}}" - ] - }, - "expect": { - "outcome": "failure", - "code": "negotiation.semantic_rejected", - "slotErrors": [ - { "slot": "接入端口名称", "code": "negotiation.rule_violation" }, - { "slot": "投诉分类", "code": "negotiation.rule_violation" } - ], - "llmCalls": 1 - } - } - ], - "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2 } - }, - { - "id": "SC-ERR-07", - "summary": "双会话交错:event-id-20260511-09013 与 fault-id-1-017-20260516-11234 两条投诉工单的缺参协商闭环步骤交替执行,各自提取的上下文与业务参数互不串扰", - "languages": ["zh-CN"], - "roles": ["A1", "B1", "A2", "B2"], - "rolesDesc": { - "A1": "工作台(client,工单 event-id-20260511-09013 的任务发起/补数方)", - "B1": "OMC(server,工单 event-id-20260511-09013 的执行/要数方,协商发起方)", - "A2": "工作台(client,工单 fault-id-1-017-20260516-11234 的任务发起/补数方)", - "B2": "OMC(server,工单 fault-id-1-017-20260516-11234 的执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A1", - "api": "generateTaskPromptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": { - "zh-CN": "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢,柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。" - } - }, - "llm": { - "script": [ - "{\"slots\":{\"任务对象\":\"深圳访问广州的专线\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\"},\"slot_errors\":[]}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" - }, - "promptTextContains": ["event-id-20260511-09013"] - } - }, - { - "step": 2, - "role": "A2", - "api": "generateTaskPromptFromText", - "context": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": { - "zh-CN": "前海片区一条专线从5月16日起完全中断,柜面业务全部不通,客户已发起投诉。OSS侧事件流水号:fault-id-1-017-20260516-11234。" - } - }, - "llm": { - "script": [ - "{\"slots\":{\"任务对象\":\"前海片区专线\",\"任务上下文\":\"投诉分类:待补充;OSS侧事件流水号:fault-id-1-017-20260516-11234;投诉详情:前海片区一条专线从5月16日起完全中断,柜面业务全部不通\"},\"slot_errors\":[]}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" - }, - "promptTextContains": ["fault-id-1-017-20260516-11234"] - } - }, - { - "step": 3, - "role": "B1", - "api": "validateTaskPromptAndDataFilling", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": { "fromStep": 1 }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "script": [ - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "faultTime": "2026-05-11T08:21:46Z", - "eventSerialNo": "event-id-20260511-09013" - }, - "missingParams": ["accessPort", "bizScenario"] - } - }, - { - "step": 4, - "role": "B2", - "api": "validateTaskPromptAndDataFilling", - "context": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": { "fromStep": 2 }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "script": [ - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"eventSerialNo\":\"fault-id-1-017-20260516-11234\"}}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "eventSerialNo": "fault-id-1-017-20260516-11234" - }, - "missingParams": ["accessPort", "bizScenario"] - } - }, - { - "step": 5, - "role": "B1", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_propose" - } - }, - { - "step": 6, - "role": "B2", - "api": "generateProposeFromText", - "context": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100))、投诉分类(举例:专线中断)。" - } - }, - "llm": { - "script": [ - "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)\"},{\"name\":\"投诉分类\",\"value\":\"举例:专线中断\"}],\"relationship\":\"OR\"}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1" - }, - "promptTextContains": ["P781-前海"] - } - }, - { - "step": 7, - "role": "A1", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "端口台账已恢复,我补充:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_accept", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 8, - "role": "A2", - "api": "generateAcceptFromText", - "context": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "端口台账已查到:接入端口名称为P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100),投诉分类为专线中断,确认补充。" - } - }, - "llm": { - "script": [ - "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)\"},{\"name\":\"投诉分类\",\"value\":\"专线中断\"}]}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["Accept", "P781-前海"], - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 9, - "role": "B1", - "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 7 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"], - "paramsFromStep": 3 - } - }, - { - "step": 10, - "role": "B2", - "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 8 }, - "context": { "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "script": [ - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"accessPort\":\"P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)\",\"bizScenario\":\"专线中断\"}}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "7f3c9a2e-1b4d-4e8f-9a6c-2d5b8e7f4a1c", - "round": 2, - "maxRounds": 5, - "accessPort": "P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)", - "bizScenario": "专线中断" - }, - "contracts": ["contextKeysInMergedParams"], - "paramsFromStep": 4 - } - } - ], - "expectFlow": { - "terminalCondition": "accept", - "roundsUsed": 2, - "distinctMessages": true, - "missingParamsFilled": 3 - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json deleted file mode 100644 index abf27e25..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/feasibility-flows.json +++ /dev/null @@ -1,423 +0,0 @@ -[ - { - "id": "SC-FSB-01", - "summary": "端口扩容可行性协商单轮闭环(双语):工作台就接入端口带宽扩容能否在割接窗口内完成发起可行性评估请求,OMC 评估通过后生成接受消息,工作台校验确认达成一致", - "languages": ["zh-CN", "en-US"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,扩容评估请求/确认方)", - "B": "OMC(server,可行性评估方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps,割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟。", - "en-US": "Please assess whether the dedicated-line access port expansion can be completed within the cutover window: expand the access port bandwidth from 100Mbps to 1000Mbps, with the cutover window limited to 02:00-06:00 on 2026-05-30 and service interruption no longer than 30 minutes." - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", - "contextEcho": true - }, - "promptTextContains": ["100Mbps", "1000Mbps", "2026", "02:00-06:00"], - "contracts": ["metadataTripleShape"] - } - }, - { - "step": 2, - "role": "B", - "api": "validateProposePromptAndDataFilling", - "prompt": { "fromStep": 1 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "schema": { "$ref": "schemas/flat" }, - "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 1, - "maxRounds": 5, - "port_expansion": "feasible" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "step": 3, - "role": "B", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "评估结论:端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束,本次可行性协商确认结束。", - "en-US": "Evaluation outcome: the port expansion can be completed within the cutover window and satisfies the 30-minute interruption constraint; this negotiation is confirmed as concluded." - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["Accept", "30"], - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 4, - "role": "A", - "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 3 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/flat" }, - "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 1, - "maxRounds": 5, - "port_expansion": "feasible" - }, - "contracts": ["contextKeysInMergedParams"] - } - } - ], - "expectFlow": { "terminalCondition": "accept", "roundsUsed": 1, "distinctMessages": true } - }, - { - "id": "SC-FSB-02", - "summary": "端口扩容可行性协商两轮演进:round 1 因汇聚机房板卡槽位不足评估不可行并拒绝,工作台于 round 2 改提更换高容量板卡并拆分割接窗口的替代方案,OMC 评估通过并接受", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,扩容评估请求/确认方)", - "B": "OMC(server,可行性评估方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps,割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_propose" - } - }, - { - "step": 2, - "role": "B", - "api": "generateRejectFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "经评估,受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成,本次可行性协商确认无法达成一致。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.reject.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_reject", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 3, - "role": "A", - "api": "validateRejectPromptAndDataFilling", - "prompt": { "fromStep": 2 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/flat" }, - "llm": { - "script": [ - "{\"semantic_verdict\":true,\"negotiation_type\":\"feasibility\",\"errors\":[],\"params\":{\"port_expansion\":\"受汇聚机房板卡槽位不足限制不可行\"}}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 1, - "maxRounds": 5, - "port_expansion": "受汇聚机房板卡槽位不足限制不可行" - } - } - }, - { - "step": 4, - "role": "A", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "原扩容方案受汇聚机房板卡槽位不足限制无法在割接窗口内完成,现提出替代方案:更换高容量板卡,并将扩容拆分为两个割接窗口执行,请评估确认。" - } - }, - "llm": { - "script": [ - "{\"feasibility_negotiation_description\":\"原扩容方案受汇聚机房板卡槽位不足限制无法在指定割接窗口内完成,替代方案见<不可行评估详情及提议>;请确认。\",\"action\":\"PROPOSE_ALTERNATIVE_ON_FAILURE\",\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":[{\"name\":\"不可行项\",\"value\":\"接入端口带宽由100Mbps扩容至1000Mbps受汇聚机房板卡槽位不足限制,无法在割接窗口内完成\"},{\"name\":\"替代提议\",\"value\":\"更换高容量板卡,并将扩容拆分为两个割接窗口执行\"}]}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", - "contextEcho": true - }, - "promptTextContains": ["1000Mbps"], - "contracts": ["metadataTripleShape"] - } - }, - { - "step": 5, - "role": "B", - "api": "validateProposePromptAndDataFilling", - "prompt": { "fromStep": 4 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "schema": { "$ref": "schemas/flat" }, - "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "port_expansion": "feasible" - } - } - }, - { - "step": 6, - "role": "B", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "替代方案评估可行:更换高容量板卡并将扩容拆分为两个割接窗口后,端口扩容可在割接窗口内完成且业务中断不超过30分钟,本次可行性协商确认结束。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_accept", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 7, - "role": "A", - "api": "validateAcceptPromptAndDataFilling", - "prompt": { "fromStep": 6 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/flat" }, - "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "port_expansion": "feasible" - }, - "contracts": ["contextKeysInMergedParams"] - } - } - ], - "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2, "distinctMessages": true } - }, - { - "id": "SC-FSB-03", - "summary": "端口扩容可行性协商中止:round 1 评估因汇聚机房板卡槽位不足不可行且双方无法推进,round 2 生成终止消息并经校验确认协商提前终止", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,扩容评估请求/确认方)", - "B": "OMC(server,可行性评估方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps,割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_propose" - } - }, - { - "step": 2, - "role": "B", - "api": "generateRejectFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "经评估,受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成,本次可行性协商确认无法达成一致。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.reject.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_reject" - } - }, - { - "step": 3, - "role": "A", - "api": "generateAbortFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "input": { - "text": { - "zh-CN": "汇聚机房板卡槽位不足短期无法解决,端口扩容可行性无法在轮次预算内达成一致,本次协商确认终止。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "abort", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 4, - "role": "B", - "api": "validateAbortPromptAndDataFilling", - "prompt": { "fromStep": 3 }, - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "schema": { "$ref": "schemas/flat" }, - "llm": { "script": [ { "$ref": "responses/semantic.common.abort.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "termination_reason": "已达到协商轮次上限" - } - } - } - ], - "expectFlow": { "terminalCondition": "abort", "roundsUsed": 2, "distinctMessages": true } - }, - { - "id": "SC-FSB-04", - "summary": "端口扩容可行性协商确认请求闭环:工作台 round 1 发起扩容评估请求,OMC 评估可行后于 round 2 发出评估可行并请求确认的提议,工作台同意后生成接受消息完成闭环", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,扩容评估请求/确认方)", - "B": "OMC(server,可行性评估与确认请求发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "请评估专线接入端口扩容方案能否在割接窗口内完成:接入端口带宽由100Mbps扩容至1000Mbps,割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_propose" - } - }, - { - "step": 2, - "role": "B", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "针对专线接入端口扩容方案的目标达成可行性评估已完成:扩容可在割接窗口2026年5月30日02:00-06:00内完成,业务中断时长满足不超过30分钟的约束,结论为可行。评估目标可行,是否同意按照此目标继续执行?" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.propose.confirm.goal" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["评估可行时的确认请求", "评估目标可行,是否同意按照此目标继续执行?"], - "metadata": { - "templateUriEcho": "Negotiation-T/feasibility-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - }, - { - "step": 3, - "role": "A", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "同意按照此目标继续执行。端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束,本次可行性协商确认结束。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.feasibility.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "feasibility_accept", - "contracts": ["conclusionLiteralPresent"] - } - } - ], - "expectFlow": { "terminalCondition": "accept", "roundsUsed": 2, "distinctMessages": true } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/information-flows.json b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/information-flows.json deleted file mode 100644 index 55ed7bef..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/information-flows.json +++ /dev/null @@ -1,464 +0,0 @@ -[ - { - "id": "SC-INFO-01", - "summary": "投诉诊断五步闭环(双语):工作台从投诉文本生成缺参任务报文,OMC 校验发现缺接入端口名称与投诉分类后发起信息协商要数,工作台接受补数,OMC 提取补参补齐闭环", - "languages": ["zh-CN", "en-US"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateTaskPromptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": { - "zh-CN": "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢,柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。", - "en-US": "Starting from 08:30 on May 11, the response latency of the dedicated line from Shenzhen to Guangzhou surged from an average of 12ms to 320ms, access to the core trading system in the Guangzhou data center became very slow, and the trading interfaces of the counter and mobile banking frequently report \"connection timeout\". OSS-side event sequence number: event-id-20260511-09013." - } - }, - "llm": { - "script": [ - "{\"slots\":{\"任务对象\":\"深圳访问广州的专线\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\",\"task_object\":\"Shenzhen-Guangzhou dedicated line\",\"task_context\":\"complaint category: to be supplemented; problem occurrence time: 2026-05-11T08:21:46Z; OSS-side event sequence number: event-id-20260511-09013; complaint details: the response latency from Shenzhen to Guangzhou surged from an average of 12ms to 320ms and the trading interfaces of the counter and mobile banking frequently report connection timeout\"},\"slot_errors\":[]}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" - }, - "promptTextContains": ["event-id-20260511-09013", "2026-05-11T08:21:46Z"] - } - }, - { - "step": 2, - "role": "B", - "api": "validateTaskPromptAndDataFilling", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": { "fromStep": 1 }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "script": [ - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "faultTime": "2026-05-11T08:21:46Z", - "eventSerialNo": "event-id-20260511-09013" - }, - "missingParams": ["accessPort", "bizScenario"] - } - }, - { - "step": 3, - "role": "B", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。", - "en-US": "The task message is missing the access port name and the complaint category; please supplement the Access Port Name (e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1) and the Complaint Category (e.g. dedicated-line quality degradation); the Private Line Service Identifier is also accepted, and the missing items are related by OR." - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1" - }, - "promptTextContains": ["P533-"], - "contracts": ["metadataTripleShape"] - } - }, - { - "step": 4, - "role": "A", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "端口台账已查到:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差,确认补充。", - "en-US": "The port inventory has been located: the access port name is P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1 and the complaint category is dedicated-line quality degradation; confirmed." - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["P533-"], - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 5, - "role": "B", - "api": "validateAcceptPromptAndDataFilling", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "fromStep": 4 }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"], - "paramsFromStep": 2 - } - } - ], - "expectFlow": { - "terminalCondition": "accept", - "roundsUsed": 2, - "distinctMessages": true, - "missingParamsFilled": 2 - } - }, - { - "id": "SC-INFO-02", - "summary": "投诉诊断闭环两轮演进:工作台首轮因端口资源台账暂不可查拒绝补数,OMC 第2轮重新要数,台账恢复后工作台接受补数,OMC 提取补参补齐闭环", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateTaskPromptFromDataWithSchema", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "data": { - "faultStartTime": "2026-05-11T08:21:46Z", - "ticketNo": "event-id-20260511-09013", - "faultDetailText": "深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时" - } - }, - "schema": { - "type": "object", - "properties": { - "portName": { - "type": "string", - "description": "接入端口名称,物理端口格式为网元名称+单板号+单板型号+端口号" - }, - "complaintScenario": { - "type": "string", - "description": "投诉分类,取值范围:专线中断、专线质差" - }, - "faultStartTime": { - "type": "string", - "description": "问题发生时间,ISO-8601 时间戳" - }, - "ticketNo": { - "type": "string", - "description": "OSS侧事件流水号" - }, - "faultDetailText": { - "type": "string", - "description": "投诉详情" - } - }, - "required": ["portName", "complaintScenario"] - }, - "llm": { - "script": [ - "{\"slots\":{\"任务对象\":\"深圳访问广州的专线\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\"},\"slot_errors\":[]}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" - }, - "promptTextContains": ["event-id-20260511-09013"] - } - }, - { - "step": 2, - "role": "B", - "api": "validateTaskPromptAndDataFilling", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": { "fromStep": 1 }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "script": [ - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "faultTime": "2026-05-11T08:21:46Z", - "eventSerialNo": "event-id-20260511-09013" - }, - "missingParams": ["accessPort", "bizScenario"] - } - }, - { - "step": 3, - "role": "B", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_propose" - } - }, - { - "step": 4, - "role": "A", - "api": "generateRejectFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "端口资源台账暂不可查,无法提供接入端口名称,本轮信息协商拒绝。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.reject.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_reject", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 5, - "role": "B", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "第2轮信息协商:端口资源台账已恢复,请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)与投诉分类(如专线质差)两项。" - } - }, - "llm": { - "script": [ - "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"}],\"relationship\":\"OR\"}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Negotiation-T/information-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - }, - { - "step": 6, - "role": "A", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "端口台账已恢复,我补充:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_accept", - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "step": 7, - "role": "B", - "api": "validateAcceptPromptAndDataFilling", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "fromStep": 6 }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"], - "paramsFromStep": 2 - } - } - ], - "expectFlow": { - "terminalCondition": "accept", - "roundsUsed": 2, - "distinctMessages": true, - "missingParamsFilled": 2 - } - }, - { - "id": "SC-INFO-03", - "summary": "投诉诊断闭环中断:任务报文缺参协商发起后,投诉被 OSS 侧工单撤销,工作台于第2轮生成终止消息结束协商", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,任务发起/补数方)", - "B": "OMC(server,执行/要数方,协商发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateTaskPromptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "input": { - "text": { - "zh-CN": "深圳访问广州的专线从5月11号早上8点半开始响应时延从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢,柜面和手机银行的交易接口频繁报“连接超时”。OSS侧事件流水号:event-id-20260511-09013。" - } - }, - "llm": { - "script": [ - "{\"slots\":{\"任务对象\":\"深圳访问广州的专线\",\"任务上下文\":\"投诉分类:待补充;问题发生时间:2026-05-11T08:21:46Z;OSS侧事件流水号:event-id-20260511-09013;投诉详情:深圳访问广州的响应时延从平均12ms骤升至320ms,柜面和手机银行的交易接口频繁报连接超时\"},\"slot_errors\":[]}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Task-T/network-layer/private-line-complaint/v1" - }, - "promptTextContains": ["event-id-20260511-09013"] - } - }, - { - "step": 2, - "role": "B", - "api": "validateTaskPromptAndDataFilling", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Task-T/network-layer/private-line-complaint/v1", - "prompt": { "fromStep": 1 }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "script": [ - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":null,\"bizScenario\":null,\"faultTime\":\"2026-05-11T08:21:46Z\",\"eventSerialNo\":\"event-id-20260511-09013\"}}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "faultTime": "2026-05-11T08:21:46Z", - "eventSerialNo": "event-id-20260511-09013" - }, - "missingParams": ["accessPort", "bizScenario"] - } - }, - { - "step": 3, - "role": "B", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "任务报文缺少接入端口名称与投诉分类,请补充:接入端口名称(举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(举例:专线质差),也可提供专线业务标识,缺失项之间为或的关系。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.information.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "information_propose" - } - }, - { - "step": 4, - "role": "A", - "api": "generateAbortFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "input": { - "text": { - "zh-CN": "OSS侧工单撤销:该投诉已被撤单,诊断任务取消,本次协商确认终止。" - } - }, - "llm": { - "script": [ - "{\"termination_reason\":\"诊断任务已被 OSS 侧工单撤销,本次协商确认终止。\"}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["Abort", "工单撤销"], - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 5, - "role": "B", - "api": "validateAbortPromptAndDataFilling", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "prompt": { "fromStep": 4 }, - "schema": { "$ref": "schemas/flat" }, - "llm": { - "script": [ - "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{\"termination_reason\":\"诊断任务已被 OSS 侧工单撤销\"}}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "termination_reason": "诊断任务已被 OSS 侧工单撤销" - } - } - } - ], - "expectFlow": { - "terminalCondition": "abort", - "roundsUsed": 2, - "distinctMessages": true - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/target-flows.json b/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/target-flows.json deleted file mode 100644 index a39b58aa..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/scenarios/target-flows.json +++ /dev/null @@ -1,361 +0,0 @@ -[ - { - "id": "SC-TGT-01", - "summary": "修复目标协商单轮闭环:工作台就专线质差修复目标发起目标协商提议,OMC 确认最终意图生成接受消息,工作台校验提取时延目标与完成时限参数", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,修复目标协商发起/确认方)", - "B": "OMC(server,修复目标澄清/确认方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "专线质差投诉的修复目标协商:修复意图是在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%;待澄清平均时延目标是恢复至20ms以内还是50ms以内、修复完成时限是48小时内还是72小时内,请澄清并确认。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "target_propose" - } - }, - { - "step": 2, - "role": "B", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "target_accept", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 3, - "role": "A", - "api": "validateAcceptPromptAndDataFilling", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "prompt": { "fromStep": 2 }, - "schema": { - "type": "object", - "properties": { - "latency_target": { "type": "string" }, - "repair_deadline": { "type": "string" } - } - }, - "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "latency_target": "within 20ms", - "repair_deadline": "2026-05-15" - }, - "contracts": ["contextKeysInMergedParams"] - } - } - ], - "expectFlow": { - "terminalCondition": "accept", - "roundsUsed": 2, - "distinctMessages": true - } - }, - { - "id": "SC-TGT-02", - "summary": "修复目标协商两轮演进:首轮因光缆割接窗口尚未确认被拒,工作台对齐时延目标后于第2轮重新提议,OMC 确认接受且参数校验提取成功", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,修复目标协商发起/确认方)", - "B": "OMC(server,修复目标澄清/确认方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "专线质差投诉的修复目标协商:修复意图是在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%;待澄清平均时延目标是恢复至20ms以内还是50ms以内、修复完成时限是48小时内还是72小时内,请澄清并确认。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "target_propose" - } - }, - { - "step": 2, - "role": "B", - "api": "generateRejectFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "因光缆割接窗口尚未确认,时延目标未能完全澄清,本轮目标协商拒绝。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.reject.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "target_reject", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 3, - "role": "A", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "第2轮目标协商:割接窗口已确认为2026年5月14日22:00-02:00,时延目标已对齐为平均时延20ms以内、高峰丢包率不高于1%;仅完成时限(48小时内还是72小时内)仍待澄清,请澄清和确认。" - } - }, - "llm": { - "script": [ - "{\"target_negotiation_description\":\"光缆割接窗口已确认为2026年5月14日22:00-02:00,时延目标已对齐见<意图理解陈述>;仅完成时限仍待澄清,见<待澄清内容>;请澄清并确认。\",\"intent_understanding\":[{\"name\":\"修复意图\",\"value\":\"在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内\"},{\"name\":\"修复目标\",\"value\":\"高峰时段丢包率不高于1%\"}],\"alignment_and_clarification\":null,\"request_for_clarification\":[{\"name\":\"完成时限\",\"value\":\"修复完成时限是48小时内还是72小时内\"}]}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "metadata": { - "templateUriEcho": "Negotiation-T/target-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - }, - { - "step": 4, - "role": "B", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "target_accept", - "contracts": ["conclusionLiteralPresent", "metadataTripleShape"] - } - }, - { - "step": 5, - "role": "A", - "api": "validateAcceptPromptAndDataFilling", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "prompt": { "fromStep": 4 }, - "schema": { - "type": "object", - "properties": { - "latency_target": { "type": "string" }, - "repair_deadline": { "type": "string" } - } - }, - "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "latency_target": "within 20ms", - "repair_deadline": "2026-05-15" - }, - "contracts": ["contextKeysInMergedParams"] - } - } - ], - "expectFlow": { - "terminalCondition": "accept", - "roundsUsed": 2, - "distinctMessages": true - } - }, - { - "id": "SC-TGT-03", - "summary": "修复目标协商中断:光缆割接窗口持续无法确认,修复目标在轮次预算内无法完成澄清,于第2轮生成终止消息结束协商", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,修复目标协商发起/确认方)", - "B": "OMC(server,修复目标澄清/确认方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 2 }, - "templateUri": "Negotiation-T/target-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "专线质差投诉的修复目标协商:修复意图是在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%;待澄清平均时延目标是恢复至20ms以内还是50ms以内、修复完成时限是48小时内还是72小时内,请澄清并确认。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "target_propose" - } - }, - { - "step": 2, - "role": "B", - "api": "generateRejectFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 2 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "因光缆割接窗口尚未确认,时延目标未能完全澄清,本轮目标协商拒绝。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.reject.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "target_reject", - "contracts": ["conclusionLiteralPresent"] - } - }, - { - "step": 3, - "role": "A", - "api": "generateAbortFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 2 }, - "templateUri": "Negotiation-T/common/abort/v1", - "input": { - "text": { - "zh-CN": "光缆割接窗口持续无法确认,修复目标在轮次预算内无法完成澄清,本次协商确认终止。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.common.abort.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "abort", - "contracts": ["conclusionLiteralPresent"] - } - } - ], - "expectFlow": { - "terminalCondition": "abort", - "roundsUsed": 2, - "distinctMessages": true - } - }, - { - "id": "SC-TGT-04", - "summary": "修复目标协商确认请求闭环:工作台 round 1 发起意图理解澄清提议,OMC 完成澄清对齐后于 round 2 发出目标已澄清并请求确认的提议,工作台同意后生成接受消息完成闭环", - "languages": ["zh-CN"], - "roles": ["A", "B"], - "rolesDesc": { - "A": "工作台(client,修复目标协商发起/确认方)", - "B": "OMC(server,修复目标澄清/执行与确认请求发起方)" - }, - "steps": [ - { - "step": 1, - "role": "A", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "专线质差投诉的修复目标协商:修复意图是在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%;待澄清平均时延目标是恢复至20ms以内还是50ms以内、修复完成时限是48小时内还是72小时内,请澄清并确认。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.propose.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "target_propose" - } - }, - { - "step": 2, - "role": "B", - "api": "generateProposeFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/propose/v1", - "input": { - "text": { - "zh-CN": "任务目标澄清完成:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%,修复完成时限为48小时内。目标已经澄清,是否同意按照此目标继续执行?" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.propose.confirm" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextContains": ["目标澄清后的确认请求", "目标已经澄清,是否同意按照此目标继续执行?"], - "metadata": { - "templateUriEcho": "Negotiation-T/target-negotiation/propose/v1", - "contextEcho": true - }, - "contracts": ["metadataTripleShape"] - } - }, - { - "step": 3, - "role": "A", - "api": "generateAcceptFromText", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "input": { - "text": { - "zh-CN": "同意按照此目标继续执行。最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" - } - }, - "llm": { "script": [ { "$ref": "responses/extract.target.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "promptTextEqualsGolden": "target_accept", - "contracts": ["conclusionLiteralPresent"] - } - } - ], - "expectFlow": { - "terminalCondition": "accept", - "roundsUsed": 2, - "distinctMessages": true - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json b/a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json deleted file mode 100644 index b3d0eedb..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/shared/llm-responses.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "extract.information.propose.full": "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"举例:专线质差\"},{\"name\":\"专线业务标识\",\"value\":null}],\"relationship\":\"OR\"}", - "extract.information.accept.full": "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}", - "extract.information.reject.full": "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"无法提供,工作台侧端口资源台账暂不可查\"}]}", - "extract.target.propose.full": "{\"target_negotiation_description\":\"专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。\",\"intent_understanding\":[{\"name\":\"修复意图\",\"value\":\"在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内\"},{\"name\":\"修复目标\",\"value\":\"高峰时段丢包率不高于1%\"}],\"alignment_and_clarification\":null,\"request_for_clarification\":[{\"name\":\"时延目标\",\"value\":\"平均时延目标是恢复至20ms以内还是50ms以内\"},{\"name\":\"完成时限\",\"value\":\"修复完成时限是48小时内还是72小时内\"}]}", - "extract.target.propose.confirm": "{\"target_negotiation_description\":\"任务目标澄清完成,请答复<目标澄清后的确认请求>。\",\"intent_understanding\":null,\"alignment_and_clarification\":null,\"request_for_clarification\":null,\"target_confirm_request\":\"目标已经澄清,是否同意按照此目标继续执行?\"}", - "extract.target.accept.full": "{\"conclusion\":\"Accept\",\"confirmed_intent\":\"最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。\",\"failure_reason\":null}", - "extract.target.reject.full": "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"因光缆割接窗口尚未确认,时延目标未能完全澄清。\"}", - "extract.feasibility.propose.full": "{\"feasibility_negotiation_description\":\"请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":[{\"name\":\"扩容方案\",\"value\":\"接入端口带宽由100Mbps扩容至1000Mbps\"},{\"name\":\"既有约束\",\"value\":\"割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟\"}],\"infeasibility_details_and_proposal\":null}", - "extract.feasibility.propose.confirm.goal": "{\"feasibility_negotiation_description\":\"针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null,\"feasibility_confirm_request\":\"评估目标可行,是否同意按照此目标继续执行?\"}", - "extract.feasibility.propose.confirm.solution": "{\"feasibility_negotiation_description\":\"故障订阅任务履行过程中自诊断识别到的保活方案评估已完成,结论为可行,请答复<评估可行时的确认请求>。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null,\"feasibility_confirm_request\":\"评估方案可行,是否同意按照此方案继续执行?\"}", - "extract.feasibility.accept.full": "{\"conclusion\":\"Accept\",\"feasibility_summary\":\"端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。\"}", - "extract.feasibility.reject.full": "{\"conclusion\":\"Reject\",\"feasibility_summary\":\"受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。\"}", - "extract.common.abort.full": "{\"termination_reason\":\"已达到协商轮次上限,本次协商确认终止。\"}", - "extract.information.accept.missing_conclusion": "{\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}", - "extract.information.accept.conclusion_swapped": "{\"conclusion\":\"Reject\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"},{\"name\":\"投诉分类\",\"value\":\"专线质差\"}]}", - "extract.information.reject.conclusion_swapped": "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"接入端口名称\",\"value\":\"无法提供,工作台侧端口资源台账暂不可查\"}]}", - "extract.information.propose.missing_items": "{\"relationship\":\"OR\"}", - "extract.information.propose.items_not_array": "{\"items\":\"接入端口名称、投诉分类与专线业务标识\",\"relationship\":\"OR\"}", - "extract.target.accept.missing_confirmed_intent": "{\"conclusion\":\"Accept\",\"confirmed_intent\":null,\"failure_reason\":null}", - "extract.target.reject.missing_failure_reason": "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":null}", - "extract.feasibility.propose.missing_action": "{\"feasibility_negotiation_description\":\"请评估专线接入端口扩容方案能否在割接窗口内完成。\",\"contents_to_evaluate\":[{\"name\":\"扩容方案\",\"value\":\"接入端口带宽由100Mbps扩容至1000Mbps\"}],\"infeasibility_details_and_proposal\":null}", - "extract.feasibility.propose.unknown_action": "{\"feasibility_negotiation_description\":\"请评估专线接入端口扩容方案能否在割接窗口内完成。\",\"action\":\"APPROVE_THE_REQUEST\",\"contents_to_evaluate\":[{\"name\":\"扩容方案\",\"value\":\"接入端口带宽由100Mbps扩容至1000Mbps\"}],\"infeasibility_details_and_proposal\":null}", - "extract.feasibility.propose.evaluation_without_contents": "{\"feasibility_negotiation_description\":\"请评估专线接入端口扩容方案能否在割接窗口内完成。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\",\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null}", - "extract.common.abort.missing_termination_reason": "{}", - "semantic.information.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\",\"bizScenario\":\"专线质差\"}}", - "semantic.information.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":\"接入端口名称\"}}],\"params\":{}}", - "semantic.target.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[],\"params\":{\"latency_target\":\"within 20ms\",\"repair_deadline\":\"2026-05-15\"}}", - "semantic.target.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"target\",\"errors\":[{\"slot_name\":\"confirmed_intent\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":\"最终确认的意图\"}}],\"params\":{}}", - "semantic.feasibility.accept.full": "{\"semantic_verdict\":true,\"negotiation_type\":\"feasibility\",\"errors\":[],\"params\":{\"port_expansion\":\"feasible\"}}", - "semantic.feasibility.reject": "{\"semantic_verdict\":false,\"negotiation_type\":\"feasibility\",\"errors\":[{\"slot_name\":\"feasibility_summary\",\"code\":\"negotiation.field_missing\",\"facts\":{\"field\":\"可行性总结\"}}],\"params\":{}}", - "semantic.common.abort.full": "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{\"termination_reason\":\"已达到协商轮次上限\"}}", - "semantic.information.type_mismatch": "{\"semantic_verdict\":true,\"negotiation_type\":\"target\",\"errors\":[],\"params\":{}}", - "semantic.information.null_type": "{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}", - "semantic.shape.missing_verdict": "{\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}", - "semantic.shape.missing_params": "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]}" -} diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/shared/schemas.json b/a2a-t-corpus/src/test/resources/negotiation-cases/shared/schemas.json deleted file mode 100644 index 86c82dca..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/shared/schemas.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "biz.complaint.params": { - "type": "object", - "properties": { - "accessPort": { - "type": "string", - "description": "接入端口名称,唯一标识被投诉的专线对象,必填,须与投诉对象一致", - "examples": [ - "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "P781-前海-PTN7900-5-TPA1EG24-09(cvlan=100)" - ] - }, - "bizScenario": { - "type": "string", - "description": "投诉分类场景,必填,仅允许取值:专线中断、专线质差", - "examples": [ - "专线中断", - "专线质差" - ] - }, - "faultTime": { - "type": "string", - "description": "问题发生时间,建议使用 ISO-8601 时间戳,例如 2026-05-11T08:21:46Z" - }, - "eventSerialNo": { - "type": "string", - "description": "OSS 侧受理的投诉工单或事件流水号,供诊断回溯关联,例如 event-id-20260511-09013" - } - }, - "required": [ - "accessPort", - "bizScenario" - ] - }, - "flat": { - "type": "object", - "properties": { - "region": { - "type": "string" - } - } - }, - "noType": { - "properties": { - "region": { - "type": "string" - } - } - }, - "no_type": { - "properties": { - "region": { - "type": "string" - } - } - }, - "nestedArray": { - "type": "object", - "properties": { - "regions": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "nested_array": { - "type": "object", - "properties": { - "regions": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "colliding": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "round": { - "type": "integer" - } - } - }, - "empty": {} -} diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/drift-probes.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/drift-probes.json deleted file mode 100644 index 93c21f85..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/drift-probes.json +++ /dev/null @@ -1,102 +0,0 @@ -[ - { - "id": "VAL-DRIFT-01", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["drift", "wording-drift", "information", "accept", "text-input"], - "summary": "对端补数接受报文的条目编号与分隔标点相对模板惯例存在差异(1.与冒号改为1、与空格),仍应校验通过并提取接入端口名称与投诉分类", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1、接入端口名称 P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2、投诉分类 专线质差" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-DRIFT-02", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["drift", "wording-drift", "information", "propose", "text-input"], - "summary": "对端要数提议报文将缺失项关系从OR字面量改写为文字化措辞,语序微变仍应校验通过并提取投诉参数", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n以上缺失项之间为“或”的关系。" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-DRIFT-03", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["drift", "wording-drift", "information", "accept", "text-input"], - "summary": "对端英文补数接受报文的条目编号改为右括号、键值分隔改为长破折号,措辞漂移仍应校验通过并提取接入端口名称与投诉分类", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "text": "## Information Negotiation Result\nAccept\n\n## Information Negotiation Result Content\n1) Access Port Name — P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\n2) Complaint Category — dedicated-line quality degradation" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-DRIFT-04", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["drift", "wording-drift", "information", "propose", "text-input"], - "summary": "对端英文要数提议报文将缺失项关系改写为完整句子并补充限定词,措辞漂移仍应校验通过并提取投诉参数", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "text": "## Information Negotiation\nPlease supplement the relevant content based on .\n\n## Required Information Items\n1. Access Port Name: e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1\n2. Complaint Category: e.g. dedicated-line quality degradation\n3. Private Line Service Identifier\nThe three missing items above are related by OR." }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/error-code-mapping.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/error-code-mapping.json deleted file mode 100644 index b3446ebd..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/error-code-mapping.json +++ /dev/null @@ -1,104 +0,0 @@ -[ - { - "id": "VAL-MAP-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["error-code-mapping", "map-code", "null-context", "invalid-input"], - "summary": "消息未携带协商上下文元数据时校验判定其不是协商消息,按非协商消息拒绝并返回协商无效输入错误码且不发起任何大模型调用", - "context": null, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "messageContains": ["缺少协商上下文"], - "llmCalls": 0 - } - }, - { - "id": "VAL-MAP-02", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["error-code-mapping", "map-code", "rule-violation", "round"], - "summary": "协商轮次超限触发协商规则违反错误码,并保留轮次越界槽位错误", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 6, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.rule_violation", - "slotErrors": [ { "slot": "round", "code": "negotiation.round_exceeded" } ], - "llmCalls": 0 - } - }, - { - "id": "VAL-MAP-03", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["error-code-mapping", "map-code", "semantic-rejected"], - "summary": "大模型语义校验否决要数提议消息时返回协商语义拒绝错误码,并保留大模型报告的接入端口缺失槽位错误", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.reject" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.semantic_rejected", - "slotErrors": [ { "slot": "accessPort", "code": "negotiation.field_missing" } ], - "llmCalls": 1 - } - }, - { - "id": "VAL-MAP-04", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["error-code-mapping", "llm-invocation-failed", "retry-exhausted"], - "summary": "大模型服务连续两次不可用并耗尽重试后,以 llm.invocation_failed 错误码失败,错误以 _llm 伪槽位保留", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "maxAttempts": 2, "script": [ { "$fail": "llm-error" }, { "$fail": "llm-error" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "llm.invocation_failed", - "messageContains": ["LLM 调用失败"], - "slotErrors": [ { "slot": "_llm", "code": "llm.invocation_failed" } ], - "llmCalls": 2 - } - }, - { - "id": "VAL-MAP-05", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["error-code-mapping", "map-code", "template-not-found", "inject"], - "summary": "语义校验提示词资源缺失时映射为 template.not_found 错误码,原始资源异常不透出且不发起任何大模型调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "inject": "failingSemanticValidator", - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "template.not_found", - "messageContains": ["不支持语言"], - "llmCalls": 0 - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/happy.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/happy.json deleted file mode 100644 index e49272d3..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/happy.json +++ /dev/null @@ -1,248 +0,0 @@ -[ - { - "id": "VAL-HAPPY-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "information", "propose", "merge", "text-input"], - "summary": "以OMC发来的信息协商提议报文全文为输入,校验通过并合并出上下文三键与接入端口名称、投诉分类参数", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-HAPPY-02", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "information", "accept", "merge", "golden-derived"], - "summary": "golden 回归:工作台补数接受报文的规范模板形态校验通过,合并参数与对端报文主线用例完全等值", - "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", - "round": 3, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-HAPPY-03", - "api": "validateRejectPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "information", "reject", "merge", "text-input"], - "summary": "以工作台发来的信息协商拒绝报文全文为输入,校验通过并合并出上下文三键与语义层提取的投诉参数", - "context": { "id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", "round": 4, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "text": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,工作台侧端口资源台账暂不可查" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", - "round": 4, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-HAPPY-04", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "target", "propose", "merge", "text-input"], - "summary": "以对端发来的修复目标协商提议报文全文为输入,校验通过并合并出上下文三键与时延目标、修复时限参数", - "context": { "id": "9e8d7c6b-5a4f-4392-8172-6b5c4d3e2f1a", "round": 1, "maxRounds": 3 }, - "templateUri": "Negotiation-T/target-negotiation/propose/v1", - "prompt": { "text": "## 目标协商\n专线质差投诉的修复目标协商意图见<意图理解陈述>;时延目标与完成时限仍存在待澄清问题,见<待澄清内容>;请澄清并确认。\n\n## 意图理解陈述\n1. 修复意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内\n2. 修复目标:高峰时段丢包率不高于1%\n\n## 待澄清内容\n1. 时延目标:平均时延目标是恢复至20ms以内还是50ms以内\n2. 完成时限:修复完成时限是48小时内还是72小时内" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "9e8d7c6b-5a4f-4392-8172-6b5c4d3e2f1a", - "round": 1, - "maxRounds": 3, - "latency_target": "within 20ms", - "repair_deadline": "2026-05-15" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-HAPPY-05", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "target", "accept", "merge", "golden-derived"], - "summary": "golden 回归:修复目标确认接受报文的规范模板形态校验通过,合并参数与对端报文主线用例完全等值", - "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "prompt": { "golden": "target_accept" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", - "round": 2, - "maxRounds": 3, - "latency_target": "within 20ms", - "repair_deadline": "2026-05-15" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-HAPPY-06", - "api": "validateRejectPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "target", "reject", "merge", "text-input"], - "summary": "以对端发来的修复目标协商拒绝报文全文为输入(割接窗口未确认),校验通过并合并出上下文三键与时延目标、修复时限参数", - "context": { "id": "6f5e4d3c-2b1a-4988-7766-5544332211ff", "round": 2, "maxRounds": 3 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "prompt": { "text": "## 目标协商结果\nReject\n\n## 目标协商结果内容\n因光缆割接窗口尚未确认,时延目标未能完全澄清。" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "6f5e4d3c-2b1a-4988-7766-5544332211ff", - "round": 2, - "maxRounds": 3, - "latency_target": "within 20ms", - "repair_deadline": "2026-05-15" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-HAPPY-07", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "feasibility", "propose", "merge", "text-input"], - "summary": "以对端发来的端口扩容可行性协商提议报文全文为输入,校验通过并合并出上下文三键与扩容可行性结论参数", - "context": { "id": "2a4b6c8d-0e1f-4a3b-8c5d-9e7f1a2b3c4d", "round": 2, "maxRounds": 4 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "prompt": { "text": "## 可行性协商\n请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。\n\n## 待评估内容说明\n1. 扩容方案:接入端口带宽由100Mbps扩容至1000Mbps\n2. 既有约束:割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "2a4b6c8d-0e1f-4a3b-8c5d-9e7f1a2b3c4d", - "round": 2, - "maxRounds": 4, - "port_expansion": "feasible" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-HAPPY-08", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "feasibility", "accept", "merge", "text-input"], - "summary": "以对端发来的端口扩容可行性确认接受报文全文为输入,校验通过并合并出上下文三键与扩容可行性结论参数", - "context": { "id": "3b5c7d9e-1f2a-4b3c-8d4e-9f6a1b2c3d4e", "round": 3, "maxRounds": 4 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "prompt": { "text": "## 可行性协商结果\nAccept\n\n## 可行性评估结果确认\n端口扩容方案可在割接窗口内完成,业务中断时长满足不超过30分钟的约束;本次可行性协商确认结束。" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3b5c7d9e-1f2a-4b3c-8d4e-9f6a1b2c3d4e", - "round": 3, - "maxRounds": 4, - "port_expansion": "feasible" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-HAPPY-09", - "api": "validateRejectPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "feasibility", "reject", "merge", "text-input"], - "summary": "以对端发来的端口扩容不可行拒绝报文全文为输入(板卡槽位不足),校验通过并合并出上下文三键与扩容可行性结论参数", - "context": { "id": "4c6d8e0f-2a3b-4c5d-8e6f-0a7b1c2d3e4f", "round": 3, "maxRounds": 4 }, - "templateUri": "Negotiation-T/feasibility-negotiation/accept-reject/v1", - "prompt": { "text": "## 可行性协商结果\nReject\n\n## 可行性评估结果确认\n受汇聚机房板卡槽位不足限制,端口扩容无法在指定割接窗口内完成;本次可行性协商确认结束。" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "4c6d8e0f-2a3b-4c5d-8e6f-0a7b1c2d3e4f", - "round": 3, - "maxRounds": 4, - "port_expansion": "feasible" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-HAPPY-10", - "api": "validateAbortPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["happy", "abort", "common", "merge", "golden-derived"], - "summary": "golden 回归:轮次耗尽协商终止报文的规范模板形态校验通过,合并出上下文三键与终止原因参数", - "context": { "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "prompt": { "golden": "abort" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$ref": "responses/semantic.common.abort.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", - "round": 5, - "maxRounds": 5, - "termination_reason": "已达到协商轮次上限" - }, - "contracts": ["contextKeysInMergedParams"] - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json deleted file mode 100644 index b9be78bf..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/llm-shape-and-retry.json +++ /dev/null @@ -1,234 +0,0 @@ -[ - { - "id": "VAL-RETRY-01", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["retry", "llm-infrastructure-error", "accept", "information", "text-input"], - "summary": "工作台补数接受报文的语义校验第一次遭遇 LLM 基础设施错误后自动重试,第二次返回合法判定并成功提取投诉参数", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:专线质差" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "maxAttempts": 3, - "script": [ { "$fail": "llm-error" }, { "$ref": "responses/semantic.information.accept.full" } ] - }, - "expect": { - "outcome": "success", - "llmCalls": 2, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-RETRY-02", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["retry", "shape-invalid", "missing-negotiation-type", "propose", "information", "text-input"], - "summary": "要数提议报文的语义返回缺少 negotiation_type 键的无效结构时触发重试,第二次结构合法并成功提取投诉参数", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "maxAttempts": 3, - "script": [ - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}", - { "$ref": "responses/semantic.information.accept.full" } - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 2, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-RETRY-03", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["retry-exhausted", "shape-invalid", "llm-pseudo-slot", "propose", "information"], - "summary": "语义返回连续三种无效结构时重试至耗尽,最终携带 _llm 伪槽位以 llm.response_invalid 错误失败", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "maxAttempts": 3, - "script": [ - "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}", - { "$ref": "responses/semantic.shape.missing_verdict" }, - { "$ref": "responses/semantic.shape.missing_params" } - ] - }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "llm.response_invalid", - "slotErrors": [ { "slot": "_llm", "code": "llm.response_invalid" } ], - "llmCalls": 3 - } - }, - { - "id": "VAL-RETRY-04", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["retry", "max-attempts-1", "shape-invalid", "llm-pseudo-slot", "propose"], - "summary": "maxAttempts 配置为 1 时语义结构错误不获重试机会,单次调用即以 llm.response_invalid 错误失败", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "maxAttempts": 1, - "script": [ "{\"semantic_verdict\":true,\"errors\":[],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}" ] - }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "llm.response_invalid", - "slotErrors": [ { "slot": "_llm", "code": "llm.response_invalid" } ], - "llmCalls": 1 - } - }, - { - "id": "VAL-RETRY-05", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["retry-exhausted", "llm-infrastructure-error", "max-attempts-2", "llm-pseudo-slot", "accept"], - "summary": "LLM 传输错误连续出现时按 maxAttempts=2 重试两次后耗尽,携带 _llm 伪槽位以 llm.invocation_failed 失败", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "maxAttempts": 2, - "script": [ { "$fail": "llm-error" }, { "$fail": "llm-error" } ] - }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "llm.invocation_failed", - "slotErrors": [ { "slot": "_llm", "code": "llm.invocation_failed" } ], - "llmCalls": 2 - } - }, - { - "id": "VAL-RETRY-06", - "api": "validateRejectPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["retry", "non-json", "max-attempts-2", "reject", "information", "text-input"], - "summary": "工作台拒绝报文的语义返回无法解析的非 JSON 内容后按 maxAttempts=2 重试,第二次返回合法判定并成功", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "text": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,工作台侧端口资源台账暂不可查" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "maxAttempts": 2, - "script": [ { "$fail": "non-json" }, { "$ref": "responses/semantic.information.accept.full" } ] - }, - "expect": { - "outcome": "success", - "llmCalls": 2, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-RETRY-07", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["retry", "max-attempts-3", "blank-content", "non-json", "propose", "text-input"], - "summary": "要数提议报文的语义连续两次空内容与非 JSON 应答后第三次成功,重试预算 maxAttempts=3 恰好用满", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "maxAttempts": 3, - "script": [ - { "$fail": "blank-content" }, - { "$fail": "non-json" }, - { "$ref": "responses/semantic.information.accept.full" } - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 3, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-RETRY-08", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P2", - "tags": ["no-retry", "runtime-exception", "llm-infrastructure-error", "accept", "information"], - "summary": "语义步骤抛出非 LLMError 的原始运行时异常时以 llm.response_invalid 错误失败(原始运行时异常现已与其他 LLM 步骤失败一样按预算重试,本探针以 maxAttempts=1 固化单次调用形态)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "maxAttempts": 1, "script": [ { "$fail": "runtime-exception" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "llm.response_invalid", - "slotErrors": [ { "slot": "_llm", "code": "llm.response_invalid" } ], - "llmCalls": 1 - } - }, - { - "id": "VAL-RETRY-09", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P2", - "tags": ["no-retry", "null-response", "llm-infrastructure-error", "accept", "information"], - "summary": "语义步骤返回空响应(null)时以 llm.response_invalid 错误失败(空响应现已按预算重试,本探针以 maxAttempts=1 固化单次调用形态)", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "maxAttempts": 1, "script": [ { "$fail": "null-response" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "llm.response_invalid", - "slotErrors": [ { "slot": "_llm", "code": "llm.response_invalid" } ], - "llmCalls": 1 - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/merge-and-schema.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/merge-and-schema.json deleted file mode 100644 index c393519d..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/merge-and-schema.json +++ /dev/null @@ -1,131 +0,0 @@ -[ - { - "id": "VAL-MERGE-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["merge", "context-wins", "key-conflict", "colliding-schema", "propose", "information", "text-input"], - "summary": "业务参数与上下文 id、round 键冲突时上下文参数优先,大模型提取的错误取值不覆盖规则层结果", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, - "schema": { "$ref": "schemas/colliding" }, - "llm": { - "script": [ - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{\"id\":\"00000000-0000-0000-0000-000000000000\",\"round\":99,\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}" - ] - }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-MERGE-02", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["merge", "no-type-schema", "schema-wrapping", "accept", "information", "text-input"], - "summary": "调用方未声明 type 关键字的参数 schema 被自动包装为对象 schema,补数接受报文的语义校验与投诉参数提取不受影响", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:专线质差" }, - "schema": { "$ref": "schemas/noType" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-MERGE-03", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["null-context", "invalid-input", "rule-gate", "propose"], - "summary": "校验要数提议消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用", - "context": null, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "text": "请补充接入端口名称(如P533-珠江旧城-PTN3900-23-TPA1EG24-1)、投诉分类(如专线质差)与专线业务标识三项信息,三项之间为或的关系。" }, - "schema": { "$ref": "schemas/flat" }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "messageContains": ["缺少协商上下文"], - "llmCalls": 0 - } - }, - { - "id": "VAL-MERGE-04", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["null-context", "invalid-input", "rule-gate", "accept"], - "summary": "校验补数接受消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用", - "context": null, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "text": "我确认补充的信息:接入端口名称为P533-珠江旧城-PTN3900-23-TPA1EG24-1,投诉分类为专线质差。" }, - "schema": { "$ref": "schemas/flat" }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "messageContains": ["缺少协商上下文"], - "llmCalls": 0 - } - }, - { - "id": "VAL-MERGE-05", - "api": "validateRejectPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["null-context", "invalid-input", "rule-gate", "reject"], - "summary": "校验拒绝消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用", - "context": null, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "text": "无法提供接入端口名称:工作台侧端口资源台账暂不可查,本轮信息协商拒绝。" }, - "schema": { "$ref": "schemas/flat" }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "messageContains": ["缺少协商上下文"], - "llmCalls": 0 - } - }, - { - "id": "VAL-MERGE-06", - "api": "validateAbortPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["null-context", "invalid-input", "rule-gate", "abort", "common"], - "summary": "校验终止消息时未携带协商上下文,判定为非协商消息并以 negotiation.invalid_input 失败,不发起 LLM 调用", - "context": null, - "templateUri": "Negotiation-T/common/abort/v1", - "prompt": { "text": "已达到协商轮次上限,本次协商确认终止。" }, - "schema": { "$ref": "schemas/flat" }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "messageContains": ["缺少协商上下文"], - "llmCalls": 0 - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json deleted file mode 100644 index 46dde12c..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/programming-errors.json +++ /dev/null @@ -1,329 +0,0 @@ -[ - { - "id": "VAL-PROG-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "null-prompt", "propose"], - "summary": "校验要数提议消息时未传入消息文本,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-02", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "blank-prompt", "propose"], - "summary": "校验要数提议消息时消息文本为空白,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "text": " " }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "messageContains": ["Prompt must not be null or blank."], - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-03", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "null-prompt", "accept"], - "summary": "校验补数接受消息时未传入消息文本,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", - "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-04", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "blank-prompt", "accept"], - "summary": "校验补数接受消息时消息文本为空白,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", - "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "text": " " }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "messageContains": ["Prompt must not be null or blank."], - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-05", - "api": "validateRejectPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "null-prompt", "reject"], - "summary": "校验修复目标拒绝消息时未传入消息文本,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", - "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-06", - "api": "validateRejectPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "blank-prompt", "reject"], - "summary": "校验修复目标拒绝消息时消息文本为空白,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", - "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "prompt": { "text": " " }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "messageContains": ["Prompt must not be null or blank."], - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-07", - "api": "validateAbortPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "null-prompt", "abort"], - "summary": "校验终止消息时未传入消息文本,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", - "context": { "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-08", - "api": "validateAbortPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "blank-prompt", "abort"], - "summary": "校验终止消息时消息文本为空白,按无效输入拒绝并返回 negotiation.invalid_input,不调用大模型", - "context": { "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "prompt": { "text": " " }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "messageContains": ["Prompt must not be null or blank."], - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-09", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "null-context", "propose"], - "summary": "校验要数提议消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input", - "context": null, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "messageContains": ["缺少协商上下文"], - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-10", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "null-context", "accept"], - "summary": "校验补数接受消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input", - "context": null, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "messageContains": ["缺少协商上下文"], - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-11", - "api": "validateRejectPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "null-context", "reject"], - "summary": "校验修复目标拒绝消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input", - "context": null, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "prompt": { "golden": "target_reject" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "messageContains": ["缺少协商上下文"], - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-12", - "api": "validateAbortPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "null-context", "abort"], - "summary": "校验终止消息时未携带协商上下文,按非协商消息拒绝并返回 negotiation.invalid_input", - "context": null, - "templateUri": "Negotiation-T/common/abort/v1", - "prompt": { "golden": "abort" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.invalid_input", - "messageContains": ["缺少协商上下文"], - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-13", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "uri-phase-mismatch", "propose"], - "summary": "校验要数提议消息时模板 URI 声明的是接受-拒绝阶段,阶段不匹配抛出参数异常且不调用大模型", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["expected phase PROPOSE"], - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-14", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "uri-phase-mismatch", "accept"], - "summary": "校验补数接受消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型", - "context": { "id": "7f3c9a2e-1b4d-4e8f-9c2a-5d6b7e8f9a01", "round": 3, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_accept" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["expected phase ACCEPT"], - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-15", - "api": "validateRejectPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "uri-phase-mismatch", "reject"], - "summary": "校验修复目标拒绝消息时模板 URI 声明的是提议阶段,阶段不匹配抛出参数异常且不调用大模型", - "context": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "round": 2, "maxRounds": 3 }, - "templateUri": "Negotiation-T/target-negotiation/propose/v1", - "prompt": { "golden": "target_reject" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["expected phase REJECT"], - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-16", - "api": "validateAbortPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["programming-error", "uri-phase-mismatch", "abort"], - "summary": "校验终止消息时模板 URI 声明的是类型化提议模板而非通用终止模板,抛出参数异常且不调用大模型", - "context": { "id": "5d7e9f1a-3b4c-4d5e-8f7a-1b8c2d3e4f5a", "round": 5, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "abort" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["expected phase ABORT"], - "llmCalls": 0 - } - }, - { - "id": "VAL-PROG-17", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["programming-error", "uri-phase-mismatch", "common-uri", "probe"], - "summary": "校验要数提议消息时误用通用终止模板 URI,类型化阶段不接受 common 段,抛出参数异常", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "IllegalArgumentException", - "messageContains": ["expected phase PROPOSE"], - "llmCalls": 0 - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/rule-gate.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/rule-gate.json deleted file mode 100644 index e51241ea..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/rule-gate.json +++ /dev/null @@ -1,180 +0,0 @@ -[ - { - "id": "VAL-RULE-01", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["rule-gate", "uuid", "invalid-uuid"], - "summary": "协商会话标识仅 8 位而非 36 位 UUID 时,消息在规则校验阶段即被拒绝并报告标识格式槽位错误,不发起任何大模型调用", - "context": { "id": "3dbc13b5", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.rule_violation", - "slotErrors": [ { "slot": "id", "code": "negotiation.invalid_context_id" } ], - "llmCalls": 0 - } - }, - { - "id": "VAL-RULE-02", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["rule-gate", "uuid", "invalid-uuid", "length-boundary"], - "summary": "协商会话标识为 35 位(末段缺少一位十六进制位)时同样被规则门拒绝,UUID 长度边界从严执行", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d", "round": 1, "maxRounds": 3 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.rule_violation", - "slotErrors": [ { "slot": "id", "code": "negotiation.invalid_context_id" } ], - "llmCalls": 0 - } - }, - { - "id": "VAL-RULE-03", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P2", - "tags": ["rule-gate", "uuid", "invalid-uuid", "non-hex"], - "summary": "协商会话标识长度与 8-4-4-4-12 分段均正确但首段含非十六进制字符 g 时,规则门仍按 UUID 格式拒绝", - "context": { "id": "gdbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.rule_violation", - "slotErrors": [ { "slot": "id", "code": "negotiation.invalid_context_id" } ], - "llmCalls": 0 - } - }, - { - "id": "VAL-RULE-04", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["rule-gate", "round", "boundary", "happy", "text-input"], - "summary": "当前轮次等于最大轮次预算(第 5 轮即最后一轮)时OMC要数提议报文仍可通过规则校验并正常提取投诉参数,只有超出预算才被拒绝", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 5, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-RULE-05", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["rule-gate", "round", "out-of-range"], - "summary": "协商轮次超出最大轮次预算(第 6 轮对 5 轮上限)时规则门拒绝消息并报告轮次越界槽位错误,不发起任何大模型调用", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 6, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.rule_violation", - "slotErrors": [ { "slot": "round", "code": "negotiation.round_exceeded" } ], - "llmCalls": 0 - } - }, - { - "id": "VAL-RULE-06", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["rule-gate", "combined-errors", "uuid", "round"], - "summary": "会话标识非法且轮次超限同时发生时,规则门一次性报告标识格式与轮次越界两条槽位错误,不发起任何大模型调用", - "context": { "id": "negotiation-session-001", "round": 6, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$fail": "assertion" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.rule_violation", - "slotErrors": [ - { "slot": "id", "code": "negotiation.invalid_context_id" }, - { "slot": "round", "code": "negotiation.round_exceeded" } - ], - "llmCalls": 0 - } - }, - { - "id": "VAL-RULE-07", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["rule-gate", "round", "boundary", "single-round", "happy", "text-input"], - "summary": "单轮预算(最大轮次为 1)的第 1 轮要数提议报文可通过规则校验并正常提取投诉参数,轮次下限边界同样放行", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 1 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 1, - "maxRounds": 1, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-RULE-08", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P2", - "tags": ["rule-gate", "uuid", "uppercase-hex", "happy", "text-input"], - "summary": "协商会话标识使用大写十六进制 UUID 时规则门同样放行,要数提议报文正常校验并提取投诉参数,合并结果原样保留大写标识", - "context": { "id": "3DBC13B5-BD57-4C2B-B503-24E381B6C8D3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3DBC13B5-BD57-4C2B-B503-24E381B6C8D3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - } -] diff --git a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/semantic-verdicts.json b/a2a-t-corpus/src/test/resources/negotiation-cases/validate/semantic-verdicts.json deleted file mode 100644 index 6d75589e..00000000 --- a/a2a-t-corpus/src/test/resources/negotiation-cases/validate/semantic-verdicts.json +++ /dev/null @@ -1,376 +0,0 @@ -[ - { - "id": "VAL-SEM-01", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["semantic-verdict", "information", "accept", "type-consistent", "text-input"], - "summary": "工作台补数接受报文语义判定通过且上报类型与信息协商模板一致时,校验成功并合并出接入端口名称与投诉分类参数", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "text": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:专线质差" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 5, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-SEM-02", - "api": "validateAcceptPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["semantic-verdict", "target", "accept", "type-consistent", "text-input"], - "summary": "修复目标确认接受报文语义判定通过且上报类型与目标协商模板一致时,校验成功并合并出时延目标与修复时限参数", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, - "templateUri": "Negotiation-T/target-negotiation/accept-reject/v1", - "prompt": { "text": "## 目标协商结果\nAccept\n\n## 目标协商结果内容\n最终确认的意图:在2026年5月15日前将深圳至广州专线的平均时延恢复至20ms以内,高峰时段丢包率不高于1%。" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$ref": "responses/semantic.target.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 1, - "maxRounds": 3, - "latency_target": "within 20ms", - "repair_deadline": "2026-05-15" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-SEM-03", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["semantic-verdict", "feasibility", "propose", "type-consistent", "text-input"], - "summary": "端口扩容可行性提议报文语义判定通过且上报类型与可行性协商模板一致时,校验成功并合并出扩容可行性结论参数", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 5 }, - "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1", - "prompt": { "text": "## 可行性协商\n请评估专线接入端口扩容方案能否在割接窗口内完成,见<待评估内容说明>;请评估。\n\n## 待评估内容说明\n1. 扩容方案:接入端口带宽由100Mbps扩容至1000Mbps\n2. 既有约束:割接窗口仅限2026年5月30日02:00-06:00,业务中断不超过30分钟" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$ref": "responses/semantic.feasibility.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 3, - "maxRounds": 5, - "port_expansion": "feasible" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-SEM-04", - "api": "validateAbortPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P0", - "tags": ["semantic-verdict", "abort", "common", "type-consistent", "text-input"], - "summary": "轮次耗尽终止报文语义判定通过且未上报协商类型时校验成功,合并出终止原因参数", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "prompt": { "text": "## 协商结果\nAbort\n\n## 协商终止原因\n已达到协商轮次上限,本次协商确认终止。" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ { "$ref": "responses/semantic.common.abort.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 5, - "maxRounds": 5, - "termination_reason": "已达到协商轮次上限" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-SEM-05", - "api": "validateRejectPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P1", - "tags": ["semantic-verdict", "information", "reject", "type-consistent", "text-input"], - "summary": "工作台无法提供端口名的拒绝报文语义判定通过且类型一致时校验成功,类型一致性不区分接受或拒绝动作", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 4 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "text": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,工作台侧端口资源台账暂不可查" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.accept.full" } ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 2, - "maxRounds": 4, - "accessPort": "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "bizScenario": "专线质差" - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-SEM-06", - "api": "validateAbortPromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P2", - "tags": ["semantic-verdict", "abort", "common", "type-unchecked", "probe", "text-input"], - "summary": "公共终止模板未声明协商类型,判定通过时即使大模型上报了类型也照常校验成功", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 4, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "prompt": { "text": "## 协商结果\nAbort\n\n## 协商终止原因\n已达到协商轮次上限,本次协商确认终止。" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}" ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 4, - "maxRounds": 5 - }, - "contracts": ["contextKeysInMergedParams"] - } - }, - { - "id": "VAL-SEM-07", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["semantic-verdict", "information", "propose", "type-mismatch"], - "summary": "大模型判定通过但上报成目标协商类型时,信息协商要数提议被语义拒绝并指出类型不一致", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.type_mismatch" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.semantic_rejected", - "slotErrors": [ { "slot": "section.target", "code": "negotiation.type_mismatch" } ], - "llmCalls": 1 - } - }, - { - "id": "VAL-SEM-08", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["semantic-verdict", "information", "propose", "null-type"], - "summary": "大模型判定通过但未上报协商类型时,信息协商要数提议被语义拒绝并按模板类型报类型不一致", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.null_type" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.semantic_rejected", - "slotErrors": [ { "slot": "section.info_static", "code": "negotiation.type_mismatch" } ], - "llmCalls": 1 - } - }, - { - "id": "VAL-SEM-09", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["semantic-verdict", "information", "propose", "unknown-type", "probe"], - "summary": "大模型判定通过但上报未知协商类型字符串时,信息协商要数提议被语义拒绝", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"bogus\",\"errors\":[],\"params\":{}}" ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.semantic_rejected", - "slotErrors": [ { "slot": "section.info_static", "code": "negotiation.type_mismatch" } ], - "llmCalls": 1 - } - }, - { - "id": "VAL-SEM-10", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P2", - "tags": ["semantic-verdict", "information", "propose", "case-sensitivity", "probe"], - "summary": "大模型上报的协商类型仅大小写不同也被视为类型不一致,信息协商要数提议被语义拒绝", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 3 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"Information\",\"errors\":[],\"params\":{}}" ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.semantic_rejected", - "slotErrors": [ { "slot": "section.info_static", "code": "negotiation.type_mismatch" } ], - "llmCalls": 1 - } - }, - { - "id": "VAL-SEM-11", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["semantic-verdict", "information", "propose", "verdict-false"], - "summary": "大模型语义判定不通过时,信息协商要数提议被拒绝并返回缺失的接入端口参数槽位错误", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ { "$ref": "responses/semantic.information.reject" } ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.semantic_rejected", - "slotErrors": [ { "slot": "accessPort", "code": "negotiation.field_missing" } ], - "llmCalls": 1 - } - }, - { - "id": "VAL-SEM-12", - "api": "validateRejectPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["semantic-verdict", "information", "reject", "verdict-false", "type-unchecked"], - "summary": "语义判定不通过时不再核对协商类型,拒绝侧消息只返回大模型报告的槽位错误", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 4 }, - "templateUri": "Negotiation-T/information-negotiation/accept-reject/v1", - "prompt": { "golden": "information_reject" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"target\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"facts\":{\"section_label\":\"接入端口名称\"}}],\"params\":{}}" ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.semantic_rejected", - "slotErrors": [ { "slot": "accessPort", "code": "negotiation.rule_violation" } ], - "llmCalls": 1 - } - }, - { - "id": "VAL-SEM-13", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P2", - "tags": ["semantic-verdict", "information", "propose", "empty-errors", "probe"], - "summary": "语义判定不通过且未报告任何槽位错误时,校验仍以语义拒绝失败且槽位错误为空", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 3 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}" ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.semantic_rejected", - "llmCalls": 1 - } - }, - { - "id": "VAL-SEM-14", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P2", - "tags": ["semantic-verdict", "information", "propose", "params-dropped", "probe"], - "summary": "语义判定不通过时已提取的投诉参数被丢弃,校验以语义拒绝失败且不合并任何业务参数", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 2, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"facts\":{\"section_label\":\"接入端口名称\"}}],\"params\":{\"accessPort\":\"P533-珠江旧城-PTN3900-23-TPA1EG24-1\"}}" ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.semantic_rejected", - "slotErrors": [ { "slot": "accessPort", "code": "negotiation.rule_violation" } ], - "llmCalls": 1 - } - }, - { - "id": "VAL-SEM-15", - "api": "validateAbortPromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P1", - "tags": ["semantic-verdict", "abort", "common", "verdict-false"], - "summary": "终止消息语义判定不通过时被拒绝并返回缺失终止原因的槽位错误", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 5, "maxRounds": 5 }, - "templateUri": "Negotiation-T/common/abort/v1", - "prompt": { "golden": "abort" }, - "schema": { "$ref": "schemas/empty" }, - "llm": { "script": [ "{\"semantic_verdict\":false,\"negotiation_type\":null,\"errors\":[{\"slot_name\":\"termination_reason\",\"code\":\"missing\",\"facts\":{\"section_label\":\"协商终止原因\"}}],\"params\":{}}" ] }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.semantic_rejected", - "slotErrors": [ { "slot": "termination_reason", "code": "negotiation.rule_violation" } ], - "llmCalls": 1 - } - }, - { - "id": "VAL-SEM-16", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN"], - "priority": "P0", - "tags": ["semantic-verdict", "information", "propose", "no-retry", "verdict-false"], - "summary": "语义拒绝不触发重试:脚本备有第二次通过的应答也不会被消费,LLM 调用数恒为 1", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 1, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "golden": "information_propose" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { - "maxAttempts": 3, - "script": [ - { "$ref": "responses/semantic.information.reject" }, - { "$ref": "responses/semantic.information.accept.full" } - ] - }, - "expect": { - "outcome": "failure", - "exception": "NegotiationParamExtractionException", - "code": "negotiation.semantic_rejected", - "slotErrors": [ { "slot": "accessPort", "code": "negotiation.field_missing" } ], - "llmCalls": 1 - } - }, - { - "id": "VAL-SEM-17", - "api": "validateProposePromptAndDataFilling", - "languages": ["zh-CN", "en-US"], - "priority": "P2", - "tags": ["semantic-verdict", "information", "propose", "errors-ignored", "probe", "text-input"], - "summary": "语义判定通过时大模型附带的槽位错误被忽略,要数提议报文校验成功且只合并上下文三键", - "context": { "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round": 3, "maxRounds": 5 }, - "templateUri": "Negotiation-T/information-negotiation/propose/v1", - "prompt": { "text": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n1. 接入端口名称:举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n2. 投诉分类:举例:专线质差\n3. 专线业务标识\n缺失项之间的关系:OR" }, - "schema": { "$ref": "schemas/biz.complaint.params" }, - "llm": { "script": [ "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[{\"slot_name\":\"accessPort\",\"code\":\"missing\",\"facts\":{\"section_label\":\"接入端口名称\"}}],\"params\":{}}" ] }, - "expect": { - "outcome": "success", - "llmCalls": 1, - "params": { - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 3, - "maxRounds": 5 - }, - "contracts": ["contextKeysInMergedParams"] - } - } -] diff --git a/a2a-t-corpus/tools/csv-templates/input-case-template.csv b/a2a-t-corpus/tools/csv-templates/input-case-template.csv new file mode 100644 index 00000000..043314de --- /dev/null +++ b/a2a-t-corpus/tools/csv-templates/input-case-template.csv @@ -0,0 +1,2 @@ +id,caseDimension,caseDesc,caseCategory,scenario,step1_api,step1_args,step1_expect_result,step1_expect_data,step1_expect_errCode,step1_expect_errMessageContains,step2_api,step2_args,step2_expect_result,step2_expect_data,step2_expect_errCode,step2_expect_errMessageContains,step3_api,step3_args,step3_expect_result,step3_expect_data,step3_expect_errCode,step3_expect_errMessageContains +TC00000001,01-意图清晰参数完整,示例:专线业务中断标准投诉(可删除本行),正常,private-line-complaint,generateTaskPromptFromText,"{""text"":""发生专线业务中断,接入端口名称为P781-……"",""templateUri"":""Task-T/network-layer/private-line-complaint/v1""}",success,{},,,validateTaskPromptAndDataFilling,"{""schema"":{""$schema"":""https://json-schema.org/draft/2020-12/schema"",""type"":""object"",""properties"":{""accessPort"":{""type"":""string""}},""required"":[""accessPort""]},""templateUri"":""Task-T/network-layer/private-line-complaint/v1"",""promptText"":{""$fromStep"":1,""$field"":""promptText""}}",success,"{""accessPort"":""P781-……""}",,,,,,,, diff --git a/a2a-t-corpus/tools/inputCsvToJson.py b/a2a-t-corpus/tools/inputCsvToJson.py new file mode 100644 index 00000000..2bd577c8 --- /dev/null +++ b/a2a-t-corpus/tools/inputCsvToJson.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""inputCsvToJson - convert the case design table (CSV) to corpus input JSON, and back. + +Subcommands (mutually exclusive): + --template write the case-design template CSV (with one example row) to --out + --reverse convert an input_case_*.json back to the design table (CSV) + +CSV contract: one row per case; `stepN_*` column pairs map 1:1 to the input/expected +array elements, so any step count is supported. All files are UTF-8; the template +ships with a BOM so Excel opens it directly. + +Validation (--no-validate to disable) mirrors the Java case loader: id uniqueness +inside the file, non-empty metadata columns, parseable JSON fragments for args and +per-step expected data, and api/expect presence per step. +""" + +import argparse +import csv +import json +import re +import sys +from pathlib import Path + +FIXED_COLUMNS = ["id", "caseDimension", "caseDesc", "caseCategory"] +STEP_PREFIX_RE = re.compile(r"^(?Pstep(?P\d+)_(?Papi|args|expect_result|expect_data|expect_errCode|expect_errMessageContains))$") +UTF8_BOM = "\ufeff" + + +def parse_step_columns(header): + """Maps column name -> (step number, field) for all stepN_* columns.""" + steps = [] + for column in header: + match = STEP_PREFIX_RE.match(column) + if match: + steps.append((int(match.group("num")), match.group("field"), column)) + return sorted(steps, key=lambda item: (item[0], item[1])) + + +def fail(message): + print(f"[error] {message}", file=sys.stderr) + sys.exit(1) + + +def load_rows(path): + with open(path, "r", encoding="utf-8-sig", newline="") as handle: + return list(csv.DictReader(handle)) + + +def parse_json_fragment(column, row_id, allow_empty=False): + text = (column or "").strip() + if not text: + if allow_empty: + return {} + fail(f"case {row_id}: missing JSON fragment") + try: + value = json.loads(text) + except json.JSONDecodeError as error: + fail(f"case {row_id}: invalid JSON fragment: {error}") + if not allow_empty and not isinstance(value, dict): + fail(f"case {row_id}: JSON fragment must be an object: {text}") + return value + + +def rows_to_cases(rows, requested_scenario=None): + if not rows: + fail("no data rows in CSV") + step_columns = parse_step_columns(list(rows[0].keys())) + cases = [] + seen_ids = set() + for row in rows: + row_id = (row.get("id") or "").strip() + if not row_id: + fail("found a data row without id") + if row_id in seen_ids: + fail(f"duplicate case id: {row_id}") + seen_ids.add(row_id) + if not row.get("caseDimension", "").strip(): + fail(f"case {row_id}: missing caseDimension") + if not row.get("caseDesc", "").strip(): + fail(f"case {row_id}: missing caseDesc") + if not row.get("caseCategory", "").strip(): + fail(f"case {row_id}: missing caseCategory") + + scenario = (row.get("scenario") or "").strip() or requested_scenario + if scenario: + row["scenario"] = scenario + else: + del row["scenario"] + + max_step = max((num for num, _, _ in step_columns), default=0) + input_steps = [] + expected_steps = [] + for num in range(1, max_step + 1): + api = (row.get(f"step{num}_api") or "").strip() + args = (row.get(f"step{num}_args") or "").strip() + result = (row.get(f"step{num}_expect_result") or "").strip() + if not api and not args and not result: + continue + if not api: + fail(f"case {row_id}: step{num} missing api") + if result not in ("success", "error"): + fail(f"case {row_id}: step{num}_expect_result must be success or error: {result}") + input_steps.append({ + "api": api, + "args": parse_json_fragment(args, row_id) if args else {}, + }) + expected_step = { + "result": result, + "data": parse_json_fragment(row.get(f"step{num}_expect_data"), row_id, allow_empty=True), + "error": { + "errCode": (row.get(f"step{num}_expect_errCode") or "").strip(), + "errMessage": (row.get(f"step{num}_expect_errMessageContains") or "").strip(), + }, + } + contains = (row.get(f"step{num}_expect_errMessageContains") or "").strip() + if contains: + expected_step["error"]["errMessageContains"] = contains + expected_steps.append(expected_step) + if not input_steps: + fail(f"case {row_id}: at least one step is required") + if len(input_steps) != len(expected_steps): + fail(f"case {row_id}: input/expected step count mismatch") + + case = { + "id": row_id, + "caseDimension": row.get("caseDimension").strip(), + "caseDesc": row.get("caseDesc").strip(), + "caseCategory": row.get("caseCategory").strip(), + "input": input_steps, + "expected": expected_steps, + } + cases.append((scenario, case)) + return cases + + +def write_template(path): + header = FIXED_COLUMNS + ["scenario"] + for num in (1, 2, 3): + header += [ + f"step{num}_api", f"step{num}_args", + f"step{num}_expect_result", f"step{num}_expect_data", + f"step{num}_expect_errCode", f"step{num}_expect_errMessageContains", + ] + example = { + "id": "TC00000001", + "caseDimension": "01-意图清晰参数完整", + "caseDesc": "示例:专线业务中断标准投诉(可删除本行)", + "caseCategory": "正常", + "scenario": "private-line-complaint", + "step1_api": "generateTaskPromptFromText", + "step1_args": '{"text":"发生专线业务中断,接入端口名称为P781-……","templateUri":"Task-T/network-layer/private-line-complaint/v1"}', + "step1_expect_result": "success", + "step1_expect_data": "{}", + "step1_expect_errCode": "", + "step1_expect_errMessageContains": "", + "step2_api": "validateTaskPromptAndDataFilling", + "step2_args": '{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"accessPort":{"type":"string"}},"required":["accessPort"]},"templateUri":"Task-T/network-layer/private-line-complaint/v1","promptText":{"$fromStep":1,"$field":"promptText"}}', + "step2_expect_result": "success", + "step2_expect_data": '{"accessPort":"P781-……"}', + "step2_expect_errCode": "", + "step2_expect_errMessageContains": "", + "step3_api": "", + "step3_args": "", + "step3_expect_result": "", + "step3_expect_data": "", + "step3_expect_errCode": "", + "step3_expect_errMessageContains": "", + } + with open(path, "w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=header) + writer.writeheader() + writer.writerow(example) + print(f"Wrote case design template: {path}") + + +def cases_to_json(cases, api_names=None): + out = [] + for _, case in cases: + serialized = { + "id": case["id"], + "caseDimension": case["caseDimension"], + "caseDesc": case["caseDesc"], + "caseCategory": case["caseCategory"], + "input": case["input"], + "expected": case["expected"], + } + out.append(serialized) + return out + + +def json_to_rows(cases): + rows = [] + step_columns = build_step_columns(cases) + header = FIXED_COLUMNS + ["scenario"] + step_columns + for case in cases: + row = {key: case.get(key, "") for key in FIXED_COLUMNS} + row["scenario"] = case.get("scenario", "") + for num in range(1, len(case.get("input", [])) + 1): + step = case["input"][num - 1] + expected = case["expected"][num - 1] if num - 1 < len(case.get("expected", [])) else {} + row[f"step{num}_api"] = step.get("api", "") + row[f"step{num}_args"] = json.dumps(step.get("args", {}), ensure_ascii=False) + row[f"step{num}_expect_result"] = expected.get("result", "") + row[f"step{num}_expect_data"] = json.dumps(expected.get("data", {}) or {}, ensure_ascii=False) + error = expected.get("error") or {} + row[f"step{num}_expect_errCode"] = error.get("errCode", "") + row[f"step{num}_expect_errMessageContains"] = error.get("errMessageContains", "") + for column in step_columns: + row.setdefault(column, "") + rows.append(row) + return header, rows + + +def build_step_columns(cases): + max_steps = max((len(case.get("input", [])) for case in cases), default=0) + columns = [] + for num in range(1, max_steps + 1): + columns += [ + f"step{num}_api", f"step{num}_args", + f"step{num}_expect_result", f"step{num}_expect_data", + f"step{num}_expect_errCode", f"step{num}_expect_errMessageContains", + ] + return columns + + +def write_csv(path, header, rows): + with open(path, "w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=header) + writer.writeheader() + writer.writerows(rows) + print(f"Wrote CSV: {path}") + + +def main(): + parser = argparse.ArgumentParser(description="Case design CSV <-> corpus input JSON converter") + parser.add_argument("--template", action="store_true", help="write the design template CSV (with one example row) to --out") + parser.add_argument("--reverse", action="store_true", help="reverse direction: input JSON -> design CSV") + parser.add_argument("--csv", help="input CSV (default command) or output CSV (--reverse)") + parser.add_argument("--out", required=True, help="output file") + parser.add_argument("--scenario", help="target scenario name (written to the scenario column; inferred from the output file name when absent)") + parser.add_argument("--no-validate", action="store_true", help="skip structural validation of the produced JSON") + args = parser.parse_args() + + if args.template: + write_template(args.out) + return + + if args.reverse: + if not args.csv: + fail("--reverse requires --csv pointing at an input_case_*.json") + with open(args.csv, "r", encoding="utf-8-sig") as handle: + cases = json.load(handle) + header, rows = json_to_rows(cases) + write_csv(args.out, header, rows) + return + + if not args.csv: + fail("--csv pointing at the design table is required (or use --template to generate one)") + rows = load_rows(args.csv) + scenario = args.scenario + if not scenario: + scenario = Path(args.out).stem.replace("input_case_", "") or None + cases = rows_to_cases(rows, scenario) + + payload = cases_to_json(cases) + if not args.no_validate: + validate_payload(payload) + with open(args.out, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + print(f"Wrote case JSON: {args.out} ({len(payload)} cases)") + + +def validate_payload(payload): + if not isinstance(payload, list) or not payload: + fail("validation failed: the top level must be a case array") + for case in payload: + case_id = case.get("id") + for key in FIXED_COLUMNS: + if not str(case.get(key, "")).strip(): + fail(f"validation failed: case {case_id} missing {key}") + if not isinstance(case.get("input"), list) or not case["input"]: + fail(f"validation failed: case {case_id} input must be a non-empty array") + if not isinstance(case.get("expected"), list) or len(case["expected"]) != len(case["input"]): + fail(f"validation failed: case {case_id} expected must be the same length as input") + for i, step in enumerate(case["input"], start=1): + if not isinstance(step, dict) or not str(step.get("api", "")).strip(): + fail(f"validation failed: case {case_id} step {i} missing api") + expected = case["expected"][i - 1] + if expected.get("result") not in ("success", "error"): + fail(f"validation failed: case {case_id} step {i} expected.result invalid") + if expected.get("result") == "error" and not str( + (expected.get("error") or {}).get("errCode", "")).strip(): + fail(f"validation failed: case {case_id} step {i} error expectation missing errCode") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/a2a-t-corpus/tools/outputJsonToCsv.py b/a2a-t-corpus/tools/outputJsonToCsv.py new file mode 100644 index 00000000..d0e30be0 --- /dev/null +++ b/a2a-t-corpus/tools/outputJsonToCsv.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""outputJsonToCsv - render corpus result JSON into one review-friendly CSV table (one row per case). + +Every step exposes its API request and response verbatim plus every underlying LLM +request/response; the content is always complete, never truncated. `stepN_*` column +pairs extend with the case step count, and the API error code is surfaced per step. + +Usage: + python outputJsonToCsv.py --json output_result_from_text.json --out result.csv + python outputJsonToCsv.py --json

            //output_result_from_text.json,/output_result_from_text.json --out merged.csv +""" + +import argparse +import csv +import json +import sys +from pathlib import Path + +UTF8_BOM = "\ufeff" + + +def fail(message): + print(f"[error] {message}", file=sys.stderr) + sys.exit(1) + + +def load_results(paths): + records = [] + for raw_path in paths: + path = Path(raw_path) + if not path.is_file(): + fail(f"result file not found: {path}") + scenario = path.parent.name + with open(path, "r", encoding="utf-8-sig") as handle: + payload = json.load(handle) + if not isinstance(payload, list): + fail(f"{path} top level must be a result array") + for record in payload: + record["__scenario"] = scenario + records.append(record) + return records + + +def split_steps(interactions): + """Splits the interleaved transcript into per-step groups {api, interaction, llm_calls}.""" + steps = [] + for interaction in interactions: + if interaction.get("type") == "LLM": + if steps: + steps[-1]["llm_calls"].append(interaction) + continue + steps.append({"api": interaction.get("type", ""), "interaction": interaction, "llm_calls": []}) + return steps + + +def jsonize(value, pretty=True): + if value is None: + return "" + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False, indent=2 if pretty else None) + + +def records_to_rows(records): + all_rows = [] + max_steps = 0 + normalized = [] + for record in records: + interactions = (record.get("output") or {}).get("interactions") or [] + steps = split_steps(interactions) + normalized.append((record, steps)) + max_steps = max(max_steps, len(steps)) + for record, steps in normalized: + row = { + "scenario": record.get("__scenario", ""), + "id": record.get("id", ""), + "caseDimension": record.get("caseDimension", ""), + "caseDesc": record.get("caseDesc", ""), + "caseCategory": record.get("caseCategory", ""), + "result": record.get("result", ""), + "totalTime_ms": record.get("totalTime", 0), + "totalInputToken": record.get("totalInputToken", 0), + "totalOutputToken": record.get("totalOutputToken", 0), + "totalToken": record.get("totalToken", 0), + } + for num, step in enumerate(steps, start=1): + interaction = step["interaction"] + prefix = f"step{num}_" + row[prefix + "api"] = step["api"] + row[prefix + "result"] = interaction.get("result", "") + row[prefix + "duration_ms"] = interaction.get("duration_ms", 0) + row[prefix + "inputToken"] = interaction.get("inputToken", 0) + row[prefix + "outputToken"] = interaction.get("outputToken", 0) + row[prefix + "api_errCode"] = (interaction.get("response") or {}).get("errorCode", "") + row[prefix + "request"] = jsonize(interaction.get("request")) + row[prefix + "response"] = jsonize(interaction.get("response")) + row[prefix + "llm_requests"] = jsonize([call.get("request") for call in step["llm_calls"]]) + row[prefix + "llm_responses"] = jsonize([call.get("response") for call in step["llm_calls"]]) + row["fail_reason"] = record.get("failReason", "") + row["__max_steps"] = len(steps) + all_rows.append(row) + return all_rows, max_steps + + +def build_header(max_steps): + header = [ + "scenario", "id", "caseDimension", "caseDesc", "caseCategory", + "result", "totalTime_ms", "totalInputToken", "totalOutputToken", "totalToken", + ] + for num in range(1, max_steps + 1): + header += [ + f"step{num}_api", f"step{num}_result", f"step{num}_duration_ms", + f"step{num}_inputToken", f"step{num}_outputToken", f"step{num}_api_errCode", + f"step{num}_request", f"step{num}_response", + f"step{num}_llm_requests", f"step{num}_llm_responses", + ] + header.append("fail_reason") + return header + + +def main(): + parser = argparse.ArgumentParser(description="Corpus result JSON -> review CSV (one row per case with full per-step request/response)") + parser.add_argument("--json", required=True, help="output_result_*.json; comma-separated to merge several scenarios") + parser.add_argument("--out", required=True, help="output CSV") + args = parser.parse_args() + + records = load_results([item.strip() for item in args.json.split(",") if item.strip()]) + rows, max_steps = records_to_rows(records) + header = build_header(max_steps) + with open(args.out, "w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=header) + writer.writeheader() + for row in rows: + writer.writerow({key: row.get(key, "") for key in header}) + print(f"Wrote review table: {args.out} ({len(rows)} rows, {max_steps} step(s) max, complete request/response output)") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java index c5f2f3f4..ff865c67 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java @@ -138,7 +138,10 @@ private String extractJsonObjectString(ChatCompletion response) { String rawContent = extractMessageText(response); try { if (rawContent == null || rawContent.isBlank()) { - throw new LLMRuntimeError(config.provider() + " returned empty content (rate limit or model timeout)"); + throw new LLMRuntimeError(config.provider() + + " returned empty content (finish_reason=" + extractFinishReason(response) + + "): typically rate limiting, a request timeout, or max_tokens exhausted by reasoning" + + " content - check model quotas and the MAX_TOKENS/TIMEOUT settings"); } Object parsed = OBJECT_MAPPER.readValue(rawContent, Object.class); if (!(parsed instanceof Map)) { @@ -152,6 +155,14 @@ private String extractJsonObjectString(ChatCompletion response) { } } + private static String extractFinishReason(ChatCompletion response) { + if (response.choices().isEmpty()) { + return "unknown"; + } + Object finishReason = response.choices().get(0).finishReason(); + return finishReason == null ? "none" : finishReason.toString(); + } + private String extractMessageText(ChatCompletion response) { if (response.choices().isEmpty()) { throw new LLMRuntimeError(config.provider() + " response did not include any choices"); @@ -161,7 +172,9 @@ private String extractMessageText(ChatCompletion response) { .message() .content() .orElseThrow( - () -> new LLMRuntimeError(config.provider() + " response did not include message content")); + () -> new LLMRuntimeError(config.provider() + + " response did not include message content (finish_reason=" + + extractFinishReason(response) + ")")); } private static Map mapUsage(ChatCompletion response) { diff --git a/client.env b/client.env deleted file mode 100644 index c7f9aeda..00000000 --- a/client.env +++ /dev/null @@ -1,10 +0,0 @@ -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=local_file -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=a2a-t-resources/src/main/resources/prompt_resources -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL= -A2AT_LLM_BASE_URL= -A2AT_LLM_API_KEY=${your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -REGISTRY_CENTER_HOST= -REGISTRY_CENTER_PORT= diff --git a/pom.xml b/pom.xml index ea01d6e2..e749c01e 100644 --- a/pom.xml +++ b/pom.xml @@ -296,6 +296,11 @@ org.apache.maven.plugins maven-surefire-plugin ${maven.surefire.plugin.version} + + + false + org.apache.maven.plugins diff --git a/server.env b/server.env deleted file mode 100644 index 7d32d043..00000000 --- a/server.env +++ /dev/null @@ -1,12 +0,0 @@ -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=local_file -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=a2a-t-resources/src/main/resources/prompt_resources -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL= -A2AT_LLM_BASE_URL= -A2AT_LLM_API_KEY=${your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -A2AT_SAMPLE_HOST= -A2AT_SAMPLE_PORT= -REGISTRY_CENTER_HOST= -REGISTRY_CENTER_PORT= diff --git a/tools/corpus_index.py b/tools/corpus_index.py deleted file mode 100644 index b4113320..00000000 --- a/tools/corpus_index.py +++ /dev/null @@ -1,363 +0,0 @@ -#!/usr/bin/env python3 -"""Generates and freshness-checks INDEX.md for the A2A-T negotiation test corpus. - -Scans every corpus JSON under the negotiation-cases root (case files, scenario files and the -shared payload/schema maps), renders a machine-generated index -- top statistics, the -API x category x expected-outcome coverage matrices and one line per case -- and writes it to -negotiation-cases/INDEX.md. With --check the rendered index is compared against the file on -disk so CI fails whenever the corpus and the index drift apart. -""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -DEFAULT_CORPUS_ROOT = REPO_ROOT / "a2a-t-corpus" / "src" / "test" / "resources" / "negotiation-cases" -INDEX_NAME = "INDEX.md" -SCHEMA_NAME = "corpus-schema.json" - -APIS = ( - "generateProposeFromText", - "generateAcceptFromText", - "generateRejectFromText", - "generateAbortFromText", - "generateProposeFromData", - "generateAcceptFromData", - "generateRejectFromData", - "generateAbortFromData", - "validateProposePromptAndDataFilling", - "validateAcceptPromptAndDataFilling", - "validateRejectPromptAndDataFilling", - "validateAbortPromptAndDataFilling", - # the three closed-loop task APIs (Q21): exercised as scenario steps, never as standalone case records - "generateTaskPromptFromText", - "generateTaskPromptFromDataWithSchema", - "validateTaskPromptAndDataFilling", -) -FAMILY_ORDER = ("FT", "FD", "VAL", "SC") -FAMILY_LABELS = {"FT": "from-text", "FD": "from-data", "VAL": "validate", "SC": "scenarios"} - - -def error(path: Path, rule: str, message: str) -> str: - return f"{path}: [{rule}] {message}" - - -class Case: - def __init__(self, path: Path, record: dict) -> None: - self.path = path - self.id: str = record["id"] - self.api: str = record["api"] - self.languages: list[str] = list(record.get("languages", [])) - self.priority: str = record.get("priority", "-") - self.summary: str = record.get("summary", "") - self.expect = record["expect"] - - -class Scenario: - def __init__(self, path: Path, record: dict) -> None: - self.path = path - self.id: str = record["id"] - self.languages: list[str] = list(record.get("languages", [])) - self.summary: str = record.get("summary", "") - self.steps: list[dict] = list(record["steps"]) - self.expect_flow: dict = record.get("expectFlow", {}) - - -def family_of(record_id: str, path: Path, errors: list[str]) -> str | None: - family = record_id.split("-", 1)[0] - if family not in FAMILY_LABELS: - errors.append(error(path, "corpus-id", f"Record id '{record_id}' does not start with a known family prefix.")) - return None - return family - - -def category_of(record_id: str) -> str: - return "-".join(record_id.split("-")[:2]) - - -def outcome_of(expect: dict) -> str: - if expect.get("outcome") == "success": - return "success" - if "code" in expect: - return expect["code"] - return f"exception:{expect.get('exception', '?')}" - - -def outcome_label(expect: dict) -> str: - if expect.get("outcome") == "success": - return "success" - parts = [] - if "exception" in expect: - parts.append(expect["exception"]) - if "code" in expect: - parts.append(expect["code"]) - return "(" + ", ".join(parts) + ")" if parts else "(failure)" - - -def load_corpus(root: Path) -> tuple[list[Case], list[Scenario], dict, list[str]]: - """Loads every corpus JSON, classifying case files, scenario files and the shared maps.""" - cases: list[Case] = [] - scenarios: list[Scenario] = [] - shared = {"payloads": 0, "schemas": 0} - errors: list[str] = [] - for path in sorted(root.rglob("*.json")): - relative = path.relative_to(root).as_posix() - if path.name == SCHEMA_NAME: - continue - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: - errors.append(error(path, "corpus-json", f"Cannot load corpus file: {exc}")) - continue - if relative.startswith("shared/"): - if not isinstance(data, dict): - errors.append(error(path, "corpus-shared", "Shared corpus files must be JSON objects.")) - continue - if path.name == "llm-responses.json": - shared["payloads"] = len(data) - elif path.name == "schemas.json": - shared["schemas"] = len(data) - continue - # The live family targets a real model and is not part of the offline coverage index. - if relative.startswith("live/"): - continue - if not isinstance(data, list): - errors.append(error(path, "corpus-file", "Corpus files must be JSON arrays of records.")) - continue - for record in data: - if not isinstance(record, dict): - errors.append(error(path, "corpus-record", "Corpus records must be JSON objects.")) - continue - record_id = record.get("id") - if not isinstance(record_id, str): - errors.append(error(path, "corpus-record", "Corpus record is missing its 'id'.")) - continue - if family_of(record_id, path, errors) is None: - continue - if "steps" in record: - scenarios.append(Scenario(path, record)) - else: - cases.append(Case(path, record)) - return cases, scenarios, shared, errors - - -def category_sort_key(category: str) -> tuple: - family = category.split("-", 1)[0] - return (FAMILY_ORDER.index(family), category) - - -def render_flow(expect_flow: dict) -> str: - if not expect_flow: - return "-" - parts = [] - if "terminalCondition" in expect_flow: - parts.append(expect_flow["terminalCondition"]) - if "roundsUsed" in expect_flow: - parts.append(f"rounds={expect_flow['roundsUsed']}") - if expect_flow.get("distinctMessages"): - parts.append("distinct-messages") - return ", ".join(parts) if parts else "-" - - -def render_index(root: Path) -> str: - cases, scenarios, shared, errors = load_corpus(root) - if errors: - for item in errors: - print(item, file=sys.stderr) - raise SystemExit(1) - - # Coverage units: one base case record, or one scenario step, keyed by (api, category, outcome). - units: list[tuple[str, str, str]] = [] - for case in cases: - units.append((case.api, category_of(case.id), outcome_of(case.expect))) - for scenario in scenarios: - category = category_of(scenario.id) - for step in scenario.steps: - units.append((step["api"], category, outcome_of(step["expect"]))) - - error_codes = sorted({outcome for _, _, outcome in units if outcome not in ("success",) and not outcome.startswith("exception:")}) - exception_types = sorted({outcome[len("exception:"):] for _, _, outcome in units if outcome.startswith("exception:")}) - outcomes = ["success", *error_codes, "exception"] - categories = sorted({category for _, category, _ in units}, key=category_sort_key) - - languages_expanded = sum(len(case.languages) for case in cases) + sum(len(scenario.languages) for scenario in scenarios) - priority_counts = {priority: 0 for priority in ("P0", "P1", "P2")} - for case in cases: - if case.priority in priority_counts: - priority_counts[case.priority] += 1 - - lines: list[str] = [] - lines.append("") - lines.append("") - lines.append("# Negotiation Test Corpus Index") - lines.append("") - lines.append("Machine-generated view of the corpus under `a2a-t-corpus/src/test/resources/negotiation-cases/`.") - lines.append("Coverage matrices count base records (one case record, or one scenario step under its scenario's category);") - lines.append("language expansion is listed per record and totaled in the statistics.") - lines.append("") - - # Statistics. - lines.append("## Statistics") - lines.append("") - lines.append("| Metric | Value |") - lines.append("|---|---|") - lines.append(f"| Case records | {len(cases)} |") - lines.append(f"| Scenario records (steps) | {len(scenarios)} ({sum(len(s.steps) for s in scenarios)}) |") - lines.append(f"| Base records total | {len(cases) + len(scenarios)} |") - lines.append(f"| Language-expanded units | {languages_expanded} |") - lines.append(f"| Shared LLM payloads | {shared['payloads']} |") - lines.append(f"| Shared schema variants | {shared['schemas']} |") - lines.append(f"| Error codes covered | {len(error_codes)} ({', '.join(error_codes)}) |") - lines.append(f"| Exception-only failures | {sum(1 for _, _, o in units if o.startswith('exception:'))} ({', '.join(exception_types) or 'none'}) |") - lines.append("") - lines.append("Priority distribution (case records; scenarios carry no priority):") - lines.append("") - lines.append("| Priority | Cases |") - lines.append("|---|---|") - for priority in ("P0", "P1", "P2"): - lines.append(f"| {priority} | {priority_counts[priority]} |") - lines.append("") - - # Family distribution. - lines.append("## Family distribution") - lines.append("") - lines.append("| Family | Category | Corpus file | Records | Steps | Languages |") - lines.append("|---|---|---|---|---|---|") - family_records: dict[tuple[str, str], dict] = {} - for case in cases: - key = (category_of(case.id), case.path.relative_to(root).as_posix()) - entry = family_records.setdefault(key, {"records": 0, "steps": 0, "languages": set()}) - entry["records"] += 1 - entry["languages"].update(case.languages) - for scenario in scenarios: - key = (category_of(scenario.id), scenario.path.relative_to(root).as_posix()) - entry = family_records.setdefault(key, {"records": 0, "steps": 0, "languages": set()}) - entry["records"] += 1 - entry["steps"] += len(scenario.steps) - entry["languages"].update(scenario.languages) - for (category, file_name), entry in sorted(family_records.items(), key=lambda item: category_sort_key(item[0][0])): - family = category.split("-", 1)[0] - lines.append( - f"| {FAMILY_LABELS[family]} ({family}) | {category} | {file_name} | {entry['records']} | {entry['steps']} | {', '.join(sorted(entry['languages']))} |" - ) - lines.append("") - - # API x category coverage matrix. - lines.append("## API x category coverage") - lines.append("") - lines.append("Cells count base records (case records plus scenario steps) per API and category; empty cells are coverage gaps.") - lines.append("") - lines.append("| Category | " + " | ".join(APIS) + " | Total |") - lines.append("|---" * (len(APIS) + 2) + "|") - for category in categories: - counts = [sum(1 for api, cat, _ in units if cat == category and api == api_name) for api_name in APIS] - total = sum(counts) - cells = " | ".join(str(count) if count else "-" for count in counts) - lines.append(f"| {category} | {cells} | {total} |") - column_totals = [sum(1 for api, _, _ in units if api == api_name) for api_name in APIS] - lines.append("| **Total** | " + " | ".join(f"**{count}**" for count in column_totals) + f" | **{sum(column_totals)}** |") - lines.append("") - - # API x expected outcome coverage matrix. - lines.append("## API x expected outcome coverage") - lines.append("") - lines.append( - "Cells count base records per API and expectation; the `exception` column aggregates failures that assert only " - "an exception type (failures carrying both an exception and a code are counted under their code)." - ) - lines.append("") - lines.append("| API | " + " | ".join(f"`{outcome}`" for outcome in outcomes) + " | Total |") - lines.append("|---" * (len(outcomes) + 2) + "|") - for api_name in APIS: - counts = [] - for outcome in outcomes: - if outcome == "exception": - counts.append(sum(1 for api, _, o in units if api == api_name and o.startswith("exception:"))) - else: - counts.append(sum(1 for api, _, o in units if api == api_name and o == outcome)) - cells = " | ".join(str(count) if count else "-" for count in counts) - lines.append(f"| `{api_name}` | {cells} | {sum(counts)} |") - lines.append("") - - # Per-record listing. - lines.append("## Case index") - lines.append("") - by_file: dict[tuple[str, Path], list] = {} - for case in cases: - by_file.setdefault((category_of(case.id), case.path), []).append(case) - for (category, path), file_cases in sorted(by_file.items(), key=lambda item: category_sort_key(item[0][0])): - lines.append(f"### {category} -- {path.relative_to(root).as_posix()}") - lines.append("") - lines.append("| Id | API | Languages | Priority | Expectation | Summary |") - lines.append("|---|---|---|---|---|---|") - for case in file_cases: - languages = ", ".join(sorted(case.languages)) - lines.append(f"| {case.id} | `{case.api}` | {languages} | {case.priority} | {outcome_label(case.expect)} | {case.summary} |") - lines.append("") - - lines.append("## Scenario index") - lines.append("") - by_file = {} - for scenario in scenarios: - by_file.setdefault((category_of(scenario.id), scenario.path), []).append(scenario) - for (category, path), file_scenarios in sorted(by_file.items(), key=lambda item: category_sort_key(item[0][0])): - lines.append(f"### {category} -- {path.relative_to(root).as_posix()}") - lines.append("") - lines.append("| Id | Step APIs | Languages | Priority | Flow expectation | Step failures | Summary |") - lines.append("|---|---|---|---|---|---|---|") - for scenario in file_scenarios: - apis = ", ".join(f"`{api}`" for api in dict.fromkeys(step["api"] for step in scenario.steps)) - languages = ", ".join(sorted(scenario.languages)) - failures: dict[str, int] = {} - for step in scenario.steps: - if step["expect"].get("outcome") == "failure": - label = outcome_label(step["expect"]) - failures[label] = failures.get(label, 0) + 1 - failure_text = ", ".join(f"{label} x{count}" if count > 1 else label for label, count in sorted(failures.items())) or "-" - lines.append( - f"| {scenario.id} | {apis} | {languages} | - | {render_flow(scenario.expect_flow)} | {failure_text} | {scenario.summary} |" - ) - lines.append("") - return "\n".join(lines) + "\n" - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--corpus-root", type=Path, default=DEFAULT_CORPUS_ROOT, help="Negotiation corpus directory (default: the bundled test corpus).") - parser.add_argument("--check", action="store_true", help="Compare the generated index against INDEX.md on disk instead of writing it; exit 1 on drift.") - args = parser.parse_args() - - root = args.corpus_root.resolve() - if not root.is_dir(): - print(f"Corpus root does not exist: {root}", file=sys.stderr) - return 1 - content = render_index(root) - index_path = root / INDEX_NAME - - if args.check: - try: - on_disk = index_path.read_text(encoding="utf-8") - except OSError: - print(f"{INDEX_NAME} is missing under {root}; run: python tools/corpus_index.py", file=sys.stderr) - return 1 - # Normalize line endings so a CRLF checkout never produces a false drift signal. - if on_disk.replace("\r\n", "\n") != content: - print(f"{index_path} is stale; regenerate it with: python tools/corpus_index.py", file=sys.stderr) - return 1 - print(f"Corpus index is fresh: {index_path}") - return 0 - - with open(index_path, "w", encoding="utf-8", newline="\n") as handle: - handle.write(content) - print(f"Wrote corpus index: {index_path}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/corpus_review.py b/tools/corpus_review.py deleted file mode 100644 index 47942788..00000000 --- a/tools/corpus_review.py +++ /dev/null @@ -1,819 +0,0 @@ -#!/usr/bin/env python3 -"""Business review support for the A2A-T negotiation test corpus (design doc-local/fromtext-validate-test-design.md §8.7, Q19). - -The corpus itself is the single-business-domain private-line complaint diagnosis corpus (Q20-Q23): every case, -scenario and golden fixture speaks the complaint-diagnosis five-step closed loop, and the dictionaries below -translate the corpus facts into that business wording (task API actions of A2ATClient/A2ATServer, task parameter -names, complaint-diagnosis business domains). - -Review material is a *projection* of the corpus: generated from negotiation-cases/ JSON, never hand-maintained. - -Two modes: - - export generate docs-local/review/corpus-review.html (self-contained dashboard + - per-domain case cards + scenario ping-pong tables) and - docs-local/review/corpus-review.xlsx (one row per case, last two columns are - blank annotation columns for the business expert) - collect --marked read the annotated xlsx (or a csv fallback) back, emit - docs-local/review/review-findings.md (findings mapped to exact JSON file + line) - and docs-local/review/review-status.json (per-case review status) - -xlsx output requires `openpyxl` (pip install openpyxl). Without it, export degrades gracefully: -a warning is printed and only the HTML report is produced; collect then accepts a .csv marked file -(export also writes docs-local/review/corpus-review.csv alongside the xlsx so the closed loop works -in degraded environments). -""" - -from __future__ import annotations - -import argparse -import csv -import html -import io -import json -import sys -from datetime import datetime -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -CORPUS_ROOT = REPO_ROOT / "a2a-t-corpus" / "src" / "test" / "resources" / "negotiation-cases" -REVIEW_DIR = REPO_ROOT / "docs-local" / "review" -HTML_PATH = REVIEW_DIR / "corpus-review.html" -XLSX_PATH = REVIEW_DIR / "corpus-review.xlsx" -CSV_PATH = REVIEW_DIR / "corpus-review.csv" -STATUS_PATH = REVIEW_DIR / "review-status.json" -FINDINGS_PATH = REVIEW_DIR / "review-findings.md" - -CASE_FAMILIES = ("from-text", "from-data", "validate") -SCENARIO_FAMILY = "scenarios" - -# --- business-language dictionaries (Q19: experts see pure business wording, never raw codes) ------------- - -API_ACTION_ZH = { - "generateProposeFromText": "从自然语言生成提议消息", - "generateAcceptFromText": "从自然语言生成接受消息", - "generateRejectFromText": "从自然语言生成拒绝消息", - "generateAbortFromText": "从自然语言生成终止消息", - "generateProposeFromData": "从结构化数据生成提议消息", - "generateAcceptFromData": "从结构化数据生成接受消息", - "generateRejectFromData": "从结构化数据生成拒绝消息", - "generateAbortFromData": "从结构化数据生成终止消息", - "validateProposePromptAndDataFilling": "校验提议消息并提取参数", - "validateAcceptPromptAndDataFilling": "校验接受消息并提取参数", - "validateRejectPromptAndDataFilling": "校验拒绝消息并提取参数", - "validateAbortPromptAndDataFilling": "校验终止消息并提取参数", - # the three closed-loop task APIs (Q20-Q23): A2ATClient facade for generation, A2ATServer facade for validation - "generateTaskPromptFromText": "工作台从投诉文本生成任务报文(A2ATClient)", - "generateTaskPromptFromDataWithSchema": "工作台从结构化数据与参数模式生成任务报文(A2ATClient)", - "validateTaskPromptAndDataFilling": "OMC 校验对端任务报文并提取任务参数(A2ATServer)", -} - -# Task parameter names of the private-line complaint diagnosis (schema biz.complaint.params), for business wording. -TASK_PARAM_ZH = { - "accessPort": "接入端口名称", - "bizScenario": "投诉分类", - "faultTime": "问题发生时间", - "eventSerialNo": "OSS侧事件流水号", -} - -# Shared schema references of the corpus, for business wording. -SCHEMA_ZH = { - "biz.complaint.params": "专线投诉诊断业务参数模式(接入端口名称/投诉分类必填)", -} - -ERROR_CODE_ZH = { - "negotiation.invalid_input": "协商输入无效(如消息不是协商消息、缺少协商上下文)", - "negotiation.field_missing": "协商消息缺少必填字段", - "negotiation.content_extract_failed": "无法从文本提取协商内容", - "negotiation.content_invalid": "from-data 协商内容字段无效(如空列表、空白描述)", - "negotiation.conclusion_mismatch": "报文结论与该方法预期不符", - "negotiation.semantic_rejected": "语义校验判定不通过", - "negotiation.rule_violation": "协商报文违反校验规则(未知码兜底,含轮次超限、标识格式非法)", - "negotiation.invalid_context_id": "协商上下文标识不是合法 UUID", - "negotiation.round_exceeded": "协商轮次超过上限", - "negotiation.type_mismatch": "报文内容与声明的模板类型不符", - "template.not_found": "协商模板不存在或不可用", - "llm.invocation_failed": "大模型调用失败(传输/网络层)", - "llm.response_invalid": "大模型返回内容不符合要求(响应契约违反)", -} - -EXCEPTION_ZH = { - "NegotiationGenerationException": "协商生成失败", - "NegotiationParamExtractionException": "协商参数提取失败", - "NullPointerException": "空指针异常(编程错误)", - "IllegalArgumentException": "非法参数异常(编程错误)", - "RuntimeException": "运行时异常(编程错误)", -} - -FAIL_MODE_ZH = { - "non-json": "非法 JSON 数据", - "null-response": "空响应", - "blank-content": "空白内容", - "llm-error": "基础设施错误", - "assertion": "断言失败(不该被调用时触发)", -} - -INJECT_ZH = { - "failingTemplateLoader": "注入不可用的模板加载器(模拟模板缺失)", - "failingSemanticValidator": "注入不可用的语义校验器(模拟校验器故障)", -} - -TEMPLATE_URI_ZH = { - # complaint-diagnosis business domains (Q20/Q23): every corpus record belongs to the private-line complaint - # diagnosis; the domain labels carry its closed-loop semantics instead of bare negotiation type names - "network-layer": "任务闭环(专线投诉诊断)", - "information-negotiation": "信息协商(缺参要数)", - "target-negotiation": "目标协商(修复目标确认)", - "feasibility-negotiation": "可行性协商(修复方案确认)", - "common": "通用(终止)", -} -PHASE_ZH = { - "propose": "提议", - "accept-reject": "接受/拒绝", - "abort": "终止", - "private-line-complaint": "任务报文", -} -TERMINAL_ZH = {"accept": "接受(协商达成)", "reject": "拒绝(协商终止)", "abort": "终止", "exhausted": "轮次预算耗尽"} - -DOMAIN_ORDER = [ - "任务闭环(专线投诉诊断)", - "信息协商(缺参要数)", - "目标协商(修复目标确认)", - "可行性协商(修复方案确认)", - "通用(终止)", - "未分类", -] -DOMAIN_FALLBACK_TAG = { - "information": "信息协商(缺参要数)", - "target": "目标协商(修复目标确认)", - "feasibility": "可行性协商(修复方案确认)", - "abort": "通用(终止)", - "common": "通用(终止)", -} - -REVIEW_STATUSES = ("通过", "有疑问", "否决") - -XLSX_HEADERS = [ - "id", "业务域", "阶段", "API动作", "输入摘要", "期望结果", "优先级", "语言", "标签", - "评审结论", "评审意见", -] - - -# --- corpus loading ----------------------------------------------------------------------------------------- - -def json_line_of(raw_text: str, case_id: str) -> int: - """1-based line number of the `"id": ""` occurrence inside the corpus JSON file.""" - needle = f'"id": "{case_id}"' - for lineno, line in enumerate(raw_text.splitlines(), start=1): - if needle in line: - return lineno - return 1 - - -def load_corpus() -> list[dict]: - """Load all case and scenario records, enriched with source location and derived business facets.""" - records: list[dict] = [] - if not CORPUS_ROOT.is_dir(): - raise SystemExit(f"corpus root not found: {CORPUS_ROOT}") - for family in (*CASE_FAMILIES, SCENARIO_FAMILY): - family_dir = CORPUS_ROOT / family - if not family_dir.is_dir(): - continue - for path in sorted(family_dir.glob("*.json")): - raw = path.read_text(encoding="utf-8") - for entry in json.loads(raw): - entry["_family"] = family - entry["_file"] = str(path.relative_to(REPO_ROOT)).replace("\\", "/") - entry["_line"] = json_line_of(raw, entry["id"]) - records.append(entry) - return records - - -def api_action(api: str) -> str: - return API_ACTION_ZH.get(api, api) - - -def business_domain(rec: dict) -> str: - uri = rec.get("templateUri") or "" - if not uri and rec["_family"] == SCENARIO_FAMILY: - # scenario records carry templateUri on their steps, not at top level - for step in rec.get("steps", []): - uri = step.get("templateUri") or "" - if uri: - break - for segment, name in TEMPLATE_URI_ZH.items(): - if segment in uri: - return "通用(终止)" if segment == "common" else name - tags = rec.get("tags") - if tags is None and rec["_family"] == SCENARIO_FAMILY: - tags = [t for step in rec.get("steps", []) for t in step.get("tags", [])] - for tag, name in DOMAIN_FALLBACK_TAG.items(): - if tag in (tags or []): - return name - return "未分类" - - -def stage_of(rec: dict) -> str: - if rec["_family"] == SCENARIO_FAMILY: - return "端到端场景" - api = rec.get("api", "") - if api.startswith("generate"): - return "生成" - if api.startswith("validate"): - return "校验" - return "其他" - - -def template_uri_zh(uri: str | None) -> str: - if not uri: - return "" - parts = uri.split("/") - domain = TEMPLATE_URI_ZH.get(parts[1], parts[1]) if len(parts) > 1 else uri - phase = PHASE_ZH.get(parts[2], parts[2]) if len(parts) > 2 else "" - version = parts[3] if len(parts) > 3 else "" - label = domain - if phase: - label += f"·{phase}" - if version: - label += f" {version}" - return label - - -# --- technical -> business translation ---------------------------------------------------------------------- - -def translate_llm_script(llm: dict | None) -> str: - """`[{"$fail":"non-json"}, {"$ref":"..."}]` -> 模拟大模型第 1 次返回非法数据、第 2 次返回正常内容...""" - if not llm: - return "" - script = llm.get("script") or [] - if not script: - return "" - parts: list[str] = [] - for index, item in enumerate(script, start=1): - if isinstance(item, dict) and "$fail" in item: - mode = FAIL_MODE_ZH.get(item["$fail"], item["$fail"]) - parts.append(f"第 {index} 次返回{mode}") - elif isinstance(item, dict) and "$ref" in item: - parts.append(f"第 {index} 次返回正常内容({item['$ref']})") - else: - parts.append(f"第 {index} 次返回指定的原始内容") - if len(script) == 1: - # single-shot wording without the enumeration reads better - text = "模拟大模型" + parts[0].replace("第 1 次", "") - else: - text = "模拟大模型" + "、".join(parts) - if "maxAttempts" in llm: - text += f"(最大尝试次数 {llm['maxAttempts']})" - return text - - -def translate_input(rec: dict) -> str: - """Input summary in business language: natural-language text, structured data, or prompt under validation.""" - chunks: list[str] = [] - inp = rec.get("input") or {} - text = inp.get("text") - if isinstance(text, dict): - zh = text.get("zh-CN") or text.get("en-US") or "" - langs = "(双语输入)" if len(text) > 1 else "" - chunks.append(f"自然语言{langs}:{clip(zh, 90)}") - elif isinstance(text, str): - chunks.append(f"自然语言:{clip(text, 90)}") - data = inp.get("data") - if data is not None: - chunks.append(f"结构化数据:{describe_data(data)}") - prompt = rec.get("prompt") - if isinstance(prompt, dict): - if "golden" in prompt: - chunks.append(f"待校验消息取自 golden 基准 {prompt['golden']}") - elif "fromStep" in prompt: - chunks.append(f"待校验消息取自第 {prompt['fromStep']} 步的生成结果") - elif "text" in prompt: - chunks.append(f"待校验消息:{clip(str(prompt['text']), 90)}") - schema = rec.get("schema") - if isinstance(schema, dict) and "$ref" in schema: - ref = schema["$ref"].split("/")[-1] - chunks.append(f"参数模式:{SCHEMA_ZH.get(ref, ref)}") - return ";".join(chunks) - - -def describe_data(data) -> str: - if not isinstance(data, dict): - return "(原始结构化数据)" - parts: list[str] = [] - if "conclusion" in data: - parts.append(f"结论={data['conclusion']}") - items = data.get("items") - if isinstance(items, list): - parts.append(f"{len(items)} 个信息项") - if "relationship" in data: - parts.append(f"关系={data['relationship']}") - if "reason" in data: - parts.append(f"终止原因={clip(str(data['reason']), 40)}") - return "、".join(parts) if parts else "(原始结构化数据)" - - -def translate_expect(rec: dict) -> str: - """Expectation summary in business language.""" - expect = rec.get("expect") or {} - outcome = expect.get("outcome") - lines: list[str] = [] - if outcome == "success": - lines.append("成功") - golden = expect.get("promptTextEqualsGolden") - if golden: - lines.append(f"渲染消息与 golden 基准一致({golden})") - params = expect.get("params") - if isinstance(params, dict): - rendered = "、".join(f"{TASK_PARAM_ZH.get(k, k)}={v}" for k, v in params.items()) - lines.append(f"提取参数:{clip(rendered, 120)}") - missing = expect.get("missingParams") - if isinstance(missing, list) and missing: - rendered = "、".join(TASK_PARAM_ZH.get(k, k) for k in missing) - lines.append(f"缺失参数(协商要数动因):{rendered}") - elif outcome == "failure": - code = expect.get("code") - lines.append(f"失败:{ERROR_CODE_ZH.get(code, code or '(无错误码)')}" + (f"({code})" if code else "")) - exception = expect.get("exception") - if exception: - lines.append(f"异常类型:{EXCEPTION_ZH.get(exception, exception)}") - slot_errors = expect.get("slotErrors") or [] - if slot_errors: - rendered = "、".join( - f"{e.get('slot')}:{ERROR_CODE_ZH.get(e.get('code'), e.get('code'))}" for e in slot_errors - ) - lines.append(f"槽位错误:{rendered}") - contains = expect.get("messageContains") - if contains: - lines.append(f"错误消息包含:{' / '.join(map(str, contains))}") - llm_calls = expect.get("llmCalls") - if llm_calls is not None: - lines.append("不发起任何大模型调用" if llm_calls == 0 else f"共调用大模型 {llm_calls} 次") - return ";".join(lines) - - -def translate_expect_flow(rec: dict) -> str: - flow = rec.get("expectFlow") or {} - if not flow: - return "" - parts: list[str] = [] - terminal = flow.get("terminalCondition") - if terminal: - parts.append(f"终态:{TERMINAL_ZH.get(terminal, terminal)}") - if "roundsUsed" in flow: - parts.append(f"共 {flow['roundsUsed']} 轮") - if flow.get("distinctMessages"): - parts.append("各轮消息互不相同") - return ",".join(parts) - - -def clip(text: str, limit: int) -> str: - text = str(text).replace("\n", " ") - return text if len(text) <= limit else text[: limit - 1] + "…" - - -def languages_label(rec: dict) -> str: - return "+".join(rec.get("languages", [])) - - -def review_status_map() -> dict: - if STATUS_PATH.is_file(): - try: - data = json.loads(STATUS_PATH.read_text(encoding="utf-8")) - return data.get("cases", {}) - except (OSError, ValueError): - return {} - return {} - - -# --- HTML rendering ----------------------------------------------------------------------------------------- - -CSS = """ -:root { color-scheme: light; } -* { box-sizing: border-box; } -body { font-family: 'Microsoft YaHei', 'PingFang SC', 'Segoe UI', sans-serif; margin: 0; background: #f5f6f8; color: #1f2430; } -header { background: #1f3a5f; color: #fff; padding: 18px 28px; } -header h1 { margin: 0 0 4px; font-size: 20px; } -header p { margin: 0; font-size: 12px; opacity: .85; } -nav { background: #fff; border-bottom: 1px solid #e3e6ea; padding: 8px 28px; font-size: 13px; position: sticky; top: 0; } -nav a { color: #1f3a5f; text-decoration: none; margin-right: 18px; font-weight: 600; } -main { padding: 20px 28px 48px; max-width: 1180px; margin: 0 auto; } -h2 { font-size: 17px; border-left: 4px solid #1f3a5f; padding-left: 10px; margin: 34px 0 14px; } -h3 { font-size: 14px; margin: 20px 0 10px; color: #1f3a5f; } -.cards { display: flex; flex-wrap: wrap; gap: 12px; } -.kpi { background: #fff; border: 1px solid #e3e6ea; border-radius: 8px; padding: 14px 18px; min-width: 150px; flex: 1; } -.kpi .num { font-size: 26px; font-weight: 700; color: #1f3a5f; } -.kpi .lbl { font-size: 12px; color: #667; margin-top: 2px; } -table { border-collapse: collapse; background: #fff; width: 100%; font-size: 13px; margin: 8px 0 16px; } -th, td { border: 1px solid #e3e6ea; padding: 6px 10px; text-align: left; vertical-align: top; } -th { background: #eef1f5; font-weight: 600; white-space: nowrap; } -.badge { display: inline-block; border-radius: 10px; padding: 1px 9px; font-size: 11px; font-weight: 600; white-space: nowrap; } -.b-P0 { background: #fdecea; color: #b3261e; } -.b-P1 { background: #fff4e0; color: #a05a00; } -.b-P2 { background: #eef1f5; color: #556; } -.b-lang { background: #e8f0fe; color: #1a4d8f; } -.b-ok { background: #e3f4e4; color: #1c6b2b; } -.b-doubt { background: #fff4e0; color: #a05a00; } -.b-veto { background: #fdecea; color: #b3261e; } -.b-none { background: #eef1f5; color: #778; } -.b-risk { background: #fdecea; color: #b3261e; } -.case { background: #fff; border: 1px solid #e3e6ea; border-radius: 8px; padding: 12px 16px; margin-bottom: 12px; } -.case .title { font-weight: 700; font-size: 14px; margin-bottom: 6px; } -.case .meta { margin: 2px 0; font-size: 12.5px; color: #334; } -.case .meta b { color: #1f3a5f; } -.case .src { font-size: 11px; color: #99a; margin-top: 6px; } -.pingpong td { height: 100%; } -.pp-step { background: #f0f5fb; border-radius: 6px; padding: 8px 10px; margin: 4px 0; font-size: 12.5px; } -.pp-step .api { font-weight: 700; color: #1f3a5f; } -footer { padding: 16px 28px; color: #889; font-size: 11px; } -.risk { background: #fff; border: 1px solid #f0c8c4; border-left: 4px solid #b3261e; border-radius: 6px; padding: 10px 14px; margin: 8px 0; font-size: 13px; } -""" - - -def esc(value) -> str: - return html.escape(str(value), quote=True) - - -def badge(value: str, cls: str) -> str: - return f'{esc(value)}' - - -def status_badge(status: str) -> str: - mapping = {"通过": "b-ok", "有疑问": "b-doubt", "否决": "b-veto"} - if status in mapping: - return badge("评审" + status, mapping[status]) - return badge("未评审", "b-none") - - -def render_html(records: list[dict], statuses: dict) -> str: - total = len(records) - expanded = sum(len(r.get("languages", [])) for r in records) - scenarios = [r for r in records if r["_family"] == SCENARIO_FAMILY] - cases = [r for r in records if r["_family"] != SCENARIO_FAMILY] - bilingual = sum(1 for r in records if len(r.get("languages", [])) > 1) - zh_only = sum(1 for r in records if r.get("languages") == ["zh-CN"]) - en_only = sum(1 for r in records if r.get("languages") == ["en-US"]) - priorities = {p: sum(1 for r in records if r.get("priority") == p) for p in ("P0", "P1", "P2")} - no_priority = total - sum(priorities.values()) - reviewed = sum(1 for s in statuses.values() if s.get("status") in REVIEW_STATUSES) - doc_gaps = [r for r in records if "doc-gap" in r.get("tags", [])] - p2_records = [r for r in records if r.get("priority") == "P2"] - - out = io.StringIO() - w = out.write - now = datetime.now().strftime("%Y-%m-%d %H:%M") - w("") - w("") - w(f"A2A-T 协商测试语料业务评审 · {now}") - w(f"

            专线投诉诊断协商测试语料业务评审材料

            " - f"

            单一业务域:传输专线业务投诉诊断五步闭环(Q23)· " - f"角色:A=工作台(client,任务发起/补数方) / B=OMC(server,执行/要数方,协商发起方)

            " - f"

            自动生成于 {now} · 语料投影,请勿手工编辑 · 生成命令:python tools/corpus_review.py export

            ") - w("
            ") - - # ---- dashboard ---- - w("

            总览仪表盘

            ") - kpis = [ - (total, "用例总数(基记录)"), - (expanded, "双语展开后执行数"), - (len(cases), "单步用例"), - (len(scenarios), "端到端场景"), - (bilingual, "双语用例"), - (priorities["P0"], "P0 用例"), - (reviewed, "已评审"), - ] - for num, label in kpis: - w(f"
            {num}
            {esc(label)}
            ") - w("
            ") - - w("

            家族分布

            ") - for family in (*CASE_FAMILIES, SCENARIO_FAMILY): - count = sum(1 for r in records if r["_family"] == family) - w(f"") - w("
            家族数量
            {esc(family)}{count}
            ") - - w("

            双语 parity

            " - f"
            双语(zh-CN + en-US)仅 zh-CN仅 en-US
            {bilingual}{zh_only}{en_only}
            " - "

            parity 契约由 CorpusContractTest 严格模式保障:happy 用例双语都成功、" - "失败用例双语同错误码、双语 golden fixture 数量相同、展开后用例数相同。

            ") - - w("

            优先级分布

            ") - for p in ("P0", "P1", "P2"): - w(f"") - if no_priority: - w(f"") - w("
            优先级数量
            {badge(p, 'b-' + p)}{priorities[p]}
            (场景默认){no_priority}
            ") - - w("

            业务域 × 阶段覆盖矩阵

            ") - for domain in DOMAIN_ORDER: - subset = [r for r in records if business_domain(r) == domain] - if not subset: - continue - gen = sum(1 for r in subset if stage_of(r) == "生成") - val = sum(1 for r in subset if stage_of(r) == "校验") - e2e = sum(1 for r in subset if stage_of(r) == "端到端场景") - w(f"") - w("
            业务域生成校验端到端场景合计
            {esc(domain)}{gen}{val}{e2e}{len(subset)}
            ") - - w(f"

            协商错误码覆盖({len(ERROR_CODE_ZH)} 个,CorpusContractTest 全量严格模式)

            " - "") - for code, zh in ERROR_CODE_ZH.items(): - count = sum(1 for r in records if (r.get("expect") or {}).get("code") == code) - marker = " style='color:#b3261e;font-weight:700'" if count == 0 else "" - w(f"{count}") - w("
            错误码业务含义失败用例数
            {esc(code)}{esc(zh)}
            ") - - w("

            风险区

            ") - w(f"
            未评审用例:{total - reviewed} / {total}(评审闭环见下方评审说明)
            ") - w(f"
            doc-gap 探针:{len(doc_gaps)} 个 —— 依据文档声明但未经验证的行为探针," - f"结论需与最新生产 Javadoc 对齐:" + "、".join(esc(r["id"]) for r in doc_gaps) + "
            ") - w(f"
            P2 低优先级用例:{len(p2_records)} 个 —— " - + "、".join(esc(r["id"]) for r in p2_records) + "
            ") - - # ---- per-domain case cards ---- - w("

            分域用例卡片

            ") - for domain in DOMAIN_ORDER: - subset = [r for r in records if r["_family"] != SCENARIO_FAMILY and business_domain(r) == domain] - if not subset: - continue - w(f"

            {esc(domain)}({len(subset)} 个用例)

            ") - for r in subset: - status = statuses.get(r["id"], {}).get("status", "") - w("
            ") - w(f"
            {esc(r['id'])} · {esc(r.get('summary', ''))}
            ") - w(f"
            动作:{esc(api_action(r.get('api', '')))}" - f" 模板:{esc(template_uri_zh(r.get('templateUri')))}
            ") - w(f"
            输入:{esc(translate_input(r))}
            ") - llm_desc = translate_llm_script(r.get("llm")) - if llm_desc: - w(f"
            大模型:{esc(llm_desc)}
            ") - inject = INJECT_ZH.get(r.get("inject", "")) - if inject: - w(f"
            故障注入:{esc(inject)}
            ") - w(f"
            期望:{esc(translate_expect(r))}
            ") - w("
            ") - w(badge(r.get("priority", "P2"), "b-" + r.get("priority", "P2"))) - w(" " + badge(languages_label(r), "b-lang")) - w(" " + status_badge(status)) - if "doc-gap" in r.get("tags", []): - w(" " + badge("doc-gap 探针", "b-risk")) - w("
            ") - w(f"
            来源:{esc(r['_file'])} 第 {r['_line']} 行 · 标签:{esc(', '.join(r.get('tags', [])))}
            ") - w("
            ") - - # ---- scenario ping-pong tables ---- - w("

            场景时序表(A/B 乒乓)

            ") - if not scenarios: - w("

            (无场景记录)

            ") - for r in scenarios: - status = statuses.get(r["id"], {}).get("status", "") - w(f"

            {esc(r['id'])} · {esc(r.get('summary', ''))} " + status_badge(status) + "

            ") - w("") - for step in r.get("steps", []): - role = step.get("role", "A") - col = "B" if role == "B" else "A" - detail = io.StringIO() - detail.write(f"
            {esc(api_action(step.get('api', '')))}
            ") - inp = translate_input(step) - if inp: - detail.write(f"
            输入:{esc(inp)}
            ") - llm_desc = translate_llm_script(step.get("llm")) - if llm_desc: - detail.write(f"
            {esc(llm_desc)}
            ") - detail.write(f"
            期望:{esc(translate_expect(step))}
            ") - cells = {"A": "", "B": ""} - cells[col] = f"" - w(f"{cells['A']}{cells['B']}") - w("
            步骤A(工作台,发起/补数方)B(OMC,执行/要数方)
            {detail.getvalue()}
            {step.get('step', '')}
            ") - flow = translate_expect_flow(r) - if flow: - w(f"

            流程期望:{esc(flow)}

            ") - w(f"
            来源:{esc(r['_file'])} 第 {r['_line']} 行
            ") - - # ---- notes ---- - w("

            评审说明(闭环流程)

            ") - w("
              " - "
            1. 本材料由 tools/corpus_review.py export 从语料自动生成,是语料的只读投影,请勿手工编辑。
            2. " - "
            3. 业务专家在 corpus-review.xlsx 最后两列批注:评审结论(通过 / 有疑问 / 否决)与评审意见。
            4. " - "
            5. 运行 python tools/corpus_review.py collect --marked <批注后的xlsx> 回读标记," - "生成 findings 报告与 review-status.json,并精确映射回 JSON 源文件与行号。
            6. " - "
            7. 有疑问 / 否决的用例驱动语料修改,随后重新 export 进入下一轮评审。
            ") - w("
            A2A-T negotiation test corpus review projection · tools/corpus_review.py
            ") - return out.getvalue() - - -# --- xlsx rendering ------------------------------------------------------------------------------------------ - -def case_row(rec: dict) -> list[str]: - tags = ", ".join(rec.get("tags", [])) - if rec["_family"] == SCENARIO_FAMILY: - apis = sorted({s.get("api", "") for s in rec.get("steps", [])}) - api_label = " → ".join(api_action(a) for a in apis) - input_label = f"端到端流程({len(rec.get('steps', []))} 步):" + clip(rec.get("summary", ""), 60) - expect_label = translate_expect_flow(rec) or "见语料 expectFlow" - else: - api_label = api_action(rec.get("api", "")) - input_label = translate_input(rec) - expect_label = translate_expect(rec) - return [ - rec["id"], business_domain(rec), stage_of(rec), api_label, input_label, expect_label, - rec.get("priority", ""), languages_label(rec), tags, "", "", - ] - - -def write_xlsx(rows: list[list[str]]) -> None: - try: - from openpyxl import Workbook - from openpyxl.styles import Alignment, Font, PatternFill - from openpyxl.utils import get_column_letter - from openpyxl.worksheet.datavalidation import DataValidation - except ImportError: - print("[warn] openpyxl 未安装(pip install openpyxl),跳过 xlsx 输出,仅生成 HTML 与 CSV", file=sys.stderr) - return - wb = Workbook() - ws = wb.active - ws.title = "corpus-review" - header_fill = PatternFill("solid", fgColor="1F3A5F") - for col, value in enumerate(XLSX_HEADERS, start=1): - cell = ws.cell(row=1, column=col, value=value) - cell.font = Font(bold=True, color="FFFFFF") - cell.fill = header_fill - cell.alignment = Alignment(horizontal="center", vertical="center") - for row_index, row in enumerate(rows, start=2): - for col, value in enumerate(row, start=1): - ws.cell(row=row_index, column=col, value=value).alignment = Alignment(vertical="top", wrap_text=True) - widths = [14, 12, 11, 26, 46, 46, 8, 14, 30, 12, 30] - for col, width in enumerate(widths, start=1): - ws.column_dimensions[get_column_letter(col)].width = width - validation = DataValidation(type="list", formula1='"通过,有疑问,否决"', allow_blank=True) - ws.add_data_validation(validation) - validation.add(f"J2:J{len(rows) + 1}") - ws.freeze_panes = "A2" - ws.auto_filter.ref = f"A1:{get_column_letter(len(XLSX_HEADERS))}{len(rows) + 1}" - REVIEW_DIR.mkdir(parents=True, exist_ok=True) - wb.save(XLSX_PATH) - print(f"[ok] wrote {XLSX_PATH.relative_to(REPO_ROOT)} ({len(rows)} rows)") - - -def write_csv(rows: list[list[str]]) -> None: - REVIEW_DIR.mkdir(parents=True, exist_ok=True) - with CSV_PATH.open("w", encoding="utf-8-sig", newline="") as fh: - csv.writer(fh).writerows([XLSX_HEADERS, *rows]) - print(f"[ok] wrote {CSV_PATH.relative_to(REPO_ROOT)} ({len(rows)} rows, degraded/backup format)") - - -# --- export / collect commands ------------------------------------------------------------------------------- - -def cmd_export() -> int: - records = load_corpus() - statuses = review_status_map() - rows = [case_row(r) for r in records] - REVIEW_DIR.mkdir(parents=True, exist_ok=True) - HTML_PATH.write_text(render_html(records, statuses), encoding="utf-8") - print(f"[ok] wrote {HTML_PATH.relative_to(REPO_ROOT)} " - f"({len(records)} records, {sum(len(r.get('languages', [])) for r in records)} expanded)") - write_xlsx(rows) - write_csv(rows) - return 0 - - -def read_marked(path: Path) -> list[dict]: - """Read annotated rows from xlsx (openpyxl) or csv; each row keeps id / 评审结论 / 评审意见.""" - rows: list[dict] = [] - if path.suffix.lower() == ".csv": - with path.open(encoding="utf-8-sig", newline="") as fh: - for raw in csv.DictReader(fh): - rows.append({ - "id": (raw.get("id") or "").strip(), - "conclusion": (raw.get("评审结论") or "").strip(), - "comment": (raw.get("评审意见") or "").strip(), - }) - return rows - try: - from openpyxl import load_workbook - except ImportError: - raise SystemExit("[error] 回读 xlsx 需要 openpyxl(pip install openpyxl);或将批注结果另存为 CSV 后重试") - wb = load_workbook(path, read_only=True, data_only=True) - ws = wb.active - it = ws.iter_rows(values_only=True) - header = [str(c).strip() if c is not None else "" for c in next(it, [])] - try: - id_col = header.index("id") - concl_col = header.index("评审结论") - comment_col = header.index("评审意见") - except ValueError as exc: - raise SystemExit(f"[error] 批注文件表头缺少必需列: {exc}") - for values in it: - if values is None or id_col >= len(values): - continue - case_id = values[id_col] - if case_id is None or str(case_id).strip() == "": - continue - def cell(index: int) -> str: - return str(values[index]).strip() if index < len(values) and values[index] is not None else "" - rows.append({"id": str(case_id).strip(), "conclusion": cell(concl_col), "comment": cell(comment_col)}) - wb.close() - return rows - - -def normalize_conclusion(value: str) -> str | None: - lowered = value.strip().lower() - mapping = {"通过": "通过", "ok": "通过", "pass": "通过", "yes": "通过", "y": "通过", - "有疑问": "有疑问", "疑问": "有疑问", "question": "有疑问", "q": "有疑问", - "否决": "否决", "reject": "否决", "no": "否决", "n": "否决"} - return mapping.get(lowered) - - -def cmd_collect(marked: Path) -> int: - if not marked.is_file(): - raise SystemExit(f"[error] marked file not found: {marked}") - records = {r["id"]: r for r in load_corpus()} - marked_rows = read_marked(marked) - statuses: dict[str, dict] = {} - problems: list[str] = [] - for row in marked_rows: - conclusion = normalize_conclusion(row["conclusion"]) - if conclusion is None: - if row["conclusion"]: - problems.append(f"无法识别的评审结论 '{row['conclusion']}'(用例 {row['id']}),应为 通过/有疑问/否决") - continue - if row["id"] not in records: - problems.append(f"批注中的用例 id {row['id']} 不存在于语料") - continue - rec = records[row["id"]] - statuses[row["id"]] = { - "status": conclusion, - "comment": row["comment"], - "source": {"file": rec["_file"], "line": rec["_line"]}, - } - - counts = {s: sum(1 for v in statuses.values() if v["status"] == s) for s in REVIEW_STATUSES} - now = datetime.now().isoformat(timespec="seconds") - status_doc = { - "generatedAt": now, - "markedFrom": str(marked).replace("\\", "/"), - "summary": { - "total": len(records), - "reviewed": len(statuses), - "pending": len(records) - len(statuses), - **counts, - }, - "cases": statuses, - } - REVIEW_DIR.mkdir(parents=True, exist_ok=True) - STATUS_PATH.write_text(json.dumps(status_doc, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"[ok] wrote {STATUS_PATH.relative_to(REPO_ROOT)} " - f"(reviewed={len(statuses)}/{len(records)}, 通过={counts['通过']}, 有疑问={counts['有疑问']}, 否决={counts['否决']})") - - lines = [ - "# 协商测试语料评审 findings", - "", - f"- 回读时间:{now}", - f"- 批注文件:`{str(marked).replace(chr(92), '/')}`", - f"- 评审进度:{len(statuses)} / {len(records)}(通过 {counts['通过']}、有疑问 {counts['有疑问']}、否决 {counts['否决']}、未评审 {len(records) - len(statuses)})", - "", - ] - if problems: - lines += ["## 回读告警", ""] - lines += [f"- {p}" for p in problems] - lines.append("") - for status, heading in (("否决", "否决(必须修改语料或期望)"), ("有疑问", "有疑问(需业务澄清)")): - subset = [rec for cid, rec in records.items() if statuses.get(cid, {}).get("status") == status] - lines += [f"## {heading}({len(subset)})", ""] - if not subset: - lines.append("(无)") - for rec in subset: - mark = statuses[rec["id"]] - lines.append(f"### {rec['id']} — {rec.get('summary', '')}") - lines.append(f"- 位置:`{mark['source']['file']}` 第 {mark['source']['line']} 行") - lines.append(f"- 意见:{mark['comment'] or '(未填写)'}") - lines.append(f"- 业务域:{business_domain(rec)} · 阶段:{stage_of(rec)} · 优先级:{rec.get('priority', '—')}") - lines.append(f"- 输入:{translate_input(rec)}") - lines.append(f"- 期望:{translate_expect(rec) if rec['_family'] != SCENARIO_FAMILY else translate_expect_flow(rec)}") - lines.append("") - passed = [cid for cid, v in statuses.items() if v["status"] == "通过"] - lines += [f"## 通过({len(passed)})", "", "、".join(passed) if passed else "(无)", ""] - FINDINGS_PATH.write_text("\n".join(lines), encoding="utf-8") - print(f"[ok] wrote {FINDINGS_PATH.relative_to(REPO_ROOT)}") - for problem in problems: - print(f"[warn] {problem}", file=sys.stderr) - return 0 - - -def main(argv: list[str] | None = None) -> int: - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8", errors="replace") - sys.stderr.reconfigure(encoding="utf-8", errors="replace") - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - sub = parser.add_subparsers(dest="command", required=True) - sub.add_parser("export", help="生成 docs-local/review/corpus-review.html 与 .xlsx(评审材料投影)") - collect = sub.add_parser("collect", help="回读批注后的 xlsx/csv,生成 findings 报告与 review-status.json") - collect.add_argument("--marked", required=True, type=Path, help="批注后的 corpus-review xlsx 或 csv 文件") - args = parser.parse_args(argv) - if args.command == "export": - return cmd_export() - return cmd_collect(args.marked) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/live_transcript_export.py b/tools/live_transcript_export.py deleted file mode 100644 index b66e1112..00000000 --- a/tools/live_transcript_export.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -"""Exports a live-corpus transcript into a review-friendly report with inline replayable requests. - -Reads target/live-corpus//transcript.json (+ summary.json when present) and writes a single -export/report.md: one section per case -- input, parsed params, every LLM call's full request -messages / schema / raw response -- and, per call, the exact OpenAI-compatible -/v1/chat/completions request body OpenAIClient sent (the JSON-mode system instruction, the schema -system message, response_format json_object, and the recorded messages), pretty-printed inline so -it can be copied straight into Postman for prompt tuning outside the test suite. - -Usage: - python tools/live_transcript_export.py [--out DIR] -""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path - -# Mirrors OpenAIClient.JSON_MODE_INSTRUCTION (a2a-t-llm) -- the first system message of every -# structured call. Kept in sync by live_replay self-check below. -JSON_MODE_INSTRUCTION = ( - "Return a valid JSON object string. The output must be valid json. " - "Do not wrap the response in markdown code fences. " - "Do not include any explanation outside the JSON object." -) - -# Mirrors the schema message prefix OpenAIClient prepends (OpenAIClient.buildStructuredMessages). -SCHEMA_MESSAGE_PREFIX = "Return JSON that conforms to this JSON schema: " - -# Temperature the live .env pins (LiveLlmEnvWriter, A2AT_LLM_TEMPERATURE=0); used when the -# per-call record carries none, which is the production default path. -DEFAULT_TEMPERATURE = 0.0 - -OUTCOME_ICONS = {"PASS": "✅", "FAIL": "❌", "ERROR": "💥", "SKIP": "⏭️"} - - -def load_run(source: Path) -> tuple[Path, list[dict], dict | None]: - """Resolves the run directory, transcript cases and optional summary from a path.""" - if source.is_dir(): - run_dir = source - transcript_path = run_dir / "transcript.json" - else: - transcript_path = source - run_dir = transcript_path.parent - if not transcript_path.is_file(): - raise SystemExit(f"transcript not found: {transcript_path}") - cases = json.loads(transcript_path.read_text(encoding="utf-8")) - summary_path = run_dir / "summary.json" - summary = json.loads(summary_path.read_text(encoding="utf-8")) if summary_path.is_file() else None - return run_dir, cases, summary - - -def build_replay_request(call: dict) -> dict: - """Rebuilds the /v1/chat/completions body OpenAIClient sent for one recorded call.""" - messages = [ - {"role": "system", "content": JSON_MODE_INSTRUCTION}, - {"role": "system", "content": SCHEMA_MESSAGE_PREFIX + json.dumps(call.get("jsonSchema") or {}, ensure_ascii=False)}, - ] - for message in call.get("messages") or []: - role = message.get("role", "user") - messages.append({"role": "system" if role == "system" else "user", "content": message.get("content", "")}) - request = { - "model": call.get("model"), - "messages": messages, - "response_format": {"type": "json_object"}, - } - temperature = call.get("temperature") - request["temperature"] = temperature if temperature is not None else DEFAULT_TEMPERATURE - max_tokens = call.get("maxTokens") - if max_tokens is not None: - request["max_tokens"] = max_tokens - return request - - -def render_messages(messages: list[dict]) -> str: - """Renders one call's pipeline messages as role-labeled fenced blocks. - - Four-backtick fences: message bodies routinely contain triple-backtick json blocks - (the extraction prompts embed output-format examples), which would close a normal fence. - """ - parts = [] - for message in messages: - role = message.get("role", "user") - parts.append(f"**{role}**\n\n````\n{message.get('content', '')}\n````") - return "\n\n".join(parts) - - -def render_params(params: dict | None) -> str: - """Renders the parsed param map as a markdown table, null values shown as such.""" - if not params: - return "_(none)_" - rows = ["| slot | extracted value |", "| --- | --- |"] - for key in sorted(params): - value = params[key] - rows.append(f"| `{key}` | {value if value is not None else '_null_'} |") - return "\n".join(rows) - - -def render_report(cases: list[dict], summary: dict | None) -> str: - """Renders the whole run into one markdown document.""" - lines = ["# Live corpus run transcript", ""] - if summary: - lines += [ - "## Run summary", - "", - f"- cases: {summary.get('totalCases')} " - f"(pass {summary.get('passCount')}, fail {summary.get('failCount')}, " - f"error {summary.get('errorCount')}, skip {summary.get('skipCount')})", - f"- LLM calls: {summary.get('totalLlmCalls')}" - f" (prompt tokens {summary.get('totalPromptTokens')}," - f" completion tokens {summary.get('totalCompletionTokens')})", - f"- schema parse failures: {summary.get('schemaParseFailureCount')}", - "", - "_Replay a request in Postman: POST `$A2AT_TEST_LLM_BASE_URL/chat/completions`," - " header `Authorization: Bearer $A2AT_TEST_LLM_API_KEY`," - " body = the JSON of the call's replay section._", - "", - ] - for index, case in enumerate(cases): - outcome = case.get("outcome", "?") - lines += [ - f"## {OUTCOME_ICONS.get(outcome, '')} {case.get('caseId')} — {outcome}", - "", - f"- assertions: {case.get('assertionSummary') or '_(none recorded)_'}", - f"- scenario: `{case.get('scenarioCode') or '_(none)'}`" - + (f", duration {case.get('durationMs')} ms" if case.get("durationMs") is not None else ""), - "", - "### Input", - "", - "````", - case.get("inputSummary") or "_(not recorded)_", - "````", - "", - "### Extracted params", - "", - render_params(case.get("params")), - "", - ] - for call_number, call in enumerate(case.get("llmCalls") or [], start=1): - usage = call.get("usage") or {} - lines += [ - f"### LLM call {call_number}", - "", - f"- model: `{call.get('model')}`" - f", duration {call.get('durationMs')} ms" - f", tokens: prompt {usage.get('prompt_tokens', '?')}" - f" / completion {usage.get('completion_tokens', '?')}" - + (f", **error: {call.get('error')}**" if call.get("error") else ""), - "", - "#### Request messages (as the pipeline sent them)", - "", - render_messages(call.get("messages") or []), - "", - "#### JSON schema", - "", - "```json", - json.dumps(call.get("jsonSchema"), ensure_ascii=False, indent=2), - "```", - "", - "#### Raw response", - "", - "````", - call.get("content") or "_(no content)_", - "````", - "", - "#### Replay request (copy into Postman)", - "", - "````json", - json.dumps(build_replay_request(call), ensure_ascii=False, indent=2), - "````", - "", - ] - if case.get("failureDiff"): - lines += ["### Failure diff", "", "````", case["failureDiff"], "````", ""] - return "\n".join(lines) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("run", help="run directory (containing transcript.json) or the transcript.json itself") - parser.add_argument("--out", help="output directory (default: /export)") - args = parser.parse_args() - - run_dir, cases, summary = load_run(Path(args.run)) - out_dir = Path(args.out) if args.out else run_dir / "export" - out_dir.mkdir(parents=True, exist_ok=True) - - report = render_report(cases, summary) - report_path = out_dir / "report.md" - report_path.write_text(report, encoding="utf-8") - - print(f"{len(cases)} cases, {sum(len(case.get('llmCalls') or []) for case in cases)} inline replay requests") - print(f"report: {report_path}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) From d41cf02238589c8ba23b081752608168ebea3b2e Mon Sep 17 00:00:00 2001 From: ZhouJie6 Date: Sun, 13 Sep 2026 10:08:28 +0800 Subject: [PATCH 265/295] feat(llm): add configurable TLS verification Signed-off-by: ZhouJie6 --- .../a2at/sdk/core/model/A2ATConfigKeys.java | 3 + .../openan/a2at/sdk/core/model/LlmConfig.java | 37 +++++ a2a-t-llm/pom.xml | 4 + .../openan/a2at/sdk/llm/LLMClientConfig.java | 33 ++++ .../a2at/sdk/llm/providers/OpenAIClient.java | 46 ++++++ .../a2at/sdk/llm/LLMClientConfigFromTest.java | 58 +++++-- .../a2at/sdk/llm/LLMClientConfigTest.java | 21 +++ .../a2at/sdk/llm/LLMClientFactoryTest.java | 2 +- .../sdk/llm/providers/OpenAIClientTest.java | 4 +- .../OpenAIClientTlsVerificationTest.java | 145 ++++++++++++++++++ .../self-signed-llm-test-keystore.p12 | Bin 0 -> 2684 bytes .../DefaultA2ATServerBuilderTest.java | 1 + docs/en/developer_guide.md | 2 +- ...00\345\217\221\346\214\207\345\215\227.md" | 1 + env.example | 4 + 15 files changed, 340 insertions(+), 21 deletions(-) create mode 100644 a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTlsVerificationTest.java create mode 100644 a2a-t-llm/src/test/resources/self-signed-llm-test-keystore.p12 diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java index a8a0647a..b173ea32 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java @@ -57,6 +57,9 @@ public static final class Llm { /** Disables use of the JVM or operating-system HTTP proxy for the LLM request. */ public static final String DISABLE_SYSTEM_PROXY = "A2AT_LLM_DISABLE_SYSTEM_PROXY"; + /** Whether to verify the TLS certificate chain and hostname of the LLM endpoint (default true). */ + public static final String SSL_VERIFY = "A2AT_LLM_SSL_VERIFY"; + /** Optional reasoning effort level for reasoning models (none/minimal/low/medium/high/xhigh). */ public static final String REASONING_EFFORT = "A2AT_LLM_REASONING_EFFORT"; diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java index fd65d70b..85d763fd 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java @@ -26,6 +26,7 @@ public record LlmConfig( int sessionMaxTotal, int sessionMaxPerProvider, boolean disableSystemProxy, + boolean sslVerify, @Nullable String reasoningEffort, int maxAttempts, List parseErrors) { @@ -52,6 +53,40 @@ public record LlmConfig( parseErrors = List.copyOf(parseErrors); } + /** Pre-{@code sslVerify} signature kept for binary compatibility; TLS peer verification stays enabled. */ + public LlmConfig( + String provider, + String model, + String apiKey, + String baseUrl, + int historyWindow, + @Nullable Integer maxTokens, + @Nullable Double temperature, + @Nullable Double timeoutSeconds, + int sessionMaxTotal, + int sessionMaxPerProvider, + boolean disableSystemProxy, + @Nullable String reasoningEffort, + int maxAttempts, + List parseErrors) { + this( + provider, + model, + apiKey, + baseUrl, + historyWindow, + maxTokens, + temperature, + timeoutSeconds, + sessionMaxTotal, + sessionMaxPerProvider, + disableSystemProxy, + true, + reasoningEffort, + maxAttempts, + parseErrors); + } + /** * Builds one LLM config from raw {@code .env} values. * @@ -79,6 +114,8 @@ public static LlmConfig fromMap(Map values) { A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER, DEFAULT_SESSION_MAX_PER_PROVIDER, parseErrors), parseBoolean(values.get(A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY), A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY, false, parseErrors), + parseBoolean(values.get(A2ATConfigKeys.Llm.SSL_VERIFY), + A2ATConfigKeys.Llm.SSL_VERIFY, true, parseErrors), parseReasoningEffort(values.get(A2ATConfigKeys.Llm.REASONING_EFFORT)), parseMaxAttempts(values.get(A2ATConfigKeys.Llm.MAX_ATTEMPTS)), parseErrors); diff --git a/a2a-t-llm/pom.xml b/a2a-t-llm/pom.xml index 72dea5d8..f8c3387d 100644 --- a/a2a-t-llm/pom.xml +++ b/a2a-t-llm/pom.xml @@ -29,6 +29,10 @@ openai-java-core ${openai.version} + + org.slf4j + slf4j-api + org.junit.jupiter junit-jupiter diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java index 10207349..ef429d09 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java @@ -22,6 +22,7 @@ * @param sessionMaxTotal reserved total session limit * @param sessionMaxPerProvider reserved per-provider session limit * @param disableSystemProxy whether to bypass the JVM or operating-system HTTP proxy + * @param sslVerify whether to verify the TLS certificate chain and hostname of the LLM endpoint * @param reasoningEffort optional reasoning effort for reasoning models (none/minimal/low/medium/high/xhigh); null * leaves the parameter unset * @since 2026-06 @@ -38,6 +39,7 @@ public record LLMClientConfig( int sessionMaxTotal, int sessionMaxPerProvider, boolean disableSystemProxy, + boolean sslVerify, String reasoningEffort) { private static final int MAX_HISTORY_WINDOW = 100; @@ -49,6 +51,36 @@ public record LLMClientConfig( private static final Set VALID_REASONING_EFFORTS = Set.of("none", "minimal", "low", "medium", "high", "xhigh"); + /** Pre-{@code sslVerify} signature kept for binary compatibility; TLS peer verification stays enabled. */ + public LLMClientConfig( + String provider, + String model, + String apiKey, + String baseUrl, + int historyWindow, + Integer maxTokens, + Double temperature, + Double timeoutSeconds, + int sessionMaxTotal, + int sessionMaxPerProvider, + boolean disableSystemProxy, + String reasoningEffort) { + this( + provider, + model, + apiKey, + baseUrl, + historyWindow, + maxTokens, + temperature, + timeoutSeconds, + sessionMaxTotal, + sessionMaxPerProvider, + disableSystemProxy, + true, + reasoningEffort); + } + /** * Derives an LLM client configuration from the unified LLM configuration. * @@ -78,6 +110,7 @@ public static LLMClientConfig from(LlmConfig llmConfig) { llmConfig.sessionMaxTotal(), llmConfig.sessionMaxPerProvider(), llmConfig.disableSystemProxy(), + llmConfig.sslVerify(), reasoningEffort); } diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java index ff865c67..cca076d0 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java @@ -11,6 +11,8 @@ import com.openai.models.chat.completions.ChatCompletionSystemMessageParam; import com.openai.models.chat.completions.ChatCompletionUserMessageParam; import java.net.Proxy; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; import java.time.Duration; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -18,11 +20,16 @@ import java.util.Locale; import java.util.Map; import java.util.function.BiFunction; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMConfigError; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * OpenAI LLM provider client. @@ -33,6 +40,8 @@ public class OpenAIClient implements LLMClient { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final Logger log = LoggerFactory.getLogger(OpenAIClient.class); + private static final String JSON_MODE_INSTRUCTION = "Return a valid JSON object string. The output must be valid json. " + "Do not wrap the response in markdown code fences. " @@ -213,6 +222,9 @@ private synchronized com.openai.client.OpenAIClient sdkClient(LLMClientConfig ru if (runtimeConfig.disableSystemProxy()) { builder.proxy(Proxy.NO_PROXY); } + if (!runtimeConfig.sslVerify()) { + applyInsecureTls(builder); + } if (runtimeConfig.timeoutSeconds() != null && runtimeConfig.timeoutSeconds() > 0.0d) { builder.timeout(Duration.ofMillis(Math.max(1L, Math.round(runtimeConfig.timeoutSeconds() * 1000.0d)))); } @@ -220,4 +232,38 @@ private synchronized com.openai.client.OpenAIClient sdkClient(LLMClientConfig ru } return sdkClient; } + + /** + * Disables TLS certificate-chain and hostname verification for the provider endpoint. + * + *

            Only for controlled environments whose HTTPS endpoint cannot yet present a certificate trusted by the JVM. + * Everything else on the connection keeps the OpenAI SDK defaults. + */ + private static void applyInsecureTls(OpenAIOkHttpClient.Builder builder) { + X509TrustManager trustAllManager = + new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[] {trustAllManager}, new SecureRandom()); + builder.sslSocketFactory(sslContext.getSocketFactory()); + } catch (Exception error) { + throw new LLMConfigError("Failed to disable TLS certificate-chain and hostname verification", error); + } + builder.trustManager(trustAllManager); + builder.hostnameVerifier((hostname, session) -> true); + log.warn( + "[A2AT-LLM] TLS certificate chain and hostname verification are disabled; " + + "use only in controlled environments with trusted networks"); + } } diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java index 06fd6692..0f4089dd 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java @@ -25,6 +25,7 @@ void derivesFullConfigCorrectly() { 500, 200, false, + true, "medium", 3, List.of()); @@ -40,15 +41,36 @@ void derivesFullConfigCorrectly() { assertEquals(0.5d, config.temperature()); assertEquals(30.0d, config.timeoutSeconds()); assertEquals(false, config.disableSystemProxy()); + assertEquals(true, config.sslVerify()); assertEquals("medium", config.reasoningEffort()); assertEquals(500, config.sessionMaxTotal()); assertEquals(200, config.sessionMaxPerProvider()); } + @Test + void carriesSslVerifyDisabledThroughFrom() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, false, null, 3, List.of()); + + LLMClientConfig config = LLMClientConfig.from(llmConfig); + + assertEquals(false, config.sslVerify()); + } + + @Test + void legacyLlmConfigConstructorKeepsSslVerifyEnabled() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); + + LLMClientConfig config = LLMClientConfig.from(llmConfig); + + assertEquals(true, config.sslVerify()); + } + @Test void rejectsMissingProvider() { LlmConfig llmConfig = - new LlmConfig("", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); + new LlmConfig("", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -58,7 +80,7 @@ void rejectsMissingProvider() { @Test void rejectsMissingModel() { LlmConfig llmConfig = - new LlmConfig("openai", "", "sk-test", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); + new LlmConfig("openai", "", "sk-test", null, 10, null, null, null, 300, 100, false, true, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -68,7 +90,7 @@ void rejectsMissingModel() { @Test void rejectsMissingApiKey() { LlmConfig llmConfig = - new LlmConfig("openai", "gpt-4o", "", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); + new LlmConfig("openai", "gpt-4o", "", null, 10, null, null, null, 300, 100, false, true, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -78,7 +100,7 @@ void rejectsMissingApiKey() { @Test void rejectsMultipleMissingKeysAndListsThemAll() { LlmConfig llmConfig = - new LlmConfig("", "", "", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); + new LlmConfig("", "", "", null, 10, null, null, null, 300, 100, false, true, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -90,7 +112,7 @@ void rejectsMultipleMissingKeysAndListsThemAll() { @Test void rejectsHistoryWindowAbove100() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 101, null, null, null, 300, 100, false, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 101, null, null, null, 300, 100, false, true, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -100,7 +122,7 @@ void rejectsHistoryWindowAbove100() { @Test void rejectsSessionMaxTotalAbove3000() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 3001, 100, false, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 3001, 100, false, true, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -110,7 +132,7 @@ void rejectsSessionMaxTotalAbove3000() { @Test void rejectsSessionMaxPerProviderAbove1000() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 1001, false, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 1001, false, true, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -120,7 +142,7 @@ void rejectsSessionMaxPerProviderAbove1000() { @Test void rejectsSessionMaxTotalSmallerThanSessionMaxPerProvider() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 50, 100, false, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 50, 100, false, true, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -131,7 +153,7 @@ void rejectsSessionMaxTotalSmallerThanSessionMaxPerProvider() { @Test void derivesUnconfiguredOptionalsAsNull() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, null, 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -140,13 +162,14 @@ void derivesUnconfiguredOptionalsAsNull() { assertNull(config.temperature()); assertNull(config.timeoutSeconds()); assertEquals(false, config.disableSystemProxy()); + assertEquals(true, config.sslVerify()); assertNull(config.reasoningEffort()); } @Test void normalizesReasoningEffortCase() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, "MEDIUM", 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, "MEDIUM", 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -156,7 +179,7 @@ void normalizesReasoningEffortCase() { @Test void rejectsUnknownReasoningEffortValue() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, "middle", 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, "middle", 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -167,7 +190,7 @@ void rejectsUnknownReasoningEffortValue() { @Test void treatsBlankReasoningEffortAsNull() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, " ", 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, " ", 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -177,7 +200,7 @@ void treatsBlankReasoningEffortAsNull() { @Test void treatsNullReasoningEffortAsNull() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, null, 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -198,6 +221,7 @@ void rejectsParseErrorsAndListsThemAll() { 300, 100, false, + true, null, 3, List.of( @@ -213,7 +237,7 @@ void rejectsParseErrorsAndListsThemAll() { @Test void acceptsEmptyParseErrors() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, 2048, 0.5d, 30.0d, 500, 200, false, "medium", 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, 2048, 0.5d, 30.0d, 500, 200, false, true, "medium", 3, List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -224,7 +248,7 @@ void acceptsEmptyParseErrors() { @Test void rejectsHistoryWindowZeroOrNegative() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 0, null, null, null, 300, 100, false, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 0, null, null, null, 300, 100, false, true, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -234,7 +258,7 @@ void rejectsHistoryWindowZeroOrNegative() { @Test void rejectsSessionMaxTotalZeroOrNegative() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 0, 100, false, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 0, 100, false, true, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -244,7 +268,7 @@ void rejectsSessionMaxTotalZeroOrNegative() { @Test void rejectsSessionMaxPerProviderZeroOrNegative() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 0, false, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 0, false, true, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java index ce7d7f75..a76590ae 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java @@ -20,6 +20,7 @@ void storesResolvedDefaultLlmClientConfiguration() { 300, 100, true, + false, "none"); assertEquals("openai", config.provider()); @@ -31,8 +32,28 @@ void storesResolvedDefaultLlmClientConfiguration() { assertEquals(0.2d, config.temperature()); assertEquals(15.5d, config.timeoutSeconds()); assertEquals(true, config.disableSystemProxy()); + assertEquals(false, config.sslVerify()); assertEquals(300, config.sessionMaxTotal()); assertEquals(100, config.sessionMaxPerProvider()); assertEquals("none", config.reasoningEffort()); } + + @Test + void legacyConstructorDefaultsSslVerifyToTrue() { + LLMClientConfig config = new LLMClientConfig( + "openai", + "gpt-4o-mini", + "sk-test", + "https://api.example.test/v1", + 10, + 1024, + 0.2d, + 15.5d, + 300, + 100, + true, + "none"); + + assertEquals(true, config.sslVerify()); + } } diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java index c8f8cd55..3ab1e098 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java @@ -82,7 +82,7 @@ void rejectsProviderClassWithoutPublicConfigConstructor() { } private static LLMClientConfig buildConfig(String provider) { - return new LLMClientConfig(provider, "test-model", "sk-test", null, 10, null, null, null, 300, 100, false, null); + return new LLMClientConfig(provider, "test-model", "sk-test", null, 10, null, null, null, 300, 100, false, true, null); } public static final class CustomClient implements LLMClient { diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java index 53c2d606..e0986807 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java @@ -98,7 +98,7 @@ void structuredBuildsJsonModePayloadAndParsesJsonObjectResponse() { void structuredFallsBackToConfigTemperatureAndMaxTokens() { AtomicReference capturedParams = new AtomicReference<>(); LLMClientConfig config = new LLMClientConfig( - "openai", "gpt-4o-mini", "sk-test", "https://api.example.test/v1", 10, 128, 0.4d, null, 300, 100, false, null); + "openai", "gpt-4o-mini", "sk-test", "https://api.example.test/v1", 10, 128, 0.4d, null, 300, 100, false, true, null); OpenAIClient client = new OpenAIClient(config, (runtimeConfig, requestParams) -> { capturedParams.set(requestParams); return chatCompletion("{}"); @@ -203,7 +203,7 @@ void concurrentStructuredCallsAllSucceed() throws Exception { } private static LLMClientConfig config(String apiKey, String baseUrl) { - return new LLMClientConfig("openai", "gpt-4o-mini", apiKey, baseUrl, 10, null, null, null, 300, 100, false, null); + return new LLMClientConfig("openai", "gpt-4o-mini", apiKey, baseUrl, 10, null, null, null, 300, 100, false, true, null); } private static ChatCompletion chatCompletion(String content) { diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTlsVerificationTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTlsVerificationTest.java new file mode 100644 index 00000000..46f59146 --- /dev/null +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTlsVerificationTest.java @@ -0,0 +1,145 @@ +package net.openan.a2at.sdk.llm.providers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.junit.jupiter.api.Test; + +/** + * Behavioral TLS tests against a local HTTPS server that presents a self-signed certificate for {@code localhost} + * while the client connects to {@code 127.0.0.1}, so the default path fails on both trust and hostname checks. + */ +class OpenAIClientTlsVerificationTest { + + private static final String CHAT_COMPLETION_BODY = + "{\"id\":\"chatcmpl_test\",\"object\":\"chat.completion\",\"created\":1," + + "\"model\":\"gpt-4o-mini\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\"," + + "\"content\":\"{\\\"device_type\\\":\\\"router\\\"}\"},\"finish_reason\":\"stop\"}]," + + "\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":2,\"total_tokens\":9}}"; + + @Test + void skipsPeerCertificateVerificationWhenDisabled() throws Exception { + try (SelfSignedHttpsServer server = SelfSignedHttpsServer.start()) { + OpenAIClient client = new OpenAIClient(config(server, false)); + + LLMResponse response = client.structured( + List.of(Map.of("role", "user", "content", "extract")), Map.of("type", "object"), null, null); + + assertEquals("{\"device_type\":\"router\"}", response.content()); + assertEquals(1, server.requestCount()); + } + } + + @Test + void verifiesPeerCertificateByDefault() throws Exception { + try (SelfSignedHttpsServer server = SelfSignedHttpsServer.start()) { + OpenAIClient client = new OpenAIClient(config(server, true)); + + LLMRuntimeError error = assertThrows( + LLMRuntimeError.class, + () -> client.structured( + List.of(Map.of("role", "user", "content", "extract")), Map.of("type", "object"), null, null)); + + assertTrue( + hasSslFailureInCauseChain(error), + "expected an SSLException in the cause chain but got: " + error.getCause()); + assertEquals(0, server.requestCount()); + } + } + + private static LLMClientConfig config(SelfSignedHttpsServer server, boolean sslVerify) { + return new LLMClientConfig( + "openai", + "gpt-4o-mini", + "sk-test", + server.baseUrl() + "/v1", + 10, + null, + null, + 5.0d, + 300, + 100, + false, + sslVerify, + null); + } + + private static boolean hasSslFailureInCauseChain(Throwable error) { + for (Throwable current = error; current != null; current = current.getCause()) { + if (current instanceof SSLException) { + return true; + } + } + return false; + } + + private static final class SelfSignedHttpsServer implements AutoCloseable { + + private final HttpsServer server; + private final AtomicInteger requests = new AtomicInteger(); + + private SelfSignedHttpsServer(HttpsServer server) { + this.server = server; + } + + static SelfSignedHttpsServer start() throws Exception { + HttpsServer server = HttpsServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.setHttpsConfigurator(new HttpsConfigurator(serverSslContext())); + SelfSignedHttpsServer wrapper = new SelfSignedHttpsServer(server); + server.createContext("/", exchange -> { + exchange.getRequestBody().readAllBytes(); + wrapper.requests.incrementAndGet(); + byte[] body = CHAT_COMPLETION_BODY.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (var out = exchange.getResponseBody()) { + out.write(body); + } + }); + server.start(); + return wrapper; + } + + String baseUrl() { + return "https://127.0.0.1:" + server.getAddress().getPort(); + } + + int requestCount() { + return requests.get(); + } + + @Override + public void close() { + server.stop(0); + } + + private static SSLContext serverSslContext() throws Exception { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (InputStream input = + OpenAIClientTlsVerificationTest.class.getResourceAsStream("/self-signed-llm-test-keystore.p12")) { + keyStore.load(input, "changeit".toCharArray()); + } + KeyManagerFactory factory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + factory.init(keyStore, "changeit".toCharArray()); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(factory.getKeyManagers(), null, null); + return context; + } + } +} diff --git a/a2a-t-llm/src/test/resources/self-signed-llm-test-keystore.p12 b/a2a-t-llm/src/test/resources/self-signed-llm-test-keystore.p12 new file mode 100644 index 0000000000000000000000000000000000000000..9bf4c468153b41e9bab2abcd93036017b083aef8 GIT binary patch literal 2684 zcma)8c{me}8{cemEesopLT*#$9@a6$YE8nXS~;c(<;X1~=Ui!&4msxNJ4q!sIU+}? zRF-3^xkf^6qL{vZ&+qs2eSUxae$V^7&-=We&-;1*dq2;MiWLnL0D@4lqI(5JwCOnd znix(-D4UQF+*oL8~SV+ZxQIH~VEQGlYXK&{nLFj)}J4As3`LU40+aLzz@kap> zL!CyU|7mxikYIRD248_{d#`5smLE$Y;@~q?Q?jjqfCfwesD#=fDE#k@0wNFqYPX<> zD;)=grr<^#!KqK1)CGtlr$n z@M+QIt9ajo?N3PaW*C1HM<@O-qq!h=J-MHyuEY(5YLP~e>Ah5y3wb9T`?j8%I3v>V zkGZw2xOP~W^qwag8D8fFv4v>qcgvNl8||uj>WbmX@BX z&Kqqv!hlXKl(1SRB+_?K9Nmp}raylY0^_~Ed_g5K%0#bb-=lMCDqRs|3t#M3YUoR? zTy>M%^qXUyV=HKP!X&K;U@kA@woxG7yfkeKFJvP|NA0m`s4ZFi&-#&q{GpxRh+WIyn=Tm4EUuRxj zq~%+cz85Af(O4SzC?jNkrQyE7Q-%*T>n(orMaI63S4*AH-j&#`2wRe*HGr(eyw}m| zn5riBkRrvH$?dL7R{JUUcn*97)*lG5<}Zu1*iKabq~GP3!IL`?lv`x$fp_r4?#Ofg z<2BCO(f4~LdOu!2G6=Jp1#pM52fd#+meGalvKpGPE`nl+%JFuCbcLNa#ns3B)bG^S zwRfbydt|*D~mU-91>GCG11l!DmFGxZ|vuBvmkzUKRS{qHaExU?paczN8SR` zHzH1$L(NqWG%NRwA~A9m@BG~My6h!@&y2($2y?)fmwFwJCs@nkKQ7zWq^Wa@U7;0O zgZ;TzDq)H1sg;#+R7_k_u8I$bl0MHmy&jy`I=iS7(u%btYN1;VRFf0rj@MmF{VC13R?ogL?k}O6 z5>Q&AF@Vz2X4i(jgeLAsCPjOCdG5235_UvUVBIFKH`l16a3%{Ex+Sksa%E#P0Bvw5 z!I36gc50vxtR$3`j;l6YJ@*N!^g*Kf^YeJ)_);dXcu}&r}X3`(A#TeU||2kv~X3fHFBP8fkBHQzSyI%s0jFmsg@`|JbDnTu*8b3~*^Xd;t3ZK>#Ws5DHk5w1?SY&&GWhT8(OPsAlj(&`e1+@@MlfErHX8aV-lhMe0z&~D5Pr5Xb(PpQ) zry-^FE|l@``&vC!0AOOH2FhQ^>HB(DRaNzH)sb@*gRU0Dpv8dRl(HJb9?42!T_o$S znj|!*mw0j-fX*9_j=r89>D0yv3@>en#y-q^$k^g#?y8cOnzrSFJ_QcTo>MEZMJ`nc z*6rrGIcmuoQwc*fSRZpzK1Vs-5=)`Gsr zR*3Ct_B$0r>e$g9<9$#!|uS?#L9%d~p z(oEl?(SG*&1|=%LYL3VcrS_K1zIe0p6GR3@LoJP7?g`Z{=o=c6sqv8)oCsE2_?m}5 zch(-|SGFZILxPI6~JhU&kb0)mh0!Eq1XQYwqCdc(XO7DY!{k)2uWr+O|nLG$CKy7_0L(<9B zvR~DWk$m8<21)khWfpAr5Haa5j$eBTMM(0v-{zZ$!kV0dp?6>6?SdO#S(%*+^ICXe zD#l!4=D``DFCl7|FHckNmb7<|Y@fDwZ`SAgh%?3x&(i{0&AzTxuS)wY5mCR$%~s4T zLZ%RG0%ToWUc)cE;Uk|0w{M9g8=ylA;>p7O9$K!4*)7zyE^fwV$c*C|5%Fm&TH`|+ zBEq^m3G0RDO)xGU8<#gV=!_AhVOu0sG@fWUSVvAYg;jeJRLh@*ho?gtVm>MDY)GIl zE^E{P{k;Z+eLSvPoan3Q&@>eN)QD6fsY@M0*ZLo0=|`SAGQMfaNzv1PvpKrFo(Pbc zct7{{UZp(S5L2@4+u|581 zt@ZKv*t5>rIhHlK=VP=C6TPDkxcRSgX{m>V@Atx~ZZGw;^|Pi@e<=BKXZ403nV|?M zJ(Sp=k4yjv0f3b{ySkj!b-9)3s3r%aThjqk`fNgBr=3h1v*Ttp$N&I_>&l4Yoy->l T{qP|;SB~HEwdOgke_6=iLLJI{ literal 0 HcmV?d00001 diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java index a64804f7..c880093a 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java @@ -97,6 +97,7 @@ void llmClientConfigDerivedFromConfigLlmNotFromEnvPath() throws IOException { 300, 100, false, + true, null, 3, List.of()), diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index d3ab2512..446a052f 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -1678,10 +1678,10 @@ The sample configuration file provided by the SDK is `env.example`. When constru | `A2AT_LLM_HISTORY_WINDOW` | Number of session history messages to keep, default `10` | | `A2AT_LLM_REASONING_EFFORT` | Reasoning effort level; one of `none`/`minimal`/`low`/`medium`/`high`/`xhigh`, not set when left empty | | `A2AT_LLM_DISABLE_SYSTEM_PROXY` | Whether to bypass the system HTTP proxy when accessing the LLM, default `false` | +| `A2AT_LLM_SSL_VERIFY` | Whether to verify the LLM endpoint TLS certificate chain and hostname; `false` disables both — prefer importing a trusted CA and use `false` only short-term in controlled environments, default `true` | | `A2AT_LLM_SESSION_MAX_TOTAL` | Maximum total number of tracked sessions, default `300` | | `A2AT_LLM_SESSION_MAX_PER_PROVIDER` | Maximum number of tracked sessions per provider, default `100` | | `A2AT_LLM_MAX_ATTEMPTS` | Maximum number of attempts for retryable LLM steps; range 1-10 (out-of-range values are clamped), default `3` | | `A2AT_NEGOTIATION_STATE_STORE_TYPE` | Negotiation state storage; currently supports `in_memory` | - diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 7a0de3f6..eb77b8cd 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -1671,6 +1671,7 @@ SDK提供的样例配置文件为`env.example`,A2A-T Client和A2A-T Server构 | `A2AT_LLM_HISTORY_WINDOW` | 保留的会话历史消息条数,默认 `10` | | `A2AT_LLM_REASONING_EFFORT` | 推理力度,取值 `none`/`minimal`/`low`/`medium`/`high`/`xhigh`,留空时不设置 | | `A2AT_LLM_DISABLE_SYSTEM_PROXY` | 是否绕过系统 HTTP 代理访问 LLM,默认 `false` | +| `A2AT_LLM_SSL_VERIFY` | 是否校验 LLM 接入点的 TLS 证书链与主机名,置 `false` 同时关闭两者;优先导入受信任 CA,仅受控环境短期使用,默认 `true` | | `A2AT_LLM_SESSION_MAX_TOTAL` | 会话跟踪总数上限,默认 `300` | | `A2AT_LLM_SESSION_MAX_PER_PROVIDER` | 单提供方会话跟踪数上限,默认 `100` | | `A2AT_LLM_MAX_ATTEMPTS` | 可重试 LLM 步骤的最大尝试次数,取值范围 1-10(越界自动裁剪),默认 `3` | diff --git a/env.example b/env.example index 1caa7d9f..cb564505 100644 --- a/env.example +++ b/env.example @@ -40,6 +40,10 @@ A2AT_LLM_HISTORY_WINDOW=10 A2AT_LLM_REASONING_EFFORT= # Whether to bypass the system HTTP proxy for LLM calls. A2AT_LLM_DISABLE_SYSTEM_PROXY=false +# Whether to verify the TLS certificate chain and hostname of the LLM endpoint. Set to false +# only short-term in controlled environments when the HTTPS LLM gateway presents a certificate +# that is not trusted; prefer importing a trusted CA instead. +A2AT_LLM_SSL_VERIFY=true # Maximum total number of tracked sessions. A2AT_LLM_SESSION_MAX_TOTAL=300 # Maximum number of tracked sessions per provider. From 3989d1ffa415600470b6805c801490b77fac07ce Mon Sep 17 00:00:00 2001 From: w00609601 Date: Thu, 10 Sep 2026 19:22:17 +0800 Subject: [PATCH 266/295] feat(prompt): fall back to built-in classpath resources for missing local_file business content (overlay semantics per ADR 0005) Signed-off-by: w00609601 --- .../DefaultA2ATClientBuilderTest.java | 9 +- .../catalog/PromptTemplateCatalog.java | 28 +- .../loader/BuiltinFallbackWarnings.java | 31 + .../LocalFilePromptScenarioCatalogLoader.java | 128 +-- .../LocalFilePromptSlotSchemaLoader.java | 148 ++-- .../loader/LocalFilePromptTemplateLoader.java | 125 ++- .../loader/LocalFileResourceSnapshot.java | 34 + .../loader/PromptResourceAccess.java | 25 +- .../catalog/TemplateQueryServiceTest.java | 46 +- .../loader/LocalFilePromptLoadersTest.java | 814 +++++++++++------- .../loader/PromptResourceAccessTest.java | 530 +++++++----- docs/en/custom_template_guide.md | 398 +++++++++ docs/en/developer_guide.md | 10 +- ...00\345\217\221\346\214\207\345\215\227.md" | 9 +- ...41\346\235\277\347\244\272\344\276\213.md" | 398 +++++++++ env.example | 8 +- 16 files changed, 1923 insertions(+), 818 deletions(-) create mode 100644 a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/BuiltinFallbackWarnings.java create mode 100644 docs/en/custom_template_guide.md create mode 100644 "docs/zh/\350\207\252\345\256\232\344\271\211\346\250\241\346\235\277\347\244\272\344\276\213.md" diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java index f405f0fe..36e2653f 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java @@ -1,7 +1,7 @@ package net.openan.a2at.sdk.client.prompt.assembly; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertNotNull; import java.io.IOException; import java.nio.file.Files; @@ -9,7 +9,6 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; @@ -55,7 +54,7 @@ void should_createLlmClientOnlyOnce_When_multipleBuildMethodsCalled() throws IOE } @Test - void buildPromptGenerationOrchestratorPropagatesMissingScenarioCatalog() throws IOException { + void buildPromptGenerationOrchestratorFallsBackToBuiltinScenarioCatalog() throws IOException { String provider = "test-scenario-failure"; if (!LLMClientFactory.availableProviders().contains(provider)) { LLMClientFactory.register(provider, CountingClient.class); @@ -75,7 +74,9 @@ void buildPromptGenerationOrchestratorPropagatesMissingScenarioCatalog() throws DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder().config(config).envPath(envFile); - assertThrows(ResourceNotFoundException.class, () -> builder.buildPromptGenerationOrchestrator()); + assertNotNull( + builder.buildPromptGenerationOrchestrator(), + "a missing local scenarios.json must fall back to the built-in scenario catalog instead of failing"); } private static Path createTempEnvFileWithoutScenarioCatalog(String provider) throws IOException { diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java index 25c87910..c4be29a6 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/PromptTemplateCatalog.java @@ -40,9 +40,10 @@ * {@code Negotiation-T/information-negotiation/propose/v1} or {@code Task-T/network-layer/ran-energy-saving/v1}. * *

            The catalog builds the effective set at assembly time according to the configured {@code sourceType}: the - * {@code classpath} mode enumerates the full built-in template tree; the {@code local_file} mode enumerates only the - * local business content (Task-T, Notification-T and Authorization-T) plus the classpath-fixed Negotiation-T templates, - * without unioning the built-in business content. Every template record carries its effective origin ({@code classpath} + * {@code classpath} mode enumerates the full built-in template tree; the {@code local_file} mode enumerates the local + * business content (Task-T, Notification-T and Authorization-T) union the built-in business content (Task-T, + * Notification-T and Authorization-T) plus the classpath-fixed Negotiation-T templates, with the local copy winning when + * the same path exists both locally and built-in. Every template record carries its effective origin ({@code classpath} * or {@code local}). Negotiation-T templates are filtered to the closed set of seven shapes; a Negotiation-T template * outside the closed set is ignored with a warning. Both query methods never throw: a template or a root that cannot be * loaded is skipped or answered with an empty result and a warning log. @@ -116,7 +117,8 @@ final class PromptTemplateCatalog { + "' but is not set; configure " + A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR + " to the local prompt resource root."); } - this.localRootDir = localRootDir == null || localRootDir.isBlank() ? null : Path.of(localRootDir); + this.localRootDir = + PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(this.sourceType) ? Path.of(localRootDir) : null; if (this.localRootDir != null && !Files.isDirectory(this.localRootDir)) { throw new IllegalArgumentException("Prompt resource local root directory configured via " + A2ATConfigKeys.PromptRuntime.LOCAL_ROOT_DIR @@ -143,8 +145,9 @@ private Map captureClasspathTemplates() { * Lists every loadable template of the configured language across all extensions of the effective set. * *

            The effective set depends on the configured {@code sourceType}: {@code classpath} lists the full built-in - * tree, {@code local_file} lists the local business content plus the classpath-fixed Negotiation-T templates. - * Negotiation-T templates outside the closed set are ignored with a warning. The result is sorted by template URI. + * tree, {@code local_file} lists the local business content union the built-in business content plus the + * classpath-fixed Negotiation-T templates, with the local copy winning on a path collision. Negotiation-T + * templates outside the closed set are ignored with a warning. The result is sorted by template URI. * * @return loadable templates of the configured language sorted by URI; empty when none can be loaded */ @@ -188,7 +191,8 @@ private Map captureClasspathTemplates() { * *

            A Negotiation-T template outside the closed set is answered with an empty result and a warning. Business * content follows the configured {@code sourceType}: {@code local_file} reads the addressed template from the local - * root only (no classpath fallback), {@code classpath} reads it from the classpath. + * root first and falls back to the classpath when it is missing locally, {@code classpath} reads it from the + * classpath. * * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/propose/v1} or * {@code Task-T/network-layer/ran-energy-saving/v1} @@ -209,8 +213,13 @@ public Optional load(@NonNull TemplateUri templateUri) { source = PromptTemplate.SOURCE_CLASSPATH; content = classpathSnapshot.get(classpathPath); } else { - source = PromptTemplate.SOURCE_LOCAL; content = localSnapshot.get(classpathPath); + if (content != null) { + source = PromptTemplate.SOURCE_LOCAL; + } else { + content = classpathSnapshot.get(classpathPath); + source = PromptTemplate.SOURCE_CLASSPATH; + } } if (content == null) { return Optional.empty(); @@ -220,9 +229,6 @@ public Optional load(@NonNull TemplateUri templateUri) { private void collectClasspathEntries(Map entriesByPath) { for (Map.Entry entry : classpathSnapshot.entrySet()) { - if (PromptRuntimeConfig.SOURCE_TYPE_LOCAL_FILE.equals(sourceType) && !isNegotiationPath(entry.getKey())) { - continue; - } entriesByPath.put(entry.getKey(), new TemplateSource(entry.getValue(), PromptTemplate.SOURCE_CLASSPATH)); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/BuiltinFallbackWarnings.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/BuiltinFallbackWarnings.java new file mode 100644 index 00000000..9ff53f3d --- /dev/null +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/BuiltinFallbackWarnings.java @@ -0,0 +1,31 @@ +package net.openan.a2at.sdk.prompt.resources.loader; + +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Emits the one-time warning for a built-in classpath fallback of a local-file business resource. + * + *

            The warning is deduplicated per resource path across the whole local-file access: the same resource path warns at + * most once, regardless of whether the first fallback happens at assembly time or at runtime. The deduplication set is + * shared by the loaders of one {@link PromptResourceAccess.LocalFileAccess} and is thread-safe. + */ +final class BuiltinFallbackWarnings { + + private static final Logger LOGGER = LoggerFactory.getLogger(BuiltinFallbackWarnings.class); + + private BuiltinFallbackWarnings() {} + + /** + * Warns once for a built-in fallback of the given resource path. + * + * @param warnedPaths thread-safe set of resource paths already warned about + * @param resourcePath path of the resource that fell back to the built-in classpath copy + */ + static void warnOnce(Set warnedPaths, String resourcePath) { + if (warnedPaths.add(resourcePath)) { + LOGGER.atWarn().log("prompt_resource_builtin_fallback path={} source=classpath", resourcePath); + } + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java index e30e7d96..4131609a 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java @@ -1,62 +1,66 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.JsonProcessingException; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; - -/** - * Loads shared scenario catalogs from one local prompt resource root. - * - *

            Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch the - * filesystem, so changes to the local files only take effect after the SDK is restarted. - * - * @since 2026-06 - */ -public final class LocalFilePromptScenarioCatalogLoader { - - private final Map snapshot; - private final Path promptRootDir; - - public LocalFilePromptScenarioCatalogLoader(Map snapshot, Path promptRootDir) { - this.snapshot = snapshot; - this.promptRootDir = promptRootDir; - } - - public List load(String language) { - PathSegments.requireSimpleSegment(language, "Prompt scenario catalog language"); - String pathKey = "scenarios/" + language + "/scenarios.json"; - if (!snapshot.containsKey(pathKey)) { - throw new ResourceNotFoundException( - "Prompt resource file does not exist.", - promptRootDir - .resolve("scenarios") - .resolve(language) - .resolve("scenarios.json") - .toString()); - } - try { - return PromptResourceJsonParser.parse(snapshot.get(pathKey), ScenarioCatalog.class) - .scenarios(); - } catch (JsonProcessingException exception) { - throw ResourceReadErrors.readFailed( - promptRootDir - .resolve("scenarios") - .resolve(language) - .resolve("scenarios.json") - .toString(), - language, - exception); - } - } - - private record ScenarioCatalog(@JsonProperty("scenarios") List scenarios) { - ScenarioCatalog { - scenarios = scenarios == null ? List.of() : List.copyOf(scenarios); - } - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.openan.a2at.sdk.core.resources.PathSegments; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; + +/** + * Loads shared scenario catalogs from one local prompt resource root. + * + *

            Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch the + * filesystem, so changes to the local files only take effect after the SDK is restarted. A local {@code scenarios.json} + * is no longer required: when it is missing, the loader falls back to the packaged classpath scenario catalog and + * reports the first fallback for the resource path with a one-time warning. + * + * @since 2026-06 + */ +public final class LocalFilePromptScenarioCatalogLoader { + + private final Map snapshot; + private final Path promptRootDir; + private final ClasspathPromptScenarioCatalogLoader classpathLoader; + private final Set warnedFallbackPaths; + + public LocalFilePromptScenarioCatalogLoader( + Map snapshot, + ClasspathPromptScenarioCatalogLoader classpathLoader, + Path promptRootDir, + Set warnedFallbackPaths) { + this.snapshot = snapshot; + this.classpathLoader = classpathLoader; + this.promptRootDir = promptRootDir; + this.warnedFallbackPaths = warnedFallbackPaths; + } + + public List load(String language) { + PathSegments.requireSimpleSegment(language, "Prompt scenario catalog language"); + String pathKey = "scenarios/" + language + "/scenarios.json"; + if (snapshot.containsKey(pathKey)) { + return parse(snapshot.get(pathKey), pathKey, language); + } + BuiltinFallbackWarnings.warnOnce( + warnedFallbackPaths, "prompt_resources/scenarios/" + language + "/scenarios.json"); + return classpathLoader.load(language); + } + + private List parse(String payload, String pathKey, String language) { + try { + return PromptResourceJsonParser.parse(payload, ScenarioCatalog.class) + .scenarios(); + } catch (JsonProcessingException exception) { + throw ResourceReadErrors.readFailed( + promptRootDir.resolve(pathKey).toString(), language, exception); + } + } + + private record ScenarioCatalog(@JsonProperty("scenarios") List scenarios) { + ScenarioCatalog { + scenarios = scenarios == null ? List.of() : List.copyOf(scenarios); + } + } +} \ No newline at end of file diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java index fe8abb6c..ae4d7ff5 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java @@ -1,84 +1,64 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import com.fasterxml.jackson.core.JsonProcessingException; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.resources.PathSegments; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; - -/** - * Loads shared slot schemas from one local prompt resource root. - * - *

            Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch the - * filesystem, so changes to the local files only take effect after the SDK is restarted. - * - * @since 2026-06 - */ -public final class LocalFilePromptSlotSchemaLoader implements PromptSlotSchemaLoader { - - private final Map snapshot; - private final Path promptRootDir; - private final List slotTypes; - - public LocalFilePromptSlotSchemaLoader(Map snapshot, Path promptRootDir) { - this.snapshot = snapshot; - this.promptRootDir = promptRootDir; - this.slotTypes = LocalFileResourceSnapshot.typeDirectories(snapshot, "slots"); - } - - @Override - public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { - PathSegments.requireSimpleRelativePath(scenarioCode, "Prompt slot schema scenario code"); - PathSegments.requireSimpleSegment(language, "Prompt slot schema language"); - String pathKey; - if (scenarioCode.contains("/")) { - pathKey = "slots/" + scenarioCode + "/" + language + "/slot.json"; - } else { - pathKey = null; - for (String slotType : slotTypes) { - String candidate = resolveBareCode(slotType, scenarioCode, language); - if (snapshot.containsKey(candidate)) { - pathKey = candidate; - break; - } - } - } - if (pathKey == null || !snapshot.containsKey(pathKey)) { - throw notFound(scenarioCode, language); - } - try { - return PromptResourceJsonParser.parse(snapshot.get(pathKey), PromptSlotJsonSchema.class) - .toPromptSlotSchema(scenarioCode); - } catch (JsonProcessingException exception) { - throw ResourceReadErrors.readFailed(promptRootDir.resolve(pathKey).toString(), language, exception); - } - } - - /** - * Resolves a bare scenario code under one slot type directory, preferring the {@code network-layer} domain layout - * over the plain layout. - */ - private String resolveBareCode(String slotType, String scenarioCode, String language) { - String networkLayer = "slots/" + slotType + "/network-layer/" + scenarioCode + "/v1/" + language + "/slot.json"; - if (snapshot.containsKey(networkLayer)) { - return networkLayer; - } - return "slots/" + slotType + "/" + scenarioCode + "/v1/" + language + "/slot.json"; - } - - private ResourceNotFoundException notFound(String scenarioCode, String language) { - String pathHint = scenarioCode.contains("/") - ? promptRootDir - .resolve("slots") - .resolve(scenarioCode) - .resolve(language) - .resolve("slot.json") - .toString() - : promptRootDir.resolve("slots") + "/*/network-layer/" + scenarioCode + "/v1/" + language - + "/slot.json (or the layout without the network-layer segment)"; - return new ResourceNotFoundException("Prompt resource file does not exist.", pathHint); - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import com.fasterxml.jackson.core.JsonProcessingException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.openan.a2at.sdk.core.resources.PathSegments; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; + +/** + * Loads shared slot schemas from one local prompt resource root. + * + *

            Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch the + * filesystem, so changes to the local files only take effect after the SDK is restarted. A schema that is missing from + * the local snapshot falls back to the packaged classpath copy, so customers only need to place the schemas they + * override under the local root; the first fallback for each resource path is reported with a one-time warning. + * + * @since 2026-06 + */ +public final class LocalFilePromptSlotSchemaLoader implements PromptSlotSchemaLoader { + + private final Map snapshot; + private final Path promptRootDir; + private final ClasspathPromptSlotSchemaLoader classpathLoader; + private final Set warnedFallbackPaths; + private final List slotTypes; + + public LocalFilePromptSlotSchemaLoader( + Map snapshot, + ClasspathPromptSlotSchemaLoader classpathLoader, + Path promptRootDir, + Set warnedFallbackPaths) { + this.snapshot = snapshot; + this.classpathLoader = classpathLoader; + this.promptRootDir = promptRootDir; + this.warnedFallbackPaths = warnedFallbackPaths; + this.slotTypes = LocalFileResourceSnapshot.typeDirectories(snapshot, "slots"); + } + + @Override + public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { + PathSegments.requireSimpleRelativePath(scenarioCode, "Prompt slot schema scenario code"); + PathSegments.requireSimpleSegment(language, "Prompt slot schema language"); + String pathKey = LocalFileResourceSnapshot.resolveResourcePath( + snapshot, "slots", slotTypes, scenarioCode, language, "slot.json"); + if (pathKey != null && snapshot.containsKey(pathKey)) { + return parse(snapshot.get(pathKey), scenarioCode, promptRootDir.resolve(pathKey).toString(), language); + } + BuiltinFallbackWarnings.warnOnce( + warnedFallbackPaths, "prompt_resources/slots/" + scenarioCode + "/" + language + "/slot.json"); + return classpathLoader.loadSlotSchema(scenarioCode, language); + } + + private static PromptSlotSchema parse(String payload, String scenarioCode, String resourcePath, String language) { + try { + return PromptResourceJsonParser.parse(payload, PromptSlotJsonSchema.class) + .toPromptSlotSchema(scenarioCode); + } catch (JsonProcessingException exception) { + throw ResourceReadErrors.readFailed(resourcePath, language, exception); + } + } +} \ No newline at end of file diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java index 756c95ff..b7c95c7b 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java @@ -1,77 +1,48 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.resources.PathSegments; - -/** - * Loads shared prompt templates from one local prompt resource root. - * - *

            Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch the - * filesystem, so changes to the local files only take effect after the SDK is restarted. - * - * @since 2026-06 - */ -public final class LocalFilePromptTemplateLoader implements PromptTemplateTextLoader { - - private final Map snapshot; - private final Path promptRootDir; - private final List templateTypes; - - public LocalFilePromptTemplateLoader(Map snapshot, Path promptRootDir) { - this.snapshot = snapshot; - this.promptRootDir = promptRootDir; - this.templateTypes = LocalFileResourceSnapshot.typeDirectories(snapshot, "templates"); - } - - @Override - public String loadTemplate(String scenarioCode, String language) { - PathSegments.requireSimpleRelativePath(scenarioCode, "Prompt template scenario code"); - PathSegments.requireSimpleSegment(language, "Prompt template language"); - String pathKey; - if (scenarioCode.contains("/")) { - pathKey = "templates/" + scenarioCode + "/" + language + "/template.md"; - } else { - pathKey = null; - for (String templateType : templateTypes) { - String candidate = resolveBareCode(templateType, scenarioCode, language); - if (snapshot.containsKey(candidate)) { - pathKey = candidate; - break; - } - } - } - if (pathKey == null || !snapshot.containsKey(pathKey)) { - throw notFound(scenarioCode, language); - } - return snapshot.get(pathKey); - } - - /** - * Resolves a bare scenario code under one template type directory, preferring the {@code network-layer} domain - * layout over the plain layout. - */ - private String resolveBareCode(String templateType, String scenarioCode, String language) { - String networkLayer = - "templates/" + templateType + "/network-layer/" + scenarioCode + "/v1/" + language + "/template.md"; - if (snapshot.containsKey(networkLayer)) { - return networkLayer; - } - return "templates/" + templateType + "/" + scenarioCode + "/v1/" + language + "/template.md"; - } - - private ResourceNotFoundException notFound(String scenarioCode, String language) { - String pathHint = scenarioCode.contains("/") - ? promptRootDir - .resolve("templates") - .resolve(scenarioCode) - .resolve(language) - .resolve("template.md") - .toString() - : promptRootDir.resolve("templates") + "/*/network-layer/" + scenarioCode + "/v1/" + language - + "/template.md (or the layout without the network-layer segment)"; - return new ResourceNotFoundException("Prompt resource file does not exist.", pathHint); - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.openan.a2at.sdk.core.resources.PathSegments; + +/** + * Loads shared prompt templates from one local prompt resource root. + * + *

            Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch the + * filesystem, so changes to the local files only take effect after the SDK is restarted. A template that is missing from + * the local snapshot falls back to the packaged classpath copy, so customers only need to place the templates they + * override under the local root; the first fallback for each resource path is reported with a one-time warning. + * + * @since 2026-06 + */ +public final class LocalFilePromptTemplateLoader implements PromptTemplateTextLoader { + + private final Map snapshot; + private final ClasspathPromptTemplateLoader classpathLoader; + private final Set warnedFallbackPaths; + private final List templateTypes; + + public LocalFilePromptTemplateLoader( + Map snapshot, + ClasspathPromptTemplateLoader classpathLoader, + Set warnedFallbackPaths) { + this.snapshot = snapshot; + this.classpathLoader = classpathLoader; + this.warnedFallbackPaths = warnedFallbackPaths; + this.templateTypes = LocalFileResourceSnapshot.typeDirectories(snapshot, "templates"); + } + + @Override + public String loadTemplate(String scenarioCode, String language) { + PathSegments.requireSimpleRelativePath(scenarioCode, "Prompt template scenario code"); + PathSegments.requireSimpleSegment(language, "Prompt template language"); + String pathKey = LocalFileResourceSnapshot.resolveResourcePath( + snapshot, "templates", templateTypes, scenarioCode, language, "template.md"); + if (pathKey != null && snapshot.containsKey(pathKey)) { + return snapshot.get(pathKey); + } + BuiltinFallbackWarnings.warnOnce( + warnedFallbackPaths, "prompt_resources/templates/" + scenarioCode + "/" + language + "/template.md"); + return classpathLoader.loadTemplate(scenarioCode, language); + } +} \ No newline at end of file diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java index 8c09b49a..27be5525 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java @@ -63,6 +63,40 @@ static List typeDirectories(Map snapshot, String categor return List.copyOf(types); } + /** + * Resolves the snapshot key a scenario code addresses under one category, without reading the filesystem. + * + *

            A path-form code that contains a slash is resolved as-is under {@code category///}. A + * bare code is searched across the type directories, preferring the {@code network-layer} domain layout over the + * plain layout for each type. Returns {@code null} when a bare code matches no type directory. + */ + static String resolveResourcePath( + Map snapshot, + String category, + List typeDirectories, + String scenarioCode, + String language, + String fileName) { + if (scenarioCode.contains(SEPARATOR)) { + return category + SEPARATOR + scenarioCode + SEPARATOR + language + SEPARATOR + fileName; + } + for (String type : typeDirectories) { + String networkLayer = category + + SEPARATOR + type + SEPARATOR + "network-layer" + SEPARATOR + scenarioCode + SEPARATOR + "v1" + + SEPARATOR + language + SEPARATOR + fileName; + if (snapshot.containsKey(networkLayer)) { + return networkLayer; + } + String plain = category + + SEPARATOR + type + SEPARATOR + scenarioCode + SEPARATOR + "v1" + SEPARATOR + language + + SEPARATOR + fileName; + if (snapshot.containsKey(plain)) { + return plain; + } + } + return null; + } + private static void captureTree(Path root, String category, Map content) { if (!Files.isDirectory(root)) { return; diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java index 0465119c..efb5cc20 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccess.java @@ -5,6 +5,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import net.openan.a2at.sdk.core.model.A2ATConfigKeys; import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; @@ -100,15 +102,29 @@ public String loadPrompt(String promptCategory, String language, String fileName } } + /** + * Local-file backing store for one prompt resource root. + * + *

            The {@code warnedFallbackPaths} set is backed by a {@link ConcurrentHashMap} and is shared by the template, + * slot schema and scenario catalog loaders of this access, so each resource path is warned about its built-in + * fallback at most once even under concurrent access. + */ final class LocalFileAccess implements PromptResourceAccess { private final Path promptRootDir; private final ClasspathPromptResourceLoader resourceLoader; private final Map snapshot; + private final Set warnedFallbackPaths = ConcurrentHashMap.newKeySet(); + private final ClasspathPromptTemplateLoader classpathTemplateLoader; + private final ClasspathPromptSlotSchemaLoader classpathSlotSchemaLoader; + private final ClasspathPromptScenarioCatalogLoader classpathScenarioCatalogLoader; private LocalFileAccess(Path promptRootDir, ClasspathPromptResourceLoader resourceLoader) { this.promptRootDir = promptRootDir; this.resourceLoader = resourceLoader; this.snapshot = LocalFileResourceSnapshot.capture(promptRootDir); + this.classpathTemplateLoader = new ClasspathPromptTemplateLoader(resourceLoader); + this.classpathSlotSchemaLoader = new ClasspathPromptSlotSchemaLoader(resourceLoader); + this.classpathScenarioCatalogLoader = new ClasspathPromptScenarioCatalogLoader(resourceLoader); } @Override @@ -128,17 +144,20 @@ public Path localRootDir() { @Override public List loadScenarios(String language) { - return new LocalFilePromptScenarioCatalogLoader(snapshot, promptRootDir).load(language); + return new LocalFilePromptScenarioCatalogLoader( + snapshot, classpathScenarioCatalogLoader, promptRootDir, warnedFallbackPaths) + .load(language); } @Override public PromptTemplateTextLoader templateLoader() { - return new LocalFilePromptTemplateLoader(snapshot, promptRootDir); + return new LocalFilePromptTemplateLoader(snapshot, classpathTemplateLoader, warnedFallbackPaths); } @Override public PromptSlotSchemaLoader slotSchemaLoader() { - return new LocalFilePromptSlotSchemaLoader(snapshot, promptRootDir); + return new LocalFilePromptSlotSchemaLoader( + snapshot, classpathSlotSchemaLoader, promptRootDir, warnedFallbackPaths); } @Override diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java index 390836a4..5af42a1f 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java @@ -13,9 +13,11 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -73,7 +75,18 @@ void classpathSourceTypeListsBuiltInTemplatesWithClasspathSource() { } @Test - void localFileSourceTypeListsLocalBusinessContentUnionClasspathNegotiationOnly() throws IOException { + void classpathSourceTypeIgnoresConfiguredButNonexistentLocalRoot() { + Path missing = localRootDir.resolve("missing"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "classpath", missing.toString()); + + List templates = service.getPrompts(); + assertFalse(templates.isEmpty()); + assertTrue(templates.stream().allMatch(t -> CLASSPATH.equals(t.source()))); + } + + @Test + void localFileSourceTypeListsLocalUnionBuiltinBusinessAndClasspathNegotiation() throws IOException { writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); @@ -82,14 +95,39 @@ void localFileSourceTypeListsLocalBusinessContentUnionClasspathNegotiationOnly() Set uris = templates.stream().map(t -> t.templateUri().uri()).collect(Collectors.toSet()); assertTrue(uris.contains("Task-T/network-layer/custom-planning/v1")); - assertFalse(uris.contains("Task-T/network-layer/ran-energy-saving/v1")); - assertFalse(uris.contains("Notification-T/network-layer/subscribe-incident/v1")); - assertFalse(uris.contains("Authorization-T/authorization-policy-management/v1")); + assertTrue(uris.contains("Task-T/network-layer/ran-energy-saving/v1")); + assertTrue(uris.contains("Notification-T/network-layer/subscribe-incident/v1")); + assertTrue(uris.contains("Authorization-T/authorization-policy-management/v1")); for (String negotiationUri : NEGOTIATION_CLOSED_SET) { assertTrue(uris.contains(negotiationUri)); } } + @Test + void localFileSingleQueryFallsBackToBuiltinWhenMissingLocally() { + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + Optional template = service.getPrompt(StandardTemplates.ENERGY_SAVING); + + assertTrue(template.isPresent()); + assertEquals(CLASSPATH, template.get().source()); + } + + @Test + void localFileSamePathLocalOverridesBuiltinWithSingleLocalEntry() throws IOException { + writeTemplate(localRootDir, "Task-T/network-layer/ran-energy-saving/v1", "\nlocal body"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + List matches = service.getPrompts().stream() + .filter(t -> t.templateUri().uri().equals("Task-T/network-layer/ran-energy-saving/v1")) + .toList(); + + assertEquals(1, matches.size()); + assertEquals(LOCAL, matches.get(0).source()); + assertTrue(matches.get(0).content().contains("local body")); + } + @Test void localFileSourceTypeAnnotatesLocalAndClasspathSources() throws IOException { writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java index 36e08404..7a5ab917 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java @@ -1,327 +1,487 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import net.openan.a2at.sdk.core.exception.A2ATError; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class LocalFilePromptLoadersTest { - - @TempDir - Path promptRootDir; - - @Test - void loadScenarioCatalogMapsJacksonAnnotatedRecords() throws IOException { - write( - promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "incident_triage", - "scenario_name": "Incident Triage", - "description": "Classify and route a production incident.", - "example": "Investigate elevated API latency." - }, - { - "scenario_code": "release_review", - "scenario_name": "Release Review", - "description": "Review a release candidate before rollout.", - "example": "Check the payment service release." - } - ] - } - """); - - List scenarios = - new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en"); - - assertEquals(2, scenarios.size()); - assertEquals("incident_triage", scenarios.get(0).scenarioCode()); - assertEquals("Incident Triage", scenarios.get(0).scenarioName()); - } - - @Test - void loadScenarioCatalogTreatsMissingScenariosArrayAsEmptyCatalog() throws IOException { - write(promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), "{}"); - - assertEquals(List.of(), new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en")); - } - - @Test - void loadTemplateReadsExactLocalMarkdownText() throws IOException { - write( - promptRootDir - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("template.md"), - """ - # Incident Triage - - Severity: {{severity}} - Summary: {{summary}} - """); - - String template = - new LocalFilePromptTemplateLoader(snapshot(), promptRootDir).loadTemplate("incident_triage", "en"); - - assertEquals( - """ - # Incident Triage - - Severity: {{severity}} - Summary: {{summary}} - """, - template); - } - - @Test - void loadSlotSchemaMapsJacksonAnnotatedRecords() throws IOException { - write( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - """ - { - "required": ["severity", "service"], - "properties": { - "service": { - "type": "string", - "pattern": "^[a-z0-9-]+$", - "description": "Affected service name" - }, - "severity": { - "type": "integer", - "minimum": 1, - "maximum": 5, - "enum": ["1", "2", "3", "4", "5"], - "x-a2at-value-constraint": "Severity from 1 to 5" - }, - "note": null - } - } - """); - - PromptSlotSchema schema = - new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir).loadSlotSchema("incident_triage", "en"); - - assertEquals("incident_triage", schema.scenarioCode()); - assertEquals(3, schema.slotDefinitions().size()); - assertEquals("service", schema.slotDefinitions().get(0).name()); - assertEquals(true, schema.slotDefinitions().get(0).required()); - assertEquals("^[a-z0-9-]+$", schema.slotDefinitions().get(0).pattern()); - assertEquals( - List.of("1", "2", "3", "4", "5"), - schema.slotDefinitions().get(1).allowedValues()); - assertEquals("Severity from 1 to 5", schema.slotDefinitions().get(1).valueConstraint()); - assertEquals("note", schema.slotDefinitions().get(2).name()); - } - - @Test - void missingTemplateIncludesResolvedLocalPathInResourceNotFoundException() { - ResourceNotFoundException exception = assertThrows( - ResourceNotFoundException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) - .loadTemplate("incident_triage", "en")); - - String expected = (promptRootDir.resolve("templates").toString() - + "/*/network-layer/incident_triage/v1/en/template.md" - + " (or the layout without the network-layer segment)") - .replace('\\', '/'); - assertEquals(expected, exception.resourcePath().replace('\\', '/')); - } - - @Test - void malformedLocalSlotSchemaIsWrappedAsA2ATError() throws IOException { - write( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - "{ \"required\": [\"severity\"], \"properties\": "); - - A2ATError exception = - assertThrows(A2ATError.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) - .loadSlotSchema("incident_triage", "en")); - - assertEquals("infra.resource_read_failed", exception.getCode()); - assertTrue(exception.getMessage().startsWith("Failed to read resource '")); - } - - @Test - void snapshotFreezesResourcesAgainstRuntimeFileChanges() throws IOException { - write( - promptRootDir - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("template.md"), - "Original template {{service}}."); - write( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - """ - { - "required": ["service"], - "properties": { - "service": {"type": "string", "description": "Affected service name"} - } - } - """); - write( - promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "incident_triage", - "scenario_name": "Incident Triage", - "description": "Classify and route a production incident.", - "example": "Investigate elevated API latency." - } - ] - } - """); - - PromptResourceAccess access = createAccess(); - - Files.writeString( - promptRootDir - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("template.md"), - "Mutated template."); - Files.writeString( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - "{\"properties\":{}}"); - write( - promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "mutated", - "scenario_name": "Mutated", - "description": "Mutated after snapshot.", - "example": "Mutated example." - } - ] - } - """); - - assertEquals("Original template {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); - assertEquals( - "service", - access.slotSchemaLoader() - .loadSlotSchema("incident_triage", "en") - .slotDefinitions() - .get(0) - .name()); - assertEquals("incident_triage", access.loadScenarios("en").get(0).scenarioCode()); - } - - @Test - void loadTemplateRejectsTraversalOrBlankScenarioPathSegments() { - assertThrows(IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) - .loadTemplate("../etc/passwd", "en")); - assertThrows(IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) - .loadTemplate("Task-T//network-layer/x", "en")); - assertThrows(IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), promptRootDir) - .loadTemplate("incident_triage", "en/../admin")); - } - - @Test - void loadSlotSchemaRejectsNonSimpleLanguage() { - assertThrows( - IllegalArgumentException.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) - .loadSlotSchema("incident_triage", "../en")); - assertThrows( - IllegalArgumentException.class, () -> new LocalFilePromptSlotSchemaLoader(snapshot(), promptRootDir) - .loadSlotSchema("Task-T/network-layer/..", "en")); - } - - @Test - void loadScenarioCatalogRejectsNonSimpleLanguage() { - assertThrows( - IllegalArgumentException.class, - () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load("en/../admin")); - assertThrows( - IllegalArgumentException.class, - () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), promptRootDir).load(" ")); - } - - @Test - void localSnapshotKeepsPromptsOnClasspath() throws IOException { - write( - promptRootDir - .resolve("prompts") - .resolve("scenario_recognition") - .resolve("en-US") - .resolve("system.md"), - "Local prompt copy."); - - PromptResourceAccess access = createAccess(); - - assertFalse(access.classpath()); - assertNotEquals("Local prompt copy.", access.loadPrompt("scenario_recognition", "en-US", "system.md")); - } - - private PromptResourceAccess createAccess() { - return PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - } - - private Map snapshot() { - return LocalFileResourceSnapshot.capture(promptRootDir); - } - - private static void write(Path file, String content) throws IOException { - Files.createDirectories(file.getParent()); - Files.writeString(file, content); - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +class LocalFilePromptLoadersTest { + + private final ClasspathPromptResourceLoader resourceLoader = new ClasspathPromptResourceLoader(); + + @TempDir + Path promptRootDir; + + private final ListAppender appender = new ListAppender<>(); + + private final Logger logger = (Logger) LoggerFactory.getLogger(BuiltinFallbackWarnings.class); + + @BeforeEach + void attachAppender() { + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + logger.detachAppender(appender); + appender.stop(); + } + + @Test + void loadScenarioCatalogMapsJacksonAnnotatedRecords() throws IOException { + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "incident_triage", + "scenario_name": "Incident Triage", + "description": "Classify and route a production incident.", + "example": "Investigate elevated API latency." + }, + { + "scenario_code": "release_review", + "scenario_name": "Release Review", + "description": "Review a release candidate before rollout.", + "example": "Check the payment service release." + } + ] + } + """); + + List scenarios = + new LocalFilePromptScenarioCatalogLoader(snapshot(), scenarioLoader(), promptRootDir, warnedPaths()) + .load("en"); + + assertEquals(2, scenarios.size()); + assertEquals("incident_triage", scenarios.get(0).scenarioCode()); + assertEquals("Incident Triage", scenarios.get(0).scenarioName()); + } + + @Test + void loadScenarioCatalogTreatsMissingScenariosArrayAsEmptyCatalog() throws IOException { + write(promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), "{}"); + + assertEquals( + List.of(), + new LocalFilePromptScenarioCatalogLoader(snapshot(), scenarioLoader(), promptRootDir, warnedPaths()) + .load("en")); + } + + @Test + void loadTemplateReadsExactLocalMarkdownText() throws IOException { + write( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + """ + # Incident Triage + + Severity: {{severity}} + Summary: {{summary}} + """); + + String template = + new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths()).loadTemplate("incident_triage", "en"); + + assertEquals( + """ + # Incident Triage + + Severity: {{severity}} + Summary: {{summary}} + """, + template); + } + + @Test + void loadTemplateResolvesPathFormScenarioCodeFromLocalSnapshot() throws IOException { + write( + promptRootDir + .resolve("templates") + .resolve("Task-T/network-layer/ran-energy-saving/v1") + .resolve("en-US") + .resolve("template.md"), + "Local path-form template body."); + + String template = new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths()) + .loadTemplate("Task-T/network-layer/ran-energy-saving/v1", "en-US"); + + assertEquals("Local path-form template body.", template); + assertEquals(0, warningMessages().size(), "a local path-form hit must not fall back to the builtin template"); + } + + @Test + void loadSlotSchemaResolvesPathFormScenarioCodeFromLocalSnapshot() throws IOException { + write( + promptRootDir + .resolve("slots") + .resolve("Task-T/network-layer/ran-energy-saving/v1") + .resolve("en-US") + .resolve("slot.json"), + """ + { + "required": ["service"], + "properties": { + "service": {"type": "string", "description": "Affected service name"} + } + } + """); + + PromptSlotSchema schema = new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths()) + .loadSlotSchema("Task-T/network-layer/ran-energy-saving/v1", "en-US"); + + assertEquals("Task-T/network-layer/ran-energy-saving/v1", schema.scenarioCode()); + assertEquals(1, schema.slotDefinitions().size()); + assertEquals("service", schema.slotDefinitions().get(0).name()); + assertEquals(0, warningMessages().size(), "a local path-form hit must not fall back to the builtin schema"); + } + + @Test + void loadSlotSchemaMapsJacksonAnnotatedRecords() throws IOException { + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + """ + { + "required": ["severity", "service"], + "properties": { + "service": { + "type": "string", + "pattern": "^[a-z0-9-]+$", + "description": "Affected service name" + }, + "severity": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "enum": ["1", "2", "3", "4", "5"], + "x-a2at-value-constraint": "Severity from 1 to 5" + }, + "note": null + } + } + """); + + PromptSlotSchema schema = new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths()) + .loadSlotSchema("incident_triage", "en"); + + assertEquals("incident_triage", schema.scenarioCode()); + assertEquals(3, schema.slotDefinitions().size()); + assertEquals("service", schema.slotDefinitions().get(0).name()); + assertEquals(true, schema.slotDefinitions().get(0).required()); + assertEquals("^[a-z0-9-]+$", schema.slotDefinitions().get(0).pattern()); + assertEquals( + List.of("1", "2", "3", "4", "5"), + schema.slotDefinitions().get(1).allowedValues()); + assertEquals("Severity from 1 to 5", schema.slotDefinitions().get(1).valueConstraint()); + assertEquals("note", schema.slotDefinitions().get(2).name()); + } + + @Test + void loadTemplateFallsBackToBuiltinWhenMissingLocally() { + Set warnedPaths = warnedPaths(); + + String template = + new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths) + .loadTemplate("ran-energy-saving", "en-US"); + + assertEquals(templateLoader().loadTemplate("ran-energy-saving", "en-US"), template); + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertTrueWarning(warnings.get(0), "prompt_resource_builtin_fallback path=prompt_resources/templates/ran-energy-saving/en-US/template.md"); + } + + @Test + void loadSlotSchemaFallsBackToBuiltinWhenMissingLocally() { + Set warnedPaths = warnedPaths(); + + PromptSlotSchema schema = new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths) + .loadSlotSchema("ran-energy-saving", "en-US"); + + assertEquals(slotLoader().loadSlotSchema("ran-energy-saving", "en-US").slotDefinitions(), schema.slotDefinitions()); + assertEquals(1, warningMessages().size()); + } + + @Test + void loadScenarioCatalogFallsBackToBuiltinWhenMissingLocally() { + Set warnedPaths = warnedPaths(); + + List scenarios = + new LocalFilePromptScenarioCatalogLoader(snapshot(), scenarioLoader(), promptRootDir, warnedPaths) + .load("en-US"); + + assertEquals(scenarioLoader().load("en-US").size(), scenarios.size()); + assertEquals(1, warningMessages().size()); + } + + @Test + void builtinFallbackWarnsOnlyOncePerResourcePath() { + Set warnedPaths = warnedPaths(); + LocalFilePromptTemplateLoader loader = new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths); + + loader.loadTemplate("ran-energy-saving", "en-US"); + loader.loadTemplate("ran-energy-saving", "en-US"); + + assertEquals(1, warningMessages().size(), "the same resource path must warn about its builtin fallback only once"); + } + + @Test + void missingTemplateFallsBackToClasspathThenThrowsNotFound() { + ResourceNotFoundException exception = assertThrows( + ResourceNotFoundException.class, + () -> new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths()) + .loadTemplate("incident_triage", "en-US")); + + assertEquals( + "prompt_resources/templates/*/network-layer/incident_triage/v1/en-US/template.md" + + " (or the layout without the network-layer segment)", + exception.resourcePath()); + } + + @Test + void malformedLocalSlotSchemaIsWrappedAsA2ATError() throws IOException { + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + "{ \"required\": [\"severity\"], \"properties\": "); + + A2ATError exception = assertThrows( + A2ATError.class, + () -> new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths()) + .loadSlotSchema("incident_triage", "en")); + + assertEquals("infra.resource_read_failed", exception.getCode()); + assertEquals(0, warningMessages().size(), "a malformed local slot schema must fail-fast without a builtin fallback WARN"); + } + + @Test + void snapshotFreezesResourcesAgainstRuntimeFileChanges() throws IOException { + write( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + "Original template {{service}}."); + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + """ + { + "required": ["service"], + "properties": { + "service": {"type": "string", "description": "Affected service name"} + } + } + """); + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "incident_triage", + "scenario_name": "Incident Triage", + "description": "Classify and route a production incident.", + "example": "Investigate elevated API latency." + } + ] + } + """); + + PromptResourceAccess access = createAccess(); + + Files.writeString( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + "Mutated template."); + Files.writeString( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + "{\"properties\":{}}"); + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "mutated", + "scenario_name": "Mutated", + "description": "Mutated after snapshot.", + "example": "Mutated example." + } + ] + } + """); + + assertEquals("Original template {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); + assertEquals( + "service", + access.slotSchemaLoader() + .loadSlotSchema("incident_triage", "en") + .slotDefinitions() + .get(0) + .name()); + assertEquals("incident_triage", access.loadScenarios("en").get(0).scenarioCode()); + } + + @Test + void loadTemplateRejectsTraversalOrBlankScenarioPathSegments() { + assertThrows( + IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths()) + .loadTemplate("../etc/passwd", "en")); + assertThrows( + IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths()) + .loadTemplate("Task-T//network-layer/x", "en")); + assertThrows( + IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths()) + .loadTemplate("incident_triage", "en/../admin")); + } + + @Test + void loadSlotSchemaRejectsNonSimpleLanguage() { + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths()) + .loadSlotSchema("incident_triage", "../en")); + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths()) + .loadSlotSchema("Task-T/network-layer/..", "en")); + } + + @Test + void loadScenarioCatalogRejectsNonSimpleLanguage() { + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), scenarioLoader(), promptRootDir, warnedPaths()) + .load("en/../admin")); + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), scenarioLoader(), promptRootDir, warnedPaths()) + .load(" ")); + } + + @Test + void localSnapshotKeepsPromptsOnClasspath() throws IOException { + write( + promptRootDir + .resolve("prompts") + .resolve("scenario_recognition") + .resolve("en-US") + .resolve("system.md"), + "Local prompt copy."); + + PromptResourceAccess access = createAccess(); + + assertFalse(access.classpath()); + assertNotEquals("Local prompt copy.", access.loadPrompt("scenario_recognition", "en-US", "system.md")); + } + + private PromptResourceAccess createAccess() { + return PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + } + + private Map snapshot() { + return LocalFileResourceSnapshot.capture(promptRootDir); + } + + private ClasspathPromptTemplateLoader templateLoader() { + return new ClasspathPromptTemplateLoader(resourceLoader); + } + + private ClasspathPromptSlotSchemaLoader slotLoader() { + return new ClasspathPromptSlotSchemaLoader(resourceLoader); + } + + private ClasspathPromptScenarioCatalogLoader scenarioLoader() { + return new ClasspathPromptScenarioCatalogLoader(resourceLoader); + } + + private static Set warnedPaths() { + return ConcurrentHashMap.newKeySet(); + } + + private List warningMessages() { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void assertTrueWarning(String message, String... fragments) { + for (String fragment : fragments) { + org.junit.jupiter.api.Assertions.assertTrue( + message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); + } + } + + private static void write(Path file, String content) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, content); + } +} \ No newline at end of file diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java index b2526c62..2b3737ab 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java @@ -1,235 +1,295 @@ -package net.openan.a2at.sdk.prompt.resources.loader; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.slf4j.LoggerFactory; - -class PromptResourceAccessTest { - - @TempDir - Path promptRootDir; - - private final ListAppender appender = new ListAppender<>(); - - private final Logger logger = (Logger) LoggerFactory.getLogger(PromptResourceAccess.class); - - @BeforeEach - void attachAppender() { - appender.start(); - logger.addAppender(appender); - } - - @AfterEach - void detachAppender() { - logger.detachAppender(appender); - } - - @Test - void classpathModeIgnoresConfiguredLocalRootWithSingleWarning() { - PromptResourceAccess access = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", promptRootDir.toString())); - - assertTrue(access.classpath()); - List warnings = warningMessages(); - assertEquals(1, warnings.size()); - assertContains( - warnings.get(0), "prompt_resource_local_root_ignored", "root=" + promptRootDir, "source=classpath"); - } - - @Test - void localFileModeFailsFastWhenLocalRootIsNull() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", null))); - - assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); - } - - @Test - void localFileModeFailsFastWhenLocalRootIsBlank() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", " "))); - - assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); - } - - @Test - void localFileModeFailsFastWhenLocalRootDoesNotExist() { - Path missing = promptRootDir.resolve("missing"); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", missing.toString()))); - - assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); - } - - @Test - void localFileModeLoadsPromptsFromClasspath() throws IOException { - write( - promptRootDir - .resolve("prompts") - .resolve("scenario_recognition") - .resolve("en-US") - .resolve("system.md"), - "Local prompt copy."); - - PromptResourceAccess access = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - - assertFalse(access.classpath()); - String prompt = access.loadPrompt("scenario_recognition", "en-US", "system.md"); - assertTrue(prompt.startsWith("You are a scenario recognition agent.")); - assertFalse(prompt.equals("Local prompt copy.")); - } - - @Test - void localFileModeLoadsBusinessContentFromLocalRoot() throws IOException { - write( - promptRootDir - .resolve("templates") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("template.md"), - "Local template for {{service}}."); - write( - promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), - """ - { - "scenarios": [ - { - "scenario_code": "incident_triage", - "scenario_name": "Incident Triage", - "description": "Classify and route a production incident.", - "example": "Investigate elevated API latency." - } - ] - } - """); - write( - promptRootDir - .resolve("slots") - .resolve("Task-T") - .resolve("network-layer") - .resolve("incident_triage") - .resolve("v1") - .resolve("en") - .resolve("slot.json"), - """ - { - "required": ["service"], - "properties": { - "service": {"type": "string", "description": "Affected service name"} - } - } - """); - - PromptResourceAccess access = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - - assertFalse(access.classpath()); - assertEquals(promptRootDir, access.localRootDir()); - assertEquals("Local template for {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); - List scenarios = access.loadScenarios("en"); - PromptSlotSchema slotSchema = access.slotSchemaLoader().loadSlotSchema("incident_triage", "en"); - assertEquals("incident_triage", scenarios.get(0).scenarioCode()); - assertEquals("service", slotSchema.slotDefinitions().get(0).name()); - assertEquals(true, slotSchema.slotDefinitions().get(0).required()); - } - - @Test - void localFileModeIgnoresUnsupportedLocalDirectoriesWithSingleWarning() throws IOException { - write( - promptRootDir - .resolve("prompts") - .resolve("scenario_recognition") - .resolve("en-US") - .resolve("system.md"), - "Local prompt copy."); - write( - promptRootDir - .resolve("templates") - .resolve("Negotiation-T") - .resolve("information-negotiation") - .resolve("propose") - .resolve("v1") - .resolve("en-US") - .resolve("template.md"), - "Local negotiation template copy."); - write(promptRootDir.resolve("negotiation-vocabulary").resolve("en-US").resolve("vocabulary.json"), "{}"); - - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - - List warnings = warningMessages(); - assertEquals(1, warnings.size()); - assertContains( - warnings.get(0), - "prompt_resource_local_directories_ignored", - "prompts", - "templates/Negotiation-T", - "negotiation-vocabulary", - "reason=classpath_fixed"); - } - - @Test - void localFileModePromptLoadUsesClasspathPathForMissingResource() { - PromptResourceAccess access = - PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - - ResourceNotFoundException exception = - assertThrows(ResourceNotFoundException.class, () -> access.loadPrompt("analysis", "en", "missing.md")); - - assertEquals("prompt_resources/prompts/analysis/en/missing.md", exception.resourcePath()); - } - - @Test - void unsupportedSourceTypeFailsFast() { - UnsupportedOperationException exception = assertThrows( - UnsupportedOperationException.class, - () -> PromptResourceAccess.create( - new PromptRuntimeConfig("en-US", "database", promptRootDir.toString()))); - - assertEquals("Unsupported prompt source type: database", exception.getMessage()); - } - - private List warningMessages() { - return appender.list.stream() - .filter(event -> event.getLevel() == Level.WARN) - .map(ILoggingEvent::getFormattedMessage) - .toList(); - } - - private static void assertContains(String message, String... fragments) { - for (String fragment : fragments) { - assertTrue(message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); - } - } - - private static void write(Path file, String content) throws IOException { - Files.createDirectories(file.getParent()); - Files.writeString(file, content); - } -} +package net.openan.a2at.sdk.prompt.resources.loader; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +class PromptResourceAccessTest { + + @TempDir + Path promptRootDir; + + private final ListAppender accessAppender = new ListAppender<>(); + + private final Logger accessLogger = (Logger) LoggerFactory.getLogger(PromptResourceAccess.class); + + private final ListAppender fallbackAppender = new ListAppender<>(); + + private final Logger fallbackLogger = (Logger) LoggerFactory.getLogger(BuiltinFallbackWarnings.class); + + @BeforeEach + void attachAppender() { + accessAppender.start(); + accessLogger.addAppender(accessAppender); + fallbackAppender.start(); + fallbackLogger.addAppender(fallbackAppender); + } + + @AfterEach + void detachAppender() { + accessLogger.detachAppender(accessAppender); + fallbackLogger.detachAppender(fallbackAppender); + fallbackAppender.stop(); + } + + @Test + void classpathModeIgnoresConfiguredLocalRootWithSingleWarning() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", promptRootDir.toString())); + + assertTrue(access.classpath()); + List warnings = warningMessages(accessAppender); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), "prompt_resource_local_root_ignored", "root=" + promptRootDir, "source=classpath"); + } + + @Test + void localFileModeFailsFastWhenLocalRootIsNull() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", null))); + + assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + } + + @Test + void localFileModeFailsFastWhenLocalRootIsBlank() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", " "))); + + assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + } + + @Test + void localFileModeFailsFastWhenLocalRootDoesNotExist() { + Path missing = promptRootDir.resolve("missing"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", missing.toString()))); + + assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + } + + @Test + void localFileModeLoadsPromptsFromClasspath() throws IOException { + write( + promptRootDir + .resolve("prompts") + .resolve("scenario_recognition") + .resolve("en-US") + .resolve("system.md"), + "Local prompt copy."); + + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + assertFalse(access.classpath()); + String prompt = access.loadPrompt("scenario_recognition", "en-US", "system.md"); + assertTrue(prompt.startsWith("You are a scenario recognition agent.")); + assertFalse(prompt.equals("Local prompt copy.")); + } + + @Test + void localFileModeLoadsBusinessContentFromLocalRoot() throws IOException { + write( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + "Local template for {{service}}."); + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "incident_triage", + "scenario_name": "Incident Triage", + "description": "Classify and route a production incident.", + "example": "Investigate elevated API latency." + } + ] + } + """); + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + """ + { + "required": ["service"], + "properties": { + "service": {"type": "string", "description": "Affected service name"} + } + } + """); + + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + assertFalse(access.classpath()); + assertEquals(promptRootDir, access.localRootDir()); + assertEquals("Local template for {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); + List scenarios = access.loadScenarios("en"); + PromptSlotSchema slotSchema = access.slotSchemaLoader().loadSlotSchema("incident_triage", "en"); + assertEquals("incident_triage", scenarios.get(0).scenarioCode()); + assertEquals("service", slotSchema.slotDefinitions().get(0).name()); + assertEquals(true, slotSchema.slotDefinitions().get(0).required()); + } + + @Test + void localFileModeFallsBackToBuiltinTemplateWhenMissingLocally() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + String template = access.templateLoader().loadTemplate("ran-energy-saving", "en-US"); + + assertTrue(template.startsWith("## Operation Type"), "the built-in template should be loaded on fallback"); + assertTrue(template.contains("{{operation_type}}"), "the built-in template should carry its slot placeholders"); + List warnings = warningMessages(fallbackAppender); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), + "prompt_resource_builtin_fallback", + "path=prompt_resources/templates/ran-energy-saving/en-US/template.md", + "source=classpath"); + } + + @Test + void localFileModeFallsBackToBuiltinSlotSchemaWhenMissingLocally() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + PromptSlotSchema schema = access.slotSchemaLoader().loadSlotSchema("ran-energy-saving", "en-US"); + + assertEquals("ran-energy-saving", schema.scenarioCode()); + assertFalse(schema.slotDefinitions().isEmpty()); + assertEquals(1, warningMessages(fallbackAppender).size()); + } + + @Test + void localFileModeFallsBackToBuiltinScenarioCatalogWhenMissingLocally() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + List scenarios = access.loadScenarios("en-US"); + + assertFalse(scenarios.isEmpty(), "a missing local scenarios.json must fall back to the built-in scenario catalog"); + assertEquals(1, warningMessages(fallbackAppender).size()); + } + + @Test + void builtinFallbackWarnsOnlyOncePerResourcePathAcrossLoaderInstances() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + access.templateLoader().loadTemplate("ran-energy-saving", "en-US"); + access.templateLoader().loadTemplate("ran-energy-saving", "en-US"); + + assertEquals(1, warningMessages(fallbackAppender).size(), "the shared dedup set must warn only once per path"); + } + + @Test + void localFileModeIgnoresUnsupportedLocalDirectoriesWithSingleWarning() throws IOException { + write( + promptRootDir + .resolve("prompts") + .resolve("scenario_recognition") + .resolve("en-US") + .resolve("system.md"), + "Local prompt copy."); + write( + promptRootDir + .resolve("templates") + .resolve("Negotiation-T") + .resolve("information-negotiation") + .resolve("propose") + .resolve("v1") + .resolve("en-US") + .resolve("template.md"), + "Local negotiation template copy."); + write(promptRootDir.resolve("negotiation-vocabulary").resolve("en-US").resolve("vocabulary.json"), "{}"); + + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + List warnings = warningMessages(accessAppender); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), + "prompt_resource_local_directories_ignored", + "prompts", + "templates/Negotiation-T", + "negotiation-vocabulary", + "reason=classpath_fixed"); + } + + @Test + void localFileModePromptLoadUsesClasspathPathForMissingResource() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + ResourceNotFoundException exception = + assertThrows(ResourceNotFoundException.class, () -> access.loadPrompt("analysis", "en", "missing.md")); + + assertEquals("prompt_resources/prompts/analysis/en/missing.md", exception.resourcePath()); + } + + @Test + void unsupportedSourceTypeFailsFast() { + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> PromptResourceAccess.create( + new PromptRuntimeConfig("en-US", "database", promptRootDir.toString()))); + + assertEquals("Unsupported prompt source type: database", exception.getMessage()); + } + + private static List warningMessages(ListAppender appender) { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void assertContains(String message, String... fragments) { + for (String fragment : fragments) { + assertTrue(message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); + } + } + + private static void write(Path file, String content) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, content); + } +} \ No newline at end of file diff --git a/docs/en/custom_template_guide.md b/docs/en/custom_template_guide.md new file mode 100644 index 00000000..283bf585 --- /dev/null +++ b/docs/en/custom_template_guide.md @@ -0,0 +1,398 @@ +# 1 Custom Template Guide + +This guide walks through an end-to-end runnable example that uses a **custom business template** (office equipment repair) with the `local_file` resource-loading mode to run the full A2A-T Task-T flow: client natural-language input -> slot extraction -> template rendering to produce a prompt -> server validation and parameter extraction. + +## 1.1 Overview + +### 1.1.1 What This Example Demonstrates + +Example business: reporting a fault on office equipment (printer, computer, monitor, projector, and so on). Given a one-sentence user description, the client SDK extracts slots against an **explicitly specified template** and renders an A2A-T task message; the server SDK then semantically validates that message and extracts structured parameters according to the caller-provided schema. + +The client uses `generateTaskPromptFromText(String, TemplateUri)`: it requires an explicit `TemplateUri`, therefore **skips scenario recognition** (an LLM call), performs slot extraction and rendering against the specified template directly, and returns `MetadataContent`, from which you read the rendered message via `promptText()`. The server uses `validateTaskPromptAndDataFilling(String, Map, TemplateUri)`: it validates the task message and extracts parameters against the provided schema, returning `FilledParamData`, from which you read the extracted `Map` via `data()`. + +### 1.1.2 Supported Versions and Prerequisites + +| Item | Requirement | +| --- | --- | +| JDK | 17+ | +| Build tool | Maven 3.8+ | +| SDK | `net.openan.a2a-t.sdk:a2a-t-client` and `net.openan.a2a-t.sdk:a2a-t-server`, version `1.0.0` | +| LLM | An accessible OpenAI-compatible service and API key | +| Resource mode | `local_file` (business content loaded from a local directory) | + +> This example only demonstrates the A2A-T SDK itself; it does not bring in a2a-java, a registry center, or an HTTP pipeline. For a complete A2A HTTP+JSON/REST integration, see the Developer Guide and `a2a-t-sample`. + +## 1.2 Local Resource Directory + +In `local_file` mode, business content is loaded from a local root directory. The root directory is specified by `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` (see [1.4](#14-env-configuration)). The directory structure is: + +``` +prompt-resources/ +├── templates/ +│ └── Task-T/ +│ └── office-it/ +│ └── printer-repair/ +│ └── v1/ +│ └── en-US/ +│ └── template.md +├── slots/ +│ └── Task-T/ +│ └── office-it/ +│ └── printer-repair/ +│ └── v1/ +│ └── en-US/ +│ └── slot.json +└── scenarios/ + └── en-US/ + └── scenarios.json +``` + +Key points: + +- **The three kinds of business content — `templates` / `slots` / `scenarios` — can be localized, and are local-first with built-in fallback.** `prompts` (LLM prompts) and negotiation (Negotiation-T) resources are always loaded from the classpath. Placing them in a local directory has no effect; the SDK only ignores them and logs a WARN message. +- The directory levels for `templates` and `slots` are fixed as `{extension}/{business-domain}/{business-name}/{version}/{language}` — here `Task-T/office-it/printer-repair/v1/en-US/`. +- `scenarios` has no `Task-T` segment; it is directly `scenarios/{language}/scenarios.json`. This file **may be omitted**: when it is absent, assembly falls back to the built-in scenario catalog. +- The language directory (here `en-US`) must match `A2AT_LANGUAGE` in `.env`, otherwise the corresponding template/slot/scenario resources cannot be found. +- Note: although `generateTaskPromptFromText` itself skips scenario recognition, in `local_file` mode `new A2ATClient(...)` still calls `loadScenarios` during **assembly**. `scenarios/{language}/scenarios.json` may be omitted — a missing local file falls back to the built-in scenario catalog and assembly does not fail. This example only overrides a custom template, so no local scenario directory is required (see [1.3.3](#133-scenariosjson)). + +## 1.3 Resource Files + +### 1.3.1 template.md (template) + +The template defines the skeleton of the task message. `{{slot-name}}` is a slot placeholder that is replaced by the actual value extracted from the user input at render time; the placeholder name must match the slot name in `slot.json`. The sectioning, the `(required)/(optional)` markers, the `Requirements:` numbered list, and the `` cross-references mirror the built-in Task-T templates (e.g. `ran-energy-saving`). + +> The `(required)/(optional)` and `Requirements:` text is authoring guidance: it is stripped at render time and never appears in the final message (see the rendered output in [1.6](#16-running-and-expected-output)). Together with the `description`/`x-a2at-value-constraint` of `slot.json`, it drives slot extraction and semantic validation. + +````markdown +## Operation Type + +{{operation_type}}(required) + +Requirements: + +1. Provide the operation type; allowed values: create, modify + +## Task Type + +Office equipment repair + +## Task Description + +{{task_description}}(required) + +Requirements: + +1. Provide the task description dynamically based on the (create/modify): + - When the type is [create]: Based on the and , issue an office equipment repair request, achieve the repair objective defined in , and return the repair result in the format defined in . + - When the type is [modify]: Based on the and , update the office equipment repair request and achieve the repair objective defined in . + +## Task Object + +{{task_object}}(required) + +Requirements: + +1. Reporter:(required) + - Name of the person reporting the fault + - Example: reporter: "Zhang Wei" +2. Department:(required) + - Department name of the reporter + - Example: department: "Marketing" +3. Faulty equipment:(required) + - Name, brand and location of the faulty office equipment + - Example: faulty equipment: "The HP printer beside the meeting room on the east side of the third floor" + +## Task Target + +{{task_target}}(optional) + +Requirements: + +1. Define the quantitative or qualitative objective for the repair task; quantitative and qualitative objectives cannot be selected at the same time, defaulting to a qualitative objective. Details: + - Quantitative objective: expected completion time of the repair, in the form of a specific time or time range; example: expected completion time: before 3 p.m. + - Qualitative objective: process and complete the repair as soon as possible and restore the equipment to a working state + +## Task Context + +{{task_context}}(optional) + +Requirements: + +1. Fault symptom: description of the specific fault; example: fault symptom: cannot power on, the power indicator keeps flashing +2. Contact: phone number of the reporter; example: contact: 13800001234 + +## Expected Output + +{{expected_output}}(optional when creating, not needed when modifying) + +Requirements: + +1. Output the repair result of the office equipment repair task; the repair result must contain the following information: + +### Repair result basic information + +1. Repair acceptance status +2. Repair task unique identifier + +### Repair result detailed information + +1. Faulty equipment information +2. Repair handling requirements +```` + +### 1.3.2 slot.json (slot schema) + +The slot schema is a JSON Schema that declares each slot's type, business meaning, and value constraint. Keys under `properties` are the slot names and must correspond to the `{{placeholders}}` in `template.md`; the `description`/`examples`/`x-a2at-value-constraint` triple on each slot is the primary source the LLM slot extraction reads. The `required` array declares required slots (this example mirrors the built-in template by leaving it empty; requiredness is primarily expressed through the value constraint and description). + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "operation_type": { + "type": "string", + "description": "Provide the operation type; allowed values: create, modify.", + "examples": [ + "create" + ], + "x-a2at-value-constraint": "Allowed values: create, modify" + }, + "task_description": { + "type": "string", + "description": "Provide the task description dynamically based on the operation type (create/modify).", + "examples": [ + "Based on the and , issue an office equipment repair request, achieve the repair objective defined in , and return the repair result in the format defined in ." + ], + "x-a2at-value-constraint": "When the type is [create]: based on the and , issue an office equipment repair request, achieve the repair objective defined in , and return the repair result in the format defined in . When the type is [modify]: based on the and , update the office equipment repair request and achieve the repair objective defined in ." + }, + "task_object": { + "type": "string", + "description": "Specify the repair object, including the reporter, department and faulty equipment.", + "examples": [ + "Reporter: Zhang Wei, department: Marketing, faulty equipment: the HP printer beside the meeting room on the east side of the third floor." + ], + "x-a2at-value-constraint": "Reporter, department and faulty equipment are all required. Reporter is the name of the person reporting the fault; department is the department name of the reporter; faulty equipment is the name, brand and location of the faulty office equipment." + }, + "task_target": { + "type": "string", + "description": "Define the quantitative or qualitative objective for the repair task.", + "examples": [ + "Expected completion time: before 3 p.m." + ], + "x-a2at-value-constraint": "1. Quantitative objective (expected completion time): the required time to complete the repair, in the form of a specific time or time range. 2. Qualitative objective: process and complete the repair as soon as possible and restore the equipment to a working state. Quantitative and qualitative objectives cannot be selected at the same time, defaulting to a qualitative objective." + }, + "task_context": { + "type": "string", + "description": "Provide the background and context of the repair task.", + "examples": [ + "Fault symptom: cannot power on, the power indicator keeps flashing; contact: 13800001234." + ], + "x-a2at-value-constraint": "1. Fault symptom: description of the specific fault. 2. Contact: phone number of the reporter." + }, + "expected_output": { + "type": "string", + "description": "Output the repair result of the office equipment repair task.", + "examples": [ + "" + ], + "x-a2at-value-constraint": "Repair result basic information: repair acceptance status, repair task unique identifier. Repair result detailed information: faulty equipment information, repair handling requirements." + } + }, + "required": [] +} +``` + +### 1.3.3 scenarios.json (scenarios) + +The scenario list is loaded during client assembly. **This example uses the `generateTaskPromptFromText` path and performs no scenario recognition**, so there is no need to register scenarios — `scenarios/{language}/scenarios.json` may be omitted entirely; when it is absent, assembly falls back to the built-in scenario catalog without failing. If local scenario recognition is needed, place this file, where `scenario_code` must match the template directory path: `Task-T/office-it/printer-repair/v1`. + +```json +{ + "scenarios": [ + { + "scenario_code": "Task-T/office-it/printer-repair/v1", + "scenario_name": "Office equipment repair", + "description": "Generates an A2A-T task request for reporting/repairing a fault on office equipment (printer, computer, monitor, projector, etc.), describing the reporter, department, faulty equipment, fault symptom, expected completion time and contact information.", + "example": "Help me create an office equipment repair task. Operation type: create. Task object: the reporter is Zhang Wei. Task target: the expected completion time is before 3 p.m." + } + ] +} +``` + +## 1.4 .env Configuration + +Save the following as `.env` (placed in the process working directory, or pass a full path via a command-line argument): + +```properties +A2AT_LANGUAGE=en-US +A2AT_PROMPT_SOURCE_TYPE=local_file +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=./prompt-resources +A2AT_PROMPT_COMPLIANCE_ENABLED=false +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=your-model-name +A2AT_LLM_BASE_URL=https://your-llm-gateway.example.com/v1 +A2AT_LLM_API_KEY=your-api-key +A2AT_LLM_MAX_TOKENS=8000 +A2AT_LLM_TIMEOUT_SECONDS=180 +``` + +Key points: + +- `A2AT_PROMPT_SOURCE_TYPE=local_file`: enable local-file resource loading. +- `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR`: the local resource root directory. **A relative path is resolved against the directory of the `.env` file** (not the process working directory). The `./prompt-resources` above means the resource directory sits next to `.env`. +- This directory is read and validated at assembly time of `new A2ATClient(...)` / `new A2ATServer(...)`: **a missing directory fails fast at construction without any LLM call**. +- `A2AT_PROMPT_COMPLIANCE_ENABLED=false`: this example disables the generation-stage compliance pre-check to focus on the "generate -> server validation/extraction" mainline. +- `A2AT_LLM_MAX_TOKENS`: use a larger value for reasoning models; a value that is too small causes the structured output to be empty. +- `A2AT_LLM_PROVIDER` / `A2AT_LLM_MODEL` / `A2AT_LLM_BASE_URL` / `A2AT_LLM_API_KEY` should be filled in per the actual LLM service; all of them are shown as placeholders here. + +## 1.5 Project and Code + +### 1.5.1 pom.xml + +```xml + + + 4.0.0 + + demo + task-t-demo + 1.0.0 + + + 17 + UTF-8 + + + + + net.openan.a2a-t.sdk + a2a-t-client + 1.0.0 + + + net.openan.a2a-t.sdk + a2a-t-server + 1.0.0 + + + org.slf4j + slf4j-simple + 2.0.17 + runtime + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + demo.TaskTDemo + + + + + +``` + +### 1.5.2 TaskTDemo.java + +```java +package demo; + +import java.nio.file.Path; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.server.A2ATServer; + +public final class TaskTDemo { + public static void main(String[] args) throws Exception { + Path env = Path.of(args.length > 0 ? args[0] : ".env"); + String text = args.length > 1 ? args[1] + : "Help me create an office equipment repair task. Operation type: create. Task description: based on the reporter, department and faulty equipment information, issue an office equipment repair request, achieve the repair objective defined in the task target, and return the repair handling result in the format defined in the expected output. Task object: the reporter is Zhang Wei, the department is Marketing, and the faulty equipment is the HP printer beside the meeting room on the east side of the third floor. Task target: the expected completion time is before 3 p.m. Task context: the fault symptom is that it cannot power on and the power indicator keeps flashing, and the contact number is 13800001234. Expected output: output the repair result containing the repair acceptance status, the repair task unique identifier, the faulty equipment information and the repair handling requirements."; + TemplateUri uri = TemplateUri.of("Task-T", "office-it", "printer-repair"); + Map schema = Map.of( + "type", "object", "properties", Map.of( + "reporter", slot("Name of the person reporting the fault"), + "department", slot("Department name of the reporter"), + "device", slot("Name, brand and location of the faulty office equipment"), + "symptom", slot("Description of the specific fault"), + "deadline", slot("Expected completion time of the repair"), + "contact", slot("Phone number of the reporter"))); + + A2ATClient client = new A2ATClient(env); + MetadataContent content = client.generateTaskPromptFromText(text, uri); + System.out.println(content.promptText()); + A2ATServer server = new A2ATServer(env); + System.out.println(server.validateTaskPromptAndDataFilling(content.promptText(), schema, uri).data()); + } + + private static Map slot(String description) { + return Map.of("type", "string", "description", description); + } +} +``` + +Two key API choices worth calling out: + +- **The client uses `generateTaskPromptFromText(String, TemplateUri)`**: it takes an explicit template URI, skips scenario recognition, runs one LLM slot-extraction step against that template and renders the result, returning `MetadataContent` (`promptText()` is the rendered message). Compared with the scenario-recognition path of `generateTaskPrompt(Object)`, it saves the recognition LLM call, and the result carries an explicit `templateUri`. +- **The server uses `validateTaskPromptAndDataFilling(String, Map, TemplateUri)`**: it validates the task message and extracts parameters against the caller-provided schema, returning `FilledParamData` (`data()` is the extracted `Map`). It needs an explicit `TemplateUri`; this example builds it segment-by-segment with `TemplateUri.of("Task-T", "office-it", "printer-repair")`, mirroring the resource directory path. +- The server-side `schema` is the caller-provided JSON parameter schema (here declared inline with `Map.of`, six business fields). Its keys (`reporter`/`department`/`device` and so on) **intentionally differ** from the client template slot names (`operation_type`/`task_object` and so on): the client renders the business fields into template sections, and the server re-extracts them under its own key names from the rendered message — demonstrating the SDK's cross-key adaptation. + +## 1.6 Running and Expected Output + +Run in the project root directory: + +```bash +mvn compile exec:java +``` + +> On first run, make sure the `net.openan.a2a-t.sdk` dependencies can be resolved over the network and that the LLM configuration (such as `A2AT_LLM_API_KEY`) is filled in correctly. + +The expected output has two parts: first the prompt text rendered by the client, followed immediately by the `data` map extracted by the server (the stringified `FilledParamData.data()`). + +```text +## Operation Type + +create + +## Task Type + +Office equipment repair + +## Task Description + +based on the reporter, department and faulty equipment information, issue an office equipment repair request, achieve the repair objective defined in the task target, and return the repair handling result in the format defined in the expected output. + +## Task Object + +the reporter is Zhang Wei, the department is Marketing, and the faulty equipment is the HP printer beside the meeting room on the east side of the third floor. + +## Task Target + +the expected completion time is before 3 p.m. + +## Task Context + +the fault symptom is that it cannot power on and the power indicator keeps flashing, and the contact number is 13800001234. + +## Expected Output + +output the repair result containing the repair acceptance status, the repair task unique identifier, the faulty equipment information and the repair handling requirements. + +{symptom=it cannot power on and the power indicator keeps flashing, department=Marketing, device=the HP printer beside the meeting room on the east side of the third floor, deadline=before 3 p.m., reporter=Zhang Wei, contact=13800001234} +``` + +Timing note: this example's complete chain triggers **2 LLM calls in total** — one client slot extraction and one server semantic validation/extraction (the `FromText` path has no scenario-recognition call). Actual time depends on the response speed of the connected model. + +## 1.7 FAQ + +- **Resource directory missing; construction fails fast (zero LLM calls).** When the directory pointed to by `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` does not exist or is invalid, `new A2ATClient(...)` / `new A2ATServer(...)` throws at assembly time before any LLM call. In `local_file` mode, business content is local-first with built-in fallback and `scenarios/{language}/scenarios.json` may be omitted (a missing file falls back to the built-in scenario catalog; see [1.3.3](#133-scenariosjson)). +- **Oversized input (`input_text_too_long`).** Both FromText generation and server validation gates are bounded by `A2AT_INPUT_TEXT_MAX_CHARS` (default 16384); oversized free text fails fast with `input_text_too_long` before reaching the LLM. +- **`slot_validation_error` (`missing_required`) or `validation_semantic_rejected`.** When `slot.json` declares a required slot that the input does not provide, the client throws `slot_validation_error` (`missing_required`); when the message contradicts itself, the server rejects it with `validation_semantic_rejected`. Both require the "template `Requirements:` prose <-> `slot.json` `description`/`x-a2at-value-constraint` <-> input content" wording to stay consistent. A typical counter-example: the task description states "restore the equipment as soon as possible" (a qualitative objective) while the task target states "before 3 p.m." (a quantitative objective) — since quantitative and qualitative objectives cannot be selected at the same time, the server rejects the content with `semantic_mismatch`. +- **Windows console encoding.** On Windows the default console encoding may not be UTF-8, which can garble output. Run `set JAVA_TOOL_OPTIONS=-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dfile.encoding=UTF-8` (or `chcp 65001`) first so the output is rendered as UTF-8. The language directory must match `A2AT_LANGUAGE` (here `en-US`). \ No newline at end of file diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 8496c611..4cc4e105 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -87,7 +87,7 @@ flowchart TD 1. JDK 17+ is required, and the build tool must be Maven 3.8+. 2. Full multi-agent protocol interaction development requires the A2A official Java SDK (a2a-java, `org.a2aproject.sdk`) as well, with a version no lower than `1.0.0.Beta1`. -3. Prompt resources support two sources: `classpath` (default; loads the resources bundled in the `a2a-t-resources` jar) and `local_file` (local file resources). +3. Prompt resources support two sources: `classpath` (default; loads the resources bundled in the `a2a-t-resources` jar) and `local_file` (local file resources); in `local_file` mode business content is local-first and falls back to the built-in copy when missing (overlay). 4. Negotiation state storage provides only `in_memory`; the state is lost when the process exits. 5. The A2A-T SDK does not provide an agent HTTP service framework, a registry center client, authentication, or key management capabilities; these must be integrated by the business system. @@ -1535,7 +1535,7 @@ When generating and validating A2A-T prompts, the SDK relies on three kinds of p | Resource | `local_file` mode | `classpath` mode | | --- | --- | --- | -| Business templates/slots/scenarios (templates/slots/scenarios of Task-T, Notification-T, Authorization-T) | Read from the local root directory | Built-in resources | +| Business templates/slots/scenarios (templates/slots/scenarios of Task-T, Notification-T, Authorization-T) | Local-first, falls back to built-in when missing (overlay) | Built-in resources | | Negotiation templates (fixed Negotiation-T set) and the negotiation vocabulary (negotiation-vocabulary) | Loaded from the classpath; not customizable | Built-in resources | | LLM instruction prompts (prompts directory) | Loaded from the classpath; not customizable | Built-in resources | @@ -1543,6 +1543,7 @@ When generating and validating A2A-T prompts, the SDK relies on three kinds of p 1. **Construction-time validation**: `A2ATClient` and `A2ATServer` validate the prompt-resource configuration at construction time. In `local_file` mode, construction fails immediately with a clear error message when `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` is unset, the path does not exist, or the path is not a directory. 2. **Initialization loading**: at construction time, the SDK reads all content under `templates/`, `slots/`, and `scenarios/` of the local root directory once into a read-only snapshot and no longer accesses the file system at runtime; after modifying local files, the SDK process must be restarted for the changes to take effect. +3. **Local-first overlay with built-in fallback**: in `local_file` mode, business content (templates/slots/scenarios of Task-T/Notification-T/Authorization-T) is read from the local root when present and falls back to the built-in classpath copy when missing; an error is only raised when both are missing. Customers therefore only place the templates they override or add — no need to copy the whole built-in tree — and `scenarios/{language}/scenarios.json` is no longer required (a local root without a scenario catalog falls back to the built-in one). Each resource path warns once (deduplicated by path) the first time it falls back to a built-in copy. ### 1.5.2 Implementation Steps @@ -1626,6 +1627,7 @@ Notes: 2. In `local_file` mode, `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` is required: when unset, an error is reported prompting you to set `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR`; assembly also fails when the path does not exist or is not a directory. 3. Relative paths of the local root directory are resolved against the directory of the `.env` file; absolute paths are recommended. 4. In `classpath` mode, a configured local root directory is ignored with a warning log. +5. Business content is local-first with built-in fallback: a `template.md`, `slot.json` or `scenarios.json` missing from the local root falls back to the built-in classpath copy (each resource path warns once about the fallback); `scenarios.json` may be omitted entirely. **templateUri mapping** @@ -1649,7 +1651,7 @@ MetadataContent metadata = client.generateTaskPromptFromText( Facade failure policy for `templateUri`: a null template URI throws `NullPointerException`; a blank or malformed URI (fewer than three segments, or a segment that is not simple) throws `IllegalArgumentException` with the message `Unparseable template URI: `. -To override a built-in template (e.g. `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI`), place `template.md` and `slot.json` at the same relative path under the local root directory and keep using the original constant in the code. Note that in `local_file` mode business templates are read only from the local root directory without classpath fallback; when the file for a `templateUri` is missing, the API throws `PromptGenerationException` with the error code `template.not_found`. +To override a built-in template (e.g. `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI`), place `template.md` and `slot.json` at the same relative path under the local root directory and keep using the original constant in the code. In `local_file` mode business templates are local-first with built-in fallback: the local copy is read when present, otherwise the built-in classpath copy is used; the API throws `PromptGenerationException` with the error code `template.not_found` only when a `templateUri` exists neither locally nor built-in. #### Step4 Verification and troubleshooting @@ -1667,7 +1669,7 @@ The sample configuration file provided by the SDK is `env.example`. When constru | --- | --- | | `A2AT_LANGUAGE` | Prompt resource language; built-in `zh-CN` and `en-US`, default `en-US` | | `A2AT_PROMPT_SOURCE_TYPE` | Prompt resource source; supports `classpath` and `local_file`, default `classpath` | -| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | Local prompt resource root directory; required in `local_file` mode — unset or non-existent paths fail fast at assembly time; only business content (templates/slots/scenarios of Task-T/Notification-T/Authorization-T) is read from this root, while negotiation resources and LLM prompts are loaded from the classpath | +| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | Local prompt resource root directory; required in `local_file` mode — unset or non-existent paths fail fast at assembly time; business content (templates/slots/scenarios of Task-T/Notification-T/Authorization-T) is loaded from this root local-first with built-in fallback (`scenarios.json` may be omitted locally), while negotiation resources and LLM prompts are loaded from the classpath | | `A2AT_INPUT_TEXT_MAX_CHARS` | Maximum number of characters for free-text inputs (FromText generation and message validation entry points); exceeding the limit fails fast with error code `input.text_too_long`, default `16384`; structured data that involves no LLM calls is not subject to this limit | | `A2AT_LLM_PROVIDER` | LLM protocol type; currently only `openai` is supported | | `A2AT_LLM_MODEL` | Model name | diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index b6c5e4c1..badcfbf5 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -87,7 +87,7 @@ flowchart TD 1. JDK 版本要求为 17+,构建工具要求 Maven 3.8+。 2. 完整的多智能体协议交互开发需要同时引入 A2A 官方 Java SDK(a2a-java,`org.a2aproject.sdk`),要求版本不小于 `1.0.0.Beta1` 。 -3. 提示词资源支持 `classpath`(默认,从 `a2a-t-resources` jar 内置资源加载)和 `local_file`(本地文件资源)两种来源。 +3. 提示词资源支持 `classpath`(默认,从 `a2a-t-resources` jar 内置资源加载)和 `local_file`(本地文件资源)两种来源;`local_file` 模式下业务内容本地优先、缺失回退内置(overlay)。 4. 协商状态存储仅提供 `in_memory`,进程退出后状态丢失。 5. A2A-T SDK 不提供 Agent HTTP 服务框架、注册中心客户端、鉴权和密钥管理能力,需由业务系统集成。 @@ -1530,7 +1530,7 @@ SDK 在生成和校验 A2A-T 提示词时依赖三类提示词资源:场景目 | 资源 | `local_file` 模式 | `classpath` 模式 | | --- | --- | --- | -| 业务模板/槽位/场景(Task-T、Notification-T、Authorization-T 的 templates/slots/scenarios) | 从本地根目录读取 | 内置资源 | +| 业务模板/槽位/场景(Task-T、Notification-T、Authorization-T 的 templates/slots/scenarios) | 本地优先,缺失回退内置(overlay) | 内置资源 | | 协商模板(Negotiation-T 固定集合)与协商词表(negotiation-vocabulary) | 从 classpath 加载,不可自定义 | 内置资源 | | LLM 指令提示词(prompts 目录) | 从 classpath 加载,不可自定义 | 内置资源 | @@ -1538,6 +1538,7 @@ SDK 在生成和校验 A2A-T 提示词时依赖三类提示词资源:场景目 1. **构造时校验**:`A2ATClient` 与 `A2ATServer` 在构造时即校验提示词资源配置。`local_file` 模式下 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 未设置、路径不存在或不是目录时,构造时直接失败并给出明确的错误信息。 2. **初始化载入**:SDK 在构造时一次性读取本地根目录下 `templates/`、`slots/`、`scenarios/` 的全部内容形成只读快照,运行期不再访问文件系统;修改本地文件后需重启 SDK 进程才能生效。 +3. **本地优先、内置兜底(overlay)**:`local_file` 模式下业务内容(Task-T/Notification-T/Authorization-T 的 `templates`/`slots`/`scenarios`)本地存在则读取本地,本地缺失回退 classpath 内置,本地与内置均缺失才报错。因此客户只需放置需要覆盖或新增的模板,无需整树拷贝内置资源;`scenarios/{语言}/scenarios.json` 也不再必填,本地根目录无场景目录时回退内置场景目录。每个业务资源路径首次发生内置兜底时输出一次 WARN(按资源路径去重),提示实际来源。 ### 1.5.2 实施步骤 @@ -1642,7 +1643,7 @@ MetadataContent metadata = client.generateTaskPromptFromText("请对xx基站开 对 `templateUri` 的失败策略:为 null 抛 `NullPointerException`;为空白或非法 URI(少于三段,或某一段不是简单段)抛 `IllegalArgumentException`,消息为 `Unparseable template URI: <输入>`。 -如需覆盖内置模板(如 `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI`),在本地根目录下按相同相对路径放置 `template.md` 与 `slot.json`,代码中继续使用原有常量即可。注意 `local_file` 模式下业务模板只从本地根目录读取,`templateUri` 对应的文件缺失时接口抛出 `PromptGenerationException`,错误码为 `template.not_found`。 +如需覆盖内置模板(如 `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI`),在本地根目录下按相同相对路径放置 `template.md` 与 `slot.json`,代码中继续使用原有常量即可。`local_file` 模式下业务模板本地优先、缺失回退内置:本地放置了对应文件则读取本地版本,未放置则回退 classpath 内置版本;本地与内置均缺失时接口抛出 `PromptGenerationException`,错误码为 `template.not_found`。 #### Step4 验证与排障 @@ -1660,7 +1661,7 @@ SDK提供的样例配置文件为`env.example`,A2A-T Client和A2A-T Server构 | ------------------------------------- | ------------------------------------------------------------ | | `A2AT_LANGUAGE` | 提示词资源语言,内置 `zh-CN` 与 `en-US`,默认 `en-US` | | `A2AT_PROMPT_SOURCE_TYPE` | 提示词资源来源,支持 `classpath` 和 `local_file`,默认 `classpath` | -| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | 本地提示词资源根目录;`local_file` 模式下必填,未设置或路径不存在时装配期快速失败;仅业务内容(Task-T/Notification-T/Authorization-T 的 templates/slots/scenarios)从该根读取,协商资源与 LLM prompt 从 classpath 加载 | +| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | 本地提示词资源根目录;`local_file` 模式下必填,未设置或路径不存在时装配期快速失败;业务内容(Task-T/Notification-T/Authorization-T 的 templates/slots/scenarios)从该根本地优先加载、缺失回退内置(`scenarios.json` 本地可省略),协商资源与 LLM prompt 恒从 classpath 加载 | | `A2AT_INPUT_TEXT_MAX_CHARS` | 自由文本输入(FromText 生成与报文校验入口)的最大字符数,超限快速失败并报错误码 `input.text_too_long`,默认 `16384`;不涉及 LLM 调用的结构化数据不受此限制 | | `A2AT_LLM_PROVIDER` | LLM 协议支持类型,当前仅支持 `openai` | | `A2AT_LLM_MODEL` | 模型名称 | diff --git "a/docs/zh/\350\207\252\345\256\232\344\271\211\346\250\241\346\235\277\347\244\272\344\276\213.md" "b/docs/zh/\350\207\252\345\256\232\344\271\211\346\250\241\346\235\277\347\244\272\344\276\213.md" new file mode 100644 index 00000000..a8b86a67 --- /dev/null +++ "b/docs/zh/\350\207\252\345\256\232\344\271\211\346\250\241\346\235\277\347\244\272\344\276\213.md" @@ -0,0 +1,398 @@ +# 1 自定义模板示例 + +本文档通过一个端到端可运行的示例,演示如何用**自定义业务模板**(办公设备故障报修)+ `local_file` 资源加载模式,跑通 A2A-T 的 Task-T 全流程:客户端自然语言输入 → 槽位提取 → 模板渲染生成 prompt → 服务端校验并提取参数。 + +## 1.1 概述 + +### 1.1.1 本示例演示什么 + +示例业务:办公设备(打印机、电脑、显示器、投影仪等)故障报修。用户用一句话描述故障,客户端 SDK 按**显式指定的模板**抽取槽位并渲染生成 A2A-T 任务报文;服务端 SDK 对该报文做语义校验并按调用方 Schema 提取出结构化参数。 + +客户端选用 `generateTaskPromptFromText(String, TemplateUri)`:它要求显式传入 `TemplateUri`,因此**跳过场景识别**这条 LLM 调用,直接按指定模板做槽位提取与渲染,返回 `MetadataContent`,用 `promptText()` 取渲染后的报文。服务端选用 `validateTaskPromptAndDataFilling(String, Map, TemplateUri)`:校验任务报文合规性并按传入 Schema 提取参数,返回 `FilledParamData`,用 `data()` 取提取出的 `Map`。 + +### 1.1.2 适用版本与前置条件 + +| 项目 | 要求 | +| --- | --- | +| JDK | 17+ | +| 构建工具 | Maven 3.8+ | +| SDK | `net.openan.a2a-t.sdk:a2a-t-client` 与 `net.openan.a2a-t.sdk:a2a-t-server`,版本 `1.0.0` | +| LLM | 可访问的 OpenAI 兼容服务及 API Key | +| 资源模式 | `local_file`(业务内容从本地目录加载) | + +> 本示例只演示 A2A-T SDK 本身,不引入 a2a-java、注册中心或 HTTP 链路。如需完整的 A2A HTTP+JSON/REST 串接,请参考《开发指南》与 `a2a-t-sample`。 + +## 1.2 本地资源目录 + +`local_file` 模式下,业务内容从本地根目录加载。根目录由 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 指定(见 [1.4](#14-env-配置))。目录结构如下: + +``` +prompt-resources/ +├── templates/ +│ └── Task-T/ +│ └── office-it/ +│ └── printer-repair/ +│ └── v1/ +│ └── zh-CN/ +│ └── template.md +├── slots/ +│ └── Task-T/ +│ └── office-it/ +│ └── printer-repair/ +│ └── v1/ +│ └── zh-CN/ +│ └── slot.json +└── scenarios/ + └── zh-CN/ + └── scenarios.json +``` + +要点: + +- **`templates` / `slots` / `scenarios` 三类业务内容可本地化且本地优先、缺失回退内置**。`prompts`(LLM 提示词)与协商(Negotiation-T)资源恒从 classpath 加载——把它们放到本地目录不会被读取,SDK 只会忽略并输出 WARN 日志。 +- `templates` 与 `slots` 的目录层级固定为 `{扩展名}/{业务域}/{业务名}/{版本}/{语言}`,即 `Task-T/office-it/printer-repair/v1/zh-CN/`。 +- `scenarios` 没有 `Task-T` 段,直接是 `scenarios/{语言}/scenarios.json`;该文件**可省略**——本地根目录无此文件时装配期回退内置场景目录。 +- 语言目录(本例为 `zh-CN`)必须与 `.env` 中的 `A2AT_LANGUAGE` 一致,否则对应的模板/槽位/场景资源找不到。 +- 注意:虽然 `generateTaskPromptFromText` 本身跳过场景识别,但 `local_file` 模式下 `new A2ATClient(...)` 在**装配期**仍会调用 `loadScenarios` 做组件装配。`scenarios/{语言}/scenarios.json` 可以省略,本地缺该文件时回退内置场景目录、装配不会失败。本示例只覆盖自定义模板,可不放置场景目录(见 [1.3.3](#133-scenariosjson))。 + +## 1.3 资源文件说明 + +### 1.3.1 template.md(模板) + +模板定义任务报文的骨架,`{{槽位名}}` 是槽位占位符,渲染时会被替换为从用户输入提取出的实际值;占位符名必须与 `slot.json` 中的槽位名一一对应。模板分节、占位符标注 `(必填)/(选填)`、`要求:` 编号列表以及 `<尖括号>` 交叉引用的写法均对齐内置 Task-T 模板(如 `ran-energy-saving`)。 + +> `(必填)/(选填)`、`要求:` 等说明文字是渲染前的编写说明,渲染时会被剥离,不会出现在最终报文中(渲染结果见 [1.6](#16-运行与预期输出))。它们与 `slot.json` 的 `description`/`x-a2at-value-constraint` 是槽位提取与语义校验的依据。 + +````markdown +## 操作类型(Operation Type) + +{{操作类型}}(必填) + +要求: + +1. 请提供操作类型,取值范围:创建、修改 + +## 任务类型(Task Type) + +办公设备故障报修 + +## 任务描述(Task Description) + +{{任务描述}}(必填) + +要求: + +1. 请根据<操作类型>(创建/修改),动态提供任务描述信息: + - 类型为【创建】时内容为:基于<任务对象>、<任务上下文>,发起办公设备故障报修请求,达成<任务目标>中定义的报修目标,并按照<预期输出>中定义的格式返回报修处理结果。 + - 类型为【修改】时内容为:基于<任务对象>、<任务上下文>,更新办公设备故障报修请求,达成<任务目标>中定义的报修目标。 + +## 任务对象(Task Object) + +{{任务对象}}(必填) + +要求: + +1. 报修人:(必填) + - 故障报修发起人的姓名 + - 举例:报修人:"张伟" +2. 部门:(必填) + - 报修人所属部门名称 + - 举例:部门:"市场部" +3. 故障设备:(必填) + - 发生故障的办公设备名称、品牌及所处位置 + - 举例:故障设备:"三楼东侧会议室旁的惠普打印机" + +## 任务目标(Task Target) + +{{任务目标}}(选填) + +要求: + +1. 定义报修任务需达成的量化或定性目标,量化目标和定性目标不可同时选择,默认为定性目标。报修目标的具体说明如下: + - 量化目标:期望完成维修的时间要求,目标值格式为具体时刻或时间区间;举例:期望完成时间:下午三点前 + - 定性目标:尽快受理并完成维修,恢复设备可用 + +## 任务上下文(Task Context) + +{{任务上下文}}(选填) + +要求: + +1. 故障现象:设备发生故障的具体现象描述;举例:故障现象:开不了机,电源灯一直闪 +2. 联系方式:报修人的联系电话;举例:联系方式:13800001234 + +## 预期输出(Expected Output) + +{{预期输出}}(创建时选填,修改时无需填写) + +要求: + +1. 输出办公设备故障报修任务的报修结果,报修结果应包含以下信息: + +### 报修结果基本信息 + +1. 报修受理状态 +2. 报修任务唯一标识 + +### 报修结果详细信息 + +1. 故障设备信息 +2. 维修处理要求 +```` + +### 1.3.2 slot.json(槽位 Schema) + +槽位 Schema 是一个 JSON Schema,声明每个槽位的类型、业务含义与取值约束。`properties` 下的键名即槽位名,必须与 `template.md` 的 `{{占位符}}` 对应;每个槽位下的 `description` / `examples` / `x-a2at-value-constraint` 三要素是 LLM 槽位提取的主要依据,写得越贴切提取越准。`required` 数组声明必填槽位(本示例对齐内置模板写法,保持为空,必填语义主要体现在值约束与描述中)。 + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "操作类型": { + "type": "string", + "description": "请提供操作类型,取值范围:创建、修改。", + "examples": [ + "创建" + ], + "x-a2at-value-constraint": "取值范围:创建、修改" + }, + "任务描述": { + "type": "string", + "description": "根据操作类型(创建/修改),动态提供任务描述信息。", + "examples": [ + "基于<任务对象>、<任务上下文>,发起办公设备故障报修请求,达成<任务目标>中定义的报修目标,并按照<预期输出>中定义的格式返回报修处理结果。" + ], + "x-a2at-value-constraint": "类型为【创建】时内容为:基于<任务对象>、<任务上下文>,发起办公设备故障报修请求,达成<任务目标>中定义的报修目标,并按照<预期输出>中定义的格式返回报修处理结果。类型为【修改】时内容为:基于<任务对象>、<任务上下文>,更新办公设备故障报修请求,达成<任务目标>中定义的报修目标。" + }, + "任务对象": { + "type": "string", + "description": "明确指定报修对象信息,包括报修人、部门、故障设备。", + "examples": [ + "报修人:张伟,部门:市场部,故障设备:三楼东侧会议室旁的惠普打印机。" + ], + "x-a2at-value-constraint": "报修人、部门、故障设备均为必填。报修人为故障报修发起人的姓名;部门为报修人所属部门名称;故障设备为发生故障的办公设备名称、品牌及所处位置。" + }, + "任务目标": { + "type": "string", + "description": "定义报修任务需达成的量化或定性目标。", + "examples": [ + "期望完成时间:下午三点前" + ], + "x-a2at-value-constraint": "1. 量化目标 期望完成时间:期望完成维修的时间要求,目标值格式为具体时刻或时间区间。2. 定性目标 尽快受理并完成维修,恢复设备可用。量化目标和定性目标不可同时选择,默认为定性目标。" + }, + "任务上下文": { + "type": "string", + "description": "提供报修任务的背景和上下文信息。", + "examples": [ + "故障现象:开不了机,电源灯一直闪;联系方式:13800001234。" + ], + "x-a2at-value-constraint": "1. 故障现象:设备发生故障的具体现象描述。2. 联系方式:报修人的联系电话。" + }, + "预期输出": { + "type": "string", + "description": "输出办公设备故障报修任务的报修结果。", + "examples": [ + "" + ], + "x-a2at-value-constraint": "报修结果基本信息:报修受理状态、报修任务唯一标识。报修结果详细信息:故障设备信息、维修处理要求。" + } + }, + "required": [] +} +``` + +### 1.3.3 scenarios.json(场景) + +场景清单用于客户端装配期的场景目录加载。**本示例走 `generateTaskPromptFromText` 路径,不做场景识别**,因此不需要登记场景——`scenarios/{语言}/scenarios.json` 可直接省略,装配期会回退内置场景目录、装配不会失败。若需要本地场景识别,才需要放置该文件,其中 `scenario_code` 必须与模板目录路径一致:`Task-T/office-it/printer-repair/v1`。 + +```json +{ + "scenarios": [ + { + "scenario_code": "Task-T/office-it/printer-repair/v1", + "scenario_name": "办公设备故障报修", + "description": "用于生成办公设备(打印机、电脑、显示器、投影仪等)故障报修/维修申请类的 A2A-T 任务请求,围绕报修人、部门、故障设备、故障现象、期望完成时间、联系方式等信息进行描述。", + "example": "帮我创建一个办公设备故障报修任务,操作类型:创建;任务对象:报修人为张伟;任务目标:期望完成时间为下午三点前。" + } + ] +} +``` + +## 1.4 .env 配置 + +将以下内容保存为 `.env`(放置位置即进程工作目录,也可通过命令行参数指定完整路径): + +```properties +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=local_file +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=./prompt-resources +A2AT_PROMPT_COMPLIANCE_ENABLED=false +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=your-model-name +A2AT_LLM_BASE_URL=https://your-llm-gateway.example.com/v1 +A2AT_LLM_API_KEY=your-api-key +A2AT_LLM_MAX_TOKENS=8000 +A2AT_LLM_TIMEOUT_SECONDS=180 +``` + +要点: + +- `A2AT_PROMPT_SOURCE_TYPE=local_file`:启用本地文件资源加载。 +- `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR`:本地资源根目录。**相对路径以 `.env` 文件所在目录为基准解析**(不是进程工作目录)。上面的 `./prompt-resources` 表示资源目录与 `.env` 同级。 +- 该目录在 `new A2ATClient(...)` / `new A2ATServer(...)` 装配期即被读取校验:**目录不存在会在构造期直接 fail-fast 抛异常,且不发起任何 LLM 调用**。 +- `A2AT_PROMPT_COMPLIANCE_ENABLED=false`:本示例关闭生成阶段的前置合规校验,聚焦「生成 → 服务端校验提参」主线。 +- `A2AT_LLM_MAX_TOKENS`:使用带推理(reasoning)的模型时需调大该值——推理会占用输出预算,过小会导致结构化为空。 +- `A2AT_LLM_PROVIDER` / `A2AT_LLM_MODEL` / `A2AT_LLM_BASE_URL` / `A2AT_LLM_API_KEY` 按实际接入的 LLM 服务填写;此处均以占位符展示。 + +## 1.5 工程与代码 + +### 1.5.1 pom.xml + +```xml + + + 4.0.0 + + demo + task-t-demo + 1.0.0 + + + 17 + UTF-8 + + + + + net.openan.a2a-t.sdk + a2a-t-client + 1.0.0 + + + net.openan.a2a-t.sdk + a2a-t-server + 1.0.0 + + + org.slf4j + slf4j-simple + 2.0.17 + runtime + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + demo.TaskTDemo + + + + + +``` + +### 1.5.2 TaskTDemo.java + +```java +package demo; + +import java.nio.file.Path; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.server.A2ATServer; + +public final class TaskTDemo { + public static void main(String[] args) throws Exception { + Path env = Path.of(args.length > 0 ? args[0] : ".env"); + String text = args.length > 1 ? args[1] + : "帮我创建一个办公设备故障报修任务,操作类型:创建;任务描述:基于报修人、部门和故障设备信息,发起办公设备故障报修请求,达成任务目标中定义的报修目标,并按照预期输出中定义的格式返回报修处理结果;任务对象:报修人为张伟、部门为市场部、故障设备为三楼东侧会议室旁的惠普打印机;任务目标:期望完成时间为下午三点前;任务上下文:故障现象为开不了机、电源灯一直闪,联系方式为13800001234;预期输出:输出报修结果,包含报修受理状态、报修任务唯一标识、故障设备信息、维修处理要求。"; + TemplateUri uri = TemplateUri.of("Task-T", "office-it", "printer-repair"); + Map schema = Map.of( + "type", "object", "properties", Map.of( + "报修人", slot("故障报修发起人的姓名"), + "部门", slot("报修人所属部门名称"), + "故障设备", slot("发生故障的办公设备名称、品牌及位置"), + "故障现象", slot("设备故障的具体现象"), + "期望完成时间", slot("期望完成维修的时间要求"), + "联系方式", slot("报修人的联系电话"))); + + A2ATClient client = new A2ATClient(env); + MetadataContent content = client.generateTaskPromptFromText(text, uri); + System.out.println(content.promptText()); + A2ATServer server = new A2ATServer(env); + System.out.println(server.validateTaskPromptAndDataFilling(content.promptText(), schema, uri).data()); + } + + private static Map slot(String description) { + return Map.of("type", "string", "description", description); + } +} +``` + +两个关键 API 说明: + +- **客户端 `generateTaskPromptFromText(String, TemplateUri)`**:显式传入模板 URI,跳过场景识别,直接按该模板做一次 LLM 槽位提取并渲染,返回 `MetadataContent`(`promptText()` 即渲染后的报文)。相较 `generateTaskPrompt(Object)` 的场景识别路径,它省去识别那次 LLM 调用,且报文结果里带显式 `templateUri`。 +- **服务端 `validateTaskPromptAndDataFilling(String, Map, TemplateUri)`**:校验任务报文合规性并按调用方 Schema 提取参数,返回 `FilledParamData`(`data()` 即提取结果 `Map`)。它需要显式 `TemplateUri`——本例 `TemplateUri.of("Task-T", "office-it", "printer-repair")` 逐段构造,与资源目录路径一一对应。 +- 服务端 `schema` 是调用方提供的参数 JSON Schema(本例用 `Map.of` 内联声明 6 个业务字段),键名(`报修人`/`部门`/`故障设备` 等)与客户端模板槽位名(`操作类型`/`任务对象` 等)**刻意不同**:客户端负责把业务字段渲染进模板分节,服务端再从渲染后的报文里按自身键名重新提取,展示了 SDK 的跨键名适配能力。 + +## 1.6 运行与预期输出 + +在工程根目录执行: + +```bash +mvn compile exec:java +``` + +> 首次运行需确保能联网解析 `net.openan.a2a-t.sdk` 依赖,并已正确填写 `A2AT_LLM_API_KEY` 等 LLM 配置。 + +预期输出分两段:先是客户端渲染出的 prompt 原文,紧随其后的是服务端提取出的 `data` Map(`FilledParamData.data()` 的字符串化结果)。 + +```text +## 操作类型(Operation Type) + +创建 + +## 任务类型(Task Type) + +办公设备故障报修 + +## 任务描述(Task Description) + +基于报修人、部门和故障设备信息,发起办公设备故障报修请求,达成任务目标中定义的报修目标,并按照预期输出中定义的格式返回报修处理结果 + +## 任务对象(Task Object) + +报修人为张伟、部门为市场部、故障设备为三楼东侧会议室旁的惠普打印机 + +## 任务目标(Task Target) + +期望完成时间为下午三点前 + +## 任务上下文(Task Context) + +故障现象为开不了机、电源灯一直闪,联系方式为13800001234 + +## 预期输出(Expected Output) + +输出报修结果,包含报修受理状态、报修任务唯一标识、故障设备信息、维修处理要求 + +{报修人=张伟, 部门=市场部, 故障设备=三楼东侧会议室旁的惠普打印机, 期望完成时间=下午三点前, 联系方式=13800001234, 故障现象=开不了机、电源灯一直闪} +``` + +耗时说明:本示例完整链路共触发 **2 次 LLM 调用**——客户端槽位提取 1 次、服务端语义校验提参 1 次(`FromText` 路径无场景识别调用)。实际耗时取决于所接入模型的响应速度。 + +## 1.7 常见问题 + +- **资源目录不存在,构造期即失败(零 LLM 调用)**:`A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 指向的目录不存在或路径非法时,`new A2ATClient(...)` / `new A2ATServer(...)` 在装配期 fail-fast 抛异常,且不会发起任何 LLM 调用。`local_file` 模式下业务内容本地优先、缺失回退内置,`scenarios/{语言}/scenarios.json` 可以省略(缺失时回退内置场景目录,见 [1.3.3](#133-scenariosjson))。 +- **输入超长 `input_text_too_long`**:FromText 生成与服务端校验入口均受 `A2AT_INPUT_TEXT_MAX_CHARS`(默认 16384)约束,超长的自由文本会在进入 LLM 之前直接以 `input_text_too_long` 快速失败,不消耗 LLM 调用。 +- **`slot_validation_error`(`missing_required`)或 `validation_semantic_rejected`**:当 `slot.json` 的 `required` 声明了某槽位而输入未能提供时,客户端抛 `slot_validation_error`(`missing_required`);而当报文的语义自相矛盾时,服务端以 `validation_semantic_rejected` 拒绝。二者都要求「模板 `要求:` 描述 ↔ `slot.json` 的 `description`/`x-a2at-value-constraint` ↔ 输入内容」口径一致。典型反例:任务描述写「尽快恢复设备可用」(定性目标)、任务目标却写「下午三点前」(量化目标)——量化/定性目标不可同时选择,会被服务端以 `semantic_mismatch` 拒绝。 +- **Windows 控制台中文乱码**:Windows 控制台默认编码非 UTF-8 时展示中文会乱。可先执行 `set JAVA_TOOL_OPTIONS=-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dfile.encoding=UTF-8`(或 `chcp 65001`)再运行,使输出以 UTF-8 呈现。 \ No newline at end of file diff --git a/env.example b/env.example index 1caa7d9f..e48f87ff 100644 --- a/env.example +++ b/env.example @@ -4,9 +4,11 @@ A2AT_LANGUAGE=en-US # Selects the prompt source backend (classpath | local_file). A2AT_PROMPT_SOURCE_TYPE=classpath # Required when A2AT_PROMPT_SOURCE_TYPE=local_file (assembly-time fail-fast if -# unset or the path does not exist). Only business content (templates/slots/ -# scenarios of Task-T/Notification-T/Authorization-T) is read from this root; -# negotiation resources and LLM prompts are always loaded from the classpath. +# unset or the path does not exist). Business content (templates/slots/scenarios +# of Task-T/Notification-T/Authorization-T) is loaded from this root with a +# local-first overlay: a missing resource falls back to the built-in classpath +# copy (each resource path warns once about the fallback). Negotiation resources +# and LLM prompts are always loaded from the classpath. A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= # Prompt compliance From 126ff5e6289a2c5b89186734186762000e139eea Mon Sep 17 00:00:00 2001 From: mamusen18 Date: Mon, 14 Sep 2026 14:50:26 +0800 Subject: [PATCH 267/295] test(corpus): add ran-energy-saving modify input cases with unified schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 4 from-text and 4 from-data input cases for the ran-energy-saving modify direction, using a unified baseline schema: UTC-normalized time outputs (HH:mm:ssZ) with timezone-mandatory inputs, an energyTarget two-form contract, and energyTargetDirection with a default-reduce fallback. Enable the required slot list (操作类型/任务对象/任务描述) in the ran-energy-saving zh-CN slot.json so generation-side missing-slot validation takes effect. Signed-off-by: mamusen18 --- .../input_case_from_data.json | 438 ++++++++++++++++++ .../input_case_from_text.json | 235 ++++++++++ .../ran-energy-saving/v1/zh-CN/slot.json | 2 +- 3 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_data.json create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_text.json diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_data.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_data.json new file mode 100644 index 00000000..65e27888 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_data.json @@ -0,0 +1,438 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-意图清晰参数完整", + "caseDesc": "修改档-结构化数据完整(操作类型+区域+目标+制式+时段),生成并校验提取", + "caseCategory": "正常", + "input": [ + { + "api": "generateTaskPromptFromDataWithSchema", + "args": { + "data": { + "operationType": "modify", + "region": "松山湖管委会", + "energyTarget": "30%", + "energyTargetDirection": "reduce", + "cellMode": "NR", + "startTime": "00:00:00+08:00", + "endTime": "12:00:00+08:00" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": [ + "create", + "modify" + ] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": [ + "reduce", + "increase" + ] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": [ + "operationType", + "region" + ] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": [ + "create", + "modify" + ] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": [ + "reduce", + "increase" + ] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": [ + "operationType", + "region" + ] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1", + "promptText": { + "$fromStep": 1, + "$field": "promptText" + } + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": { + "operationType": "modify", + "region": "松山湖管委会", + "startTime": "16:00:00Z", + "endTime": "04:00:00Z", + "energyTarget": "30%", + "energyTargetDirection": "reduce" + }, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + }, + { + "id": "TC00000002", + "caseDimension": "04-关键信息缺失", + "caseDesc": "结构化数据缺少节能区域字段,生成侧校验应拦截", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromDataWithSchema", + "args": { + "data": { + "operationType": "modify", + "cellMode": "NR", + "startTime": "00:00:00+08:00", + "endTime": "12:00:00+08:00" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": [ + "create", + "modify" + ] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": [ + "reduce", + "increase" + ] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": [ + "operationType", + "region" + ] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + } + ], + "expected": [ + { + "result": "error", + "data": {}, + "error": { + "errCode": "slot.not_provided", + "errMessage": "" + } + } + ] + }, + { + "id": "TC00000003", + "caseDimension": "05-意图描述模糊", + "caseDesc": "结构化数据节能目标未按量化格式(百分比)提供(口语化模糊表述),服务端语义校验应拒绝", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromDataWithSchema", + "args": { + "data": { + "operationType": "modify", + "region": "松山湖管委会", + "energyTarget": "再往下压一压", + "cellMode": "NR", + "startTime": "00:00:00+08:00", + "endTime": "12:00:00+08:00" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": [ + "create", + "modify" + ] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": [ + "reduce", + "increase" + ] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": [ + "operationType", + "region" + ] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": [ + "create", + "modify" + ] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": [ + "reduce", + "increase" + ] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": [ + "operationType", + "region" + ] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1", + "promptText": { + "$fromStep": 1, + "$field": "promptText" + } + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "error", + "data": {}, + "error": { + "errCode": "negotiation.semantic_rejected", + "errMessage": "" + } + } + ] + }, + { + "id": "TC00000004", + "caseDimension": "06-值域违规检测", + "caseDesc": "结构化数据操作类型“删除”不在创建/修改取值范围内,生成侧拒绝", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromDataWithSchema", + "args": { + "data": { + "operationType": "delete", + "region": "东莞松山湖片区" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": [ + "create", + "modify" + ] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": [ + "reduce", + "increase" + ] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": [ + "operationType", + "region" + ] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + } + ], + "expected": [ + { + "result": "error", + "data": {}, + "error": { + "errCode": "slot.constraint_violated", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_text.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_text.json new file mode 100644 index 00000000..0efdcbac --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_text.json @@ -0,0 +1,235 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-意图清晰参数完整", + "caseDesc": "修改档-无线网络节能-标准修改诉求(区域+量化目标+制式+时段,北京时间输入→期望UTC归一化HH:mm:ssZ)", + "caseCategory": "正常", + "input": [ + { + "api": "generateTaskPromptFromText", + "args": { + "text": "把东莞松山湖片区的无线网络节能任务调整为修改:节能的小区制式:NR,需要节能的时间范围(北京时间,UTC+8):起始时间“00:00:00”,结束时间“12:00:00”,能耗目标:总功耗降低30%", + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": [ + "create", + "modify" + ] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": [ + "reduce", + "increase" + ] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": [ + "operationType", + "region" + ] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1", + "promptText": { + "$fromStep": 1, + "$field": "promptText" + } + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": { + "operationType": "modify", + "region": "东莞松山湖片区", + "startTime": "16:00:00Z", + "endTime": "04:00:00Z", + "energyTarget": "30%", + "energyTargetDirection": "reduce" + }, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + }, + { + "id": "TC00000002", + "caseDimension": "04-关键信息缺失", + "caseDesc": "修改诉求缺少节能区域,校验required应拦截", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromText", + "args": { + "text": "修改无线网络节能任务,节能的小区制式:NR,需要节能的时间范围(北京时间,UTC+8):起始时间“00:00:00”,结束时间“12:00:00”", + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + } + ], + "expected": [ + { + "result": "error", + "data": {}, + "error": { + "errCode": "slot.not_provided", + "errMessage": "" + } + } + ] + }, + { + "id": "TC00000003", + "caseDimension": "05-意图描述模糊", + "caseDesc": "节能目标未按量化格式(百分比)提供(口语化模糊表述),服务端语义校验应拒绝", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromText", + "args": { + "text": "把松山湖管委会的无线网络节能任务调整为修改:节能的小区制式:NR,需要节能的时间范围(北京时间,UTC+8):起始时间“00:00:00”,结束时间“12:00:00”,能耗目标:再往下压一压", + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": [ + "create", + "modify" + ] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": [ + "reduce", + "increase" + ] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": [ + "operationType", + "region" + ] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1", + "promptText": { + "$fromStep": 1, + "$field": "promptText" + } + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "error", + "data": {}, + "error": { + "errCode": "negotiation.semantic_rejected", + "errMessage": "" + } + } + ] + }, + { + "id": "TC00000004", + "caseDimension": "06-值域违规检测", + "caseDesc": "操作类型“删除”不在创建/修改取值范围内", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromText", + "args": { + "text": "删除东莞松山湖片区的无线网络节能任务,该任务已不再需要", + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + } + ], + "expected": [ + { + "result": "error", + "data": {}, + "error": { + "errCode": "slot.constraint_violated", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/zh-CN/slot.json index 9ff06f6d..fb76d91d 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/zh-CN/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/zh-CN/slot.json @@ -52,5 +52,5 @@ "x-a2at-value-constraint": "意图报告基本信息:网络节能报告唯一标识、节能任务唯一标识、总体意图执行结果(状态、详情、未达成原因)。意图报告详细信息:具体KPI执行结果(状态、详情、未达成原因)。" } }, - "required": [] + "required": ["操作类型", "任务对象", "任务描述"] } From c8b7a2e89c68f60eb70f4a7abac7407e7a7d594b Mon Sep 17 00:00:00 2001 From: mamusen18 Date: Mon, 14 Sep 2026 16:04:09 +0800 Subject: [PATCH 268/295] style(corpus): normalize ran-energy-saving modify input case JSON to benchmark style Reformat the two input case JSON files generated by inputCsvToJson.py (Python json.dump expands every array/object onto its own lines) to match the private-line-complaint benchmark formatting: short scalar arrays (enum/required) and the fixed-shape error object inlined, all other containers expanded with 2-space indent. Content is unchanged (verified semantically against the pre-reformat files); CorpusSelfGuardTest passes. Signed-off-by: mamusen18 --- .../input_case_from_data.json | 122 ++++-------------- .../input_case_from_text.json | 62 ++------- 2 files changed, 38 insertions(+), 146 deletions(-) diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_data.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_data.json index 65e27888..4f7aa2f3 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_data.json +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_data.json @@ -23,10 +23,7 @@ "operationType": { "type": "string", "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", - "enum": [ - "create", - "modify" - ] + "enum": ["create", "modify"] }, "region": { "type": "string", @@ -39,10 +36,7 @@ "energyTargetDirection": { "type": "string", "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", - "enum": [ - "reduce", - "increase" - ] + "enum": ["reduce", "increase"] }, "cellMode": { "type": "string", @@ -57,10 +51,7 @@ "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." } }, - "required": [ - "operationType", - "region" - ] + "required": ["operationType", "region"] }, "templateUri": "Task-T/network-layer/ran-energy-saving/v1" } @@ -74,10 +65,7 @@ "operationType": { "type": "string", "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", - "enum": [ - "create", - "modify" - ] + "enum": ["create", "modify"] }, "region": { "type": "string", @@ -90,10 +78,7 @@ "energyTargetDirection": { "type": "string", "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", - "enum": [ - "reduce", - "increase" - ] + "enum": ["reduce", "increase"] }, "cellMode": { "type": "string", @@ -108,10 +93,7 @@ "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." } }, - "required": [ - "operationType", - "region" - ] + "required": ["operationType", "region"] }, "templateUri": "Task-T/network-layer/ran-energy-saving/v1", "promptText": { @@ -125,10 +107,7 @@ { "result": "success", "data": {}, - "error": { - "errCode": "", - "errMessage": "" - } + "error": { "errCode": "", "errMessage": "" } }, { "result": "success", @@ -140,10 +119,7 @@ "energyTarget": "30%", "energyTargetDirection": "reduce" }, - "error": { - "errCode": "", - "errMessage": "" - } + "error": { "errCode": "", "errMessage": "" } } ] }, @@ -168,10 +144,7 @@ "operationType": { "type": "string", "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", - "enum": [ - "create", - "modify" - ] + "enum": ["create", "modify"] }, "region": { "type": "string", @@ -184,10 +157,7 @@ "energyTargetDirection": { "type": "string", "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", - "enum": [ - "reduce", - "increase" - ] + "enum": ["reduce", "increase"] }, "cellMode": { "type": "string", @@ -202,10 +172,7 @@ "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." } }, - "required": [ - "operationType", - "region" - ] + "required": ["operationType", "region"] }, "templateUri": "Task-T/network-layer/ran-energy-saving/v1" } @@ -215,10 +182,7 @@ { "result": "error", "data": {}, - "error": { - "errCode": "slot.not_provided", - "errMessage": "" - } + "error": { "errCode": "slot.not_provided", "errMessage": "" } } ] }, @@ -245,10 +209,7 @@ "operationType": { "type": "string", "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", - "enum": [ - "create", - "modify" - ] + "enum": ["create", "modify"] }, "region": { "type": "string", @@ -261,10 +222,7 @@ "energyTargetDirection": { "type": "string", "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", - "enum": [ - "reduce", - "increase" - ] + "enum": ["reduce", "increase"] }, "cellMode": { "type": "string", @@ -279,10 +237,7 @@ "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." } }, - "required": [ - "operationType", - "region" - ] + "required": ["operationType", "region"] }, "templateUri": "Task-T/network-layer/ran-energy-saving/v1" } @@ -296,10 +251,7 @@ "operationType": { "type": "string", "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", - "enum": [ - "create", - "modify" - ] + "enum": ["create", "modify"] }, "region": { "type": "string", @@ -312,10 +264,7 @@ "energyTargetDirection": { "type": "string", "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", - "enum": [ - "reduce", - "increase" - ] + "enum": ["reduce", "increase"] }, "cellMode": { "type": "string", @@ -330,10 +279,7 @@ "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." } }, - "required": [ - "operationType", - "region" - ] + "required": ["operationType", "region"] }, "templateUri": "Task-T/network-layer/ran-energy-saving/v1", "promptText": { @@ -347,18 +293,12 @@ { "result": "success", "data": {}, - "error": { - "errCode": "", - "errMessage": "" - } + "error": { "errCode": "", "errMessage": "" } }, { "result": "error", "data": {}, - "error": { - "errCode": "negotiation.semantic_rejected", - "errMessage": "" - } + "error": { "errCode": "negotiation.semantic_rejected", "errMessage": "" } } ] }, @@ -381,10 +321,7 @@ "operationType": { "type": "string", "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", - "enum": [ - "create", - "modify" - ] + "enum": ["create", "modify"] }, "region": { "type": "string", @@ -397,10 +334,7 @@ "energyTargetDirection": { "type": "string", "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", - "enum": [ - "reduce", - "increase" - ] + "enum": ["reduce", "increase"] }, "cellMode": { "type": "string", @@ -415,10 +349,7 @@ "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." } }, - "required": [ - "operationType", - "region" - ] + "required": ["operationType", "region"] }, "templateUri": "Task-T/network-layer/ran-energy-saving/v1" } @@ -428,11 +359,8 @@ { "result": "error", "data": {}, - "error": { - "errCode": "slot.constraint_violated", - "errMessage": "" - } + "error": { "errCode": "slot.constraint_violated", "errMessage": "" } } ] } -] +] \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_text.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_text.json index 0efdcbac..2444509d 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_text.json +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_text.json @@ -21,10 +21,7 @@ "operationType": { "type": "string", "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", - "enum": [ - "create", - "modify" - ] + "enum": ["create", "modify"] }, "region": { "type": "string", @@ -37,10 +34,7 @@ "energyTargetDirection": { "type": "string", "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", - "enum": [ - "reduce", - "increase" - ] + "enum": ["reduce", "increase"] }, "cellMode": { "type": "string", @@ -55,10 +49,7 @@ "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." } }, - "required": [ - "operationType", - "region" - ] + "required": ["operationType", "region"] }, "templateUri": "Task-T/network-layer/ran-energy-saving/v1", "promptText": { @@ -72,10 +63,7 @@ { "result": "success", "data": {}, - "error": { - "errCode": "", - "errMessage": "" - } + "error": { "errCode": "", "errMessage": "" } }, { "result": "success", @@ -87,10 +75,7 @@ "energyTarget": "30%", "energyTargetDirection": "reduce" }, - "error": { - "errCode": "", - "errMessage": "" - } + "error": { "errCode": "", "errMessage": "" } } ] }, @@ -112,10 +97,7 @@ { "result": "error", "data": {}, - "error": { - "errCode": "slot.not_provided", - "errMessage": "" - } + "error": { "errCode": "slot.not_provided", "errMessage": "" } } ] }, @@ -141,10 +123,7 @@ "operationType": { "type": "string", "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", - "enum": [ - "create", - "modify" - ] + "enum": ["create", "modify"] }, "region": { "type": "string", @@ -157,10 +136,7 @@ "energyTargetDirection": { "type": "string", "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", - "enum": [ - "reduce", - "increase" - ] + "enum": ["reduce", "increase"] }, "cellMode": { "type": "string", @@ -175,10 +151,7 @@ "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." } }, - "required": [ - "operationType", - "region" - ] + "required": ["operationType", "region"] }, "templateUri": "Task-T/network-layer/ran-energy-saving/v1", "promptText": { @@ -192,18 +165,12 @@ { "result": "success", "data": {}, - "error": { - "errCode": "", - "errMessage": "" - } + "error": { "errCode": "", "errMessage": "" } }, { "result": "error", "data": {}, - "error": { - "errCode": "negotiation.semantic_rejected", - "errMessage": "" - } + "error": { "errCode": "negotiation.semantic_rejected", "errMessage": "" } } ] }, @@ -225,11 +192,8 @@ { "result": "error", "data": {}, - "error": { - "errCode": "slot.constraint_violated", - "errMessage": "" - } + "error": { "errCode": "slot.constraint_violated", "errMessage": "" } } ] } -] +] \ No newline at end of file From e7dc30affa2e49c7aaff40b75ac02dd3d9ba3157 Mon Sep 17 00:00:00 2001 From: mamusen18 Date: Mon, 14 Sep 2026 16:04:17 +0800 Subject: [PATCH 269/295] fix(resources): enable required slots in ran-energy-saving en-US slot.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the required slot list enabled for zh-CN in commit 126ff5e (操作类型/任务对象/任务描述) using the English snake_case property names operation_type/task_object/task_description, following the same zh-CN/en-US convention as the service-recovery scenario. Missed in 126ff5e; generation-side missing-slot validation now behaves the same for both languages. Signed-off-by: mamusen18 --- .../Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json index 22a220af..c7c48c3a 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json @@ -52,5 +52,5 @@ "x-a2at-value-constraint": "Intent Report Basic Information: unique identifier of the network energy saving report, unique identifier of the energy saving task, execution result of the overall intent (status, details, reason for not achieving). Intent Report Detailed Information: execution result of specific KPIs (status, details, reason for not achieving)." } }, - "required": [] + "required": ["operation_type", "task_object", "task_description"] } From aba4c7d70cb2c237bfbc7003af06ad65dc7d701d Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Mon, 14 Sep 2026 16:54:36 +0800 Subject: [PATCH 270/295] feat(corpus): add Negotiation-T accuracy verification module and unify suite directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Negotiation-T accuracy verification module to the Java corpus, ported from the Python implementation, and restructure both existing and new suites under a shared suites/ directory. Negotiation-T (new): - engine/registry/NegotiationApis.java: register all 12 negotiation handlers (4 from-text, 4 from-data, 4 validate) with typed argument construction — NegotiationContext from JSON, typed content projected from the template URI's negotiation type segment (target / information / feasibility) and the performative - suites/negotiation/: from-text and from-data workflow suites, offline self-guard, and three RAN energy-saving scenarios covering target negotiation (rate guarantees), information negotiation (missing data) and feasibility negotiation (target achievability) - engine/registry/SdkRuntimeAssembler.java: add NegotiationRuntime record and negotiationRuntime() building the production NegotiationGenerationOrchestrator with the recording LLM seam - engine/CorpusWorkFlowSuite.java: generalize base class to support both task and negotiation runtimes via runtimeKind() and apiNames() abstract methods Directory restructure (both Task-T and Negotiation-T): - Move task/ → suites/task/ (package net.openan.a2at.sdk.corpus.suites.task) - Move self/ → suites/self/ (package net.openan.a2at.sdk.corpus.suites.self) - engine/discover/ScenarioScanner.java: update source tree and classpath discovery prefix to suites//resources/ - pom.xml test resources already match **/resources/** — unchanged Signed-off-by: zhangwenjun-huawei --- .gitignore | 4 + a2a-t-corpus/README.md | 39 ++- a2a-t-corpus/README_zh.md | 29 +- .../corpus/engine/CorpusWorkFlowSuite.java | 94 ++++-- .../engine/discover/ScenarioScanner.java | 5 +- .../engine/registry/NegotiationApis.java | 313 ++++++++++++++++++ .../engine/registry/SdkRuntimeAssembler.java | 55 ++- .../negotiation/NegotiationSelfGuardTest.java | 58 ++++ .../NegotiationTFromDataWorkFlowTest.java | 44 +++ .../NegotiationTFromTextWorkFlowTest.java | 44 +++ .../input_case_from_data.json | 78 +++++ .../input_case_from_text.json | 61 ++++ .../input_case_from_data.json | 79 +++++ .../input_case_from_text.json | 117 +++++++ .../input_case_from_data.json | 87 +++++ .../input_case_from_text.json | 117 +++++++ .../task/TaskSelfGuardTest.java} | 6 +- .../task/TaskTFromDataWorkFlowTest.java | 10 +- .../task/TaskTFromTextWorkFlowTest.java | 10 +- .../input_case_from_data.json | 0 .../input_case_from_text.json | 0 .../input_case_from_data.json | 0 .../input_case_from_text.json | 0 23 files changed, 1185 insertions(+), 65 deletions(-) create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/NegotiationApis.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationSelfGuardTest.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromDataWorkFlowTest.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromTextWorkFlowTest.java create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_data.json create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_text.json create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_data.json create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_text.json create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_data.json create mode 100644 a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_text.json rename a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/{self/CorpusSelfGuardTest.java => suites/task/TaskSelfGuardTest.java} (95%) rename a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/{ => suites}/task/TaskTFromDataWorkFlowTest.java (78%) rename a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/{ => suites}/task/TaskTFromTextWorkFlowTest.java (79%) rename a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/{ => suites}/task/resources/private-line-complaint/input_case_from_data.json (100%) rename a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/{ => suites}/task/resources/private-line-complaint/input_case_from_text.json (100%) rename a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/{ => suites}/task/resources/ran-energy-saving-modify/input_case_from_data.json (100%) rename a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/{ => suites}/task/resources/ran-energy-saving-modify/input_case_from_text.json (100%) diff --git a/.gitignore b/.gitignore index 32b1283a..982f790b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ eval-run*.log eval-smoke.json .jqwik-database __pycache__/ + +# Accuracy-verification corpus (a2a-t-corpus): transcripts written back into the scenario +# directories by each run are regenerated artifacts, never committed. +a2a-t-corpus/src/test/java/**/suites/*/resources/*/output_result_*.json diff --git a/a2a-t-corpus/README.md b/a2a-t-corpus/README.md index 72ec921e..de34abde 100644 --- a/a2a-t-corpus/README.md +++ b/a2a-t-corpus/README.md @@ -4,9 +4,9 @@ Data-driven accuracy verification for the A2A-T SDK: workflow cases written as J through the production SDK assembly path, with per-step API/LLM transcripts, latency and token metrics, and 95%+ accuracy target reporting for prompt tuning. -Phase 1 (current) implements **Task-T only**, as the benchmark pilot; Notification-T / Negotiation-T / -Authorization-T follow the same structure once Task-T stabilizes (suite class + `resources/` + registry entries, -zero fork). +Current extensions: **Task-T** (private-line complaint diagnosis) and **Negotiation-T** (RAN energy-saving target / +information / feasibility negotiation), each as the benchmark pilot; Notification-T / Authorization-T follow the same +structure once their reference scenarios stabilize (suite class + `resources/` + registry entries, zero fork). - **Pure test module**: no `src/main`, not in the BOM, excluded from release artifacts (`maven.deploy.skip/source.skip/javadoc.skip=true`) and from CI (`mvn ... -pl '!a2a-t-corpus'`). @@ -23,12 +23,19 @@ a2a-t-corpus/ ├── tools/ inputCsvToJson(.py/.sh), outputJsonToCsv(.py/.sh), csv-templates/ └── src/test/java/net/openan/a2at/sdk/corpus/ ├── engine/ framework core: config/loader/registry/engine/llm/assertion/report/discover + CorpusWorkFlowSuite - ├── task/ TaskTFromTextWorkFlowTest, TaskTFromDataWorkFlowTest, resources// - └── self/ corpus self-guard meta test (no LLM) + └── suites/ the per-extension workflow suites + ├── task/ TaskTFromTextWorkFlowTest, TaskTFromDataWorkFlowTest, TaskSelfGuardTest, + │ └── resources// input_case_from_text.json, input_case_from_data.json + │ (+ output_result_*.json written back by each run) + └── negotiation/ NegotiationTFromTextWorkFlowTest, NegotiationTFromDataWorkFlowTest, + NegotiationSelfGuardTest + └── resources// ran-energy-saving-target-negotiation, + ran-energy-saving-information-negotiation, + ran-energy-saving-feasibility-negotiation ``` -`task/resources//` holds `input_case_from_text.json` and `input_case_from_data.json` (case design, -hand-authored); `output_result_*.json` files are written back by the engine after each run. +`suites//resources//` holds `input_case_from_text.json` and `input_case_from_data.json` +(case design, hand-authored); `output_result_*.json` files are written back by the engine after each run. ## Quick start @@ -37,6 +44,8 @@ cp a2a-t-corpus/env.example a2a-t-corpus/.env # fill A2AT_LLM_BASE_URL / API_K mvn -pl a2a-t-corpus -am test -Dtest=TaskTFromTextWorkFlowTest mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario=private-line-complaint mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario='private-*' -Dcase.filter='TC0000000*' +# Negotiation-T scenarios (RAN energy-saving) +mvn -pl a2a-t-corpus test -Dtest=NegotiationTFromTextWorkFlowTest -Dcorpus.scenario='ran-energy-saving-*' ``` - `-Dcorpus.scenario`: scenario name glob (`*` wildcard, comma-separated); `-Dcase.filter`: case id glob. @@ -47,7 +56,7 @@ mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario='pr cases only). - `-Dcorpus.output.dir=

            `: redirect outputs instead of writing back into the scenario directories (summaries default to `target/corpus/`). -- Structure-only gate without an LLM: `mvn -pl a2a-t-corpus test -Dtest=CorpusSelfGuardTest`. +- Structure-only gates without an LLM: `mvn -pl a2a-t-corpus test -Dtest=TaskSelfGuardTest,NegotiationSelfGuardTest`. ## Case JSON contract (v1, first-hand definitions in `schemas/`) @@ -98,10 +107,10 @@ mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario='pr python a2a-t-corpus/tools/inputCsvToJson.py --template --out my-cases.csv # design table (with an example row) # fill the table, then convert with structural validation on by default python a2a-t-corpus/tools/inputCsvToJson.py --csv my-cases.csv \ - --out a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources//input_case_from_text.json + --out a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources//input_case_from_text.json # run a suite, then review (one row per case, full per-step request/response) python a2a-t-corpus/tools/outputJsonToCsv.py \ - --json a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources//output_result_from_text.json \ + --json a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources//output_result_from_text.json \ --out review.csv # optional: fill input JSON back into the design table python a2a-t-corpus/tools/inputCsvToJson.py --reverse --csv --out back.csv @@ -125,10 +134,10 @@ Python interpreter is installed. ## Adding a scenario (zero Java changes) -Create `task/resources//` with the two input JSON files - the `@TestFactory` suites discover scenarios at -runtime. Run `CorpusSelfGuardTest` (no LLM) as the structural gate first. +Create `suites//resources//` with the two input JSON files - the `@TestFactory` suites discover +scenarios at runtime. Run the matching `*SelfGuardTest` (no LLM) as the structural gate first. -## Later phases (after Task-T stabilizes) +## Later phases (after the reference scenarios stabilize) -For Notification-T / Negotiation-T / Authorization-T: add `corpus//` suites plus their -`resources/`, register the extension facade methods in `ApiRegistry`, and reuse the framework and tools as-is. \ No newline at end of file +For Notification-T / Authorization-T: add `suites//` suites plus their `resources/`, +register the extension facade methods in `ApiRegistry`, and reuse the framework and tools as-is. diff --git a/a2a-t-corpus/README_zh.md b/a2a-t-corpus/README_zh.md index d1519a2a..fde0b222 100644 --- a/a2a-t-corpus/README_zh.md +++ b/a2a-t-corpus/README_zh.md @@ -2,7 +2,7 @@ A2A-T SDK 的数据驱动准确性验证:把 JSON 工作流用例经生产 SDK 装配链路对**真实 LLM** 执行,产出逐步 API/LLM 完整转录、时延与 Token 指标,并以 95%+ 准确率达标线支撑提示词调优。 -阶段策略(当前)仅实现 **Task-T**,作为标杆迭代优化;Notification-T / Negotiation-T / Authorization-T 待 Task-T 稳定后按同结构扩展(套件类 + `resources/` + 注册表条目,零分叉)。 +当前实现扩展:**Task-T**(专线投诉诊断)与 **Negotiation-T**(无线节能目标/信息/可行性协商),均为标杆迭代;Notification-T / Authorization-T 待参考场景稳定后按同结构扩展(套件类 + `resources/` + 注册表条目,零分叉)。 - **纯测试模块**:无 `src/main`,不进 BOM、不参与发布(`maven.deploy.skip/source.skip/javadoc.skip=true`),且从 CI 排除(`mvn ... -pl '!a2a-t-corpus'`)。 - **必须配置真实 LLM**:缺少三个必填 `A2AT_LLM_*` 配置时 fail-fast 并给出配置指引。`.env` 键名与项目根 `env.example` 保持一致,corpus 模板即根模板的精调子集。 @@ -16,11 +16,18 @@ a2a-t-corpus/ ├── tools/ inputCsvToJson(.py/.sh)、outputJsonToCsv(.py/.sh)、csv-templates/ └── src/test/java/net/openan/a2at/sdk/corpus/ ├── engine/ 框架核心:config/loader/registry/engine/llm/assertion/report/discover + CorpusWorkFlowSuite - ├── task/ TaskTFromTextWorkFlowTest、TaskTFromDataWorkFlowTest、resources/<场景>/ - └── self/ 语料自守卫元测试(无 LLM) + └── suites/ 各扩展的准确性验证套件 + ├── task/ TaskTFromTextWorkFlowTest、TaskTFromDataWorkFlowTest、TaskSelfGuardTest、 + │ └── resources/<场景>/ input_case_from_text.json 与 input_case_from_data.json + │ (用例设计,人工构造);output_result_*.json 由引擎每次运行写回 + └── negotiation/ NegotiationTFromTextWorkFlowTest、NegotiationTFromDataWorkFlowTest、 + NegotiationSelfGuardTest + └── resources/<场景>/ ran-energy-saving-target-negotiation、 + ran-energy-saving-information-negotiation、 + ran-energy-saving-feasibility-negotiation ``` -`task/resources/<场景>/` 下为 `input_case_from_text.json` 与 `input_case_from_data.json`(用例设计,人工构造);`output_result_*.json` 由引擎在每次运行后写回。 +`suites/<扩展>/resources/<场景>/` 下为 `input_case_from_text.json` 与 `input_case_from_data.json`(用例设计,人工构造);`output_result_*.json` 由引擎在每次运行后写回。 ## 快速开始 @@ -29,13 +36,15 @@ cp a2a-t-corpus/env.example a2a-t-corpus/.env # 填写 A2AT_LLM_BASE_URL / API mvn -pl a2a-t-corpus -am test -Dtest=TaskTFromTextWorkFlowTest mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario=private-line-complaint mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario='private-*' -Dcase.filter='TC0000000*' +# 无线节能协商场景 +mvn -pl a2a-t-corpus test -Dtest=NegotiationTFromTextWorkFlowTest -Dcorpus.scenario='ran-energy-saving-*' ``` - `-Dcorpus.scenario`:场景名 glob(`*` 通配,逗号分隔);`-Dcase.filter`:用例 id glob。 - `-Dtest` 过滤在 `-am` 下作用于整个反应堆;父 POM 已配置 surefire `failIfNoSpecifiedTests=false`,上游模块无同名测试类不会导致构建失败。 - 每条已执行用例完成即打印到控制台;失败/崩溃用例同时打印完整 interaction 轨迹;转录文件只反映本次执行(过滤运行会以命中的用例整体覆盖文件)。 - `-Dcorpus.output.dir=`:把输出重定向到指定目录(缺省写回场景目录;summary 默认落 `target/corpus/`)。 -- 无 LLM 时先跑结构自守卫:`mvn -pl a2a-t-corpus test -Dtest=CorpusSelfGuardTest`。 +- 无 LLM 时先跑结构自守卫:`mvn -pl a2a-t-corpus test -Dtest=TaskSelfGuardTest,NegotiationSelfGuardTest`。 ## 用例 JSON 契约(v1,一手定义见 `schemas/`) @@ -75,10 +84,10 @@ mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario='pr python a2a-t-corpus/tools/inputCsvToJson.py --template --out my-cases.csv # 用例设计表(含一行示例) # 填表后转换(默认开启产物结构校验) python a2a-t-corpus/tools/inputCsvToJson.py --csv my-cases.csv \ - --out a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/<场景>/input_case_from_text.json + --out a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/<场景>/input_case_from_text.json # 跑套件后审视(一行=一个用例,含每步完整请求/响应) python a2a-t-corpus/tools/outputJsonToCsv.py \ - --json a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/<场景>/output_result_from_text.json \ + --json a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/<场景>/output_result_from_text.json \ --out review.csv # 可选:把 input JSON 回填为设计表 python a2a-t-corpus/tools/inputCsvToJson.py --reverse --csv --out back.csv @@ -97,8 +106,8 @@ tools/inputCsvToJson.sh --reverse --csv --out back.c ## 新增场景(零 Java 改动) -新建 `task/resources/<场景>/` 并放入两个 input JSON 即可——`@TestFactory` 套件运行时扫描发现场景。结构门禁先用 `CorpusSelfGuardTest`(无 LLM)跑一遍。 +新建 `suites/<扩展>/resources/<场景>/` 并放入两个 input JSON 即可——`@TestFactory` 套件运行时扫描发现场景。结构门禁先用对应扩展的 `*SelfGuardTest`(无 LLM)跑一遍。 -## 后续阶段(Task-T 稳定后) +## 后续阶段(参考场景稳定后) -Notification-T / Negotiation-T / Authorization-T:新增 `corpus/<扩展>/` 套件与对应 `resources/`,在 `ApiRegistry` 登记该扩展 facade 方法,框架与工具链原样复用。 \ No newline at end of file +Notification-T / Authorization-T:新增 `suites/<扩展>/` 套件与对应 `resources/`,在 `ApiRegistry` 登记该扩展 facade 方法,框架与工具链原样复用。 \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/CorpusWorkFlowSuite.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/CorpusWorkFlowSuite.java index f6b58e29..5d9d6878 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/CorpusWorkFlowSuite.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/CorpusWorkFlowSuite.java @@ -13,12 +13,11 @@ import org.junit.jupiter.api.TestInstance; import net.openan.a2at.sdk.corpus.engine.discover.ScenarioScanner; import net.openan.a2at.sdk.corpus.engine.engine.WorkflowEngine; +import net.openan.a2at.sdk.corpus.engine.llm.RecordingMeteredLlmClient; import net.openan.a2at.sdk.corpus.engine.loader.CaseFileLoader; import net.openan.a2at.sdk.corpus.engine.loader.InputCase; import net.openan.a2at.sdk.corpus.engine.registry.ApiRegistry; -import net.openan.a2at.sdk.corpus.engine.registry.ClientApis; import net.openan.a2at.sdk.corpus.engine.registry.SdkRuntimeAssembler; -import net.openan.a2at.sdk.corpus.engine.registry.ServerApis; import net.openan.a2at.sdk.corpus.engine.report.ResultAggregator; import net.openan.a2at.sdk.corpus.engine.report.TranscriptWriter; @@ -26,8 +25,8 @@ /** * Base class of every {@code -T} workflow suite. A concrete suite declares only its extension folder, its workflow - * type (from_text / from_data) and the consumed case file; scenario discovery, case loading, execution, expectation - * comparison and transcript/summary writing are all inherited. + * type (from_text / from_data), the consumed case file and the runtime kind; scenario discovery, case loading, + * execution, expectation comparison and transcript/summary writing are all inherited. */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) public abstract class CorpusWorkFlowSuite { @@ -46,13 +45,29 @@ public abstract class CorpusWorkFlowSuite { public static final String API_VALIDATE_TASK = "validateTaskPromptAndDataFilling"; - private static volatile SdkRuntimeAssembler.Runtime runtime; + /** Negotiation-T API names, used for load-time membership checks. */ + public static final String API_GENERATE_NEGOTIATION_PROPOSE_FROM_TEXT = "generateNegotiationProposePromptFromText"; + public static final String API_GENERATE_NEGOTIATION_ACCEPT_FROM_TEXT = "generateNegotiationAcceptPromptFromText"; + public static final String API_GENERATE_NEGOTIATION_REJECT_FROM_TEXT = "generateNegotiationRejectPromptFromText"; + public static final String API_GENERATE_NEGOTIATION_ABORT_FROM_TEXT = "generateNegotiationAbortPromptFromText"; + public static final String API_GENERATE_NEGOTIATION_PROPOSE_FROM_DATA = "generateNegotiationProposePromptFromData"; + public static final String API_GENERATE_NEGOTIATION_ACCEPT_FROM_DATA = "generateNegotiationAcceptPromptFromData"; + public static final String API_GENERATE_NEGOTIATION_REJECT_FROM_DATA = "generateNegotiationRejectPromptFromData"; + public static final String API_GENERATE_NEGOTIATION_ABORT_FROM_DATA = "generateNegotiationAbortPromptFromData"; + public static final String API_VALIDATE_NEGOTIATION_PROPOSE = "validateProposePromptAndDataFilling"; + public static final String API_VALIDATE_NEGOTIATION_ACCEPT = "validateAcceptPromptAndDataFilling"; + public static final String API_VALIDATE_NEGOTIATION_REJECT = "validateRejectPromptAndDataFilling"; + public static final String API_VALIDATE_NEGOTIATION_ABORT = "validateAbortPromptAndDataFilling"; + + private static volatile SdkRuntimeAssembler.Runtime taskRuntimeSingleton; + + private static volatile SdkRuntimeAssembler.NegotiationRuntime negotiationRuntimeSingleton; private final List results = new ArrayList<>(); private final Map scenarioDirs = new LinkedHashMap<>(); - /** Extension resource folder under the corpus package, e.g. {@code task}. */ + /** Extension resource folder under the suites package, e.g. {@code task} or {@code negotiation}. */ protected abstract String extensionFolder(); /** Consumed case file, e.g. {@code input_case_from_text.json}. */ @@ -61,11 +76,25 @@ public abstract class CorpusWorkFlowSuite { /** Flow type used for the transcript and summary file names: {@code from_text} or {@code from_data}. */ protected abstract String flowType(); + /** Runtime kind: {@code "task"} (default) or {@code "negotiation"}. */ + protected String runtimeKind() { + return "task"; + } + + /** API names for load-time membership checks. */ + protected abstract Set apiNames(); + @TestFactory Stream corpusCases() { - SdkRuntimeAssembler.Runtime runtime = runtime(); - ApiRegistry registry = taskRegistry(runtime); - WorkflowEngine engine = new WorkflowEngine(registry, runtime.recorder()); + ApiRegistry registry; + if ("negotiation".equals(runtimeKind())) { + SdkRuntimeAssembler.NegotiationRuntime rt = negotiationRuntime(); + registry = SdkRuntimeAssembler.buildNegotiationRegistry(rt); + } else { + SdkRuntimeAssembler.Runtime rt = taskRuntime(); + registry = SdkRuntimeAssembler.buildTaskRegistry(rt); + } + WorkflowEngine engine = new WorkflowEngine(registry, registryRecorder()); List scenarios = ScenarioScanner.filterScenarios( ScenarioScanner.discover(extensionFolder(), inputFileName()), @@ -108,11 +137,25 @@ void writeTranscripts() { System.out.println("Summary: " + summaryFile); } - private static synchronized SdkRuntimeAssembler.Runtime runtime() { - if (runtime == null) { - runtime = SdkRuntimeAssembler.taskRuntime(); + private static synchronized SdkRuntimeAssembler.Runtime taskRuntime() { + if (taskRuntimeSingleton == null) { + taskRuntimeSingleton = SdkRuntimeAssembler.taskRuntime(); + } + return taskRuntimeSingleton; + } + + private static synchronized SdkRuntimeAssembler.NegotiationRuntime negotiationRuntime() { + if (negotiationRuntimeSingleton == null) { + negotiationRuntimeSingleton = SdkRuntimeAssembler.negotiationRuntime(); + } + return negotiationRuntimeSingleton; + } + + private RecordingMeteredLlmClient registryRecorder() { + if ("negotiation".equals(runtimeKind())) { + return negotiationRuntime().recorder(); } - return runtime; + return taskRuntime().recorder(); } private static Map> groupByScenario( @@ -124,16 +167,25 @@ private static Map> groupByScenario( return grouped; } - /** Registers the Task-T phase-1 registry entries. */ - public static ApiRegistry taskRegistry(SdkRuntimeAssembler.Runtime runtime) { - ApiRegistry registry = new ApiRegistry(); - ClientApis.registerTaskApis(registry, runtime.clientGeneration()); - ServerApis.registerTaskApis(registry, runtime.taskValidator()); - return registry; - } - /** Task-T phase-1 API names, used for load-time membership checks without touching the LLM runtime. */ public static Set taskApiNames() { return Set.of(API_GENERATE_TASK_FROM_TEXT, API_GENERATE_TASK_FROM_DATA, API_VALIDATE_TASK); } + + /** Negotiation-T phase-1 API names, used for load-time membership checks without touching the LLM runtime. */ + public static Set negotiationApiNames() { + return Set.of( + API_GENERATE_NEGOTIATION_PROPOSE_FROM_TEXT, + API_GENERATE_NEGOTIATION_ACCEPT_FROM_TEXT, + API_GENERATE_NEGOTIATION_REJECT_FROM_TEXT, + API_GENERATE_NEGOTIATION_ABORT_FROM_TEXT, + API_GENERATE_NEGOTIATION_PROPOSE_FROM_DATA, + API_GENERATE_NEGOTIATION_ACCEPT_FROM_DATA, + API_GENERATE_NEGOTIATION_REJECT_FROM_DATA, + API_GENERATE_NEGOTIATION_ABORT_FROM_DATA, + API_VALIDATE_NEGOTIATION_PROPOSE, + API_VALIDATE_NEGOTIATION_ACCEPT, + API_VALIDATE_NEGOTIATION_REJECT, + API_VALIDATE_NEGOTIATION_ABORT); + } } \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/discover/ScenarioScanner.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/discover/ScenarioScanner.java index c5001e54..7502bb41 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/discover/ScenarioScanner.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/discover/ScenarioScanner.java @@ -17,7 +17,7 @@ * {@code resources/} folder that carries the requested case file ({@code input_case_from_text.json} or * {@code input_case_from_data.json}). New scenarios therefore require zero Java changes. * - *

            Discovery prefers the source tree ({@code src/test/java/...//resources/}), which is where Maven + *

            Discovery prefers the source tree ({@code src/test/java/.../suites//resources/}), which is where Maven * runs surefire ({@code user.dir} is the module base directory); it falls back to the test classpath for IDE runs. */ public final class ScenarioScanner { @@ -31,7 +31,7 @@ public Path inputFile(String flowFileName) { } private static final String RESOURCE_PATH_PREFIX = - "net/openan/a2at/sdk/corpus/"; + "net/openan/a2at/sdk/corpus/suites/"; private ScenarioScanner() {} @@ -47,6 +47,7 @@ public static List discover(String extensionFolder, String flowFileNam "a2at", "sdk", "corpus", + "suites", extensionFolder, "resources") .toAbsolutePath() diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/NegotiationApis.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/NegotiationApis.java new file mode 100644 index 00000000..feac554e --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/NegotiationApis.java @@ -0,0 +1,313 @@ +package net.openan.a2at.sdk.corpus.engine.registry; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; + +public final class NegotiationApis { + + private NegotiationApis() {} + + public static void registerNegotiationApis( + ApiRegistry registry, NegotiationGenerationOrchestrator orchestrator) { + + // ---- from-text generation ---- + + registry.register("generateNegotiationProposePromptFromText", args -> metadataToMap( + orchestrator.generateProposeFromText( + Args.text(args, "text"), + Args.context(args, "context"), + Args.templateUri(args, "templateUri")))); + registry.register("generateNegotiationAcceptPromptFromText", args -> metadataToMap( + orchestrator.generateAcceptFromText( + Args.text(args, "text"), + Args.context(args, "context"), + Args.templateUri(args, "templateUri")))); + registry.register("generateNegotiationRejectPromptFromText", args -> metadataToMap( + orchestrator.generateRejectFromText( + Args.text(args, "text"), + Args.context(args, "context"), + Args.templateUri(args, "templateUri")))); + registry.register("generateNegotiationAbortPromptFromText", args -> metadataToMap( + orchestrator.generateAbortFromText( + Args.text(args, "text"), + Args.context(args, "context"), + Args.templateUri(args, "templateUri")))); + + // ---- from-data generation ---- + + registry.register("generateNegotiationProposePromptFromData", args -> metadataToMap( + orchestrator.generateProposeFromData( + Args.proposeData(args, "data"), + Args.templateUri(args, "templateUri")))); + registry.register("generateNegotiationAcceptPromptFromData", args -> metadataToMap( + orchestrator.generateAcceptFromData( + Args.endingData(args, "data", NegotiationPerformative.ACCEPT), + Args.templateUri(args, "templateUri")))); + registry.register("generateNegotiationRejectPromptFromData", args -> metadataToMap( + orchestrator.generateRejectFromData( + Args.endingData(args, "data", NegotiationPerformative.REJECT), + Args.templateUri(args, "templateUri")))); + registry.register("generateNegotiationAbortPromptFromData", args -> metadataToMap( + orchestrator.generateAbortFromData( + Args.abortData(args, "data"), + Args.templateUri(args, "templateUri")))); + + // ---- validation ---- + + registry.register("validateProposePromptAndDataFilling", args -> filledToMap( + orchestrator.validateProposePromptAndDataFilling( + Args.text(args, "promptText"), + Args.optionalContext(args, "context"), + Args.map(args, "schema"), + Args.templateUri(args, "templateUri")))); + registry.register("validateAcceptPromptAndDataFilling", args -> filledToMap( + orchestrator.validateAcceptPromptAndDataFilling( + Args.text(args, "promptText"), + Args.optionalContext(args, "context"), + Args.map(args, "schema"), + Args.templateUri(args, "templateUri")))); + registry.register("validateRejectPromptAndDataFilling", args -> filledToMap( + orchestrator.validateRejectPromptAndDataFilling( + Args.text(args, "promptText"), + Args.optionalContext(args, "context"), + Args.map(args, "schema"), + Args.templateUri(args, "templateUri")))); + registry.register("validateAbortPromptAndDataFilling", args -> filledToMap( + orchestrator.validateAbortPromptAndDataFilling( + Args.text(args, "promptText"), + Args.optionalContext(args, "context"), + Args.map(args, "schema"), + Args.templateUri(args, "templateUri")))); + } + + static Map metadataToMap(MetadataContent content) { + Map map = new LinkedHashMap<>(); + map.put("templateUri", content.templateUri()); + map.put("promptText", content.promptText()); + map.put("extensionUri", content.extensionUri()); + return map; + } + + static Map filledToMap(FilledParamData filled) { + Map map = new LinkedHashMap<>(); + map.put("data", filled.data() == null ? Map.of() : filled.data()); + return map; + } + + // -- helper types -- + + private static final class Args { + + private Args() {} + + static String text(Map args, String name) { + Object value = args.get(name); + if (!(value instanceof String str)) { + throw new IllegalArgumentException("step argument " + name + " must be a string: " + value); + } + return str; + } + + @SuppressWarnings("unchecked") + static Map map(Map args, String name) { + Object value = args.get(name); + if (!(value instanceof Map)) { + throw new IllegalArgumentException("step argument " + name + " must be an object: " + value); + } + Map typed = (Map) value; + if (typed.isEmpty()) { + throw new IllegalArgumentException("step argument " + name + " must not be an empty object"); + } + return typed; + } + + static TemplateUri templateUri(Map args, String name) { + return ClientApis.templateUri(text(args, name)); + } + + static NegotiationContext context(Map args, String name) { + return contextFromMap(map(args, name)); + } + + static NegotiationContext optionalContext(Map args, String name) { + Object raw = args.get(name); + if (raw == null) { + return null; + } + if (!(raw instanceof Map)) { + throw new IllegalArgumentException("step argument " + name + " must be an object: " + raw); + } + @SuppressWarnings("unchecked") + Map typed = (Map) raw; + return contextFromMap(typed); + } + + static NegotiationProposeData proposeData(Map args, String name) { + Map raw = map(args, name); + Map content = map(raw, "content"); + NegotiationType type = negotiationTypeOf(args); + return new NegotiationProposeData( + contextFromMap(map(raw, "context")), + (NegotiationProposeContent) buildContent(type, NegotiationPerformative.PROPOSE, content)); + } + + static NegotiationEndingData endingData( + Map args, String name, NegotiationPerformative performative) { + Map raw = map(args, name); + Map content = map(raw, "content"); + NegotiationType type = negotiationTypeOf(args); + return new NegotiationEndingData( + contextFromMap(map(raw, "context")), + (NegotiationEndingContent) buildContent(type, performative, content)); + } + + static NegotiationAbortData abortData(Map args, String name) { + Map raw = map(args, name); + return new NegotiationAbortData( + contextFromMap(map(raw, "context")), + new NegotiationAbortContent(text(map(raw, "content"), "termination_reason"))); + } + } + + // -- content factory -- + + private static final Map TYPE_MAP = Map.of( + "information-negotiation", NegotiationType.INFORMATION, + "target-negotiation", NegotiationType.TARGET, + "feasibility-negotiation", NegotiationType.FEASIBILITY); + + private static NegotiationType negotiationTypeOf(Map args) { + String raw = Args.text(args, "templateUri"); + TemplateUri parsed = TemplateUri.parse(raw) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); + String segment = parsed.pathSegments().get(0); + NegotiationType type = TYPE_MAP.get(segment); + if (type == null) { + throw new IllegalArgumentException("Unrecognized negotiation type segment: " + segment); + } + return type; + } + + private static Object buildContent( + NegotiationType type, NegotiationPerformative performative, Map raw) { + if (performative == NegotiationPerformative.ABORT) { + return new NegotiationAbortContent(required(raw, "termination_reason", "abort content")); + } + if (performative == NegotiationPerformative.ACCEPT || performative == NegotiationPerformative.REJECT) { + return buildEndingContent(type, parseConclusion(raw), raw); + } + return buildProposeContent(type, raw); + } + + private static NegotiationConclusion parseConclusion(Map raw) { + Object rawValue = raw.get("conclusion"); + if (rawValue == null) { + throw new IllegalArgumentException("ending content must declare its conclusion"); + } + return NegotiationConclusion.valueOf(String.valueOf(rawValue)); + } + + private static NegotiationEndingContent buildEndingContent( + NegotiationType type, NegotiationConclusion conclusion, Map raw) { + return switch (type) { + case INFORMATION -> + new InformationEndingContent(conclusion, items(raw.get("items"))); + case TARGET -> + new TargetEndingContent(conclusion, optionalStr(raw, "confirmed_intent"), + optionalStr(raw, "failure_reason")); + case FEASIBILITY -> + new FeasibilityEndingContent(conclusion, required(raw, "feasibility_summary", "ending content")); + }; + } + + private static NegotiationProposeContent buildProposeContent(NegotiationType type, Map raw) { + return switch (type) { + case INFORMATION -> + new InformationProposeContent(items(raw.get("items")), optionalStr(raw, "relationship")); + case TARGET -> + new TargetProposeContent( + required(raw, "target_negotiation_description", "propose content"), + items(raw.get("intent_understanding")), + items(raw.get("alignment_and_clarification")), + items(raw.get("request_for_clarification")), + optionalStr(raw, "target_confirm_request")); + case FEASIBILITY -> { + Object rawAction = raw.get("action"); + if (rawAction == null) { + throw new IllegalArgumentException("Feasibility propose content must declare its action"); + } + yield new FeasibilityProposeContent( + required(raw, "feasibility_negotiation_description", "propose content"), + NegotiationAction.valueOf(String.valueOf(rawAction)), + items(raw.get("contents_to_evaluate")), + items(raw.get("infeasibility_details_and_proposal")), + optionalStr(raw, "feasibility_confirm_request")); + } + }; + } + + private static NegotiationContext contextFromMap(Map raw) { + return new NegotiationContext( + (String) raw.get("id"), + ((Number) raw.get("round")).intValue(), + raw.containsKey("maxRounds") + ? ((Number) raw.get("maxRounds")).intValue() + : NegotiationContext.DEFAULT_MAX_ROUNDS, + NegotiationPerformative.valueOf((String) raw.get("performative"))); + } + + private static List items(Object raw) { + if (raw == null) { + return null; + } + if (!(raw instanceof List rawList)) { + throw new IllegalArgumentException("negotiation items must be an array: " + raw); + } + List items = new ArrayList<>(); + for (Object element : rawList) { + @SuppressWarnings("unchecked") + Map itemMap = (Map) element; + items.add(new NegotiationItem( + required(itemMap, "name", "negotiation item"), + optionalStr(itemMap, "value"))); + } + return items; + } + + private static String required(Map raw, String key, String where) { + Object value = raw.get(key); + if (!(value instanceof String str) || str.isBlank()) { + throw new IllegalArgumentException(where + " requires a non-blank '" + key + "'"); + } + return str; + } + + private static String optionalStr(Map raw, String key) { + Object value = raw.get(key); + return value == null ? null : String.valueOf(value); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/SdkRuntimeAssembler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/SdkRuntimeAssembler.java index cd8ca4ce..a1d80338 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/SdkRuntimeAssembler.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/SdkRuntimeAssembler.java @@ -14,30 +14,35 @@ import net.openan.a2at.sdk.corpus.engine.llm.RecordingMeteredLlmClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.providers.OpenAIClient; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; /** * Assembles the SDK runtime the corpus exercises against. * *

            Every component is produced by the production builders ({@code DefaultA2ATClientBuilder} / - * {@code DefaultA2ATServerBuilder}); the sole test seam is the {@link RecordingMeteredLlmClient} injected through the - * builder {@code llmClient(...)} injection point. The unified {@link A2ATConfig} is constructed directly from the - * corpus test configuration (classpath prompt resources, fixed language), so the module never touches production - * {@code A2AT_LLM_*} environment values. + * {@code DefaultA2ATServerBuilder} / {@code NegotiationGenerationOrchestratorBuilder}); the sole test seam is the + * {@link RecordingMeteredLlmClient} injected through the builder {@code llmClient(...)} injection point. The unified + * {@link A2ATConfig} is constructed directly from the corpus test configuration (classpath prompt resources, fixed + * language), so the module never touches production {@code A2AT_LLM_*} environment values. */ public final class SdkRuntimeAssembler { - /** Fully assembled runtime with the single shared recording LLM client. */ + /** Fully assembled Task-T runtime with the single shared recording LLM client. */ public record Runtime( RecordingMeteredLlmClient recorder, ClientPromptGenerationOrchestrator clientGeneration, ContentValidator taskValidator) {} - private SdkRuntimeAssembler() {} + /** Fully assembled Negotiation-T runtime with the single shared recording LLM client. */ + public record NegotiationRuntime( + RecordingMeteredLlmClient recorder, + NegotiationGenerationOrchestrator orchestrator) {} - public static Runtime taskRuntime() { - CorpusEnvConfig env = CorpusEnvConfig.load(); + private SdkRuntimeAssembler() {} + private static RecordingMeteredLlmClient newRecorder(CorpusEnvConfig env) { LLMClientConfig llmClientConfig = new LLMClientConfig( env.provider(), env.model(), @@ -51,8 +56,12 @@ public static Runtime taskRuntime() { 100, false, null); - RecordingMeteredLlmClient recorder = - new RecordingMeteredLlmClient(new OpenAIClient(llmClientConfig), env.model()); + return new RecordingMeteredLlmClient(new OpenAIClient(llmClientConfig), env.model()); + } + + public static Runtime taskRuntime() { + CorpusEnvConfig env = CorpusEnvConfig.load(); + RecordingMeteredLlmClient recorder = newRecorder(env); LlmConfig llmConfig = new LlmConfig( env.provider(), @@ -89,4 +98,30 @@ public static Runtime taskRuntime() { return new Runtime(recorder, clientGeneration, taskValidator); } + + public static NegotiationRuntime negotiationRuntime() { + CorpusEnvConfig env = CorpusEnvConfig.load(); + RecordingMeteredLlmClient recorder = newRecorder(env); + + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(recorder) + .maxAttempts(env.maxAttempts()) + .build(); + + return new NegotiationRuntime(recorder, orchestrator); + } + + public static ApiRegistry buildTaskRegistry(Runtime runtime) { + ApiRegistry registry = new ApiRegistry(); + ClientApis.registerTaskApis(registry, runtime.clientGeneration()); + ServerApis.registerTaskApis(registry, runtime.taskValidator()); + return registry; + } + + public static ApiRegistry buildNegotiationRegistry(NegotiationRuntime runtime) { + ApiRegistry registry = new ApiRegistry(); + NegotiationApis.registerNegotiationApis(registry, runtime.orchestrator()); + return registry; + } } \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationSelfGuardTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationSelfGuardTest.java new file mode 100644 index 00000000..f00a754f --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationSelfGuardTest.java @@ -0,0 +1,58 @@ +package net.openan.a2at.sdk.corpus.suites.negotiation; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import net.openan.a2at.sdk.corpus.engine.CorpusWorkFlowSuite; +import net.openan.a2at.sdk.corpus.engine.discover.ScenarioScanner; +import net.openan.a2at.sdk.corpus.engine.loader.CaseFileLoader; +import net.openan.a2at.sdk.corpus.engine.loader.ExpectedStep; +import net.openan.a2at.sdk.corpus.engine.loader.InputCase; +import org.junit.jupiter.api.Test; + +/** + * Negotiation-T corpus self-guard: validates every Negotiation-T scenario case file structurally without any LLM call. + * Unknown keys, misaligned expectations, unknown api names, out-of-catalog error codes and category/expectation + * contradictions all fail here. + */ +public final class NegotiationSelfGuardTest { + + private static final String CATEGORY_NORMAL = "正常"; + private static final String CATEGORY_EXCEPTION = "异常"; + + @Test + void negotiationScenarioCaseFilesAreStructurallyValid() { + List problems = new ArrayList<>(); + for (String flowFile : List.of( + CorpusWorkFlowSuite.INPUT_CASE_FROM_TEXT, CorpusWorkFlowSuite.INPUT_CASE_FROM_DATA)) { + for (ScenarioScanner.Scenario scenario : ScenarioScanner.discover("negotiation", flowFile)) { + List cases = new CaseFileLoader() + .load(scenario.inputFile(flowFile), CorpusWorkFlowSuite.negotiationApiNames()); + for (InputCase inputCase : cases) { + checkCategoryContract(inputCase, problems); + } + } + } + assertTrue(problems.isEmpty(), "Negotiation self-guard failures:\n- " + String.join("\n- ", problems)); + } + + private static void checkCategoryContract(InputCase inputCase, List problems) { + long errorExpected = 0; + for (ExpectedStep expectedStep : inputCase.expected()) { + if ("error".equals(expectedStep.result())) { + errorExpected++; + if (expectedStep.errCode().isBlank()) { + problems.add(inputCase.id() + ": error expectations must declare an errCode from the SDK error catalog"); + } + } + } + if (CATEGORY_EXCEPTION.equals(inputCase.caseCategory()) && errorExpected == 0) { + problems.add(inputCase.id() + ": categorized as " + CATEGORY_EXCEPTION + " but no step expects an error"); + } + if (CATEGORY_NORMAL.equals(inputCase.caseCategory()) && errorExpected > 0) { + problems.add(inputCase.id() + ": categorized as " + CATEGORY_NORMAL + " but a step expects an error; use " + + CATEGORY_EXCEPTION + " or \"boundary\""); + } + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromDataWorkFlowTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromDataWorkFlowTest.java new file mode 100644 index 00000000..91a6de6f --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromDataWorkFlowTest.java @@ -0,0 +1,44 @@ +package net.openan.a2at.sdk.corpus.suites.negotiation; + +import java.util.Set; +import net.openan.a2at.sdk.corpus.engine.CorpusWorkFlowSuite; + +/** + * Negotiation-T structured-input workflow suite: for every scenario directory under + * {@code suites/negotiation/resources/}, runs the recorded SDK API flow of each case in + * {@code input_case_from_data.json} against a real LLM (the from-data generation leg is + * deterministic; the validation leg uses the real semantic pipeline). + * + *

            Default API flow (also shown in the case files): one {@code generateNegotiation*PromptFromData} step followed + * by its matching {@code validate*PromptAndDataFilling} step. + * + *

            Filters: {@code -Dcorpus.scenario} and {@code -Dcase.filter}, see + * {@link NegotiationTFromTextWorkFlowTest}. + */ +public final class NegotiationTFromDataWorkFlowTest extends CorpusWorkFlowSuite { + + @Override + protected String extensionFolder() { + return "negotiation"; + } + + @Override + protected String inputFileName() { + return INPUT_CASE_FROM_DATA; + } + + @Override + protected String flowType() { + return FLOW_FROM_DATA; + } + + @Override + protected String runtimeKind() { + return "negotiation"; + } + + @Override + protected Set apiNames() { + return negotiationApiNames(); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromTextWorkFlowTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromTextWorkFlowTest.java new file mode 100644 index 00000000..76e04fd6 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromTextWorkFlowTest.java @@ -0,0 +1,44 @@ +package net.openan.a2at.sdk.corpus.suites.negotiation; + +import java.util.Set; +import net.openan.a2at.sdk.corpus.engine.CorpusWorkFlowSuite; + +/** + * Negotiation-T natural-language workflow suite: for every scenario directory under + * {@code suites/negotiation/resources/}, runs the recorded SDK API flow of each case in + * {@code input_case_from_text.json} against a real LLM. + * + *

            Default API flow (also shown in the case files): one {@code generateNegotiation*PromptFromText} step followed + * by its matching {@code validate*PromptAndDataFilling} step; the concrete performative (propose / accept / reject / + * abort) is chosen per case. + * + *

            Filters: {@code -Dcorpus.scenario=ran-energy-saving-target-negotiation} (glob, comma-separated) and + * {@code -Dcase.filter=TC00000001} (glob on case id). + */ +public final class NegotiationTFromTextWorkFlowTest extends CorpusWorkFlowSuite { + + @Override + protected String extensionFolder() { + return "negotiation"; + } + + @Override + protected String inputFileName() { + return INPUT_CASE_FROM_TEXT; + } + + @Override + protected String flowType() { + return FLOW_FROM_TEXT; + } + + @Override + protected String runtimeKind() { + return "negotiation"; + } + + @Override + protected Set apiNames() { + return negotiationApiNames(); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_data.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_data.json new file mode 100644 index 00000000..4746d0cc --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_data.json @@ -0,0 +1,78 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-结构化数据完整", + "caseDesc": "无线节能-可行性协商-propose-结构数据生成并校验", + "caseCategory": "正常", + "input": [ + { + "api": "generateNegotiationProposePromptFromData", + "args": { + "data": { + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "content": { + "feasibility_negotiation_description": "已收到无线网络节能任务请求,请协助评估该节能目标能否达成,目标内容:区域松山湖管委会,分时段速率保障目标{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps},节能时段08:00~12:00,小区制式NR。请评估。", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": [ + { + "name": "目标达成", + "value": "松山湖管委会区域的NR小区在08:00~12:00时段执行节能,需保障分时段速率目标,评估目标是否可达成" + }, + { + "name": "资源现状", + "value": "小区当前配置:NR制式,节能时段内可关闭的载波频点1650/1600" + } + ], + "infeasibility_details_and_proposal": null, + "feasibility_confirm_request": null + } + }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": { + "$fromStep": 1, + "$field": "promptText" + }, + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_text.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_text.json new file mode 100644 index 00000000..beb8a9ca --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_text.json @@ -0,0 +1,61 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-意图清晰参数完整", + "caseDesc": "无线节能-可行性协商-propose-自然语言生成并校验", + "caseCategory": "正常", + "input": [ + { + "api": "generateNegotiationProposePromptFromText", + "args": { + "text": "无线网络节能任务已完成目标澄清,区域松山湖管委会,分时段速率保障目标{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps},节能时段08:00~12:00,小区制式NR,请评估该节能目标能否达成。", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": { + "$fromStep": 1, + "$field": "promptText" + }, + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_data.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_data.json new file mode 100644 index 00000000..af498923 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_data.json @@ -0,0 +1,79 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-结构化数据完整", + "caseDesc": "无线节能-信息协商-propose-结构数据生成并校验", + "caseCategory": "正常", + "input": [ + { + "api": "generateNegotiationProposePromptFromData", + "args": { + "data": { + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "content": { + "items": [ + { + "name": "节能区域信息", + "value": "区域:松山湖管委会" + }, + { + "name": "节能速率保障目标", + "value": "分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}" + }, + { + "name": "可关闭下行频点", + "value": "下行频点号:1650,1600" + } + ], + "relationship": "以上信息项共同用于配置无线网络节能任务,缺失任一项均无法生成完整的节能方案" + } + }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": { + "$fromStep": 1, + "$field": "promptText" + }, + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_text.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_text.json new file mode 100644 index 00000000..2d0b460f --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_text.json @@ -0,0 +1,117 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-意图清晰参数完整", + "caseDesc": "无线节能-信息协商-propose-自然语言生成并校验", + "caseCategory": "正常", + "input": [ + { + "api": "generateNegotiationProposePromptFromText", + "args": { + "text": "无线网络节能任务缺少必要信息,请补充节能区域信息(如松山湖管委会)、节能速率保障目标(如20Mbps)、以及节能时段内允许关闭的下行频点号(如1650),请提供这些信息项以完成节能任务配置。", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": { + "$fromStep": 1, + "$field": "promptText" + }, + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + }, + { + "id": "TC00000002", + "caseDimension": "02-校验空报文", + "caseDesc": "信息协商-propose 后 validate 传入空报文,返回 negotiation.invalid_input", + "caseCategory": "异常", + "input": [ + { + "api": "generateNegotiationProposePromptFromText", + "args": { + "text": "请补充节能区域信息与速率保障目标。", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": "", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "error", + "data": {}, + "error": { + "errCode": "negotiation.invalid_input", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_data.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_data.json new file mode 100644 index 00000000..3034bd60 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_data.json @@ -0,0 +1,87 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-结构化数据完整", + "caseDesc": "无线节能-目标协商-propose-结构数据生成并校验", + "caseCategory": "正常", + "input": [ + { + "api": "generateNegotiationProposePromptFromData", + "args": { + "data": { + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "content": { + "target_negotiation_description": "无线网络节能任务:区域松山湖管委会,分时段速率保障目标{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps},小区制式NR,需确认目标是否清晰", + "intent_understanding": [ + { + "name": "区域", + "value": "松山湖管委会" + }, + { + "name": "分时段速率保障目标", + "value": "{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}" + }, + { + "name": "小区制式", + "value": "NR" + } + ], + "alignment_and_clarification": null, + "request_for_clarification": [ + { + "name": "节能时间范围", + "value": "未明确指定默认全时段节能?" + } + ], + "target_confirm_request": null + } + }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": { + "$fromStep": 1, + "$field": "promptText" + }, + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_text.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_text.json new file mode 100644 index 00000000..d742fc2e --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_text.json @@ -0,0 +1,117 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-意图清晰参数完整", + "caseDesc": "无线节能-目标协商-propose-自然语言生成并校验", + "caseCategory": "正常", + "input": [ + { + "api": "generateNegotiationProposePromptFromText", + "args": { + "text": "我们需要确认无线网络节能任务的速率保障目标,区域为松山湖管委会,计划的分时段速率保障目标为{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps},节能的小区制式为NR,请确认这些目标是否清晰。", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": { + "$fromStep": 1, + "$field": "promptText" + }, + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + }, + { + "id": "TC00000002", + "caseDimension": "02-校验空报文", + "caseDesc": "目标协商-propose 后 validate 传入空报文,返回 negotiation.invalid_input", + "caseCategory": "异常", + "input": [ + { + "api": "generateNegotiationProposePromptFromText", + "args": { + "text": "无线网络节能任务,区域松山湖管委会,分时段速率保障目标{00:00~06:00,2Mbps}。", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": "", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "error", + "data": {}, + "error": { + "errCode": "negotiation.invalid_input", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/self/CorpusSelfGuardTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskSelfGuardTest.java similarity index 95% rename from a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/self/CorpusSelfGuardTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskSelfGuardTest.java index 02142899..18c16003 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/self/CorpusSelfGuardTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskSelfGuardTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sdk.corpus.self; +package net.openan.a2at.sdk.corpus.suites.task; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -12,14 +12,14 @@ import org.junit.jupiter.api.Test; /** - * Corpus self-guard (meta test): validates every Task-T scenario case file structurally without any LLM call. This is + * Task-T self-guard (meta test): validates every Task-T scenario case file structurally without any LLM call. This is * the fast gate hit first in IDE runs and local workflows - unknown keys, misaligned expectations, unknown api names, * out-of-catalog error codes and category/expectation contradictions all fail here. * *

            Id uniqueness is enforced per-file by the {@link CaseFileLoader}; ids may repeat across different flow files * within the same scenario. */ -public final class CorpusSelfGuardTest { +public final class TaskSelfGuardTest { /** Categories carrying a cross-step expectation contract (case data is Chinese). */ static final String CATEGORY_NORMAL = "正常"; diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/TaskTFromDataWorkFlowTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskTFromDataWorkFlowTest.java similarity index 78% rename from a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/TaskTFromDataWorkFlowTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskTFromDataWorkFlowTest.java index 841350ea..5a3e258c 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/TaskTFromDataWorkFlowTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskTFromDataWorkFlowTest.java @@ -1,10 +1,11 @@ -package net.openan.a2at.sdk.corpus.task; +package net.openan.a2at.sdk.corpus.suites.task; +import java.util.Set; import net.openan.a2at.sdk.corpus.engine.CorpusWorkFlowSuite; /** * Task-T structured-input workflow suite: for every scenario directory under - * {@code corpus/task/resources/}, runs the recorded SDK API flow of each case in + * {@code suites/task/resources/}, runs the recorded SDK API flow of each case in * {@code input_case_from_data.json} against a real LLM. * *

            Default API flow (also shown in the case files): {@code generateTaskPromptFromDataWithSchema} → @@ -28,4 +29,9 @@ protected String inputFileName() { protected String flowType() { return FLOW_FROM_DATA; } + + @Override + protected Set apiNames() { + return taskApiNames(); + } } \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/TaskTFromTextWorkFlowTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskTFromTextWorkFlowTest.java similarity index 79% rename from a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/TaskTFromTextWorkFlowTest.java rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskTFromTextWorkFlowTest.java index b5c8f294..08eeb20d 100644 --- a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/TaskTFromTextWorkFlowTest.java +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskTFromTextWorkFlowTest.java @@ -1,10 +1,11 @@ -package net.openan.a2at.sdk.corpus.task; +package net.openan.a2at.sdk.corpus.suites.task; +import java.util.Set; import net.openan.a2at.sdk.corpus.engine.CorpusWorkFlowSuite; /** * Task-T natural-language workflow suite: for every scenario directory under - * {@code corpus/task/resources/}, runs the recorded SDK API flow of each case in + * {@code suites/task/resources/}, runs the recorded SDK API flow of each case in * {@code input_case_from_text.json} against a real LLM. * *

            Default API flow (also shown in the case files): {@code generateTaskPromptFromText} → @@ -29,4 +30,9 @@ protected String inputFileName() { protected String flowType() { return FLOW_FROM_TEXT; } + + @Override + protected Set apiNames() { + return taskApiNames(); + } } \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/private-line-complaint/input_case_from_data.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/private-line-complaint/input_case_from_data.json similarity index 100% rename from a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/private-line-complaint/input_case_from_data.json rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/private-line-complaint/input_case_from_data.json diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/private-line-complaint/input_case_from_text.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/private-line-complaint/input_case_from_text.json similarity index 100% rename from a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/private-line-complaint/input_case_from_text.json rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/private-line-complaint/input_case_from_text.json diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_data.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/ran-energy-saving-modify/input_case_from_data.json similarity index 100% rename from a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_data.json rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/ran-energy-saving-modify/input_case_from_data.json diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_text.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/ran-energy-saving-modify/input_case_from_text.json similarity index 100% rename from a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/task/resources/ran-energy-saving-modify/input_case_from_text.json rename to a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/ran-energy-saving-modify/input_case_from_text.json From 03aa16ea086c573739582a39e7f9e62af12151ce Mon Sep 17 00:00:00 2001 From: w00609601 Date: Tue, 15 Sep 2026 10:42:41 +0800 Subject: [PATCH 271/295] fix(prompt): emit builtin-fallback warnings only after successful load with real resource paths Signed-off-by: w00609601 --- .../ClasspathPromptSlotSchemaLoader.java | 3 +- .../LocalFilePromptScenarioCatalogLoader.java | 8 +- .../LocalFilePromptSlotSchemaLoader.java | 20 +- .../loader/LocalFilePromptTemplateLoader.java | 20 +- .../catalog/TemplateQueryServiceTest.java | 34 +- .../loader/LocalFilePromptLoadersTest.java | 49 ++- .../loader/PromptResourceAccessTest.java | 54 +-- docs/en/custom_template_guide.md | 398 ------------------ docs/en/developer_guide.md | 2 +- ...00\345\217\221\346\214\207\345\215\227.md" | 2 +- ...41\346\235\277\347\244\272\344\276\213.md" | 398 ------------------ env.example | 2 +- 12 files changed, 122 insertions(+), 868 deletions(-) delete mode 100644 docs/en/custom_template_guide.md delete mode 100644 "docs/zh/\350\207\252\345\256\232\344\271\211\346\250\241\346\235\277\347\244\272\344\276\213.md" diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java index 75109b86..afb8c7e3 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java @@ -40,7 +40,8 @@ public ClasspathPromptSlotSchemaLoader(ClasspathPromptResourceLoader resourceLoa public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { java.util.Optional parsed = TemplateUri.parse(scenarioCode); if (parsed.isPresent()) { - return load(parsed.orElseThrow(), scenarioCode, language); + TemplateUri uri = parsed.orElseThrow(); + return load(uri, scenarioCode, language); } for (String slotType : SLOT_TYPES) { for (TemplateUri candidate : bareCodeCandidates(slotType, scenarioCode)) { diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java index 4131609a..0616c243 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java @@ -8,6 +8,7 @@ import java.util.Set; import net.openan.a2at.sdk.core.resources.PathSegments; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.resources.PromptResourceKey; /** * Loads shared scenario catalogs from one local prompt resource root. @@ -43,9 +44,10 @@ public List load(String language) { if (snapshot.containsKey(pathKey)) { return parse(snapshot.get(pathKey), pathKey, language); } + List scenarios = classpathLoader.load(language); BuiltinFallbackWarnings.warnOnce( - warnedFallbackPaths, "prompt_resources/scenarios/" + language + "/scenarios.json"); - return classpathLoader.load(language); + warnedFallbackPaths, PromptResourceKey.scenario(language, "scenarios.json").relativePath()); + return scenarios; } private List parse(String payload, String pathKey, String language) { @@ -63,4 +65,4 @@ private record ScenarioCatalog(@JsonProperty("scenarios") List new TemplateQueryService(LANGUAGE, "local_file", null)); - - assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); - } - - @Test - void localFileSourceTypeWithBlankRootFailsFast() { + @ParameterizedTest(name = "local-file source fails fast for an invalid root [{0}]") + @MethodSource("invalidLocalRoots") + void localFileSourceTypeWithInvalidRootFailsFast(String localRoot) { IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> new TemplateQueryService(LANGUAGE, "local_file", " ")); + IllegalArgumentException.class, () -> new TemplateQueryService(LANGUAGE, "local_file", localRoot)); assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); } - @Test - void localFileSourceTypeWithNonexistentRootFailsFast() { - Path missing = localRootDir.resolve("missing"); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new TemplateQueryService(LANGUAGE, "local_file", missing.toString())); - - assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); + static Stream invalidLocalRoots() { + Path missing = Path.of(System.getProperty("java.io.tmpdir"), "a2a-t-prompt", "missing-" + System.nanoTime()); + return Stream.of( + Arguments.of((String) null), + Arguments.of(" "), + Arguments.of(missing.toString())); } @Test diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java index 7a5ab917..b90dd30c 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java @@ -225,7 +225,10 @@ void loadTemplateFallsBackToBuiltinWhenMissingLocally() { assertEquals(templateLoader().loadTemplate("ran-energy-saving", "en-US"), template); List warnings = warningMessages(); assertEquals(1, warnings.size()); - assertTrueWarning(warnings.get(0), "prompt_resource_builtin_fallback path=prompt_resources/templates/ran-energy-saving/en-US/template.md"); + assertTrueWarning( + warnings.get(0), + "prompt_resource_builtin_fallback path=prompt_resources/templates/*/network-layer/ran-energy-saving/v1/en-US/template.md" + + " (or the layout without the network-layer segment)"); } @Test @@ -236,7 +239,12 @@ void loadSlotSchemaFallsBackToBuiltinWhenMissingLocally() { .loadSlotSchema("ran-energy-saving", "en-US"); assertEquals(slotLoader().loadSlotSchema("ran-energy-saving", "en-US").slotDefinitions(), schema.slotDefinitions()); - assertEquals(1, warningMessages().size()); + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertTrueWarning( + warnings.get(0), + "prompt_resource_builtin_fallback path=prompt_resources/slots/*/network-layer/ran-energy-saving/v1/en-US/slot.json" + + " (or the layout without the network-layer segment)"); } @Test @@ -251,17 +259,6 @@ void loadScenarioCatalogFallsBackToBuiltinWhenMissingLocally() { assertEquals(1, warningMessages().size()); } - @Test - void builtinFallbackWarnsOnlyOncePerResourcePath() { - Set warnedPaths = warnedPaths(); - LocalFilePromptTemplateLoader loader = new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths); - - loader.loadTemplate("ran-energy-saving", "en-US"); - loader.loadTemplate("ran-energy-saving", "en-US"); - - assertEquals(1, warningMessages().size(), "the same resource path must warn about its builtin fallback only once"); - } - @Test void missingTemplateFallsBackToClasspathThenThrowsNotFound() { ResourceNotFoundException exception = assertThrows( @@ -273,6 +270,32 @@ void missingTemplateFallsBackToClasspathThenThrowsNotFound() { "prompt_resources/templates/*/network-layer/incident_triage/v1/en-US/template.md" + " (or the layout without the network-layer segment)", exception.resourcePath()); + assertEquals(0, warningMessages().size(), "a double-missing template must not emit a builtin fallback WARN"); + } + + @Test + void missingSlotSchemaFallsBackToClasspathThenThrowsNotFound() { + ResourceNotFoundException exception = assertThrows( + ResourceNotFoundException.class, + () -> new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths()) + .loadSlotSchema("incident_triage", "en-US")); + + assertEquals( + "prompt_resources/slots/*/network-layer/incident_triage/v1/en-US/slot.json" + + " (or the layout without the network-layer segment)", + exception.resourcePath()); + assertEquals(0, warningMessages().size(), "a double-missing slot schema must not emit a builtin fallback WARN"); + } + + @Test + void missingScenarioCatalogFallsBackToClasspathThenThrowsNotFound() { + ResourceNotFoundException exception = assertThrows( + ResourceNotFoundException.class, + () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), scenarioLoader(), promptRootDir, warnedPaths()) + .load("xx")); + + assertEquals("prompt_resources/scenarios/xx/scenarios.json", exception.resourcePath()); + assertEquals(0, warningMessages().size(), "a double-missing scenario catalog must not emit a builtin fallback WARN"); } @Test diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java index 2b3737ab..1ee9f69a 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java @@ -13,6 +13,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.stream.Stream; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; @@ -21,6 +22,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.slf4j.LoggerFactory; class PromptResourceAccessTest { @@ -48,6 +52,7 @@ void attachAppender() { void detachAppender() { accessLogger.detachAppender(accessAppender); fallbackLogger.detachAppender(fallbackAppender); + accessAppender.stop(); fallbackAppender.stop(); } @@ -63,33 +68,22 @@ void classpathModeIgnoresConfiguredLocalRootWithSingleWarning() { warnings.get(0), "prompt_resource_local_root_ignored", "root=" + promptRootDir, "source=classpath"); } - @Test - void localFileModeFailsFastWhenLocalRootIsNull() { + @ParameterizedTest(name = "local-file mode fails fast for an invalid root [{0}]") + @MethodSource("invalidLocalRoots") + void localFileModeFailsFastForInvalidRoot(String localRoot) { IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, - () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", null))); + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", localRoot))); assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); } - @Test - void localFileModeFailsFastWhenLocalRootIsBlank() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", " "))); - - assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); - } - - @Test - void localFileModeFailsFastWhenLocalRootDoesNotExist() { - Path missing = promptRootDir.resolve("missing"); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", missing.toString()))); - - assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + static Stream invalidLocalRoots() { + Path missing = Path.of(System.getProperty("java.io.tmpdir"), "a2a-t-prompt", "missing-" + System.nanoTime()); + return Stream.of( + Arguments.of((String) null), + Arguments.of(" "), + Arguments.of(missing.toString())); } @Test @@ -182,7 +176,7 @@ void localFileModeFallsBackToBuiltinTemplateWhenMissingLocally() { assertContains( warnings.get(0), "prompt_resource_builtin_fallback", - "path=prompt_resources/templates/ran-energy-saving/en-US/template.md", + "path=prompt_resources/templates/*/network-layer/ran-energy-saving/v1/en-US/template.md", "source=classpath"); } @@ -195,7 +189,13 @@ void localFileModeFallsBackToBuiltinSlotSchemaWhenMissingLocally() { assertEquals("ran-energy-saving", schema.scenarioCode()); assertFalse(schema.slotDefinitions().isEmpty()); - assertEquals(1, warningMessages(fallbackAppender).size()); + List warnings = warningMessages(fallbackAppender); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), + "prompt_resource_builtin_fallback", + "path=prompt_resources/slots/*/network-layer/ran-energy-saving/v1/en-US/slot.json", + "source=classpath"); } @Test @@ -206,7 +206,13 @@ void localFileModeFallsBackToBuiltinScenarioCatalogWhenMissingLocally() { List scenarios = access.loadScenarios("en-US"); assertFalse(scenarios.isEmpty(), "a missing local scenarios.json must fall back to the built-in scenario catalog"); - assertEquals(1, warningMessages(fallbackAppender).size()); + List warnings = warningMessages(fallbackAppender); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), + "prompt_resource_builtin_fallback", + "path=prompt_resources/scenarios/en-US/scenarios.json", + "source=classpath"); } @Test diff --git a/docs/en/custom_template_guide.md b/docs/en/custom_template_guide.md deleted file mode 100644 index 283bf585..00000000 --- a/docs/en/custom_template_guide.md +++ /dev/null @@ -1,398 +0,0 @@ -# 1 Custom Template Guide - -This guide walks through an end-to-end runnable example that uses a **custom business template** (office equipment repair) with the `local_file` resource-loading mode to run the full A2A-T Task-T flow: client natural-language input -> slot extraction -> template rendering to produce a prompt -> server validation and parameter extraction. - -## 1.1 Overview - -### 1.1.1 What This Example Demonstrates - -Example business: reporting a fault on office equipment (printer, computer, monitor, projector, and so on). Given a one-sentence user description, the client SDK extracts slots against an **explicitly specified template** and renders an A2A-T task message; the server SDK then semantically validates that message and extracts structured parameters according to the caller-provided schema. - -The client uses `generateTaskPromptFromText(String, TemplateUri)`: it requires an explicit `TemplateUri`, therefore **skips scenario recognition** (an LLM call), performs slot extraction and rendering against the specified template directly, and returns `MetadataContent`, from which you read the rendered message via `promptText()`. The server uses `validateTaskPromptAndDataFilling(String, Map, TemplateUri)`: it validates the task message and extracts parameters against the provided schema, returning `FilledParamData`, from which you read the extracted `Map` via `data()`. - -### 1.1.2 Supported Versions and Prerequisites - -| Item | Requirement | -| --- | --- | -| JDK | 17+ | -| Build tool | Maven 3.8+ | -| SDK | `net.openan.a2a-t.sdk:a2a-t-client` and `net.openan.a2a-t.sdk:a2a-t-server`, version `1.0.0` | -| LLM | An accessible OpenAI-compatible service and API key | -| Resource mode | `local_file` (business content loaded from a local directory) | - -> This example only demonstrates the A2A-T SDK itself; it does not bring in a2a-java, a registry center, or an HTTP pipeline. For a complete A2A HTTP+JSON/REST integration, see the Developer Guide and `a2a-t-sample`. - -## 1.2 Local Resource Directory - -In `local_file` mode, business content is loaded from a local root directory. The root directory is specified by `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` (see [1.4](#14-env-configuration)). The directory structure is: - -``` -prompt-resources/ -├── templates/ -│ └── Task-T/ -│ └── office-it/ -│ └── printer-repair/ -│ └── v1/ -│ └── en-US/ -│ └── template.md -├── slots/ -│ └── Task-T/ -│ └── office-it/ -│ └── printer-repair/ -│ └── v1/ -│ └── en-US/ -│ └── slot.json -└── scenarios/ - └── en-US/ - └── scenarios.json -``` - -Key points: - -- **The three kinds of business content — `templates` / `slots` / `scenarios` — can be localized, and are local-first with built-in fallback.** `prompts` (LLM prompts) and negotiation (Negotiation-T) resources are always loaded from the classpath. Placing them in a local directory has no effect; the SDK only ignores them and logs a WARN message. -- The directory levels for `templates` and `slots` are fixed as `{extension}/{business-domain}/{business-name}/{version}/{language}` — here `Task-T/office-it/printer-repair/v1/en-US/`. -- `scenarios` has no `Task-T` segment; it is directly `scenarios/{language}/scenarios.json`. This file **may be omitted**: when it is absent, assembly falls back to the built-in scenario catalog. -- The language directory (here `en-US`) must match `A2AT_LANGUAGE` in `.env`, otherwise the corresponding template/slot/scenario resources cannot be found. -- Note: although `generateTaskPromptFromText` itself skips scenario recognition, in `local_file` mode `new A2ATClient(...)` still calls `loadScenarios` during **assembly**. `scenarios/{language}/scenarios.json` may be omitted — a missing local file falls back to the built-in scenario catalog and assembly does not fail. This example only overrides a custom template, so no local scenario directory is required (see [1.3.3](#133-scenariosjson)). - -## 1.3 Resource Files - -### 1.3.1 template.md (template) - -The template defines the skeleton of the task message. `{{slot-name}}` is a slot placeholder that is replaced by the actual value extracted from the user input at render time; the placeholder name must match the slot name in `slot.json`. The sectioning, the `(required)/(optional)` markers, the `Requirements:` numbered list, and the `` cross-references mirror the built-in Task-T templates (e.g. `ran-energy-saving`). - -> The `(required)/(optional)` and `Requirements:` text is authoring guidance: it is stripped at render time and never appears in the final message (see the rendered output in [1.6](#16-running-and-expected-output)). Together with the `description`/`x-a2at-value-constraint` of `slot.json`, it drives slot extraction and semantic validation. - -````markdown -## Operation Type - -{{operation_type}}(required) - -Requirements: - -1. Provide the operation type; allowed values: create, modify - -## Task Type - -Office equipment repair - -## Task Description - -{{task_description}}(required) - -Requirements: - -1. Provide the task description dynamically based on the (create/modify): - - When the type is [create]: Based on the and , issue an office equipment repair request, achieve the repair objective defined in , and return the repair result in the format defined in . - - When the type is [modify]: Based on the and , update the office equipment repair request and achieve the repair objective defined in . - -## Task Object - -{{task_object}}(required) - -Requirements: - -1. Reporter:(required) - - Name of the person reporting the fault - - Example: reporter: "Zhang Wei" -2. Department:(required) - - Department name of the reporter - - Example: department: "Marketing" -3. Faulty equipment:(required) - - Name, brand and location of the faulty office equipment - - Example: faulty equipment: "The HP printer beside the meeting room on the east side of the third floor" - -## Task Target - -{{task_target}}(optional) - -Requirements: - -1. Define the quantitative or qualitative objective for the repair task; quantitative and qualitative objectives cannot be selected at the same time, defaulting to a qualitative objective. Details: - - Quantitative objective: expected completion time of the repair, in the form of a specific time or time range; example: expected completion time: before 3 p.m. - - Qualitative objective: process and complete the repair as soon as possible and restore the equipment to a working state - -## Task Context - -{{task_context}}(optional) - -Requirements: - -1. Fault symptom: description of the specific fault; example: fault symptom: cannot power on, the power indicator keeps flashing -2. Contact: phone number of the reporter; example: contact: 13800001234 - -## Expected Output - -{{expected_output}}(optional when creating, not needed when modifying) - -Requirements: - -1. Output the repair result of the office equipment repair task; the repair result must contain the following information: - -### Repair result basic information - -1. Repair acceptance status -2. Repair task unique identifier - -### Repair result detailed information - -1. Faulty equipment information -2. Repair handling requirements -```` - -### 1.3.2 slot.json (slot schema) - -The slot schema is a JSON Schema that declares each slot's type, business meaning, and value constraint. Keys under `properties` are the slot names and must correspond to the `{{placeholders}}` in `template.md`; the `description`/`examples`/`x-a2at-value-constraint` triple on each slot is the primary source the LLM slot extraction reads. The `required` array declares required slots (this example mirrors the built-in template by leaving it empty; requiredness is primarily expressed through the value constraint and description). - -```json -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "additionalProperties": false, - "properties": { - "operation_type": { - "type": "string", - "description": "Provide the operation type; allowed values: create, modify.", - "examples": [ - "create" - ], - "x-a2at-value-constraint": "Allowed values: create, modify" - }, - "task_description": { - "type": "string", - "description": "Provide the task description dynamically based on the operation type (create/modify).", - "examples": [ - "Based on the and , issue an office equipment repair request, achieve the repair objective defined in , and return the repair result in the format defined in ." - ], - "x-a2at-value-constraint": "When the type is [create]: based on the and , issue an office equipment repair request, achieve the repair objective defined in , and return the repair result in the format defined in . When the type is [modify]: based on the and , update the office equipment repair request and achieve the repair objective defined in ." - }, - "task_object": { - "type": "string", - "description": "Specify the repair object, including the reporter, department and faulty equipment.", - "examples": [ - "Reporter: Zhang Wei, department: Marketing, faulty equipment: the HP printer beside the meeting room on the east side of the third floor." - ], - "x-a2at-value-constraint": "Reporter, department and faulty equipment are all required. Reporter is the name of the person reporting the fault; department is the department name of the reporter; faulty equipment is the name, brand and location of the faulty office equipment." - }, - "task_target": { - "type": "string", - "description": "Define the quantitative or qualitative objective for the repair task.", - "examples": [ - "Expected completion time: before 3 p.m." - ], - "x-a2at-value-constraint": "1. Quantitative objective (expected completion time): the required time to complete the repair, in the form of a specific time or time range. 2. Qualitative objective: process and complete the repair as soon as possible and restore the equipment to a working state. Quantitative and qualitative objectives cannot be selected at the same time, defaulting to a qualitative objective." - }, - "task_context": { - "type": "string", - "description": "Provide the background and context of the repair task.", - "examples": [ - "Fault symptom: cannot power on, the power indicator keeps flashing; contact: 13800001234." - ], - "x-a2at-value-constraint": "1. Fault symptom: description of the specific fault. 2. Contact: phone number of the reporter." - }, - "expected_output": { - "type": "string", - "description": "Output the repair result of the office equipment repair task.", - "examples": [ - "" - ], - "x-a2at-value-constraint": "Repair result basic information: repair acceptance status, repair task unique identifier. Repair result detailed information: faulty equipment information, repair handling requirements." - } - }, - "required": [] -} -``` - -### 1.3.3 scenarios.json (scenarios) - -The scenario list is loaded during client assembly. **This example uses the `generateTaskPromptFromText` path and performs no scenario recognition**, so there is no need to register scenarios — `scenarios/{language}/scenarios.json` may be omitted entirely; when it is absent, assembly falls back to the built-in scenario catalog without failing. If local scenario recognition is needed, place this file, where `scenario_code` must match the template directory path: `Task-T/office-it/printer-repair/v1`. - -```json -{ - "scenarios": [ - { - "scenario_code": "Task-T/office-it/printer-repair/v1", - "scenario_name": "Office equipment repair", - "description": "Generates an A2A-T task request for reporting/repairing a fault on office equipment (printer, computer, monitor, projector, etc.), describing the reporter, department, faulty equipment, fault symptom, expected completion time and contact information.", - "example": "Help me create an office equipment repair task. Operation type: create. Task object: the reporter is Zhang Wei. Task target: the expected completion time is before 3 p.m." - } - ] -} -``` - -## 1.4 .env Configuration - -Save the following as `.env` (placed in the process working directory, or pass a full path via a command-line argument): - -```properties -A2AT_LANGUAGE=en-US -A2AT_PROMPT_SOURCE_TYPE=local_file -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=./prompt-resources -A2AT_PROMPT_COMPLIANCE_ENABLED=false -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=your-model-name -A2AT_LLM_BASE_URL=https://your-llm-gateway.example.com/v1 -A2AT_LLM_API_KEY=your-api-key -A2AT_LLM_MAX_TOKENS=8000 -A2AT_LLM_TIMEOUT_SECONDS=180 -``` - -Key points: - -- `A2AT_PROMPT_SOURCE_TYPE=local_file`: enable local-file resource loading. -- `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR`: the local resource root directory. **A relative path is resolved against the directory of the `.env` file** (not the process working directory). The `./prompt-resources` above means the resource directory sits next to `.env`. -- This directory is read and validated at assembly time of `new A2ATClient(...)` / `new A2ATServer(...)`: **a missing directory fails fast at construction without any LLM call**. -- `A2AT_PROMPT_COMPLIANCE_ENABLED=false`: this example disables the generation-stage compliance pre-check to focus on the "generate -> server validation/extraction" mainline. -- `A2AT_LLM_MAX_TOKENS`: use a larger value for reasoning models; a value that is too small causes the structured output to be empty. -- `A2AT_LLM_PROVIDER` / `A2AT_LLM_MODEL` / `A2AT_LLM_BASE_URL` / `A2AT_LLM_API_KEY` should be filled in per the actual LLM service; all of them are shown as placeholders here. - -## 1.5 Project and Code - -### 1.5.1 pom.xml - -```xml - - - 4.0.0 - - demo - task-t-demo - 1.0.0 - - - 17 - UTF-8 - - - - - net.openan.a2a-t.sdk - a2a-t-client - 1.0.0 - - - net.openan.a2a-t.sdk - a2a-t-server - 1.0.0 - - - org.slf4j - slf4j-simple - 2.0.17 - runtime - - - - - - - org.codehaus.mojo - exec-maven-plugin - 3.5.0 - - demo.TaskTDemo - - - - - -``` - -### 1.5.2 TaskTDemo.java - -```java -package demo; - -import java.nio.file.Path; -import java.util.Map; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.server.A2ATServer; - -public final class TaskTDemo { - public static void main(String[] args) throws Exception { - Path env = Path.of(args.length > 0 ? args[0] : ".env"); - String text = args.length > 1 ? args[1] - : "Help me create an office equipment repair task. Operation type: create. Task description: based on the reporter, department and faulty equipment information, issue an office equipment repair request, achieve the repair objective defined in the task target, and return the repair handling result in the format defined in the expected output. Task object: the reporter is Zhang Wei, the department is Marketing, and the faulty equipment is the HP printer beside the meeting room on the east side of the third floor. Task target: the expected completion time is before 3 p.m. Task context: the fault symptom is that it cannot power on and the power indicator keeps flashing, and the contact number is 13800001234. Expected output: output the repair result containing the repair acceptance status, the repair task unique identifier, the faulty equipment information and the repair handling requirements."; - TemplateUri uri = TemplateUri.of("Task-T", "office-it", "printer-repair"); - Map schema = Map.of( - "type", "object", "properties", Map.of( - "reporter", slot("Name of the person reporting the fault"), - "department", slot("Department name of the reporter"), - "device", slot("Name, brand and location of the faulty office equipment"), - "symptom", slot("Description of the specific fault"), - "deadline", slot("Expected completion time of the repair"), - "contact", slot("Phone number of the reporter"))); - - A2ATClient client = new A2ATClient(env); - MetadataContent content = client.generateTaskPromptFromText(text, uri); - System.out.println(content.promptText()); - A2ATServer server = new A2ATServer(env); - System.out.println(server.validateTaskPromptAndDataFilling(content.promptText(), schema, uri).data()); - } - - private static Map slot(String description) { - return Map.of("type", "string", "description", description); - } -} -``` - -Two key API choices worth calling out: - -- **The client uses `generateTaskPromptFromText(String, TemplateUri)`**: it takes an explicit template URI, skips scenario recognition, runs one LLM slot-extraction step against that template and renders the result, returning `MetadataContent` (`promptText()` is the rendered message). Compared with the scenario-recognition path of `generateTaskPrompt(Object)`, it saves the recognition LLM call, and the result carries an explicit `templateUri`. -- **The server uses `validateTaskPromptAndDataFilling(String, Map, TemplateUri)`**: it validates the task message and extracts parameters against the caller-provided schema, returning `FilledParamData` (`data()` is the extracted `Map`). It needs an explicit `TemplateUri`; this example builds it segment-by-segment with `TemplateUri.of("Task-T", "office-it", "printer-repair")`, mirroring the resource directory path. -- The server-side `schema` is the caller-provided JSON parameter schema (here declared inline with `Map.of`, six business fields). Its keys (`reporter`/`department`/`device` and so on) **intentionally differ** from the client template slot names (`operation_type`/`task_object` and so on): the client renders the business fields into template sections, and the server re-extracts them under its own key names from the rendered message — demonstrating the SDK's cross-key adaptation. - -## 1.6 Running and Expected Output - -Run in the project root directory: - -```bash -mvn compile exec:java -``` - -> On first run, make sure the `net.openan.a2a-t.sdk` dependencies can be resolved over the network and that the LLM configuration (such as `A2AT_LLM_API_KEY`) is filled in correctly. - -The expected output has two parts: first the prompt text rendered by the client, followed immediately by the `data` map extracted by the server (the stringified `FilledParamData.data()`). - -```text -## Operation Type - -create - -## Task Type - -Office equipment repair - -## Task Description - -based on the reporter, department and faulty equipment information, issue an office equipment repair request, achieve the repair objective defined in the task target, and return the repair handling result in the format defined in the expected output. - -## Task Object - -the reporter is Zhang Wei, the department is Marketing, and the faulty equipment is the HP printer beside the meeting room on the east side of the third floor. - -## Task Target - -the expected completion time is before 3 p.m. - -## Task Context - -the fault symptom is that it cannot power on and the power indicator keeps flashing, and the contact number is 13800001234. - -## Expected Output - -output the repair result containing the repair acceptance status, the repair task unique identifier, the faulty equipment information and the repair handling requirements. - -{symptom=it cannot power on and the power indicator keeps flashing, department=Marketing, device=the HP printer beside the meeting room on the east side of the third floor, deadline=before 3 p.m., reporter=Zhang Wei, contact=13800001234} -``` - -Timing note: this example's complete chain triggers **2 LLM calls in total** — one client slot extraction and one server semantic validation/extraction (the `FromText` path has no scenario-recognition call). Actual time depends on the response speed of the connected model. - -## 1.7 FAQ - -- **Resource directory missing; construction fails fast (zero LLM calls).** When the directory pointed to by `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` does not exist or is invalid, `new A2ATClient(...)` / `new A2ATServer(...)` throws at assembly time before any LLM call. In `local_file` mode, business content is local-first with built-in fallback and `scenarios/{language}/scenarios.json` may be omitted (a missing file falls back to the built-in scenario catalog; see [1.3.3](#133-scenariosjson)). -- **Oversized input (`input_text_too_long`).** Both FromText generation and server validation gates are bounded by `A2AT_INPUT_TEXT_MAX_CHARS` (default 16384); oversized free text fails fast with `input_text_too_long` before reaching the LLM. -- **`slot_validation_error` (`missing_required`) or `validation_semantic_rejected`.** When `slot.json` declares a required slot that the input does not provide, the client throws `slot_validation_error` (`missing_required`); when the message contradicts itself, the server rejects it with `validation_semantic_rejected`. Both require the "template `Requirements:` prose <-> `slot.json` `description`/`x-a2at-value-constraint` <-> input content" wording to stay consistent. A typical counter-example: the task description states "restore the equipment as soon as possible" (a qualitative objective) while the task target states "before 3 p.m." (a quantitative objective) — since quantitative and qualitative objectives cannot be selected at the same time, the server rejects the content with `semantic_mismatch`. -- **Windows console encoding.** On Windows the default console encoding may not be UTF-8, which can garble output. Run `set JAVA_TOOL_OPTIONS=-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dfile.encoding=UTF-8` (or `chcp 65001`) first so the output is rendered as UTF-8. The language directory must match `A2AT_LANGUAGE` (here `en-US`). \ No newline at end of file diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 4cc4e105..08620dab 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -1626,7 +1626,7 @@ Notes: 1. `A2AT_PROMPT_SOURCE_TYPE` takes `classpath` (default) or `local_file`; any other value reports `Unsupported prompt source type` at construction time. 2. In `local_file` mode, `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` is required: when unset, an error is reported prompting you to set `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR`; assembly also fails when the path does not exist or is not a directory. 3. Relative paths of the local root directory are resolved against the directory of the `.env` file; absolute paths are recommended. -4. In `classpath` mode, a configured local root directory is ignored with a warning log. +4. In `classpath` mode, a configured local root directory is ignored: the generation/validation path logs a warning, while the template-query path ignores it silently. 5. Business content is local-first with built-in fallback: a `template.md`, `slot.json` or `scenarios.json` missing from the local root falls back to the built-in classpath copy (each resource path warns once about the fallback); `scenarios.json` may be omitted entirely. **templateUri mapping** diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index badcfbf5..ebaf3b87 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -1621,7 +1621,7 @@ A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=/opt/a2at/prompt_resources 1. `A2AT_PROMPT_SOURCE_TYPE` 取值为 `classpath`(默认)或 `local_file`,其他取值在构造时报 `Unsupported prompt source type`。 2. `local_file` 模式下 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 必填:未设置时报错并提示设置 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR`;路径不存在或不是目录时同样装配失败。 3. 本地根目录的相对路径按 `.env` 文件所在目录解析,建议使用绝对路径。 -4. `classpath` 模式下即使配置了本地根目录也会被忽略,并输出告警日志。 +4. `classpath` 模式下即使配置了本地根目录也会被忽略:生成/校验路径会输出告警日志,模板查询路径则静默忽略。 **templateUri 回填** diff --git "a/docs/zh/\350\207\252\345\256\232\344\271\211\346\250\241\346\235\277\347\244\272\344\276\213.md" "b/docs/zh/\350\207\252\345\256\232\344\271\211\346\250\241\346\235\277\347\244\272\344\276\213.md" deleted file mode 100644 index a8b86a67..00000000 --- "a/docs/zh/\350\207\252\345\256\232\344\271\211\346\250\241\346\235\277\347\244\272\344\276\213.md" +++ /dev/null @@ -1,398 +0,0 @@ -# 1 自定义模板示例 - -本文档通过一个端到端可运行的示例,演示如何用**自定义业务模板**(办公设备故障报修)+ `local_file` 资源加载模式,跑通 A2A-T 的 Task-T 全流程:客户端自然语言输入 → 槽位提取 → 模板渲染生成 prompt → 服务端校验并提取参数。 - -## 1.1 概述 - -### 1.1.1 本示例演示什么 - -示例业务:办公设备(打印机、电脑、显示器、投影仪等)故障报修。用户用一句话描述故障,客户端 SDK 按**显式指定的模板**抽取槽位并渲染生成 A2A-T 任务报文;服务端 SDK 对该报文做语义校验并按调用方 Schema 提取出结构化参数。 - -客户端选用 `generateTaskPromptFromText(String, TemplateUri)`:它要求显式传入 `TemplateUri`,因此**跳过场景识别**这条 LLM 调用,直接按指定模板做槽位提取与渲染,返回 `MetadataContent`,用 `promptText()` 取渲染后的报文。服务端选用 `validateTaskPromptAndDataFilling(String, Map, TemplateUri)`:校验任务报文合规性并按传入 Schema 提取参数,返回 `FilledParamData`,用 `data()` 取提取出的 `Map`。 - -### 1.1.2 适用版本与前置条件 - -| 项目 | 要求 | -| --- | --- | -| JDK | 17+ | -| 构建工具 | Maven 3.8+ | -| SDK | `net.openan.a2a-t.sdk:a2a-t-client` 与 `net.openan.a2a-t.sdk:a2a-t-server`,版本 `1.0.0` | -| LLM | 可访问的 OpenAI 兼容服务及 API Key | -| 资源模式 | `local_file`(业务内容从本地目录加载) | - -> 本示例只演示 A2A-T SDK 本身,不引入 a2a-java、注册中心或 HTTP 链路。如需完整的 A2A HTTP+JSON/REST 串接,请参考《开发指南》与 `a2a-t-sample`。 - -## 1.2 本地资源目录 - -`local_file` 模式下,业务内容从本地根目录加载。根目录由 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 指定(见 [1.4](#14-env-配置))。目录结构如下: - -``` -prompt-resources/ -├── templates/ -│ └── Task-T/ -│ └── office-it/ -│ └── printer-repair/ -│ └── v1/ -│ └── zh-CN/ -│ └── template.md -├── slots/ -│ └── Task-T/ -│ └── office-it/ -│ └── printer-repair/ -│ └── v1/ -│ └── zh-CN/ -│ └── slot.json -└── scenarios/ - └── zh-CN/ - └── scenarios.json -``` - -要点: - -- **`templates` / `slots` / `scenarios` 三类业务内容可本地化且本地优先、缺失回退内置**。`prompts`(LLM 提示词)与协商(Negotiation-T)资源恒从 classpath 加载——把它们放到本地目录不会被读取,SDK 只会忽略并输出 WARN 日志。 -- `templates` 与 `slots` 的目录层级固定为 `{扩展名}/{业务域}/{业务名}/{版本}/{语言}`,即 `Task-T/office-it/printer-repair/v1/zh-CN/`。 -- `scenarios` 没有 `Task-T` 段,直接是 `scenarios/{语言}/scenarios.json`;该文件**可省略**——本地根目录无此文件时装配期回退内置场景目录。 -- 语言目录(本例为 `zh-CN`)必须与 `.env` 中的 `A2AT_LANGUAGE` 一致,否则对应的模板/槽位/场景资源找不到。 -- 注意:虽然 `generateTaskPromptFromText` 本身跳过场景识别,但 `local_file` 模式下 `new A2ATClient(...)` 在**装配期**仍会调用 `loadScenarios` 做组件装配。`scenarios/{语言}/scenarios.json` 可以省略,本地缺该文件时回退内置场景目录、装配不会失败。本示例只覆盖自定义模板,可不放置场景目录(见 [1.3.3](#133-scenariosjson))。 - -## 1.3 资源文件说明 - -### 1.3.1 template.md(模板) - -模板定义任务报文的骨架,`{{槽位名}}` 是槽位占位符,渲染时会被替换为从用户输入提取出的实际值;占位符名必须与 `slot.json` 中的槽位名一一对应。模板分节、占位符标注 `(必填)/(选填)`、`要求:` 编号列表以及 `<尖括号>` 交叉引用的写法均对齐内置 Task-T 模板(如 `ran-energy-saving`)。 - -> `(必填)/(选填)`、`要求:` 等说明文字是渲染前的编写说明,渲染时会被剥离,不会出现在最终报文中(渲染结果见 [1.6](#16-运行与预期输出))。它们与 `slot.json` 的 `description`/`x-a2at-value-constraint` 是槽位提取与语义校验的依据。 - -````markdown -## 操作类型(Operation Type) - -{{操作类型}}(必填) - -要求: - -1. 请提供操作类型,取值范围:创建、修改 - -## 任务类型(Task Type) - -办公设备故障报修 - -## 任务描述(Task Description) - -{{任务描述}}(必填) - -要求: - -1. 请根据<操作类型>(创建/修改),动态提供任务描述信息: - - 类型为【创建】时内容为:基于<任务对象>、<任务上下文>,发起办公设备故障报修请求,达成<任务目标>中定义的报修目标,并按照<预期输出>中定义的格式返回报修处理结果。 - - 类型为【修改】时内容为:基于<任务对象>、<任务上下文>,更新办公设备故障报修请求,达成<任务目标>中定义的报修目标。 - -## 任务对象(Task Object) - -{{任务对象}}(必填) - -要求: - -1. 报修人:(必填) - - 故障报修发起人的姓名 - - 举例:报修人:"张伟" -2. 部门:(必填) - - 报修人所属部门名称 - - 举例:部门:"市场部" -3. 故障设备:(必填) - - 发生故障的办公设备名称、品牌及所处位置 - - 举例:故障设备:"三楼东侧会议室旁的惠普打印机" - -## 任务目标(Task Target) - -{{任务目标}}(选填) - -要求: - -1. 定义报修任务需达成的量化或定性目标,量化目标和定性目标不可同时选择,默认为定性目标。报修目标的具体说明如下: - - 量化目标:期望完成维修的时间要求,目标值格式为具体时刻或时间区间;举例:期望完成时间:下午三点前 - - 定性目标:尽快受理并完成维修,恢复设备可用 - -## 任务上下文(Task Context) - -{{任务上下文}}(选填) - -要求: - -1. 故障现象:设备发生故障的具体现象描述;举例:故障现象:开不了机,电源灯一直闪 -2. 联系方式:报修人的联系电话;举例:联系方式:13800001234 - -## 预期输出(Expected Output) - -{{预期输出}}(创建时选填,修改时无需填写) - -要求: - -1. 输出办公设备故障报修任务的报修结果,报修结果应包含以下信息: - -### 报修结果基本信息 - -1. 报修受理状态 -2. 报修任务唯一标识 - -### 报修结果详细信息 - -1. 故障设备信息 -2. 维修处理要求 -```` - -### 1.3.2 slot.json(槽位 Schema) - -槽位 Schema 是一个 JSON Schema,声明每个槽位的类型、业务含义与取值约束。`properties` 下的键名即槽位名,必须与 `template.md` 的 `{{占位符}}` 对应;每个槽位下的 `description` / `examples` / `x-a2at-value-constraint` 三要素是 LLM 槽位提取的主要依据,写得越贴切提取越准。`required` 数组声明必填槽位(本示例对齐内置模板写法,保持为空,必填语义主要体现在值约束与描述中)。 - -```json -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "additionalProperties": false, - "properties": { - "操作类型": { - "type": "string", - "description": "请提供操作类型,取值范围:创建、修改。", - "examples": [ - "创建" - ], - "x-a2at-value-constraint": "取值范围:创建、修改" - }, - "任务描述": { - "type": "string", - "description": "根据操作类型(创建/修改),动态提供任务描述信息。", - "examples": [ - "基于<任务对象>、<任务上下文>,发起办公设备故障报修请求,达成<任务目标>中定义的报修目标,并按照<预期输出>中定义的格式返回报修处理结果。" - ], - "x-a2at-value-constraint": "类型为【创建】时内容为:基于<任务对象>、<任务上下文>,发起办公设备故障报修请求,达成<任务目标>中定义的报修目标,并按照<预期输出>中定义的格式返回报修处理结果。类型为【修改】时内容为:基于<任务对象>、<任务上下文>,更新办公设备故障报修请求,达成<任务目标>中定义的报修目标。" - }, - "任务对象": { - "type": "string", - "description": "明确指定报修对象信息,包括报修人、部门、故障设备。", - "examples": [ - "报修人:张伟,部门:市场部,故障设备:三楼东侧会议室旁的惠普打印机。" - ], - "x-a2at-value-constraint": "报修人、部门、故障设备均为必填。报修人为故障报修发起人的姓名;部门为报修人所属部门名称;故障设备为发生故障的办公设备名称、品牌及所处位置。" - }, - "任务目标": { - "type": "string", - "description": "定义报修任务需达成的量化或定性目标。", - "examples": [ - "期望完成时间:下午三点前" - ], - "x-a2at-value-constraint": "1. 量化目标 期望完成时间:期望完成维修的时间要求,目标值格式为具体时刻或时间区间。2. 定性目标 尽快受理并完成维修,恢复设备可用。量化目标和定性目标不可同时选择,默认为定性目标。" - }, - "任务上下文": { - "type": "string", - "description": "提供报修任务的背景和上下文信息。", - "examples": [ - "故障现象:开不了机,电源灯一直闪;联系方式:13800001234。" - ], - "x-a2at-value-constraint": "1. 故障现象:设备发生故障的具体现象描述。2. 联系方式:报修人的联系电话。" - }, - "预期输出": { - "type": "string", - "description": "输出办公设备故障报修任务的报修结果。", - "examples": [ - "" - ], - "x-a2at-value-constraint": "报修结果基本信息:报修受理状态、报修任务唯一标识。报修结果详细信息:故障设备信息、维修处理要求。" - } - }, - "required": [] -} -``` - -### 1.3.3 scenarios.json(场景) - -场景清单用于客户端装配期的场景目录加载。**本示例走 `generateTaskPromptFromText` 路径,不做场景识别**,因此不需要登记场景——`scenarios/{语言}/scenarios.json` 可直接省略,装配期会回退内置场景目录、装配不会失败。若需要本地场景识别,才需要放置该文件,其中 `scenario_code` 必须与模板目录路径一致:`Task-T/office-it/printer-repair/v1`。 - -```json -{ - "scenarios": [ - { - "scenario_code": "Task-T/office-it/printer-repair/v1", - "scenario_name": "办公设备故障报修", - "description": "用于生成办公设备(打印机、电脑、显示器、投影仪等)故障报修/维修申请类的 A2A-T 任务请求,围绕报修人、部门、故障设备、故障现象、期望完成时间、联系方式等信息进行描述。", - "example": "帮我创建一个办公设备故障报修任务,操作类型:创建;任务对象:报修人为张伟;任务目标:期望完成时间为下午三点前。" - } - ] -} -``` - -## 1.4 .env 配置 - -将以下内容保存为 `.env`(放置位置即进程工作目录,也可通过命令行参数指定完整路径): - -```properties -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=local_file -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=./prompt-resources -A2AT_PROMPT_COMPLIANCE_ENABLED=false -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=your-model-name -A2AT_LLM_BASE_URL=https://your-llm-gateway.example.com/v1 -A2AT_LLM_API_KEY=your-api-key -A2AT_LLM_MAX_TOKENS=8000 -A2AT_LLM_TIMEOUT_SECONDS=180 -``` - -要点: - -- `A2AT_PROMPT_SOURCE_TYPE=local_file`:启用本地文件资源加载。 -- `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR`:本地资源根目录。**相对路径以 `.env` 文件所在目录为基准解析**(不是进程工作目录)。上面的 `./prompt-resources` 表示资源目录与 `.env` 同级。 -- 该目录在 `new A2ATClient(...)` / `new A2ATServer(...)` 装配期即被读取校验:**目录不存在会在构造期直接 fail-fast 抛异常,且不发起任何 LLM 调用**。 -- `A2AT_PROMPT_COMPLIANCE_ENABLED=false`:本示例关闭生成阶段的前置合规校验,聚焦「生成 → 服务端校验提参」主线。 -- `A2AT_LLM_MAX_TOKENS`:使用带推理(reasoning)的模型时需调大该值——推理会占用输出预算,过小会导致结构化为空。 -- `A2AT_LLM_PROVIDER` / `A2AT_LLM_MODEL` / `A2AT_LLM_BASE_URL` / `A2AT_LLM_API_KEY` 按实际接入的 LLM 服务填写;此处均以占位符展示。 - -## 1.5 工程与代码 - -### 1.5.1 pom.xml - -```xml - - - 4.0.0 - - demo - task-t-demo - 1.0.0 - - - 17 - UTF-8 - - - - - net.openan.a2a-t.sdk - a2a-t-client - 1.0.0 - - - net.openan.a2a-t.sdk - a2a-t-server - 1.0.0 - - - org.slf4j - slf4j-simple - 2.0.17 - runtime - - - - - - - org.codehaus.mojo - exec-maven-plugin - 3.5.0 - - demo.TaskTDemo - - - - - -``` - -### 1.5.2 TaskTDemo.java - -```java -package demo; - -import java.nio.file.Path; -import java.util.Map; -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.server.A2ATServer; - -public final class TaskTDemo { - public static void main(String[] args) throws Exception { - Path env = Path.of(args.length > 0 ? args[0] : ".env"); - String text = args.length > 1 ? args[1] - : "帮我创建一个办公设备故障报修任务,操作类型:创建;任务描述:基于报修人、部门和故障设备信息,发起办公设备故障报修请求,达成任务目标中定义的报修目标,并按照预期输出中定义的格式返回报修处理结果;任务对象:报修人为张伟、部门为市场部、故障设备为三楼东侧会议室旁的惠普打印机;任务目标:期望完成时间为下午三点前;任务上下文:故障现象为开不了机、电源灯一直闪,联系方式为13800001234;预期输出:输出报修结果,包含报修受理状态、报修任务唯一标识、故障设备信息、维修处理要求。"; - TemplateUri uri = TemplateUri.of("Task-T", "office-it", "printer-repair"); - Map schema = Map.of( - "type", "object", "properties", Map.of( - "报修人", slot("故障报修发起人的姓名"), - "部门", slot("报修人所属部门名称"), - "故障设备", slot("发生故障的办公设备名称、品牌及位置"), - "故障现象", slot("设备故障的具体现象"), - "期望完成时间", slot("期望完成维修的时间要求"), - "联系方式", slot("报修人的联系电话"))); - - A2ATClient client = new A2ATClient(env); - MetadataContent content = client.generateTaskPromptFromText(text, uri); - System.out.println(content.promptText()); - A2ATServer server = new A2ATServer(env); - System.out.println(server.validateTaskPromptAndDataFilling(content.promptText(), schema, uri).data()); - } - - private static Map slot(String description) { - return Map.of("type", "string", "description", description); - } -} -``` - -两个关键 API 说明: - -- **客户端 `generateTaskPromptFromText(String, TemplateUri)`**:显式传入模板 URI,跳过场景识别,直接按该模板做一次 LLM 槽位提取并渲染,返回 `MetadataContent`(`promptText()` 即渲染后的报文)。相较 `generateTaskPrompt(Object)` 的场景识别路径,它省去识别那次 LLM 调用,且报文结果里带显式 `templateUri`。 -- **服务端 `validateTaskPromptAndDataFilling(String, Map, TemplateUri)`**:校验任务报文合规性并按调用方 Schema 提取参数,返回 `FilledParamData`(`data()` 即提取结果 `Map`)。它需要显式 `TemplateUri`——本例 `TemplateUri.of("Task-T", "office-it", "printer-repair")` 逐段构造,与资源目录路径一一对应。 -- 服务端 `schema` 是调用方提供的参数 JSON Schema(本例用 `Map.of` 内联声明 6 个业务字段),键名(`报修人`/`部门`/`故障设备` 等)与客户端模板槽位名(`操作类型`/`任务对象` 等)**刻意不同**:客户端负责把业务字段渲染进模板分节,服务端再从渲染后的报文里按自身键名重新提取,展示了 SDK 的跨键名适配能力。 - -## 1.6 运行与预期输出 - -在工程根目录执行: - -```bash -mvn compile exec:java -``` - -> 首次运行需确保能联网解析 `net.openan.a2a-t.sdk` 依赖,并已正确填写 `A2AT_LLM_API_KEY` 等 LLM 配置。 - -预期输出分两段:先是客户端渲染出的 prompt 原文,紧随其后的是服务端提取出的 `data` Map(`FilledParamData.data()` 的字符串化结果)。 - -```text -## 操作类型(Operation Type) - -创建 - -## 任务类型(Task Type) - -办公设备故障报修 - -## 任务描述(Task Description) - -基于报修人、部门和故障设备信息,发起办公设备故障报修请求,达成任务目标中定义的报修目标,并按照预期输出中定义的格式返回报修处理结果 - -## 任务对象(Task Object) - -报修人为张伟、部门为市场部、故障设备为三楼东侧会议室旁的惠普打印机 - -## 任务目标(Task Target) - -期望完成时间为下午三点前 - -## 任务上下文(Task Context) - -故障现象为开不了机、电源灯一直闪,联系方式为13800001234 - -## 预期输出(Expected Output) - -输出报修结果,包含报修受理状态、报修任务唯一标识、故障设备信息、维修处理要求 - -{报修人=张伟, 部门=市场部, 故障设备=三楼东侧会议室旁的惠普打印机, 期望完成时间=下午三点前, 联系方式=13800001234, 故障现象=开不了机、电源灯一直闪} -``` - -耗时说明:本示例完整链路共触发 **2 次 LLM 调用**——客户端槽位提取 1 次、服务端语义校验提参 1 次(`FromText` 路径无场景识别调用)。实际耗时取决于所接入模型的响应速度。 - -## 1.7 常见问题 - -- **资源目录不存在,构造期即失败(零 LLM 调用)**:`A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 指向的目录不存在或路径非法时,`new A2ATClient(...)` / `new A2ATServer(...)` 在装配期 fail-fast 抛异常,且不会发起任何 LLM 调用。`local_file` 模式下业务内容本地优先、缺失回退内置,`scenarios/{语言}/scenarios.json` 可以省略(缺失时回退内置场景目录,见 [1.3.3](#133-scenariosjson))。 -- **输入超长 `input_text_too_long`**:FromText 生成与服务端校验入口均受 `A2AT_INPUT_TEXT_MAX_CHARS`(默认 16384)约束,超长的自由文本会在进入 LLM 之前直接以 `input_text_too_long` 快速失败,不消耗 LLM 调用。 -- **`slot_validation_error`(`missing_required`)或 `validation_semantic_rejected`**:当 `slot.json` 的 `required` 声明了某槽位而输入未能提供时,客户端抛 `slot_validation_error`(`missing_required`);而当报文的语义自相矛盾时,服务端以 `validation_semantic_rejected` 拒绝。二者都要求「模板 `要求:` 描述 ↔ `slot.json` 的 `description`/`x-a2at-value-constraint` ↔ 输入内容」口径一致。典型反例:任务描述写「尽快恢复设备可用」(定性目标)、任务目标却写「下午三点前」(量化目标)——量化/定性目标不可同时选择,会被服务端以 `semantic_mismatch` 拒绝。 -- **Windows 控制台中文乱码**:Windows 控制台默认编码非 UTF-8 时展示中文会乱。可先执行 `set JAVA_TOOL_OPTIONS=-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dfile.encoding=UTF-8`(或 `chcp 65001`)再运行,使输出以 UTF-8 呈现。 \ No newline at end of file diff --git a/env.example b/env.example index e48f87ff..8e3d989e 100644 --- a/env.example +++ b/env.example @@ -18,7 +18,7 @@ A2AT_PROMPT_COMPLIANCE_ENABLED=false # Input limits # Maximum length in characters (String.length()) accepted for free-text inputs (FromText generation and # prompt validation entry points) before they reach an LLM step; oversized inputs fail fast with the -# error code input_text_too_long instead of overflowing the LLM context. +# error code input.text_too_long instead of overflowing the LLM context. A2AT_INPUT_TEXT_MAX_CHARS=16384 # LLM runtime From 80e4b88a63c123dd23cc7dd0e68cf1d1d5471bc3 Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Tue, 15 Sep 2026 22:26:13 +0800 Subject: [PATCH 272/295] feat(llm): add DEBUG-level LLM call logging with opt-in payload switch Add structured LLM call logging on a dedicated logger net.openan.a2at.sdk.llm.call: summary lines (timestamp, elapsed time, prompt/completion/total tokens, content length, response id) are recorded at DEBUG level, while full request/response payloads are gated behind the new A2AT_LLM_DETAIL_LOG_ENABLED switch (default false, no truncation). - add A2AT_LLM_DETAIL_LOG_ENABLED parsing to LlmConfig/LLMClientConfig with legacy constructor compatibility - instrument OpenAIClient.structured() with level-short-circuited DEBUG logging; API keys are never logged - document logging configuration and integration guidance in the zh/en developer guides with per-framework output-path and rotation examples Signed-off-by: zhangwenjun-huawei --- .../a2at/sdk/core/model/A2ATConfigKeys.java | 3 + .../openan/a2at/sdk/core/model/LlmConfig.java | 80 +++++--- .../a2at/sdk/core/model/LlmConfigTest.java | 41 ++++ a2a-t-corpus/env.example | 10 +- a2a-t-llm/pom.xml | 6 + .../openan/a2at/sdk/llm/LLMClientConfig.java | 5 + .../a2at/sdk/llm/providers/OpenAIClient.java | 150 +++++++++++--- .../a2at/sdk/llm/LLMClientConfigFromTest.java | 71 +++++-- .../a2at/sdk/llm/LLMClientConfigTest.java | 3 + .../a2at/sdk/llm/LLMClientFactoryTest.java | 3 +- .../sdk/llm/providers/OpenAIClientTest.java | 188 +++++++++++++++++- .../OpenAIClientTlsVerificationTest.java | 10 +- .../DefaultA2ATServerBuilderTest.java | 1 + docs/en/developer_guide.md | 120 ++++++++++- ...00\345\217\221\346\214\207\345\215\227.md" | 117 ++++++++++- env.example | 8 + 16 files changed, 735 insertions(+), 81 deletions(-) diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java index b173ea32..19c544f3 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java @@ -60,6 +60,9 @@ public static final class Llm { /** Whether to verify the TLS certificate chain and hostname of the LLM endpoint (default true). */ public static final String SSL_VERIFY = "A2AT_LLM_SSL_VERIFY"; + /** Whether to print the full LLM request and response payloads (no truncation). Default false. */ + public static final String DETAIL_LOG_ENABLED = "A2AT_LLM_DETAIL_LOG_ENABLED"; + /** Optional reasoning effort level for reasoning models (none/minimal/low/medium/high/xhigh). */ public static final String REASONING_EFFORT = "A2AT_LLM_REASONING_EFFORT"; diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java index 85d763fd..a48e67c2 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java @@ -27,6 +27,7 @@ public record LlmConfig( int sessionMaxPerProvider, boolean disableSystemProxy, boolean sslVerify, + boolean detailLogEnabled, @Nullable String reasoningEffort, int maxAttempts, List parseErrors) { @@ -82,6 +83,7 @@ public LlmConfig( sessionMaxPerProvider, disableSystemProxy, true, + false, reasoningEffort, maxAttempts, parseErrors); @@ -100,22 +102,48 @@ public static LlmConfig fromMap(Map values) { StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.Llm.MODEL), ""), StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.Llm.API_KEY), ""), StringUtils.defaultIfBlank(values.get(A2ATConfigKeys.Llm.BASE_URL), ""), - parseInt(values.get(A2ATConfigKeys.Llm.HISTORY_WINDOW), - A2ATConfigKeys.Llm.HISTORY_WINDOW, DEFAULT_HISTORY_WINDOW, parseErrors), - parseOptionalNumeric(values.get(A2ATConfigKeys.Llm.MAX_TOKENS), - A2ATConfigKeys.Llm.MAX_TOKENS, parseErrors, Integer::parseInt), - parseOptionalNumeric(values.get(A2ATConfigKeys.Llm.TEMPERATURE), - A2ATConfigKeys.Llm.TEMPERATURE, parseErrors, Double::parseDouble), - parseOptionalNumeric(values.get(A2ATConfigKeys.Llm.TIMEOUT_SECONDS), - A2ATConfigKeys.Llm.TIMEOUT_SECONDS, parseErrors, Double::parseDouble), - parseInt(values.get(A2ATConfigKeys.Llm.SESSION_MAX_TOTAL), - A2ATConfigKeys.Llm.SESSION_MAX_TOTAL, DEFAULT_SESSION_MAX_TOTAL, parseErrors), - parseInt(values.get(A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER), - A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER, DEFAULT_SESSION_MAX_PER_PROVIDER, parseErrors), - parseBoolean(values.get(A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY), - A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY, false, parseErrors), - parseBoolean(values.get(A2ATConfigKeys.Llm.SSL_VERIFY), - A2ATConfigKeys.Llm.SSL_VERIFY, true, parseErrors), + parseInt( + values.get(A2ATConfigKeys.Llm.HISTORY_WINDOW), + A2ATConfigKeys.Llm.HISTORY_WINDOW, + DEFAULT_HISTORY_WINDOW, + parseErrors), + parseOptionalNumeric( + values.get(A2ATConfigKeys.Llm.MAX_TOKENS), + A2ATConfigKeys.Llm.MAX_TOKENS, + parseErrors, + Integer::parseInt), + parseOptionalNumeric( + values.get(A2ATConfigKeys.Llm.TEMPERATURE), + A2ATConfigKeys.Llm.TEMPERATURE, + parseErrors, + Double::parseDouble), + parseOptionalNumeric( + values.get(A2ATConfigKeys.Llm.TIMEOUT_SECONDS), + A2ATConfigKeys.Llm.TIMEOUT_SECONDS, + parseErrors, + Double::parseDouble), + parseInt( + values.get(A2ATConfigKeys.Llm.SESSION_MAX_TOTAL), + A2ATConfigKeys.Llm.SESSION_MAX_TOTAL, + DEFAULT_SESSION_MAX_TOTAL, + parseErrors), + parseInt( + values.get(A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER), + A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER, + DEFAULT_SESSION_MAX_PER_PROVIDER, + parseErrors), + parseBoolean( + values.get(A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY), + A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY, + false, + parseErrors), + parseBoolean( + values.get(A2ATConfigKeys.Llm.SSL_VERIFY), A2ATConfigKeys.Llm.SSL_VERIFY, true, parseErrors), + parseBoolean( + values.get(A2ATConfigKeys.Llm.DETAIL_LOG_ENABLED), + A2ATConfigKeys.Llm.DETAIL_LOG_ENABLED, + false, + parseErrors), parseReasoningEffort(values.get(A2ATConfigKeys.Llm.REASONING_EFFORT)), parseMaxAttempts(values.get(A2ATConfigKeys.Llm.MAX_ATTEMPTS)), parseErrors); @@ -130,18 +158,18 @@ private static int parseMaxAttempts(String rawValue) { if (parsed < MAX_ATTEMPTS_LOWER_BOUND) { log.atWarn() .log( - "LLM max attempts value is below the allowed minimum, clamped to bound. key={} raw_value={} clamped_value={}", - A2ATConfigKeys.Llm.MAX_ATTEMPTS, - rawValue.trim(), + "LLM max attempts value is below the allowed minimum, clamped to bound. key={} raw_value={} clamped_value={}", + A2ATConfigKeys.Llm.MAX_ATTEMPTS, + rawValue.trim(), MAX_ATTEMPTS_LOWER_BOUND); return MAX_ATTEMPTS_LOWER_BOUND; } if (parsed > MAX_ATTEMPTS_UPPER_BOUND) { log.atWarn() .log( -"LLM max attempts value is above the allowed maximum, clamped to bound. key={} raw_value={} clamped_value={}", - A2ATConfigKeys.Llm.MAX_ATTEMPTS, - rawValue.trim(), + "LLM max attempts value is above the allowed maximum, clamped to bound. key={} raw_value={} clamped_value={}", + A2ATConfigKeys.Llm.MAX_ATTEMPTS, + rawValue.trim(), MAX_ATTEMPTS_UPPER_BOUND); return MAX_ATTEMPTS_UPPER_BOUND; } @@ -149,9 +177,9 @@ private static int parseMaxAttempts(String rawValue) { } catch (NumberFormatException error) { log.atWarn() .log( -"LLM max attempts value is not a valid integer, falling back to default. key={} raw_value={} default_value={}", - A2ATConfigKeys.Llm.MAX_ATTEMPTS, - rawValue.trim(), + "LLM max attempts value is not a valid integer, falling back to default. key={} raw_value={} default_value={}", + A2ATConfigKeys.Llm.MAX_ATTEMPTS, + rawValue.trim(), DEFAULT_MAX_ATTEMPTS); return DEFAULT_MAX_ATTEMPTS; } @@ -203,4 +231,4 @@ private static String parseReasoningEffort(String rawValue) { } return rawValue.trim(); } -} \ No newline at end of file +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java index b2e03468..6ab6d588 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java @@ -47,6 +47,7 @@ void should_useDefaults_When_keysAreMissing() { assertNull(config.temperature()); assertNull(config.timeoutSeconds()); assertEquals(false, config.disableSystemProxy()); + assertEquals(false, config.detailLogEnabled()); assertNull(config.reasoningEffort()); assertEquals(300, config.sessionMaxTotal()); assertEquals(100, config.sessionMaxPerProvider()); @@ -247,4 +248,44 @@ void shouldParseBooleanValueCaseInsensitive() { assertTrue(trueConfig.parseErrors().isEmpty()); assertTrue(falseConfig.parseErrors().isEmpty()); } + + /** Verifies that {@code A2AT_LLM_DETAIL_LOG_ENABLED} resolves to {@code false} when the key is missing or blank. */ + @Test + void should_defaultDetailLogEnabledToFalse_When_keyIsMissingOrBlank() { + LlmConfig absent = LlmConfig.fromMap(Map.of()); + LlmConfig blank = LlmConfig.fromMap(Map.of("A2AT_LLM_DETAIL_LOG_ENABLED", "")); + + assertEquals(false, absent.detailLogEnabled()); + assertEquals(false, blank.detailLogEnabled()); + assertTrue(absent.parseErrors().isEmpty()); + assertTrue(blank.parseErrors().isEmpty()); + } + + /** + * Verifies that {@code A2AT_LLM_DETAIL_LOG_ENABLED} is parsed case-insensitively without recording parse errors. + */ + @Test + void should_parseDetailLogEnabledCaseInsensitive() { + LlmConfig trueConfig = LlmConfig.fromMap(Map.of("A2AT_LLM_DETAIL_LOG_ENABLED", "TRUE")); + LlmConfig falseConfig = LlmConfig.fromMap(Map.of("A2AT_LLM_DETAIL_LOG_ENABLED", "False")); + + assertEquals(true, trueConfig.detailLogEnabled()); + assertEquals(false, falseConfig.detailLogEnabled()); + assertTrue(trueConfig.parseErrors().isEmpty()); + assertTrue(falseConfig.parseErrors().isEmpty()); + } + + /** + * Verifies that an invalid {@code A2AT_LLM_DETAIL_LOG_ENABLED} value records a parse error and falls back to + * {@code false}. + */ + @Test + void should_recordParseError_When_detailLogEnabledIsInvalid() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_DETAIL_LOG_ENABLED", "maybe")); + + List errors = config.parseErrors(); + assertEquals(1, errors.size()); + assertTrue(errors.contains("A2AT_LLM_DETAIL_LOG_ENABLED: invalid boolean value 'maybe'")); + assertEquals(false, config.detailLogEnabled()); + } } diff --git a/a2a-t-corpus/env.example b/a2a-t-corpus/env.example index 23731ae9..ae4073ee 100644 --- a/a2a-t-corpus/env.example +++ b/a2a-t-corpus/env.example @@ -20,4 +20,12 @@ A2AT_LLM_TEMPERATURE=0 # Request timeout in seconds. A2AT_LLM_TIMEOUT_SECONDS=300 # Maximum number of attempts for retryable LLM steps (range 1-10, default 3). -A2AT_LLM_MAX_ATTEMPTS=1 \ No newline at end of file +A2AT_LLM_MAX_ATTEMPTS=1 +# Whether to print the full LLM request and response payloads (no truncation). Defaults to +# false. WARNING: enabling this prints detailed LLM interaction content, which may expose +# sensitive information and consume large amounts of log space; use ONLY during project +# debugging and keep it disabled in production. +# Summary logs (timestamp, token usage, elapsed time) are recorded at DEBUG level whenever +# the dedicated logger net.openan.a2at.sdk.llm.call is enabled for DEBUG; where the lines +# are printed is decided by the embedding application's logging framework. +A2AT_LLM_DETAIL_LOG_ENABLED=false \ No newline at end of file diff --git a/a2a-t-llm/pom.xml b/a2a-t-llm/pom.xml index f8c3387d..2c0f1991 100644 --- a/a2a-t-llm/pom.xml +++ b/a2a-t-llm/pom.xml @@ -38,5 +38,11 @@ junit-jupiter test + + ch.qos.logback + logback-classic + 1.4.14 + test + diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java index ef429d09..74da6e04 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java @@ -23,6 +23,8 @@ * @param sessionMaxPerProvider reserved per-provider session limit * @param disableSystemProxy whether to bypass the JVM or operating-system HTTP proxy * @param sslVerify whether to verify the TLS certificate chain and hostname of the LLM endpoint + * @param detailLogEnabled whether to print the full LLM request and response payloads (no truncation); summary logs + * (timestamp, token usage, elapsed time) are recorded at DEBUG level independently of this flag * @param reasoningEffort optional reasoning effort for reasoning models (none/minimal/low/medium/high/xhigh); null * leaves the parameter unset * @since 2026-06 @@ -40,6 +42,7 @@ public record LLMClientConfig( int sessionMaxPerProvider, boolean disableSystemProxy, boolean sslVerify, + boolean detailLogEnabled, String reasoningEffort) { private static final int MAX_HISTORY_WINDOW = 100; @@ -78,6 +81,7 @@ public LLMClientConfig( sessionMaxPerProvider, disableSystemProxy, true, + false, reasoningEffort); } @@ -111,6 +115,7 @@ public static LLMClientConfig from(LlmConfig llmConfig) { llmConfig.sessionMaxPerProvider(), llmConfig.disableSystemProxy(), llmConfig.sslVerify(), + llmConfig.detailLogEnabled(), reasoningEffort); } diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java index cca076d0..fd7d8c4e 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java @@ -14,6 +14,9 @@ import java.security.SecureRandom; import java.security.cert.X509Certificate; import java.time.Duration; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -42,6 +45,13 @@ public class OpenAIClient implements LLMClient { private static final Logger log = LoggerFactory.getLogger(OpenAIClient.class); + /** + * Dedicated logger for structured {@code llm_call} request/response logging (DEBUG level). Separate from the module + * logger so embedding applications can filter LLM call logs independently, e.g. + * {@code logging.level.net.openan.a2at.sdk.llm.call=DEBUG}. + */ + private static final Logger CALL_LOG = LoggerFactory.getLogger("net.openan.a2at.sdk.llm.call"); + private static final String JSON_MODE_INSTRUCTION = "Return a valid JSON object string. The output must be valid json. " + "Do not wrap the response in markdown code fences. " @@ -73,12 +83,33 @@ public LLMResponse structured( if (config.baseUrl() == null || config.baseUrl().isBlank()) { throw new LLMConfigError(config.provider() + " client requires a non-empty base_url"); } + ChatCompletionCreateParams params = buildStructuredParams(messages, jsonSchema, temperature, maxTokens); + long startNanos = System.nanoTime(); + boolean debugEnabled = CALL_LOG.isDebugEnabled(); + if (debugEnabled) { + logRequest( + messages, + jsonSchema, + params.messages().size(), + temperature == null ? config.temperature() : temperature, + maxTokens == null ? config.maxTokens() : maxTokens); + } try { - return parseResponse( - executor.apply(config, buildStructuredParams(messages, jsonSchema, temperature, maxTokens))); + ChatCompletion response = executor.apply(config, params); + LLMResponse parsed = parseResponse(response); + if (debugEnabled) { + logResponse(startNanos, response); + } + return parsed; } catch (LLMConfigError | LLMRuntimeError error) { + if (debugEnabled) { + logError(startNanos, error); + } throw error; } catch (Exception error) { + if (debugEnabled) { + logError(startNanos, error); + } throw new LLMRuntimeError(config.provider() + " invocation failed: " + error.getMessage(), error); } } @@ -130,9 +161,9 @@ private static ChatCompletionMessageParam systemMessage(String content) { ChatCompletionSystemMessageParam.builder().content(content).build()); } - private static String toJson(Map jsonSchema) { + private static String toJson(Object value) { try { - return OBJECT_MAPPER.writeValueAsString(jsonSchema); + return OBJECT_MAPPER.writeValueAsString(value); } catch (JsonProcessingException error) { throw new LLMRuntimeError("Failed to serialize JSON schema", error); } @@ -180,10 +211,9 @@ private String extractMessageText(ChatCompletion response) { .get(0) .message() .content() - .orElseThrow( - () -> new LLMRuntimeError(config.provider() - + " response did not include message content (finish_reason=" - + extractFinishReason(response) + ")")); + .orElseThrow(() -> new LLMRuntimeError(config.provider() + + " response did not include message content (finish_reason=" + + extractFinishReason(response) + ")")); } private static Map mapUsage(ChatCompletion response) { @@ -209,6 +239,80 @@ private static Map mapMetadata(ChatCompletion response) { return metadata; } + private void logRequest( + List> messages, + Map jsonSchema, + int messageCount, + Double resolvedTemperature, + Integer resolvedMaxTokens) { + String schemaText = "Return JSON that conforms to this JSON schema: " + toJson(jsonSchema); + List> fullMessages = new ArrayList<>(messages.size() + 2); + fullMessages.add(Map.of("role", "system", "content", JSON_MODE_INSTRUCTION)); + fullMessages.add(Map.of("role", "system", "content", schemaText)); + fullMessages.addAll(messages); + String messagesJson = toJson(fullMessages); + CALL_LOG.debug( + "llm_call event=request ts={} provider={} model={} messages={} chars={} temperature={} max_tokens={}", + utcNow(), + config.provider(), + config.model(), + messageCount, + messagesJson.length(), + resolvedTemperature == null ? "-" : resolvedTemperature, + resolvedMaxTokens == null ? "-" : resolvedMaxTokens); + if (config.detailLogEnabled()) { + CALL_LOG.debug( + "llm_call event=request_body ts={} provider={} model={} messages_json={}", + utcNow(), + config.provider(), + config.model(), + messagesJson); + } + } + + private void logResponse(long startNanos, ChatCompletion response) { + String contentText = extractMessageText(response); + Map usage = mapUsage(response); + CALL_LOG.debug( + "llm_call event=response ts={} provider={} model={} elapsed_ms={} prompt_tokens={} completion_tokens={} total_tokens={} content_chars={} response_id={}", + utcNow(), + config.provider(), + response.model(), + elapsedMs(startNanos), + usage.get("prompt_tokens"), + usage.get("completion_tokens"), + usage.get("total_tokens"), + contentText.length(), + response.id()); + if (config.detailLogEnabled()) { + CALL_LOG.debug( + "llm_call event=response_body ts={} provider={} model={} content={}", + utcNow(), + config.provider(), + response.model(), + contentText); + } + } + + private void logError(long startNanos, Throwable error) { + CALL_LOG.debug( + "llm_call event=error ts={} provider={} model={} elapsed_ms={} error_code={} error={}", + utcNow(), + config.provider(), + config.model(), + elapsedMs(startNanos), + error.getClass().getSimpleName(), + error.getMessage()); + } + + private static String elapsedMs(long startNanos) { + return String.format(Locale.ROOT, "%.1f", (System.nanoTime() - startNanos) / 1_000_000.0d); + } + + private static String utcNow() { + return DateTimeFormatter.ISO_INSTANT.format(Instant.now().truncatedTo(ChronoUnit.MILLIS)); + } + @SuppressWarnings("deprecation") private ChatCompletion executeWithSdk(LLMClientConfig runtimeConfig, ChatCompletionCreateParams params) { return sdkClient(runtimeConfig).chat().completions().create(params); @@ -240,19 +344,18 @@ private synchronized com.openai.client.OpenAIClient sdkClient(LLMClientConfig ru * Everything else on the connection keeps the OpenAI SDK defaults. */ private static void applyInsecureTls(OpenAIOkHttpClient.Builder builder) { - X509TrustManager trustAllManager = - new X509TrustManager() { - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) {} - - @Override - public void checkServerTrusted(X509Certificate[] chain, String authType) {} - - @Override - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - }; + X509TrustManager trustAllManager = new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; try { SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, new TrustManager[] {trustAllManager}, new SecureRandom()); @@ -262,8 +365,7 @@ public X509Certificate[] getAcceptedIssuers() { } builder.trustManager(trustAllManager); builder.hostnameVerifier((hostname, session) -> true); - log.warn( - "[A2AT-LLM] TLS certificate chain and hostname verification are disabled; " - + "use only in controlled environments with trusted networks"); + log.warn("[A2AT-LLM] TLS certificate chain and hostname verification are disabled; " + + "use only in controlled environments with trusted networks"); } } diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java index 0f4089dd..b5f7bbf2 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java @@ -26,6 +26,7 @@ void derivesFullConfigCorrectly() { 200, false, true, + false, "medium", 3, List.of()); @@ -42,15 +43,28 @@ void derivesFullConfigCorrectly() { assertEquals(30.0d, config.timeoutSeconds()); assertEquals(false, config.disableSystemProxy()); assertEquals(true, config.sslVerify()); + assertEquals(false, config.detailLogEnabled()); assertEquals("medium", config.reasoningEffort()); assertEquals(500, config.sessionMaxTotal()); assertEquals(200, config.sessionMaxPerProvider()); } + @Test + void carriesDetailLogEnabledThroughFrom() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, true, null, 3, + List.of()); + + LLMClientConfig config = LLMClientConfig.from(llmConfig); + + assertEquals(true, config.detailLogEnabled()); + } + @Test void carriesSslVerifyDisabledThroughFrom() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, false, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, false, false, null, 3, + List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -65,12 +79,13 @@ void legacyLlmConfigConstructorKeepsSslVerifyEnabled() { LLMClientConfig config = LLMClientConfig.from(llmConfig); assertEquals(true, config.sslVerify()); + assertEquals(false, config.detailLogEnabled()); } @Test void rejectsMissingProvider() { - LlmConfig llmConfig = - new LlmConfig("", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, null, 3, List.of()); + LlmConfig llmConfig = new LlmConfig( + "", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -79,8 +94,8 @@ void rejectsMissingProvider() { @Test void rejectsMissingModel() { - LlmConfig llmConfig = - new LlmConfig("openai", "", "sk-test", null, 10, null, null, null, 300, 100, false, true, null, 3, List.of()); + LlmConfig llmConfig = new LlmConfig( + "openai", "", "sk-test", null, 10, null, null, null, 300, 100, false, true, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -89,8 +104,8 @@ void rejectsMissingModel() { @Test void rejectsMissingApiKey() { - LlmConfig llmConfig = - new LlmConfig("openai", "gpt-4o", "", null, 10, null, null, null, 300, 100, false, true, null, 3, List.of()); + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "", null, 10, null, null, null, 300, 100, false, true, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -100,7 +115,7 @@ void rejectsMissingApiKey() { @Test void rejectsMultipleMissingKeysAndListsThemAll() { LlmConfig llmConfig = - new LlmConfig("", "", "", null, 10, null, null, null, 300, 100, false, true, null, 3, List.of()); + new LlmConfig("", "", "", null, 10, null, null, null, 300, 100, false, true, false, null, 3, List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -112,7 +127,8 @@ void rejectsMultipleMissingKeysAndListsThemAll() { @Test void rejectsHistoryWindowAbove100() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 101, null, null, null, 300, 100, false, true, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 101, null, null, null, 300, 100, false, true, false, null, 3, + List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -122,7 +138,8 @@ void rejectsHistoryWindowAbove100() { @Test void rejectsSessionMaxTotalAbove3000() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 3001, 100, false, true, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 3001, 100, false, true, false, null, 3, + List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -132,7 +149,8 @@ void rejectsSessionMaxTotalAbove3000() { @Test void rejectsSessionMaxPerProviderAbove1000() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 1001, false, true, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 1001, false, true, false, null, 3, + List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -142,7 +160,8 @@ void rejectsSessionMaxPerProviderAbove1000() { @Test void rejectsSessionMaxTotalSmallerThanSessionMaxPerProvider() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 50, 100, false, true, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 50, 100, false, true, false, null, 3, + List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -153,7 +172,8 @@ void rejectsSessionMaxTotalSmallerThanSessionMaxPerProvider() { @Test void derivesUnconfiguredOptionalsAsNull() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, false, null, 3, + List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -169,7 +189,8 @@ void derivesUnconfiguredOptionalsAsNull() { @Test void normalizesReasoningEffortCase() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, "MEDIUM", 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, false, "MEDIUM", 3, + List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -179,7 +200,8 @@ void normalizesReasoningEffortCase() { @Test void rejectsUnknownReasoningEffortValue() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, "middle", 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, false, "middle", 3, + List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -190,7 +212,8 @@ void rejectsUnknownReasoningEffortValue() { @Test void treatsBlankReasoningEffortAsNull() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, " ", 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, false, " ", 3, + List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -200,7 +223,8 @@ void treatsBlankReasoningEffortAsNull() { @Test void treatsNullReasoningEffortAsNull() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, false, null, 3, + List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -222,6 +246,7 @@ void rejectsParseErrorsAndListsThemAll() { 100, false, true, + false, null, 3, List.of( @@ -237,7 +262,8 @@ void rejectsParseErrorsAndListsThemAll() { @Test void acceptsEmptyParseErrors() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, 2048, 0.5d, 30.0d, 500, 200, false, true, "medium", 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, 2048, 0.5d, 30.0d, 500, 200, false, true, false, "medium", 3, + List.of()); LLMClientConfig config = LLMClientConfig.from(llmConfig); @@ -248,7 +274,8 @@ void acceptsEmptyParseErrors() { @Test void rejectsHistoryWindowZeroOrNegative() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 0, null, null, null, 300, 100, false, true, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 0, null, null, null, 300, 100, false, true, false, null, 3, + List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -258,7 +285,8 @@ void rejectsHistoryWindowZeroOrNegative() { @Test void rejectsSessionMaxTotalZeroOrNegative() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 0, 100, false, true, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 0, 100, false, true, false, null, 3, + List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); @@ -268,7 +296,8 @@ void rejectsSessionMaxTotalZeroOrNegative() { @Test void rejectsSessionMaxPerProviderZeroOrNegative() { LlmConfig llmConfig = new LlmConfig( - "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 0, false, true, null, 3, List.of()); + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 0, false, true, false, null, 3, + List.of()); LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java index a76590ae..58f6c07f 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java @@ -21,6 +21,7 @@ void storesResolvedDefaultLlmClientConfiguration() { 100, true, false, + false, "none"); assertEquals("openai", config.provider()); @@ -33,6 +34,7 @@ void storesResolvedDefaultLlmClientConfiguration() { assertEquals(15.5d, config.timeoutSeconds()); assertEquals(true, config.disableSystemProxy()); assertEquals(false, config.sslVerify()); + assertEquals(false, config.detailLogEnabled()); assertEquals(300, config.sessionMaxTotal()); assertEquals(100, config.sessionMaxPerProvider()); assertEquals("none", config.reasoningEffort()); @@ -55,5 +57,6 @@ void legacyConstructorDefaultsSslVerifyToTrue() { "none"); assertEquals(true, config.sslVerify()); + assertEquals(false, config.detailLogEnabled()); } } diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java index 3ab1e098..2de1b1ef 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java @@ -82,7 +82,8 @@ void rejectsProviderClassWithoutPublicConfigConstructor() { } private static LLMClientConfig buildConfig(String provider) { - return new LLMClientConfig(provider, "test-model", "sk-test", null, 10, null, null, null, 300, 100, false, true, null); + return new LLMClientConfig( + provider, "test-model", "sk-test", null, 10, null, null, null, 300, 100, false, true, false, null); } public static final class CustomClient implements LLMClient { diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java index e0986807..bb3504e9 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java @@ -5,6 +5,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import com.fasterxml.jackson.databind.node.TextNode; import com.openai.core.JsonValue; import com.openai.models.chat.completions.ChatCompletion; @@ -25,10 +29,29 @@ import net.openan.a2at.sdk.llm.LLMConfigError; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; class OpenAIClientTest { + private final ListAppender callLogAppender = new ListAppender<>(); + + private final Logger callLogger = (Logger) LoggerFactory.getLogger("net.openan.a2at.sdk.llm.call"); + + @BeforeEach + void attachCallLogAppender() { + callLogAppender.start(); + callLogger.addAppender(callLogAppender); + } + + @AfterEach + void detachCallLogAppender() { + callLogger.detachAppender(callLogAppender); + callLogger.setLevel(null); + } + @Test void rejectsBlankApiKeyOnConstruction() { LLMConfigError error = assertThrows( @@ -98,7 +121,20 @@ void structuredBuildsJsonModePayloadAndParsesJsonObjectResponse() { void structuredFallsBackToConfigTemperatureAndMaxTokens() { AtomicReference capturedParams = new AtomicReference<>(); LLMClientConfig config = new LLMClientConfig( - "openai", "gpt-4o-mini", "sk-test", "https://api.example.test/v1", 10, 128, 0.4d, null, 300, 100, false, true, null); + "openai", + "gpt-4o-mini", + "sk-test", + "https://api.example.test/v1", + 10, + 128, + 0.4d, + null, + 300, + 100, + false, + true, + false, + null); OpenAIClient client = new OpenAIClient(config, (runtimeConfig, requestParams) -> { capturedParams.set(requestParams); return chatCompletion("{}"); @@ -203,7 +239,155 @@ void concurrentStructuredCallsAllSucceed() throws Exception { } private static LLMClientConfig config(String apiKey, String baseUrl) { - return new LLMClientConfig("openai", "gpt-4o-mini", apiKey, baseUrl, 10, null, null, null, 300, 100, false, true, null); + return new LLMClientConfig( + "openai", "gpt-4o-mini", apiKey, baseUrl, 10, null, null, null, 300, 100, false, true, false, null); + } + + private static LLMClientConfig config(String apiKey, String baseUrl, boolean detailLogEnabled) { + return new LLMClientConfig( + "openai", + "gpt-4o-mini", + apiKey, + baseUrl, + 10, + null, + null, + null, + 300, + 100, + false, + true, + detailLogEnabled, + null); + } + + // ---- Detailed call logging (dedicated logger net.openan.a2at.sdk.llm.call at DEBUG) ---- + + @Test + void summaryLogsRecordedAtDebugWithoutPayloadWhenDetailLogDisabled() { + OpenAIClient client = new OpenAIClient( + config("sk-secret-summary", "https://api.example.test/v1", false), + (runtimeConfig, requestParams) -> chatCompletion("{\"device_type\":\"router\"}")); + + client.structured( + List.of(Map.of("role", "user", "content", "extract router")), Map.of("type", "object"), 0.25d, 9); + + List messages = recordedMessages(); + assertTrue(messages.stream().anyMatch(line -> line.startsWith("llm_call event=request "))); + assertTrue(messages.stream().anyMatch(line -> line.startsWith("llm_call event=response "))); + assertTrue(messages.stream().noneMatch(line -> line.contains("event=request_body"))); + assertTrue(messages.stream().noneMatch(line -> line.contains("event=response_body"))); + + String requestLine = messages.stream() + .filter(line -> line.startsWith("llm_call event=request ")) + .findFirst() + .orElseThrow(); + assertContains( + requestLine, + "provider=openai", + "model=gpt-4o-mini", + "messages=3", + "chars=", + "temperature=0.25", + "max_tokens=9"); + + String responseLine = messages.stream() + .filter(line -> line.startsWith("llm_call event=response ")) + .findFirst() + .orElseThrow(); + assertContains( + responseLine, + "provider=openai", + "model=gpt-4o-mini", + "elapsed_ms=", + "prompt_tokens=7", + "completion_tokens=2", + "total_tokens=9", + "content_chars=", + "response_id=chatcmpl_123"); + + assertTrue( + messages.stream().noneMatch(line -> line.contains("sk-secret-summary")), + "logs must not leak the API key"); + } + + @Test + void payloadLinesRecordedWithoutTruncationWhenDetailLogEnabled() { + OpenAIClient client = new OpenAIClient( + config("sk-test", "https://api.example.test/v1", true), + (runtimeConfig, requestParams) -> chatCompletion("{\"device_type\":\"router\"}")); + + client.structured( + List.of(Map.of("role", "user", "content", "extract the device type here please")), + Map.of("type", "object", "properties", Map.of("device_type", Map.of("type", "string"))), + null, + null); + + List messages = recordedMessages(); + String requestBodyLine = messages.stream() + .filter(line -> line.contains("event=request_body")) + .findFirst() + .orElseThrow(); + assertContains( + requestBodyLine, + "extract the device type here please", + "device_type", + "Return a valid JSON object string"); + String responseBodyLine = messages.stream() + .filter(line -> line.contains("event=response_body")) + .findFirst() + .orElseThrow(); + assertContains(responseBodyLine, "{\"device_type\":\"router\"}"); + } + + @Test + void errorLineRecordedOnProviderFailure() { + OpenAIClient client = new OpenAIClient( + config("sk-test", "https://api.example.test/v1", false), (runtimeConfig, requestParams) -> { + throw new IllegalStateException("provider unavailable"); + }); + + assertThrows( + LLMRuntimeError.class, + () -> client.structured( + List.of(Map.of("role", "user", "content", "extract")), Map.of("type", "object"), null, null)); + + List messages = recordedMessages(); + String errorLine = messages.stream() + .filter(line -> line.startsWith("llm_call event=error ")) + .findFirst() + .orElseThrow(); + assertContains(errorLine, "elapsed_ms=", "error_code=IllegalStateException", "error=provider unavailable"); + assertTrue(!errorLine.contains("prompt_tokens"), "token fields must not appear on the error line"); + assertTrue(messages.stream().noneMatch(line -> line.contains("event=response"))); + } + + @Test + void noCallLogsWhenDedicatedLoggerNotEnabledForDebug() { + callLogger.setLevel(Level.INFO); + + OpenAIClient client = new OpenAIClient( + config("sk-test", "https://api.example.test/v1", true), (runtimeConfig, requestParams) -> { + return chatCompletion("{}"); + }); + + client.structured(List.of(Map.of("role", "user", "content", "extract")), Map.of("type", "object"), null, null); + + assertTrue( + recordedMessages().isEmpty(), + "no llm_call logs expected when the dedicated logger is not enabled for DEBUG"); + } + + private List recordedMessages() { + return callLogAppender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void assertContains(String message, String... fragments) { + for (String fragment : fragments) { + assertTrue(message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); + } } private static ChatCompletion chatCompletion(String content) { diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTlsVerificationTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTlsVerificationTest.java index 46f59146..176c54d7 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTlsVerificationTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTlsVerificationTest.java @@ -22,8 +22,8 @@ import org.junit.jupiter.api.Test; /** - * Behavioral TLS tests against a local HTTPS server that presents a self-signed certificate for {@code localhost} - * while the client connects to {@code 127.0.0.1}, so the default path fails on both trust and hostname checks. + * Behavioral TLS tests against a local HTTPS server that presents a self-signed certificate for {@code localhost} while + * the client connects to {@code 127.0.0.1}, so the default path fails on both trust and hostname checks. */ class OpenAIClientTlsVerificationTest { @@ -54,7 +54,10 @@ void verifiesPeerCertificateByDefault() throws Exception { LLMRuntimeError error = assertThrows( LLMRuntimeError.class, () -> client.structured( - List.of(Map.of("role", "user", "content", "extract")), Map.of("type", "object"), null, null)); + List.of(Map.of("role", "user", "content", "extract")), + Map.of("type", "object"), + null, + null)); assertTrue( hasSslFailureInCauseChain(error), @@ -77,6 +80,7 @@ private static LLMClientConfig config(SelfSignedHttpsServer server, boolean sslV 100, false, sslVerify, + false, null); } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java index c880093a..9b1087d1 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java @@ -98,6 +98,7 @@ void llmClientConfigDerivedFromConfigLlmNotFromEnvPath() throws IOException { 100, false, true, + false, null, 3, List.of()), diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 446a052f..2c7bd848 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -1658,7 +1658,123 @@ After starting the client or server, confirm and troubleshoot as follows: 2. **Content not updated**: changes to local files do not take effect without restarting the process; restart the SDK process and construct again. 3. **Negotiation and LLM resources are not customizable**: `Negotiation-T` templates support only the built-in fixed set; negotiation template URIs outside the set are skipped with a warning. -## 1.6 Configuration Item List +## 1.6 Logging Configuration and Integration Guide + +### 1.6.1 Logging Mechanism Overview + +The SDK's LLM call logs are emitted through SLF4J on a **dedicated logger** `net.openan.a2at.sdk.llm.call`, decoupled from the application's other logs so the level can be controlled and filtered independently: + +| Log category | Level | Content | Controlled by the switch? | +| --- | --- | --- | --- | +| Call summary logs (`llm_call event=request` / `event=response` / `event=error`) | DEBUG | Timestamp, model, message count/character count, elapsed time (`elapsed_ms`), input/output/total tokens (`prompt_tokens`/`completion_tokens`/`total_tokens`), response content length (`content_chars`), `response_id`; **no payload content** | No; emitted at DEBUG level regardless | +| Full payload logs (`llm_call event=request_body` / `event=response_body`) | DEBUG | Full request messages and response content, **no truncation** | Yes; controlled by `A2AT_LLM_DETAIL_LOG_ENABLED`, default off | + +Summary log example: + +```text +llm_call event=response ts=2026-09-14T08:12:36.012Z provider=openai model=deepseek-v3 elapsed_ms=2556.4 prompt_tokens=512 completion_tokens=120 total_tokens=632 content_chars=344 response_id=chatcmpl-abc123 +``` + +Key points: + +1. **No payload is printed by default**: `A2AT_LLM_DETAIL_LOG_ENABLED` defaults to `false`; summary logs are only visible when the embedding application enables DEBUG for the dedicated logger, so production INFO/WARN defaults produce no new output from the SDK. +2. **Two-level control**: first set the level of `net.openan.a2at.sdk.llm.call` to DEBUG through the application's logging framework (controls whether summary logs are visible), then optionally set `A2AT_LLM_DETAIL_LOG_ENABLED=true` (controls whether full payloads are printed). +3. **Risk warning**: enabling the switch prints detailed LLM interaction content, which may expose sensitive information (business templates, slots, negotiation messages, model responses, etc.) or consume large amounts of log space. Use it **only during the project DEBUG phase and keep it disabled in production**. + +### 1.6.2 Configuration + +Add the following to `client.env` / `server.env`: + +```properties +# Whether to print the full LLM request and response payloads (no truncation). Defaults to +# false. WARNING: enabling this prints detailed LLM interaction content, which may expose +# sensitive information and consume large amounts of log space; use ONLY during project +# debugging and keep it disabled in production. +A2AT_LLM_DETAIL_LOG_ENABLED=false +``` + +| Value | Behavior | +| --- | --- | +| `false` (default, unset, or blank) | Full request/response payloads are not printed | +| `true` | Full payloads are appended to the DEBUG-level logs, without truncation | + +Values are case-insensitive; any other invalid value is recorded as a configuration parse error and the SDK construction throws `LLMConfigError`. + +### 1.6.3 Integration Configuration Guide + +Viewing the LLM call logs requires the embedding application's cooperation (recommended only in debugging environments). The log level (logger) and the output destination/rotation (appender) are two layers of the logging configuration: the level decides which log events are produced, and the appender decides which file the events are written to and how they roll. Complete examples for common frameworks (level + output path + rotation): + +**logback (logback.xml / logback-spring.xml)** + +```xml + + + + + logs/llm-call.log + + + logs/llm-call.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 7 + 2GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %msg%n + + + + + + + + + + + + + +``` + +**Spring Boot (application.yml, Spring Boot 2.4+)** + +```yaml +logging: + level: + net.openan.a2at.sdk.llm.call: DEBUG + # Output path and rotation (applies to Spring Boot's default file appender) + file: + name: logs/llm-call.log + logback: + rollingpolicy: + max-file-size: 100MB + max-history: 7 + total-size-cap: 2GB +``` + +Note: this configuration writes **all** application logs (including LLM call logs) to `logs/llm-call.log` and rotates them per the policy above; `application.yml` cannot assign a dedicated file per logger. To keep LLM call logs in a separate file, add a `logback-spring.xml` (Spring Boot integrates logback by default) and reuse the logback example above. + +**Quarkus (application.properties)** + +```properties +# Level +quarkus.log.category."net.openan.a2at.sdk.llm.call".level=DEBUG +# Write only to the dedicated file handler; do not bubble up to the console +quarkus.log.category."net.openan.a2at.sdk.llm.call".use-parent-handlers=false +# Dedicated file handler: output path +quarkus.log.handler.file.llm-call.enable=true +quarkus.log.handler.file.llm-call.path=logs/llm-call.log +# Rotation: roll daily, keep the latest 7 files +quarkus.log.handler.file.llm-call.rotation.file-suffix=.yyyy-MM-dd +quarkus.log.handler.file.llm-call.rotation.max-backup-index=7 +# Bind the logger to the dedicated file handler +quarkus.log.category."net.openan.a2at.sdk.llm.call".handlers=llm-call +``` + +> Note: the SDK only produces log events and never configures output destinations; both the output path and the rotation policy are entirely decided by the embedding application's appender (the examples above are recommendations). When `A2AT_LLM_DETAIL_LOG_ENABLED` is enabled, every LLM call prints full payloads — set a per-file size cap and a retention window as shown to avoid filling up the disk. If the application provides no SLF4J binding (e.g. logback/log4j2), the log events are discarded with only a single "No SLF4J providers were found" warning. + +## 1.7 Configuration Item List The sample configuration file provided by the SDK is `env.example`. When constructing the A2A-T Client and the A2A-T Server, copy the file to the corresponding `client.env` / `server.env`. The configuration items are described below: @@ -1682,6 +1798,6 @@ The sample configuration file provided by the SDK is `env.example`. When constru | `A2AT_LLM_SESSION_MAX_TOTAL` | Maximum total number of tracked sessions, default `300` | | `A2AT_LLM_SESSION_MAX_PER_PROVIDER` | Maximum number of tracked sessions per provider, default `100` | | `A2AT_LLM_MAX_ATTEMPTS` | Maximum number of attempts for retryable LLM steps; range 1-10 (out-of-range values are clamped), default `3` | +| `A2AT_LLM_DETAIL_LOG_ENABLED` | Whether to print the full LLM request/response payloads (no truncation), default `false`; enabling it may expose sensitive information or consume log space — use only in the DEBUG phase and keep disabled in production; timestamp/token/latency summary logs are emitted at DEBUG level on the dedicated logger `net.openan.a2at.sdk.llm.call`, see 1.6 | | `A2AT_NEGOTIATION_STATE_STORE_TYPE` | Negotiation state storage; currently supports `in_memory` | - diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index eb77b8cd..2bc97321 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -1651,7 +1651,121 @@ MetadataContent metadata = client.generateTaskPromptFromText("请对xx基站开 2. **内容未更新**:修改本地文件后不重启进程不生效,重启 SDK 进程后重新构造。 3. **协商与 LLM 资源不可定制**:`Negotiation-T` 模板只支持内置的固定集合,集合之外的协商模板 URI 会被跳过并输出告警。 -## 1.6 配置项列表 +## 1.6 日志打印配置说明与集成指引 + +### 1.6.1 日志机制概述 + +SDK 的 LLM 调用日志通过 SLF4J 输出,使用**专用 logger** `net.openan.a2at.sdk.llm.call`,与应用其他业务日志解耦,便于单独控制级别与过滤: + +| 日志类别 | 级别 | 内容 | 是否受开关控制 | +| --- | --- | --- | --- | +| 调用摘要日志(`llm_call event=request` / `event=response` / `event=error`) | DEBUG | 时间戳、模型、消息数/字符数、耗时(elapsed_ms)、输入/输出/总 Token(prompt_tokens/completion_tokens/total_tokens)、响应内容长度(content_chars)、response_id;**不含报文内容** | 否,随 DEBUG 级别输出 | +| 完整报文日志(`llm_call event=request_body` / `event=response_body`) | DEBUG | 完整请求消息与响应内容,**不做截断** | 是,由 `A2AT_LLM_DETAIL_LOG_ENABLED` 控制,默认关闭 | + +摘要日志示例: + +```text +llm_call event=response ts=2026-09-14T08:12:36.012Z provider=openai model=deepseek-v3 elapsed_ms=2556.4 prompt_tokens=512 completion_tokens=120 total_tokens=632 content_chars=344 response_id=chatcmpl-abc123 +``` + +要点: + +1. **默认不打印任何报文**:`A2AT_LLM_DETAIL_LOG_ENABLED` 默认 `false`;摘要日志仅在集成方把专用 logger 打开到 DEBUG 时可见,生产默认 INFO/WARN 级别下 SDK 无任何新增输出。 +2. **两级控制**:先通过应用日志框架把 `net.openan.a2at.sdk.llm.call` 级别调为 DEBUG(决定摘要日志是否可见),再按需设置 `A2AT_LLM_DETAIL_LOG_ENABLED=true`(决定是否打印完整报文)。 +3. **风险提示**:开启开关会打印 LLM 交互详细内容,可能打印敏感信息(业务模板、槽位、协商消息、模型返回等)或占用大量日志空间,**仅限项目 DEBUG 阶段使用,正式运行时必须关闭该配置项**。 + +### 1.6.2 配置说明 + +在 `client.env` / `server.env` 中添加: + +```properties +# 是否打印 LLM 请求与响应的完整报文(不截断)。默认 false。 +# 风险提示:开启会打印 LLM 交互详细内容,可能打印敏感信息或占用日志空间, +# 仅限项目 DEBUG 阶段使用,正式运行时需关闭此配置项。 +A2AT_LLM_DETAIL_LOG_ENABLED=false +``` + +| 取值 | 行为 | +| --- | --- | +| `false`(默认、未配置或空值) | 不打印请求/响应的完整报文 | +| `true` | 在 DEBUG 级别日志的基础上追加打印完整报文,不做截断 | + +`true`/`false` 大小写不敏感;其他非法取值记入配置解析错误,SDK 构造时抛出 `LLMConfigError`。 + +### 1.6.3 集成方式配置指引 + +打开 LLM 调用日志需要应用侧配合(建议仅在联调环境执行)。日志级别(logger)与输出位置/绕接(appender)是日志配置的两个层次:级别决定哪些日志事件产生,appender 决定事件写到哪个文件、如何滚动。以下为常见框架的完整配置示例(级别 + 输出路径 + 绕接配置): + +**logback(logback.xml / logback-spring.xml)** + +```xml + + + + + logs/llm-call.log + + + logs/llm-call.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 7 + 2GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %msg%n + + + + + + + + + + + + + +``` + +**Spring Boot(application.yml,Spring Boot 2.4+)** + +```yaml +logging: + level: + net.openan.a2at.sdk.llm.call: DEBUG + # 输出路径与绕接配置(作用于 Spring Boot 默认文件 appender) + file: + name: logs/llm-call.log + logback: + rollingpolicy: + max-file-size: 100MB + max-history: 7 + total-size-cap: 2GB +``` + +说明:该配置会把应用的**全部日志**(含 LLM 调用日志)写入 `logs/llm-call.log` 并按上述策略绕接;`application.yml` 无法按 logger 单独指定文件。如需 LLM 调用日志**单独成文件**,请添加 `logback-spring.xml`(Spring Boot 默认集成 logback)并套用上方 logback 示例。 + +**Quarkus(application.properties)** + +```properties +# 级别 +quarkus.log.category."net.openan.a2at.sdk.llm.call".level=DEBUG +# 仅写入专用文件 handler,不再冒泡到控制台 +quarkus.log.category."net.openan.a2at.sdk.llm.call".use-parent-handlers=false +# 专用文件 handler:输出路径 +quarkus.log.handler.file.llm-call.enable=true +quarkus.log.handler.file.llm-call.path=logs/llm-call.log +# 绕接配置:按天滚动,保留最近 7 份 +quarkus.log.handler.file.llm-call.rotation.file-suffix=.yyyy-MM-dd +quarkus.log.handler.file.llm-call.rotation.max-backup-index=7 +# 该 logger 绑定到专用文件 handler +quarkus.log.category."net.openan.a2at.sdk.llm.call".handlers=llm-call +``` + +> 说明:SDK 只产生日志事件,不配置输出位置;日志打印路径与绕接策略全部由集成方 appender 决定(上文示例仅作建议)。开启 `A2AT_LLM_DETAIL_LOG_ENABLED` 后每次 LLM 调用会输出完整报文,建议按示例设置单文件大小上限与保留天数,避免占满磁盘。若应用未提供 SLF4J 绑定(如 logback/log4j2),日志会被丢弃并仅打印一条 "No SLF4J providers were found" 告警。 + +## 1.7 配置项列表 SDK提供的样例配置文件为`env.example`,A2A-T Client和A2A-T Server构造时均需拷贝文件至对应的`client.env`、`server.env`,配置项含义如下: @@ -1675,4 +1789,5 @@ SDK提供的样例配置文件为`env.example`,A2A-T Client和A2A-T Server构 | `A2AT_LLM_SESSION_MAX_TOTAL` | 会话跟踪总数上限,默认 `300` | | `A2AT_LLM_SESSION_MAX_PER_PROVIDER` | 单提供方会话跟踪数上限,默认 `100` | | `A2AT_LLM_MAX_ATTEMPTS` | 可重试 LLM 步骤的最大尝试次数,取值范围 1-10(越界自动裁剪),默认 `3` | +| `A2AT_LLM_DETAIL_LOG_ENABLED` | 是否打印 LLM 请求/响应的完整报文(不截断),默认 `false`;开启后可能打印敏感信息或占用日志空间,仅限 DEBUG 阶段使用、正式运行必须关闭;时间/Token/耗时等摘要日志由专用 logger `net.openan.a2at.sdk.llm.call` 以 DEBUG 级输出,详见 1.6 | | `A2AT_NEGOTIATION_STATE_STORE_TYPE` | 协商状态存储,当前支持 `in_memory` | diff --git a/env.example b/env.example index cb564505..8755dcf2 100644 --- a/env.example +++ b/env.example @@ -50,6 +50,14 @@ A2AT_LLM_SESSION_MAX_TOTAL=300 A2AT_LLM_SESSION_MAX_PER_PROVIDER=100 # Maximum number of attempts for retryable LLM steps (range 1-10, default 3). A2AT_LLM_MAX_ATTEMPTS=3 +# Whether to print the full LLM request and response payloads (no truncation). Defaults to +# false. WARNING: enabling this prints detailed LLM interaction content, which may expose +# sensitive information and consume large amounts of log space; use ONLY during project +# debugging and keep it disabled in production. +# Summary logs (timestamp, token usage, elapsed time) are recorded at DEBUG level whenever +# the dedicated logger net.openan.a2at.sdk.llm.call is enabled for DEBUG; where the lines +# are printed is decided by the embedding application's logging framework. +A2AT_LLM_DETAIL_LOG_ENABLED=false # Negotiation # Selects the negotiation state store implementation. From f94c7719f69da31415849c9a1342b9c72dea20af Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Wed, 16 Sep 2026 11:58:09 +0800 Subject: [PATCH 273/295] fix(resources): update ran-energy-saving template Signed-off-by: zhangwenjun-huawei --- .../ran-energy-saving/v1/en-US/slot.json | 2 +- .../ran-energy-saving/v1/zh-CN/slot.json | 2 +- .../ran-energy-saving/v1/en-US/template.md | 58 +++++++++---------- .../ran-energy-saving/v1/zh-CN/template.md | 58 +++++++++---------- 4 files changed, 56 insertions(+), 64 deletions(-) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json index c7c48c3a..5ec75144 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json @@ -49,7 +49,7 @@ "examples": [ "" ], - "x-a2at-value-constraint": "Intent Report Basic Information: unique identifier of the network energy saving report, unique identifier of the energy saving task, execution result of the overall intent (status, details, reason for not achieving). Intent Report Detailed Information: execution result of specific KPIs (status, details, reason for not achieving)." + "x-a2at-value-constraint": "Basic information about the intent execution result: area, intent goal achievement status. Detailed information about the intent execution result: energy saving gain achievement status (current energy saving gain goal, actual energy saving gain percentage, target adjustment suggestions), rate goal achievement status (current rate goal, actual rate, target adjustment suggestions)." } }, "required": ["operation_type", "task_object", "task_description"] diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/zh-CN/slot.json index fb76d91d..700a8fd0 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/zh-CN/slot.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/zh-CN/slot.json @@ -49,7 +49,7 @@ "examples": [ "" ], - "x-a2at-value-constraint": "意图报告基本信息:网络节能报告唯一标识、节能任务唯一标识、总体意图执行结果(状态、详情、未达成原因)。意图报告详细信息:具体KPI执行结果(状态、详情、未达成原因)。" + "x-a2at-value-constraint": "意图的执行结果基本信息:区域、意图目标达成情况。意图的执行结果详情:节能增益达成情况(当前节能增益目标、实际节能增益百分比、目标调整建议)、速率目标达成情况(当前速率目标、实际速率、目标调整建议)。" } }, "required": ["操作类型", "任务对象", "任务描述"] diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/en-US/template.md index 857401d0..6a205377 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/en-US/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/en-US/template.md @@ -17,8 +17,8 @@ Wireless network energy saving Requirement: 1. Please dynamically provide the task description based on (Create/Modify): - - When the type is [Create]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in . - - When the type is [Modify]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in . + - When the type is [Create]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in . + - When the type is [Modify]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in . ## Task Object @@ -27,9 +27,9 @@ Requirement: Requirement: 1. Area name: (required) - - Explicitly specify the energy saving coverage scope information, a geographic or logical area (e.g., district-level administrative unit) - - Must be a concrete and physically real community name such as "Songshanhu Administration Committee", rather than a description without a specific physical location such as "all communities", "indoor communities", or "outdoor communities" - - Example: Area: "Songshanhu Administration Committee" + - Explicitly specify the energy saving coverage scope information, a geographic or logical area (e.g., district-level administrative unit) + - Must be a concrete and physically real community name such as "Songshanhu Administration Committee", rather than a description without a specific physical location such as "all communities", "indoor communities", or "outdoor communities" + - Example: Area: "Songshanhu Administration Committee" ## Task Target @@ -38,8 +38,8 @@ Requirement: Requirement: 1. Define the quantitative or qualitative goals the task must achieve. Quantitative and qualitative goals cannot be selected at the same time, and the default is a qualitative goal. The specific description of energy saving goals is as follows: - - Quantitative goal: Energy consumption goal; the gain target of energy saving. The target value format must be a percentage, such as the percentage by which total power consumption is reduced. Example: Energy consumption goal: 30% - - Qualitative goal: Maximize energy saving under the conditions defined in the task context + - Quantitative goal: Energy consumption goal; the gain target of energy saving. The target value format must be a percentage, such as the percentage by which total power consumption is reduced. Example: Energy consumption goal: 30% + - Qualitative goal: Maximize energy saving under the conditions defined in the task context ## Task Context @@ -52,23 +52,23 @@ Requirement: 3. Carrier frequencies that can be turned off during energy saving. Example: Downlink frequency numbers: 1650, 1600 4. Rate guarantee goal: the minimum downlink throughput target that must be guaranteed and its effective time period. - - **For the area-level setting**, the following information needs to be provided: + - **For the area-level setting**, the following information needs to be provided: - Option 1: - - Time-segmented rate guarantee goal (required). For time periods without a rate guarantee goal set, experience-lossless energy saving is adopted by default, with a maximum of three rate values - - Example: Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps} + Option 1: + - Time-segmented rate guarantee goal (required). For time periods without a rate guarantee goal set, experience-lossless energy saving is adopted by default, with a maximum of three rate values + - Example: Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps} - Option 2: - - Full-period rate guarantee goal (required) - - Example: Rate guarantee goal: 10Mbps + Option 2: + - Full-period rate guarantee goal (required) + - Example: Rate guarantee goal: 10Mbps - - **For the site-level setting**, the following information needs to be provided: - - Site list (required) - - Time-segmented rate guarantee goal (required). For time periods without a rate guarantee goal set, experience-lossless energy saving is adopted by default, with a maximum of three rate values - - Background event and event duration (optional) - - Example: - - Site list 1: ["Site Name 1", "Site Name 2"], Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, Background event: power outage from 00:00 to 18:00. - - Site list 2: ["Site Name 3", "Site Name 4"], Time-segmented rate guarantee goal: {01:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, Background event: power outage from 01:00 to 18:00. + - **For the site-level setting**, the following information needs to be provided: + - Site list (required) + - Time-segmented rate guarantee goal (required). For time periods without a rate guarantee goal set, experience-lossless energy saving is adopted by default, with a maximum of three rate values + - Background event and event duration (optional) + - Example: + - Site list 1: ["Site Name 1", "Site Name 2"], Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, Background event: power outage from 00:00 to 18:00. + - Site list 2: ["Site Name 3", "Site Name 4"], Time-segmented rate guarantee goal: {01:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, Background event: power outage from 01:00 to 18:00. ## Expected Output @@ -77,13 +77,9 @@ Requirement: Requirement: 1. When the task object is a geographic/logical area, output the intent report for the network energy saving task. The intent report should contain the following information: - -### Intent Report Basic Information - -1. Unique identifier of the network energy saving report -2. Unique identifier of the energy saving task -3. Execution result of the overall intent, including the execution result status and execution result details. If the goal is not achieved, describe the specific reason for not achieving it - -### Intent Report Detailed Information - -1. Execution result of specific KPIs, including the execution result status and execution result details. If the goal is not achieved, describe the specific reason for not achieving it + - Basic information about the intent execution result, including: + - Area + - Intent goal achievement status + - Detailed information about the intent execution result, including: + - Energy saving gain achievement status, current energy saving gain goal, actual energy saving gain percentage, target adjustment suggestions + - Rate goal achievement status, current rate goal, actual rate, target adjustment suggestions diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/zh-CN/template.md index e35cab44..67866a88 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/zh-CN/template.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/zh-CN/template.md @@ -17,8 +17,8 @@ 要求: 1. 请根据<操作类型>(创建/修改),动态提供任务描述信息: - - 类型为【创建】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。 - - 类型为【修改】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标。 + - 类型为【创建】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。 + - 类型为【修改】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标。 ## 任务对象(Task Object) @@ -27,9 +27,9 @@ 要求: 1. 区域名称:(必填) - - 明确指定节能覆盖范围信息,地理或逻辑区域(如 区级行政单位) - - 必须是类似"松山湖管委会"这种具体且物理真实的小区名称,而不是"所有小区"、"室内小区"、"室外小区"这种无具体物理位置的描述 - - 举例:区域:"松山湖管委会" + - 明确指定节能覆盖范围信息,地理或逻辑区域(如 区级行政单位) + - 必须是类似"松山湖管委会"这种具体且物理真实的小区名称,而不是"所有小区"、"室内小区"、"室外小区"这种无具体物理位置的描述 + - 举例:区域:"松山湖管委会" ## 任务目标(Task Target) @@ -38,8 +38,8 @@ 要求: 1. 定义任务需达成的量化或定性目标,量化目标和定性目标不可同时选择,默认为定性目标。节能目标的具体说明如下: - - 量化目标:能耗目标;节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比;举例:能耗目标:30% - - 定性目标:在满足任务上下文的条件下进行最大化节能 + - 量化目标:能耗目标;节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比;举例:能耗目标:30% + - 定性目标:在满足任务上下文的条件下进行最大化节能 ## 任务上下文(Task Context) @@ -52,23 +52,23 @@ 3. 可在节能时关闭载波的频点;举例:下行频点号:1650,1600 4. 速率保障目标:需要保障的下行吞吐率目标最小值及生效时间段。 - - **针对区域级设置方式**,需要填写以下信息: + - **针对区域级设置方式**,需要填写以下信息: - 方式一: - - 分时段速率保障目标(必填),未设置速率保障目标时间段,默认采取用户体验无损方式节能,最多设置三种速率值 - - 举例:分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps} + 方式一: + - 分时段速率保障目标(必填),未设置速率保障目标时间段,默认采取用户体验无损方式节能,最多设置三种速率值 + - 举例:分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps} - 方式二: - - 全时段速率保障目标(必填) - - 举例:速率保障目标:10Mbps + 方式二: + - 全时段速率保障目标(必填) + - 举例:速率保障目标:10Mbps - - **针对站点级设置方式**,需要填写以下信息: - - 站点列表(必填) - - 分时段速率保障目标(必填),未设置速率保障目标时间段,默认采取用户体验无损方式节能,最多设置三种速率值 - - 背景事件与事件持续时间(选填) - - 举例: - - 站点列表1:["站点名称1","站点名称2"],分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps},背景事件:在00:00~18:00停电。 - - 站点列表2:["站点名称3","站点名称4"],分时段速率保障目标:{01:00~06:00,2Mbps}、{06:00~18:00,10Mbps},背景事件:在01:00~18:00停电。 + - **针对站点级设置方式**,需要填写以下信息: + - 站点列表(必填) + - 分时段速率保障目标(必填),未设置速率保障目标时间段,默认采取用户体验无损方式节能,最多设置三种速率值 + - 背景事件与事件持续时间(选填) + - 举例: + - 站点列表1:["站点名称1","站点名称2"],分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps},背景事件:在00:00~18:00停电。 + - 站点列表2:["站点名称3","站点名称4"],分时段速率保障目标:{01:00~06:00,2Mbps}、{06:00~18:00,10Mbps},背景事件:在01:00~18:00停电。 ## 预期输出(Expected Output) @@ -77,13 +77,9 @@ 要求: 1. 若任务对象为地理/逻辑区域时,输出网络节能任务的意图报告,意图报告应包含以下信息: - -### 意图报告基本信息 - -1. 网络节能报告的唯一标识 -2. 节能任务唯一标识 -3. 总体意图的执行结果,执行结果包括执行结果状态,执行结果详情,如果未达成目标,描述未达成的具体原因 - -### 意图报告详细信息 - -1. 具体KPI的执行结果,执行结果包括执行结果状态,执行结果详情,如果未达成目标,描述未达成的具体原因 + - 意图的执行结果基本信息,包含如下内容: + - 区域 + - 意图目标达成情况 + - 意图的执行结果详情,包含如下内容: + - 节能增益达成情况、当前的节能增益目标、实际节能增益百分比、目标调整建议 + - 速率目标达成情况、当前的速率目标、实际速率、目标调整建议 From 2155c36a50239e56e4d3876f0553474483b30b1b Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Wed, 16 Sep 2026 12:36:08 +0800 Subject: [PATCH 274/295] chore(release): bump version to 1.1.1 Signed-off-by: zhangwenjun-huawei --- docs/en/developer_guide.md | 2 +- "docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" | 2 +- pom.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 032ffcbb..52feadac 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -293,7 +293,7 @@ Business projects can use the BOM to manage A2A-T SDK versions uniformly: net.openan.a2a-t.sdk a2a-t-bom - 1.1.0 + 1.1.1 pom import diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index a2658c3e..a44a63a3 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -292,7 +292,7 @@ flowchart TD net.openan.a2a-t.sdk a2a-t-bom - 1.1.0 + 1.1.1 pom import diff --git a/pom.xml b/pom.xml index e749c01e..3ea1dcf8 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ - 1.1.0 + 1.1.1 17 UTF-8 3.3.1 From 28ddd6390e4be6c74e1e8fed864935ac7d37ef6f Mon Sep 17 00:00:00 2001 From: zhangwenjun-huawei Date: Wed, 16 Sep 2026 12:50:05 +0800 Subject: [PATCH 275/295] chore(ci): exclude corpus module from release pipeli Signed-off-by: zhangwenjun-huawei --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28796965..19450df6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,13 +64,13 @@ jobs: EOF - name: Verify - run: mvn -B verify + run: mvn -B verify -pl '!a2a-t-corpus' - name: Publish env: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} MAVEN_GPG_PASSPHRASE: ${{ secrets.SIGNING_PASSWORD }} run: > - mvn -B -Prelease deploy + mvn -B -Prelease deploy -pl '!a2a-t-corpus' -Dgpg.executable=gpg -Dgpg.keyname="${SIGNING_KEY_ID}" From ab2bf353bd597ae643cecc35f49e289442da24f3 Mon Sep 17 00:00:00 2001 From: z00930736 Date: Tue, 20 Oct 2026 15:06:32 +0800 Subject: [PATCH 276/295] docs: add observability java port design spec --- ...26-10-20-observability-java-port-design.md | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-20-observability-java-port-design.md diff --git a/docs/superpowers/specs/2026-10-20-observability-java-port-design.md b/docs/superpowers/specs/2026-10-20-observability-java-port-design.md new file mode 100644 index 00000000..38494c7b --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-observability-java-port-design.md @@ -0,0 +1,218 @@ +# A2A-T Java SDK 可观测性设计(Python 版移植) + +> **版本**: v1.0 +> **日期**: 2026-10-20 +> **定位**: 将 `a2a-t-sdk-python` 的可观测模块(v3.0 装饰器架构)忠实移植到 `a2a-t-sdk-java` +> **范围决策**: 完全对齐 Python(SDK 内部插桩 + a2a-java 协议层装饰器);OTel 依赖采用 api 编译 + sdk/exporter 可选;双模块拆分;日志 JSON 行格式 + +--- + +## 1. 背景与目标 + +Python 版可观测模块(参照 `a2a-t-sdk-python/observity.md` 与 `src/a2a_t/observability/`)提供三大信号: + +- **Trace**:协议层入口 Span(`SendMessage`/`GetTask` 等)、SSE per-event Span(同 trace 孤儿上下文 + Link)、W3C traceparent 传播 +- **Metrics**:`gen_ai.client.operation.duration`、`gen_ai.client.token.usage`、`a2at.task.request.duration`(双端各记)、`a2at.negotiation.total_rounds` +- **Logs**:结构化事件(`task.request`、`task.status_changed`、`task.artifact`、`negotiation.message` 等) + +### 1.1 Java 版的关键差异 + +| 差异点 | Python | Java | +|---|---|---| +| A2A 协议层归属 | SDK 内含(装饰 a2a-sdk 的 `ClientTransport`/`RequestHandler`) | SDK 模块**不含** a2a-java(仅 sample 依赖);协议层插桩放入独立可选模块 | +| 鸭子类型 | observability 层用 `getattr` 结构式探测,零编译期 a2a 依赖 | Java 无法 duck-type,协议层模块必须编译期依赖 a2a-java 1.1.0.Final | +| 门面插桩 | `trace_facade()` 实例方法动态包装 | 门面为 final 类且 SDK 全同步,改为 `TelemetryHook` SPI + builder `@Nullable` 注入(沿用 `llmClient(@Nullable ...)` 惯例) | +| NoOp 降级 | 手写 `_NoOpTracer` 等类 | OTel API 天然 no-op(未装 SDK 时 `GlobalOpenTelemetry` 返回 no-op),只需开关短路 | +| LLM 用量桥接 | contextvar stash | ThreadLocal(SDK 全同步,无协程切换问题) | +| 客户端流式 | async generator | 回调式 `Consumer` | +| 服务端流式 | EventQueue 装饰 | `Flow.Publisher` 包装 | +| 日志 | Python logging JSON record | SLF4J + Jackson 序列化 JSON 行(**决策:JSON 而非仓库 key=value 惯例,对齐后端摄入管道**) | + +### 1.2 目标 + +- Span/Span 属性/Metric/Log 事件命名与 Python 版**逐字一致**,保证仪表盘查询零改动 +- 永不破坏业务流:所有插桩点异常吞并 + WARN +- OTel 未安装 / 开关关闭时全链路 pass-through +- 核心 SDK 模块对 OTel 与 a2a-java 保持零依赖 + +## 2. 模块结构(方案 A,已确认) + +``` +a2a-t-observability 核心:配置/装配/手动 API/传播/日志/指标/LLM 装饰器 + 依赖: a2a-t-core, a2a-t-llm, + io.opentelemetry:opentelemetry-api (compile), + opentelemetry-sdk + opentelemetry-exporter-otlp-grpc + + opentelemetry-exporter-otlp-http + opentelemetry-exporter-logging (true) + +a2a-t-observability-a2a 协议层装饰器(可选模块) + 依赖: a2a-t-observability, + org.a2aproject.sdk:a2a-java-sdk-client-transport-spi / -server-common / -spec / -jsonrpc-common (1.1.0.Final) +``` + +- 两模块登记进根 `pom.xml` `` 与 `a2a-t-bom` +- 包名:`net.openan.a2at.sdk.observability`(及 `net.openan.a2at.sdk.observability.a2a`),包级 `@NullMarked` +- a2a-java 版本 1.1.0.Final(本地 `.m2` 已有;含 `ClientTransportWrapper` SPI、抽象类 `OpenTelemetryRequestHandlerDecorator` 先例、`PushNotificationSender`、`EventEnqueueHook`) + +### 2.1 既有模块改动(最小侵入) + +| 模块 | 改动 | +|---|---| +| `a2a-t-core` | 新增 `net.openan.a2at.sdk.core.telemetry.TelemetryHook` SPI 接口 + `NO_OP` 常量(见 §4) | +| `a2a-t-client` | `DefaultA2ATClientBuilder` 新增 `telemetryHook(@Nullable TelemetryHook)`;协商终态时回调 `reportNegotiationRounds` | +| `a2a-t-server` | 同上(`DefaultA2ATServerBuilder`) | +| `a2a-t-bom` / 根 pom | 登记两新模块与依赖管理 | + +依赖方向保持单向:`observability → core/llm`,`observability-a2a → observability + a2a-java`。**`ObservabilityLLMClient` 由使用者/sample 组装时显式包装**(builder 不感知 OTel),文档给出示例。 + +## 3. 核心模块 `a2a-t-observability` 内部设计 + +与 Python `src/a2a_t/observability/` 文件一一对应: + +| Python | Java 类 | 要点 | +|---|---|---| +| `config.py` | `A2ATObservabilityConfig` | record + builder + `fromMap(DotEnvConfigSource)`;显式 > 环境变量 > 默认 | +| `setup.py` | `ObservabilitySetup` | `ensureConfigured()`:自动装配 `SdkTracerProvider`/`SdkMeterProvider` + `BatchSpanProcessor`/`PeriodicExportingMetricReader`;OTLP gRPC(默认)或 HTTP/protobuf(endpoint 自动拼 `/v1/traces`、`/v1/metrics`);无 endpoint 降级 Console(`opentelemetry-exporter-logging` 的 `LoggingSpanExporter`/`LoggingMetricExporter`);幂等(已注册非 Proxy/空 provider 则跳过);`sdk` 类缺失时 `Class.forName` 探测跳过自动装配 | +| `_otel_compat.py` | `ObservabilityRuntime` | 总开关 `OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED`、tracer/meter 访问器(scope `a2at-observability`/`1.0.0`)、trace/metric/log 三信号独立开关 | +| `span.py` | `A2ATSpan implements AutoCloseable` | try-with-resources;kind 枚举 `INTERNAL/CLIENT/SERVER/PRODUCER/CONSUMER`;`setAttribute`/`addLink(A2ATSpan)`;close 时记录异常 + OK/ERROR 状态 | +| `current_span.py` | `CurrentSpans.current()` → `A2ATCurrentSpan` | 只读视图:`traceId()`/`spanId()`/`isValid()`/`setAttribute()`;非 recording 时返回空 | +| `attributes.py` | `A2ATAttributes` + `AttributeExtractors` | 全部属性常量 + 提取器(§5 命名注册表) | +| `propagation.py` | `TracePropagation` | `inject(Map)` / `extract(Map)`;W3C traceparent;key 小写容错;extract 返回 null 当 span context 无效 | +| `payload.py` | `PayloadSanitizer` | **先脱敏后截断**(安全顺序);默认 4096 + `[truncated]` 后缀;自定义 redactor 函数接口 | +| `logs.py` | `ObservabilityLogs` | SLF4J logger `net.openan.a2at.sdk.observability`;**JSON 行**(Jackson 序列化):`{"event": ..., <业务字段>, "trace_id", "span_id", "payload"?}`;事件名沿用 Python | +| `llm_decorator.py` + `llm_stash.py` | `ObservabilityLLMClient implements LLMClient` + `LlmUsageStash` | `structured()` 返回后:记 `gen_ai.client.token.usage` 直方图(unit `{token}`,按 `gen_ai.token.type`=input/output 各一样本)+ `LlmUsageStash`(ThreadLocal)暂存(input/output tokens、request model)供协议层入口 Span 取用(**取后清空**) | +| `negotiation_metrics.py` | `NegotiationMetrics` | `a2at.negotiation.total_rounds` 直方图(unit `{round}`);属性 `gen_ai.agent.a2at.extension.name="Negotiation-T"`、`negotiation.id`、`outcome`(accept/reject/abort);仅终态触发 | +| `sdk_trace.py` | `SdkOperationSpans` | `TelemetryHook` 的 OTel 实现:`a2at.sdk.{role}.{method}` INTERNAL Span | + +指标 instrument 缓存沿用 Python 的惰性单例模式(`ConcurrentHashMap`)。 + +## 4. `TelemetryHook` SPI(a2a-t-core) + +```java +package net.openan.a2at.sdk.core.telemetry; + +public interface TelemetryHook { + TelemetryOperation startSdkOperation(String role, String method); + void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds); + + TelemetryHook NO_OP = new TelemetryHook() { ... }; // 全 no-op +} +``` + +- `TelemetryOperation`:AutoCloseable(try-with-resources 结束 Span)+ `setAttribute(String, Object)`;调用方在业务成功后先调 `markSuccess()` 再 close(close 时若未 markSuccess 且无异常标记则记 ERROR),异常路径由 try/catch 中的 `markError(Throwable)` 标记 +- 调用点一律 try/catch 包裹 + WARN,业务异常原样抛出 +- `startSdkOperation` 等价 Python `trace_facade`(opt-in L4):注入 hook 后每个门面方法包 Span,名 `a2at.sdk.client.generateTaskPrompt` / `a2at.sdk.server.checkTaskPrompt` 等 + +## 5. 命名注册表(与 Python 逐字一致) + +### 5.1 Span 名(kind) + +| Span 名 | kind | 端 | +|---|---|---| +| `SendMessage` / `SendStreamingMessage` | CLIENT / SERVER | 双端 | +| `SendStreamingMessage-event` | CLIENT / SERVER | 双端(同 trace 孤儿上下文 + Link 指向入口 Span) | +| `SendStreamingMessage-error` | CLIENT | 错误回调(`error.type` 属性) | +| `SendStreamingMessage-negotiation` | SERVER | 协商 Message 事件(**父子**挂入口 Span) | +| `SubscribeToTask` / `GetTask` / `ListTasks` / `CancelTask` | CLIENT / SERVER | 双端 | +| `CreateTaskPushNotificationConfig` / `GetTaskPushNotificationConfig` / `ListTaskPushNotificationConfigs` / `DeleteTaskPushNotificationConfig` | CLIENT / SERVER | 双端 | +| `GetExtendedAgentCard` | CLIENT / SERVER | 双端 | +| `SendMessage-pushNotification` | CLIENT | PushSender 装饰器(Link 入口 Span) | +| `a2at.sdk.{role}.{method}` | INTERNAL | SDK 门面(opt-in) | + +流结束判定:终态事件(`completed`/`failed`/`canceled`/`rejected`)。状态语义:取消 → ERROR "cancelled";其他异常 → record_exception + ERROR;成功 → OK。 + +### 5.2 属性 + +- A2A-T 扩展(前缀 `gen_ai.agent.a2at.`):`extension.name`(由扩展 URI 解析,基址 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/` → `Negotiation-T` 等)、`task.id`、`task.status`、`task.type`、`negotiation.id/round/max_rounds/performative/total_rounds`、`notification.topic`、`streaming.event.kind`、`push.notification.url`、`authorization.policy.operation.type` +- a2a-java 仪表盘双写(前缀 `gen_ai.agent.a2a.`,与 `A2AObservabilityNames` 对齐):`operation.name`、`context_id`、`task_id`、`message_id`、`role`、`extensions`、`parts.number`、`protocol`、`request`、`response` +- GenAI semconv:`gen_ai.operation.name`、`gen_ai.conversation.id`、`gen_ai.usage.input_tokens/output_tokens`、`gen_ai.request.model`、`gen_ai.response.model`、`gen_ai.token.type`、`streaming`、`error.type` +- 侧别:`a2at.span.side` = `client` | `server` +- 报文正则提取(默认值照搬 Python):任务类型 `##\s*任务类型\(Task Type\)`、通知主题 `##\s*通知主题`、授权操作类型 `##\s*授权策略的操作类型` + +### 5.3 指标 + +| 指标 | 类型/unit | 属性 | +|---|---|---| +| `gen_ai.client.operation.duration` | Histogram, s | `gen_ai.operation.name`(client 端) | +| `gen_ai.client.token.usage` | Histogram, `{token}` | `gen_ai.token.type`=input/output | +| `a2at.task.request.duration` | Histogram, s | `gen_ai.operation.name` + `a2at.span.side`(双端各记) | +| `a2at.negotiation.total_rounds` | Histogram, `{round}` | `extension.name` + `negotiation.id` + `outcome` | + +### 5.4 日志事件 + +`task.request`(DEBUG)、`task.response`(DEBUG)、`task.status_changed`(INFO)、`task.artifact`(INFO)、`notification.push`(INFO)、`negotiation.message`(DEBUG);字段:`task.id`、`task.status`、`artifact.name`、`extension.name`、`trace_id`、`span_id`、`payload?`。 + +## 6. 协议层 `a2a-t-observability-a2a` 设计 + +### 6.1 Client:`A2ATClientTransportDecorator implements ClientTransport` + +- 覆写全部 12 方法;入口 Span kind=CLIENT,名见 §5.1 +- **传播**:从 `ClientCallContext.getHeaders()`(小写容错)提取调用方 traceparent 为父;无则显式 ROOT(`NonRecordingSpan(INVALID_SPAN_CONTEXT)` 语义等价物);向 delegate 传递**新建** `ClientCallContext`(原 state + 注入入口 Span traceparent 的 headers,不改传入对象) +- **流式**(`sendMessageStreaming`/`subscribeToTask`):包装两个 Consumer;每事件发 `SendStreamingMessage-event`(同 trace 孤儿上下文 + Link);错误 Consumer 发 `SendStreamingMessage-error`;终态事件止流 +- **属性**:请求结构化提取 + 双写 + stash 中 LLM token 用量 + 正则提取 +- **指标**:`gen_ai.client.operation.duration`、`a2at.task.request.duration`(side=client) +- 另提供 `A2ATA2ATClientTransportWrapper implements ClientTransportWrapper`(a2a-java 原生 SPI,可注册自动包装,等价 Python `register_client_factory`) + +### 6.2 Server:`A2ATRequestHandlerDecorator implements RequestHandler` + +- `onMessageSend`→`SendMessage`;`onMessageSendStream`→`SendStreamingMessage`(Span 存活至流结束,try/finally);`onSubscribeToTask`→`SubscribeToTask`(仅入口);其余 `_runSimple` 等价处理;kind=SERVER +- **父上下文**:`ServerCallContext.getState()` 探测 headers(运行时探测 `headers`/`http.headers` 等常见 key,小写容错) +- **流式 per-event**:包装返回的 `Flow.Publisher`(订阅者包装);每事件 `SendStreamingMessage-event` SERVER Span(孤儿 + Link);协商 Message 事件 `SendStreamingMessage-negotiation`(**父子**)+ `negotiation.message` 日志 +- **入口 Span 注册表**:有界 map(上限 2048,FIFO 淘汰),task_id 与 context_id 双 key(context_id 仅在入口 Span 仍 recording 时作 fallback),供 per-event/Push/executor 关联 +- **指标**:`a2at.task.request.duration`(side=server) + +### 6.3 协作装饰器 + +- `A2ATAgentExecutorDecorator implements AgentExecutor`:激活入口 Span(业务代码可见 `current_span()`)。用法:**先包 executor 再 `DefaultRequestHandler.create(...)`,后包 handler**(create 显式收 executor,无需反射) +- `A2ATPushSenderDecorator implements PushNotificationSender`:`SendMessage-pushNotification` CLIENT Span + Link 入口 Span + `push.notification.url` 属性 +- `PayloadSanitizer` 复用核心模块;payload 提取(request/response JSON)受 `A2AT_EXTRACT_REQUEST/RESPONSE` 门控 + +### 6.4 与 a2a-java 自带 OTel 插桩的关系 + +**二选一,不叠加**:使用本模块装饰器时,不得同时注册 `OpenTelemetryClientTransportWrapper` / `OpenTelemetryClientPropagatorTransportWrapper` / `OpenTelemetryRequestHandlerDecorator`,否则双重 Span。README 明示。 + +## 7. 配置 + +| 键 | 默认 | 说明 | +|---|---|---| +| `OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED` | `true` | 总开关,false → 全 no-op | +| `A2AT_OBSERVABILITY_ENABLED` | `true` | 装饰器门闸,false → 装饰器纯转发 | +| `A2AT_TRACE_ENABLED` / `A2AT_METRIC_ENABLED` / `A2AT_LOG_ENABLED` | `true` | 三信号独立(trace 关,metrics/logs 照常) | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | 无 | 无 → Console 降级 | +| `A2AT_EXPORTER_PROTOCOL` | `grpc` | `grpc` \| `http/protobuf`(别名 `http`;非法 → grpc + WARN) | +| `A2AT_CONSOLE_EXPORTER` | `true` | 关闭 Console 降级 | +| `OTEL_SERVICE_NAME` / `A2AT_SERVICE_NAME` | `a2a-t-agent` | Resource `service.name`;`A2AT_AGENT_ROLE` → Resource `agent.role`(opt-in) | +| `A2AT_EXTRACT_REQUEST` / `A2AT_EXTRACT_RESPONSE` | `false` | payload 上 Span 属性 | +| `A2AT_LOGS_PAYLOAD_ENABLED` | `false` | 日志 payload 通道 | +| `A2AT_LOGS_PAYLOAD_MAX_LENGTH` | `4096` | 截断上限(`[truncated]`) | + +读取顺序:`.env`(复用 core `DotEnvConfigSource`)→ 系统环境变量 → 默认值。`A2ATObservabilityConfig` 可编程构造传入各装饰器;正则可覆盖(空串 = opt-out)。懒装配:首次装饰调用时 `ensureConfigured()`。 + +## 8. 降级与错误处理(三条铁律) + +1. **永不破坏业务流**:插桩点 try/catch + WARN(logger `net.openan.a2at.sdk.observability`),业务异常原样抛出 +2. **全链路 pass-through**:OTel SDK 未安装 / 开关关 → 装饰器纯转发;OTel API 层天然 no-op 兜底 +3. **Span 激活防泄漏**:入口 Span makeCurrent 前快照 ambient `Context`,finally 强制恢复(防御异步/SSE 跨线程 detach 失败) + +## 9. 测试策略 + +沿用仓库惯例:JUnit 5 + 手写 scripted stub + logback `ListAppender`;无 Mockito。 + +- **核心模块**:配置解析矩阵(显式/env/默认/非法值)、装配幂等与降级(无 endpoint → Console;无 sdk 类 → 跳过)、传播 inject/extract 往返与容错、Payload 脱敏→截断顺序、LLM 装饰器 token 指标与 stash 取后清空、协商指标终态触发 +- **协议层**:scripted `ClientTransport`/`RequestHandler`/`AgentExecutor`/`PushNotificationSender` fake + a2a-java spec 真实类型构造请求/事件;用 `SdkTracerProvider` + 内存 `SpanProcessor`(收集已结束 Span)断言:Span 名/kind/属性/状态/Link 拓扑/per-event 序列/双端指标 +- **合同测试**:公共 API 导出面测试(对齐 Python `test_public_api.py`)、日志 JSON 字段合同、包结构测试 +- **既有模块回归**:builder `@Nullable` 注入点新增注入测试;全量 `mvn test` +- **Sample**:`a2a-t-sample` 新增 `observability` 示例场景(对标 Python `observability-sample`:Console 模式端到端跑通 trace/metrics/logs) + +## 10. 交付边界(不做的事) + +- 不做 Micrometer/Prometheus 桥、StatsD、Zipkin +- 不做 ByteBuddy/Java Agent 字节码插桩 +- 不做动态实例包装(Python `trace_facade` 的 Java 等价由 builder 注入 hook 实现) +- 不叠加 a2a-java 自带 OTel 插桩(二选一) +- 协议层模块不追求兼容 a2a-java 1.0.0.Beta1(锁定 1.1.0.Final) + +## 11. 参考 + +- Python 实现:`a2a-t-sdk-python/src/a2a_t/observability/`(对照文件清单见 §3 表格) +- Python 设计大纲:`a2a-t-sdk-python/observity.md` +- a2a-java 接缝(已用 javap 验证 1.1.0.Final):`ClientTransport`、`ClientTransportWrapper`、`ClientCallContext(Map state, Map headers)`、`RequestHandler`(流式返回 `Flow.Publisher`)、`ServerCallContext.getState()`、`AgentExecutor`、`PushNotificationSender`、`DefaultRequestHandler.create(AgentExecutor, TaskStore, QueueManager, PushNotificationConfigStore, MainEventBusProcessor, Executor, Executor)`、`A2AObservabilityNames` From 1c2898c3cdd03e011fbea795a2babf857f67c4fb Mon Sep 17 00:00:00 2001 From: z00930736 Date: Tue, 20 Oct 2026 15:22:58 +0800 Subject: [PATCH 277/295] docs: amend observability spec after feasibility review --- ...26-10-20-observability-java-port-design.md | 79 +++++++++++++------ 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/specs/2026-10-20-observability-java-port-design.md b/docs/superpowers/specs/2026-10-20-observability-java-port-design.md index 38494c7b..229e7455 100644 --- a/docs/superpowers/specs/2026-10-20-observability-java-port-design.md +++ b/docs/superpowers/specs/2026-10-20-observability-java-port-design.md @@ -41,12 +41,13 @@ Python 版可观测模块(参照 `a2a-t-sdk-python/observity.md` 与 `src/a2a_ a2a-t-observability 核心:配置/装配/手动 API/传播/日志/指标/LLM 装饰器 依赖: a2a-t-core, a2a-t-llm, io.opentelemetry:opentelemetry-api (compile), - opentelemetry-sdk + opentelemetry-exporter-otlp-grpc - + opentelemetry-exporter-otlp-http + opentelemetry-exporter-logging (true) + opentelemetry-sdk + opentelemetry-exporter-otlp + + opentelemetry-exporter-logging (true) a2a-t-observability-a2a 协议层装饰器(可选模块) 依赖: a2a-t-observability, - org.a2aproject.sdk:a2a-java-sdk-client-transport-spi / -server-common / -spec / -jsonrpc-common (1.1.0.Final) + org.a2aproject.sdk:a2a-java-sdk-client-transport-spi / -server-common / -spec / -jsonrpc-common + / -transport-rest (1.1.0.Final,RestContextKeys.HEADERS_KEY 提取 headers) ``` - 两模块登记进根 `pom.xml` `` 与 `a2a-t-bom` @@ -71,16 +72,16 @@ a2a-t-observability-a2a 协议层装饰器(可选模块) | Python | Java 类 | 要点 | |---|---|---| | `config.py` | `A2ATObservabilityConfig` | record + builder + `fromMap(DotEnvConfigSource)`;显式 > 环境变量 > 默认 | -| `setup.py` | `ObservabilitySetup` | `ensureConfigured()`:自动装配 `SdkTracerProvider`/`SdkMeterProvider` + `BatchSpanProcessor`/`PeriodicExportingMetricReader`;OTLP gRPC(默认)或 HTTP/protobuf(endpoint 自动拼 `/v1/traces`、`/v1/metrics`);无 endpoint 降级 Console(`opentelemetry-exporter-logging` 的 `LoggingSpanExporter`/`LoggingMetricExporter`);幂等(已注册非 Proxy/空 provider 则跳过);`sdk` 类缺失时 `Class.forName` 探测跳过自动装配 | -| `_otel_compat.py` | `ObservabilityRuntime` | 总开关 `OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED`、tracer/meter 访问器(scope `a2at-observability`/`1.0.0`)、trace/metric/log 三信号独立开关 | +| `setup.py` | `ObservabilitySetup` | `ensureConfigured()`:自动装配 `SdkTracerProvider`/`SdkMeterProvider`/`SdkLoggerProvider` + `BatchSpanProcessor`/`PeriodicExportingMetricReader`/`BatchLogRecordProcessor`(**三信号**,对齐 Python);OTLP gRPC(默认)或 HTTP/protobuf(endpoint 自动拼 `/v1/traces`、`/v1/metrics`、`/v1/logs`;Java `opentelemetry-exporter-otlp` 单 artifact 含三种 exporter);无 endpoint 降级 Console(`opentelemetry-exporter-logging` 的 `LoggingSpanExporter`/`LoggingMetricExporter`/`LoggingLogRecordExporter`);幂等(已注册非 Proxy/空 provider 则跳过);`sdk` 类缺失时 `Class.forName` 探测跳过自动装配 | +| `_otel_compat.py` | `ObservabilityRuntime` | 总开关 `OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED`、tracer/meter 访问器(scope `a2at-observability`/`1.0.0`)、trace/metric/log 三信号独立开关、`sameTraceOrphanContext`(同 trace 随机 span_id 无父上下文 + 0 值守卫) | | `span.py` | `A2ATSpan implements AutoCloseable` | try-with-resources;kind 枚举 `INTERNAL/CLIENT/SERVER/PRODUCER/CONSUMER`;`setAttribute`/`addLink(A2ATSpan)`;close 时记录异常 + OK/ERROR 状态 | | `current_span.py` | `CurrentSpans.current()` → `A2ATCurrentSpan` | 只读视图:`traceId()`/`spanId()`/`isValid()`/`setAttribute()`;非 recording 时返回空 | | `attributes.py` | `A2ATAttributes` + `AttributeExtractors` | 全部属性常量 + 提取器(§5 命名注册表) | | `propagation.py` | `TracePropagation` | `inject(Map)` / `extract(Map)`;W3C traceparent;key 小写容错;extract 返回 null 当 span context 无效 | | `payload.py` | `PayloadSanitizer` | **先脱敏后截断**(安全顺序);默认 4096 + `[truncated]` 后缀;自定义 redactor 函数接口 | -| `logs.py` | `ObservabilityLogs` | SLF4J logger `net.openan.a2at.sdk.observability`;**JSON 行**(Jackson 序列化):`{"event": ..., <业务字段>, "trace_id", "span_id", "payload"?}`;事件名沿用 Python | -| `llm_decorator.py` + `llm_stash.py` | `ObservabilityLLMClient implements LLMClient` + `LlmUsageStash` | `structured()` 返回后:记 `gen_ai.client.token.usage` 直方图(unit `{token}`,按 `gen_ai.token.type`=input/output 各一样本)+ `LlmUsageStash`(ThreadLocal)暂存(input/output tokens、request model)供协议层入口 Span 取用(**取后清空**) | -| `negotiation_metrics.py` | `NegotiationMetrics` | `a2at.negotiation.total_rounds` 直方图(unit `{round}`);属性 `gen_ai.agent.a2at.extension.name="Negotiation-T"`、`negotiation.id`、`outcome`(accept/reject/abort);仅终态触发 | +| `logs.py` | `ObservabilityLogs` | SLF4J logger `net.openan.a2at.sdk.observability`;**JSON 行**(Jackson 序列化,`ensure_ascii=false` 等价):`{"event": ..., <业务字段>, "trace_id", "span_id", "payload"?}`;事件名沿用 Python;log_enabled 双门控(运行时开关 + config);**OTel 日志信号**:log_enabled 时同步写入 OTel `Logger` API(`SdkLoggerProvider` + OTLP/Console exporter),SLF4J 与 OTel 双通道共享同一 JSON 语义,对齐 Python LoggingHandler 桥接 | +| `llm_decorator.py` + `llm_stash.py` | `ObservabilityLLMClient implements LLMClient` + `LlmUsageStash` | `structured()` 返回后:从 `LLMResponse.usage`(键 `prompt_tokens`/`completion_tokens`,与 Python stash 读法一致)+ `model()` 取数;记 `gen_ai.client.token.usage` 直方图(unit `{token}`,按 `gen_ai.token.type`=input/output 各一样本)+ `LlmUsageStash`(ThreadLocal)暂存(input/output tokens、request model)供协议层入口 Span 取用(**取后清空**;trace 关闭时入口侧直接丢弃 stash) | +| `negotiation_metrics.py` | `NegotiationMetrics` | `a2at.negotiation.total_rounds` 直方图(unit `{round}`);属性 `gen_ai.agent.a2at.extension.name="Negotiation-T"`、`negotiation.id`、`outcome`(accept/reject/abort);仅终态触发;**数据路径差异**:Python 从返回的 `MetadataContent.negotiation_context` 取,Java 从门面入参/结果的 `NegotiationContext(type, negotiationId, round, NegotiationStatus)` 取(终态 status 映射 outcome),结果等价 | | `sdk_trace.py` | `SdkOperationSpans` | `TelemetryHook` 的 OTel 实现:`a2at.sdk.{role}.{method}` INTERNAL Span | 指标 instrument 缓存沿用 Python 的惰性单例模式(`ConcurrentHashMap`)。 @@ -123,8 +124,9 @@ public interface TelemetryHook { ### 5.2 属性 - A2A-T 扩展(前缀 `gen_ai.agent.a2at.`):`extension.name`(由扩展 URI 解析,基址 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/` → `Negotiation-T` 等)、`task.id`、`task.status`、`task.type`、`negotiation.id/round/max_rounds/performative/total_rounds`、`notification.topic`、`streaming.event.kind`、`push.notification.url`、`authorization.policy.operation.type` +- 预授权(无前缀,对齐 Python):`authorization.policy.id`、`authorization.operation_type`、`authorization.operation_risk_level` - a2a-java 仪表盘双写(前缀 `gen_ai.agent.a2a.`,与 `A2AObservabilityNames` 对齐):`operation.name`、`context_id`、`task_id`、`message_id`、`role`、`extensions`、`parts.number`、`protocol`、`request`、`response` -- GenAI semconv:`gen_ai.operation.name`、`gen_ai.conversation.id`、`gen_ai.usage.input_tokens/output_tokens`、`gen_ai.request.model`、`gen_ai.response.model`、`gen_ai.token.type`、`streaming`、`error.type` +- GenAI semconv:`gen_ai.operation.name`、`gen_ai.conversation.id`、`gen_ai.usage.input_tokens/output_tokens`、`gen_ai.request.model`、`gen_ai.response.model`、`gen_ai.token.type`、`gen_ai.provider.name`、`streaming`、`error.type` - 侧别:`a2at.span.side` = `client` | `server` - 报文正则提取(默认值照搬 Python):任务类型 `##\s*任务类型\(Task Type\)`、通知主题 `##\s*通知主题`、授权操作类型 `##\s*授权策略的操作类型` @@ -145,26 +147,28 @@ public interface TelemetryHook { ### 6.1 Client:`A2ATClientTransportDecorator implements ClientTransport` -- 覆写全部 12 方法;入口 Span kind=CLIENT,名见 §5.1 -- **传播**:从 `ClientCallContext.getHeaders()`(小写容错)提取调用方 traceparent 为父;无则显式 ROOT(`NonRecordingSpan(INVALID_SPAN_CONTEXT)` 语义等价物);向 delegate 传递**新建** `ClientCallContext`(原 state + 注入入口 Span traceparent 的 headers,不改传入对象) -- **流式**(`sendMessageStreaming`/`subscribeToTask`):包装两个 Consumer;每事件发 `SendStreamingMessage-event`(同 trace 孤儿上下文 + Link);错误 Consumer 发 `SendStreamingMessage-error`;终态事件止流 -- **属性**:请求结构化提取 + 双写 + stash 中 LLM token 用量 + 正则提取 -- **指标**:`gen_ai.client.operation.duration`、`a2at.task.request.duration`(side=client) +- 覆写全部 12 方法;入口 Span kind=CLIENT,名见 §5.1;**构造器防双重装饰守卫**(内层已是本装饰器则解包,杜绝双重 Span,对齐 Python factory-guard) +- **传播**:从 `ClientCallContext.getHeaders()`(小写容错)提取调用方 traceparent 为父(业务根传播 mode B);无则显式 ROOT(`SpanContext` INVALID 语义等价物);向 delegate 传递**新建** `ClientCallContext`(原 state + 注入入口 Span traceparent 的 headers,不改传入对象 —— 相比 Python 原地覆写 `service_parameters` 更安全,消除"覆写调用方业务根"缺陷) +- **流式**(`sendMessageStreaming`/`subscribeToTask`):包装两个 Consumer;每事件发 `SendStreamingMessage-event`(同 trace 孤儿上下文 + Link + `gen_ai.conversation.id`);错误 Consumer 发 `SendStreamingMessage-error`(`error.type`);**取消/中止类异常(`CancellationException`)不发 error span**(对齐 Python CancelledError 豁免);终态事件止流(对齐 a2a-java `shouldAutoClose`) +- **协商 Span(client 侧)**:sync 响应与流式事件中的协商 Message 发 `{操作名}-negotiation`(CLIENT kind,**父子**挂入口 Span,即时结束;属性 `gen_ai.operation.name`/`extension.name`/`gen_ai.conversation.id`/`task.id`/`negotiation.*`)+ `negotiation.message` 日志 +- **属性**:请求结构化提取 + 双写 + stash 中 LLM token 用量 + 正则提取(DEFAULT_* 回退、空串 opt-out、尾括号清洗);**trace 关闭时 stash 必须丢弃**(防泄漏到下一请求入口 Span,对齐 Python) +- **指标**:`gen_ai.client.operation.duration` + `a2at.task.request.duration`(side=client),双门控(运行时开关 + config 实例开关) - 另提供 `A2ATA2ATClientTransportWrapper implements ClientTransportWrapper`(a2a-java 原生 SPI,可注册自动包装,等价 Python `register_client_factory`) ### 6.2 Server:`A2ATRequestHandlerDecorator implements RequestHandler` -- `onMessageSend`→`SendMessage`;`onMessageSendStream`→`SendStreamingMessage`(Span 存活至流结束,try/finally);`onSubscribeToTask`→`SubscribeToTask`(仅入口);其余 `_runSimple` 等价处理;kind=SERVER -- **父上下文**:`ServerCallContext.getState()` 探测 headers(运行时探测 `headers`/`http.headers` 等常见 key,小写容错) -- **流式 per-event**:包装返回的 `Flow.Publisher`(订阅者包装);每事件 `SendStreamingMessage-event` SERVER Span(孤儿 + Link);协商 Message 事件 `SendStreamingMessage-negotiation`(**父子**)+ `negotiation.message` 日志 -- **入口 Span 注册表**:有界 map(上限 2048,FIFO 淘汰),task_id 与 context_id 双 key(context_id 仅在入口 Span 仍 recording 时作 fallback),供 per-event/Push/executor 关联 -- **指标**:`a2at.task.request.duration`(side=server) +- `onMessageSend`→`SendMessage`;`onMessageSendStream`→`SendStreamingMessage`(Span 存活至流结束,try/finally);`onSubscribeToTask`→`SubscribeToTask`(仅入口,不重复观测——事件由产生请求侧观测);其余简单包装;kind=SERVER +- **父上下文**:`ServerCallContext.getState()` 按 `RestContextKeys.HEADERS_KEY`(transport-rest)提取 headers,找不到时通用探测 `headers` key(小写容错,兼容 jsonrpc transport) +- **流式 per-event**:包装返回的 `Flow.Publisher`(订阅者包装,**仅流式操作**——同步 `SendMessage` 不产生幻影 event Span,对齐 Python `_start_event_span` 门);每事件 `SendStreamingMessage-event` SERVER Span(孤儿 + Link);协商 Message 事件发 `SendStreamingMessage-negotiation`(**父子**)+ `negotiation.message` 日志 +- **同步 `SendMessage` 的协商 Span**:响应分类后在收尾阶段发(对齐 Python `_capture_response`,EventQueue/Publisher 侧只管流式,避免双发) +- **入口 Span 注册表**:有界 map(上限 2048,FIFO 淘汰),task_id 与 context_id 双 key 注册;**context_id fallback 仅当该记录的入口 Span 仍在 recording 时可被采纳**(防止复用上一请求的记录,对齐 Python `_find_record`) +- **指标**:`a2at.task.request.duration`(side=server,双门控) ### 6.3 协作装饰器 - `A2ATAgentExecutorDecorator implements AgentExecutor`:激活入口 Span(业务代码可见 `current_span()`)。用法:**先包 executor 再 `DefaultRequestHandler.create(...)`,后包 handler**(create 显式收 executor,无需反射) -- `A2ATPushSenderDecorator implements PushNotificationSender`:`SendMessage-pushNotification` CLIENT Span + Link 入口 Span + `push.notification.url` 属性 -- `PayloadSanitizer` 复用核心模块;payload 提取(request/response JSON)受 `A2AT_EXTRACT_REQUEST/RESPONSE` 门控 +- `A2ATPushSenderDecorator implements PushNotificationSender`:`SendMessage-pushNotification` CLIENT Span + Link 入口 Span + `push.notification.url` 属性。**注入点**:a2a-java 的 push 发送经 `MainEventBusProcessor(MainEventBus, TaskStore, PushNotificationSender, QueueManager)` 公共构造器注入 → 先装饰 sender 再构造。**URL 提取**:a2a-java `sendNotification(event, task)` 不携带 URL(URL 在内部私有 dispatch 中);装饰器持有 `PushNotificationConfigStore`,发 Span 时按 task_id 查询配置取 URL(对齐 Python 经 `push_info.url` 的语义) +- `PayloadSanitizer` 复用核心模块;payload 提取(request/response JSON)受 `A2AT_EXTRACT_REQUEST/RESPONSE` 门控;span 属性通道(`gen_ai.agent.a2a.request/response`)与日志通道(`task.request`/`task.response`)共享同一 redact→truncate 语义 ### 6.4 与 a2a-java 自带 OTel 插桩的关系 @@ -198,8 +202,16 @@ public interface TelemetryHook { 沿用仓库惯例:JUnit 5 + 手写 scripted stub + logback `ListAppender`;无 Mockito。 - **核心模块**:配置解析矩阵(显式/env/默认/非法值)、装配幂等与降级(无 endpoint → Console;无 sdk 类 → 跳过)、传播 inject/extract 往返与容错、Payload 脱敏→截断顺序、LLM 装饰器 token 指标与 stash 取后清空、协商指标终态触发 -- **协议层**:scripted `ClientTransport`/`RequestHandler`/`AgentExecutor`/`PushNotificationSender` fake + a2a-java spec 真实类型构造请求/事件;用 `SdkTracerProvider` + 内存 `SpanProcessor`(收集已结束 Span)断言:Span 名/kind/属性/状态/Link 拓扑/per-event 序列/双端指标 -- **合同测试**:公共 API 导出面测试(对齐 Python `test_public_api.py`)、日志 JSON 字段合同、包结构测试 +- **协议层**:scripted `ClientTransport`/`RequestHandler`/`AgentExecutor`/`PushNotificationSender` fake + a2a-java spec 真实类型构造请求/事件;用 `SdkTracerProvider` + 内存 `SpanProcessor`(收集已结束 Span)断言:Span 名/kind/属性/状态/Link 拓扑/per-event 序列/双端指标;**双重装饰守卫**(装饰器套装饰器只出一层 Span);**取消豁免**(CancellationException 不发 error span);**context_id fallback 仅 recording 可采纳** +- **公共 API 导出面合同**(对齐 Python `test_public_api.py` 的 "nothing more, nothing less"),Java 导出面基准: + - 装配:`ObservabilitySetup.setup / isConfigured` + - 集成:`A2ATClientTransportWrapper`、`A2ATRequestHandlerDecorator`(+ Executor/PushSender 装饰器) + - 手动 API:`A2ATSpan`(try-with-resources)、`CurrentSpans.current()` / `A2ATCurrentSpan` + - 门面追踪:`TelemetryHook`(+ `SdkOperationSpans` 实现) + - 传播:`TracePropagation.inject/extract`、`TRACEPARENT_HEADER` + - 配置:`A2ATObservabilityConfig` + - 属性常量:§5.2 全量(A2A-T 扩展 14 个 + 预授权 3 个 + GenAI 7 个 + `a2at.span.side`/`streaming`/`error.type`) +- **日志 JSON 字段合同**(ListAppender + Jackson 解析断言 event/trace_id/span_id/payload) - **既有模块回归**:builder `@Nullable` 注入点新增注入测试;全量 `mvn test` - **Sample**:`a2a-t-sample` 新增 `observability` 示例场景(对标 Python `observability-sample`:Console 模式端到端跑通 trace/metrics/logs) @@ -211,8 +223,23 @@ public interface TelemetryHook { - 不叠加 a2a-java 自带 OTel 插桩(二选一) - 协议层模块不追求兼容 a2a-java 1.0.0.Beta1(锁定 1.1.0.Final) -## 11. 参考 +## 11. 可行性核查结论(2026-10-20,源码级验证) -- Python 实现:`a2a-t-sdk-python/src/a2a_t/observability/`(对照文件清单见 §3 表格) +| # | 接缝 | 结论 | +|---|---|---| +| 1 | Client 12 方法装饰 + `ClientCallContext(state, headers)` 新建实例注入 traceparent | ✅ 可行 | +| 2 | Server 11 方法装饰 + `Flow.Publisher` 订阅者包装 per-event Span | ✅ 可行 | +| 3 | traceparent 提取:`RestContextKeys.HEADERS_KEY`(transport-rest 公共常量)+ 通用探测回退 | ✅ 可行(依赖清单已补 transport-rest) | +| 4 | PushNotificationSender 注入:`MainEventBusProcessor(MainEventBus, TaskStore, PushNotificationSender, QueueManager)` 公共构造器 | ✅ 可行(DefaultRequestHandler 不直接收 sender;先装饰再构造) | +| 5 | `push.notification.url`:`sendNotification(event, task)` 无 URL 参数 | ⚠️ 调整:装饰器持 `PushNotificationConfigStore` 按 task_id 查 URL(§6.3) | +| 6 | `A2ATAgentExecutorDecorator`:`DefaultRequestHandler.create` 显式收 executor | ✅ 先包后建,无需反射 | +| 7 | LLM token:`LLMResponse.usage` 键 `prompt_tokens`/`completion_tokens` + `model()` | ✅ 与 Python stash 读法一致 | +| 8 | 协商轮次指标:Java 从入参 `NegotiationContext` 取(Python 从返回值取) | ✅ 数据路径小异,结果等价 | +| 9 | 同 trace 孤儿上下文:`SpanContext.create(traceId, randomSpanId, flags, state)` + `spanBuilder.setParent` | ✅ 可行(含 0 值 span_id 守卫) | +| 10 | 防泄漏激活:Java SDK 全同步,`Span.makeCurrent()`/`Scope.close()` 同步可靠 | ✅ 简化(Python 的跨 async 上下文修复逻辑不需要) | + +## 12. 参考 + +- Python 实现:`a2a-t-sdk-python/src/a2a_t/observability/`(对照文件清单见 §3 表格;`__init__.py` 公共导出 24 符号为 API 面合同基准) - Python 设计大纲:`a2a-t-sdk-python/observity.md` -- a2a-java 接缝(已用 javap 验证 1.1.0.Final):`ClientTransport`、`ClientTransportWrapper`、`ClientCallContext(Map state, Map headers)`、`RequestHandler`(流式返回 `Flow.Publisher`)、`ServerCallContext.getState()`、`AgentExecutor`、`PushNotificationSender`、`DefaultRequestHandler.create(AgentExecutor, TaskStore, QueueManager, PushNotificationConfigStore, MainEventBusProcessor, Executor, Executor)`、`A2AObservabilityNames` +- a2a-java 接缝(javap + 常量池扫描验证 1.1.0.Final):`ClientTransport`、`ClientTransportWrapper`、`ClientCallContext(Map state, Map headers)`、`RequestHandler`(流式返回 `Flow.Publisher`)、`ServerCallContext.getState()` + `RestContextKeys.HEADERS_KEY`、`AgentExecutor`、`PushNotificationSender`/`BasePushNotificationSender`(私有 `dispatch(event, task, TaskPushNotificationConfig, headers)` 含 URL)、`MainEventBusProcessor` 构造器、`DefaultRequestHandler.create(AgentExecutor, TaskStore, QueueManager, PushNotificationConfigStore, MainEventBusProcessor, Executor, Executor)`、`A2AObservabilityNames` From 25a17a4230e9542d23803b9b67f4aa30834fef8b Mon Sep 17 00:00:00 2001 From: z00930736 Date: Tue, 20 Oct 2026 16:19:20 +0800 Subject: [PATCH 278/295] docs: resolve convergence issues in observability spec --- ...26-10-20-observability-java-port-design.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-10-20-observability-java-port-design.md b/docs/superpowers/specs/2026-10-20-observability-java-port-design.md index 229e7455..d07a604c 100644 --- a/docs/superpowers/specs/2026-10-20-observability-java-port-design.md +++ b/docs/superpowers/specs/2026-10-20-observability-java-port-design.md @@ -26,7 +26,7 @@ Python 版可观测模块(参照 `a2a-t-sdk-python/observity.md` 与 `src/a2a_ | LLM 用量桥接 | contextvar stash | ThreadLocal(SDK 全同步,无协程切换问题) | | 客户端流式 | async generator | 回调式 `Consumer` | | 服务端流式 | EventQueue 装饰 | `Flow.Publisher` 包装 | -| 日志 | Python logging JSON record | SLF4J + Jackson 序列化 JSON 行(**决策:JSON 而非仓库 key=value 惯例,对齐后端摄入管道**) | +| 日志 | Python logging JSON record(LoggingHandler 桥接 OTLP logs) | SLF4J + Jackson 序列化 JSON 行(**决策:JSON 而非仓库 key=value 惯例,对齐后端摄入管道**)+ OTel Logger API 双通道(§3) | ### 1.2 目标 @@ -72,13 +72,13 @@ a2a-t-observability-a2a 协议层装饰器(可选模块) | Python | Java 类 | 要点 | |---|---|---| | `config.py` | `A2ATObservabilityConfig` | record + builder + `fromMap(DotEnvConfigSource)`;显式 > 环境变量 > 默认 | -| `setup.py` | `ObservabilitySetup` | `ensureConfigured()`:自动装配 `SdkTracerProvider`/`SdkMeterProvider`/`SdkLoggerProvider` + `BatchSpanProcessor`/`PeriodicExportingMetricReader`/`BatchLogRecordProcessor`(**三信号**,对齐 Python);OTLP gRPC(默认)或 HTTP/protobuf(endpoint 自动拼 `/v1/traces`、`/v1/metrics`、`/v1/logs`;Java `opentelemetry-exporter-otlp` 单 artifact 含三种 exporter);无 endpoint 降级 Console(`opentelemetry-exporter-logging` 的 `LoggingSpanExporter`/`LoggingMetricExporter`/`LoggingLogRecordExporter`);幂等(已注册非 Proxy/空 provider 则跳过);`sdk` 类缺失时 `Class.forName` 探测跳过自动装配 | +| `setup.py` | `ObservabilitySetup` | `ensureConfigured()`:自动装配 `SdkTracerProvider`/`SdkMeterProvider`/`SdkLoggerProvider` + `BatchSpanProcessor`/`PeriodicExportingMetricReader`/`BatchLogRecordProcessor`(**三信号**,对齐 Python);OTLP gRPC(默认)或 HTTP/protobuf(endpoint 自动拼 `/v1/traces`、`/v1/metrics`、`/v1/logs`;Java `opentelemetry-exporter-otlp` 单 artifact 含三种 exporter);无 endpoint 降级 Console(`opentelemetry-exporter-logging` 的 `LoggingSpanExporter`/`LoggingMetricExporter`/`LoggingLogRecordExporter`);幂等(Java 无 Proxy provider 概念:内部 once 标志 + `GlobalOpenTelemetry.set()` 已设置时抛 `IllegalStateException` 捕获后跳过并 DEBUG);`sdk` 类缺失时 `Class.forName` 探测跳过自动装配 | | `_otel_compat.py` | `ObservabilityRuntime` | 总开关 `OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED`、tracer/meter 访问器(scope `a2at-observability`/`1.0.0`)、trace/metric/log 三信号独立开关、`sameTraceOrphanContext`(同 trace 随机 span_id 无父上下文 + 0 值守卫) | -| `span.py` | `A2ATSpan implements AutoCloseable` | try-with-resources;kind 枚举 `INTERNAL/CLIENT/SERVER/PRODUCER/CONSUMER`;`setAttribute`/`addLink(A2ATSpan)`;close 时记录异常 + OK/ERROR 状态 | +| `span.py` | `A2ATSpan implements AutoCloseable` | try-with-resources;kind 枚举 `INTERNAL/CLIENT/SERVER/PRODUCER/CONSUMER`;`setAttribute`/`addLink(A2ATSpan)`。**异常语义**(Java try-with-resources 的 close() 收不到业务异常,与 Python `__exit__(exc)` 不同):增加 `recordError(Throwable)` 方法,惯用法在 catch 中调用;`close()` 按状态收尾——已 `recordError` → record_exception + ERROR,否则 OK。**若使用者既未调用 recordError 又发生异常,Span 以 UNSET 结束**(内部装饰器不经过此路径,状态语义由 §6 的显式 try/finally 全量保证) | | `current_span.py` | `CurrentSpans.current()` → `A2ATCurrentSpan` | 只读视图:`traceId()`/`spanId()`/`isValid()`/`setAttribute()`;非 recording 时返回空 | | `attributes.py` | `A2ATAttributes` + `AttributeExtractors` | 全部属性常量 + 提取器(§5 命名注册表) | | `propagation.py` | `TracePropagation` | `inject(Map)` / `extract(Map)`;W3C traceparent;key 小写容错;extract 返回 null 当 span context 无效 | -| `payload.py` | `PayloadSanitizer` | **先脱敏后截断**(安全顺序);默认 4096 + `[truncated]` 后缀;自定义 redactor 函数接口 | +| `payload.py` | `PayloadExtractor` + `PayloadSanitizer` | **提取**:a2a-java spec 对象(Jackson 注解 POJO)经 Jackson 序列化为 JSON(对齐 Python `MessageToDict`);**净化**:先脱敏后截断(安全顺序);默认 4096 + `[truncated]` 后缀;自定义 redactor 函数接口 | | `logs.py` | `ObservabilityLogs` | SLF4J logger `net.openan.a2at.sdk.observability`;**JSON 行**(Jackson 序列化,`ensure_ascii=false` 等价):`{"event": ..., <业务字段>, "trace_id", "span_id", "payload"?}`;事件名沿用 Python;log_enabled 双门控(运行时开关 + config);**OTel 日志信号**:log_enabled 时同步写入 OTel `Logger` API(`SdkLoggerProvider` + OTLP/Console exporter),SLF4J 与 OTel 双通道共享同一 JSON 语义,对齐 Python LoggingHandler 桥接 | | `llm_decorator.py` + `llm_stash.py` | `ObservabilityLLMClient implements LLMClient` + `LlmUsageStash` | `structured()` 返回后:从 `LLMResponse.usage`(键 `prompt_tokens`/`completion_tokens`,与 Python stash 读法一致)+ `model()` 取数;记 `gen_ai.client.token.usage` 直方图(unit `{token}`,按 `gen_ai.token.type`=input/output 各一样本)+ `LlmUsageStash`(ThreadLocal)暂存(input/output tokens、request model)供协议层入口 Span 取用(**取后清空**;trace 关闭时入口侧直接丢弃 stash) | | `negotiation_metrics.py` | `NegotiationMetrics` | `a2at.negotiation.total_rounds` 直方图(unit `{round}`);属性 `gen_ai.agent.a2at.extension.name="Negotiation-T"`、`negotiation.id`、`outcome`(accept/reject/abort);仅终态触发;**数据路径差异**:Python 从返回的 `MetadataContent.negotiation_context` 取,Java 从门面入参/结果的 `NegotiationContext(type, negotiationId, round, NegotiationStatus)` 取(终态 status 映射 outcome),结果等价 | @@ -99,7 +99,7 @@ public interface TelemetryHook { } ``` -- `TelemetryOperation`:AutoCloseable(try-with-resources 结束 Span)+ `setAttribute(String, Object)`;调用方在业务成功后先调 `markSuccess()` 再 close(close 时若未 markSuccess 且无异常标记则记 ERROR),异常路径由 try/catch 中的 `markError(Throwable)` 标记 +- `TelemetryOperation`:AutoCloseable(try-with-resources 结束 Span)+ `setAttribute(String, Object)`;调用方在业务成功后先调 `markSuccess()` 再 close(close 时若未 markSuccess 且无异常标记则记 ERROR——防御性默认,内部调用点全部受控、两条路径必走其一),异常路径由 try/catch 中的 `markError(Throwable)` 标记。与手动 API `A2ATSpan` 的差异:后者面向使用者,无法感知未上报的异常,未记录时 UNSET 结束(§3),两者语义各自明确 - 调用点一律 try/catch 包裹 + WARN,业务异常原样抛出 - `startSdkOperation` 等价 Python `trace_facade`(opt-in L4):注入 hook 后每个门面方法包 Span,名 `a2at.sdk.client.generateTaskPrompt` / `a2at.sdk.server.checkTaskPrompt` 等 @@ -111,8 +111,8 @@ public interface TelemetryHook { |---|---|---| | `SendMessage` / `SendStreamingMessage` | CLIENT / SERVER | 双端 | | `SendStreamingMessage-event` | CLIENT / SERVER | 双端(同 trace 孤儿上下文 + Link 指向入口 Span) | -| `SendStreamingMessage-error` | CLIENT | 错误回调(`error.type` 属性) | -| `SendStreamingMessage-negotiation` | SERVER | 协商 Message 事件(**父子**挂入口 Span) | +| `SendStreamingMessage-error` | CLIENT | 错误回调(`error.type` 属性;取消类异常豁免) | +| `SendMessage-negotiation` / `SendStreamingMessage-negotiation` | CLIENT / SERVER | 双端,协商 Message 触发(**父子**挂入口 Span;client 由响应/事件观测发,server 流式由 Publisher 侧发、sync 由收尾阶段发,避免双发) | | `SubscribeToTask` / `GetTask` / `ListTasks` / `CancelTask` | CLIENT / SERVER | 双端 | | `CreateTaskPushNotificationConfig` / `GetTaskPushNotificationConfig` / `ListTaskPushNotificationConfigs` / `DeleteTaskPushNotificationConfig` | CLIENT / SERVER | 双端 | | `GetExtendedAgentCard` | CLIENT / SERVER | 双端 | @@ -141,7 +141,7 @@ public interface TelemetryHook { ### 5.4 日志事件 -`task.request`(DEBUG)、`task.response`(DEBUG)、`task.status_changed`(INFO)、`task.artifact`(INFO)、`notification.push`(INFO)、`negotiation.message`(DEBUG);字段:`task.id`、`task.status`、`artifact.name`、`extension.name`、`trace_id`、`span_id`、`payload?`。 +`task.request`(DEBUG)、`task.response`(DEBUG)、`task.status_changed`(INFO)、`task.artifact`(INFO)、`notification.push`(INFO)、`negotiation.message`(DEBUG);字段:`task.id`、`task.status`、`artifact.name`、`extension.name`、`streaming.event.kind`(notification.push 携带)、`negotiation.id/round/performative`(negotiation.message 携带)、`trace_id`、`span_id`、`payload?`。 ## 6. 协议层 `a2a-t-observability-a2a` 设计 @@ -149,7 +149,7 @@ public interface TelemetryHook { - 覆写全部 12 方法;入口 Span kind=CLIENT,名见 §5.1;**构造器防双重装饰守卫**(内层已是本装饰器则解包,杜绝双重 Span,对齐 Python factory-guard) - **传播**:从 `ClientCallContext.getHeaders()`(小写容错)提取调用方 traceparent 为父(业务根传播 mode B);无则显式 ROOT(`SpanContext` INVALID 语义等价物);向 delegate 传递**新建** `ClientCallContext`(原 state + 注入入口 Span traceparent 的 headers,不改传入对象 —— 相比 Python 原地覆写 `service_parameters` 更安全,消除"覆写调用方业务根"缺陷) -- **流式**(`sendMessageStreaming`/`subscribeToTask`):包装两个 Consumer;每事件发 `SendStreamingMessage-event`(同 trace 孤儿上下文 + Link + `gen_ai.conversation.id`);错误 Consumer 发 `SendStreamingMessage-error`(`error.type`);**取消/中止类异常(`CancellationException`)不发 error span**(对齐 Python CancelledError 豁免);终态事件止流(对齐 a2a-java `shouldAutoClose`) +- **流式**(`sendMessageStreaming`/`subscribeToTask`):包装两个 Consumer;每事件发 `SendStreamingMessage-event`(同 trace 孤儿上下文 + Link + `gen_ai.conversation.id`);错误 Consumer 发 `SendStreamingMessage-error`(`error.type`);**取消/中止类异常(`CancellationException`)不发 error span**(对齐 Python CancelledError 豁免)。**注意**:Java 回调式流式模型下装饰器只观测、不可中断内层流——"终态事件"仅用于内部判定流尾(记录 final_event 供响应属性/`task.response` 日志),流的终止由 a2a-java 客户端层控制(与 Python 生成器 `break` 的能力差异,行为等价性由 a2a-java 保证) - **协商 Span(client 侧)**:sync 响应与流式事件中的协商 Message 发 `{操作名}-negotiation`(CLIENT kind,**父子**挂入口 Span,即时结束;属性 `gen_ai.operation.name`/`extension.name`/`gen_ai.conversation.id`/`task.id`/`negotiation.*`)+ `negotiation.message` 日志 - **属性**:请求结构化提取 + 双写 + stash 中 LLM token 用量 + 正则提取(DEFAULT_* 回退、空串 opt-out、尾括号清洗);**trace 关闭时 stash 必须丢弃**(防泄漏到下一请求入口 Span,对齐 Python) - **指标**:`gen_ai.client.operation.duration` + `a2at.task.request.duration`(side=client),双门控(运行时开关 + config 实例开关) @@ -195,7 +195,7 @@ public interface TelemetryHook { 1. **永不破坏业务流**:插桩点 try/catch + WARN(logger `net.openan.a2at.sdk.observability`),业务异常原样抛出 2. **全链路 pass-through**:OTel SDK 未安装 / 开关关 → 装饰器纯转发;OTel API 层天然 no-op 兜底 -3. **Span 激活防泄漏**:入口 Span makeCurrent 前快照 ambient `Context`,finally 强制恢复(防御异步/SSE 跨线程 detach 失败) +3. **Span 激活防泄漏(Java 简化)**:SDK 全同步,`Span.makeCurrent()` 返回的 `Scope` 在同线程 close 即可靠恢复——不需要 Python 的快照+强制修复逻辑(§11#10);跨线程路径(executor 跑在 worker 线程、push 跑在 MainEventBusProcessor 线程)均在线程入口处显式激活/关闭,不依赖 ambient 跨线程继承 ## 9. 测试策略 From cd3c1166ccb7f7ea6020121fac5d9408854ade2b Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 10:33:05 +0800 Subject: [PATCH 279/295] docs: add observability core implementation plan (plan A) --- .../plans/2026-10-20-observability-core.md | 2890 +++++++++++++++++ ...26-10-20-observability-java-port-design.md | 8 +- 2 files changed, 2895 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-20-observability-core.md diff --git a/docs/superpowers/plans/2026-10-20-observability-core.md b/docs/superpowers/plans/2026-10-20-observability-core.md new file mode 100644 index 00000000..9686f495 --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-observability-core.md @@ -0,0 +1,2890 @@ +# A2A-T Java SDK 可观测性(核心模块 + SPI + 门面接线)实施计划 — Plan A + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 按 `docs/superpowers/specs/2026-10-20-observability-java-port-design.md` 落地 `a2a-t-observability` 核心模块、`TelemetryHook` SPI 与门面接线(Plan B:a2a-java 协议层装饰器,单独计划,待本计划落地后编写)。 + +**Architecture:** 双新模块(core 观测 + a2a 协议层);core 定义 `TelemetryHook` SPI,门面二级构造器注入 `@Nullable` hook 包装全部公开方法;OTel api 编译依赖 + sdk/exporter 可选;全同步 SDK,ThreadLocal 作 LLM 用量 stash。 + +**Tech Stack:** Java 17 / Maven 多模块 / OpenTelemetry 1.64.0(api 编译 + sdk、exporter-otlp ``;`exporter-logging` 不在本地 .m2 → Console 降级为内置 SLF4J JSON 导出器)/ JUnit 5(无 Mockito)/ SLF4J + Jackson。 + +## Global Constraints + +- 构建/测试一律在仓库根目录:`mvn -q -pl -am test`;提交前 `mvn -q spotless:apply`(Palantir 格式强制)。 +- 每个新包必须有 `package-info.java` 且 `@NullMarked`(JSpecify);可空参数/字段用 `org.jspecify.annotations.@Nullable`(NullAway ERROR)。 +- 新代码不用 Lombok、不用 Mockito;测试用 JUnit 5 + 手写 stub + logback `ListAppender`。 +- OTel 版本 1.64.0(root pom 属性 + bom import);a2a-java 1.1.0.Final。 +- 观测失败永不破坏业务:插桩点 catch RuntimeException → WARN(logger `net.openan.a2at.sdk.observability`)→ 吞掉;业务异常原样抛。 +- 命名注册表与 spec §5 逐字一致;环境变量与 spec §7 一致。 +- 开关读取顺序(全部键):`System.getProperty(key)` → `System.getenv(key)` → 默认值(比 Python 多一级 getProperty,便于测试覆盖;行为等价)。 +- 提交信息 conventional commits(`feat(observability): ...`)。 + +## File Structure(Plan A 产出) + +``` +pom.xml (改: modules + 属性 + otel bom import + a2a-java 版本管理) +a2a-t-bom/pom.xml (改: +2 模块) +a2a-t-observability/pom.xml (新) +a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ + package-info.java, A2ATObservabilityConfig.java, A2ATAttributes.java, + AttributeExtractors.java, EventInfo.java, ObservabilityRuntime.java, + A2ATSpan.java, A2ATCurrentSpan.java, CurrentSpans.java, TracePropagation.java, + PayloadExtractor.java, PayloadSanitizer.java, LlmUsageStash.java, LlmUsageStashHolder.java, + ObservabilityLLMClient.java, MetricHistograms.java, NegotiationMetrics.java, + ObservabilityLogs.java, ConsoleExporters.java, ObservabilitySetup.java +a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ (镜像测试) +a2a-t-observability-a2a/pom.xml (新: 骨架,Plan B 填充) +a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/ + package-info.java, TelemetryHook.java, TelemetryOperation.java +a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java (改: hook 接线) +a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java (改: hook 接线) +env.example (改: 可观测性小节) +``` + +> spec §3 的 attributes.py 在 Java 拆分为两半:core 模块放常量 + Map 级提取器 + `EventInfo`;a2a-java 类型化分类器(`StreamingEventKind`/`MessageSendParams` instanceof 分派)放 a2a 模块(Plan B)——依赖方向要求。 + +--- + +### Task 1: 模块骨架与依赖登记 + +**Files:** +- Modify: `pom.xml`(modules、properties、dependencyManagement) +- Modify: `a2a-t-bom/pom.xml` +- Create: `a2a-t-observability/pom.xml`、`a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/package-info.java` +- Create: `a2a-t-observability-a2a/pom.xml`、`a2a-t-observability-a2a/src/main/java/net/openan/a2at/sdk/observability/a2a/package-info.java` + +**Interfaces:** +- Produces: 两个可编译空模块;root pom 属性 `opentelemetry.version=1.64.0`、`a2a-java.version=1.1.0.Final`;后续任务靠 root `dependencyManagement` 省略版本号。 + +- [ ] **Step 1: 根 pom 三处修改** + +`` 中 `a2a-t-server` 之后追加: + +```xml + a2a-t-observability + a2a-t-observability-a2a +``` + +`` 中 `2.0.17` 之后追加: + +```xml + 1.64.0 + 1.1.0.Final +``` + +`` 中 `jspecify` 条目之后追加: + +```xml + + io.opentelemetry + opentelemetry-bom + ${opentelemetry.version} + pom + import + + + org.a2aproject.sdk + a2a-java-sdk-client-transport-spi + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-spec + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-server-common + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-jsonrpc-common + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-transport-rest + ${a2a-java.version} + +``` + +- [ ] **Step 2: BOM 登记** — `a2a-t-bom/pom.xml` 的 `a2a-t-server` 条目后追加: + +```xml + + net.openan.a2a-t.sdk + a2a-t-observability + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-observability-a2a + ${project.version} + +``` + +- [ ] **Step 3: 核心模块 pom** — `a2a-t-observability/pom.xml`(父 POM + ${project.version} 依赖,模板同 `a2a-t-llm/pom.xml`): + +```xml + + 4.0.0 + + net.openan.a2a-t.sdk + a2a-t-parent + ${revision} + + a2a-t-observability + a2a-t Observability + Observability core for the a2a-t Java SDK: config, setup, manual span API, propagation, logs, metrics, LLM decorator. + + + net.openan.a2a-t.sdk + a2a-t-core + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-llm + ${project.version} + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-sdk + true + + + io.opentelemetry + opentelemetry-exporter-otlp + true + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter + test + + + ch.qos.logback + logback-classic + 1.4.14 + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + +``` + +(`opentelemetry-sdk-testing` 经 bom 管理 1.64.0,提供 `InMemorySpanExporter`/`InMemoryMetricReader`,测试基建统一走它。) + +- [ ] **Step 4: a2a 模块骨架 pom** — `a2a-t-observability-a2a/pom.xml`: + +```xml + + 4.0.0 + + net.openan.a2a-t.sdk + a2a-t-parent + ${revision} + + a2a-t-observability-a2a + a2a-t Observability A2A + Protocol-level observability decorators for a2a-java (transport, request handler, executor, push sender). Filled by the observability-a2a plan. + + + net.openan.a2a-t.sdk + a2a-t-observability + ${project.version} + + + org.a2aproject.sdk + a2a-java-sdk-client-transport-spi + + + org.a2aproject.sdk + a2a-java-sdk-spec + + + org.a2aproject.sdk + a2a-java-sdk-server-common + + + org.a2aproject.sdk + a2a-java-sdk-jsonrpc-common + + + org.a2aproject.sdk + a2a-java-sdk-transport-rest + + + org.junit.jupiter + junit-jupiter + test + + + +``` + +- [ ] **Step 5: package-info** — 两个文件,内容模式一致: + +```java +/** Observability core for the a2a-t Java SDK. */ +@NullMarked +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.NullMarked; +``` + +(a2a 模块:`package net.openan.a2at.sdk.observability.a2a;`,注释 `/** Protocol-level observability decorators for a2a-java. */`) + +- [ ] **Step 6: 验证编译** — 根目录运行 `mvn -q -pl a2a-t-observability,a2a-t-observability-a2a -am compile`,期望 BUILD SUCCESS(无 OTel/“a2a-java 缺失”报错)。 + +- [ ] **Step 7: 提交** — `mvn -q spotless:apply; git add pom.xml a2a-t-bom/pom.xml a2a-t-observability a2a-t-observability-a2a; git commit -m "feat(observability): add observability module skeletons and dependency registration"` + +### Task 2: TelemetryHook SPI(a2a-t-core) + +**Files:** +- Create: `a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/package-info.java` +- Create: `a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryOperation.java` +- Create: `a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryHook.java` +- Test: `a2a-t-core/src/test/java/net/openan/a2at/sdk/core/telemetry/TelemetryHookTest.java` + +**Interfaces:** +- Produces: `TelemetryHook.startSdkOperation(String role, String method) -> TelemetryOperation`、`TelemetryHook.reportNegotiationRounds(String negotiationId, String outcome, long totalRounds)`、`TelemetryHook.NO_OP`;`TelemetryOperation`(AutoCloseable)`setAttribute(String, Object)` / `markSuccess()` / `markError(Throwable)` / `close()` / `TelemetryOperation.NO_OP`。Task 10 门面与 Plan B `SdkOperationSpans` 依赖此面。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.core.telemetry; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class TelemetryHookTest { + + @Test + void noOpHookAbsorbsEverything() { + TelemetryOperation operation = TelemetryHook.NO_OP.startSdkOperation("client", "getPrompts"); + assertSame(TelemetryOperation.NO_OP, operation); + assertDoesNotThrow(() -> { + operation.setAttribute("k", "v"); + operation.markSuccess(); + operation.markError(new RuntimeException("x")); + operation.close(); + TelemetryHook.NO_OP.reportNegotiationRounds("n1", "accept", 3); + }); + } + + @Test + void customHookReceivesCalls() { + List started = new ArrayList<>(); + List rounds = new ArrayList<>(); + TelemetryHook hook = new TelemetryHook() { + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + started.add(role + "." + method); + return TelemetryOperation.NO_OP; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + }; + hook.startSdkOperation("server", "checkTaskPrompt").close(); + hook.reportNegotiationRounds("N1", "reject", 2); + assertTrue(started.contains("server.checkTaskPrompt")); + assertTrue(rounds.contains("N1:reject:2")); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-core test "-Dtest=TelemetryHookTest"`,期望编译错误(类不存在)。 + +- [ ] **Step 3: 实现** + +`TelemetryOperation.java`: + +```java +package net.openan.a2at.sdk.core.telemetry; + +/** One observed SDK operation; AutoCloseable for try-with-resources. */ +public interface TelemetryOperation extends AutoCloseable { + + /** Attaches one attribute to the underlying observation. No-op values are ignored. */ + void setAttribute(String key, Object value); + + /** Marks the operation as business-successful before close. */ + void markSuccess(); + + /** Marks the operation as failed; the Throwable is recorded verbatim. */ + void markError(Throwable error); + + /** Ends the operation. Idempotent. */ + @Override + void close(); + + TelemetryOperation NO_OP = + new TelemetryOperation() { + @Override + public void setAttribute(String key, Object value) {} + + @Override + public void markSuccess() {} + + @Override + public void markError(Throwable error) {} + + @Override + public void close() {} + }; +} +``` + +`TelemetryHook.java`: + +```java +package net.openan.a2at.sdk.core.telemetry; + +/** + * Telemetry SPI consumed by SDK facades. Implementations live in optional observability modules; + * the default {@link #NO_OP} keeps the core dependency-free. + */ +public interface TelemetryHook { + + /** Starts one facade-operation observation named {@code a2at.sdk..}. */ + TelemetryOperation startSdkOperation(String role, String method); + + /** Reports one terminal negotiation round count (outcome: accept/reject). */ + void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds); + + TelemetryHook NO_OP = + new TelemetryHook() { + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + return TelemetryOperation.NO_OP; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) {} + }; +} +``` + +`package-info.java`:`@NullMarked` 模式,注释 `/** Telemetry SPI for the a2a-t Java SDK core. */`。 + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-core test "-Dtest=TelemetryHookTest"`,期望 PASS。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-core; git commit -m "feat(observability): add TelemetryHook SPI to core"` + +--- + +### Task 3: A2ATObservabilityConfig + +**Files:** +- Create: `a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java` +- Test: `a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java` + +**Interfaces:** +- Produces: record 15 字段(顺序即下码)+ `fromEnv()` / `fromMap(Map)` / `builder()`;布尔键解析 truthy 集合 `{1,true,yes,on}`(大小写不敏感,其余 false);协议别名 `http` → `http/protobuf`,非法 → `grpc`;`A2AT_SERVICE_NAME` 优先于 `OTEL_SERVICE_NAME`;正则字段仅编程式设置(无 env 键),空串 = opt-out、null = 用内置默认(消费者决定)。Task 4/8/9 与 Plan B 全部消费。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class A2ATObservabilityConfigTest { + + @Test + void defaultsMatchSpec() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv(); + assertTrue(config.enabled()); + assertTrue(config.traceEnabled()); + assertTrue(config.metricEnabled()); + assertTrue(config.logEnabled()); + assertEquals("grpc", config.protocol()); + assertEquals(A2ATObservabilityConfig.DEFAULT_PAYLOAD_LOG_MAX_LENGTH, config.payloadLogMaxLength()); + assertFalse(config.extractRequest()); + assertFalse(config.extractResponse()); + assertFalse(config.payloadLogEnabled()); + } + + @Test + void mapValuesBeatDefaults() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of( + "A2AT_OBSERVABILITY_ENABLED", "false", + "A2AT_TRACE_ENABLED", "false", + "OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318", + "A2AT_EXPORTER_PROTOCOL", "http", + "A2AT_SERVICE_NAME", "svc", + "A2AT_LOGS_PAYLOAD_ENABLED", "TRUE", + "A2AT_LOGS_PAYLOAD_MAX_LENGTH", "128")); + assertFalse(config.enabled()); + assertFalse(config.traceEnabled()); + assertTrue(config.metricEnabled()); + assertEquals("http/protobuf", config.protocol()); + assertEquals("http://collector:4318", config.endpoint()); + assertEquals("svc", config.serviceName()); + assertTrue(config.payloadLogEnabled()); + assertEquals(128, config.payloadLogMaxLength()); + } + + @Test + void invalidProtocolFallsBackToGrpcAndInvalidIntFallsBackToDefault() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of( + "A2AT_EXPORTER_PROTOCOL", "udp", "A2AT_LOGS_PAYLOAD_MAX_LENGTH", "abc")); + assertEquals("grpc", config.protocol()); + assertEquals(4096, config.payloadLogMaxLength()); + } + + @Test + void builderBeatsMap() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of("A2AT_SERVICE_NAME", "from-map")) + .toBuilder() + .serviceName("explicit") + .taskTypeRegex("") + .build(); + assertEquals("explicit", config.serviceName()); + assertEquals("", config.taskTypeRegex()); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=A2ATObservabilityConfigTest"`(编译错误)。 + +- [ ] **Step 3: 实现** — `A2ATObservabilityConfig.java`: + +```java +package net.openan.a2at.sdk.observability; + +import java.util.Locale; +import java.util.Map; +import java.util.function.UnaryOperator; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Observability configuration: explicit builder > .env map > system env > spec defaults. */ +public record A2ATObservabilityConfig( + boolean enabled, + boolean traceEnabled, + boolean metricEnabled, + boolean logEnabled, + @Nullable String endpoint, + String protocol, + @Nullable String serviceName, + @Nullable String taskTypeRegex, + @Nullable String notificationTopicRegex, + @Nullable String authorizationPolicyOperationTypeRegex, + boolean extractRequest, + boolean extractResponse, + boolean payloadLogEnabled, + int payloadLogMaxLength, + @Nullable UnaryOperator payloadRedactor) { + + private static final Logger LOG = LoggerFactory.getLogger(A2ATObservabilityConfig.class); + + public static final String ENV_ENABLED = "A2AT_OBSERVABILITY_ENABLED"; + public static final String ENV_TRACE = "A2AT_TRACE_ENABLED"; + public static final String ENV_METRIC = "A2AT_METRIC_ENABLED"; + public static final String ENV_LOG = "A2AT_LOG_ENABLED"; + public static final String ENV_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"; + public static final String ENV_PROTOCOL = "A2AT_EXPORTER_PROTOCOL"; + public static final String ENV_SERVICE_NAME = "A2AT_SERVICE_NAME"; + public static final String ENV_OTEL_SERVICE_NAME = "OTEL_SERVICE_NAME"; + public static final String ENV_AGENT_ROLE = "A2AT_AGENT_ROLE"; + public static final String ENV_EXTRACT_REQUEST = "A2AT_EXTRACT_REQUEST"; + public static final String ENV_EXTRACT_RESPONSE = "A2AT_EXTRACT_RESPONSE"; + public static final String ENV_PAYLOAD_LOG_ENABLED = "A2AT_LOGS_PAYLOAD_ENABLED"; + public static final String ENV_PAYLOAD_LOG_MAX_LENGTH = "A2AT_LOGS_PAYLOAD_MAX_LENGTH"; + public static final String ENV_CONSOLE_EXPORTER = "A2AT_CONSOLE_EXPORTER"; + + public static final String DEFAULT_PROTOCOL = "grpc"; + public static final int DEFAULT_PAYLOAD_LOG_MAX_LENGTH = 4096; + public static final String DEFAULT_SERVICE_NAME = "a2a-t-agent"; + + public static A2ATObservabilityConfig fromEnv() { + return fromMap(Map.of()); + } + + /** @param values .env file values (take precedence over system env); missing keys fall back to env then defaults */ + public static A2ATObservabilityConfig fromMap(Map values) { + Builder b = new Builder(); + b.enabled = bool(values, ENV_ENABLED, true); + b.traceEnabled = bool(values, ENV_TRACE, true); + b.metricEnabled = bool(values, ENV_METRIC, true); + b.logEnabled = bool(values, ENV_LOG, true); + b.endpoint = str(values, ENV_ENDPOINT); + b.protocol = normalizeProtocol(str(values, ENV_PROTOCOL)); + String fromSdkKey = str(values, ENV_SERVICE_NAME); + b.serviceName = fromSdkKey != null ? fromSdkKey : str(values, ENV_OTEL_SERVICE_NAME); + b.extractRequest = bool(values, ENV_EXTRACT_REQUEST, false); + b.extractResponse = bool(values, ENV_EXTRACT_RESPONSE, false); + b.payloadLogEnabled = bool(values, ENV_PAYLOAD_LOG_ENABLED, false); + b.payloadLogMaxLength = intOr(values, ENV_PAYLOAD_LOG_MAX_LENGTH, DEFAULT_PAYLOAD_LOG_MAX_LENGTH); + return b.build(); + } + + public static Builder builder() { + return new Builder(); + } + + public Builder toBuilder() { + Builder b = new Builder(); + b.enabled = enabled; + b.traceEnabled = traceEnabled; + b.metricEnabled = metricEnabled; + b.logEnabled = logEnabled; + b.endpoint = endpoint; + b.protocol = protocol; + b.serviceName = serviceName; + b.agentRole = resolveAgentRole(); + b.taskTypeRegex = taskTypeRegex; + b.notificationTopicRegex = notificationTopicRegex; + b.authorizationPolicyOperationTypeRegex = authorizationPolicyOperationTypeRegex; + b.extractRequest = extractRequest; + b.extractResponse = extractResponse; + b.payloadLogEnabled = payloadLogEnabled; + b.payloadLogMaxLength = payloadLogMaxLength; + b.payloadRedactor = payloadRedactor; + return b; + } + + /** Agent role: env-only opt-in (no builder default). */ + public @Nullable String resolveAgentRole() { + String role = System.getProperty(ENV_AGENT_ROLE, System.getenv(ENV_AGENT_ROLE)); + return role == null || role.isBlank() ? null : role.trim(); + } + + static String normalizeProtocol(@Nullable String raw) { + String value = (raw == null ? DEFAULT_PROTOCOL : raw).trim().toLowerCase(Locale.ROOT); + if (value.equals("grpc")) { + return "grpc"; + } + if (value.equals("http") || value.equals("http/protobuf")) { + return "http/protobuf"; + } + LOG.warn("a2at: invalid exporter protocol {}; falling back to grpc", raw); + return DEFAULT_PROTOCOL; + } + + private static @Nullable String str(Map values, String key) { + String v = values.get(key); + if (v != null && !v.isBlank()) { + return v.trim(); + } + String env = System.getenv(key); + return env == null || env.isBlank() ? null : env.trim(); + } + + private static boolean bool(Map values, String key, boolean def) { + String v = str(values, key); + if (v == null) { + return def; + } + return switch (v.toLowerCase(Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + private static int intOr(Map values, String key, int def) { + String v = str(values, key); + if (v == null) { + return def; + } + try { + return Integer.parseInt(v); + } catch (NumberFormatException e) { + return def; + } + } + + public static final class Builder { + private boolean enabled = true; + private boolean traceEnabled = true; + private boolean metricEnabled = true; + private boolean logEnabled = true; + private @Nullable String endpoint; + private String protocol = DEFAULT_PROTOCOL; + private @Nullable String serviceName; + private @Nullable String agentRole; + private @Nullable String taskTypeRegex; + private @Nullable String notificationTopicRegex; + private @Nullable String authorizationPolicyOperationTypeRegex; + private boolean extractRequest; + private boolean extractResponse; + private boolean payloadLogEnabled; + private int payloadLogMaxLength = DEFAULT_PAYLOAD_LOG_MAX_LENGTH; + private @Nullable UnaryOperator payloadRedactor; + + public Builder enabled(boolean v) { this.enabled = v; return this; } + public Builder traceEnabled(boolean v) { this.traceEnabled = v; return this; } + public Builder metricEnabled(boolean v) { this.metricEnabled = v; return this; } + public Builder logEnabled(boolean v) { this.logEnabled = v; return this; } + public Builder endpoint(@Nullable String v) { this.endpoint = v; return this; } + public Builder protocol(String v) { this.protocol = normalizeProtocol(v); return this; } + public Builder serviceName(@Nullable String v) { this.serviceName = v; return this; } + public Builder agentRole(@Nullable String v) { this.agentRole = v; return this; } + public Builder taskTypeRegex(@Nullable String v) { this.taskTypeRegex = v; return this; } + public Builder notificationTopicRegex(@Nullable String v) { this.notificationTopicRegex = v; return this; } + public Builder authorizationPolicyOperationTypeRegex(@Nullable String v) { this.authorizationPolicyOperationTypeRegex = v; return this; } + public Builder extractRequest(boolean v) { this.extractRequest = v; return this; } + public Builder extractResponse(boolean v) { this.extractResponse = v; return this; } + public Builder payloadLogEnabled(boolean v) { this.payloadLogEnabled = v; return this; } + public Builder payloadLogMaxLength(int v) { this.payloadLogMaxLength = v; return this; } + public Builder payloadRedactor(@Nullable UnaryOperator v) { this.payloadRedactor = v; return this; } + + public A2ATObservabilityConfig build() { + if (payloadLogMaxLength <= 0) { + throw new IllegalArgumentException("payloadLogMaxLength must be positive"); + } + String name = serviceName != null ? serviceName : System.getenv(ENV_SERVICE_NAME); + if (name == null || name.isBlank()) { + name = System.getenv(ENV_OTEL_SERVICE_NAME); + } + return new A2ATObservabilityConfig( + enabled, traceEnabled, metricEnabled, logEnabled, endpoint, protocol, + name == null || name.isBlank() ? DEFAULT_SERVICE_NAME : name.trim(), + agentRole, taskTypeRegex, notificationTopicRegex, authorizationPolicyOperationTypeRegex, + extractRequest, extractResponse, payloadLogEnabled, payloadLogMaxLength, payloadRedactor); + } + } +} +``` + +注意:record 显式包含 `agentRole` 字段——在头部 15 字段列表中补上(`serviceName` 之后),构造器调用相应对齐。 + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=A2ATObservabilityConfigTest"`,期望 PASS。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add A2ATObservabilityConfig"` + +--- + +### Task 4: A2ATAttributes + EventInfo + AttributeExtractors + +**Files:** +- Create: `.../observability/A2ATAttributes.java`、`.../observability/EventInfo.java`、`.../observability/AttributeExtractors.java` +- Test: `.../observability/AttributeExtractorsTest.java` + +**Interfaces:** +- Produces(后续所有任务与 Plan B 消费): + - `A2ATAttributes`:全部属性常量(spec §5.2 逐字值) + - `EventInfo(String kind, @Nullable String taskId, @Nullable String taskStatus, boolean finalEvent, boolean terminal, @Nullable Map messageMetadata, @Nullable String artifactName)` + `EventInfo.UNKNOWN` + - `AttributeExtractors.extensionNameFromUri(@Nullable String)`、`extensionNameFromMetadata(Map)`、`negotiationAttributes(Map)`、`isNegotiationMessage(Message)`、`normalizeTaskState(@Nullable TaskState)`、`classifyEvent(StreamingEventKind)`、`requestAttributes(String operationName, @Nullable Object params)`、`messageDoubleAttributes(Message, boolean clientSide)`、`regexAttributes(Map, A2ATObservabilityConfig)` + - 常量 `NEGOTIATION_EXTENSION = "Negotiation-T"`、`NEGOTIATION_SUFFIX = "-negotiation"`、`EVENT_LOG_KINDS` + +> 类型化提取器(`Message`/`StreamingEventKind`/`MessageSendParams` 参数)**不引 a2a-java 依赖就无法编译**——但 a2a-java 不在本模块依赖里。按 spec §3 的 Java 拆分:本任务只落 **Map 级**部分(extension/negotiation/regex/常量/EventInfo);`classifyEvent`/`requestAttributes`/`messageDoubleAttributes` 等类型化分派放 **a2a 模块**(Plan B 的 `A2AJavaAttributeExtractors`)。本任务接口因此收窄为: + +- `extensionNameFromUri(@Nullable String uri) -> @Nullable String` +- `extensionNameFromMetadata(Map metadata) -> @Nullable String` +- `isNegotiationMessage(Map metadata) -> boolean`(metadata 键含 Negotiation-T 扩展 URI) +- `negotiationAttributes(Map metadata) -> Map`(读 `negotiationContext.id/round/maxRounds/performative`,终态 performative 附 `total_rounds`) +- `normalizeTaskStateName(@Nullable String enumName) -> @Nullable String`(`TASK_STATE_COMPLETED` → `completed`) +- `regexAttributes(Map metadata, A2ATObservabilityConfig config) -> Map`(null regex → DEFAULT 回退、空串 opt-out、尾括号清洗) + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AttributeExtractorsTest { + + private static final String NEGOTIATION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T"; + + @Test + void extensionNameFromUriParsesBase() { + assertEquals("Negotiation-T", AttributeExtractors.extensionNameFromUri(NEGOTIATION_URI)); + assertEquals("Task-T", AttributeExtractors.extensionNameFromUri( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/sub")); + assertNull(AttributeExtractors.extensionNameFromUri("https://other.example/ext")); + assertNull(AttributeExtractors.extensionNameFromUri(null)); + } + + @Test + void negotiationAttributesExtractedFromMetadata() { + Map metadata = Map.of(NEGOTIATION_URI, "v1", "negotiationContext", + Map.of("id", "N001", "round", 3, "maxRounds", 5, "performative", "ACCEPT")); + Map attrs = AttributeExtractors.negotiationAttributes(metadata); + assertEquals("N001", attrs.get(A2ATAttributes.ATTR_NEGOTIATION_ID)); + assertEquals(3, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_ROUND)); + assertEquals(5, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_MAX_ROUNDS)); + assertEquals("ACCEPT", attrs.get(A2ATAttributes.ATTR_NEGOTIATION_PERFORMATIVE)); + assertEquals(3, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS)); + } + + @Test + void nonTerminalPerformativeOmitsTotalRounds() { + Map attrs = AttributeExtractors.negotiationAttributes( + Map.of("negotiationContext", Map.of("id", "N1", "round", 1, "performative", "PROPOSE"))); + assertFalse(attrs.containsKey(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS)); + assertTrue(AttributeExtractors.isNegotiationMessage(Map.of(NEGOTIATION_URI, "v1"))); + assertFalse(AttributeExtractors.isNegotiationMessage(Map.of("k", "v"))); + } + + @Test + void regexAttributesFallBackToDefaultsAndHonorOptOut() { + Map metadata = Map.of("text", "## 任务类型(Task Type)\n\n配置下发\n## 通知主题\n\nalarm"); + A2ATObservabilityConfig fallback = A2ATObservabilityConfig.fromEnv(); + Map attrs = AttributeExtractors.regexAttributes(metadata, fallback); + assertEquals("配置下发", attrs.get(A2ATAttributes.ATTR_TASK_TYPE)); + assertEquals("alarm", attrs.get(A2ATAttributes.ATTR_NOTIFICATION_TOPIC)); + + Map optedOut = AttributeExtractors.regexAttributes( + metadata, A2ATObservabilityConfig.fromEnv().toBuilder().taskTypeRegex("").build()); + assertFalse(optedOut.containsKey(A2ATAttributes.ATTR_TASK_TYPE)); + } + + @Test + void terminalStateNamesNormalize() { + assertEquals("completed", AttributeExtractors.normalizeTaskStateName("TASK_STATE_COMPLETED")); + assertEquals("canceled", AttributeExtractors.normalizeTaskStateName("TASK_STATE_CANCELED")); + assertNull(AttributeExtractors.normalizeTaskStateName(null)); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("status")); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("artifact")); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("completed")); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=AttributeExtractorsTest"`。 + +- [ ] **Step 3: 实现** + +`A2ATAttributes.java`(常量值与 spec §5.2 / Python attributes.py 逐字一致): + +```java +package net.openan.a2at.sdk.observability; + +import java.util.Set; + +/** Span attribute keys and shared constants (values byte-identical to the Python SDK). */ +public final class A2ATAttributes { + + public static final String ATTR_EXTENSION_NAME = "gen_ai.agent.a2at.extension.name"; + public static final String ATTR_TASK_ID = "gen_ai.agent.a2at.task.id"; + public static final String ATTR_TASK_STATUS = "gen_ai.agent.a2at.task.status"; + public static final String ATTR_TASK_TYPE = "gen_ai.agent.a2at.task.type"; + public static final String ATTR_NEGOTIATION_ID = "gen_ai.agent.a2at.negotiation.id"; + public static final String ATTR_NEGOTIATION_ROUND = "gen_ai.agent.a2at.negotiation.round"; + public static final String ATTR_NEGOTIATION_MAX_ROUNDS = "gen_ai.agent.a2at.negotiation.max_rounds"; + public static final String ATTR_NEGOTIATION_PERFORMATIVE = "gen_ai.agent.a2at.negotiation.performative"; + public static final String ATTR_NEGOTIATION_TOTAL_ROUNDS = "gen_ai.agent.a2at.negotiation.total_rounds"; + public static final String ATTR_NOTIFICATION_TOPIC = "gen_ai.agent.a2at.notification.topic"; + public static final String ATTR_STREAMING_EVENT_KIND = "gen_ai.agent.a2at.streaming.event.kind"; + public static final String ATTR_PUSH_NOTIFICATION_URL = "gen_ai.agent.a2at.push.notification.url"; + public static final String ATTR_SPAN_SIDE = "a2at.span.side"; + public static final String ATTR_AUTHORIZATION_POLICY_OPERATION_TYPE = + "gen_ai.agent.a2at.authorization.policy.operation.type"; + public static final String ATTR_AUTHORIZATION_POLICY_ID = "authorization.policy.id"; + public static final String ATTR_AUTHORIZATION_OPERATION_TYPE = "authorization.operation_type"; + public static final String ATTR_AUTHORIZATION_OPERATION_RISK_LEVEL = "authorization.operation_risk_level"; + public static final String ATTR_A2A_OPERATION_NAME = "gen_ai.agent.a2a.operation.name"; + public static final String ATTR_A2A_CONTEXT_ID = "gen_ai.agent.a2a.context_id"; + public static final String ATTR_A2A_TASK_ID = "gen_ai.agent.a2a.task_id"; + public static final String ATTR_A2A_MESSAGE_ID = "gen_ai.agent.a2a.message_id"; + public static final String ATTR_A2A_ROLE = "gen_ai.agent.a2a.role"; + public static final String ATTR_A2A_EXTENSIONS = "gen_ai.agent.a2a.extensions"; + public static final String ATTR_A2A_PARTS_NUMBER = "gen_ai.agent.a2a.parts.number"; + public static final String ATTR_A2A_PROTOCOL = "gen_ai.agent.a2a.protocol"; + public static final String ATTR_A2A_REQUEST = "gen_ai.agent.a2a.request"; + public static final String ATTR_A2A_RESPONSE = "gen_ai.agent.a2a.response"; + public static final String ATTR_GEN_AI_OPERATION_NAME = "gen_ai.operation.name"; + public static final String ATTR_GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id"; + public static final String ATTR_GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"; + public static final String ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"; + public static final String ATTR_GEN_AI_REQUEST_MODEL = "gen_ai.request.model"; + public static final String ATTR_GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"; + public static final String ATTR_GEN_AI_TOKEN_TYPE = "gen_ai.token.type"; + public static final String ATTR_GEN_AI_PROVIDER_NAME = "gen_ai.provider.name"; + public static final String ATTR_STREAMING = "streaming"; + public static final String ATTR_ERROR_TYPE = "error.type"; + + public static final String NEGOTIATION_EXTENSION = "Negotiation-T"; + public static final String NEGOTIATION_SUFFIX = "-negotiation"; + + public static final Set EVENT_LOG_KINDS = Set.of( + "status", "artifact", "completed", "canceled", "failed", "rejected"); + + private A2ATAttributes() {} +} +``` + +`EventInfo.java`: + +```java +package net.openan.a2at.sdk.observability; + +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** Classified view of one A2A stream event (protocol-agnostic). */ +public record EventInfo( + String kind, + @Nullable String taskId, + @Nullable String taskStatus, + boolean finalEvent, + boolean terminal, + @Nullable Map messageMetadata, + @Nullable String artifactName) { + + public static final EventInfo UNKNOWN = + new EventInfo("unknown", null, null, false, false, null, null); + + public boolean isNegotiationCandidate() { + return "message".equals(kind); + } +} +``` + +`AttributeExtractors.java`: + +```java +package net.openan.a2at.sdk.observability; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** Metadata-level attribute extractors (Map in / attributes out); never raises. */ +public final class AttributeExtractors { + + public static final String EXTENSION_BASE = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/"; + public static final String DEFAULT_TASK_TYPE_REGEX = "##\\s*任务类型\\(Task Type\\)\\s*\\n+\\s*(\\S[^\\n]*)"; + public static final String DEFAULT_NOTIFICATION_TOPIC_REGEX = "##\\s*通知主题\\s*\\n+\\s*(\\S[^\\n]*)"; + public static final String DEFAULT_AUTHORIZATION_REGEX = "##\\s*授权策略的操作类型\\s*\\n+\\s*(\\S[^\\n]*)"; + private static final java.util.Set TERMINAL_PERFORMATIVES = java.util.Set.of("ACCEPT", "REJECT", "ABORT"); + + private AttributeExtractors() {} + + public static @Nullable String extensionNameFromUri(@Nullable String uri) { + if (uri == null || !uri.startsWith(EXTENSION_BASE)) { + return null; + } + String rest = uri.substring(EXTENSION_BASE.length()); + int slash = rest.indexOf('/'); + String name = slash >= 0 ? rest.substring(0, slash) : rest; + return name.isEmpty() ? null : name; + } + + public static @Nullable String extensionNameFromMetadata(Map metadata) { + for (String key : metadata.keySet()) { + String name = extensionNameFromUri(key); + if (name != null) { + return name; + } + } + return null; + } + + public static boolean isNegotiationMessage(Map metadata) { + return AttributeExtractors.NEGOTIATION_EXTENSION.equals(extensionNameFromMetadata(metadata)); + } + + @SuppressWarnings("unchecked") + public static Map negotiationAttributes(Map metadata) { + Map attrs = new LinkedHashMap<>(); + Object contextObj = metadata.get("negotiationContext"); + if (!(contextObj instanceof Map context)) { + return attrs; + } + Map ctx = (Map) context; + Object id = ctx.get("id"); + if (id instanceof String s && !s.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_ID, s); + } + Integer round = wireInt(ctx.get("round")); + if (round != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_ROUND, round); + } + Integer maxRounds = wireInt(ctx.get("maxRounds")); + if (maxRounds != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_MAX_ROUNDS, maxRounds); + } + Object performative = ctx.get("performative"); + if (performative instanceof String p && !p.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_PERFORMATIVE, p); + if (TERMINAL_PERFORMATIVES.contains(p) && round != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS, round); + } + } + return attrs; + } + + private static @Nullable Integer wireInt(Object value) { + if (value instanceof Integer i) { + return i; + } + if (value instanceof Number n && n.longValue() == n.doubleValue()) { + return n.intValue(); + } + return null; + } + + public static @Nullable String normalizeTaskStateName(@Nullable String enumName) { + if (enumName == null) { + return null; + } + String prefix = "TASK_STATE_"; + return enumName.startsWith(prefix) ? enumName.substring(prefix.length()).toLowerCase(java.util.Locale.ROOT) + : enumName.toLowerCase(java.util.Locale.ROOT); + } + + public static Map regexAttributes(Map metadata, A2ATObservabilityConfig config) { + Map attrs = new LinkedHashMap<>(); + putRegex(attrs, A2ATAttributes.ATTR_TASK_TYPE, metadata, + config.taskTypeRegex() != null ? config.taskTypeRegex() : DEFAULT_TASK_TYPE_REGEX, false); + putRegex(attrs, A2ATAttributes.ATTR_NOTIFICATION_TOPIC, metadata, + config.notificationTopicRegex() != null ? config.notificationTopicRegex() + : DEFAULT_NOTIFICATION_TOPIC_REGEX, + false); + String authorization = findFirst(metadata, config.authorizationPolicyOperationTypeRegex() != null + ? config.authorizationPolicyOperationTypeRegex() : DEFAULT_AUTHORIZATION_REGEX); + if (authorization != null) { + String cleaned = authorization.replaceAll("[((][^))]*[))]\\s*$", "").trim(); + if (!cleaned.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_AUTHORIZATION_POLICY_OPERATION_TYPE, cleaned); + } + } + return attrs; + } + + private static void putRegex(Map attrs, String key, Map metadata, + @Nullable String pattern, boolean unused) { + if (pattern == null || pattern.isEmpty()) { + return; + } + String found = findFirst(metadata, pattern); + if (found != null && !found.isEmpty()) { + attrs.put(key, found); + } + } + + private static @Nullable String findFirst(Map metadata, @Nullable String pattern) { + if (pattern == null || pattern.isEmpty()) { + return null; + } + Pattern compiled = Pattern.compile(pattern); + for (Object value : metadata.values()) { + if (value instanceof String s) { + Matcher matcher = compiled.matcher(s); + if (matcher.find()) { + return matcher.groupCount() > 0 ? matcher.group(1) : matcher.group(); + } + } + } + return null; + } +} +``` + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=AttributeExtractorsTest"`,期望 PASS。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add attribute constants and metadata extractors"` + +--- + +### Task 5: ObservabilityRuntime + +**Files:** +- Create: `.../observability/ObservabilityRuntime.java` +- Test: `.../observability/ObservabilityRuntimeTest.java` + +**Interfaces:** +- Consumes: Task 3 常量键名。 +- Produces: `isEnabled()` / `isTraceEnabled()` / `isMetricEnabled()` / `isLogEnabled()`(每次调用动态读 `System.getProperty` → `System.getenv`,无 import 期冻结——比 Python 可测性更好,行为等价);`tracer()` / `meter()`(`GlobalOpenTelemetry.getTracer/getMeter("a2at-observability", "1.0.0")`,未装 SDK 时 OTel API 天然 no-op);`sameTraceOrphanContext(@Nullable SpanContext) -> @Nullable Context`(同 trace 随机 span_id、无父、0 值守卫)。Task 6-10 与 Plan B 全部消费。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.api.trace.Span; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ObservabilityRuntimeTest { + + @AfterEach + void clearSwitches() { + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + System.clearProperty("A2AT_TRACE_ENABLED"); + } + + @Test + void masterSwitchDisablesAllSignals() { + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + assertFalse(ObservabilityRuntime.isEnabled()); + assertFalse(ObservabilityRuntime.isTraceEnabled()); + assertFalse(ObservabilityRuntime.isMetricEnabled()); + assertFalse(ObservabilityRuntime.isLogEnabled()); + } + + @Test + void traceToggleIsIndependentOfMasterOn() { + System.setProperty("A2AT_TRACE_ENABLED", "false"); + assertTrue(ObservabilityRuntime.isEnabled()); + assertFalse(ObservabilityRuntime.isTraceEnabled()); + assertTrue(ObservabilityRuntime.isMetricEnabled()); + } + + @Test + void sameTraceOrphanContextKeepsTraceIdAndDropsParent() { + SpanContext parent = SpanContext.create( + "0123456789abcdef0123456789abcdef", "0123456789abcdef", + TraceFlags.getSampled(), TraceState.getDefault()); + Context orphan = ObservabilityRuntime.sameTraceOrphanContext(parent); + assertNotNull(orphan); + SpanContext orphanSpan = Span.fromContext(orphan).getSpanContext(); + assertEquals(parent.getTraceId(), orphanSpan.getTraceId()); + assertNotEquals(parent.getSpanId(), orphanSpan.getSpanId()); + assertTrue(orphanSpan.isValid()); + } + + @Test + void invalidParentYieldsNull() { + assertNull(ObservabilityRuntime.sameTraceOrphanContext(SpanContext.getInvalid())); + assertNull(ObservabilityRuntime.sameTraceOrphanContext(null)); + } + + @Test + void tracerAndMeterScopeNames() { + try (Scope scope = Span.fromContext(Context.root()).makeCurrent()) { + assertNotNull(ObservabilityRuntime.tracer()); + assertNotNull(ObservabilityRuntime.meter()); + } + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=ObservabilityRuntimeTest"`。 + +- [ ] **Step 3: 实现** + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import java.util.concurrent.ThreadLocalRandom; +import org.jspecify.annotations.Nullable; + +/** Master/signal switches + OTel accessors. Switches read dynamically (property > env > default). */ +public final class ObservabilityRuntime { + + public static final String ENV_MASTER = "OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"; + public static final String ENV_TRACE = "A2AT_TRACE_ENABLED"; + public static final String ENV_METRIC = "A2AT_METRIC_ENABLED"; + public static final String ENV_LOG = "A2AT_LOG_ENABLED"; + + public static final String SCOPE_NAME = "a2at-observability"; + public static final String SCOPE_VERSION = "1.0.0"; + + private ObservabilityRuntime() {} + + private static boolean switchOn(String key) { + String raw = System.getProperty(key, System.getenv(key)); + if (raw == null) { + return true; + } + return switch (raw.trim().toLowerCase(java.util.Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + public static boolean isEnabled() { + return switchOn(ENV_MASTER); + } + + public static boolean isTraceEnabled() { + return isEnabled() && switchOn(ENV_TRACE); + } + + public static boolean isMetricEnabled() { + return isEnabled() && switchOn(ENV_METRIC); + } + + public static boolean isLogEnabled() { + return isEnabled() && switchOn(ENV_LOG); + } + + public static Tracer tracer() { + return GlobalOpenTelemetry.getTracer(SCOPE_NAME, SCOPE_VERSION); + } + + public static Meter meter() { + return GlobalOpenTelemetry.getMeter(SCOPE_NAME, SCOPE_VERSION); + } + + /** Same-trace orphan context: new span context shares trace_id, random span_id, no parent. */ + public static @Nullable Context sameTraceOrphanContext(@Nullable SpanContext parent) { + if (parent == null || !parent.isValid()) { + return null; + } + long random = ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE); + SpanContext orphan = SpanContext.create( + parent.getTraceId(), String.format("%016x", random), + TraceFlags.fromByte(parent.getTraceFlags().asByte()), parent.getTraceState()); + return Context.root().with(io.opentelemetry.api.trace.Span.wrap(orphan)); + } +} +``` + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=ObservabilityRuntimeTest"`。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add ObservabilityRuntime switches and orphan context"` + +--- + +### Task 6: A2ATSpan + A2ATCurrentSpan + CurrentSpans + +**Files:** +- Create: `.../observability/A2ATSpan.java`、`.../observability/A2ATCurrentSpan.java`、`.../observability/CurrentSpans.java` +- Test: `.../observability/A2ATSpanTest.java` + +**Interfaces:** +- Consumes: Task 5。 +- Produces: `A2ATSpan(String name)` / `A2ATSpan(String, SpanKind)`,链式 `setAttribute(String, Object)` / `addLink(A2ATSpan)`,`open() -> A2ATSpan`(启动并 makeCurrent;trace 关时吸收),`recordError(Throwable)`,`spanContext()`,`close()`(recordError→ERROR+record_exception,否则 OK;try-with-resources);`CurrentSpans.current() -> @Nullable A2ATCurrentSpan`;`A2ATCurrentSpan.traceId()/spanId()/isValid()/setAttribute(String,Object)`。手动 API 面合同(spec §9)。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class A2ATSpanTest { + + private InMemorySpanExporter exporter; + private SdkTracerProvider provider; + + @BeforeEach + void setUp() { + exporter = InMemorySpanExporter.create(); + provider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().setTracerProvider(provider).build()); + } + + @AfterEach + void tearDown() { + provider.close(); + GlobalOpenTelemetry.reset(); + } + + @Test + void tryWithResourcesEndsWithOk() { + try (A2ATSpan span = new A2ATSpan("manual.op", SpanKind.INTERNAL).setAttribute("k", "v").open()) { + span.setAttribute("k2", 2); + assertNotNull(CurrentSpans.current()); + assertNotNull(CurrentSpans.current().traceId()); + } + List ended = exporter.getFinishedSpanItems(); + assertEquals(1, ended.size()); + assertEquals("manual.op", ended.get(0).getName()); + assertEquals(io.opentelemetry.api.trace.StatusCode.OK, ended.get(0).getStatus().getStatusCode()); + assertEquals("v", ended.get(0).getAttributes().get( + io.opentelemetry.api.common.AttributeKey.stringKey("k"))); + } + + @Test + void recordErrorEndsWithErrorAndException() { + RuntimeException failure = new IllegalStateException("boom"); + try (A2ATSpan span = new A2ATSpan("manual.fail").open()) { + span.recordError(failure); + } + SpanData data = exporter.getFinishedSpanItems().get(0); + assertEquals(io.opentelemetry.api.trace.StatusCode.ERROR, data.getStatus().getStatusCode()); + assertFalse(data.getEvents().isEmpty()); + assertTrue(data.getStatus().getDescription().contains("boom")); + } + + @Test + void addLinkBeforeOpenCreatesLink() { + try (A2ATSpan entry = new A2ATSpan("entry").open()) { + try (A2ATSpan event = new A2ATSpan("event").addLink(entry).open()) { + // linked at creation + } + } + List ended = exporter.getFinishedSpanItems(); + SpanData event = ended.stream().filter(d -> d.getName().equals("event")).findFirst().orElseThrow(); + assertEquals(1, event.getLinks().size()); + assertEquals( + ended.stream().filter(d -> d.getName().equals("entry")).findFirst().orElseThrow() + .getSpanContext(), + event.getLinks().get(0).getSpanContext()); + } + + @Test + void singleUseAndClosedAbsorb() { + A2ATSpan span = new A2ATSpan("once").open(); + assertThrows(IllegalStateException.class, span::open); + span.close(); + span.close(); + } + + @Test + void currentSpanNullOutsideAndValidInside() { + assertNull(CurrentSpans.current()); + try (A2ATSpan span = new A2ATSpan("s").open()) { + A2ATCurrentSpan current = CurrentSpans.current(); + assertNotNull(current); + assertTrue(current.isValid()); + assertEquals(32, current.traceId().length()); + assertEquals(16, current.spanId().length()); + current.setAttribute("cur", "x"); + } + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=A2ATSpanTest"`。 + +- [ ] **Step 3: 实现** + +`A2ATSpan.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Scope; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Manual A2A-T span; try-with-resources + open(). Absorbs everything when trace is disabled. */ +public final class A2ATSpan implements AutoCloseable { + + private final String name; + private final SpanKind kind; + private final Map attributes = new LinkedHashMap<>(); + private final List pendingLinks = new ArrayList<>(); + private Span span; + private Scope scope; + private boolean opened; + private boolean closed; + private boolean errorRecorded; + + public A2ATSpan(String name) { + this(name, SpanKind.INTERNAL); + } + + public A2ATSpan(String name, SpanKind kind) { + this.name = name; + this.kind = kind; + } + + public A2ATSpan setAttribute(String key, Object value) { + if (span != null) { + applyAttribute(span, key, value); + } else { + attributes.put(key, value); + } + return this; + } + + public A2ATSpan addLink(A2ATSpan other) { + SpanContext context = other == null ? null : other.spanContext(); + if (context == null || !context.isValid()) { + return this; + } + if (span != null) { + span.addLink(context); + } else { + pendingLinks.add(context); + } + return this; + } + + /** Starts the span and makes it current; idempotent-guarded, absorbs when trace is off. */ + public A2ATSpan open() { + if (opened) { + throw new IllegalStateException("A2ATSpan is single-use; create a new instance for each span"); + } + opened = true; + if (!ObservabilityRuntime.isTraceEnabled()) { + return this; + } + SpanBuilder builder = ObservabilityRuntime.tracer().spanBuilder(name).setSpanKind(kind); + for (SpanContext link : pendingLinks) { + builder.addLink(link); + } + span = builder.startSpan(); + attributes.forEach(this::applyOnSpan); + scope = span.makeCurrent(); + return this; + } + + private void applyOnSpan(String key, Object value) { + if (span != null) { + applyAttribute(span, key, value); + } + } + + private static void applyAttribute(Span target, String key, Object value) { + switch (value) { + case String s -> target.setAttribute(key, s); + case Integer i -> target.setAttribute(key, i); + case Long l -> target.setAttribute(key, l); + case Boolean b -> target.setAttribute(key, b); + case Double d -> target.setAttribute(key, d); + case null, default -> target.setAttribute(key, String.valueOf(value)); + } + } + + public void recordError(Throwable error) { + if (span == null || error == null) { + return; + } + errorRecorded = true; + span.recordException(error); + span.setStatus(StatusCode.ERROR, String.valueOf(error.getMessage())); + } + + public SpanContext spanContext() { + return span != null ? span.getSpanContext() : SpanContext.getInvalid(); + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + try (Scope restore = scope) { + if (span != null && span.isRecording()) { + if (!errorRecorded) { + span.setStatus(StatusCode.OK); + } + span.end(); + } + } + } +} +``` + +注意:`errorRecorded` 守卫保证 `recordError` 后 `close` 不再覆盖为 OK(Python `__exit__(exc)` 语义的 Java 等价)。 + +`A2ATCurrentSpan.java` + `CurrentSpans.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; + +/** Read/append view of the active span. */ +public final class A2ATCurrentSpan { + private final Span span; + + A2ATCurrentSpan(Span span) { + this.span = span; + } + + public String traceId() { return span.getSpanContext().getTraceId(); } + public String spanId() { return span.getSpanContext().getSpanId(); } + public boolean isValid() { return span.getSpanContext().isValid(); } + public void setAttribute(String key, Object value) { span.setAttribute(key, String.valueOf(value)); } +} +``` + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; +import org.jspecify.annotations.Nullable; + +/** Accessor for the active A2A-T span. */ +public final class CurrentSpans { + + private CurrentSpans() {} + + public static @Nullable A2ATCurrentSpan current() { + Span span = Span.current(); + if (!span.getSpanContext().isValid() || !span.isRecording()) { + return null; + } + return new A2ATCurrentSpan(span); + } +} +``` + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=A2ATSpanTest"`。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add manual span API"` + +--- + +### Task 7: TracePropagation + PayloadExtractor + PayloadSanitizer + +**Files:** +- Create: `.../observability/TracePropagation.java`、`.../observability/PayloadExtractor.java`、`.../observability/PayloadSanitizer.java` +- Test: `.../observability/PropagationAndPayloadTest.java` + +**Interfaces:** +- Produces: `TracePropagation.TRACEPARENT_HEADER="traceparent"`;`inject(Map)`(当前上下文)/ `inject(Map, Context)`;`extract(Map) -> @Nullable Context`(key 小写容错;无效返回 null)。`PayloadExtractor.toJson(@Nullable Object) -> @Nullable String`(Jackson;失败 DEBUG+null)。`PayloadSanitizer.prepare(String, config)`(先脱敏后截断,`[truncated]` 后缀)。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class PropagationAndPayloadTest { + + @AfterEach + void reset() { + io.opentelemetry.api.GlobalOpenTelemetry.reset(); + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + } + + @Test + void injectExtractRoundtripSurvivesLowercasing() { + io.opentelemetry.api.GlobalOpenTelemetry.set(io.opentelemetry.sdk.OpenTelemetrySdk.builder() + .setTracerProvider(io.opentelemetry.sdk.trace.SdkTracerProvider.builder().build()).build()); + Map headers = new HashMap<>(); + String traceId; + try (var span = new A2ATSpan("root", io.opentelemetry.api.trace.SpanKind.CLIENT).open()) { + traceId = Span.current().getSpanContext().getTraceId(); + TracePropagation.inject(headers); + } + Map mixed = Map.of("TRACEPARENT", headers.get("traceparent")); + Context extracted = TracePropagation.extract(mixed); + org.junit.jupiter.api.Assertions.assertNotNull(extracted); + assertEquals(traceId, Span.fromContext(extracted).getSpanContext().getTraceId()); + assertTrue(headers.get("traceparent").startsWith("00-" + traceId)); + } + + @Test + void extractReturnsNullWhenInvalidOrDisabled() { + assertNull(TracePropagation.extract(Map.of("traceparent", "00-abc-def-01"))); + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + assertNull(TracePropagation.extract(Map.of("traceparent", "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"))); + } + + @Test + void payloadRedactsBeforeTruncation() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv().toBuilder() + .payloadLogMaxLength(10) + .payloadRedactor(value -> value.replace("secret", "***")) + .build(); + String prepared = PayloadSanitizer.prepare("secret-abcdefghijklmnopqrstuvwxyz", config); + assertFalse(prepared.contains("secret")); + assertTrue(prepared.endsWith("[truncated]")); + assertTrue(prepared.length() <= 10 + "[truncated]".length()); + } + + @Test + void payloadExtractorSerializesRecordsAndNullFails() { + record Point(int x, int y) {} + assertTrue(PayloadExtractor.toJson(new Point(1, 2)).contains("\"x\":1")); + assertNull(PayloadExtractor.toJson(null)); + assertNull(PayloadExtractor.toJson(new Object() { + @Override + public String toString() { + throw new IllegalStateException("unserializable"); + } + })); + } +} +``` + +注意:Jackson 序列化匿名类默认会抛 `InvalidDefinitionException` → `toJson` 捕获返回 null,正是断言路径。 + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=PropagationAndPayloadTest"`。 + +- [ ] **Step 3: 实现** + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapGetter; +import io.opentelemetry.context.propagation.TextMapSetter; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** W3C TraceContext propagation over plain header maps. */ +public final class TracePropagation { + + public static final String TRACEPARENT_HEADER = "traceparent"; + + private static final TextMapSetter> SETTER = Map::put; + private static final TextMapGetter> GETTER = new TextMapGetter<>() { + @Override + public Iterable keys(Map carrier) { + return carrier.keySet(); + } + + @Override + public @Nullable String get(@Nullable Map carrier, String key) { + return carrier == null ? null : carrier.get(key); + } + }; + + private TracePropagation() {} + + public static void inject(Map headers) { + if (headers == null || !ObservabilityRuntime.isEnabled()) { + return; + } + GlobalOpenTelemetry.getPropagators().getTextMapPropagator() + .inject(Context.current(), headers, SETTER); + } + + public static void inject(Map headers, Context context) { + if (headers == null || context == null || !ObservabilityRuntime.isEnabled()) { + return; + } + GlobalOpenTelemetry.getPropagators().getTextMapPropagator().inject(context, headers, SETTER); + } + + public static @Nullable Context extract(@Nullable Map headers) { + if (headers == null || !ObservabilityRuntime.isEnabled()) { + return null; + } + Map lowered = new HashMap<>(); + headers.forEach((key, value) -> lowered.put(key.toLowerCase(Locale.ROOT), value)); + Context extracted = GlobalOpenTelemetry.getPropagators().getTextMapPropagator() + .extract(Context.root(), lowered, GETTER); + SpanContextProbe probe = new SpanContextProbe(extracted); + return probe.isValid() ? extracted : null; + } + + private static final class SpanContextProbe { + private final boolean valid; + + SpanContextProbe(Context context) { + this.valid = io.opentelemetry.api.trace.Span.fromContext(context).getSpanContext().isValid(); + } + + boolean isValid() { + return valid; + } + } +} +``` + +(实现可内联为单行 `Span.fromContext(extracted).getSpanContext().isValid() ? extracted : null`,无需内部类——以简洁版为准。) + +```java +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Serializes request/event objects to JSON for payload channels. */ +public final class PayloadExtractor { + + private static final Logger LOG = LoggerFactory.getLogger(PayloadExtractor.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private PayloadExtractor() {} + + public static @Nullable String toJson(@Nullable Object value) { + if (value == null) { + return null; + } + try { + return MAPPER.writeValueAsString(value); + } catch (Exception e) { + LOG.debug("a2at: payload extraction failed", e); + return null; + } + } +} +``` + +```java +package net.openan.a2at.sdk.observability; + +import java.util.function.UnaryOperator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Shared payload preparation: redact first, then truncate (security order). */ +public final class PayloadSanitizer { + + private static final Logger LOG = LoggerFactory.getLogger(PayloadSanitizer.class); + + private PayloadSanitizer() {} + + public static String prepare(String payload, A2ATObservabilityConfig config) { + String value = payload; + UnaryOperator redactor = config.payloadRedactor(); + if (redactor != null) { + try { + value = redactor.apply(value); + } catch (RuntimeException e) { + LOG.warn("a2at: payload redactor failed; using unredacted value", e); + } + } + int max = config.payloadLogMaxLength(); + if (value.length() > max) { + return value.substring(0, max) + "[truncated]"; + } + return value; + } +} +``` + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=PropagationAndPayloadTest"`。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add propagation and payload channels"` + +--- + +### Task 8: LlmUsageStash + ObservabilityLLMClient + MetricHistograms + NegotiationMetrics + +**Files:** +- Create: `.../observability/LlmUsageStash.java`、`.../observability/LlmUsageStashHolder.java`、`.../observability/MetricHistograms.java`、`.../observability/NegotiationMetrics.java`、`.../observability/ObservabilityLLMClient.java` +- Test: `.../observability/LlmObservabilityTest.java`、`.../observability/NegotiationMetricsTest.java` + +**Interfaces:** +- Consumes: `LLMClient`/`LLMResponse`(a2a-t-llm;`usage` 键 `prompt_tokens`/`completion_tokens`)、Task 4 常量、Task 5 开关。 +- Produces: + - `record LlmUsageStash(@Nullable Integer inputTokens, @Nullable Integer outputTokens, @Nullable String requestModel)` + - `LlmUsageStashHolder.set(stash)` / `getAndClear()` / `clear()`(ThreadLocal) + - `ObservabilityLLMClient implements LLMClient`(包装 delegate;调用后 stash + 记 `gen_ai.client.token.usage` 直方图,`gen_ai.token.type`=input/output;metric 关时仅 stash) + - `MetricHistograms.histogram(name, unit, description) -> LongHistogram`(ConcurrentHashMap 惰性缓存;package-private) + - `NegotiationMetrics.reportRounds(@Nullable String negotiationId, @Nullable String outcome, int rounds)`(outcome ∈ {accept,reject,abort};`extension.name`+`negotiation.id`+`outcome` 属性;metric 关 → 跳过;永不抛) + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.PeriodicExportingMetricReader; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LlmObservabilityTest { + + private InMemoryMetricReader reader; + + @BeforeEach + void setUp() { + reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder() + .registerMetricReader(reader) + .build(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().setMeterProvider(provider).build()); + } + + @AfterEach + void tearDown() { + GlobalOpenTelemetry.reset(); + } + + @Test + void structuredStashesUsageAndRecordsTokenHistogram() { + LLMClient scripted = (messages, schema, temperature, maxTokens) -> new LLMResponse( + "ok", "gpt-x", Map.of("prompt_tokens", 10, "completion_tokens", 5), Map.of()); + ObservabilityLLMClient client = new ObservabilityLLMClient(scripted); + client.structured(List.of(Map.of("role", "user", "content", "hi")), Map.of(), null, null); + + LlmUsageStash stash = LlmUsageStashHolder.getAndClear(); + assertNotNull(stash); + assertEquals(10, stash.inputTokens()); + assertEquals(5, stash.outputTokens()); + assertEquals("gpt-x", stash.requestModel()); + assertNull(LlmUsageStashHolder.getAndClear()); + + var metrics = reader.collectAllMetrics().join(); + assertTrue(metrics.stream().anyMatch(m -> "gen_ai.client.token.usage".equals(m.getName()) + && !m.getHistogramData().getDataPoints().isEmpty()), + "token usage histogram must be recorded"); + } + + @Test + void metricDisabledStillStashes() { + System.setProperty("A2AT_METRIC_ENABLED", "false"); + try { + LLMClient scripted = (messages, schema, temperature, maxTokens) -> + new LLMResponse("ok", "m", Map.of("prompt_tokens", 1, "completion_tokens", 2), Map.of()); + new ObservabilityLLMClient(scripted).structured(List.of(), Map.of(), null, null); + assertNotNull(LlmUsageStashHolder.getAndClear()); + assertEquals(0, reader.collectAllMetrics().join().size()); + } finally { + System.clearProperty("A2AT_METRIC_ENABLED"); + } + } + + @Test + void stashFailurePathKeepsSemantics() { + // delegate 异常原样抛出,不写 stash(观测永不破坏业务) + LLMClient throwing = (messages, schema, temperature, maxTokens) -> { + throw new IllegalStateException("llm down"); + }; + org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, + () -> new ObservabilityLLMClient(throwing).structured(List.of(), Map.of(), null, null)); + assertNull(LlmUsageStashHolder.getAndClear()); + } +} +``` + +注意:`LLMResponse.usage` 类型为 `Map`(键 `prompt_tokens`/`completion_tokens`,见 `a2a-t-llm` 的 `OpenAIClient`),类型约束由编译器保证,无需非法值用例。 + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class NegotiationMetricsTest { + + private InMemoryMetricReader reader; + + @BeforeEach + void setUp() { + reader = InMemoryMetricReader.create(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder() + .setMeterProvider(SdkMeterProvider.builder().registerMetricReader(reader).build()).build()); + } + + @AfterEach + void tearDown() { + GlobalOpenTelemetry.reset(); + } + + @Test + void terminalOutcomeRecordsRoundsWithAttributes() { + NegotiationMetrics.reportRounds("N001", "accept", 3); + var metrics = reader.collectAllMetrics().join(); + var histogram = metrics.stream() + .filter(m -> "a2at.negotiation.total_rounds".equals(m.getName())) + .findFirst().orElseThrow(); + assertEquals("a2at.negotiation.total_rounds", histogram.getName()); + assertTrue(!histogram.getHistogramData().getDataPoints().isEmpty()); + } + + @Test + void nonTerminalOutcomeAndNegativeRoundsAreSkipped() { + NegotiationMetrics.reportRounds("N1", "propose", 2); + NegotiationMetrics.reportRounds("N1", "accept", -1); + assertEquals(0, reader.collectAllMetrics().join().size()); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=LlmObservabilityTest,NegotiationMetricsTest"`。 + +> 依赖补充:`InMemoryMetricReader` 来自 `io.opentelemetry:opentelemetry-sdk-testing:1.64.0` —— 在 `a2a-t-observability/pom.xml` 增加测试依赖(版本经 bom 管理): +> ```xml +> +> io.opentelemetry +> opentelemetry-sdk-testing +> test +> +> ``` + +- [ ] **Step 3: 实现** + +`LlmUsageStash.java` + `LlmUsageStashHolder.java`: + +```java +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.Nullable; + +/** Token usage captured from one LLM call; drained onto the next entry span. */ +public record LlmUsageStash( + @Nullable Integer inputTokens, + @Nullable Integer outputTokens, + @Nullable String requestModel) {} +``` + +```java +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.Nullable; + +/** ThreadLocal bridge: LLM layer -> entry span (SDK is fully synchronous). */ +public final class LlmUsageStashHolder { + + private static final ThreadLocal STASH = new ThreadLocal<>(); + + private LlmUsageStashHolder() {} + + public static void set(LlmUsageStash usage) { + STASH.set(usage); + } + + public static @Nullable LlmUsageStash getAndClear() { + LlmUsageStash stash = STASH.get(); + STASH.remove(); + return stash; + } + + public static void clear() { + STASH.remove(); + } +} +``` + +`MetricHistograms.java`(package-private): + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.metrics.LongHistogram; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** Lazy histogram cache keyed by metric name (shared meter scope a2at-observability/1.0.0). */ +final class MetricHistograms { + + private static final ConcurrentMap INSTRUMENTS = new ConcurrentHashMap<>(); + + private MetricHistograms() {} + + static LongHistogram histogram(String name, String unit, String description) { + return INSTRUMENTS.computeIfAbsent(name, metricName -> ObservabilityRuntime.meter() + .histogramBuilder(metricName) + .setUnit(unit) + .setDescription(description) + .ofLongs() + .build()); + } +} +``` + +`NegotiationMetrics.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.common.Attributes; +import java.util.Set; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** a2at.negotiation.total_rounds histogram; terminal outcomes only; never raises. */ +public final class NegotiationMetrics { + + public static final String METRIC_NAME = "a2at.negotiation.total_rounds"; + private static final Logger LOG = LoggerFactory.getLogger(NegotiationMetrics.class); + private static final Set OUTCOMES = Set.of("accept", "reject", "abort"); + + private NegotiationMetrics() {} + + public static void reportRounds(@Nullable String negotiationId, @Nullable String outcome, int rounds) { + try { + if (!ObservabilityRuntime.isMetricEnabled() || rounds < 0) { + return; + } + if (outcome == null || !OUTCOMES.contains(outcome.toLowerCase(java.util.Locale.ROOT))) { + return; + } + var builder = Attributes.builder(); + builder.put(A2ATAttributes.ATTR_EXTENSION_NAME, A2ATAttributes.NEGOTIATION_EXTENSION); + if (negotiationId != null && !negotiationId.isEmpty()) { + builder.put(A2ATAttributes.ATTR_NEGOTIATION_ID, negotiationId); + } + builder.put("outcome", outcome.toLowerCase(java.util.Locale.ROOT)); + MetricHistograms.histogram(METRIC_NAME, "{round}", "Negotiation terminal message round count") + .record(rounds, builder.build()); + } catch (RuntimeException e) { + LOG.warn("a2at: failed to report negotiation rounds", e); + } + } +} +``` + +`ObservabilityLLMClient.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.common.Attributes; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** LLMClient decorator: usage stash + gen_ai.client.token.usage histogram; never breaks the call. */ +public final class ObservabilityLLMClient implements LLMClient { + + public static final String METRIC_TOKEN_USAGE = "gen_ai.client.token.usage"; + private static final Logger LOG = LoggerFactory.getLogger(ObservabilityLLMClient.class); + private final LLMClient delegate; + + public ObservabilityLLMClient(LLMClient delegate) { + this.delegate = java.util.Objects.requireNonNull(delegate); + } + + @Override + public LLMResponse structured( + List> messages, Map jsonSchema, + @Nullable Double temperature, @Nullable Integer maxTokens) { + LLMResponse response = delegate.structured(messages, jsonSchema, temperature, maxTokens); + report(response); + return response; + } + + private void report(LLMResponse response) { + try { + Map usage = response.usage() == null ? Map.of() : response.usage(); + Integer input = usage.get("prompt_tokens"); + Integer output = usage.get("completion_tokens"); + LlmUsageStashHolder.set(new LlmUsageStash(input, output, response.model())); + if (ObservabilityRuntime.isMetricEnabled()) { + recordTokenUsage(input, output); + } + } catch (RuntimeException e) { + LOG.warn("a2at: failed to report llm token usage", e); + } + } + + private void recordTokenUsage(@Nullable Integer input, @Nullable Integer output) { + if (input != null) { + MetricHistograms.histogram(METRIC_TOKEN_USAGE, "{token}", "GenAI client token usage") + .record(input, Attributes.of(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE, "input")); + } + if (output != null) { + MetricHistograms.histogram(METRIC_TOKEN_USAGE, "{token}", "GenAI client token usage") + .record(output, Attributes.of(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE, "output")); + } + } +} +``` + +(`LongHistogram unused` 残行已清理——以上码即最终版。) + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=LlmObservabilityTest,NegotiationMetricsTest"`。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add LLM usage stash, token metric and negotiation rounds metric"` + +--- + +### Task 9: ObservabilityLogs + ConsoleExporters + ObservabilitySetup + +**Files:** +- Create: `.../observability/ObservabilityLogs.java`、`.../observability/ConsoleExporters.java`、`.../observability/ObservabilitySetup.java` +- Test: `.../observability/ObservabilityLogsTest.java`、`.../observability/ObservabilitySetupTest.java` + +**Interfaces:** +- Consumes: Task 3 config、Task 5 开关、Task 7 sanitizer。 +- Produces: + - `ObservabilityLogs.logEvent(String event, org.slf4j.event.Level level, Map fields, @Nullable String payload, A2ATObservabilityConfig config)`:SLF4J JSON 行(logger `net.openan.a2at.sdk.observability`,字段 event + fields + trace_id/span_id + payload?)+ OTel `Logger` API 同步发射(`GlobalLoggerProvider.get().get("a2at-observability")`);log_enabled 双门控;永不抛。 + - `ConsoleExporters`(package-private):`spanExporter()` / `metricExporter()` / `logRecordExporter()` —— 内置 SLF4J JSON 导出器(`opentelemetry-exporter-logging` 不在 .m2,等效自研)。 + - `ObservabilitySetup.ensureConfigured()` / `ensureConfigured(A2ATObservabilityConfig)` / `isConfigured()`:懒装配三信号(SdkTracerProvider/SdkMeterProvider/SdkLoggerProvider + Batch* + PeriodicExportingMetricReader);endpoint → OTLP gRPC/HTTP(HTTP 拼 `/v1/traces|metrics|logs`);无 endpoint → Console(`A2AT_CONSOLE_EXPORTER=false` 且无 endpoint → 信号保持 no-op);幂等(once 标志 + `GlobalOpenTelemetry.set` 抛 `IllegalStateException` 捕获跳过);sdk 类缺失 `Class.forName` 探测跳过。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.api.GlobalOpenTelemetry; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +class ObservabilityLogsTest { + + private ListAppender appender; + private Logger logger; + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void attach() { + logger = (Logger) LoggerFactory.getLogger("net.openan.a2at.sdk.observability"); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(ch.qos.logback.classic.Level.DEBUG); + GlobalOpenTelemetry.reset(); + } + + @AfterEach + void detach() { + logger.detachAppender(appender); + System.clearProperty("A2AT_LOG_ENABLED"); + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + } + + @Test + void jsonRecordCarriesEventFieldsAndTraceIds() throws Exception { + try (A2ATSpan span = new A2ATSpan("entry").open()) { + ObservabilityLogs.logEvent("task.status_changed", Level.INFO, + Map.of("task.id", "t1", "task.status", "working"), null, A2ATObservabilityConfig.fromEnv()); + } + ILoggingEvent event = appender.list.stream() + .filter(e -> e.getLevel() == Level.INFO).findFirst().orElseThrow(); + Map json = mapper.readValue(event.getFormattedMessage(), Map.class); + assertEquals("task.status_changed", json.get("event")); + assertEquals("t1", json.get("task.id")); + assertEquals("working", json.get("task.status")); + assertNotNull(json.get("trace_id")); + assertNotNull(json.get("span_id")); + } + + @Test + void payloadChannelHonorsConfigAndRedaction() throws Exception { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv().toBuilder() + .payloadLogEnabled(true) + .payloadRedactor(v -> v.replace("key", "***")) + .build(); + ObservabilityLogs.logEvent("task.request", Level.DEBUG, Map.of(), "payload: key=1", config); + Map json = mapper.readValue(appender.list.get(0).getFormattedMessage(), Map.class); + assertTrue(String.valueOf(json.get("payload")).contains("***")); + assertFalse(String.valueOf(json.get("payload")).contains("key=1")); + } + + @Test + void logSwitchOffEmitsNothing() { + System.setProperty("A2AT_LOG_ENABLED", "false"); + ObservabilityLogs.logEvent("task.request", Level.DEBUG, Map.of(), null, A2ATObservabilityConfig.fromEnv()); + System.clearProperty("A2AT_LOG_ENABLED"); + assertEquals(0, appender.list.size()); + } +} +``` + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ObservabilitySetupTest { + + @AfterEach + void reset() { + GlobalOpenTelemetry.reset(); + System.clearProperty("A2AT_OBSERVABILITY_ENABLED"); + } + + @Test + void consoleFallbackConfiguresAllThreeSignals() { + GlobalOpenTelemetry.reset(); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertTrue(ObservabilitySetup.isConfigured()); + assertInstanceOf(OpenTelemetrySdk.class, GlobalOpenTelemetry.get()); + GlobalOpenTelemetry.getTracer("t").spanBuilder("s").startSpan().end(); + } + + @Test + void existingGlobalProviderIsRespected() { + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().build()); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertFalse(ObservabilitySetup.isConfigured()); + } + + @Test + void disabledSkipsEverything() { + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertFalse(ObservabilitySetup.isConfigured()); + assertFalse(ObservabilityRuntime.isEnabled()); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=ObservabilityLogsTest,ObservabilitySetupTest"`。 + +- [ ] **Step 3: 实现** + +`ObservabilityLogs.java`: + +```java +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.logs.SeverityNumber; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.slf4j.event.Level; + +/** Structured JSON events: SLF4J channel + OTel logs channel sharing identical semantics. */ +public final class ObservabilityLogs { + + public static final String LOGGER_NAME = "net.openan.a2at.sdk.observability"; + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(LOGGER_NAME); + private static final Logger OTEL_LOGGER = io.opentelemetry.api.logs.GlobalLoggerProvider.get() + .get(ObservabilityRuntime.SCOPE_NAME); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ObservabilityLogs() {} + + public static void logEvent(String event, Level level, Map fields, + @Nullable String payload, A2ATObservabilityConfig config) { + if (!ObservabilityRuntime.isLogEnabled() || !config.logEnabled()) { + return; + } + try { + Map data = new LinkedHashMap<>(); + data.put("event", event); + if (fields != null) { + data.putAll(fields); + } + var spanContext = io.opentelemetry.api.trace.Span.current().getSpanContext(); + if (spanContext.isValid()) { + data.put("trace_id", spanContext.getTraceId()); + data.put("span_id", spanContext.getSpanId()); + } + if (payload != null && config.payloadLogEnabled()) { + data.put("payload", PayloadSanitizer.prepare(payload, config)); + } + String json = MAPPER.writeValueAsString(data); + LOG.atLevel(level).log(json); + emitOtel(level, data, json); + } catch (Exception e) { + LOG.debug("a2at: log_event failed", e); + } + } + + private static void emitOtel(Level level, Map data, String json) { + try { + var builder = OTEL_LOGGER.logRecordBuilder() + .setSeverity(severity(level)) + .setSeverityText(level.name()) + .setBody(json); + data.forEach((key, value) -> { + if (!"event".equals(key) && !"trace_id".equals(key) && !"span_id".equals(key)) { + builder.setAttribute(key, String.valueOf(value)); + } + }); + builder.emit(); + } catch (RuntimeException e) { + LOG.debug("a2at: otel log emit failed", e); + } + } + + private static SeverityNumber severity(Level level) { + return switch (level) { + case DEBUG -> SeverityNumber.DEBUG; + case INFO -> SeverityNumber.INFO; + case WARN -> SeverityNumber.WARN; + case ERROR -> SeverityNumber.ERROR; + default -> SeverityNumber.INFO; + }; + } +} +``` + +`ConsoleExporters.java`(package-private,先 `javap -cp io.opentelemetry.sdk.metrics.export.MetricExporter io.opentelemetry.sdk.logs.export.LogRecordExporter io.opentelemetry.sdk.trace.export.SpanExporter` 核对 1.64 方法签名,若与下码不符按实际调整): + +```java +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.logs.export.LogRecordExporter; +import io.opentelemetry.sdk.metrics.InstrumentType; +import io.opentelemetry.sdk.metrics.data.AggregationTemporality; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Collection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** In-house dev-mode exporters (JSON lines on SLF4J); replaces opentelemetry-exporter-logging. */ +final class ConsoleExporters { + + private static final Logger LOG = LoggerFactory.getLogger("net.openan.a2at.sdk.observability.console"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ConsoleExporters() {} + + static SpanExporter spanExporter() { + return new SpanExporter() { + @Override + public CompletableResultCode export(Collection spans) { + spans.forEach(span -> LOG.info(json("span", span.getName(), + java.util.Map.of( + "traceId", span.getTraceId(), "spanId", span.getSpanId(), + "kind", span.getKind().name(), + "status", span.getStatus().getStatusCode().name(), + "attributes", span.getAttributes().asMap())))); + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.success(); + } + }; + } + + static MetricExporter metricExporter() { + return new MetricExporter() { + @Override + public CompletableResultCode export(Collection metrics) { + metrics.forEach(metric -> LOG.info(json("metric", metric.getName(), + java.util.Map.of("unit", String.valueOf(metric.getUnit()), + "description", String.valueOf(metric.getDescription()))))); + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.success(); + } + + @Override + public AggregationTemporality getAggregationTemporality(InstrumentType instrumentType) { + return AggregationTemporality.CUMULATIVE; + } + }; + } + + static LogRecordExporter logRecordExporter() { + return new LogRecordExporter() { + @Override + public CompletableResultCode export(Collection records) { + records.forEach(record -> LOG.info(json("log", String.valueOf(record.getBodyValue()), + java.util.Map.of( + "severity", String.valueOf(record.getSeverityText()), + "attributes", record.getAttributes().asMap())))); + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.success(); + } + }; + } + + private static String json(String signal, String name, Object data) { + try { + return MAPPER.writeValueAsString(java.util.Map.of("signal", signal, "name", name, "data", data)); + } catch (Exception e) { + return "{\"signal\":\"" + signal + "\",\"error\":\"serialize_failed\"}"; + } + } +} +``` + +`ObservabilitySetup.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.logs.OtlpGrpcLogRecordExporter; +import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor; +import io.opentelemetry.sdk.metrics.PeriodicExportingMetricReader; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Lazy auto-config of all three signals; user-registered providers are never overridden. */ +public final class ObservabilitySetup { + + private static final Logger LOG = LoggerFactory.getLogger(ObservabilitySetup.class); + private static final AtomicBoolean CONFIGURED = new AtomicBoolean(false); + + private ObservabilitySetup() {} + + public static boolean isConfigured() { + return CONFIGURED.get(); + } + + public static void ensureConfigured() { + ensureConfigured(A2ATObservabilityConfig.fromEnv()); + } + + public static void ensureConfigured(A2ATObservabilityConfig config) { + if (CONFIGURED.get() || !ObservabilityRuntime.isEnabled() || !config.enabled()) { + return; + } + if (!sdkAvailable()) { + return; + } + try { + configure(config); + CONFIGURED.set(true); + } catch (IllegalStateException alreadySet) { + LOG.debug("a2at: global OTel already registered; skipping auto-config"); + } catch (RuntimeException e) { + LOG.warn("a2at: otel auto-config failed", e); + } + } + + private static boolean sdkAvailable() { + try { + Class.forName("io.opentelemetry.sdk.OpenTelemetrySdk"); + return true; + } catch (ClassNotFoundException e) { + LOG.warn("a2at: opentelemetry-sdk not on classpath; auto-config skipped"); + return false; + } + } + + private static void configure(A2ATObservabilityConfig config) { + Resource resource = buildResource(config); + Exporters exporters = buildExporters(config); + SdkTracerProvider tracerProvider = null; + SdkMeterProvider meterProvider = null; + SdkLoggerProvider loggerProvider = null; + if (exporters.span != null && config.traceEnabled() && ObservabilityRuntime.isTraceEnabled()) { + tracerProvider = SdkTracerProvider.builder() + .setResource(resource) + .addSpanProcessor(BatchSpanProcessor.builder(exporters.span) + .setExporterTimeout(2, TimeUnit.SECONDS).build()) + .build(); + } + if (exporters.metric != null && config.metricEnabled() && ObservabilityRuntime.isMetricEnabled()) { + meterProvider = SdkMeterProvider.builder() + .setResource(resource) + .registerMetricReader(PeriodicExportingMetricReader.builder(exporters.metric) + .setInterval(30, TimeUnit.SECONDS).build()) + .build(); + } + if (exporters.log != null && config.logEnabled() && ObservabilityRuntime.isLogEnabled()) { + loggerProvider = SdkLoggerProvider.builder() + .setResource(resource) + .addLogRecordProcessor(BatchLogRecordProcessor.builder(exporters.log).build()) + .build(); + } + OpenTelemetrySdk.Builder builder = OpenTelemetrySdk.builder(); + if (tracerProvider != null) { + builder.setTracerProvider(tracerProvider); + } + if (meterProvider != null) { + builder.setMeterProvider(meterProvider); + } + if (loggerProvider != null) { + builder.setLoggerProvider(loggerProvider); + } + GlobalOpenTelemetry.set(builder.build()); + if (exporters.console) { + LOG.warn("a2at: no OTLP endpoint configured; using Console exporters (dev mode)"); + } + } + + private static Resource buildResource(A2ATObservabilityConfig config) { + Attributes.Builder attributes = Attributes.builder(); + attributes.put("service.name", config.serviceName() == null + ? A2ATObservabilityConfig.DEFAULT_SERVICE_NAME : config.serviceName()); + String version = ObservabilitySetup.class.getPackage() == null + ? null : ObservabilitySetup.class.getPackage().getImplementationVersion(); + attributes.put("service.version", version == null ? "dev" : version); + if (config.resolveAgentRole() != null) { + attributes.put("agent.role", config.resolveAgentRole()); + } + return Resource.getDefault().merge(Resource.create(attributes.build())); + } + + private static Exporters buildExporters(A2ATObservabilityConfig config) { + Exporters exporters = new Exporters(); + if (config.endpoint() == null) { + boolean consoleEnabled = switchOn(A2ATObservabilityConfig.ENV_CONSOLE_EXPORTER); + if (!consoleEnabled) { + LOG.info("a2at: console exporter disabled and no OTLP endpoint; signals stay no-op"); + return exporters; + } + exporters.span = ConsoleExporters.spanExporter(); + exporters.metric = ConsoleExporters.metricExporter(); + exporters.log = ConsoleExporters.logRecordExporter(); + exporters.console = true; + return exporters; + } + exporters.console = false; + if ("http/protobuf".equals(config.protocol())) { + String base = config.endpoint().replaceAll("/$", ""); + exporters.span = OtlpHttpSpanExporter.builder() + .setEndpoint(base + "/v1/traces").build(); + exporters.metric = OtlpHttpMetricExporter.builder() + .setEndpoint(base + "/v1/metrics").build(); + exporters.log = OtlpHttpLogRecordExporter.builder() + .setEndpoint(base + "/v1/logs").build(); + } else { + exporters.span = OtlpGrpcSpanExporter.builder() + .setEndpoint(config.endpoint()).build(); + exporters.metric = OtlpGrpcMetricExporter.builder() + .setEndpoint(config.endpoint()).build(); + exporters.log = OtlpGrpcLogRecordExporter.builder() + .setEndpoint(config.endpoint()).build(); + } + return exporters; + } + + private static boolean switchOn(String key) { + String raw = System.getProperty(key, System.getenv(key)); + if (raw == null) { + return true; + } + return switch (raw.trim().toLowerCase(java.util.Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + private static final class Exporters { + @Nullable SpanExporter span; + @Nullable MetricExporter metric; + @Nullable LogRecordExporter log; + boolean console; + } +} +``` + +(`Exporters` 为可变静态内部类——record 不可变与逐字段赋值冲突,故不用 record。) + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=ObservabilityLogsTest,ObservabilitySetupTest"`(若 OTLP exporter builder API 与 1.64 签名不符,按 javap 调整;console 路径不依赖 OTLP 类加载——但 configure 在 endpoint=null 时不触碰 OTLP 类,`Class.forName` 保护仅在 sdkAvailable 一层,OTLP 类加载发生在 buildExporters 的 http/grpc 分支,endpoint=null 不会加载 ✓)。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add structured logs, console exporters and OTel auto-setup"` + +--- + +### Task 10: 门面接线(A2ATClient / A2ATServer)+ env.example + 全量回归 + +**Files:** +- Modify: `a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java` +- Modify: `a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java` +- Modify: `env.example`(追加可观测性小节) +- Test: `a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java`、`a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java` + +**Interfaces:** +- Consumes: Task 2 `TelemetryHook`/`TelemetryOperation`。 +- Produces: + - `A2ATClient(Path envPath, @Nullable TelemetryHook hook)` 新二级构造器;既有 `A2ATClient(Path)` 委托 `NO_OP`。字段 `private final TelemetryHook hook;`。 + - `A2ATServer(Path envPath, @Nullable TelemetryHook hook)` 同构。 + - 包私有 `static @Nullable String terminalOutcome(NegotiationStatus status)`:`AGREED→"accept"`、`REJECTED→"reject"`、其余 null(client/server 各一份 4 行副本,因 observability 模块不依赖 negotiation)。 + - 门面全部公开方法经 `observed(...)` 包 Span:名 `a2at.sdk.client.{method}` / `a2at.sdk.server.{method}`;成功 `markSuccess()`,RuntimeException 路径 `markError` + 原样抛。 + - `continueNegotiation(NegotiationContext, NegotiationStatus, String)` 成功后按 `terminalOutcome(status)` 回调 `hook.reportNegotiationRounds(context.negotiationId(), outcome, context.round())`。 + +**方法清单(必须全部包装,逐个修改,不得遗漏):** +- client(24):`generateTaskPrompt`、`generateTaskPromptFromText`、`generateTaskPromptFromDataWithSchema`、`generateAuthPromptFromText`、`generateAuthPromptFromDataWithSchema`、`generateNotificationPromptFromText`、`generateNotificationPromptFromDataWithSchema`、`startNegotiation`、`receiveNegotiation`、`continueNegotiation`、`generateNegotiationProposePromptFromData`、`generateNegotiationAcceptPromptFromData`、`generateNegotiationRejectPromptFromData`、`generateNegotiationAbortPromptFromData`、`generateNegotiationProposePromptFromText`、`generateNegotiationAcceptPromptFromText`、`generateNegotiationRejectPromptFromText`、`generateNegotiationAbortPromptFromText`、`getPrompts`、`getPrompt`、`validateProposePromptAndDataFilling`、`validateAcceptPromptAndDataFilling`、`validateRejectPromptAndDataFilling`、`validateAbortPromptAndDataFilling` +- server(21):`checkTaskPrompt`、`startNegotiation`、`receiveNegotiation`、`continueNegotiation`、`generateNegotiationProposePromptFromData`、`generateNegotiationAcceptPromptFromData`、`generateNegotiationRejectPromptFromData`、`generateNegotiationAbortPromptFromData`、`generateNegotiationProposePromptFromText`、`generateNegotiationAcceptPromptFromText`、`generateNegotiationRejectPromptFromText`、`generateNegotiationAbortPromptFromText`、`getPrompts`、`getPrompt`、`validateProposePromptAndDataFilling`、`validateAcceptPromptAndDataFilling`、`validateRejectPromptAndDataFilling`、`validateAbortPromptAndDataFilling`、`validateTaskPromptAndDataFilling`、`validateNotificationPromptAndDataFilling`、`validateAuthPromptAndDataFilling` + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.client.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class A2ATClientObservabilityTest { + + private static final String MOCK_PROVIDER = "a2at-observability-mock"; + + static final class NoopLLM implements LLMClient { + @Override + public LLMResponse structured( + List> messages, + java.util.Map schema, Double temperature, Integer maxTokens) { + return new LLMResponse("{}", "mock", java.util.Map.of(), java.util.Map.of()); + } + } + + static final class RecordingHook implements TelemetryHook { + final List started = new ArrayList<>(); + final List succeeded = new ArrayList<>(); + final List rounds = new ArrayList<>(); + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + String name = role + "." + method; + started.add(name); + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + @Override public void markSuccess() { succeeded.add(name); } + @Override public void markError(Throwable error) { started.add("FAILED:" + name); } + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + } + + @TempDir + Path tempDir; + + private A2ATClient newClient(TelemetryHook hook) throws IOException { + if (!LLMClientFactory.availableProviders().contains(MOCK_PROVIDER)) { + LLMClientFactory.register(MOCK_PROVIDER, NoopLLM.class); + } + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """.formatted(MOCK_PROVIDER)); + return new A2ATClient(envFile, hook); + } + + @Test + void llmFreePathIsWrappedAsSdkOperation() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATClient client = newClient(hook); + client.getPrompts(); + assertTrue(hook.started.contains("client.getPrompts")); + assertTrue(hook.succeeded.contains("client.getPrompts")); + } + + @Test + void runtimeFailureMarksErrorAndPropagates() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATClient client = newClient(hook); + assertThrows(NullPointerException.class, () -> client.generateTaskPromptFromText(null, null)); + assertTrue(hook.started.contains("client.generateTaskPromptFromText")); + assertTrue(hook.started.contains("FAILED:client.generateTaskPromptFromText")); + } + + @Test + void legacyConstructorUsesNoOpHook() throws IOException { + A2ATClient client = newClient(TelemetryHook.NO_OP); + assertEquals(client.getPrompts().size(), client.getPrompts().size()); + } + + @Test + void terminalOutcomeMapping() { + assertEquals("accept", A2ATClient.terminalOutcome(NegotiationStatus.AGREED)); + assertEquals("reject", A2ATClient.terminalOutcome(NegotiationStatus.REJECTED)); + assertEquals(null, A2ATClient.terminalOutcome(NegotiationStatus.IN_PROGRESS)); + } +} +``` + +server 测试 `A2ATServerObservabilityTest`:镜像以上结构(fixture env 同形、`new A2ATServer(env, hook)`、断言 `server.getPrompts`/`server.checkTaskPrompt` 起止、`A2ATServer.terminalOutcome` 映射)。server 包下既有测试(`A2ATServerNegotiationApiTest` 等)已有同型 env fixture 可参照。 + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-client test "-Dtest=A2ATClientObservabilityTest"`(编译错误:二级构造器/terminalOutcome 不存在)。 + +- [ ] **Step 3: 实现(A2ATClient)** + +1) 导入 + 字段 + 构造器改造: + +```java +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import org.jspecify.annotations.Nullable; + +public final class A2ATClient { + private final ClientPromptGenerationOrchestrator promptGenerationOrchestrator; + private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; + private final NegotiationContentService negotiationContentService; + private final TemplateQueryService templateQueryService; + private final TelemetryHook hook; + + public A2ATClient(Path envPath) { + this(envPath, null); + } + + public A2ATClient(Path envPath, @Nullable TelemetryHook hook) { + Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); + A2ATConfig config = + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + DefaultA2ATClientBuilder builder = + DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); + this.hook = hook == null ? TelemetryHook.NO_OP : hook; + this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); + this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); + this.negotiationContentService = + new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.templateQueryService = builder.buildTemplateQueryService(); + } + + private T observed(String method, java.util.function.Supplier action) { + try (TelemetryOperation operation = hook.startSdkOperation("client", method)) { + try { + T result = action.get(); + operation.markSuccess(); + return result; + } catch (RuntimeException error) { + operation.markError(error); + throw error; + } + } + } + + static @Nullable String terminalOutcome(NegotiationStatus status) { + if (status == NegotiationStatus.AGREED) { + return "accept"; + } + return status == NegotiationStatus.REJECTED ? "reject" : null; + } + + private void reportNegotiationRounds(NegotiationContext context, NegotiationStatus status) { + try { + String outcome = terminalOutcome(status); + if (outcome != null) { + hook.reportNegotiationRounds(context.negotiationId(), outcome, context.round()); + } + } catch (RuntimeException error) { + LOGGER.warn("a2at: negotiation metric report failed", error); + } + } +} +``` + +(client 现无 LOGGER 则加 `private static final org.slf4j.Logger LOGGER = org.slf4j.LoggerFactory.getLogger(A2ATClient.class);`) + +2) 方法包装(保持既有方法体为 lambda 载荷,逐个改写;三个代表样例): + +```java +public PromptGenerationResult generateTaskPrompt(Object userInput) { + return observed("generateTaskPrompt", () -> promptGenerationOrchestrator.generateTaskPrompt(userInput)); +} + +public Map continueNegotiation( + NegotiationContext context, NegotiationStatus status, String contentText) { + return observed("continueNegotiation", () -> { + Map result = negotiationOrchestrator.continueNegotiation(context, status, contentText); + reportNegotiationRounds(context, status); + return result; + }); +} + +public Optional getPrompt(@NonNull String templateUri) { + return observed("getPrompt", () -> templateQueryService.findTemplate(TemplateUri.of(templateUri))); +} +``` + +其余 21 个方法按同样模式改写(把现有方法体整体放入 `observed("", () -> )`;`getPrompts` 等无参方法同样处理)。**禁止改动任何业务语义**——只做包装。 + +3) A2ATServer 同构实现:role 用 `"server"`,24→21 个方法按清单包装,`continueNegotiation` 同样接 `reportNegotiationRounds`,`terminalOutcome` 副本同名。 + +- [ ] **Step 4: 运行确认通过 + 全量回归** + +``` +mvn -q -pl a2a-t-client test "-Dtest=A2ATClientObservabilityTest" +mvn -q -pl a2a-t-server test "-Dtest=A2ATServerObservabilityTest" +mvn -q test # 全模块回归(corpus/sample 在内) +``` + +期望:全部 PASS,既有测试零回归。 + +- [ ] **Step 5: env.example 追加可观测性小节** + +``` +# --- Observability (a2a-t-observability) --- +OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED=true +A2AT_OBSERVABILITY_ENABLED=true +A2AT_TRACE_ENABLED=true +A2AT_METRIC_ENABLED=true +A2AT_LOG_ENABLED=true +#OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +A2AT_EXPORTER_PROTOCOL=grpc +A2AT_CONSOLE_EXPORTER=true +#OTEL_SERVICE_NAME=a2a-t-agent +#A2AT_AGENT_ROLE= +A2AT_EXTRACT_REQUEST=false +A2AT_EXTRACT_RESPONSE=false +A2AT_LOGS_PAYLOAD_ENABLED=false +A2AT_LOGS_PAYLOAD_MAX_LENGTH=4096 +``` + +- [ ] **Step 6: 提交** — `mvn -q spotless:apply; git add a2a-t-client a2a-t-server env.example; git commit -m "feat(observability): wire TelemetryHook into client and server facades"` + +--- + +## Plan A 自检清单(完成后执行) + +- [ ] spec §3 表 12 行 → 逐行对应类已存在且被测试覆盖(`attributes.py` 拆分说明见 Task 4;`sdk_trace.py` 的 `SdkOperationSpans` OTel 实现移入 Plan B,因其属于可选实现侧) +- [ ] spec §5 命名注册表 → Task 4 常量 + Task 8 指标名 + Task 9 事件名逐字一致 +- [ ] spec §7 配置表 → Task 3 常量 + Task 5 开关 + Task 9 console/env 处理 +- [ ] spec §8 三条铁律 → Task 9 幂等/降级、Task 10 try/catch 包装、A2ATSpan scope close +- [ ] spec §9 测试策略 → 各 Task 测试 + Task 10 全量回归 +- [ ] `mvn -q spotless:check` 全绿;NullAway 零报错 + +## 后续(Plan B,另行编写) + +a2a-java 协议层装饰器(`A2ATClientTransportDecorator`、`A2ATRequestHandlerDecorator`、`A2ATAgentExecutorDecorator`、`A2ATPushSenderDecorator`、`A2ATA2ATClientTransportWrapper`、类型化提取器 `A2AJavaAttributeExtractors`、`SdkOperationSpans` hook 实现)+ sample 可观测场景 + README。待 Plan A 落地后按其真实签名编写独立计划。 + + + + diff --git a/docs/superpowers/specs/2026-10-20-observability-java-port-design.md b/docs/superpowers/specs/2026-10-20-observability-java-port-design.md index d07a604c..45ec14da 100644 --- a/docs/superpowers/specs/2026-10-20-observability-java-port-design.md +++ b/docs/superpowers/specs/2026-10-20-observability-java-port-design.md @@ -59,10 +59,12 @@ a2a-t-observability-a2a 协议层装饰器(可选模块) | 模块 | 改动 | |---|---| | `a2a-t-core` | 新增 `net.openan.a2at.sdk.core.telemetry.TelemetryHook` SPI 接口 + `NO_OP` 常量(见 §4) | -| `a2a-t-client` | `DefaultA2ATClientBuilder` 新增 `telemetryHook(@Nullable TelemetryHook)`;协商终态时回调 `reportNegotiationRounds` | -| `a2a-t-server` | 同上(`DefaultA2ATServerBuilder`) | +| `a2a-t-client` | `A2ATClient` 新增二级构造器 `A2ATClient(Path envPath, @Nullable TelemetryHook hook)`(既有构造器委托 NO_OP);全部公开方法经 hook 包 `a2at.sdk.client.{method}` Span;`continueNegotiation` 终态时回调 `reportNegotiationRounds` | +| `a2a-t-server` | 同上(`A2ATServer(Path, @Nullable TelemetryHook)`,role=server) | | `a2a-t-bom` / 根 pom | 登记两新模块与依赖管理 | +> 注:hook 注入点在**门面二级构造器**而非 builder——builder 只组装 orchestrator(非门面),hook 生命周期属于门面方法包装;与 §4 `trace_facade` 等价机制一致。 + 依赖方向保持单向:`observability → core/llm`,`observability-a2a → observability + a2a-java`。**`ObservabilityLLMClient` 由使用者/sample 组装时显式包装**(builder 不感知 OTel),文档给出示例。 ## 3. 核心模块 `a2a-t-observability` 内部设计 @@ -81,7 +83,7 @@ a2a-t-observability-a2a 协议层装饰器(可选模块) | `payload.py` | `PayloadExtractor` + `PayloadSanitizer` | **提取**:a2a-java spec 对象(Jackson 注解 POJO)经 Jackson 序列化为 JSON(对齐 Python `MessageToDict`);**净化**:先脱敏后截断(安全顺序);默认 4096 + `[truncated]` 后缀;自定义 redactor 函数接口 | | `logs.py` | `ObservabilityLogs` | SLF4J logger `net.openan.a2at.sdk.observability`;**JSON 行**(Jackson 序列化,`ensure_ascii=false` 等价):`{"event": ..., <业务字段>, "trace_id", "span_id", "payload"?}`;事件名沿用 Python;log_enabled 双门控(运行时开关 + config);**OTel 日志信号**:log_enabled 时同步写入 OTel `Logger` API(`SdkLoggerProvider` + OTLP/Console exporter),SLF4J 与 OTel 双通道共享同一 JSON 语义,对齐 Python LoggingHandler 桥接 | | `llm_decorator.py` + `llm_stash.py` | `ObservabilityLLMClient implements LLMClient` + `LlmUsageStash` | `structured()` 返回后:从 `LLMResponse.usage`(键 `prompt_tokens`/`completion_tokens`,与 Python stash 读法一致)+ `model()` 取数;记 `gen_ai.client.token.usage` 直方图(unit `{token}`,按 `gen_ai.token.type`=input/output 各一样本)+ `LlmUsageStash`(ThreadLocal)暂存(input/output tokens、request model)供协议层入口 Span 取用(**取后清空**;trace 关闭时入口侧直接丢弃 stash) | -| `negotiation_metrics.py` | `NegotiationMetrics` | `a2at.negotiation.total_rounds` 直方图(unit `{round}`);属性 `gen_ai.agent.a2at.extension.name="Negotiation-T"`、`negotiation.id`、`outcome`(accept/reject/abort);仅终态触发;**数据路径差异**:Python 从返回的 `MetadataContent.negotiation_context` 取,Java 从门面入参/结果的 `NegotiationContext(type, negotiationId, round, NegotiationStatus)` 取(终态 status 映射 outcome),结果等价 | +| `negotiation_metrics.py` | `NegotiationMetrics` | `a2at.negotiation.total_rounds` 直方图(unit `{round}`);属性 `gen_ai.agent.a2at.extension.name="Negotiation-T"`、`negotiation.id`、`outcome`(accept/reject/abort);仅终态触发;outcome 映射:Java `NegotiationStatus` 仅 `IN_PROGRESS/AGREED/REJECTED`(无 ABORT)→ `AGREED→accept`、`REJECTED→reject`(Python 的 abort 在 Java 无对应,不产生样本);**数据路径差异**:Python 从返回的 `MetadataContent.negotiation_context` 取,Java 从门面入参/结果的 `NegotiationContext(type, negotiationId, round, NegotiationStatus)` 取(终态 status 映射 outcome),结果等价 | | `sdk_trace.py` | `SdkOperationSpans` | `TelemetryHook` 的 OTel 实现:`a2at.sdk.{role}.{method}` INTERNAL Span | 指标 instrument 缓存沿用 Python 的惰性单例模式(`ConcurrentHashMap`)。 From 6552a04a80c36d107c738d0b8f9fc2f4d8e6b266 Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 10:38:00 +0800 Subject: [PATCH 280/295] chore: ignore .superpowers scratch dir --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 982f790b..b9cd7b3f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ __pycache__/ # Accuracy-verification corpus (a2a-t-corpus): transcripts written back into the scenario # directories by each run are regenerated artifacts, never committed. a2a-t-corpus/src/test/java/**/suites/*/resources/*/output_result_*.json + +.superpowers/ From c3fd0b55336695bd017b2b3d39251e9b174d9af2 Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 10:42:33 +0800 Subject: [PATCH 281/295] feat(observability): add observability module skeletons and dependency registration --- a2a-t-bom/pom.xml | 10 ++++ a2a-t-observability-a2a/pom.xml | 45 ++++++++++++++ .../sdk/observability/a2a/package-info.java | 5 ++ a2a-t-observability/pom.xml | 59 +++++++++++++++++++ .../a2at/sdk/observability/package-info.java | 5 ++ pom.xml | 36 +++++++++++ 6 files changed, 160 insertions(+) create mode 100644 a2a-t-observability-a2a/pom.xml create mode 100644 a2a-t-observability-a2a/src/main/java/net/openan/a2at/sdk/observability/a2a/package-info.java create mode 100644 a2a-t-observability/pom.xml create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/package-info.java diff --git a/a2a-t-bom/pom.xml b/a2a-t-bom/pom.xml index f123ee3c..ccd3d11e 100644 --- a/a2a-t-bom/pom.xml +++ b/a2a-t-bom/pom.xml @@ -51,6 +51,16 @@ a2a-t-server ${project.version} + + net.openan.a2a-t.sdk + a2a-t-observability + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-observability-a2a + ${project.version} + diff --git a/a2a-t-observability-a2a/pom.xml b/a2a-t-observability-a2a/pom.xml new file mode 100644 index 00000000..06824053 --- /dev/null +++ b/a2a-t-observability-a2a/pom.xml @@ -0,0 +1,45 @@ + + 4.0.0 + + net.openan.a2a-t.sdk + a2a-t-parent + ${revision} + + a2a-t-observability-a2a + a2a-t Observability A2A + Protocol-level observability decorators for a2a-java (transport, request handler, executor, push sender). Filled by the observability-a2a plan. + + + net.openan.a2a-t.sdk + a2a-t-observability + ${project.version} + + + org.a2aproject.sdk + a2a-java-sdk-client-transport-spi + + + org.a2aproject.sdk + a2a-java-sdk-spec + + + org.a2aproject.sdk + a2a-java-sdk-server-common + + + org.a2aproject.sdk + a2a-java-sdk-jsonrpc-common + + + org.a2aproject.sdk + a2a-java-sdk-transport-rest + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/a2a-t-observability-a2a/src/main/java/net/openan/a2at/sdk/observability/a2a/package-info.java b/a2a-t-observability-a2a/src/main/java/net/openan/a2at/sdk/observability/a2a/package-info.java new file mode 100644 index 00000000..e6004ec6 --- /dev/null +++ b/a2a-t-observability-a2a/src/main/java/net/openan/a2at/sdk/observability/a2a/package-info.java @@ -0,0 +1,5 @@ +/** Protocol-level observability decorators for a2a-java. */ +@NullMarked +package net.openan.a2at.sdk.observability.a2a; + +import org.jspecify.annotations.NullMarked; diff --git a/a2a-t-observability/pom.xml b/a2a-t-observability/pom.xml new file mode 100644 index 00000000..6159650b --- /dev/null +++ b/a2a-t-observability/pom.xml @@ -0,0 +1,59 @@ + + 4.0.0 + + net.openan.a2a-t.sdk + a2a-t-parent + ${revision} + + a2a-t-observability + a2a-t Observability + Observability core for the a2a-t Java SDK: config, setup, manual span API, propagation, logs, metrics, LLM decorator. + + + net.openan.a2a-t.sdk + a2a-t-core + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-llm + ${project.version} + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-sdk + true + + + io.opentelemetry + opentelemetry-exporter-otlp + true + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter + test + + + ch.qos.logback + logback-classic + 1.4.14 + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/package-info.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/package-info.java new file mode 100644 index 00000000..0919f6bb --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/package-info.java @@ -0,0 +1,5 @@ +/** Observability core for the a2a-t Java SDK. */ +@NullMarked +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.NullMarked; diff --git a/pom.xml b/pom.xml index 3ea1dcf8..007e54cc 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,8 @@ a2a-t-negotiation a2a-t-client a2a-t-server + a2a-t-observability + a2a-t-observability-a2a a2a-t-corpus a2a-t-sample @@ -46,6 +48,8 @@ 3.14.0 4.36.0 2.0.17 + 1.64.0 + 1.1.0.Final 2.50.0 0.13.4 -XDaddTypeAnnotationsToSymbol=true @@ -102,6 +106,38 @@ 1.0.1 provided + + io.opentelemetry + opentelemetry-bom + ${opentelemetry.version} + pom + import + + + org.a2aproject.sdk + a2a-java-sdk-client-transport-spi + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-spec + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-server-common + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-jsonrpc-common + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-transport-rest + ${a2a-java.version} + From d0a560e8b499b06e458c84d91282732752ac0725 Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 10:53:16 +0800 Subject: [PATCH 282/295] feat(observability): add TelemetryHook SPI to core --- .../sdk/core/telemetry/TelemetryHook.java | 24 ++++++++++ .../core/telemetry/TelemetryOperation.java | 32 +++++++++++++ .../a2at/sdk/core/telemetry/package-info.java | 5 ++ .../sdk/core/telemetry/TelemetryHookTest.java | 47 +++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryHook.java create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryOperation.java create mode 100644 a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/package-info.java create mode 100644 a2a-t-core/src/test/java/net/openan/a2at/sdk/core/telemetry/TelemetryHookTest.java diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryHook.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryHook.java new file mode 100644 index 00000000..14c37699 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryHook.java @@ -0,0 +1,24 @@ +package net.openan.a2at.sdk.core.telemetry; + +/** + * Telemetry SPI consumed by SDK facades. Implementations live in optional observability modules; the default + * {@link #NO_OP} keeps the core dependency-free. + */ +public interface TelemetryHook { + + /** Starts one facade-operation observation named {@code a2at.sdk..}. */ + TelemetryOperation startSdkOperation(String role, String method); + + /** Reports one terminal negotiation round count (outcome: accept/reject). */ + void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds); + + TelemetryHook NO_OP = new TelemetryHook() { + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + return TelemetryOperation.NO_OP; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) {} + }; +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryOperation.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryOperation.java new file mode 100644 index 00000000..16465de1 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryOperation.java @@ -0,0 +1,32 @@ +package net.openan.a2at.sdk.core.telemetry; + +/** One observed SDK operation; AutoCloseable for try-with-resources. */ +public interface TelemetryOperation extends AutoCloseable { + + /** Attaches one attribute to the underlying observation. No-op values are ignored. */ + void setAttribute(String key, Object value); + + /** Marks the operation as business-successful before close. */ + void markSuccess(); + + /** Marks the operation as failed; the Throwable is recorded verbatim. */ + void markError(Throwable error); + + /** Ends the operation. Idempotent. */ + @Override + void close(); + + TelemetryOperation NO_OP = new TelemetryOperation() { + @Override + public void setAttribute(String key, Object value) {} + + @Override + public void markSuccess() {} + + @Override + public void markError(Throwable error) {} + + @Override + public void close() {} + }; +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/package-info.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/package-info.java new file mode 100644 index 00000000..f4325776 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/package-info.java @@ -0,0 +1,5 @@ +/** Telemetry SPI for the a2a-t Java SDK core. */ +@NullMarked +package net.openan.a2at.sdk.core.telemetry; + +import org.jspecify.annotations.NullMarked; diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/telemetry/TelemetryHookTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/telemetry/TelemetryHookTest.java new file mode 100644 index 00000000..3b24d7c2 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/telemetry/TelemetryHookTest.java @@ -0,0 +1,47 @@ +package net.openan.a2at.sdk.core.telemetry; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class TelemetryHookTest { + + @Test + void noOpHookAbsorbsEverything() { + TelemetryOperation operation = TelemetryHook.NO_OP.startSdkOperation("client", "getPrompts"); + assertSame(TelemetryOperation.NO_OP, operation); + assertDoesNotThrow(() -> { + operation.setAttribute("k", "v"); + operation.markSuccess(); + operation.markError(new RuntimeException("x")); + operation.close(); + TelemetryHook.NO_OP.reportNegotiationRounds("n1", "accept", 3); + }); + } + + @Test + void customHookReceivesCalls() { + List started = new ArrayList<>(); + List rounds = new ArrayList<>(); + TelemetryHook hook = new TelemetryHook() { + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + started.add(role + "." + method); + return TelemetryOperation.NO_OP; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + }; + hook.startSdkOperation("server", "checkTaskPrompt").close(); + hook.reportNegotiationRounds("N1", "reject", 2); + assertTrue(started.contains("server.checkTaskPrompt")); + assertTrue(rounds.contains("N1:reject:2")); + } +} From 63c44ab8c8ac433b63db67c17c70ed5dc651f046 Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 11:04:52 +0800 Subject: [PATCH 283/295] feat(observability): add A2ATObservabilityConfig --- .../A2ATObservabilityConfig.java | 260 ++++++++++++++++++ .../A2ATObservabilityConfigTest.java | 64 +++++ 2 files changed, 324 insertions(+) create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java create mode 100644 a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java new file mode 100644 index 00000000..6c07ccfa --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java @@ -0,0 +1,260 @@ +package net.openan.a2at.sdk.observability; + +import java.util.Locale; +import java.util.Map; +import java.util.function.UnaryOperator; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Observability configuration: explicit builder > .env map > system env > spec defaults. */ +public record A2ATObservabilityConfig( + boolean enabled, + boolean traceEnabled, + boolean metricEnabled, + boolean logEnabled, + @Nullable String endpoint, + String protocol, + @Nullable String serviceName, + @Nullable String agentRole, + @Nullable String taskTypeRegex, + @Nullable String notificationTopicRegex, + @Nullable String authorizationPolicyOperationTypeRegex, + boolean extractRequest, + boolean extractResponse, + boolean payloadLogEnabled, + int payloadLogMaxLength, + @Nullable UnaryOperator payloadRedactor) { + + private static final Logger LOG = LoggerFactory.getLogger(A2ATObservabilityConfig.class); + + public static final String ENV_ENABLED = "A2AT_OBSERVABILITY_ENABLED"; + public static final String ENV_TRACE = "A2AT_TRACE_ENABLED"; + public static final String ENV_METRIC = "A2AT_METRIC_ENABLED"; + public static final String ENV_LOG = "A2AT_LOG_ENABLED"; + public static final String ENV_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"; + public static final String ENV_PROTOCOL = "A2AT_EXPORTER_PROTOCOL"; + public static final String ENV_SERVICE_NAME = "A2AT_SERVICE_NAME"; + public static final String ENV_OTEL_SERVICE_NAME = "OTEL_SERVICE_NAME"; + public static final String ENV_AGENT_ROLE = "A2AT_AGENT_ROLE"; + public static final String ENV_EXTRACT_REQUEST = "A2AT_EXTRACT_REQUEST"; + public static final String ENV_EXTRACT_RESPONSE = "A2AT_EXTRACT_RESPONSE"; + public static final String ENV_PAYLOAD_LOG_ENABLED = "A2AT_LOGS_PAYLOAD_ENABLED"; + public static final String ENV_PAYLOAD_LOG_MAX_LENGTH = "A2AT_LOGS_PAYLOAD_MAX_LENGTH"; + public static final String ENV_CONSOLE_EXPORTER = "A2AT_CONSOLE_EXPORTER"; + + public static final String DEFAULT_PROTOCOL = "grpc"; + public static final int DEFAULT_PAYLOAD_LOG_MAX_LENGTH = 4096; + public static final String DEFAULT_SERVICE_NAME = "a2a-t-agent"; + + public static A2ATObservabilityConfig fromEnv() { + return fromMap(Map.of()); + } + + /** @param values .env file values (take precedence over system env); missing keys fall back to env then defaults */ + public static A2ATObservabilityConfig fromMap(Map values) { + Builder b = new Builder(); + b.enabled = bool(values, ENV_ENABLED, true); + b.traceEnabled = bool(values, ENV_TRACE, true); + b.metricEnabled = bool(values, ENV_METRIC, true); + b.logEnabled = bool(values, ENV_LOG, true); + b.endpoint = str(values, ENV_ENDPOINT); + b.protocol = normalizeProtocol(str(values, ENV_PROTOCOL)); + String fromSdkKey = str(values, ENV_SERVICE_NAME); + b.serviceName = fromSdkKey != null ? fromSdkKey : str(values, ENV_OTEL_SERVICE_NAME); + b.extractRequest = bool(values, ENV_EXTRACT_REQUEST, false); + b.extractResponse = bool(values, ENV_EXTRACT_RESPONSE, false); + b.payloadLogEnabled = bool(values, ENV_PAYLOAD_LOG_ENABLED, false); + b.payloadLogMaxLength = intOr(values, ENV_PAYLOAD_LOG_MAX_LENGTH, DEFAULT_PAYLOAD_LOG_MAX_LENGTH); + return b.build(); + } + + public static Builder builder() { + return new Builder(); + } + + public Builder toBuilder() { + Builder b = new Builder(); + b.enabled = enabled; + b.traceEnabled = traceEnabled; + b.metricEnabled = metricEnabled; + b.logEnabled = logEnabled; + b.endpoint = endpoint; + b.protocol = protocol; + b.serviceName = serviceName; + b.agentRole = resolveAgentRole(); + b.taskTypeRegex = taskTypeRegex; + b.notificationTopicRegex = notificationTopicRegex; + b.authorizationPolicyOperationTypeRegex = authorizationPolicyOperationTypeRegex; + b.extractRequest = extractRequest; + b.extractResponse = extractResponse; + b.payloadLogEnabled = payloadLogEnabled; + b.payloadLogMaxLength = payloadLogMaxLength; + b.payloadRedactor = payloadRedactor; + return b; + } + + /** Agent role: env-only opt-in (no builder default). */ + public @Nullable String resolveAgentRole() { + String role = System.getProperty(ENV_AGENT_ROLE, System.getenv(ENV_AGENT_ROLE)); + return role == null || role.isBlank() ? null : role.trim(); + } + + static String normalizeProtocol(@Nullable String raw) { + String value = (raw == null ? DEFAULT_PROTOCOL : raw).trim().toLowerCase(Locale.ROOT); + if (value.equals("grpc")) { + return "grpc"; + } + if (value.equals("http") || value.equals("http/protobuf")) { + return "http/protobuf"; + } + LOG.warn("a2at: invalid exporter protocol {}; falling back to grpc", raw); + return DEFAULT_PROTOCOL; + } + + private static @Nullable String str(Map values, String key) { + String v = values.get(key); + if (v != null && !v.isBlank()) { + return v.trim(); + } + String env = System.getenv(key); + return env == null || env.isBlank() ? null : env.trim(); + } + + private static boolean bool(Map values, String key, boolean def) { + String v = str(values, key); + if (v == null) { + return def; + } + return switch (v.toLowerCase(Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + private static int intOr(Map values, String key, int def) { + String v = str(values, key); + if (v == null) { + return def; + } + try { + return Integer.parseInt(v); + } catch (NumberFormatException e) { + return def; + } + } + + public static final class Builder { + private boolean enabled = true; + private boolean traceEnabled = true; + private boolean metricEnabled = true; + private boolean logEnabled = true; + private @Nullable String endpoint; + private String protocol = DEFAULT_PROTOCOL; + private @Nullable String serviceName; + private @Nullable String agentRole; + private @Nullable String taskTypeRegex; + private @Nullable String notificationTopicRegex; + private @Nullable String authorizationPolicyOperationTypeRegex; + private boolean extractRequest; + private boolean extractResponse; + private boolean payloadLogEnabled; + private int payloadLogMaxLength = DEFAULT_PAYLOAD_LOG_MAX_LENGTH; + private @Nullable UnaryOperator payloadRedactor; + + public Builder enabled(boolean v) { + this.enabled = v; + return this; + } + + public Builder traceEnabled(boolean v) { + this.traceEnabled = v; + return this; + } + + public Builder metricEnabled(boolean v) { + this.metricEnabled = v; + return this; + } + + public Builder logEnabled(boolean v) { + this.logEnabled = v; + return this; + } + + public Builder endpoint(@Nullable String v) { + this.endpoint = v; + return this; + } + + public Builder protocol(String v) { + this.protocol = normalizeProtocol(v); + return this; + } + + public Builder serviceName(@Nullable String v) { + this.serviceName = v; + return this; + } + + public Builder agentRole(@Nullable String v) { + this.agentRole = v; + return this; + } + + public Builder taskTypeRegex(@Nullable String v) { + this.taskTypeRegex = v; + return this; + } + + public Builder notificationTopicRegex(@Nullable String v) { + this.notificationTopicRegex = v; + return this; + } + + public Builder authorizationPolicyOperationTypeRegex(@Nullable String v) { + this.authorizationPolicyOperationTypeRegex = v; + return this; + } + + public Builder extractRequest(boolean v) { + this.extractRequest = v; + return this; + } + + public Builder extractResponse(boolean v) { + this.extractResponse = v; + return this; + } + + public Builder payloadLogEnabled(boolean v) { + this.payloadLogEnabled = v; + return this; + } + + public Builder payloadLogMaxLength(int v) { + this.payloadLogMaxLength = v; + return this; + } + + public Builder payloadRedactor(@Nullable UnaryOperator v) { + this.payloadRedactor = v; + return this; + } + + public A2ATObservabilityConfig build() { + if (payloadLogMaxLength <= 0) { + throw new IllegalArgumentException("payloadLogMaxLength must be positive"); + } + String name = serviceName != null ? serviceName : System.getenv(ENV_SERVICE_NAME); + if (name == null || name.isBlank()) { + name = System.getenv(ENV_OTEL_SERVICE_NAME); + } + return new A2ATObservabilityConfig( + enabled, traceEnabled, metricEnabled, logEnabled, endpoint, protocol, + name == null || name.isBlank() ? DEFAULT_SERVICE_NAME : name.trim(), + agentRole, taskTypeRegex, notificationTopicRegex, authorizationPolicyOperationTypeRegex, + extractRequest, extractResponse, payloadLogEnabled, payloadLogMaxLength, payloadRedactor); + } + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java new file mode 100644 index 00000000..0ab1e219 --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java @@ -0,0 +1,64 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class A2ATObservabilityConfigTest { + + @Test + void defaultsMatchSpec() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv(); + assertTrue(config.enabled()); + assertTrue(config.traceEnabled()); + assertTrue(config.metricEnabled()); + assertTrue(config.logEnabled()); + assertEquals("grpc", config.protocol()); + assertEquals(A2ATObservabilityConfig.DEFAULT_PAYLOAD_LOG_MAX_LENGTH, config.payloadLogMaxLength()); + assertFalse(config.extractRequest()); + assertFalse(config.extractResponse()); + assertFalse(config.payloadLogEnabled()); + } + + @Test + void mapValuesBeatDefaults() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of( + "A2AT_OBSERVABILITY_ENABLED", "false", + "A2AT_TRACE_ENABLED", "false", + "OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318", + "A2AT_EXPORTER_PROTOCOL", "http", + "A2AT_SERVICE_NAME", "svc", + "A2AT_LOGS_PAYLOAD_ENABLED", "TRUE", + "A2AT_LOGS_PAYLOAD_MAX_LENGTH", "128")); + assertFalse(config.enabled()); + assertFalse(config.traceEnabled()); + assertTrue(config.metricEnabled()); + assertEquals("http/protobuf", config.protocol()); + assertEquals("http://collector:4318", config.endpoint()); + assertEquals("svc", config.serviceName()); + assertTrue(config.payloadLogEnabled()); + assertEquals(128, config.payloadLogMaxLength()); + } + + @Test + void invalidProtocolFallsBackToGrpcAndInvalidIntFallsBackToDefault() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of( + "A2AT_EXPORTER_PROTOCOL", "udp", "A2AT_LOGS_PAYLOAD_MAX_LENGTH", "abc")); + assertEquals("grpc", config.protocol()); + assertEquals(4096, config.payloadLogMaxLength()); + } + + @Test + void builderBeatsMap() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of("A2AT_SERVICE_NAME", "from-map")) + .toBuilder() + .serviceName("explicit") + .taskTypeRegex("") + .build(); + assertEquals("explicit", config.serviceName()); + assertEquals("", config.taskTypeRegex()); + } +} From f35464541dd77f71cf4301af039d861c28c3e6af Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 11:19:59 +0800 Subject: [PATCH 284/295] feat(observability): add attribute constants and metadata extractors --- .../sdk/observability/A2ATAttributes.java | 54 +++++++ .../observability/AttributeExtractors.java | 140 ++++++++++++++++++ .../a2at/sdk/observability/EventInfo.java | 22 +++ .../AttributeExtractorsTest.java | 68 +++++++++ 4 files changed, 284 insertions(+) create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATAttributes.java create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/AttributeExtractors.java create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/EventInfo.java create mode 100644 a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/AttributeExtractorsTest.java diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATAttributes.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATAttributes.java new file mode 100644 index 00000000..cb86b11e --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATAttributes.java @@ -0,0 +1,54 @@ +package net.openan.a2at.sdk.observability; + +import java.util.Set; + +/** Span attribute keys and shared constants (values byte-identical to the Python SDK). */ +public final class A2ATAttributes { + + public static final String ATTR_EXTENSION_NAME = "gen_ai.agent.a2at.extension.name"; + public static final String ATTR_TASK_ID = "gen_ai.agent.a2at.task.id"; + public static final String ATTR_TASK_STATUS = "gen_ai.agent.a2at.task.status"; + public static final String ATTR_TASK_TYPE = "gen_ai.agent.a2at.task.type"; + public static final String ATTR_NEGOTIATION_ID = "gen_ai.agent.a2at.negotiation.id"; + public static final String ATTR_NEGOTIATION_ROUND = "gen_ai.agent.a2at.negotiation.round"; + public static final String ATTR_NEGOTIATION_MAX_ROUNDS = "gen_ai.agent.a2at.negotiation.max_rounds"; + public static final String ATTR_NEGOTIATION_PERFORMATIVE = "gen_ai.agent.a2at.negotiation.performative"; + public static final String ATTR_NEGOTIATION_TOTAL_ROUNDS = "gen_ai.agent.a2at.negotiation.total_rounds"; + public static final String ATTR_NOTIFICATION_TOPIC = "gen_ai.agent.a2at.notification.topic"; + public static final String ATTR_STREAMING_EVENT_KIND = "gen_ai.agent.a2at.streaming.event.kind"; + public static final String ATTR_PUSH_NOTIFICATION_URL = "gen_ai.agent.a2at.push.notification.url"; + public static final String ATTR_SPAN_SIDE = "a2at.span.side"; + public static final String ATTR_AUTHORIZATION_POLICY_OPERATION_TYPE = + "gen_ai.agent.a2at.authorization.policy.operation.type"; + public static final String ATTR_AUTHORIZATION_POLICY_ID = "authorization.policy.id"; + public static final String ATTR_AUTHORIZATION_OPERATION_TYPE = "authorization.operation_type"; + public static final String ATTR_AUTHORIZATION_OPERATION_RISK_LEVEL = "authorization.operation_risk_level"; + public static final String ATTR_A2A_OPERATION_NAME = "gen_ai.agent.a2a.operation.name"; + public static final String ATTR_A2A_CONTEXT_ID = "gen_ai.agent.a2a.context_id"; + public static final String ATTR_A2A_TASK_ID = "gen_ai.agent.a2a.task_id"; + public static final String ATTR_A2A_MESSAGE_ID = "gen_ai.agent.a2a.message_id"; + public static final String ATTR_A2A_ROLE = "gen_ai.agent.a2a.role"; + public static final String ATTR_A2A_EXTENSIONS = "gen_ai.agent.a2a.extensions"; + public static final String ATTR_A2A_PARTS_NUMBER = "gen_ai.agent.a2a.parts.number"; + public static final String ATTR_A2A_PROTOCOL = "gen_ai.agent.a2a.protocol"; + public static final String ATTR_A2A_REQUEST = "gen_ai.agent.a2a.request"; + public static final String ATTR_A2A_RESPONSE = "gen_ai.agent.a2a.response"; + public static final String ATTR_GEN_AI_OPERATION_NAME = "gen_ai.operation.name"; + public static final String ATTR_GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id"; + public static final String ATTR_GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"; + public static final String ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"; + public static final String ATTR_GEN_AI_REQUEST_MODEL = "gen_ai.request.model"; + public static final String ATTR_GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"; + public static final String ATTR_GEN_AI_TOKEN_TYPE = "gen_ai.token.type"; + public static final String ATTR_GEN_AI_PROVIDER_NAME = "gen_ai.provider.name"; + public static final String ATTR_STREAMING = "streaming"; + public static final String ATTR_ERROR_TYPE = "error.type"; + + public static final String NEGOTIATION_EXTENSION = "Negotiation-T"; + public static final String NEGOTIATION_SUFFIX = "-negotiation"; + + public static final Set EVENT_LOG_KINDS = Set.of( + "status", "artifact", "completed", "canceled", "failed", "rejected"); + + private A2ATAttributes() {} +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/AttributeExtractors.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/AttributeExtractors.java new file mode 100644 index 00000000..f503c5e4 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/AttributeExtractors.java @@ -0,0 +1,140 @@ +package net.openan.a2at.sdk.observability; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** Metadata-level attribute extractors (Map in / attributes out); never raises. */ +public final class AttributeExtractors { + + public static final String EXTENSION_BASE = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/"; + public static final String DEFAULT_TASK_TYPE_REGEX = "##\\s*任务类型\\(Task Type\\)\\s*\\n+\\s*(\\S[^\\n]*)"; + public static final String DEFAULT_NOTIFICATION_TOPIC_REGEX = "##\\s*通知主题\\s*\\n+\\s*(\\S[^\\n]*)"; + public static final String DEFAULT_AUTHORIZATION_REGEX = "##\\s*授权策略的操作类型\\s*\\n+\\s*(\\S[^\\n]*)"; + private static final Set TERMINAL_PERFORMATIVES = Set.of("ACCEPT", "REJECT", "ABORT"); + + private AttributeExtractors() {} + + public static @Nullable String extensionNameFromUri(@Nullable String uri) { + if (uri == null || !uri.startsWith(EXTENSION_BASE)) { + return null; + } + String rest = uri.substring(EXTENSION_BASE.length()); + int slash = rest.indexOf('/'); + String name = slash >= 0 ? rest.substring(0, slash) : rest; + return name.isEmpty() ? null : name; + } + + public static @Nullable String extensionNameFromMetadata(Map metadata) { + for (String key : metadata.keySet()) { + String name = extensionNameFromUri(key); + if (name != null) { + return name; + } + } + return null; + } + + public static boolean isNegotiationMessage(Map metadata) { + return A2ATAttributes.NEGOTIATION_EXTENSION.equals(extensionNameFromMetadata(metadata)); + } + + @SuppressWarnings("unchecked") + public static Map negotiationAttributes(Map metadata) { + Map attrs = new LinkedHashMap<>(); + Object contextObj = metadata.get("negotiationContext"); + if (!(contextObj instanceof Map context)) { + return attrs; + } + Map ctx = (Map) context; + Object id = ctx.get("id"); + if (id instanceof String s && !s.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_ID, s); + } + Integer round = wireInt(ctx.get("round")); + if (round != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_ROUND, round); + } + Integer maxRounds = wireInt(ctx.get("maxRounds")); + if (maxRounds != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_MAX_ROUNDS, maxRounds); + } + Object performative = ctx.get("performative"); + if (performative instanceof String p && !p.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_PERFORMATIVE, p); + if (TERMINAL_PERFORMATIVES.contains(p) && round != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS, round); + } + } + return attrs; + } + + private static @Nullable Integer wireInt(@Nullable Object value) { + if (value instanceof Integer i) { + return i; + } + if (value instanceof Number n && n.longValue() == n.doubleValue()) { + return n.intValue(); + } + return null; + } + + public static @Nullable String normalizeTaskStateName(@Nullable String enumName) { + if (enumName == null) { + return null; + } + String prefix = "TASK_STATE_"; + return enumName.startsWith(prefix) ? enumName.substring(prefix.length()).toLowerCase(Locale.ROOT) + : enumName.toLowerCase(Locale.ROOT); + } + + public static Map regexAttributes(Map metadata, A2ATObservabilityConfig config) { + Map attrs = new LinkedHashMap<>(); + putRegex(attrs, A2ATAttributes.ATTR_TASK_TYPE, metadata, + config.taskTypeRegex() != null ? config.taskTypeRegex() : DEFAULT_TASK_TYPE_REGEX); + putRegex(attrs, A2ATAttributes.ATTR_NOTIFICATION_TOPIC, metadata, + config.notificationTopicRegex() != null ? config.notificationTopicRegex() + : DEFAULT_NOTIFICATION_TOPIC_REGEX); + String authorization = findFirst(metadata, config.authorizationPolicyOperationTypeRegex() != null + ? config.authorizationPolicyOperationTypeRegex() : DEFAULT_AUTHORIZATION_REGEX); + if (authorization != null) { + String cleaned = authorization.replaceAll("[((][^))]*[))]\\s*$", "").trim(); + if (!cleaned.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_AUTHORIZATION_POLICY_OPERATION_TYPE, cleaned); + } + } + return attrs; + } + + private static void putRegex(Map attrs, String key, Map metadata, + @Nullable String pattern) { + if (pattern == null || pattern.isEmpty()) { + return; + } + String found = findFirst(metadata, pattern); + if (found != null && !found.isEmpty()) { + attrs.put(key, found); + } + } + + private static @Nullable String findFirst(Map metadata, @Nullable String pattern) { + if (pattern == null || pattern.isEmpty()) { + return null; + } + Pattern compiled = Pattern.compile(pattern); + for (Object value : metadata.values()) { + if (value instanceof String s) { + Matcher matcher = compiled.matcher(s); + if (matcher.find()) { + return matcher.groupCount() > 0 ? matcher.group(1) : matcher.group(); + } + } + } + return null; + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/EventInfo.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/EventInfo.java new file mode 100644 index 00000000..be97be73 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/EventInfo.java @@ -0,0 +1,22 @@ +package net.openan.a2at.sdk.observability; + +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** Classified view of one A2A stream event (protocol-agnostic). */ +public record EventInfo( + String kind, + @Nullable String taskId, + @Nullable String taskStatus, + boolean finalEvent, + boolean terminal, + @Nullable Map messageMetadata, + @Nullable String artifactName) { + + public static final EventInfo UNKNOWN = + new EventInfo("unknown", null, null, false, false, null, null); + + public boolean isNegotiationCandidate() { + return "message".equals(kind); + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/AttributeExtractorsTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/AttributeExtractorsTest.java new file mode 100644 index 00000000..15581d44 --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/AttributeExtractorsTest.java @@ -0,0 +1,68 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AttributeExtractorsTest { + + private static final String NEGOTIATION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T"; + + @Test + void extensionNameFromUriParsesBase() { + assertEquals("Negotiation-T", AttributeExtractors.extensionNameFromUri(NEGOTIATION_URI)); + assertEquals("Task-T", AttributeExtractors.extensionNameFromUri( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/sub")); + assertNull(AttributeExtractors.extensionNameFromUri("https://other.example/ext")); + assertNull(AttributeExtractors.extensionNameFromUri(null)); + } + + @Test + void negotiationAttributesExtractedFromMetadata() { + Map metadata = Map.of(NEGOTIATION_URI, "v1", "negotiationContext", + Map.of("id", "N001", "round", 3, "maxRounds", 5, "performative", "ACCEPT")); + Map attrs = AttributeExtractors.negotiationAttributes(metadata); + assertEquals("N001", attrs.get(A2ATAttributes.ATTR_NEGOTIATION_ID)); + assertEquals(3, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_ROUND)); + assertEquals(5, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_MAX_ROUNDS)); + assertEquals("ACCEPT", attrs.get(A2ATAttributes.ATTR_NEGOTIATION_PERFORMATIVE)); + assertEquals(3, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS)); + } + + @Test + void nonTerminalPerformativeOmitsTotalRounds() { + Map attrs = AttributeExtractors.negotiationAttributes( + Map.of("negotiationContext", Map.of("id", "N1", "round", 1, "performative", "PROPOSE"))); + assertFalse(attrs.containsKey(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS)); + assertTrue(AttributeExtractors.isNegotiationMessage(Map.of(NEGOTIATION_URI, "v1"))); + assertFalse(AttributeExtractors.isNegotiationMessage(Map.of("k", "v"))); + } + + @Test + void regexAttributesFallBackToDefaultsAndHonorOptOut() { + Map metadata = Map.of("text", "## 任务类型(Task Type)\n\n配置下发\n## 通知主题\n\nalarm"); + A2ATObservabilityConfig fallback = A2ATObservabilityConfig.fromEnv(); + Map attrs = AttributeExtractors.regexAttributes(metadata, fallback); + assertEquals("配置下发", attrs.get(A2ATAttributes.ATTR_TASK_TYPE)); + assertEquals("alarm", attrs.get(A2ATAttributes.ATTR_NOTIFICATION_TOPIC)); + + Map optedOut = AttributeExtractors.regexAttributes( + metadata, A2ATObservabilityConfig.fromEnv().toBuilder().taskTypeRegex("").build()); + assertFalse(optedOut.containsKey(A2ATAttributes.ATTR_TASK_TYPE)); + } + + @Test + void terminalStateNamesNormalize() { + assertEquals("completed", AttributeExtractors.normalizeTaskStateName("TASK_STATE_COMPLETED")); + assertEquals("canceled", AttributeExtractors.normalizeTaskStateName("TASK_STATE_CANCELED")); + assertNull(AttributeExtractors.normalizeTaskStateName(null)); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("status")); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("artifact")); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("completed")); + } +} From 1c07c0b8760939e67c80329470b94c43a00f2064 Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 11:32:00 +0800 Subject: [PATCH 285/295] feat(observability): add ObservabilityRuntime switches and orphan context --- .../observability/ObservabilityRuntime.java | 73 +++++++++++++++++++ .../ObservabilityRuntimeTest.java | 67 +++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityRuntime.java create mode 100644 a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityRuntime.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityRuntime.java new file mode 100644 index 00000000..6a9f4766 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityRuntime.java @@ -0,0 +1,73 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import java.util.concurrent.ThreadLocalRandom; +import org.jspecify.annotations.Nullable; + +/** Master/signal switches + OTel accessors. Switches read dynamically (property > env > default). */ +public final class ObservabilityRuntime { + + public static final String ENV_MASTER = "OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"; + public static final String ENV_TRACE = "A2AT_TRACE_ENABLED"; + public static final String ENV_METRIC = "A2AT_METRIC_ENABLED"; + public static final String ENV_LOG = "A2AT_LOG_ENABLED"; + + public static final String SCOPE_NAME = "a2at-observability"; + public static final String SCOPE_VERSION = "1.0.0"; + + private ObservabilityRuntime() {} + + private static boolean switchOn(String key) { + String raw = System.getProperty(key, System.getenv(key)); + if (raw == null) { + return true; + } + return switch (raw.trim().toLowerCase(java.util.Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + public static boolean isEnabled() { + return switchOn(ENV_MASTER); + } + + public static boolean isTraceEnabled() { + return isEnabled() && switchOn(ENV_TRACE); + } + + public static boolean isMetricEnabled() { + return isEnabled() && switchOn(ENV_METRIC); + } + + public static boolean isLogEnabled() { + return isEnabled() && switchOn(ENV_LOG); + } + + public static Tracer tracer() { + return GlobalOpenTelemetry.getTracer(SCOPE_NAME, SCOPE_VERSION); + } + + public static Meter meter() { + return GlobalOpenTelemetry.meterBuilder(SCOPE_NAME) + .setInstrumentationVersion(SCOPE_VERSION) + .build(); + } + + /** Same-trace orphan context: new span context shares trace_id, random span_id, no parent. */ + public static @Nullable Context sameTraceOrphanContext(@Nullable SpanContext parent) { + if (parent == null || !parent.isValid()) { + return null; + } + long random = ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE); + SpanContext orphan = SpanContext.create( + parent.getTraceId(), String.format("%016x", random), + TraceFlags.fromByte(parent.getTraceFlags().asByte()), parent.getTraceState()); + return Context.root().with(io.opentelemetry.api.trace.Span.wrap(orphan)); + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java new file mode 100644 index 00000000..8c75bea3 --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java @@ -0,0 +1,67 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.context.Context; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ObservabilityRuntimeTest { + + @AfterEach + void clearSwitches() { + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + System.clearProperty("A2AT_TRACE_ENABLED"); + } + + @Test + void masterSwitchDisablesAllSignals() { + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + assertFalse(ObservabilityRuntime.isEnabled()); + assertFalse(ObservabilityRuntime.isTraceEnabled()); + assertFalse(ObservabilityRuntime.isMetricEnabled()); + assertFalse(ObservabilityRuntime.isLogEnabled()); + } + + @Test + void traceToggleIsIndependentOfMasterOn() { + System.setProperty("A2AT_TRACE_ENABLED", "false"); + assertTrue(ObservabilityRuntime.isEnabled()); + assertFalse(ObservabilityRuntime.isTraceEnabled()); + assertTrue(ObservabilityRuntime.isMetricEnabled()); + } + + @Test + void sameTraceOrphanContextKeepsTraceIdAndDropsParent() { + SpanContext parent = SpanContext.create( + "0123456789abcdef0123456789abcdef", "0123456789abcdef", + TraceFlags.getSampled(), TraceState.getDefault()); + Context orphan = ObservabilityRuntime.sameTraceOrphanContext(parent); + assertNotNull(orphan); + SpanContext orphanSpan = Span.fromContext(orphan).getSpanContext(); + assertEquals(parent.getTraceId(), orphanSpan.getTraceId()); + assertNotEquals(parent.getSpanId(), orphanSpan.getSpanId()); + assertTrue(orphanSpan.isValid()); + } + + @Test + void invalidParentYieldsNull() { + assertNull(ObservabilityRuntime.sameTraceOrphanContext(SpanContext.getInvalid())); + assertNull(ObservabilityRuntime.sameTraceOrphanContext(null)); + } + + @Test + void tracerAndMeterScopeNames() { + assertNotNull(ObservabilityRuntime.tracer()); + assertNotNull(ObservabilityRuntime.meter()); + } +} From 124111f004b2e2a96958febd7cfb34c5d35fa6c2 Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 14:25:03 +0800 Subject: [PATCH 286/295] feat(observability): add manual span API --- .../sdk/observability/A2ATCurrentSpan.java | 28 ++++ .../a2at/sdk/observability/A2ATSpan.java | 128 ++++++++++++++++++ .../a2at/sdk/observability/CurrentSpans.java | 18 +++ .../a2at/sdk/observability/A2ATSpanTest.java | 105 ++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATCurrentSpan.java create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATSpan.java create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/CurrentSpans.java create mode 100644 a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATSpanTest.java diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATCurrentSpan.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATCurrentSpan.java new file mode 100644 index 00000000..844fbbd6 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATCurrentSpan.java @@ -0,0 +1,28 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; + +/** Read/append view of the active span. */ +public final class A2ATCurrentSpan { + private final Span span; + + A2ATCurrentSpan(Span span) { + this.span = span; + } + + public String traceId() { + return span.getSpanContext().getTraceId(); + } + + public String spanId() { + return span.getSpanContext().getSpanId(); + } + + public boolean isValid() { + return span.getSpanContext().isValid(); + } + + public void setAttribute(String key, Object value) { + span.setAttribute(key, String.valueOf(value)); + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATSpan.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATSpan.java new file mode 100644 index 00000000..71bcb7a8 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATSpan.java @@ -0,0 +1,128 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Scope; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** Manual A2A-T span; try-with-resources + open(). Absorbs everything when trace is disabled. */ +public final class A2ATSpan implements AutoCloseable { + + private final String name; + private final SpanKind kind; + private final Map attributes = new LinkedHashMap<>(); + private final List pendingLinks = new ArrayList<>(); + private @Nullable Span span; + private @Nullable Scope scope; + private boolean opened; + private boolean closed; + private boolean errorRecorded; + + public A2ATSpan(String name) { + this(name, SpanKind.INTERNAL); + } + + public A2ATSpan(String name, SpanKind kind) { + this.name = name; + this.kind = kind; + } + + public A2ATSpan setAttribute(String key, Object value) { + if (span != null) { + applyAttribute(span, key, value); + } else { + attributes.put(key, value); + } + return this; + } + + public A2ATSpan addLink(A2ATSpan other) { + SpanContext context = other == null ? null : other.spanContext(); + if (context == null || !context.isValid()) { + return this; + } + if (span != null) { + span.addLink(context); + } else { + pendingLinks.add(context); + } + return this; + } + + /** Starts the span and makes it current; idempotent-guarded, absorbs when trace is off. */ + public A2ATSpan open() { + if (opened) { + throw new IllegalStateException("A2ATSpan is single-use; create a new instance for each span"); + } + opened = true; + if (!ObservabilityRuntime.isTraceEnabled()) { + return this; + } + SpanBuilder builder = ObservabilityRuntime.tracer().spanBuilder(name).setSpanKind(kind); + for (SpanContext link : pendingLinks) { + builder.addLink(link); + } + span = builder.startSpan(); + attributes.forEach(this::applyOnSpan); + scope = span.makeCurrent(); + return this; + } + + private void applyOnSpan(String key, Object value) { + if (span != null) { + applyAttribute(span, key, value); + } + } + + private static void applyAttribute(Span target, String key, Object value) { + if (value instanceof String s) { + target.setAttribute(key, s); + } else if (value instanceof Integer i) { + target.setAttribute(key, i); + } else if (value instanceof Long l) { + target.setAttribute(key, l); + } else if (value instanceof Boolean b) { + target.setAttribute(key, b); + } else if (value instanceof Double d) { + target.setAttribute(key, d); + } else { + target.setAttribute(key, String.valueOf(value)); + } + } + + public void recordError(Throwable error) { + if (span == null || error == null) { + return; + } + errorRecorded = true; + span.recordException(error); + span.setStatus(StatusCode.ERROR, String.valueOf(error.getMessage())); + } + + public SpanContext spanContext() { + return span != null ? span.getSpanContext() : SpanContext.getInvalid(); + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + try (Scope restore = scope) { + if (span != null && span.isRecording()) { + if (!errorRecorded) { + span.setStatus(StatusCode.OK); + } + span.end(); + } + } + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/CurrentSpans.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/CurrentSpans.java new file mode 100644 index 00000000..934d2b2d --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/CurrentSpans.java @@ -0,0 +1,18 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; +import org.jspecify.annotations.Nullable; + +/** Accessor for the active A2A-T span. */ +public final class CurrentSpans { + + private CurrentSpans() {} + + public static @Nullable A2ATCurrentSpan current() { + Span span = Span.current(); + if (!span.getSpanContext().isValid() || !span.isRecording()) { + return null; + } + return new A2ATCurrentSpan(span); + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATSpanTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATSpanTest.java new file mode 100644 index 00000000..db1fe256 --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATSpanTest.java @@ -0,0 +1,105 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class A2ATSpanTest { + + private InMemorySpanExporter exporter; + private SdkTracerProvider provider; + + @BeforeEach + void setUp() { + exporter = InMemorySpanExporter.create(); + provider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().setTracerProvider(provider).build()); + } + + @AfterEach + void tearDown() { + provider.close(); + GlobalOpenTelemetry.resetForTest(); + } + + @Test + void tryWithResourcesEndsWithOk() { + try (A2ATSpan span = new A2ATSpan("manual.op", SpanKind.INTERNAL).setAttribute("k", "v").open()) { + span.setAttribute("k2", 2); + assertNotNull(CurrentSpans.current()); + assertNotNull(CurrentSpans.current().traceId()); + } + List ended = exporter.getFinishedSpanItems(); + assertEquals(1, ended.size()); + assertEquals("manual.op", ended.get(0).getName()); + assertEquals(io.opentelemetry.api.trace.StatusCode.OK, ended.get(0).getStatus().getStatusCode()); + assertEquals("v", ended.get(0).getAttributes().get( + io.opentelemetry.api.common.AttributeKey.stringKey("k"))); + } + + @Test + void recordErrorEndsWithErrorAndException() { + RuntimeException failure = new IllegalStateException("boom"); + try (A2ATSpan span = new A2ATSpan("manual.fail").open()) { + span.recordError(failure); + } + SpanData data = exporter.getFinishedSpanItems().get(0); + assertEquals(io.opentelemetry.api.trace.StatusCode.ERROR, data.getStatus().getStatusCode()); + assertFalse(data.getEvents().isEmpty()); + assertTrue(data.getStatus().getDescription().contains("boom")); + } + + @Test + void addLinkBeforeOpenCreatesLink() { + try (A2ATSpan entry = new A2ATSpan("entry").open()) { + try (A2ATSpan event = new A2ATSpan("event").addLink(entry).open()) { + // linked at creation + } + } + List ended = exporter.getFinishedSpanItems(); + SpanData event = ended.stream().filter(d -> d.getName().equals("event")).findFirst().orElseThrow(); + assertEquals(1, event.getLinks().size()); + assertEquals( + ended.stream().filter(d -> d.getName().equals("entry")).findFirst().orElseThrow() + .getSpanContext(), + event.getLinks().get(0).getSpanContext()); + } + + @Test + void singleUseAndClosedAbsorb() { + A2ATSpan span = new A2ATSpan("once").open(); + assertThrows(IllegalStateException.class, span::open); + span.close(); + span.close(); + } + + @Test + void currentSpanNullOutsideAndValidInside() { + assertNull(CurrentSpans.current()); + try (A2ATSpan span = new A2ATSpan("s").open()) { + A2ATCurrentSpan current = CurrentSpans.current(); + assertNotNull(current); + assertTrue(current.isValid()); + assertEquals(32, current.traceId().length()); + assertEquals(16, current.spanId().length()); + current.setAttribute("cur", "x"); + } + } +} From b48aabe47ff4bdd224daa5467e54da648f7fd51d Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 14:37:16 +0800 Subject: [PATCH 287/295] feat(observability): add propagation and payload channels --- .../sdk/observability/PayloadExtractor.java | 27 +++++++ .../sdk/observability/PayloadSanitizer.java | 30 +++++++ .../sdk/observability/TracePropagation.java | 62 ++++++++++++++ .../PropagationAndPayloadTest.java | 80 +++++++++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadExtractor.java create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadSanitizer.java create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java create mode 100644 a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/PropagationAndPayloadTest.java diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadExtractor.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadExtractor.java new file mode 100644 index 00000000..0c811a6c --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadExtractor.java @@ -0,0 +1,27 @@ +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Serializes request/event objects to JSON for payload channels. */ +public final class PayloadExtractor { + + private static final Logger LOG = LoggerFactory.getLogger(PayloadExtractor.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private PayloadExtractor() {} + + public static @Nullable String toJson(@Nullable Object value) { + if (value == null) { + return null; + } + try { + return MAPPER.writeValueAsString(value); + } catch (Exception e) { + LOG.debug("a2at: payload extraction failed", e); + return null; + } + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadSanitizer.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadSanitizer.java new file mode 100644 index 00000000..a0db4227 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadSanitizer.java @@ -0,0 +1,30 @@ +package net.openan.a2at.sdk.observability; + +import java.util.function.UnaryOperator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Shared payload preparation: redact first, then truncate (security order). */ +public final class PayloadSanitizer { + + private static final Logger LOG = LoggerFactory.getLogger(PayloadSanitizer.class); + + private PayloadSanitizer() {} + + public static String prepare(String payload, A2ATObservabilityConfig config) { + String value = payload; + UnaryOperator redactor = config.payloadRedactor(); + if (redactor != null) { + try { + value = redactor.apply(value); + } catch (RuntimeException e) { + LOG.warn("a2at: payload redactor failed; using unredacted value", e); + } + } + int max = config.payloadLogMaxLength(); + if (value.length() > max) { + return value.substring(0, max) + "[truncated]"; + } + return value; + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java new file mode 100644 index 00000000..6424e327 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java @@ -0,0 +1,62 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapGetter; +import io.opentelemetry.context.propagation.TextMapSetter; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** W3C TraceContext propagation over plain header maps. */ +public final class TracePropagation { + + public static final String TRACEPARENT_HEADER = "traceparent"; + + private static final TextMapSetter> SETTER = (carrier, key, value) -> { + if (carrier != null) { + carrier.put(key, value); + } + }; + private static final TextMapGetter> GETTER = new TextMapGetter<>() { + @Override + public Iterable keys(Map carrier) { + return carrier.keySet(); + } + + @Override + public @Nullable String get(@Nullable Map carrier, String key) { + return carrier == null ? null : carrier.get(key); + } + }; + + private TracePropagation() {} + + public static void inject(Map headers) { + if (headers == null || !ObservabilityRuntime.isEnabled()) { + return; + } + GlobalOpenTelemetry.getPropagators().getTextMapPropagator() + .inject(Context.current(), headers, SETTER); + } + + public static void inject(Map headers, Context context) { + if (headers == null || context == null || !ObservabilityRuntime.isEnabled()) { + return; + } + GlobalOpenTelemetry.getPropagators().getTextMapPropagator().inject(context, headers, SETTER); + } + + public static @Nullable Context extract(@Nullable Map headers) { + if (headers == null || !ObservabilityRuntime.isEnabled()) { + return null; + } + Map lowered = new HashMap<>(); + headers.forEach((key, value) -> lowered.put(key.toLowerCase(Locale.ROOT), value)); + Context extracted = GlobalOpenTelemetry.getPropagators().getTextMapPropagator() + .extract(Context.root(), lowered, GETTER); + return Span.fromContext(extracted).getSpanContext().isValid() ? extracted : null; + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/PropagationAndPayloadTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/PropagationAndPayloadTest.java new file mode 100644 index 00000000..9b8a6ac2 --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/PropagationAndPayloadTest.java @@ -0,0 +1,80 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class PropagationAndPayloadTest { + + @AfterEach + void reset() { + GlobalOpenTelemetry.resetForTest(); + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + } + + @Test + void injectExtractRoundtripSurvivesLowercasing() { + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder() + .setTracerProvider(SdkTracerProvider.builder().build()) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build()); + Map headers = new HashMap<>(); + String traceId; + try (var span = new A2ATSpan("root", SpanKind.CLIENT).open()) { + traceId = Span.current().getSpanContext().getTraceId(); + TracePropagation.inject(headers); + } + Map mixed = Map.of("TRACEPARENT", headers.get("traceparent")); + Context extracted = TracePropagation.extract(mixed); + assertNotNull(extracted); + assertEquals(traceId, Span.fromContext(extracted).getSpanContext().getTraceId()); + assertTrue(headers.get("traceparent").startsWith("00-" + traceId)); + } + + @Test + void extractReturnsNullWhenInvalidOrDisabled() { + assertNull(TracePropagation.extract(Map.of("traceparent", "00-abc-def-01"))); + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + assertNull(TracePropagation.extract(Map.of("traceparent", "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"))); + } + + @Test + void payloadRedactsBeforeTruncation() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv().toBuilder() + .payloadLogMaxLength(10) + .payloadRedactor(value -> value.replace("secret", "***")) + .build(); + String prepared = PayloadSanitizer.prepare("secret-abcdefghijklmnopqrstuvwxyz", config); + assertFalse(prepared.contains("secret")); + assertTrue(prepared.endsWith("[truncated]")); + assertTrue(prepared.length() <= 10 + "[truncated]".length()); + } + + @Test + void payloadExtractorSerializesRecordsAndNullFails() { + record Point(int x, int y) {} + assertTrue(PayloadExtractor.toJson(new Point(1, 2)).contains("\"x\":1")); + assertNull(PayloadExtractor.toJson(null)); + assertNull(PayloadExtractor.toJson(new Object() { + @Override + public String toString() { + throw new IllegalStateException("unserializable"); + } + })); + } +} From 3c858f4322967806be711f66ab5723c4df4643f0 Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 14:57:56 +0800 Subject: [PATCH 288/295] feat(observability): add LLM usage stash, token metric and negotiation rounds metric --- .../a2at/sdk/observability/LlmUsageStash.java | 7 ++ .../observability/LlmUsageStashHolder.java | 25 ++++++ .../sdk/observability/MetricHistograms.java | 22 +++++ .../sdk/observability/NegotiationMetrics.java | 38 ++++++++ .../observability/ObservabilityLLMClient.java | 65 ++++++++++++++ .../observability/LlmObservabilityTest.java | 86 +++++++++++++++++++ .../observability/NegotiationMetricsTest.java | 49 +++++++++++ 7 files changed, 292 insertions(+) create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStash.java create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStashHolder.java create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/MetricHistograms.java create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/NegotiationMetrics.java create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLLMClient.java create mode 100644 a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/LlmObservabilityTest.java create mode 100644 a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/NegotiationMetricsTest.java diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStash.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStash.java new file mode 100644 index 00000000..d60f2732 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStash.java @@ -0,0 +1,7 @@ +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.Nullable; + +/** Token usage captured from one LLM call; drained onto the next entry span. */ +public record LlmUsageStash( + @Nullable Integer inputTokens, @Nullable Integer outputTokens, @Nullable String requestModel) {} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStashHolder.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStashHolder.java new file mode 100644 index 00000000..0cfefce7 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStashHolder.java @@ -0,0 +1,25 @@ +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.Nullable; + +/** ThreadLocal bridge: LLM layer -> entry span (SDK is fully synchronous). */ +public final class LlmUsageStashHolder { + + private static final ThreadLocal STASH = new ThreadLocal<>(); + + private LlmUsageStashHolder() {} + + public static void set(LlmUsageStash usage) { + STASH.set(usage); + } + + public static @Nullable LlmUsageStash getAndClear() { + LlmUsageStash stash = STASH.get(); + STASH.remove(); + return stash; + } + + public static void clear() { + STASH.remove(); + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/MetricHistograms.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/MetricHistograms.java new file mode 100644 index 00000000..0a7f6834 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/MetricHistograms.java @@ -0,0 +1,22 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.metrics.LongHistogram; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** Lazy histogram cache keyed by metric name (shared meter scope a2at-observability/1.0.0). */ +final class MetricHistograms { + + private static final ConcurrentMap INSTRUMENTS = new ConcurrentHashMap<>(); + + private MetricHistograms() {} + + static LongHistogram histogram(String name, String unit, String description) { + return INSTRUMENTS.computeIfAbsent(name, metricName -> ObservabilityRuntime.meter() + .histogramBuilder(metricName) + .setUnit(unit) + .setDescription(description) + .ofLongs() + .build()); + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/NegotiationMetrics.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/NegotiationMetrics.java new file mode 100644 index 00000000..83589d22 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/NegotiationMetrics.java @@ -0,0 +1,38 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.common.Attributes; +import java.util.Set; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** a2at.negotiation.total_rounds histogram; terminal outcomes only; never raises. */ +public final class NegotiationMetrics { + + public static final String METRIC_NAME = "a2at.negotiation.total_rounds"; + private static final Logger LOG = LoggerFactory.getLogger(NegotiationMetrics.class); + private static final Set OUTCOMES = Set.of("accept", "reject", "abort"); + + private NegotiationMetrics() {} + + public static void reportRounds(@Nullable String negotiationId, @Nullable String outcome, int rounds) { + try { + if (!ObservabilityRuntime.isMetricEnabled() || rounds < 0) { + return; + } + if (outcome == null || !OUTCOMES.contains(outcome.toLowerCase(java.util.Locale.ROOT))) { + return; + } + var builder = Attributes.builder(); + builder.put(A2ATAttributes.ATTR_EXTENSION_NAME, A2ATAttributes.NEGOTIATION_EXTENSION); + if (negotiationId != null && !negotiationId.isEmpty()) { + builder.put(A2ATAttributes.ATTR_NEGOTIATION_ID, negotiationId); + } + builder.put("outcome", outcome.toLowerCase(java.util.Locale.ROOT)); + MetricHistograms.histogram(METRIC_NAME, "{round}", "Negotiation terminal message round count") + .record(rounds, builder.build()); + } catch (RuntimeException e) { + LOG.warn("a2at: failed to report negotiation rounds", e); + } + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLLMClient.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLLMClient.java new file mode 100644 index 00000000..b40902a1 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLLMClient.java @@ -0,0 +1,65 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** LLMClient decorator: usage stash + gen_ai.client.token.usage histogram; never breaks the call. */ +public final class ObservabilityLLMClient implements LLMClient { + + public static final String METRIC_TOKEN_USAGE = "gen_ai.client.token.usage"; + private static final Logger LOG = LoggerFactory.getLogger(ObservabilityLLMClient.class); + private final LLMClient delegate; + + public ObservabilityLLMClient(LLMClient delegate) { + this.delegate = java.util.Objects.requireNonNull(delegate); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + @Nullable Double temperature, + @Nullable Integer maxTokens) { + LLMResponse response = delegate.structured(messages, jsonSchema, temperature, maxTokens); + report(response); + return response; + } + + private void report(LLMResponse response) { + try { + Map usage = response.usage() == null ? Map.of() : response.usage(); + Integer input = usage.get("prompt_tokens"); + Integer output = usage.get("completion_tokens"); + LlmUsageStashHolder.set(new LlmUsageStash(input, output, response.model())); + if (ObservabilityRuntime.isMetricEnabled()) { + recordTokenUsage(input, output); + } + } catch (RuntimeException e) { + LOG.warn("a2at: failed to report llm token usage", e); + } + } + + private void recordTokenUsage(@Nullable Integer input, @Nullable Integer output) { + if (input != null) { + MetricHistograms.histogram(METRIC_TOKEN_USAGE, "{token}", "GenAI client token usage") + .record( + input, + Attributes.of( + AttributeKey.stringKey(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE), "input")); + } + if (output != null) { + MetricHistograms.histogram(METRIC_TOKEN_USAGE, "{token}", "GenAI client token usage") + .record( + output, + Attributes.of( + AttributeKey.stringKey(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE), "output")); + } + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/LlmObservabilityTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/LlmObservabilityTest.java new file mode 100644 index 00000000..729f1747 --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/LlmObservabilityTest.java @@ -0,0 +1,86 @@ +package net.openan.a2at.sdk.observability; + +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 io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LlmObservabilityTest { + + private InMemoryMetricReader reader; + + @BeforeEach + void setUp() { + reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder() + .registerMetricReader(reader) + .build(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().setMeterProvider(provider).build()); + } + + @AfterEach + void tearDown() { + GlobalOpenTelemetry.resetForTest(); + } + + @Test + void structuredStashesUsageAndRecordsTokenHistogram() { + LLMClient scripted = (messages, schema, temperature, maxTokens) -> new LLMResponse( + "ok", "gpt-x", Map.of("prompt_tokens", 10, "completion_tokens", 5), Map.of()); + ObservabilityLLMClient client = new ObservabilityLLMClient(scripted); + client.structured(List.of(Map.of("role", "user", "content", "hi")), Map.of(), null, null); + + LlmUsageStash stash = LlmUsageStashHolder.getAndClear(); + assertNotNull(stash); + assertEquals(10, stash.inputTokens()); + assertEquals(5, stash.outputTokens()); + assertEquals("gpt-x", stash.requestModel()); + assertNull(LlmUsageStashHolder.getAndClear()); + + var metrics = reader.collectAllMetrics(); + assertTrue( + metrics.stream() + .anyMatch(m -> "gen_ai.client.token.usage".equals(m.getName()) + && !m.getHistogramData().getPoints().isEmpty()), + "token usage histogram must be recorded"); + } + + @Test + void metricDisabledStillStashes() { + System.setProperty("A2AT_METRIC_ENABLED", "false"); + try { + LLMClient scripted = (messages, schema, temperature, maxTokens) -> + new LLMResponse("ok", "m", Map.of("prompt_tokens", 1, "completion_tokens", 2), Map.of()); + new ObservabilityLLMClient(scripted).structured(List.of(), Map.of(), null, null); + assertNotNull(LlmUsageStashHolder.getAndClear()); + assertEquals(0, reader.collectAllMetrics().size()); + } finally { + System.clearProperty("A2AT_METRIC_ENABLED"); + } + } + + @Test + void stashFailurePathKeepsSemantics() { + // delegate exceptions propagate unchanged; nothing stashed (observability never breaks business) + LLMClient throwing = (messages, schema, temperature, maxTokens) -> { + throw new IllegalStateException("llm down"); + }; + assertThrows( + IllegalStateException.class, + () -> new ObservabilityLLMClient(throwing).structured(List.of(), Map.of(), null, null)); + assertNull(LlmUsageStashHolder.getAndClear()); + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/NegotiationMetricsTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/NegotiationMetricsTest.java new file mode 100644 index 00000000..5c12ca6c --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/NegotiationMetricsTest.java @@ -0,0 +1,49 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class NegotiationMetricsTest { + + private InMemoryMetricReader reader; + + @BeforeEach + void setUp() { + reader = InMemoryMetricReader.create(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder() + .setMeterProvider(SdkMeterProvider.builder().registerMetricReader(reader).build()) + .build()); + } + + @AfterEach + void tearDown() { + GlobalOpenTelemetry.resetForTest(); + } + + @Test + void terminalOutcomeRecordsRoundsWithAttributes() { + NegotiationMetrics.reportRounds("N001", "accept", 3); + var metrics = reader.collectAllMetrics(); + var histogram = metrics.stream() + .filter(m -> "a2at.negotiation.total_rounds".equals(m.getName())) + .findFirst() + .orElseThrow(); + assertEquals("a2at.negotiation.total_rounds", histogram.getName()); + assertTrue(!histogram.getHistogramData().getPoints().isEmpty()); + } + + @Test + void nonTerminalOutcomeAndNegativeRoundsAreSkipped() { + NegotiationMetrics.reportRounds("N1", "propose", 2); + NegotiationMetrics.reportRounds("N1", "accept", -1); + assertEquals(0, reader.collectAllMetrics().size()); + } +} From dbdc69d95a020835b9382451e7b814e02788ac58 Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 15:43:46 +0800 Subject: [PATCH 289/295] feat(observability): add structured logs, console exporters and OTel auto-setup --- .../sdk/observability/ConsoleExporters.java | 108 ++++++++++ .../sdk/observability/ObservabilityLogs.java | 76 ++++++++ .../sdk/observability/ObservabilitySetup.java | 184 ++++++++++++++++++ .../observability/ObservabilityLogsTest.java | 87 +++++++++ .../observability/ObservabilitySetupTest.java | 54 +++++ 5 files changed, 509 insertions(+) create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ConsoleExporters.java create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLogs.java create mode 100644 a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilitySetup.java create mode 100644 a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityLogsTest.java create mode 100644 a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilitySetupTest.java diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ConsoleExporters.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ConsoleExporters.java new file mode 100644 index 00000000..7896a251 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ConsoleExporters.java @@ -0,0 +1,108 @@ +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.logs.export.LogRecordExporter; +import io.opentelemetry.sdk.metrics.InstrumentType; +import io.opentelemetry.sdk.metrics.data.AggregationTemporality; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Collection; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** In-house dev-mode exporters (JSON lines on SLF4J); replaces opentelemetry-exporter-logging. */ +final class ConsoleExporters { + + private static final Logger LOG = LoggerFactory.getLogger("net.openan.a2at.sdk.observability.console"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ConsoleExporters() {} + + static SpanExporter spanExporter() { + return new SpanExporter() { + @Override + public CompletableResultCode export(Collection spans) { + spans.forEach(span -> LOG.info(json("span", span.getName(), + Map.of( + "traceId", span.getTraceId(), "spanId", span.getSpanId(), + "kind", span.getKind().name(), + "status", span.getStatus().getStatusCode().name(), + "attributes", span.getAttributes().asMap())))); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + }; + } + + static MetricExporter metricExporter() { + return new MetricExporter() { + @Override + public CompletableResultCode export(Collection metrics) { + metrics.forEach(metric -> LOG.info(json("metric", metric.getName(), + Map.of("unit", String.valueOf(metric.getUnit()), + "description", String.valueOf(metric.getDescription()))))); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public AggregationTemporality getAggregationTemporality(InstrumentType instrumentType) { + return AggregationTemporality.CUMULATIVE; + } + }; + } + + static LogRecordExporter logRecordExporter() { + return new LogRecordExporter() { + @Override + public CompletableResultCode export(Collection records) { + records.forEach(record -> LOG.info(json("log", String.valueOf(record.getBodyValue()), + Map.of( + "severity", String.valueOf(record.getSeverityText()), + "attributes", record.getAttributes().asMap())))); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + }; + } + + private static String json(String signal, String name, Object data) { + try { + return MAPPER.writeValueAsString(Map.of("signal", signal, "name", name, "data", data)); + } catch (Exception e) { + return "{\"signal\":\"" + signal + "\",\"error\":\"serialize_failed\"}"; + } + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLogs.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLogs.java new file mode 100644 index 00000000..69993dc3 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLogs.java @@ -0,0 +1,76 @@ +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.logs.Severity; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.slf4j.event.Level; + +/** Structured JSON events: SLF4J channel + OTel logs channel sharing identical semantics. */ +public final class ObservabilityLogs { + + public static final String LOGGER_NAME = "net.openan.a2at.sdk.observability"; + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(LOGGER_NAME); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ObservabilityLogs() {} + + public static void logEvent(String event, Level level, Map fields, + @Nullable String payload, A2ATObservabilityConfig config) { + if (!ObservabilityRuntime.isLogEnabled() || !config.logEnabled()) { + return; + } + try { + Map data = new LinkedHashMap<>(); + data.put("event", event); + if (fields != null) { + data.putAll(fields); + } + var spanContext = io.opentelemetry.api.trace.Span.current().getSpanContext(); + if (spanContext.isValid()) { + data.put("trace_id", spanContext.getTraceId()); + data.put("span_id", spanContext.getSpanId()); + } + if (payload != null && config.payloadLogEnabled()) { + data.put("payload", PayloadSanitizer.prepare(payload, config)); + } + String json = MAPPER.writeValueAsString(data); + LOG.atLevel(level).log(json); + emitOtel(level, data, json); + } catch (Exception e) { + LOG.debug("a2at: log_event failed", e); + } + } + + private static void emitOtel(Level level, Map data, String json) { + try { + Logger logger = GlobalOpenTelemetry.getOrNoop().getLogsBridge() + .get(ObservabilityRuntime.SCOPE_NAME); + var builder = logger.logRecordBuilder() + .setSeverity(severity(level)) + .setSeverityText(level.name()) + .setBody(json); + data.forEach((key, value) -> { + if (!"event".equals(key) && !"trace_id".equals(key) && !"span_id".equals(key)) { + builder.setAttribute(key, String.valueOf(value)); + } + }); + builder.emit(); + } catch (RuntimeException e) { + LOG.debug("a2at: otel log emit failed", e); + } + } + + private static Severity severity(Level level) { + return switch (level) { + case DEBUG -> Severity.DEBUG; + case INFO -> Severity.INFO; + case WARN -> Severity.WARN; + case ERROR -> Severity.ERROR; + default -> Severity.INFO; + }; + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilitySetup.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilitySetup.java new file mode 100644 index 00000000..8a4b701c --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilitySetup.java @@ -0,0 +1,184 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.logs.OtlpGrpcLogRecordExporter; +import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.OpenTelemetrySdkBuilder; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor; +import io.opentelemetry.sdk.logs.export.LogRecordExporter; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Lazy auto-config of all three signals; user-registered providers are never overridden. */ +public final class ObservabilitySetup { + + private static final Logger LOG = LoggerFactory.getLogger(ObservabilitySetup.class); + private static final AtomicBoolean CONFIGURED = new AtomicBoolean(false); + + private ObservabilitySetup() {} + + public static boolean isConfigured() { + return CONFIGURED.get(); + } + + /** Test-only: clears the configured flag (mirrors GlobalOpenTelemetry.resetForTest). */ + static void resetForTest() { + CONFIGURED.set(false); + } + + public static void ensureConfigured() { + ensureConfigured(A2ATObservabilityConfig.fromEnv()); + } + + public static void ensureConfigured(A2ATObservabilityConfig config) { + if (CONFIGURED.get() || !ObservabilityRuntime.isEnabled() || !config.enabled()) { + return; + } + if (!sdkAvailable()) { + return; + } + try { + configure(config); + CONFIGURED.set(true); + } catch (IllegalStateException alreadySet) { + LOG.debug("a2at: global OTel already registered; skipping auto-config"); + } catch (RuntimeException e) { + LOG.warn("a2at: otel auto-config failed", e); + } + } + + private static boolean sdkAvailable() { + try { + Class.forName("io.opentelemetry.sdk.OpenTelemetrySdk"); + return true; + } catch (ClassNotFoundException e) { + LOG.warn("a2at: opentelemetry-sdk not on classpath; auto-config skipped"); + return false; + } + } + + private static void configure(A2ATObservabilityConfig config) { + Resource resource = buildResource(config); + Exporters exporters = buildExporters(config); + SdkTracerProvider tracerProvider = null; + SdkMeterProvider meterProvider = null; + SdkLoggerProvider loggerProvider = null; + if (exporters.span != null && config.traceEnabled() && ObservabilityRuntime.isTraceEnabled()) { + tracerProvider = SdkTracerProvider.builder() + .setResource(resource) + .addSpanProcessor(BatchSpanProcessor.builder(exporters.span) + .setExporterTimeout(2, TimeUnit.SECONDS).build()) + .build(); + } + if (exporters.metric != null && config.metricEnabled() && ObservabilityRuntime.isMetricEnabled()) { + meterProvider = SdkMeterProvider.builder() + .setResource(resource) + .registerMetricReader(PeriodicMetricReader.builder(exporters.metric) + .setInterval(30, TimeUnit.SECONDS).build()) + .build(); + } + if (exporters.log != null && config.logEnabled() && ObservabilityRuntime.isLogEnabled()) { + loggerProvider = SdkLoggerProvider.builder() + .setResource(resource) + .addLogRecordProcessor(BatchLogRecordProcessor.builder(exporters.log).build()) + .build(); + } + OpenTelemetrySdkBuilder builder = OpenTelemetrySdk.builder(); + if (tracerProvider != null) { + builder.setTracerProvider(tracerProvider); + } + if (meterProvider != null) { + builder.setMeterProvider(meterProvider); + } + if (loggerProvider != null) { + builder.setLoggerProvider(loggerProvider); + } + GlobalOpenTelemetry.set(builder.build()); + if (exporters.console) { + LOG.warn("a2at: no OTLP endpoint configured; using Console exporters (dev mode)"); + } + } + + private static Resource buildResource(A2ATObservabilityConfig config) { + AttributesBuilder attributes = Attributes.builder(); + attributes.put("service.name", config.serviceName() == null + ? A2ATObservabilityConfig.DEFAULT_SERVICE_NAME : config.serviceName()); + String version = ObservabilitySetup.class.getPackage() == null + ? null : ObservabilitySetup.class.getPackage().getImplementationVersion(); + attributes.put("service.version", version == null ? "dev" : version); + if (config.resolveAgentRole() != null) { + attributes.put("agent.role", config.resolveAgentRole()); + } + return Resource.getDefault().merge(Resource.create(attributes.build())); + } + + private static Exporters buildExporters(A2ATObservabilityConfig config) { + Exporters exporters = new Exporters(); + if (config.endpoint() == null) { + boolean consoleEnabled = switchOn(A2ATObservabilityConfig.ENV_CONSOLE_EXPORTER); + if (!consoleEnabled) { + LOG.info("a2at: console exporter disabled and no OTLP endpoint; signals stay no-op"); + return exporters; + } + exporters.span = ConsoleExporters.spanExporter(); + exporters.metric = ConsoleExporters.metricExporter(); + exporters.log = ConsoleExporters.logRecordExporter(); + exporters.console = true; + return exporters; + } + exporters.console = false; + if ("http/protobuf".equals(config.protocol())) { + String base = config.endpoint().replaceAll("/$", ""); + exporters.span = OtlpHttpSpanExporter.builder() + .setEndpoint(base + "/v1/traces").build(); + exporters.metric = OtlpHttpMetricExporter.builder() + .setEndpoint(base + "/v1/metrics").build(); + exporters.log = OtlpHttpLogRecordExporter.builder() + .setEndpoint(base + "/v1/logs").build(); + } else { + exporters.span = OtlpGrpcSpanExporter.builder() + .setEndpoint(config.endpoint()).build(); + exporters.metric = OtlpGrpcMetricExporter.builder() + .setEndpoint(config.endpoint()).build(); + exporters.log = OtlpGrpcLogRecordExporter.builder() + .setEndpoint(config.endpoint()).build(); + } + return exporters; + } + + private static boolean switchOn(String key) { + String raw = System.getProperty(key, System.getenv(key)); + if (raw == null) { + return true; + } + return switch (raw.trim().toLowerCase(java.util.Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + private static final class Exporters { + @Nullable SpanExporter span; + @Nullable MetricExporter metric; + @Nullable LogRecordExporter log; + boolean console; + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityLogsTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityLogsTest.java new file mode 100644 index 00000000..c1d7c585 --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityLogsTest.java @@ -0,0 +1,87 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +class ObservabilityLogsTest { + + private ListAppender appender; + private Logger logger; + private SdkTracerProvider tracerProvider; + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void attach() { + logger = (Logger) LoggerFactory.getLogger("net.openan.a2at.sdk.observability"); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(ch.qos.logback.classic.Level.DEBUG); + GlobalOpenTelemetry.resetForTest(); + tracerProvider = SdkTracerProvider.builder().build(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build()); + } + + @AfterEach + void detach() { + logger.detachAppender(appender); + if (tracerProvider != null) { + tracerProvider.close(); + } + GlobalOpenTelemetry.resetForTest(); + System.clearProperty("A2AT_LOG_ENABLED"); + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + } + + @Test + void jsonRecordCarriesEventFieldsAndTraceIds() throws Exception { + try (A2ATSpan span = new A2ATSpan("entry").open()) { + ObservabilityLogs.logEvent("task.status_changed", Level.INFO, + Map.of("task.id", "t1", "task.status", "working"), null, A2ATObservabilityConfig.fromEnv()); + } + ILoggingEvent event = appender.list.stream() + .filter(e -> e.getLevel() == ch.qos.logback.classic.Level.INFO).findFirst().orElseThrow(); + Map json = mapper.readValue(event.getFormattedMessage(), Map.class); + assertEquals("task.status_changed", json.get("event")); + assertEquals("t1", json.get("task.id")); + assertEquals("working", json.get("task.status")); + assertNotNull(json.get("trace_id")); + assertNotNull(json.get("span_id")); + } + + @Test + void payloadChannelHonorsConfigAndRedaction() throws Exception { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv().toBuilder() + .payloadLogEnabled(true) + .payloadRedactor(v -> v.replace("key", "***")) + .build(); + ObservabilityLogs.logEvent("task.request", Level.DEBUG, Map.of(), "payload: key=1", config); + Map json = mapper.readValue(appender.list.get(0).getFormattedMessage(), Map.class); + assertTrue(String.valueOf(json.get("payload")).contains("***")); + assertFalse(String.valueOf(json.get("payload")).contains("key=1")); + } + + @Test + void logSwitchOffEmitsNothing() { + System.setProperty("A2AT_LOG_ENABLED", "false"); + ObservabilityLogs.logEvent("task.request", Level.DEBUG, Map.of(), null, A2ATObservabilityConfig.fromEnv()); + System.clearProperty("A2AT_LOG_ENABLED"); + assertEquals(0, appender.list.size()); + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilitySetupTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilitySetupTest.java new file mode 100644 index 00000000..cc62de78 --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilitySetupTest.java @@ -0,0 +1,54 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ObservabilitySetupTest { + + @BeforeEach + void resetState() { + GlobalOpenTelemetry.resetForTest(); + ObservabilitySetup.resetForTest(); + } + + @AfterEach + void reset() { + GlobalOpenTelemetry.resetForTest(); + ObservabilitySetup.resetForTest(); + System.clearProperty("A2AT_OBSERVABILITY_ENABLED"); + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + } + + @Test + void consoleFallbackConfiguresAllThreeSignals() { + GlobalOpenTelemetry.resetForTest(); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertTrue(ObservabilitySetup.isConfigured()); + assertTrue(GlobalOpenTelemetry.isSet()); + Span span = GlobalOpenTelemetry.getTracer("t").spanBuilder("s").startSpan(); + assertTrue(span.isRecording()); + span.end(); + } + + @Test + void existingGlobalProviderIsRespected() { + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().build()); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertFalse(ObservabilitySetup.isConfigured()); + } + + @Test + void disabledSkipsEverything() { + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertFalse(ObservabilitySetup.isConfigured()); + assertFalse(ObservabilityRuntime.isEnabled()); + } +} From 6c2ef4ab9df968e09d4c8849d1dbc82ce62ff76b Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 15:49:29 +0800 Subject: [PATCH 290/295] fix(observability): pin a2a-java versions in a2a module to unbreak sample transitive resolution --- a2a-t-observability-a2a/pom.xml | 5 +++++ pom.xml | 25 ------------------------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/a2a-t-observability-a2a/pom.xml b/a2a-t-observability-a2a/pom.xml index 06824053..c64c25fd 100644 --- a/a2a-t-observability-a2a/pom.xml +++ b/a2a-t-observability-a2a/pom.xml @@ -19,22 +19,27 @@ org.a2aproject.sdk a2a-java-sdk-client-transport-spi + ${a2a-java.version} org.a2aproject.sdk a2a-java-sdk-spec + ${a2a-java.version} org.a2aproject.sdk a2a-java-sdk-server-common + ${a2a-java.version} org.a2aproject.sdk a2a-java-sdk-jsonrpc-common + ${a2a-java.version} org.a2aproject.sdk a2a-java-sdk-transport-rest + ${a2a-java.version} org.junit.jupiter diff --git a/pom.xml b/pom.xml index 007e54cc..2659005b 100644 --- a/pom.xml +++ b/pom.xml @@ -113,31 +113,6 @@ pom import - - org.a2aproject.sdk - a2a-java-sdk-client-transport-spi - ${a2a-java.version} - - - org.a2aproject.sdk - a2a-java-sdk-spec - ${a2a-java.version} - - - org.a2aproject.sdk - a2a-java-sdk-server-common - ${a2a-java.version} - - - org.a2aproject.sdk - a2a-java-sdk-jsonrpc-common - ${a2a-java.version} - - - org.a2aproject.sdk - a2a-java-sdk-transport-rest - ${a2a-java.version} - From 5843b80a2c2332f0a177b08392345f38bba3359b Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 16:25:26 +0800 Subject: [PATCH 291/295] feat(observability): wire TelemetryHook into client and server facades --- .../openan/a2at/sdk/client/A2ATClient.java | 207 +++++++++++++----- .../api/A2ATClientObservabilityTest.java | 134 ++++++++++++ .../a2at/sdk/client/api/A2ATClientTest.java | 8 +- .../openan/a2at/sdk/server/A2ATServer.java | 139 +++++++++--- .../api/A2ATServerObservabilityTest.java | 124 +++++++++++ env.example | 16 ++ 6 files changed, 545 insertions(+), 83 deletions(-) create mode 100644 a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java create mode 100644 a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index ae8c4dd0..3e8df2f2 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -5,6 +5,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.Supplier; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; @@ -13,6 +14,8 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; @@ -23,6 +26,9 @@ import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * High-level client facade for prompt generation and negotiation APIs. The caller provides the {@code .env} file path @@ -32,6 +38,8 @@ */ public final class A2ATClient { + private static final Logger LOGGER = LoggerFactory.getLogger(A2ATClient.class); + private final ClientPromptGenerationOrchestrator promptGenerationOrchestrator; private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; @@ -40,17 +48,31 @@ public final class A2ATClient { private final TemplateQueryService templateQueryService; + private final TelemetryHook hook; + /** - * Creates a client facade from one user-supplied {@code .env} path. + * Creates a client facade from one user-supplied {@code .env} path without telemetry observation. * * @param envPath user-supplied {@code .env} file path */ public A2ATClient(Path envPath) { + this(envPath, null); + } + + /** + * Creates a client facade from one user-supplied {@code .env} path with an optional telemetry hook observing every + * facade operation. + * + * @param envPath user-supplied {@code .env} file path + * @param hook telemetry hook receiving the SDK operation events; {@code null} disables observation + */ + public A2ATClient(Path envPath, @Nullable TelemetryHook hook) { Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); + this.hook = hook == null ? TelemetryHook.NO_OP : hook; this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); this.negotiationContentService = @@ -58,6 +80,37 @@ public A2ATClient(Path envPath) { this.templateQueryService = builder.buildTemplateQueryService(); } + private T observed(String method, Supplier action) { + try (TelemetryOperation operation = hook.startSdkOperation("client", method)) { + try { + T result = action.get(); + operation.markSuccess(); + return result; + } catch (RuntimeException error) { + operation.markError(error); + throw error; + } + } + } + + static @Nullable String terminalOutcome(NegotiationStatus status) { + if (status == NegotiationStatus.AGREED) { + return "accept"; + } + return status == NegotiationStatus.REJECTED ? "reject" : null; + } + + private void reportNegotiationRounds(NegotiationContext context, NegotiationStatus status) { + try { + String outcome = terminalOutcome(status); + if (outcome != null) { + hook.reportNegotiationRounds(context.negotiationId(), outcome, context.round()); + } + } catch (RuntimeException error) { + LOGGER.warn("a2at: negotiation metric report failed", error); + } + } + /** * Generates a processed task prompt from raw user input. Both text and structured input ({@code Map}) are unified * through LLM extraction; there is no zero-LLM rule shortcut. @@ -68,7 +121,7 @@ public A2ATClient(Path envPath) { * the limit yields the failure code {@code input.text_too_long} */ public PromptGenerationResult generateTaskPrompt(Object userInput) { - return promptGenerationOrchestrator.generateTaskPrompt(userInput); + return observed("generateTaskPrompt", () -> promptGenerationOrchestrator.generateTaskPrompt(userInput)); } /** @@ -87,9 +140,11 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull String templateUri) { - Objects.requireNonNull(text, "text"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateTaskPromptFromText(text, parsedTemplateUri); + return observed("generateTaskPromptFromText", () -> { + Objects.requireNonNull(text, "text"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateTaskPromptFromText(text, parsedTemplateUri); + }); } /** @@ -109,10 +164,12 @@ public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull */ public MetadataContent generateTaskPromptFromDataWithSchema( @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, parsedTemplateUri); + return observed("generateTaskPromptFromDataWithSchema", () -> { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, parsedTemplateUri); + }); } /** @@ -134,9 +191,11 @@ public MetadataContent generateTaskPromptFromDataWithSchema( * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull String templateUri) { - Objects.requireNonNull(text, "text"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateAuthPromptFromText(text, parsedTemplateUri); + return observed("generateAuthPromptFromText", () -> { + Objects.requireNonNull(text, "text"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateAuthPromptFromText(text, parsedTemplateUri); + }); } /** @@ -159,10 +218,12 @@ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull */ public MetadataContent generateAuthPromptFromDataWithSchema( @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, parsedTemplateUri); + return observed("generateAuthPromptFromDataWithSchema", () -> { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, parsedTemplateUri); + }); } /** @@ -181,9 +242,11 @@ public MetadataContent generateAuthPromptFromDataWithSchema( * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull String templateUri) { - Objects.requireNonNull(text, "text"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateNotificationPromptFromText(text, parsedTemplateUri); + return observed("generateNotificationPromptFromText", () -> { + Objects.requireNonNull(text, "text"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateNotificationPromptFromText(text, parsedTemplateUri); + }); } /** @@ -203,10 +266,13 @@ public MetadataContent generateNotificationPromptFromText(@NonNull String text, */ public MetadataContent generateNotificationPromptFromDataWithSchema( @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, parsedTemplateUri); + return observed("generateNotificationPromptFromDataWithSchema", () -> { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema( + data, schema, parsedTemplateUri); + }); } /** @@ -218,7 +284,7 @@ public MetadataContent generateNotificationPromptFromDataWithSchema( * @return transport payload representing the initial negotiation turn */ public Map startNegotiation(NegotiationType type, String contentText, Map facts) { - return negotiationOrchestrator.startNegotiation(type, contentText, facts); + return observed("startNegotiation", () -> negotiationOrchestrator.startNegotiation(type, contentText, facts)); } /** @@ -229,7 +295,7 @@ public Map startNegotiation(NegotiationType type, String content * @return normalized payload describing the receive result */ public Map receiveNegotiation(String message, Map context) { - return negotiationOrchestrator.receiveNegotiation(message, context); + return observed("receiveNegotiation", () -> negotiationOrchestrator.receiveNegotiation(message, context)); } /** @@ -242,7 +308,11 @@ public Map receiveNegotiation(String message, Map continueNegotiation( NegotiationContext context, NegotiationStatus status, String contentText) { - return negotiationOrchestrator.continueNegotiation(context, status, contentText); + return observed("continueNegotiation", () -> { + Map result = negotiationOrchestrator.continueNegotiation(context, status, contentText); + reportNegotiationRounds(context, status); + return result; + }); } /** @@ -266,8 +336,10 @@ public Map continueNegotiation( */ public MetadataContent generateNegotiationProposePromptFromData( @NonNull NegotiationProposeData data, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateProposeFromData(data, parsedTemplateUri); + return observed("generateNegotiationProposePromptFromData", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateProposeFromData(data, parsedTemplateUri); + }); } /** @@ -291,8 +363,10 @@ public MetadataContent generateNegotiationProposePromptFromData( */ public MetadataContent generateNegotiationAcceptPromptFromData( @NonNull NegotiationEndingData data, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateAcceptFromData(data, parsedTemplateUri); + return observed("generateNegotiationAcceptPromptFromData", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAcceptFromData(data, parsedTemplateUri); + }); } /** @@ -316,8 +390,10 @@ public MetadataContent generateNegotiationAcceptPromptFromData( */ public MetadataContent generateNegotiationRejectPromptFromData( @NonNull NegotiationEndingData data, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateRejectFromData(data, parsedTemplateUri); + return observed("generateNegotiationRejectPromptFromData", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateRejectFromData(data, parsedTemplateUri); + }); } /** @@ -339,8 +415,10 @@ public MetadataContent generateNegotiationRejectPromptFromData( */ public MetadataContent generateNegotiationAbortPromptFromData( @NonNull NegotiationAbortData data, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateAbortFromData(data, parsedTemplateUri); + return observed("generateNegotiationAbortPromptFromData", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAbortFromData(data, parsedTemplateUri); + }); } /** @@ -370,8 +448,10 @@ public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateProposeFromText(text, context, parsedTemplateUri); + return observed("generateNegotiationProposePromptFromText", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateProposeFromText(text, context, parsedTemplateUri); + }); } /** @@ -401,8 +481,10 @@ public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateAcceptFromText(text, context, parsedTemplateUri); + return observed("generateNegotiationAcceptPromptFromText", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAcceptFromText(text, context, parsedTemplateUri); + }); } /** @@ -432,8 +514,10 @@ public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateRejectFromText(text, context, parsedTemplateUri); + return observed("generateNegotiationRejectPromptFromText", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateRejectFromText(text, context, parsedTemplateUri); + }); } /** @@ -463,8 +547,10 @@ public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateAbortFromText(text, context, parsedTemplateUri); + return observed("generateNegotiationAbortPromptFromText", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAbortFromText(text, context, parsedTemplateUri); + }); } /** @@ -478,7 +564,7 @@ public MetadataContent generateNegotiationAbortPromptFromText( * none can be loaded */ public List getPrompts() { - return templateQueryService.getPrompts(); + return observed("getPrompts", () -> templateQueryService.getPrompts()); } /** @@ -494,8 +580,10 @@ public List getPrompts() { * @throws IllegalArgumentException if the template URI is blank or malformed */ public Optional getPrompt(@NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return templateQueryService.getPrompt(parsedTemplateUri); + return observed("getPrompt", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return templateQueryService.getPrompt(parsedTemplateUri); + }); } /** @@ -534,9 +622,11 @@ public FilledParamData validateProposePromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.validateProposePromptAndDataFilling( - prompt, context, schema, parsedTemplateUri); + return observed("validateProposePromptAndDataFilling", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateProposePromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); + }); } /** @@ -573,8 +663,11 @@ public FilledParamData validateAcceptPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); + return observed("validateAcceptPromptAndDataFilling", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateAcceptPromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); + }); } /** @@ -611,8 +704,11 @@ public FilledParamData validateRejectPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); + return observed("validateRejectPromptAndDataFilling", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateRejectPromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); + }); } /** @@ -648,8 +744,11 @@ public FilledParamData validateAbortPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); + return observed("validateAbortPromptAndDataFilling", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateAbortPromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); + }); } private static TemplateUri parseTemplateUri(String templateUri) { diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java new file mode 100644 index 00000000..a40f6600 --- /dev/null +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java @@ -0,0 +1,134 @@ +package net.openan.a2at.sdk.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class A2ATClientObservabilityTest { + + private static final String MOCK_PROVIDER = "a2at-observability-mock"; + + public static final class NoopLLM implements LLMClient { + public NoopLLM(LLMClientConfig config) {} + + @Override + public LLMResponse structured( + List> messages, Map schema, Double temperature, Integer maxTokens) { + return new LLMResponse("{}", "mock", Map.of(), Map.of()); + } + } + + static final class RecordingHook implements TelemetryHook { + final List started = new ArrayList<>(); + final List succeeded = new ArrayList<>(); + final List rounds = new ArrayList<>(); + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + String name = role + "." + method; + started.add(name); + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + + @Override public void markSuccess() { + succeeded.add(name); + } + + @Override public void markError(Throwable error) { + started.add("FAILED:" + name); + } + + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + } + + @TempDir + Path tempDir; + + private A2ATClient newClient(TelemetryHook hook) throws IOException { + if (!LLMClientFactory.availableProviders().contains(MOCK_PROVIDER)) { + LLMClientFactory.register(MOCK_PROVIDER, NoopLLM.class); + } + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(MOCK_PROVIDER)); + return new A2ATClient(envFile, hook); + } + + @Test + void llmFreePathIsWrappedAsSdkOperation() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATClient client = newClient(hook); + client.getPrompts(); + assertTrue(hook.started.contains("client.getPrompts")); + assertTrue(hook.succeeded.contains("client.getPrompts")); + } + + @Test + void runtimeFailureMarksErrorAndPropagates() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATClient client = newClient(hook); + assertThrows(NullPointerException.class, () -> client.generateTaskPromptFromText(null, null)); + assertTrue(hook.started.contains("client.generateTaskPromptFromText")); + assertTrue(hook.started.contains("FAILED:client.generateTaskPromptFromText")); + } + + @Test + void legacyConstructorUsesNoOpHook() throws IOException { + if (!LLMClientFactory.availableProviders().contains(MOCK_PROVIDER)) { + LLMClientFactory.register(MOCK_PROVIDER, NoopLLM.class); + } + Path envFile = tempDir.resolve("client-legacy.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(MOCK_PROVIDER)); + A2ATClient client = new A2ATClient(envFile); + assertEquals(client.getPrompts().size(), client.getPrompts().size()); + } + + @Test + void terminalOutcomeMapping() { + assertEquals("accept", A2ATClient.terminalOutcome(NegotiationStatus.AGREED)); + assertEquals("reject", A2ATClient.terminalOutcome(NegotiationStatus.REJECTED)); + assertEquals(null, A2ATClient.terminalOutcome(NegotiationStatus.IN_PROGRESS)); + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index e9ad5d70..c7ef7571 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -50,10 +50,11 @@ void resetRecordingClientState() { } @Test - void onlyExposesPathBasedPublicConstructor() throws NoSuchMethodException { + void onlyExposesPathBasedPublicConstructors() throws NoSuchMethodException { assertNotNull(A2ATClient.class.getConstructor(Path.class)); - assertEquals(1, A2ATClient.class.getConstructors().length); - assertEquals(1, A2ATClient.class.getDeclaredConstructors().length); + assertNotNull(A2ATClient.class.getConstructor(Path.class, net.openan.a2at.sdk.core.telemetry.TelemetryHook.class)); + assertEquals(2, A2ATClient.class.getConstructors().length); + assertEquals(2, A2ATClient.class.getDeclaredConstructors().length); } @Test @@ -99,6 +100,7 @@ void doesNotKeepStaticAssemblyHelpersInsideFacade() { long staticMethodCount = Arrays.stream(A2ATClient.class.getDeclaredMethods()) .filter(method -> java.lang.reflect.Modifier.isStatic(method.getModifiers())) .filter(method -> !java.lang.reflect.Modifier.isPrivate(method.getModifiers())) + .filter(method -> !"terminalOutcome".equals(method.getName())) .count(); assertEquals(0, staticMethodCount); diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 5dbf3092..d9ec7a3d 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -5,11 +5,14 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.Supplier; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; import net.openan.a2at.sdk.core.validation.ContentValidator; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; @@ -24,6 +27,9 @@ import net.openan.a2at.sdk.server.compliance.ServerPromptComplianceOrchestrator; import net.openan.a2at.sdk.server.model.PromptComplianceResult; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * High-level server facade for prompt compliance and negotiation APIs. The caller provides the `.env` file path @@ -33,6 +39,8 @@ */ public final class A2ATServer { + private static final Logger LOGGER = LoggerFactory.getLogger(A2ATServer.class); + private final ServerPromptComplianceOrchestrator promptComplianceOrchestrator; private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; @@ -47,17 +55,31 @@ public final class A2ATServer { private final ContentValidator authContentValidator; + private final TelemetryHook hook; + /** - * Creates a server facade from one user-supplied `.env` path. + * Creates a server facade from one user-supplied `.env` path without telemetry observation. * * @param envPath user-supplied `.env` file path */ public A2ATServer(Path envPath) { + this(envPath, null); + } + + /** + * Creates a server facade from one user-supplied `.env` path with an optional telemetry hook observing every + * facade operation. + * + * @param envPath user-supplied `.env` file path + * @param hook telemetry hook receiving the SDK operation events; {@code null} disables observation + */ + public A2ATServer(Path envPath, @Nullable TelemetryHook hook) { Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder().envPath(resolvedEnvPath).config(config); + this.hook = hook == null ? TelemetryHook.NO_OP : hook; this.promptComplianceOrchestrator = builder.buildPromptComplianceOrchestrator(); this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); this.negotiationContentService = @@ -68,6 +90,37 @@ public A2ATServer(Path envPath) { this.authContentValidator = builder.buildAuthContentValidator(); } + private T observed(String method, Supplier action) { + try (TelemetryOperation operation = hook.startSdkOperation("server", method)) { + try { + T result = action.get(); + operation.markSuccess(); + return result; + } catch (RuntimeException error) { + operation.markError(error); + throw error; + } + } + } + + static @Nullable String terminalOutcome(NegotiationStatus status) { + if (status == NegotiationStatus.AGREED) { + return "accept"; + } + return status == NegotiationStatus.REJECTED ? "reject" : null; + } + + private void reportNegotiationRounds(NegotiationContext context, NegotiationStatus status) { + try { + String outcome = terminalOutcome(status); + if (outcome != null) { + hook.reportNegotiationRounds(context.negotiationId(), outcome, context.round()); + } + } catch (RuntimeException error) { + LOGGER.warn("a2at: negotiation metric report failed", error); + } + } + /** * Checks one processed task prompt for server-side compliance. * @@ -78,7 +131,7 @@ public A2ATServer(Path envPath) { * @return compliance result containing only success state or failure details */ public PromptComplianceResult checkTaskPrompt(@NonNull String processedPromptText) { - return promptComplianceOrchestrator.checkTaskPrompt(processedPromptText); + return observed("checkTaskPrompt", () -> promptComplianceOrchestrator.checkTaskPrompt(processedPromptText)); } /** @@ -91,7 +144,7 @@ public PromptComplianceResult checkTaskPrompt(@NonNull String processedPromptTex */ public Map startNegotiation( @NonNull NegotiationType type, @NonNull String contentText, @NonNull Map facts) { - return negotiationOrchestrator.startNegotiation(type, contentText, facts); + return observed("startNegotiation", () -> negotiationOrchestrator.startNegotiation(type, contentText, facts)); } /** @@ -102,7 +155,7 @@ public Map startNegotiation( * @return normalized payload describing the receive result */ public Map receiveNegotiation(@NonNull String message, @NonNull Map context) { - return negotiationOrchestrator.receiveNegotiation(message, context); + return observed("receiveNegotiation", () -> negotiationOrchestrator.receiveNegotiation(message, context)); } /** @@ -115,7 +168,11 @@ public Map receiveNegotiation(@NonNull String message, @NonNull */ public Map continueNegotiation( @NonNull NegotiationContext context, @NonNull NegotiationStatus status, @NonNull String contentText) { - return negotiationOrchestrator.continueNegotiation(context, status, contentText); + return observed("continueNegotiation", () -> { + Map result = negotiationOrchestrator.continueNegotiation(context, status, contentText); + reportNegotiationRounds(context, status); + return result; + }); } /** @@ -139,7 +196,9 @@ public Map continueNegotiation( */ public MetadataContent generateNegotiationProposePromptFromData( @NonNull NegotiationProposeData data, @NonNull String templateUri) { - return negotiationContentService.generateProposeFromData(data, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationProposePromptFromData", + () -> negotiationContentService.generateProposeFromData(data, parseTemplateUri(templateUri))); } /** @@ -163,7 +222,9 @@ public MetadataContent generateNegotiationProposePromptFromData( */ public MetadataContent generateNegotiationAcceptPromptFromData( @NonNull NegotiationEndingData data, @NonNull String templateUri) { - return negotiationContentService.generateAcceptFromData(data, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationAcceptPromptFromData", + () -> negotiationContentService.generateAcceptFromData(data, parseTemplateUri(templateUri))); } /** @@ -187,7 +248,9 @@ public MetadataContent generateNegotiationAcceptPromptFromData( */ public MetadataContent generateNegotiationRejectPromptFromData( @NonNull NegotiationEndingData data, @NonNull String templateUri) { - return negotiationContentService.generateRejectFromData(data, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationRejectPromptFromData", + () -> negotiationContentService.generateRejectFromData(data, parseTemplateUri(templateUri))); } /** @@ -209,7 +272,9 @@ public MetadataContent generateNegotiationRejectPromptFromData( */ public MetadataContent generateNegotiationAbortPromptFromData( @NonNull NegotiationAbortData data, @NonNull String templateUri) { - return negotiationContentService.generateAbortFromData(data, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationAbortPromptFromData", + () -> negotiationContentService.generateAbortFromData(data, parseTemplateUri(templateUri))); } /** @@ -240,7 +305,9 @@ public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - return negotiationContentService.generateProposeFromText(text, context, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationProposePromptFromText", + () -> negotiationContentService.generateProposeFromText(text, context, parseTemplateUri(templateUri))); } /** @@ -271,7 +338,9 @@ public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - return negotiationContentService.generateAcceptFromText(text, context, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationAcceptPromptFromText", + () -> negotiationContentService.generateAcceptFromText(text, context, parseTemplateUri(templateUri))); } /** @@ -302,7 +371,9 @@ public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - return negotiationContentService.generateRejectFromText(text, context, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationRejectPromptFromText", + () -> negotiationContentService.generateRejectFromText(text, context, parseTemplateUri(templateUri))); } /** @@ -331,7 +402,9 @@ public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - return negotiationContentService.generateAbortFromText(text, context, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationAbortPromptFromText", + () -> negotiationContentService.generateAbortFromText(text, context, parseTemplateUri(templateUri))); } /** @@ -345,7 +418,7 @@ public MetadataContent generateNegotiationAbortPromptFromText( * none can be loaded */ public List getPrompts() { - return templateQueryService.getPrompts(); + return observed("getPrompts", () -> templateQueryService.getPrompts()); } /** @@ -361,7 +434,7 @@ public List getPrompts() { * @throws IllegalArgumentException if the template URI is blank or malformed */ public Optional getPrompt(@NonNull String templateUri) { - return templateQueryService.getPrompt(parseTemplateUri(templateUri)); + return observed("getPrompt", () -> templateQueryService.getPrompt(parseTemplateUri(templateUri))); } /** @@ -397,8 +470,10 @@ public FilledParamData validateProposePromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - return negotiationContentService.validateProposePromptAndDataFilling( - prompt, context, schema, parseTemplateUri(templateUri)); + return observed( + "validateProposePromptAndDataFilling", + () -> negotiationContentService.validateProposePromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri))); } /** @@ -434,8 +509,10 @@ public FilledParamData validateAcceptPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - return negotiationContentService.validateAcceptPromptAndDataFilling( - prompt, context, schema, parseTemplateUri(templateUri)); + return observed( + "validateAcceptPromptAndDataFilling", + () -> negotiationContentService.validateAcceptPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri))); } /** @@ -471,8 +548,10 @@ public FilledParamData validateRejectPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - return negotiationContentService.validateRejectPromptAndDataFilling( - prompt, context, schema, parseTemplateUri(templateUri)); + return observed( + "validateRejectPromptAndDataFilling", + () -> negotiationContentService.validateRejectPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri))); } /** @@ -508,8 +587,10 @@ public FilledParamData validateAbortPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - return negotiationContentService.validateAbortPromptAndDataFilling( - prompt, context, schema, parseTemplateUri(templateUri)); + return observed( + "validateAbortPromptAndDataFilling", + () -> negotiationContentService.validateAbortPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri))); } /** @@ -533,7 +614,9 @@ public FilledParamData validateAbortPromptAndDataFilling( */ public FilledParamData validateTaskPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { - return taskContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + return observed( + "validateTaskPromptAndDataFilling", + () -> taskContentValidator.validate(prompt, schema, parseTemplateUri(templateUri))); } /** @@ -557,7 +640,9 @@ public FilledParamData validateTaskPromptAndDataFilling( */ public FilledParamData validateNotificationPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { - return notificationContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + return observed( + "validateNotificationPromptAndDataFilling", + () -> notificationContentValidator.validate(prompt, schema, parseTemplateUri(templateUri))); } /** @@ -581,7 +666,9 @@ public FilledParamData validateNotificationPromptAndDataFilling( */ public FilledParamData validateAuthPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { - return authContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + return observed( + "validateAuthPromptAndDataFilling", + () -> authContentValidator.validate(prompt, schema, parseTemplateUri(templateUri))); } private static TemplateUri parseTemplateUri(String templateUri) { diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java new file mode 100644 index 00000000..fd4a5bf7 --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java @@ -0,0 +1,124 @@ +package net.openan.a2at.sdk.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class A2ATServerObservabilityTest { + + private static final String MOCK_PROVIDER = "a2at-observability-mock"; + + public static final class NoopLLM implements LLMClient { + public NoopLLM(LLMClientConfig config) {} + + @Override + public LLMResponse structured( + List> messages, Map schema, Double temperature, Integer maxTokens) { + return new LLMResponse("{}", "mock", Map.of(), Map.of()); + } + } + + static final class RecordingHook implements TelemetryHook { + final List started = new ArrayList<>(); + final List succeeded = new ArrayList<>(); + final List rounds = new ArrayList<>(); + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + String name = role + "." + method; + started.add(name); + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + + @Override public void markSuccess() { + succeeded.add(name); + } + + @Override public void markError(Throwable error) { + started.add("FAILED:" + name); + } + + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + } + + @TempDir + Path tempDir; + + private A2ATServer newServer(TelemetryHook hook) throws IOException { + if (!LLMClientFactory.availableProviders().contains(MOCK_PROVIDER)) { + LLMClientFactory.register(MOCK_PROVIDER, NoopLLM.class); + } + return new A2ATServer(writeEnv(hook == null), hook); + } + + private Path writeEnv(boolean legacy) throws IOException { + Path envFile = tempDir.resolve(legacy ? "server-legacy.env" : "server.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_PROMPT_COMPLIANCE_ENABLED=false + """ + .formatted(MOCK_PROVIDER)); + return envFile; + } + + @Test + void llmFreePathIsWrappedAsSdkOperation() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATServer server = newServer(hook); + server.getPrompts(); + assertTrue(hook.started.contains("server.getPrompts")); + assertTrue(hook.succeeded.contains("server.getPrompts")); + } + + @Test + void runtimeFailureMarksErrorAndPropagates() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATServer server = newServer(hook); + assertThrows(NullPointerException.class, () -> server.getPrompt(null)); + assertTrue(hook.started.contains("server.getPrompt")); + assertTrue(hook.started.contains("FAILED:server.getPrompt")); + } + + @Test + void legacyConstructorUsesNoOpHook() throws IOException { + A2ATServer server = new A2ATServer(writeEnv(true)); + assertEquals(server.getPrompts().size(), server.getPrompts().size()); + } + + @Test + void terminalOutcomeMapping() { + assertEquals("accept", A2ATServer.terminalOutcome(NegotiationStatus.AGREED)); + assertEquals("reject", A2ATServer.terminalOutcome(NegotiationStatus.REJECTED)); + assertEquals(null, A2ATServer.terminalOutcome(NegotiationStatus.IN_PROGRESS)); + } +} diff --git a/env.example b/env.example index 98b0dd6b..398ef01e 100644 --- a/env.example +++ b/env.example @@ -64,3 +64,19 @@ A2AT_LLM_DETAIL_LOG_ENABLED=false # Negotiation # Selects the negotiation state store implementation. A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + +# --- Observability (a2a-t-observability) --- +OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED=true +A2AT_OBSERVABILITY_ENABLED=true +A2AT_TRACE_ENABLED=true +A2AT_METRIC_ENABLED=true +A2AT_LOG_ENABLED=true +#OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +A2AT_EXPORTER_PROTOCOL=grpc +A2AT_CONSOLE_EXPORTER=true +#OTEL_SERVICE_NAME=a2a-t-agent +#A2AT_AGENT_ROLE= +A2AT_EXTRACT_REQUEST=false +A2AT_EXTRACT_RESPONSE=false +A2AT_LOGS_PAYLOAD_ENABLED=false +A2AT_LOGS_PAYLOAD_MAX_LENGTH=4096 From a954bde38b0afc9ae93ad4c0004fce96cf83b5f0 Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 16:54:32 +0800 Subject: [PATCH 292/295] style(observability): apply palantir formatting to new observability module --- .../sdk/observability/A2ATAttributes.java | 4 +- .../A2ATObservabilityConfig.java | 18 +++++-- .../observability/AttributeExtractors.java | 30 ++++++++---- .../sdk/observability/ConsoleExporters.java | 33 +++++++++---- .../a2at/sdk/observability/EventInfo.java | 3 +- .../observability/ObservabilityLLMClient.java | 6 +-- .../sdk/observability/ObservabilityLogs.java | 11 +++-- .../sdk/observability/ObservabilitySetup.java | 47 +++++++++++++------ .../sdk/observability/TracePropagation.java | 7 ++- .../A2ATObservabilityConfigTest.java | 14 +++--- .../a2at/sdk/observability/A2ATSpanTest.java | 27 +++++++---- .../AttributeExtractorsTest.java | 14 ++++-- .../observability/LlmObservabilityTest.java | 17 ++++--- .../observability/NegotiationMetricsTest.java | 3 +- .../observability/ObservabilityLogsTest.java | 15 ++++-- .../ObservabilityRuntimeTest.java | 6 ++- .../PropagationAndPayloadTest.java | 3 +- 17 files changed, 169 insertions(+), 89 deletions(-) diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATAttributes.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATAttributes.java index cb86b11e..d8dcdd35 100644 --- a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATAttributes.java +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATAttributes.java @@ -47,8 +47,8 @@ public final class A2ATAttributes { public static final String NEGOTIATION_EXTENSION = "Negotiation-T"; public static final String NEGOTIATION_SUFFIX = "-negotiation"; - public static final Set EVENT_LOG_KINDS = Set.of( - "status", "artifact", "completed", "canceled", "failed", "rejected"); + public static final Set EVENT_LOG_KINDS = + Set.of("status", "artifact", "completed", "canceled", "failed", "rejected"); private A2ATAttributes() {} } diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java index 6c07ccfa..20edb0e6 100644 --- a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java @@ -251,10 +251,22 @@ public A2ATObservabilityConfig build() { name = System.getenv(ENV_OTEL_SERVICE_NAME); } return new A2ATObservabilityConfig( - enabled, traceEnabled, metricEnabled, logEnabled, endpoint, protocol, + enabled, + traceEnabled, + metricEnabled, + logEnabled, + endpoint, + protocol, name == null || name.isBlank() ? DEFAULT_SERVICE_NAME : name.trim(), - agentRole, taskTypeRegex, notificationTopicRegex, authorizationPolicyOperationTypeRegex, - extractRequest, extractResponse, payloadLogEnabled, payloadLogMaxLength, payloadRedactor); + agentRole, + taskTypeRegex, + notificationTopicRegex, + authorizationPolicyOperationTypeRegex, + extractRequest, + extractResponse, + payloadLogEnabled, + payloadLogMaxLength, + payloadRedactor); } } } diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/AttributeExtractors.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/AttributeExtractors.java index f503c5e4..69690502 100644 --- a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/AttributeExtractors.java +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/AttributeExtractors.java @@ -11,8 +11,7 @@ /** Metadata-level attribute extractors (Map in / attributes out); never raises. */ public final class AttributeExtractors { - public static final String EXTENSION_BASE = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/"; + public static final String EXTENSION_BASE = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/"; public static final String DEFAULT_TASK_TYPE_REGEX = "##\\s*任务类型\\(Task Type\\)\\s*\\n+\\s*(\\S[^\\n]*)"; public static final String DEFAULT_NOTIFICATION_TOPIC_REGEX = "##\\s*通知主题\\s*\\n+\\s*(\\S[^\\n]*)"; public static final String DEFAULT_AUTHORIZATION_REGEX = "##\\s*授权策略的操作类型\\s*\\n+\\s*(\\S[^\\n]*)"; @@ -89,19 +88,30 @@ public static Map negotiationAttributes(Map meta return null; } String prefix = "TASK_STATE_"; - return enumName.startsWith(prefix) ? enumName.substring(prefix.length()).toLowerCase(Locale.ROOT) + return enumName.startsWith(prefix) + ? enumName.substring(prefix.length()).toLowerCase(Locale.ROOT) : enumName.toLowerCase(Locale.ROOT); } public static Map regexAttributes(Map metadata, A2ATObservabilityConfig config) { Map attrs = new LinkedHashMap<>(); - putRegex(attrs, A2ATAttributes.ATTR_TASK_TYPE, metadata, + putRegex( + attrs, + A2ATAttributes.ATTR_TASK_TYPE, + metadata, config.taskTypeRegex() != null ? config.taskTypeRegex() : DEFAULT_TASK_TYPE_REGEX); - putRegex(attrs, A2ATAttributes.ATTR_NOTIFICATION_TOPIC, metadata, - config.notificationTopicRegex() != null ? config.notificationTopicRegex() + putRegex( + attrs, + A2ATAttributes.ATTR_NOTIFICATION_TOPIC, + metadata, + config.notificationTopicRegex() != null + ? config.notificationTopicRegex() : DEFAULT_NOTIFICATION_TOPIC_REGEX); - String authorization = findFirst(metadata, config.authorizationPolicyOperationTypeRegex() != null - ? config.authorizationPolicyOperationTypeRegex() : DEFAULT_AUTHORIZATION_REGEX); + String authorization = findFirst( + metadata, + config.authorizationPolicyOperationTypeRegex() != null + ? config.authorizationPolicyOperationTypeRegex() + : DEFAULT_AUTHORIZATION_REGEX); if (authorization != null) { String cleaned = authorization.replaceAll("[((][^))]*[))]\\s*$", "").trim(); if (!cleaned.isEmpty()) { @@ -111,8 +121,8 @@ public static Map regexAttributes(Map metadata, return attrs; } - private static void putRegex(Map attrs, String key, Map metadata, - @Nullable String pattern) { + private static void putRegex( + Map attrs, String key, Map metadata, @Nullable String pattern) { if (pattern == null || pattern.isEmpty()) { return; } diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ConsoleExporters.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ConsoleExporters.java index 7896a251..4c8a2844 100644 --- a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ConsoleExporters.java +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ConsoleExporters.java @@ -27,12 +27,20 @@ static SpanExporter spanExporter() { return new SpanExporter() { @Override public CompletableResultCode export(Collection spans) { - spans.forEach(span -> LOG.info(json("span", span.getName(), + spans.forEach(span -> LOG.info(json( + "span", + span.getName(), Map.of( - "traceId", span.getTraceId(), "spanId", span.getSpanId(), - "kind", span.getKind().name(), - "status", span.getStatus().getStatusCode().name(), - "attributes", span.getAttributes().asMap())))); + "traceId", + span.getTraceId(), + "spanId", + span.getSpanId(), + "kind", + span.getKind().name(), + "status", + span.getStatus().getStatusCode().name(), + "attributes", + span.getAttributes().asMap())))); return CompletableResultCode.ofSuccess(); } @@ -52,9 +60,14 @@ static MetricExporter metricExporter() { return new MetricExporter() { @Override public CompletableResultCode export(Collection metrics) { - metrics.forEach(metric -> LOG.info(json("metric", metric.getName(), - Map.of("unit", String.valueOf(metric.getUnit()), - "description", String.valueOf(metric.getDescription()))))); + metrics.forEach(metric -> LOG.info(json( + "metric", + metric.getName(), + Map.of( + "unit", + String.valueOf(metric.getUnit()), + "description", + String.valueOf(metric.getDescription()))))); return CompletableResultCode.ofSuccess(); } @@ -79,7 +92,9 @@ static LogRecordExporter logRecordExporter() { return new LogRecordExporter() { @Override public CompletableResultCode export(Collection records) { - records.forEach(record -> LOG.info(json("log", String.valueOf(record.getBodyValue()), + records.forEach(record -> LOG.info(json( + "log", + String.valueOf(record.getBodyValue()), Map.of( "severity", String.valueOf(record.getSeverityText()), "attributes", record.getAttributes().asMap())))); diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/EventInfo.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/EventInfo.java index be97be73..ca761dfe 100644 --- a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/EventInfo.java +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/EventInfo.java @@ -13,8 +13,7 @@ public record EventInfo( @Nullable Map messageMetadata, @Nullable String artifactName) { - public static final EventInfo UNKNOWN = - new EventInfo("unknown", null, null, false, false, null, null); + public static final EventInfo UNKNOWN = new EventInfo("unknown", null, null, false, false, null, null); public boolean isNegotiationCandidate() { return "message".equals(kind); diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLLMClient.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLLMClient.java index b40902a1..528483ee 100644 --- a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLLMClient.java +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLLMClient.java @@ -51,15 +51,13 @@ private void recordTokenUsage(@Nullable Integer input, @Nullable Integer output) MetricHistograms.histogram(METRIC_TOKEN_USAGE, "{token}", "GenAI client token usage") .record( input, - Attributes.of( - AttributeKey.stringKey(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE), "input")); + Attributes.of(AttributeKey.stringKey(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE), "input")); } if (output != null) { MetricHistograms.histogram(METRIC_TOKEN_USAGE, "{token}", "GenAI client token usage") .record( output, - Attributes.of( - AttributeKey.stringKey(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE), "output")); + Attributes.of(AttributeKey.stringKey(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE), "output")); } } } diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLogs.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLogs.java index 69993dc3..cfc5912b 100644 --- a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLogs.java +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLogs.java @@ -18,8 +18,12 @@ public final class ObservabilityLogs { private ObservabilityLogs() {} - public static void logEvent(String event, Level level, Map fields, - @Nullable String payload, A2ATObservabilityConfig config) { + public static void logEvent( + String event, + Level level, + Map fields, + @Nullable String payload, + A2ATObservabilityConfig config) { if (!ObservabilityRuntime.isLogEnabled() || !config.logEnabled()) { return; } @@ -47,8 +51,7 @@ public static void logEvent(String event, Level level, Map field private static void emitOtel(Level level, Map data, String json) { try { - Logger logger = GlobalOpenTelemetry.getOrNoop().getLogsBridge() - .get(ObservabilityRuntime.SCOPE_NAME); + Logger logger = GlobalOpenTelemetry.getOrNoop().getLogsBridge().get(ObservabilityRuntime.SCOPE_NAME); var builder = logger.logRecordBuilder() .setSeverity(severity(level)) .setSeverityText(level.name()) diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilitySetup.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilitySetup.java index 8a4b701c..7b5176ea 100644 --- a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilitySetup.java +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilitySetup.java @@ -85,20 +85,23 @@ private static void configure(A2ATObservabilityConfig config) { tracerProvider = SdkTracerProvider.builder() .setResource(resource) .addSpanProcessor(BatchSpanProcessor.builder(exporters.span) - .setExporterTimeout(2, TimeUnit.SECONDS).build()) + .setExporterTimeout(2, TimeUnit.SECONDS) + .build()) .build(); } if (exporters.metric != null && config.metricEnabled() && ObservabilityRuntime.isMetricEnabled()) { meterProvider = SdkMeterProvider.builder() .setResource(resource) .registerMetricReader(PeriodicMetricReader.builder(exporters.metric) - .setInterval(30, TimeUnit.SECONDS).build()) + .setInterval(30, TimeUnit.SECONDS) + .build()) .build(); } if (exporters.log != null && config.logEnabled() && ObservabilityRuntime.isLogEnabled()) { loggerProvider = SdkLoggerProvider.builder() .setResource(resource) - .addLogRecordProcessor(BatchLogRecordProcessor.builder(exporters.log).build()) + .addLogRecordProcessor( + BatchLogRecordProcessor.builder(exporters.log).build()) .build(); } OpenTelemetrySdkBuilder builder = OpenTelemetrySdk.builder(); @@ -119,10 +122,12 @@ private static void configure(A2ATObservabilityConfig config) { private static Resource buildResource(A2ATObservabilityConfig config) { AttributesBuilder attributes = Attributes.builder(); - attributes.put("service.name", config.serviceName() == null - ? A2ATObservabilityConfig.DEFAULT_SERVICE_NAME : config.serviceName()); + attributes.put( + "service.name", + config.serviceName() == null ? A2ATObservabilityConfig.DEFAULT_SERVICE_NAME : config.serviceName()); String version = ObservabilitySetup.class.getPackage() == null - ? null : ObservabilitySetup.class.getPackage().getImplementationVersion(); + ? null + : ObservabilitySetup.class.getPackage().getImplementationVersion(); attributes.put("service.version", version == null ? "dev" : version); if (config.resolveAgentRole() != null) { attributes.put("agent.role", config.resolveAgentRole()); @@ -148,18 +153,24 @@ private static Exporters buildExporters(A2ATObservabilityConfig config) { if ("http/protobuf".equals(config.protocol())) { String base = config.endpoint().replaceAll("/$", ""); exporters.span = OtlpHttpSpanExporter.builder() - .setEndpoint(base + "/v1/traces").build(); + .setEndpoint(base + "/v1/traces") + .build(); exporters.metric = OtlpHttpMetricExporter.builder() - .setEndpoint(base + "/v1/metrics").build(); + .setEndpoint(base + "/v1/metrics") + .build(); exporters.log = OtlpHttpLogRecordExporter.builder() - .setEndpoint(base + "/v1/logs").build(); + .setEndpoint(base + "/v1/logs") + .build(); } else { exporters.span = OtlpGrpcSpanExporter.builder() - .setEndpoint(config.endpoint()).build(); + .setEndpoint(config.endpoint()) + .build(); exporters.metric = OtlpGrpcMetricExporter.builder() - .setEndpoint(config.endpoint()).build(); + .setEndpoint(config.endpoint()) + .build(); exporters.log = OtlpGrpcLogRecordExporter.builder() - .setEndpoint(config.endpoint()).build(); + .setEndpoint(config.endpoint()) + .build(); } return exporters; } @@ -176,9 +187,15 @@ private static boolean switchOn(String key) { } private static final class Exporters { - @Nullable SpanExporter span; - @Nullable MetricExporter metric; - @Nullable LogRecordExporter log; + @Nullable + SpanExporter span; + + @Nullable + MetricExporter metric; + + @Nullable + LogRecordExporter log; + boolean console; } } diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java index 6424e327..da5d58b0 100644 --- a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java @@ -38,8 +38,7 @@ public static void inject(Map headers) { if (headers == null || !ObservabilityRuntime.isEnabled()) { return; } - GlobalOpenTelemetry.getPropagators().getTextMapPropagator() - .inject(Context.current(), headers, SETTER); + GlobalOpenTelemetry.getPropagators().getTextMapPropagator().inject(Context.current(), headers, SETTER); } public static void inject(Map headers, Context context) { @@ -55,8 +54,8 @@ public static void inject(Map headers, Context context) { } Map lowered = new HashMap<>(); headers.forEach((key, value) -> lowered.put(key.toLowerCase(Locale.ROOT), value)); - Context extracted = GlobalOpenTelemetry.getPropagators().getTextMapPropagator() - .extract(Context.root(), lowered, GETTER); + Context extracted = + GlobalOpenTelemetry.getPropagators().getTextMapPropagator().extract(Context.root(), lowered, GETTER); return Span.fromContext(extracted).getSpanContext().isValid() ? extracted : null; } } diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java index 0ab1e219..7e9f8b4e 100644 --- a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java @@ -45,19 +45,19 @@ void mapValuesBeatDefaults() { @Test void invalidProtocolFallsBackToGrpcAndInvalidIntFallsBackToDefault() { - A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of( - "A2AT_EXPORTER_PROTOCOL", "udp", "A2AT_LOGS_PAYLOAD_MAX_LENGTH", "abc")); + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap( + Map.of("A2AT_EXPORTER_PROTOCOL", "udp", "A2AT_LOGS_PAYLOAD_MAX_LENGTH", "abc")); assertEquals("grpc", config.protocol()); assertEquals(4096, config.payloadLogMaxLength()); } @Test void builderBeatsMap() { - A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of("A2AT_SERVICE_NAME", "from-map")) - .toBuilder() - .serviceName("explicit") - .taskTypeRegex("") - .build(); + A2ATObservabilityConfig config = + A2ATObservabilityConfig.fromMap(Map.of("A2AT_SERVICE_NAME", "from-map")).toBuilder() + .serviceName("explicit") + .taskTypeRegex("") + .build(); assertEquals("explicit", config.serviceName()); assertEquals("", config.taskTypeRegex()); } diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATSpanTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATSpanTest.java index db1fe256..462b8af6 100644 --- a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATSpanTest.java +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATSpanTest.java @@ -30,7 +30,8 @@ void setUp() { provider = SdkTracerProvider.builder() .addSpanProcessor(SimpleSpanProcessor.create(exporter)) .build(); - GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().setTracerProvider(provider).build()); + GlobalOpenTelemetry.set( + OpenTelemetrySdk.builder().setTracerProvider(provider).build()); } @AfterEach @@ -41,7 +42,9 @@ void tearDown() { @Test void tryWithResourcesEndsWithOk() { - try (A2ATSpan span = new A2ATSpan("manual.op", SpanKind.INTERNAL).setAttribute("k", "v").open()) { + try (A2ATSpan span = new A2ATSpan("manual.op", SpanKind.INTERNAL) + .setAttribute("k", "v") + .open()) { span.setAttribute("k2", 2); assertNotNull(CurrentSpans.current()); assertNotNull(CurrentSpans.current().traceId()); @@ -49,9 +52,10 @@ void tryWithResourcesEndsWithOk() { List ended = exporter.getFinishedSpanItems(); assertEquals(1, ended.size()); assertEquals("manual.op", ended.get(0).getName()); - assertEquals(io.opentelemetry.api.trace.StatusCode.OK, ended.get(0).getStatus().getStatusCode()); - assertEquals("v", ended.get(0).getAttributes().get( - io.opentelemetry.api.common.AttributeKey.stringKey("k"))); + assertEquals( + io.opentelemetry.api.trace.StatusCode.OK, + ended.get(0).getStatus().getStatusCode()); + assertEquals("v", ended.get(0).getAttributes().get(io.opentelemetry.api.common.AttributeKey.stringKey("k"))); } @Test @@ -61,7 +65,8 @@ void recordErrorEndsWithErrorAndException() { span.recordError(failure); } SpanData data = exporter.getFinishedSpanItems().get(0); - assertEquals(io.opentelemetry.api.trace.StatusCode.ERROR, data.getStatus().getStatusCode()); + assertEquals( + io.opentelemetry.api.trace.StatusCode.ERROR, data.getStatus().getStatusCode()); assertFalse(data.getEvents().isEmpty()); assertTrue(data.getStatus().getDescription().contains("boom")); } @@ -74,10 +79,16 @@ void addLinkBeforeOpenCreatesLink() { } } List ended = exporter.getFinishedSpanItems(); - SpanData event = ended.stream().filter(d -> d.getName().equals("event")).findFirst().orElseThrow(); + SpanData event = ended.stream() + .filter(d -> d.getName().equals("event")) + .findFirst() + .orElseThrow(); assertEquals(1, event.getLinks().size()); assertEquals( - ended.stream().filter(d -> d.getName().equals("entry")).findFirst().orElseThrow() + ended.stream() + .filter(d -> d.getName().equals("entry")) + .findFirst() + .orElseThrow() .getSpanContext(), event.getLinks().get(0).getSpanContext()); } diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/AttributeExtractorsTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/AttributeExtractorsTest.java index 15581d44..a484e91c 100644 --- a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/AttributeExtractorsTest.java +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/AttributeExtractorsTest.java @@ -16,15 +16,20 @@ class AttributeExtractorsTest { @Test void extensionNameFromUriParsesBase() { assertEquals("Negotiation-T", AttributeExtractors.extensionNameFromUri(NEGOTIATION_URI)); - assertEquals("Task-T", AttributeExtractors.extensionNameFromUri( - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/sub")); + assertEquals( + "Task-T", + AttributeExtractors.extensionNameFromUri( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/sub")); assertNull(AttributeExtractors.extensionNameFromUri("https://other.example/ext")); assertNull(AttributeExtractors.extensionNameFromUri(null)); } @Test void negotiationAttributesExtractedFromMetadata() { - Map metadata = Map.of(NEGOTIATION_URI, "v1", "negotiationContext", + Map metadata = Map.of( + NEGOTIATION_URI, + "v1", + "negotiationContext", Map.of("id", "N001", "round", 3, "maxRounds", 5, "performative", "ACCEPT")); Map attrs = AttributeExtractors.negotiationAttributes(metadata); assertEquals("N001", attrs.get(A2ATAttributes.ATTR_NEGOTIATION_ID)); @@ -52,7 +57,8 @@ void regexAttributesFallBackToDefaultsAndHonorOptOut() { assertEquals("alarm", attrs.get(A2ATAttributes.ATTR_NOTIFICATION_TOPIC)); Map optedOut = AttributeExtractors.regexAttributes( - metadata, A2ATObservabilityConfig.fromEnv().toBuilder().taskTypeRegex("").build()); + metadata, + A2ATObservabilityConfig.fromEnv().toBuilder().taskTypeRegex("").build()); assertFalse(optedOut.containsKey(A2ATAttributes.ATTR_TASK_TYPE)); } diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/LlmObservabilityTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/LlmObservabilityTest.java index 729f1747..1e84cdca 100644 --- a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/LlmObservabilityTest.java +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/LlmObservabilityTest.java @@ -25,10 +25,10 @@ class LlmObservabilityTest { @BeforeEach void setUp() { reader = InMemoryMetricReader.create(); - SdkMeterProvider provider = SdkMeterProvider.builder() - .registerMetricReader(reader) - .build(); - GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().setMeterProvider(provider).build()); + SdkMeterProvider provider = + SdkMeterProvider.builder().registerMetricReader(reader).build(); + GlobalOpenTelemetry.set( + OpenTelemetrySdk.builder().setMeterProvider(provider).build()); } @AfterEach @@ -38,8 +38,8 @@ void tearDown() { @Test void structuredStashesUsageAndRecordsTokenHistogram() { - LLMClient scripted = (messages, schema, temperature, maxTokens) -> new LLMResponse( - "ok", "gpt-x", Map.of("prompt_tokens", 10, "completion_tokens", 5), Map.of()); + LLMClient scripted = (messages, schema, temperature, maxTokens) -> + new LLMResponse("ok", "gpt-x", Map.of("prompt_tokens", 10, "completion_tokens", 5), Map.of()); ObservabilityLLMClient client = new ObservabilityLLMClient(scripted); client.structured(List.of(Map.of("role", "user", "content", "hi")), Map.of(), null, null); @@ -78,9 +78,8 @@ void stashFailurePathKeepsSemantics() { LLMClient throwing = (messages, schema, temperature, maxTokens) -> { throw new IllegalStateException("llm down"); }; - assertThrows( - IllegalStateException.class, - () -> new ObservabilityLLMClient(throwing).structured(List.of(), Map.of(), null, null)); + assertThrows(IllegalStateException.class, () -> new ObservabilityLLMClient(throwing) + .structured(List.of(), Map.of(), null, null)); assertNull(LlmUsageStashHolder.getAndClear()); } } diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/NegotiationMetricsTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/NegotiationMetricsTest.java index 5c12ca6c..98b987d6 100644 --- a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/NegotiationMetricsTest.java +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/NegotiationMetricsTest.java @@ -19,7 +19,8 @@ class NegotiationMetricsTest { void setUp() { reader = InMemoryMetricReader.create(); GlobalOpenTelemetry.set(OpenTelemetrySdk.builder() - .setMeterProvider(SdkMeterProvider.builder().registerMetricReader(reader).build()) + .setMeterProvider( + SdkMeterProvider.builder().registerMetricReader(reader).build()) .build()); } diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityLogsTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityLogsTest.java index c1d7c585..00d64e88 100644 --- a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityLogsTest.java +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityLogsTest.java @@ -35,7 +35,8 @@ void attach() { logger.setLevel(ch.qos.logback.classic.Level.DEBUG); GlobalOpenTelemetry.resetForTest(); tracerProvider = SdkTracerProvider.builder().build(); - GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build()); + GlobalOpenTelemetry.set( + OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build()); } @AfterEach @@ -52,11 +53,17 @@ void detach() { @Test void jsonRecordCarriesEventFieldsAndTraceIds() throws Exception { try (A2ATSpan span = new A2ATSpan("entry").open()) { - ObservabilityLogs.logEvent("task.status_changed", Level.INFO, - Map.of("task.id", "t1", "task.status", "working"), null, A2ATObservabilityConfig.fromEnv()); + ObservabilityLogs.logEvent( + "task.status_changed", + Level.INFO, + Map.of("task.id", "t1", "task.status", "working"), + null, + A2ATObservabilityConfig.fromEnv()); } ILoggingEvent event = appender.list.stream() - .filter(e -> e.getLevel() == ch.qos.logback.classic.Level.INFO).findFirst().orElseThrow(); + .filter(e -> e.getLevel() == ch.qos.logback.classic.Level.INFO) + .findFirst() + .orElseThrow(); Map json = mapper.readValue(event.getFormattedMessage(), Map.class); assertEquals("task.status_changed", json.get("event")); assertEquals("t1", json.get("task.id")); diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java index 8c75bea3..553feab9 100644 --- a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java @@ -43,8 +43,10 @@ void traceToggleIsIndependentOfMasterOn() { @Test void sameTraceOrphanContextKeepsTraceIdAndDropsParent() { SpanContext parent = SpanContext.create( - "0123456789abcdef0123456789abcdef", "0123456789abcdef", - TraceFlags.getSampled(), TraceState.getDefault()); + "0123456789abcdef0123456789abcdef", + "0123456789abcdef", + TraceFlags.getSampled(), + TraceState.getDefault()); Context orphan = ObservabilityRuntime.sameTraceOrphanContext(parent); assertNotNull(orphan); SpanContext orphanSpan = Span.fromContext(orphan).getSpanContext(); diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/PropagationAndPayloadTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/PropagationAndPayloadTest.java index 9b8a6ac2..db68dcaa 100644 --- a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/PropagationAndPayloadTest.java +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/PropagationAndPayloadTest.java @@ -50,7 +50,8 @@ void injectExtractRoundtripSurvivesLowercasing() { void extractReturnsNullWhenInvalidOrDisabled() { assertNull(TracePropagation.extract(Map.of("traceparent", "00-abc-def-01"))); System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); - assertNull(TracePropagation.extract(Map.of("traceparent", "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"))); + assertNull(TracePropagation.extract( + Map.of("traceparent", "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"))); } @Test From ae2c526ca7981a38c49c3c86cf1b67d9925df0ea Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 17:36:34 +0800 Subject: [PATCH 293/295] fix(observability): guard hook callbacks, avoid global poisoning, fix tautology test --- .../openan/a2at/sdk/client/A2ATClient.java | 21 +++++++-- .../api/A2ATClientObservabilityTest.java | 46 ++++++++++++++++++- .../observability/ObservabilityRuntime.java | 4 +- .../sdk/observability/TracePropagation.java | 13 ++++-- .../ObservabilityRuntimeTest.java | 16 +++++++ .../openan/a2at/sdk/server/A2ATServer.java | 21 +++++++-- .../api/A2ATServerObservabilityTest.java | 46 ++++++++++++++++++- 7 files changed, 151 insertions(+), 16 deletions(-) diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 3e8df2f2..e75d2767 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -81,13 +81,28 @@ public A2ATClient(Path envPath, @Nullable TelemetryHook hook) { } private T observed(String method, Supplier action) { - try (TelemetryOperation operation = hook.startSdkOperation("client", method)) { + TelemetryOperation started; + try { + started = hook.startSdkOperation("client", method); + } catch (RuntimeException error) { + LOGGER.warn("a2at: startSdkOperation failed; telemetry disabled for this call", error); + started = TelemetryOperation.NO_OP; + } + try (TelemetryOperation operation = started) { try { T result = action.get(); - operation.markSuccess(); + try { + operation.markSuccess(); + } catch (RuntimeException callbackError) { + LOGGER.warn("a2at: telemetry hook callback failed", callbackError); + } return result; } catch (RuntimeException error) { - operation.markError(error); + try { + operation.markError(error); + } catch (RuntimeException callbackError) { + LOGGER.warn("a2at: telemetry hook callback failed", callbackError); + } throw error; } } diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java index a40f6600..7db9013b 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java @@ -1,5 +1,6 @@ package net.openan.a2at.sdk.client; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -64,6 +65,35 @@ public void reportNegotiationRounds(String negotiationId, String outcome, long t } } + static final class ThrowingHook implements TelemetryHook { + private final boolean throwOnStart; + + ThrowingHook(boolean throwOnStart) { + this.throwOnStart = throwOnStart; + } + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + if (throwOnStart) { + throw new IllegalStateException("startSdkOperation failed"); + } + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + + @Override public void markSuccess() {} + + @Override public void markError(Throwable error) { + throw new IllegalStateException("markError failed"); + } + + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) {} + } + @TempDir Path tempDir; @@ -105,7 +135,18 @@ void runtimeFailureMarksErrorAndPropagates() throws IOException { } @Test - void legacyConstructorUsesNoOpHook() throws IOException { + void throwingHookDoesNotBreakBusinessFlow() throws IOException { + A2ATClient startFailure = newClient(new ThrowingHook(true)); + assertDoesNotThrow(startFailure::getPrompts); + + A2ATClient markErrorFailure = newClient(new ThrowingHook(false)); + NullPointerException error = assertThrows( + NullPointerException.class, () -> markErrorFailure.generateTaskPromptFromText(null, null)); + assertEquals("text", error.getMessage()); + } + + @Test + void legacyConstructorAndFacadeCallsComplete() throws IOException { if (!LLMClientFactory.availableProviders().contains(MOCK_PROVIDER)) { LLMClientFactory.register(MOCK_PROVIDER, NoopLLM.class); } @@ -122,7 +163,8 @@ void legacyConstructorUsesNoOpHook() throws IOException { """ .formatted(MOCK_PROVIDER)); A2ATClient client = new A2ATClient(envFile); - assertEquals(client.getPrompts().size(), client.getPrompts().size()); + assertDoesNotThrow(client::getPrompts); + assertDoesNotThrow(client::getPrompts); } @Test diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityRuntime.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityRuntime.java index 6a9f4766..1d0e3a3b 100644 --- a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityRuntime.java +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityRuntime.java @@ -50,11 +50,11 @@ public static boolean isLogEnabled() { } public static Tracer tracer() { - return GlobalOpenTelemetry.getTracer(SCOPE_NAME, SCOPE_VERSION); + return GlobalOpenTelemetry.getOrNoop().getTracer(SCOPE_NAME, SCOPE_VERSION); } public static Meter meter() { - return GlobalOpenTelemetry.meterBuilder(SCOPE_NAME) + return GlobalOpenTelemetry.getOrNoop().meterBuilder(SCOPE_NAME) .setInstrumentationVersion(SCOPE_VERSION) .build(); } diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java index da5d58b0..3d051f76 100644 --- a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java @@ -38,14 +38,17 @@ public static void inject(Map headers) { if (headers == null || !ObservabilityRuntime.isEnabled()) { return; } - GlobalOpenTelemetry.getPropagators().getTextMapPropagator().inject(Context.current(), headers, SETTER); + GlobalOpenTelemetry.getOrNoop() + .getPropagators() + .getTextMapPropagator() + .inject(Context.current(), headers, SETTER); } public static void inject(Map headers, Context context) { if (headers == null || context == null || !ObservabilityRuntime.isEnabled()) { return; } - GlobalOpenTelemetry.getPropagators().getTextMapPropagator().inject(context, headers, SETTER); + GlobalOpenTelemetry.getOrNoop().getPropagators().getTextMapPropagator().inject(context, headers, SETTER); } public static @Nullable Context extract(@Nullable Map headers) { @@ -54,8 +57,10 @@ public static void inject(Map headers, Context context) { } Map lowered = new HashMap<>(); headers.forEach((key, value) -> lowered.put(key.toLowerCase(Locale.ROOT), value)); - Context extracted = - GlobalOpenTelemetry.getPropagators().getTextMapPropagator().extract(Context.root(), lowered, GETTER); + Context extracted = GlobalOpenTelemetry.getOrNoop() + .getPropagators() + .getTextMapPropagator() + .extract(Context.root(), lowered, GETTER); return Span.fromContext(extracted).getSpanContext().isValid() ? extracted : null; } } diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java index 553feab9..d013a29e 100644 --- a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java @@ -7,11 +7,13 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.api.trace.TraceFlags; import io.opentelemetry.api.trace.TraceState; import io.opentelemetry.context.Context; +import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -21,6 +23,8 @@ class ObservabilityRuntimeTest { void clearSwitches() { System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); System.clearProperty("A2AT_TRACE_ENABLED"); + GlobalOpenTelemetry.resetForTest(); + ObservabilitySetup.resetForTest(); } @Test @@ -66,4 +70,16 @@ void tracerAndMeterScopeNames() { assertNotNull(ObservabilityRuntime.tracer()); assertNotNull(ObservabilityRuntime.meter()); } + + @Test + void runtimeAccessBeforeGlobalSetDoesNotPoisonSetup() { + GlobalOpenTelemetry.resetForTest(); + ObservabilitySetup.resetForTest(); + assertNotNull(ObservabilityRuntime.tracer()); + TracePropagation.extract(Map.of( + "traceparent", "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01")); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertTrue(ObservabilitySetup.isConfigured()); + assertTrue(GlobalOpenTelemetry.isSet()); + } } diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index d9ec7a3d..d6f77908 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -91,13 +91,28 @@ public A2ATServer(Path envPath, @Nullable TelemetryHook hook) { } private T observed(String method, Supplier action) { - try (TelemetryOperation operation = hook.startSdkOperation("server", method)) { + TelemetryOperation started; + try { + started = hook.startSdkOperation("server", method); + } catch (RuntimeException error) { + LOGGER.warn("a2at: startSdkOperation failed; telemetry disabled for this call", error); + started = TelemetryOperation.NO_OP; + } + try (TelemetryOperation operation = started) { try { T result = action.get(); - operation.markSuccess(); + try { + operation.markSuccess(); + } catch (RuntimeException callbackError) { + LOGGER.warn("a2at: telemetry hook callback failed", callbackError); + } return result; } catch (RuntimeException error) { - operation.markError(error); + try { + operation.markError(error); + } catch (RuntimeException callbackError) { + LOGGER.warn("a2at: telemetry hook callback failed", callbackError); + } throw error; } } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java index fd4a5bf7..58a20e16 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java @@ -1,5 +1,6 @@ package net.openan.a2at.sdk.server; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -64,6 +65,35 @@ public void reportNegotiationRounds(String negotiationId, String outcome, long t } } + static final class ThrowingHook implements TelemetryHook { + private final boolean throwOnStart; + + ThrowingHook(boolean throwOnStart) { + this.throwOnStart = throwOnStart; + } + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + if (throwOnStart) { + throw new IllegalStateException("startSdkOperation failed"); + } + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + + @Override public void markSuccess() {} + + @Override public void markError(Throwable error) { + throw new IllegalStateException("markError failed"); + } + + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) {} + } + @TempDir Path tempDir; @@ -110,9 +140,21 @@ void runtimeFailureMarksErrorAndPropagates() throws IOException { } @Test - void legacyConstructorUsesNoOpHook() throws IOException { + void throwingHookDoesNotBreakBusinessFlow() throws IOException { + A2ATServer startFailure = newServer(new ThrowingHook(true)); + assertDoesNotThrow(startFailure::getPrompts); + + A2ATServer markErrorFailure = newServer(new ThrowingHook(false)); + NullPointerException error = + assertThrows(NullPointerException.class, () -> markErrorFailure.getPrompt(null)); + assertEquals("templateUri", error.getMessage()); + } + + @Test + void legacyConstructorAndFacadeCallsComplete() throws IOException { A2ATServer server = new A2ATServer(writeEnv(true)); - assertEquals(server.getPrompts().size(), server.getPrompts().size()); + assertDoesNotThrow(server::getPrompts); + assertDoesNotThrow(server::getPrompts); } @Test From 125c165252938088863709c76b6f9e880936a0a1 Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 17:45:04 +0800 Subject: [PATCH 294/295] docs: record final-review deferred items as Plan B inputs --- docs/superpowers/plans/2026-10-20-observability-core.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/superpowers/plans/2026-10-20-observability-core.md b/docs/superpowers/plans/2026-10-20-observability-core.md index 9686f495..aca664ea 100644 --- a/docs/superpowers/plans/2026-10-20-observability-core.md +++ b/docs/superpowers/plans/2026-10-20-observability-core.md @@ -2885,6 +2885,14 @@ A2AT_LOGS_PAYLOAD_MAX_LENGTH=4096 a2a-java 协议层装饰器(`A2ATClientTransportDecorator`、`A2ATRequestHandlerDecorator`、`A2ATAgentExecutorDecorator`、`A2ATPushSenderDecorator`、`A2ATA2ATClientTransportWrapper`、类型化提取器 `A2AJavaAttributeExtractors`、`SdkOperationSpans` hook 实现)+ sample 可观测场景 + README。待 Plan A 落地后按其真实签名编写独立计划。 +### Plan B 必须吸收的终审遗留项(final review 2026-10-20) + +1. `ensureConfigured()` 仅读系统环境变量;`.env` 接线(`fromMap(DotEnvConfigSource.load(path))`)由 Plan B 的 README/sample 负责,并注意 `A2AT_SERVICE_NAME` 未列入 env.example 示例 +2. `PayloadSanitizer` redactor 失败时 fail-open(透传未脱敏值)→ 后续安全加固改 fail-closed(丢弃 payload) +3. `MetricHistograms` 静态缓存跨 `GlobalOpenTelemetry.resetForTest()` 存活 + 装配前缓存的直方图永久绑定 no-op meter → Plan B 设计 provider 感知缓存或在 `ObservabilitySetup.resetForTest` 中清缓存 +4. 终审已修:门面 hook 回调全防护(ae2c526);`getOrNoop()` 防 get() 副作用(ae2c526)——Plan B 的 `SdkOperationSpans` 必须沿用 `getOrNoop()` 模式且自身调用点全部 try/catch 防护 +5. 遗留 follow-up(不阻塞合并):console-exporter-disabled 分支跳过 `set()`、ISE 路径 provider 关闭、`replaceAll("/+$")` 多斜杠、`switchOn` 去重、`A2ATCurrentSpan.setAttribute` 类型化 + From ff742c2ce9ed509846f2116a4617c77450f2bdc7 Mon Sep 17 00:00:00 2001 From: z00930736 Date: Wed, 21 Oct 2026 18:01:31 +0800 Subject: [PATCH 295/295] docs: record TelemetryOperation.close guard as Plan B input --- docs/superpowers/plans/2026-10-20-observability-core.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-20-observability-core.md b/docs/superpowers/plans/2026-10-20-observability-core.md index aca664ea..c8999d8d 100644 --- a/docs/superpowers/plans/2026-10-20-observability-core.md +++ b/docs/superpowers/plans/2026-10-20-observability-core.md @@ -2890,7 +2890,7 @@ a2a-java 协议层装饰器(`A2ATClientTransportDecorator`、`A2ATRequestHandl 1. `ensureConfigured()` 仅读系统环境变量;`.env` 接线(`fromMap(DotEnvConfigSource.load(path))`)由 Plan B 的 README/sample 负责,并注意 `A2AT_SERVICE_NAME` 未列入 env.example 示例 2. `PayloadSanitizer` redactor 失败时 fail-open(透传未脱敏值)→ 后续安全加固改 fail-closed(丢弃 payload) 3. `MetricHistograms` 静态缓存跨 `GlobalOpenTelemetry.resetForTest()` 存活 + 装配前缓存的直方图永久绑定 no-op meter → Plan B 设计 provider 感知缓存或在 `ObservabilitySetup.resetForTest` 中清缓存 -4. 终审已修:门面 hook 回调全防护(ae2c526);`getOrNoop()` 防 get() 副作用(ae2c526)——Plan B 的 `SdkOperationSpans` 必须沿用 `getOrNoop()` 模式且自身调用点全部 try/catch 防护 +4. 终审已修:门面 hook 回调全防护(ae2c526);`getOrNoop()` 防 get() 副作用(ae2c526)——Plan B 的 `SdkOperationSpans` 必须沿用 `getOrNoop()` 模式且自身调用点全部 try/catch 防护;**`TelemetryOperation.close()` 是最后一个未防护 hook 点**(成功路径 try-with-resources 的 close 在 return 后触发,用户实现抛异常会把业务成功变失败)——Plan B 实现的 close() 必须自防护,或后续在门面 observed() 内补 guard 5. 遗留 follow-up(不阻塞合并):console-exporter-disabled 分支跳过 `set()`、ISE 路径 provider 关闭、`replaceAll("/+$")` 多斜杠、`switchOn` 去重、`A2ATCurrentSpan.setAttribute` 类型化